tina4-nodejs 3.13.98 → 3.13.99
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 +24 -25
- package/package.json +1 -2
- package/packages/cli/dist/bin.js +20620 -18995
- package/packages/cli/src/bin.ts +28 -71
- package/packages/cli/src/commands/migrate.ts +36 -75
- package/packages/cli/src/commands/migrateRollback.ts +10 -1
- package/packages/cli/src/commands/test.ts +92 -21
- package/packages/core/dist/index.js +20459 -18815
- package/packages/core/public/js/tina4-dev-admin.min.js +23 -19
- package/packages/core/src/ai.ts +28 -12
- package/packages/core/src/api.ts +13 -5
- package/packages/core/src/background.ts +9 -3
- package/packages/core/src/devAdmin.ts +135 -20
- package/packages/core/src/dispatchPipeline.ts +185 -1
- package/packages/core/src/docs.ts +33 -5
- package/packages/core/src/env.ts +1 -1
- package/packages/core/src/errorOverlay.ts +39 -48
- package/packages/core/src/fakeData.ts +15 -0
- package/packages/core/src/index.ts +17 -6
- package/packages/core/src/logger.ts +892 -572
- package/packages/core/src/mcp.ts +9 -1
- package/packages/core/src/messenger.ts +31 -4
- package/packages/core/src/middleware.ts +169 -43
- package/packages/core/src/portTakeover.ts +232 -0
- package/packages/core/src/request.ts +57 -8
- package/packages/core/src/response.ts +67 -0
- package/packages/core/src/router.ts +35 -7
- package/packages/core/src/server.ts +450 -190
- package/packages/core/src/static.ts +81 -12
- package/packages/core/src/testClient.ts +126 -137
- package/packages/core/src/testing.ts +16 -12
- package/packages/core/src/types.ts +21 -9
- package/packages/core/src/version.ts +66 -0
- package/packages/core/src/websocket.ts +2 -2
- package/packages/core/src/websocketBackplane.ts +2 -2
- package/packages/frond/dist/index.js +31 -13
- package/packages/frond/src/engine.ts +39 -7
- package/packages/orm/dist/index.js +10879 -9258
- package/packages/orm/src/adapters/firebird.ts +200 -27
- package/packages/orm/src/adapters/mongodb.ts +160 -10
- package/packages/orm/src/adapters/mssql.ts +38 -11
- package/packages/orm/src/adapters/mysql.ts +24 -1
- package/packages/orm/src/adapters/odbc.ts +127 -29
- package/packages/orm/src/adapters/postgres.ts +18 -0
- package/packages/orm/src/adapters/sqlite.ts +93 -14
- package/packages/orm/src/autoCrud.ts +72 -8
- package/packages/orm/src/baseModel.ts +323 -71
- package/packages/orm/src/cachedDatabase.ts +48 -1
- package/packages/orm/src/database.ts +162 -59
- package/packages/orm/src/fakeData.ts +6 -2
- package/packages/orm/src/index.ts +4 -1
- package/packages/orm/src/migration.ts +95 -52
- package/packages/orm/src/query.ts +16 -4
- package/packages/orm/src/seeder.ts +43 -25
- package/packages/orm/src/sqlTranslator.ts +104 -19
- package/packages/orm/src/types.ts +97 -21
- package/packages/orm/src/validation.ts +5 -1
- package/packages/swagger/dist/index.js +3 -2
- package/packages/swagger/src/generator.ts +19 -4
- package/packages/swagger/src/ui.ts +6 -4
- package/types/cli/src/bin.d.ts +0 -22
- package/types/core/src/api.d.ts +11 -4
- package/types/core/src/background.d.ts +5 -2
- package/types/core/src/devAdmin.d.ts +35 -0
- package/types/core/src/dispatchPipeline.d.ts +41 -1
- package/types/core/src/errorOverlay.d.ts +13 -13
- package/types/core/src/index.d.ts +9 -6
- package/types/core/src/logger.d.ts +111 -185
- package/types/core/src/middleware.d.ts +40 -5
- package/types/core/src/portTakeover.d.ts +50 -0
- package/types/core/src/request.d.ts +15 -0
- package/types/core/src/response.d.ts +29 -0
- package/types/core/src/server.d.ts +92 -0
- package/types/core/src/testClient.d.ts +29 -3
- package/types/core/src/testing.d.ts +16 -12
- package/types/core/src/types.d.ts +21 -9
- package/types/core/src/version.d.ts +11 -0
- package/types/core/src/websocketBackplane.d.ts +1 -1
- package/types/frond/src/engine.d.ts +10 -0
- package/types/orm/src/adapters/firebird.d.ts +61 -2
- package/types/orm/src/adapters/mongodb.d.ts +20 -0
- package/types/orm/src/adapters/mssql.d.ts +11 -0
- package/types/orm/src/adapters/mysql.d.ts +11 -0
- package/types/orm/src/adapters/odbc.d.ts +35 -4
- package/types/orm/src/adapters/postgres.d.ts +11 -0
- package/types/orm/src/adapters/sqlite.d.ts +23 -4
- package/types/orm/src/baseModel.d.ts +45 -25
- package/types/orm/src/cachedDatabase.d.ts +27 -1
- package/types/orm/src/database.d.ts +56 -6
- package/types/orm/src/index.d.ts +3 -2
- package/types/orm/src/migration.d.ts +23 -5
- package/types/orm/src/query.d.ts +3 -0
- package/types/orm/src/seeder.d.ts +15 -2
- package/types/orm/src/sqlTranslator.d.ts +17 -4
- package/types/orm/src/types.d.ts +75 -16
- package/packages/core/src/errorOverlay.test.ts +0 -122
|
@@ -19,26 +19,115 @@ import { DevMailbox } from "./devMailbox.js";
|
|
|
19
19
|
import { isTruthy } from "./dotenv.js";
|
|
20
20
|
import { quickMetrics, fullAnalysis, fileDetail, MetricsEngineError } from "./metrics.js";
|
|
21
21
|
import { registerFeedbackRoutes } from "./feedback.js";
|
|
22
|
-
import { getDefaultDevServer, mcpEnabled, isRequestAllowed } from "./mcp.js";
|
|
22
|
+
import { getDefaultDevServer, mcpEnabled, isRequestAllowed, isLoopback } from "./mcp.js";
|
|
23
23
|
import { timingSafeEqual } from "node:crypto";
|
|
24
|
+
// VERSION-DEC-01 (feature 130): the dashboard reads the SAME resolved version
|
|
25
|
+
// as server.ts's banner/health and mcp.ts's default dev server -- one shared
|
|
26
|
+
// walk-up resolver in ./version.ts, not this file's own two-fixed-path reader
|
|
27
|
+
// (which also floored at "0.0.0" once @tina4/core was relocated out of the
|
|
28
|
+
// monorepo layout).
|
|
29
|
+
import { TINA4_VERSION } from "./version.js";
|
|
24
30
|
|
|
25
31
|
const cpuCount = osCpus().length;
|
|
26
32
|
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
33
|
+
// ── Dev-admin mutation security (feature 127, DEVADMIN-DEC-01/02/03) ──────────
|
|
34
|
+
// The dashboard can write files, run SQL and install packages, so it must assume
|
|
35
|
+
// the developer ALSO browses the web. Two fail-closed gates guard every /__dev
|
|
36
|
+
// write, and a secret denylist guards the file-read surface. Mirrors the Python
|
|
37
|
+
// master (tina4_python/dev_admin/__init__.py).
|
|
38
|
+
|
|
39
|
+
/** Safe HTTP methods that never carry a state change — they skip the write gate. */
|
|
40
|
+
export const DEV_SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The MCP surface carries its OWN richer loopback+token+remote gate
|
|
44
|
+
* (mcpRequestAllowed -> 404), so the REST loopback gate skips these prefixes and
|
|
45
|
+
* lets the MCP gate govern (keeps the mcp/call refusal a 404, not a 403).
|
|
46
|
+
*/
|
|
47
|
+
const DEV_MCP_PREFIXES = ["/__dev/api/mcp", "/__dev/mcp"];
|
|
48
|
+
|
|
49
|
+
/** Private-key / credential basenames the file endpoints must never serve. */
|
|
50
|
+
const DEV_SECRET_BASENAMES = new Set([
|
|
51
|
+
".env", ".envrc", "id_rsa", "id_dsa", "id_ecdsa", "id_ed25519",
|
|
52
|
+
]);
|
|
53
|
+
const DEV_SECRET_SUFFIXES = [".pem", ".key", ".pfx", ".p12", ".keystore", ".jks"];
|
|
54
|
+
|
|
55
|
+
/** HTML-escape `& < > " '` — the injected toolbar reflects the raw request path. */
|
|
56
|
+
function escapeHtml(value: string): string {
|
|
57
|
+
return String(value)
|
|
58
|
+
.replace(/&/g, "&")
|
|
59
|
+
.replace(/</g, "<")
|
|
60
|
+
.replace(/>/g, ">")
|
|
61
|
+
.replace(/"/g, """)
|
|
62
|
+
.replace(/'/g, "'");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Fail-closed same-origin check for a dev-admin mutation (DEVADMIN-DEC-01).
|
|
67
|
+
*
|
|
68
|
+
* A drive-by CSRF is a BROWSER cross-origin request, and a modern browser always
|
|
69
|
+
* sends `Sec-Fetch-Site` (and any browser sends `Origin` on a cross-origin POST):
|
|
70
|
+
* - Sec-Fetch-Site present -> trust the browser's classification
|
|
71
|
+
* (cross-site refused; same-origin / same-site / none ok).
|
|
72
|
+
* - else Origin present -> require its host to match the request Host.
|
|
73
|
+
* - else neither header -> not a browser cross-origin request (curl, a test
|
|
74
|
+
* client, a server-side caller); it cannot be a drive-by, so allow here and
|
|
75
|
+
* let the loopback gate still constrain the peer.
|
|
76
|
+
*/
|
|
77
|
+
export function devSameOriginOk(req: Tina4Request): boolean {
|
|
78
|
+
const secFetchSite = (req.header("sec-fetch-site") ?? "").trim().toLowerCase();
|
|
79
|
+
if (secFetchSite) {
|
|
80
|
+
return secFetchSite === "same-origin" || secFetchSite === "same-site" || secFetchSite === "none";
|
|
81
|
+
}
|
|
82
|
+
const origin = (req.header("origin") ?? "").trim();
|
|
83
|
+
if (origin) {
|
|
84
|
+
const netloc = origin.includes("://") ? origin.split("://", 2)[1] : origin;
|
|
85
|
+
const host = (req.header("host") ?? "").trim();
|
|
86
|
+
return host !== "" && netloc.toLowerCase() === host.toLowerCase();
|
|
87
|
+
}
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Return `{status, error}` to REFUSE a dev-admin write, or `null` to allow.
|
|
93
|
+
*
|
|
94
|
+
* Two independent fail-closed gates on every /__dev mutation:
|
|
95
|
+
* DEVADMIN-DEC-01 same-origin (all writes, incl. mcp/call) - drive-by CSRF.
|
|
96
|
+
* DEVADMIN-DEC-02 loopback peer (all writes EXCEPT the MCP surface, which
|
|
97
|
+
* carries its own gate) - a network-exposed debug box.
|
|
98
|
+
* Reads the RAW socket peer (never X-Forwarded-For), exactly like the MCP gate.
|
|
99
|
+
*/
|
|
100
|
+
export function devMutationDenial(req: Tina4Request): { status: number; error: string } | null {
|
|
101
|
+
if (!devSameOriginOk(req)) {
|
|
102
|
+
return { status: 403, error: "dev-admin: refused (cross-origin request)" };
|
|
103
|
+
}
|
|
104
|
+
const path = req.path ?? "";
|
|
105
|
+
const isMcp = DEV_MCP_PREFIXES.some((prefix) => path.startsWith(prefix));
|
|
106
|
+
if (!isMcp) {
|
|
107
|
+
const peer = (req as unknown as { socket?: { remoteAddress?: string } }).socket?.remoteAddress ?? "";
|
|
108
|
+
if (!(isLoopback(peer) || mcpTokenOk(req))) {
|
|
109
|
+
return { status: 403, error: "dev-admin: refused (non-loopback peer)" };
|
|
38
110
|
}
|
|
39
|
-
}
|
|
40
|
-
return
|
|
41
|
-
}
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* True when `rel` names secret material the file endpoints must never serve
|
|
117
|
+
* (DEVADMIN-DEC-03): `.env` / `.env.*` (the `.env.example` template is allowed),
|
|
118
|
+
* anything under `.git/` or `secrets/`, and private-key material.
|
|
119
|
+
*/
|
|
120
|
+
export function isSecretPath(rel: string): boolean {
|
|
121
|
+
const norm = (rel ?? "").replace(/\\/g, "/").replace(/^\/+|\/+$/g, "").toLowerCase();
|
|
122
|
+
if (!norm) return false;
|
|
123
|
+
const parts = norm.split("/");
|
|
124
|
+
if (parts.some((p) => p === ".git" || p === "secrets")) return true;
|
|
125
|
+
const base = parts[parts.length - 1];
|
|
126
|
+
if (base === ".env.example") return false;
|
|
127
|
+
if (base === ".env" || base.startsWith(".env.")) return true;
|
|
128
|
+
if (DEV_SECRET_BASENAMES.has(base)) return true;
|
|
129
|
+
return DEV_SECRET_SUFFIXES.some((suffix) => base.endsWith(suffix));
|
|
130
|
+
}
|
|
42
131
|
|
|
43
132
|
// ---------------------------------------------------------------------------
|
|
44
133
|
// Types
|
|
@@ -1251,7 +1340,10 @@ const handleSeed: RouteHandler = async (req, res) => {
|
|
|
1251
1340
|
}
|
|
1252
1341
|
// P1 — delegate to the shared seedTable so each row is wrapped (no
|
|
1253
1342
|
// unhandled failure can crash the endpoint) and we get a summary back.
|
|
1254
|
-
|
|
1343
|
+
// SEED-TABLE-SEED-INERT: seedTable no longer takes opts.seed (it throws
|
|
1344
|
+
// if supplied) — reproducibility already comes from the seeded `fake`
|
|
1345
|
+
// built above and closed over by every entry in fieldMap.
|
|
1346
|
+
const summary = await seedTable(db, table, count, fieldMap, undefined, { clear, strict });
|
|
1255
1347
|
res.json({ seeded: summary.seeded, failed: summary.failed, errors: summary.errors, table });
|
|
1256
1348
|
} catch (e) {
|
|
1257
1349
|
res.json({ error: (e as Error)?.message ?? "Database not connected" });
|
|
@@ -2081,7 +2173,10 @@ const DEV_FILES_IGNORED = new Set([
|
|
|
2081
2173
|
// Hidden dot-entries are filtered too, except the env files.
|
|
2082
2174
|
function devFilesHidden(name: string): boolean {
|
|
2083
2175
|
if (DEV_FILES_IGNORED.has(name)) return true;
|
|
2084
|
-
|
|
2176
|
+
// DEVADMIN-DEC-03: hide every dotfile EXCEPT the safe `.env.example` template.
|
|
2177
|
+
// `.env` is a secret (TINA4_SECRET / DB password / TINA4_MCP_TOKEN) and must
|
|
2178
|
+
// NOT be surfaced in the browser (it used to be un-hidden here).
|
|
2179
|
+
return name.startsWith(".") && name !== ".env.example";
|
|
2085
2180
|
}
|
|
2086
2181
|
|
|
2087
2182
|
// Same 4-status mapping Python/PHP use for a porcelain code.
|
|
@@ -2171,6 +2266,9 @@ const handleFiles: RouteHandler = async (req, res) => {
|
|
|
2171
2266
|
if (devFilesHidden(name)) continue;
|
|
2172
2267
|
const full = join(target, name);
|
|
2173
2268
|
const entryRel = relative(root, full).replace(/\\/g, "/");
|
|
2269
|
+
// DEVADMIN-DEC-03: never surface secrets in the listing (.env, keys, .git/,
|
|
2270
|
+
// secrets/). The .env.example template is safe and stays visible.
|
|
2271
|
+
if (isSecretPath(entryRel)) continue;
|
|
2174
2272
|
|
|
2175
2273
|
let isDir = false;
|
|
2176
2274
|
let size: number | null = null;
|
|
@@ -2265,6 +2363,11 @@ export function devAdminLanguage(rel: string): string {
|
|
|
2265
2363
|
const handleFileRead: RouteHandler = (req, res) => {
|
|
2266
2364
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
2267
2365
|
const rel = url.searchParams.get("path") ?? "";
|
|
2366
|
+
// DEVADMIN-DEC-03: never serve secret material (.env, keys, .git/, secrets/).
|
|
2367
|
+
if (isSecretPath(rel)) {
|
|
2368
|
+
res.json({ error: "Refused: secret file", path: rel, content: "", language: "text", bytes: 0 }, 403);
|
|
2369
|
+
return;
|
|
2370
|
+
}
|
|
2268
2371
|
const root = resolve(process.cwd());
|
|
2269
2372
|
const target = safeJoin(root, rel);
|
|
2270
2373
|
if (!target || !existsSync(target) || !statSync(target).isFile()) {
|
|
@@ -2307,6 +2410,11 @@ const handleFileSave: RouteHandler = async (req, res) => {
|
|
|
2307
2410
|
const handleFileRaw: RouteHandler = (req, res) => {
|
|
2308
2411
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
2309
2412
|
const rel = url.searchParams.get("path") ?? "";
|
|
2413
|
+
// DEVADMIN-DEC-03: never serve secret material (.env, keys, .git/, secrets/).
|
|
2414
|
+
if (isSecretPath(rel)) {
|
|
2415
|
+
res.json({ error: "Refused: secret file" }, 403);
|
|
2416
|
+
return;
|
|
2417
|
+
}
|
|
2310
2418
|
const root = resolve(process.cwd());
|
|
2311
2419
|
const target = safeJoin(root, rel);
|
|
2312
2420
|
if (!target || !existsSync(target) || !statSync(target).isFile()) {
|
|
@@ -2873,6 +2981,13 @@ function renderToolbarHtml(ctx: {
|
|
|
2873
2981
|
routeCount: number;
|
|
2874
2982
|
}): string {
|
|
2875
2983
|
const nodeVersion = process.version;
|
|
2984
|
+
// DEVADMIN-DEC-04: the toolbar is injected into every text/html response
|
|
2985
|
+
// (including 404s), so the reflected request path/method MUST be HTML-escaped
|
|
2986
|
+
// or a crafted path reflects <script> that runs in the dev-server origin and
|
|
2987
|
+
// can then drive every /__dev mutation route. (PHP already escapes; parity.)
|
|
2988
|
+
const method = escapeHtml(ctx.method);
|
|
2989
|
+
const path = escapeHtml(ctx.path);
|
|
2990
|
+
const matchedPattern = escapeHtml(ctx.matchedPattern);
|
|
2876
2991
|
return `<div id="tina4-dev-toolbar" style="position:fixed;bottom:0;left:0;right:0;background:#333;color:#fff;font-family:monospace;font-size:12px;padding:6px 16px;z-index:99999;display:flex;align-items:center;gap:16px;">
|
|
2877
2992
|
<span id="tina4-ver-btn" style="color:#2e7d32;font-weight:bold;cursor:pointer;text-decoration:underline dotted;" onclick="tina4VersionModal()" title="Click to check for updates">Tina4 v${ctx.version}</span>
|
|
2878
2993
|
<div id="tina4-ver-modal" style="display:none;position:fixed;bottom:3rem;left:1rem;background:#1e1e2e;border:1px solid #2e7d32;border-radius:8px;padding:16px 20px;z-index:100000;min-width:320px;box-shadow:0 8px 32px rgba(0,0,0,0.5);font-family:monospace;font-size:13px;color:#cdd6f4;">
|
|
@@ -2885,9 +3000,9 @@ function renderToolbarHtml(ctx: {
|
|
|
2885
3000
|
<div id="tina4-ver-latest" style="color:#888;">Checking for updates...</div>
|
|
2886
3001
|
</div>
|
|
2887
3002
|
</div>
|
|
2888
|
-
<span style="color:#4caf50;">${
|
|
2889
|
-
<span>${
|
|
2890
|
-
<span style="color:#666;">→ ${
|
|
3003
|
+
<span style="color:#4caf50;">${method}</span>
|
|
3004
|
+
<span>${path}</span>
|
|
3005
|
+
<span style="color:#666;">→ ${matchedPattern}</span>
|
|
2891
3006
|
<span style="color:#ffeb3b;">req:${ctx.requestId}</span>
|
|
2892
3007
|
<span style="color:#90caf9;">${ctx.routeCount} routes</span>
|
|
2893
3008
|
<span style="color:#888;">Node.js ${nodeVersion}</span>
|
|
@@ -29,6 +29,8 @@
|
|
|
29
29
|
* the source of the stage-list-as-data pattern.
|
|
30
30
|
*/
|
|
31
31
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
32
|
+
import { gzipSync } from "node:zlib";
|
|
33
|
+
import { createHash } from "node:crypto";
|
|
32
34
|
import type { Tina4Request } from "./types.js";
|
|
33
35
|
import type { Session as SessionInstance } from "./session.js";
|
|
34
36
|
import { Log } from "./logger.js";
|
|
@@ -40,6 +42,7 @@ import { Log } from "./logger.js";
|
|
|
40
42
|
export const PROLOGUE_STAGES = [
|
|
41
43
|
"resetRequestCaches",
|
|
42
44
|
"headStripIntercept",
|
|
45
|
+
"compressionEtagIntercept",
|
|
43
46
|
"sessionAutoStart",
|
|
44
47
|
] as const;
|
|
45
48
|
|
|
@@ -172,6 +175,178 @@ export function headStripIntercept(rawReq: IncomingMessage, rawRes: ServerRespon
|
|
|
172
175
|
}) as typeof rawRes.end;
|
|
173
176
|
}
|
|
174
177
|
|
|
178
|
+
/** Content-type prefixes that benefit from gzip. Mirrors the Python master's `_is_compressible`. */
|
|
179
|
+
const COMPRESSIBLE_PREFIXES = [
|
|
180
|
+
"text/",
|
|
181
|
+
"application/json",
|
|
182
|
+
"application/xml",
|
|
183
|
+
"application/javascript",
|
|
184
|
+
"image/svg",
|
|
185
|
+
];
|
|
186
|
+
|
|
187
|
+
/** Whether a content type benefits from gzip compression (feature 40, CE-DEC-01). */
|
|
188
|
+
function isCompressibleContentType(contentType: string): boolean {
|
|
189
|
+
return COMPRESSIBLE_PREFIXES.some((prefix) => contentType.includes(prefix));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Match an If-None-Match header value against `etag` (RFC 7232 S3.2 weak
|
|
194
|
+
* comparison): an optional W/ prefix is ignored on both sides, the header may
|
|
195
|
+
* carry a comma-separated candidate list, and `*` matches any current
|
|
196
|
+
* representation. Same algorithm as static.ts's own matcher (kept local here
|
|
197
|
+
* rather than imported, since static.ts writes to the raw response directly
|
|
198
|
+
* and never reaches this interceptor).
|
|
199
|
+
*/
|
|
200
|
+
function etagMatchesInm(ifNoneMatch: string, etag: string): boolean {
|
|
201
|
+
const strip = (tag: string) => tag.trim().replace(/^W\//, "");
|
|
202
|
+
const target = strip(etag);
|
|
203
|
+
return ifNoneMatch.split(",").some((candidate) => {
|
|
204
|
+
const trimmed = candidate.trim();
|
|
205
|
+
return trimmed === "*" || strip(trimmed) === target;
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Gzip-compress + attach an ETag, and answer a matching conditional GET with
|
|
211
|
+
* a 304 that PRESERVES whichever validators the 200 would have carried
|
|
212
|
+
* (feature 40, CE-DEC-01/02). Mirrors the Python master's `build_headers()` +
|
|
213
|
+
* `app()` dispatch — the ONE header-builder step every DYNAMIC response
|
|
214
|
+
* funnels through.
|
|
215
|
+
*
|
|
216
|
+
* Node has no single "build the response, then send it" object the way
|
|
217
|
+
* Python/PHP/Ruby do — every response.ts method (json/html/text/xml/send/
|
|
218
|
+
* file/render) calls `res.end()` directly. So this intercepts `write`/`end`
|
|
219
|
+
* on the raw `ServerResponse` and buffers the body until `end()` is finally
|
|
220
|
+
* called, which is the only point a COMPLETE body — and therefore a
|
|
221
|
+
* Content-Length, a gzip candidate, and an ETag — exists to compute.
|
|
222
|
+
*
|
|
223
|
+
* BYPASS: a response that has ALREADY sent its headers by the time
|
|
224
|
+
* write()/end() is first called here (checked via `rawRes.headersSent`) is a
|
|
225
|
+
* streaming response — `response.ts`'s `stream()` calls `res.raw.writeHead()`
|
|
226
|
+
* up front, before any chunk — and is passed straight through unbuffered,
|
|
227
|
+
* exactly like Python's "streaming responses bypass ETag/compression".
|
|
228
|
+
*
|
|
229
|
+
* Installed in the PROLOGUE, right after `headStripIntercept`: since the LAST
|
|
230
|
+
* installed wrapper runs FIRST when `end()` is finally called, and
|
|
231
|
+
* `wrapResponseEnd` (dev-toolbar/feedback injection) installs LATER (in the
|
|
232
|
+
* REQUEST stage), the real execution order at send time is
|
|
233
|
+
* injection -> this -> `headStripIntercept` -> the true Node `res.end()` — so
|
|
234
|
+
* a HEAD response's preserved Content-Length reflects the (possibly
|
|
235
|
+
* compressed) body the equivalent GET would have sent, and the injected
|
|
236
|
+
* bytes are included in the compressed body + ETag hash, matching Python's
|
|
237
|
+
* ordering exactly.
|
|
238
|
+
*
|
|
239
|
+
* A static-file response (`static.ts`) still funnels through this same
|
|
240
|
+
* intercepted `write`/`end` — it pins its own weak size+mtime ETag and (when
|
|
241
|
+
* eligible) compresses itself BEFORE calling `res.raw.end()`, so by the time
|
|
242
|
+
* this runs, its status is already 200-with-ETag-set (this never overwrites
|
|
243
|
+
* it) or already 304 (the `statusCode === 200` guard below leaves it alone).
|
|
244
|
+
*
|
|
245
|
+
* @param rawReq Node's incoming message, read for Accept-Encoding / If-None-Match / If-Modified-Since
|
|
246
|
+
* @param rawRes Node's server response, whose write/end are replaced in place
|
|
247
|
+
*/
|
|
248
|
+
export function compressionEtagIntercept(rawReq: IncomingMessage, rawRes: ServerResponse): void {
|
|
249
|
+
const origEnd = rawRes.end.bind(rawRes);
|
|
250
|
+
const origWrite = rawRes.write.bind(rawRes);
|
|
251
|
+
const chunks: Buffer[] = [];
|
|
252
|
+
let bypass = false;
|
|
253
|
+
|
|
254
|
+
const toBuffer = (chunk: unknown, encoding?: unknown): Buffer | null => {
|
|
255
|
+
if (chunk == null || typeof chunk === "function") return null;
|
|
256
|
+
if (Buffer.isBuffer(chunk)) return chunk;
|
|
257
|
+
return Buffer.from(String(chunk), typeof encoding === "string" ? (encoding as BufferEncoding) : "utf-8");
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
rawRes.write = ((chunk?: any, encodingOrCb?: any, cb?: any): boolean => {
|
|
261
|
+
if (bypass || rawRes.headersSent) {
|
|
262
|
+
bypass = true;
|
|
263
|
+
return origWrite(chunk, encodingOrCb, cb);
|
|
264
|
+
}
|
|
265
|
+
const buf = toBuffer(chunk, typeof encodingOrCb === "string" ? encodingOrCb : undefined);
|
|
266
|
+
if (buf) chunks.push(buf);
|
|
267
|
+
const realCb = typeof encodingOrCb === "function" ? encodingOrCb : cb;
|
|
268
|
+
if (typeof realCb === "function") realCb();
|
|
269
|
+
return true;
|
|
270
|
+
}) as typeof rawRes.write;
|
|
271
|
+
|
|
272
|
+
rawRes.end = ((chunk?: any, encodingOrCb?: any, cb?: any): any => {
|
|
273
|
+
if (bypass || rawRes.headersSent) {
|
|
274
|
+
return origEnd(chunk, encodingOrCb, cb);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const buf = toBuffer(chunk, typeof encodingOrCb === "string" ? encodingOrCb : undefined);
|
|
278
|
+
if (buf) chunks.push(buf);
|
|
279
|
+
let body = chunks.length > 0 ? Buffer.concat(chunks) : Buffer.alloc(0);
|
|
280
|
+
const realCb = typeof encodingOrCb === "function" ? encodingOrCb : cb;
|
|
281
|
+
const statusCode = rawRes.statusCode || 200;
|
|
282
|
+
|
|
283
|
+
// Compression: body > 1024 bytes AND Accept-Encoding offers gzip AND the
|
|
284
|
+
// content type is compressible. Applies to ANY response (matches every
|
|
285
|
+
// route, not status-gated), same as the Python master.
|
|
286
|
+
//
|
|
287
|
+
// REAL BUG (found 2026-08-13, tina4cssServed.test.ts): a static-file
|
|
288
|
+
// response (static.ts) already gzips itself and sets Content-Encoding
|
|
289
|
+
// before calling res.raw.end() — but that end() is THIS intercepted one,
|
|
290
|
+
// so without the guard below it gzipped an already-gzipped body a second
|
|
291
|
+
// time. The client's one layer of automatic decompression then handed
|
|
292
|
+
// back a still-gzipped blob instead of the real bytes. Skip compression
|
|
293
|
+
// here whenever an earlier stage already set Content-Encoding.
|
|
294
|
+
const acceptEncoding = String(rawReq.headers["accept-encoding"] ?? "");
|
|
295
|
+
const contentTypeHeader = rawRes.getHeader("content-type");
|
|
296
|
+
const contentType = typeof contentTypeHeader === "string" ? contentTypeHeader : "";
|
|
297
|
+
const alreadyEncoded = !!rawRes.getHeader("content-encoding");
|
|
298
|
+
if (!alreadyEncoded && body.length > 1024 && acceptEncoding.includes("gzip") && isCompressibleContentType(contentType)) {
|
|
299
|
+
body = gzipSync(body, { level: 6 });
|
|
300
|
+
rawRes.setHeader("Content-Encoding", "gzip");
|
|
301
|
+
rawRes.setHeader("Vary", "Accept-Encoding");
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (statusCode === 200 && body.length > 0) {
|
|
305
|
+
// ETag: a strong md5 hash (first 16 hex chars) over the FINAL
|
|
306
|
+
// (post-compression) body, UNLESS a validator is already set — a
|
|
307
|
+
// static-file response (static.ts) pins its own weak size+mtime ETag
|
|
308
|
+
// before this ever runs (CE-DEC-02), so this never overwrites it with
|
|
309
|
+
// a content hash.
|
|
310
|
+
let etag = rawRes.getHeader("etag");
|
|
311
|
+
if (!etag) {
|
|
312
|
+
etag = `"${createHash("md5").update(body).digest("hex").slice(0, 16)}"`;
|
|
313
|
+
rawRes.setHeader("ETag", etag);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Conditional GET -> 304, preserving whichever validators are set.
|
|
317
|
+
// If-None-Match takes precedence over If-Modified-Since (RFC 9110 S13.1.3).
|
|
318
|
+
const ifNoneMatch = String(rawReq.headers["if-none-match"] ?? "");
|
|
319
|
+
const lastModifiedHeader = rawRes.getHeader("last-modified");
|
|
320
|
+
const lastModified = typeof lastModifiedHeader === "string" ? lastModifiedHeader : "";
|
|
321
|
+
let notModified = false;
|
|
322
|
+
if (ifNoneMatch) {
|
|
323
|
+
notModified = etagMatchesInm(ifNoneMatch, String(etag));
|
|
324
|
+
} else if (lastModified) {
|
|
325
|
+
const ifModifiedSince = String(rawReq.headers["if-modified-since"] ?? "");
|
|
326
|
+
if (ifModifiedSince) {
|
|
327
|
+
const modified = Date.parse(lastModified);
|
|
328
|
+
const since = Date.parse(ifModifiedSince);
|
|
329
|
+
notModified = !Number.isNaN(modified) && !Number.isNaN(since) && modified <= since;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (notModified) {
|
|
334
|
+
rawRes.statusCode = 304;
|
|
335
|
+
rawRes.removeHeader("Content-Type");
|
|
336
|
+
rawRes.removeHeader("Content-Encoding");
|
|
337
|
+
rawRes.removeHeader("Vary");
|
|
338
|
+
rawRes.removeHeader("Content-Length");
|
|
339
|
+
return typeof realCb === "function" ? origEnd(realCb) : origEnd();
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (!rawRes.headersSent && statusCode !== 304) {
|
|
344
|
+
rawRes.setHeader("Content-Length", body.length);
|
|
345
|
+
}
|
|
346
|
+
return typeof realCb === "function" ? origEnd(body, realCb) : origEnd(body);
|
|
347
|
+
}) as typeof rawRes.end;
|
|
348
|
+
}
|
|
349
|
+
|
|
175
350
|
/**
|
|
176
351
|
* `Type: message` for a caught value, so an operator reading the log sees the
|
|
177
352
|
* REAL driver failure rather than an opaque wrapper. Mirrors the Python fix's
|
|
@@ -278,7 +453,16 @@ export async function sessionAutoStart(
|
|
|
278
453
|
const xfProto = rawReq.headers["x-forwarded-proto"];
|
|
279
454
|
const forwardedProto = Array.isArray(xfProto) ? xfProto[0] : xfProto;
|
|
280
455
|
const socketEncrypted = (rawReq.socket as { encrypted?: boolean })?.encrypted === true;
|
|
281
|
-
|
|
456
|
+
// appendHeader, not setHeader (feature 131 fix, found while proving
|
|
457
|
+
// TC-DEC-02): setHeader REPLACES any existing Set-Cookie value wholesale,
|
|
458
|
+
// so a route that had already called response.cookie() of its own — on
|
|
459
|
+
// the SAME request that also needs a fresh session cookie (first visit
|
|
460
|
+
// to any route, or a session id rotation) — had its own cookie(s)
|
|
461
|
+
// silently discarded, live server included, not just under TestClient.
|
|
462
|
+
// appendHeader adds to whatever is already there (promoting a scalar to
|
|
463
|
+
// an array, extending an existing array) and behaves exactly like
|
|
464
|
+
// setHeader when nothing is set yet, so the common case is unchanged.
|
|
465
|
+
rawRes.appendHeader("Set-Cookie", buildSessionCookie(newSid, ttl, undefined, forwardedProto, socketEncrypted));
|
|
282
466
|
}
|
|
283
467
|
return origEnd(...args);
|
|
284
468
|
} as typeof rawRes.end;
|
|
@@ -872,6 +872,26 @@ function buildLineIndex(text: string): (offset: number) => number {
|
|
|
872
872
|
|
|
873
873
|
// ── Public Docs class ───────────────────────────────────────────────
|
|
874
874
|
|
|
875
|
+
/**
|
|
876
|
+
* Framework-wide reflection index, shared across every `Docs` instance in
|
|
877
|
+
* this process (keyed by frameworkRoots + version — both are baked into the
|
|
878
|
+
* built entries, see buildEntriesForFile's `rel`/`version` fields).
|
|
879
|
+
*
|
|
880
|
+
* `detectFrameworkRoots()` never depends on `projectRoot` (it walks up from
|
|
881
|
+
* THIS module's own file location), so the framework source tree is the same
|
|
882
|
+
* for every instance that resolves the same roots+version — re-walking and
|
|
883
|
+
* re-parsing it per INSTANCE was pure waste. Measured: docs.test.ts alone
|
|
884
|
+
* constructs ~17 fresh `Docs` instances, each paying a full AST walk over
|
|
885
|
+
* packages/{core,orm,swagger,frond}/src on first use — fast on an idle
|
|
886
|
+
* machine (~1s total on the lab), but it blew the test runner's 60s
|
|
887
|
+
* per-file budget on a CI runner under load (many concurrent service
|
|
888
|
+
* containers sharing 2 vCPUs), landing as "died before reporting" with no
|
|
889
|
+
* useful diagnostic. Still mtime-gated exactly as before (see `ensureIndex`),
|
|
890
|
+
* so a framework source edit during a live dev session is still picked up —
|
|
891
|
+
* this changes WHEN the scan is shared, never WHETHER the index stays fresh.
|
|
892
|
+
*/
|
|
893
|
+
const sharedFrameworkIndex = new Map<string, { entries: Map<string, InternalEntry>; mtime: number }>();
|
|
894
|
+
|
|
875
895
|
export class Docs {
|
|
876
896
|
private projectRoot: string;
|
|
877
897
|
private frameworkRoots: string[];
|
|
@@ -1157,16 +1177,24 @@ export class Docs {
|
|
|
1157
1177
|
}
|
|
1158
1178
|
|
|
1159
1179
|
private ensureIndex(): void {
|
|
1160
|
-
// Framework:
|
|
1180
|
+
// Framework: shared process-wide, rebuilt only if not built yet OR mtime
|
|
1181
|
+
// changed (see `sharedFrameworkIndex` above for why this is safe to share).
|
|
1182
|
+
const fwKey = `${this.frameworkRoots.join("|")}@${this.version}`;
|
|
1161
1183
|
const fwMtime = this.maxMtime(this.frameworkRoots);
|
|
1162
|
-
|
|
1163
|
-
|
|
1184
|
+
let shared = sharedFrameworkIndex.get(fwKey);
|
|
1185
|
+
if (!shared || fwMtime !== shared.mtime) {
|
|
1186
|
+
const entries = new Map<string, InternalEntry>();
|
|
1164
1187
|
for (const root of this.frameworkRoots) {
|
|
1165
1188
|
for (const f of walkTsFiles(root)) {
|
|
1166
|
-
buildEntriesForFile(f, "framework", this.frameworkRoots, this.projectRoot, this.version,
|
|
1189
|
+
buildEntriesForFile(f, "framework", this.frameworkRoots, this.projectRoot, this.version, entries);
|
|
1167
1190
|
}
|
|
1168
1191
|
}
|
|
1169
|
-
|
|
1192
|
+
shared = { entries, mtime: fwMtime };
|
|
1193
|
+
sharedFrameworkIndex.set(fwKey, shared);
|
|
1194
|
+
}
|
|
1195
|
+
if (this.frameworkEntries !== shared.entries) {
|
|
1196
|
+
this.frameworkEntries = shared.entries;
|
|
1197
|
+
this.frameworkMtime = shared.mtime;
|
|
1170
1198
|
this.indexCache = null;
|
|
1171
1199
|
}
|
|
1172
1200
|
|
package/packages/core/src/env.ts
CHANGED
|
@@ -1,28 +1,47 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tina4 Debug — Rich error overlay for development mode.
|
|
3
3
|
*
|
|
4
|
-
* Renders a
|
|
5
|
-
*
|
|
4
|
+
* Renders a rich HTML error page (exception type + message, the full stack with a
|
|
5
|
+
* seven-line source window per frame, request details, environment) when an unhandled
|
|
6
|
+
* exception reaches the server dispatch in development.
|
|
6
7
|
*
|
|
7
|
-
* import { renderErrorOverlay,
|
|
8
|
+
* import { renderErrorOverlay, isDebugMode } from "./errorOverlay.js";
|
|
8
9
|
*
|
|
9
10
|
* try {
|
|
10
11
|
* await handler(req, res);
|
|
11
12
|
* } catch (err) {
|
|
12
|
-
*
|
|
13
|
-
* ? renderErrorOverlay(err as Error, req)
|
|
14
|
-
* : renderProductionError();
|
|
15
|
-
* res.html(html, 500);
|
|
13
|
+
* if (isDebugMode()) res.html(renderErrorOverlay(err as Error, req), 500);
|
|
16
14
|
* }
|
|
17
15
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
16
|
+
* Dev-only: the caller gates this on isDebugMode() (TINA4_DEBUG). The production 500 is
|
|
17
|
+
* NOT rendered here — the server dispatch renders errors/500.twig with an empty
|
|
18
|
+
* error_message (CWE-209), so the exception detail stays in the server log only.
|
|
19
|
+
*
|
|
20
|
+
* Sensitive request fields (Authorization / Cookie / Set-Cookie headers and
|
|
21
|
+
* password-like body/param keys) are redacted even in the dev overlay, the frame count
|
|
22
|
+
* is capped, and the caller wraps this render in a guard, so a broken overlay or a
|
|
23
|
+
* recursive stack still yields a bounded, safe 500.
|
|
20
24
|
*/
|
|
21
25
|
|
|
22
26
|
import { readFileSync, statSync } from "node:fs";
|
|
23
27
|
import { resolve } from "node:path";
|
|
24
28
|
import { isTruthy } from "./dotenv.js";
|
|
25
29
|
|
|
30
|
+
// OVERLAY-DEC-03: cap the rendered frames so a deep/recursive stack yields a bounded
|
|
31
|
+
// page, not one source-file read per frame.
|
|
32
|
+
const MAX_FRAMES = 50;
|
|
33
|
+
|
|
34
|
+
// OVERLAY-DEC-02: request fields whose KEY matches this are masked in the dev overlay
|
|
35
|
+
// (Authorization/Cookie/Set-Cookie headers via authorization|cookie; password/token/
|
|
36
|
+
// secret/api_key body/param keys via the rest). Over-matching a benign field is the
|
|
37
|
+
// SAFE direction in a dev tool — over-masking leaks nothing; under-masking leaks.
|
|
38
|
+
const SENSITIVE_KEY_RE = /password|passwd|secret|token|authorization|cookie|key/i;
|
|
39
|
+
const REDACTED = "[redacted]";
|
|
40
|
+
|
|
41
|
+
function redact(key: string, value: string): string {
|
|
42
|
+
return SENSITIVE_KEY_RE.test(key) ? REDACTED : value;
|
|
43
|
+
}
|
|
44
|
+
|
|
26
45
|
// ── Colour palette (Catppuccin Mocha) ────────────────────────────────────
|
|
27
46
|
const BG = "#1e1e2e";
|
|
28
47
|
const SURFACE = "#313244";
|
|
@@ -196,10 +215,18 @@ export function renderErrorOverlay(error: Error, request?: any): string {
|
|
|
196
215
|
// if the file has been modified since — protects against the "browser cached
|
|
197
216
|
// an old overlay, then the AI rewrote the file" confusion where displayed
|
|
198
217
|
// source no longer matches what actually raised the error.
|
|
218
|
+
// OVERLAY-DEC-03: cap the rendered frames. A recursive stack of thousands of frames
|
|
219
|
+
// would otherwise do one source-file read per frame and emit an unbounded page;
|
|
220
|
+
// render only the innermost MAX_FRAMES and note the rest.
|
|
199
221
|
let framesHtml = "";
|
|
200
|
-
for (const frame of frames) {
|
|
222
|
+
for (const frame of frames.slice(0, MAX_FRAMES)) {
|
|
201
223
|
framesHtml += formatFrame(frame, capturedAt);
|
|
202
224
|
}
|
|
225
|
+
const hidden = frames.length - Math.min(frames.length, MAX_FRAMES);
|
|
226
|
+
if (hidden > 0) {
|
|
227
|
+
framesHtml += `<div style="color:${SUBTEXT};padding:8px 0;font-size:13px;">`
|
|
228
|
+
+ `… ${hidden} more stack frames hidden (truncated at ${MAX_FRAMES})</div>`;
|
|
229
|
+
}
|
|
203
230
|
|
|
204
231
|
// ── Request info ──
|
|
205
232
|
const requestPairs: Array<[string, string]> = [];
|
|
@@ -216,7 +243,8 @@ export function renderErrorOverlay(error: Error, request?: any): string {
|
|
|
216
243
|
for (const [label, val] of dictFields) {
|
|
217
244
|
if (val != null && typeof val === "object" && Object.keys(val as object).length > 0) {
|
|
218
245
|
for (const [k, v] of Object.entries(val as Record<string, unknown>)) {
|
|
219
|
-
|
|
246
|
+
const pairKey = `${label}.${k}`;
|
|
247
|
+
requestPairs.push([pairKey, redact(pairKey, String(v))]);
|
|
220
248
|
}
|
|
221
249
|
} else if (val != null && typeof val === "string" && val !== "") {
|
|
222
250
|
requestPairs.push([label, val]);
|
|
@@ -273,43 +301,6 @@ body{background:${BG};color:${TEXT};font-family:-apple-system,BlinkMacSystemFont
|
|
|
273
301
|
</html>`;
|
|
274
302
|
}
|
|
275
303
|
|
|
276
|
-
/**
|
|
277
|
-
* Render a safe, generic error page for production.
|
|
278
|
-
*/
|
|
279
|
-
export function renderProductionError(statusCode = 500, message = "Internal Server Error", path = ""): string {
|
|
280
|
-
const codeColor = statusCode === 403 ? "#f59e0b" : statusCode === 404 ? "#3b82f6" : "#ef4444";
|
|
281
|
-
const pathHtml = path ? `<div class="error-path">${esc(path)}</div><br>` : "";
|
|
282
|
-
return `<!DOCTYPE html>
|
|
283
|
-
<html lang="en">
|
|
284
|
-
<head>
|
|
285
|
-
<meta charset="utf-8">
|
|
286
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
287
|
-
<title>${statusCode} — ${esc(message)}</title>
|
|
288
|
-
<style>
|
|
289
|
-
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
290
|
-
body { font-family: system-ui, -apple-system, sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
|
|
291
|
-
.error-card { background: #1e293b; border: 1px solid #334155; border-radius: 1rem; padding: 3rem; text-align: center; max-width: 520px; width: 90%; }
|
|
292
|
-
.error-code { font-size: 8rem; font-weight: 900; color: ${codeColor}; opacity: 0.6; line-height: 1; margin-bottom: 0.5rem; }
|
|
293
|
-
.error-title { font-size: 1.5rem; font-weight: 700; margin-bottom: 0.75rem; }
|
|
294
|
-
.error-msg { color: #94a3b8; font-size: 1rem; margin-bottom: 1.5rem; line-height: 1.5; }
|
|
295
|
-
.error-path { font-family: 'SF Mono', monospace; background: #0f172a; color: ${codeColor}; padding: 0.5rem 1rem; border-radius: 0.5rem; font-size: 0.85rem; word-break: break-all; margin-bottom: 1.5rem; display: inline-block; }
|
|
296
|
-
.error-home { display: inline-block; padding: 0.6rem 2rem; background: #3b82f6; color: #fff; text-decoration: none; border-radius: 0.5rem; font-size: 0.9rem; font-weight: 600; }
|
|
297
|
-
.error-home:hover { opacity: 0.9; }
|
|
298
|
-
.logo { font-size: 1.5rem; margin-bottom: 1rem; opacity: 0.5; }
|
|
299
|
-
</style>
|
|
300
|
-
</head>
|
|
301
|
-
<body>
|
|
302
|
-
<div class="error-card">
|
|
303
|
-
<div class="error-code">${statusCode}</div>
|
|
304
|
-
<div class="error-title">${esc(message)}</div>
|
|
305
|
-
<div class="error-msg">Something went wrong while processing your request.</div>
|
|
306
|
-
${pathHtml}
|
|
307
|
-
<a href="/" class="error-home">Go Home</a>
|
|
308
|
-
</div>
|
|
309
|
-
</body>
|
|
310
|
-
</html>`;
|
|
311
|
-
}
|
|
312
|
-
|
|
313
304
|
/**
|
|
314
305
|
* Check if TINA4_DEBUG is enabled.
|
|
315
306
|
*/
|
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
// Tina4 FakeData — Fake data generation and database seeding, zero dependencies.
|
|
2
2
|
// Instance-based with optional seeded PRNG for deterministic output.
|
|
3
|
+
//
|
|
4
|
+
// Determinism is PER-LANGUAGE, not cross-language (SEED-DETERMINISM-PERLANG):
|
|
5
|
+
// `new FakeData(42)` reproduces the identical sequence on every run *within
|
|
6
|
+
// Node*, but the same seed on Python/PHP/Ruby's FakeData will NOT produce the
|
|
7
|
+
// same values -- each language uses its own PRNG (Node's mulberry32, Python's
|
|
8
|
+
// Mersenne Twister, PHP's per-instance Mt19937, Ruby's Random). There is no
|
|
9
|
+
// shared cross-language PRNG, and hand-rolling one would add cost for no real
|
|
10
|
+
// benefit -- use a seed to make ONE language's run reproducible, never to
|
|
11
|
+
// compare output across languages.
|
|
12
|
+
//
|
|
13
|
+
// NOT FOR SECRETS (SEED-SECRETS-DOC): this is a non-cryptographic PRNG meant
|
|
14
|
+
// for realistic-looking fixtures and test data. Never use it to generate API
|
|
15
|
+
// keys, passwords, tokens, or anything else that must be unguessable -- use
|
|
16
|
+
// node:crypto's randomBytes/randomUUID (already used here for the unseeded
|
|
17
|
+
// path) directly, or Tina4's Auth helpers for password hashing.
|
|
3
18
|
|
|
4
19
|
import { randomInt, randomUUID } from "node:crypto";
|
|
5
20
|
import { existsSync, readdirSync } from "node:fs";
|