Files
han-app/architectory/arch-02-api-contracts.md
T
2026-07-09 12:39:52 +03:00

502 lines
36 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`; internal API не публикуются наружу через `nginx`.
- Internal HTTP API между backend-сервисами используют единую маску: **`/internal/{service_mnemonic}/v1/{resource}`**, где `{service_mnemonic}` — короткое имя владельца endpoint (см. [`arch-00-glossary.md`](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` |
Пары значений (должны совпадать):
- `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)
Генерация: `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 |
Единый формат ошибки:
```json
{
"error": {
"code": "profile_not_found",
"message": "Profile was not found",
"request_id": "01J00000000000000000000000",
"details": {}
}
}
```
### `POST /api/v1/auth/bootstrap` (после OTP)
Вызывается **один раз** после успешного OTP и получения JWT. Создаёт локального пользователя и **сразу** сохраняет согласия из тела (атомарно в одной транзакции). **Не** создаёт UX-сессию — для этого `POST /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 /auth/bootstrap``POST /analytics/session-start` → чат.
**Ответ `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` выполнен), иначе **`404`** / **`409`** по контракту модуля. Обязательные согласия без `accepted: true`**`403`** `consents_required`.
### `POST /api/v1/analytics/session-start` (событие `session_start`)
Вызывается frontend **только** при начале **новой** UX-сессии у **авторизованного** пользователя (есть валидный JWT и обычно уже выполнен `bootstrap`). **Не** вызывается в гостевом режиме.
**Заголовки:** `Authorization: Bearer <access_token>`, `X-Request-ID` (опционально).
**Тело:**
```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`** (обязателен); ответ `{ "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` |
### Формат исходящего сообщения клиента (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.
### Загрузка вложения (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. Клиент отправляет подписку:
```json
{ "type": "subscribe", "dialog_ids": ["uuid"] }
```
4. Сервер отвечает `{ "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) |
Правила:
- breaking change публичного API → новый path-prefix (`/api/v2`) + запись в arch-02;
- internal API версионируется тем же правилом (`/internal/{mnemonic}/v2/...`);
- OpenAPI генерируется или поддерживается вручную — на усмотрение модуля, но файл обязателен в DoD (arch-05).
## 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 `otp.phone.*`; mock или SMS |
| 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):** единственный канал первичной авторизации — телефон. OTP-flow нужен, когда refresh token отсутствует или истёк. При действующем refresh token frontend использует **Refresh Token Grant** и не показывает OTP. После ввода кода **Keycloak проверяет OTP**: при `KEYCLOAK_OTP_MOCK_ENABLED=true` — сверка с `KEYCLOAK_OTP_MOCK_CODE` (`.env`); при `false` — сверка с OTP от SMS-провайдера (post-MVP, [`!Backlog.md`](../../HAN_chat/!Backlog.md)). Счётчики и product limits OTP — **только** Keycloak/SPI (+ nginx edge); `api-backend` OTP **не** проверяет и **не** ведёт OTP counters в Redis.
**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), не очередь анализа.
Маппинг в 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` | `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; вернуть клиенту безопасную ошибку зависимости.
## 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`;
- файлы оператора: api-backend скачивает по `download_url` (timeout budget) и сохраняет в **S3-data attachments** + `MessageAttachment`; MIME/size — те же продуктовые лимиты чата (`chat.attachments.*`) или отдельный allow-list модуля (зафиксировать в OpenAPI);
- пустой `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-сеть.