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
info:
title: Krona Staff API
version: 0.3.1
version: 0.3.2
description: API посещаемости Krona Staff для кабинета сотрудника, терминала и табеля руководителя.
license:
name: Proprietary

View file

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

View file

@ -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,

View file

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

View file

@ -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; }

View file

@ -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}

View file

@ -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 \

View file

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

View file

@ -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"

View file

@ -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": [],

4
package-lock.json generated
View file

@ -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",

View file

@ -1,6 +1,6 @@
{
"name": "@krona/staff",
"version": "0.3.1",
"version": "0.3.2",
"private": true,
"type": "module",
"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;
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<boolean> {
async deleteSubscription(
tenantId: string,
employeeId: string,
endpoint: string,
): Promise<boolean> {
const parsedTenantId = parseUlid(tenantId);
const parsedActorId = parseUlid(this.systemActorId);
const parsedEmployeeId = parseUlid(employeeId);
return this.database.tenantTransaction<boolean>(
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<DispatchReport> {
const parsedTenantId = parseUlid(tenantId);
@ -161,7 +164,7 @@ export class PushDispatcher {
parsedActorId,
async (client) => {
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
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<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 {
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<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 {
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],
);

View file

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

View file

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

View file

@ -1,2 +1,2 @@
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/);
});
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 () => {