Compare commits

...

5 Commits

Author SHA1 Message Date
Ilia Denisov 65f2c87a74 docs+test(email): document the confirm deeplink + wire-contract tests
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 1m3s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m52s
Document the one-tap confirm deeplink in ARCHITECTURE (§4 the login magic-link /
link confirm+profile-refresh, prefetch-safe token) and the new notify 'profile'
sub-kind (§10), and add the one-tap link to the FUNCTIONAL email story (+ru). Add
codec round-trip assertions for the EmailRequest language field and
encodeEmailConfirmLink (the mock e2e bypasses the codec).
2026-07-03 04:33:27 +02:00
Ilia Denisov 409462fc09 feat(ui): one-tap confirm deeplink screen + client language
Add the /confirm/<token> SPA route and Confirm screen: a prefetch-safe button
POSTs the token via a new confirmEmailLink RPC (client/transport/mock/codec +
ConfirmLinkResult model). A login adopts the minted session and enters the app; a
link shows confirmed / merge-in-the-app; an invalid or expired token asks for a new
code. Exempt /confirm from the no-session /login redirect. Forward the client
locale on the email request (authEmailRequest gains language → app.locale) so a
fresh web login email is localised. Handle the new 'profile' live-event sub-kind by
re-fetching the profile, so a link confirmed in another browser reflects in-app at
once. i18n en+ru.
2026-07-03 04:30:16 +02:00
Ilia Denisov 762155a55e feat(gateway): confirmEmailLink RPC + language on the email request + profile event
Add the confirm-link edge method (auth.email.confirm_link): a new
EmailConfirmLinkRequest/Result fbs table, the transcode const + handler + encoder,
and the backend-client call to the existing /sessions/email/confirm-link endpoint —
it rides Execute under the existing service prefix, so no proto/Caddy change. Add a
language field to EmailRequestRequest and forward it (the backend already seeds it).
Add the NotifyProfile sub-kind + notify.ProfileChanged, published by the confirm-link
handler on a successful link so an in-app session re-fetches its profile when the
email was confirmed in another browser. Regenerated fbs bindings (Go + TS).
2026-07-03 04:22:09 +02:00
Ilia Denisov 25d80bc31d feat(server): confirm-link endpoint for the one-tap deeplink
Add POST /internal/sessions/email/confirm-link: it verifies a deeplink token via
ConfirmByToken and, for a login, mints a session (the deeplink page signs in with
it); for a link, attaches the confirmed email and reports "confirmed" or
"merge_required" (the app drives the interactive merge). The token, not a request
session, is the authorization. Add LinkConfirmation.IsLogin() and integration tests
for the login, link and merge branches (the token is read from the mailed link).
The gateway RPC, live event and SPA route follow.
2026-07-03 04:13:48 +02:00
Ilia Denisov d5b4bba018 feat(account): restore the confirm deeplink token (domain layer)
Re-apply the deeplink backend deferred out of PR1a: migration 00006 adds
email_confirmations.purpose + link_token_hash (hand-edited jet), each issued code
now carries an opaque 256-bit token (only its SHA-256 stored), and ConfirmByToken
resolves a token to a login (confirm + clear guest) or a link (attach when free,
signal merge when owned elsewhere). issueCode now embeds the /app/#/confirm/<token>
link in the email. The confirm endpoint, gateway RPC and SPA route follow.
2026-07-03 04:07:10 +02:00
35 changed files with 958 additions and 60 deletions
+152 -7
View File
@@ -5,6 +5,7 @@ import (
crand "crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
@@ -26,6 +27,11 @@ const (
emailCodeTTL = 15 * time.Minute
// emailCodeMaxAttempts caps wrong-code submissions before a code is dead.
emailCodeMaxAttempts = 5
// linkTokenBytes is the entropy of a confirm deeplink token: 256 bits.
linkTokenBytes = 32
// emailConfirmPath is the SPA route the one-tap confirm deeplink opens (the token
// is appended). The SPA is served under /app/ behind a hash router.
emailConfirmPath = "/app/#/confirm/"
)
// Confirmation purposes recorded on a pending confirm-code row. They select what
@@ -92,18 +98,23 @@ func (s *EmailService) allowSend(email string) bool {
return s.limiter == nil || s.limiter.Allow(email)
}
// issueCode generates a fresh confirm-code for (accountID, email), replaces any prior
// pending confirmation, and mails the branded code in locale; purpose selects the
// email wording. Only the SHA-256 hash of the code is stored.
// 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 {
code, codeHash, err := generateCode()
if err != nil {
return err
}
if err := s.store.replacePendingConfirmation(ctx, accountID, email, codeHash, s.now().Add(emailCodeTTL)); err != nil {
token, tokenHash, err := generateLinkToken()
if err != nil {
return err
}
msg, err := renderConfirmationEmail(purpose, code, "", s.baseURL, locale)
if err := s.store.replacePendingConfirmation(ctx, accountID, email, codeHash, tokenHash, purpose, s.now().Add(emailCodeTTL)); err != nil {
return err
}
msg, err := renderConfirmationEmail(purpose, code, s.confirmURL(token), s.baseURL, locale)
if err != nil {
return err
}
@@ -111,6 +122,15 @@ func (s *EmailService) issueCode(ctx context.Context, accountID uuid.UUID, email
return s.mailer.Send(ctx, msg)
}
// confirmURL builds the absolute one-tap confirm deeplink for token, or "" when no
// public base URL is configured.
func (s *EmailService) confirmURL(token string) string {
if s.baseURL == "" {
return ""
}
return strings.TrimRight(s.baseURL, "/") + emailConfirmPath + token
}
// accountLocale returns the account's preferred UI language for localising email,
// defaulting to "en" when the account cannot be loaded.
func (s *EmailService) accountLocale(ctx context.Context, accountID uuid.UUID) string {
@@ -241,6 +261,70 @@ func (s *EmailService) LoginWithCode(ctx context.Context, email, code string) (A
return s.store.GetByID(ctx, acc.ID)
}
// LinkConfirmation is the outcome of confirming a one-tap deeplink token: what the
// transport layer must finish. Purpose is the pending row's purpose. For a login,
// Account is the account to sign in. For a link, Account is the account the email was
// (or would be) attached to; NeedsMerge is set when another account (MergeOwner)
// already owns the address, so the caller drives the interactive merge instead of a
// plain link — the token is left unconsumed for that merge step.
type LinkConfirmation struct {
Purpose string
Account uuid.UUID
NeedsMerge bool
MergeOwner uuid.UUID
}
// IsLogin reports whether the confirmation is a login (the caller mints a session)
// rather than a link (attach the identity, or drive a merge).
func (r LinkConfirmation) IsLogin() bool { return r.Purpose == purposeLogin }
// ConfirmByToken verifies a one-tap deeplink token and performs its purpose. A login
// confirms the email identity, clears the guest flag and returns the account to sign
// in. A link attaches the confirmed email to the pending account when the address is
// free, or reports NeedsMerge when another account already owns it (leaving the token
// live so the caller's merge step can re-verify). It returns ErrNoPendingCode when the
// token matches no live confirmation and ErrCodeExpired when it has lapsed. The token
// is high-entropy, so there is no wrong-attempt counter.
func (s *EmailService) ConfirmByToken(ctx context.Context, token string) (LinkConfirmation, error) {
pend, err := s.store.pendingByTokenHash(ctx, hashCode(token))
if err != nil {
return LinkConfirmation{}, err
}
if s.now().After(pend.expiresAt) {
return LinkConfirmation{}, ErrCodeExpired
}
switch pend.purpose {
case purposeLogin:
if err := s.store.confirmEmailLogin(ctx, pend.id, pend.accountID, pend.email, s.now()); err != nil {
return LinkConfirmation{}, err
}
if err := s.store.ClearGuest(ctx, pend.accountID); err != nil {
return LinkConfirmation{}, err
}
return LinkConfirmation{Purpose: purposeLogin, Account: pend.accountID}, nil
case purposeLink:
owner, ok, err := s.store.confirmedEmailAccount(ctx, pend.email)
if err != nil {
return LinkConfirmation{}, err
}
if ok {
if owner == pend.accountID {
if err := s.store.consumeConfirmation(ctx, pend.id, s.now()); err != nil {
return LinkConfirmation{}, err
}
return LinkConfirmation{Purpose: purposeLink, Account: pend.accountID}, nil
}
return LinkConfirmation{Purpose: purposeLink, Account: pend.accountID, NeedsMerge: true, MergeOwner: owner}, nil
}
if err := s.store.confirmEmailIdentity(ctx, pend.id, pend.accountID, pend.email, s.now()); err != nil {
return LinkConfirmation{}, err
}
return LinkConfirmation{Purpose: purposeLink, Account: pend.accountID}, nil
default:
return LinkConfirmation{}, fmt.Errorf("account: unsupported confirmation purpose %q", pend.purpose)
}
}
// emailConfirmation is a pending confirm-code row in domain form.
type emailConfirmation struct {
id uuid.UUID
@@ -271,7 +355,7 @@ func (s *Store) confirmedEmailAccount(ctx context.Context, email string) (uuid.U
// replacePendingConfirmation clears any pending code for (accountID, email) and
// inserts a fresh one, inside one transaction.
func (s *Store) replacePendingConfirmation(ctx context.Context, accountID uuid.UUID, email, codeHash string, expiresAt time.Time) error {
func (s *Store) replacePendingConfirmation(ctx context.Context, accountID uuid.UUID, email, codeHash, linkTokenHash, purpose string, expiresAt time.Time) error {
id, err := uuid.NewV7()
if err != nil {
return fmt.Errorf("account: new confirmation id: %w", err)
@@ -288,7 +372,8 @@ func (s *Store) replacePendingConfirmation(ctx context.Context, accountID uuid.U
ins := table.EmailConfirmations.INSERT(
table.EmailConfirmations.ConfirmationID, table.EmailConfirmations.AccountID,
table.EmailConfirmations.Email, table.EmailConfirmations.CodeHash, table.EmailConfirmations.ExpiresAt,
).VALUES(id, accountID, email, codeHash, expiresAt)
table.EmailConfirmations.LinkTokenHash, table.EmailConfirmations.Purpose,
).VALUES(id, accountID, email, codeHash, expiresAt, linkTokenHash, purpose)
if _, err := ins.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("insert confirmation: %w", err)
}
@@ -321,6 +406,54 @@ func (s *Store) latestPendingConfirmation(ctx context.Context, accountID uuid.UU
}, nil
}
// pendingConfirmation is a pending confirm-code row loaded by its deeplink token, in
// domain form.
type pendingConfirmation struct {
id uuid.UUID
accountID uuid.UUID
email string
purpose string
expiresAt time.Time
}
// pendingByTokenHash loads the unconsumed confirmation whose deeplink token hashes to
// tokenHash, or ErrNoPendingCode. The high-entropy token needs no attempt counter, so
// a partial-unique index guarantees at most one match.
func (s *Store) pendingByTokenHash(ctx context.Context, tokenHash string) (pendingConfirmation, error) {
stmt := postgres.SELECT(table.EmailConfirmations.AllColumns).
FROM(table.EmailConfirmations).
WHERE(
table.EmailConfirmations.LinkTokenHash.EQ(postgres.String(tokenHash)).
AND(table.EmailConfirmations.ConsumedAt.IS_NULL()),
).LIMIT(1)
var row model.EmailConfirmations
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return pendingConfirmation{}, ErrNoPendingCode
}
return pendingConfirmation{}, fmt.Errorf("account: load confirmation by token: %w", err)
}
return pendingConfirmation{
id: row.ConfirmationID,
accountID: row.AccountID,
email: row.Email,
purpose: row.Purpose,
expiresAt: row.ExpiresAt,
}, nil
}
// consumeConfirmation marks a confirmation consumed without writing an identity, used
// for the idempotent already-linked deeplink path.
func (s *Store) consumeConfirmation(ctx context.Context, id uuid.UUID, now time.Time) error {
upd := table.EmailConfirmations.UPDATE(table.EmailConfirmations.ConsumedAt).
SET(postgres.TimestampzT(now)).
WHERE(table.EmailConfirmations.ConfirmationID.EQ(postgres.UUID(id)))
if _, err := upd.ExecContext(ctx, s.db); err != nil {
return fmt.Errorf("account: consume confirmation: %w", err)
}
return nil
}
// bumpConfirmationAttempts increments a code's wrong-attempt counter by one.
func (s *Store) bumpConfirmationAttempts(ctx context.Context, id uuid.UUID) error {
stmt := table.EmailConfirmations.
@@ -414,6 +547,18 @@ func generateCode() (code, hash string, err error) {
return code, hashCode(code), nil
}
// generateLinkToken returns a fresh opaque one-tap confirm deeplink token (URL-safe
// base64, 256-bit) and its hex SHA-256 hash. Only the hash is stored; the token
// travels only in the emailed link, mirroring the session-token model.
func generateLinkToken() (token, hash string, err error) {
buf := make([]byte, linkTokenBytes)
if _, err := crand.Read(buf); err != nil {
return "", "", fmt.Errorf("account: generate link token: %w", err)
}
token = base64.RawURLEncoding.EncodeToString(buf)
return token, hashCode(token), nil
}
// hashCode returns the hex-encoded SHA-256 of a confirm-code.
func hashCode(code string) string {
sum := sha256.Sum256([]byte(code))
+95
View File
@@ -278,3 +278,98 @@ func TestEmailLoginProvisionsGuestUntilConfirmed(t *testing.T) {
t.Error("confirming the login must clear the guest flag (promote to durable)")
}
}
// confirmToken extracts the one-tap deeplink token from the /app/#/confirm/<token> link
// the branded email carries.
var confirmToken = regexp.MustCompile(`/confirm/([A-Za-z0-9_-]+)`)
func tokenFromMail(t *testing.T, body string) string {
t.Helper()
m := confirmToken.FindStringSubmatch(body)
if m == nil {
t.Fatalf("no confirm token in mail body %q", body)
}
return m[1]
}
// TestConfirmByTokenLogin: the one-tap deeplink token completes an email login,
// clearing the guest flag.
func TestConfirmByTokenLogin(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
email := "tok-login-" + uuid.NewString() + "@example.com"
id, err := svc.RequestLoginCode(ctx, email, "", "en")
if err != nil {
t.Fatalf("request login: %v", err)
}
res, err := svc.ConfirmByToken(ctx, tokenFromMail(t, mailer.lastBody))
if err != nil {
t.Fatalf("confirm by token: %v", err)
}
if !res.IsLogin() || res.Account != id {
t.Fatalf("login result = %+v, want login for %s", res, id)
}
if !identityConfirmed(t, account.KindEmail, email) {
t.Error("email identity must be confirmed after the token login")
}
if acc, _ := store.GetByID(ctx, id); acc.IsGuest {
t.Error("the token login must clear the guest flag")
}
}
// TestConfirmByTokenLink: the token attaches a free email to the requesting account.
func TestConfirmByTokenLink(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
acc := provisionAccount(t)
email := "tok-link-" + uuid.NewString() + "@example.com"
if err := svc.RequestLinkCode(ctx, acc, email); err != nil {
t.Fatalf("request link: %v", err)
}
res, err := svc.ConfirmByToken(ctx, tokenFromMail(t, mailer.lastBody))
if err != nil {
t.Fatalf("confirm by token: %v", err)
}
if res.IsLogin() || res.NeedsMerge || res.Account != acc {
t.Fatalf("link result = %+v, want a plain link for %s", res, acc)
}
if !identityConfirmed(t, account.KindEmail, email) {
t.Error("email identity must be confirmed after the token link")
}
}
// TestConfirmByTokenLinkMerge: a token for an address owned by another account signals
// a merge (leaving the token unconsumed for the interactive step).
func TestConfirmByTokenLinkMerge(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
email := "tok-merge-" + uuid.NewString() + "@example.com"
owner := provisionAccount(t)
if err := svc.RequestCode(ctx, owner, email); err != nil {
t.Fatalf("owner request: %v", err)
}
if _, err := svc.ConfirmCode(ctx, owner, email, sixDigit.FindString(mailer.lastBody)); err != nil {
t.Fatalf("owner confirm: %v", err)
}
other := provisionAccount(t)
if err := svc.RequestLinkCode(ctx, other, email); err != nil {
t.Fatalf("link request: %v", err)
}
res, err := svc.ConfirmByToken(ctx, tokenFromMail(t, mailer.lastBody))
if err != nil {
t.Fatalf("confirm by token: %v", err)
}
if !res.NeedsMerge || res.MergeOwner != owner {
t.Fatalf("merge result = %+v, want NeedsMerge with owner=%s", res, owner)
}
}
+7
View File
@@ -155,6 +155,13 @@ func Notification(userID uuid.UUID, kind string) Intent {
return Intent{UserID: userID, Kind: KindNotification, Payload: b.FinishedBytes(), EventID: eventID()}
}
// ProfileChanged is a payload-free "re-fetch your profile" signal to userID, emitted
// when the viewer's own account changed out of band — an email confirmed through the
// one-tap deeplink opened in another browser.
func ProfileChanged(userID uuid.UUID) Intent {
return Notification(userID, NotifyProfile)
}
// NotificationAccount builds a lobby notification of one of the friend_* kinds carrying the
// account it concerns (the requester, the new friend or the decliner), so the client updates its
// requests/friends lists and the in-game "add friend" state without a refetch.
+4
View File
@@ -69,6 +69,10 @@ const (
// it re-fetches profile.get to show or hide the banner. It carries no payload (the
// banner set rides the profile response). In-app only.
NotifyBanner = "banner"
// NotifyProfile tells the client that the viewer's own profile changed out of band
// (e.g. an email was confirmed via the one-tap deeplink opened in another browser),
// so it re-fetches profile.get. It carries no payload. In-app only.
NotifyProfile = "profile"
// NotifyUserBlocked confirms to the blocker that a per-user block took effect,
// carrying the blocked account, so every one of the blocker's sessions updates the
// in-game block/add-friend controls and the struck name in place. It is delivered
@@ -21,4 +21,6 @@ type EmailConfirmations struct {
Attempts int16
ConsumedAt *time.Time
CreatedAt time.Time
Purpose string
LinkTokenHash *string
}
@@ -25,6 +25,8 @@ type emailConfirmationsTable struct {
Attempts postgres.ColumnInteger
ConsumedAt postgres.ColumnTimestampz
CreatedAt postgres.ColumnTimestampz
Purpose postgres.ColumnString
LinkTokenHash postgres.ColumnString
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
@@ -74,9 +76,11 @@ func newEmailConfirmationsTableImpl(schemaName, tableName, alias string) emailCo
AttemptsColumn = postgres.IntegerColumn("attempts")
ConsumedAtColumn = postgres.TimestampzColumn("consumed_at")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
allColumns = postgres.ColumnList{ConfirmationIDColumn, AccountIDColumn, EmailColumn, CodeHashColumn, ExpiresAtColumn, AttemptsColumn, ConsumedAtColumn, CreatedAtColumn}
mutableColumns = postgres.ColumnList{AccountIDColumn, EmailColumn, CodeHashColumn, ExpiresAtColumn, AttemptsColumn, ConsumedAtColumn, CreatedAtColumn}
defaultColumns = postgres.ColumnList{AttemptsColumn, CreatedAtColumn}
PurposeColumn = postgres.StringColumn("purpose")
LinkTokenHashColumn = postgres.StringColumn("link_token_hash")
allColumns = postgres.ColumnList{ConfirmationIDColumn, AccountIDColumn, EmailColumn, CodeHashColumn, ExpiresAtColumn, AttemptsColumn, ConsumedAtColumn, CreatedAtColumn, PurposeColumn, LinkTokenHashColumn}
mutableColumns = postgres.ColumnList{AccountIDColumn, EmailColumn, CodeHashColumn, ExpiresAtColumn, AttemptsColumn, ConsumedAtColumn, CreatedAtColumn, PurposeColumn, LinkTokenHashColumn}
defaultColumns = postgres.ColumnList{AttemptsColumn, CreatedAtColumn, PurposeColumn}
)
return emailConfirmationsTable{
@@ -91,6 +95,8 @@ func newEmailConfirmationsTableImpl(schemaName, tableName, alias string) emailCo
Attempts: AttemptsColumn,
ConsumedAt: ConsumedAtColumn,
CreatedAt: CreatedAtColumn,
Purpose: PurposeColumn,
LinkTokenHash: LinkTokenHashColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
@@ -0,0 +1,27 @@
-- Add the confirm deeplink token and the confirmation purpose to email_confirmations.
-- purpose tags what verifying the code/token does (email login, identity link, email
-- change, or account deletion); link_token_hash holds the SHA-256 hex of the one-click
-- deeplink token (only the hash is stored, mirroring the session-token model).
-- Expand-contract: both columns are additive. purpose gets a NOT NULL DEFAULT so
-- existing rows fill in; link_token_hash is nullable with a partial-unique index. A
-- backend image rollback stays DB-safe — older code simply ignores the new columns. The
-- table shape changes, so the generated go-jet model for this table is regenerated.
-- +goose Up
ALTER TABLE backend.email_confirmations
ADD COLUMN purpose text NOT NULL DEFAULT 'link',
ADD COLUMN link_token_hash text;
ALTER TABLE backend.email_confirmations
ADD CONSTRAINT email_confirmations_purpose_chk
CHECK ((purpose = ANY (ARRAY['login'::text, 'link'::text, 'change'::text, 'delete'::text])));
CREATE UNIQUE INDEX email_confirmations_link_token_hash_key
ON backend.email_confirmations (link_token_hash)
WHERE link_token_hash IS NOT NULL;
-- +goose Down
DROP INDEX IF EXISTS backend.email_confirmations_link_token_hash_key;
ALTER TABLE backend.email_confirmations
DROP CONSTRAINT IF EXISTS email_confirmations_purpose_chk;
ALTER TABLE backend.email_confirmations
DROP COLUMN IF EXISTS link_token_hash,
DROP COLUMN IF EXISTS purpose;
+1
View File
@@ -31,6 +31,7 @@ func (s *Server) registerRoutes() {
in.POST("/sessions/guest", s.handleGuestAuth)
in.POST("/sessions/email/request", s.handleEmailRequest)
in.POST("/sessions/email/login", s.handleEmailLogin)
in.POST("/sessions/email/confirm-link", s.handleEmailConfirmLink)
in.POST("/sessions/resolve", s.handleResolveSession)
in.POST("/sessions/revoke", s.handleRevokeSession)
// Out-of-app push routing for the platform side-service: the
+51
View File
@@ -9,6 +9,7 @@ import (
"go.uber.org/zap"
"scrabble/backend/internal/account"
"scrabble/backend/internal/notify"
)
// The /api/v1/internal/sessions/* endpoints are gateway-only: the gateway has
@@ -212,6 +213,56 @@ func (s *Server) handleEmailLogin(c *gin.Context) {
s.mintSession(c, acc)
}
// confirmLinkResponse is the outcome of a one-tap deeplink confirmation. For a login,
// Session carries the freshly minted credential (the deeplink page signs in with it);
// for a link, Status is "confirmed" or "merge_required" (the app completes the merge).
type confirmLinkResponse struct {
Purpose string `json:"purpose"`
Status string `json:"status"`
Session *sessionResponse `json:"session,omitempty"`
}
// handleEmailConfirmLink verifies a one-tap deeplink token. A login mints a session
// (the deeplink page signs in with it); a link attaches the confirmed email, reporting
// "merge_required" when the address is owned by another account so the app drives the
// interactive merge. The token, not a request session, is the authorization — the page
// need not be signed in.
func (s *Server) handleEmailConfirmLink(c *gin.Context) {
var req tokenRequest
if err := c.ShouldBindJSON(&req); err != nil || req.Token == "" {
abortBadRequest(c, "token is required")
return
}
res, err := s.emails.ConfirmByToken(c.Request.Context(), req.Token)
if err != nil {
s.abortErr(c, err)
return
}
if res.IsLogin() {
acc, err := s.accounts.GetByID(c.Request.Context(), res.Account)
if err != nil {
s.abortErr(c, err)
return
}
token, _, err := s.sessions.Create(c.Request.Context(), acc.ID)
if err != nil {
s.abortErr(c, err)
return
}
sess := sessionResponseFor(token, acc)
c.JSON(http.StatusOK, confirmLinkResponse{Purpose: "login", Status: "confirmed", Session: &sess})
return
}
if res.NeedsMerge {
c.JSON(http.StatusOK, confirmLinkResponse{Purpose: "link", Status: "merge_required"})
return
}
// A free link attached the email; nudge the account's in-app session(s) to re-fetch
// the profile, so a link confirmed in another browser reflects at once.
s.notifier.Publish(notify.ProfileChanged(res.Account))
c.JSON(http.StatusOK, confirmLinkResponse{Purpose: "link", Status: "confirmed"})
}
// tokenRequest carries an opaque session token.
type tokenRequest struct {
Token string `json:"token"`
+11 -2
View File
@@ -239,7 +239,13 @@ arrive from a platform rather than completing a mandatory registration).
development log mailer when no relay is configured) and, once
verified, attaches a confirmed email identity. Sends are throttled per recipient
(a 60-second cooldown and a five-per-hour cap). Links in the email are built from
a configured public base URL, never a request Host header (anti-injection). An
a configured public base URL, never a request Host header (anti-injection). The email
also carries a **one-tap confirm deeplink** (`/app/#/confirm/<token>`, an opaque
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, and a would-be merge is deferred to the
interactive flow. The confirm page is prefetch-safe — it confirms only on a button
press, so a mail scanner's GET does not consume the single-use token. An
**email-login** account is created flagged `is_guest` and stays reapable until the
code is confirmed — so an abandoned, never-confirmed login frees its reserved
address — with confirming clearing the flag. Accounts and identities use
@@ -933,7 +939,10 @@ edge-pause, scroll speed, and the fade-out → gap → fade-in transition) are o
eligibility inputs (grants hints, grants/revokes `no_banner`; a future payment flow sets
`paid_account`), the backend emits a `notify` **`banner`** sub-kind (a payload-free re-poll signal),
and the open client re-fetches `profile.get` to show or hide the banner in place. Operator *content*
edits take effect on the next `profile.get` (open/reconnect/foreground), not mid-session.
edits take effect on the next `profile.get` (open/reconnect/foreground), not mid-session. The same
mechanism carries a **`profile`** sub-kind — a payload-free re-fetch signal emitted when a viewer's
own account changed out of band (an email confirmed through the one-tap deeplink opened in another
browser), so an open in-app session reflects it at once.
> A single `app.load` bootstrap aggregator (collapsing `profile.get` + lobby + badge fetches into
> one round-trip) was **considered and deferred**: client↔gateway is HTTP/2 (h2c), so the bootstrap
+4 -1
View File
@@ -66,7 +66,10 @@ client's light/dark scheme, and the layout clears the VK mobile home bar. The sa
is provisioned on the first request but only becomes a durable account once the code
is confirmed, so an abandoned, never-confirmed attempt is cleaned up and its address
freed. Sends are rate-limited (a short cooldown between codes and a small hourly cap
per address), so a mistyped address or an impatient tap cannot flood an inbox.
per address), so a mistyped address or an impatient tap cannot flood an inbox. The email
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 never triggers it (it acts only on a button press).
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
+4 -1
View File
@@ -71,7 +71,10 @@ launch-параметрам VK (их проверяет gateway), и при пе
запросе, но становится постоянным аккаунтом только после подтверждения кода — так брошенная,
неподтверждённая попытка вычищается, а адрес освобождается. Отправки ограничены по частоте (короткая
пауза между кодами и небольшой часовой лимит на адрес), чтобы опечатка в адресе или нетерпеливый тап
не завалили почтовый ящик.
не завалили почтовый ящик. В письме также есть **ссылка одного нажатия**, которая подтверждает
адрес — или сразу выполняет вход — в один тап; открытие её в другом браузере тут же отражается в
приложении, а почтовый сканер, который просто загружает ссылку, её не срабатывает (действие
только по нажатию кнопки).
Telegram держит **единого бота**: все игроки пользуются одним
и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке
интерфейса** самого игрока (en/ru). Рядом с основным может работать отдельный опциональный
+19 -2
View File
@@ -276,9 +276,9 @@ 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 string) error {
func (c *Client) EmailRequest(ctx context.Context, email, browserTz, language string) error {
return c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/email/request", "", "",
map[string]string{"email": email, "browser_tz": browserTz}, nil)
map[string]string{"email": email, "browser_tz": browserTz, "language": language}, nil)
}
// EmailLogin verifies a login code and mints a session.
@@ -289,6 +289,23 @@ func (c *Client) EmailLogin(ctx context.Context, email, code string) (SessionRes
return out, err
}
// ConfirmLinkResp is the backend's outcome of a one-tap deeplink confirmation: for a
// login Session carries the minted credential; for a link Status is "confirmed" or
// "merge_required".
type ConfirmLinkResp struct {
Purpose string `json:"purpose"`
Status string `json:"status"`
Session *SessionResp `json:"session,omitempty"`
}
// EmailConfirmLink verifies a one-tap deeplink token; the token is the authorization.
func (c *Client) EmailConfirmLink(ctx context.Context, token string) (ConfirmLinkResp, error) {
var out ConfirmLinkResp
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/email/confirm-link", "", "",
map[string]string{"token": token}, &out)
return out, err
}
// ResolveSession maps a token to its account id and guest flag (gateway
// session-cache miss). The guest flag lets the edge gate guest-forbidden ops.
func (c *Client) ResolveSession(ctx context.Context, token string) (string, bool, error) {
+29
View File
@@ -37,6 +37,35 @@ func encodeAck(ok bool) []byte {
return b.FinishedBytes()
}
// encodeConfirmLinkResult builds an EmailConfirmLinkResult payload, embedding the
// minted Session for a login. All strings and the nested Session table are built
// before the result table is opened.
func encodeConfirmLinkResult(r backendclient.ConfirmLinkResp) []byte {
b := flatbuffers.NewBuilder(160)
purpose := b.CreateString(r.Purpose)
status := b.CreateString(r.Status)
var session flatbuffers.UOffsetT
if r.Session != nil {
token := b.CreateString(r.Session.Token)
uid := b.CreateString(r.Session.UserID)
name := b.CreateString(r.Session.DisplayName)
fb.SessionStart(b)
fb.SessionAddToken(b, token)
fb.SessionAddUserId(b, uid)
fb.SessionAddIsGuest(b, r.Session.IsGuest)
fb.SessionAddDisplayName(b, name)
session = fb.SessionEnd(b)
}
fb.EmailConfirmLinkResultStart(b)
fb.EmailConfirmLinkResultAddPurpose(b, purpose)
fb.EmailConfirmLinkResultAddStatus(b, status)
if r.Session != nil {
fb.EmailConfirmLinkResultAddSession(b, session)
}
b.Finish(fb.EmailConfirmLinkResultEnd(b))
return b.FinishedBytes()
}
// encodeProfile builds a Profile payload, including the advertising-banner block
// when the backend marked the viewer eligible.
func encodeProfile(p backendclient.ProfileResp) []byte {
+14 -1
View File
@@ -24,6 +24,7 @@ const (
MsgAuthGuest = "auth.guest"
MsgAuthEmailReq = "auth.email.request"
MsgAuthEmailLogin = "auth.email.login"
MsgAuthEmailConfirmLink = "auth.email.confirm_link"
MsgProfileGet = "profile.get"
MsgBlockStatus = "account.block_status"
MsgGameSubmitPlay = "game.submit_play"
@@ -101,6 +102,7 @@ func NewRegistry(backend *backendclient.Client, tg TelegramValidator, opts ...Op
r.ops[MsgAuthGuest] = Op{Handler: authGuestHandler(backend)}
r.ops[MsgAuthEmailReq] = Op{Handler: authEmailRequestHandler(backend), Email: true}
r.ops[MsgAuthEmailLogin] = Op{Handler: authEmailLoginHandler(backend), Email: true}
r.ops[MsgAuthEmailConfirmLink] = Op{Handler: authEmailConfirmLinkHandler(backend)}
r.ops[MsgProfileGet] = Op{Handler: profileHandler(backend), Auth: true}
r.ops[MsgBlockStatus] = Op{Handler: blockStatusHandler(backend), Auth: true}
r.ops[MsgGameSubmitPlay] = Op{Handler: submitPlayHandler(backend), Auth: true}
@@ -238,7 +240,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())); err != nil {
if err := backend.EmailRequest(ctx, string(in.Email()), string(in.BrowserTz()), string(in.Language())); err != nil {
return nil, err
}
return encodeAck(true), nil
@@ -256,6 +258,17 @@ func authEmailLoginHandler(backend *backendclient.Client) Handler {
}
}
func authEmailConfirmLinkHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsEmailConfirmLinkRequest(req.Payload, 0)
res, err := backend.EmailConfirmLink(ctx, string(in.Token()))
if err != nil {
return nil, err
}
return encodeConfirmLinkResult(res), nil
}
}
func profileHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
p, err := backend.Profile(ctx, req.UserID)
+18 -1
View File
@@ -132,10 +132,12 @@ table GuestLoginRequest {
// 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.
// offset) is seeded into its time zone here, not at the later login step; language
// (the client's UI language) seeds the new account's language and localises the email.
table EmailRequestRequest {
email:string;
browser_tz:string;
language:string;
}
// EmailLoginRequest logs in to the account owning email (provisioned at the
@@ -145,6 +147,21 @@ table EmailLoginRequest {
code:string;
}
// EmailConfirmLinkRequest verifies a one-tap deeplink token from a confirmation
// email. The token, not a session, is the authorization.
table EmailConfirmLinkRequest {
token:string;
}
// EmailConfirmLinkResult is the outcome of a deeplink confirmation: purpose is
// "login" or "link"; for a login session carries the minted credential; for a link
// status is "confirmed" or "merge_required" (the app completes the merge).
table EmailConfirmLinkResult {
purpose:string;
status:string;
session:Session;
}
// Session is the minted credential returned by every auth operation.
table Session {
token:string;
@@ -0,0 +1,60 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package scrabblefb
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type EmailConfirmLinkRequest struct {
_tab flatbuffers.Table
}
func GetRootAsEmailConfirmLinkRequest(buf []byte, offset flatbuffers.UOffsetT) *EmailConfirmLinkRequest {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &EmailConfirmLinkRequest{}
x.Init(buf, n+offset)
return x
}
func FinishEmailConfirmLinkRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsEmailConfirmLinkRequest(buf []byte, offset flatbuffers.UOffsetT) *EmailConfirmLinkRequest {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &EmailConfirmLinkRequest{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedEmailConfirmLinkRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *EmailConfirmLinkRequest) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *EmailConfirmLinkRequest) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *EmailConfirmLinkRequest) Token() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func EmailConfirmLinkRequestStart(builder *flatbuffers.Builder) {
builder.StartObject(1)
}
func EmailConfirmLinkRequestAddToken(builder *flatbuffers.Builder, token flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(token), 0)
}
func EmailConfirmLinkRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
@@ -0,0 +1,87 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package scrabblefb
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type EmailConfirmLinkResult struct {
_tab flatbuffers.Table
}
func GetRootAsEmailConfirmLinkResult(buf []byte, offset flatbuffers.UOffsetT) *EmailConfirmLinkResult {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &EmailConfirmLinkResult{}
x.Init(buf, n+offset)
return x
}
func FinishEmailConfirmLinkResultBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsEmailConfirmLinkResult(buf []byte, offset flatbuffers.UOffsetT) *EmailConfirmLinkResult {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &EmailConfirmLinkResult{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedEmailConfirmLinkResultBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *EmailConfirmLinkResult) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *EmailConfirmLinkResult) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *EmailConfirmLinkResult) Purpose() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *EmailConfirmLinkResult) Status() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *EmailConfirmLinkResult) Session(obj *Session) *Session {
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
if o != 0 {
x := rcv._tab.Indirect(o + rcv._tab.Pos)
if obj == nil {
obj = new(Session)
}
obj.Init(rcv._tab.Bytes, x)
return obj
}
return nil
}
func EmailConfirmLinkResultStart(builder *flatbuffers.Builder) {
builder.StartObject(3)
}
func EmailConfirmLinkResultAddPurpose(builder *flatbuffers.Builder, purpose flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(purpose), 0)
}
func EmailConfirmLinkResultAddStatus(builder *flatbuffers.Builder, status flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(status), 0)
}
func EmailConfirmLinkResultAddSession(builder *flatbuffers.Builder, session flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(session), 0)
}
func EmailConfirmLinkResultEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+12 -1
View File
@@ -57,8 +57,16 @@ func (rcv *EmailRequestRequest) BrowserTz() []byte {
return nil
}
func (rcv *EmailRequestRequest) Language() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func EmailRequestRequestStart(builder *flatbuffers.Builder) {
builder.StartObject(2)
builder.StartObject(3)
}
func EmailRequestRequestAddEmail(builder *flatbuffers.Builder, email flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(email), 0)
@@ -66,6 +74,9 @@ func EmailRequestRequestAddEmail(builder *flatbuffers.Builder, email flatbuffers
func EmailRequestRequestAddBrowserTz(builder *flatbuffers.Builder, browserTz flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(browserTz), 0)
}
func EmailRequestRequestAddLanguage(builder *flatbuffers.Builder, language flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(language), 0)
}
func EmailRequestRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+3
View File
@@ -15,6 +15,7 @@
import NewGame from './screens/NewGame.svelte';
import SettingsHub from './screens/SettingsHub.svelte';
import Stats from './screens/Stats.svelte';
import Confirm from './screens/Confirm.svelte';
import Game from './game/Game.svelte';
import CommsHub from './game/CommsHub.svelte';
import Feedback from './screens/Feedback.svelte';
@@ -110,6 +111,8 @@
<Feedback />
{:else if router.route.name === 'stats'}
<Stats />
{:else if router.route.name === 'confirm'}
<Confirm token={router.route.params.token} />
{:else}
<Lobby />
{/if}
+2
View File
@@ -17,6 +17,8 @@ export { ComplaintRequest } from './scrabblefb/complaint-request.js';
export { CreateInvitationRequest } from './scrabblefb/create-invitation-request.js';
export { DraftRequest } from './scrabblefb/draft-request.js';
export { DraftView } from './scrabblefb/draft-view.js';
export { EmailConfirmLinkRequest } from './scrabblefb/email-confirm-link-request.js';
export { EmailConfirmLinkResult } from './scrabblefb/email-confirm-link-result.js';
export { EmailLoginRequest } from './scrabblefb/email-login-request.js';
export { EmailRequestRequest } from './scrabblefb/email-request-request.js';
export { EnqueueRequest } from './scrabblefb/enqueue-request.js';
@@ -0,0 +1,48 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class EmailConfirmLinkRequest {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):EmailConfirmLinkRequest {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsEmailConfirmLinkRequest(bb:flatbuffers.ByteBuffer, obj?:EmailConfirmLinkRequest):EmailConfirmLinkRequest {
return (obj || new EmailConfirmLinkRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsEmailConfirmLinkRequest(bb:flatbuffers.ByteBuffer, obj?:EmailConfirmLinkRequest):EmailConfirmLinkRequest {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new EmailConfirmLinkRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
token():string|null
token(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
token(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startEmailConfirmLinkRequest(builder:flatbuffers.Builder) {
builder.startObject(1);
}
static addToken(builder:flatbuffers.Builder, tokenOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, tokenOffset, 0);
}
static endEmailConfirmLinkRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createEmailConfirmLinkRequest(builder:flatbuffers.Builder, tokenOffset:flatbuffers.Offset):flatbuffers.Offset {
EmailConfirmLinkRequest.startEmailConfirmLinkRequest(builder);
EmailConfirmLinkRequest.addToken(builder, tokenOffset);
return EmailConfirmLinkRequest.endEmailConfirmLinkRequest(builder);
}
}
@@ -0,0 +1,66 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
import { Session } from '../scrabblefb/session.js';
export class EmailConfirmLinkResult {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):EmailConfirmLinkResult {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsEmailConfirmLinkResult(bb:flatbuffers.ByteBuffer, obj?:EmailConfirmLinkResult):EmailConfirmLinkResult {
return (obj || new EmailConfirmLinkResult()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsEmailConfirmLinkResult(bb:flatbuffers.ByteBuffer, obj?:EmailConfirmLinkResult):EmailConfirmLinkResult {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new EmailConfirmLinkResult()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
purpose():string|null
purpose(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
purpose(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
status():string|null
status(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
status(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;
}
session(obj?:Session):Session|null {
const offset = this.bb!.__offset(this.bb_pos, 8);
return offset ? (obj || new Session()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null;
}
static startEmailConfirmLinkResult(builder:flatbuffers.Builder) {
builder.startObject(3);
}
static addPurpose(builder:flatbuffers.Builder, purposeOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, purposeOffset, 0);
}
static addStatus(builder:flatbuffers.Builder, statusOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, statusOffset, 0);
}
static addSession(builder:flatbuffers.Builder, sessionOffset:flatbuffers.Offset) {
builder.addFieldOffset(2, sessionOffset, 0);
}
static endEmailConfirmLinkResult(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
}
@@ -34,8 +34,15 @@ browserTz(optionalEncoding?:any):string|Uint8Array|null {
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
language():string|null
language(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
language(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 startEmailRequestRequest(builder:flatbuffers.Builder) {
builder.startObject(2);
builder.startObject(3);
}
static addEmail(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset) {
@@ -46,15 +53,20 @@ static addBrowserTz(builder:flatbuffers.Builder, browserTzOffset:flatbuffers.Off
builder.addFieldOffset(1, browserTzOffset, 0);
}
static addLanguage(builder:flatbuffers.Builder, languageOffset:flatbuffers.Offset) {
builder.addFieldOffset(2, languageOffset, 0);
}
static endEmailRequestRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createEmailRequestRequest(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset, browserTzOffset:flatbuffers.Offset):flatbuffers.Offset {
static createEmailRequestRequest(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset, browserTzOffset:flatbuffers.Offset, languageOffset:flatbuffers.Offset):flatbuffers.Offset {
EmailRequestRequest.startEmailRequestRequest(builder);
EmailRequestRequest.addEmail(builder, emailOffset);
EmailRequestRequest.addBrowserTz(builder, browserTzOffset);
EmailRequestRequest.addLanguage(builder, languageOffset);
return EmailRequestRequest.endEmailRequestRequest(builder);
}
}
+21 -2
View File
@@ -409,6 +409,11 @@ function openStream(): void {
if (e.sub === 'banner') {
void refreshProfile();
}
// The viewer's own profile changed out of band (an email confirmed via the
// one-tap deeplink opened in another browser): re-fetch it.
if (e.sub === 'profile') {
void refreshProfile();
}
void refreshNotifications();
}
},
@@ -738,7 +743,7 @@ export async function bootstrap(): Promise<void> {
if (saved) {
await adoptSession(saved);
if (router.route.name === 'login') navigate('/');
} else if (router.route.name !== 'login') {
} else if (router.route.name !== 'login' && router.route.name !== 'confirm') {
navigate('/login');
}
app.ready = true;
@@ -915,7 +920,7 @@ export async function loginGuest(): Promise<void> {
export async function requestEmailCode(email: string): Promise<boolean> {
try {
await gateway.authEmailRequest(email);
await gateway.authEmailRequest(email, app.locale);
return true;
} catch (err) {
handleError(err);
@@ -933,6 +938,20 @@ export async function loginEmail(email: string, code: string): Promise<void> {
}
}
// confirmDeeplink verifies a one-tap email deeplink token opened from a confirmation
// email. A login adopts the minted session and enters the app; a link reports whether
// it confirmed or needs the interactive merge. It throws on an invalid/expired token,
// which the confirm screen surfaces. This browser need not be signed in.
export async function confirmDeeplink(token: string): Promise<'login' | 'linked' | 'merge_required'> {
const r = await gateway.confirmEmailLink(token);
if (r.purpose === 'login' && r.session) {
await adoptSession(r.session);
navigate('/');
return 'login';
}
return r.status === 'merge_required' ? 'merge_required' : 'linked';
}
export async function logout(): Promise<void> {
closeStream();
resetConnection();
+5 -1
View File
@@ -20,6 +20,7 @@ import type {
HintResult,
Invitation,
InvitationSettings,
ConfirmLinkResult,
LinkResult,
MatchResult,
MoveResult,
@@ -64,8 +65,11 @@ export interface GatewayClient {
* cosmetic seed for a brand-new account). */
authVK(params: string, displayName: string): Promise<Session>;
authGuest(locale?: string): Promise<Session>;
authEmailRequest(email: string): Promise<void>;
authEmailRequest(email: string, language: string): Promise<void>;
authEmailLogin(email: string, code: string): Promise<Session>;
/** Confirm a one-tap email deeplink token: a login returns a session to adopt; a
* link returns a status ('confirmed' | 'merge_required'). */
confirmEmailLink(token: string): Promise<ConfirmLinkResult>;
// --- profile / lists ---
profileGet(): Promise<Profile>;
+8 -1
View File
@@ -21,6 +21,7 @@ import {
decodeStats,
encodeCheckWord,
encodeEmailRequest,
encodeEmailConfirmLink,
encodeFeedbackSubmit,
encodeDraftSave,
encodeEnqueue,
@@ -117,10 +118,16 @@ describe('codec', () => {
expect(guest.browserTz()).toBe('-05:30');
const email = fb.EmailRequestRequest.getRootAsEmailRequestRequest(
new ByteBuffer(encodeEmailRequest('a@example.com', '+00:00')),
new ByteBuffer(encodeEmailRequest('a@example.com', '+00:00', 'en')),
);
expect(email.email()).toBe('a@example.com');
expect(email.browserTz()).toBe('+00:00');
expect(email.language()).toBe('en');
const confirm = fb.EmailConfirmLinkRequest.getRootAsEmailConfirmLinkRequest(
new ByteBuffer(encodeEmailConfirmLink('tok-abc')),
);
expect(confirm.token()).toBe('tok-abc');
const vk = fb.VKLoginRequest.getRootAsVKLoginRequest(
new ByteBuffer(encodeVKLogin('vk_user_id=494075&vk_ts=1&sign=abc', '+03:00', 'Иван Петров')),
+22 -1
View File
@@ -31,6 +31,7 @@ import type {
Invitation,
InvitationInvitee,
InvitationSettings,
ConfirmLinkResult,
LinkResult,
MatchResult,
MoveRecord,
@@ -212,13 +213,15 @@ export function encodeGuestLogin(locale: string, browserTz: string): Uint8Array
return finish(b, fb.GuestLoginRequest.endGuestLoginRequest(b));
}
export function encodeEmailRequest(email: string, browserTz: string): Uint8Array {
export function encodeEmailRequest(email: string, browserTz: string, language: string): Uint8Array {
const b = new Builder(128);
const e = b.createString(email);
const tz = b.createString(browserTz);
const l = b.createString(language);
fb.EmailRequestRequest.startEmailRequestRequest(b);
fb.EmailRequestRequest.addEmail(b, e);
fb.EmailRequestRequest.addBrowserTz(b, tz);
fb.EmailRequestRequest.addLanguage(b, l);
return finish(b, fb.EmailRequestRequest.endEmailRequestRequest(b));
}
@@ -232,6 +235,14 @@ export function encodeEmailLogin(email: string, code: string): Uint8Array {
return finish(b, fb.EmailLoginRequest.endEmailLoginRequest(b));
}
export function encodeEmailConfirmLink(token: string): Uint8Array {
const b = new Builder(128);
const t = b.createString(token);
fb.EmailConfirmLinkRequest.startEmailConfirmLinkRequest(b);
fb.EmailConfirmLinkRequest.addToken(b, t);
return finish(b, fb.EmailConfirmLinkRequest.endEmailConfirmLinkRequest(b));
}
// --- response decoders ---
function s(v: string | null): string {
@@ -730,6 +741,16 @@ export function decodeLinkResult(buf: Uint8Array): LinkResult {
};
}
export function decodeConfirmLinkResult(buf: Uint8Array): ConfirmLinkResult {
const r = fb.EmailConfirmLinkResult.getRootAsEmailConfirmLinkResult(new ByteBuffer(buf));
const sess = r.session();
return {
purpose: (s(r.purpose()) || 'link') as ConfirmLinkResult['purpose'],
status: (s(r.status()) || 'confirmed') as ConfirmLinkResult['status'],
session: sess ? sessionFromTable(sess) : null,
};
}
// --- social decoders ---
function decodeAccountRef(r: fb.AccountRef): AccountRef {
+6
View File
@@ -36,6 +36,12 @@ export const en = {
'login.sendCode': 'Send code',
'login.codePlaceholder': '6-digit code',
'login.signIn': 'Sign in',
'confirm.prompt': 'Confirm your email to finish.',
'confirm.action': 'Confirm',
'confirm.linked': 'Email confirmed.',
'confirm.merge': 'Almost done — finish the merge in the app.',
'confirm.close': 'You can close this window and return to the game.',
'confirm.expired': 'This link is invalid or has expired. Request a new code.',
'login.codeSent': 'We sent a code to {email}.',
'lobby.activeGames': 'Active games',
+6
View File
@@ -37,6 +37,12 @@ export const ru: Record<MessageKey, string> = {
'login.sendCode': 'Отправить код',
'login.codePlaceholder': 'Код из 6 цифр',
'login.signIn': 'Войти',
'confirm.prompt': 'Подтвердите почту, чтобы завершить.',
'confirm.action': 'Подтвердить',
'confirm.linked': 'Почта подтверждена.',
'confirm.merge': 'Почти готово — завершите объединение в приложении.',
'confirm.close': 'Можно закрыть это окно и вернуться в игру.',
'confirm.expired': 'Ссылка недействительна или истекла. Запросите новый код.',
'login.codeSent': 'Мы отправили код на {email}.',
'lobby.activeGames': 'Активные игры',
+5 -1
View File
@@ -27,6 +27,7 @@ import type {
HintResult,
Invitation,
InvitationSettings,
ConfirmLinkResult,
LinkResult,
MatchResult,
MoveResult,
@@ -149,7 +150,10 @@ export class MockGateway implements GatewayClient {
async authGuest(): Promise<Session> {
return { ...SESSION };
}
async authEmailRequest(): Promise<void> {}
async authEmailRequest(_email: string, _language: string): Promise<void> {}
async confirmEmailLink(_token: string): Promise<ConfirmLinkResult> {
return { purpose: 'link', status: 'confirmed', session: null };
}
async authEmailLogin(): Promise<Session> {
return { ...SESSION, isGuest: false };
}
+9
View File
@@ -355,6 +355,15 @@ export interface LinkResult {
session: Session | null;
}
// ConfirmLinkResult is the outcome of a one-tap deeplink confirmation. purpose is
// 'login' (session carries the minted credential to sign in) or 'link' (status is
// 'confirmed' or 'merge_required' — the app finishes any merge from its manual flow).
export interface ConfirmLinkResult {
purpose: 'login' | 'link';
status: 'confirmed' | 'merge_required';
session: Session | null;
}
export interface MatchResult {
matched: boolean;
game?: GameView;
+6
View File
@@ -15,6 +15,7 @@ export type RouteName =
| 'friends'
| 'feedback'
| 'stats'
| 'confirm'
| 'notfound';
export interface Route {
@@ -58,6 +59,11 @@ export function parse(hash: string): Route {
return { name: 'feedback', params: {} };
case 'stats':
return { name: 'stats', params: {} };
case 'confirm':
// The one-tap email deeplink: /confirm/<token>. The token is the confirmation
// authorization; the screen POSTs it (prefetch-safe), so a mail scanner's GET
// does not consume it.
return { name: 'confirm', params: { token: seg[1] ?? '' } };
default:
return { name: 'notfound', params: {} };
}
+5 -2
View File
@@ -101,8 +101,11 @@ export function createTransport(baseUrl: string): GatewayClient {
async authGuest(locale) {
return codec.decodeSession(await exec('auth.guest', codec.encodeGuestLogin(locale ?? '', browserOffset())));
},
async authEmailRequest(email) {
await exec('auth.email.request', codec.encodeEmailRequest(email, browserOffset()));
async authEmailRequest(email, language) {
await exec('auth.email.request', codec.encodeEmailRequest(email, browserOffset(), language));
},
async confirmEmailLink(token) {
return codec.decodeConfirmLinkResult(await exec('auth.email.confirm_link', codec.encodeEmailConfirmLink(token)));
},
async authEmailLogin(email, code) {
return codec.decodeSession(await exec('auth.email.login', codec.encodeEmailLogin(email, code)));
+95
View File
@@ -0,0 +1,95 @@
<script lang="ts">
import { confirmDeeplink } from '../lib/app.svelte';
import { t } from '../lib/i18n/index.svelte';
let { token }: { token: string } = $props();
// idle → busy → (login navigates away) | linked | merge | error. The token is
// confirmed only on the button press (a POST), so a mail scanner prefetching the
// link (a GET on the SPA route) never consumes it.
let stage = $state<'idle' | 'busy' | 'linked' | 'merge' | 'error'>('idle');
async function confirm(): Promise<void> {
if (!token) {
stage = 'error';
return;
}
stage = 'busy';
try {
const r = await confirmDeeplink(token);
// A login has already navigated into the app; a link stays here with a result.
stage = r === 'merge_required' ? 'merge' : 'linked';
} catch {
stage = 'error';
}
}
</script>
<main class="confirm">
<div class="card">
<div class="brand">{t('app.title')}</div>
{#if stage === 'idle' || stage === 'busy'}
<p>{t('confirm.prompt')}</p>
<button class="primary" disabled={stage === 'busy'} onclick={confirm}>
{t('confirm.action')}
</button>
{:else if stage === 'linked'}
<p class="ok">{t('confirm.linked')}</p>
<p class="muted">{t('confirm.close')}</p>
{:else if stage === 'merge'}
<p class="ok">{t('confirm.merge')}</p>
<p class="muted">{t('confirm.close')}</p>
{:else}
<p class="err">{t('confirm.expired')}</p>
{/if}
</div>
</main>
<style>
.confirm {
height: 100%;
display: grid;
place-items: center;
padding: 16px;
}
.card {
width: min(94vw, 360px);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 24px;
display: flex;
flex-direction: column;
gap: 12px;
text-align: center;
}
.brand {
font-weight: 700;
letter-spacing: 0.04em;
color: var(--accent);
}
p {
margin: 0;
}
.muted {
color: var(--text-muted);
font-size: 0.9rem;
}
.ok {
font-weight: 600;
}
.err {
color: var(--danger, #c0392b);
}
button {
padding: 12px;
border-radius: var(--radius-sm);
border: 1px solid var(--accent);
font-weight: 600;
background: var(--accent);
color: var(--accent-text);
}
button:disabled {
opacity: 0.5;
}
</style>