Files
han-app/architectory/arch-02-api-contracts.md

63 KiB
Raw Permalink Blame History

arch-02. API-контракты и связность взаимодействий

Термины — в arch-00-glossary.md. Общая схема — в arch-01-system-architecture.md.

Назначение

Этот документ — канонический реестр API-контрактов между frontend, backend-сервисами и внешними системами. Его цель — контролировать связность: если сервис описан как участник сценария, здесь должен быть указан контракт, направление вызова, владелец и потребитель. Когда появятся профильные спецификации модулей, они могут дублировать здесь зафиксированные контракты для удобства разработки.

Правила связности

  • Любой новый endpoint, webhook, worker-contract или внешний вызов сначала добавляется в этот файл; при появлении профильного документа модуля-владельца — дублируется там для детализации реализации.
  • Публичные пользовательские API находятся под /api/v1; public listeners nginx 80/443 не публикуют /internal/*. Канонический production ingress Safety — отдельный private listener nginx ВМ2 :8443 с internal CA, source allow-list и service token; это не public route и не Docker HTTP fallback.
  • Internal HTTP API между backend-сервисами используют единую маску: /internal/{service_mnemonic}/v1/{resource}, где {service_mnemonic} — короткое имя владельца endpoint (см. arch-00-glossary.md, «Мнемоники internal API»). Утверждённое исключение — target Message Safety /internal/safety/v2/*; legacy /internal/safety/v1/* остаётся только stub до cutover и на private :8443 не публикуется. 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 и по возможности W3C traceparent.
  • 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. Public listeners nginx их не публикуют. Private nginx ВМ2 :8443 является утверждённым ingress для Safety hot path и allow-listed ops endpoint внутри 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 resource_state_conflict JWT валиден, но локальный UserIdentity ещё не создан через bootstrap, либо ресурс находится в несовместимом lifecycle state после bootstrap либо изменения state
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 используется только для операций, где сам факт ресурса уже известен пользователю или оператору.

Для любого protected endpoint, кроме самого POST /api/v1/auth/bootstrap, валидный JWT при отсутствии локального UserIdentity возвращает 409 resource_state_conflict с generic сообщением bootstrap required. Frontend после такого ответа выполняет bootstrap один раз и повторяет исходную операцию с тем же idempotency key, если она идемпотентна.

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/bootstrapPOST /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 выполнен), иначе 409 resource_state_conflict. Обязательные согласия без accepted: true403 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 и вложение → 400 mixed_content_not_allowed (до message-safety);
  • пустое сообщение (нет text и нет attachment_id) → 400 empty_message;
  • более одного вложения → 400 too_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).

  1. 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.
  2. Frontend загружает байты напрямую в Selectel S3 по upload_url (не через api-backend).
  3. POST .../attachments/{attachment_id}/complete с checksum (SHA-256) → api-backend получает authoritative version_id, ETag, size и server checksum; сравнивает client checksum и атомарно фиксирует {quarantine_object_key, version_id, etag, checksum}, scan_status=pending. Complete с другой версией/ETag/checksum → 409 resource_state_conflict.
  4. 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).

Подключение:

  1. Клиент открывает WS с валидным access token.
  2. Сервер отправляет { "type": "connected", "server_time": "ISO8601" }.
  3. Клиент отправляет подписку:
{ "type": "subscribe", "dialog_ids": ["uuid"], "notifications": true }
  1. Сервер отвечает { "type": "subscribed", "dialog_ids": ["uuid"], "notifications": true }. Поле notifications опционально, default false; старые 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
GET /internal/safety/status nginx ВМ2 → message-safety /health/ready api-backend, ops Короткоживущий capability snapshot; не correctness gate private HTTPS + internal CA + source allow-list
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-контрактом.

Канонический wire DTO POST .../check:

{"message_id":"uuid","content_kind":"text","text":"Текст сообщения","attachment":null}
{
  "message_id":"uuid",
  "content_kind":"file",
  "text":"",
  "attachment":{
    "attachment_id":"uuid",
    "quarantine_object_key":"quarantine/users/{user_id}/dialogs/{dialog_id}/{attachment_id}",
    "quarantine_version_id":"opaque-version-id",
    "quarantine_etag":"\"etag\"",
    "mime_type":"application/pdf",
    "size_bytes":12345,
    "checksum":"sha256:<64-lowercase-hex>"
  }
}

DTO является strict discriminated union, unknown fields запрещены. Caller маппит App DB checksum_sha256 в attachment.checksum с обязательным prefix sha256:; quarantine_version_id и quarantine_etag передаются без переименования.

Normative details v2: каждый verdict/pending содержит processing_mode=standard|mock и config_version; 202 обязательно содержит Location, Retry-After, task_id, expires_at и существует только в standard mode; terminal 503 task_failedterminal=true,retryable=false; transient 503 dependency_unavailableterminal=false,retryable=true; 409 safety_request_conflict — non-retryable caller invariant. Все domain deny имеют reason_code=message_blocked.

Location должен быть origin-relative path /internal/safety/v2/messages/tasks/{task_id}. Caller и recovery job резолвят его относительно origin MESSAGE_SAFETY_URL; absolute URL, другой host или path вне этого prefix отклоняются как нарушение контракта без HTTP-запроса.

CA-пара caller: MESSAGE_SAFETY_CA_HOST_PATH задаёт root-owned host bind, MESSAGE_SAFETY_CA_FILE — путь к нему внутри контейнера api-backend. Для remote production URL обязательны оба уровня доставки; TLS verification отключать запрещено.

Caller budget: POST timeout MESSAGE_SAFETY_POST_TIMEOUT_SEC=5, одна попытка GET task — не более 2 секунд, client sync-poll budget MESSAGE_SAFETY_TASK_POLL_MAX_SEC=300, durable recovery budget HAN_APP_SAFETY_RECOVERY_MAX_SEC=1200. Числа являются initial defaults из arch-04; изменение выполняется синхронно в arch-04 и профильных модулях.

Internal error subset не смешивается с public JWT errors: 401 service_unauthorized, 400 validation_error, 404 task_not_found, 409 safety_request_conflict, 429 rate_limit_exceeded, 500 internal_error, 503 dependency_unavailable|task_failed. Поля и retry-семантика определены в module-05 §8.3.

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:

  1. Синхронно вызывает POST .../check, получает один из трёх кодов.
  2. При 200 / 403 — сразу завершает сценарий и отвечает клиенту.
  3. При 202 сохраняет task_id, Location, deadline и не ставит задачу в свою очередь анализа; синхронно поллит Location, соблюдая Retry-After, до 200/403, terminal failed 503 или timeout.
  4. Решение «проверка быстрая или долгая» — только у message-safety. Ожидание poll держит одно клиентское HTTP-соединение; это не блокирует обработку других запросов (параллельные workers/async).

api-backend не вызывает /internal/sync/v1/*. Этот ops-only namespace может находиться на том же private listener :8443, но защищается отдельным BITRIX_SYNC_SERVICE_TOKEN и path allow-list.

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 запись в одной транзакции после финального safety allow;
  • 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_idbitrix_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 в schema bitrix_sync, флаг регистрации в Bitrix24;
  • contact.update — push только App-master телефона/служебных полей;
  • contact.deactivate — flag N, закрытие 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 — module-07-bitrix-sync.md, §§69.

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 не пишутся. Структура таблицы принадлежит module-01-api-backend и migration owner схемы han_app.

Health-контракты

Все backend-сервисы имеют GET /health/live и GET /health/ready. Наружу публикуются только health endpoints, которые нужны nginx/Bitrix24; internal services проверяются через Docker/VPC-сеть.