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
|
@@ -321,6 +321,33 @@ export function parseFrame(
|
|
|
321
321
|
return { fin: !!fin, opcode, payload: Buffer.from(payload), bytesConsumed: offset + payloadLen };
|
|
322
322
|
}
|
|
323
323
|
|
|
324
|
+
/**
|
|
325
|
+
* Reassembles fragmented messages (RFC 6455 section 5.4). A browser sends a large message - a pasted image in a chat,
|
|
326
|
+
* base64 inside JSON - as a TEXT or BINARY frame with FIN clear, then CONTINUATION frames; control frames may arrive
|
|
327
|
+
* between the pieces. Returns a complete message when one is done, a control frame as it comes, and null while a
|
|
328
|
+
* message is still arriving. A CONTINUATION with no message started is dropped. Mirrors the Python master
|
|
329
|
+
* (tina4_python.websocket _handle_frame). One per connection.
|
|
330
|
+
*/
|
|
331
|
+
export class MessageAssembler {
|
|
332
|
+
private parts: Buffer[] = [];
|
|
333
|
+
private opcode = 0;
|
|
334
|
+
|
|
335
|
+
push(frame: { fin: boolean; opcode: number; payload: Buffer }): { opcode: number; payload: Buffer } | null {
|
|
336
|
+
if (frame.opcode >= 0x8) return { opcode: frame.opcode, payload: frame.payload }; // control frames are never fragmented
|
|
337
|
+
if (frame.opcode === OP_CONTINUATION) {
|
|
338
|
+
if (!this.opcode) return null;
|
|
339
|
+
this.parts.push(frame.payload);
|
|
340
|
+
if (!frame.fin) return null;
|
|
341
|
+
const whole = { opcode: this.opcode, payload: Buffer.concat(this.parts) };
|
|
342
|
+
this.parts = []; this.opcode = 0;
|
|
343
|
+
return whole;
|
|
344
|
+
}
|
|
345
|
+
if (frame.fin) { this.parts = []; this.opcode = 0; return { opcode: frame.opcode, payload: frame.payload }; }
|
|
346
|
+
this.opcode = frame.opcode; this.parts = [frame.payload];
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
324
351
|
// ── WebSocket Server ─────────────────────────────────────────
|
|
325
352
|
|
|
326
353
|
export class WebSocketServer {
|
|
@@ -854,6 +881,13 @@ export class WebSocketServer {
|
|
|
854
881
|
});
|
|
855
882
|
}
|
|
856
883
|
|
|
884
|
+
private readonly assemblers = new WeakMap<WebSocketClient, MessageAssembler>();
|
|
885
|
+
private assemblerFor(client: WebSocketClient): MessageAssembler {
|
|
886
|
+
let a = this.assemblers.get(client);
|
|
887
|
+
if (!a) { a = new MessageAssembler(); this.assemblers.set(client, a); }
|
|
888
|
+
return a;
|
|
889
|
+
}
|
|
890
|
+
|
|
857
891
|
private processBuffer(
|
|
858
892
|
client: WebSocketClient,
|
|
859
893
|
buffer: Buffer,
|
|
@@ -868,17 +902,20 @@ export class WebSocketServer {
|
|
|
868
902
|
remaining = remaining.subarray(frame.bytesConsumed);
|
|
869
903
|
client.lastActivity = Date.now(); // mark activity for the idle reaper
|
|
870
904
|
|
|
871
|
-
|
|
905
|
+
// a fragmented message is delivered once, whole (MessageAssembler)
|
|
906
|
+
const whole = this.assemblerFor(client).push(frame);
|
|
907
|
+
if (!whole) continue;
|
|
908
|
+
switch (whole.opcode) {
|
|
872
909
|
case OP_TEXT:
|
|
873
|
-
this.emit("message", client,
|
|
910
|
+
this.emit("message", client, whole.payload.toString("utf-8"));
|
|
874
911
|
break;
|
|
875
912
|
|
|
876
913
|
case OP_BINARY:
|
|
877
|
-
this.emit("message", client,
|
|
914
|
+
this.emit("message", client, whole.payload);
|
|
878
915
|
break;
|
|
879
916
|
|
|
880
917
|
case OP_PING: {
|
|
881
|
-
const pongFrame = buildFrame(OP_PONG,
|
|
918
|
+
const pongFrame = buildFrame(OP_PONG, whole.payload);
|
|
882
919
|
try {
|
|
883
920
|
client.socket.write(pongFrame);
|
|
884
921
|
} catch {
|
|
@@ -1243,15 +1280,19 @@ export function serveWebSocketRoute(req: IncomingMessage, socket: Socket, head:
|
|
|
1243
1280
|
.finally(() => wsRouteManager.remove(conn.id));
|
|
1244
1281
|
};
|
|
1245
1282
|
|
|
1283
|
+
const assembler = new MessageAssembler();
|
|
1246
1284
|
socket.on("data", (chunk: Buffer) => {
|
|
1247
1285
|
buffer = Buffer.concat([buffer, chunk]);
|
|
1248
1286
|
while (buffer.length > 0) {
|
|
1249
1287
|
const frame = parseFrame(buffer);
|
|
1250
1288
|
if (!frame) break;
|
|
1251
1289
|
buffer = buffer.subarray(frame.bytesConsumed);
|
|
1252
|
-
|
|
1290
|
+
// a fragmented message is delivered once, whole (MessageAssembler)
|
|
1291
|
+
const whole = assembler.push(frame);
|
|
1292
|
+
if (!whole) continue;
|
|
1293
|
+
switch (whole.opcode) {
|
|
1253
1294
|
case OP_TEXT: {
|
|
1254
|
-
const text =
|
|
1295
|
+
const text = whole.payload.toString("utf-8");
|
|
1255
1296
|
void Promise.resolve()
|
|
1256
1297
|
.then(() => (conn._onMessage ? conn._onMessage(text) : handler(conn, "message", text)))
|
|
1257
1298
|
.catch((e) => Log.error(`WebSocket message handler error: ${(e as Error).message}`));
|
|
@@ -1259,7 +1300,7 @@ export function serveWebSocketRoute(req: IncomingMessage, socket: Socket, head:
|
|
|
1259
1300
|
}
|
|
1260
1301
|
case OP_PING:
|
|
1261
1302
|
try {
|
|
1262
|
-
socket.write(buildFrame(OP_PONG,
|
|
1303
|
+
socket.write(buildFrame(OP_PONG, whole.payload));
|
|
1263
1304
|
} catch {
|
|
1264
1305
|
/* client gone */
|
|
1265
1306
|
}
|
|
@@ -17,6 +17,6 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
|
17
17
|
<div class="spinner"></div>
|
|
18
18
|
<div class="error-title">Redirecting…</div>
|
|
19
19
|
<div class="error-msg">You are being redirected to a new location.</div>
|
|
20
|
-
<div class="error-path
|
|
20
|
+
<div class="error-path c-primary">{{ redirect_url }}</div>
|
|
21
21
|
<a href="{{ redirect_url }}" class="error-home">Click here if not redirected</a>
|
|
22
22
|
{% endblock %}
|
|
@@ -9,9 +9,9 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
|
9
9
|
{% extends "errors/base.twig" %}
|
|
10
10
|
{% block title %}401 — Unauthorized{% endblock %}
|
|
11
11
|
{% block content %}
|
|
12
|
-
<div class="error-code
|
|
12
|
+
<div class="error-code c-danger">401</div>
|
|
13
13
|
<div class="error-title">Unauthorized</div>
|
|
14
14
|
<div class="error-msg">You need to sign in to access this resource.</div>
|
|
15
|
-
<div class="error-path
|
|
15
|
+
<div class="error-path c-danger">{{ path }}</div>
|
|
16
16
|
<a href="/" class="error-home">Go Home</a>
|
|
17
17
|
{% endblock %}
|
|
@@ -12,7 +12,7 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
|
12
12
|
<meta charset="utf-8">
|
|
13
13
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
14
14
|
<title>403 — Forbidden</title>
|
|
15
|
-
<style>
|
|
15
|
+
<style nonce="{{ csp_nonce() }}">
|
|
16
16
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
17
17
|
body { font-family: system-ui, -apple-system, sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
|
|
18
18
|
.error-card { background: #1e293b; border: 1px solid #334155; border-radius: 1rem; padding: 3rem; text-align: center; max-width: 520px; width: 90%; }
|
|
@@ -12,7 +12,7 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
|
12
12
|
<meta charset="utf-8">
|
|
13
13
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
14
14
|
<title>404 — Not Found</title>
|
|
15
|
-
<style>
|
|
15
|
+
<style nonce="{{ csp_nonce() }}">
|
|
16
16
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
17
17
|
body { font-family: system-ui, -apple-system, sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
|
|
18
18
|
.error-card { background: #1e293b; border: 1px solid #334155; border-radius: 1rem; padding: 3rem; text-align: center; max-width: 520px; width: 90%; }
|
|
@@ -12,7 +12,7 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
|
12
12
|
<meta charset="utf-8">
|
|
13
13
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
14
14
|
<title>500 — Server Error</title>
|
|
15
|
-
<style>
|
|
15
|
+
<style nonce="{{ csp_nonce() }}">
|
|
16
16
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
17
17
|
body { font-family: system-ui, -apple-system, sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
|
|
18
18
|
.error-card { background: #1e293b; border: 1px solid #334155; border-radius: 1rem; padding: 3rem; text-align: left; max-width: 700px; width: 90%; }
|
|
@@ -9,9 +9,9 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
|
9
9
|
{% extends "errors/base.twig" %}
|
|
10
10
|
{% block title %}502 — Bad Gateway{% endblock %}
|
|
11
11
|
{% block content %}
|
|
12
|
-
<div class="error-code
|
|
12
|
+
<div class="error-code c-danger">502</div>
|
|
13
13
|
<div class="error-title">Bad Gateway</div>
|
|
14
14
|
<div class="error-msg">The upstream server returned an invalid response.</div>
|
|
15
|
-
<div class="error-path
|
|
15
|
+
<div class="error-path c-danger">{{ path }}</div>
|
|
16
16
|
<a href="/" class="error-home">Go Home</a>
|
|
17
17
|
{% endblock %}
|
|
@@ -12,9 +12,9 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
|
12
12
|
.error-code { color: var(--warn); }
|
|
13
13
|
{% endblock %}
|
|
14
14
|
{% block content %}
|
|
15
|
-
<div class="error-code
|
|
15
|
+
<div class="error-code c-warn">503</div>
|
|
16
16
|
<div class="error-title">Service Unavailable</div>
|
|
17
17
|
<div class="error-msg">The server is temporarily unavailable. Please try again shortly.</div>
|
|
18
|
-
<div class="error-path
|
|
18
|
+
<div class="error-path c-warn">{{ path }}</div>
|
|
19
19
|
<a href="/" class="error-home">Go Home</a>
|
|
20
20
|
{% endblock %}
|
|
@@ -13,7 +13,7 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
|
13
13
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
14
14
|
{% block meta %}{% endblock %}
|
|
15
15
|
<title>{% block title %}Error{% endblock %}</title>
|
|
16
|
-
<style>
|
|
16
|
+
<style nonce="{{ csp_nonce() }}">
|
|
17
17
|
:root {
|
|
18
18
|
--bg: #0f172a; --surface: #1e293b; --border: #334155;
|
|
19
19
|
--text: #e2e8f0; --muted: #94a3b8; --primary: #3b82f6;
|
|
@@ -31,6 +31,9 @@ body { font-family: var(--font); background: var(--bg); color: var(--text); min-
|
|
|
31
31
|
.error-home { display: inline-block; margin-top: 1.5rem; padding: 0.5rem 1.5rem; background: var(--primary); color: #fff; text-decoration: none; border-radius: 0.5rem; font-size: 0.85rem; }
|
|
32
32
|
.error-home:hover { opacity: 0.9; }
|
|
33
33
|
.logo { width: 3rem; height: 3rem; margin-bottom: 1rem; opacity: 0.5; }
|
|
34
|
+
.c-danger { color: var(--danger); }
|
|
35
|
+
.c-warn { color: var(--warn); }
|
|
36
|
+
.c-primary { color: var(--primary); }
|
|
34
37
|
{% block extra_styles %}{% endblock %}
|
|
35
38
|
</style>
|
|
36
39
|
</head>
|
|
@@ -1413,6 +1413,7 @@ function _b64url(data) {
|
|
|
1413
1413
|
return data.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
1414
1414
|
}
|
|
1415
1415
|
var _formTokenSessionId = "";
|
|
1416
|
+
var _cspNonceProvider = () => "";
|
|
1416
1417
|
function _buildFormTokenJwt(descriptor = "") {
|
|
1417
1418
|
const secret = process.env.TINA4_SECRET ?? "";
|
|
1418
1419
|
const secretBytes = Buffer.byteLength(secret, "utf8");
|
|
@@ -1604,6 +1605,7 @@ var Frond = class _Frond {
|
|
|
1604
1605
|
this.globals.formTokenValue = (descriptor) => _generateFormTokenValue(descriptor || "");
|
|
1605
1606
|
this.globals.form_token_value = (descriptor) => _generateFormTokenValue(descriptor || "");
|
|
1606
1607
|
this.globals.dump = (value) => renderDump(value);
|
|
1608
|
+
this.globals.csp_nonce = () => _cspNonceProvider();
|
|
1607
1609
|
for (const [k, v] of _Frond.classFilters) this.filters[k] = v;
|
|
1608
1610
|
for (const [k, v] of _Frond.classGlobals) this.globals[k] = v;
|
|
1609
1611
|
for (const [k, v] of _Frond.classTests) this.tests[k] = v;
|
|
@@ -1785,6 +1785,21 @@ export function setFormTokenSessionId(sessionId: string): void {
|
|
|
1785
1785
|
_formTokenSessionId = sessionId || "";
|
|
1786
1786
|
}
|
|
1787
1787
|
|
|
1788
|
+
// CSP nonce provider (ADR-0088). Frond never imports @tina4/core (that would be
|
|
1789
|
+
// circular — core depends on frond), so core injects the per-response nonce
|
|
1790
|
+
// source through this setter. Default returns "" for standalone Frond use.
|
|
1791
|
+
let _cspNonceProvider: () => string = () => "";
|
|
1792
|
+
|
|
1793
|
+
/**
|
|
1794
|
+
* Register the function that backs the `{{ csp_nonce() }}` template global.
|
|
1795
|
+
* @tina4/core calls this once with its request-scoped nonce source so every
|
|
1796
|
+
* Frond instance (framework templates AND the app's) resolves the same nonce
|
|
1797
|
+
* the Content-Security-Policy header names.
|
|
1798
|
+
*/
|
|
1799
|
+
export function setCspNonceProvider(fn: () => string): void {
|
|
1800
|
+
_cspNonceProvider = fn;
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1788
1803
|
function _buildFormTokenJwt(descriptor: string = ""): string {
|
|
1789
1804
|
// Fail-closed, IDENTICAL to the validator (auth.ts validToken:
|
|
1790
1805
|
// `secret ?? process.env.TINA4_SECRET ?? ""`). With TINA4_SECRET unset the
|
|
@@ -2014,6 +2029,14 @@ export class Frond {
|
|
|
2014
2029
|
// {{ user|dump }} and {{ dump(user) }}
|
|
2015
2030
|
this.globals.dump = (value: unknown) => renderDump(value);
|
|
2016
2031
|
|
|
2032
|
+
// CSP nonce (ADR-0088): {{ csp_nonce() }} returns the current response's
|
|
2033
|
+
// nonce so a template can serve an inline <style>/<script> under the strict
|
|
2034
|
+
// default Content-Security-Policy — <style nonce="{{ csp_nonce() }}">. The
|
|
2035
|
+
// value matches the 'nonce-X' the security middleware puts in the CSP header.
|
|
2036
|
+
// Frond stays zero-dependency: @tina4/core injects the provider (it never
|
|
2037
|
+
// imports core); standalone Frond returns "".
|
|
2038
|
+
this.globals.csp_nonce = () => _cspNonceProvider();
|
|
2039
|
+
|
|
2017
2040
|
// Drain the class-level registry. This is the key to surviving
|
|
2018
2041
|
// hot-reloads AND the static-facade pattern: app.ts calls
|
|
2019
2042
|
// Frond.addFilter("money", fn) once, the class remembers it,
|