48 KiB
arch-02. API-контракты и связность взаимодействий
Термины — в
arch-00-glossary.md. Общая схема — вarch-01-system-architecture.md.
Назначение
Этот документ — канонический реестр API-контрактов между frontend, backend-сервисами и внешними системами. Его цель — контролировать связность: если сервис описан как участник сценария, здесь должен быть указан контракт, направление вызова, владелец и потребитель. Когда появятся профильные спецификации модулей, они могут дублировать здесь зафиксированные контракты для удобства разработки.
Правила связности
- Любой новый endpoint, webhook, worker-contract или внешний вызов сначала добавляется в этот файл; при появлении профильного документа модуля-владельца — дублируется там для детализации реализации.
- Публичные пользовательские API находятся под
/api/v1; internal API не публикуются наружу черезnginx. - Internal HTTP API между backend-сервисами используют единую маску:
/internal/{service_mnemonic}/v1/{resource}, где{service_mnemonic}— короткое имя владельца endpoint (см.arch-00-glossary.md, «Мнемоники internal API»). Health-check остаётся на/health/*. - OpenAPI 3.1 обязателен для HTTP-контрактов
api-backend,message-safety,bitrix-syncиbitrix-local-app— файлы{service}/openapi.yamlв репозитории сервиса (см. раздел «OpenAPI»); для Bitrix24 REST фиксируются используемые методы и payload-мэппинг. - Все service-to-service вызовы передают
X-Request-IDи по возможности W3Ctraceparent. - Frontend передаёт
X-Ux-Session-Idво всех JWT-запросах кapi-backend, когда UX-сессия активна (рекомендуется для аналитики и логов; не является auth).session-startиconsentsтребуют JWT. - Все internal API защищаются service token и закрытой Docker/VPC-сетью.
Service tokens (internal API)
Все internal endpoint (/internal/*) доступны только из Docker/VPC-сети и требуют service token. Endpoint не публикуются через nginx (исключение — ops внутри VPC).
| Переменная | Кто проверяет | Кто передаёт | Endpoint | Заголовок |
|---|---|---|---|---|
MESSAGE_SAFETY_SERVICE_TOKEN |
message-safety |
api-backend |
POST/GET /internal/safety/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/bootstrap → POST /api/v1/analytics/session-start (если нужна новая UX-сессия) → чат.
Ответ 200: { "user_id": "uuid", "profile_ready": true }.
Ошибки: 401 (JWT), 403 consents_required (обязательные согласия не accepted: true), 400 (невалидные версии/тело / нет phone claim).
POST /api/v1/consents (повторное принятие)
Не часть OTP-flow. Используется, когда уже есть UserIdentity и нужно зафиксировать новые версии документов (или повторное принятие).
Заголовки: Authorization: Bearer <access_token>, X-Ux-Session-Id (если есть).
{
"consents": {
"personal_data": { "accepted": true, "version": "2026-06-10" },
"user_agreement": { "accepted": true, "version": "2026-06-10" },
"marketing": { "accepted": false, "version": "2026-06-10" }
}
}
api-backend сохраняет согласия с привязкой к user_id из JWT. Пользователь должен уже существовать (bootstrap выполнен), иначе 404 / 409 по контракту модуля. Обязательные согласия без accepted: true → 403 consents_required.
POST /api/v1/analytics/session-start (событие session_start)
Вызывается frontend только при начале новой UX-сессии у авторизованного пользователя (есть валидный JWT и обычно уже выполнен bootstrap). Не вызывается в гостевом режиме.
Заголовки: Authorization: Bearer <access_token>, X-Request-ID (опционально).
Тело:
{
"start_reason": "first_launch",
"device": {
"platform": "web",
"app_version": "1.0.0",
"device_id": "..."
}
}
start_reason— обязательно:first_launch|cold_start|idle_timeout.
Ответ 201:
{
"ux_session_id": "660e8400-e29b-41d4-a716-446655440001",
"started_at": "2026-07-08T12:00:00Z"
}
Frontend сохраняет ux_session_id в памяти и передаёт X-Ux-Session-Id в последующих запросах.
Повторный вызов в рамках той же UX-сессии не требуется (возврат из фона в пределах idle timeout).
Ошибки: 401 без/с невалидным JWT.
Создание диалога
POST /api/v1/dialogs— заголовокIdempotency-Key(обязателен); без заголовка →400 validation_error.- Ответ
201— создан новый активный диалог:{ "dialog_id": "uuid", "status": "open" }. - Ответ
200— у пользователя уже есть активный диалог или повторён тот же idempotent-запрос:{ "dialog_id": "uuid", "status": "open" | "waiting_for_company" | "waiting_for_client" }. - Один активный диалог на пользователя: если уже есть диалог со статусом не
closed, endpoint возвращает его (idempotent), новый не создаёт. - Обязателен перед первым
POST .../messages(включая популярный вопрос после auth), если у клиента ещё нетdialog_id. dialog_id=external_chat_id(см.arch-00-glossary.md).
Idempotency (G1)
| Правило | Значение |
|---|---|
| Заголовок | Idempotency-Key (UUID или opaque string ≤ 128 символов) |
| Endpoint | POST /api/v1/dialogs, POST /api/v1/dialogs/{dialog_id}/messages (и другие mutating POST по OpenAPI) |
| Хранение | Redis (DB /0 api-backend): ключ → ответ / request fingerprint |
| TTL | 24 часа |
| Повтор с тем же ключом и тем же телом | тот же HTTP-ответ, без повторного side-effect |
| Повтор с тем же ключом и другим телом | 409 idempotency_key_reused |
| Отсутствует на обязательном endpoint | 400 validation_error |
Формат исходящего сообщения клиента (MVP)
Имена content_kind, полей — arch-00-glossary.md. Правила:
content_kind |
Тело POST .../messages |
Message.text |
MessageAttachment |
|---|---|---|---|
text |
непустой text; без вложения |
текст | 0 записей |
file |
attachment_id + checksum; text пустой |
пустая строка | ровно 1 запись |
- непустой
textи вложение →400mixed_content_not_allowed(доmessage-safety); - пустое сообщение (нет
textи нетattachment_id) →400empty_message; - более одного вложения →
400too_many_attachments; - файловое сообщение в Bitrix24:
message.files(signed URL),message.textпустой.
Post-MVP: допускается «текст + файлы» отдельной версией API.
DTO чата и profile API MVP
MessageResponse — общий DTO для REST и WS:
{
"message_id": "uuid",
"dialog_id": "uuid",
"sender_type": "client",
"content_kind": "text",
"text": "Здравствуйте",
"attachments": [],
"safety_status": "allowed",
"delivery_status": "delivered",
"created_at": "2026-07-09T12:00:00Z"
}
GET /api/v1/dialogs возвращает { "items": [DialogSummary], "next_cursor": "opaque-or-null" }, сортировка — по updated_at desc. GET /api/v1/dialogs/{dialog_id}/messages?after=<cursor>&limit=50 возвращает { "items": [MessageResponse], "next_cursor": "opaque-or-null" }, сортировка — по created_at asc для удобства append в чате. Cursor opaque; frontend не парсит его.
GET /api/v1/me возвращает блочный профиль:
{
"user_id": "uuid",
"profile": {
"personal_data": {
"full_name": "string-or-null",
"citizenship": "string-or-null",
"russian_phone": "string-or-null",
"foreign_phone": "string-or-null",
"email": "string-or-null"
},
"documents": { "count": 0 }
}
}
POST /api/v1/dialogs/{dialog_id}/messages:
- заголовок
Idempotency-Keyобязателен; - request body для текста:
{ "content_kind": "text", "text": "..." }; - request body для файла:
{ "content_kind": "file", "attachment_id": "uuid", "checksum": "sha256:..." }; - success
201:MessageResponseс финальнымdelivery_status=delivered; - safety deny:
422 message_blocked, при этом запись может сохраняться сsafety_status=blocked,delivery_status=rejected; - dependency error:
503 dependency_unavailableили504 dependency_timeout,delivery_status=failedесли сообщение уже было создано.
Загрузка вложения (MVP)
Байты файла идут напрямую в S3-quarantine по короткоживущему presigned URL. api-backend не проксирует тело файла: выдаёт URL, проверяет результат, управляет lifecycle (promote/delete).
POST .../attachments/init(JWT) →{ attachment_id, upload_url, upload_headers?, expires_at }—upload_url= presigned PUT (или POST policy) в S3-quarantine; ключ объекта и ограничения (bucket, key prefix,Content-Type, max size) задаётapi-backend.- Frontend загружает байты напрямую в Selectel S3 по
upload_url(не черезapi-backend). POST .../attachments/{attachment_id}/completeсchecksum(SHA-256) → api-backend проверяет наличие объекта в quarantine (HeadObject / размер / checksum), фиксирует metadata,scan_status=pending.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).
Подключение:
- Клиент открывает WS с валидным access token.
- Сервер отправляет
{ "type": "connected", "server_time": "ISO8601" }. - Клиент отправляет подписку:
{ "type": "subscribe", "dialog_ids": ["uuid"] }
- Сервер отвечает
{ "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:
- Синхронно вызывает
POST .../check, получает один из трёх кодов. - При
200/403— сразу завершает сценарий и отвечает клиенту. - При
203— не ставит задачу в свою очередь анализа; регулярно и синхронно поллитGET .../tasks/{task_id}до200/403или timeout (MESSAGE_SAFETY_TASK_POLL_MAX_SEC), затем отвечает клиенту. - Решение «проверка быстрая или долгая» — только у
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 запись в одной транзакции после финального safetyallow; delivery_status=acceptedне считается доставкой оператору и может быть виден только как промежуточный статус в логах/recovery;delivery_status=deliveredвыставляется после успешного ответаPOST /internal/openlines/v1/messages;- повтор delivery checkpoint идемпотентен по
message_idи не создаёт дубль в Bitrix24; - если
bitrix-local-appили Bitrix24 недоступны после allow,delivery_status=failed, клиент получает dependency error, а recovery может повторить доставку только если контракт модуля явно разрешает безопасный retry без дубля.
api-backend ↔ bitrix-local-app (Open Lines)
Мнемоника сервиса: openlines. Endpoint Open Lines на стороне bitrix-local-app и приёмник событий на стороне api-backend используют один префикс /internal/openlines/v1/.
| Контракт | Владелец | Потребитель | Назначение | Защита |
|---|---|---|---|---|
POST /internal/openlines/v1/messages |
bitrix-local-app |
api-backend |
Отправка сообщения клиента в Bitrix24 Open Lines | Bearer BITRIX_INTERNAL_API_TOKEN |
GET /internal/openlines/v1/dialogs/{external_chat_id} |
bitrix-local-app |
api-backend |
Получение маппинга dialog_id ↔ bitrix_chat_id |
Bearer token |
GET /internal/openlines/v1/status |
bitrix-local-app |
ops / api-backend |
Статус OAuth и imconnector.status |
Bearer token |
POST /internal/openlines/v1/setup/retry |
bitrix-local-app |
ops | Повтор register/activate/event.bind | Bearer token |
POST /internal/openlines/v1/inbox |
api-backend |
bitrix-local-app |
Forward нормализованных событий оператора | Bearer BITRIX_API_INBOX_TOKEN |
external_chat_id всегда равен dialog_id приложения. bitrix-sync не участвует в hot path чата.
Inbox: входящие сообщения и файлы оператора (G5)
POST /internal/openlines/v1/inbox — нормализованное событие от bitrix-local-app. Минимальный контракт MVP:
{
"event_type": "message.new",
"external_chat_id": "uuid",
"bitrix_message_id": "string",
"occurred_at": "2026-07-09T12:00:00Z",
"message": {
"text": "текст оператора или пустая строка",
"files": [
{
"name": "scan.pdf",
"mime_type": "application/pdf",
"size_bytes": 12345,
"download_url": "https://..."
}
]
}
}
Правила:
event_type:message.new|dialog.closed(и др. по OpenAPI модуля);- idempotency по
(external_chat_id, bitrix_message_id)на сторонеapi-backend; повтор того же события возвращает200/204без повторного side-effect; - если
api-backendнедоступен,bitrix-local-appхранит событие во внутреннем inbox, повторяет forward с exponential backoff и после исчерпания retry переводит запись в DLQ со статусомdead_letter; bitrix-local-appподтверждает доставку в Bitrix24 черезimconnector.send.status.deliveryтолько после успешного ответаapi-backendили после идемпотентного duplicate-ack;- файлы оператора: api-backend скачивает по
download_url(timeout budget) и сохраняет в S3-data attachments +MessageAttachment; в MVP применяются те же продуктовые лимитыchat.attachments.allowed_*иchat.attachments.max_size_mb, что и для клиентских файлов; - сообщения и файлы оператора считаются доверенным Bitrix24-channel для 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-сеть.