# Задание для Codex: исправления по аудиту и решения владельца - Репозиторий: https://git.ochenstarik.ru/ochenstarik/krona-staff - Основа: ветка `codex/bootstrap-platform-foundation`, коммит `778ba2d` - Материалы в репозитории: `docs/audit/2026-09-26/` — `AUDIT.md` (полный аудит) и `evidence/` (сценарии живой проверки и их вывод). Идентификаторы K, N, D, T, A, F ниже — из аудита. ## Контекст Аудит на живой PostgreSQL 17 показал: - на реальной базе не работают создание сотрудника, подтверждение смены, расчёт и утверждение зарплаты — везде 500; - без токенов сервер открыт всем; - одна смена может быть оплачена трижды; - утверждённую смену можно отклонить задним числом без сторно; - импорт из Excel теряет суммы; - отметки терминала не доходят до сервера. Юнит-тесты при этом зелёные, потому что работают на поддельной базе. ## Зона ответственности **Твоя зона:** - сервер: авторизация, сотрудники, смены, расчёт, импорт, выплаты, процесс подтверждения; - база и миграции; - развёртывание пилота; - портал; - Windows-терминал, служба и установщик; - мобильное приложение в части экранов согласия и подписи. **Не трогай:** - `src/domain/notification/**`, маршруты `/push/*`, диспетчер и публикатор outbox — это Antigravity; - `src/sync.ts`, `src/domain/sync/**`, миграцию `0011` — это тоже Antigravity; - `tests/e2e/**`, `tests/integration/tenant-isolation.test.ts`, `.forgejo/workflows/ci.yml` — это Grok. Если нужна правка в чужой зоне, попроси исполнителя. **Общий хелпер вставки в outbox** (задача K3) делаешь ты. Antigravity будет пользоваться им для своих событий, поэтому сделай его первым и сообщи, когда он влит. ## Правила - Ветки `codex/fix-<тема>` от основной, отдельный PR на каждый этап. - Маленькие коммиты. **В теле каждого коммита объясни причину** (AGENTS.md). Сейчас у 40 последних коммитов тела нет. - Перед пушем должны быть зелёными `npm run check` и `npm run format:check`. - Каждое исправление SQL или маршрута сопровождай интеграционным тестом **на настоящей PostgreSQL** (переменная `DATABASE_TEST_URL`, роль приложения `krona_staff_app`). Тесты на `fakeDatabase` подтверждением не считаются. - Пункт считается закрытым, только когда соответствующий сценарий из `evidence/live-audit.mjs.txt` перестаёт воспроизводить дефект. Каждый пункт независимо перепроверит Grok. - Реальные ФИО и суммы из книги владельца в тесты и фикстуры не копировать. - Миграции проверяй вверх и вниз на чистой PostgreSQL 17 мигратором без SUPERUSER. ## Решения владельца **Роли.** | Роль | Может | Не может | | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | | Руководитель | Всё по всем точкам. Просматривает пакет на подтверждение, подписывает или возвращает его. **Проводит оплаты:** только его подпись регистрирует начисления и выплаты | — | | Бухгалтер | Просмотр по всем точкам, расчёт, ведомость выплат, **отправка пакета на подтверждение**, выгрузки | Подписывать; регистрировать начисления и выплаты; менять правила оплаты и смены | | Управляющий | Сотрудники, смены, выручка и черновик расчёта **по своим точкам** | Отправлять на подтверждение, подписывать, выплачивать; видеть данные чужих точек | | Сотрудник | Только свои смены, начисления, выплаты и остаток | Всё остальное | **Точки.** - У каждого сотрудника и управляющего есть основная точка и дополнительные, в том числе временные (подмена), с датами действия. - Работать на другой точке можно. Смена относится к точке, где она отработана, и считается по правилам и фонду этой точки. **Процесс оплаты.** Бухгалтер отправляет пакет на подтверждение, руководитель получает оповещение, проверяет, подписывает — и всё регистрируется. **Согласие.** Согласие на обработку персональных данных и биометрии обязательно. Сотрудник подтверждает его в мобильном приложении при авторизации. ## Этап 0 — форматирование (первым коммитом, до всех остальных) Одним коммитом выполни `npm run format` по всему репозиторию (сейчас 16 файлов не проходят проверку). Коммит сделай до того, как остальные исполнители начнут работу, чтобы не было конфликтов. ## Этап 1 — разблокировать работу на реальной базе (сегодня) | ID | Что сделать | Где | Готово, когда | | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | | K3 (N2) | Во вставках в `staff_outbox` нет `occurred_at`, а столбец `NOT NULL` без значения по умолчанию. Вынеси вставку в один общий хелпер outbox, который всегда заполняет время, и замени им все вставки | `src/portal.ts:1145-1163`, `src/domain/payroll/service.ts:770-784`, `917-935`; эталон — `src/portal.ts:810-823` | Подтверждение смены, расчёт и утверждение отвечают 200 на живой базе; тест на PostgreSQL | | K4 (N1) | Параметр `$2` выводится PostgreSQL как `text` и как `varchar` (ошибка `42P08`). Добавь явные приведения или вынеси поиск профессии в отдельный запрос | `src/portal.ts:465-483` | `POST /employees` → 201 на живой базе; тест на PostgreSQL | | — | Проверь остальные запросы на тот же дефект: один `$N` в разных типах | весь `src/` | — | ## Этап 2 — закрыть открытый доступ и привести пилот в порядок | ID | Что сделать | Где | Готово, когда | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | | K1 (F2, P0) | Без токенов сейчас любой анонимный запрос получает права `STANDALONE_ACCESS`. Сделай fail-closed: сервер не стартует без токенов, если `HOST` не loopback. Во всех compose токены и пароли — через `${VAR:?}` | `src/adapters/identity.ts:32-49`, `src/config.ts:45-47`, `compose.yaml`, `deploy/pilot/compose.yaml:14-15` | Запуск без токенов на `0.0.0.0` падает с понятной ошибкой; анонимный `GET /portal` → 401 | | K2 (F1, P0) | Пилот работает с `NODE_ENV=development`, чтобы обойти запрет standalone в production. Сделай явный профиль `pilot`: обязательные токены и HTTPS, никакого development-поведения. Платформенный JWT — отдельной задачей, когда будет контракт Core | `deploy/pilot/compose.yaml:8`, `compose.yaml:5`, `src/config.ts:51-66` | В пилотном compose нет `NODE_ENV=development`; проверки `loadConfig` действуют | | K10 (F3) | Серверные сессии: в cookie — случайный идентификатор, в базе — хеш, срок жизни, отзыв при выходе и при смене ключа. Токену сотрудника — срок жизни | `src/portal.ts:262-276` | После `DELETE /session` старая cookie → 401 | | K11 (F4, A2) | Ограничение частоты по IP на `/session` и на неуспешную аутентификацию. Для заведомо неверного токена не открывать транзакцию в БД: пул всего на 10 соединений | `src/portal.ts:1276-1296`, `src/infrastructure/database.ts:9` | 60 неверных входов подряд дают 429 | | A4 | Глобальный обработчик ошибок: при 5xx — общий текст и correlation id, детали только в лог | `src/app.ts` | В ответе 500 нет кода и текста ошибки PostgreSQL | | F10, A6 | Проверку Origin для мутаций по cookie сделать fail-closed. Пропускать её только при `Authorization: Bearer` | `src/portal.ts:1359-1374` | Мутация по cookie без Origin → 403 | | A5, A7, A9 | Аутентификацию делать до валидации тела; `/openapi.json` — только менеджеру или в dev. `no-store` на выдаче токена сотрудника. Correlation id от клиента хранить отдельно от серверного | `src/portal.ts:734`, `src/app.ts` | — | | D13 | Лишние поля в теле запроса отклонять ответом 400 (`removeAdditional: false`), а не удалять молча, как сейчас | `src/app.ts` | Поведение совпадает с `additionalProperties: false` в OpenAPI | | K18 (P1) | Резервное копирование пилота: `pg_dump -Fc` по расписанию, шифрование, копия вне хоста, срок хранения. Бэкап перед каждой миграцией в `setup.sh`. Провести restore drill и записать результат | `deploy/pilot/`, `docs/BACKUP-RESTORE.md` | Есть скрипт, расписание и протокол успешного восстановления | | N6 | Задавать атрибуты роли только в `CREATE ROLE`, затем `ALTER ROLE … PASSWORD`, затем проверять `rolsuper` и `rolbypassrls` и при нарушении отказывать | `scripts/create-app-role.mjs:19`, `docker/postgres/init-app-role.sh:17` | Скрипт работает мигратором без SUPERUSER | | N7 | Пиннинг сертификата вместо отключения проверки TLS; токен только из переменной окружения; убрать поле `code` из тела `/professions` | `scripts/import-owner-workbook.py:241, 255, 278` | — | | F9 | Healthcheck образа по HTTPS | `Dockerfile:21` | — | | D3 | TLS пилота: локальный CA, установленный на устройства, или домен с ACME; пиннинг SPKI или CA вместо листового сертификата; ключ не хранить незашифрованным рядом с pfx; runbook продления | `deploy/pilot/setup.sh:37-47` | На телефоне сотрудника регистрируется service worker (иначе PWA и push не работают) | | D4, D11, D12 | Образы с тегами и возможность отката; `lock_timeout` в миграциях; миграции не от root и без dev-зависимостей; smoke-тест проверяет коды ответов и не пишет в боевую базу | `deploy/pilot/setup.sh`, `scripts/migrate.mjs`, `deploy/pilot/smoke-test.sh` | — | | D9, D10, D14, D15 | Лимиты памяти, CPU и числа процессов, `cap_drop`, ротация логов; `.dockerignore` для `**/.env` и `deploy/pilot/secrets`; `redact` в логгере, ошибки пула — через pino; безопасные значения по умолчанию в корневом compose | compose-файлы, `.dockerignore`, `src/app.ts:42`, `src/infrastructure/database.ts` | — | ## Этап 3 — денежная логика (до первого боевого расчёта) | ID | Что сделать | Где | Готово, когда | | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | | K5 (N4) | Каждая смена начисляется ровно один раз: `session_id` в строке ведомости и уникальность среди неаннулированных ведомостей (или exclusion constraint на пересечение периодов, включая сочетание «одна точка» и «все точки»). При пересчёте обновлять `period_end` | `src/domain/payroll/service.ts:425-454, 759-768, 860-903`; индексы `migrations/0006:196-199` | Три пересекающихся расчёта дают одно начисление 2 400 ₽ или 409 (сейчас 7 200 ₽) | | K6 (D5) | Смены и строки ведомости после подписи неизменяемы на уровне БД: триггер или политика, у роли приложения нет DELETE и UPDATE. Исправление — только сторнирующей записью | `src/portal.ts:1024-1106`, `migrations/0006:296-300` | Отклонение оплаченной смены → 409 или создаётся сторно | | K7 (D2) | Импорт считает предыдущую сумму по всем записям дня, а пишет по точному ключу — суммы теряются. Считать по точному `external_key`, корректировку писать с отдельным ключом ревизии | `src/operations.ts:1017-1052`, `scripts/import-owner-workbook.py:147, 168, 176` | Две точки в один день и повторный импорт с изменённой суммой дают верный итог; тест | | K8 (D1) | Сделать версии правил настоящими: v1 неизменяема, любое изменение создаёт новую версию, учитываются даты действия правил и условий точки. В строке ведомости хранить JSON дерева правила, версию, id назначения, id и ревизию смены | `service.ts:152-180, 517-529, 686-695` | Подписанную ведомость можно пересчитать и получить то же число; тест | | K9 (D3) | Сохранять произвольные параметры из `param_declarations` со значениями по умолчанию. Загружать все метрики и позиции, которые требует дерево правила | `service.ts:295-299, 488-500`, `src/portal-ui.ts` | Пресеты «официант» и «премия за порог выручки» дают ожидаемые суммы (сейчас 0 ₽); тест | | N12, D9 | Ограничить ставки сверху (не больше 10¹² коп.), проверять `Number.isSafeInteger` и реальность даты и месяца | `src/operations.ts:560-585, 665-671` | `rate_minor = 1e20` → 400; месяц `2026-13` → 400 (сейчас 200 и 500) | | N11 | Строгая схема импорта: `kind` из списка, знак выплаты, обязательные строки | `src/operations.ts:776-812, 1168-1210` | Некорректный импорт → 400, а не 500 | | N13, N14 | Первый расчёт периода без гонки (`ON CONFLICT … RETURNING` или advisory lock). GET-запросы не пишут в базу; начальные правила создаются один раз | `service.ts:152-180, 446-454` | — | | F7 | `Idempotency-Key` для расчёта, отправки, подписи, импорта и выплат | — | Повтор с тем же ключом не создаёт дублей | | D7, F12 | Остаток округления фонда не выплачивается сверх фонда. Деньги в расчётах — `bigint` или проверенные безопасные целые | `src/domain/payroll/calculator.ts` | — | ## Этап 4 — роли, точки, подтверждение и согласие (решения владельца) ### 4.1 Роли и точки - Роли — по таблице в разделе «Решения владельца». Права проверяются на сервере в каждом запросе, включая расчёт и выгрузки. Интерфейс только прячет недоступное. - Привязки «человек — точка» хранить в отдельной таблице с датами действия: одна основная точка и сколько угодно дополнительных, в том числе временных. - Управляющий видит и подтверждает смены всех, кто работал на его точке, включая приходящих. Их начисления на других точках он не видит. - В режиме `standalone` роль и список точек задаются при выдаче доступа (через серверные сессии из K10). В режиме `platform` они берутся из прав Core. - Каждое действие пишется в журнал аудита с конкретным человеком. Сейчас все менеджеры записываются одним `STANDALONE_ACTOR_ID` (A8). ### 4.2 Подтверждение и оплата ``` Бухгалтер: расчёт → ведомость выплат → «Отправить на подтверждение» ↓ оповещение руководителю (делает Antigravity) Руководитель: просмотр пакета → «Подписать» или «Вернуть с комментарием» ↓ подпись Система: начисления и выплаты регистрируются в реестре, пакет и его смены становятся неизменяемыми; бухгалтеру и сотрудникам уходят оповещения ``` - **Пакет** — расчётная ведомость за период и ведомость выплат (выплат может не быть). Руководитель подписывает весь пакет одной подписью. - **Статусы пакета:** `draft → calculated → submitted → signed`; возврат: `submitted → returned → calculated`. - **Отправка** фиксирует снимок пакета: строки, итоги, предупреждения расчёта и хеш содержимого. - Если после отправки изменились данные пакета (смена, выручка, ставка, правило), отправка отменяется. Пакет возвращается бухгалтеру с пометкой «данные изменились». - Подписать устаревший пакет нельзя (закрывает D4). - **Экран руководителя** в портале, позже и в приложении. На нём: - итоги по точкам и сотрудникам и сравнение с прошлым периодом; - все предупреждения: нет выручки, нет профиля оплаты, аномальные смены, выплата больше остатка; - расшифровка любой строки. Сотрудник без профиля оплаты блокирует подпись, а не выпадает из фонда молча. Руководитель пакет не редактирует — только подписывает или возвращает. - **Подпись** — простая электронная. В момент подписи руководитель повторно подтверждает личность: в приложении PIN или биометрией телефона, в портале паролем. - Сохраняется: кто, когда, устройство, IP, хеш подписанного снимка и версия соглашения об электронной подписи. - Подписывает только роль «Руководитель». Бухгалтер не может подписать свой пакет, даже если у него есть обе роли. - **Регистрация при подписи** идёт одной транзакцией: - начисления и выплаты записываются в реестр идемпотентно по пакету; - пакет и его смены становятся неизменяемыми (K6); - в outbox через общий хелпер записываются события `staff.payroll.package_submitted`, `staff.payroll.package_returned`, `staff.payroll.package_signed`. Оповещения по ним делает Antigravity. - **Возврат** — только с обязательным комментарием. Бухгалтер правит данные и отправляет новую версию пакета. История версий и комментариев сохраняется. - **Выплаты только через подпись.** Закрыть ручные выплаты в обход подписи, включая импорт. Импорт исторических выплат из Excel — отдельная операция, которую тоже подписывает руководитель. - **API:** отправить, вернуть, подписать, история пакета. Добавь эти маршруты в OpenAPI. ### 4.3 Согласие **Сервер.** - Журнал согласий только на вставку: сотрудник, вид согласия, версия документа, хеш текста, время по серверу, устройство, IP, способ подписания, время отзыва. - Три отдельных вида согласия: обработка персональных данных, **биометрия**, передача оповещений через иностранные мессенджеры (Telegram). - API: текущие версии документов, принять, отозвать, статус по сотруднику (для терминала). - Тексты хранятся в базе с версиями, чтобы менять их без релиза. Новая версия текста требует повторного согласия при следующем входе. **Приложение сотрудника** (дополнение к `docs/APP-CORE.md`). - После приглашения и установки пароля и PIN — обязательный экран согласий, до входа в кабинет. - Без согласия на обработку ПДн дальше не пустит. Без согласия на биометрию кабинет работает, но отпечаток зарегистрировать нельзя. - В профиле — подписанные версии и кнопка отзыва. **Без смартфона.** Управляющий отмечает бумажное согласие в кабинете и прикладывает скан; отметка идёт в журнал аудита. **Юридически.** Для биометрии закон требует письменной формы. Электронное согласие равнозначно письменному, если подписано электронной подписью (в том числе простой) и есть соглашение о её использовании. Тексты согласий и соглашения готовит юрист; в коде предусмотри только их подстановку. Этап 4 готов, когда на живой базе: - управляющий точки А получает 403 на расчёт точки Б и на отправку пакета; - бухгалтер отправляет пакет, но при попытке подписать получает 403; - начисления и выплаты появляются в реестре только после подписи руководителя; - изменение смены после отправки не даёт подписать пакет; - возврат с комментарием возвращает пакет бухгалтеру; - смена сотрудника точки А, отработанная на точке Б, попадает в расчёт точки Б; - без согласия на биометрию сервер отвечает терминалу «регистрация запрещена». ## Этап 5 — терминал и служба | ID | Что сделать | Где | Готово, когда | | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------- | | K14 (T4, P1) | Терминал создаёт сотрудников со своими ULID, сервер их не знает и отвечает 404, отметки повторяются бесконечно. Нужна синхронизация справочника сотрудников с сервера или передача `personnel_number`. Привести `docs/TERMINAL-API-REQUIREMENTS.md` к фактическому контракту; поля, которых нет в схеме сервера, добавить на сервере или не отправлять | `apps/KronaStaffTerminal/MainPage.xaml.cs:479-492`, `Models/TerminalModels.cs:157` | Отметка с терминала появляется на сервере; бесконечных повторов нет | | K15 (T1, P1) | Служба создаёт pipe `global\KronaStaffTerminalPipe`, клиент подключается к `KronaStaffTerminalPipe`, поэтому IPC не работает (проверено запуском). Нужно: одно имя; `PipeOptions.FirstPipeInstance`; проверка клиентом, что сервер — процесс службы или SYSTEM; `TokenImpersonationLevel.Identification`; интеграционный тест на реальном pipe | `apps/KronaStaffTerminal/Services/TerminalIpc.cs:84, 236` | Клиент подключается к службе; посторонний процесс не может занять имя | | K16 (T2, P1) | Служба от LocalSystem выдаёт операторам полный доступ к каталогу данных и сама меняет в нём ACL и ротирует логи — путь к SYSTEM через junction. Данные службы и логи — только для SYSTEM и Администраторов, с проверкой reparse-точек. Интерфейс работает через IPC. Служба — под виртуальной учётной записью | `Services/TerminalStore.cs:305-360`, `Services/CrashLogService.cs:331-371`, `installer/KronaStaffTerminal.nsi:119` | — | | K17 (T5) | Регистрация отпечатка разрешена только при действующем согласии на биометрию (статус с сервера, кэшируется для офлайна). При отзыве согласия, блокировке и увольнении шаблоны удаляются на следующей синхронизации, включая `.bak1-3` и `*.corrupt-*` | `MainPage.xaml.cs:685-776`, `TerminalStore.cs:96-100, 376-384` | Без согласия регистрация недоступна; после отзыва шаблонов нет ни в одном файле | | T3, T6 | Единственный писатель файла состояния — служба (или межпроцессная блокировка с перечитыванием). Секреты недоступны группе операторов | `KronaStaffWorker.cs:375-393`, `TerminalStore.cs:164, 263, 325-351` | — | | T7, T8 | Установщик: ACL на каталог установки; владелец каталога данных — Администраторы; терминал не запускается с повышенными правами ни после установки, ни после обновления | `installer/KronaStaffTerminal.nsi:49, 56, 110-111`; `MainPage.xaml.cs:1529-1530` | — | | T9, T10 | Проверенный файл обновления держать открытым с запретом записи или копировать в каталог только для администраторов. Токен обновлений с узкими правами. `libzkfp.dll` загружать по абсолютному пути | `Services/UpdateService.cs:118-127, 200-212`; `FingerprintScanner.cs:605-617` | — | | D5 | Скрипт настройки терминала пишет в `%ProgramData%` с DPAPI LocalMachine | `deploy/pilot/configure-terminal-pilot.ps1:11, 24-27, 43` | — | | T11–T13 | Ограничить IPC до проверки HMAC; добавить backoff в синхронизацию; уведомлять об откате к резервной копии; ввести порог совпадения отпечатка | — | — | ## Этап 6 — портал и документация - W1: маршрут `/staff/*` отвечает 200 и HTML на любой путь (`src/app.ts:146`). Оставить явный список разделов, остальное — 404. - D8: привести к коду `ARCHITECTURE.md`, `ROADMAP.md`, `PLATFORM-INTEGRATION.md`, `README.md`, `.env.example` и `SECURITY.md`. `AUDIT-REMEDIATION-2026-09-18.md` пометить устаревшим. ## Порядок работы с остальными 1. **Сразу:** этап 0 и этап 1. Как только общий хелпер outbox из K3 влит, сообщи Antigravity. 2. **Параллельно:** этапы 2 и 3. Antigravity в это время исправляет синхронизацию и push, Grok строит тестовый контур. 3. **Этап 4.** События пакета нужны Antigravity для оповещений. Согласие нужно этапу 5. 4. **Этап 5:** K14 и K15 можно начинать сразу после этапа 1; K17 — после серверной части согласия. ## Отчёт после каждого этапа Таблица «ID — что сделано — коммит — тест» и список того, что не удалось, с причиной.