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
@@ -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 ? "▶" : " ";
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
  */
@@ -0,0 +1,24 @@
1
+ /*
2
+ Copyright (c) 2026 Code Infinity
3
+ SPDX-License-Identifier: MPL-2.0
4
+ This Source Code Form is subject to the terms of the Mozilla Public
5
+ License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+ */
8
+
9
+ /**
10
+ * Does an `If-None-Match` header match the response ETag?
11
+ *
12
+ * The header may be `*`, a single tag, or a comma-separated list; the `W/`
13
+ * weak prefix is stripped on both sides before comparing (RFC 7232 §2.3.2
14
+ * weak comparison). Shared by the static file server and the compression
15
+ * interceptor so both answer 304 the same way.
16
+ */
17
+ export function etagMatches(ifNoneMatch: string, etag: string): boolean {
18
+ const strip = (tag: string) => tag.trim().replace(/^W\//, "");
19
+ const target = strip(etag);
20
+ return ifNoneMatch.split(",").some((candidate) => {
21
+ const trimmed = candidate.trim();
22
+ return trimmed === "*" || strip(trimmed) === target;
23
+ });
24
+ }
@@ -36,6 +36,7 @@ import { fileURLToPath } from "node:url";
36
36
 
37
37
  import { authenticateRequest } from "./auth.js";
38
38
  import { supervisorBaseUrl } from "./devAdmin.js";
39
+ import { relayUpstreamResponse } from "./upstreamResponse.js";
39
40
  import type { RouteHandler, Tina4Request } from "./types.js";
40
41
  import type { Router } from "./router.js";
41
42
 
@@ -221,17 +222,7 @@ export const handleFeedbackTurn: RouteHandler = async (req, res) => {
221
222
  }
222
223
  clearTimeout(timer);
223
224
 
224
- const raw = await upstream.text();
225
- const status = upstream.status || 200;
226
- try {
227
- res.json(JSON.parse(raw), status);
228
- } catch {
229
- res.raw.writeHead(status, {
230
- "Content-Type":
231
- upstream.headers.get("content-type") ?? "text/plain; charset=utf-8",
232
- });
233
- res.raw.end(raw);
234
- }
225
+ await relayUpstreamResponse(res, upstream);
235
226
  };
236
227
 
237
228
  // Widget bundle lives at packages/core/src/__feedback/widget.js so that
@@ -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";
@@ -13,6 +13,7 @@ import { Log } from "./logger.js";
13
13
  import { isTruthy } from "./dotenv.js";
14
14
  import { defaultRouter, type Router } from "./router.js";
15
15
  import { resolveClientIp } from "./trustedProxy.js";
16
+ import { currentCspNonce, resolveCspHeader } from "./csp.js";
16
17
  import { getFrond, getFrameworkFrond, wantsJson, negotiatedErrorBody } from "./response.js";
17
18
 
18
19
  /**
@@ -951,12 +952,18 @@ export class SecurityHeadersMiddleware {
951
952
  * overrides, WITHOUT Strict-Transport-Security, which depends on the
952
953
  * request's scheme. Also what a transport rejection carries (ADR-0068
953
954
  * section 4), written before the scheme is known.
955
+ *
956
+ * Content-Security-Policy always carries a per-response nonce in style-src
957
+ * AND script-src (ADR-0088), read from the request async-context (minted on
958
+ * first access), so the framework's own inline content runs under the strict
959
+ * default without 'unsafe-inline'. The nonce is random per response, so a
960
+ * caller comparing this header must assert its STRUCTURE, never a byte string.
954
961
  */
955
962
  static canonicalHeaders(): Record<string, string> {
956
963
  return {
957
964
  "X-Frame-Options": process.env.TINA4_FRAME_OPTIONS ?? "SAMEORIGIN",
958
965
  "X-Content-Type-Options": "nosniff",
959
- "Content-Security-Policy": process.env.TINA4_CSP ?? "default-src 'self'",
966
+ "Content-Security-Policy": resolveCspHeader(currentCspNonce()),
960
967
  "Referrer-Policy": process.env.TINA4_REFERRER_POLICY ?? "strict-origin-when-cross-origin",
961
968
  "X-XSS-Protection": "0",
962
969
  "Permissions-Policy": process.env.TINA4_PERMISSIONS_POLICY ?? "camera=(), microphone=(), geolocation=()",
@@ -985,6 +992,10 @@ export class SecurityHeadersMiddleware {
985
992
  if (process.env.TINA4_CSP === undefined) {
986
993
  SecurityHeadersMiddleware.warnCspDefaultOnce();
987
994
  }
995
+ // Content-Security-Policy carries a per-response nonce in style-src AND
996
+ // script-src (ADR-0088), already resolved in canonical, so the framework's
997
+ // own inline <style>/<script> (and user templates using the Frond
998
+ // csp_nonce() global) run under the strict policy without 'unsafe-inline'.
988
999
  for (const name of ["Content-Security-Policy", "Referrer-Policy", "X-XSS-Protection", "Permissions-Policy"]) {
989
1000
  res.header(name, canonical[name]);
990
1001
  }
@@ -1012,11 +1023,14 @@ export class SecurityHeadersMiddleware {
1012
1023
  SecurityHeadersMiddleware.cspDefaultWarned = true;
1013
1024
  const message =
1014
1025
  "TINA4_CSP is not set, so Tina4 is serving the default Content-Security-Policy " +
1015
- "\"default-src 'self'\" on every response. That default blocks runtime-injected " +
1016
- "inline styles, cross-origin fonts/scripts/CDNs, data: URIs, and cross-origin " +
1017
- "WebSocket/XHR (e.g. a separate API or LiveKit host). If your app uses any of " +
1018
- "these, set TINA4_CSP to a policy that allows them (see https://tina4.com); to " +
1019
- "silence this notice without changing behaviour, set TINA4_CSP=\"default-src 'self'\".";
1026
+ "\"default-src 'self'\" on every response. The framework injects a per-response " +
1027
+ "nonce into style-src and script-src, so its own inline <style>/<script> (and " +
1028
+ "your templates using the csp_nonce() Frond global) work under this policy. The " +
1029
+ "default still blocks cross-origin fonts/scripts/CDNs, data: URIs, and " +
1030
+ "cross-origin WebSocket/XHR (e.g. a separate API or LiveKit host). If your app " +
1031
+ "uses any of these, set TINA4_CSP to a policy that allows them (see " +
1032
+ "https://tina4.com); to silence this notice without changing behaviour, set " +
1033
+ "TINA4_CSP=\"default-src 'self'\".";
1020
1034
  try {
1021
1035
  Log.warning(message);
1022
1036
  } catch {
@@ -11,6 +11,7 @@ import fs from "node:fs";
11
11
  import nodePath from "node:path";
12
12
  import { once } from "node:events";
13
13
  import type { Tina4Response, CookieOptions } from "./types.js";
14
+ import { currentCspNonce } from "./csp.js";
14
15
 
15
16
  /**
16
17
  * Best-effort close of a streaming source on client disconnect or a mid-stream
@@ -93,7 +94,8 @@ export async function getFrond(): Promise<InstanceType<any>> {
93
94
  const dir = _defaultTemplatesDir ?? nodePath.resolve(process.cwd(), "src/templates");
94
95
  let engine = _frondCache.get(dir);
95
96
  if (!engine) {
96
- const { Frond } = await import("../../frond/src/engine.js");
97
+ const { Frond, setCspNonceProvider } = await import("../../frond/src/engine.js");
98
+ setCspNonceProvider(currentCspNonce);
97
99
  engine = new Frond(dir);
98
100
  _frondCache.set(dir, engine);
99
101
  }
@@ -109,7 +111,8 @@ export async function getFrameworkFrond(): Promise<InstanceType<any> | null> {
109
111
  const frameworkDir = nodePath.resolve(nodePath.dirname(import.meta.url.replace("file://", "")), "..", "templates");
110
112
  if (!_frameworkFrond && fs.existsSync(frameworkDir)) {
111
113
  try {
112
- const { Frond } = await import("../../frond/src/engine.js");
114
+ const { Frond, setCspNonceProvider } = await import("../../frond/src/engine.js");
115
+ setCspNonceProvider(currentCspNonce);
113
116
  _frameworkFrond = new Frond(frameworkDir);
114
117
  } catch { return null; }
115
118
  }
@@ -262,6 +265,13 @@ export function createResponse(res: ServerResponse): Tina4Response {
262
265
  // ── Attach the underlying ServerResponse ──
263
266
  response.raw = res;
264
267
 
268
+ // ── Per-response CSP nonce (ADR-0088) ──
269
+ // Read the request-scoped nonce (csp.ts), minting one on first access. Inside
270
+ // a request this is the SAME value runDispatch() established and the security
271
+ // middleware names in the Content-Security-Policy header, so a route emitting
272
+ // an inline block as `<script nonce="${res.cspNonce}">` stays CSP-clean.
273
+ response.cspNonce = currentCspNonce();
274
+
265
275
  // ── Explicit methods ──
266
276
 
267
277
  response.json = function (data: unknown, status?: number): Tina4Response {
@@ -450,7 +460,8 @@ export function createResponse(res: ServerResponse): Tina4Response {
450
460
  templateDir?: string,
451
461
  ): Promise<Tina4Response> {
452
462
  try {
453
- const { Frond } = await import("../../frond/src/engine.js");
463
+ const { Frond, setCspNonceProvider } = await import("../../frond/src/engine.js");
464
+ setCspNonceProvider(currentCspNonce);
454
465
  const dir = templateDir ?? _defaultTemplatesDir ?? nodePath.resolve(process.cwd(), "src/templates");
455
466
  let engine = _frondCache.get(dir);
456
467
  if (!engine) {
@@ -42,6 +42,7 @@ import { MiddlewareChain, MiddlewareRunner, cors, requestLogger, isMiddlewareCla
42
42
  import { tryServeStatic } from "./static.js";
43
43
  import { loadEnv, isTruthy } from "./dotenv.js";
44
44
  import { isDebugMode } from "./errorOverlay.js";
45
+ import { generateNonce, runWithCspNonce, currentCspNonce } from "./csp.js";
45
46
  import { createHealthRoutes } from "./health.js";
46
47
  import { rateLimiter } from "./rateLimiter.js";
47
48
  import { Log } from "./logger.js";
@@ -493,7 +494,8 @@ async function renderErrorPage(
493
494
  templatesDir: string,
494
495
  ): Promise<string | null> {
495
496
  try {
496
- const { Frond } = await import("../../frond/src/engine.js");
497
+ const { Frond, setCspNonceProvider } = await import("../../frond/src/engine.js");
498
+ setCspNonceProvider(currentCspNonce);
497
499
  const templateFile = `errors/${code}.twig`;
498
500
 
499
501
  // Helper: get-or-create a cached Frond instance for a directory
@@ -706,6 +708,10 @@ export function resolveTemplate(pathname: string, templatesDir: string): string
706
708
 
707
709
  function renderLandingPage(port: number = 7148): string {
708
710
  const version = TINA4_VERSION;
711
+ // The framework's welcome page runs under the strict default CSP. Every inline
712
+ // <style>/<script> below carries this response's nonce (ADR-0088); there are no
713
+ // style= or onclick= attributes (a nonce covers an element, never an attribute).
714
+ const nonce = currentCspNonce();
709
715
 
710
716
  const galleryItems = [
711
717
  { id: "rest-api", icon: "&#128640;", name: "REST API", desc: "A simple JSON API with GET and POST endpoints", accent: "accent-blue", tryUrl: "/api/gallery/hello" },
@@ -729,7 +735,7 @@ function renderLandingPage(port: number = 7148): string {
729
735
  : "";
730
736
  const deployBtn = isDeployed
731
737
  ? `<span class="gbtn gbtn-deployed">Deployed</span>`
732
- : `<button class="gbtn gbtn-deploy" onclick="deployGallery('${item.id}')">Deploy</button>`;
738
+ : `<button class="gbtn gbtn-deploy" data-deploy="${item.id}">Deploy</button>`;
733
739
  return `<div class="gallery-card">
734
740
  <div class="accent ${item.accent}"></div>
735
741
  <div class="icon">${item.icon}</div>
@@ -745,7 +751,7 @@ function renderLandingPage(port: number = 7148): string {
745
751
  <meta charset="utf-8">
746
752
  <meta name="viewport" content="width=device-width, initial-scale=1">
747
753
  <title>Tina4NodeJs</title>
748
- <style>
754
+ <style nonce="${nonce}">
749
755
  *{margin:0;padding:0;box-sizing:border-box}
750
756
  body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#0f172a;color:#e2e8f0;min-height:100vh;display:flex;flex-direction:column;align-items:center;position:relative}
751
757
  .bg-watermark{position:fixed;bottom:-5%;right:-5%;width:45%;opacity:0.04;pointer-events:none;z-index:0}
@@ -784,6 +790,11 @@ h1{font-size:3rem;font-weight:700;margin-bottom:0.25rem;letter-spacing:-1px}
784
790
  .gbtn-deployed{background:transparent;border:1px solid #22c55e;color:#22c55e;cursor:default;font-size:0.7rem}
785
791
  @keyframes wiggle{0%{transform:rotate(0deg)}15%{transform:rotate(14deg)}30%{transform:rotate(-10deg)}45%{transform:rotate(8deg)}60%{transform:rotate(-4deg)}75%{transform:rotate(2deg)}100%{transform:rotate(0deg)}}
786
792
  .star-wiggle{display:inline-block;transform-origin:center}
793
+ .gallery-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:1rem}
794
+ .tok-comment{color:#64748b}
795
+ .tok-kw{color:#c084fc}
796
+ .tok-str{color:#4ade80}
797
+ .tok-fn{color:#38bdf8}
787
798
  </style>
788
799
  </head>
789
800
  <body>
@@ -809,23 +820,23 @@ h1{font-size:3rem;font-weight:700;margin-bottom:0.25rem;letter-spacing:-1px}
809
820
  <div class="section">
810
821
  <div class="card">
811
822
  <h2>Getting Started</h2>
812
- <pre class="code-block"><code><span style="color:#64748b">// app.ts</span>
813
- <span style="color:#c084fc">import</span> { startServer, Router } <span style="color:#c084fc">from</span> <span style="color:#4ade80">"tina4-nodejs"</span>;
823
+ <pre class="code-block"><code><span class="tok-comment">// app.ts</span>
824
+ <span class="tok-kw">import</span> { startServer, Router } <span class="tok-kw">from</span> <span class="tok-str">"tina4-nodejs"</span>;
814
825
 
815
- Router.get(<span style="color:#4ade80">"/hello"</span>, <span style="color:#c084fc">async</span> (<span style="color:#38bdf8">req</span>, <span style="color:#38bdf8">res</span>) =&gt; {
816
- <span style="color:#c084fc">return</span> res.json({ message: <span style="color:#4ade80">"Hello World!"</span> });
826
+ Router.get(<span class="tok-str">"/hello"</span>, <span class="tok-kw">async</span> (<span class="tok-fn">req</span>, <span class="tok-fn">res</span>) =&gt; {
827
+ <span class="tok-kw">return</span> res.json({ message: <span class="tok-str">"Hello World!"</span> });
817
828
  });
818
829
 
819
- startServer({ port: 7148 }); <span style="color:#64748b">// starts on port 7148</span></code></pre>
830
+ startServer({ port: 7148 }); <span class="tok-comment">// starts on port 7148</span></code></pre>
820
831
  </div>
821
832
  </div>
822
833
  <div class="gallery">
823
834
  <h2 id="gallery">What You Can Build</h2>
824
- <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:1rem;">
835
+ <div class="gallery-cards">
825
836
  ${galleryCards}
826
837
  </div>
827
838
  </div>
828
- <script>
839
+ <script nonce="${nonce}">
829
840
  function deployGallery(name) {
830
841
  if (!confirm('Deploy the "' + name + '" gallery example into your project? This will copy files into src/.')) return;
831
842
  fetch('/__dev/api/gallery/deploy', {
@@ -860,6 +871,12 @@ function deployGallery(name) {
860
871
  }
861
872
  setTimeout(doWiggle,3000);
862
873
  })();
874
+ // CSP-clean wiring: no inline onclick handlers — bind every Deploy button from
875
+ // its data-deploy attribute (a nonce covers script elements, not event-handler
876
+ // attributes).
877
+ document.querySelectorAll('.gbtn-deploy[data-deploy]').forEach(function(btn){
878
+ btn.addEventListener('click', function(){ deployGallery(btn.dataset.deploy); });
879
+ });
863
880
  </script>
864
881
  </body>
865
882
  </html>`;
@@ -1685,7 +1702,8 @@ export async function buildDispatchContext(router: Router, base?: string): Promi
1685
1702
 
1686
1703
  let frondEngine: DispatchContext["frondEngine"] = null;
1687
1704
  try {
1688
- const { Frond } = await import("../../frond/src/engine.js");
1705
+ const { Frond, setCspNonceProvider } = await import("../../frond/src/engine.js");
1706
+ setCspNonceProvider(currentCspNonce);
1689
1707
  frondEngine = new Frond(templatesDir);
1690
1708
  } catch {
1691
1709
  // Frond not available — template-route fallback stays inert, same guard startServer() uses.
@@ -1891,7 +1909,14 @@ export async function runDispatch(
1891
1909
  // requests interleaving on the one event loop never read each other's id.
1892
1910
  const requestId = Log.sanitizeRequestId(rawReq.headers["x-request-id"]) ?? randomBytes(4).toString("hex");
1893
1911
  if (!rawRes.headersSent) rawRes.setHeader("x-request-id", requestId);
1894
- return Log.runWithRequestId(requestId, () => dispatchInner(ctx, rawReq, rawRes, requestId));
1912
+ // One CSP nonce per request (ADR-0088), established in the SAME async scope as
1913
+ // the request id so the inline HTML body, the Frond csp_nonce() global, and the
1914
+ // security middleware's Content-Security-Policy header all name one value - and
1915
+ // two requests interleaving on the one event loop never share it.
1916
+ const requestNonce = generateNonce();
1917
+ return Log.runWithRequestId(requestId, () =>
1918
+ runWithCspNonce(requestNonce, () => dispatchInner(ctx, rawReq, rawRes, requestId)),
1919
+ );
1895
1920
  }
1896
1921
 
1897
1922
  /**
@@ -2128,7 +2153,8 @@ export async function startServer(config?: Tina4Config): Promise<{
2128
2153
  let frondEngine: any = null;
2129
2154
  setDefaultTemplatesDir(templatesDir);
2130
2155
  try {
2131
- const { Frond } = await import("../../frond/src/engine.js");
2156
+ const { Frond, setCspNonceProvider } = await import("../../frond/src/engine.js");
2157
+ setCspNonceProvider(currentCspNonce);
2132
2158
  frondEngine = new Frond(templatesDir);
2133
2159
 
2134
2160
  // Always-on Frond {% live %} refresh endpoint. Re-renders a server-rendered
@@ -2367,10 +2393,21 @@ export async function startServer(config?: Tina4Config): Promise<{
2367
2393
  });
2368
2394
 
2369
2395
  aiServer.on("error", (err: any) => {
2370
- if (err.code === "EADDRINUSE") {
2396
+ // The auxiliary AI/test port is a debug convenience, never
2397
+ // load-bearing: ANY bind failure must degrade to no-aux-port, logged
2398
+ // LOUD, never silent. The old handler logged only EADDRINUSE and
2399
+ // silently swallowed every other code (EADDRNOTAVAIL, EACCES, ...),
2400
+ // leaving `aiServer` a dangling non-listening object. The 'error'
2401
+ // listener's presence already stops Node throwing, so the main server
2402
+ // never crashes; this just makes the degrade visible and resets the
2403
+ // handle on every failure. (The synchronous ERR_SOCKET_BAD_PORT for
2404
+ // an out-of-range derived port is handled separately by aiPortInRange.)
2405
+ if (err?.code === "EADDRINUSE") {
2371
2406
  Log.warning(`Test port ${testPort} in use — skipping`);
2372
- aiServer = null;
2407
+ } else {
2408
+ Log.error(`Test port ${testPort} bind failed (${err?.code ?? err}) — skipping`);
2373
2409
  }
2410
+ aiServer = null;
2374
2411
  });
2375
2412
 
2376
2413
  aiServer.listen(testPort, host);