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,7 +1,22 @@
1
- import { existsSync, readFileSync, statSync, type Stats } from "node:fs";
2
- import { join, extname } from "node:path";
1
+ import { readFileSync, realpathSync, statSync, type Stats } from "node:fs";
2
+ import { join, extname, sep } from "node:path";
3
+ import { gzipSync } from "node:zlib";
3
4
  import type { Tina4Request, Tina4Response } from "./types.js";
4
5
 
6
+ /** Content-type prefixes that benefit from gzip. Mirrors the Python master's `_is_compressible`. */
7
+ const COMPRESSIBLE_PREFIXES = [
8
+ "text/",
9
+ "application/json",
10
+ "application/xml",
11
+ "application/javascript",
12
+ "image/svg",
13
+ ];
14
+
15
+ /** Whether a content type benefits from gzip compression (feature 40, CE-DEC-01). */
16
+ function isCompressible(contentType: string): boolean {
17
+ return COMPRESSIBLE_PREFIXES.some((prefix) => contentType.includes(prefix));
18
+ }
19
+
5
20
  const MIME_TYPES: Record<string, string> = {
6
21
  ".html": "text/html; charset=utf-8",
7
22
  ".css": "text/css; charset=utf-8",
@@ -37,6 +52,22 @@ export function tryServeStatic(
37
52
  : raw.split("?")[0];
38
53
  }
39
54
 
55
+ // Security: refuse an up-level segment or a dotfile before touching the FS
56
+ // (defense in depth; the realpath confinement below is what actually stops a
57
+ // symlink escape). This also blocks the sibling-prefix vector reachable via the
58
+ // malformed-URL fallback, since that path carries `..` too.
59
+ if (hasHiddenSegment(pathname)) return false;
60
+
61
+ // Resolve the real static root ONCE. Resolving the dir AND the file keeps them
62
+ // consistent when the temp/base path itself is a symlink (e.g. macOS
63
+ // /var -> /private/var), which a startsWith on the raw dir would break.
64
+ let realDir: string;
65
+ try {
66
+ realDir = realpathSync(staticDir);
67
+ } catch {
68
+ return false;
69
+ }
70
+
40
71
  // Try exact file match, then index.html for directory requests
41
72
  const candidates = [
42
73
  join(staticDir, pathname),
@@ -44,22 +75,38 @@ export function tryServeStatic(
44
75
  ];
45
76
 
46
77
  for (const filePath of candidates) {
47
- if (!existsSync(filePath)) continue;
78
+ let realPath: string;
79
+ try {
80
+ realPath = realpathSync(filePath);
81
+ } catch {
82
+ continue; // missing path component
83
+ }
48
84
 
49
- const stat = statSync(filePath);
85
+ const stat = statSync(realPath);
50
86
  if (!stat.isFile()) continue;
51
87
 
52
- // Prevent directory traversal
53
- if (!filePath.startsWith(staticDir)) continue;
88
+ // Confinement: realpath + trailing separator (ADR-0050) — a symlink pointing
89
+ // outside, a sibling-prefix dir (publicsecret) and a `..` escape all fail
90
+ // this. The trailing separator is what defeats the sibling-prefix match.
91
+ if (realPath !== realDir && !realPath.startsWith(realDir + sep)) continue;
54
92
 
55
- const ext = extname(filePath);
93
+ // Never emit bytes from a dotfile, even when a symlink inside the public dir
94
+ // points AT one. Check the segments BELOW the public dir so a public dir that
95
+ // itself lives under a dot-directory is unaffected.
96
+ if (hasHiddenSegment(realPath.slice(realDir.length + 1), sep)) continue;
97
+
98
+ const ext = extname(realPath);
56
99
  const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
57
100
 
58
101
  // Cheap validators from the file's size + mtime — no hashing needed. A weak
59
102
  // ETag (W/) is correct here: two representations with the same size+mtime
60
- // are treated as equivalent for caching. Last-Modified is second-resolution
61
- // per HTTP.
62
- const etag = `W/"${stat.size}-${stat.mtimeMs}"`;
103
+ // are treated as equivalent for caching. Format PINNED across all four
104
+ // frameworks (feature 40, CE-DEC-02): decimal `W/"<size>-<mtime>"`,
105
+ // integer-SECOND mtime (dropping the fractional-ms Node otherwise reports)
106
+ // — a client behind a reverse proxy sees an identical validator for the
107
+ // same file regardless of backend language. Last-Modified is
108
+ // second-resolution per HTTP.
109
+ const etag = `W/"${stat.size}-${Math.floor(stat.mtimeMs / 1000)}"`;
63
110
  const lastModified = stat.mtime.toUTCString();
64
111
 
65
112
  // A static asset MAY be cached but MUST be revalidated before use, so a
@@ -78,15 +125,37 @@ export function tryServeStatic(
78
125
  return true;
79
126
  }
80
127
 
128
+ // Compression (feature 40, CE-DEC-01): gzip when eligible — a static
129
+ // asset gets the SAME treatment as a dynamic response. The ETag stays
130
+ // file-stat-based regardless (a weak validator is deliberately
131
+ // representation-independent — CE-ETAG-OVER-COMPRESSED), so compressing
132
+ // here never invalidates the ETag/Last-Modified already sent above.
133
+ let body: Buffer = readFileSync(realPath);
134
+ const acceptEncoding = conditionalHeader(req, "accept-encoding");
135
+ if (body.length > 1024 && acceptEncoding.includes("gzip") && isCompressible(contentType)) {
136
+ body = gzipSync(body, { level: 6 });
137
+ res.raw.setHeader("Content-Encoding", "gzip");
138
+ res.raw.setHeader("Vary", "Accept-Encoding");
139
+ }
140
+
81
141
  res.raw.setHeader("Content-Type", contentType);
82
- res.raw.setHeader("Content-Length", stat.size);
83
- res.raw.end(readFileSync(filePath));
142
+ res.raw.setHeader("Content-Length", body.length);
143
+ res.raw.end(body);
84
144
  return true;
85
145
  }
86
146
 
87
147
  return false;
88
148
  }
89
149
 
150
+ /**
151
+ * Whether any separator-delimited segment of `path` is hidden (begins with a
152
+ * dot). Refuses a dotfile (`.env`, `.git/config`); a `..` segment also begins
153
+ * with a dot, so this doubles as a belt on traversal.
154
+ */
155
+ function hasHiddenSegment(path: string, separator = "/"): boolean {
156
+ return path.split(separator).some((segment) => segment.length > 0 && segment.startsWith("."));
157
+ }
158
+
90
159
  /**
91
160
  * Read a conditional-request header off the incoming request. Works with the
92
161
  * real `IncomingMessage` (`req.headers`, already lower-cased by Node) AND with
@@ -1,6 +1,28 @@
1
1
  /**
2
2
  * Tina4 Test Client — Test routes without starting a server.
3
3
  *
4
+ * Builds a mock IncomingMessage/ServerResponse and dispatches them through
5
+ * the REAL Tina4 front controller (server.ts's `runDispatch`, over either the
6
+ * live server's DispatchContext when one is running in this process, or a
7
+ * standalone one bound to the given/default router when none is) — the same
8
+ * function every live socket connection runs. Everything a live request
9
+ * gets, an in-process test request gets: the session stage, global + per-
10
+ * route middleware in the live order (gate BEFORE route middleware, per
11
+ * ADR-0012), the secure-by-default auth gate, static files, template routes,
12
+ * the landing page, RFC 9110 OPTIONS/405 `Allow` responses, and the 404/500
13
+ * renderers.
14
+ *
15
+ * This used to re-implement the dispatch order itself — matching the route
16
+ * directly and running global/route middleware and the auth gate by hand —
17
+ * which meant the session stage never ran (a session-token auth regression
18
+ * was structurally unreachable) and route middleware ran BEFORE the gate
19
+ * (the live server's order is gate first, ADR-0012). Delegating to the real
20
+ * `runDispatch` closes both gaps for free, along with everything else the
21
+ * live pipeline does that this file never had to know about (feature 131,
22
+ * TC-DEC-01 — the same shape as the #PY2 auth fix and the Python/PHP/Ruby
23
+ * TestClients, which have always called their own real front controller:
24
+ * `core.server.app`, `Router::dispatch`, `RackApp#call`).
25
+ *
4
26
  * Usage:
5
27
  *
6
28
  * import { TestClient } from "@tina4/core";
@@ -16,11 +38,8 @@
16
38
  */
17
39
  import { IncomingMessage, ServerResponse } from "node:http";
18
40
  import { Socket } from "node:net";
19
- import { createRequest } from "./request.js";
20
- import { createResponse } from "./response.js";
21
- import { defaultRouter, Router, runRouteMiddlewares } from "./router.js";
22
- import { MiddlewareRunner, isMiddlewareClass } from "./middleware.js";
23
- import { enforceRouteAuth } from "./authGate.js";
41
+ import { defaultRouter, Router } from "./router.js";
42
+ import { runDispatch, buildDispatchContext, getLiveDispatchContext, type DispatchContext } from "./server.js";
24
43
 
25
44
  export class TestResponse {
26
45
  public readonly status: number;
@@ -28,11 +47,39 @@ export class TestResponse {
28
47
  public readonly headers: Record<string, string>;
29
48
  public readonly contentType: string;
30
49
 
31
- constructor(statusCode: number, headers: Record<string, string>, body: string) {
50
+ /** Every value sent per header name (lowercased), in emission order. */
51
+ private readonly headerList: Record<string, string[]>;
52
+
53
+ constructor(statusCode: number, headerList: Record<string, string[]>, body: string) {
32
54
  this.status = statusCode;
33
55
  this.body = body;
34
- this.headers = headers;
35
- this.contentType = headers["content-type"] ?? "";
56
+ this.headerList = headerList;
57
+
58
+ // `headers` stays the back-compat single-value view — the LAST value per
59
+ // name, the shape every existing reader already expects (TC-HEADER-
60
+ // COLLAPSE, TC-DEC-02: a duplicate response header, e.g. two Set-Cookie
61
+ // from two response.cookie() calls, used to collapse via a comma-join
62
+ // here, which is unsafe for Set-Cookie specifically since a cookie's own
63
+ // Expires attribute can itself contain a comma — getHeaderList() below is
64
+ // the one place every value is visible; headers[name] keeps collapsing).
65
+ const flat: Record<string, string> = {};
66
+ for (const [name, values] of Object.entries(headerList)) {
67
+ if (values.length > 0) flat[name] = values[values.length - 1]!;
68
+ }
69
+ this.headers = flat;
70
+ this.contentType = this.headers["content-type"] ?? "";
71
+ }
72
+
73
+ /**
74
+ * Every value sent for `name` (case-insensitive), in emission order.
75
+ *
76
+ * A header sent once returns a one-item array; a header never sent returns
77
+ * an empty array. This is the one place a duplicate response header (two
78
+ * `Set-Cookie`) is visible — `headers[name]` always collapses to the LAST
79
+ * value, same as before (TC-HEADER-COLLAPSE, TC-DEC-02).
80
+ */
81
+ getHeaderList(name: string): string[] {
82
+ return this.headerList[name.toLowerCase()] ?? [];
36
83
  }
37
84
 
38
85
  /** Parse body as JSON. */
@@ -62,10 +109,39 @@ export interface RequestOptions {
62
109
  }
63
110
 
64
111
  export class TestClient {
65
- private router: Router;
112
+ /** An explicitly-injected router (test isolation); undefined means "use the live server's router, or defaultRouter". */
113
+ private readonly explicitRouter: Router | undefined;
114
+ private ctxPromise: Promise<DispatchContext> | null = null;
66
115
 
67
116
  constructor(router?: Router) {
68
- this.router = router ?? defaultRouter;
117
+ this.explicitRouter = router;
118
+ }
119
+
120
+ /**
121
+ * Resolve (and memoise) the DispatchContext this client dispatches
122
+ * through.
123
+ *
124
+ * An explicitly-injected router always gets its OWN standalone context
125
+ * (buildDispatchContext) — the test-isolation contract an injected router
126
+ * has always had: a dedicated Router never races with whatever else is
127
+ * registered on defaultRouter or a live server. With no injected router,
128
+ * the LIVE server's context wins when one is running in this process
129
+ * (getLiveDispatchContext — maximum fidelity, mirrors Ruby's
130
+ * `RackApp.current`), else a standalone context bound to defaultRouter.
131
+ */
132
+ private context(): Promise<DispatchContext> {
133
+ if (this.ctxPromise) return this.ctxPromise;
134
+
135
+ if (!this.explicitRouter) {
136
+ const live = getLiveDispatchContext();
137
+ if (live) {
138
+ this.ctxPromise = Promise.resolve(live);
139
+ return this.ctxPromise;
140
+ }
141
+ }
142
+
143
+ this.ctxPromise = buildDispatchContext(this.explicitRouter ?? defaultRouter);
144
+ return this.ctxPromise;
69
145
  }
70
146
 
71
147
  /** Send a GET request. */
@@ -93,7 +169,7 @@ export class TestClient {
93
169
  return this._request("DELETE", path, options);
94
170
  }
95
171
 
96
- /** Build a mock request, match the route, execute the handler. */
172
+ /** Build a mock request/response pair and dispatch it through the REAL pipeline (runDispatch). */
97
173
  private async _request(method: string, path: string, options?: RequestOptions): Promise<TestResponse> {
98
174
  const { json, body, headers } = options ?? {};
99
175
 
@@ -126,7 +202,7 @@ export class TestClient {
126
202
  const rawReq = new IncomingMessage(socket);
127
203
  rawReq.method = method.toUpperCase();
128
204
  rawReq.url = path;
129
- rawReq.headers = { ...reqHeaders, host: "localhost:7145" };
205
+ rawReq.headers = { ...reqHeaders, host: "localhost:7148" };
130
206
 
131
207
  // Push body data into the readable stream
132
208
  if (rawBody) {
@@ -134,133 +210,47 @@ export class TestClient {
134
210
  }
135
211
  rawReq.push(null); // signal end of stream
136
212
 
137
- // Create a mock ServerResponse that captures output
213
+ // Create a mock ServerResponse that captures output.
214
+ //
215
+ // A response over a real socket flips `writableEnded` only when Node's
216
+ // OWN write/end implementation actually runs, and this mock never calls
217
+ // it — write()/end() are fully replaced, since there is no real peer to
218
+ // stream bytes to. Left alone, `rawRes.writableEnded` therefore stays
219
+ // FALSE forever (confirmed empirically), even after this mock's own
220
+ // end() has "completed". The real pipeline checks `res.raw.writableEnded`
221
+ // in several places to decide whether a stage already answered the
222
+ // request — most importantly runMatchedRoute's trailing
223
+ // `if (!res.raw.writableEnded) res.raw.end();` — so a permanently-false
224
+ // reading causes a SECOND, redundant end() call after every matched
225
+ // route. That reaches compressionEtagIntercept's wrapped end(), whose own
226
+ // buffered chunks were never cleared from the first call, and it resends
227
+ // them: the captured body comes out DUPLICATED. `ended` is the real
228
+ // single source of truth here, exposed via an own-property override of
229
+ // `writableEnded` so every pipeline stage's check reads correctly, and
230
+ // write()/end() themselves become no-ops once it is set (idempotent,
231
+ // matching the real "write/end after end is a no-op" contract).
138
232
  const rawRes = new ServerResponse(rawReq);
139
233
  const chunks: Buffer[] = [];
140
- const originalWrite = rawRes.write.bind(rawRes);
141
- const originalEnd = rawRes.end.bind(rawRes);
234
+ let ended = false;
235
+ Object.defineProperty(rawRes, "writableEnded", { get: () => ended, configurable: true });
142
236
 
143
- rawRes.write = function (chunk: any, ...args: any[]): boolean {
144
- if (chunk) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
237
+ rawRes.write = ((chunk?: any, ..._args: any[]): boolean => {
238
+ if (ended) return true;
239
+ if (chunk != null) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
145
240
  return true;
146
- } as typeof rawRes.write;
147
-
148
- rawRes.end = function (chunk?: any, ...args: any[]): ServerResponse {
149
- if (chunk) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
150
- return rawRes;
151
- } as typeof rawRes.end;
152
-
153
- // Create Tina4 request/response wrappers
154
- const req = createRequest(rawReq);
155
- const res = createResponse(rawRes);
156
-
157
- // Parse body (populates req.body)
158
- await req.parseBody();
241
+ }) as typeof rawRes.write;
159
242
 
160
- // Split path for route matching
161
- const cleanPath = path.includes("?") ? path.split("?")[0] : path;
162
-
163
- // Match route
164
- const httpMethod = method.toUpperCase();
165
- const match = this.router.match(httpMethod, cleanPath);
166
- if (!match) {
167
- // D6: dispatch through the REAL front-controller behaviour on a miss —
168
- // never fabricate a body. This mirrors the live server tail
169
- // (server.ts dispatch): RFC 9110 conformance first (a path registered
170
- // under another method answers OPTIONS with 204 + Allow and any other
171
- // method with 405 + Allow), then the framework's real 404. The old
172
- // TestClient short-circuited to a hand-invented {"error":"Not found"}
173
- // that the live server never sends, so a green test proved nothing about
174
- // production (the same silent-success class as Python's pre-fix client).
175
- const allowed = this.router.methodsAllowedForPath(cleanPath);
176
- if (allowed.length > 0) {
177
- const allowHeader = allowed.join(", ");
178
- if (httpMethod === "OPTIONS") {
179
- return new TestResponse(204, { allow: allowHeader, "content-length": "0" }, "");
180
- }
181
- const body405 = JSON.stringify({
182
- error: "Method Not Allowed",
183
- path: cleanPath,
184
- method: httpMethod,
185
- allow: allowed,
186
- statusCode: 405,
187
- });
188
- return new TestResponse(405, { allow: allowHeader, "content-type": "application/json" }, body405);
189
- }
190
- // The framework's real 404. The live server renders an HTML error page
191
- // when a project templatesDir/frondEngine exists and otherwise emits this
192
- // exact JSON; a server-less TestClient has no project dir, so it produces
193
- // the JSON fallback the live front controller itself falls back to.
194
- // tina4: HTML-error-page + filesystem static serving are the only live
195
- // front-controller steps a server-less client can't reproduce.
196
- const body404 = JSON.stringify({
197
- error: "Not Found",
198
- statusCode: 404,
199
- message: `No route found for ${httpMethod} ${cleanPath}`,
200
- });
201
- return new TestResponse(404, { "content-type": "application/json" }, body404);
202
- }
203
-
204
- // Inject route params
205
- req.params = match.params;
206
-
207
- // Global class-based middleware (Router.use / MiddlewareRunner.use), run in
208
- // the SAME order the live dispatcher uses (server.ts): beforeX hooks before
209
- // the handler, afterX hooks after — even on a before-short-circuit. Empty
210
- // when a test registers none, so this is additive/non-breaking.
211
- const globalMiddleware = [
212
- ...new Set([...Router.getClassMiddlewares(), ...MiddlewareRunner.getGlobal()]),
213
- ];
214
- if (globalMiddleware.length > 0) {
215
- const [, , proceed] = await MiddlewareRunner.runBefore(globalMiddleware, req, res);
216
- if (!proceed || res.raw.writableEnded) {
217
- await MiddlewareRunner.runAfter(globalMiddleware, req, res);
218
- if (!res.raw.writableEnded) res.raw.end();
219
- return this._collect(rawRes, chunks, socket);
243
+ rawRes.end = ((chunk?: any, ..._args: any[]): ServerResponse => {
244
+ if (ended) return rawRes;
245
+ if (chunk != null && typeof chunk !== "function") {
246
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
220
247
  }
221
- }
222
-
223
- // Per-route middleware: functions, string specs, and CLASSES, through the
224
- // same runner the live dispatcher uses. A route class's afterX hooks join
225
- // the after pass below, exactly as in server.ts.
226
- //
227
- // ORDER DRIFT, stated rather than hidden: the live dispatcher runs the auth
228
- // gate BEFORE the route's own middleware (ADR-0012 — middleware on a
229
- // secured route must never process a request that is about to be rejected);
230
- // here it still runs before the gate. Aligning the two is a behaviour
231
- // change to the test surface and belongs in its own change.
232
- const routeMiddlewareClasses = (match.middlewares ?? []).filter(isMiddlewareClass);
233
- if (match.middlewares && match.middlewares.length > 0) {
234
- const proceed = await runRouteMiddlewares(match.middlewares, req, res);
235
- if (!proceed || res.raw.writableEnded) {
236
- await MiddlewareRunner.runAfter([...globalMiddleware, ...routeMiddlewareClasses], req, res);
237
- if (!res.raw.writableEnded) res.raw.end();
238
- return this._collect(rawRes, chunks, socket);
239
- }
240
- }
248
+ ended = true;
249
+ return rawRes;
250
+ }) as typeof rawRes.end;
241
251
 
242
- // Route through the REAL auth gate (parity with the live server). A write to
243
- // an auth-required route (secure-by-default POST/PUT/PATCH/DELETE, or any
244
- // .secure() route) with no valid token / formToken / session token 401s here
245
- // exactly as it would in production. The TestClient used to skip this and run
246
- // the handler directly, so a green test could hide a live 401 — the
247
- // verification layer lied. A public route (GET, or a write marked .noAuth())
248
- // passes straight through. When rejected, enforceRouteAuth has written a 401
249
- // to res.raw, so the collection below reports it just like a handler response.
250
- // (#PY2 parity)
251
- const isDevAdmin = cleanPath.startsWith("/__dev");
252
- const rejected = enforceRouteAuth(req, res, match, isDevAdmin);
253
-
254
- // Execute handler (only if auth passed)
255
- if (!rejected) {
256
- await match.handler(req, res);
257
- // Global + route-class afterX hooks (logging / post-processing),
258
- // mirroring the live tail.
259
- const afterMiddleware = [...globalMiddleware, ...routeMiddlewareClasses];
260
- if (afterMiddleware.length > 0) {
261
- await MiddlewareRunner.runAfter(afterMiddleware, req, res);
262
- }
263
- }
252
+ const ctx = await this.context();
253
+ await runDispatch(ctx, rawReq, rawRes);
264
254
 
265
255
  return this._collect(rawRes, chunks, socket);
266
256
  }
@@ -268,13 +258,12 @@ export class TestClient {
268
258
  /** Gather the captured status/headers/body into a TestResponse and free the socket. */
269
259
  private _collect(rawRes: ServerResponse, chunks: Buffer[], socket: Socket): TestResponse {
270
260
  const responseBody = Buffer.concat(chunks).toString();
271
- const responseHeaders: Record<string, string> = {};
261
+ const headerList: Record<string, string[]> = {};
272
262
  for (const [name, value] of Object.entries(rawRes.getHeaders())) {
273
- if (value !== undefined) {
274
- responseHeaders[name] = Array.isArray(value) ? value.join(", ") : String(value);
275
- }
263
+ if (value === undefined) continue;
264
+ headerList[name.toLowerCase()] = Array.isArray(value) ? value.map(String) : [String(value)];
276
265
  }
277
266
  socket.destroy();
278
- return new TestResponse(rawRes.statusCode, responseHeaders, responseBody);
267
+ return new TestResponse(rawRes.statusCode, headerList, responseBody);
279
268
  }
280
269
  }
@@ -1,19 +1,23 @@
1
1
  /**
2
2
  * Tina4 Node.js — Inline testing framework.
3
3
  *
4
- * Attach test assertions to functions and run them all at once.
4
+ * Attach test expectations to functions and run them all at once.
5
5
  *
6
- * import { tests, assertEqual, assertRaises, runAll } from "./testing.js";
6
+ * import { tests, expectEqual, expectRaises, runAll } from "./testing.js";
7
7
  *
8
8
  * const add = tests(
9
- * assertEqual([5, 3], 8),
10
- * assertRaises(Error, [null]),
9
+ * expectEqual([5, 3], 8),
10
+ * expectRaises(Error, [null]),
11
11
  * )(function add(a: number, b: number | null = null): number {
12
12
  * if (b === null) throw new Error("b required");
13
13
  * return a + b;
14
14
  * });
15
15
  *
16
16
  * runAll();
17
+ *
18
+ * The builders are named expect* — DESCRIPTORS that record an expectation for the
19
+ * runner — deliberately distinct from the xUnit assert* on Tina4Test (test.ts),
20
+ * so importing the wrong surface can never silently change call semantics.
17
21
  */
18
22
 
19
23
  // ── Types ──────────────────────────────────────────────────────────
@@ -44,26 +48,26 @@ const registry: RegistryEntry[] = [];
44
48
 
45
49
  // ── Assertion builders ─────────────────────────────────────────────
46
50
 
47
- /** Assert that calling the function with `args` returns `expected`. */
48
- export function assertEqual(args: unknown[], expected: unknown): Assertion {
51
+ /** Expect that calling the function with `args` returns `expected`. */
52
+ export function expectEqual(args: unknown[], expected: unknown): Assertion {
49
53
  return { type: "equal", args, expected };
50
54
  }
51
55
 
52
- /** Assert that calling the function with `args` throws an instance of `errorClass`. */
53
- export function assertRaises(
56
+ /** Expect that calling the function with `args` throws an instance of `errorClass`. */
57
+ export function expectRaises(
54
58
  errorClass: new (...a: unknown[]) => Error,
55
59
  args: unknown[],
56
60
  ): Assertion {
57
61
  return { type: "raises", args, exception: errorClass };
58
62
  }
59
63
 
60
- /** Assert that calling the function with `args` returns a truthy value. */
61
- export function assertTrue(args: unknown[]): Assertion {
64
+ /** Expect that calling the function with `args` returns a truthy value. */
65
+ export function expectTrue(args: unknown[]): Assertion {
62
66
  return { type: "true", args };
63
67
  }
64
68
 
65
- /** Assert that calling the function with `args` returns a falsy value. */
66
- export function assertFalse(args: unknown[]): Assertion {
69
+ /** Expect that calling the function with `args` returns a falsy value. */
70
+ export function expectFalse(args: unknown[]): Assertion {
67
71
  return { type: "false", args };
68
72
  }
69
73
 
@@ -21,20 +21,32 @@ export interface Tina4Request extends IncomingMessage {
21
21
  /**
22
22
  * Path params. Typed params arrive coerced: `{id:int}`/`{id:integer}` and
23
23
  * `{p:float}`/`{p:number}` are JS `number`s; every other type and untyped
24
- * `{id}` stay `string` (parity with Python/PHP/Ruby).
24
+ * `{id}` stay `string` (parity with Python/PHP/Ruby). ROUTE-ONLY — never
25
+ * the query string or body (REQ-PARAM-POLLUTION, 3.13.99). Writable: the
26
+ * router assigns it AFTER createRequest() builds the wire-derived fields
27
+ * below, once a route has matched.
25
28
  */
26
29
  params: Record<string, string | number>;
27
- query: Record<string, string>;
30
+ /**
31
+ * Core wire-derived fields below are `readonly` (REQ-IMMUTABILITY-DIVERGE,
32
+ * 3.13.99) — set once in createRequest() and never reassigned afterward,
33
+ * matching PHP's `readonly` properties and Ruby's writer-less attr_reader
34
+ * (the two languages already at this posture; this is TS-compile-time
35
+ * only, like PHP/Ruby's enforcement is at their own language boundary).
36
+ * `params`/`body`/`files`/`session`/`user` stay mutable: the router and
37
+ * middleware legitimately set them after construction.
38
+ */
39
+ readonly query: Record<string, string>;
28
40
  /**
29
41
  * Request path only — no query string. Matches `request.path` in
30
42
  * Python/PHP/Ruby. Example: `/users/42`.
31
43
  */
32
- path: string;
44
+ readonly path: string;
33
45
  /**
34
46
  * Raw query string with no leading "?". Matches `request.query_string`
35
47
  * (Python/Ruby) and `request.queryString` (PHP). Example: `"page=2"`.
36
48
  */
37
- queryString: string;
49
+ readonly queryString: string;
38
50
  /**
39
51
  * Full absolute URL — `scheme://host[:port]/path[?query]`.
40
52
  * Honours X-Forwarded-Proto / X-Forwarded-Host. Matches PHP/Ruby/Python parity.
@@ -42,19 +54,19 @@ export interface Tina4Request extends IncomingMessage {
42
54
  * Note: this overrides Node's native `IncomingMessage.url` (which contains
43
55
  * only path+query). Inside Tina4 handlers, `req.url` is always the full URL.
44
56
  */
45
- url: string;
57
+ readonly url: string;
46
58
  body: unknown;
47
- ip: string;
59
+ readonly ip: string;
48
60
  /**
49
61
  * Raw socket peer address - NEVER honours X-Forwarded-For (which any
50
62
  * caller can spoof), so it can be trusted for security decisions.
51
63
  * Empty for in-process / synthetic requests. Parity with Python's
52
64
  * request.remote_ip and PHP's Request::$remoteIp.
53
65
  */
54
- remoteIp: string;
66
+ readonly remoteIp: string;
55
67
  files: Record<string, UploadedFile | UploadedFile[]>;
56
- cookies: Record<string, string>;
57
- contentType: string;
68
+ readonly cookies: Record<string, string>;
69
+ readonly contentType: string;
58
70
  /**
59
71
  * NULL when the session backend was unusable for this request (ADR-0021).
60
72
  * The request path logs the failure and degrades rather than 500-ing, so a
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The single Tina4 framework version resolver (feature 130, VERSION-DEC-01).
3
+ *
4
+ * Before this file, three surfaces inside @tina4/core each read their own copy
5
+ * of package.json independently: server.ts's `readPackageVersion()` was a
6
+ * FIXED `../../../package.json` (three levels up from wherever this file
7
+ * physically sits) with no fallback -- correct in the monorepo dev tree, but
8
+ * silently `"0.0.0"` the moment @tina4/core is relocated (a published npm
9
+ * install, a bundled dist/, a pnpm .pnpm store symlink) because the fixed
10
+ * depth no longer lands on a package.json at all. devAdmin.ts tried two fixed
11
+ * paths (`../../../package.json` then `../../package.json`) with the same
12
+ * "0.0.0" floor. mcp.ts's default dev MCP server never read a manifest at
13
+ * all -- its serverInfo.version was just the constructor's generic '1.0.0'
14
+ * default. Three readers, three ways to drift from the real version and from
15
+ * each other.
16
+ *
17
+ * The CLI (`packages/cli/src/bin.ts` `readCliVersion()`) already had the
18
+ * RIGHT algorithm: walk up from this file's own location to the NEAREST
19
+ * package.json that declares a version, rather than assuming a fixed depth.
20
+ * That is robust to being relocated because it does not care how many
21
+ * directories separate it from the root -- it finds whichever package.json is
22
+ * actually adjacent to wherever this code ended up running from (its own
23
+ * package's manifest in a published install, the monorepo root in the dev
24
+ * tree). This file ports that exact algorithm into @tina4/core so the THREE
25
+ * in-package readers collapse into ONE. (The CLI keeps its own small copy
26
+ * rather than importing this one: `packages/cli` deliberately avoids a
27
+ * top-level import of `@tina4/core` so a bare `tina4nodejs --help` does not
28
+ * pay to load the whole bundled core package -- see the port-takeover comment
29
+ * in bin.ts. Both copies run the identical walk-up, so in any real deployment
30
+ * layout they resolve to the same value.)
31
+ *
32
+ * Cheap and side-effect-free: filesystem reads only, no bootstrap.
33
+ */
34
+ import { existsSync, readFileSync } from "node:fs";
35
+ import { dirname, join } from "node:path";
36
+ import { fileURLToPath } from "node:url";
37
+
38
+ /**
39
+ * Walk up from this file to the nearest package.json carrying a non-empty
40
+ * `version` field. Stops at the first hit (nearest wins), so a published
41
+ * `@tina4/core` install resolves its OWN package.json, and the monorepo dev
42
+ * tree resolves the workspace root's -- both the real, current version.
43
+ * Falls back to "0.0.0" only if none is found within the walk (a layout with
44
+ * no package.json anywhere in its ancestry at all).
45
+ */
46
+ export function resolveFrameworkVersion(): string {
47
+ let dir = dirname(fileURLToPath(import.meta.url));
48
+ for (let i = 0; i < 6; i++) {
49
+ const pkgPath = join(dir, "package.json");
50
+ if (existsSync(pkgPath)) {
51
+ try {
52
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
53
+ if (typeof pkg.version === "string" && pkg.version) return pkg.version;
54
+ } catch {
55
+ // keep walking -- a malformed package.json isn't ours
56
+ }
57
+ }
58
+ const parent = dirname(dir);
59
+ if (parent === dir) break;
60
+ dir = parent;
61
+ }
62
+ return "0.0.0";
63
+ }
64
+
65
+ /** Resolved once at module load -- every @tina4/core surface imports this. */
66
+ export const TINA4_VERSION = resolveFrameworkVersion();