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.
- package/CLAUDE.md +28 -2
- package/package.json +1 -1
- package/packages/cli/dist/bin.js +817 -538
- package/packages/cli/src/bin.ts +4 -14
- package/packages/cli/src/commands/build.ts +2 -18
- package/packages/cli/src/commands/generate.ts +9 -24
- package/packages/cli/src/commands/init.ts +18 -8
- package/packages/cli/src/commands/lint.ts +3 -13
- package/packages/cli/src/commands/migrate.ts +2 -6
- package/packages/cli/src/commands/migrateRollback.ts +2 -6
- package/packages/cli/src/commands/migrateStatus.ts +2 -6
- package/packages/cli/src/commands/queue.ts +2 -30
- package/packages/cli/src/util.ts +90 -0
- package/packages/core/dist/index.js +592 -262
- package/packages/core/gallery/auth/src/routes/gallery/auth/get.ts +27 -11
- package/packages/core/gallery/queue/src/lib/queueDb.ts +11 -0
- package/packages/core/gallery/queue/src/routes/api/gallery/queue/consume/post.ts +2 -5
- package/packages/core/gallery/queue/src/routes/api/gallery/queue/fail/post.ts +2 -5
- package/packages/core/gallery/queue/src/routes/gallery/queue/get.ts +20 -7
- package/packages/core/gallery/templates/src/templates/gallery_page.twig +34 -15
- package/packages/core/src/csp.ts +138 -0
- package/packages/core/src/devAdmin.ts +3 -12
- package/packages/core/src/dispatchPipeline.ts +2 -18
- package/packages/core/src/errorOverlay.ts +80 -37
- package/packages/core/src/etag.ts +24 -0
- package/packages/core/src/feedback.ts +2 -11
- package/packages/core/src/index.ts +10 -0
- package/packages/core/src/middleware.ts +20 -6
- package/packages/core/src/response.ts +14 -3
- package/packages/core/src/server.ts +52 -15
- package/packages/core/src/session.ts +174 -71
- package/packages/core/src/sessionHandlers/respSessionHandler.ts +94 -0
- package/packages/core/src/sessionHandlers/valkeyHandler.ts +22 -73
- package/packages/core/src/sso.ts +1 -1
- package/packages/core/src/static.ts +1 -13
- package/packages/core/src/types.ts +8 -0
- package/packages/core/src/upstreamResponse.ts +31 -0
- package/packages/core/src/websocket.ts +48 -7
- package/packages/core/templates/errors/302.twig +1 -1
- package/packages/core/templates/errors/401.twig +2 -2
- package/packages/core/templates/errors/403.twig +1 -1
- package/packages/core/templates/errors/404.twig +1 -1
- package/packages/core/templates/errors/500.twig +1 -1
- package/packages/core/templates/errors/502.twig +2 -2
- package/packages/core/templates/errors/503.twig +2 -2
- package/packages/core/templates/errors/base.twig +4 -1
- package/packages/frond/dist/index.js +2 -0
- package/packages/frond/src/engine.ts +23 -0
- package/packages/orm/dist/index.js +593 -271
- package/packages/orm/src/graph/adapters/bolt.ts +8 -29
- package/packages/orm/src/graph/adapters/rowMapping.ts +57 -0
- package/packages/orm/src/graph/adapters/ultipa.ts +8 -30
- package/packages/swagger/dist/index.js +4 -4
- package/packages/swagger/src/ui.ts +7 -4
- package/types/cli/src/util.d.ts +28 -0
- package/types/core/src/csp.d.ts +42 -0
- package/types/core/src/etag.d.ts +9 -0
- package/types/core/src/index.d.ts +1 -0
- package/types/core/src/middleware.d.ts +6 -0
- package/types/core/src/session.d.ts +64 -21
- package/types/core/src/sessionHandlers/respSessionHandler.d.ts +52 -0
- package/types/core/src/sessionHandlers/valkeyHandler.d.ts +2 -26
- package/types/core/src/types.d.ts +8 -0
- package/types/core/src/upstreamResponse.d.ts +10 -0
- package/types/core/src/websocket.d.ts +21 -0
- package/types/frond/src/engine.d.ts +7 -0
- 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
|
|
128
|
+
const rowClass = isError ? "eo-line eo-line-err" : "eo-line";
|
|
128
129
|
const marker = isError ? "▶" : " ";
|
|
129
|
-
return `<div
|
|
130
|
-
+ `<span
|
|
131
|
-
+ `<span
|
|
132
|
-
+ `<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
|
|
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
|
|
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 — 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
|
|
174
|
-
+ `<div
|
|
175
|
-
+ `<span
|
|
176
|
-
+ `<span
|
|
177
|
-
+ `<span
|
|
178
|
-
+ `<span
|
|
179
|
-
+ `<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
|
|
189
|
-
+ `<summary
|
|
190
|
-
+
|
|
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
|
|
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
|
|
200
|
-
+ `<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
|
|
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
|
|
232
|
+
framesHtml += `<div class="eo-hidden-frames">`
|
|
236
233
|
+ `… ${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
|
-
|
|
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
|
|
293
|
-
<div
|
|
294
|
-
<div
|
|
295
|
-
<span
|
|
296
|
-
<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
|
|
299
|
-
<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
|
|
304
|
+
<div class="eo-footer">
|
|
305
305
|
Tina4 Debug Overlay — 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
|
-
|
|
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":
|
|
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.
|
|
1016
|
-
"
|
|
1017
|
-
"
|
|
1018
|
-
"
|
|
1019
|
-
"
|
|
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: "🚀", 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"
|
|
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
|
|
813
|
-
<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
|
|
816
|
-
<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>) => {
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|