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
@@ -13,11 +13,13 @@ import (
)
type Sessions struct {
SessionID uuid.UUID `sql:"primary_key"`
AccountID uuid.UUID
TokenHash string
Status string
CreatedAt time.Time
LastSeenAt *time.Time
RevokedAt *time.Time
SessionID uuid.UUID `sql:"primary_key"`
AccountID uuid.UUID
TokenHash string
Status string
CreatedAt time.Time
LastSeenAt *time.Time
RevokedAt *time.Time
PlatformKind *string
PlatformSubtype *string
}
@@ -17,13 +17,15 @@ type sessionsTable struct {
postgres.Table
// Columns
SessionID postgres.ColumnString
AccountID postgres.ColumnString
TokenHash postgres.ColumnString
Status postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
LastSeenAt postgres.ColumnTimestampz
RevokedAt postgres.ColumnTimestampz
SessionID postgres.ColumnString
AccountID postgres.ColumnString
TokenHash postgres.ColumnString
Status postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
LastSeenAt postgres.ColumnTimestampz
RevokedAt postgres.ColumnTimestampz
PlatformKind postgres.ColumnString
PlatformSubtype postgres.ColumnString
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
@@ -65,29 +67,33 @@ func newSessionsTable(schemaName, tableName, alias string) *SessionsTable {
func newSessionsTableImpl(schemaName, tableName, alias string) sessionsTable {
var (
SessionIDColumn = postgres.StringColumn("session_id")
AccountIDColumn = postgres.StringColumn("account_id")
TokenHashColumn = postgres.StringColumn("token_hash")
StatusColumn = postgres.StringColumn("status")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
LastSeenAtColumn = postgres.TimestampzColumn("last_seen_at")
RevokedAtColumn = postgres.TimestampzColumn("revoked_at")
allColumns = postgres.ColumnList{SessionIDColumn, AccountIDColumn, TokenHashColumn, StatusColumn, CreatedAtColumn, LastSeenAtColumn, RevokedAtColumn}
mutableColumns = postgres.ColumnList{AccountIDColumn, TokenHashColumn, StatusColumn, CreatedAtColumn, LastSeenAtColumn, RevokedAtColumn}
defaultColumns = postgres.ColumnList{StatusColumn, CreatedAtColumn}
SessionIDColumn = postgres.StringColumn("session_id")
AccountIDColumn = postgres.StringColumn("account_id")
TokenHashColumn = postgres.StringColumn("token_hash")
StatusColumn = postgres.StringColumn("status")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
LastSeenAtColumn = postgres.TimestampzColumn("last_seen_at")
RevokedAtColumn = postgres.TimestampzColumn("revoked_at")
PlatformKindColumn = postgres.StringColumn("platform_kind")
PlatformSubtypeColumn = postgres.StringColumn("platform_subtype")
allColumns = postgres.ColumnList{SessionIDColumn, AccountIDColumn, TokenHashColumn, StatusColumn, CreatedAtColumn, LastSeenAtColumn, RevokedAtColumn, PlatformKindColumn, PlatformSubtypeColumn}
mutableColumns = postgres.ColumnList{AccountIDColumn, TokenHashColumn, StatusColumn, CreatedAtColumn, LastSeenAtColumn, RevokedAtColumn, PlatformKindColumn, PlatformSubtypeColumn}
defaultColumns = postgres.ColumnList{StatusColumn, CreatedAtColumn}
)
return sessionsTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
SessionID: SessionIDColumn,
AccountID: AccountIDColumn,
TokenHash: TokenHashColumn,
Status: StatusColumn,
CreatedAt: CreatedAtColumn,
LastSeenAt: LastSeenAtColumn,
RevokedAt: RevokedAtColumn,
SessionID: SessionIDColumn,
AccountID: AccountIDColumn,
TokenHash: TokenHashColumn,
Status: StatusColumn,
CreatedAt: CreatedAtColumn,
LastSeenAt: LastSeenAtColumn,
RevokedAt: RevokedAtColumn,
PlatformKind: PlatformKindColumn,
PlatformSubtype: PlatformSubtypeColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
@@ -0,0 +1,23 @@
-- Record the trusted execution platform on a session: platform_kind (the wrapper the
-- session was established through — vk/telegram/direct) plus platform_subtype (the device
-- family — ios/android/web). Both are nullable: a session minted before this migration, or
-- one the gateway could not attribute, carries NULL and is treated as untrusted (view-only)
-- by the payments compliance gate. kind is derived server-side from the validated establish
-- path (never a client field); the subtype is trustworthy only for VK (vk_platform rides
-- inside the signed launch params) and best-effort for telegram/direct.
--
-- Expand-contract: the columns are additive and nullable, so a backend image rollback stays
-- DB-safe (older code neither writes nor reads them). The table shape changes, so the
-- generated go-jet model IS regenerated.
-- +goose Up
ALTER TABLE backend.sessions ADD COLUMN platform_kind text;
ALTER TABLE backend.sessions ADD COLUMN platform_subtype text;
ALTER TABLE backend.sessions ADD CONSTRAINT sessions_platform_kind_chk CHECK ((platform_kind IS NULL) OR (platform_kind = ANY (ARRAY['vk'::text, 'telegram'::text, 'direct'::text])));
ALTER TABLE backend.sessions ADD CONSTRAINT sessions_platform_subtype_chk CHECK ((platform_subtype IS NULL) OR (platform_subtype = ANY (ARRAY['ios'::text, 'android'::text, 'web'::text])));
-- +goose Down
ALTER TABLE backend.sessions DROP CONSTRAINT sessions_platform_subtype_chk;
ALTER TABLE backend.sessions DROP CONSTRAINT sessions_platform_kind_chk;
ALTER TABLE backend.sessions DROP COLUMN platform_subtype;
ALTER TABLE backend.sessions DROP COLUMN platform_kind;