Skip to content

Commit cf613a4

Browse files
authored
feat(web): register PWA lifecycle and scoped caches (#37)
1 parent 84646c0 commit cf613a4

5 files changed

Lines changed: 277 additions & 73 deletions

File tree

apps/web/public/sw.js

Lines changed: 97 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,101 +1,130 @@
11
/**
22
* ThreatCrush Service Worker (PRD 14)
3-
* Provides offline shell caching and runtime API caching.
3+
* Provides an installable offline shell, scoped runtime caching for dashboard
4+
* reads, push notification display, and cache invalidation hooks on logout/org
5+
* switch so cached data does not bleed between users or organizations.
46
*/
57

6-
const CACHE_NAME = 'tc-v1';
7-
const STATIC_CACHE = 'tc-static-v1';
8-
const DATA_CACHE = 'tc-data-v1'; // runtime cache for API responses
9-
10-
// App shell files to precache
11-
const APP_SHELL = [
12-
'/',
13-
'/manifest.json',
8+
const STATIC_CACHE = 'tc-static-v2';
9+
const DATA_CACHE_PREFIX = 'tc-data-v2';
10+
const APP_SHELL = ['/', '/manifest.json'];
11+
const CACHE_PREFIXES = ['tc-static-', 'tc-data-'];
12+
13+
let dataCacheName = `${DATA_CACHE_PREFIX}-anonymous`;
14+
15+
const DASHBOARD_READ_ROUTES = [
16+
/^\/api\/orgs\/[^/]+$/,
17+
/^\/api\/orgs\/[^/]+\/detections\/?$/,
18+
/^\/api\/orgs\/[^/]+\/remediations\/?$/,
19+
/^\/api\/orgs\/[^/]+\/servers\/?$/,
20+
/^\/api\/orgs\/[^/]+\/servers\/[^/]+\/?$/,
21+
/^\/api\/orgs\/[^/]+\/servers\/[^/]+\/detections\/?$/,
22+
/^\/api\/orgs\/[^/]+\/servers\/[^/]+\/findings\/?$/,
23+
/^\/api\/orgs\/[^/]+\/properties\/?$/,
24+
/^\/api\/orgs\/[^/]+\/properties\/[^/]+\/?$/,
1425
];
1526

16-
// Install: precache app shell
17-
self.addEventListener('install', (event) => {
18-
event.waitUntil(
19-
caches.open(STATIC_CACHE).then((cache) => {
20-
return cache.addAll(APP_SHELL);
21-
}).then(() => self.skipWaiting())
27+
function isDashboardRead(pathname) {
28+
return DASHBOARD_READ_ROUTES.some((pattern) => pattern.test(pathname));
29+
}
30+
31+
function scopedCacheName(scope) {
32+
const safeScope = String(scope || 'anonymous').replace(/[^a-zA-Z0-9_-]/g, '-').slice(0, 80);
33+
return `${DATA_CACHE_PREFIX}-${safeScope || 'anonymous'}`;
34+
}
35+
36+
async function deleteThreatCrushCaches() {
37+
const keys = await caches.keys();
38+
await Promise.all(
39+
keys
40+
.filter((key) => CACHE_PREFIXES.some((prefix) => key.startsWith(prefix)))
41+
.map((key) => caches.delete(key)),
2242
);
43+
}
44+
45+
async function precacheShell() {
46+
const cache = await caches.open(STATIC_CACHE);
47+
await cache.addAll(APP_SHELL);
48+
}
49+
50+
async function staleWhileRevalidate(request) {
51+
const cache = await caches.open(dataCacheName);
52+
const cached = await cache.match(request);
53+
54+
const fetchPromise = fetch(request)
55+
.then((response) => {
56+
if (response.ok && response.type === 'basic') {
57+
void cache.put(request, response.clone());
58+
}
59+
return response;
60+
})
61+
.catch(() => cached);
62+
63+
return cached || fetchPromise;
64+
}
65+
66+
self.addEventListener('install', (event) => {
67+
event.waitUntil(precacheShell().then(() => self.skipWaiting()));
2368
});
2469

25-
// Activate: clean old caches
2670
self.addEventListener('activate', (event) => {
2771
event.waitUntil(
28-
caches.keys().then((keys) => {
29-
return Promise.all(
30-
keys.filter((key) => key !== STATIC_CACHE && key !== DATA_CACHE)
31-
.map((key) => caches.delete(key))
32-
);
33-
}).then(() => self.clients.claim())
72+
caches.keys()
73+
.then((keys) => Promise.all(
74+
keys
75+
.filter((key) => CACHE_PREFIXES.some((prefix) => key.startsWith(prefix)) && key !== STATIC_CACHE && key !== dataCacheName)
76+
.map((key) => caches.delete(key)),
77+
))
78+
.then(() => self.clients.claim()),
3479
);
3580
});
3681

37-
// Fetch: stale-while-revalidate for API, cache-first for static
3882
self.addEventListener('fetch', (event) => {
3983
const url = new URL(event.request.url);
4084

41-
// Skip non-GET requests
4285
if (event.request.method !== 'GET') return;
43-
44-
// Skip auth-related requests
86+
if (url.origin !== self.location.origin) return;
4587
if (url.pathname.startsWith('/api/auth/')) return;
4688

47-
// API responses: stale-while-revalidate
89+
// Runtime stale-while-revalidate for overview/detection/finding/remediation
90+
// reads only. Auth and non-dashboard APIs stay network-only to reduce the risk
91+
// of cross-user data leaks.
4892
if (url.pathname.startsWith('/api/')) {
49-
event.respondWith(
50-
caches.open(DATA_CACHE).then(async (cache) => {
51-
const cached = await cache.match(event.request);
52-
const fetchPromise = fetch(event.request).then((response) => {
53-
if (response.ok) {
54-
cache.put(event.request, response.clone());
55-
}
56-
return response;
57-
}).catch(() => cached);
58-
59-
return cached || fetchPromise;
60-
})
61-
);
93+
if (!isDashboardRead(url.pathname)) return;
94+
event.respondWith(staleWhileRevalidate(event.request));
6295
return;
6396
}
6497

65-
// Static assets and pages: cache-first
6698
if (url.pathname.startsWith('/_next/') || url.pathname.startsWith('/icons/')) {
6799
event.respondWith(
68-
caches.match(event.request).then((cached) => {
69-
return cached || fetch(event.request).then((response) => {
70-
if (response.ok) {
100+
caches.match(event.request).then((cached) => (
101+
cached || fetch(event.request).then((response) => {
102+
if (response.ok && response.type === 'basic') {
71103
const clone = response.clone();
72-
caches.open(STATIC_CACHE).then((cache) => cache.put(event.request, clone));
104+
void caches.open(STATIC_CACHE).then((cache) => cache.put(event.request, clone));
73105
}
74106
return response;
75-
});
76-
})
107+
})
108+
)),
77109
);
78110
return;
79111
}
80112

81-
// Navigation: network-first with offline fallback
82113
if (event.request.mode === 'navigate') {
83114
event.respondWith(
84-
fetch(event.request).then((response) => {
85-
const clone = response.clone();
86-
caches.open(STATIC_CACHE).then((cache) => cache.put(event.request, clone));
87-
return response;
88-
}).catch(() => {
89-
return caches.match(event.request).then((cached) => {
90-
return cached || caches.match('/');
91-
});
92-
})
115+
fetch(event.request)
116+
.then((response) => {
117+
if (response.ok && response.type === 'basic') {
118+
const clone = response.clone();
119+
void caches.open(STATIC_CACHE).then((cache) => cache.put(event.request, clone));
120+
}
121+
return response;
122+
})
123+
.catch(() => caches.match(event.request).then((cached) => cached || caches.match('/'))),
93124
);
94-
return;
95125
}
96126
});
97127

98-
// Push notifications
99128
self.addEventListener('push', (event) => {
100129
if (!event.data) return;
101130

@@ -119,11 +148,10 @@ self.addEventListener('push', (event) => {
119148
};
120149

121150
event.waitUntil(
122-
self.registration.showNotification(data.title || 'ThreatCrush', options)
151+
self.registration.showNotification(data.title || 'ThreatCrush', options),
123152
);
124153
});
125154

126-
// Notification click
127155
self.addEventListener('notificationclick', (event) => {
128156
event.notification.close();
129157

@@ -138,23 +166,19 @@ self.addEventListener('notificationclick', (event) => {
138166
}
139167
}
140168
return self.clients.openWindow(url);
141-
})
169+
}),
142170
);
143171
});
144172

145-
// Message handler for cache invalidation on logout/org switch
146173
self.addEventListener('message', (event) => {
147-
// Only accept messages from same origin
148174
if (event.origin && event.origin !== self.location.origin) return;
175+
176+
if (event.data?.type === 'SET_CACHE_SCOPE') {
177+
dataCacheName = scopedCacheName(event.data.scope);
178+
return;
179+
}
180+
149181
if (event.data?.type === 'CLEAR_CACHES') {
150-
event.waitUntil(
151-
Promise.all([
152-
caches.delete(DATA_CACHE),
153-
caches.delete(STATIC_CACHE),
154-
]).then(() => {
155-
// Re-cache app shell
156-
return caches.open(STATIC_CACHE).then((cache) => cache.addAll(APP_SHELL));
157-
})
158-
);
182+
event.waitUntil(deleteThreatCrushCaches().then(precacheShell));
159183
}
160184
});

apps/web/src/app/layout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import Script from "next/script";
33
import "./globals.css";
44
import SiteHeader from "@/components/SiteHeader";
55
import SiteFooter from "@/components/SiteFooter";
6+
import PwaLifecycle from "@/components/PwaLifecycle";
67
import { AuthProvider } from "@/lib/auth-context";
78

89
const SITE_URL =
@@ -219,6 +220,7 @@ export default function RootLayout({
219220
</head>
220221
<body className="antialiased">
221222
<AuthProvider>
223+
<PwaLifecycle />
222224
<SiteHeader />
223225
{children}
224226
<SiteFooter />
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"use client";
2+
3+
import { useEffect, useRef } from "react";
4+
import { useAuth } from "@/lib/auth-context";
5+
import {
6+
clearThreatCrushServiceWorkerCaches,
7+
registerThreatCrushServiceWorker,
8+
setThreatCrushServiceWorkerCacheScope,
9+
} from "@/lib/pwa-client";
10+
11+
export default function PwaLifecycle() {
12+
const { currentOrgId, loading, signedIn } = useAuth();
13+
const previousOrgId = useRef<string | null | undefined>(undefined);
14+
const previousSignedIn = useRef<boolean | undefined>(undefined);
15+
16+
useEffect(() => {
17+
void registerThreatCrushServiceWorker();
18+
}, []);
19+
20+
useEffect(() => {
21+
if (loading) return;
22+
23+
const orgChanged =
24+
previousOrgId.current !== undefined && previousOrgId.current !== currentOrgId;
25+
const signedOut = previousSignedIn.current === true && !signedIn;
26+
27+
if (signedOut) {
28+
void clearThreatCrushServiceWorkerCaches("logout");
29+
} else if (orgChanged) {
30+
void clearThreatCrushServiceWorkerCaches("org-switch");
31+
}
32+
33+
if (signedIn) {
34+
void setThreatCrushServiceWorkerCacheScope(currentOrgId || "no-org");
35+
}
36+
37+
previousOrgId.current = currentOrgId;
38+
previousSignedIn.current = signedIn;
39+
}, [currentOrgId, loading, signedIn]);
40+
41+
return null;
42+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
buildPwaCacheMessage,
4+
clearThreatCrushServiceWorkerCaches,
5+
isSecureServiceWorkerContext,
6+
} from "../pwa-client";
7+
8+
describe("pwa-client", () => {
9+
it("allows service worker registration only in secure/local browser contexts", () => {
10+
expect(isSecureServiceWorkerContext({ protocol: "https:", hostname: "threatcrush.com" } as Location)).toBe(true);
11+
expect(isSecureServiceWorkerContext({ protocol: "http:", hostname: "localhost" } as Location)).toBe(true);
12+
expect(isSecureServiceWorkerContext({ protocol: "http:", hostname: "127.0.0.1" } as Location)).toBe(true);
13+
expect(isSecureServiceWorkerContext({ protocol: "http:", hostname: "example.com" } as Location)).toBe(false);
14+
});
15+
16+
it("builds typed cache messages for the service worker", () => {
17+
expect(buildPwaCacheMessage("CLEAR_CACHES", { reason: "logout" })).toEqual({
18+
type: "CLEAR_CACHES",
19+
reason: "logout",
20+
});
21+
expect(buildPwaCacheMessage("SET_CACHE_SCOPE", { scope: "org_123" })).toEqual({
22+
type: "SET_CACHE_SCOPE",
23+
scope: "org_123",
24+
});
25+
});
26+
27+
it("directly clears ThreatCrush cache namespaces when no controller is active", async () => {
28+
const deleted: string[] = [];
29+
const cacheStorage = {
30+
keys: async () => ["tc-static-v1", "tc-data-v2-org_123", "unrelated-cache"],
31+
delete: async (key: string) => {
32+
deleted.push(key);
33+
return true;
34+
},
35+
} as unknown as CacheStorage;
36+
37+
await clearThreatCrushServiceWorkerCaches("logout", cacheStorage);
38+
39+
expect(deleted.sort()).toEqual(["tc-data-v2-org_123", "tc-static-v1"]);
40+
});
41+
});

0 commit comments

Comments
 (0)