Files
scrabble-game/backend/internal/link/service.go
T
Ilia Denisov da25eac070 feat(link): auto-merge a guest initiator into its durable account (seamless sign-in)
When a guest links an identity (email/TG/VK) that already belongs to a durable account,
the guest-primary rule already made the durable account the survivor and switched the
session — but the client still showed an irreversible 'merge two accounts?' confirmation,
which is nonsense from the user's side (they are simply signing into their account). The
confirm step now merges inline for a GUEST initiator and returns the completed merge (the
switched token); a durable initiator still gets the explicit confirmation (consolidating
two real accounts is consequential). The active-game guard still refuses, surfaced as the
clear error.merge_active_game_conflict message rather than swallowed.

Also fixes the merged-away device-local games: their human/host seat was recorded under
the retired account id, so after the switch the lobby and game header could not identify
'me' and showed every seat as an opponent (the game still played — turn logic is
seat-index based). applyLinkResult now re-points local game seats from the retired id to
the survivor (repointLocalGameSeats).

Docs: FUNCTIONAL.md (+_ru).
2026-07-15 00:34:58 +02:00

241 lines
9.2 KiB
Go

// Package link orchestrates account linking & merge (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,
// MergeRequired or Merged is set. SecondaryID is the account to be retired when a merge
// is required (the caller renders an irreversible-merge confirmation from it). Merged is
// set when the caller was a guest, so the merge ran inline (see confirmOrAutoMerge) and
// Merge carries its result (the switched-session token for the surviving durable account).
type ConfirmResult struct {
Linked bool
MergeRequired bool
SecondaryID uuid.UUID
Merged bool
Merge MergeResult
}
// confirmOrAutoMerge decides a required merge at the confirm step. A durable caller gets
// the explicit MergeRequired confirmation (consolidating two real accounts is irreversible
// and consequential — the user must see it). A GUEST caller does not: by the guest-primary
// rule the durable other account survives and the ephemeral guest is retired, folding its
// games/wallet/stats in — from the user's side it is simply "logged into my account", so the
// merge runs inline and the client just adopts the switched session. The active-game guard can
// still refuse (accountmerge.ErrActiveGameConflict), surfaced to the caller unchanged.
func (s *Service) confirmOrAutoMerge(ctx context.Context, callerID, owner uuid.UUID) (ConfirmResult, error) {
caller, err := s.accounts.GetByID(ctx, callerID)
if err != nil {
return ConfirmResult{}, err
}
if !caller.IsGuest {
return ConfirmResult{MergeRequired: true, SecondaryID: owner}, nil
}
res, err := s.merge(ctx, callerID, owner)
if err != nil {
return ConfirmResult{}, err
}
return ConfirmResult{Merged: true, Merge: res}, nil
}
// 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 s.confirmOrAutoMerge(ctx, accountID, owner)
}
// 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 s.confirmOrAutoMerge(ctx, callerID, owner)
}
// 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)
}
// ConfirmVK attaches a gateway-validated VK identity to the caller (Linked) or
// reports that it belongs to another account (MergeRequired). The gateway has already
// completed the VK ID code exchange, so externalID is the trusted vk user id.
func (s *Service) ConfirmVK(ctx context.Context, callerID uuid.UUID, externalID string) (ConfirmResult, error) {
owner, ok, err := s.accounts.AccountIDByIdentity(ctx, account.KindVK, externalID)
if err != nil {
return ConfirmResult{}, err
}
if !ok {
if err := s.attachVK(ctx, callerID, externalID); err != nil {
return ConfirmResult{}, err
}
return ConfirmResult{Linked: true}, nil
}
if owner == callerID {
return ConfirmResult{Linked: true}, nil
}
return s.confirmOrAutoMerge(ctx, callerID, owner)
}
// MergeVK merges the account owning a gateway-validated VK identity into the caller's
// (subject to the guest-primary rule).
func (s *Service) MergeVK(ctx context.Context, callerID uuid.UUID, externalID string) (MergeResult, error) {
owner, ok, err := s.accounts.AccountIDByIdentity(ctx, account.KindVK, externalID)
if err != nil {
return MergeResult{}, err
}
if !ok {
if err := s.attachVK(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)
}
// attachVK links the identity to the caller and promotes a guest.
func (s *Service) attachVK(ctx context.Context, callerID uuid.UUID, externalID string) error {
if err := s.accounts.AttachIdentity(ctx, callerID, account.KindVK, 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 {
// The switched-to session inherits the caller's current trusted platform
// (the context the merge was initiated from); absent when the caller's
// session itself is untrusted.
platform, _ := session.PlatformFromContext(ctx)
token, _, err := s.sessions.Create(ctx, primary, platform)
if err != nil {
return MergeResult{}, err
}
res.SwitchedToken = token
}
return res, nil
}