diff --git a/api/openapi.yaml b/api/openapi.yaml
index c2602ae..b538654 100644
--- a/api/openapi.yaml
+++ b/api/openapi.yaml
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: Krona Staff API
- version: 0.3.1
+ version: 0.3.2
description: API посещаемости Krona Staff для кабинета сотрудника, терминала и табеля руководителя.
license:
name: Proprietary
diff --git a/apps/KronaStaffService/KronaStaffService.csproj b/apps/KronaStaffService/KronaStaffService.csproj
index 523b4ed..cc528ed 100644
--- a/apps/KronaStaffService/KronaStaffService.csproj
+++ b/apps/KronaStaffService/KronaStaffService.csproj
@@ -8,9 +8,9 @@
x64
win-x64
KronaStaffService
- 0.3.1
- 0.3.1.0
- 0.3.1.0
+ 0.3.2
+ 0.3.2.0
+ 0.3.2.0
diff --git a/apps/KronaStaffService/KronaStaffWorker.cs b/apps/KronaStaffService/KronaStaffWorker.cs
index dad57f7..7d25644 100644
--- a/apps/KronaStaffService/KronaStaffWorker.cs
+++ b/apps/KronaStaffService/KronaStaffWorker.cs
@@ -258,7 +258,7 @@ public sealed class KronaStaffWorker : BackgroundService
return new IpcServiceStatus
{
ServiceRunning = true,
- ServiceVersion = "0.3.1",
+ ServiceVersion = "0.3.2",
ScannerInSession0Supported = _scannerSession0Supported,
ScannerDiagnostic = _scannerDiagnostic,
PendingSyncCount = pendingCount,
diff --git a/apps/KronaStaffTerminal/KronaStaffTerminal.csproj b/apps/KronaStaffTerminal/KronaStaffTerminal.csproj
index 162dc09..520755f 100644
--- a/apps/KronaStaffTerminal/KronaStaffTerminal.csproj
+++ b/apps/KronaStaffTerminal/KronaStaffTerminal.csproj
@@ -17,10 +17,10 @@
enable
enable
x64
- 0.3.1
- 0.3.1.0
- 0.3.1.0
- 0.3.1
+ 0.3.2
+ 0.3.2.0
+ 0.3.2.0
+ 0.3.2
diff --git a/apps/KronaStaffTerminal/Services/TerminalIpc.cs b/apps/KronaStaffTerminal/Services/TerminalIpc.cs
index 95dca28..a06bd71 100644
--- a/apps/KronaStaffTerminal/Services/TerminalIpc.cs
+++ b/apps/KronaStaffTerminal/Services/TerminalIpc.cs
@@ -29,7 +29,7 @@ public sealed class IpcResponse
public sealed class IpcServiceStatus
{
public bool ServiceRunning { get; set; } = true;
- public string ServiceVersion { get; set; } = "0.3.1";
+ public string ServiceVersion { get; set; } = "0.3.2";
public bool ScannerInSession0Supported { get; set; }
public string ScannerDiagnostic { get; set; } = "";
public int PendingSyncCount { get; set; }
diff --git a/deploy/pilot/compose.yaml b/deploy/pilot/compose.yaml
index a65ef16..42c7ace 100644
--- a/deploy/pilot/compose.yaml
+++ b/deploy/pilot/compose.yaml
@@ -16,6 +16,9 @@ services:
STANDALONE_TENANT_ID: ${STANDALONE_TENANT_ID}
STANDALONE_ACTOR_ID: ${STANDALONE_ACTOR_ID}
STANDALONE_TERMINAL_ACTOR_ID: ${STANDALONE_TERMINAL_ACTOR_ID}
+ VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
+ VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
+ VAPID_SUBJECT: ${VAPID_SUBJECT}
DATABASE_URL: postgres://krona_staff_app:${POSTGRES_APP_PASSWORD}@postgres:5432/krona_staff
HTTPS_PFX_PATH: /run/secrets/staff.pfx
HTTPS_PFX_PASSPHRASE: ${HTTPS_PFX_PASSPHRASE}
diff --git a/deploy/pilot/setup.sh b/deploy/pilot/setup.sh
index fd95c63..d74313d 100644
--- a/deploy/pilot/setup.sh
+++ b/deploy/pilot/setup.sh
@@ -25,6 +25,14 @@ if [ ! -f .env ]; then
printf '%s\n' "$terminal_token" > secrets/terminal.token
fi
+if ! grep -q '^VAPID_PUBLIC_KEY=' .env; then
+ docker run --rm \
+ -v "$(cd ../.. && pwd):/app:ro" \
+ -w /app \
+ node:22-alpine@sha256:c610fcdfb1d5b4740dd70c284ed3cb16bb857e0f7166196e36a5501df7a3aa32 \
+ node scripts/generate-vapid-keys.mjs >> .env
+fi
+
. ./.env
if [ ! -f secrets/staff.pfx ]; then
openssl req -x509 -newkey rsa:3072 -sha256 -days 365 -nodes \
diff --git a/docs/TERMINAL-API-REQUIREMENTS.md b/docs/TERMINAL-API-REQUIREMENTS.md
index 6538645..f85d520 100644
--- a/docs/TERMINAL-API-REQUIREMENTS.md
+++ b/docs/TERMINAL-API-REQUIREMENTS.md
@@ -31,7 +31,7 @@ POST /api/v1/staff/attendance/events
| `Authorization` | String | Yes | Terminal Bearer API Key | `Bearer trm_sec_991823ab...` |
| `Idempotency-Key` | String | Yes | Unique idempotency key per event batch or event | `6ba7b810-9dad-11d1-80b4-00c04fd430c8` |
| `X-Request-Id` | String | Yes | Unique trace ID for telemetry and crash debugging | `req_01HV8G7P89J21...` |
-| `User-Agent` | String | Yes | Terminal client identification | `KronaStaffTerminal/0.3.1 (Windows NT 10.0; Win64; x64)` |
+| `User-Agent` | String | Yes | Terminal client identification | `KronaStaffTerminal/0.3.2 (Windows NT 10.0; Win64; x64)` |
---
diff --git a/installer/KronaStaffTerminal.nsi b/installer/KronaStaffTerminal.nsi
index 41be6b5..98356f4 100644
--- a/installer/KronaStaffTerminal.nsi
+++ b/installer/KronaStaffTerminal.nsi
@@ -5,7 +5,7 @@ SetCompressor /SOLID lzma
!include "x64.nsh"
!ifndef APP_VERSION
- !define APP_VERSION "0.3.1"
+ !define APP_VERSION "0.3.2"
!endif
!ifndef PUBLISH_DIR
!error "PUBLISH_DIR is required"
diff --git a/module.json b/module.json
index 8555f83..ee2e0c4 100644
--- a/module.json
+++ b/module.json
@@ -2,7 +2,7 @@
"$schema": "./schemas/module.schema.json",
"id": "ru.krona.staff",
"name": "Krona Staff",
- "version": "0.3.1",
+ "version": "0.3.2",
"min_platform_version": "3.2.0",
"dependencies": ["ru.krona.core"],
"optional_dependencies": [],
diff --git a/package-lock.json b/package-lock.json
index a5ea0ae..fc4e7a7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@krona/staff",
- "version": "0.3.1",
+ "version": "0.3.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@krona/staff",
- "version": "0.3.1",
+ "version": "0.3.2",
"dependencies": {
"@fastify/helmet": "^13.0.2",
"@fastify/sensible": "^6.0.3",
diff --git a/package.json b/package.json
index 6977e97..58fe779 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@krona/staff",
- "version": "0.3.1",
+ "version": "0.3.2",
"private": true,
"type": "module",
"engines": {
diff --git a/scripts/generate-vapid-keys.mjs b/scripts/generate-vapid-keys.mjs
new file mode 100644
index 0000000..0086712
--- /dev/null
+++ b/scripts/generate-vapid-keys.mjs
@@ -0,0 +1,21 @@
+import crypto from 'node:crypto';
+
+const { publicKey, privateKey } = crypto.generateKeyPairSync('ec', {
+ namedCurve: 'prime256v1',
+});
+const publicJwk = publicKey.export({ format: 'jwk' });
+const privateJwk = privateKey.export({ format: 'jwk' });
+
+if (!publicJwk.x || !publicJwk.y || !privateJwk.d) {
+ throw new Error('Failed to generate VAPID P-256 key pair');
+}
+
+const rawPublicKey = Buffer.concat([
+ Buffer.from([0x04]),
+ Buffer.from(publicJwk.x, 'base64url'),
+ Buffer.from(publicJwk.y, 'base64url'),
+]).toString('base64url');
+
+process.stdout.write(
+ `VAPID_PUBLIC_KEY=${rawPublicKey}\nVAPID_PRIVATE_KEY=${privateJwk.d}\nVAPID_SUBJECT=mailto:admin@krona.local\n`,
+);
diff --git a/src/domain/notification/push-dispatcher.ts b/src/domain/notification/push-dispatcher.ts
index 8a17834..f6420d2 100644
--- a/src/domain/notification/push-dispatcher.ts
+++ b/src/domain/notification/push-dispatcher.ts
@@ -40,8 +40,6 @@ interface OutboxRow {
event_type: string;
subject_id: string;
payload: unknown;
- published_at: string | null;
- attempts: number;
}
interface SubscriptionRow {
@@ -129,9 +127,14 @@ export class PushDispatcher {
/**
* Remove an employee's WebPush subscription by endpoint.
*/
- async deleteSubscription(tenantId: string, endpoint: string): Promise {
+ async deleteSubscription(
+ tenantId: string,
+ employeeId: string,
+ endpoint: string,
+ ): Promise {
const parsedTenantId = parseUlid(tenantId);
const parsedActorId = parseUlid(this.systemActorId);
+ const parsedEmployeeId = parseUlid(employeeId);
return this.database.tenantTransaction(
parsedTenantId,
@@ -139,8 +142,8 @@ export class PushDispatcher {
async (client) => {
const result = await client.query(
`DELETE FROM staff_push_subscriptions
- WHERE tenant_id = $1 AND endpoint = $2`,
- [parsedTenantId, endpoint],
+ WHERE tenant_id = $1 AND employee_id = $2 AND endpoint = $3`,
+ [parsedTenantId, parsedEmployeeId, endpoint],
);
return (result.rowCount ?? 0) > 0;
},
@@ -149,7 +152,7 @@ export class PushDispatcher {
/**
* Dispatches push notifications for a specific transactional outbox record.
- * Ensures idempotency: skips already delivered endpoints and marks outbox.published_at.
+ * Ensures notification idempotency without changing the platform outbox publication state.
*/
async dispatchForOutbox(tenantId: string, outboxId: string): Promise {
const parsedTenantId = parseUlid(tenantId);
@@ -161,7 +164,7 @@ export class PushDispatcher {
parsedActorId,
async (client) => {
const result = await client.query(
- `SELECT id, tenant_id, event_type, subject_id, payload, published_at, attempts
+ `SELECT id, tenant_id, event_type, subject_id, payload
FROM staff_outbox
WHERE tenant_id = $1 AND id = $2`,
[parsedTenantId, parsedOutboxId],
@@ -179,15 +182,6 @@ export class PushDispatcher {
};
}
- if (outboxRow.published_at) {
- return {
- outboxId,
- subscriptionsFound: 0,
- deliveries: [],
- published: true,
- };
- }
-
const employeeId = outboxRow.subject_id;
const parsedEmployeeId = parseUlid(employeeId);
@@ -206,20 +200,8 @@ export class PushDispatcher {
},
);
- // If no subscriptions exist for this employee, mark as published
+ // There is nothing to notify. The platform outbox remains available to its publisher.
if (subscriptions.length === 0) {
- await this.database.tenantTransaction(
- parsedTenantId,
- parsedActorId,
- async (client) => {
- await client.query(
- `UPDATE staff_outbox
- SET published_at = now()
- WHERE tenant_id = $1 AND id = $2 AND published_at IS NULL`,
- [parsedTenantId, parsedOutboxId],
- );
- },
- );
return {
outboxId,
subscriptionsFound: 0,
@@ -348,29 +330,9 @@ export class PushDispatcher {
}
}
- // 3. Update staff_outbox status
+ // The delivery table owns notification progress. staff_outbox.published_at is reserved
+ // for the platform event publisher and must not be consumed by the push adapter.
const shouldMarkPublished = deliveredCount > 0 || (deliveredCount === 0 && failedCount === 0);
- await this.database.tenantTransaction(
- parsedTenantId,
- parsedActorId,
- async (client) => {
- if (shouldMarkPublished) {
- await client.query(
- `UPDATE staff_outbox
- SET published_at = now(), attempts = attempts + 1, last_error = NULL
- WHERE tenant_id = $1 AND id = $2`,
- [parsedTenantId, parsedOutboxId],
- );
- } else {
- await client.query(
- `UPDATE staff_outbox
- SET attempts = attempts + 1, last_error = 'Push notification delivery failed for all endpoints'
- WHERE tenant_id = $1 AND id = $2`,
- [parsedTenantId, parsedOutboxId],
- );
- }
- },
- );
return {
outboxId,
@@ -392,13 +354,18 @@ export class PushDispatcher {
parsedActorId,
async (client) => {
const result = await client.query<{ id: string }>(
- `SELECT id
- FROM staff_outbox
- WHERE tenant_id = $1
- AND published_at IS NULL
- AND event_type IN ('attendance.clock.in', 'attendance.clock.out')
- AND attempts < 5
- ORDER BY occurred_at ASC
+ `SELECT DISTINCT o.id
+ FROM staff_outbox o
+ JOIN staff_push_subscriptions s
+ ON s.tenant_id = o.tenant_id AND s.employee_id = o.subject_id
+ LEFT JOIN staff_notification_deliveries d
+ ON d.tenant_id = o.tenant_id
+ AND d.outbox_id = o.id
+ AND d.endpoint = s.endpoint
+ WHERE o.tenant_id = $1
+ AND o.event_type IN ('attendance.clock.in', 'attendance.clock.out')
+ AND (d.id IS NULL OR (d.status = 'failed' AND d.attempts < 5))
+ ORDER BY o.id ASC
LIMIT $2`,
[parsedTenantId, limit],
);
diff --git a/src/portal-ui.ts b/src/portal-ui.ts
index 00a67fe..1620026 100644
--- a/src/portal-ui.ts
+++ b/src/portal-ui.ts
@@ -448,10 +448,10 @@ self.addEventListener('activate',e=>e.waitUntil(caches.keys().then(keys=>Promise
self.addEventListener('fetch',e=>{
if(e.request.method==='GET'){
const url=new URL(e.request.url);
- if(url.pathname.startsWith('/api/v1/staff/')){
- e.respondWith(fetch(e.request).then(r=>{if(r.ok){const copy=r.clone();caches.open(CACHE).then(c=>c.put(e.request,copy));}return r;}).catch(()=>caches.match(e.request)));
- return;
- }
+ // Authenticated API responses can contain attendance and payroll data. They must never
+ // enter the shared service-worker cache; offline personal data needs a user-bound,
+ // encrypted store with an explicit retention policy.
+ if(url.pathname.startsWith('/api/v1/staff/'))return;
if(url.pathname.startsWith('/staff')){
e.respondWith(fetch(e.request).then(r=>{const copy=r.clone();caches.open(CACHE).then(c=>c.put(e.request,copy));return r;}).catch(()=>caches.match(e.request)));
}
@@ -469,4 +469,3 @@ self.addEventListener('notificationclick',e=>{
const url=(e.notification.data&&e.notification.data.url)||'/staff';
e.waitUntil(clients.matchAll({type:'window',includeUncontrolled:true}).then(wList=>{for(const c of wList){if(c.url.includes('/staff')&&'focus' in c)return c.focus();}if(clients.openWindow)return clients.openWindow(url);}));
});`;
-
diff --git a/src/portal.ts b/src/portal.ts
index 99f1818..ffeb09c 100644
--- a/src/portal.ts
+++ b/src/portal.ts
@@ -208,7 +208,27 @@ export async function registerPortalRoutes(
async (request, reply) => {
assertCookieMutationOrigin(request);
const context = await resolveContext(identity, request);
- await pushDispatcher.deleteSubscription(context.tenantId, request.body.endpoint);
+ const employee = await database.tenantTransaction(
+ context.tenantId,
+ context.actorId,
+ async (client) => {
+ const res = await client.query<{ id: string }>(
+ `SELECT id FROM staff_employees
+ WHERE tenant_id = $1 AND (core_user_id = $2 OR id = $2) AND status = 'active'
+ LIMIT 1`,
+ [context.tenantId, context.actorId],
+ );
+ return res.rows[0];
+ },
+ );
+ if (!employee) {
+ return reply.notFound('Сотрудник для текущего пользователя не найден.');
+ }
+ await pushDispatcher.deleteSubscription(
+ context.tenantId,
+ employee.id,
+ request.body.endpoint,
+ );
return reply.code(204).send();
},
);
diff --git a/src/version.ts b/src/version.ts
index 678c7b8..0a4bb72 100644
--- a/src/version.ts
+++ b/src/version.ts
@@ -1,2 +1,2 @@
export const SERVICE_NAME = 'krona-staff';
-export const SERVICE_VERSION = '0.3.1';
+export const SERVICE_VERSION = '0.3.2';
diff --git a/tests/unit/employee-push.test.ts b/tests/unit/employee-push.test.ts
index 9925f35..f19351b 100644
--- a/tests/unit/employee-push.test.ts
+++ b/tests/unit/employee-push.test.ts
@@ -288,7 +288,7 @@ describe('PushDispatcher idempotent outbox handling', () => {
).rejects.toThrow(/Subscription endpoint must use HTTPS/);
});
- it('marks outbox published when employee has no subscriptions', async () => {
+ it('leaves the platform outbox untouched when employee has no subscriptions', async () => {
const { database, queries } = mockDb([
// 1. SELECT staff_outbox
[
@@ -304,8 +304,6 @@ describe('PushDispatcher idempotent outbox handling', () => {
],
// 2. SELECT staff_push_subscriptions (empty)
[],
- // 3. UPDATE staff_outbox SET published_at = now()
- [],
]);
const dispatcher = new PushDispatcher({ database, vapidKeys, systemActorId });
@@ -313,8 +311,19 @@ describe('PushDispatcher idempotent outbox handling', () => {
expect(report.published).toBe(true);
expect(report.subscriptionsFound).toBe(0);
- expect(queries[2]?.sql).toContain('UPDATE staff_outbox');
- expect(queries[2]?.sql).toContain('published_at = now()');
+ expect(queries).toHaveLength(2);
+ expect(queries.some((q) => q.sql.includes('UPDATE staff_outbox'))).toBe(false);
+ });
+
+ it('deletes only a subscription owned by the current employee', async () => {
+ const endpoint = 'https://push.example.com/sub/current-device';
+ const { database, queries } = mockDb([[{ id: newUlid() }]]);
+ const dispatcher = new PushDispatcher({ database, vapidKeys, systemActorId });
+
+ await dispatcher.deleteSubscription(tenantId, employeeId, endpoint);
+
+ expect(queries[0]?.sql).toContain('employee_id = $2');
+ expect(queries[0]?.params).toEqual([tenantId, employeeId, endpoint]);
});
it('delivers push idempotently and avoids duplicate delivery', async () => {
@@ -351,8 +360,6 @@ describe('PushDispatcher idempotent outbox handling', () => {
[{ id: newUlid(), status: 'pending', attempts: 0 }],
// 5. UPDATE delivery to delivered
[],
- // 6. UPDATE outbox to published
- [],
]);
const dispatcher = new PushDispatcher({
@@ -369,9 +376,9 @@ describe('PushDispatcher idempotent outbox handling', () => {
expect(report.deliveries[0]?.status).toBe('delivered');
expect(mockFetch).toHaveBeenCalledTimes(1);
- // Verify outbox and delivery updates
+ // 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_outbox') && q.sql.includes('published_at = now()'))).toBe(true);
+ expect(queries.some((q) => q.sql.includes('UPDATE staff_outbox'))).toBe(false);
});
it('skips sending if endpoint was already delivered (idempotency check)', async () => {