From 061366da5ad895401638bb0cd76bbf9a97499fe7 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 5 Jul 2026 22:13:21 +0200 Subject: [PATCH] feat(email): PWA login sends code only; drop admin link from alert emails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two email changes, per the owner: 1. Code-only login from an installed PWA. A login requested while running as a standalone PWA now omits the one-tap confirm link from the email — the link would open in a separate browser whose minted session cannot reach the PWA, stranding the login. The code is typed in the same window instead. The client sends a `pwa` flag (isStandalone) on the email-code request (a new FBS field, threaded through the gateway); the backend omits the deeplink when it is set, reusing the existing deletion-code link-omission path. Non-PWA browser logins keep the one-tap link. No polling, no migration. 2. Security: the operator alert digest no longer embeds the admin-console (/_gm) URL. An admin link must never travel in an email, where a mail provider could cache or index it; the operator opens the console directly. Tests: an inttest asserting the PWA login email omits the link (and a browser one keeps it); a codec round-trip of the new pwa field; the alert-digest test flipped to guard the admin link is absent. Docs: ARCHITECTURE + FUNCTIONAL (+ru). --- backend/cmd/backend/main.go | 9 ++--- backend/internal/account/email.go | 27 +++++++------ backend/internal/account/link.go | 6 +-- backend/internal/adminalert/notify.go | 17 ++++----- backend/internal/adminalert/notify_test.go | 10 +++-- backend/internal/inttest/email_test.go | 38 ++++++++++++++++--- backend/internal/server/handlers_auth.go | 6 ++- docs/ARCHITECTURE.md | 8 +++- docs/FUNCTIONAL.md | 4 +- docs/FUNCTIONAL_ru.md | 4 +- gateway/internal/backendclient/api.go | 7 ++-- gateway/internal/transcode/transcode.go | 2 +- pkg/fbs/scrabble.fbs | 5 +++ pkg/fbs/scrabblefb/EmailRequestRequest.go | 17 ++++++++- .../fbs/scrabblefb/email-request-request.ts | 14 ++++++- ui/src/lib/app.svelte.ts | 3 +- ui/src/lib/client.ts | 2 +- ui/src/lib/codec.test.ts | 3 +- ui/src/lib/codec.ts | 8 +++- ui/src/lib/mock/client.ts | 2 +- ui/src/lib/transport.ts | 4 +- 21 files changed, 139 insertions(+), 57 deletions(-) diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index c85a229..cf8a82d 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -12,7 +12,6 @@ import ( "fmt" "log" "os/signal" - "strings" "syscall" "time" @@ -216,11 +215,9 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { // Operator alert emails on new feedback / word complaints, coalesced into one digest // per interval. Inert unless a distinct admin sender and recipient are configured. if cfg.SMTP.AdminFrom != "" && cfg.SMTP.AdminTo != "" { - consoleURL := "" - if cfg.PublicBaseURL != "" { - consoleURL = strings.TrimRight(cfg.PublicBaseURL, "/") + "/_gm" - } - alerts := adminalert.New(mailer, feedbackSvc, games, cfg.SMTP.AdminFrom, cfg.SMTP.AdminTo, consoleURL, logger) + // The alert digest carries no admin-console link on purpose — an admin URL must not + // travel in an email (a mail provider could cache or index it); see adminalert.New. + alerts := adminalert.New(mailer, feedbackSvc, games, cfg.SMTP.AdminFrom, cfg.SMTP.AdminTo, logger) go alerts.Run(ctx, adminAlertInterval) logger.Info("admin alert worker started", zap.Duration("interval", adminAlertInterval)) } diff --git a/backend/internal/account/email.go b/backend/internal/account/email.go index 102bd73..ac6e578 100644 --- a/backend/internal/account/email.go +++ b/backend/internal/account/email.go @@ -105,9 +105,10 @@ func (s *EmailService) allowSend(email string) bool { // issueCode generates a fresh confirm-code and one-tap deeplink token for (accountID, // email), replaces any prior pending confirmation, and mails the branded code in -// locale; purpose selects the email wording and what verifying does. Only the SHA-256 -// hashes of the code and token are stored. -func (s *EmailService) issueCode(ctx context.Context, accountID uuid.UUID, email, purpose, locale string) error { +// locale; purpose selects the email wording and what verifying does. omitLink drops the +// one-tap deeplink from the email (account deletion, or a login requested from an installed +// PWA). Only the SHA-256 hashes of the code and token are stored. +func (s *EmailService) issueCode(ctx context.Context, accountID uuid.UUID, email, purpose, locale string, omitLink bool) error { code, codeHash, err := generateCode() if err != nil { return err @@ -119,11 +120,12 @@ func (s *EmailService) issueCode(ctx context.Context, accountID uuid.UUID, email if err := s.store.replacePendingConfirmation(ctx, accountID, email, codeHash, tokenHash, purpose, s.now().Add(emailCodeTTL)); err != nil { return err } - // Account deletion is never a one-tap link (a prefetch or a stray click must not delete - // an account): the delete code is entered in the app only, so the email omits the - // deeplink and ConfirmByToken refuses a delete token. + // Omit the one-tap deeplink when asked: for a login from an installed PWA (the link would + // open in a separate browser whose minted session cannot reach the PWA), or for account + // deletion (a prefetch or stray click must not delete an account — the delete code is entered + // in the app only, and ConfirmByToken refuses a delete token). deeplink := s.confirmURL(token, locale) - if purpose == purposeDelete { + if purpose == purposeDelete || omitLink { deeplink = "" } msg, err := renderConfirmationEmail(purpose, code, deeplink, s.baseURL, locale) @@ -175,7 +177,7 @@ func (s *EmailService) RequestCode(ctx context.Context, accountID uuid.UUID, ema } return ErrEmailTaken } - return s.issueCode(ctx, accountID, addr, purposeLink, s.accountLocale(ctx, accountID)) + return s.issueCode(ctx, accountID, addr, purposeLink, s.accountLocale(ctx, accountID), false) } // ConfirmCode verifies code for accountID and email. On success it attaches a @@ -221,8 +223,11 @@ func (s *EmailService) ConfirmCode(ctx context.Context, accountID uuid.UUID, ema // the ordinary returning-user login. The code is mailed to the address, so only its // real owner can complete the login. On first contact browserTZ (the client's // detected "±HH:MM" UTC offset) seeds the new account's time zone and language its UI -// language. It returns the target account id for the subsequent LoginWithCode. -func (s *EmailService) RequestLoginCode(ctx context.Context, email, browserTZ, language string) (uuid.UUID, error) { +// language. When pwa is set (the request came from an installed PWA) the login email omits the +// one-tap confirm link — it would open in a separate browser, out of the PWA's reach — so the +// code is entered in the same window. It returns the target account id for the subsequent +// LoginWithCode. +func (s *EmailService) RequestLoginCode(ctx context.Context, email, browserTZ, language string, pwa bool) (uuid.UUID, error) { addr, err := normalizeEmail(email) if err != nil { return uuid.UUID{}, err @@ -234,7 +239,7 @@ func (s *EmailService) RequestLoginCode(ctx context.Context, email, browserTZ, l if err != nil { return uuid.UUID{}, err } - if err := s.issueCode(ctx, acc.ID, addr, purposeLogin, language); err != nil { + if err := s.issueCode(ctx, acc.ID, addr, purposeLogin, language, pwa); err != nil { return uuid.UUID{}, err } return acc.ID, nil diff --git a/backend/internal/account/link.go b/backend/internal/account/link.go index c897e15..229c4cb 100644 --- a/backend/internal/account/link.go +++ b/backend/internal/account/link.go @@ -92,7 +92,7 @@ func (s *EmailService) RequestLinkCode(ctx context.Context, accountID uuid.UUID, if !s.allowSend(addr) { return ErrTooManyRequests } - return s.issueCode(ctx, accountID, addr, purposeLink, s.accountLocale(ctx, accountID)) + return s.issueCode(ctx, accountID, addr, purposeLink, s.accountLocale(ctx, accountID), false) } // ConfirmLink verifies code for (accountID, email) and reports the address's @@ -140,7 +140,7 @@ func (s *EmailService) RequestChangeCode(ctx context.Context, accountID uuid.UUI if !s.allowSend(addr) { return ErrTooManyRequests } - return s.issueCode(ctx, accountID, addr, purposeChange, s.accountLocale(ctx, accountID)) + return s.issueCode(ctx, accountID, addr, purposeChange, s.accountLocale(ctx, accountID), false) } // ConfirmChange verifies code for (accountID, newEmail) and atomically replaces the @@ -199,7 +199,7 @@ func (s *EmailService) RequestDeleteCode(ctx context.Context, accountID uuid.UUI if !s.allowSend(addr) { return ErrTooManyRequests } - return s.issueCode(ctx, accountID, addr, purposeDelete, s.accountLocale(ctx, accountID)) + return s.issueCode(ctx, accountID, addr, purposeDelete, s.accountLocale(ctx, accountID), false) } // VerifyDeleteCode verifies the account-deletion code against the account's own email and diff --git a/backend/internal/adminalert/notify.go b/backend/internal/adminalert/notify.go index 5f2a242..88e9b85 100644 --- a/backend/internal/adminalert/notify.go +++ b/backend/internal/adminalert/notify.go @@ -33,21 +33,21 @@ type Notifier struct { complaints ComplaintCounter from string to string - consoleURL string clock func() time.Time log *zap.Logger last time.Time } -// New constructs a Notifier. from and to are the alert sender and recipient(s); consoleURL, -// when non-empty, is the admin-console link included in the email. log may be nil. The -// watermark starts at "now", so only items arriving after start-up are reported. -func New(mailer account.Mailer, fb FeedbackCounter, cp ComplaintCounter, from, to, consoleURL string, log *zap.Logger) *Notifier { +// New constructs a Notifier. from and to are the alert sender and recipient(s); log may be +// nil. The watermark starts at "now", so only items arriving after start-up are reported. The +// digest deliberately carries no admin-console link — an admin URL must never travel in an +// email, where a mail provider could cache or index it. +func New(mailer account.Mailer, fb FeedbackCounter, cp ComplaintCounter, from, to string, log *zap.Logger) *Notifier { if log == nil { log = zap.NewNop() } return &Notifier{ - mailer: mailer, feedback: fb, complaints: cp, from: from, to: to, consoleURL: consoleURL, + mailer: mailer, feedback: fb, complaints: cp, from: from, to: to, clock: func() time.Time { return time.Now().UTC() }, log: log, last: time.Now().UTC(), } } @@ -103,10 +103,9 @@ func (n *Notifier) digest(fb, cp int) account.Message { parts = append(parts, fmt.Sprintf("%d new word complaint(s)", cp)) } summary := strings.Join(parts, ", ") + // No admin-console link in the body: an admin URL must never travel in an email (a mail + // provider could cache or index it). The operator opens the console directly. text := summary + "." - if n.consoleURL != "" { - text += "\n\nOpen the admin console: " + n.consoleURL - } return account.Message{ From: n.from, To: n.to, diff --git a/backend/internal/adminalert/notify_test.go b/backend/internal/adminalert/notify_test.go index d001bb0..83d3a2a 100644 --- a/backend/internal/adminalert/notify_test.go +++ b/backend/internal/adminalert/notify_test.go @@ -28,7 +28,7 @@ func (m *recordingMailer) Send(_ context.Context, msg account.Message) error { func TestNotifierSkipsWhenNothingNew(t *testing.T) { mailer := &recordingMailer{} - n := New(mailer, fbCounter{0}, cpCounter{0}, "alerts@erudit-game.ru", "op@x.ru", "", nil) + n := New(mailer, fbCounter{0}, cpCounter{0}, "alerts@erudit-game.ru", "op@x.ru", nil) n.tick(context.Background()) if len(mailer.sent) != 0 { t.Fatalf("sent %d emails, want 0 when nothing is new", len(mailer.sent)) @@ -37,7 +37,7 @@ func TestNotifierSkipsWhenNothingNew(t *testing.T) { func TestNotifierDigestsNewItems(t *testing.T) { mailer := &recordingMailer{} - n := New(mailer, fbCounter{2}, cpCounter{1}, "alerts@erudit-game.ru", "op@x.ru, two@x.ru", "https://erudit-game.ru/_gm", nil) + n := New(mailer, fbCounter{2}, cpCounter{1}, "alerts@erudit-game.ru", "op@x.ru, two@x.ru", nil) n.tick(context.Background()) if len(mailer.sent) != 1 { t.Fatalf("sent %d emails, want 1 digest", len(mailer.sent)) @@ -49,7 +49,9 @@ func TestNotifierDigestsNewItems(t *testing.T) { if !strings.Contains(msg.Subject, "2 new feedback") || !strings.Contains(msg.Subject, "1 new word complaint") { t.Errorf("digest subject = %q, want the feedback + complaint counts", msg.Subject) } - if !strings.Contains(msg.Text, "/_gm") { - t.Errorf("digest body = %q, want the console link", msg.Text) + // The digest must never carry an admin-console link — an admin URL in an email is a leak + // (mail providers cache/index it). + if strings.Contains(msg.Text, "/_gm") || strings.Contains(strings.ToLower(msg.Text), "admin console") { + t.Errorf("digest body = %q, must not carry an admin-console link", msg.Text) } } diff --git a/backend/internal/inttest/email_test.go b/backend/internal/inttest/email_test.go index 667edd1..9d4235a 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, "https://erudit-game.ru") email := "login-" + uuid.NewString() + "@example.com" - accountID, err := svc.RequestLoginCode(ctx, email, "+02:00", "en") + accountID, err := svc.RequestLoginCode(ctx, email, "+02:00", "en", false) if err != nil { t.Fatalf("request login code: %v", err) } @@ -233,7 +233,7 @@ func TestEmailLoginFlow(t *testing.T) { } // A second login for the same email is the returning user: same account. - if _, err := svc.RequestLoginCode(ctx, email, "", "ru"); err != nil { + if _, err := svc.RequestLoginCode(ctx, email, "", "ru", false); err != nil { t.Fatalf("second request: %v", err) } acc2, err := svc.LoginWithCode(ctx, email, sixDigit.FindString(mailer.lastBody)) @@ -255,7 +255,7 @@ func TestEmailLoginProvisionsGuestUntilConfirmed(t *testing.T) { svc := account.NewEmailService(store, mailer, "https://erudit-game.ru") email := "squat-" + uuid.NewString() + "@example.com" - id, err := svc.RequestLoginCode(ctx, email, "", "en") + id, err := svc.RequestLoginCode(ctx, email, "", "en", false) if err != nil { t.Fatalf("request login code: %v", err) } @@ -279,6 +279,34 @@ func TestEmailLoginProvisionsGuestUntilConfirmed(t *testing.T) { } } +// TestEmailLoginPWAOmitsLink: a login requested from an installed PWA (pwa=true) mails only the +// code — no one-tap confirm link, which would otherwise open in a separate browser out of the +// PWA's reach. A non-PWA request keeps the link. +func TestEmailLoginPWAOmitsLink(t *testing.T) { + ctx := context.Background() + mailer := &capturingMailer{} + svc := account.NewEmailService(account.NewStore(testDB), mailer, "https://erudit-game.ru") + + pwaEmail := "pwa-login-" + uuid.NewString() + "@example.com" + if _, err := svc.RequestLoginCode(ctx, pwaEmail, "", "en", true); err != nil { + t.Fatalf("pwa request login: %v", err) + } + if sixDigit.FindString(mailer.lastBody) == "" { + t.Errorf("pwa login mail must still carry the code, body %q", mailer.lastBody) + } + if confirmToken.MatchString(mailer.lastBody) { + t.Errorf("pwa login mail must omit the one-tap confirm link, body %q", mailer.lastBody) + } + + webEmail := "web-login-" + uuid.NewString() + "@example.com" + if _, err := svc.RequestLoginCode(ctx, webEmail, "", "en", false); err != nil { + t.Fatalf("web request login: %v", err) + } + if !confirmToken.MatchString(mailer.lastBody) { + t.Errorf("non-pwa login mail must keep the one-tap confirm link, body %q", mailer.lastBody) + } +} + // confirmToken extracts the one-tap deeplink token from the /app/#/confirm/ link // the branded email carries. var confirmToken = regexp.MustCompile(`/confirm/([A-Za-z0-9_-]+)`) @@ -301,7 +329,7 @@ func TestConfirmByTokenLogin(t *testing.T) { svc := account.NewEmailService(store, mailer, "https://erudit-game.ru") email := "tok-login-" + uuid.NewString() + "@example.com" - id, err := svc.RequestLoginCode(ctx, email, "", "en") + id, err := svc.RequestLoginCode(ctx, email, "", "en", false) if err != nil { t.Fatalf("request login: %v", err) } @@ -382,7 +410,7 @@ func TestEmailAccountSeedsDisplayName(t *testing.T) { svc := account.NewEmailService(store, &capturingMailer{}, "https://erudit-game.ru") local := "kaya-" + uuid.NewString()[:8] - id, err := svc.RequestLoginCode(ctx, local+"@example.com", "", "en") + id, err := svc.RequestLoginCode(ctx, local+"@example.com", "", "en", false) if err != nil { t.Fatalf("request login: %v", err) } diff --git a/backend/internal/server/handlers_auth.go b/backend/internal/server/handlers_auth.go index edfc8a5..364f8c0 100644 --- a/backend/internal/server/handlers_auth.go +++ b/backend/internal/server/handlers_auth.go @@ -174,6 +174,10 @@ type emailRequest struct { Email string `json:"email"` BrowserTZ string `json:"browser_tz"` Language string `json:"language"` + // Pwa marks a request from an installed PWA (standalone display mode): the login email then + // omits the one-tap confirm link so the code is typed in the same window (the link would open + // in a separate browser whose session cannot reach the PWA). See EmailService.RequestLoginCode. + Pwa bool `json:"pwa"` } // handleEmailRequest issues a login confirm-code to the email. It always reports @@ -185,7 +189,7 @@ func (s *Server) handleEmailRequest(c *gin.Context) { abortBadRequest(c, "email is required") return } - if _, err := s.emails.RequestLoginCode(c.Request.Context(), req.Email, req.BrowserTZ, req.Language); err != nil { + if _, err := s.emails.RequestLoginCode(c.Request.Context(), req.Email, req.BrowserTZ, req.Language, req.Pwa); err != nil { s.abortErr(c, err) return } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index eeeb5ea..f1e113f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -244,7 +244,9 @@ arrive from a platform rather than completing a mandatory registration). 256-bit token stored only as its SHA-256, 12-hour TTL): a login mints a session in the browser that opens it (magic-link), a link confirms the identity and emits a `notify` profile-refresh to the in-app session, a change switches the account's email in place, - and a would-be merge is deferred to the interactive flow. The confirm runs on load; the token rides the URL fragment (never + and a would-be merge is deferred to the interactive flow. A login requested from an installed **PWA** omits the deeplink + entirely — its email carries the code only, typed in the same window, since the link would open + in a separate browser whose minted session cannot reach the PWA. The confirm runs on load; the token rides the URL fragment (never sent to the server), so a plain link prefetch cannot reach it, and the manual six-digit code is the fallback if an aggressive scanner runs the page. An **email-login** account is created flagged `is_guest` and stays reapable until the @@ -1041,7 +1043,9 @@ link — misses the event; while an add-email confirmation is pending the client (`internal/adminalert`, started from `main`) emails the operator (`ADMIN_EMAIL`, from a distinct `SMTP_RELAY_ADMIN_FROM`) when new player feedback or word complaints arrive, **coalescing** a burst into one digest per interval; both recipients may be several - comma-separated addresses. Both paths are inert unless configured. + comma-separated addresses. The digest carries **no admin-console link** — an admin URL must + never travel in an email, where a mail provider could cache or index it; the operator opens the + console directly. Both paths are inert unless configured. - Per-request server-side timing via gin middleware from day one (the access log carries method, route, status, latency and the active trace id). A client-measured RTT piggybacked on the next request is a later enhancement. diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 1d96e2e..39ffc2b 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -76,7 +76,9 @@ per address), so a mistyped address or an impatient tap cannot flood an inbox. T also carries a **one-tap link** that confirms the address — or signs the player straight in — in a single tap; opening it in another browser reflects in the app at once, and a mail scanner that merely fetches the link cannot reach it — the token rides the URL -fragment, which is never sent to the server. +fragment, which is never sent to the server. A sign-in requested from an **installed PWA** omits +this link: the email carries only the code, entered in the same window, since the link would open +in a separate browser the app cannot reach. Telegram runs a **single bot**: every player uses the same bot, and all of its chat and out-of-app notifications are written in the player's own **interface language** (en/ru). A separate optional **promo bot** can run alongside the diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index cd849b4..1c6700e 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -80,7 +80,9 @@ launch-параметрам VK (их проверяет gateway), и при пе не завалили почтовый ящик. В письме также есть **ссылка одного нажатия**, которая подтверждает адрес — или сразу выполняет вход — в один тап; открытие её в другом браузере тут же отражается в приложении, а почтовый сканер, который просто загружает ссылку, до токена не дотянется — -он едет в URL-фрагменте, который не уходит на сервер. +он едет в URL-фрагменте, который не уходит на сервер. Вход, запрошенный из **установленного +PWA**, эту ссылку не содержит: в письме только код, который вводится в том же окне (ссылка +открылась бы в отдельном браузере, недоступном приложению). Telegram держит **единого бота**: все игроки пользуются одним и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке интерфейса** самого игрока (en/ru). Рядом с основным может работать отдельный опциональный diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index ef5ce5c..9aa357a 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -289,10 +289,11 @@ func (c *Client) GuestAuth(ctx context.Context, browserTz string) (SessionResp, // 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, language string) error { +// account's time zone, since the email account is created here, not at login. pwa marks a +// request from an installed PWA, so the backend omits the one-tap confirm link from the email. +func (c *Client) EmailRequest(ctx context.Context, email, browserTz, language string, pwa bool) error { return c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/email/request", "", "", - map[string]string{"email": email, "browser_tz": browserTz, "language": language}, nil) + map[string]any{"email": email, "browser_tz": browserTz, "language": language, "pwa": pwa}, 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 c876964..15decd5 100644 --- a/gateway/internal/transcode/transcode.go +++ b/gateway/internal/transcode/transcode.go @@ -253,7 +253,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()), string(in.BrowserTz()), string(in.Language())); err != nil { + if err := backend.EmailRequest(ctx, string(in.Email()), string(in.BrowserTz()), string(in.Language()), in.Pwa()); err != nil { return nil, err } return encodeAck(true), nil diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index 0afd743..636ab8f 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -138,6 +138,11 @@ table EmailRequestRequest { email:string; browser_tz:string; language:string; + // Set when the request originates from an installed PWA (standalone display mode): the + // backend then omits the one-tap confirm link from the login email so the code is typed in + // the same window, avoiding a link that opens in a separate browser (a different storage + // context) where the minted session could not reach the PWA. + pwa:bool; } // EmailLoginRequest logs in to the account owning email (provisioned at the diff --git a/pkg/fbs/scrabblefb/EmailRequestRequest.go b/pkg/fbs/scrabblefb/EmailRequestRequest.go index 9f95230..8c89159 100644 --- a/pkg/fbs/scrabblefb/EmailRequestRequest.go +++ b/pkg/fbs/scrabblefb/EmailRequestRequest.go @@ -65,8 +65,20 @@ func (rcv *EmailRequestRequest) Language() []byte { return nil } +func (rcv *EmailRequestRequest) Pwa() bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(10)) + if o != 0 { + return rcv._tab.GetBool(o + rcv._tab.Pos) + } + return false +} + +func (rcv *EmailRequestRequest) MutatePwa(n bool) bool { + return rcv._tab.MutateBoolSlot(10, n) +} + func EmailRequestRequestStart(builder *flatbuffers.Builder) { - builder.StartObject(3) + builder.StartObject(4) } func EmailRequestRequestAddEmail(builder *flatbuffers.Builder, email flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(email), 0) @@ -77,6 +89,9 @@ func EmailRequestRequestAddBrowserTz(builder *flatbuffers.Builder, browserTz fla func EmailRequestRequestAddLanguage(builder *flatbuffers.Builder, language flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(language), 0) } +func EmailRequestRequestAddPwa(builder *flatbuffers.Builder, pwa bool) { + builder.PrependBoolSlot(3, pwa, false) +} func EmailRequestRequestEnd(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 47212e5..23835d6 100644 --- a/ui/src/gen/fbs/scrabblefb/email-request-request.ts +++ b/ui/src/gen/fbs/scrabblefb/email-request-request.ts @@ -41,8 +41,13 @@ language(optionalEncoding?:any):string|Uint8Array|null { return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; } +pwa():boolean { + const offset = this.bb!.__offset(this.bb_pos, 10); + return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false; +} + static startEmailRequestRequest(builder:flatbuffers.Builder) { - builder.startObject(3); + builder.startObject(4); } static addEmail(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset) { @@ -57,16 +62,21 @@ static addLanguage(builder:flatbuffers.Builder, languageOffset:flatbuffers.Offse builder.addFieldOffset(2, languageOffset, 0); } +static addPwa(builder:flatbuffers.Builder, pwa:boolean) { + builder.addFieldInt8(3, +pwa, +false); +} + static endEmailRequestRequest(builder:flatbuffers.Builder):flatbuffers.Offset { const offset = builder.endObject(); return offset; } -static createEmailRequestRequest(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset, browserTzOffset:flatbuffers.Offset, languageOffset:flatbuffers.Offset):flatbuffers.Offset { +static createEmailRequestRequest(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset, browserTzOffset:flatbuffers.Offset, languageOffset:flatbuffers.Offset, pwa:boolean):flatbuffers.Offset { EmailRequestRequest.startEmailRequestRequest(builder); EmailRequestRequest.addEmail(builder, emailOffset); EmailRequestRequest.addBrowserTz(builder, browserTzOffset); EmailRequestRequest.addLanguage(builder, languageOffset); + EmailRequestRequest.addPwa(builder, pwa); return EmailRequestRequest.endEmailRequestRequest(builder); } } diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 25bbc16..1c0a784 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -35,6 +35,7 @@ import { } from './telegram'; import { onVKPath, insideVK, vkInit, vkClose, vkDisableSwipeBack, vkLaunchParams, vkUserName, vkStartParam, vkOnScheme, vkOnInsets, vkSetViewSettings, appearanceForBg } from './vk'; import { pendingVKLink, type VKLinkCallback } from './vkid'; +import { isStandalone } from './pwa'; import { registerServiceWorker } from './pwa.svelte'; import { haptic } from './haptics'; import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs'; @@ -980,7 +981,7 @@ export async function loginGuest(): Promise { export async function requestEmailCode(email: string): Promise { try { - await gateway.authEmailRequest(email, app.locale); + await gateway.authEmailRequest(email, app.locale, isStandalone()); return true; } catch (err) { handleError(err); diff --git a/ui/src/lib/client.ts b/ui/src/lib/client.ts index 5c52348..a647da3 100644 --- a/ui/src/lib/client.ts +++ b/ui/src/lib/client.ts @@ -65,7 +65,7 @@ export interface GatewayClient { * cosmetic seed for a brand-new account). */ authVK(params: string, displayName: string): Promise; authGuest(locale?: string): Promise; - authEmailRequest(email: string, language: string): Promise; + authEmailRequest(email: string, language: string, pwa: boolean): Promise; authEmailLogin(email: string, code: string): Promise; /** Confirm a one-tap email deeplink token: a login returns a session to adopt; a * link returns a status ('confirmed' | 'merge_required'). */ diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts index 45fac3f..bd8bb59 100644 --- a/ui/src/lib/codec.test.ts +++ b/ui/src/lib/codec.test.ts @@ -122,10 +122,11 @@ describe('codec', () => { expect(guest.browserTz()).toBe('-05:30'); const email = fb.EmailRequestRequest.getRootAsEmailRequestRequest( - new ByteBuffer(encodeEmailRequest('a@example.com', '+00:00', 'en')), + new ByteBuffer(encodeEmailRequest('a@example.com', '+00:00', 'en', true)), ); expect(email.email()).toBe('a@example.com'); expect(email.browserTz()).toBe('+00:00'); + expect(email.pwa()).toBe(true); expect(email.language()).toBe('en'); const confirm = fb.EmailConfirmLinkRequest.getRootAsEmailConfirmLinkRequest( diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index be6474a..8e62f08 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -214,7 +214,12 @@ export function encodeGuestLogin(locale: string, browserTz: string): Uint8Array return finish(b, fb.GuestLoginRequest.endGuestLoginRequest(b)); } -export function encodeEmailRequest(email: string, browserTz: string, language: string): Uint8Array { +export function encodeEmailRequest( + email: string, + browserTz: string, + language: string, + pwa: boolean, +): Uint8Array { const b = new Builder(128); const e = b.createString(email); const tz = b.createString(browserTz); @@ -223,6 +228,7 @@ export function encodeEmailRequest(email: string, browserTz: string, language: s fb.EmailRequestRequest.addEmail(b, e); fb.EmailRequestRequest.addBrowserTz(b, tz); fb.EmailRequestRequest.addLanguage(b, l); + fb.EmailRequestRequest.addPwa(b, pwa); return finish(b, fb.EmailRequestRequest.endEmailRequestRequest(b)); } diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index d0d589b..a1219e2 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -150,7 +150,7 @@ export class MockGateway implements GatewayClient { async authGuest(): Promise { return { ...SESSION }; } - async authEmailRequest(_email: string, _language: string): Promise {} + async authEmailRequest(_email: string, _language: string, _pwa: boolean): Promise {} async confirmEmailLink(_token: string): Promise { return { purpose: 'link', status: 'confirmed', session: null }; } diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index dd89956..51bf052 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -118,8 +118,8 @@ export function createTransport(baseUrl: string): GatewayClient { async authGuest(locale) { return codec.decodeSession(await exec('auth.guest', codec.encodeGuestLogin(locale ?? '', browserOffset()))); }, - async authEmailRequest(email, language) { - await exec('auth.email.request', codec.encodeEmailRequest(email, browserOffset(), language)); + async authEmailRequest(email, language, pwa) { + await exec('auth.email.request', codec.encodeEmailRequest(email, browserOffset(), language, pwa)); }, async confirmEmailLink(token) { return codec.decodeConfirmLinkResult(await exec('auth.email.confirm_link', codec.encodeEmailConfirmLink(token)));