From 7264a9880f71158c7f441d720ac2f8160c602d8d Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Tue, 9 Jun 2026 18:20:36 -0400 Subject: [PATCH] docs(05): research phase web-push-notifications --- .../05-web-push-notifications/05-RESEARCH.md | 875 ++++++++++++++++++ 1 file changed, 875 insertions(+) create mode 100644 .planning/phases/05-web-push-notifications/05-RESEARCH.md diff --git a/.planning/phases/05-web-push-notifications/05-RESEARCH.md b/.planning/phases/05-web-push-notifications/05-RESEARCH.md new file mode 100644 index 0000000..54cb79e --- /dev/null +++ b/.planning/phases/05-web-push-notifications/05-RESEARCH.md @@ -0,0 +1,875 @@ +# Phase 5: Web Push Notifications — Research + +**Researched:** 2026-06-09 +**Domain:** Web Push (VAPID), vite-plugin-pwa injectManifest, iOS push reliability, Node.js scheduler, MariaDB schema migration +**Confidence:** HIGH (codebase facts) / MEDIUM (library APIs via Context7) + +--- + + +## User Constraints (from CONTEXT.md) + +### Locked Decisions + +- **D-01:** Coalesce list-change pushes per list within ~30–60s window. Reorder (`position`) changes do NOT push. +- **D-02:** Event notifications show specifics (title, time, action). List pings stay generic (actor + list + change count, no item text). +- **D-03:** Name the actor in every change notification. Two-person household. +- **D-04:** Event-change trigger = meaningful changes only (new, delete, time/date/title/location changes). Description-only edits are silent. +- **D-05:** Reminders fire for SHARED Family-calendar events only (is_shared=1). Native device calendar apps cover personal events. +- **D-06:** Fixed ~15 min lead time for v1. No per-event offset. +- **D-07:** All-day events get no reminder. +- **D-08:** Contextual permission prompt right after PWA install or first installed launch; trigger pushManager.subscribe() on a tap gesture. +- **D-09:** Single master on/off toggle for v1. +- **D-10:** Dead-subscription recovery = silent auto re-subscribe on app open if OS permission is still granted. Surface UI only if OS permission itself was revoked. +- **D-11:** iOS reliability is mandatory from day one: subscription health-check + event.waitUntil() in SW + every push MUST display a visible notification. +- **D-12:** In-memory EventEmitter fan-out, no Redis. API is a single Node process. Push dispatch hooks same publish points as SSE. +- **D-13:** Broker is the only Fastmail I/O boundary. Notification code reads from MariaDB cache / poller / outbox — no tsdav in notification code. +- **D-14:** react-router is installed. Tap targets use real URLs for deep-linking. + +### Claude's Discretion + +- No quiet-hours / DND in v1. +- Tap-to-open deep-link targets (obvious mapping). +- Service-worker strategy: switch generateSW → injectManifest with custom SW; preserve Workbox precache + autoUpdate. +- VAPID key generation + storage strategy. +- Push-subscription table schema (member-count-agnostic per D-18). +- Reminder-scheduler mechanism (cron/interval scanning shared-calendar timed events in MariaDB cache). +- Coalescing debounce implementation. +- Event-change detection trigger points (poller vs outbox-confirm). + +### Deferred Ideas (OUT OF SCOPE) + +- Quiet hours / Do-Not-Disturb +- Per-event / custom reminder lead time +- Per-category opt-out (reminder / event-change / list-change toggles) +- Reminders for personal-calendar events +- Notifying on the member's own changes + + +--- + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|------------------| +| NOTIF-01 | User receives a Web Push reminder before an event starts | Reminder scheduler (node-cron interval scanning shared-calendar timed events at dtstart_utc in MariaDB cache), web-push sendNotification, SW push event with event.waitUntil() | +| NOTIF-02 | User receives a Web Push alert when the other member changes a shared list | Hook publishListEvent in listEmitter.ts, coalescing debounce (30–60s per list), suppress actor's own subscription | +| NOTIF-03 | User receives a Web Push alert when an event is added or changed | Hook syncCalendar (poller) and outboxWorker (on success + targeted re-sync), filter meaningful fields, suppress actor's own subscription | + + +--- + +## Summary + +Phase 5 delivers Web Push for three distinct triggers — event reminders (NOTIF-01), event changes (NOTIF-03), and list changes (NOTIF-02) — across iOS and Android. The technical implementation splits cleanly into four areas: server-side VAPID dispatch, a new reminder scheduler, PWA service-worker migration, and frontend subscription lifecycle. + +The most critical constraint is iOS reliability. iOS silently revokes a push subscription after approximately three pushes that do not display a visible notification. The mandatory mitigations (event.waitUntil(), every push shows a notification, subscription health-check on app open) must be present from the first commit. Missing any one of them causes silent subscription death that the user cannot observe. + +The second critical constraint is the service-worker migration. The existing vite-plugin-pwa config uses `generateSW` which auto-generates the entire SW. Adding `push` and `notificationclick` handlers requires switching to `injectManifest` with a custom SW source file. The migration must preserve the existing Workbox precache manifest injection, the `/callback` denylist (T-03-20), and the `autoUpdate` behavior — all of which are currently handled automatically and must be explicitly re-declared in the custom SW. + +**Primary recommendation:** Implement in this order — (1) migrate SW to injectManifest + add push/notificationclick skeleton, (2) add push-subscription table + API routes, (3) wire dispatch to listEmitter + sync/outbox hooks, (4) add reminder scheduler, (5) add PWA permission prompt + settings UI. + +--- + +## Architectural Responsibility Map + +| Capability | Primary Tier | Secondary Tier | Rationale | +|------------|-------------|----------------|-----------| +| VAPID key storage | API / Backend | — | Private key must never reach browser; stored in .env / DB | +| Push subscription storage | API / Backend (MariaDB) | — | Subscriptions are server-side state; browser only holds in pushManager | +| Push dispatch (sendNotification) | API / Backend | — | Server-side only; web-push library runs in Node.js | +| Reminder scheduling | API / Backend (node-cron) | — | Timer + DB query; no browser involvement | +| Coalescing debounce for list pushes | API / Backend | — | Debounce runs on publish events in the API process | +| Event-change detection | API / Backend (poller + outbox) | — | Reads MariaDB cache; D-13 prohibits tsdav in notification code | +| SW push event + showNotification | Browser / Service Worker | — | push event fires in SW; must call event.waitUntil(showNotification()) | +| SW notificationclick (deep-link) | Browser / Service Worker | — | Open /calendar or /lists/:id via clients.openWindow() | +| Permission request lifecycle | Browser / Client (React hook) | — | Must be in tap handler; iOS requires user gesture | +| Subscription persist / health-check | Browser / Client (React hook) | API / Backend | Hook reads pushManager, POSTs subscription to API | +| Settings toggle (master on/off) | Frontend / React PWA | API / Backend | UI toggle; DELETE subscription via API | +| Permission-denied banner | Frontend / React PWA | — | Read Notification.permission; no API call needed | + +--- + +## Standard Stack + +### Core + +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| web-push | 3.6.7 | VAPID key generation + push dispatch | Listed in CLAUDE.md; only maintained Node.js VAPID push library; 5M+ weekly downloads [VERIFIED: npm registry] | +| @types/web-push | 3.6.4 | TypeScript types for web-push | Official DefinitelyTyped types; required for strict-mode TS [VERIFIED: npm registry] | +| workbox-precaching | 7.4.1 | Precache manifest in custom SW | Required by vite-plugin-pwa injectManifest; currently handled auto by generateSW [VERIFIED: npm registry] | +| workbox-core | 7.x | clientsClaim + skipWaiting for autoUpdate | Required for autoUpdate behavior in injectManifest mode [ASSUMED — workbox-core is the peer of workbox-precaching; version matches Workbox 7] | + +### Supporting + +| Library | Version | Purpose | When to Use | +|---------|---------|---------|-------------| +| node-cron | 4.2.1 | Reminder scheduler | Already installed in apps/api; used by poller and outboxWorker [VERIFIED: codebase] | + +### Installation + +```bash +# API +pnpm --filter @familysync/api add web-push +pnpm --filter @familysync/api add -D @types/web-push + +# PWA (devDependencies — workbox is bundled into SW at build time) +pnpm --filter @familysync/pwa add -D workbox-precaching workbox-core +``` + +**Version verification (run at implementation time):** + +```bash +npm view web-push version # confirmed 3.6.7 +npm view @types/web-push version # confirmed 3.6.4 +npm view workbox-precaching version # confirmed 7.4.1 +``` + +--- + +## Package Legitimacy Audit + +| Package | Registry | Age | Downloads | Source Repo | Verdict | Disposition | +|---------|----------|-----|-----------|-------------|---------|-------------| +| web-push | npm | ~9 yrs (2024-01-16 last pub) | 5.09M/wk | github.com/web-push-libs/web-push | OK | Approved | +| @types/web-push | npm | ~8 yrs (2024-10-22 last pub) | 1.68M/wk | github.com/DefinitelyTyped/DefinitelyTyped | OK | Approved | +| workbox-precaching | npm | ~8 yrs (2026-05-04 last pub) | 7.92M/wk | github.com/googlechrome/workbox | OK | Approved | + +**Packages removed due to SLOP verdict:** none +**Packages flagged as suspicious (SUS):** none + +--- + +## Architecture Patterns + +### System Architecture Diagram + +``` +Browser (PWA) API (Node.js / Hono) +───────────────────────────── ────────────────────────────────────────── +[InstallPrompt/PushPermissionPrompt] + │ tap: requestPermission() + ↓ +[pushManager.subscribe(vapidPublicKey)] + │ PushSubscription {endpoint, keys} + │ POST /api/push/subscription + ─────────────────────────────────→ [pushRouter] + │ INSERT push_subscriptions (userId, endpoint, p256dh, auth) + ↓ + [MariaDB: push_subscriptions] + +── Triggers (server-side) ────────────────────────────────────────────────── + +[node-cron every 1 min] [poller/sync.ts — on calendarEvents upsert] + │ SELECT shared timed events │ detect meaningful change (new/updated uid) + │ WHERE dtstart_utc BETWEEN │ skip actor's subscription + │ NOW()+14min AND NOW()+16min │ + ↓ ↓ +[reminderScheduler.ts] [eventChangeDispatcher.ts] + │ SELECT push_subscriptions │ SELECT push_subscriptions + │ WHERE userId != event.userId? │ WHERE userId NOT IN (actor) + │ (all members for shared events) │ + ↓ ↓ +[pushDispatcher.ts] ←─────────────[listChangeDispatcher.ts (coalesced)] + │ webpush.sendNotification() ↑ + │ payload: {web_push:8030, [publishListEvent hook in listEmitter.ts] + │ notification:{title,body, │ debounce 30-60s per (listId, actorId) + │ navigate, ...}} │ suppress actor's own subscription + │ + │ on 410/404 → DELETE push_subscriptions (prune expired) + │ on success → subscription stays + ↓ +[Push Service (APNs/FCM)] + ↓ +[Browser / iOS SW] + +── PWA Service Worker (sw.ts) ────────────────────────────────────────────── +[push event] + └→ event.waitUntil( + self.registration.showNotification(data.notification.title, { + body, tag, data: {url} + }) + ) +[notificationclick event] + └→ clients.openWindow(event.notification.data.url) +``` + +### Recommended Project Structure + +New files this phase: + +``` +apps/api/src/ +├── routes/ +│ └── push.ts # POST /api/push/subscription, DELETE, GET /api/push/vapid-public-key +├── lib/ +│ ├── pushDispatcher.ts # webpush.sendNotification wrapper + 410/404 pruning +│ └── pushCoalescer.ts # per-(listId,actorId) debounce for list-change pushes +├── broker/ +│ └── reminderScheduler.ts # node-cron 1-min interval: scan shared timed events ±1min window +└── db/schema.ts # add push_subscriptions table + +apps/pwa/src/ +├── sw.ts # NEW custom SW: precacheAndRoute + push + notificationclick +├── components/ +│ ├── PushPermissionPrompt.tsx +│ ├── SettingsSheet.tsx +│ └── PermissionDeniedBanner.tsx +└── hooks/ + └── usePushSubscription.ts # subscribe/unsubscribe/health-check lifecycle + +apps/api/src/db/migrations/ +└── 0003_push_subscriptions.sql # generated by drizzle-kit generate +``` + +### Pattern 1: web-push VAPID dispatch (TypeScript / ESM) + +```typescript +// Source: https://github.com/web-push-libs/web-push/blob/master/README.md +import webpush from 'web-push' + +// Call once at API startup (index.ts isMainModule() guard) +webpush.setVapidDetails( + 'mailto:admin@familysync.bergerhouse.net', + process.env.VAPID_PUBLIC_KEY!, + process.env.VAPID_PRIVATE_KEY!, +) + +// Dispatch helper — prunes expired subscriptions on 410/404 +async function dispatchPush( + subscription: { endpoint: string; p256dh: string; auth: string }, + payload: object, + dbRowId: number, +): Promise { + const sub = { + endpoint: subscription.endpoint, + keys: { p256dh: subscription.p256dh, auth: subscription.auth }, + } + const body = JSON.stringify({ + web_push: 8030, + notification: payload, + }) + try { + await webpush.sendNotification(sub, body, { + TTL: 300, // 5 min: notification has already expired if not delivered soon + urgency: 'normal', + }) + } catch (err: unknown) { + const statusCode = (err as { statusCode?: number }).statusCode + if (statusCode === 410 || statusCode === 404) { + // Subscription expired — delete from DB to avoid future failed sends + await db.delete(pushSubscriptions).where(eq(pushSubscriptions.id, dbRowId)) + } + // Other errors: log and continue (transient failures; next send will retry) + console.error('[pushDispatcher] sendNotification error:', statusCode, (err as Error).message) + } +} +``` + +### Pattern 2: Custom Service Worker (sw.ts) — injectManifest + +```typescript +// Source: https://github.com/vite-pwa/vite-plugin-pwa/blob/main/docs/guide/inject-manifest.md +import { precacheAndRoute } from 'workbox-precaching' +import { clientsClaim } from 'workbox-core' + +declare let self: ServiceWorkerGlobalScope + +// autoUpdate behavior: claim all clients immediately on activate +self.skipWaiting() +clientsClaim() + +// Inject Workbox precache manifest (plugin populates self.__WB_MANIFEST at build time) +precacheAndRoute(self.__WB_MANIFEST) + +// CRITICAL: every push MUST call showNotification (D-11 / iOS requirement) +self.addEventListener('push', (event: PushEvent) => { + let title = 'FamilySync' + let options: NotificationOptions = { body: 'You have a new notification' } + + if (event.data) { + try { + const data = event.data.json() as { + notification?: { title?: string; body?: string; navigate?: string } + title?: string + body?: string + tag?: string + data?: { url?: string } + } + // Support both Declarative Web Push format (iOS 18.4+) and legacy format + const notif = data.notification ?? data + title = notif.title ?? title + options = { + body: notif.body ?? options.body, + tag: (data as { tag?: string }).tag ?? undefined, + data: { url: (notif as { navigate?: string }).navigate ?? (data as { data?: { url?: string } }).data?.url ?? '/' }, + } + } catch { + // Malformed payload — still show a generic notification (iOS: never drop silently) + } + } + + // event.waitUntil is MANDATORY — iOS revokes subscription after ~3 silent pushes (D-11) + event.waitUntil(self.registration.showNotification(title, options)) +}) + +self.addEventListener('notificationclick', (event: NotificationEvent) => { + event.notification.close() + const url: string = (event.notification.data as { url?: string })?.url ?? '/' + event.waitUntil( + (self.clients as Clients).matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => { + // Focus existing window if already open + for (const client of clientList) { + if ('url' in client && (client as WindowClient).url === url && 'focus' in client) { + return (client as WindowClient).focus() + } + } + return (self.clients as Clients).openWindow(url) + }), + ) +}) +``` + +### Pattern 3: vite.config.ts migration to injectManifest + +```typescript +// Source: https://github.com/vite-pwa/vite-plugin-pwa/blob/main/docs/guide/inject-manifest.md +VitePWA({ + strategies: 'injectManifest', + srcDir: 'src', + filename: 'sw.ts', + registerType: 'autoUpdate', + injectManifest: { + // Preserve existing SW denylist behavior (T-03-20: /callback must not be precached) + globIgnores: ['**/node_modules/**', '**/callback**'], + }, + manifest: { + // ... same manifest config as current generateSW setup ... + }, + // Note: navigateFallback moves from workbox: {} to injectManifest: {} or is handled + // directly in the custom SW via WorkboxRouter if needed +}) +``` + +### Pattern 4: Push subscription schema (Drizzle, MariaDB) + +```typescript +// apps/api/src/db/schema.ts addition +import { varchar, text, timestamp, int, mysqlTable, index, unique } from 'drizzle-orm/mysql-core' + +export const pushSubscriptions = mysqlTable( + 'push_subscriptions', + { + id: int().primaryKey().autoincrement(), + userId: int('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + endpoint: text('endpoint').notNull(), + p256dh: text('p256dh').notNull(), + auth: varchar('auth', { length: 256 }).notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(), + }, + (t) => [ + // One endpoint per user (a member can have multiple devices, but endpoint is globally unique) + unique('uniq_push_endpoint').on(t.endpoint), + index('idx_push_subscriptions_user_id').on(t.userId), + ], +) +``` + +**Migration:** + +```bash +# MUST use generate+migrate, never db:push (drizzle-mariadb-push-unsafe.md) +pnpm --filter @familysync/api db:generate +pnpm --filter @familysync/api db:migrate +``` + +The migration file will be generated at `apps/api/src/db/migrations/0003_.sql`. + +### Pattern 5: Reminder Scheduler (node-cron, 1-min interval) + +```typescript +// apps/api/src/broker/reminderScheduler.ts +import { schedule } from 'node-cron' +import { and, eq, gte, lte, isNull, not } from 'drizzle-orm' +import { db } from '../db/client.js' +import { calendars, calendarEvents, pushSubscriptions } from '../db/schema.js' + +// Fire once per minute; scan window: [now+14min, now+16min] (D-06: 15-min lead) +export function startReminderScheduler(): void { + schedule('* * * * *', async () => { + const now = new Date() + const windowStart = new Date(now.getTime() + 14 * 60 * 1000) + const windowEnd = new Date(now.getTime() + 16 * 60 * 1000) + + // D-05: shared events only; D-07: timed events only (allDay=false) + const events = await db + .select({ uid: calendarEvents.uid, title: calendarEvents.uid /* swap for title field */ }) + .from(calendarEvents) + .innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id)) + .where( + and( + eq(calendars.isShared, true), + eq(calendarEvents.allDay, false), + gte(calendarEvents.dtstartUtc, windowStart), + lte(calendarEvents.dtstartUtc, windowEnd), + not(isNull(calendarEvents.dtstartUtc)), + ), + ) + + for (const event of events) { + // Get all subscriptions for all users (shared event — notify all members) + const subs = await db.select().from(pushSubscriptions) + for (const sub of subs) { + await dispatchPush(sub, { title: event.uid, body: 'Starts in 15 min', ... }, sub.id) + } + } + }) +} +``` + +**Critical schema note:** `calendarEvents` does NOT have a `title` column — only `uid`, `rawVevent`, and `dtstartUtc`. The scheduler must extract the VEVENT SUMMARY from `rawVevent` using ical.js, or the schema must be extended with a `title` column (recommended — avoids ical.js parsing on every reminder fire). + +### Pattern 6: List-change coalescing debounce + +```typescript +// apps/api/src/lib/pushCoalescer.ts +const pendingCoalesced = new Map }>() + +export function coalesceListPush( + listId: number, + actorId: number, + actorName: string, + listName: string, + dispatch: (payload: object, excludeUserId: number) => void, + windowMs = 45_000, +): void { + const key = `${listId}:${actorId}` + const existing = pendingCoalesced.get(key) + if (existing) { + existing.count++ + clearTimeout(existing.timer) + } + const entry = existing ?? { count: 1, timer: null! } + entry.timer = setTimeout(() => { + pendingCoalesced.delete(key) + dispatch( + { + title: `${actorName} updated ${listName}`, + body: `${entry.count} change${entry.count === 1 ? '' : 's'}`, + navigate: `/lists/${listId}`, + }, + actorId, // D-03: suppress own notification + ) + }, windowMs) + if (!existing) pendingCoalesced.set(key, entry) +} +``` + +### Pattern 7: usePushSubscription hook (React PWA) + +```typescript +// apps/pwa/src/hooks/usePushSubscription.ts +// Manages: subscribe, unsubscribe, health-check on mount (D-10) +export function usePushSubscription() { + // On mount: if permission granted but no subscription → silently re-subscribe (D-10) + useEffect(() => { + if (Notification.permission !== 'granted') return + navigator.serviceWorker.ready.then(async (reg) => { + const existing = await reg.pushManager.getSubscription() + if (!existing) { + // Silent re-subscribe (D-10) — no user gesture needed (permission already granted) + await subscribeAndPost(reg) + } + }) + }, []) + + // subscribe: must be called inside a tap handler (D-08 / iOS requirement) + async function subscribe(reg: ServiceWorkerRegistration): Promise { + const sub = await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY), + }) + await fetch('/api/push/subscription', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(sub.toJSON()), + credentials: 'include', + }) + } + + async function unsubscribe(): Promise { + const reg = await navigator.serviceWorker.ready + const sub = await reg.pushManager.getSubscription() + if (sub) await sub.unsubscribe() + await fetch('/api/push/subscription', { method: 'DELETE', credentials: 'include' }) + } + + return { subscribe, unsubscribe } +} +``` + +### Anti-Patterns to Avoid + +- **Calling pushManager.subscribe() outside a user gesture:** iOS silently fails. Always call inside onClick/onTap handler, never on component mount or useEffect. +- **Silent pushes (no showNotification in push handler):** iOS revokes subscription after ~3 silent pushes. event.waitUntil(showNotification(...)) is mandatory on every push event, even if payload is malformed. +- **Using db:push for schema migration:** drizzle-kit push emits false destructive diff on populated MariaDB (truncates tables). Always use `db:generate` + `db:migrate`. +- **Using generateSW with custom push handler:** generateSW auto-generates the entire SW from options only — there is no hook to inject push event listeners. Must switch to injectManifest. +- **Storing VAPID private key in code / git:** Store in .env (VAPID_PRIVATE_KEY). Never commit. +- **Fan-out to all users for a list change:** Only fan out to users who can access the list (list_shares join table). Use same access check as SSE route. +- **Duplicate reminder pushes:** The 1-min scheduler with a ±1-min window will fire twice for an event if it falls exactly at the boundary. Deduplicate via a reminder_sent flag or a separate `sent_reminders` table keyed by (eventUid, scheduledAt bucket). + +--- + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| VAPID signing + encryption | Custom crypto | web-push | RFC 8292 + Message Encryption for Web Push; 40+ lines of crypto primitives per send | +| Push payload encryption | Manual AES-128-GCM | web-push.sendNotification | Handles p256dh key agreement + content encryption per IETF RFC 8291 | +| Service worker precache manifest | Manual file list | workbox-precaching + self.__WB_MANIFEST | Build-time injection; stale hash mismatches cause update failures | +| VAPID key generation | crypto.generateKeyPair | webpush.generateVAPIDKeys() or web-push CLI | Returns URL-safe Base64 directly; format required by push services | +| Subscription expiry cleanup | Custom cron | 410/404 error handler in dispatchPush | Push services send 410 exactly when subscription is gone; polling misses edge cases | + +--- + +## Runtime State Inventory + +> Not a rename/refactor phase. Section omitted. + +--- + +## Common Pitfalls + +### Pitfall 1: iOS subscription silently revoked after ~3 silent pushes +**What goes wrong:** Push notifications stop arriving on iOS with no error. The subscription endpoint still exists in the DB. The push service returns 200 but the notification never appears. +**Why it happens:** iOS Safari enforces that every push event results in a visible notification. Three consecutive push events without showNotification() cause APNs to mark the subscription dead. +**How to avoid:** Every push event handler MUST call event.waitUntil(self.registration.showNotification(...)) — even for malformed payloads (fall back to a generic message). No silent pushes, ever. +**Warning signs:** Users stop receiving notifications after a period of working correctly. DB shows no pruned subscriptions (410 errors never appear because the subscription is dead but not explicitly invalidated by APNs). + +### Pitfall 2: pushManager.subscribe() outside a user gesture fails silently on iOS +**What goes wrong:** The subscribe call returns a rejected promise or does nothing. No error surfaced to the user. +**Why it happens:** iOS requires pushManager.subscribe() to be invoked directly within a user tap event handler — not in a useEffect, not in a setTimeout, not after an await boundary. Any async hop breaks the user-gesture context. +**How to avoid:** The "Enable Notifications" button onClick must call pushManager.subscribe() synchronously (before any awaits) or use the existing tap event reference. See D-08 and UI-SPEC surface 1. +**Warning signs:** Subscribe works on Android/Chrome but silently fails on iOS. + +### Pitfall 3: vite-plugin-pwa injectManifest — missing workbox-precaching devDependency +**What goes wrong:** Build fails with `Cannot find module 'workbox-precaching'` or the SW bundles without precache support. +**Why it happens:** In generateSW mode, vite-plugin-pwa bundles Workbox internally. In injectManifest mode, the custom SW source is compiled by Vite — workbox-precaching must be an explicit devDependency. +**How to avoid:** `pnpm --filter @familysync/pwa add -D workbox-precaching workbox-core` +**Warning signs:** TypeScript error in sw.ts on `import { precacheAndRoute }`. + +### Pitfall 4: /callback denylist lost after SW migration +**What goes wrong:** After migrating to injectManifest, the OIDC /callback route is served from SW cache instead of reaching the server. This causes the login loop bug (T-03-20). +**Why it happens:** The existing `workbox.navigateFallbackDenylist` in vite.config.ts only applies to the generateSW strategy. injectManifest does not read from `workbox:` key — the SW must implement the navigation denylist explicitly (via Workbox Router or a fetch event handler checking the URL). +**How to avoid:** In sw.ts, add a fetch handler that falls through for /callback and /api/ requests. Or use workbox-routing NavigationRoute with denylist. +**Warning signs:** After re-login, the app loops at /callback. + +### Pitfall 5: Duplicate reminder fires for events at the window boundary +**What goes wrong:** A user gets two reminder pushes for the same event ~1 minute apart. +**Why it happens:** The 1-minute cron runs at T=0 and T=1; an event at dtstart_utc=T+15 falls in both [T+14, T+16] windows. +**How to avoid:** Track sent reminders. Simplest approach: a `sent_reminders` table with `(eventUid, reminderBucket CHAR(16))` where bucket = the UTC minute of the scheduled fire. Unique key on (eventUid, reminderBucket) prevents double-insert → skip dispatch on duplicate key. +**Warning signs:** Members report receiving identical reminder notifications 1 minute apart. + +### Pitfall 6: calendarEvents has no title column — must parse rawVevent +**What goes wrong:** Reminder copy shows the VEVENT UID instead of the event title (e.g. "abc123-def456-..." instead of "Dentist"). +**Why it happens:** The existing schema stores the VEVENT SUMMARY only in rawVevent (text blob), not as a dedicated indexed column. The scheduler query cannot SELECT a title. +**How to avoid:** Add a `title` varchar column to calendar_events (populated during sync from ical.js SUMMARY). This is a new migration (0004) but avoids ical.js parsing on every reminder check. Alternatively, parse rawVevent with ical.js in the scheduler — correct but slower. +**Recommended:** Add `title` column to calendar_events schema in Wave 0 (same migration as push_subscriptions, or a separate migration 0004). +**Warning signs:** Notification titles are raw UIDs. + +### Pitfall 7: web-push ESM import — requires default import with @types/web-push +**What goes wrong:** TypeScript error `Module '"web-push"' has no exported member 'sendNotification'` or runtime error on named import. +**Why it happens:** web-push 3.6.7 ships CommonJS only. In an ESM project (apps/api `"type":"module"`), it must be imported as the default export: `import webpush from 'web-push'` (not named imports). @types/web-push provides the types for this pattern. +**How to avoid:** Always use `import webpush from 'web-push'` (default import). +**Warning signs:** TypeScript compiles but `webpush.setVapidDetails` is undefined at runtime. + +### Pitfall 8: VAPID public key must be served to the PWA as an environment variable +**What goes wrong:** pushManager.subscribe() fails with "invalid applicationServerKey" if the PWA uses a hardcoded or stale key. +**Why it happens:** The public key must match the private key used by web-push to sign notifications. If they are mismatched (e.g., key regenerated without updating the PWA build), the push service rejects. +**How to avoid:** Expose VAPID_PUBLIC_KEY to the Vite build via `VITE_VAPID_PUBLIC_KEY` environment variable. Alternatively, add a GET /api/push/vapid-public-key endpoint (unauthenticated, public). The hook fetches it at subscribe time — this also allows key rotation without a rebuild. +**Warning signs:** pushManager.subscribe() rejects with DOMException; existing subscriptions fail to send after key rotation. + +--- + +## Code Examples + +### VAPID key generation (one-time CLI) + +```bash +# Source: https://github.com/web-push-libs/web-push/blob/master/README.md +npx web-push generate-vapid-keys --json +# → {"publicKey":"B...","privateKey":"I..."} +# Add to .env: +# VAPID_PUBLIC_KEY=B... +# VAPID_PRIVATE_KEY=I... +``` + +### Event payload format (Declarative Web Push + legacy SW compatible) + +```json +// Source: https://webkit.org/blog/16535/meet-declarative-web-push/ +// Dual-format payload: "web_push":8030 enables iOS 18.4+ declarative path; +// title/body/tag/data fields are read by the SW push handler for older iOS + Android. +{ + "web_push": 8030, + "notification": { + "title": "Dentist", + "body": "Starts in 15 min", + "navigate": "/calendar?date=2026-06-10&event=abc123" + }, + "title": "Dentist", + "body": "Starts in 15 min", + "tag": "reminder-abc123", + "data": { "url": "/calendar?date=2026-06-10&event=abc123" } +} +``` + +### SW navigateFallback preservation in injectManifest mode + +```typescript +// Source: https://github.com/vite-pwa/vite-plugin-pwa/blob/main/docs/guide/inject-manifest.md +// In sw.ts — replicate the existing navigateFallback + denylist behavior: +import { NavigationRoute, registerRoute } from 'workbox-routing' +import { createHandlerBoundToURL } from 'workbox-precaching' + +// Deny /callback, /api/*, /health from SW navigation handling (T-03-20) +const navigationHandler = createHandlerBoundToURL('/index.html') +const navigationRoute = new NavigationRoute(navigationHandler, { + denylist: [/^\/callback/, /^\/api\//, /^\/health/], +}) +registerRoute(navigationRoute) +``` + +Alternative — add `workbox-routing` and `workbox-precaching` as devDependencies if this approach is used. + +--- + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| Safari required separate APS certificate for push | VAPID (RFC 8292) — same as Chrome/Firefox | Safari 16+ (2022) | Single web-push flow works across all browsers | +| Traditional Web Push required service worker JS always | Declarative Web Push — SW optional | iOS 18.4 / Safari 18.4 (April 2025) | Payload format change; SW handler can be simpler | +| generateSW sufficient for most PWAs | injectManifest required when custom SW events needed | vite-plugin-pwa 0.12+ | Need to add workbox-precaching explicitly | +| CRA for React PWAs | Vite + vite-plugin-pwa | 2023+ (CRA deprecated Feb 2025) | Already using correct stack | + +**Declarative Web Push (iOS 18.4+):** +The dual-format payload approach (embedding both `"web_push":8030 + notification{}` AND the legacy `title/body/tag/data` fields in the same JSON body) is backward compatible and handles all iOS versions from 16.4+ through 18.4+ in a single payload. iOS 16.4–18.3 uses the SW push event path; iOS 18.4+ can use the declarative path as a fallback but still processes the SW push event if a SW is installed. [CITED: https://webkit.org/blog/16535/meet-declarative-web-push/] + +--- + +## Codebase Ground-Truth (verified by reading source) + +These facts were confirmed by direct inspection and are the planner's authoritative source. [VERIFIED: codebase] + +### 1. Migration workflow confirmed +- `drizzle.config.ts` uses dialect `mysql`, schema at `./src/db/schema.ts`, migrations out to `./src/db/migrations`. +- Scripts: `db:generate` → `drizzle-kit generate`; `db:migrate` → `drizzle-kit migrate`; `db:push` exists but is UNSAFE per memory note. +- Latest migration: `0002_yielding_mattie_franklin.sql` (adds utf8mb4_bin collation to list_items.rank). +- Next migration will be numbered `0003_.sql`. +- The `customType` pattern for special column types (e.g., utf8mb4_bin collation) is established in schema.ts and should be used again if needed. + +### 2. web-push NOT installed +`apps/api/package.json` does not include `web-push`. Must be installed as part of Wave 0. + +### 3. workbox-precaching NOT installed in apps/pwa +`apps/pwa/package.json` has `vite-plugin-pwa ^1.3.0` but neither `workbox-precaching` nor `workbox-core`. Must be installed as devDependencies. + +### 4. vite.config.ts is generateSW mode +Confirmed: `VitePWA({ registerType: 'autoUpdate', workbox: { navigateFallback, navigateFallbackDenylist, runtimeCaching: [] } })`. Migration to `injectManifest` MUST: +- Preserve the `navigateFallbackDenylist` entries: `/^\/callback/`, `/^\/api\//`, `/^\/health/` +- Preserve `runtimeCaching: []` (no API caching) +- Re-add `skipWaiting()` + `clientsClaim()` for autoUpdate + +### 5. listEmitter.ts publish points +`publishListEvent(listId, event)` is called in the lists routes (confirmed by import chain). Push dispatch for NOTIF-02 hooks this same function. The coalescer wraps the dispatch, not the emitter itself. + +### 6. poller.ts calls syncCalendar on ctag change +`runPoll()` calls `syncCalendar(client, davCal, cred.userId)` when ctag changes. This is where external event changes (other member's writes arriving at Fastmail) are detected. NOTIF-03 for external changes should hook here or inside `syncCalendar`. + +### 7. outboxWorker.ts calls triggerTargetedResync on success +After a successful CalDAV write, `triggerTargetedResync` runs and calls `syncCalendar`. NOTIF-03 for this-member writes (notifying the OTHER member) should hook at the point where outboxWorker marks a row `done` and the re-sync detects the new/changed event. + +### 8. calendarEvents schema has NO title column +`calendarEvents` columns: id, calendarId, uid, etag, objectUrl, rawVevent, dtstartUtc, dtstartDate, allDay, hasRrule, updatedAt. No `title` or `summary` column. The reminder scheduler and event-change dispatcher must either parse `rawVevent` or the schema must be extended (recommended: add `title varchar(500)`). + +### 9. index.ts startup pattern +Background workers are started only inside the `isMainModule()` guard to prevent test contamination. The new `startReminderScheduler()` must follow this same pattern. + +### 10. API test pattern +Route tests in `tests/routes/` use a real MariaDB connection with `vi.mock('../../src/auth/devBypass.js', ...)` to inject a user. The new `tests/routes/push.test.ts` should follow this pattern. Pure-unit tests (pushDispatcher, pushCoalescer) mock the DB. The `tests/broker/` directory holds worker tests with DB mocking. + +### 11. Test setup truncates list tables only +`test/setup.ts` afterEach truncates `list_items`, `list_shares`, `lists`. When `push_subscriptions` is added, the setup must be updated to also truncate it. + +--- + +## Assumptions Log + +| # | Claim | Section | Risk if Wrong | +|---|-------|---------|---------------| +| A1 | web-push 3.6.7 is CommonJS-only; ESM project must use default import `import webpush from 'web-push'` | Standard Stack, Pitfall 7 | Named import works at runtime → no impact; but TypeScript types may differ | +| A2 | workbox-core version 7.x is compatible with workbox-precaching 7.4.1 | Standard Stack | Version mismatch causes runtime error in SW; verify with `npm view workbox-core version` | +| A3 | iOS 18.4+ Declarative Web Push still processes the SW push event when a SW is installed | Code Examples, dual-format payload | If iOS 18.4+ skips the push event entirely when web_push:8030 is present, the dual-format approach is unnecessary; simpler — no impact on correctness | +| A4 | workbox-routing and createHandlerBoundToURL are available in workbox-precaching@7.4.1 suite | Code Examples, navigateFallback | May need `workbox-routing` as a separate devDependency; check if it is a sub-package | +| A5 | listEmitter.publishListEvent is called in the route handlers rather than a service layer | Codebase Ground-Truth | If called elsewhere, the coalescer attachment point changes | + +--- + +## Open Questions + +1. **Does event-change detection require a new syncCalendar hook or a separate table diff?** + - What we know: `syncCalendar` does an `onDuplicateKeyUpdate` upsert but does not return which rows changed. + - What's unclear: To detect NOTIF-03 changes (new vs modified vs deleted event), the sync must compare old vs new state. The current sync has no "what changed" output. + - Recommendation: Add a `pushChangedEvents()` side-effect callback parameter to `syncCalendar` (or a post-sync query comparing `updatedAt` timestamps) that returns newly upserted/deleted events for push dispatch. Alternatively, use a DB trigger or a separate "last_seen_etag" comparison in the scheduler. + +2. **Should VAPID_PUBLIC_KEY be injected at build time (VITE_VAPID_PUBLIC_KEY) or fetched at runtime (GET /api/push/vapid-public-key)?** + - What we know: Build-time injection is simpler. Runtime fetch allows key rotation without rebuilds. + - What's unclear: How often VAPID keys will rotate in practice. + - Recommendation: Use GET /api/push/vapid-public-key endpoint (unauthenticated). Fetched once by usePushSubscription hook before subscribe. Cached in sessionStorage. + +3. **Reminder deduplication strategy: column flag vs separate table?** + - What we know: The 1-min cron window approach risks double-firing for events at the window boundary. + - What's unclear: Whether a `sent_reminders` table is overkill for a two-person household. + - Recommendation: Add a `sentAt` timestamp column to calendarEvents for reminder tracking, or use a simple in-memory Set of `${eventUid}:${minuteBucket}` pairs per process (acceptable for single-process deployment per D-12). + +--- + +## Environment Availability + +| Dependency | Required By | Available | Version | Fallback | +|------------|------------|-----------|---------|----------| +| node-cron | Reminder scheduler | Yes | ^4.2.1 | — (already installed in apps/api) | +| MariaDB | push_subscriptions table | Yes | 11.x (Unraid) | — | +| node 22 LTS | web-push (VAPID uses Web Crypto) | Yes | 22.x | — (web-push requires Node 18+) | +| vite-plugin-pwa 1.3.x | injectManifest strategy | Yes | ^1.3.0 | — (already installed in apps/pwa) | +| web-push | Push dispatch | No (not installed) | — | Must install: `pnpm --filter @familysync/api add web-push` | +| workbox-precaching / workbox-core | Custom SW build | No (not installed) | — | Must install as devDependencies in apps/pwa | + +**Missing dependencies with no fallback:** +- `web-push` in apps/api — blocks all push dispatch +- `workbox-precaching` in apps/pwa — blocks SW injectManifest build + +--- + +## Validation Architecture + +### Test Framework + +| Property | Value | +|----------|-------| +| Framework | Vitest ^4.1.8 | +| Config file | apps/api/vitest.config.ts, apps/pwa/vitest.config.ts | +| Quick run command | `pnpm --filter @familysync/api exec vitest run tests/routes/push.test.ts` | +| Full suite command | `pnpm test` (root, runs API suite) | + +### Phase Requirements → Test Map + +| Req ID | Behavior | Test Type | Automated Command | File Exists? | +|--------|----------|-----------|-------------------|-------------| +| NOTIF-01 | Reminder fires for shared timed events ~15min before start | unit (reminderScheduler) | `pnpm --filter @familysync/api exec vitest run tests/broker/reminderScheduler.test.ts` | No — Wave 0 | +| NOTIF-01 | All-day events do not get reminders (D-07) | unit | same file | No — Wave 0 | +| NOTIF-01 | Non-shared events do not get reminders (D-05) | unit | same file | No — Wave 0 | +| NOTIF-02 | List-change push fires for other member | unit (pushCoalescer) | `pnpm --filter @familysync/api exec vitest run tests/lib/pushCoalescer.test.ts` | No — Wave 0 | +| NOTIF-02 | Coalescing collapses burst into one push (D-01) | unit | same file | No — Wave 0 | +| NOTIF-02 | Reorder changes do not push (D-01) | unit (lists route) | existing `tests/routes/lists.test.ts` — extend | Partial | +| NOTIF-03 | Event-change dispatch on new/updated event (NOTIF-03) | unit (eventChangeDispatcher) | `pnpm --filter @familysync/api exec vitest run tests/lib/eventChangeDispatcher.test.ts` | No — Wave 0 | +| NOTIF-03 | Description-only change does NOT push (D-04) | unit | same file | No — Wave 0 | +| D-11 | Push subscription POST/DELETE API | integration | `pnpm --filter @familysync/api exec vitest run tests/routes/push.test.ts` | No — Wave 0 | +| D-11 | 410/404 from push service prunes subscription | unit (pushDispatcher) | `pnpm --filter @familysync/api exec vitest run tests/lib/pushDispatcher.test.ts` | No — Wave 0 | +| D-08 | Permission prompt renders after install | PWA component (playwright-cli) | `playwright-cli evaluate "document.querySelector('[aria-label=\"Enable push notifications\"]')"` | No — Wave 0 | + +### Sampling Rate +- **Per task commit:** `pnpm --filter @familysync/api exec vitest run` (unit tests; skip integration DB tests) +- **Per wave merge:** `pnpm test` (full API suite) + playwright-cli smoke on permission prompt +- **Phase gate:** Full suite green + human verify on iOS device (push delivery, Home Screen required) + +### Wave 0 Gaps +- [ ] `tests/broker/reminderScheduler.test.ts` — NOTIF-01 unit tests +- [ ] `tests/lib/pushCoalescer.test.ts` — NOTIF-02 coalescing unit tests +- [ ] `tests/lib/pushDispatcher.test.ts` — 410/404 pruning unit tests +- [ ] `tests/lib/eventChangeDispatcher.test.ts` — NOTIF-03 dispatch unit tests +- [ ] `tests/routes/push.test.ts` — subscription POST/DELETE integration tests +- [ ] `test/setup.ts` update — add `push_subscriptions` to afterEach truncation +- [ ] `apps/pwa/src/sw.ts` — custom SW source file (required for injectManifest build) +- [ ] Install: `pnpm --filter @familysync/api add web-push && pnpm --filter @familysync/api add -D @types/web-push` +- [ ] Install: `pnpm --filter @familysync/pwa add -D workbox-precaching workbox-core` + +--- + +## Security Domain + +### Applicable ASVS Categories (Level 1) + +| ASVS Category | Applies | Standard Control | +|---------------|---------|-----------------| +| V2 Authentication | yes | Push routes behind oidcAuthMiddleware; subscription belongs to authenticated user | +| V3 Session Management | no | Push subscription is not session state | +| V4 Access Control | yes | Subscription POST/DELETE scoped to c.get('user').id; push fan-out must not cross user boundaries | +| V5 Input Validation | yes | zod validation on subscription body (endpoint string, p256dh, auth) | +| V6 Cryptography | yes | web-push handles VAPID signing; NEVER hand-roll; VAPID_PRIVATE_KEY in env only | + +### Known Threat Patterns for this Stack + +| Pattern | STRIDE | Standard Mitigation | +|---------|--------|---------------------| +| VAPID private key exposure | Information Disclosure | Store in .env; never in code; never in git | +| Unauthorized push subscription (user A subscribes on behalf of user B) | Spoofing | Subscription POST always uses authenticated userId from OIDC session | +| Push to wrong user's subscriptions | Tampering | fan-out queries filter by userId and list access (same as SSE scope check) | +| Endpoint enumeration via POST /api/push/subscription | Information Disclosure | Endpoint is user-specific; server does not expose other users' endpoints | +| Malformed subscription body causing crypto crash | Denial of Service | zod validation before DB insert; web-push errors caught per-subscription | +| VAPID key in Docker image layers | Information Disclosure | Pass VAPID keys as environment variables at runtime (Docker Compose .env or secrets) | + +--- + +## Project Constraints (from CLAUDE.md) + +- **MariaDB only** (no PostgreSQL). Drizzle ORM with mysql2 driver. All migrations via `db:generate` + `db:migrate`. +- **`db:push` is UNSAFE** on populated MariaDB — never use it after data exists. +- **Single Node process** — in-memory EventEmitter fan-out is correct; no Redis. +- **Broker-only CalDAV I/O (D-13)** — notification code must not call tsdav. +- **React PWA only** — no native app. Service worker runs in browser. +- **iOS 16.4 minimum** — push requires Home Screen install; pushManager.subscribe() must be in a user gesture. +- **Every push must show a visible notification** — no silent pushes (iOS revokes after ~3). +- **web-push 3.6.7** is the designated VAPID library (CLAUDE.md stack table). +- **node-cron already installed** in apps/api — no new scheduler library needed. +- **playwright-cli** is available at `/usr/local/bin/playwright-cli` for browser-side verification. +- **Hono** is the API framework — push routes follow same pattern as existing routers. +- **`apps/api/src/db/schema.ts`** is the single source of truth for DB schema. New table goes here. + +--- + +## Sources + +### Primary (MEDIUM confidence — Context7 from official docs) +- `/web-push-libs/web-push` — VAPID key generation, sendNotification API, error codes (410/404), TypeScript usage +- `/vite-pwa/vite-plugin-pwa` — injectManifest strategy, autoUpdate with custom SW, self.__WB_MANIFEST + +### Secondary (MEDIUM confidence — web search + official blog) +- [https://webkit.org/blog/16535/meet-declarative-web-push/](https://webkit.org/blog/16535/meet-declarative-web-push/) — Declarative Web Push payload format, iOS 18.4+ availability +- [https://webkit.org/blog/16574/webkit-features-in-safari-18-4/](https://webkit.org/blog/16574/webkit-features-in-safari-18-4/) — Safari 18.4 feature confirmation + +### Codebase (HIGH confidence — direct inspection) +- `apps/api/src/db/schema.ts` — confirmed column list, customType pattern, existing table structure +- `apps/api/src/lib/listEmitter.ts` — confirmed publishListEvent signature and call pattern +- `apps/api/src/broker/poller.ts` + `outboxWorker.ts` — confirmed event change detection hooks +- `apps/pwa/vite.config.ts` — confirmed generateSW mode, denylist, runtimeCaching +- `apps/pwa/src/components/InstallPrompt.tsx` — confirmed isInstalled(), WalkthroughSheet pattern +- `apps/api/package.json` / `apps/pwa/package.json` — confirmed web-push and workbox NOT installed +- `apps/api/drizzle.config.ts` + migration journal — confirmed db:generate+migrate workflow + +## Metadata + +**Confidence breakdown:** +- Standard stack (web-push, workbox): MEDIUM — confirmed via npm registry + Context7 from GitHub README +- Architecture: HIGH — based on direct codebase inspection; patterns derived from existing workers +- Pitfalls: HIGH (iOS) — confirmed in CLAUDE.md and STATE.md; HIGH (DB migration) — confirmed in memory note; MEDIUM (others) — based on library docs +- Service worker migration: MEDIUM — Context7 docs; runtime behavior on iOS needs human verification + +**Research date:** 2026-06-09 +**Valid until:** 2026-07-09 (30 days — libraries are stable)