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.
- package/CLAUDE.md +60 -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
|
@@ -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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
101
|
+
w.path = path;
|
|
102
|
+
w.queryString = queryString;
|
|
103
|
+
w.url = fullUrl;
|
|
91
104
|
tReq.body = undefined;
|
|
92
105
|
tReq.files = {};
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
791
|
+
pattern: joinGroupPath(this.prefix, path),
|
|
764
792
|
handler,
|
|
765
793
|
middlewares: this.mergeMiddlewares(middlewares),
|
|
766
794
|
meta,
|