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:
parent
53737f8c36
commit
7cf5142678
17 changed files with 230 additions and 143 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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`). |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)`);
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
35
src/app.ts
35
src/app.ts
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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()),
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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;',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue