style: rebuild formatting without sync behavior changes

Recreate the formatting baseline from f5ac179 using only Prettier output. Leave sync and tenant-isolation files byte-identical to the base so Antigravity and Grok retain ownership. Normalized comparison confirms all 19 changed files are formatting-only; npm run check passes. Full format check retains five upstream warnings in excluded files.
This commit is contained in:
Ochenstarik 2026-09-26 16:09:55 +07:00
parent f5ac179623
commit a05777c59f
19 changed files with 307 additions and 156 deletions

View file

@ -691,7 +691,18 @@ paths:
properties: properties:
device: device:
type: object 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: properties:
id: { $ref: '#/components/schemas/Ulid' } id: { $ref: '#/components/schemas/Ulid' }
tenant_id: { $ref: '#/components/schemas/Ulid' } tenant_id: { $ref: '#/components/schemas/Ulid' }
@ -803,7 +814,8 @@ paths:
type: array type: array
items: items:
type: object 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: properties:
cursor: { type: string } cursor: { type: string }
entity_type: { type: string } entity_type: { type: string }
@ -837,7 +849,15 @@ paths:
maxItems: 100 maxItems: 100
items: items:
type: object 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: properties:
command_type: { type: string } command_type: { type: string }
schema_version: { type: integer } schema_version: { type: integer }
@ -874,7 +894,16 @@ paths:
conflict: conflict:
oneOf: oneOf:
- type: object - 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: properties:
conflict_code: { type: string, enum: [REVISION_MISMATCH] } conflict_code: { type: string, enum: [REVISION_MISMATCH] }
entity_type: { type: string } entity_type: { type: string }

View file

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

View file

@ -34,7 +34,11 @@ services:
tmpfs: [/tmp] tmpfs: [/tmp]
security_opt: ['no-new-privileges:true'] security_opt: ['no-new-privileges:true']
healthcheck: 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 interval: 30s
timeout: 3s timeout: 3s
retries: 3 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)**: 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.** > **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: ### 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. 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. 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: 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 ### 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: 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` - System driver directory: `%WINDIR%\System32\drivers\slk20r.sys`, `zkfp.sys`, `zkfpos.sys`, `ZKUSB.sys`
- Windows Service Registry keys: - 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. - 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 ### 3.2 Terminal Runtime Flow
1. If the terminal starts without the driver installed: 1. If the terminal starts without the driver installed:
- The scanner state transitions to `ScannerState.DriverError` or `ScannerState.Disconnected`. - 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 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. - 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. 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 ## 4. Enterprise Mass Deployment Guide
For automated deployment via Microsoft Intune, Group Policy (GPO), or SCCM: For automated deployment via Microsoft Intune, Group Policy (GPO), or SCCM:
1. Download the official ZKTeco SLK20R Driver package (`ZKFinger SDK / Driver Setup`). 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`). 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: 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. 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: ### 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. 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: 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. - **`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 ## 2. Session 0 Investigation & Architectural Analysis
### The Problem ### 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`). 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: #### Empirical Observations:
- In Windows Service Session 0, calling `zkfp2.Init()` returns `0` (ZKFP_ERR_OK). - 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. - 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). - Attempting to pass handle `0` to `zkfp2.OpenDevice(0)` returns error code `-1` (device not found).
#### Root Cause: #### 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`). 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: #### 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: 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 | | Interactive User Session |
@ -69,14 +75,18 @@ Instead of forcing unreliable Session 0 USB hooks or interactive service workaro
## 3. SDK Device Deduplication Analysis ## 3. SDK Device Deduplication Analysis
### The Problem ### 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. 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: Opening device index `1` resulted in one of two failure modes:
1. `OpenDevice(1)` returned error `-2` (device already open/busy). 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. 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 ### The Solution: Serial Number Deduplication
`FingerprintScanner.cs` inspects hardware parameters for each enumerated slot: `FingerprintScanner.cs` inspects hardware parameters for each enumerated slot:
```csharp ```csharp
// Query serial number parameter (parameter codes 1101, 1102, 106) // Query serial number parameter (parameter codes 1101, 1102, 106)
byte[] serialBuffer = new byte[256]; 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'); 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. 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 ## 4. USB Disconnection & Hotplug Recovery Testing
### Test Scenario ### Test Scenario
1. Terminal running in kiosk mode with SLK20R connected. 1. Terminal running in kiosk mode with SLK20R connected.
2. Employee clocks in successfully. 2. Employee clocks in successfully.
3. USB cable is physically unplugged or hub power is cycled. 3. USB cable is physically unplugged or hub power is cycled.
### Observed Results ### Observed Results
- The active capture thread in `zkfp2.AcquireFingerprint` returns negative error code `-101` or `-102` (ZKFP_ERR_IO / disconnect). - 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%. - Without reconnect logic, earlier versions hung in a tight error loop, pinning the CPU at 100%.
- **With Production Reconnect Logic:** - **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 ## 5. Performance & Biometric Accuracy Verification
| Metric | Target | Measured Result | Status | | Metric | Target | Measured Result | Status |
|---|---|---|---| | ------------------------------------------------ | -------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------- |
| Finger placement to verification | < 1000 ms | ~420 ms | PASSED | | Finger placement to verification | < 1000 ms | ~420 ms | PASSED |
| 1:N Biometric Matching (100 templates) | < 100 ms | 14 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 | | False Rejection Rate (FRR) at threshold 60 | < 1.5% | 0.8% | PASSED |
| USB Disconnect to UI State Update | < 500 ms | ~120 ms | PASSED | | USB Disconnect to UI State Update | < 500 ms | ~120 ms | PASSED |
| Cable Re-insertion to Active Ready State | < 3000 ms | 2150 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 | | 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 | | 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. Архитектурный контекст и инварианты ## 1. Архитектурный контекст и инварианты
Krona Staff является модулем платформы Krona Business Platform, а не изолированной системой пользователей, прав и ролей: Krona Staff является модулем платформы Krona Business Platform, а не изолированной системой пользователей, прав и ролей:
1. **Единственный источник истины (Single Source of Truth)**: Центральный сервер Krona Staff и Platform Core являются владельцами эталонных данных. Локальные терминалы, планшеты и клиенты являются исполнительными терминалами и не могут становиться вторым источником истины. 1. **Единственный источник истины (Single Source of Truth)**: Центральный сервер Krona Staff и Platform Core являются владельцами эталонных данных. Локальные терминалы, планшеты и клиенты являются исполнительными терминалами и не могут становиться вторым источником истины.
2. **User != Employee**: Учётная запись пользователя платформы (`User`) отделена от карточки сотрудника (`Employee`). 2. **User != Employee**: Учётная запись пользователя платформы (`User`) отделена от карточки сотрудника (`Employee`).
3. **Строгая изоляция арендаторов (Tenant Isolation)**: Все сущности привязаны к `tenant_id` (ULID). Новые таблицы включают и форсируют PostgreSQL Row Level Security (`FORCE ROW LEVEL SECURITY`), гарантируя невозможность утечки данных между организациями даже на уровне прямого SQL. 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. Жизненный цикл устройства и аутентификация
### 2.1. Регистрация устройства (`POST /api/v1/staff/sync/devices`) ### 2.1. Регистрация устройства (`POST /api/v1/staff/sync/devices`)
Регистрация выполняется администратором или руководителем предприятия (требуется сессия руководителя или токен с правом `staff:sync:manage` / `staff:attendance:manage`). Регистрация выполняется администратором или руководителем предприятия (требуется сессия руководителя или токен с правом `staff:sync:manage` / `staff:attendance:manage`).
**Запрос:** **Запрос:**
```json ```json
{ {
"device_code": "TERM-MAIN-01", "device_code": "TERM-MAIN-01",
@ -26,6 +29,7 @@ Krona Staff является модулем платформы Krona Business Pl
``` ```
**Ответ (201 Created):** **Ответ (201 Created):**
```json ```json
{ {
"device": { "device": {
@ -48,11 +52,15 @@ Krona Staff является модулем платформы Krona Business Pl
> Сырой токен `token` возвращается клиенту ровно один раз при регистрации. В базе данных (`staff_sync_devices`) сохраняется исключительно криптографический хэш SHA-256 (`token_hash`) и безопасная маска (`token_hint`). > Сырой токен `token` возвращается клиенту ровно один раз при регистрации. В базе данных (`staff_sync_devices`) сохраняется исключительно криптографический хэш SHA-256 (`token_hash`) и безопасная маска (`token_hint`).
### 2.2. Аутентификация запросов устройства ### 2.2. Аутентификация запросов устройства
Каждый запрос от локального терминала к API синхронизации должен содержать заголовок: Каждый запрос от локального терминала к API синхронизации должен содержать заголовок:
```http ```http
Authorization: Bearer <device_token> Authorization: Bearer <device_token>
``` ```
Сервер: Сервер:
1. Вычисляет `SHA-256(device_token)`. 1. Вычисляет `SHA-256(device_token)`.
2. Извлекает `tenant_id` из префикса токена или заголовка `X-Tenant-ID`. 2. Извлекает `tenant_id` из префикса токена или заголовка `X-Tenant-ID`.
3. Открывает транзакцию с RLS контекстом `database.tenantTransaction(tenantId, actorId, ...)`. 3. Открывает транзакцию с RLS контекстом `database.tenantTransaction(tenantId, actorId, ...)`.
@ -61,9 +69,11 @@ Authorization: Bearer <device_token>
6. Асинхронно обновляет `last_seen_at`. 6. Асинхронно обновляет `last_seen_at`.
### 2.3. Отзыв устройства (`POST /api/v1/staff/sync/devices/:deviceId/revoke`) ### 2.3. Отзыв устройства (`POST /api/v1/staff/sync/devices/:deviceId/revoke`)
При компрометации устройства, утере или списании администратор выполняет отзыв. Статус устройства в БД переводится в `revoked`, фиксируется `revoked_at = now()`. При компрометации устройства, утере или списании администратор выполняет отзыв. Статус устройства в БД переводится в `revoked`, фиксируется `revoked_at = now()`.
Все последующие запросы с токеном отозванного устройства немедленно отклоняются со статусом **403 Forbidden** и кодом ошибки `DEVICE_REVOKED`: Все последующие запросы с токеном отозванного устройства немедленно отклоняются со статусом **403 Forbidden** и кодом ошибки `DEVICE_REVOKED`:
```json ```json
{ {
"statusCode": 403, "statusCode": 403,
@ -77,28 +87,37 @@ Authorization: Bearer <device_token>
## 3. Монотонный непрозрачный курсор изменений ## 3. Монотонный непрозрачный курсор изменений
### 3.1. Формат курсора ### 3.1. Формат курсора
Курсор представляет собой непрозрачную строку вида: Курсор представляет собой непрозрачную строку вида:
```text ```text
cur_<sequence>_<signature_hash> cur_<sequence>_<signature_hash>
``` ```
Например: `cur_105_7a9f8b2c1d`. Например: `cur_105_7a9f8b2c1d`.
- `<sequence>`: Монотонный идентификатор последовательности изменений в таблице `staff_sync_change_log` (`cursor_seq BIGSERIAL`). - `<sequence>`: Монотонный идентификатор последовательности изменений в таблице `staff_sync_change_log` (`cursor_seq BIGSERIAL`).
- `<signature_hash>`: Криптографическая контрольная подпись от арендатора, последовательности и серверной соли (`SHA-256(tenant_id + seq + salt)`). - `<signature_hash>`: Криптографическая контрольная подпись от арендатора, последовательности и серверной соли (`SHA-256(tenant_id + seq + salt)`).
Свойства курсора: Свойства курсора:
- **Монотонность**: Гарантирует строго возрастающий порядок чтения журнала изменений. - **Монотонность**: Гарантирует строго возрастающий порядок чтения журнала изменений.
- **Независимость от времени**: Не зависит от системных часов терминала и защищён от сдвигов локального времени. - **Независимость от времени**: Не зависит от системных часов терминала и защищён от сдвигов локального времени.
- **Целостность и изоляция**: Попытка передать повреждённый курсор или курсор другого арендатора немедленно отклоняется с ошибкой `INVALID_CURSOR` (400 Bad Request). - **Целостность и изоляция**: Попытка передать повреждённый курсор или курсор другого арендатора немедленно отклоняется с ошибкой `INVALID_CURSOR` (400 Bad Request).
### 3.2. Начальная загрузка (Bootstrap, `cursor = null`) ### 3.2. Начальная загрузка (Bootstrap, `cursor = null`)
Когда терминал настраивается впервые или выполняет полный сброс локальной базы данных, он отправляет `POST /api/v1/staff/sync/pull` с пустым или отсутствующим курсором (`cursor: null`): Когда терминал настраивается впервые или выполняет полный сброс локальной базы данных, он отправляет `POST /api/v1/staff/sync/pull` с пустым или отсутствующим курсором (`cursor: null`):
1. Сервер формирует снимок текущих **актуальных** данных для поддерживаемых сущностей (`org_unit`, `profession`, `employee`, `work_session`, `timesheet_entry`, `planned_shift`). 1. Сервер формирует снимок текущих **актуальных** данных для поддерживаемых сущностей (`org_unit`, `profession`, `employee`, `work_session`, `timesheet_entry`, `planned_shift`).
2. Для каждой сущности формируется безопасная полезная нагрузка с операцией `upsert`. 2. Для каждой сущности формируется безопасная полезная нагрузка с операцией `upsert`.
3. В ответе возвращается `next_cursor`, соответствующий текущему максимальному `cursor_seq` журнала изменений сервера. 3. В ответе возвращается `next_cursor`, соответствующий текущему максимальному `cursor_seq` журнала изменений сервера.
4. После применения начального снимка терминал фиксирует `next_cursor` и переходит в режим инкрементального получения дельт. 4. После применения начального снимка терминал фиксирует `next_cursor` и переходит в режим инкрементального получения дельт.
### 3.3. Инкрементальная загрузка (Pull, `POST /api/v1/staff/sync/pull`) ### 3.3. Инкрементальная загрузка (Pull, `POST /api/v1/staff/sync/pull`)
Запрос: Запрос:
```json ```json
{ {
"cursor": "cur_105_7a9f8b2c1d", "cursor": "cur_105_7a9f8b2c1d",
@ -106,7 +125,9 @@ cur_<sequence>_<signature_hash>
"entity_types": ["employee", "work_session"] "entity_types": ["employee", "work_session"]
} }
``` ```
Ответ: Ответ:
```json ```json
{ {
"changes": [ "changes": [
@ -133,12 +154,15 @@ cur_<sequence>_<signature_hash>
``` ```
### 3.4. Подтверждение изменений (Ack, `POST /api/v1/staff/sync/ack`) ### 3.4. Подтверждение изменений (Ack, `POST /api/v1/staff/sync/ack`)
После успешного локального сохранения изменений устройство подтверждает обработанный курсор: После успешного локального сохранения изменений устройство подтверждает обработанный курсор:
```json ```json
{ {
"cursor": "cur_106_3c4d5e6f7a" "cursor": "cur_106_3c4d5e6f7a"
} }
``` ```
Сервер сохраняет контрольную точку в таблице `staff_sync_device_checkpoints`. Это позволяет отслеживать отставание устройств и безопасно выполнять очистку журнала изменений. Сервер сохраняет контрольную точку в таблице `staff_sync_device_checkpoints`. Это позволяет отслеживать отставание устройств и безопасно выполнять очистку журнала изменений.
--- ---
@ -146,13 +170,16 @@ cur_<sequence>_<signature_hash>
## 4. Пакетная отправка команд (Push) и идемпотентность ## 4. Пакетная отправка команд (Push) и идемпотентность
### 4.1. Контракт отправки (`POST /api/v1/staff/sync/push`) ### 4.1. Контракт отправки (`POST /api/v1/staff/sync/push`)
Клиент отправляет накопленные в офлайне команды пачками. Клиент отправляет накопленные в офлайне команды пачками.
- Ограничение пакета: от 1 до 100 команд (`BATCH_SIZE_EXCEEDED` при превышении). - Ограничение пакета: от 1 до 100 команд (`BATCH_SIZE_EXCEEDED` при превышении).
- Максимальный размер полезной нагрузки: не более 64 КБ на команду (`PAYLOAD_TOO_LARGE`). - Максимальный размер полезной нагрузки: не более 64 КБ на команду (`PAYLOAD_TOO_LARGE`).
- Максимальный размер HTTP-запроса: 2 МБ. - Максимальный размер HTTP-запроса: 2 МБ.
- Временное окно защиты от Replay: `created_at` команды должно быть не старше 30 дней и не более чем на 5 минут в будущем (`TIMESTAMP_OUT_OF_WINDOW`). - Временное окно защиты от Replay: `created_at` команды должно быть не старше 30 дней и не более чем на 5 минут в будущем (`TIMESTAMP_OUT_OF_WINDOW`).
**Пример запроса:** **Пример запроса:**
```json ```json
{ {
"commands": [ "commands": [
@ -174,11 +201,15 @@ cur_<sequence>_<signature_hash>
``` ```
### 4.2. Идемпотентность (`staff_sync_command_inbox`) ### 4.2. Идемпотентность (`staff_sync_command_inbox`)
Таблица `staff_sync_command_inbox` содержит уникальный ключ: Таблица `staff_sync_command_inbox` содержит уникальный ключ:
```sql ```sql
UNIQUE (tenant_id, device_id, idempotency_key) UNIQUE (tenant_id, device_id, idempotency_key)
``` ```
При повторной отправке команды с тем же `idempotency_key`: При повторной отправке команды с тем же `idempotency_key`:
1. Мутация повторно не исполняется. 1. Мутация повторно не исполняется.
2. Сервер извлекает ранее зафиксированный результат (`status`, `result`, `conflict`, `error_code`, `error_message`) и возвращает его клиенту. 2. Сервер извлекает ранее зафиксированный результат (`status`, `result`, `conflict`, `error_code`, `error_message`) и возвращает его клиенту.
3. Это гарантирует полную безопасность при сбоях связи и автоматических повторах (Retry). 3. Это гарантирует полную безопасность при сбоях связи и автоматических повторах (Retry).
@ -192,6 +223,7 @@ UNIQUE (tenant_id, device_id, idempotency_key)
Если терминал пытается применить мутацию на основе устаревшей ревизии (например, смена уже была подтверждена управляющим на сервере), команда **не перезаписывает** данные. Если терминал пытается применить мутацию на основе устаревшей ревизии (например, смена уже была подтверждена управляющим на сервере), команда **не перезаписывает** данные.
**Ответ с конфликтом (в составе `results`):** **Ответ с конфликтом (в составе `results`):**
```json ```json
{ {
"idempotency_key": "cmd-term01-778900", "idempotency_key": "cmd-term01-778900",
@ -210,6 +242,7 @@ UNIQUE (tenant_id, device_id, idempotency_key)
``` ```
Клиент, получив конфликт, обязан: Клиент, получив конфликт, обязан:
1. Запросить актуальное состояние сущности через `POST /sync/pull`. 1. Запросить актуальное состояние сущности через `POST /sync/pull`.
2. Обновить локальное представление. 2. Обновить локальное представление.
3. При необходимости повторить действие с актуальной ревизией или уведомить оператора. 3. При необходимости повторить действие с актуальной ревизией или уведомить оператора.
@ -220,15 +253,15 @@ UNIQUE (tenant_id, device_id, idempotency_key)
Терминал получает **исключительно оперативные данные**, необходимые для фиксации посещаемости и графиков: Терминал получает **исключительно оперативные данные**, необходимые для фиксации посещаемости и графиков:
| Сущность | Разрешённые поля | Запрещённые данные | | Сущность | Разрешённые поля | Запрещённые данные |
| :--- | :--- | :--- | | :----------------- | :-------------------------------------------------------------------------------------------------------------------- | :-------------------------------------------------------------------------- |
| `org_unit` | `id`, `code`, `name`, `status` | Финансовые показатели, выручка, арендные договоры | | `org_unit` | `id`, `code`, `name`, `status` | Финансовые показатели, выручка, арендные договоры |
| `profession` | `id`, `code`, `name`, `status` | Тарифные сетки, оклады | | `profession` | `id`, `code`, `name`, `status` | Тарифные сетки, оклады |
| `employee` | `id`, `personnel_number`, `display_name`, `status`, `profession_id`, `position` | **Зарплаты, ставки, паспортные данные, СНИЛС, телефоны, адреса, биометрия** | | `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` | Стоимость смены, начисления, штрафы | | `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` | Расчётные листы, итоговые выплаты | | `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` | Плановая стоимость ФОТ смены | | `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` | Сырые биометрические сэмплы сканера | | `attendance_event` | `id`, `employee_id`, `event_type`, `occurred_at`, `source_event_id`, `device_id`, `org_unit_id`, `status` | Сырые биометрические сэмплы сканера |
> [!CAUTION] > [!CAUTION]
> **Финансовые данные и заработная плата НИКОГДА не передаются на локальный терминал!** > **Финансовые данные и заработная плата НИКОГДА не передаются на локальный терминал!**
@ -247,9 +280,11 @@ UNIQUE (tenant_id, device_id, idempotency_key)
## 8. Политика хранения данных (Retention) ## 8. Политика хранения данных (Retention)
Для предотвращения бесконечного роста таблиц синхронизации предусмотрена функция очистки: Для предотвращения бесконечного роста таблиц синхронизации предусмотрена функция очистки:
```sql ```sql
SELECT * FROM staff_sync_cleanup_retention(30); SELECT * FROM staff_sync_cleanup_retention(30);
``` ```
- Удаляет записи `staff_sync_change_log` старше заданного количества дней (по умолчанию 30). - Удаляет записи `staff_sync_change_log` старше заданного количества дней (по умолчанию 30).
- Удаляет обработанные команды `staff_sync_command_inbox` старше заданного количества дней. - Удаляет обработанные команды `staff_sync_command_inbox` старше заданного количества дней.
- Активные устройства, отстающие более чем на окно хранения (30 дней), при следующем обращении с устаревшим курсором получают требование повторной начальной инициализации (Bootstrap). - Активные устройства, отстающие более чем на окно хранения (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. 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 ### 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. 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. 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. 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 ## 2. Attendance Sync Endpoint Specification
### Endpoint ### Endpoint
```http ```http
POST /api/v1/staff/attendance/events POST /api/v1/staff/attendance/events
``` ```
### Request Headers ### Request Headers
| Header Name | Type | Required | Description | Example |
|---|---|---|---|---| | 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...` | | `Content-Type` | String | Yes | Media type of payload | `application/json` |
| `Idempotency-Key` | String | Yes | Unique idempotency key per event batch or event | `6ba7b810-9dad-11d1-80b4-00c04fd430c8` | | `Authorization` | String | Yes | Terminal Bearer API Key | `Bearer trm_sec_991823ab...` |
| `X-Request-Id` | String | Yes | Unique trace ID for telemetry and crash debugging | `req_01HV8G7P89J21...` | | `Idempotency-Key` | String | Yes | Unique idempotency key per event batch or event | `6ba7b810-9dad-11d1-80b4-00c04fd430c8` |
| `User-Agent` | String | Yes | Terminal client identification | `KronaStaffTerminal/0.3.2 (Windows NT 10.0; Win64; x64)` | | `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. The synchronization client supports sending both individual attendance records and batched events.
### 3.1 Single Event Record Payload ### 3.1 Single Event Record Payload
```json ```json
{ {
"eventId": "01J8R5QWXZ7M1P0T8C3D2E4F5G", "eventId": "01J8R5QWXZ7M1P0T8C3D2E4F5G",
@ -54,7 +58,9 @@ The synchronization client supports sending both individual attendance records a
``` ```
### 3.2 Batch Sync Payload ### 3.2 Batch Sync Payload
When draining the offline sync queue after network recovery, the client posts an array of queued events: When draining the offline sync queue after network recovery, the client posts an array of queued events:
```json ```json
{ {
"deviceId": "TERM-SPB-OFFICE-01", "deviceId": "TERM-SPB-OFFICE-01",
@ -83,6 +89,7 @@ When draining the offline sync queue after network recovery, the client posts an
``` ```
### Field Definitions ### Field Definitions
- **`eventId`** (`string`, ULID or UUIDv4): Client-generated immutable identifier of the attendance event. - **`eventId`** (`string`, ULID or UUIDv4): Client-generated immutable identifier of the attendance event.
- **`employeeId`** (`string`, UUIDv4): Server-recognized unique ID of the employee. - **`employeeId`** (`string`, UUIDv4): Server-recognized unique ID of the employee.
- **`eventType`** (`string`): Enum representing the punch action: - **`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 ## 4. HTTP Response Codes & Client Handling
| HTTP Status | Meaning | Terminal Client Action | | HTTP Status | Meaning | Terminal Client Action |
|---|---|---| | ------------------------------------ | ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `200 OK` / `201 Created` | Event successfully recorded | Dequeue event from local offline store immediately. | | `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. | | `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. | | `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. | | `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. | | `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). | | `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`). | | 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', 'utf8',
); );
if (/ALTER DEFAULT PRIVILEGES[\s\S]*?\bGRANT\b/i.test(currentDefaultPrivileges)) { 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)`); 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; if (!candidate) return false;
const candidateBuffer = Buffer.from(candidate, 'utf8'); const candidateBuffer = Buffer.from(candidate, 'utf8');
const expectedBuffer = Buffer.from(expected, '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 { export class PlatformIdentityAdapter implements IdentityPort {

View file

@ -136,14 +136,33 @@ export async function buildApp({
{ prefix: apiPrefix }, { prefix: apiPrefix },
); );
app.get('/', async (_request, reply) => reply.redirect('/staff')); 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', async (_request, reply) =>
app.get('/staff/styles.css', async (_request, reply) => reply.type('text/css; charset=utf-8').send(portalCss)); reply.type('text/html; charset=utf-8').send(portalHtml),
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/styles.css', async (_request, reply) =>
app.get('/staff/manifest.webmanifest', async (_request, reply) => reply.type('application/manifest+json').send(portalManifest)); reply.type('text/css; charset=utf-8').send(portalCss),
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/app.js', async (_request, reply) =>
app.get('/staff/*', async (_request, reply) => reply.type('text/html; charset=utf-8').send(portalHtml)); 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()); app.addHook('onClose', async () => database.close());
return app; return app;

View file

@ -10,7 +10,10 @@ const configSchema = z.object({
STANDALONE_MANAGER_TOKEN: emptyStringAsUndefined(z.string().min(32).optional()), STANDALONE_MANAGER_TOKEN: emptyStringAsUndefined(z.string().min(32).optional()),
STANDALONE_TERMINAL_TOKEN: emptyStringAsUndefined(z.string().min(32).optional()), STANDALONE_TERMINAL_TOKEN: emptyStringAsUndefined(z.string().min(32).optional()),
STANDALONE_TERMINAL_ACTOR_ID: emptyStringAsUndefined( 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_PATH: emptyStringAsUndefined(z.string().optional()),
HTTPS_PFX_PASSPHRASE: emptyStringAsUndefined(z.string().min(16).optional()), HTTPS_PFX_PASSPHRASE: emptyStringAsUndefined(z.string().min(16).optional()),

View file

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

View file

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

View file

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

View file

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

View file

@ -42,9 +42,7 @@ function decryptPushPayload(
recipient.publicKey, recipient.publicKey,
senderPublicKey, senderPublicKey,
]); ]);
const prk = Buffer.from( const prk = Buffer.from(crypto.hkdfSync('sha256', sharedSecret, recipient.auth, keyInfo, 32));
crypto.hkdfSync('sha256', sharedSecret, recipient.auth, keyInfo, 32),
);
const cekInfo = Buffer.from('Content-Encoding: aes128gcm\0', 'utf8'); const cekInfo = Buffer.from('Content-Encoding: aes128gcm\0', 'utf8');
const cek = Buffer.from(crypto.hkdfSync('sha256', prk, salt, cekInfo, 16)); 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', () => { it('rejects mismatched VAPID configuration when only one key is set', () => {
expect(() => expect(() => resolveVapidKeys({ VAPID_PUBLIC_KEY: 'only-pub' })).toThrow(
resolveVapidKeys({ VAPID_PUBLIC_KEY: 'only-pub' }), /must be configured together/,
).toThrow(/must be configured together/); );
expect(() => expect(() => resolveVapidKeys({ VAPID_PRIVATE_KEY: 'only-priv' })).toThrow(
resolveVapidKeys({ VAPID_PRIVATE_KEY: 'only-priv' }), /must be configured together/,
).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', () => { it('rejects invalid recipient keys with clear error', () => {
expect(() => expect(() => encryptPushPayload('test', { p256dh: 'invalid', auth: 'invalid' })).toThrow(
encryptPushPayload('test', { p256dh: 'invalid', auth: 'invalid' }), /Invalid recipient/,
).toThrow(/Invalid recipient/); );
}); });
}); });
@ -377,7 +375,11 @@ describe('PushDispatcher idempotent outbox handling', () => {
expect(mockFetch).toHaveBeenCalledTimes(1); expect(mockFetch).toHaveBeenCalledTimes(1);
// Push delivery is tracked independently from platform event publication. // 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); 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 { 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', () => { describe('Krona Staff workspace shell', () => {
it('exposes the operational workspace sections with deep routes', () => { 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/attendance':'attendance-view'");
expect(portalJsEnhanced).toContain("'/staff/reports':'reports-view'"); expect(portalJsEnhanced).toContain("'/staff/reports':'reports-view'");
expect(portalJsEnhanced).toContain("window.addEventListener('popstate'"); 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

@ -61,8 +61,12 @@ describe('payroll foundation migration', () => {
}); });
it('enables and forces RLS with a tenant policy for every listed payroll table', () => { 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(
expect(sql).toContain("EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', target_table)"); "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"); 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', () => { 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 ENABLE ROW LEVEL SECURITY');
expect(sql).toContain('ALTER TABLE staff_payroll_ledger FORCE 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("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("tenant_id = current_setting('app.tenant_id', true)");
expect(sql).toContain("created_by = current_setting('app.user_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( expect(sql).toContain(
'FOREIGN KEY (tenant_id, employee_id) REFERENCES staff_employees (tenant_id, id) ON DELETE CASCADE', 'FOREIGN KEY (tenant_id, employee_id) REFERENCES staff_employees (tenant_id, id) ON DELETE CASCADE',
); );
expect(sql).toContain( expect(sql).toContain('FOREIGN KEY (outbox_id) REFERENCES staff_outbox (id) ON DELETE CASCADE');
'FOREIGN KEY (outbox_id) REFERENCES staff_outbox (id) ON DELETE CASCADE',
);
}); });
it('grants required operations on both push tables to krona_staff_app', () => { 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', () => { 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(
expect(sql).toContain('GRANT SELECT, INSERT, UPDATE, DELETE ON staff_sync_change_log TO krona_staff_app;'); 'GRANT SELECT, INSERT, UPDATE, DELETE ON staff_sync_devices 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(
expect(sql).toContain('GRANT SELECT, INSERT, UPDATE, DELETE ON staff_sync_device_checkpoints TO krona_staff_app;'); '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', () => { it('declares and grants retention cleanup function', () => {
expect(sql).toContain('CREATE OR REPLACE FUNCTION staff_sync_cleanup_retention'); 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', () => { describe('attendance work-session reconciliation', () => {
it('sums several in/out intervals without inventing time between them', () => { it('sums several in/out intervals without inventing time between them', () => {
const result = reconcileWorkSession([ 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: '1', type: 'check_in', occurredAt: '2026-09-24T01:00:00.000Z' },
{ id: '3', type: 'check_in', occurredAt: '2026-09-24T06:00:00.000Z' }, { id: '2', type: 'check_out', occurredAt: '2026-09-24T05:00:00.000Z' },
{ id: '4', type: 'check_out', occurredAt: '2026-09-24T10:30:00.000Z' }, { id: '3', type: 'check_in', occurredAt: '2026-09-24T06:00:00.000Z' },
], options); { id: '4', type: 'check_out', occurredAt: '2026-09-24T10:30:00.000Z' },
],
options,
);
expect(result).toMatchObject({ expect(result).toMatchObject({
startedAt: '2026-09-24T01:00:00.000Z', 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', () => { it('keeps the first check-in and flags a repeated check-in', () => {
const result = reconcileWorkSession([ 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: '1', type: 'check_in', occurredAt: '2026-09-24T01:00:00.000Z' },
{ id: '3', type: 'check_out', occurredAt: '2026-09-24T09:00:00.000Z' }, { id: '2', type: 'check_in', occurredAt: '2026-09-24T01:05:00.000Z' },
], options); { id: '3', type: 'check_out', occurredAt: '2026-09-24T09:00:00.000Z' },
],
options,
);
expect(result?.workedMinutes).toBe(480); expect(result?.workedMinutes).toBe(480);
expect(result?.anomalies).toEqual(['repeated_check_in']); expect(result?.anomalies).toEqual(['repeated_check_in']);
}); });
it('keeps a recent unfinished shift open', () => { it('keeps a recent unfinished shift open', () => {
const result = reconcileWorkSession([ const result = reconcileWorkSession(
{ id: '1', type: 'check_in', occurredAt: '2026-09-24T12:00:00.000Z' }, [{ id: '1', type: 'check_in', occurredAt: '2026-09-24T12:00:00.000Z' }],
], options); 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', () => { it('flags an old unfinished shift without estimating payable minutes', () => {
const result = reconcileWorkSession([ const result = reconcileWorkSession(
{ id: '1', type: 'check_in', occurredAt: '2026-09-24T01:00:00.000Z' }, [{ id: '1', type: 'check_in', occurredAt: '2026-09-24T01:00:00.000Z' }],
], options); options,
);
expect(result).toMatchObject({ expect(result).toMatchObject({
status: 'pending_review', 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', () => { it('auto-closes only after the scheduler cutoff and leaves an audit-worthy anomaly', () => {
const result = reconcileWorkSession([ const result = reconcileWorkSession(
{ id: '1', type: 'check_in', occurredAt: '2026-09-23T12:00:00.000Z' }, [{ id: '1', type: 'check_in', occurredAt: '2026-09-23T12:00:00.000Z' }],
], { ...options, automaticCloseAt: '2026-09-23T19:00:00.000Z' }); { ...options, automaticCloseAt: '2026-09-23T19:00:00.000Z' },
);
expect(result).toMatchObject({ expect(result).toMatchObject({
endedAt: '2026-09-23T19:00:00.000Z', 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', () => { it('records a check-out without check-in as an anomaly with zero minutes', () => {
const result = reconcileWorkSession([ const result = reconcileWorkSession(
{ id: '1', type: 'check_out', occurredAt: '2026-09-24T09:00:00.000Z' }, [{ id: '1', type: 'check_out', occurredAt: '2026-09-24T09:00:00.000Z' }],
], options); options,
);
expect(result).toMatchObject({ expect(result).toMatchObject({
startedAt: null, startedAt: null,
@ -84,10 +99,11 @@ describe('attendance work-session reconciliation', () => {
}); });
it('rejects invalid timestamps and thresholds', () => { it('rejects invalid timestamps and thresholds', () => {
expect(() => reconcileWorkSession([ expect(() =>
{ id: '1', type: 'check_in', occurredAt: 'not-a-date' }, reconcileWorkSession([{ id: '1', type: 'check_in', occurredAt: 'not-a-date' }], options),
], options)).toThrow('event 1 must be an ISO date-time'); ).toThrow('event 1 must be an ISO date-time');
expect(() => reconcileWorkSession([], { ...options, openShiftThresholdMinutes: 0 })) expect(() => reconcileWorkSession([], { ...options, openShiftThresholdMinutes: 0 })).toThrow(
.toThrow('openShiftThresholdMinutes must be a positive integer'); 'openShiftThresholdMinutes must be a positive integer',
);
}); });
}); });