From ef2c2d1eb9dfa95f4bf609737630c5c7f354fb32 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 22 Jun 2026 18:43:24 +0200 Subject: [PATCH] feat(account): seed the time zone from the client's detected offset at creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new account's time_zone defaulted to 'UTC' until the player saved a profile, so the robot's sleep window and the turn-timeout away-window sweeper — both anchored to the account zone via account.ResolveZone — ran on UTC for every fresh player, skewing robot-game timing until a manual Settings save. Seed the zone at creation instead, from the client's detected "±HH:MM" offset. - Carry browser_tz on the three account-creating auth requests (TelegramLoginRequest, GuestLoginRequest, EmailRequestRequest — the email account is provisioned at the code-request step, not at login) through the fbs envelope (+ Go/TS codegen), the gateway transcode + backend client, and the backend auth handlers into ProvisionTelegram / ProvisionGuest / ProvisionEmail. - create() now writes time_zone explicitly: the validated detected offset, or 'UTC' (equal to the column default) when absent or malformed — deterministic, never guessed. The column is already NOT NULL DEFAULT 'UTC', so no migration is needed and existing accounts keep 'UTC'. An existing account is never overwritten on re-login. - A detected zero offset is stored as "+00:00" (the zone is known and equals UTC), distinct from the "UTC" default that means "unknown" — which the feedback console's three-zone Filed display already reflects. - Guard the guest handler against an empty payload (the bootstrap historically carried none) so it degrades to no-seed rather than panicking in GetRootAs*. - Tests: zone seeding across Telegram/guest/email plus the "+00:00"/malformed/empty cases and the not-overwrite rule; codec round-trip for the three auth encoders. ARCHITECTURE + FUNCTIONAL(+ru) updated. --- backend/internal/account/account.go | 64 +++++++++++++---- backend/internal/account/email.go | 8 ++- backend/internal/inttest/account_test.go | 70 ++++++++++++++++--- backend/internal/inttest/admin_test.go | 2 +- backend/internal/inttest/chat_access_test.go | 2 +- backend/internal/inttest/email_test.go | 7 +- backend/internal/inttest/helpers.go | 2 +- .../internal/inttest/suspension_gate_test.go | 2 +- backend/internal/inttest/userlist_test.go | 4 +- backend/internal/server/handlers_auth.go | 31 ++++++-- docs/ARCHITECTURE.md | 6 +- docs/FUNCTIONAL.md | 3 +- docs/FUNCTIONAL_ru.md | 5 +- gateway/internal/backendclient/api.go | 23 +++--- gateway/internal/transcode/transcode.go | 15 ++-- pkg/fbs/scrabble.fbs | 19 +++-- pkg/fbs/scrabblefb/EmailRequestRequest.go | 13 +++- pkg/fbs/scrabblefb/GuestLoginRequest.go | 13 +++- pkg/fbs/scrabblefb/TelegramLoginRequest.go | 13 +++- .../fbs/scrabblefb/email-request-request.ts | 16 ++++- .../gen/fbs/scrabblefb/guest-login-request.ts | 16 ++++- .../fbs/scrabblefb/telegram-login-request.ts | 16 ++++- ui/src/lib/codec.test.ts | 23 ++++++ ui/src/lib/codec.ts | 12 +++- ui/src/lib/transport.ts | 6 +- 25 files changed, 312 insertions(+), 79 deletions(-) diff --git a/backend/internal/account/account.go b/backend/internal/account/account.go index 2c79cda..1591f2d 100644 --- a/backend/internal/account/account.go +++ b/backend/internal/account/account.go @@ -119,6 +119,16 @@ func (s *Store) ProvisionByIdentity(ctx context.Context, kind, externalID string return s.provision(ctx, kind, externalID, provisionSeed{}) } +// ProvisionEmail returns the account owning the email identity externalID, creating +// it (unconfirmed) on first contact with browserTZ — the client's detected "±HH:MM" +// UTC offset — seeded into its time zone. Like ProvisionByIdentity it is race-safe +// and leaves an existing account untouched, so a returning user's saved zone is never +// overwritten. The email account is created here (the code-request step), not at the +// later login, so this is where its zone is seeded. +func (s *Store) ProvisionEmail(ctx context.Context, externalID, browserTZ string) (Account, error) { + return s.provision(ctx, KindEmail, externalID, provisionSeed{timeZone: seedZone(browserTZ)}) +} + // ProvisionRobot provisions (or finds) the durable account backing a robot pool // member: a KindRobot identity carrying displayName, with chat blocked but friend // requests NOT blocked — a request to a robot is accepted as pending and, since the @@ -160,7 +170,7 @@ func (s *Store) ProvisionRobot(ctx context.Context, externalID, displayName stri // is never overwritten. The created flag lets the auth handler re-evaluate moderated- // chat write access on first registration — the path of a user who joined the chat // before registering, whom no chat_member event covers. -func (s *Store) ProvisionTelegram(ctx context.Context, externalID, languageCode, username, firstName string) (Account, bool, error) { +func (s *Store) ProvisionTelegram(ctx context.Context, externalID, languageCode, username, firstName, browserTZ string) (Account, bool, error) { // Pre-check whether the identity already exists so the caller can act on first // contact. A race with a concurrent create only over- or under-reports created for // that one call, which the idempotent chat-access re-evaluation tolerates. @@ -169,7 +179,9 @@ func (s *Store) ProvisionTelegram(ctx context.Context, externalID, languageCode, if err != nil && !created { return Account{}, false, err } - acc, err := s.provision(ctx, KindTelegram, externalID, telegramSeed(languageCode, username, firstName)) + seed := telegramSeed(languageCode, username, firstName) + seed.timeZone = seedZone(browserTZ) + acc, err := s.provision(ctx, KindTelegram, externalID, seed) return acc, created, err } @@ -197,12 +209,24 @@ func (s *Store) provision(ctx context.Context, kind, externalID string, seed pro } // provisionSeed carries the optional create-time profile seed for a brand-new -// account (Telegram first contact). Empty fields fall back to the accounts table -// defaults, so an unknown language keeps the 'en' default and an empty name keeps -// the ” default. +// account (first contact). Empty fields fall back to the accounts table defaults, +// so an unknown language keeps the 'en' default, an empty name keeps the ” default +// and an empty time zone keeps the 'UTC' default. type provisionSeed struct { preferredLanguage string displayName string + timeZone string +} + +// seedZone returns browserTZ when it is a well-formed zone to persist at account +// creation (a "±HH:MM" offset or a loadable IANA name), else "" so the new account +// falls back to the accounts table's 'UTC' default. The client reports the device's +// detected offset deterministically; a bad value is dropped rather than guessed at. +func seedZone(browserTZ string) string { + if validZone(browserTZ) { + return browserTZ + } + return "" } // telegramSeed derives the create-time seed from Telegram launch fields: a @@ -368,16 +392,22 @@ func (s *Store) create(ctx context.Context, kind, externalID string, seed provis var created Account err = withTx(ctx, s.db, func(tx *sql.Tx) error { - // Seed the new row's display name and language (Telegram first contact); an - // empty seed reproduces the table defaults ('' and 'en') the other callers - // relied on, so their behaviour is unchanged. + // Seed the new row's display name, language and time zone (first contact); an + // empty seed reproduces the table defaults ('', 'en' and 'UTC') the other callers + // relied on, so their behaviour is unchanged. time_zone is written explicitly (the + // detected offset, or 'UTC' equal to the column default) so a seeded zone lands at + // creation while an unseeded one stays UTC. lang := seed.preferredLanguage if lang == "" { lang = "en" } + tz := seed.timeZone + if tz == "" { + tz = "UTC" + } insertAccount := table.Accounts. - INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.PreferredLanguage). - VALUES(accountID, seed.displayName, lang). + INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.PreferredLanguage, table.Accounts.TimeZone). + VALUES(accountID, seed.displayName, lang, tz). RETURNING(table.Accounts.AllColumns) var row model.Accounts @@ -416,15 +446,21 @@ const guestDisplayName = "Guest" // ProvisionGuest creates a fresh ephemeral guest account: a durable row carrying // no identity, flagged is_guest, so it can hold a session and a game seat (both // foreign-key the accounts table) while being excluded from statistics, friends -// and history. Guests are not reused — each bootstrap mints a new account. -func (s *Store) ProvisionGuest(ctx context.Context) (Account, error) { +// and history. Guests are not reused — each bootstrap mints a new account. browserTZ +// (the client's detected "±HH:MM" UTC offset) seeds the guest's time zone, falling +// back to the 'UTC' default when empty or malformed. +func (s *Store) ProvisionGuest(ctx context.Context, browserTZ string) (Account, error) { accountID, err := uuid.NewV7() if err != nil { return Account{}, fmt.Errorf("account: new guest id: %w", err) } + tz := seedZone(browserTZ) + if tz == "" { + tz = "UTC" + } stmt := table.Accounts. - INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.IsGuest). - VALUES(accountID, guestDisplayName, true). + INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.IsGuest, table.Accounts.TimeZone). + VALUES(accountID, guestDisplayName, true, tz). RETURNING(table.Accounts.AllColumns) var row model.Accounts diff --git a/backend/internal/account/email.go b/backend/internal/account/email.go index 4efbbfe..ae1fb7f 100644 --- a/backend/internal/account/email.go +++ b/backend/internal/account/email.go @@ -131,13 +131,15 @@ func (s *EmailService) ConfirmCode(ctx context.Context, accountID uuid.UUID, ema // the unauthenticated email-login entry point and, unlike RequestCode, // does not refuse an already-confirmed email — that is the ordinary returning-user // login. The code is mailed to the address, so only its real owner can complete -// the login. It returns the target account id for the subsequent LoginWithCode. -func (s *EmailService) RequestLoginCode(ctx context.Context, email string) (uuid.UUID, error) { +// the login. On first contact browserTZ (the client's detected "±HH:MM" UTC offset) +// seeds the new account's time zone. It returns the target account id for the +// subsequent LoginWithCode. +func (s *EmailService) RequestLoginCode(ctx context.Context, email, browserTZ string) (uuid.UUID, error) { addr, err := normalizeEmail(email) if err != nil { return uuid.UUID{}, err } - acc, err := s.store.ProvisionByIdentity(ctx, KindEmail, addr) + acc, err := s.store.ProvisionEmail(ctx, addr, browserTZ) if err != nil { return uuid.UUID{}, err } diff --git a/backend/internal/inttest/account_test.go b/backend/internal/inttest/account_test.go index 67e002c..253f7ab 100644 --- a/backend/internal/inttest/account_test.go +++ b/backend/internal/inttest/account_test.go @@ -110,15 +110,15 @@ func identityConfirmed(t *testing.T, kind, externalID string) bool { } // TestProvisionTelegramSeedsNewAccountOnly checks that Telegram first contact -// seeds the new account's language and display name from the launch fields, -// defaults the in-app-only flag on, and never overwrites an existing account on a -// later login (language seeding). +// seeds the new account's language, display name and time zone from the launch +// fields / detected offset, defaults the in-app-only flag on, and never overwrites +// an existing account on a later login (language and zone seeding). func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) { ctx := context.Background() store := account.NewStore(testDB) ext := "tg-" + uuid.NewString() - acc, created, err := store.ProvisionTelegram(ctx, ext, "ru-RU", "thehandle", "Иван") + acc, created, err := store.ProvisionTelegram(ctx, ext, "ru-RU", "thehandle", "Иван", "+03:00") if err != nil { t.Fatalf("provision telegram: %v", err) } @@ -131,12 +131,15 @@ func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) { if acc.DisplayName != "Иван" { t.Errorf("DisplayName = %q, want Иван", acc.DisplayName) } + if acc.TimeZone != "+03:00" { + t.Errorf("TimeZone = %q, want the seeded +03:00", acc.TimeZone) + } if !acc.NotificationsInAppOnly { t.Error("NotificationsInAppOnly should default to true") } // A later login with different fields returns the same account, unchanged. - again, created, err := store.ProvisionTelegram(ctx, ext, "en", "other", "Other") + again, created, err := store.ProvisionTelegram(ctx, ext, "en", "other", "Other", "+09:00") if err != nil { t.Fatalf("re-provision telegram: %v", err) } @@ -146,8 +149,53 @@ func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) { if again.ID != acc.ID { t.Errorf("re-provision id = %s, want %s", again.ID, acc.ID) } - if again.PreferredLanguage != "ru" || again.DisplayName != "Иван" { - t.Errorf("existing account overwritten: lang=%q name=%q", again.PreferredLanguage, again.DisplayName) + if again.PreferredLanguage != "ru" || again.DisplayName != "Иван" || again.TimeZone != "+03:00" { + t.Errorf("existing account overwritten: lang=%q name=%q tz=%q", again.PreferredLanguage, again.DisplayName, again.TimeZone) + } +} + +// TestProvisionSeedsTimeZone checks the create-time time-zone seed across paths: a +// valid detected offset is stored verbatim (even "+00:00", which is deliberately +// distinct from the unset "UTC" default), a guest is seeded the same way, and a +// missing or malformed offset falls back to the "UTC" column default rather than +// being guessed at. +func TestProvisionSeedsTimeZone(t *testing.T) { + ctx := context.Background() + store := account.NewStore(testDB) + + // A detected zero offset is written as "+00:00" — we record that the zone was + // detected (and equals UTC), distinct from the "UTC" default meaning "unknown". + utcDetected, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Zero", "+00:00") + if err != nil { + t.Fatalf("provision telegram +00:00: %v", err) + } + if utcDetected.TimeZone != "+00:00" { + t.Errorf("TimeZone = %q, want the seeded +00:00", utcDetected.TimeZone) + } + + // A malformed offset is dropped: the account keeps the UTC default. + bad, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Bad", "not-a-zone") + if err != nil { + t.Fatalf("provision telegram bad tz: %v", err) + } + if bad.TimeZone != "UTC" { + t.Errorf("TimeZone = %q, want UTC fallback for a malformed offset", bad.TimeZone) + } + + // A guest is seeded its detected offset; an empty one keeps the UTC default. + guest, err := store.ProvisionGuest(ctx, "-05:30") + if err != nil { + t.Fatalf("provision guest: %v", err) + } + if guest.TimeZone != "-05:30" { + t.Errorf("guest TimeZone = %q, want the seeded -05:30", guest.TimeZone) + } + plainGuest, err := store.ProvisionGuest(ctx, "") + if err != nil { + t.Fatalf("provision plain guest: %v", err) + } + if plainGuest.TimeZone != "UTC" { + t.Errorf("plain guest TimeZone = %q, want UTC default", plainGuest.TimeZone) } } @@ -156,7 +204,7 @@ func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) { // language CHECK. func TestProvisionTelegramUnknownLanguageDefaults(t *testing.T) { ctx := context.Background() - acc, _, err := account.NewStore(testDB).ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "fr", "", "") + acc, _, err := account.NewStore(testDB).ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "fr", "", "", "") if err != nil { t.Fatalf("provision telegram: %v", err) } @@ -172,7 +220,7 @@ func TestProvisionTelegramUnknownLanguageDefaults(t *testing.T) { func TestHighRateFlagRoundTrip(t *testing.T) { ctx := context.Background() store := account.NewStore(testDB) - acc, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Player") + acc, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Player", "") if err != nil { t.Fatalf("provision telegram: %v", err) } @@ -228,7 +276,7 @@ func TestIdentityExternalID(t *testing.T) { ctx := context.Background() store := account.NewStore(testDB) ext := "tg-" + uuid.NewString() - acc, _, err := store.ProvisionTelegram(ctx, ext, "en", "", "Tg User") + acc, _, err := store.ProvisionTelegram(ctx, ext, "en", "", "Tg User", "") if err != nil { t.Fatalf("provision telegram: %v", err) } @@ -253,7 +301,7 @@ func TestIdentityExternalID(t *testing.T) { func TestNotificationsInAppOnlyRoundTrip(t *testing.T) { ctx := context.Background() store := account.NewStore(testDB) - acc, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Player") + acc, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Player", "") if err != nil { t.Fatalf("provision telegram: %v", err) } diff --git a/backend/internal/inttest/admin_test.go b/backend/internal/inttest/admin_test.go index 46f3689..a6c27b1 100644 --- a/backend/internal/inttest/admin_test.go +++ b/backend/internal/inttest/admin_test.go @@ -222,7 +222,7 @@ func TestConsoleGameDetailRobotSchedule(t *testing.T) { func TestConsoleThrottledViewAndFlagClear(t *testing.T) { ctx := context.Background() accounts := account.NewStore(testDB) - acc, _, err := accounts.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Throttled Player") + acc, _, err := accounts.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Throttled Player", "") if err != nil { t.Fatalf("provision: %v", err) } diff --git a/backend/internal/inttest/chat_access_test.go b/backend/internal/inttest/chat_access_test.go index b36599b..7f94b9d 100644 --- a/backend/internal/inttest/chat_access_test.go +++ b/backend/internal/inttest/chat_access_test.go @@ -55,7 +55,7 @@ func TestChatAccessResolver(t *testing.T) { srv := server.New(":0", server.Deps{Logger: zaptest.NewLogger(t), DB: testDB, Accounts: accounts}) ext := "tg-" + uuid.NewString() - acc, _, err := accounts.ProvisionTelegram(ctx, ext, "en", "", "Chatter") + acc, _, err := accounts.ProvisionTelegram(ctx, ext, "en", "", "Chatter", "") if err != nil { t.Fatalf("provision: %v", err) } diff --git a/backend/internal/inttest/email_test.go b/backend/internal/inttest/email_test.go index 469ef30..dc9a169 100644 --- a/backend/internal/inttest/email_test.go +++ b/backend/internal/inttest/email_test.go @@ -206,7 +206,7 @@ func TestEmailLoginFlow(t *testing.T) { svc := account.NewEmailService(account.NewStore(testDB), mailer) email := "login-" + uuid.NewString() + "@example.com" - accountID, err := svc.RequestLoginCode(ctx, email) + accountID, err := svc.RequestLoginCode(ctx, email, "+02:00") if err != nil { t.Fatalf("request login code: %v", err) } @@ -225,12 +225,15 @@ func TestEmailLoginFlow(t *testing.T) { if acc.IsGuest { t.Error("an email account must be durable, not a guest") } + if acc.TimeZone != "+02:00" { + t.Errorf("TimeZone = %q, want the +02:00 seeded at the request step", acc.TimeZone) + } if !identityConfirmed(t, account.KindEmail, email) { t.Error("the email identity must be confirmed after login") } // A second login for the same email is the returning user: same account. - if _, err := svc.RequestLoginCode(ctx, email); err != nil { + if _, err := svc.RequestLoginCode(ctx, email, ""); err != nil { t.Fatalf("second request: %v", err) } acc2, err := svc.LoginWithCode(ctx, email, sixDigit.FindString(mailer.lastBody)) diff --git a/backend/internal/inttest/helpers.go b/backend/internal/inttest/helpers.go index f8ab433..96cf8e8 100644 --- a/backend/internal/inttest/helpers.go +++ b/backend/internal/inttest/helpers.go @@ -120,7 +120,7 @@ func provisionAccount(t *testing.T) uuid.UUID { // provisionGuest creates a fresh ephemeral guest account and returns its id. func provisionGuest(t *testing.T) uuid.UUID { t.Helper() - acc, err := account.NewStore(testDB).ProvisionGuest(context.Background()) + acc, err := account.NewStore(testDB).ProvisionGuest(context.Background(), "") if err != nil { t.Fatalf("provision guest: %v", err) } diff --git a/backend/internal/inttest/suspension_gate_test.go b/backend/internal/inttest/suspension_gate_test.go index 3d123af..3836bc6 100644 --- a/backend/internal/inttest/suspension_gate_test.go +++ b/backend/internal/inttest/suspension_gate_test.go @@ -38,7 +38,7 @@ func TestSuspensionGate(t *testing.T) { Accounts: accounts, }) - acc, _, err := accounts.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "ru", "", "Blocked") + acc, _, err := accounts.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "ru", "", "Blocked", "") if err != nil { t.Fatalf("provision: %v", err) } diff --git a/backend/internal/inttest/userlist_test.go b/backend/internal/inttest/userlist_test.go index 78a4e2c..cfb7ac7 100644 --- a/backend/internal/inttest/userlist_test.go +++ b/backend/internal/inttest/userlist_test.go @@ -18,7 +18,7 @@ func TestUserListFilter(t *testing.T) { st := account.NewStore(testDB) uniq := uuid.NewString() - human, _, err := st.ProvisionTelegram(ctx, "tg-"+uniq, "en", "", "Zzqxhuman") + human, _, err := st.ProvisionTelegram(ctx, "tg-"+uniq, "en", "", "Zzqxhuman", "") if err != nil { t.Fatalf("provision human: %v", err) } @@ -26,7 +26,7 @@ func TestUserListFilter(t *testing.T) { if err != nil { t.Fatalf("provision robot: %v", err) } - guest, err := st.ProvisionGuest(ctx) + guest, err := st.ProvisionGuest(ctx, "") if err != nil { t.Fatalf("provision guest: %v", err) } diff --git a/backend/internal/server/handlers_auth.go b/backend/internal/server/handlers_auth.go index 06cdf72..9074fb4 100644 --- a/backend/internal/server/handlers_auth.go +++ b/backend/internal/server/handlers_auth.go @@ -18,12 +18,14 @@ import ( // telegramAuthRequest carries the identity the connector extracted from a // validated initData payload. Username, FirstName and LanguageCode seed a -// brand-new account's display name and language (first contact only). +// brand-new account's display name and language; BrowserTZ (the client's detected +// "±HH:MM" UTC offset) seeds its time zone (first contact only). type telegramAuthRequest struct { ExternalID string `json:"external_id"` Username string `json:"username"` FirstName string `json:"first_name"` LanguageCode string `json:"language_code"` + BrowserTZ string `json:"browser_tz"` } // handleTelegramAuth provisions (or finds) the account bound to a Telegram @@ -35,7 +37,7 @@ func (s *Server) handleTelegramAuth(c *gin.Context) { abortBadRequest(c, "external_id is required") return } - acc, created, err := s.accounts.ProvisionTelegram(c.Request.Context(), req.ExternalID, req.LanguageCode, req.Username, req.FirstName) + acc, created, err := s.accounts.ProvisionTelegram(c.Request.Context(), req.ExternalID, req.LanguageCode, req.Username, req.FirstName, req.BrowserTZ) if err != nil { s.abortErr(c, err) return @@ -97,9 +99,21 @@ func (s *Server) handlePushTarget(c *gin.Context) { }) } -// handleGuestAuth provisions a fresh ephemeral guest account and mints a session. +// guestAuthRequest carries the guest bootstrap's optional time-zone seed: BrowserTZ +// (the client's detected "±HH:MM" UTC offset) is written to the new guest account's +// time zone, so robot timing is anchored to the player's zone from the first game. +type guestAuthRequest struct { + BrowserTZ string `json:"browser_tz"` +} + +// handleGuestAuth provisions a fresh ephemeral guest account and mints a session, +// seeding its time zone from the optional detected browser offset. func (s *Server) handleGuestAuth(c *gin.Context) { - acc, err := s.accounts.ProvisionGuest(c.Request.Context()) + // The body is optional: an absent or malformed one simply yields no time-zone seed + // (the account keeps the UTC default), so a bind error must not fail the bootstrap. + var req guestAuthRequest + _ = c.ShouldBindJSON(&req) + acc, err := s.accounts.ProvisionGuest(c.Request.Context(), req.BrowserTZ) if err != nil { s.abortErr(c, err) return @@ -107,9 +121,12 @@ func (s *Server) handleGuestAuth(c *gin.Context) { s.mintSession(c, acc) } -// emailRequest is an email-login code request. +// emailRequest is an email-login code request. BrowserTZ (the client's detected +// "±HH:MM" UTC offset) seeds the time zone of an account provisioned here on first +// contact (the email account is created at the request step, not at login). type emailRequest struct { - Email string `json:"email"` + Email string `json:"email"` + BrowserTZ string `json:"browser_tz"` } // handleEmailRequest issues a login confirm-code to the email. It always reports @@ -121,7 +138,7 @@ func (s *Server) handleEmailRequest(c *gin.Context) { abortBadRequest(c, "email is required") return } - if _, err := s.emails.RequestLoginCode(c.Request.Context(), req.Email); err != nil { + if _, err := s.emails.RequestLoginCode(c.Request.Context(), req.Email, req.BrowserTZ); err != nil { s.abortErr(c, err) return } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2604706..3e37f7a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -649,7 +649,11 @@ in either direction (the enqueue excludes the caller's `BlockedWith` set); separators (no leading/trailing/adjacent separators, ≤ 32 runes); the timezone is a fixed `±HH:MM` **UTC offset** (or a legacy IANA name) resolved by `account.ResolveZone` for the sweeper and the robot's sleep (a fixed offset trades DST for a simple - picker); the away window is at most **12 h** (midnight-wrap aware). Linked platform + picker), and is **seeded at account creation** from the client's detected offset — sent + on the Telegram / guest / email first-contact request — so the robot's sleep and the + away-window sweeper are anchored to the player's real zone from the first game rather + than the `UTC` default (an undetected or malformed offset keeps the default); the away + window is at most **12 h** (midnight-wrap aware). Linked platform accounts and merge are covered in §4. ## 9. Persistence diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 4b37f1a..38260fe 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -241,7 +241,8 @@ also clears the moment its recipient **takes their move**. Edit the display name (letters joined by a single space / "." / "_" separator, with an optional trailing "." or a trailing run of up to five digits, up to 32 characters and at most 5 special characters — the "." / "_" punctuation, spaces and digits aside), the timezone -(chosen as a UTC offset), the +(chosen as a UTC offset, and pre-filled from your device's detected offset when the account +is first created — so robot games are timed correctly before you ever open this form), the daily away window (on a 10-minute grid, at most 12 hours, wrapping midnight) and the block toggles. The profile form is edited inline (no separate edit mode). Linking an email or Telegram and merging accounts are covered under "Accounts, linking & diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 7ece650..fa1dcd6 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -248,8 +248,9 @@ _Вход сейчас только через провайдера, поэто Редактирование отображаемого имени (буквы, разделённые одиночным пробелом / «.» / «_», с необязательной завершающей «.» или хвостом до пяти цифр, до 32 символов и не более 5 спецсимволов — пунктуации «.» / «_», пробелы и цифры не в счёт), таймзоны (выбор смещения от -UTC), суточного окна отсутствия (away; сетка по 10 минут, не более 12 часов, с -переходом через полночь) и переключателей блокировок. Форма профиля редактируется +UTC; при создании аккаунта она подставляется из определённого смещения устройства — чтобы +игры с роботом таймились правильно ещё до открытия этой формы), суточного окна отсутствия +(away; сетка по 10 минут, не более 12 часов, с переходом через полночь) и переключателей блокировок. Форма профиля редактируется сразу (без отдельного режима редактирования). Привязка email и Telegram, а также слияние аккаунтов вынесены в раздел «Аккаунты, привязка и слияние». diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index cb039f4..62e4951 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -184,8 +184,10 @@ type ChatResp struct { } // TelegramAuth provisions/finds the Telegram account and mints a session, seeding a -// brand-new account's display name and language from the validated launch fields. -func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, username, firstName string) (SessionResp, error) { +// brand-new account's display name and language from the validated launch fields and +// its time zone from browserTz (the client's detected "±HH:MM" UTC offset; first +// contact only). +func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, username, firstName, browserTz string) (SessionResp, error) { var out SessionResp err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/telegram", "", "", map[string]string{ @@ -193,6 +195,7 @@ func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, use "language_code": languageCode, "username": username, "first_name": firstName, + "browser_tz": browserTz, }, &out) return out, err } @@ -243,17 +246,21 @@ func (c *Client) ChatAccessByUser(ctx context.Context, userID string) (ChatAcces return out, err } -// GuestAuth provisions a guest account and mints a session. -func (c *Client) GuestAuth(ctx context.Context) (SessionResp, error) { +// GuestAuth provisions a guest account and mints a session, seeding its time zone +// from browserTz (the client's detected "±HH:MM" UTC offset). +func (c *Client) GuestAuth(ctx context.Context, browserTz string) (SessionResp, error) { var out SessionResp - err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/guest", "", "", struct{}{}, &out) + err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/guest", "", "", + map[string]string{"browser_tz": browserTz}, &out) return out, err } -// EmailRequest asks the backend to mail a login code. -func (c *Client) EmailRequest(ctx context.Context, email string) error { +// EmailRequest asks the backend to mail a login code, provisioning the account on +// first contact; browserTz (the client's detected "±HH:MM" UTC offset) seeds the new +// account's time zone, since the email account is created here, not at login. +func (c *Client) EmailRequest(ctx context.Context, email, browserTz string) error { return c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/email/request", "", "", - map[string]string{"email": email}, nil) + map[string]string{"email": email, "browser_tz": browserTz}, nil) } // EmailLogin verifies a login code and mints a session. diff --git a/gateway/internal/transcode/transcode.go b/gateway/internal/transcode/transcode.go index a7b7aab..5c04035 100644 --- a/gateway/internal/transcode/transcode.go +++ b/gateway/internal/transcode/transcode.go @@ -158,7 +158,7 @@ func authTelegramHandler(backend *backendclient.Client, tg TelegramValidator) Ha if err != nil { return nil, err } - sess, err := backend.TelegramAuth(ctx, user.ExternalID, user.LanguageCode, user.Username, user.FirstName) + sess, err := backend.TelegramAuth(ctx, user.ExternalID, user.LanguageCode, user.Username, user.FirstName, string(in.BrowserTz())) if err != nil { return nil, err } @@ -167,8 +167,15 @@ func authTelegramHandler(backend *backendclient.Client, tg TelegramValidator) Ha } func authGuestHandler(backend *backendclient.Client) Handler { - return func(ctx context.Context, _ Request) ([]byte, error) { - sess, err := backend.GuestAuth(ctx) + return func(ctx context.Context, req Request) ([]byte, error) { + // The guest bootstrap historically carried no payload; the detected zone is + // optional, so an absent or empty one simply yields no time-zone seed (rather + // than panicking in GetRootAs* on a zero-length buffer). + var browserTz string + if len(req.Payload) > 0 { + browserTz = string(fb.GetRootAsGuestLoginRequest(req.Payload, 0).BrowserTz()) + } + sess, err := backend.GuestAuth(ctx, browserTz) if err != nil { return nil, err } @@ -179,7 +186,7 @@ func authGuestHandler(backend *backendclient.Client) Handler { func authEmailRequestHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { in := fb.GetRootAsEmailRequestRequest(req.Payload, 0) - if err := backend.EmailRequest(ctx, string(in.Email())); err != nil { + if err := backend.EmailRequest(ctx, string(in.Email()), string(in.BrowserTz())); err != nil { return nil, err } return encodeAck(true), nil diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index 93e8091..74f25dc 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -99,24 +99,33 @@ table MoveRecord { // --- auth (unauthenticated) --- // TelegramLoginRequest carries the platform launch data; the gateway validates -// its HMAC before forwarding the extracted identity to the backend. +// its HMAC before forwarding the extracted identity to the backend. browser_tz is +// the client's detected UTC offset ("±HH:MM"), seeded into a brand-new account's +// time zone so the robot's sleep window and the turn-timeout away window are +// anchored to the player's real zone from first contact (first contact only). table TelegramLoginRequest { init_data:string; + browser_tz:string; } // GuestLoginRequest bootstraps an ephemeral guest session. locale is an optional -// preferred-language hint. +// preferred-language hint; browser_tz is the detected UTC offset seeded into the +// guest account's time zone (see TelegramLoginRequest.browser_tz). table GuestLoginRequest { locale:string; + browser_tz:string; } -// EmailRequestRequest asks the backend to send a login confirm-code to email. +// EmailRequestRequest asks the backend to send a login confirm-code to email. It +// also provisions the account on first contact, so browser_tz (the detected UTC +// offset) is seeded into its time zone here, not at the later login step. table EmailRequestRequest { email:string; + browser_tz:string; } -// EmailLoginRequest logs in (or provisions) the account owning email, verifying -// the confirm-code. +// EmailLoginRequest logs in to the account owning email (provisioned at the +// request step), verifying the confirm-code. table EmailLoginRequest { email:string; code:string; diff --git a/pkg/fbs/scrabblefb/EmailRequestRequest.go b/pkg/fbs/scrabblefb/EmailRequestRequest.go index 3173481..567b8ba 100644 --- a/pkg/fbs/scrabblefb/EmailRequestRequest.go +++ b/pkg/fbs/scrabblefb/EmailRequestRequest.go @@ -49,12 +49,23 @@ func (rcv *EmailRequestRequest) Email() []byte { return nil } +func (rcv *EmailRequestRequest) BrowserTz() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + func EmailRequestRequestStart(builder *flatbuffers.Builder) { - builder.StartObject(1) + builder.StartObject(2) } func EmailRequestRequestAddEmail(builder *flatbuffers.Builder, email flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(email), 0) } +func EmailRequestRequestAddBrowserTz(builder *flatbuffers.Builder, browserTz flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(browserTz), 0) +} func EmailRequestRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/pkg/fbs/scrabblefb/GuestLoginRequest.go b/pkg/fbs/scrabblefb/GuestLoginRequest.go index 2d06f6e..dbdb026 100644 --- a/pkg/fbs/scrabblefb/GuestLoginRequest.go +++ b/pkg/fbs/scrabblefb/GuestLoginRequest.go @@ -49,12 +49,23 @@ func (rcv *GuestLoginRequest) Locale() []byte { return nil } +func (rcv *GuestLoginRequest) BrowserTz() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + func GuestLoginRequestStart(builder *flatbuffers.Builder) { - builder.StartObject(1) + builder.StartObject(2) } func GuestLoginRequestAddLocale(builder *flatbuffers.Builder, locale flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(locale), 0) } +func GuestLoginRequestAddBrowserTz(builder *flatbuffers.Builder, browserTz flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(browserTz), 0) +} func GuestLoginRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/pkg/fbs/scrabblefb/TelegramLoginRequest.go b/pkg/fbs/scrabblefb/TelegramLoginRequest.go index 2dccaf3..d37245c 100644 --- a/pkg/fbs/scrabblefb/TelegramLoginRequest.go +++ b/pkg/fbs/scrabblefb/TelegramLoginRequest.go @@ -49,12 +49,23 @@ func (rcv *TelegramLoginRequest) InitData() []byte { return nil } +func (rcv *TelegramLoginRequest) BrowserTz() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + func TelegramLoginRequestStart(builder *flatbuffers.Builder) { - builder.StartObject(1) + builder.StartObject(2) } func TelegramLoginRequestAddInitData(builder *flatbuffers.Builder, initData flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(initData), 0) } +func TelegramLoginRequestAddBrowserTz(builder *flatbuffers.Builder, browserTz flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(browserTz), 0) +} func TelegramLoginRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/ui/src/gen/fbs/scrabblefb/email-request-request.ts b/ui/src/gen/fbs/scrabblefb/email-request-request.ts index 73edb2d..6ecbb46 100644 --- a/ui/src/gen/fbs/scrabblefb/email-request-request.ts +++ b/ui/src/gen/fbs/scrabblefb/email-request-request.ts @@ -27,22 +27,34 @@ email(optionalEncoding?:any):string|Uint8Array|null { return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; } +browserTz():string|null +browserTz(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +browserTz(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 6); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + static startEmailRequestRequest(builder:flatbuffers.Builder) { - builder.startObject(1); + builder.startObject(2); } static addEmail(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset) { builder.addFieldOffset(0, emailOffset, 0); } +static addBrowserTz(builder:flatbuffers.Builder, browserTzOffset:flatbuffers.Offset) { + builder.addFieldOffset(1, browserTzOffset, 0); +} + static endEmailRequestRequest(builder:flatbuffers.Builder):flatbuffers.Offset { const offset = builder.endObject(); return offset; } -static createEmailRequestRequest(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset):flatbuffers.Offset { +static createEmailRequestRequest(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset, browserTzOffset:flatbuffers.Offset):flatbuffers.Offset { EmailRequestRequest.startEmailRequestRequest(builder); EmailRequestRequest.addEmail(builder, emailOffset); + EmailRequestRequest.addBrowserTz(builder, browserTzOffset); return EmailRequestRequest.endEmailRequestRequest(builder); } } diff --git a/ui/src/gen/fbs/scrabblefb/guest-login-request.ts b/ui/src/gen/fbs/scrabblefb/guest-login-request.ts index ff46bdd..a059036 100644 --- a/ui/src/gen/fbs/scrabblefb/guest-login-request.ts +++ b/ui/src/gen/fbs/scrabblefb/guest-login-request.ts @@ -27,22 +27,34 @@ locale(optionalEncoding?:any):string|Uint8Array|null { return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; } +browserTz():string|null +browserTz(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +browserTz(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 6); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + static startGuestLoginRequest(builder:flatbuffers.Builder) { - builder.startObject(1); + builder.startObject(2); } static addLocale(builder:flatbuffers.Builder, localeOffset:flatbuffers.Offset) { builder.addFieldOffset(0, localeOffset, 0); } +static addBrowserTz(builder:flatbuffers.Builder, browserTzOffset:flatbuffers.Offset) { + builder.addFieldOffset(1, browserTzOffset, 0); +} + static endGuestLoginRequest(builder:flatbuffers.Builder):flatbuffers.Offset { const offset = builder.endObject(); return offset; } -static createGuestLoginRequest(builder:flatbuffers.Builder, localeOffset:flatbuffers.Offset):flatbuffers.Offset { +static createGuestLoginRequest(builder:flatbuffers.Builder, localeOffset:flatbuffers.Offset, browserTzOffset:flatbuffers.Offset):flatbuffers.Offset { GuestLoginRequest.startGuestLoginRequest(builder); GuestLoginRequest.addLocale(builder, localeOffset); + GuestLoginRequest.addBrowserTz(builder, browserTzOffset); return GuestLoginRequest.endGuestLoginRequest(builder); } } diff --git a/ui/src/gen/fbs/scrabblefb/telegram-login-request.ts b/ui/src/gen/fbs/scrabblefb/telegram-login-request.ts index 47ba75f..f883033 100644 --- a/ui/src/gen/fbs/scrabblefb/telegram-login-request.ts +++ b/ui/src/gen/fbs/scrabblefb/telegram-login-request.ts @@ -27,22 +27,34 @@ initData(optionalEncoding?:any):string|Uint8Array|null { return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; } +browserTz():string|null +browserTz(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +browserTz(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 6); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + static startTelegramLoginRequest(builder:flatbuffers.Builder) { - builder.startObject(1); + builder.startObject(2); } static addInitData(builder:flatbuffers.Builder, initDataOffset:flatbuffers.Offset) { builder.addFieldOffset(0, initDataOffset, 0); } +static addBrowserTz(builder:flatbuffers.Builder, browserTzOffset:flatbuffers.Offset) { + builder.addFieldOffset(1, browserTzOffset, 0); +} + static endTelegramLoginRequest(builder:flatbuffers.Builder):flatbuffers.Offset { const offset = builder.endObject(); return offset; } -static createTelegramLoginRequest(builder:flatbuffers.Builder, initDataOffset:flatbuffers.Offset):flatbuffers.Offset { +static createTelegramLoginRequest(builder:flatbuffers.Builder, initDataOffset:flatbuffers.Offset, browserTzOffset:flatbuffers.Offset):flatbuffers.Offset { TelegramLoginRequest.startTelegramLoginRequest(builder); TelegramLoginRequest.addInitData(builder, initDataOffset); + TelegramLoginRequest.addBrowserTz(builder, browserTzOffset); return TelegramLoginRequest.endTelegramLoginRequest(builder); } } diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts index cfecf72..98c299c 100644 --- a/ui/src/lib/codec.test.ts +++ b/ui/src/lib/codec.test.ts @@ -19,13 +19,16 @@ import { decodeStateView, decodeStats, encodeCheckWord, + encodeEmailRequest, encodeFeedbackSubmit, encodeDraftSave, encodeEnqueue, encodeExchange, + encodeGuestLogin, encodeStateRequest, encodeSubmitPlay, encodeTarget, + encodeTelegramLogin, encodeUpdateProfile, } from './codec'; @@ -73,6 +76,26 @@ describe('codec', () => { }); }); + it('carries the detected browser zone on every account-creating auth request', () => { + const tg = fb.TelegramLoginRequest.getRootAsTelegramLoginRequest( + new ByteBuffer(encodeTelegramLogin('init-data-blob', '+03:00')), + ); + expect(tg.initData()).toBe('init-data-blob'); + expect(tg.browserTz()).toBe('+03:00'); + + const guest = fb.GuestLoginRequest.getRootAsGuestLoginRequest( + new ByteBuffer(encodeGuestLogin('ru', '-05:30')), + ); + expect(guest.locale()).toBe('ru'); + expect(guest.browserTz()).toBe('-05:30'); + + const email = fb.EmailRequestRequest.getRootAsEmailRequestRequest( + new ByteBuffer(encodeEmailRequest('a@example.com', '+00:00')), + ); + expect(email.email()).toBe('a@example.com'); + expect(email.browserTz()).toBe('+00:00'); + }); + it('round-trips a feedback submit and decodes state + unread', () => { const att = new Uint8Array([1, 2, 3, 4]); const req = fb.FeedbackSubmitRequest.getRootAsFeedbackSubmitRequest( diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index faf24b8..abc0181 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -179,27 +179,33 @@ export function encodeChatPost(gameId: string, body: string): Uint8Array { return finish(b, fb.ChatPostRequest.endChatPostRequest(b)); } -export function encodeTelegramLogin(initData: string): Uint8Array { +export function encodeTelegramLogin(initData: string, browserTz: string): Uint8Array { const b = new Builder(512); const d = b.createString(initData); + const tz = b.createString(browserTz); fb.TelegramLoginRequest.startTelegramLoginRequest(b); fb.TelegramLoginRequest.addInitData(b, d); + fb.TelegramLoginRequest.addBrowserTz(b, tz); return finish(b, fb.TelegramLoginRequest.endTelegramLoginRequest(b)); } -export function encodeGuestLogin(locale: string): Uint8Array { +export function encodeGuestLogin(locale: string, browserTz: string): Uint8Array { const b = new Builder(64); const l = b.createString(locale); + const tz = b.createString(browserTz); fb.GuestLoginRequest.startGuestLoginRequest(b); fb.GuestLoginRequest.addLocale(b, l); + fb.GuestLoginRequest.addBrowserTz(b, tz); return finish(b, fb.GuestLoginRequest.endGuestLoginRequest(b)); } -export function encodeEmailRequest(email: string): Uint8Array { +export function encodeEmailRequest(email: string, browserTz: string): Uint8Array { const b = new Builder(128); const e = b.createString(email); + const tz = b.createString(browserTz); fb.EmailRequestRequest.startEmailRequestRequest(b); fb.EmailRequestRequest.addEmail(b, e); + fb.EmailRequestRequest.addBrowserTz(b, tz); return finish(b, fb.EmailRequestRequest.endEmailRequestRequest(b)); } diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index 53b5d94..9360769 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -63,13 +63,13 @@ export function createTransport(baseUrl: string): GatewayClient { }, async authTelegram(initData) { - return codec.decodeSession(await exec('auth.telegram', codec.encodeTelegramLogin(initData))); + return codec.decodeSession(await exec('auth.telegram', codec.encodeTelegramLogin(initData, browserOffset()))); }, async authGuest(locale) { - return codec.decodeSession(await exec('auth.guest', codec.encodeGuestLogin(locale ?? ''))); + return codec.decodeSession(await exec('auth.guest', codec.encodeGuestLogin(locale ?? '', browserOffset()))); }, async authEmailRequest(email) { - await exec('auth.email.request', codec.encodeEmailRequest(email)); + await exec('auth.email.request', codec.encodeEmailRequest(email, browserOffset())); }, async authEmailLogin(email, code) { return codec.decodeSession(await exec('auth.email.login', codec.encodeEmailLogin(email, code)));