Merge pull request 'feat(payments): trusted platform signal on the session' (#216) from feature/trusted-platform-signal into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 21s
CI / ui (push) Successful in 1m7s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m44s

This commit was merged in pull request #216.
This commit is contained in:
2026-07-08 01:37:39 +00:00
39 changed files with 970 additions and 221 deletions
+57 -39
View File
@@ -27,7 +27,7 @@ tests, done-criteria, and current status — without re-deriving decisions.
| Stage | Title | Release | Status |
|-------|-------|---------|--------|
| E0 | Payments data foundation | 1 | DONE |
| E1 | Trusted platform signal | 1 | TODO |
| E1 | Trusted platform signal | 1 | DONE |
| E2 | Currency + benefit core | 1 | TODO |
| E3 | Wallet UI | 1 | TODO |
| E4 | Durability (PITR) | 2 | TODO |
@@ -210,56 +210,74 @@ role creation is idempotent (`DO $$` / `IF NOT EXISTS`) for fresh volumes.
## E1 — Trusted platform signal
**Status:** TODO · **Release 1** · depends on: none (parallel to E0) · mechanics: PAYMENTS §8.
**Status:** DONE · **Release 1** · depends on: none (parallel to E0) · mechanics: PAYMENTS §8.
**Goal.** Make the server know the execution platform from a trusted, unforgeable source,
carried on the session and re-confirmed each cold start. This is the foundation the gate
(E2) stands on; without it the gate is meaningless.
carried on the session. This is the foundation the gate (E2) stands on; without it the gate is
meaningless. Signal plumbing only — no user-visible change.
**Model.** `platform = {kind: vk|telegram|direct, subtype: ios|android|web}` becomes a
property of the **session**. On cold start the client submits the fresh wrapper signature
(VK launch-params `sign` / TG `initData`); the gateway (or backend session-resolve)
validates it and records/refreshes the session's platform. `direct` needs no signature — it
is implied by a web/native session's creation path.
**Model.** `platform = {kind: vk|telegram|direct, subtype: ios|android|web}` is a property of
the **session**, captured at creation. `kind` is always trusted — the gateway derives it from
the validated establish path (VK launch / TG initData / a web-native session), never a client
field. `subtype` is **cryptographically trusted only for VK** (it rides inside the signed
`vk_platform` launch param); for telegram and direct it is client-reported best-effort, and the
gate never relies on it (only the VK-iOS-frozen case is compliance-critical, and VK subtype is
trusted). VK/TG re-mint a session on **every cold start**, so their platform is re-captured each
launch; web/direct/email reuse the stored token, so their platform is captured once at creation
(`direct` needs no signature).
**Validation stays at the gateway.** Both wrapper verifiers already live there and the backend
cannot import either (separate modules under `internal/`, and the VK app secret is gateway-only):
VK launch params via the in-process `gateway/internal/vkauth.Verify` (HMAC-SHA256 over the signed
`vk_*` params under `GATEWAY_VK_APP_SECRET`, extended here to expose the signed `vk_platform`
a trusted `Subtype()`), TG `initData` via the validator RPC. The gateway hands the derived
platform to the backend at establish; the backend persists it and returns it on resolve.
**Backend.**
- Session store (`backend/internal/session/`): add `platform` (kind+subtype) to the session
row + `Session` struct; set it at creation and refresh it on a validated cold-start call.
- Session resolve (`handlers_auth.go handleResolveSession``resolveResponse` DTO
`dto.go`): return `platform` so the gateway can carry it.
- Validation: TG `initData` validator already exists (`platform/telegram/internal/initdata`)
— reuse. Add VK launch-params `sign` verification (HMAC over sorted params with the VK app
secret) — new small verifier, unit-tested against known VK vectors.
- Gateway (`gateway/internal/backendclient`): inject a trusted `X-Platform` header (mirror
`X-User-ID` injection in `client.go do()`), sourced from the resolved session — never from
the client request body.
- Backend middleware: read `X-Platform` into context alongside `X-User-ID`
(`middleware.go`); expose a typed accessor `platform(c)`.
- **Fail-closed default:** absent/invalid/stale platform ⇒ context = untrusted; the payments
interface treats untrusted as "view only" (enforced in E2's gate, but the signal plumbing
lands here).
- `backend.sessions` gains nullable `platform_kind` + `platform_subtype` columns (migration
`00011`, CHECK-constrained, jet regenerated). A `session.Platform` value type + `session.Create`
captures it; `Session` carries it through the store and the warm cache.
- `handleResolveSession``resolveResponse` (`dto.go`) returns the platform; the establish
handlers set `kind` from their own endpoint (`/sessions/telegram|vk|guest|email/*`) and
`subtype` from the request (VK: gateway-extracted from the signed params; TG/direct: the client
best-effort field, defaulting web). The account-merge session mint (`link.merge`) inherits the
caller's platform from the request context.
- Middleware (`middleware.go`) parses the gateway-injected `X-Platform` into the request context
on the `s.user` group; `platform(c)` exposes it. Absent ⇒ untrusted (fail-closed).
**Client (`ui/`).** On cold start, submit the wrapper signature to the session-establish/
refresh call: VK via `ui/src/lib/vk.ts` (launch params), TG via `ui/src/lib/telegram.ts`
(`initData`). `direct` submits nothing (web/native). Compose the platform from
`insideVK()` + `insideTelegram()` + `clientChannel()` + `vkPlatform()` (no single
discriminator exists today — see `ui/src/lib/channel.ts`, note VK reads as `web` there).
**Gateway.**
- `backendclient.WithPlatform(ctx, "<kind>/<subtype>")` + `do`/`getRaw` inject `X-Platform` on
every authenticated backend call; `connectsrv.Execute` enriches the request context from the
resolved session's platform (carried through the gateway session cache + `ResolveSession`).
Never from a client request body.
**Client (`ui/`).** `platformSubtype()` (`ui/src/lib/platform.ts`) supplies a best-effort device
subtype on the `auth.telegram` / `auth.guest` / `auth.email.login` requests (new FBS `subtype`
field): Telegram's `WebApp.platform` inside a Mini App, else the Capacitor/web channel. VK sends
nothing (the gateway derives the trusted subtype from the signed launch params).
**Tests.**
- unit (Go): VK sign verifier accepts valid / rejects tampered params; TG initData path
(existing) covered; platform kind+subtype derivation.
- integration: session carries platform; resolve returns it; stale/absent → untrusted.
- UI: cold start submits the right signature per wrapper (mock).
- unit (Go): `vkauth` exposes the signed `vk_platform` and maps iPhone/iPad → ios (the frozen
case); the `X-Platform` middleware round-trips + reports untrusted when absent; the backend
client injects `X-Platform` from the context and omits it when untrusted.
- integration: a session carries its platform through create + a cold-cache (DB) resolve for each
kind; an unattributed session is untrusted; the CHECK constraints bite; migration `00011`
applies forward **and backward** on a throwaway PG.
- UI: subtype normalization (vitest) + the new `subtype` field on the wire (codec unit test).
**Done-criteria.** A VK/TG session resolves to a trusted `{kind,subtype}`; a forged body
cannot change it; `direct` sessions resolve to `direct`; untrusted path is reachable and
observable. No user-visible change.
**Done-criteria (met).** A VK/TG session resolves to a trusted `{kind, subtype}`; a forged body
cannot change `kind` (nor the VK subtype); `direct` sessions resolve to `direct`; the untrusted
path is reachable and observable via `platform(c)`. No user-visible change; all layers green.
**Notes/risks.** VK iOS must surface as `subtype=ios` (drives the frozen state in E2/E3).
Old sessions predating this stage have no platform → untrusted → fail-closed (acceptable;
re-cold-start fixes it). Keep the VK app secret in config/secret store, not code.
**Notes/risks.** High blast-radius (auth/session table + broad gateway header threading): additive
migration, no mixed-in refactors, `X-Platform` inert until E2 consumes it. **Sessions minted before
E1 carry no platform → untrusted (view-only) until re-login**; VK/TG self-heal on the next
cold-start re-mint, direct/email do not (accepted: Release 1 has no money, sessions cycle by
Release 2). TG/direct subtype is not cryptographically trusted — E2 must keep the gate on `kind`
+ the trusted VK subtype only.
---
@@ -0,0 +1,178 @@
//go:build integration
package inttest
import (
"context"
"database/sql"
"testing"
"github.com/google/uuid"
"github.com/pressly/goose/v3"
testcontainers "github.com/testcontainers/testcontainers-go"
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
"scrabble/backend/internal/account"
"scrabble/backend/internal/postgres"
"scrabble/backend/internal/postgres/migrations"
"scrabble/backend/internal/session"
)
// TestSessionPlatformCaptureAndUntrusted proves a session round-trips its captured
// platform through create and a cold-cache (DB-backed) resolve for each kind, and
// that an unattributed session records no platform — the untrusted, view-only case.
func TestSessionPlatformCaptureAndUntrusted(t *testing.T) {
ctx := context.Background()
accounts := account.NewStore(testDB)
store := session.NewStore(testDB)
svc := session.NewService(store, session.NewCache())
for _, tc := range []struct {
name string
platform session.Platform
}{
{"vk-ios", session.Platform{Kind: session.PlatformKindVK, Subtype: session.SubtypeIOS}},
{"telegram-android", session.Platform{Kind: session.PlatformKindTelegram, Subtype: session.SubtypeAndroid}},
{"direct-web", session.Platform{Kind: session.PlatformKindDirect, Subtype: session.SubtypeWeb}},
{"untrusted", session.Platform{}},
} {
t.Run(tc.name, func(t *testing.T) {
acc, err := accounts.ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
if err != nil {
t.Fatalf("provision: %v", err)
}
token, sess, err := svc.Create(ctx, acc.ID, tc.platform)
if err != nil {
t.Fatalf("create: %v", err)
}
if sess.Platform != tc.platform {
t.Errorf("created platform = %+v, want %+v", sess.Platform, tc.platform)
}
// Resolve through a cold cache so the value comes back via the DB columns.
cold := session.NewService(store, session.NewCache())
if err := cold.Warm(ctx); err != nil {
t.Fatalf("warm: %v", err)
}
got, err := cold.Resolve(ctx, token)
if err != nil {
t.Fatalf("resolve: %v", err)
}
if got.Platform != tc.platform {
t.Errorf("resolved platform = %+v, want %+v", got.Platform, tc.platform)
}
if got.Platform.Trusted() != tc.platform.Trusted() {
t.Errorf("resolved Trusted() = %v, want %v", got.Platform.Trusted(), tc.platform.Trusted())
}
})
}
}
// TestSessionPlatformCheckConstraints proves the CHECK constraints reject an
// out-of-range kind or subtype, so a corrupt value cannot be persisted.
func TestSessionPlatformCheckConstraints(t *testing.T) {
ctx := context.Background()
acc, err := account.NewStore(testDB).ProvisionByIdentity(ctx, account.KindTelegram, "tg-"+uuid.NewString())
if err != nil {
t.Fatalf("provision: %v", err)
}
const pgCheckViolation = "23514"
for _, tc := range []struct {
name string
kind string
subtype string
}{
{"bad-kind", "facebook", "web"},
{"bad-subtype", "vk", "windows"},
} {
t.Run(tc.name, func(t *testing.T) {
_, err := testDB.ExecContext(ctx,
`INSERT INTO backend.sessions (session_id, account_id, token_hash, platform_kind, platform_subtype)
VALUES ($1, $2, $3, $4, $5)`,
uuid.New(), acc.ID, uuid.NewString(), tc.kind, tc.subtype)
if !isPgCode(err, pgCheckViolation) {
t.Errorf("insert %s: err = %v, want check_violation", tc.name, err)
}
})
}
}
// TestSessionPlatformMigrationReversible proves migration 00011 is expand-contract:
// it applies, rolls back (dropping the platform columns), and re-applies cleanly —
// so a backend image rollback stays DB-safe. It uses its own container so the Down
// does not disturb the shared suite database.
func TestSessionPlatformMigrationReversible(t *testing.T) {
ctx := context.Background()
container, err := tcpostgres.Run(ctx, pgImage,
tcpostgres.WithDatabase(pgDatabase),
tcpostgres.WithUsername(pgUser),
tcpostgres.WithPassword(pgPassword),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).
WithStartupTimeout(containerStartup),
),
)
if err != nil {
t.Fatalf("start container: %v", err)
}
defer func() { _ = container.Terminate(context.Background()) }()
baseDSN, err := container.ConnectionString(ctx, "sslmode=disable")
if err != nil {
t.Fatalf("connection string: %v", err)
}
dsn, err := withSearchPath(baseDSN, pgSchema)
if err != nil {
t.Fatalf("search path: %v", err)
}
cfg := postgres.DefaultConfig()
cfg.DSN = dsn
db, err := postgres.Open(ctx, cfg)
if err != nil {
t.Fatalf("open pool: %v", err)
}
defer func() { _ = db.Close() }()
if err := postgres.ApplyMigrations(ctx, db); err != nil {
t.Fatalf("apply up: %v", err)
}
if !sessionsPlatformColumnsExist(ctx, t, db) {
t.Fatal("platform columns absent after up")
}
goose.SetBaseFS(migrations.Migrations())
defer goose.SetBaseFS(nil)
if err := goose.SetDialect("postgres"); err != nil {
t.Fatalf("set dialect: %v", err)
}
// Down past 00011 (to 00010) and assert the columns are gone.
if err := goose.DownToContext(ctx, db, ".", 10); err != nil {
t.Fatalf("down to 10: %v", err)
}
if sessionsPlatformColumnsExist(ctx, t, db) {
t.Error("platform columns survived the down migration")
}
// Re-apply and assert they return.
if err := goose.UpContext(ctx, db, "."); err != nil {
t.Fatalf("re-apply up: %v", err)
}
if !sessionsPlatformColumnsExist(ctx, t, db) {
t.Fatal("platform columns absent after re-apply")
}
}
// sessionsPlatformColumnsExist reports whether both platform columns are present on
// backend.sessions.
func sessionsPlatformColumnsExist(ctx context.Context, t *testing.T, db *sql.DB) bool {
t.Helper()
var n int
if err := db.QueryRowContext(ctx,
`SELECT count(*) FROM information_schema.columns
WHERE table_schema = 'backend' AND table_name = 'sessions'
AND column_name IN ('platform_kind', 'platform_subtype')`).Scan(&n); err != nil {
t.Fatalf("count platform columns: %v", err)
}
return n == 2
}
+7 -3
View File
@@ -26,7 +26,8 @@ func TestSessionLifecycle(t *testing.T) {
store := session.NewStore(testDB)
svc := session.NewService(store, session.NewCache())
token, sess, err := svc.Create(ctx, acc.ID)
platform := session.Platform{Kind: session.PlatformKindVK, Subtype: session.SubtypeIOS}
token, sess, err := svc.Create(ctx, acc.ID, platform)
if err != nil {
t.Fatalf("create session: %v", err)
}
@@ -36,6 +37,9 @@ func TestSessionLifecycle(t *testing.T) {
if token == sess.TokenHash {
t.Error("plaintext token must not equal the stored hash")
}
if sess.Platform != platform {
t.Errorf("session platform = %+v, want %+v", sess.Platform, platform)
}
// Resolve via the warm write-through cache.
got, err := svc.Resolve(ctx, token)
@@ -63,8 +67,8 @@ func TestSessionLifecycle(t *testing.T) {
if _, ok := cold.Get(session.HashToken(token)); !ok {
t.Error("Warm must load the active session into the cache")
}
if got2, err := svc2.Resolve(ctx, token); err != nil || got2.ID != sess.ID {
t.Errorf("resolve after warm = (%s, %v), want %s", got2.ID, err, sess.ID)
if got2, err := svc2.Resolve(ctx, token); err != nil || got2.ID != sess.ID || got2.Platform != platform {
t.Errorf("resolve after warm = (%s, %+v, %v), want %s / %+v", got2.ID, got2.Platform, err, sess.ID, platform)
}
// Revoke, then the token no longer resolves; revoke again is a no-op.
+5 -1
View File
@@ -200,7 +200,11 @@ func (s *Service) merge(ctx context.Context, callerID, otherID uuid.UUID) (Merge
}
res := MergeResult{PrimaryID: primary}
if primary != callerID {
token, _, err := s.sessions.Create(ctx, primary)
// The switched-to session inherits the caller's current trusted platform
// (the context the merge was initiated from); absent when the caller's
// session itself is untrusted.
platform, _ := session.PlatformFromContext(ctx)
token, _, err := s.sessions.Create(ctx, primary, platform)
if err != nil {
return MergeResult{}, err
}
@@ -13,11 +13,13 @@ import (
)
type Sessions struct {
SessionID uuid.UUID `sql:"primary_key"`
AccountID uuid.UUID
TokenHash string
Status string
CreatedAt time.Time
LastSeenAt *time.Time
RevokedAt *time.Time
SessionID uuid.UUID `sql:"primary_key"`
AccountID uuid.UUID
TokenHash string
Status string
CreatedAt time.Time
LastSeenAt *time.Time
RevokedAt *time.Time
PlatformKind *string
PlatformSubtype *string
}
@@ -17,13 +17,15 @@ type sessionsTable struct {
postgres.Table
// Columns
SessionID postgres.ColumnString
AccountID postgres.ColumnString
TokenHash postgres.ColumnString
Status postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
LastSeenAt postgres.ColumnTimestampz
RevokedAt postgres.ColumnTimestampz
SessionID postgres.ColumnString
AccountID postgres.ColumnString
TokenHash postgres.ColumnString
Status postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
LastSeenAt postgres.ColumnTimestampz
RevokedAt postgres.ColumnTimestampz
PlatformKind postgres.ColumnString
PlatformSubtype postgres.ColumnString
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
@@ -65,29 +67,33 @@ func newSessionsTable(schemaName, tableName, alias string) *SessionsTable {
func newSessionsTableImpl(schemaName, tableName, alias string) sessionsTable {
var (
SessionIDColumn = postgres.StringColumn("session_id")
AccountIDColumn = postgres.StringColumn("account_id")
TokenHashColumn = postgres.StringColumn("token_hash")
StatusColumn = postgres.StringColumn("status")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
LastSeenAtColumn = postgres.TimestampzColumn("last_seen_at")
RevokedAtColumn = postgres.TimestampzColumn("revoked_at")
allColumns = postgres.ColumnList{SessionIDColumn, AccountIDColumn, TokenHashColumn, StatusColumn, CreatedAtColumn, LastSeenAtColumn, RevokedAtColumn}
mutableColumns = postgres.ColumnList{AccountIDColumn, TokenHashColumn, StatusColumn, CreatedAtColumn, LastSeenAtColumn, RevokedAtColumn}
defaultColumns = postgres.ColumnList{StatusColumn, CreatedAtColumn}
SessionIDColumn = postgres.StringColumn("session_id")
AccountIDColumn = postgres.StringColumn("account_id")
TokenHashColumn = postgres.StringColumn("token_hash")
StatusColumn = postgres.StringColumn("status")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
LastSeenAtColumn = postgres.TimestampzColumn("last_seen_at")
RevokedAtColumn = postgres.TimestampzColumn("revoked_at")
PlatformKindColumn = postgres.StringColumn("platform_kind")
PlatformSubtypeColumn = postgres.StringColumn("platform_subtype")
allColumns = postgres.ColumnList{SessionIDColumn, AccountIDColumn, TokenHashColumn, StatusColumn, CreatedAtColumn, LastSeenAtColumn, RevokedAtColumn, PlatformKindColumn, PlatformSubtypeColumn}
mutableColumns = postgres.ColumnList{AccountIDColumn, TokenHashColumn, StatusColumn, CreatedAtColumn, LastSeenAtColumn, RevokedAtColumn, PlatformKindColumn, PlatformSubtypeColumn}
defaultColumns = postgres.ColumnList{StatusColumn, CreatedAtColumn}
)
return sessionsTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
SessionID: SessionIDColumn,
AccountID: AccountIDColumn,
TokenHash: TokenHashColumn,
Status: StatusColumn,
CreatedAt: CreatedAtColumn,
LastSeenAt: LastSeenAtColumn,
RevokedAt: RevokedAtColumn,
SessionID: SessionIDColumn,
AccountID: AccountIDColumn,
TokenHash: TokenHashColumn,
Status: StatusColumn,
CreatedAt: CreatedAtColumn,
LastSeenAt: LastSeenAtColumn,
RevokedAt: RevokedAtColumn,
PlatformKind: PlatformKindColumn,
PlatformSubtype: PlatformSubtypeColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
@@ -0,0 +1,23 @@
-- Record the trusted execution platform on a session: platform_kind (the wrapper the
-- session was established through — vk/telegram/direct) plus platform_subtype (the device
-- family — ios/android/web). Both are nullable: a session minted before this migration, or
-- one the gateway could not attribute, carries NULL and is treated as untrusted (view-only)
-- by the payments compliance gate. kind is derived server-side from the validated establish
-- path (never a client field); the subtype is trustworthy only for VK (vk_platform rides
-- inside the signed launch params) and best-effort for telegram/direct.
--
-- Expand-contract: the columns are additive and nullable, so a backend image rollback stays
-- DB-safe (older code neither writes nor reads them). The table shape changes, so the
-- generated go-jet model IS regenerated.
-- +goose Up
ALTER TABLE backend.sessions ADD COLUMN platform_kind text;
ALTER TABLE backend.sessions ADD COLUMN platform_subtype text;
ALTER TABLE backend.sessions ADD CONSTRAINT sessions_platform_kind_chk CHECK ((platform_kind IS NULL) OR (platform_kind = ANY (ARRAY['vk'::text, 'telegram'::text, 'direct'::text])));
ALTER TABLE backend.sessions ADD CONSTRAINT sessions_platform_subtype_chk CHECK ((platform_subtype IS NULL) OR (platform_subtype = ANY (ARRAY['ios'::text, 'android'::text, 'web'::text])));
-- +goose Down
ALTER TABLE backend.sessions DROP CONSTRAINT sessions_platform_subtype_chk;
ALTER TABLE backend.sessions DROP CONSTRAINT sessions_platform_kind_chk;
ALTER TABLE backend.sessions DROP COLUMN platform_subtype;
ALTER TABLE backend.sessions DROP COLUMN platform_kind;
+7 -3
View File
@@ -28,10 +28,14 @@ type okResponse struct {
}
// resolveResponse maps a session token to its account. IsGuest lets the gateway
// gate guest-forbidden operations without an extra round-trip.
// gate guest-forbidden operations without an extra round-trip. PlatformKind and
// PlatformSubtype carry the session's trusted execution platform so the gateway can
// inject X-Platform; both are empty for an untrusted (pre-capture) session.
type resolveResponse struct {
UserID string `json:"user_id"`
IsGuest bool `json:"is_guest"`
UserID string `json:"user_id"`
IsGuest bool `json:"is_guest"`
PlatformKind string `json:"platform_kind"`
PlatformSubtype string `json:"platform_subtype"`
}
// profileResponse is the authenticated account's own profile. AwayStart and AwayEnd
+32 -14
View File
@@ -10,6 +10,7 @@ import (
"scrabble/backend/internal/account"
"scrabble/backend/internal/notify"
"scrabble/backend/internal/session"
)
// The /api/v1/internal/sessions/* endpoints are gateway-only: the gateway has
@@ -23,7 +24,9 @@ import (
// brand-new account's display name and language; BrowserTZ (the client's detected
// "±HH:MM" UTC offset) seeds its time zone; StartParam is the validated launch
// deep-link payload, which may seed the new account's variant preferences (first
// contact only).
// contact only). Subtype is the client-reported device family (ios/android/web);
// Telegram's initData does not sign it, so it is recorded best-effort and the
// payments gate never relies on it.
type telegramAuthRequest struct {
ExternalID string `json:"external_id"`
Username string `json:"username"`
@@ -31,6 +34,7 @@ type telegramAuthRequest struct {
LanguageCode string `json:"language_code"`
BrowserTZ string `json:"browser_tz"`
StartParam string `json:"start_param"`
Subtype string `json:"subtype"`
}
// handleTelegramAuth provisions (or finds) the account bound to a Telegram
@@ -63,19 +67,22 @@ func (s *Server) handleTelegramAuth(c *gin.Context) {
}
}
}
s.mintSession(c, acc)
s.mintSession(c, acc, session.Platform{Kind: session.PlatformKindTelegram, Subtype: session.NormalizeSubtype(req.Subtype)})
}
// 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.
// offset) seeds its time zone. All seeds apply on first contact only. Subtype is the
// device family the gateway derived from the signed vk_platform param — trusted,
// since it rides inside the verified launch signature.
type vkAuthRequest struct {
ExternalID string `json:"external_id"`
LanguageCode string `json:"language_code"`
DisplayName string `json:"display_name"`
BrowserTZ string `json:"browser_tz"`
Subtype string `json:"subtype"`
}
// handleVKAuth provisions (or finds) the account bound to a VK identity and mints a
@@ -94,7 +101,7 @@ func (s *Server) handleVKAuth(c *gin.Context) {
s.abortErr(c, err)
return
}
s.mintSession(c, acc)
s.mintSession(c, acc, session.Platform{Kind: session.PlatformKindVK, Subtype: session.NormalizeSubtype(req.Subtype)})
}
// pushTargetRequest asks for a user's out-of-app push routing data by account id.
@@ -150,6 +157,9 @@ func (s *Server) handlePushTarget(c *gin.Context) {
// time zone, so robot timing is anchored to the player's zone from the first game.
type guestAuthRequest struct {
BrowserTZ string `json:"browser_tz"`
// Subtype is the client-reported device family (ios/android/web) of this direct
// (web/native) session; there is no external signer, so it is recorded best-effort.
Subtype string `json:"subtype"`
}
// handleGuestAuth provisions a fresh ephemeral guest account and mints a session,
@@ -164,7 +174,7 @@ func (s *Server) handleGuestAuth(c *gin.Context) {
s.abortErr(c, err)
return
}
s.mintSession(c, acc)
s.mintSession(c, acc, session.Platform{Kind: session.PlatformKindDirect, Subtype: session.NormalizeSubtype(req.Subtype)})
}
// emailRequest is an email-login code request. BrowserTZ (the client's detected
@@ -196,10 +206,12 @@ func (s *Server) handleEmailRequest(c *gin.Context) {
c.JSON(http.StatusOK, okResponse{OK: true})
}
// emailLoginRequest verifies an email login code.
// emailLoginRequest verifies an email login code. Subtype is the client-reported
// device family (ios/android/web) of this direct session; recorded best-effort.
type emailLoginRequest struct {
Email string `json:"email"`
Code string `json:"code"`
Email string `json:"email"`
Code string `json:"code"`
Subtype string `json:"subtype"`
}
// handleEmailLogin verifies the code and mints a session for the owning account.
@@ -214,7 +226,7 @@ func (s *Server) handleEmailLogin(c *gin.Context) {
s.abortErr(c, err)
return
}
s.mintSession(c, acc)
s.mintSession(c, acc, session.Platform{Kind: session.PlatformKindDirect, Subtype: session.NormalizeSubtype(req.Subtype)})
}
// confirmLinkResponse is the outcome of a one-tap deeplink confirmation. For a login,
@@ -248,7 +260,8 @@ func (s *Server) handleEmailConfirmLink(c *gin.Context) {
s.abortErr(c, err)
return
}
token, _, err := s.sessions.Create(c.Request.Context(), acc.ID)
// A magic-link login always opens in a browser, so its session is direct/web.
token, _, err := s.sessions.Create(c.Request.Context(), acc.ID, session.Platform{Kind: session.PlatformKindDirect, Subtype: session.SubtypeWeb})
if err != nil {
s.abortErr(c, err)
return
@@ -288,7 +301,11 @@ func (s *Server) handleResolveSession(c *gin.Context) {
// is_guest is best-effort: a transient account read must not fail an otherwise
// valid resolve (the auth hot path), so a read error falls back to false; the
// per-operation backend gate remains the authoritative guest check.
resp := resolveResponse{UserID: sess.AccountID.String()}
resp := resolveResponse{
UserID: sess.AccountID.String(),
PlatformKind: sess.Platform.Kind,
PlatformSubtype: sess.Platform.Subtype,
}
if acc, err := s.accounts.GetByID(c.Request.Context(), sess.AccountID); err == nil {
resp.IsGuest = acc.IsGuest
}
@@ -309,9 +326,10 @@ func (s *Server) handleRevokeSession(c *gin.Context) {
c.JSON(http.StatusOK, okResponse{OK: true})
}
// mintSession creates a session for acc and writes the credential response.
func (s *Server) mintSession(c *gin.Context, acc account.Account) {
token, _, err := s.sessions.Create(c.Request.Context(), acc.ID)
// mintSession creates a session for acc carrying the captured platform and writes
// the credential response.
func (s *Server) mintSession(c *gin.Context, acc account.Account, platform session.Platform) {
token, _, err := s.sessions.Create(c.Request.Context(), acc.ID, platform)
if err != nil {
s.abortErr(c, err)
return
+43
View File
@@ -4,9 +4,12 @@ import (
"context"
"net/http"
"net/url"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"scrabble/backend/internal/session"
)
// headerUserID is the identity header the gateway injects after resolving a
@@ -41,6 +44,46 @@ func UserIDFromContext(ctx context.Context) (uuid.UUID, bool) {
return id, ok
}
// headerPlatform is the trusted execution-platform header the gateway injects after
// resolving a session's platform. Its value is "<kind>/<subtype>" (e.g. "vk/ios");
// it is omitted for an untrusted session, in which case platform(c) reports the
// zero Platform. Like X-User-ID, the value is the gateway's — never a client body.
const headerPlatform = "X-Platform"
// platformContext returns middleware that parses the gateway-injected X-Platform
// header into the request context, so handlers (and the link/merge session mint)
// read the caller's trusted platform. An absent or malformed header leaves the
// context without a platform (untrusted) and never rejects the request — X-Platform
// is a capability signal, not an identity gate.
func platformContext() gin.HandlerFunc {
return func(c *gin.Context) {
if p, ok := parsePlatformHeader(c.GetHeader(headerPlatform)); ok {
c.Request = c.Request.WithContext(session.WithPlatform(c.Request.Context(), p))
}
c.Next()
}
}
// parsePlatformHeader splits a "<kind>/<subtype>" X-Platform value into a Platform.
// A blank value or blank kind yields no platform (an untrusted session).
func parsePlatformHeader(h string) (session.Platform, bool) {
if h == "" {
return session.Platform{}, false
}
kind, subtype, _ := strings.Cut(h, "/")
if kind == "" {
return session.Platform{}, false
}
return session.Platform{Kind: kind, Subtype: subtype}, true
}
// platform returns the caller's trusted execution platform, or the zero Platform
// and false when the session was not attributed to one — an untrusted, view-only
// context for the payments gate.
func platform(c *gin.Context) (session.Platform, bool) {
return session.PlatformFromContext(c.Request.Context())
}
// requireSameOrigin guards the admin console's state-changing requests: it rejects
// a non-safe request whose Origin (or, failing that, Referer) host does not match
// the request Host. The gateway authenticates the operator with Basic-Auth in front
@@ -7,6 +7,8 @@ import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"scrabble/backend/internal/session"
)
// TestRequireUserID checks that the middleware accepts a valid X-User-ID,
@@ -58,3 +60,51 @@ func TestRequireUserID(t *testing.T) {
}
})
}
// TestPlatformContext checks that the middleware parses the gateway-injected
// X-Platform header into a trusted platform reachable via platform(c), treats an
// absent or blank-kind header as untrusted, and never rejects the request.
func TestPlatformContext(t *testing.T) {
gin.SetMode(gin.TestMode)
var seen session.Platform
var ok bool
r := gin.New()
r.Use(platformContext())
r.GET("/x", func(c *gin.Context) {
seen, ok = platform(c)
c.String(http.StatusOK, "ok")
})
for _, tc := range []struct {
name string
header string
setHeader bool
wantOK bool
want session.Platform
}{
{"vk-ios", "vk/ios", true, true, session.Platform{Kind: session.PlatformKindVK, Subtype: session.SubtypeIOS}},
{"telegram-no-subtype", "telegram", true, true, session.Platform{Kind: session.PlatformKindTelegram}},
{"direct-web", "direct/web", true, true, session.Platform{Kind: session.PlatformKindDirect, Subtype: session.SubtypeWeb}},
{"absent", "", false, false, session.Platform{}},
{"empty", "", true, false, session.Platform{}},
{"blank-kind", "/ios", true, false, session.Platform{}},
} {
t.Run(tc.name, func(t *testing.T) {
seen, ok = session.Platform{}, false
req := httptest.NewRequest(http.MethodGet, "/x", nil)
if tc.setHeader {
req.Header.Set("X-Platform", tc.header)
}
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (X-Platform never rejects)", rec.Code)
}
if ok != tc.wantOK || seen != tc.want {
t.Fatalf("platform = %+v (ok=%v), want %+v (ok=%v)", seen, ok, tc.want, tc.wantOK)
}
})
}
}
+4
View File
@@ -238,6 +238,10 @@ func (s *Server) registerAPIGroups(engine *gin.Engine) {
s.public = v1.Group("/public")
s.user = v1.Group("/user")
s.user.Use(RequireUserID())
// Capture the gateway-injected trusted platform (X-Platform) into the request context,
// so the payments gate can read it via platform(c). Optional: an untrusted session simply
// carries no platform and is treated as view-only. Never rejects.
s.user.Use(platformContext())
// The suspension gate runs after identity is established: a blocked account is refused on
// every user route (except the block-status probe) so the UI can show the blocked screen.
s.user.Use(s.requireNotSuspended())
+65
View File
@@ -0,0 +1,65 @@
package session
import "context"
// Platform is the trusted execution context recorded on a session: the wrapper
// Kind the session was established through and the device Subtype. An empty Kind
// marks an untrusted session — one minted before platform capture, or one the
// gateway could not attribute — which the payments compliance gate treats as
// view-only.
//
// Kind is always trustworthy: it is derived server-side from the validated
// establish path, never from a client-supplied field. Subtype is trustworthy only
// for VK (vk_platform rides inside the signed launch params); for telegram and
// direct it is client-reported and best-effort, so the gate must never rely on it.
type Platform struct {
Kind string
Subtype string
}
// Platform kinds recorded in platform_kind.
const (
PlatformKindVK = "vk"
PlatformKindTelegram = "telegram"
PlatformKindDirect = "direct"
)
// Platform subtypes recorded in platform_subtype.
const (
SubtypeIOS = "ios"
SubtypeAndroid = "android"
SubtypeWeb = "web"
)
// Trusted reports whether the session was attributed to a platform (a non-empty
// Kind). The zero Platform is untrusted.
func (p Platform) Trusted() bool { return p.Kind != "" }
// NormalizeSubtype coerces subtype to one of the known device families, defaulting
// an empty or unrecognised value to web. A newly established session always carries
// at least a web subtype, so a trusted session is never left without one.
func NormalizeSubtype(subtype string) string {
switch subtype {
case SubtypeIOS, SubtypeAndroid, SubtypeWeb:
return subtype
default:
return SubtypeWeb
}
}
// platformCtxKey types the request-context slot the trusted platform rides in.
type platformCtxKey struct{}
// WithPlatform returns a copy of ctx carrying platform. The backend middleware
// calls it after parsing the gateway-injected X-Platform header, so downstream
// handlers and the link/merge session mint read the caller's platform from ctx.
func WithPlatform(ctx context.Context, platform Platform) context.Context {
return context.WithValue(ctx, platformCtxKey{}, platform)
}
// PlatformFromContext returns the platform stored by WithPlatform and whether one
// was present. A missing value yields the zero (untrusted) Platform.
func PlatformFromContext(ctx context.Context) (Platform, bool) {
p, ok := ctx.Value(platformCtxKey{}).(Platform)
return p, ok
}
+6 -4
View File
@@ -29,14 +29,16 @@ func (svc *Service) Ready() bool {
return svc.cache.Ready()
}
// Create mints a new active session for accountID and returns the plaintext
// token (shown to the caller once) together with the persisted session.
func (svc *Service) Create(ctx context.Context, accountID uuid.UUID) (string, Session, error) {
// Create mints a new active session for accountID carrying the captured platform
// and returns the plaintext token (shown to the caller once) together with the
// persisted session. Pass the zero Platform for a session that could not be
// attributed to a trusted platform.
func (svc *Service) Create(ctx context.Context, accountID uuid.UUID, platform Platform) (string, Session, error) {
token, tokenHash, err := GenerateToken()
if err != nil {
return "", Session{}, err
}
sess, err := svc.store.Insert(ctx, accountID, tokenHash)
sess, err := svc.store.Insert(ctx, accountID, tokenHash, platform)
if err != nil {
return "", Session{}, err
}
+25 -5
View File
@@ -25,7 +25,8 @@ const (
var ErrNotFound = errors.New("session: not found")
// Session mirrors a row in backend.sessions. TokenHash is the hex-encoded
// SHA-256 of the bearer token.
// SHA-256 of the bearer token. Platform is the trusted execution context captured
// at creation; its zero value marks an untrusted session (see Platform).
type Session struct {
ID uuid.UUID
AccountID uuid.UUID
@@ -34,6 +35,7 @@ type Session struct {
CreatedAt time.Time
LastSeenAt *time.Time
RevokedAt *time.Time
Platform Platform
}
// Store is the Postgres-backed query surface for backend.sessions.
@@ -46,9 +48,10 @@ func NewStore(db *sql.DB) *Store {
return &Store{db: db}
}
// Insert persists a new active session for accountID carrying tokenHash and
// returns the persisted row.
func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, tokenHash string) (Session, error) {
// Insert persists a new active session for accountID carrying tokenHash and the
// captured platform, and returns the persisted row. An empty platform Kind/Subtype
// is written as SQL NULL, so an untrusted session records no platform.
func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, tokenHash string, platform Platform) (Session, error) {
id, err := uuid.NewV7()
if err != nil {
return Session{}, fmt.Errorf("session: new id: %w", err)
@@ -57,7 +60,9 @@ func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, tokenHash strin
table.Sessions.SessionID,
table.Sessions.AccountID,
table.Sessions.TokenHash,
).VALUES(id, accountID, tokenHash).RETURNING(table.Sessions.AllColumns)
table.Sessions.PlatformKind,
table.Sessions.PlatformSubtype,
).VALUES(id, accountID, tokenHash, stringOrNull(platform.Kind), stringOrNull(platform.Subtype)).RETURNING(table.Sessions.AllColumns)
var row model.Sessions
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
@@ -173,5 +178,20 @@ func modelToSession(row model.Sessions) Session {
t := *row.RevokedAt
s.RevokedAt = &t
}
if row.PlatformKind != nil {
s.Platform.Kind = *row.PlatformKind
}
if row.PlatformSubtype != nil {
s.Platform.Subtype = *row.PlatformSubtype
}
return s
}
// stringOrNull maps an empty string to a SQL NULL literal and any other value to a
// string literal, so an untrusted session's absent platform is stored as NULL.
func stringOrNull(s string) postgres.Expression {
if s == "" {
return postgres.NULL
}
return postgres.String(s)
}
+18 -3
View File
@@ -130,8 +130,8 @@ dropped). Horizontal scaling is explicit future work.
solver version, so it cannot drift from the running backend. The **move journal, history
and GCG are unaffected** (they stay decoded concrete characters, §9.1).
- **gateway ↔ backend (sync)**: plain HTTP REST/JSON. The gateway injects
`X-User-ID` for authenticated requests; `backend` never re-derives identity
from the body. Because every sync call targets the one backend host, the
`X-User-ID` (and the session's trusted `X-Platform`, §3) for authenticated
requests; `backend` never re-derives identity or platform from the body. Because every sync call targets the one backend host, the
gateway's REST client widens its keep-alive pool well past the stdlib default
of 2 idle connections per host; otherwise the per-request connection churn
exhausts ephemeral ports and burns gateway CPU under load (see
@@ -199,6 +199,21 @@ arrive from a platform rather than completing a mandatory registration).
until explicitly revoked (`status``revoked`). A revoke can target one token or,
on an account merge (§4), **every** session of the retired account
(`RevokeAllForAccount`, which also evicts them from the warm cache).
- **Trusted execution platform.** Each session also records a `platform` — a `kind`
(`vk`/`telegram`/`direct`) plus a device `subtype` (`ios`/`android`/`web`) — captured
at creation, so a store-compliance gate has an unforgeable execution context
(`docs/PAYMENTS.md` §8). `kind` is derived server-side from the validated establish
path (the VK launch, the Telegram initData, or a web-native session), **never a client
field**; the `subtype` is trusted only for VK (it rides inside the signed `vk_platform`
parameter) and is client-reported best-effort for telegram/direct. VK and Telegram
re-mint (and so re-validate and re-capture) on every cold start; a direct session
captures it once. The gateway injects the resolved platform as **`X-Platform`**
(`<kind>/<subtype>`) alongside `X-User-ID`, sourced from the session and never the
request body; an absent or unrecorded platform — a session predating the feature, or
one the gateway could not attribute — is **untrusted**, treated as view-only (no
spends). Consistent with the revoke-only model, platform freshness carries no separate
TTL; a stale untrusted session recovers on its next VK/TG cold-start re-mint, or (for a
reused direct/email session) on re-login.
- **Guest** = ephemeral web session (no platform, no email). A guest is backed by
a durable `accounts` row flagged `is_guest` and carrying **no identity** — the
row is a technical necessity (the `sessions` and `game_players` foreign keys
@@ -1150,7 +1165,7 @@ link — misses the event; while an add-email confirmation is pending the client
| Public rate limiting / anti-abuse | gateway (per-IP public/email/admin classes, per-user authenticated class; a request body cap of `GATEWAY_MAX_BODY_BYTES`; rejections are metered, summarised to the backend and surfaced in the admin console with a conservative reversible auto-flag — §11). In prod a **temporary IP ban** (`GATEWAY_ABUSE_BAN_ENABLED`) blocks an IP that sustains rejections or trips a **honeypot** decoy path / **honeytoken**, refused with 429 before any work; operators lift bans from the console. Off in the shared-NAT test contour, where the client IP is not real (§11) |
| Telegram initData validation (bot-token HMAC) | the Telegram **validator**; the gateway delegates it over gRPC, so the bot token (the HMAC secret) lives only in the validator and the bot, never in the gateway. The validator also **rejects a bot principal** (the signed `is_bot` flag) before any account is provisioned |
| Session minting; email-code / guest validation | gateway (with backend) |
| Session → `user_id` resolution, `X-User-ID` injection | gateway |
| Session → `user_id` + trusted platform resolution, `X-User-ID` / `X-Platform` injection | gateway (platform sourced from the session, never the request body — §3) |
| Authorisation, ownership, state transitions | backend (`X-User-ID` is the sole identity input) |
| Manual account block (suspension) | backend: a per-request gate refuses a blocked account on every `/api/v1/user/*` route except the block-status probe with **403 `account_blocked`**; the operator blocks/unblocks from the admin console (§11) |
| User feedback gate | backend rejects a guest or a `feedback_banned` account from submitting; the **gateway** also rejects a guest's `feedback.submit` (the `Op.NonGuest` flag + `is_guest` from session resolve) with **`guest_forbidden`** before any backend call; attachments are served `nosniff` with a download disposition for non-images (§15) |
+16 -13
View File
@@ -180,20 +180,23 @@ an archive, so history/receipts/tax are independent of later catalog edits.
The gate (§4) needs a **trusted, unforgeable** platform context on the server. The client is
never the source of truth.
- The platform is a **property of the session**, re-confirmed by a fresh signature on **every
cold start** — VK launch-params `sign` / TG `initData` (validator already exists at
`platform/telegram/internal/initdata`). VK/TG wrappers resend the signature on every open,
so this is not a one-time login.
- `direct` is established by the fact of a web/native session's creation (no external
signer, and none needed — reaching vk/tg segments in a direct context still requires a
real attachment, §6).
- The platform is a **property of the session**, captured when the session is created. VK and
Telegram wrappers re-mint (and so re-validate the launch signature — VK launch-params `sign`
/ Telegram `initData`) on **every cold start**, so their platform is re-confirmed each launch;
a `direct` session captures it once, established by the fact of a web/native session's creation
(no external signer, and none needed — reaching vk/tg segments in a direct context still
requires a real attachment, §6).
- The platform carries **kind** (`vk`/`telegram`/`direct`) **plus subtype**
(`ios`/`android`/`web`) — the subtype is mandatory (VK iOS is frozen).
- The gateway resolves the session and passes `platform` to the backend (alongside the
existing `X-User-ID`).
- **Fail-closed:** an untrusted/unconfirmed platform (a VK/TG session without a valid
cold-start signature; an old session with no recorded platform) denies spends/purchases
and applying any foreign origin — view only.
(`ios`/`android`/`web`). `kind` is always trusted — the server derives it from the validated
launch, never a client field. The **subtype is trusted only for VK**: it rides inside the
signed launch parameters, which is what makes the **VK iOS freeze** enforceable; for Telegram
and direct the subtype is client-reported and best-effort, so the gate never keys off it.
- The gateway resolves the session and passes `platform` to the backend (alongside the existing
`X-User-ID`), sourced from the session — never the client request body.
- **Fail-closed:** an untrusted platform — a session with no recorded platform, one predating
this feature or one the gateway could not attribute — denies spends/purchases and applying any
foreign origin (view only). A VK/TG session recovers on its next cold-start re-mint; a reused
direct/email session on re-login.
## 9. Payment intake
+17 -14
View File
@@ -180,20 +180,23 @@ durable).
Гейту (§4) нужен **доверенный, неподделываемый** контекст платформы на сервере. Клиент —
никогда не источник правды.
- Платформа — **свойство сессии**, переподтверждается свежей подписью на **каждом холодном
старте** — VK launch-params `sign` / TG `initData` (валидатор уже есть в
`platform/telegram/internal/initdata`). Обёртки VK/TG шлют подпись при каждом открытии,
так что это не одноразовый вход.
- `direct` устанавливается самим фактом создания веб/native-сессии (внешней подписи нет и
не нужно — доступ к vk/tg-сегментам в direct-контексте всё равно требует реальной
привязки, §6).
- Платформа несёт **kind** (`vk`/`telegram`/`direct`) **плюс подтип**
(`ios`/`android`/`web`) — подтип обязателен (VK iOS заморожен).
- Гейтвей резолвит сессию и передаёт `platform` в бэкенд (рядом с существующим
`X-User-ID`).
- **Fail-closed:** недоверенная/неподтверждённая платформа (VK/TG-сессия без валидной
подписи на холодном старте; старая сессия без записанной платформы) запрещает
траты/покупки и применение любого чужого origin — только просмотр.
- Платформа — **свойство сессии**, фиксируется при создании сессии. Обёртки VK и Telegram
пересоздают сессию (и потому заново проверяют подпись запуска — VK launch-params `sign` /
Telegram `initData`) на **каждом холодном старте**, поэтому их платформа переподтверждается
при каждом запуске; `direct`-сессия фиксирует платформу один раз, самим фактом создания
веб/native-сессии (внешней подписи нет и не нужно — доступ к vk/tg-сегментам в direct-контексте
всё равно требует реальной привязки, §6).
- Платформа несёт **kind** (`vk`/`telegram`/`direct`) **плюс подтип** (`ios`/`android`/`web`).
`kind` доверенный всегда — сервер выводит его из проверенного запуска, не из клиентского поля.
**Подтип доверенный только у VK**: он лежит внутри подписанных параметров запуска, что и делает
**заморозку VK iOS** выполнимой; у Telegram и direct подтип сообщает клиент, он best-effort, и
гейт на него не опирается.
- Гейтвей резолвит сессию и передаёт `platform` в бэкенд (рядом с существующим `X-User-ID`),
беря его из сессии — не из тела клиентского запроса.
- **Fail-closed:** недоверенная платформа — сессия без записанной платформы, созданная до этой
функции или которую гейтвей не смог атрибутировать — запрещает траты/покупки и применение
любого чужого origin (только просмотр). VK/TG-сессия восстанавливается на следующем холодном
старте (пересоздание), переиспользуемая direct/email-сессия — при повторном входе.
## 9. Приём платежей
+22 -12
View File
@@ -211,7 +211,7 @@ type ChatResp struct {
// brand-new account's display name and language from the validated launch fields, its
// time zone from browserTz (the client's detected "±HH:MM" UTC offset) and, from the
// validated launch deep-link startParam, its variant preferences (first contact only).
func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, username, firstName, browserTz, startParam string) (SessionResp, error) {
func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, username, firstName, browserTz, startParam, subtype string) (SessionResp, error) {
var out SessionResp
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/telegram", "", "",
map[string]string{
@@ -221,6 +221,7 @@ func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, use
"first_name": firstName,
"browser_tz": browserTz,
"start_param": startParam,
"subtype": subtype,
}, &out)
return out, err
}
@@ -230,7 +231,7 @@ func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, use
// 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) {
func (c *Client) VKAuth(ctx context.Context, externalID, languageCode, displayName, browserTz, subtype string) (SessionResp, error) {
var out SessionResp
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/vk", "", "",
map[string]string{
@@ -238,6 +239,7 @@ func (c *Client) VKAuth(ctx context.Context, externalID, languageCode, displayNa
"language_code": languageCode,
"display_name": displayName,
"browser_tz": browserTz,
"subtype": subtype,
}, &out)
return out, err
}
@@ -290,10 +292,10 @@ func (c *Client) ChatAccessByUser(ctx context.Context, userID string) (ChatAcces
// GuestAuth provisions a guest account and mints a session, seeding its time zone
// from browserTz (the client's detected "±HH:MM" UTC offset).
func (c *Client) GuestAuth(ctx context.Context, browserTz string) (SessionResp, error) {
func (c *Client) GuestAuth(ctx context.Context, browserTz, subtype string) (SessionResp, error) {
var out SessionResp
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/guest", "", "",
map[string]string{"browser_tz": browserTz}, &out)
map[string]string{"browser_tz": browserTz, "subtype": subtype}, &out)
return out, err
}
@@ -307,10 +309,10 @@ func (c *Client) EmailRequest(ctx context.Context, email, browserTz, language st
}
// EmailLogin verifies a login code and mints a session.
func (c *Client) EmailLogin(ctx context.Context, email, code string) (SessionResp, error) {
func (c *Client) EmailLogin(ctx context.Context, email, code, subtype string) (SessionResp, error) {
var out SessionResp
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/email/login", "", "",
map[string]string{"email": email, "code": code}, &out)
map[string]string{"email": email, "code": code, "subtype": subtype}, &out)
return out, err
}
@@ -331,16 +333,24 @@ func (c *Client) EmailConfirmLink(ctx context.Context, token string) (ConfirmLin
return out, err
}
// ResolveSession maps a token to its account id and guest flag (gateway
// session-cache miss). The guest flag lets the edge gate guest-forbidden ops.
func (c *Client) ResolveSession(ctx context.Context, token string) (string, bool, error) {
// ResolveSession maps a token to its account id, guest flag, and trusted platform
// (gateway session-cache miss). The guest flag lets the edge gate guest-forbidden
// ops; the platform ("<kind>/<subtype>", empty for an untrusted session) is injected
// as X-Platform on the caller's backend requests.
func (c *Client) ResolveSession(ctx context.Context, token string) (string, bool, string, error) {
var out struct {
UserID string `json:"user_id"`
IsGuest bool `json:"is_guest"`
UserID string `json:"user_id"`
IsGuest bool `json:"is_guest"`
PlatformKind string `json:"platform_kind"`
PlatformSubtype string `json:"platform_subtype"`
}
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/resolve", "", "",
map[string]string{"token": token}, &out)
return out.UserID, out.IsGuest, err
platform := ""
if out.PlatformKind != "" {
platform = out.PlatformKind + "/" + out.PlatformSubtype
}
return out.UserID, out.IsGuest, platform, err
}
// Profile returns the authenticated account's profile.
+29 -1
View File
@@ -118,8 +118,30 @@ func (e *APIError) Error() string {
return fmt.Sprintf("backend %d (%s): %s", e.Status, e.Code, e.Message)
}
// platformCtxKey types the request-context slot the trusted X-Platform value rides in.
type platformCtxKey struct{}
// WithPlatform returns a copy of ctx carrying the trusted platform header value
// ("<kind>/<subtype>", e.g. "vk/ios") that do and getRaw inject as X-Platform on the
// outbound backend request. An empty platform (an untrusted session) leaves ctx
// unchanged, so no header is sent and the backend treats the request as untrusted.
func WithPlatform(ctx context.Context, platform string) context.Context {
if platform == "" {
return ctx
}
return context.WithValue(ctx, platformCtxKey{}, platform)
}
// platformFromContext returns the platform header value stored by WithPlatform, or
// an empty string when none is present.
func platformFromContext(ctx context.Context) string {
p, _ := ctx.Value(platformCtxKey{}).(string)
return p
}
// do performs one REST call. userID, when non-empty, is forwarded as X-User-ID;
// clientIP, when non-empty, as X-Forwarded-For (for chat moderation). A non-2xx
// clientIP, when non-empty, as X-Forwarded-For (for chat moderation); the trusted
// platform carried on ctx (see WithPlatform), when present, as X-Platform. A non-2xx
// response is returned as an *APIError carrying the backend error code.
func (c *Client) do(ctx context.Context, method, path, userID, clientIP string, body, out any) error {
var reader io.Reader
@@ -141,6 +163,9 @@ func (c *Client) do(ctx context.Context, method, path, userID, clientIP string,
if clientIP != "" {
req.Header.Set("X-Forwarded-For", clientIP)
}
if p := platformFromContext(ctx); p != "" {
req.Header.Set("X-Platform", p)
}
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("backendclient: %s %s: %w", method, path, err)
@@ -174,6 +199,9 @@ func (c *Client) getRaw(ctx context.Context, path, userID, respHeader string) ([
if userID != "" {
req.Header.Set("X-User-ID", userID)
}
if p := platformFromContext(ctx); p != "" {
req.Header.Set("X-Platform", p)
}
resp, err := c.http.Do(req)
if err != nil {
return nil, "", fmt.Errorf("backendclient: GET %s: %w", path, err)
@@ -82,3 +82,43 @@ func TestSyncBans(t *testing.T) {
t.Fatalf("unban = %v, want [203.0.113.9]", unban)
}
}
// TestXPlatformInjection verifies the trusted platform carried on the context
// (WithPlatform) rides an authenticated backend request as X-Platform, and that an
// untrusted context (no platform) sends no header at all — the fail-closed default.
func TestXPlatformInjection(t *testing.T) {
var gotPlatform string
var hadHeader bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPlatform = r.Header.Get("X-Platform")
_, hadHeader = r.Header["X-Platform"]
_, _ = w.Write([]byte(`{}`))
}))
defer srv.Close()
c, err := backendclient.New(srv.URL, "localhost:9090", 2*time.Second)
if err != nil {
t.Fatalf("backendclient: %v", err)
}
defer func() { _ = c.Close() }()
t.Run("trusted forwards header", func(t *testing.T) {
ctx := backendclient.WithPlatform(context.Background(), "vk/ios")
if _, err := c.Profile(ctx, "user-1"); err != nil {
t.Fatalf("Profile: %v", err)
}
if gotPlatform != "vk/ios" {
t.Fatalf("X-Platform = %q, want vk/ios", gotPlatform)
}
})
t.Run("untrusted omits header", func(t *testing.T) {
hadHeader = true // ensure the handler actually clears it
if _, err := c.Profile(context.Background(), "user-1"); err != nil {
t.Fatalf("Profile: %v", err)
}
if hadHeader {
t.Fatal("X-Platform must be absent for an untrusted context")
}
})
}
+14 -10
View File
@@ -275,7 +275,7 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut
tr := transcode.Request{Payload: req.Msg.GetPayload(), ClientIP: clientIP}
if op.Auth {
uid, isGuest, err := s.resolve(ctx, req.Header(), clientIP)
uid, isGuest, platform, err := s.resolve(ctx, req.Header(), clientIP)
if err != nil {
result = "unauthenticated"
return nil, err
@@ -297,6 +297,10 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut
return nil, s.rejectRateLimited(ctx, classUser, uid, msgType)
}
tr.UserID = uid
// Carry the resolved trusted platform on the context so the backend client injects
// X-Platform on every downstream REST call for this request. Empty for an untrusted
// session ⇒ no header ⇒ the backend treats the request as untrusted (view-only).
ctx = backendclient.WithPlatform(ctx, platform)
} else {
if !s.limiter.Allow("ip:"+clientIP, s.publicPolicy) {
result = "rate_limited"
@@ -331,7 +335,7 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut
// Subscribe streams the authenticated user's live events with a keep-alive
// heartbeat until the client disconnects.
func (s *Server) Subscribe(ctx context.Context, req *connect.Request[edgev1.SubscribeRequest], stream *connect.ServerStream[edgev1.Event]) error {
uid, _, err := s.resolve(ctx, req.Header(), peerIP(req.Peer().Addr, req.Header()))
uid, _, _, err := s.resolve(ctx, req.Header(), peerIP(req.Peer().Addr, req.Header()))
if err != nil {
return err
}
@@ -494,7 +498,7 @@ func (s *Server) dictBytesHandler() http.Handler {
http.Error(w, "rate limited", http.StatusTooManyRequests)
return
}
uid, _, err := s.resolve(r.Context(), r.Header, ip)
uid, _, _, err := s.resolve(r.Context(), r.Header, ip)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
@@ -552,7 +556,7 @@ func (s *Server) localEvalMetricsHandler() http.Handler {
return
}
ip := peerIP(r.RemoteAddr, r.Header)
if _, _, err := s.resolve(r.Context(), r.Header, ip); err != nil {
if _, _, _, err := s.resolve(r.Context(), r.Header, ip); err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
@@ -659,10 +663,10 @@ func truncate(s string, n int) string {
// resolve extracts and resolves the Authorization bearer token to an account id
// and its guest flag, returning a Connect Unauthenticated error when it is missing
// or unknown.
func (s *Server) resolve(ctx context.Context, h http.Header, clientIP string) (string, bool, error) {
func (s *Server) resolve(ctx context.Context, h http.Header, clientIP string) (string, bool, string, error) {
token := bearerToken(h.Get("Authorization"))
if token == "" {
return "", false, connect.NewError(connect.CodeUnauthenticated, errMissingToken)
return "", false, "", connect.NewError(connect.CodeUnauthenticated, errMissingToken)
}
// The honeytoken is a planted value no real client holds: presenting it is a
// high-confidence intrusion signal, so ban the caller and raise the alarm, then
@@ -672,9 +676,9 @@ func (s *Server) resolve(ctx context.Context, h http.Header, clientIP string) (s
if s.banlist.BanNow(clientIP, ratelimit.ReasonHoneytoken) {
s.metrics.recordBan(ctx, string(ratelimit.ReasonHoneytoken))
}
return "", false, connect.NewError(connect.CodeUnauthenticated, errInvalidSession)
return "", false, "", connect.NewError(connect.CodeUnauthenticated, errInvalidSession)
}
uid, isGuest, err := s.sessions.Resolve(ctx, token)
uid, isGuest, platform, err := s.sessions.Resolve(ctx, token)
if err != nil {
// An unknown or expired token (a backend 4xx) is the client's problem and
// stays silent; anything else — a resolve timeout, a refused connection, a
@@ -685,9 +689,9 @@ func (s *Server) resolve(ctx context.Context, h http.Header, clientIP string) (s
if !errors.As(err, &apiErr) || apiErr.Status >= http.StatusInternalServerError {
s.log.Warn("session resolve failed", zap.Error(err))
}
return "", false, connect.NewError(connect.CodeUnauthenticated, errInvalidSession)
return "", false, "", connect.NewError(connect.CodeUnauthenticated, errInvalidSession)
}
return uid, isGuest, nil
return uid, isGuest, platform, nil
}
// bearerToken extracts the token from an "Authorization: Bearer <token>" header,
+26 -24
View File
@@ -11,10 +11,11 @@ import (
"time"
)
// Resolver resolves a token to an account id and its guest flag at the backend
// (the cache miss path). backendclient.Client satisfies it.
// Resolver resolves a token to an account id, its guest flag and its trusted
// platform ("<kind>/<subtype>", empty when untrusted) at the backend (the cache
// miss path). backendclient.Client satisfies it.
type Resolver interface {
ResolveSession(ctx context.Context, token string) (string, bool, error)
ResolveSession(ctx context.Context, token string) (string, bool, string, error)
}
// Cache resolves session tokens to account ids, caching hits for ttl.
@@ -29,9 +30,10 @@ type Cache struct {
}
type entry struct {
userID string
isGuest bool
expires time.Time
userID string
isGuest bool
platform string
expires time.Time
}
// NewCache constructs a Cache over backend with the given TTL and maximum size.
@@ -48,19 +50,19 @@ func NewCache(backend Resolver, ttl time.Duration, max int) *Cache {
}
}
// Resolve returns the account id and guest flag for token, consulting the cache
// first and the backend on a miss (caching the result). An empty token is rejected
// by the backend like any unknown token.
func (c *Cache) Resolve(ctx context.Context, token string) (string, bool, error) {
if uid, guest, ok := c.lookup(token); ok {
return uid, guest, nil
// Resolve returns the account id, guest flag and trusted platform for token,
// consulting the cache first and the backend on a miss (caching the result). An
// empty token is rejected by the backend like any unknown token.
func (c *Cache) Resolve(ctx context.Context, token string) (string, bool, string, error) {
if uid, guest, platform, ok := c.lookup(token); ok {
return uid, guest, platform, nil
}
uid, guest, err := c.backend.ResolveSession(ctx, token)
uid, guest, platform, err := c.backend.ResolveSession(ctx, token)
if err != nil {
return "", false, err
return "", false, "", err
}
c.store(token, uid, guest)
return uid, guest, nil
c.store(token, uid, guest, platform)
return uid, guest, platform, nil
}
// Invalidate drops a token from the cache (e.g. after a revoke).
@@ -70,26 +72,26 @@ func (c *Cache) Invalidate(token string) {
delete(c.entries, token)
}
// lookup returns a live cached account id and guest flag for token.
func (c *Cache) lookup(token string) (string, bool, bool) {
// lookup returns a live cached account id, guest flag and platform for token.
func (c *Cache) lookup(token string) (string, bool, string, bool) {
c.mu.Lock()
defer c.mu.Unlock()
e, ok := c.entries[token]
if !ok || !c.now().Before(e.expires) {
return "", false, false
return "", false, "", false
}
return e.userID, e.isGuest, true
return e.userID, e.isGuest, e.platform, true
}
// store caches token -> (userID, isGuest), sweeping expired entries and bounding
// the size.
func (c *Cache) store(token, userID string, isGuest bool) {
// store caches token -> (userID, isGuest, platform), sweeping expired entries and
// bounding the size.
func (c *Cache) store(token, userID string, isGuest bool, platform string) {
c.mu.Lock()
defer c.mu.Unlock()
if len(c.entries) >= c.max {
c.evictLocked()
}
c.entries[token] = entry{userID: userID, isGuest: isGuest, expires: c.now().Add(c.ttl)}
c.entries[token] = entry{userID: userID, isGuest: isGuest, platform: platform, expires: c.now().Add(c.ttl)}
}
// evictLocked removes expired entries and, if still at capacity, drops arbitrary
+20 -19
View File
@@ -8,18 +8,19 @@ import (
)
type fakeResolver struct {
uid string
guest bool
err error
calls int
uid string
guest bool
platform string
err error
calls int
}
func (f *fakeResolver) ResolveSession(_ context.Context, _ string) (string, bool, error) {
func (f *fakeResolver) ResolveSession(_ context.Context, _ string) (string, bool, string, error) {
f.calls++
if f.err != nil {
return "", false, f.err
return "", false, "", f.err
}
return f.uid, f.guest, nil
return f.uid, f.guest, f.platform, nil
}
func TestResolveCachesBackendHit(t *testing.T) {
@@ -27,7 +28,7 @@ func TestResolveCachesBackendHit(t *testing.T) {
c := NewCache(r, time.Minute, 10)
for i := 0; i < 3; i++ {
uid, _, err := c.Resolve(context.Background(), "tok")
uid, _, _, err := c.Resolve(context.Background(), "tok")
if err != nil || uid != "user-1" {
t.Fatalf("resolve #%d = (%q, %v)", i, uid, err)
}
@@ -37,24 +38,24 @@ func TestResolveCachesBackendHit(t *testing.T) {
}
}
func TestResolveCarriesAndCachesGuestFlag(t *testing.T) {
r := &fakeResolver{uid: "guest-1", guest: true}
func TestResolveCarriesAndCachesGuestFlagAndPlatform(t *testing.T) {
r := &fakeResolver{uid: "guest-1", guest: true, platform: "vk/ios"}
c := NewCache(r, time.Minute, 10)
for i := 0; i < 2; i++ {
uid, guest, err := c.Resolve(context.Background(), "tok")
if err != nil || uid != "guest-1" || !guest {
t.Fatalf("resolve #%d = (%q, guest=%v, %v)", i, uid, guest, err)
uid, guest, platform, err := c.Resolve(context.Background(), "tok")
if err != nil || uid != "guest-1" || !guest || platform != "vk/ios" {
t.Fatalf("resolve #%d = (%q, guest=%v, platform=%q, %v)", i, uid, guest, platform, err)
}
}
if r.calls != 1 {
t.Fatalf("backend calls = %d, want 1 (guest flag cached)", r.calls)
t.Fatalf("backend calls = %d, want 1 (guest flag + platform cached)", r.calls)
}
}
func TestResolvePropagatesBackendError(t *testing.T) {
r := &fakeResolver{err: errors.New("nope")}
c := NewCache(r, time.Minute, 10)
if _, _, err := c.Resolve(context.Background(), "tok"); err == nil {
if _, _, _, err := c.Resolve(context.Background(), "tok"); err == nil {
t.Fatal("expected backend error to propagate")
}
}
@@ -65,11 +66,11 @@ func TestResolveReResolvesAfterTTL(t *testing.T) {
base := time.Now()
c.now = func() time.Time { return base }
if _, _, err := c.Resolve(context.Background(), "tok"); err != nil {
if _, _, _, err := c.Resolve(context.Background(), "tok"); err != nil {
t.Fatal(err)
}
c.now = func() time.Time { return base.Add(2 * time.Minute) } // past TTL
if _, _, err := c.Resolve(context.Background(), "tok"); err != nil {
if _, _, _, err := c.Resolve(context.Background(), "tok"); err != nil {
t.Fatal(err)
}
if r.calls != 2 {
@@ -80,9 +81,9 @@ func TestResolveReResolvesAfterTTL(t *testing.T) {
func TestInvalidateForcesReResolve(t *testing.T) {
r := &fakeResolver{uid: "user-1"}
c := NewCache(r, time.Minute, 10)
_, _, _ = c.Resolve(context.Background(), "tok")
_, _, _, _ = c.Resolve(context.Background(), "tok")
c.Invalidate("tok")
_, _, _ = c.Resolve(context.Background(), "tok")
_, _, _, _ = c.Resolve(context.Background(), "tok")
if r.calls != 2 {
t.Fatalf("backend calls = %d, want 2 after invalidate", r.calls)
}
+8 -6
View File
@@ -206,7 +206,7 @@ func authTelegramHandler(backend *backendclient.Client, tg TelegramValidator) Ha
if q, perr := url.ParseQuery(initData); perr == nil {
startParam = q.Get("start_param")
}
sess, err := backend.TelegramAuth(ctx, user.ExternalID, user.LanguageCode, user.Username, user.FirstName, string(in.BrowserTz()), startParam)
sess, err := backend.TelegramAuth(ctx, user.ExternalID, user.LanguageCode, user.Username, user.FirstName, string(in.BrowserTz()), startParam, string(in.Subtype()))
if err != nil {
return nil, err
}
@@ -225,7 +225,7 @@ func authVKHandler(backend *backendclient.Client, secret string) Handler {
if err != nil {
return nil, err
}
sess, err := backend.VKAuth(ctx, user.ExternalID, user.Language, string(in.DisplayName()), string(in.BrowserTz()))
sess, err := backend.VKAuth(ctx, user.ExternalID, user.Language, string(in.DisplayName()), string(in.BrowserTz()), user.Subtype())
if err != nil {
return nil, err
}
@@ -238,11 +238,13 @@ func authGuestHandler(backend *backendclient.Client) Handler {
// The guest bootstrap historically carried no payload; the detected zone is
// optional, so an absent or empty one simply yields no time-zone seed (rather
// than panicking in GetRootAs* on a zero-length buffer).
var browserTz string
var browserTz, subtype string
if len(req.Payload) > 0 {
browserTz = string(fb.GetRootAsGuestLoginRequest(req.Payload, 0).BrowserTz())
g := fb.GetRootAsGuestLoginRequest(req.Payload, 0)
browserTz = string(g.BrowserTz())
subtype = string(g.Subtype())
}
sess, err := backend.GuestAuth(ctx, browserTz)
sess, err := backend.GuestAuth(ctx, browserTz, subtype)
if err != nil {
return nil, err
}
@@ -263,7 +265,7 @@ func authEmailRequestHandler(backend *backendclient.Client) Handler {
func authEmailLoginHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsEmailLoginRequest(req.Payload, 0)
sess, err := backend.EmailLogin(ctx, string(in.Email()), string(in.Code()))
sess, err := backend.EmailLogin(ctx, string(in.Email()), string(in.Code()), string(in.Subtype()))
if err != nil {
return nil, err
}
+20 -2
View File
@@ -22,10 +22,28 @@ 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.
// seeds a brand-new account's preferred language; Platform is the raw signed
// vk_platform value (e.g. "mobile_iphone", "desktop_web"), from which Subtype derives
// the trusted device family.
type Identity struct {
ExternalID string
Language string
Platform string
}
// Subtype maps the signed vk_platform to the trusted device family recorded on the
// session — ios (iPhone/iPad, the store-frozen case), android, or web (desktop/mobile
// web and anything unrecognised). Because vk_platform rides inside the verified
// signature, this subtype is trustworthy, unlike a client-reported one.
func (i Identity) Subtype() string {
switch {
case strings.Contains(i.Platform, "android"):
return "android"
case strings.Contains(i.Platform, "iphone"), strings.Contains(i.Platform, "ipad"):
return "ios"
default:
return "web"
}
}
// Verify checks the `sign` of a VK Mini App launch query string against secret and
@@ -68,5 +86,5 @@ func Verify(params, secret string) (Identity, error) {
if externalID == "" {
return Identity{}, ErrInvalid
}
return Identity{ExternalID: externalID, Language: values.Get("vk_language")}, nil
return Identity{ExternalID: externalID, Language: values.Get("vk_language"), Platform: values.Get("vk_platform")}, nil
}
+27
View File
@@ -47,6 +47,33 @@ func TestVerifyValid(t *testing.T) {
if id.ExternalID != "494075" || id.Language != "ru" {
t.Fatalf("identity = %+v, want ExternalID=494075 Language=ru", id)
}
if id.Platform != "android" || id.Subtype() != "android" {
t.Fatalf("platform = %q subtype = %q, want android/android", id.Platform, id.Subtype())
}
}
// TestSubtype locks the vk_platform -> device-family mapping (the trusted subtype),
// notably that iPhone and iPad both surface as the store-frozen "ios" and that any
// unrecognised or empty value falls back to web.
func TestSubtype(t *testing.T) {
for _, tc := range []struct {
platform string
want string
}{
{"mobile_iphone", "ios"},
{"mobile_ipad", "ios"},
{"mobile_iphone_messenger", "ios"},
{"mobile_android", "android"},
{"android", "android"},
{"desktop_web", "web"},
{"mobile_web", "web"},
{"", "web"},
{"something_new", "web"},
} {
if got := (vkauth.Identity{Platform: tc.platform}).Subtype(); got != tc.want {
t.Errorf("Subtype(%q) = %q, want %q", tc.platform, got, tc.want)
}
}
}
// TestVerifyCommaValue locks the URL-encoding of the one realistic special-char VK
+8
View File
@@ -106,6 +106,9 @@ table MoveRecord {
table TelegramLoginRequest {
init_data:string;
browser_tz:string;
// Client-reported device family (ios/android/web). Telegram's initData does not sign it, so
// the server records it best-effort and the payments gate never relies on it.
subtype:string;
}
// VKLoginRequest carries a VK Mini App launch. params is the raw query string of the
@@ -128,6 +131,9 @@ table VKLoginRequest {
table GuestLoginRequest {
locale:string;
browser_tz:string;
// Client-reported device family (ios/android/web) of this direct session; best-effort
// (a direct session has no external signer).
subtype:string;
}
// EmailRequestRequest asks the backend to send a login confirm-code to email. It
@@ -150,6 +156,8 @@ table EmailRequestRequest {
table EmailLoginRequest {
email:string;
code:string;
// Client-reported device family (ios/android/web) of this direct session; best-effort.
subtype:string;
}
// EmailConfirmLinkRequest verifies a one-tap deeplink token from a confirmation
+12 -1
View File
@@ -57,8 +57,16 @@ func (rcv *EmailLoginRequest) Code() []byte {
return nil
}
func (rcv *EmailLoginRequest) Subtype() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func EmailLoginRequestStart(builder *flatbuffers.Builder) {
builder.StartObject(2)
builder.StartObject(3)
}
func EmailLoginRequestAddEmail(builder *flatbuffers.Builder, email flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(email), 0)
@@ -66,6 +74,9 @@ func EmailLoginRequestAddEmail(builder *flatbuffers.Builder, email flatbuffers.U
func EmailLoginRequestAddCode(builder *flatbuffers.Builder, code flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(code), 0)
}
func EmailLoginRequestAddSubtype(builder *flatbuffers.Builder, subtype flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(subtype), 0)
}
func EmailLoginRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+12 -1
View File
@@ -57,8 +57,16 @@ func (rcv *GuestLoginRequest) BrowserTz() []byte {
return nil
}
func (rcv *GuestLoginRequest) Subtype() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func GuestLoginRequestStart(builder *flatbuffers.Builder) {
builder.StartObject(2)
builder.StartObject(3)
}
func GuestLoginRequestAddLocale(builder *flatbuffers.Builder, locale flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(locale), 0)
@@ -66,6 +74,9 @@ func GuestLoginRequestAddLocale(builder *flatbuffers.Builder, locale flatbuffers
func GuestLoginRequestAddBrowserTz(builder *flatbuffers.Builder, browserTz flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(browserTz), 0)
}
func GuestLoginRequestAddSubtype(builder *flatbuffers.Builder, subtype flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(subtype), 0)
}
func GuestLoginRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+12 -1
View File
@@ -57,8 +57,16 @@ func (rcv *TelegramLoginRequest) BrowserTz() []byte {
return nil
}
func (rcv *TelegramLoginRequest) Subtype() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func TelegramLoginRequestStart(builder *flatbuffers.Builder) {
builder.StartObject(2)
builder.StartObject(3)
}
func TelegramLoginRequestAddInitData(builder *flatbuffers.Builder, initData flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(initData), 0)
@@ -66,6 +74,9 @@ func TelegramLoginRequestAddInitData(builder *flatbuffers.Builder, initData flat
func TelegramLoginRequestAddBrowserTz(builder *flatbuffers.Builder, browserTz flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(browserTz), 0)
}
func TelegramLoginRequestAddSubtype(builder *flatbuffers.Builder, subtype flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(subtype), 0)
}
func TelegramLoginRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
@@ -34,8 +34,15 @@ code(optionalEncoding?:any):string|Uint8Array|null {
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
subtype():string|null
subtype(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
subtype(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 startEmailLoginRequest(builder:flatbuffers.Builder) {
builder.startObject(2);
builder.startObject(3);
}
static addEmail(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset) {
@@ -46,15 +53,20 @@ static addCode(builder:flatbuffers.Builder, codeOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, codeOffset, 0);
}
static addSubtype(builder:flatbuffers.Builder, subtypeOffset:flatbuffers.Offset) {
builder.addFieldOffset(2, subtypeOffset, 0);
}
static endEmailLoginRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createEmailLoginRequest(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset, codeOffset:flatbuffers.Offset):flatbuffers.Offset {
static createEmailLoginRequest(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset, codeOffset:flatbuffers.Offset, subtypeOffset:flatbuffers.Offset):flatbuffers.Offset {
EmailLoginRequest.startEmailLoginRequest(builder);
EmailLoginRequest.addEmail(builder, emailOffset);
EmailLoginRequest.addCode(builder, codeOffset);
EmailLoginRequest.addSubtype(builder, subtypeOffset);
return EmailLoginRequest.endEmailLoginRequest(builder);
}
}
@@ -34,8 +34,15 @@ browserTz(optionalEncoding?:any):string|Uint8Array|null {
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
subtype():string|null
subtype(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
subtype(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 startGuestLoginRequest(builder:flatbuffers.Builder) {
builder.startObject(2);
builder.startObject(3);
}
static addLocale(builder:flatbuffers.Builder, localeOffset:flatbuffers.Offset) {
@@ -46,15 +53,20 @@ static addBrowserTz(builder:flatbuffers.Builder, browserTzOffset:flatbuffers.Off
builder.addFieldOffset(1, browserTzOffset, 0);
}
static addSubtype(builder:flatbuffers.Builder, subtypeOffset:flatbuffers.Offset) {
builder.addFieldOffset(2, subtypeOffset, 0);
}
static endGuestLoginRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createGuestLoginRequest(builder:flatbuffers.Builder, localeOffset:flatbuffers.Offset, browserTzOffset:flatbuffers.Offset):flatbuffers.Offset {
static createGuestLoginRequest(builder:flatbuffers.Builder, localeOffset:flatbuffers.Offset, browserTzOffset:flatbuffers.Offset, subtypeOffset:flatbuffers.Offset):flatbuffers.Offset {
GuestLoginRequest.startGuestLoginRequest(builder);
GuestLoginRequest.addLocale(builder, localeOffset);
GuestLoginRequest.addBrowserTz(builder, browserTzOffset);
GuestLoginRequest.addSubtype(builder, subtypeOffset);
return GuestLoginRequest.endGuestLoginRequest(builder);
}
}
@@ -34,8 +34,15 @@ browserTz(optionalEncoding?:any):string|Uint8Array|null {
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
subtype():string|null
subtype(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
subtype(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 startTelegramLoginRequest(builder:flatbuffers.Builder) {
builder.startObject(2);
builder.startObject(3);
}
static addInitData(builder:flatbuffers.Builder, initDataOffset:flatbuffers.Offset) {
@@ -46,15 +53,20 @@ static addBrowserTz(builder:flatbuffers.Builder, browserTzOffset:flatbuffers.Off
builder.addFieldOffset(1, browserTzOffset, 0);
}
static addSubtype(builder:flatbuffers.Builder, subtypeOffset:flatbuffers.Offset) {
builder.addFieldOffset(2, subtypeOffset, 0);
}
static endTelegramLoginRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createTelegramLoginRequest(builder:flatbuffers.Builder, initDataOffset:flatbuffers.Offset, browserTzOffset:flatbuffers.Offset):flatbuffers.Offset {
static createTelegramLoginRequest(builder:flatbuffers.Builder, initDataOffset:flatbuffers.Offset, browserTzOffset:flatbuffers.Offset, subtypeOffset:flatbuffers.Offset):flatbuffers.Offset {
TelegramLoginRequest.startTelegramLoginRequest(builder);
TelegramLoginRequest.addInitData(builder, initDataOffset);
TelegramLoginRequest.addBrowserTz(builder, browserTzOffset);
TelegramLoginRequest.addSubtype(builder, subtypeOffset);
return TelegramLoginRequest.endTelegramLoginRequest(builder);
}
}
+4 -2
View File
@@ -110,16 +110,18 @@ describe('codec', () => {
it('carries the detected browser zone on every account-creating auth request', () => {
const tg = fb.TelegramLoginRequest.getRootAsTelegramLoginRequest(
new ByteBuffer(encodeTelegramLogin('init-data-blob', '+03:00')),
new ByteBuffer(encodeTelegramLogin('init-data-blob', '+03:00', 'ios')),
);
expect(tg.initData()).toBe('init-data-blob');
expect(tg.browserTz()).toBe('+03:00');
expect(tg.subtype()).toBe('ios');
const guest = fb.GuestLoginRequest.getRootAsGuestLoginRequest(
new ByteBuffer(encodeGuestLogin('ru', '-05:30')),
new ByteBuffer(encodeGuestLogin('ru', '-05:30', 'android')),
);
expect(guest.locale()).toBe('ru');
expect(guest.browserTz()).toBe('-05:30');
expect(guest.subtype()).toBe('android');
const email = fb.EmailRequestRequest.getRootAsEmailRequestRequest(
new ByteBuffer(encodeEmailRequest('a@example.com', '+00:00', 'en', true)),
+17 -3
View File
@@ -182,13 +182,19 @@ export function encodeChatPost(gameId: string, body: string): Uint8Array {
return finish(b, fb.ChatPostRequest.endChatPostRequest(b));
}
export function encodeTelegramLogin(initData: string, browserTz: string): Uint8Array {
export function encodeTelegramLogin(
initData: string,
browserTz: string,
subtype: string,
): Uint8Array {
const b = new Builder(512);
const d = b.createString(initData);
const tz = b.createString(browserTz);
const st = b.createString(subtype);
fb.TelegramLoginRequest.startTelegramLoginRequest(b);
fb.TelegramLoginRequest.addInitData(b, d);
fb.TelegramLoginRequest.addBrowserTz(b, tz);
fb.TelegramLoginRequest.addSubtype(b, st);
return finish(b, fb.TelegramLoginRequest.endTelegramLoginRequest(b));
}
@@ -204,13 +210,19 @@ export function encodeVKLogin(params: string, browserTz: string, displayName: st
return finish(b, fb.VKLoginRequest.endVKLoginRequest(b));
}
export function encodeGuestLogin(locale: string, browserTz: string): Uint8Array {
export function encodeGuestLogin(
locale: string,
browserTz: string,
subtype: string,
): Uint8Array {
const b = new Builder(64);
const l = b.createString(locale);
const tz = b.createString(browserTz);
const st = b.createString(subtype);
fb.GuestLoginRequest.startGuestLoginRequest(b);
fb.GuestLoginRequest.addLocale(b, l);
fb.GuestLoginRequest.addBrowserTz(b, tz);
fb.GuestLoginRequest.addSubtype(b, st);
return finish(b, fb.GuestLoginRequest.endGuestLoginRequest(b));
}
@@ -232,13 +244,15 @@ export function encodeEmailRequest(
return finish(b, fb.EmailRequestRequest.endEmailRequestRequest(b));
}
export function encodeEmailLogin(email: string, code: string): Uint8Array {
export function encodeEmailLogin(email: string, code: string, subtype: string): Uint8Array {
const b = new Builder(128);
const e = b.createString(email);
const c = b.createString(code);
const st = b.createString(subtype);
fb.EmailLoginRequest.startEmailLoginRequest(b);
fb.EmailLoginRequest.addEmail(b, e);
fb.EmailLoginRequest.addCode(b, c);
fb.EmailLoginRequest.addSubtype(b, st);
return finish(b, fb.EmailLoginRequest.endEmailLoginRequest(b));
}
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { normalizeSubtype } from './platform';
describe('normalizeSubtype', () => {
it('maps iPhone and iPad variants to the store-frozen ios', () => {
expect(normalizeSubtype('ios')).toBe('ios');
expect(normalizeSubtype('iphone')).toBe('ios');
expect(normalizeSubtype('mobile_iphone')).toBe('ios');
expect(normalizeSubtype('mobile_ipad')).toBe('ios');
});
it('maps android variants to android', () => {
expect(normalizeSubtype('android')).toBe('android');
expect(normalizeSubtype('mobile_android')).toBe('android');
expect(normalizeSubtype('android_x')).toBe('android');
});
it('is case-insensitive', () => {
expect(normalizeSubtype('IPhone')).toBe('ios');
expect(normalizeSubtype('Android')).toBe('android');
});
it('defaults desktop, tdesktop, web, empty and unknown values to web', () => {
expect(normalizeSubtype('web')).toBe('web');
expect(normalizeSubtype('mobile_web')).toBe('web');
expect(normalizeSubtype('desktop_web')).toBe('web');
expect(normalizeSubtype('tdesktop')).toBe('web');
expect(normalizeSubtype('')).toBe('web');
expect(normalizeSubtype('something_new')).toBe('web');
});
});
+30
View File
@@ -0,0 +1,30 @@
// Platform subtype (device family) derivation for the trusted platform signal. The
// server records the wrapper kind (vk / telegram / direct) itself, from the validated
// establish path; the client supplies only this device subtype. For VK it is ignored
// server-side (the gateway derives a trusted subtype from the signed vk_platform); for
// Telegram and a direct (web/native) session it is client-reported and best-effort.
import { insideTelegram, telegramPlatform } from './telegram';
import { clientChannel } from './channel';
export type Subtype = 'ios' | 'android' | 'web';
// normalizeSubtype coerces a raw device string (Telegram's WebApp.platform, a
// Capacitor platform, VK's vk_platform, …) to the ios/android/web wire subtype,
// mapping any iPhone/iPad variant to ios and defaulting anything unrecognised —
// desktop, tdesktop, an empty value — to web.
export function normalizeSubtype(raw: string): Subtype {
const s = raw.toLowerCase();
if (s.includes('android')) return 'android';
if (s.includes('iphone') || s.includes('ipad') || s === 'ios') return 'ios';
return 'web';
}
// platformSubtype reports this client's best-effort device family for the current
// launch: Telegram's reported platform inside a Mini App, otherwise the Capacitor /
// web channel for a direct session. VK does not use it (server-derived from the signed
// launch params).
export function platformSubtype(): Subtype {
if (insideTelegram()) return normalizeSubtype(telegramPlatform());
return normalizeSubtype(clientChannel());
}
+4 -3
View File
@@ -11,6 +11,7 @@ import { Gateway } from '../gen/edge/v1/edge_pb';
import { GatewayError, type GatewayClient } from './client';
import * as codec from './codec';
import { browserOffset } from './profileValidation';
import { platformSubtype } from './platform';
import { registerProbe, reportOffline, reportOnline } from './connection.svelte';
import { offlineMode } from './offline.svelte';
import { maintenanceRecovered, registerMaintenanceProbe, reportMaintenance } from './maintenance.svelte';
@@ -124,13 +125,13 @@ export function createTransport(baseUrl: string): GatewayClient {
},
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(), platformSubtype())));
},
async authVK(params, displayName) {
return codec.decodeSession(await exec('auth.vk', codec.encodeVKLogin(params, browserOffset(), displayName)));
},
async authGuest(locale) {
return codec.decodeSession(await exec('auth.guest', codec.encodeGuestLogin(locale ?? '', browserOffset())));
return codec.decodeSession(await exec('auth.guest', codec.encodeGuestLogin(locale ?? '', browserOffset(), platformSubtype())));
},
async authEmailRequest(email, language, pwa) {
await exec('auth.email.request', codec.encodeEmailRequest(email, browserOffset(), language, pwa));
@@ -139,7 +140,7 @@ export function createTransport(baseUrl: string): GatewayClient {
return codec.decodeConfirmLinkResult(await exec('auth.email.confirm_link', codec.encodeEmailConfirmLink(token)));
},
async authEmailLogin(email, code) {
return codec.decodeSession(await exec('auth.email.login', codec.encodeEmailLogin(email, code)));
return codec.decodeSession(await exec('auth.email.login', codec.encodeEmailLogin(email, code, platformSubtype())));
},
async profileGet() {