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

754 lines
63 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# arch-02. API-контракты и связность взаимодействий
> Термины — в [`arch-00-glossary.md`](arch-00-glossary.md). Общая схема — в [`arch-01-system-architecture.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`](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 | Идемпотентные действия и CTA по каталогу | JWT + rate limit |
| `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 |
Единый формат ошибки:
```json
{
"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`.
**Тело:**
```json
{
"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` (если есть).
```json
{
"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: true`**`403`** `consents_required`.
### `POST /api/v1/analytics/session-start` (событие `session_start`)
Вызывается frontend **только** при начале **новой** UX-сессии у **авторизованного** пользователя (есть валидный JWT и обычно уже выполнен `bootstrap`). **Не** вызывается в гостевом режиме.
**Заголовки:** `Authorization: Bearer <access_token>`, `X-Request-ID` (опционально).
**Тело:**
```json
{
"start_reason": "first_launch",
"device": {
"platform": "web",
"app_version": "1.0.0",
"device_id": "..."
}
}
```
- `start_reason` — обязательно: `first_launch` | `cold_start` | `idle_timeout`.
**Ответ `201`:**
```json
{
"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`](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`](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:
```json
{
"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` возвращает блочный профиль:
```json
{
"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. Клиент отправляет подписку:
```json
{ "type": "subscribe", "dialog_ids": ["uuid"], "notifications": true }
```
4. Сервер отвечает `{ "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` |
Ответ:
```json
{
"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`
```json
{
"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`:
```json
{"message_id":"uuid","content_kind":"text","text":"Текст сообщения","attachment":null}
```
```json
{
"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_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`.
`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`](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_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:
```json
{
"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`](../VM2_services/documentation/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-сеть.