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.
- package/CLAUDE.md +170 -28
- package/README.md +2 -2
- package/package.json +13 -9
- package/packages/cli/dist/bin.js +33126 -30055
- package/packages/cli/src/commands/metrics.ts +17 -11
- package/packages/cli/src/commands/serve.ts +10 -9
- package/packages/core/dist/index.js +33062 -29908
- package/packages/core/src/ai.ts +7 -1
- package/packages/core/src/auth.ts +191 -39
- package/packages/core/src/background.ts +19 -19
- package/packages/core/src/cache.ts +492 -49
- package/packages/core/src/devAdmin.ts +79 -32
- package/packages/core/src/devMailbox.ts +20 -44
- package/packages/core/src/dispatchPipeline.ts +285 -0
- package/packages/core/src/dotenv.ts +185 -40
- package/packages/core/src/index.ts +7 -6
- package/packages/core/src/logger.ts +257 -36
- package/packages/core/src/mcp.ts +1 -1
- package/packages/core/src/messenger.ts +81 -13
- package/packages/core/src/metrics.ts +199 -961
- package/packages/core/src/middleware.ts +390 -123
- package/packages/core/src/queue.ts +188 -32
- package/packages/core/src/queueBackends/kafkaBackend.ts +109 -13
- package/packages/core/src/queueBackends/liteBackend.ts +13 -0
- package/packages/core/src/queueBackends/mongoBackend.ts +101 -9
- package/packages/core/src/queueBackends/rabbitmqBackend.ts +22 -4
- package/packages/core/src/rateLimiter.ts +10 -5
- package/packages/core/src/request.ts +6 -9
- package/packages/core/src/response.ts +46 -1
- package/packages/core/src/router.ts +29 -4
- package/packages/core/src/server.ts +751 -414
- package/packages/core/src/session.ts +244 -27
- package/packages/core/src/sessionHandlers/childError.ts +72 -0
- package/packages/core/src/sessionHandlers/databaseHandler.ts +338 -48
- package/packages/core/src/sessionHandlers/memcachedHandler.ts +181 -0
- package/packages/core/src/sessionHandlers/mongoClient.ts +293 -202
- package/packages/core/src/sessionHandlers/mongoHandler.ts +88 -8
- package/packages/core/src/sessionHandlers/respClient.ts +16 -143
- package/packages/core/src/sessionHandlers/sqlClient.ts +290 -0
- package/packages/core/src/sessionHandlers/syncBridge.ts +190 -0
- package/packages/core/src/sessionHandlers/syncSocket.ts +236 -0
- package/packages/core/src/testClient.ts +18 -5
- package/packages/core/src/trustedProxy.ts +249 -0
- package/packages/core/src/types.ts +29 -5
- package/packages/core/src/websocket.ts +66 -0
- package/packages/frond/dist/index.js +74 -31
- package/packages/frond/src/engine.ts +99 -33
- package/packages/orm/dist/index.js +26554 -23400
- package/packages/orm/src/adapters/firebird.ts +183 -56
- package/packages/orm/src/adapters/mongodb.ts +25 -4
- package/packages/orm/src/adapters/mssql.ts +114 -29
- package/packages/orm/src/adapters/mysql.ts +103 -40
- package/packages/orm/src/adapters/odbc.ts +44 -21
- package/packages/orm/src/adapters/postgres.ts +118 -26
- package/packages/orm/src/adapters/sqlDialect.ts +120 -0
- package/packages/orm/src/adapters/sqlite.ts +64 -25
- package/packages/orm/src/baseModel.ts +135 -40
- package/packages/orm/src/cachedDatabase.ts +43 -19
- package/packages/orm/src/connectTimeout.ts +265 -0
- package/packages/orm/src/database.ts +338 -198
- package/packages/orm/src/databaseResult.ts +65 -13
- package/packages/orm/src/databaseUrl.ts +484 -0
- package/packages/orm/src/docstore.ts +386 -145
- package/packages/orm/src/index.ts +13 -3
- package/packages/orm/src/migration.ts +18 -3
- package/packages/orm/src/queryBuilder.ts +38 -4
- package/packages/orm/src/sqlTranslator.ts +310 -4
- package/packages/orm/src/types.ts +15 -4
- package/types/cli/src/bin.d.ts +92 -0
- package/types/cli/src/commands/build.d.ts +2 -0
- package/types/cli/src/commands/generate.d.ts +47 -0
- package/types/cli/src/commands/init.d.ts +1 -0
- package/types/cli/src/commands/metrics.d.ts +6 -0
- package/types/cli/src/commands/migrate.d.ts +1 -0
- package/types/cli/src/commands/migrateCreate.d.ts +1 -0
- package/types/cli/src/commands/migrateRollback.d.ts +1 -0
- package/types/cli/src/commands/migrateStatus.d.ts +1 -0
- package/types/cli/src/commands/queue.d.ts +20 -0
- package/types/cli/src/commands/routes.d.ts +1 -0
- package/types/cli/src/commands/seed.d.ts +1 -0
- package/types/cli/src/commands/serve.d.ts +6 -0
- package/types/cli/src/commands/test.d.ts +1 -0
- package/types/core/src/ai.d.ts +64 -0
- package/types/core/src/api.d.ts +262 -0
- package/types/core/src/auth.d.ts +177 -0
- package/types/core/src/authGate.d.ts +20 -0
- package/types/core/src/background.d.ts +34 -0
- package/types/core/src/cache.d.ts +163 -0
- package/types/core/src/constants.d.ts +38 -0
- package/types/core/src/container.d.ts +44 -0
- package/types/core/src/context/chunker.d.ts +31 -0
- package/types/core/src/context/index.d.ts +93 -0
- package/types/core/src/devAdmin.d.ts +179 -0
- package/types/core/src/devMailbox.d.ts +54 -0
- package/types/core/src/dispatchPipeline.d.ts +117 -0
- package/types/core/src/docs.d.ts +141 -0
- package/types/core/src/docsAutoDiscovery.d.ts +6 -0
- package/types/core/src/dotenv.d.ts +87 -0
- package/types/core/src/env.d.ts +28 -0
- package/types/core/src/errorOverlay.d.ts +36 -0
- package/types/core/src/events.d.ts +75 -0
- package/types/core/src/fakeData.d.ts +55 -0
- package/types/core/src/feedback.d.ts +90 -0
- package/types/core/src/graphql.d.ts +207 -0
- package/types/core/src/health.d.ts +22 -0
- package/types/core/src/htmlElement.d.ts +75 -0
- package/types/core/src/i18n.d.ts +37 -0
- package/types/core/src/index.d.ts +92 -0
- package/types/core/src/job.d.ts +39 -0
- package/types/core/src/logger.d.ts +200 -0
- package/types/core/src/mcp.d.ts +248 -0
- package/types/core/src/messenger.d.ts +191 -0
- package/types/core/src/metrics.d.ts +41 -0
- package/types/core/src/middleware.d.ts +330 -0
- package/types/core/src/mqtt.d.ts +257 -0
- package/types/core/src/mqttMessage.d.ts +67 -0
- package/types/core/src/plan.d.ts +96 -0
- package/types/core/src/projectIndex.d.ts +56 -0
- package/types/core/src/queue.d.ts +268 -0
- package/types/core/src/queueBackends/kafkaBackend.d.ts +117 -0
- package/types/core/src/queueBackends/liteBackend.d.ts +128 -0
- package/types/core/src/queueBackends/mongoBackend.d.ts +119 -0
- package/types/core/src/queueBackends/rabbitmqBackend.d.ts +55 -0
- package/types/core/src/rateLimiter.d.ts +49 -0
- package/types/core/src/request.d.ts +25 -0
- package/types/core/src/response.d.ts +28 -0
- package/types/core/src/routeDiscovery.d.ts +12 -0
- package/types/core/src/router.d.ts +366 -0
- package/types/core/src/scss.d.ts +19 -0
- package/types/core/src/server.d.ts +146 -0
- package/types/core/src/service.d.ts +115 -0
- package/types/core/src/session.d.ts +341 -0
- package/types/core/src/sessionHandlers/childError.d.ts +34 -0
- package/types/core/src/sessionHandlers/databaseHandler.d.ts +97 -0
- package/types/core/src/sessionHandlers/memcachedHandler.d.ts +60 -0
- package/types/core/src/sessionHandlers/mongoClient.d.ts +35 -0
- package/types/core/src/sessionHandlers/mongoHandler.d.ts +109 -0
- package/types/core/src/sessionHandlers/respClient.d.ts +22 -0
- package/types/core/src/sessionHandlers/sqlClient.d.ts +39 -0
- package/types/core/src/sessionHandlers/syncBridge.d.ts +91 -0
- package/types/core/src/sessionHandlers/syncSocket.d.ts +49 -0
- package/types/core/src/sessionHandlers/valkeyHandler.d.ts +65 -0
- package/types/core/src/static.d.ts +2 -0
- package/types/core/src/test.d.ts +94 -0
- package/types/core/src/testClient.d.ts +36 -0
- package/types/core/src/testing.d.ts +58 -0
- package/types/core/src/trustedProxy.d.ts +44 -0
- package/types/core/src/types.d.ts +242 -0
- package/types/core/src/validator.d.ts +52 -0
- package/types/core/src/websocket.d.ts +402 -0
- package/types/core/src/websocketBackplane.d.ts +166 -0
- package/types/core/src/websocketConnection.d.ts +54 -0
- package/types/core/src/wsdl.d.ts +101 -0
- package/types/frond/src/engine.d.ts +263 -0
- package/types/frond/src/index.d.ts +2 -0
- package/types/orm/src/adapters/firebird.d.ts +183 -0
- package/types/orm/src/adapters/mongodb.d.ts +81 -0
- package/types/orm/src/adapters/mssql.d.ts +77 -0
- package/types/orm/src/adapters/mysql.d.ts +67 -0
- package/types/orm/src/adapters/odbc.d.ts +94 -0
- package/types/orm/src/adapters/postgres.d.ts +86 -0
- package/types/orm/src/adapters/sqlDialect.d.ts +71 -0
- package/types/orm/src/adapters/sqlite.d.ts +68 -0
- package/types/orm/src/autoCrud.d.ts +73 -0
- package/types/orm/src/baseModel.d.ts +427 -0
- package/types/orm/src/cachedDatabase.d.ts +190 -0
- package/types/orm/src/connectTimeout.d.ts +100 -0
- package/types/orm/src/database.d.ts +655 -0
- package/types/orm/src/databaseResult.d.ts +109 -0
- package/types/orm/src/databaseUrl.d.ts +125 -0
- package/types/orm/src/docstore.d.ts +241 -0
- package/types/orm/src/fakeData.d.ts +22 -0
- package/types/orm/src/index.d.ts +43 -0
- package/types/orm/src/migration.d.ts +275 -0
- package/types/orm/src/model.d.ts +7 -0
- package/types/orm/src/query.d.ts +14 -0
- package/types/orm/src/queryBuilder.d.ts +193 -0
- package/types/orm/src/realtime/index.d.ts +7 -0
- package/types/orm/src/realtime/models/attachment.d.ts +43 -0
- package/types/orm/src/realtime/models/channel.d.ts +32 -0
- package/types/orm/src/realtime/models/channelMember.d.ts +32 -0
- package/types/orm/src/realtime/models/message.d.ts +36 -0
- package/types/orm/src/realtime/models/workspace.d.ts +26 -0
- package/types/orm/src/realtime/realtime.d.ts +24 -0
- package/types/orm/src/realtime/storage.d.ts +61 -0
- package/types/orm/src/seeder.d.ts +118 -0
- package/types/orm/src/sqlTranslator.d.ts +258 -0
- package/types/orm/src/types.d.ts +148 -0
- package/types/orm/src/validation.d.ts +6 -0
- package/types/swagger/src/generator.d.ts +46 -0
- package/types/swagger/src/index.d.ts +2 -0
- package/types/swagger/src/ui.d.ts +11 -0
- 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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
//
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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 (
|
|
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
|
|
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
|
|
833
|
-
*
|
|
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, () => {
|