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:
@@ -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 (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 |
|
||||
| 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>
|
||||
|
||||
---
|
||||
@@ -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.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/]
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user