Files
familysync/.planning/milestones/v1.0-phases/05-web-push-notifications/05-RESEARCH.md
T

49 KiB
Raw Blame History

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>

User Constraints (from CONTEXT.md)

Locked Decisions

  • D-01: Coalesce list-change pushes per list within ~3060s 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 </user_constraints>

<phase_requirements>

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 (3060s 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
</phase_requirements>

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

# 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):

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)

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)

// 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<void> {
  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

// 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

// 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)

// 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:

# 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_<name>.sql.

Pattern 5: Reminder Scheduler (node-cron, 1-min interval)

// 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

// apps/api/src/lib/pushCoalescer.ts
const pendingCoalesced = new Map<string, { count: number; timer: ReturnType<typeof setTimeout> }>()

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)

// 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<void> {
    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<void> {
    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)

# 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)

// 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

// 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.418.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:generatedrizzle-kit generate; db:migratedrizzle-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_<generated-name>.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 (RESOLVED)

All three questions were resolved during planning (Phase 5 plans, 2026-06-09). Resolutions locked below.

  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.
    • RESOLVED (Plan 05-07): Add an onChanges side-effect callback parameter to syncCalendar, consumed by the poller (external changes) and the outbox resync (this-member writes). The syncing userId is the actor and is suppressed from its own notifications. No DB trigger.
  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.
    • RESOLVED (Plan 05-04): Runtime fetch via GET /api/push/vapid-public-key (unauthenticated). Fetched once by the usePushSubscription hook before subscribe. Enables key rotation without a PWA rebuild.
  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.
    • RESOLVED (Plan 05-06): In-memory Set<${eventUid}:${minuteBucket}> per process (acceptable for the single-process deployment, per D-12). No sent_reminders table. Tradeoff accepted: a process restart loses the dedup set, so a reminder could re-fire once after a restart that coincides with the 2-minute send window — tolerable for a two-person household.

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)

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)