POST /api/investors/me/email/change-confirm — Подтверждение смены email (confirmEmailChange)
Назначение
Второй шаг смены email: проверить код из письма (request-email-change.md) и
применить новый адрес (Email = NewEmail, EmailConfirmedAt = now). Вызывается аутентифицированным
пользователем. Новый email становится виден при следующем чтении профиля
(get-current-investor.md).
Metadata
| Поле | Значение |
|---|---|
| Сервис/контекст | identity |
| API area | public |
| Feature group | contacts |
| Статус | approved |
| Документ | docs/06-services/identity/api/investor/confirm-email-change.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | User (Bearer JWT) |
| Auth policy / Permissions | authenticated; permission identity.security.manage-self |
| Scope (RBAC) | self |
| Record-level | Pending-запрос ищется строго по UserId актора |
| Tenant isolation | не применяется (ADR-0052) |
| Audit | update контакта: событие UserContactChangedEvent (старое/новое значения маскируются в логах) |
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | POST |
| Route | /api/investors/me/email/change-confirm |
| Success status | 200 |
| Idempotency header | не применяется; одноразовость — статус запроса Confirmed + оптимистическая блокировка |
| Correlation | сквозной trace через traceparent |
Входные данные / параметры запроса (body ConfirmEmailChangeForm)
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
UserId | identity claims (sub) | Guid | Да | валидный JWT | 401 без токена |
code | body | string | Да | min 1, max 16 | никогда не логируется |
Модель ответа ConfirmEmailChangeCommandResult
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
investorId | string (Guid) | Да | UserEntity.Id | — | Идентификатор пользователя. |
Алгоритм
- Контекст и доступ. Операцию выполняет аутентифицированный пользователь над собственной записью
(
UserIdиз токена). - Проверка входа.
codeнепустой, ≤ 16 символов — иначеValidationError. - Проверки состояния. Получить пользователя; он
Active— иначеIDENTITY_USER_BLOCKED; контакты управляются самим пользователем (ContactsManagedBy == SelfService, INV-4) — иначеIDENTITY_CONTACTS_MANAGED_EXTERNALLY. Найти его активный (Pending) запрос с цельюEmailChange— нет запроса ⇒IDENTITY_CONFIRMATION_INVALID; запрос не истёк (INV-S3); в запросе заданNewEmail(INV-S8). - Правила и переходы. Подтверждение кода: счётчик попыток увеличивается до сравнения (INV-S2, 5-я
неверная ⇒
Exhausted); сравнение с хэшем в постоянном времени (INV-S5); при успехе запросConfirmed,ConfirmedAt = now. Повторная проверка уникальности email (INV-3, гонка между request и confirm): адрес занят другим пользователем ⇒IDENTITY_USER_EMAIL_TAKEN. Применение:Email = NewEmail,EmailConfirmedAt = now; фиксируется доменное событиеUserContactChangedEvent(видEmail, старое/новое — маскированные). - Что меняется. В одном согласованном изменении обновляются запрос и пользователь; уникальность email
окончательно гарантируется ограничением уникальности в хранилище (нарушение →
IDENTITY_USER_EMAIL_TAKEN). - События. Интеграционных нет (email не входит в снапшот
pin.identity.users.v1); доменноеUserContactChangedEvent— аудит. - Результат.
investorId; HTTP 200.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|---|
| Интеграционные события | none |
| Внешние вызовы | none |
| Проекции / поиск | none |
| Уведомления | уведомление на старый email «адрес изменён» через сервис notifications |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|---|---|---|---|
| — (auth) | 401 | Нет/просрочен JWT | клиент выполняет re-login | re-login |
ValidationError | 400 | Пустой/длинный code | Список ошибок валидации | no retry |
IDENTITY_CONFIRMATION_INVALID | 400 | Нет Pending-запроса / неверный код | «Неверный код подтверждения» | ввести заново |
IDENTITY_CONFIRMATION_EXPIRED | 400 | INV-S3 | «Код истёк» | новый request-шаг |
IDENTITY_CONFIRMATION_EXHAUSTED | 400 | INV-S2 | «Превышено число попыток» | новый request-шаг |
IDENTITY_USER_EMAIL_TAKEN | 400 | Email занят (гонка, INV-3) | «Email уже используется» | новый request с другим email |
IDENTITY_CONTACTS_MANAGED_EXTERNALLY | 400 | INV-4 | «Контакты управляются внешней системой» | no retry |
IDENTITY_USER_BLOCKED | 400 | Status != Active | «Учётная запись заблокирована» | no retry |
Acceptance Criteria
- Given активный self-service пользователь и непросроченный
Pending-запросEmailChangeс заданнымNewEmail, When confirm с вернымcodeи свободнымNewEmail, Then запрос →Confirmed,Email = NewEmail,EmailConfirmedAt = now, поднимаетсяUserContactChangedEvent(Email, маскированные значения), ответ200; новый email виден при следующем чтении профиля. - Given между request и confirm тот же адрес занял другой пользователь (INV-3), When confirm с
верным
code, ThenIDENTITY_USER_EMAIL_TAKEN(400) — требуется новый request с другим адресом. - Given
codeневерный, When confirm, Then счётчик попыток растёт; на 5-й неверной — запрос →ExhaustedиIDENTITY_CONFIRMATION_EXHAUSTED; до этого —IDENTITY_CONFIRMATION_INVALID(400). - Given запрос истёк (INV-S3) / нет
Pending-запросаEmailChange, When confirm, ThenIDENTITY_CONFIRMATION_EXPIRED/IDENTITY_CONFIRMATION_INVALID(400). - Given контакты управляются извне (
ContactsManagedBy != SelfService, INV-4), When confirm, ThenIDENTITY_CONTACTS_MANAGED_EXTERNALLY(400); GivenStatus != Active, ThenIDENTITY_USER_BLOCKED(400).
Совместимость и наблюдаемость
- Logs:
UserId, результат, маскированные старый/новый email; metrics:identity_email_change_confirms_total{result}; traces — OTel.
Обратные ссылки
Автоссылки: где используется этот документ.
- API (2): Identity API — карта областей и реестр investor-области, POST /api/investors/me/email/change-request — Запрос смены email (requestEmailChange)
- registries (1): Реестр API-методов PIN
Связанные документы
- Сущности: user.md (INV-3/4,
Email/EmailConfirmedAt), user-session.md (NewEmail, INV-S2/S3/S5/S8). - Пред-шаг: request-email-change.md.