diff --git a/architectory/README.md b/architectory/README.md index 99cacda..d581160 100644 --- a/architectory/README.md +++ b/architectory/README.md @@ -8,25 +8,25 @@ | Документ | Содержание | |---|---| -| [`arch-00-glossary.md`](arch-00-glossary.md) | Канонические имена: сущности, поля, id, enum, бакеты S3, env | +| [`arch-00-glossary.md`](arch-00-glossary.md) | Канонические имена и семантика enum/lifecycle: сущности, поля, id, enum, бакеты S3, env | | [`arch-01-system-architecture.md`](arch-01-system-architecture.md) | Общая архитектура: компоненты, сценарии, потоки данных, безопасность | | [`arch-02-api-contracts.md`](arch-02-api-contracts.md) | Реестр API-контрактов, realtime, гостевая сессия, OpenAPI, аудит | | [`arch-03-docker-compose-blueprint.md`](arch-03-docker-compose-blueprint.md) | Требования к Docker Compose, nginx, сетям, TLS и rate limits | -| [`arch-04-settings-and-content.md`](arch-04-settings-and-content.md) | `.env` (infra), таблица `app_settings`, service tokens, типы файлов | +| [`arch-04-settings-and-content.md`](arch-04-settings-and-content.md) | `.env` (infra), таблица `app_settings`, значения service-token переменных, типы файлов | | [`arch-05-agent-development-process.md`](arch-05-agent-development-process.md) | Правила разработки модулей отдельными агентами | ## Как читать 1. Начните с **arch-01** — общая картина и зафиксированные решения MVP. 2. При работе с API — **arch-02**; при деплое — **arch-03**; при настройках — **arch-04**. -3. Спорные **имена** полей, id, enum, бакетов — **arch-00** (не правила и не лимиты). +3. Спорные **имена** полей, id, enum, бакетов и базовая семантика enum/lifecycle — **arch-00**. Лимиты и правила реализации остаются в профильных arch-*. 4. Перед разработкой модуля — **arch-05** и релевантные разделы arch-01/arch-02. ## Приоритет документов При конфликте требований: -1. **arch-00** — только **имена** (поля, id, enum, бакеты, env); не правила и не лимиты. +1. **arch-00** — **имена и базовая семантика** (поля, id, enum, бакеты, env, смысл статусов); не бизнес-лимиты и не детальная реализация. 2. **arch-01** — границы сервисов, сценарии, sync, безопасность. 3. **arch-02** — HTTP-контракты и направление вызовов. 4. **arch-03** — инфраструктура и nginx. @@ -56,6 +56,11 @@ | # | Пробел | Статус | |---|---|---| | G8 | Явный список `is_public=true` для ключей `app_settings` | Отложить до оформления сервисов; seed в модуле `database` | +| G9 | GRANT-модель `bitrix_sync_user` на `han_app`: таблицы, колонки, read/write границы | Уточнить в спецификации `database` и `bitrix-sync` | +| G10 | Полный DTO `GET /api/v1/public/app-config` и мэппинг `setting_key → response field` | Уточнить при оформлении OpenAPI `api-backend` | +| G11 | Версионирование API/WS: deprecation policy, срок поддержки v1, `ws_protocol_version` | Уточнить перед публичным релизом API | +| G12 | Масштабирование realtime: Redis Pub/Sub, sticky sessions, backpressure при нескольких репликах `api-backend` | Post-MVP / перед горизонтальным масштабированием | +| G13 | Contract tests между `api-backend`, `message-safety`, `bitrix-local-app`, `bitrix-sync` | Добавить в DoD модулей после появления OpenAPI | ## Обновление документации diff --git a/architectory/arch-00-glossary.md b/architectory/arch-00-glossary.md index 48aa8fc..20d6b22 100644 --- a/architectory/arch-00-glossary.md +++ b/architectory/arch-00-glossary.md @@ -75,9 +75,12 @@ ### Когда **та же** `UxSession` продолжается - возврат из фона **в пределах** idle timeout (напр. через 5 минут — **без** нового `session_start`); -- успешный OTP или refresh access token; +- успешный refresh access token, если `ux_session_id` уже создан и idle timeout не превышен; +- успешный OTP внутри уже активной UX-сессии, если повторная авторизация не очистила память приложения; - навигация между экранами внутри приложения. +При первом JWT-входе после гостевого режима или после cold start, когда в памяти нет активного `ux_session_id`, frontend создаёт новую UX-сессию через `POST /api/v1/analytics/session-start`. + ## `Dialog.status` | Значение | Смысл | @@ -136,6 +139,7 @@ Realtime-событие `message.status` передаёт актуальные ` | `safety` | `message-safety` | | `openlines` | `bitrix-local-app`, приёмник inbox на `api-backend` | | `sync` | `bitrix-sync` | +| `settings` | internal settings bridge на `api-backend` для Keycloak SPI | ## Bitrix24 Open Lines diff --git a/architectory/arch-01-system-architecture.md b/architectory/arch-01-system-architecture.md index c3e0add..40d432c 100644 --- a/architectory/arch-01-system-architecture.md +++ b/architectory/arch-01-system-architecture.md @@ -26,11 +26,11 @@ HAN Chat - приложение для мигрантов, где стартов ## Пользовательские сценарии 1. Клиент открывает мобильное или web-приложение и видит главный экран с приветствием, популярными вопросами и полем ввода. -2. Frontend определяет, нужна ли **новая UX-сессия**, и при необходимости отправляет событие **`session_start`** (см. «Аналитическая UX-сессия»). Клиент может изучить сервис без авторизации. +2. Frontend определяет, нужна ли **новая UX-сессия**, но до JWT не вызывает backend write-endpoint: клиент может изучить сервис без авторизации через guest UI и `GET /api/v1/public/*`. 3. Если у клиента сохранён **действующий refresh token**, frontend выполняет silent refresh **без OTP** (см. «Поток возврата пользователя»). 4. Клиент нажимает популярный вопрос — frontend подставляет текст вопроса и **инициирует отправку сообщения** (тот же поток, что ручной ввод). Либо клиент вводит свой текст и отправляет. 5. Если клиент не авторизован, перед отправкой первого сообщения frontend показывает pop-up с согласиями и запускает OTP (см. «Поток авторизации»). -6. После успешной авторизации frontend с JWT вызывает **`POST /auth/bootstrap`** (в теле — принятые согласия): api-backend создаёт или находит локального пользователя по `keycloak_sub`, сохраняет согласия на `user_id`, создаёт или обновляет профиль; триггер App DB ставит задачу в `sync_queue` для `bitrix-sync`. Затем при необходимости — `POST /analytics/session-start`. +6. После успешной авторизации frontend с JWT вызывает **`POST /api/v1/auth/bootstrap`** (в теле — принятые согласия): api-backend создаёт или находит локального пользователя по `keycloak_sub`, сохраняет согласия на `user_id`, создаёт или обновляет профиль; триггер App DB ставит задачу в `sync_queue` для `bitrix-sync`. Затем, если у frontend нет активной UX-сессии или она истекла, вызывается **`POST /api/v1/analytics/session-start`**. 7. api-backend выполняет find-or-create диалога (см. «Создание диалога») и отправляет сообщение (текст популярного вопроса или введённый клиентом). 8. Сообщение клиента проходит Message Safety и через Bitrix24 Local App направляется в Битрикс24 Open Lines. 9. Ответ оператора из Битрикс24 Open Lines поступает через Bitrix24 Local App в api-backend и отображается в чате приложения. @@ -223,7 +223,7 @@ Frontend не должен: - OTP-only регистрацию и вход; - OTP по номеру телефона; проверка кода — в Keycloak (заглушка `KEYCLOAK_OTP_MOCK_*` или SMS-провайдер, см. arch-04 и «Поток авторизации»); -- продуктовые лимиты OTP (`otp.phone.*` из `app_settings`) через authenticator/SPI; счётчики попыток — в зоне Keycloak (Redis DB Keycloak/SPI или in-memory Keycloak), **не** в `api-backend`; +- продуктовые лимиты OTP (`otp.phone.*` из `app_settings`) через authenticator/SPI и settings bridge `api-backend` (см. arch-04); счётчики попыток — в зоне Keycloak (Redis DB Keycloak/SPI или in-memory Keycloak), **не** в `api-backend`; - хранение учетных записей; - выдачу и обновление токенов (access + refresh); - настройку realm, clients, roles, policies; @@ -294,7 +294,7 @@ api-backend не решает, sync или async нужна проверка в - UI главного экрана, популярные вопросы и публичный контент — через `GET /api/v1/public/*` (без JWT); - pop-up согласий показывается **до** OTP, но факт принятия хранится **только на клиенте** до получения tokens; -- **`POST /consents`**, **`POST /analytics/session-start`** и остальные write/API чата — **только с JWT**; +- **`POST /api/v1/consents`**, **`POST /api/v1/analytics/session-start`** и остальные write/API чата — **только с JWT**; - опциональный локальный `guest_session_id` (UUID в secure storage) может использоваться frontend для своей аналитики/идемпотентности UI, но **не** является auth и **не** открывает backend write-endpoint. ## Аналитическая UX-сессия (`ux_session_id`) @@ -326,7 +326,7 @@ api-backend не решает, sync или async нужна проверка в 1. Клиент открывает приложение. Пока нет JWT — гостевой UI; `session-start` не вызывается. 2. Frontend проверяет наличие refresh token в secure storage. -3. Если refresh token **действителен** — frontend запрашивает новый access token у Keycloak (Refresh Token Grant), **OTP не показывается**, затем при необходимости `POST /analytics/session-start`. +3. Если refresh token **действителен** — frontend запрашивает новый access token у Keycloak (Refresh Token Grant), **OTP не показывается**, затем при необходимости начала новой UX-сессии вызывает `POST /api/v1/analytics/session-start`. 4. Frontend работает как авторизованный пользователь (история, профиль, чат). 5. Если refresh token **отсутствует или истёк** — клиент остаётся в гостевом режиме до сценария, требующего auth; при первом сообщении — «Поток авторизации» с OTP. @@ -417,7 +417,7 @@ api-backend не решает, sync или async нужна проверка в **Общая ветка вердикта (оба типа):** 5. **`403 deny`**: API удаляет quarantine (если был файл), выставляет `safety_status=blocked`, `delivery_status=rejected`, возвращает клиенту безопасную ошибку; в Bitrix24 ничего не уходит. -6. **`200 allow`**: API переносит файл в S3-data attachments (если был), сохраняет сообщение (`safety_status=allowed`, `delivery_status=delivered`), отправляет в Bitrix24, подтверждает клиенту; `Dialog.status` → `waiting_for_company`. +6. **`200 allow`**: API переносит файл в S3-data attachments (если был), сохраняет сообщение (`safety_status=allowed`, `delivery_status=accepted`) и фиксирует задачу доставки в Open Lines. После успешной отправки через `bitrix-local-app` статус становится `delivery_status=delivered`, API подтверждает клиенту финальный результат; `Dialog.status` → `waiting_for_company`. Если Bitrix24/S3/dependency недоступны после allow, статус становится `delivery_status=failed`, клиент получает безопасную ошибку зависимости. 7. **`203 pending` + `task_id`**: api-backend пишет checkpoint в `safety_tasks` и **регулярно синхронно** вызывает `GET /internal/safety/v1/messages/tasks/{task_id}` (backoff), пока не получит финальный вердикт или не истечёт `MESSAGE_SAFETY_TASK_POLL_MAX_SEC`. Пока идёт poll, **этот** клиентский `POST .../messages` ещё не завершён (соединение ждёт). Параллельные запросы других клиентов **не** блокируются — общей очереди анализа на api-backend нет. - финальный **`200 allow`** → как п. 6, затем ответ клиенту; - финальный **`403 deny`** → как п. 5, затем ответ клиенту; @@ -425,15 +425,22 @@ api-backend не решает, sync или async нужна проверка в Клиент на `POST .../messages` получает **только финальный** результат (или ошибку инфраструктуры), не промежуточное «обрабатывается». +### Надёжность доставки и recovery + +- Для доставки в Bitrix24 используется transactional outbox/checkpoint в App DB: запись `Message` и запись намерения доставки фиксируются атомарно, а повторная отправка в `bitrix-local-app` идемпотентна по `message_id` / `Idempotency-Key`. +- `delivery_status=accepted` означает, что API принял сообщение и завершил safety allow, но ещё не получил подтверждение доставки в Open Lines. `delivery_status=delivered` выставляется только после успешного ответа `bitrix-local-app` о приёме сообщения для Bitrix24 Open Lines. +- Recovery по `han_app.safety_tasks` восстанавливает только сценарии, где Message Safety вернул `203 pending` и клиентский запрос оборвался из-за timeout/crash. Recovery job повторно опрашивает `message-safety` по `task_id`, затем идемпотентно выполняет promote/delete quarantine и обновляет `Message`/`MessageAttachment`. +- Объекты в S3-quarantine не удаляются при timeout safety до финального verdict; orphan-cleanup удаляет только просроченные объекты без активного `safety_tasks` или attachment metadata. + ## Поток работы с чатом: Битрикс24 -> клиент 1. Оператор отвечает клиенту в Битрикс24 Open Lines. 2. Битрикс24 отправляет `ONIMCONNECTOR*` webhook/event в `bitrix-local-app`. 3. `bitrix-local-app` проверяет `application_token`, нормализует payload и сохраняет idempotent inbox. -4. `bitrix-local-app` обогащает событие данными из `dialog_sessions` и forward-ит в API, если `BITRIX_API_FORWARD_URL` включен. +4. `bitrix-local-app` обогащает событие данными из `dialog_sessions` и forward-ит в API, если `BITRIX_API_FORWARD_URL` включен. При недоступности API событие остаётся во внутреннем inbox, повторяется с backoff и после исчерпания retry попадает в DLQ; дубликаты определяются по `(external_chat_id, bitrix_message_id)`. 5. api-backend находит локальный диалог по `external_chat_id` (= `dialog_id`, см. [`arch-00-glossary.md`](arch-00-glossary.md)). 6. api-backend сохраняет входящее сообщение в App DB (`sender_type=company`, `delivery_status=delivered`). Файл оператора (если есть) — в бакет **S3-data attachments** (`han-chat-attachments`) с metadata в `MessageAttachment`; бакет **documents** зарезервирован для документов компании в профиле (post-MVP). По факту сообщения API обновляет `Dialog.status`: входящее от оператора → `waiting_for_client`, исходящее от клиента → `waiting_for_company` (значения — arch-00). -7. `bitrix-local-app` подтверждает доставку в Bitrix24 через `imconnector.send.status.delivery`. +7. `bitrix-local-app` подтверждает доставку в Bitrix24 через `imconnector.send.status.delivery` после успешного сохранения события в api-backend или идемпотентного duplicate-ack. 8. api-backend публикует событие для frontend через **WebSocket** (`WS /api/v1/realtime`). Если realtime недоступен, frontend получает сообщение через polling `GET /api/v1/dialogs/{dialog_id}/messages?after=...`. 9. Frontend отображает сообщение оператора в чате. 10. При получении от `bitrix-local-app` доменного события `dialog.closed` (Bitrix24 `ONIMCONNECTORDIALOGFINISH`) API переводит `Dialog.status` в `closed`. @@ -517,7 +524,7 @@ App DB — **локальный кэш** для UI. Двусторонний syn - Доступ к профилю, диалогам, сообщениям, файлам и документам ограничен текущим `user_id`. - Все запросы, содержащие в себе ссылку на сущность, которая относится к конкретному пользователю (ИД продукта, услуги, чата, документа и тп), проверяются backend_api на соответствие тому пользователю, от которого пришел запрос. - Все публичные id создаются в формате UUID. -- Сервисные API защищаются внутренней сетью Docker/VPC плюс service token (перечень переменных — [`arch-04-settings-and-content.md`](arch-04-settings-and-content.md), «Service tokens (internal API)»). +- Сервисные API защищаются внутренней сетью Docker/VPC плюс service token (канонический контракт — [`arch-02-api-contracts.md`](arch-02-api-contracts.md), «Service tokens (internal API)»; значения переменных — [`arch-04-settings-and-content.md`](arch-04-settings-and-content.md)). - Rate limits применяются минимум на двух уровнях: edge-лимиты в `nginx` и пользовательские лимиты в API с состоянием в Redis. - Входящие сообщения пользователя: синхронный `POST /internal/safety/v1/messages/check` → при `203` api-backend синхронно поллит `task_id` до финального `200`/`403` (или timeout); без очереди анализа на api-backend. - Файлы пользователя до финального `allow` только в S3-quarantine; в S3-data — после `200 allow`. diff --git a/architectory/arch-02-api-contracts.md b/architectory/arch-02-api-contracts.md index 9b6534c..c96a52b 100644 --- a/architectory/arch-02-api-contracts.md +++ b/architectory/arch-02-api-contracts.md @@ -28,6 +28,7 @@ | `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` | Пары значений (должны совпадать): @@ -79,9 +80,35 @@ } ``` +### Каталог публичных ошибок 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 /analytics/session-start`. +Вызывается **один раз** после успешного OTP и получения JWT. Создаёт локального пользователя и **сразу** сохраняет согласия из тела (атомарно в одной транзакции). **Не** создаёт UX-сессию — для этого используется `POST /api/v1/analytics/session-start`. **Заголовки:** `Authorization: Bearer ` — **единственный** источник идентичности пользователя. @@ -113,7 +140,7 @@ } ``` -**Порядок после OTP:** `POST /auth/bootstrap` → `POST /analytics/session-start` → чат. +**Порядок после OTP:** `POST /api/v1/auth/bootstrap` → `POST /api/v1/analytics/session-start` (если нужна новая UX-сессия) → чат. **Ответ `200`:** `{ "user_id": "uuid", "profile_ready": true }`. @@ -175,7 +202,9 @@ Frontend сохраняет `ux_session_id` **в памяти** и переда ### Создание диалога -- `POST /api/v1/dialogs` — заголовок **`Idempotency-Key`** (обязателен); ответ `{ "dialog_id": "uuid", "status": "open" | "waiting_for_company" | "waiting_for_client" }`. +- `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)). @@ -190,6 +219,7 @@ Frontend сохраняет `ux_session_id` **в памяти** и переда | TTL | **24 часа** | | Повтор с тем же ключом и тем же телом | тот же HTTP-ответ, без повторного side-effect | | Повтор с тем же ключом и **другим** телом | **`409`** `idempotency_key_reused` | +| Отсутствует на обязательном endpoint | **`400`** `validation_error` | ### Формат исходящего сообщения клиента (MVP) @@ -207,6 +237,53 @@ Frontend сохраняет `ux_session_id` **в памяти** и переда 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=&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). @@ -270,6 +347,16 @@ Transport: **WebSocket** over HTTPS (`wss://`), JWT в query `?access_token=` и - 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.phone.max_send_attempts_per_24h`, `otp.phone.min_seconds_between_attempts`, cache metadata | internal network + Bearer `KEYCLOAK_SETTINGS_BRIDGE_TOKEN` | + +Ответ не содержит секретов и PII. При недоступности endpoint Keycloak SPI использует последнее валидное cached value; если cache пустой — fail-closed для выдачи OTP. + ## Frontend ↔ Keycloak Keycloak **обязателен** в production-like контуре с первого запуска (OTP, tokens, JWKS). @@ -335,17 +422,34 @@ HTTP-семантика от `message-safety`: `200 allow`, `403 deny`, `203 pen 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`](arch-00-glossary.md)): | HTTP / `message-safety` | `Message.safety_status` | `Message.delivery_status` (после завершения `POST .../messages`) | Финальный для клиента? | |---|---|---|---| -| `200` / `allow` | `allowed` | `delivered` (после успешной отправки в Open Lines) | да | +| `200` / `allow` | `allowed` | `accepted` до вызова Open Lines; `delivered` только после успешной отправки в Open Lines | да | | `403` / `deny` | `blocked` | `rejected` | да | | `203` → затем `200`/`403` | как финальный | как финальный | да (после sync-wait) | | timeout / circuit open | `pending` или `blocked` по политике модуля | `failed` | да (ошибка инфраструктуры) | Circuit breaker + timeout budget (I2): при открытом circuit на `message-safety` — не слать сообщение в Bitrix; вернуть клиенту безопасную ошибку зависимости. +Доставка в Open Lines: + +- api-backend сохраняет `Message` и delivery checkpoint/outbox запись в одной транзакции после финального safety `allow`; +- `delivery_status=accepted` не считается доставкой оператору и может быть виден только как промежуточный статус в логах/recovery; +- `delivery_status=delivered` выставляется после успешного ответа `POST /internal/openlines/v1/messages`; +- повтор delivery checkpoint идемпотентен по `message_id` и не создаёт дубль в Bitrix24; +- если `bitrix-local-app` или Bitrix24 недоступны после allow, `delivery_status=failed`, клиент получает dependency error, а recovery может повторить доставку только если контракт модуля явно разрешает безопасный retry без дубля. + ## api-backend ↔ bitrix-local-app (Open Lines) Мнемоника сервиса: **`openlines`**. Endpoint Open Lines на стороне `bitrix-local-app` и приёмник событий на стороне `api-backend` используют один префикс `/internal/openlines/v1/`. @@ -387,8 +491,11 @@ Circuit breaker + timeout budget (I2): при открытом circuit на `mes Правила: - `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); +- 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`. diff --git a/architectory/arch-03-docker-compose-blueprint.md b/architectory/arch-03-docker-compose-blueprint.md index 31778a7..e087595 100644 --- a/architectory/arch-03-docker-compose-blueprint.md +++ b/architectory/arch-03-docker-compose-blueprint.md @@ -129,6 +129,7 @@ Reverse proxy и единственная публичная точка вход - передает upstream-сервисам `Host`, `X-Real-IP`, `X-Forwarded-For`, `X-Forwarded-Proto`, `X-Forwarded-Host`, `X-Request-ID`; - если входящий запрос **без** `X-Request-ID`, nginx **генерирует** UUID и устанавливает заголовок до proxy_pass (I3); - задает разумные `proxy_connect_timeout`, `proxy_read_timeout`, `client_max_body_size`; +- для `POST /api/v1/dialogs/*/messages` `proxy_read_timeout` должен быть не меньше `MESSAGE_SAFETY_TASK_POLL_MAX_SEC + 30s`, чтобы nginx не обрывал sync-wait при async file scan; - применяет edge rate limits для auth, API и download endpoints; - ограничивает частоту соединений и размер тела запроса; - разрешает только TLS 1.2/1.3 и запрещает слабые шифры; @@ -190,7 +191,7 @@ Python worker/service **двусторонней** синхронизации Ap - не блокирует пользовательский API при ошибках Битрикс24; - не участвует в OTP-flow, не создаёт `UserIdentity`/`ClientProfile`; - **не участвует** в hot path чата Open Lines; -- включается/отключается флагом **`BITRIX_SYNC_ENABLED`** в `.env` (default `true`): при `false` сервис не стартует или работает в no-op (синхронизация с Bitrix24 CRM не выполняется). +- включается/отключается флагом **`BITRIX_SYNC_ENABLED`** в `.env` (default `true`): при `false` сервис стартует в no-op/degraded режиме, но не обрабатывает `sync_queue` и не выполняет синхронизацию с Bitrix24 CRM. ### bitrix-local-app @@ -283,7 +284,7 @@ Identity provider. **Обязателен** в compose-контуре с пер ## Переменные окружения -Корневой `backend/.env` читается всеми сервисами compose через `${VAR}` в сервисных `docker-compose.yml`. Канонический `.env.example`, service tokens, `app_settings` — в [`arch-04-settings-and-content.md`](arch-04-settings-and-content.md). +Корневой `backend/.env` читается всеми сервисами compose через `${VAR}` в сервисных `docker-compose.yml`. Канонический `.env.example` и `app_settings` — в [`arch-04-settings-and-content.md`](arch-04-settings-and-content.md); контракты service tokens — в [`arch-02-api-contracts.md`](arch-02-api-contracts.md). ## HTTPS и TLS @@ -293,7 +294,7 @@ Identity provider. **Обязателен** в compose-контуре с пер | Host | Порт 80 | Порт 443 | Примечание | |---|---|---|---| -| Веб-домен (frontend) | только `301`/`308` → HTTPS | HTTPS, бизнес-логика | MVP: `tohin.ru` / `app.example.ru` | +| Веб-домен (frontend) | только `301`/`308` → HTTPS | HTTPS, бизнес-логика | MVP: `tohin.ru`; staging/dev может использовать отдельный host | | API-домен (если выделен) | **не слушает** | только HTTPS | Post-MVP: `api.example.ru` | | Bitrix callbacks (`/bitrix/*`, `/bitrix/sync/*`) | не обслуживает API; только redirect на том же host | HTTPS | webhook и install URL | @@ -325,7 +326,7 @@ Identity provider. **Обязателен** в compose-контуре с пер Рекомендуемая схема: -- **веб-домен** (MVP: `tohin.ru` или `app.example.ru`): `/api/*` (REST + WS realtime), `/auth/*`, web frontend; `:80` → redirect HTTPS; `:443` — TLS + маршрутизация; +- **веб-домен** (MVP: `tohin.ru`): `/api/*` (REST + WS realtime), `/auth/*`, web frontend; `:80` → redirect HTTPS; `:443` — TLS + маршрутизация; - **выделенный API-домен** (post-MVP, опционально): отдельный `server { listen 443 ssl; ... }` **без** `listen 80`; только `/api/*`; - для `location` WebSocket (`/api/v1/realtime`): `proxy_http_version 1.1`, `Upgrade`/`Connection` headers, увеличенный `proxy_read_timeout`; - домен или path `/bitrix/*` → `bitrix-local-app`; `/bitrix/sync/*` → `bitrix-sync`; @@ -400,13 +401,15 @@ WAF не заменяет обязательные лимиты, валидац Минимальные проверки: - `nginx`: на веб-домене — `301` с `:80` на HTTPS; на API-домене (если выделен) — `:80` не слушает; `:443` — HTTP 200/301 и успешная TLS handshake; -- `api-backend`: HTTP 200 от `/health/ready`; +- `api-backend`: `/health/live` проверяет процесс; `/health/ready` проверяет PostgreSQL `han_app`, Redis `/0` и `/1`, доступность JWKS/discovery Keycloak, S3 permissions для presign/promote и readiness `message-safety`; - `message-safety`: HTTP 200 от `/health/ready` (проверяет PostgreSQL, Redis, workers, read S3-quarantine); -- `bitrix-sync`: HTTP 200 от `/health/live` и `/health/ready` (ready — PostgreSQL + доступ к `sync_queue`); -- `bitrix-local-app`: HTTP 200 от `/health/live`, readiness показывает наличие OAuth-токенов после установки приложения; +- `bitrix-sync`: `/health/live` проверяет процесс; `/health/ready` проверяет PostgreSQL, доступ к `sync_queue`, worker state и CRM webhook config; при `BITRIX_SYNC_ENABLED=false` ready возвращает degraded/not-ready с причиной `sync_disabled`; +- `bitrix-local-app`: `/health/live` проверяет процесс; `/health/ready` показывает PostgreSQL, OAuth-токены после установки приложения, connector activation и возможность forward в API при включённом `BITRIX_API_FORWARD_URL`; - `keycloak`: health endpoint Keycloak; readiness — подключение к managed PostgreSQL; - `redis`: `redis-cli ping`; +Наружу через `nginx` публикуются только health endpoint, которые нужны Bitrix24 install/callback validation или внешнему мониторингу. Internal services (`message-safety`, internal `bitrix-sync`, Redis, otel) проверяются только из Docker/VPC-сети. + ## Порядок запуска 1. `redis` (managed PostgreSQL должна быть доступна до старта зависимых сервисов). @@ -449,3 +452,10 @@ Docker Compose на одной VM — production-контур первого э - backup и restore; - централизованный мониторинг; - горизонтальное масштабирование API и worker. + +### Backup, restore и cleanup + +- Managed PostgreSQL должен иметь ежедневные backups и PITR; целевые RPO/RTO для MVP фиксируются в ops runbook до production-запуска. +- S3-data (`attachments`, `documents`) хранит production-файлы; удаление выполняется только через lifecycle, retention или явный audit-backed процесс. +- S3-quarantine очищается периодическим cleanup job: удаляются просроченные объекты без активного `MessageAttachment`/`safety_tasks` или объекты с завершённым deny/failed lifecycle. +- Redis не является единственным хранилищем бизнес-событий; потеря Redis не должна терять сообщения, sync tasks или audit. diff --git a/architectory/arch-04-settings-and-content.md b/architectory/arch-04-settings-and-content.md index cc8ada6..e07b623 100644 --- a/architectory/arch-04-settings-and-content.md +++ b/architectory/arch-04-settings-and-content.md @@ -85,7 +85,7 @@ Managed PostgreSQL **поднимается до** развёртывания п | Группа | Ключи | |---|---| | Auth | `auth.phone.enabled`, `auth.password.enabled` | -| OTP (продукт; потребитель — Keycloak SPI, не api-backend) | `otp.phone.max_send_attempts_per_24h`, `otp.phone.min_seconds_between_attempts` | +| OTP (продукт; потребитель — Keycloak SPI через settings bridge api-backend) | `otp.phone.max_send_attempts_per_24h`, `otp.phone.min_seconds_between_attempts` | | Оператор | `operator.call.phone` | | Consent | `consent.personal_data.*`, `consent.user_agreement.*`, `consent.marketing.*` | | Файлы чата | `chat.attachments.*` | @@ -120,6 +120,7 @@ chat.attachments.max_size_mb=5 chat.attachments.storage=selectel_s3 chat.attachments.upload_mode=presigned_put chat.attachments.safety_scan_required=true +chat.attachments.presigned_upload_ttl_seconds=600 rate_limit.message_send.per_user=30/minute rate_limit.message_send.per_dialog=20/minute @@ -129,7 +130,7 @@ rate_limit.login.per_ip=10/minute ux.session.idle_timeout_minutes=30 -security.cors.allowed_origins=https://tohin.ru,https://app.example.ru +security.cors.allowed_origins=https://tohin.ru security.public_cache.max_age_seconds=3600 ``` @@ -165,9 +166,9 @@ KC_DB_URL_PROPERTIES=currentSchema=keycloak # ============================================================================= # Публичные URL (HTTPS) # ============================================================================= -PUBLIC_WEB_URL=https://app.example.ru -PUBLIC_API_URL=https://app.example.ru/api -PUBLIC_AUTH_URL=https://app.example.ru/auth +PUBLIC_WEB_URL=https://tohin.ru +PUBLIC_API_URL=https://tohin.ru/api +PUBLIC_AUTH_URL=https://tohin.ru/auth # ============================================================================= # nginx (edge, TLS, rate limits) @@ -188,7 +189,7 @@ NGINX_RATE_LIMIT_POLLING=60r/m # ============================================================================= # Keycloak (infra; OTP-заглушка — dev/MVP) # ============================================================================= -KEYCLOAK_PUBLIC_URL=https://app.example.ru/auth +KEYCLOAK_PUBLIC_URL=https://tohin.ru/auth KEYCLOAK_INTERNAL_URL=http://keycloak:8080 KEYCLOAK_REALM=han-chat KEYCLOAK_AUDIENCE=han-chat-api @@ -214,6 +215,7 @@ BITRIX_API_INBOX_TOKEN=change-me BITRIX_INTERNAL_API_TOKEN=change-me BITRIX_API_FORWARD_TOKEN=change-me BITRIX_SYNC_SERVICE_TOKEN=change-me +KEYCLOAK_SETTINGS_BRIDGE_TOKEN=change-me # ============================================================================= # api-backend (интеграции + resilience I2) @@ -258,6 +260,7 @@ BITRIX_APPLICATION_TOKEN=change-me MESSAGE_SAFETY_POST_TIMEOUT_SEC=5 MESSAGE_SAFETY_TASK_POLL_INTERVAL_SEC=2 MESSAGE_SAFETY_TASK_POLL_MAX_SEC=300 +MESSAGE_SAFETY_FILE_SCAN_TIMEOUT_SEC=60 MESSAGE_SAFETY_RULES_VERSION=2026-01-01 # ============================================================================= @@ -301,7 +304,22 @@ OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317 | Значение | Поведение | |---|---| | `true` (default) | `bitrix-sync` обрабатывает `sync_queue` и принимает CRM webhook | -| `false` | синхронизация с Bitrix24 CRM не выполняется (сервис не стартует или no-op); чат Open Lines через `bitrix-local-app` **не** затрагивается | +| `false` | синхронизация с Bitrix24 CRM не выполняется; сервис стартует в no-op/degraded режиме; чат Open Lines через `bitrix-local-app` **не** затрагивается | + +В MVP выбран режим **no-op service**: контейнер `bitrix-sync` стартует, `/health/live` отвечает успешно, `/health/ready` возвращает degraded/not-ready с явной причиной `sync_disabled`, worker не обрабатывает `sync_queue`, webhook CRM возвращает безопасный `503` или `202 ignored` по контракту модуля. Это сохраняет единый compose-контур и не влияет на чат Open Lines. + +## Keycloak settings bridge для OTP + +Product limits OTP (`otp.phone.*`) хранятся в `app_settings`, но Keycloak не получает прямой доступ к схеме `han_app`. + +MVP-механизм: + +1. `api-backend` читает публичные/служебные настройки из `app_settings` и кэширует их. +2. Для Keycloak SPI доступен internal endpoint `GET /internal/settings/v1/otp` в Docker/VPC-сети, защищённый service token. +3. Keycloak SPI читает `otp.phone.max_send_attempts_per_24h` и `otp.phone.min_seconds_between_attempts` через этот endpoint с локальным cache TTL. +4. При недоступности settings bridge SPI использует последнее валидное cache-значение; если cache пустой — fail-closed и не выдаёт OTP. + +Счётчики попыток OTP остаются в зоне Keycloak/SPI, не в `api-backend`. ## Разрешённые типы файлов чата (MVP) diff --git a/architectory/arch-05-agent-development-process.md b/architectory/arch-05-agent-development-process.md index 428fb0e..639e842 100644 --- a/architectory/arch-05-agent-development-process.md +++ b/architectory/arch-05-agent-development-process.md @@ -72,13 +72,17 @@ Raw OTP запрещено хранить в открытом виде: это - идемпотентность обработки задач очереди; - поведение при повторной доставке webhook; -- таймауты и retry/backoff.## Definition of Done +- таймауты и retry/backoff. + +## Definition of Done Модуль считается готовым, если: - реализованы сценарии из задачи; - обновлен `{service}/openapi.yaml`, если менялся HTTP API; +- обновлены каталог ошибок в `arch-02` и contract tests, если менялась публичная или internal HTTP-семантика; - созданы миграции, если менялась БД; +- обновлены seed `app_settings` и `.env.example`, если добавлялись настройки, service tokens, лимиты или feature flags; - добавлены тесты; - сервис запускается в Docker Compose; - все изменяемые параметры вынесены из кода;