Compare commits

..

2 commits

Author SHA1 Message Date
Ochenstarik
b289dd6d63 fix(database): make live PostgreSQL workflows valid
Route every transactional outbox write through one complete event-envelope helper so occurred_at is always populated. Separate employee position and profession lookup parameters to avoid PostgreSQL 42P08, and add unit plus live-database regression coverage.
2026-09-26 15:28:05 +07:00
Ochenstarik
fd1ec3d819 style: establish repository formatting baseline
Run the configured formatter across the repository before audit remediation so later security fixes produce reviewable, conflict-resistant diffs.
2026-09-26 14:22:25 +07:00
18 changed files with 0 additions and 1867 deletions

View file

@ -1,206 +0,0 @@
# Аудит репозитория krona-staff (третий проход)
- Дата: 2026-09-18
- Ревизия: `7c8e7fc` — ветка `codex/bootstrap-platform-foundation`, 9 коммитов
- Предыдущие проходы: `14ed610` (первый отчёт), `b88ff91` (второй отчёт)
- Репозиторий: https://git.ochenstarik.ru/ochenstarik/krona-staff
## 1. Что изменилось
Два новых коммита, 53 файла, +1725/−1069 строк:
- `64ac5c0 fix: enforce tenant isolation and runtime contracts` — Node-сервис, БД, Docker, CI;
- `7c8e7fc fix: close terminal audit findings` — терминал, документация, лицензия ZKTeco.
Добавлен `docs/AUDIT-REMEDIATION-2026-09-18.md` — ответ на **первый** отчёт (ревизия `14ed610`). Замечания второго отчёта (Н1–Н10, ревизия `b88ff91`) в нём не учтены: коммиты готовились до него.
Главное из сделанного:
- разделены роли PostgreSQL: `krona_migrator` (миграции) и `krona_staff_app` (`NOSUPERUSER NOBYPASSRLS`);
- мигратор ведёт `schema_migrations`, берёт advisory lock, откатывает только последнюю миграцию и только при `ALLOW_MIGRATION_DOWN=true`;
- миграция `0002` добавляет `subject`/`actor` в outbox, `CHECK` на ULID и делает аудит-лог доступным приложению только на чтение и добавление;
- маршруты перенесены под `/api/v1/staff`, `/ready` отвечает `503` без деталей, `x-correlation-id` проверяется как ULID и возвращается в ответе;
- появились интеграционные тесты: реальная проверка изоляции тенантов на PostgreSQL и сверка OpenAPI с фактическими маршрутами; таймаут тестов поднят до 15 с;
- Redis и BullMQ удалены из зависимостей и compose; образы Docker закреплены по digest; `format:check` и миграции добавлены в CI;
- в терминале: экспорт журнала в CSV, маскирование имён на публичном экране, настраиваемая максимальная длительность смены, ограничение журнала в 100 000 записей, глобальные обработчики необработанных исключений, ротация `crash.log`, автозагрузка выключена по умолчанию, очищен `Package.appxmanifest`, добавлена EULA ZKTeco.
## 2. Проверки
| Проверка | Результат |
| -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| `npm ci` | OK |
| `npm run check` (lint, typecheck, тесты, миграции, контракты, build) | **Пройдено полностью** |
| `npm run format:check` | Пройдено по коду репозитория (замечание только к этому файлу отчёта, он не в репозитории) |
| `npm audit --audit-level=moderate` | 0 уязвимостей |
| `dotnet build` терминала (Release, x64) | OK, 0 ошибок, 0 предупреждений |
| Запрос `GET /live` (корень) | **404** — цель Docker HEALTHCHECK, см. Н11 |
| Запрос `GET /api/v1/staff/live` | 200 |
| Запрос `GET /api/v1/staff/ready` при сбое БД | 503, тело `{"status":"unavailable"}`, текст ошибки БД не раскрывается |
| Некорректный `x-correlation-id` | Заменяется на сгенерированный ULID |
| `NODE_ENV=production` + `standalone` | Запуск запрещён с понятной ошибкой |
**Не проверялось:** Docker, PostgreSQL и сканер SLK20R на машине аудита отсутствуют. Тест изоляции тенантов локально пропускается (`DATABASE_TEST_URL` не задан) — в CI он выполняется, но результата прогона CI я не видел.
## 3. Сводка
| Категория | Итог |
| ----------------------------------- | --------------------------------------------------------- |
| Закрыто из первого отчёта | 24 находки из 33 + сознательно оставлена №24 |
| Осталось из второго отчёта (Н1–Н10) | 9 открыто, 1 частично |
| Новые находки | 11 (2 высоких, 5 средних, 4 низких) |
| Архитектура и право (A1, A2) | Вынесены владельцу, зафиксированы в документе исправлений |
## 4. Новые находки
### Н11. 🟠 Docker HEALTHCHECK проверяет несуществующий маршрут
- **Где:** `Dockerfile:21`
- **Суть:** после переноса маршрутов под `/api/v1/staff` проверка осталась на `http://127.0.0.1:8080/live`. Проверено запросом: корневой `/live` теперь отдаёт 404, `wget` вернёт ошибку.
- **Последствия:** контейнер всегда в состоянии `unhealthy`. В Docker Compose это ломает `depends_on: condition: service_healthy`, в оркестраторах (Swarm, Kubernetes при аналогичной пробе) — приводит к бесконечному перезапуску или к тому, что контейнер не попадает в балансировку. Автоматическая проверка развёртывания при этом молчит.
- **Исправление:** `CMD wget -q -O - http://127.0.0.1:8080/api/v1/staff/live || exit 1`; добавить healthcheck для сервиса `app` в `compose.yaml` и тест, который сверяет путь из `Dockerfile` с маршрутами.
### Н12. 🟠 Контейнер приложения получает учётные данные мигратора
- **Где:** `compose.yaml:4`, `.env.example:5-8`
- **Суть:** у сервиса `app` указан `env_file: .env`, а в `.env` лежат `DATABASE_MIGRATION_URL`, `POSTGRES_MIGRATION_PASSWORD` и `POSTGRES_APP_PASSWORD`. Переопределение `DATABASE_URL` не убирает остальные переменные из окружения контейнера.
- **Последствия:** пароль роли-владельца схемы доступен процессу приложения и любому, кто прочитает `/proc/1/environ` или `docker inspect`. Разделение ролей, ради которого сделана находка №6, частично обесценивается. `docs/DEPLOYMENT.md` прямо утверждает обратное: «миграционный URL хранится отдельно и не передаётся runtime-контейнеру».
- **Исправление:** отдельные файлы `.env.app` и `.env.migrate`, либо перечислить нужные переменные в `environment:` без `env_file`.
### Н13. 🟡 Обновление существующей установки ломается
- **Где:** `compose.yaml:23-32`, `docker/postgres/init-app-role.sh`
- **Суть:** `POSTGRES_USER` изменён с `krona_staff` на `krona_migrator`, а роль приложения создаётся скриптом из `docker-entrypoint-initdb.d`, который выполняется только при инициализации пустого каталога данных. На уже существующем томе:
- роль `krona_staff_app` не появится;
- блок `GRANT` в миграциях обёрнут в `IF EXISTS (SELECT 1 FROM pg_roles ...)` и молча ничего не сделает;
- приложение не сможет подключиться, в кластере останется прежний суперпользователь `krona_staff`.
- **Исправление:** выполнять `scripts/create-app-role.mjs` в сервисе `migrate` перед миграциями и описать порядок обновления в `docs/DEPLOYMENT.md`.
### Н14. 🟡 Права выдаются «если роль есть» и не распространяются на будущие таблицы
- **Где:** `migrations/0001_infrastructure.up.sql:80-89`, `migrations/0002_security_hardening.up.sql:59-68`
- **Суть:** при отсутствии роли миграция завершается успешно, но без выдачи прав — ошибка проявится позже как `permission denied` в рантайме. `ALTER DEFAULT PRIVILEGES` не настроен, поэтому каждая новая миграция обязана повторять блок `GRANT`; забытый блок — новая таблица, недоступная приложению.
- **Исправление:** падать с понятной ошибкой, если роли нет; настроить `ALTER DEFAULT PRIVILEGES FOR ROLE krona_migrator IN SCHEMA public`.
### Н15. 🟡 Новое правило «Приход/Уход» теряет длинные смены и не имеет ручной корректировки
- **Где:** `MainPage.xaml.cs:586-588`
- **Суть:** «Уход» проставляется, только если предыдущее событие — «Приход» и с него прошло не больше `MaximumShiftHours` (по умолчанию 20 ч). Переход через полночь теперь обрабатывается верно (№14 закрыта), но появились новые случаи:
- смена длиннее настроенного значения (суточное дежурство при значении 20 ч) даёт второй «Приход» вместо «Ухода»;
- если сотрудник не отметил уход, «Приход» остаётся незакрытым навсегда, и следующая отметка тоже станет «Приходом»;
- исправить ошибочную запись в интерфейсе нельзя — доступна только полная очистка журнала.
- **Исправление:** редактирование или удаление отдельной записи под администратором с пометкой о ручной правке; предупреждение при незакрытом «Приходе».
### Н16. 🟡 Ограничение журнала в 100 000 записей делает сохранение очень дорогим
- **Где:** `MainPage.xaml.cs:599-601`, `Services/TerminalStore.cs:127`
- **Суть:** предел в 100 000 записей — это десятки мегабайт JSON с отступами. Каждая отметка выполняет два сохранения, каждое сохранение полностью сериализует состояние, шифрует его, пишет с принудительным сбросом на диск и копирует три резервные копии. То есть на одну отметку приходится до восьми полных записей файла.
- Записи сверх лимита удаляются молча, без архива и без предложения выгрузить CSV.
- **Исправление:** перейти на журнал с добавлением записей (SQLite или файл-журнал), либо хранить в состоянии только последние отметки, а историю писать отдельно; перед усечением требовать экспорт.
### Н17. 🟡 Приватный режим скрывает имена только в списке
- **Где:** `MainPage.xaml.cs:581`, `:605`
- **Суть:** при включённом маскировании (по умолчанию) список последних отметок показывает «И\*\*\* П\*\*\*», но крупный текст на экране терминала по-прежнему выводит полное имя: «Иван Петров / Приход отмечен в 09:14», то же при отклонении повторного скана.
- **Последствия:** цель находки №26 не достигнута — имя всё равно видно постороннему у терминала, причём более заметно, чем в списке.
- **Исправление:** применять то же маскирование к `TerminalMessage` и к сообщениям строки состояния в публичном режиме.
### Н18. ⚪ Экспорт CSV не фиксируется и не защищён
- **Где:** `MainPage.xaml.cs:641-667`
- **Суть:** экспорт выгружает полные имена, табельные номера, должности и все отметки в произвольный незашифрованный файл. Факт экспорта нигде не сохраняется.
- **Исправление:** записывать в журнал событие экспорта (кто, когда, сколько записей); предупреждать, что файл не зашифрован.
### Н19. ⚪ Изменена уже применённая миграция, контрольные суммы не проверяются
- **Где:** `migrations/0001_infrastructure.up.sql`, `scripts/migrate.mjs`
- **Суть:** файл `0001` изменён задним числом (в `CREATE TABLE` добавлены `subject_*`/`actor_*` и `CHECK`), при этом на уже мигрированных базах таблица создана по старой версии, а колонки добавляет `0002`. Сейчас расхождение компенсируется, но мигратор не хранит контрольные суммы, поэтому следующая правка применённого файла останется незамеченной.
- **Исправление:** хранить хеш файла в `schema_migrations` и падать при расхождении.
### Н20. ⚪ Подстановка Release вместо Debug в пути автозагрузки
- **Где:** `Services/StartupService.cs:25-38`
- **Суть:** если путь процесса содержит `\Debug\`, он заменяется на `\Release\`, а при отсутствии файла выбрасывается исключение. Установка в каталог, в названии которого есть «Debug», сломает автозагрузку; подставленный Release-файл может быть старой сборкой.
- **Исправление:** определять отладочную сборку по `#if DEBUG` или по атрибуту сборки, а не по пути.
### Н21. ⚪ ADR-001 не синхронизирован с кодом
- **Где:** `docs/adr/ADR-001-technology-and-runtime-modes.md:8`, `:12`
- **Суть:** ADR по-прежнему фиксирует Redis/BullMQ как принятое решение, хотя зависимости и сервис удалены; `AGENTS.md` требует синхронизировать ADR при изменении контракта. `docs/ARCHITECTURE.md` обновлён, ADR — нет.
- **Исправление:** дополнить ADR-001 или выпустить ADR-002 «Redis откладывается до появления сценария очередей».
## 5. Находки второго отчёта: почти все открыты
`Services/TerminalStore.cs` в новых коммитах **не менялся**, поэтому связанные находки в силе.
| № | Находка | Статус |
| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| Н1 | 🔴 После переименования повреждённого файла следующий запуск стартует с пустым состоянием: терминал открыт для создания чужого PIN, резервные копии затираются через 3–4 сохранения. То же при простом удалении файла состояния | ❌ Открыто |
| Н2 | 🟠 Любая ошибка чтения, включая временную блокировку файла антивирусом, считается повреждением; откат к резервной копии происходит без предупреждения | ❌ Открыто |
| Н3 | 🟡 Резервные копии обновляются на каждом сохранении и покрывают 1–2 скана; ошибочная очистка журнала попадает во все копии | ❌ Открыто, усугублено Н16 |
| Н4 | 🟡 `GetDeviceCount() - 1` не проверен на оборудовании | ❌ Открыто |
| Н5 | 🟡 Счётчик неверных PIN живёт в памяти, задержка блокирует и вход по отпечатку, старые хеши не пересчитываются | ❌ Открыто |
| Н6 | ⚪ `Disconnect` освобождает дескрипторы после 2 с ожидания независимо от результата | ❌ Открыто |
| Н7 | ⚪ Самопроизвольная отмена регистрации | 🟨 Частично: добавлено событие `RegistrationCancelled`, но `RefreshLists()` по-прежнему сбрасывает выбор и отменяет регистрацию |
| Н8 | ⚪ Редактирование сотрудника переписывает имена в прошлых отметках | ❌ Открыто |
| Н9 | ⚪ При недоступном хранилище приложение нельзя закрыть штатно | ❌ Открыто |
| Н10 | ⚪ Блокирующие циклы в пуле потоков, гонка при освобождении события активации, мьютекс в пространстве `Local\` | ❌ Открыто |
## 6. Находки первого отчёта: статус
### Закрыто
| № | Находка | Как закрыто |
| ---------- | ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| 1, 4, 5 | Регистрация отпечатка, гонка дескрипторов, потеря связи со сканером | Отмена регистрации, передача дескрипторов, контроль связи (остаточные Н4, Н6) |
| 3 | Гонка записи состояния | `SemaphoreSlim`, `WriteThrough` |
| 6 | RLS обходился суперпользователем | Роль `krona_staff_app` с `NOSUPERUSER NOBYPASSRLS`, тест проверяет `rolsuper`/`rolbypassrls` |
| 7 | Несовпадение паролей в быстром старте | Пароли и URL согласованы |
| 8 | Мигратор | `schema_migrations`, advisory lock, откат только последней, `ALLOW_MIGRATION_DOWN` |
| 9 | Потеря отметок при открытом диалоге | Отметка записывается |
| 11, 12 | Второй экземпляр, дубликат пальца | Мьютекс с активацией, `DBIdentify` перед регистрацией |
| 13 | Журнал без экспорта и без границ | CSV-экспорт и лимит (новые замечания Н16, Н18) |
| 14 | Смена через полночь | Настраиваемая максимальная длительность смены (новое замечание Н15) |
| 15 | `/ready` отдавал 500 с текстом ошибки | 503 без деталей, проверено |
| 16 | Расхождение OpenAPI и маршрутов | Префикс `/api/v1/staff` и тест сверки |
| 17 | Пул PostgreSQL | `pool.on('error')`, `release(error)`, защищённый `ROLLBACK` |
| 18 | Тесты не проверяли инварианты | Реальный тест изоляции двух тенантов, сверка OpenAPI, таймаут 15 с |
| 19 | Аудит-лог и outbox | Политики `SELECT`/`INSERT`, `subject`/`actor`, `CHECK` на ULID |
| 20 | Неиспользуемый Redis | Удалён из зависимостей, compose и конфигурации (ADR не обновлён — Н21) |
| 21, 22 | Автозагрузка | `--autostart` открывает в трее, по умолчанию выключена (остаток — Н20) |
| 23 | Защита последнего администратора | Учитываются все администраторы, отдельная проверка при удалении отпечатков |
| 25 | `Trim` у PIN | Проверяются оба варианта |
| 26 | Имена на публичном экране | Маскирование в списке (неполно — Н17) |
| 27 | Ошибки фоновых задач и `crash.log` | Глобальные обработчики, ротация по 1 МБ на 3 файла |
| 28, 29 | Платформы проекта, шаблонный манифест | Только x64, манифест очищен, BOM убран |
| 31, 32, 33 | `x-correlation-id`, версия, shutdown, CI, digest | Проверка ULID, `src/version.ts` и сверка версий, таймаут остановки, `format:check` в CI, образы по digest, запрет standalone в production |
### Оставлено сознательно и вынесено владельцу
- **№24** — повторный запрос PIN перед разрушительными действиями сохранён по требованию владельца.
- **№30** — EULA ZKTeco добавлена, но сборки терминала и подписи в CI нет: нужны Windows-раннер и сертификат. Стоит отметить, что EULA запрещает включение компонента в продукты, извлекающие из этого коммерческую выгоду, — перед коммерческим распространением нужна юридическая проверка.
- **A1** (роль терминала: локальное хранилище против edge-клиента Staff) и **A2** (письменные согласия на биометрию, сроки хранения, трансграничная передача) — решения владельца и юриста, зафиксированы в `docs/AUDIT-REMEDIATION-2026-09-18.md`.
## 7. Оценка качества исправлений
Сделано аккуратно: исправления не косметические, к ним добавлены тесты (изоляция тенантов, сверка OpenAPI), документация обновлена вместе с кодом, отдельный документ фиксирует, что оставлено сознательно. Обход RLS, мигратор и контракт API — самые опасные места первого отчёта — закрыты по существу, а не «подпоркой».
Основные замечания к процессу:
1. Изменения не проверялись в реальной среде: Docker и PostgreSQL не запускались, поэтому неработающий HEALTHCHECK (Н11) и сломанный путь обновления (Н13) не были замечены. `AGENTS.md` прямо требует не считать Docker и CI проверенными, если среда не запускалась, — документ исправлений это честно фиксирует.
2. Замечания второго отчёта не попали в работу, а хранилище состояния — источник самой опасной из открытых проблем (Н1).
3. Коммиты по-прежнему крупные и смешивают исправления с новыми функциями (экспорт CSV, приватный режим, редактирование).
## 8. План
| Приоритет | Задача | Находки |
| --------- | -------------------------------------------------------------------------------------------------------------------------------- | ------------- |
| 1 | Не стартовать с пустым состоянием при наличии `*.corrupt-*`/`.bak*`; не затирать копии; отличать повреждение от временных ошибок | Н1, Н2 |
| 2 | Исправить HEALTHCHECK и добавить проверку сервиса `app` в compose | Н11 |
| 3 | Убрать миграционные учётные данные из контейнера приложения | Н12 |
| 4 | Создание роли приложения в сервисе `migrate`, порядок обновления существующих установок, явная ошибка при отсутствии роли | Н13, Н14 |
| 5 | Журнал с добавлением записей вместо полной перезаписи, резервные копии по времени, ручная правка отметок | Н3, Н15, Н16 |
| 6 | Маскирование имени на главном экране терминала | Н17 |
| 7 | Проверить `GetDeviceCount() - 1` на оборудовании, довести устойчивость сканера | Н4, Н6 |
| 8 | Счётчик попыток PIN в хранилище, не блокировать вход по отпечатку, пересчёт старых хешей | Н5 |
| 9 | Контрольные суммы миграций, обновление ADR-001, аудит экспорта CSV | Н19, Н21, Н18 |
| 10 | Решения владельца: контракт терминала и правовая база обработки биометрии | A1, A2 |

View file

@ -1,56 +0,0 @@
# Приёмка №1: Grok и Antigravity — 2026-09-26
Проверял независимо, на живой PostgreSQL 17.10, в отдельных рабочих копиях. Код веток не менялся. Основа обеих веток — `778ba2d`; текущая основная ветка — `331e490`. Обе ветки вливаются в неё без конфликтов, друг с другом тоже.
## Итог
| Исполнитель | Ветка | Сделано из задания | Вердикт |
| ----------- | -------------------------------------------------- | --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Antigravity | `antigravity/fix-reject-device-shifts` (`795bba0`) | Только часть 0 — немедленная мера по S1 | **Принять после одной правки**: подделка смены с устройства закрыта, но новый тест на реальной базе красный. Части 1–4 не начаты |
| Grok | `grok/e2e-harness` (`421da72`) | Задачи 1–7 | **Принять после двух правок**: стенд работает и честно ловит дефекты, но под Windows не запускается, а ID моего аудита не сопоставлены — файлы аудита до Grok не дошли |
## Antigravity
**Что проверено вживую** ([evidence/acceptance-antigravity-live-sync.txt](evidence/acceptance-antigravity-live-sync.txt)):
- **S1 закрыт.** Устройство отправляет смену `status: confirmed, worked_minutes: 2880`. Ответ: `rejected / COMMAND_NOT_SUPPORTED`, смена не создана, расчёт по ней начисляет 0 ₽. До исправления было 14 400 ₽.
- **Сценарии Grok на этой ветке.** SYNC-A и SYNC-B (устройство не может подтвердить смену и утвердить табель) стали зелёными ([evidence/acceptance-grok-sync-with-antigravity.txt](evidence/acceptance-grok-sync-with-antigravity.txt)).
- **Штатные проверки.** lint, typecheck, сборка, миграции и контракты проходят; 188 тестов пройдено. `format:check` красный только по чужим старым файлам, файлы Antigravity отформатированы.
**Что не так:**
1. **Новый тест на реальной базе падает.** В очистке `tests/integration/sync-rejection.test.ts:69-74` запись удаляется ролью приложения, а у неё нет права DELETE на `staff_timesheet_entries` и `staff_employees`. Это тот же дефект, что N5. Проверка внутри теста проходит, но сам файл красный — значит, на живой базе тест не запускался. Исправление: очистка ролью мигратора, как Grok сделал для N5.
2. **Сделана только часть 0.** Не начаты: синхронизация S2–S9 (S2 и S9 проверил на этой ветке — оба по-прежнему воспроизводятся), SSRF через push (K12), оповещения процесса подтверждения, публикатор outbox.
3. **Сообщение коммита — один абзац в строке заголовка.** Нужны короткий заголовок и причина в теле (AGENTS.md).
4. **После влития нужно синхронизироваться с Grok.** SYNC-A и SYNC-B надо убрать из `tests/e2e/known-defects.json` — иначе CI упадёт с «дефект исправлен, обновите список». Это зона Grok.
## Grok
**Что проверено** ([evidence/acceptance-grok-e2e.txt](evidence/acceptance-grok-e2e.txt)):
- **Зона соблюдена.** Изменены только `tests/e2e/**`, `tests/integration/tenant-isolation.test.ts`, `.forgejo/workflows/ci.yml` и `scripts/test/**`. Продукт не тронут.
- **Коммиты оформлены правильно.** 12 маленьких коммитов, у каждого в теле объяснена причина.
- **Стенд работает.** Прогон на моей PostgreSQL дал **38 красных и 9 зелёных** — ровно как в его отчёте. Красные совпадают с моими находками: N1, N2, N3, N4, N9, N10, N11, N12, F2, F3, F4, F7, F10, решения владельца OWN-01…11, синхронизация, пилотный compose.
- **Логика «известных дефектов» верная.** Известный красный сценарий не роняет CI. Исправленный дефект, который остался в списке, роняет CI. Незнакомый красный сценарий тоже роняет CI. Так исправление не пройдёт незамеченным, и регрессия тоже.
- **Новая находка, которой не было у меня (SYNC-C0).** Начальная загрузка данных на устройство на реальной базе всегда отвечает 500: `column "status" does not exist`. Офлайн-синхронизация фактически не работает на приём данных. Это задача для Antigravity.
**Что не так:**
1. **Под Windows стенд не запускается**, а проект разрабатывается под Windows:
- `scripts/test/run-e2e.mjs:35` — `spawn('npx')`;
- `tests/e2e/harness/global-setup.ts:67` и `scripts/test/n13-race.mjs:102` — `spawn(node_modules/.bin/tsx)`.
Всё это даёт `ENOENT`, потому что в Windows это `.cmd`. Исправление: `spawn(process.execPath, [<путь к tsx/dist/cli.mjs>, …])` и то же для vitest. С такой локальной правкой я и проводил прогон.
2. **ID моего аудита не сопоставлены.** Файлы `AUDIT.md` и `evidence` до Grok не дошли. Он это честно отметил: K1, K3–K6, K10–K12, S1, S2, S9 и A4 помечены «не проверено». Сценарии фактически покрывают те же дефекты под его номерами, нужно только переименовать теги.
3. **PR не создан**: у Grok нет токена Forgejo. Описание лежит в `tests/e2e/REPORT.md`. Также `tests/e2e/evidence/` коммитится в репозиторий и будет меняться при каждом прогоне — лучше хранить это как артефакт CI.
## Что делать дальше
1. **Влить Grok первым** (только тесты и CI) — после исправления запуска под Windows.
2. **Влить Antigravity** — после исправления очистки в тесте. В том же PR или сразу после Grok убирает SYNC-A и SYNC-B из списка известных дефектов.
3. **Antigravity продолжает** части 1–4 задания, плюс SYNC-C0 (загрузка на устройство падает на реальной базе).
4. **Grok сопоставляет ID** с моим аудитом, когда получит файлы.
5. **Codex.** В репозитории уже есть ветки `codex/fix-format-baseline` и `codex/fix-live-database` (этапы 0 и 1). Их можно принимать следующими.
Стенды удалены, база остановлена. В этой приёмке ничего не влито и не запушено.

View file

@ -1,213 +0,0 @@
# Полный аудит krona-staff — 2026-09-26
- Аудитор: Claude (Opus 5.5), режим только чтения: код, ветки и удалённый репозиторий не менялись
- Проверено: основная ветка `codex/bootstrap-platform-foundation` на коммите `3e20544` (2026-09-26 12:31 +07, версия 0.3.2) и ветки `antigravity/offline-sync-backend` (`5ee7517`, офлайн-синхронизация) и `codex/frontend-library` (`1812870`, оформление портала)
- Действует для: `778ba2d` — текущая основная ветка, в которую обе ветки влиты без изменения проверенного кода
- Сверка: аудит Grok на том же коммите `3e20544` (14 старых и 14 новых находок) — каждая перепроверена заново
- Доказательства: папка [evidence](evidence) — сценарии живой проверки и их вывод
> Во время проверки обе ветки были влиты в основную (`3595792`), плюс косметический коммит `778ba2d`. Код синхронизации совпадает с проверенным `5ee7517`, поэтому находки S1–S9 относятся к основной ветке, а S1 (устройство создаёт подтверждённые смены, и они попадают в зарплату) — действующий дефект.
## 1. Итог в трёх строках
1. **Выпускать нельзя.** На настоящей PostgreSQL главная цепочка «подтвердить смену → рассчитать → утвердить» не работает (500), а создать сотрудника через API невозможно.
2. **Деньги считаются неверно даже там, где работает.** Одна смена может быть оплачена трижды, утверждённую смену можно отклонить задним числом без сторно, импорт из Excel теряет суммы, а устройство офлайн-синхронизации может само создать «подтверждённую» смену на 48 часов.
3. **Терминал пока не отправляет отметки на сервер.** Сотрудники на терминале получают свои идентификаторы, сервер их не знает и отвечает 404; связь служба ↔ интерфейс не работает из-за разных имён канала.
## 2. Что реально запускалось
| Проверка | Результат |
| ------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `npm ci`, lint, typecheck, build, check:migrations, check:contracts | пройдено |
| `format:check` | не пройдено — 16 файлов ([evidence/format-check.txt](evidence/format-check.txt)) |
| vitest без базы | 156 пройдено, 1 пропущен |
| vitest на живой PostgreSQL 17.10 (роль приложения) | тест изоляции арендаторов падает при очистке: `permission denied for table staff_employees` |
| Миграции 0001–0010 мигратором **без** SUPERUSER: вверх, откат 10 шагов, вверх, повтор | пройдено |
| Миграции ветки синхронизации 0001–0011, откат 0011 и повтор | пройдено |
| `scripts/create-app-role.mjs` мигратором без SUPERUSER | ошибка `permission denied to alter role` |
| RLS на живой базе | 23 из 23 таблиц с `tenant_id`: ENABLE + FORCE + политика; роль приложения без SUPERUSER, BYPASSRLS, CREATEROLE; журналы только на чтение и вставку |
| Живой API: сервер на реальной базе, реальные HTTP-запросы | 19 сценариев, см. раздел 4 |
| `npm audit` (всё и production) | 0 уязвимостей |
| .NET: сборка службы Release, тесты терминала | сборка без ошибок; тесты 43 из 43 |
| Named pipe службы | проверено запуском отдельного тестового проекта |
| Не проверено | Docker и compose (нет на машине), WinUI-интерфейс вживую, DPAPI и ACL на отдельной Windows-машине, Snyk, реальные push-сервисы |
Стенд: Windows 11, Node 24, PostgreSQL 17.10 (встроенная сборка), .NET 10 SDK. База аудита создавалась в UTF-8; первая попытка в WIN1251 дала ложную ошибку кодировки, её результаты отброшены.
## 3. Сводка
| Уровень | Открыто | Из них подтверждено вживую |
| ------- | ------- | -------------------------- |
| P0 | 2 | 1 |
| P1 | 17 | 10 |
| P2 | 32 | 6 |
| P3 | 31 | 3 |
Сверка с Grok: из его 28 находок 22 подтверждены, 4 подтверждены частично (F8, F11, N7, N10), 1 исправлена (F5), 1 не воспроизведена (N13). Сверх Grok — 57 новых замечаний, главное — в денежной логике, терминале, эксплуатации и офлайн-синхронизации.
## 4. Подтверждено на живом сервере
| ID | Что проверено | Результат |
| --- | ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| F2 | Сервер без токенов, `STANDALONE_ACCESS=manager`, запросы без авторизации | `GET /portal` 200, `GET /payroll` 200, `GET /finance` 200, **запись** `POST /professions` 201 |
| F2 | То же при `STANDALONE_ACCESS=employee` (значение по умолчанию в compose) | `GET /portal` 200, `GET /payroll` 200 анонимно |
| F3 | Вход менеджером, выход, повтор старой cookie | cookie содержит сам токен менеджера; после `DELETE /session` старая cookie даёт `GET /portal` 200 |
| F4 | 60 неверных входов подряд | 60 × 401, ни одного 429 |
| N1 | `POST /employees` | 500, `42P08 inconsistent types deduced for parameter $2` |
| N2 | Подтверждение смены, расчёт | оба 500, `23502 null value in column "occurred_at" of relation "staff_outbox"` |
| N4 | Одна смена 8 ч × 300 ₽, три пересекающихся расчёта с утверждением | 3 начисления в реестре, **7 200 ₽ вместо 2 400 ₽** |
| D5 | Отклонение смены, уже оплаченной по утверждённой ведомости | 200, сторно не создано, начисления остались |
| N12 | Ставка `rate_minor = 1e20` с лишним полем | принято, 200 |
| — | Расчёт за `month = 2026-13` | 500 |
| N3 | Сотрудник подписывает push на `https://127.0.0.1:18443`, затем отметка на терминале | сервер открыл соединение на внутренний адрес и отправил TLS ClientHello |
| N9 | Сотрудник Б регистрирует endpoint сотрудника А | 201, подписка перешла к Б |
| A4 | Ошибки 500 | наружу уходят код и текст ошибки PostgreSQL |
| N10 | Токен терминала | `GET /professions` 200 со всем справочником; `GET /org-units` отдаёт пустой список — эта часть находки Grok не подтвердилась |
| N13 | Два одновременных первых расчёта периода | **не воспроизведено**: обе заявки получили один расчёт |
| S1 | Устройство синхронизации отправляет смену `status: confirmed, worked_minutes: 2880` | смена сохранена подтверждённой, подтвердил «никто», журнал аудита пуст; расчёт начислил **14 400 ₽** |
| S2 | Устройство отправляет отметку с `occurred_at = 2020-01-01` | сохранена |
| S9 | Удаление точки, к которой привязано устройство синхронизации | ошибка `23502 null value in column "tenant_id"` — точку удалить нельзя |
| — | Заголовки портала | CSP `script-src 'self'`, HSTS присутствуют |
Для N4, D5 и S1 в базе аудита временно задавалось значение по умолчанию для `staff_outbox.occurred_at` (иначе мешает N2); после сценария оно снималось.
## 5. P0 — блокируют любое использование вне стенда
**K1. Открытый режим без токенов (F2, подтверждено вживую).** Если токены менеджера и терминала не заданы, любой запрос без авторизации получает права `STANDALONE_ACCESS` (`src/adapters/identity.ts:32-49`, `src/config.ts:45-47`). Корневой `compose.yaml` публикует порт 8080 на всех интерфейсах по HTTP и токенов не задаёт; в пилотном compose переменные подставляются без `:?`, поэтому пустая строка в `.env` молча открывает сервис.
**K2. Production-режим недостижим, пилот работает как development (F1).** Адаптер платформенного JWT — заглушка (`identity.ts:60-66`), standalone в production запрещён. Поэтому оба compose ставят `NODE_ENV=development` (`compose.yaml:5`, `deploy/pilot/compose.yaml:8`) — это обходит проверки «standalone запрещён» и «HTTPS обязателен» на пилоте с реальными ФИО и зарплатами.
## 6. P1 — денежная логика и работоспособность
| ID | Суть | Где | Подтверждение |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ | ---------------------------------- |
| K3 (N2) | Вставки в outbox без `occurred_at`: подтверждение смены, расчёт и утверждение всегда 500 | `src/portal.ts:1145-1163`, `src/domain/payroll/service.ts:770-784`, `917-935` | вживую |
| K4 (N1) | `POST /employees` всегда 500 из-за типа параметра `$2` | `src/portal.ts:465-483` | вживую |
| K5 (N4) | Пересекающиеся и «точка / все точки» расчёты начисляют смену повторно; при пересчёте не обновляется `period_end` | `service.ts:425-454`, `759-768`, `860-903`; индексы `0006:196-199` | вживую, 7 200 ₽ за 2 400 ₽ |
| K6 (D5) | Смены и строки утверждённой ведомости можно менять; у роли приложения есть DELETE на строки и UPDATE на ведомости, неизменяемость держится только кодом | `portal.ts:1024-1106`, `0006:296-300` | вживую |
| K7 (D2) | Импорт начислений и выплат теряет деньги: дельта считается по всем записям дня, а вставка по конкретному ключу | `src/operations.ts:1017-1052`, `scripts/import-owner-workbook.py:147,168,176` | по коду |
| K8 (D1) | Версии правил фиктивны: v1 изменяемая, даты действия не учитываются, условия точки не читаются, в строке ведомости нет дерева правила и ревизии смены — утверждённый расчёт нельзя воспроизвести | `service.ts:152-180`, `517-529`, `686-695` | по коду |
| K9 (D3) | Параметры пресетов кроме ставки, процента и гарантии не сохраняются, метрики кроме выручки и позиции не загружаются: официант по пресету получает 0 ₽, премия за порог выручки — 0 ₽ | `service.ts:295-299`, `488-500`; `src/portal-ui.ts:273-275` | по коду |
| K10 (F3) | В cookie лежит сам токен, выход не отзывает сессию; токен сотрудника бессрочный | `portal.ts:262-276` | вживую |
| K11 (F4, A2) | Нет ограничения частоты; каждый неверный токен открывает транзакцию в БД при пуле 10 соединений — отказ в обслуживании | `portal.ts:1276-1296`, `src/infrastructure/database.ts:9` | вживую (F4) |
| K12 (N3) | SSRF через push-подписку: разрешён любой `https://`, редиректы следуются, фрагмент ответа сохраняется | `src/domain/notification/push-dispatcher.ts:80`, `web-push.ts:226-258` | вживую |
| K13 (S1) | Офлайн-синхронизация: устройство создаёт и перезаписывает подтверждённые смены и строки табеля | `src/domain/sync/service.ts:784-990` | вживую, 14 400 ₽ |
| K14 (T4) | Отметки терминала не доходят до сервера: локальные ULID сотрудников сервер не знает (404), повтор каждые 15 с бесконечно; контракт в `docs/TERMINAL-API-REQUIREMENTS.md` расходится с кодом | `apps/KronaStaffTerminal/MainPage.xaml.cs:479-492`, `TerminalModels.cs:157` | по коду + ответ сервера 404 |
| K15 (T1) | Служба создаёт pipe `\\.\pipe\global\KronaStaffTerminalPipe`, интерфейс подключается к `\\.\pipe\KronaStaffTerminalPipe`: IPC не работает, свободное имя может занять любой процесс и получать отметки с ФИО | `apps/KronaStaffTerminal/Services/TerminalIpc.cs:84`, `:236` | проверено запуском |
| K16 (T2) | Служба от LocalSystem выдаёт операторам полный доступ к каталогу данных и сама ротирует там логи и меняет ACL — путь к правам SYSTEM через junction | `TerminalStore.cs:305-360`, `CrashLogService.cs:331-371`, `installer/KronaStaffTerminal.nsi:119` | по коду (механизм), PoC не делался |
| K17 (T5) | Биометрия: нет письменного согласия (152-ФЗ ст. 11), шаблоны остаются в резервных копиях после удаления, уведомления в Telegram — трансграничная передача | `MainPage.xaml.cs:685-776`, `TerminalStore.cs:96-100, 376-384` | по коду |
| K18 (D1 эксплуатации) | Нет резервного копирования пилотной базы с реальными ФИО, табелем и зарплатой; `BACKUP-RESTORE.md` — только шаблон | `docs/BACKUP-RESTORE.md`, `deploy/pilot/` | по коду |
| K19 (D4 расчёта) | Утверждается устаревший расчёт: ревизии смен и выручки не сверяются, предупреждения «нет выручки», «нет профиля оплаты» не сохраняются, сотрудник без профиля выпадает из фонда и его долю делят остальные | `service.ts:632-641`, `840-903` | по коду |
## 7. P2
**Сервер и расчёт**
- N5 — тест изоляции арендаторов удаляет сотрудников ролью приложения, у которой нет DELETE: CI с базой красный (подтверждено). Маршрутные тесты работают на поддельной базе, поэтому N1 и N2 не ловятся.
- N6 — `create-app-role.mjs` требует мигратора-суперпользователя (подтверждено).
- N7 — импорт-скрипт отключает проверку TLS при `--allow-self-signed` и принимает токен в аргументах командной строки. **Уточнение к Grok:** ответа 400 на `/professions` не будет — Fastify по умолчанию молча отбрасывает лишние поля (`removeAdditional`), поэтому ответ 201, а задуманный код профессии теряется.
- N12, D9 — нет верхних границ ставок и `additionalProperties:false` на назначении оплаты; `2026-13` и `2026-02-30` проходят шаблон и дают 500 (подтверждено).
- F7 — `Idempotency-Key` и таблица ключей не используются, хотя ADR и контракт приложения их требуют.
- F9 — healthcheck в Dockerfile по HTTP при HTTPS-only production.
- F10, A6 — проверка Origin пропускает запросы без Origin и с любым `Authorization`; практическая эксплуатация закрыта `SameSite=Strict`, но должно быть fail-closed.
- A3 — один общий токен терминала на все устройства, отметки до 180 дней назад.
- A4 — ошибки 500 раскрывают текст PostgreSQL (подтверждено).
- D6 — одна роль «менеджер» и считает, и утверждает, и выплачивает, без привязки к точке — разделения обязанностей нет.
- D13 — `additionalProperties:false` в схемах на деле не отклоняет лишние поля, а молча их удаляет: опечатки клиентов не видны.
- D7 — округление строк после деления фонда выплачивает на 1 ₽ больше фонда; вес фонда у правила без доли фонда теряет эту часть фонда.
**Уведомления и outbox**
- F6 — outbox пишется, но публикатора нет, `dispatchPending` не вызывается, строки в `pending` не повторяются никогда; отправка живёт в `setImmediate` и теряется при рестарте.
- U1 — некорректные ключи подписки роняют цикл отправки остальным устройствам.
- U2 — нет лимита подписок и таймаутов: одна отметка может породить тысячи запросов.
- U3 — нет захвата строки при отправке: при двух экземплярах возможна двойная отправка.
**Офлайн-синхронизация**
- S2 — время отметки задаётся клиентом без ограничений (подтверждено: 2020 год принят).
- S3 — гонки идемпотентности, потеря обновлений без проверки ревизии, одна плохая команда откатывает весь пакет из 100.
- S4 — начальная синхронизация отдаёт устройству весь арендатор, включая уволенных и всю историю минут.
- S5 — курсор теряет изменения за пределами первых 100 строк; изменения из портала устройство не видит.
- S6 — отметки через синхронизацию не пишут outbox и принимаются для уволенных.
- S9 — внешний ключ `ON DELETE SET NULL` на составном ключе ломает удаление точки (подтверждено).
**Терминал и служба**
- T3 — член группы операторов может расшифровать состояние: хеш PIN, токены сервера и ботов, токен обновлений, шаблоны отпечатков.
- T6 — служба и интерфейс перезаписывают один файл без межпроцессной блокировки: теряются смена PIN, счётчик попыток, блокировки.
- T7 — установщик не задаёт ACL на выбранный каталог установки; предсозданный пользователем каталог данных остаётся в его владении.
- T8 — терминал запускается с правами администратора после установки и автообновления; через диалог экспорта можно получить оболочку.
- T9 — проверка обновления сильная (HTTPS, SHA-256, Authenticode, закреплённый издатель), но файл проверяется в профиле пользователя и запускается позже (TOCTOU); на каждом терминале лежит токен чтения приватного репозитория.
- T10 — служба от SYSTEM загружает `libzkfp.dll` по PATH, если драйвер не установлен.
**Эксплуатация**
- D3 — самоподписанный сертификат на IP: у сотрудников не заработают PWA и push; ключ лежит рядом с pfx незашифрованным; продление ломает пиннинг на всех терминалах.
- D4 — обновление без тегированных образов и отката, без бэкапа перед миграцией, без `lock_timeout`.
- D5 — скрипт настройки терминала пишет не туда, где терминал читает состояние после переноса в `%ProgramData%`.
- D6 — `docker build` в CI внутри docker-раннера, down-миграции в CI не проверяются, пилотный compose тестами не покрыт.
## 8. P3 (кратко)
N8 эфемерные VAPID-ключи и кэш публичного ключа на сутки; N9 перепривязка чужого endpoint (подтверждено, но нужен секретный endpoint); N10 справочник профессий без проверки прав; N11 слабая валидация импорта; N13 гонка первого расчёта (не воспроизведена); N14 GET-запросы переписывают правило v1; F12 деньги в `number`; F13 плавающие теги в CI; F14 форматирование 16 файлов; A5 валидация и импорт до проверки прав, открытый `/openapi.json`; A7 новый токен сотрудника без `no-store`; A8 все менеджеры — один актор в аудите; A9 correlation id от клиента; U4 нет политики повторов и dead letter; U5 scope service worker не покрывает `/staff`; U6 выход не отзывает push-подписку; U7 FK без `tenant_id`; S7 очистка входящих не работает (передаётся не тот идентификатор); S8 CSRF и белый список scope устройств; W1 `/staff/*` отвечает 200 на любой путь; T11 DoS через IPC до проверки HMAC; T12 нет backoff в синхронизации терминала; T13 молчаливый откат службы к резервной копии, нет порога совпадения отпечатка; D8 документация расходится с кодом; D9 нет лимитов ресурсов контейнеров; D10 `.dockerignore` не исключает `deploy/pilot/.env` и секреты; D11 миграции из build-стадии от root; D12 smoke-тест ничего не проверяет и пишет в боевую базу; D14 нет редактирования PII в логах; D15 дефолты корневого compose; D16 коммиты без тела и смешанные по темам, 67 файлов bin/obj в истории.
## 9. Сверка с аудитом Grok
| ID Grok | Вердикт | Комментарий |
| ---------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------- |
| F1, F2, F3, F4 | подтверждено | F2–F4 вживую; к F1 добавлено: пилот обходит запрет через `NODE_ENV=development` |
| F5 | исправлено | неизвестный сотрудник → 404 |
| F6 | подтверждено, серьёзнее | строки `pending` не повторяются никогда |
| F7, F9, F12, F13, F14 | подтверждено | F13: `ajv allErrors` в проверке контрактов вектором не является |
| F8 | исправлено по форме, остаток P1 | ACL и HMAC есть, но имена pipe не совпадают (K15) |
| F10 | подтверждено, практически закрыто cookie `SameSite=Strict` | |
| F11 | API есть, но неработоспособно на реальной базе (K3) | |
| N1, N2, N3, N4, N5, N6 | подтверждено вживую | |
| N7 | частично | TLS и токен в argv — да; ответ 400 — нет (поля молча отбрасываются) |
| N8, N11, N12, N14 | подтверждено | |
| N9 | подтверждено вживую | эксплуатация требует знать секретный endpoint |
| N10 | частично | `/professions` — да; `/org-units` для не-менеджеров отфильтрован |
| N13 | не воспроизведено вживую | по коду возможно, оставлено P3 |
Чего в аудите Grok нет: денежные дефекты K6–K9 и K19, терминал K14–K17, отсутствие бэкапа, пилот в режиме development, ветка синхронизации целиком (она появилась после его аудита).
## 10. Что сделано хорошо
- Изоляция арендаторов: RLS с FORCE на всех 23 таблицах, составные внешние ключи, `set_config(..., true)` в транзакции, арендатор никогда не берётся из запроса.
- Роли базы: приложение без SUPERUSER и BYPASSRLS, журналы начислений и аудита — только чтение и вставка на уровне прав.
- Миграции: SHA-256, advisory lock, откат только последней и только явным флагом; работают мигратором без суперпользователя.
- Токены: сравнение за постоянное время, 256 бит, в базе только хеш; cookie `__Host-`, HttpOnly, Secure, SameSite=Strict; CSP `script-src 'self'`, экранирование в портале.
- Web-push: шифрование по RFC 8291/8188 и VAPID реализованы корректно, в уведомлениях нет персональных данных.
- Калькулятор: чистая функция, распределение фонда методом наибольшего остатка, тесты на реальной таблице владельца.
- Терминал: закрыты все прошлые находки по хранилищу и PIN (Argon2id, маркер первичной настройки в HKLM, отличие повреждения от временной ошибки), шаблоны отпечатков не уходят на сервер, автообновление проверяет подпись и издателя.
- Контейнеры не от root, read-only, образы по digest; секретов в истории нет; UTF-8 без BOM и LF соблюдены.
## 11. Порядок исправлений
1. **Разблокировать работу на базе:** K3 (одна строка в трёх местах), K4 и сразу интеграционные тесты этих маршрутов на PostgreSQL в CI.
2. **Закрыть открытый доступ и пилот:** K1, K2 (профиль `pilot` с обязательными токенами и HTTPS), K10, K11, бэкап K18.
3. **Деньги:** K5, K6, K7, K8, K9, K19 — до первого боевого расчёта.
4. **Синхронизация (уже в основной ветке):** сразу отключить приём смен и табеля от устройств и не регистрировать устройства в пилоте; затем закрыть K13, S2, S3, S9.
5. **Уведомления:** K12, F6, U1–U3.
6. **Терминал:** K14 (без этого отметки не попадают на сервер), K15, K16, K17.
7. P2 и P3 по мере работы.
Распределение по исполнителям — в файлах [TASK-CODEX.md](TASK-CODEX.md), [TASK-ANTIGRAVITY.md](TASK-ANTIGRAVITY.md), [TASK-GROK.md](TASK-GROK.md).
## 12. Решения владельца (2026-09-26)
**Роли и точки (закрывает вопрос D6):**
- **бухгалтер** готовит расчёт и ведомость выплат и отправляет пакет на подтверждение;
- **руководитель** получает оповещение, просматривает и подписывает пакет (или возвращает с комментарием); только его подпись регистрирует начисления и выплаты — руководитель проводит оплаты;
- **управляющий** привязан к своим точкам и работает с ними; утверждать и выплачивать не может;
- сотрудники и управляющие **могут работать на других точках**: привязка к основной точке сохраняется, но смена на другой точке допустима и относится к той точке, где отработана.
**Согласие на обработку биометрии (закрывает вопрос K17):** согласие обязательно; подтверждается в мобильном приложении сотрудника при авторизации.
Юридическая оговорка для исполнителя: 152-ФЗ требует для биометрии согласия в письменной форме. Электронное согласие равнозначно письменному, если подписано электронной подписью, в том числе простой, при наличии соглашения о её использовании (например, пункт трудового договора или локального акта). Текст согласия и такое соглашение готовит юрист.
## 13. Локальная ветка
Ветка `codex/payroll-foundation` удалена по решению владельца. Её незакоммиченные копии, уже влитые в основную ветку коммитом `3464998`, сохранены в `git stash` на случай сверки.

View file

@ -1,22 +0,0 @@
# Аудит 2026-09-26 и задания исполнителям
Рабочая папка аудита: исполнители берут отсюда задания и отчитываются через git.
| Файл | Что это |
| ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- |
| [AUDIT.md](AUDIT.md) | Полный аудит на живой PostgreSQL 17: находки P0–P3, сверка с аудитом Grok, решения владельца |
| [ACCEPTANCE-1.md](ACCEPTANCE-1.md) | Приёмка первого круга работ Grok и Antigravity |
| [TASK-CODEX.md](TASK-CODEX.md) | Codex: сервер, расчёт, база, пилот, терминал, роли, подтверждение и согласие — этапы 0–6 |
| [TASK-ANTIGRAVITY-2.md](TASK-ANTIGRAVITY-2.md) | Antigravity, **текущий круг**: синхронизация, push, оповещения, outbox |
| [TASK-GROK-2.md](TASK-GROK-2.md) | Grok, **текущий круг**: запуск стенда под Windows, сопоставление ID, недостающие сценарии, приёмка |
| [TASK-ANTIGRAVITY.md](TASK-ANTIGRAVITY.md), [TASK-GROK.md](TASK-GROK.md) | Задания первого круга, для истории |
| [evidence/](evidence) | Сценарии живой проверки и их фактический вывод |
## Как работать
- Каждый исполнитель работает в своих ветках и только в своей зоне файлов — зоны перечислены в задании.
- Готовность пункта подтверждается зелёным сценарием стенда Grok (`node scripts/test/run-e2e.mjs`) на живой PostgreSQL, а не тестами на поддельной базе.
- Отчёт о выполненном — в описании ветки или коммита: ID, коммит, какой сценарий стал зелёным.
- Приёмку каждой ветки независимо проводит Grok; итог следующего круга появится здесь как `ACCEPTANCE-2.md`.
Решения владельца (роли, точки, процесс подтверждения, согласие) — раздел 12 в [AUDIT.md](AUDIT.md) и раздел «Решения владельца» в [TASK-CODEX.md](TASK-CODEX.md).

View file

@ -1,116 +0,0 @@
# Задание для Antigravity, круг 2: синхронизация, push, outbox
- Репозиторий: https://git.ochenstarik.ru/ochenstarik/krona-staff
- Основа: текущая основная ветка `codex/bootstrap-platform-foundation` (`331e490`)
- Задание самодостаточное; полный аудит и доказательства — в `docs/audit/2026-09-26/`.
## Итог приёмки прошлого круга
Ветку `antigravity/fix-reject-device-shifts` (`795bba0`) проверили на живой PostgreSQL 17.
**Что сработало:**
- Подделка смены с устройства закрыта. Смена `status: confirmed, worked_minutes: 2880` теперь получает `rejected / COMMAND_NOT_SUPPORTED`. Раньше она начисляла 14 400 ₽, теперь 0 ₽.
- Сценарии Grok SYNC-A и SYNC-B на этой ветке зелёные.
**Что не так:**
1. `tests/integration/sync-rejection.test.ts:69-74` на реальной базе падает. Очистка идёт ролью приложения, а у неё нет права DELETE на `staff_timesheet_entries` и `staff_employees`. Проверка внутри теста проходит, но файл красный — значит, на живой базе тест не запускали.
2. Сообщение коммита — один абзац в строке заголовка. Нужен короткий заголовок, а причина — в теле.
3. Из задания сделана только немедленная мера. Остальные части не начаты.
## Как работать в этом круге
- **Ветки.** `antigravity/fix-<тема>` от `331e490`, по одной на каждый пункт ниже. Маленькие коммиты: заголовок до 72 символов, причина в теле (AGENTS.md).
- **Проверка на живой базе обязательна.** В репозитории есть стенд Grok (ветка `grok/e2e-harness`, после влития — в основной):
- `scripts/test/provision-pg.mjs` создаёт роли как в пилоте: мигратор без SUPERUSER, приложение без BYPASSRLS;
- `node scripts/test/run-e2e.mjs` прогоняет сценарии на реальном сервере и реальной PostgreSQL 17.
Пункт считается сделанным, когда соответствующий сценарий стал зелёным. Номера сценариев указаны в таблицах.
- **Очистка в тестах** — ролью мигратора, как в `tests/integration/tenant-isolation.test.ts` после исправления Grok. Роли приложения удалять запрещено, так задумано.
- **Список известных дефектов.** После каждого исправления сообщи Grok ID сценариев, которые стали зелёными, — он уберёт их из `tests/e2e/known-defects.json`. Сам этот файл не правь: иначе CI упадёт с «дефект исправлен, обновите список».
- **Outbox.** Писать в `staff_outbox` только через общий хелпер, который делает Codex (ветка `codex/fix-live-database`). Во вставке обязательно поле `occurred_at` — без него подтверждение и расчёт падают.
**Твоя зона:**
- `src/sync.ts`, `src/domain/sync/**`, миграции синхронизации;
- `src/domain/notification/**`, маршруты `/push/*`;
- service worker и подписка в `src/portal-ui.ts`;
- диспетчер и публикатор outbox.
**Не трогай:** авторизацию, сотрудников, смены, расчёт, терминал и развёртывание — это зона Codex; `tests/e2e/**` и CI — зона Grok.
## 1. Довести прошлую ветку (сегодня)
| Что сделать | Готово, когда |
| --------------------------------------------------------------- | ----------------------------------------- |
| Очистка в `sync-rejection.test.ts` — ролью мигратора | Тест зелёный на живой базе |
| Перебазировать ветку на `331e490`, переписать сообщение коммита | Ветка вливается в основную без конфликтов |
## 2. Синхронизация на реальной базе не работает (срочно)
| ID | Что сломано | Где | Готово, когда |
| ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | --------------- |
| SYNC-C0 | Начальная загрузка на устройство (`/sync/pull` без курсора) всегда отвечает 500: `column "status" does not exist`. Запрос обращается к столбцу, которого в таблице нет | `src/domain/sync/service.ts`, начальная загрузка | SYNC-C0 зелёный |
## 3. Синхронизация: остальные дефекты
| Мой ID | Сценарий Grok | Что сделать | Готово, когда |
| ------ | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
| S2 | SYNC-J | `occurred_at` — строгий ISO с часовым поясом. Устройство передаёт своё текущее время, сервер вычисляет расхождение часов и ограничивает время отметки относительно момента получения. При большом расхождении — `pending_review`. Сейчас отметка с датой 2020 года принимается | SYNC-J зелёный |
| S3 | SYNC-G | Сначала захватить ключ идемпотентности, потом выполнять команду. `SAVEPOINT` на каждую команду. Проверять ревизию в UPDATE. `source_event_id = sync:{device}:{key}`. Временные отказы не кэшировать | Битая команда не откатывает весь пакет; две параллельные отправки с одним ключом выполняются один раз |
| S4 | SYNC-E, SYNC-F | Начальная загрузка — только по точке устройства, только активные сотрудники, ограниченное окно дат, пагинация в SQL. Отметки уволенных отклонять. Точку брать из устройства; чужую точку в payload отклонять | SYNC-E и SYNC-F зелёные |
| S5 | SYNC-C, SYNC-D | Курсор не теряет данные при `has_more`. Порядок — по фиксации транзакций, а не по `BIGSERIAL`. Изменения из портала видны устройству. Устаревший курсор запускает повторную начальную загрузку | SYNC-C и SYNC-D зелёные |
| S6 | — | Отметки через синхронизацию пишут outbox (через общий хелпер), как онлайн-маршрут | Отметка через синхронизацию порождает событие |
| S7 | — | Очистка входящих получает `tenant_id`, а не идентификатор системного актора (`service.ts:1056`), и запускается по расписанию | — |
| S8 | SYNC-H | Проверка Origin на маршрутах управления устройствами, белый список scope, `no-store` на ответе с токеном устройства | SYNC-H зелёный |
| S9 | попроси Grok добавить | Внешний ключ `(tenant_id, org_unit_id) ON DELETE SET NULL` обнуляет и `tenant_id`, поэтому удаление точки с привязанным устройством падает с `23502`. Новой миграцией заменить на `SET NULL (org_unit_id)` или `RESTRICT` | Точку можно удалить или получить понятный отказ |
| — | SYNC-I | Без настроенных токенов анонимный запрос не должен регистрировать устройство. Сделать вместе с Codex: он закрывает открытый режим без токенов (F2) | SYNC-I зелёный |
| — | SYNC-K | Подтверждение курсора «из будущего» (больше выданного сервером) отклонять с 400 | SYNC-K зелёный |
## 4. Push-уведомления
| Мой ID | Сценарий Grok | Что сделать | Готово, когда |
| ------ | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------- |
| K12 | N3 | SSRF: сейчас сотрудник подписывает push на `https://127.0.0.1:…`, и сервер сам туда подключается. Разрешать только известные push-сервисы (FCM, Mozilla, Apple, WNS). После DNS запрещать приватные, loopback и link-local адреса. `redirect: 'manual'`, таймаут 5–10 с, тело ответа не сохранять. Где: `push-dispatcher.ts:80`, `web-push.ts:226-258` | N3 зелёный |
| N9 | N9 | Конфликт endpoint с другим сотрудником — ответ 409. Перепривязка разрешена только тем же браузером | N9 зелёный |
| N8 | — | В production без `VAPID_*` сервер не стартует. Публичный ключ отдавать с `no-cache` | — |
| U1–U4 | — | Проверять ключи подписки при регистрации. Ошибка по одной подписке не прерывает отправку остальным. Не больше 5–10 подписок на сотрудника. Таймаут и лимит чтения ответа 1 КБ. Атомарный захват строки перед отправкой (`sending` плюс аренда), повторы с backoff, dead letter | Два экземпляра сервера не отправляют сообщение дважды |
| U5–U7 | — | Scope service worker покрывает `/staff`. Выход отзывает push-подписку устройства. Составной внешний ключ с `tenant_id` в `staff_notification_deliveries` | — |
## 5. Оповещения процесса подтверждения (после этапа 4 у Codex)
Решение владельца: бухгалтер отправляет пакет расчёта и выплат на подтверждение, руководитель получает оповещение, проверяет и подписывает, после чего всё регистрируется. Сам процесс и события делает Codex, от тебя — доставка.
| Событие | Кому | Содержание |
| --------------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------ |
| `staff.payroll.package_submitted` | Всем с ролью «Руководитель» в арендаторе | Период, число сотрудников, итог, число предупреждений, ссылка на экран подписи |
| `staff.payroll.package_returned` | Бухгалтеру, который отправил пакет | Комментарий руководителя |
| `staff.payroll.package_signed` | Бухгалтеру и сотрудникам из пакета | Сотрудникам — только «начисление за период доступно», без сумм |
Основной канал — push. Запасной — Telegram, без персональных данных и сумм. Если пакет не подписан за 24 ч (значение настраивается), отправить повторное напоминание. Адресата определять по роли, а не по фиксированному списку.
Сценарий Grok OWN-03 («каждый руководитель получает ровно одно оповещение») должен стать зелёным.
## 6. Публикатор outbox
Вместо `setImmediate` нужен фоновый публикатор:
- повторяет строки в статусах `pending` и `failed`;
- ставит `published_at`;
- на стороне получателя — идемпотентность через inbox;
- соблюдает политику хранения.
Готово, когда после перезапуска сервера неотправленные события уходят, а `staff_outbox` не растёт бесконечно.
## Порядок
1. Пункт 1, затем пункт 2 — сегодня.
2. Пункты 3 и 4 — параллельно, по одной ветке на пункт.
3. Пункт 5 — когда Codex выпустит события пакета.
4. Пункт 6 — после влития общего хелпера outbox от Codex.
## Отчёт после каждой ветки
Ветка, коммит и ID сценариев Grok, которые стали зелёными, — с фактическим выводом `run-e2e`.

View file

@ -1,105 +0,0 @@
# Задание для Antigravity: синхронизация, уведомления, outbox
- Репозиторий: https://git.ochenstarik.ru/ochenstarik/krona-staff
- Основа: ветка `codex/bootstrap-platform-foundation`, коммит `778ba2d`. Твоя офлайн-синхронизация в неё уже влита (`3595792`); код такой же, как в проверенном коммите `5ee7517`.
- Материалы в репозитории: `docs/audit/2026-09-26/` — `AUDIT.md` и `evidence/`. Для синхронизации есть живые сценарии: `evidence/live-sync.mjs.txt`, вывод — `evidence/live-sync.txt`.
## Контекст
На живой PostgreSQL 17 подтвердилось:
- **Устройство синхронизации создаёт подтверждённые смены.** Оно отправило смену с `status: confirmed` и `worked_minutes: 2880`. Смена сохранилась подтверждённой без участия управляющего и без записи в журнале аудита. Расчёт начислил по ней 14 400 ₽. Это дефект основной ветки.
- **Отметки задним числом проходят.** Устройство сохранило отметку с датой 2020 года.
- **Точку с устройством нельзя удалить.** Удаление падает с `23502`.
- **Push-подписка даёт доступ во внутреннюю сеть.** Сотрудник подписал push на `https://127.0.0.1:…`, после чего сервер сам открыл туда соединение.
## Зона ответственности
**Твоя зона:**
- офлайн-синхронизация: `src/sync.ts`, `src/domain/sync/**`, миграция `0011`, `docs/OFFLINE-SYNC-PROTOCOL.md`;
- уведомления: `src/domain/notification/**`, маршруты `/push/*` в `src/portal.ts`, миграция `0010`, service worker и подписка в `src/portal-ui.ts`;
- диспетчер и публикатор outbox — всё, что читает `staff_outbox` и доставляет события.
**Не трогай:**
- авторизацию, сотрудников, смены, расчёт, импорт, процесс подтверждения, терминал и развёртывание — это Codex;
- `tests/e2e/**`, `tests/integration/tenant-isolation.test.ts`, CI — это Grok.
**Записывать в outbox** только через общий хелпер, который Codex делает в задаче K3. Свою вставку не пиши: сейчас именно из-за разрозненных вставок без `occurred_at` падают подтверждение и расчёт.
## Правила
- Ветки `antigravity/fix-<тема>` от основной, отдельный PR на каждую часть.
- Маленькие коммиты. **В теле каждого коммита объясни причину** (AGENTS.md).
- Перед пушем: `npm run check` и `npm run format:check` зелёные. Миграции проверены вверх и вниз на чистой PostgreSQL 17 мигратором без SUPERUSER.
- Каждое исправление — с тестом на настоящей PostgreSQL, а не только на поддельной базе.
- Пункт считается закрытым, только если соответствующий сценарий из `evidence/live-sync.mjs.txt` или `evidence/live-audit.mjs.txt` больше не воспроизводит дефект. Grok проверяет каждый пункт независимо.
## Часть 0 — немедленная мера (первым PR, сегодня)
- Сервер отклоняет от устройств команды `upsert_work_session` и `upsert_timesheet_entry`: ответ «команда не поддерживается», в базу ничего не пишется. Файл: `src/domain/sync/service.ts:784-990`.
- Отдельный маленький PR. Grok проверяет его первым.
- До закрытия S1 и S2 устройства синхронизации в пилоте не регистрировать.
**Готово, когда:** сценарий S1 из `evidence/live-sync.mjs.txt` больше не создаёт смену.
## Часть 1 — офлайн-синхронизация
| ID | Что сделать | Где | Готово, когда |
| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- |
| S1 (P1) | Устройство отправляет только события отметок, смены и табель считает сервер (reconcile). Если правка с устройства действительно нужна: отдельный scope, только черновые статусы, запрет трогать `confirmed` и `approved`, запись в журнал аудита | `src/domain/sync/service.ts:784-990` | Команда с `status: confirmed` отклоняется; поддельная смена не попадает в расчёт |
| S2 | `occurred_at` принимать только в строгом ISO с зоной. Устройство передаёт своё текущее время, сервер считает расхождение часов и ограничивает время отметки относительно времени получения. При большом расхождении — статус `pending_review` | `service.ts:659, 723, 756` | Отметка с датой 2020 года отклоняется или уходит на проверку |
| S3 | Сначала захватить ключ идемпотентности, потом выполнять команду. `SAVEPOINT` на каждую команду. Проверка ревизии в `UPDATE`. `source_event_id = sync:{device}:{key}`. Временные отказы не кэшировать | `service.ts:672, 702, 774, 832, 936` | Две параллельные отправки с одним ключом выполняются один раз; одна плохая команда не откатывает весь пакет |
| S4 | Начальная загрузка: только точка устройства, только активные сотрудники, ограниченное окно дат, пагинация в SQL | `service.ts:~380-590, 448` | Устройство не получает уволенных и чужие точки |
| S5 | Курсор не теряет данные при `has_more`. Порядок — по фиксации транзакций, а не по `BIGSERIAL`. Изменения из портала видны устройству. Повторная начальная загрузка при устаревшем курсоре, как обещает протокол | `service.ts:343, 595-600` | — |
| S6 | Отметки через синхронизацию пишут outbox так же, как онлайн-маршрут. Проверять `status = 'active'` сотрудника. Сохранять `org_unit_id` | `service.ts:720-780` | — |
| S9 | Внешний ключ `(tenant_id, org_unit_id) ON DELETE SET NULL` обнуляет и `tenant_id`, поэтому удаление точки падает. Заменить на `SET NULL (org_unit_id)` или `RESTRICT` новой миграцией | `migrations/0011_offline_sync_backend.up.sql:21` | Точку с устройством можно удалить или получить понятный отказ |
| S7 | Очистка входящих должна получать `tenant_id`, а не идентификатор системного актора. Запускать её по расписанию | `service.ts:1056` | — |
| S8 | Проверка Origin на маршрутах управления устройствами. Белый список scope. `no-store` на ответе с токеном устройства | `src/sync.ts:74`, `service.ts:190` | — |
## Часть 2 — уведомления
| ID | Что сделать | Где | Готово, когда |
| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
| K12 (N3, P1) | Разрешать только известные push-сервисы: FCM, Mozilla, Apple, WNS. После разрешения DNS запрещать приватные, loopback и link-local адреса. `redirect: 'manual'`, таймаут 5–10 с, тело ответа не сохранять | `src/domain/notification/push-dispatcher.ts:80`, `web-push.ts:226-258` | Подписка на `https://127.0.0.1:…` отклоняется; сценарий N3 не даёт входящего соединения |
| N9 | Конфликт endpoint с другим сотрудником → 409. Перепривязка — только тем же браузером | `push-dispatcher.ts:100-111` | Сценарий N9 отвечает 409 |
| N8 | В production без `VAPID_*` сервер не стартует. Публичный ключ отдавать с `no-cache` | `web-push.ts:80-87`, `src/portal.ts:121-123` | — |
| U1 | Проверять ключи подписки при регистрации. Ошибки отправки обрабатывать отдельно для каждой подписки | `web-push.ts:230`, `push-dispatcher.ts:283` | Неверная подписка не мешает остальным устройствам сотрудника |
| U2 | Не больше 5–10 подписок на сотрудника, таймаут, чтение тела ответа не больше 1 КБ, ограничение параллельности | `web-push.ts:235-258` | — |
| U3, U4 | Атомарный захват строки перед отправкой: статус `sending` плюс аренда. Повторы с backoff и учётом `Retry-After`. Отдельный статус dead letter. Срок хранения `last_error` | `push-dispatcher.ts:233-367` | При двух экземплярах сервера сообщение уходит один раз |
| U5, U6 | Scope service worker должен покрывать `/staff`. Выход из кабинета отзывает push-подписку устройства | `src/portal-ui.ts` (регистрация service worker и выход) | — |
| U7 | Составной внешний ключ с `tenant_id` в `staff_notification_deliveries` | `migrations/0010…up.sql:44` (новой миграцией) | — |
## Часть 3 — оповещения процесса подтверждения
Решение владельца: бухгалтер отправляет пакет с расчётом и выплатами на подтверждение, руководитель получает оповещение, проверяет и подписывает, после чего всё регистрируется. Процесс, подпись и события в outbox делает Codex (его этап 4). От тебя — доставка.
| Событие | Кому | Что в оповещении |
| --------------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `staff.payroll.package_submitted` | Всем с ролью «Руководитель» в арендаторе | Период, число сотрудников, итог, число предупреждений; ссылка сразу на экран подписи |
| `staff.payroll.package_returned` | Бухгалтеру, отправившему пакет | Комментарий руководителя |
| `staff.payroll.package_signed` | Бухгалтеру; сотрудникам из пакета | Сотрудникам — только «начисление за период доступно», без сумм в тексте; суммы видны лишь в приложении |
- Основной канал — push в приложении. Если push не доставлен (нет подписки или ошибка), руководителю уходит сообщение в Telegram — без персональных данных и сумм.
- Если пакет не подписан за настраиваемое время (по умолчанию 24 ч), руководителю приходит повторное напоминание.
- Адресат определяется по роли и арендатору, а не по фиксированному списку: при смене руководителя оповещения получает новый.
**Готово, когда** на живом стенде отправка пакета даёт ровно одно оповещение каждому руководителю, а перезапуск сервера его не дублирует. Сотрудник получает уведомление без сумм и только после подписи.
## Часть 4 — публикатор outbox
| ID | Что сделать | Где | Готово, когда |
| ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| F6, D7 | Заменить `setImmediate` фоновым публикатором. Он повторяет строки в `pending` и `failed`, ставит `published_at`, обеспечивает идемпотентность у получателя через inbox и соблюдает политику хранения. Альтернатива — убрать из `module.json` события, которые никто не публикует | `src/portal.ts:830-842`, `push-dispatcher.ts:333-367`, `module.json` | После перезапуска сервера неотправленные события уходят; `staff_outbox` не растёт бесконечно |
## Порядок работы с остальными
1. **Сразу:** часть 0.
2. **Параллельно с этапами 1–3 у Codex:** части 1 и 2. Часть S6 делай после того, как Codex сообщит, что влит общий хелпер outbox (K3).
3. **После этапа 4 у Codex** (события пакета): часть 3.
4. **Часть 4** — в любой момент после K3.
## Отчёт после каждой части
Таблица «ID — что сделано — коммит — тест» и список того, что не удалось, с причиной.

View file

@ -1,219 +0,0 @@
# Задание для 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 — что сделано — коммит — тест» и список того, что не удалось, с причиной.

View file

@ -1,93 +0,0 @@
# Задание для Grok, круг 2: Windows, сопоставление ID, недостающие сценарии, приёмка
- Репозиторий: https://git.ochenstarik.ru/ochenstarik/krona-staff
- Основа: текущая основная ветка `codex/bootstrap-platform-foundation` (`331e490`) и твоя ветка `grok/e2e-harness` (`421da72`)
- Полный аудит владельца теперь в репозитории: `docs/audit/2026-09-26/AUDIT.md`; таблица соответствия ID ниже дублирует главное.
## Итог приёмки прошлого круга
Ветку `grok/e2e-harness` проверили на живой PostgreSQL 17.10. Вердикт: **принять после правок из пункта 1**.
**Что сделано хорошо:**
- Зона соблюдена: код продукта не тронут.
- Все коммиты с объяснением причины.
- Прогон дал ровно те же 38 красных и 9 зелёных сценариев, что в твоём отчёте.
- Логика `known-defects.json` верная.
- SYNC-C0 — настоящая находка, которой не было в аудите владельца: начальная загрузка синхронизации на реальной базе отвечает 500 (`column "status" does not exist`).
- Перекрёстная проверка: исправление Antigravity (`antigravity/fix-reject-device-shifts`) на твоих сценариях переводит SYNC-A и SYNC-B в зелёные.
**Что не так:**
1. **Под Windows стенд не запускается** (проект разрабатывается под Windows). Все три места падают с `ENOENT`, потому что в Windows это `.cmd`:
- `scripts/test/run-e2e.mjs:35` — `spawn('npx', …)`;
- `tests/e2e/harness/global-setup.ts:67` — `spawn(node_modules/.bin/tsx, …)`;
- `scripts/test/n13-race.mjs:102` — то же, что в предыдущем пункте.
2. **ID аудита владельца не сопоставлены** — таблица ниже.
3. **`tests/e2e/evidence/` коммитится в репозиторий** и будет меняться при каждом прогоне.
## Правила — как раньше
- Ветка `grok/e2e-harness` (дописывай в неё), маленькие коммиты с причиной в теле.
- Твоя зона: `tests/e2e/**`, `tests/integration/**` (только очистка и стенд), `.forgejo/workflows/ci.yml`, `scripts/test/**`.
- `src/`, `apps/`, `migrations/`, `deploy/` не трогать. Нужна правка продукта — пиши в `tests/e2e/NOTES-FOR-EXECUTORS.md`.
## 1. Правки, без которых ветку не влить (сегодня)
| Что сделать | Готово, когда |
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
| Запуск без `.cmd`-обёрток: `spawn(process.execPath, [<путь к tsx/dist/cli.mjs>, 'src/server.ts'])`, vitest — через `process.execPath` и его `vitest.mjs`, либо `shell: true` на `win32`. Добавить в CI проверку запуска на Windows, если есть раннер; если нет — записать это в REPORT | `node scripts/test/run-e2e.mjs` работает на Windows 11 с Node 22+ без правок |
| `tests/e2e/evidence/` убрать из git и добавить в `.gitignore`. Результаты прогона — артефакт CI. В репозитории оставить `REPORT.md` и таблицу | Повторный прогон не меняет отслеживаемые файлы |
| Перебазировать ветку на `331e490` | Вливается без конфликтов |
## 2. Сопоставление с ID аудита владельца
Переименуй теги в названиях сценариев в формат `[K3/N2]`, а ключи в `known-defects.json` — в двойные.
| ID владельца | Твой сценарий | Суть |
| -------------------------- | -------------- | ----------------------------------------------------------------------------------- |
| K1 | F2 | Без токенов анонимный запрос получает права |
| K3 | N2 | Вставка в outbox без `occurred_at`: подтверждение, расчёт, утверждение падают с 500 |
| K4 | N1 | `POST /employees` падает с 500 (`42P08`) |
| K5 | N4 | Пересекающиеся расчёты начисляют смену повторно |
| K10 | F3 | Сырой токен в cookie, выход не отзывает сессию |
| K11 | F4 | Нет ограничения частоты |
| K12 | N3 | SSRF через push-подписку |
| K13 (S1) | SYNC-A, SYNC-B | Устройство создаёт подтверждённые смены и табель |
| S2 | SYNC-J | Отметка задним числом через синхронизацию |
| S3 | SYNC-G | Одна битая команда откатывает пакет |
| S4 | SYNC-E, SYNC-F | Лишние данные на устройство, отметки уволенных, чужая точка |
| S5 | SYNC-C, SYNC-D | Курсор теряет изменения |
| S8 | SYNC-H | CSRF на управлении устройствами |
| K19 (D4) | OWN-07 | Подпись устаревшего пакета |
| D6 | OWN-01, OWN-02 | Управляющий вне своих точек |
| K17 | OWN-10, OWN-11 | Согласие на биометрию |
| N9, N10, N11, N12, F7, F10 | те же | — |
## 3. Недостающие сценарии
Все проверяют правильное поведение. Сейчас они красные — внеси их в `known-defects.json`.
| ID владельца | Сценарий | Ожидаемое поведение |
| ------------ | ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| K6 (D5) | Подтверждённую смену включают в расчёт, руководитель подписывает, затем смену отклоняют | 409 или создаётся сторнирующая запись. Сейчас: 200, сторно нет, начисление остаётся |
| K7 (D2) | Импорт книги: начисления за один день на двух точках, затем повторный импорт с изменённой суммой | Итог в реестре равен сумме по книге. Сейчас теряется: вторая точка пишется как отрицательная корректировка, изменение суммы пропадает |
| K8 (D1) | Расчёт подписан, затем меняется правило оплаты или ставка, затем подписанную ведомость пересчитывают для проверки | Сумма та же, что в подписанной ведомости |
| K9 (D3) | Сотрудник с пресетом «официант» (оклад смены, процент от личных продаж, допродажи, премия за средний чек) | Начисление по параметрам пресета. Сейчас 0 ₽: сохраняются только ставка, процент и гарантия |
| S9 | Удаление точки, к которой привязано устройство синхронизации | Точка удалена или получен понятный отказ. Сейчас 500, `23502 null value in column "tenant_id"` |
| A4 | Любой ответ 500 | Без кода и текста ошибки PostgreSQL в теле. Сейчас наружу уходят, например, `42P08` и `23502` с текстом |
| N12-дата | Расчёт с `month = 2026-13` | 400. Сейчас 500 |
## 4. Приёмка
1. **Ветка Antigravity `antigravity/fix-reject-device-shifts`.** После того как Antigravity исправит очистку в `sync-rejection.test.ts` и ветку вольют, убери SYNC-A и SYNC-B из `known-defects.json` отдельным коммитом.
2. **Ветки Codex `codex/fix-format-baseline` и `codex/fix-live-database`** (этапы 0 и 1: форматирование, K3, K4). Прогони стенд на каждой и дай таблицу «ID — было — стало — доказательство». Если K3 и K4 зелёные, убери их из списка известных дефектов после влития.
3. **Дальше** — каждая новая ветка Codex и Antigravity, по той же схеме. На слово ничего не принимать.
## Порядок
Пункт 1 → пункт 2 → пункт 3. Приёмку из пункта 4 веди параллельно, по мере появления веток.
## Отчёт
Обновлённый `tests/e2e/REPORT.md`: что изменилось, таблица «ID владельца / твой ID — цвет — фактический вывод», приёмочные таблицы по веткам исполнителей.

View file

@ -1,84 +0,0 @@
# Задание для Grok: тестовый контур на живой базе и независимая приёмка
- Репозиторий: https://git.ochenstarik.ru/ochenstarik/krona-staff
- Основа: ветка `codex/bootstrap-platform-foundation`, коммит `778ba2d`
- Материалы в репозитории: `docs/audit/2026-09-26/` — `AUDIT.md` и `evidence/` со сценариями живой проверки и их выводом.
## Контекст
`AUDIT.md` сверен с твоим аудитом `2026-09-26_full-project-audit_grok_3e20544.md`. Итог сверки:
- 22 находки подтверждены;
- 4 подтверждены частично (F8, F11, N7, N10);
- 1 уже исправлена (F5);
- 1 не воспроизвелась (N13).
После твоего аудита в основную ветку влиты офлайн-синхронизация и новое оформление портала. Находки по синхронизации (S1–S9) в `AUDIT.md` уже учтены.
Главный урок: 156 зелёных тестов на поддельной базе не заметили, что создание сотрудника, подтверждение смены и расчёт зарплаты на реальной PostgreSQL всегда падают.
## Твоя роль
Продукт ты не исправляешь — это делают Codex и Antigravity.
От тебя нужно две вещи:
- построить тестовый контур, который ловит дефекты на настоящей базе;
- независимо принимать каждое исправление.
## Зона ответственности
**Твоя зона:**
- `tests/e2e/**` — новый набор сквозных сценариев: живой сервер на живой PostgreSQL;
- `tests/integration/tenant-isolation.test.ts`;
- `.forgejo/workflows/ci.yml` и вспомогательные скрипты для тестов.
**Не трогай:** `src/`, `apps/`, `migrations/`, `deploy/`. Это зоны Codex и Antigravity. Если тесту нужна правка продукта, оставь замечание исполнителю.
## Правила
- Ветка `grok/e2e-harness`, маленькие коммиты. В теле каждого коммита объясни причину (AGENTS.md).
- Токены и ключи в тестах генерируй случайно. Реальные ФИО и суммы не используй.
- К каждому выводу прикладывай команду и фактический вывод. То, что не удалось проверить, помечай «не проверено» явно, как ты уже делаешь.
## Задачи
| № | Что сделать | Готово, когда |
| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| 1 | **N5.** В тесте изоляции арендаторов очистка идёт ролью приложения, а у неё нет права DELETE на сотрудников. Перенеси очистку на роль мигратора или оберни в транзакцию с откатом | Тест проходит на живой базе; CI с `DATABASE_TEST_URL` из-за него больше не красный |
| 2 | **Набор `tests/e2e`.** Поднять сервер на реальной PostgreSQL 17 и прогнать сценарии по всем находкам P0 и P1. За основу взять `evidence/live-audit.mjs.txt` и `evidence/live-sync.mjs.txt`. Каждый сценарий проверяет правильное поведение, поэтому сейчас он красный. Список красных сценариев и есть список открытых дефектов | Есть сценарии для K1, K3, K4, K5, K6, K10, K11, K12, S1, S2, S9, N9, N12 и A4; отчёт «зелёный / красный» по каждому ID |
| 3 | **Сценарии по решениям владельца (роли, точки, процесс подтверждения, согласие).** Подробности — в таблице ниже | Сценарии есть и пока красные; это ожидаемо |
| 4 | **CI.** Сервис PostgreSQL; роли как в пилоте, но мигратор **без** SUPERUSER. Миграции вверх, откат всех, снова вверх. Запуск `tests/e2e`. `docker build` перенести туда, где есть docker, либо вынести в отдельную задачу. Закрепить действия по SHA (F13) | CI реально проверяет схему и маршруты на живой базе |
| 5 | **Проверка пилотного compose** тестом, как сейчас проверяется корневой: переменные с `:?`, нет `NODE_ENV=development`, у приложения нет учётных данных мигратора | — |
| 6 | **Приёмка.** Первым проверь немедленную меру Antigravity по S1: устройство больше не создаёт и не меняет смену. Дальше после каждого PR от Codex и Antigravity прогоняй `tests/e2e` и целевые сценарии. На слово ничего не принимай | По каждому закрытому ID есть независимое подтверждение: таблица «ID — было — стало — доказательство» |
| 7 | **Повторить N13** (гонка первого расчёта) под нагрузкой: 10–50 параллельных запросов. У меня на двух запросах не воспроизвелось | Вердикт с числом попыток |
| 8 | **Snyk** — только если владелец выдаст токен. Без токена не запускать | — |
Сценарии для задачи 3:
| Сценарий | Ожидаемый результат |
| --------------------------------------------- | -------------------------------------------------------------------- |
| Управляющий точки А запускает расчёт точки Б | 403 |
| Управляющий отправляет пакет на подтверждение | 403 |
| Бухгалтер отправляет пакет | успешно; оповещение каждому руководителю ровно одно |
| Бухгалтер пытается подписать пакет | 403 |
| Реестр до подписи руководителя | начислений и выплат нет |
| Руководитель подписывает | начисления и выплаты зарегистрированы; пакет и его смены неизменяемы |
| Изменение смены после отправки | подписать пакет нельзя, он возвращается бухгалтеру |
| Возврат с комментарием | пакет у бухгалтера, комментарий сохранён |
| Сотрудник точки А отработал смену на точке Б | смена попадает в расчёт точки Б |
| Сотрудник без согласия на биометрию | сервер отвечает терминалу «регистрация запрещена» |
| Отзыв согласия | статус для терминала меняется |
Проверки, которым нужна Windows (DPAPI, ACL каталога данных, установщик, WinUI, реальный pipe), в твоём окружении недоступны. Отмечай их «не проверено» и ссылайся на тесты Codex.
## Порядок работы с остальными
1. **Сразу:** задачи 1, 2, 4 и 5. Это не зависит от других исполнителей.
2. **Задача 3** — сразу. Сценарии будут красными, пока Codex не сделает этап 4.
3. **Задача 6** — по мере поступления PR. Первым — немедленная мера Antigravity по S1, затем этап 1 Codex (K3, K4).
## Отчёт
Первый PR — после задач 1, 2, 4 и 5. В нём набор сценариев и краткий отчёт: какие сценарии красные. Дальше — приёмочная таблица после каждого PR исполнителей.

View file

@ -1,17 +0,0 @@
# Доказательства аудита 2026-09-26
| Файл | Что это |
| ---------------------- | ---------------------------------------------------------------------------------------------------------- |
| `live-audit.mjs.txt` | Сценарии живой проверки основной ветки `3e20544`: сервер на реальной PostgreSQL 17, настоящие HTTP-запросы |
| `live-audit-final.txt` | Вывод последнего прогона на базе в UTF-8 |
| `live-sync.mjs.txt` | Сценарии для ветки синхронизации `5ee7517` |
| `live-sync.txt` | Вывод прогона |
| `format-check.txt` | Вывод `npm run format:check` |
Сценарии сохранены с расширением `.mjs.txt`, чтобы их не проверял линтер проекта. Для запуска скопируйте файл вне репозитория и уберите `.txt`. Поддерживаемая рабочая версия этих проверок — стенд Grok в `tests/e2e/`.
Как запускались: рабочая копия нужного коммита (`git worktree add --detach … <commit>`), `npm ci`, PostgreSQL 17 на порту 55432 с суперпользователем `postgres`, мигратор `krona_migrator` без SUPERUSER (владелец базы), роль приложения `krona_staff_app`, база в UTF-8, миграции `node scripts/migrate.mjs up`. Пути к рабочим копиям в начале скриптов (`REPO`) — локальные, их нужно поправить под своё окружение.
Токены в сценариях генерируются случайно при каждом запуске и никуда не выводятся. Пароли ролей — одноразовые для локального стенда.
Для сценариев N4, D5 и S1 скрипт временно задаёт значение по умолчанию для `staff_outbox.occurred_at` (иначе мешает дефект N2) и снимает его после сценария. После исправления N2 эти строки нужно удалить.

View file

@ -1,6 +0,0 @@
[S9] ПОДТВЕРЖДЕНО — удаление точки с устройством → 23502 null value in column "tenant_id" of relation "staff_sync_devices" violates not-null constraint
device registration → 201; scopes ["attendance:sync"]
device push → 200 {"results":[{"idempotency_key":"forge-1790409261814","command_type":"upsert_work_session","status":"rejected","error_code":"COMMAND_NOT_SUPPORTED","error_message":"Команда не поддерживается: прямое создание или изменение смен и табелей с устройств запрещено"},…
[S1] не подтверждено — смена от устройства: статус undefined, минут undefined, подтвердил никто, записей в журнале аудита арендатора: 0
[S2] ПОДТВЕРЖДЕНО — отметка с временем 2020-01-01 через синхронизацию сохранена: true
[S1→payroll] расчёт сентября → 200; начислено по поддельной смене: 0 ₽ (48 ч × 300 ₽)

View file

@ -1,40 +0,0 @@
× [N1] менеджер создаёт сотрудника через POST /employees → 201 487ms
× [N2] подтверждение смены, расчёт и утверждение зарплаты проходят (200) и начисляют 8 ч × ставка 1795ms
× [N4] пересекающиеся расчёты не начисляют одну смену дважды 943ms
× [N3] push-подписка на внутренний адрес отклоняется, сервер не подключается к 127.0.0.1 2834ms
× [N9] чужой push-endpoint нельзя перепривязать к другому сотруднику 421ms
× [N12] PUT pay-assignment отвергает лишние поля и нереальные суммы (400) 386ms
× [N10] терминальный токен не читает справочники профессий и точек (403) 31ms
× [F2] standalone без токенов не выдаёт права менеджера анонимному запросу (401) 322ms
× [F3] cookie сессии не содержит сырой токен и отзывается при выходе 50ms
× [F10] мутация по cookie без Origin отклоняется (CSRF, 403) 21ms
× [F7] повтор Idempotency-Key с другим телом не создаёт второе событие 790ms
× [N11] импорт с неверным kind начисления → 400, а не 500 602ms
× [F4] перебор ключей входа ограничивается (429 не позже 30 неверных попыток) 744ms
× [OWN-01] менеджер точки A запускает расчёт для точки B → 403 978ms
× [OWN-02] менеджер точки отправляет пакет на согласование → 403 750ms
× [OWN-03] бухгалтер отправляет пакет → успех, каждый директор получает ровно одно уведомление 10ms
× [OWN-04] бухгалтер пытается подписать пакет → 403 20ms
× [OWN-05] до подписи директора в ledger нет начислений и выплат 5ms
× [OWN-06] подпись директора → начисления и выплаты в ledger, пакет и смены неизменяемы 10ms
× [OWN-07] смена изменена после отправки → подписать нельзя (409), пакет возвращён бухгалтеру 5ms
× [OWN-08] возврат с комментарием → пакет у бухгалтера, комментарий сохранён 10ms
× [OWN-09] сотрудник точки A отработал смену в точке B → смена попадает в зарплату точки B 16ms
× [OWN-10] сотрудник без согласия на биометрию → сервер сообщает терминалу «регистрация запрещена» 250ms
× [OWN-11] согласие отозвано → статус для терминала меняется 297ms
× [SYNC-A] устройство не может само подтвердить смену (status=confirmed отклоняется) 791ms
× [SYNC-B] устройство не может утвердить табель (status=approved отклоняется) 392ms
× [SYNC-C0] начальная загрузка (pull без курсора) работает на реальной БД (200) 427ms
× [SYNC-C] начальная загрузка >100 сущностей отдаёт все сущности постранично 960ms
× [SYNC-D] изменения, сделанные через портал, попадают в инкрементальный pull 518ms
× [SYNC-E] отметка уволенного сотрудника через sync отклоняется 279ms
× [SYNC-F] отметка через sync сохраняет точку устройства; чужая точка в payload отклоняется 664ms
× [SYNC-G] битая команда в пакете отклоняется по отдельности, остальные применяются (не 500) 350ms
× [SYNC-H] регистрация устройства по cookie с чужим Origin отклоняется (CSRF, 403) 180ms
× [SYNC-I] без настроенных токенов анонимный запрос не регистрирует устройство (401) 180ms
× [SYNC-J] отметка через sync вне окна времени (2001 год) отклоняется, как в POST /attendance/events 308ms
× [SYNC-K] ack курсора из будущего (больше выданного сервером) отклоняется (400) 25ms
× [PILOT-1] все подставляемые переменные обязательны: ${VAR:?сообщение} 103ms
× [PILOT-2] ни один сервис не запускается с NODE_ENV=development 4ms
⎯⎯⎯⎯⎯⎯ Failed Tests 38 ⎯⎯⎯⎯⎯⎯⎯
Tests 38 failed | 9 passed (47)

View file

@ -1,12 +0,0 @@
× [SYNC-C0] начальная загрузка (pull без курсора) работает на реальной БД (200) 466ms
× [SYNC-C] начальная загрузка >100 сущностей отдаёт все сущности постранично 624ms
× [SYNC-D] изменения, сделанные через портал, попадают в инкрементальный pull 372ms
× [SYNC-E] отметка уволенного сотрудника через sync отклоняется 223ms
× [SYNC-F] отметка через sync сохраняет точку устройства; чужая точка в payload отклоняется 477ms
× [SYNC-G] битая команда в пакете отклоняется по отдельности, остальные применяются (не 500) 213ms
× [SYNC-H] регистрация устройства по cookie с чужим Origin отклоняется (CSRF, 403) 136ms
× [SYNC-I] без настроенных токенов анонимный запрос не регистрирует устройство (401) 33ms
× [SYNC-J] отметка через sync вне окна времени (2001 год) отклоняется, как в POST /attendance/events 283ms
× [SYNC-K] ack курсора из будущего (больше выданного сервером) отклоняется (400) 27ms
⎯⎯⎯⎯⎯⎯ Failed Tests 10 ⎯⎯⎯⎯⎯⎯⎯
Tests 10 failed | 3 passed (13)

View file

@ -1,18 +0,0 @@
Checking formatting...
[warn] apps/KronaStaffService/README.md
[warn] deploy/pilot/compose.yaml
[warn] docs/DRIVER-DISTRIBUTION.md
[warn] docs/HARDWARE-TEST-SLK20R.md
[warn] docs/TERMINAL-API-REQUIREMENTS.md
[warn] scripts/check-migrations.mjs
[warn] src/adapters/identity.ts
[warn] src/app.ts
[warn] src/config.ts
[warn] src/domain/attendance/work-session.ts
[warn] src/domain/notification/web-push.ts
[warn] src/portal.ts
[warn] tests/unit/config.test.ts
[warn] tests/unit/employee-push.test.ts
[warn] tests/unit/tenant-sql.test.ts
[warn] tests/unit/work-session.test.ts
[warn] Code style issues found in 16 files. Run Prettier with --write to fix.

View file

@ -1,16 +0,0 @@
[F2] ПОДТВЕРЖДЕНО — STANDALONE_ACCESS=manager, без токенов и без Authorization: GET /portal 200, GET /payroll 200, GET /finance 200, POST /professions 201; POST /session 403
[F2] ПОДТВЕРЖДЕНО — STANDALONE_ACCESS=employee, без токенов и без Authorization: GET /portal 200, GET /payroll 200, GET /finance 403, POST /professions 403; POST /session 403
[F4] ПОДТВЕРЖДЕНО — 60 неверных POST /session → {"401":60}
[F3] ПОДТВЕРЖДЕНО — вход 200, выход 204; cookie содержит сам токен менеджера: true; после DELETE /session старая cookie → GET /portal 200; флаги: Path=/; Max-Age=28800; HttpOnly; Secure; SameSite=Strict
[N1] ПОДТВЕРЖДЕНО — POST /employees → 500 {"statusCode":500,"code":"42P08","error":"Internal Server Error","message":"inconsistent types deduced for parameter $2"}
[N2] ПОДТВЕРЖДЕНО — PATCH /work-sessions (confirmed) → 500 {"statusCode":500,"code":"23502","error":"Internal Server Error","message":"null value in column \"occurred_at\" of relation \"staff_outbox\" violates not-null constraint"}; GET /pay-rules → 200 (6 правил, выбрано hourly); PUT pay-assignment → 200; POST /payroll/calculate → 500 {"statusCode":500,"code":"23502","error":"Internal Server Error","message":"null value in column \"occurred_at\" of relation \"staff_outbox\" violates not-null constraint"}
[N4] ПОДТВЕРЖДЕНО — повторное подтверждение смены → 200; {"month":"2026-09"}: calculate 200, approve 200; {"from":"2026-09-02","to":"2026-09-30"}: calculate 200, approve 200; {"month":"2026-09","org_unit_id":"01M3E6YN08K537W6JNRANDR3X3"}: calculate 200, approve 200; в реестре начислений за одну смену 8 ч × 300 ₽: записей 3, сумма 7200 ₽
[D5] ПОДТВЕРЖДЕНО — отклонение смены, уже начисленной в утверждённой ведомости → 200; записей в реестре после этого: 3 (сторно не создано)
[N13] не воспроизведено — два одновременных первых расчёта октября → 200 {"run_id":"01M3E6YXA273MH6XX04CSAQJK9","status":"calculated","org_unit_id":null,"period_st | 200 {"run_id":"01M3E6YXA273MH6XX04CSAQJK9","status":"calculated","org_unit_id":null,"period_st
[D9] проверка — month=2026-13 → 500; rate_minor=1e20 и лишнее поле → 200 {"id":"01M3E6YXHHCBJPWMF6VT7MYZGY","employee_id":"01M3E6YN08XJM58HP60FWVJSJ5","org_unit_id":"01M3E6YN08K537W6JNRANDR3X3"
[N3] ПОДТВЕРЖДЕНО — выдача ключа сотруднику → 200; подписка на https://127.0.0.1:18443 → 201; отметка терминала → 201; входящих соединений на внутренний адрес: 1, TLS ClientHello: true
[N9] ПОДТВЕРЖДЕНО — сотрудник Б подписывает endpoint сотрудника А → 201; подписка теперь принадлежит сотруднику Б: true
[A4] проверка — 500 с деталями: POST /employees с несуществующей профессией → 500 {"statusCode":500,"code":"42P08","error":"Internal Server Error","message":"inconsistent types deduced for parameter $2"}
[N10] проверка — терминал: GET /professions → 200 ({"professions":[{"id":"01M3E6YEJRAZQ9ZRQM1XZXWJ8T","code":"profession-01m3e6yejrazq9zrqm1xzxwj8t","name":"audit-open-manager-1790404672076","status":"active","created_at":"2026-09-26T06:37:52.089Z","updated_at":"2026-09-…); GET /org-units → 200 {"org_units":[]}
[HDR] проверка — GET /staff: content-security-policy="default-src 'self';base-uri 'self';font-src 'self' https: data:;form-action 'self';frame-ancestors 'self';img-src 'self' data:;object-src 'none';script-src 'self';script-src-attr 'none';style-src 'self' https: 'unsafe-inline';upgrade-insecure-requests", strict-transport-security="max-age=31536000; includeSubDomains"

View file

@ -1,426 +0,0 @@
// Live audit of krona-staff against a real PostgreSQL 17. Read-only for the repository:
// data is seeded in a throwaway local database, tokens are random and never printed.
import { createRequire } from 'node:module';
import { spawn } from 'node:child_process';
import { randomBytes, createECDH } from 'node:crypto';
import net from 'node:net';
const REPO = 'C:/Users/Ochenstarik/Agent_projects/krona-staff-audit';
const require = createRequire(`${REPO}/package.json`);
const pg = require('pg');
const { ulid } = require('ulid');
const SUPER = 'postgres://postgres:audit-superuser@127.0.0.1:55432/krona_staff';
const APP = 'postgres://krona_staff_app:audit-app-password@127.0.0.1:55432/krona_staff';
const PORT = 18080;
const BASE = `http://127.0.0.1:${PORT}/api/v1/staff`;
const TENANT = ulid();
const ACTOR = ulid();
const TERMINAL_ACTOR = ulid();
const MANAGER_TOKEN = randomBytes(32).toString('base64url');
const TERMINAL_TOKEN = randomBytes(32).toString('base64url');
const results = [];
function record(id, verdict, detail) {
results.push({ id, verdict, detail });
console.log(`[${id}] ${verdict} — ${detail}`);
}
async function superQuery(sql, params = []) {
const client = new pg.Client({ connectionString: SUPER });
await client.connect();
try {
return await client.query(sql, params);
} finally {
await client.end();
}
}
function startServer(extraEnv) {
const env = {
...process.env,
NODE_ENV: 'development',
HOST: '127.0.0.1',
PORT: String(PORT),
LOG_LEVEL: 'silent',
STAFF_RUNTIME_MODE: 'standalone',
DATABASE_URL: APP,
STANDALONE_TENANT_ID: TENANT,
STANDALONE_ACTOR_ID: ACTOR,
STANDALONE_TERMINAL_ACTOR_ID: TERMINAL_ACTOR,
...extraEnv,
};
for (const [key, value] of Object.entries(env)) if (value === undefined) delete env[key];
const child = spawn(process.execPath, ['node_modules/tsx/dist/cli.mjs', 'src/server.ts'], {
cwd: REPO,
env,
stdio: ['ignore', 'ignore', 'pipe'],
});
let stderr = '';
child.stderr.on('data', (chunk) => (stderr += chunk));
child.stderrText = () => stderr;
return child;
}
async function waitReady() {
for (let attempt = 0; attempt < 120; attempt += 1) {
try {
const response = await fetch(`${BASE}/live`);
if (response.ok) return;
} catch {}
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw new Error('server did not start');
}
async function stop(child) {
child.kill();
await new Promise((resolve) => setTimeout(resolve, 800));
}
async function call(method, path, { token, body, headers = {} } = {}) {
const response = await fetch(`${BASE}${path}`, {
method,
headers: {
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
...(token ? { authorization: `Bearer ${token}` } : {}),
...headers,
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await response.text();
let json;
try {
json = JSON.parse(text);
} catch {
json = text;
}
return { status: response.status, json, headers: response.headers };
}
function brief(value) {
const text = typeof value === 'string' ? value : JSON.stringify(value);
return text.length > 220 ? `${text.slice(0, 220)}…` : text;
}
// ---------------------------------------------------------------- scenario A
async function scenarioOpenMode(access) {
const server = startServer({ STANDALONE_ACCESS: access });
try {
await waitReady();
const portal = await call('GET', '/portal');
const payroll = await call('GET', '/payroll');
const finance = await call('GET', '/finance');
const writeProfession = await call('POST', '/professions', {
body: { name: `audit-open-${access}-${Date.now()}` },
});
const session = await call('POST', '/session', {
body: { access_token: 'x'.repeat(40) },
headers: { origin: `http://127.0.0.1:${PORT}` },
});
record(
'F2',
portal.status === 200 || payroll.status === 200 ? 'ПОДТВЕРЖДЕНО' : 'не подтверждено',
`STANDALONE_ACCESS=${access}, без токенов и без Authorization: GET /portal ${portal.status}, GET /payroll ${payroll.status}, GET /finance ${finance.status}, POST /professions ${writeProfession.status}; POST /session ${session.status}`,
);
} finally {
await stop(server);
}
}
// ---------------------------------------------------------------- scenario B
async function seed() {
const orgUnit = ulid();
const employee = ulid();
const pushEmployee = ulid();
const session = ulid();
await superQuery(
`INSERT INTO staff_org_units (id, tenant_id, code, name) VALUES ($1, $2, 'audit-unit', 'Аудиторская точка')`,
[orgUnit, TENANT],
);
await superQuery(
`INSERT INTO staff_employees (id, tenant_id, personnel_number, display_name, status)
VALUES ($1, $3, 'A-001', 'Аудит Сотрудник', 'active'), ($2, $3, 'A-002', 'Аудит Push', 'active')`,
[employee, pushEmployee, TENANT],
);
await superQuery(
`INSERT INTO staff_work_sessions
(id, tenant_id, employee_id, org_unit_id, business_date, started_at, ended_at, worked_minutes, status)
VALUES ($1, $2, $3, $4, '2026-09-10', '2026-09-10T02:00:00Z', '2026-09-10T10:00:00Z', 480, 'pending_review')`,
[session, TENANT, employee, orgUnit],
);
return { orgUnit, employee, pushEmployee, session };
}
async function scenarioWithTokens() {
const ids = await seed();
const server = startServer({
STANDALONE_ACCESS: 'employee',
STANDALONE_MANAGER_TOKEN: MANAGER_TOKEN,
STANDALONE_TERMINAL_TOKEN: TERMINAL_TOKEN,
});
try {
await waitReady();
const origin = { origin: `http://127.0.0.1:${PORT}` };
// F4: brute force on /session
const statuses = {};
for (let index = 0; index < 60; index += 1) {
const response = await call('POST', '/session', {
body: { access_token: randomBytes(32).toString('base64url') },
});
statuses[response.status] = (statuses[response.status] ?? 0) + 1;
}
record(
'F4',
statuses[429] ? 'не подтверждено' : 'ПОДТВЕРЖДЕНО',
`60 неверных POST /session → ${JSON.stringify(statuses)}`,
);
// F3: cookie carries the raw token and survives logout
const login = await call('POST', '/session', { body: { access_token: MANAGER_TOKEN } });
const setCookie = login.headers.get('set-cookie') ?? '';
const cookieValue = decodeURIComponent(setCookie.split(';')[0].split('=').slice(1).join('='));
const cookieIsToken = cookieValue === MANAGER_TOKEN;
const cookieHeader = setCookie.split(';')[0];
const logout = await call('DELETE', '/session', { headers: { cookie: cookieHeader } });
const afterLogout = await call('GET', '/portal', { headers: { cookie: cookieHeader } });
record(
'F3',
cookieIsToken && afterLogout.status === 200 ? 'ПОДТВЕРЖДЕНО' : 'частично',
`вход ${login.status}, выход ${logout.status}; cookie содержит сам токен менеджера: ${cookieIsToken}; после DELETE /session старая cookie → GET /portal ${afterLogout.status}; флаги: ${setCookie.split(';').slice(1).map((part) => part.trim()).join('; ')}`,
);
// N1: create employee
const created = await call('POST', '/employees', {
token: MANAGER_TOKEN,
body: { personnel_number: 'A-100', display_name: 'Новый Сотрудник', position: 'Повар' },
});
record(
'N1',
created.status >= 500 ? 'ПОДТВЕРЖДЕНО' : 'не подтверждено',
`POST /employees → ${created.status} ${brief(created.json)}`,
);
// N2: confirm session and calculate
const confirm = await call('PATCH', `/work-sessions/${ids.session}`, {
token: MANAGER_TOKEN,
body: { status: 'confirmed' },
});
const rules = await call('GET', '/pay-rules', { token: MANAGER_TOKEN });
const ruleList = rules.json?.pay_rules ?? [];
const hourlyRule =
ruleList.find((rule) => /hour/.test(String(rule.code))) ?? ruleList[0];
const assignment = hourlyRule
? await call('PUT', `/employees/${ids.employee}/pay-assignment`, {
token: MANAGER_TOKEN,
body: {
pay_rule_id: hourlyRule.id,
org_unit_id: ids.orgUnit,
rate_minor: 30000,
effective_from: '2026-09-01',
},
})
: { status: 'нет правил', json: rules.json };
const calculate = await call('POST', '/payroll/calculate', {
token: MANAGER_TOKEN,
body: { month: '2026-09' },
});
record(
'N2',
confirm.status >= 500 || calculate.status >= 500 ? 'ПОДТВЕРЖДЕНО' : 'не подтверждено',
`PATCH /work-sessions (confirmed) → ${confirm.status} ${brief(confirm.json)}; GET /pay-rules → ${rules.status} (${ruleList.length} правил, выбрано ${hourlyRule?.code}); PUT pay-assignment → ${assignment.status}; POST /payroll/calculate → ${calculate.status} ${brief(calculate.json)}`,
);
// N4: overlapping runs, after bypassing N2 in this throwaway database only
await superQuery('ALTER TABLE staff_outbox ALTER COLUMN occurred_at SET DEFAULT now()');
try {
const confirmAgain = await call('PATCH', `/work-sessions/${ids.session}`, {
token: MANAGER_TOKEN,
body: { status: 'confirmed' },
});
const periods = [
{ month: '2026-09' },
{ from: '2026-09-02', to: '2026-09-30' },
{ month: '2026-09', org_unit_id: ids.orgUnit },
];
const steps = [];
for (const period of periods) {
const run = await call('POST', '/payroll/calculate', { token: MANAGER_TOKEN, body: period });
const runId = run.json?.run_id ?? run.json?.id ?? run.json?.run?.id;
const approve = runId
? await call('POST', `/payroll/runs/${runId}/approve`, { token: MANAGER_TOKEN })
: { status: 'нет run_id', json: run.json };
steps.push(`${JSON.stringify(period)}: calculate ${run.status}, approve ${approve.status}`);
}
const ledger = await superQuery(
`SELECT count(*)::int AS entries, COALESCE(sum(amount_minor), 0)::bigint AS total
FROM staff_payroll_ledger WHERE tenant_id = $1 AND employee_id = $2 AND kind = 'accrual'`,
[TENANT, ids.employee],
);
const { entries, total } = ledger.rows[0];
record(
'N4',
entries > 1 ? 'ПОДТВЕРЖДЕНО' : 'не подтверждено',
`повторное подтверждение смены → ${confirmAgain.status}; ${steps.join('; ')}; в реестре начислений за одну смену 8 ч × 300 ₽: записей ${entries}, сумма ${Number(total) / 100} ₽`,
);
// D5: a session already paid by an approved run can still be rejected or edited
const rejectPaid = await call('PATCH', `/work-sessions/${ids.session}`, {
token: MANAGER_TOKEN,
body: { status: 'rejected', correction_note: 'audit: after approval' },
});
const ledgerAfter = await superQuery(
`SELECT count(*)::int AS entries FROM staff_payroll_ledger
WHERE tenant_id = $1 AND employee_id = $2`,
[TENANT, ids.employee],
);
record(
'D5',
rejectPaid.status < 300 ? 'ПОДТВЕРЖДЕНО' : 'не подтверждено',
`отклонение смены, уже начисленной в утверждённой ведомости → ${rejectPaid.status}; записей в реестре после этого: ${ledgerAfter.rows[0].entries} (сторно не создано)`,
);
// N13: two first calculations of the same new period at once
await superQuery(
`INSERT INTO staff_work_sessions
(id, tenant_id, employee_id, org_unit_id, business_date, worked_minutes, status)
VALUES ($1, $2, $3, $4, '2026-10-05', 480, 'confirmed')`,
[ulid(), TENANT, ids.employee, ids.orgUnit],
);
const race = await Promise.all([
call('POST', '/payroll/calculate', { token: MANAGER_TOKEN, body: { month: '2026-10' } }),
call('POST', '/payroll/calculate', { token: MANAGER_TOKEN, body: { month: '2026-10' } }),
]);
record(
'N13',
race.some((response) => response.status >= 500) ? 'ПОДТВЕРЖДЕНО' : 'не воспроизведено',
`два одновременных первых расчёта октября → ${race.map((response) => `${response.status} ${brief(response.json).slice(0, 90)}`).join(' | ')}`,
);
// Validation holes: impossible month and effective date
const badMonth = await call('POST', '/payroll/calculate', {
token: MANAGER_TOKEN,
body: { month: '2026-13' },
});
const hugeRate = await call('PUT', `/employees/${ids.employee}/pay-assignment`, {
token: MANAGER_TOKEN,
body: {
pay_rule_id: hourlyRule?.id,
org_unit_id: ids.orgUnit,
rate_minor: 1e20,
effective_from: '2026-11-01',
unexpected_field: 'accepted?',
},
});
record(
'D9',
'проверка',
`month=2026-13 → ${badMonth.status}; rate_minor=1e20 и лишнее поле → ${hugeRate.status} ${brief(hugeRate.json).slice(0, 120)}`,
);
} finally {
await superQuery('ALTER TABLE staff_outbox ALTER COLUMN occurred_at DROP DEFAULT');
}
// N3: SSRF through a push subscription
const inbound = { connections: 0, tlsHello: false };
const listener = net.createServer((socket) => {
inbound.connections += 1;
socket.once('data', (chunk) => {
if (chunk[0] === 0x16) inbound.tlsHello = true;
socket.destroy();
});
});
await new Promise((resolve) => listener.listen(18443, '127.0.0.1', resolve));
try {
const issued = await call('POST', `/employees/${ids.pushEmployee}/access-token`, {
token: MANAGER_TOKEN,
});
const employeeToken = issued.json?.access_token;
const ecdh = createECDH('prime256v1');
ecdh.generateKeys();
const subscribe = await call('POST', '/push/subscriptions', {
token: employeeToken,
body: {
endpoint: 'https://127.0.0.1:18443/internal-admin',
keys: {
p256dh: ecdh.getPublicKey().toString('base64url'),
auth: randomBytes(16).toString('base64url'),
},
},
});
const clock = await call('POST', '/attendance/events', {
token: TERMINAL_TOKEN,
body: {
employee_id: ids.pushEmployee,
event_type: 'check_in',
occurred_at: new Date().toISOString(),
source_event_id: `audit-${Date.now()}`,
org_unit_id: ids.orgUnit,
},
});
await new Promise((resolve) => setTimeout(resolve, 3000));
record(
'N3',
inbound.connections > 0 ? 'ПОДТВЕРЖДЕНО' : 'не подтверждено',
`выдача ключа сотруднику → ${issued.status}; подписка на https://127.0.0.1:18443 → ${subscribe.status}; отметка терминала → ${clock.status}; входящих соединений на внутренний адрес: ${inbound.connections}, TLS ClientHello: ${inbound.tlsHello}`,
);
// N9: another employee re-binds the same endpoint
const other = await call('POST', `/employees/${ids.employee}/access-token`, {
token: MANAGER_TOKEN,
});
const rebind = await call('POST', '/push/subscriptions', {
token: other.json?.access_token,
body: {
endpoint: 'https://127.0.0.1:18443/internal-admin',
keys: { p256dh: ecdh.getPublicKey().toString('base64url'), auth: randomBytes(16).toString('base64url') },
},
});
const owner = await superQuery(
`SELECT employee_id FROM staff_push_subscriptions WHERE tenant_id = $1 AND endpoint = $2`,
[TENANT, 'https://127.0.0.1:18443/internal-admin'],
).catch((error) => ({ rows: [{ employee_id: `ошибка: ${error.message}` }] }));
record(
'N9',
rebind.status < 300 && owner.rows[0]?.employee_id === ids.employee ? 'ПОДТВЕРЖДЕНО' : 'не подтверждено',
`сотрудник Б подписывает endpoint сотрудника А → ${rebind.status}; подписка теперь принадлежит сотруднику Б: ${owner.rows[0]?.employee_id === ids.employee}`,
);
// A4: internal error details leak in 500 responses
const badFk = await call('POST', '/employees', {
token: MANAGER_TOKEN,
body: { personnel_number: 'A-200', display_name: 'X', profession_id: ulid() },
});
record('A4', 'проверка', `500 с деталями: POST /employees с несуществующей профессией → ${badFk.status} ${brief(badFk.json)}`);
// N10: reference data without permission checks
const professionsByTerminal = await call('GET', '/professions', { token: TERMINAL_TOKEN });
const orgUnitsByTerminal = await call('GET', '/org-units', { token: TERMINAL_TOKEN });
record(
'N10',
'проверка',
`терминал: GET /professions → ${professionsByTerminal.status} (${Array.isArray(professionsByTerminal.json) ? professionsByTerminal.json.length : brief(professionsByTerminal.json)}); GET /org-units → ${orgUnitsByTerminal.status} ${brief(orgUnitsByTerminal.json)}`,
);
// Security headers on the portal
const page = await fetch(`http://127.0.0.1:${PORT}/staff`);
record(
'HDR',
'проверка',
`GET /staff: content-security-policy="${page.headers.get('content-security-policy')}", strict-transport-security="${page.headers.get('strict-transport-security')}"`,
);
} finally {
listener.close();
}
} finally {
const serverErrors = server.stderrText();
await stop(server);
if (serverErrors.trim()) console.log(`server stderr: ${brief(serverErrors)}`);
}
}
await scenarioOpenMode('manager');
await scenarioOpenMode('employee');
await scenarioWithTokens();
console.log('\nJSON', JSON.stringify(results));

View file

@ -1,213 +0,0 @@
// Live check of the offline sync branch (5ee7517) on a real PostgreSQL 17.
import { createRequire } from 'node:module';
import { spawn } from 'node:child_process';
import { randomBytes } from 'node:crypto';
const REPO = 'C:/Users/Ochenstarik/Agent_projects/krona-staff-sync';
const require = createRequire(`${REPO}/package.json`);
const pg = require('pg');
const { ulid } = require('ulid');
const SUPER = 'postgres://postgres:audit-superuser@127.0.0.1:55432/krona_staff_sync';
const APP = 'postgres://krona_staff_app:audit-app-password@127.0.0.1:55432/krona_staff_sync';
const PORT = 18081;
const BASE = `http://127.0.0.1:${PORT}/api/v1/staff`;
const TENANT = ulid();
const MANAGER_TOKEN = randomBytes(32).toString('base64url');
const TERMINAL_TOKEN = randomBytes(32).toString('base64url');
async function superQuery(sql, params = []) {
const client = new pg.Client({ connectionString: SUPER });
await client.connect();
try {
return await client.query(sql, params);
} finally {
await client.end();
}
}
async function call(method, path, { token, body } = {}) {
const response = await fetch(`${BASE}${path}`, {
method,
headers: {
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
...(token ? { authorization: `Bearer ${token}` } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await response.text();
try {
return { status: response.status, json: JSON.parse(text) };
} catch {
return { status: response.status, json: text };
}
}
const brief = (value) => {
const text = typeof value === 'string' ? value : JSON.stringify(value);
return text.length > 260 ? `${text.slice(0, 260)}…` : text;
};
// S9: deleting an org unit referenced by a sync device
const s9Unit = ulid();
const s9Device = ulid();
await superQuery(`INSERT INTO staff_org_units (id, tenant_id, code, name) VALUES ($1, $2, 's9', 'S9')`, [
s9Unit,
TENANT,
]);
const deviceColumns = (
await superQuery(
`SELECT column_name, is_nullable, column_default FROM information_schema.columns
WHERE table_name = 'staff_sync_devices' ORDER BY ordinal_position`,
)
).rows;
const values = {
id: s9Device,
tenant_id: TENANT,
device_code: 's9-device',
name: 'S9',
token_hash: 'a'.repeat(64),
token_hint: 'hint',
org_unit_id: s9Unit,
created_by: ulid(),
status: 'active',
};
const insertable = deviceColumns.filter(
(column) => values[column.column_name] !== undefined,
);
await superQuery(
`INSERT INTO staff_sync_devices (${insertable.map((c) => c.column_name).join(', ')})
VALUES (${insertable.map((_, index) => `$${index + 1}`).join(', ')})`,
insertable.map((column) => values[column.column_name]),
).catch((error) => console.log(`S9 setup: ${error.code} ${error.message}`));
try {
await superQuery(`DELETE FROM staff_org_units WHERE id = $1`, [s9Unit]);
console.log('[S9] не подтверждено — точку с привязанным устройством удалить удалось');
} catch (error) {
console.log(`[S9] ПОДТВЕРЖДЕНО — удаление точки с устройством → ${error.code} ${error.message}`);
}
// Seed an employee and a unit for S1
const orgUnit = ulid();
const employee = ulid();
await superQuery(
`INSERT INTO staff_org_units (id, tenant_id, code, name) VALUES ($1, $2, 'sync-unit', 'Точка')`,
[orgUnit, TENANT],
);
await superQuery(
`INSERT INTO staff_employees (id, tenant_id, personnel_number, display_name, status)
VALUES ($1, $2, 'S-001', 'Сотрудник Синхронизации', 'active')`,
[employee, TENANT],
);
const server = spawn(process.execPath, ['node_modules/tsx/dist/cli.mjs', 'src/server.ts'], {
cwd: REPO,
env: {
...process.env,
NODE_ENV: 'development',
HOST: '127.0.0.1',
PORT: String(PORT),
LOG_LEVEL: 'silent',
STAFF_RUNTIME_MODE: 'standalone',
STANDALONE_ACCESS: 'employee',
STANDALONE_MANAGER_TOKEN: MANAGER_TOKEN,
STANDALONE_TERMINAL_TOKEN: TERMINAL_TOKEN,
DATABASE_URL: APP,
STANDALONE_TENANT_ID: TENANT,
STANDALONE_ACTOR_ID: ulid(),
},
stdio: 'ignore',
});
try {
for (let attempt = 0; attempt < 120; attempt += 1) {
try {
if ((await fetch(`${BASE}/live`)).ok) break;
} catch {}
await new Promise((resolve) => setTimeout(resolve, 500));
}
const registered = await call('POST', '/sync/devices', {
token: MANAGER_TOKEN,
body: { device_code: 'kiosk-1', name: 'Киоск', org_unit_id: orgUnit },
});
const deviceToken = registered.json?.token;
console.log(`device registration → ${registered.status}; scopes ${JSON.stringify(registered.json?.device?.allowed_scopes)}`);
const forged = await call('POST', '/sync/push', {
token: deviceToken,
body: {
commands: [
{
command_type: 'upsert_work_session',
schema_version: 1,
idempotency_key: `forge-${Date.now()}`,
correlation_id: ulid(),
created_at: new Date().toISOString(),
payload: {
employee_id: employee,
org_unit_id: orgUnit,
business_date: '2026-09-15',
worked_minutes: 2880,
status: 'confirmed',
},
},
{
command_type: 'record_attendance',
schema_version: 1,
idempotency_key: `old-${Date.now()}`,
correlation_id: ulid(),
created_at: new Date().toISOString(),
payload: {
employee_id: employee,
event_type: 'check_in',
occurred_at: '2020-01-01T00:00:00Z',
},
},
],
},
});
console.log(`device push → ${forged.status} ${brief(forged.json)}`);
const session = await superQuery(
`SELECT status, worked_minutes, confirmed_by, correction_note
FROM staff_work_sessions WHERE tenant_id = $1 AND employee_id = $2 AND business_date = '2026-09-15'`,
[TENANT, employee],
);
const oldEvent = await superQuery(
`SELECT occurred_at FROM staff_attendance_events
WHERE tenant_id = $1 AND employee_id = $2 AND occurred_at < '2021-01-01'`,
[TENANT, employee],
);
const audit = await superQuery(
`SELECT count(*)::int AS entries FROM staff_audit_log WHERE tenant_id = $1`,
[TENANT],
);
const row = session.rows[0];
console.log(
`[S1] ${row?.status === 'confirmed' ? 'ПОДТВЕРЖДЕНО' : 'не подтверждено'} — смена от устройства: статус ${row?.status}, минут ${row?.worked_minutes}, подтвердил ${row?.confirmed_by ?? 'никто'}, записей в журнале аудита арендатора: ${audit.rows[0].entries}`,
);
console.log(
`[S2] ${oldEvent.rowCount > 0 ? 'ПОДТВЕРЖДЕНО' : 'не подтверждено'} — отметка с временем 2020-01-01 через синхронизацию сохранена: ${oldEvent.rowCount > 0}`,
);
// The forged session flows into payroll (outbox default only in this throwaway database, see N2)
await superQuery('ALTER TABLE staff_outbox ALTER COLUMN occurred_at SET DEFAULT now()');
try {
const rules = await call('GET', '/pay-rules', { token: MANAGER_TOKEN });
const hourly = (rules.json?.pay_rules ?? []).find((rule) => /hour/.test(rule.code));
await call('PUT', `/employees/${employee}/pay-assignment`, {
token: MANAGER_TOKEN,
body: { pay_rule_id: hourly?.id, org_unit_id: orgUnit, rate_minor: 30000, effective_from: '2026-09-01' },
});
const run = await call('POST', '/payroll/calculate', { token: MANAGER_TOKEN, body: { month: '2026-09' } });
const total = run.json?.total_accrued_minor ?? run.json?.totals?.accrued_minor;
console.log(
`[S1→payroll] расчёт сентября → ${run.status}; начислено по поддельной смене: ${total !== undefined ? Number(total) / 100 : brief(run.json)} ₽ (48 ч × 300 ₽)`,
);
} finally {
await superQuery('ALTER TABLE staff_outbox ALTER COLUMN occurred_at DROP DEFAULT');
}
} finally {
server.kill();
}

View file

@ -1,5 +0,0 @@
[S9] ПОДТВЕРЖДЕНО — удаление точки с устройством → 23502 null value in column "tenant_id" of relation "staff_sync_devices" violates not-null constraint
device push → 200 {"results":[{"idempotency_key":"forge-1790405052967","command_type":"upsert_work_session","status":"applied","result":{"id":"01M3E7A2JB9PQ3YZQ4J5TE2V49","revision":1,"status":"applied"}},{"idempotency_key":"old-1790405052969","command_type":"record_attendance"…
[S1] ПОДТВЕРЖДЕНО — смена от устройства: статус confirmed, минут 2880, подтвердил никто, записей в журнале аудита арендатора: 0
[S2] ПОДТВЕРЖДЕНО — отметка с временем 2020-01-01 через синхронизацию сохранена: true
[S1→payroll] расчёт сентября → 200; начислено по поддельной смене: 14400 ₽ (48 ч × 300 ₽)