tina4-nodejs 3.13.142 → 3.13.144

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CLAUDE.md +28 -2
  2. package/package.json +1 -1
  3. package/packages/cli/dist/bin.js +379 -85
  4. package/packages/cli/src/commands/generate.ts +7 -2
  5. package/packages/cli/src/commands/init.ts +18 -8
  6. package/packages/core/dist/index.js +377 -78
  7. package/packages/core/gallery/auth/src/routes/gallery/auth/get.ts +27 -11
  8. package/packages/core/gallery/queue/src/routes/gallery/queue/get.ts +20 -7
  9. package/packages/core/gallery/templates/src/templates/gallery_page.twig +34 -15
  10. package/packages/core/src/csp.ts +138 -0
  11. package/packages/core/src/errorOverlay.ts +80 -37
  12. package/packages/core/src/index.ts +10 -0
  13. package/packages/core/src/middleware.ts +20 -6
  14. package/packages/core/src/response.ts +14 -3
  15. package/packages/core/src/server.ts +52 -15
  16. package/packages/core/src/session.ts +154 -7
  17. package/packages/core/src/sso.ts +1 -1
  18. package/packages/core/src/types.ts +8 -0
  19. package/packages/core/templates/errors/302.twig +1 -1
  20. package/packages/core/templates/errors/401.twig +2 -2
  21. package/packages/core/templates/errors/403.twig +1 -1
  22. package/packages/core/templates/errors/404.twig +1 -1
  23. package/packages/core/templates/errors/500.twig +1 -1
  24. package/packages/core/templates/errors/502.twig +2 -2
  25. package/packages/core/templates/errors/503.twig +2 -2
  26. package/packages/core/templates/errors/base.twig +4 -1
  27. package/packages/frond/dist/index.js +2 -0
  28. package/packages/frond/src/engine.ts +23 -0
  29. package/packages/orm/dist/index.js +378 -87
  30. package/packages/swagger/dist/index.js +4 -4
  31. package/packages/swagger/src/ui.ts +7 -4
  32. package/types/core/src/csp.d.ts +42 -0
  33. package/types/core/src/index.d.ts +1 -0
  34. package/types/core/src/middleware.d.ts +6 -0
  35. package/types/core/src/session.d.ts +62 -2
  36. package/types/core/src/types.d.ts +8 -0
  37. package/types/frond/src/engine.d.ts +7 -0
@@ -10,14 +10,28 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
10
10
  import type { Tina4Request, Tina4Response } from "tina4-nodejs";
11
11
 
12
12
  export default async function (_req: Tina4Request, res: Tina4Response) {
13
+ // CSP-clean under the strict default policy (ADR-0088): one nonce'd <style>
14
+ // holds the rules, no style= attributes, and the buttons bind via
15
+ // addEventListener instead of inline onclick= (a nonce covers an element,
16
+ // never an attribute). res.cspNonce matches the Content-Security-Policy header.
17
+ const nonce = res.cspNonce;
13
18
  const html = `<!DOCTYPE html>
14
19
  <html lang="en">
15
20
  <head>
16
21
  <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
17
22
  <title>Auth Demo</title><link rel="stylesheet" href="/css/tina4.min.css">
23
+ <style nonce="${nonce}">
24
+ .auth-container { max-width: 600px; }
25
+ .auth-hidden { display: none; }
26
+ .token-box { word-break: break-all; white-space: pre-wrap; color: #4ade80; background: #1e293b; padding: 1rem; border-radius: 0.5rem; }
27
+ .payload-box { color: #38bdf8; background: #1e293b; padding: 1rem; border-radius: 0.5rem; }
28
+ .auth-info-card { border: 1px solid #334155; }
29
+ .auth-info-title { color: #e2e8f0; }
30
+ .auth-howto { background: #0f172a; color: #4ade80; padding: 1rem; border-radius: 0.5rem; font-size: 0.8rem; }
31
+ </style>
18
32
  </head>
19
33
  <body class="bg-dark text-light">
20
- <div class="container mt-5" style="max-width:600px;">
34
+ <div class="container mt-5 auth-container">
21
35
  <h2 class="mb-4">JWT Authentication Demo</h2>
22
36
  <div class="card mb-4">
23
37
  <div class="card-header bg-primary text-white">Login</div>
@@ -30,29 +44,29 @@ export default async function (_req: Tina4Request, res: Tina4Response) {
30
44
  <label class="form-label">Password</label>
31
45
  <input type="password" id="password" class="form-control" placeholder="secret" value="secret">
32
46
  </div>
33
- <button class="btn btn-primary" onclick="doLogin()">Login</button>
47
+ <button class="btn btn-primary" data-action="login">Login</button>
34
48
  </div>
35
49
  </div>
36
- <div id="result" style="display:none;">
50
+ <div id="result" class="auth-hidden">
37
51
  <div class="card mb-3">
38
52
  <div class="card-header bg-success text-white">Token Received</div>
39
53
  <div class="card-body">
40
- <pre id="token" style="word-break:break-all;white-space:pre-wrap;color:#4ade80;background:#1e293b;padding:1rem;border-radius:0.5rem;"></pre>
54
+ <pre id="token" class="token-box"></pre>
41
55
  </div>
42
56
  </div>
43
57
  <div class="card mb-3">
44
58
  <div class="card-header">Token Payload (decoded)</div>
45
59
  <div class="card-body">
46
- <pre id="payload" style="color:#38bdf8;background:#1e293b;padding:1rem;border-radius:0.5rem;"></pre>
60
+ <pre id="payload" class="payload-box"></pre>
47
61
  </div>
48
62
  </div>
49
- <button class="btn btn-outline-info" onclick="verifyToken()">Verify Token</button>
63
+ <button class="btn btn-outline-info" data-action="verify">Verify Token</button>
50
64
  <span id="verify-result" class="ms-2"></span>
51
65
  </div>
52
- <div class="card bg-dark mt-4" style="border:1px solid #334155;">
66
+ <div class="card bg-dark mt-4 auth-info-card">
53
67
  <div class="card-body">
54
- <h6 style="color:#e2e8f0;">How it works</h6>
55
- <pre style="background:#0f172a;color:#4ade80;padding:1rem;border-radius:0.5rem;font-size:0.8rem;"><code>import { Auth } from "tina4-nodejs";
68
+ <h6 class="auth-info-title">How it works</h6>
69
+ <pre class="auth-howto"><code>import { Auth } from "tina4-nodejs";
56
70
  const auth = new Auth();
57
71
  const token = auth.createToken({ username: "admin" });
58
72
  const payload = auth.getPayload(token);
@@ -60,7 +74,7 @@ const isValid = auth.validateToken(token);</code></pre>
60
74
  </div>
61
75
  </div>
62
76
  </div>
63
- <script>
77
+ <script nonce="${nonce}">
64
78
  var currentToken = '';
65
79
  function doLogin() {
66
80
  fetch('/api/gallery/auth/login', {
@@ -81,7 +95,7 @@ function doLogin() {
81
95
  } catch(e) {
82
96
  document.getElementById('payload').textContent = 'Could not decode';
83
97
  }
84
- document.getElementById('result').style.display = 'block';
98
+ document.getElementById('result').classList.remove('auth-hidden');
85
99
  document.getElementById('verify-result').textContent = '';
86
100
  } else {
87
101
  alert(d.error || 'Login failed');
@@ -99,6 +113,8 @@ function verifyToken() {
99
113
  }
100
114
  });
101
115
  }
116
+ document.querySelectorAll('[data-action="login"]').forEach(function(b){ b.addEventListener('click', doLogin); });
117
+ document.querySelectorAll('[data-action="verify"]').forEach(function(b){ b.addEventListener('click', verifyToken); });
102
118
  </script>
103
119
  </body></html>`;
104
120
  return res.html(html);
@@ -10,6 +10,10 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
10
10
  import type { Tina4Request, Tina4Response } from "tina4-nodejs";
11
11
 
12
12
  export default async function (_req: Tina4Request, res: Tina4Response) {
13
+ // CSP-clean under the strict default policy (ADR-0088): the <script> carries
14
+ // this response's nonce and every button binds via addEventListener instead of
15
+ // inline onclick= (a nonce covers a script element, never an attribute).
16
+ const nonce = res.cspNonce;
13
17
  const html = `<!DOCTYPE html>
14
18
  <html lang="en">
15
19
  <head>
@@ -30,7 +34,7 @@ export default async function (_req: Tina4Request, res: Tina4Response) {
30
34
  <div class="card-body">
31
35
  <div class="d-flex gap-2">
32
36
  <input type="text" id="msgInput" class="form-control" placeholder="Enter a task message, e.g. send-email">
33
- <button class="btn btn-primary" onclick="produce()">Produce</button>
37
+ <button class="btn btn-primary" data-action="produce">Produce</button>
34
38
  </div>
35
39
  </div>
36
40
  </div>
@@ -39,10 +43,10 @@ export default async function (_req: Tina4Request, res: Tina4Response) {
39
43
  <div class="card">
40
44
  <div class="card-header">Actions</div>
41
45
  <div class="card-body d-flex gap-2 flex-wrap">
42
- <button class="btn btn-success" onclick="consume()">Consume Next</button>
43
- <button class="btn btn-danger" onclick="failNext()">Fail Next</button>
44
- <button class="btn btn-warning" onclick="retryFailed()">Retry Failed</button>
45
- <button class="btn btn-secondary" onclick="refresh()">Refresh</button>
46
+ <button class="btn btn-success" data-action="consume">Consume Next</button>
47
+ <button class="btn btn-danger" data-action="fail-next">Fail Next</button>
48
+ <button class="btn btn-warning" data-action="retry-failed">Retry Failed</button>
49
+ <button class="btn btn-secondary" data-action="refresh">Refresh</button>
46
50
  </div>
47
51
  </div>
48
52
  </div>
@@ -75,7 +79,7 @@ export default async function (_req: Tina4Request, res: Tina4Response) {
75
79
  </div>
76
80
  </div>
77
81
 
78
- <script>
82
+ <script nonce="${nonce}">
79
83
  function statusBadge(status) {
80
84
  var colors = {pending:"primary", reserved:"warning", completed:"success", failed:"danger", dead:"secondary"};
81
85
  var color = colors[status] || "secondary";
@@ -85,7 +89,10 @@ function statusBadge(status) {
85
89
  function showAlert(msg, type) {
86
90
  var area = document.getElementById("alertArea");
87
91
  area.innerHTML = '<div class="alert alert-' + type + ' alert-dismissible">' + msg +
88
- '<button type="button" class="btn-close" onclick="this.parentElement.remove()"></button></div>';
92
+ '<button type="button" class="btn-close js-dismiss"></button></div>';
93
+ area.querySelectorAll('.js-dismiss').forEach(function(b){
94
+ b.addEventListener('click', function(){ this.parentElement.remove(); });
95
+ });
89
96
  setTimeout(function(){ area.innerHTML = ""; }, 3000);
90
97
  }
91
98
 
@@ -159,6 +166,12 @@ async function retryFailed() {
159
166
  refresh();
160
167
  }
161
168
 
169
+ document.querySelectorAll('[data-action="produce"]').forEach(function(b){ b.addEventListener('click', produce); });
170
+ document.querySelectorAll('[data-action="consume"]').forEach(function(b){ b.addEventListener('click', consume); });
171
+ document.querySelectorAll('[data-action="fail-next"]').forEach(function(b){ b.addEventListener('click', failNext); });
172
+ document.querySelectorAll('[data-action="retry-failed"]').forEach(function(b){ b.addEventListener('click', retryFailed); });
173
+ document.querySelectorAll('[data-action="refresh"]').forEach(function(b){ b.addEventListener('click', refresh); });
174
+
162
175
  refresh();
163
176
  setInterval(refresh, 2000);
164
177
  </script>
@@ -13,10 +13,23 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
13
13
  <meta name="viewport" content="width=device-width, initial-scale=1">
14
14
  <title>{{ title }}</title>
15
15
  <link rel="stylesheet" href="/css/tina4.min.css">
16
- <style>
16
+ <style nonce="{{ csp_nonce() }}">
17
17
  .component-section { margin-bottom: 2.5rem; }
18
18
  .component-section h3 { border-bottom: 2px solid var(--bs-primary, #0d6efd); padding-bottom: 0.5rem; margin-bottom: 1rem; }
19
19
  .code-preview { background: #1e293b; color: #4ade80; padding: 1rem; border-radius: 0.5rem; font-family: monospace; font-size: 0.85rem; overflow-x: auto; margin-top: 0.75rem; }
20
+ .progress-25 { width: 25%; }
21
+ .progress-50 { width: 50%; }
22
+ .progress-75 { width: 75%; }
23
+ .progress-100 { width: 100%; }
24
+ .build-card { background: #0f172a; border: 1px solid #334155; }
25
+ .build-heading { color: #e2e8f0; }
26
+ .build-code { background: #1e293b; border: 1px solid #334155; border-radius: 0.5rem; padding: 1.25rem; margin-top: 0.75rem; overflow-x: auto; font-family: 'SF Mono', SFMono-Regular, Consolas, monospace; font-size: 0.9rem; line-height: 1.7; }
27
+ .build-note { color: #94a3b8; margin-top: 0.75rem; margin-bottom: 0; }
28
+ .tok-kw { color: #38bdf8; }
29
+ .tok-str { color: #4ade80; }
30
+ .tok-fn { color: #fbbf24; }
31
+ .tok-dec { color: #c084fc; }
32
+ .tok-comment { color: #94a3b8; }
20
33
  </style>
21
34
  </head>
22
35
  <body>
@@ -37,7 +50,7 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
37
50
 
38
51
  <div class="alert alert-info alert-dismissible mb-4">
39
52
  <strong>tina4css</strong> — Zero-dependency CSS framework (~24KB). Bootstrap-compatible class names, dark mode ready. No CDN needed — ships with every Tina4 project.
40
- <button type="button" class="btn-close" onclick="this.parentElement.remove()"></button>
53
+ <button type="button" class="btn-close js-dismiss"></button>
41
54
  </div>
42
55
 
43
56
  <!-- Buttons -->
@@ -225,10 +238,10 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
225
238
  <!-- Progress -->
226
239
  <div class="component-section">
227
240
  <h3>Progress Bars</h3>
228
- <div class="progress mb-2"><div class="progress-bar bg-primary" style="width: 25%">25%</div></div>
229
- <div class="progress mb-2"><div class="progress-bar bg-success" style="width: 50%">50%</div></div>
230
- <div class="progress mb-2"><div class="progress-bar bg-warning" style="width: 75%">75%</div></div>
231
- <div class="progress"><div class="progress-bar bg-danger" style="width: 100%">100%</div></div>
241
+ <div class="progress mb-2"><div class="progress-bar bg-primary progress-25">25%</div></div>
242
+ <div class="progress mb-2"><div class="progress-bar bg-success progress-50">50%</div></div>
243
+ <div class="progress mb-2"><div class="progress-bar bg-warning progress-75">75%</div></div>
244
+ <div class="progress"><div class="progress-bar bg-danger progress-100">100%</div></div>
232
245
  </div>
233
246
 
234
247
  <!-- List Group -->
@@ -243,23 +256,29 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
243
256
  </div>
244
257
 
245
258
  <!-- How this page was built -->
246
- <div class="card mt-4 mb-5" style="background:#0f172a;border:1px solid #334155;">
259
+ <div class="card mt-4 mb-5 build-card">
247
260
  <div class="card-body">
248
- <h5 style="color:#e2e8f0;">How this page was built</h5>
249
- <pre style="background:#1e293b;border:1px solid #334155;border-radius:0.5rem;padding:1.25rem;margin-top:0.75rem;overflow-x:auto;font-family:'SF Mono',SFMono-Regular,Consolas,monospace;font-size:0.9rem;line-height:1.7;"><code><span style="color:#64748b">// src/routes/gallery/page/get.ts</span>
250
- <span style="color:#c084fc">export const</span> <span style="color:#fbbf24">template</span> = <span style="color:#4ade80">"gallery_page.twig"</span>;
261
+ <h5 class="build-heading">How this page was built</h5>
262
+ <pre class="build-code"><code><span class="tok-comment">// src/routes/gallery/page/get.ts</span>
263
+ <span class="tok-dec">export const</span> <span class="tok-fn">template</span> = <span class="tok-str">"gallery_page.twig"</span>;
251
264
 
252
- <span style="color:#c084fc">export default</span> <span style="color:#38bdf8">async function</span>(<span style="color:#38bdf8">_req</span>, <span style="color:#38bdf8">_res</span>) {
253
- <span style="color:#c084fc">return</span> { <span style="color:#4ade80">"title"</span>: <span style="color:#4ade80">"tina4css Component Showcase"</span>, <span style="color:#4ade80">"items"</span>: [...] };
265
+ <span class="tok-dec">export default</span> <span class="tok-kw">async function</span>(<span class="tok-kw">_req</span>, <span class="tok-kw">_res</span>) {
266
+ <span class="tok-dec">return</span> { <span class="tok-str">"title"</span>: <span class="tok-str">"tina4css Component Showcase"</span>, <span class="tok-str">"items"</span>: [...] };
254
267
  }</code></pre>
255
- <p style="color:#94a3b8;margin-top:0.75rem;margin-bottom:0;">
256
- Rendered via <code style="color:#c084fc;">template</code> export. Styled with <strong style="color:#e2e8f0;">tina4css</strong> — zero external CDN.
257
- All components above use only <code style="color:#4ade80;">&lt;link href="/css/tina4.min.css"&gt;</code>.
268
+ <p class="build-note">
269
+ Rendered via <code class="tok-dec">template</code> export. Styled with <strong class="build-heading">tina4css</strong> — zero external CDN.
270
+ All components above use only <code class="tok-str">&lt;link href="/css/tina4.min.css"&gt;</code>.
258
271
  </p>
259
272
  </div>
260
273
  </div>
261
274
 
262
275
  </div>
263
276
 
277
+ <script nonce="{{ csp_nonce() }}">
278
+ document.querySelectorAll('.js-dismiss').forEach(function(b){
279
+ b.addEventListener('click', function(){ this.parentElement.remove(); });
280
+ });
281
+ </script>
282
+
264
283
  </body>
265
284
  </html>
@@ -0,0 +1,138 @@
1
+ // Copyright (c) 2026 Code Infinity
2
+ // SPDX-License-Identifier: MPL-2.0
3
+ // This Source Code Form is subject to the terms of the Mozilla Public
4
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
5
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
6
+
7
+ /**
8
+ * Per-response Content-Security-Policy nonce (ADR-0088).
9
+ *
10
+ * The framework serves a strict default CSP (`default-src 'self'`). A browser
11
+ * refuses every inline `<style>` and `<script>` under that policy unless the
12
+ * element carries a nonce that the `Content-Security-Policy` header also names.
13
+ * So the framework mints one cryptographically-random nonce per response, stamps
14
+ * it on every inline `<style>`/`<script>` it emits, and injects the matching
15
+ * `'nonce-<X>'` into the `style-src` and `script-src` directives of the CSP
16
+ * header. The same value reaches user templates through the Frond global
17
+ * `csp_nonce()` and routes through `response.cspNonce`.
18
+ *
19
+ * The nonce lives in an `AsyncLocalStorage`, the same Node adaptation of a
20
+ * per-request contextvar the logger uses for the request id (Decision 12).
21
+ * `runDispatch()` establishes a fresh nonce for the whole request, so the inline
22
+ * HTML body and the security middleware both read the SAME value across every
23
+ * await, and two requests interleaving on the one event loop never read each
24
+ * other's. Whoever touches the nonce first in a request - the body emitter or the
25
+ * header builder - gets the same value, because `currentCspNonce()` mints one on
26
+ * first access and caches it for the rest of the request.
27
+ */
28
+ import { AsyncLocalStorage } from "node:async_hooks";
29
+ import { randomBytes } from "node:crypto";
30
+
31
+ // The per-request nonce. `undefined` until a request (or the first emitter /
32
+ // header call) sets one. A fallback covers code that runs outside any request
33
+ // scope (a script, a test), exactly as the logger's request-id fallback does.
34
+ const nonceStore = new AsyncLocalStorage<{ nonce: string | undefined }>();
35
+ let nonceFallback: string | undefined;
36
+
37
+ /** Return a fresh cryptographically-random nonce (128 bits, base64). */
38
+ export function generateNonce(): string {
39
+ return randomBytes(16).toString("base64");
40
+ }
41
+
42
+ /** Run `fn` with `nonce` established as the request-scoped CSP nonce. */
43
+ export function runWithCspNonce<T>(nonce: string, fn: () => T): T {
44
+ return nonceStore.run({ nonce }, fn);
45
+ }
46
+
47
+ /** Set the nonce for the current request context (mirrors Log.setRequestId). */
48
+ export function setCurrentNonce(nonce: string | undefined): void {
49
+ const store = nonceStore.getStore();
50
+ if (store) store.nonce = nonce;
51
+ else nonceFallback = nonce;
52
+ }
53
+
54
+ /** Clear the nonce (mirrors Log.clearRequestId); the scope ends on its own. */
55
+ export function clearCurrentNonce(): void {
56
+ setCurrentNonce(undefined);
57
+ }
58
+
59
+ /**
60
+ * Return the current request's nonce, minting one on first access.
61
+ *
62
+ * Generate-on-first-access makes the value independent of ordering: whether the
63
+ * inline body or the CSP header is built first, both read the same nonce.
64
+ */
65
+ export function currentCspNonce(): string {
66
+ const store = nonceStore.getStore();
67
+ let value = store ? store.nonce : nonceFallback;
68
+ if (!value) {
69
+ value = generateNonce();
70
+ if (store) store.nonce = value;
71
+ else nonceFallback = value;
72
+ }
73
+ return value;
74
+ }
75
+
76
+ /**
77
+ * Frond global `csp_nonce()`: the current response's CSP nonce.
78
+ *
79
+ * Use it on any inline element a template emits:
80
+ *
81
+ * <style nonce="{{ csp_nonce() }}"> ... </style>
82
+ * <script nonce="{{ csp_nonce() }}"> ... </script>
83
+ */
84
+ export function cspNonce(): string {
85
+ return currentCspNonce();
86
+ }
87
+
88
+ /**
89
+ * Return `csp` with `'nonce-<nonce>'` present in style-src AND script-src.
90
+ *
91
+ * For the default `default-src 'self'` this yields
92
+ * `default-src 'self'; style-src 'self' 'nonce-X'; script-src 'self' 'nonce-X'`.
93
+ * When a directive is absent it is derived from `default-src` (falling back to
94
+ * `'self'`) so the framework's own nonce'd content always works; when present
95
+ * the nonce is appended (idempotently). Every other directive is kept, in order.
96
+ */
97
+ export function injectNonceIntoCsp(csp: string, nonce: string): string {
98
+ const token = `'nonce-${nonce}'`;
99
+ const directives: Array<[string, string]> = [];
100
+ for (const rawPart of csp.split(";")) {
101
+ const part = rawPart.trim();
102
+ if (!part) continue;
103
+ const spaceAt = part.search(/\s/);
104
+ const name = (spaceAt === -1 ? part : part.slice(0, spaceAt)).toLowerCase();
105
+ const value = spaceAt === -1 ? "" : part.slice(spaceAt + 1).trim();
106
+ directives.push([name, value]);
107
+ }
108
+
109
+ const defaultEntry = directives.find(([name]) => name === "default-src");
110
+ const defaultValue = defaultEntry && defaultEntry[1] ? defaultEntry[1] : "'self'";
111
+
112
+ for (const directive of ["style-src", "script-src"]) {
113
+ const existing = directives.find(([name]) => name === directive);
114
+ if (existing) {
115
+ if (!existing[1].split(/\s+/).includes(token)) {
116
+ existing[1] = `${existing[1]} ${token}`.trim();
117
+ }
118
+ } else {
119
+ directives.push([directive, `${defaultValue} ${token}`.trim()]);
120
+ }
121
+ }
122
+
123
+ return directives
124
+ .map(([name, value]) => (value ? `${name} ${value}` : name))
125
+ .join("; ");
126
+ }
127
+
128
+ /**
129
+ * Build the CSP header value for `nonce` from the environment.
130
+ *
131
+ * Honours `TINA4_CSP` (default `default-src 'self'`) and always injects the
132
+ * nonce into style-src and script-src. An empty `TINA4_CSP` is treated as the
133
+ * default policy, matching how the warn-once gate reads "unset".
134
+ */
135
+ export function resolveCspHeader(nonce: string): string {
136
+ const csp = process.env.TINA4_CSP || "default-src 'self'";
137
+ return injectNonceIntoCsp(csp, nonce);
138
+ }
@@ -34,6 +34,7 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
34
34
  import { readFileSync, statSync } from "node:fs";
35
35
  import { resolve } from "node:path";
36
36
  import { isTruthy } from "./dotenv.js";
37
+ import { currentCspNonce } from "./csp.js";
37
38
 
38
39
  // OVERLAY-DEC-03: cap the rendered frames so a deep/recursive stack yields a bounded
39
40
  // page, not one source-file read per frame.
@@ -124,18 +125,16 @@ function formatSourceBlock(filename: string, lineno: number): string {
124
125
  if (lines.length === 0) return "";
125
126
 
126
127
  const rows = lines.map(([num, text, isError]) => {
127
- const bg = isError ? `background:${ERROR_LINE_BG};` : "";
128
+ const rowClass = isError ? "eo-line eo-line-err" : "eo-line";
128
129
  const marker = isError ? "&#x25b6;" : " ";
129
- return `<div style="${bg}display:flex;padding:1px 0;">`
130
- + `<span style="color:${YELLOW};min-width:3.5em;text-align:right;padding-right:1em;user-select:none;">${num}</span>`
131
- + `<span style="color:${RED};width:1.2em;user-select:none;">${marker}</span>`
132
- + `<span style="color:${TEXT};white-space:pre-wrap;tab-size:4;">${esc(text)}</span>`
130
+ return `<div class="${rowClass}">`
131
+ + `<span class="eo-ln">${num}</span>`
132
+ + `<span class="eo-marker">${marker}</span>`
133
+ + `<span class="eo-code">${esc(text)}</span>`
133
134
  + `</div>`;
134
135
  }).join("\n");
135
136
 
136
- return `<div style="background:${SURFACE};border-radius:6px;padding:12px;overflow-x:auto;`
137
- + `font-family:'SF Mono','Fira Code','Consolas',monospace;font-size:13px;line-height:1.6;">`
138
- + rows + `</div>`;
137
+ return `<div class="eo-source">` + rows + `</div>`;
139
138
  }
140
139
 
141
140
  /**
@@ -162,21 +161,20 @@ function formatFrame(frame: StackFrame, capturedAt = 0): string {
162
161
  const mtimeIso = `${String(d.getUTCHours()).padStart(2, "0")}:`
163
162
  + `${String(d.getUTCMinutes()).padStart(2, "0")}:`
164
163
  + `${String(d.getUTCSeconds()).padStart(2, "0")}`;
165
- staleBadge = ` <span style="background:${PEACH};color:${BG};padding:1px 8px;`
166
- + `border-radius:3px;font-size:11px;font-weight:700;margin-left:6px;">`
164
+ staleBadge = ` <span class="eo-stale">`
167
165
  + `FILE MODIFIED @ ${mtimeIso} UTC &mdash; source may not match what failed</span>`;
168
166
  }
169
167
  } catch {
170
168
  // best-effort — ignore missing files / permission errors
171
169
  }
172
170
  }
173
- return `<div style="margin-bottom:16px;">`
174
- + `<div style="margin-bottom:4px;">`
175
- + `<span style="color:${BLUE};">${esc(frame.file)}</span>`
176
- + `<span style="color:${SUBTEXT};"> : </span>`
177
- + `<span style="color:${YELLOW};">${frame.line}</span>`
178
- + `<span style="color:${SUBTEXT};"> in </span>`
179
- + `<span style="color:${GREEN};">${esc(frame.func)}</span>`
171
+ return `<div class="eo-frame">`
172
+ + `<div class="eo-frame-head">`
173
+ + `<span class="eo-file">${esc(frame.file)}</span>`
174
+ + `<span class="eo-sep"> : </span>`
175
+ + `<span class="eo-lineno">${frame.line}</span>`
176
+ + `<span class="eo-sep"> in </span>`
177
+ + `<span class="eo-fn">${esc(frame.func)}</span>`
180
178
  + staleBadge
181
179
  + `</div>`
182
180
  + source
@@ -185,22 +183,21 @@ function formatFrame(frame: StackFrame, capturedAt = 0): string {
185
183
 
186
184
  function collapsible(title: string, content: string, openByDefault = false): string {
187
185
  const open = openByDefault ? " open" : "";
188
- return `<details style="margin-top:16px;"${open}>`
189
- + `<summary style="cursor:pointer;color:${LAVENDER};font-weight:600;font-size:15px;`
190
- + `padding:8px 0;user-select:none;">${esc(title)}</summary>`
191
- + `<div style="padding:8px 0;">${content}</div>`
186
+ return `<details class="eo-details"${open}>`
187
+ + `<summary class="eo-summary">${esc(title)}</summary>`
188
+ + `<div class="eo-details-body">${content}</div>`
192
189
  + `</details>`;
193
190
  }
194
191
 
195
192
  function table(pairs: Array<[string, string]>): string {
196
- if (pairs.length === 0) return `<span style="color:${SUBTEXT};">None</span>`;
193
+ if (pairs.length === 0) return `<span class="eo-none">None</span>`;
197
194
  const rows = pairs.map(([key, val]) =>
198
195
  `<tr>`
199
- + `<td style="color:${PEACH};padding:4px 16px 4px 0;vertical-align:top;white-space:nowrap;">${esc(key)}</td>`
200
- + `<td style="color:${TEXT};padding:4px 0;word-break:break-all;">${esc(val)}</td>`
196
+ + `<td class="eo-key">${esc(key)}</td>`
197
+ + `<td class="eo-val">${esc(val)}</td>`
201
198
  + `</tr>`
202
199
  ).join("");
203
- return `<table style="border-collapse:collapse;width:100%;">${rows}</table>`;
200
+ return `<table class="eo-table">${rows}</table>`;
204
201
  }
205
202
 
206
203
  /**
@@ -232,7 +229,7 @@ export function renderErrorOverlay(error: Error, request?: any): string {
232
229
  }
233
230
  const hidden = frames.length - Math.min(frames.length, MAX_FRAMES);
234
231
  if (hidden > 0) {
235
- framesHtml += `<div style="color:${SUBTEXT};padding:8px 0;font-size:13px;">`
232
+ framesHtml += `<div class="eo-hidden-frames">`
236
233
  + `&#8230; ${hidden} more stack frames hidden (truncated at ${MAX_FRAMES})</div>`;
237
234
  }
238
235
 
@@ -277,31 +274,34 @@ export function renderErrorOverlay(error: Error, request?: any): string {
277
274
  const envSection = collapsible("Environment", table(envPairs));
278
275
  const stackSection = collapsible("Stack Trace", framesHtml, true);
279
276
 
277
+ // The overlay runs under the strict default CSP: one nonce'd <style> carries
278
+ // every rule, and the body uses classes only — no style= attribute, because a
279
+ // nonce covers a <style> ELEMENT but never a style attribute (ADR-0088).
280
+ const nonce = currentCspNonce();
280
281
  return `<!DOCTYPE html>
281
282
  <html lang="en">
282
283
  <head>
283
284
  <meta charset="utf-8">
284
285
  <meta name="viewport" content="width=device-width,initial-scale=1">
285
286
  <title>Tina4 Error — ${esc(excType)}</title>
286
- <style>
287
- *{margin:0;padding:0;box-sizing:border-box;}
288
- body{background:${BG};color:${TEXT};font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;padding:24px;line-height:1.5;}
287
+ <style nonce="${nonce}">
288
+ ${overlayStylesheet()}
289
289
  </style>
290
290
  </head>
291
291
  <body>
292
- <div style="max-width:960px;margin:0 auto;">
293
- <div style="margin-bottom:24px;">
294
- <div style="display:flex;align-items:center;gap:12px;margin-bottom:12px;">
295
- <span style="background:${RED};color:${BG};padding:4px 12px;border-radius:4px;font-weight:700;font-size:13px;text-transform:uppercase;">Error</span>
296
- <span style="color:${SUBTEXT};font-size:14px;">Tina4 Debug Overlay</span>
292
+ <div class="eo-wrap">
293
+ <div class="eo-header">
294
+ <div class="eo-badge-row">
295
+ <span class="eo-badge">Error</span>
296
+ <span class="eo-sub">Tina4 Debug Overlay</span>
297
297
  </div>
298
- <h1 style="color:${RED};font-size:28px;font-weight:700;margin-bottom:8px;">${esc(excType)}</h1>
299
- <p style="color:${TEXT};font-size:18px;font-family:'SF Mono','Fira Code','Consolas',monospace;background:${SURFACE};padding:12px 16px;border-radius:6px;border-left:4px solid ${RED};">${esc(excMsg)}</p>
298
+ <h1 class="eo-type">${esc(excType)}</h1>
299
+ <p class="eo-msg">${esc(excMsg)}</p>
300
300
  </div>
301
301
  ${stackSection}
302
302
  ${requestSection}
303
303
  ${envSection}
304
- <div style="margin-top:32px;padding-top:16px;border-top:1px solid ${OVERLAY};color:${SUBTEXT};font-size:12px;">
304
+ <div class="eo-footer">
305
305
  Tina4 Debug Overlay &mdash; This page is only shown in debug mode. Set TINA4_DEBUG=false in production.
306
306
  </div>
307
307
  </div>
@@ -309,6 +309,49 @@ body{background:${BG};color:${TEXT};font-family:-apple-system,BlinkMacSystemFont
309
309
  </html>`;
310
310
  }
311
311
 
312
+ /**
313
+ * The overlay's full stylesheet, served inside one nonce'd <style> block.
314
+ *
315
+ * Keeping the rules here (not on the elements) is what makes the overlay
316
+ * CSP-clean: no framework page emits a style="..." attribute, because a nonce
317
+ * covers a <style> ELEMENT but never a style attribute (ADR-0088).
318
+ */
319
+ function overlayStylesheet(): string {
320
+ return `
321
+ *{margin:0;padding:0;box-sizing:border-box;}
322
+ body{background:${BG};color:${TEXT};font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;padding:24px;line-height:1.5;}
323
+ .eo-wrap{max-width:960px;margin:0 auto;}
324
+ .eo-header{margin-bottom:24px;}
325
+ .eo-badge-row{display:flex;align-items:center;gap:12px;margin-bottom:12px;}
326
+ .eo-badge{background:${RED};color:${BG};padding:4px 12px;border-radius:4px;font-weight:700;font-size:13px;text-transform:uppercase;}
327
+ .eo-sub{color:${SUBTEXT};font-size:14px;}
328
+ .eo-type{color:${RED};font-size:28px;font-weight:700;margin-bottom:8px;}
329
+ .eo-msg{color:${TEXT};font-size:18px;font-family:'SF Mono','Fira Code','Consolas',monospace;background:${SURFACE};padding:12px 16px;border-radius:6px;border-left:4px solid ${RED};}
330
+ .eo-footer{margin-top:32px;padding-top:16px;border-top:1px solid ${OVERLAY};color:${SUBTEXT};font-size:12px;}
331
+ .eo-source{background:${SURFACE};border-radius:6px;padding:12px;overflow-x:auto;font-family:'SF Mono','Fira Code','Consolas',monospace;font-size:13px;line-height:1.6;}
332
+ .eo-line{display:flex;padding:1px 0;}
333
+ .eo-line-err{background:${ERROR_LINE_BG};}
334
+ .eo-ln{color:${YELLOW};min-width:3.5em;text-align:right;padding-right:1em;user-select:none;}
335
+ .eo-marker{color:${RED};width:1.2em;user-select:none;}
336
+ .eo-code{color:${TEXT};white-space:pre-wrap;tab-size:4;}
337
+ .eo-frame{margin-bottom:16px;}
338
+ .eo-frame-head{margin-bottom:4px;}
339
+ .eo-file{color:${BLUE};}
340
+ .eo-sep{color:${SUBTEXT};}
341
+ .eo-lineno{color:${YELLOW};}
342
+ .eo-fn{color:${GREEN};}
343
+ .eo-stale{background:${PEACH};color:${BG};padding:1px 8px;border-radius:3px;font-size:11px;font-weight:700;margin-left:6px;}
344
+ .eo-details{margin-top:16px;}
345
+ .eo-summary{cursor:pointer;color:${LAVENDER};font-weight:600;font-size:15px;padding:8px 0;user-select:none;}
346
+ .eo-details-body{padding:8px 0;}
347
+ .eo-table{border-collapse:collapse;width:100%;}
348
+ .eo-key{color:${PEACH};padding:4px 16px 4px 0;vertical-align:top;white-space:nowrap;}
349
+ .eo-val{color:${TEXT};padding:4px 0;word-break:break-all;}
350
+ .eo-none{color:${SUBTEXT};}
351
+ .eo-hidden-frames{color:${SUBTEXT};padding:8px 0;font-size:13px;}
352
+ `;
353
+ }
354
+
312
355
  /**
313
356
  * Check if TINA4_DEBUG is enabled.
314
357
  */
@@ -114,6 +114,16 @@ export { WSDLService, WSDLOperation } from "./wsdl.js";
114
114
  export type { WSDLOperationMeta } from "./wsdl.js";
115
115
  export { HtmlElement, htmlElement, addHtmlHelpers, Raw, SafeString } from "./htmlElement.js";
116
116
  export { renderErrorOverlay, isDebugMode } from "./errorOverlay.js";
117
+ export {
118
+ cspNonce,
119
+ currentCspNonce,
120
+ generateNonce,
121
+ injectNonceIntoCsp,
122
+ resolveCspHeader,
123
+ runWithCspNonce,
124
+ setCurrentNonce,
125
+ clearCurrentNonce,
126
+ } from "./csp.js";
117
127
  export { AI_TOOLS, isInstalled, showMenu, installSelected, installAll, generateContext } from "./ai.js";
118
128
  export type { AiTool } from "./ai.js";
119
129
  export { Sso, SSO, SsoError } from "./sso.js";