diff --git a/PLAN.md b/PLAN.md index a3e8e7a..f6c8abd 100644 --- a/PLAN.md +++ b/PLAN.md @@ -44,7 +44,7 @@ independent (see ARCHITECTURE §9.1). | 8 | UI — social/account/history (friends, blocks, invitations, profile edit, stats, history/GCG) | **done** | | 9 | Telegram integration (bot side-service, deep-link, push) | **done** | | 10 | Admin & dictionary ops (complaint review, version reload) | **done** | -| 11 | Account linking & merge | todo | +| 11 | Account linking & merge | **done** | | 12 | Polish (observability, perf with evidence, deploy) | todo | Scaffolding is incremental: `go.work` lists only existing modules; each stage @@ -738,6 +738,64 @@ Open details: deployment target/host; dashboards; load expectations. (the console is server-rendered Go). The Go workflows already span `./backend/... ./gateway/... ./pkg/...`; integration stays `./backend/...`. +- **Stage 11** (interview + implementation): + - **Scope = link-via-confirm + merge for email and Telegram** (interview): the + current account is the merge **primary**; a linked identity that already has its + own account is merged into the current one and the secondary is retired as an + **audit tombstone** (`accounts.merged_into`/`merged_at`, migration `00009` + + jetgen). Linkable this stage: **email** (the existing confirm-code) and + **Telegram via the Login Widget** (the web sign-in). New `internal/accountmerge` + (the single-transaction data merge) and `internal/link` (the orchestrator over + account + accountmerge + session). + - **Tombstone, not delete** (interview): the secondary row is kept so a **shared + finished game**'s no-cascade `game_players`/`chat`/`complaints` foreign keys stay + valid; its seat in such a game is left in place. The merge is **refused** + (`ErrActiveGameConflict`) only when the two share an **active** game. + - **Merge algorithm** (one tx): stats summed (wins/losses/draws) + max kept; + `hint_balance` summed; identities repointed; non-shared `game_players` transferred + (shared kept); `chat_messages`/`complaints` reassigned; friendships/blocks repointed + with self-edge drop and dedupe (friendships by status precedence + accepted>pending>declined); invitations: secondary's as inviter deleted, invitee + rows deduped; secondary's `email_confirmations`/`friend_codes` dropped; secondary + tombstoned. Sessions are handled one layer up: `session.Service.RevokeAllForAccount` + (+ `Cache.RemoveByAccount`) retires the secondary's sessions after the tx. + - **Primary direction + guest inversion** (interview): primary = the current account, + **except** when the initiator is a **guest** and the linked identity already has a + **durable** owner — then the **durable account wins**, the guest's active games + transfer into it, the guest is retired, and a **fresh session for the durable + account is minted and returned** (the client adopts it). Binding a **free** identity + to a guest is a plain upgrade (clear `is_guest`, same session). Discharges Stage 8's + "guest email-binding is Stage 11". + - **API/UX = dedicated ops; reveal only after the code** (interview): new edge ops + `link.email.request/confirm/merge` (Email-rate-limited) and + `link.telegram.confirm/merge`. `request` **always** mails a code (no pre-send + "taken" signal, so a probe cannot enumerate registered addresses); a required merge + is revealed **only after** the code is verified, gating an explicit irreversible + merge step (the Profile screen's confirmation dialog). This **supersedes Stage 8's** + `email.bind.*` ops (and their fbs `EmailBindRequest`/`EmailConfirmRequest` tables), + which were retired from the gateway/UI for that reason; the backend + `EmailService.RequestCode`/`ConfirmCode` primitives stay (still covered by inttest). + - **Field policy** (interview): `display_name` = primary's; profile prefs/flags + (language, timezone, away window, block toggles, `notifications_in_app_only`) = + primary's; `hint_balance` = **sum**. A new service column **`paid_account`** + (`bool`, default false; lifetime one-time-payment marker, no purchase flow yet) is + added in `00009` and **ORed** on merge (`true` always wins). It is not user-editable + and is shown read-only on the admin account-detail page. + - **Telegram Login Widget** (interview, owner chose the broader scope): the connector + validates it (`internal/loginwidget`, secret = `SHA-256(bot_token)`, distinct from + initData) via a new `Telegram.ValidateLoginWidget` RPC; the gateway validates the + widget payload and passes the **trusted** `external_id` to the backend link route + (same trust model as `auth.telegram`). The UI offers "Link Telegram" only in a plain + web context (`loginWidgetAvailable`), driving the popup `Telegram.Login.auth`; it is + **inert in production until BotFather `/setdomain`** registers the site domain and + `VITE_TELEGRAM_BOT_ID` is configured (a deploy concern, Stage 12). e2e mocks the + widget (telegram.org is blocked on CI). + - **Wire/CI**: new fbs `LinkEmailRequest`/`LinkEmailConfirm`/`LinkTelegramRequest`/ + `LinkResult` (committed Go + TS); new proto RPC (committed Go); new REST routes under + `/api/v1/user/link/*`. The Go workflows already span `./backend/... ./gateway/... + ./pkg/... ./platform/telegram/...`; integration stays `./backend/...`. UI ~90 KB gzip + JS (budget 100 KB). New error code `merge_active_game_conflict`. + ## Deferred TODOs (cross-stage) - **TODO-1 — publish & version the solver.** Once `scrabble-solver` is stable, diff --git a/backend/README.md b/backend/README.md index f65c337..8262bc8 100644 --- a/backend/README.md +++ b/backend/README.md @@ -83,6 +83,18 @@ pipeline, dictionary **hot-reload** from `BACKEND_DICT_DIR//` backend Telegram-connector client (`internal/connector`, `BACKEND_CONNECTOR_ADDR`). The shared wire contracts live in the sibling [`../pkg`](../pkg) module. +Stage 11 adds **account linking & merge** (`/api/v1/user/link/*`). `internal/link` +orchestrates it: an email confirm-code or a gateway-validated Telegram identity is +attached to the current account, and when the identity already has its own account +the two are merged in one transaction (`internal/accountmerge`) — stats and the hint +wallet summed, `paid_account` ORed, identities/games/chat/complaints transferred, +friends/blocks de-duplicated, the secondary kept as a `merged_into` tombstone (so a +shared finished game's foreign keys hold); a shared **active** game blocks the merge. +The current account is primary, except a guest initiator whose linked identity has a +durable owner — then the durable account wins and a fresh session is minted for it. +Migration `00009` adds `paid_account`/`merged_into`/`merged_at`. This supersedes the +Stage 8 `email.bind.*` edge surface (the `RequestCode`/`ConfirmCode` primitives stay). + ## Package layout ``` @@ -93,8 +105,10 @@ internal/telemetry/ # OpenTelemetry providers + per-request timing middleware internal/postgres/ # pgx-over-database/sql pool (otelsql), goose migrations migrations/ # embedded *.sql (goose), schema `backend` jet/ # generated go-jet models + table builders (committed) -internal/account/ # durable accounts + platform/email identities (store) -internal/session/ # opaque tokens, sessions store, write-through cache, service +internal/account/ # durable accounts + platform/email identities (store) + email/identity link primitives +internal/accountmerge/ # single-transaction merge of a secondary account into a primary (Stage 11) +internal/link/ # link/merge orchestrator over account + accountmerge + session (Stage 11) +internal/session/ # opaque tokens, sessions store, write-through cache, service (incl. RevokeAllForAccount) internal/server/ # gin engine, route groups, X-User-ID middleware, probes internal/engine/ # in-process scrabble-solver bridge: registry, bag, Game, replay internal/game/ # game domain: lifecycle, journal+cache, hint, word-check, GCG, sweeper diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index 76c2a38..7cbc0fb 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -18,10 +18,12 @@ import ( "go.uber.org/zap" "scrabble/backend/internal/account" + "scrabble/backend/internal/accountmerge" "scrabble/backend/internal/config" "scrabble/backend/internal/connector" "scrabble/backend/internal/engine" "scrabble/backend/internal/game" + "scrabble/backend/internal/link" "scrabble/backend/internal/lobby" "scrabble/backend/internal/notify" "scrabble/backend/internal/postgres" @@ -138,6 +140,9 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { // groups) for the handlers to come. mailer := newMailer(cfg.SMTP, logger) emails := account.NewEmailService(accounts, mailer) + // Stage 11 account linking & merge: the orchestrator over the account, merge and + // session layers. Wired to the /api/v1/user/link REST surface below. + links := link.NewService(emails, accounts, accountmerge.NewMerger(db), sessions) socialSvc := social.NewService(social.NewStore(db), accounts, games) socialSvc.SetNotifier(hub) @@ -170,6 +175,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { Matchmaker: matchmaker, Invitations: invitations, Emails: emails, + Links: links, Registry: registry, DictDir: cfg.Game.DictDir, Connector: conn, diff --git a/backend/internal/account/account.go b/backend/internal/account/account.go index 2629c78..063533f 100644 --- a/backend/internal/account/account.go +++ b/backend/internal/account/account.go @@ -63,8 +63,16 @@ type Account struct { // true (the default): the platform side-service skips out-of-app push for the // account (Stage 9). NotificationsInAppOnly bool - CreatedAt time.Time - UpdatedAt time.Time + // PaidAccount marks a lifetime one-time-payment account. It is a service field + // (no purchase flow yet); an account linking & merge ORs it so a paid status is + // never lost when accounts are consolidated (Stage 11). + PaidAccount bool + // MergedInto is the primary account a retired (merged) secondary points at, or + // uuid.Nil for a live account. A tombstone keeps the row so the no-cascade + // foreign keys of a shared finished game stay valid (Stage 11). + MergedInto uuid.UUID + CreatedAt time.Time + UpdatedAt time.Time } // Identity is one of an account's platform/email identities, surfaced on the @@ -368,6 +376,10 @@ func (s *Store) SpendHint(ctx context.Context, id uuid.UUID) (bool, error) { // modelToAccount projects a generated model row into the public Account struct. func modelToAccount(row model.Accounts) Account { + var mergedInto uuid.UUID + if row.MergedInto != nil { + mergedInto = *row.MergedInto + } return Account{ ID: row.AccountID, DisplayName: row.DisplayName, @@ -380,6 +392,8 @@ func modelToAccount(row model.Accounts) Account { BlockFriendRequests: row.BlockFriendRequests, IsGuest: row.IsGuest, NotificationsInAppOnly: row.NotificationsInAppOnly, + PaidAccount: row.PaidAccount, + MergedInto: mergedInto, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, } diff --git a/backend/internal/account/link.go b/backend/internal/account/link.go new file mode 100644 index 0000000..bcc18fb --- /dev/null +++ b/backend/internal/account/link.go @@ -0,0 +1,145 @@ +package account + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/go-jet/jet/v2/postgres" + "github.com/google/uuid" + + "scrabble/backend/internal/postgres/jet/backend/table" +) + +// ErrIdentityTaken is returned when a platform identity being linked already +// belongs to another account; the caller turns it into a merge (Stage 11). +var ErrIdentityTaken = errors.New("account: identity already linked to another account") + +// RequestLinkCode issues and mails a confirm-code for email to accountID, +// replacing any prior pending code. Unlike RequestCode it never refuses up front +// (taken or already-confirmed): possession of the address is the authorization for +// a later link or merge, and the merge is only revealed once the code is verified, +// so a probe cannot learn whether an address is registered (Stage 11). +func (s *EmailService) RequestLinkCode(ctx context.Context, accountID uuid.UUID, email string) error { + addr, err := normalizeEmail(email) + if err != nil { + return err + } + code, hash, err := generateCode() + if err != nil { + return err + } + if err := s.store.replacePendingConfirmation(ctx, accountID, addr, hash, s.now().Add(emailCodeTTL)); err != nil { + return err + } + subject := "Your Scrabble confirmation code" + body := fmt.Sprintf("Your confirmation code is %s. It expires in %d minutes.", code, int(emailCodeTTL/time.Minute)) + return s.mailer.Send(ctx, addr, subject, body) +} + +// ConfirmLink verifies code for (accountID, email) and reports the address's +// current owner. When the address is free it binds a confirmed email identity to +// accountID and returns (accountID, true, nil). When accountID already owns it, +// it returns (accountID, true, nil) unchanged. When another account owns it, it +// returns (owner, false, nil) without consuming the code, so the explicit merge +// step can re-verify the same live code. It returns the usual confirm-code errors +// (ErrNoPendingCode, ErrCodeExpired, ErrTooManyAttempts, ErrCodeMismatch). +func (s *EmailService) ConfirmLink(ctx context.Context, accountID uuid.UUID, email, code string) (uuid.UUID, bool, error) { + addr, err := normalizeEmail(email) + if err != nil { + return uuid.Nil, false, err + } + conf, err := s.verifyPendingCode(ctx, accountID, addr, code) + if err != nil { + return uuid.Nil, false, err + } + owner, ok, err := s.store.confirmedEmailAccount(ctx, addr) + if err != nil { + return uuid.Nil, false, err + } + if ok { + if owner == accountID { + return accountID, true, nil + } + return owner, false, nil + } + if err := s.store.confirmEmailIdentity(ctx, conf.id, accountID, addr, s.now()); err != nil { + return uuid.Nil, false, err + } + return accountID, true, nil +} + +// verifyPendingCode loads and checks the pending confirm-code for (accountID, +// addr), counting a wrong attempt. It returns the confirmation on success. +func (s *EmailService) verifyPendingCode(ctx context.Context, accountID uuid.UUID, addr, code string) (emailConfirmation, error) { + conf, err := s.store.latestPendingConfirmation(ctx, accountID, addr) + if err != nil { + return emailConfirmation{}, err + } + if s.now().After(conf.expiresAt) { + return emailConfirmation{}, ErrCodeExpired + } + if conf.attempts >= emailCodeMaxAttempts { + return emailConfirmation{}, ErrTooManyAttempts + } + if hashCode(code) != conf.codeHash { + if err := s.store.bumpConfirmationAttempts(ctx, conf.id); err != nil { + return emailConfirmation{}, err + } + return emailConfirmation{}, ErrCodeMismatch + } + return conf, nil +} + +// AccountIDByIdentity returns the account owning (kind, externalID) and true, or +// (uuid.Nil, false) when the identity is free. It backs the platform-identity link +// flow (Stage 11). +func (s *Store) AccountIDByIdentity(ctx context.Context, kind, externalID string) (uuid.UUID, bool, error) { + acc, err := s.findByIdentity(ctx, kind, externalID) + if errors.Is(err, ErrNotFound) { + return uuid.Nil, false, nil + } + if err != nil { + return uuid.Nil, false, err + } + return acc.ID, true, nil +} + +// AttachIdentity links a new (kind, externalID) identity to an existing account. +// A unique-constraint violation means the identity was taken meanwhile, surfaced +// as ErrIdentityTaken. It is used to attach a platform identity (e.g. Telegram) +// to the current account during linking (Stage 11). +func (s *Store) AttachIdentity(ctx context.Context, accountID uuid.UUID, kind, externalID string, confirmed bool) error { + id, err := uuid.NewV7() + if err != nil { + return fmt.Errorf("account: new identity id: %w", err) + } + ins := table.Identities.INSERT( + table.Identities.IdentityID, table.Identities.AccountID, table.Identities.Kind, + table.Identities.ExternalID, table.Identities.Confirmed, + ).VALUES(id, accountID, kind, externalID, confirmed) + if _, err := ins.ExecContext(ctx, s.db); err != nil { + if isUniqueViolation(err) { + return ErrIdentityTaken + } + return fmt.Errorf("account: attach identity (%s, %s): %w", kind, externalID, err) + } + return nil +} + +// ClearGuest removes the is_guest flag from accountID, promoting an ephemeral guest +// to a durable account once it gains its first identity (Stage 11). It is a no-op +// for an already-durable account. +func (s *Store) ClearGuest(ctx context.Context, accountID uuid.UUID) error { + upd := table.Accounts.UPDATE(table.Accounts.IsGuest, table.Accounts.UpdatedAt). + SET(postgres.Bool(false), postgres.TimestampzT(time.Now().UTC())). + WHERE( + table.Accounts.AccountID.EQ(postgres.UUID(accountID)). + AND(table.Accounts.IsGuest.EQ(postgres.Bool(true))), + ) + if _, err := upd.ExecContext(ctx, s.db); err != nil { + return fmt.Errorf("account: clear guest %s: %w", accountID, err) + } + return nil +} diff --git a/backend/internal/accountmerge/merge.go b/backend/internal/accountmerge/merge.go new file mode 100644 index 0000000..f7fe606 --- /dev/null +++ b/backend/internal/accountmerge/merge.go @@ -0,0 +1,497 @@ +// Package accountmerge retires a secondary account into a primary one in a single +// transaction: it sums statistics and the hint wallet, ORs the paid flag, repoints +// the secondary's identities, transfers its games/chat/complaints/invitations, +// de-duplicates friends and blocks, and leaves the secondary as an audit tombstone +// (accounts.merged_into). It is the data core of Stage 11 account linking & merge +// (ARCHITECTURE.md §4); session revocation and any session switch are orchestrated +// one layer up (the link service), since the in-memory session cache lives there. +package accountmerge + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/go-jet/jet/v2/postgres" + "github.com/go-jet/jet/v2/qrm" + "github.com/google/uuid" + + "scrabble/backend/internal/postgres/jet/backend/model" + "scrabble/backend/internal/postgres/jet/backend/table" +) + +// statusActive mirrors game.StatusActive; the active-shared-game guard reads it +// without taking a dependency on the game package. +const statusActive = "active" + +// Friendship statuses, highest precedence first, mirroring internal/social. +const ( + friendAccepted = "accepted" + friendPending = "pending" + friendDeclined = "declined" +) + +// ErrActiveGameConflict is returned when the primary and secondary accounts share +// an active game: merging would seat one player against themselves, so the caller +// must wait for the game to finish. +var ErrActiveGameConflict = errors.New("accountmerge: primary and secondary share an active game") + +// ErrSameAccount is returned when primary and secondary are the same account. +var ErrSameAccount = errors.New("accountmerge: primary and secondary are the same account") + +// Merger merges accounts over a Postgres handle. +type Merger struct { + db *sql.DB + now func() time.Time +} + +// NewMerger constructs a Merger over db. +func NewMerger(db *sql.DB) *Merger { + return &Merger{db: db, now: func() time.Time { return time.Now().UTC() }} +} + +// Merge retires secondary into primary atomically. The secondary is kept as a +// tombstone (merged_into=primary) so the no-cascade foreign keys of any shared +// finished game stay valid; its seat in such a game is left untouched. The merge +// is refused with ErrActiveGameConflict when the two share an active game. +func (m *Merger) Merge(ctx context.Context, primary, secondary uuid.UUID) error { + if primary == secondary { + return ErrSameAccount + } + now := m.now() + return withTx(ctx, m.db, func(tx *sql.Tx) error { + if err := guardActiveSharedGame(ctx, tx, primary, secondary); err != nil { + return err + } + if err := mergeStats(ctx, tx, primary, secondary, now); err != nil { + return err + } + if err := mergeAccountFields(ctx, tx, primary, secondary, now); err != nil { + return err + } + if err := reassignColumn(ctx, tx, table.Identities, table.Identities.AccountID, primary, secondary); err != nil { + return fmt.Errorf("accountmerge: identities: %w", err) + } + if err := transferGamePlayers(ctx, tx, primary, secondary); err != nil { + return err + } + if err := reassignColumn(ctx, tx, table.ChatMessages, table.ChatMessages.SenderID, primary, secondary); err != nil { + return fmt.Errorf("accountmerge: chat: %w", err) + } + if err := reassignColumn(ctx, tx, table.Complaints, table.Complaints.ComplainantID, primary, secondary); err != nil { + return fmt.Errorf("accountmerge: complaints: %w", err) + } + if err := mergeFriendships(ctx, tx, primary, secondary); err != nil { + return err + } + if err := mergeBlocks(ctx, tx, primary, secondary); err != nil { + return err + } + if err := mergeInvitations(ctx, tx, primary, secondary); err != nil { + return err + } + if err := deleteEphemerals(ctx, tx, secondary); err != nil { + return err + } + return tombstone(ctx, tx, primary, secondary, now) + }) +} + +// guardActiveSharedGame returns ErrActiveGameConflict when primary and secondary +// are both seated in the same active game. +func guardActiveSharedGame(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID) error { + pri, err := activeGameIDs(ctx, tx, primary) + if err != nil { + return err + } + if len(pri) == 0 { + return nil + } + sec, err := activeGameIDs(ctx, tx, secondary) + if err != nil { + return err + } + have := make(map[uuid.UUID]struct{}, len(pri)) + for _, id := range pri { + have[id] = struct{}{} + } + for _, id := range sec { + if _, ok := have[id]; ok { + return ErrActiveGameConflict + } + } + return nil +} + +// activeGameIDs lists the active games accountID is seated in. +func activeGameIDs(ctx context.Context, tx *sql.Tx, accountID uuid.UUID) ([]uuid.UUID, error) { + stmt := postgres.SELECT(table.GamePlayers.GameID). + FROM(table.GamePlayers.INNER_JOIN(table.Games, table.Games.GameID.EQ(table.GamePlayers.GameID))). + WHERE( + table.GamePlayers.AccountID.EQ(postgres.UUID(accountID)). + AND(table.Games.Status.EQ(postgres.String(statusActive))), + ) + var rows []model.GamePlayers + if err := stmt.QueryContext(ctx, tx, &rows); err != nil { + if errors.Is(err, qrm.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("accountmerge: active games %s: %w", accountID, err) + } + out := make([]uuid.UUID, 0, len(rows)) + for _, r := range rows { + out = append(out, r.GameID) + } + return out, nil +} + +// mergeStats folds secondary's lifetime statistics into primary (wins/losses/draws +// summed, max points kept) and deletes the secondary row. +func mergeStats(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error { + var sec model.AccountStats + err := postgres.SELECT(table.AccountStats.AllColumns). + FROM(table.AccountStats). + WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(secondary))). + QueryContext(ctx, tx, &sec) + if errors.Is(err, qrm.ErrNoRows) { + return nil + } + if err != nil { + return fmt.Errorf("accountmerge: load secondary stats: %w", err) + } + + ensure := table.AccountStats.INSERT(table.AccountStats.AccountID). + VALUES(primary).ON_CONFLICT(table.AccountStats.AccountID).DO_NOTHING() + if _, err := ensure.ExecContext(ctx, tx); err != nil { + return fmt.Errorf("accountmerge: ensure primary stats: %w", err) + } + var pri model.AccountStats + if err := postgres.SELECT(table.AccountStats.AllColumns). + FROM(table.AccountStats). + WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(primary))). + FOR(postgres.UPDATE()). + QueryContext(ctx, tx, &pri); err != nil { + return fmt.Errorf("accountmerge: lock primary stats: %w", err) + } + + upd := table.AccountStats.UPDATE( + table.AccountStats.Wins, table.AccountStats.Losses, table.AccountStats.Draws, + table.AccountStats.MaxGamePoints, table.AccountStats.MaxWordPoints, table.AccountStats.UpdatedAt, + ).SET( + postgres.Int(int64(pri.Wins+sec.Wins)), + postgres.Int(int64(pri.Losses+sec.Losses)), + postgres.Int(int64(pri.Draws+sec.Draws)), + postgres.Int(int64(max(pri.MaxGamePoints, sec.MaxGamePoints))), + postgres.Int(int64(max(pri.MaxWordPoints, sec.MaxWordPoints))), + postgres.TimestampzT(now), + ).WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(primary))) + if _, err := upd.ExecContext(ctx, tx); err != nil { + return fmt.Errorf("accountmerge: update primary stats: %w", err) + } + + del := table.AccountStats.DELETE().WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(secondary))) + if _, err := del.ExecContext(ctx, tx); err != nil { + return fmt.Errorf("accountmerge: delete secondary stats: %w", err) + } + return nil +} + +// mergeAccountFields adds secondary's hint wallet to primary and ORs the paid flag; +// all other profile fields stay the primary's. +func mergeAccountFields(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error { + var sec model.Accounts + if err := postgres.SELECT(table.Accounts.AllColumns). + FROM(table.Accounts). + WHERE(table.Accounts.AccountID.EQ(postgres.UUID(secondary))). + QueryContext(ctx, tx, &sec); err != nil { + return fmt.Errorf("accountmerge: load secondary account: %w", err) + } + upd := table.Accounts.UPDATE( + table.Accounts.HintBalance, table.Accounts.PaidAccount, table.Accounts.UpdatedAt, + ).SET( + table.Accounts.HintBalance.ADD(postgres.Int(int64(sec.HintBalance))), + table.Accounts.PaidAccount.OR(postgres.Bool(sec.PaidAccount)), + postgres.TimestampzT(now), + ).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(primary))) + if _, err := upd.ExecContext(ctx, tx); err != nil { + return fmt.Errorf("accountmerge: update primary account: %w", err) + } + return nil +} + +// transferGamePlayers moves secondary's seats to primary, except in a game primary +// already sits in (a shared finished game — active is barred by the guard), where +// the secondary seat is left as the tombstone so the no-cascade FK stays valid. +func transferGamePlayers(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID) error { + var prows []model.GamePlayers + if err := postgres.SELECT(table.GamePlayers.GameID). + FROM(table.GamePlayers). + WHERE(table.GamePlayers.AccountID.EQ(postgres.UUID(primary))). + QueryContext(ctx, tx, &prows); err != nil { + if !errors.Is(err, qrm.ErrNoRows) { + return fmt.Errorf("accountmerge: primary seats: %w", err) + } + } + cond := table.GamePlayers.AccountID.EQ(postgres.UUID(secondary)) + if len(prows) > 0 { + ids := make([]postgres.Expression, len(prows)) + for i, r := range prows { + ids[i] = postgres.UUID(r.GameID) + } + cond = cond.AND(table.GamePlayers.GameID.NOT_IN(ids...)) + } + upd := table.GamePlayers.UPDATE(table.GamePlayers.AccountID).SET(postgres.UUID(primary)).WHERE(cond) + if _, err := upd.ExecContext(ctx, tx); err != nil { + return fmt.Errorf("accountmerge: transfer seats: %w", err) + } + return nil +} + +// reassignColumn blanket-reassigns a no-collision account column from secondary to +// primary (identities, chat sender, complaint complainant). +func reassignColumn(ctx context.Context, tx *sql.Tx, tbl postgres.Table, col postgres.ColumnString, primary, secondary uuid.UUID) error { + upd := tbl.UPDATE(col).SET(postgres.UUID(primary)). + WHERE(col.EQ(postgres.UUID(secondary))) + _, err := upd.ExecContext(ctx, tx) + return err +} + +// friendRank ranks a friendship status for dedupe precedence (higher wins). +func friendRank(status string) int { + switch status { + case friendAccepted: + return 3 + case friendPending: + return 2 + case friendDeclined: + return 1 + default: + return 0 + } +} + +// mergeFriendships repoints secondary's friendships to primary, dropping the direct +// primary-secondary edge (it would become a self-edge) and de-duplicating a shared +// counterparty by keeping the higher-precedence status (accepted > pending > +// declined). Each account has at most one edge per unordered pair, so the per-other +// decision is unambiguous. +func mergeFriendships(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID) error { + if err := deletePair(ctx, tx, table.Friendships.DELETE(), + table.Friendships.RequesterID, table.Friendships.AddresseeID, primary, secondary); err != nil { + return fmt.Errorf("accountmerge: drop self-friendship: %w", err) + } + + priByOther := map[uuid.UUID]string{} + var prows []model.Friendships + if err := selectEdges(ctx, tx, table.Friendships, table.Friendships.AllColumns, table.Friendships.RequesterID, table.Friendships.AddresseeID, primary, &prows); err != nil { + return fmt.Errorf("accountmerge: primary friendships: %w", err) + } + for _, r := range prows { + priByOther[otherOf(r.RequesterID, r.AddresseeID, primary)] = r.Status + } + + var srows []model.Friendships + if err := selectEdges(ctx, tx, table.Friendships, table.Friendships.AllColumns, table.Friendships.RequesterID, table.Friendships.AddresseeID, secondary, &srows); err != nil { + return fmt.Errorf("accountmerge: secondary friendships: %w", err) + } + for _, r := range srows { + other := otherOf(r.RequesterID, r.AddresseeID, secondary) + if priStatus, ok := priByOther[other]; ok { + if friendRank(r.Status) <= friendRank(priStatus) { + if err := deleteEdge(ctx, tx, table.Friendships.DELETE(), + table.Friendships.RequesterID, table.Friendships.AddresseeID, r.RequesterID, r.AddresseeID); err != nil { + return fmt.Errorf("accountmerge: drop dominated friendship: %w", err) + } + continue + } + if err := deletePair(ctx, tx, table.Friendships.DELETE(), + table.Friendships.RequesterID, table.Friendships.AddresseeID, primary, other); err != nil { + return fmt.Errorf("accountmerge: drop superseded friendship: %w", err) + } + } + if err := repointEdge(ctx, tx, table.Friendships, table.Friendships.RequesterID, table.Friendships.AddresseeID, + r.RequesterID, r.AddresseeID, primary, secondary); err != nil { + return fmt.Errorf("accountmerge: repoint friendship: %w", err) + } + } + return nil +} + +// mergeBlocks repoints secondary's blocks to primary, dropping the direct +// primary-secondary block (a self-block) and de-duplicating a counterparty already +// blocked by primary in either direction (a block is undirected for suppression). +func mergeBlocks(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID) error { + if err := deletePair(ctx, tx, table.Blocks.DELETE(), + table.Blocks.BlockerID, table.Blocks.BlockedID, primary, secondary); err != nil { + return fmt.Errorf("accountmerge: drop self-block: %w", err) + } + + priOthers := map[uuid.UUID]struct{}{} + var prows []model.Blocks + if err := selectEdges(ctx, tx, table.Blocks, table.Blocks.AllColumns, table.Blocks.BlockerID, table.Blocks.BlockedID, primary, &prows); err != nil { + return fmt.Errorf("accountmerge: primary blocks: %w", err) + } + for _, r := range prows { + priOthers[otherOf(r.BlockerID, r.BlockedID, primary)] = struct{}{} + } + + var srows []model.Blocks + if err := selectEdges(ctx, tx, table.Blocks, table.Blocks.AllColumns, table.Blocks.BlockerID, table.Blocks.BlockedID, secondary, &srows); err != nil { + return fmt.Errorf("accountmerge: secondary blocks: %w", err) + } + for _, r := range srows { + if _, ok := priOthers[otherOf(r.BlockerID, r.BlockedID, secondary)]; ok { + if err := deleteEdge(ctx, tx, table.Blocks.DELETE(), + table.Blocks.BlockerID, table.Blocks.BlockedID, r.BlockerID, r.BlockedID); err != nil { + return fmt.Errorf("accountmerge: drop dup block: %w", err) + } + continue + } + if err := repointEdge(ctx, tx, table.Blocks, table.Blocks.BlockerID, table.Blocks.BlockedID, + r.BlockerID, r.BlockedID, primary, secondary); err != nil { + return fmt.Errorf("accountmerge: repoint block: %w", err) + } + } + return nil +} + +// mergeInvitations deletes secondary's pending invitations as inviter (cascading to +// their invitees) and repoints its invitee rows to primary, dropping a row where +// primary is already an invitee of the same invitation. +func mergeInvitations(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID) error { + delInv := table.GameInvitations.DELETE(). + WHERE(table.GameInvitations.InviterID.EQ(postgres.UUID(secondary))) + if _, err := delInv.ExecContext(ctx, tx); err != nil { + return fmt.Errorf("accountmerge: delete secondary invitations: %w", err) + } + + priInv := map[uuid.UUID]struct{}{} + var prows []model.GameInvitationInvitees + if err := postgres.SELECT(table.GameInvitationInvitees.InvitationID). + FROM(table.GameInvitationInvitees). + WHERE(table.GameInvitationInvitees.AccountID.EQ(postgres.UUID(primary))). + QueryContext(ctx, tx, &prows); err != nil && !errors.Is(err, qrm.ErrNoRows) { + return fmt.Errorf("accountmerge: primary invitees: %w", err) + } + for _, r := range prows { + priInv[r.InvitationID] = struct{}{} + } + + var srows []model.GameInvitationInvitees + if err := postgres.SELECT(table.GameInvitationInvitees.InvitationID). + FROM(table.GameInvitationInvitees). + WHERE(table.GameInvitationInvitees.AccountID.EQ(postgres.UUID(secondary))). + QueryContext(ctx, tx, &srows); err != nil && !errors.Is(err, qrm.ErrNoRows) { + return fmt.Errorf("accountmerge: secondary invitees: %w", err) + } + for _, r := range srows { + where := table.GameInvitationInvitees.InvitationID.EQ(postgres.UUID(r.InvitationID)). + AND(table.GameInvitationInvitees.AccountID.EQ(postgres.UUID(secondary))) + if _, dup := priInv[r.InvitationID]; dup { + if _, err := table.GameInvitationInvitees.DELETE().WHERE(where).ExecContext(ctx, tx); err != nil { + return fmt.Errorf("accountmerge: drop dup invitee: %w", err) + } + continue + } + upd := table.GameInvitationInvitees.UPDATE(table.GameInvitationInvitees.AccountID). + SET(postgres.UUID(primary)).WHERE(where) + if _, err := upd.ExecContext(ctx, tx); err != nil { + return fmt.Errorf("accountmerge: repoint invitee: %w", err) + } + } + return nil +} + +// deleteEphemerals drops the secondary's pending email confirmations and friend +// codes (short-lived, single-use; not worth carrying over). +func deleteEphemerals(ctx context.Context, tx *sql.Tx, secondary uuid.UUID) error { + if _, err := table.EmailConfirmations.DELETE(). + WHERE(table.EmailConfirmations.AccountID.EQ(postgres.UUID(secondary))). + ExecContext(ctx, tx); err != nil { + return fmt.Errorf("accountmerge: delete confirmations: %w", err) + } + if _, err := table.FriendCodes.DELETE(). + WHERE(table.FriendCodes.AccountID.EQ(postgres.UUID(secondary))). + ExecContext(ctx, tx); err != nil { + return fmt.Errorf("accountmerge: delete friend codes: %w", err) + } + return nil +} + +// tombstone marks secondary retired, pointing at primary for audit. +func tombstone(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error { + upd := table.Accounts.UPDATE(table.Accounts.MergedInto, table.Accounts.MergedAt, table.Accounts.UpdatedAt). + SET(postgres.UUID(primary), postgres.TimestampzT(now), postgres.TimestampzT(now)). + WHERE(table.Accounts.AccountID.EQ(postgres.UUID(secondary))) + if _, err := upd.ExecContext(ctx, tx); err != nil { + return fmt.Errorf("accountmerge: tombstone secondary: %w", err) + } + return nil +} + +// otherOf returns the endpoint of a two-account edge that is not self. +func otherOf(a, b, self uuid.UUID) uuid.UUID { + if a == self { + return b + } + return a +} + +// selectEdges loads the rows of a symmetric two-column edge table touching account. +func selectEdges[T any](ctx context.Context, tx *sql.Tx, tbl postgres.Table, cols postgres.Projection, left, right postgres.ColumnString, account uuid.UUID, dest *[]T) error { + err := postgres.SELECT(cols). + FROM(tbl). + WHERE(left.EQ(postgres.UUID(account)).OR(right.EQ(postgres.UUID(account)))). + QueryContext(ctx, tx, dest) + if errors.Is(err, qrm.ErrNoRows) { + return nil + } + return err +} + +// deletePair deletes the directed-or-reverse edge between a and b. +func deletePair(ctx context.Context, tx *sql.Tx, del postgres.DeleteStatement, left, right postgres.ColumnString, a, b uuid.UUID) error { + cond := left.EQ(postgres.UUID(a)).AND(right.EQ(postgres.UUID(b))). + OR(left.EQ(postgres.UUID(b)).AND(right.EQ(postgres.UUID(a)))) + _, err := del.WHERE(cond).ExecContext(ctx, tx) + return err +} + +// deleteEdge deletes the single edge identified by its (left, right) primary key. +func deleteEdge(ctx context.Context, tx *sql.Tx, del postgres.DeleteStatement, left, right postgres.ColumnString, l, r uuid.UUID) error { + cond := left.EQ(postgres.UUID(l)).AND(right.EQ(postgres.UUID(r))) + _, err := del.WHERE(cond).ExecContext(ctx, tx) + return err +} + +// repointEdge replaces the secondary endpoint of edge (l, r) with primary, keeping +// the edge's direction. +func repointEdge(ctx context.Context, tx *sql.Tx, tbl postgres.Table, left, right postgres.ColumnString, l, r, primary, secondary uuid.UUID) error { + var col postgres.ColumnString + var where postgres.BoolExpression + if l == secondary { + col, where = left, left.EQ(postgres.UUID(secondary)).AND(right.EQ(postgres.UUID(r))) + } else { + col, where = right, left.EQ(postgres.UUID(l)).AND(right.EQ(postgres.UUID(secondary))) + } + _, err := tbl.UPDATE(col).SET(postgres.UUID(primary)).WHERE(where).ExecContext(ctx, tx) + return err +} + +// withTx wraps fn in a transaction, committing on nil and rolling back on error. +func withTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("accountmerge: begin tx: %w", err) + } + if err := fn(tx); err != nil { + _ = tx.Rollback() + return err + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("accountmerge: commit tx: %w", err) + } + return nil +} diff --git a/backend/internal/adminconsole/templates/pages/user_detail.gohtml b/backend/internal/adminconsole/templates/pages/user_detail.gohtml index 824ca10..4e6c6bd 100644 --- a/backend/internal/adminconsole/templates/pages/user_detail.gohtml +++ b/backend/internal/adminconsole/templates/pages/user_detail.gohtml @@ -10,7 +10,9 @@
  • Timezone {{.TimeZone}}
  • Guest {{if .Guest}}yes{{else}}no{{end}}
  • Push {{if .NotificationsInAppOnly}}in-app only{{else}}out-of-app{{end}}
  • +
  • Paid {{if .PaidAccount}}yes{{else}}no{{end}}
  • Hint wallet {{.HintBalance}}
  • +{{if .MergedInto}}
  • Merged into {{.MergedInto}}
  • {{end}}
  • Created {{.CreatedAt}}
  • diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go index 7384ac1..d05ddc6 100644 --- a/backend/internal/adminconsole/views.go +++ b/backend/internal/adminconsole/views.go @@ -68,14 +68,18 @@ type UserDetailView struct { TimeZone string Guest bool NotificationsInAppOnly bool - HintBalance int - CreatedAt string - HasStats bool - Stats StatsRow - Identities []IdentityRow - Games []GameRow - TelegramID string - ConnectorEnabled bool + PaidAccount bool + // MergedInto is the primary account id when this account has been retired by a + // merge (Stage 11), or empty for a live account. + MergedInto string + HintBalance int + CreatedAt string + HasStats bool + Stats StatsRow + Identities []IdentityRow + Games []GameRow + TelegramID string + ConnectorEnabled bool } // StatsRow is an account's lifetime statistics. diff --git a/backend/internal/inttest/merge_test.go b/backend/internal/inttest/merge_test.go new file mode 100644 index 0000000..8f1f906 --- /dev/null +++ b/backend/internal/inttest/merge_test.go @@ -0,0 +1,302 @@ +//go:build integration + +package inttest + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + + "scrabble/backend/internal/account" + "scrabble/backend/internal/accountmerge" + "scrabble/backend/internal/engine" + "scrabble/backend/internal/game" + "scrabble/backend/internal/link" + "scrabble/backend/internal/session" +) + +// --- merge test helpers --- + +func setStats(t *testing.T, id uuid.UUID, w, l, d, mg, mw int) { + t.Helper() + if _, err := testDB.ExecContext(context.Background(), + `INSERT INTO backend.account_stats (account_id, wins, losses, draws, max_game_points, max_word_points) + VALUES ($1,$2,$3,$4,$5,$6) + ON CONFLICT (account_id) DO UPDATE SET wins=$2, losses=$3, draws=$4, max_game_points=$5, max_word_points=$6`, + id, w, l, d, mg, mw); err != nil { + t.Fatalf("set stats: %v", err) + } +} + +func setWallet(t *testing.T, id uuid.UUID, hints int, paid bool) { + t.Helper() + if _, err := testDB.ExecContext(context.Background(), + `UPDATE backend.accounts SET hint_balance=$2, paid_account=$3 WHERE account_id=$1`, id, hints, paid); err != nil { + t.Fatalf("set wallet: %v", err) + } +} + +func bindEmailIdentity(t *testing.T, acc uuid.UUID, email string) { + t.Helper() + if _, err := testDB.ExecContext(context.Background(), + `INSERT INTO backend.identities (identity_id, account_id, kind, external_id, confirmed) VALUES ($1,$2,'email',$3,true)`, + uuid.New(), acc, email); err != nil { + t.Fatalf("bind email identity: %v", err) + } +} + +func insertFriendship(t *testing.T, a, b uuid.UUID, status string) { + t.Helper() + if _, err := testDB.ExecContext(context.Background(), + `INSERT INTO backend.friendships (requester_id, addressee_id, status) VALUES ($1,$2,$3)`, a, b, status); err != nil { + t.Fatalf("insert friendship: %v", err) + } +} + +func mergedInto(t *testing.T, id uuid.UUID) uuid.UUID { + t.Helper() + var into *uuid.UUID + if err := testDB.QueryRowContext(context.Background(), + `SELECT merged_into FROM backend.accounts WHERE account_id=$1`, id).Scan(&into); err != nil { + t.Fatalf("read merged_into: %v", err) + } + if into == nil { + return uuid.Nil + } + return *into +} + +func seatCount(t *testing.T, gameID, accountID uuid.UUID) int { + t.Helper() + var n int + if err := testDB.QueryRowContext(context.Background(), + `SELECT count(*) FROM backend.game_players WHERE game_id=$1 AND account_id=$2`, gameID, accountID).Scan(&n); err != nil { + t.Fatalf("seat count: %v", err) + } + return n +} + +func seatGame(t *testing.T, seats []uuid.UUID, timeout time.Duration) uuid.UUID { + t.Helper() + g, err := newGameService().Create(context.Background(), game.CreateParams{ + Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: timeout, Seed: openingSeed(t), + }) + if err != nil { + t.Fatalf("seat game: %v", err) + } + return g.ID +} + +func newLinkService(mailer account.Mailer) *link.Service { + store := account.NewStore(testDB) + emails := account.NewEmailService(store, mailer) + sessions := session.NewService(session.NewStore(testDB), session.NewCache()) + return link.NewService(emails, store, accountmerge.NewMerger(testDB), sessions) +} + +// TestAccountMergeCore folds every account-scoped artifact of a secondary into a +// primary: stats summed, wallet summed, paid flag ORed, identity repointed, a +// non-shared game transferred, a friend carried over, and the secondary tombstoned. +func TestAccountMergeCore(t *testing.T) { + ctx := context.Background() + store := account.NewStore(testDB) + merger := accountmerge.NewMerger(testDB) + + primary := provisionAccount(t) + secondary := provisionAccount(t) + friend := provisionAccount(t) + + setStats(t, primary, 1, 0, 0, 100, 90) + setStats(t, secondary, 3, 1, 2, 400, 80) + setWallet(t, primary, 2, false) + setWallet(t, secondary, 5, true) + + email := "merge-" + uuid.NewString() + "@example.com" + bindEmailIdentity(t, secondary, email) + insertFriendship(t, secondary, friend, "accepted") + gameID := seatGame(t, []uuid.UUID{secondary, friend}, 24*time.Hour) + + if err := merger.Merge(ctx, primary, secondary); err != nil { + t.Fatalf("merge: %v", err) + } + + w, l, d, mg, mw, found := readStats(t, primary) + if !found || w != 4 || l != 1 || d != 2 || mg != 400 || mw != 90 { + t.Errorf("primary stats = (%d,%d,%d,%d,%d found=%v), want (4,1,2,400,90 true)", w, l, d, mg, mw, found) + } + if _, _, _, _, _, found := readStats(t, secondary); found { + t.Error("secondary stats row should be deleted after merge") + } + + acc, err := store.GetByID(ctx, primary) + if err != nil { + t.Fatalf("get primary: %v", err) + } + if acc.HintBalance != 7 { + t.Errorf("hint balance = %d, want 7", acc.HintBalance) + } + if !acc.PaidAccount { + t.Error("paid_account should be true (ORed from secondary)") + } + + if owner, ok, _ := store.AccountIDByIdentity(ctx, account.KindEmail, email); !ok || owner != primary { + t.Errorf("email owner = %s ok=%v, want primary %s", owner, ok, primary) + } + if seatCount(t, gameID, primary) != 1 || seatCount(t, gameID, secondary) != 0 { + t.Error("non-shared game seat should transfer to primary") + } + if friends, _ := newSocialService().ListFriends(ctx, primary); len(friends) != 1 || friends[0] != friend { + t.Errorf("primary friends = %v, want [%s]", friends, friend) + } + if mergedInto(t, secondary) != primary { + t.Errorf("secondary.merged_into = %s, want primary %s", mergedInto(t, secondary), primary) + } +} + +// TestAccountMergeActiveGameConflict refuses a merge when the two share an active +// game (one player cannot be merged against themselves). +func TestAccountMergeActiveGameConflict(t *testing.T) { + ctx := context.Background() + merger := accountmerge.NewMerger(testDB) + primary := provisionAccount(t) + secondary := provisionAccount(t) + seatGame(t, []uuid.UUID{primary, secondary}, 24*time.Hour) + + if err := merger.Merge(ctx, primary, secondary); err != accountmerge.ErrActiveGameConflict { + t.Fatalf("merge = %v, want ErrActiveGameConflict", err) + } + if mergedInto(t, secondary) != uuid.Nil { + t.Error("a refused merge must not tombstone the secondary") + } +} + +// TestAccountMergeFinishedSharedGameKept allows a merge when the shared game is +// finished and leaves the secondary's seat in place (the tombstone keeps the +// no-cascade foreign key valid). +func TestAccountMergeFinishedSharedGameKept(t *testing.T) { + ctx := context.Background() + merger := accountmerge.NewMerger(testDB) + primary := provisionAccount(t) + secondary := provisionAccount(t) + gameID := seatGame(t, []uuid.UUID{primary, secondary}, 24*time.Hour) + if _, err := testDB.ExecContext(ctx, `UPDATE backend.games SET status='finished' WHERE game_id=$1`, gameID); err != nil { + t.Fatalf("finish game: %v", err) + } + + if err := merger.Merge(ctx, primary, secondary); err != nil { + t.Fatalf("merge: %v", err) + } + if seatCount(t, gameID, primary) != 1 || seatCount(t, gameID, secondary) != 1 { + t.Error("a shared finished game must keep both seats (secondary as tombstone)") + } + if mergedInto(t, secondary) != primary { + t.Error("secondary should be tombstoned") + } +} + +// TestAccountLinkFreeEmail binds a free email and promotes a guest to durable. +func TestAccountLinkFreeEmail(t *testing.T) { + ctx := context.Background() + store := account.NewStore(testDB) + mailer := &capturingMailer{} + links := newLinkService(mailer) + + guest := provisionGuest(t) + email := "fresh-" + uuid.NewString() + "@example.com" + if err := links.RequestEmail(ctx, guest, email); err != nil { + t.Fatalf("request: %v", err) + } + res, err := links.ConfirmEmail(ctx, guest, email, sixDigit.FindString(mailer.lastBody)) + if err != nil { + t.Fatalf("confirm: %v", err) + } + if !res.Linked || res.MergeRequired { + t.Fatalf("confirm = %+v, want linked", res) + } + acc, _ := store.GetByID(ctx, guest) + if acc.IsGuest { + t.Error("guest flag should clear once an identity is linked") + } + if owner, ok, _ := store.AccountIDByIdentity(ctx, account.KindEmail, email); !ok || owner != guest { + t.Errorf("email owner = %s, want the promoted guest %s", owner, guest) + } +} + +// TestAccountLinkEmailMergeIntoCaller merges the email's owner into the current +// (durable) account: the caller stays primary and keeps its session. +func TestAccountLinkEmailMergeIntoCaller(t *testing.T) { + ctx := context.Background() + store := account.NewStore(testDB) + mailer := &capturingMailer{} + links := newLinkService(mailer) + + caller := provisionAccount(t) + other := provisionAccount(t) + email := "owned-" + uuid.NewString() + "@example.com" + bindEmailIdentity(t, other, email) + + if err := links.RequestEmail(ctx, caller, email); err != nil { + t.Fatalf("request: %v", err) + } + code := sixDigit.FindString(mailer.lastBody) + confirm, err := links.ConfirmEmail(ctx, caller, email, code) + if err != nil { + t.Fatalf("confirm: %v", err) + } + if !confirm.MergeRequired || confirm.SecondaryID != other { + t.Fatalf("confirm = %+v, want merge_required to other %s", confirm, other) + } + merge, err := links.MergeEmail(ctx, caller, email, code) + if err != nil { + t.Fatalf("merge: %v", err) + } + if merge.PrimaryID != caller || merge.SwitchedToken != "" { + t.Fatalf("merge = %+v, want primary caller and no session switch", merge) + } + if mergedInto(t, other) != caller { + t.Error("other should be tombstoned into caller") + } + if owner, _, _ := store.AccountIDByIdentity(ctx, account.KindEmail, email); owner != caller { + t.Errorf("email owner = %s, want caller", owner) + } +} + +// TestAccountLinkGuestInversion merges a guest initiator into the durable account +// that owns the email: the durable account wins and a fresh session is minted. +func TestAccountLinkGuestInversion(t *testing.T) { + ctx := context.Background() + store := account.NewStore(testDB) + mailer := &capturingMailer{} + links := newLinkService(mailer) + + durable := provisionAccount(t) + email := "durable-" + uuid.NewString() + "@example.com" + bindEmailIdentity(t, durable, email) + guest := provisionGuest(t) + + if err := links.RequestEmail(ctx, guest, email); err != nil { + t.Fatalf("request: %v", err) + } + code := sixDigit.FindString(mailer.lastBody) + if _, err := links.ConfirmEmail(ctx, guest, email, code); err != nil { + t.Fatalf("confirm: %v", err) + } + merge, err := links.MergeEmail(ctx, guest, email, code) + if err != nil { + t.Fatalf("merge: %v", err) + } + if merge.PrimaryID != durable { + t.Fatalf("primary = %s, want durable %s", merge.PrimaryID, durable) + } + if merge.SwitchedToken == "" { + t.Error("a guest initiator whose durable counterpart wins must get a switched session token") + } + if mergedInto(t, guest) != durable { + t.Error("the guest should be tombstoned into the durable account") + } + if owner, _, _ := store.AccountIDByIdentity(ctx, account.KindEmail, email); owner != durable { + t.Errorf("email owner = %s, want durable", owner) + } +} diff --git a/backend/internal/link/service.go b/backend/internal/link/service.go new file mode 100644 index 0000000..3057a8b --- /dev/null +++ b/backend/internal/link/service.go @@ -0,0 +1,163 @@ +// Package link orchestrates account linking & merge (Stage 11, ARCHITECTURE.md §4). +// It sits above the account, accountmerge and session layers: it verifies the +// caller's control of an identity (an email confirm-code or a gateway-validated +// platform identity), binds a free identity to the current account, and — when the +// identity already has its own account — merges the two. The current account is the +// merge primary, except when the initiator is a guest and the other account is +// durable, in which case the durable account wins and a fresh session is minted for +// it (the client switches to it). +package link + +import ( + "context" + + "github.com/google/uuid" + + "scrabble/backend/internal/account" + "scrabble/backend/internal/accountmerge" + "scrabble/backend/internal/session" +) + +// Service drives the link/merge flow. +type Service struct { + emails *account.EmailService + accounts *account.Store + merger *accountmerge.Merger + sessions *session.Service +} + +// NewService constructs a Service over its collaborators. +func NewService(emails *account.EmailService, accounts *account.Store, merger *accountmerge.Merger, sessions *session.Service) *Service { + return &Service{emails: emails, accounts: accounts, merger: merger, sessions: sessions} +} + +// ConfirmResult reports the outcome of a confirm step. Exactly one of Linked or +// MergeRequired is set; SecondaryID is the account to be retired when a merge is +// required (the caller renders an irreversible-merge confirmation from it). +type ConfirmResult struct { + Linked bool + MergeRequired bool + SecondaryID uuid.UUID +} + +// MergeResult reports a completed merge. PrimaryID is the surviving account. +// SwitchedToken is a fresh session token for the primary when the active account +// changed (a guest initiator whose durable counterpart won); empty otherwise, in +// which case the caller keeps its current session. +type MergeResult struct { + PrimaryID uuid.UUID + SwitchedToken string +} + +// RequestEmail mails a confirm-code for email to the caller (always sent). +func (s *Service) RequestEmail(ctx context.Context, accountID uuid.UUID, email string) error { + return s.emails.RequestLinkCode(ctx, accountID, email) +} + +// ConfirmEmail verifies the code and either binds the free address to the caller +// (Linked) or reports that the address belongs to another account (MergeRequired). +func (s *Service) ConfirmEmail(ctx context.Context, accountID uuid.UUID, email, code string) (ConfirmResult, error) { + owner, linked, err := s.emails.ConfirmLink(ctx, accountID, email, code) + if err != nil { + return ConfirmResult{}, err + } + if linked { + if err := s.accounts.ClearGuest(ctx, accountID); err != nil { + return ConfirmResult{}, err + } + return ConfirmResult{Linked: true}, nil + } + return ConfirmResult{MergeRequired: true, SecondaryID: owner}, nil +} + +// MergeEmail re-verifies the code and merges the address's account into the +// caller's (subject to the guest-primary rule). +func (s *Service) MergeEmail(ctx context.Context, callerID uuid.UUID, email, code string) (MergeResult, error) { + owner, linked, err := s.emails.ConfirmLink(ctx, callerID, email, code) + if err != nil { + return MergeResult{}, err + } + if linked { + // Raced to free/self between confirm and merge: it is now simply linked. + if err := s.accounts.ClearGuest(ctx, callerID); err != nil { + return MergeResult{}, err + } + return MergeResult{PrimaryID: callerID}, nil + } + return s.merge(ctx, callerID, owner) +} + +// ConfirmTelegram attaches a gateway-validated Telegram identity to the caller +// (Linked) or reports that it belongs to another account (MergeRequired). +func (s *Service) ConfirmTelegram(ctx context.Context, callerID uuid.UUID, externalID string) (ConfirmResult, error) { + owner, ok, err := s.accounts.AccountIDByIdentity(ctx, account.KindTelegram, externalID) + if err != nil { + return ConfirmResult{}, err + } + if !ok { + if err := s.attachTelegram(ctx, callerID, externalID); err != nil { + return ConfirmResult{}, err + } + return ConfirmResult{Linked: true}, nil + } + if owner == callerID { + return ConfirmResult{Linked: true}, nil + } + return ConfirmResult{MergeRequired: true, SecondaryID: owner}, nil +} + +// MergeTelegram merges the account owning a gateway-validated Telegram identity +// into the caller's (subject to the guest-primary rule). +func (s *Service) MergeTelegram(ctx context.Context, callerID uuid.UUID, externalID string) (MergeResult, error) { + owner, ok, err := s.accounts.AccountIDByIdentity(ctx, account.KindTelegram, externalID) + if err != nil { + return MergeResult{}, err + } + if !ok { + if err := s.attachTelegram(ctx, callerID, externalID); err != nil { + return MergeResult{}, err + } + return MergeResult{PrimaryID: callerID}, nil + } + if owner == callerID { + return MergeResult{PrimaryID: callerID}, nil + } + return s.merge(ctx, callerID, owner) +} + +// attachTelegram links the identity to the caller and promotes a guest. +func (s *Service) attachTelegram(ctx context.Context, callerID uuid.UUID, externalID string) error { + if err := s.accounts.AttachIdentity(ctx, callerID, account.KindTelegram, externalID, true); err != nil { + return err + } + return s.accounts.ClearGuest(ctx, callerID) +} + +// merge decides the primary (the caller, unless it is a guest and the other is +// durable), runs the data merge, retires the secondary's sessions and mints a new +// session when the active account switches. +func (s *Service) merge(ctx context.Context, callerID, otherID uuid.UUID) (MergeResult, error) { + caller, err := s.accounts.GetByID(ctx, callerID) + if err != nil { + return MergeResult{}, err + } + primary, secondary := callerID, otherID + if caller.IsGuest { + primary, secondary = otherID, callerID + } + if err := s.merger.Merge(ctx, primary, secondary); err != nil { + return MergeResult{}, err + } + if err := s.sessions.RevokeAllForAccount(ctx, secondary); err != nil { + return MergeResult{}, err + } + res := MergeResult{PrimaryID: primary} + if primary != callerID { + token, _, err := s.sessions.Create(ctx, primary) + if err != nil { + return MergeResult{}, err + } + res.SwitchedToken = token + } + return res, nil +} diff --git a/backend/internal/postgres/jet/backend/model/accounts.go b/backend/internal/postgres/jet/backend/model/accounts.go index 081dbbf..243617b 100644 --- a/backend/internal/postgres/jet/backend/model/accounts.go +++ b/backend/internal/postgres/jet/backend/model/accounts.go @@ -26,4 +26,7 @@ type Accounts struct { HintBalance int32 IsGuest bool NotificationsInAppOnly bool + PaidAccount bool + MergedInto *uuid.UUID + MergedAt *time.Time } diff --git a/backend/internal/postgres/jet/backend/table/accounts.go b/backend/internal/postgres/jet/backend/table/accounts.go index f130516..ed3d730 100644 --- a/backend/internal/postgres/jet/backend/table/accounts.go +++ b/backend/internal/postgres/jet/backend/table/accounts.go @@ -30,6 +30,9 @@ type accountsTable struct { HintBalance postgres.ColumnInteger IsGuest postgres.ColumnBool NotificationsInAppOnly postgres.ColumnBool + PaidAccount postgres.ColumnBool + MergedInto postgres.ColumnString + MergedAt postgres.ColumnTimestampz AllColumns postgres.ColumnList MutableColumns postgres.ColumnList @@ -84,9 +87,12 @@ func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable { HintBalanceColumn = postgres.IntegerColumn("hint_balance") IsGuestColumn = postgres.BoolColumn("is_guest") NotificationsInAppOnlyColumn = postgres.BoolColumn("notifications_in_app_only") - allColumns = postgres.ColumnList{AccountIDColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn} - mutableColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn} - defaultColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn} + PaidAccountColumn = postgres.BoolColumn("paid_account") + MergedIntoColumn = postgres.StringColumn("merged_into") + MergedAtColumn = postgres.TimestampzColumn("merged_at") + allColumns = postgres.ColumnList{AccountIDColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn} + mutableColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn} + defaultColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn} ) return accountsTable{ @@ -106,6 +112,9 @@ func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable { HintBalance: HintBalanceColumn, IsGuest: IsGuestColumn, NotificationsInAppOnly: NotificationsInAppOnlyColumn, + PaidAccount: PaidAccountColumn, + MergedInto: MergedIntoColumn, + MergedAt: MergedAtColumn, AllColumns: allColumns, MutableColumns: mutableColumns, diff --git a/backend/internal/postgres/migrations/00009_account_merge.sql b/backend/internal/postgres/migrations/00009_account_merge.sql new file mode 100644 index 0000000..b388982 --- /dev/null +++ b/backend/internal/postgres/migrations/00009_account_merge.sql @@ -0,0 +1,24 @@ +-- +goose Up +-- Stage 11 account linking & merge: retire a secondary account into a primary one. +-- merged_into/merged_at turn the secondary into an audit tombstone (its identities +-- are repointed and its non-shared rows transferred to the primary, but the row is +-- kept so the no-cascade game_players/chat/complaints foreign keys of any shared +-- finished game stay valid). merged_into self-references accounts and is SET NULL on +-- delete so a future guest reaper (PLAN.md TODO-3) can still remove a primary. +-- paid_account is a forward-looking lifetime one-time-payment marker (no purchase +-- flow yet); the merge ORs it so a paid status is never lost. Adds columns, so the +-- generated jet code is regenerated (cmd/jetgen). +SET search_path = backend, pg_catalog; + +ALTER TABLE accounts + ADD COLUMN paid_account boolean NOT NULL DEFAULT false, + ADD COLUMN merged_into uuid REFERENCES accounts (account_id) ON DELETE SET NULL, + ADD COLUMN merged_at timestamptz; + +-- +goose Down +SET search_path = backend, pg_catalog; + +ALTER TABLE accounts + DROP COLUMN merged_at, + DROP COLUMN merged_into, + DROP COLUMN paid_account; diff --git a/backend/internal/server/handlers.go b/backend/internal/server/handlers.go index 113ae11..dbbe120 100644 --- a/backend/internal/server/handlers.go +++ b/backend/internal/server/handlers.go @@ -10,6 +10,7 @@ import ( "go.uber.org/zap" "scrabble/backend/internal/account" + "scrabble/backend/internal/accountmerge" "scrabble/backend/internal/engine" "scrabble/backend/internal/game" "scrabble/backend/internal/lobby" @@ -42,9 +43,15 @@ func (s *Server) registerRoutes() { u.PUT("/profile", s.handleUpdateProfile) u.GET("/stats", s.handleStats) } - if s.emails != nil { - u.POST("/email/request", s.handleEmailBindRequest) - u.POST("/email/confirm", s.handleEmailBindConfirm) + if s.links != nil { + // Account linking & merge (Stage 11). The request step always mails a code; + // a required merge is revealed only after the code is verified, and the + // irreversible merge is an explicit second step. + u.POST("/link/email/request", s.handleLinkEmailRequest) + u.POST("/link/email/confirm", s.handleLinkEmailConfirm) + u.POST("/link/email/merge", s.handleLinkEmailMerge) + u.POST("/link/telegram", s.handleLinkTelegram) + u.POST("/link/telegram/merge", s.handleLinkTelegramMerge) } if s.games != nil { u.GET("/games", s.handleListGames) @@ -179,8 +186,10 @@ func statusForError(err error) (int, string) { return http.StatusConflict, "hint_unavailable" case errors.Is(err, engine.ErrIllegalPlay), errors.Is(err, engine.ErrTilesNotOnRack), errors.Is(err, engine.ErrGameOver): return http.StatusUnprocessableEntity, "illegal_play" - case errors.Is(err, account.ErrEmailTaken): + case errors.Is(err, account.ErrEmailTaken), errors.Is(err, account.ErrIdentityTaken): return http.StatusConflict, "email_taken" + case errors.Is(err, accountmerge.ErrActiveGameConflict): + return http.StatusConflict, "merge_active_game_conflict" case errors.Is(err, account.ErrInvalidEmail): return http.StatusBadRequest, "invalid_email" case errors.Is(err, account.ErrCodeMismatch), errors.Is(err, account.ErrCodeExpired), diff --git a/backend/internal/server/handlers_account.go b/backend/internal/server/handlers_account.go index af405ac..3da79d4 100644 --- a/backend/internal/server/handlers_account.go +++ b/backend/internal/server/handlers_account.go @@ -38,17 +38,6 @@ type statsDTO struct { MaxWordPoints int `json:"max_word_points"` } -// emailBindRequestBody starts binding an email to the caller's account. -type emailBindRequestBody struct { - Email string `json:"email"` -} - -// emailBindConfirmBody completes binding an email with its confirm code. -type emailBindConfirmBody struct { - Email string `json:"email"` - Code string `json:"code"` -} - // parseAwayTime parses an "HH:MM" away-window bound. func parseAwayTime(s string) (time.Time, bool) { t, err := time.Parse(awayTimeLayout, strings.TrimSpace(s)) @@ -117,43 +106,3 @@ func (s *Server) handleStats(c *gin.Context) { MaxWordPoints: st.MaxWordPoints, }) } - -// handleEmailBindRequest issues a confirm code to bind an email to the caller. -func (s *Server) handleEmailBindRequest(c *gin.Context) { - uid, ok := userID(c) - if !ok { - abortBadRequest(c, "missing identity") - return - } - var req emailBindRequestBody - if err := c.ShouldBindJSON(&req); err != nil { - abortBadRequest(c, "invalid request body") - return - } - if err := s.emails.RequestCode(c.Request.Context(), uid, req.Email); err != nil { - s.abortErr(c, err) - return - } - c.JSON(http.StatusOK, okResponse{OK: true}) -} - -// handleEmailBindConfirm verifies the code and binds the email, returning the -// updated profile. -func (s *Server) handleEmailBindConfirm(c *gin.Context) { - uid, ok := userID(c) - if !ok { - abortBadRequest(c, "missing identity") - return - } - var req emailBindConfirmBody - if err := c.ShouldBindJSON(&req); err != nil { - abortBadRequest(c, "invalid request body") - return - } - acc, err := s.emails.ConfirmCode(c.Request.Context(), uid, req.Email, req.Code) - if err != nil { - s.abortErr(c, err) - return - } - c.JSON(http.StatusOK, profileResponseFor(acc)) -} diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index 81f6111..1b6b977 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -110,8 +110,11 @@ func (s *Server) consoleUserDetail(c *gin.Context) { view := adminconsole.UserDetailView{ ID: acc.ID.String(), DisplayName: acc.DisplayName, Language: acc.PreferredLanguage, TimeZone: acc.TimeZone, Guest: acc.IsGuest, NotificationsInAppOnly: acc.NotificationsInAppOnly, - HintBalance: acc.HintBalance, CreatedAt: fmtTime(acc.CreatedAt), HasStats: !acc.IsGuest, - ConnectorEnabled: s.connector != nil, + PaidAccount: acc.PaidAccount, HintBalance: acc.HintBalance, CreatedAt: fmtTime(acc.CreatedAt), + HasStats: !acc.IsGuest, ConnectorEnabled: s.connector != nil, + } + if acc.MergedInto != uuid.Nil { + view.MergedInto = acc.MergedInto.String() } if view.HasStats { if st, err := s.accounts.GetStats(ctx, id); err == nil { diff --git a/backend/internal/server/handlers_link.go b/backend/internal/server/handlers_link.go new file mode 100644 index 0000000..1729cf2 --- /dev/null +++ b/backend/internal/server/handlers_link.go @@ -0,0 +1,200 @@ +package server + +import ( + "context" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "scrabble/backend/internal/link" +) + +// The /api/v1/user/link handlers drive account linking & merge (Stage 11). The +// request step always mails a code (no pre-send "taken" signal, so a probe cannot +// enumerate registered emails); confirm reveals a required merge only after the +// code is verified; merge performs the irreversible consolidation behind an +// explicit step. A merge into a guest initiator's durable counterpart switches the +// active session — the new token rides back in the result for the client to adopt. + +// linkEmailRequestBody starts a link/merge by mailing a code to email. +type linkEmailRequestBody struct { + Email string `json:"email"` +} + +// linkEmailConfirmBody carries the email and its confirm code. +type linkEmailConfirmBody struct { + Email string `json:"email"` + Code string `json:"code"` +} + +// linkTelegramBody carries a gateway-validated Telegram identity. +type linkTelegramBody struct { + ExternalID string `json:"external_id"` +} + +// linkResultResponse is the unified result of a confirm or merge step. Status is +// "linked" (bound to the caller), "merge_required" (the identity belongs to another +// account — the secondary_* fields summarise it for the irreversible confirmation), +// or "merged" (done; token is non-empty when the active account switched). +type linkResultResponse struct { + Status string `json:"status"` + SecondaryUserID string `json:"secondary_user_id,omitempty"` + SecondaryName string `json:"secondary_display_name,omitempty"` + SecondaryGames int `json:"secondary_games"` + SecondaryFriends int `json:"secondary_friends"` + Token string `json:"token,omitempty"` + Profile *profileResponse `json:"profile,omitempty"` +} + +// handleLinkEmailRequest mails a confirm-code to email for a later link or merge. +func (s *Server) handleLinkEmailRequest(c *gin.Context) { + uid, ok := userID(c) + if !ok { + abortBadRequest(c, "missing identity") + return + } + var req linkEmailRequestBody + if err := c.ShouldBindJSON(&req); err != nil { + abortBadRequest(c, "invalid request body") + return + } + if err := s.links.RequestEmail(c.Request.Context(), uid, req.Email); err != nil { + s.abortErr(c, err) + return + } + c.JSON(http.StatusOK, okResponse{OK: true}) +} + +// handleLinkEmailConfirm verifies the code and binds a free email or reports a +// required merge. +func (s *Server) handleLinkEmailConfirm(c *gin.Context) { + uid, ok := userID(c) + if !ok { + abortBadRequest(c, "missing identity") + return + } + var req linkEmailConfirmBody + if err := c.ShouldBindJSON(&req); err != nil { + abortBadRequest(c, "invalid request body") + return + } + res, err := s.links.ConfirmEmail(c.Request.Context(), uid, req.Email, req.Code) + if err != nil { + s.abortErr(c, err) + return + } + c.JSON(http.StatusOK, s.confirmResultResponse(c, uid, res)) +} + +// handleLinkEmailMerge re-verifies the code and performs the merge. +func (s *Server) handleLinkEmailMerge(c *gin.Context) { + uid, ok := userID(c) + if !ok { + abortBadRequest(c, "missing identity") + return + } + var req linkEmailConfirmBody + if err := c.ShouldBindJSON(&req); err != nil { + abortBadRequest(c, "invalid request body") + return + } + res, err := s.links.MergeEmail(c.Request.Context(), uid, req.Email, req.Code) + if err != nil { + s.abortErr(c, err) + return + } + c.JSON(http.StatusOK, s.mergeResultResponse(c, res)) +} + +// handleLinkTelegram attaches a gateway-validated Telegram identity to the caller +// or reports a required merge. +func (s *Server) handleLinkTelegram(c *gin.Context) { + uid, ok := userID(c) + if !ok { + abortBadRequest(c, "missing identity") + return + } + var req linkTelegramBody + if err := c.ShouldBindJSON(&req); err != nil || req.ExternalID == "" { + abortBadRequest(c, "missing external_id") + return + } + res, err := s.links.ConfirmTelegram(c.Request.Context(), uid, req.ExternalID) + if err != nil { + s.abortErr(c, err) + return + } + c.JSON(http.StatusOK, s.confirmResultResponse(c, uid, res)) +} + +// handleLinkTelegramMerge merges the account owning a gateway-validated Telegram +// identity into the caller's. +func (s *Server) handleLinkTelegramMerge(c *gin.Context) { + uid, ok := userID(c) + if !ok { + abortBadRequest(c, "missing identity") + return + } + var req linkTelegramBody + if err := c.ShouldBindJSON(&req); err != nil || req.ExternalID == "" { + abortBadRequest(c, "missing external_id") + return + } + res, err := s.links.MergeTelegram(c.Request.Context(), uid, req.ExternalID) + if err != nil { + s.abortErr(c, err) + return + } + c.JSON(http.StatusOK, s.mergeResultResponse(c, res)) +} + +// confirmResultResponse renders a confirm step: a merge preview (secondary summary) +// or a completed link (the active account's refreshed profile). +func (s *Server) confirmResultResponse(c *gin.Context, activeID uuid.UUID, res link.ConfirmResult) linkResultResponse { + ctx := c.Request.Context() + if res.MergeRequired { + out := linkResultResponse{Status: "merge_required", SecondaryUserID: res.SecondaryID.String()} + if acc, err := s.accounts.GetByID(ctx, res.SecondaryID); err == nil { + out.SecondaryName = acc.DisplayName + } + out.SecondaryGames, out.SecondaryFriends = s.secondaryCounts(ctx, res.SecondaryID) + return out + } + return linkResultResponse{Status: "linked", Profile: s.profileFor(ctx, activeID)} +} + +// mergeResultResponse renders a completed merge: the surviving account's profile +// plus a switched-session token when the active account changed. +func (s *Server) mergeResultResponse(c *gin.Context, res link.MergeResult) linkResultResponse { + return linkResultResponse{ + Status: "merged", + Token: res.SwitchedToken, + Profile: s.profileFor(c.Request.Context(), res.PrimaryID), + } +} + +// profileFor loads an account's profile DTO, or nil when it cannot be read. +func (s *Server) profileFor(ctx context.Context, id uuid.UUID) *profileResponse { + acc, err := s.accounts.GetByID(ctx, id) + if err != nil { + return nil + } + p := profileResponseFor(acc) + return &p +} + +// secondaryCounts summarises the to-be-retired account for the merge confirmation. +func (s *Server) secondaryCounts(ctx context.Context, id uuid.UUID) (games, friends int) { + if s.games != nil { + if gs, err := s.games.ListForAccount(ctx, id); err == nil { + games = len(gs) + } + } + if s.social != nil { + if fs, err := s.social.ListFriends(ctx, id); err == nil { + friends = len(fs) + } + } + return games, friends +} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index d081b01..9c914f7 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -22,6 +22,7 @@ import ( "scrabble/backend/internal/connector" "scrabble/backend/internal/engine" "scrabble/backend/internal/game" + "scrabble/backend/internal/link" "scrabble/backend/internal/lobby" "scrabble/backend/internal/session" "scrabble/backend/internal/social" @@ -58,6 +59,9 @@ type Deps struct { Matchmaker *lobby.Matchmaker Invitations *lobby.InvitationService Emails *account.EmailService + // Links drives account linking & merge (Stage 11): the /api/v1/user/link + // endpoints. A nil Links disables them. + Links *link.Service // Registry holds the resident dictionaries; the admin console (Stage 10) reads // its versions and hot-reloads new ones. DictDir is the dictionary directory a // reload reads a version subdirectory from. A nil Registry disables the console. @@ -85,6 +89,7 @@ type Server struct { matchmaker *lobby.Matchmaker invitations *lobby.InvitationService emails *account.EmailService + links *link.Service registry *engine.Registry dictDir string connector *connector.Client @@ -124,6 +129,7 @@ func New(addr string, deps Deps) *Server { matchmaker: deps.Matchmaker, invitations: deps.Invitations, emails: deps.Emails, + links: deps.Links, registry: deps.Registry, dictDir: deps.DictDir, connector: deps.Connector, diff --git a/backend/internal/session/cache.go b/backend/internal/session/cache.go index 39739c4..f5d9490 100644 --- a/backend/internal/session/cache.go +++ b/backend/internal/session/cache.go @@ -4,6 +4,8 @@ import ( "context" "sync" "sync/atomic" + + "github.com/google/uuid" ) // Cache is the in-memory write-through projection of the active rows in @@ -93,3 +95,19 @@ func (c *Cache) Remove(tokenHash string) { defer c.mu.Unlock() delete(c.byHash, tokenHash) } + +// RemoveByAccount evicts every cached session belonging to accountID. The +// account-merge flow uses it to drop a retired secondary account's sessions +// (Stage 11); a linear scan is adequate at the cache's size. +func (c *Cache) RemoveByAccount(accountID uuid.UUID) { + if c == nil { + return + } + c.mu.Lock() + defer c.mu.Unlock() + for hash, s := range c.byHash { + if s.AccountID == accountID { + delete(c.byHash, hash) + } + } +} diff --git a/backend/internal/session/service.go b/backend/internal/session/service.go index 47d0f9e..de339c3 100644 --- a/backend/internal/session/service.go +++ b/backend/internal/session/service.go @@ -71,3 +71,14 @@ func (svc *Service) Revoke(ctx context.Context, token string) error { svc.cache.Remove(hash) return nil } + +// RevokeAllForAccount revokes every active session of accountID and evicts them +// from the cache. The account-merge flow calls it to retire a secondary account +// (Stage 11). It is idempotent. +func (svc *Service) RevokeAllForAccount(ctx context.Context, accountID uuid.UUID) error { + if _, err := svc.store.RevokeAllForAccount(ctx, accountID, time.Now().UTC()); err != nil { + return err + } + svc.cache.RemoveByAccount(accountID) + return nil +} diff --git a/backend/internal/session/store.go b/backend/internal/session/store.go index 51ef935..35868ff 100644 --- a/backend/internal/session/store.go +++ b/backend/internal/session/store.go @@ -110,6 +110,34 @@ func (s *Store) RevokeByTokenHash(ctx context.Context, tokenHash string, at time return modelToSession(row), true, nil } +// RevokeAllForAccount transitions every active session of accountID to revoked +// and returns the post-update rows (so the caller can evict them from the cache). +// It backs the account-merge flow, which retires a secondary account's sessions +// (Stage 11). No matching rows is not an error. +func (s *Store) RevokeAllForAccount(ctx context.Context, accountID uuid.UUID, at time.Time) ([]Session, error) { + stmt := table.Sessions. + UPDATE(table.Sessions.Status, table.Sessions.RevokedAt). + SET(postgres.String(StatusRevoked), postgres.TimestampzT(at)). + WHERE( + table.Sessions.AccountID.EQ(postgres.UUID(accountID)). + AND(table.Sessions.Status.EQ(postgres.String(StatusActive))), + ). + RETURNING(table.Sessions.AllColumns) + + var rows []model.Sessions + if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { + if errors.Is(err, qrm.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("session: revoke all for account %s: %w", accountID, err) + } + out := make([]Session, 0, len(rows)) + for _, row := range rows { + out = append(out, modelToSession(row)) + } + return out, nil +} + // ListActive loads every active session. Cache.Warm calls this at boot. func (s *Store) ListActive(ctx context.Context) ([]Session, error) { stmt := postgres.SELECT(table.Sessions.AllColumns). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1f3c4a4..ba7d285 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -113,7 +113,9 @@ arrive from a platform rather than completing a mandatory registration). records live in `backend`, which stores only a **SHA-256 hash** of the opaque token (never the plaintext), keeps a warmed in-memory cache for fast resolution, and treats sessions as **revoke-only** — they have no TTL and live - until explicitly revoked (`status` → `revoked`). + until explicitly revoked (`status` → `revoked`). A revoke can target one token or, + on an account merge (§4), **every** session of the retired account + (`RevokeAllForAccount`, which also evicts them from the warm cache). - **Guest** = ephemeral web session (no platform, no email). A guest is backed by a durable `accounts` row flagged `is_guest` and carrying **no identity** — the row is a technical necessity (the `sessions` and `game_players` foreign keys @@ -134,17 +136,33 @@ arrive from a platform rather than completing a mandatory registration). authenticated account: a 6-digit code (stored only as a SHA-256 hash, 15-minute TTL, ≤ 5 attempts) is sent through a `Mailer` seam (an SMTP relay, or a development log mailer when none is configured) and, once verified, attaches a - confirmed email identity. An email already confirmed by **another** account is - refused — adopting it would be a merge, which Stage 11 owns. Accounts and - identities use application-generated **UUIDv7** primary keys. -- **Linking** is initiated from an authenticated profile: choose a platform → - complete that platform's web-auth confirm → attach the identity to the - current account. -- **Merge**: if the identity being linked already has its own account with - history, the two accounts are **merged into the current one (A is primary)**: - statistics are summed, games and friends are transferred, duplicates are - de-duplicated, the secondary account is retired. High blast-radius; an - isolated, well-tested stage. + confirmed email identity. Accounts and identities use application-generated + **UUIDv7** primary keys. A service flag `paid_account` (lifetime one-time + payment; no purchase flow yet) is carried on the account and ORed on a merge. +- **Linking** (Stage 11) is initiated from an authenticated profile and proves + control of the identity before attaching it: **email** through the confirm-code + flow, **Telegram** through the web **Login Widget** (validated by the connector, + HMAC under `SHA-256(bot_token)` — distinct from Mini App initData; the gateway + passes the trusted `external_id` to the backend, as for `auth.telegram`). The + request step **always** sends/accepts the proof (no pre-send "already taken" + signal, so a probe cannot enumerate registered addresses); a required **merge** + is revealed **only after** the proof is verified and is performed behind an + explicit, irreversible confirmation. A free identity is simply attached (and a + guest is promoted to durable, clearing `is_guest`). +- **Merge** retires the account that owns the linked identity into the **current** + account, in a single transaction (`internal/accountmerge`): statistics summed + (max points kept), the hint wallet summed, `paid_account` ORed, identities + repointed, games / chat / complaints transferred, friends and blocks + de-duplicated (friendships keep the strongest status accepted>pending>declined), + pending invitations/codes dropped, and the secondary kept as an **audit + tombstone** (`accounts.merged_into`/`merged_at`) so a shared **finished** game's + no-cascade foreign keys stay valid — its seat there is left untouched. A merge is + **refused** only when the two share an **active** game. The current account is the + primary, **except** when the initiator is a **guest** and the linked identity + already has a **durable** owner: then the durable account wins, the guest's active + games move into it, the guest is retired, and a **fresh session** is minted for the + durable account (the client switches to it). The secondary's sessions are revoked + (§3). High blast-radius; an isolated, well-tested stage. ## 5. Game engine integration (`scrabble-solver`) @@ -337,7 +355,9 @@ requires (there is no DM surface; chat is per-game). - Tables: `accounts` (durable internal accounts; Stage 3 added the away-window columns `away_start`/`away_end` and the hint wallet `hint_balance`; Stage 6's migration `00005` added the `is_guest` flag for ephemeral guest rows; Stage 9's - migration `00007` added the `notifications_in_app_only` out-of-app push toggle), + migration `00007` added the `notifications_in_app_only` out-of-app push toggle; + Stage 11's migration `00009` added the `paid_account` service flag and the + merge-tombstone columns `merged_into`/`merged_at`), `identities` (platform/email/robot identities, unique `(kind, external_id)`; Stage 5's migration `00004` admits the `robot` kind), `sessions` (revoke-only opaque-token hashes), the Stage 3 game tables diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 66cb323..2444c52 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -36,11 +36,17 @@ out-of-app events (your turn, nudge, a found match, an invitation or friend requ arrive as a **Telegram notification** instead — unless the player keeps notifications in the app only (a profile setting, **on by default**). -### Accounts, linking & merge *(Stage 1 / 10)* -First platform contact auto-provisions a durable account. From the profile a -player links additional platform identities or an email via a confirm flow; -linking an identity that already has history merges it into the current -account (stats summed, games/friends transferred). +### Accounts, linking & merge *(Stage 1 / 11)* +First platform contact auto-provisions a durable account. From the profile a player +links an email (via a confirm code) or their Telegram (via the web sign-in); a guest +who links their first identity becomes a durable account. The "already taken" status +of an identity is never revealed before the code/sign-in is verified. If the linked +identity already belongs to another account, the player is shown an explicit, +**irreversible** confirmation and the two accounts are merged into the one they are +using (statistics summed, games and friends transferred, duplicates removed) — except +when a guest links an identity that already has a durable account, where the durable +account is kept and the guest's games move into it. A merge is blocked only while the +two accounts share a game still in progress. ### Lobby & matchmaking *(Stage 4)* Bottom tab menu: **my games**, **profile**. Auto-match (always 2 players) joins a @@ -95,11 +101,9 @@ nudge is part of the game chat); the out-of-app push is delivered via the platfo ### Profile & settings *(Stage 4 / 8)* Edit the display name (letters joined by single space / "." / "_" separators, up to 32 characters), the timezone (chosen as a UTC offset), the daily away window (on a -10-minute grid, at most 12 hours, wrapping midnight) and the block toggles, and bind -an email by confirm-code: the backend emails a short code that, -once entered, attaches the email to the account (an email already confirmed by -another account cannot be taken — that is a merge, a later stage). Linked platform -accounts and merge arrive in Stage 11. +10-minute grid, at most 12 hours, wrapping midnight) and the block toggles. Linking +an email or Telegram and merging accounts are covered under "Accounts, linking & +merge" (Stage 11). ### History & statistics *(Stage 3 / 8)* Finished games are archived in a dictionary-independent form and exportable to diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 680cb2c..049ef5b 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -37,11 +37,17 @@ Mini App** авторизует по подписанным `initData` плат вместо этого **уведомлением в Telegram** — если только игрок не оставил уведомления только в приложении (настройка профиля, **включена по умолчанию**). -### Аккаунты, привязка и слияние *(Stage 1 / 10)* +### Аккаунты, привязка и слияние *(Stage 1 / 11)* Первый контакт с платформы заводит постоянный аккаунт. Из профиля игрок -привязывает другие платформенные личности или email через confirm-поток; -привязка личности, у которой уже есть история, сливает её в текущий аккаунт -(статистика суммируется, игры/друзья переносятся). +привязывает email (по confirm-коду) или свой Telegram (через веб-вход); гость, +привязавший первую личность, становится постоянным аккаунтом. Факт «личность уже +занята» не раскрывается до проверки кода/входа. Если привязываемая личность уже +принадлежит другому аккаунту, игроку показывают явное **необратимое** +подтверждение, и два аккаунта сливаются в тот, под которым он сейчас работает +(статистика суммируется, игры и друзья переносятся, дубликаты убираются), — кроме +случая, когда гость привязывает личность с уже существующим постоянным аккаунтом: +тогда сохраняется постоянный аккаунт, а игры гостя переходят в него. Слияние +запрещено, только пока у аккаунтов есть общая незавершённая игра. ### Лобби и подбор *(Stage 4)* Нижнее tab-меню: **мои игры**, **профиль**. Авто-подбор (всегда 2 игрока) @@ -98,11 +104,8 @@ push доставляется через платформу. Редактирование отображаемого имени (буквы, разделённые одиночными пробелом / «.» / «_», до 32 символов), таймзоны (выбор смещения от UTC), суточного окна отсутствия (away; сетка по 10 минут, не более 12 часов, с переходом через полночь) и -переключателей блокировок, а также привязка email по confirm-коду: backend шлёт на -почту короткий код, и после ввода email -привязывается к аккаунту (email, уже подтверждённый другим аккаунтом, занять -нельзя — это слияние, отдельный этап). Привязанные платформенные аккаунты и -слияние появятся в Stage 11. +переключателей блокировок. Привязка email и Telegram, а также слияние аккаунтов +вынесены в раздел «Аккаунты, привязка и слияние» (Stage 11). ### История и статистика *(Stage 3 / 8)* Завершённые партии архивируются в независимом от словаря виде и экспортируются diff --git a/gateway/README.md b/gateway/README.md index 8aa4fc4..b293f49 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -51,8 +51,12 @@ The Stage 6 message-type slice: `auth.telegram`, `auth.guest`, added the play-loop ops; **Stage 8** added the social/account/history ops — `friends.*` (list/incoming/request/respond/cancel/unfriend/code.issue/code.redeem), `blocks.*`, `invitation.*` (list/create/accept/decline/cancel), `profile.update`, -`email.bind.*`, `stats.get`, `game.gcg`, and the `notify` live event — all via the -identical transcode pattern (`transcode_social.go`). +`stats.get`, `game.gcg`, and the `notify` live event — all via the identical +transcode pattern (`transcode_social.go`). **Stage 11** added account linking & merge +— `link.email.request/confirm/merge` and `link.telegram.confirm/merge` +(`transcode_link.go`); the telegram ops validate the **Login Widget** payload via the +connector (`ValidateLoginWidget`) and forward the trusted `external_id`. These +**superseded** the Stage 8 `email.bind.*` ops, which were removed. ## Configuration diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index 943076e..221680c 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -33,6 +33,20 @@ type ProfileResp struct { NotificationsInAppOnly bool `json:"notifications_in_app_only"` } +// LinkResultResp is the result of an account link/merge step (Stage 11). Status is +// "linked", "merge_required" (the secondary_* fields summarise the other account) or +// "merged". Token is a switched-session token (a guest initiator's durable +// counterpart won); Profile is the surviving/active account's profile. +type LinkResultResp struct { + Status string `json:"status"` + SecondaryUserID string `json:"secondary_user_id"` + SecondaryName string `json:"secondary_display_name"` + SecondaryGames int `json:"secondary_games"` + SecondaryFriends int `json:"secondary_friends"` + Token string `json:"token"` + Profile *ProfileResp `json:"profile"` +} + // TileJSON is one placed tile, used in both play requests and move responses. type TileJSON struct { Row int `json:"row"` diff --git a/gateway/internal/backendclient/api_social.go b/gateway/internal/backendclient/api_social.go index e9b3bab..28399eb 100644 --- a/gateway/internal/backendclient/api_social.go +++ b/gateway/internal/backendclient/api_social.go @@ -228,20 +228,47 @@ func (c *Client) UpdateProfile(ctx context.Context, userID string, p ProfileResp return out, err } -// EmailBindRequest asks the backend to mail a confirm-code binding email. -func (c *Client) EmailBindRequest(ctx context.Context, userID, email string) error { - return c.do(ctx, http.MethodPost, "/api/v1/user/email/request", userID, "", +// LinkEmailRequest asks the backend to mail a confirm-code for a link or merge. +func (c *Client) LinkEmailRequest(ctx context.Context, userID, email string) error { + return c.do(ctx, http.MethodPost, "/api/v1/user/link/email/request", userID, "", map[string]string{"email": email}, nil) } -// EmailBindConfirm verifies the code and binds the email, returning the profile. -func (c *Client) EmailBindConfirm(ctx context.Context, userID, email, code string) (ProfileResp, error) { - var out ProfileResp - err := c.do(ctx, http.MethodPost, "/api/v1/user/email/confirm", userID, "", +// LinkEmailConfirm verifies the code and binds a free email or reports a required +// merge (Stage 11). +func (c *Client) LinkEmailConfirm(ctx context.Context, userID, email, code string) (LinkResultResp, error) { + var out LinkResultResp + err := c.do(ctx, http.MethodPost, "/api/v1/user/link/email/confirm", userID, "", map[string]string{"email": email, "code": code}, &out) return out, err } +// LinkEmailMerge re-verifies the code and performs the merge (Stage 11). +func (c *Client) LinkEmailMerge(ctx context.Context, userID, email, code string) (LinkResultResp, error) { + var out LinkResultResp + err := c.do(ctx, http.MethodPost, "/api/v1/user/link/email/merge", userID, "", + map[string]string{"email": email, "code": code}, &out) + return out, err +} + +// LinkTelegram attaches a gateway-validated Telegram identity to the caller or +// reports a required merge (Stage 11). +func (c *Client) LinkTelegram(ctx context.Context, userID, externalID string) (LinkResultResp, error) { + var out LinkResultResp + err := c.do(ctx, http.MethodPost, "/api/v1/user/link/telegram", userID, "", + map[string]string{"external_id": externalID}, &out) + return out, err +} + +// LinkTelegramMerge merges the account owning a gateway-validated Telegram identity +// into the caller's (Stage 11). +func (c *Client) LinkTelegramMerge(ctx context.Context, userID, externalID string) (LinkResultResp, error) { + var out LinkResultResp + err := c.do(ctx, http.MethodPost, "/api/v1/user/link/telegram/merge", userID, "", + map[string]string{"external_id": externalID}, &out) + return out, err +} + // Stats returns the caller's lifetime statistics. func (c *Client) Stats(ctx context.Context, userID string) (StatsResp, error) { var out StatsResp diff --git a/gateway/internal/connector/client.go b/gateway/internal/connector/client.go index 0ed64b7..40f0d17 100644 --- a/gateway/internal/connector/client.go +++ b/gateway/internal/connector/client.go @@ -22,6 +22,10 @@ import ( // result code. var ErrInvalidInitData = errors.New("connector: invalid telegram init data") +// ErrInvalidLoginWidget is returned by ValidateLoginWidget when the connector +// rejects the Login Widget data (a gRPC InvalidArgument). +var ErrInvalidLoginWidget = errors.New("connector: invalid telegram login widget data") + // User is a validated Mini App identity. type User struct { ExternalID string @@ -66,6 +70,24 @@ func (c *Client) ValidateInitData(ctx context.Context, initData string) (User, e }, nil } +// ValidateLoginWidget verifies Telegram Login Widget data and returns the user +// identity, mapping a connector InvalidArgument to ErrInvalidLoginWidget. It backs +// the link.telegram edge operation (Stage 11). +func (c *Client) ValidateLoginWidget(ctx context.Context, data string) (User, error) { + resp, err := c.c.ValidateLoginWidget(ctx, &telegramv1.ValidateLoginWidgetRequest{Data: data}) + if err != nil { + if status.Code(err) == codes.InvalidArgument { + return User{}, ErrInvalidLoginWidget + } + return User{}, err + } + return User{ + ExternalID: resp.GetExternalId(), + Username: resp.GetUsername(), + FirstName: resp.GetFirstName(), + }, nil +} + // Notify delivers an out-of-app notification for a push event; delivered reports // whether a message was actually sent. func (c *Client) Notify(ctx context.Context, externalID, kind string, payload []byte, language string) (bool, error) { diff --git a/gateway/internal/transcode/encode.go b/gateway/internal/transcode/encode.go index 80ce5de..aa8840d 100644 --- a/gateway/internal/transcode/encode.go +++ b/gateway/internal/transcode/encode.go @@ -61,6 +61,40 @@ func encodeProfile(p backendclient.ProfileResp) []byte { return b.FinishedBytes() } +// encodeLinkResult builds a LinkResult payload (Stage 11). A switched-session token +// (a guest initiator whose durable counterpart won) is carried as a nested Session +// for the client to adopt; it is omitted otherwise. +func encodeLinkResult(r backendclient.LinkResultResp) []byte { + b := flatbuffers.NewBuilder(256) + status := b.CreateString(r.Status) + secID := b.CreateString(r.SecondaryUserID) + secName := b.CreateString(r.SecondaryName) + hasSession := r.Token != "" && r.Profile != nil + var sess flatbuffers.UOffsetT + if hasSession { + token := b.CreateString(r.Token) + uid := b.CreateString(r.Profile.UserID) + name := b.CreateString(r.Profile.DisplayName) + fb.SessionStart(b) + fb.SessionAddToken(b, token) + fb.SessionAddUserId(b, uid) + fb.SessionAddIsGuest(b, r.Profile.IsGuest) + fb.SessionAddDisplayName(b, name) + sess = fb.SessionEnd(b) + } + fb.LinkResultStart(b) + fb.LinkResultAddStatus(b, status) + fb.LinkResultAddSecondaryUserId(b, secID) + fb.LinkResultAddSecondaryDisplayName(b, secName) + fb.LinkResultAddSecondaryGames(b, int32(r.SecondaryGames)) + fb.LinkResultAddSecondaryFriends(b, int32(r.SecondaryFriends)) + if hasSession { + fb.LinkResultAddSession(b, sess) + } + b.Finish(fb.LinkResultEnd(b)) + return b.FinishedBytes() +} + // encodeMoveResult builds a MoveResult payload. func encodeMoveResult(r backendclient.MoveResultResp) []byte { b := flatbuffers.NewBuilder(512) diff --git a/gateway/internal/transcode/transcode.go b/gateway/internal/transcode/transcode.go index bfc74f5..2d36814 100644 --- a/gateway/internal/transcode/transcode.go +++ b/gateway/internal/transcode/transcode.go @@ -63,10 +63,13 @@ type Registry struct { ops map[string]Op } -// TelegramValidator validates Mini App launch data via the connector side-service. -// *connector.Client implements it; a nil value disables the telegram auth path. +// TelegramValidator validates Telegram credentials via the connector side-service: +// Mini App launch data (auth) and Login Widget data (linking, Stage 11). +// *connector.Client implements it; a nil value disables the telegram auth and +// telegram-link paths. type TelegramValidator interface { ValidateInitData(ctx context.Context, initData string) (connector.User, error) + ValidateLoginWidget(ctx context.Context, data string) (connector.User, error) } // NewRegistry builds the slice's message-type catalog over the backend client. @@ -98,6 +101,7 @@ func NewRegistry(backend *backendclient.Client, tg TelegramValidator) *Registry r.ops[MsgChatList] = Op{Handler: chatListHandler(backend), Auth: true} r.ops[MsgChatNudge] = Op{Handler: nudgeHandler(backend), Auth: true} registerStage8(r, backend) + registerStage11(r, backend, tg) return r } @@ -118,6 +122,9 @@ func DomainCode(err error) (string, bool) { if errors.Is(err, connector.ErrInvalidInitData) { return "invalid_init_data", true } + if errors.Is(err, connector.ErrInvalidLoginWidget) { + return "invalid_login_widget", true + } return "", false } diff --git a/gateway/internal/transcode/transcode_link.go b/gateway/internal/transcode/transcode_link.go new file mode 100644 index 0000000..e1714ac --- /dev/null +++ b/gateway/internal/transcode/transcode_link.go @@ -0,0 +1,87 @@ +package transcode + +import ( + "context" + + "scrabble/gateway/internal/backendclient" + fb "scrabble/pkg/fbs/scrabblefb" +) + +// Stage 11 account linking & merge message types. The email ops carry the costly- +// email rate flag; the telegram ops validate Login Widget data through the +// connector (registered only when the connector is configured). All are +// authenticated. The merge ops are the explicit irreversible step, gated in the UI +// after a merge_required confirm. +const ( + MsgLinkEmailRequest = "link.email.request" + MsgLinkEmailConfirm = "link.email.confirm" + MsgLinkEmailMerge = "link.email.merge" + MsgLinkTelegram = "link.telegram.confirm" + MsgLinkTelegramMerge = "link.telegram.merge" +) + +// registerStage11 adds the linking & merge operations. The telegram ops need the +// connector's Login Widget validator, so they are registered only when tg is set. +func registerStage11(r *Registry, backend *backendclient.Client, tg TelegramValidator) { + r.ops[MsgLinkEmailRequest] = Op{Handler: linkEmailRequestHandler(backend), Auth: true, Email: true} + r.ops[MsgLinkEmailConfirm] = Op{Handler: linkEmailConfirmHandler(backend), Auth: true, Email: true} + r.ops[MsgLinkEmailMerge] = Op{Handler: linkEmailMergeHandler(backend), Auth: true, Email: true} + if tg != nil { + r.ops[MsgLinkTelegram] = Op{Handler: linkTelegramHandler(backend, tg, false), Auth: true} + r.ops[MsgLinkTelegramMerge] = Op{Handler: linkTelegramHandler(backend, tg, true), Auth: true} + } +} + +func linkEmailRequestHandler(backend *backendclient.Client) Handler { + return func(ctx context.Context, req Request) ([]byte, error) { + in := fb.GetRootAsLinkEmailRequest(req.Payload, 0) + if err := backend.LinkEmailRequest(ctx, req.UserID, string(in.Email())); err != nil { + return nil, err + } + return encodeAck(true), nil + } +} + +func linkEmailConfirmHandler(backend *backendclient.Client) Handler { + return func(ctx context.Context, req Request) ([]byte, error) { + in := fb.GetRootAsLinkEmailConfirm(req.Payload, 0) + res, err := backend.LinkEmailConfirm(ctx, req.UserID, string(in.Email()), string(in.Code())) + if err != nil { + return nil, err + } + return encodeLinkResult(res), nil + } +} + +func linkEmailMergeHandler(backend *backendclient.Client) Handler { + return func(ctx context.Context, req Request) ([]byte, error) { + in := fb.GetRootAsLinkEmailConfirm(req.Payload, 0) + res, err := backend.LinkEmailMerge(ctx, req.UserID, string(in.Email()), string(in.Code())) + if err != nil { + return nil, err + } + return encodeLinkResult(res), nil + } +} + +// linkTelegramHandler validates Login Widget data via the connector and then calls +// the backend's link or merge endpoint with the trusted Telegram external id. +func linkTelegramHandler(backend *backendclient.Client, tg TelegramValidator, merge bool) Handler { + return func(ctx context.Context, req Request) ([]byte, error) { + in := fb.GetRootAsLinkTelegramRequest(req.Payload, 0) + user, err := tg.ValidateLoginWidget(ctx, string(in.Data())) + if err != nil { + return nil, err + } + var res backendclient.LinkResultResp + if merge { + res, err = backend.LinkTelegramMerge(ctx, req.UserID, user.ExternalID) + } else { + res, err = backend.LinkTelegram(ctx, req.UserID, user.ExternalID) + } + if err != nil { + return nil, err + } + return encodeLinkResult(res), nil + } +} diff --git a/gateway/internal/transcode/transcode_link_test.go b/gateway/internal/transcode/transcode_link_test.go new file mode 100644 index 0000000..7f6b9c6 --- /dev/null +++ b/gateway/internal/transcode/transcode_link_test.go @@ -0,0 +1,122 @@ +package transcode_test + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + flatbuffers "github.com/google/flatbuffers/go" + + "scrabble/gateway/internal/connector" + "scrabble/gateway/internal/transcode" + fb "scrabble/pkg/fbs/scrabblefb" +) + +func linkEmailConfirmPayload(email, code string) []byte { + b := flatbuffers.NewBuilder(64) + e := b.CreateString(email) + c := b.CreateString(code) + fb.LinkEmailConfirmStart(b) + fb.LinkEmailConfirmAddEmail(b, e) + fb.LinkEmailConfirmAddCode(b, c) + b.Finish(fb.LinkEmailConfirmEnd(b)) + return b.FinishedBytes() +} + +func linkTelegramPayload(data string) []byte { + b := flatbuffers.NewBuilder(64) + d := b.CreateString(data) + fb.LinkTelegramRequestStart(b) + fb.LinkTelegramRequestAddData(b, d) + b.Finish(fb.LinkTelegramRequestEnd(b)) + return b.FinishedBytes() +} + +func TestLinkEmailConfirmMergeRequired(t *testing.T) { + backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/user/link/email/confirm" { + t.Errorf("path = %q", r.URL.Path) + } + _, _ = w.Write([]byte(`{"status":"merge_required","secondary_user_id":"b-1","secondary_display_name":"Ann","secondary_games":7,"secondary_friends":3}`)) + }) + defer cleanup() + + reg := transcode.NewRegistry(backend, nil) + op, ok := reg.Lookup(transcode.MsgLinkEmailConfirm) + if !ok { + t.Fatal("link.email.confirm not registered") + } + payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1", Payload: linkEmailConfirmPayload("e@x.com", "123456")}) + if err != nil { + t.Fatalf("handler: %v", err) + } + lr := fb.GetRootAsLinkResult(payload, 0) + if string(lr.Status()) != "merge_required" || string(lr.SecondaryUserId()) != "b-1" || + string(lr.SecondaryDisplayName()) != "Ann" || lr.SecondaryGames() != 7 || lr.SecondaryFriends() != 3 { + t.Fatalf("link result = %q/%q/%q/%d/%d", lr.Status(), lr.SecondaryUserId(), lr.SecondaryDisplayName(), lr.SecondaryGames(), lr.SecondaryFriends()) + } + if lr.Session(nil) != nil { + t.Error("a merge_required result must not carry a session") + } +} + +func TestLinkEmailMergeSwitchesSession(t *testing.T) { + backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/user/link/email/merge" { + t.Errorf("path = %q", r.URL.Path) + } + _, _ = w.Write([]byte(`{"status":"merged","token":"tok-9","profile":{"user_id":"a-1","display_name":"Kaya","is_guest":false}}`)) + }) + defer cleanup() + + reg := transcode.NewRegistry(backend, nil) + op, _ := reg.Lookup(transcode.MsgLinkEmailMerge) + payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1", Payload: linkEmailConfirmPayload("e@x.com", "123456")}) + if err != nil { + t.Fatalf("handler: %v", err) + } + lr := fb.GetRootAsLinkResult(payload, 0) + if string(lr.Status()) != "merged" { + t.Fatalf("status = %q, want merged", lr.Status()) + } + sess := lr.Session(nil) + if sess == nil { + t.Fatal("a switched merge must carry a session") + } + if string(sess.Token()) != "tok-9" || string(sess.UserId()) != "a-1" { + t.Fatalf("session = %q/%q, want tok-9/a-1", sess.Token(), sess.UserId()) + } +} + +func TestLinkTelegramValidatesAndForwards(t *testing.T) { + var gotExternalID string + backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/user/link/telegram" { + t.Errorf("path = %q", r.URL.Path) + } + var body struct { + ExternalID string `json:"external_id"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + gotExternalID = body.ExternalID + _, _ = w.Write([]byte(`{"status":"linked"}`)) + }) + defer cleanup() + + reg := transcode.NewRegistry(backend, fakeValidator{user: connector.User{ExternalID: "42"}}) + op, ok := reg.Lookup(transcode.MsgLinkTelegram) + if !ok { + t.Fatal("link.telegram.confirm not registered") + } + payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1", Payload: linkTelegramPayload("id=42&hash=x")}) + if err != nil { + t.Fatalf("handler: %v", err) + } + if gotExternalID != "42" { + t.Errorf("backend external_id = %q, want 42 (the gateway-validated id)", gotExternalID) + } + if string(fb.GetRootAsLinkResult(payload, 0).Status()) != "linked" { + t.Error("expected a linked result") + } +} diff --git a/gateway/internal/transcode/transcode_social.go b/gateway/internal/transcode/transcode_social.go index e64e574..fdfa6f6 100644 --- a/gateway/internal/transcode/transcode_social.go +++ b/gateway/internal/transcode/transcode_social.go @@ -28,8 +28,6 @@ const ( MsgInvitationDecline = "invitation.decline" MsgInvitationCancel = "invitation.cancel" MsgProfileUpdate = "profile.update" - MsgEmailBindReq = "email.bind.request" - MsgEmailBindConfirm = "email.bind.confirm" MsgStatsGet = "stats.get" MsgGameGCG = "game.gcg" ) @@ -54,8 +52,6 @@ func registerStage8(r *Registry, backend *backendclient.Client) { r.ops[MsgInvitationDecline] = Op{Handler: invitationRespondHandler(backend, false), Auth: true} r.ops[MsgInvitationCancel] = Op{Handler: invitationCancelHandler(backend), Auth: true} r.ops[MsgProfileUpdate] = Op{Handler: profileUpdateHandler(backend), Auth: true} - r.ops[MsgEmailBindReq] = Op{Handler: emailBindRequestHandler(backend), Auth: true, Email: true} - r.ops[MsgEmailBindConfirm] = Op{Handler: emailBindConfirmHandler(backend), Auth: true, Email: true} r.ops[MsgStatsGet] = Op{Handler: statsHandler(backend), Auth: true} r.ops[MsgGameGCG] = Op{Handler: gcgHandler(backend), Auth: true} } @@ -250,27 +246,6 @@ func profileUpdateHandler(backend *backendclient.Client) Handler { } } -func emailBindRequestHandler(backend *backendclient.Client) Handler { - return func(ctx context.Context, req Request) ([]byte, error) { - in := fb.GetRootAsEmailBindRequest(req.Payload, 0) - if err := backend.EmailBindRequest(ctx, req.UserID, string(in.Email())); err != nil { - return nil, err - } - return encodeAck(true), nil - } -} - -func emailBindConfirmHandler(backend *backendclient.Client) Handler { - return func(ctx context.Context, req Request) ([]byte, error) { - in := fb.GetRootAsEmailConfirmRequest(req.Payload, 0) - out, err := backend.EmailBindConfirm(ctx, req.UserID, string(in.Email()), string(in.Code())) - if err != nil { - return nil, err - } - return encodeProfile(out), nil - } -} - func statsHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { res, err := backend.Stats(ctx, req.UserID) diff --git a/gateway/internal/transcode/transcode_telegram_test.go b/gateway/internal/transcode/transcode_telegram_test.go index deb7dc1..2d02015 100644 --- a/gateway/internal/transcode/transcode_telegram_test.go +++ b/gateway/internal/transcode/transcode_telegram_test.go @@ -23,6 +23,10 @@ func (f fakeValidator) ValidateInitData(context.Context, string) (connector.User return f.user, f.err } +func (f fakeValidator) ValidateLoginWidget(context.Context, string) (connector.User, error) { + return f.user, f.err +} + func telegramLoginPayload(initData string) []byte { b := flatbuffers.NewBuilder(0) off := b.CreateString(initData) diff --git a/pkg/README.md b/pkg/README.md index 9d0cb7d..c3f4431 100644 --- a/pkg/README.md +++ b/pkg/README.md @@ -16,6 +16,8 @@ fbs/scrabblefb/ # committed generated Go for the schema - **`proto/push/v1`** is the single gRPC server-stream the backend exposes and the gateway subscribes to (`Event{user_id, kind, payload, event_id}`); the `payload` is an opaque FlatBuffers body the gateway forwards verbatim. +- **`proto/telegram/v1`** is the Telegram connector's RPC contract (Stage 9; Stage 11 + added `ValidateLoginWidget` for the web Login Widget sign-in). - **`fbs`** holds the client↔gateway request/response and event payloads as FlatBuffers tables. The backend encodes the push payloads from these types; the gateway transcodes the rest to and from the backend's JSON; the UI generates diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index cdef56f..8e38778 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -272,18 +272,42 @@ table UpdateProfileRequest { notifications_in_app_only:bool = true; } -// EmailBindRequest asks the backend to send a confirm-code binding email to the -// caller's account. -table EmailBindRequest { +// --- account linking & merge (Stage 11, authenticated) --- + +// LinkEmailRequest mails a confirm-code to email for a later link or merge. The +// code is always sent (no pre-send "taken" signal), so a probe cannot enumerate +// registered addresses. +table LinkEmailRequest { email:string; } -// EmailConfirmRequest verifies the code and binds the email (returns Profile). -table EmailConfirmRequest { +// LinkEmailConfirm carries the email and its confirm code, for both the confirm +// (preview) and the explicit merge step. +table LinkEmailConfirm { email:string; code:string; } +// LinkTelegramRequest carries Telegram Login Widget data (a URL query string) for +// attaching a Telegram identity to the current account. +table LinkTelegramRequest { + data:string; +} + +// LinkResult is the unified result of a confirm or merge step. status is "linked" +// (bound to the caller), "merge_required" (the identity belongs to another account — +// the secondary_* fields summarise it for the irreversible confirmation), or +// "merged" (done). session is present only when the active account switched (a guest +// initiator whose durable counterpart won) — the client adopts it. +table LinkResult { + status:string; + secondary_user_id:string; + secondary_display_name:string; + secondary_games:int; + secondary_friends:int; + session:Session; +} + // StatsView is a durable account's lifetime statistics (games-played and win-rate // are derived client-side). table StatsView { diff --git a/pkg/fbs/scrabblefb/EmailConfirmRequest.go b/pkg/fbs/scrabblefb/EmailConfirmRequest.go deleted file mode 100644 index 0666a4c..0000000 --- a/pkg/fbs/scrabblefb/EmailConfirmRequest.go +++ /dev/null @@ -1,71 +0,0 @@ -// Code generated by the FlatBuffers compiler. DO NOT EDIT. - -package scrabblefb - -import ( - flatbuffers "github.com/google/flatbuffers/go" -) - -type EmailConfirmRequest struct { - _tab flatbuffers.Table -} - -func GetRootAsEmailConfirmRequest(buf []byte, offset flatbuffers.UOffsetT) *EmailConfirmRequest { - n := flatbuffers.GetUOffsetT(buf[offset:]) - x := &EmailConfirmRequest{} - x.Init(buf, n+offset) - return x -} - -func FinishEmailConfirmRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { - builder.Finish(offset) -} - -func GetSizePrefixedRootAsEmailConfirmRequest(buf []byte, offset flatbuffers.UOffsetT) *EmailConfirmRequest { - n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) - x := &EmailConfirmRequest{} - x.Init(buf, n+offset+flatbuffers.SizeUint32) - return x -} - -func FinishSizePrefixedEmailConfirmRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { - builder.FinishSizePrefixed(offset) -} - -func (rcv *EmailConfirmRequest) Init(buf []byte, i flatbuffers.UOffsetT) { - rcv._tab.Bytes = buf - rcv._tab.Pos = i -} - -func (rcv *EmailConfirmRequest) Table() flatbuffers.Table { - return rcv._tab -} - -func (rcv *EmailConfirmRequest) Email() []byte { - o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) - if o != 0 { - return rcv._tab.ByteVector(o + rcv._tab.Pos) - } - return nil -} - -func (rcv *EmailConfirmRequest) Code() []byte { - o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) - if o != 0 { - return rcv._tab.ByteVector(o + rcv._tab.Pos) - } - return nil -} - -func EmailConfirmRequestStart(builder *flatbuffers.Builder) { - builder.StartObject(2) -} -func EmailConfirmRequestAddEmail(builder *flatbuffers.Builder, email flatbuffers.UOffsetT) { - builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(email), 0) -} -func EmailConfirmRequestAddCode(builder *flatbuffers.Builder, code flatbuffers.UOffsetT) { - builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(code), 0) -} -func EmailConfirmRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { - return builder.EndObject() -} diff --git a/pkg/fbs/scrabblefb/LinkEmailConfirm.go b/pkg/fbs/scrabblefb/LinkEmailConfirm.go new file mode 100644 index 0000000..636334e --- /dev/null +++ b/pkg/fbs/scrabblefb/LinkEmailConfirm.go @@ -0,0 +1,71 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type LinkEmailConfirm struct { + _tab flatbuffers.Table +} + +func GetRootAsLinkEmailConfirm(buf []byte, offset flatbuffers.UOffsetT) *LinkEmailConfirm { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &LinkEmailConfirm{} + x.Init(buf, n+offset) + return x +} + +func FinishLinkEmailConfirmBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsLinkEmailConfirm(buf []byte, offset flatbuffers.UOffsetT) *LinkEmailConfirm { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &LinkEmailConfirm{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedLinkEmailConfirmBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *LinkEmailConfirm) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *LinkEmailConfirm) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *LinkEmailConfirm) Email() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *LinkEmailConfirm) Code() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func LinkEmailConfirmStart(builder *flatbuffers.Builder) { + builder.StartObject(2) +} +func LinkEmailConfirmAddEmail(builder *flatbuffers.Builder, email flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(email), 0) +} +func LinkEmailConfirmAddCode(builder *flatbuffers.Builder, code flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(code), 0) +} +func LinkEmailConfirmEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/EmailBindRequest.go b/pkg/fbs/scrabblefb/LinkEmailRequest.go similarity index 53% rename from pkg/fbs/scrabblefb/EmailBindRequest.go rename to pkg/fbs/scrabblefb/LinkEmailRequest.go index f7ebeca..8b98881 100644 --- a/pkg/fbs/scrabblefb/EmailBindRequest.go +++ b/pkg/fbs/scrabblefb/LinkEmailRequest.go @@ -6,42 +6,42 @@ import ( flatbuffers "github.com/google/flatbuffers/go" ) -type EmailBindRequest struct { +type LinkEmailRequest struct { _tab flatbuffers.Table } -func GetRootAsEmailBindRequest(buf []byte, offset flatbuffers.UOffsetT) *EmailBindRequest { +func GetRootAsLinkEmailRequest(buf []byte, offset flatbuffers.UOffsetT) *LinkEmailRequest { n := flatbuffers.GetUOffsetT(buf[offset:]) - x := &EmailBindRequest{} + x := &LinkEmailRequest{} x.Init(buf, n+offset) return x } -func FinishEmailBindRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { +func FinishLinkEmailRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { builder.Finish(offset) } -func GetSizePrefixedRootAsEmailBindRequest(buf []byte, offset flatbuffers.UOffsetT) *EmailBindRequest { +func GetSizePrefixedRootAsLinkEmailRequest(buf []byte, offset flatbuffers.UOffsetT) *LinkEmailRequest { n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) - x := &EmailBindRequest{} + x := &LinkEmailRequest{} x.Init(buf, n+offset+flatbuffers.SizeUint32) return x } -func FinishSizePrefixedEmailBindRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { +func FinishSizePrefixedLinkEmailRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { builder.FinishSizePrefixed(offset) } -func (rcv *EmailBindRequest) Init(buf []byte, i flatbuffers.UOffsetT) { +func (rcv *LinkEmailRequest) Init(buf []byte, i flatbuffers.UOffsetT) { rcv._tab.Bytes = buf rcv._tab.Pos = i } -func (rcv *EmailBindRequest) Table() flatbuffers.Table { +func (rcv *LinkEmailRequest) Table() flatbuffers.Table { return rcv._tab } -func (rcv *EmailBindRequest) Email() []byte { +func (rcv *LinkEmailRequest) Email() []byte { o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) if o != 0 { return rcv._tab.ByteVector(o + rcv._tab.Pos) @@ -49,12 +49,12 @@ func (rcv *EmailBindRequest) Email() []byte { return nil } -func EmailBindRequestStart(builder *flatbuffers.Builder) { +func LinkEmailRequestStart(builder *flatbuffers.Builder) { builder.StartObject(1) } -func EmailBindRequestAddEmail(builder *flatbuffers.Builder, email flatbuffers.UOffsetT) { +func LinkEmailRequestAddEmail(builder *flatbuffers.Builder, email flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(email), 0) } -func EmailBindRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { +func LinkEmailRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/pkg/fbs/scrabblefb/LinkResult.go b/pkg/fbs/scrabblefb/LinkResult.go new file mode 100644 index 0000000..f87c6ec --- /dev/null +++ b/pkg/fbs/scrabblefb/LinkResult.go @@ -0,0 +1,128 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type LinkResult struct { + _tab flatbuffers.Table +} + +func GetRootAsLinkResult(buf []byte, offset flatbuffers.UOffsetT) *LinkResult { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &LinkResult{} + x.Init(buf, n+offset) + return x +} + +func FinishLinkResultBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsLinkResult(buf []byte, offset flatbuffers.UOffsetT) *LinkResult { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &LinkResult{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedLinkResultBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *LinkResult) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *LinkResult) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *LinkResult) Status() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *LinkResult) SecondaryUserId() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *LinkResult) SecondaryDisplayName() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *LinkResult) SecondaryGames() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(10)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *LinkResult) MutateSecondaryGames(n int32) bool { + return rcv._tab.MutateInt32Slot(10, n) +} + +func (rcv *LinkResult) SecondaryFriends() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(12)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *LinkResult) MutateSecondaryFriends(n int32) bool { + return rcv._tab.MutateInt32Slot(12, n) +} + +func (rcv *LinkResult) Session(obj *Session) *Session { + o := flatbuffers.UOffsetT(rcv._tab.Offset(14)) + 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 LinkResultStart(builder *flatbuffers.Builder) { + builder.StartObject(6) +} +func LinkResultAddStatus(builder *flatbuffers.Builder, status flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(status), 0) +} +func LinkResultAddSecondaryUserId(builder *flatbuffers.Builder, secondaryUserId flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(secondaryUserId), 0) +} +func LinkResultAddSecondaryDisplayName(builder *flatbuffers.Builder, secondaryDisplayName flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(secondaryDisplayName), 0) +} +func LinkResultAddSecondaryGames(builder *flatbuffers.Builder, secondaryGames int32) { + builder.PrependInt32Slot(3, secondaryGames, 0) +} +func LinkResultAddSecondaryFriends(builder *flatbuffers.Builder, secondaryFriends int32) { + builder.PrependInt32Slot(4, secondaryFriends, 0) +} +func LinkResultAddSession(builder *flatbuffers.Builder, session flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(5, flatbuffers.UOffsetT(session), 0) +} +func LinkResultEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/LinkTelegramRequest.go b/pkg/fbs/scrabblefb/LinkTelegramRequest.go new file mode 100644 index 0000000..d2ab9ed --- /dev/null +++ b/pkg/fbs/scrabblefb/LinkTelegramRequest.go @@ -0,0 +1,60 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type LinkTelegramRequest struct { + _tab flatbuffers.Table +} + +func GetRootAsLinkTelegramRequest(buf []byte, offset flatbuffers.UOffsetT) *LinkTelegramRequest { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &LinkTelegramRequest{} + x.Init(buf, n+offset) + return x +} + +func FinishLinkTelegramRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsLinkTelegramRequest(buf []byte, offset flatbuffers.UOffsetT) *LinkTelegramRequest { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &LinkTelegramRequest{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedLinkTelegramRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *LinkTelegramRequest) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *LinkTelegramRequest) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *LinkTelegramRequest) Data() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func LinkTelegramRequestStart(builder *flatbuffers.Builder) { + builder.StartObject(1) +} +func LinkTelegramRequestAddData(builder *flatbuffers.Builder, data flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(data), 0) +} +func LinkTelegramRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/proto/telegram/v1/telegram.pb.go b/pkg/proto/telegram/v1/telegram.pb.go index 0670b1c..7f348f5 100644 --- a/pkg/proto/telegram/v1/telegram.pb.go +++ b/pkg/proto/telegram/v1/telegram.pb.go @@ -148,6 +148,115 @@ func (x *ValidateInitDataResponse) GetLanguageCode() string { return "" } +// ValidateLoginWidgetRequest carries the Login Widget result serialized as a URL +// query string (the widget fields plus the hash, e.g. "auth_date=...&id=...&hash=..."). +type ValidateLoginWidgetRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Data string `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ValidateLoginWidgetRequest) Reset() { + *x = ValidateLoginWidgetRequest{} + mi := &file_telegram_v1_telegram_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ValidateLoginWidgetRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ValidateLoginWidgetRequest) ProtoMessage() {} + +func (x *ValidateLoginWidgetRequest) ProtoReflect() protoreflect.Message { + mi := &file_telegram_v1_telegram_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ValidateLoginWidgetRequest.ProtoReflect.Descriptor instead. +func (*ValidateLoginWidgetRequest) Descriptor() ([]byte, []int) { + return file_telegram_v1_telegram_proto_rawDescGZIP(), []int{2} +} + +func (x *ValidateLoginWidgetRequest) GetData() string { + if x != nil { + return x.Data + } + return "" +} + +// ValidateLoginWidgetResponse is the validated identity. external_id is the +// Telegram user id used as the identities external_id. The Login Widget carries no +// language_code (unlike Mini App initData). +type ValidateLoginWidgetResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + ExternalId string `protobuf:"bytes,1,opt,name=external_id,json=externalId,proto3" json:"external_id,omitempty"` + Username string `protobuf:"bytes,2,opt,name=username,proto3" json:"username,omitempty"` + FirstName string `protobuf:"bytes,3,opt,name=first_name,json=firstName,proto3" json:"first_name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ValidateLoginWidgetResponse) Reset() { + *x = ValidateLoginWidgetResponse{} + mi := &file_telegram_v1_telegram_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ValidateLoginWidgetResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ValidateLoginWidgetResponse) ProtoMessage() {} + +func (x *ValidateLoginWidgetResponse) ProtoReflect() protoreflect.Message { + mi := &file_telegram_v1_telegram_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ValidateLoginWidgetResponse.ProtoReflect.Descriptor instead. +func (*ValidateLoginWidgetResponse) Descriptor() ([]byte, []int) { + return file_telegram_v1_telegram_proto_rawDescGZIP(), []int{3} +} + +func (x *ValidateLoginWidgetResponse) GetExternalId() string { + if x != nil { + return x.ExternalId + } + return "" +} + +func (x *ValidateLoginWidgetResponse) GetUsername() string { + if x != nil { + return x.Username + } + return "" +} + +func (x *ValidateLoginWidgetResponse) GetFirstName() string { + if x != nil { + return x.FirstName + } + return "" +} + // NotifyRequest addresses a push event to one recipient. kind is the backend push // catalog kind (your_turn, nudge, match_found, notify); payload is the FlatBuffers // scrabblefb.* body for that kind; language (en/ru) selects the message template. @@ -163,7 +272,7 @@ type NotifyRequest struct { func (x *NotifyRequest) Reset() { *x = NotifyRequest{} - mi := &file_telegram_v1_telegram_proto_msgTypes[2] + mi := &file_telegram_v1_telegram_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -175,7 +284,7 @@ func (x *NotifyRequest) String() string { func (*NotifyRequest) ProtoMessage() {} func (x *NotifyRequest) ProtoReflect() protoreflect.Message { - mi := &file_telegram_v1_telegram_proto_msgTypes[2] + mi := &file_telegram_v1_telegram_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -188,7 +297,7 @@ func (x *NotifyRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use NotifyRequest.ProtoReflect.Descriptor instead. func (*NotifyRequest) Descriptor() ([]byte, []int) { - return file_telegram_v1_telegram_proto_rawDescGZIP(), []int{2} + return file_telegram_v1_telegram_proto_rawDescGZIP(), []int{4} } func (x *NotifyRequest) GetExternalId() string { @@ -230,7 +339,7 @@ type NotifyResponse struct { func (x *NotifyResponse) Reset() { *x = NotifyResponse{} - mi := &file_telegram_v1_telegram_proto_msgTypes[3] + mi := &file_telegram_v1_telegram_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -242,7 +351,7 @@ func (x *NotifyResponse) String() string { func (*NotifyResponse) ProtoMessage() {} func (x *NotifyResponse) ProtoReflect() protoreflect.Message { - mi := &file_telegram_v1_telegram_proto_msgTypes[3] + mi := &file_telegram_v1_telegram_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -255,7 +364,7 @@ func (x *NotifyResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use NotifyResponse.ProtoReflect.Descriptor instead. func (*NotifyResponse) Descriptor() ([]byte, []int) { - return file_telegram_v1_telegram_proto_rawDescGZIP(), []int{3} + return file_telegram_v1_telegram_proto_rawDescGZIP(), []int{5} } func (x *NotifyResponse) GetDelivered() bool { @@ -276,7 +385,7 @@ type SendToUserRequest struct { func (x *SendToUserRequest) Reset() { *x = SendToUserRequest{} - mi := &file_telegram_v1_telegram_proto_msgTypes[4] + mi := &file_telegram_v1_telegram_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -288,7 +397,7 @@ func (x *SendToUserRequest) String() string { func (*SendToUserRequest) ProtoMessage() {} func (x *SendToUserRequest) ProtoReflect() protoreflect.Message { - mi := &file_telegram_v1_telegram_proto_msgTypes[4] + mi := &file_telegram_v1_telegram_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -301,7 +410,7 @@ func (x *SendToUserRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SendToUserRequest.ProtoReflect.Descriptor instead. func (*SendToUserRequest) Descriptor() ([]byte, []int) { - return file_telegram_v1_telegram_proto_rawDescGZIP(), []int{4} + return file_telegram_v1_telegram_proto_rawDescGZIP(), []int{6} } func (x *SendToUserRequest) GetExternalId() string { @@ -328,7 +437,7 @@ type SendToGameChannelRequest struct { func (x *SendToGameChannelRequest) Reset() { *x = SendToGameChannelRequest{} - mi := &file_telegram_v1_telegram_proto_msgTypes[5] + mi := &file_telegram_v1_telegram_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -340,7 +449,7 @@ func (x *SendToGameChannelRequest) String() string { func (*SendToGameChannelRequest) ProtoMessage() {} func (x *SendToGameChannelRequest) ProtoReflect() protoreflect.Message { - mi := &file_telegram_v1_telegram_proto_msgTypes[5] + mi := &file_telegram_v1_telegram_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -353,7 +462,7 @@ func (x *SendToGameChannelRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SendToGameChannelRequest.ProtoReflect.Descriptor instead. func (*SendToGameChannelRequest) Descriptor() ([]byte, []int) { - return file_telegram_v1_telegram_proto_rawDescGZIP(), []int{5} + return file_telegram_v1_telegram_proto_rawDescGZIP(), []int{7} } func (x *SendToGameChannelRequest) GetText() string { @@ -373,7 +482,7 @@ type SendResponse struct { func (x *SendResponse) Reset() { *x = SendResponse{} - mi := &file_telegram_v1_telegram_proto_msgTypes[6] + mi := &file_telegram_v1_telegram_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -385,7 +494,7 @@ func (x *SendResponse) String() string { func (*SendResponse) ProtoMessage() {} func (x *SendResponse) ProtoReflect() protoreflect.Message { - mi := &file_telegram_v1_telegram_proto_msgTypes[6] + mi := &file_telegram_v1_telegram_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -398,7 +507,7 @@ func (x *SendResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SendResponse.ProtoReflect.Descriptor instead. func (*SendResponse) Descriptor() ([]byte, []int) { - return file_telegram_v1_telegram_proto_rawDescGZIP(), []int{6} + return file_telegram_v1_telegram_proto_rawDescGZIP(), []int{8} } func (x *SendResponse) GetDelivered() bool { @@ -421,7 +530,15 @@ const file_telegram_v1_telegram_proto_rawDesc = "" + "\busername\x18\x02 \x01(\tR\busername\x12\x1d\n" + "\n" + "first_name\x18\x03 \x01(\tR\tfirstName\x12#\n" + - "\rlanguage_code\x18\x04 \x01(\tR\flanguageCode\"z\n" + + "\rlanguage_code\x18\x04 \x01(\tR\flanguageCode\"0\n" + + "\x1aValidateLoginWidgetRequest\x12\x12\n" + + "\x04data\x18\x01 \x01(\tR\x04data\"y\n" + + "\x1bValidateLoginWidgetResponse\x12\x1f\n" + + "\vexternal_id\x18\x01 \x01(\tR\n" + + "externalId\x12\x1a\n" + + "\busername\x18\x02 \x01(\tR\busername\x12\x1d\n" + + "\n" + + "first_name\x18\x03 \x01(\tR\tfirstName\"z\n" + "\rNotifyRequest\x12\x1f\n" + "\vexternal_id\x18\x01 \x01(\tR\n" + "externalId\x12\x12\n" + @@ -437,9 +554,10 @@ const file_telegram_v1_telegram_proto_rawDesc = "" + "\x18SendToGameChannelRequest\x12\x12\n" + "\x04text\x18\x01 \x01(\tR\x04text\",\n" + "\fSendResponse\x12\x1c\n" + - "\tdelivered\x18\x01 \x01(\bR\tdelivered2\x96\x03\n" + + "\tdelivered\x18\x01 \x01(\bR\tdelivered2\x92\x04\n" + "\bTelegram\x12q\n" + - "\x10ValidateInitData\x12-.scrabble.telegram.v1.ValidateInitDataRequest\x1a..scrabble.telegram.v1.ValidateInitDataResponse\x12S\n" + + "\x10ValidateInitData\x12-.scrabble.telegram.v1.ValidateInitDataRequest\x1a..scrabble.telegram.v1.ValidateInitDataResponse\x12z\n" + + "\x13ValidateLoginWidget\x120.scrabble.telegram.v1.ValidateLoginWidgetRequest\x1a1.scrabble.telegram.v1.ValidateLoginWidgetResponse\x12S\n" + "\x06Notify\x12#.scrabble.telegram.v1.NotifyRequest\x1a$.scrabble.telegram.v1.NotifyResponse\x12Y\n" + "\n" + "SendToUser\x12'.scrabble.telegram.v1.SendToUserRequest\x1a\".scrabble.telegram.v1.SendResponse\x12g\n" + @@ -457,27 +575,31 @@ func file_telegram_v1_telegram_proto_rawDescGZIP() []byte { return file_telegram_v1_telegram_proto_rawDescData } -var file_telegram_v1_telegram_proto_msgTypes = make([]protoimpl.MessageInfo, 7) +var file_telegram_v1_telegram_proto_msgTypes = make([]protoimpl.MessageInfo, 9) var file_telegram_v1_telegram_proto_goTypes = []any{ - (*ValidateInitDataRequest)(nil), // 0: scrabble.telegram.v1.ValidateInitDataRequest - (*ValidateInitDataResponse)(nil), // 1: scrabble.telegram.v1.ValidateInitDataResponse - (*NotifyRequest)(nil), // 2: scrabble.telegram.v1.NotifyRequest - (*NotifyResponse)(nil), // 3: scrabble.telegram.v1.NotifyResponse - (*SendToUserRequest)(nil), // 4: scrabble.telegram.v1.SendToUserRequest - (*SendToGameChannelRequest)(nil), // 5: scrabble.telegram.v1.SendToGameChannelRequest - (*SendResponse)(nil), // 6: scrabble.telegram.v1.SendResponse + (*ValidateInitDataRequest)(nil), // 0: scrabble.telegram.v1.ValidateInitDataRequest + (*ValidateInitDataResponse)(nil), // 1: scrabble.telegram.v1.ValidateInitDataResponse + (*ValidateLoginWidgetRequest)(nil), // 2: scrabble.telegram.v1.ValidateLoginWidgetRequest + (*ValidateLoginWidgetResponse)(nil), // 3: scrabble.telegram.v1.ValidateLoginWidgetResponse + (*NotifyRequest)(nil), // 4: scrabble.telegram.v1.NotifyRequest + (*NotifyResponse)(nil), // 5: scrabble.telegram.v1.NotifyResponse + (*SendToUserRequest)(nil), // 6: scrabble.telegram.v1.SendToUserRequest + (*SendToGameChannelRequest)(nil), // 7: scrabble.telegram.v1.SendToGameChannelRequest + (*SendResponse)(nil), // 8: scrabble.telegram.v1.SendResponse } var file_telegram_v1_telegram_proto_depIdxs = []int32{ 0, // 0: scrabble.telegram.v1.Telegram.ValidateInitData:input_type -> scrabble.telegram.v1.ValidateInitDataRequest - 2, // 1: scrabble.telegram.v1.Telegram.Notify:input_type -> scrabble.telegram.v1.NotifyRequest - 4, // 2: scrabble.telegram.v1.Telegram.SendToUser:input_type -> scrabble.telegram.v1.SendToUserRequest - 5, // 3: scrabble.telegram.v1.Telegram.SendToGameChannel:input_type -> scrabble.telegram.v1.SendToGameChannelRequest - 1, // 4: scrabble.telegram.v1.Telegram.ValidateInitData:output_type -> scrabble.telegram.v1.ValidateInitDataResponse - 3, // 5: scrabble.telegram.v1.Telegram.Notify:output_type -> scrabble.telegram.v1.NotifyResponse - 6, // 6: scrabble.telegram.v1.Telegram.SendToUser:output_type -> scrabble.telegram.v1.SendResponse - 6, // 7: scrabble.telegram.v1.Telegram.SendToGameChannel:output_type -> scrabble.telegram.v1.SendResponse - 4, // [4:8] is the sub-list for method output_type - 0, // [0:4] is the sub-list for method input_type + 2, // 1: scrabble.telegram.v1.Telegram.ValidateLoginWidget:input_type -> scrabble.telegram.v1.ValidateLoginWidgetRequest + 4, // 2: scrabble.telegram.v1.Telegram.Notify:input_type -> scrabble.telegram.v1.NotifyRequest + 6, // 3: scrabble.telegram.v1.Telegram.SendToUser:input_type -> scrabble.telegram.v1.SendToUserRequest + 7, // 4: scrabble.telegram.v1.Telegram.SendToGameChannel:input_type -> scrabble.telegram.v1.SendToGameChannelRequest + 1, // 5: scrabble.telegram.v1.Telegram.ValidateInitData:output_type -> scrabble.telegram.v1.ValidateInitDataResponse + 3, // 6: scrabble.telegram.v1.Telegram.ValidateLoginWidget:output_type -> scrabble.telegram.v1.ValidateLoginWidgetResponse + 5, // 7: scrabble.telegram.v1.Telegram.Notify:output_type -> scrabble.telegram.v1.NotifyResponse + 8, // 8: scrabble.telegram.v1.Telegram.SendToUser:output_type -> scrabble.telegram.v1.SendResponse + 8, // 9: scrabble.telegram.v1.Telegram.SendToGameChannel:output_type -> scrabble.telegram.v1.SendResponse + 5, // [5:10] is the sub-list for method output_type + 0, // [0:5] is the sub-list for method input_type 0, // [0:0] is the sub-list for extension type_name 0, // [0:0] is the sub-list for extension extendee 0, // [0:0] is the sub-list for field type_name @@ -494,7 +616,7 @@ func file_telegram_v1_telegram_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_telegram_v1_telegram_proto_rawDesc), len(file_telegram_v1_telegram_proto_rawDesc)), NumEnums: 0, - NumMessages: 7, + NumMessages: 9, NumExtensions: 0, NumServices: 1, }, diff --git a/pkg/proto/telegram/v1/telegram.proto b/pkg/proto/telegram/v1/telegram.proto index 5a9af90..204e977 100644 --- a/pkg/proto/telegram/v1/telegram.proto +++ b/pkg/proto/telegram/v1/telegram.proto @@ -20,6 +20,11 @@ service Telegram { // the authenticated user. The gateway calls it during the auth.telegram edge // operation, then provisions the session through the backend internal API. rpc ValidateInitData(ValidateInitDataRequest) returns (ValidateInitDataResponse); + // ValidateLoginWidget verifies Telegram Login Widget authorization data (the web + // sign-in flow, HMAC under SHA-256(bot_token)) and returns the authenticated + // user. The gateway calls it during the link.telegram edge operation to attach a + // Telegram identity to an existing account (Stage 11). + rpc ValidateLoginWidget(ValidateLoginWidgetRequest) returns (ValidateLoginWidgetResponse); // Notify delivers an out-of-app notification for a backend push event. The // gateway calls it only for a recipient with no live in-app stream (so the // platform push never duplicates in-app delivery). The connector renders a @@ -50,6 +55,21 @@ message ValidateInitDataResponse { string language_code = 4; } +// ValidateLoginWidgetRequest carries the Login Widget result serialized as a URL +// query string (the widget fields plus the hash, e.g. "auth_date=...&id=...&hash=..."). +message ValidateLoginWidgetRequest { + string data = 1; +} + +// ValidateLoginWidgetResponse is the validated identity. external_id is the +// Telegram user id used as the identities external_id. The Login Widget carries no +// language_code (unlike Mini App initData). +message ValidateLoginWidgetResponse { + string external_id = 1; + string username = 2; + string first_name = 3; +} + // NotifyRequest addresses a push event to one recipient. kind is the backend push // catalog kind (your_turn, nudge, match_found, notify); payload is the FlatBuffers // scrabblefb.* body for that kind; language (en/ru) selects the message template. diff --git a/pkg/proto/telegram/v1/telegram_grpc.pb.go b/pkg/proto/telegram/v1/telegram_grpc.pb.go index 6c7fa9d..081e9ea 100644 --- a/pkg/proto/telegram/v1/telegram_grpc.pb.go +++ b/pkg/proto/telegram/v1/telegram_grpc.pb.go @@ -30,10 +30,11 @@ import ( const _ = grpc.SupportPackageIsVersion9 const ( - Telegram_ValidateInitData_FullMethodName = "/scrabble.telegram.v1.Telegram/ValidateInitData" - Telegram_Notify_FullMethodName = "/scrabble.telegram.v1.Telegram/Notify" - Telegram_SendToUser_FullMethodName = "/scrabble.telegram.v1.Telegram/SendToUser" - Telegram_SendToGameChannel_FullMethodName = "/scrabble.telegram.v1.Telegram/SendToGameChannel" + Telegram_ValidateInitData_FullMethodName = "/scrabble.telegram.v1.Telegram/ValidateInitData" + Telegram_ValidateLoginWidget_FullMethodName = "/scrabble.telegram.v1.Telegram/ValidateLoginWidget" + Telegram_Notify_FullMethodName = "/scrabble.telegram.v1.Telegram/Notify" + Telegram_SendToUser_FullMethodName = "/scrabble.telegram.v1.Telegram/SendToUser" + Telegram_SendToGameChannel_FullMethodName = "/scrabble.telegram.v1.Telegram/SendToGameChannel" ) // TelegramClient is the client API for Telegram service. @@ -46,6 +47,11 @@ type TelegramClient interface { // the authenticated user. The gateway calls it during the auth.telegram edge // operation, then provisions the session through the backend internal API. ValidateInitData(ctx context.Context, in *ValidateInitDataRequest, opts ...grpc.CallOption) (*ValidateInitDataResponse, error) + // ValidateLoginWidget verifies Telegram Login Widget authorization data (the web + // sign-in flow, HMAC under SHA-256(bot_token)) and returns the authenticated + // user. The gateway calls it during the link.telegram edge operation to attach a + // Telegram identity to an existing account (Stage 11). + ValidateLoginWidget(ctx context.Context, in *ValidateLoginWidgetRequest, opts ...grpc.CallOption) (*ValidateLoginWidgetResponse, error) // Notify delivers an out-of-app notification for a backend push event. The // gateway calls it only for a recipient with no live in-app stream (so the // platform push never duplicates in-app delivery). The connector renders a @@ -79,6 +85,16 @@ func (c *telegramClient) ValidateInitData(ctx context.Context, in *ValidateInitD return out, nil } +func (c *telegramClient) ValidateLoginWidget(ctx context.Context, in *ValidateLoginWidgetRequest, opts ...grpc.CallOption) (*ValidateLoginWidgetResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ValidateLoginWidgetResponse) + err := c.cc.Invoke(ctx, Telegram_ValidateLoginWidget_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *telegramClient) Notify(ctx context.Context, in *NotifyRequest, opts ...grpc.CallOption) (*NotifyResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(NotifyResponse) @@ -119,6 +135,11 @@ type TelegramServer interface { // the authenticated user. The gateway calls it during the auth.telegram edge // operation, then provisions the session through the backend internal API. ValidateInitData(context.Context, *ValidateInitDataRequest) (*ValidateInitDataResponse, error) + // ValidateLoginWidget verifies Telegram Login Widget authorization data (the web + // sign-in flow, HMAC under SHA-256(bot_token)) and returns the authenticated + // user. The gateway calls it during the link.telegram edge operation to attach a + // Telegram identity to an existing account (Stage 11). + ValidateLoginWidget(context.Context, *ValidateLoginWidgetRequest) (*ValidateLoginWidgetResponse, error) // Notify delivers an out-of-app notification for a backend push event. The // gateway calls it only for a recipient with no live in-app stream (so the // platform push never duplicates in-app delivery). The connector renders a @@ -145,6 +166,9 @@ type UnimplementedTelegramServer struct{} func (UnimplementedTelegramServer) ValidateInitData(context.Context, *ValidateInitDataRequest) (*ValidateInitDataResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method ValidateInitData not implemented") } +func (UnimplementedTelegramServer) ValidateLoginWidget(context.Context, *ValidateLoginWidgetRequest) (*ValidateLoginWidgetResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ValidateLoginWidget not implemented") +} func (UnimplementedTelegramServer) Notify(context.Context, *NotifyRequest) (*NotifyResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method Notify not implemented") } @@ -193,6 +217,24 @@ func _Telegram_ValidateInitData_Handler(srv interface{}, ctx context.Context, de return interceptor(ctx, in, info, handler) } +func _Telegram_ValidateLoginWidget_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ValidateLoginWidgetRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(TelegramServer).ValidateLoginWidget(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Telegram_ValidateLoginWidget_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(TelegramServer).ValidateLoginWidget(ctx, req.(*ValidateLoginWidgetRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _Telegram_Notify_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(NotifyRequest) if err := dec(in); err != nil { @@ -258,6 +300,10 @@ var Telegram_ServiceDesc = grpc.ServiceDesc{ MethodName: "ValidateInitData", Handler: _Telegram_ValidateInitData_Handler, }, + { + MethodName: "ValidateLoginWidget", + Handler: _Telegram_ValidateLoginWidget_Handler, + }, { MethodName: "Notify", Handler: _Telegram_Notify_Handler, diff --git a/platform/telegram/README.md b/platform/telegram/README.md index 5e05f22..2d710ff 100644 --- a/platform/telegram/README.md +++ b/platform/telegram/README.md @@ -30,8 +30,12 @@ Telegram-specific. ## gRPC API -`pkg/proto/telegram/v1`, service `Telegram`: `ValidateInitData`, `Notify`, -`SendToUser`, `SendToGameChannel`. Generated Go is committed under `pkg`. +`pkg/proto/telegram/v1`, service `Telegram`: `ValidateInitData`, +`ValidateLoginWidget`, `Notify`, `SendToUser`, `SendToGameChannel`. Generated Go is +committed under `pkg`. `ValidateLoginWidget` (Stage 11) verifies Telegram **Login +Widget** web sign-in data — HMAC under `SHA-256(bot_token)`, distinct from initData +(`internal/loginwidget`) — for attaching a Telegram identity to an account from a +browser. ## Deep-link scheme diff --git a/platform/telegram/cmd/telegram/main.go b/platform/telegram/cmd/telegram/main.go index 115a917..32cdfae 100644 --- a/platform/telegram/cmd/telegram/main.go +++ b/platform/telegram/cmd/telegram/main.go @@ -21,6 +21,7 @@ import ( "scrabble/platform/telegram/internal/config" "scrabble/platform/telegram/internal/connector" "scrabble/platform/telegram/internal/initdata" + "scrabble/platform/telegram/internal/loginwidget" ) func main() { @@ -54,7 +55,10 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { if err != nil { return err } - srv := connector.NewServer(initdata.NewHMACValidator(cfg.BotToken), b, cfg.GameChannelID, logger) + srv := connector.NewServer( + initdata.NewHMACValidator(cfg.BotToken), + loginwidget.NewHMACValidator(cfg.BotToken), + b, cfg.GameChannelID, logger) grpcServer := grpc.NewServer() telegramv1.RegisterTelegramServer(grpcServer, srv) diff --git a/platform/telegram/internal/connector/server.go b/platform/telegram/internal/connector/server.go index 2f3123e..825236b 100644 --- a/platform/telegram/internal/connector/server.go +++ b/platform/telegram/internal/connector/server.go @@ -16,6 +16,7 @@ import ( telegramv1 "scrabble/pkg/proto/telegram/v1" "scrabble/platform/telegram/internal/initdata" + "scrabble/platform/telegram/internal/loginwidget" "scrabble/platform/telegram/internal/render" ) @@ -30,19 +31,21 @@ type Sender interface { // Server implements telegramv1.TelegramServer. type Server struct { telegramv1.UnimplementedTelegramServer - validator initdata.Validator - sender Sender - channelID int64 - log *zap.Logger + validator initdata.Validator + widgetValidator loginwidget.Validator + sender Sender + channelID int64 + log *zap.Logger } -// NewServer builds the gRPC service from a validator (for ValidateInitData), a -// sender (the bot), and the configured game channel id (0 disables channel posts). -func NewServer(validator initdata.Validator, sender Sender, channelID int64, log *zap.Logger) *Server { +// NewServer builds the gRPC service from the Mini App initData validator, the Login +// Widget validator (Stage 11), a sender (the bot), and the configured game channel +// id (0 disables channel posts). +func NewServer(validator initdata.Validator, widgetValidator loginwidget.Validator, sender Sender, channelID int64, log *zap.Logger) *Server { if log == nil { log = zap.NewNop() } - return &Server{validator: validator, sender: sender, channelID: channelID, log: log} + return &Server{validator: validator, widgetValidator: widgetValidator, sender: sender, channelID: channelID, log: log} } // ValidateInitData verifies Mini App launch data and returns the user identity. @@ -59,6 +62,20 @@ func (s *Server) ValidateInitData(ctx context.Context, req *telegramv1.ValidateI }, nil } +// ValidateLoginWidget verifies Login Widget authorization data and returns the user +// identity, for attaching a Telegram identity to an existing account (Stage 11). +func (s *Server) ValidateLoginWidget(ctx context.Context, req *telegramv1.ValidateLoginWidgetRequest) (*telegramv1.ValidateLoginWidgetResponse, error) { + u, err := s.widgetValidator.Validate(req.GetData()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + return &telegramv1.ValidateLoginWidgetResponse{ + ExternalId: u.ExternalID, + Username: u.Username, + FirstName: u.FirstName, + }, nil +} + // Notify renders and delivers an out-of-app notification. It reports // delivered=false (without an error) for a kind that is not pushed out-of-app or a // delivery the bot could not complete (e.g. the user never started the bot), so the diff --git a/platform/telegram/internal/connector/server_test.go b/platform/telegram/internal/connector/server_test.go index 8682e3e..4d02b22 100644 --- a/platform/telegram/internal/connector/server_test.go +++ b/platform/telegram/internal/connector/server_test.go @@ -11,6 +11,7 @@ import ( "scrabble/pkg/fbs/scrabblefb" telegramv1 "scrabble/pkg/proto/telegram/v1" "scrabble/platform/telegram/internal/initdata" + "scrabble/platform/telegram/internal/loginwidget" ) // stubValidator returns a fixed user / error from Validate. @@ -21,6 +22,14 @@ type stubValidator struct { func (s stubValidator) Validate(string) (initdata.User, error) { return s.user, s.err } +// stubWidgetValidator returns a fixed user / error from the Login Widget Validate. +type stubWidgetValidator struct { + user loginwidget.User + err error +} + +func (s stubWidgetValidator) Validate(string) (loginwidget.User, error) { return s.user, s.err } + // fakeSender records the delivery calls the server makes. type fakeSender struct { notify []notifyCall @@ -58,7 +67,7 @@ func yourTurnPayload(gameID string) []byte { func TestValidateInitData(t *testing.T) { want := initdata.User{ExternalID: "42", Username: "neo", FirstName: "Thomas", LanguageCode: "ru"} - srv := NewServer(stubValidator{user: want}, &fakeSender{}, 0, nil) + srv := NewServer(stubValidator{user: want}, stubWidgetValidator{}, &fakeSender{}, 0, nil) resp, err := srv.ValidateInitData(context.Background(), &telegramv1.ValidateInitDataRequest{InitData: "x"}) if err != nil { t.Fatalf("validate: %v", err) @@ -67,16 +76,33 @@ func TestValidateInitData(t *testing.T) { t.Errorf("resp = %+v, want %+v", resp, want) } - bad := NewServer(stubValidator{err: initdata.ErrInvalidInitData}, &fakeSender{}, 0, nil) + bad := NewServer(stubValidator{err: initdata.ErrInvalidInitData}, stubWidgetValidator{}, &fakeSender{}, 0, nil) if _, err := bad.ValidateInitData(context.Background(), &telegramv1.ValidateInitDataRequest{}); status.Code(err) != codes.InvalidArgument { t.Errorf("err code = %v, want InvalidArgument", status.Code(err)) } } +func TestValidateLoginWidget(t *testing.T) { + want := loginwidget.User{ExternalID: "42", Username: "neo", FirstName: "Thomas"} + srv := NewServer(stubValidator{}, stubWidgetValidator{user: want}, &fakeSender{}, 0, nil) + resp, err := srv.ValidateLoginWidget(context.Background(), &telegramv1.ValidateLoginWidgetRequest{Data: "x"}) + if err != nil { + t.Fatalf("validate: %v", err) + } + if resp.GetExternalId() != "42" || resp.GetUsername() != "neo" || resp.GetFirstName() != "Thomas" { + t.Errorf("resp = %+v, want %+v", resp, want) + } + + bad := NewServer(stubValidator{}, stubWidgetValidator{err: loginwidget.ErrInvalidLoginWidget}, &fakeSender{}, 0, nil) + if _, err := bad.ValidateLoginWidget(context.Background(), &telegramv1.ValidateLoginWidgetRequest{}); status.Code(err) != codes.InvalidArgument { + t.Errorf("err code = %v, want InvalidArgument", status.Code(err)) + } +} + func TestNotifyDelivers(t *testing.T) { const gameID = "7c9e6679-7425-40de-944b-e07fc1f90ae7" sender := &fakeSender{} - srv := NewServer(stubValidator{}, sender, 0, nil) + srv := NewServer(stubValidator{}, stubWidgetValidator{}, sender, 0, nil) resp, err := srv.Notify(context.Background(), &telegramv1.NotifyRequest{ ExternalId: "12345", Kind: "your_turn", Payload: yourTurnPayload(gameID), Language: "en", }) @@ -96,7 +122,7 @@ func TestNotifyDelivers(t *testing.T) { func TestNotifySkipsUnrenderedKind(t *testing.T) { sender := &fakeSender{} - srv := NewServer(stubValidator{}, sender, 0, nil) + srv := NewServer(stubValidator{}, stubWidgetValidator{}, sender, 0, nil) resp, err := srv.Notify(context.Background(), &telegramv1.NotifyRequest{ ExternalId: "12345", Kind: "opponent_moved", Language: "en", }) @@ -112,7 +138,7 @@ func TestNotifySkipsUnrenderedKind(t *testing.T) { } func TestNotifyInvalidExternalID(t *testing.T) { - srv := NewServer(stubValidator{}, &fakeSender{}, 0, nil) + srv := NewServer(stubValidator{}, stubWidgetValidator{}, &fakeSender{}, 0, nil) _, err := srv.Notify(context.Background(), &telegramv1.NotifyRequest{ ExternalId: "not-a-number", Kind: "your_turn", Payload: yourTurnPayload("g"), Language: "en", }) @@ -123,7 +149,7 @@ func TestNotifyInvalidExternalID(t *testing.T) { func TestSendToUser(t *testing.T) { sender := &fakeSender{} - srv := NewServer(stubValidator{}, sender, 0, nil) + srv := NewServer(stubValidator{}, stubWidgetValidator{}, sender, 0, nil) resp, err := srv.SendToUser(context.Background(), &telegramv1.SendToUserRequest{ExternalId: "999", Text: "hi"}) if err != nil { t.Fatalf("send to user: %v", err) @@ -135,7 +161,7 @@ func TestSendToUser(t *testing.T) { func TestSendToGameChannel(t *testing.T) { t.Run("unconfigured", func(t *testing.T) { - srv := NewServer(stubValidator{}, &fakeSender{}, 0, nil) + srv := NewServer(stubValidator{}, stubWidgetValidator{}, &fakeSender{}, 0, nil) _, err := srv.SendToGameChannel(context.Background(), &telegramv1.SendToGameChannelRequest{Text: "x"}) if status.Code(err) != codes.FailedPrecondition { t.Errorf("err code = %v, want FailedPrecondition", status.Code(err)) @@ -143,7 +169,7 @@ func TestSendToGameChannel(t *testing.T) { }) t.Run("configured", func(t *testing.T) { sender := &fakeSender{} - srv := NewServer(stubValidator{}, sender, 555, nil) + srv := NewServer(stubValidator{}, stubWidgetValidator{}, sender, 555, nil) resp, err := srv.SendToGameChannel(context.Background(), &telegramv1.SendToGameChannelRequest{Text: "news"}) if err != nil { t.Fatalf("send to channel: %v", err) diff --git a/platform/telegram/internal/loginwidget/validator.go b/platform/telegram/internal/loginwidget/validator.go new file mode 100644 index 0000000..1b5ee90 --- /dev/null +++ b/platform/telegram/internal/loginwidget/validator.go @@ -0,0 +1,122 @@ +// Package loginwidget validates Telegram Login Widget authorization data, the +// web (non-Mini-App) sign-in flow used to attach a Telegram identity to an existing +// account during linking (Stage 11). Like initdata it lives in the connector +// because the secret is derived from the bot token, held only here +// (ARCHITECTURE.md §12); the gateway calls the connector's ValidateLoginWidget RPC. +// +// The Login Widget algorithm differs from Mini App initData: the secret key is +// SHA-256(bot_token) (not HMAC(bot_token, "WebAppData")), the data-check string is +// the sorted key=value lines of the top-level fields (id, first_name, username, +// auth_date, ...), and there is no nested user JSON or language_code. +package loginwidget + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "errors" + "net/url" + "sort" + "strconv" + "strings" + "time" +) + +// ErrInvalidLoginWidget is returned when the data fails HMAC validation, is +// missing the hash or id, is malformed, or is older than the freshness window. +var ErrInvalidLoginWidget = errors.New("loginwidget: invalid telegram login widget data") + +// defaultMaxAge bounds how old a validated payload may be. +const defaultMaxAge = 24 * time.Hour + +// User is the identity extracted from validated Login Widget data. ExternalID is +// the Telegram user id used as the identities external_id. +type User struct { + ExternalID string + Username string + FirstName string +} + +// Validator validates Login Widget data and returns the authenticated user. It is +// an interface so the connector can be tested with a fixture. +type Validator interface { + Validate(data string) (User, error) +} + +// HMACValidator validates Login Widget data against a bot token. +type HMACValidator struct { + botToken string + maxAge time.Duration + now func() time.Time +} + +// NewHMACValidator constructs a validator for botToken. +func NewHMACValidator(botToken string) *HMACValidator { + return &HMACValidator{botToken: botToken, maxAge: defaultMaxAge, now: time.Now} +} + +// Validate parses and verifies the widget data (a URL-encoded key=value query +// string carrying the widget fields plus hash) and returns the authenticated user. +func (v *HMACValidator) Validate(data string) (User, error) { + values, err := url.ParseQuery(data) + if err != nil { + return User{}, ErrInvalidLoginWidget + } + hash := values.Get("hash") + if hash == "" { + return User{}, ErrInvalidLoginWidget + } + values.Del("hash") + + if !v.checkSignature(values, hash) { + return User{}, ErrInvalidLoginWidget + } + if err := v.checkFreshness(values.Get("auth_date")); err != nil { + return User{}, err + } + id := values.Get("id") + if id == "" { + return User{}, ErrInvalidLoginWidget + } + return User{ExternalID: id, Username: values.Get("username"), FirstName: values.Get("first_name")}, nil +} + +// checkSignature recomputes the HMAC over the sorted data-check string under the +// SHA-256(bot_token) secret and compares it with hash in constant time. +func (v *HMACValidator) checkSignature(values url.Values, hash string) bool { + keys := make([]string, 0, len(values)) + for k := range values { + keys = append(keys, k) + } + sort.Strings(keys) + lines := make([]string, 0, len(keys)) + for _, k := range keys { + lines = append(lines, k+"="+values.Get(k)) + } + dataCheck := strings.Join(lines, "\n") + + secret := sha256.Sum256([]byte(v.botToken)) + mac := hmac.New(sha256.New, secret[:]) + mac.Write([]byte(dataCheck)) + want := mac.Sum(nil) + got, err := hex.DecodeString(hash) + if err != nil { + return false + } + return hmac.Equal(want, got) +} + +// checkFreshness rejects an auth_date older than the validator's window. +func (v *HMACValidator) checkFreshness(authDate string) error { + if authDate == "" { + return ErrInvalidLoginWidget + } + secs, err := strconv.ParseInt(authDate, 10, 64) + if err != nil { + return ErrInvalidLoginWidget + } + if v.now().Sub(time.Unix(secs, 0)) > v.maxAge { + return ErrInvalidLoginWidget + } + return nil +} diff --git a/platform/telegram/internal/loginwidget/validator_test.go b/platform/telegram/internal/loginwidget/validator_test.go new file mode 100644 index 0000000..26e1ad4 --- /dev/null +++ b/platform/telegram/internal/loginwidget/validator_test.go @@ -0,0 +1,98 @@ +package loginwidget + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "errors" + "net/url" + "sort" + "strconv" + "strings" + "testing" + "time" +) + +const testToken = "123456:TESTTOKEN" + +// signWidget builds validly signed Login Widget data for the token and fields, +// mirroring Telegram's algorithm (secret = SHA-256(token); HMAC over the sorted +// data-check string). +func signWidget(token string, fields map[string]string) string { + keys := make([]string, 0, len(fields)) + for k := range fields { + keys = append(keys, k) + } + sort.Strings(keys) + lines := make([]string, 0, len(keys)) + for _, k := range keys { + lines = append(lines, k+"="+fields[k]) + } + secret := sha256.Sum256([]byte(token)) + mac := hmac.New(sha256.New, secret[:]) + mac.Write([]byte(strings.Join(lines, "\n"))) + + v := url.Values{} + for k, val := range fields { + v.Set(k, val) + } + v.Set("hash", hex.EncodeToString(mac.Sum(nil))) + return v.Encode() +} + +func freshFields() map[string]string { + return map[string]string{ + "auth_date": strconv.FormatInt(time.Now().Unix(), 10), + "id": "42", + "username": "neo", + "first_name": "Thomas", + } +} + +func TestValidateOK(t *testing.T) { + data := signWidget(testToken, freshFields()) + u, err := NewHMACValidator(testToken).Validate(data) + if err != nil { + t.Fatalf("validate: %v", err) + } + if u.ExternalID != "42" || u.Username != "neo" || u.FirstName != "Thomas" { + t.Errorf("user = %+v, want {42 neo Thomas}", u) + } +} + +func TestValidateRejects(t *testing.T) { + valid := signWidget(testToken, freshFields()) + + t.Run("tampered hash", func(t *testing.T) { + tampered := strings.Replace(valid, "hash=", "hash=00", 1) + if _, err := NewHMACValidator(testToken).Validate(tampered); !errors.Is(err, ErrInvalidLoginWidget) { + t.Errorf("err = %v, want ErrInvalidLoginWidget", err) + } + }) + t.Run("wrong token", func(t *testing.T) { + if _, err := NewHMACValidator("other:TOKEN").Validate(valid); !errors.Is(err, ErrInvalidLoginWidget) { + t.Errorf("err = %v, want ErrInvalidLoginWidget", err) + } + }) + t.Run("tampered field", func(t *testing.T) { + // Flip the id after signing: the HMAC must no longer match. + forged := strings.Replace(valid, "id=42", "id=43", 1) + if _, err := NewHMACValidator(testToken).Validate(forged); !errors.Is(err, ErrInvalidLoginWidget) { + t.Errorf("err = %v, want ErrInvalidLoginWidget", err) + } + }) + t.Run("missing hash", func(t *testing.T) { + if _, err := NewHMACValidator(testToken).Validate("id=42&auth_date=1"); !errors.Is(err, ErrInvalidLoginWidget) { + t.Errorf("err = %v, want ErrInvalidLoginWidget", err) + } + }) + t.Run("stale auth_date", func(t *testing.T) { + stale := signWidget(testToken, map[string]string{ + "auth_date": strconv.FormatInt(time.Now().Add(-48*time.Hour).Unix(), 10), + "id": "42", + }) + if _, err := NewHMACValidator(testToken).Validate(stale); !errors.Is(err, ErrInvalidLoginWidget) { + t.Errorf("err = %v, want ErrInvalidLoginWidget", err) + } + }) +} diff --git a/ui/README.md b/ui/README.md index 15edc71..0e667ba 100644 --- a/ui/README.md +++ b/ui/README.md @@ -26,7 +26,10 @@ pnpm codegen # regenerate src/gen from edge.proto + scrabble.fbs (dev-time) ``` `GATEWAY_URL` overrides the dev proxy target; `VITE_GATEWAY_URL` sets the runtime -gateway origin for a packaged (non-proxied) build. +gateway origin for a packaged (non-proxied) build. `VITE_TELEGRAM_BOT_ID` (Stage 11) +enables the "Link Telegram" web sign-in (the Login Widget) — inert until the site +domain is registered with BotFather (`/setdomain`); `VITE_TELEGRAM_LINK` is the +share-to-Telegram deep-link base (Stage 9). ## How it talks to the gateway diff --git a/ui/e2e/social.spec.ts b/ui/e2e/social.spec.ts index 0c37e15..3f03d88 100644 --- a/ui/e2e/social.spec.ts +++ b/ui/e2e/social.spec.ts @@ -139,6 +139,34 @@ test('profile edit disables Save and flags an invalid display name', async ({ pa await expect(save).toBeEnabled(); }); +test('link account: a taken email opens the irreversible merge confirmation', async ({ page }) => { + await loginLobby(page); + await page.locator('.burger').first().click(); + await page.getByRole('button', { name: /Profile/ }).click(); + + // The linking section is shown to everyone (guests upgrade by linking). + await expect(page.getByRole('heading', { name: 'Link an account' })).toBeVisible(); + // An address containing "merge" stands in (in the mock) for one already owned by + // another account, so the confirm step reveals a required merge. + await page.locator('.emailbox input[type="email"]').fill('merge@example.com'); + await page.getByRole('button', { name: 'Send code' }).click(); + await page.locator('.emailbox .codein').fill('123456'); + await page.getByRole('button', { name: 'OK' }).click(); + + // The reveal happens only after the code, and names the other account. + await expect(page.getByText('Merge accounts?')).toBeVisible(); + await expect(page.getByText(/Ann/)).toBeVisible(); + await page.getByRole('button', { name: 'Merge' }).click(); + await expect(page.getByText('Merge accounts?')).toBeHidden(); +}); + +test('link account: the Telegram web sign-in control is offered in a browser', async ({ page }) => { + await loginLobby(page); + await page.locator('.burger').first().click(); + await page.getByRole('button', { name: /Profile/ }).click(); + await expect(page.getByRole('button', { name: 'Link Telegram' })).toBeVisible(); +}); + test('chat send and nudge are icon buttons', async ({ page }) => { await loginLobby(page); await page.getByRole('button', { name: /Ann/ }).click(); diff --git a/ui/src/gen/fbs/scrabblefb.ts b/ui/src/gen/fbs/scrabblefb.ts index 6262d10..b8be1e2 100644 --- a/ui/src/gen/fbs/scrabblefb.ts +++ b/ui/src/gen/fbs/scrabblefb.ts @@ -9,8 +9,6 @@ export { ChatPostRequest } from './scrabblefb/chat-post-request.js'; export { CheckWordRequest } from './scrabblefb/check-word-request.js'; export { ComplaintRequest } from './scrabblefb/complaint-request.js'; export { CreateInvitationRequest } from './scrabblefb/create-invitation-request.js'; -export { EmailBindRequest } from './scrabblefb/email-bind-request.js'; -export { EmailConfirmRequest } from './scrabblefb/email-confirm-request.js'; export { EmailLoginRequest } from './scrabblefb/email-login-request.js'; export { EmailRequestRequest } from './scrabblefb/email-request-request.js'; export { EnqueueRequest } from './scrabblefb/enqueue-request.js'; @@ -32,6 +30,10 @@ export { Invitation } from './scrabblefb/invitation.js'; export { InvitationActionRequest } from './scrabblefb/invitation-action-request.js'; export { InvitationInvitee } from './scrabblefb/invitation-invitee.js'; export { InvitationList } from './scrabblefb/invitation-list.js'; +export { LinkEmailConfirm } from './scrabblefb/link-email-confirm.js'; +export { LinkEmailRequest } from './scrabblefb/link-email-request.js'; +export { LinkResult } from './scrabblefb/link-result.js'; +export { LinkTelegramRequest } from './scrabblefb/link-telegram-request.js'; export { MatchFoundEvent } from './scrabblefb/match-found-event.js'; export { MatchResult } from './scrabblefb/match-result.js'; export { MoveRecord } from './scrabblefb/move-record.js'; diff --git a/ui/src/gen/fbs/scrabblefb/email-confirm-request.ts b/ui/src/gen/fbs/scrabblefb/link-email-confirm.ts similarity index 53% rename from ui/src/gen/fbs/scrabblefb/email-confirm-request.ts rename to ui/src/gen/fbs/scrabblefb/link-email-confirm.ts index 6ec5bcf..01aaefc 100644 --- a/ui/src/gen/fbs/scrabblefb/email-confirm-request.ts +++ b/ui/src/gen/fbs/scrabblefb/link-email-confirm.ts @@ -2,22 +2,22 @@ import * as flatbuffers from 'flatbuffers'; -export class EmailConfirmRequest { +export class LinkEmailConfirm { bb: flatbuffers.ByteBuffer|null = null; bb_pos = 0; - __init(i:number, bb:flatbuffers.ByteBuffer):EmailConfirmRequest { + __init(i:number, bb:flatbuffers.ByteBuffer):LinkEmailConfirm { this.bb_pos = i; this.bb = bb; return this; } -static getRootAsEmailConfirmRequest(bb:flatbuffers.ByteBuffer, obj?:EmailConfirmRequest):EmailConfirmRequest { - return (obj || new EmailConfirmRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +static getRootAsLinkEmailConfirm(bb:flatbuffers.ByteBuffer, obj?:LinkEmailConfirm):LinkEmailConfirm { + return (obj || new LinkEmailConfirm()).__init(bb.readInt32(bb.position()) + bb.position(), bb); } -static getSizePrefixedRootAsEmailConfirmRequest(bb:flatbuffers.ByteBuffer, obj?:EmailConfirmRequest):EmailConfirmRequest { +static getSizePrefixedRootAsLinkEmailConfirm(bb:flatbuffers.ByteBuffer, obj?:LinkEmailConfirm):LinkEmailConfirm { bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); - return (obj || new EmailConfirmRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + return (obj || new LinkEmailConfirm()).__init(bb.readInt32(bb.position()) + bb.position(), bb); } email():string|null @@ -34,7 +34,7 @@ code(optionalEncoding?:any):string|Uint8Array|null { return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; } -static startEmailConfirmRequest(builder:flatbuffers.Builder) { +static startLinkEmailConfirm(builder:flatbuffers.Builder) { builder.startObject(2); } @@ -46,15 +46,15 @@ static addCode(builder:flatbuffers.Builder, codeOffset:flatbuffers.Offset) { builder.addFieldOffset(1, codeOffset, 0); } -static endEmailConfirmRequest(builder:flatbuffers.Builder):flatbuffers.Offset { +static endLinkEmailConfirm(builder:flatbuffers.Builder):flatbuffers.Offset { const offset = builder.endObject(); return offset; } -static createEmailConfirmRequest(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset, codeOffset:flatbuffers.Offset):flatbuffers.Offset { - EmailConfirmRequest.startEmailConfirmRequest(builder); - EmailConfirmRequest.addEmail(builder, emailOffset); - EmailConfirmRequest.addCode(builder, codeOffset); - return EmailConfirmRequest.endEmailConfirmRequest(builder); +static createLinkEmailConfirm(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset, codeOffset:flatbuffers.Offset):flatbuffers.Offset { + LinkEmailConfirm.startLinkEmailConfirm(builder); + LinkEmailConfirm.addEmail(builder, emailOffset); + LinkEmailConfirm.addCode(builder, codeOffset); + return LinkEmailConfirm.endLinkEmailConfirm(builder); } } diff --git a/ui/src/gen/fbs/scrabblefb/email-bind-request.ts b/ui/src/gen/fbs/scrabblefb/link-email-request.ts similarity index 54% rename from ui/src/gen/fbs/scrabblefb/email-bind-request.ts rename to ui/src/gen/fbs/scrabblefb/link-email-request.ts index c4aabdf..2f3bdb6 100644 --- a/ui/src/gen/fbs/scrabblefb/email-bind-request.ts +++ b/ui/src/gen/fbs/scrabblefb/link-email-request.ts @@ -2,22 +2,22 @@ import * as flatbuffers from 'flatbuffers'; -export class EmailBindRequest { +export class LinkEmailRequest { bb: flatbuffers.ByteBuffer|null = null; bb_pos = 0; - __init(i:number, bb:flatbuffers.ByteBuffer):EmailBindRequest { + __init(i:number, bb:flatbuffers.ByteBuffer):LinkEmailRequest { this.bb_pos = i; this.bb = bb; return this; } -static getRootAsEmailBindRequest(bb:flatbuffers.ByteBuffer, obj?:EmailBindRequest):EmailBindRequest { - return (obj || new EmailBindRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +static getRootAsLinkEmailRequest(bb:flatbuffers.ByteBuffer, obj?:LinkEmailRequest):LinkEmailRequest { + return (obj || new LinkEmailRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb); } -static getSizePrefixedRootAsEmailBindRequest(bb:flatbuffers.ByteBuffer, obj?:EmailBindRequest):EmailBindRequest { +static getSizePrefixedRootAsLinkEmailRequest(bb:flatbuffers.ByteBuffer, obj?:LinkEmailRequest):LinkEmailRequest { bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); - return (obj || new EmailBindRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb); + return (obj || new LinkEmailRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb); } email():string|null @@ -27,7 +27,7 @@ email(optionalEncoding?:any):string|Uint8Array|null { return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; } -static startEmailBindRequest(builder:flatbuffers.Builder) { +static startLinkEmailRequest(builder:flatbuffers.Builder) { builder.startObject(1); } @@ -35,14 +35,14 @@ static addEmail(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset) { builder.addFieldOffset(0, emailOffset, 0); } -static endEmailBindRequest(builder:flatbuffers.Builder):flatbuffers.Offset { +static endLinkEmailRequest(builder:flatbuffers.Builder):flatbuffers.Offset { const offset = builder.endObject(); return offset; } -static createEmailBindRequest(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset):flatbuffers.Offset { - EmailBindRequest.startEmailBindRequest(builder); - EmailBindRequest.addEmail(builder, emailOffset); - return EmailBindRequest.endEmailBindRequest(builder); +static createLinkEmailRequest(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset):flatbuffers.Offset { + LinkEmailRequest.startLinkEmailRequest(builder); + LinkEmailRequest.addEmail(builder, emailOffset); + return LinkEmailRequest.endLinkEmailRequest(builder); } } diff --git a/ui/src/gen/fbs/scrabblefb/link-result.ts b/ui/src/gen/fbs/scrabblefb/link-result.ts new file mode 100644 index 0000000..42c8318 --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/link-result.ts @@ -0,0 +1,95 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +import * as flatbuffers from 'flatbuffers'; + +import { Session } from '../scrabblefb/session.js'; + + +export class LinkResult { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):LinkResult { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsLinkResult(bb:flatbuffers.ByteBuffer, obj?:LinkResult):LinkResult { + return (obj || new LinkResult()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsLinkResult(bb:flatbuffers.ByteBuffer, obj?:LinkResult):LinkResult { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new LinkResult()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +status():string|null +status(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +status(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; +} + +secondaryUserId():string|null +secondaryUserId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +secondaryUserId(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; +} + +secondaryDisplayName():string|null +secondaryDisplayName(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +secondaryDisplayName(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; +} + +secondaryGames():number { + const offset = this.bb!.__offset(this.bb_pos, 10); + return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; +} + +secondaryFriends():number { + const offset = this.bb!.__offset(this.bb_pos, 12); + return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; +} + +session(obj?:Session):Session|null { + const offset = this.bb!.__offset(this.bb_pos, 14); + return offset ? (obj || new Session()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null; +} + +static startLinkResult(builder:flatbuffers.Builder) { + builder.startObject(6); +} + +static addStatus(builder:flatbuffers.Builder, statusOffset:flatbuffers.Offset) { + builder.addFieldOffset(0, statusOffset, 0); +} + +static addSecondaryUserId(builder:flatbuffers.Builder, secondaryUserIdOffset:flatbuffers.Offset) { + builder.addFieldOffset(1, secondaryUserIdOffset, 0); +} + +static addSecondaryDisplayName(builder:flatbuffers.Builder, secondaryDisplayNameOffset:flatbuffers.Offset) { + builder.addFieldOffset(2, secondaryDisplayNameOffset, 0); +} + +static addSecondaryGames(builder:flatbuffers.Builder, secondaryGames:number) { + builder.addFieldInt32(3, secondaryGames, 0); +} + +static addSecondaryFriends(builder:flatbuffers.Builder, secondaryFriends:number) { + builder.addFieldInt32(4, secondaryFriends, 0); +} + +static addSession(builder:flatbuffers.Builder, sessionOffset:flatbuffers.Offset) { + builder.addFieldOffset(5, sessionOffset, 0); +} + +static endLinkResult(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +} diff --git a/ui/src/gen/fbs/scrabblefb/link-telegram-request.ts b/ui/src/gen/fbs/scrabblefb/link-telegram-request.ts new file mode 100644 index 0000000..7888614 --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/link-telegram-request.ts @@ -0,0 +1,48 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +import * as flatbuffers from 'flatbuffers'; + +export class LinkTelegramRequest { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):LinkTelegramRequest { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsLinkTelegramRequest(bb:flatbuffers.ByteBuffer, obj?:LinkTelegramRequest):LinkTelegramRequest { + return (obj || new LinkTelegramRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsLinkTelegramRequest(bb:flatbuffers.ByteBuffer, obj?:LinkTelegramRequest):LinkTelegramRequest { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new LinkTelegramRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +data():string|null +data(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +data(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 startLinkTelegramRequest(builder:flatbuffers.Builder) { + builder.startObject(1); +} + +static addData(builder:flatbuffers.Builder, dataOffset:flatbuffers.Offset) { + builder.addFieldOffset(0, dataOffset, 0); +} + +static endLinkTelegramRequest(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +static createLinkTelegramRequest(builder:flatbuffers.Builder, dataOffset:flatbuffers.Offset):flatbuffers.Offset { + LinkTelegramRequest.startLinkTelegramRequest(builder); + LinkTelegramRequest.addData(builder, dataOffset); + return LinkTelegramRequest.endLinkTelegramRequest(builder); +} +} diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index e1bdb1f..1cde1b6 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -3,7 +3,7 @@ // gateway calls funnel through here so errors map to one user-facing toast and an // expired session logs out. -import type { Profile, PushEvent, Session } from './model'; +import type { LinkResult, Profile, PushEvent, Session } from './model'; import { gateway } from './gateway'; import { GatewayError } from './client'; import { navigate, router } from './router.svelte'; @@ -129,6 +129,19 @@ async function adoptSession(s: Session): Promise { void refreshNotifications(); } +/** + * applyLinkResult applies a completed account link or merge (Stage 11): it adopts a + * switched session (a guest initiator whose durable counterpart won, so the active + * account changed) or, otherwise, refreshes the current profile in place. + */ +export async function applyLinkResult(r: LinkResult): Promise { + if (r.session && r.session.token) { + await adoptSession(r.session); + return; + } + app.profile = await gateway.profileGet(); +} + export async function bootstrap(): Promise { const prefs = await loadPrefs(); app.theme = prefs.theme ?? 'auto'; diff --git a/ui/src/lib/client.ts b/ui/src/lib/client.ts index 51802db..b503842 100644 --- a/ui/src/lib/client.ts +++ b/ui/src/lib/client.ts @@ -16,6 +16,7 @@ import type { HintResult, Invitation, InvitationSettings, + LinkResult, MatchResult, MoveResult, Profile, @@ -106,11 +107,16 @@ export interface GatewayClient { // --- profile / stats / history (Stage 8) --- profileUpdate(p: ProfileUpdate): Promise; - emailBindRequest(email: string): Promise; - emailBindConfirm(email: string, code: string): Promise; statsGet(): Promise; exportGcg(gameId: string): Promise; + // --- account linking & merge (Stage 11) --- + linkEmailRequest(email: string): Promise; + linkEmailConfirm(email: string, code: string): Promise; + linkEmailMerge(email: string, code: string): Promise; + linkTelegram(data: string): Promise; + linkTelegramMerge(data: string): Promise; + // --- live stream --- subscribe(onEvent: (e: PushEvent) => void, onError?: (err: unknown) => void): Unsubscribe; diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts index d5f5022..ef926a4 100644 --- a/ui/src/lib/codec.test.ts +++ b/ui/src/lib/codec.test.ts @@ -5,6 +5,7 @@ import { decodeFriendList, decodeGameList, decodeInvitation, + decodeLinkResult, decodeSession, decodeStats, encodeSubmitPlay, @@ -124,6 +125,49 @@ describe('codec', () => { expect(decodeFriendList(b.asUint8Array())).toEqual([{ accountId: 'a-1', displayName: 'Ann' }]); }); + it('decodes a merge_required LinkResult without a session', () => { + const b = new Builder(128); + const status = b.createString('merge_required'); + const sid = b.createString('b-1'); + const sname = b.createString('Ann'); + fb.LinkResult.startLinkResult(b); + fb.LinkResult.addStatus(b, status); + fb.LinkResult.addSecondaryUserId(b, sid); + fb.LinkResult.addSecondaryDisplayName(b, sname); + fb.LinkResult.addSecondaryGames(b, 7); + fb.LinkResult.addSecondaryFriends(b, 3); + b.finish(fb.LinkResult.endLinkResult(b)); + expect(decodeLinkResult(b.asUint8Array())).toEqual({ + status: 'merge_required', + secondaryUserId: 'b-1', + secondaryDisplayName: 'Ann', + secondaryGames: 7, + secondaryFriends: 3, + session: null, + }); + }); + + it('decodes a merged LinkResult carrying a switched session', () => { + const b = new Builder(128); + const token = b.createString('tok-9'); + const uid = b.createString('a-1'); + const dn = b.createString('Kaya'); + fb.Session.startSession(b); + fb.Session.addToken(b, token); + fb.Session.addUserId(b, uid); + fb.Session.addIsGuest(b, false); + fb.Session.addDisplayName(b, dn); + const sess = fb.Session.endSession(b); + const status = b.createString('merged'); + fb.LinkResult.startLinkResult(b); + fb.LinkResult.addStatus(b, status); + fb.LinkResult.addSession(b, sess); + b.finish(fb.LinkResult.endLinkResult(b)); + const r = decodeLinkResult(b.asUint8Array()); + expect(r.status).toBe('merged'); + expect(r.session).toEqual({ token: 'tok-9', userId: 'a-1', isGuest: false, displayName: 'Kaya' }); + }); + it('decodes an Invitation with inviter and invitees', () => { const b = new Builder(256); const iid = b.createString('u-1'); diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index 330dc2c..b238f84 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -19,6 +19,7 @@ import type { Invitation, InvitationInvitee, InvitationSettings, + LinkResult, MatchResult, MoveRecord, MoveResult, @@ -457,22 +458,47 @@ export function encodeUpdateProfile(p: ProfileUpdate): Uint8Array { return finish(b, fb.UpdateProfileRequest.endUpdateProfileRequest(b)); } -export function encodeEmailBind(email: string): Uint8Array { +// --- account linking & merge (Stage 11) --- + +export function encodeLinkEmailRequest(email: string): Uint8Array { const b = new Builder(128); const e = b.createString(email); - fb.EmailBindRequest.startEmailBindRequest(b); - fb.EmailBindRequest.addEmail(b, e); - return finish(b, fb.EmailBindRequest.endEmailBindRequest(b)); + fb.LinkEmailRequest.startLinkEmailRequest(b); + fb.LinkEmailRequest.addEmail(b, e); + return finish(b, fb.LinkEmailRequest.endLinkEmailRequest(b)); } -export function encodeEmailConfirm(email: string, code: string): Uint8Array { +export function encodeLinkEmailConfirm(email: string, code: string): Uint8Array { const b = new Builder(128); const e = b.createString(email); const c = b.createString(code); - fb.EmailConfirmRequest.startEmailConfirmRequest(b); - fb.EmailConfirmRequest.addEmail(b, e); - fb.EmailConfirmRequest.addCode(b, c); - return finish(b, fb.EmailConfirmRequest.endEmailConfirmRequest(b)); + fb.LinkEmailConfirm.startLinkEmailConfirm(b); + fb.LinkEmailConfirm.addEmail(b, e); + fb.LinkEmailConfirm.addCode(b, c); + return finish(b, fb.LinkEmailConfirm.endLinkEmailConfirm(b)); +} + +export function encodeLinkTelegram(data: string): Uint8Array { + const b = new Builder(256); + const d = b.createString(data); + fb.LinkTelegramRequest.startLinkTelegramRequest(b); + fb.LinkTelegramRequest.addData(b, d); + return finish(b, fb.LinkTelegramRequest.endLinkTelegramRequest(b)); +} + +export function decodeLinkResult(buf: Uint8Array): LinkResult { + const r = fb.LinkResult.getRootAsLinkResult(new ByteBuffer(buf)); + const sess = r.session(); + return { + status: (s(r.status()) || 'linked') as LinkResult['status'], + secondaryUserId: s(r.secondaryUserId()), + secondaryDisplayName: s(r.secondaryDisplayName()), + secondaryGames: r.secondaryGames(), + secondaryFriends: r.secondaryFriends(), + session: sess + ? { token: s(sess.token()), userId: s(sess.userId()), isGuest: sess.isGuest(), displayName: s(sess.displayName()) } + : null, + }; } // --- Stage 8 decoders --- diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 4f1677c..6cf7c09 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -118,6 +118,14 @@ export const en = { 'profile.emailBound': 'Email confirmed.', 'profile.saved': 'Profile saved.', 'profile.guestLocked': 'Sign in with email to manage your profile.', + 'profile.linkAccount': 'Link an account', + 'profile.linkTelegram': 'Link Telegram', + 'profile.linked': 'Account linked.', + 'profile.merged': 'Accounts merged.', + 'profile.mergeTitle': 'Merge accounts?', + 'profile.mergeBody': 'This identity already belongs to “{name}” ({games} games, {friends} friends).', + 'profile.mergeIrreversible': 'Merging combines both accounts into this one and cannot be undone.', + 'profile.mergeConfirm': 'Merge', 'settings.title': 'Settings', 'settings.theme': 'Theme', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 56396a2..7034bd2 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -119,6 +119,14 @@ export const ru: Record = { 'profile.emailBound': 'Почта подтверждена.', 'profile.saved': 'Профиль сохранён.', 'profile.guestLocked': 'Войдите по почте, чтобы управлять профилем.', + 'profile.linkAccount': 'Привязать аккаунт', + 'profile.linkTelegram': 'Привязать Telegram', + 'profile.linked': 'Аккаунт привязан.', + 'profile.merged': 'Аккаунты объединены.', + 'profile.mergeTitle': 'Объединить аккаунты?', + 'profile.mergeBody': 'Эта личность уже принадлежит «{name}» (игр: {games}, друзей: {friends}).', + 'profile.mergeIrreversible': 'Объединение сольёт оба аккаунта в этот и необратимо.', + 'profile.mergeConfirm': 'Объединить', 'settings.title': 'Настройки', 'settings.theme': 'Тема', diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index 45f7d44..ecf7775 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -22,6 +22,7 @@ import type { HintResult, Invitation, InvitationSettings, + LinkResult, MatchResult, MoveResult, Profile, @@ -46,6 +47,18 @@ import { type MockGame, } from './data'; +// emptyLinked is a "linked" LinkResult with no secondary summary or session switch. +function emptyLinked(): LinkResult { + return { + status: 'linked', + secondaryUserId: '', + secondaryDisplayName: '', + secondaryGames: 0, + secondaryFriends: 0, + session: null, + }; +} + const POOL: Record = { english: 'AAAAEEEEIIIOONNRRTTLLSSUDGBCMPFHVWYK', russian: 'ОООААЕЕИИННТТСРРВЛКМДПУЯЫЬГЗБ', @@ -415,10 +428,35 @@ export class MockGateway implements GatewayClient { Object.assign(this.profile, p); return { ...this.profile }; } - async emailBindRequest(_email: string): Promise {} - async emailBindConfirm(_email: string, _code: string): Promise { + // --- account linking & merge (Stage 11) --- + async linkEmailRequest(_email: string): Promise {} + async linkEmailConfirm(email: string, _code: string): Promise { + // An address containing "merge" stands in for one already owned by another + // account, so the mock can drive the irreversible-merge confirmation. + if (email.includes('merge')) { + return { + status: 'merge_required', + secondaryUserId: 'mock-secondary', + secondaryDisplayName: 'Ann', + secondaryGames: 7, + secondaryFriends: 3, + session: null, + }; + } this.profile.isGuest = false; - return { ...this.profile }; + return emptyLinked(); + } + async linkEmailMerge(_email: string, _code: string): Promise { + this.profile.isGuest = false; + return { ...emptyLinked(), status: 'merged' }; + } + async linkTelegram(_data: string): Promise { + this.profile.isGuest = false; + return emptyLinked(); + } + async linkTelegramMerge(_data: string): Promise { + this.profile.isGuest = false; + return { ...emptyLinked(), status: 'merged' }; } async statsGet(): Promise { return { ...this.stats }; diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts index 20e3ddd..1a0df53 100644 --- a/ui/src/lib/model.ts +++ b/ui/src/lib/model.ts @@ -188,6 +188,20 @@ export interface Session { displayName: string; } +// LinkResult is the outcome of an account link/merge step (Stage 11). status is +// 'linked' (bound to the current account), 'merge_required' (the identity belongs to +// another account — the secondary* fields summarise it for the irreversible +// confirmation) or 'merged'. session is set only when the active account switched +// (a guest initiator whose durable counterpart won); the client adopts it. +export interface LinkResult { + status: 'linked' | 'merge_required' | 'merged'; + secondaryUserId: string; + secondaryDisplayName: string; + secondaryGames: number; + secondaryFriends: number; + session: Session | null; +} + export interface MatchResult { matched: boolean; game?: GameView; diff --git a/ui/src/lib/telegram.ts b/ui/src/lib/telegram.ts index a20b25e..db77efd 100644 --- a/ui/src/lib/telegram.ts +++ b/ui/src/lib/telegram.ts @@ -65,3 +65,96 @@ export function onTelegramPath(): boolean { if (typeof location === 'undefined') return false; return location.pathname.startsWith('/telegram/'); } + +// --- Login Widget (web sign-in for account linking, Stage 11) --- + +// The Login Widget is the web (non-Mini-App) Telegram sign-in. It is used only to +// attach a Telegram identity to an existing account from a browser; inside the Mini +// App the session is already a Telegram identity. It needs the bot id (numeric, +// VITE_TELEGRAM_BOT_ID) and, in production, the site domain registered with BotFather +// (/setdomain) — without that Telegram refuses to render. The connector validates the +// returned data (HMAC under SHA-256(bot_token)). + +const widgetScriptSrc = 'https://telegram.org/js/telegram-widget.js?22'; + +interface telegramAuthUser { + id: number; + first_name?: string; + last_name?: string; + username?: string; + photo_url?: string; + auth_date: number; + hash: string; +} + +interface telegramLoginSDK { + auth(opts: { bot_id: string; request_access?: string }, cb: (user: telegramAuthUser | false) => void): void; +} + +function isMock(): boolean { + return import.meta.env.MODE === 'mock'; +} + +function botID(): string { + return (import.meta.env.VITE_TELEGRAM_BOT_ID as string | undefined) ?? ''; +} + +/** + * loginWidgetAvailable reports whether the "Link Telegram" control should be shown: + * not already inside the Mini App, and either the mock build or a configured bot id. + */ +export function loginWidgetAvailable(): boolean { + if (insideTelegram()) return false; + return isMock() || botID() !== ''; +} + +let widgetLoad: Promise | null = null; + +function loadWidget(): Promise { + if (typeof document === 'undefined') return Promise.reject(new Error('telegram: no document')); + const sdk = (window as unknown as { Telegram?: { Login?: telegramLoginSDK } }).Telegram?.Login; + if (sdk) return Promise.resolve(); + if (!widgetLoad) { + widgetLoad = new Promise((resolve, reject) => { + const s = document.createElement('script'); + s.src = widgetScriptSrc; + s.async = true; + s.onload = () => resolve(); + s.onerror = () => reject(new Error('telegram: widget load failed')); + document.head.appendChild(s); + }); + } + return widgetLoad; +} + +/** + * requestTelegramLogin drives the Login Widget popup and resolves with the auth data + * serialized as a URL query string (id=...&auth_date=...&hash=...) — the form the + * connector validates — or null when the user cancels. In the mock build it returns + * a fixed payload without loading the real widget (telegram.org is blocked in tests). + */ +export async function requestTelegramLogin(): Promise { + if (isMock()) { + return `id=42&first_name=Telegram&auth_date=${Math.floor(Date.now() / 1000)}&hash=mock`; + } + await loadWidget(); + const login = (window as unknown as { Telegram?: { Login?: telegramLoginSDK } }).Telegram?.Login; + if (!login) throw new Error('telegram: login unavailable'); + const user = await new Promise((resolve) => { + login.auth({ bot_id: botID(), request_access: 'write' }, resolve); + }); + if (!user) return null; + return serializeTelegramAuth(user); +} + +function serializeTelegramAuth(u: telegramAuthUser): string { + const params = new URLSearchParams(); + params.set('id', String(u.id)); + if (u.first_name) params.set('first_name', u.first_name); + if (u.last_name) params.set('last_name', u.last_name); + if (u.username) params.set('username', u.username); + if (u.photo_url) params.set('photo_url', u.photo_url); + params.set('auth_date', String(u.auth_date)); + params.set('hash', u.hash); + return params.toString(); +} diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index d1181f5..4ef12e4 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -176,11 +176,20 @@ export function createTransport(baseUrl: string): GatewayClient { async profileUpdate(p) { return codec.decodeProfile(await exec('profile.update', codec.encodeUpdateProfile(p))); }, - async emailBindRequest(email) { - await exec('email.bind.request', codec.encodeEmailBind(email)); + async linkEmailRequest(email) { + await exec('link.email.request', codec.encodeLinkEmailRequest(email)); }, - async emailBindConfirm(email, code) { - return codec.decodeProfile(await exec('email.bind.confirm', codec.encodeEmailConfirm(email, code))); + async linkEmailConfirm(email, code) { + return codec.decodeLinkResult(await exec('link.email.confirm', codec.encodeLinkEmailConfirm(email, code))); + }, + async linkEmailMerge(email, code) { + return codec.decodeLinkResult(await exec('link.email.merge', codec.encodeLinkEmailConfirm(email, code))); + }, + async linkTelegram(data) { + return codec.decodeLinkResult(await exec('link.telegram.confirm', codec.encodeLinkTelegram(data))); + }, + async linkTelegramMerge(data) { + return codec.decodeLinkResult(await exec('link.telegram.merge', codec.encodeLinkTelegram(data))); }, async statsGet() { return codec.decodeStats(await exec('stats.get', codec.empty())); diff --git a/ui/src/screens/Profile.svelte b/ui/src/screens/Profile.svelte index a3483e6..f427f1d 100644 --- a/ui/src/screens/Profile.svelte +++ b/ui/src/screens/Profile.svelte @@ -1,7 +1,9 @@