tina4-nodejs 3.13.97 → 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 +60 -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
@@ -1,4 +1,6 @@
1
1
  import type { IncomingMessage, IncomingHttpHeaders } from "node:http";
2
+ import { mkdirSync, realpathSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
2
4
  import type { Tina4Request, UploadedFile } from "./types.js";
3
5
  import { resolveClientIp } from "./trustedProxy.js";
4
6
 
@@ -32,8 +34,19 @@ export function makeCaseInsensitiveHeaders(
32
34
  }) as IncomingHttpHeaders;
33
35
  }
34
36
 
37
+ /** Strips `readonly` so construction code can write once; callers outside
38
+ * this function still see the real (readonly) `Tina4Request` shape
39
+ * (REQ-IMMUTABILITY-DIVERGE, 3.13.99). */
40
+ type Writable<T> = { -readonly [K in keyof T]: T[K] };
41
+
35
42
  export function createRequest(req: IncomingMessage): Tina4Request {
36
43
  const tReq = req as Tina4Request;
44
+ // Construction-only mutable view of the same object — the core wire-derived
45
+ // fields (path/queryString/url/ip/remoteIp/cookies/contentType/query) are
46
+ // `readonly` on Tina4Request itself; this alias is how THIS function is
47
+ // still allowed to set them exactly once, the same way PHP's constructor
48
+ // writes its own `readonly` properties.
49
+ const w = tReq as Writable<Tina4Request>;
37
50
  // Wrap `req.headers` so mixed-case lookups work — Node's underlying object
38
51
  // is already lower-cased, this just lets readers use any casing they like.
39
52
  (tReq as unknown as { headers: IncomingHttpHeaders }).headers =
@@ -80,17 +93,17 @@ export function createRequest(req: IncomingMessage): Tina4Request {
80
93
  }
81
94
 
82
95
  tReq.params = {};
83
- tReq.query = query;
96
+ w.query = query;
84
97
  // Path, query string, and full URL — same shape across all four frameworks.
85
98
  // `path` is the URL path only; `queryString` is the raw query without "?".
86
99
  // `url` is overridden from Node's IncomingMessage.url (path+query) to the
87
100
  // full absolute URL — parity with PHP/Python/Ruby.
88
- tReq.path = path;
89
- tReq.queryString = queryString;
90
- tReq.url = fullUrl;
101
+ w.path = path;
102
+ w.queryString = queryString;
103
+ w.url = fullUrl;
91
104
  tReq.body = undefined;
92
105
  tReq.files = {};
93
- tReq.contentType = (req.headers["content-type"] ?? "") as string;
106
+ w.contentType = (req.headers["content-type"] ?? "") as string;
94
107
 
95
108
  // Parse cookies from Cookie header
96
109
  const cookieHeader = (req.headers.cookie ?? "") as string;
@@ -101,13 +114,13 @@ export function createRequest(req: IncomingMessage): Tina4Request {
101
114
  if (k) cookies[k.trim()] = v.join("=").trim();
102
115
  }
103
116
  }
104
- tReq.cookies = cookies;
117
+ w.cookies = cookies;
105
118
 
106
119
  // Raw socket peer — NEVER honours a forwarding header, so it can be trusted
107
120
  // for security decisions. Resolved BEFORE .ip: the peer decides whether the
108
121
  // forwarding headers may be believed at all (TINA4_TRUSTED_PROXIES, ADR-0019).
109
- tReq.remoteIp = req.socket?.remoteAddress ?? "";
110
- tReq.ip = resolveClientIp(req.headers, tReq.remoteIp) || "127.0.0.1";
122
+ w.remoteIp = req.socket?.remoteAddress ?? "";
123
+ w.ip = resolveClientIp(req.headers, tReq.remoteIp) || "127.0.0.1";
111
124
 
112
125
  // Add convenience methods
113
126
  tReq.header = function (name: string): string | undefined {
@@ -318,6 +331,42 @@ export function parseMultipart(
318
331
  return { fields, files };
319
332
  }
320
333
 
334
+ /**
335
+ * Persist an uploaded file's content inside `targetDir` under a SAFE name.
336
+ *
337
+ * The client-supplied filename is untrusted. Directory components are stripped
338
+ * (so `../../evil` or `/etc/passwd` becomes `evil` / `passwd`), a NUL byte or an
339
+ * unusable name (`''`/`.`/`..`) is refused, and the resolved path is confined to
340
+ * `targetDir` (realpath containment) so an upload can never write outside it.
341
+ *
342
+ * @param file an uploaded-file descriptor (`req.files[name]`) carrying `content`.
343
+ * @param targetDir the directory to write into (created if missing).
344
+ * @param filename an explicit name to use instead of the client filename.
345
+ * @returns the absolute path written.
346
+ * @throws when the derived name is unsafe or would escape `targetDir`.
347
+ */
348
+ export function saveUpload(file: UploadedFile, targetDir: string, filename?: string): string {
349
+ const raw = String(filename ?? file.filename ?? "");
350
+ if (raw.includes("\0")) throw new Error("upload filename contains a null byte");
351
+ // Reduce to a single path segment, handling BOTH separators so a Windows
352
+ // "..\\..\\evil" cannot smuggle a directory part past a POSIX basename.
353
+ const base = raw.replace(/\\/g, "/").split("/").pop() ?? "";
354
+ if (base === "" || base === "." || base === "..") {
355
+ throw new Error(`upload filename is not a usable name: ${JSON.stringify(raw)}`);
356
+ }
357
+ mkdirSync(targetDir, { recursive: true });
358
+ const dest = join(targetDir, base);
359
+ // Defence in depth: the resolved parent of the destination must be exactly
360
+ // the resolved target dir (guards a pre-existing symlink at target/base).
361
+ const realDir = realpathSync(targetDir);
362
+ const realParent = realpathSync(dirname(dest));
363
+ if (realParent !== realDir) {
364
+ throw new Error(`refusing to write outside ${targetDir}: ${JSON.stringify(raw)}`);
365
+ }
366
+ writeFileSync(dest, file.content);
367
+ return dest;
368
+ }
369
+
321
370
  function bufferIndexOf(haystack: Buffer, needle: Buffer, offset: number): number {
322
371
  for (let i = offset; i <= haystack.length - needle.length; i++) {
323
372
  let found = true;
@@ -504,3 +504,70 @@ export function createResponse(res: ServerResponse): Tina4Response {
504
504
  export function errorResponse(code: string, message: string, status: number = 400): Record<string, unknown> {
505
505
  return { error: true, code, message, status };
506
506
  }
507
+
508
+ /**
509
+ * Content negotiation for an error response (feature 42, ERR-DEC-02): does an
510
+ * Accept header prefer application/json over text/html?
511
+ *
512
+ * `Accept: application/json` (an API client) prefers JSON; a browser Accept
513
+ * (`text/html`, `*\/*`, or no header at all) prefers HTML. A mixed Accept
514
+ * header - a real browser's
515
+ * `text/html,application/xhtml+xml,application/xml;q=0.9,*\/*;q=0.8` - is
516
+ * resolved by q-value: whichever of the two media types this function cares
517
+ * about is weighted higher wins; a tie or neither present defaults to HTML,
518
+ * the historical/back-compatible behaviour for an unspecified client. This is
519
+ * the ONE shared decision reused by the 403/404/500 error paths (server.ts's
520
+ * serveNotFound/renderDispatchError, middleware.ts's interpretHookResult), so
521
+ * a JSON API client sees the SAME negotiated shape everywhere
522
+ * (ERR-403-SPLIT) - ported with the same algorithm to Python/PHP/Ruby.
523
+ */
524
+ export function acceptPrefersJson(accept: string | undefined | null): boolean {
525
+ if (!accept) return false;
526
+
527
+ let bestJson = -1;
528
+ let bestHtml = -1;
529
+ for (const part of accept.split(",")) {
530
+ const segments = part.trim().split(";");
531
+ const media = (segments[0] ?? "").trim().toLowerCase();
532
+ let q = 1;
533
+ for (const rawParam of segments.slice(1)) {
534
+ const param = rawParam.trim();
535
+ if (param.startsWith("q=")) {
536
+ const parsed = Number(param.slice(2));
537
+ if (!Number.isNaN(parsed)) q = parsed;
538
+ }
539
+ }
540
+ if (media === "application/json") {
541
+ bestJson = Math.max(bestJson, q);
542
+ } else if (media === "text/html" || media === "*/*" || media === "application/xhtml+xml") {
543
+ bestHtml = Math.max(bestHtml, q);
544
+ }
545
+ }
546
+
547
+ if (bestJson < 0) return false;
548
+ if (bestHtml < 0) return true;
549
+ return bestJson > bestHtml;
550
+ }
551
+
552
+ /** True when the request's Accept header prefers JSON - see acceptPrefersJson(). */
553
+ export function wantsJson(req: { headers?: Record<string, string | string[] | undefined> }): boolean {
554
+ const accept = req?.headers?.["accept"];
555
+ return acceptPrefersJson(Array.isArray(accept) ? accept[0] : accept);
556
+ }
557
+
558
+ const ERROR_CODE_NAMES: Record<number, string> = {
559
+ 403: "FORBIDDEN", 404: "NOT_FOUND", 405: "METHOD_NOT_ALLOWED", 500: "INTERNAL_SERVER_ERROR",
560
+ };
561
+
562
+ /**
563
+ * The ONE JSON error envelope for a negotiated 403/404/500 (ERR-DEC-02).
564
+ *
565
+ * Reuses errorResponse() (error: true, code, message, status) already shared
566
+ * by app-level response.error() calls, plus request_id for correlation
567
+ * (feature 43, ERR-404-REQUESTID) - the SAME shape Python/PHP/Ruby build.
568
+ */
569
+ export function negotiatedErrorBody(code: number, message: string, requestId: string): Record<string, unknown> {
570
+ const body = errorResponse(ERROR_CODE_NAMES[code] ?? `HTTP_${code}`, message, code);
571
+ body.request_id = requestId;
572
+ return body;
573
+ }
@@ -39,6 +39,26 @@ function coerceParam(raw: string, type: string | undefined): string | number {
39
39
  }
40
40
  }
41
41
 
42
+ /**
43
+ * Join a route-group prefix with a route's own path.
44
+ *
45
+ * Feature 32 (RG-DEC-01): ports PHP's normalization grammar verbatim
46
+ * (`Tina4/Router.php` `addRoute` — the reference) so Node converges with
47
+ * PHP/Python/Ruby instead of `RouteGroup`'s old bare concatenation. One
48
+ * separator between prefix and path, a single leading slash, no trailing
49
+ * slash, and any run of slashes collapsed to one — so `group("/api")` +
50
+ * `get("users")`, `get("/users")`, and `group("/api/")` + `get("/users")`
51
+ * all resolve to the SAME `/api/users`. Before this fix, `this.prefix +
52
+ * path` bare-concatenated with NO normalization at all — the worst of the
53
+ * four, since Node's `RouteGroup` prefix isn't even trailing-slash-trimmed
54
+ * on construction (unlike Python's `rstrip`/Ruby's `chomp`).
55
+ */
56
+ function joinGroupPath(prefix: string, path: string): string {
57
+ const full = `${prefix}/${path.replace(/^\/+/, "")}`;
58
+ const trimmed = `/${full.replace(/^\/+|\/+$/g, "")}`;
59
+ return trimmed.replace(/\/+/g, "/");
60
+ }
61
+
42
62
  interface MatchResult {
43
63
  handler: RouteHandler;
44
64
  params: Record<string, string | number>;
@@ -676,7 +696,15 @@ export class Router {
676
696
  paramTypes.push("string");
677
697
  return "(.+)";
678
698
  }
679
- return segment;
699
+ // Literal segment -- escape every regex metacharacter so it matches
700
+ // itself only (real-bug pre-merge, 3.13.99). Before this, a literal
701
+ // segment was interpolated into the pattern string UNESCAPED: e.g.
702
+ // registering `/blocked-xss(1)` compiled `(1)` as a capture group,
703
+ // so `new RegExp('^/blocked-xss(1)$')` actually required the URL
704
+ // WITHOUT the parens -- the exact literal path it was registered
705
+ // for 404'd. Mirrors Python's re.escape / Ruby's Regexp.escape /
706
+ // PHP's preg_quote, all already correct here.
707
+ return segment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
680
708
  })
681
709
  .join("/");
682
710
 
@@ -708,7 +736,7 @@ export class RouteGroup {
708
736
  get(path: string, handler: RouteHandler, middlewares?: MiddlewareSpec[], meta?: RouteMeta): RouteRef {
709
737
  return this.router.addRoute({
710
738
  method: "GET",
711
- pattern: this.prefix + path,
739
+ pattern: joinGroupPath(this.prefix, path),
712
740
  handler,
713
741
  middlewares: this.mergeMiddlewares(middlewares),
714
742
  meta,
@@ -718,7 +746,7 @@ export class RouteGroup {
718
746
  post(path: string, handler: RouteHandler, middlewares?: MiddlewareSpec[], meta?: RouteMeta): RouteRef {
719
747
  return this.router.addRoute({
720
748
  method: "POST",
721
- pattern: this.prefix + path,
749
+ pattern: joinGroupPath(this.prefix, path),
722
750
  handler,
723
751
  middlewares: this.mergeMiddlewares(middlewares),
724
752
  meta,
@@ -728,7 +756,7 @@ export class RouteGroup {
728
756
  put(path: string, handler: RouteHandler, middlewares?: MiddlewareSpec[], meta?: RouteMeta): RouteRef {
729
757
  return this.router.addRoute({
730
758
  method: "PUT",
731
- pattern: this.prefix + path,
759
+ pattern: joinGroupPath(this.prefix, path),
732
760
  handler,
733
761
  middlewares: this.mergeMiddlewares(middlewares),
734
762
  meta,
@@ -738,7 +766,7 @@ export class RouteGroup {
738
766
  patch(path: string, handler: RouteHandler, middlewares?: MiddlewareSpec[], meta?: RouteMeta): RouteRef {
739
767
  return this.router.addRoute({
740
768
  method: "PATCH",
741
- pattern: this.prefix + path,
769
+ pattern: joinGroupPath(this.prefix, path),
742
770
  handler,
743
771
  middlewares: this.mergeMiddlewares(middlewares),
744
772
  meta,
@@ -748,7 +776,7 @@ export class RouteGroup {
748
776
  delete(path: string, handler: RouteHandler, middlewares?: MiddlewareSpec[], meta?: RouteMeta): RouteRef {
749
777
  return this.router.addRoute({
750
778
  method: "DELETE",
751
- pattern: this.prefix + path,
779
+ pattern: joinGroupPath(this.prefix, path),
752
780
  handler,
753
781
  middlewares: this.mergeMiddlewares(middlewares),
754
782
  meta,
@@ -760,7 +788,7 @@ export class RouteGroup {
760
788
  for (const method of ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"]) {
761
789
  lastRef = this.router.addRoute({
762
790
  method,
763
- pattern: this.prefix + path,
791
+ pattern: joinGroupPath(this.prefix, path),
764
792
  handler,
765
793
  middlewares: this.mergeMiddlewares(middlewares),
766
794
  meta,