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

48 KiB
Raw Blame History

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 и по возможности 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. Endpoint не публикуются через nginx (исключение — ops внутри VPC).

Переменная Кто проверяет Кто передаёт Endpoint Заголовок
MESSAGE_SAFETY_SERVICE_TOKEN message-safety api-backend POST/GET /internal/safety/v1/* X-Service-Token
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

Пары значений (должны совпадать):

  • 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. Секреты не коммитить.

Не путать с webhook-токенами (публичные callback от Bitrix24, не internal service API):

Переменная Назначение
BITRIX_APPLICATION_TOKEN проверка событий Bitrix24 → bitrix-local-app /bitrix/handler
BITRIX_SYNC_WEBHOOK_TOKEN проверка webhook Bitrix24 → bitrix-sync /bitrix/sync/webhook/contact

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

Единый формат ошибки:

{
  "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 нет
422 message_blocked Message Safety вернул final deny нет
429 rate_limit_exceeded Edge/API лимит превышен; должен быть Retry-After, если повтор допустим да
503 dependency_unavailable Circuit open или недоступны safety/Bitrix/S3 да
504 dependency_timeout Истёк timeout budget внешней зависимости да

Правило доступа к пользовательским ресурсам: для 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/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 выполнен), иначе 404 / 409 по контракту модуля. Обязательные согласия без 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 }upload_url = presigned PUT (или POST policy) в S3-quarantine; ключ объекта и ограничения (bucket, key prefix, Content-Type, max size) задаёт api-backend.
  2. Frontend загружает байты напрямую в Selectel S3 по upload_url (не через api-backend).
  3. POST .../attachments/{attachment_id}/complete с checksum (SHA-256) → api-backend проверяет наличие объекта в quarantine (HeadObject / размер / checksum), фиксирует metadata, scan_status=pending.
  4. POST .../messages с attachment_id + checksum → Message Safety.

Правила безопасности:

  • у клиента нет постоянных S3 access keys — только одноразовый/короткий presigned URL;
  • presigned URL разрешает запись только в выделенный key в S3-quarantine (не в S3-data);
  • 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"] }
  1. Сервер отвечает { "type": "subscribed", "dialog_ids": ["uuid"] }.

События сервер → клиент:

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

Reconnect:

  • exponential backoff: 1s → 2s → 4s → … max 30s;
  • после reconnect — повтор subscribe с актуальным списком dialog_ids;
  • при недоступности WS > 30s — fallback на polling GET .../messages?after=<cursor>.

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).

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/v1/messages/check message-safety api-backend Проверка текста, ссылок и файлов internal network + X-Service-Token
GET /internal/safety/v1/messages/tasks/{task_id} message-safety api-backend Опрос до финального вердикта внутри того же POST .../messages internal network + X-Service-Token
Read S3-quarantine Selectel S3 message-safety Чтение файла worker-ом при cache miss read-only key

HTTP-семантика от message-safety: 200 allow, 403 deny, 203 pending.

Поведение api-backend:

  1. Синхронно вызывает POST .../check, получает один из трёх кодов.
  2. При 200 / 403 — сразу завершает сценарий и отвечает клиенту.
  3. При 203не ставит задачу в свою очередь анализа; регулярно и синхронно поллит GET .../tasks/{task_id} до 200/403 или timeout (MESSAGE_SAFETY_TASK_POLL_MAX_SEC), затем отвечает клиенту.
  4. Решение «проверка быстрая или долгая» — только у message-safety. Ожидание poll держит одно клиентское HTTP-соединение; это не блокирует обработку других запросов (параллельные workers/async).

Checkpoint: на время poll — запись в safety_tasks (han_app) для recovery при crash/timeout (I1), не очередь анализа.

Recovery contract для han_app.safety_tasks:

  • запись создаётся, когда message-safety вернул 203 pending, и содержит task_id, message_id, attachment_id, текущий quarantine_object_key, deadline и retry metadata;
  • если клиентское HTTP-соединение оборвалось или api-backend упал, recovery job продолжает poll GET /internal/safety/v1/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;
  • 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 да
203 → затем 200/403 как финальный как финальный да (после sync-wait)
timeout / circuit open pending или blocked по политике модуля failed да (ошибка инфраструктуры)

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 для Message Safety: они не проходят outbound moderation pipeline, но проходят MIME/size validation, antivirus policy модуля и audit скачивания;
  • пустой 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 Асинхронная очередь App DB → Bitrix24: триггер ставит задачу при изменении отслеживаемых полей
han.sync_suppress (GUC) PostgreSQL session bitrix-sync триггеры han_app Подавление эхо-задач при записи данных от Bitrix24 в App DB
ClientProfile.bitrix_contact_id PostgreSQL bitrix-sync App DB Маппинг профиля на CRM Contact после map/create
han_app.entity_external_mapping PostgreSQL bitrix-sync App DB Универсальный маппинг App entity ↔ Bitrix entity (MVP: Contact)
Обновление sync_queue.status PostgreSQL bitrix-sync App DB processed / failed / dead_letter, retry metadata

Типы задач MVP (sync_queue.task_type):

  • contact.map_or_create — матчинг/создание Contact, запись bitrix_contact_id, флаг регистрации в Bitrix24;
  • contact.update — push изменений профиля в Bitrix24.

bitrix-sync не создаёт UserIdentity / ClientProfile в auth-flow; вход worker — задачи из sync_queue, созданные триггерами.

Internal HTTP bitrix-sync (ops, не hot path)

Контракт Владелец Потребитель Назначение Защита
GET /internal/sync/v1/status bitrix-sync ops / мониторинг Глубина очереди, dead letter, последний успешный run internal network + BITRIX_SYNC_SERVICE_TOKEN

Повтор dead letter и ручной replay в MVP — через БД/ops-процедуры; отдельный HTTP replay-endpoint — post-MVP.

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.contact.get/list/add/update bitrix-sync → Bitrix24 Поиск, создание и обновление Contact
POST /bitrix/sync/webhook/contact Bitrix24 (робот) → bitrix-sync Исходящий webhook при изменении полей Contact, зарегистрированного в приложении
PostgreSQL schema bitrix_sync bitrix-sync ↔ PostgreSQL Worker state, field mapping, retry/dead letter audit
PostgreSQL schema han_app bitrix-sync ↔ PostgreSQL Очередь sync_queue, маппинг ID, обновление профиля (Bitrix → App)

Очередь han_app.sync_queue и write-back — в разделе «api-backend ↔ bitrix-sync» выше.

bitrix-sync использует BITRIX_SYNC_APP_DATABASE_URL для han_app + bitrix_sync, только BITRIX_SYNC_CRM_* для Bitrix24 CRM REST и не читает 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-сеть.