diff --git a/apps/web/public/sw.js b/apps/web/public/sw.js index 79a4884..16ce9ad 100644 --- a/apps/web/public/sw.js +++ b/apps/web/public/sw.js @@ -1,101 +1,130 @@ /** * ThreatCrush Service Worker (PRD 14) - * Provides offline shell caching and runtime API caching. + * Provides an installable offline shell, scoped runtime caching for dashboard + * reads, push notification display, and cache invalidation hooks on logout/org + * switch so cached data does not bleed between users or organizations. */ -const CACHE_NAME = 'tc-v1'; -const STATIC_CACHE = 'tc-static-v1'; -const DATA_CACHE = 'tc-data-v1'; // runtime cache for API responses - -// App shell files to precache -const APP_SHELL = [ - '/', - '/manifest.json', +const STATIC_CACHE = 'tc-static-v2'; +const DATA_CACHE_PREFIX = 'tc-data-v2'; +const APP_SHELL = ['/', '/manifest.json']; +const CACHE_PREFIXES = ['tc-static-', 'tc-data-']; + +let dataCacheName = `${DATA_CACHE_PREFIX}-anonymous`; + +const DASHBOARD_READ_ROUTES = [ + /^\/api\/orgs\/[^/]+$/, + /^\/api\/orgs\/[^/]+\/detections\/?$/, + /^\/api\/orgs\/[^/]+\/remediations\/?$/, + /^\/api\/orgs\/[^/]+\/servers\/?$/, + /^\/api\/orgs\/[^/]+\/servers\/[^/]+\/?$/, + /^\/api\/orgs\/[^/]+\/servers\/[^/]+\/detections\/?$/, + /^\/api\/orgs\/[^/]+\/servers\/[^/]+\/findings\/?$/, + /^\/api\/orgs\/[^/]+\/properties\/?$/, + /^\/api\/orgs\/[^/]+\/properties\/[^/]+\/?$/, ]; -// Install: precache app shell -self.addEventListener('install', (event) => { - event.waitUntil( - caches.open(STATIC_CACHE).then((cache) => { - return cache.addAll(APP_SHELL); - }).then(() => self.skipWaiting()) +function isDashboardRead(pathname) { + return DASHBOARD_READ_ROUTES.some((pattern) => pattern.test(pathname)); +} + +function scopedCacheName(scope) { + const safeScope = String(scope || 'anonymous').replace(/[^a-zA-Z0-9_-]/g, '-').slice(0, 80); + return `${DATA_CACHE_PREFIX}-${safeScope || 'anonymous'}`; +} + +async function deleteThreatCrushCaches() { + const keys = await caches.keys(); + await Promise.all( + keys + .filter((key) => CACHE_PREFIXES.some((prefix) => key.startsWith(prefix))) + .map((key) => caches.delete(key)), ); +} + +async function precacheShell() { + const cache = await caches.open(STATIC_CACHE); + await cache.addAll(APP_SHELL); +} + +async function staleWhileRevalidate(request) { + const cache = await caches.open(dataCacheName); + const cached = await cache.match(request); + + const fetchPromise = fetch(request) + .then((response) => { + if (response.ok && response.type === 'basic') { + void cache.put(request, response.clone()); + } + return response; + }) + .catch(() => cached); + + return cached || fetchPromise; +} + +self.addEventListener('install', (event) => { + event.waitUntil(precacheShell().then(() => self.skipWaiting())); }); -// Activate: clean old caches self.addEventListener('activate', (event) => { event.waitUntil( - caches.keys().then((keys) => { - return Promise.all( - keys.filter((key) => key !== STATIC_CACHE && key !== DATA_CACHE) - .map((key) => caches.delete(key)) - ); - }).then(() => self.clients.claim()) + caches.keys() + .then((keys) => Promise.all( + keys + .filter((key) => CACHE_PREFIXES.some((prefix) => key.startsWith(prefix)) && key !== STATIC_CACHE && key !== dataCacheName) + .map((key) => caches.delete(key)), + )) + .then(() => self.clients.claim()), ); }); -// Fetch: stale-while-revalidate for API, cache-first for static self.addEventListener('fetch', (event) => { const url = new URL(event.request.url); - // Skip non-GET requests if (event.request.method !== 'GET') return; - - // Skip auth-related requests + if (url.origin !== self.location.origin) return; if (url.pathname.startsWith('/api/auth/')) return; - // API responses: stale-while-revalidate + // Runtime stale-while-revalidate for overview/detection/finding/remediation + // reads only. Auth and non-dashboard APIs stay network-only to reduce the risk + // of cross-user data leaks. if (url.pathname.startsWith('/api/')) { - event.respondWith( - caches.open(DATA_CACHE).then(async (cache) => { - const cached = await cache.match(event.request); - const fetchPromise = fetch(event.request).then((response) => { - if (response.ok) { - cache.put(event.request, response.clone()); - } - return response; - }).catch(() => cached); - - return cached || fetchPromise; - }) - ); + if (!isDashboardRead(url.pathname)) return; + event.respondWith(staleWhileRevalidate(event.request)); return; } - // Static assets and pages: cache-first if (url.pathname.startsWith('/_next/') || url.pathname.startsWith('/icons/')) { event.respondWith( - caches.match(event.request).then((cached) => { - return cached || fetch(event.request).then((response) => { - if (response.ok) { + caches.match(event.request).then((cached) => ( + cached || fetch(event.request).then((response) => { + if (response.ok && response.type === 'basic') { const clone = response.clone(); - caches.open(STATIC_CACHE).then((cache) => cache.put(event.request, clone)); + void caches.open(STATIC_CACHE).then((cache) => cache.put(event.request, clone)); } return response; - }); - }) + }) + )), ); return; } - // Navigation: network-first with offline fallback if (event.request.mode === 'navigate') { event.respondWith( - fetch(event.request).then((response) => { - const clone = response.clone(); - caches.open(STATIC_CACHE).then((cache) => cache.put(event.request, clone)); - return response; - }).catch(() => { - return caches.match(event.request).then((cached) => { - return cached || caches.match('/'); - }); - }) + fetch(event.request) + .then((response) => { + if (response.ok && response.type === 'basic') { + const clone = response.clone(); + void caches.open(STATIC_CACHE).then((cache) => cache.put(event.request, clone)); + } + return response; + }) + .catch(() => caches.match(event.request).then((cached) => cached || caches.match('/'))), ); - return; } }); -// Push notifications self.addEventListener('push', (event) => { if (!event.data) return; @@ -119,11 +148,10 @@ self.addEventListener('push', (event) => { }; event.waitUntil( - self.registration.showNotification(data.title || 'ThreatCrush', options) + self.registration.showNotification(data.title || 'ThreatCrush', options), ); }); -// Notification click self.addEventListener('notificationclick', (event) => { event.notification.close(); @@ -138,23 +166,19 @@ self.addEventListener('notificationclick', (event) => { } } return self.clients.openWindow(url); - }) + }), ); }); -// Message handler for cache invalidation on logout/org switch self.addEventListener('message', (event) => { - // Only accept messages from same origin if (event.origin && event.origin !== self.location.origin) return; + + if (event.data?.type === 'SET_CACHE_SCOPE') { + dataCacheName = scopedCacheName(event.data.scope); + return; + } + if (event.data?.type === 'CLEAR_CACHES') { - event.waitUntil( - Promise.all([ - caches.delete(DATA_CACHE), - caches.delete(STATIC_CACHE), - ]).then(() => { - // Re-cache app shell - return caches.open(STATIC_CACHE).then((cache) => cache.addAll(APP_SHELL)); - }) - ); + event.waitUntil(deleteThreatCrushCaches().then(precacheShell)); } }); diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index e6f0b2f..7c9144a 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -3,6 +3,7 @@ import Script from "next/script"; import "./globals.css"; import SiteHeader from "@/components/SiteHeader"; import SiteFooter from "@/components/SiteFooter"; +import PwaLifecycle from "@/components/PwaLifecycle"; import { AuthProvider } from "@/lib/auth-context"; const SITE_URL = @@ -219,6 +220,7 @@ export default function RootLayout({