From fb1270a0a87d71515c6b92f5a5f02ec14c4cebe7 Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Sat, 26 Sep 2026 12:08:26 +0700 Subject: [PATCH] feat(notification): employee push subscriptions, rls migrations, and web-push dispatcher --- .../0010_employee_push_notifications.down.sql | 2 + .../0010_employee_push_notifications.up.sql | 58 +++ src/app.ts | 4 + src/config.ts | 6 + src/domain/notification/push-dispatcher.ts | 416 ++++++++++++++++++ src/domain/notification/web-push.ts | 269 +++++++++++ 6 files changed, 755 insertions(+) create mode 100644 migrations/0010_employee_push_notifications.down.sql create mode 100644 migrations/0010_employee_push_notifications.up.sql create mode 100644 src/domain/notification/push-dispatcher.ts create mode 100644 src/domain/notification/web-push.ts diff --git a/migrations/0010_employee_push_notifications.down.sql b/migrations/0010_employee_push_notifications.down.sql new file mode 100644 index 0000000..006fee2 --- /dev/null +++ b/migrations/0010_employee_push_notifications.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS staff_notification_deliveries; +DROP TABLE IF EXISTS staff_push_subscriptions; diff --git a/migrations/0010_employee_push_notifications.up.sql b/migrations/0010_employee_push_notifications.up.sql new file mode 100644 index 0000000..cbc6bd5 --- /dev/null +++ b/migrations/0010_employee_push_notifications.up.sql @@ -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; diff --git a/src/app.ts b/src/app.ts index 1b2c37e..5063848 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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; diff --git a/src/config.ts b/src/config.ts index 52903ef..ad74520 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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(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'); } diff --git a/src/domain/notification/push-dispatcher.ts b/src/domain/notification/push-dispatcher.ts new file mode 100644 index 0000000..8a17834 --- /dev/null +++ b/src/domain/notification/push-dispatcher.ts @@ -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 { + const parsedTenantId = parseUlid(tenantId); + const parsedActorId = parseUlid(this.systemActorId); + + return this.database.tenantTransaction( + 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 { + const parsedTenantId = parseUlid(tenantId); + const parsedActorId = parseUlid(this.systemActorId); + const parsedOutboxId = parseUlid(outboxId); + + const outboxRow = await this.database.tenantTransaction( + parsedTenantId, + parsedActorId, + async (client) => { + const result = await client.query( + `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( + parsedTenantId, + parsedActorId, + async (client) => { + const result = await client.query( + `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( + 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( + 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( + `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( + 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( + 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( + 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 { + const parsedTenantId = parseUlid(tenantId); + const parsedActorId = parseUlid(this.systemActorId); + + const pendingRows = await this.database.tenantTransaction>( + 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; + } +} diff --git a/src/domain/notification/web-push.ts b/src/domain/notification/web-push.ts new file mode 100644 index 0000000..478a9a8 --- /dev/null +++ b/src/domain/notification/web-push.ts @@ -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 { + 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}`, + }; + } +}