From a31a6577622a63db46c4daa7753e501c0c9747d2 Mon Sep 17 00:00:00 2001 From: Qyra-One Date: Sun, 28 Jun 2026 04:38:05 -0400 Subject: [PATCH] feat(web): register PWA lifecycle and scoped caches --- apps/web/public/sw.js | 170 ++++++++++-------- apps/web/src/app/layout.tsx | 2 + apps/web/src/components/PwaLifecycle.tsx | 42 +++++ apps/web/src/lib/__tests__/pwa-client.test.ts | 41 +++++ apps/web/src/lib/pwa-client.ts | 95 ++++++++++ 5 files changed, 277 insertions(+), 73 deletions(-) create mode 100644 apps/web/src/components/PwaLifecycle.tsx create mode 100644 apps/web/src/lib/__tests__/pwa-client.test.ts create mode 100644 apps/web/src/lib/pwa-client.ts 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({ + {children} diff --git a/apps/web/src/components/PwaLifecycle.tsx b/apps/web/src/components/PwaLifecycle.tsx new file mode 100644 index 0000000..1404d0f --- /dev/null +++ b/apps/web/src/components/PwaLifecycle.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { useAuth } from "@/lib/auth-context"; +import { + clearThreatCrushServiceWorkerCaches, + registerThreatCrushServiceWorker, + setThreatCrushServiceWorkerCacheScope, +} from "@/lib/pwa-client"; + +export default function PwaLifecycle() { + const { currentOrgId, loading, signedIn } = useAuth(); + const previousOrgId = useRef(undefined); + const previousSignedIn = useRef(undefined); + + useEffect(() => { + void registerThreatCrushServiceWorker(); + }, []); + + useEffect(() => { + if (loading) return; + + const orgChanged = + previousOrgId.current !== undefined && previousOrgId.current !== currentOrgId; + const signedOut = previousSignedIn.current === true && !signedIn; + + if (signedOut) { + void clearThreatCrushServiceWorkerCaches("logout"); + } else if (orgChanged) { + void clearThreatCrushServiceWorkerCaches("org-switch"); + } + + if (signedIn) { + void setThreatCrushServiceWorkerCacheScope(currentOrgId || "no-org"); + } + + previousOrgId.current = currentOrgId; + previousSignedIn.current = signedIn; + }, [currentOrgId, loading, signedIn]); + + return null; +} diff --git a/apps/web/src/lib/__tests__/pwa-client.test.ts b/apps/web/src/lib/__tests__/pwa-client.test.ts new file mode 100644 index 0000000..f32715a --- /dev/null +++ b/apps/web/src/lib/__tests__/pwa-client.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { + buildPwaCacheMessage, + clearThreatCrushServiceWorkerCaches, + isSecureServiceWorkerContext, +} from "../pwa-client"; + +describe("pwa-client", () => { + it("allows service worker registration only in secure/local browser contexts", () => { + expect(isSecureServiceWorkerContext({ protocol: "https:", hostname: "threatcrush.com" } as Location)).toBe(true); + expect(isSecureServiceWorkerContext({ protocol: "http:", hostname: "localhost" } as Location)).toBe(true); + expect(isSecureServiceWorkerContext({ protocol: "http:", hostname: "127.0.0.1" } as Location)).toBe(true); + expect(isSecureServiceWorkerContext({ protocol: "http:", hostname: "example.com" } as Location)).toBe(false); + }); + + it("builds typed cache messages for the service worker", () => { + expect(buildPwaCacheMessage("CLEAR_CACHES", { reason: "logout" })).toEqual({ + type: "CLEAR_CACHES", + reason: "logout", + }); + expect(buildPwaCacheMessage("SET_CACHE_SCOPE", { scope: "org_123" })).toEqual({ + type: "SET_CACHE_SCOPE", + scope: "org_123", + }); + }); + + it("directly clears ThreatCrush cache namespaces when no controller is active", async () => { + const deleted: string[] = []; + const cacheStorage = { + keys: async () => ["tc-static-v1", "tc-data-v2-org_123", "unrelated-cache"], + delete: async (key: string) => { + deleted.push(key); + return true; + }, + } as unknown as CacheStorage; + + await clearThreatCrushServiceWorkerCaches("logout", cacheStorage); + + expect(deleted.sort()).toEqual(["tc-data-v2-org_123", "tc-static-v1"]); + }); +}); diff --git a/apps/web/src/lib/pwa-client.ts b/apps/web/src/lib/pwa-client.ts new file mode 100644 index 0000000..df4eef6 --- /dev/null +++ b/apps/web/src/lib/pwa-client.ts @@ -0,0 +1,95 @@ +const SERVICE_WORKER_PATH = "/sw.js"; +const CACHE_PREFIXES = ["tc-static-", "tc-data-"]; + +export type ServiceWorkerBridge = Pick< + ServiceWorkerContainer, + "controller" | "getRegistrations" | "register" +>; + +export interface PwaCacheMessage { + type: "CLEAR_CACHES" | "SET_CACHE_SCOPE"; + reason?: "logout" | "org-switch" | "startup"; + scope?: string; +} + +export function isSecureServiceWorkerContext(location: Pick) { + return ( + location.protocol === "https:" || + location.hostname === "localhost" || + location.hostname === "127.0.0.1" || + location.hostname === "::1" + ); +} + +export function buildPwaCacheMessage( + type: PwaCacheMessage["type"], + options: Omit = {}, +): PwaCacheMessage { + return { type, ...options }; +} + +function getServiceWorkerBridge(): ServiceWorkerBridge | null { + if (typeof window === "undefined") return null; + if (!("serviceWorker" in navigator)) return null; + if (!isSecureServiceWorkerContext(window.location)) return null; + return navigator.serviceWorker; +} + +function getCacheStorage(): CacheStorage | null { + if (typeof window === "undefined") return null; + return "caches" in window ? window.caches : null; +} + +function postToRegistration( + registration: Pick, + message: PwaCacheMessage, +) { + registration.active?.postMessage(message); + registration.waiting?.postMessage(message); + registration.installing?.postMessage(message); +} + +export async function registerThreatCrushServiceWorker( + bridge: ServiceWorkerBridge | null = getServiceWorkerBridge(), +) { + if (!bridge) return null; + return bridge.register(SERVICE_WORKER_PATH); +} + +export async function postThreatCrushServiceWorkerMessage( + message: PwaCacheMessage, + bridge: ServiceWorkerBridge | null = getServiceWorkerBridge(), +) { + if (!bridge) return; + + bridge.controller?.postMessage(message); + + const registrations = await bridge.getRegistrations(); + for (const registration of registrations) { + postToRegistration(registration, message); + } +} + +export async function setThreatCrushServiceWorkerCacheScope(scope: string | null) { + const normalizedScope = scope || "anonymous"; + await postThreatCrushServiceWorkerMessage( + buildPwaCacheMessage("SET_CACHE_SCOPE", { scope: normalizedScope }), + ); +} + +export async function clearThreatCrushServiceWorkerCaches( + reason: PwaCacheMessage["reason"], + cacheStorage: CacheStorage | null = getCacheStorage(), +) { + await postThreatCrushServiceWorkerMessage(buildPwaCacheMessage("CLEAR_CACHES", { reason })); + + // Do a best-effort direct clear from the client as well. This covers the first + // install/update window before a service worker controls the current page. + if (!cacheStorage) return; + const keys = await cacheStorage.keys(); + await Promise.all( + keys + .filter((key) => CACHE_PREFIXES.some((prefix) => key.startsWith(prefix))) + .map((key) => cacheStorage.delete(key)), + ); +}