Commit Graph
348 Commits
Author SHA1 Message Date
AndreyandClaude Opus 5 09fb0177b1 🔥 refactor(ai): убрать учёт расхода на модель и валюту организации
Учёт стоимости вызовов остался без потребителя, когда сняли лимиты, и сам
по себе не работал: цену присылает только OpenRouter, для остальных
провайдеров была прайс-таблица из двух моделей и ноль для всех прочих.
Ни одна цифра расхода в интерфейсе не показывалась. Удалены ai/pricing.py,
ai/limits.py, поля LlmInvocation.cost_micros и currency, статус BLOCKED
вместе со строками журнала, ChatResult.cost_micros и чтение usage.cost,
настройки CHATBALLS_AI_PRICING и CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS,
поля aiCostMicros/aiCostPrevMicros в сводке и стоимость в демо-данных.
Обработка входящих ловит теперь только сбой провайдера — лимиту неоткуда
сработать.

Валюта организации удалена целиком: поле принимало единственное значение
RUB, не читалось нигде и ни одна сумма в продукте в ней не считалась.
Убраны колонка с миграцией, валидация «только рубль», payload настроек и
платформенной выдачи, обязательное поле currency в API провижининга,
типы и транспорт на фронте.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 09:02:01 +03:00
AndreyandClaude Opus 5 b76871caf0 🐛 fix(chat): вернуть свёрнутый список диалогов, зелёный акцент по умолчанию
Кнопка «Показать список» жила только в шапке переписки. При пустом списке
или просто без выбранного диалога переписки нет — свёрнутый список
оказывался не вернуть ничем. Теперь кнопка есть и в пустой области, на том
же месте, что и в шапке. Сценарий закрыт e2e-тестом.

Цвет продукта по умолчанию — зелёный #0f9b8e: он же первый в палитре
акцентов профиля. Прежний синий остался вторым пресетом.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 08:20:14 +03:00
AndreyandClaude Opus 5 8580b480e7 💄 fix(organization): язык интерфейса селектом, одна вёрстка у обеих форм
После удаления валюты в ряду полей осталась пустая половина. Теперь ряд
занимают часовой пояс и язык интерфейса: язык был сегментом во всю ширину
карточки, стал таким же селектом, как соседнее поле. Подпись под ним
убрана вместе с ключом словаря.

Форма создания организации и «Настройки → Организация» — одна форма по
смыслу, поэтому у них теперь одна ширина колонки и одни отступы.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 08:12:22 +03:00
AndreyandClaude Opus 5 eff6d7ac0c 🔥 refactor(ai): убрать дневной лимит агента и выбор валюты организации
Дневной бюджет агента (limits.dailyCostUsd) снят вместе с полем. Проверка
работала, а учёт — нет: расход брался из ответа провайдера, а его шлёт
только OpenRouter; для Custom и локальной модели оставалась прайс-таблица
из двух моделей, и на любой другой стоимость записывалась нулём. То есть
на всех провайдерах, кроме OpenRouter, лимит не срабатывал никогда и давал
ложное чувство защиты. Общий лимит установки из переменной окружения
CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS остаётся.

Выбор валюты убран из настроек организации и из формы её создания. Сервер
принимал только RUB, то есть в списке был один вариант, а само поле не
читается нигде: ни одна сумма в продукте не считается в валюте
организации. Колонка в базе остаётся, интерфейс её больше не спрашивает —
сервер проставляет значение сам.

Второй шаг онбординга больше не велит задавать дневной бюджет.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 07:54:14 +03:00
AndreyandClaude Opus 5 1cff9e2183 ✨ feat(onboarding): визард «Начало работы» вместо блока «Запуск»
Онбординг из восьми шагов: приветствие, визард с точным путём и превью
результата на каждом шаге, тур «Показать где» с подсветкой реального
элемента и финальный экран. Точки возврата — ссылка внизу субменю
«Настроек» и пилюля в углу рабочей области. Вёрстка по макету
design/baseline/Онбординг.

Признак «закрыл» и «прошёл» живёт на членстве человека в организации, а
не в localStorage: требование — показать визард всем, кто его ещё не
закрывал, включая тех, кто работает в установке давно. У каждого он свой,
поэтому один администратор не прячет визард команде. Прогресс шагов
считается по факту настройки, ручное «Далее» его не подменяет.

Заодно все селекты приложения переведены на общее меню: SelectMenu в
shared/ui-controls, поверх него FilterDropdown (фильтры списков) и
SelectField (поля форм). Нативных <select> не осталось, вместе с ними
ушли пять копий скина селекта. Изменения переплетены с онбордингом через
общие файлы (словари, e2e-сценарии), поэтому одним коммитом.

Устаревший design/design-system удалён: источник истины по интерфейсу —
макеты design/baseline/<фича>/*.dc.html.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 07:22:35 +03:00
AndreyandClaude Fable 5.1 21b8100598 ⚡ fix(updates): проверка канала релизов раз в 15 минут вместо шести часов
Обновление видно на установке в пределах четверти часа после публикации; кнопка «Проверить» по-прежнему делает запрос сразу. Интервал переопределяется CHATBALLS_UPDATE_CHECK_INTERVAL_SECONDS.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 04:29:18 +03:00
AndreyandClaude Fable 5.1 82805f7b1a ✨ feat(identity): организации из интерфейса — создание, выбор после входа, /join под ролью app
Кнопка «Добавить организацию» внизу переключателя у логотипа (A1) ведёт на
страницу /organizations/new: логотип, название, часовой пояс, валюта, язык.
Создавший становится владельцем и сразу переключается в новую организацию.
Право — у администратора установки и у владельца или администратора любой
организации; сервер проверяет то же (POST /api/v1/organizations/).

После входа учётная запись с несколькими организациями выбирает, с какой
начать: экран в рамке входа, строки «логотип · название · роль». Прямая
ссылка на организацию экран минует.

tenancy/0035: роль app вставляет организацию только в контексте заранее
выделенного id (как мастер первого запуска) вместо политики «только первая»;
security-barrier каталог invitation_directory — ссылка /join открывается без
контекста, и под ролью app приглашение раньше не находилось вовсе. Тем же
путём язык организации в профиле: членства читаются через каталог входа.

Тесты: создание организации по API, RLS под реальной ролью app (вставка
только в своём контексте, поиск приглашения по токену), e2e переключателя,
страницы создания и экрана выбора. UpdateState исключён из проверки покрытия
демо-набором — одна строка на установку.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 04:14:24 +03:00
AndreyandClaude Opus 5 eca0e52667 ♻️ refactor(ui): один селект и один скин полей на всё приложение
Карточка агента рисовала свои выпадающие селекты: «Группа» и «Провайдер»
жили на собственной вёрстке и своём CSS, а рядом, в модалке создания
агента, стоял общий SelectField. Два стандарта одного элемента успели
разойтись в высоте, подложке, размере текста, подписи и фокусе.

Общий SelectField научился тому, из-за чего карточка писала свой: метке
перед значением (точка группы), невалидному состоянию и режиму без прав
правки, где значение показывается текстом. Форм у него две — обычный
селект и бокс, который держит рамку, когда внутрь нужно поставить точку
или текст вместо контрола. Карточка перешла на него, локальный скин
удалён, кольцо фокуса кадра G2 сохранено.

Геометрия базлайна v2 (поле 36px на подложке n-10, текст 13.5px, подпись
12.5/600) стала дефолтом shared/form-controls.css. До этого дефолтом
были 38px на surface-card, а базлайн добирали поверх профиль, настройки
и модалка создания агента — вместе с карточкой четыре копии одного поля.
Копии сняты, мобильные 44px тоже общие. Заодно шеврон встал по центру
поля: его отступ считался от прежних 38px.

Справочник дизайн-системы обновлён следом, чтобы не описывать геометрию,
которой в приложении больше нет.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 03:59:10 +03:00
AndreyandClaude Fable 5.1 5396dda8ad ✨ feat(updates): обновление установки из интерфейса
Установка раз в шесть часов и по кнопке проверяет страницу релизов
GitHub. Администратор установки видит баннер о новой версии и ставит
её одной кнопкой; карточка «Обновления» — в «Настройки → Платформа».

Установку выполняет отдельный сервис updater с Docker-сокетом:
backend-app общается с ним только файлами в томе chatballs-updates
(heartbeat, request.json, status.json). Updater принимает лишь релизы
своего репозитория с образами по digest и применяет compose.yaml в
одноразовом контейнере-помощнике, после чего кладёт файл в каталог
установки. Запрос установки попадает в аудит.

Релизный workflow собирает образ updater и пришпиливает его в
compose.yaml; версия бэкенда зашивается в образ (CHATBALLS_VERSION).
Миграции updates.0001 и tenancy.0034 (гранты на таблицу состояния).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 00:43:11 +03:00
AndreyandClaude Fable 5.1 c5a53ccec5 ✨ feat(identity): SVG в качестве логотипа организации
К PNG, JPEG и WebP добавлен SVG. Файл проверяется при загрузке и
отклоняется целиком, если содержит скрипты, обработчики событий,
foreignObject, внешние ссылки в href или url() внутри стилей, DOCTYPE и
сущности — переписывать чужую графику молча хуже, чем попросить другой
файл. При отдаче логотип получает Content-Security-Policy с sandbox и
nosniff как второй рубеж: адрес можно открыть и напрямую.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 00:03:54 +03:00
AndreyandClaude Fable 5.1 049217505a 🔇 fix(worker): пауза после сбоя опроса подключения и тишина в журнале
Подключение с ненастоящим токеном или недоступным сервером писало
предупреждение на каждом цикле опроса, каждые три секунды, на каждое
подключение. Теперь сбой транспорта — исключение PollFailed, а слой пауз
(transports.backoff) пропускает подключение с удвоением паузы от шести
секунд до четверти часа и пишет в журнал только смену состояния: первый
сбой, выход на максимальную паузу и восстановление. Курсор при сбое не
двигается. Демо-подключения из демо-набора помечаются demoSeed и не
опрашиваются вовсе: их токены ненастоящие.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 00:03:54 +03:00
AndreyandClaude Fable 5.1 bfe90e9abd 💄 fix(knowledge): пустое состояние библиотеки снова по центру
Коммит 776bc895 при переносе библиотеки на серверную пагинацию удалил
правило контейнера .knowledge-empty, оставив только вложенный блок шириной
480px — он прижимался к левому краю рабочей области. Правило возвращено
как было: flex, по центру, отступ 40px (кадр KB1).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 23:18:43 +03:00
AndreyandClaude Fable 5.1 5b49904d6a 🐛 fix(identity): адрес установки виден соседним процессам сразу, а не через 10 секунд
Кэш адреса установки живёт в каждом процессе gunicorn по 10 секунд. Мастер
первого запуска и смена адреса в «Настройках» сбрасывали его только в том
процессе, где выполнялись: соседний отвечал «Invalid host» на первый же
запрос после мастера, и приложение показывало «Ошибка загрузки» до
обновления страницы. Промах по хосту теперь перечитывает строку настроек,
но не чаще раза в секунду на процесс, чтобы поток запросов с чужим Host не
превращался в поток запросов к базе.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 23:18:43 +03:00
AndreyandClaude Fable 5.1 145c44a17d ✨ feat(internal-ui): несколько организаций — переключатель, разделы администратора установки, приглашения
Переключатель организаций в сайдбаре (дизайн-базлайн v2, A1) показывает все
организации человека, текущая отмечена, выбор другой пересобирает Shell по
ключу организации. Вход без организации в адресе открывает последнюю
открытую или первую по списку вместо экрана «нет доступа»; предпочтение
переехало в localStorage.

Разделы «Платформа» и «Хранилище файлов» видны только администратору
установки, relay для звонков у остальных менеджеров только на чтение;
настройки установки запрашиваются с /api/v1/instance/.

Ссылка-приглашение /join: вошедший принимает приглашение и попадает в новую
организацию, гость без учётной записи задаёт имя и пароль полями мастера
первого запуска. Ожидающие приглашения показаны в списке сотрудников
строками «Приглашён» с меню «отправить ещё раз» и «отозвать» — теми же
элементами, что строка сотрудника.

e2e: проекту internal-ui задана русская локаль браузера, моки сессии отдают
язык установки — сценарии перестали зависеть от языка машины; добавлены
сценарии переключателя, строки приглашения и регистрации гостя.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 14:37:52 +03:00
AndreyandClaude Fable 5.1 3e790af6e5 ✉️ feat(identity): приглашения в организацию — существующие учётные записи и владелец из провижининга
Учётная запись глобальная, а во вторую организацию было не попасть: форма
создания сотрудника отвечала «e-mail занят». Теперь занятый адрес из другой
организации получает приглашение с той же ролью, должностью, телефоном и
группами; членство появляется, когда человек принял его под своим входом.
Письмо отправляет воркер и выпускает токен в момент отправки — открытый
токен нигде не хранится. Повторное приглашение заменяет прежнее.

Приглашение владельца из платформенного провижининга доходит тем же
письмом. По ссылке /join предпросмотр говорит, есть ли учётная запись:
существующая идёт на вход и принимает приглашение, новая задаёт имя и пароль
(регистрация с теми же правилами, что в мастере) и активирует организацию.

Ожидающие приглашения отдаются в списке сотрудников блоком invitations с
действиями «отправить ещё раз» и «отозвать». Ответ на ошибки формы полями
переехал из мастера в auth/common — им пользуется и регистрация.
ADR-CHATBALLS-0047.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 14:37:25 +03:00
AndreyandClaude Fable 5.1 1390ccc426 🔒 fix(tenancy): гранты платформенной роли, backend-app только ролью app, организация видна в своём контексте
Провижининг организаций через платформенный API работал только в тестах:
роль chatballs_platform не имела прав на таблицы платформы, и уже проверка
токена падала с permission denied. Тесты ходят в базу владельцем кластера и
этого не видели — новый тест выполняет запрос под реальной runtime-ролью.

Публичный процесс backend-app держал platform-соединение ради каталогов
входа и мастера первого запуска. Теперь каталоги читаются ролью app
(SELECT на security-barrier вьюхи), а первую организацию мастер создаёт под
политикой «пока организаций нет» через SECURITY DEFINER-функцию; алиас
platform поднимается только у платформенной поверхности и воркера.

Роль app читала таблицу организаций целиком. Политика сужена до текущей
организации: входы без контекста находят id через каталог организаций и
читают строку уже внутри tenant_atomic (tenancy/lookup), воркер обходит
организации по каталогу, мастер выделяет id из последовательности заранее.
Должность владельца при провижининге берётся из словаря на языке
организации. ADR-CHATBALLS-0048.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 14:37:10 +03:00
AndreyandClaude Fable 5.1 2e52bdaea0 👥 feat(identity): администратор установки и настройки инсталляции на /api/v1/instance/
Настройки установки — адрес, почта, TURN, хранилище файлов, язык по
умолчанию — общие для всех организаций, а редактировал их любой владелец
любой организации через путь под организацией. Теперь у учётной записи есть
глобальный признак is_instance_admin: первым его получает владелец из
мастера первого запуска, существующим установкам — те, у кого был
is_superuser. Эндпоинты переехали на /api/v1/instance/ вне tenant
middleware: читает менеджер любой организации (ради карточки relay),
меняет только администратор установки. Аудит и событие переноса файлов —
уровня установки, без организации. Передача признака — командой
set_instance_admin и полем в Django admin; последнего активного
администратора отозвать нельзя.

Сессия отдаёт isInstanceAdmin. Старые пути под организацией отвечают 404.
ADR-CHATBALLS-0046.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 14:36:51 +03:00
AndreyandClaude Opus 5 6d94aa1f9f 🚨 fix(lint): порядок импортов по конфигурации из корня репозитория
Проверка ruff в контейнере шла из /app/apps/backend, где корневого
pyproject.toml нет, — правило I (сортировка импортов) там просто не
применялось, и CI поймал то, что локальный прогон пропустил. Правки чисто
механические: порядок импортов и пустые строки, оставшиеся от удалённых
констант.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 03:20:50 +03:00
AndreyandClaude Opus 5 0e6a3db9a6 🌐 feat(i18n): язык ответов агента, английское демо и язык в мастере
Язык ответов агента. Поле AIAgent.answer_language: «как у клиента»
(по умолчанию), «язык организации» или конкретный язык. Системный промпт при
этом не переводится — его читает модель, а не человек; язык задаёт отдельная
директива, потому что русский промпт сам по себе тянет ответ в русский язык.
База знаний остаётся одноязычной: поиск ведёт семантическая ветка, а
эмбеддинги кроссязычные.

Язык уходящего наружу текста. Приглашение на звонок, просьба поделиться
контактом, подписи кнопок мессенджеров, тема письма, приветствие виджета и
ответы бота уведомлений берут язык организации (i18n.customer_language), а не
язык запроса: клиент не должен получать письмо на языке того сотрудника, кто
нажал кнопку, и половина такого текста рождается в воркере, где запроса нет.

Английское демо. Манифесты разложены по demo_seed/data/<язык>/, набор
выбирается языком организации. Английский набор повторяет русский по составу —
тот же каст, те же тринадцать диалогов, — переводился только текст. Свои
документы знаний и каталог тканей; аватары и голосовые общие. Демо-провайдер
стал двуязычным: наборы стоп-слов и ключевых слов на оба языка, язык
определяется по письму клиента.

Выбор языка в мастере первого запуска. Переключатель меняет экран без
перезагрузки: форму уже заполняют, а пароль в хранилище браузера класть
нельзя. Выбранный язык становится языком установки, и на нём же идёт вся
установка — должность владельца, демо-данные, ошибки формы.

Попутно исправлено:

- Демо-стенд нельзя было редактировать. configure_agent_provider принимал
  только OpenRouter и Custom, а демо ставит агентов на встроенный DEMO.
  providerIntegrationId уходит с каждым PATCH карточки, поэтому падало любое
  сохранение агента — даже правка инструкций.
- Список типов уведомлений отдавал ярлык TextChoices — захардкоженную русскую
  строку. Ярлык переехал ключом в реестр TYPE_META.
- Результат «Проверить» у подключений (20 строк) и ошибки политики исходящих
  адресов уходили владельцу по-русски.
- Правило data/ в .gitignore ловило demo_seed/data: русские манифесты лежали в
  индексе с прежних времён, а новые файлы туда молча не попадали бы.

Каталог бэкенда — 519 ключей, наборы ru и en совпадают. Полный прогон
бэкенда: 759 passed, 1 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 03:12:18 +03:00
AndreyandClaude Opus 5 6c3dec7339 🌐 feat(i18n): язык интерфейса — русский и английский
Интерфейс был русским в коде: строки лежали прямо в JSX и в ответах API,
даты форматировались прибитым «ru-RU», склонение по числу писалось руками
в каждом файле. Теперь текста в коде нет — он живёт в словарях, а язык
выбирается человеком.

Цепочка одна на обе стороны: профиль сотрудника → организация → установка
→ браузер. Пустое значение на каждом уровне значит «как выше», а не
«русский»: тот, кто язык не трогал, поедет за организацией, когда владелец
её переключит, а выбравший явно — останется на своём.

Владелец задаёт язык организации («Настройки» → «Организация», рядом с
часовым поясом) и язык установки («Платформа») — на нём открываются вход,
сброс пароля и мастер первого запуска. Сотрудник переопределяет его в
профиле.

Русский каталог задаёт набор ключей, английский обязан его повторить:
во фронтенде это ловит tsc — пропущенный перевод становится ошибкой
сборки, — на бэкенде тест каталога, который заодно сверяет имена
параметров в фразах. Формы множественного числа берутся из CLDR через
Intl.PluralRules, даты и размеры — через Intl, а не через свои списки
месяцев.

Отдельно пришлось разобраться с тем, что уже записано в базу. Системные
события диалога писались готовой русской фразой, и перевести историю
задним числом нельзя — теперь пишется код события, а фразу собирает
сервер на языке читателя. То же с уведомлениями: они адресованы
операторам, а не одному человеку, и в смешанной команде готовая фраза
неверна для половины. Тон системной строки в треде и признак анонимного
посетителя больше не угадываются регуляркой по русским словам: под
английским бэкендом это просто перестало бы работать.

Письма уходят на языке получателя, а не отправителя: приглашение
отправляет владелец, а читает другой человек.

gettext не взят намеренно: .po/.mo потребовали бы msgfmt в сборке образа
ради того же результата, что дают обычные словари, одинаковые на обеих
сторонах. Язык запроса при этом активируется штатным механизмом Django,
поэтому сообщения DRF переводятся тоже.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 00:36:52 +03:00
AndreyandClaude Opus 5 482fa73d95 🚨 fix(lint): привести код к своей же конфигурации ruff и включить её в CI
Линтер никогда не проверял то, что задумано. Конфигурация лежит в корневом
pyproject.toml (`select = ["E","F","I","UP","B","DJ"]`), а запускали ruff из
apps/backend — там конфигурации нет, и он молча брал правила по умолчанию.
Из корня та же команда показывает 1102 нарушения вместо четырнадцати.

Что с ними сделано:

- E501 (968 из 1102) вынесен в ignore с объяснением: длину строки правит
  форматтер, а `ruff format` в этом репозитории не принят — он переписал бы
  374 файла. Порога, до которого можно поднять line-length, нет: 398 строк
  длиннее 120 символов, 222 длиннее 140. Правило, которое всегда красное,
  просто перестают читать.
- Миграции исключены из проверки: их пишет Django.
- Остальные 104 исправлены. Содержательное: assertRaises(Exception) в тесте
  доставки приглашения заменён на конкретный CallInviteDeliveryError; убрана
  мёртвая переменная identity в демо-сиде (у Conversation нет такого поля —
  единственный источник identity это контакт); порядок членов у пяти моделей
  по Django Style Guide; __str__ двум синглтонам инстанса, которые в админке
  показывались бы как «object (1)»; isinstance с кортежем → оператор |.

CI: ruff добавлен в .github/workflows/checks.yml (push и pull request — до
этого на GitHub не проверялось ничего до первого тега), в стадию validate
GitLab и в scripts/check.ps1. Везде запуск из корня репозитория, иначе
повторится та же подмена правил. Версия берётся из requirements бэкенда,
чтобы CI и образ не разъезжались.

Из pre-commit убран ruff-format: висел хук, который при первой же установке
переписал бы 374 файла — это отдельное решение, а не побочный эффект.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 07:39:18 +03:00
AndreyandClaude Opus 5 bd40ff38fe 🔧 fix(repo): переводы строк в .py — только LF
21 файл доехал до репозитория с `\r\r\n` внутри: строка кончается двумя
возвратами каретки и переводом строки. Python такое читает, поэтому оно и
дожило, но линуксовые инструменты спотыкаются — ruff принимает одиночный CR за
перевод строки и пишет его в вывод, портя файл дальше.

Содержимое не менялось: `git diff -w` по этим файлам пуст, различаются только
переводы строк. Правило `*.py text eol=lf` в .gitattributes закрывает повтор —
на Windows такие файлы теперь и выкладываются с LF.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 07:00:53 +03:00
AndreyandClaude Opus 5 f74d242988 ✅ fix(tests): признак «мастер пройден» не кэшируется в тестах
Кэш живёт на весь процесс — в проде это ровно то, что нужно: после установки
признак больше не меняется, а до неё его спрашивает каждый запрос. Но в
тестах каждый тест начинается с пустой базы, и первый же из них, который
создал организацию и постучался с нелокальным Host, закрывал мастер всем
остальным в том же процессе: тесты мастера падали с «Invalid host» в
зависимости от порядка сбора.

Заодно README описывает переход на TLS так, как он теперь работает: домен
задаётся в «Настройках», шлюз выписывает на него сертификат сам, прежний
адрес остаётся принятым.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 06:12:01 +03:00
AndreyandClaude Opus 5 cc03c5fa3d 🔒 fix(security): границы, которые обходились через соседний эндпоинт
Разбор аудита, блоки B, C и D. Каждая правка — с тестом.

Критичное:
- 2FA снималась без пароля: POST /auth/profile/totp/start/ выключал уже
  включённую 2FA, минуя и текущий пароль, и требование организации, которые
  спрашивает соседний /disable/. Теперь 409, если 2FA включена.
- Сброс пароля по письму оставлял чужие сессии живыми — то есть не помогал
  ровно в том случае, ради которого пароль и сбрасывают. Сессии завершаются,
  как при смене пароля из профиля.
- HTTPS на домене установки не выпускался никогда: ask-эндпоинт шлюза знал
  только домены порталов. Теперь он признаёт и адрес самой установки.
- WebSocket молча не работал на любой установке с TLS: браузер держит
  __Host-cookie, а Channels ищет сессию по обычному имени, и HTTP-middleware
  на хендшейк не выполняется. Добавлен chatballs.http.ws_middleware.

Существенное:
- Повтор входящего сообщения ронял весь цикл поллинга: IntegrityError ловился
  без точки сохранения внутри чужой транзакции.
- Смена адреса в «Настройках» выбрасывала того, кто её делает. Прежний адрес
  остаётся принятым (identity.0033).
- Редирект уводил скачивание во внутреннюю сеть: политика исходящих проверяла
  только исходный адрес. Проверка висит на каждом Location.
- Портал помощи можно было повесить на адрес установки и подменить
  сотрудникам приложение своим Help Center.
- Пароль прокси уходил в ответ API целиком; теперь маскируется, а маска при
  сохранении возвращает сохранённый пароль.
- /api/v1/health/ready/ закрыт на публичной границе.

Мелочи: адрес для A-записи портала считается от адреса установки, а не от
127.0.0.1; колонка EncryptedCharField вмещает шифротекст, а не открытое
значение; WS-маршруты проверяют Origin; мёртвый require_organization_scope
убран.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 05:35:16 +03:00
AndreyandClaude Opus 5 8fe66385d6 ✨ feat(release): установка на чистый хост одной командой
Раньше compose монтировал с хоста Caddyfile, init-скрипты базы и генератор
секретов. Из-за этого установка требовала рядом распакованный репозиторий, а
файл, забытый при сборке релиза, Docker молча подменял пустым каталогом — и
стек падал на первом старте у человека. Плюс production-образ backend вообще
не собирался: COPY content ссылался на каталог, которого в репозитории нет,
так что релиза не существовало ни на GitHub, ни на GitLab.

Теперь весь дистрибутив — один compose.yaml со страницы релиза:

  curl -fsSL .../compose.yaml -o compose.yaml
  docker compose up -d --wait

- Caddyfile переехал в свой образ шлюза (caddy validate — в сборке),
  init-скрипты базы — в свой образ postgres, генератор секретов — в
  backend-образ. Bind-mount'ов в production-манифесте не осталось.
- Состояние установки — именованные тома вместо каталогов рабочего каталога.
  Заодно чинит загрузку файлов на Linux: том наследует владельца из образа
  (hub), тогда как bind-mount доставался контейнеру как root:root.
- scripts/pin-release-compose.py закрепляет ссылки на образы по digest и
  падает, если хоть один ключ остался подстановкой.
- GitHub-workflow собирает четыре образа и прикладывает к релизу compose.yaml
  (основной путь) и release.env (для `chatballs deploy`).
- tests/cli/test_release_compose.py держит свойство: манифест без bind-mount'ов
  и полностью закрепляем по digest.
- Из окружения шлюза убраны CHATBALLS_APP_DOMAIN и CHATBALLS_ACME_EMAIL —
  Caddyfile их не читает.

Dev-контур не меняется по смыслу: compose.dev.yaml по-прежнему собирает всё
из исходников и держит состояние в ./data.

AUDIT-TODO.md — временный список остального из аудита; удаляется целиком,
когда закрыт последний пункт.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 04:55:17 +03:00
AndreyandClaude Opus 5 77552dfad4 ♻️ refactor(calls): статус видеозвонка считается без мнимой мемоизации
useMemo держал в зависимостях весь объект `props`, новый на каждом рендере, —
мемоизация не срабатывала ни разу. Сделать её настоящей нельзя без стабилизации
`onRetry` и `onClose` выше по дереву, а смысла в этом нет: buildStatus дёшев, а
идентичность результата никому не нужна — CallView не обёрнут в React.memo.
Считаем на месте, как это уже сделано в AudioCallOverlay.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 04:00:14 +03:00
AndreyandClaude Opus 5 48003f9bab 🐛 fix(calls): устройства отпускаются после отбоя, экран звонка на токенах
Утечка камеры и микрофона у оператора:
- терминальный статус приходит и поллингом состояния, а не только по RTC-сокету
  (до «Присоединиться» сокет ещё не открыт), поэтому `onCallState` → `stop()` не
  срабатывал. Экран уходил в `status`, а оба выхода с него RTC не глушили:
  `endAndClose` для этого режима пропускает `finish()`, а «Закрыть» зовёт
  `close()`, который только прячет модалку. Оверлей при этом остаётся
  смонтированным, так что и cleanup хука не отрабатывал — камера оператора
  горела до переключения диалога. Оба оверлея явно освобождают устройства,
  когда оверлей закрыт или звонок завершён.

call-view.css переведён на токены дизайн-системы:
- радиус карточки 24px → `--radius-4xl` (14px), тень → `--shadow-lg`,
  рамки → `--border-default`, второстепенный текст → `--text-secondary`,
  круглые кнопки → `--n-7`/`--n-9`/`--n-3`, выключенные → `--error-*`,
  таймер → `--error-bg`/`--error-text` + 12.5px и моноширинный шрифт,
  таймер переподключения → `--warning-text` и точка `--warning`,
  кнопки precall → `--radius-lg` (9px), 14px и `--shadow-primary`;
- радиус контейнера модалки взят из того же токена: снаружи виден именно он;
- хардкод оставлен там, где он есть и в макете — сцена видео и слои поверх неё
  от темы не зависят.

Побочный эффект: карточка звонка у оператора теперь следует тёмной теме
internal-ui, как остальные поверхности. Проверено в обеих темах.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 03:57:32 +03:00
AndreyandClaude Opus 5 3461556446 🐛 fix(calls): окно аудиозвонка держит пропорцию и переживает обрыв
Вёрстка:
- мобильная сцена брала высоту как `height:100%` от родителя, у которого задан
  только `min-height` — процент схлопывался в высоту по контенту, и окно выходило
  412x427 вместо 412x915. Высота берётся от вьюпорта (vh/dvh), как у видеозвонка;
- на низком десктопном окне карточка 9:16 не влезала и кнопки приёма уезжали за
  фолд — ширина зажата по высоте вьюпорта, пропорция остаётся ровно 9:16;
- операторской модалке нужен `height:auto`, иначе мобильная ветка растягивала
  сцену на весь экран внутри antd Modal;
- пустая «пилюля» канала больше не рисуется без метки.

Жизненный цикл:
- токен из хеша сохраняется в sessionStorage: хеш срезается replaceState, а в
  пути лежит callId, поэтому после F5 звонок было не восстановить;
- обрабатывается CONNECTED от собеседника — UI выходит из «Переподключения»,
  когда обрыв был на его стороне;
- у режима reconnecting появился нижний бар: экран был без единой кнопки;
- клиенту не показывается «Позвонить снова», которого он не может сделать; на
  DECLINED/MISSED/EXPIRED это была единственная кнопка, и уйти было некуда;
- «Повторить» на упавшем соединении рестартует RTC, а не перепроверяет микрофон.

Чистка:
- TERMINAL_CALL_STATUSES стал единственным источником вместо трёх копий набора;
- волна в тишине спокойная: амплитуда растёт от idle к speak по громкости;
- сигналинг и track.enabled вынесены из updater'а setState.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 03:35:20 +03:00
AndreyandClaude Opus 5 ee9cbf0995 ⚡ perf(realtime): всплеск событий приводит к одному обновлению
В оживлённой организации поток событий плотнее прежнего опроса: пять сообщений
подряд дали бы пять перезапросов списка вместо одного за четыре секунды.
События схлопываются в окно 700 мс — обновление остаётся мгновенным для
человека и не превращается в шквал запросов.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 02:34:39 +03:00
AndreyandClaude Opus 5 03825dfbd5 ⚡ perf(realtime): обновление по событию вместо опроса
Клиент опрашивал сервер: список раз в четыре секунды, карточку и дельту
истории — раз в три; на оператора выходило под сорок запросов в минуту, и новое
сообщение всё равно появлялось с задержкой.

- WebSocket-канал организации (channels уже стоял ради сигналинга звонков):
  аутентификация — сессией того же SPA, организация — в адресе, как в HTTP;
- событие несёт только повод обновиться, данные клиент забирает обычным
  запросом: проверка видимости остаётся в одном месте, и канал не может в ней
  ошибиться. Событие инбокса не содержит идентификаторов — сотрудник видит не
  все диалоги организации; на события диалога подписка отдельная, и сервер
  проверяет видимость перед ней;
- события шлют те же сигналы, что держат свежесть диалога: одно место на семь
  мест создания сообщений и на все изменения состояния;
- опрос остался запасным путём и замедляется до тридцати секунд, пока канал
  жив: при обрыве всё возвращается к прежнему поведению само.

Проверено сквозь шлюз: апгрейд проходит до консьюмера, анонимное соединение
отклоняется кодом 403.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 02:33:28 +03:00
AndreyandClaude Opus 5 f3cbf1658c ⚡ perf(db): порядок инбокса по индексу, страницы без N+1
Пагинация была правильной, но опиралась на то, что не ложится в индекс: список
диалогов сортировался по агрегату max(messages.created_at) — GROUP BY по всей
ленте на каждый запрос, — а превью строки и бейдж непрочитанных спрашивались
по два запроса на диалог, тридцать раз на страницу, каждые четыре секунды.

- Conversation.last_message_at: денормализованное поле вместо агрегата, держит
  сигнал на создание сообщения (одно место вместо семи), миграция заполняет
  существующие диалоги;
- индексы: conv_inbox_order (организация → свежесть) и conv_message_window
  (диалог → время → id) — под окно инбокса и окно истории; conv_contact_recent
  под подзапросы списка контактов; триграммы для поиска по имени и телефону;
- превью и бейдж считаются на страницу целиком (last_messages_for,
  pending_counts_for), а не построчно;
- список сотрудников: своя сортировка групп в payload отменяла prefetch, а
  организация тянулась на каждую строку — обе связи взяты в выборке;
- test_query_budget: число запросов сравнивается на разном объёме данных, так
  что вернуть N+1 незаметно больше нельзя;
- журнал аудита переведён на общий usePagedResource — он был последним списком
  со своей копией отложенного поиска и защиты от гонок.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 02:12:34 +03:00
AndreyandClaude Opus 5 ed78617722 ✨ feat(pickers): поиск в выборе ответственного и передаче владения
Выборы работали списком без поиска: при большой команде найти человека в них
было нельзя, а справочник коллег отдавался целиком.

- /conversations/directory/ отдаёт ограниченную выдачу, ищет по имени и почте
  и сообщает hasMoreEmployees;
- выбор ответственного берёт справочник сам (сквозной проп employees убран из
  ChatPage и карточки контакта) и показывает строку поиска, когда коллег
  больше, чем помещается в выдачу;
- передача владения ищет кандидатов по имени, почте и должности; строка поиска
  появляется, только если администраторов больше страницы;
- в маленькой команде оба выбора выглядят как прежде — строка поиска не
  появляется.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 01:39:11 +03:00
AndreyandClaude Opus 5 776bc89514 ⚡ perf(knowledge): библиотека знаний — страница и фильтры на сервере
Список знаний отдавался целиком, ветку категорий и фильтр по агенту отбирал
браузер, а число прикреплённых агентов считалось перебором всех карточек
агентов организации — из-за этого приходилось грузить и их.

- список знаний отдаётся страницей; ветка категорий, агент, состояние и поиск
  применяются до среза;
- счётчики знания считаются подзапросами: фильтр по агенту идёт по той же
  связи и больше не урезает «прикреплено к N агентам»;
- материал несёт agentIds, карточка — список агентов с состоянием AI: диалогу
  прикрепления и рейке карточки больше не нужен весь набор агентов;
- /agents/directory/ — компактный справочник для выпадающих выборов с именем,
  состоянием AI и числом материалов;
- четвёртый подвал со страницами (KnowledgeTableFooter) заменён общим;
- подписи «Показано N из M» в подвалах сотрудников и агентов убраны: таких
  строк нет в макетах, а счётчик уже стоит в шапке экрана.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 01:30:02 +03:00
AndreyandClaude Opus 5 e6a6bf77b6 ⚡ perf(ui): списки отдаются страницами, фильтры считает база
Пагинация в порталах и контактах была нарисованной: сервер отдавал весь набор,
браузер резал его на страницы. У сотрудников и агентов не было и этого. Любой
из четырёх списков рос вместе с организацией и целиком уезжал клиенту.

Серверная часть — страницы и фильтры до среза:
- контакты: подзапросы вместо join-агрегатов (фильтр по каналу больше не
  урезает счётчики диалогов), поиск, каналы, агенты, «с открытым диалогом»
  и порядок — в SQL;
- сотрудники: роль, группа и поиск по имени, почте и должности;
- агенты: группа и поиск;
- порталы: статус и поиск, архивные последними;
- библиотека статей: категория с вложенными, язык, статус и поиск по последней
  редакции.

Клиент:
- один подвал со страницами на всё приложение вместо трёх разных
  (PortalTableFooter, SalesClientsPagination, TablePagination удалены);
- usePagedResource: страница принадлежит набору фильтров, гонки ответов
  отсекаются, сервер решает, какая страница существует;
- useDebounced вынесен в shared — поиск придерживает запрос;
- карточка сотрудника грузится по идентификатору, а передача владения сама
  запрашивает кандидатов: список постраничный, и нужного человека может не
  быть на открытой странице;
- App больше не тянет всех сотрудников на старте.

Выпадающие выборы (ответственный, передача владения, фильтр по агентам)
работают со справочниками; библиотеке знаний нужен серверный контракт со
счётчиками прикреплений — до него потолок в 100 карточек оставлен явным.

Контракт списков и лент записан в SPEC-CHATBALLS-0031 §8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 00:05:02 +03:00
AndreyandClaude Opus 5 0636ef5fa9 ⚡ perf(conversations): история и инбокс грузятся окнами, а не целиком
Открытие диалога тянуло все сообщения разом: карточка несла messages без
ограничения, а workspace перезапрашивал её каждые 3 секунды — тысяча реплик
уезжала клиенту двадцать раз в минуту. Список диалогов приходил так же целиком
и обновлялся раз в 4 секунды.

Серверная часть:
- api/pagination: единый контракт — страницы (page/pageSize/total/pageCount) и
  окно по курсору (keyset) с потолками размера;
- карточка диалога больше не несёт сообщений ни в одном режиме; факт запроса
  контакта считает сервер, ленте для этого история не нужна;
- GET /conversations/<id>/messages/ — окно истории: before — вверх по ленте,
  after — дельта после последнего показанного сообщения;
- инбокс отдаётся окном по курсору с серверной сортировкой (активность или
  «ждущие первыми») и total по текущим фильтрам.

Клиент:
- useConversationHistory: хвост переписки при открытии, догрузка вверх,
  обновление дельтой вместо перезапроса всей ленты;
- useHistoryScroll: лента держит низ, подгружает старые сообщения у верхней
  границы и сохраняет место чтения при вклейке;
- useConversationList: окно инбокса, догрузка прокруткой, обновление головы
  списка без сброса прокрутки;
- поиск, вкладка и сортировка списка переехали в параметры запроса —
  фильтровать окно в браузере было бы неправильно;
- клавиатурная навигация вынесена в useDialogKeyboardNav.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 23:00:08 +03:00
AndreyandClaude Opus 5 c01bf8aef6 🔥 chore(db): мёртвые поля отменённых доменов удалены из схемы
Колонки пережили решения, которыми были отменены:

- `Organization.tax_regime` и `vat_mode` — налоговый профиль организации,
  отменён вместе с управленческой картиной продаж (ADR-HUB-0010, отменён
  ADR-CHATBALLS-0041). Миграция identity/0032;
- `Channel.allow_sales_attribution` и `allow_checkout_actions` — коммерческие
  флаги канала; ADR-CHATBALLS-0045 §4 объявил их запрещёнными безусловно, но
  колонки остались. Миграция channels/0008.

Вместе с флагами ушёл аппарат инвариантов канала. P1-P2 были их единственным
содержимым, P3-P5 сняты с сущностью Product: после удаления полей
`policy_violations()` всегда возвращала бы пустой кортеж, `PolicyInvariantError`
не могла быть возбуждена никогда, а `require_valid_policy()` стала бы
вызовом-пустышкой. В `channels/policy.py` остались только состав полей и их
отображение в payload для двух живых флагов (`allow_anonymous_sessions`,
`allow_self_reported_contact`).

Удалён `channels/test_policy_migration.py`: он проверял приведение данных
исторической миграции channels/0005 по колонкам, которых больше нет. Отсюда
622 backend-теста вместо 624 — ровно два его метода, покрытие не потеряно.

Из `identity/admin.py` колонки убраны из `list_display`: иначе админка падает
проверкой admin.E108.

Django выполняет `DROP COLUMN ... CASCADE` и молча уносит зависящие вьюхи,
поэтому зависимости проверены до миграции: три вьюхи зависят от таблиц, но ни
одна не читает эти колонки; политик RLS, constraint'ов и индексов на них нет.
После применения все 10 вьюх схемы chatballs на месте, колонок в
information_schema нет.

Проверка: `manage.py check` и `makemigrations --check` чисты, локальная БД
смигрирована, полный check.ps1 зелёный (typecheck ×2, vitest 76, CLI 9, e2e 8,
backend 622).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 20:44:01 +03:00
AndreyandClaude Opus 5 6a93cfcbd7 ✅ fix(tests): pytest-сьют backend снова запускается целиком
`docker compose run backend-app pytest` (то, что делает scripts/check.ps1) не
доходил до первого теста. Две независимые причины, обе давние.

1. Область сбора. В pytest.ini не было ни `testpaths`, ни `norecursedirs`, а
   compose монтирует `data/media` в `/app/apps/backend/media`. На установке с
   демо-данными это 741 каталог организаций, и сбор падал через 2:46 с
   `OSError: [Errno 12] Cannot allocate memory` на обходе медиа.

   `testpaths` перечисляет оба пакета с тестами: только `chatballs` было бы
   мало — в `chatballs_backend/test_surfaces.py` лежат проверки изоляции
   поверхностей (loopback-only admin, раздельные cookie, исключение
   gateway-эндпоинта из SSL-редиректа), и они бы молча выпали из прогона.
   `norecursedirs` добавлен сверх этого: на запуск с явным путём `testpaths`
   не распространяется.

2. Боевая функция, собираемая как тест. `ai/test_demo_provider.py` импортировал
   на уровне модуля `integrations.services.test_integration`; pytest считает
   тестом любое имя `test_*` в тестовом модуле и падал на ненайденных фикстурах
   `context` и `integration`.

   Функция не переименована намеренно: в `integrations/tests.py` и
   `integrations/test_email.py` она уже импортируется под алиасом
   `run_integration_test` — конвенция в проекте есть, в этом файле её забыли
   применить. Добавлен алиас и комментарий, зачем он.

Проверка: сбор — 625 позиций за 15 с вместо падения через 2:46; полный прогон —
624 passed, 0 failed (21 мин). Остальные наборы зелёные: internal-ui vitest
76/76, typecheck internal-ui и web-chat, CLI 9/9.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 19:26:57 +03:00
AndreyandClaude Opus 5 e088ead06f 📝 docs: идентификаторы проектных документов приведены к CHATBALLS
Документация переработана: действующее отделено от истории, отменённые
контуры (продажи, биллинг, managed AI, отделы, сущность Product) убраны из
действующих документов в архив.

Здесь — только кодовая часть: ссылки на документы в комментариях. Ссылки на
действующие документы переименованы ADR/SPEC/ARCH/BUS-HUB-NNNN →
*-CHATBALLS-NNNN (274 ссылки в 173 файлах). Ссылки на документы, ушедшие в
архив, намеренно сохранили прежний идентификатор: он совпадает с именем
архивного файла.

Логика не менялась — правки только в комментариях, докстрингах и одном
описании теста.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 18:13:33 +03:00
AndreyandClaude Opus 5 486492c1d3 🔒 fix(identity): ключ шифрования секретов в БД перестал следовать за SECRET_KEY
Ключ выводился из SECRET_KEY, а ошибка расшифровки глоталась молча. Значит
смена ключа подписи — обычное действие после утечки — делала нечитаемыми
секреты TOTP сотрудников, токены интеграций, пароль SMTP и ключи S3, и в логе
об этом не было ни строки: секреты просто становились пустыми.

Теперь ключ живёт своим файлом в томе секретов: его кладёт туда первый старт
стека, выводя из secret_key ровно тем же способом, каким это делал сам продукт.
Значение от этого не меняется, поэтому работающая установка ничего не теряет —
но ключ больше не привязан к SECRET_KEY, и подпись можно ротировать.

Человек ключ по-прежнему не вводит: файла с переменными у продукта нет.

Молчание убрано: не расшифровавшееся значение пишет предупреждение в лог, а
неверный ключ в настройке падает ImproperlyConfigured сразу, а не отдаёт пустой
секрет при первой расшифровке.

Проверено на живом томе: shell-вывод ключа совпадает с питоновским байт в байт
(сверено в образе pgvector/pgvector:pg16); после запуска скрипта сгенерированный
ключ равен действующему выводимому; приложение читает его из файла и
расшифровывает все пять сохранённых секретов интеграций. Четыре теста.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 17:22:43 +03:00
AndreyandClaude Opus 5 a2e6672424 🔥 chore: .env убран отовсюду — установка не читает файл с переменными
Переход на запуск без .env (41a4b0d) не был доведён: в дереве остались файл
`env.example`, чтение instance .env во всём deploy/cli и CI, который этот .env
сам же и создавал из `env.example`. Из-за остатков ломались две вещи.

`chatballs deploy` не работал на установке без .env: _normalize_schema_ownership
брал POSTGRES_USER и POSTGRES_DB из файла и падал с «POSTGRES_USER not set»,
хотя установка исправна. Теперь берёт те же значения по умолчанию, что compose.

`chatballs doctor` выдавал пять ложных ошибок подряд: искал в .env домены,
ACME-почту, POSTGRES_PASSWORD и CHATBALLS_SECRET_KEY. Первое задаёт владелец
в «Настройках», второе генерирует в том с секретами первый старт стека —
снаружи, с хоста, этого не видно, и проверки убраны.

Убрано: env.example (из репозитория, release bundle и README); instance_env_file()
и все его чтения в common/compose/deploy/doctor/status; --env-file instance .env
из вызова compose — остаётся только release.env с digest-пинами образов от CI;
создание .env в gateway:validate. Профиль calls и его адреса читаются из
переменных окружения — оттуда же, откуда их берёт сам compose.

Тесты CLI переведены с фиктивного .env на переменные окружения; три проверки
удалённого поведения doctor убраны. Девять оставшихся проходят вообще без .env
— это и есть проверка, что установка теперь обслуживается. Заодно у
test_custocrm_cli.py выправлены окончания строк: в файле были одиночные CR
внутри кода, отчего diff по нему больше содержательной правки.

Проверено: pytest tests/cli — 9 passed; docker compose config валиден с пустым
каталогом инстанса; bash -n чист по всем скриптам CLI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 17:22:29 +03:00
AndreyandClaude Opus 5 03914fba09 ⚡ perf(conversations): расшифровка ждёт провайдера без транзакции
TenantContextMiddleware заворачивает весь вызов view в транзакцию: RLS-контекст
ставится через SET LOCAL и живёт ровно столько же. Удобно и даёт запросу
атомарность, но пока идёт обращение наружу, запрос держит соединение из пула, а
пул на процесс небольшой. Расшифровка голосового ждёт провайдера до 90 секунд
(30 с таймаут на три попытки) — несколько операторов, нажавших «расшифровать»,
вставали поперёк всех остальных запросов процесса.

Появился отказ от общей транзакции: вьюха выставляет
`tenant_manages_own_transaction` и открывает tenant_atomic сама. Забытый блок
не опасен — без транзакции RLS-настройка пуста и строки просто не видны, то
есть ошибка проявится сразу, а не утечкой в чужую организацию.

`transcribe_voice_message` разложена на prepare → run → store, и вьюха держит
транзакцию только вокруг чтения и вокруг записи. Прежний вызов целиком остался
для ingest, где входящее сообщение обрабатывается одной транзакцией.

Тест на TransactionTestCase (обычный TestCase сам держит транзакцию на весь
тест) проверяет напрямую: во время вызова провайдера in_atomic_block — False,
RLS-контекст снят, результат сохранён.

Остальные держатели транзакции не тронуты намеренно. В ingest_inbound
обращения наружу чередуются с записью по всей длине, и общая транзакция даёт
там атомарность приёма — разносить её по фазам значит менять семантику, а не
рефакторить. Отправка файла и голосового оператором опирается на синхронный
502 от канала. Проверка интеграции для WEB-провайдера сама пишет в БД.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 13:58:39 +03:00
AndreyandClaude Opus 5 3659ce564a 🔒 fix(calls): сокет сигналинга не ждёт аутентификации вечно
Константа AUTH_TIMEOUT_CLOSE намекала на срок, но кода не было: соединение
принималось до аутентификации (иначе клиенту некуда прислать токен) и дальше
ждало первое сообщение сколько угодно долго. Неаутентифицированный клиент так
держал сокет и запись в channel layer.

Срок — 10 секунд, снимается при успешной аутентификации и при отключении.

Два теста.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 13:58:39 +03:00
AndreyandClaude Opus 5 037be19786 ⚡ perf(identity): перебор только живых сессий, воркер чистит просроченные
Владельца сессии хранит её подписанное содержимое, отдельной колонки у
django_session нет, поэтому счётчик сессий сотрудника расшифровывал строки и
сравнивал. Перебиралась вся таблица — а просроченные строки Django сам не
удаляет и `clearsessions` не звал никто, так что стоимость просмотра карточки
сотрудника росла вместе с историей входов за всё время. Перебор ограничен
живыми сессиями, а чистку просроченных ведёт maintenance-цикл воркера.

Там же исправлено голодание самого воркера: обработка события обрывала цикл
на `continue`, и при непрерывном потоке событий — а породить его может кто
угодно через публичный виджет — переставали забираться входящие сообщения и
истекать приглашения на звонки. Периодические работы теперь выполняются в
любом случае, а сон остался только когда работы нет: скорость разбора очереди
не пострадала.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 13:58:20 +03:00
AndreyandClaude Opus 5 642c49bc4a 🔒 fix(support): файлы статей — inline только картинки, тип не от клиента
Файл статьи отдавался inline с типом, который прислал загружавший, и на домене
портала. То есть `text/html` открывался как страница на чужом домене, а имя
файла не прогонялось через safe_filename. Спасала только CSP поверхности —
единственный слой, и он теряется, если установщик переопределит CHATBALLS_APP_CSP.

Теперь inline остались картинки и PDF, остальное уходит вложением; тип
приводится к известному, а незнакомый угадывается по имени файла — значение
идёт в заголовок ответа, брать его у клиента как есть нельзя. Плюс на каждый
такой ответ ставится своя `default-src 'none'; sandbox`: svg-картинка со
скриптом внутри не должна зависеть от того, не ослабят ли CSP установки.

Три теста.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 13:58:20 +03:00
AndreyandClaude Opus 5 9606be8bc9 🔒 fix(identity): код TOTP принимается один раз
Отметка `totp_last_used_at` писалась, но ни с чем не сравнивалась. Код живёт
30 секунд и принимается с окном ±1 интервал, значит подсмотренный код работал
второй раз ещё полторы минуты — RFC 6238 §5.2 требует запоминать интервал.
Появилось поле `totp_last_counter`: `verify_totp` возвращает номер принятого
интервала, и коды до него включительно больше не проходят. Тем же путём идёт
подтверждение при настройке 2FA — код, которым её включили, на входе не
сработает.

Заодно два соседних. Шаг с кодом доставал пользователя без `is_active`: пароль
приняли раньше, и сотрудник, отключённый между шагами, всё равно входил.
И у начатого входа не было срока — брошенная вкладка ждала код сколько угодно
долго; теперь пять минут.

Три теста.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 13:58:02 +03:00
AndreyandClaude Opus 5 28909f6951 🐛 fix(events): X-Correlation-Id из запроса больше не валит запись аудита
Значение приходило заголовком и ложилось в CharField(max_length=128) записи
аудита и очереди событий как есть. Строка длиннее валила запись в БД, и первым
это ловил публичный /api/v1/auth/login/, где аудит пишется на каждую неудачную
попытку: вместо 401 приходило 500, а след входа терялся. Заголовок разрешён в
CORS, то есть это штатный клиентский ввод.

Теперь значение обрезается до 128 символов и чистится до печатного ASCII:
оно идёт и в строку лога, и в заголовок ответа.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 13:57:50 +03:00
AndreyandClaude Opus 5 64f94756a7 🔒 fix(http): по TLS сильнее защищённое имя cookie, Vary не затирается
Cookie с префиксом `__Host-` браузер принимает только с самого хоста, по TLS
и без Domain — в этом весь её смысл. Но при наличии обеих сторона выигрывало
обычное имя, а его может выставить сосед по родительскому домену: приоритет
у незащищённого имени сводил защиту на нет и позволял подменить и сессию, и
CSRF-токен. Теперь защищённое имя всегда сильнее, а обычное по TLS вовсе
игнорируется — сами мы его по TLS не выдаём, значит пришло оно не от нас.
Цена — один повторный вход в тот момент, когда перед установкой впервые
появился сертификат: cookie, выданные по http, дальше не в счёт.

`Vary` ставился присваиванием и сносил «Vary: Cookie», который Django
проставляет на ответах с данными пользователя. За общим кешем это позволяло
отдать ответ одного пользователя другому. Заменено на patch_vary_headers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 13:57:38 +03:00
AndreyandClaude Opus 5 cbb16e3862 🔒 fix(integrations): исходящие только по http/https и мимо внутренней сети
`base_url` и `proxy_url` сохранялись свободными строками, без проверки схемы.
Дальше ответ провайдера задавал адрес скачивания вложения, а `download_bytes`
открывал его голым `urllib.request.build_opener()` — у которого включены
обработчики file и ftp. То есть владелец организации, поставив `base_url` на
свой сервер, получал ответом ссылку `file:///run/chatballs/secrets/secret_key`
и содержимое секрета оседало вложением в диалоге. Тем же путём проверка
«Проверить» ходила по внутренним адресам и возвращала код ответа в UI.

Адреса разделены по источнику доверия (chatballs.integrations.outbound).
Настройку вводит владелец, и он вправе указать хост внутри своей сети —
self-hosted ставит рядом свой Bot API или LLM-сервер; у неё проверяется только
схема. А адрес из ответа провайдера — данные: он ограничен http/https и
публичными хостами, кроме того хоста, который владелец сам задал в `base_url`
этого подключения. При настроенном прокси проверка хоста снимается: до цели
хаб идёт не сам, а при socks5h локальный резолвер о ней и не знает.

Сбой DNS считается разрешением, а не отказом: соединение пойдёт через тот же
резолвер и упадёт там же, так что запрещать нечего, — иначе скачивание
вложений стало бы заложником доступности DNS. От DNS rebinding проверка не
спасает, и это записано в коде честно.

Второй рубеж — opener: `build_opener` штатные обработчики не убирает, но
предпочитает наследника, поэтому file, ftp и data подменены заглушками.
Это закрывает и редирект на ftp, который urllib пропускает сам.

13 тестов.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 13:57:27 +03:00
AndreyandClaude Opus 5 6f422ac67c 🔒 fix(webchat): виджет открыт миру — origin по заголовкам, лимиты, срок сессии
Публичные endpoint'ы виджета принимают кого угодно без входа и без CSRF, и
до сих пор у них не было ни одного ограничителя. Три дыры разом.

Origin страницы-хозяина брался из поля `hostOrigin` тела запроса — раньше
заголовков. Поле заявляет клиент, поэтому ограничение по доменам виджета
(`allowed_origins`) обходилось строчкой в curl. Теперь решают заголовки,
которые ставит браузер, а `hostOrigin` читается только там, где они указывают
на нас самих: виджет живёт в iframe на нашем же origin, и узнать хозяина
оттуда больше нечем. Что это заявление, а не доказательство, записано в
докстринге `host_origin` — чтобы поверх не построили настоящий контроль.

Лимитов не было вовсе. Каждое сообщение — ход AI по ключу организации
(ingest зовёт провайдера прямо в запросе), каждый файл — место в хранилище:
один скрипт тратил чужие деньги и диск, а поток порождённых событий вдобавок
вытеснял из воркера поллинг мессенджеров. Контуров два. По адресу клиента —
для того, у кого сессии ещё нет; потолки нарочно высокие, потому что за одним
адресом мобильного оператора сидят тысячи живых посетителей. По токену сессии
— строго: за NAT адрес общий, и счёт по нему либо бьёт по соседям, либо
бесполезен. Токен для лимита достаётся из заголовка, а не из тела: иначе
двадцатимегабайтная загрузка доезжала бы до сервера прежде, чем её отобьют.

Сессия не истекала никогда. Токен лежит в localStorage: на общем компьютере
он открывал бы чужую переписку сколько угодно долго. Теперь срок считается от
последней активности (30 суток), отметка обновляется не чаще раза в час —
иначе опрос раз в 2.5 с писал бы в строку сессии четыре раза в минуту.
Виджет на 401 забывает токен и предлагает начать заново, а не висит молча.

Пять тестов. Для главного отдельно проверено, что он ловит прежнее поведение:
со старым `_origin` подделанный `hostOrigin` давал 201 вместо 404.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 13:57:10 +03:00
AndreyandClaude Opus 5 a9139c5c66 💄 fix(agents): «Выключить агента» и «Удалить» — в меню карточки
Под карточкой висел свой ряд кнопок: две самодельные `agent-footer-button`
и подпись про условие удаления. На странице уже есть меню действий агента,
и опасное действие рядом с обычной кнопкой — лишний повод промахнуться.

Оба пункта переехали в меню за ⋯: «Скопировать код», разделитель,
«Выключить агента» (или «Включить») и «Удалить агента» красным по общему
стандарту `button.danger`. Подпись про диалоги и подключения не потерялась —
она и так в подтверждении удаления, где её читают перед решением. Стили
`agent-footer*` удалены, переключатель AI в шапке не тронут.

Проверено на «Консультанте»: пункты в меню активны, разделитель на месте,
удаление красное, ряда кнопок под карточкой больше нет. Ничего не удалял и
не выключал. Typecheck зелёный.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 03:15:47 +03:00