Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: externalize inline <script> blocks to satisfy CSP (#694)

Closes #694

scott 348217de 20cfb10e

+173 -271
+1
CHANGELOG.md
··· 8 8 ## [Unreleased] 9 9 10 10 ### Fixed 11 + - CSP: externalized 3 inline scripts from every page template so the app's `script-src 'self'` CSP stops silently blocking them (v0.62.2, #694). Theme init (FOUC prevention, reads localStorage before paint), theme-toggle click handler, and service-worker update→reload handler now live in `public/theme-init.js`, `public/theme-toggle.js`, and `public/sw-reload.js` respectively, loaded via `<script src="...">` so they satisfy the strict CSP without needing nonces or `unsafe-inline`. Before: the theme toggle button did nothing, dark-mode users saw a flash of light theme on every page load, and users never auto-reloaded when a new version deployed. All 7 HTML templates (landing + 6 editors) had the inline blocks; all 7 are now externalized. Added `tests/csp-no-inline-scripts.test.ts` which scans every template for inline `<script>` blocks and fails if any are reintroduced. Caught live by driving the deployed v0.62.1 app via Playwright MCP. (#694) 11 12 - Sheets: first printable keystroke against an empty cell no longer duplicates the character (v0.62.1, #693). The grid's keydown handler for printable chars in `src/sheets/keyboard-handler.ts` entered edit mode and set `editor.value = key` but never called `e.preventDefault()`, so the browser's native keypress/input pipeline on the now-focused cell-editor input also inserted the same character — pressing `5` produced `55`, pressing `1`→`0`→Enter produced `110`. Added the missing `preventDefault()` plus a targeted regression test in `tests/sheets-keyboard-handler.test.ts` (3 new tests: preventDefault invariant, single-char-not-doubled invariant, and the pre-existing Cmd+key-skip invariant). Caught live by driving the deployed app in a real browser via Playwright MCP during TipTap v3 post-ship smoke testing. (#693) 12 13 13 14 ### Changed
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.62.1", 3 + "version": "0.62.2", 4 4 "private": true, 5 5 "type": "module", 6 6 "main": "electron/main.js",
+22
public/sw-reload.js
··· 1 + // Service worker registration + auto-reload on controllerchange. 2 + // When a new SW activates (a new deploy), this triggers a single page reload 3 + // so users get the new version without a manual refresh. 4 + (function () { 5 + if (!('serviceWorker' in navigator)) return; 6 + 7 + var reloading = false; 8 + navigator.serviceWorker.addEventListener('controllerchange', function () { 9 + if (!reloading) { 10 + reloading = true; 11 + location.reload(); 12 + } 13 + }); 14 + 15 + navigator.serviceWorker 16 + .register('/sw.js', { updateViaCache: 'none' }) 17 + .then(function (reg) { 18 + // Force an immediate update check on every page load. 19 + reg.update().catch(function () {}); 20 + }) 21 + .catch(function () {}); 22 + })();
+19
public/theme-init.js
··· 1 + // Theme init — runs before paint to avoid FOUC. 2 + // Must be loaded from <head> as a blocking <script src="/theme-init.js"> 3 + // so that data-theme is set before the body renders. 4 + // Served with long cache headers since the content is stable; bump the URL 5 + // query string (e.g. /theme-init.js?v=2) when editing to bust caches. 6 + (function () { 7 + var saved = localStorage.getItem('tools-theme'); 8 + if (saved === 'dark' || saved === 'light') { 9 + document.documentElement.setAttribute('data-theme', saved); 10 + } 11 + if (window.electronAPI) document.documentElement.classList.add('is-electron'); 12 + // Enable focus rings only when keyboard navigation is detected 13 + document.addEventListener('keydown', function (e) { 14 + if (e.key === 'Tab') document.documentElement.setAttribute('data-a11y-focus', ''); 15 + }); 16 + document.addEventListener('mousedown', function () { 17 + document.documentElement.removeAttribute('data-a11y-focus'); 18 + }); 19 + })();
+34
public/theme-toggle.js
··· 1 + // Theme toggle button wiring. Requires a #theme-toggle element in the page. 2 + // Loaded as <script src="/theme-toggle.js"> at the end of <body> (defer-equivalent 3 + // since the DOM is parsed by then). Only attached when the element exists, so 4 + // pages without a toggle (e.g. calendar) don't crash. 5 + (function () { 6 + var toggle = document.getElementById('theme-toggle'); 7 + if (!toggle) return; 8 + 9 + function getEffectiveTheme() { 10 + var saved = localStorage.getItem('tools-theme'); 11 + if (saved === 'dark' || saved === 'light') return saved; 12 + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; 13 + } 14 + 15 + function updateIcon() { 16 + var theme = getEffectiveTheme(); 17 + toggle.textContent = theme === 'dark' ? '\u263E' : '\u2600'; 18 + toggle.setAttribute('data-tooltip', theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'); 19 + } 20 + 21 + toggle.addEventListener('click', function () { 22 + var current = getEffectiveTheme(); 23 + var next = current === 'dark' ? 'light' : 'dark'; 24 + document.documentElement.setAttribute('data-theme', next); 25 + localStorage.setItem('tools-theme', next); 26 + updateIcon(); 27 + }); 28 + 29 + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function () { 30 + if (!localStorage.getItem('tools-theme')) updateIcon(); 31 + }); 32 + 33 + updateIcon(); 34 + })();
+2 -27
src/calendar/index.html
··· 14 14 <link rel="icon" type="image/svg+xml" href="/favicon.svg"> 15 15 <link rel="apple-touch-icon" href="/favicon.svg"> 16 16 <link rel="stylesheet" href="../css/app.css"> 17 - <script> 18 - (function() { 19 - var saved = localStorage.getItem('tools-theme'); 20 - if (saved === 'dark' || saved === 'light') { 21 - document.documentElement.setAttribute('data-theme', saved); 22 - } 23 - if (window.electronAPI) document.documentElement.classList.add('is-electron'); 24 - document.addEventListener('keydown', function(e) { 25 - if (e.key === 'Tab') document.documentElement.setAttribute('data-a11y-focus', ''); 26 - }); 27 - document.addEventListener('mousedown', function() { 28 - document.documentElement.removeAttribute('data-a11y-focus'); 29 - }); 30 - })(); 31 - </script> 17 + <script src="/theme-init.js"></script> 32 18 </head> 33 19 <body> 34 20 <a class="skip-link" href="#main-content">Skip to content</a> ··· 328 314 329 315 <div class="version-badge">v%APP_VERSION%</div> 330 316 <script type="module" src="./main.ts"></script> 331 - <script> 332 - // Service Worker registration for offline support 333 - if ('serviceWorker' in navigator) { 334 - var reloading = false; 335 - navigator.serviceWorker.addEventListener('controllerchange', function() { 336 - if (!reloading) { reloading = true; location.reload(); } 337 - }); 338 - navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' }).then(function(reg) { 339 - reg.update().catch(function() {}); 340 - }).catch(function() {}); 341 - } 342 - </script> 317 + <script src="/sw-reload.js"></script> 343 318 </body> 344 319 </html>
+3 -27
src/diagrams/index.html
··· 14 14 <link rel="icon" type="image/svg+xml" href="/favicon.svg"> 15 15 <link rel="apple-touch-icon" href="/favicon.svg"> 16 16 <link rel="stylesheet" href="../css/app.css"> 17 - <script> 18 - (function() { 19 - var saved = localStorage.getItem('tools-theme'); 20 - if (saved === 'dark' || saved === 'light') { 21 - document.documentElement.setAttribute('data-theme', saved); 22 - } 23 - if (window.electronAPI) document.documentElement.classList.add('is-electron'); 24 - document.addEventListener('keydown', function(e) { 25 - if (e.key === 'Tab') document.documentElement.setAttribute('data-a11y-focus', ''); 26 - }); 27 - document.addEventListener('mousedown', function() { 28 - document.documentElement.removeAttribute('data-a11y-focus'); 29 - }); 30 - })(); 31 - </script> 17 + <!-- Theme init — runs before paint to avoid FOUC. See #694. --> 18 + <script src="/theme-init.js"></script> 32 19 </head> 33 20 <body> 34 21 <a class="skip-link" href="#main-content">Skip to content</a> ··· 228 215 229 216 <div class="version-badge">v%APP_VERSION%</div> 230 217 <script type="module" src="./main.ts"></script> 231 - <script> 232 - // Service Worker registration for offline support 233 - if ('serviceWorker' in navigator) { 234 - var reloading = false; 235 - navigator.serviceWorker.addEventListener('controllerchange', function() { 236 - if (!reloading) { reloading = true; location.reload(); } 237 - }); 238 - navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' }).then(function(reg) { 239 - reg.update().catch(function() {}); 240 - }).catch(function() {}); 241 - } 242 - </script> 218 + <script src="/sw-reload.js"></script> 243 219 </body> 244 220 </html>
+4 -54
src/docs/index.html
··· 14 14 <link rel="icon" type="image/svg+xml" href="/favicon.svg"> 15 15 <link rel="apple-touch-icon" href="/favicon.svg"> 16 16 <link rel="stylesheet" href="../css/app.css"> 17 - <script> 18 - (function() { 19 - var saved = localStorage.getItem('tools-theme'); 20 - if (saved === 'dark' || saved === 'light') { 21 - document.documentElement.setAttribute('data-theme', saved); 22 - } 23 - if (window.electronAPI) document.documentElement.classList.add('is-electron'); 24 - // Enable focus rings only when keyboard navigation is detected 25 - document.addEventListener('keydown', function(e) { 26 - if (e.key === 'Tab') document.documentElement.setAttribute('data-a11y-focus', ''); 27 - }); 28 - document.addEventListener('mousedown', function() { 29 - document.documentElement.removeAttribute('data-a11y-focus'); 30 - }); 31 - })(); 32 - </script> 17 + <!-- Theme init — runs before paint to avoid FOUC. See #694. --> 18 + <script src="/theme-init.js"></script> 33 19 </head> 34 20 <body> 35 21 <a class="skip-link" href="#main-content">Skip to content</a> ··· 461 447 </div> 462 448 463 449 <script type="module" src="./main.ts"></script> 464 - <script> 465 - // Service Worker registration for offline support 466 - if ('serviceWorker' in navigator) { 467 - var reloading = false; 468 - navigator.serviceWorker.addEventListener('controllerchange', function() { 469 - if (!reloading) { reloading = true; location.reload(); } 470 - }); 471 - navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' }).then(function(reg) { 472 - reg.update().catch(function() {}); 473 - }).catch(function() {}); 474 - } 475 - </script> 476 - <script> 477 - (function() { 478 - var toggle = document.getElementById('theme-toggle'); 479 - function getEffectiveTheme() { 480 - var saved = localStorage.getItem('tools-theme'); 481 - if (saved === 'dark' || saved === 'light') return saved; 482 - return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; 483 - } 484 - function updateIcon() { 485 - var theme = getEffectiveTheme(); 486 - toggle.textContent = theme === 'dark' ? '\u263E' : '\u2600'; 487 - toggle.setAttribute('data-tooltip', theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'); 488 - } 489 - toggle.addEventListener('click', function() { 490 - var current = getEffectiveTheme(); 491 - var next = current === 'dark' ? 'light' : 'dark'; 492 - document.documentElement.setAttribute('data-theme', next); 493 - localStorage.setItem('tools-theme', next); 494 - updateIcon(); 495 - }); 496 - window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function() { 497 - if (!localStorage.getItem('tools-theme')) updateIcon(); 498 - }); 499 - updateIcon(); 500 - })(); 501 - </script> 450 + <script src="/sw-reload.js"></script> 451 + <script src="/theme-toggle.js"></script> 502 452 <div class="version-badge">v%APP_VERSION%</div> 503 453 </body> 504 454 </html>
+3 -27
src/forms/index.html
··· 14 14 <link rel="icon" type="image/svg+xml" href="/favicon.svg"> 15 15 <link rel="apple-touch-icon" href="/favicon.svg"> 16 16 <link rel="stylesheet" href="../css/app.css"> 17 - <script> 18 - (function() { 19 - var saved = localStorage.getItem('tools-theme'); 20 - if (saved === 'dark' || saved === 'light') { 21 - document.documentElement.setAttribute('data-theme', saved); 22 - } 23 - if (window.electronAPI) document.documentElement.classList.add('is-electron'); 24 - document.addEventListener('keydown', function(e) { 25 - if (e.key === 'Tab') document.documentElement.setAttribute('data-a11y-focus', ''); 26 - }); 27 - document.addEventListener('mousedown', function() { 28 - document.documentElement.removeAttribute('data-a11y-focus'); 29 - }); 30 - })(); 31 - </script> 17 + <!-- Theme init — runs before paint to avoid FOUC. See #694. --> 18 + <script src="/theme-init.js"></script> 32 19 </head> 33 20 <body> 34 21 <a class="skip-link" href="#main-content">Skip to content</a> ··· 76 63 <div class="version-badge">v%APP_VERSION%</div> 77 64 78 65 <script type="module" src="./main.ts"></script> 79 - <script> 80 - // Service Worker registration for offline support 81 - if ('serviceWorker' in navigator) { 82 - var reloading = false; 83 - navigator.serviceWorker.addEventListener('controllerchange', function() { 84 - if (!reloading) { reloading = true; location.reload(); } 85 - }); 86 - navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' }).then(function(reg) { 87 - reg.update().catch(function() {}); 88 - }).catch(function() {}); 89 - } 90 - </script> 66 + <script src="/sw-reload.js"></script> 91 67 </body> 92 68 </html>
+4 -56
src/index.html
··· 14 14 <link rel="icon" type="image/svg+xml" href="/favicon.svg"> 15 15 <link rel="apple-touch-icon" href="/favicon.svg"> 16 16 <link rel="stylesheet" href="./css/app.css"> 17 - <script> 18 - // Theme init — runs before paint to avoid FOUC 19 - (function() { 20 - var saved = localStorage.getItem('tools-theme'); 21 - if (saved === 'dark' || saved === 'light') { 22 - document.documentElement.setAttribute('data-theme', saved); 23 - } 24 - if (window.electronAPI) document.documentElement.classList.add('is-electron'); 25 - document.addEventListener('keydown', function(e) { 26 - if (e.key === 'Tab') document.documentElement.setAttribute('data-a11y-focus', ''); 27 - }); 28 - document.addEventListener('mousedown', function() { 29 - document.documentElement.removeAttribute('data-a11y-focus'); 30 - }); 31 - })(); 32 - </script> 17 + <!-- Theme init runs before paint to avoid FOUC; externalized to satisfy CSP (see #694). --> 18 + <script src="/theme-init.js"></script> 33 19 </head> 34 20 <body> 35 21 <a class="skip-link" href="#main-content">Skip to content</a> ··· 207 193 </div> 208 194 209 195 <script type="module" src="./landing.ts"></script> 210 - <script> 211 - // Theme toggle logic 212 - (function() { 213 - var toggle = document.getElementById('theme-toggle'); 214 - function getEffectiveTheme() { 215 - var saved = localStorage.getItem('tools-theme'); 216 - if (saved === 'dark' || saved === 'light') return saved; 217 - return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; 218 - } 219 - function updateIcon() { 220 - var theme = getEffectiveTheme(); 221 - toggle.textContent = theme === 'dark' ? '\u263E' : '\u2600'; 222 - toggle.setAttribute('data-tooltip', theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'); 223 - } 224 - toggle.addEventListener('click', function() { 225 - var current = getEffectiveTheme(); 226 - var next = current === 'dark' ? 'light' : 'dark'; 227 - document.documentElement.setAttribute('data-theme', next); 228 - localStorage.setItem('tools-theme', next); 229 - updateIcon(); 230 - }); 231 - window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function() { 232 - if (!localStorage.getItem('tools-theme')) updateIcon(); 233 - }); 234 - updateIcon(); 235 - })(); 236 - </script> 237 - <script> 238 - if ('serviceWorker' in navigator) { 239 - // Reload when a new SW takes control (covers update from any prior version) 240 - var reloading = false; 241 - navigator.serviceWorker.addEventListener('controllerchange', function() { 242 - if (!reloading) { reloading = true; location.reload(); } 243 - }); 244 - navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' }).then(function(reg) { 245 - // Force an immediate update check on every page load 246 - reg.update().catch(function() {}); 247 - }).catch(function() {}); 248 - } 249 - </script> 196 + <script src="/theme-toggle.js"></script> 197 + <script src="/sw-reload.js"></script> 250 198 </body> 251 199 </html>
+4 -52
src/sheets/index.html
··· 14 14 <link rel="icon" type="image/svg+xml" href="/favicon.svg"> 15 15 <link rel="apple-touch-icon" href="/favicon.svg"> 16 16 <link rel="stylesheet" href="../css/app.css"> 17 - <script> 18 - (function() { 19 - var saved = localStorage.getItem('tools-theme'); 20 - if (saved === 'dark' || saved === 'light') { 21 - document.documentElement.setAttribute('data-theme', saved); 22 - } 23 - if (window.electronAPI) document.documentElement.classList.add('is-electron'); 24 - document.addEventListener('keydown', function(e) { 25 - if (e.key === 'Tab') document.documentElement.setAttribute('data-a11y-focus', ''); 26 - }); 27 - document.addEventListener('mousedown', function() { 28 - document.documentElement.removeAttribute('data-a11y-focus'); 29 - }); 30 - })(); 31 - </script> 17 + <!-- Theme init — runs before paint to avoid FOUC. See #694. --> 18 + <script src="/theme-init.js"></script> 32 19 </head> 33 20 <body> 34 21 <a class="skip-link" href="#main-content">Skip to content</a> ··· 392 379 </div> 393 380 394 381 <script type="module" src="./main.js"></script> 395 - <script> 396 - (function() { 397 - var toggle = document.getElementById('theme-toggle'); 398 - function getEffectiveTheme() { 399 - var saved = localStorage.getItem('tools-theme'); 400 - if (saved === 'dark' || saved === 'light') return saved; 401 - return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; 402 - } 403 - function updateIcon() { 404 - var theme = getEffectiveTheme(); 405 - toggle.textContent = theme === 'dark' ? '\u263E' : '\u2600'; 406 - toggle.setAttribute('data-tooltip', theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'); 407 - } 408 - toggle.addEventListener('click', function() { 409 - var current = getEffectiveTheme(); 410 - var next = current === 'dark' ? 'light' : 'dark'; 411 - document.documentElement.setAttribute('data-theme', next); 412 - localStorage.setItem('tools-theme', next); 413 - updateIcon(); 414 - }); 415 - window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function() { 416 - if (!localStorage.getItem('tools-theme')) updateIcon(); 417 - }); 418 - updateIcon(); 419 - })(); 420 - </script> 421 - <script> 422 - if ('serviceWorker' in navigator) { 423 - var reloading = false; 424 - navigator.serviceWorker.addEventListener('controllerchange', function() { 425 - if (!reloading) { reloading = true; location.reload(); } 426 - }); 427 - navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' }).then(function(reg) { 428 - reg.update().catch(function() {}); 429 - }).catch(function() {}); 430 - } 431 - </script> 382 + <script src="/theme-toggle.js"></script> 383 + <script src="/sw-reload.js"></script> 432 384 <input type="file" id="image-upload-input" accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml" style="display:none"> 433 385 <div class="version-badge">v%APP_VERSION%</div> 434 386 </body>
+3 -27
src/slides/index.html
··· 14 14 <link rel="icon" type="image/svg+xml" href="/favicon.svg"> 15 15 <link rel="apple-touch-icon" href="/favicon.svg"> 16 16 <link rel="stylesheet" href="../css/app.css"> 17 - <script> 18 - (function() { 19 - var saved = localStorage.getItem('tools-theme'); 20 - if (saved === 'dark' || saved === 'light') { 21 - document.documentElement.setAttribute('data-theme', saved); 22 - } 23 - if (window.electronAPI) document.documentElement.classList.add('is-electron'); 24 - document.addEventListener('keydown', function(e) { 25 - if (e.key === 'Tab') document.documentElement.setAttribute('data-a11y-focus', ''); 26 - }); 27 - document.addEventListener('mousedown', function() { 28 - document.documentElement.removeAttribute('data-a11y-focus'); 29 - }); 30 - })(); 31 - </script> 17 + <!-- Theme init — runs before paint to avoid FOUC. See #694. --> 18 + <script src="/theme-init.js"></script> 32 19 </head> 33 20 <body> 34 21 <a class="skip-link" href="#main-content">Skip to content</a> ··· 124 111 125 112 <div class="version-badge">v%APP_VERSION%</div> 126 113 <script type="module" src="./main.ts"></script> 127 - <script> 128 - // Service Worker registration for offline support 129 - if ('serviceWorker' in navigator) { 130 - var reloading = false; 131 - navigator.serviceWorker.addEventListener('controllerchange', function() { 132 - if (!reloading) { reloading = true; location.reload(); } 133 - }); 134 - navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' }).then(function(reg) { 135 - reg.update().catch(function() {}); 136 - }).catch(function() {}); 137 - } 138 - </script> 114 + <script src="/sw-reload.js"></script> 139 115 </body> 140 116 </html>
+73
tests/csp-no-inline-scripts.test.ts
··· 1 + /** 2 + * Regression test for #694: CSP blocks inline scripts on every page. 3 + * 4 + * The server sets `script-src 'self'` (see server/index.ts), which silently 5 + * blocks any inline <script> in the HTML templates. This test pins the 6 + * invariant that every page template only references external scripts via 7 + * <script src="...">, never inline <script>...</script> blocks. 8 + * 9 + * If this test fails, either: 10 + * (a) you added an inline script to a page template — externalize it to a 11 + * .js file under public/ and reference it with <script src="/name.js">, or 12 + * (b) you weakened the CSP to allow 'unsafe-inline' — don't. The whole point 13 + * of a strict CSP is that a single XSS can't inject executable <script>. 14 + */ 15 + 16 + import { describe, it, expect } from 'vitest'; 17 + import { readFileSync } from 'fs'; 18 + import { resolve } from 'path'; 19 + 20 + const HTML_TEMPLATES = [ 21 + 'src/index.html', 22 + 'src/docs/index.html', 23 + 'src/sheets/index.html', 24 + 'src/slides/index.html', 25 + 'src/forms/index.html', 26 + 'src/diagrams/index.html', 27 + 'src/calendar/index.html', 28 + ]; 29 + 30 + // Matches <script ...>body</script> where body is non-empty (ignoring pure 31 + // whitespace). Does NOT match self-closing <script src="..."></script>. 32 + // Using a tolerant regex so any accidental inline script content is caught, 33 + // including minified or oddly-formatted ones. 34 + const INLINE_SCRIPT_RE = /<script\b(?![^>]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/gi; 35 + 36 + describe('#694 — CSP: no inline scripts in HTML templates', () => { 37 + for (const template of HTML_TEMPLATES) { 38 + it(`${template} has no inline <script> blocks`, () => { 39 + const html = readFileSync(resolve(process.cwd(), template), 'utf-8'); 40 + const matches: string[] = []; 41 + let m: RegExpExecArray | null; 42 + while ((m = INLINE_SCRIPT_RE.exec(html)) !== null) { 43 + const body = (m[1] ?? '').trim(); 44 + if (body.length > 0) { 45 + // Capture first 80 chars of each offender for a useful failure message 46 + matches.push(body.slice(0, 80).replace(/\s+/g, ' ')); 47 + } 48 + } 49 + expect( 50 + matches, 51 + `Inline <script> blocks found in ${template} — these will be blocked ` + 52 + `by the CSP 'script-src self' directive. Move them to public/*.js and ` + 53 + `reference via <script src="/name.js">. Offenders:\n - ${matches.join('\n - ')}`, 54 + ).toEqual([]); 55 + }); 56 + } 57 + 58 + it('server CSP header still uses strict script-src self (no unsafe-inline)', () => { 59 + const serverSrc = readFileSync( 60 + resolve(process.cwd(), 'server/index.ts'), 61 + 'utf-8', 62 + ); 63 + // CSP block lives in the response-header middleware; look for the directive. 64 + expect(serverSrc).toContain(`"script-src 'self'"`); 65 + // The whole point of this issue is NOT to weaken the CSP. Guard against 66 + // anyone reintroducing unsafe-inline for script-src. 67 + const scriptSrcLine = serverSrc.match(/"script-src[^"]*"/g) ?? []; 68 + for (const line of scriptSrcLine) { 69 + expect(line, `script-src directive must not include 'unsafe-inline': ${line}`) 70 + .not.toMatch(/unsafe-inline/); 71 + } 72 + }); 73 + });