# G1 / SP1 — Самозапись клиента с подтверждением почты (backend)

**Дата:** 18.06.2026. Часть находки **G1** (самозапись клиента), под-проект **SP1** (backend, проверяемо Pest).
**Связанные:** SP2 (реквизиты + гейт первого проекта), SP3 (фронт). Здесь — только SP1.

## Цель

Превратить регистрацию из «прицепить нового пользователя к первому существующему тенанту» в настоящую
самозапись: посетитель вводит почту, пароль и проходит капчу → создаётся **новый** тенант в состоянии
ожидания подтверждения почты и его владелец-пользователь → на почту уходит 6-значный код → ввод верного
кода активирует тенанта и пользователя, начисляет стартовый баланс 300 ₽ и выполняет вход.

Реквизиты юр.лица/ИП/физлица, тяга по ИНН и блокировка создания первого проекта до заполнения реквизитов —
**вне** SP1 (это SP2). Фронтенд — вне SP1 (SP3). Реальный провайдер капчи — вне SP1 (подключается позже).

## Контракт register {#D1}

`POST /api/auth/register` (публичный, throttled). Тело: `email`, `password`, `accept_offer`, `accept_pdn`,
`captcha_token`.

- Валидация: `email` — корректный, ≤255; `password` — текущие правила проекта (`HasPasswordRules`);
  `accept_offer`/`accept_pdn` — `accepted`; `captcha_token` — обязателен, строка.
- Капча проверяется через шов `CaptchaVerifier` (см. {#D5}); провал → `422` с ошибкой по полю `captcha_token`.
- Уникальность email: если активный пользователь с таким email уже существует → `422` «Аккаунт с таким
  email уже существует». Если существует **неподтверждённый** (тенант в состоянии ожидания подтверждения) →
  это путь повторной отправки кода (resend), а не создание дубля: код перегенерируется, тенант/пользователь
  не дублируются.
- Успех (новый email): создаётся тенант в состоянии ожидания подтверждения почты с уникальным `subdomain`
  (см. {#D6}), `organization_name` = временный плейсхолдер на основе email (уточняется в SP2), `balance_rub`
  = 0, пробный режим; создаётся владелец-пользователь, неактивный (`is_active=false`); генерируется код и
  пишется строка подтверждения почты (см. {#D4}); код отправляется письмом.
- Вход на этом шаге **не выполняется**. Ответ `201`: состояние «ожидает подтверждения», email, срок
  годности кода. На окружениях `local`/`testing` ответ дополнительно содержит plain-код для тестов (как это
  уже делает init impersonation-флоу), на prod — нет.

## Контракт confirm-email {#D2}

`POST /api/auth/confirm-email` (публичный, throttled). Тело: `email`, `code`.

- Поиск последней непогашенной строки подтверждения по email через BYPASSRLS-подключение (на публичном
  роуте нет tenant-контекста — см. {#D7}).
- Если строки нет / срок истёк / превышены попытки / код не совпал → `422`; при несовпадении —
  `failed_attempts++`, на 5-й неудаче строка инвалидируется (требуется resend).
- Успех: помечается `verified_at`; владелец-пользователь становится активным; тенант переходит в активное
  состояние; начисляется `balance_rub = 300.00`; выполняется `Auth::login` + регенерация сессии; пишется
  лог успешной регистрации. Ответ `200`: пользователь + `requires_2fa: false`.

## Контракт resend-code {#D3}

`POST /api/auth/resend-code` (публичный, throttled + cooldown). Тело: `email`.

- Anti-enumeration: ответ унифицирован независимо от существования аккаунта (как forgot-password флоу).
- Для существующего неподтверждённого аккаунта — старый код инвалидируется, генерируется новый, уходит
  письмом. Для активного/несуществующего — ничего не делается, ответ тот же.
- Cooldown между отправками защищает от спама писем.

## Изменения схемы email_verifications {#D4}

Таблица `email_verifications` уже существует (id, user_id, email, token UNIQUE, expires_at, verified_at,
created_at). Расширяется под код-флоу:

- `+ code_hash VARCHAR(255)` — bcrypt 6-значного кода (plain-код в БД не хранится).
- `+ failed_attempts SMALLINT NOT NULL DEFAULT 0` — счётчик неверных вводов; лимит 5 (как у
  impersonation-токена).
- `token` сохраняется как внутренний уникальный идентификатор строки (UUID), пользовательский секрет — это
  код в `code_hash`. TTL строки — 15 минут (`expires_at`).
- Состояние тенанта при ожидании подтверждения — уже предусмотренное схемой значение статуса
  `pending_email_confirm`; новых колонок в `tenants` не требуется.

Правка `db/schema.sql` сопровождается записью в `db/CHANGELOG_schema.md` и Laravel-миграцией.

## Капча: шов CaptchaVerifier {#D5}

Капча в SP1 — это серверный шов, чтобы поток оставался проверяемым Pest без внешних ключей.

- Интерфейс `CaptchaVerifier` с методом проверки токена (возвращает bool/исход).
- Драйвер по умолчанию для dev/testing — нулевой (`NullCaptchaVerifier`), конфигурируемый на пропуск/провал
  через конфиг, чтобы тест мог проверить обе ветки.
- Конфиг `services.captcha` (driver + место под ключи). Реальный драйвер Yandex SmartCaptcha — задокументи-
  рованный TODO, подключается позже (SP3/ops), интерфейс под него спроектирован сразу.
- Контроллер/сервис регистрации зовёт только интерфейс, не зная провайдера.

## Генерация subdomain {#D6}

`tenants.subdomain` — `UNIQUE NOT NULL`, ≤63. При самозаписи известен только email.

- База: локальная часть email, приведённая к `[a-z0-9]` (прочее отбрасывается/заменяется).
- Уникальность: при коллизии добавляется короткий суффикс (счётчик/случайный), пока значение свободно.
- Fallback при пустой базе — `client` + случайный суффикс. Длина усечена до 63.

## Edge-cases и безопасность {#D7}

- Публичные роуты register/confirm/resend не проходят middleware с tenant-контекстом → запись/чтение
  `email_verifications` (RLS-таблица) идёт через BYPASSRLS-подключение `pgsql_supplier`, как уже устроено в
  impersonation-контроллере.
- Код 6-значный (`100000..999999`), хранится только в виде хеша; сравнение через `Hash::check`.
- Брошенные неподтверждённые тенанты — уборка вынесена в опциональный follow-up (команда
  `auth:prune-unconfirmed`), не входит в ядро SP1.
- Стартовый баланс 300 ₽ начисляется **в момент подтверждения** (активации), не при создании заявки —
  неподтверждённый аккаунт денег не получает.
- Throttle на всех трёх роутах + cooldown на resend — против перебора кода и спама писем.

## Критерии приёмки (Pest) {#D8}

- register с новым email и валидным captcha_token → тенант в ожидании подтверждения, владелец неактивен,
  строка кода создана; на testing виден plain-код; вход не выполнен.
- register с невалидным captcha_token → `422`, тенант/пользователь не создаются.
- confirm верным кодом → тенант активен, пользователь активен, `balance_rub = 300.00`, выполнен вход.
- confirm неверным кодом → `422`, `failed_attempts` увеличился; на 5-й неудаче строка инвалидирована.
- confirm протухшим кодом → `422`; resend выдаёт новый рабочий код.
- повторный register на неподтверждённый email → resend без второго тенанта.
- register на активный email → `422`.
- resend на несуществующий/активный email → унифицированный ответ (anti-enumeration).

```verified-context-json
[
  {"id":"D2","kind":"EXTRACTED","ref":"app/app/Http/Controllers/Api/AuthController.php","anchor":"Tenant::first()"},
  {"id":"D4","kind":"EXTRACTED","ref":"db/schema.sql","anchor":"pending_email_confirm"},
  {"id":"D1","kind":"EXTRACTED","ref":"app/app/Http/Controllers/Api/ImpersonationController.php","anchor":"random_int(100_000, 999_999)"}
]
```
