// 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 }