package account import ( "context" "database/sql" "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. var ErrIdentityTaken = errors.New("account: identity already linked to another account") // ErrLastIdentity is returned when removing an identity would leave the account with // none, making it unreachable after logout. The admin email-erase refuses it. var ErrLastIdentity = errors.New("account: cannot remove the last identity") // RemoveIdentity deletes the account's identity of the given kind (and, for an email, // any pending confirmations for it), freeing it for reuse. It refuses when that is the // account's only identity (ErrLastIdentity) — which would leave the account // unreachable — and returns ErrNotFound when the account has no identity of that kind. // It backs the profile Unlink control and the admin "erase email" action. func (s *Store) RemoveIdentity(ctx context.Context, accountID uuid.UUID, kind string) error { ids, err := s.Identities(ctx, accountID) if err != nil { return err } var toRetain []Identity others := 0 for _, id := range ids { if id.Kind == kind { toRetain = append(toRetain, id) } else { others++ } } if len(toRetain) == 0 { return ErrNotFound } if others == 0 { return ErrLastIdentity } return withTx(ctx, s.db, func(tx *sql.Tx) error { // Journal the detached credential before removing it, so the legal dossier // survives while the identity frees for reuse (see retention.go). for _, id := range toRetain { if err := retainIdentityTx(ctx, tx, accountID, id.Kind, id.ExternalID, id.Confirmed, id.CreatedAt, retainUnlink); err != nil { return err } } delID := table.Identities.DELETE().WHERE( table.Identities.AccountID.EQ(postgres.UUID(accountID)). AND(table.Identities.Kind.EQ(postgres.String(kind))), ) if _, err := delID.ExecContext(ctx, tx); err != nil { return fmt.Errorf("account: delete %s identity %s: %w", kind, accountID, err) } if kind == KindEmail { delConf := table.EmailConfirmations.DELETE().WHERE( table.EmailConfirmations.AccountID.EQ(postgres.UUID(accountID)), ) if _, err := delConf.ExecContext(ctx, tx); err != nil { return fmt.Errorf("account: delete email confirmations %s: %w", accountID, err) } } return nil }) } // RemoveEmailIdentity erases the account's email identity. It backs the admin console's // "erase email" action; the user-facing profile never unlinks email (it is changed). func (s *Store) RemoveEmailIdentity(ctx context.Context, accountID uuid.UUID) error { return s.RemoveIdentity(ctx, accountID, KindEmail) } // 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. func (s *EmailService) RequestLinkCode(ctx context.Context, accountID uuid.UUID, email string) error { addr, err := normalizeEmail(email) if err != nil { return err } if !s.allowSend(addr) { return ErrTooManyRequests } return s.issueCode(ctx, accountID, addr, purposeLink, s.accountLocale(ctx, accountID)) } // 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 } // RequestChangeCode issues and mails a confirm-code to newEmail for an authenticated // email change on accountID, replacing any prior pending code. Like RequestLinkCode it // never refuses up front on "taken" (anti-enumeration): possession of newEmail is the // authorization, and a conflict with another account is revealed only at confirm — as a // non-disclosing refusal, never a merge. func (s *EmailService) RequestChangeCode(ctx context.Context, accountID uuid.UUID, newEmail string) error { addr, err := normalizeEmail(newEmail) if err != nil { return err } if !s.allowSend(addr) { return ErrTooManyRequests } return s.issueCode(ctx, accountID, addr, purposeChange, s.accountLocale(ctx, accountID)) } // ConfirmChange verifies code for (accountID, newEmail) and atomically replaces the // account's confirmed email with newEmail, freeing the old address. When newEmail is // already confirmed by another account it refuses with ErrEmailTaken (surfaced to the // user as a non-disclosing "check the address or contact support"), never merging; when // the account already owns newEmail it is an idempotent no-op. It returns the usual // confirm-code errors (ErrNoPendingCode, ErrCodeExpired, ErrTooManyAttempts, // ErrCodeMismatch) and the updated account on success. func (s *EmailService) ConfirmChange(ctx context.Context, accountID uuid.UUID, newEmail, code string) (Account, error) { addr, err := normalizeEmail(newEmail) if err != nil { return Account{}, err } conf, err := s.verifyPendingCode(ctx, accountID, addr, code) if err != nil { return Account{}, err } owner, ok, err := s.store.confirmedEmailAccount(ctx, addr) if err != nil { return Account{}, err } if ok && owner != accountID { return Account{}, ErrEmailTaken } if ok && owner == accountID { if err := s.store.consumeConfirmation(ctx, conf.id, s.now()); err != nil { return Account{}, err } return s.store.GetByID(ctx, accountID) } if err := s.store.replaceEmailIdentity(ctx, conf.id, accountID, addr, s.now()); err != nil { return Account{}, err } return s.store.GetByID(ctx, accountID) } // HasEmail reports whether accountID owns a confirmed email. The account-deletion step-up // mails a confirm-code when it does, and falls back to a typed phrase otherwise. func (s *EmailService) HasEmail(ctx context.Context, accountID uuid.UUID) (bool, error) { _, ok, err := s.store.confirmedEmailOf(ctx, accountID) return ok, err } // RequestDeleteCode mails an account-deletion confirm-code to the account's own confirmed // email (no deeplink — deletion is confirmed in the app). It returns ErrNoEmail when the // account holds no email, ErrTooManyRequests when throttled. func (s *EmailService) RequestDeleteCode(ctx context.Context, accountID uuid.UUID) error { addr, ok, err := s.store.confirmedEmailOf(ctx, accountID) if err != nil { return err } if !ok { return ErrNoEmail } if !s.allowSend(addr) { return ErrTooManyRequests } return s.issueCode(ctx, accountID, addr, purposeDelete, s.accountLocale(ctx, accountID)) } // VerifyDeleteCode verifies the account-deletion code against the account's own email and // consumes it on success. It returns ErrNoEmail (no email), the usual confirm-code errors // (ErrNoPendingCode, ErrCodeExpired, ErrTooManyAttempts, ErrCodeMismatch), or nil when the // code is valid — the caller then performs the deletion. func (s *EmailService) VerifyDeleteCode(ctx context.Context, accountID uuid.UUID, code string) error { addr, ok, err := s.store.confirmedEmailOf(ctx, accountID) if err != nil { return err } if !ok { return ErrNoEmail } conf, err := s.verifyPendingCode(ctx, accountID, addr, code) if err != nil { return err } return s.store.consumeConfirmation(ctx, conf.id, s.now()) } // 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. 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. 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. 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 }