tina4-nodejs 3.13.142 → 3.13.144
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +28 -2
- package/package.json +1 -1
- package/packages/cli/dist/bin.js +379 -85
- package/packages/cli/src/commands/generate.ts +7 -2
- package/packages/cli/src/commands/init.ts +18 -8
- package/packages/core/dist/index.js +377 -78
- package/packages/core/gallery/auth/src/routes/gallery/auth/get.ts +27 -11
- 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/errorOverlay.ts +80 -37
- 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 +154 -7
- package/packages/core/src/sso.ts +1 -1
- package/packages/core/src/types.ts +8 -0
- 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 +378 -87
- package/packages/swagger/dist/index.js +4 -4
- package/packages/swagger/src/ui.ts +7 -4
- package/types/core/src/csp.d.ts +42 -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 +62 -2
- package/types/core/src/types.d.ts +8 -0
- package/types/frond/src/engine.d.ts +7 -0
|
@@ -10,14 +10,28 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
|
10
10
|
import type { Tina4Request, Tina4Response } from "tina4-nodejs";
|
|
11
11
|
|
|
12
12
|
export default async function (_req: Tina4Request, res: Tina4Response) {
|
|
13
|
+
// CSP-clean under the strict default policy (ADR-0088): one nonce'd <style>
|
|
14
|
+
// holds the rules, no style= attributes, and the buttons bind via
|
|
15
|
+
// addEventListener instead of inline onclick= (a nonce covers an element,
|
|
16
|
+
// never an attribute). res.cspNonce matches the Content-Security-Policy header.
|
|
17
|
+
const nonce = res.cspNonce;
|
|
13
18
|
const html = `<!DOCTYPE html>
|
|
14
19
|
<html lang="en">
|
|
15
20
|
<head>
|
|
16
21
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
17
22
|
<title>Auth Demo</title><link rel="stylesheet" href="/css/tina4.min.css">
|
|
23
|
+
<style nonce="${nonce}">
|
|
24
|
+
.auth-container { max-width: 600px; }
|
|
25
|
+
.auth-hidden { display: none; }
|
|
26
|
+
.token-box { word-break: break-all; white-space: pre-wrap; color: #4ade80; background: #1e293b; padding: 1rem; border-radius: 0.5rem; }
|
|
27
|
+
.payload-box { color: #38bdf8; background: #1e293b; padding: 1rem; border-radius: 0.5rem; }
|
|
28
|
+
.auth-info-card { border: 1px solid #334155; }
|
|
29
|
+
.auth-info-title { color: #e2e8f0; }
|
|
30
|
+
.auth-howto { background: #0f172a; color: #4ade80; padding: 1rem; border-radius: 0.5rem; font-size: 0.8rem; }
|
|
31
|
+
</style>
|
|
18
32
|
</head>
|
|
19
33
|
<body class="bg-dark text-light">
|
|
20
|
-
<div class="container mt-5
|
|
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"
|
|
47
|
+
<button class="btn btn-primary" data-action="login">Login</button>
|
|
34
48
|
</div>
|
|
35
49
|
</div>
|
|
36
|
-
<div id="result"
|
|
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"
|
|
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"
|
|
60
|
+
<pre id="payload" class="payload-box"></pre>
|
|
47
61
|
</div>
|
|
48
62
|
</div>
|
|
49
|
-
<button class="btn btn-outline-info"
|
|
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
|
|
66
|
+
<div class="card bg-dark mt-4 auth-info-card">
|
|
53
67
|
<div class="card-body">
|
|
54
|
-
<h6
|
|
55
|
-
<pre
|
|
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').
|
|
98
|
+
document.getElementById('result').classList.remove('auth-hidden');
|
|
85
99
|
document.getElementById('verify-result').textContent = '';
|
|
86
100
|
} else {
|
|
87
101
|
alert(d.error || 'Login failed');
|
|
@@ -99,6 +113,8 @@ function verifyToken() {
|
|
|
99
113
|
}
|
|
100
114
|
});
|
|
101
115
|
}
|
|
116
|
+
document.querySelectorAll('[data-action="login"]').forEach(function(b){ b.addEventListener('click', doLogin); });
|
|
117
|
+
document.querySelectorAll('[data-action="verify"]').forEach(function(b){ b.addEventListener('click', verifyToken); });
|
|
102
118
|
</script>
|
|
103
119
|
</body></html>`;
|
|
104
120
|
return res.html(html);
|
|
@@ -10,6 +10,10 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
|
10
10
|
import type { Tina4Request, Tina4Response } from "tina4-nodejs";
|
|
11
11
|
|
|
12
12
|
export default async function (_req: Tina4Request, res: Tina4Response) {
|
|
13
|
+
// CSP-clean under the strict default policy (ADR-0088): the <script> carries
|
|
14
|
+
// this response's nonce and every button binds via addEventListener instead of
|
|
15
|
+
// inline onclick= (a nonce covers a script element, never an attribute).
|
|
16
|
+
const nonce = res.cspNonce;
|
|
13
17
|
const html = `<!DOCTYPE html>
|
|
14
18
|
<html lang="en">
|
|
15
19
|
<head>
|
|
@@ -30,7 +34,7 @@ export default async function (_req: Tina4Request, res: Tina4Response) {
|
|
|
30
34
|
<div class="card-body">
|
|
31
35
|
<div class="d-flex gap-2">
|
|
32
36
|
<input type="text" id="msgInput" class="form-control" placeholder="Enter a task message, e.g. send-email">
|
|
33
|
-
<button class="btn btn-primary"
|
|
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"
|
|
43
|
-
<button class="btn btn-danger"
|
|
44
|
-
<button class="btn btn-warning"
|
|
45
|
-
<button class="btn btn-secondary"
|
|
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
|
|
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
|
|
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
|
|
229
|
-
<div class="progress mb-2"><div class="progress-bar bg-success
|
|
230
|
-
<div class="progress mb-2"><div class="progress-bar bg-warning
|
|
231
|
-
<div class="progress"><div class="progress-bar bg-danger
|
|
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
|
|
259
|
+
<div class="card mt-4 mb-5 build-card">
|
|
247
260
|
<div class="card-body">
|
|
248
|
-
<h5
|
|
249
|
-
<pre
|
|
250
|
-
<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
|
|
253
|
-
<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
|
|
256
|
-
Rendered via <code
|
|
257
|
-
All components above use only <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"><link href="/css/tina4.min.css"></code>.
|
|
258
271
|
</p>
|
|
259
272
|
</div>
|
|
260
273
|
</div>
|
|
261
274
|
|
|
262
275
|
</div>
|
|
263
276
|
|
|
277
|
+
<script nonce="{{ csp_nonce() }}">
|
|
278
|
+
document.querySelectorAll('.js-dismiss').forEach(function(b){
|
|
279
|
+
b.addEventListener('click', function(){ this.parentElement.remove(); });
|
|
280
|
+
});
|
|
281
|
+
</script>
|
|
282
|
+
|
|
264
283
|
</body>
|
|
265
284
|
</html>
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Copyright (c) 2026 Code Infinity
|
|
2
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
3
|
+
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
4
|
+
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
5
|
+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Per-response Content-Security-Policy nonce (ADR-0088).
|
|
9
|
+
*
|
|
10
|
+
* The framework serves a strict default CSP (`default-src 'self'`). A browser
|
|
11
|
+
* refuses every inline `<style>` and `<script>` under that policy unless the
|
|
12
|
+
* element carries a nonce that the `Content-Security-Policy` header also names.
|
|
13
|
+
* So the framework mints one cryptographically-random nonce per response, stamps
|
|
14
|
+
* it on every inline `<style>`/`<script>` it emits, and injects the matching
|
|
15
|
+
* `'nonce-<X>'` into the `style-src` and `script-src` directives of the CSP
|
|
16
|
+
* header. The same value reaches user templates through the Frond global
|
|
17
|
+
* `csp_nonce()` and routes through `response.cspNonce`.
|
|
18
|
+
*
|
|
19
|
+
* The nonce lives in an `AsyncLocalStorage`, the same Node adaptation of a
|
|
20
|
+
* per-request contextvar the logger uses for the request id (Decision 12).
|
|
21
|
+
* `runDispatch()` establishes a fresh nonce for the whole request, so the inline
|
|
22
|
+
* HTML body and the security middleware both read the SAME value across every
|
|
23
|
+
* await, and two requests interleaving on the one event loop never read each
|
|
24
|
+
* other's. Whoever touches the nonce first in a request - the body emitter or the
|
|
25
|
+
* header builder - gets the same value, because `currentCspNonce()` mints one on
|
|
26
|
+
* first access and caches it for the rest of the request.
|
|
27
|
+
*/
|
|
28
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
29
|
+
import { randomBytes } from "node:crypto";
|
|
30
|
+
|
|
31
|
+
// The per-request nonce. `undefined` until a request (or the first emitter /
|
|
32
|
+
// header call) sets one. A fallback covers code that runs outside any request
|
|
33
|
+
// scope (a script, a test), exactly as the logger's request-id fallback does.
|
|
34
|
+
const nonceStore = new AsyncLocalStorage<{ nonce: string | undefined }>();
|
|
35
|
+
let nonceFallback: string | undefined;
|
|
36
|
+
|
|
37
|
+
/** Return a fresh cryptographically-random nonce (128 bits, base64). */
|
|
38
|
+
export function generateNonce(): string {
|
|
39
|
+
return randomBytes(16).toString("base64");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Run `fn` with `nonce` established as the request-scoped CSP nonce. */
|
|
43
|
+
export function runWithCspNonce<T>(nonce: string, fn: () => T): T {
|
|
44
|
+
return nonceStore.run({ nonce }, fn);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Set the nonce for the current request context (mirrors Log.setRequestId). */
|
|
48
|
+
export function setCurrentNonce(nonce: string | undefined): void {
|
|
49
|
+
const store = nonceStore.getStore();
|
|
50
|
+
if (store) store.nonce = nonce;
|
|
51
|
+
else nonceFallback = nonce;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Clear the nonce (mirrors Log.clearRequestId); the scope ends on its own. */
|
|
55
|
+
export function clearCurrentNonce(): void {
|
|
56
|
+
setCurrentNonce(undefined);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Return the current request's nonce, minting one on first access.
|
|
61
|
+
*
|
|
62
|
+
* Generate-on-first-access makes the value independent of ordering: whether the
|
|
63
|
+
* inline body or the CSP header is built first, both read the same nonce.
|
|
64
|
+
*/
|
|
65
|
+
export function currentCspNonce(): string {
|
|
66
|
+
const store = nonceStore.getStore();
|
|
67
|
+
let value = store ? store.nonce : nonceFallback;
|
|
68
|
+
if (!value) {
|
|
69
|
+
value = generateNonce();
|
|
70
|
+
if (store) store.nonce = value;
|
|
71
|
+
else nonceFallback = value;
|
|
72
|
+
}
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Frond global `csp_nonce()`: the current response's CSP nonce.
|
|
78
|
+
*
|
|
79
|
+
* Use it on any inline element a template emits:
|
|
80
|
+
*
|
|
81
|
+
* <style nonce="{{ csp_nonce() }}"> ... </style>
|
|
82
|
+
* <script nonce="{{ csp_nonce() }}"> ... </script>
|
|
83
|
+
*/
|
|
84
|
+
export function cspNonce(): string {
|
|
85
|
+
return currentCspNonce();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Return `csp` with `'nonce-<nonce>'` present in style-src AND script-src.
|
|
90
|
+
*
|
|
91
|
+
* For the default `default-src 'self'` this yields
|
|
92
|
+
* `default-src 'self'; style-src 'self' 'nonce-X'; script-src 'self' 'nonce-X'`.
|
|
93
|
+
* When a directive is absent it is derived from `default-src` (falling back to
|
|
94
|
+
* `'self'`) so the framework's own nonce'd content always works; when present
|
|
95
|
+
* the nonce is appended (idempotently). Every other directive is kept, in order.
|
|
96
|
+
*/
|
|
97
|
+
export function injectNonceIntoCsp(csp: string, nonce: string): string {
|
|
98
|
+
const token = `'nonce-${nonce}'`;
|
|
99
|
+
const directives: Array<[string, string]> = [];
|
|
100
|
+
for (const rawPart of csp.split(";")) {
|
|
101
|
+
const part = rawPart.trim();
|
|
102
|
+
if (!part) continue;
|
|
103
|
+
const spaceAt = part.search(/\s/);
|
|
104
|
+
const name = (spaceAt === -1 ? part : part.slice(0, spaceAt)).toLowerCase();
|
|
105
|
+
const value = spaceAt === -1 ? "" : part.slice(spaceAt + 1).trim();
|
|
106
|
+
directives.push([name, value]);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const defaultEntry = directives.find(([name]) => name === "default-src");
|
|
110
|
+
const defaultValue = defaultEntry && defaultEntry[1] ? defaultEntry[1] : "'self'";
|
|
111
|
+
|
|
112
|
+
for (const directive of ["style-src", "script-src"]) {
|
|
113
|
+
const existing = directives.find(([name]) => name === directive);
|
|
114
|
+
if (existing) {
|
|
115
|
+
if (!existing[1].split(/\s+/).includes(token)) {
|
|
116
|
+
existing[1] = `${existing[1]} ${token}`.trim();
|
|
117
|
+
}
|
|
118
|
+
} else {
|
|
119
|
+
directives.push([directive, `${defaultValue} ${token}`.trim()]);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return directives
|
|
124
|
+
.map(([name, value]) => (value ? `${name} ${value}` : name))
|
|
125
|
+
.join("; ");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Build the CSP header value for `nonce` from the environment.
|
|
130
|
+
*
|
|
131
|
+
* Honours `TINA4_CSP` (default `default-src 'self'`) and always injects the
|
|
132
|
+
* nonce into style-src and script-src. An empty `TINA4_CSP` is treated as the
|
|
133
|
+
* default policy, matching how the warn-once gate reads "unset".
|
|
134
|
+
*/
|
|
135
|
+
export function resolveCspHeader(nonce: string): string {
|
|
136
|
+
const csp = process.env.TINA4_CSP || "default-src 'self'";
|
|
137
|
+
return injectNonceIntoCsp(csp, nonce);
|
|
138
|
+
}
|
|
@@ -34,6 +34,7 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
|
34
34
|
import { readFileSync, statSync } from "node:fs";
|
|
35
35
|
import { resolve } from "node:path";
|
|
36
36
|
import { isTruthy } from "./dotenv.js";
|
|
37
|
+
import { currentCspNonce } from "./csp.js";
|
|
37
38
|
|
|
38
39
|
// OVERLAY-DEC-03: cap the rendered frames so a deep/recursive stack yields a bounded
|
|
39
40
|
// page, not one source-file read per frame.
|
|
@@ -124,18 +125,16 @@ function formatSourceBlock(filename: string, lineno: number): string {
|
|
|
124
125
|
if (lines.length === 0) return "";
|
|
125
126
|
|
|
126
127
|
const rows = lines.map(([num, text, isError]) => {
|
|
127
|
-
const
|
|
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
|
*/
|
|
@@ -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";
|