Milestone v1.0: FamilySync MVP #1

Merged
luckberg merged 376 commits from gsd/v1.0-milestone into main 2026-06-10 17:39:19 -04:00
Showing only changes of commit e4170b3823 - Show all commits
+121
View File
@@ -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.418.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.418.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<void> {
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)
}
}