tina4-nodejs 3.13.141 → 3.13.143

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 (67) hide show
  1. package/CLAUDE.md +28 -2
  2. package/package.json +1 -1
  3. package/packages/cli/dist/bin.js +817 -538
  4. package/packages/cli/src/bin.ts +4 -14
  5. package/packages/cli/src/commands/build.ts +2 -18
  6. package/packages/cli/src/commands/generate.ts +9 -24
  7. package/packages/cli/src/commands/init.ts +18 -8
  8. package/packages/cli/src/commands/lint.ts +3 -13
  9. package/packages/cli/src/commands/migrate.ts +2 -6
  10. package/packages/cli/src/commands/migrateRollback.ts +2 -6
  11. package/packages/cli/src/commands/migrateStatus.ts +2 -6
  12. package/packages/cli/src/commands/queue.ts +2 -30
  13. package/packages/cli/src/util.ts +90 -0
  14. package/packages/core/dist/index.js +592 -262
  15. package/packages/core/gallery/auth/src/routes/gallery/auth/get.ts +27 -11
  16. package/packages/core/gallery/queue/src/lib/queueDb.ts +11 -0
  17. package/packages/core/gallery/queue/src/routes/api/gallery/queue/consume/post.ts +2 -5
  18. package/packages/core/gallery/queue/src/routes/api/gallery/queue/fail/post.ts +2 -5
  19. package/packages/core/gallery/queue/src/routes/gallery/queue/get.ts +20 -7
  20. package/packages/core/gallery/templates/src/templates/gallery_page.twig +34 -15
  21. package/packages/core/src/csp.ts +138 -0
  22. package/packages/core/src/devAdmin.ts +3 -12
  23. package/packages/core/src/dispatchPipeline.ts +2 -18
  24. package/packages/core/src/errorOverlay.ts +80 -37
  25. package/packages/core/src/etag.ts +24 -0
  26. package/packages/core/src/feedback.ts +2 -11
  27. package/packages/core/src/index.ts +10 -0
  28. package/packages/core/src/middleware.ts +20 -6
  29. package/packages/core/src/response.ts +14 -3
  30. package/packages/core/src/server.ts +52 -15
  31. package/packages/core/src/session.ts +174 -71
  32. package/packages/core/src/sessionHandlers/respSessionHandler.ts +94 -0
  33. package/packages/core/src/sessionHandlers/valkeyHandler.ts +22 -73
  34. package/packages/core/src/sso.ts +1 -1
  35. package/packages/core/src/static.ts +1 -13
  36. package/packages/core/src/types.ts +8 -0
  37. package/packages/core/src/upstreamResponse.ts +31 -0
  38. package/packages/core/src/websocket.ts +48 -7
  39. package/packages/core/templates/errors/302.twig +1 -1
  40. package/packages/core/templates/errors/401.twig +2 -2
  41. package/packages/core/templates/errors/403.twig +1 -1
  42. package/packages/core/templates/errors/404.twig +1 -1
  43. package/packages/core/templates/errors/500.twig +1 -1
  44. package/packages/core/templates/errors/502.twig +2 -2
  45. package/packages/core/templates/errors/503.twig +2 -2
  46. package/packages/core/templates/errors/base.twig +4 -1
  47. package/packages/frond/dist/index.js +2 -0
  48. package/packages/frond/src/engine.ts +23 -0
  49. package/packages/orm/dist/index.js +593 -271
  50. package/packages/orm/src/graph/adapters/bolt.ts +8 -29
  51. package/packages/orm/src/graph/adapters/rowMapping.ts +57 -0
  52. package/packages/orm/src/graph/adapters/ultipa.ts +8 -30
  53. package/packages/swagger/dist/index.js +4 -4
  54. package/packages/swagger/src/ui.ts +7 -4
  55. package/types/cli/src/util.d.ts +28 -0
  56. package/types/core/src/csp.d.ts +42 -0
  57. package/types/core/src/etag.d.ts +9 -0
  58. package/types/core/src/index.d.ts +1 -0
  59. package/types/core/src/middleware.d.ts +6 -0
  60. package/types/core/src/session.d.ts +64 -21
  61. package/types/core/src/sessionHandlers/respSessionHandler.d.ts +52 -0
  62. package/types/core/src/sessionHandlers/valkeyHandler.d.ts +2 -26
  63. package/types/core/src/types.d.ts +8 -0
  64. package/types/core/src/upstreamResponse.d.ts +10 -0
  65. package/types/core/src/websocket.d.ts +21 -0
  66. package/types/frond/src/engine.d.ts +7 -0
  67. package/types/orm/src/graph/adapters/rowMapping.d.ts +26 -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);
@@ -38,3 +38,14 @@ export async function getQueueDb(): Promise<DatabaseAdapter> {
38
38
  export function now(): string {
39
39
  return new Date().toISOString();
40
40
  }
41
+
42
+ /**
43
+ * Claim the next pending message for a topic — highest priority first, then
44
+ * the oldest (lowest id). The `LIMIT 1` bounds the work: without it SQLite
45
+ * sorts the WHOLE pending backlog on every consume call before `fetchOne`
46
+ * reads a single row (carbonah E003, an unbounded query on a hot path). One
47
+ * row asked for, one row returned. Shared by the consume and fail routes so
48
+ * the claim query lives in exactly one place.
49
+ */
50
+ export const CLAIM_NEXT_PENDING_SQL =
51
+ "SELECT * FROM tina4_queue WHERE topic = ? AND status = 'pending' AND available_at <= ? ORDER BY priority DESC, id ASC LIMIT 1";
@@ -8,17 +8,14 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
8
8
 
9
9
  /** Gallery: Queue — consume (complete) the next pending message. */
10
10
  import type { Tina4Request, Tina4Response } from "tina4-nodejs";
11
- import { getQueueDb, now } from "../../../../lib/queueDb.js";
11
+ import { getQueueDb, now, CLAIM_NEXT_PENDING_SQL } from "../../../../lib/queueDb.js";
12
12
 
13
13
  export default async function (_req: Tina4Request, res: Tina4Response) {
14
14
  try {
15
15
  const db = await getQueueDb();
16
16
  const ts = now();
17
17
 
18
- const row = db.fetchOne<Record<string, unknown>>(
19
- "SELECT * FROM tina4_queue WHERE topic = ? AND status = 'pending' AND available_at <= ? ORDER BY priority DESC, id ASC",
20
- ["gallery-tasks", ts]
21
- );
18
+ const row = db.fetchOne<Record<string, unknown>>(CLAIM_NEXT_PENDING_SQL, ["gallery-tasks", ts]);
22
19
 
23
20
  if (!row) {
24
21
  return res.json({ consumed: false, message: "No pending messages to consume" });
@@ -8,17 +8,14 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
8
8
 
9
9
  /** Gallery: Queue — deliberately fail the next pending message. */
10
10
  import type { Tina4Request, Tina4Response } from "tina4-nodejs";
11
- import { getQueueDb, now } from "../../../../lib/queueDb.js";
11
+ import { getQueueDb, now, CLAIM_NEXT_PENDING_SQL } from "../../../../lib/queueDb.js";
12
12
 
13
13
  export default async function (_req: Tina4Request, res: Tina4Response) {
14
14
  try {
15
15
  const db = await getQueueDb();
16
16
  const ts = now();
17
17
 
18
- const row = db.fetchOne<Record<string, unknown>>(
19
- "SELECT * FROM tina4_queue WHERE topic = ? AND status = 'pending' AND available_at <= ? ORDER BY priority DESC, id ASC",
20
- ["gallery-tasks", ts]
21
- );
18
+ const row = db.fetchOne<Record<string, unknown>>(CLAIM_NEXT_PENDING_SQL, ["gallery-tasks", ts]);
22
19
 
23
20
  if (!row) {
24
21
  return res.json({ failed: false, message: "No pending messages to fail" });
@@ -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
+ }
@@ -24,6 +24,7 @@ import { fileURLToPath } from "node:url";
24
24
  import type { Router } from "./router.js";
25
25
  import type { RouteHandler, Tina4Request } from "./types.js";
26
26
  import { DevMailbox } from "./devMailbox.js";
27
+ import { relayUpstreamResponse } from "./upstreamResponse.js";
27
28
  import { isTruthy } from "./dotenv.js";
28
29
  import { fullAnalysis, fileDetail, MetricsEngineError, metricsScanRoot } from "./metrics.js";
29
30
  import { registerFeedbackRoutes } from "./feedback.js";
@@ -1682,18 +1683,8 @@ async function proxyToSupervisor(
1682
1683
 
1683
1684
  clearTimeout(timer);
1684
1685
 
1685
- // JSON / other — drain the body and return as before.
1686
- const raw = await upstream.text();
1687
- const status = upstream.status || 200;
1688
- try {
1689
- res.json(JSON.parse(raw), status);
1690
- } catch {
1691
- // Non-JSON upstream — pass through as text with the same status.
1692
- res.raw.writeHead(status, {
1693
- "Content-Type": upstream.headers.get("content-type") ?? "text/plain; charset=utf-8",
1694
- });
1695
- res.raw.end(raw);
1696
- }
1686
+ // JSON / other — drain the body and relay with the upstream status.
1687
+ await relayUpstreamResponse(res, upstream);
1697
1688
  }
1698
1689
 
1699
1690
  // -- Framework-grounding (mcp.tina4.com) token config --
@@ -42,6 +42,7 @@ import { createHash } from "node:crypto";
42
42
  import type { Tina4Request } from "./types.js";
43
43
  import type { Session as SessionInstance } from "./session.js";
44
44
  import { Log } from "./logger.js";
45
+ import { etagMatches } from "./etag.js";
45
46
 
46
47
  /**
47
48
  * The prologue, in order. Exported as DATA so the pipeline can be asserted and
@@ -197,23 +198,6 @@ function isCompressibleContentType(contentType: string): boolean {
197
198
  return COMPRESSIBLE_PREFIXES.some((prefix) => contentType.includes(prefix));
198
199
  }
199
200
 
200
- /**
201
- * Match an If-None-Match header value against `etag` (RFC 7232 S3.2 weak
202
- * comparison): an optional W/ prefix is ignored on both sides, the header may
203
- * carry a comma-separated candidate list, and `*` matches any current
204
- * representation. Same algorithm as static.ts's own matcher (kept local here
205
- * rather than imported, since static.ts writes to the raw response directly
206
- * and never reaches this interceptor).
207
- */
208
- function etagMatchesInm(ifNoneMatch: string, etag: string): boolean {
209
- const strip = (tag: string) => tag.trim().replace(/^W\//, "");
210
- const target = strip(etag);
211
- return ifNoneMatch.split(",").some((candidate) => {
212
- const trimmed = candidate.trim();
213
- return trimmed === "*" || strip(trimmed) === target;
214
- });
215
- }
216
-
217
201
  function maybeCompressResponse(body: Buffer, rawReq: IncomingMessage, rawRes: ServerResponse): Buffer {
218
202
  const acceptEncoding = String(rawReq.headers["accept-encoding"] ?? "");
219
203
  const contentTypeHeader = rawRes.getHeader("content-type");
@@ -230,7 +214,7 @@ function maybeCompressResponse(body: Buffer, rawReq: IncomingMessage, rawRes: Se
230
214
 
231
215
  function responseIsNotModified(rawReq: IncomingMessage, rawRes: ServerResponse, etag: string): boolean {
232
216
  const ifNoneMatch = String(rawReq.headers["if-none-match"] ?? "");
233
- if (ifNoneMatch) return etagMatchesInm(ifNoneMatch, etag);
217
+ if (ifNoneMatch) return etagMatches(ifNoneMatch, etag);
234
218
  const lastModifiedHeader = rawRes.getHeader("last-modified");
235
219
  if (typeof lastModifiedHeader !== "string") return false;
236
220
  const ifModifiedSince = String(rawReq.headers["if-modified-since"] ?? "");