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 () => {