fix(push): isolate employee data and preserve platform outbox
Some checks are pending
ci / verify (push) Waiting to run
Some checks are pending
ci / verify (push) Waiting to run
This commit is contained in:
parent
2f1bdf549d
commit
3e2054480c
18 changed files with 115 additions and 90 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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)` |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
4
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@krona/staff",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
|
|
|||
21
scripts/generate-vapid-keys.mjs
Normal file
21
scripts/generate-vapid-keys.mjs
Normal 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`,
|
||||
);
|
||||
|
|
@ -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],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);}));
|
||||
});`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
export const SERVICE_NAME = 'krona-staff';
|
||||
export const SERVICE_VERSION = '0.3.1';
|
||||
export const SERVICE_VERSION = '0.3.2';
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue