tina4-nodejs 3.13.149 → 3.13.151
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 +2 -2
- package/package.json +5 -9
- package/packages/cli/dist/bin.js +1074 -521
- package/packages/core/dist/index.js +1071 -516
- package/packages/core/public/bundled-component-provenance.json +2 -2
- package/packages/core/public/js/tina4-dev-admin-split.html +202 -0
- package/packages/core/public/js/tina4-dev-admin.min.js +147 -138
- package/packages/core/src/aiClient.ts +2 -1
- package/packages/core/src/api.ts +2 -1
- package/packages/core/src/auth.ts +1 -1
- package/packages/core/src/cache.ts +6 -1
- package/packages/core/src/devAdmin.ts +209 -123
- package/packages/core/src/dispatchPipeline.ts +4 -0
- package/packages/core/src/docs.ts +5 -3
- package/packages/core/src/errorOverlay.ts +18 -9
- package/packages/core/src/index.ts +2 -1
- package/packages/core/src/mcp.ts +53 -16
- package/packages/core/src/messenger.ts +22 -30
- package/packages/core/src/metrics.ts +5 -1
- package/packages/core/src/middleware.ts +8 -1
- package/packages/core/src/optionalPackage.ts +2 -2
- package/packages/core/src/plan.ts +4 -4
- package/packages/core/src/push.ts +1 -1
- package/packages/core/src/request.ts +185 -25
- package/packages/core/src/router.ts +3 -2
- package/packages/core/src/server.ts +32 -10
- package/packages/core/src/session.ts +44 -0
- package/packages/core/src/slashTrim.ts +39 -0
- package/packages/core/src/wsdl.ts +66 -48
- package/packages/core/templates/crud/form.twig +5 -5
- package/packages/core/templates/crud/table.twig +2 -2
- package/packages/core/templates/errors/302.twig +4 -3
- package/packages/frond/dist/index.js +206 -64
- package/packages/frond/src/engine.ts +278 -69
- package/packages/frond/src/index.ts +1 -1
- package/packages/orm/dist/index.js +1055 -502
- package/packages/orm/src/crudRender.ts +5 -5
- package/packages/orm/src/crudTable.ts +1 -1
- package/packages/orm/src/database.ts +15 -29
- package/packages/orm/src/databaseResult.ts +11 -1
- package/packages/orm/src/realtime/index.ts +1 -0
- package/packages/orm/src/realtime/realtime.ts +2 -1
- package/packages/orm/src/realtime/s3Client.ts +276 -0
- package/packages/orm/src/realtime/storage.ts +19 -60
- package/packages/orm/src/sqlTranslator.ts +58 -42
- package/packages/swagger/dist/index.js +25 -4
- package/packages/swagger/src/generator.ts +7 -4
- package/packages/swagger/src/ui.ts +2 -1
- package/types/core/src/devAdmin.d.ts +40 -0
- package/types/core/src/index.d.ts +2 -1
- package/types/core/src/middleware.d.ts +1 -0
- package/types/core/src/optionalPackage.d.ts +2 -2
- package/types/core/src/request.d.ts +5 -1
- package/types/core/src/session.d.ts +25 -0
- package/types/core/src/slashTrim.d.ts +6 -0
- package/types/frond/src/engine.d.ts +11 -0
- package/types/frond/src/index.d.ts +1 -1
- package/types/orm/src/realtime/index.d.ts +1 -0
- package/types/orm/src/realtime/s3Client.d.ts +48 -0
- package/types/orm/src/realtime/storage.d.ts +9 -7
|
@@ -29,6 +29,53 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
|
29
29
|
|
|
30
30
|
import { DatabaseUrl } from "./databaseUrl.js";
|
|
31
31
|
import { DEFAULT_SRID, SpatialNotSupportedError } from "./point.js";
|
|
32
|
+
|
|
33
|
+
// ── placeholder-scan helpers ─────────────────────────────────
|
|
34
|
+
// The `?`-placeholder scanner skips over literals/comments it must not count.
|
|
35
|
+
// Each span-skip is a pure "return the index just past this span" helper so the
|
|
36
|
+
// scan loop itself stays flat (keeps placeholderPositions' complexity down).
|
|
37
|
+
const isIdentChar = (ch: string | undefined): boolean => !!ch && /[A-Za-z0-9_]/.test(ch);
|
|
38
|
+
|
|
39
|
+
/** Index just past the quoted string/identifier opening at i (sql[i] is the quote). */
|
|
40
|
+
function skipQuoted(sql: string, i: number, backslashEscapes: boolean): number {
|
|
41
|
+
const c = sql[i];
|
|
42
|
+
// E'...' (Postgres escape string) honours backslash escapes; so does every
|
|
43
|
+
// quoted string when the engine does (MySQL).
|
|
44
|
+
const escapeString = c === "'" && (sql[i - 1] === "E" || sql[i - 1] === "e") && !isIdentChar(sql[i - 2]);
|
|
45
|
+
const backslash = c !== "`" && (escapeString || backslashEscapes);
|
|
46
|
+
const n = sql.length;
|
|
47
|
+
i++;
|
|
48
|
+
while (i < n) {
|
|
49
|
+
if (backslash && sql[i] === "\\") { i += 2; continue; }
|
|
50
|
+
if (sql[i] === c) {
|
|
51
|
+
if (sql[i + 1] === c) { i += 2; continue; } // doubled quote = escaped quote
|
|
52
|
+
return i + 1;
|
|
53
|
+
}
|
|
54
|
+
i++;
|
|
55
|
+
}
|
|
56
|
+
return i;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Index just past the `-- ...` line comment opening at i. */
|
|
60
|
+
function skipLineComment(sql: string, i: number): number {
|
|
61
|
+
while (i < sql.length && sql[i] !== "\n") i++;
|
|
62
|
+
return i;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Index just past the block comment opening at i. */
|
|
66
|
+
function skipBlockComment(sql: string, i: number): number {
|
|
67
|
+
const end = sql.indexOf("*/", i + 2);
|
|
68
|
+
return end === -1 ? sql.length : end + 2;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Index just past the `$tag$ ... $tag$` dollar-quoted block opening at i, or i if none opens here. */
|
|
72
|
+
function skipDollarQuote(sql: string, i: number): number {
|
|
73
|
+
const tag = sql.slice(i).match(/^\$([A-Za-z_][A-Za-z0-9_]*)?\$/);
|
|
74
|
+
if (!tag) return i;
|
|
75
|
+
const end = sql.indexOf(tag[0], i + tag[0].length);
|
|
76
|
+
return end === -1 ? sql.length : end + tag[0].length;
|
|
77
|
+
}
|
|
78
|
+
|
|
32
79
|
export class SQLTranslator {
|
|
33
80
|
private static readonly SPATIAL_ENGINES = new Set(["postgres", "postgresql"]);
|
|
34
81
|
private static readonly SPATIAL_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$/;
|
|
@@ -357,51 +404,17 @@ export class SQLTranslator {
|
|
|
357
404
|
static placeholderPositions(sql: string, options: { backslashEscapes?: boolean } = {}): number[] {
|
|
358
405
|
const positions: number[] = [];
|
|
359
406
|
const n = sql.length;
|
|
360
|
-
const isIdent = (ch: string | undefined): boolean => !!ch && /[A-Za-z0-9_]/.test(ch);
|
|
361
407
|
let i = 0;
|
|
362
408
|
while (i < n) {
|
|
363
409
|
const c = sql[i];
|
|
364
410
|
const next = sql[i + 1];
|
|
365
|
-
|
|
366
|
-
if (c === "
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
const
|
|
370
|
-
|
|
371
|
-
const backslash = c !== "`" && (escapeString || options.backslashEscapes === true);
|
|
372
|
-
i++;
|
|
373
|
-
while (i < n) {
|
|
374
|
-
if (backslash && sql[i] === "\\") { i += 2; continue; }
|
|
375
|
-
if (sql[i] === c) {
|
|
376
|
-
if (sql[i + 1] === c) { i += 2; continue; }
|
|
377
|
-
i++;
|
|
378
|
-
break;
|
|
379
|
-
}
|
|
380
|
-
i++;
|
|
381
|
-
}
|
|
382
|
-
continue;
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
if (c === "-" && next === "-") {
|
|
386
|
-
while (i < n && sql[i] !== "\n") i++;
|
|
387
|
-
continue;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
if (c === "/" && next === "*") {
|
|
391
|
-
const end = sql.indexOf("*/", i + 2);
|
|
392
|
-
i = end === -1 ? n : end + 2;
|
|
393
|
-
continue;
|
|
411
|
+
if (c === "'" || c === '"' || c === "`") { i = skipQuoted(sql, i, options.backslashEscapes === true); continue; }
|
|
412
|
+
if (c === "-" && next === "-") { i = skipLineComment(sql, i); continue; }
|
|
413
|
+
if (c === "/" && next === "*") { i = skipBlockComment(sql, i); continue; }
|
|
414
|
+
if (c === "$" && !isIdentChar(sql[i - 1])) {
|
|
415
|
+
const past = skipDollarQuote(sql, i);
|
|
416
|
+
if (past !== i) { i = past; continue; }
|
|
394
417
|
}
|
|
395
|
-
|
|
396
|
-
if (c === "$" && !isIdent(sql[i - 1])) {
|
|
397
|
-
const tag = sql.slice(i).match(/^\$([A-Za-z_][A-Za-z0-9_]*)?\$/);
|
|
398
|
-
if (tag) {
|
|
399
|
-
const end = sql.indexOf(tag[0], i + tag[0].length);
|
|
400
|
-
i = end === -1 ? n : end + tag[0].length;
|
|
401
|
-
continue;
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
|
|
405
418
|
if (c === "?") positions.push(i);
|
|
406
419
|
i++;
|
|
407
420
|
}
|
|
@@ -437,7 +450,7 @@ export class SQLTranslator {
|
|
|
437
450
|
* → { sql: "INSERT INTO t (x) VALUES (1)", columns: ["id", "name"] }
|
|
438
451
|
*/
|
|
439
452
|
static parseReturning(sql: string): { sql: string; columns: string[] } {
|
|
440
|
-
const m = sql.match(/\bRETURNING\s+(
|
|
453
|
+
const m = sql.match(/\bRETURNING\s+(\S.*)$/i);
|
|
441
454
|
if (!m) return { sql, columns: [] };
|
|
442
455
|
const columns = m[1].split(",").map((c) => c.trim());
|
|
443
456
|
return {
|
|
@@ -496,7 +509,10 @@ export class SQLTranslator {
|
|
|
496
509
|
// start of the VALUES group rather than by hunting for a parenthesis (the
|
|
497
510
|
// column list has parentheses too).
|
|
498
511
|
private static readonly INSERT_VALUES =
|
|
499
|
-
|
|
512
|
+
// ReDoS-safe: the table ref matches `\S(?:[\s\S]*?\S)?` (bounded by non-space
|
|
513
|
+
// at both ends) so the separator `\s+` before VALUES can't overlap it the way
|
|
514
|
+
// the old `.+?\s+` did (polynomial on `INSERT INTO ` + a long space run).
|
|
515
|
+
/^\s*INSERT\s+INTO\s+\S(?:[\s\S]*?\S)?\s+VALUES\s*\(([^()]*)\)\s*$/dis;
|
|
500
516
|
|
|
501
517
|
/**
|
|
502
518
|
* Engines whose lastInsertId reports the FIRST generated id of a multi-row
|
|
@@ -1,3 +1,21 @@
|
|
|
1
|
+
// ../core/src/slashTrim.ts
|
|
2
|
+
var SLASH = 47;
|
|
3
|
+
function rtrimSlashes(s) {
|
|
4
|
+
let end = s.length;
|
|
5
|
+
while (end > 0 && s.charCodeAt(end - 1) === SLASH) end--;
|
|
6
|
+
return end === s.length ? s : s.slice(0, end);
|
|
7
|
+
}
|
|
8
|
+
function trimChar(s, code) {
|
|
9
|
+
let start = 0;
|
|
10
|
+
let end = s.length;
|
|
11
|
+
while (start < end && s.charCodeAt(start) === code) start++;
|
|
12
|
+
while (end > start && s.charCodeAt(end - 1) === code) end--;
|
|
13
|
+
return start === 0 && end === s.length ? s : s.slice(start, end);
|
|
14
|
+
}
|
|
15
|
+
function trimSlashes(s) {
|
|
16
|
+
return trimChar(s, SLASH);
|
|
17
|
+
}
|
|
18
|
+
|
|
1
19
|
// src/generator.ts
|
|
2
20
|
var WRITE_METHODS = /* @__PURE__ */ new Set(["post", "put", "patch", "delete"]);
|
|
3
21
|
var INTERNAL_PREFIXES = ["/swagger", "/__dev", "/__feedback", "/ai", "/rag", "/vision", "/embed", "/image"];
|
|
@@ -85,7 +103,10 @@ function generate(routes, models = []) {
|
|
|
85
103
|
openapi: resolveOpenApiVersion(),
|
|
86
104
|
info: buildInfo(),
|
|
87
105
|
servers: resolveServers(),
|
|
88
|
-
|
|
106
|
+
// Prototype-less: route paths are developer-defined, but a null prototype
|
|
107
|
+
// makes `spec.paths[path][method] = op` structurally pollution-proof
|
|
108
|
+
// regardless (js/prototype-polluting-assignment). Serialises to JSON the same.
|
|
109
|
+
paths: /* @__PURE__ */ Object.create(null),
|
|
89
110
|
components: {
|
|
90
111
|
schemas: {},
|
|
91
112
|
// Configurable security schemes (v3.13.42): bearerFormat via env, optional
|
|
@@ -150,7 +171,7 @@ function buildOperation(route, spec, ctx) {
|
|
|
150
171
|
const openApiPath = patternToOpenAPI(route.pattern);
|
|
151
172
|
const method = route.method.toLowerCase();
|
|
152
173
|
if (!spec.paths[openApiPath]) {
|
|
153
|
-
spec.paths[openApiPath] =
|
|
174
|
+
spec.paths[openApiPath] = /* @__PURE__ */ Object.create(null);
|
|
154
175
|
}
|
|
155
176
|
const tags = route.meta?.tags ?? inferTags(route.pattern);
|
|
156
177
|
for (const t of tags) {
|
|
@@ -468,7 +489,7 @@ function inferModelFromPath(pattern) {
|
|
|
468
489
|
return null;
|
|
469
490
|
}
|
|
470
491
|
function operationIdBase(method, openApiPath) {
|
|
471
|
-
const clean = openApiPath
|
|
492
|
+
const clean = trimSlashes(openApiPath).replace(/\//g, "_").replace(/\.\.\./g, "").replace(/[{}]/g, "").replace(/\*/g, "wildcard");
|
|
472
493
|
return clean ? `${method}_${clean}` : method;
|
|
473
494
|
}
|
|
474
495
|
function uniqueOperationId(method, openApiPath, seen) {
|
|
@@ -485,7 +506,7 @@ function uniqueOperationId(method, openApiPath, seen) {
|
|
|
485
506
|
|
|
486
507
|
// src/ui.ts
|
|
487
508
|
function swaggerUiCdn() {
|
|
488
|
-
return (process.env.TINA4_SWAGGER_UI_CDN ?? "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5")
|
|
509
|
+
return rtrimSlashes(process.env.TINA4_SWAGGER_UI_CDN ?? "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5");
|
|
489
510
|
}
|
|
490
511
|
var SWAGGER_UI_HTML = (specUrl, nonce) => `<!DOCTYPE html>
|
|
491
512
|
<html lang="en">
|
|
@@ -7,6 +7,7 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { RouteDefinition } from "../../core/src/index.js";
|
|
10
|
+
import { trimSlashes } from "../../core/src/slashTrim.js";
|
|
10
11
|
import type { ModelDefinition, FieldDefinition } from "../../orm/src/index.js";
|
|
11
12
|
|
|
12
13
|
interface OpenAPISpecInfo {
|
|
@@ -191,7 +192,10 @@ export function generate(
|
|
|
191
192
|
openapi: resolveOpenApiVersion(),
|
|
192
193
|
info: buildInfo(),
|
|
193
194
|
servers: resolveServers(),
|
|
194
|
-
|
|
195
|
+
// Prototype-less: route paths are developer-defined, but a null prototype
|
|
196
|
+
// makes `spec.paths[path][method] = op` structurally pollution-proof
|
|
197
|
+
// regardless (js/prototype-polluting-assignment). Serialises to JSON the same.
|
|
198
|
+
paths: Object.create(null),
|
|
195
199
|
components: {
|
|
196
200
|
schemas: {},
|
|
197
201
|
// Configurable security schemes (v3.13.42): bearerFormat via env, optional
|
|
@@ -297,7 +301,7 @@ function buildOperation(route: RouteDefinition, spec: OpenAPISpec, ctx: BuildCon
|
|
|
297
301
|
const method = route.method.toLowerCase();
|
|
298
302
|
|
|
299
303
|
if (!spec.paths[openApiPath]) {
|
|
300
|
-
spec.paths[openApiPath] =
|
|
304
|
+
spec.paths[openApiPath] = Object.create(null);
|
|
301
305
|
}
|
|
302
306
|
|
|
303
307
|
const tags = route.meta?.tags ?? inferTags(route.pattern);
|
|
@@ -793,8 +797,7 @@ function inferModelFromPath(pattern: string): string | null {
|
|
|
793
797
|
* distinct ids straight from the two distinct paths.
|
|
794
798
|
*/
|
|
795
799
|
function operationIdBase(method: string, openApiPath: string): string {
|
|
796
|
-
const clean = openApiPath
|
|
797
|
-
.replace(/^\/+|\/+$/g, "") // strip leading/trailing slashes (Python .strip("/"))
|
|
800
|
+
const clean = trimSlashes(openApiPath) // strip leading/trailing slashes (ReDoS-safe)
|
|
798
801
|
.replace(/\//g, "_") // internal slash -> underscore
|
|
799
802
|
.replace(/\.\.\./g, "") // catch-all '...' inside braces -> nothing ({...slug} -> slug)
|
|
800
803
|
.replace(/[{}]/g, "") // drop braces
|
|
@@ -7,6 +7,7 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { Tina4Request, Tina4Response, RouteDefinition } from "../../core/src/index.js";
|
|
10
|
+
import { rtrimSlashes } from "../../core/src/slashTrim.js";
|
|
10
11
|
|
|
11
12
|
// The UI assets load from a CDN by default (a documented architecture decision —
|
|
12
13
|
// we don't vendor ~1.4MB of swagger-ui-dist, to stay small). jsdelivr
|
|
@@ -15,7 +16,7 @@ import type { Tina4Request, Tina4Response, RouteDefinition } from "../../core/sr
|
|
|
15
16
|
// splitting jsdelivr/unpkg. Air-gapped deployments point TINA4_SWAGGER_UI_CDN
|
|
16
17
|
// at a self-hosted mirror (a base URL serving swagger-ui.css + swagger-ui-bundle.js).
|
|
17
18
|
function swaggerUiCdn(): string {
|
|
18
|
-
return (process.env.TINA4_SWAGGER_UI_CDN ?? "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5")
|
|
19
|
+
return rtrimSlashes(process.env.TINA4_SWAGGER_UI_CDN ?? "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5");
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
// The inline <style> and the inline SwaggerUIBundle(...) <script> carry this
|
|
@@ -221,6 +221,46 @@ export declare class DevAdmin {
|
|
|
221
221
|
reload?: boolean;
|
|
222
222
|
}): string;
|
|
223
223
|
}
|
|
224
|
+
/** A column as the DB adapter's getColumns() reports it, for seeding. */
|
|
225
|
+
interface SeedColumn {
|
|
226
|
+
name: string;
|
|
227
|
+
type: string;
|
|
228
|
+
primaryKey?: boolean;
|
|
229
|
+
}
|
|
230
|
+
/** The fake-data surface buildSeedFieldMap draws from (orm's FakeData). */
|
|
231
|
+
interface SeedFake {
|
|
232
|
+
email(): string;
|
|
233
|
+
name(): string;
|
|
234
|
+
phone(): string;
|
|
235
|
+
address(): string;
|
|
236
|
+
url(): string;
|
|
237
|
+
integer(min: number, max: number): number;
|
|
238
|
+
numeric(min: number, max: number, dp: number): number;
|
|
239
|
+
boolean(): boolean;
|
|
240
|
+
date(): unknown;
|
|
241
|
+
sentence(words: number): string;
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Resolve the seed request's options from the query string and JSON body (the
|
|
245
|
+
* query string wins). Exported so the parsing is tested directly. P4b accepts
|
|
246
|
+
* seed/clear/strict; an unparseable `seed` becomes undefined (not reproducible).
|
|
247
|
+
*/
|
|
248
|
+
export declare function parseSeedRequest(url: URL, body: Record<string, unknown> | null | undefined): {
|
|
249
|
+
table: string;
|
|
250
|
+
count: number;
|
|
251
|
+
seed: number | undefined;
|
|
252
|
+
clear: boolean;
|
|
253
|
+
strict: boolean;
|
|
254
|
+
};
|
|
255
|
+
/**
|
|
256
|
+
* Build a column -> fake-value generator map from the table's columns, keyed on
|
|
257
|
+
* the column NAME first (email/name/phone/address/url) then its SQL TYPE
|
|
258
|
+
* (int/float/bool/date), falling back to a short sentence. id / primary-key
|
|
259
|
+
* columns are skipped (the engine assigns them). Exported so the heuristic is
|
|
260
|
+
* tested directly. `fake` is seeded by the caller so a run is reproducible; its
|
|
261
|
+
* generators are closed over by every entry.
|
|
262
|
+
*/
|
|
263
|
+
export declare function buildSeedFieldMap(columns: SeedColumn[], fake: SeedFake): Record<string, () => unknown>;
|
|
224
264
|
/**
|
|
225
265
|
* Return the base URL for the co-located Rust agent server.
|
|
226
266
|
*
|
|
@@ -8,6 +8,7 @@ export { discoverRoutes } from "./routeDiscovery.js";
|
|
|
8
8
|
export { MiddlewareChain, MiddlewareRunner, cors, requestLogger, CorsMiddleware, RateLimiterMiddleware, RequestLogger, SecurityHeadersMiddleware, CsrfMiddleware, attachCsrfFromEnv } from "./middleware.js";
|
|
9
9
|
export type { CorsConfig } from "./middleware.js";
|
|
10
10
|
export { createRequest, makeCaseInsensitiveHeaders, parseMultipart, saveUpload } from "./request.js";
|
|
11
|
+
export type { FormValue } from "./request.js";
|
|
11
12
|
export { createResponse, errorResponse, setDefaultTemplatesDir, getFrond, setFrond, getFrameworkFrond, acceptPrefersJson, wantsJson, negotiatedErrorBody, } from "./response.js";
|
|
12
13
|
export { tryServeStatic } from "./static.js";
|
|
13
14
|
export { loadEnv, getEnv, requireEnv, hasEnv, allEnv, resetEnv, isTruthy } from "./dotenv.js";
|
|
@@ -45,7 +46,7 @@ export type { ApiResult, ApiOptions, ApiTransport, DownloadResult, UploadOptions
|
|
|
45
46
|
export { Context, defaultContext, existingContext, fts5Supported, _sharedContexts } from "./context/index.js";
|
|
46
47
|
export type { SearchHit } from "./context/index.js";
|
|
47
48
|
export { Events } from "./events.js";
|
|
48
|
-
export { DevAdmin, MessageLog, RequestInspector, ErrorTracker, DevMailboxStore, DevQueue, WsTracker, supervisorBaseUrl, devAdminLanguage } from "./devAdmin.js";
|
|
49
|
+
export { DevAdmin, MessageLog, RequestInspector, ErrorTracker, DevMailboxStore, DevQueue, WsTracker, supervisorBaseUrl, devAdminLanguage, parseSeedRequest, buildSeedFieldMap } from "./devAdmin.js";
|
|
49
50
|
export { feedbackEnabled, feedbackWhitelist, feedbackIdentifyUser, feedbackIsWhitelisted, feedbackRateLimitOk, injectFeedbackWidget, handleFeedbackTurn, handleFeedbackWidgetJs, registerFeedbackRoutes, } from "./feedback.js";
|
|
50
51
|
export { Messenger, MessengerConnectionError, createMessenger } from "./messenger.js";
|
|
51
52
|
export type { SendResult, EmailMessage } from "./messenger.js";
|
|
@@ -325,6 +325,7 @@ export declare class RequestLogger {
|
|
|
325
325
|
* TINA4_CSP — Content-Security-Policy (default: "default-src 'self'")
|
|
326
326
|
* TINA4_REFERRER_POLICY — Referrer-Policy (default: "strict-origin-when-cross-origin")
|
|
327
327
|
* TINA4_PERMISSIONS_POLICY — Permissions-Policy (default: "camera=(), microphone=(), geolocation=()")
|
|
328
|
+
* TINA4_COOP — Cross-Origin-Opener-Policy (default: "same-origin-allow-popups")
|
|
328
329
|
*
|
|
329
330
|
* Usage:
|
|
330
331
|
* Router.use(SecurityHeadersMiddleware);
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
* APPLICATION installs, never the framework (ADR-0067).
|
|
4
4
|
*
|
|
5
5
|
* tina4-nodejs installs as exactly one package. `pg`, `mongodb`, `redis` and
|
|
6
|
-
* the
|
|
7
|
-
*
|
|
6
|
+
* the SQL drivers are optional peerDependencies, which npm does not install, so
|
|
7
|
+
* for most apps "missing" is the normal state. Every feature that
|
|
8
8
|
* reaches for one must therefore fail at the point of use with the exact
|
|
9
9
|
* command that fixes it - never a bare MODULE_NOT_FOUND, never a silent no-op.
|
|
10
10
|
*/
|
|
@@ -32,7 +32,7 @@ export declare class PayloadTooLargeError extends Error {
|
|
|
32
32
|
* Zero-dependency implementation.
|
|
33
33
|
*/
|
|
34
34
|
export declare function parseMultipart(body: Buffer, boundary: string): {
|
|
35
|
-
fields: Record<string,
|
|
35
|
+
fields: Record<string, FormValue>;
|
|
36
36
|
files: Record<string, UploadedFile | UploadedFile[]>;
|
|
37
37
|
};
|
|
38
38
|
/**
|
|
@@ -50,3 +50,7 @@ export declare function parseMultipart(body: Buffer, boundary: string): {
|
|
|
50
50
|
* @throws when the derived name is unsafe or would escape `targetDir`.
|
|
51
51
|
*/
|
|
52
52
|
export declare function saveUpload(file: UploadedFile, targetDir: string, filename?: string): string;
|
|
53
|
+
/** A form field's value once its name is nested: `tags[]` a list, `user[name]` an object. */
|
|
54
|
+
export type FormValue = string | FormValue[] | {
|
|
55
|
+
[key: string]: FormValue;
|
|
56
|
+
};
|
|
@@ -413,4 +413,29 @@ export declare function sessionCookieToSend(sess: {
|
|
|
413
413
|
}, rawRes: {
|
|
414
414
|
headersSent: boolean;
|
|
415
415
|
}): string | null;
|
|
416
|
+
/**
|
|
417
|
+
* The session of the request being dispatched, for CSRF form-token binding.
|
|
418
|
+
*
|
|
419
|
+
* Frond's form_token() reads it through the provider core registers
|
|
420
|
+
* (setFormTokenSessionProvider), so a token is bound to the session that
|
|
421
|
+
* rendered it. It lives in an AsyncLocalStorage, like the CSP nonce, so two
|
|
422
|
+
* requests interleaving on the one event loop never bind to each other's session.
|
|
423
|
+
*/
|
|
424
|
+
interface BindableSession {
|
|
425
|
+
getSessionId(): string | null;
|
|
426
|
+
isFresh(): boolean;
|
|
427
|
+
set(key: string, value: unknown): void;
|
|
428
|
+
}
|
|
429
|
+
/** Run `fn` with a request scope the dispatcher publishes its session into. */
|
|
430
|
+
export declare function runWithFormTokenSession<T>(fn: () => T): T;
|
|
431
|
+
/** Publish the current request's session (no-op outside a request scope). */
|
|
432
|
+
export declare function setFormTokenSession(session: BindableSession | null): void;
|
|
433
|
+
/**
|
|
434
|
+
* The current request's session id, or "" outside a request.
|
|
435
|
+
*
|
|
436
|
+
* A session that holds nothing is sent no cookie, so the form's own POST would
|
|
437
|
+
* arrive under a newly minted id and fail its own token. Rendering a bound
|
|
438
|
+
* token therefore stores a marker, which makes the session persist.
|
|
439
|
+
*/
|
|
440
|
+
export declare function currentFormTokenSessionId(): string;
|
|
416
441
|
export {};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Strip the given char code from the end only (the `/\/+$/` replacement). */
|
|
2
|
+
export declare function rtrimSlashes(s: string): string;
|
|
3
|
+
/** Strip the given char code from both ends (linear, no backtracking). */
|
|
4
|
+
export declare function trimChar(s: string, code: number): string;
|
|
5
|
+
/** Strip leading AND trailing '/' (the ReDoS-safe `/^\/+|\/+$/g` replacement). */
|
|
6
|
+
export declare function trimSlashes(s: string): string;
|
|
@@ -47,6 +47,12 @@ export declare const TEMPLATE_CACHE_MAX = 256;
|
|
|
47
47
|
* string, the same order of magnitude as a compiled template.
|
|
48
48
|
*/
|
|
49
49
|
export declare const MEMO_CACHE_MAX = 1024;
|
|
50
|
+
/**
|
|
51
|
+
* Integers from start to end inclusive, counting down when start is greater
|
|
52
|
+
* than end. Backs both range() and `a..b`; [] for a bound that is not a
|
|
53
|
+
* number or a zero step.
|
|
54
|
+
*/
|
|
55
|
+
export declare function frondRange(start: unknown, end: unknown, step?: unknown): number[];
|
|
50
56
|
/** Cached expression dispatcher branch; exported only for cache-bound verification. */
|
|
51
57
|
export declare const expressionFormCache: Map<string, number>;
|
|
52
58
|
/**
|
|
@@ -59,6 +65,11 @@ export declare function escapeHtml(value: unknown): string;
|
|
|
59
65
|
* Set the session ID used by formToken() / form_token() for CSRF session binding.
|
|
60
66
|
*/
|
|
61
67
|
export declare function setFormTokenSessionId(sessionId: string): void;
|
|
68
|
+
/**
|
|
69
|
+
* Register the function that gives form_token() the current request's session
|
|
70
|
+
* id, so each token is bound to the session that rendered it (CSRF).
|
|
71
|
+
*/
|
|
72
|
+
export declare function setFormTokenSessionProvider(fn: () => string): void;
|
|
62
73
|
/**
|
|
63
74
|
* Register the function that backs the `{{ csp_nonce() }}` template global.
|
|
64
75
|
* @tina4/core calls this once with its request-scoped nonce source so every
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { Frond, escapeHtml, setCspNonceProvider } from "./engine.js";
|
|
1
|
+
export { Frond, escapeHtml, setCspNonceProvider, setFormTokenSessionProvider } from "./engine.js";
|
|
2
2
|
export type { FilterFn, TestFn, LiveProvider, LiveRequest, LiveResponse, LiveBroadcaster } from "./engine.js";
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { realtime, iceServers, type RealtimeOptions } from "./realtime.js";
|
|
2
2
|
export { LocalStorage, S3Storage, selectStorage, storageKey, type StorageBackend, } from "./storage.js";
|
|
3
|
+
export { S3Client, S3Error, S3ConfigError, uriEncode } from "./s3Client.js";
|
|
3
4
|
export { default as Workspace } from "./models/workspace.js";
|
|
4
5
|
export { default as Channel } from "./models/channel.js";
|
|
5
6
|
export { default as ChannelMember } from "./models/channelMember.js";
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** An S3 request returned a non-2xx status; `status` holds the HTTP code. */
|
|
2
|
+
export declare class S3Error extends Error {
|
|
3
|
+
readonly status: number;
|
|
4
|
+
/** Seconds from a Retry-After header in its delta-seconds form, else null. */
|
|
5
|
+
retryAfter: number | null;
|
|
6
|
+
constructor(status: number, message: string);
|
|
7
|
+
}
|
|
8
|
+
/** The client cannot sign a request: no access key or secret was given. */
|
|
9
|
+
export declare class S3ConfigError extends Error {
|
|
10
|
+
constructor(message: string);
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* SigV4 canonical encoding: keep the RFC 3986 unreserved set literal; keep "/"
|
|
14
|
+
* in a path and encode it in a query; every other byte becomes %XX (upper-case).
|
|
15
|
+
*/
|
|
16
|
+
export declare function uriEncode(value: string, isPath: boolean): string;
|
|
17
|
+
export declare class S3Client {
|
|
18
|
+
private readonly scheme;
|
|
19
|
+
private readonly host;
|
|
20
|
+
private readonly prefix;
|
|
21
|
+
private readonly region;
|
|
22
|
+
private readonly accessKey;
|
|
23
|
+
private readonly secretKey;
|
|
24
|
+
private readonly timeout;
|
|
25
|
+
constructor(opts?: {
|
|
26
|
+
endpoint?: string;
|
|
27
|
+
accessKey?: string;
|
|
28
|
+
secretKey?: string;
|
|
29
|
+
region?: string;
|
|
30
|
+
timeout?: number;
|
|
31
|
+
});
|
|
32
|
+
putObject(bucket: string, key: string, data: Buffer | string, contentType?: string): Promise<void>;
|
|
33
|
+
getObject(bucket: string, key: string): Promise<Buffer>;
|
|
34
|
+
deleteObject(bucket: string, key: string): Promise<void>;
|
|
35
|
+
headObject(bucket: string, key: string): Promise<void>;
|
|
36
|
+
/** Create the bucket (path-style `PUT /<bucket>`); an already-owned bucket (409) is success. */
|
|
37
|
+
createBucket(bucket: string): Promise<void>;
|
|
38
|
+
/** A URL that fetches the object directly, signed via SigV4 query params. */
|
|
39
|
+
presignedGetUrl(bucket: string, key: string, expires?: number): string;
|
|
40
|
+
private requireCredentials;
|
|
41
|
+
private now;
|
|
42
|
+
private canonicalUri;
|
|
43
|
+
private canonicalQuery;
|
|
44
|
+
private signingKey;
|
|
45
|
+
private sign;
|
|
46
|
+
private request;
|
|
47
|
+
private send;
|
|
48
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pluggable file storage for the realtime "files" feature. StorageBackend is
|
|
3
3
|
* the interface; LocalStorage (default, zero-dependency, filesystem) and
|
|
4
|
-
* S3Storage (opt-in, S3-compatible
|
|
5
|
-
*
|
|
4
|
+
* S3Storage (opt-in, S3-compatible, also zero-dependency — it uses Tina4's own
|
|
5
|
+
* stdlib SigV4 client, not @aws-sdk) are the shipped implementations.
|
|
6
|
+
* Selection mirrors the cache/session/queue backend pattern:
|
|
6
7
|
* TINA4_STORAGE_BACKEND (local default | s3) plus per-backend env vars, with a
|
|
7
8
|
* graceful fallback to local if an s3 backend cannot be constructed - a real
|
|
8
9
|
* persistent store, never a silent no-op. Parity with Python's storage.py.
|
|
@@ -33,10 +34,11 @@ export declare class LocalStorage implements StorageBackend {
|
|
|
33
34
|
exists(key: string): boolean;
|
|
34
35
|
}
|
|
35
36
|
/**
|
|
36
|
-
* S3-compatible store (AWS S3, MinIO, ...). Opt-in
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
37
|
+
* S3-compatible store (AWS S3, MinIO, Garage, ...). Opt-in, zero dependencies.
|
|
38
|
+
*
|
|
39
|
+
* Talks to S3 through Tina4's own stdlib SigV4 client (S3Client, node:crypto +
|
|
40
|
+
* global fetch) — no @aws-sdk packages. Presigned GET URLs let clients fetch
|
|
41
|
+
* large blobs straight from object storage instead of streaming through the app.
|
|
40
42
|
*/
|
|
41
43
|
export declare class S3Storage implements StorageBackend {
|
|
42
44
|
private client;
|
|
@@ -57,6 +59,6 @@ export declare class S3Storage implements StorageBackend {
|
|
|
57
59
|
/**
|
|
58
60
|
* Resolve the storage backend from an explicit instance or the environment.
|
|
59
61
|
* Falls back to LocalStorage (logging why) when an s3 backend cannot be built
|
|
60
|
-
* (
|
|
62
|
+
* (config incomplete, e.g. no bucket) - a real store, never a silent no-op.
|
|
61
63
|
*/
|
|
62
64
|
export declare function selectStorage(storage?: StorageBackend): StorageBackend;
|