tina4-nodejs 3.13.94 → 3.13.96
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 +158 -30
- package/README.md +1 -1
- package/package.json +3 -1
- package/packages/cli/dist/bin.js +30911 -28444
- package/packages/cli/src/commands/metrics.ts +17 -11
- package/packages/cli/src/commands/serve.ts +10 -9
- package/packages/core/dist/index.js +30810 -28261
- package/packages/core/public/css/tina4.min.css +1 -1
- 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/dispatchPipeline.ts +285 -0
- package/packages/core/src/dotenv.ts +185 -40
- package/packages/core/src/index.ts +6 -7
- package/packages/core/src/logger.ts +257 -36
- package/packages/core/src/mcp.ts +1 -1
- package/packages/core/src/messenger.ts +294 -106
- 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 +1 -1
- 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 +34 -16
- package/packages/core/src/response.ts +46 -1
- package/packages/core/src/router.ts +29 -4
- package/packages/core/src/server.ts +886 -421
- package/packages/core/src/session.ts +244 -27
- 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 -208
- package/packages/core/src/sessionHandlers/mongoHandler.ts +88 -8
- package/packages/core/src/sessionHandlers/respClient.ts +16 -147
- 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/orm/dist/index.js +22717 -20168
- 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 +60 -24
- package/packages/orm/src/autoCrud.ts +12 -10
- 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 +241 -197
- package/packages/orm/src/databaseResult.ts +51 -28
- package/packages/orm/src/databaseUrl.ts +484 -0
- package/packages/orm/src/docstore.ts +386 -145
- package/packages/orm/src/index.ts +13 -6
- package/packages/orm/src/migration.ts +44 -11
- package/packages/orm/src/model.ts +4 -0
- package/packages/orm/src/queryBuilder.ts +47 -6
- package/packages/orm/src/sqlTranslator.ts +310 -4
- package/packages/orm/src/types.ts +21 -77
- package/packages/swagger/dist/index.js +78 -20
- package/packages/swagger/src/generator.ts +172 -29
- package/types/core/src/ai.d.ts +1 -1
- package/types/core/src/auth.d.ts +28 -5
- package/types/core/src/background.d.ts +3 -3
- package/types/core/src/cache.d.ts +15 -12
- package/types/core/src/dispatchPipeline.d.ts +117 -0
- package/types/core/src/dotenv.d.ts +38 -16
- package/types/core/src/index.d.ts +6 -9
- package/types/core/src/logger.d.ts +93 -16
- package/types/core/src/messenger.d.ts +47 -6
- package/types/core/src/metrics.d.ts +25 -61
- package/types/core/src/middleware.d.ts +134 -11
- package/types/core/src/queue.d.ts +54 -5
- package/types/core/src/queueBackends/kafkaBackend.d.ts +1 -1
- package/types/core/src/queueBackends/liteBackend.d.ts +9 -0
- package/types/core/src/queueBackends/mongoBackend.d.ts +24 -2
- package/types/core/src/queueBackends/rabbitmqBackend.d.ts +3 -3
- package/types/core/src/router.d.ts +14 -3
- package/types/core/src/server.d.ts +15 -4
- package/types/core/src/session.d.ts +87 -2
- package/types/core/src/sessionHandlers/databaseHandler.d.ts +60 -5
- package/types/core/src/sessionHandlers/memcachedHandler.d.ts +60 -0
- package/types/core/src/sessionHandlers/mongoClient.d.ts +16 -5
- package/types/core/src/sessionHandlers/mongoHandler.d.ts +51 -3
- package/types/core/src/sessionHandlers/respClient.d.ts +2 -2
- 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/trustedProxy.d.ts +44 -0
- package/types/core/src/types.d.ts +28 -5
- package/types/core/src/websocket.d.ts +26 -0
- package/types/orm/src/adapters/firebird.d.ts +55 -10
- package/types/orm/src/adapters/mongodb.d.ts +2 -2
- package/types/orm/src/adapters/mssql.d.ts +18 -11
- package/types/orm/src/adapters/mysql.d.ts +11 -10
- package/types/orm/src/adapters/odbc.d.ts +9 -12
- package/types/orm/src/adapters/postgres.d.ts +11 -10
- package/types/orm/src/adapters/sqlDialect.d.ts +71 -0
- package/types/orm/src/adapters/sqlite.d.ts +15 -3
- package/types/orm/src/baseModel.d.ts +45 -9
- package/types/orm/src/cachedDatabase.d.ts +18 -5
- package/types/orm/src/connectTimeout.d.ts +100 -0
- package/types/orm/src/database.d.ts +78 -28
- package/types/orm/src/databaseResult.d.ts +29 -15
- package/types/orm/src/databaseUrl.d.ts +125 -0
- package/types/orm/src/docstore.d.ts +102 -43
- package/types/orm/src/index.d.ts +6 -4
- package/types/orm/src/migration.d.ts +4 -3
- package/types/orm/src/queryBuilder.d.ts +23 -3
- package/types/orm/src/sqlTranslator.d.ts +126 -2
- package/types/orm/src/types.d.ts +21 -38
- package/packages/core/src/scss.ts +0 -623
- package/packages/core/src/sessionHandlers/redisHandler.ts +0 -219
- package/types/core/src/scss.d.ts +0 -19
- package/types/core/src/sessionHandlers/redisHandler.d.ts +0 -60
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import type { Tina4Request, Tina4Response, Middleware } from "./types.js";
|
|
2
|
+
import { HTTP_OK, HTTP_FORBIDDEN } from "./constants.js";
|
|
2
3
|
import { validToken, getPayload } from "./auth.js";
|
|
3
4
|
import { Log } from "./logger.js";
|
|
4
5
|
import { isTruthy } from "./dotenv.js";
|
|
6
|
+
import { defaultRouter, type Router } from "./router.js";
|
|
7
|
+
import { resolveClientIp } from "./trustedProxy.js";
|
|
5
8
|
|
|
6
9
|
/**
|
|
7
10
|
* Whether to emit a per-request log line (v3.13.14). TINA4_LOG_REQUESTS is
|
|
@@ -65,24 +68,86 @@ export class MiddlewareChain {
|
|
|
65
68
|
}
|
|
66
69
|
|
|
67
70
|
// ── Class-based middleware runner ────────────────────────────────
|
|
71
|
+
//
|
|
72
|
+
// Class-based middleware follows the beforeX / afterX naming convention:
|
|
73
|
+
// statics named before* run before the route handler (MiddlewareRunner.runBefore),
|
|
74
|
+
// statics named after* run once it is done (runAfter). Each hook receives
|
|
75
|
+
// (req, res); what it RETURNS is interpreted by the one table in
|
|
76
|
+
// interpretHookResult below.
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* True when a middleware spec is a CLASS (the beforeX/afterX convention)
|
|
80
|
+
* rather than a plain `(req, res, next)` middleware function.
|
|
81
|
+
*
|
|
82
|
+
* A class's `prototype` property is non-writable by the language spec
|
|
83
|
+
* (ClassDefinitionEvaluation); an ordinary function's is writable, and an
|
|
84
|
+
* arrow function, async function or bound function has no `prototype` at all.
|
|
85
|
+
* That is a language-level distinction rather than a name or source-string
|
|
86
|
+
* sniff, so a class named `cors` and a function named `Cors` both classify
|
|
87
|
+
* correctly.
|
|
88
|
+
*/
|
|
89
|
+
export function isMiddlewareClass(spec: unknown): boolean {
|
|
90
|
+
if (typeof spec !== "function") return false;
|
|
91
|
+
const proto = Object.getOwnPropertyDescriptor(spec, "prototype");
|
|
92
|
+
return proto !== undefined && proto.writable === false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The Tina4 response object — callable, and carrying the raw ServerResponse.
|
|
97
|
+
* Structural, so a rebound response is recognised too.
|
|
98
|
+
*/
|
|
99
|
+
function isResponse(value: unknown): value is Tina4Response {
|
|
100
|
+
return typeof value === "function"
|
|
101
|
+
&& typeof (value as Tina4Response).raw?.end === "function";
|
|
102
|
+
}
|
|
68
103
|
|
|
69
104
|
/**
|
|
70
|
-
*
|
|
105
|
+
* ONE return-value table, for EVERY beforeX/afterX hook, at EVERY scope
|
|
106
|
+
* (global and per-route):
|
|
71
107
|
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
108
|
+
* a Response object SHORT-CIRCUIT. That object IS the response, at ANY
|
|
109
|
+
* status. This is the PRIMARY rule and the only return
|
|
110
|
+
* that can express a 302 redirect.
|
|
111
|
+
* the [req, res] pair rebind both, continue (length >= 2, mirroring Python's
|
|
112
|
+
* `isinstance(result, tuple) and len(result) >= 2`)
|
|
113
|
+
* false SHORT-CIRCUIT. Send the response AS SET; a still
|
|
114
|
+
* default and still unwritten response becomes a 403,
|
|
115
|
+
* because a bare `return false` is a deny.
|
|
116
|
+
* undefined / null continue
|
|
75
117
|
*
|
|
76
|
-
*
|
|
77
|
-
* If a "before" method returns a response whose status code is >= 400
|
|
78
|
-
* the chain short-circuits and runBefore returns shouldContinue = false.
|
|
118
|
+
* Returns [req, res, stop].
|
|
79
119
|
*/
|
|
120
|
+
function interpretHookResult(
|
|
121
|
+
result: unknown,
|
|
122
|
+
req: Tina4Request,
|
|
123
|
+
res: Tina4Response,
|
|
124
|
+
): [Tina4Request, Tina4Response, boolean] {
|
|
125
|
+
if (Array.isArray(result)) {
|
|
126
|
+
return result.length >= 2
|
|
127
|
+
? [result[0] as Tina4Request, result[1] as Tina4Response, false]
|
|
128
|
+
: [req, res, false];
|
|
129
|
+
}
|
|
130
|
+
if (isResponse(result)) return [req, result, true];
|
|
131
|
+
if (result === false) {
|
|
132
|
+
if (!res.raw.writableEnded && res.raw.statusCode === HTTP_OK) {
|
|
133
|
+
res.raw.statusCode = HTTP_FORBIDDEN;
|
|
134
|
+
}
|
|
135
|
+
return [req, res, true];
|
|
136
|
+
}
|
|
137
|
+
return [req, res, false];
|
|
138
|
+
}
|
|
139
|
+
|
|
80
140
|
/**
|
|
81
141
|
* Produce the deterministic clean 500 for a throwing class-based middleware
|
|
82
|
-
* (M2)
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
142
|
+
* (M2): LOG via Log.error (class + method + error type + message — never
|
|
143
|
+
* silent) then return a 500 with the exact JSON body shape shared across all
|
|
144
|
+
* four frameworks. The worker never crashes and no unhandled exception leaks.
|
|
145
|
+
*
|
|
146
|
+
* The counterpart is Python's `Middleware.middleware_500`
|
|
147
|
+
* (tina4_python/core/middleware.py), called from its own run_before/run_after.
|
|
148
|
+
* This used to cite `_middleware_500`, which is not a symbol in tina4-python at
|
|
149
|
+
* all — that name belonged to its dispatcher, back when its orchestrator had no
|
|
150
|
+
* exception handling to mirror.
|
|
86
151
|
*/
|
|
87
152
|
function middleware500(
|
|
88
153
|
res: Tina4Response,
|
|
@@ -126,6 +191,33 @@ export class MiddlewareRunner {
|
|
|
126
191
|
}
|
|
127
192
|
|
|
128
193
|
/** Return the list of globally registered middleware classes. */
|
|
194
|
+
/**
|
|
195
|
+
* Global middleware that runs BEFORE route matching.
|
|
196
|
+
*
|
|
197
|
+
* A middleware opts in with `static preMatch = true`. Everything else stays
|
|
198
|
+
* where it has always run - after matching - so this is additive and no
|
|
199
|
+
* existing middleware changes behaviour.
|
|
200
|
+
*
|
|
201
|
+
* The two groups need opposite things. CORS must run before matching so its
|
|
202
|
+
* headers survive a short-circuited 401/403; a browser shown a 401 without
|
|
203
|
+
* them reports a CORS error and the real status never reaches the developer.
|
|
204
|
+
* CSRF must run AFTER, because it reads the matched route's metadata to
|
|
205
|
+
* honour a route marked noAuth - PHP shipped exactly that bypass as dead
|
|
206
|
+
* code once, because the metadata was not assigned yet.
|
|
207
|
+
*
|
|
208
|
+
* NOT named `beforeMatch` - hook discovery treats every `before*` static as
|
|
209
|
+
* a middleware hook and would call the flag itself with (req, res).
|
|
210
|
+
*/
|
|
211
|
+
static partitionByMatchPhase(all: any[]): { pre: any[]; post: any[] } {
|
|
212
|
+
const pre: any[] = [];
|
|
213
|
+
const post: any[] = [];
|
|
214
|
+
for (const m of all) {
|
|
215
|
+
if (m && m.preMatch === true) pre.push(m);
|
|
216
|
+
else post.push(m);
|
|
217
|
+
}
|
|
218
|
+
return { pre, post };
|
|
219
|
+
}
|
|
220
|
+
|
|
129
221
|
static getGlobal(): any[] {
|
|
130
222
|
return [...MiddlewareRunner.globalMiddleware];
|
|
131
223
|
}
|
|
@@ -136,19 +228,59 @@ export class MiddlewareRunner {
|
|
|
136
228
|
}
|
|
137
229
|
|
|
138
230
|
/**
|
|
139
|
-
* Discover the before-prefixed / after-prefixed
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
231
|
+
* Discover the before-prefixed / after-prefixed hook names on a middleware
|
|
232
|
+
* class, INHERITED HOOKS INCLUDED, base class first (M1).
|
|
233
|
+
*
|
|
234
|
+
* `Object.getOwnPropertyNames` returns a class's OWN statics only, in
|
|
235
|
+
* source-declaration order. On its own that silently DROPPED every hook a
|
|
236
|
+
* subclass inherited: for `class Sub extends Base` with `static beforeBase`
|
|
237
|
+
* on the base, discovery returned only ["beforeSub"] even though
|
|
238
|
+
* `Sub.beforeBase` is a live function — so a shared base middleware simply
|
|
239
|
+
* never ran, with no error. Python returns ['before_base','before_sub'] and
|
|
240
|
+
* Ruby [:before_base,:before_sub]; Node was the only one of the four that
|
|
241
|
+
* lost hooks.
|
|
242
|
+
*
|
|
243
|
+
* So walk the prototype chain (the STATIC side: Sub -> Base -> ...) and emit
|
|
244
|
+
* base-class hooks BEFORE the subclass's own, de-duping an override to its
|
|
245
|
+
* first (base) position. That is exactly Python's `_discover_methods`
|
|
246
|
+
* walking `reversed(__mro__)` over each `__dict__`, and Ruby's
|
|
247
|
+
* `discover_methods` walking `ancestors.reverse_each`.
|
|
248
|
+
*
|
|
249
|
+
* Within one class the order is still source-declaration order — we
|
|
250
|
+
* deliberately do NOT sort(), so hooks run in the order they were written
|
|
251
|
+
* (parity with Python walking __dict__, PHP get_class_methods, Ruby
|
|
145
252
|
* instance_methods(false)). Cross-class order is the natural iteration of
|
|
146
253
|
* the registered classes = REGISTRATION order.
|
|
254
|
+
*
|
|
255
|
+
* The chain walk stops at Function.prototype / Object.prototype, so the
|
|
256
|
+
* built-in members are never scanned. A plain object registered as
|
|
257
|
+
* middleware still works: its own keys are level 0.
|
|
147
258
|
*/
|
|
148
259
|
private static methodNames(cls: any, prefix: string): string[] {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
260
|
+
const levels: string[][] = [];
|
|
261
|
+
for (
|
|
262
|
+
let level: any = cls;
|
|
263
|
+
level && level !== Function.prototype && level !== Object.prototype;
|
|
264
|
+
level = Object.getPrototypeOf(level)
|
|
265
|
+
) {
|
|
266
|
+
levels.push(
|
|
267
|
+
Object.getOwnPropertyNames(level).filter(
|
|
268
|
+
(name) => name.startsWith(prefix) && typeof cls[name] === "function",
|
|
269
|
+
),
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const seen = new Set<string>();
|
|
274
|
+
const names: string[] = [];
|
|
275
|
+
// Reverse the levels: base class first, then each derived class.
|
|
276
|
+
for (let i = levels.length - 1; i >= 0; i--) {
|
|
277
|
+
for (const name of levels[i]) {
|
|
278
|
+
if (seen.has(name)) continue;
|
|
279
|
+
seen.add(name);
|
|
280
|
+
names.push(name);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return names;
|
|
152
284
|
}
|
|
153
285
|
|
|
154
286
|
/**
|
|
@@ -172,6 +304,10 @@ export class MiddlewareRunner {
|
|
|
172
304
|
* a synchronous hook that returns an array is harmless (the array resolves
|
|
173
305
|
* immediately), so existing sync hooks keep working unchanged.
|
|
174
306
|
*
|
|
307
|
+
* RETURN VALUE — see `interpretHookResult` for the one table every hook at
|
|
308
|
+
* every scope obeys. A returned Response object is the PRIMARY
|
|
309
|
+
* short-circuit; `false` is a deny.
|
|
310
|
+
*
|
|
175
311
|
* Returns [req, res, shouldContinue].
|
|
176
312
|
*/
|
|
177
313
|
static async runBefore(
|
|
@@ -182,16 +318,22 @@ export class MiddlewareRunner {
|
|
|
182
318
|
for (const cls of classes) {
|
|
183
319
|
for (const method of MiddlewareRunner.methodNames(cls, "before")) {
|
|
184
320
|
try {
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
321
|
+
const [nextReq, nextRes, stop] =
|
|
322
|
+
interpretHookResult(await cls[method](req, res), req, res);
|
|
323
|
+
req = nextReq;
|
|
324
|
+
res = nextRes;
|
|
325
|
+
if (stop) return [req, res, false];
|
|
189
326
|
} catch (error) {
|
|
190
327
|
// Throw → logged clean 500, skip the handler (deterministic).
|
|
191
328
|
res = middleware500(res, cls, method, error);
|
|
192
329
|
return [req, res, false];
|
|
193
330
|
}
|
|
194
|
-
//
|
|
331
|
+
// LEGACY COMPAT PATH — retained, but NOT the main mechanism. A hook
|
|
332
|
+
// that returns nothing and merely leaves an error status (or an ended
|
|
333
|
+
// response) still short-circuits, so middleware written before the
|
|
334
|
+
// return-value contract keeps working. It cannot express a 3xx
|
|
335
|
+
// redirect, which is exactly why a returned Response is the primary
|
|
336
|
+
// rule above.
|
|
195
337
|
if (res.raw.statusCode >= 400 || res.raw.writableEnded) {
|
|
196
338
|
return [req, res, false];
|
|
197
339
|
}
|
|
@@ -218,6 +360,12 @@ export class MiddlewareRunner {
|
|
|
218
360
|
* ASYNC — each hook is awaited (e.g. the responseCache after-hook awaiting
|
|
219
361
|
* `backend.set`). Awaiting a synchronous hook is harmless, so existing sync
|
|
220
362
|
* after-hooks keep working unchanged.
|
|
363
|
+
*
|
|
364
|
+
* RETURN VALUE — the SAME table as runBefore (`interpretHookResult`): the
|
|
365
|
+
* contract is one table for every hook at every scope. There is nothing left
|
|
366
|
+
* to skip after the handler, so a short-circuit here ends the after chain.
|
|
367
|
+
* A THROW is different and unchanged: it is logged, becomes a clean 500, and
|
|
368
|
+
* the remaining after hooks still run.
|
|
221
369
|
*/
|
|
222
370
|
static async runAfter(
|
|
223
371
|
classes: any[],
|
|
@@ -227,10 +375,11 @@ export class MiddlewareRunner {
|
|
|
227
375
|
for (const cls of classes) {
|
|
228
376
|
for (const method of MiddlewareRunner.methodNames(cls, "after")) {
|
|
229
377
|
try {
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
378
|
+
const [nextReq, nextRes, stop] =
|
|
379
|
+
interpretHookResult(await cls[method](req, res), req, res);
|
|
380
|
+
req = nextReq;
|
|
381
|
+
res = nextRes;
|
|
382
|
+
if (stop) return [req, res];
|
|
234
383
|
} catch (error) {
|
|
235
384
|
// Throw → logged clean 500, but remaining after* STILL run.
|
|
236
385
|
res = middleware500(res, cls, method, error);
|
|
@@ -246,7 +395,7 @@ export class MiddlewareRunner {
|
|
|
246
395
|
|
|
247
396
|
/** Configuration for the CORS middleware */
|
|
248
397
|
export interface CorsConfig {
|
|
249
|
-
/** Allowed origins. Default:
|
|
398
|
+
/** Allowed origins. Default: NONE (deny) — or TINA4_CORS_ORIGINS env, comma-separated. "*" allows any. */
|
|
250
399
|
origins?: string | string[];
|
|
251
400
|
/** Allowed methods. Default: standard REST methods (or TINA4_CORS_METHODS env) */
|
|
252
401
|
methods?: string | string[];
|
|
@@ -254,71 +403,218 @@ export interface CorsConfig {
|
|
|
254
403
|
headers?: string | string[];
|
|
255
404
|
/** Access-Control-Max-Age in seconds. Default: 86400 (or TINA4_CORS_MAX_AGE env) */
|
|
256
405
|
maxAge?: number;
|
|
406
|
+
/** Send Access-Control-Allow-Credentials. Default: false (or TINA4_CORS_CREDENTIALS env). Never sent with a wildcard origin. */
|
|
407
|
+
credentials?: boolean;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Warn-once ledger so a scripted probe cannot flood the log. */
|
|
411
|
+
const corsWarned = new Set<string>();
|
|
412
|
+
|
|
413
|
+
/** Reset the CORS warn-once ledger. Test seam. */
|
|
414
|
+
export function resetCorsWarnings(): void {
|
|
415
|
+
corsWarned.clear();
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function corsWarnOnce(key: string, message: string): void {
|
|
419
|
+
if (corsWarned.has(key)) return;
|
|
420
|
+
corsWarned.add(key);
|
|
421
|
+
Log.warning(message);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* The resolved CORS policy — ONE implementation of the rules.
|
|
426
|
+
*
|
|
427
|
+
* Both the function middleware `cors()` and the class middleware
|
|
428
|
+
* `CorsMiddleware` build one of these and apply what it returns. They used to
|
|
429
|
+
* be two independent implementations that had already drifted: `cors()` never
|
|
430
|
+
* read TINA4_CORS_CREDENTIALS at all, so the DEFAULT always-on pipeline
|
|
431
|
+
* silently ignored a documented env var (measured 2026-07-31). One feature,
|
|
432
|
+
* one code path.
|
|
433
|
+
*
|
|
434
|
+
* DENY BY DEFAULT (ADR-0018). With no origins configured, NO
|
|
435
|
+
* Access-Control-Allow-Origin is emitted and the browser's own CORS check
|
|
436
|
+
* blocks the cross-origin request. "*" still works, it just has to be asked for.
|
|
437
|
+
*
|
|
438
|
+
* CREDENTIALS AND THE WILDCARD ARE MUTUALLY EXCLUSIVE. The Fetch Standard's
|
|
439
|
+
* CORS check treats "*" as a literal (not a wildcard) once the request's
|
|
440
|
+
* credentials mode is "include", so ACAO: * with
|
|
441
|
+
* Access-Control-Allow-Credentials: true is rejected by every browser.
|
|
442
|
+
*
|
|
443
|
+
* VARY: ORIGIN whenever the ACAO value is COMPUTED from the request's Origin,
|
|
444
|
+
* i.e. whenever an allow-list is configured — on a MISS as well as a match.
|
|
445
|
+
* RFC 9110 s12.5.5: a Vary field name list tells cache recipients they "MUST
|
|
446
|
+
* NOT use this response to satisfy a later request unless the later request
|
|
447
|
+
* has the same values for the listed header fields as the original request".
|
|
448
|
+
* The miss case matters most: without it a shared cache can store the no-ACAO
|
|
449
|
+
* response for origin B and serve it to origin A. A constant "*" genuinely
|
|
450
|
+
* does not vary and gets no Vary, which would only fragment a CDN's cache.
|
|
451
|
+
*
|
|
452
|
+
* Access-Control-Allow-Methods / -Allow-Headers are static configured lists
|
|
453
|
+
* here, never derived from the request's Access-Control-Request-* headers, so
|
|
454
|
+
* those field names do NOT belong in Vary.
|
|
455
|
+
*/
|
|
456
|
+
export class CorsPolicy {
|
|
457
|
+
readonly allowedOrigins: string[];
|
|
458
|
+
readonly allowedMethods: string;
|
|
459
|
+
readonly allowedHeaders: string;
|
|
460
|
+
readonly maxAge: number;
|
|
461
|
+
readonly credentials: boolean;
|
|
462
|
+
|
|
463
|
+
constructor(config?: CorsConfig) {
|
|
464
|
+
// Default is EMPTY, not "*" — deny by default (ADR-0018).
|
|
465
|
+
const originsRaw = config?.origins ?? process.env.TINA4_CORS_ORIGINS ?? "";
|
|
466
|
+
const list = Array.isArray(originsRaw) ? originsRaw : originsRaw.split(",");
|
|
467
|
+
this.allowedOrigins = list.map((o) => o.trim()).filter((o) => o !== "");
|
|
468
|
+
|
|
469
|
+
const methodsRaw = config?.methods
|
|
470
|
+
?? process.env.TINA4_CORS_METHODS
|
|
471
|
+
?? "GET, POST, PUT, DELETE, PATCH, OPTIONS";
|
|
472
|
+
this.allowedMethods = Array.isArray(methodsRaw) ? methodsRaw.join(", ") : methodsRaw;
|
|
473
|
+
|
|
474
|
+
const headersRaw = config?.headers
|
|
475
|
+
?? process.env.TINA4_CORS_HEADERS
|
|
476
|
+
?? "Content-Type,Authorization,X-Request-ID";
|
|
477
|
+
this.allowedHeaders = Array.isArray(headersRaw) ? headersRaw.join(", ") : headersRaw;
|
|
478
|
+
|
|
479
|
+
this.maxAge = config?.maxAge
|
|
480
|
+
?? (process.env.TINA4_CORS_MAX_AGE ? parseInt(process.env.TINA4_CORS_MAX_AGE, 10) : 86400);
|
|
481
|
+
|
|
482
|
+
this.credentials = config?.credentials
|
|
483
|
+
?? ["true", "1", "yes"].includes((process.env.TINA4_CORS_CREDENTIALS ?? "false").toLowerCase());
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** Whether an operator has actually declared a CORS policy. */
|
|
487
|
+
isConfigured(): boolean {
|
|
488
|
+
return this.allowedOrigins.length > 0;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** The origin to send in Access-Control-Allow-Origin, or undefined for none. */
|
|
492
|
+
resolveOrigin(requestOrigin: string): string | undefined {
|
|
493
|
+
if (this.allowedOrigins.length === 0) return undefined;
|
|
494
|
+
if (this.allowedOrigins.includes("*")) return "*";
|
|
495
|
+
if (requestOrigin && this.allowedOrigins.includes(requestOrigin)) return requestOrigin;
|
|
496
|
+
return undefined;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* The CORS headers for a request origin. `isPreflight` adds Max-Age, which
|
|
501
|
+
* the Fetch Standard only defines for a preflight response.
|
|
502
|
+
*/
|
|
503
|
+
headersFor(requestOrigin: string, isPreflight: boolean): Record<string, string> {
|
|
504
|
+
if (this.allowedOrigins.length === 0) {
|
|
505
|
+
if (requestOrigin) {
|
|
506
|
+
corsWarnOnce("unconfigured",
|
|
507
|
+
`CORS: refused cross-origin request from ${requestOrigin} — no policy is configured. `
|
|
508
|
+
+ "Set TINA4_CORS_ORIGINS to the origins you want to allow, e.g. "
|
|
509
|
+
+ "TINA4_CORS_ORIGINS=https://app.example.com (or '*' to allow any origin).");
|
|
510
|
+
}
|
|
511
|
+
return {};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const out: Record<string, string> = {};
|
|
515
|
+
if (!this.allowedOrigins.includes("*")) {
|
|
516
|
+
out["Vary"] = "Origin";
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const origin = this.resolveOrigin(requestOrigin);
|
|
520
|
+
if (origin === undefined) {
|
|
521
|
+
if (requestOrigin) {
|
|
522
|
+
corsWarnOnce(`denied:${requestOrigin}`,
|
|
523
|
+
`CORS: origin ${requestOrigin} is not in TINA4_CORS_ORIGINS `
|
|
524
|
+
+ `(${this.allowedOrigins.join(",")}) — the browser will block this response.`);
|
|
525
|
+
}
|
|
526
|
+
return out;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
out["Access-Control-Allow-Origin"] = origin;
|
|
530
|
+
out["Access-Control-Allow-Methods"] = this.allowedMethods;
|
|
531
|
+
out["Access-Control-Allow-Headers"] = this.allowedHeaders;
|
|
532
|
+
if (isPreflight) out["Access-Control-Max-Age"] = String(this.maxAge);
|
|
533
|
+
|
|
534
|
+
if (this.credentials) {
|
|
535
|
+
if (origin === "*") {
|
|
536
|
+
corsWarnOnce("wildcard-credentials",
|
|
537
|
+
"CORS: TINA4_CORS_CREDENTIALS is true but TINA4_CORS_ORIGINS is '*'. The Fetch Standard "
|
|
538
|
+
+ "forbids Access-Control-Allow-Origin: * with credentials, so credentials are NOT being "
|
|
539
|
+
+ "sent. Credentialed CORS requires an explicit origin list, e.g. "
|
|
540
|
+
+ "TINA4_CORS_ORIGINS=https://app.example.com.");
|
|
541
|
+
} else {
|
|
542
|
+
out["Access-Control-Allow-Credentials"] = "true";
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
return out;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** Fold a Vary field name into whatever Vary the response already carries. */
|
|
550
|
+
function applyCorsHeaders(res: Tina4Response, headers: Record<string, string>): void {
|
|
551
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
552
|
+
if (name === "Vary") {
|
|
553
|
+
const current = String((res as { raw?: { getHeader?(n: string): unknown } }).raw?.getHeader?.("Vary") ?? "");
|
|
554
|
+
const parts = current.split(",").map((p) => p.trim()).filter((p) => p !== "");
|
|
555
|
+
if (!parts.some((p) => p.toLowerCase() === value.toLowerCase())) parts.push(value);
|
|
556
|
+
res.header(name, parts.join(", "));
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
res.header(name, value);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Is this a REAL CORS preflight (as opposed to a bare protocol-introspection
|
|
565
|
+
* OPTIONS)? A preflight carries an Origin — browsers always send one. A bare
|
|
566
|
+
* OPTIONS does not, and belongs to the RFC 9110 s9.3.7 handler in dispatch.
|
|
567
|
+
*/
|
|
568
|
+
function isCorsPreflight(method: string | undefined, requestOrigin: string): boolean {
|
|
569
|
+
return method === "OPTIONS" && requestOrigin !== "";
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/** The Allow header for a path, from the LIVE router. */
|
|
573
|
+
function allowHeaderForUrl(url: string | undefined): string {
|
|
574
|
+
const pathname = new URL(url ?? "/", "http://localhost").pathname;
|
|
575
|
+
// startServer builds its own Router and publishes it on globalThis;
|
|
576
|
+
// defaultRouter is the module-level instance used by the standalone
|
|
577
|
+
// get()/post() helpers. A file-routed app registers nothing in the latter,
|
|
578
|
+
// so reading it alone returned an empty method set and stamped Allow: "".
|
|
579
|
+
const liveRouter = (globalThis as { __tina4_router?: Router }).__tina4_router ?? defaultRouter;
|
|
580
|
+
return liveRouter.methodsAllowedForPath(pathname).join(", ");
|
|
257
581
|
}
|
|
258
582
|
|
|
259
583
|
/**
|
|
260
584
|
* Built-in CORS middleware (function form).
|
|
261
|
-
*
|
|
585
|
+
*
|
|
586
|
+
* A thin adapter over CorsPolicy — see that class for the rules and the
|
|
587
|
+
* standards behind them. Reads configuration from env vars when not provided:
|
|
262
588
|
* TINA4_CORS_ORIGINS — comma-separated list of allowed origins, or "*"
|
|
263
589
|
* TINA4_CORS_METHODS — comma-separated list of allowed methods
|
|
264
590
|
* TINA4_CORS_HEADERS — comma-separated list of allowed headers
|
|
265
591
|
* TINA4_CORS_MAX_AGE — preflight cache duration in seconds
|
|
592
|
+
* TINA4_CORS_CREDENTIALS — send Access-Control-Allow-Credentials
|
|
266
593
|
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
594
|
+
* A real preflight is answered 204. The status is the same whether the origin
|
|
595
|
+
* was allowed or denied — the browser does the blocking.
|
|
269
596
|
*/
|
|
270
597
|
export function cors(config?: CorsConfig): Middleware {
|
|
271
|
-
const
|
|
272
|
-
?? process.env.TINA4_CORS_ORIGINS
|
|
273
|
-
?? "*";
|
|
274
|
-
const allowedOrigins = Array.isArray(originsRaw)
|
|
275
|
-
? originsRaw
|
|
276
|
-
: originsRaw.split(",").map((o) => o.trim());
|
|
277
|
-
|
|
278
|
-
const methodsRaw = config?.methods
|
|
279
|
-
?? process.env.TINA4_CORS_METHODS
|
|
280
|
-
?? "GET, POST, PUT, DELETE, PATCH, OPTIONS";
|
|
281
|
-
const allowedMethods = Array.isArray(methodsRaw)
|
|
282
|
-
? methodsRaw.join(", ")
|
|
283
|
-
: methodsRaw;
|
|
284
|
-
|
|
285
|
-
const headersRaw = config?.headers
|
|
286
|
-
?? process.env.TINA4_CORS_HEADERS
|
|
287
|
-
?? "Content-Type,Authorization,X-Request-ID";
|
|
288
|
-
const allowedHeaders = Array.isArray(headersRaw)
|
|
289
|
-
? headersRaw.join(", ")
|
|
290
|
-
: headersRaw;
|
|
291
|
-
|
|
292
|
-
const maxAge = config?.maxAge
|
|
293
|
-
?? (process.env.TINA4_CORS_MAX_AGE ? parseInt(process.env.TINA4_CORS_MAX_AGE, 10) : 86400);
|
|
598
|
+
const policy = new CorsPolicy(config);
|
|
294
599
|
|
|
295
600
|
return (req, res, next) => {
|
|
296
601
|
const requestOrigin = req.headers.origin ?? "";
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
//
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
//
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
return;
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
res.header("Access-Control-Allow-Origin", originHeader);
|
|
317
|
-
res.header("Access-Control-Allow-Methods", allowedMethods);
|
|
318
|
-
res.header("Access-Control-Allow-Headers", allowedHeaders);
|
|
319
|
-
|
|
320
|
-
if (req.method === "OPTIONS") {
|
|
321
|
-
res.header("Access-Control-Max-Age", String(maxAge));
|
|
602
|
+
const preflight = isCorsPreflight(req.method, requestOrigin);
|
|
603
|
+
|
|
604
|
+
applyCorsHeaders(res as Tina4Response, policy.headersFor(requestOrigin, preflight));
|
|
605
|
+
|
|
606
|
+
if (preflight) {
|
|
607
|
+
// Carry the resource's REAL method set as Allow (RFC 9110 s9.3.7): a
|
|
608
|
+
// preflight IS an OPTIONS response, so it answers the same question a
|
|
609
|
+
// bare OPTIONS does, on top of the CORS policy headers. This is
|
|
610
|
+
// CONFORMANCE, not a deviation — Django's View.options() and Express's
|
|
611
|
+
// router already emit Allow; the add-on CORS libraries lose it only
|
|
612
|
+
// because they short-circuit ahead of the framework. See ADR-0013.
|
|
613
|
+
//
|
|
614
|
+
// Allow and Access-Control-Allow-Methods are NOT interchangeable: Allow
|
|
615
|
+
// is what the resource supports, ACAM is what the CORS policy permits
|
|
616
|
+
// cross-origin. A policy allowing DELETE on a GET-only route still 405s.
|
|
617
|
+
res.header("Allow", allowHeaderForUrl(req.url));
|
|
322
618
|
res(null, 204);
|
|
323
619
|
return;
|
|
324
620
|
}
|
|
@@ -329,53 +625,21 @@ export function cors(config?: CorsConfig): Middleware {
|
|
|
329
625
|
|
|
330
626
|
/**
|
|
331
627
|
* Class-based CORS middleware using the before/after convention.
|
|
332
|
-
*
|
|
628
|
+
*
|
|
629
|
+
* The same CorsPolicy as `cors()` — one implementation, one set of semantics.
|
|
333
630
|
*
|
|
334
631
|
* Usage:
|
|
335
632
|
* Router.use(CorsMiddleware);
|
|
336
633
|
*/
|
|
337
634
|
export class CorsMiddleware {
|
|
338
635
|
static beforeCors(req: Tina4Request, res: Tina4Response): [Tina4Request, Tina4Response] {
|
|
339
|
-
const originsRaw = process.env.TINA4_CORS_ORIGINS ?? "*";
|
|
340
|
-
const allowedOrigins = originsRaw.split(",").map((o) => o.trim());
|
|
341
|
-
|
|
342
|
-
const allowedMethods = process.env.TINA4_CORS_METHODS
|
|
343
|
-
?? "GET, POST, PUT, DELETE, PATCH, OPTIONS";
|
|
344
|
-
|
|
345
|
-
const allowedHeaders = process.env.TINA4_CORS_HEADERS
|
|
346
|
-
?? "Content-Type,Authorization,X-Request-ID";
|
|
347
|
-
|
|
348
|
-
const credentials = process.env.TINA4_CORS_CREDENTIALS ?? "false";
|
|
349
|
-
|
|
350
|
-
const maxAge = process.env.TINA4_CORS_MAX_AGE
|
|
351
|
-
? parseInt(process.env.TINA4_CORS_MAX_AGE, 10)
|
|
352
|
-
: 86400;
|
|
353
|
-
|
|
354
636
|
const requestOrigin = req.headers.origin ?? "";
|
|
637
|
+
const preflight = isCorsPreflight(req.method, requestOrigin);
|
|
355
638
|
|
|
356
|
-
|
|
357
|
-
if (allowedOrigins.includes("*")) {
|
|
358
|
-
originHeader = "*";
|
|
359
|
-
} else if (allowedOrigins.includes(requestOrigin)) {
|
|
360
|
-
originHeader = requestOrigin;
|
|
361
|
-
res.header("Vary", "Origin");
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
if (originHeader) {
|
|
365
|
-
res.header("Access-Control-Allow-Origin", originHeader);
|
|
366
|
-
res.header("Access-Control-Allow-Methods", allowedMethods);
|
|
367
|
-
res.header("Access-Control-Allow-Headers", allowedHeaders);
|
|
368
|
-
|
|
369
|
-
// Add credentials header when enabled and origin is not wildcard
|
|
370
|
-
if (credentials === "true" && originHeader !== "*") {
|
|
371
|
-
res.header("Access-Control-Allow-Credentials", "true");
|
|
372
|
-
}
|
|
639
|
+
applyCorsHeaders(res, new CorsPolicy().headersFor(requestOrigin, preflight));
|
|
373
640
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
res(null, 204);
|
|
377
|
-
}
|
|
378
|
-
} else if (req.method === "OPTIONS") {
|
|
641
|
+
if (preflight) {
|
|
642
|
+
res.header("Allow", allowHeaderForUrl(req.url));
|
|
379
643
|
res(null, 204);
|
|
380
644
|
}
|
|
381
645
|
|
|
@@ -384,6 +648,10 @@ export class CorsMiddleware {
|
|
|
384
648
|
|
|
385
649
|
/**
|
|
386
650
|
* Check if a request is an OPTIONS preflight.
|
|
651
|
+
*
|
|
652
|
+
* NOTE: returns true for ANY OPTIONS, with no Origin check, so the name
|
|
653
|
+
* overstates what it tests. The real short-circuit uses isCorsPreflight().
|
|
654
|
+
* Kept because existing tests pin this meaning.
|
|
387
655
|
*/
|
|
388
656
|
static isPreflight(method: string): boolean {
|
|
389
657
|
return method?.toUpperCase() === "OPTIONS";
|
|
@@ -436,10 +704,9 @@ export class RateLimiterMiddleware {
|
|
|
436
704
|
const now = Date.now();
|
|
437
705
|
const cutoff = now - windowMs;
|
|
438
706
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
?? "unknown";
|
|
707
|
+
// Client key. X-Forwarded-For is honoured ONLY when the socket peer is a
|
|
708
|
+
// declared trusted proxy (TINA4_TRUSTED_PROXIES). ADR-0019.
|
|
709
|
+
const ip = resolveClientIp(req.headers, req.socket?.remoteAddress ?? "") || "unknown";
|
|
443
710
|
|
|
444
711
|
let entry = RateLimiterMiddleware.store.get(ip);
|
|
445
712
|
if (!entry) {
|