Mechanical reformat — no logic changes. 398 files changed, 19125 insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc (singleQuote:true, semi:true, tabWidth:2, trailingComma:all, printWidth:100). Isolated per D-13-08 for reviewability.
56 KiB
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 ~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 </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 (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 |
</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)
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)
// 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_reminderstable 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.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.tsuses dialectmysql, schema at./src/db/schema.ts, migrations out to./src/db/migrations.- Scripts:
db:generate→drizzle-kit generate;db:migrate→drizzle-kit migrate;db:pushexists 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
customTypepattern 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
navigateFallbackDenylistentries:/^\/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.
-
Does event-change detection require a new syncCalendar hook or a separate table diff?
- What we know:
syncCalendardoes anonDuplicateKeyUpdateupsert 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
onChangesside-effect callback parameter tosyncCalendar, 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.
- What we know:
-
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.
-
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_reminderstable 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). Nosent_reminderstable. 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-pushin apps/api — blocks all push dispatchworkbox-precachingin 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 teststests/lib/pushCoalescer.test.ts— NOTIF-02 coalescing unit teststests/lib/pushDispatcher.test.ts— 410/404 pruning unit teststests/lib/eventChangeDispatcher.test.ts— NOTIF-03 dispatch unit teststests/routes/push.test.ts— subscription POST/DELETE integration teststest/setup.tsupdate — addpush_subscriptionsto afterEach truncationapps/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:pushis 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-clifor browser-side verification. - Hono is the API framework — push routes follow same pattern as existing routers.
apps/api/src/db/schema.tsis 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/ — Declarative Web Push payload format, iOS 18.4+ availability
- 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 structureapps/api/src/lib/listEmitter.ts— confirmed publishListEvent signature and call patternapps/api/src/broker/poller.ts+outboxWorker.ts— confirmed event change detection hooksapps/pwa/vite.config.ts— confirmed generateSW mode, denylist, runtimeCachingapps/pwa/src/components/InstallPrompt.tsx— confirmed isInstalled(), WalkthroughSheet patternapps/api/package.json/apps/pwa/package.json— confirmed web-push and workbox NOT installedapps/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)