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.
Files changed (96) hide show
  1. package/CLAUDE.md +24 -25
  2. package/package.json +1 -2
  3. package/packages/cli/dist/bin.js +20620 -18995
  4. package/packages/cli/src/bin.ts +28 -71
  5. package/packages/cli/src/commands/migrate.ts +36 -75
  6. package/packages/cli/src/commands/migrateRollback.ts +10 -1
  7. package/packages/cli/src/commands/test.ts +92 -21
  8. package/packages/core/dist/index.js +20459 -18815
  9. package/packages/core/public/js/tina4-dev-admin.min.js +23 -19
  10. package/packages/core/src/ai.ts +28 -12
  11. package/packages/core/src/api.ts +13 -5
  12. package/packages/core/src/background.ts +9 -3
  13. package/packages/core/src/devAdmin.ts +135 -20
  14. package/packages/core/src/dispatchPipeline.ts +185 -1
  15. package/packages/core/src/docs.ts +33 -5
  16. package/packages/core/src/env.ts +1 -1
  17. package/packages/core/src/errorOverlay.ts +39 -48
  18. package/packages/core/src/fakeData.ts +15 -0
  19. package/packages/core/src/index.ts +17 -6
  20. package/packages/core/src/logger.ts +892 -572
  21. package/packages/core/src/mcp.ts +9 -1
  22. package/packages/core/src/messenger.ts +31 -4
  23. package/packages/core/src/middleware.ts +169 -43
  24. package/packages/core/src/portTakeover.ts +232 -0
  25. package/packages/core/src/request.ts +57 -8
  26. package/packages/core/src/response.ts +67 -0
  27. package/packages/core/src/router.ts +35 -7
  28. package/packages/core/src/server.ts +450 -190
  29. package/packages/core/src/static.ts +81 -12
  30. package/packages/core/src/testClient.ts +126 -137
  31. package/packages/core/src/testing.ts +16 -12
  32. package/packages/core/src/types.ts +21 -9
  33. package/packages/core/src/version.ts +66 -0
  34. package/packages/core/src/websocket.ts +2 -2
  35. package/packages/core/src/websocketBackplane.ts +2 -2
  36. package/packages/frond/dist/index.js +31 -13
  37. package/packages/frond/src/engine.ts +39 -7
  38. package/packages/orm/dist/index.js +10879 -9258
  39. package/packages/orm/src/adapters/firebird.ts +200 -27
  40. package/packages/orm/src/adapters/mongodb.ts +160 -10
  41. package/packages/orm/src/adapters/mssql.ts +38 -11
  42. package/packages/orm/src/adapters/mysql.ts +24 -1
  43. package/packages/orm/src/adapters/odbc.ts +127 -29
  44. package/packages/orm/src/adapters/postgres.ts +18 -0
  45. package/packages/orm/src/adapters/sqlite.ts +93 -14
  46. package/packages/orm/src/autoCrud.ts +72 -8
  47. package/packages/orm/src/baseModel.ts +323 -71
  48. package/packages/orm/src/cachedDatabase.ts +48 -1
  49. package/packages/orm/src/database.ts +162 -59
  50. package/packages/orm/src/fakeData.ts +6 -2
  51. package/packages/orm/src/index.ts +4 -1
  52. package/packages/orm/src/migration.ts +95 -52
  53. package/packages/orm/src/query.ts +16 -4
  54. package/packages/orm/src/seeder.ts +43 -25
  55. package/packages/orm/src/sqlTranslator.ts +104 -19
  56. package/packages/orm/src/types.ts +97 -21
  57. package/packages/orm/src/validation.ts +5 -1
  58. package/packages/swagger/dist/index.js +3 -2
  59. package/packages/swagger/src/generator.ts +19 -4
  60. package/packages/swagger/src/ui.ts +6 -4
  61. package/types/cli/src/bin.d.ts +0 -22
  62. package/types/core/src/api.d.ts +11 -4
  63. package/types/core/src/background.d.ts +5 -2
  64. package/types/core/src/devAdmin.d.ts +35 -0
  65. package/types/core/src/dispatchPipeline.d.ts +41 -1
  66. package/types/core/src/errorOverlay.d.ts +13 -13
  67. package/types/core/src/index.d.ts +9 -6
  68. package/types/core/src/logger.d.ts +111 -185
  69. package/types/core/src/middleware.d.ts +40 -5
  70. package/types/core/src/portTakeover.d.ts +50 -0
  71. package/types/core/src/request.d.ts +15 -0
  72. package/types/core/src/response.d.ts +29 -0
  73. package/types/core/src/server.d.ts +92 -0
  74. package/types/core/src/testClient.d.ts +29 -3
  75. package/types/core/src/testing.d.ts +16 -12
  76. package/types/core/src/types.d.ts +21 -9
  77. package/types/core/src/version.d.ts +11 -0
  78. package/types/core/src/websocketBackplane.d.ts +1 -1
  79. package/types/frond/src/engine.d.ts +10 -0
  80. package/types/orm/src/adapters/firebird.d.ts +61 -2
  81. package/types/orm/src/adapters/mongodb.d.ts +20 -0
  82. package/types/orm/src/adapters/mssql.d.ts +11 -0
  83. package/types/orm/src/adapters/mysql.d.ts +11 -0
  84. package/types/orm/src/adapters/odbc.d.ts +35 -4
  85. package/types/orm/src/adapters/postgres.d.ts +11 -0
  86. package/types/orm/src/adapters/sqlite.d.ts +23 -4
  87. package/types/orm/src/baseModel.d.ts +45 -25
  88. package/types/orm/src/cachedDatabase.d.ts +27 -1
  89. package/types/orm/src/database.d.ts +56 -6
  90. package/types/orm/src/index.d.ts +3 -2
  91. package/types/orm/src/migration.d.ts +23 -5
  92. package/types/orm/src/query.d.ts +3 -0
  93. package/types/orm/src/seeder.d.ts +15 -2
  94. package/types/orm/src/sqlTranslator.d.ts +17 -4
  95. package/types/orm/src/types.d.ts +75 -16
  96. 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
- // Read version from root package.json dynamically
28
- const TINA4_VERSION = (() => {
29
- try {
30
- const __dirname = dirname(fileURLToPath(import.meta.url));
31
- // Try root package.json first, then core package.json
32
- for (const rel of ["../../../package.json", "../../package.json"]) {
33
- const p = resolve(__dirname, rel);
34
- if (existsSync(p)) {
35
- const pkg = JSON.parse(readFileSync(p, "utf-8"));
36
- if (pkg.version) return pkg.version;
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, "&amp;")
59
+ .replace(/</g, "&lt;")
60
+ .replace(/>/g, "&gt;")
61
+ .replace(/"/g, "&quot;")
62
+ .replace(/'/g, "&#39;");
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
- } catch {}
40
- return "0.0.0";
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
- const summary = await seedTable(db, table, count, fieldMap, undefined, { clear, seed, strict });
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
- return name.startsWith(".") && name !== ".env" && name !== ".env.example";
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;">${ctx.method}</span>
2889
- <span>${ctx.path}</span>
2890
- <span style="color:#666;">&rarr; ${ctx.matchedPattern}</span>
3003
+ <span style="color:#4caf50;">${method}</span>
3004
+ <span>${path}</span>
3005
+ <span style="color:#666;">&rarr; ${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
- rawRes.setHeader("Set-Cookie", buildSessionCookie(newSid, ttl, undefined, forwardedProto, socketEncrypted));
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: rebuild only if not built yet OR mtime changed.
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
- if (this.frameworkEntries === null || fwMtime !== this.frameworkMtime) {
1163
- this.frameworkEntries = new Map();
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, this.frameworkEntries);
1189
+ buildEntriesForFile(f, "framework", this.frameworkRoots, this.projectRoot, this.version, entries);
1167
1190
  }
1168
1191
  }
1169
- this.frameworkMtime = fwMtime;
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
 
@@ -34,7 +34,7 @@ function logWarning(message: string): void {
34
34
  import("./logger.js")
35
35
  .then((mod) => {
36
36
  try {
37
- mod.Log.warn(message);
37
+ mod.Log.warning(message);
38
38
  } catch {
39
39
  /* Log not ready — skip */
40
40
  }
@@ -1,28 +1,47 @@
1
1
  /**
2
2
  * Tina4 Debug — Rich error overlay for development mode.
3
3
  *
4
- * Renders a professional, syntax-highlighted HTML error page when an unhandled
5
- * exception occurs in a route handler.
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, renderProductionError, isDebugMode } from "./errorOverlay.js";
8
+ * import { renderErrorOverlay, isDebugMode } from "./errorOverlay.js";
8
9
  *
9
10
  * try {
10
11
  * await handler(req, res);
11
12
  * } catch (err) {
12
- * const html = isDebugMode()
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
- * Only activate when TINA4_DEBUG is true.
19
- * In production, call renderProductionError() instead.
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
+ + `&#8230; ${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
- requestPairs.push([`${label}.${k}`, String(v)]);
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";