# Changelog Все заметные изменения Полки. Формат — [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/), версии — [SemVer](https://semver.org/lang/ru/). До `1.0.0` API, схема БД и интерфейс могут меняться между prerelease-версиями. ## [Unreleased] ### Added - **Стенд «Контур»** ([deploy/contour-stand](deploy/contour-stand/README.md)) — окружение для сторонних агентов на локальной ВМ: профиль задачи от ИБ, диспетчер (Keycloak → ключ LiteLLM с бюджетом → токен задачи Полки → правила Squid → песочница gVisor), подстановка git-токена прокси, Gitea с защищённым `main`, журнал по `task_id` с расходом и сводкой сети, отзыв задачи. `checks.sh` проверяет границы изнутри песочницы и ищет секреты стенда в её окружении. - **Рой на стенде «Контур»** ([deploy/contour-stand](deploy/contour-stand/README.md#рой-компания-из-агентов)) — процессы компании как пакеты в каталоге, а не отдельные внедрения. В составе: маркет навыков с допуском по digest, шина NATS JetStream, политика OPA со ступенями доверия, проверки в песочнице без сети, приёмка версии на Полке, шлюз действий с идемпотентностью и компенсацией, системы учёта только с API, подпись только принятой версии. Новый процесс появляется из описания словами и теневого прогона на прошлых данных. `swarm-demo.sh` показывает весь путь, `swarm.mjs selftest` добавляет проверки в `checks.sh`. - **Финансовый стенд, этап M1** ([deploy/finance-stand](deploy/finance-stand/README.md)) — внешний мир синтетической фирмы для первой версии финансового продукта «хвосты квартала». Детерминированный генератор квартала с 40 заложенными хвостами и эталоном бухгалтера. Банк A отдаёт выписку в схеме T-API, банк B — в формате «Клиент-банк → 1С» (windows-1251). Есть ЭДО, учёт по образцу OData 1С (писать можно только черновиком) и почта; симулятор контрагентов отвечает по их профилям. Адаптеры банков: токен банка держит координатор, а не песочница. `fin.sh contract` проверяет адаптер на публичной песочнице Т-Бизнеса. - **Финансовый стенд, этап M2** — журнал обязательств на событиях. Обязательство закрыто только по обеим линиям: деньги и документы. Повтор оплаты, расхождение учёта с ЭДО и смена реквизитов — исключения, их закрывает человек. Писать контрагенту можно не чаще раза в 3 дня. Сверка `fin.match` идёт через рой: навык `fin-match` в песочнице без сети, проверки, политика, отчёт на Полке, хук пишет события в журнал. `fin.sh audit` делает ту же сверку на выгрузках компании («Клиент-банк → 1С», T-API, реестр ЭДО, счета из учёта) в отдельном журнале. - **Финансовый стенд, этап M3** — работа с хвостами: запросы первички поставщикам (ЭДО или письмо), напоминания должникам, разбор ответов в ящике бухгалтерии, черновики актов сверки. Каждый шаг — пакет со своим навыком и проверками, письма уходят после приёмки на Полке. Слова письма не закрывают оплату, «новые реквизиты» решает человек, исправленный УПД сам снимает исключение по НДС. Акт сверки подписывает только роль `signer`. `finance-demo.sh` показывает две недели работы на синтетической фирме. - **Финансовый стенд, этап M4** — границы стенда в `checks.sh`. Изнутри песочницы агента: ЭДО только читается, банк без токена закрыт, до API банка не достучаться. Снаружи (`fin.mjs selftest`): платёжных действий нет, а политика запрещает `bank.payment`; реквизиты из письма уходят человеку; повтор запроса не создаёт второй документ; журнал отказывает второму письму раньше трёх дней; картина пересобирается из журнала; навыки сверены с допуском. - **Финансовый стенд, этап M5** — аудит на выгрузках компании в том виде, в каком их отдаёт бухгалтер. Читаются списки 1С в Excel и CSV (счета, поступления, реализации, контрагенты), реестры Диадока и СБИС, «Клиент-банк» в Windows и DOS с несколькими счетами. Перед сверкой печатается паспорт выгрузки: что взято из каждого файла, сходятся ли выписки с итогами банка, чего не хватает (`fin.sh audit --check`). Навык `fin-match` 1.2.0: - находит контрагента по наименованию, если в списке нет ИНН; - различает одинаковые номера счетов у разных поставщиков; - сопоставляет УПД без основания по контрагенту, сумме и дате; - раскладывает платёж за несколько счетов; - принимает законные ставки НДС (22, 10, 7, 5, 0). `fin.sh review` и `fin.sh verdicts` дают точность и полноту по разметке бухгалтера: это проверка допущения 2 на настоящих данных. - **Финансовый стенд, этап M6** — документы из почты в аудите. Читаются выгрузки ящика (`.mbox`, `.eml`) и вложения папкой. УПД в XML по формату ФНС (5.01 и 5.03) берётся без модели. PDF рядом с XML считается печатной формой. PDF или скан без XML паспорт называет с письмом, а счёт остаётся хвостом. УПД, пришедший и через ЭДО, и письмом, считается один раз. - **Финансовый стенд, этап M7** — прямые подключения компании: банк через T-API, 1С через стандартный OData, отдельный ящик для документов через IMAP. Все подключения только читают. Коннектор кладёт новое с прошлого прогона в папку компании в том же виде, что и ручные выгрузки, поэтому аудит не меняется. Доступы хранятся в OpenBao (`fin.sh secret`, из stdin) и идут только по TLS, кроме loopback. Если источник не ответил, остальные работают дальше, а отчёт прогона в паспорте называет, на какую дату его данные. `fin.sh connect <компания> --audit`, ежедневный таймер. В ВМ есть компания для показа с настоящим IMAP-сервером: через подключения она даёт те же 41 хвост, что и ручные выгрузки. - **Финансовый стенд: расписание шагов** — пакеты говорят, когда идут сами (`trigger.timer`): сверка `fin.match` каждый день в 09:00, запросы первички в 09:30, напоминания должникам по понедельникам в 10:00, разбор писем на каждом проходе таймера. `fin.mjs due` запускает то, что пора, в порядке рабочего дня; пропущенный за неделю слот идёт один раз, упавший шаг — снова на следующем проходе. Таймер `finance-schedule.timer` (каждые 5 минут) включается `fin.sh schedule on`, по умолчанию выключен. Акты сверки по-прежнему по запросу. - **Финансовый стенд: решения по исключениям на Полке** — каждое открытое исключение (смена реквизитов, двойная оплата, учёт ≠ ЭДО, ошибка НДС, сумма УПД) один раз уходит на Полку работой пакета `fin.exceptions`. На странице: что случилось, основание и безопасное решение. Приёмка версии записывает решение в журнал обязательств через шлюз (`fin.decision`, запись хука пакета), повтор не пишет второе. В границах ИБ `human_only`: решает только человек на любой ступени. В рое маршрут действия может быть записью хука пакета (`"hook": true`), в политике — флаг `human_only`. Подпись актов сверки остаётся за ролью `signer`. - **Финансовый стенд, ревью M7.** Исправлено: - токен Т-Банка тоже идёт только по TLS, кроме loopback, а свой корень компании действует и для банка; - одно огромное письмо больше не останавливает ящик; - два одновременных прогона и прогон, упавший посередине, не задваивают письма; - удалённый файл подключения берётся с начала периода; - ручные выгрузки рядом с файлами подключений не считаются дважды; - запрос к 1С кодирует пробелы как `%20`; - счета контрагента, помеченного на удаление, не пропадают; - самопроверка не проходит, если не прочитала секреты. ### Changed - **Журнал обязательств принимает любую законную ставку НДС 2026 года** (22, 10, 7, 5, 0) и реестр без НДС. Раньше УПД поставщика на УСН без НДС становился исключением «ошибка НДС». - **`fin.sh sync` перезапускает службы стенда.** Рой загружает хуки стенда один раз, поэтому после копирования новых файлов он продолжал работать на старом коде. - **Рой стенда «Контур» передаёт параметры цели и даёт пакету право вето.** `swarm.mjs goal` принимает `--param`. Хук пакета может отказать в действии до шлюза (`beforeEffect`) и получает контекст: публикацию на шину и запись в ленту цели. - **Рой стенда «Контур» опрашивает события Полки раз в 8 секунд, а не раз в 4.** Опрос раз в 4 секунды давал 150 запросов за 10 минут при лимите Полки 120 на токен. После нескольких минут работы демона `reader-bot` получал 429, и рой переставал видеть приёмку. - **Рой стенда «Контур» подключает другие стенды** через `/etc/contour/extensions.json`: свои каталоги навыков, пакеты процессов и маршруты действий шлюза; вместо цепочки условий шлюз берёт маршруты из таблицы. Свои границы стенд кладёт в `policy/ext/<стенд>/`, а хук пакета (`work.hook`) видит результат и выполненные действия. В Squid и диспетчере новое назначение `fin` — только чтение финансовых систем. ## [0.19.0] — 2026-10-17 Обновление: без миграции. Журнал агентов и новые права по умолчанию работают только с `TEAM_SHELVES=on` (и `SERVICE_ACCOUNTS=on` для сервисных доступов). ### Added - **Стенд «агент под ограничениями»** ([deploy/agent-stand](deploy/agent-stand/README.md)) — показ для службы ИБ на локальной ВМ: Codex в песочнице `srt` с токеном задачи сервисного доступа, обходы прав из bash, журнал агентов, приёмка, замер отзыва. - **Проверка песочницы агента** ([AGENT_SANDBOX](docs/specs/AGENT_SANDBOX.md)). `scripts/polka-sandbox-check.mjs` (Node 22, без зависимостей) проверяет на конкретной машине, что песочница агента не даёт прочитать приманки (`cat`, `bash`, `python3`, `node`), не пропускает секрет из окружения, запись вне рабочей папки, сеть и DNS, Docker. Каждая проверка идёт в паре с контролем без песочницы, каждый инструмент сначала читает разрешённый файл, поэтому сломанная песочница даёт «не проверено», а не «заблокировано». Отчёт — таблица и JSON. - **Журнал агентов полки отдела** ([TEAM_SHELVES](docs/specs/TEAM_SHELVES.md#журнал-агентов-16102026)). `GET /api/shelves/:id/agent-audit` для администратора полки: действия под токенами агентов, события сервисных доступов и приёмка — с подключением, сервисным доступом, номером задачи и ответственным. Чтение содержимого версии сервисным доступом теперь пишется в журнал (`revision.read`, одна строка на задачу и версию); чтения людей остаются дневным счётчиком. Без миграции. ### Changed - **Пустая полка не обещает загрузку папки.** Карточка «Загрузить файл» говорила, что с компьютера можно загрузить папку проекта, а выбора папки в вебе нет. Теперь: «Папку проекта сохраняет агент». - **Узкие права агента на полке отдела.** На странице согласия OAuth для полки отдела по умолчанию отмечены только «Сведения и статус», чтение, исходники и «Сохранять новые работы»; «Создавать версии» и «Управлять…» отмечаются вручную ([MCP_CONNECTOR](docs/MCP_CONNECTOR.md)). На своей полке по-прежнему отмечено всё. Галочки помнятся для каждой полки отдельно. В ответе `GET /oauth/authorize/details` появилось поле `teamDefaultScopes`. ### Fixed - **Ссылка после правки.** Если агент после `polka_revise` просил новую ссылку, а у работы уже была открытая ссылка на прошлую версию, Полка отвечала 409 и не называла эту ссылку, так что перенести её (`polka_share` с `moveShareId`) агент не мог. Теперь отказ содержит `shareId` и `revisionId` открытой ссылки. - **«Вставить текст» сохраняет формат.** Вставленный текст всегда сохранялся как `.txt`, а новая версия страницы из вставки тихо становилась текстом. Теперь новая версия сохраняет формат своей работы, а новая работа становится страницей, если вставлен HTML, и документом Markdown, если у текста есть заголовок и другая разметка Markdown; под полем видно, как он сохранится. - **Подсказки о форматах.** Блок «Подключите агента», пустая полка и главная перечисляли только HTML, текст и изображения; теперь называют и Markdown, CSV, JSON, PDF (блок агента берёт тот же список, что окно загрузки). ## [0.18.0] — 2026-10-15 Обновление: миграция 077 (сделайте дамп); рецепты прав `runtime-grants.sql`, `purge-worker-grants.sql` и `restore-worker-grants.sql` теперь ждут ровно 001–077. ### Added - **Markdown, CSV и JSON — работы из одного файла** ([SINGLE_FILE_FORMATS](docs/specs/SINGLE_FILE_FORMATS.md), часть #46). Файлы `.md`, `.csv`, `.json` сохраняются как есть: с компьютера, через `polka_publish` и `POST /api/v1/publish` (`kind: "file"` с `name` и `text` или `base64`) и через `polka-publish.mjs`, который раньше заворачивал Markdown в HTML. Полка показывает Markdown свёрстанным документом, CSV — таблицей (первые 2 000 строк), JSON — кодом; страницу рисует сама, без скриптов, и отдаёт только с домена просмотра по правилам статичного просмотра, для владельца и по ссылке. Без домена просмотра показывается текст файла. Сервер проверяет UTF-8 и разбор JSON; поиск, фильтр содержимого, модель-ревьюер, обложка, контекст агента (`source.md`, `source.csv`, `source.json`) и перенос полки знают новые форматы. В пакетах ничего не меняется. - **PDF — работа из одного файла** ([SINGLE_FILE_FORMATS](docs/specs/SINGLE_FILE_FORMATS.md#pdf), часть 2 #46). PDF до 5 МБ сохраняется с компьютера, через `polka_publish` и `POST /api/v1/publish` (`kind: "file"`, байты в `base64`) и через `polka-publish.mjs`. Сохранение проверяет, что pdf.js открывает файл: повреждённый файл или PDF с паролем — 422. На домене просмотра PDF рисует pdf.js, который отдаёт сам домен просмотра (без CDN), страницы — по мере прокрутки, по ширине экрана; JavaScript и формы PDF не выполняются, CSP страницы не разрешает ни eval, ни worker, ни сеть. Без домена просмотра владелец видит карточку «Скачать PDF», ссылка не выпускается; получатель оригинал не скачивает. Текст PDF (pdf.js в пуле воркеров, с дедлайном) идёт в поиск, фильтр содержимого, модели-ревьюеру и обложку «Документ»; ссылки PDF проверяются на фишинг как ссылки страницы; агент получает `source.pdf` и `source.txt` с текстом. Новая зависимость — `pdfjs-dist` 6.3.289 (Apache-2.0). - **Кто сохранил работу.** В списке работ и в карточке работы есть `savedBy`: кто сохранил последнюю версию — человек или подключение агента с его именем (из журнала `revision.saved`, одним запросом на страницу, частичный индекс `audit_outbox_saved`). На карточке — «Сохранил Codex», в «О работе» — «Сохранил Codex» или «Сохранили вы» ([ONBOARDING_V2](docs/specs/ONBOARDING_V2.md#решения-владельца-15102026)). - **Интерактивный пример.** Где интерактивный просмотр работает на отдельном домене (`liveMode=production`, polochka.page), «Сохранить пример» на `/start` кладёт на полку страницу с переключателем недель (профиль `limited`); на остальных установках — прежний статичный пример. - **Подключение видно сразу.** После «Разрешить» страница согласия сообщает об этом другим вкладкам Полки (`BroadcastChannel('polka:agent-connected')`) и показывает «Вернуться на полку»; открытая полка перечитывает подключения сразу и ещё дважды за 5 секунд (агент завершает вход через мгновение после «Разрешить»). Опрос раз в 8 секунд остаётся запасным. - **Проверка восстановления на минуту без остановки** (`deploy/hosted/pitr-drill.sh`, [hosted](deploy/hosted/README.md#восстановление-на-момент-времени)): восстанавливает базовый бэкап и WAL во временный том, поднимает PostgreSQL без сети и сверяет копию с работающей базой — в копии должны быть все версии работ, сохранённые до цели. Ключ чтения бэкапов, цель по умолчанию — 30 минут назад; том удаляется при выходе. ### Changed - **«Подключите агента» только новым полкам.** Блок наверху полки видят полки младше 30 дней или с меньше чем 3 работами; остальные — одну строку с «Подключить агента», которая открывает шаги по просьбе. - **«Скрыть» хранится в аккаунте** (`accounts.onboarding_hidden_at`, `POST /api/account/onboarding/hide` и `…/unhide`, поле `onboardingHiddenAt` в `/api/session`) и действует во всех браузерах, включая приватные окна. Скрытие, сделанное до обновления в этом браузере, при следующем визите переносится в аккаунт. ## [0.17.1] — 2026-10-14 Обновление без миграций. С профилем `pitr` образ бэкапа пересобирается, `wal-archive` перезапускается и начинает писать `wal/heartbeat`; `objects-mirror` тоже перезапускается. ### Added - **Статус оператора видит архив журнала.** С профилем `pitr` `/api/ops/status` получает проверку `walArchive`: `wal-archive` раз в 5 минут обновляет `BACKUP_BUCKET/wal/heartbeat`, и проверка краснеет, если отметка старше 15 минут или самый новый базовый бэкап в `base/` старше `BASE_BACKUP_INTERVAL_SECONDS` плюс 2 часа. Раньше о сбое архива говорил только healthcheck контейнера, а алерты мониторинга его не видели. Профили приложение берёт из `COMPOSE_PROFILES` (`OPS_COMPOSE_PROFILES` в compose); новых прав ключу приложения не нужно — только листинг ([hosted](deploy/hosted/README.md#мониторинг)). - **Точки расширения для объединения аккаунтов** ([EXTENSIONS](docs/specs/EXTENSIONS.md#точки-расширения)): `policies.accountMerge` может запретить объединение (на `/claim` — 409, у оператора — «Отказ»), например для аккаунта, которым управляет каталог компании; `accountMerged` переносит записи расширения в той же транзакции, и его ошибка отменяет объединение. Команда оператора `account merge` теперь загружает расширения из `POLKA_EXTENSIONS`. ### Changed - **Зависимости:** 13 npm-пакетов в пределах минорных версий (среди них lucide-react 1.55.0 в библиотеках сборки страниц), playwright-core и образ рендерера 1.64.1 (Chromium прежний), образ бэкапа на свежем postgres:16-alpine, setup-node 7.1.0 в CI. - **Восстановление на новую машину:** событие `restore.failed` команды `restore-target` называет класс ошибки, её код и статус хранилища (без текста ошибки, в нём бывают строки подключения): по нему видно, что сломалось — база или хранилище. ### Fixed - **Сохранение проекта под нагрузкой.** После сохранения сервер проверял сборку каждой страницы с React по очереди, и каждая могла ждать свободный сборщик до 10 секунд: проект из многих страниц держал ответ агенту минутами, а «сервер занят» попадал в `pagesNotBuilt` как страница, которая не собирается. Теперь занятый сборщик заканчивает проверку сразу, вся проверка — не дольше 20 секунд, а непроверенные страницы собираются при первом открытии ([PROJECTS](docs/specs/PROJECTS.md#страницы-с-react)). - **Код выхода при остановке.** Если сторож пула БД останавливал процесс во время остановки по SIGTERM, процесс завершался с кодом 0; теперь — с наибольшим запрошенным (1). ## [0.17.0] — 2026-10-14 Обновление: миграции 075 и 076 (сделайте дамп), `postgres` один раз перезапускается с новыми настройками сервера, роли получают таймауты из `role-settings.sql`, Caddy перезапускается ради лимитов тел и сжатия ([hosted → Обновление](deploy/hosted/README.md#обновление)). Восстановление на минуту и копия работ включаются отдельно: профили `pitr` и `mirror`. ### Added - **Восстановление базы на любую минуту** (профиль `pitr`, [hosted](deploy/hosted/README.md#восстановление-на-момент-времени)): служба `wal-archive` принимает журнал предзаписи по репликационному соединению (`pg_receivewal`, слот `polka_wal`) и раз в минуту кладёт его в `BACKUP_BUCKET/wal/`, раз в сутки делает базовый бэкап в `base/`; `polka-pitr-restore` в образе бэкапа готовит том базы к восстановлению на заданное время UTC или до последнего байта. RPO — около минуты вместо часа. Новые настройки `POLKA_REPLICA_PASSWORD`, `WAL_UPLOAD_SECONDS`, `BASE_BACKUP_INTERVAL_SECONDS`; роль `polka_replica` создают `init-roles.sh` и `worker-roles.sh`; PostgreSQL держит для остановленного архива не больше 2 ГБ WAL (`max_slot_wal_keep_size`, перезапуск `postgres` при обновлении). Ключу записи бэкапов нужны `wal/*` и `base/*` в политике бакета. - **Копия работ под object lock** (профиль `mirror`, [hosted](deploy/hosted/README.md#копия-работ)): служба `objects-mirror` каждые 15 минут копирует `S3_BUCKET` в `MIRROR_BUCKET` внутри хранилища своим ключом, которому нельзя удалять версии; бакет копии версионирован с блокировкой версий на 14 дней, прежние версии удаляются через 15 дней после удаления у источника. Массовое исчезновение объектов (больше `MIRROR_MAX_DELETES`) в копию не переносится и пишется как `mirror ALERT`; объект, который не удалось скопировать, назван в логе, остальное копируется, служба не перезапускается. `objects-restore` (профиль `ops`) возвращает пропавшие версии из копии и переводит на них строки базы. - **Метрики для Prometheus:** `GET /api/ops/metrics/prometheus` с токеном `OPS_STATUS_TOKEN` — запросы и гистограмма времени ответа по методу, шаблону маршрута (не адресу) и коду, пул БД (`total`, `idle`, `waiting`), задержка цикла событий, память, очередь воркеров разбора страниц, очередь вебхуков и накопительные счётчики ([hosted](deploy/hosted/README.md#метрики-для-prometheus)). - **Строка лога на запрос:** `{"event":"request","method":…,"route":…,"status":…,"ms":…,"reqId":…}` — шаблон маршрута, никогда адрес с токенами; клиент получает тот же id в `x-request-id`, он же в `request.failed`. Проверки здоровья — на уровне `debug`; `ACCESS_LOG=info|debug|off`. - **Самовосстановление приложения.** Docker не перезапускает «unhealthy» контейнер, поэтому процесс завершается сам: цикл событий заблокирован дольше `WATCHDOG_EVENT_LOOP_SECONDS` (30) — отдельный поток пишет `watchdog.event_loop_blocked` и убивает процесс; пул БД полон, запросы ждут и ничего не движется `WATCHDOG_POOL_STALL_SECONDS` (120) — `watchdog.pool_stalled` и остановка с кодом 1. `restart: unless-stopped` поднимает его заново. У `app` в `deploy/hosted/compose.yml` и `deploy/compose.base.yml` теперь `init: true`: процесс с PID 1 не может убить себя сам. ### Security - **Сессии агентов: пароль в аргументах инструмента.** `polka-sessions.mjs` проверял аргументы инструментов уже как JSON: `PASSWORD="Secr3t123!" npm run task` превращался в `PASSWORD=\"Secr3t123!\"`, экранированная кавычка останавливала правило присваивания, и пароль уходил в расшифровку. То же с heredoc (строки склеивались через `\n`) и с JSON, переданным строкой внутри аргумента. Теперь каждая строка аргументов проверяется раскодированной (и JSON внутри строки — тоже), затем весь JSON ещё раз — для значения под именем-ключом (`{"PASSWORD": "…"}`). Метки и отпечатки прежние. Плагин нужно обновить; уже отправленные расшифровки не переписаны — их заменит повторная отправка (`sync --since …`). - **Сессии агентов: отказ в загрузке расшифровки удалял сохранённую.** Повторная отправка тех же байтов токеном той же полки без права «Сессии агентов» (или отзыв токена во время загрузки) удаляла из хранилища уже сохранённую версию, хотя строка сессии на неё указывала. Теперь токен, полка и сессия проверяются до записи в хранилище, а при неудаче удаляется только версия, которую положила эта попытка и на которую строка не указывает. ### Fixed - **Тело запроса без токена больше не читается.** `/mcp`, HTTP API (`/api/v1/…`, включая расшифровки сессий до 64 МиБ) проверяли токен уже после того, как сервер принял тело целиком: два десятка анонимных запросов по 64 МиБ занимали 1,8 ГБ памяти и роняли контейнер на 1,5 ГиБ. Теперь токен проверяется по заголовкам, без него ответ 401 приходит сразу, соединение закрывается, тело не читается (на замере: +1854 МБ → +1 МБ). Новая настройка `BODY_BYTES_IN_FLIGHT_MB` (по умолчанию 256): сколько байт тел запросов сервер держит в памяти одновременно; сверх неё новый запрос с телом получает `503` с `code: "busy"` и `Retry-After: 5`, не прочитав тело. Один запрос больше бюджета по-прежнему принимается; видео проекта идёт потоком и в бюджет не входит. - **Markdown проектов:** нарисованные страницы хранились до 300 штук без счёта байт (текстовая копия огромного документа занимала мегабайты, на долгом прогоне 36 страниц — 155 МБ). Теперь не больше 64 МиБ, а текстовая копия документа больше 512 КБ не хранится. Подпись ссылок `/away` в хранимой странице истекала через 12 часов, а страница хранилась дольше: внешние ссылки старого документа переставали открываться. Теперь подпись ставится при каждой отдаче. - **Просмотр проекта после объединения аккаунтов** брал файлы по старым ключам хранилища, пока сервер не перезапустят: карта файлов версии хранилась только по её id. Теперь в ключе и ключ объекта версии. - **Импорт по URL:** ``, скрипты и другие убираемые элементы вынимались из дерева по одному с фильтром всех соседей (100 тысяч элементов — около 10¹⁰ шагов на основном потоке); теперь за один проход. Страницу больше 16 тысяч знаков сначала читает воркер с дедлайном: не прочитанную за 2 с импорт отклоняет (`too_complex`) и не разбирает на основном потоке. - **Вход по ссылке от агента после закрепления или отзыва.** Вход читал ссылку как живую, затем ждал блокировку полки; если за это время полку закрепили (закрепление удаляет слабые сессии) или отозвали подключение агента, вход всё равно выдавал слабую сессию по устаревшему решению. Теперь ссылка и подключение перечитываются под блокировкой полки, и такой вход получает «Ссылка устарела». - **Обслуживание оставляло временную полку недоудалённой.** Без конвейера удаления (`ACCOUNT_DELETION_ENABLED=false`) обслуживание закрывало простаивающую временную полку и затем удаляло её объекты и строки; сбой S3, конец времени прогона или остановка между этими шагами оставляли полку закрытой с объектами навсегда. Теперь закрытие записывает задание (`provisional_erasures`, миграция 076), и следующий прогон доводит удаление до конца; рецепты прав обновлены. - **Импорт полки и снимок на дату.** Импорт ставил работе время ухода в корзину, но не писал это событие в журнал; после восстановления работы на новой установке снимок полки за время между исходным удалением в корзину и восстановлением показывал её на полке. Теперь уход в корзину пишется в журнал с исходным временем, как отметка принятия. - **Рендерер не зависает на оборванном сжатом ответе.** Если сайт закрывал соединение посреди тела в gzip, deflate или br, запрос `/fetch` (и `robots.txt`) не завершался никогда, даже по таймауту, и держал место в очереди рендерера. Теперь такой ответ сразу заканчивается ошибкой сети. - **Лимит соединений egress-прокси рендерера учитывает ожидание DNS.** Место занимается до разрешения имени и освобождается при любом исходе, поэтому зависшие DNS-запросы больше не позволяют открыть больше `maxConnections` соединений. - **Объединение полок теряло сессии агентов.** Работы и подключения переносились, а сессии (строки, расшифровки, место, ключ отпечатков) оставались у источника и пропали бы при его очистке. Теперь они переносятся: расшифровки копируются под префикс получателя, сессия, отправленная на обе полки, остаётся одна (отправленная позже), место пересчитывается, ключ отпечатков получатель без своего ключа берёт у источника ([AGENT_SESSIONS](docs/specs/AGENT_SESSIONS.md#объединение-полок)). Заодно переносятся ответственный за работы (`owner_account_id`), автор перенесённых ссылок и предложений в «Ленту». Список `MERGE_INVENTORY` и тест сверяют со схемой каждую колонку, указывающую на аккаунт или полку: новая таблица без решения для объединения не пройдёт тесты. - **Страница сессии с большой расшифровкой держала сервер.** Чтобы показать 50 событий, сервер скачивал расшифровку целиком, распаковывал до 256 МиБ в память и делил на строки: на синтетической расшифровке в 250 МБ пик памяти ~1 000 МБ и 0,3 с остановки цикла событий. Теперь она читается из хранилища потоком и распаковывается по кускам, в памяти — только запрошенные строки: пик ~210 МБ (база процесса ~170 МБ), остановка не дольше 2 мс. Индекс сессии распаковывается вне главного потока, предел распакованного индекса — 16 МиБ вместо 96 (самый полный индекс скрипта ~10 МиБ): худший разбор держит цикл событий ~80 мс вместо ~700. - **`hosted:setup` портил секреты с `$`, `#`, пробелами и кавычками.** Значения писались в `hosted.env` без кавычек, и Docker Compose подставлял вместо `$NAME` переменную окружения, а всё после ` #` отбрасывал: пароль S3 или почты, заданный так, не совпадал с настоящим. Теперь такие значения пишутся в двойных кавычках с экранированием и читаются буквально; простые значения и строки, оставшиеся от прежнего файла, не меняются. Проверено через `docker compose config`. - **Медленные анонимные отправители больше не занимают бюджет тел запросов.** Запрос резервировал свой `Content-Length` (без него — предел маршрута) ещё до проверки, кто его прислал, и до конца ответа: полсотни запросов с `Content-Length: 5 MiB` на любой адрес, даже несуществующий, присылающих по нескольку байт, занимали все 256 МиБ, и настоящие загрузки получали `503 busy`. Теперь объявленный размер резервирует только запрос, чей токен проверен до тела (`/api/v1/…`, `/mcp`); любой другой считается по пришедшим байтам, такие вместе занимают не больше четверти `BODY_BYTES_IN_FLIGHT_MB`, а с одного адреса — не больше шестнадцатой; сверх — `503 busy` и закрытое соединение. - **Поиск из одних стоп-слов находил всю полку.** Запрос вроде «что», «что и» или «the» превращался в пустой `tsquery`, и после перехода на индекс по полкам от него оставалась одна лексема полки: на полке и в `polka_list` / `GET /api/v1/works` находилась каждая работа с текстом. Теперь такой запрос по тексту ничего не находит, по названию ищется как набран. - **Счётчик чтений агентов не теряет пачку при коротком сбое базы.** Пачка, которую база не приняла (нет соединения, перезапуск, таймаут, взаимоблокировка), возвращается в память и пишется со следующей; в памяти не больше 10 000 счётчиков (полка × день × тип токена). - **Одна неудаляемая временная полка больше не останавливает обслуживание.** Сбой удаления одной полки (объекты или строки) прерывал весь прогон, и следующие шаги не выполнялись ни в этот раз, ни в следующие. Теперь такая полка считается (`provisionalErasuresFailed` в `maintenance.completed`) и пробуется снова следующим прогоном, а остальные шаги идут дальше. Перед удалением строк аккаунт перепроверяется под блокировкой: если полку тем временем закрепили, строки остаются. ### Changed - **Меньше блокировок и запросов на частых путях.** Открытие ссылки получателем трижды проверяло, не в «Ленте» ли она, запросом из восьми таблиц, у которого планирование занимало 4 мс; теперь сначала одна выборка по уникальному `share_id` (0,6 мс вместо 11 мс на вызов под нагрузкой), полный запрос — только для ссылок из каталога. Новая ссылка закрывала прежние перебором всех ссылок установки; теперь по индексу открытой ссылки (`AND NOT revoked`), так же корзина. Агент блокировал полку, аккаунт и участие дважды в каждой транзакции сохранения; повторная блокировка в той же транзакции теперь одна проверка без `FOR UPDATE` (90 → 82 запроса и 37 → 25 блокирующих выборок на одно сохранение). Список участников полки отдела больше не ждёт сохранений на ней ([TEAM_SHELVES](docs/specs/TEAM_SHELVES.md#проверки-прав)). - **Поиск и список полки быстрее на большой установке.** Текст искался по всей установке, а полка отбиралась потом: частое слово читало строки всех полок (на тестовой базе в 50 000 работ — 650 мс, «Сначала подходящие» — до 4,5 с), а счётчики по типам заново проверяли каждую работу. Теперь индекс текста знает полку, вектор названия хранится, тип последней версии лежит на работе, а страница и счётчики берутся из одного запроса: первая страница полки отдела с частым словом — 25–35 мс вместо 570, `GET /api/v1/works` — 120 мс вместо 380. Порядок, фразы и «Сначала подходящие» не изменились. Миграция 075 (`artifact_search.tenant_id`, `artifacts.title_document`, `artifacts.latest_mime`, индексы вызовов сессий для «Расхода»); рецепты прав обновлены, расширений не нужно ([CONTENT_SEARCH](docs/specs/CONTENT_SEARCH.md#индекс)). - **Вызов агента дешевле для базы.** Каждый `/api/v1/*` и `/mcp` делал шесть запросов к базе, из них три записи: отметку «когда подключение работало», лимит частоты и счётчик чтений на одной горячей строке полки за день; подключение к тому же читалось дважды. Теперь `last_seen_at` пишется не чаще раза в минуту на подключение (в каждом процессе), счётчик чтений копится в памяти и пишется раз в 10 секунд одним запросом (при аварийной остановке теряется не больше 10 секунд счёта; при штатной его сбрасывает `flushAgentReads`), первая перепроверка подключения в запросе берёт результат проверки токена, а следующие читают базу, как раньше. Лимит частоты не изменился и по-прежнему общий для всех процессов. JSON-схемы инструментов MCP строятся один раз, а не на каждый `tools/list` и вызов. На замере `GET /api/v1/works`: 3 запроса к базе вместо 6, 756 ответов в секунду вместо 547 при 10 параллельных и 1046 вместо 531 при 50; `polka_list` через MCP — 881 вместо 578, `tools/list` — 1332 вместо 591. - **Вызов агента дешевле для базы.** Каждый `/api/v1/*` и `/mcp` делал шесть запросов к базе, из них три записи: отметку «когда подключение работало», лимит частоты и счётчик чтений на одной горячей строке полки за день. Теперь `last_seen_at` пишется не чаще раза в минуту на подключение (в каждом процессе) и по id, без повторной проверки подключения; счётчик чтений копится в памяти и пишется раз в 10 секунд одним запросом (при аварийной остановке теряется не больше 10 секунд счёта; при штатной его записывает остановка сервера). Проверка доступа не кэшируется: каждая перепроверка подключения читает базу, поэтому отзыв токена и удаление аккаунта действуют сразу. Лимит частоты не изменился и по-прежнему общий для всех процессов. JSON-схемы инструментов MCP строятся один раз, а не на каждый `tools/list` и вызов. На замере `GET /api/v1/works`: 4 запроса к базе вместо 6; `tools/list` — 2 вместо 3 и 1387 ответов в секунду вместо 591. - **Пул воркеров для проверки страниц.** Классификация и признаки фишинга больших страниц (больше 16 тысяч знаков), текстов и скриптов, факты обложки и Markdown проектов шли в новом воркере на каждую страницу: его запуск занимал 180–230 мс при 10 мс работы. Теперь `HTML_WORKERS` воркеров (по умолчанию 2, на одном CPU — 1) загружены заранее и берут задачи по очереди; задача дольше дедлайна по-прежнему получает «не прочитано», а её воркер заменяется новым. В очереди ждут не больше `HTML_WORKER_QUEUE` задач (по умолчанию 64), сверх — 503 `busy` с `Retry-After: 5`. На замере: публикация страницы 85 КБ при 10 параллельных запросах — 67,6 запроса/с вместо 10,5; четыре страницы по 4,5 МБ сразу — +156 МБ памяти вместо +619 МБ; проект из 100 страниц по 20 КБ — `finalize` за 1,1 с вместо 26,3 с. - **Сборка страниц проекта читает файлы только после допуска:** одновременно не больше сборок, чем воркеров сборщика, и не больше 64 МиБ входных файлов на всех; раньше каждый просмотр сначала читал все файлы страницы в память и только потом ждал сборщика. - **Первый экран грузится быстрее.** Общий первый файл скрипта, который скачивает любая страница (лендинг, ссылка получателя, лента), уменьшился с 551 до 247 КБ (с 167 до 78 КБ в gzip): рабочая полка, её окна и настройки, участники, вебхуки, сервисные аккаунты, сравнение версий, просмотр работы и комментарии загружаются отдельно, когда они нужны, а zod — только со страницами, которым он нужен. Общие стили — 49 КБ вместо 121: стили страниц приходят вместе со страницами. Гостю на `/` лендинг начинает загружаться сразу, не дожидаясь ответа о входе; иконки собраны в один файл вместо десятков мелких. Полужирный шрифт заголовков загружается заранее. `/api/capabilities` и `/api/account/identities` запрашиваются один раз на страницу; первый список полки — без задержки поиска. `tests/web-entry-chunk.test.ts` собирает приложение и не пускает zod и рабочую полку обратно в первый файл. - **Мягкая остановка.** По SIGTERM приложение снимает готовность, останавливает таймеры модерации, писем о проверке и вебхуков, закрывает порт, дожидается запросов в работе и работы после коммита (письма, удаление объектов, доставки), затем закрывает пул БД — всё в пределах `SHUTDOWN_GRACE_SECONDS` (25). Раньше работа после коммита обрывалась вместе с пулом. В `deploy/hosted/compose.yml` у `app` `stop_grace_period: 30s`, у `maintenance` — `65s`, как в `compose.base.yml`. - **PostgreSQL своей установки настроен под нагрузку** (`deploy/hosted/compose.yml`): `shared_buffers=256MB` (было 128MB по умолчанию), `pg_stat_statements` для статистики запросов, в журнал попадает запрос дольше 500 мс — текстом, без значений параметров. У ролей появились таймауты по умолчанию (`deploy/hosted/role-settings.sql`): `polka_runtime` — 15 с на запрос и 60 с простоя в транзакции, `polka_purge` и `polka_restore` — 60 с и 5 минут, `polka_schema` — 15 минут простоя в транзакции; бэкап и миграции работают как раньше. **При обновлении** `up -d` один раз перезапускает `postgres` (несколько секунд без базы), затем один раз нужен `role-settings.sql` и `restart caddy` — команды в [README](deploy/hosted/README.md#обновление). - **Caddy своей установки** сжимает ответы приложения zstd или gzip (раньше только gzip) и обрывает слишком большое тело запроса ответом 413 до приложения: видео проекта — больше 201 МиБ, расшифровка сессии — больше 65 МиБ, всё остальное — больше 9 МиБ, чуть выше лимитов самого приложения. Пути `/api/ext/*` расширения ограничивают сами. - **Задержка цикла событий в `/api/ops/status`** (`runtime.eventLoopMs`) включала период замера: простаивающий процесс показывал около 20 мс вместо долей миллисекунды. Теперь период (10 мс) вычитается; пороги тревоги, подобранные под старые числа, стоит снизить на 20 мс. - **Страница не прыгает при загрузке:** строка с названием у получателя ссылки, строка «Вы вошли как…» в настройках, подвал на `/sessions` и в материале ленты держат место, пока данные в пути. - **Доступность (WCAG 2.1 AA, axe):** переключатель комментариев у получателя ссылается на панель, только когда она открыта; контраст `--muted` (`#5d687d`), числа на выбранном фильтре и подписей обложек ленты не ниже 4,5:1; ссылка «перенесите полку» подчёркнута; блоки с командами на лендинге и в настройках доступны с клавиатуры; у `/trash` есть заголовок h1, у материала ленты — один banner и один contentinfo; «Перейти к содержимому» ведёт на `
` на каждой странице; порядок заголовков в «Перенести полку», ленте и корзине без пропусков. ## [0.16.0] — 2026-10-13 ### Added - **Гайд «Полка в компании: запуск за один день»** ([docs/company-launch.md](docs/company-launch.md)): облако, своя установка или коммерческая редакция; что подготовить (VM, два домена, S3, почта, IdP, документы); установка `install.sh`, первый администратор `account bootstrap`, проверка `ops doctor`; настройки для одной компании и подключение сотрудников фразой `Подключи Полку: https://<ваш домен>/connect`. - **`ACCOUNT_TRUST=company` для установки одной компании.** Правила для новых аккаунтов (не больше 5 ссылок, срок до 7 дней, проверка ссылок нового автора, повтор содержимого у разных аккаунтов как спам) защищают открытое облако и мешают внутри компании. С `company` любой вошедший аккаунт доверенный с первого дня; приостановленная ссылка, блокировка и непросмотренная жалоба по-прежнему снимают доверие. По умолчанию `public`, как на polochka.app. Ставить, только если вход закрыт для чужих ([ABUSE_PROTECTION](docs/specs/ABUSE_PROTECTION.md#своя-установка-компании)). - **Первый администратор без почты:** `npm run admin -- account bootstrap <почта>` (на VM — `scripts/admin.ts account bootstrap` в контейнере `app`) находит или создаёт аккаунт с этой почтой (она считается подтверждённой), делает его администратором компании (`--org` на общем облаке, `--no-admin` — только вход) и печатает одноразовую ссылку `/welcome#…` на час. На странице видно, чья полка, вход — по кнопке; дальше код на почту или SSO с тем же адресом открывают этот же аккаунт. Новая ссылка гасит прежнюю. Миграция 074 (`operator_sign_in_links`, только SHA-256 токена); рецепты прав обновлены. ### Security - **Расширение действует только для людей своей организации:** `context.accounts.update`, `deprovision`, `reactivate` и `startSession` проверяют, что текущая подтверждённая почта аккаунта на домене организации, от имени которой идёт вызов (иначе 403, `organisation_member`). Раньше SSO или каталог одной организации могли сменить почту человеку другой и войти под ним, отключить его или снять отключение, сделанное другой организацией. Отключение помнит организацию (`accounts.deprovisioned_organisation_id`, миграция 073), и снять его может только она. `reactivate` и `startSession` теперь принимают `{ organisationId }` (у `startSession` — вместе с `previous`); вызов без организации отклоняется (400). Коммерческой редакции нужна совместимая версия. - **Ссылка-приглашение не возвращает снятых:** кого сняли с полки отдела после того, как ссылку создали (администратор полки, уход сотрудника у администратора компании, каталог SCIM), той же многоразовой ссылкой вернуться не может (403, `removed_after_invitation`). ### Fixed - **Вход по ссылке от агента после закрепления или отзыва.** Вход читал ссылку как живую, затем ждал блокировку полки; если за это время полку закрепили (закрепление удаляет слабые сессии) или отозвали подключение агента, вход всё равно выдавал слабую сессию по устаревшему решению. Теперь ссылка и подключение перечитываются под блокировкой полки, и такой вход получает «Ссылка устарела». - **Обслуживание оставляло временную полку недоудалённой.** Без конвейера удаления (`ACCOUNT_DELETION_ENABLED=false`) обслуживание закрывало простаивающую временную полку и затем удаляло её объекты и строки; сбой S3, конец времени прогона или остановка между этими шагами оставляли полку закрытой с объектами навсегда. Теперь закрытие записывает задание (`provisional_erasures`, миграция 076), и следующий прогон доводит удаление до конца; рецепты прав обновлены. - **Импорт полки и снимок на дату.** Импорт ставил работе время ухода в корзину, но не писал это событие в журнал; после восстановления работы на новой установке снимок полки за время между исходным удалением в корзину и восстановлением показывал её на полке. Теперь уход в корзину пишется в журнал с исходным временем, как отметка принятия. - **Своя установка подключала агентов к облаку.** `/connect`, `/llms.txt`, страница «Агенты», блок «Подключите агента» и первые шаги предлагали плагин `polka@polka` из `artkruglov/polka-plugin`, а он подключает `https://polochka.app/mcp`: сотрудники компании попадали в публичное облако. Теперь на любом `APP_ORIGIN`, кроме polochka.app, первыми идут прямые команды с адресом установки (`claude mcp add --transport http --scope user polka <адрес>/mcp`, `codex mcp add polka --url <адрес>/mcp`), скиллы ставятся отдельно с самой установки (`npx skills add <адрес>`), а `/connect` просит агента плагин не ставить. На polochka.app всё как раньше. - **Своя установка показывала документы облака.** `/privacy` и `/terms` на любой установке показывали политику и соглашение polochka.app с именем его оператора. Теперь на другой установке это короткая страница: «Эта установка Полки принадлежит <`OPERATOR_NAME` или «организации, которая её развернула»>. Политику обработки персональных данных и условия использования публикует она» и контакт из `OPERATOR_CONTACT`. С `PRIVACY_URL` и `TERMS_URL` страницы переходят на документы владельца, туда же ведут ссылки в подвале, под формой входа и в заявке на `/enterprise`. Где страница описывает само облако (тарифы, «Для компаний»), ссылка ведёт на документы polochka.app. Настройки — в `compose.yml` и `hosted.env.example`. - **Сессии агентов: пароль в шаблоне команды.** Шаблон команды (`argv0` и флаги, его видно в «Сессиях» и его читают правила) строился из исходной команды, а не скрытой, и оставлял любое слово с `-` как есть: `mysql -u root -pS3cret` уходил на сервер с паролем, так же `curl -uadmin:пароль` и `sshpass -p пароль` (слово из строчных букв считалось подкомандой). Теперь шаблон строится из скрытой команды и оставляет только подкоманды до первого флага и флаги без значений: `mysql -u `, `git push --force `. Сервер так же чистит шаблоны от прежних версий `polka-sessions.mjs`. Шаблоны, уже сохранённые на сервере, не переписаны. Плагин нужно обновить. - **Сессии агентов: пароль в шаблоне команды.** Шаблон команды (`argv0` и флаги, его видно в «Сессиях» и его читают правила) строился из исходной команды, а не скрытой, и оставлял любое слово с `-` как есть: `mysql -u root -pS3cret` уходил на сервер с паролем, так же `curl -uadmin:пароль` и `sshpass -p пароль` (слово из строчных букв считалось подкомандой). Теперь шаблон строится из скрытой команды и оставляет только подкоманды до первого флага и флаги без значений: `mysql -u `, `git push --force `. Сервер так же чистит шаблоны от прежних версий `polka-sessions.mjs`. Шаблоны, уже сохранённые на сервере, переписывает команда оператора `npm run admin -- sessions scrub-templates [--dry-run]` (на VM — `docker compose --env-file hosted.env run --rm --no-deps app node --import tsx scripts/admin.ts sessions scrub-templates`): пачками, повторный запуск ничего не меняет, печатает только числа. Плагин нужно обновить. - **Сессии агентов: пропущенные именованные секреты.** `DB_PASSWORD=Pa$$w0rd123!` (знак `$`), `SECRET=correcthorsebatterystaple` (одни буквы) и `AWS_SECRET_ACCESS_KEY=/wJalr…` (начало с `/`) принимались за код или путь и не скрывались. Значение имени со словом SECRET, PASSWORD, PASSWD, TOKEN, API_KEY или PRIVATE_KEY длиной от 8 знаков теперь скрывается всегда, кроме явного кода (вызов, имя через точку, `$VAR`, camelCase и snake_case) и пути под именем `*_FILE`/`*_PATH`/`*_DIR`. Новый тип `cli-password` — пароль клиента в командной строке (`mysql -p…`, `sshpass -p …`, `curl -u user:…`) и в расшифровке. Формат меток `[REDACTED:<тип>:<отпечаток>]` и отпечатки прежние; `context.redact` на сервере использует те же правила. - **Вебхуки:** запись результата доставки блокирует адрес раньше доставки, в том же порядке, что изменение адреса и отмена очереди, — без взаимной блокировки в базе. - **Список сессий:** неверный курсор страницы (`before` с несуществующей датой или кривым id) — 400, а не ошибка сервера. - **`/connect` в браузере:** человек, который нажал ссылку «Подключи Полку: …/connect» в чате или посте, видел сырой текст инструкции для агента. Теперь браузеру (`Accept: text/html`) отдаётся страница с фразой и кнопкой «Скопировать», тремя шагами и ссылками на ручное подключение Claude.ai и ChatGPT; полный текст для агента — под «Текст для агента». Агенты, которые просят текст или markdown (и `curl`), получают прежний текст. `/connect/` перенаправляет на `/connect`. - **Вебхуки: секрет нового вебхука был за краем экрана.** После «Добавить вебхук» уведомление с секретом появлялось выше формы, а форма просто очищалась. Теперь страница прокручивается к секрету и фокус переходит на него, а под формой (и для экранного диктора) — «Вебхук добавлен. Секрет показан выше — сохраните его сейчас.» После удаления фокус — на сообщении под списком, после «Новый секрет» — на новом секрете, после «Сохранить» и «Отмена» — на карточке вебхука, после остальных действий — на той же кнопке; раньше он падал на страницу и Tab уходил из окна «Участники». - **Окна (Dialog) держат Tab внутри:** Tab и Shift+Tab ходят по кругу внутри открытого окна, в том числе когда кнопка с фокусом стала неактивной или исчезла. - **«Полки компании» после «Убрать»:** список полок отделов обновляется — число участников, администраторы, «без администратора» и «Стать администратором», открытые списки участников. Пустой список участников — «На полке пока никого нет.» Для одной полки — «больше не на полке «X»», для нескольких — «на полках». - **Настройки → агенты на телефоне:** форма токена и команды больше не растягивают страницу шире экрана (колонка страницы сжимается до ширины экрана). - **Слепок полки:** «Войти» возвращает на `/snapshot` той же полки отдела (`?shelf=` больше не теряется). - **Тексты без рода:** «Коллеге нужно хотя бы раз войти», «Взять ответственность и разморозить», «если у страницы включён доступ по ссылке», «Ссылка размещена на странице», «Заметок от автора пока нет», «Вход выполнен — открыть»; журнал библиотеки шаблонов — «Выпуск опубликован · <имя>» вместо «<имя> опубликовал выпуск». Флажок в заявке на `/enterprise` — «Согласие с Политикой обработки персональных данных» вместо «Я прочитал(а) Политику…». - **`/enterprise`:** на макете полки метка «Работа» вместо «Диск». - **Восстановление из дампа ([порядок](deploy/hosted/README.md#восстановление-из-дампа)):** шаг 1 останавливал `app`, `maintenance` и `backup`, но не службу очистки `account-purge` (профиль `purge`): она раз в минуту ходила в восстановленную, ещё не мигрированную базу и в журнал стираний, и `restore-reconcile` мог отказать. Теперь она останавливается вместе с ними и запускается в конце (`--profile purge up -d`; без службы очистки команда та же, что раньше). Шаг 5б закрывает и новые пути доступа: активные приглашения на полки отделов отзываются, вебхуки выключаются (как выключенные владельцем, неотправленные доставки не уйдут). То же делает `scripts/restore-drill.ts`. - **Курсоры ленты после восстановления:** курсор `GET /api/v1/events` и `context.auditFeed` расширений, сохранённый до восстановления из дампа, указывал на номер транзакции, которого новый кластер ещё не выдал, и лента стояла на нём неделями. Теперь такой курсор продолжается с той же записи: приходит всё, что записано после восстановления, и новый курсор — как у рассылки вебхуков. - **`hosted:setup --force`** заново генерировал пароли БД и `LINK_KEY` в существующем `hosted.env`: приложение теряло вход в базу, а все ссылки и секреты вебхуков переставали работать. Теперь `--force` оставляет всё, что в файле уже есть (секреты, ключи S3, настройки не из примера), и меняет только переданное; новые секреты — только с `--rotate`. Домен просмотра на том же registrable domain, что и приложение (`view.company.ru` при `polka.company.ru`), команда отклоняет. - **Статус оператора:** проверка `backup` краснела только через 26 часов без дампа, даже при бэкапе раз в час. Порог теперь — `BACKUP_INTERVAL_SECONDS` плюс 2 часа (`compose.yml` передаёт интервал приложению), в ответе — `limitHours`. - **`company admin` с опечаткой в ключе** (`--revok`) молча выдавал роль администратора компании. Неизвестные ключи и лишние аргументы теперь отклоняются, ничего не меняя. - **Письмо о блокировке** называло несуществующую команду `npm run moderation:hold`; теперь — `npm run admin -- moderation legal-hold on --authority …`. - Заголовки и `\echo` рецептов прав БД называли миграцию 071, хотя проверяют 072. ### Changed - **Своя установка без сборки ([«Быстрый путь»](deploy/hosted/README.md#быстрый-путь)):** - на тег `v*` workflow `release-images` публикует `ghcr.io/artkruglov/polka:<версия>` и `ghcr.io/artkruglov/polka-backup:<версия>` (и `latest` для релиза без суффикса) для amd64 и arm64, с метками OCI, provenance и SBOM. Веб-сборка в образе делается один раз на платформе сборки; - `compose.yml` берёт образ бэкапа из `POLKA_BACKUP_IMAGE`; пусто — собирает его на VM, как раньше; - в образе есть файлы `deploy/hosted` своей версии, рецепты прав и `scripts/hosted-setup.mjs`: `hosted.env` можно создать на VM без Node.js; - `deploy/hosted/install.sh` (POSIX sh): проверяет Docker и compose, скачивает образы и закрепляет версию, раскладывает файлы версии в `/opt/polka/deploy` (изменённые сохраняет как `*.previous`), сверяет DNS обоих доменов с IP машины, пишет `hosted.env` (600) через `hosted-setup.mjs` в контейнере без сети и запускает `up -d`. Без `--force` существующий `hosted.env` не меняет; ключи — из окружения или с вводом без эха; - `hosted:setup` берёт из окружения ещё `SMTP_PASS` и `OIDC_CLIENT_SECRET`, новый ключ `--backup-image`; - `npm run admin -- ops doctor`: настройки, DNS и TLS обоих доменов, отдельный registrable domain просмотра, база и версия схемы, бакет (версионирование, условная запись — без новых объектов), возраст последнего дампа, вход в SMTP (письмо — только с `--send-test`), OIDC discovery, есть ли способ входа и администратор компании, сводка настроек. ✓/✗ с подсказками по-русски, код выхода 1 при ✗, секреты не печатаются; - CI проверяет `install.sh` через `sh -n` и shellcheck. - **Шаблон своей установки (`deploy/hosted`) — для компании, а не для polochka.app:** - модели фильтра содержимого в `hosted.env.example` выключены (`CONTENT_MODEL_PROVIDER=off`): раньше там был Yandex AI Studio с заглушкой модели и пустым ключом; как включить — в комментарии; - `OPERATOR_CONTACT` пуст вместо `privacy@polochka.app`, и `hosted:setup` перечисляет его как незаполненный; - `TEAM_SHELVES_SCOPE=installation` в примере и по умолчанию в `compose.yml` (было `organisation`): своя установка — одна компания, как и по умолчанию в коде. На polochka.app `organisation` задан в `hosted.env` явно и не меняется; - в примере появился блок `OIDC_*` (свой IdP компании), так что `hosted:setup --set OIDC_…` работает. - **README хостинга:** раздел «Что нужно» (VM от 4 ГБ, Docker с compose, git, Node.js 22 для `hosted:setup`, два домена и зачем, S3 с версионированием и условной записью, три ключа, открытые 80/443); ключ бэкапа назван обязательным, как в `compose.yml`; `TEAM_SHELVES` и `TEAM_SHELVES_SCOPE` описаны как в шаблоне; команды `company …` даны и в форме для VM (`docker compose … run --rm --no-deps app node --import tsx scripts/admin.ts company …`); у `account-email` добавлен `--no-deps`; сказано, откуда берётся `$PASSWORD`; обновление ждёт `backup ok`, прежде чем `up -d`. - **SAML и SCIM на сайте и в README — как есть:** «Для компаний», тарифы, README, README_EN и COMMERCIAL.md больше не говорят, что SAML и SCIM «пока нет»: они в коммерческой редакции с 0.9.0, на `/enterprise` — отдельная готовая карточка «Вход через SSO и каталог сотрудников». Вебхуки названы среди возможностей открытого ядра и облака. ## [0.15.1] — 2026-10-13 ### Fixed - **Вход по ссылке от агента после закрепления или отзыва.** Вход читал ссылку как живую, затем ждал блокировку полки; если за это время полку закрепили (закрепление удаляет слабые сессии) или отозвали подключение агента, вход всё равно выдавал слабую сессию по устаревшему решению. Теперь ссылка и подключение перечитываются под блокировкой полки, и такой вход получает «Ссылка устарела». - **Обслуживание оставляло временную полку недоудалённой.** Без конвейера удаления (`ACCOUNT_DELETION_ENABLED=false`) обслуживание закрывало простаивающую временную полку и затем удаляло её объекты и строки; сбой S3, конец времени прогона или остановка между этими шагами оставляли полку закрытой с объектами навсегда. Теперь закрытие записывает задание (`provisional_erasures`, миграция 076), и следующий прогон доводит удаление до конца; рецепты прав обновлены. - **Импорт полки и снимок на дату.** Импорт ставил работе время ухода в корзину, но не писал это событие в журнал; после восстановления работы на новой установке снимок полки за время между исходным удалением в корзину и восстановлением показывал её на полке. Теперь уход в корзину пишется в журнал с исходным временем, как отметка принятия. - **Сервер падал на доставке вебхука** по адресу, до которого у машины нет маршрута (IPv6 на VM без IPv6, EHOSTUNREACH): ответ закреплённого DNS приходил сразу, подключение падало внутри `request()`, и ошибка сокета без обработчика завершала процесс. Ответ теперь приходит на следующем тике, как у настоящего DNS, а `request()` обёрнут. То же исправлено в загрузке по ссылке (`url-import/public-fetch.ts`). На polochka.app вебхуки были выключены, пока исправление не выложено. - **Восстановление из дампа:** номера транзакций в журнале событий оставались от старого кластера, и в новом кластере такие записи были «из будущего». Лента `GET /api/v1/events` их не показывала, пока счётчик транзакций их не догонит, а рассылка вебхуков каждые 5 секунд писала `webhooks.cursor_skipped_stale`. В [порядок восстановления](deploy/hosted/README.md#восстановление-из-дампа) добавлен шаг 3a, который снимает эти номера. На polochka.app 59 записей после переезда 11.10.2026 исправлены вручную. - **Вход по коду при `MAIL_MODE=local` не подтверждал почту:** `accounts.email_verified_at` ставился только при `MAIL_MODE=smtp`, поэтому на локальной установке нельзя было проверить правила организации (прямое добавление на полку отдела, поиск сотрудника администратором компании). Теперь верный код подтверждает адрес в любом режиме почты — при входе, при регистрации и при закреплении временной полки. В рабочих режимах (SMTP, в том числе Postbox на polochka.app) поведение не менялось: там адрес и раньше подтверждался, переносить данные не нужно. Адрес, привязанный командой `npm run admin -- account email`, по-прежнему подтверждается первым входом по коду. - **Настройки на телефоне** прокручивались вбок (страница ~628 px при ширине 390): длинная команда curl в «Перенести полку» растягивала колонку. Теперь она прокручивается внутри своего блока. - **Вебхуки:** у только что созданного вебхука уведомление о секрете говорило «Прежний секрет больше не подписывает запросы» — теперь только после «Новый секрет». «Новый секрет» спрашивает подтверждение (получатель перестаёт принимать запросы сразу), «Удалить» подтверждается красной кнопкой. Ответы «Проверить», «Выключить», «События» и ошибки показываются под нужным вебхуком или под формой, а не внизу раздела; пока меняются события, кнопки вебхука скрыты. Журнал доставок называет события по-русски. Список событий и права сервисного доступа — в стиле приложения, без серой рамки браузера; идентификатор события — на отдельной строке. Отказы сервера переписаны: «Вебхуки отправляются только по https.», внутренние имена и адреса — одним текстом «Адрес во внутренней сети…». - **Участники полки отдела:** карточка полки, сервисные доступы и вебхуки — после списка участников, добавления и приглашения. Ошибка прямого добавления — под полем и пропадает после созданной ссылки. В облаке текст приглашения больше не говорит «для тех, кто ещё ни разу не входил»: ссылка нужна всем, кого нельзя добавить сразу. Роль в приглашении — на всю ширину, не обрезается. - **«Полки компании»** для не-администратора: без надписи «Администратор компании» и без запроса `/api/company/shelves` (404 в консоли). Текст об уходе сотрудника зависит от установки: «вход закрывается в SSO компании» — только когда SSO подключено; в облаке — что аккаунт и личная полка остаются у человека; на своей установке без SSO — что вход закрывает оператор. - **Тексты без рода:** «<имя> теперь на полке», «Попросите коллегу один раз войти», «автор ссылки: …» и другие фразы о третьих лицах больше не предполагают пол. ### Changed - Лендинг: «Одна полка для любого агента» вместо «Один диск…». На `/enterprise` — строка о вебхуках (включены на polochka.app). ## [0.15.0] — 2026-10-12 ### Added - **Вебхуки** ([WEBHOOKS](docs/specs/WEBHOOKS.md), миграция 071, `WEBHOOKS=off` по умолчанию, на polochka.app включены). Владелец личной полки в «Настройках» или администратор полки отдела добавляет HTTPS-адреса и выбирает события: те же, что в ленте `GET /api/v1/events`, плюс открытие и закрытие ссылки и новый комментарий. Полка отправляет POST с событием ленты (только id и время, без содержимого) и подписью `Polka-Signature: t=…,v1=…` (HMAC-SHA256 секретом вебхука; секрет показывается один раз, хранится зашифрованным, меняется кнопкой). Повторы через 1 минуту … 24 часа, 8 попыток; не больше 2 попыток одновременно на адрес из общего пула на 10, пауза адреса после 3 ошибок подряд, очередь адреса до 1000 событий; журнал доставок с кодом ответа и первыми 200 знаками; после 3 суток одних ошибок вебхук выключается и автор получает письмо. Защита от SSRF: только https на 443, имя проверяется в DNS (напрямую, со сроком 3 секунды) перед каждой попыткой и соединение идёт на проверенный публичный адрес, перенаправления не выполняются. Вебхуки удаляются вместе с аккаунтом (и снова — при восстановлении старой копии); на полке отдела вебхук выключается, когда добавивший перестал быть администратором. Курсор рассылки сам восстанавливается после переноса базы в новый кластер и после долгого выключения. Ядро пишет в журнал полки `comment.created` (без текста). Решение владельца от 12.10.2026 отменяет «вебхуки — только по запросу пилота». - **Точки расширения для входа и учёта сотрудников** ([EXTENSIONS](docs/specs/EXTENSIONS.md#вход-и-учёт-сотрудников-sso-scim), миграция 072). Расширение может: - принять ответ сервиса входа компании (`signInPaths`: POST без `Origin` Полки, без входа по cookie); - показать свою кнопку на `/signup` и `/login` (`signInMethods`, в `/api/capabilities` — `extensionSignIn`); - запретить код, пароль, поставщиков, привязку поставщика и `/claim` для адресов компании (`policies.signIn` с адресом самого аккаунта, 403 с `reason: "sign_in_policy"`, у поставщика — `idp_error=policy`); - заводить людей и менять им почту только на доменах организации ядра; смена почты закрывает сеансы и коды; - отключать и возвращать людей своей причиной и начинать им сеанс ядра (`context.accounts`); - вести состав полок отделов своей организации — только её людьми с подтверждённой почтой (`context.teamShelves`). Отключение расширением (`deprovision`) заканчивает сеансы, отзывает агентов и снимает с полок отделов организации. Оно и отключение оператором или модерацией хранятся отдельно (`accounts.deprovisioned_at`, `operator_disabled_at`): аккаунт отключён, пока держится хоть одна причина, и ни одна сторона не снимает чужую. Уже отключённые аккаунты миграция записывает отключёнными оператором, кроме удаляемых: их по-прежнему отключает запрос на удаление. `machinePaths` принимает `…/*` для адресов с id (SCIM). Протоколов SAML и SCIM в ядре нет. - **Горячая перезагрузка интерфейса при разработке** ([local-development](docs/local-development.md#интерфейс-с-горячей-перезагрузкой), #14). `npm run dev:web` запускает Vite на http://127.0.0.1:4392 рядом с `npm run dev`: правка компонента в `apps/web/src` видна на странице без `npm run build` и без перезагрузки. Страницы приложения отдаёт Vite, API, MCP, OAuth и остальные адреса сервера он передаёт серверу на `PORT`. Вход и сохранение работ через него работают, проверка `Origin` на сервере не ослаблена. ### Changed - **Бэкапы polochka.app каждый час** вместо раза в сутки (#32, [deploy/hosted](deploy/hosted/README.md#бэкапы-и-секреты)): после отключения питания в зоне `ru-central1-a` 11.10.2026 восстановление из суточного дампа потеряло данные почти за сутки. Настройка `BACKUP_INTERVAL_SECONDS=3600`, код не менялся. - **Обложки-снимки** ([SHELF_COVERS](docs/specs/SHELF_COVERS.md), #31): у визуальных работ на полке — картинка первого экрана. На polochka.app включались 10.10.2026; после переезда в зону `ru-central1-d` 11.10.2026 временно выключены, пока рендерер не вернётся (#33). - **Полки отделов и сервисные доступы в облаке — по организациям** ([TEAM_SHELVES](docs/specs/TEAM_SHELVES.md#организации-в-облаке-миграция-070), миграция 070). На polochka.app с 12.10.2026 включены `TEAM_SHELVES` и `SERVICE_ACCOUNTS`. Новая настройка `TEAM_SHELVES_SCOPE=installation|organisation`: в режиме `organisation` (облако, по умолчанию в `deploy/hosted/compose.yml`) у администратора компании есть организация, и страница «Полки компании», поиск и уход сотрудника, «Стать администратором» касаются только её полок. Сразу добавить коллегу на полку по почте или логину можно, только если у него подтверждённая почта на домене организации полки; остальных, в том числе вступивших по ссылке на другую её полку, — ссылкой-приглашением, а отказ не выдаёт, есть ли такой аккаунт. Почту участников видно только у адресов на доменах организации, и по скрытой почте никого не найти. Команды оператора: `npm run admin -- company org "<название>" [--domain …] [--shelf … [--move]]` и `npm run admin -- company admin <логин|почта> --org "<название>"` (организация должна уже быть). Расширения получают `companyAdmin(accountId)` с организацией администратора. Стирание аккаунта снимает с него роль администратора компании. На своей установке (`installation`, по умолчанию) всё как раньше. Как получить полку отдела в облаке, сказано на `/settings/company`: написать на hello@polochka.app. - **Проверка слоёв фронтенда читается** (#12). `scripts/check-frontend-layers.mjs` переписан на TypeScript (`scripts/check-frontend-layers.ts`, запускается через `tsx`): понятные имена, короткие функции, правила перечислены в заголовке файла. Поведение не менялось: те же 162 проверенных модуля, те же сообщения и коды выхода. ### Fixed - **Вход по ссылке от агента после закрепления или отзыва.** Вход читал ссылку как живую, затем ждал блокировку полки; если за это время полку закрепили (закрепление удаляет слабые сессии) или отозвали подключение агента, вход всё равно выдавал слабую сессию по устаревшему решению. Теперь ссылка и подключение перечитываются под блокировкой полки, и такой вход получает «Ссылка устарела». - **Обслуживание оставляло временную полку недоудалённой.** Без конвейера удаления (`ACCOUNT_DELETION_ENABLED=false`) обслуживание закрывало простаивающую временную полку и затем удаляло её объекты и строки; сбой S3, конец времени прогона или остановка между этими шагами оставляли полку закрытой с объектами навсегда. Теперь закрытие записывает задание (`provisional_erasures`, миграция 076), и следующий прогон доводит удаление до конца; рецепты прав обновлены. - **Импорт полки и снимок на дату.** Импорт ставил работе время ухода в корзину, но не писал это событие в журнал; после восстановления работы на новой установке снимок полки за время между исходным удалением в корзину и восстановлением показывал её на полке. Теперь уход в корзину пишется в журнал с исходным временем, как отметка принятия. - **Рендерер не запускался** в 0.14.0: Dependabot (#49) поднял образ Playwright до 1.64, а `playwright-core` остался 1.63, и Chromium нужной сборки в образе не было — контейнер перезапускался по кругу. Обе версии теперь 1.64.0, а тест `tests/renderer-playwright-version.test.ts` не пропустит такое расхождение снова. - Рендерер на той же VM: в `deploy/hosted/compose.yml` передаётся `RENDERER_CHROMIUM_SANDBOX`, а в [deploy/hosted](deploy/hosted/README.md#рендерер-ссылок) описано, что песочнице Chromium нужны непривилегированные user namespaces на хосте; на Ubuntu 24.04 в Yandex Cloud их нет. - **Песочница Chromium в контейнере рендерера:** стандартный профиль seccomp Docker закрывал вызовы, без которых она не запускается, даже на хосте, где user namespaces разрешены. Compose обоих вариантов подключает [`deploy/renderer/seccomp-chromium.json`](deploy/renderer/seccomp-chromium.json) — стандартный профиль, в котором разрешены `clone`, `unshare`, `setns` и `chroot`, без новых capabilities. В [deploy/renderer](deploy/renderer/README.md#yandex-cloud) исправлены правила `DOCKER-USER` (они отрезали DNS облака и ответы на внутренние запросы), добавлены DNS для Docker, работа по IP (`default_sni`) и формат `RENDERER_CA`. - `npm run dev:web` с пустым `WEB_DEV_PORT=`, `PORT=` или `APP_ORIGIN=` в `.env` брал порт 0 или 2 и не подменял `Origin`, так что вход и сохранение через него не проходили; пустое значение теперь значит «по умолчанию». ## [0.14.0] — 2026-10-10 ### Added - **Страницы проекта с React** ([PROJECTS](docs/specs/PROJECTS.md#страницы-с-react), #20). HTML-страница проекта может грузить React, ReactDOM, Babel или Tailwind с CDN или подключать `