From e4170b382393bc7423ae599b7b1f6180ff3bf9a9 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Tue, 9 Jun 2026 20:54:47 -0400 Subject: [PATCH] =?UTF-8?q?feat(05-02):=20implement=20pushDispatcher=20?= =?UTF-8?q?=E2=80=94=20VAPID=20send=20+=20410/404=20prune?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - buildPushBody: dual-format payload (iOS 18.4+ declarative + legacy) - dispatchPush: calls webpush.sendNotification with TTL=300, urgency=normal - Prunes push_subscriptions row on 410/404 from push service (D-11) - Logs transient errors with [pushDispatcher] prefix; never throws to caller - Default import for web-push (CommonJS — Pitfall 7) --- apps/api/src/lib/pushDispatcher.ts | 121 +++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 apps/api/src/lib/pushDispatcher.ts diff --git a/apps/api/src/lib/pushDispatcher.ts b/apps/api/src/lib/pushDispatcher.ts new file mode 100644 index 0000000..d0c9912 --- /dev/null +++ b/apps/api/src/lib/pushDispatcher.ts @@ -0,0 +1,121 @@ +/** + * pushDispatcher — VAPID-signed push send + 410/404 subscription prune. + * + * Single send path for all push triggers (reminders, list changes, event changes). + * Centralises VAPID signing and dead-subscription pruning so individual triggers + * never re-implement crypto or expiry handling. + * + * Threat mitigations (T-05-03, T-05-04, T-05-05): + * - Uses web-push library exclusively for VAPID signing (never hand-rolled). + * - Catches per-subscription errors; one failed send never aborts a fan-out loop. + * - Logs statusCode + err.message only — never logs subscription keys or payload body. + * + * Design notes: + * - Default import for web-push (CommonJS module — Pitfall 7 from RESEARCH.md). + * - setVapidDetails is NOT called here — it is called once from index.ts at startup. + * - dispatchPush resolves (never throws) so fan-out loops continue after failures. + */ + +import webpush from 'web-push' +import { eq } from 'drizzle-orm' +import { db } from '../db/client.js' +import { pushSubscriptions } from '../db/schema.js' + +/** + * Push subscription row shape (subset of schema.pushSubscriptions used by dispatcher). + */ +export type PushSubscription = { + id: number + userId: number + endpoint: string + p256dh: string + auth: string +} + +/** + * Notification content passed to dispatchPush and used to build the push body. + */ +export type NotificationPayload = { + title: string + body?: string + tag?: string + navigate?: string +} + +/** + * Build the dual-format push payload body string. + * + * Carries both: + * 1. iOS 18.4+ declarative web push (web_push:8030 + notification object) + * 2. Legacy title/body/tag/data for iOS 16.4–18.3 and Android + * + * The service worker reads whichever format the browser understands. + */ +export function buildPushBody(notification: NotificationPayload): string { + const { title, body = '', tag, navigate } = notification + + return JSON.stringify({ + // iOS 18.4+ declarative web push format (WebKit blog 2025-04-14) + web_push: 8030, + notification: { + title, + body, + navigate, + }, + // Legacy format for iOS 16.4–18.3 and Android + title, + body, + ...(tag !== undefined && { tag }), + data: { + url: navigate, + }, + }) +} + +/** + * Sign and dispatch a single push notification to one subscription. + * + * On 410 (Gone) or 404 (Not Found) response from the push service: + * - The subscription row is deleted from push_subscriptions (D-11 prune). + * + * On transient errors (5xx, 429, network failures): + * - NO delete is performed. + * - The error is logged with the [pushDispatcher] prefix. + * - The function resolves (never throws to caller). + * + * On success: + * - No action beyond the send. + */ +export async function dispatchPush( + sub: PushSubscription, + notification: NotificationPayload, +): Promise { + const webPushSub = { + endpoint: sub.endpoint, + keys: { + p256dh: sub.p256dh, + auth: sub.auth, + }, + } + + const body = buildPushBody(notification) + + try { + await webpush.sendNotification(webPushSub, body, { + TTL: 300, + urgency: 'normal', + }) + } catch (err: unknown) { + const statusCode = (err as { statusCode?: number }).statusCode + + if (statusCode === 410 || statusCode === 404) { + // Dead subscription — prune from DB (D-11) + await db.delete(pushSubscriptions).where(eq(pushSubscriptions.id, sub.id)) + return + } + + // Transient error — log and continue; do NOT delete subscription + const message = err instanceof Error ? err.message : String(err) + console.error('[pushDispatcher] sendNotification failed:', statusCode, message) + } +}