style(13-03): apply Prettier formatting across repo

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.
This commit is contained in:
Lucas Berger
2026-06-11 20:35:18 -04:00
parent 4bc0445173
commit 982438dc10
398 changed files with 19050 additions and 16382 deletions
@@ -7,6 +7,7 @@
---
<user_constraints>
## User Constraints (from CONTEXT.md)
### Locked Decisions
@@ -44,18 +45,20 @@
- Per-category opt-out (reminder / event-change / list-change toggles)
- Reminders for personal-calendar events
- Notifying on the member's own changes
</user_constraints>
</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 |
| 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>
---
@@ -74,20 +77,20 @@ The second critical constraint is the service-worker migration. The existing vit
## 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 |
| 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 |
---
@@ -95,18 +98,18 @@ The second critical constraint is the service-worker migration. The existing vit
### 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] |
| 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] |
| 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
@@ -131,11 +134,11 @@ 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 |
| 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
@@ -228,14 +231,14 @@ apps/api/src/db/migrations/
```typescript
// Source: https://github.com/web-push-libs/web-push/blob/master/README.md
import webpush from 'web-push'
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(
@@ -246,24 +249,24 @@ async function dispatchPush(
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
TTL: 300, // 5 min: notification has already expired if not delivered soon
urgency: 'normal',
})
});
} catch (err: unknown) {
const statusCode = (err as { statusCode?: number }).statusCode
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))
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)
console.error('[pushDispatcher] sendNotification error:', statusCode, (err as Error).message);
}
}
```
@@ -272,64 +275,71 @@ async function dispatchPush(
```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'
import { precacheAndRoute } from 'workbox-precaching';
import { clientsClaim } from 'workbox-core';
declare let self: ServiceWorkerGlobalScope
declare let self: ServiceWorkerGlobalScope;
// autoUpdate behavior: claim all clients immediately on activate
self.skipWaiting()
clientsClaim()
self.skipWaiting();
clientsClaim();
// Inject Workbox precache manifest (plugin populates self.__WB_MANIFEST at build time)
precacheAndRoute(self.__WB_MANIFEST)
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' }
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 }
}
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
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 ?? '/' },
}
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))
})
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.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()
(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)
}),
)
})
return (self.clients as Clients).openWindow(url);
}),
);
});
```
### Pattern 3: vite.config.ts migration to injectManifest
@@ -350,14 +360,14 @@ VitePWA({
},
// 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'
import { varchar, text, timestamp, int, mysqlTable, index, unique } from 'drizzle-orm/mysql-core';
export const pushSubscriptions = mysqlTable(
'push_subscriptions',
@@ -377,7 +387,7 @@ export const pushSubscriptions = mysqlTable(
unique('uniq_push_endpoint').on(t.endpoint),
index('idx_push_subscriptions_user_id').on(t.userId),
],
)
);
```
**Migration:**
@@ -438,7 +448,7 @@ export function startReminderScheduler(): void {
```typescript
// apps/api/src/lib/pushCoalescer.ts
const pendingCoalesced = new Map<string, { count: number; timer: ReturnType<typeof setTimeout> }>()
const pendingCoalesced = new Map<string, { count: number; timer: ReturnType<typeof setTimeout> }>();
export function coalesceListPush(
listId: number,
@@ -448,15 +458,15 @@ export function coalesceListPush(
dispatch: (payload: object, excludeUserId: number) => void,
windowMs = 45_000,
): void {
const key = `${listId}:${actorId}`
const existing = pendingCoalesced.get(key)
const key = `${listId}:${actorId}`;
const existing = pendingCoalesced.get(key);
if (existing) {
existing.count++
clearTimeout(existing.timer)
existing.count++;
clearTimeout(existing.timer);
}
const entry = existing ?? { count: 1, timer: null! }
const entry = existing ?? { count: 1, timer: null! };
entry.timer = setTimeout(() => {
pendingCoalesced.delete(key)
pendingCoalesced.delete(key);
dispatch(
{
title: `${actorName} updated ${listName}`,
@@ -464,9 +474,9 @@ export function coalesceListPush(
navigate: `/lists/${listId}`,
},
actorId, // D-03: suppress own notification
)
}, windowMs)
if (!existing) pendingCoalesced.set(key, entry)
);
}, windowMs);
if (!existing) pendingCoalesced.set(key, entry);
}
```
@@ -478,38 +488,38 @@ export function coalesceListPush(
export function usePushSubscription() {
// On mount: if permission granted but no subscription → silently re-subscribe (D-10)
useEffect(() => {
if (Notification.permission !== 'granted') return
if (Notification.permission !== 'granted') return;
navigator.serviceWorker.ready.then(async (reg) => {
const existing = await reg.pushManager.getSubscription()
const existing = await reg.pushManager.getSubscription();
if (!existing) {
// Silent re-subscribe (D-10) — no user gesture needed (permission already granted)
await subscribeAndPost(reg)
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' })
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 }
return { subscribe, unsubscribe };
}
```
@@ -527,13 +537,13 @@ export function usePushSubscription() {
## 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 |
| 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 |
---
@@ -546,36 +556,42 @@ export function usePushSubscription() {
## 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.
@@ -583,12 +599,14 @@ export function usePushSubscription() {
**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.
@@ -634,15 +652,15 @@ npx web-push generate-vapid-keys --json
```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'
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 navigationHandler = createHandlerBoundToURL('/index.html');
const navigationRoute = new NavigationRoute(navigationHandler, {
denylist: [/^\/callback/, /^\/api\//, /^\/health/],
})
registerRoute(navigationRoute)
});
registerRoute(navigationRoute);
```
Alternative — add `workbox-routing` and `workbox-precaching` as devDependencies if this approach is used.
@@ -651,12 +669,12 @@ Alternative — add `workbox-routing` and `workbox-precaching` as devDependencie
## 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 |
| 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/]
@@ -668,6 +686,7 @@ The dual-format payload approach (embedding both `"web_push":8030 + notification
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).
@@ -675,49 +694,60 @@ These facts were confirmed by direct inspection and are the planner's authoritat
- 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 |
| # | 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 |
---
@@ -744,16 +774,17 @@ Route tests in `tests/routes/` use a real MariaDB connection with `vi.mock('../.
## 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 |
| 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
@@ -763,35 +794,37 @@ Route tests in `tests/routes/` use a real MariaDB connection with `vi.mock('../.
### 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) |
| 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 |
| 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
@@ -808,24 +841,24 @@ Route tests in `tests/routes/` use a real MariaDB connection with `vi.mock('../.
### 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 |
| 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) |
| 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) |
---
@@ -849,14 +882,17 @@ Route tests in `tests/routes/` use a real MariaDB connection with `vi.mock('../.
## 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
- `/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
@@ -868,6 +904,7 @@ Route tests in `tests/routes/` use a real MariaDB connection with `vi.mock('../.
## 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