# module-11. Сервис доставки SMS (i-Digital Direct) > Статус: целевая проектная спецификация post-MVP (закрывает K-TBD8 / бэклог «интеграция с SMS-провайдером»). > Реализация отсутствует. Документ задаёт обязательные контракты для разработки `sms-service` и доработки Keycloak. > Источники провайдера: [Отправка SMS](https://api.docs.direct.i-dgtl.ru/messages/sms-sending/), [Авторизация](https://api.docs.direct.i-dgtl.ru/authorization/), [Callback](https://api.docs.direct.i-dgtl.ru/messages/callback/). > Смежные: [`module-08-keycloak.md`](module-08-keycloak.md), [`arch-01`](../../architectory/arch-01-system-architecture.md), [`arch-02`](../../architectory/arch-02-api-contracts.md), [`arch-04`](../../architectory/arch-04-settings-and-content.md), [`arch-06`](../../architectory/arch-06-service-hosting-security.md). **Критерий применимости:** документ описывает target real-SMS rollout, который остаётся post-MVP backlog до реализации и закрытия gates. Текущее as-is состояние до cutover — `KEYCLOAK_OTP_MOCK_ENABLED=true`. При конфликте приоритет всегда у architectory/README; настоящий модуль не переопределяет действующий mock-only runtime сам по себе. ## 1. Разделение ответственности | Зона | Модуль | Что хранит / делает | |---|---|---| | Доставка сообщений | **module-11 (sms-service)** | Шаблоны, журнал отправок (кому/что/когда/статусы), вызов провайдера, callback доставки | | Auth OTP | **module-08 (Keycloak)** | Генерация и локальная проверка кода, challenge, лимиты, **результат verify**, **контекст устройства**, ссылка на `sms_message_id` | **Жёсткие правила:** 1. Keycloak **не** вызывает i-Digital напрямую и **не** хранит полный журнал SMS (текст, delivery status провайдера, шаблоны). 2. sms-service **не** генерирует OTP, **не** проверяет код и **не** знает, верно ли пользователь ввёл код. 3. Связка: Keycloak получает от sms-service `sms_message_id` и сохраняет его в своём challenge/событиях. 4. [API верификации телефона](https://api.docs.direct.i-dgtl.ru/verifier/api/) (`/verifier/send`, `/verifier/check`) **не используется**. ```text User → nginx → Keycloak │ 1. generate OTP, create challenge (+ device context) │ 2. POST /internal/sms/v1/send → sms-service │ ├─ render template │ ├─ INSERT sms_outbound_message │ └─ return sms_message_id │ 3. сохранить sms_message_id в challenge │ 4. user enters code → local verify │ 5. записать verify outcome (+ device) в Keycloak DB └─ OIDC code sms-service worker → POST Direct /api/v1/message → update send_status Direct callback → sms-service only → update delivery_status ``` Текущий заказчик: `keycloak`. Процесс: `auth_otp`. Канал: `SMS`. Провайдер: `idgtl` (резервный канал — будущее расширение той же модели). --- ## 2. Границы module-11 ### В scope - отдельный сервис `sms-service` (Compose-модуль); - схема БД: шаблоны + журнал исходящих сообщений; - internal API для заказчиков (сейчас Keycloak); - адаптер провайдера `idgtl` (`POST /api/v1/message`, `TOKEN_1`); - асинхронная отправка worker-ом и обновление статусов отправки/доставки; - секреты провайдера, health/metrics. - OpenAPI 3.1 для internal send/read API и JSON Schema callback; - бессрочный журнал отправок и reconciliation зависших `pending`/`uncertain`. ### Вне scope - генерация/проверка OTP; - product limits `otp.phone.*` (остаются в Keycloak); - каскады VK/WhatsApp, FLASHCALL, рассылки; - публичный API для frontend; - решение «пользователь авторизован» / выдача токенов. --- ## 3. Модель данных module-11 Схема: отдельная managed PostgreSQL schema, например `sms` (роль `sms_user`). App DB `han_app` и schema `keycloak` **не** используются для журнала SMS. ### 3.1. `sms_template` — шаблоны Шаблон **не** хранится в env. Env только credentials/timeouts провайдера. | Поле | Тип | Описание | |---|---|---| | `id` | UUID PK | Идентификатор версии шаблона | | `code` | varchar | Стабильный код, напр. `auth_otp` | | `channel` | enum | `SMS` (расширяемо) | | `locale` | varchar | напр. `ru` | | `version` | int | Монотонная версия внутри `code`+`channel`+`locale` | | `body_template` | text | Текст с плейсхолдерами, напр. `Код входа в HAN Chat: {code}. Действителен {ttl_min} мин.` | | `placeholders` | jsonb | Описание обязательных ключей: `["code","ttl_min"]` | | `sender_name` | varchar | Имя отправителя для этого шаблона (или null → default провайдера) | | `max_parts` | int | Максимально допустимое число SMS-частей; для `auth_otp` — `1` | | `is_active` | bool | Активная версия для `code` (ровно одна active на code+channel+locale) | | `approved_at` | timestamptz | Согласование с оператором/провайдером | | `created_at` / `updated_at` | timestamptz | Аудит | | `created_by` | varchar | ops/system | Seed первой версии: `code=auth_otp`, `channel=SMS`, `locale=ru`. ### 3.2. Настройки SMS и OTP Параметры, изменение которых не требует изменения Compose, секретов или сетевой топологии, в `.env` не хранятся. **OTP-настройки в `han_app.app_settings`** (владелец продукта, потребитель — Keycloak через settings bridge): | Ключ | Тип | Seed | Назначение | |---|---|---:|---| | `otp.phone.code_length` | integer | `6` | Длина numeric OTP | | `otp.phone.ttl_seconds` | integer | `60` | Срок жизни OTP от `ordered_at`; диапазон `60..900`, значение кратно 60 | | `otp.phone.sms_order_timeout_ms` | integer | `3000` | Timeout Keycloak → sms-service только на durable order | Эти ключи возвращаются существующим `GET /internal/settings/v1/otp` вместе с лимитами и `version`. Keycloak сохраняет snapshot `otp_ttl_sec`, `otp_code_length` и `settings_version` в challenge. Изменение settings действует только на новые challenges. **Технические настройки в `sms.sms_setting`** (владелец — `sms-service`): | Ключ | Тип | Seed | Назначение | |---|---|---:|---| | `provider.idgtl.default_sender_name` | string | согласованное имя | Default, если sender отсутствует в шаблоне | | `provider.idgtl.connect_timeout_ms` | integer | `3000` | Connect timeout worker → Direct | | `provider.idgtl.request_timeout_ms` | integer | `70000` | Total/read timeout worker → Direct | | `provider.idgtl.callback_enabled` | boolean | `true` | Включение callback в production | | `worker.poll_interval_ms` | integer | `500` | Интервал поиска pending-заказов | | `worker.lease_seconds` | integer | `90` | Lease записи на время внешнего вызова | Минимальные поля `sms_setting`: `setting_key` PK, `setting_value`, `value_type`, `description`, `updated_at`. Seed выполняется versioned migration. `sms-service` валидирует обязательные ключи при startup, кэширует их и периодически перечитывает по `updated_at`; некорректное значение не применяется и вызывает alert. ### 3.3. `sms_outbound_message` — журнал отправок Каждый заказ Keycloak на новую SMS — одна строка. Повторные HTTP-попытки worker по тому же заказу увеличивают `attempt_count`, но не создают новую строку. Resend создаёт новый challenge и новую строку. Это **источник истины** «когда, кому и какой текст заказали, что произошло при отправке и доставке». | Поле | Тип | Обязательность | Описание | |---|---|---|---| | `id` | UUID PK | да | **`sms_message_id`** — то, на что ссылается Keycloak | | `created_at` | timestamptz | да | Создание записи (до/в момент вызова провайдера) | | `requested_at` | timestamptz | да | Время запроса от заказчика | | `accepted_at` | timestamptz | нет | Провайдер принял сообщение | | `sent_at` | timestamptz | нет | Статус sent от провайдера/callback | | `delivered_at` | timestamptz | нет | delivered | | `updated_at` | timestamptz | да | Последнее изменение статусов | | `requester_service` | varchar | да | Заказчик: сейчас `keycloak`; позже др. сервисы | | `process` | varchar | да | Бизнес-процесс: сейчас `auth_otp` | | `channel` | varchar | да | `SMS` | | `provider` | varchar | да | Сервис доставки: сейчас `idgtl`; резерв — новый код | | `phone_e164` | varchar | да | Кому: E.164 (`+79001234567`) | | `phone_digits` | varchar | да | Как у провайдера: `79001234567` | | `phone_masked` | varchar | да | Для UI/ops без полного номера | | `template_id` | UUID FK | да | Ссылка на `sms_template.id` | | `template_code` | varchar | да | Денормализация `auth_otp` | | `body_rendered` | text | да | Итоговый текст, ушедший провайдеру | | `substitutions` | jsonb | да | Подстановки (`code`, `ttl_min`, …) | | `send_status` | enum | да | Статус **отправки** (наш/accept) | | `delivery_status` | enum | да | Статус **доставки** (провайдер) | | `provider_message_id` | varchar | нет | `messageUuid` Direct | | `provider_external_id` | varchar | нет | `externalMessageId`, отправленный в Direct | | `customer_ref` | varchar | нет | Корреляция заказчика (напр. Keycloak `challenge_id`) | | `idempotency_key` | varchar | да | Уникальный ключ от заказчика; защита от дублей | | `request_fingerprint` | varchar | да | SHA-256 канонического значимого payload для обнаружения повторного ключа с другим запросом | | `request_id` | varchar | нет | `X-Request-ID` / trace | | `provider_http_status` | int | нет | HTTP ответа Direct | | `provider_error_code` | varchar | нет | Код ошибки провайдера | | `provider_error_message` | varchar | нет | Краткий класс/текст ошибки (без секретов) | | `sender_name` | varchar | да | Фактически использованное имя | | `message_ttl_sec` | int | нет | TTL у провайдера | | `attempt_count` | int | да | Число HTTP-попыток к провайдеру | | `last_attempt_at` | timestamptz | нет | Время последней попытки worker | | `next_attempt_at` | timestamptz | нет | Когда разрешена следующая однозначно безопасная попытка | | `worker_locked_until` | timestamptz | нет | Lease фонового worker для защиты от параллельной обработки | | `parts` / `price` / `currency` | — | нет | Из price-callback, если включён | | `callback_last_at` | timestamptz | нет | Последний callback | #### Enum `send_status` (отправка) | Значение | Смысл | |---|---| | `pending` | Запись создана, вызов провайдера ещё не завершён | | `accepted` | Провайдер принял (`errors=false`, success item code) | | `rejected` | Провайдер отклонил (4xx бизнес) | | `failed` | Однозначный технический сбой до передачи запроса провайдеру | | `uncertain` | Результат внешнего вызова неизвестен: запрос мог быть принят, но подтверждение не получено | | `skipped` | Не вызывали провайдера (напр. dry-run/dev) | #### Enum `delivery_status` (доставка) | Значение | Смысл | |---|---| | `unknown` | Ещё нет данных о доставке | | `sent` | Отправлено оператору | | `delivered` | Доставлено | | `undelivered` | Не доставлено за TTL | | `unsent` | Не отправлено | `send_status` и `delivery_status` — **разные** оси и относятся только к журналу `sms-service`. Keycloak не читает их, не ждёт и не использует при проверке OTP. Безопасность обеспечивается тем, что корректный код известен только Keycloak и получателю SMS. ### 3.4. Дополнительные поля (рекомендации) Имеет смысл заложить сразу: | Поле | Зачем | |---|---| | `idempotency_key` UNIQUE | Повтор Keycloak при timeout не создаёт вторую SMS | | `customer_ref` | Связь с challenge без join через другие БД | | `phone_masked` | Ops-выборки без полного MSISDN | | `attempt_count` + timestamps | Диагностика retry | | `provider` как код | Переключение/failover без смены схемы | | `template_id` + `template_code` | Аудит «какой текст был согласован» | | `request_id` | Сквозная трассировка | | архивирование/партиционирование | Журнал хранится бессрочно; при росте объёма используются месячные partition и перенос старых partition в архивный storage без удаления данных | **Хранение журнала:** - application-level encryption текста и substitutions не применяется: после истечения OTP они не дают возможности авторизоваться, а отдельный контур ключей несоразмерно усложняет реализацию; - используется штатное encryption at rest managed PostgreSQL и backups; - OTP действует `challenge.otp_ttl_sec` от `ordered_at`; snapshot берётся из `app_settings["otp.phone.ttl_seconds"]`, после истечения код не принимается независимо от состояния SMS; - автоматическое удаление, очистка или обезличивание строк журнала запрещены; - текст, substitutions, телефон, provider IDs, статусы и timestamps сохраняются бессрочно для будущего аудита и аналитики; - при росте объёма допускаются PostgreSQL partitioning, сжатие backup и перенос старых partition в архивное хранилище при сохранении возможности восстановления/выборки; - удаление возможно только отдельной утверждённой процедурой по юридическому требованию или запросу субъекта данных, с audit события; - hash итогового текста/OTP отдельно не хранится; - полный телефон доступен только роли `sms_user`; ops/read API по умолчанию возвращает mask; - доступ к raw `body_rendered`/`substitutions` разрешён только `sms_user`; internal read API их не возвращает. В логах/метриках текст, OTP, полный телефон, callback credentials и Authorization **запрещены**. ### 3.5. Индексы - UNIQUE(`requester_service`, `idempotency_key`); - UNIQUE(`provider`, `provider_message_id`) where not null; - (`phone_e164`, `created_at DESC`); - (`requester_service`, `process`, `created_at DESC`); - (`customer_ref`); - (`send_status`, `created_at`); - (`delivery_status`, `updated_at`). - UNIQUE(`code`, `channel`, `locale`, `version`) для шаблонов; - UNIQUE partial (`code`, `channel`, `locale`) where `is_active=true`. Все enum/check constraints и индексы создаются versioned-миграциями. DDL-on-start запрещён. --- ## 4. Internal API module-11 (для заказчиков) Только закрытая Docker-сеть `backend`. Auth: `Authorization: Bearer `. - `KEYCLOAK_SMS_SERVICE_TOKEN` передаёт Keycloak; значение равно `SMS_SERVICE_TOKEN`, который проверяет `sms-service`; - токен — random secret не менее 32 bytes, constant-time compare, без вывода в логи; - в v1 разрешён только caller `keycloak` и только process/template `auth_otp`; - `requester_service`, `process`, `channel` и `provider` не считаются доверенными данными запроса: сервис сверяет их с allowlist токена либо подставляет серверные значения; - `X-Request-ID` и `traceparent` передаются сквозным образом; - rate limit по caller + destination HMAC обязателен как дополнительная защита при компрометации service token. ### 4.1. `POST /internal/sms/v1/send` Запрос: ```text { "idempotency_key": "keycloak:challenge:01JABCDEF", "template_code": "auth_otp", "locale": "ru", "phone_e164": "+79001234567", "substitutions": { "code": "482193", "ttl_min": "" }, "customer_ref": "01JABCDEF", "message_ttl_sec": } ``` `message_ttl_sec` равен snapshot `app_settings["otp.phone.ttl_seconds"]` для challenge. `ttl_min` вычисляется из того же snapshot; настройка обязана быть кратна 60. `requester_service=keycloak`, `process=auth_otp`, `channel=SMS`, `provider=idgtl` определяются сервером по service token/route. `request_id` передаётся только заголовком `X-Request-ID` и не входит в idempotency fingerprint. Поведение: 1. Проверить service token и allowlist caller/process/template/provider. 2. Нормализовать и повторно проверить E.164; `phone_digits` должен однозначно соответствовать `phone_e164`. 3. Проверить `message_ttl_sec` в диапазоне Direct `60..86400`, длины полей и строгий набор substitutions; неизвестные/пропущенные placeholder → `422`. 4. Рассчитать `request_fingerprint` по каноническому значимому payload. 5. Если `(requester_service,idempotency_key)` уже есть: - fingerprint совпадает → вернуть сохранённый результат без нового внешнего вызова; - fingerprint отличается → `409 idempotency_key_reused`. Конкурентная вставка разрешается UNIQUE constraint: проигравшая transaction перечитывает существующую запись и применяет те же правила fingerprint. 6. Найти единственный active `sms_template` по `template_code`+`channel`+`locale`; locale fallback в v1 отсутствует. 7. Срендерить `body_rendered`; проверить лимит длины, UTF-8 без BOM и ожидаемое число SMS-частей. 8. В одной DB transaction вставить `sms_outbound_message` (`send_status=pending`, `delivery_status=unknown`, `next_attempt_at=now`). 9. Commit гарантирует, что заказ на отправку сохранён. 10. Немедленно вернуть `sms_message_id`; внешний API Direct в обработчике этого запроса не вызывается. 11. Фоновый worker выбирает готовые `pending` через lease/`FOR UPDATE SKIP LOCKED`, вызывает адаптер `idgtl` и обновляет journal row. Ответ `202 Accepted` для нового заказа: ```json { "sms_message_id": "9f3c…", "ordered_at": "2026-07-22T13:00:00Z" } ``` Ошибки используют envelope из `arch-02`: `401 unauthorized`, `409 idempotency_key_reused`, `422 sms_request_invalid`, `429 rate_limit_exceeded`, `503 sms_service_unavailable`. Правило ответа: - `202` означает только «заказ надёжно записан в БД sms-service», но не подтверждает отправку или доставку; - идемпотентный повтор с тем же fingerprint возвращает `200` и тот же `sms_message_id` независимо от текущего provider status; - ошибки до commit journal row возвращаются соответствующим 4xx/5xx; - Keycloak считает задачу «заказать SMS» выполненной при `200`/`202` и наличии `sms_message_id`; - Keycloak не анализирует и не запрашивает `send_status`, `delivery_status` или `provider_message_id`. ### 4.2. `GET /internal/sms/v1/messages/{sms_message_id}` Для диагностики заказчика. Доступ Keycloak разрешён только к сообщениям `requester_service=keycloak`. Endpoint никогда не отдаёт OTP, substitutions или полный итоговый текст, в том числе через privileged flag. Телефон всегда masked. ### 4.3. Callback от Direct Публичный endpoint: `POST /callbacks/idgtl/sms` через root nginx. Префикс `/internal/*` для callback запрещён. Защита: - только HTTPS; - nginx allowlist source IP `185.203.96.7`; изменение IP требует сверки с актуальной документацией Direct; - Basic auth callback (`IDGTL_SMS_CALLBACK_USERNAME` / `IDGTL_SMS_CALLBACK_PASSWORD`), который Direct поддерживает через credentials в `callbackUrl`; - URL с credentials и Authorization редактируются во всех логах/traces; - service дополнительно проверяет `channel_type=SMS`, известный `message_uuid` и соответствие `external_message_id`. Обработка: - callback body — массив; каждый item валидируется и обрабатывается независимо; - дедупликация по `(message_uuid, callback_event, status, status_time)`; - повторы ожидаемы: при отсутствии 2xx Direct повторяет callback каждые 5 минут в течение суток; - `status_time` провайдера сохраняется как время статуса; `callback_last_at` — время получения; - переходы монотонны: поздний `sent` не понижает `delivered`/`undelivered`/`unsent`; - неизвестный/противоречивый item пишется в security log без PII и не изменяет запись; - 2xx возвращается только после успешной фиксации всех валидных items; transient DB failure → 5xx для повтора. Callback обновляет только `delivery_status`, timestamps, error code и price. **Не** уведомляет Keycloak и **не** влияет на verify. --- ## 5. Адаптер провайдера `idgtl` ### 5.1. Вызов ```http POST https://direct.i-dgtl.ru/api/v1/message Authorization: Basic {TOKEN_1} Content-Type: application/json ``` ```text [ { "channelType": "SMS", "senderName": "", "destination": "79001234567", "content": "", "externalMessageId": "", "ttl": , "callbackUrl": "https://@tohin.ru/callbacks/idgtl/sms", "callbackEvents": ["delivered", "sent"] } ] ``` Успех: только HTTP 200, `errors=false`, ровно один response item, `item.code=201`, валидный `messageUuid` и совпадающий `externalMessageId` → `send_status=accepted`. Маппинг остальных результатов: - HTTP `401`/`402`/`403`/`422` → `rejected`, без retry; сохранить provider error code и безопасный класс ошибки; - HTTP 200 с `errors=true`, отсутствующим item, `item.code!=201`, неверным `externalMessageId` или невалидным `messageUuid` → `rejected` и alert о нарушении provider contract; - connect failure до установления соединения → `failed`; допускается ограниченный retry с jitter; - полученный явный `503` до такого подтверждения → `uncertain`; retry разрешается только после письменного подтверждения Direct, что сообщение не создано; - read timeout, connection reset после отправки body, `502`/`504` и любой ответ, при котором неизвестно, создал ли Direct сообщение, → `uncertain`, **без автоматического retry**. `externalMessageId` всегда равен `sms_message_id` и не использует `customer_ref`. ### 5.2. Таймауты и защита от дублей Direct рекомендует ожидание ответа до 70 секунд. Фактические значения берутся из settings: - connect timeout worker → Direct — `sms_setting["provider.idgtl.connect_timeout_ms"]`; - total/read timeout worker → Direct — `sms_setting["provider.idgtl.request_timeout_ms"]`; - timeout Keycloak → sms-service для записи заказа — snapshot `app_settings["otp.phone.sms_order_timeout_ms"]`; - ожидание Direct происходит только в background worker и не удерживает Keycloak auth request; - при превышении provider request timeout результат считается `uncertain`; новый вызов Direct с тем же или другим `externalMessageId` автоматически не выполняется. Local idempotency защищает только от повторного запроса Keycloak к `sms-service`. Она **не доказывает** идемпотентность Direct. До письменного подтверждения провайдера `externalMessageId` считается корреляцией, а не idempotency key. ### 5.3. Env (только infra, не шаблоны) ```text KEYCLOAK_SMS_SERVICE_URL=http://sms-service:8080 SMS_SERVICE_TOKEN= KEYCLOAK_SMS_SERVICE_TOKEN= SMS_DATABASE_URL=postgresql://sms_user:...@/?... IDGTL_SMS_BASE_URL=https://direct.i-dgtl.ru IDGTL_SMS_API_KEY= IDGTL_SMS_CALLBACK_PUBLIC_URL=https://tohin.ru/callbacks/idgtl/sms IDGTL_SMS_CALLBACK_USERNAME= IDGTL_SMS_CALLBACK_PASSWORD= ``` Здесь намеренно отсутствуют OTP TTL/length/order timeout, sender default, provider timeouts, callback flag и worker intervals: они хранятся в `app_settings` или `sms.sms_setting` согласно §3.2. `KEYCLOAK_OTP_MOCK_ENABLED=true` — Keycloak **не** вызывает sms-service (текущий MVP). `false` + sms-service down/unconfigured — новый заказ SMS завершается generic unavailable; уже созданные active challenges продолжают локальную проверку до TTL. `IDGTL_SMS_API_KEY` содержит выданный Direct готовый API key для Basic (`TOKEN_1`); повторно Base64-кодировать его запрещено. При возможности у Direct включается outbound IP allowlist на egress IP VM. `senderName` обязателен у Direct. Если он отсутствует и в active template, и в `sms_setting["provider.idgtl.default_sender_name"]`, readiness=false и отправка запрещена. ### 5.4. Запрещено | Метод | Почему | |---|---| | `/api/v1/verifier/send` | код генерирует провайдер | | `/api/v1/verifier/check` | проверка у провайдера | | вызов Direct из Keycloak | нарушает границу module-11 | --- ## 6. Что хранит Keycloak (module-08) — отдельно Keycloak остаётся владельцем auth-факта. Расширить provider-owned таблицы в schema `keycloak` (не копировать журнал SMS). Текущая реализация mock-only должна быть изменена: `Config` больше не запрещает startup при `KEYCLOAK_OTP_MOCK_ENABLED=false`, а `OtpStore.reserve()` не должен хешировать постоянный `KEYCLOAK_OTP_MOCK_CODE` в real mode. ### 6.1. Challenge + ссылка на SMS `han_otp_challenge` (расширение): | Поле | Описание | |---|---| | существующие | `id`, `phone_hmac`, `destination_masked`, `otp_hash`, TTL, `verify_attempts`, `consumed_at`, … | | `sms_message_id` | UUID из module-11; **логическая** ссылка (FK между БД нет) | | `delivery_mode` | `mock` / `sms` — snapshot режима challenge | | `challenge_status` | `ordering` / `active` / `consumed` / `superseded` / `expired` / `limited` / `order_failed` | | `ordered_at` | Когда sms-service надёжно принял заказ; с этого момента challenge `active` | | `otp_ttl_sec` | Snapshot `app_settings["otp.phone.ttl_seconds"]` | | `otp_code_length` | Snapshot `app_settings["otp.phone.code_length"]` | | `settings_version` | Версия набора OTP settings из bridge | Raw OTP и полный текст SMS в Keycloak **не** хранятся (только `otp_hash`). Keycloak не хранит provider send/delivery status. В real mode `expires_at = ordered_at + otp_ttl_sec`. `sms_message_id` обязателен для `active` real-mode challenge и nullable для mock/`order_failed`. Переходы: - `ordering → active` после HTTP `200`/`202` от sms-service; - `ordering → order_failed` при невозможности надёжно записать заказ; - `active → consumed` после верного кода; - `active → superseded` при запросе новой SMS; - `active → expired` после `expires_at`; - `active → limited` после исчерпания verify attempts. Никакой переход не зависит от `send_status` или `delivery_status` в sms-service. ### 6.2. Результат ввода кода пользователем Источник истины verify — Keycloak. **A. Агрегат на challenge** (текущее + уточнение): - `challenge_status`, `verify_attempts`, `consumed_at`, `expires_at`; - итоговый outcome определяется только состоянием challenge и результатом локального сравнения OTP. **B. Append-only события** `han_otp_security_event` (обязательно на **каждую** попытку ввода): | Поле | Описание | |---|---| | `id` | UUID события | | `occurred_at` | Когда пользователь отправил код | | `event_type` | `otp_verify` | | `challenge_id` | Ссылка на challenge | | `sms_message_id` | Копия ссылки на отправленное SMS (денормализация для выборок) | | `phone_hmac` | Без raw phone | | `outcome` | `success` / `failure` / `limited` / `expired` / `already_used` | | `details` | `invalid` / `attempt_limit` / … | | device-поля | см. §6.3 | Так отвечаем на вопрос «верно/неверно ввёл»: **только** в Keycloak (`han_otp_security_event` + состояние challenge), со ссылкой на `sms_message_id`. Событие `otp_send` при успехе заказа SMS тоже пишет `sms_message_id`. ### 6.3. Контекст устройства (на send и на каждую verify-попытку) Фиксировать в событии (и/или snapshot на challenge при send): | Поле | Источник | Описание | |---|---|---| | `client_ip` | trusted proxy (`X-Forwarded-For` от nginx) | IP | | `user_agent` | заголовок | UA строка | | `device_id` | клиент (theme/form/auth note) | Стабильный id устройства приложения | | `fingerprint` | клиент | Browser/device fingerprint (не секрет auth) | | `os_name` / `os_version` | клиент | ОС | | `platform` | клиент | `web` / `ios` / `android` | | `app_version` | клиент | Версия приложения (если есть) | Правила: - device metadata **не** заменяет phone OTP; - IP только из trusted hop nginx; - в логах fingerprint/device_id допустимы; не логировать OTP. Механизм передачи зафиксирован: 1. Frontend добавляет в OIDC authorization request необязательные параметры `han_device_id`, `han_fingerprint`, `han_platform`, `han_os_name`, `han_os_version`, `han_app_version`. 2. `PhoneIdentityAuthenticator.authenticate()` читает их только на первом шаге, валидирует и сохраняет в auth session notes. Это недоверенные audit metadata, а не auth-фактор. 3. Ограничения: `device_id`/`fingerprint` ≤ 256 символов; OS/app version ≤ 64; `platform` только `web`/`ios`/`android`; control characters запрещены. 4. Для web при отсутствии `han_device_id` theme создаёт random UUID, хранит его в `localStorage` и отправляет hidden field формы телефона; native-клиент передаёт свой stable installation id. 5. `client_ip` берётся сервером из trusted proxy chain, `user_agent` — из текущего HTTP-запроса на каждой send/verify попытке; клиент их не задаёт. 6. Snapshot device fields копируется в `otp_send` и каждое `otp_verify` event. Новые значения hidden fields могут обновить snapshot перед verify. 7. Nginx/Keycloak access logs для `/auth` используют path без query string либо редактируют `han_*`, чтобы device identifiers не размножались в технических логах. 8. `phone.ftl` и `otp.ftl` получают hidden fields/атрибуты через SPI; `otp.ftl` строит число digit inputs из `challenge.otp_code_length`, countdown — из `expires_at`, без hardcoded `6`/`0:59`. После успешного OTP те же device metadata по-прежнему уходят в `POST /auth/bootstrap` (arch-02) для App DB — это **другой** контур (продуктовая сессия), не замена Keycloak OTP audit. ### 6.4. Чего Keycloak не делает - не пишет `body_rendered` / delivery callback; - не держит шаблоны; - не вызывает Direct. --- ## 7. Поток end-to-end 1. Пользователь вводит телефон (+ device context попадает в Keycloak session). 2. Keycloak применяет уже реализованные send limits/cooldown/counters. 3. В короткой transaction Keycloak: - помечает прежний `active`/`ordering` challenge этого телефона как `superseded`; - генерирует новый криптографически случайный numeric OTP длиной `settings_snapshot.otp_code_length`; - сохраняет только HMAC; - создаёт новый challenge со статусом `ordering`; - резервирует одну send attempt по действующим правилам counters. 4. Keycloak формирует `idempotency_key=keycloak:challenge:{challenge_id}` и вызывает `POST /internal/sms/v1/send` вне DB transaction. 5. sms-service валидирует запрос, сохраняет journal row и сразу возвращает `sms_message_id` (`202`; при идемпотентном повторе — `200`). Direct ещё может не быть вызван. 6. Keycloak сохраняет `sms_message_id`, `ordered_at=now`, `expires_at=ordered_at+challenge.otp_ttl_sec`, переводит challenge в `active`, пишет событие `otp_send/ordered` и показывает форму кода. 7. Background worker sms-service отправляет SMS в Direct и обновляет журнал. Результаты отправки/доставки не передаются в Keycloak и не меняют challenge. 8. Пользователь вводит код (+ тот же/обновлённый device context). 9. Keycloak проверяет только `challenge_status=active`, TTL, verify limits и локальный HMAC: - верный код → `consumed`, событие success, завершение OIDC flow; - неверный → increment verify attempts и failure event; - attempts exhausted → `limited`; - `now >= expires_at` → `expired`. 10. Если пользователь запрашивает новую SMS, поток повторяется с шага 2; прежний challenge становится `superseded`, поэтому его код больше не принимается. 11. Periodic expiry job помечает оставшиеся `active` challenges как `expired` после `expires_at`; verify также выполняет этот переход лениво, если job ещё не успел. Изменение текущего `otp.phone.ttl_seconds` не пересчитывает `expires_at` существующих challenges. 12. Direct callback обновляет только журнал sms-service. Если sms-service не подтвердил durable order (`200`/`202`), новый challenge становится `order_failed`; прежний уже остаётся `superseded`. Frontend получает generic unavailable и может начать новый resend с учётом counters. Mock-режим: внешний заказ не создаётся; challenge сразу получает `active`, `sms_message_id=null`, а остальные TTL/verify/resend/counter rules идентичны real mode. **Граница транзакций Keycloak:** HTTP-вызов sms-service не выполняется внутри transaction с блокировкой counters/challenge. Создание `ordering` и перевод в `active`/`order_failed` — отдельные короткие transaction. Повтор после потерянного HTTP-ответа использует тот же challenge/idempotency key и не создаёт вторую SMS. --- ## 8. Безопасность - Direct credentials только в sms-service. - Internal SMS API недоступен из публичной сети. - OTP в `substitutions`/`body_rendered` хранится как часть закрытого журнала, но никогда не попадает в logs/traces/read API; после `challenge.expires_at` Keycloak его не принимает. - Keycloak хранит только hash OTP и `sms_message_id`. - Enumeration: ошибки send/verify наружу generic + request id. - Service token Keycloak→sms-service и callback credentials различны; ротация через secret store. - TLS certificate Direct проверяется стандартным trust store; `verify=false` запрещён. - Шаблоны редактируются только controlled migration/ops-процедурой; active version требует `approved_at`. - API key Direct ограничивается типом TOKEN_1 и, если поддержано, egress IP. --- ## 9. Наблюдаемость **sms-service:** `sms_send_total{provider,send_status}`, provider latency, `sms_uncertain_total`, callback counters/lag, pending age, journal size/partition age; логи: `sms_message_id`, `provider_message_id`, `requester_service`, `process` — без phone plaintext/OTP/body. **Keycloak:** существующие OTP metrics + verify outcomes; в audit events — `sms_message_id`, device fields. Alerting: 401/402 у Direct, contract violation, любой `uncertain`, рост `failed`, callback lag, зависшие pending, аномальный рост журнала, sms-service not-ready. `/health/live` проверяет процесс. `/health/ready` проверяет DB/schema, active approved template, sender/API key configuration; кратковременная недоступность Direct отражается отдельным dependency status и метрикой, но не вызывает restart loop. --- ## 10. Совместимость документов | Документ | Изменение при внедрении | |---|---| | module-08 | `OtpDeliveryProvider` вызывает **sms-service**, не Direct; challenge + events + device (§6) | | arch-01/02 | Новый internal сервис; направление Keycloak → sms-service → Direct | | arch-03 | Compose-сервис `sms-service`, schema `sms`, сеть backend | | arch-04 | `SMS_SERVICE_*`, `IDGTL_SMS_*`; шаблоны — в БД, не env | | arch-00 | Термины `sms_message_id`, `sms_outbound_message`, `sms_template` | ### 10.1. Compose и сети Добавить `sms-service` в `backend/infra/compose/application.yml`: - networks: `backend`, `egress`, `observability`; - `expose: 8080`, без host `ports`; - managed PostgreSQL schema `sms`, роль только `sms_user`; - Keycloak видит `sms-service` по сети `backend`; при включённой Yandex SmartCaptcha получает отдельный ограниченный egress только к SmartCaptcha API, а при выключенной CAPTCHA остаётся без egress; - root nginx маршрутизирует только точный публичный `POST /callbacks/idgtl/sms` в `sms-service`; `/internal/sms/*` наружу блокируется; - callback location: HTTPS, IP allowlist, request body limit, без access-log Authorization; - зависимости запуска не должны образовывать цикл: Keycloak может стартовать при недоступном `sms-service`; недоступность блокирует только создание нового real-mode заказа, но не verify уже активного challenge. ### 10.2. Артефакты реализации ```text backend/sms-service/ app/ migrations/ tests/ openapi.yaml Dockerfile pyproject.toml ``` Отдельный `docker-compose.yml` не обязателен: действующий репозиторий использует агрегированный `infra/compose/application.yml`. ### 10.3. ТЗ на доработку смежных модулей Ниже перечислены обязательные изменения вне `sms-service`, без которых end-to-end использование нового сервиса не считается реализованным. #### 10.3.1. Общие интеграционные правила 1. Единственный заказчик SMS в v1 — Keycloak SPI. 2. Frontend, `api-backend` и другие сервисы не вызывают `sms-service` и Direct для OTP. 3. Keycloak ждёт только durable order (`200`/`202` + `sms_message_id`) и не ждёт вызова Direct. 4. `send_status`, `delivery_status`, callback и provider errors используются только журналом/ops и никогда не меняют результат verify. 5. OTP генерируется и проверяется только Keycloak; raw OTP передаётся только в закрытом HTTP-запросе Keycloak → sms-service и не логируется. 6. Во всех вызовах передаются `X-Request-ID` и `traceparent`; `idempotency_key=keycloak:challenge:{challenge_id}`. #### 10.3.2. `module-08-keycloak` **Settings bridge** - расширить DTO `GET /internal/settings/v1/otp`: `code_length`, `ttl_seconds`, `sms_order_timeout_ms`; - валидировать диапазоны и сохранять единый immutable settings snapshot на новый challenge; - убрать чтение `KEYCLOAK_OTP_TTL_SEC` и других перенесённых runtime-параметров из env; - last-known-good/cache semantics оставить как для существующих OTP limits. **Миграция provider-owned таблиц** Добавить в `han_otp_challenge`: - `sms_message_id` UUID nullable; - `delivery_mode varchar(16)` с CHECK `mock|sms`; - `challenge_status varchar(16)` с CHECK `ordering|active|consumed|superseded|expired|limited|order_failed`; - `ordered_at timestamptz` nullable; - `otp_ttl_sec integer` с CHECK `60..900` и кратностью 60; - `otp_code_length smallint` с CHECK `4..10`; - существующий `settings_version varchar(128)` переиспользовать, новую колонку не создавать. Миграция существующих mock-записей: - `delivery_mode=mock`, `sms_message_id=null`; - перед migration дождаться прежнего max OTP TTL либо в maintenance transaction пометить все неиспользованные challenges как `expired`; - `ordered_at=created_at`; - `challenge_status=consumed`, если `consumed_at` заполнен; иначе `expired`; - `otp_ttl_sec` и `otp_code_length` backfill текущими seed из `app_settings`; исторические challenges уже не проверяются; - старые `provider_id`/`provider_status` сначала сделать nullable и перестать использовать; удалить отдельной backward-incompatible migration после стабилизации. Расширить `han_otp_security_event`: - `sms_message_id uuid` nullable; - `client_ip inet`, `user_agent text`; - `device_id varchar(256)`, `fingerprint varchar(256)`; - `os_name varchar(64)`, `os_version varchar(64)`; - `platform varchar(16)`, `app_version varchar(64)`. Добавить индексы `han_otp_challenge(challenge_status, expires_at)`, `han_otp_challenge(sms_message_id)` where not null и `han_otp_security_event(sms_message_id)` where not null. Обновить JPA entities и Liquibase changelog; migration должна быть повторяемо проверена на копии production schema. **Клиент sms-service** - реализовать `SmsOrderClient`, который вызывает `POST /internal/sms/v1/send`; - URL и service token — env; timeout — settings snapshot; - успех заказа: только HTTP `200`/`202`, валидный `sms_message_id`; - HTTP timeout/5xx: повторить один раз с тем же challenge/idempotency key; новый challenge и новый OTP не создавать; - не реализовывать GET/poll provider status в auth flow. **Challenge lifecycle** - перед новым заказом после успешной проверки limits перевести прежний `active`/`ordering` challenge в `superseded`; - создать новый `ordering`, сгенерировать numeric OTP по snapshot length, сохранить только HMAC; - после durable order перевести в `active`, установить `ordered_at`/`expires_at`, записать `otp_send/ordered`; - при невозможности durable order перевести в `order_failed`; - verify допускается только для `active` и зависит только от HMAC, TTL и verify counters; - верный код → `consumed`; resend → `superseded`; TTL → `expired`; attempts → `limited`; - periodic expiry job и lazy expiry на verify обязательны; - повтор одного auth action использует тот же challenge и idempotency key. **Counters и mock** - существующие send/verify limits, cooldown, phone HMAC и locking сохраняются; - один новый challenge резервирует одну send attempt; HTTP retry того же заказа повторно counter не увеличивает; - mock mode не вызывает sms-service, но использует те же statuses, TTL, resend и verify rules; - недоступность Direct не влияет на Keycloak; недоступность sms-service блокирует только создание нового real-mode заказа; - общая readiness Keycloak не должна зависеть от Direct или provider status. Допускается отдельный degraded dependency indicator для sms-service. **Тесты Keycloak** - migration/backfill существующих challenges; - durable order → форма OTP до ответа Direct; - resend отклоняет старый код; - expiry и attempts transitions; - provider rejected/timeout не меняет active challenge; - идемпотентный повтор не создаёт второй challenge и не увеличивает counter; - отсутствие OTP/phone/service token в logs/traces. #### 10.3.3. `module-01-api-backend` и App DB settings - добавить migration/seed `app_settings`: - `otp.phone.code_length`; - `otp.phone.ttl_seconds`; - `otp.phone.sms_order_timeout_ms`; - расширить строгий DTO `/internal/settings/v1/otp` согласно `arch-02`; - возвращать все OTP settings одной версией, чтобы Keycloak не смешивал значения разных revisions; - добавить валидацию: code length в разрешённом диапазоне; TTL `60..900` и кратен 60; timeout положительный и bounded; - не добавлять отправку/проверку OTP в `api-backend`; - покрыть endpoint contract tests, cache/ETag и отсутствие новых ключей в public config, если они явно не разрешены. #### 10.3.4. Managed PostgreSQL и deployment jobs - в init-managed-postgres создать schema `sms` и роль `sms_user`; - выдать `sms_user` права только на schema `sms`; доступа к `han_app` и `keycloak` нет; - `sms-service` применяет собственные versioned migrations для `sms_template`, `sms_setting`, `sms_outbound_message`; - добавить idempotent seed active template `auth_otp` и `sms_setting`; - добавить pre-deploy migration job и проверку schema version; - backup/PITR должны включать schema `sms`; автоматическое удаление журнала запрещено; - restore test обязан подтверждать сохранность journal rows, templates, settings и provider IDs. #### 10.3.5. Root Compose и конфигурация Добавить в `backend/infra/compose/application.yml`: - `sms-service` — internal HTTP API/callback receiver; - `sms-worker` — background sender из того же image либо обязательный worker process внутри `sms-service`; - `sms-service`: networks `backend`, `observability`, `expose: 8080`, без `ports`; `egress` добавляется только в совмещённом callback+worker process; - отдельный `sms-worker`: networks `egress`, `observability`, без published/exposed port; - оба процесса используют `SMS_DATABASE_URL`; только worker получает `IDGTL_SMS_API_KEY`; - callback credentials получают `sms-service` для проверки и `sms-worker` для формирования callback URL в запросе Direct; Keycloak получает только `KEYCLOAK_SMS_SERVICE_TOKEN`; - healthchecks, graceful shutdown, lease recovery, read-only rootfs, non-root и resource limits; - startup не строится на `depends_on` Direct; provider outage не вызывает restart loop. Обновить: - root `.env.example` только URL/DB/secrets; - `scripts/validate-env` и config tests; - image/build/release manifests; - secret generation и rotation runbook. #### 10.3.6. `module-03-nginx` Требования относятся к [`module-03-nginx-vm1.md`](module-03-nginx-vm1.md) и общему контракту [`arch-08-nginx.md`](../../architectory/arch-08-nginx.md): - добавить точный public route `POST /callbacks/idgtl/sms` → `sms-service:8080`; - остальные методы на callback path отклонять; - source IP allowlist Direct, учитывая только trusted proxy chain; - передавать Basic Authorization в sms-service, но не писать его в access/error logs; - ограничить размер body, отключить cache, задать отдельный callback rate limit без блокировки легитимных повторов; - `/internal/sms/*` и порт sms-service наружу не публиковать; - добавить config/route tests: allowed callback, wrong IP, wrong method, internal path denied. #### 10.3.7. `module-02-frontend-test-site` и Keycloak theme - frontend не вызывает sms-service; - resend запускает новый Keycloak action; двойной click блокируется на время запроса; - после resend UI явно сообщает, что предыдущий код недействителен; - countdown берётся из challenge/settings snapshot, а не из hardcoded значения; - корректно отображать `invalid`, `expired`, `superseded`, `limited` и generic order unavailable; - raw OTP, service URLs/tokens и provider status не попадают в frontend config/analytics. #### 10.3.8. `module-09-observability` - добавить metrics/alerts в [`module-09-observability-vm1.md`](module-09-observability-vm1.md) и имя сервиса в реестр [`arch-07-observability.md`](../../architectory/arch-07-observability.md) §4 для `sms-service` и `sms-worker`; - dashboard: pending age, send outcomes, provider latency, callback lag, uncertain, journal growth; - traces: Keycloak order span → sms-service DB commit; worker → Direct отдельным trace/span с correlation через `sms_message_id`; - настроить redaction OTP, body, phone, Authorization, API key и callback credentials; - alert routing/runbook для Direct 401/402, `uncertain`, stuck pending и callback failures. #### 10.3.9. `module-10-deployment-runbook` и `deploy-steps.md` Зафиксировать rollout в [`module-10-deployment-vm1.md`](module-10-deployment-vm1.md) и контракте [`arch-10-deployment.md`](../../architectory/arch-10-deployment.md): 1. применить App DB seed новых OTP settings; 2. создать schema/role `sms`, применить migrations и seed; 3. в test environment deploy `sms-service`/worker с `IDGTL_SMS_BASE_URL` локального mock Direct и выполнить contract/E2E; 4. выпустить/установить production Direct TOKEN_1, sender и callback credentials; 5. deploy production `sms-service`/worker, проверить health/migrations, оставив Keycloak в mock mode; 6. применить Keycloak migration и deploy SPI с `KEYCLOAK_OTP_MOCK_ENABLED=true`; 7. выполнить provider smoke отдельной ops-командой на контролируемом номере; 8. проверить реальный callback, журнал и redaction; 9. переключить Keycloak в real mode; 10. проверить resend/expiry/limits и сохранить release evidence. Rollback: - вернуть Keycloak в mock mode без удаления schema/journal; - остановить создание новых real orders, дать worker завершить/зафиксировать in-flight; - migrations откатывать только при доказанной backward compatibility; иначе forward-fix. #### 10.3.10. Архитектурные документы До merge реализации синхронизировать: - `arch-00`: сервис/сущности/ID/settings/env, `send_status`, `delivery_status`, `challenge_status`; - `arch-01`: компонент `sms-service`, schema `sms`, поток Keycloak → durable order → worker → Direct, отсутствие зависимости verify от provider status; - `arch-02`: полный `POST/GET /internal/sms/v1/*`, callback, service-token pair, HTTP-коды и OpenAPI registry; - `arch-03`: `sms-service`/worker, networks, schema/role, nginx callback route, startup/health; - `arch-04`: разделение env / `app_settings` / `sms.sms_setting`; - `architectory/README.md`: убрать формулировку о неоформленной интеграции после начала реализации и добавить ссылки на новый контракт; - `module-01`, `module-02`, `module-03`, `module-08`, `module-09`, `module-10` — добавить перечисленные требования в профильные DoD/test matrix; - `backlog.md`: переводить интеграцию из backlog только после выполнения общего DoD; - `deploy-steps.md`: добавить rollout/rollback и smoke-команды. `module-04-redis`, `module-05-message-safety`, `module-06-bitrix-local-app`, `module-07-bitrix-sync` изменений для SMS не требуют. ### 10.4. Общие критерии приёмки смежных изменений - новый OTP-заказ возвращается до начала/завершения внешнего HTTP-вызова Direct; - Keycloak не содержит кода чтения provider send/delivery status; - provider failure после durable order не деактивирует challenge; - resend делает старый challenge и код `superseded`; - challenge становится `expired` по сохранённому settings snapshot; - повтор с тем же idempotency key не создаёт вторую SMS и не увеличивает counters; - internal SMS API недоступен извне; callback доступен только по установленным правилам; - журнал содержит заказ, provider result и callback и сохраняется бессрочно; - OTP, body, телефон и секреты отсутствуют в logs/traces/metrics; - все изменённые OpenAPI/DTO/migrations/docs проходят contract, migration и E2E tests; - поиск по документации не находит старого прямого потока Keycloak → Direct или зависимости verify от provider status. --- ## 11. Тест-план (будущая реализация) - unit: strict template render, E.164/TTL, request fingerprint, idempotency conflict, status transitions; - contract: локальный mock/WireMock Direct + callback fixtures; существование отдельного sandbox Direct не предполагается; - provider smoke: выделенный test account/sender `sms_promo` только по отдельному ops-runbook, чтобы тест не отправлял SMS случайным адресатам; - integration: Keycloak → durable order в sms-service → background worker → mock Direct; - E2E: форма OTP открывается после durable order и до ответа Direct; provider reject/timeout не меняет Keycloak challenge; - E2E: wrong code → success verify; `sms_message_id` совпадает в обеих БД; - E2E: resend переводит прежний challenge в `superseded`, старый код отклоняется, новый принимается; - E2E: active challenge без ввода кода становится `expired` через snapshot `otp.phone.ttl_seconds`; - E2E: изменение `otp.phone.ttl_seconds`/`code_length` влияет только на новые challenges; - E2E: counters/cooldown применяются до создания нового заказа; идемпотентный HTTP-повтор не увеличивает counters повторно; - resilience: connect failure, 401/402/403/422, `errors=true`, malformed 200, 503, read timeout → `uncertain`, crash после INSERT и после provider accept; - callback: массив, duplicate, out-of-order sent after delivered, unknown UUID, Basic auth/IP reject, retry после DB failure; - security: нет OTP/phone/token/callback credentials в logs/traces; internal API без token → 401; provider TLS verification; - migration: upgrade существующих Keycloak tables и rollback compatibility; - persistence: записи и полный состав журнала сохраняются после архивирования/ротации partition и восстановления backup. --- ## 12. Definition of Done - Журнал SMS целиком в module-11 (`sms_template` + `sms_outbound_message`); - Verify outcomes + device — в Keycloak с `sms_message_id`; - Keycloak не ходит в Direct; Direct не проверяет код; - mock XOR real; отсутствие durable order блокирует только новый challenge; - Keycloak не читает и не проверяет provider send/delivery statuses; - sms-service возвращает durable order до фонового вызова Direct; - ambiguous provider result → `uncertain` без автоматической повторной SMS; - callback защищён HTTPS + IP allowlist + Basic auth и обрабатывается идемпотентно; - TTL OTP задаётся `app_settings["otp.phone.ttl_seconds"]` и считается от `ordered_at`; resend делает прежний challenge `superseded`, expiry job — `expired`; - журнал SMS хранится бессрочно без автоматической очистки; - OpenAPI, migrations, Compose, env validation, health/metrics и runbook готовы; - arch-* и module-08 синхронизированы. --- ## 13. Решения, допущения и внешние предпосылки **Решения:** - S1: module-11 — единственный владелец отправки SMS и журнала. - S2: шаблоны в БД (`sms_template`), не в env. - S3: OTP generate/verify — Keycloak; связь через `sms_message_id`. - S4: первый provider `idgtl`, канал `SMS`, process `auth_otp`, requester `keycloak`. - S5: delivery callback только в sms-service. - S6: устройство (IP, UA, device_id, fingerprint, OS) — в Keycloak verify/send events. - S7: Keycloak зависит только от durable order (`sms_message_id`) и не зависит от provider send/delivery status. - S8: отправка в Direct выполняется background worker-ом после ответа Keycloak. - S9: resend всегда делает прежний challenge `superseded`; неиспользованный challenge после TTL становится `expired`. - S10: `externalMessageId` в v1 считается только корреляцией, не idempotency key; ambiguous provider call не повторяется независимо от будущего ответа Direct. - S11: failover-провайдер не входит в v1; поле `provider` остаётся для аудита и будущего расширения. - S12: device metadata передаётся через custom OIDC `han_*` параметры/auth notes и hidden fields theme по §6.3. - S13: точная миграция Keycloak фиксируется §10.3.2; все прежние незавершённые challenges истекают при rollout. **Допущения:** - A1: отдельная schema `sms` на том же managed PostgreSQL допустима. - A2: sender/template согласуются с i-Digital до prod. - A3: Direct отправляет callback с IP `185.203.96.7`; адрес повторно подтверждается перед production. - A4: Direct поддерживает Basic auth callback через credentials в callback URL согласно опубликованной документации. **Внешняя production-предпосылка:** - Перед production rollout ops определяет фактический статический egress IP из контейнера `sms-worker`, фиксирует его в deployment inventory и передаёт Direct для API-key allowlist. Если egress IP не статичен, production-включение real mode запрещено до настройки NAT/static IP. Это deployment value, а не параметр приложения или открытое архитектурное решение.