Files
iistwin/IMPLEMENTATION_LOG.md
Ильяс Султанов 0fb3b54b66 ci(deploy): прод-compose из приватного инфра-репо iistwin-infra (шаг 0.14)
Убраны git stash кастомизаций и восстановление compose из бэкапа вне репо.
Источник правды для прод-compose: iisultanov/iistwin-infra (приватный),
клонируется в /opt/crm-infra deploy-ключом при каждом деплое
2026-09-08 14:27:29 +03:00

517 lines
65 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# IMPLEMENTATION_LOG — доведение iistwin до коммерческой готовности
## Контекст для любого ИИ-агента / разработчика
**Проект:** iistwin (TaskTitan) — CRM/таск-трекер. Стек: React 18 + TypeScript + Vite + Wouter + TanStack Query + shadcn/ui (клиент `client/src`), Express + Drizzle ORM + PostgreSQL с мультитенантностью через `organizationId` (сервер `server/`), JWT (access + refresh), SSE real-time. Миграции — детерминированный SQL-раннер в `migrations/` (`scripts/migrate-docker.js`, НЕ drizzle-kit). Деплой: push в `main` → GitHub Actions `deploy.yml` → Docker на VPS 82.202.130.22 (прод: https://iistwin.ru). Финансы — субапп `/di2` (`client-di2/` + `server/finance-di2/`).
**Инвариант (жёсткое требование владельца):** механика мгновенного глобального поиска — на главной (`client/src/components/HomeGlobalSearch.tsx`), в реестре задач (`TaskRegistry.tsx`) и в канбане (`TaskKanban.tsx`) — НЕ должна измениться ни в поведении, ни в скорости ощущений. API `GET /api/tasks?search=&limit=` и подстроковая семантика поиска (`ilike '%…%'` находит совпадения внутри слов, «омент» → «моментально») сохраняются. Ускорение поиска — только через pg_trgm GIN-индексы, НЕ через tsvector (он меняет семантику).
**Источники:** этот лог ведётся по плану «план улучшения до продакт.md» (аудит production-готовности) и исполнительному плану Kimi Code. Нумерация шагов ниже соответствует исходному плану (Фаза 0: 0.0–0.15, Фаза 1: 1.1–1.10, Фаза 2: 2.1–2.5). Дополнительный шаг A3 (подключение тестов) вынесен вперёд из 1.6.
**Принятые решения:**
- Биллинг (0.13): только абстракция `PaymentProvider` + `MockPaymentProvider`; реальный эквайринг — позже, отдельным этапом.
- Шаг 0.14: серверные кастомизации — в отдельный приватный инфра-репозиторий (основной репозиторий остаётся как есть).
**Как читать файл:** записи идут в порядке выполнения (не обязательно совпадает с нумерацией). Каждая запись — по шаблону: статус, зачем, что изменено, ключевые файлы, коммит, как проверялось, влияние на поиск/UX, подводные камни. Ничего не менять без записи в этот лог.
---
## [0.0] Обновить локальный репозиторий
- Статус: ✅ done
- Зачем: рабочая копия должна соответствовать актуальному `main` на GitHub.
- Что изменено: — (только проверка)
- Как проверялось: `git fetch origin` + `git status` (чисто, синхронизировано с origin/main, HEAD `a2b231d`), `npm run check` — зелёный (оба tsc: основной и client-di2).
- Влияние на поиск/UX: нет.
---
## [0.15] Сквозная документация изменений (этот файл)
- Статус: ✅ done (коммит `76a0d85` «chore(docs): add implementation log»)
- Зачем: по одному файлу любой ИИ-агент понимает, что сделано, зачем, какие файлы тронуты и как проверялось.
- Что изменено: создан `IMPLEMENTATION_LOG.md` в корне репозитория (шапка-контекст + шаблон записей).
- Влияние на поиск/UX: нет.
---
## [A3 / 1.6 частично] Подключение тестов + починка 5 падающих
- Статус: ✅ done
- Зачем: тесты существовали (3 файла, vitest), но не запускались ни локально (нет скрипта), ни в CI; 5 из 45 падали. Без зелёных тестов дальнейшие изменения безопасности/производительности — вслепую.
- Что изменено:
- `package.json` — скрипт `"test": "vitest run"`.
- `.github/workflows/pr-check.yml` — шаг «Tests» (`npm test`) после type check.
- `server/utils/userActivity.ts` — **фикс реального бага**: `trackUserActivity` мог кинуть синхронно внутри `authenticateToken`, исключение попадало в catch аутентификации → ложный `403 Недействительный токен` на первом запросе пользователя за минуту. Тело обёрнуто в try/catch, throttle сбрасывается для повторной попытки (соответствие контракту fire-and-forget).
- `tests/auto-transitions.test.ts` — 3 устаревших теста обновлены под намеренное текущее поведение (forward-only по position; синк assignee в task_assignees; системное сообщение заменено аудит-записью «Автопереход»), добавлен мок `delegation.service`.
- Как проверялось: `npx vitest run` — 45/45 зелёные; `npm run check` — чисто.
- Влияние на поиск/UX: нет.
- Подводные камни: баг в `userActivity.ts` проявлялся только на первом аутентифицированном запросе в минуту (throttle) — в проде мог давать sporadic 403.
---
## [0.7] Обработчик ошибок без утечки деталей
- Статус: ✅ done
- Зачем: клиент не должен видеть SQL и внутренности БД в 5xx-ответах (аудит, Фаза 0).
- Что изменено:
- `server/index.ts` — глобальный error handler: при status ≥ 500 отдаёт «Внутренняя ошибка сервера (код XXXXXXXX)», оригинальная ошибка — в серверный лог с тем же кодом инцидента. 4xx не тронуты.
- Route-уровень (12 файлов): зачищены все 5xx-ответы с `err.message`/`String(err)`/телом апстрима — `auth.users.routes.ts`, `form-offline.routes.ts`, `sync.routes.ts`, `polls.routes.ts`, `reactions.routes.ts`, `mcp-rag.routes.ts`, `llm-providers.routes.ts`, `external.routes.ts`, `task-crud-write.routes.ts`, `task-fields.routes.ts`, `finance-di2/routes.ts`, `documents/worker/server.ts`. Где не было — добавлен `console.error` с контекстом.
- Как проверялось: повторный grep `status(5xx)` + `err.message` по `server/` — 0 совпадений; `npm run check` чисто; `npx vitest run` 45/45.
- Влияние на поиск/UX: нет (только тексты 5xx-ответов).
- Подводные камни: остались ответы `200 + success:false` с `err?.message` (llm-providers, rag, finance-di2) и 4xx с `err.message` (documents, data-tables) — сознательно вне скоупа шага, кандидаты для фазы 2.
---
## [0.8] Убрать passwordHash из выдачи пользователей
- Статус: ✅ done
- Зачем: хэши паролей и токены не должны уходить клиенту (в т.ч. через offline-sync).
- Что изменено:
- `shared/schema.ts` — тип `SafeUser` (User без passwordHash/verificationToken/resetPasswordToken/resetPasswordExpires) + набор колонок `safeUserColumns` (единая точка правды).
- `server/storage/users.storage.ts` — `getUsersByOrganization` и `listUsers` → `SafeUser[]`.
- `server/storage/social.storage.ts` (`searchUsers`), `server/storage/task-meta.storage.ts` (`getUsersByRole`, `getUsersByOrgRoleId`), `server/documents/data-resolution.service.ts` (getUser для шаблонов) — тоже на `safeUserColumns`.
- Сигнатуры в `server/storage.ts`; типовые правки у caller'ов (логика не менялась). Методы аутентификации (где passwordHash нужен) не тронуты.
- Побочный эффект: `ctx.users.list()` в автоматизациях и MCP `list_users` тоже sanitized.
- Как проверялось: ни один из 43 caller'ов не использовал исключённые поля (tsc); sync (initial + delta) теперь отдаёт пользователей без хэшей; `npm run check` чисто; `npx vitest run` 45/45; grep `passwordHash` по server/ — только auth-флоу.
- Влияние на поиск/UX: нет.
- Подводные камни: при явном списке колонок drizzle возвращает плоские строки даже с join — мёртвый маппинг в listUsers убран, на это опираться нельзя в будущих правках.
---
## [0.11] Миграция индексов (0079_performance_indexes.sql)
- Статус: ✅ done
- Зачем: убрать Seq Scan на горячих путях — поиск задач, дельта-синк, история полей, логин по email.
- Что изменено: `migrations/0079_performance_indexes.sql` — `CREATE EXTENSION IF NOT EXISTS pg_trgm`; GIN (gin_trgm_ops) на `tasks.title` и `tasks.description`; `(organization_id, updated_at DESC)` и `(form_id, updated_at)` на tasks; `field_history(task_id)`; `users(email)`. Все с `IF NOT EXISTS`, без CONCURRENTLY (раннер в транзакции).
- Как проверялось: миграция применена на scratch-контейнере postgres:16-alpine с 100k синтетических задач — все 7 операторов чисто. EXPLAIN ANALYZE поиска `ilike '%омент%'` (совпадение внутри слова — инвариант): полный проход 77.5 мс (Seq Scan) → 4.8 мс (Bitmap Index Scan по trgm + BitmapAnd с org-индексом), ускорение ~16×; с LIMIT 50 — 10.8 мс → 9.7 мс. Отдельно проверено: `CREATE EXTENSION pg_trgm` выполняется не-суперпользователем-владельцем БД (trusted extension, PG 13+) — на проде миграция пройдёт под appuser.
- Влияние на поиск/UX: семантика НЕ меняется (тот же ilike, тот же API) — только скорость.
- Подводные камни: на проде построение GIN-индексов без CONCURRENTLY кратковременно блокирует запись в tasks — при текущих объёмах (десятки тысяч задач) это секунды, приемлемо.
---
## [0.5] Чистый production-образ (без devDependencies)
- Статус: ✅ done (коммиты `a54ec77` (Dockerfile), `9fc6ad1`, `5426c70` (фиксы запуска))
- Зачем: в прод-образ не должны попадать typescript/vite/vitest — поверхность атаки и размер.
- Что изменено:
- `Dockerfile` — production-стадия: `npm ci --omit=dev` вместо копирования node_modules из builder.
- `package.json` — vitest/supertest/@types/* (16 пакетов) перенесены в devDependencies (именно `vitest` в dependencies тянул vite/esbuild/tsx в прод-образ).
- `server/vite.ts` — vite и vite.config импортируются динамически (dev-only), vite.config — по вычисляемому URL.
- Как проверялось: локальная сборка образа; бандл стартует без devDeps (проверка до подключения БД); `grep` — 0 статических импортов vite в dist/index.js.
- Влияние на поиск/UX: нет.
- **Инцидент и подводные камни (важно для будущих агентов):**
1. Правки Dockerfile ушли в main досрочно (попали в коммит 0.11 через `git add -A`) — автодеплой поднял их до завершения smoke-теста. Правило: коммитить явным списком файлов, не `git add -A`.
2. Первое падение: `ERR_MODULE_NOT_FOUND @vitejs/plugin-react` — `server/vite.ts` статически импортировал vite/vite.config; раньше спасало наличие всех devDeps в образе.
3. Второе падение: esbuild без `--splitting` ИНЛАЙНИТ статический `import("../vite.config")` в бандл и поднимает его импорты на верхний уровень — динамический импорт должен быть по вычисляемому пути (`new URL(...)`, esbuild не анализирует).
4. Локальная проверка `node dist/index.js` НЕ ловит такие ошибки, если локальный node_modules содержит devDeps — резолвится молча. Проверять grep'ом по бандлу или в чистом окружении.
5. Даунтайм продакшена ~25 минут (краш-луп до фикса `5426c70`).
## [0.2] Контейнер не от root
- Статус: ✅ done (коммит `a54ec77`)
- Зачем: компрометация приложения не даёт root в контейнере.
- Что изменено: `Dockerfile` — `adduser -D app` (uid 1001), `chown -R app:app /app`, `USER app`, `HOME=/tmp`, `npm_config_cache=/tmp/.npm`, `PYTHONDONTWRITEBYTECODE=1`; `docker/Dockerfile.documents` — аналогично (там уже был `npm ci --production`).
- Как проверялось: `docker run --entrypoint id` → uid=1001(app); `/app/data` и `/tmp` доступны на запись от app; document-worker на проде поднялся и healthy сразу.
- Влияние на поиск/UX: нет.
- Подводные камни: named volume `crm_crm_data` на проде был root-owned — на сервере заранее выполнен `chown -R 1001:1001` на `crm_crm_data` и `crm_crm_uploads` (иначе app не смог бы писать в /app/data). При развёртывании на новых серверах — учитывать.
---
## [0.10] Лимиты и пагинация в /api/tasks
- Статус: ✅ done
- Зачем: листинг не отдаёт до 10000 полных строк (аудит, Фаза 0).
- Что изменено:
- `server/utils/task-pagination.ts` — `clampTasksLimit` (дефолт 50, кап 200), курсор `<updatedAt>_<id>` (parse/build).
- `server/storage/tasks-core.storage.ts` — дефолт limit 50/кап 200 на уровне storage; опция `excludeDescription` (списковые ответы без тяжёлой text-колонки); курсорная пагинация `(updated_at, id)` с тай-брейкером; новый `getTasksCountByOrganization`.
- `server/routes/task-crud-list.routes.ts` — кап на роуте, параметр `cursor`, в ответе `nextCursor`; ключ minimal-кэша включает limit/курсор.
- Caller'ы: MCP search_tasks limit 500→200; embedding.service — pre-count через `getTasksCountByOrganization` вместо выгрузки миллиона строк; ShareTarget.tsx — явный `?limit=200`.
- `tests/task-pagination.test.ts` — 11 юнит-тестов (кламп, курсор, round-trip).
- Как проверялось: `.toSQL()` — курсор и тай-брейкер корректны, списковый SELECT без description; `npm run check` чисто; `npx vitest run` 56/56.
- Влияние на поиск/UX: search-ветка не изменена (SQL, лимиты, формат) — инвариант сохранён. Дельта: список без параметров — 50 строк вместо 10000, без description; кастомные JS-виджеты (Bots.tsx, CustomPageView) получают задачи без description (при необходимости дотягивают деталь).
- Подводные камни: `/api/forms/:id/tasks` и `/with-fields` НЕ тронуты (там description остаётся — реестр/канбан/Гантт используют их).
## [0.9] Дельта-синхронизация: фильтр на уровне SQL
- Статус: ✅ done
- Зачем: sync не выгружает все задачи всех форм (аудит, Фаза 0).
- Что изменено: `getTasksWithFieldsByFormOptimized` и `getUsersByOrganization` принимают `since?: Date` → `WHERE updated_at >= since OR updated_at IS NULL` (паритет со старым JS-фильтром); JS-фильтры в `sync.routes.ts` удалены; initial sync не тронут.
- Как проверялось: `.toSQL()` — условие в SQL; индекс `tasks_form_updated_idx` (0079) покрывает; vitest 56/56.
- Влияние на поиск/UX: нет.
- Подводные камни: `OR IS NULL` обязателен — старые строки с NULL updated_at должны попадать в дельту (паритет с `!t.updatedAt ||` в JS).
---
## Регрессия поиска после 0.10/0.9 (инвариант) — ПРОЙДЕНА
- Подстрока внутри слова на проде: «емон» → находит «Ремонт…», «отельн» → находит «Котельная…» (через API, тот же путь, что HomeGlobalSearch).
- EXPLAIN ANALYZE на проде (1002 задачи): Index Scan по `tasks_org_updated_idx`, Execution Time 0.806 мс — мгновенность сохранена с запасом.
- Формат search-ответа не изменён; списковые ответы без description — MCP list_tasks подтверждает.
---
## [0.12] getAccessibleTaskIds: кэш + инвалидация
- Статус: ✅ done
- Зачем: самый горячий эндпоинт перестаёт деградировать линейно (аудит, Фаза 0). Переписывание EXISTS-цепочки на чистый SQL отложено до замеров после кэша (по плану).
- Что изменено:
- `server/utils/cache.ts` — `accessibleTasksCache` (TTL 45 сек), ключ `accessible:<userId>:<orgId>`, метод `invalidateSuffix`, хелперы точечной (per-user) и org-wide инвалидации.
- `server/storage/system.storage.ts` — кэш внутри `getAccessibleTaskIds` (все caller'ы получают его автоматически, код роутов не тронут); null (admin/view_all) кэшируется отдельно от miss.
- Инвалидация проставлена в storage-слое (покрывает роуты и MCP): assignees/roles/formAccess/delegation/form visibility/updateUser appRole/createTask/updateTask assignedTo.
- Как проверялось: `npm run check` чисто; `npx vitest run` 65/65 (новый tests/accessible-tasks-cache.test.ts — TTL, null vs miss, invalidateSuffix, LRU, изоляция per-user).
- Влияние на поиск/UX: нет (форматы ответов не тронуты).
- Подводные камни: stale-доступ до 45 сек теоретически возможен только при обходе storage-методов (прямые UPDATE в БД) — все кодовые пути покрыты инвалидацией; кэш in-memory — при multi-instance нужен Redis (та же оговорка, что у существующих кэшей).
---
## [0.13] Биллинг: PaymentProvider + MockPaymentProvider
- Статус: ✅ done
- Зачем: самообслуживание оплаты без superadmin; реальный эквайринг (ЮKassa/CloudPayments) позже без переписывания (решение пользователя — только mock + интерфейс).
- Что изменено:
- `server/billing/payment-provider.ts` — интерфейс PaymentProvider + фабрика по env `PAYMENT_PROVIDER` (дефолт mock).
- `server/billing/mock-provider.ts` — mock: externalId mock_<uuid>, webhook-подпись sha256 (timingSafeEqual), secret env `MOCK_PAYMENT_SECRET`.
- `server/billing/payments.service.ts` — createBillingPayment (ON CONFLICT по idempotency_key), `applySucceededPayment`: транзакция с атомарным guard pending→succeeded (дубли webhook невозможны), credit в billing_transactions, инкремент balance, авто-снятие billingBlocked при балансе > 0 (та же логика, что superadmin unblock).
- `server/routes/billing-payments.routes.ts` — POST/GET /api/billing/payments (billing.manage), POST /:id/confirm-test (только mock, иначе 404), публичный POST /api/billing/webhooks/:provider (401 невалидная подпись, идемпотентность).
- `migrations/0080_billing_payments.sql` + таблица в shared/schema.ts (UNIQUE external_id/idempotency_key).
- `client/src/pages/Billing.tsx` — фикс setLocation в теле рендера (useEffect), кнопка «Пополнить» с диалогом, секция «Платежи», «Подтвердить (тест)» у pending при mock (признак paymentProvider в /api/billing/summary), текст блокировки про авто-восстановление.
- `tests/billing-payments.test.ts` — 13 тестов.
- Как проверялось: vitest 78/78 (дедуп по Idempotency-Key, повторный webhook не зачисляет дважды, изоляция организаций, 404 confirm-test при не-mock, 401 по подписи); `npm run check` чисто.
- Влияние на поиск/UX: нет.
- Подводные камни: на проде PAYMENT_PROVIDER не задан → mock активен, кнопка «Подтвердить (тест)» видна админам организаций — это и есть поставка шага; при подключении реального провайдера кнопка скроется автоматически. Подключение реального провайдера: новый класс в server/billing/ + case в фабрике + env (см. отчёт в коде payment-provider.ts).
---
## [1.1] Data-tables: фильтрация на уровне SQL
- Статус: ✅ done
- Зачем: справочники не грузят все строки/таблицы ради фильтрации в JS (Фаза 1).
- Что изменено:
- `server/storage/data-tables-core.storage.ts` — `getDataTablesWithAccess` без N+1 (2 запроса вместо 1+N); новый `getDataTableRowsPaged` (SQL: фильтры `values->>N ILIKE ESCAPE`, поиск `values::text ILIKE`, сортировка lower()+тай-брейкер position/id, LIMIT/OFFSET); `getDataTableRowCounts` (один GROUP BY); `moveDataTableRow` — siblings SQL-запросом.
- `server/utils/data-table-rows-query.ts` — escapeIlikePattern, капы limit (дефолт 100, макс 1000)/offset.
- `server/routes/data-tables.routes.ts` — GET /rows с limit/offset/search → SQL-путь `{rows, total}`; без параметров — прежний JS-путь (сортировка localeCompare('ru') сохранена 1:1 для текущего фронта).
- `server/mcp.ts` — list_directory_rows (flat) на SQL-пагинацию; list_directories rowCount одним запросом.
- Tree-режим сознательно не тронут: дерево строится из всех строк, пагинация неприменима.
- Как проверялось: vitest 88/88 (10 новых тестов хелперов); `npm run check` чисто.
- Влияние на поиск/UX: нет (TableEditor попадает в legacy-ветку).
- Подводные камни: `/api/directories/:id/column-values` всё ещё грузит все строки (прерывается на 20) — кандидат на следующий заход (SELECT DISTINCT values->>N LIMIT); фронт TableEditor пока не использует SQL-пагинацию (эндпоинт готов).
## [1.2] /column-values: лимиты и чистка горячего пути
- Статус: ✅ done
- Зачем: cap выборки, убрать console.log с горячего пути (Фаза 1).
- Что изменено: `server/routes/task-crud-list.routes.ts` — убраны 7 debug-логов на запрос + per-task лог contract-number; параметр limit (дефолт 20, кап 1000).
- Как проверялось: выборка покрыта индексом tasks_org_id_form_id_idx — новый индекс не нужен; vitest 88/88.
- Влияние на поиск/UX: нет.
---
## [1.4] SSE: индекс соединений
- Статус: ✅ done
- Зачем: рассылка событий не перебирает все соединения; один heartbeat вместо таймера на соединение (Фаза 1).
- Что изменено: `server/utils/sse-connection-index.ts` (новый класс: byId/byOrg/byUser); `server/routes/shared.ts` — EventBus рассылает через индекс (таргетинг org/user/user+org/broadcast с прежней tenant-семантикой), один глобальный heartbeat-таймер (unref, стоп при отсутствии соединений), убраны per-event console.log.
- Протокол НЕ тронут: id:/event:/data:, буферы 500, replay по lastEventId — клиенты совместимы.
- Как проверялось: 8 новых юнит-тестов индекса; vitest 96/96; `npm run check` чисто.
- Влияние на поиск/UX: нет.
- Подводные камни: живой прогон SSE (чат в двух вкладках, reconnect по lastEventId) — проверить на проде после деплоя (юнит-тесты покрывают маршрутизацию, не HTTP-поток).
## [1.5] N+1: recursive CTE, Promise.all, батчи
- Статус: ✅ done
- Зачем: убрать N+1 на горячих путях (Фаза 1).
- Что изменено:
- `getTaskTree` — один WITH RECURSIVE CTE + один IN-запрос (было 1+2N); защита от циклов (lvl<100, assembled-Set); формат результата прежний.
- Новый `getTaskParentChain` (CTE вверх, max 10) — MCP get_task_tree без цикла getTask.
- Батч-методы: getTasksByIds, getTaskFieldValuesByTaskIds, getTaskMessagesByIds/ByTaskIds.
- sync.routes.ts — /initial и /delta: Promise.all по формам (порядок ответа сохранён).
- embedding.service.ts — processEmbeddingQueue и reindexOrganization: батч-предзагрузки (3–4 запроса на org вместо 2–3 на элемент; 2 запроса на чанк 100 задач вместо ~200).
- Как проверялось: vitest 96/96; `npm run check` чисто; caller'ы дерева (роут /tree, MCP) — формат не изменился.
- Влияние на поиск/UX: нет.
- Подводные камни: raw CTE под PostgreSQL — при переименовании колонок tasks/forms обновить вручную (drizzle не проверяет); getRelatedTasks (граф task_relations) сознательно оставлен — кандидат на отдельный шаг.
---
## [1.3] Виртуализация списков (@tanstack/react-virtual)
- Статус: ✅ done
- Зачем: длинные реестры/чаты не рендерят тысячи DOM-узлов (Фаза 1).
- Что изменено:
- `@tanstack/react-virtual@3.14.11` в dependencies (+6-7 kB gzip).
- `client/src/components/TaskRegistry.tsx` — виртуализация строк таблицы (padding-строки в нативном tbody, динамическое измерение высоты через ResizeObserver, overscan 10, getItemKey=task.id). Порог 30 строк — меньше рендер 1:1 прежний. Массовое выделение (Shift+клик через anchorTaskId), сортировка, мгновенный клиентский поиск (инвариант), DnD колонок, inline-редакторы — работают по полному массиву, не по DOM.
- `client/src/components/TaskChat.tsx` — виртуализация сообщений (плоский список: дата-разделители + сообщения, динамическая высота, overscan 10). Порог 100 элементов. Автоскролл к новому, scrollToMessage с fallback scrollToIndex, оптимистичная отправка, реакции/опросы/вложения сохранены.
- Как проверялось: `npm run check` чисто; vitest 96/96; `npm run build` собирается.
- Влияние на поиск/UX: поиск в реестре мгновенный как и был (фильтрация до виртуализации).
- Подводные камни: в виртуальном чате дата-разделители не sticky; возможна «игра» ширин колонок реестра при table-layout:auto; уход редактируемой строки за overscan размонтирует несохранённый ввод (стандарт виртсписков). Ручная проверка в браузере: реестр с длинными значениями, чат >100 сообщений (автоскролл, скролл к ответу).
---
## [1.6] CI: eslint + npm audit
- Статус: ✅ done
- Зачем: lint и аудит зависимостей в CI (Фаза 1).
- Что изменено: `eslint.config.js` (flat-config eslint 9 + typescript-eslint + react-hooks; только баг-ловушки, легаси — warn); скрипт `npm run lint`; pr-check.yml — шаги Lint (блокирующий) и npm audit --audit-level=high (continue-on-error, только отчёт).
- Результаты: старт — 703 проблемы; итог 0 errors / 576 warnings (exit 0). Попутно исправлен РЕАЛЬНЫЙ баг: условный useRef после early return в TaskTitleInline (client/src/pages/TaskDetail.tsx:1612) — react-hooks/rules-of-hooks.
- Как проверялось: npm run lint exit 0; vitest 96/96; `npm run check` чисто.
- Влияние на поиск/UX: нет.
- Подводные камни: npm audit нашёл 60 уязвимостей (49 moderate, 10 high, 1 critical — XSS в DOMPurify GHSA-v2wj-7wpq-c8vv) — в бэклог, чинить отдельным шагом; eslint-plugin-react-hooks взят v5 (v7 требует zod-validation-error/v4, в проекте v3.5).
## [1.7] Структурированные логи + retention error_logs
- Статус: ✅ done
- Зачем: единый logger с уровнями, автоочистка error_logs (Фаза 1).
- Что изменено:
- `server/utils/logger.ts` — уровни debug/info/warn/error (env LOG_LEVEL), LOG_FORMAT=json; подключён в 5 местах (error handler, SSE eventBus, gps worker, automation-scheduler, billing payments). Массовая замена console.* сознательно не делалась (инвазивно) — новые модули обязаны использовать logger.
- `server/workers/error-logs-retention.ts` — DELETE error_logs старше 30 дней, раз в сутки + при старте, unref-таймер.
- `server/index.ts` — redactSensitive() в captureErrorLog: маскировка password/token/secret/Bearer в записываемых сообщениях.
- Как проверялось: auth-роуты возвращают статичные тексты без секретов; vitest 96/96.
- Влияние на поиск/UX: нет.
---
## [1.8] Бэкапы PostgreSQL: документирование + проверка восстановления
- Статус: ✅ done
- Зачем: задокументированная стратегия восстановления (RPO/RTO), подтверждённая практикой (Фаза 1).
- Что изменено: `DOCKER.md` — раздел «Бэкапы PostgreSQL (production)»: расписание (3:00 pg_dump + 3:30 CSV, ротация 14/7), RPO 24ч/RTO 15–30 мин, команды восстановления, что НЕ входит в дамп (MinIO/Ollama/crm_data), риск «бэкапы на том же VPS» (выгрузка наружу — в бэклог).
- Как проверялось: восстановление свежего дампа (2026-09-07 03:00, 11 МБ) в scratch-БД на сервере — 0 ошибок pg_restore, 125 таблиц, 990 задач/49 пользователей/14 форм/6024 значений полей, последняя миграция 0078 (дамп предшествует 0079/0080 — консистентно). Scratch-БД удалена.
- Влияние на поиск/UX: нет.
---
## [1.9] Onboarding
- Статус: ✅ done
- Зачем: новая организация не попадает в тупик (Фаза 1).
- Что изменено: `client/src/components/OnboardingChecklist.tsx` — чеклист «Начало работы · N из 3» (форма → коллеги → задача), состояние из реальных данных, скрытие per org (localStorage); встроен в Home.tsx. `client/src/pages/Tasks.tsx` — при отсутствии форм EmptyState с кнопкой «Создать форму» вместо задизейбленной «Создать задачу».
- Как проверялось: `npm run check`/lint/build чисто; vitest 96/96.
- Влияние на поиск/UX: нет (новая плашка только для пустых организаций).
## [1.10] Модули и мёртвый код
- Статус: ✅ done
- Зачем: MedSchedule не в ядре без флага; мёртвый Capacitor-стек удалён (Фаза 1).
- Что изменено:
- MedSchedule за org-флагом `organizations.medschedule_enabled` (миграция 0081, default FALSE, TRUE для slug='medschedule'; выбран org-флаг как наименее инвазивный и соответствующий прежнему org-ориентированному гейту, admin-override сохранён): `/api/auth/me` отдаёт флаг, серверный гейт requireMedOrganization, сайдбар и страница модуля по флагу. Worker оставлен (работает только с org medschedule).
- Capacitor удалён полностью: capacitor.config.ts, client/src/lib/push-notifications.ts (единственный файл с импортами @capacitor/*), вызов в App.tsx, 5 зависимостей @capacitor/* из package.json. Expo (mobile/) не тронут — рабочее. Web-Push через @/lib/pwa остался.
- Как проверялось: grep — упоминаний Capacitor не осталось (и в lockfile); `npm run check`/lint/build чисто; vitest 96/96.
- Влияние на поиск/UX: нет.
- Подводные камни: включение MedSchedule для другой org — SQL UPDATE (UI-переключателя нет, как у GPS-флагов до матрицы).
---
## npm audit fix (внеплановый шаг после 1.6)
- Статус: ✅ done
- Зачем: npm audit нашёл 60 уязвимостей (1 critical, 10 high) при подключении аудита в CI.
- Что изменено: `package-lock.json` — npm audit fix (совместимые обновления): dompurify 3.4.11→3.4.15 (~20 XSS-advisory закрыты), tar (critical DoS), tiptap и др. Итог: 60 → 46 уязвимостей (0 critical, 2 high, 43 moderate — остаток требует breaking changes, разбор отдельно).
- Как проверялось: `npm run check` чисто; vitest зелёные; lint 0 errors; `npm run build` собирается.
- Влияние на поиск/UX: нет.
- Подводные камни: dompurify используется в client/src/lib/htmlUtils.ts для rich-text — обновление патчевое, API совместим.
---
## [2.1] Средние security-фиксы
- Статус: ✅ done
- Зачем: anti-enumeration, open redirect, брутфорс, явный SSL, доделка 0.7 (Фаза 2).
- Что изменено:
- Anti-enumeration: `server/services/auth.service.ts` — bcrypt-сравнение всегда (dummy-хэш), единый 401 «Неверный email или пароль» для всех причин отказа; forgot-password — идентичный 200 «Если аккаунт существует…» независимо от email.
- returnTo: `server/utils/return-to.ts` + `client/src/lib/return-to.ts` — только относительные пути с одиночным `/` (отброс //host, /\host, ctrl-символов); применено в auth и /api/documents/generate-link (был open redirect через res.redirect).
- Лимит логина: `server/utils/login-attempts.ts` — 10 неудачных → блок 15 мин (in-memory, сброс при успехе), аудит auth.login.locked; поверх authLimiter по IP.
- SSL: `server/db.ts` — rejectUnauthorized через env DATABASE_SSL_REJECT_UNAUTHORIZED (дефолт true).
- Доделка 0.7: 200+success:false с err.message зачищены в llm-providers/rag/finance-di2.
- Как проверялось: 8 новых тестов (tests/auth-security.test.ts) — vitest 104/104; check/lint чисто.
- Влияние на поиск/UX: нет; UX логина: деактивированный аккаунт видит «Неверный email или пароль» (цель anti-enumeration), после 10 попыток — сообщение о 15-минутной блокировке.
- Подводные камни: блокировки in-memory (рестарт обнуляет; multi-instance делит лимит — как остальные кэши).
---
## [2.3] Дедупликация
- Статус: ✅ done
- Зачем: единая точка форматирования сумм (Фаза 2).
- Что изменено: создан `client/src/lib/format.ts` с `formatAmount` (ru-RU, 2 знака, пустое → «0.00» — покрывает обе семантики копий); локальные копии в Billing.tsx и SuperAdminOrgDetail.tsx заменены импортом. Дубля ActiveFilterBadges/ColumnFilter нет — finance/-дубль удалён ранее вместе со старым модулем, канонические в ui/.
- Как проверялось: check/lint/build чисто; vitest 104/104.
- Влияние на поиск/UX: нет (отображение сумм идентично).
## [2.4] Гигиена репозитория
- Статус: ✅ done
- Зачем: мусор, пины образов, дефолты MinIO, доля any (Фаза 2).
- Что изменено:
- Удалён `_tablefield_orig.txt`.
- Пины образов во всех compose (по фактически работающим версиям на проде, существование тегов подтверждено docker manifest inspect): minio RELEASE.2025-09-07T16-13-09Z, rclone 1.74.0, ollama 0.30.10, traccar 6.15.2, pgvector 0.8.4-pg16, osrm latest@sha256:af5d… (тега v6.0.0 не существует — digest, совпал с продовым).
- Дефолты minioadmin убраны: `${MINIO_ACCESS_KEY:?...}` + комментарии в .env.example/.env.local.example (dev-only дефолт).
- 28 замен `: any` + 4 `as any` в server/utils/auto-transitions.ts (23→0, типы из @shared/schema) и server/routes/shared.ts (5) — без изменения логики. finance-di2/routes.ts (113 any) сознательно не тронут.
- Как проверялось: docker compose config валиден для всех 4 файлов; check/lint/build чисто; vitest 104/104.
- Влияние на поиск/UX: нет.
- Подводные камни: пины синхронизированы в серверный бэкап .bak.serverfix (крит. правило №1) — только image-теги, креды бэкапа не тронуты; локальный .env.local требует MINIO_ACCESS_KEY (есть в .env.local.example).
---
## [2.2a] Разбиение Chat.tsx (2696 → 639 строк)
- Статус: ✅ done
- Зачем: гигантские файлы нечитаемы и рискованны в правках (Фаза 2). БЕЗ смены поведения.
- Что изменено: `client/src/pages/Chat.tsx` → 8 файлов в `client/src/components/chat/`: chat-types.ts, chat-utils.ts, MessageBubble.tsx (пузырь + кнопки ботов), NewConversationDialog.tsx, GroupMembersDialog.tsx, HighlightMatch.tsx, TypingIndicator.tsx, useChatController.ts (вся логика страницы дословно). В Chat.tsx — только деструктуризация хука + прежний JSX.
- Как проверялось: ПРОГРАММНАЯ сверка с оригиналом (git show): тело логики == useChatController посимвольно, рендер == оригиналу (кроме замены inline-блока на <TypingIndicator>). Чеклист real-time фич дословно на месте (SSE typing, reportActiveChat в useLayoutEffect, polling fallback 10с, оптимистичная автоотметка прочтения с debounce, кнопки ботов с «нажато»). check/lint/build чисто; vitest 104/104.
- Влияние на поиск/UX: нет (поведение идентично доказанно).
---
## [2.2b] Разбиение TaskDetail.tsx (3278 → 804 строки)
- Статус: ✅ done
- Зачем: см. 2.2a. БЕЗ смены поведения.
- Что изменено: `client/src/pages/TaskDetail.tsx` → 14 файлов в `client/src/components/task-detail/`: types.ts, systemTabs.ts, TabFieldsContent, TaskAccessTab (мёртвый код, сохранён для паритета — кандидат на удаление), InlineSystemFields (ответственные, «На себя», срок, замещение), SubtasksTabContent, TaskContent (роутер вкладок + слоты пресетов), TaskTitleInline (хуки в исходном порядке, фикс условного useRef сохранён), useTaskFieldConditions, ReminderDialog, DeleteTaskDialog, TaskDetailHeader, TaskTabsBar, TaskMobileAccordion. В TaskDetail — композиция + хуки верхнего уровня (SSE, переходы, просмотры).
- Как проверялось: посимвольная сверка — 32 несовпавшие строки, все объяснены (22 — префикс export, 4 — разбитые импорты, 5 — адаптации пропсов, 1 — комментарий). Чеклист: per-field editing, «На себя», просмотры, geo/contact в потоке, пресеты, порядок хуков 1:1. check/lint/build чисто; vitest 104/104.
- Влияние на поиск/UX: нет.
---
## [2.2c] Разбиение Bots.tsx (3928 → 163 строки)
- Статус: ✅ done
- Зачем: см. 2.2a. БЕЗ смены поведения.
- Что изменено: `client/src/pages/Bots.tsx` → 16 файлов в `client/src/components/bots/`: bots-types.ts (+zod-схемы), bots-utils.ts, CollapsibleSection, CreateBotUsersPicker, useBotsController.tsx (вся логика дословно, хуки 1:1), CreateBotDialog, BotListCard, BotSettingsTab (apiAccess/регенерация ключей), BotAiTab, BotWebhookTab, BotSubscriptionsTab, BotApiDocsCard, ServicesSection, JsPagesSection, TabModulesSection. В Bots.tsx — каркас композиции (публичный экспорт BotsContent сохранён — используется в Settings.tsx).
- Как проверялось: программная сверка — 28/28 сегментов дословно; пропсы типизированы через Pick<ReturnType<typeof useBotsController>>. Чеклист: выдача/регенерация API-ключей, zod-валидации, вкладки — дословно. check/lint/build чисто; vitest 104/104.
- Влияние на поиск/UX: нет.
---
## [2.2d] Разбиение FormEditor.tsx (4975 → 337 строк) — шаг 2.2 завершён
- Статус: ✅ done
- Зачем: см. 2.2a. БЕЗ смены поведения.
- Что изменено: `client/src/pages/FormEditor.tsx` → 14 файлов в `client/src/components/form-editor/`: types.ts, constants.ts, utils.ts, SortableWrappers, useFormEditorController.tsx (все хуки 1:1, 1440 строк), FieldCard (карточка поля: Гантт-роли, button-настройки, companyAutofill, contract-number, formula, document_template), MainTab, FieldsTab, StatusesTab, TransitionsTab (конструктор согласующих с resolveTableMode и обратной совместимостью), OfflineTab, LayoutTab (пресеты @preset), FormEditorDialogs. В FormEditor — композиция + шапка + Tabs.
- Как проверялось: построчная программная сверка — 13/15 блоков байт-в-байт, расхождения объяснены (export-префиксы, перенос типов/констант, фрагменты вместо leading-комментариев JSX, удаление 24 неиспользуемых импортов оригинала). Чеклист: bulkSaveFormFields, правила согласующих, пресеты, QR-toggle, условия полей — дословно. check/lint/build чисто (warnings 566→542); vitest 104/104.
- Влияние на поиск/UX: нет.
- Итог 2.2: Chat 2696→639, TaskDetail 3278→804, Bots 3928→163, FormEditor 4975→337.
---
## [2.5] Продуктовая середина: центр уведомлений + a11y
- Статус: ✅ done (i18n-задел — ⏸ отклонено/отложено по решению пользователя: не-русские рынки не подтверждены)
- Зачем: центр уведомлений страницей, skip-link и базовый axe-проход (Фаза 2).
- Что изменено:
- `client/src/pages/NotificationsPage.tsx` — страница /notifications: фильтр все/непрочитанные (кнопки с aria-pressed), клиентская «Загрузить ещё» по 30, отметка одного/всех, переход к задаче через общий хелпер `getNotificationTarget`. Маршрут в App.tsx.
- `client/src/lib/notification-utils.tsx` — общие хелперы/хуки уведомлений (дедупликация: SystemNotificationsDropdown отрефакторен на них); ссылка «Все уведомления» в обоих dropdown + пункт в MobileBottomNav (на мобильном dropdown недоступен).
- Skip-link «Перейти к содержимому» в Sidebar.tsx (первым фокусируемым элементом, sr-only до фокуса, оба main получили id="content") — размещён в Sidebar, т.к. Layout рендерит children внутрь него.
- `tests/a11y.test.tsx` — axe smoke (jsdom) на Login/OnboardingChecklist/NotificationsPage: 0 critical/serious; инфра: axe-core, jsdom, @testing-library/react, alias @ в vitest.config + oxc jsx runtime (vitest 4/Vite 8).
- Исправлены найденные axe-нарушения: aria-valid-attr-value (Tabs без панелей → кнопки), nested-interactive (строка уведомления перестроена).
- Как проверялось: vitest 107/107; check/lint/build чисто.
- Влияние на поиск/UX: нет.
- Подводные камни: GET /api/notifications без серверной пагинации — при очень больших историях «Загрузить ещё» работает на клиенте; серверная пагинация — отдельная задача. Полный axe-проход по всему приложению — задел, не входил.
---
## [0.4] Ресурсные лимиты во все compose-файлы [ПРОД]
- Статус: ✅ done (коммит `87fe0f0`, прод подтверждён: app mem=1GiB/cpus=1.5/pids=256, doc-worker mem=1GiB/cpus=1.0 — docker inspect; health 200)
- Зачем: утечка в одном сервисе не роняет хост (аудит, Фаза 0). Подтверждено пользователем 2026-09-08.
- Что изменено: mem_limit/cpus/pids_limit во всех 4 compose-файлах + серверный бэкап .bak.serverfix (крит. правило №1; преддеплойная копия .bak2-20260908). Значения по docker stats прода 2026-09-07 + запас (сервер 6 ГБ/3 CPU, на хосте другие стеки): app 1g/1.5/256, document-worker 1g/1.0/256, db 768m/0.75/128, minio 256m/0.5/64, webdav 64m/0.25/32, ollama 2g/2.0/64 (модель в RAM на ночном батче), traccar 768m/0.5/128, osrm 512m/0.5/32. proxy (Traefik) сознательно без лимита — критичная инфраструктура хоста.
- Как проверялось: docker compose config валиден для всех 4 файлов и для бэкапа на сервере.
- Влияние на поиск/UX: нет.
- Подводные камни: сумма лимитов ~6.3 ГБ > 5.9 ГБ RAM — лимиты это потолки, не резерв; ollama в простое 36 МБ. На проде лимиты применяются к app/document-worker при ближайшем деплое; остальные сервисы — при их пересоздании.
---
## [0.1] RLS обязательным в production — подготовка и локальная проверка
- Статус: ⏸ частично (локальная проверка пройдена; прод-включение и fatal-проверка — после подтверждения пользователя)
- Зачем: изоляция tenant'ов на уровне БД (аудит, Фаза 0).
- Что сделано:
- Локальная проверка на КОПИИ прод-БД (scratch pgvector контейнер, свежий дамп): app стартует с ENABLE_RLS=true, 31 таблица ENABLE+FORCE, login/поиск (подстрока «варий»→«Аварийка»)/список задач (50, без description)/формы/sync работают, passwordHash не утекает.
- **Найден и исправлен блокирующий баг** в `server/index.ts` (startup RLS-проверка): pg-драйвер возвращает `array_agg(tablename)` (тип name[]) СТРОКОЙ "{tasks,users,...}", а не массивом — проверка schema drift находила 0 таблиц и падала при ЛЮБОМ запуске с ENABLE_RLS=true. Т.е. RLS нельзя было включить без этого фикса. Поддержаны оба формата.
- Что осталось (прод, с подтверждения): 1) добавить ENABLE_RLS=true в .env сервера и перезапустить app, проверить; 2) затем задеплоить fatal-проверку (NODE_ENV=production без ENABLE_RLS → процесс не стартует) + пометки в .env.example/DOCKER.md.
- Влияние на поиск/UX: нет.
- Подводные камни: порядок важен — fatal-проверку деплоить ТОЛЬКО после включения флага на проде, иначе app не поднимется.
---
## [0.1] RLS обязательным в production — завершение
- Статус: ✅ done
- Что сделано после локальной проверки (см. запись выше):
1. Прод: бэкап БД (crm-20260908-075039.dump.gz), `ENABLE_RLS=true` в /opt/crm/.env, recreate app — старт-лог «Startup RLS: active — ENABLE+FORCE verified on 31 tables, 31 policies», health 200, поиск/списки через API работают.
2. Код: `server/index.ts` — при NODE_ENV=production без ENABLE_RLS=true — fatal (процесс не поднимается); dev — предупреждение.
3. Документация: `.env.example` (обязательность для production), `DOCKER.md` (sequence при старте + блок про RLS).
- Как проверялось: на проде подтверждено включение (pg_class relrowsecurity+relforcerowsecurity=31), smoke через API; локально — полный цикл на копии прод-БД.
- Влияние на поиск/UX: нет.
- Подводные камни: при переносе на новый сервер — сначала ENABLE_RLS=true в .env, иначе app не поднимется (это и есть цель шага).
---
## [0.6] TTL access-токена 15 минут + хэширование refresh/reset-токенов в БД
- Статус: ⏸ частично — код готов, ждёт окно деплоя (разлогин всех)
- Зачем: украденный access-токен жил до 30 дней; refresh/reset-токены лежали в БД в открытом виде — при утечке БД атакующий получал вечный доступ (аудит, Фаза 0).
- Что изменено:
- `server/utils/jwt.ts` — дефолт `JWT_ACCESS_EXPIRES` '30d' → '15m' (env-переопределение сохранено), добавлен `hashToken()` (sha256 hex) и экспорт `ACCESS_TOKEN_EXPIRY`.
- Миграция `migrations/0082_token_hashes.sql` — колонки `refresh_token_hash` (NOT NULL, UNIQUE), `parent_refresh_token_hash`, `replaced_by_token_hash` в `user_sessions`; `reset_password_token_hash` в `users`. **`DELETE FROM user_sessions` + очистка reset-токенов — глобальный разлогин при деплое.** Legacy plain-колонки (`refresh_token`, `parent_refresh_token`, `replaced_by_token`, `reset_password_token`) НЕ удалены (отдельная миграция позже), но перестали писаться; у `refresh_token` снят NOT NULL.
- `shared/schema.ts` — новые колонки в `userSessions`/`users`; `resetPasswordTokenHash` добавлен в исключения `SafeUser`/`safeUserColumns`.
- `server/storage/users.storage.ts` + интерфейс `server/storage.ts` — все lookup/отзыв сессий (`getUserSessionByToken`, `getUserSessionByParentToken`, `revokeSession`, `deleteUserSession`) и `getUserByResetPasswordToken` ищут по `hashToken(plain)`; `createUserSession` принудительно обнуляет plain-колонки; `markSessionReplaced(sessionId, newToken)` — по id сессии (plain старого токена больше недоступен).
- `server/services/auth.service.ts` — `createSession` и ротация пишут только хэши; ротация вынесена в `rotateFamilySession()`. Grace-period (гонка вкладок, 5 мин): раньше клиенту возвращался plain-токен активной сессии из БД — теперь он не хранится, поэтому активная сессия ротируется и клиент получает новую пару (поведение сходится, сессий чуть больше). Cookie access Max-Age берётся из `ACCESS_TOKEN_EXPIRY`.
- `server/routes/auth.core.routes.ts` — forgot-password пишет `resetPasswordTokenHash` (+ `resetPasswordToken: null`), plain только в письме; reset-password очищает обе колонки.
- `.env.example`, `server/swagger.ts` — дефолты/описания приведены к факту (access 15m, refresh 30d/90d).
- `tests/token-security.test.ts` — 12 тестов: hashToken, выпуск по хэшу (plain не уходит в storage), ротация с parent-хэшем, невалидный токен, reuse detection → revokeSessionFamily, grace-ротация, logout, reset по хэшу/очистка/истёкший/невалидный.
- Как проверялось: `npm run check` чисто (оба tsc); `npx vitest run` — 118/118; `npm run lint` — 0 errors; `npm run build` — собирается. Клиент не тронут: refresh реактивный по 401 (singleton `refreshSession` в queryClient + useOfflineSync), таймеров, завязанных на 30d, нет.
- Влияние на поиск/UX: нет (только auth-контур).
- Подводные камни:
- **Деплой разлогинит всех** (миграция чистит `user_sessions`) — деплоить в окно согласованного простоя; после деплоя проверить re-login и refresh-ротацию вручную.
- JWT детерминирован по (payload, iat в секундах): две ротации одной и той же сессии в пределах одной секунды выдадут одинаковый токен — безвредно (хэш и семантика совпадают).
- `bot_sessions` (refresh-токены bot_login) сознательно не тронуты — отдельная модель audience `workflow-bots`; кандидат на аналогичное хэширование отдельным шагом. In-memory `resetTokenStore` суперадмина — не в БД, вне скоупа.
- Удаление legacy plain-колонок — отдельная миграция после подтверждения стабильности на проде.
---
## [0.6] TTL access-токена 15m + хэширование refresh/reset-токенов
- Статус: ✅ done (коммит `06bfd2a`, ветка feat/token-security → main, окно подтверждено пользователем 2026-09-08)
- Зачем: укоротить окно атаки при краже access-токена; токены в БД только в виде хэша.
- Что изменено: дефолт JWT_ACCESS_EXPIRES 30d→15m (env сохранён); миграция 0082 (refresh_token_hash + parent/replaced_by хэши в user_sessions, reset_password_token_hash в users; очистка сессий = глобальный разлогин); выпуск/поиск/отзыв только по sha256-хэшу (server/utils/jwt.ts hashToken, users.storage.ts, auth.service.ts — rotateFamilySession, grace-ротация для гонки вкладок); resetPasswordTokenHash в исключениях SafeUser. Фронт не тронут — refresh реактивный по 401.
- Как проверялось: 12 новых тестов (118/118); деплой на прод — сессии очищены миграцией (ожидаемо).
- Проверка на проде: re-login работает, refresh-ротация (access 15 мин) — см. ниже в этой записи после подтверждения.
- Влияние на поиск/UX: нет; пользователи перелогинились один раз.
- Подводные камни: bot_sessions не хэшированы (отдельная audience-модель, отложено); legacy plain-колонки удалить отдельной миграцией после стабилизации.
## [0.3] Не-root deploy-пользователь
- Статус: ✅ done
- Зачем: убрать постоянный root-SSH из CI.
- Что изменено:
- Сервер: пользователь `deploy` (uid 1000, группа docker), SSH-ключ `github-actions-deploy` (сгенерирован локально, публичный — в authorized_keys deploy, приватный — в секрет SSH_KEY через GitHub API, локальная копия `.ssh/deploy_ci` вне репо).
- `/opt/crm` переписан на deploy:deploy; бэкап compose доступен на чтение.
- `.github/workflows/deploy.yml`: `username: deploy`, хост — секрет `DEPLOY_HOST` (вместо хардкода).
- PermitRootLogin — будет выставлен в prohibit-password после подтверждения, что деплой от deploy работает (ниже).
- Как проверялось: SSH от deploy + docker ps — OK; su deploy git status в /opt/crm — OK; деплой от deploy — после пуша этого коммита.
- Влияние на поиск/UX: нет.
- Подводные камни: на сервере остался UU-конфликт в index (последствие stash pop), чистится в 0.14; root-SSH пока остаётся как fallback до подтверждения.
## CI: фикс pr-check (jsdom для Node 20)
- Статус: ✅ done
- Зачем: pr-check падал с 06bfd2a (и 639036c): vitest не стартовал a11y-файл — `webidl.util.markAsUncloneable is not a function` в undici: jsdom@30 несовместим с Node 20 раннера (локально Node 24 — работало).
- Что изменено: jsdom 30 → ^26 (devDependencies).
- Как проверялось: vitest 118/118 локально; pr-check на этом коммите.
- Подводные камни: CI = Node 20, локаль = Node 24 — расхождение учитывать при добавлении тестовых зависимостей.
---
## [0.14] Дрейф конфигурации: прод-compose в приватном инфра-репозитории
- Статус: ⏳ деплой (код запушен, первый деплой новой схемы — проверка)
- Зачем: прод работает ровно на коде из Git; убрать git stash и бэкап compose вне репо (аудит С5, решение пользователя — отдельный приватный репо).
- Что сделано:
- Создан приватный репо `iisultanov/iistwin-infra` (через GitHub API): `docker-compose.prod.yml` (бывший .bak.serverfix с лимитами 0.4), `README.md` (план перехода), `.env.template` (32 ключа без значений).
- На сервере: `/opt/crm-infra` — клон инфра-репо под deploy (deploy-key `id_ed25519_infra`, read-only deploy key в GitHub).
- `deploy.yml`: удалены stash push/pop и `cp .bak.serverfix`; вместо них — fetch/reset основного репо + fetch/reset `/opt/crm-infra` + `cp docker-compose.prod.yml`.
- Чистка дрейфа на сервере: `git stash clear` (3 старых стеша, в т.ч. дубль /apps static — уже в репо), снят UU-конфликт, HEAD чист.
- Старый `/opt/crm-server/docker-compose.yml.bak.serverfix` сохранён (откат до первого успешного деплоя новой схемы).
- Как проверялось: clone от deploy — OK; первый деплой новой схемы — ниже в этой записи.
- Влияние на поиск/UX: нет.
- Подводные камни: deploy-ключи GitHub нельзя шарить между репо (422) — у infra свой ключ; `.env` по-прежнему только на сервере; Traefik-метки/сеть n8n_proxy теперь версионируются в iistwin-infra.