fix(push): isolate employee data and preserve platform outbox
Some checks are pending
ci / verify (push) Waiting to run

This commit is contained in:
Ochenstarik 2026-09-26 12:31:10 +07:00
parent 2f1bdf549d
commit 3e2054480c
18 changed files with 115 additions and 90 deletions

View file

@ -1,7 +1,7 @@
openapi: 3.1.0 openapi: 3.1.0
info: info:
title: Krona Staff API title: Krona Staff API
version: 0.3.1 version: 0.3.2
description: API посещаемости Krona Staff для кабинета сотрудника, терминала и табеля руководителя. description: API посещаемости Krona Staff для кабинета сотрудника, терминала и табеля руководителя.
license: license:
name: Proprietary name: Proprietary

View file

@ -8,9 +8,9 @@
<PlatformTarget>x64</PlatformTarget> <PlatformTarget>x64</PlatformTarget>
<RuntimeIdentifier Condition="'$(RuntimeIdentifier)' == ''">win-x64</RuntimeIdentifier> <RuntimeIdentifier Condition="'$(RuntimeIdentifier)' == ''">win-x64</RuntimeIdentifier>
<RootNamespace>KronaStaffService</RootNamespace> <RootNamespace>KronaStaffService</RootNamespace>
<Version>0.3.1</Version> <Version>0.3.2</Version>
<AssemblyVersion>0.3.1.0</AssemblyVersion> <AssemblyVersion>0.3.2.0</AssemblyVersion>
<FileVersion>0.3.1.0</FileVersion> <FileVersion>0.3.2.0</FileVersion>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -258,7 +258,7 @@ public sealed class KronaStaffWorker : BackgroundService
return new IpcServiceStatus return new IpcServiceStatus
{ {
ServiceRunning = true, ServiceRunning = true,
ServiceVersion = "0.3.1", ServiceVersion = "0.3.2",
ScannerInSession0Supported = _scannerSession0Supported, ScannerInSession0Supported = _scannerSession0Supported,
ScannerDiagnostic = _scannerDiagnostic, ScannerDiagnostic = _scannerDiagnostic,
PendingSyncCount = pendingCount, PendingSyncCount = pendingCount,

View file

@ -17,10 +17,10 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<PlatformTarget>x64</PlatformTarget> <PlatformTarget>x64</PlatformTarget>
<Version>0.3.1</Version> <Version>0.3.2</Version>
<AssemblyVersion>0.3.1.0</AssemblyVersion> <AssemblyVersion>0.3.2.0</AssemblyVersion>
<FileVersion>0.3.1.0</FileVersion> <FileVersion>0.3.2.0</FileVersion>
<InformationalVersion>0.3.1</InformationalVersion> <InformationalVersion>0.3.2</InformationalVersion>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -29,7 +29,7 @@ public sealed class IpcResponse
public sealed class IpcServiceStatus public sealed class IpcServiceStatus
{ {
public bool ServiceRunning { get; set; } = true; 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 bool ScannerInSession0Supported { get; set; }
public string ScannerDiagnostic { get; set; } = ""; public string ScannerDiagnostic { get; set; } = "";
public int PendingSyncCount { get; set; } public int PendingSyncCount { get; set; }

View file

@ -16,6 +16,9 @@ services:
STANDALONE_TENANT_ID: ${STANDALONE_TENANT_ID} STANDALONE_TENANT_ID: ${STANDALONE_TENANT_ID}
STANDALONE_ACTOR_ID: ${STANDALONE_ACTOR_ID} STANDALONE_ACTOR_ID: ${STANDALONE_ACTOR_ID}
STANDALONE_TERMINAL_ACTOR_ID: ${STANDALONE_TERMINAL_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 DATABASE_URL: postgres://krona_staff_app:${POSTGRES_APP_PASSWORD}@postgres:5432/krona_staff
HTTPS_PFX_PATH: /run/secrets/staff.pfx HTTPS_PFX_PATH: /run/secrets/staff.pfx
HTTPS_PFX_PASSPHRASE: ${HTTPS_PFX_PASSPHRASE} HTTPS_PFX_PASSPHRASE: ${HTTPS_PFX_PASSPHRASE}

View file

@ -25,6 +25,14 @@ if [ ! -f .env ]; then
printf '%s\n' "$terminal_token" > secrets/terminal.token printf '%s\n' "$terminal_token" > secrets/terminal.token
fi 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 . ./.env
if [ ! -f secrets/staff.pfx ]; then if [ ! -f secrets/staff.pfx ]; then
openssl req -x509 -newkey rsa:3072 -sha256 -days 365 -nodes \ openssl req -x509 -newkey rsa:3072 -sha256 -days 365 -nodes \

View file

@ -31,7 +31,7 @@ POST /api/v1/staff/attendance/events
| `Authorization` | String | Yes | Terminal Bearer API Key | `Bearer trm_sec_991823ab...` | | `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` | | `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...` | | `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)` |
--- ---

View file

@ -5,7 +5,7 @@ SetCompressor /SOLID lzma
!include "x64.nsh" !include "x64.nsh"
!ifndef APP_VERSION !ifndef APP_VERSION
!define APP_VERSION "0.3.1" !define APP_VERSION "0.3.2"
!endif !endif
!ifndef PUBLISH_DIR !ifndef PUBLISH_DIR
!error "PUBLISH_DIR is required" !error "PUBLISH_DIR is required"

View file

@ -2,7 +2,7 @@
"$schema": "./schemas/module.schema.json", "$schema": "./schemas/module.schema.json",
"id": "ru.krona.staff", "id": "ru.krona.staff",
"name": "Krona Staff", "name": "Krona Staff",
"version": "0.3.1", "version": "0.3.2",
"min_platform_version": "3.2.0", "min_platform_version": "3.2.0",
"dependencies": ["ru.krona.core"], "dependencies": ["ru.krona.core"],
"optional_dependencies": [], "optional_dependencies": [],

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "@krona/staff", "name": "@krona/staff",
"version": "0.3.1", "version": "0.3.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@krona/staff", "name": "@krona/staff",
"version": "0.3.1", "version": "0.3.2",
"dependencies": { "dependencies": {
"@fastify/helmet": "^13.0.2", "@fastify/helmet": "^13.0.2",
"@fastify/sensible": "^6.0.3", "@fastify/sensible": "^6.0.3",

View file

@ -1,6 +1,6 @@
{ {
"name": "@krona/staff", "name": "@krona/staff",
"version": "0.3.1", "version": "0.3.2",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {

View file

@ -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`,
);

View file

@ -40,8 +40,6 @@ interface OutboxRow {
event_type: string; event_type: string;
subject_id: string; subject_id: string;
payload: unknown; payload: unknown;
published_at: string | null;
attempts: number;
} }
interface SubscriptionRow { interface SubscriptionRow {
@ -129,9 +127,14 @@ export class PushDispatcher {
/** /**
* Remove an employee's WebPush subscription by endpoint. * Remove an employee's WebPush subscription by endpoint.
*/ */
async deleteSubscription(tenantId: string, endpoint: string): Promise<boolean> { async deleteSubscription(
tenantId: string,
employeeId: string,
endpoint: string,
): Promise<boolean> {
const parsedTenantId = parseUlid(tenantId); const parsedTenantId = parseUlid(tenantId);
const parsedActorId = parseUlid(this.systemActorId); const parsedActorId = parseUlid(this.systemActorId);
const parsedEmployeeId = parseUlid(employeeId);
return this.database.tenantTransaction<boolean>( return this.database.tenantTransaction<boolean>(
parsedTenantId, parsedTenantId,
@ -139,8 +142,8 @@ export class PushDispatcher {
async (client) => { async (client) => {
const result = await client.query( const result = await client.query(
`DELETE FROM staff_push_subscriptions `DELETE FROM staff_push_subscriptions
WHERE tenant_id = $1 AND endpoint = $2`, WHERE tenant_id = $1 AND employee_id = $2 AND endpoint = $3`,
[parsedTenantId, endpoint], [parsedTenantId, parsedEmployeeId, endpoint],
); );
return (result.rowCount ?? 0) > 0; return (result.rowCount ?? 0) > 0;
}, },
@ -149,7 +152,7 @@ export class PushDispatcher {
/** /**
* Dispatches push notifications for a specific transactional outbox record. * 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<DispatchReport> { async dispatchForOutbox(tenantId: string, outboxId: string): Promise<DispatchReport> {
const parsedTenantId = parseUlid(tenantId); const parsedTenantId = parseUlid(tenantId);
@ -161,7 +164,7 @@ export class PushDispatcher {
parsedActorId, parsedActorId,
async (client) => { async (client) => {
const result = await client.query<OutboxRow>( const result = await client.query<OutboxRow>(
`SELECT id, tenant_id, event_type, subject_id, payload, published_at, attempts `SELECT id, tenant_id, event_type, subject_id, payload
FROM staff_outbox FROM staff_outbox
WHERE tenant_id = $1 AND id = $2`, WHERE tenant_id = $1 AND id = $2`,
[parsedTenantId, parsedOutboxId], [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 employeeId = outboxRow.subject_id;
const parsedEmployeeId = parseUlid(employeeId); 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) { if (subscriptions.length === 0) {
await this.database.tenantTransaction<void>(
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 { return {
outboxId, outboxId,
subscriptionsFound: 0, 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); const shouldMarkPublished = deliveredCount > 0 || (deliveredCount === 0 && failedCount === 0);
await this.database.tenantTransaction<void>(
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 { return {
outboxId, outboxId,
@ -392,13 +354,18 @@ export class PushDispatcher {
parsedActorId, parsedActorId,
async (client) => { async (client) => {
const result = await client.query<{ id: string }>( const result = await client.query<{ id: string }>(
`SELECT id `SELECT DISTINCT o.id
FROM staff_outbox FROM staff_outbox o
WHERE tenant_id = $1 JOIN staff_push_subscriptions s
AND published_at IS NULL ON s.tenant_id = o.tenant_id AND s.employee_id = o.subject_id
AND event_type IN ('attendance.clock.in', 'attendance.clock.out') LEFT JOIN staff_notification_deliveries d
AND attempts < 5 ON d.tenant_id = o.tenant_id
ORDER BY occurred_at ASC 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`, LIMIT $2`,
[parsedTenantId, limit], [parsedTenantId, limit],
); );

View file

@ -448,10 +448,10 @@ self.addEventListener('activate',e=>e.waitUntil(caches.keys().then(keys=>Promise
self.addEventListener('fetch',e=>{ self.addEventListener('fetch',e=>{
if(e.request.method==='GET'){ if(e.request.method==='GET'){
const url=new URL(e.request.url); const url=new URL(e.request.url);
if(url.pathname.startsWith('/api/v1/staff/')){ // Authenticated API responses can contain attendance and payroll data. They must never
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))); // enter the shared service-worker cache; offline personal data needs a user-bound,
return; // encrypted store with an explicit retention policy.
} if(url.pathname.startsWith('/api/v1/staff/'))return;
if(url.pathname.startsWith('/staff')){ 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))); 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'; 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);})); 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);}));
});`; });`;

View file

@ -208,7 +208,27 @@ export async function registerPortalRoutes(
async (request, reply) => { async (request, reply) => {
assertCookieMutationOrigin(request); assertCookieMutationOrigin(request);
const context = await resolveContext(identity, 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(); return reply.code(204).send();
}, },
); );

View file

@ -1,2 +1,2 @@
export const SERVICE_NAME = 'krona-staff'; export const SERVICE_NAME = 'krona-staff';
export const SERVICE_VERSION = '0.3.1'; export const SERVICE_VERSION = '0.3.2';

View file

@ -288,7 +288,7 @@ describe('PushDispatcher idempotent outbox handling', () => {
).rejects.toThrow(/Subscription endpoint must use HTTPS/); ).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([ const { database, queries } = mockDb([
// 1. SELECT staff_outbox // 1. SELECT staff_outbox
[ [
@ -304,8 +304,6 @@ describe('PushDispatcher idempotent outbox handling', () => {
], ],
// 2. SELECT staff_push_subscriptions (empty) // 2. SELECT staff_push_subscriptions (empty)
[], [],
// 3. UPDATE staff_outbox SET published_at = now()
[],
]); ]);
const dispatcher = new PushDispatcher({ database, vapidKeys, systemActorId }); const dispatcher = new PushDispatcher({ database, vapidKeys, systemActorId });
@ -313,8 +311,19 @@ describe('PushDispatcher idempotent outbox handling', () => {
expect(report.published).toBe(true); expect(report.published).toBe(true);
expect(report.subscriptionsFound).toBe(0); expect(report.subscriptionsFound).toBe(0);
expect(queries[2]?.sql).toContain('UPDATE staff_outbox'); expect(queries).toHaveLength(2);
expect(queries[2]?.sql).toContain('published_at = now()'); 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 () => { it('delivers push idempotently and avoids duplicate delivery', async () => {
@ -351,8 +360,6 @@ describe('PushDispatcher idempotent outbox handling', () => {
[{ id: newUlid(), status: 'pending', attempts: 0 }], [{ id: newUlid(), status: 'pending', attempts: 0 }],
// 5. UPDATE delivery to delivered // 5. UPDATE delivery to delivered
[], [],
// 6. UPDATE outbox to published
[],
]); ]);
const dispatcher = new PushDispatcher({ const dispatcher = new PushDispatcher({
@ -369,9 +376,9 @@ describe('PushDispatcher idempotent outbox handling', () => {
expect(report.deliveries[0]?.status).toBe('delivered'); expect(report.deliveries[0]?.status).toBe('delivered');
expect(mockFetch).toHaveBeenCalledTimes(1); 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_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 () => { it('skips sending if endpoint was already delivered (idempotency check)', async () => {