From ea8bb6181a2db94a97bfe00c1b75d3f2add6af2b Mon Sep 17 00:00:00 2001 From: mi Date: Thu, 9 Jul 2026 11:03:44 +0300 Subject: [PATCH] Initial commit --- HAN_chat_specification.rar | Bin 0 -> 53652 bytes architectory/README.md | 56 ++ architectory/arch-00-glossary.md | 123 ++++ architectory/arch-01-system-architecture.md | 566 ++++++++++++++++++ architectory/arch-02-api-contracts.md | 395 ++++++++++++ .../arch-03-docker-compose-blueprint.md | 445 ++++++++++++++ architectory/arch-04-settings-and-content.md | 322 ++++++++++ .../arch-05-agent-development-process.md | 96 +++ 8 files changed, 2003 insertions(+) create mode 100644 HAN_chat_specification.rar create mode 100644 architectory/README.md create mode 100644 architectory/arch-00-glossary.md create mode 100644 architectory/arch-01-system-architecture.md create mode 100644 architectory/arch-02-api-contracts.md create mode 100644 architectory/arch-03-docker-compose-blueprint.md create mode 100644 architectory/arch-04-settings-and-content.md create mode 100644 architectory/arch-05-agent-development-process.md diff --git a/HAN_chat_specification.rar b/HAN_chat_specification.rar new file mode 100644 index 0000000000000000000000000000000000000000..e7f4c63a22699f69eb6345ade242c0e314f96f79 GIT binary patch literal 53652 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 ayzVlcKPq@_DhmIT<-s0ctWQWxg!dm#S73<% literal 0 HcmV?d00001 diff --git a/architectory/README.md b/architectory/README.md new file mode 100644 index 0000000..fc86e0c --- /dev/null +++ b/architectory/README.md @@ -0,0 +1,56 @@ +# Архитектура HAN Chat + +Канонический набор архитектурных документов проекта. Описывает границы системы, интеграции, контракты API, инфраструктуру, настройки и процесс разработки. + +Детальная **схема таблиц App DB**, **правила проверки файлов** (`file_rules` и др.) и **OpenAPI-файлы** — зона ответственности соответствующих модулей; архитектура задаёт только границы, контракты и общие правила. + +## Состав документов + +| Документ | Содержание | +|---|---| +| [`arch-00-glossary.md`](arch-00-glossary.md) | Канонические имена: сущности, поля, 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-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** (не правила и не лимиты). +4. Перед разработкой модуля — **arch-05** и релевантные разделы arch-01/arch-02. + +## Приоритет документов + +При конфликте требований: + +1. **arch-00** — только **имена** (поля, id, enum, бакеты, env); не правила и не лимиты. +2. **arch-01** — границы сервисов, сценарии, sync, безопасность. +3. **arch-02** — HTTP-контракты и направление вызовов. +4. **arch-03** — инфраструктура и nginx. +5. **arch-04** — env, `app_settings`, публичные DTO. +6. **arch-05** — процесс разработки. + +Профильные спецификации модулей уточняют реализацию внутри этих границ. Если границы не позволяют эффективно реализовать модуль, то агент, разрабатывающий модуль, может предложить внести изменения в архитектуру. + +## Разрешение конфликтов + +- Имена полей, бакетов, статусов → **arch-00**, затем синхронизация arch-*. +- Endpoint или auth → **arch-02**, при необходимости arch-01/arch-03. +- Новая интеграция → сначала **arch-02**. +- Compose, nginx, TLS → **arch-03**. + +## В бэклоге (не MVP) + +| Тема | Где зафиксировано | +|---|---| +| Доставка документов компании из Bitrix24 в приложение (`bitrix-sync` → `api-backend`, уведомление клиента) | [`!Backlog.md`](../../HAN_chat/!Backlog.md), п. 9; arch-01 — заглушка UI «Документы» | +| Интеграция с SMS-провайдерами (отправка OTP, отключение `KEYCLOAK_OTP_MOCK_*`) | [`!Backlog.md`](../../HAN_chat/!Backlog.md), п. 10 | + +## Обновление документации + +- Изменение MVP → arch-01 + arch-02 (+ arch-03/arch-04 при необходимости). +- Новый env или ключ `app_settings` → arch-04. +- Новый термин → arch-00, затем поиск по arch-*. diff --git a/architectory/arch-00-glossary.md b/architectory/arch-00-glossary.md new file mode 100644 index 0000000..f80d331 --- /dev/null +++ b/architectory/arch-00-glossary.md @@ -0,0 +1,123 @@ +# arch-00. Глоссарий и единый словарь терминов + +## Назначение + +Канонические **имена** сущностей, полей, идентификаторов, enum-значений, бакетов S3, env-переменных и терминов проекта. + +При расхождении имён приоритет у настоящего словаря. + +## Bucket Selectel S3 + +| Логическое имя | env-переменная | Пример физического бакета | +|---|---|---| +| **S3-quarantine** | `SELECTEL_S3_BUCKET_QUARANTINE` | `han-chat-quarantine` | +| **S3-data** (attachments) | `SELECTEL_S3_BUCKET_ATTACHMENTS` | `han-chat-attachments` | +| **S3-data** (documents) | `SELECTEL_S3_BUCKET_DOCUMENTS` | `han-chat-documents` | + +В тексте: **S3-quarantine** — временное хранилище до вердикта Message Safety; **S3-data** — проверенные файлы (attachments и documents — два физических бакета). + +## Сущности App DB (основные) + +| Имя | Схема | Назначение (кратко) | +|---|---|---| +| `UserIdentity` | `han_app` | Локальный пользователь, связь с Keycloak | +| `UxSession` | `han_app` | Аналитическая UX-сессия (период активности пользователя) | +| `UserConsent` | `han_app` | Запись о принятии согласий (до OTP) | +| `ClientProfile` | `han_app` | Кэш профиля для UI | +| `Dialog` | `han_app` | Диалог клиента с Open Lines | +| `Message` | `han_app` | Сообщение в диалоге | +| `MessageAttachment` | `han_app` | Вложение к сообщению | +| `sync_queue` | `han_app` | Очередь sync App → Bitrix24 | +| `entity_external_mapping` | `han_app` | Маппинг App entity ↔ Bitrix entity | +| `app_settings` | `han_app` | Бизнес-настройки | +| `text_resources` | `han_app` | Тексты UI по мнемоникам | +| `popular_questions` | `han_app` | Популярные вопросы главного экрана | +| `dialog_sessions` | `bitrix_local` | Маппинг чата Open Lines | + +## Идентификаторы + +| Имя | Где используется | +|---|---| +| `dialog_id` | UUID диалога в приложении; **равен** `external_chat_id` в Open Lines | +| `external_chat_id` | Идентификатор чата для `bitrix-local-app` / `imconnector` | +| `keycloak_sub` | Subject JWT Keycloak; ключ `UserIdentity` | +| `guest_session_id` | UUID гостевой сессии до OTP | +| `ux_session_id` | UUID **аналитической UX-сессии**; заголовок `X-Ux-Session-Id` | +| `bitrix_contact_id` | ID Contact в Bitrix24 CRM | +| `bitrix_chat_id` | ID чата Open Lines в Bitrix24 | +| `session_id` | ID сессии Open Lines (поле `dialog_sessions`; не путать с `ux_session_id`) | +| `task_id` | ID async-проверки Message Safety | +| `request_id` | Корреляция HTTP-запроса (заголовок `X-Request-ID`) | + +Публичные id сущностей — **UUID**. + + +## `UxSession` (аналитическая UX-сессия) + +### Определение + +**`UxSession`** — период **непрерывной активности** пользователя в приложении (web / iOS / Android) для **аналитики** и **сквозной корреляции** логов и событий. + +- Идентификатор периода — **`ux_session_id`** (UUID). +- Начало периода фиксируется событием **`session_start`** (**ровно один раз** на период). +- Запись создаётся в App DB при `POST /api/v1/analytics/session-start` (см. arch-02). +- Frontend передаёт **`X-Ux-Session-Id`** во всех запросах к backend, пока сессия активна. + +**`UxSession` не является механизмом авторизации.** Отсутствие или неизвестный `ux_session_id` **не** блокирует API (кроме endpoint, где id обязателен по контракту, напр. `POST /api/v1/consents`). + +### Когда начинается **новая** `UxSession` +Новый **`ux_session_id`** + событие **`session_start`** — **только** если: +1. **`first_launch`** — приложение открыто, в памяти **нет** `ux_session_id`. +2. **`cold_start`** — после kill app или закрытия вкладки браузера (память очищена). +3. **`idle_timeout`** — возврат спустя **более N минут** (`ux.session.idle_timeout_minutes` в `app_settings`, default **30**). + +### Когда **та же** `UxSession` продолжается +- возврат из фона **в пределах** idle timeout (напр. через 5 минут — **без** нового `session_start`); +- успешный OTP или refresh access token; +- навигация между экранами внутри приложения. + +## `Message` — enum и поля + +| Имя | Допустимые значения | +|---|---| +| `Message.sender_type` | `client`, `company` | +| `Message.safety_status` | `pending`, `allowed`, `blocked` (`needs_review` — зарезервирован, MVP не используется) | +| `Message.text` | текст сообщения; пустая строка для файлового сообщения | +| `content_kind` (логическое) | `text`, `file` — тип исходящего сообщения клиента (MVP) | + +Семантика `allow` / `deny` / `pending` в `message-safety` и HTTP-коды — [`arch-02-api-contracts.md`](arch-02-api-contracts.md). + +## `MessageAttachment.scan_status` + +| Значение | Смысл | +|---|---| +| `pending` | Файл в S3-quarantine, проверка не завершена | +| `clean` | Проверка завершена, allow | +| `infected` | Проверка завершена, deny | +| `failed` | Ошибка инфраструктуры проверки | + +## Мнемоники internal API + +Префикс: **`/internal/{service_mnemonic}/v1/`**. Health: **`/health/*`**. + +| `{service_mnemonic}` | Сервис | +|---|---| +| `safety` | `message-safety` | +| `openlines` | `bitrix-local-app`, приёмник inbox на `api-backend` | +| `sync` | `bitrix-sync` | + +## Bitrix24 Open Lines + +| Имя | Значение | +|---|---| +| `BITRIX_CONNECTOR_ID` / connector | `han_mobile_app` | +| `BITRIX_OPEN_LINE_ID` | `8` | +| Канонический URL коннектора | `https://han0107.bitrix24.ru/contact_center/connector/?ID=han_mobile_app&LINE=8` | + +## Термины чата + +| Термин | `Message.sender_type` / направление | +|---|---| +| клиент | `client`; исходящее сообщение | +| оператор | `company`; входящее сообщение | +| сообщение пользователя | исходящее; проверяет Message Safety | diff --git a/architectory/arch-01-system-architecture.md b/architectory/arch-01-system-architecture.md new file mode 100644 index 0000000..9812124 --- /dev/null +++ b/architectory/arch-01-system-architecture.md @@ -0,0 +1,566 @@ +# arch-01. Общая архитектура системы + +> Термины — в [`arch-00-glossary.md`](arch-00-glossary.md). +> Приоритет документов — в [`README.md`](README.md). + +## Назначение + +HAN Chat - приложение для мигрантов, где стартовый экран знакомит клиента с сервисом и предлагает задать вопрос. Авторизация не требуется при первом входе: она запрашивается при попытке отправить первое сообщение, потому что в переписке могут обрабатываться персональные данные. + +## Зафиксированные решения MVP + +- Авторизация: только OTP по **номеру телефона** (email-канал в MVP не используется). +- Вторая сторона чата: Битрикс24 Open Lines. +- Master source auth-данных: Keycloak; профиль в UI — кэш App DB с двусторонней sync через `bitrix-sync`. +- Диалог приложения соответствует диалогу в Битрикс24 Open Lines. +- Лиды и сделки в MVP не используются. +- Файлы production-хранилища: Selectel S3, бакет **S3-data** (логическое имя; физически два бакета — `han-chat-attachments` для файлов чата и `han-chat-documents` для документов компании). +- Файлы до проверки: Selectel S3, бакет **S3-quarantine**; после `200 allow` — перенос в S3-data (attachments). Имена бакетов — [`arch-00-glossary.md`](arch-00-glossary.md); права доступа — ниже и в «Принципы безопасности». +- Мультиязычность в первом релизе не нужна, но тексты должны храниться по мнемоникам для будущих переводов. +- Среда на первом этапе одна и проектируется как боевая. +- Вложения чата MVP: **только изображения и PDF** — см. [`arch-04-settings-and-content.md`](arch-04-settings-and-content.md), «Разрешённые типы файлов чата». +- SMS OTP на старте: **заглушка** — пользователь вводит фиксированный код из `.env` (`KEYCLOAK_OTP_MOCK_CODE`); SMS не отправляется. Интеграция с SMS-провайдерами — в бэклоге (см. [`!Backlog.md`](../../HAN_chat/!Backlog.md)). +- Популярный вопрос при выборе **автоматически отправляется как сообщение**; если пользователь не авторизован — сначала согласия и OTP, затем отправка. +- Перечень таблиц и миграций App DB проектирует модуль `database` (и владельцы схем других сервисов); arch фиксирует только **разделение схем** PostgreSQL и контракты между сервисами. + +## Пользовательские сценарии + +1. Клиент открывает мобильное или web-приложение и видит главный экран с приветствием, популярными вопросами и полем ввода. +2. Frontend определяет, нужна ли **новая UX-сессия**, и при необходимости отправляет событие **`session_start`** (см. «Аналитическая UX-сессия»). Клиент может изучить сервис без авторизации. +3. Если у клиента сохранён **действующий refresh token**, frontend выполняет silent refresh **без OTP** (см. «Поток возврата пользователя»). +4. Клиент нажимает популярный вопрос — frontend подставляет текст вопроса и **инициирует отправку сообщения** (тот же поток, что ручной ввод). Либо клиент вводит свой текст и отправляет. +5. Если клиент не авторизован, перед отправкой первого сообщения frontend показывает pop-up с согласиями и запускает OTP (см. «Поток авторизации»). +6. После успешной авторизации api-backend создаёт или находит локального пользователя по `keycloak_sub`, связывает ранее сохранённые согласия с `guest_session_id`, создаёт или обновляет профиль; триггер App DB ставит задачу в `sync_queue` для `bitrix-sync`. +7. api-backend выполняет find-or-create диалога (см. «Создание диалога») и отправляет сообщение (текст популярного вопроса или введённый клиентом). +8. Сообщение клиента проходит Message Safety и через Bitrix24 Local App направляется в Битрикс24 Open Lines. +9. Ответ оператора из Битрикс24 Open Lines поступает через Bitrix24 Local App в api-backend и отображается в чате приложения. +10. Клиент может открыть историю диалогов. +11. Клиент может открыть профиль, где данные структурированы блоками: «Личные данные» и «Документы». В дальнейшем могут добавляться новые блоки. +12. Редактирование профиля из профиля недоступно. Для изменения данных клиент переходит в чат и пишет запрос оператору. + +## Компоненты верхнего уровня + +- Expo App: единая frontend-кодовая база для iOS, Android и web. +- Keycloak: identity provider, OTP-only авторизация по номеру телефона. +- api-backend: Python-приложение с REST API, realtime-доставкой сообщений и бизнес-логикой. +- Nginx Reverse Proxy: единая публичная точка входа, HTTPS termination и маршрутизация на Keycloak/API/frontend web/Bitrix24. +- Message Safety Service: отдельный сервис проверки входящих сообщений; синхронный вызов из API → `200 allow` | `403 deny` | `203 pending` + `task_id`. +- Bitrix24 Local App: локальное приложение, custom connector `han_mobile_app` для Bitrix24 Open Lines: чат, OAuth, webhook-события, маппинг `dialog_id` ↔ `bitrix_chat_id`. +- Bitrix24 sync service: двусторонняя синхронизация App DB ↔ Битрикс24 CRM (Contact на MVP; маппинг ID, очередь через триггеры, webhook от роботов Bitrix24). +- Managed PostgreSQL (приватная сеть, одна база): схемы `han_app`, `bitrix_sync`, `bitrix_local`, `keycloak`, `message_safety` — отдельный DB-user на схему. +- Redis: rate limits, временные счетчики OTP и realtime/service coordination. +- S3-data: production-хранилище проверенных файлов чата (`han-chat-attachments`) и документов компании (`han-chat-documents`). +- S3-quarantine: временное хранилище загруженных файлов до вердикта Message Safety Service (`han-chat-quarantine`); read-only для `message-safety`. +- observability: JSON-логи в stdout, `request_id`, `trace_id`, **`ux_session_id`** (если передан), базовая трассировка через OpenTelemetry Collector. + +## Инфраструктура развёртывания (зафиксировано) + +На первом этапе весь backend-контур работает на **одной VM** в облаке провайдера: + +- `nginx`, `api-backend`, `message-safety`, `keycloak`, `bitrix-sync`, `bitrix-local-app`, `redis`, `otel-collector` — в Docker Compose на VM; +- публичный доступ из интернета только через `nginx` (порты 80/443); +- внутренние сервисы общаются по Docker-сети на localhost VM. + +Базы данных — **managed PostgreSQL** того же провайдера в **том же облачном кластере/VPC**, **без публичного доступа** из интернета. VM подключается к БД только по приватной сети. + +Схема данных в managed PostgreSQL (перечень таблиц внутри схем — в модульных спецификациях, не в arch-*): + +| База / схема | Сервисы | Назначение схемы | +|---|---|---| +| одна база / `han_app` | `api-backend`, `bitrix-sync` (ограниченный GRANT) | прикладные данные приложения, очередь sync, audit | +| одна база / `bitrix_sync` | `bitrix-sync` | worker state, retry/dead letter, sync audit | +| одна база / `message_safety` | `message-safety` | verdict cache, safety_task, rule config | +| одна база / `bitrix_local` | `bitrix-local-app` | OAuth, inbox, `dialog_sessions` | +| одна база / `keycloak` | Keycloak | учётные записи, realm, сессии IdP | + +Redis на первом этапе остаётся на VM в Docker (ephemeral/coordination). Selectel S3 — внешнее object storage: три бакета (`han-chat-quarantine`, `han-chat-attachments`, `han-chat-documents`); см. [`arch-00-glossary.md`](arch-00-glossary.md). + +## Контекстная схема + +```mermaid +flowchart LR + Client[Expo Mobile/Web App] + Nginx[Nginx Reverse Proxy] + Keycloak[Keycloak OTP] + API[Python api-backend] + Safety[Message Safety Service] + DB[(PostgreSQL)] + Redis[(Redis)] + Sync[Bitrix24 sync service] + LocalApp[Bitrix24 Local App] + Bitrix[Bitrix24 CRM] + S3Data[(S3-data: attachments + documents)] + S3Q[(S3-quarantine)] + Obs[observability] + + Client -->|HTTPS REST + Realtime| Nginx + Nginx -->|/auth| Keycloak + Nginx -->|/api + /realtime| API + Keycloak --> DB + API --> DB + API --> Redis + API -->|upload / move / delete| S3Q + API -->|promote delivered files| S3Data + API -->|internal check message| Safety + Safety --> DB + Safety --> Redis + Safety -->|read scan| S3Q + API -->|send messages| LocalApp + API -->|App DB writes| DB + Sync -->|sync_queue + profile| DB + Sync -->|CRM Contact REST| Bitrix + Bitrix -->|robot webhook| Sync + Bitrix -->|ONIMCONNECTOR*| LocalApp + LocalApp -->|imconnector.send.messages/status| Bitrix + LocalApp -->|normalized inbox events| API + API -->|WebSocket/SSE or polling fallback| Client + API --> Obs + Safety --> Obs + Sync --> Obs + LocalApp --> Obs + LocalApp --> DB +``` + +## Архитектурные границы + +### Frontend + +Отвечает за: + +- стартовый экран с приветствием, популярными вопросами, полем ввода, историей и профилем; +- гостевой режим до первого сообщения; +- показ pop-up с обязательными согласиями на обработку персональных данных и пользовательское соглашение, а также необязательным согласием на рекламные коммуникации; +- сбор данных устройства для передачи в backend; +- **управление аналитической UX-сессией** на клиенте: определение начала нового периода активности, хранение `ux_session_id` и `last_activity_at` **только в памяти**, отправка `session_start`, заголовок `X-Ux-Session-Id` во всех запросах; +- хранение access token и refresh token в безопасном хранилище после авторизации; +- **жизненный цикл access token**: проактивное обновление по расписанию (до истечения `exp`) и обработка **`401`** от `api-backend` (см. «Обновление access token (frontend)»); +- при открытии приложения: проверку refresh token → silent refresh через Keycloak **или** OTP-flow при истечении refresh token; +- отображение входящих сообщений от оператора; +- загрузку файлов в чат через backend; +- работу с текстовыми мнемониками; +- отправку `traceparent`/correlation id в backend. + +Frontend не должен: + +- хранить бизнес-логику синхронизации с Битрикс24; +- принимать решения о доступе к чужим документам или диалогам; +- обращаться напрямую к Битрикс24, Selectel S3 или базе данных. + +### api-backend + +Отвечает за: + +- публичные настройки приложения для frontend; +- проверку JWT от Keycloak для защищенных операций; при истёкшем или невалидном access token — **`401`** (refresh выполняет frontend, не backend); +- локальную регистрацию пользователя приложения: `find-or-create` `UserIdentity` по `keycloak_sub`, создание минимального `ClientProfile` для нового пользователя, обновление `last_login_at` для существующего (после OTP — см. `POST /api/v1/auth/bootstrap`); +- **приём события `session_start`**: запись `UxSession`, audit/analytics-событие; **не** используется для контроля доступа; +- валидация данных получаемых от frontend (соответствие типов данных, проверка обязательности полей, проверка формата данных, диапазоны значений, размер полей) через Pydantic +- хранение согласий пользователя в App DB (`guest_session_id`, **`ux_session_id`**, **`client_ip`**, версии документов); +- профиль, структурированный блоками; +- API чата, истории, файлов и документов; +- realtime-доставку входящих сообщений клиенту; +- отправку сообщений клиента в Open Lines через Bitrix24 Local App; +- прием нормализованных входящих событий Open Lines от Bitrix24 Local App; +- хранение истории диалогов; +- запись данных профиля в App DB (синхронизация с Bitrix24 — триггеры → `sync_queue` → `bitrix-sync`, без участия api-backend); +- загрузку файлов из чата в S3-quarantine до проверки; +- синхронный вызов Message Safety Service (`POST /internal/safety/v1/messages/check`) и интерпретацию ответа: `200 allow`, `403 deny`, `203 pending` + `task_id`; +- при `200`: перенос файлов quarantine → S3-data, сохранение сообщения, отправка в Bitrix24; +- при `403`: удаление файлов из quarantine, безопасный ответ клиенту; +- при `203`: сохранение сообщения со статусом ожидания проверки, ответ клиенту «обрабатывается», опрос `GET /internal/safety/v1/messages/tasks/{task_id}` и доставка цепочки после финального `200` или cleanup после `403`; +- auth-aware rate limits для сообщений, пользовательских и сервисных операций; +- аудит пользовательских действий; +- единые ошибки и валидацию входных данных. + +### Bitrix24 Local App + +Отвечает за Open Lines (чат): + +- регистрацию локального приложения Bitrix24; +- OAuth lifecycle Bitrix24 и хранение токенов портала; +- регистрацию и активацию custom connector `han_mobile_app` для открытой линии 8; +- прием публичных событий Bitrix24 `ONIMCONNECTOR*` на `/bitrix/handler`; +- нормализацию событий Open Lines в доменные события HAN; +- хранение `dialog_sessions`: связка `external_chat_id` (=`dialog_id` приложения) ↔ `bitrix_chat_id` ↔ `session_id`; +- хранение локального inbox до готовности API; +- internal API для api-backend: `POST /internal/openlines/v1/messages`, `GET /internal/openlines/v1/dialogs/{external_chat_id}`; +- forward нормализованных событий оператора в API (`BITRIX_API_FORWARD_URL`); +- вызовы `imconnector.send.messages` и `imconnector.send.status.delivery`. + +Не отвечает за: + +- CRM Contact mapping и синхронизацию прочих CRM-сущностей; +- сохранение сообщений и истории чата в App DB; +- realtime-доставку в Expo App; +- бизнес-логику профиля и документов. + +### Bitrix24 sync service + +Отвечает за **двустороннюю** синхронизацию данных между App DB и Битрикс24 CRM: + +- **маппинг ID** сущностей приложения ↔ Bitrix24 (`bitrix_contact_id`, `entity_external_mapping`); +- **App DB → Bitrix24:** обработка очереди `sync_queue` (триггеры App DB) — map/create Contact по телефону, push обновлений полей; +- **Bitrix24 → App DB:** приём webhook от роботов Bitrix24, обновление профиля с GUC `han.sync_suppress`; +- реестр синхронизируемых сущностей (MVP: Contact; post-MVP: Lead, Deal, Document); +- повторные попытки, rate limiting Bitrix REST, dead letter; +- прямой доступ к схеме `han_app` и собственной `bitrix_sync`. + +Не отвечает за: + +- hot path чата Open Lines; +- OAuth lifecycle локального приложения Bitrix24; +- создание `UserIdentity` / `ClientProfile` в auth-flow; +- хранение `dialog_sessions`. + +### Keycloak + +Отвечает за: + +- OTP-only регистрацию и вход; +- OTP по номеру телефона; проверка кода — в Keycloak (заглушка `KEYCLOAK_OTP_MOCK_*` или SMS-провайдер, см. arch-04 и «Поток авторизации»); +- хранение учетных записей; +- выдачу и обновление токенов; +- настройку realm, clients, roles, policies. + +Парольная авторизация, magic link и социальные логины не входят в MVP. + +### Nginx Reverse Proxy + +Отвечает за: + +- прием внешнего HTTPS-трафика; +- TLS termination; +- редирект HTTP на HTTPS (на веб-домене; для выделенного API-домена HTTP не допускается — см. «Принципы безопасности»); +- маршрутизацию `/api/*` и `/realtime/*` в api-backend; +- маршрутизацию `/auth/*` или выделенного auth-домена в Keycloak; +- маршрутизацию публичных `/bitrix/*` endpoint в `bitrix-local-app`; +- маршрутизацию `/bitrix/sync/*` webhook endpoint в `bitrix-sync`; +- защиту internal endpoint `bitrix-local-app` через private network или `nginx allowlist`; +- отсутствие публичной маршрутизации к `message-safety` — сервис доступен только из внутренней Docker-сети; +- передачу `X-Forwarded-For`, `X-Forwarded-Proto`, `X-Forwarded-Host`, `X-Request-ID`; +- базовые лимиты размера запроса и timeout; +- грубые edge rate limits по IP, route и зоне риска; +- TLS 1.2/1.3, HSTS, security headers и скрытие технологических заголовков; +- кэширование публичных endpoint настроек и контента; +- запрет доступа к внутренним сервисам и техническим портам извне. + +### Message Safety Service + +Отвечает за: + +- проверку **входящих сообщений от пользователя** (текст, ссылки, файлы); +- **внутреннюю** orchestration: синхронно текст и ссылки; при необходимости — async-проверка файлов; +- HTTP-контракт для api-backend: + - `200` — синхронная проверка завершена, **allow**; + - `403` — синхронная проверка завершена, **deny**; + - `203` + `task_id` — нужна async-проверка (обычно файлы), сообщение в обработке; +- финальный вердикт async-задачи по `GET /internal/safety/v1/messages/tasks/{task_id}`: `200 allow` | `403 deny` | `203 pending`; +- SHA-256 хеширование и lookup кэша вердиктов; +- отдельный pipeline проверки ссылок; +- запись verdict cache, `safety_task` и audit в схеме `message_safety`; +- internal API: `POST /internal/safety/v1/messages/check`, `GET /internal/safety/v1/messages/tasks/{task_id}`. + +Не отвечает за: + +- загрузку файлов клиентом, presigned URL, перемещение quarantine → S3-data, удаление из quarantine; +- сохранение сообщений, истории диалогов (CRM sync — зона `bitrix-sync`, не api-backend); +- доставку в Bitrix24 Open Lines и realtime клиенту; +- проверку JWT, согласий, edge rate limits; +- polling `task_id` на стороне клиента — только api-backend (фоновый worker или internal loop). + +api-backend не решает, sync или async нужна проверка: это определяет Message Safety Service по результатам фазы текста/ссылок и кэша файлов. + +## Гостевая сессия (до JWT) + +До OTP frontend работает в гостевом режиме с локально сгенерированным **`guest_session_id`** (UUID v4): + +- создаётся при первом запуске приложения, хранится в secure storage устройства; +- передаётся в `POST /api/v1/consents` вместе с согласиями и device metadata; +- api-backend сохраняет согласия с привязкой к `guest_session_id` (TTL записи — 24 ч); +- после успешного OTP api-backend **связывает** записи согласий и device session с `UserIdentity` по `keycloak_sub`; +- `guest_session_id` не используется для доступа к защищённым ресурсам после выдачи JWT. + +## Аналитическая UX-сессия (`ux_session_id`) + +**UX-сессия** — период непрерывной активности пользователя в приложении для аналитики и сквозной трассировки. Это **не** сессия Keycloak, **не** refresh/access token и **не** механизм авторизации. + +### Роли компонентов + +**Frontend** (источник истины по правилам сессии): + +- хранит `ux_session_id` и `last_activity_at` **только в памяти** (не в localStorage/secure storage); +- при новой сессии вызывает `POST /api/v1/analytics/session-start` и сохраняет полученный `ux_session_id`; +- обновляет `last_activity_at` при пользовательской активности и при возврате из фона; +- при resume проверяет `(now - last_activity_at) > idle_timeout` → при превышении — новая сессия; +- передаёт **`X-Ux-Session-Id`** во **всех** запросах к backend (public и JWT). + +**api-backend**: + +1. принимает `session_start`, создаёт запись **`UxSession`**, возвращает `ux_session_id`; +2. пишет analytics/audit-событие `session_start` (без PII); +3. включает `ux_session_id` из заголовка в JSON-логи (если передан); +4. **не** блокирует запросы при отсутствии или неизвестном `ux_session_id` — это не auth. + +`request_id` — один HTTP-запрос; `ux_session_id` — период UX-активности для аналитики и корреляции логов. + +## Поток возврата пользователя (без OTP) + +1. Клиент открывает приложение (UX-сессия определяется по правилам выше, независимо от auth). +2. Frontend проверяет наличие refresh token в secure storage. +3. Если refresh token **действителен** — frontend запрашивает новый access token у Keycloak (Refresh Token Grant), **OTP не показывается**. +4. Frontend работает как авторизованный пользователь (история, профиль, чат). +5. Если refresh token **отсутствует или истёк** — клиент остаётся в гостевом режиме до сценария, требующего auth; при первом сообщении — «Поток авторизации» с OTP. + +## Обновление access token (frontend) + +Пока refresh token **действителен**, frontend **сам** поддерживает актуальный access token — **не** полагаясь только на открытие приложения и **не** дожидаясь истечения refresh token (использует его для обновления access token заранее). + +### Проактивное обновление по расписанию + +1. После получения tokens (OTP или refresh) frontend сохраняет access token, refresh token и момент истечения access token (`exp` из JWT или `expires_in` из ответа Keycloak). +2. Запускает таймер/scheduler: обновить access token **до** наступления `exp` (рекомендуемый запас — **60 с** до `exp`; константа модуля frontend). +3. По срабатыванию таймера — **Refresh Token Grant** к Keycloak, сохранение новой пары tokens, перепланирование следующего обновления. +4. **Single-flight:** параллельные refresh-запросы не дублируются (один in-flight refresh, остальные ждут результат). +5. Успешный refresh access token **не** создаёт новую UX-сессию и **не** вызывает `session_start`. + +### Обработка `401` от `api-backend` + +Если запрос с access token вернул **`401`** (токен уже истёк или отклонён): + +1. HTTP-клиент frontend **один раз** инициирует Refresh Token Grant (если refresh ещё не выполняется — через тот же single-flight). +2. При успехе — подставляет новый access token и **повторяет исходный запрос** (без бесконечных retry). +3. При неудаче refresh (`invalid_grant`, истёк refresh token, ошибка Keycloak) — очищает tokens, переводит UI в **гостевой режим**; повторная авторизация — через OTP при следующем защищённом действии. +4. Запросы, пришедшие во время in-flight refresh, **ставятся в очередь** и выполняются после успешного обновления (или отклоняются при провале refresh). +5. Тот же принцип — для **WebSocket** `/api/v1/realtime`: при ошибке auth — refresh и переподключение с новым access token. + +### Разделение ответственности + +| Компонент | Поведение | +|---|---| +| **Frontend** | scheduler refresh, intercept `401`, retry, single-flight, хранение tokens | +| **Keycloak** | выдача и ротация tokens (Refresh Token Grant) | +| **api-backend** | проверка JWT; при невалидном/expired access token — **`401`**, refresh **не** выполняет | + +## Создание диалога (MVP) + +- Диалог создаётся **лениво** при первой отправке сообщения авторизованным клиентом. +- Frontend перед `POST .../messages` вызывает `POST /api/v1/dialogs` (idempotency key), получает `dialog_id` и использует его далее. +- Популярный вопрос: после auth тот же порядок — `POST /dialogs` → `POST .../messages` с текстом вопроса. +- `dialog_id` = `external_chat_id` для Open Lines (см. [`arch-00-glossary.md`](arch-00-glossary.md), «Идентификаторы»). +- При первой доставке в Bitrix24 `bitrix-local-app` создаёт запись `dialog_sessions`. + +## Поток авторизации (OTP) + +Срабатывает, когда клиент **ещё не имеет действующего refresh token** (первый вход) или refresh token **истёк**. Если refresh token валиден — см. «Поток возврата пользователя». +1. Клиент находится в гостевом режиме (`guest_session_id` уже создан). +2. Клиент инициирует отправку сообщения (ручной ввод или популярный вопрос). +3. Frontend показывает pop-up с тремя согласиями. +4. Клиент обязан принять согласие на обработку персональных данных и пользовательское соглашение. +5. Клиент может опционально согласиться на рекламные коммуникации. +6. Если обязательные согласия не даны, отправка блокируется. +7. Frontend вызывает `POST /api/v1/consents` с `guest_session_id`, версиями документов, device metadata, IP/user agent (через backend). +8. Frontend запрашивает публичные настройки и показывает форму ввода номера телефона (единственный канал MVP). +9. Keycloak запускает OTP-flow по телефону: клиент вводит номер, инициируется «отправка» OTP (при заглушке SMS фактически не уходит — см. arch-04). +10. Лимиты OTP проверяются по **`app_settings`** (`otp.phone.*`). +11. Клиент вводит OTP и отправляет его в Keycloak. +12. **Keycloak проверяет корректность введённого OTP**: + - при **`KEYCLOAK_OTP_MOCK_ENABLED=true`** (MVP и любой режим с включённой заглушкой): введённое значение должно **совпадать** с `KEYCLOAK_OTP_MOCK_CODE` из `.env`; + - при **`KEYCLOAK_OTP_MOCK_ENABLED=false`** (после интеграции с SMS-провайдером, см. бэклог): введённое значение должно **совпадать** с одноразовым OTP, сгенерированным Keycloak и отправленным провайдером на телефон клиента (с учётом TTL и лимита попыток). + - при неверном коде Keycloak возвращает ошибку; frontend не получает tokens, шаг 13 не выполняется. +13. При успешной проверке frontend получает tokens через OIDC Authorization Code Flow with PKCE. +14. Frontend вызывает **`POST /api/v1/auth/bootstrap`** с JWT и `guest_session_id` (см. arch-02). +15. api-backend выполняет `find-or-create` пользователя, связывает согласия с `guest_session_id`, при необходимости привязывает `user_id` к текущей **`UxSession`** по `ux_session_id`. +16. Триггер App DB ставит задачу `contact.map_or_create` в `sync_queue`; `bitrix-sync` асинхронно находит или создает Contact в Битрикс24. Авторизация не должна синхронно зависеть от ответа Битрикс24 CRM. +17. Frontend создаёт диалог и отправляет отложенное сообщение (см. «Создание диалога» и поток чата). + +## Поток работы с чатом: клиент -> Битрикс24 + +В MVP сообщение клиента — **`content_kind`** `text` или `file`, не оба (см. [`arch-02-api-contracts.md`](arch-02-api-contracts.md), «Формат исходящего сообщения»). + +**Текстовое сообщение:** + +1. Frontend вызывает `POST /api/v1/dialogs` (если `dialog_id` ещё нет), затем отправляет `POST /api/v1/dialogs/{dialog_id}/messages` с непустым `text` (без вложения). +2. Nginx и API применяют rate limits. +3. API **синхронно** вызывает Message Safety Service (`POST /internal/safety/v1/messages/check`) — шаги текст и ссылки. +4. Далее — общая ветка вердикта (п. 5–8 ниже). + +**Файловое сообщение:** + +1. Frontend инициализирует **одно** вложение (`POST .../attachments/init`), загружает файл; api-backend сохраняет его в **S3-quarantine**. +2. Frontend отправляет `POST /api/v1/dialogs/{dialog_id}/messages` с `attachment_id` и `checksum` (поле `text` пустое). +3. Nginx и API применяют rate limits. +4. API **синхронно** вызывает Message Safety Service — шаг проверки файла (текст и ссылки пропускаются, если `text` пуст). + +**Общая ветка вердикта (оба типа):** + +5. **`403 deny`**: API удаляет quarantine (если был файл), возвращает клиенту безопасную ошибку; в Bitrix24 ничего не уходит. +6. **`200 allow`**: API переносит файл в S3-data (если был), сохраняет сообщение, отправляет в Bitrix24, подтверждает клиенту (realtime/polling). +7. **`203 pending` + `task_id`**: api-backend сохраняет сообщение со статусом ожидания проверки, отвечает клиенту, что сообщение обрабатывается; quarantine не трогает. +8. Фоновый процесс API опрашивает `GET /internal/safety/v1/messages/tasks/{task_id}`: + - финальный **`200 allow`** → S3-data, Bitrix24, статус «доставлено», realtime клиенту; + - финальный **`403 deny`** → удаление quarantine, статус «отклонено», уведомление клиенту; + - **`203 pending`** → повтор опроса с backoff. + +## Поток работы с чатом: Битрикс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` включен. +5. api-backend находит локальный диалог по `external_chat_id` (= `dialog_id`, см. [`arch-00-glossary.md`](arch-00-glossary.md)). +6. api-backend сохраняет входящее сообщение в App DB (`sender_type=company`), а файл — в Selectel S3 (documents) с metadata в App DB. По факту сообщения API обновляет `Dialog.status`: входящее от оператора → `waiting_for_client`, исходящее от клиента → `waiting_for_company`. +7. `bitrix-local-app` подтверждает доставку в Bitrix24 через `imconnector.send.status.delivery`. +8. api-backend публикует событие для frontend через WebSocket/SSE. Если 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`. + +## Документы компании (post-MVP) + +Доставка документов из Bitrix24 в приложение **не входит в MVP** — см. [`!Backlog.md`](../../HAN_chat/!Backlog.md), п. 9. + +В MVP блок профиля «Документы» и API `GET /api/v1/me/documents` зарезервированы; список может быть пустым. Контракт endpoint — в [`arch-02-api-contracts.md`](arch-02-api-contracts.md). + +## Профиль клиента + +Профиль должен быть блочным. + +Блок "Личные данные": + +- ФИО; +- гражданство; +- номер телефона в РФ; +- зарубежный номер телефона; +- email. + +Блок "Документы": + +- перечень документов, отправленных клиенту компанией (в MVP — пустой до реализации бэклога); +- дата отправки; +- наименование документа; +- возможность скачать документ (после реализации доставки). + +Редактирование данных профиля недоступно. + +### Sync профиля и master для PII + +App DB — **локальный кэш** для UI. Двусторонний sync — `bitrix-sync` (имена полей — [`arch-00-glossary.md`](arch-00-glossary.md)): + +- **Auth-телефон:** master — Keycloak (`UserIdentity.phone_number`); изменения могут инициировать `contact.update` через триггеры. +- **Поля профиля для UI:** master — последнее успешно синхронизированное значение; основной входящий поток на MVP — правки сотрудником в Bitrix24 (webhook → App DB). +- **App → Bitrix:** триггеры `han_app` → `sync_queue` (`contact.update`). +- **Bitrix → App:** webhook робота → `bitrix-sync`; запись с GUC `han.sync_suppress` (без эхо в очередь). +- **Конфликт:** побеждает более позднее событие (`updated_at`, audit в `bitrix_sync`). + +## Аудит скачиваний + +При выдаче presigned URL на скачивание (`GET .../download-url`, вложения чата) api-backend пишет audit-событие в App DB: + +| Поле | Значение | +|---|---| +| `event_type` | `attachment.download_url_issued` / `document.download_url_issued` | +| `user_id` | текущий пользователь из JWT | +| `resource_type` | `attachment` / `document` | +| `resource_id` | UUID сущности | +| `ux_session_id` | из заголовка `X-Ux-Session-Id` | +| `request_id` | из заголовка запроса | +| `ip`, `user_agent` | из proxy headers | + +В audit **не** сохраняются presigned URL, содержимое файлов и PII. Формат таблицы — в модуле `database`. + +## Realtime (кратко) + +Детальный контракт — [`arch-02-api-contracts.md`](arch-02-api-contracts.md), раздел «Realtime». + +- Transport: WebSocket `WS /api/v1/realtime` (JWT). +- Fallback: polling `GET /api/v1/dialogs/{dialog_id}/messages?after=...`. +- События: новое сообщение, смена статуса сообщения/диалога. + +## Принципы безопасности + +- Все защищенные пользовательские API требуют валидный JWT. +- Гостевые API доступны только для публичных настроек и стартового контента. +- Все внешние пользовательские соединения работают через HTTPS. +- HTTP допускается только для веб-домена как вход для редиректа на HTTPS. Для api домена HTTP не допускается. +- TLS завершается на reverse proxy; внутренний HTTP между контейнерами допускается только в закрытой backend-сети. +- TLS 1.0/1.1 и слабые шифры запрещены. +- HSTS обязателен после проверки домена и сертификата. +- INPUT-validation на api-backend +- использовать только Параметризованные SQL-запросы +- обязательное Экранирование вывода +- настройка CORS только на разрешенные домены (указать в .env) +- настройка Secure Headers (CSP, X-Frame-Options и др.) +- Доступ к профилю, диалогам, сообщениям, файлам и документам ограничен текущим `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)»). +- Rate limits применяются минимум на двух уровнях: edge-лимиты в `nginx` и пользовательские лимиты в API с состоянием в Redis. +- Входящие сообщения пользователя: синхронный `POST /internal/safety/v1/messages/check` → `200` | `403` | `203`; при `203` API опрашивает `task_id` до финального вердикта. +- Файлы пользователя до финального `allow` только в S3-quarantine; в S3-data — после `200 allow`. +- Клиент **не пишет** напрямую в S3; загрузка только через `api-backend`. +- `message-safety` — read-only к S3-quarantine, без прав записи в бакеты. +- Все изменяемые параметры, телефоны, лимиты, mime types и флаги хранятся в настройках ([`arch-04-settings-and-content.md`](arch-04-settings-and-content.md)). +- PII-данные не пишутся в логи в открытом виде. +- Документы и файлы чата должны иметь контроль доступа и аудит скачиваний. + +## Backend-репозиторий и инфраструктура + +### Состав backend-контура + +Минимальный production-like контур на одной VM: `nginx`, `api-backend`, `message-safety`, `keycloak`, `bitrix-sync`, `bitrix-local-app`, `redis`, `otel-collector`. Managed PostgreSQL и Selectel S3 находятся вне Docker Compose. + +### Предлагаемая структура backend-репозитория + +```text +backend/ + docker-compose.yml # корневой compose: nginx + include сервисов + networks/volumes + .env.example + nginx/ + docker-compose.yml + nginx.conf + conf.d/ + certs/ + .gitkeep + api-backend/ + app/ + docker-compose.yml + tests/ + pyproject.toml + Dockerfile + message-safety/ + app/ + docker-compose.yml + tests/ + pyproject.toml + Dockerfile + bitrix-local-app/ + app/ + docker-compose.yml + deploy/ + tests/ + pyproject.toml + Dockerfile + bitrix-sync/ + app/ + docker-compose.yml + tests/ + pyproject.toml + Dockerfile + keycloak/ + docker-compose.yml + realm/ + themes/ + providers/ + redis/ + docker-compose.yml + observability/ + docker-compose.yml # сервис otel-collector + otel-collector.yaml +``` + +Детальная внутренняя структура каждого сервиса (`app/`, модули, миграции) определяется в профильных спецификациях модулей (TBD). + +### Compose-контур + +Корневой `backend/docker-compose.yml` подключает сервисные compose-файлы через `include`. + +Публикация портов наружу разрешена только `nginx` (`80/443`). Остальные сервисы доступны через Docker-сети и private VPC. diff --git a/architectory/arch-02-api-contracts.md b/architectory/arch-02-api-contracts.md new file mode 100644 index 0000000..078dbea --- /dev/null +++ b/architectory/arch-02-api-contracts.md @@ -0,0 +1,395 @@ +# 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`** во всех запросах к `api-backend`, когда UX-сессия активна (рекомендуется для аналитики и логов; **не** является auth). +- Все 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 | Сохранение согласий перед OTP; тело включает `guest_session_id`, версии документов, device metadata | public + `guest_session_id` + rate limit | +| `POST /api/v1/analytics/session-start` | `api-backend` | Expo frontend | Событие `session_start`, новая `UxSession` | public + rate limit | +| `POST /api/v1/auth/bootstrap` | `api-backend` | Expo frontend | После OTP: `find-or-create` пользователя, связь согласий, привязка `user_id` к `UxSession` | 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 | Инициализация загрузки в S3-quarantine | JWT | +| `POST /api/v1/dialogs/{dialog_id}/attachments/{attachment_id}/complete` | `api-backend` | Expo frontend | Завершение загрузки и фиксация checksum/metadata | 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/consents` (тело запроса) + +```json +{ + "guest_session_id": "550e8400-e29b-41d4-a716-446655440000", + "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 api-backend связывает запись с `UserIdentity` по `guest_session_id` (в рамках `POST /api/v1/auth/bootstrap`). TTL guest-записи — 24 ч. + +### `POST /api/v1/analytics/session-start` (событие `session_start`) + +Вызывается frontend **только** при начале **новой** UX-сессии (см. arch-01, «Аналитическая UX-сессия»). **Не** привязан к OTP и JWT. + +**Заголовки:** `X-Request-ID` (опционально). + +**Тело:** + +```json +{ + "start_reason": "first_launch", + "guest_session_id": "550e8400-e29b-41d4-a716-446655440000", + "device": { + "platform": "web", + "app_version": "1.0.0", + "device_id": "..." + } +} +``` + +- `start_reason` — обязательно: `first_launch` | `cold_start` | `idle_timeout`; +- `guest_session_id` — опционально (если уже создан в гостевом режиме). + +**Ответ `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). + +### `POST /api/v1/auth/bootstrap` (после OTP) + +Вызывается **один раз** после успешного OTP и получения JWT. **Не** создаёт UX-сессию. + +**Заголовки:** `Authorization: Bearer `, `X-Ux-Session-Id` (рекомендуется). + +**Тело:** + +```json +{ + "guest_session_id": "550e8400-e29b-41d4-a716-446655440000", + "ux_session_id": "660e8400-e29b-41d4-a716-446655440001" +} +``` + +**Ответ `200`:** `{ "user_id": "uuid", "profile_ready": true }`. + +**Ошибки:** `401` (JWT), `403` (согласия не приняты / guest session истёк). + +### Создание диалога + +- `POST /api/v1/dialogs` — idempotency key в заголовке; ответ `{ "dialog_id": "uuid", "status": "open" }`. +- Обязателен перед первым `POST .../messages` (включая популярный вопрос после auth). +- `dialog_id` = `external_chat_id` (см. [`arch-00-glossary.md`](arch-00-glossary.md)). + +### Формат исходящего сообщения клиента (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`); +- пустое сообщение → **`403`** `empty_message`; +- более одного вложения → **`400`** `too_many_attachments`; +- файловое сообщение в Bitrix24: `message.files` (signed URL), `message.text` пустой. + +Post-MVP: допускается «текст + файлы» отдельной версией API. + +## 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 | `dialog_id`, `message_id`, `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=`. + +**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 + +| Контракт | Владелец | Потребитель | Назначение | +|---|---|---|---| +| OIDC Authorization Code Flow with PKCE | Keycloak | Expo frontend | OTP-only login, token issue, refresh | +| OIDC Refresh Token Grant | Keycloak | Expo frontend | Обновление access token без OTP при действующем refresh token | +| OIDC logout | Keycloak | Expo frontend | Завершение сессии Keycloak, очистка tokens | +| JWKS / discovery | Keycloak | Expo frontend, `api-backend` | Проверка issuer, audience и ключей | + +Frontend не обращается напрямую к Keycloak DB и не хранит парольные credentials. Парольная авторизация в MVP отключена. + +**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)). `api-backend` OTP не проверяет, только JWT. + +### Жизненный цикл 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` | Синхронная проверка текста, ссылок и файлов по cache/rules | internal network + `X-Service-Token` | +| `GET /internal/safety/v1/messages/tasks/{task_id}` | `message-safety` | `api-backend` | Опрос async-проверки файлов | internal network + `X-Service-Token` | +| Read S3-quarantine | Selectel S3 | `message-safety` | Чтение файла worker-ом при cache miss | read-only key | + +HTTP-семантика: `200 allow`, `403 deny`, `203 pending`. `api-backend` не выбирает sync/async режим, а только интерпретирует ответ. + +Маппинг в App DB (`Message.safety_status` — см. [`arch-00-glossary.md`](arch-00-glossary.md)): + +| HTTP / `message-safety` | `Message.safety_status` | Финальный? | +|---|---|---| +| `200` / `allow` | `allowed` | да | +| `403` / `deny` | `blocked` | да | +| `203` / `pending` | `pending` | нет | + +## 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 чата. + +## 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, OTP counters, coordination/realtime state | + +## 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` | +| `guest_session_id` | UUID или null | +| `user_id` | null (до auth bootstrap) | +| `request_id` | из `X-Request-ID` | +| `ip`, `user_agent` | из proxy headers | + +Raw OTP и полный номер телефона в audit **не** пишутся. + +### Audit: выдача download URL + +При `GET /api/v1/documents/{document_id}/download-url` и аналогичных endpoint вложений чата 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-сеть. diff --git a/architectory/arch-03-docker-compose-blueprint.md b/architectory/arch-03-docker-compose-blueprint.md new file mode 100644 index 0000000..12e957a --- /dev/null +++ b/architectory/arch-03-docker-compose-blueprint.md @@ -0,0 +1,445 @@ +# arch-03. Docker Compose blueprint + +> Термины (имена бакетов S3, идентификаторы) — в [`arch-00-glossary.md`](arch-00-glossary.md). Контракт Message Safety Service — в [`arch-02-api-contracts.md`](arch-02-api-contracts.md), раздел «api-backend ↔ message-safety». Переменные окружения и настройки — в [`arch-04-settings-and-content.md`](arch-04-settings-and-content.md). + +## Назначение + +Этот документ описывает целевой Docker Compose контур для первой production-like среды. Он не заменяет будущий `docker-compose.yml`, но задает требования, которым он должен соответствовать. + +Требования к безопасности на уровне приложения и данных — в [`arch-01-system-architecture.md`](arch-01-system-architecture.md), раздел **«Принципы безопасности»**. Настоящий документ описывает только инфраструктурную реализацию этих принципов в compose/nginx: TLS, маршрутизация, сетевые границы, rate limits на edge. Значения переменных окружения — в [`arch-04-settings-and-content.md`](arch-04-settings-and-content.md). Дублировать прикладные требования (JWT, валидация, CORS в API, PII в логах и т.п.) здесь не нужно — они остаются в `arch-01`. + +## Единый compose-контур (обязательно) + +Это зафиксированное архитектурное требование, а не рекомендация. + +### Принцип единого входа + +- Весь backend-контур поднимается **одной командой** `docker compose up -d` из корня репозитория (`backend/`). +- Корневой `docker-compose.yml` — единственный источник правды для production-like среды. Отдельных compose-файлов для production-деплоя отдельных сервисов не должно быть. +- **Один `nginx`** поднимается из корневого `docker-compose.yml` и является единой публичной точкой входа с маршрутизацией на все сервисы: + - `/api/*`, `/realtime/*` → `api-backend`; + - `/auth/*` → `keycloak`; + - `/bitrix/*` (public: `/bitrix/handler`, `/bitrix/install`, `/bitrix/placement`, `/health/*` для `bitrix-local-app`) → `bitrix-local-app`; + - `/bitrix/sync/*` (public: webhook CRM sync для `bitrix-sync`) → `bitrix-sync`; + - web-сборка frontend или прокси на dev-сервер; + - `/internal/openlines/*`, `/internal/safety/*`, `/internal/sync/*` **не публикуются** наружу — доступны только из внутренней Docker-сети. +- Никакой другой `nginx` (ни в контейнере сервиса, ни на хосте) не терминирует внешний HTTPS для backend-контура. Site-конфиг `tohin.ru` на хосте, если используется, должен проксировать весь трафик на корневой `nginx` контейнера, а не на порты отдельных сервисов напрямую. + +### Структура compose через `include` + +Каждый сервис описывается в собственном `docker-compose.yml` внутри папки сервиса и подключается в корневой файл директивой `include`: + +```text +backend/ + docker-compose.yml # корневой: nginx + include сервисов + общие networks/volumes + .env + nginx/ + docker-compose.yml # описание сервиса nginx (или секция в корневом) + nginx.conf + conf.d/ + certs/ + .gitkeep + api-backend/ + docker-compose.yml # описание сервиса api-backend + message-safety/ + docker-compose.yml # описание сервиса message-safety + bitrix-sync/ + docker-compose.yml # описание сервиса bitrix-sync + bitrix-local-app/ + docker-compose.yml # описание сервиса bitrix-local-app + keycloak/ + docker-compose.yml # описание сервиса keycloak (или секция в корневом) + observability/ + docker-compose.yml # otel-collector и т.п. +``` + +Корневой `backend/docker-compose.yml` (принципиальная схема): + +```yaml +name: han-chat + +include: + - nginx/docker-compose.yml + - api-backend/docker-compose.yml + - message-safety/docker-compose.yml + - bitrix-sync/docker-compose.yml + - bitrix-local-app/docker-compose.yml + - keycloak/docker-compose.yml + - redis/docker-compose.yml + - observability/docker-compose.yml + +networks: + public: + backend: + observability: + +volumes: + redis-data: + nginx-certs: +``` + +### Правила для сервисных compose-файлов + +- Сервисный `docker-compose.yml` описывает **только** сервис(ы) своего модуля: образ, build context, `environment` (через `${VAR}` из корневого `.env`), порты (только внутренние, кроме случаев ниже), `depends_on`, healthcheck, подключение к сетям `public`/`backend`/`observability` (объявленным в корневом файле). +- Сервисный файл **не объявляет** сети и volumes верхнего уровня — они объявляются в корневом `docker-compose.yml`. Сервис только ссылается на них через `networks:` / `volumes:` (external-стиль не нужен, т.к. `include` объединяет файлы в один проект). +- Публикация портов наружу (`ports:`) разрешена **только** для `nginx` (80/443). Все остальные сервисы используют `expose:` для внутренних портов и общаются через Docker-сети. +- `bitrix-local-app` не публикует `8080` на хост (даже на `127.0.0.1`) — он доступен `api-backend` и `nginx` через сеть `backend`/`public`. Ранее применявшийся `127.0.0.1:8080:8080` считаем устаревшим; проверки через curl на `127.0.0.1:8080` заменяются на `docker compose exec bitrix-local-app` или прокси через `nginx`. +- Каждый сервисный compose-файл должен запускаться и в составе корневого контура, и автономно (`docker compose -f bitrix-local-app/docker-compose.yml up`) для локальной разработки сервиса — при условии, что переменные окружения заданы. Для автономного запуска сервис может объявлять заглушки сетей/volumes, но в составе корневого контура они переопределяются общими. + +### Команды разработки + +```text +docker compose up -d +docker compose logs -f nginx +docker compose logs -f api-backend +docker compose logs -f message-safety +docker compose logs -f bitrix-sync +docker compose logs -f bitrix-local-app +docker compose exec api-backend alembic upgrade head +docker compose exec api-backend pytest +docker compose exec api-backend ruff check . +docker compose exec api-backend ruff format . +``` + +## Сервисы + +### nginx + +Reverse proxy и единственная публичная точка входа в Docker Compose контур. + +Требования: + +- публикует наружу только `80` и `443` (см. политику HTTP ниже); +- принимает внешний HTTPS-трафик; +- выполняет TLS termination на reverse proxy; внутренний HTTP между контейнерами — только в закрытой Docker-сети `backend`; +- **политика HTTP/HTTPS по доменам** (каноническое правило — [`arch-01-system-architecture.md`](arch-01-system-architecture.md), «Принципы безопасности»): + - **веб-домен** (frontend, SPA, статика): `listen 80` допускается **только** для безусловного редиректа `301`/`308` на HTTPS; обработка бизнес-логики по HTTP запрещена; + - **API-домен** (если выделен отдельный host, напр. `api.example.ru`): **не** слушает порт `80`; только `listen 443 ssl`; HTTP-запросы к API-домену недоступны; + - **единый домен MVP** (напр. `tohin.ru` с путями `/api/*`, `/auth/*`, web): считается веб-доменом; порт `80` — только redirect на HTTPS для всего server block; после редиректа весь пользовательский трафик — HTTPS; + - **auth** на том же host, что API (`/auth/*`): следует политике host (redirect-only на :80 или HTTPS-only для выделенного API-host); + - **Bitrix callbacks** (`/bitrix/*`, `/bitrix/sync/*`): только HTTPS; порт `80` не обслуживает эти location — только redirect; +- маршрутизирует `/api/*` и `/realtime/*` в `api-backend`; +- маршрутизирует `/auth/*` в `keycloak` или проксирует отдельный auth-домен; +- маршрутизирует публичные `/bitrix/*` endpoint в `bitrix-local-app`; +- маршрутизирует `/bitrix/sync/*` webhook endpoint в `bitrix-sync`; +- закрывает `/internal/*` (в т.ч. `bitrix-local-app`, `message-safety`, `bitrix-sync` ops) от публичного доступа — только private network Docker/VPC; +- **не публикует** `message-safety` наружу; +- **production-like / production**: отдаёт **статическую сборку Expo web** из volume или каталога (`/usr/share/nginx/html` или аналог); `index.html` + assets, SPA fallback `try_files $uri /index.html`; +- **local dev** (опционально): при `FRONTEND_DEV_PROXY_ENABLED=true` проксирует `/` на Expo dev server (`EXPO_DEV_SERVER_URL`, напр. `http://host.docker.internal:8081`); +- передает upstream-сервисам `Host`, `X-Real-IP`, `X-Forwarded-For`, `X-Forwarded-Proto`, `X-Forwarded-Host`, `X-Request-ID`; +- задает разумные `proxy_connect_timeout`, `proxy_read_timeout`, `client_max_body_size`; +- применяет edge rate limits для auth, API и download endpoints; +- ограничивает частоту соединений и размер тела запроса; +- разрешает только TLS 1.2/1.3 и запрещает слабые шифры; +- добавляет HSTS и базовые security headers; +- скрывает заголовки, раскрывающие внутренние технологии; +- кэширует публичные endpoint настроек и контента; +- не проксирует наружу managed PostgreSQL, `redis`, `otel-collector` (БД вне compose, в VPC); + +### api-backend + +Python FastAPI backend. + +Требования: + +- запускается после доступности managed PostgreSQL, `keycloak`, `redis`; +- применяет настройки из `.env`; +- отдает `/health/live` и `/health/ready`; +- корректно работает за reverse proxy и доверяет proxy headers только от `nginx`; +- применяет API-level rate limits с состоянием в Redis; +- вызывает message safety pipeline для сообщений до отправки в Open Lines; +- вызывает `bitrix-local-app` для отправки сообщений в Open Lines; +- принимает forward нормализованных событий оператора от `bitrix-local-app`; +- поддерживает realtime endpoint для сообщений оператора; +- работает с Selectel S3 для файлов и документов; +- экспортирует traces/logs в `otel-collector`; +- не хранит состояние внутри контейнера. + +### message-safety + +Отдельный backend-сервис проверки входящих сообщений пользователя. HTTP-контракт — в [`arch-02-api-contracts.md`](arch-02-api-contracts.md), раздел «api-backend ↔ message-safety». + +Требования: + +- запускается после доступности managed PostgreSQL (схема `message_safety`), `redis`; +- **не публикуется** через `nginx` — доступен только из внутренней Docker-сети; +- отдаёт `/health/live` и `/health/ready` (ready проверяет PostgreSQL, Redis, workers, read-доступ к S3-quarantine); +- exposing endpoints: `POST /internal/safety/v1/messages/check`, `GET /internal/safety/v1/messages/tasks/{task_id}` (internal Docker network + `X-Service-Token` / `MESSAGE_SAFETY_SERVICE_TOKEN`); +- read-only доступ к S3-quarantine (отдельный access key без прав записи); +- использует отдельную схему `message_safety` в managed PostgreSQL и отдельный DB-user; +- использует Redis (отдельная DB, напр. `redis://redis:6379/2`) для verdict cache и rate limits; +- запускает async workers для file scan из S3-quarantine; +- экспортирует traces/logs в `otel-collector`; +- таймауты: POST check 5 s, GET task 2 s, file scan 60 s (см. [`arch-04-settings-and-content.md`](arch-04-settings-and-content.md), переменные `MESSAGE_SAFETY_*`). + +### bitrix-sync + +Python worker/service **двусторонней** синхронизации App DB ↔ Bitrix24 CRM. + +Требования: + +- запускается после готовности managed PostgreSQL, `redis`; +- читает задачи из `han_app.sync_queue` (заполняется триггерами App DB); +- имеет прямой доступ к `han_app` (`BITRIX_SYNC_APP_DATABASE_URL`) и схеме `bitrix_sync`; +- выполняет map/create Contact по телефону (интервал `BITRIX_SYNC_CONTACT_MAP_INTERVAL_SEC`, default 60); +- push обновлений Contact (интервал `BITRIX_SYNC_CONTACT_UPDATE_INTERVAL_SEC`, default 30); +- принимает webhook `POST /bitrix/sync/webhook/contact` от роботов Bitrix24; +- при записи в App DB от Bitrix использует GUC `han.sync_suppress=true`; +- поддерживает graceful shutdown и rate limiting Bitrix REST; +- не блокирует пользовательский API при ошибках Битрикс24; +- не участвует в OTP-flow, не создаёт `UserIdentity`/`ClientProfile`; +- **не участвует** в hot path чата Open Lines. +- `bitrix-sync` должен быть подключаем через .env (если отключили, то синхронизация с битрикс24 не проводится; если не отключили - проводится) + +### bitrix-local-app + +Локальное приложение Bitrix24 и custom connector `han_mobile_app`. + +Требования: + +- публикует наружу только `/bitrix/handler`, `/bitrix/install`, `/bitrix/placement`, `/health/live`, `/health/ready`; +- принимает `ONAPPINSTALL` и `ONIMCONNECTOR*` события от Bitrix24; +- регистрирует и активирует connector `han_mobile_app` для открытой линии 8; +- хранит OAuth-токены Bitrix24, inbox событий и `dialog_sessions` в managed PostgreSQL, схема `bitrix_local`; +- предоставляет internal API `POST /internal/openlines/v1/messages` и `GET /internal/openlines/v1/dialogs/{external_chat_id}` для api-backend; +- защищает internal API через `Authorization: Bearer {BITRIX_INTERNAL_API_TOKEN}`; +- forward-ит нормализованные события Open Lines в API, если задан `BITRIX_API_FORWARD_URL`; +- не хранит бизнес-данные приложения и не пишет напрямую в App DB. + +### Managed PostgreSQL + +**Во всех средах** (production, production-like, local dev) данные хранятся в **managed PostgreSQL** провайдера. Контейнер PostgreSQL в Docker Compose **не используется** — ни для production, ни для локальной разработки. + +Прикладные данные, Keycloak, `bitrix-sync`, `bitrix-local-app` и `message-safety` подключаются к одной managed базе по URL из `.env` (`HAN_PG_HOST`, `HAN_PG_PORT`, `HAN_PG_DATABASE` и схемо-специфичные `*_DATABASE_URL`). + +Требования: + +- подключение только из приватной сети VPC (VM → managed PostgreSQL); +- одна managed база: схемы `han_app`, `bitrix_sync`, `message_safety`, `bitrix_local`, `keycloak`; +- отдельные DB-пользователи с доступом только к своей схеме; исключение: `bitrix_sync_user` дополнительно имеет ограниченный GRANT на `han_app` (`sync_queue`, `entity_external_mapping`, tracked columns профиля — детали схемы TBD в спецификации database); +- TLS к managed PostgreSQL обязателен; +- миграции Alembic выполняются отдельной командой при деплое; +- бэкапы и PITR — на стороне провайдера. + +### keycloak + +Identity provider. + +Требования: + +- отдельный realm для приложения; +- отдельный frontend client с PKCE; +- backend client для service-to-service сценариев; +- публичный issuer должен соответствовать HTTPS URL, видимому frontend-приложению; +- включены proxy settings для работы за `nginx`; +- импорт realm в local/dev; +- использует managed PostgreSQL, схема `keycloak` (см. раздел «Managed PostgreSQL» выше); +- healthcheck. + +### redis + +Очереди, кеш, rate limiting. + +Требования: + +- не использовать как единственное надежное хранилище бизнес-событий; +- хранить счетчики API-level rate limits; +- поддерживать TTL для лимитных ключей; +- sync_queue хранится в PostgreSQL (`han_app`), Redis может использоваться для wake-up/locking/queue optimization. + +### otel-collector + +Принимает telemetry от сервисов. + +Требования: + +- OTLP HTTP/gRPC receiver; +- экспорт traces/logs в stdout или платформенный collector; +- единые resource attributes: `service.name`, `deployment.environment`. + +## Networks + +Рекомендуемые сети: + +- `public`: `nginx`, frontend dev access, внешний HTTPS entrypoint. +- `backend`: API, `message-safety`, `bitrix-sync`, `bitrix-local-app`, `redis` (managed PostgreSQL — вне compose, в VPC). +- `observability`: otel-collector. + +Базы данных, Redis, Keycloak internal port и API internal port не должны публиковаться наружу. `message-safety` доступен только внутри сети `backend`. Основной пользовательский путь должен идти через `nginx` и HTTPS. + +## Volumes + +Минимальные volumes (production на одной VM): + +- `redis-data` (опционально, если нужна персистентность); +- certbot / TLS volumes для `nginx`. + +Данные PostgreSQL **не** хранятся в Docker volumes — только managed PostgreSQL вне 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). + +## HTTPS и TLS + +Соответствует [`arch-01-system-architecture.md`](arch-01-system-architecture.md), «Принципы безопасности» (HTTPS, TLS, HSTS). Инфраструктурная реализация: + +### Домены и HTTP + +| Host | Порт 80 | Порт 443 | Примечание | +|---|---|---|---| +| Веб-домен (frontend) | только `301`/`308` → HTTPS | HTTPS, бизнес-логика | MVP: `tohin.ru` / `app.example.ru` | +| API-домен (если выделен) | **не слушает** | только HTTPS | Post-MVP: `api.example.ru` | +| Bitrix callbacks (`/bitrix/*`, `/bitrix/sync/*`) | не обслуживает API; только redirect на том же host | HTTPS | webhook и install URL | + +Правила: + +- все внешние пользовательские соединения — **HTTPS**; +- HTTP допускается **только** на веб-домене как вход для редиректа на HTTPS; +- для **выделенного API-домена** HTTP **не допускается** (нет listener на :80); +- при едином домене MVP redirect на :80 применяется ко всему host, включая `/api/*` и `/auth/*`, после редиректа — только HTTPS. + +### TLS и заголовки + +- cookies в web-клиенте: `Secure`, `HttpOnly`, корректный `SameSite`; +- OIDC redirect URI в Keycloak — HTTPS; +- `KEYCLOAK_PUBLIC_URL`, issuer и frontend auth discovery URL совпадают по схеме, host и path; +- backend формирует внешние ссылки с учётом `X-Forwarded-Proto=https`; +- HSTS включается в production-like среде **после** проверки доменов и сертификатов; +- TLS 1.0/1.1 запрещены; минимум TLS 1.2, предпочтительно TLS 1.3; +- слабые шифры запрещены на уровне `nginx`; +- `nginx` скрывает `Server`, `X-Powered-By` и аналогичные технологические заголовки; +- security headers: `Strict-Transport-Security`, `X-Content-Type-Options`, `Referrer-Policy`, `Content-Security-Policy` для web-приложения; +- секретный ключ сертификата не коммитится в репозиторий; +- использовать сертификаты доверенного CA; автоматизировать выпуск и продление (Let's Encrypt + reload `nginx`); +- закрыть прямой доступ к внутренним портам контейнеров извне. + +## Nginx routing для Bitrix24 Local App + +`nginx` должен поддерживать отдельные server/location rules для `bitrix-local-app`. + +Рекомендуемая схема: + +- **веб-домен** (MVP: `tohin.ru` или `app.example.ru`): `/api/*`, `/auth/*`, `/realtime/*`, web frontend; `:80` → redirect HTTPS; `:443` — TLS + маршрутизация; +- **выделенный API-домен** (post-MVP, опционально): отдельный `server { listen 443 ssl; ... }` **без** `listen 80`; только `/api/*`, `/realtime/*`; +- домен или path `/bitrix/*` → `bitrix-local-app`; `/bitrix/sync/*` → `bitrix-sync`; +- `GET/POST /bitrix/handler` и `GET/POST /bitrix/install` доступны публично для Bitrix24; +- `/bitrix/placement` доступен публично как заглушка UI настроек коннектора; +- `/health/live` и `/health/ready` для `bitrix-local-app` доступны только там, где это нужно для healthcheck и проверки Bitrix form URL; +- `/internal/openlines/v1/*` не публикуется наружу или защищается allowlist/private network плюс `Authorization: Bearer {BITRIX_INTERNAL_API_TOKEN}`; +- для `/bitrix/*` callbacks кэширование отключено; +- для `/bitrix/*` callbacks включены отдельные rate limits, но они не должны блокировать легитимные webhook-повторы Bitrix24. + +## Nginx routing для bitrix-sync (CRM webhook) + +`nginx` маршрутизирует публичные webhook CRM sync в `bitrix-sync`: + +- `POST /bitrix/sync/webhook/contact` — исходящий webhook от роботов Bitrix24 при изменении Contact; +- проверка `BITRIX_SYNC_WEBHOOK_TOKEN` выполняется в `bitrix-sync`; +- кэширование отключено; rate limits не должны блокировать легитимные повторы Bitrix24; +- `/internal/sync/v1/*` не публикуется наружу (только internal network + `BITRIX_SYNC_SERVICE_TOKEN`). + +## Rate limits и защита от abuse + +Rate limits должны быть распределены по двум слоям. + +`nginx`: + +- ограничивает частоту запросов до попадания в API; +- держит отдельные зоны лимитов для `/auth`, `/api`, public endpoints, fallback polling и download endpoints; +- ограничивает `client_max_body_size`; +- ограничивает загрузку файлов лимитом 5 МБ; `client_max_body_size` должен быть чуть выше бизнес-лимита для учета overhead запроса; +- применяет `limit_req` для endpoint авторизации и fallback polling; +- для публичных endpoint использует лимит не выше 60 запросов в минуту с одного IP, если настройки не говорят иначе; +- возвращает `429` при превышении лимитов; +- не должен использоваться для сложных пользовательских правил, завязанных на `user_id`. + +API: + +- применяет лимиты после проверки JWT; +- считает лимиты по `user_id`, IP, route, dialog id и service client; +- хранит быстрые счетчики в Redis; +- пишет значимые превышения в audit/App DB; +- возвращает `Retry-After`, если клиент может повторить запрос позже. + +Проверка сообщений на prompt injection и вредоносные действия не должна выполняться в `nginx`: это задача отдельного сервиса `message-safety`, вызываемого из `api-backend` (см. [`arch-02-api-contracts.md`](arch-02-api-contracts.md)). + +## WAF + +WAF можно подключить внешним слоем перед `nginx` без изменения бизнес-кода, если соблюдены требования: + +- `nginx` и API корректно работают с цепочкой proxy headers и доверяют real IP только от доверенных прокси; +- CORS разрешает только доверенные домены; +- публичные endpoint имеют rate limits и кэширование даже без WAF; +- схема TLS termination согласована с тем, где завершается TLS: WAF/CDN, load balancer или `nginx`; +- WAF не должен подменять тело запросов и ответы API без явной необходимости. + +WAF не заменяет обязательные лимиты, валидацию схем, авторизацию и аудит внутри приложения. + +## Публичные endpoint + +`GET /api/v1/public/app-config` и `GET /api/v1/public/content` являются публичными, поэтому для них обязательны: + +- `limit_req` на уровне `nginx`, базово 60 запросов в минуту с одного IP; +- агрессивное кэширование на уровне `nginx` или CDN; +- заголовок `Cache-Control: public, max-age=3600`; +- строгая DTO-схема ответа на backend, без сериализации всех строк таблицы настроек; +- CORS только для доверенных доменов приложения; +- отсутствие секретов, внутренних URL, service tokens и приватных feature flags в ответе. + +Подробнее — [`arch-04-settings-and-content.md`](arch-04-settings-and-content.md), разделы «Публичный config endpoint» и «Публичный content endpoint». + +## Healthchecks + +Минимальные проверки: + +- `nginx`: на веб-домене — `301` с `:80` на HTTPS; на API-домене (если выделен) — `:80` не слушает; `:443` — HTTP 200/301 и успешная TLS handshake; +- `api-backend`: HTTP 200 от `/health/ready`; +- `message-safety`: HTTP 200 от `/health/ready` (проверяет PostgreSQL, Redis, workers, read S3-quarantine); +- `bitrix-sync`: процесс жив, подключение к App DB доступно; +- `bitrix-local-app`: HTTP 200 от `/health/live`, readiness показывает наличие OAuth-токенов после установки приложения; +- `keycloak`: health endpoint Keycloak; readiness — подключение к managed PostgreSQL; +- `redis`: `redis-cli ping`; + +## Порядок запуска + +1. `redis` (managed PostgreSQL должна быть доступна до старта зависимых сервисов). +2. `keycloak`. +3. `otel-collector`. +4. `message-safety`. +5. `api-backend`. +6. `bitrix-local-app`. +7. `bitrix-sync`. +8. `nginx`. + +`depends_on` не заменяет проверку готовности. Сервисы должны уметь ждать зависимости или корректно завершаться с понятной ошибкой. `api-backend` должен ждать готовности `message-safety` (healthcheck), т.к. отправка сообщения синхронно зависит от `POST /internal/safety/v1/messages/check`. + +## Развёртывание на одной VM + +Production-контур на `tohin.ru`: + +1. VM и managed PostgreSQL в одном VPC/кластере провайдера. +2. Managed PostgreSQL без публичного IP; security group разрешает подключение только с VM. +3. `docker compose up -d` на VM поднимает все сервисы кроме БД. +4. Сервисы подключаются к managed PostgreSQL по приватному FQDN/IP. + +## Production-замечания + +### Frontend (Expo web) + +| Режим | Поведение nginx | +|---|---| +| production-like / production | Статика Expo web (`expo export` / EAS web build), `FRONTEND_DEV_PROXY_ENABLED=false` | +| local dev | Опционально proxy на Expo dev server, `FRONTEND_DEV_PROXY_ENABLED=true` | + +Переменные — [`arch-04-settings-and-content.md`](arch-04-settings-and-content.md), блок «Frontend (nginx)». + +Docker Compose на одной VM — production-контур первого этапа. Позже при росте нагрузки можно отдельно решить: + +- вынос Redis в managed cache; +- managed object storage; +- secret manager; +- TLS, reverse proxy или managed ingress; +- backup и restore; +- централизованный мониторинг; +- горизонтальное масштабирование API и worker. diff --git a/architectory/arch-04-settings-and-content.md b/architectory/arch-04-settings-and-content.md new file mode 100644 index 0000000..1798ef5 --- /dev/null +++ b/architectory/arch-04-settings-and-content.md @@ -0,0 +1,322 @@ +# arch-04. Настройки и изменяемые параметры + +> **`.env`** — инфраструктура и секреты. **`app_settings`** (App DB) — единственный источник бизнес-настроек. Имена полей и enum — [`arch-00-glossary.md`](arch-00-glossary.md). Docker Compose — [`arch-03-docker-compose-blueprint.md`](arch-03-docker-compose-blueprint.md). + +## Цель + +Параметры разделены по слоям: + +| Слой | Где | Что | +|---|---|---| +| **Инфраструктура** | `.env` | подключения, URL, секреты, nginx/TLS, service tokens | +| **Бизнес-логика** | таблица **`app_settings`** | лимиты, флаги, телефоны, типы файлов, CORS, consent URLs | +| **Контент** | `text_resources`, `popular_questions` | тексты UI | + +Managed PostgreSQL **поднимается до** развёртывания приложения. Бизнес-настройки **не дублируются** в `.env`: seed в `app_settings` выполняется миграцией/скриптом модуля `database` **до** первого запуска `api-backend`. + +## Источники настроек + +### `.env` — только инфраструктура + +Корневой `backend/.env` читается сервисами compose. В репозитории — `.env.example`, не `.env`. + +**Допустимо в `.env`:** + +- URL сервисов, публичные endpoint, порты; +- строки подключения PostgreSQL, Redis, Keycloak DB; +- секреты: S3, Bitrix OAuth, service tokens, webhook-тokens; +- параметры **nginx/TLS** и edge rate limits (`NGINX_RATE_LIMIT_*`); +- идентификация Keycloak: realm, audience, public/internal URL; +- **OTP-заглушка MVP** (`KEYCLOAK_OTP_MOCK_*`) — infra/dev-секрет, не бизнес-настройка; +- технические таймауты worker-ов (`MESSAGE_SAFETY_*`, интервалы `bitrix-sync`). + +**Запрещено в `.env` (→ только `app_settings`):** + +- включение/отключение OTP, OTP-лимиты для UI/продукта; +- телефон оператора, consent URLs/versions; +- лимиты приложения (сообщения, download URL, login); +- типы/размер файлов чата, UX idle timeout; +- CORS origins, feature flags frontend. + +### `app_settings` — бизнес-настройки (App DB) + +**Единственный источник правды** для параметров, которые: + +- меняет продукт/оператор без redeploy; +- отдаются в `GET /api/v1/public/app-config` (публичные ключи); +- используются `api-backend` (и при необходимости другими сервисами) в runtime. + +Позже — редактирование через админку; на MVP — seed-миграция. + +### `text_resources` / `popular_questions` + +Контент UI — отдельные таблицы (не `app_settings`). Ключи MVP — TBD (спецификация frontend). + +--- + +## Требования к таблице `app_settings` + +Схема: **`han_app`**. Детальная DDL — модуль `database`; arch фиксирует контракт. + +### Колонки (минимум) + +| Колонка | Тип | Назначение | +|---|---|---| +| `setting_key` | `varchar`, PK | Канонический ключ (`auth.phone.enabled`, см. ниже) | +| `setting_value` | `text`, NOT NULL | Значение (строка; парсинг по типу) | +| `value_type` | `enum` | `boolean` \| `integer` \| `string` \| `duration` \| `string_list` | +| `is_public` | `boolean` | Разрешён в `GET /api/v1/public/app-config` | +| `description` | `text`, nullable | Комментарий для админки/ops | +| `updated_at` | `timestamptz` | Последнее изменение | +| `record_status` | `char(1)` | Soft delete: `'A'` active | + +### Правила + +1. **Seed обязателен** до первого запуска `api-backend` в новой среде (миграция или idempotent seed-скрипт). +2. **`api-backend`** загружает настройки при старте; допускается in-memory cache с инвалидацией по `updated_at` (реализация — модуль). +3. Отсутствие **обязательного** ключа при старте → сервис **не** переходит в `ready` (fail-fast). +4. Публичные ключи (`is_public=true`) отдаются только через **строгий DTO** `app-config`, не raw dump таблицы. +5. Секреты и infra **не** хранятся в `app_settings`. + +### Ключи MVP (seed) + +Полный пример значений — раздел «Seed MVP» ниже. Группы: + +| Группа | Ключи | +|---|---| +| Auth | `auth.phone.enabled`, `auth.password.enabled` | +| OTP (продукт) | `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.*` | +| Rate limits (app) | `rate_limit.message_send.*`, `rate_limit.download_url.*`, `rate_limit.public_endpoints.*`, `rate_limit.login.*` | +| UX | `ux.session.idle_timeout_minutes` | +| Security | `security.cors.allowed_origins`, `security.public_cache.max_age_seconds` | + +### Seed MVP + +```text +auth.phone.enabled=true +auth.password.enabled=false + +otp.phone.max_send_attempts_per_24h=3 +otp.phone.min_seconds_between_attempts=30 + +operator.call.phone=+74999591007 + +consent.personal_data.required=true +consent.personal_data.document_url=https://www.han0107.ru/privacy/persdata-agree-mobile +consent.personal_data.version=2026-06-10 +consent.user_agreement.required=true +consent.user_agreement.document_url=https://www.han0107.ru/user-agreement +consent.user_agreement.version=2026-06-10 +consent.marketing.required=false +consent.marketing.version=2026-06-10 + +chat.attachments.allowed_extensions=jpg,jpeg,png,webp,heic,heif,pdf +chat.attachments.allowed_mime_types=image/jpeg,image/png,image/webp,image/heic,image/heif,application/pdf +chat.attachments.disallowed_extensions=svg,doc,docx,xls,xlsx,csv +chat.attachments.max_size_mb=5 +chat.attachments.storage=selectel_s3 +chat.attachments.upload_mode=backend_controlled_upload +chat.attachments.safety_scan_required=true + +rate_limit.message_send.per_user=30/minute +rate_limit.message_send.per_dialog=20/minute +rate_limit.download_url.per_user=60/hour +rate_limit.public_endpoints.per_ip=60/minute +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.public_cache.max_age_seconds=3600 +``` + +--- + +## Пример `.env.example` + +Только инфраструктура. Бизнес-параметры — в seed `app_settings`. + +```text +# ============================================================================= +# Общие +# ============================================================================= +APP_ENV=production-like +API_PORT=8000 +LOG_LEVEL=INFO + +# ============================================================================= +# Managed PostgreSQL +# ============================================================================= +HAN_PG_HOST= +HAN_PG_PORT=6432 +HAN_PG_DATABASE=han_chat + +DATABASE_URL=postgresql+asyncpg://han_app:change-me@:/?options=-csearch_path%3Dhan_app +BITRIX_DATABASE_URL=postgresql://bitrix_local_app:change-me@:/?options=-csearch_path%3Dbitrix_local +BITRIX_SYNC_APP_DATABASE_URL=postgresql://bitrix_sync_user:change-me@:/?options=-csearch_path%3Dbitrix_sync%2Chan_app +BITRIX_SYNC_DATABASE_URL=postgresql://bitrix_sync_user:change-me@:/?options=-csearch_path%3Dbitrix_sync +MESSAGE_SAFETY_DATABASE_URL=postgresql://message_safety_app:change-me@:/?options=-csearch_path%3Dmessage_safety +KEYCLOAK_DB_URL=jdbc:postgresql://:/?user=keycloak_user&password=change-me¤tSchema=keycloak +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 + +# ============================================================================= +# nginx (edge, TLS, rate limits) +# ============================================================================= +NGINX_HTTP_PORT=80 +NGINX_HTTPS_PORT=443 +TLS_CERT_PATH=/etc/nginx/certs/fullchain.pem +TLS_KEY_PATH=/etc/nginx/certs/privkey.pem +NGINX_TLS_PROTOCOLS=TLSv1.2 TLSv1.3 +NGINX_HSTS_MAX_AGE=31536000 +NGINX_CLIENT_MAX_BODY_SIZE=8m +NGINX_RATE_LIMIT_API=60r/m +NGINX_RATE_LIMIT_AUTH=10r/m +NGINX_RATE_LIMIT_DOWNLOADS=30r/m +NGINX_RATE_LIMIT_PUBLIC=60r/m +NGINX_RATE_LIMIT_POLLING=60r/m + +# ============================================================================= +# Keycloak (infra; OTP-заглушка — dev/MVP) +# ============================================================================= +KEYCLOAK_PUBLIC_URL=https://app.example.ru/auth +KEYCLOAK_INTERNAL_URL=http://keycloak:8080 +KEYCLOAK_REALM=han-chat +KEYCLOAK_AUDIENCE=han-chat-api +KEYCLOAK_OTP_MOCK_ENABLED=true +KEYCLOAK_OTP_MOCK_CODE=1234 + +# ============================================================================= +# Redis +# ============================================================================= +REDIS_URL=redis://redis:6379/0 + +# ============================================================================= +# Service tokens (internal API) — все переменные только в backend/.env +# ============================================================================= +MESSAGE_SAFETY_SERVICE_TOKEN=change-me +BITRIX_LOCAL_APP_INTERNAL_TOKEN=change-me +BITRIX_API_INBOX_TOKEN=change-me +BITRIX_INTERNAL_API_TOKEN=change-me +BITRIX_API_FORWARD_TOKEN=change-me +BITRIX_SYNC_SERVICE_TOKEN=change-me + +# ============================================================================= +# api-backend (интеграции) +# ============================================================================= +BITRIX_LOCAL_APP_BASE_URL=http://bitrix-local-app:8080 +BITRIX_API_INBOX_PATH=/internal/openlines/v1/inbox +MESSAGE_SAFETY_URL=http://message-safety:8080 + +# ============================================================================= +# bitrix-sync +# ============================================================================= +BITRIX_SYNC_CRM_BASE_URL=https://han0107.bitrix24.ru +BITRIX_SYNC_CRM_WEBHOOK_URL=change-me +BITRIX_SYNC_CONTACT_MAP_INTERVAL_SEC=60 +BITRIX_SYNC_CONTACT_UPDATE_INTERVAL_SEC=30 +BITRIX_SYNC_CRM_MAX_CONCURRENCY=2 +BITRIX_SYNC_CONTACT_LIST_BATCH_SIZE=50 +BITRIX_SYNC_WEBHOOK_TOKEN=change-me + +# ============================================================================= +# bitrix-local-app +# ============================================================================= +BITRIX_CLIENT_ID=change-me +BITRIX_CLIENT_SECRET=change-me +BITRIX_CONNECTOR_ID=han_mobile_app +BITRIX_CONNECTOR_NAME=HAN Mobile App +BITRIX_OPEN_LINE_ID=8 +BITRIX_PUBLIC_BASE_URL=https://tohin.ru/bitrix +BITRIX_API_FORWARD_URL=http://api-backend:8000/internal/openlines/v1/inbox +BITRIX_APPLICATION_TOKEN=change-me + +# ============================================================================= +# message-safety (technical) +# ============================================================================= +MESSAGE_SAFETY_POST_TIMEOUT_SEC=5 +MESSAGE_SAFETY_TASK_POLL_INTERVAL_SEC=2 +MESSAGE_SAFETY_TASK_POLL_MAX_SEC=300 +MESSAGE_SAFETY_RULES_VERSION=2026-01-01 + +# ============================================================================= +# Frontend (nginx) +# ============================================================================= +FRONTEND_STATIC_PATH=/usr/share/nginx/html +FRONTEND_DEV_PROXY_ENABLED=false +EXPO_DEV_SERVER_URL=http://host.docker.internal:8081 + +# ============================================================================= +# Selectel S3 +# ============================================================================= +SELECTEL_S3_ENDPOINT_URL=https://s3.storage.selcloud.ru +SELECTEL_S3_BUCKET_DOCUMENTS=han-chat-documents +SELECTEL_S3_BUCKET_ATTACHMENTS=han-chat-attachments +SELECTEL_S3_BUCKET_QUARANTINE=han-chat-quarantine +SELECTEL_S3_ACCESS_KEY=change-me +SELECTEL_S3_SECRET_KEY=change-me +SELECTEL_S3_QUARANTINE_READ_ACCESS_KEY=change-me +SELECTEL_S3_QUARANTINE_READ_SECRET_KEY=change-me + +# ============================================================================= +# Observability +# ============================================================================= +OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317 +``` + +Все переменные — **только** в `backend/.env`. Отдельного хранилища нет. + +**Webhook-токены** (публичные callback, не service API): `BITRIX_APPLICATION_TOKEN`, `BITRIX_SYNC_WEBHOOK_TOKEN`. + +## Namespace переменных Bitrix + +- `bitrix-local-app`: `BITRIX_CLIENT_*`, `BITRIX_CONNECTOR_*`, `BITRIX_PUBLIC_BASE_URL`, `BITRIX_DATABASE_URL`, `BITRIX_API_FORWARD_URL`, `BITRIX_APPLICATION_TOKEN` + service tokens. +- `api-backend`: `BITRIX_LOCAL_APP_BASE_URL`, `MESSAGE_SAFETY_URL` + service tokens; **бизнес-настройки** — из `app_settings`. +- `bitrix-sync`: `BITRIX_SYNC_*`, `BITRIX_SYNC_WEBHOOK_TOKEN`, `BITRIX_SYNC_SERVICE_TOKEN`. +- `bitrix-sync` не читает `BITRIX_CLIENT_ID` / `BITRIX_CLIENT_SECRET`. + +## Разрешённые типы файлов чата (MVP) + +Источник значений — ключи **`app_settings`** (раздел «Seed MVP»). Остальные arch-* **ссылаются сюда**. + +| Ключ | MVP-значение | +|---|---| +| `chat.attachments.allowed_extensions` | `jpg`, `jpeg`, `png`, `webp`, `heic`, `heif`, `pdf` | +| `chat.attachments.allowed_mime_types` | `image/jpeg`, `image/png`, `image/webp`, `image/heic`, `image/heif`, `application/pdf` | +| `chat.attachments.max_size_mb` | `5` | + +Правило: файл принимается только если **и** расширение, **и** MIME в allow-list. Детальная проверка — модуль `message-safety`. + +Публичный UI: `GET /api/v1/public/app-config` (строгий DTO, без секретов). + +## Публичный config endpoint + +`GET /api/v1/public/app-config` — только ключи с `is_public=true` из `app_settings`: + +- OTP по телефону (`auth.phone.enabled`); +- номер оператора; +- типы файлов и max size; +- `ux.session.idle_timeout_minutes`; +- feature flags; +- публичные лимиты для подсказок UI. + +Секреты, service tokens, внутренние URL **не** возвращаются. DTO явный, не сериализация всей таблицы. Rate limit: 60/min per IP. `Cache-Control: public, max-age` из `security.public_cache.max_age_seconds`. + +## Публичный content endpoint + +`GET /api/v1/public/content` — `text_resources` для текущего языка. Те же требования безопасности, что у config. + +## Nginx и HTTPS + +Infra-переменные — `.env.example` (`NGINX_*`, `TLS_*`). Edge rate limits (`NGINX_RATE_LIMIT_*`) **не** дублируют `rate_limit.*` из `app_settings`: nginx — защита периметра, app — бизнес-лимиты в backend. + +Реализация — [`arch-03-docker-compose-blueprint.md`](arch-03-docker-compose-blueprint.md). diff --git a/architectory/arch-05-agent-development-process.md b/architectory/arch-05-agent-development-process.md new file mode 100644 index 0000000..51531d5 --- /dev/null +++ b/architectory/arch-05-agent-development-process.md @@ -0,0 +1,96 @@ +# arch-05. Правила разработки модулей отдельными агентами + +> Термины — в [`arch-00-glossary.md`](arch-00-glossary.md). Настоящий документ описывает процесс разработки и не переопределяет архитектуру. Иерархия приоритета — в [`README.md`](README.md), раздел «Разрешение конфликтов». + +## Цель + +Этот документ задает единый процесс разработки, чтобы отдельные агенты создавали совместимые части приложения без расхождения архитектуры. + +## Общие правила +- Каждый агент работает только в границах назначенного модуля. +- Перед разработкой агент читает [`README.md`](README.md), архитектурные документы и профильный документ назначенного модуля. +- Любое изменение публичного API сопровождается обновлением OpenAPI. +- Любое изменение структуры данных сопровождается миграцией. +- Все **бизнес-параметры** — в таблице `app_settings`; **infra и секреты** — в `.env`. +- Нельзя hardcode-ить телефоны, лимиты, тексты, mime types, feature flags и параметры Битрикс24. +- Модули, принимающие пользовательский ввод, должны учитывать rate limits и security/safety проверки. + +## Правила базы данных + +- Перечень таблиц, полей, индексов и миграций **определяет модуль-владелец** (`database`, `api-backend`, `bitrix-sync`, `message-safety`, `bitrix-local-app`), а не arch-*. +- Архитектура фиксирует **разделение схем** и общие подходы к ведению баз данных, которые должны соблюдаться при проработке модулей. +- У каждой основной **прикладной** сущности должен быть `record_status`. Базовые статусы: `A` — active, `D` — deleted. +- Физическое удаление строк прикладных сущностей запрещено. Если нужно удалить сущность, сервис меняет `record_status` с `A` на `D`. +- При смене статуса на `D` сервис обязан заполнить `status_changed_at` и `status_change_reason`. +- Все сервисы при чтении бизнес-данных по умолчанию запрашивают только `record_status = 'A'`. +- Исключения допускаются только для аудита, админки, технического восстановления и миграций. +- Прикладные сущности, имеют `id`, `created_at`, `updated_at`, `updater_user_id`. +- Системные таблицы (`app_settings`, `text_resources`, `popular_questions`, `sync_queue`, audit, справочники) могут использовать `user_id = NULL` или отдельное поле `actor_type` — по спецификации модуля `database`. +- Для списков использовать справочники. Если значения в столбце могут принимать определенный набор значений, записывать их через ИД (sequence) и создавать справочник с расшифровкой ИД. Это существенно позволит экономить на размере таблиц. +- Для часто используемых фильтров добавляются индексы. +- Миграции не должны удалять данные без отдельного согласования. +- Все юзеры должны иметь ИД, которое указывается в `updater_user_id` которое они меняют. + + +## API + +Правила: + +- endpoint naming должен следовать `arch-02-api-contracts.md`; +- response schema не должна раскрывать внутренние поля; +- ошибки возвращаются в едином формате; +- для пользовательских данных всегда используется текущий user context из JWT; +- frontend не передает `client_profile_id` для доступа к своим данным; +- профиль в MVP не редактируется через `PATCH /me`; +- сообщения оператора должны приходить в frontend через realtime или polling fallback. + +## Логирование и OTP + +Каждый модуль должен: + +- использовать общий формат JSON-логов; +- добавлять `module`, `event`, `request_id`, `trace_id`; +- для `api-backend` добавлять **`ux_session_id`** в JSON-логи, если передан заголовок `X-Ux-Session-Id`; +- не логировать access token, refresh token, raw OTP, документы, полные PII; +- хранить факт отправки OTP через `provider_message_id`, `sent_at`, `destination_masked`, `otp_hash`, попытки и итог проверки. + +Raw OTP запрещено хранить в открытом виде: это временный секрет. Доказательство отправки и проверки строится на аудите, delivery id провайдера и hash-проверке. + +## Тесты + +Минимум для каждого модуля: + +- happy path; +- ошибки авторизации и доступа; +- rate limits, если модуль принимает пользовательский ввод; +- soft delete и фильтрация `record_status = 'A'`, если модуль работает с БД; +- idempotency, если операция может повториться; +- отсутствие секретов и PII в логах. + +Дополнительно: + + + +## Definition of Done + +Модуль считается готовым, если: + +- реализованы сценарии из задачи; +- обновлен `{service}/openapi.yaml`, если менялся HTTP API; +- созданы миграции, если менялась БД; +- добавлены тесты; +- сервис запускается в Docker Compose; +- все изменяемые параметры вынесены из кода; +- логи содержат `request_id`, `trace_id` и **`ux_session_id`** (если передан в запросе); +- нет секретов, raw OTP и PII в логах; +- soft delete соблюден; +- агент указал, какие документы архитектуры были затронуты. + +## Правила изменения архитектуры + +Если агент видит, что текущая архитектура мешает задаче, он должен: + +1. описать проблему; +2. предложить минимальное изменение; +3. указать затронутые документы; +4. не делать широкий рефакторинг без подтверждения.