tina4-nodejs 3.13.92 → 3.13.95

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 (193) hide show
  1. package/CLAUDE.md +170 -28
  2. package/README.md +2 -2
  3. package/package.json +13 -9
  4. package/packages/cli/dist/bin.js +33126 -30055
  5. package/packages/cli/src/commands/metrics.ts +17 -11
  6. package/packages/cli/src/commands/serve.ts +10 -9
  7. package/packages/core/dist/index.js +33062 -29908
  8. package/packages/core/src/ai.ts +7 -1
  9. package/packages/core/src/auth.ts +191 -39
  10. package/packages/core/src/background.ts +19 -19
  11. package/packages/core/src/cache.ts +492 -49
  12. package/packages/core/src/devAdmin.ts +79 -32
  13. package/packages/core/src/devMailbox.ts +20 -44
  14. package/packages/core/src/dispatchPipeline.ts +285 -0
  15. package/packages/core/src/dotenv.ts +185 -40
  16. package/packages/core/src/index.ts +7 -6
  17. package/packages/core/src/logger.ts +257 -36
  18. package/packages/core/src/mcp.ts +1 -1
  19. package/packages/core/src/messenger.ts +81 -13
  20. package/packages/core/src/metrics.ts +199 -961
  21. package/packages/core/src/middleware.ts +390 -123
  22. package/packages/core/src/queue.ts +188 -32
  23. package/packages/core/src/queueBackends/kafkaBackend.ts +109 -13
  24. package/packages/core/src/queueBackends/liteBackend.ts +13 -0
  25. package/packages/core/src/queueBackends/mongoBackend.ts +101 -9
  26. package/packages/core/src/queueBackends/rabbitmqBackend.ts +22 -4
  27. package/packages/core/src/rateLimiter.ts +10 -5
  28. package/packages/core/src/request.ts +6 -9
  29. package/packages/core/src/response.ts +46 -1
  30. package/packages/core/src/router.ts +29 -4
  31. package/packages/core/src/server.ts +751 -414
  32. package/packages/core/src/session.ts +244 -27
  33. package/packages/core/src/sessionHandlers/childError.ts +72 -0
  34. package/packages/core/src/sessionHandlers/databaseHandler.ts +338 -48
  35. package/packages/core/src/sessionHandlers/memcachedHandler.ts +181 -0
  36. package/packages/core/src/sessionHandlers/mongoClient.ts +293 -202
  37. package/packages/core/src/sessionHandlers/mongoHandler.ts +88 -8
  38. package/packages/core/src/sessionHandlers/respClient.ts +16 -143
  39. package/packages/core/src/sessionHandlers/sqlClient.ts +290 -0
  40. package/packages/core/src/sessionHandlers/syncBridge.ts +190 -0
  41. package/packages/core/src/sessionHandlers/syncSocket.ts +236 -0
  42. package/packages/core/src/testClient.ts +18 -5
  43. package/packages/core/src/trustedProxy.ts +249 -0
  44. package/packages/core/src/types.ts +29 -5
  45. package/packages/core/src/websocket.ts +66 -0
  46. package/packages/frond/dist/index.js +74 -31
  47. package/packages/frond/src/engine.ts +99 -33
  48. package/packages/orm/dist/index.js +26554 -23400
  49. package/packages/orm/src/adapters/firebird.ts +183 -56
  50. package/packages/orm/src/adapters/mongodb.ts +25 -4
  51. package/packages/orm/src/adapters/mssql.ts +114 -29
  52. package/packages/orm/src/adapters/mysql.ts +103 -40
  53. package/packages/orm/src/adapters/odbc.ts +44 -21
  54. package/packages/orm/src/adapters/postgres.ts +118 -26
  55. package/packages/orm/src/adapters/sqlDialect.ts +120 -0
  56. package/packages/orm/src/adapters/sqlite.ts +64 -25
  57. package/packages/orm/src/baseModel.ts +135 -40
  58. package/packages/orm/src/cachedDatabase.ts +43 -19
  59. package/packages/orm/src/connectTimeout.ts +265 -0
  60. package/packages/orm/src/database.ts +338 -198
  61. package/packages/orm/src/databaseResult.ts +65 -13
  62. package/packages/orm/src/databaseUrl.ts +484 -0
  63. package/packages/orm/src/docstore.ts +386 -145
  64. package/packages/orm/src/index.ts +13 -3
  65. package/packages/orm/src/migration.ts +18 -3
  66. package/packages/orm/src/queryBuilder.ts +38 -4
  67. package/packages/orm/src/sqlTranslator.ts +310 -4
  68. package/packages/orm/src/types.ts +15 -4
  69. package/types/cli/src/bin.d.ts +92 -0
  70. package/types/cli/src/commands/build.d.ts +2 -0
  71. package/types/cli/src/commands/generate.d.ts +47 -0
  72. package/types/cli/src/commands/init.d.ts +1 -0
  73. package/types/cli/src/commands/metrics.d.ts +6 -0
  74. package/types/cli/src/commands/migrate.d.ts +1 -0
  75. package/types/cli/src/commands/migrateCreate.d.ts +1 -0
  76. package/types/cli/src/commands/migrateRollback.d.ts +1 -0
  77. package/types/cli/src/commands/migrateStatus.d.ts +1 -0
  78. package/types/cli/src/commands/queue.d.ts +20 -0
  79. package/types/cli/src/commands/routes.d.ts +1 -0
  80. package/types/cli/src/commands/seed.d.ts +1 -0
  81. package/types/cli/src/commands/serve.d.ts +6 -0
  82. package/types/cli/src/commands/test.d.ts +1 -0
  83. package/types/core/src/ai.d.ts +64 -0
  84. package/types/core/src/api.d.ts +262 -0
  85. package/types/core/src/auth.d.ts +177 -0
  86. package/types/core/src/authGate.d.ts +20 -0
  87. package/types/core/src/background.d.ts +34 -0
  88. package/types/core/src/cache.d.ts +163 -0
  89. package/types/core/src/constants.d.ts +38 -0
  90. package/types/core/src/container.d.ts +44 -0
  91. package/types/core/src/context/chunker.d.ts +31 -0
  92. package/types/core/src/context/index.d.ts +93 -0
  93. package/types/core/src/devAdmin.d.ts +179 -0
  94. package/types/core/src/devMailbox.d.ts +54 -0
  95. package/types/core/src/dispatchPipeline.d.ts +117 -0
  96. package/types/core/src/docs.d.ts +141 -0
  97. package/types/core/src/docsAutoDiscovery.d.ts +6 -0
  98. package/types/core/src/dotenv.d.ts +87 -0
  99. package/types/core/src/env.d.ts +28 -0
  100. package/types/core/src/errorOverlay.d.ts +36 -0
  101. package/types/core/src/events.d.ts +75 -0
  102. package/types/core/src/fakeData.d.ts +55 -0
  103. package/types/core/src/feedback.d.ts +90 -0
  104. package/types/core/src/graphql.d.ts +207 -0
  105. package/types/core/src/health.d.ts +22 -0
  106. package/types/core/src/htmlElement.d.ts +75 -0
  107. package/types/core/src/i18n.d.ts +37 -0
  108. package/types/core/src/index.d.ts +92 -0
  109. package/types/core/src/job.d.ts +39 -0
  110. package/types/core/src/logger.d.ts +200 -0
  111. package/types/core/src/mcp.d.ts +248 -0
  112. package/types/core/src/messenger.d.ts +191 -0
  113. package/types/core/src/metrics.d.ts +41 -0
  114. package/types/core/src/middleware.d.ts +330 -0
  115. package/types/core/src/mqtt.d.ts +257 -0
  116. package/types/core/src/mqttMessage.d.ts +67 -0
  117. package/types/core/src/plan.d.ts +96 -0
  118. package/types/core/src/projectIndex.d.ts +56 -0
  119. package/types/core/src/queue.d.ts +268 -0
  120. package/types/core/src/queueBackends/kafkaBackend.d.ts +117 -0
  121. package/types/core/src/queueBackends/liteBackend.d.ts +128 -0
  122. package/types/core/src/queueBackends/mongoBackend.d.ts +119 -0
  123. package/types/core/src/queueBackends/rabbitmqBackend.d.ts +55 -0
  124. package/types/core/src/rateLimiter.d.ts +49 -0
  125. package/types/core/src/request.d.ts +25 -0
  126. package/types/core/src/response.d.ts +28 -0
  127. package/types/core/src/routeDiscovery.d.ts +12 -0
  128. package/types/core/src/router.d.ts +366 -0
  129. package/types/core/src/scss.d.ts +19 -0
  130. package/types/core/src/server.d.ts +146 -0
  131. package/types/core/src/service.d.ts +115 -0
  132. package/types/core/src/session.d.ts +341 -0
  133. package/types/core/src/sessionHandlers/childError.d.ts +34 -0
  134. package/types/core/src/sessionHandlers/databaseHandler.d.ts +97 -0
  135. package/types/core/src/sessionHandlers/memcachedHandler.d.ts +60 -0
  136. package/types/core/src/sessionHandlers/mongoClient.d.ts +35 -0
  137. package/types/core/src/sessionHandlers/mongoHandler.d.ts +109 -0
  138. package/types/core/src/sessionHandlers/respClient.d.ts +22 -0
  139. package/types/core/src/sessionHandlers/sqlClient.d.ts +39 -0
  140. package/types/core/src/sessionHandlers/syncBridge.d.ts +91 -0
  141. package/types/core/src/sessionHandlers/syncSocket.d.ts +49 -0
  142. package/types/core/src/sessionHandlers/valkeyHandler.d.ts +65 -0
  143. package/types/core/src/static.d.ts +2 -0
  144. package/types/core/src/test.d.ts +94 -0
  145. package/types/core/src/testClient.d.ts +36 -0
  146. package/types/core/src/testing.d.ts +58 -0
  147. package/types/core/src/trustedProxy.d.ts +44 -0
  148. package/types/core/src/types.d.ts +242 -0
  149. package/types/core/src/validator.d.ts +52 -0
  150. package/types/core/src/websocket.d.ts +402 -0
  151. package/types/core/src/websocketBackplane.d.ts +166 -0
  152. package/types/core/src/websocketConnection.d.ts +54 -0
  153. package/types/core/src/wsdl.d.ts +101 -0
  154. package/types/frond/src/engine.d.ts +263 -0
  155. package/types/frond/src/index.d.ts +2 -0
  156. package/types/orm/src/adapters/firebird.d.ts +183 -0
  157. package/types/orm/src/adapters/mongodb.d.ts +81 -0
  158. package/types/orm/src/adapters/mssql.d.ts +77 -0
  159. package/types/orm/src/adapters/mysql.d.ts +67 -0
  160. package/types/orm/src/adapters/odbc.d.ts +94 -0
  161. package/types/orm/src/adapters/postgres.d.ts +86 -0
  162. package/types/orm/src/adapters/sqlDialect.d.ts +71 -0
  163. package/types/orm/src/adapters/sqlite.d.ts +68 -0
  164. package/types/orm/src/autoCrud.d.ts +73 -0
  165. package/types/orm/src/baseModel.d.ts +427 -0
  166. package/types/orm/src/cachedDatabase.d.ts +190 -0
  167. package/types/orm/src/connectTimeout.d.ts +100 -0
  168. package/types/orm/src/database.d.ts +655 -0
  169. package/types/orm/src/databaseResult.d.ts +109 -0
  170. package/types/orm/src/databaseUrl.d.ts +125 -0
  171. package/types/orm/src/docstore.d.ts +241 -0
  172. package/types/orm/src/fakeData.d.ts +22 -0
  173. package/types/orm/src/index.d.ts +43 -0
  174. package/types/orm/src/migration.d.ts +275 -0
  175. package/types/orm/src/model.d.ts +7 -0
  176. package/types/orm/src/query.d.ts +14 -0
  177. package/types/orm/src/queryBuilder.d.ts +193 -0
  178. package/types/orm/src/realtime/index.d.ts +7 -0
  179. package/types/orm/src/realtime/models/attachment.d.ts +43 -0
  180. package/types/orm/src/realtime/models/channel.d.ts +32 -0
  181. package/types/orm/src/realtime/models/channelMember.d.ts +32 -0
  182. package/types/orm/src/realtime/models/message.d.ts +36 -0
  183. package/types/orm/src/realtime/models/workspace.d.ts +26 -0
  184. package/types/orm/src/realtime/realtime.d.ts +24 -0
  185. package/types/orm/src/realtime/storage.d.ts +61 -0
  186. package/types/orm/src/seeder.d.ts +118 -0
  187. package/types/orm/src/sqlTranslator.d.ts +258 -0
  188. package/types/orm/src/types.d.ts +148 -0
  189. package/types/orm/src/validation.d.ts +6 -0
  190. package/types/swagger/src/generator.d.ts +46 -0
  191. package/types/swagger/src/index.d.ts +2 -0
  192. package/types/swagger/src/ui.d.ts +11 -0
  193. package/packages/core/src/sessionHandlers/redisHandler.ts +0 -206
@@ -44,10 +44,12 @@ export interface MongoConfig {
44
44
  }
45
45
 
46
46
  export interface QueueBackend {
47
- push(queue: string, payload: unknown, delay?: number): string;
47
+ push(queue: string, payload: unknown, delay?: number, priority?: number): string;
48
48
  pop(queue: string): QueueJob | null;
49
49
  size(queue: string): number;
50
50
  clear(queue: string): void;
51
+ /** Release whatever connection the backend holds. Must be idempotent. */
52
+ close(): void;
51
53
  }
52
54
 
53
55
  // ── MongoDB Backend ──────────────────────────────────────────
@@ -214,12 +216,31 @@ export class MongoBackend implements QueueBackend {
214
216
  {
215
217
  queue: queueName,
216
218
  status: "pending",
217
- $or: [
218
- { availableAt: null },
219
- { availableAt: { $exists: false } },
220
- { availableAt: { $lte: now } },
221
- { delayUntil: null },
222
- { delayUntil: { $lte: now } },
219
+ // TWO INDEPENDENT GATES, both of which must pass: the
220
+ // reservation gate (availableAt) and the delay gate
221
+ // (delayUntil). These used to share ONE $or, which made them
222
+ // alternatives rather than requirements a freshly pushed
223
+ // delayed job has no availableAt, matched
224
+ // { availableAt: { $exists: false } }, and was handed straight
225
+ // to a consumer. That is why push(payload, delay) fired
226
+ // immediately on Mongo and on time on the file backend.
227
+ // The $exists arms keep documents written before either field
228
+ // existed claimable, instead of stranding them forever.
229
+ $and: [
230
+ {
231
+ $or: [
232
+ { availableAt: null },
233
+ { availableAt: { $exists: false } },
234
+ { availableAt: { $lte: now } },
235
+ ],
236
+ },
237
+ {
238
+ $or: [
239
+ { delayUntil: null },
240
+ { delayUntil: { $exists: false } },
241
+ { delayUntil: { $lte: now } },
242
+ ],
243
+ },
223
244
  ],
224
245
  },
225
246
  { $set: { status: "reserved", reservedAt: now, availableAt: future } },
@@ -250,6 +271,28 @@ export class MongoBackend implements QueueBackend {
250
271
  process.stdout.write("__EMPTY__");
251
272
  }
252
273
  }
274
+ else if (operation === "popById") {
275
+ // Claim ONE specific job by id, the same way pop() claims the head.
276
+ // Queue.popById used to read the LOCAL FILE STORE regardless of the
277
+ // configured backend, so it never saw a mongodb job at all.
278
+ const now = new Date().toISOString();
279
+ const future = new Date(Date.now() + visibilityTimeout * 1000).toISOString();
280
+ const wanted = JSON.parse(data);
281
+ const result = await col.findOneAndUpdate(
282
+ { queue: queueName, status: "pending", id: wanted.id },
283
+ { $set: { status: "reserved", reservedAt: now, availableAt: future } },
284
+ { returnDocument: "before" },
285
+ );
286
+ const doc = result && result.value ? result.value : (result && result._id ? { ...result } : null);
287
+ if (doc) {
288
+ doc.topic = queueName;
289
+ delete doc._id;
290
+ delete doc.queue;
291
+ process.stdout.write(JSON.stringify(doc));
292
+ } else {
293
+ process.stdout.write("__EMPTY__");
294
+ }
295
+ }
253
296
  else if (operation === "size") {
254
297
  const count = await col.countDocuments({
255
298
  queue: queueName,
@@ -318,8 +361,20 @@ export class MongoBackend implements QueueBackend {
318
361
  process.stdout.write(JSON.stringify(out));
319
362
  }
320
363
  else if (operation === "failed") {
364
+ // Found by the ATTEMPTS COUNTER, not by a "failed" status. The
365
+ // fail() branch above re-queues a still-retryable job as "pending"
366
+ // (that is what makes the next pop redeliver it) and dead-letters
367
+ // an exhausted one as "dead" - nothing ever writes "failed", so
368
+ // this query matched nothing and returned [] forever. An empty
369
+ // list is indistinguishable from "nothing has failed"
370
+ // (ADR-0022 decision 7). attempts > 0 is the real marker of a job
371
+ // that has already died at least once.
321
372
  const docs = await col
322
- .find({ queue: queueName, status: "failed", attempts: { $lt: maxRetries } })
373
+ .find({
374
+ queue: queueName,
375
+ status: "pending",
376
+ attempts: { $gt: 0, $lt: maxRetries },
377
+ })
323
378
  .toArray();
324
379
  const out = docs.map((d) => { delete d._id; delete d.queue; return d; });
325
380
  process.stdout.write(JSON.stringify(out));
@@ -380,7 +435,17 @@ export class MongoBackend implements QueueBackend {
380
435
  }
381
436
  }
382
437
 
383
- push(queue: string, payload: unknown, delay?: number): string {
438
+ popById(queue: string, id: string): QueueJob | null {
439
+ const out = this.execSync("popById", queue, JSON.stringify({ id }));
440
+ if (!out || out === "__EMPTY__") return null;
441
+ try {
442
+ return JSON.parse(out) as QueueJob;
443
+ } catch {
444
+ return null;
445
+ }
446
+ }
447
+
448
+ push(queue: string, payload: unknown, delay?: number, priority?: number): string {
384
449
  const id = randomUUID();
385
450
  const now = new Date().toISOString();
386
451
 
@@ -391,6 +456,11 @@ export class MongoBackend implements QueueBackend {
391
456
  createdAt: now,
392
457
  attempts: 0,
393
458
  delayUntil: delay ? new Date(Date.now() + delay * 1000).toISOString() : null,
459
+ // The pop sort has always been { priority: -1, createdAt: 1 }, but this
460
+ // field was never written, so every job scored undefined and the queue
461
+ // ran pure FIFO. Priority did not even reach here: the backend interface
462
+ // had no such parameter and Queue.push dropped it for external backends.
463
+ priority: priority ?? 0,
394
464
  };
395
465
 
396
466
  const result = this.execSync("push", queue, JSON.stringify(job));
@@ -471,4 +541,26 @@ export class MongoBackend implements QueueBackend {
471
541
  const out = this.execSync("purge", queue, JSON.stringify({ status: status ?? "" }));
472
542
  return parseInt(out, 10) || 0;
473
543
  }
544
+
545
+ /**
546
+ * Release the MongoDB connection. Idempotent — a second call is a no-op.
547
+ *
548
+ * HONEST CAVEAT, and it is the whole reason ADR-0022 exists: THIS backend
549
+ * holds no connection between calls to release. Every operation runs in its
550
+ * own child process (see execSync/buildScript), and that child's `finally`
551
+ * already does `await client.close()` before it exits — so the pool it opened
552
+ * is gone by the time the method returns. Unlike tina4-python, tina4-php and
553
+ * tina4-ruby, whose Mongo/broker backends hold a long-lived client that this
554
+ * method genuinely hands back, Node has nothing to give back.
555
+ *
556
+ * It is implemented anyway, and required by the QueueBackend interface,
557
+ * because the CONTRACT is what matters: `Queue.close()` must be callable on
558
+ * every backend in every framework, and the day the persistent-connection
559
+ * rewrite lands (ADR-0022's tracked fix) the client goes here with no change
560
+ * at any call site. A method that is a no-op today and correct forever beats
561
+ * a missing method the caller has to feature-detect.
562
+ */
563
+ close(): void {
564
+ // Nothing held: the per-operation child process owns and closes its client.
565
+ }
474
566
  }
@@ -40,8 +40,8 @@ export interface RabbitMQConfig {
40
40
  * Parse an AMQP URL (amqp://[user:pass@]host[:port][/vhost]) into a partial
41
41
  * RabbitMQConfig. Mirrors the Python/PHP/Ruby `parse_amqp_url` semantics:
42
42
  * strips a leading amqp:// or amqps:// scheme, splits optional credentials,
43
- * and prepends a leading "/" to the vhost when missing. Only fields present
44
- * in the URL are populated.
43
+ * and reads the path segment as the URL-decoded vhost name. Only fields
44
+ * present in the URL are populated.
45
45
  */
46
46
  export function parseAmqpUrl(url: string): RabbitMQConfig {
47
47
  const config: RabbitMQConfig = {};
@@ -65,8 +65,26 @@ export function parseAmqpUrl(url: string): RabbitMQConfig {
65
65
  if (slashIndex !== -1) {
66
66
  hostport = rest.slice(0, slashIndex);
67
67
  const vhost = rest.slice(slashIndex + 1);
68
+ // THE VHOST IS THE PATH SEGMENT, URL-DECODED, WITH NO LEADING SLASH
69
+ // (RabbitMQ URI spec). This used to prepend "/", so
70
+ // amqp://guest:guest@rabbit:5672/orders asked for a vhost literally named
71
+ // "/orders". No broker has that one - it is named "orders" - so every
72
+ // publish failed against a named vhost, which is the ordinary multi-tenant
73
+ // setup and the form every RabbitMQ tutorial shows. MEASURED against a real
74
+ // broker: 4 of 5 URL shapes resolved to the wrong name, and the only one
75
+ // that worked carried no vhost at all, which is why four green suites never
76
+ // noticed.
77
+ //
78
+ // Decoding matters for the same reason: the DEFAULT vhost is named "/",
79
+ // which cannot appear literally in a path, so the spec spells it "%2f".
80
+ //
81
+ // DELIBERATE DEVIATION, one shape: the spec reads a bare trailing slash as
82
+ // the EMPTY vhost name. Tina4 treats it as "not specified" and keeps the
83
+ // caller's default - nobody writes a trailing slash intending a vhost named
84
+ // "", and reading it literally would break a working "amqp://host:5672/"
85
+ // for no benefit.
68
86
  if (vhost) {
69
- config.vhost = vhost.startsWith("/") ? vhost : "/" + vhost;
87
+ config.vhost = decodeURIComponent(vhost);
70
88
  }
71
89
  }
72
90
 
@@ -82,7 +100,7 @@ export function parseAmqpUrl(url: string): RabbitMQConfig {
82
100
  }
83
101
 
84
102
  export interface QueueBackend {
85
- push(queue: string, payload: unknown, delay?: number): string;
103
+ push(queue: string, payload: unknown, delay?: number, priority?: number): string;
86
104
  pop(queue: string): QueueJob | null;
87
105
  size(queue: string): number;
88
106
  clear(queue: string): void;
@@ -1,4 +1,5 @@
1
1
  import type { Middleware, Tina4Request, Tina4Response } from "./types.js";
2
+ import { resolveClientIp } from "./trustedProxy.js";
2
3
 
3
4
  /** Per-IP sliding window entry */
4
5
  interface RateLimitEntry {
@@ -59,11 +60,15 @@ export function rateLimiter(config?: RateLimiterConfig): Middleware {
59
60
  const now = Date.now();
60
61
  const cutoff = now - windowMs;
61
62
 
62
- // Extract client IP check x-forwarded-for, then socket
63
- const forwarded = req.headers["x-forwarded-for"];
64
- const ip = (typeof forwarded === "string" ? forwarded.split(",")[0].trim() : undefined)
65
- ?? req.socket?.remoteAddress
66
- ?? "unknown";
63
+ // Client key. X-Forwarded-For is honoured ONLY when the socket peer is a
64
+ // declared trusted proxy (TINA4_TRUSTED_PROXIES) - otherwise any client
65
+ // could pick its own bucket, and pick someone else's. ADR-0019.
66
+ //
67
+ // This derived the key itself rather than reading req.ip, and its
68
+ // `typeof forwarded === "string"` test meant a REPEATED header (which
69
+ // arrives as an array) silently fell through to the socket address -
70
+ // inconsistent with req.ip, which did read the array.
71
+ const ip = resolveClientIp(req.headers, req.socket?.remoteAddress ?? "") || "unknown";
67
72
 
68
73
  // Get or create entry
69
74
  let entry = store.get(ip);
@@ -1,5 +1,6 @@
1
1
  import type { IncomingMessage, IncomingHttpHeaders } from "node:http";
2
2
  import type { Tina4Request, UploadedFile } from "./types.js";
3
+ import { resolveClientIp } from "./trustedProxy.js";
3
4
 
4
5
  /**
5
6
  * Wrap Node's `IncomingHttpHeaders` in a Proxy so mixed-case lookups
@@ -102,15 +103,11 @@ export function createRequest(req: IncomingMessage): Tina4Request {
102
103
  }
103
104
  tReq.cookies = cookies;
104
105
 
105
- // Determine client IP with X-Forwarded-For support
106
- const forwarded = req.headers["x-forwarded-for"];
107
- if (typeof forwarded === "string") {
108
- tReq.ip = forwarded.split(",")[0].trim();
109
- } else if (Array.isArray(forwarded) && forwarded.length > 0) {
110
- tReq.ip = forwarded[0].split(",")[0].trim();
111
- } else {
112
- tReq.ip = req.socket?.remoteAddress ?? "127.0.0.1";
113
- }
106
+ // Raw socket peer NEVER honours a forwarding header, so it can be trusted
107
+ // for security decisions. Resolved BEFORE .ip: the peer decides whether the
108
+ // forwarding headers may be believed at all (TINA4_TRUSTED_PROXIES, ADR-0019).
109
+ tReq.remoteIp = req.socket?.remoteAddress ?? "";
110
+ tReq.ip = resolveClientIp(req.headers, tReq.remoteIp) || "127.0.0.1";
114
111
 
115
112
  // Add convenience methods
116
113
  tReq.header = function (name: string): string | undefined {
@@ -293,11 +293,56 @@ export function createResponse(res: ServerResponse): Tina4Response {
293
293
  return response.json({ error: true, code, message, status: statusCode }, statusCode);
294
294
  };
295
295
 
296
- response.file = function (filePath: string, options?: { download?: boolean; contentType?: string }): Tina4Response {
296
+ response.file = function (
297
+ filePath: string,
298
+ options?: { download?: boolean; contentType?: string; root?: string },
299
+ ): Tina4Response {
297
300
  if (res.headersSent) return response;
298
301
 
302
+ // SECURITY: confine the read. The natural spelling of a download route,
303
+ //
304
+ // response.file("downloads/" + name) // name = "../secret.env"
305
+ //
306
+ // served any file the process could read - measured over real HTTP at 200
307
+ // with the contents of a .env one directory above the intended one.
308
+ //
309
+ // TWO checks. Containment ALONE does not close it: that payload resolves to
310
+ // <project>/secret.env, which IS inside the project root, and the project
311
+ // root is exactly where .env lives. Rejecting ".." on the way in is the
312
+ // check that closes it; containment then catches absolute paths and
313
+ // symlinks, neither of which carries a ".." segment. Same shape as
314
+ // static.ts's startsWith guard, which this function never had.
315
+ // Containment ONLY when a root is declared. Defaulting to cwd broke every
316
+ // legitimate absolute path.
317
+ const base = options?.root ? nodePath.resolve(options.root) : null;
318
+ let forbidden = filePath.split(/[\\/]/).includes("..");
319
+
320
+ if (!forbidden) {
321
+ const candidate = (base === null || nodePath.isAbsolute(filePath)) ? filePath : nodePath.join(base, filePath);
322
+ let resolved = candidate;
323
+ try {
324
+ resolved = fs.realpathSync(candidate);
325
+ } catch {
326
+ /* missing file: fall through to the 404 below with the joined path */
327
+ }
328
+ if (base !== null && base !== nodePath.sep && resolved !== base && !resolved.startsWith(base + nodePath.sep)) {
329
+ forbidden = true;
330
+ } else {
331
+ filePath = resolved;
332
+ }
333
+ }
334
+
335
+ if (forbidden) {
336
+ // Refuse BEFORE reading: never load bytes we will not send.
337
+ res.statusCode = 403;
338
+ safeSetHeader("Content-Type", "text/plain");
339
+ safeEnd("Forbidden");
340
+ return response;
341
+ }
342
+
299
343
  if (!fs.existsSync(filePath)) {
300
344
  res.statusCode = 404;
345
+ safeSetHeader("Content-Type", "text/plain");
301
346
  safeEnd("File not found");
302
347
  return response;
303
348
  }
@@ -1,5 +1,6 @@
1
1
  import type { RouteHandler, RouteDefinition, RouteMeta, Middleware, MiddlewareSpec, Tina4Request, Tina4Response, WebSocketRouteHandler, WebSocketRouteDefinition } from "./types.js";
2
2
  import { isTruthy } from "./dotenv.js";
3
+ import { MiddlewareRunner, isMiddlewareClass } from "./middleware.js";
3
4
 
4
5
  /**
5
6
  * Whether `TINA4_TRAILING_SLASH_REDIRECT` is enabled.
@@ -821,16 +822,29 @@ export async function resolveStringMiddleware(spec: string): Promise<Middleware>
821
822
  /**
822
823
  * Resolve a single route-middleware spec to a middleware function. Functions
823
824
  * pass through unchanged; strings are resolved via resolveStringMiddleware.
825
+ * A middleware CLASS never reaches here — runRouteMiddlewares runs it through
826
+ * the MiddlewareRunner instead.
824
827
  */
825
828
  async function resolveMiddlewareSpec(spec: MiddlewareSpec): Promise<Middleware> {
826
- return typeof spec === "string" ? resolveStringMiddleware(spec) : spec;
829
+ return typeof spec === "string" ? resolveStringMiddleware(spec) : spec as Middleware;
827
830
  }
828
831
 
829
832
  /**
830
- * Run per-route middleware chain, then call the handler.
833
+ * Run the per-route middleware chain. Returns false when it short-circuited
834
+ * and the handler must be skipped.
831
835
  *
832
- * Accepts middleware functions and/or string specs (e.g. "ResponseCache:300").
833
- * Each spec is resolved to a middleware function just before it runs.
836
+ * Accepts middleware functions, middleware CLASSES, and string specs
837
+ * (e.g. "ResponseCache:300"). A function or string spec is resolved and
838
+ * invoked as `mw(req, res, next)` exactly as before.
839
+ *
840
+ * A CLASS runs its beforeX hooks through the SAME `MiddlewareRunner.runBefore`
841
+ * and the SAME return-value table as global middleware — no parallel runner.
842
+ * Its afterX hooks run with the global after pass once the handler is done
843
+ * (server.ts / testClient.ts append the route's classes to that list), because
844
+ * "after" means after the handler, not after this function. Every spec used to
845
+ * be invoked as `mw(req, res, next)`, which for a class throws "Class
846
+ * constructor cannot be invoked without 'new'", so a class attached per-route
847
+ * was inert. Python and PHP both ran per-route class hooks already.
834
848
  */
835
849
  export async function runRouteMiddlewares(
836
850
  middlewares: MiddlewareSpec[],
@@ -838,6 +852,17 @@ export async function runRouteMiddlewares(
838
852
  res: Tina4Response,
839
853
  ): Promise<boolean> {
840
854
  for (const spec of middlewares) {
855
+ if (isMiddlewareClass(spec)) {
856
+ // tina4: a class hook that returns a DIFFERENT [req, res] pair cannot be
857
+ // rebound here — this returns a bool and is public API (four of this
858
+ // repo's own test files import it). Node's req/res are per-request
859
+ // singletons, so every built-in returns the same pair back; widen the
860
+ // return type if that ever stops being true.
861
+ const [, , proceed] = await MiddlewareRunner.runBefore([spec], req, res);
862
+ if (!proceed || res.raw.writableEnded) return false;
863
+ continue;
864
+ }
865
+
841
866
  const mw = await resolveMiddlewareSpec(spec);
842
867
  let nextCalled = false;
843
868
  await mw(req, res, () => {