feat(payments): trusted platform signal on the session
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 1m7s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m42s

Record the execution platform (kind vk|telegram|direct + device subtype
ios|android|web) on each session, captured at creation and carried
gateway->backend as a trusted X-Platform header, so the upcoming
store-compliance gate has an unforgeable execution context.

- backend.sessions gains nullable platform_kind/platform_subtype columns
  (migration 00011, CHECK-constrained, jet regenerated); session.Platform
  captures them at mint, resolve returns them, middleware exposes platform(c).
  kind is derived from the establish endpoint, never a client field; the
  account-merge session mint inherits the caller's platform.
- gateway derives the platform (VK subtype from the signed vk_platform via
  vkauth, Telegram/direct best-effort from the client) and injects X-Platform
  on every authenticated backend call through the request context.
- ui submits a best-effort device subtype on the telegram/guest/email login
  requests (new FBS subtype field); VK is server-derived from the signed params.
- an unattributed session is untrusted (view-only); VK/TG self-heal on the next
  cold-start re-mint, direct/email on re-login.

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