diff --git a/PLAN.md b/PLAN.md index 5cd1e2a..4fd47c5 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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, "/")` + `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. --- diff --git a/backend/internal/inttest/session_platform_test.go b/backend/internal/inttest/session_platform_test.go new file mode 100644 index 0000000..9369170 --- /dev/null +++ b/backend/internal/inttest/session_platform_test.go @@ -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 +} diff --git a/backend/internal/inttest/session_test.go b/backend/internal/inttest/session_test.go index 95f4875..1a0a3d8 100644 --- a/backend/internal/inttest/session_test.go +++ b/backend/internal/inttest/session_test.go @@ -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. diff --git a/backend/internal/link/service.go b/backend/internal/link/service.go index 8f502d5..590e668 100644 --- a/backend/internal/link/service.go +++ b/backend/internal/link/service.go @@ -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 } diff --git a/backend/internal/postgres/jet/backend/model/sessions.go b/backend/internal/postgres/jet/backend/model/sessions.go index 4694d73..85d855f 100644 --- a/backend/internal/postgres/jet/backend/model/sessions.go +++ b/backend/internal/postgres/jet/backend/model/sessions.go @@ -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 } diff --git a/backend/internal/postgres/jet/backend/table/sessions.go b/backend/internal/postgres/jet/backend/table/sessions.go index 5a08254..e3b87ca 100644 --- a/backend/internal/postgres/jet/backend/table/sessions.go +++ b/backend/internal/postgres/jet/backend/table/sessions.go @@ -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, diff --git a/backend/internal/postgres/migrations/00011_session_platform.sql b/backend/internal/postgres/migrations/00011_session_platform.sql new file mode 100644 index 0000000..01bb2fd --- /dev/null +++ b/backend/internal/postgres/migrations/00011_session_platform.sql @@ -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; diff --git a/backend/internal/server/dto.go b/backend/internal/server/dto.go index 15a2734..c96cac1 100644 --- a/backend/internal/server/dto.go +++ b/backend/internal/server/dto.go @@ -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 diff --git a/backend/internal/server/handlers_auth.go b/backend/internal/server/handlers_auth.go index 364f8c0..e9e073d 100644 --- a/backend/internal/server/handlers_auth.go +++ b/backend/internal/server/handlers_auth.go @@ -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 diff --git a/backend/internal/server/middleware.go b/backend/internal/server/middleware.go index 56a5e41..8134295 100644 --- a/backend/internal/server/middleware.go +++ b/backend/internal/server/middleware.go @@ -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 "/" (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 "/" 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 diff --git a/backend/internal/server/middleware_test.go b/backend/internal/server/middleware_test.go index 7a01b53..6436515 100644 --- a/backend/internal/server/middleware_test.go +++ b/backend/internal/server/middleware_test.go @@ -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) + } + }) + } +} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 29b2385..9e61c48 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -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()) diff --git a/backend/internal/session/platform.go b/backend/internal/session/platform.go new file mode 100644 index 0000000..39657ac --- /dev/null +++ b/backend/internal/session/platform.go @@ -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 +} diff --git a/backend/internal/session/service.go b/backend/internal/session/service.go index d517462..637ebb6 100644 --- a/backend/internal/session/service.go +++ b/backend/internal/session/service.go @@ -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 } diff --git a/backend/internal/session/store.go b/backend/internal/session/store.go index 4f595bf..0a6db60 100644 --- a/backend/internal/session/store.go +++ b/backend/internal/session/store.go @@ -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) +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1e6ae33..1dccaa8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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`** + (`/`) 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) | diff --git a/docs/PAYMENTS.md b/docs/PAYMENTS.md index 12aa3f2..e1e61f1 100644 --- a/docs/PAYMENTS.md +++ b/docs/PAYMENTS.md @@ -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 diff --git a/docs/PAYMENTS_ru.md b/docs/PAYMENTS_ru.md index 134629c..cc06a06 100644 --- a/docs/PAYMENTS_ru.md +++ b/docs/PAYMENTS_ru.md @@ -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. Приём платежей diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index 33091bd..f777c63 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -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 ("/", 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. diff --git a/gateway/internal/backendclient/client.go b/gateway/internal/backendclient/client.go index dd7c6b5..9da6bfa 100644 --- a/gateway/internal/backendclient/client.go +++ b/gateway/internal/backendclient/client.go @@ -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 +// ("/", 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) diff --git a/gateway/internal/backendclient/client_test.go b/gateway/internal/backendclient/client_test.go index 201478d..68ea42b 100644 --- a/gateway/internal/backendclient/client_test.go +++ b/gateway/internal/backendclient/client_test.go @@ -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") + } + }) +} diff --git a/gateway/internal/connectsrv/server.go b/gateway/internal/connectsrv/server.go index 1863390..704de92 100644 --- a/gateway/internal/connectsrv/server.go +++ b/gateway/internal/connectsrv/server.go @@ -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 " header, diff --git a/gateway/internal/session/cache.go b/gateway/internal/session/cache.go index 061b4f3..4d48216 100644 --- a/gateway/internal/session/cache.go +++ b/gateway/internal/session/cache.go @@ -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 ("/", 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 diff --git a/gateway/internal/session/cache_test.go b/gateway/internal/session/cache_test.go index 8dc1607..b646c27 100644 --- a/gateway/internal/session/cache_test.go +++ b/gateway/internal/session/cache_test.go @@ -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) } diff --git a/gateway/internal/transcode/transcode.go b/gateway/internal/transcode/transcode.go index 15decd5..0e2b893 100644 --- a/gateway/internal/transcode/transcode.go +++ b/gateway/internal/transcode/transcode.go @@ -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 } diff --git a/gateway/internal/vkauth/vkauth.go b/gateway/internal/vkauth/vkauth.go index 70e602f..ad106f7 100644 --- a/gateway/internal/vkauth/vkauth.go +++ b/gateway/internal/vkauth/vkauth.go @@ -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 } diff --git a/gateway/internal/vkauth/vkauth_test.go b/gateway/internal/vkauth/vkauth_test.go index 9986081..4973d24 100644 --- a/gateway/internal/vkauth/vkauth_test.go +++ b/gateway/internal/vkauth/vkauth_test.go @@ -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 diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index 3cc877e..ff8b585 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -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 diff --git a/pkg/fbs/scrabblefb/EmailLoginRequest.go b/pkg/fbs/scrabblefb/EmailLoginRequest.go index b98b8e5..38b75d2 100644 --- a/pkg/fbs/scrabblefb/EmailLoginRequest.go +++ b/pkg/fbs/scrabblefb/EmailLoginRequest.go @@ -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() } diff --git a/pkg/fbs/scrabblefb/GuestLoginRequest.go b/pkg/fbs/scrabblefb/GuestLoginRequest.go index dbdb026..0c89fef 100644 --- a/pkg/fbs/scrabblefb/GuestLoginRequest.go +++ b/pkg/fbs/scrabblefb/GuestLoginRequest.go @@ -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() } diff --git a/pkg/fbs/scrabblefb/TelegramLoginRequest.go b/pkg/fbs/scrabblefb/TelegramLoginRequest.go index d37245c..b06bae1 100644 --- a/pkg/fbs/scrabblefb/TelegramLoginRequest.go +++ b/pkg/fbs/scrabblefb/TelegramLoginRequest.go @@ -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() } diff --git a/ui/src/gen/fbs/scrabblefb/email-login-request.ts b/ui/src/gen/fbs/scrabblefb/email-login-request.ts index c5e2332..7ad6e89 100644 --- a/ui/src/gen/fbs/scrabblefb/email-login-request.ts +++ b/ui/src/gen/fbs/scrabblefb/email-login-request.ts @@ -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); } } diff --git a/ui/src/gen/fbs/scrabblefb/guest-login-request.ts b/ui/src/gen/fbs/scrabblefb/guest-login-request.ts index a059036..ecacc3a 100644 --- a/ui/src/gen/fbs/scrabblefb/guest-login-request.ts +++ b/ui/src/gen/fbs/scrabblefb/guest-login-request.ts @@ -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); } } diff --git a/ui/src/gen/fbs/scrabblefb/telegram-login-request.ts b/ui/src/gen/fbs/scrabblefb/telegram-login-request.ts index f883033..46720ad 100644 --- a/ui/src/gen/fbs/scrabblefb/telegram-login-request.ts +++ b/ui/src/gen/fbs/scrabblefb/telegram-login-request.ts @@ -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); } } diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts index f0f716a..c66b61e 100644 --- a/ui/src/lib/codec.test.ts +++ b/ui/src/lib/codec.test.ts @@ -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)), diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index dc8657d..57d6fa4 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -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)); } diff --git a/ui/src/lib/platform.test.ts b/ui/src/lib/platform.test.ts new file mode 100644 index 0000000..1010cd0 --- /dev/null +++ b/ui/src/lib/platform.test.ts @@ -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'); + }); +}); diff --git a/ui/src/lib/platform.ts b/ui/src/lib/platform.ts new file mode 100644 index 0000000..101beae --- /dev/null +++ b/ui/src/lib/platform.ts @@ -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()); +} diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index 0273a3b..0737337 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -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() {