Stage 11: account linking & merge (email + Telegram Login Widget)
Tests · Go / test (push) Successful in 7s
Tests · Integration / integration (push) Successful in 11s
Tests · UI / test (push) Successful in 20s
Tests · Go / test (pull_request) Successful in 6s
Tests · Integration / integration (pull_request) Successful in 11s
Tests · UI / test (pull_request) Successful in 19s

Link an email (confirm-code) or Telegram (web Login Widget) to the current
account; if the identity already has its own account, merge the two into the
one in use (the current account is primary, except a guest initiator whose
durable counterpart wins). The merge runs in one transaction
(internal/accountmerge): stats + 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
no-cascade FKs hold; a shared active game blocks the merge.

- migration 00009: accounts.paid_account, merged_into, merged_at (+ jetgen)
- internal/link orchestrator; session.RevokeAllForAccount on merge
- connector ValidateLoginWidget RPC + loginwidget HMAC validator
- edge ops link.email.request/confirm/merge, link.telegram.confirm/merge;
  supersedes the Stage 8 email.bind.* surface (request never reveals 'taken'
  before the code is verified, so a probe cannot enumerate addresses)
- UI Profile link section + irreversible-merge dialog; Telegram web sign-in
- focused regression tests (merge core, guest inversion, active-game refusal,
  finished-shared-game kept), gateway transcode + connector + UI codec/e2e
- docs: PLAN, ARCHITECTURE 3/4/9, FUNCTIONAL(+ru), module READMEs
This commit is contained in:
Ilia Denisov
2026-06-04 11:15:14 +02:00
parent 3a640a17a4
commit 52f898ca6f
68 changed files with 3331 additions and 369 deletions
+59 -1
View File
@@ -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,
+16 -2
View File
@@ -83,6 +83,18 @@ pipeline, dictionary **hot-reload** from `BACKEND_DICT_DIR/<version>/`
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
+6
View File
@@ -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,
+16 -2
View File
@@ -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,
}
+145
View File
@@ -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
}
+497
View File
@@ -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
}
@@ -10,7 +10,9 @@
<li><b>Timezone</b> {{.TimeZone}}</li>
<li><b>Guest</b> {{if .Guest}}yes{{else}}no{{end}}</li>
<li><b>Push</b> {{if .NotificationsInAppOnly}}in-app only{{else}}out-of-app{{end}}</li>
<li><b>Paid</b> {{if .PaidAccount}}yes{{else}}no{{end}}</li>
<li><b>Hint wallet</b> {{.HintBalance}}</li>
{{if .MergedInto}}<li><b>Merged into</b> {{.MergedInto}}</li>{{end}}
<li><b>Created</b> {{.CreatedAt}}</li>
</ul>
</section>
+12 -8
View File
@@ -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.
+302
View File
@@ -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)
}
}
+163
View File
@@ -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
}
@@ -26,4 +26,7 @@ type Accounts struct {
HintBalance int32
IsGuest bool
NotificationsInAppOnly bool
PaidAccount bool
MergedInto *uuid.UUID
MergedAt *time.Time
}
@@ -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,
@@ -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;
+13 -4
View File
@@ -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),
@@ -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))
}
@@ -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 {
+200
View File
@@ -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
}
+6
View File
@@ -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,
+18
View File
@@ -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)
}
}
}
+11
View File
@@ -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
}
+28
View File
@@ -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).
+33 -13
View File
@@ -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
+14 -10
View File
@@ -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
+12 -9
View File
@@ -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)*
Завершённые партии архивируются в независимом от словаря виде и экспортируются
+6 -2
View File
@@ -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
+14
View File
@@ -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"`
+34 -7
View File
@@ -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
+22
View File
@@ -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) {
+34
View File
@@ -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)
+9 -2
View File
@@ -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
}
@@ -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
}
}
@@ -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")
}
}
@@ -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)
@@ -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)
+2
View File
@@ -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
+29 -5
View File
@@ -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 {
-71
View File
@@ -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()
}
+71
View File
@@ -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()
}
@@ -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()
}
+128
View File
@@ -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()
}
+60
View File
@@ -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()
}
+158 -36
View File
@@ -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,
},
+20
View File
@@ -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.
+50 -4
View File
@@ -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,
+6 -2
View File
@@ -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
+5 -1
View File
@@ -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)
+25 -8
View File
@@ -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
@@ -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)
@@ -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
}
@@ -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)
}
})
}
+4 -1
View File
@@ -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
+28
View File
@@ -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();
+4 -2
View File
@@ -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';
@@ -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);
}
}
@@ -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);
}
}
+95
View File
@@ -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;
}
}
@@ -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);
}
}
+14 -1
View File
@@ -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> {
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<void> {
if (r.session && r.session.token) {
await adoptSession(r.session);
return;
}
app.profile = await gateway.profileGet();
}
export async function bootstrap(): Promise<void> {
const prefs = await loadPrefs();
app.theme = prefs.theme ?? 'auto';
+8 -2
View File
@@ -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<Profile>;
emailBindRequest(email: string): Promise<void>;
emailBindConfirm(email: string, code: string): Promise<Profile>;
statsGet(): Promise<Stats>;
exportGcg(gameId: string): Promise<GcgExport>;
// --- account linking & merge (Stage 11) ---
linkEmailRequest(email: string): Promise<void>;
linkEmailConfirm(email: string, code: string): Promise<LinkResult>;
linkEmailMerge(email: string, code: string): Promise<LinkResult>;
linkTelegram(data: string): Promise<LinkResult>;
linkTelegramMerge(data: string): Promise<LinkResult>;
// --- live stream ---
subscribe(onEvent: (e: PushEvent) => void, onError?: (err: unknown) => void): Unsubscribe;
+44
View File
@@ -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');
+35 -9
View File
@@ -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 ---
+8
View File
@@ -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',
+8
View File
@@ -119,6 +119,14 @@ export const ru: Record<MessageKey, string> = {
'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': 'Тема',
+41 -3
View File
@@ -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<Variant, string> = {
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<void> {}
async emailBindConfirm(_email: string, _code: string): Promise<Profile> {
// --- account linking & merge (Stage 11) ---
async linkEmailRequest(_email: string): Promise<void> {}
async linkEmailConfirm(email: string, _code: string): Promise<LinkResult> {
// 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<LinkResult> {
this.profile.isGuest = false;
return { ...emptyLinked(), status: 'merged' };
}
async linkTelegram(_data: string): Promise<LinkResult> {
this.profile.isGuest = false;
return emptyLinked();
}
async linkTelegramMerge(_data: string): Promise<LinkResult> {
this.profile.isGuest = false;
return { ...emptyLinked(), status: 'merged' };
}
async statsGet(): Promise<Stats> {
return { ...this.stats };
+14
View File
@@ -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;
+93
View File
@@ -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<void> | null = null;
function loadWidget(): Promise<void> {
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<void>((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<string | null> {
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<telegramAuthUser | false>((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();
}
+13 -4
View File
@@ -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()));
+99 -33
View File
@@ -1,7 +1,9 @@
<script lang="ts">
import Modal from '../components/Modal.svelte';
import Screen from '../components/Screen.svelte';
import { app, handleError, logout, showToast } from '../lib/app.svelte';
import { app, applyLinkResult, handleError, logout, showToast } from '../lib/app.svelte';
import { gateway } from '../lib/gateway';
import { loginWidgetAvailable, requestTelegramLogin } from '../lib/telegram';
import { t } from '../lib/i18n/index.svelte';
import {
awayDurationOk,
@@ -29,6 +31,11 @@
let emailInput = $state('');
let codeInput = $state('');
let emailSent = $state(false);
// A pending irreversible merge surfaced after the code/widget was verified; the
// dialog confirms it. tgData holds the Telegram widget payload for the merge step.
let pendingMerge = $state<null | { kind: 'email' | 'telegram'; name: string; games: number; friends: number }>(null);
let tgData = '';
const telegramLinkable = loginWidgetAvailable();
function defaultTz(): string {
const b = browserOffset();
@@ -79,10 +86,16 @@
}
}
function resetEmail() {
emailSent = false;
emailInput = '';
codeInput = '';
}
async function requestEmail() {
if (!emailOk) return;
try {
await gateway.emailBindRequest(emailInput.trim());
await gateway.linkEmailRequest(emailInput.trim());
emailSent = true;
showToast(t('profile.emailSent', { email: emailInput.trim() }));
} catch (e) {
@@ -92,11 +105,48 @@
async function confirmEmail() {
try {
app.profile = await gateway.emailBindConfirm(emailInput.trim(), codeInput.trim());
emailSent = false;
emailInput = '';
codeInput = '';
showToast(t('profile.emailBound'));
const r = await gateway.linkEmailConfirm(emailInput.trim(), codeInput.trim());
if (r.status === 'merge_required') {
pendingMerge = { kind: 'email', name: r.secondaryDisplayName, games: r.secondaryGames, friends: r.secondaryFriends };
return;
}
await applyLinkResult(r);
resetEmail();
showToast(t('profile.linked'));
} catch (e) {
handleError(e);
}
}
async function linkTelegram() {
try {
const data = await requestTelegramLogin();
if (!data) return;
const r = await gateway.linkTelegram(data);
if (r.status === 'merge_required') {
tgData = data;
pendingMerge = { kind: 'telegram', name: r.secondaryDisplayName, games: r.secondaryGames, friends: r.secondaryFriends };
return;
}
await applyLinkResult(r);
showToast(t('profile.linked'));
} catch (e) {
handleError(e);
}
}
async function confirmMerge() {
if (!pendingMerge) return;
try {
const r =
pendingMerge.kind === 'email'
? await gateway.linkEmailMerge(emailInput.trim(), codeInput.trim())
: await gateway.linkTelegramMerge(tgData);
await applyLinkResult(r);
pendingMerge = null;
tgData = '';
resetEmail();
showToast(t('profile.merged'));
} catch (e) {
handleError(e);
}
@@ -169,38 +219,54 @@
<p class="muted">{t('profile.guestLocked')}</p>
{:else}
<button class="btn" onclick={startEdit}>{t('profile.edit')}</button>
<section class="emailbox">
<h3>{t('profile.bindEmail')}</h3>
{#if !emailSent}
<div class="addrow">
<input
class:invalid={emailInput.length > 0 && !emailOk}
bind:value={emailInput}
placeholder={t('login.emailPlaceholder')}
type="email"
/>
<button class="ghost" onclick={requestEmail} disabled={!emailOk}>{t('login.sendCode')}</button>
</div>
{:else}
<div class="addrow">
<input
class="codein"
bind:value={codeInput}
placeholder={t('profile.emailCode')}
inputmode="numeric"
maxlength="6"
/>
<button class="btn" onclick={confirmEmail}>{t('common.ok')}</button>
</div>
{/if}
</section>
{/if}
<!-- Linking & merge (Stage 11). Shown to everyone, including guests, who
upgrade by binding their first identity. -->
<section class="emailbox">
<h3>{t('profile.linkAccount')}</h3>
{#if !emailSent}
<div class="addrow">
<input
class:invalid={emailInput.length > 0 && !emailOk}
bind:value={emailInput}
placeholder={t('login.emailPlaceholder')}
type="email"
/>
<button class="ghost" onclick={requestEmail} disabled={!emailOk}>{t('login.sendCode')}</button>
</div>
{:else}
<div class="addrow">
<input
class="codein"
bind:value={codeInput}
placeholder={t('profile.emailCode')}
inputmode="numeric"
maxlength="6"
/>
<button class="btn" onclick={confirmEmail}>{t('common.ok')}</button>
</div>
{/if}
{#if telegramLinkable}
<button class="ghost tg" onclick={linkTelegram}>{t('profile.linkTelegram')}</button>
{/if}
</section>
{/if}
<button class="logout" onclick={() => logout()}>{t('login.title')} / logout</button>
{/if}
</div>
{#if pendingMerge}
<Modal title={t('profile.mergeTitle')} onclose={() => (pendingMerge = null)}>
<p>{t('profile.mergeBody', { name: pendingMerge.name, games: pendingMerge.games, friends: pendingMerge.friends })}</p>
<p class="warn">{t('profile.mergeIrreversible')}</p>
<div class="addrow end">
<button class="ghost" onclick={() => (pendingMerge = null)}>{t('common.cancel')}</button>
<button class="btn" onclick={confirmMerge}>{t('profile.mergeConfirm')}</button>
</div>
</Modal>
{/if}
</Screen>
<style>