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
+43
View File
@@ -178,6 +178,49 @@ func (s *EmailService) ConfirmChange(ctx context.Context, accountID uuid.UUID, n
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) {