feat(account): deletion orchestration + step-up + gateway edge

Step-up: email accounts confirm with a mailed code (purpose=delete, no deeplink —
ConfirmByToken refuses a delete token so a stray click can't delete); platform-only
accounts type a fixed phrase (anti-impulse). Endpoints /user/delete/{request,confirm};
the confirm orchestration resigns active games, drops all-robot games, tombstones +
anonymizes the account (freeing its creds), and revokes its sessions — the tombstone is
the point of no return, the rest best-effort. Gateway account.delete.{request,confirm}
ops + fbs AccountDeleteConfirm/AccountDeleteRequestResult + branded ru/en delete email.
Integration tests cover the step-up (code + no-email) and the orchestration pieces.
This commit is contained in:
Ilia Denisov
2026-07-03 13:10:34 +02:00
parent fcde7d3db6
commit aa2290b7b4
15 changed files with 589 additions and 1 deletions
+36 -1
View File
@@ -42,6 +42,7 @@ const (
purposeLogin = "login"
purposeLink = "link"
purposeChange = "change"
purposeDelete = "delete"
)
// Errors returned by the email confirm-code flow.
@@ -65,6 +66,9 @@ var (
// ErrTooManyRequests is returned when confirm-code sends to an address are being
// requested too frequently (the resend cooldown or the rolling-hour cap).
ErrTooManyRequests = errors.New("account: too many code requests")
// ErrNoEmail is returned when an email-code step-up is requested for an account that
// holds no confirmed email (the caller must use the typed-phrase path instead).
ErrNoEmail = errors.New("account: no confirmed email")
)
// EmailService runs the email confirm-code flow: it issues a 6-digit code over a
@@ -115,7 +119,14 @@ func (s *EmailService) issueCode(ctx context.Context, accountID uuid.UUID, email
if err := s.store.replacePendingConfirmation(ctx, accountID, email, codeHash, tokenHash, purpose, s.now().Add(emailCodeTTL)); err != nil {
return err
}
msg, err := renderConfirmationEmail(purpose, code, s.confirmURL(token, locale), s.baseURL, locale)
// Account deletion is never a one-tap link (a prefetch or a stray click must not delete
// an account): the delete code is entered in the app only, so the email omits the
// deeplink and ConfirmByToken refuses a delete token.
deeplink := s.confirmURL(token, locale)
if purpose == purposeDelete {
deeplink = ""
}
msg, err := renderConfirmationEmail(purpose, code, deeplink, s.baseURL, locale)
if err != nil {
return err
}
@@ -347,6 +358,9 @@ func (s *EmailService) ConfirmByToken(ctx context.Context, token string) (LinkCo
return LinkConfirmation{}, err
}
return LinkConfirmation{Purpose: purposeChange, Account: pend.accountID}, nil
case purposeDelete:
// Deletion is confirmed in the app with the code, never via a one-tap link.
return LinkConfirmation{}, fmt.Errorf("account: deletion cannot be confirmed by link")
default:
return LinkConfirmation{}, fmt.Errorf("account: unsupported confirmation purpose %q", pend.purpose)
}
@@ -380,6 +394,27 @@ func (s *Store) confirmedEmailAccount(ctx context.Context, email string) (uuid.U
return row.AccountID, true, nil
}
// confirmedEmailOf returns the account's confirmed email address and true, or ("", false)
// when it holds none. It backs the deletion step-up, which mails a code to the account's
// own address.
func (s *Store) confirmedEmailOf(ctx context.Context, accountID uuid.UUID) (string, bool, error) {
stmt := postgres.SELECT(table.Identities.ExternalID).
FROM(table.Identities).
WHERE(
table.Identities.AccountID.EQ(postgres.UUID(accountID)).
AND(table.Identities.Kind.EQ(postgres.String(KindEmail))).
AND(table.Identities.Confirmed.EQ(postgres.Bool(true))),
).LIMIT(1)
var row model.Identities
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return "", false, nil
}
return "", false, fmt.Errorf("account: confirmed email of %s: %w", accountID, err)
}
return row.ExternalID, true, nil
}
// replacePendingConfirmation clears any pending code for (accountID, email) and
// inserts a fresh one, inside one transaction.
func (s *Store) replacePendingConfirmation(ctx context.Context, accountID uuid.UUID, email, codeHash, linkTokenHash, purpose string, expiresAt time.Time) error {