59 KiB
arch-02. API-контракты и связность взаимодействий
Термины — в
arch-00-glossary.md. Общая схема — вarch-01-system-architecture.md.
Назначение
Этот документ — канонический реестр API-контрактов между frontend, backend-сервисами и внешними системами. Его цель — контролировать связность: если сервис описан как участник сценария, здесь должен быть указан контракт, направление вызова, владелец и потребитель. Когда появятся профильные спецификации модулей, они могут дублировать здесь зафиксированные контракты для удобства разработки.
Правила связности
- Любой новый endpoint, webhook, worker-contract или внешний вызов сначала добавляется в этот файл; при появлении профильного документа модуля-владельца — дублируется там для детализации реализации.
- Публичные пользовательские API находятся под
/api/v1; internal API не публикуются наружу черезnginx. - Internal HTTP API между backend-сервисами используют единую маску:
/internal/{service_mnemonic}/v1/{resource}, где{service_mnemonic}— короткое имя владельца endpoint (см.arch-00-glossary.md, «Мнемоники internal API»). Health-check остаётся на/health/*. - OpenAPI 3.1 обязателен для HTTP-контрактов
api-backend,message-safety,bitrix-syncиbitrix-local-app— файлы{service}/openapi.yamlв репозитории сервиса (см. раздел «OpenAPI»); для Bitrix24 REST фиксируются используемые методы и payload-мэппинг. - Все service-to-service вызовы передают
X-Request-IDи по возможности W3Ctraceparent. - Frontend передаёт
X-Ux-Session-Idво всех JWT-запросах кapi-backend, когда UX-сессия активна (рекомендуется для аналитики и логов; не является auth).session-startиconsentsтребуют JWT. - Все internal API защищаются service token и закрытой Docker/VPC-сетью.
Service tokens (internal API)
Все internal endpoint (/internal/*) доступны только из Docker/VPC-сети и требуют service token. Endpoint не публикуются через nginx (исключение — ops внутри VPC).
| Переменная | Кто проверяет | Кто передаёт | Endpoint | Заголовок |
|---|---|---|---|---|
MESSAGE_SAFETY_SERVICE_TOKEN |
message-safety |
api-backend |
POST/GET /internal/safety/v2/* |
X-Service-Token, private TLS |
BITRIX_INTERNAL_API_TOKEN |
bitrix-local-app |
api-backend |
POST/GET /internal/openlines/v1/* |
Authorization: Bearer |
BITRIX_LOCAL_APP_INTERNAL_TOKEN |
— | api-backend (исходящий) |
то же | Authorization: Bearer |
BITRIX_API_INBOX_TOKEN |
api-backend |
bitrix-local-app |
POST /internal/openlines/v1/inbox |
Authorization: Bearer |
BITRIX_API_FORWARD_TOKEN |
— | bitrix-local-app (исходящий) |
то же | Authorization: Bearer |
BITRIX_SYNC_SERVICE_TOKEN |
bitrix-sync |
ops / мониторинг | GET /internal/sync/v1/* |
Authorization: Bearer или X-Service-Token |
KEYCLOAK_SETTINGS_BRIDGE_TOKEN |
api-backend |
Keycloak SPI | GET /internal/settings/v1/otp |
Authorization: Bearer |
SMS_SERVICE_TOKEN |
sms-service |
Keycloak SPI | POST/GET /internal/sms/v1/* |
Authorization: Bearer |
NOTIFICATIONS_TOKEN_<SOURCE> |
api-backend |
соответствующий продюсер | POST /internal/notifications/v1/* |
Authorization: Bearer |
Пары значений (должны совпадать):
BITRIX_LOCAL_APP_INTERNAL_TOKEN(api-backend) =BITRIX_INTERNAL_API_TOKEN(bitrix-local-app)BITRIX_API_FORWARD_TOKEN(bitrix-local-app) =BITRIX_API_INBOX_TOKEN(api-backend)KEYCLOAK_SMS_SERVICE_TOKEN(Keycloak) =SMS_SERVICE_TOKEN(sms-service)
Генерация: openssl rand -hex 32. Секреты не коммитить.
Для Notifications токен отдельный на каждый source: секрет существует только в deployment secret/env, а notification_sources хранит только hash. Токен разрешает identity продюсера и сравнивается constant-time; source в body обязан совпасть. Seed-источник producer_test и NOTIFICATIONS_TOKEN_PRODUCER_TEST предназначены для smoke Create/Cancel, не для бизнес-интеграции.
Не путать с webhook-токенами (публичные callback от Bitrix24, не internal service API):
| Переменная | Назначение |
|---|---|
BITRIX_APPLICATION_TOKEN |
проверка событий Bitrix24 → bitrix-local-app /bitrix/handler |
BITRIX_SYNC_CONTACT_RECEIVER_TOKEN |
query token штатного HTTP-webhook робота Contact → receiver bitrix-sync; дополнительно source IP CIDR allow-list |
BITRIX_SYNC_ALERT_RECEIVER_TOKEN |
query token штатного HTTP-webhook робота smart-process alert → receiver bitrix-sync; дополнительно source IP CIDR allow-list |
Frontend ↔ api-backend
| Контракт | Владелец | Потребитель | Назначение | Auth |
|---|---|---|---|---|
GET /api/v1/public/app-config |
api-backend |
Expo frontend | Публичные настройки: OTP, оператор, лимиты, файлы, UX idle timeout | public + CORS/rate limit |
GET /api/v1/public/content |
api-backend |
Expo frontend | Тексты по мнемоникам и популярные вопросы | public + CORS/rate limit |
POST /api/v1/consents |
api-backend |
Expo frontend | Повторное сохранение согласий (новые версии документов); привязка к user_id |
JWT + rate limit |
POST /api/v1/analytics/session-start |
api-backend |
Expo frontend | Событие session_start, новая UxSession (только для авторизованного пользователя) |
JWT + rate limit |
POST /api/v1/auth/bootstrap |
api-backend |
Expo frontend | После OTP: find-or-create пользователя + сохранение согласий из тела запроса |
JWT |
POST /api/v1/dialogs |
api-backend |
Expo frontend | Создание диалога перед первым сообщением (в т.ч. после популярного вопроса) | JWT + idempotency |
GET /api/v1/me |
api-backend |
Expo frontend | Профиль текущего клиента | JWT |
GET /api/v1/me/documents |
api-backend |
Expo frontend | Список документов (MVP: может быть пустым; доставка — post-MVP) | JWT |
GET /api/v1/documents/{document_id} |
api-backend |
Expo frontend | Метаданные документа (post-MVP) | JWT |
GET /api/v1/documents/{document_id}/download-url |
api-backend |
Expo frontend | Presigned URL документа профиля; обязателен audit | JWT |
GET /api/v1/dialogs |
api-backend |
Expo frontend | История диалогов | JWT |
GET /api/v1/dialogs/{dialog_id} |
api-backend |
Expo frontend | Карточка диалога | JWT |
GET /api/v1/dialogs/{dialog_id}/messages |
api-backend |
Expo frontend | История сообщений, polling fallback | JWT |
POST /api/v1/dialogs/{dialog_id}/messages |
api-backend |
Expo frontend | Отправка сообщения клиента (MVP: content_kind text или file, см. ниже) |
JWT + idempotency + safety |
POST /api/v1/dialogs/{dialog_id}/attachments/init |
api-backend |
Expo frontend | Инициализация загрузки; ответ: attachment_id + presigned PUT в S3-quarantine |
JWT |
POST /api/v1/dialogs/{dialog_id}/attachments/{attachment_id}/complete |
api-backend |
Expo frontend | Подтверждение загрузки, проверка объекта в quarantine, фиксация checksum/metadata | JWT |
GET /api/v1/dialogs/{dialog_id}/attachments/{attachment_id}/download-url |
api-backend |
Expo frontend | Presigned URL вложения чата; обязателен audit | JWT |
WS /api/v1/realtime |
api-backend |
Expo frontend | Realtime-события чата, статусы доставки, unread | JWT |
GET /api/v1/public/notifications |
api-backend |
Expo frontend | Активные гостевые кампании G | public + CORS/rate limit |
GET /api/v1/public/notification-types |
api-backend |
Expo frontend | Публичный каталог видов с ETag, без серверных правил переходов | public + cache/rate limit |
GET /api/v1/notifications?place=home|center |
api-backend |
Expo frontend | Персональная выборка P с серверными лимитами 7/15 и сортировкой | JWT |
GET /api/v1/notifications/counter |
api-backend |
Expo frontend | Счётчик непрочитанных в окне Центра | JWT |
GET /api/v1/notifications/{id} |
api-backend |
Expo frontend | Деталка активного собственного уведомления | JWT |
| `POST /api/v1/notifications/{id}/read | hide | cta` | api-backend |
Expo frontend |
POST /api/v1/notifications/{id}/buttons/{button_code} |
api-backend |
Expo frontend | Единое действие кнопки деталки | JWT + rate limit |
GET /api/v1/notifications/{id}/documents/{document_id}/download-url |
api-backend |
Expo frontend | Presigned GET + audit; первое скачивание любого связанного документа может скрыть уведомление | JWT |
POST/GET/DELETE /api/v1/uploads/* |
api-backend |
Expo frontend | Универсальные upload drafts клиента | JWT + rate limit |
Единый формат ошибки:
{
"error": {
"code": "profile_not_found",
"message": "Profile was not found",
"request_id": "01J00000000000000000000000",
"details": {}
}
}
Каталог публичных ошибок MVP
Все ошибки возвращаются в envelope выше. details не содержит PII, raw OTP, presigned URL и внутренние stack traces.
| HTTP | error.code |
Когда | Retry |
|---|---|---|---|
400 |
validation_error |
Невалидное тело, query или header | нет |
400 |
phone_claim_missing |
В JWT нет канонического phone claim для bootstrap |
нет |
400 |
mixed_content_not_allowed |
В сообщении одновременно текст и вложение | нет |
400 |
empty_message |
Нет текста и attachment_id |
нет |
400 |
too_many_attachments |
Более одного вложения в MVP | нет |
400 |
attachment_not_completed |
POST .../messages с незавершённым upload |
да, после complete |
400 |
attachment_checksum_mismatch |
Checksum клиента не совпал с объектом в S3 | нет |
401 |
unauthorized |
Нет access token или он невалиден | после auth |
401 |
token_expired |
Access token истёк | да, после refresh token grant |
403 |
consents_required |
Обязательные согласия не приняты | нет |
403 |
forbidden |
Доступ запрещён и ресурс не скрывается | нет |
404 |
not_found |
Ресурс не существует или принадлежит другому пользователю | нет |
409 |
idempotency_key_reused |
Тот же Idempotency-Key с другим fingerprint |
нет |
409 |
notification_conflict |
(source, external_id) уже занят Create с другим fingerprint |
нет |
409 |
notification_closed |
Действие по уже закрытому уведомлению | нет |
422 |
message_blocked |
Message Safety вернул final deny | нет |
422 |
button_not_allowed |
Кнопка не привязана к виду уведомления | нет |
429 |
rate_limit_exceeded |
Edge/API лимит превышен; должен быть Retry-After, если повтор допустим |
да |
503 |
dependency_unavailable |
Circuit open или недоступны safety/Bitrix/S3 | да |
504 |
dependency_timeout |
Истёк timeout budget внешней зависимости | да |
422 message_blocked возвращает только стандартный public error envelope (code, generic message, request_id) без internal rule_id/reason_code. Пользовательский текст появляется отдельной локальной company-репликой из text_resources по мнемонике safety.chat.blocked.
Правило доступа к пользовательским ресурсам: для dialog_id, message_id, attachment_id, document_id, принадлежащих другому user_id, api-backend по умолчанию возвращает 404 not_found, чтобы не раскрывать существование ресурса. 403 forbidden используется только для операций, где сам факт ресурса уже известен пользователю или оператору.
POST /api/v1/auth/bootstrap (после OTP)
Вызывается один раз после успешного OTP и получения JWT. Создаёт локального пользователя и сразу сохраняет согласия из тела (атомарно в одной транзакции). Не создаёт UX-сессию — для этого используется POST /api/v1/analytics/session-start.
Заголовки: Authorization: Bearer <access_token> — единственный источник идентичности пользователя.
Как привязываются согласия (не через тело JSON):
| Источник | Поле | Назначение |
|---|---|---|
JWT claim sub |
→ UserIdentity.keycloak_sub |
find-or-create пользователя; FK для UserConsent.user_id |
| JWT claim телефона | → UserIdentity.phone_number |
номер, подтверждённый OTP в Keycloak (master auth-телефона) |
Тело consents |
версии / accepted | что именно принял пользователь |
Тело device |
metadata | устройство; не идентичность |
Телефон не передаётся в JSON body: клиент мог бы подставить чужой номер. api-backend читает телефон из claims access token Keycloak (канонический claim — по настройке realm; типично phone_number или preferred_username в E.164). Если claim отсутствует — 400 phone_claim_missing.
Тело:
{
"consents": {
"personal_data": { "accepted": true, "version": "2026-06-10" },
"user_agreement": { "accepted": true, "version": "2026-06-10" },
"marketing": { "accepted": false, "version": "2026-06-10" }
},
"device": {
"platform": "ios",
"app_version": "1.0.0",
"device_id": "..."
}
}
Порядок после OTP: POST /api/v1/auth/bootstrap → POST /api/v1/analytics/session-start (если нужна новая UX-сессия) → чат.
Ответ 200: { "user_id": "uuid", "profile_ready": true }.
Ошибки: 401 (JWT), 403 consents_required (обязательные согласия не accepted: true), 400 (невалидные версии/тело / нет phone claim).
POST /api/v1/consents (повторное принятие)
Не часть OTP-flow. Используется, когда уже есть UserIdentity и нужно зафиксировать новые версии документов (или повторное принятие).
Заголовки: Authorization: Bearer <access_token>, X-Ux-Session-Id (если есть).
{
"consents": {
"personal_data": { "accepted": true, "version": "2026-06-10" },
"user_agreement": { "accepted": true, "version": "2026-06-10" },
"marketing": { "accepted": false, "version": "2026-06-10" }
}
}
api-backend сохраняет согласия с привязкой к user_id из JWT. Пользователь должен уже существовать (bootstrap выполнен), иначе 404 / 409 по контракту модуля. Обязательные согласия без accepted: true → 403 consents_required.
POST /api/v1/analytics/session-start (событие session_start)
Вызывается frontend только при начале новой UX-сессии у авторизованного пользователя (есть валидный JWT и обычно уже выполнен bootstrap). Не вызывается в гостевом режиме.
Заголовки: Authorization: Bearer <access_token>, X-Request-ID (опционально).
Тело:
{
"start_reason": "first_launch",
"device": {
"platform": "web",
"app_version": "1.0.0",
"device_id": "..."
}
}
start_reason— обязательно:first_launch|cold_start|idle_timeout.
Ответ 201:
{
"ux_session_id": "660e8400-e29b-41d4-a716-446655440001",
"started_at": "2026-07-08T12:00:00Z"
}
Frontend сохраняет ux_session_id в памяти и передаёт X-Ux-Session-Id в последующих запросах.
Повторный вызов в рамках той же UX-сессии не требуется (возврат из фона в пределах idle timeout).
Ошибки: 401 без/с невалидным JWT.
Создание диалога
POST /api/v1/dialogs— заголовокIdempotency-Key(обязателен); без заголовка →400 validation_error.- Ответ
201— создан новый активный диалог:{ "dialog_id": "uuid", "status": "open" }. - Ответ
200— у пользователя уже есть активный диалог или повторён тот же idempotent-запрос:{ "dialog_id": "uuid", "status": "open" | "waiting_for_company" | "waiting_for_client" }. - Один активный диалог на пользователя: если уже есть диалог со статусом не
closed, endpoint возвращает его (idempotent), новый не создаёт. - Обязателен перед первым
POST .../messages(включая популярный вопрос после auth), если у клиента ещё нетdialog_id. dialog_id=external_chat_id(см.arch-00-glossary.md).
Idempotency (G1)
| Правило | Значение |
|---|---|
| Заголовок | Idempotency-Key (UUID или opaque string ≤ 128 символов) |
| Endpoint | POST /api/v1/dialogs, POST /api/v1/dialogs/{dialog_id}/messages (и другие mutating POST по OpenAPI) |
| Хранение | Redis (DB /0 api-backend): ключ → ответ / request fingerprint |
| TTL | 24 часа |
| Повтор с тем же ключом и тем же телом | тот же HTTP-ответ, без повторного side-effect |
| Повтор с тем же ключом и другим телом | 409 idempotency_key_reused |
| Отсутствует на обязательном endpoint | 400 validation_error |
Формат исходящего сообщения клиента (MVP)
Имена content_kind, полей — arch-00-glossary.md. Правила:
content_kind |
Тело POST .../messages |
Message.text |
MessageAttachment |
|---|---|---|---|
text |
непустой text; без вложения |
текст | 0 записей |
file |
attachment_id + checksum; text пустой |
пустая строка | ровно 1 запись |
- непустой
textи вложение →400mixed_content_not_allowed(доmessage-safety); - пустое сообщение (нет
textи нетattachment_id) →400empty_message; - более одного вложения →
400too_many_attachments; - файловое сообщение в Bitrix24:
message.files(signed URL),message.textпустой.
Post-MVP: допускается «текст + файлы» отдельной версией API.
DTO чата и profile API MVP
MessageResponse — общий DTO для REST и WS:
{
"message_id": "uuid",
"dialog_id": "uuid",
"sender_type": "client",
"content_kind": "text",
"text": "Здравствуйте",
"attachments": [],
"safety_status": "allowed",
"delivery_status": "delivered",
"created_at": "2026-07-09T12:00:00Z"
}
GET /api/v1/dialogs возвращает { "items": [DialogSummary], "next_cursor": "opaque-or-null" }, сортировка — по updated_at desc. GET /api/v1/dialogs/{dialog_id}/messages?after=<cursor>&limit=50 возвращает { "items": [MessageResponse], "next_cursor": "opaque-or-null" }, сортировка — по created_at asc для удобства append в чате. Cursor opaque; frontend не парсит его.
GET /api/v1/me возвращает блочный профиль:
{
"user_id": "uuid",
"profile": {
"personal_data": {
"full_name": "string-or-null",
"citizenship": "string-or-null",
"russian_phone": "string-or-null",
"foreign_phone": "string-or-null",
"email": "string-or-null"
},
"documents": { "count": 0 }
}
}
POST /api/v1/dialogs/{dialog_id}/messages:
- заголовок
Idempotency-Keyобязателен; - request body для текста:
{ "content_kind": "text", "text": "..." }; - request body для файла:
{ "content_kind": "file", "attachment_id": "uuid", "checksum": "sha256:..." }; - success
201:MessageResponseс финальнымdelivery_status=delivered; - safety deny:
422 message_blocked, при этом запись может сохраняться сsafety_status=blocked,delivery_status=rejected; - dependency error:
503 dependency_unavailableили504 dependency_timeout,delivery_status=failedесли сообщение уже было создано.
Загрузка вложения (MVP)
Байты файла идут напрямую в S3-quarantine по короткоживущему presigned URL. api-backend не проксирует тело файла: выдаёт URL, проверяет результат, управляет lifecycle (promote/delete).
POST .../attachments/init(JWT) →{ attachment_id, upload_url, upload_headers, expires_at }— presigned PUT в versioned S3-quarantine. Подпись обязательно включаетIf-None-Match: *, checksum header (x-amz-checksum-sha256либо подтверждённый эквивалент Selectel) иContent-Type; повторная запись того же key получает412 Precondition Failed.- Frontend загружает байты напрямую в Selectel S3 по
upload_url(не черезapi-backend). POST .../attachments/{attachment_id}/completeсchecksum(SHA-256) → api-backend получает authoritativeversion_id, ETag, size и server checksum; сравнивает client checksum и атомарно фиксирует{quarantine_object_key, version_id, etag, checksum},scan_status=pending. Complete с другой версией/ETag/checksum →409 resource_state_conflict.POST .../messagesсattachment_id+checksum→ Message Safety.
Правила безопасности:
- у клиента нет постоянных S3 access keys — только одноразовый/короткий presigned URL;
- presigned URL разрешает запись только в выделенный key в S3-quarantine (не в S3-data);
- bucket versioning включён; Safety читает только сохранённый
version_idс conditional ETag match; - allow-promote копирует именно эту version и использует conditional source ETag/checksum; mismatch запрещает delivery;
- TTL URL короткий (константа модуля /
app_settings, ориентир минуты); - скачивание из S3-data — отдельные presigned GET через
.../download-url(с audit).
Realtime (WS /api/v1/realtime)
Transport: WebSocket over HTTPS (wss://), JWT в query ?access_token= или subprotocol (реализация — в модуле api-backend).
Подключение:
- Клиент открывает WS с валидным access token.
- Сервер отправляет
{ "type": "connected", "server_time": "ISO8601" }. - Клиент отправляет подписку:
{ "type": "subscribe", "dialog_ids": ["uuid"], "notifications": true }
- Сервер отвечает
{ "type": "subscribed", "dialog_ids": ["uuid"], "notifications": true }. Полеnotificationsопционально, defaultfalse; старые chat-клиенты совместимы.
События сервер → клиент:
type |
Назначение | Ключевые поля |
|---|---|---|
message.new |
Новое сообщение в диалоге | dialog_id, message (DTO как в REST) |
message.status |
Смена safety_status / delivery_status |
dialog_id, message_id, safety_status, delivery_status |
dialog.status |
Смена Dialog.status |
dialog_id, status |
notification.created |
Создано персональное уведомление | event_id, occurred_at, notification, unread_count |
notification.updated |
Изменено состояние/документы уведомления | event_id, occurred_at, notification_id, изменённые поля, unread_count |
notification.closed |
Уведомление закрыто | event_id, occurred_at, notification_id, close_reason, unread_count |
Reconnect:
- exponential backoff: 1s → 2s → 4s → … max 30s;
- после reconnect — повтор
subscribeс актуальным спискомdialog_ids; - при недоступности WS > 30s — fallback на polling чата и, при подписке на уведомления,
GET /api/v1/notifications+/counterраз в 60 секунд.
События уведомлений публикуются в han:rt:user:{user_id} на все соединения, включая инициатора. Массовый expire job не отправляет событие на каждую запись; reconnect/polling всегда выполняет REST reconcile.
Ping: сервер может слать { "type": "ping" } каждые 30s; клиент отвечает { "type": "pong" }.
OpenAPI
| Сервис | Файл | Публикуется наружу |
|---|---|---|
api-backend |
api-backend/openapi.yaml |
да (/api/v1/*, health) |
message-safety |
message-safety/openapi.yaml |
нет (internal) |
bitrix-local-app |
bitrix-local-app/openapi.yaml |
частично (/bitrix/*, health) |
bitrix-sync |
bitrix-sync/openapi.yaml |
нет (internal + webhook) |
sms-service |
sms-service/openapi.yaml + callback JSON Schema |
internal send/read; публичен только exact callback |
Правила:
- breaking change публичного API → новый path-prefix (
/api/v2) + запись в arch-02; - internal API версионируется тем же правилом (
/internal/{mnemonic}/v2/...); - OpenAPI генерируется или поддерживается вручную — на усмотрение модуля, но файл обязателен в DoD (arch-05).
Producers ↔ api-backend: Notifications
| Контракт | Назначение | Защита |
|---|---|---|
POST /internal/notifications/v1/notifications |
Create персонального уведомления | private network + Bearer token конкретного source |
POST /internal/notifications/v1/notifications/cancel |
Cancel по (source, external_id) с cancelled или paid |
то же |
Пара (source, external_id) уникальна бессрочно и заменяет Idempotency-Key: одинаковый canonical fingerprint возвращает существующую запись с 200, другой — 409 notification_conflict. Cancel идемпотентен; чужой source не раскрывается.
Каталог, валидация details, обязательных полей CTA и эффектов кнопок применяются по данным справочников без ветвления по notification_type. Инструкция install_app всегда возвращает открытие instruction_url в новой вкладке, без iframe/модалки.
При скрытии действие всегда ставит visibility=hidden. TTL из вида/default применяется только если date_expired IS NULL; уже заданная продюсером дата сохраняется. Первое успешное получение download URL для любого связанного документа считается началом скачивания и, при hide_on_document_download=true, один раз скрывает уведомление; последующие документы состояние не меняют.
Keycloak SPI ↔ api-backend settings bridge
Keycloak SPI получает product limits OTP из app_settings через internal endpoint, а не через прямой доступ к han_app.
| Контракт | Владелец | Потребитель | Назначение | Защита |
|---|---|---|---|---|
GET /internal/settings/v1/otp |
api-backend |
Keycloak SPI | OTP limits + code_length, ttl_seconds, sms_order_timeout_ms, cache metadata |
internal network + Bearer KEYCLOAK_SETTINGS_BRIDGE_TOKEN |
Ответ:
{
"max_send_attempts_per_24h": 3,
"min_seconds_between_attempts": 30,
"max_verify_attempts": 5,
"code_length": 6,
"ttl_seconds": 60,
"sms_order_timeout_ms": 3000,
"version": "2026-07-22T14:00:00Z",
"cache_ttl_seconds": 60
}
Ответ не содержит секретов и PII. Challenge сохраняет snapshot code_length, ttl_seconds и version. При недоступности endpoint Keycloak SPI использует последнее валидное cached value; если cache пустой — fail-closed для выдачи нового OTP.
Keycloak SPI ↔ sms-service
Контракт действует в real mode; в mock mode Keycloak не вызывает sms-service. API доступен только в закрытой сети backend, Bearer token — парные KEYCLOAK_SMS_SERVICE_TOKEN/SMS_SERVICE_TOKEN. Caller v1 фиксирован как keycloak, process/template — auth_otp, channel — SMS, provider — idgtl; эти поля не доверяются request body.
POST /internal/sms/v1/send
{
"idempotency_key": "keycloak:challenge:<CHALLENGE_ID>",
"template_code": "auth_otp",
"locale": "ru",
"phone_e164": "+79001234567",
"substitutions": {"code": "<OTP>", "ttl_min": "<TTL_MIN>"},
"customer_ref": "<CHALLENGE_ID>",
"message_ttl_sec": 60
}
- Строгая проверка E.164, TTL Direct
60..86400, locale и точного набора placeholders; неизвестный/пропущенный placeholder →422 sms_request_invalid. - В одной transaction рендерится active approved
sms_templateи создаётсяsms_outbound_message(pending/unknown); внешний Direct API в request handler не вызывается. - Новый durable order →
202сsms_message_id,ordered_at; идемпотентный повтор с тем же fingerprint →200и тот же id; тот же key с другим payload →409 idempotency_key_reused. - Остальные коды:
401 unauthorized,429 rate_limit_exceeded,503 sms_service_unavailable; envelope общий для arch-02. - Keycloak считает заказ успешным только при
200/202и валидномsms_message_id, сохраняет его в challenge/event и не запрашивает provider status.
GET /internal/sms/v1/messages/{sms_message_id}
Диагностический read для Keycloak только по собственному requester_service. Телефон всегда masked; OTP, substitutions и body_rendered не возвращаются.
POST /callbacks/idgtl/sms
Единственный публичный SMS endpoint. Только HTTPS и POST через root nginx; source IP 185.203.96.7 повторно сверяется перед production, применяется allowlist. Direct передаёт Basic auth, проверяемый sms-service по IDGTL_SMS_CALLBACK_USERNAME/IDGTL_SMS_CALLBACK_PASSWORD; credentials/Authorization не логируются.
Callback body — массив; items валидируются и дедуплицируются по (message_uuid, callback_event, status, status_time). Повторы и out-of-order события ожидаемы. Callback обновляет только delivery fields журнала после DB commit, не уведомляет Keycloak и не влияет на OTP verify. Transient DB failure → 5xx для повтора Direct.
Frontend ↔ Keycloak
Keycloak обязателен в production-like контуре с первого запуска (OTP, tokens, JWKS).
| Контракт | Владелец | Потребитель | Назначение |
|---|---|---|---|
| OIDC Authorization Code Flow with PKCE | Keycloak | Expo frontend | OTP-only login, token issue |
| OIDC Refresh Token Grant | Keycloak | Expo frontend | Обновление access token без OTP при действующем refresh token |
| OIDC logout | Keycloak | Expo frontend | Завершение сессии Keycloak, очистка tokens |
OIDC Discovery (/.well-known/openid-configuration) |
Keycloak | Expo frontend, api-backend |
issuer, token/jwks endpoints |
| JWKS | Keycloak | api-backend |
Проверка подписи access token (issuer, audience, exp) |
| OTP authenticator / SPI | Keycloak | — | Генерация/локальная проверка OTP, product limits, challenge lifecycle и вызов sms-service в real mode |
PostgreSQL schema keycloak |
Keycloak | Managed PostgreSQL | Учётные записи IdP |
Frontend не обращается напрямую к Keycloak DB и не хранит парольные credentials. Парольная авторизация в MVP отключена.
api-backend ↔ Keycloak: только валидация JWT по JWKS/discovery (кэш ключей). Admin REST / User API Keycloak в hot path не используются. Телефон и sub для bootstrap берутся из claims access token.
OTP (Keycloak): единственный канал первичной авторизации — телефон. При действующем refresh token OTP не показывается. Keycloak всегда является источником истины verify: mock сравнивает secret-код, real mode — локальный HMAC случайного OTP. sms-service только принимает durable order, рендерит шаблон, отправляет через Direct worker и ведёт provider journal. API верификации Direct /verifier/send и /verifier/check запрещён. Счётчики и product limits — только Keycloak/SPI (+ nginx edge).
Clients в realm (MVP):
| Client | Тип | Назначение |
|---|---|---|
| Frontend (Expo) | public + PKCE | login / refresh / logout |
| Backend confidential | optional | не нужен для hot path; зарезервирован под будущие admin/ops S2S |
Жизненный цикл access token (frontend)
Детали — arch-01, «Обновление access token (frontend)». Кратко:
| Механизм | Когда | Действие |
|---|---|---|
| Scheduler | за ~60 с до exp access token |
Refresh Token Grant → новые tokens, перепланировать таймер |
| 401 interceptor | api-backend / WS отклонил access token |
single-flight refresh → один retry запроса |
| Открытие приложения | cold start / resume | silent refresh, если refresh token ещё действителен |
Правила:
- refresh выполняет только frontend (Keycloak token endpoint);
api-backendна401не обновляет токен; - параллельные запросы при refresh — очередь + single-flight;
- провал refresh → очистка tokens, гостевой режим, OTP при следующем защищённом действии;
- успешный refresh не создаёт UX-сессию (
session_start).
401 от api-backend: единый формат ошибки (см. выше); типичный code: unauthorized / token_expired — frontend трактует как сигнал к refresh+retry (если refresh token ещё валиден).
api-backend ↔ message-safety
| Контракт | Владелец | Потребитель | Назначение | Защита |
|---|---|---|---|---|
POST /internal/safety/v2/messages/check |
message-safety |
api-backend |
Проверка текста, ссылок и файлов | private HTTPS + internal CA + X-Service-Token |
GET /internal/safety/v2/messages/tasks/{task_id} |
message-safety |
api-backend |
Опрос до финального вердикта внутри того же public POST .../messages |
private HTTPS + internal CA + X-Service-Token |
| Read S3-quarantine | Selectel S3 | message-safety |
Чтение файла worker-ом при cache miss | read-only key |
HTTP-семантика target v2 от message-safety: 200 allow, 403 deny, 202 Accepted/pending. Текущие /v1/* и 203 относятся только к legacy stub и не являются production-контрактом.
Normative details v2: каждый verdict/pending содержит processing_mode=standard|mock и config_version; 202 обязательно содержит Location, Retry-After, task_id, expires_at и существует только в standard mode; terminal 503 task_failed — terminal=true,retryable=false; transient 503 dependency_unavailable — terminal=false,retryable=true; 409 safety_request_conflict — non-retryable caller invariant. Все domain deny имеют reason_code=message_blocked.
Emergency MOCK включается только root-owned helper/restart на ВМ2. В MOCK нет content/link/file checks и 202: TEXT_FREE/FILE_FREE=true → sync 200, false → canonical sync 403. Auth/DTO/idempotency/audit/rate limits сохраняются. Public API не раскрывает processing_mode.
Поведение api-backend:
- Синхронно вызывает
POST .../check, получает один из трёх кодов. - При
200/403— сразу завершает сценарий и отвечает клиенту. - При
202сохраняетtask_id,Location, deadline и не ставит задачу в свою очередь анализа; синхронно поллитLocation, соблюдаяRetry-After, до200/403, terminal failed503или timeout. - Решение «проверка быстрая или долгая» — только у
message-safety. Ожидание poll держит одно клиентское HTTP-соединение; это не блокирует обработку других запросов (параллельные workers/async).
Checkpoint: на время poll — запись в safety_tasks (han_app) для recovery при crash/timeout (I1), не очередь анализа.
Recovery contract для han_app.safety_tasks:
- запись создаётся, когда
message-safetyвернул202 pending, и содержитtask_id,Location,message_id,attachment_id, текущиеquarantine_object_key/version_id/ETag, deadline и retry metadata; - если клиентское HTTP-соединение оборвалось или api-backend упал, recovery job продолжает poll
GET /internal/safety/v2/messages/tasks/{task_id}; - final allow выполняет idempotent promote quarantine → S3-data и продолжает delivery checkpoint в Open Lines;
- final deny выполняет idempotent delete quarantine и выставляет
safety_status=blocked,delivery_status=rejected; - при final deny создаётся локальная company-реплика с
text_resources.mnemonic=safety.chat.blocked; она публикуется какmessage.new, но не отправляется в Open Lines; - timeout/circuit после recovery budget выставляет
delivery_status=failed, оставляет audit trail и отдаёт объект на quarantine cleanup policy; - recovery job не принимает новых сообщений и не решает, sync или async нужна проверка: это остаётся ответственностью
message-safety.
Маппинг в App DB (Message.safety_status / delivery_status — см. arch-00-glossary.md):
HTTP / message-safety |
Message.safety_status |
Message.delivery_status (после завершения POST .../messages) |
Финальный для клиента? |
|---|---|---|---|
200 / allow |
allowed |
accepted до вызова Open Lines; delivered только после успешной отправки в Open Lines |
да |
403 / deny |
blocked |
rejected |
да |
202 → затем 200/403 |
как финальный | как финальный | да (после sync-wait) |
terminal failed 503, retryable=false |
pending |
failed |
да: public 503, не deny |
409 safety_request_conflict |
pending |
failed |
да: public 500 + alert, POST не повторять |
| timeout / circuit open | pending |
failed |
да: public 503/504, не deny |
Для mock file allow MessageAttachment.scan_status=bypassed; значение clean запрещено, так как фактической проверки не было. Message.safety_processing_mode и Message.safety_config_version хранятся для audit, но отсутствуют в public DTO.
Circuit breaker + timeout budget (I2): при открытом circuit на message-safety — не слать сообщение в Bitrix; вернуть клиенту безопасную ошибку зависимости.
Доставка в Open Lines:
- api-backend сохраняет
Messageи delivery checkpoint/outbox запись в одной транзакции после финального safetyallow; delivery_status=acceptedне считается доставкой оператору и может быть виден только как промежуточный статус в логах/recovery;delivery_status=deliveredвыставляется после успешного ответаPOST /internal/openlines/v1/messages;- повтор delivery checkpoint идемпотентен по
message_idи не создаёт дубль в Bitrix24; - если
bitrix-local-appили Bitrix24 недоступны после allow,delivery_status=failed, клиент получает dependency error, а recovery может повторить доставку только если контракт модуля явно разрешает безопасный retry без дубля.
api-backend ↔ bitrix-local-app (Open Lines)
Мнемоника сервиса: openlines. Endpoint Open Lines на стороне bitrix-local-app и приёмник событий на стороне api-backend используют один префикс /internal/openlines/v1/.
| Контракт | Владелец | Потребитель | Назначение | Защита |
|---|---|---|---|---|
POST /internal/openlines/v1/messages |
bitrix-local-app |
api-backend |
Отправка сообщения клиента в Bitrix24 Open Lines | Bearer BITRIX_INTERNAL_API_TOKEN |
GET /internal/openlines/v1/dialogs/{external_chat_id} |
bitrix-local-app |
api-backend |
Получение маппинга dialog_id ↔ bitrix_chat_id |
Bearer token |
GET /internal/openlines/v1/status |
bitrix-local-app |
ops / api-backend |
Статус OAuth и imconnector.status |
Bearer token |
POST /internal/openlines/v1/setup/retry |
bitrix-local-app |
ops | Повтор register/activate/event.bind | Bearer token |
POST /internal/openlines/v1/inbox |
api-backend |
bitrix-local-app |
Forward нормализованных событий оператора | Bearer BITRIX_API_INBOX_TOKEN |
external_chat_id всегда равен dialog_id приложения. bitrix-sync не участвует в hot path чата.
Inbox: входящие сообщения и файлы оператора (G5)
POST /internal/openlines/v1/inbox — нормализованное событие от bitrix-local-app. Минимальный контракт MVP:
{
"event_type": "message.new",
"external_chat_id": "uuid",
"bitrix_message_id": "string",
"occurred_at": "2026-07-09T12:00:00Z",
"message": {
"text": "текст оператора или пустая строка",
"files": [
{
"name": "scan.pdf",
"mime_type": "application/pdf",
"size_bytes": 12345,
"download_url": "https://..."
}
]
}
}
Правила:
event_type:message.new|dialog.closed(и др. по OpenAPI модуля);- idempotency по
(external_chat_id, bitrix_message_id)на сторонеapi-backend; повтор того же события возвращает200/204без повторного side-effect; - если
api-backendнедоступен,bitrix-local-appхранит событие во внутреннем inbox, повторяет forward с exponential backoff и после исчерпания retry переводит запись в DLQ со статусомdead_letter; bitrix-local-appподтверждает доставку в Bitrix24 черезimconnector.send.status.deliveryтолько после успешного ответаapi-backendили после идемпотентного duplicate-ack;- файлы оператора: api-backend скачивает по
download_url(timeout budget) и сохраняет в S3-data attachments +MessageAttachment; в MVP применяются те же продуктовые лимитыchat.attachments.allowed_*иchat.attachments.max_size_mb, что и для клиентских файлов; - сообщения и файлы оператора считаются доверенным Bitrix24-channel: они не идут в quarantine и Message Safety, проходят только MIME/size validation и audit скачивания, затем сохраняются в S3-data. Остаточный malware-риск принят для MVP; UI/скачивание должны сохранять безопасный
Content-Disposition/Content-Typeи не исполнять active content. - пустой
textи пустойfiles→ reject события; - детальная JSON Schema — в
bitrix-local-app/openapi.yamlиapi-backend/openapi.yaml.
api-backend ↔ bitrix-sync
без синхронного HTTP в пользовательских сценариях. Связь — PostgreSQL-триггеры в App DB → очередь han_app.sync_queue + прямой доступ bitrix-sync к han_app для write-back. api-backend не создаёт задачи синхронизации вручную.
Очередь, триггеры и write-back (основной контракт MVP)
| Контракт | Тип | Владелец | Потребитель | Назначение |
|---|---|---|---|---|
han_app.sync_queue |
PostgreSQL | триггеры han_app (миграции App DB) |
bitrix-sync |
Durable очередь App DB → Bitrix24 с lease/fencing и active-only dedup |
han.sync_suppress (GUC) |
PostgreSQL session | bitrix-sync |
триггеры han_app |
Подавление эхо-задач при записи данных от Bitrix24 в App DB |
bitrix_sync.entity_external_mapping |
PostgreSQL | bitrix-sync |
bitrix-sync |
Единственная каноническая active/closed/broken история user_id ↔ Contact; App DB не хранит b24_id |
Обновление sync_queue.status |
PostgreSQL | bitrix-sync |
App DB | pending/leased/retry_wait/processed/dead_letter/cancelled, lease и safe error metadata |
bitrix_sync.workflow_instances / crm_commands |
PostgreSQL | bitrix-sync |
bitrix-sync |
Persisted scenario state и конкретные Bitrix batch subcommands |
bitrix_sync.webhook_inbox |
PostgreSQL | bitrix-sync |
bitrix-sync |
Durable приём, dedup и coalescing событий Битрикс24 |
Типы задач MVP (sync_queue.task_type):
contact.map_or_create— матчинг/создание Contact, запись mapping в schemabitrix_sync, флаг регистрации в Bitrix24;contact.update— push только App-master телефона/служебных полей;contact.deactivate— flagN, закрытие active mapping без удаления Contact.
contact.rebind не является задачей han_app.sync_queue: это audited административный workflow, создаваемый только через bitrix_sync.request_bitrix_contact_rebind.
bitrix-sync не создаёт UserIdentity / ClientProfile в auth-flow; вход worker — задачи из sync_queue, созданные триггерами.
entity_id contact-задачи всегда равен UserIdentity.id; payload не содержит PII snapshot. Полный DDL/state-machine contract — ../modules/module-07-bitrix-sync.md, §§6–9.
Internal HTTP bitrix-sync (ops, не hot path)
| Контракт | Владелец | Потребитель | Назначение | Защита |
|---|---|---|---|---|
GET /internal/sync/v1/status |
bitrix-sync |
ops / мониторинг | Queue/workflow/webhook/reconciliation/limiter state без PII | internal network + BITRIX_SYNC_SERVICE_TOKEN |
Публичный/manual replay HTTP endpoint отсутствует. Controlled ops-действия используют утверждённые процедуры с audit; произвольный UPDATE mapping запрещён.
bitrix-local-app ↔ Bitrix24
| Контракт | Направление | Назначение |
|---|---|---|
GET/POST /bitrix/install |
Bitrix24 → bitrix-local-app |
Установка local app, OAuth lifecycle |
GET/POST /bitrix/handler |
Bitrix24 → bitrix-local-app |
ONIMCONNECTOR*, ONAPPINSTALL, ONAPPUNINSTALL |
imconnector.register |
bitrix-local-app → Bitrix24 |
Регистрация han_mobile_app |
imconnector.activate |
bitrix-local-app → Bitrix24 |
Привязка к линии 8 |
event.bind |
bitrix-local-app → Bitrix24 |
Подписка на события коннектора |
imconnector.send.messages |
bitrix-local-app → Bitrix24 |
Доставка сообщения клиента оператору |
imconnector.send.status.delivery |
bitrix-local-app → Bitrix24 |
Подтверждение доставки входящего события |
bitrix-sync ↔ Bitrix24 CRM
| Контракт | Направление | Назначение |
|---|---|---|
crm.duplicate.findbycomm, crm.contact.get/add/update, crm.item.list, batch |
bitrix-sync → Bitrix24 |
Первичный поиск, recovery, чтение, создание и точечное обновление Contact; reconciliation через crm.item.list, entityTypeId=3, >=updatedTime, opened=1, registration flag =1 |
Contact receiver URL /bitrix/sync/webhook/contact?token=... |
HTTP-webhook робот Битрикс24 → bitrix-sync |
application/x-www-form-urlencoded, query token, source IP CIDR allow-list, durable inbox; затем snapshot по ID |
Alert receiver URL /bitrix/sync/webhook/alert?token=... |
HTTP-webhook робот Битрикс24 → bitrix-sync |
Form-urlencoded сигнал элемента smart process, query token и source IP CIDR allow-list |
| Smart process «Конфликты синхронизации» | bitrix-sync ↔ Bitrix24 |
Business alerts с fingerprint, occurrence и SLA |
PostgreSQL schema bitrix_sync |
bitrix-sync ↔ PostgreSQL |
Workflow/commands, inbox, snapshots, settings, alerts, reconciliation и technical DLQ |
PostgreSQL schema han_app |
bitrix-sync ↔ PostgreSQL |
Очередь sync_queue и обновление профиля (Bitrix → App); canonical mapping хранится только в bitrix_sync |
Очередь han_app.sync_queue и write-back — в разделе «api-backend ↔ bitrix-sync» выше.
bitrix-sync использует отдельный secret DB URL с search path/access к bitrix_sync и точечными GRANT на han_app, отдельный входящий webhook технического пользователя для CRM REST и отдельные application tokens исходящих webhook. OAuth-токены bitrix-local-app не читает.
api-backend ↔ внешние хранилища
| Контракт | Внешний сервис | Назначение |
|---|---|---|
PostgreSQL schema han_app |
Managed PostgreSQL | App DB: пользователи, профили, диалоги, сообщения, настройки, sync_queue, audit |
Selectel S3 han-chat-quarantine |
Selectel S3 | Временное хранение вложений клиента до verdict |
Selectel S3 han-chat-attachments |
Selectel S3 | Проверенные файлы чата |
Selectel S3 han-chat-documents |
Selectel S3 | Документы компании для клиента |
| Redis | redis |
rate limits API (/0), realtime/coordination (опц. /1); не OTP counters |
Observability-контракты
| Контракт | Владелец | Потребители | Назначение |
|---|---|---|---|
| OTLP gRPC/HTTP | otel-collector (observability) |
backend-сервисы | Приём traces/logs/metrics |
| JSON stdout logs | каждый сервис | platform logs / оператор | Техническая диагностика; ux_session_id из X-Ux-Session-Id, если передан |
| Audit / analytics events в App DB | api-backend |
аналитика, расследования | session_start и чувствительные действия без PII |
Analytics: session_start
При POST /api/v1/analytics/session-start api-backend создаёт запись:
| Поле | Пример |
|---|---|
event_type |
session_start |
ux_session_id |
UUID новой UX-сессии |
start_reason |
first_launch / cold_start / idle_timeout |
user_id |
из JWT |
guest_session_id |
не используется (endpoint только с JWT) |
request_id |
из X-Request-ID |
ip, user_agent |
из proxy headers |
Raw OTP и полный номер телефона в audit не пишутся.
Audit: выдача download URL
При GET /api/v1/documents/{document_id}/download-url и GET /api/v1/dialogs/{dialog_id}/attachments/{attachment_id}/download-url api-backend создаёт запись:
| Поле | Пример |
|---|---|
event_type |
document.download_url_issued, attachment.download_url_issued |
user_id |
UUID пользователя |
resource_type |
document / attachment |
resource_id |
UUID ресурса |
ux_session_id |
из X-Ux-Session-Id |
request_id |
из X-Request-ID |
ip, user_agent |
из proxy headers |
Presigned URL и содержимое файла в audit не пишутся. Структура таблицы — модуль database.
Health-контракты
Все backend-сервисы имеют GET /health/live и GET /health/ready. Наружу публикуются только health endpoints, которые нужны nginx/Bitrix24; internal services проверяются через Docker/VPC-сеть.