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
|
@@ -26,6 +26,12 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
|
26
26
|
* them as strings, so a Cypher `WHERE id(n) = $id` is fed `Number(id)` back.
|
|
27
27
|
*/
|
|
28
28
|
import { GraphNode, GraphEdge, GraphResult } from "../shapes.js";
|
|
29
|
+
import {
|
|
30
|
+
errorMessage,
|
|
31
|
+
nodeFromRow as toGraphNode,
|
|
32
|
+
edgeFromRow as toGraphEdge,
|
|
33
|
+
type DriverRow,
|
|
34
|
+
} from "./rowMapping.js";
|
|
29
35
|
import { GraphError, GraphConnectTimeout } from "../errors.js";
|
|
30
36
|
import {
|
|
31
37
|
resolveGraphConnectTimeout,
|
|
@@ -52,20 +58,6 @@ const driverModule: any = await import(DRIVER_PACKAGE);
|
|
|
52
58
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
53
59
|
const neo4j: any = driverModule.default ?? driverModule;
|
|
54
60
|
|
|
55
|
-
interface DriverRow {
|
|
56
|
-
id?: unknown;
|
|
57
|
-
labels?: unknown;
|
|
58
|
-
props?: unknown;
|
|
59
|
-
type?: unknown;
|
|
60
|
-
f?: unknown;
|
|
61
|
-
t?: unknown;
|
|
62
|
-
[key: string]: unknown;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function errorMessage(exc: unknown): string {
|
|
66
|
-
if (exc instanceof Error) return exc.message;
|
|
67
|
-
return String(exc);
|
|
68
|
-
}
|
|
69
61
|
|
|
70
62
|
/**
|
|
71
63
|
* A neutral GraphNode id back to the engine's integer form. Non-numeric ids
|
|
@@ -151,12 +143,7 @@ export class BoltGraphAdapter implements GraphAdapter {
|
|
|
151
143
|
|
|
152
144
|
// -- portable node/edge/traverse core (Cypher) -------------------------
|
|
153
145
|
private nodeFromRow(row: DriverRow | undefined | null): GraphNode | null {
|
|
154
|
-
|
|
155
|
-
return new GraphNode(
|
|
156
|
-
String(row.id),
|
|
157
|
-
(row.labels as string[]) ?? [],
|
|
158
|
-
(row.props as Record<string, unknown>) ?? {},
|
|
159
|
-
);
|
|
146
|
+
return toGraphNode(row);
|
|
160
147
|
}
|
|
161
148
|
|
|
162
149
|
async addNode(
|
|
@@ -185,15 +172,7 @@ export class BoltGraphAdapter implements GraphAdapter {
|
|
|
185
172
|
to_id: boltId(toId),
|
|
186
173
|
props: properties ?? {},
|
|
187
174
|
});
|
|
188
|
-
|
|
189
|
-
const row = rows[0];
|
|
190
|
-
return new GraphEdge(
|
|
191
|
-
String(row.id),
|
|
192
|
-
String(row.type),
|
|
193
|
-
String(row.f),
|
|
194
|
-
String(row.t),
|
|
195
|
-
(row.props as Record<string, unknown>) ?? {},
|
|
196
|
-
);
|
|
175
|
+
return toGraphEdge(rows[0]);
|
|
197
176
|
}
|
|
198
177
|
|
|
199
178
|
async getNode(nodeId: string): Promise<GraphNode | null> {
|
|
@@ -0,0 +1,57 @@
|
|
|
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
|
+
* Shared driver-row mapping for the Cypher/GQL graph adapters (Bolt, Ultipa).
|
|
11
|
+
*
|
|
12
|
+
* Both drivers return a flat record with the same reserved column names —
|
|
13
|
+
* `id`, `labels`, `props`, `type`, `f`, `t` — because both adapters run the
|
|
14
|
+
* same `RETURN ... AS id, ... AS props` projection. Turning such a row into an
|
|
15
|
+
* engine-neutral GraphNode or GraphEdge is therefore identical work, so it
|
|
16
|
+
* lives here once rather than in each adapter.
|
|
17
|
+
*/
|
|
18
|
+
import { GraphNode, GraphEdge } from "../shapes.js";
|
|
19
|
+
|
|
20
|
+
/** A flat driver record from a Cypher/GQL `RETURN ... AS <col>` projection. */
|
|
21
|
+
export interface DriverRow {
|
|
22
|
+
id?: unknown;
|
|
23
|
+
labels?: unknown;
|
|
24
|
+
props?: unknown;
|
|
25
|
+
type?: unknown;
|
|
26
|
+
f?: unknown;
|
|
27
|
+
t?: unknown;
|
|
28
|
+
[key: string]: unknown;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Message of an error value, whether or not it is a real Error. */
|
|
32
|
+
export function errorMessage(exc: unknown): string {
|
|
33
|
+
if (exc instanceof Error) return exc.message;
|
|
34
|
+
return String(exc);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Build an engine-neutral GraphNode from a driver row, or null when there is none. */
|
|
38
|
+
export function nodeFromRow(row: DriverRow | undefined | null): GraphNode | null {
|
|
39
|
+
if (row === null || row === undefined) return null;
|
|
40
|
+
return new GraphNode(
|
|
41
|
+
String(row.id),
|
|
42
|
+
(row.labels as string[]) ?? [],
|
|
43
|
+
(row.props as Record<string, unknown>) ?? {},
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Build an engine-neutral GraphEdge from a driver row, or null when there is none. */
|
|
48
|
+
export function edgeFromRow(row: DriverRow | undefined | null): GraphEdge | null {
|
|
49
|
+
if (row === null || row === undefined) return null;
|
|
50
|
+
return new GraphEdge(
|
|
51
|
+
String(row.id),
|
|
52
|
+
String(row.type),
|
|
53
|
+
String(row.f),
|
|
54
|
+
String(row.t),
|
|
55
|
+
(row.props as Record<string, unknown>) ?? {},
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -23,6 +23,12 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
|
23
23
|
* query() is rejected by Ultipa.
|
|
24
24
|
*/
|
|
25
25
|
import { GraphNode, GraphEdge, GraphResult } from "../shapes.js";
|
|
26
|
+
import {
|
|
27
|
+
errorMessage,
|
|
28
|
+
nodeFromRow as toGraphNode,
|
|
29
|
+
edgeFromRow as toGraphEdge,
|
|
30
|
+
type DriverRow,
|
|
31
|
+
} from "./rowMapping.js";
|
|
26
32
|
import { GraphError, GraphConnectTimeout } from "../errors.js";
|
|
27
33
|
import {
|
|
28
34
|
resolveGraphConnectTimeout,
|
|
@@ -56,16 +62,6 @@ const UltipaConnectError = driver.UltipaConnectError;
|
|
|
56
62
|
*/
|
|
57
63
|
const UNBOUNDED_CONNECT_SECONDS = 315_360_000; // ~10 years
|
|
58
64
|
|
|
59
|
-
interface DriverRow {
|
|
60
|
-
id?: unknown;
|
|
61
|
-
labels?: unknown;
|
|
62
|
-
props?: unknown;
|
|
63
|
-
type?: unknown;
|
|
64
|
-
f?: unknown;
|
|
65
|
-
t?: unknown;
|
|
66
|
-
[key: string]: unknown;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
65
|
/**
|
|
70
66
|
* Build a GQL property map `{k1: $p_k1, ...}` plus the param dict for it.
|
|
71
67
|
*
|
|
@@ -84,11 +80,6 @@ function propClause(
|
|
|
84
80
|
return { clause: `{${pairs}}`, params };
|
|
85
81
|
}
|
|
86
82
|
|
|
87
|
-
function errorMessage(exc: unknown): string {
|
|
88
|
-
if (exc instanceof Error) return exc.message;
|
|
89
|
-
return String(exc);
|
|
90
|
-
}
|
|
91
|
-
|
|
92
83
|
export class UltipaGraphAdapter implements GraphAdapter {
|
|
93
84
|
private readonly url: GraphUrl;
|
|
94
85
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -154,12 +145,7 @@ export class UltipaGraphAdapter implements GraphAdapter {
|
|
|
154
145
|
|
|
155
146
|
// -- portable node/edge/traverse core (GQL) ----------------------------
|
|
156
147
|
private nodeFromRow(row: DriverRow | undefined | null): GraphNode | null {
|
|
157
|
-
|
|
158
|
-
return new GraphNode(
|
|
159
|
-
String(row.id),
|
|
160
|
-
(row.labels as string[]) ?? [],
|
|
161
|
-
(row.props as Record<string, unknown>) ?? {},
|
|
162
|
-
);
|
|
148
|
+
return toGraphNode(row);
|
|
163
149
|
}
|
|
164
150
|
|
|
165
151
|
async addNode(
|
|
@@ -190,15 +176,7 @@ export class UltipaGraphAdapter implements GraphAdapter {
|
|
|
190
176
|
+ `INSERT (a)-[e:\`${type}\` ${clause}]->(b) `
|
|
191
177
|
+ `RETURN id(e) AS id, type(e) AS type, id(a) AS f, id(b) AS t, properties(e) AS props`;
|
|
192
178
|
const rows = (await this.run(gql, params, false)).dicts() as DriverRow[];
|
|
193
|
-
|
|
194
|
-
const row = rows[0];
|
|
195
|
-
return new GraphEdge(
|
|
196
|
-
String(row.id),
|
|
197
|
-
String(row.type),
|
|
198
|
-
String(row.f),
|
|
199
|
-
String(row.t),
|
|
200
|
-
(row.props as Record<string, unknown>) ?? {},
|
|
201
|
-
);
|
|
179
|
+
return toGraphEdge(rows[0]);
|
|
202
180
|
}
|
|
203
181
|
|
|
204
182
|
async getNode(nodeId: string): Promise<GraphNode | null> {
|
|
@@ -487,13 +487,13 @@ function uniqueOperationId(method, openApiPath, seen) {
|
|
|
487
487
|
function swaggerUiCdn() {
|
|
488
488
|
return (process.env.TINA4_SWAGGER_UI_CDN ?? "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5").replace(/\/+$/, "");
|
|
489
489
|
}
|
|
490
|
-
var SWAGGER_UI_HTML = (specUrl) => `<!DOCTYPE html>
|
|
490
|
+
var SWAGGER_UI_HTML = (specUrl, nonce) => `<!DOCTYPE html>
|
|
491
491
|
<html lang="en">
|
|
492
492
|
<head>
|
|
493
493
|
<meta charset="UTF-8">
|
|
494
494
|
<title>Tina4 API Documentation</title>
|
|
495
495
|
<link rel="stylesheet" href="${swaggerUiCdn()}/swagger-ui.css">
|
|
496
|
-
<style>
|
|
496
|
+
<style nonce="${nonce}">
|
|
497
497
|
body { margin: 0; background: #fafafa; }
|
|
498
498
|
.topbar { display: none !important; }
|
|
499
499
|
</style>
|
|
@@ -501,7 +501,7 @@ var SWAGGER_UI_HTML = (specUrl) => `<!DOCTYPE html>
|
|
|
501
501
|
<body>
|
|
502
502
|
<div id="swagger-ui"></div>
|
|
503
503
|
<script src="${swaggerUiCdn()}/swagger-ui-bundle.js"></script>
|
|
504
|
-
<script>
|
|
504
|
+
<script nonce="${nonce}">
|
|
505
505
|
SwaggerUIBundle({
|
|
506
506
|
url: "${specUrl}",
|
|
507
507
|
dom_id: '#swagger-ui',
|
|
@@ -522,7 +522,7 @@ function swaggerEnabled() {
|
|
|
522
522
|
}
|
|
523
523
|
function createSwaggerRoutes(getSpec) {
|
|
524
524
|
const serveUi = async (_req, res) => {
|
|
525
|
-
res.html(SWAGGER_UI_HTML("/swagger/openapi.json"));
|
|
525
|
+
res.html(SWAGGER_UI_HTML("/swagger/openapi.json", res.cspNonce));
|
|
526
526
|
};
|
|
527
527
|
return [
|
|
528
528
|
{
|
|
@@ -18,13 +18,16 @@ function swaggerUiCdn(): string {
|
|
|
18
18
|
return (process.env.TINA4_SWAGGER_UI_CDN ?? "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5").replace(/\/+$/, "");
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
// The inline <style> and the inline SwaggerUIBundle(...) <script> carry this
|
|
22
|
+
// response's CSP nonce (ADR-0088) so they run under the strict default
|
|
23
|
+
// Content-Security-Policy; the external <script src>/<link> need none.
|
|
24
|
+
const SWAGGER_UI_HTML = (specUrl: string, nonce: string) => `<!DOCTYPE html>
|
|
22
25
|
<html lang="en">
|
|
23
26
|
<head>
|
|
24
27
|
<meta charset="UTF-8">
|
|
25
28
|
<title>Tina4 API Documentation</title>
|
|
26
29
|
<link rel="stylesheet" href="${swaggerUiCdn()}/swagger-ui.css">
|
|
27
|
-
<style>
|
|
30
|
+
<style nonce="${nonce}">
|
|
28
31
|
body { margin: 0; background: #fafafa; }
|
|
29
32
|
.topbar { display: none !important; }
|
|
30
33
|
</style>
|
|
@@ -32,7 +35,7 @@ const SWAGGER_UI_HTML = (specUrl: string) => `<!DOCTYPE html>
|
|
|
32
35
|
<body>
|
|
33
36
|
<div id="swagger-ui"></div>
|
|
34
37
|
<script src="${swaggerUiCdn()}/swagger-ui-bundle.js"></script>
|
|
35
|
-
<script>
|
|
38
|
+
<script nonce="${nonce}">
|
|
36
39
|
SwaggerUIBundle({
|
|
37
40
|
url: "${specUrl}",
|
|
38
41
|
dom_id: '#swagger-ui',
|
|
@@ -65,7 +68,7 @@ export function createSwaggerRoutes(
|
|
|
65
68
|
getSpec: () => unknown
|
|
66
69
|
): RouteDefinition[] {
|
|
67
70
|
const serveUi = async (_req: Tina4Request, res: Tina4Response): Promise<void> => {
|
|
68
|
-
res.html(SWAGGER_UI_HTML("/swagger/openapi.json"));
|
|
71
|
+
res.html(SWAGGER_UI_HTML("/swagger/openapi.json", res.cspNonce));
|
|
69
72
|
};
|
|
70
73
|
|
|
71
74
|
return [
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Flags and positional arguments split out of a command's argv slice. */
|
|
2
|
+
export interface ParsedFlags {
|
|
3
|
+
flags: Record<string, string | boolean>;
|
|
4
|
+
positional: string[];
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Parse `--key value` and bare `--flag` arguments into flags plus positionals.
|
|
8
|
+
*
|
|
9
|
+
* A name listed in `booleanFlags` never consumes the next token; every other
|
|
10
|
+
* `--key` takes the following token as its value unless that token is itself a
|
|
11
|
+
* `--flag` (or the argv ends), in which case the key is a bare boolean.
|
|
12
|
+
*/
|
|
13
|
+
export declare function parseFlags(args: string[], booleanFlags?: ReadonlySet<string>): ParsedFlags;
|
|
14
|
+
/**
|
|
15
|
+
* Absolute path of the first of `names` found on PATH, or null.
|
|
16
|
+
*
|
|
17
|
+
* Scans PATH directories directly (no `which` shell-out) so it behaves the same
|
|
18
|
+
* on every platform and honours a deliberately emptied PATH. Callers pass the
|
|
19
|
+
* platform-specific candidate names (e.g. `["npm.cmd", "npm.exe", "npm"]`).
|
|
20
|
+
*/
|
|
21
|
+
export declare function findOnPath(names: string[]): string | null;
|
|
22
|
+
/**
|
|
23
|
+
* Await the ORM's async `initDatabase()` and exit(1) with a clear message on
|
|
24
|
+
* failure. The await matters: initDatabase() calls setAdapter() inside the
|
|
25
|
+
* promise, so without it the next getAdapter() throws "No database adapter
|
|
26
|
+
* configured." Shared by the migrate, rollback, and status commands.
|
|
27
|
+
*/
|
|
28
|
+
export declare function initDatabaseOrExit(initDatabase: () => Promise<unknown>): Promise<void>;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** Return a fresh cryptographically-random nonce (128 bits, base64). */
|
|
2
|
+
export declare function generateNonce(): string;
|
|
3
|
+
/** Run `fn` with `nonce` established as the request-scoped CSP nonce. */
|
|
4
|
+
export declare function runWithCspNonce<T>(nonce: string, fn: () => T): T;
|
|
5
|
+
/** Set the nonce for the current request context (mirrors Log.setRequestId). */
|
|
6
|
+
export declare function setCurrentNonce(nonce: string | undefined): void;
|
|
7
|
+
/** Clear the nonce (mirrors Log.clearRequestId); the scope ends on its own. */
|
|
8
|
+
export declare function clearCurrentNonce(): void;
|
|
9
|
+
/**
|
|
10
|
+
* Return the current request's nonce, minting one on first access.
|
|
11
|
+
*
|
|
12
|
+
* Generate-on-first-access makes the value independent of ordering: whether the
|
|
13
|
+
* inline body or the CSP header is built first, both read the same nonce.
|
|
14
|
+
*/
|
|
15
|
+
export declare function currentCspNonce(): string;
|
|
16
|
+
/**
|
|
17
|
+
* Frond global `csp_nonce()`: the current response's CSP nonce.
|
|
18
|
+
*
|
|
19
|
+
* Use it on any inline element a template emits:
|
|
20
|
+
*
|
|
21
|
+
* <style nonce="{{ csp_nonce() }}"> ... </style>
|
|
22
|
+
* <script nonce="{{ csp_nonce() }}"> ... </script>
|
|
23
|
+
*/
|
|
24
|
+
export declare function cspNonce(): string;
|
|
25
|
+
/**
|
|
26
|
+
* Return `csp` with `'nonce-<nonce>'` present in style-src AND script-src.
|
|
27
|
+
*
|
|
28
|
+
* For the default `default-src 'self'` this yields
|
|
29
|
+
* `default-src 'self'; style-src 'self' 'nonce-X'; script-src 'self' 'nonce-X'`.
|
|
30
|
+
* When a directive is absent it is derived from `default-src` (falling back to
|
|
31
|
+
* `'self'`) so the framework's own nonce'd content always works; when present
|
|
32
|
+
* the nonce is appended (idempotently). Every other directive is kept, in order.
|
|
33
|
+
*/
|
|
34
|
+
export declare function injectNonceIntoCsp(csp: string, nonce: string): string;
|
|
35
|
+
/**
|
|
36
|
+
* Build the CSP header value for `nonce` from the environment.
|
|
37
|
+
*
|
|
38
|
+
* Honours `TINA4_CSP` (default `default-src 'self'`) and always injects the
|
|
39
|
+
* nonce into style-src and script-src. An empty `TINA4_CSP` is treated as the
|
|
40
|
+
* default policy, matching how the warn-once gate reads "unset".
|
|
41
|
+
*/
|
|
42
|
+
export declare function resolveCspHeader(nonce: string): string;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Does an `If-None-Match` header match the response ETag?
|
|
3
|
+
*
|
|
4
|
+
* The header may be `*`, a single tag, or a comma-separated list; the `W/`
|
|
5
|
+
* weak prefix is stripped on both sides before comparing (RFC 7232 §2.3.2
|
|
6
|
+
* weak comparison). Shared by the static file server and the compression
|
|
7
|
+
* interceptor so both answer 304 the same way.
|
|
8
|
+
*/
|
|
9
|
+
export declare function etagMatches(ifNoneMatch: string, etag: string): boolean;
|
|
@@ -54,6 +54,7 @@ export { WSDLService, WSDLOperation } from "./wsdl.js";
|
|
|
54
54
|
export type { WSDLOperationMeta } from "./wsdl.js";
|
|
55
55
|
export { HtmlElement, htmlElement, addHtmlHelpers, Raw, SafeString } from "./htmlElement.js";
|
|
56
56
|
export { renderErrorOverlay, isDebugMode } from "./errorOverlay.js";
|
|
57
|
+
export { cspNonce, currentCspNonce, generateNonce, injectNonceIntoCsp, resolveCspHeader, runWithCspNonce, setCurrentNonce, clearCurrentNonce, } from "./csp.js";
|
|
57
58
|
export { AI_TOOLS, isInstalled, showMenu, installSelected, installAll, generateContext } from "./ai.js";
|
|
58
59
|
export type { AiTool } from "./ai.js";
|
|
59
60
|
export { Sso, SSO, SsoError } from "./sso.js";
|
|
@@ -344,6 +344,12 @@ export declare class SecurityHeadersMiddleware {
|
|
|
344
344
|
* overrides, WITHOUT Strict-Transport-Security, which depends on the
|
|
345
345
|
* request's scheme. Also what a transport rejection carries (ADR-0068
|
|
346
346
|
* section 4), written before the scheme is known.
|
|
347
|
+
*
|
|
348
|
+
* Content-Security-Policy always carries a per-response nonce in style-src
|
|
349
|
+
* AND script-src (ADR-0088), read from the request async-context (minted on
|
|
350
|
+
* first access), so the framework's own inline content runs under the strict
|
|
351
|
+
* default without 'unsafe-inline'. The nonce is random per response, so a
|
|
352
|
+
* caller comparing this header must assert its STRUCTURE, never a byte string.
|
|
347
353
|
*/
|
|
348
354
|
static canonicalHeaders(): Record<string, string>;
|
|
349
355
|
static beforeSecurity(req: Tina4Request, res: Tina4Response): [Tina4Request, Tina4Response];
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { RespSessionHandler } from "./sessionHandlers/respSessionHandler.js";
|
|
1
2
|
export interface SessionConfig {
|
|
2
3
|
/** Session backend type: "file", "redis", "valkey", "mongo", "memcached", "database" (or "db") */
|
|
3
4
|
backend?: string;
|
|
@@ -95,26 +96,8 @@ export declare class FileSessionHandler implements SessionHandler {
|
|
|
95
96
|
*
|
|
96
97
|
* Or pass via SessionConfig.
|
|
97
98
|
*/
|
|
98
|
-
export declare class RedisSessionHandler
|
|
99
|
-
private host;
|
|
100
|
-
private port;
|
|
101
|
-
private password;
|
|
102
|
-
private prefix;
|
|
103
|
-
private db;
|
|
99
|
+
export declare class RedisSessionHandler extends RespSessionHandler {
|
|
104
100
|
constructor(config?: SessionConfig);
|
|
105
|
-
/**
|
|
106
|
-
* Execute a Redis command synchronously against the live server.
|
|
107
|
-
*
|
|
108
|
-
* Delegates to the shared {@link respCommandSync} transport: a genuine key miss
|
|
109
|
-
* yields `""`, and a transport/connection FAILURE (server unreachable, rejected
|
|
110
|
-
* AUTH, timeout) THROWS so the Session boundary can distinguish "not found"
|
|
111
|
-
* (silent) from "backend failed" (log-loud + degrade). Backend-failure parity.
|
|
112
|
-
*/
|
|
113
|
-
private execSync;
|
|
114
|
-
private key;
|
|
115
|
-
read(sessionId: string): SessionData | null;
|
|
116
|
-
write(sessionId: string, data: SessionData, ttl?: number): void;
|
|
117
|
-
destroy(sessionId: string): void;
|
|
118
101
|
}
|
|
119
102
|
export declare class Session {
|
|
120
103
|
private handler;
|
|
@@ -140,6 +123,29 @@ export declare class Session {
|
|
|
140
123
|
* Lets `start()` tell "no such session" from "the store is unreachable".
|
|
141
124
|
*/
|
|
142
125
|
private lastReadFailed;
|
|
126
|
+
/**
|
|
127
|
+
* What this request last saw of the stored record, one fingerprint per key:
|
|
128
|
+
* taken by start() and again by every save. save() compares the live data
|
|
129
|
+
* against it to find the keys THIS request changed.
|
|
130
|
+
*/
|
|
131
|
+
private loaded;
|
|
132
|
+
/**
|
|
133
|
+
* True when this request is working on a record the store holds: start()
|
|
134
|
+
* adopted one, or a save has written one since. A save never re-creates such
|
|
135
|
+
* a record once another request has removed it.
|
|
136
|
+
*/
|
|
137
|
+
private stored;
|
|
138
|
+
/**
|
|
139
|
+
* True when clear() ran since the last save: the save then replaces the
|
|
140
|
+
* stored record instead of merging into it.
|
|
141
|
+
*/
|
|
142
|
+
private cleared;
|
|
143
|
+
/**
|
|
144
|
+
* True once this request found its session ended by another request (the
|
|
145
|
+
* record it loaded is gone). It stays ended for the rest of the request:
|
|
146
|
+
* regenerate() mints nothing either.
|
|
147
|
+
*/
|
|
148
|
+
private ended;
|
|
143
149
|
constructor(backend?: string, config?: SessionConfig);
|
|
144
150
|
/**
|
|
145
151
|
* Use a custom session handler (for advanced use cases).
|
|
@@ -226,8 +232,14 @@ export declare class Session {
|
|
|
226
232
|
* session fixation — the pre-auth ID is destroyed and the data is carried
|
|
227
233
|
* onto a fresh, unguessable ID. A backend destroy/write failure is logged
|
|
228
234
|
* (never silent) but does not throw under the default policy.
|
|
235
|
+
*
|
|
236
|
+
* What is carried is the session as it is stored now with this request's
|
|
237
|
+
* own changes applied, the same merge save() does. If another request ended
|
|
238
|
+
* the session after this one loaded it (a logout, or a regenerate of its
|
|
239
|
+
* own), it stays ended: nothing is carried, no id is minted and null is
|
|
240
|
+
* returned, so no cookie goes out to replace the one that request sent.
|
|
229
241
|
*/
|
|
230
|
-
regenerate(): string;
|
|
242
|
+
regenerate(): string | null;
|
|
231
243
|
/**
|
|
232
244
|
* Dual-mode flash: set with value, get+remove without.
|
|
233
245
|
*
|
|
@@ -273,9 +285,40 @@ export declare class Session {
|
|
|
273
285
|
*
|
|
274
286
|
* Returns true on a successful persist, false if the backend was unreachable
|
|
275
287
|
* (logged). The dirty flag is cleared only on success so a later save()
|
|
276
|
-
* retries once the backend recovers. A
|
|
288
|
+
* retries once the backend recovers. A call with no session returns true.
|
|
289
|
+
*
|
|
290
|
+
* Other requests on the same session run while this one does, so the save
|
|
291
|
+
* writes only THIS request's changes, onto the record as it is stored now.
|
|
292
|
+
* Writing back the whole snapshot start() loaded undid whatever another
|
|
293
|
+
* request did in between: a logout (destroy, clear, regenerate) or a
|
|
294
|
+
* privilege change made with set(). A record removed since this request
|
|
295
|
+
* loaded it is never re-created: the session has ended, for this request
|
|
296
|
+
* too. A request that has not loaded a stored record (a new session, the new
|
|
297
|
+
* id regenerate() mints) writes its data whole, as before.
|
|
298
|
+
*
|
|
299
|
+
* A session the store already holds is re-written even when this request
|
|
300
|
+
* changed nothing: that write re-stamps the backend deadline to
|
|
301
|
+
* now + TINA4_SESSION_TTL, so a session expires after that span of INACTIVITY
|
|
302
|
+
* rather than that long after its last CHANGE (ADR-0087 — a session's expiry
|
|
303
|
+
* slides on activity). The re-write is the re-read, merged record, so sliding
|
|
304
|
+
* the deadline is concurrent-safe: it never clobbers another request's change,
|
|
305
|
+
* never re-creates an ended record, and TINA4_SESSION_TTL=0 still re-stamps a
|
|
306
|
+
* never-expires deadline (ADR-0027). A request with no stored session and no
|
|
307
|
+
* change writes nothing — there is no deadline to move.
|
|
277
308
|
*/
|
|
278
309
|
save(): boolean;
|
|
310
|
+
/**
|
|
311
|
+
* The stored record with this request's changes applied: every key whose
|
|
312
|
+
* value differs from what this request loaded or last saved, and the
|
|
313
|
+
* removal of every key it deleted. Keys it did not touch keep whatever is
|
|
314
|
+
* stored now.
|
|
315
|
+
*/
|
|
316
|
+
private merged;
|
|
317
|
+
/**
|
|
318
|
+
* End the session for this request without touching the store: no id and
|
|
319
|
+
* no data, so nothing more is written and no cookie goes out for it.
|
|
320
|
+
*/
|
|
321
|
+
private forget;
|
|
279
322
|
}
|
|
280
323
|
/**
|
|
281
324
|
* Is the client's scheme HTTPS? Proxy-aware.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tina4 RESP Session Handler base — the shared store logic behind Redis and
|
|
3
|
+
* Valkey, zero dependencies.
|
|
4
|
+
*
|
|
5
|
+
* Redis and Valkey speak the same RESP protocol, so the read/write/destroy
|
|
6
|
+
* transport is byte-for-byte identical between them — only the configuration
|
|
7
|
+
* variable names and the log label differ. That difference lives entirely in
|
|
8
|
+
* each subclass constructor; everything the session store actually DOES lives
|
|
9
|
+
* here, once. A subclass resolves its own host/port/password/prefix/db from
|
|
10
|
+
* config or environment, then hands a {@link RespConnection} plus a label to
|
|
11
|
+
* `super()`.
|
|
12
|
+
*/
|
|
13
|
+
import type { SessionHandler } from "../session.js";
|
|
14
|
+
interface SessionData {
|
|
15
|
+
_created: number;
|
|
16
|
+
_accessed: number;
|
|
17
|
+
[key: string]: unknown;
|
|
18
|
+
}
|
|
19
|
+
/** Resolved connection details a RESP session handler needs. */
|
|
20
|
+
export interface RespConnection {
|
|
21
|
+
host: string;
|
|
22
|
+
port: number;
|
|
23
|
+
password: string;
|
|
24
|
+
prefix: string;
|
|
25
|
+
db: number;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* RESP (Redis/Valkey) session handler using raw TCP — no external client.
|
|
29
|
+
*
|
|
30
|
+
* Stores session data as JSON strings with a server-side TTL for automatic
|
|
31
|
+
* expiry. A genuine key miss yields `null`; a transport/connection FAILURE
|
|
32
|
+
* (server unreachable, rejected AUTH, timeout) THROWS so the Session boundary
|
|
33
|
+
* can tell "not found" (silent) from "backend failed" (log-loud + degrade).
|
|
34
|
+
*/
|
|
35
|
+
export declare abstract class RespSessionHandler implements SessionHandler {
|
|
36
|
+
private readonly conn;
|
|
37
|
+
private readonly label;
|
|
38
|
+
protected constructor(conn: RespConnection, label: string);
|
|
39
|
+
/**
|
|
40
|
+
* Execute a RESP command synchronously against the live server.
|
|
41
|
+
*
|
|
42
|
+
* Delegates to the shared {@link respCommandSync} transport: a genuine key
|
|
43
|
+
* miss yields `""`, and a transport/connection FAILURE THROWS so the caller
|
|
44
|
+
* can distinguish "not found" from "backend failed". Backend-failure parity.
|
|
45
|
+
*/
|
|
46
|
+
private execSync;
|
|
47
|
+
private key;
|
|
48
|
+
read(sessionId: string): SessionData | null;
|
|
49
|
+
write(sessionId: string, data: SessionData, ttl?: number): void;
|
|
50
|
+
destroy(sessionId: string): void;
|
|
51
|
+
}
|
|
52
|
+
export {};
|
|
@@ -11,12 +11,7 @@
|
|
|
11
11
|
* TINA4_SESSION_VALKEY_PREFIX (default: "tina4:session:")
|
|
12
12
|
* TINA4_SESSION_VALKEY_DB (default: 0)
|
|
13
13
|
*/
|
|
14
|
-
import
|
|
15
|
-
interface SessionData {
|
|
16
|
-
_created: number;
|
|
17
|
-
_accessed: number;
|
|
18
|
-
[key: string]: unknown;
|
|
19
|
-
}
|
|
14
|
+
import { RespSessionHandler } from "./respSessionHandler.js";
|
|
20
15
|
export interface ValkeySessionConfig {
|
|
21
16
|
host?: string;
|
|
22
17
|
port?: number;
|
|
@@ -41,25 +36,6 @@ export interface ValkeySessionConfig {
|
|
|
41
36
|
* Valkey uses the same RESP protocol as Redis, so this handler is functionally
|
|
42
37
|
* identical to RedisSessionHandler but with VALKEY config variable names.
|
|
43
38
|
*/
|
|
44
|
-
export declare class ValkeySessionHandler
|
|
45
|
-
private host;
|
|
46
|
-
private port;
|
|
47
|
-
private password;
|
|
48
|
-
private prefix;
|
|
49
|
-
private db;
|
|
39
|
+
export declare class ValkeySessionHandler extends RespSessionHandler {
|
|
50
40
|
constructor(config?: ValkeySessionConfig);
|
|
51
|
-
/**
|
|
52
|
-
* Execute a RESP command synchronously against the live Valkey server.
|
|
53
|
-
*
|
|
54
|
-
* Delegates to the shared {@link respCommandSync} transport: a genuine key miss
|
|
55
|
-
* yields `""`, and a transport/connection FAILURE (server unreachable, rejected
|
|
56
|
-
* AUTH, timeout) THROWS so the Session boundary can distinguish "not found"
|
|
57
|
-
* (silent) from "backend failed" (log-loud + degrade). Backend-failure parity.
|
|
58
|
-
*/
|
|
59
|
-
private execSync;
|
|
60
|
-
private key;
|
|
61
|
-
read(sessionId: string): SessionData | null;
|
|
62
|
-
write(sessionId: string, data: SessionData, ttl: number): void;
|
|
63
|
-
destroy(sessionId: string): void;
|
|
64
41
|
}
|
|
65
|
-
export {};
|
|
@@ -116,6 +116,14 @@ export interface Tina4ResponseMethods {
|
|
|
116
116
|
stream(source: AsyncIterable<string | Buffer>, contentType?: string): Promise<Tina4Response>;
|
|
117
117
|
/** The underlying ServerResponse for advanced use */
|
|
118
118
|
raw: ServerResponse;
|
|
119
|
+
/**
|
|
120
|
+
* This response's Content-Security-Policy nonce (ADR-0088). `runDispatch()`
|
|
121
|
+
* mints one per request and publishes it on the async-context (csp.ts) so the
|
|
122
|
+
* framework's inline `<style>`/`<script>`, the Frond `csp_nonce()` global, and
|
|
123
|
+
* the value the security middleware names in the header all agree. Stamp it on
|
|
124
|
+
* any inline block a route emits: `<script nonce="${res.cspNonce}">`.
|
|
125
|
+
*/
|
|
126
|
+
cspNonce: string;
|
|
119
127
|
}
|
|
120
128
|
/**
|
|
121
129
|
* Tina4 Response — callable AND has methods.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Tina4Response } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Relay a drained upstream (proxied) response to the client.
|
|
4
|
+
*
|
|
5
|
+
* The body is read to a string once, then returned as JSON when it parses and
|
|
6
|
+
* as raw text with the upstream Content-Type otherwise, keeping the upstream
|
|
7
|
+
* status either way. Shared by the dev-admin supervisor proxy and the feedback
|
|
8
|
+
* agent proxy so both pass a backend response through identically.
|
|
9
|
+
*/
|
|
10
|
+
export declare function relayUpstreamResponse(res: Tina4Response, upstream: Response): Promise<void>;
|
|
@@ -131,6 +131,25 @@ export declare function parseFrame(data: Buffer): {
|
|
|
131
131
|
payload: Buffer;
|
|
132
132
|
bytesConsumed: number;
|
|
133
133
|
} | null;
|
|
134
|
+
/**
|
|
135
|
+
* Reassembles fragmented messages (RFC 6455 section 5.4). A browser sends a large message - a pasted image in a chat,
|
|
136
|
+
* base64 inside JSON - as a TEXT or BINARY frame with FIN clear, then CONTINUATION frames; control frames may arrive
|
|
137
|
+
* between the pieces. Returns a complete message when one is done, a control frame as it comes, and null while a
|
|
138
|
+
* message is still arriving. A CONTINUATION with no message started is dropped. Mirrors the Python master
|
|
139
|
+
* (tina4_python.websocket _handle_frame). One per connection.
|
|
140
|
+
*/
|
|
141
|
+
export declare class MessageAssembler {
|
|
142
|
+
private parts;
|
|
143
|
+
private opcode;
|
|
144
|
+
push(frame: {
|
|
145
|
+
fin: boolean;
|
|
146
|
+
opcode: number;
|
|
147
|
+
payload: Buffer;
|
|
148
|
+
}): {
|
|
149
|
+
opcode: number;
|
|
150
|
+
payload: Buffer;
|
|
151
|
+
} | null;
|
|
152
|
+
}
|
|
134
153
|
export declare class WebSocketServer {
|
|
135
154
|
private port;
|
|
136
155
|
private server;
|
|
@@ -271,6 +290,8 @@ export declare class WebSocketServer {
|
|
|
271
290
|
*/
|
|
272
291
|
private startIdleReaper;
|
|
273
292
|
private handleUpgrade;
|
|
293
|
+
private readonly assemblers;
|
|
294
|
+
private assemblerFor;
|
|
274
295
|
private processBuffer;
|
|
275
296
|
private removeClientFromAllRooms;
|
|
276
297
|
}
|
|
@@ -53,6 +53,13 @@ export declare const expressionFormCache: Map<string, number>;
|
|
|
53
53
|
* Set the session ID used by formToken() / form_token() for CSRF session binding.
|
|
54
54
|
*/
|
|
55
55
|
export declare function setFormTokenSessionId(sessionId: string): void;
|
|
56
|
+
/**
|
|
57
|
+
* Register the function that backs the `{{ csp_nonce() }}` template global.
|
|
58
|
+
* @tina4/core calls this once with its request-scoped nonce source so every
|
|
59
|
+
* Frond instance (framework templates AND the app's) resolves the same nonce
|
|
60
|
+
* the Content-Security-Policy header names.
|
|
61
|
+
*/
|
|
62
|
+
export declare function setCspNonceProvider(fn: () => string): void;
|
|
56
63
|
export declare class Frond {
|
|
57
64
|
private static classFilters;
|
|
58
65
|
private static classGlobals;
|