Compare commits
3 commits
f74aa6d7f5
...
2f1bdf549d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f1bdf549d | ||
|
|
87a6a447c2 | ||
|
|
fb1270a0a8 |
13 changed files with 1706 additions and 8 deletions
|
|
@ -590,6 +590,76 @@ paths:
|
|||
responses:
|
||||
'200': { description: Пакет импортирован повторяемо без дублирования }
|
||||
'403': { description: Недостаточно прав }
|
||||
/push/public-key:
|
||||
get:
|
||||
summary: Получить публичный VAPID-ключ для WebPush
|
||||
operationId: getPushPublicKey
|
||||
tags: [PushNotifications]
|
||||
responses:
|
||||
'200':
|
||||
description: Публичный VAPID-ключ
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [public_key]
|
||||
properties:
|
||||
public_key: { type: string }
|
||||
'400': { $ref: '#/components/responses/BadRequest' }
|
||||
/push/subscriptions:
|
||||
post:
|
||||
summary: Сохранить WebPush-подписку текущего сотрудника
|
||||
operationId: createPushSubscription
|
||||
tags: [PushNotifications]
|
||||
security: [{ bearerAuth: [] }]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [endpoint, keys]
|
||||
properties:
|
||||
endpoint: { type: string, minLength: 10, maxLength: 2000 }
|
||||
keys:
|
||||
type: object
|
||||
required: [p256dh, auth]
|
||||
properties:
|
||||
p256dh: { type: string, minLength: 10, maxLength: 250 }
|
||||
auth: { type: string, minLength: 10, maxLength: 100 }
|
||||
user_agent: { type: string, maxLength: 500 }
|
||||
responses:
|
||||
'201':
|
||||
description: Подписка сохранена
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [id, endpoint]
|
||||
properties:
|
||||
id: { type: string }
|
||||
endpoint: { type: string }
|
||||
'401': { description: Требуется авторизация }
|
||||
'403': { description: Недостаточно прав }
|
||||
'404': { description: Сотрудник не найден }
|
||||
delete:
|
||||
summary: Удалить WebPush-подписку
|
||||
operationId: deletePushSubscription
|
||||
tags: [PushNotifications]
|
||||
security: [{ bearerAuth: [] }]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [endpoint]
|
||||
properties:
|
||||
endpoint: { type: string, minLength: 10, maxLength: 2000 }
|
||||
responses:
|
||||
'204': { description: Подписка удалена }
|
||||
'401': { description: Требуется авторизация }
|
||||
'403': { description: Недостаточно прав }
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
|
|
|
|||
2
migrations/0010_employee_push_notifications.down.sql
Normal file
2
migrations/0010_employee_push_notifications.down.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
DROP TABLE IF EXISTS staff_notification_deliveries;
|
||||
DROP TABLE IF EXISTS staff_push_subscriptions;
|
||||
58
migrations/0010_employee_push_notifications.up.sql
Normal file
58
migrations/0010_employee_push_notifications.up.sql
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
-- Push subscriptions and delivery tracking for mobile employee notifications.
|
||||
-- Protected by PostgreSQL Row Level Security (RLS) and tenant isolation.
|
||||
|
||||
CREATE TABLE staff_push_subscriptions (
|
||||
id VARCHAR(26) PRIMARY KEY CHECK (id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||
tenant_id VARCHAR(26) NOT NULL CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||
employee_id VARCHAR(26) NOT NULL,
|
||||
endpoint TEXT NOT NULL,
|
||||
p256dh TEXT NOT NULL,
|
||||
auth TEXT NOT NULL,
|
||||
user_agent TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (tenant_id, id),
|
||||
UNIQUE (tenant_id, endpoint),
|
||||
FOREIGN KEY (tenant_id, employee_id) REFERENCES staff_employees (tenant_id, id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX staff_push_subscriptions_employee_idx
|
||||
ON staff_push_subscriptions (tenant_id, employee_id);
|
||||
|
||||
ALTER TABLE staff_push_subscriptions ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE staff_push_subscriptions FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY staff_push_subscriptions_tenant_isolation
|
||||
ON staff_push_subscriptions
|
||||
USING (tenant_id = current_setting('app.tenant_id', true))
|
||||
WITH CHECK (tenant_id = current_setting('app.tenant_id', true));
|
||||
|
||||
CREATE TABLE staff_notification_deliveries (
|
||||
id VARCHAR(26) PRIMARY KEY CHECK (id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||
tenant_id VARCHAR(26) NOT NULL CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||
employee_id VARCHAR(26) NOT NULL,
|
||||
outbox_id VARCHAR(26) NOT NULL CHECK (outbox_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||
endpoint TEXT NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'delivered', 'failed')),
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT,
|
||||
delivered_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (tenant_id, id),
|
||||
UNIQUE (tenant_id, outbox_id, endpoint),
|
||||
FOREIGN KEY (tenant_id, employee_id) REFERENCES staff_employees (tenant_id, id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (outbox_id) REFERENCES staff_outbox (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX staff_notification_deliveries_pending_idx
|
||||
ON staff_notification_deliveries (tenant_id, status) WHERE status = 'pending';
|
||||
|
||||
ALTER TABLE staff_notification_deliveries ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE staff_notification_deliveries FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY staff_notification_deliveries_tenant_isolation
|
||||
ON staff_notification_deliveries
|
||||
USING (tenant_id = current_setting('app.tenant_id', true))
|
||||
WITH CHECK (tenant_id = current_setting('app.tenant_id', true));
|
||||
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON staff_push_subscriptions TO krona_staff_app;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON staff_notification_deliveries TO krona_staff_app;
|
||||
|
|
@ -11,6 +11,8 @@ import { registerPortalRoutes } from './portal.js';
|
|||
import {
|
||||
portalCssEnhanced as portalCss,
|
||||
portalHtml,
|
||||
portalIcon192,
|
||||
portalIcon512,
|
||||
portalJsEnhanced as portalJs,
|
||||
portalManifest,
|
||||
portalServiceWorkerEnhanced as portalServiceWorker,
|
||||
|
|
@ -137,6 +139,8 @@ export async function buildApp({
|
|||
app.get('/staff/app.js', async (_request, reply) => reply.type('application/javascript; charset=utf-8').send(portalJs));
|
||||
app.get('/staff/sw.js', async (_request, reply) => reply.header('service-worker-allowed', '/staff').type('application/javascript; charset=utf-8').send(portalServiceWorker));
|
||||
app.get('/staff/manifest.webmanifest', async (_request, reply) => reply.type('application/manifest+json').send(portalManifest));
|
||||
app.get('/staff/icon-192.svg', async (_request, reply) => reply.type('image/svg+xml').send(portalIcon192));
|
||||
app.get('/staff/icon-512.svg', async (_request, reply) => reply.type('image/svg+xml').send(portalIcon512));
|
||||
|
||||
app.addHook('onClose', async () => database.close());
|
||||
return app;
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@ const configSchema = z.object({
|
|||
PLATFORM_JWKS_URL: optionalUrl(),
|
||||
STANDALONE_TENANT_ID: z.string().regex(/^[0-9A-HJKMNP-TV-Z]{26}$/),
|
||||
STANDALONE_ACTOR_ID: z.string().regex(/^[0-9A-HJKMNP-TV-Z]{26}$/),
|
||||
VAPID_PUBLIC_KEY: emptyStringAsUndefined(z.string().optional()),
|
||||
VAPID_PRIVATE_KEY: emptyStringAsUndefined(z.string().optional()),
|
||||
VAPID_SUBJECT: emptyStringAsUndefined(z.string().optional()),
|
||||
});
|
||||
|
||||
function emptyStringAsUndefined<T extends z.ZodTypeAny>(schema: T) {
|
||||
|
|
@ -42,6 +45,9 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
|||
if (Boolean(config.STANDALONE_MANAGER_TOKEN) !== Boolean(config.STANDALONE_TERMINAL_TOKEN)) {
|
||||
throw new Error('Both standalone manager and terminal tokens must be configured together');
|
||||
}
|
||||
if (Boolean(config.VAPID_PUBLIC_KEY) !== Boolean(config.VAPID_PRIVATE_KEY)) {
|
||||
throw new Error('VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY must be configured together');
|
||||
}
|
||||
if (config.NODE_ENV === 'production' && config.STAFF_RUNTIME_MODE === 'standalone') {
|
||||
throw new Error('STAFF_RUNTIME_MODE=standalone is not allowed in production');
|
||||
}
|
||||
|
|
|
|||
416
src/domain/notification/push-dispatcher.ts
Normal file
416
src/domain/notification/push-dispatcher.ts
Normal file
|
|
@ -0,0 +1,416 @@
|
|||
import type { Database } from '../../infrastructure/database.js';
|
||||
import { newUlid, parseUlid } from '../../core/ids.js';
|
||||
import {
|
||||
type PushSubscriptionTarget,
|
||||
type VapidKeys,
|
||||
type WebPushOptions,
|
||||
sendWebPush,
|
||||
} from './web-push.js';
|
||||
|
||||
export interface PushSubscriptionInput {
|
||||
endpoint: string;
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
user_agent?: string | undefined;
|
||||
}
|
||||
|
||||
export interface PushDispatcherOptions {
|
||||
database: Database;
|
||||
vapidKeys: VapidKeys;
|
||||
systemActorId: string;
|
||||
fetchFn?: typeof fetch | undefined;
|
||||
}
|
||||
|
||||
export interface DispatchDeliverySummary {
|
||||
endpoint: string;
|
||||
status: 'delivered' | 'failed' | 'skipped';
|
||||
error?: string | undefined;
|
||||
}
|
||||
|
||||
export interface DispatchReport {
|
||||
outboxId: string;
|
||||
subscriptionsFound: number;
|
||||
deliveries: DispatchDeliverySummary[];
|
||||
published: boolean;
|
||||
}
|
||||
|
||||
interface OutboxRow {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
event_type: string;
|
||||
subject_id: string;
|
||||
payload: unknown;
|
||||
published_at: string | null;
|
||||
attempts: number;
|
||||
}
|
||||
|
||||
interface SubscriptionRow {
|
||||
id: string;
|
||||
endpoint: string;
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
user_agent: string;
|
||||
}
|
||||
|
||||
interface DeliveryRow {
|
||||
id: string;
|
||||
status: 'pending' | 'delivered' | 'failed';
|
||||
attempts: number;
|
||||
}
|
||||
|
||||
export class PushDispatcher {
|
||||
private readonly database: Database;
|
||||
private readonly vapidKeys: VapidKeys;
|
||||
private readonly systemActorId: string;
|
||||
private readonly fetchFn?: typeof fetch | undefined;
|
||||
|
||||
constructor(options: PushDispatcherOptions) {
|
||||
this.database = options.database;
|
||||
this.vapidKeys = options.vapidKeys;
|
||||
this.systemActorId = options.systemActorId;
|
||||
this.fetchFn = options.fetchFn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save or update an employee's WebPush subscription.
|
||||
*/
|
||||
async saveSubscription(
|
||||
tenantId: string,
|
||||
employeeId: string,
|
||||
input: PushSubscriptionInput,
|
||||
): Promise<{ id: string; endpoint: string }> {
|
||||
if (!input.endpoint.startsWith('https://')) {
|
||||
throw new Error('Subscription endpoint must use HTTPS');
|
||||
}
|
||||
const cleanEndpoint = input.endpoint.trim();
|
||||
const cleanP256dh = input.p256dh.trim();
|
||||
const cleanAuth = input.auth.trim();
|
||||
const userAgent = (input.user_agent ?? '').trim();
|
||||
|
||||
if (!cleanEndpoint || !cleanP256dh || !cleanAuth) {
|
||||
throw new Error('Endpoint, p256dh, and auth are required for WebPush subscription');
|
||||
}
|
||||
|
||||
const subscriptionId = newUlid();
|
||||
const parsedTenantId = parseUlid(tenantId);
|
||||
const parsedActorId = parseUlid(this.systemActorId);
|
||||
const parsedEmployeeId = parseUlid(employeeId);
|
||||
|
||||
return this.database.tenantTransaction<{ id: string; endpoint: string }>(
|
||||
parsedTenantId,
|
||||
parsedActorId,
|
||||
async (client) => {
|
||||
const result = await client.query<{ id: string; endpoint: string }>(
|
||||
`INSERT INTO staff_push_subscriptions
|
||||
(id, tenant_id, employee_id, endpoint, p256dh, auth, user_agent, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, now())
|
||||
ON CONFLICT (tenant_id, endpoint)
|
||||
DO UPDATE SET employee_id = EXCLUDED.employee_id,
|
||||
p256dh = EXCLUDED.p256dh,
|
||||
auth = EXCLUDED.auth,
|
||||
user_agent = EXCLUDED.user_agent,
|
||||
updated_at = now()
|
||||
RETURNING id, endpoint`,
|
||||
[
|
||||
subscriptionId,
|
||||
parsedTenantId,
|
||||
parsedEmployeeId,
|
||||
cleanEndpoint,
|
||||
cleanP256dh,
|
||||
cleanAuth,
|
||||
userAgent,
|
||||
],
|
||||
);
|
||||
return result.rows[0]!;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an employee's WebPush subscription by endpoint.
|
||||
*/
|
||||
async deleteSubscription(tenantId: string, endpoint: string): Promise<boolean> {
|
||||
const parsedTenantId = parseUlid(tenantId);
|
||||
const parsedActorId = parseUlid(this.systemActorId);
|
||||
|
||||
return this.database.tenantTransaction<boolean>(
|
||||
parsedTenantId,
|
||||
parsedActorId,
|
||||
async (client) => {
|
||||
const result = await client.query(
|
||||
`DELETE FROM staff_push_subscriptions
|
||||
WHERE tenant_id = $1 AND endpoint = $2`,
|
||||
[parsedTenantId, endpoint],
|
||||
);
|
||||
return (result.rowCount ?? 0) > 0;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches push notifications for a specific transactional outbox record.
|
||||
* Ensures idempotency: skips already delivered endpoints and marks outbox.published_at.
|
||||
*/
|
||||
async dispatchForOutbox(tenantId: string, outboxId: string): Promise<DispatchReport> {
|
||||
const parsedTenantId = parseUlid(tenantId);
|
||||
const parsedActorId = parseUlid(this.systemActorId);
|
||||
const parsedOutboxId = parseUlid(outboxId);
|
||||
|
||||
const outboxRow = await this.database.tenantTransaction<OutboxRow | undefined>(
|
||||
parsedTenantId,
|
||||
parsedActorId,
|
||||
async (client) => {
|
||||
const result = await client.query<OutboxRow>(
|
||||
`SELECT id, tenant_id, event_type, subject_id, payload, published_at, attempts
|
||||
FROM staff_outbox
|
||||
WHERE tenant_id = $1 AND id = $2`,
|
||||
[parsedTenantId, parsedOutboxId],
|
||||
);
|
||||
return result.rows[0];
|
||||
},
|
||||
);
|
||||
|
||||
if (!outboxRow) {
|
||||
return {
|
||||
outboxId,
|
||||
subscriptionsFound: 0,
|
||||
deliveries: [],
|
||||
published: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (outboxRow.published_at) {
|
||||
return {
|
||||
outboxId,
|
||||
subscriptionsFound: 0,
|
||||
deliveries: [],
|
||||
published: true,
|
||||
};
|
||||
}
|
||||
|
||||
const employeeId = outboxRow.subject_id;
|
||||
const parsedEmployeeId = parseUlid(employeeId);
|
||||
|
||||
// Fetch active subscriptions for this employee
|
||||
const subscriptions = await this.database.tenantTransaction<SubscriptionRow[]>(
|
||||
parsedTenantId,
|
||||
parsedActorId,
|
||||
async (client) => {
|
||||
const result = await client.query<SubscriptionRow>(
|
||||
`SELECT id, endpoint, p256dh, auth, user_agent
|
||||
FROM staff_push_subscriptions
|
||||
WHERE tenant_id = $1 AND employee_id = $2`,
|
||||
[parsedTenantId, parsedEmployeeId],
|
||||
);
|
||||
return result.rows;
|
||||
},
|
||||
);
|
||||
|
||||
// If no subscriptions exist for this employee, mark as published
|
||||
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,
|
||||
deliveries: [],
|
||||
published: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Neutral notification content according to Requirement 2:
|
||||
// No employee names, no salary or personal data in the push text.
|
||||
const notificationPayload = JSON.stringify({
|
||||
title: 'Krona Staff',
|
||||
body: 'Отметка рабочего времени зарегистрирована',
|
||||
icon: '/staff/icon-192.svg',
|
||||
badge: '/staff/icon-192.svg',
|
||||
tag: 'attendance',
|
||||
data: {
|
||||
url: '/staff',
|
||||
event_type: outboxRow.event_type,
|
||||
},
|
||||
});
|
||||
|
||||
const deliverySummaries: DispatchDeliverySummary[] = [];
|
||||
let deliveredCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
for (const sub of subscriptions) {
|
||||
// 1. Idempotency check in staff_notification_deliveries
|
||||
const deliveryRecord = await this.database.tenantTransaction<DeliveryRow | undefined>(
|
||||
parsedTenantId,
|
||||
parsedActorId,
|
||||
async (client) => {
|
||||
const deliveryId = newUlid();
|
||||
await client.query(
|
||||
`INSERT INTO staff_notification_deliveries
|
||||
(id, tenant_id, employee_id, outbox_id, endpoint, status, attempts)
|
||||
VALUES ($1, $2, $3, $4, $5, 'pending', 0)
|
||||
ON CONFLICT (tenant_id, outbox_id, endpoint) DO NOTHING`,
|
||||
[deliveryId, parsedTenantId, parsedEmployeeId, parsedOutboxId, sub.endpoint],
|
||||
);
|
||||
const current = await client.query<DeliveryRow>(
|
||||
`SELECT id, status, attempts
|
||||
FROM staff_notification_deliveries
|
||||
WHERE tenant_id = $1 AND outbox_id = $2 AND endpoint = $3`,
|
||||
[parsedTenantId, parsedOutboxId, sub.endpoint],
|
||||
);
|
||||
return current.rows[0];
|
||||
},
|
||||
);
|
||||
|
||||
if (!deliveryRecord) continue;
|
||||
|
||||
if (deliveryRecord.status === 'delivered') {
|
||||
deliverySummaries.push({
|
||||
endpoint: sub.endpoint,
|
||||
status: 'skipped',
|
||||
});
|
||||
deliveredCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (deliveryRecord.attempts >= 5) {
|
||||
deliverySummaries.push({
|
||||
endpoint: sub.endpoint,
|
||||
status: 'skipped',
|
||||
error: 'Max delivery attempts exceeded',
|
||||
});
|
||||
failedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2. Dispatch via WebPush
|
||||
const target: PushSubscriptionTarget = {
|
||||
endpoint: sub.endpoint,
|
||||
keys: { p256dh: sub.p256dh, auth: sub.auth },
|
||||
};
|
||||
|
||||
const sendOptions: WebPushOptions = this.fetchFn ? { fetchFn: this.fetchFn } : {};
|
||||
const result = await sendWebPush(target, notificationPayload, this.vapidKeys, sendOptions);
|
||||
|
||||
if (result.successful) {
|
||||
await this.database.tenantTransaction<void>(
|
||||
parsedTenantId,
|
||||
parsedActorId,
|
||||
async (client) => {
|
||||
await client.query(
|
||||
`UPDATE staff_notification_deliveries
|
||||
SET status = 'delivered', attempts = attempts + 1, delivered_at = now(),
|
||||
last_error = NULL, updated_at = now()
|
||||
WHERE tenant_id = $1 AND id = $2`,
|
||||
[parsedTenantId, deliveryRecord.id],
|
||||
);
|
||||
},
|
||||
);
|
||||
deliverySummaries.push({ endpoint: sub.endpoint, status: 'delivered' });
|
||||
deliveredCount++;
|
||||
} else {
|
||||
await this.database.tenantTransaction<void>(
|
||||
parsedTenantId,
|
||||
parsedActorId,
|
||||
async (client) => {
|
||||
await client.query(
|
||||
`UPDATE staff_notification_deliveries
|
||||
SET status = 'failed', attempts = attempts + 1,
|
||||
last_error = $3, updated_at = now()
|
||||
WHERE tenant_id = $1 AND id = $2`,
|
||||
[parsedTenantId, deliveryRecord.id, result.error ?? 'Unknown delivery failure'],
|
||||
);
|
||||
|
||||
// Expired subscription cleanup on 404/410
|
||||
if (result.shouldRemoveSubscription) {
|
||||
await client.query(
|
||||
`DELETE FROM staff_push_subscriptions
|
||||
WHERE tenant_id = $1 AND endpoint = $2`,
|
||||
[parsedTenantId, sub.endpoint],
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
deliverySummaries.push({
|
||||
endpoint: sub.endpoint,
|
||||
status: 'failed',
|
||||
error: result.error,
|
||||
});
|
||||
failedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Update staff_outbox status
|
||||
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,
|
||||
subscriptionsFound: subscriptions.length,
|
||||
deliveries: deliverySummaries,
|
||||
published: shouldMarkPublished,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Process all pending attendance outbox events.
|
||||
*/
|
||||
async dispatchPending(tenantId: string, limit = 20): Promise<DispatchReport[]> {
|
||||
const parsedTenantId = parseUlid(tenantId);
|
||||
const parsedActorId = parseUlid(this.systemActorId);
|
||||
|
||||
const pendingRows = await this.database.tenantTransaction<Array<{ id: string }>>(
|
||||
parsedTenantId,
|
||||
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
|
||||
LIMIT $2`,
|
||||
[parsedTenantId, limit],
|
||||
);
|
||||
return result.rows;
|
||||
},
|
||||
);
|
||||
|
||||
const reports: DispatchReport[] = [];
|
||||
for (const row of pendingRows) {
|
||||
const report = await this.dispatchForOutbox(tenantId, row.id);
|
||||
reports.push(report);
|
||||
}
|
||||
return reports;
|
||||
}
|
||||
}
|
||||
269
src/domain/notification/web-push.ts
Normal file
269
src/domain/notification/web-push.ts
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
import crypto from 'node:crypto';
|
||||
|
||||
export interface VapidKeys {
|
||||
publicKey: string; // base64url uncompressed P-256 (65 bytes)
|
||||
privateKey: string; // base64url scalar D (32 bytes) or PEM
|
||||
subject: string;
|
||||
}
|
||||
|
||||
export interface PushSubscriptionKeys {
|
||||
p256dh: string; // base64url
|
||||
auth: string; // base64url
|
||||
}
|
||||
|
||||
export interface PushSubscriptionTarget {
|
||||
endpoint: string;
|
||||
keys: PushSubscriptionKeys;
|
||||
}
|
||||
|
||||
export interface WebPushSendResult {
|
||||
statusCode: number;
|
||||
successful: boolean;
|
||||
shouldRemoveSubscription: boolean; // 404 Not Found or 410 Gone
|
||||
error?: string | undefined;
|
||||
}
|
||||
|
||||
export interface WebPushOptions {
|
||||
fetchFn?: typeof fetch | undefined;
|
||||
ttl?: number | undefined;
|
||||
urgency?: ('very-low' | 'low' | 'normal' | 'high') | undefined;
|
||||
}
|
||||
|
||||
let cachedEphemeralKeys: VapidKeys | null = null;
|
||||
|
||||
/**
|
||||
* Generate a fresh ECDSA P-256 key pair suitable for WebPush VAPID.
|
||||
*/
|
||||
export function generateVapidKeys(subject = 'mailto:admin@krona.local'): VapidKeys {
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync('ec', {
|
||||
namedCurve: 'prime256v1',
|
||||
});
|
||||
const pubJwk = publicKey.export({ format: 'jwk' });
|
||||
const privJwk = privateKey.export({ format: 'jwk' });
|
||||
|
||||
if (!pubJwk.x || !pubJwk.y || !privJwk.d) {
|
||||
throw new Error('Failed to generate EC P-256 key pair');
|
||||
}
|
||||
|
||||
const rawPub = Buffer.concat([
|
||||
Buffer.from([0x04]),
|
||||
Buffer.from(pubJwk.x, 'base64url'),
|
||||
Buffer.from(pubJwk.y, 'base64url'),
|
||||
]);
|
||||
|
||||
return {
|
||||
publicKey: rawPub.toString('base64url'),
|
||||
privateKey: privJwk.d,
|
||||
subject,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load VAPID keys from config or environment, or fallback to an in-memory ephemeral key pair.
|
||||
*/
|
||||
export function resolveVapidKeys(config: {
|
||||
VAPID_PUBLIC_KEY?: string | undefined;
|
||||
VAPID_PRIVATE_KEY?: string | undefined;
|
||||
VAPID_SUBJECT?: string | undefined;
|
||||
}): VapidKeys {
|
||||
const subject = config.VAPID_SUBJECT || 'mailto:admin@krona.local';
|
||||
|
||||
if (config.VAPID_PUBLIC_KEY && config.VAPID_PRIVATE_KEY) {
|
||||
return {
|
||||
publicKey: config.VAPID_PUBLIC_KEY,
|
||||
privateKey: config.VAPID_PRIVATE_KEY,
|
||||
subject,
|
||||
};
|
||||
}
|
||||
|
||||
if (Boolean(config.VAPID_PUBLIC_KEY) !== Boolean(config.VAPID_PRIVATE_KEY)) {
|
||||
throw new Error('VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY must be configured together');
|
||||
}
|
||||
|
||||
// Safe ephemeral fallback in memory for dev/test
|
||||
if (!cachedEphemeralKeys) {
|
||||
cachedEphemeralKeys = generateVapidKeys(subject);
|
||||
}
|
||||
return cachedEphemeralKeys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert VAPID private key string to a KeyObject.
|
||||
*/
|
||||
function createVapidPrivateKeyObject(vapidKeys: VapidKeys): crypto.KeyObject {
|
||||
const priv = vapidKeys.privateKey.trim();
|
||||
if (priv.startsWith('-----BEGIN')) {
|
||||
return crypto.createPrivateKey({ key: priv, format: 'pem' });
|
||||
}
|
||||
|
||||
// Raw 32-byte base64url scalar D
|
||||
const pubBuf = Buffer.from(vapidKeys.publicKey, 'base64url');
|
||||
if (pubBuf.length !== 65 || pubBuf[0] !== 0x04) {
|
||||
throw new Error('Invalid VAPID public key length or format');
|
||||
}
|
||||
const x = pubBuf.subarray(1, 33).toString('base64url');
|
||||
const y = pubBuf.subarray(33, 65).toString('base64url');
|
||||
|
||||
return crypto.createPrivateKey({
|
||||
key: {
|
||||
kty: 'EC',
|
||||
crv: 'P-256',
|
||||
x,
|
||||
y,
|
||||
d: vapidKeys.privateKey,
|
||||
},
|
||||
format: 'jwk',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a VAPID Authorization header (RFC 8292).
|
||||
*/
|
||||
export function createVapidAuthorizationHeader(
|
||||
endpoint: string,
|
||||
vapidKeys: VapidKeys,
|
||||
expirationSeconds = 12 * 3600,
|
||||
): string {
|
||||
const url = new URL(endpoint);
|
||||
const audience = `${url.protocol}//${url.host}`;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const exp = now + expirationSeconds;
|
||||
|
||||
const header = { typ: 'JWT', alg: 'ES256' };
|
||||
const payload = { aud: audience, exp, sub: vapidKeys.subject };
|
||||
|
||||
const headerB64 = Buffer.from(JSON.stringify(header)).toString('base64url');
|
||||
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||
const signingInput = `${headerB64}.${payloadB64}`;
|
||||
|
||||
const privateKey = createVapidPrivateKeyObject(vapidKeys);
|
||||
const signature = crypto.sign('SHA256', Buffer.from(signingInput, 'utf8'), {
|
||||
key: privateKey,
|
||||
dsaEncoding: 'ieee-p1363',
|
||||
});
|
||||
|
||||
const jwt = `${signingInput}.${signature.toString('base64url')}`;
|
||||
return `vapid t=${jwt}, k=${vapidKeys.publicKey}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a payload using AES-128-GCM content encoding (RFC 8291 / RFC 8188).
|
||||
*/
|
||||
export function encryptPushPayload(
|
||||
payload: string | Buffer,
|
||||
keys: PushSubscriptionKeys,
|
||||
): Buffer {
|
||||
const plaintext = Buffer.isBuffer(payload) ? payload : Buffer.from(payload, 'utf8');
|
||||
const recipientP256dh = Buffer.from(keys.p256dh, 'base64url');
|
||||
const recipientAuth = Buffer.from(keys.auth, 'base64url');
|
||||
|
||||
if (recipientP256dh.length !== 65 || recipientP256dh[0] !== 0x04) {
|
||||
throw new Error('Invalid recipient p256dh key: expected 65-byte uncompressed point');
|
||||
}
|
||||
if (recipientAuth.length !== 16) {
|
||||
throw new Error('Invalid recipient auth secret: expected 16 bytes');
|
||||
}
|
||||
|
||||
// 1. Generate ephemeral sender key pair
|
||||
const sender = crypto.createECDH('prime256v1');
|
||||
sender.generateKeys();
|
||||
const senderPublicKey = sender.getPublicKey(); // 65 bytes
|
||||
const sharedSecret = sender.computeSecret(recipientP256dh);
|
||||
|
||||
// 2. Derive PRK via HKDF-Extract & Expand (RFC 8291 Section 3.2)
|
||||
const keyInfo = Buffer.concat([
|
||||
Buffer.from('WebPush: info\0', 'utf8'),
|
||||
recipientP256dh,
|
||||
senderPublicKey,
|
||||
]);
|
||||
const prk = Buffer.from(
|
||||
crypto.hkdfSync('sha256', sharedSecret, recipientAuth, keyInfo, 32),
|
||||
);
|
||||
|
||||
// 3. Generate 16-byte random salt
|
||||
const salt = crypto.randomBytes(16);
|
||||
|
||||
// 4. Derive CEK and NONCE (RFC 8291 Section 3.3)
|
||||
const cekInfo = Buffer.from('Content-Encoding: aes128gcm\0', 'utf8');
|
||||
const cek = Buffer.from(crypto.hkdfSync('sha256', prk, salt, cekInfo, 16));
|
||||
|
||||
const nonceInfo = Buffer.from('Content-Encoding: nonce\0', 'utf8');
|
||||
const nonce = Buffer.from(crypto.hkdfSync('sha256', prk, salt, nonceInfo, 12));
|
||||
|
||||
// 5. Append padding delimiter (0x02 for the final/only record in RFC 8188)
|
||||
const record = Buffer.concat([plaintext, Buffer.from([0x02])]);
|
||||
|
||||
// 6. Encrypt with AES-128-GCM
|
||||
const cipher = crypto.createCipheriv('aes-128-gcm', cek, nonce);
|
||||
const ciphertext = Buffer.concat([cipher.update(record), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
// 7. Build RFC 8188 header:
|
||||
// salt (16) + rs (4, uint32 BE = 4096) + idlen (1, uint8 = 65) + keyid (65, senderPublicKey)
|
||||
const rs = 4096;
|
||||
const rsBuf = Buffer.alloc(4);
|
||||
rsBuf.writeUInt32BE(rs, 0);
|
||||
|
||||
const header = Buffer.concat([
|
||||
salt,
|
||||
rsBuf,
|
||||
Buffer.from([senderPublicKey.length]),
|
||||
senderPublicKey,
|
||||
]);
|
||||
|
||||
return Buffer.concat([header, ciphertext, tag]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an encrypted WebPush notification over HTTPS.
|
||||
*/
|
||||
export async function sendWebPush(
|
||||
target: PushSubscriptionTarget,
|
||||
payload: string,
|
||||
vapidKeys: VapidKeys,
|
||||
options?: WebPushOptions,
|
||||
): Promise<WebPushSendResult> {
|
||||
if (!target.endpoint.startsWith('https://')) {
|
||||
throw new Error('Subscription endpoint must use HTTPS');
|
||||
}
|
||||
|
||||
const encryptedBody = encryptPushPayload(payload, target.keys);
|
||||
const authHeader = createVapidAuthorizationHeader(target.endpoint, vapidKeys);
|
||||
const fetchImpl = options?.fetchFn ?? fetch;
|
||||
|
||||
try {
|
||||
const response = await fetchImpl(target.endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': authHeader,
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Content-Encoding': 'aes128gcm',
|
||||
'TTL': String(options?.ttl ?? 86400),
|
||||
'Urgency': options?.urgency ?? 'normal',
|
||||
},
|
||||
body: new Uint8Array(encryptedBody),
|
||||
});
|
||||
|
||||
const statusCode = response.status;
|
||||
if (statusCode >= 200 && statusCode < 300) {
|
||||
return { statusCode, successful: true, shouldRemoveSubscription: false };
|
||||
}
|
||||
|
||||
const shouldRemoveSubscription = statusCode === 404 || statusCode === 410;
|
||||
const bodyText = await response.text().catch(() => '');
|
||||
return {
|
||||
statusCode,
|
||||
successful: false,
|
||||
shouldRemoveSubscription,
|
||||
error: `Push service rejected notification: HTTP ${statusCode}${bodyText ? ` (${bodyText.slice(0, 200)})` : ''}`,
|
||||
};
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
statusCode: 0,
|
||||
successful: false,
|
||||
shouldRemoveSubscription: false,
|
||||
error: `Network error delivering push: ${errorMsg}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
138
src/portal-ui.ts
138
src/portal-ui.ts
|
|
@ -6,6 +6,11 @@ export const portalHtml = `<!doctype html>
|
|||
<meta name="theme-color" content="#657255">
|
||||
<title>Krona Staff</title>
|
||||
<link rel="manifest" href="/staff/manifest.webmanifest">
|
||||
<link rel="icon" type="image/svg+xml" href="/staff/icon-192.svg">
|
||||
<link rel="apple-touch-icon" href="/staff/icon-192.svg">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||
<link rel="stylesheet" href="/staff/styles.css">
|
||||
<style>[hidden]{display:none!important}.auth-screen{min-height:100vh;display:grid;place-items:center;padding:28px;background:var(--omni-bg-workspace)}.auth-card{width:min(100%,460px);padding:42px;background:var(--omni-surface-raised);border:1px solid var(--omni-border);border-radius:var(--omni-radius-xl);box-shadow:var(--omni-shadow-strong)}.auth-brand{display:flex;align-items:center;gap:12px;margin-bottom:42px;font-size:20px}.auth-card h1{margin:6px 0 12px;font:500 38px/1.08 var(--omni-font-serif)}.auth-copy{margin:0 0 26px;color:var(--omni-text-secondary);line-height:1.55}.auth-card form{display:grid;gap:10px}.auth-card label{font-size:13px;font-weight:700}.auth-card input{width:100%;min-height:50px;padding:0 14px;border:1px solid var(--omni-border-strong);border-radius:var(--omni-radius-md);background:var(--omni-surface-raised);color:var(--omni-text-primary)}.auth-card button{margin-top:6px;min-height:50px}.auth-card small{display:block;margin-top:20px;color:var(--omni-text-secondary);line-height:1.45}.login-error{margin-top:16px;padding:12px 14px;border:1px solid var(--omni-danger);border-radius:var(--omni-radius-md);background:var(--omni-danger-soft);color:var(--omni-danger)}@media(max-width:520px){.auth-screen{padding:16px}.auth-card{padding:28px 22px}.auth-card h1{font-size:32px}.auth-brand{margin-bottom:32px}}</style>
|
||||
</head>
|
||||
|
|
@ -51,7 +56,7 @@ export const portalHtml = `<!doctype html>
|
|||
</section>
|
||||
<section class="layout">
|
||||
<article class="panel attendance-panel"><div class="panel-head"><div><p class="eyebrow">Последние события</p><h2>Отметки терминала</h2></div><span class="live">● в реальном времени</span></div><div id="attendance" class="feed"></div></article>
|
||||
<aside class="panel people-panel"><div class="panel-head"><div><p class="eyebrow">Команда</p><h2>Сотрудники</h2></div><div class="actions"><button id="manage-professions" class="quiet compact" type="button">Профессии</button><button id="add-employee" class="quiet compact" type="button">Добавить</button></div></div><div id="employees" class="people"></div></aside>
|
||||
<aside class="panel people-panel manager-only"><div class="panel-head"><div><p class="eyebrow">Команда</p><h2>Сотрудники</h2></div><div class="actions"><button id="manage-professions" class="quiet compact" type="button">Профессии</button><button id="add-employee" class="quiet compact" type="button">Добавить</button></div></div><div id="employees" class="people"></div></aside>
|
||||
</section>
|
||||
<section class="panel sessions-panel timesheet-panel"><div class="panel-head"><div><p class="eyebrow">Сверка терминала</p><h2>Рабочие смены</h2></div><button id="reconcile-sessions" class="quiet" type="button">Сверить 7 дней</button></div><div class="table-wrap"><table><thead><tr><th>Дата</th><th>Сотрудник</th><th>Подразделение</th><th>Интервал</th><th>Часы</th><th>Состояние</th><th>Причина правки</th><th></th></tr></thead><tbody id="work-sessions"></tbody></table></div></section>
|
||||
<section class="panel timesheet-panel"><div class="panel-head"><div><p class="eyebrow">Учёт времени</p><h2>Табель за 40 дней</h2></div><span id="timesheet-help">Изменения сохраняет руководитель</span></div><form id="timesheet-create" class="timesheet-create"><label>Сотрудник<select id="new-time-employee" required></select></label><label>Дата<input id="new-time-date" type="date" required></label><label>Часы<input id="new-time-hours" type="number" min="0" max="24" step="0.25" value="8" required></label><label>Статус<select id="new-time-status"><option value="draft">Черновик</option><option value="submitted">На проверке</option><option value="approved">Утверждён</option><option value="rejected">Возвращён</option></select></label><label class="grow">Примечание<input id="new-time-note" maxlength="1000" placeholder="Необязательно"></label><button type="submit">Добавить строку</button></form><div class="table-wrap"><table><thead><tr><th>Дата</th><th>Сотрудник</th><th>Часы</th><th>Статус</th><th>Примечание</th><th></th></tr></thead><tbody id="timesheet"></tbody></table></div></section>
|
||||
|
|
@ -139,6 +144,17 @@ body,.auth-screen{background:var(--omni-bg-workspace);color:var(--omni-text-prim
|
|||
.payroll-warnings ul{margin:6px 0 0 18px;padding:0}
|
||||
.payroll-warnings li{margin-bottom:4px}
|
||||
.section-nav{background:color-mix(in srgb,var(--omni-surface) 94%,transparent)}.period-control input,.stack-form input,.stack-form select,.inline-form input,.inline-form select{background:var(--omni-surface-raised)}.shift-card{background:var(--omni-primary-soft)}.shift-card.actual{background:var(--omni-gold-soft)}.payroll-warnings{background:var(--omni-warning-soft)!important;border-color:color-mix(in srgb,var(--omni-warning) 35%,transparent)!important;color:var(--omni-warning)!important}[data-omni-theme="graphite-forest-dark"]{--omni-warning:#E0B96C;--omni-warning-soft:#403522}
|
||||
@media(max-width:680px){
|
||||
body{padding-bottom:76px}
|
||||
.section-nav{position:fixed;bottom:0;top:auto;left:0;right:0;z-index:90;display:flex;justify-content:space-around;padding:8px 8px calc(8px + env(safe-area-inset-bottom,0px));background:var(--omni-surface);border-top:1px solid var(--omni-border);border-bottom:none;box-shadow:0 -4px 18px rgba(0,0,0,.08);gap:4px}
|
||||
.section-nav .nav-item{flex:1 1 auto;text-align:center;min-height:42px;padding:6px 4px;font-size:13px;border-radius:8px}
|
||||
.period-control{display:none}
|
||||
.status.offline{color:var(--omni-warning,#B78335);font-weight:700;font-size:12px}
|
||||
.people-panel{order:unset}
|
||||
.status,#notify{display:inline-flex}
|
||||
}
|
||||
#notify.active{background:var(--omni-primary-soft,#DDE7DB);color:var(--omni-primary,#315F46)}
|
||||
.status.offline{color:var(--omni-warning,#B78335);font-weight:600}
|
||||
`;
|
||||
|
||||
export const portalJsEnhanced =
|
||||
|
|
@ -324,21 +340,133 @@ export const portalJsEnhanced =
|
|||
});
|
||||
}
|
||||
|
||||
const notifyBtn=byId('notify');
|
||||
async function updatePushStatus(){
|
||||
if(!('serviceWorker' in navigator)||!('PushManager' in window)||!notifyBtn) return;
|
||||
try{
|
||||
const reg=await navigator.serviceWorker.ready;
|
||||
const sub=await reg.pushManager.getSubscription();
|
||||
notifyBtn.textContent=sub?'Уведомления включены':'Включить push';
|
||||
notifyBtn.classList.toggle('active',!!sub);
|
||||
}catch{}
|
||||
}
|
||||
if(notifyBtn){
|
||||
notifyBtn.addEventListener('click',async()=>{
|
||||
if(!('serviceWorker' in navigator)||!('PushManager' in window)){
|
||||
alert('Этот браузер не поддерживает WebPush-уведомления.');
|
||||
return;
|
||||
}
|
||||
const reg=await navigator.serviceWorker.ready;
|
||||
const existing=await reg.pushManager.getSubscription();
|
||||
if(existing){
|
||||
try{await send('/push/subscriptions','DELETE',{endpoint:existing.endpoint});}catch{}
|
||||
await existing.unsubscribe();
|
||||
await updatePushStatus();
|
||||
return;
|
||||
}
|
||||
const perm=await Notification.requestPermission();
|
||||
if(perm!=='granted'){
|
||||
alert('Разрешение на push-уведомления не предоставлено.');
|
||||
return;
|
||||
}
|
||||
try{
|
||||
notifyBtn.disabled=true;
|
||||
const res=await get('/push/public-key');
|
||||
const pub=res.public_key;
|
||||
const padding='='.repeat((4-(pub.length%4))%4);
|
||||
const b64=(pub+padding).replace(/-/g,'+').replace(/_/g,'/');
|
||||
const raw=atob(b64);
|
||||
const arr=new Uint8Array(raw.length);
|
||||
for(let i=0;i<raw.length;++i) arr[i]=raw.charCodeAt(i);
|
||||
const sub=await reg.pushManager.subscribe({userVisibleOnly:true,applicationServerKey:arr});
|
||||
const json=sub.toJSON();
|
||||
await send('/push/subscriptions','POST',{
|
||||
endpoint:sub.endpoint,
|
||||
keys:{p256dh:json.keys.p256dh,auth:json.keys.auth},
|
||||
user_agent:navigator.userAgent
|
||||
});
|
||||
await updatePushStatus();
|
||||
const msg=byId('message');
|
||||
if(msg){
|
||||
msg.textContent='Push-уведомления о фиксации отпечатка успешно включены!';
|
||||
msg.hidden=false;
|
||||
setTimeout(()=>{msg.hidden=true;},4000);
|
||||
}
|
||||
}catch(err){
|
||||
alert('Ошибка подключения push: '+(err.message||err));
|
||||
}finally{
|
||||
notifyBtn.disabled=false;
|
||||
}
|
||||
});
|
||||
setTimeout(updatePushStatus,500);
|
||||
}
|
||||
window.addEventListener('online',()=>{
|
||||
const conn=byId('connection');if(conn){conn.textContent='Синхронизировано';conn.className='status ok';}
|
||||
loadActive();
|
||||
});
|
||||
window.addEventListener('offline',()=>{
|
||||
const conn=byId('connection');if(conn){conn.textContent='Нет связи. Показаны сохранённые данные';conn.className='status offline';}
|
||||
});
|
||||
|
||||
const observer=new MutationObserver(()=>{if(role!=='manager')return;document.querySelectorAll('.edit-employee').forEach(edit=>{if(edit.parentElement.querySelector('.person-access'))return;const button=document.createElement('button');button.type='button';button.className='quiet compact person-access';button.dataset.id=edit.dataset.id;button.textContent='Личный вход';edit.insertAdjacentElement('afterend',button)});fillSelects()});observer.observe(byId('employees'),{childList:true,subtree:true});const shellObserver=new MutationObserver(()=>{if(!byId('app-shell').hidden){initialized=false;initialize()}else initialized=false});shellObserver.observe(byId('app-shell'),{attributes:true,attributeFilter:['hidden']});setTimeout(initialize,0);
|
||||
})();
|
||||
`;
|
||||
|
||||
export const portalIcon192 = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192" width="192" height="192"><rect width="192" height="192" rx="44" fill="#315F46"/><circle cx="96" cy="96" r="64" fill="#657255" opacity="0.35"/><path d="M 60 48 L 60 144 M 60 96 L 132 48 M 90 76 L 132 144" stroke="#B78335" stroke-width="16" stroke-linecap="round" stroke-linejoin="round" fill="none"/><circle cx="60" cy="48" r="6" fill="#EFE0C2"/><circle cx="132" cy="48" r="6" fill="#EFE0C2"/><circle cx="132" cy="144" r="6" fill="#EFE0C2"/></svg>`;
|
||||
|
||||
export const portalIcon512 = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512"><rect width="512" height="512" rx="120" fill="#315F46"/><circle cx="256" cy="256" r="170" fill="#657255" opacity="0.35"/><path d="M 160 128 L 160 384 M 160 256 L 352 128 M 240 200 L 352 384" stroke="#B78335" stroke-width="44" stroke-linecap="round" stroke-linejoin="round" fill="none"/><circle cx="160" cy="128" r="16" fill="#EFE0C2"/><circle cx="352" cy="128" r="16" fill="#EFE0C2"/><circle cx="352" cy="384" r="16" fill="#EFE0C2"/></svg>`;
|
||||
|
||||
export const portalManifest = JSON.stringify({
|
||||
name: 'Krona Staff',
|
||||
short_name: 'Staff',
|
||||
start_url: '/staff',
|
||||
scope: '/staff',
|
||||
display: 'standalone',
|
||||
background_color: '#F3EAD7',
|
||||
theme_color: '#657255',
|
||||
lang: 'ru',
|
||||
icons: [
|
||||
{
|
||||
src: '/staff/icon-192.svg',
|
||||
sizes: '192x192',
|
||||
type: 'image/svg+xml',
|
||||
purpose: 'any maskable',
|
||||
},
|
||||
{
|
||||
src: '/staff/icon-512.svg',
|
||||
sizes: '512x512',
|
||||
type: 'image/svg+xml',
|
||||
purpose: 'any maskable',
|
||||
},
|
||||
],
|
||||
});
|
||||
export const portalServiceWorker = `const CACHE='krona-staff-v3';const ASSETS=['/staff','/staff/styles.css','/staff/app.js','/staff/manifest.webmanifest'];self.addEventListener('install',e=>e.waitUntil(caches.open(CACHE).then(c=>c.addAll(ASSETS))));self.addEventListener('activate',e=>e.waitUntil(caches.keys().then(keys=>Promise.all(keys.filter(k=>k!==CACHE).map(k=>caches.delete(k))))));self.addEventListener('fetch',e=>{if(e.request.method==='GET'&&new URL(e.request.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)));});`;
|
||||
export const portalServiceWorkerEnhanced = portalServiceWorker.replace(
|
||||
'krona-staff-v3',
|
||||
'krona-staff-v4',
|
||||
);
|
||||
export const portalServiceWorkerEnhanced = `const CACHE='krona-staff-v5';
|
||||
const ASSETS=['/staff','/staff/styles.css','/staff/app.js','/staff/manifest.webmanifest','/staff/icon-192.svg','/staff/icon-512.svg'];
|
||||
self.addEventListener('install',e=>e.waitUntil(caches.open(CACHE).then(c=>c.addAll(ASSETS)).then(()=>self.skipWaiting())));
|
||||
self.addEventListener('activate',e=>e.waitUntil(caches.keys().then(keys=>Promise.all(keys.filter(k=>k!==CACHE).map(k=>caches.delete(k)))).then(()=>self.clients.claim())));
|
||||
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;
|
||||
}
|
||||
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)));
|
||||
}
|
||||
}
|
||||
});
|
||||
self.addEventListener('push',e=>{
|
||||
let data={title:'Krona Staff',body:'Отметка рабочего времени зарегистрирована',icon:'/staff/icon-192.svg',badge:'/staff/icon-192.svg',tag:'attendance',data:{url:'/staff'}};
|
||||
if(e.data){
|
||||
try{data=Object.assign(data,e.data.json());}catch{data.body=e.data.text()||data.body;}
|
||||
}
|
||||
e.waitUntil(self.registration.showNotification(data.title,{body:data.body,icon:data.icon||'/staff/icon-192.svg',badge:data.badge||'/staff/icon-192.svg',tag:data.tag||'attendance',renotify:true,data:data.data||{url:'/staff'}}));
|
||||
});
|
||||
self.addEventListener('notificationclick',e=>{
|
||||
e.notification.close();
|
||||
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);}));
|
||||
});`;
|
||||
|
||||
|
|
|
|||
155
src/portal.ts
155
src/portal.ts
|
|
@ -8,9 +8,24 @@ import { PlatformIdentityAdapter, StandaloneIdentityAdapter } from './adapters/i
|
|||
import type { Database } from './infrastructure/database.js';
|
||||
import { reconcileWorkSession } from './domain/attendance/work-session.js';
|
||||
import { registerOperationsRoutes } from './operations.js';
|
||||
import { resolveVapidKeys } from './domain/notification/web-push.js';
|
||||
import { PushDispatcher } from './domain/notification/push-dispatcher.js';
|
||||
|
||||
const datePattern = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
interface PushSubscriptionBody {
|
||||
endpoint: string;
|
||||
keys: {
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
};
|
||||
user_agent?: string | undefined;
|
||||
}
|
||||
|
||||
interface DeleteSubscriptionBody {
|
||||
endpoint: string;
|
||||
}
|
||||
|
||||
interface AttendanceBody {
|
||||
event_id?: string;
|
||||
employee_id: string;
|
||||
|
|
@ -83,6 +98,121 @@ export async function registerPortalRoutes(
|
|||
: new StandaloneIdentityAdapter(config);
|
||||
const identity = withStandaloneEmployeeAccess(baseIdentity, config, database);
|
||||
|
||||
const vapidKeys = resolveVapidKeys(config);
|
||||
const pushDispatcher = new PushDispatcher({
|
||||
database,
|
||||
vapidKeys,
|
||||
systemActorId: config.STANDALONE_ACTOR_ID,
|
||||
});
|
||||
|
||||
api.get(
|
||||
'/push/public-key',
|
||||
{
|
||||
schema: {
|
||||
tags: ['PushNotifications'],
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
required: ['public_key'],
|
||||
properties: { public_key: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (_request, reply) => {
|
||||
reply.header('cache-control', 'public, max-age=86400');
|
||||
return { public_key: vapidKeys.publicKey };
|
||||
},
|
||||
);
|
||||
|
||||
api.post<{ Body: PushSubscriptionBody }>(
|
||||
'/push/subscriptions',
|
||||
{
|
||||
schema: {
|
||||
tags: ['PushNotifications'],
|
||||
body: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['endpoint', 'keys'],
|
||||
properties: {
|
||||
endpoint: { type: 'string', minLength: 10, maxLength: 2000 },
|
||||
keys: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['p256dh', 'auth'],
|
||||
properties: {
|
||||
p256dh: { type: 'string', minLength: 10, maxLength: 250 },
|
||||
auth: { type: 'string', minLength: 10, maxLength: 100 },
|
||||
},
|
||||
},
|
||||
user_agent: { type: 'string', maxLength: 500 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
assertCookieMutationOrigin(request);
|
||||
const context = await resolveContext(identity, request);
|
||||
const canReadOwn = context.permissions.has('staff:attendance:read_own');
|
||||
const canManage = context.permissions.has('staff:attendance:manage');
|
||||
if (!canReadOwn && !canManage) {
|
||||
return reply.forbidden('Недостаточно прав для настройки push-уведомлений.');
|
||||
}
|
||||
|
||||
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('Сотрудник для текущего пользователя не найден.');
|
||||
}
|
||||
|
||||
const saved = await pushDispatcher.saveSubscription(
|
||||
context.tenantId,
|
||||
employee.id,
|
||||
{
|
||||
endpoint: request.body.endpoint,
|
||||
p256dh: request.body.keys.p256dh,
|
||||
auth: request.body.keys.auth,
|
||||
user_agent: request.body.user_agent,
|
||||
},
|
||||
);
|
||||
return reply.code(201).send(saved);
|
||||
},
|
||||
);
|
||||
|
||||
api.delete<{ Body: DeleteSubscriptionBody }>(
|
||||
'/push/subscriptions',
|
||||
{
|
||||
schema: {
|
||||
tags: ['PushNotifications'],
|
||||
body: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['endpoint'],
|
||||
properties: {
|
||||
endpoint: { type: 'string', minLength: 10, maxLength: 2000 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
assertCookieMutationOrigin(request);
|
||||
const context = await resolveContext(identity, request);
|
||||
await pushDispatcher.deleteSubscription(context.tenantId, request.body.endpoint);
|
||||
return reply.code(204).send();
|
||||
},
|
||||
);
|
||||
|
||||
api.post<{ Body: SessionBody }>(
|
||||
'/session',
|
||||
{
|
||||
|
|
@ -653,6 +783,7 @@ export async function registerPortalRoutes(
|
|||
);
|
||||
if (inserted.rowCount === 0) return { id: eventId, duplicate: true };
|
||||
|
||||
const outboxId = newUlid();
|
||||
const eventType =
|
||||
request.body.event_type === 'check_in' ? 'attendance.clock.in' : 'attendance.clock.out';
|
||||
await client.query(
|
||||
|
|
@ -661,7 +792,7 @@ export async function registerPortalRoutes(
|
|||
actor_type, actor_id, payload, correlation_id, occurred_at)
|
||||
VALUES ($1, $2, $3, 1, 'Employee', $4, 'service', $5, $6, $7, $8)`,
|
||||
[
|
||||
newUlid(),
|
||||
outboxId,
|
||||
context.tenantId,
|
||||
eventType,
|
||||
employeeId,
|
||||
|
|
@ -671,11 +802,29 @@ export async function registerPortalRoutes(
|
|||
occurredAt.toISOString(),
|
||||
],
|
||||
);
|
||||
return { id: eventId, duplicate: false };
|
||||
return { id: eventId, outbox_id: outboxId, duplicate: false };
|
||||
},
|
||||
);
|
||||
if (!result) return reply.notFound('Активный сотрудник не найден.');
|
||||
return reply.code(result.duplicate ? 200 : 201).send(result);
|
||||
|
||||
if (!result.duplicate && result.outbox_id) {
|
||||
const outboxIdToDispatch = result.outbox_id;
|
||||
setImmediate(() => {
|
||||
void pushDispatcher
|
||||
.dispatchForOutbox(context.tenantId, outboxIdToDispatch)
|
||||
.catch((err: unknown) => {
|
||||
request.log.error(
|
||||
{ err, outboxId: outboxIdToDispatch },
|
||||
'Background push dispatch error',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(result.duplicate ? 200 : 201).send({
|
||||
id: result.id,
|
||||
duplicate: result.duplicate,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ describeDatabase('PostgreSQL tenant isolation', () => {
|
|||
const actorB = parseUlid('01J0000000000000000000001B');
|
||||
const eventId = newUlid();
|
||||
const professionId = newUlid();
|
||||
const employeeIdA = newUlid();
|
||||
const subscriptionIdA = newUlid();
|
||||
const deliveryIdA = newUlid();
|
||||
|
||||
beforeAll(async () => {
|
||||
const role = await database.pool.query(
|
||||
|
|
@ -23,8 +26,11 @@ describeDatabase('PostgreSQL tenant isolation', () => {
|
|||
|
||||
afterAll(async () => {
|
||||
await database.tenantTransaction(tenantA, actorA, async (client) => {
|
||||
await client.query('DELETE FROM staff_notification_deliveries WHERE id = $1', [deliveryIdA]);
|
||||
await client.query('DELETE FROM staff_push_subscriptions WHERE id = $1', [subscriptionIdA]);
|
||||
await client.query('DELETE FROM staff_outbox WHERE id = $1', [eventId]);
|
||||
await client.query('DELETE FROM staff_professions WHERE id = $1', [professionId]);
|
||||
await client.query('DELETE FROM staff_employees WHERE id = $1', [employeeIdA]);
|
||||
});
|
||||
await database.close();
|
||||
});
|
||||
|
|
@ -102,5 +108,58 @@ describeDatabase('PostgreSQL tenant isolation', () => {
|
|||
),
|
||||
),
|
||||
).rejects.toThrow();
|
||||
|
||||
// Push subscriptions tenant isolation
|
||||
await database.tenantTransaction(tenantA, actorA, async (client) => {
|
||||
await client.query(
|
||||
`INSERT INTO staff_employees (id, tenant_id, personnel_number, display_name)
|
||||
VALUES ($1, $2, 'EMP-PUSH-1', 'Тестовый сотрудник')`,
|
||||
[employeeIdA, tenantA],
|
||||
);
|
||||
await client.query(
|
||||
`INSERT INTO staff_push_subscriptions (id, tenant_id, employee_id, endpoint, p256dh, auth)
|
||||
VALUES ($1, $2, $3, 'https://push.example.com/subA', 'test-p256dh', 'test-auth')`,
|
||||
[subscriptionIdA, tenantA, employeeIdA],
|
||||
);
|
||||
});
|
||||
|
||||
const subVisibleToB = await database.tenantTransaction(tenantB, actorB, (client) =>
|
||||
client.query('SELECT id FROM staff_push_subscriptions WHERE id = $1', [subscriptionIdA]),
|
||||
);
|
||||
expect(subVisibleToB.rowCount).toBe(0);
|
||||
|
||||
await expect(
|
||||
database.tenantTransaction(tenantB, actorB, (client) =>
|
||||
client.query(
|
||||
`INSERT INTO staff_push_subscriptions (id, tenant_id, employee_id, endpoint, p256dh, auth)
|
||||
VALUES ($1, $2, $3, 'https://push.example.com/subB', 'key', 'auth')`,
|
||||
[newUlid(), tenantA, employeeIdA],
|
||||
),
|
||||
),
|
||||
).rejects.toThrow();
|
||||
|
||||
// Notification deliveries tenant isolation
|
||||
await database.tenantTransaction(tenantA, actorA, async (client) => {
|
||||
await client.query(
|
||||
`INSERT INTO staff_notification_deliveries (id, tenant_id, employee_id, outbox_id, endpoint, status)
|
||||
VALUES ($1, $2, $3, $4, 'https://push.example.com/subA', 'delivered')`,
|
||||
[deliveryIdA, tenantA, employeeIdA, eventId],
|
||||
);
|
||||
});
|
||||
|
||||
const delVisibleToB = await database.tenantTransaction(tenantB, actorB, (client) =>
|
||||
client.query('SELECT id FROM staff_notification_deliveries WHERE id = $1', [deliveryIdA]),
|
||||
);
|
||||
expect(delVisibleToB.rowCount).toBe(0);
|
||||
|
||||
await expect(
|
||||
database.tenantTransaction(tenantB, actorB, (client) =>
|
||||
client.query(
|
||||
`INSERT INTO staff_notification_deliveries (id, tenant_id, employee_id, outbox_id, endpoint, status)
|
||||
VALUES ($1, $2, $3, $4, 'https://push.example.com/subB', 'pending')`,
|
||||
[newUlid(), tenantA, employeeIdA, eventId],
|
||||
),
|
||||
),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -28,4 +28,22 @@ describe('runtime modes', () => {
|
|||
PLATFORM_JWKS_URL: '',
|
||||
}).PLATFORM_GATEWAY_URL,
|
||||
).toBeUndefined());
|
||||
it('requires VAPID keys to be configured together if provided', () => {
|
||||
expect(() =>
|
||||
loadConfig({ ...base, VAPID_PUBLIC_KEY: 'pubkey' }),
|
||||
).toThrow(/VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY must be configured together/);
|
||||
expect(() =>
|
||||
loadConfig({ ...base, VAPID_PRIVATE_KEY: 'privkey' }),
|
||||
).toThrow(/VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY must be configured together/);
|
||||
const config = loadConfig({
|
||||
...base,
|
||||
VAPID_PUBLIC_KEY: 'pubkey',
|
||||
VAPID_PRIVATE_KEY: 'privkey',
|
||||
VAPID_SUBJECT: 'mailto:ops@krona.local',
|
||||
});
|
||||
expect(config.VAPID_PUBLIC_KEY).toBe('pubkey');
|
||||
expect(config.VAPID_PRIVATE_KEY).toBe('privkey');
|
||||
expect(config.VAPID_SUBJECT).toBe('mailto:ops@krona.local');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
477
tests/unit/employee-push.test.ts
Normal file
477
tests/unit/employee-push.test.ts
Normal file
|
|
@ -0,0 +1,477 @@
|
|||
import crypto from 'node:crypto';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { newUlid } from '../../src/core/ids.js';
|
||||
import {
|
||||
createVapidAuthorizationHeader,
|
||||
encryptPushPayload,
|
||||
generateVapidKeys,
|
||||
resolveVapidKeys,
|
||||
sendWebPush,
|
||||
} from '../../src/domain/notification/web-push.js';
|
||||
import { PushDispatcher } from '../../src/domain/notification/push-dispatcher.js';
|
||||
|
||||
function createTestRecipient() {
|
||||
const ecdh = crypto.createECDH('prime256v1');
|
||||
ecdh.generateKeys();
|
||||
const auth = crypto.randomBytes(16);
|
||||
return {
|
||||
ecdh,
|
||||
publicKey: ecdh.getPublicKey(), // 65 bytes
|
||||
auth,
|
||||
keys: {
|
||||
p256dh: ecdh.getPublicKey().toString('base64url'),
|
||||
auth: auth.toString('base64url'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function decryptPushPayload(
|
||||
encrypted: Buffer,
|
||||
recipient: ReturnType<typeof createTestRecipient>,
|
||||
): string {
|
||||
const salt = encrypted.subarray(0, 16);
|
||||
const idlen = encrypted[20]!;
|
||||
const senderPublicKey = encrypted.subarray(21, 21 + idlen);
|
||||
const ciphertextAndTag = encrypted.subarray(21 + idlen);
|
||||
const ciphertext = ciphertextAndTag.subarray(0, ciphertextAndTag.length - 16);
|
||||
const tag = ciphertextAndTag.subarray(ciphertextAndTag.length - 16);
|
||||
|
||||
const sharedSecret = recipient.ecdh.computeSecret(senderPublicKey);
|
||||
const keyInfo = Buffer.concat([
|
||||
Buffer.from('WebPush: info\0', 'utf8'),
|
||||
recipient.publicKey,
|
||||
senderPublicKey,
|
||||
]);
|
||||
const prk = Buffer.from(
|
||||
crypto.hkdfSync('sha256', sharedSecret, recipient.auth, keyInfo, 32),
|
||||
);
|
||||
|
||||
const cekInfo = Buffer.from('Content-Encoding: aes128gcm\0', 'utf8');
|
||||
const cek = Buffer.from(crypto.hkdfSync('sha256', prk, salt, cekInfo, 16));
|
||||
|
||||
const nonceInfo = Buffer.from('Content-Encoding: nonce\0', 'utf8');
|
||||
const nonce = Buffer.from(crypto.hkdfSync('sha256', prk, salt, nonceInfo, 12));
|
||||
|
||||
const decipher = crypto.createDecipheriv('aes-128-gcm', cek, nonce);
|
||||
decipher.setAuthTag(tag);
|
||||
const decryptedRecord = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||
|
||||
// Strip trailing delimiter byte (0x02)
|
||||
expect(decryptedRecord[decryptedRecord.length - 1]).toBe(0x02);
|
||||
return decryptedRecord.subarray(0, decryptedRecord.length - 1).toString('utf8');
|
||||
}
|
||||
|
||||
describe('WebPush VAPID key management', () => {
|
||||
it('generates valid uncompressed P-256 public keys and scalar private keys', () => {
|
||||
const keys = generateVapidKeys('mailto:test@krona.local');
|
||||
const pubBuf = Buffer.from(keys.publicKey, 'base64url');
|
||||
expect(pubBuf).toHaveLength(65);
|
||||
expect(pubBuf[0]).toBe(0x04);
|
||||
expect(keys.subject).toBe('mailto:test@krona.local');
|
||||
expect(keys.privateKey.length).toBeGreaterThanOrEqual(40);
|
||||
});
|
||||
|
||||
it('provides safe ephemeral fallback keys in development/test when env keys are absent', () => {
|
||||
const keys1 = resolveVapidKeys({});
|
||||
const keys2 = resolveVapidKeys({});
|
||||
expect(keys1.publicKey).toBe(keys2.publicKey);
|
||||
expect(keys1.privateKey).toBe(keys2.privateKey);
|
||||
});
|
||||
|
||||
it('rejects mismatched VAPID configuration when only one key is set', () => {
|
||||
expect(() =>
|
||||
resolveVapidKeys({ VAPID_PUBLIC_KEY: 'only-pub' }),
|
||||
).toThrow(/must be configured together/);
|
||||
expect(() =>
|
||||
resolveVapidKeys({ VAPID_PRIVATE_KEY: 'only-priv' }),
|
||||
).toThrow(/must be configured together/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WebPush RFC 8292 VAPID Authorization', () => {
|
||||
it('signs an ES256 JWT with audience matching the push service origin', () => {
|
||||
const vapidKeys = generateVapidKeys('mailto:admin@krona.local');
|
||||
const endpoint = 'https://fcm.googleapis.com/fcm/send/sub-123';
|
||||
const authHeader = createVapidAuthorizationHeader(endpoint, vapidKeys);
|
||||
|
||||
expect(authHeader).toMatch(/^vapid t=[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+, k=/);
|
||||
|
||||
const match = authHeader.match(/^vapid t=([^,]+), k=(.+)$/);
|
||||
expect(match).not.toBeNull();
|
||||
const jwt = match?.[1];
|
||||
const pubKey = match?.[2];
|
||||
expect(pubKey).toBe(vapidKeys.publicKey);
|
||||
if (!jwt) {
|
||||
throw new Error('jwt missing');
|
||||
}
|
||||
|
||||
const parts = jwt.split('.');
|
||||
expect(parts).toHaveLength(3);
|
||||
const [headerB64, payloadB64, sigB64] = parts;
|
||||
if (!headerB64 || !payloadB64 || !sigB64) {
|
||||
throw new Error('jwt parts missing');
|
||||
}
|
||||
|
||||
const header = JSON.parse(Buffer.from(headerB64, 'base64url').toString('utf8')) as {
|
||||
typ: string;
|
||||
alg: string;
|
||||
};
|
||||
const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString('utf8')) as {
|
||||
aud: string;
|
||||
sub: string;
|
||||
exp: number;
|
||||
};
|
||||
|
||||
expect(header).toEqual({ typ: 'JWT', alg: 'ES256' });
|
||||
expect(payload.aud).toBe('https://fcm.googleapis.com');
|
||||
expect(payload.sub).toBe('mailto:admin@krona.local');
|
||||
expect(payload.exp).toBeGreaterThan(Math.floor(Date.now() / 1000));
|
||||
|
||||
// Verify ECDSA P-256 signature
|
||||
const signingInput = `${headerB64}.${payloadB64}`;
|
||||
const pubBuf = Buffer.from(vapidKeys.publicKey, 'base64url');
|
||||
const x = pubBuf.subarray(1, 33).toString('base64url');
|
||||
const y = pubBuf.subarray(33, 65).toString('base64url');
|
||||
const publicKeyObject = crypto.createPublicKey({
|
||||
key: { kty: 'EC', crv: 'P-256', x, y },
|
||||
format: 'jwk',
|
||||
});
|
||||
|
||||
const isValid = crypto.verify(
|
||||
'SHA256',
|
||||
Buffer.from(signingInput, 'utf8'),
|
||||
{ key: publicKeyObject, dsaEncoding: 'ieee-p1363' },
|
||||
Buffer.from(sigB64, 'base64url'),
|
||||
);
|
||||
expect(isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WebPush RFC 8291 / RFC 8188 AES-128-GCM encryption', () => {
|
||||
it('encrypts and successfully decrypts payload with recipient keys', () => {
|
||||
const recipient = createTestRecipient();
|
||||
const originalText = JSON.stringify({
|
||||
title: 'Krona Staff',
|
||||
body: 'Отметка рабочего времени зарегистрирована',
|
||||
});
|
||||
|
||||
const encrypted = encryptPushPayload(originalText, recipient.keys);
|
||||
expect(encrypted.length).toBeGreaterThan(originalText.length);
|
||||
|
||||
// Verify RFC 8188 header structure
|
||||
const salt = encrypted.subarray(0, 16);
|
||||
expect(salt).toHaveLength(16);
|
||||
const rs = encrypted.readUInt32BE(16);
|
||||
expect(rs).toBe(4096);
|
||||
const idlen = encrypted[20];
|
||||
expect(idlen).toBe(65);
|
||||
|
||||
// Decrypt and verify roundtrip
|
||||
const decrypted = decryptPushPayload(encrypted, recipient);
|
||||
expect(decrypted).toBe(originalText);
|
||||
});
|
||||
|
||||
it('rejects invalid recipient keys with clear error', () => {
|
||||
expect(() =>
|
||||
encryptPushPayload('test', { p256dh: 'invalid', auth: 'invalid' }),
|
||||
).toThrow(/Invalid recipient/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Push notification privacy (Requirement 2)', () => {
|
||||
it('guarantees push notification body contains only neutral text without PII', () => {
|
||||
const payload = {
|
||||
title: 'Krona Staff',
|
||||
body: 'Отметка рабочего времени зарегистрирована',
|
||||
icon: '/staff/icon-192.svg',
|
||||
badge: '/staff/icon-192.svg',
|
||||
tag: 'attendance',
|
||||
data: { url: '/staff', event_type: 'attendance.clock.in' },
|
||||
};
|
||||
|
||||
const serialized = JSON.stringify(payload);
|
||||
// Neutral text check
|
||||
expect(payload.body).toBe('Отметка рабочего времени зарегистрирована');
|
||||
// Ensure no name, salary, personal identifiers, or money values
|
||||
expect(serialized).not.toMatch(/Иван|Петр|Сидор|зарплат|руб|₽|amount|salary|minor/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendWebPush delivery handling', () => {
|
||||
it('enforces HTTPS on subscription endpoints', async () => {
|
||||
const vapidKeys = generateVapidKeys();
|
||||
const recipient = createTestRecipient();
|
||||
await expect(
|
||||
sendWebPush(
|
||||
{ endpoint: 'http://insecure.example.com/push', keys: recipient.keys },
|
||||
'test',
|
||||
vapidKeys,
|
||||
),
|
||||
).rejects.toThrow(/Subscription endpoint must use HTTPS/);
|
||||
});
|
||||
|
||||
it('reports successful delivery on 201 Created from push service', async () => {
|
||||
const vapidKeys = generateVapidKeys();
|
||||
const recipient = createTestRecipient();
|
||||
const mockFetch = vi.fn(async () => new Response(null, { status: 201 }));
|
||||
|
||||
const result = await sendWebPush(
|
||||
{ endpoint: 'https://push.example.com/sub/1', keys: recipient.keys },
|
||||
'test payload',
|
||||
vapidKeys,
|
||||
{ fetchFn: mockFetch as unknown as typeof fetch },
|
||||
);
|
||||
|
||||
expect(result.successful).toBe(true);
|
||||
expect(result.statusCode).toBe(201);
|
||||
expect(result.shouldRemoveSubscription).toBe(false);
|
||||
});
|
||||
|
||||
it('flags 404 and 410 for expired subscription removal', async () => {
|
||||
const vapidKeys = generateVapidKeys();
|
||||
const recipient = createTestRecipient();
|
||||
const mockFetch410 = vi.fn(async () => new Response('Gone', { status: 410 }));
|
||||
|
||||
const result = await sendWebPush(
|
||||
{ endpoint: 'https://push.example.com/sub/expired', keys: recipient.keys },
|
||||
'test payload',
|
||||
vapidKeys,
|
||||
{ fetchFn: mockFetch410 as unknown as typeof fetch },
|
||||
);
|
||||
|
||||
expect(result.successful).toBe(false);
|
||||
expect(result.statusCode).toBe(410);
|
||||
expect(result.shouldRemoveSubscription).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PushDispatcher idempotent outbox handling', () => {
|
||||
const tenantId = '01J00000000000000000000000';
|
||||
const systemActorId = '01J00000000000000000000001';
|
||||
const employeeId = '01J00000000000000000000002';
|
||||
const outboxId = '01J00000000000000000000003';
|
||||
const vapidKeys = generateVapidKeys();
|
||||
|
||||
function mockDb(responses: Array<Array<Record<string, unknown>>>) {
|
||||
const queries: Array<{ sql: string; params: readonly unknown[] | undefined }> = [];
|
||||
const database = {
|
||||
tenantTransaction: vi.fn(
|
||||
async (
|
||||
_tenant: unknown,
|
||||
_actor: unknown,
|
||||
callback: (client: { query: typeof queryFn }) => Promise<unknown>,
|
||||
) => {
|
||||
return callback({ query: queryFn });
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
async function queryFn(sql: string, params?: readonly unknown[]) {
|
||||
queries.push({ sql, params });
|
||||
const rows = responses.shift() ?? [];
|
||||
return { rows, rowCount: rows.length };
|
||||
}
|
||||
|
||||
return { database: database as never, queries };
|
||||
}
|
||||
|
||||
it('rejects saving non-HTTPS subscription endpoints', async () => {
|
||||
const { database } = mockDb([]);
|
||||
const dispatcher = new PushDispatcher({ database, vapidKeys, systemActorId });
|
||||
|
||||
await expect(
|
||||
dispatcher.saveSubscription(tenantId, employeeId, {
|
||||
endpoint: 'http://plain-http.example.com/sub',
|
||||
p256dh: 'p256dh',
|
||||
auth: 'auth',
|
||||
}),
|
||||
).rejects.toThrow(/Subscription endpoint must use HTTPS/);
|
||||
});
|
||||
|
||||
it('marks outbox published when employee has no subscriptions', async () => {
|
||||
const { database, queries } = mockDb([
|
||||
// 1. SELECT staff_outbox
|
||||
[
|
||||
{
|
||||
id: outboxId,
|
||||
tenant_id: tenantId,
|
||||
event_type: 'attendance.clock.in',
|
||||
subject_id: employeeId,
|
||||
payload: {},
|
||||
published_at: null,
|
||||
attempts: 0,
|
||||
},
|
||||
],
|
||||
// 2. SELECT staff_push_subscriptions (empty)
|
||||
[],
|
||||
// 3. UPDATE staff_outbox SET published_at = now()
|
||||
[],
|
||||
]);
|
||||
|
||||
const dispatcher = new PushDispatcher({ database, vapidKeys, systemActorId });
|
||||
const report = await dispatcher.dispatchForOutbox(tenantId, outboxId);
|
||||
|
||||
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()');
|
||||
});
|
||||
|
||||
it('delivers push idempotently and avoids duplicate delivery', async () => {
|
||||
const recipient = createTestRecipient();
|
||||
const endpoint = 'https://push.example.com/sub/active';
|
||||
const mockFetch = vi.fn(async () => new Response(null, { status: 201 }));
|
||||
|
||||
const { database, queries } = mockDb([
|
||||
// 1. SELECT staff_outbox
|
||||
[
|
||||
{
|
||||
id: outboxId,
|
||||
tenant_id: tenantId,
|
||||
event_type: 'attendance.clock.in',
|
||||
subject_id: employeeId,
|
||||
payload: {},
|
||||
published_at: null,
|
||||
attempts: 0,
|
||||
},
|
||||
],
|
||||
// 2. SELECT staff_push_subscriptions
|
||||
[
|
||||
{
|
||||
id: newUlid(),
|
||||
endpoint,
|
||||
p256dh: recipient.keys.p256dh,
|
||||
auth: recipient.keys.auth,
|
||||
user_agent: 'TestAgent',
|
||||
},
|
||||
],
|
||||
// 3. INSERT staff_notification_deliveries ON CONFLICT DO NOTHING
|
||||
[],
|
||||
// 4. SELECT current delivery record
|
||||
[{ id: newUlid(), status: 'pending', attempts: 0 }],
|
||||
// 5. UPDATE delivery to delivered
|
||||
[],
|
||||
// 6. UPDATE outbox to published
|
||||
[],
|
||||
]);
|
||||
|
||||
const dispatcher = new PushDispatcher({
|
||||
database,
|
||||
vapidKeys,
|
||||
systemActorId,
|
||||
fetchFn: mockFetch,
|
||||
});
|
||||
|
||||
const report = await dispatcher.dispatchForOutbox(tenantId, outboxId);
|
||||
|
||||
expect(report.published).toBe(true);
|
||||
expect(report.subscriptionsFound).toBe(1);
|
||||
expect(report.deliveries[0]?.status).toBe('delivered');
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Verify outbox and delivery updates
|
||||
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);
|
||||
});
|
||||
|
||||
it('skips sending if endpoint was already delivered (idempotency check)', async () => {
|
||||
const recipient = createTestRecipient();
|
||||
const endpoint = 'https://push.example.com/sub/active';
|
||||
const mockFetch = vi.fn(async () => new Response(null, { status: 201 }));
|
||||
|
||||
const { database } = mockDb([
|
||||
// 1. SELECT staff_outbox
|
||||
[
|
||||
{
|
||||
id: outboxId,
|
||||
tenant_id: tenantId,
|
||||
event_type: 'attendance.clock.in',
|
||||
subject_id: employeeId,
|
||||
payload: {},
|
||||
published_at: null,
|
||||
attempts: 0,
|
||||
},
|
||||
],
|
||||
// 2. SELECT staff_push_subscriptions
|
||||
[
|
||||
{
|
||||
id: newUlid(),
|
||||
endpoint,
|
||||
p256dh: recipient.keys.p256dh,
|
||||
auth: recipient.keys.auth,
|
||||
user_agent: 'TestAgent',
|
||||
},
|
||||
],
|
||||
// 3. INSERT delivery ON CONFLICT DO NOTHING
|
||||
[],
|
||||
// 4. SELECT current delivery: already 'delivered'
|
||||
[{ id: newUlid(), status: 'delivered', attempts: 1 }],
|
||||
// 5. UPDATE outbox to published
|
||||
[],
|
||||
]);
|
||||
|
||||
const dispatcher = new PushDispatcher({
|
||||
database,
|
||||
vapidKeys,
|
||||
systemActorId,
|
||||
fetchFn: mockFetch,
|
||||
});
|
||||
|
||||
const report = await dispatcher.dispatchForOutbox(tenantId, outboxId);
|
||||
|
||||
expect(report.deliveries[0]?.status).toBe('skipped');
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
expect(report.published).toBe(true);
|
||||
});
|
||||
|
||||
it('deletes expired subscription from staff_push_subscriptions on 410 Gone', async () => {
|
||||
const recipient = createTestRecipient();
|
||||
const endpoint = 'https://push.example.com/sub/expired';
|
||||
const mockFetch = vi.fn(async () => new Response('Expired', { status: 410 }));
|
||||
|
||||
const { database, queries } = mockDb([
|
||||
// 1. SELECT staff_outbox
|
||||
[
|
||||
{
|
||||
id: outboxId,
|
||||
tenant_id: tenantId,
|
||||
event_type: 'attendance.clock.out',
|
||||
subject_id: employeeId,
|
||||
payload: {},
|
||||
published_at: null,
|
||||
attempts: 0,
|
||||
},
|
||||
],
|
||||
// 2. SELECT staff_push_subscriptions
|
||||
[
|
||||
{
|
||||
id: newUlid(),
|
||||
endpoint,
|
||||
p256dh: recipient.keys.p256dh,
|
||||
auth: recipient.keys.auth,
|
||||
user_agent: 'TestAgent',
|
||||
},
|
||||
],
|
||||
// 3. INSERT delivery
|
||||
[],
|
||||
// 4. SELECT delivery
|
||||
[{ id: newUlid(), status: 'pending', attempts: 0 }],
|
||||
// 5. UPDATE delivery to failed AND DELETE subscription
|
||||
[],
|
||||
// 6. UPDATE outbox
|
||||
[],
|
||||
]);
|
||||
|
||||
const dispatcher = new PushDispatcher({
|
||||
database,
|
||||
vapidKeys,
|
||||
systemActorId,
|
||||
fetchFn: mockFetch,
|
||||
});
|
||||
|
||||
const report = await dispatcher.dispatchForOutbox(tenantId, outboxId);
|
||||
|
||||
expect(report.deliveries[0]?.status).toBe('failed');
|
||||
expect(queries.some((q) => q.sql.includes('DELETE FROM staff_push_subscriptions'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -139,3 +139,45 @@ describe('employee access migration', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('employee push notifications migration', () => {
|
||||
const sql = readFileSync('migrations/0010_employee_push_notifications.up.sql', 'utf8');
|
||||
const tables = ['staff_push_subscriptions', 'staff_notification_deliveries'];
|
||||
|
||||
it.each(tables)('%s is tenant scoped and forces RLS', (table) => {
|
||||
const create = sql.slice(
|
||||
sql.indexOf(`CREATE TABLE ${table}`),
|
||||
sql.indexOf(';', sql.indexOf(`CREATE TABLE ${table}`)),
|
||||
);
|
||||
expect(create).toContain('tenant_id VARCHAR(26) NOT NULL');
|
||||
expect(sql).toContain(`ALTER TABLE ${table} ENABLE ROW LEVEL SECURITY`);
|
||||
expect(sql).toContain(`ALTER TABLE ${table} FORCE ROW LEVEL SECURITY`);
|
||||
expect(sql).toContain(`CREATE POLICY ${table}_tenant_isolation`);
|
||||
expect(sql).toContain(`tenant_id = current_setting('app.tenant_id', true)`);
|
||||
});
|
||||
|
||||
it('declares uniqueness and idempotency constraints with tenant scope', () => {
|
||||
expect(sql).toContain('UNIQUE (tenant_id, id)');
|
||||
expect(sql).toContain('UNIQUE (tenant_id, endpoint)');
|
||||
expect(sql).toContain('UNIQUE (tenant_id, outbox_id, endpoint)');
|
||||
});
|
||||
|
||||
it('uses tenant-scoped foreign keys to staff_employees and staff_outbox', () => {
|
||||
expect(sql).toContain(
|
||||
'FOREIGN KEY (tenant_id, employee_id) REFERENCES staff_employees (tenant_id, id) ON DELETE CASCADE',
|
||||
);
|
||||
expect(sql).toContain(
|
||||
'FOREIGN KEY (outbox_id) REFERENCES staff_outbox (id) ON DELETE CASCADE',
|
||||
);
|
||||
});
|
||||
|
||||
it('grants required operations on both push tables to krona_staff_app', () => {
|
||||
expect(sql).toContain(
|
||||
'GRANT SELECT, INSERT, UPDATE, DELETE ON staff_push_subscriptions TO krona_staff_app;',
|
||||
);
|
||||
expect(sql).toContain(
|
||||
'GRANT SELECT, INSERT, UPDATE, DELETE ON staff_notification_deliveries TO krona_staff_app;',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue