diff --git a/apps/pwa/src/sw.ts b/apps/pwa/src/sw.ts
new file mode 100644
index 0000000..8fd6daa
--- /dev/null
+++ b/apps/pwa/src/sw.ts
@@ -0,0 +1,164 @@
+/**
+ * FamilySync Service Worker — injectManifest custom SW.
+ *
+ * Responsibilities:
+ * 1. Precache the app shell (Workbox injects self.__WB_MANIFEST at build time).
+ * 2. Reproduce autoUpdate behavior: skipWaiting + clientsClaim.
+ * 3. Navigation denylist (T-03-20): /callback, /api/*, /health must never be
+ * intercepted — they must reach the server. Re-implemented from the former
+ * vite.config.ts workbox.navigateFallbackDenylist.
+ * 4. Push handler: ALWAYS shows a visible notification (D-11 / iOS anti-pattern
+ * mitigation). iOS revokes subscriptions after ~3 silent pushes. Never swallow
+ * a push silently — even malformed payloads get a generic fallback notification.
+ * 5. notificationclick handler: deep-link to the URL in notification.data.url.
+ * Focuses an existing window at that URL or opens a new one (D-14).
+ *
+ * Payload format (from pushDispatcher.ts buildPushBody):
+ * iOS 18.4+ declarative: { web_push: 8030, notification: { title, body, navigate } }
+ * Legacy (iOS 16.4–18.3 + Android): { title, body, tag, data: { url } }
+ *
+ * Security (T-05-11, T-05-12):
+ * - NavigationRoute denylist prevents the OIDC /callback from being served from cache.
+ * - Try/catch on push payload parse falls back to generic notification — prevents
+ * a malformed payload from silently dropping a push (which would revoke iOS subs).
+ */
+
+///
+
+import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching'
+import { clientsClaim } from 'workbox-core'
+import { NavigationRoute, registerRoute } from 'workbox-routing'
+
+declare const self: ServiceWorkerGlobalScope
+
+// ---------------------------------------------------------------------------
+// AutoUpdate behavior: replace old SW immediately on install/activate.
+// Equivalent to the former generateSW autoUpdate: 'prompt' → 'autoUpdate' path.
+// ---------------------------------------------------------------------------
+self.skipWaiting()
+clientsClaim()
+
+// ---------------------------------------------------------------------------
+// Precache the app shell.
+// self.__WB_MANIFEST is injected by vite-plugin-pwa injectManifest at build time.
+// At dev time this is an empty array; the real manifest is injected in production.
+// ---------------------------------------------------------------------------
+precacheAndRoute(self.__WB_MANIFEST)
+
+// ---------------------------------------------------------------------------
+// Navigation denylist (T-03-20 — CRITICAL).
+//
+// Routes that must NEVER be intercepted by the SW navigation handler:
+// /callback — OIDC authorization-code exchange; must reach the API server.
+// /api/* — All API calls; SW must not cache or serve these.
+// /health — Health endpoint; must not be served from cache.
+//
+// Re-implements the former workbox.navigateFallbackDenylist from vite.config.ts.
+// ---------------------------------------------------------------------------
+const navHandler = createHandlerBoundToURL('/index.html')
+registerRoute(
+ new NavigationRoute(navHandler, {
+ denylist: [
+ /^\/callback/, // OIDC redirect — must reach server (T-03-20)
+ /^\/api\//, // API calls — never serve from cache
+ /^\/health/, // Health endpoint
+ ],
+ }),
+)
+
+// ---------------------------------------------------------------------------
+// Push handler (D-11, NOTIF-01/02/03).
+//
+// ALWAYS shows a visible notification. iOS silently revokes subscriptions after
+// ~3 silent pushes — we must NEVER let a push event resolve without displaying
+// a notification. The fallback title/body covers malformed payloads.
+//
+// Payload format (dual-format from buildPushBody):
+// Declarative (iOS 18.4+): { web_push: 8030, notification: { title, body, navigate } }
+// Legacy (iOS 16.4–18.3 + Android): { title, body, tag, data: { url } }
+// ---------------------------------------------------------------------------
+self.addEventListener('push', (event: PushEvent) => {
+ let title = 'FamilySync'
+ let body = 'You have a new notification'
+ let tag = 'familysync-notification'
+ let url = '/'
+
+ try {
+ if (event.data) {
+ const payload = event.data.json() as Record
+
+ // iOS 18.4+ declarative web push format
+ if (
+ payload.web_push === 8030 &&
+ payload.notification &&
+ typeof payload.notification === 'object'
+ ) {
+ const notif = payload.notification as Record
+ if (typeof notif.title === 'string') title = notif.title
+ if (typeof notif.body === 'string') body = notif.body
+ if (typeof notif.navigate === 'string') url = notif.navigate
+ // Use navigate as tag for coalescing duplicate pushes
+ tag = `familysync-${url}`
+ } else {
+ // Legacy format: top-level title/body/tag/data
+ if (typeof payload.title === 'string') title = payload.title
+ if (typeof payload.body === 'string') body = payload.body
+ if (typeof payload.tag === 'string') tag = payload.tag
+ if (
+ payload.data &&
+ typeof payload.data === 'object' &&
+ typeof (payload.data as Record).url === 'string'
+ ) {
+ url = (payload.data as Record).url
+ }
+ }
+ }
+ } catch {
+ // Malformed payload — fall through with default title/body/url.
+ // We still show the notification below (D-11 — never silent).
+ }
+
+ // event.waitUntil() is MANDATORY: keeps the SW alive until the notification
+ // is displayed. Without it the SW may be terminated before showNotification
+ // resolves, resulting in a silent push (iOS subscription death).
+ event.waitUntil(
+ self.registration.showNotification(title, {
+ body,
+ tag,
+ data: { url },
+ }),
+ )
+})
+
+// ---------------------------------------------------------------------------
+// notificationclick handler (D-14 — deep-link on tap).
+//
+// Closes the notification, then:
+// 1. If a window is already open at the target URL, focus it.
+// 2. Otherwise, open a new window at the target URL.
+// ---------------------------------------------------------------------------
+self.addEventListener('notificationclick', (event: NotificationEvent) => {
+ event.notification.close()
+
+ const url: string =
+ typeof event.notification.data?.url === 'string'
+ ? event.notification.data.url
+ : '/'
+
+ event.waitUntil(
+ self.clients
+ .matchAll({ type: 'window', includeUncontrolled: true })
+ .then((clientList) => {
+ // Focus an existing window already at the target URL
+ for (const client of clientList) {
+ if (client.url === url && 'focus' in client) {
+ return (client as WindowClient).focus()
+ }
+ }
+ // No existing window — open a new one
+ if (self.clients.openWindow) {
+ return self.clients.openWindow(url)
+ }
+ }),
+ )
+})
diff --git a/apps/pwa/vite.config.ts b/apps/pwa/vite.config.ts
index 1a53218..0370c10 100644
--- a/apps/pwa/vite.config.ts
+++ b/apps/pwa/vite.config.ts
@@ -7,19 +7,24 @@ export default defineConfig({
react(),
VitePWA({
registerType: 'autoUpdate',
- // ⚠️ CRITICAL: exclude /callback from SW navigation handling (Gate 2 — T-03-20)
- // The OIDC authorization-code exchange lands on /callback — if the SW
- // intercepts this as a navigation, it serves the cached shell instead of
- // letting the server process the authorization code exchange (login loop).
- workbox: {
- navigateFallback: '/index.html',
- navigateFallbackDenylist: [
- /^\/callback/, // OIDC redirect endpoint — must reach the server
- /^\/api\//, // API calls — never serve from cache
- /^\/health/, // Health endpoint
- ],
- // No /api caching — runtimeCaching: [] means all API calls fall through to network
- runtimeCaching: [],
+ // ⚠️ CRITICAL (T-03-20): generateSW migrated to injectManifest so the custom
+ // sw.ts can add push + notificationclick handlers while preserving the /callback
+ // denylist. The denylist is re-implemented explicitly in src/sw.ts.
+ strategies: 'injectManifest',
+ srcDir: 'src',
+ filename: 'sw.ts',
+ // rolldownOptions: force the SW output to IIFE so the output file is sw.js
+ // (not sw.mjs). Vite 8 + vite-plugin-pwa 1.3.x defaults to ES module SW format
+ // when the source is .ts, producing sw.mjs. The registerSW.js generated by
+ // the plugin registers '/sw.js', so the filenames must match.
+ rolldownOptions: {
+ output: {
+ format: 'iife',
+ },
+ },
+ injectManifest: {
+ // Prevent /callback-related URLs from appearing in the precache manifest.
+ globIgnores: ['**/node_modules/**', '**/callback**'],
},
manifest: {
name: 'FamilySync',