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+ / ^ \/ a p i \/ o r g s \/ [ ^ / ] + $ / ,
17+ / ^ \/ a p i \/ o r g s \/ [ ^ / ] + \/ d e t e c t i o n s \/ ? $ / ,
18+ / ^ \/ a p i \/ o r g s \/ [ ^ / ] + \/ r e m e d i a t i o n s \/ ? $ / ,
19+ / ^ \/ a p i \/ o r g s \/ [ ^ / ] + \/ s e r v e r s \/ ? $ / ,
20+ / ^ \/ a p i \/ o r g s \/ [ ^ / ] + \/ s e r v e r s \/ [ ^ / ] + \/ ? $ / ,
21+ / ^ \/ a p i \/ o r g s \/ [ ^ / ] + \/ s e r v e r s \/ [ ^ / ] + \/ d e t e c t i o n s \/ ? $ / ,
22+ / ^ \/ a p i \/ o r g s \/ [ ^ / ] + \/ s e r v e r s \/ [ ^ / ] + \/ f i n d i n g s \/ ? $ / ,
23+ / ^ \/ a p i \/ o r g s \/ [ ^ / ] + \/ p r o p e r t i e s \/ ? $ / ,
24+ / ^ \/ a p i \/ o r g s \/ [ ^ / ] + \/ p r o p e r t i e s \/ [ ^ / ] + \/ ? $ / ,
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 - z A - Z 0 - 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
2670self . 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
3882self . 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
99128self . 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
127155self . 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
146173self . 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} ) ;
0 commit comments