# 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

```bash
# API
pnpm --filter @familysync/api add web-push
pnpm --filter @familysync/api add -D @types/web-push

# PWA (devDependencies — workbox is bundled into SW at build time)
pnpm --filter @familysync/pwa add -D workbox-precaching workbox-core
```

**Version verification (run at implementation time):**

```bash
npm view web-push version           # confirmed 3.6.7
npm view @types/web-push version    # confirmed 3.6.4
npm view workbox-precaching version # confirmed 7.4.1
```

---

## Package Legitimacy Audit

| Package            | Registry | Age                          | Downloads | Source Repo                                | Verdict | Disposition |
| ------------------ | -------- | ---------------------------- | --------- | ------------------------------------------ | ------- | ----------- |
| web-push           | npm      | ~9 yrs (2024-01-16 last pub) | 5.09M/wk  | github.com/web-push-libs/web-push          | OK      | Approved    |
| @types/web-push    | npm      | ~8 yrs (2024-10-22 last pub) | 1.68M/wk  | github.com/DefinitelyTyped/DefinitelyTyped | OK      | Approved    |
| workbox-precaching | npm      | ~8 yrs (2026-05-04 last pub) | 7.92M/wk  | github.com/googlechrome/workbox            | OK      | Approved    |

**Packages removed due to SLOP verdict:** none
**Packages flagged as suspicious (SUS):** none

---

## Architecture Patterns

### System Architecture Diagram

```
Browser (PWA)                          API (Node.js / Hono)
─────────────────────────────          ──────────────────────────────────────────
[InstallPrompt/PushPermissionPrompt]
  │ tap: requestPermission()
  ↓
[pushManager.subscribe(vapidPublicKey)]
  │ PushSubscription {endpoint, keys}
  │ POST /api/push/subscription
  ─────────────────────────────────→  [pushRouter]
                                         │ INSERT push_subscriptions (userId, endpoint, p256dh, auth)
                                         ↓
                              [MariaDB: push_subscriptions]

── Triggers (server-side) ──────────────────────────────────────────────────

[node-cron every 1 min]                [poller/sync.ts — on calendarEvents upsert]
  │ SELECT shared timed events           │ detect meaningful change (new/updated uid)
  │ WHERE dtstart_utc BETWEEN            │ skip actor's subscription
  │   NOW()+14min AND NOW()+16min        │
  ↓                                      ↓
[reminderScheduler.ts]               [eventChangeDispatcher.ts]
  │ SELECT push_subscriptions             │ SELECT push_subscriptions
  │   WHERE userId != event.userId?       │   WHERE userId NOT IN (actor)
  │   (all members for shared events)     │
  ↓                                      ↓
[pushDispatcher.ts]  ←─────────────[listChangeDispatcher.ts (coalesced)]
  │ webpush.sendNotification()              ↑
  │ payload: {web_push:8030,           [publishListEvent hook in listEmitter.ts]
  │   notification:{title,body,           │ debounce 30-60s per (listId, actorId)
  │   navigate, ...}}                     │ suppress actor's own subscription
  │
  │ on 410/404 → DELETE push_subscriptions (prune expired)
  │ on success → subscription stays
  ↓
[Push Service (APNs/FCM)]
  ↓
[Browser / iOS SW]

── PWA Service Worker (sw.ts) ──────────────────────────────────────────────
[push event]
  └→ event.waitUntil(
       self.registration.showNotification(data.notification.title, {
         body, tag, data: {url}
       })
     )
[notificationclick event]
  └→ clients.openWindow(event.notification.data.url)
```

### Recommended Project Structure

New files this phase:

```
apps/api/src/
├── routes/
│   └── push.ts                 # POST /api/push/subscription, DELETE, GET /api/push/vapid-public-key
├── lib/
│   ├── pushDispatcher.ts       # webpush.sendNotification wrapper + 410/404 pruning
│   └── pushCoalescer.ts        # per-(listId,actorId) debounce for list-change pushes
├── broker/
│   └── reminderScheduler.ts   # node-cron 1-min interval: scan shared timed events ±1min window
└── db/schema.ts               # add push_subscriptions table

apps/pwa/src/
├── sw.ts                       # NEW custom SW: precacheAndRoute + push + notificationclick
├── components/
│   ├── PushPermissionPrompt.tsx
│   ├── SettingsSheet.tsx
│   └── PermissionDeniedBanner.tsx
└── hooks/
    └── usePushSubscription.ts  # subscribe/unsubscribe/health-check lifecycle

apps/api/src/db/migrations/
└── 0003_push_subscriptions.sql # generated by drizzle-kit generate
```

### Pattern 1: web-push VAPID dispatch (TypeScript / ESM)

```typescript
// Source: https://github.com/web-push-libs/web-push/blob/master/README.md
import webpush from 'web-push';

// Call once at API startup (index.ts isMainModule() guard)
webpush.setVapidDetails(
  'mailto:admin@familysync.bergerhouse.net',
  process.env.VAPID_PUBLIC_KEY!,
  process.env.VAPID_PRIVATE_KEY!,
);

// Dispatch helper — prunes expired subscriptions on 410/404
async function dispatchPush(
  subscription: { endpoint: string; p256dh: string; auth: string },
  payload: object,
  dbRowId: number,
): Promise<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

```typescript
// Source: https://github.com/vite-pwa/vite-plugin-pwa/blob/main/docs/guide/inject-manifest.md
import { precacheAndRoute } from 'workbox-precaching';
import { clientsClaim } from 'workbox-core';

declare let self: ServiceWorkerGlobalScope;

// autoUpdate behavior: claim all clients immediately on activate
self.skipWaiting();
clientsClaim();

// Inject Workbox precache manifest (plugin populates self.__WB_MANIFEST at build time)
precacheAndRoute(self.__WB_MANIFEST);

// CRITICAL: every push MUST call showNotification (D-11 / iOS requirement)
self.addEventListener('push', (event: PushEvent) => {
  let title = 'FamilySync';
  let options: NotificationOptions = { body: 'You have a new notification' };

  if (event.data) {
    try {
      const data = event.data.json() as {
        notification?: { title?: string; body?: string; navigate?: string };
        title?: string;
        body?: string;
        tag?: string;
        data?: { url?: string };
      };
      // Support both Declarative Web Push format (iOS 18.4+) and legacy format
      const notif = data.notification ?? data;
      title = notif.title ?? title;
      options = {
        body: notif.body ?? options.body,
        tag: (data as { tag?: string }).tag ?? undefined,
        data: {
          url:
            (notif as { navigate?: string }).navigate ??
            (data as { data?: { url?: string } }).data?.url ??
            '/',
        },
      };
    } catch {
      // Malformed payload — still show a generic notification (iOS: never drop silently)
    }
  }

  // event.waitUntil is MANDATORY — iOS revokes subscription after ~3 silent pushes (D-11)
  event.waitUntil(self.registration.showNotification(title, options));
});

self.addEventListener('notificationclick', (event: NotificationEvent) => {
  event.notification.close();
  const url: string = (event.notification.data as { url?: string })?.url ?? '/';
  event.waitUntil(
    (self.clients as Clients)
      .matchAll({ type: 'window', includeUncontrolled: true })
      .then((clientList) => {
        // Focus existing window if already open
        for (const client of clientList) {
          if ('url' in client && (client as WindowClient).url === url && 'focus' in client) {
            return (client as WindowClient).focus();
          }
        }
        return (self.clients as Clients).openWindow(url);
      }),
  );
});
```

### Pattern 3: vite.config.ts migration to injectManifest

```typescript
// Source: https://github.com/vite-pwa/vite-plugin-pwa/blob/main/docs/guide/inject-manifest.md
VitePWA({
  strategies: 'injectManifest',
  srcDir: 'src',
  filename: 'sw.ts',
  registerType: 'autoUpdate',
  injectManifest: {
    // Preserve existing SW denylist behavior (T-03-20: /callback must not be precached)
    globIgnores: ['**/node_modules/**', '**/callback**'],
  },
  manifest: {
    // ... same manifest config as current generateSW setup ...
  },
  // Note: navigateFallback moves from workbox: {} to injectManifest: {} or is handled
  // directly in the custom SW via WorkboxRouter if needed
});
```

### Pattern 4: Push subscription schema (Drizzle, MariaDB)

```typescript
// apps/api/src/db/schema.ts addition
import { varchar, text, timestamp, int, mysqlTable, index, unique } from 'drizzle-orm/mysql-core';

export const pushSubscriptions = mysqlTable(
  'push_subscriptions',
  {
    id: int().primaryKey().autoincrement(),
    userId: int('user_id')
      .notNull()
      .references(() => users.id, { onDelete: 'cascade' }),
    endpoint: text('endpoint').notNull(),
    p256dh: text('p256dh').notNull(),
    auth: varchar('auth', { length: 256 }).notNull(),
    createdAt: timestamp('created_at').defaultNow().notNull(),
    updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(),
  },
  (t) => [
    // One endpoint per user (a member can have multiple devices, but endpoint is globally unique)
    unique('uniq_push_endpoint').on(t.endpoint),
    index('idx_push_subscriptions_user_id').on(t.userId),
  ],
);
```

**Migration:**

```bash
# MUST use generate+migrate, never db:push (drizzle-mariadb-push-unsafe.md)
pnpm --filter @familysync/api db:generate
pnpm --filter @familysync/api db:migrate
```

The migration file will be generated at `apps/api/src/db/migrations/0003_<name>.sql`.

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

```typescript
// apps/api/src/broker/reminderScheduler.ts
import { schedule } from 'node-cron'
import { and, eq, gte, lte, isNull, not } from 'drizzle-orm'
import { db } from '../db/client.js'
import { calendars, calendarEvents, pushSubscriptions } from '../db/schema.js'

// Fire once per minute; scan window: [now+14min, now+16min] (D-06: 15-min lead)
export function startReminderScheduler(): void {
  schedule('* * * * *', async () => {
    const now = new Date()
    const windowStart = new Date(now.getTime() + 14 * 60 * 1000)
    const windowEnd   = new Date(now.getTime() + 16 * 60 * 1000)

    // D-05: shared events only; D-07: timed events only (allDay=false)
    const events = await db
      .select({ uid: calendarEvents.uid, title: calendarEvents.uid /* swap for title field */ })
      .from(calendarEvents)
      .innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id))
      .where(
        and(
          eq(calendars.isShared, true),
          eq(calendarEvents.allDay, false),
          gte(calendarEvents.dtstartUtc, windowStart),
          lte(calendarEvents.dtstartUtc, windowEnd),
          not(isNull(calendarEvents.dtstartUtc)),
        ),
      )

    for (const event of events) {
      // Get all subscriptions for all users (shared event — notify all members)
      const subs = await db.select().from(pushSubscriptions)
      for (const sub of subs) {
        await dispatchPush(sub, { title: event.uid, body: 'Starts in 15 min', ... }, sub.id)
      }
    }
  })
}
```

**Critical schema note:** `calendarEvents` does NOT have a `title` column — only `uid`, `rawVevent`, and `dtstartUtc`. The scheduler must extract the VEVENT SUMMARY from `rawVevent` using ical.js, or the schema must be extended with a `title` column (recommended — avoids ical.js parsing on every reminder fire).

### Pattern 6: List-change coalescing debounce

```typescript
// apps/api/src/lib/pushCoalescer.ts
const pendingCoalesced = new Map<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)

```typescript
// apps/pwa/src/hooks/usePushSubscription.ts
// Manages: subscribe, unsubscribe, health-check on mount (D-10)
export function usePushSubscription() {
  // On mount: if permission granted but no subscription → silently re-subscribe (D-10)
  useEffect(() => {
    if (Notification.permission !== 'granted') return;
    navigator.serviceWorker.ready.then(async (reg) => {
      const existing = await reg.pushManager.getSubscription();
      if (!existing) {
        // Silent re-subscribe (D-10) — no user gesture needed (permission already granted)
        await subscribeAndPost(reg);
      }
    });
  }, []);

  // subscribe: must be called inside a tap handler (D-08 / iOS requirement)
  async function subscribe(reg: ServiceWorkerRegistration): Promise<void> {
    const sub = await reg.pushManager.subscribe({
      userVisibleOnly: true,
      applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
    });
    await fetch('/api/push/subscription', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(sub.toJSON()),
      credentials: 'include',
    });
  }

  async function unsubscribe(): Promise<void> {
    const reg = await navigator.serviceWorker.ready;
    const sub = await reg.pushManager.getSubscription();
    if (sub) await sub.unsubscribe();
    await fetch('/api/push/subscription', { method: 'DELETE', credentials: 'include' });
  }

  return { subscribe, unsubscribe };
}
```

### Anti-Patterns to Avoid

- **Calling pushManager.subscribe() outside a user gesture:** iOS silently fails. Always call inside onClick/onTap handler, never on component mount or useEffect.
- **Silent pushes (no showNotification in push handler):** iOS revokes subscription after ~3 silent pushes. event.waitUntil(showNotification(...)) is mandatory on every push event, even if payload is malformed.
- **Using db:push for schema migration:** drizzle-kit push emits false destructive diff on populated MariaDB (truncates tables). Always use `db:generate` + `db:migrate`.
- **Using generateSW with custom push handler:** generateSW auto-generates the entire SW from options only — there is no hook to inject push event listeners. Must switch to injectManifest.
- **Storing VAPID private key in code / git:** Store in .env (VAPID_PRIVATE_KEY). Never commit.
- **Fan-out to all users for a list change:** Only fan out to users who can access the list (list_shares join table). Use same access check as SSE route.
- **Duplicate reminder pushes:** The 1-min scheduler with a ±1-min window will fire twice for an event if it falls exactly at the boundary. Deduplicate via a reminder_sent flag or a separate `sent_reminders` table keyed by (eventUid, scheduledAt bucket).

---

## Don't Hand-Roll

| Problem                          | Don't Build            | Use Instead                                 | Why                                                                                 |
| -------------------------------- | ---------------------- | ------------------------------------------- | ----------------------------------------------------------------------------------- |
| VAPID signing + encryption       | Custom crypto          | web-push                                    | RFC 8292 + Message Encryption for Web Push; 40+ lines of crypto primitives per send |
| Push payload encryption          | Manual AES-128-GCM     | web-push.sendNotification                   | Handles p256dh key agreement + content encryption per IETF RFC 8291                 |
| Service worker precache manifest | Manual file list       | workbox-precaching + self.\_\_WB_MANIFEST   | Build-time injection; stale hash mismatches cause update failures                   |
| VAPID key generation             | crypto.generateKeyPair | webpush.generateVAPIDKeys() or web-push CLI | Returns URL-safe Base64 directly; format required by push services                  |
| Subscription expiry cleanup      | Custom cron            | 410/404 error handler in dispatchPush       | Push services send 410 exactly when subscription is gone; polling misses edge cases |

---

## Runtime State Inventory

> Not a rename/refactor phase. Section omitted.

---

## Common Pitfalls

### Pitfall 1: iOS subscription silently revoked after ~3 silent pushes

**What goes wrong:** Push notifications stop arriving on iOS with no error. The subscription endpoint still exists in the DB. The push service returns 200 but the notification never appears.
**Why it happens:** iOS Safari enforces that every push event results in a visible notification. Three consecutive push events without showNotification() cause APNs to mark the subscription dead.
**How to avoid:** Every push event handler MUST call event.waitUntil(self.registration.showNotification(...)) — even for malformed payloads (fall back to a generic message). No silent pushes, ever.
**Warning signs:** Users stop receiving notifications after a period of working correctly. DB shows no pruned subscriptions (410 errors never appear because the subscription is dead but not explicitly invalidated by APNs).

### Pitfall 2: pushManager.subscribe() outside a user gesture fails silently on iOS

**What goes wrong:** The subscribe call returns a rejected promise or does nothing. No error surfaced to the user.
**Why it happens:** iOS requires pushManager.subscribe() to be invoked directly within a user tap event handler — not in a useEffect, not in a setTimeout, not after an await boundary. Any async hop breaks the user-gesture context.
**How to avoid:** The "Enable Notifications" button onClick must call pushManager.subscribe() synchronously (before any awaits) or use the existing tap event reference. See D-08 and UI-SPEC surface 1.
**Warning signs:** Subscribe works on Android/Chrome but silently fails on iOS.

### Pitfall 3: vite-plugin-pwa injectManifest — missing workbox-precaching devDependency

**What goes wrong:** Build fails with `Cannot find module 'workbox-precaching'` or the SW bundles without precache support.
**Why it happens:** In generateSW mode, vite-plugin-pwa bundles Workbox internally. In injectManifest mode, the custom SW source is compiled by Vite — workbox-precaching must be an explicit devDependency.
**How to avoid:** `pnpm --filter @familysync/pwa add -D workbox-precaching workbox-core`
**Warning signs:** TypeScript error in sw.ts on `import { precacheAndRoute }`.

### Pitfall 4: /callback denylist lost after SW migration

**What goes wrong:** After migrating to injectManifest, the OIDC /callback route is served from SW cache instead of reaching the server. This causes the login loop bug (T-03-20).
**Why it happens:** The existing `workbox.navigateFallbackDenylist` in vite.config.ts only applies to the generateSW strategy. injectManifest does not read from `workbox:` key — the SW must implement the navigation denylist explicitly (via Workbox Router or a fetch event handler checking the URL).
**How to avoid:** In sw.ts, add a fetch handler that falls through for /callback and /api/ requests. Or use workbox-routing NavigationRoute with denylist.
**Warning signs:** After re-login, the app loops at /callback.

### Pitfall 5: Duplicate reminder fires for events at the window boundary

**What goes wrong:** A user gets two reminder pushes for the same event ~1 minute apart.
**Why it happens:** The 1-minute cron runs at T=0 and T=1; an event at dtstart_utc=T+15 falls in both [T+14, T+16] windows.
**How to avoid:** Track sent reminders. Simplest approach: a `sent_reminders` table with `(eventUid, reminderBucket CHAR(16))` where bucket = the UTC minute of the scheduled fire. Unique key on (eventUid, reminderBucket) prevents double-insert → skip dispatch on duplicate key.
**Warning signs:** Members report receiving identical reminder notifications 1 minute apart.

### Pitfall 6: calendarEvents has no title column — must parse rawVevent

**What goes wrong:** Reminder copy shows the VEVENT UID instead of the event title (e.g. "abc123-def456-..." instead of "Dentist").
**Why it happens:** The existing schema stores the VEVENT SUMMARY only in rawVevent (text blob), not as a dedicated indexed column. The scheduler query cannot SELECT a title.
**How to avoid:** Add a `title` varchar column to calendar_events (populated during sync from ical.js SUMMARY). This is a new migration (0004) but avoids ical.js parsing on every reminder check. Alternatively, parse rawVevent with ical.js in the scheduler — correct but slower.
**Recommended:** Add `title` column to calendar_events schema in Wave 0 (same migration as push_subscriptions, or a separate migration 0004).
**Warning signs:** Notification titles are raw UIDs.

### Pitfall 7: web-push ESM import — requires default import with @types/web-push

**What goes wrong:** TypeScript error `Module '"web-push"' has no exported member 'sendNotification'` or runtime error on named import.
**Why it happens:** web-push 3.6.7 ships CommonJS only. In an ESM project (apps/api `"type":"module"`), it must be imported as the default export: `import webpush from 'web-push'` (not named imports). @types/web-push provides the types for this pattern.
**How to avoid:** Always use `import webpush from 'web-push'` (default import).
**Warning signs:** TypeScript compiles but `webpush.setVapidDetails` is undefined at runtime.

### Pitfall 8: VAPID public key must be served to the PWA as an environment variable

**What goes wrong:** pushManager.subscribe() fails with "invalid applicationServerKey" if the PWA uses a hardcoded or stale key.
**Why it happens:** The public key must match the private key used by web-push to sign notifications. If they are mismatched (e.g., key regenerated without updating the PWA build), the push service rejects.
**How to avoid:** Expose VAPID_PUBLIC_KEY to the Vite build via `VITE_VAPID_PUBLIC_KEY` environment variable. Alternatively, add a GET /api/push/vapid-public-key endpoint (unauthenticated, public). The hook fetches it at subscribe time — this also allows key rotation without a rebuild.
**Warning signs:** pushManager.subscribe() rejects with DOMException; existing subscriptions fail to send after key rotation.

---

## Code Examples

### VAPID key generation (one-time CLI)

```bash
# Source: https://github.com/web-push-libs/web-push/blob/master/README.md
npx web-push generate-vapid-keys --json
# → {"publicKey":"B...","privateKey":"I..."}
# Add to .env:
# VAPID_PUBLIC_KEY=B...
# VAPID_PRIVATE_KEY=I...
```

### Event payload format (Declarative Web Push + legacy SW compatible)

```json
// Source: https://webkit.org/blog/16535/meet-declarative-web-push/
// Dual-format payload: "web_push":8030 enables iOS 18.4+ declarative path;
// title/body/tag/data fields are read by the SW push handler for older iOS + Android.
{
  "web_push": 8030,
  "notification": {
    "title": "Dentist",
    "body": "Starts in 15 min",
    "navigate": "/calendar?date=2026-06-10&event=abc123"
  },
  "title": "Dentist",
  "body": "Starts in 15 min",
  "tag": "reminder-abc123",
  "data": { "url": "/calendar?date=2026-06-10&event=abc123" }
}
```

### SW navigateFallback preservation in injectManifest mode

```typescript
// Source: https://github.com/vite-pwa/vite-plugin-pwa/blob/main/docs/guide/inject-manifest.md
// In sw.ts — replicate the existing navigateFallback + denylist behavior:
import { NavigationRoute, registerRoute } from 'workbox-routing';
import { createHandlerBoundToURL } from 'workbox-precaching';

// Deny /callback, /api/*, /health from SW navigation handling (T-03-20)
const navigationHandler = createHandlerBoundToURL('/index.html');
const navigationRoute = new NavigationRoute(navigationHandler, {
  denylist: [/^\/callback/, /^\/api\//, /^\/health/],
});
registerRoute(navigationRoute);
```

Alternative — add `workbox-routing` and `workbox-precaching` as devDependencies if this approach is used.

---

## State of the Art

| Old Approach                                           | Current Approach                                     | When Changed                        | Impact                                           |
| ------------------------------------------------------ | ---------------------------------------------------- | ----------------------------------- | ------------------------------------------------ |
| Safari required separate APS certificate for push      | VAPID (RFC 8292) — same as Chrome/Firefox            | Safari 16+ (2022)                   | Single web-push flow works across all browsers   |
| Traditional Web Push required service worker JS always | Declarative Web Push — SW optional                   | iOS 18.4 / Safari 18.4 (April 2025) | Payload format change; SW handler can be simpler |
| generateSW sufficient for most PWAs                    | injectManifest required when custom SW events needed | vite-plugin-pwa 0.12+               | Need to add workbox-precaching explicitly        |
| CRA for React PWAs                                     | Vite + vite-plugin-pwa                               | 2023+ (CRA deprecated Feb 2025)     | Already using correct stack                      |

**Declarative Web Push (iOS 18.4+):**
The dual-format payload approach (embedding both `"web_push":8030 + notification{}` AND the legacy `title/body/tag/data` fields in the same JSON body) is backward compatible and handles all iOS versions from 16.4+ through 18.4+ in a single payload. iOS 16.4–18.3 uses the SW push event path; iOS 18.4+ can use the declarative path as a fallback but still processes the SW push event if a SW is installed. [CITED: https://webkit.org/blog/16535/meet-declarative-web-push/]

---

## Codebase Ground-Truth (verified by reading source)

These facts were confirmed by direct inspection and are the planner's authoritative source. [VERIFIED: codebase]

### 1. Migration workflow confirmed

- `drizzle.config.ts` uses dialect `mysql`, schema at `./src/db/schema.ts`, migrations out to `./src/db/migrations`.
- Scripts: `db:generate` → `drizzle-kit generate`; `db:migrate` → `drizzle-kit migrate`; `db:push` exists but is UNSAFE per memory note.
- Latest migration: `0002_yielding_mattie_franklin.sql` (adds utf8mb4_bin collation to list_items.rank).
- Next migration will be numbered `0003_<generated-name>.sql`.
- The `customType` pattern for special column types (e.g., utf8mb4_bin collation) is established in schema.ts and should be used again if needed.

### 2. web-push NOT installed

`apps/api/package.json` does not include `web-push`. Must be installed as part of Wave 0.

### 3. workbox-precaching NOT installed in apps/pwa

`apps/pwa/package.json` has `vite-plugin-pwa ^1.3.0` but neither `workbox-precaching` nor `workbox-core`. Must be installed as devDependencies.

### 4. vite.config.ts is generateSW mode

Confirmed: `VitePWA({ registerType: 'autoUpdate', workbox: { navigateFallback, navigateFallbackDenylist, runtimeCaching: [] } })`. Migration to `injectManifest` MUST:

- Preserve the `navigateFallbackDenylist` entries: `/^\/callback/`, `/^\/api\//`, `/^\/health/`
- Preserve `runtimeCaching: []` (no API caching)
- Re-add `skipWaiting()` + `clientsClaim()` for autoUpdate

### 5. listEmitter.ts publish points

`publishListEvent(listId, event)` is called in the lists routes (confirmed by import chain). Push dispatch for NOTIF-02 hooks this same function. The coalescer wraps the dispatch, not the emitter itself.

### 6. poller.ts calls syncCalendar on ctag change

`runPoll()` calls `syncCalendar(client, davCal, cred.userId)` when ctag changes. This is where external event changes (other member's writes arriving at Fastmail) are detected. NOTIF-03 for external changes should hook here or inside `syncCalendar`.

### 7. outboxWorker.ts calls triggerTargetedResync on success

After a successful CalDAV write, `triggerTargetedResync` runs and calls `syncCalendar`. NOTIF-03 for this-member writes (notifying the OTHER member) should hook at the point where outboxWorker marks a row `done` and the re-sync detects the new/changed event.

### 8. calendarEvents schema has NO title column

`calendarEvents` columns: id, calendarId, uid, etag, objectUrl, rawVevent, dtstartUtc, dtstartDate, allDay, hasRrule, updatedAt. No `title` or `summary` column. The reminder scheduler and event-change dispatcher must either parse `rawVevent` or the schema must be extended (recommended: add `title varchar(500)`).

### 9. index.ts startup pattern

Background workers are started only inside the `isMainModule()` guard to prevent test contamination. The new `startReminderScheduler()` must follow this same pattern.

### 10. API test pattern

Route tests in `tests/routes/` use a real MariaDB connection with `vi.mock('../../src/auth/devBypass.js', ...)` to inject a user. The new `tests/routes/push.test.ts` should follow this pattern. Pure-unit tests (pushDispatcher, pushCoalescer) mock the DB. The `tests/broker/` directory holds worker tests with DB mocking.

### 11. Test setup truncates list tables only

`test/setup.ts` afterEach truncates `list_items`, `list_shares`, `lists`. When `push_subscriptions` is added, the setup must be updated to also truncate it.

---

## Assumptions Log

| #   | Claim                                                                                                 | Section                            | Risk if Wrong                                                                                                                                         |
| --- | ----------------------------------------------------------------------------------------------------- | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| A1  | web-push 3.6.7 is CommonJS-only; ESM project must use default import `import webpush from 'web-push'` | Standard Stack, Pitfall 7          | Named import works at runtime → no impact; but TypeScript types may differ                                                                            |
| A2  | workbox-core version 7.x is compatible with workbox-precaching 7.4.1                                  | Standard Stack                     | Version mismatch causes runtime error in SW; verify with `npm view workbox-core version`                                                              |
| A3  | iOS 18.4+ Declarative Web Push still processes the SW push event when a SW is installed               | Code Examples, dual-format payload | If iOS 18.4+ skips the push event entirely when web_push:8030 is present, the dual-format approach is unnecessary; simpler — no impact on correctness |
| A4  | workbox-routing and createHandlerBoundToURL are available in workbox-precaching@7.4.1 suite           | Code Examples, navigateFallback    | May need `workbox-routing` as a separate devDependency; check if it is a sub-package                                                                  |
| A5  | listEmitter.publishListEvent is called in the route handlers rather than a service layer              | Codebase Ground-Truth              | If called elsewhere, the coalescer attachment point changes                                                                                           |

---

## Open Questions (RESOLVED)

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

1. **Does event-change detection require a new syncCalendar hook or a separate table diff?**
   - What we know: `syncCalendar` does an `onDuplicateKeyUpdate` upsert but does not return which rows changed.
   - What's unclear: To detect NOTIF-03 changes (new vs modified vs deleted event), the sync must compare old vs new state. The current sync has no "what changed" output.
   - **RESOLVED (Plan 05-07):** Add an `onChanges` side-effect callback parameter to `syncCalendar`, consumed by the poller (external changes) and the outbox resync (this-member writes). The syncing userId is the actor and is suppressed from its own notifications. No DB trigger.

2. **Should VAPID_PUBLIC_KEY be injected at build time (VITE_VAPID_PUBLIC_KEY) or fetched at runtime (GET /api/push/vapid-public-key)?**
   - What we know: Build-time injection is simpler. Runtime fetch allows key rotation without rebuilds.
   - What's unclear: How often VAPID keys will rotate in practice.
   - **RESOLVED (Plan 05-04):** Runtime fetch via `GET /api/push/vapid-public-key` (unauthenticated). Fetched once by the usePushSubscription hook before subscribe. Enables key rotation without a PWA rebuild.

3. **Reminder deduplication strategy: column flag vs separate table?**
   - What we know: The 1-min cron window approach risks double-firing for events at the window boundary.
   - What's unclear: Whether a `sent_reminders` table is overkill for a two-person household.
   - **RESOLVED (Plan 05-06):** In-memory `Set<${eventUid}:${minuteBucket}>` per process (acceptable for the single-process deployment, per D-12). No `sent_reminders` table. Tradeoff accepted: a process restart loses the dedup set, so a reminder could re-fire once after a restart that coincides with the 2-minute send window — tolerable for a two-person household.

---

## Environment Availability

| Dependency                        | Required By                      | Available          | Version       | Fallback                                                   |
| --------------------------------- | -------------------------------- | ------------------ | ------------- | ---------------------------------------------------------- |
| node-cron                         | Reminder scheduler               | Yes                | ^4.2.1        | — (already installed in apps/api)                          |
| MariaDB                           | push_subscriptions table         | Yes                | 11.x (Unraid) | —                                                          |
| node 22 LTS                       | web-push (VAPID uses Web Crypto) | Yes                | 22.x          | — (web-push requires Node 18+)                             |
| vite-plugin-pwa 1.3.x             | injectManifest strategy          | Yes                | ^1.3.0        | — (already installed in apps/pwa)                          |
| web-push                          | Push dispatch                    | No (not installed) | —             | Must install: `pnpm --filter @familysync/api add web-push` |
| workbox-precaching / workbox-core | Custom SW build                  | No (not installed) | —             | Must install as devDependencies in apps/pwa                |

**Missing dependencies with no fallback:**

- `web-push` in apps/api — blocks all push dispatch
- `workbox-precaching` in apps/pwa — blocks SW injectManifest build

---

## Validation Architecture

### Test Framework

| Property           | Value                                                                     |
| ------------------ | ------------------------------------------------------------------------- |
| Framework          | Vitest ^4.1.8                                                             |
| Config file        | apps/api/vitest.config.ts, apps/pwa/vitest.config.ts                      |
| Quick run command  | `pnpm --filter @familysync/api exec vitest run tests/routes/push.test.ts` |
| Full suite command | `pnpm test` (root, runs API suite)                                        |

### Phase Requirements → Test Map

| Req ID   | Behavior                                                   | Test Type                      | Automated Command                                                                                | File Exists? |
| -------- | ---------------------------------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------ | ------------ |
| NOTIF-01 | Reminder fires for shared timed events ~15min before start | unit (reminderScheduler)       | `pnpm --filter @familysync/api exec vitest run tests/broker/reminderScheduler.test.ts`           | No — Wave 0  |
| NOTIF-01 | All-day events do not get reminders (D-07)                 | unit                           | same file                                                                                        | No — Wave 0  |
| NOTIF-01 | Non-shared events do not get reminders (D-05)              | unit                           | same file                                                                                        | No — Wave 0  |
| NOTIF-02 | List-change push fires for other member                    | unit (pushCoalescer)           | `pnpm --filter @familysync/api exec vitest run tests/lib/pushCoalescer.test.ts`                  | No — Wave 0  |
| NOTIF-02 | Coalescing collapses burst into one push (D-01)            | unit                           | same file                                                                                        | No — Wave 0  |
| NOTIF-02 | Reorder changes do not push (D-01)                         | unit (lists route)             | existing `tests/routes/lists.test.ts` — extend                                                   | Partial      |
| NOTIF-03 | Event-change dispatch on new/updated event (NOTIF-03)      | unit (eventChangeDispatcher)   | `pnpm --filter @familysync/api exec vitest run tests/lib/eventChangeDispatcher.test.ts`          | No — Wave 0  |
| NOTIF-03 | Description-only change does NOT push (D-04)               | unit                           | same file                                                                                        | No — Wave 0  |
| D-11     | Push subscription POST/DELETE API                          | integration                    | `pnpm --filter @familysync/api exec vitest run tests/routes/push.test.ts`                        | No — Wave 0  |
| D-11     | 410/404 from push service prunes subscription              | unit (pushDispatcher)          | `pnpm --filter @familysync/api exec vitest run tests/lib/pushDispatcher.test.ts`                 | No — Wave 0  |
| D-08     | Permission prompt renders after install                    | PWA component (playwright-cli) | `playwright-cli evaluate "document.querySelector('[aria-label=\"Enable push notifications\"]')"` | No — Wave 0  |

### Sampling Rate

- **Per task commit:** `pnpm --filter @familysync/api exec vitest run` (unit tests; skip integration DB tests)
- **Per wave merge:** `pnpm test` (full API suite) + playwright-cli smoke on permission prompt
- **Phase gate:** Full suite green + human verify on iOS device (push delivery, Home Screen required)

### Wave 0 Gaps

- [ ] `tests/broker/reminderScheduler.test.ts` — NOTIF-01 unit tests
- [ ] `tests/lib/pushCoalescer.test.ts` — NOTIF-02 coalescing unit tests
- [ ] `tests/lib/pushDispatcher.test.ts` — 410/404 pruning unit tests
- [ ] `tests/lib/eventChangeDispatcher.test.ts` — NOTIF-03 dispatch unit tests
- [ ] `tests/routes/push.test.ts` — subscription POST/DELETE integration tests
- [ ] `test/setup.ts` update — add `push_subscriptions` to afterEach truncation
- [ ] `apps/pwa/src/sw.ts` — custom SW source file (required for injectManifest build)
- [ ] Install: `pnpm --filter @familysync/api add web-push && pnpm --filter @familysync/api add -D @types/web-push`
- [ ] Install: `pnpm --filter @familysync/pwa add -D workbox-precaching workbox-core`

---

## Security Domain

### Applicable ASVS Categories (Level 1)

| ASVS Category         | Applies | Standard Control                                                                                 |
| --------------------- | ------- | ------------------------------------------------------------------------------------------------ |
| V2 Authentication     | yes     | Push routes behind oidcAuthMiddleware; subscription belongs to authenticated user                |
| V3 Session Management | no      | Push subscription is not session state                                                           |
| V4 Access Control     | yes     | Subscription POST/DELETE scoped to c.get('user').id; push fan-out must not cross user boundaries |
| V5 Input Validation   | yes     | zod validation on subscription body (endpoint string, p256dh, auth)                              |
| V6 Cryptography       | yes     | web-push handles VAPID signing; NEVER hand-roll; VAPID_PRIVATE_KEY in env only                   |

### Known Threat Patterns for this Stack

| Pattern                                                                | STRIDE                 | Standard Mitigation                                                                  |
| ---------------------------------------------------------------------- | ---------------------- | ------------------------------------------------------------------------------------ |
| VAPID private key exposure                                             | Information Disclosure | Store in .env; never in code; never in git                                           |
| Unauthorized push subscription (user A subscribes on behalf of user B) | Spoofing               | Subscription POST always uses authenticated userId from OIDC session                 |
| Push to wrong user's subscriptions                                     | Tampering              | fan-out queries filter by userId and list access (same as SSE scope check)           |
| Endpoint enumeration via POST /api/push/subscription                   | Information Disclosure | Endpoint is user-specific; server does not expose other users' endpoints             |
| Malformed subscription body causing crypto crash                       | Denial of Service      | zod validation before DB insert; web-push errors caught per-subscription             |
| VAPID key in Docker image layers                                       | Information Disclosure | Pass VAPID keys as environment variables at runtime (Docker Compose .env or secrets) |

---

## Project Constraints (from CLAUDE.md)

- **MariaDB only** (no PostgreSQL). Drizzle ORM with mysql2 driver. All migrations via `db:generate` + `db:migrate`.
- **`db:push` is UNSAFE** on populated MariaDB — never use it after data exists.
- **Single Node process** — in-memory EventEmitter fan-out is correct; no Redis.
- **Broker-only CalDAV I/O (D-13)** — notification code must not call tsdav.
- **React PWA only** — no native app. Service worker runs in browser.
- **iOS 16.4 minimum** — push requires Home Screen install; pushManager.subscribe() must be in a user gesture.
- **Every push must show a visible notification** — no silent pushes (iOS revokes after ~3).
- **web-push 3.6.7** is the designated VAPID library (CLAUDE.md stack table).
- **node-cron already installed** in apps/api — no new scheduler library needed.
- **playwright-cli** is available at `/usr/local/bin/playwright-cli` for browser-side verification.
- **Hono** is the API framework — push routes follow same pattern as existing routers.
- **`apps/api/src/db/schema.ts`** is the single source of truth for DB schema. New table goes here.

---

## Sources

### Primary (MEDIUM confidence — Context7 from official docs)

- `/web-push-libs/web-push` — VAPID key generation, sendNotification API, error codes (410/404), TypeScript usage
- `/vite-pwa/vite-plugin-pwa` — injectManifest strategy, autoUpdate with custom SW, self.\_\_WB_MANIFEST

### Secondary (MEDIUM confidence — web search + official blog)

- [https://webkit.org/blog/16535/meet-declarative-web-push/](https://webkit.org/blog/16535/meet-declarative-web-push/) — Declarative Web Push payload format, iOS 18.4+ availability
- [https://webkit.org/blog/16574/webkit-features-in-safari-18-4/](https://webkit.org/blog/16574/webkit-features-in-safari-18-4/) — Safari 18.4 feature confirmation

### Codebase (HIGH confidence — direct inspection)

- `apps/api/src/db/schema.ts` — confirmed column list, customType pattern, existing table structure
- `apps/api/src/lib/listEmitter.ts` — confirmed publishListEvent signature and call pattern
- `apps/api/src/broker/poller.ts` + `outboxWorker.ts` — confirmed event change detection hooks
- `apps/pwa/vite.config.ts` — confirmed generateSW mode, denylist, runtimeCaching
- `apps/pwa/src/components/InstallPrompt.tsx` — confirmed isInstalled(), WalkthroughSheet pattern
- `apps/api/package.json` / `apps/pwa/package.json` — confirmed web-push and workbox NOT installed
- `apps/api/drizzle.config.ts` + migration journal — confirmed db:generate+migrate workflow

## Metadata

**Confidence breakdown:**

- Standard stack (web-push, workbox): MEDIUM — confirmed via npm registry + Context7 from GitHub README
- Architecture: HIGH — based on direct codebase inspection; patterns derived from existing workers
- Pitfalls: HIGH (iOS) — confirmed in CLAUDE.md and STATE.md; HIGH (DB migration) — confirmed in memory note; MEDIUM (others) — based on library docs
- Service worker migration: MEDIUM — Context7 docs; runtime behavior on iOS needs human verification

**Research date:** 2026-06-09
**Valid until:** 2026-07-09 (30 days — libraries are stable)
