tina4-nodejs 3.13.137 → 3.13.138

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 (242) hide show
  1. package/CLAUDE.md +33 -3
  2. package/COMMERCIAL-LICENSE.md +8 -0
  3. package/LICENSE +373 -0
  4. package/NOTICE +11 -0
  5. package/README.md +24 -2
  6. package/package.json +44 -6
  7. package/packages/cli/dist/bin.js +19466 -18048
  8. package/packages/cli/src/bin.ts +8 -0
  9. package/packages/cli/src/commands/build.ts +8 -0
  10. package/packages/cli/src/commands/generate.ts +8 -0
  11. package/packages/cli/src/commands/init.ts +8 -0
  12. package/packages/cli/src/commands/lint.ts +8 -0
  13. package/packages/cli/src/commands/migrate.ts +8 -0
  14. package/packages/cli/src/commands/migrateCreate.ts +8 -0
  15. package/packages/cli/src/commands/migrateRollback.ts +8 -0
  16. package/packages/cli/src/commands/migrateStatus.ts +8 -0
  17. package/packages/cli/src/commands/queue.ts +8 -0
  18. package/packages/cli/src/commands/routes.ts +8 -0
  19. package/packages/cli/src/commands/seed.ts +8 -0
  20. package/packages/cli/src/commands/serve.ts +11 -0
  21. package/packages/cli/src/commands/test.ts +8 -0
  22. package/packages/core/dist/index.js +19458 -18043
  23. package/packages/core/gallery/auth/src/routes/api/gallery/auth/login/post.ts +8 -0
  24. package/packages/core/gallery/auth/src/routes/api/gallery/auth/verify/get.ts +8 -0
  25. package/packages/core/gallery/auth/src/routes/gallery/auth/get.ts +8 -0
  26. package/packages/core/gallery/database/src/routes/api/gallery/db/notes/get.ts +8 -0
  27. package/packages/core/gallery/database/src/routes/api/gallery/db/notes/post.ts +8 -0
  28. package/packages/core/gallery/database/src/routes/api/gallery/db/tables/get.ts +8 -0
  29. package/packages/core/gallery/error-overlay/src/routes/api/gallery/crash/get.ts +8 -0
  30. package/packages/core/gallery/orm/src/routes/api/gallery/products/get.ts +8 -0
  31. package/packages/core/gallery/orm/src/routes/api/gallery/products/post.ts +8 -0
  32. package/packages/core/gallery/queue/src/lib/queueDb.ts +8 -0
  33. package/packages/core/gallery/queue/src/routes/api/gallery/queue/consume/post.ts +8 -0
  34. package/packages/core/gallery/queue/src/routes/api/gallery/queue/fail/post.ts +8 -0
  35. package/packages/core/gallery/queue/src/routes/api/gallery/queue/produce/post.ts +8 -0
  36. package/packages/core/gallery/queue/src/routes/api/gallery/queue/retry/post.ts +8 -0
  37. package/packages/core/gallery/queue/src/routes/api/gallery/queue/status/get.ts +8 -0
  38. package/packages/core/gallery/queue/src/routes/gallery/queue/get.ts +8 -0
  39. package/packages/core/gallery/rest-api/src/routes/api/gallery/hello/get.ts +8 -0
  40. package/packages/core/gallery/rest-api/src/routes/api/gallery/hello/post.ts +8 -0
  41. package/packages/core/gallery/templates/src/routes/gallery/page/get.ts +8 -0
  42. package/packages/core/gallery/templates/src/templates/gallery_page.twig +8 -0
  43. package/packages/core/public/THIRD-PARTY-NOTICES.md +55 -0
  44. package/packages/core/public/bundled-component-provenance.json +48 -0
  45. package/packages/core/public/js/tina4js.min.js +13 -3
  46. package/packages/core/public/licenses/swagger-ui-3.10.0-original-notice.txt +11 -0
  47. package/packages/core/public/licenses/swagger-ui-5.17.14-NOTICE.txt +2 -0
  48. package/packages/core/public/licenses/swagger-ui-Apache-2.0.txt +202 -0
  49. package/packages/core/public/licenses/tina4-css-MIT.txt +21 -0
  50. package/packages/core/public/licenses/tina4-dev-admin-components.json +240 -0
  51. package/packages/core/public/licenses/tina4-dev-admin-third-party.txt +919 -0
  52. package/packages/core/public/licenses/tina4-js-MIT.txt +20 -0
  53. package/packages/core/src/__feedback/widget.js +8 -0
  54. package/packages/core/src/_missing.ts +8 -0
  55. package/packages/core/src/ai.ts +8 -0
  56. package/packages/core/src/aiClient.ts +8 -0
  57. package/packages/core/src/api.ts +15 -3
  58. package/packages/core/src/auth.ts +21 -8
  59. package/packages/core/src/authGate.ts +8 -0
  60. package/packages/core/src/background.ts +8 -0
  61. package/packages/core/src/cache.ts +43 -4
  62. package/packages/core/src/constants.ts +8 -0
  63. package/packages/core/src/container.ts +8 -0
  64. package/packages/core/src/context/chunker.ts +8 -0
  65. package/packages/core/src/context/index.ts +8 -0
  66. package/packages/core/src/devAdmin.ts +185 -59
  67. package/packages/core/src/devMailbox.ts +8 -0
  68. package/packages/core/src/dispatchPipeline.ts +8 -0
  69. package/packages/core/src/docs.ts +8 -0
  70. package/packages/core/src/docsAutoDiscovery.ts +8 -0
  71. package/packages/core/src/docsParser.ts +8 -0
  72. package/packages/core/src/docsScanner.ts +44 -0
  73. package/packages/core/src/docsSignatures.ts +8 -0
  74. package/packages/core/src/dotenv.ts +8 -0
  75. package/packages/core/src/env.ts +8 -0
  76. package/packages/core/src/errorOverlay.ts +8 -0
  77. package/packages/core/src/events.ts +8 -0
  78. package/packages/core/src/fakeData.ts +8 -0
  79. package/packages/core/src/feedback.ts +8 -0
  80. package/packages/core/src/graphql.ts +16 -5
  81. package/packages/core/src/health.ts +8 -0
  82. package/packages/core/src/htmlElement.ts +8 -0
  83. package/packages/core/src/i18n.ts +8 -0
  84. package/packages/core/src/index.ts +8 -0
  85. package/packages/core/src/job.ts +8 -0
  86. package/packages/core/src/logger.ts +8 -0
  87. package/packages/core/src/mcp.test.ts +8 -0
  88. package/packages/core/src/mcp.ts +8 -0
  89. package/packages/core/src/messenger.ts +143 -86
  90. package/packages/core/src/metrics.ts +13 -0
  91. package/packages/core/src/middleware.ts +1178 -1096
  92. package/packages/core/src/mqtt.ts +8 -0
  93. package/packages/core/src/mqttMessage.ts +8 -0
  94. package/packages/core/src/optionalPackage.ts +48 -0
  95. package/packages/core/src/plan.ts +8 -0
  96. package/packages/core/src/portTakeover.ts +8 -0
  97. package/packages/core/src/projectIndex.ts +8 -0
  98. package/packages/core/src/projectIndexExtractors.ts +8 -0
  99. package/packages/core/src/projectIndexStorage.ts +8 -0
  100. package/packages/core/src/push.ts +8 -0
  101. package/packages/core/src/queue.ts +8 -0
  102. package/packages/core/src/queueBackends/kafkaBackend.ts +8 -0
  103. package/packages/core/src/queueBackends/liteBackend.ts +8 -0
  104. package/packages/core/src/queueBackends/mongoBackend.ts +21 -8
  105. package/packages/core/src/queueBackends/rabbitmqBackend.ts +8 -0
  106. package/packages/core/src/rateLimiter.ts +8 -0
  107. package/packages/core/src/request.ts +59 -10
  108. package/packages/core/src/response.ts +79 -5
  109. package/packages/core/src/routeDiscovery.ts +8 -0
  110. package/packages/core/src/router.ts +8 -0
  111. package/packages/core/src/server.ts +73 -24
  112. package/packages/core/src/service.ts +8 -0
  113. package/packages/core/src/session.ts +8 -0
  114. package/packages/core/src/sessionHandlers/childError.ts +8 -0
  115. package/packages/core/src/sessionHandlers/databaseHandler.ts +8 -0
  116. package/packages/core/src/sessionHandlers/memcachedHandler.ts +8 -0
  117. package/packages/core/src/sessionHandlers/mongoClient.ts +8 -0
  118. package/packages/core/src/sessionHandlers/mongoHandler.ts +8 -0
  119. package/packages/core/src/sessionHandlers/respClient.ts +8 -0
  120. package/packages/core/src/sessionHandlers/sqlClient.ts +8 -0
  121. package/packages/core/src/sessionHandlers/syncBridge.ts +8 -0
  122. package/packages/core/src/sessionHandlers/syncSocket.ts +8 -0
  123. package/packages/core/src/sessionHandlers/valkeyHandler.ts +8 -0
  124. package/packages/core/src/sso.ts +8 -0
  125. package/packages/core/src/static.ts +8 -0
  126. package/packages/core/src/test.ts +8 -0
  127. package/packages/core/src/testClient.ts +8 -0
  128. package/packages/core/src/testing.ts +8 -0
  129. package/packages/core/src/transport.ts +369 -0
  130. package/packages/core/src/trustedProxy.ts +8 -0
  131. package/packages/core/src/types.ts +13 -0
  132. package/packages/core/src/validator.ts +8 -0
  133. package/packages/core/src/version.ts +8 -0
  134. package/packages/core/src/websocket.ts +8 -0
  135. package/packages/core/src/websocketBackplane.ts +49 -32
  136. package/packages/core/src/websocketConnection.ts +8 -0
  137. package/packages/core/src/wsdl.ts +197 -115
  138. package/packages/core/templates/errors/302.twig +8 -0
  139. package/packages/core/templates/errors/401.twig +8 -0
  140. package/packages/core/templates/errors/403.twig +8 -0
  141. package/packages/core/templates/errors/404.twig +8 -0
  142. package/packages/core/templates/errors/500.twig +8 -0
  143. package/packages/core/templates/errors/502.twig +8 -0
  144. package/packages/core/templates/errors/503.twig +8 -0
  145. package/packages/core/templates/errors/base.twig +8 -0
  146. package/packages/frond/dist/index.js +151 -17
  147. package/packages/frond/src/engine.ts +177 -17
  148. package/packages/frond/src/index.ts +8 -0
  149. package/packages/orm/dist/index.js +8125 -6708
  150. package/packages/orm/src/adapters/firebird.ts +50 -3
  151. package/packages/orm/src/adapters/mongodb.ts +8 -0
  152. package/packages/orm/src/adapters/mssql.ts +29 -7
  153. package/packages/orm/src/adapters/mysql.ts +17 -1
  154. package/packages/orm/src/adapters/odbc.ts +11 -0
  155. package/packages/orm/src/adapters/pg-types.d.ts +8 -0
  156. package/packages/orm/src/adapters/postgres.ts +34 -8
  157. package/packages/orm/src/adapters/sqlDialect.ts +57 -0
  158. package/packages/orm/src/adapters/sqlite.ts +26 -0
  159. package/packages/orm/src/autoCrud.ts +78 -36
  160. package/packages/orm/src/baseModel.ts +61 -29
  161. package/packages/orm/src/cachedDatabase.ts +67 -1
  162. package/packages/orm/src/connectTimeout.ts +8 -0
  163. package/packages/orm/src/database.ts +649 -401
  164. package/packages/orm/src/databaseResult.ts +8 -0
  165. package/packages/orm/src/databaseUrl.ts +8 -0
  166. package/packages/orm/src/docstore.ts +23 -1
  167. package/packages/orm/src/fakeData.ts +8 -0
  168. package/packages/orm/src/graph/adapters/arango.ts +8 -0
  169. package/packages/orm/src/graph/adapters/bolt.ts +8 -0
  170. package/packages/orm/src/graph/adapters/ultipa.ts +8 -0
  171. package/packages/orm/src/graph/connectTimeout.ts +8 -0
  172. package/packages/orm/src/graph/errors.ts +8 -0
  173. package/packages/orm/src/graph/graphAdapter.ts +8 -0
  174. package/packages/orm/src/graph/graphDatabase.ts +8 -0
  175. package/packages/orm/src/graph/graphUrl.ts +10 -1
  176. package/packages/orm/src/graph/shapes.ts +8 -0
  177. package/packages/orm/src/index.ts +147 -139
  178. package/packages/orm/src/migration.ts +8 -0
  179. package/packages/orm/src/model.ts +8 -0
  180. package/packages/orm/src/modelCollection.ts +8 -0
  181. package/packages/orm/src/point.ts +8 -0
  182. package/packages/orm/src/query.ts +149 -33
  183. package/packages/orm/src/queryBuilder.ts +8 -0
  184. package/packages/orm/src/realtime/index.ts +8 -0
  185. package/packages/orm/src/realtime/models/attachment.ts +8 -0
  186. package/packages/orm/src/realtime/models/channel.ts +8 -0
  187. package/packages/orm/src/realtime/models/channelMember.ts +8 -0
  188. package/packages/orm/src/realtime/models/message.ts +8 -0
  189. package/packages/orm/src/realtime/models/workspace.ts +8 -0
  190. package/packages/orm/src/realtime/realtime.ts +8 -0
  191. package/packages/orm/src/realtime/storage.ts +43 -12
  192. package/packages/orm/src/seeder.ts +8 -0
  193. package/packages/orm/src/seederTable.ts +8 -0
  194. package/packages/orm/src/seederTypes.ts +8 -0
  195. package/packages/orm/src/sqlTranslator.ts +185 -8
  196. package/packages/orm/src/types.ts +15 -0
  197. package/packages/orm/src/validation.ts +8 -0
  198. package/packages/swagger/src/generator.ts +8 -0
  199. package/packages/swagger/src/index.ts +8 -0
  200. package/packages/swagger/src/ui.ts +8 -0
  201. package/types/core/src/background.d.ts +0 -16
  202. package/types/core/src/cache.d.ts +6 -0
  203. package/types/core/src/devAdmin.d.ts +18 -12
  204. package/types/core/src/docs.d.ts +0 -17
  205. package/types/core/src/errorOverlay.d.ts +0 -24
  206. package/types/core/src/events.d.ts +0 -12
  207. package/types/core/src/feedback.d.ts +0 -23
  208. package/types/core/src/graphql.d.ts +0 -21
  209. package/types/core/src/htmlElement.d.ts +0 -15
  210. package/types/core/src/messenger.d.ts +12 -1
  211. package/types/core/src/metrics.d.ts +2 -0
  212. package/types/core/src/middleware.d.ts +45 -1
  213. package/types/core/src/mqtt.d.ts +0 -33
  214. package/types/core/src/optionalPackage.d.ts +25 -0
  215. package/types/core/src/plan.d.ts +0 -10
  216. package/types/core/src/projectIndex.d.ts +0 -10
  217. package/types/core/src/queueBackends/mongoBackend.d.ts +2 -0
  218. package/types/core/src/request.d.ts +12 -0
  219. package/types/core/src/response.d.ts +13 -0
  220. package/types/core/src/server.d.ts +19 -0
  221. package/types/core/src/sessionHandlers/childError.d.ts +0 -15
  222. package/types/core/src/transport.d.ts +28 -0
  223. package/types/core/src/trustedProxy.d.ts +0 -16
  224. package/types/core/src/types.d.ts +5 -0
  225. package/types/core/src/websocketBackplane.d.ts +1 -0
  226. package/types/core/src/wsdl.d.ts +8 -22
  227. package/types/frond/src/engine.d.ts +7 -1
  228. package/types/orm/src/adapters/firebird.d.ts +12 -0
  229. package/types/orm/src/adapters/mssql.d.ts +11 -0
  230. package/types/orm/src/adapters/mysql.d.ts +2 -0
  231. package/types/orm/src/adapters/postgres.d.ts +11 -0
  232. package/types/orm/src/adapters/sqlDialect.d.ts +25 -0
  233. package/types/orm/src/adapters/sqlite.d.ts +7 -0
  234. package/types/orm/src/baseModel.d.ts +5 -0
  235. package/types/orm/src/cachedDatabase.d.ts +27 -0
  236. package/types/orm/src/database.d.ts +46 -7
  237. package/types/orm/src/graph/graphUrl.d.ts +0 -8
  238. package/types/orm/src/index.d.ts +1 -1
  239. package/types/orm/src/query.d.ts +48 -2
  240. package/types/orm/src/realtime/storage.d.ts +2 -1
  241. package/types/orm/src/sqlTranslator.d.ts +70 -18
  242. package/types/orm/src/types.d.ts +6 -0
@@ -0,0 +1,369 @@
1
+ /*
2
+ Copyright (c) 2026 Code Infinity
3
+ SPDX-License-Identifier: MPL-2.0
4
+ This Source Code Form is subject to the terms of the Mozilla Public
5
+ License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+ */
8
+
9
+ /**
10
+ * The HTTP transport around node:http (ADR-0068 sections 2-4).
11
+ *
12
+ * node:http parses the request, so most of the rules are its own; this module
13
+ * makes what the client SEES match the contract every Tina4 framework keeps:
14
+ *
15
+ * - every transport rejection (400, 408, 413, 431, 500) has one shape: an
16
+ * exact JSON body, Content-Type, Content-Length, Connection: close and the
17
+ * canonical security headers (no HSTS - it is written before the scheme is
18
+ * known);
19
+ * - TINA4_MAX_REQUEST_HEADER caps the head (431) and TINA4_REQUEST_TIMEOUT
20
+ * answers a request that stalls part-way (408);
21
+ * - a declared body over TINA4_MAX_UPLOAD_SIZE is refused before any of it is
22
+ * read, and a refused upload is closed - node:http would otherwise read and
23
+ * discard the rest of it to keep the connection alive;
24
+ * - a header node:http refuses natively still produces the fixture's answer
25
+ * (500 {"error":"Invalid response header"}), not a route error page.
26
+ */
27
+ import { createServer, validateHeaderName, validateHeaderValue } from "node:http";
28
+ import type { IncomingMessage, ServerResponse, Server, OutgoingHttpHeaders } from "node:http";
29
+ import type { Socket } from "node:net";
30
+ import { SecurityHeadersMiddleware } from "./middleware.js";
31
+ import { Log } from "./logger.js";
32
+ import { quoteHeaderName } from "./response.js";
33
+ import { resolveLimit, maxUploadSize, bodyTooLargeMessage } from "./request.js";
34
+
35
+ const DEFAULT_MAX_REQUEST_HEADER = 65_536;
36
+ const DEFAULT_REQUEST_TIMEOUT_SECONDS = 30;
37
+
38
+ /**
39
+ * After a rejection the server stops reading the connection at once and keeps
40
+ * it open for at most HOLD_SECONDS before closing it (ADR-0068 section 4).
41
+ *
42
+ * Why hold rather than close: closing with unread bytes in the kernel buffer
43
+ * sends a reset, and a client still uploading would lose the answer. Why not
44
+ * drain (read and discard, as PHP and Python do): on loopback two seconds of
45
+ * draining is gigabytes read, and a refused upload must not make the server
46
+ * read more of it. Paused, TCP flow control stalls the client instead, it
47
+ * reads the answer, and the bytes read after the refusal stay bounded by what
48
+ * was already in flight.
49
+ */
50
+ const HOLD_SECONDS = 2;
51
+
52
+ const held = new WeakSet<Socket>();
53
+ function stopReadingThenClose(socket: Socket): void {
54
+ if (held.has(socket)) return;
55
+ held.add(socket);
56
+ socket.pause();
57
+ // node:http resumes the socket to discard an unread request body (and after
58
+ // a parse error); on this socket that must not happen any more.
59
+ (socket as Socket & { resume: () => Socket }).resume = () => socket;
60
+ const timer = setTimeout(() => socket.destroy(), HOLD_SECONDS * 1000);
61
+ timer.unref();
62
+ socket.once("close", () => clearTimeout(timer));
63
+ }
64
+
65
+ const REASONS: Record<number, string> = {
66
+ 400: "Bad Request",
67
+ 408: "Request Timeout",
68
+ 413: "Content Too Large",
69
+ 431: "Request Header Fields Too Large",
70
+ 500: "Internal Server Error",
71
+ };
72
+
73
+ function rejectionHeaders(body: string): Array<[string, string]> {
74
+ const headers: Array<[string, string]> = [
75
+ ["Content-Type", "application/json"],
76
+ ["Content-Length", String(Buffer.byteLength(body))],
77
+ ["Connection", "close"],
78
+ ];
79
+ for (const [name, value] of Object.entries(SecurityHeadersMiddleware.canonicalHeaders())) {
80
+ // A broken environment override must not break the rejection itself.
81
+ if (!/[\r\n\0]/.test(value)) headers.push([name, value]);
82
+ }
83
+ return headers;
84
+ }
85
+
86
+ function rejectionBody(message: string): string {
87
+ return JSON.stringify({ error: message });
88
+ }
89
+
90
+ /** A whole rejection as raw bytes, for a socket no ServerResponse owns yet. */
91
+ function rawRejection(status: number, message: string): string {
92
+ const body = rejectionBody(message);
93
+ const head = rejectionHeaders(body).map(([name, value]) => `${name}: ${value}\r\n`).join("");
94
+ return `HTTP/1.1 ${status} ${REASONS[status] ?? "Error"}\r\n${head}\r\n${body}`;
95
+ }
96
+
97
+ /**
98
+ * Answer through a ServerResponse with the rejection shape and close. Every
99
+ * header already on the response is dropped: the shape is exact. Node closes
100
+ * the connection once the answer is flushed (Connection: close), so the rest
101
+ * of a refused upload is never read.
102
+ */
103
+ export function sendTransportRejection(res: ServerResponse, status: number, message: string, end = res.end.bind(res)): void {
104
+ if (res.headersSent) {
105
+ res.socket?.destroy();
106
+ return;
107
+ }
108
+ for (const name of res.getHeaderNames()) res.removeHeader(name);
109
+ res.statusCode = status;
110
+ res.statusMessage = REASONS[status] ?? "Error";
111
+ const body = rejectionBody(message);
112
+ for (const [name, value] of rejectionHeaders(body)) res.setHeader(name, value);
113
+ res.shouldKeepAlive = false;
114
+ const socket = res.socket;
115
+ if (socket) {
116
+ // node:http closes a Connection: close response with destroySoon() once
117
+ // it is flushed, which resets a client that is still uploading before it
118
+ // has read the answer. Half-close instead; stopReadingThenClose() closes.
119
+ (socket as Socket & { destroySoon: () => void }).destroySoon = () => socket.end();
120
+ stopReadingThenClose(socket);
121
+ }
122
+ end(body);
123
+ }
124
+
125
+ /** Write a rejection to a socket no response owns yet, then drain and close. */
126
+ const rejectedSockets = new WeakSet<Socket>();
127
+ function rejectSocket(socket: Socket, status: number, message: string): void {
128
+ if (rejectedSockets.has(socket)) return;
129
+ rejectedSockets.add(socket);
130
+ socket.end(rawRejection(status, message));
131
+ stopReadingThenClose(socket);
132
+ }
133
+
134
+ const HEADER_REFUSAL_CODES = new Set(["ERR_INVALID_CHAR", "ERR_INVALID_HTTP_TOKEN", "ERR_HTTP_INVALID_HEADER_VALUE"]);
135
+
136
+ function isHeaderRefusal(err: unknown): boolean {
137
+ return err instanceof TypeError && HEADER_REFUSAL_CODES.has((err as { code?: string }).code ?? "");
138
+ }
139
+
140
+ /**
141
+ * ADR-0068 section 2 for Node. node:http refuses an unsafe header where it is
142
+ * set (setHeader / appendHeader / writeHead). A header set straight on the raw
143
+ * response - past Tina4's own call-site check - used to surface as a route
144
+ * error page. Here the refusal is recorded instead, and whatever the handler
145
+ * sends next becomes the fixture's 500 {"error":"Invalid response header"},
146
+ * logged by header name. Tina4's own Response keeps refusing at the call site
147
+ * (its check runs before this layer is reached).
148
+ */
149
+ export function guardUnsafeHeaders(res: ServerResponse): void {
150
+ let refusedName: string | null = null;
151
+ const originalEnd = res.end.bind(res);
152
+ const originalWrite = res.write.bind(res);
153
+ const originalWriteHead = res.writeHead.bind(res);
154
+ const originalSetHeader = res.setHeader.bind(res);
155
+ const originalAppendHeader = typeof res.appendHeader === "function" ? res.appendHeader.bind(res) : null;
156
+
157
+ const refuse = (name: unknown): void => {
158
+ if (refusedName !== null) return;
159
+ refusedName = String(name);
160
+ Log.error(
161
+ `Refusing to write an unsafe response header ${quoteHeaderName(refusedName)}: ` +
162
+ "the name is not an HTTP token or the value contains CR, LF or NUL",
163
+ );
164
+ };
165
+ // While the refusal itself is being written the wrappers step aside:
166
+ // node:http's end() calls this.writeHead() and this.setHeader() internally.
167
+ let answering = false;
168
+ const answerRefused = (): void => {
169
+ if (answering) return;
170
+ answering = true;
171
+ sendTransportRejection(res, 500, "Invalid response header", originalEnd as typeof res.end);
172
+ };
173
+
174
+ res.setHeader = function (name: string, value: number | string | readonly string[]) {
175
+ try {
176
+ return originalSetHeader(name, value);
177
+ } catch (err) {
178
+ if (!isHeaderRefusal(err)) throw err;
179
+ refuse(name);
180
+ return res;
181
+ }
182
+ } as typeof res.setHeader;
183
+
184
+ if (originalAppendHeader) {
185
+ res.appendHeader = function (name: string, value: string | readonly string[]) {
186
+ try {
187
+ return originalAppendHeader(name, value);
188
+ } catch (err) {
189
+ if (!isHeaderRefusal(err)) throw err;
190
+ refuse(name);
191
+ return res;
192
+ }
193
+ } as typeof res.appendHeader;
194
+ }
195
+
196
+ res.writeHead = function (this: ServerResponse, statusCode: number, ...rest: unknown[]) {
197
+ // Validate the headers object BEFORE anything reaches the wire: node:http
198
+ // would otherwise send the head without the refused header.
199
+ const headers = rest.find((arg) => arg !== null && typeof arg === "object") as OutgoingHttpHeaders | string[] | undefined;
200
+ if (headers && !Array.isArray(headers)) {
201
+ for (const [name, value] of Object.entries(headers)) {
202
+ try {
203
+ validateHeaderName(name);
204
+ if (value !== undefined) for (const item of Array.isArray(value) ? value : [value]) validateHeaderValue(name, String(item));
205
+ } catch (err) {
206
+ if (!isHeaderRefusal(err)) throw err;
207
+ refuse(name);
208
+ }
209
+ }
210
+ }
211
+ if (refusedName !== null && !answering) return res;
212
+ try {
213
+ return (originalWriteHead as (...args: unknown[]) => ServerResponse)(statusCode, ...rest);
214
+ } catch (err) {
215
+ if (!isHeaderRefusal(err)) throw err;
216
+ refuse("(writeHead)");
217
+ return res;
218
+ }
219
+ } as typeof res.writeHead;
220
+
221
+ res.write = function (...args: unknown[]) {
222
+ if (refusedName !== null && !answering) {
223
+ answerRefused();
224
+ return false;
225
+ }
226
+ return (originalWrite as (...a: unknown[]) => boolean)(...args);
227
+ } as typeof res.write;
228
+
229
+ res.end = function (...args: unknown[]) {
230
+ if (refusedName !== null && !answering) {
231
+ answerRefused();
232
+ return res;
233
+ }
234
+ return (originalEnd as (...a: unknown[]) => ServerResponse)(...args);
235
+ } as typeof res.end;
236
+ }
237
+
238
+ /**
239
+ * Refuse a request on its head alone, before a body byte is read: a
240
+ * Transfer-Encoding other than exactly `chunked` (node:http has already
241
+ * refused one combined with Content-Length, or repeated), and a declared
242
+ * Content-Length over TINA4_MAX_UPLOAD_SIZE. Returns true when it answered.
243
+ */
244
+ function rejectOnHead(req: IncomingMessage, res: ServerResponse): boolean {
245
+ const transferEncoding = req.headers["transfer-encoding"];
246
+ if (transferEncoding !== undefined && transferEncoding.trim().toLowerCase() !== "chunked") {
247
+ sendTransportRejection(res, 400, "Invalid Transfer-Encoding");
248
+ return true;
249
+ }
250
+ const contentLength = req.headers["content-length"];
251
+ if (contentLength !== undefined) {
252
+ const limit = maxUploadSize();
253
+ const declared = BigInt(contentLength); // node:http accepts digits only
254
+ if (declared > BigInt(limit)) {
255
+ sendTransportRejection(res, 413, bodyTooLargeMessage(declared, limit));
256
+ return true;
257
+ }
258
+ }
259
+ return false;
260
+ }
261
+
262
+ /**
263
+ * Answer 408 when a request's body stops arriving for TINA4_REQUEST_TIMEOUT
264
+ * seconds. Idle, not total: a slow but steady upload is never cut off.
265
+ * (A head that never completes is node:http's headersTimeout, set to the same
266
+ * value.)
267
+ */
268
+ function watchBody(req: IncomingMessage, res: ServerResponse, idleSeconds: number): void {
269
+ if (idleSeconds <= 0 || req.complete) return;
270
+ const socket = req.socket;
271
+ let timer: NodeJS.Timeout | null = null;
272
+ const stop = (): void => {
273
+ if (timer) clearTimeout(timer);
274
+ timer = null;
275
+ socket.off("data", onData);
276
+ };
277
+ const fire = (): void => {
278
+ stop();
279
+ if (req.complete) return;
280
+ sendTransportRejection(res, 408, "Request timed out before it was complete");
281
+ };
282
+ const arm = (): void => {
283
+ if (timer) clearTimeout(timer);
284
+ timer = setTimeout(fire, idleSeconds * 1000);
285
+ timer.unref();
286
+ };
287
+ // Added after node:http's own listener, so the parser has already consumed
288
+ // the chunk and req.complete is current.
289
+ function onData(): void {
290
+ if (req.complete) stop();
291
+ else arm();
292
+ }
293
+ socket.on("data", onData);
294
+ req.once("end", stop);
295
+ req.once("close", stop);
296
+ res.once("close", stop);
297
+ arm();
298
+ }
299
+
300
+ /** Map a node:http parse failure to the ADR-0068 answer. */
301
+ function onClientError(err: NodeJS.ErrnoException, socket: Socket): void {
302
+ if (rejectedSockets.has(socket) || held.has(socket)) return; // answered already
303
+ if (err.code === "ECONNRESET" || !socket.writable) {
304
+ socket.destroy();
305
+ return;
306
+ }
307
+ const inFlight = (socket as Socket & { _httpMessage?: ServerResponse | null })._httpMessage ?? null;
308
+
309
+ if (err.code === "ERR_HTTP_REQUEST_TIMEOUT") {
310
+ // A connection that never sent a byte has no request to answer.
311
+ if (socket.bytesRead === 0) {
312
+ socket.destroy();
313
+ } else if (inFlight) {
314
+ sendTransportRejection(inFlight, 408, "Request timed out before it was complete");
315
+ } else {
316
+ rejectSocket(socket, 408, "Request timed out before it was complete");
317
+ }
318
+ return;
319
+ }
320
+
321
+ // The head was accepted and the body broke: bad chunk framing.
322
+ if (inFlight) {
323
+ if (inFlight.headersSent) socket.destroy();
324
+ else sendTransportRejection(inFlight, 400, "Invalid Transfer-Encoding");
325
+ return;
326
+ }
327
+
328
+ switch (err.code) {
329
+ case "HPE_HEADER_OVERFLOW":
330
+ rejectSocket(socket, 431, `Request header fields exceed TINA4_MAX_REQUEST_HEADER (${resolveLimit("TINA4_MAX_REQUEST_HEADER", DEFAULT_MAX_REQUEST_HEADER)} bytes)`);
331
+ return;
332
+ case "HPE_INVALID_CONTENT_LENGTH":
333
+ case "HPE_UNEXPECTED_CONTENT_LENGTH":
334
+ rejectSocket(socket, 400, "Invalid Content-Length");
335
+ return;
336
+ case "HPE_INVALID_TRANSFER_ENCODING":
337
+ case "HPE_INVALID_CHUNK_SIZE":
338
+ rejectSocket(socket, 400, "Invalid Transfer-Encoding");
339
+ return;
340
+ default:
341
+ rejectSocket(socket, 400, "Malformed request head");
342
+ }
343
+ }
344
+
345
+ /**
346
+ * Create the HTTP server Tina4 listens with - the main port, its loopback
347
+ * siblings, the AI port and every cluster worker - so every one of them keeps
348
+ * the same limits and answers.
349
+ */
350
+ export function createTina4HttpServer(handler: (req: IncomingMessage, res: ServerResponse) => unknown): Server {
351
+ const maxRequestHeader = resolveLimit("TINA4_MAX_REQUEST_HEADER", DEFAULT_MAX_REQUEST_HEADER);
352
+ const idleSeconds = resolveLimit("TINA4_REQUEST_TIMEOUT", DEFAULT_REQUEST_TIMEOUT_SECONDS, true);
353
+ const timeouts =
354
+ idleSeconds > 0
355
+ ? {
356
+ headersTimeout: idleSeconds * 1000,
357
+ requestTimeout: Math.max(300_000, idleSeconds * 1000),
358
+ connectionsCheckingInterval: 1000,
359
+ }
360
+ : {};
361
+
362
+ const server = createServer({ maxHeaderSize: maxRequestHeader, ...timeouts }, (req, res) => {
363
+ if (rejectOnHead(req, res)) return;
364
+ watchBody(req, res, idleSeconds);
365
+ return handler(req, res);
366
+ });
367
+ server.on("clientError", onClientError);
368
+ return server;
369
+ }
@@ -1,3 +1,11 @@
1
+ /*
2
+ Copyright (c) 2026 Code Infinity
3
+ SPDX-License-Identifier: MPL-2.0
4
+ This Source Code Form is subject to the terms of the Mozilla Public
5
+ License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+ */
8
+
1
9
  /**
2
10
  * Which upstream hops are allowed to speak for a client (ADR-0019).
3
11
  *
@@ -1,3 +1,11 @@
1
+ /*
2
+ Copyright (c) 2026 Code Infinity
3
+ SPDX-License-Identifier: MPL-2.0
4
+ This Source Code Form is subject to the terms of the Mozilla Public
5
+ License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+ */
8
+
1
9
  import type { IncomingMessage, ServerResponse } from "node:http";
2
10
 
3
11
  export interface UploadedFile {
@@ -207,6 +215,11 @@ export interface Tina4Config {
207
215
  * and staticDir are resolved relative to this path instead of process.cwd(). */
208
216
  basePath?: string;
209
217
  routesDir?: string;
218
+ /**
219
+ * Never open a browser on start (ADR-0070 rule 3, the programmatic
220
+ * --no-browser). It can only veto: false does not force one open.
221
+ */
222
+ noBrowser?: boolean;
210
223
  modelsDir?: string;
211
224
  templatesDir?: string;
212
225
  staticDir?: string;
@@ -1,3 +1,11 @@
1
+ /*
2
+ Copyright (c) 2026 Code Infinity
3
+ SPDX-License-Identifier: MPL-2.0
4
+ This Source Code Form is subject to the terms of the Mozilla Public
5
+ License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+ */
8
+
1
9
  /**
2
10
  * Tina4 Validator — Request body validation.
3
11
  *
@@ -1,3 +1,11 @@
1
+ /*
2
+ Copyright (c) 2026 Code Infinity
3
+ SPDX-License-Identifier: MPL-2.0
4
+ This Source Code Form is subject to the terms of the Mozilla Public
5
+ License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+ */
8
+
1
9
  /**
2
10
  * The single Tina4 framework version resolver (feature 130, VERSION-DEC-01).
3
11
  *
@@ -1,3 +1,11 @@
1
+ /*
2
+ Copyright (c) 2026 Code Infinity
3
+ SPDX-License-Identifier: MPL-2.0
4
+ This Source Code Form is subject to the terms of the Mozilla Public
5
+ License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+ */
8
+
1
9
  /**
2
10
  * Tina4 WebSocket — Zero-dependency RFC 6455 implementation.
3
11
  *
@@ -1,3 +1,11 @@
1
+ /*
2
+ Copyright (c) 2026 Code Infinity
3
+ SPDX-License-Identifier: MPL-2.0
4
+ This Source Code Form is subject to the terms of the Mozilla Public
5
+ License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+ */
8
+
1
9
  /**
2
10
  * WebSocket Backplane Abstraction for Tina4 Node.js.
3
11
  *
@@ -17,6 +25,31 @@
17
25
  * }
18
26
  */
19
27
  import { randomUUID } from "node:crypto";
28
+ import { resolveOptionalPackage } from "./optionalPackage.js";
29
+ import { redactCredentials } from "../../orm/src/databaseUrl.js";
30
+
31
+ /**
32
+ * The one "connected" line both backplanes print. The URL goes through the
33
+ * framework's single redaction primitive: it can carry a password
34
+ * (`redis://:secret@host`), and this line used to print it verbatim.
35
+ */
36
+ function logConnected(backplaneName: string, url: string): void {
37
+ console.log(`[Tina4] ${backplaneName} connected to ${redactCredentials(url)}`);
38
+ }
39
+
40
+ /**
41
+ * A backplane connects in the background, and publish()/subscribe() await that
42
+ * connect. Until someone does, a failed connect is a rejected promise nobody is
43
+ * holding - and Node's default for an unhandled rejection is to kill the
44
+ * process. Observe it so the failure is LOGGED once and still rejects every
45
+ * later publish()/subscribe(). The URL is deliberately not logged: it can carry
46
+ * a password.
47
+ */
48
+ function observeConnectFailure(ready: Promise<void>, backplaneName: string): void {
49
+ ready.catch((error: unknown) => {
50
+ console.error(`[Tina4] ${backplaneName} could not connect: ${error instanceof Error ? error.message : String(error)}`);
51
+ });
52
+ }
20
53
 
21
54
  /**
22
55
  * Base interface for scaling WebSocket broadcast across instances.
@@ -55,20 +88,12 @@ export class RedisBackplane implements WebSocketBackplane {
55
88
  const resolvedUrl = url ?? process.env.TINA4_WS_BACKPLANE_URL ?? "redis://localhost:6379";
56
89
  this.url = resolvedUrl;
57
90
 
58
- this.ready = (async () => {
59
- let redis: any;
60
- try {
61
- // Optional peer dependency — resolved via a string specifier so the
62
- // module isn't required at type-check time when it isn't installed.
63
- const redisModule: string = "redis";
64
- redis = await import(redisModule);
65
- } catch {
66
- throw new Error(
67
- "The 'redis' package is required for RedisBackplane. " +
68
- "Install it with: npm install redis"
69
- );
70
- }
91
+ // Optional peer (ADR-0067): resolved NOW so a missing package throws from
92
+ // the constructor with the install command, as the Python master's does.
93
+ const redisModuleUrl = resolveOptionalPackage("redis", "RedisBackplane");
71
94
 
95
+ this.ready = (async () => {
96
+ const redis: any = await import(redisModuleUrl);
72
97
  this.publisher = redis.createClient({ url: resolvedUrl });
73
98
  this.subscriber = this.publisher.duplicate();
74
99
 
@@ -76,8 +101,9 @@ export class RedisBackplane implements WebSocketBackplane {
76
101
  this.publisher.connect(),
77
102
  this.subscriber.connect(),
78
103
  ]);
79
- console.log(`[Tina4] RedisBackplane connected to ${resolvedUrl}`);
104
+ logConnected("RedisBackplane", resolvedUrl);
80
105
  })();
106
+ observeConnectFailure(this.ready, "RedisBackplane");
81
107
  }
82
108
 
83
109
  async publish(channel: string, message: string): Promise<void> {
@@ -118,42 +144,33 @@ export class NATSBackplane implements WebSocketBackplane {
118
144
  private url: string;
119
145
  private subs: Map<string, any> = new Map();
120
146
  private ready: Promise<void>;
147
+ private natsModuleUrl: string;
121
148
 
122
149
  constructor(url?: string) {
123
150
  const resolvedUrl = url ?? process.env.TINA4_WS_BACKPLANE_URL ?? "nats://localhost:4222";
124
151
  this.url = resolvedUrl;
125
152
 
126
- this.ready = (async () => {
127
- let nats: any;
128
- try {
129
- // Optional peer dependency — resolved via a string specifier so the
130
- // module isn't required at type-check time when it isn't installed.
131
- const natsModule: string = "nats";
132
- nats = await import(natsModule);
133
- } catch {
134
- throw new Error(
135
- "The 'nats' package is required for NATSBackplane. " +
136
- "Install it with: npm install nats"
137
- );
138
- }
153
+ const natsModuleUrl = resolveOptionalPackage("nats", "NATSBackplane");
154
+ this.natsModuleUrl = natsModuleUrl;
139
155
 
156
+ this.ready = (async () => {
157
+ const nats: any = await import(natsModuleUrl);
140
158
  this.nc = await nats.connect({ servers: resolvedUrl });
141
- console.log(`[Tina4] NATSBackplane connected to ${resolvedUrl}`);
159
+ logConnected("NATSBackplane", resolvedUrl);
142
160
  })();
161
+ observeConnectFailure(this.ready, "NATSBackplane");
143
162
  }
144
163
 
145
164
  async publish(channel: string, message: string): Promise<void> {
146
165
  await this.ready;
147
- const natsModule: string = "nats";
148
- const { StringCodec } = await import(natsModule);
166
+ const { StringCodec } = await import(this.natsModuleUrl);
149
167
  const sc = StringCodec();
150
168
  this.nc.publish(channel, sc.encode(message));
151
169
  }
152
170
 
153
171
  async subscribe(channel: string, callback: (message: string) => void): Promise<void> {
154
172
  await this.ready;
155
- const natsModule: string = "nats";
156
- const { StringCodec } = await import(natsModule);
173
+ const { StringCodec } = await import(this.natsModuleUrl);
157
174
  const sc = StringCodec();
158
175
  const sub = this.nc.subscribe(channel);
159
176
  this.subs.set(channel, sub);
@@ -1,3 +1,11 @@
1
+ /*
2
+ Copyright (c) 2026 Code Infinity
3
+ SPDX-License-Identifier: MPL-2.0
4
+ This Source Code Form is subject to the terms of the Mozilla Public
5
+ License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+ */
8
+
1
9
  /**
2
10
  * Represents a WebSocket connection with send/broadcast/close capabilities.
3
11
  * Used by Router.websocket() route handlers.