style: establish repository formatting baseline

Run the configured formatter across the repository before audit remediation so later security fixes produce reviewable, conflict-resistant diffs.
This commit is contained in:
Ochenstarik 2026-09-26 14:22:25 +07:00
parent 331e4909c9
commit fd1ec3d819
24 changed files with 544 additions and 449 deletions

View file

@ -691,7 +691,18 @@ paths:
properties:
device:
type: object
required: [id, tenant_id, device_code, name, token_hint, status, allowed_scopes, registered_at, created_by]
required:
[
id,
tenant_id,
device_code,
name,
token_hint,
status,
allowed_scopes,
registered_at,
created_by,
]
properties:
id: { $ref: '#/components/schemas/Ulid' }
tenant_id: { $ref: '#/components/schemas/Ulid' }
@ -803,7 +814,8 @@ paths:
type: array
items:
type: object
required: [cursor, entity_type, entity_id, operation, revision, payload, created_at]
required:
[cursor, entity_type, entity_id, operation, revision, payload, created_at]
properties:
cursor: { type: string }
entity_type: { type: string }
@ -837,7 +849,15 @@ paths:
maxItems: 100
items:
type: object
required: [command_type, schema_version, idempotency_key, correlation_id, created_at, payload]
required:
[
command_type,
schema_version,
idempotency_key,
correlation_id,
created_at,
payload,
]
properties:
command_type: { type: string }
schema_version: { type: integer }
@ -874,7 +894,16 @@ paths:
conflict:
oneOf:
- type: object
required: [conflict_code, entity_type, entity_id, expected_revision, actual_revision, safe_description, client_action]
required:
[
conflict_code,
entity_type,
entity_id,
expected_revision,
actual_revision,
safe_description,
client_action,
]
properties:
conflict_code: { type: string, enum: [REVISION_MISMATCH] }
entity_type: { type: string }

View file

@ -15,11 +15,13 @@
В операционных системах Windows (начиная с Windows Vista / Server 2008 и включая Windows 10/11) службы выполняются в изолированной сессии **Session 0** без интерактивного рабочего стола.
### Результаты анализа ZKFinger SDK 5.3 и драйвера SLK20R в Session 0:
1. Пользовательская библиотека `libzkfp.dll` и драйвер `slk20r.sys` при инициализации используют оконные сообщения Win32 (`WM_DEVICECHANGE`) и оконные дескрипторы для отслеживания PnP-событий USB, а также дескрипторы интерфейсов устройств, привязанные к интерактивным пользовательским сессиям.
2. При вызове `zkfp2.Init()` в Session 0 драйвер сообщает 0 подключённых устройств (`GetDeviceCount() == 0`) либо завершается с кодом ошибки дескриптора (-1 / -7).
3. Попытка эксклюзивного захвата USB-устройства службой Session 0 блокирует доступ к сканеру со стороны интерактивного пользовательского сеанса для регистрации новых отпечатков.
### Принятое решение:
- **KronaStaffService** работает как системная служба Windows и берет на себя непрерывную синхронизацию очереди отметок, хранение состояния в `%ProgramData%\Krona\StaffTerminal` под защитой Windows DPAPI (`DataProtectionScope.LocalMachine`) и IPC-шлюз.
- Интерактивное приложение **KronaStaffTerminal** подключается к сканеру SLK20R в пользовательской сессии (с автозапуском при входе в систему и сворачиванием в трей), выполняет высокоточное распознавание и регистрацию отпечатков, фиксирует отметки и передаёт их в очередь службы.
- Если интерактивный пользователь выходит из системы или закрывает приложение, служба **KronaStaffService** продолжает выгрузку всех накопленных офлайн-событий на сервер.
@ -27,12 +29,14 @@
## Управление службой
Установка и регистрация службы:
```powershell
sc.exe create KronaStaffService binPath= "C:\Program Files\Krona Staff Terminal\KronaStaffService.exe" start= auto obj= "NT AUTHORITY\SYSTEM" DisplayName= "Krona Staff Service"
sc.exe start KronaStaffService
```
Остановка и удаление:
```powershell
sc.exe stop KronaStaffService
sc.exe delete KronaStaffService

View file

@ -34,7 +34,11 @@ services:
tmpfs: [/tmp]
security_opt: ['no-new-privileges:true']
healthcheck:
test: ['CMD-SHELL', 'wget --no-check-certificate -q -O - https://127.0.0.1:8080/api/v1/staff/live || exit 1']
test:
[
'CMD-SHELL',
'wget --no-check-certificate -q -O - https://127.0.0.1:8080/api/v1/staff/live || exit 1',
]
interval: 30s
timeout: 3s
retries: 3

View file

@ -21,9 +21,10 @@ The device driver must be obtained by end-users or enterprise system administrat
The official End-User License Agreement included with the ZKTeco ZKFinger SDK (`apps/KronaStaffTerminal/Vendor/ZKFinger/LICENSE.txt`) contains the following clause in **Section 2 (Description of Other Rights and Limitations)**:
> **Distribution.**
> *"The SOFTWARE PRODUCT may not be sold or be included in a product or package which intends to receive benefits through the inclusion of the SOFTWARE PRODUCT. The SOFTWARE PRODUCT may be included in any free or non-profit packages or products."*
> _"The SOFTWARE PRODUCT may not be sold or be included in a product or package which intends to receive benefits through the inclusion of the SOFTWARE PRODUCT. The SOFTWARE PRODUCT may be included in any free or non-profit packages or products."_
### Legal Interpretation:
1. **Commercial Benefit Restriction:** Krona Staff is a commercial workforce management system provided under commercial software licensing terms. Bundling the ZKTeco drivers or redistributable setup packages directly inside the commercial installer (`KronaStaffTerminal-Setup-*.exe`) could be construed as including the software product in a package intended to derive commercial benefit, violating Section 2.
2. **Permitted Managed Integration:** Building client applications that link against the public SDK wrapper library while requiring users to independently install the vendor-supplied hardware driver for their purchased optical scanner is standard industry practice and fully respects the hardware vendor's EULA.
@ -34,6 +35,7 @@ The official End-User License Agreement included with the ZKTeco ZKFinger SDK (`
To ensure a seamless operational experience without violating vendor licensing terms, the Krona Staff installer and application implement non-blocking automated driver detection and guidance:
### 3.1 Installer Detection Flow
1. During installation (via `installer/KronaStaffTerminal.nsi`), the installer checks for the presence of the SLK20R / ZKFinger driver across standard system locations:
- System driver directory: `%WINDIR%\System32\drivers\slk20r.sys`, `zkfp.sys`, `zkfpos.sys`, `ZKUSB.sys`
- Windows Service Registry keys:
@ -47,9 +49,10 @@ To ensure a seamless operational experience without violating vendor licensing t
- The installer does **not** fail or block installation; it completes cleanly so that the terminal and synchronization service are ready once the driver is installed.
### 3.2 Terminal Runtime Flow
1. If the terminal starts without the driver installed:
- The scanner state transitions to `ScannerState.DriverError` or `ScannerState.Disconnected`.
- The system tray icon updates to `TrayState.NoScanner` (amber warning icon) with tooltip: *"Сканер отпечатков пальцев не подключен или не установлен драйвер"*.
- The system tray icon updates to `TrayState.NoScanner` (amber warning icon) with tooltip: _"Сканер отпечатков пальцев не подключен или не установлен драйвер"_.
- The main UI displays an alert InfoBar with instructions and a link to the official ZKTeco driver download page.
- The terminal remains usable: administrator PIN authentication, employee listing, offline queue synchronization, and manual attendance tracking continue to operate normally.
2. When the administrator plugs in the scanner and installs the driver, the terminal's background auto-reconnect loop automatically detects the device within 2 seconds without requiring an application restart.
@ -59,6 +62,7 @@ To ensure a seamless operational experience without violating vendor licensing t
## 4. Enterprise Mass Deployment Guide
For automated deployment via Microsoft Intune, Group Policy (GPO), or SCCM:
1. Download the official ZKTeco SLK20R Driver package (`ZKFinger SDK / Driver Setup`).
2. Extract the driver INF and SYS files (`slk20r.inf`, `slk20r.sys`, `slk20rx64.cat`).
3. Deploy the driver package silently using standard Windows DISM or pnputil:

View file

@ -13,6 +13,7 @@
This report documents empirical hardware testing and architectural findings gathered during the integration of the ZKTeco SLK20R fingerprint scanner into the Krona Staff Terminal ecosystem.
### Key Takeaways:
1. **Session 0 Limitation:** The ZKTeco USB driver and native SDK (`libzkfp.dll`) rely on interactive desktop Windows messaging subsystems (`WM_DEVICECHANGE`) and Win32 user session interfaces. Running biometric capture inside a Windows Service (Session 0) consistently enumerates `0` devices.
2. **Hybrid Solution Adopted:** A decoupled dual-process architecture:
- **`KronaStaffTerminal.exe` (Interactive User Session):** Owns hardware USB scanner lifecycle, capture loop, biometric template matching, and multi-finger enrollment.
@ -25,18 +26,23 @@ This report documents empirical hardware testing and architectural findings gath
## 2. Session 0 Investigation & Architectural Analysis
### The Problem
During development of the headless Windows Service (`KronaStaffService`), tests were performed to verify whether `libzkfp.dll` could capture fingerprints in Session 0 (running as `NT AUTHORITY\SYSTEM`).
#### Empirical Observations:
- In Windows Service Session 0, calling `zkfp2.Init()` returns `0` (ZKFP_ERR_OK).
- Calling `zkfp2.GetDeviceCount()` immediately following returns `0`, despite the USB scanner being plugged into the machine and operational in Device Manager.
- Attempting to pass handle `0` to `zkfp2.OpenDevice(0)` returns error code `-1` (device not found).
#### Root Cause:
The ZKTeco SLK20R driver implementation (`slk20r.sys`) registers device interfaces that bind specifically to interactive Windows desktop sessions. Because Session 0 in modern Windows (Vista through Windows 11/Server 2022) is isolated from window stations, the native driver cannot establish the USB pipe without an interactive user station (`WinSta0`).
#### Architectural Resolution:
Instead of forcing unreliable Session 0 USB hooks or interactive service workarounds (which violate Microsoft Windows Security Guidelines), the architecture cleanly divides responsibilities:
```
+--------------------------------------------------------------+
| Interactive User Session |
@ -69,14 +75,18 @@ Instead of forcing unreliable Session 0 USB hooks or interactive service workaro
## 3. SDK Device Deduplication Analysis
### The Problem
Under certain driver configurations and USB 3.0/3.1 xHCI host controllers, `zkfp2.GetDeviceCount()` reports `2` or more devices even when only a single physical SLK20R reader is connected.
Opening device index `1` resulted in one of two failure modes:
1. `OpenDevice(1)` returned error `-2` (device already open/busy).
2. `OpenDevice(1)` succeeded as a phantom clone that interfered with image acquisition on device index `0`, causing dropped frames and degraded capture reliability.
### The Solution: Serial Number Deduplication
`FingerprintScanner.cs` inspects hardware parameters for each enumerated slot:
```csharp
// Query serial number parameter (parameter codes 1101, 1102, 106)
byte[] serialBuffer = new byte[256];
@ -87,6 +97,7 @@ if (zkfp2.GetParameters(devHandle, 1101, serialBuffer, ref size) == 0 && size >
serial = Encoding.UTF8.GetString(serialBuffer, 0, size).Trim('\0', ' ', '\r', '\n');
}
```
If an enumerated slot returns a serial number that has already been registered by slot `0`, the terminal skips the duplicate slot and logs a diagnostic warning. This guarantees that only one active capture session exists per physical unit.
---
@ -94,11 +105,13 @@ If an enumerated slot returns a serial number that has already been registered b
## 4. USB Disconnection & Hotplug Recovery Testing
### Test Scenario
1. Terminal running in kiosk mode with SLK20R connected.
2. Employee clocks in successfully.
3. USB cable is physically unplugged or hub power is cycled.
### Observed Results
- The active capture thread in `zkfp2.AcquireFingerprint` returns negative error code `-101` or `-102` (ZKFP_ERR_IO / disconnect).
- Without reconnect logic, earlier versions hung in a tight error loop, pinning the CPU at 100%.
- **With Production Reconnect Logic:**
@ -115,12 +128,12 @@ If an enumerated slot returns a serial number that has already been registered b
## 5. Performance & Biometric Accuracy Verification
| Metric | Target | Measured Result | Status |
|---|---|---|---|
| Finger placement to verification | < 1000 ms | ~420 ms | PASSED |
| 1:N Biometric Matching (100 templates) | < 100 ms | 14 ms | PASSED |
| False Rejection Rate (FRR) at threshold 60 | < 1.5% | 0.8% | PASSED |
| USB Disconnect to UI State Update | < 500 ms | ~120 ms | PASSED |
| Cable Re-insertion to Active Ready State | < 3000 ms | 2150 ms | PASSED |
| Duplicate Scan Guard (30s lockout) | 100% rejection | 100% rejection (0 duplicate punches) | PASSED |
| Windows Service startup persistence after reboot | Reliable | NOT TESTED YET IN HARDWARE (Requires real service installation test after machine reboot to verify background sync without login) | PENDING |
| Metric | Target | Measured Result | Status |
| ------------------------------------------------ | -------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------- |
| Finger placement to verification | < 1000 ms | ~420 ms | PASSED |
| 1:N Biometric Matching (100 templates) | < 100 ms | 14 ms | PASSED |
| False Rejection Rate (FRR) at threshold 60 | < 1.5% | 0.8% | PASSED |
| USB Disconnect to UI State Update | < 500 ms | ~120 ms | PASSED |
| Cable Re-insertion to Active Ready State | < 3000 ms | 2150 ms | PASSED |
| Duplicate Scan Guard (30s lockout) | 100% rejection | 100% rejection (0 duplicate punches) | PASSED |
| Windows Service startup persistence after reboot | Reliable | NOT TESTED YET IN HARDWARE (Requires real service installation test after machine reboot to verify background sync without login) | PENDING |

View file

@ -3,6 +3,7 @@
## 1. Архитектурный контекст и инварианты
Krona Staff является модулем платформы Krona Business Platform, а не изолированной системой пользователей, прав и ролей:
1. **Единственный источник истины (Single Source of Truth)**: Центральный сервер Krona Staff и Platform Core являются владельцами эталонных данных. Локальные терминалы, планшеты и клиенты являются исполнительными терминалами и не могут становиться вторым источником истины.
2. **User != Employee**: Учётная запись пользователя платформы (`User`) отделена от карточки сотрудника (`Employee`).
3. **Строгая изоляция арендаторов (Tenant Isolation)**: Все сущности привязаны к `tenant_id` (ULID). Новые таблицы включают и форсируют PostgreSQL Row Level Security (`FORCE ROW LEVEL SECURITY`), гарантируя невозможность утечки данных между организациями даже на уровне прямого SQL.
@ -13,9 +14,11 @@ Krona Staff является модулем платформы Krona Business Pl
## 2. Жизненный цикл устройства и аутентификация
### 2.1. Регистрация устройства (`POST /api/v1/staff/sync/devices`)
Регистрация выполняется администратором или руководителем предприятия (требуется сессия руководителя или токен с правом `staff:sync:manage` / `staff:attendance:manage`).
**Запрос:**
```json
{
"device_code": "TERM-MAIN-01",
@ -26,6 +29,7 @@ Krona Staff является модулем платформы Krona Business Pl
```
**Ответ (201 Created):**
```json
{
"device": {
@ -48,11 +52,15 @@ Krona Staff является модулем платформы Krona Business Pl
> Сырой токен `token` возвращается клиенту ровно один раз при регистрации. В базе данных (`staff_sync_devices`) сохраняется исключительно криптографический хэш SHA-256 (`token_hash`) и безопасная маска (`token_hint`).
### 2.2. Аутентификация запросов устройства
Каждый запрос от локального терминала к API синхронизации должен содержать заголовок:
```http
Authorization: Bearer <device_token>
```
Сервер:
1. Вычисляет `SHA-256(device_token)`.
2. Извлекает `tenant_id` из префикса токена или заголовка `X-Tenant-ID`.
3. Открывает транзакцию с RLS контекстом `database.tenantTransaction(tenantId, actorId, ...)`.
@ -61,9 +69,11 @@ Authorization: Bearer <device_token>
6. Асинхронно обновляет `last_seen_at`.
### 2.3. Отзыв устройства (`POST /api/v1/staff/sync/devices/:deviceId/revoke`)
При компрометации устройства, утере или списании администратор выполняет отзыв. Статус устройства в БД переводится в `revoked`, фиксируется `revoked_at = now()`.
Все последующие запросы с токеном отозванного устройства немедленно отклоняются со статусом **403 Forbidden** и кодом ошибки `DEVICE_REVOKED`:
```json
{
"statusCode": 403,
@ -77,28 +87,37 @@ Authorization: Bearer <device_token>
## 3. Монотонный непрозрачный курсор изменений
### 3.1. Формат курсора
Курсор представляет собой непрозрачную строку вида:
```text
cur_<sequence>_<signature_hash>
```
Например: `cur_105_7a9f8b2c1d`.
- `<sequence>`: Монотонный идентификатор последовательности изменений в таблице `staff_sync_change_log` (`cursor_seq BIGSERIAL`).
- `<signature_hash>`: Криптографическая контрольная подпись от арендатора, последовательности и серверной соли (`SHA-256(tenant_id + seq + salt)`).
Свойства курсора:
- **Монотонность**: Гарантирует строго возрастающий порядок чтения журнала изменений.
- **Независимость от времени**: Не зависит от системных часов терминала и защищён от сдвигов локального времени.
- **Целостность и изоляция**: Попытка передать повреждённый курсор или курсор другого арендатора немедленно отклоняется с ошибкой `INVALID_CURSOR` (400 Bad Request).
### 3.2. Начальная загрузка (Bootstrap, `cursor = null`)
Когда терминал настраивается впервые или выполняет полный сброс локальной базы данных, он отправляет `POST /api/v1/staff/sync/pull` с пустым или отсутствующим курсором (`cursor: null`):
1. Сервер формирует снимок текущих **актуальных** данных для поддерживаемых сущностей (`org_unit`, `profession`, `employee`, `work_session`, `timesheet_entry`, `planned_shift`).
2. Для каждой сущности формируется безопасная полезная нагрузка с операцией `upsert`.
3. В ответе возвращается `next_cursor`, соответствующий текущему максимальному `cursor_seq` журнала изменений сервера.
4. После применения начального снимка терминал фиксирует `next_cursor` и переходит в режим инкрементального получения дельт.
### 3.3. Инкрементальная загрузка (Pull, `POST /api/v1/staff/sync/pull`)
Запрос:
```json
{
"cursor": "cur_105_7a9f8b2c1d",
@ -106,7 +125,9 @@ cur_<sequence>_<signature_hash>
"entity_types": ["employee", "work_session"]
}
```
Ответ:
```json
{
"changes": [
@ -133,12 +154,15 @@ cur_<sequence>_<signature_hash>
```
### 3.4. Подтверждение изменений (Ack, `POST /api/v1/staff/sync/ack`)
После успешного локального сохранения изменений устройство подтверждает обработанный курсор:
```json
{
"cursor": "cur_106_3c4d5e6f7a"
}
```
Сервер сохраняет контрольную точку в таблице `staff_sync_device_checkpoints`. Это позволяет отслеживать отставание устройств и безопасно выполнять очистку журнала изменений.
---
@ -146,13 +170,16 @@ cur_<sequence>_<signature_hash>
## 4. Пакетная отправка команд (Push) и идемпотентность
### 4.1. Контракт отправки (`POST /api/v1/staff/sync/push`)
Клиент отправляет накопленные в офлайне команды пачками.
- Ограничение пакета: от 1 до 100 команд (`BATCH_SIZE_EXCEEDED` при превышении).
- Максимальный размер полезной нагрузки: не более 64 КБ на команду (`PAYLOAD_TOO_LARGE`).
- Максимальный размер HTTP-запроса: 2 МБ.
- Временное окно защиты от Replay: `created_at` команды должно быть не старше 30 дней и не более чем на 5 минут в будущем (`TIMESTAMP_OUT_OF_WINDOW`).
**Пример запроса:**
```json
{
"commands": [
@ -174,11 +201,15 @@ cur_<sequence>_<signature_hash>
```
### 4.2. Идемпотентность (`staff_sync_command_inbox`)
Таблица `staff_sync_command_inbox` содержит уникальный ключ:
```sql
UNIQUE (tenant_id, device_id, idempotency_key)
```
При повторной отправке команды с тем же `idempotency_key`:
1. Мутация повторно не исполняется.
2. Сервер извлекает ранее зафиксированный результат (`status`, `result`, `conflict`, `error_code`, `error_message`) и возвращает его клиенту.
3. Это гарантирует полную безопасность при сбоях связи и автоматических повторах (Retry).
@ -192,6 +223,7 @@ UNIQUE (tenant_id, device_id, idempotency_key)
Если терминал пытается применить мутацию на основе устаревшей ревизии (например, смена уже была подтверждена управляющим на сервере), команда **не перезаписывает** данные.
**Ответ с конфликтом (в составе `results`):**
```json
{
"idempotency_key": "cmd-term01-778900",
@ -210,6 +242,7 @@ UNIQUE (tenant_id, device_id, idempotency_key)
```
Клиент, получив конфликт, обязан:
1. Запросить актуальное состояние сущности через `POST /sync/pull`.
2. Обновить локальное представление.
3. При необходимости повторить действие с актуальной ревизией или уведомить оператора.
@ -220,15 +253,15 @@ UNIQUE (tenant_id, device_id, idempotency_key)
Терминал получает **исключительно оперативные данные**, необходимые для фиксации посещаемости и графиков:
| Сущность | Разрешённые поля | Запрещённые данные |
| :--- | :--- | :--- |
| `org_unit` | `id`, `code`, `name`, `status` | Финансовые показатели, выручка, арендные договоры |
| `profession` | `id`, `code`, `name`, `status` | Тарифные сетки, оклады |
| `employee` | `id`, `personnel_number`, `display_name`, `status`, `profession_id`, `position` | **Зарплаты, ставки, паспортные данные, СНИЛС, телефоны, адреса, биометрия** |
| `work_session` | `id`, `employee_id`, `org_unit_id`, `business_date`, `started_at`, `ended_at`, `worked_minutes`, `status`, `revision` | Стоимость смены, начисления, штрафы |
| `timesheet_entry` | `id`, `employee_id`, `work_date`, `minutes_worked`, `status`, `revision` | Расчётные листы, итоговые выплаты |
| `planned_shift` | `id`, `employee_id`, `org_unit_id`, `business_date`, `planned_minutes`, `start_time`, `end_time` | Плановая стоимость ФОТ смены |
| `attendance_event` | `id`, `employee_id`, `event_type`, `occurred_at`, `source_event_id`, `device_id`, `org_unit_id`, `status` | Сырые биометрические сэмплы сканера |
| Сущность | Разрешённые поля | Запрещённые данные |
| :----------------- | :-------------------------------------------------------------------------------------------------------------------- | :-------------------------------------------------------------------------- |
| `org_unit` | `id`, `code`, `name`, `status` | Финансовые показатели, выручка, арендные договоры |
| `profession` | `id`, `code`, `name`, `status` | Тарифные сетки, оклады |
| `employee` | `id`, `personnel_number`, `display_name`, `status`, `profession_id`, `position` | **Зарплаты, ставки, паспортные данные, СНИЛС, телефоны, адреса, биометрия** |
| `work_session` | `id`, `employee_id`, `org_unit_id`, `business_date`, `started_at`, `ended_at`, `worked_minutes`, `status`, `revision` | Стоимость смены, начисления, штрафы |
| `timesheet_entry` | `id`, `employee_id`, `work_date`, `minutes_worked`, `status`, `revision` | Расчётные листы, итоговые выплаты |
| `planned_shift` | `id`, `employee_id`, `org_unit_id`, `business_date`, `planned_minutes`, `start_time`, `end_time` | Плановая стоимость ФОТ смены |
| `attendance_event` | `id`, `employee_id`, `event_type`, `occurred_at`, `source_event_id`, `device_id`, `org_unit_id`, `status` | Сырые биометрические сэмплы сканера |
> [!CAUTION]
> **Финансовые данные и заработная плата НИКОГДА не передаются на локальный терминал!**
@ -247,9 +280,11 @@ UNIQUE (tenant_id, device_id, idempotency_key)
## 8. Политика хранения данных (Retention)
Для предотвращения бесконечного роста таблиц синхронизации предусмотрена функция очистки:
```sql
SELECT * FROM staff_sync_cleanup_retention(30);
```
- Удаляет записи `staff_sync_change_log` старше заданного количества дней (по умолчанию 30).
- Удаляет обработанные команды `staff_sync_command_inbox` старше заданного количества дней.
- Активные устройства, отстающие более чем на окно хранения (30 дней), при следующем обращении с устаревшим курсором получают требование повторной начальной инициализации (Bootstrap).

View file

@ -11,6 +11,7 @@
The Krona Staff Terminal (`KronaStaffTerminal.exe`) and its companion Windows Service (`KronaStaffService.exe`) operate on Windows POS / kiosk devices in branch locations. The terminal functions both in fully connected environments and during extended offline network outages.
### Security Guarantees
1. **Biometric Isolation:** Biometric fingerprint templates **NEVER** leave the physical terminal. Templates are stored strictly in local DPAPI-encrypted machine storage (`%ProgramData%\Krona\StaffTerminal\terminal-state.dat`) and are matched on-device using the biometric matching engine. No raw images, templates, minutiae, or biometric features are ever transmitted to the server or written to network logs.
2. **TLS Mandate:** All communications with the Krona Staff backend **MUST** occur over TLS 1.3 or TLS 1.2 with strict certificate verification. Disabling certificate validation or bypassing SNI/SAN checks is strictly forbidden.
3. **Transport Protocol:** Synchronous HTTPS POST requests over standard REST endpoints.
@ -20,18 +21,20 @@ The Krona Staff Terminal (`KronaStaffTerminal.exe`) and its companion Windows Se
## 2. Attendance Sync Endpoint Specification
### Endpoint
```http
POST /api/v1/staff/attendance/events
```
### Request Headers
| Header Name | Type | Required | Description | Example |
|---|---|---|---|---|
| `Content-Type` | String | Yes | Media type of payload | `application/json` |
| `Authorization` | String | Yes | Terminal Bearer API Key | `Bearer trm_sec_991823ab...` |
| `Idempotency-Key` | String | Yes | Unique idempotency key per event batch or event | `6ba7b810-9dad-11d1-80b4-00c04fd430c8` |
| `X-Request-Id` | String | Yes | Unique trace ID for telemetry and crash debugging | `req_01HV8G7P89J21...` |
| `User-Agent` | String | Yes | Terminal client identification | `KronaStaffTerminal/0.3.2 (Windows NT 10.0; Win64; x64)` |
| Header Name | Type | Required | Description | Example |
| ----------------- | ------ | -------- | ------------------------------------------------- | -------------------------------------------------------- |
| `Content-Type` | String | Yes | Media type of payload | `application/json` |
| `Authorization` | String | Yes | Terminal Bearer API Key | `Bearer trm_sec_991823ab...` |
| `Idempotency-Key` | String | Yes | Unique idempotency key per event batch or event | `6ba7b810-9dad-11d1-80b4-00c04fd430c8` |
| `X-Request-Id` | String | Yes | Unique trace ID for telemetry and crash debugging | `req_01HV8G7P89J21...` |
| `User-Agent` | String | Yes | Terminal client identification | `KronaStaffTerminal/0.3.2 (Windows NT 10.0; Win64; x64)` |
---
@ -40,6 +43,7 @@ POST /api/v1/staff/attendance/events
The synchronization client supports sending both individual attendance records and batched events.
### 3.1 Single Event Record Payload
```json
{
"eventId": "01J8R5QWXZ7M1P0T8C3D2E4F5G",
@ -54,7 +58,9 @@ The synchronization client supports sending both individual attendance records a
```
### 3.2 Batch Sync Payload
When draining the offline sync queue after network recovery, the client posts an array of queued events:
```json
{
"deviceId": "TERM-SPB-OFFICE-01",
@ -83,6 +89,7 @@ When draining the offline sync queue after network recovery, the client posts an
```
### Field Definitions
- **`eventId`** (`string`, ULID or UUIDv4): Client-generated immutable identifier of the attendance event.
- **`employeeId`** (`string`, UUIDv4): Server-recognized unique ID of the employee.
- **`eventType`** (`string`): Enum representing the punch action:
@ -103,15 +110,15 @@ When draining the offline sync queue after network recovery, the client posts an
## 4. HTTP Response Codes & Client Handling
| HTTP Status | Meaning | Terminal Client Action |
|---|---|---|
| `200 OK` / `201 Created` | Event successfully recorded | Dequeue event from local offline store immediately. |
| `409 Conflict` | Event with `eventId` or `Idempotency-Key` was already committed by server | **Treated as idempotent success.** Dequeue event immediately from local store to prevent head-of-line blocking. |
| `400 Bad Request` | Malformed payload schema | Log diagnostic event in crash log; quarantine event into dead-letter store to prevent blocking subsequent events. |
| `401 Unauthorized` / `403 Forbidden` | Invalid API token or revoked terminal license | Retain events in offline queue; update system tray status to `TrayState.Error` (`401 Auth Error`); prompt administrator. |
| `429 Too Many Requests` | Server rate limit reached | Back off exponentially, respecting `Retry-After` header if present. |
| `500` / `502` / `503` / `504` | Temporary server-side failure / Gateway error | Retain events in queue; schedule retry with exponential backoff (initial delay 5s, max delay 300s, with randomized jitter). |
| Network Timeout / Socket Error | Terminal offline or disconnected | Keep events in persistent DPAPI-protected queue; retry on timer or upon Windows network change notification (`NetworkChange.NetworkAddressChanged`). |
| HTTP Status | Meaning | Terminal Client Action |
| ------------------------------------ | ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `200 OK` / `201 Created` | Event successfully recorded | Dequeue event from local offline store immediately. |
| `409 Conflict` | Event with `eventId` or `Idempotency-Key` was already committed by server | **Treated as idempotent success.** Dequeue event immediately from local store to prevent head-of-line blocking. |
| `400 Bad Request` | Malformed payload schema | Log diagnostic event in crash log; quarantine event into dead-letter store to prevent blocking subsequent events. |
| `401 Unauthorized` / `403 Forbidden` | Invalid API token or revoked terminal license | Retain events in offline queue; update system tray status to `TrayState.Error` (`401 Auth Error`); prompt administrator. |
| `429 Too Many Requests` | Server rate limit reached | Back off exponentially, respecting `Retry-After` header if present. |
| `500` / `502` / `503` / `504` | Temporary server-side failure / Gateway error | Retain events in queue; schedule retry with exponential backoff (initial delay 5s, max delay 300s, with randomized jitter). |
| Network Timeout / Socket Error | Terminal offline or disconnected | Keep events in persistent DPAPI-protected queue; retry on timer or upon Windows network change notification (`NetworkChange.NetworkAddressChanged`). |
---

View file

@ -18,6 +18,8 @@ const currentDefaultPrivileges = await readFile(
'utf8',
);
if (/ALTER DEFAULT PRIVILEGES[\s\S]*?\bGRANT\b/i.test(currentDefaultPrivileges)) {
throw new Error('Runtime roles must receive explicit object grants; broad default grants are forbidden');
throw new Error(
'Runtime roles must receive explicit object grants; broad default grants are forbidden',
);
}
console.log(`validated ${ups.length} reversible migration(s)`);

View file

@ -54,7 +54,10 @@ function secureTokenEquals(candidate: string | undefined, expected: string): boo
if (!candidate) return false;
const candidateBuffer = Buffer.from(candidate, 'utf8');
const expectedBuffer = Buffer.from(expected, 'utf8');
return candidateBuffer.length === expectedBuffer.length && timingSafeEqual(candidateBuffer, expectedBuffer);
return (
candidateBuffer.length === expectedBuffer.length &&
timingSafeEqual(candidateBuffer, expectedBuffer)
);
}
export class PlatformIdentityAdapter implements IdentityPort {

View file

@ -136,14 +136,33 @@ export async function buildApp({
{ prefix: apiPrefix },
);
app.get('/', async (_request, reply) => reply.redirect('/staff'));
app.get('/staff', async (_request, reply) => reply.type('text/html; charset=utf-8').send(portalHtml));
app.get('/staff/styles.css', async (_request, reply) => reply.type('text/css; charset=utf-8').send(portalCss));
app.get('/staff/app.js', async (_request, reply) => reply.type('application/javascript; charset=utf-8').send(portalJs));
app.get('/staff/sw.js', async (_request, reply) => reply.header('service-worker-allowed', '/staff').type('application/javascript; charset=utf-8').send(portalServiceWorker));
app.get('/staff/manifest.webmanifest', async (_request, reply) => reply.type('application/manifest+json').send(portalManifest));
app.get('/staff/icon-192.svg', async (_request, reply) => reply.type('image/svg+xml').send(portalIcon192));
app.get('/staff/icon-512.svg', async (_request, reply) => reply.type('image/svg+xml').send(portalIcon512));
app.get('/staff/*', async (_request, reply) => reply.type('text/html; charset=utf-8').send(portalHtml));
app.get('/staff', async (_request, reply) =>
reply.type('text/html; charset=utf-8').send(portalHtml),
);
app.get('/staff/styles.css', async (_request, reply) =>
reply.type('text/css; charset=utf-8').send(portalCss),
);
app.get('/staff/app.js', async (_request, reply) =>
reply.type('application/javascript; charset=utf-8').send(portalJs),
);
app.get('/staff/sw.js', async (_request, reply) =>
reply
.header('service-worker-allowed', '/staff')
.type('application/javascript; charset=utf-8')
.send(portalServiceWorker),
);
app.get('/staff/manifest.webmanifest', async (_request, reply) =>
reply.type('application/manifest+json').send(portalManifest),
);
app.get('/staff/icon-192.svg', async (_request, reply) =>
reply.type('image/svg+xml').send(portalIcon192),
);
app.get('/staff/icon-512.svg', async (_request, reply) =>
reply.type('image/svg+xml').send(portalIcon512),
);
app.get('/staff/*', async (_request, reply) =>
reply.type('text/html; charset=utf-8').send(portalHtml),
);
app.addHook('onClose', async () => database.close());
return app;

View file

@ -10,7 +10,10 @@ const configSchema = z.object({
STANDALONE_MANAGER_TOKEN: emptyStringAsUndefined(z.string().min(32).optional()),
STANDALONE_TERMINAL_TOKEN: emptyStringAsUndefined(z.string().min(32).optional()),
STANDALONE_TERMINAL_ACTOR_ID: emptyStringAsUndefined(
z.string().regex(/^[0-9A-HJKMNP-TV-Z]{26}$/).optional(),
z
.string()
.regex(/^[0-9A-HJKMNP-TV-Z]{26}$/)
.optional(),
),
HTTPS_PFX_PATH: emptyStringAsUndefined(z.string().optional()),
HTTPS_PFX_PASSPHRASE: emptyStringAsUndefined(z.string().min(16).optional()),

View file

@ -7,10 +7,7 @@ export interface AttendanceEventInput {
}
export type WorkSessionAnomaly =
| 'auto_closed'
| 'missing_check_in'
| 'missing_check_out'
| 'repeated_check_in';
'auto_closed' | 'missing_check_in' | 'missing_check_out' | 'repeated_check_in';
export interface ReconcileWorkSessionOptions {
now: string;
@ -37,7 +34,10 @@ export function reconcileWorkSession(
events: readonly AttendanceEventInput[],
options: ReconcileWorkSessionOptions,
): WorkSessionCandidate | null {
if (!Number.isInteger(options.openShiftThresholdMinutes) || options.openShiftThresholdMinutes < 1) {
if (
!Number.isInteger(options.openShiftThresholdMinutes) ||
options.openShiftThresholdMinutes < 1
) {
throw new Error('openShiftThresholdMinutes must be a positive integer');
}
if (events.length === 0) return null;
@ -46,8 +46,9 @@ export function reconcileWorkSession(
const automaticCloseAt = options.automaticCloseAt
? parseInstant(options.automaticCloseAt, 'automaticCloseAt')
: undefined;
const parsed = events.map(parseEvent).sort((left, right) =>
left.timestamp - right.timestamp || left.id.localeCompare(right.id));
const parsed = events
.map(parseEvent)
.sort((left, right) => left.timestamp - right.timestamp || left.id.localeCompare(right.id));
const anomalies = new Set<WorkSessionAnomaly>();
const attendanceEventIds: string[] = [];
@ -79,9 +80,8 @@ export function reconcileWorkSession(
}
if (openAt !== null) {
const canAutoClose = automaticCloseAt !== undefined
&& automaticCloseAt > openAt
&& automaticCloseAt <= now;
const canAutoClose =
automaticCloseAt !== undefined && automaticCloseAt > openAt && automaticCloseAt <= now;
if (canAutoClose) {
workedMilliseconds += automaticCloseAt - openAt;
lastEnd = automaticCloseAt;

View file

@ -149,10 +149,7 @@ export function createVapidAuthorizationHeader(
/**
* Encrypt a payload using AES-128-GCM content encoding (RFC 8291 / RFC 8188).
*/
export function encryptPushPayload(
payload: string | Buffer,
keys: PushSubscriptionKeys,
): Buffer {
export function encryptPushPayload(payload: string | Buffer, keys: PushSubscriptionKeys): Buffer {
const plaintext = Buffer.isBuffer(payload) ? payload : Buffer.from(payload, 'utf8');
const recipientP256dh = Buffer.from(keys.p256dh, 'base64url');
const recipientAuth = Buffer.from(keys.auth, 'base64url');
@ -176,9 +173,7 @@ export function encryptPushPayload(
recipientP256dh,
senderPublicKey,
]);
const prk = Buffer.from(
crypto.hkdfSync('sha256', sharedSecret, recipientAuth, keyInfo, 32),
);
const prk = Buffer.from(crypto.hkdfSync('sha256', sharedSecret, recipientAuth, keyInfo, 32));
// 3. Generate 16-byte random salt
const salt = crypto.randomBytes(16);
@ -235,11 +230,11 @@ export async function sendWebPush(
const response = await fetchImpl(target.endpoint, {
method: 'POST',
headers: {
'Authorization': authHeader,
Authorization: authHeader,
'Content-Type': 'application/octet-stream',
'Content-Encoding': 'aes128gcm',
'TTL': String(options?.ttl ?? 86400),
'Urgency': options?.urgency ?? 'normal',
TTL: String(options?.ttl ?? 86400),
Urgency: options?.urgency ?? 'normal',
},
body: new Uint8Array(encryptedBody),
});

View file

@ -44,7 +44,9 @@ export function decodeCursor(tenantId: string, cursor: string): bigint {
candidateBuffer.length !== expectedBuffer.length ||
!crypto.timingSafeEqual(candidateBuffer, expectedBuffer)
) {
throw new InvalidCursorError('Подпись курсора синхронизации недействительна для данного арендатора');
throw new InvalidCursorError(
'Подпись курсора синхронизации недействительна для данного арендатора',
);
}
return BigInt(seqStr);

View file

@ -90,7 +90,11 @@ export class SyncService {
const tokenTenant = SyncService.parseTenantFromToken(rawToken);
if (providedTenantId && tokenTenant && providedTenantId !== tokenTenant) {
throw new SyncAuthError('Несоответствие арендатора в токене устройства', 'TENANT_MISMATCH', 403);
throw new SyncAuthError(
'Несоответствие арендатора в токене устройства',
'TENANT_MISMATCH',
403,
);
}
const tenantId = providedTenantId ?? tokenTenant;
@ -187,9 +191,10 @@ export class SyncService {
const rawToken = `kstaff_dev_${tenantId}_${rawSecret}`;
const tokenHash = SyncService.hashToken(rawToken);
const tokenHint = rawToken.slice(0, 16);
const scopes = params.allowed_scopes && params.allowed_scopes.length > 0
? params.allowed_scopes
: ['attendance:sync'];
const scopes =
params.allowed_scopes && params.allowed_scopes.length > 0
? params.allowed_scopes
: ['attendance:sync'];
const orgUnitId = params.org_unit_id ? parseUlid(params.org_unit_id) : null;
@ -210,7 +215,17 @@ export class SyncService {
(id, tenant_id, device_code, name, token_hash, token_hint, status, allowed_scopes, org_unit_id, created_by)
VALUES ($1, $2, $3, $4, $5, $6, 'active', $7, $8, $9)
RETURNING id, tenant_id, device_code, name, token_hint, status, allowed_scopes, org_unit_id, registered_at, created_by`,
[deviceId, tenantId, params.device_code.trim(), params.name.trim(), tokenHash, tokenHint, scopes, orgUnitId, actorId],
[
deviceId,
tenantId,
params.device_code.trim(),
params.name.trim(),
tokenHash,
tokenHint,
scopes,
orgUnitId,
actorId,
],
);
const row = res.rows[0]!;
@ -326,7 +341,8 @@ export class SyncService {
// 1. If cursor is provided -> incremental pull from staff_sync_change_log
if (options.cursor) {
const seq = decodeCursor(tenantId, options.cursor);
const filterTypes = options.entity_types && options.entity_types.length > 0 ? options.entity_types : null;
const filterTypes =
options.entity_types && options.entity_types.length > 0 ? options.entity_types : null;
const res = await client.query<{
id: string;
@ -360,9 +376,8 @@ export class SyncService {
created_at: r.created_at.toISOString(),
}));
const nextCursor = changes.length > 0
? changes[changes.length - 1]!.cursor
: options.cursor;
const nextCursor =
changes.length > 0 ? changes[changes.length - 1]!.cursor : options.cursor;
return {
changes,
@ -380,16 +395,22 @@ export class SyncService {
const latestCursor = encodeCursor(tenantId, maxSeq);
const wanted = (type: string) =>
!options.entity_types || options.entity_types.length === 0 || options.entity_types.includes(type);
!options.entity_types ||
options.entity_types.length === 0 ||
options.entity_types.includes(type);
const changes: SyncChange[] = [];
// Org Units
if (wanted('org_unit')) {
const orgRes = await client.query<{ id: string; code: string; name: string; status: string }>(
`SELECT id, code, name, status FROM staff_org_units WHERE tenant_id = $1 ORDER BY id`,
[tenantId],
);
const orgRes = await client.query<{
id: string;
code: string;
name: string;
status: string;
}>(`SELECT id, code, name, status FROM staff_org_units WHERE tenant_id = $1 ORDER BY id`, [
tenantId,
]);
for (const row of orgRes.rows) {
const payload: SafeOrgUnitPayload = {
id: row.id,
@ -411,7 +432,12 @@ export class SyncService {
// Professions
if (wanted('profession')) {
const profRes = await client.query<{ id: string; code: string; name: string; status: string }>(
const profRes = await client.query<{
id: string;
code: string;
name: string;
status: string;
}>(
`SELECT id, code, name, status FROM staff_professions WHERE tenant_id = $1 ORDER BY id`,
[tenantId],
);
@ -662,7 +688,8 @@ export class SyncService {
command_type: cmd.command_type,
status: 'rejected',
error_code: 'TIMESTAMP_OUT_OF_WINDOW',
error_message: 'Временная метка команды находится вне допустимого окна (-30 дней .. +5 минут)',
error_message:
'Временная метка команды находится вне допустимого окна (-30 дней .. +5 минут)',
};
await this.saveInboxCommand(client, tenantId, device.id, cmd, rejectedResult);
results.push(rejectedResult);
@ -757,7 +784,17 @@ export class SyncService {
(id, tenant_id, employee_id, event_type, source, source_event_id, occurred_at, device_id, actor_id, correlation_id)
VALUES ($1, $2, $3, $4, 'terminal', $5, $6, $7, $8, $9)
ON CONFLICT (tenant_id, source, source_event_id) DO NOTHING`,
[eventId, tenantId, employeeId, eventType, sourceEventId, occurredAt, device.id, device.id, correlationId],
[
eventId,
tenantId,
employeeId,
eventType,
sourceEventId,
occurredAt,
device.id,
device.id,
correlationId,
],
);
const safePayload: SafeAttendanceEventPayload = {
@ -771,7 +808,15 @@ export class SyncService {
status: 'accepted',
};
await this.recordChange(client, tenantId, 'attendance_event', eventId, 'upsert', 1, safePayload);
await this.recordChange(
client,
tenantId,
'attendance_event',
eventId,
'upsert',
1,
safePayload,
);
return {
idempotency_key: cmd.idempotency_key,
@ -781,207 +826,15 @@ export class SyncService {
};
}
case 'upsert_work_session': {
const sessionId = (payload.id as string) || newUlid();
const employeeId = payload.employee_id as string;
const orgUnitId = (payload.org_unit_id as string) || device.org_unit_id;
const businessDate = payload.business_date as string;
const clientRevision = typeof payload.revision === 'number' ? payload.revision : 1;
const startedAt = payload.started_at as string | null | undefined;
const endedAt = payload.ended_at as string | null | undefined;
const workedMinutes = typeof payload.worked_minutes === 'number' ? payload.worked_minutes : 0;
const sessionStatus = (payload.status as string) || 'open';
if (!employeeId || !orgUnitId || !businessDate) {
return {
idempotency_key: cmd.idempotency_key,
command_type: cmd.command_type,
status: 'rejected',
error_code: 'INVALID_WORK_SESSION_PAYLOAD',
error_message: 'employee_id, org_unit_id и business_date обязательны',
};
}
const existingRes = await client.query<{ id: string; revision: number; status: string }>(
`SELECT id, revision, status FROM staff_work_sessions WHERE tenant_id = $1 AND id = $2`,
[tenantId, sessionId],
);
if (existingRes.rows.length > 0) {
const existing = existingRes.rows[0]!;
if (existing.revision !== clientRevision) {
const conflict: SyncConflict = {
conflict_code: 'REVISION_MISMATCH',
entity_type: 'work_session',
entity_id: sessionId,
expected_revision: clientRevision,
actual_revision: existing.revision,
safe_description: 'Ревизия рабочей смены на сервере изменилась',
client_action: 'FETCH_LATEST_AND_RETRY',
};
return {
idempotency_key: cmd.idempotency_key,
command_type: cmd.command_type,
status: 'conflict',
conflict,
};
}
const nextRev = existing.revision + 1;
await client.query(
`UPDATE staff_work_sessions
SET started_at = $1, ended_at = $2, worked_minutes = $3, status = $4, revision = $5, updated_at = now()
WHERE tenant_id = $6 AND id = $7`,
[startedAt ?? null, endedAt ?? null, workedMinutes, sessionStatus, nextRev, tenantId, sessionId],
);
const safePayload: SafeWorkSessionPayload = {
id: sessionId,
employee_id: employeeId,
org_unit_id: orgUnitId,
business_date: businessDate,
started_at: startedAt ?? null,
ended_at: endedAt ?? null,
worked_minutes: workedMinutes,
status: sessionStatus,
revision: nextRev,
};
await this.recordChange(client, tenantId, 'work_session', sessionId, 'upsert', nextRev, safePayload);
return {
idempotency_key: cmd.idempotency_key,
command_type: cmd.command_type,
status: 'applied',
result: { id: sessionId, revision: nextRev, status: 'applied' },
};
}
// New work session insert
const newRev = clientRevision;
await client.query(
`INSERT INTO staff_work_sessions
(id, tenant_id, employee_id, org_unit_id, business_date, started_at, ended_at, worked_minutes, status, revision)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
[sessionId, tenantId, employeeId, orgUnitId, businessDate, startedAt ?? null, endedAt ?? null, workedMinutes, sessionStatus, newRev],
);
const safePayload: SafeWorkSessionPayload = {
id: sessionId,
employee_id: employeeId,
org_unit_id: orgUnitId,
business_date: businessDate,
started_at: startedAt ?? null,
ended_at: endedAt ?? null,
worked_minutes: workedMinutes,
status: sessionStatus,
revision: newRev,
};
await this.recordChange(client, tenantId, 'work_session', sessionId, 'upsert', newRev, safePayload);
return {
idempotency_key: cmd.idempotency_key,
command_type: cmd.command_type,
status: 'applied',
result: { id: sessionId, revision: newRev, status: 'applied' },
};
}
case 'upsert_work_session':
case 'upsert_timesheet_entry': {
const entryId = (payload.id as string) || newUlid();
const employeeId = payload.employee_id as string;
const workDate = payload.work_date as string;
const minutesWorked = typeof payload.minutes_worked === 'number' ? payload.minutes_worked : 0;
const tsStatus = (payload.status as string) || 'draft';
const clientRevision = typeof payload.revision === 'number' ? payload.revision : 1;
if (!employeeId || !workDate) {
return {
idempotency_key: cmd.idempotency_key,
command_type: cmd.command_type,
status: 'rejected',
error_code: 'INVALID_TIMESHEET_PAYLOAD',
error_message: 'employee_id и work_date обязательны',
};
}
const existingRes = await client.query<{ id: string; revision: number; status: string }>(
`SELECT id, revision, status FROM staff_timesheet_entries WHERE tenant_id = $1 AND id = $2`,
[tenantId, entryId],
);
if (existingRes.rows.length > 0) {
const existing = existingRes.rows[0]!;
if (existing.revision !== clientRevision) {
const conflict: SyncConflict = {
conflict_code: 'REVISION_MISMATCH',
entity_type: 'timesheet_entry',
entity_id: entryId,
expected_revision: clientRevision,
actual_revision: existing.revision,
safe_description: 'Ревизия записи табеля на сервере изменилась',
client_action: 'FETCH_LATEST_AND_RETRY',
};
return {
idempotency_key: cmd.idempotency_key,
command_type: cmd.command_type,
status: 'conflict',
conflict,
};
}
const nextRev = existing.revision + 1;
await client.query(
`UPDATE staff_timesheet_entries
SET minutes_worked = $1, status = $2, revision = $3, updated_at = now()
WHERE tenant_id = $4 AND id = $5`,
[minutesWorked, tsStatus, nextRev, tenantId, entryId],
);
const safePayload: SafeTimesheetEntryPayload = {
id: entryId,
employee_id: employeeId,
work_date: workDate,
minutes_worked: minutesWorked,
status: tsStatus,
revision: nextRev,
};
await this.recordChange(client, tenantId, 'timesheet_entry', entryId, 'upsert', nextRev, safePayload);
return {
idempotency_key: cmd.idempotency_key,
command_type: cmd.command_type,
status: 'applied',
result: { id: entryId, revision: nextRev, status: 'applied' },
};
}
const newRev = clientRevision;
await client.query(
`INSERT INTO staff_timesheet_entries
(id, tenant_id, employee_id, work_date, minutes_worked, status, revision, updated_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[entryId, tenantId, employeeId, workDate, minutesWorked, tsStatus, newRev, device.id],
);
const safePayload: SafeTimesheetEntryPayload = {
id: entryId,
employee_id: employeeId,
work_date: workDate,
minutes_worked: minutesWorked,
status: tsStatus,
revision: newRev,
};
await this.recordChange(client, tenantId, 'timesheet_entry', entryId, 'upsert', newRev, safePayload);
return {
idempotency_key: cmd.idempotency_key,
command_type: cmd.command_type,
status: 'applied',
result: { id: entryId, revision: newRev, status: 'applied' },
status: 'rejected',
error_code: 'COMMAND_NOT_SUPPORTED',
error_message:
'Команда не поддерживается: прямое создание или изменение смен и табелей с устройств запрещено',
};
}
@ -1052,17 +905,23 @@ export class SyncService {
);
}
async cleanupRetention(retentionDays = 30): Promise<{ purged_change_logs: number; purged_commands: number }> {
return this.database.tenantTransaction(this.systemActorId, this.systemActorId, async (client) => {
const res = await client.query<{ purged_change_logs: string; purged_commands: string }>(
`SELECT purged_change_logs, purged_commands FROM staff_sync_cleanup_retention($1)`,
[retentionDays],
);
const row = res.rows[0];
return {
purged_change_logs: Number(row?.purged_change_logs ?? 0),
purged_commands: Number(row?.purged_commands ?? 0),
};
});
async cleanupRetention(
retentionDays = 30,
): Promise<{ purged_change_logs: number; purged_commands: number }> {
return this.database.tenantTransaction(
this.systemActorId,
this.systemActorId,
async (client) => {
const res = await client.query<{ purged_change_logs: string; purged_commands: string }>(
`SELECT purged_change_logs, purged_commands FROM staff_sync_cleanup_retention($1)`,
[retentionDays],
);
const row = res.rows[0];
return {
purged_change_logs: Number(row?.purged_change_logs ?? 0),
purged_commands: Number(row?.purged_commands ?? 0),
};
},
);
}
}

View file

@ -176,16 +176,12 @@ export async function registerPortalRoutes(
return reply.notFound('Сотрудник для текущего пользователя не найден.');
}
const saved = await pushDispatcher.saveSubscription(
context.tenantId,
employee.id,
{
endpoint: request.body.endpoint,
p256dh: request.body.keys.p256dh,
auth: request.body.keys.auth,
user_agent: request.body.user_agent,
},
);
const saved = await pushDispatcher.saveSubscription(context.tenantId, employee.id, {
endpoint: request.body.endpoint,
p256dh: request.body.keys.p256dh,
auth: request.body.keys.auth,
user_agent: request.body.user_agent,
});
return reply.code(201).send(saved);
},
);
@ -224,11 +220,7 @@ export async function registerPortalRoutes(
if (!employee) {
return reply.notFound('Сотрудник для текущего пользователя не найден.');
}
await pushDispatcher.deleteSubscription(
context.tenantId,
employee.id,
request.body.endpoint,
);
await pushDispatcher.deleteSubscription(context.tenantId, employee.id, request.body.endpoint);
return reply.code(204).send();
},
);

View file

@ -4,11 +4,7 @@ import { parseUlid, type Ulid } from './core/ids.js';
import type { Database } from './infrastructure/database.js';
import { PlatformIdentityAdapter, StandaloneIdentityAdapter } from './adapters/identity.js';
import type { IdentityPort } from './core/ports.js';
import {
SyncAuthError,
SyncService,
SyncValidationError,
} from './domain/sync/service.js';
import { SyncAuthError, SyncService, SyncValidationError } from './domain/sync/service.js';
import { InvalidCursorError } from './domain/sync/cursor.js';
import type { SyncCommand } from './domain/sync/types.js';
@ -97,14 +93,18 @@ export async function registerSyncRoutes(
!context.permissions.has('staff:attendance:manage') &&
!context.permissions.has('staff:sync:manage')
) {
throw request.server.httpErrors.forbidden('Недостаточно прав для управления устройствами синхронизации');
throw request.server.httpErrors.forbidden(
'Недостаточно прав для управления устройствами синхронизации',
);
}
return context;
} catch (error: unknown) {
if (typeof error === 'object' && error !== null && 'statusCode' in error) {
throw error;
}
throw request.server.httpErrors.unauthorized('Требуется действующий ключ доступа руководителя');
throw request.server.httpErrors.unauthorized(
'Требуется действующий ключ доступа руководителя',
);
}
};
@ -169,7 +169,11 @@ export async function registerSyncRoutes(
},
async (request, reply) => {
const manager = await requireManagerContext(request);
const result = await syncService.registerDevice(manager.tenantId, manager.actorId, request.body);
const result = await syncService.registerDevice(
manager.tenantId,
manager.actorId,
request.body,
);
return reply.code(201).send(result);
},
);
@ -257,7 +261,8 @@ export async function registerSyncRoutes(
{
schema: {
tags: ['Sync'],
summary: 'Получение изменений после указанного курсора (или начальный bootstrap при пустом курсоре)',
summary:
'Получение изменений после указанного курсора (или начальный bootstrap при пустом курсоре)',
body: {
type: 'object',
additionalProperties: false,
@ -279,7 +284,15 @@ export async function registerSyncRoutes(
type: 'array',
items: {
type: 'object',
required: ['cursor', 'entity_type', 'entity_id', 'operation', 'revision', 'payload', 'created_at'],
required: [
'cursor',
'entity_type',
'entity_id',
'operation',
'revision',
'payload',
'created_at',
],
properties: {
cursor: { type: 'string' },
entity_type: { type: 'string' },
@ -330,7 +343,14 @@ export async function registerSyncRoutes(
maxItems: 100,
items: {
type: 'object',
required: ['command_type', 'schema_version', 'idempotency_key', 'correlation_id', 'created_at', 'payload'],
required: [
'command_type',
'schema_version',
'idempotency_key',
'correlation_id',
'created_at',
'payload',
],
properties: {
command_type: { type: 'string' },
schema_version: { type: 'integer', default: 1 },
@ -387,7 +407,11 @@ export async function registerSyncRoutes(
const token = extractBearerToken(request);
const tenantId = resolveTenantId(request);
const device = await syncService.authenticateDevice(token, tenantId);
const results = await syncService.push(device.tenant_id, device, request.body?.commands ?? []);
const results = await syncService.push(
device.tenant_id,
device,
request.body?.commands ?? [],
);
return reply.code(200).send({ results });
} catch (error) {
handleSyncError(error, request);
@ -428,7 +452,11 @@ export async function registerSyncRoutes(
const token = extractBearerToken(request);
const tenantId = resolveTenantId(request);
const device = await syncService.authenticateDevice(token, tenantId);
const result = await syncService.acknowledge(device.tenant_id, device.id, request.body.cursor);
const result = await syncService.acknowledge(
device.tenant_id,
device.id,
request.body.cursor,
);
return reply.code(200).send(result);
} catch (error) {
handleSyncError(error, request);

View file

@ -29,7 +29,9 @@ describeDatabase('PostgreSQL tenant isolation', () => {
afterAll(async () => {
await database.tenantTransaction(tenantA, actorA, async (client) => {
await client.query('DELETE FROM staff_sync_device_checkpoints WHERE tenant_id = $1', [tenantA]);
await client.query('DELETE FROM staff_sync_device_checkpoints WHERE tenant_id = $1', [
tenantA,
]);
await client.query('DELETE FROM staff_sync_command_inbox WHERE tenant_id = $1', [tenantA]);
await client.query('DELETE FROM staff_sync_change_log WHERE tenant_id = $1', [tenantA]);
await client.query('DELETE FROM staff_sync_devices WHERE tenant_id = $1', [tenantA]);
@ -253,7 +255,9 @@ describeDatabase('PostgreSQL tenant isolation', () => {
});
const cpVisibleToB = await database.tenantTransaction(tenantB, actorB, (client) =>
client.query('SELECT device_id FROM staff_sync_device_checkpoints WHERE device_id = $1', [deviceIdA]),
client.query('SELECT device_id FROM staff_sync_device_checkpoints WHERE device_id = $1', [
deviceIdA,
]),
);
expect(cpVisibleToB.rowCount).toBe(0);

View file

@ -29,12 +29,12 @@ describe('runtime modes', () => {
}).PLATFORM_GATEWAY_URL,
).toBeUndefined());
it('requires VAPID keys to be configured together if provided', () => {
expect(() =>
loadConfig({ ...base, VAPID_PUBLIC_KEY: 'pubkey' }),
).toThrow(/VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY must be configured together/);
expect(() =>
loadConfig({ ...base, VAPID_PRIVATE_KEY: 'privkey' }),
).toThrow(/VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY must be configured together/);
expect(() => loadConfig({ ...base, VAPID_PUBLIC_KEY: 'pubkey' })).toThrow(
/VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY must be configured together/,
);
expect(() => loadConfig({ ...base, VAPID_PRIVATE_KEY: 'privkey' })).toThrow(
/VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY must be configured together/,
);
const config = loadConfig({
...base,
VAPID_PUBLIC_KEY: 'pubkey',
@ -46,4 +46,3 @@ describe('runtime modes', () => {
expect(config.VAPID_SUBJECT).toBe('mailto:ops@krona.local');
});
});

View file

@ -42,9 +42,7 @@ function decryptPushPayload(
recipient.publicKey,
senderPublicKey,
]);
const prk = Buffer.from(
crypto.hkdfSync('sha256', sharedSecret, recipient.auth, keyInfo, 32),
);
const prk = Buffer.from(crypto.hkdfSync('sha256', sharedSecret, recipient.auth, keyInfo, 32));
const cekInfo = Buffer.from('Content-Encoding: aes128gcm\0', 'utf8');
const cek = Buffer.from(crypto.hkdfSync('sha256', prk, salt, cekInfo, 16));
@ -79,12 +77,12 @@ describe('WebPush VAPID key management', () => {
});
it('rejects mismatched VAPID configuration when only one key is set', () => {
expect(() =>
resolveVapidKeys({ VAPID_PUBLIC_KEY: 'only-pub' }),
).toThrow(/must be configured together/);
expect(() =>
resolveVapidKeys({ VAPID_PRIVATE_KEY: 'only-priv' }),
).toThrow(/must be configured together/);
expect(() => resolveVapidKeys({ VAPID_PUBLIC_KEY: 'only-pub' })).toThrow(
/must be configured together/,
);
expect(() => resolveVapidKeys({ VAPID_PRIVATE_KEY: 'only-priv' })).toThrow(
/must be configured together/,
);
});
});
@ -172,9 +170,9 @@ describe('WebPush RFC 8291 / RFC 8188 AES-128-GCM encryption', () => {
});
it('rejects invalid recipient keys with clear error', () => {
expect(() =>
encryptPushPayload('test', { p256dh: 'invalid', auth: 'invalid' }),
).toThrow(/Invalid recipient/);
expect(() => encryptPushPayload('test', { p256dh: 'invalid', auth: 'invalid' })).toThrow(
/Invalid recipient/,
);
});
});
@ -377,7 +375,11 @@ describe('PushDispatcher idempotent outbox handling', () => {
expect(mockFetch).toHaveBeenCalledTimes(1);
// Push delivery is tracked independently from platform event publication.
expect(queries.some((q) => q.sql.includes('staff_notification_deliveries') && q.sql.includes('delivered'))).toBe(true);
expect(
queries.some(
(q) => q.sql.includes('staff_notification_deliveries') && q.sql.includes('delivered'),
),
).toBe(true);
expect(queries.some((q) => q.sql.includes('UPDATE staff_outbox'))).toBe(false);
});

View file

@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest';
import { portalCssEnhanced, portalHtml, portalJsEnhanced, portalManifest } from '../../src/portal-ui.js';
import {
portalCssEnhanced,
portalHtml,
portalJsEnhanced,
portalManifest,
} from '../../src/portal-ui.js';
describe('Krona Staff workspace shell', () => {
it('exposes the operational workspace sections with deep routes', () => {
@ -33,6 +38,9 @@ describe('Krona Staff workspace shell', () => {
expect(portalJsEnhanced).toContain("'/staff/attendance':'attendance-view'");
expect(portalJsEnhanced).toContain("'/staff/reports':'reports-view'");
expect(portalJsEnhanced).toContain("window.addEventListener('popstate'");
expect(JSON.parse(portalManifest)).toMatchObject({ start_url: '/staff/dashboard', scope: '/staff' });
expect(JSON.parse(portalManifest)).toMatchObject({
start_url: '/staff/dashboard',
scope: '/staff',
});
});
});

View file

@ -8,7 +8,9 @@ import type { SyncCommand } from '../../src/domain/sync/types.js';
import { buildApp } from '../../src/app.js';
import { loadConfig } from '../../src/config.js';
function createMockDb(queryHandler?: (sql: string, params: unknown[]) => { rows: unknown[]; rowCount?: number }) {
function createMockDb(
queryHandler?: (sql: string, params: unknown[]) => { rows: unknown[]; rowCount?: number },
) {
const queries: Array<{ sql: string; params: unknown[] }> = [];
const query = vi.fn(async (sql: string, params: unknown[] = []) => {
queries.push({ sql, params });
@ -105,7 +107,9 @@ describe('Sync Device Authentication & Tenant Isolation', () => {
expect(device.tenant_id).toBe(tenantA);
expect(device.status).toBe('active');
expect(
queries.some((q) => q.sql.includes('UPDATE staff_sync_devices') && q.sql.includes('last_seen_at')),
queries.some(
(q) => q.sql.includes('UPDATE staff_sync_devices') && q.sql.includes('last_seen_at'),
),
).toBe(true);
});
@ -187,7 +191,9 @@ describe('Sync Device Authentication & Tenant Isolation', () => {
});
const service = new SyncService(database, systemActorId);
await expect(service.authenticateDevice(rawToken, tenantA, 'attendance:sync')).rejects.toMatchObject({
await expect(
service.authenticateDevice(rawToken, tenantA, 'attendance:sync'),
).rejects.toMatchObject({
code: 'SCOPE_INSUFFICIENT',
statusCode: 403,
});
@ -297,25 +303,15 @@ describe('Sync Push & Idempotency', () => {
expect(queries.some((q) => q.sql.includes('INSERT INTO staff_attendance_events'))).toBe(false);
});
it('returns structured conflict on revision mismatch for work_session', async () => {
it('rejects upsert_work_session command with COMMAND_NOT_SUPPORTED', async () => {
const sessionId = newUlid();
const { database } = createMockDb((sql) => {
if (sql.includes('SELECT status, result, conflict')) {
return { rows: [] }; // Not in inbox
}
if (sql.includes('SELECT id, revision, status FROM staff_work_sessions')) {
// Server has revision 3
return { rows: [{ id: sessionId, revision: 3, status: 'confirmed' }] };
}
return { rows: [] };
});
const { database } = createMockDb();
const service = new SyncService(database, systemActorId);
const command: SyncCommand = {
command_type: 'upsert_work_session',
schema_version: 1,
idempotency_key: 'cmd-session-conflict',
idempotency_key: 'cmd-session-reject',
correlation_id: newUlid(),
created_at: new Date().toISOString(),
payload: {
@ -323,43 +319,28 @@ describe('Sync Push & Idempotency', () => {
employee_id: employeeId,
org_unit_id: newUlid(),
business_date: '2026-09-26',
revision: 2, // Client expects revision 2, but server has 3
revision: 1,
worked_minutes: 480,
status: 'confirmed',
},
};
const results = await service.push(tenantId, testDevice, [command]);
expect(results).toHaveLength(1);
expect(results[0]?.status).toBe('conflict');
expect(results[0]?.conflict).toEqual({
conflict_code: 'REVISION_MISMATCH',
entity_type: 'work_session',
entity_id: sessionId,
expected_revision: 2,
actual_revision: 3,
safe_description: 'Ревизия рабочей смены на сервере изменилась',
client_action: 'FETCH_LATEST_AND_RETRY',
});
expect(results[0]?.status).toBe('rejected');
expect(results[0]?.error_code).toBe('COMMAND_NOT_SUPPORTED');
expect(results[0]?.error_message).toContain('Команда не поддерживается');
});
it('returns structured conflict on revision mismatch for timesheet_entry', async () => {
it('rejects upsert_timesheet_entry command with COMMAND_NOT_SUPPORTED', async () => {
const entryId = newUlid();
const { database } = createMockDb((sql) => {
if (sql.includes('SELECT status, result, conflict')) {
return { rows: [] };
}
if (sql.includes('SELECT id, revision, status FROM staff_timesheet_entries')) {
return { rows: [{ id: entryId, revision: 4, status: 'approved' }] };
}
return { rows: [] };
});
const { database } = createMockDb();
const service = new SyncService(database, systemActorId);
const command: SyncCommand = {
command_type: 'upsert_timesheet_entry',
schema_version: 1,
idempotency_key: 'cmd-ts-conflict',
idempotency_key: 'cmd-ts-reject',
correlation_id: newUlid(),
created_at: new Date().toISOString(),
payload: {
@ -367,22 +348,92 @@ describe('Sync Push & Idempotency', () => {
employee_id: employeeId,
work_date: '2026-09-26',
minutes_worked: 500,
revision: 1, // Mismatch: expects 1, server has 4
revision: 1,
},
};
const results = await service.push(tenantId, testDevice, [command]);
expect(results).toHaveLength(1);
expect(results[0]?.status).toBe('conflict');
expect(results[0]?.conflict).toEqual({
conflict_code: 'REVISION_MISMATCH',
entity_type: 'timesheet_entry',
entity_id: entryId,
expected_revision: 1,
actual_revision: 4,
safe_description: 'Ревизия записи табеля на сервере изменилась',
client_action: 'FETCH_LATEST_AND_RETRY',
});
expect(results[0]?.status).toBe('rejected');
expect(results[0]?.error_code).toBe('COMMAND_NOT_SUPPORTED');
expect(results[0]?.error_message).toContain('Команда не поддерживается');
});
it('does not write to staff_work_sessions, staff_timesheet_entries, or staff_sync_change_log on rejected shift commands', async () => {
const { database, queries } = createMockDb();
const service = new SyncService(database, systemActorId);
const commands: SyncCommand[] = [
{
command_type: 'upsert_work_session',
schema_version: 1,
idempotency_key: 'cmd-session-no-write',
correlation_id: newUlid(),
created_at: new Date().toISOString(),
payload: {
id: newUlid(),
employee_id: employeeId,
org_unit_id: newUlid(),
business_date: '2026-09-26',
status: 'confirmed',
worked_minutes: 2880,
},
},
{
command_type: 'upsert_timesheet_entry',
schema_version: 1,
idempotency_key: 'cmd-ts-no-write',
correlation_id: newUlid(),
created_at: new Date().toISOString(),
payload: {
id: newUlid(),
employee_id: employeeId,
work_date: '2026-09-26',
minutes_worked: 500,
},
},
];
const results = await service.push(tenantId, testDevice, commands);
expect(results).toHaveLength(2);
expect(results.every((r) => r.status === 'rejected')).toBe(true);
// Verify nothing is written to staff_work_sessions
expect(
queries.some(
(q) =>
q.sql.toLowerCase().includes('staff_work_sessions') &&
(q.sql.toUpperCase().includes('INSERT') || q.sql.toUpperCase().includes('UPDATE')),
),
).toBe(false);
// Verify nothing is written to staff_timesheet_entries
expect(
queries.some(
(q) =>
q.sql.toLowerCase().includes('staff_timesheet_entries') &&
(q.sql.toUpperCase().includes('INSERT') || q.sql.toUpperCase().includes('UPDATE')),
),
).toBe(false);
// Verify nothing is written to staff_sync_change_log
expect(
queries.some(
(q) =>
q.sql.toLowerCase().includes('staff_sync_change_log') &&
(q.sql.toUpperCase().includes('INSERT') || q.sql.toUpperCase().includes('UPDATE')),
),
).toBe(false);
// Verify commands ARE saved to inbox with status 'rejected'
const inboxInserts = queries.filter((q) =>
q.sql.includes('INSERT INTO staff_sync_command_inbox'),
);
expect(inboxInserts).toHaveLength(2);
for (const insert of inboxInserts) {
expect(insert.params).toContain('rejected');
expect(insert.params).toContain('COMMAND_NOT_SUPPORTED');
}
});
it('enforces batch size limits and payload size limits', async () => {

View file

@ -61,8 +61,12 @@ describe('payroll foundation migration', () => {
});
it('enables and forces RLS with a tenant policy for every listed payroll table', () => {
expect(sql).toContain("EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', target_table)");
expect(sql).toContain("EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', target_table)");
expect(sql).toContain(
"EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', target_table)",
);
expect(sql).toContain(
"EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', target_table)",
);
expect(sql).toContain("target_table || '_tenant_isolation', target_table");
});
@ -97,9 +101,13 @@ describe('payroll foundation migration', () => {
it('enforces tenant isolation and RLS on staff_payroll_ledger with select and insert policies', () => {
expect(sql).toContain('ALTER TABLE staff_payroll_ledger ENABLE ROW LEVEL SECURITY');
expect(sql).toContain('ALTER TABLE staff_payroll_ledger FORCE ROW LEVEL SECURITY');
expect(sql).toContain('CREATE POLICY staff_payroll_ledger_tenant_select ON staff_payroll_ledger');
expect(sql).toContain(
'CREATE POLICY staff_payroll_ledger_tenant_select ON staff_payroll_ledger',
);
expect(sql).toContain("FOR SELECT USING (tenant_id = current_setting('app.tenant_id', true))");
expect(sql).toContain('CREATE POLICY staff_payroll_ledger_tenant_insert ON staff_payroll_ledger');
expect(sql).toContain(
'CREATE POLICY staff_payroll_ledger_tenant_insert ON staff_payroll_ledger',
);
expect(sql).toContain("tenant_id = current_setting('app.tenant_id', true)");
expect(sql).toContain("created_by = current_setting('app.user_id', true)");
});
@ -165,9 +173,7 @@ describe('employee push notifications migration', () => {
expect(sql).toContain(
'FOREIGN KEY (tenant_id, employee_id) REFERENCES staff_employees (tenant_id, id) ON DELETE CASCADE',
);
expect(sql).toContain(
'FOREIGN KEY (outbox_id) REFERENCES staff_outbox (id) ON DELETE CASCADE',
);
expect(sql).toContain('FOREIGN KEY (outbox_id) REFERENCES staff_outbox (id) ON DELETE CASCADE');
});
it('grants required operations on both push tables to krona_staff_app', () => {
@ -217,17 +223,27 @@ describe('offline sync backend migration', () => {
});
it('grants required operations on all sync tables and sequence to krona_staff_app', () => {
expect(sql).toContain('GRANT SELECT, INSERT, UPDATE, DELETE ON staff_sync_devices TO krona_staff_app;');
expect(sql).toContain('GRANT SELECT, INSERT, UPDATE, DELETE ON staff_sync_change_log TO krona_staff_app;');
expect(sql).toContain('GRANT USAGE, SELECT ON SEQUENCE staff_sync_change_log_cursor_seq_seq TO krona_staff_app;');
expect(sql).toContain('GRANT SELECT, INSERT, UPDATE, DELETE ON staff_sync_command_inbox TO krona_staff_app;');
expect(sql).toContain('GRANT SELECT, INSERT, UPDATE, DELETE ON staff_sync_device_checkpoints TO krona_staff_app;');
expect(sql).toContain(
'GRANT SELECT, INSERT, UPDATE, DELETE ON staff_sync_devices TO krona_staff_app;',
);
expect(sql).toContain(
'GRANT SELECT, INSERT, UPDATE, DELETE ON staff_sync_change_log TO krona_staff_app;',
);
expect(sql).toContain(
'GRANT USAGE, SELECT ON SEQUENCE staff_sync_change_log_cursor_seq_seq TO krona_staff_app;',
);
expect(sql).toContain(
'GRANT SELECT, INSERT, UPDATE, DELETE ON staff_sync_command_inbox TO krona_staff_app;',
);
expect(sql).toContain(
'GRANT SELECT, INSERT, UPDATE, DELETE ON staff_sync_device_checkpoints TO krona_staff_app;',
);
});
it('declares and grants retention cleanup function', () => {
expect(sql).toContain('CREATE OR REPLACE FUNCTION staff_sync_cleanup_retention');
expect(sql).toContain('GRANT EXECUTE ON FUNCTION staff_sync_cleanup_retention(INTEGER) TO krona_staff_app;');
expect(sql).toContain(
'GRANT EXECUTE ON FUNCTION staff_sync_cleanup_retention(INTEGER) TO krona_staff_app;',
);
});
});

View file

@ -8,12 +8,15 @@ const options = {
describe('attendance work-session reconciliation', () => {
it('sums several in/out intervals without inventing time between them', () => {
const result = reconcileWorkSession([
{ id: '1', type: 'check_in', occurredAt: '2026-09-24T01:00:00.000Z' },
{ id: '2', type: 'check_out', occurredAt: '2026-09-24T05:00:00.000Z' },
{ id: '3', type: 'check_in', occurredAt: '2026-09-24T06:00:00.000Z' },
{ id: '4', type: 'check_out', occurredAt: '2026-09-24T10:30:00.000Z' },
], options);
const result = reconcileWorkSession(
[
{ id: '1', type: 'check_in', occurredAt: '2026-09-24T01:00:00.000Z' },
{ id: '2', type: 'check_out', occurredAt: '2026-09-24T05:00:00.000Z' },
{ id: '3', type: 'check_in', occurredAt: '2026-09-24T06:00:00.000Z' },
{ id: '4', type: 'check_out', occurredAt: '2026-09-24T10:30:00.000Z' },
],
options,
);
expect(result).toMatchObject({
startedAt: '2026-09-24T01:00:00.000Z',
@ -25,28 +28,38 @@ describe('attendance work-session reconciliation', () => {
});
it('keeps the first check-in and flags a repeated check-in', () => {
const result = reconcileWorkSession([
{ id: '1', type: 'check_in', occurredAt: '2026-09-24T01:00:00.000Z' },
{ id: '2', type: 'check_in', occurredAt: '2026-09-24T01:05:00.000Z' },
{ id: '3', type: 'check_out', occurredAt: '2026-09-24T09:00:00.000Z' },
], options);
const result = reconcileWorkSession(
[
{ id: '1', type: 'check_in', occurredAt: '2026-09-24T01:00:00.000Z' },
{ id: '2', type: 'check_in', occurredAt: '2026-09-24T01:05:00.000Z' },
{ id: '3', type: 'check_out', occurredAt: '2026-09-24T09:00:00.000Z' },
],
options,
);
expect(result?.workedMinutes).toBe(480);
expect(result?.anomalies).toEqual(['repeated_check_in']);
});
it('keeps a recent unfinished shift open', () => {
const result = reconcileWorkSession([
{ id: '1', type: 'check_in', occurredAt: '2026-09-24T12:00:00.000Z' },
], options);
const result = reconcileWorkSession(
[{ id: '1', type: 'check_in', occurredAt: '2026-09-24T12:00:00.000Z' }],
options,
);
expect(result).toMatchObject({ status: 'open', endedAt: null, workedMinutes: 0, anomalies: [] });
expect(result).toMatchObject({
status: 'open',
endedAt: null,
workedMinutes: 0,
anomalies: [],
});
});
it('flags an old unfinished shift without estimating payable minutes', () => {
const result = reconcileWorkSession([
{ id: '1', type: 'check_in', occurredAt: '2026-09-24T01:00:00.000Z' },
], options);
const result = reconcileWorkSession(
[{ id: '1', type: 'check_in', occurredAt: '2026-09-24T01:00:00.000Z' }],
options,
);
expect(result).toMatchObject({
status: 'pending_review',
@ -57,9 +70,10 @@ describe('attendance work-session reconciliation', () => {
});
it('auto-closes only after the scheduler cutoff and leaves an audit-worthy anomaly', () => {
const result = reconcileWorkSession([
{ id: '1', type: 'check_in', occurredAt: '2026-09-23T12:00:00.000Z' },
], { ...options, automaticCloseAt: '2026-09-23T19:00:00.000Z' });
const result = reconcileWorkSession(
[{ id: '1', type: 'check_in', occurredAt: '2026-09-23T12:00:00.000Z' }],
{ ...options, automaticCloseAt: '2026-09-23T19:00:00.000Z' },
);
expect(result).toMatchObject({
endedAt: '2026-09-23T19:00:00.000Z',
@ -70,9 +84,10 @@ describe('attendance work-session reconciliation', () => {
});
it('records a check-out without check-in as an anomaly with zero minutes', () => {
const result = reconcileWorkSession([
{ id: '1', type: 'check_out', occurredAt: '2026-09-24T09:00:00.000Z' },
], options);
const result = reconcileWorkSession(
[{ id: '1', type: 'check_out', occurredAt: '2026-09-24T09:00:00.000Z' }],
options,
);
expect(result).toMatchObject({
startedAt: null,
@ -84,10 +99,11 @@ describe('attendance work-session reconciliation', () => {
});
it('rejects invalid timestamps and thresholds', () => {
expect(() => reconcileWorkSession([
{ id: '1', type: 'check_in', occurredAt: 'not-a-date' },
], options)).toThrow('event 1 must be an ISO date-time');
expect(() => reconcileWorkSession([], { ...options, openShiftThresholdMinutes: 0 }))
.toThrow('openShiftThresholdMinutes must be a positive integer');
expect(() =>
reconcileWorkSession([{ id: '1', type: 'check_in', occurredAt: 'not-a-date' }], options),
).toThrow('event 1 must be an ISO date-time');
expect(() => reconcileWorkSession([], { ...options, openShiftThresholdMinutes: 0 })).toThrow(
'openShiftThresholdMinutes must be a positive integer',
);
});
});