Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 97 additions & 73 deletions apps/web/public/sw.js
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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();

Expand All @@ -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));
}
});
2 changes: 2 additions & 0 deletions apps/web/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -219,6 +220,7 @@ export default function RootLayout({
</head>
<body className="antialiased">
<AuthProvider>
<PwaLifecycle />
<SiteHeader />
{children}
<SiteFooter />
Expand Down
42 changes: 42 additions & 0 deletions apps/web/src/components/PwaLifecycle.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null | undefined>(undefined);
const previousSignedIn = useRef<boolean | undefined>(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;
}
41 changes: 41 additions & 0 deletions apps/web/src/lib/__tests__/pwa-client.test.ts
Original file line number Diff line number Diff line change
@@ -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"]);
});
});
Loading
Loading