tina4-nodejs 3.13.121 → 3.13.122
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 +2 -2
- package/package.json +1 -1
- package/packages/cli/dist/bin.js +27 -0
- package/packages/core/dist/index.js +27 -0
- package/packages/core/src/middleware.ts +1156 -1120
- package/packages/orm/dist/index.js +27 -0
- package/types/core/src/middleware.d.ts +15 -0
|
@@ -1,1120 +1,1156 @@
|
|
|
1
|
-
import type { Tina4Request, Tina4Response, Middleware } from "./types.js";
|
|
2
|
-
import { HTTP_OK, HTTP_FORBIDDEN } from "./constants.js";
|
|
3
|
-
import { validToken, getPayload } from "./auth.js";
|
|
4
|
-
import { Log } from "./logger.js";
|
|
5
|
-
import { isTruthy } from "./dotenv.js";
|
|
6
|
-
import { defaultRouter, type Router } from "./router.js";
|
|
7
|
-
import { resolveClientIp } from "./trustedProxy.js";
|
|
8
|
-
import { getFrond, getFrameworkFrond, wantsJson, negotiatedErrorBody } from "./response.js";
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Whether to emit a per-request log line (v3.13.14). TINA4_LOG_REQUESTS is
|
|
12
|
-
* the explicit control (true/false); when unset, request logging follows
|
|
13
|
-
* dev mode (on under TINA4_DEBUG, off in production). Same contract across
|
|
14
|
-
* all four frameworks.
|
|
15
|
-
*/
|
|
16
|
-
function requestLoggingEnabled(): boolean {
|
|
17
|
-
const val = process.env.TINA4_LOG_REQUESTS;
|
|
18
|
-
if (val !== undefined && val !== "") return isTruthy(val);
|
|
19
|
-
return isTruthy(process.env.TINA4_DEBUG);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export class MiddlewareChain {
|
|
23
|
-
private middlewares: Middleware[] = [];
|
|
24
|
-
|
|
25
|
-
use(fn: Middleware): void {
|
|
26
|
-
this.middlewares.push(fn);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Run the chain in REGISTRATION order — each middleware runs exactly once,
|
|
31
|
-
* in the order it was attached via use(). The chain advances from ONE
|
|
32
|
-
* source only: `next()`. (The old runner double-advanced — a for-loop index
|
|
33
|
-
* AND next() both incremented — so every other middleware was silently
|
|
34
|
-
* skipped. Fixed by driving the chain purely by next(), mirroring Python's
|
|
35
|
-
* _make_mw_continuation Russian-doll continuation.)
|
|
36
|
-
*
|
|
37
|
-
* A middleware may stop the chain by:
|
|
38
|
-
* - not calling next() (it owns the response), or
|
|
39
|
-
* - ending the response (res.raw.writableEnded).
|
|
40
|
-
* Returns true when the whole chain ran to completion (handler may proceed),
|
|
41
|
-
* false when it was short-circuited.
|
|
42
|
-
*/
|
|
43
|
-
async run(req: Tina4Request, res: Tina4Response): Promise<boolean> {
|
|
44
|
-
const dispatch = async (i: number): Promise<void> => {
|
|
45
|
-
if (i >= this.middlewares.length) return;
|
|
46
|
-
let advanced = false;
|
|
47
|
-
|
|
48
|
-
const next = (): void => {
|
|
49
|
-
advanced = true;
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
await this.middlewares[i](req, res, next);
|
|
53
|
-
|
|
54
|
-
// The middleware owns the response — stop the chain.
|
|
55
|
-
if (res.raw.writableEnded) return;
|
|
56
|
-
|
|
57
|
-
// next() was called → advance to the following middleware (exactly one
|
|
58
|
-
// step). next() not called → the middleware short-circuited; stop here.
|
|
59
|
-
if (advanced) {
|
|
60
|
-
await dispatch(i + 1);
|
|
61
|
-
}
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
await dispatch(0);
|
|
65
|
-
|
|
66
|
-
// Completed (handler may proceed) iff no middleware ended the response.
|
|
67
|
-
return !res.raw.writableEnded;
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// ── Class-based middleware runner ────────────────────────────────
|
|
72
|
-
//
|
|
73
|
-
// Class-based middleware follows the beforeX / afterX naming convention:
|
|
74
|
-
// statics named before* run before the route handler (MiddlewareRunner.runBefore),
|
|
75
|
-
// statics named after* run once it is done (runAfter). Each hook receives
|
|
76
|
-
// (req, res); what it RETURNS is interpreted by the one table in
|
|
77
|
-
// interpretHookResult below.
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* True when a middleware spec is a CLASS (the beforeX/afterX convention)
|
|
81
|
-
* rather than a plain `(req, res, next)` middleware function.
|
|
82
|
-
*
|
|
83
|
-
* A class's `prototype` property is non-writable by the language spec
|
|
84
|
-
* (ClassDefinitionEvaluation); an ordinary function's is writable, and an
|
|
85
|
-
* arrow function, async function or bound function has no `prototype` at all.
|
|
86
|
-
* That is a language-level distinction rather than a name or source-string
|
|
87
|
-
* sniff, so a class named `cors` and a function named `Cors` both classify
|
|
88
|
-
* correctly.
|
|
89
|
-
*/
|
|
90
|
-
export function isMiddlewareClass(spec: unknown): boolean {
|
|
91
|
-
if (typeof spec !== "function") return false;
|
|
92
|
-
const proto = Object.getOwnPropertyDescriptor(spec, "prototype");
|
|
93
|
-
return proto !== undefined && proto.writable === false;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* The Tina4 response object — callable, and carrying the raw ServerResponse.
|
|
98
|
-
* Structural, so a rebound response is recognised too.
|
|
99
|
-
*/
|
|
100
|
-
function isResponse(value: unknown): value is Tina4Response {
|
|
101
|
-
return typeof value === "function"
|
|
102
|
-
&& typeof (value as Tina4Response).raw?.end === "function";
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* The 403 a hook gets when it says no without saying what to send
|
|
107
|
-
* (ERR-DEC-01/ERR-DEC-02). Routed through the SAME negotiated renderer
|
|
108
|
-
* 404/500 use (server.ts's serveNotFound/renderDispatchError share the same
|
|
109
|
-
* getFrond/getFrameworkFrond singletons via response.ts), so a middleware
|
|
110
|
-
* refusal looks like every other error page - a user template if the app
|
|
111
|
-
* ships one, the framework's errors/403.twig otherwise, negotiated JSON for
|
|
112
|
-
* an API client - instead of the old bare `res.raw.statusCode = 403` with no
|
|
113
|
-
* body at all.
|
|
114
|
-
*/
|
|
115
|
-
async function renderForbidden(req: Tina4Request, res: Tina4Response): Promise<void> {
|
|
116
|
-
const requestId = Log.getRequestId() ?? "";
|
|
117
|
-
|
|
118
|
-
if (wantsJson(req)) {
|
|
119
|
-
const body = negotiatedErrorBody(403, "Forbidden", requestId);
|
|
120
|
-
res.raw.statusCode = HTTP_FORBIDDEN;
|
|
121
|
-
res.raw.setHeader("Content-Type", "application/json");
|
|
122
|
-
res.raw.end(JSON.stringify(body));
|
|
123
|
-
return;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
const data = { path: req.path ?? "", error_message: "Forbidden", request_id: requestId, status_code: 403 };
|
|
127
|
-
let html: string | null = null;
|
|
128
|
-
try {
|
|
129
|
-
html = (await getFrond()).render("errors/403.twig", data);
|
|
130
|
-
} catch {
|
|
131
|
-
// fall through to the framework default
|
|
132
|
-
}
|
|
133
|
-
if (!html) {
|
|
134
|
-
try {
|
|
135
|
-
const fw = await getFrameworkFrond();
|
|
136
|
-
html = fw ? fw.render("errors/403.twig", data) : null;
|
|
137
|
-
} catch {
|
|
138
|
-
html = null;
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
if (html) {
|
|
143
|
-
res.raw.writeHead(HTTP_FORBIDDEN, { "Content-Type": "text/html; charset=utf-8" });
|
|
144
|
-
res.raw.end(html);
|
|
145
|
-
return;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
const body = negotiatedErrorBody(403, "Forbidden", requestId);
|
|
149
|
-
res.raw.statusCode = HTTP_FORBIDDEN;
|
|
150
|
-
res.raw.setHeader("Content-Type", "application/json");
|
|
151
|
-
res.raw.end(JSON.stringify(body));
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
* ONE return-value table, for EVERY beforeX/afterX hook, at EVERY scope
|
|
156
|
-
* (global and per-route):
|
|
157
|
-
*
|
|
158
|
-
* a Response object SHORT-CIRCUIT. That object IS the response, at ANY
|
|
159
|
-
* status. This is the PRIMARY rule and the only return
|
|
160
|
-
* that can express a 302 redirect.
|
|
161
|
-
* the [req, res] pair rebind both, continue (length >= 2, mirroring Python's
|
|
162
|
-
* `isinstance(result, tuple) and len(result) >= 2`)
|
|
163
|
-
* false SHORT-CIRCUIT. Send the response AS SET; a still
|
|
164
|
-
* default and still unwritten response becomes a
|
|
165
|
-
* NEGOTIATED 403 (renderForbidden), because a bare
|
|
166
|
-
* `return false` is a deny.
|
|
167
|
-
* undefined / null continue
|
|
168
|
-
*
|
|
169
|
-
* ASYNC because the false-row now renders a template (await getFrond()).
|
|
170
|
-
* Returns [req, res, stop].
|
|
171
|
-
*/
|
|
172
|
-
async function interpretHookResult(
|
|
173
|
-
result: unknown,
|
|
174
|
-
req: Tina4Request,
|
|
175
|
-
res: Tina4Response,
|
|
176
|
-
): Promise<[Tina4Request, Tina4Response, boolean]> {
|
|
177
|
-
if (Array.isArray(result)) {
|
|
178
|
-
return result.length >= 2
|
|
179
|
-
? [result[0] as Tina4Request, result[1] as Tina4Response, false]
|
|
180
|
-
: [req, res, false];
|
|
181
|
-
}
|
|
182
|
-
if (isResponse(result)) return [req, result, true];
|
|
183
|
-
if (result === false) {
|
|
184
|
-
if (!res.raw.writableEnded && res.raw.statusCode === HTTP_OK) {
|
|
185
|
-
await renderForbidden(req, res);
|
|
186
|
-
}
|
|
187
|
-
return [req, res, true];
|
|
188
|
-
}
|
|
189
|
-
return [req, res, false];
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/**
|
|
193
|
-
* Produce the deterministic clean 500 for a throwing class-based middleware
|
|
194
|
-
* (M2): LOG via Log.error (class + method + error type + message — never
|
|
195
|
-
* silent) then return a 500 with the exact JSON body shape shared across all
|
|
196
|
-
* four frameworks. The worker never crashes and no unhandled exception leaks.
|
|
197
|
-
*
|
|
198
|
-
* The counterpart is Python's `Middleware.middleware_500`
|
|
199
|
-
* (tina4_python/core/middleware.py), called from its own run_before/run_after.
|
|
200
|
-
* This used to cite `_middleware_500`, which is not a symbol in tina4-python at
|
|
201
|
-
* all — that name belonged to its dispatcher, back when its orchestrator had no
|
|
202
|
-
* exception handling to mirror.
|
|
203
|
-
*/
|
|
204
|
-
function middleware500(
|
|
205
|
-
res: Tina4Response,
|
|
206
|
-
mwClass: any,
|
|
207
|
-
methodName: string,
|
|
208
|
-
error: unknown,
|
|
209
|
-
): Tina4Response {
|
|
210
|
-
const clsName = mwClass?.name ?? mwClass?.constructor?.name ?? "Middleware";
|
|
211
|
-
const err = error as { name?: string; message?: string };
|
|
212
|
-
const type = err?.name ?? (error as object)?.constructor?.name ?? "Error";
|
|
213
|
-
const message = err?.message ?? String(error);
|
|
214
|
-
try {
|
|
215
|
-
Log.error(`Middleware ${clsName}.${methodName} raised ${type}: ${message}`);
|
|
216
|
-
} catch {
|
|
217
|
-
/* never let a broken logger swallow the 500 */
|
|
218
|
-
}
|
|
219
|
-
// res is callable (json) in real Response; tolerate either shape.
|
|
220
|
-
if (typeof (res as any).json === "function") {
|
|
221
|
-
(res as any).json({ error: "Internal Server Error", status: 500 }, 500);
|
|
222
|
-
} else if (typeof (res as any) === "function") {
|
|
223
|
-
(res as any)({ error: "Internal Server Error", status: 500 }, 500);
|
|
224
|
-
} else if (typeof (res as any).status === "function") {
|
|
225
|
-
(res as any).status(500);
|
|
226
|
-
}
|
|
227
|
-
return res;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
export class MiddlewareRunner {
|
|
231
|
-
/** Globally registered middleware classes (parity with PHP/Ruby/Python orchestrators). */
|
|
232
|
-
private static globalMiddleware: any[] = [];
|
|
233
|
-
|
|
234
|
-
/**
|
|
235
|
-
* Register a middleware class to run on every request.
|
|
236
|
-
* Mirrors Tina4\Middleware::use (PHP), Tina4::Middleware.use (Ruby),
|
|
237
|
-
* and Middleware.use (Python).
|
|
238
|
-
*/
|
|
239
|
-
static use(cls: any): void {
|
|
240
|
-
if (!MiddlewareRunner.globalMiddleware.includes(cls)) {
|
|
241
|
-
MiddlewareRunner.globalMiddleware.push(cls);
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
/** Return the list of globally registered middleware classes. */
|
|
246
|
-
/**
|
|
247
|
-
* Global middleware that runs BEFORE route matching.
|
|
248
|
-
*
|
|
249
|
-
* A middleware opts in with `static preMatch = true`. Everything else stays
|
|
250
|
-
* where it has always run - after matching - so this is additive and no
|
|
251
|
-
* existing middleware changes behaviour.
|
|
252
|
-
*
|
|
253
|
-
* The two groups need opposite things. CORS must run before matching so its
|
|
254
|
-
* headers survive a short-circuited 401/403; a browser shown a 401 without
|
|
255
|
-
* them reports a CORS error and the real status never reaches the developer.
|
|
256
|
-
* CSRF must run AFTER, because it reads the matched route's metadata to
|
|
257
|
-
* honour a route marked noAuth - PHP shipped exactly that bypass as dead
|
|
258
|
-
* code once, because the metadata was not assigned yet.
|
|
259
|
-
*
|
|
260
|
-
* NOT named `beforeMatch` - hook discovery treats every `before*` static as
|
|
261
|
-
* a middleware hook and would call the flag itself with (req, res).
|
|
262
|
-
*/
|
|
263
|
-
static partitionByMatchPhase(all: any[]): { pre: any[]; post: any[] } {
|
|
264
|
-
const pre: any[] = [];
|
|
265
|
-
const post: any[] = [];
|
|
266
|
-
for (const m of all) {
|
|
267
|
-
if (m && m.preMatch === true) pre.push(m);
|
|
268
|
-
else post.push(m);
|
|
269
|
-
}
|
|
270
|
-
return { pre, post };
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
static getGlobal(): any[] {
|
|
274
|
-
return [...MiddlewareRunner.globalMiddleware];
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
/** Clear all globally registered middleware (primarily for tests). */
|
|
278
|
-
static reset(): void {
|
|
279
|
-
MiddlewareRunner.globalMiddleware = [];
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
/**
|
|
283
|
-
* Discover the before-prefixed / after-prefixed hook names on a middleware
|
|
284
|
-
* class, INHERITED HOOKS INCLUDED, base class first (M1).
|
|
285
|
-
*
|
|
286
|
-
* `Object.getOwnPropertyNames` returns a class's OWN statics only, in
|
|
287
|
-
* source-declaration order. On its own that silently DROPPED every hook a
|
|
288
|
-
* subclass inherited: for `class Sub extends Base` with `static beforeBase`
|
|
289
|
-
* on the base, discovery returned only ["beforeSub"] even though
|
|
290
|
-
* `Sub.beforeBase` is a live function — so a shared base middleware simply
|
|
291
|
-
* never ran, with no error. Python returns ['before_base','before_sub'] and
|
|
292
|
-
* Ruby [:before_base,:before_sub]; Node was the only one of the four that
|
|
293
|
-
* lost hooks.
|
|
294
|
-
*
|
|
295
|
-
* So walk the prototype chain (the STATIC side: Sub -> Base -> ...) and emit
|
|
296
|
-
* base-class hooks BEFORE the subclass's own, de-duping an override to its
|
|
297
|
-
* first (base) position. That is exactly Python's `_discover_methods`
|
|
298
|
-
* walking `reversed(__mro__)` over each `__dict__`, and Ruby's
|
|
299
|
-
* `discover_methods` walking `ancestors.reverse_each`.
|
|
300
|
-
*
|
|
301
|
-
* Within one class the order is still source-declaration order — we
|
|
302
|
-
* deliberately do NOT sort(), so hooks run in the order they were written
|
|
303
|
-
* (parity with Python walking __dict__, PHP get_class_methods, Ruby
|
|
304
|
-
* instance_methods(false)). Cross-class order is the natural iteration of
|
|
305
|
-
* the registered classes = REGISTRATION order.
|
|
306
|
-
*
|
|
307
|
-
* The chain walk stops at Function.prototype / Object.prototype, so the
|
|
308
|
-
* built-in members are never scanned. A plain object registered as
|
|
309
|
-
* middleware still works: its own keys are level 0.
|
|
310
|
-
*/
|
|
311
|
-
private static methodNames(cls: any, prefix: string): string[] {
|
|
312
|
-
const levels: string[][] = [];
|
|
313
|
-
for (
|
|
314
|
-
let level: any = cls;
|
|
315
|
-
level && level !== Function.prototype && level !== Object.prototype;
|
|
316
|
-
level = Object.getPrototypeOf(level)
|
|
317
|
-
) {
|
|
318
|
-
levels.push(
|
|
319
|
-
Object.getOwnPropertyNames(level).filter(
|
|
320
|
-
(name) => name.startsWith(prefix) && typeof cls[name] === "function",
|
|
321
|
-
),
|
|
322
|
-
);
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
const seen = new Set<string>();
|
|
326
|
-
const names: string[] = [];
|
|
327
|
-
// Reverse the levels: base class first, then each derived class.
|
|
328
|
-
for (let i = levels.length - 1; i >= 0; i--) {
|
|
329
|
-
for (const name of levels[i]) {
|
|
330
|
-
if (seen.has(name)) continue;
|
|
331
|
-
seen.add(name);
|
|
332
|
-
names.push(name);
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
return names;
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
/**
|
|
339
|
-
* Execute every beforeX static method found on the supplied classes.
|
|
340
|
-
*
|
|
341
|
-
* ORDER (M1): cross-class = REGISTRATION order (the order classes were
|
|
342
|
-
* attached via Router.use / MiddlewareRunner.use); within a class =
|
|
343
|
-
* DEFINITION order (source order, never alphabetical). before_* run before
|
|
344
|
-
* the handler.
|
|
345
|
-
*
|
|
346
|
-
* THROW (M2): each before* call is wrapped — a throwing middleware is
|
|
347
|
-
* LOGGED and produces a deterministic clean 500 (it never crashes the
|
|
348
|
-
* worker / leaks an unhandled exception), and the chain short-circuits
|
|
349
|
-
* (skip = true, handler skipped).
|
|
350
|
-
*
|
|
351
|
-
* Short-circuits (skip = true, handler skipped) when a before* sets a
|
|
352
|
-
* status >= 400 or ends/500s the response.
|
|
353
|
-
*
|
|
354
|
-
* ASYNC — each hook is awaited so middleware can perform async work (e.g.
|
|
355
|
-
* the distributed responseCache before-hook awaiting `backend.get`). Awaiting
|
|
356
|
-
* a synchronous hook that returns an array is harmless (the array resolves
|
|
357
|
-
* immediately), so existing sync hooks keep working unchanged.
|
|
358
|
-
*
|
|
359
|
-
* RETURN VALUE — see `interpretHookResult` for the one table every hook at
|
|
360
|
-
* every scope obeys. A returned Response object is the PRIMARY
|
|
361
|
-
* short-circuit; `false` is a deny.
|
|
362
|
-
*
|
|
363
|
-
* Returns [req, res, shouldContinue].
|
|
364
|
-
*/
|
|
365
|
-
static async runBefore(
|
|
366
|
-
classes: any[],
|
|
367
|
-
req: Tina4Request,
|
|
368
|
-
res: Tina4Response,
|
|
369
|
-
): Promise<[Tina4Request, Tina4Response, boolean]> {
|
|
370
|
-
for (const cls of classes) {
|
|
371
|
-
for (const method of MiddlewareRunner.methodNames(cls, "before")) {
|
|
372
|
-
try {
|
|
373
|
-
const [nextReq, nextRes, stop] =
|
|
374
|
-
await interpretHookResult(await cls[method](req, res), req, res);
|
|
375
|
-
req = nextReq;
|
|
376
|
-
res = nextRes;
|
|
377
|
-
if (stop) return [req, res, false];
|
|
378
|
-
} catch (error) {
|
|
379
|
-
// Throw → logged clean 500, skip the handler (deterministic).
|
|
380
|
-
res = middleware500(res, cls, method, error);
|
|
381
|
-
return [req, res, false];
|
|
382
|
-
}
|
|
383
|
-
// LEGACY COMPAT PATH — retained, but NOT the main mechanism. A hook
|
|
384
|
-
// that returns nothing and merely leaves an error status (or an ended
|
|
385
|
-
// response) still short-circuits, so middleware written before the
|
|
386
|
-
// return-value contract keeps working. It cannot express a 3xx
|
|
387
|
-
// redirect, which is exactly why a returned Response is the primary
|
|
388
|
-
// rule above.
|
|
389
|
-
if (res.raw.statusCode >= 400 || res.raw.writableEnded) {
|
|
390
|
-
return [req, res, false];
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
return [req, res, true];
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
/**
|
|
398
|
-
* Execute every afterX static method found on the supplied classes.
|
|
399
|
-
*
|
|
400
|
-
* ORDER (M1): cross-class = REGISTRATION order; within a class = DEFINITION
|
|
401
|
-
* order. after_* run after the handler.
|
|
402
|
-
*
|
|
403
|
-
* THROW (M2): each after* call is wrapped — a throwing after middleware is
|
|
404
|
-
* LOGGED and produces a clean 500, then the remaining after* STILL run
|
|
405
|
-
* (they may add headers / logging). No unhandled exception leaks.
|
|
406
|
-
*
|
|
407
|
-
* AFTER-ON-4xx RULE (M2): after_* ALWAYS run, even when a before_*
|
|
408
|
-
* short-circuited with status >= 400 and the handler was skipped — so they
|
|
409
|
-
* can still add headers / logging. The dispatcher calls runAfter
|
|
410
|
-
* unconditionally after the before/handler block (see server.ts).
|
|
411
|
-
*
|
|
412
|
-
* ASYNC — each hook is awaited (e.g. the responseCache after-hook awaiting
|
|
413
|
-
* `backend.set`). Awaiting a synchronous hook is harmless, so existing sync
|
|
414
|
-
* after-hooks keep working unchanged.
|
|
415
|
-
*
|
|
416
|
-
* RETURN VALUE — the SAME table as runBefore (`interpretHookResult`): the
|
|
417
|
-
* contract is one table for every hook at every scope. There is nothing left
|
|
418
|
-
* to skip after the handler, so a short-circuit here ends the after chain.
|
|
419
|
-
* A THROW is different and unchanged: it is logged, becomes a clean 500, and
|
|
420
|
-
* the remaining after hooks still run.
|
|
421
|
-
*/
|
|
422
|
-
static async runAfter(
|
|
423
|
-
classes: any[],
|
|
424
|
-
req: Tina4Request,
|
|
425
|
-
res: Tina4Response,
|
|
426
|
-
): Promise<[Tina4Request, Tina4Response]> {
|
|
427
|
-
for (const cls of classes) {
|
|
428
|
-
for (const method of MiddlewareRunner.methodNames(cls, "after")) {
|
|
429
|
-
try {
|
|
430
|
-
const [nextReq, nextRes, stop] =
|
|
431
|
-
await interpretHookResult(await cls[method](req, res), req, res);
|
|
432
|
-
req = nextReq;
|
|
433
|
-
res = nextRes;
|
|
434
|
-
if (stop) return [req, res];
|
|
435
|
-
} catch (error) {
|
|
436
|
-
// Throw → logged clean 500, but remaining after* STILL run.
|
|
437
|
-
res = middleware500(res, cls, method, error);
|
|
438
|
-
continue;
|
|
439
|
-
}
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
return [req, res];
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
// ── Built-in class-based middleware ─────────────────────────────
|
|
447
|
-
|
|
448
|
-
/** Configuration for the CORS middleware */
|
|
449
|
-
export interface CorsConfig {
|
|
450
|
-
/** Allowed origins. Default: NONE (deny) — or TINA4_CORS_ORIGINS env, comma-separated. "*" allows any. */
|
|
451
|
-
origins?: string | string[];
|
|
452
|
-
/** Allowed methods. Default: standard REST methods (or TINA4_CORS_METHODS env) */
|
|
453
|
-
methods?: string | string[];
|
|
454
|
-
/** Allowed headers. Default: Content-Type, Authorization (or TINA4_CORS_HEADERS env) */
|
|
455
|
-
headers?: string | string[];
|
|
456
|
-
/** Access-Control-Max-Age in seconds. Default: 86400 (or TINA4_CORS_MAX_AGE env) */
|
|
457
|
-
maxAge?: number;
|
|
458
|
-
/** Send Access-Control-Allow-Credentials. Default: false (or TINA4_CORS_CREDENTIALS env). Never sent with a wildcard origin. */
|
|
459
|
-
credentials?: boolean;
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
/** Warn-once ledger so a scripted probe cannot flood the log. */
|
|
463
|
-
const corsWarned = new Set<string>();
|
|
464
|
-
|
|
465
|
-
/** Reset the CORS warn-once ledger. Test seam. */
|
|
466
|
-
export function resetCorsWarnings(): void {
|
|
467
|
-
corsWarned.clear();
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
function corsWarnOnce(key: string, message: string): void {
|
|
471
|
-
if (corsWarned.has(key)) return;
|
|
472
|
-
corsWarned.add(key);
|
|
473
|
-
Log.warning(message);
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
/**
|
|
477
|
-
* The resolved CORS policy — ONE implementation of the rules.
|
|
478
|
-
*
|
|
479
|
-
* Both the function middleware `cors()` and the class middleware
|
|
480
|
-
* `CorsMiddleware` build one of these and apply what it returns. They used to
|
|
481
|
-
* be two independent implementations that had already drifted: `cors()` never
|
|
482
|
-
* read TINA4_CORS_CREDENTIALS at all, so the DEFAULT always-on pipeline
|
|
483
|
-
* silently ignored a documented env var (measured 2026-07-31). One feature,
|
|
484
|
-
* one code path.
|
|
485
|
-
*
|
|
486
|
-
* DENY BY DEFAULT (ADR-0018). With no origins configured, NO
|
|
487
|
-
* Access-Control-Allow-Origin is emitted and the browser's own CORS check
|
|
488
|
-
* blocks the cross-origin request. "*" still works, it just has to be asked for.
|
|
489
|
-
*
|
|
490
|
-
* CREDENTIALS AND THE WILDCARD ARE MUTUALLY EXCLUSIVE. The Fetch Standard's
|
|
491
|
-
* CORS check treats "*" as a literal (not a wildcard) once the request's
|
|
492
|
-
* credentials mode is "include", so ACAO: * with
|
|
493
|
-
* Access-Control-Allow-Credentials: true is rejected by every browser.
|
|
494
|
-
*
|
|
495
|
-
* VARY: ORIGIN whenever the ACAO value is COMPUTED from the request's Origin,
|
|
496
|
-
* i.e. whenever an allow-list is configured — on a MISS as well as a match.
|
|
497
|
-
* RFC 9110 s12.5.5: a Vary field name list tells cache recipients they "MUST
|
|
498
|
-
* NOT use this response to satisfy a later request unless the later request
|
|
499
|
-
* has the same values for the listed header fields as the original request".
|
|
500
|
-
* The miss case matters most: without it a shared cache can store the no-ACAO
|
|
501
|
-
* response for origin B and serve it to origin A. A constant "*" genuinely
|
|
502
|
-
* does not vary and gets no Vary, which would only fragment a CDN's cache.
|
|
503
|
-
*
|
|
504
|
-
* Access-Control-Allow-Methods / -Allow-Headers are static configured lists
|
|
505
|
-
* here, never derived from the request's Access-Control-Request-* headers, so
|
|
506
|
-
* those field names do NOT belong in Vary.
|
|
507
|
-
*/
|
|
508
|
-
export class CorsPolicy {
|
|
509
|
-
readonly allowedOrigins: string[];
|
|
510
|
-
readonly allowedMethods: string;
|
|
511
|
-
readonly allowedHeaders: string;
|
|
512
|
-
readonly maxAge: number;
|
|
513
|
-
readonly credentials: boolean;
|
|
514
|
-
|
|
515
|
-
constructor(config?: CorsConfig) {
|
|
516
|
-
// Default is EMPTY, not "*" — deny by default (ADR-0018).
|
|
517
|
-
const originsRaw = config?.origins ?? process.env.TINA4_CORS_ORIGINS ?? "";
|
|
518
|
-
const list = Array.isArray(originsRaw) ? originsRaw : originsRaw.split(",");
|
|
519
|
-
this.allowedOrigins = list.map((o) => o.trim()).filter((o) => o !== "");
|
|
520
|
-
|
|
521
|
-
const methodsRaw = config?.methods
|
|
522
|
-
?? process.env.TINA4_CORS_METHODS
|
|
523
|
-
?? "GET, POST, PUT, DELETE, PATCH, OPTIONS";
|
|
524
|
-
this.allowedMethods = Array.isArray(methodsRaw) ? methodsRaw.join(", ") : methodsRaw;
|
|
525
|
-
|
|
526
|
-
const headersRaw = config?.headers
|
|
527
|
-
?? process.env.TINA4_CORS_HEADERS
|
|
528
|
-
?? "Content-Type,Authorization,X-Request-ID";
|
|
529
|
-
this.allowedHeaders = Array.isArray(headersRaw) ? headersRaw.join(", ") : headersRaw;
|
|
530
|
-
|
|
531
|
-
this.maxAge = config?.maxAge
|
|
532
|
-
?? (process.env.TINA4_CORS_MAX_AGE ? parseInt(process.env.TINA4_CORS_MAX_AGE, 10) : 86400);
|
|
533
|
-
|
|
534
|
-
this.credentials = config?.credentials
|
|
535
|
-
?? ["true", "1", "yes"].includes((process.env.TINA4_CORS_CREDENTIALS ?? "false").toLowerCase());
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
/** Whether an operator has actually declared a CORS policy. */
|
|
539
|
-
isConfigured(): boolean {
|
|
540
|
-
return this.allowedOrigins.length > 0;
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
/** The origin to send in Access-Control-Allow-Origin, or undefined for none. */
|
|
544
|
-
resolveOrigin(requestOrigin: string): string | undefined {
|
|
545
|
-
if (this.allowedOrigins.length === 0) return undefined;
|
|
546
|
-
if (this.allowedOrigins.includes("*")) return "*";
|
|
547
|
-
if (requestOrigin && this.allowedOrigins.includes(requestOrigin)) return requestOrigin;
|
|
548
|
-
return undefined;
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
/**
|
|
552
|
-
* The CORS headers for a request origin. `isPreflight` adds Max-Age, which
|
|
553
|
-
* the Fetch Standard only defines for a preflight response.
|
|
554
|
-
*/
|
|
555
|
-
headersFor(requestOrigin: string, isPreflight: boolean): Record<string, string> {
|
|
556
|
-
if (this.allowedOrigins.length === 0) {
|
|
557
|
-
if (requestOrigin) {
|
|
558
|
-
corsWarnOnce("unconfigured",
|
|
559
|
-
`CORS: refused cross-origin request from ${requestOrigin} — no policy is configured. `
|
|
560
|
-
+ "Set TINA4_CORS_ORIGINS to the origins you want to allow, e.g. "
|
|
561
|
-
+ "TINA4_CORS_ORIGINS=https://app.example.com (or '*' to allow any origin).");
|
|
562
|
-
}
|
|
563
|
-
return {};
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
const out: Record<string, string> = {};
|
|
567
|
-
if (!this.allowedOrigins.includes("*")) {
|
|
568
|
-
out["Vary"] = "Origin";
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
const origin = this.resolveOrigin(requestOrigin);
|
|
572
|
-
if (origin === undefined) {
|
|
573
|
-
if (requestOrigin) {
|
|
574
|
-
corsWarnOnce(`denied:${requestOrigin}`,
|
|
575
|
-
`CORS: origin ${requestOrigin} is not in TINA4_CORS_ORIGINS `
|
|
576
|
-
+ `(${this.allowedOrigins.join(",")}) — the browser will block this response.`);
|
|
577
|
-
}
|
|
578
|
-
return out;
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
out["Access-Control-Allow-Origin"] = origin;
|
|
582
|
-
out["Access-Control-Allow-Methods"] = this.allowedMethods;
|
|
583
|
-
out["Access-Control-Allow-Headers"] = this.allowedHeaders;
|
|
584
|
-
if (isPreflight) out["Access-Control-Max-Age"] = String(this.maxAge);
|
|
585
|
-
|
|
586
|
-
if (this.credentials) {
|
|
587
|
-
if (origin === "*") {
|
|
588
|
-
corsWarnOnce("wildcard-credentials",
|
|
589
|
-
"CORS: TINA4_CORS_CREDENTIALS is true but TINA4_CORS_ORIGINS is '*'. The Fetch Standard "
|
|
590
|
-
+ "forbids Access-Control-Allow-Origin: * with credentials, so credentials are NOT being "
|
|
591
|
-
+ "sent. Credentialed CORS requires an explicit origin list, e.g. "
|
|
592
|
-
+ "TINA4_CORS_ORIGINS=https://app.example.com.");
|
|
593
|
-
} else {
|
|
594
|
-
out["Access-Control-Allow-Credentials"] = "true";
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
return out;
|
|
598
|
-
}
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
/** Fold a Vary field name into whatever Vary the response already carries. */
|
|
602
|
-
function applyCorsHeaders(res: Tina4Response, headers: Record<string, string>): void {
|
|
603
|
-
for (const [name, value] of Object.entries(headers)) {
|
|
604
|
-
if (name === "Vary") {
|
|
605
|
-
const current = String((res as { raw?: { getHeader?(n: string): unknown } }).raw?.getHeader?.("Vary") ?? "");
|
|
606
|
-
const parts = current.split(",").map((p) => p.trim()).filter((p) => p !== "");
|
|
607
|
-
if (!parts.some((p) => p.toLowerCase() === value.toLowerCase())) parts.push(value);
|
|
608
|
-
res.header(name, parts.join(", "));
|
|
609
|
-
continue;
|
|
610
|
-
}
|
|
611
|
-
res.header(name, value);
|
|
612
|
-
}
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
/**
|
|
616
|
-
* Is this a REAL CORS preflight (as opposed to a bare protocol-introspection
|
|
617
|
-
* OPTIONS)? A preflight carries an Origin — browsers always send one. A bare
|
|
618
|
-
* OPTIONS does not, and belongs to the RFC 9110 s9.3.7 handler in dispatch.
|
|
619
|
-
*/
|
|
620
|
-
function isCorsPreflight(method: string | undefined, requestOrigin: string): boolean {
|
|
621
|
-
return method === "OPTIONS" && requestOrigin !== "";
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
/** The Allow header for a path, from the LIVE router. */
|
|
625
|
-
function allowHeaderForUrl(url: string | undefined): string {
|
|
626
|
-
const pathname = new URL(url ?? "/", "http://localhost").pathname;
|
|
627
|
-
// startServer builds its own Router and publishes it on globalThis;
|
|
628
|
-
// defaultRouter is the module-level instance used by the standalone
|
|
629
|
-
// get()/post() helpers. A file-routed app registers nothing in the latter,
|
|
630
|
-
// so reading it alone returned an empty method set and stamped Allow: "".
|
|
631
|
-
const liveRouter = (globalThis as { __tina4_router?: Router }).__tina4_router ?? defaultRouter;
|
|
632
|
-
return liveRouter.methodsAllowedForPath(pathname).join(", ");
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
/**
|
|
636
|
-
* Built-in CORS middleware (function form).
|
|
637
|
-
*
|
|
638
|
-
* A thin adapter over CorsPolicy — see that class for the rules and the
|
|
639
|
-
* standards behind them. Reads configuration from env vars when not provided:
|
|
640
|
-
* TINA4_CORS_ORIGINS — comma-separated list of allowed origins, or "*"
|
|
641
|
-
* TINA4_CORS_METHODS — comma-separated list of allowed methods
|
|
642
|
-
* TINA4_CORS_HEADERS — comma-separated list of allowed headers
|
|
643
|
-
* TINA4_CORS_MAX_AGE — preflight cache duration in seconds
|
|
644
|
-
* TINA4_CORS_CREDENTIALS — send Access-Control-Allow-Credentials
|
|
645
|
-
*
|
|
646
|
-
* A real preflight is answered 204. The status is the same whether the origin
|
|
647
|
-
* was allowed or denied — the browser does the blocking.
|
|
648
|
-
*/
|
|
649
|
-
export function cors(config?: CorsConfig): Middleware {
|
|
650
|
-
const policy = new CorsPolicy(config);
|
|
651
|
-
|
|
652
|
-
return (req, res, next) => {
|
|
653
|
-
const requestOrigin = req.headers.origin ?? "";
|
|
654
|
-
const preflight = isCorsPreflight(req.method, requestOrigin);
|
|
655
|
-
|
|
656
|
-
applyCorsHeaders(res as Tina4Response, policy.headersFor(requestOrigin, preflight));
|
|
657
|
-
|
|
658
|
-
if (preflight) {
|
|
659
|
-
// Carry the resource's REAL method set as Allow (RFC 9110 s9.3.7): a
|
|
660
|
-
// preflight IS an OPTIONS response, so it answers the same question a
|
|
661
|
-
// bare OPTIONS does, on top of the CORS policy headers. This is
|
|
662
|
-
// CONFORMANCE, not a deviation — Django's View.options() and Express's
|
|
663
|
-
// router already emit Allow; the add-on CORS libraries lose it only
|
|
664
|
-
// because they short-circuit ahead of the framework. See ADR-0013.
|
|
665
|
-
//
|
|
666
|
-
// Allow and Access-Control-Allow-Methods are NOT interchangeable: Allow
|
|
667
|
-
// is what the resource supports, ACAM is what the CORS policy permits
|
|
668
|
-
// cross-origin. A policy allowing DELETE on a GET-only route still 405s.
|
|
669
|
-
res.header("Allow", allowHeaderForUrl(req.url));
|
|
670
|
-
res(null, 204);
|
|
671
|
-
return;
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
next();
|
|
675
|
-
};
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
/**
|
|
679
|
-
* Class-based CORS middleware using the before/after convention.
|
|
680
|
-
*
|
|
681
|
-
* The same CorsPolicy as `cors()` — one implementation, one set of semantics.
|
|
682
|
-
*
|
|
683
|
-
* Usage:
|
|
684
|
-
* Router.use(CorsMiddleware);
|
|
685
|
-
*/
|
|
686
|
-
export class CorsMiddleware {
|
|
687
|
-
static beforeCors(req: Tina4Request, res: Tina4Response): [Tina4Request, Tina4Response] {
|
|
688
|
-
const requestOrigin = req.headers.origin ?? "";
|
|
689
|
-
const preflight = isCorsPreflight(req.method, requestOrigin);
|
|
690
|
-
|
|
691
|
-
applyCorsHeaders(res, new CorsPolicy().headersFor(requestOrigin, preflight));
|
|
692
|
-
|
|
693
|
-
if (preflight) {
|
|
694
|
-
res.header("Allow", allowHeaderForUrl(req.url));
|
|
695
|
-
res(null, 204);
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
return [req, res];
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
/**
|
|
702
|
-
* Check if a request is an OPTIONS preflight.
|
|
703
|
-
*
|
|
704
|
-
* NOTE: returns true for ANY OPTIONS, with no Origin check, so the name
|
|
705
|
-
* overstates what it tests. The real short-circuit uses isCorsPreflight().
|
|
706
|
-
* Kept because existing tests pin this meaning.
|
|
707
|
-
*/
|
|
708
|
-
static isPreflight(method: string): boolean {
|
|
709
|
-
return method?.toUpperCase() === "OPTIONS";
|
|
710
|
-
}
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
/**
|
|
714
|
-
* Class-based rate limiter middleware using the before/after convention.
|
|
715
|
-
* Uses the same sliding-window algorithm as the `rateLimiter()` function.
|
|
716
|
-
*
|
|
717
|
-
* Reads configuration from env vars:
|
|
718
|
-
* TINA4_RATE_LIMIT — max requests per window (default 100)
|
|
719
|
-
* TINA4_RATE_WINDOW — window duration in seconds (default 60)
|
|
720
|
-
*
|
|
721
|
-
* Usage:
|
|
722
|
-
* Router.use(RateLimiterMiddleware);
|
|
723
|
-
*/
|
|
724
|
-
export class RateLimiterMiddleware {
|
|
725
|
-
private static store = new Map<string, { timestamps: number[] }>();
|
|
726
|
-
private static cleanupTimer: ReturnType<typeof setInterval> | null = null;
|
|
727
|
-
|
|
728
|
-
private static ensureCleanup(windowMs: number): void {
|
|
729
|
-
if (RateLimiterMiddleware.cleanupTimer) return;
|
|
730
|
-
RateLimiterMiddleware.cleanupTimer = setInterval(() => {
|
|
731
|
-
const now = Date.now();
|
|
732
|
-
const cutoff = now - windowMs;
|
|
733
|
-
for (const [ip, entry] of RateLimiterMiddleware.store) {
|
|
734
|
-
entry.timestamps = entry.timestamps.filter((t) => t > cutoff);
|
|
735
|
-
if (entry.timestamps.length === 0) {
|
|
736
|
-
RateLimiterMiddleware.store.delete(ip);
|
|
737
|
-
}
|
|
738
|
-
}
|
|
739
|
-
}, 60_000);
|
|
740
|
-
if (RateLimiterMiddleware.cleanupTimer.unref) {
|
|
741
|
-
RateLimiterMiddleware.cleanupTimer.unref();
|
|
742
|
-
}
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
static beforeRateLimit(req: Tina4Request, res: Tina4Response): [Tina4Request, Tina4Response] {
|
|
746
|
-
const limit = process.env.TINA4_RATE_LIMIT
|
|
747
|
-
? parseInt(process.env.TINA4_RATE_LIMIT, 10)
|
|
748
|
-
: 100;
|
|
749
|
-
const windowSeconds = process.env.TINA4_RATE_WINDOW
|
|
750
|
-
? parseInt(process.env.TINA4_RATE_WINDOW, 10)
|
|
751
|
-
: 60;
|
|
752
|
-
const windowMs = windowSeconds * 1000;
|
|
753
|
-
|
|
754
|
-
RateLimiterMiddleware.ensureCleanup(windowMs);
|
|
755
|
-
|
|
756
|
-
const now = Date.now();
|
|
757
|
-
const cutoff = now - windowMs;
|
|
758
|
-
|
|
759
|
-
// Client key. X-Forwarded-For is honoured ONLY when the socket peer is a
|
|
760
|
-
// declared trusted proxy (TINA4_TRUSTED_PROXIES). ADR-0019.
|
|
761
|
-
const ip = resolveClientIp(req.headers, req.socket?.remoteAddress ?? "") || "unknown";
|
|
762
|
-
|
|
763
|
-
let entry = RateLimiterMiddleware.store.get(ip);
|
|
764
|
-
if (!entry) {
|
|
765
|
-
entry = { timestamps: [] };
|
|
766
|
-
RateLimiterMiddleware.store.set(ip, entry);
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
entry.timestamps = entry.timestamps.filter((t) => t > cutoff);
|
|
770
|
-
|
|
771
|
-
const resetTimestamp = entry.timestamps.length > 0
|
|
772
|
-
? Math.ceil((entry.timestamps[0] + windowMs) / 1000)
|
|
773
|
-
: Math.ceil((now + windowMs) / 1000);
|
|
774
|
-
|
|
775
|
-
const remaining = Math.max(0, limit - entry.timestamps.length);
|
|
776
|
-
|
|
777
|
-
res.header("X-RateLimit-Limit", String(limit));
|
|
778
|
-
res.header("X-RateLimit-Remaining", String(Math.max(0, remaining - 1)));
|
|
779
|
-
res.header("X-RateLimit-Reset", String(resetTimestamp));
|
|
780
|
-
|
|
781
|
-
if (entry.timestamps.length >= limit) {
|
|
782
|
-
const retryAfter = Math.max(1, resetTimestamp - Math.ceil(now / 1000));
|
|
783
|
-
res.header("Retry-After", String(retryAfter));
|
|
784
|
-
res.header("X-RateLimit-Remaining", "0");
|
|
785
|
-
res({
|
|
786
|
-
error: "Too Many Requests",
|
|
787
|
-
statusCode: 429,
|
|
788
|
-
message: `Rate limit exceeded. Try again in ${retryAfter} seconds.`,
|
|
789
|
-
}, 429);
|
|
790
|
-
return [req, res];
|
|
791
|
-
}
|
|
792
|
-
|
|
793
|
-
entry.timestamps.push(now);
|
|
794
|
-
return [req, res];
|
|
795
|
-
}
|
|
796
|
-
|
|
797
|
-
/**
|
|
798
|
-
* Check if an IP is within rate limits without recording a request.
|
|
799
|
-
* Returns [allowed, info] matching Python/Ruby API.
|
|
800
|
-
*/
|
|
801
|
-
static check(ip: string): [boolean, { limit: number; remaining: number; reset: number; window: number }] {
|
|
802
|
-
const limit = process.env.TINA4_RATE_LIMIT ? parseInt(process.env.TINA4_RATE_LIMIT, 10) : 100;
|
|
803
|
-
const windowSeconds = process.env.TINA4_RATE_WINDOW ? parseInt(process.env.TINA4_RATE_WINDOW, 10) : 60;
|
|
804
|
-
const windowMs = windowSeconds * 1000;
|
|
805
|
-
const now = Date.now();
|
|
806
|
-
const cutoff = now - windowMs;
|
|
807
|
-
|
|
808
|
-
let entry = RateLimiterMiddleware.store.get(ip);
|
|
809
|
-
if (!entry) {
|
|
810
|
-
entry = { timestamps: [] };
|
|
811
|
-
RateLimiterMiddleware.store.set(ip, entry);
|
|
812
|
-
}
|
|
813
|
-
entry.timestamps = entry.timestamps.filter((t) => t > cutoff);
|
|
814
|
-
|
|
815
|
-
const remaining = Math.max(0, limit - entry.timestamps.length);
|
|
816
|
-
const reset = entry.timestamps.length > 0
|
|
817
|
-
? Math.ceil((entry.timestamps[0] + windowMs - now) / 1000)
|
|
818
|
-
: windowSeconds;
|
|
819
|
-
|
|
820
|
-
if (entry.timestamps.length >= limit) {
|
|
821
|
-
return [false, { limit, remaining: 0, reset, window: windowSeconds }];
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
return [true, { limit, remaining: remaining - 1, reset: windowSeconds, window: windowSeconds }];
|
|
825
|
-
}
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
/**
|
|
829
|
-
* Class-based request logger middleware using the before/after convention.
|
|
830
|
-
* `beforeLog` stamps the request start time.
|
|
831
|
-
* `afterLog` prints the coloured status line.
|
|
832
|
-
*
|
|
833
|
-
* Usage:
|
|
834
|
-
* Router.use(RequestLogger);
|
|
835
|
-
*/
|
|
836
|
-
export class RequestLogger {
|
|
837
|
-
static beforeLog(req: Tina4Request, res: Tina4Response): [Tina4Request, Tina4Response] {
|
|
838
|
-
(req as any).startTime = Date.now();
|
|
839
|
-
return [req, res];
|
|
840
|
-
}
|
|
841
|
-
|
|
842
|
-
static afterLog(req: Tina4Request, res: Tina4Response): [Tina4Request, Tina4Response] {
|
|
843
|
-
const duration = Date.now() - ((req as any).startTime ?? Date.now());
|
|
844
|
-
const status = res.raw.statusCode;
|
|
845
|
-
const method = req.method ?? "?";
|
|
846
|
-
const url = req.url ?? "/";
|
|
847
|
-
const color = status >= 400 ? "\x1b[31m" : status >= 300 ? "\x1b[33m" : "\x1b[32m";
|
|
848
|
-
console.log(` ${color}${status}\x1b[0m ${method} ${url} \x1b[90m${duration}ms\x1b[0m`);
|
|
849
|
-
return [req, res];
|
|
850
|
-
}
|
|
851
|
-
}
|
|
852
|
-
|
|
853
|
-
/**
|
|
854
|
-
* Class-based security headers middleware using the before/after convention.
|
|
855
|
-
* Auto-injects security headers on every response.
|
|
856
|
-
*
|
|
857
|
-
* Configuration via env vars:
|
|
858
|
-
* TINA4_FRAME_OPTIONS — X-Frame-Options (default: "SAMEORIGIN")
|
|
859
|
-
* TINA4_HSTS — Strict-Transport-Security max-age value
|
|
860
|
-
* (default: "" = off; set to "31536000" to enable)
|
|
861
|
-
* TINA4_CSP — Content-Security-Policy (default: "default-src 'self'")
|
|
862
|
-
* TINA4_REFERRER_POLICY — Referrer-Policy (default: "strict-origin-when-cross-origin")
|
|
863
|
-
* TINA4_PERMISSIONS_POLICY — Permissions-Policy (default: "camera=(), microphone=(), geolocation=()")
|
|
864
|
-
*
|
|
865
|
-
* Usage:
|
|
866
|
-
* Router.use(SecurityHeadersMiddleware);
|
|
867
|
-
*/
|
|
868
|
-
export class SecurityHeadersMiddleware {
|
|
869
|
-
static beforeSecurity(req: Tina4Request, res: Tina4Response): [Tina4Request, Tina4Response] {
|
|
870
|
-
res.header(
|
|
871
|
-
"X-Frame-Options",
|
|
872
|
-
process.env.TINA4_FRAME_OPTIONS ?? "SAMEORIGIN",
|
|
873
|
-
);
|
|
874
|
-
|
|
875
|
-
res.header("X-Content-Type-Options", "nosniff");
|
|
876
|
-
|
|
877
|
-
// HSTS is HTTPS-only (SECHDR-DEC-02): a downgrade-protection header on a
|
|
878
|
-
// plain-HTTP response is inert at best and ships a bad max-age on an
|
|
879
|
-
// unencrypted scheme at worst. Emit it ONLY when TINA4_HSTS is set AND the
|
|
880
|
-
// request is HTTPS (x-forwarded-proto first hop, else the native TLS socket)
|
|
881
|
-
// — the same proxy-aware scheme the session cookie's Secure flag uses.
|
|
882
|
-
const hsts = process.env.TINA4_HSTS ?? "";
|
|
883
|
-
if (hsts && SecurityHeadersMiddleware.isSecureRequest(req)) {
|
|
884
|
-
res.header(
|
|
885
|
-
"Strict-Transport-Security",
|
|
886
|
-
`max-age=${hsts}; includeSubDomains`,
|
|
887
|
-
);
|
|
888
|
-
}
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
//
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
//
|
|
1016
|
-
const
|
|
1017
|
-
if (
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
const
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
//
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
}
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
}
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1
|
+
import type { Tina4Request, Tina4Response, Middleware } from "./types.js";
|
|
2
|
+
import { HTTP_OK, HTTP_FORBIDDEN } from "./constants.js";
|
|
3
|
+
import { validToken, getPayload } from "./auth.js";
|
|
4
|
+
import { Log } from "./logger.js";
|
|
5
|
+
import { isTruthy } from "./dotenv.js";
|
|
6
|
+
import { defaultRouter, type Router } from "./router.js";
|
|
7
|
+
import { resolveClientIp } from "./trustedProxy.js";
|
|
8
|
+
import { getFrond, getFrameworkFrond, wantsJson, negotiatedErrorBody } from "./response.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Whether to emit a per-request log line (v3.13.14). TINA4_LOG_REQUESTS is
|
|
12
|
+
* the explicit control (true/false); when unset, request logging follows
|
|
13
|
+
* dev mode (on under TINA4_DEBUG, off in production). Same contract across
|
|
14
|
+
* all four frameworks.
|
|
15
|
+
*/
|
|
16
|
+
function requestLoggingEnabled(): boolean {
|
|
17
|
+
const val = process.env.TINA4_LOG_REQUESTS;
|
|
18
|
+
if (val !== undefined && val !== "") return isTruthy(val);
|
|
19
|
+
return isTruthy(process.env.TINA4_DEBUG);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class MiddlewareChain {
|
|
23
|
+
private middlewares: Middleware[] = [];
|
|
24
|
+
|
|
25
|
+
use(fn: Middleware): void {
|
|
26
|
+
this.middlewares.push(fn);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Run the chain in REGISTRATION order — each middleware runs exactly once,
|
|
31
|
+
* in the order it was attached via use(). The chain advances from ONE
|
|
32
|
+
* source only: `next()`. (The old runner double-advanced — a for-loop index
|
|
33
|
+
* AND next() both incremented — so every other middleware was silently
|
|
34
|
+
* skipped. Fixed by driving the chain purely by next(), mirroring Python's
|
|
35
|
+
* _make_mw_continuation Russian-doll continuation.)
|
|
36
|
+
*
|
|
37
|
+
* A middleware may stop the chain by:
|
|
38
|
+
* - not calling next() (it owns the response), or
|
|
39
|
+
* - ending the response (res.raw.writableEnded).
|
|
40
|
+
* Returns true when the whole chain ran to completion (handler may proceed),
|
|
41
|
+
* false when it was short-circuited.
|
|
42
|
+
*/
|
|
43
|
+
async run(req: Tina4Request, res: Tina4Response): Promise<boolean> {
|
|
44
|
+
const dispatch = async (i: number): Promise<void> => {
|
|
45
|
+
if (i >= this.middlewares.length) return;
|
|
46
|
+
let advanced = false;
|
|
47
|
+
|
|
48
|
+
const next = (): void => {
|
|
49
|
+
advanced = true;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
await this.middlewares[i](req, res, next);
|
|
53
|
+
|
|
54
|
+
// The middleware owns the response — stop the chain.
|
|
55
|
+
if (res.raw.writableEnded) return;
|
|
56
|
+
|
|
57
|
+
// next() was called → advance to the following middleware (exactly one
|
|
58
|
+
// step). next() not called → the middleware short-circuited; stop here.
|
|
59
|
+
if (advanced) {
|
|
60
|
+
await dispatch(i + 1);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
await dispatch(0);
|
|
65
|
+
|
|
66
|
+
// Completed (handler may proceed) iff no middleware ended the response.
|
|
67
|
+
return !res.raw.writableEnded;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── Class-based middleware runner ────────────────────────────────
|
|
72
|
+
//
|
|
73
|
+
// Class-based middleware follows the beforeX / afterX naming convention:
|
|
74
|
+
// statics named before* run before the route handler (MiddlewareRunner.runBefore),
|
|
75
|
+
// statics named after* run once it is done (runAfter). Each hook receives
|
|
76
|
+
// (req, res); what it RETURNS is interpreted by the one table in
|
|
77
|
+
// interpretHookResult below.
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* True when a middleware spec is a CLASS (the beforeX/afterX convention)
|
|
81
|
+
* rather than a plain `(req, res, next)` middleware function.
|
|
82
|
+
*
|
|
83
|
+
* A class's `prototype` property is non-writable by the language spec
|
|
84
|
+
* (ClassDefinitionEvaluation); an ordinary function's is writable, and an
|
|
85
|
+
* arrow function, async function or bound function has no `prototype` at all.
|
|
86
|
+
* That is a language-level distinction rather than a name or source-string
|
|
87
|
+
* sniff, so a class named `cors` and a function named `Cors` both classify
|
|
88
|
+
* correctly.
|
|
89
|
+
*/
|
|
90
|
+
export function isMiddlewareClass(spec: unknown): boolean {
|
|
91
|
+
if (typeof spec !== "function") return false;
|
|
92
|
+
const proto = Object.getOwnPropertyDescriptor(spec, "prototype");
|
|
93
|
+
return proto !== undefined && proto.writable === false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The Tina4 response object — callable, and carrying the raw ServerResponse.
|
|
98
|
+
* Structural, so a rebound response is recognised too.
|
|
99
|
+
*/
|
|
100
|
+
function isResponse(value: unknown): value is Tina4Response {
|
|
101
|
+
return typeof value === "function"
|
|
102
|
+
&& typeof (value as Tina4Response).raw?.end === "function";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The 403 a hook gets when it says no without saying what to send
|
|
107
|
+
* (ERR-DEC-01/ERR-DEC-02). Routed through the SAME negotiated renderer
|
|
108
|
+
* 404/500 use (server.ts's serveNotFound/renderDispatchError share the same
|
|
109
|
+
* getFrond/getFrameworkFrond singletons via response.ts), so a middleware
|
|
110
|
+
* refusal looks like every other error page - a user template if the app
|
|
111
|
+
* ships one, the framework's errors/403.twig otherwise, negotiated JSON for
|
|
112
|
+
* an API client - instead of the old bare `res.raw.statusCode = 403` with no
|
|
113
|
+
* body at all.
|
|
114
|
+
*/
|
|
115
|
+
async function renderForbidden(req: Tina4Request, res: Tina4Response): Promise<void> {
|
|
116
|
+
const requestId = Log.getRequestId() ?? "";
|
|
117
|
+
|
|
118
|
+
if (wantsJson(req)) {
|
|
119
|
+
const body = negotiatedErrorBody(403, "Forbidden", requestId);
|
|
120
|
+
res.raw.statusCode = HTTP_FORBIDDEN;
|
|
121
|
+
res.raw.setHeader("Content-Type", "application/json");
|
|
122
|
+
res.raw.end(JSON.stringify(body));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const data = { path: req.path ?? "", error_message: "Forbidden", request_id: requestId, status_code: 403 };
|
|
127
|
+
let html: string | null = null;
|
|
128
|
+
try {
|
|
129
|
+
html = (await getFrond()).render("errors/403.twig", data);
|
|
130
|
+
} catch {
|
|
131
|
+
// fall through to the framework default
|
|
132
|
+
}
|
|
133
|
+
if (!html) {
|
|
134
|
+
try {
|
|
135
|
+
const fw = await getFrameworkFrond();
|
|
136
|
+
html = fw ? fw.render("errors/403.twig", data) : null;
|
|
137
|
+
} catch {
|
|
138
|
+
html = null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (html) {
|
|
143
|
+
res.raw.writeHead(HTTP_FORBIDDEN, { "Content-Type": "text/html; charset=utf-8" });
|
|
144
|
+
res.raw.end(html);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const body = negotiatedErrorBody(403, "Forbidden", requestId);
|
|
149
|
+
res.raw.statusCode = HTTP_FORBIDDEN;
|
|
150
|
+
res.raw.setHeader("Content-Type", "application/json");
|
|
151
|
+
res.raw.end(JSON.stringify(body));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* ONE return-value table, for EVERY beforeX/afterX hook, at EVERY scope
|
|
156
|
+
* (global and per-route):
|
|
157
|
+
*
|
|
158
|
+
* a Response object SHORT-CIRCUIT. That object IS the response, at ANY
|
|
159
|
+
* status. This is the PRIMARY rule and the only return
|
|
160
|
+
* that can express a 302 redirect.
|
|
161
|
+
* the [req, res] pair rebind both, continue (length >= 2, mirroring Python's
|
|
162
|
+
* `isinstance(result, tuple) and len(result) >= 2`)
|
|
163
|
+
* false SHORT-CIRCUIT. Send the response AS SET; a still
|
|
164
|
+
* default and still unwritten response becomes a
|
|
165
|
+
* NEGOTIATED 403 (renderForbidden), because a bare
|
|
166
|
+
* `return false` is a deny.
|
|
167
|
+
* undefined / null continue
|
|
168
|
+
*
|
|
169
|
+
* ASYNC because the false-row now renders a template (await getFrond()).
|
|
170
|
+
* Returns [req, res, stop].
|
|
171
|
+
*/
|
|
172
|
+
async function interpretHookResult(
|
|
173
|
+
result: unknown,
|
|
174
|
+
req: Tina4Request,
|
|
175
|
+
res: Tina4Response,
|
|
176
|
+
): Promise<[Tina4Request, Tina4Response, boolean]> {
|
|
177
|
+
if (Array.isArray(result)) {
|
|
178
|
+
return result.length >= 2
|
|
179
|
+
? [result[0] as Tina4Request, result[1] as Tina4Response, false]
|
|
180
|
+
: [req, res, false];
|
|
181
|
+
}
|
|
182
|
+
if (isResponse(result)) return [req, result, true];
|
|
183
|
+
if (result === false) {
|
|
184
|
+
if (!res.raw.writableEnded && res.raw.statusCode === HTTP_OK) {
|
|
185
|
+
await renderForbidden(req, res);
|
|
186
|
+
}
|
|
187
|
+
return [req, res, true];
|
|
188
|
+
}
|
|
189
|
+
return [req, res, false];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Produce the deterministic clean 500 for a throwing class-based middleware
|
|
194
|
+
* (M2): LOG via Log.error (class + method + error type + message — never
|
|
195
|
+
* silent) then return a 500 with the exact JSON body shape shared across all
|
|
196
|
+
* four frameworks. The worker never crashes and no unhandled exception leaks.
|
|
197
|
+
*
|
|
198
|
+
* The counterpart is Python's `Middleware.middleware_500`
|
|
199
|
+
* (tina4_python/core/middleware.py), called from its own run_before/run_after.
|
|
200
|
+
* This used to cite `_middleware_500`, which is not a symbol in tina4-python at
|
|
201
|
+
* all — that name belonged to its dispatcher, back when its orchestrator had no
|
|
202
|
+
* exception handling to mirror.
|
|
203
|
+
*/
|
|
204
|
+
function middleware500(
|
|
205
|
+
res: Tina4Response,
|
|
206
|
+
mwClass: any,
|
|
207
|
+
methodName: string,
|
|
208
|
+
error: unknown,
|
|
209
|
+
): Tina4Response {
|
|
210
|
+
const clsName = mwClass?.name ?? mwClass?.constructor?.name ?? "Middleware";
|
|
211
|
+
const err = error as { name?: string; message?: string };
|
|
212
|
+
const type = err?.name ?? (error as object)?.constructor?.name ?? "Error";
|
|
213
|
+
const message = err?.message ?? String(error);
|
|
214
|
+
try {
|
|
215
|
+
Log.error(`Middleware ${clsName}.${methodName} raised ${type}: ${message}`);
|
|
216
|
+
} catch {
|
|
217
|
+
/* never let a broken logger swallow the 500 */
|
|
218
|
+
}
|
|
219
|
+
// res is callable (json) in real Response; tolerate either shape.
|
|
220
|
+
if (typeof (res as any).json === "function") {
|
|
221
|
+
(res as any).json({ error: "Internal Server Error", status: 500 }, 500);
|
|
222
|
+
} else if (typeof (res as any) === "function") {
|
|
223
|
+
(res as any)({ error: "Internal Server Error", status: 500 }, 500);
|
|
224
|
+
} else if (typeof (res as any).status === "function") {
|
|
225
|
+
(res as any).status(500);
|
|
226
|
+
}
|
|
227
|
+
return res;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export class MiddlewareRunner {
|
|
231
|
+
/** Globally registered middleware classes (parity with PHP/Ruby/Python orchestrators). */
|
|
232
|
+
private static globalMiddleware: any[] = [];
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Register a middleware class to run on every request.
|
|
236
|
+
* Mirrors Tina4\Middleware::use (PHP), Tina4::Middleware.use (Ruby),
|
|
237
|
+
* and Middleware.use (Python).
|
|
238
|
+
*/
|
|
239
|
+
static use(cls: any): void {
|
|
240
|
+
if (!MiddlewareRunner.globalMiddleware.includes(cls)) {
|
|
241
|
+
MiddlewareRunner.globalMiddleware.push(cls);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Return the list of globally registered middleware classes. */
|
|
246
|
+
/**
|
|
247
|
+
* Global middleware that runs BEFORE route matching.
|
|
248
|
+
*
|
|
249
|
+
* A middleware opts in with `static preMatch = true`. Everything else stays
|
|
250
|
+
* where it has always run - after matching - so this is additive and no
|
|
251
|
+
* existing middleware changes behaviour.
|
|
252
|
+
*
|
|
253
|
+
* The two groups need opposite things. CORS must run before matching so its
|
|
254
|
+
* headers survive a short-circuited 401/403; a browser shown a 401 without
|
|
255
|
+
* them reports a CORS error and the real status never reaches the developer.
|
|
256
|
+
* CSRF must run AFTER, because it reads the matched route's metadata to
|
|
257
|
+
* honour a route marked noAuth - PHP shipped exactly that bypass as dead
|
|
258
|
+
* code once, because the metadata was not assigned yet.
|
|
259
|
+
*
|
|
260
|
+
* NOT named `beforeMatch` - hook discovery treats every `before*` static as
|
|
261
|
+
* a middleware hook and would call the flag itself with (req, res).
|
|
262
|
+
*/
|
|
263
|
+
static partitionByMatchPhase(all: any[]): { pre: any[]; post: any[] } {
|
|
264
|
+
const pre: any[] = [];
|
|
265
|
+
const post: any[] = [];
|
|
266
|
+
for (const m of all) {
|
|
267
|
+
if (m && m.preMatch === true) pre.push(m);
|
|
268
|
+
else post.push(m);
|
|
269
|
+
}
|
|
270
|
+
return { pre, post };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
static getGlobal(): any[] {
|
|
274
|
+
return [...MiddlewareRunner.globalMiddleware];
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Clear all globally registered middleware (primarily for tests). */
|
|
278
|
+
static reset(): void {
|
|
279
|
+
MiddlewareRunner.globalMiddleware = [];
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Discover the before-prefixed / after-prefixed hook names on a middleware
|
|
284
|
+
* class, INHERITED HOOKS INCLUDED, base class first (M1).
|
|
285
|
+
*
|
|
286
|
+
* `Object.getOwnPropertyNames` returns a class's OWN statics only, in
|
|
287
|
+
* source-declaration order. On its own that silently DROPPED every hook a
|
|
288
|
+
* subclass inherited: for `class Sub extends Base` with `static beforeBase`
|
|
289
|
+
* on the base, discovery returned only ["beforeSub"] even though
|
|
290
|
+
* `Sub.beforeBase` is a live function — so a shared base middleware simply
|
|
291
|
+
* never ran, with no error. Python returns ['before_base','before_sub'] and
|
|
292
|
+
* Ruby [:before_base,:before_sub]; Node was the only one of the four that
|
|
293
|
+
* lost hooks.
|
|
294
|
+
*
|
|
295
|
+
* So walk the prototype chain (the STATIC side: Sub -> Base -> ...) and emit
|
|
296
|
+
* base-class hooks BEFORE the subclass's own, de-duping an override to its
|
|
297
|
+
* first (base) position. That is exactly Python's `_discover_methods`
|
|
298
|
+
* walking `reversed(__mro__)` over each `__dict__`, and Ruby's
|
|
299
|
+
* `discover_methods` walking `ancestors.reverse_each`.
|
|
300
|
+
*
|
|
301
|
+
* Within one class the order is still source-declaration order — we
|
|
302
|
+
* deliberately do NOT sort(), so hooks run in the order they were written
|
|
303
|
+
* (parity with Python walking __dict__, PHP get_class_methods, Ruby
|
|
304
|
+
* instance_methods(false)). Cross-class order is the natural iteration of
|
|
305
|
+
* the registered classes = REGISTRATION order.
|
|
306
|
+
*
|
|
307
|
+
* The chain walk stops at Function.prototype / Object.prototype, so the
|
|
308
|
+
* built-in members are never scanned. A plain object registered as
|
|
309
|
+
* middleware still works: its own keys are level 0.
|
|
310
|
+
*/
|
|
311
|
+
private static methodNames(cls: any, prefix: string): string[] {
|
|
312
|
+
const levels: string[][] = [];
|
|
313
|
+
for (
|
|
314
|
+
let level: any = cls;
|
|
315
|
+
level && level !== Function.prototype && level !== Object.prototype;
|
|
316
|
+
level = Object.getPrototypeOf(level)
|
|
317
|
+
) {
|
|
318
|
+
levels.push(
|
|
319
|
+
Object.getOwnPropertyNames(level).filter(
|
|
320
|
+
(name) => name.startsWith(prefix) && typeof cls[name] === "function",
|
|
321
|
+
),
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const seen = new Set<string>();
|
|
326
|
+
const names: string[] = [];
|
|
327
|
+
// Reverse the levels: base class first, then each derived class.
|
|
328
|
+
for (let i = levels.length - 1; i >= 0; i--) {
|
|
329
|
+
for (const name of levels[i]) {
|
|
330
|
+
if (seen.has(name)) continue;
|
|
331
|
+
seen.add(name);
|
|
332
|
+
names.push(name);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return names;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Execute every beforeX static method found on the supplied classes.
|
|
340
|
+
*
|
|
341
|
+
* ORDER (M1): cross-class = REGISTRATION order (the order classes were
|
|
342
|
+
* attached via Router.use / MiddlewareRunner.use); within a class =
|
|
343
|
+
* DEFINITION order (source order, never alphabetical). before_* run before
|
|
344
|
+
* the handler.
|
|
345
|
+
*
|
|
346
|
+
* THROW (M2): each before* call is wrapped — a throwing middleware is
|
|
347
|
+
* LOGGED and produces a deterministic clean 500 (it never crashes the
|
|
348
|
+
* worker / leaks an unhandled exception), and the chain short-circuits
|
|
349
|
+
* (skip = true, handler skipped).
|
|
350
|
+
*
|
|
351
|
+
* Short-circuits (skip = true, handler skipped) when a before* sets a
|
|
352
|
+
* status >= 400 or ends/500s the response.
|
|
353
|
+
*
|
|
354
|
+
* ASYNC — each hook is awaited so middleware can perform async work (e.g.
|
|
355
|
+
* the distributed responseCache before-hook awaiting `backend.get`). Awaiting
|
|
356
|
+
* a synchronous hook that returns an array is harmless (the array resolves
|
|
357
|
+
* immediately), so existing sync hooks keep working unchanged.
|
|
358
|
+
*
|
|
359
|
+
* RETURN VALUE — see `interpretHookResult` for the one table every hook at
|
|
360
|
+
* every scope obeys. A returned Response object is the PRIMARY
|
|
361
|
+
* short-circuit; `false` is a deny.
|
|
362
|
+
*
|
|
363
|
+
* Returns [req, res, shouldContinue].
|
|
364
|
+
*/
|
|
365
|
+
static async runBefore(
|
|
366
|
+
classes: any[],
|
|
367
|
+
req: Tina4Request,
|
|
368
|
+
res: Tina4Response,
|
|
369
|
+
): Promise<[Tina4Request, Tina4Response, boolean]> {
|
|
370
|
+
for (const cls of classes) {
|
|
371
|
+
for (const method of MiddlewareRunner.methodNames(cls, "before")) {
|
|
372
|
+
try {
|
|
373
|
+
const [nextReq, nextRes, stop] =
|
|
374
|
+
await interpretHookResult(await cls[method](req, res), req, res);
|
|
375
|
+
req = nextReq;
|
|
376
|
+
res = nextRes;
|
|
377
|
+
if (stop) return [req, res, false];
|
|
378
|
+
} catch (error) {
|
|
379
|
+
// Throw → logged clean 500, skip the handler (deterministic).
|
|
380
|
+
res = middleware500(res, cls, method, error);
|
|
381
|
+
return [req, res, false];
|
|
382
|
+
}
|
|
383
|
+
// LEGACY COMPAT PATH — retained, but NOT the main mechanism. A hook
|
|
384
|
+
// that returns nothing and merely leaves an error status (or an ended
|
|
385
|
+
// response) still short-circuits, so middleware written before the
|
|
386
|
+
// return-value contract keeps working. It cannot express a 3xx
|
|
387
|
+
// redirect, which is exactly why a returned Response is the primary
|
|
388
|
+
// rule above.
|
|
389
|
+
if (res.raw.statusCode >= 400 || res.raw.writableEnded) {
|
|
390
|
+
return [req, res, false];
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return [req, res, true];
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Execute every afterX static method found on the supplied classes.
|
|
399
|
+
*
|
|
400
|
+
* ORDER (M1): cross-class = REGISTRATION order; within a class = DEFINITION
|
|
401
|
+
* order. after_* run after the handler.
|
|
402
|
+
*
|
|
403
|
+
* THROW (M2): each after* call is wrapped — a throwing after middleware is
|
|
404
|
+
* LOGGED and produces a clean 500, then the remaining after* STILL run
|
|
405
|
+
* (they may add headers / logging). No unhandled exception leaks.
|
|
406
|
+
*
|
|
407
|
+
* AFTER-ON-4xx RULE (M2): after_* ALWAYS run, even when a before_*
|
|
408
|
+
* short-circuited with status >= 400 and the handler was skipped — so they
|
|
409
|
+
* can still add headers / logging. The dispatcher calls runAfter
|
|
410
|
+
* unconditionally after the before/handler block (see server.ts).
|
|
411
|
+
*
|
|
412
|
+
* ASYNC — each hook is awaited (e.g. the responseCache after-hook awaiting
|
|
413
|
+
* `backend.set`). Awaiting a synchronous hook is harmless, so existing sync
|
|
414
|
+
* after-hooks keep working unchanged.
|
|
415
|
+
*
|
|
416
|
+
* RETURN VALUE — the SAME table as runBefore (`interpretHookResult`): the
|
|
417
|
+
* contract is one table for every hook at every scope. There is nothing left
|
|
418
|
+
* to skip after the handler, so a short-circuit here ends the after chain.
|
|
419
|
+
* A THROW is different and unchanged: it is logged, becomes a clean 500, and
|
|
420
|
+
* the remaining after hooks still run.
|
|
421
|
+
*/
|
|
422
|
+
static async runAfter(
|
|
423
|
+
classes: any[],
|
|
424
|
+
req: Tina4Request,
|
|
425
|
+
res: Tina4Response,
|
|
426
|
+
): Promise<[Tina4Request, Tina4Response]> {
|
|
427
|
+
for (const cls of classes) {
|
|
428
|
+
for (const method of MiddlewareRunner.methodNames(cls, "after")) {
|
|
429
|
+
try {
|
|
430
|
+
const [nextReq, nextRes, stop] =
|
|
431
|
+
await interpretHookResult(await cls[method](req, res), req, res);
|
|
432
|
+
req = nextReq;
|
|
433
|
+
res = nextRes;
|
|
434
|
+
if (stop) return [req, res];
|
|
435
|
+
} catch (error) {
|
|
436
|
+
// Throw → logged clean 500, but remaining after* STILL run.
|
|
437
|
+
res = middleware500(res, cls, method, error);
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
return [req, res];
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// ── Built-in class-based middleware ─────────────────────────────
|
|
447
|
+
|
|
448
|
+
/** Configuration for the CORS middleware */
|
|
449
|
+
export interface CorsConfig {
|
|
450
|
+
/** Allowed origins. Default: NONE (deny) — or TINA4_CORS_ORIGINS env, comma-separated. "*" allows any. */
|
|
451
|
+
origins?: string | string[];
|
|
452
|
+
/** Allowed methods. Default: standard REST methods (or TINA4_CORS_METHODS env) */
|
|
453
|
+
methods?: string | string[];
|
|
454
|
+
/** Allowed headers. Default: Content-Type, Authorization (or TINA4_CORS_HEADERS env) */
|
|
455
|
+
headers?: string | string[];
|
|
456
|
+
/** Access-Control-Max-Age in seconds. Default: 86400 (or TINA4_CORS_MAX_AGE env) */
|
|
457
|
+
maxAge?: number;
|
|
458
|
+
/** Send Access-Control-Allow-Credentials. Default: false (or TINA4_CORS_CREDENTIALS env). Never sent with a wildcard origin. */
|
|
459
|
+
credentials?: boolean;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** Warn-once ledger so a scripted probe cannot flood the log. */
|
|
463
|
+
const corsWarned = new Set<string>();
|
|
464
|
+
|
|
465
|
+
/** Reset the CORS warn-once ledger. Test seam. */
|
|
466
|
+
export function resetCorsWarnings(): void {
|
|
467
|
+
corsWarned.clear();
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function corsWarnOnce(key: string, message: string): void {
|
|
471
|
+
if (corsWarned.has(key)) return;
|
|
472
|
+
corsWarned.add(key);
|
|
473
|
+
Log.warning(message);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* The resolved CORS policy — ONE implementation of the rules.
|
|
478
|
+
*
|
|
479
|
+
* Both the function middleware `cors()` and the class middleware
|
|
480
|
+
* `CorsMiddleware` build one of these and apply what it returns. They used to
|
|
481
|
+
* be two independent implementations that had already drifted: `cors()` never
|
|
482
|
+
* read TINA4_CORS_CREDENTIALS at all, so the DEFAULT always-on pipeline
|
|
483
|
+
* silently ignored a documented env var (measured 2026-07-31). One feature,
|
|
484
|
+
* one code path.
|
|
485
|
+
*
|
|
486
|
+
* DENY BY DEFAULT (ADR-0018). With no origins configured, NO
|
|
487
|
+
* Access-Control-Allow-Origin is emitted and the browser's own CORS check
|
|
488
|
+
* blocks the cross-origin request. "*" still works, it just has to be asked for.
|
|
489
|
+
*
|
|
490
|
+
* CREDENTIALS AND THE WILDCARD ARE MUTUALLY EXCLUSIVE. The Fetch Standard's
|
|
491
|
+
* CORS check treats "*" as a literal (not a wildcard) once the request's
|
|
492
|
+
* credentials mode is "include", so ACAO: * with
|
|
493
|
+
* Access-Control-Allow-Credentials: true is rejected by every browser.
|
|
494
|
+
*
|
|
495
|
+
* VARY: ORIGIN whenever the ACAO value is COMPUTED from the request's Origin,
|
|
496
|
+
* i.e. whenever an allow-list is configured — on a MISS as well as a match.
|
|
497
|
+
* RFC 9110 s12.5.5: a Vary field name list tells cache recipients they "MUST
|
|
498
|
+
* NOT use this response to satisfy a later request unless the later request
|
|
499
|
+
* has the same values for the listed header fields as the original request".
|
|
500
|
+
* The miss case matters most: without it a shared cache can store the no-ACAO
|
|
501
|
+
* response for origin B and serve it to origin A. A constant "*" genuinely
|
|
502
|
+
* does not vary and gets no Vary, which would only fragment a CDN's cache.
|
|
503
|
+
*
|
|
504
|
+
* Access-Control-Allow-Methods / -Allow-Headers are static configured lists
|
|
505
|
+
* here, never derived from the request's Access-Control-Request-* headers, so
|
|
506
|
+
* those field names do NOT belong in Vary.
|
|
507
|
+
*/
|
|
508
|
+
export class CorsPolicy {
|
|
509
|
+
readonly allowedOrigins: string[];
|
|
510
|
+
readonly allowedMethods: string;
|
|
511
|
+
readonly allowedHeaders: string;
|
|
512
|
+
readonly maxAge: number;
|
|
513
|
+
readonly credentials: boolean;
|
|
514
|
+
|
|
515
|
+
constructor(config?: CorsConfig) {
|
|
516
|
+
// Default is EMPTY, not "*" — deny by default (ADR-0018).
|
|
517
|
+
const originsRaw = config?.origins ?? process.env.TINA4_CORS_ORIGINS ?? "";
|
|
518
|
+
const list = Array.isArray(originsRaw) ? originsRaw : originsRaw.split(",");
|
|
519
|
+
this.allowedOrigins = list.map((o) => o.trim()).filter((o) => o !== "");
|
|
520
|
+
|
|
521
|
+
const methodsRaw = config?.methods
|
|
522
|
+
?? process.env.TINA4_CORS_METHODS
|
|
523
|
+
?? "GET, POST, PUT, DELETE, PATCH, OPTIONS";
|
|
524
|
+
this.allowedMethods = Array.isArray(methodsRaw) ? methodsRaw.join(", ") : methodsRaw;
|
|
525
|
+
|
|
526
|
+
const headersRaw = config?.headers
|
|
527
|
+
?? process.env.TINA4_CORS_HEADERS
|
|
528
|
+
?? "Content-Type,Authorization,X-Request-ID";
|
|
529
|
+
this.allowedHeaders = Array.isArray(headersRaw) ? headersRaw.join(", ") : headersRaw;
|
|
530
|
+
|
|
531
|
+
this.maxAge = config?.maxAge
|
|
532
|
+
?? (process.env.TINA4_CORS_MAX_AGE ? parseInt(process.env.TINA4_CORS_MAX_AGE, 10) : 86400);
|
|
533
|
+
|
|
534
|
+
this.credentials = config?.credentials
|
|
535
|
+
?? ["true", "1", "yes"].includes((process.env.TINA4_CORS_CREDENTIALS ?? "false").toLowerCase());
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** Whether an operator has actually declared a CORS policy. */
|
|
539
|
+
isConfigured(): boolean {
|
|
540
|
+
return this.allowedOrigins.length > 0;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** The origin to send in Access-Control-Allow-Origin, or undefined for none. */
|
|
544
|
+
resolveOrigin(requestOrigin: string): string | undefined {
|
|
545
|
+
if (this.allowedOrigins.length === 0) return undefined;
|
|
546
|
+
if (this.allowedOrigins.includes("*")) return "*";
|
|
547
|
+
if (requestOrigin && this.allowedOrigins.includes(requestOrigin)) return requestOrigin;
|
|
548
|
+
return undefined;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* The CORS headers for a request origin. `isPreflight` adds Max-Age, which
|
|
553
|
+
* the Fetch Standard only defines for a preflight response.
|
|
554
|
+
*/
|
|
555
|
+
headersFor(requestOrigin: string, isPreflight: boolean): Record<string, string> {
|
|
556
|
+
if (this.allowedOrigins.length === 0) {
|
|
557
|
+
if (requestOrigin) {
|
|
558
|
+
corsWarnOnce("unconfigured",
|
|
559
|
+
`CORS: refused cross-origin request from ${requestOrigin} — no policy is configured. `
|
|
560
|
+
+ "Set TINA4_CORS_ORIGINS to the origins you want to allow, e.g. "
|
|
561
|
+
+ "TINA4_CORS_ORIGINS=https://app.example.com (or '*' to allow any origin).");
|
|
562
|
+
}
|
|
563
|
+
return {};
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const out: Record<string, string> = {};
|
|
567
|
+
if (!this.allowedOrigins.includes("*")) {
|
|
568
|
+
out["Vary"] = "Origin";
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const origin = this.resolveOrigin(requestOrigin);
|
|
572
|
+
if (origin === undefined) {
|
|
573
|
+
if (requestOrigin) {
|
|
574
|
+
corsWarnOnce(`denied:${requestOrigin}`,
|
|
575
|
+
`CORS: origin ${requestOrigin} is not in TINA4_CORS_ORIGINS `
|
|
576
|
+
+ `(${this.allowedOrigins.join(",")}) — the browser will block this response.`);
|
|
577
|
+
}
|
|
578
|
+
return out;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
out["Access-Control-Allow-Origin"] = origin;
|
|
582
|
+
out["Access-Control-Allow-Methods"] = this.allowedMethods;
|
|
583
|
+
out["Access-Control-Allow-Headers"] = this.allowedHeaders;
|
|
584
|
+
if (isPreflight) out["Access-Control-Max-Age"] = String(this.maxAge);
|
|
585
|
+
|
|
586
|
+
if (this.credentials) {
|
|
587
|
+
if (origin === "*") {
|
|
588
|
+
corsWarnOnce("wildcard-credentials",
|
|
589
|
+
"CORS: TINA4_CORS_CREDENTIALS is true but TINA4_CORS_ORIGINS is '*'. The Fetch Standard "
|
|
590
|
+
+ "forbids Access-Control-Allow-Origin: * with credentials, so credentials are NOT being "
|
|
591
|
+
+ "sent. Credentialed CORS requires an explicit origin list, e.g. "
|
|
592
|
+
+ "TINA4_CORS_ORIGINS=https://app.example.com.");
|
|
593
|
+
} else {
|
|
594
|
+
out["Access-Control-Allow-Credentials"] = "true";
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
return out;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** Fold a Vary field name into whatever Vary the response already carries. */
|
|
602
|
+
function applyCorsHeaders(res: Tina4Response, headers: Record<string, string>): void {
|
|
603
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
604
|
+
if (name === "Vary") {
|
|
605
|
+
const current = String((res as { raw?: { getHeader?(n: string): unknown } }).raw?.getHeader?.("Vary") ?? "");
|
|
606
|
+
const parts = current.split(",").map((p) => p.trim()).filter((p) => p !== "");
|
|
607
|
+
if (!parts.some((p) => p.toLowerCase() === value.toLowerCase())) parts.push(value);
|
|
608
|
+
res.header(name, parts.join(", "));
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
res.header(name, value);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Is this a REAL CORS preflight (as opposed to a bare protocol-introspection
|
|
617
|
+
* OPTIONS)? A preflight carries an Origin — browsers always send one. A bare
|
|
618
|
+
* OPTIONS does not, and belongs to the RFC 9110 s9.3.7 handler in dispatch.
|
|
619
|
+
*/
|
|
620
|
+
function isCorsPreflight(method: string | undefined, requestOrigin: string): boolean {
|
|
621
|
+
return method === "OPTIONS" && requestOrigin !== "";
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** The Allow header for a path, from the LIVE router. */
|
|
625
|
+
function allowHeaderForUrl(url: string | undefined): string {
|
|
626
|
+
const pathname = new URL(url ?? "/", "http://localhost").pathname;
|
|
627
|
+
// startServer builds its own Router and publishes it on globalThis;
|
|
628
|
+
// defaultRouter is the module-level instance used by the standalone
|
|
629
|
+
// get()/post() helpers. A file-routed app registers nothing in the latter,
|
|
630
|
+
// so reading it alone returned an empty method set and stamped Allow: "".
|
|
631
|
+
const liveRouter = (globalThis as { __tina4_router?: Router }).__tina4_router ?? defaultRouter;
|
|
632
|
+
return liveRouter.methodsAllowedForPath(pathname).join(", ");
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Built-in CORS middleware (function form).
|
|
637
|
+
*
|
|
638
|
+
* A thin adapter over CorsPolicy — see that class for the rules and the
|
|
639
|
+
* standards behind them. Reads configuration from env vars when not provided:
|
|
640
|
+
* TINA4_CORS_ORIGINS — comma-separated list of allowed origins, or "*"
|
|
641
|
+
* TINA4_CORS_METHODS — comma-separated list of allowed methods
|
|
642
|
+
* TINA4_CORS_HEADERS — comma-separated list of allowed headers
|
|
643
|
+
* TINA4_CORS_MAX_AGE — preflight cache duration in seconds
|
|
644
|
+
* TINA4_CORS_CREDENTIALS — send Access-Control-Allow-Credentials
|
|
645
|
+
*
|
|
646
|
+
* A real preflight is answered 204. The status is the same whether the origin
|
|
647
|
+
* was allowed or denied — the browser does the blocking.
|
|
648
|
+
*/
|
|
649
|
+
export function cors(config?: CorsConfig): Middleware {
|
|
650
|
+
const policy = new CorsPolicy(config);
|
|
651
|
+
|
|
652
|
+
return (req, res, next) => {
|
|
653
|
+
const requestOrigin = req.headers.origin ?? "";
|
|
654
|
+
const preflight = isCorsPreflight(req.method, requestOrigin);
|
|
655
|
+
|
|
656
|
+
applyCorsHeaders(res as Tina4Response, policy.headersFor(requestOrigin, preflight));
|
|
657
|
+
|
|
658
|
+
if (preflight) {
|
|
659
|
+
// Carry the resource's REAL method set as Allow (RFC 9110 s9.3.7): a
|
|
660
|
+
// preflight IS an OPTIONS response, so it answers the same question a
|
|
661
|
+
// bare OPTIONS does, on top of the CORS policy headers. This is
|
|
662
|
+
// CONFORMANCE, not a deviation — Django's View.options() and Express's
|
|
663
|
+
// router already emit Allow; the add-on CORS libraries lose it only
|
|
664
|
+
// because they short-circuit ahead of the framework. See ADR-0013.
|
|
665
|
+
//
|
|
666
|
+
// Allow and Access-Control-Allow-Methods are NOT interchangeable: Allow
|
|
667
|
+
// is what the resource supports, ACAM is what the CORS policy permits
|
|
668
|
+
// cross-origin. A policy allowing DELETE on a GET-only route still 405s.
|
|
669
|
+
res.header("Allow", allowHeaderForUrl(req.url));
|
|
670
|
+
res(null, 204);
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
next();
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Class-based CORS middleware using the before/after convention.
|
|
680
|
+
*
|
|
681
|
+
* The same CorsPolicy as `cors()` — one implementation, one set of semantics.
|
|
682
|
+
*
|
|
683
|
+
* Usage:
|
|
684
|
+
* Router.use(CorsMiddleware);
|
|
685
|
+
*/
|
|
686
|
+
export class CorsMiddleware {
|
|
687
|
+
static beforeCors(req: Tina4Request, res: Tina4Response): [Tina4Request, Tina4Response] {
|
|
688
|
+
const requestOrigin = req.headers.origin ?? "";
|
|
689
|
+
const preflight = isCorsPreflight(req.method, requestOrigin);
|
|
690
|
+
|
|
691
|
+
applyCorsHeaders(res, new CorsPolicy().headersFor(requestOrigin, preflight));
|
|
692
|
+
|
|
693
|
+
if (preflight) {
|
|
694
|
+
res.header("Allow", allowHeaderForUrl(req.url));
|
|
695
|
+
res(null, 204);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
return [req, res];
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* Check if a request is an OPTIONS preflight.
|
|
703
|
+
*
|
|
704
|
+
* NOTE: returns true for ANY OPTIONS, with no Origin check, so the name
|
|
705
|
+
* overstates what it tests. The real short-circuit uses isCorsPreflight().
|
|
706
|
+
* Kept because existing tests pin this meaning.
|
|
707
|
+
*/
|
|
708
|
+
static isPreflight(method: string): boolean {
|
|
709
|
+
return method?.toUpperCase() === "OPTIONS";
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* Class-based rate limiter middleware using the before/after convention.
|
|
715
|
+
* Uses the same sliding-window algorithm as the `rateLimiter()` function.
|
|
716
|
+
*
|
|
717
|
+
* Reads configuration from env vars:
|
|
718
|
+
* TINA4_RATE_LIMIT — max requests per window (default 100)
|
|
719
|
+
* TINA4_RATE_WINDOW — window duration in seconds (default 60)
|
|
720
|
+
*
|
|
721
|
+
* Usage:
|
|
722
|
+
* Router.use(RateLimiterMiddleware);
|
|
723
|
+
*/
|
|
724
|
+
export class RateLimiterMiddleware {
|
|
725
|
+
private static store = new Map<string, { timestamps: number[] }>();
|
|
726
|
+
private static cleanupTimer: ReturnType<typeof setInterval> | null = null;
|
|
727
|
+
|
|
728
|
+
private static ensureCleanup(windowMs: number): void {
|
|
729
|
+
if (RateLimiterMiddleware.cleanupTimer) return;
|
|
730
|
+
RateLimiterMiddleware.cleanupTimer = setInterval(() => {
|
|
731
|
+
const now = Date.now();
|
|
732
|
+
const cutoff = now - windowMs;
|
|
733
|
+
for (const [ip, entry] of RateLimiterMiddleware.store) {
|
|
734
|
+
entry.timestamps = entry.timestamps.filter((t) => t > cutoff);
|
|
735
|
+
if (entry.timestamps.length === 0) {
|
|
736
|
+
RateLimiterMiddleware.store.delete(ip);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
}, 60_000);
|
|
740
|
+
if (RateLimiterMiddleware.cleanupTimer.unref) {
|
|
741
|
+
RateLimiterMiddleware.cleanupTimer.unref();
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
static beforeRateLimit(req: Tina4Request, res: Tina4Response): [Tina4Request, Tina4Response] {
|
|
746
|
+
const limit = process.env.TINA4_RATE_LIMIT
|
|
747
|
+
? parseInt(process.env.TINA4_RATE_LIMIT, 10)
|
|
748
|
+
: 100;
|
|
749
|
+
const windowSeconds = process.env.TINA4_RATE_WINDOW
|
|
750
|
+
? parseInt(process.env.TINA4_RATE_WINDOW, 10)
|
|
751
|
+
: 60;
|
|
752
|
+
const windowMs = windowSeconds * 1000;
|
|
753
|
+
|
|
754
|
+
RateLimiterMiddleware.ensureCleanup(windowMs);
|
|
755
|
+
|
|
756
|
+
const now = Date.now();
|
|
757
|
+
const cutoff = now - windowMs;
|
|
758
|
+
|
|
759
|
+
// Client key. X-Forwarded-For is honoured ONLY when the socket peer is a
|
|
760
|
+
// declared trusted proxy (TINA4_TRUSTED_PROXIES). ADR-0019.
|
|
761
|
+
const ip = resolveClientIp(req.headers, req.socket?.remoteAddress ?? "") || "unknown";
|
|
762
|
+
|
|
763
|
+
let entry = RateLimiterMiddleware.store.get(ip);
|
|
764
|
+
if (!entry) {
|
|
765
|
+
entry = { timestamps: [] };
|
|
766
|
+
RateLimiterMiddleware.store.set(ip, entry);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
entry.timestamps = entry.timestamps.filter((t) => t > cutoff);
|
|
770
|
+
|
|
771
|
+
const resetTimestamp = entry.timestamps.length > 0
|
|
772
|
+
? Math.ceil((entry.timestamps[0] + windowMs) / 1000)
|
|
773
|
+
: Math.ceil((now + windowMs) / 1000);
|
|
774
|
+
|
|
775
|
+
const remaining = Math.max(0, limit - entry.timestamps.length);
|
|
776
|
+
|
|
777
|
+
res.header("X-RateLimit-Limit", String(limit));
|
|
778
|
+
res.header("X-RateLimit-Remaining", String(Math.max(0, remaining - 1)));
|
|
779
|
+
res.header("X-RateLimit-Reset", String(resetTimestamp));
|
|
780
|
+
|
|
781
|
+
if (entry.timestamps.length >= limit) {
|
|
782
|
+
const retryAfter = Math.max(1, resetTimestamp - Math.ceil(now / 1000));
|
|
783
|
+
res.header("Retry-After", String(retryAfter));
|
|
784
|
+
res.header("X-RateLimit-Remaining", "0");
|
|
785
|
+
res({
|
|
786
|
+
error: "Too Many Requests",
|
|
787
|
+
statusCode: 429,
|
|
788
|
+
message: `Rate limit exceeded. Try again in ${retryAfter} seconds.`,
|
|
789
|
+
}, 429);
|
|
790
|
+
return [req, res];
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
entry.timestamps.push(now);
|
|
794
|
+
return [req, res];
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* Check if an IP is within rate limits without recording a request.
|
|
799
|
+
* Returns [allowed, info] matching Python/Ruby API.
|
|
800
|
+
*/
|
|
801
|
+
static check(ip: string): [boolean, { limit: number; remaining: number; reset: number; window: number }] {
|
|
802
|
+
const limit = process.env.TINA4_RATE_LIMIT ? parseInt(process.env.TINA4_RATE_LIMIT, 10) : 100;
|
|
803
|
+
const windowSeconds = process.env.TINA4_RATE_WINDOW ? parseInt(process.env.TINA4_RATE_WINDOW, 10) : 60;
|
|
804
|
+
const windowMs = windowSeconds * 1000;
|
|
805
|
+
const now = Date.now();
|
|
806
|
+
const cutoff = now - windowMs;
|
|
807
|
+
|
|
808
|
+
let entry = RateLimiterMiddleware.store.get(ip);
|
|
809
|
+
if (!entry) {
|
|
810
|
+
entry = { timestamps: [] };
|
|
811
|
+
RateLimiterMiddleware.store.set(ip, entry);
|
|
812
|
+
}
|
|
813
|
+
entry.timestamps = entry.timestamps.filter((t) => t > cutoff);
|
|
814
|
+
|
|
815
|
+
const remaining = Math.max(0, limit - entry.timestamps.length);
|
|
816
|
+
const reset = entry.timestamps.length > 0
|
|
817
|
+
? Math.ceil((entry.timestamps[0] + windowMs - now) / 1000)
|
|
818
|
+
: windowSeconds;
|
|
819
|
+
|
|
820
|
+
if (entry.timestamps.length >= limit) {
|
|
821
|
+
return [false, { limit, remaining: 0, reset, window: windowSeconds }];
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
return [true, { limit, remaining: remaining - 1, reset: windowSeconds, window: windowSeconds }];
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* Class-based request logger middleware using the before/after convention.
|
|
830
|
+
* `beforeLog` stamps the request start time.
|
|
831
|
+
* `afterLog` prints the coloured status line.
|
|
832
|
+
*
|
|
833
|
+
* Usage:
|
|
834
|
+
* Router.use(RequestLogger);
|
|
835
|
+
*/
|
|
836
|
+
export class RequestLogger {
|
|
837
|
+
static beforeLog(req: Tina4Request, res: Tina4Response): [Tina4Request, Tina4Response] {
|
|
838
|
+
(req as any).startTime = Date.now();
|
|
839
|
+
return [req, res];
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
static afterLog(req: Tina4Request, res: Tina4Response): [Tina4Request, Tina4Response] {
|
|
843
|
+
const duration = Date.now() - ((req as any).startTime ?? Date.now());
|
|
844
|
+
const status = res.raw.statusCode;
|
|
845
|
+
const method = req.method ?? "?";
|
|
846
|
+
const url = req.url ?? "/";
|
|
847
|
+
const color = status >= 400 ? "\x1b[31m" : status >= 300 ? "\x1b[33m" : "\x1b[32m";
|
|
848
|
+
console.log(` ${color}${status}\x1b[0m ${method} ${url} \x1b[90m${duration}ms\x1b[0m`);
|
|
849
|
+
return [req, res];
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* Class-based security headers middleware using the before/after convention.
|
|
855
|
+
* Auto-injects security headers on every response.
|
|
856
|
+
*
|
|
857
|
+
* Configuration via env vars:
|
|
858
|
+
* TINA4_FRAME_OPTIONS — X-Frame-Options (default: "SAMEORIGIN")
|
|
859
|
+
* TINA4_HSTS — Strict-Transport-Security max-age value
|
|
860
|
+
* (default: "" = off; set to "31536000" to enable)
|
|
861
|
+
* TINA4_CSP — Content-Security-Policy (default: "default-src 'self'")
|
|
862
|
+
* TINA4_REFERRER_POLICY — Referrer-Policy (default: "strict-origin-when-cross-origin")
|
|
863
|
+
* TINA4_PERMISSIONS_POLICY — Permissions-Policy (default: "camera=(), microphone=(), geolocation=()")
|
|
864
|
+
*
|
|
865
|
+
* Usage:
|
|
866
|
+
* Router.use(SecurityHeadersMiddleware);
|
|
867
|
+
*/
|
|
868
|
+
export class SecurityHeadersMiddleware {
|
|
869
|
+
static beforeSecurity(req: Tina4Request, res: Tina4Response): [Tina4Request, Tina4Response] {
|
|
870
|
+
res.header(
|
|
871
|
+
"X-Frame-Options",
|
|
872
|
+
process.env.TINA4_FRAME_OPTIONS ?? "SAMEORIGIN",
|
|
873
|
+
);
|
|
874
|
+
|
|
875
|
+
res.header("X-Content-Type-Options", "nosniff");
|
|
876
|
+
|
|
877
|
+
// HSTS is HTTPS-only (SECHDR-DEC-02): a downgrade-protection header on a
|
|
878
|
+
// plain-HTTP response is inert at best and ships a bad max-age on an
|
|
879
|
+
// unencrypted scheme at worst. Emit it ONLY when TINA4_HSTS is set AND the
|
|
880
|
+
// request is HTTPS (x-forwarded-proto first hop, else the native TLS socket)
|
|
881
|
+
// — the same proxy-aware scheme the session cookie's Secure flag uses.
|
|
882
|
+
const hsts = process.env.TINA4_HSTS ?? "";
|
|
883
|
+
if (hsts && SecurityHeadersMiddleware.isSecureRequest(req)) {
|
|
884
|
+
res.header(
|
|
885
|
+
"Strict-Transport-Security",
|
|
886
|
+
`max-age=${hsts}; includeSubDomains`,
|
|
887
|
+
);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
if (process.env.TINA4_CSP === undefined) {
|
|
891
|
+
SecurityHeadersMiddleware.warnCspDefaultOnce();
|
|
892
|
+
}
|
|
893
|
+
res.header(
|
|
894
|
+
"Content-Security-Policy",
|
|
895
|
+
process.env.TINA4_CSP ?? "default-src 'self'",
|
|
896
|
+
);
|
|
897
|
+
|
|
898
|
+
res.header(
|
|
899
|
+
"Referrer-Policy",
|
|
900
|
+
process.env.TINA4_REFERRER_POLICY ?? "strict-origin-when-cross-origin",
|
|
901
|
+
);
|
|
902
|
+
|
|
903
|
+
res.header("X-XSS-Protection", "0");
|
|
904
|
+
|
|
905
|
+
res.header(
|
|
906
|
+
"Permissions-Policy",
|
|
907
|
+
process.env.TINA4_PERMISSIONS_POLICY ?? "camera=(), microphone=(), geolocation=()",
|
|
908
|
+
);
|
|
909
|
+
|
|
910
|
+
return [req, res];
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/** Warn-once ledger for the default-CSP heads-up (per process). */
|
|
914
|
+
private static cspDefaultWarned = false;
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* Warn once per process that the default CSP is in force (TINA4_CSP unset).
|
|
918
|
+
*
|
|
919
|
+
* Secure-by-default keeps `default-src 'self'` (SECHDR-DEC-01), but that
|
|
920
|
+
* default is invisible: it blocks runtime-injected inline styles, cross-origin
|
|
921
|
+
* fonts/scripts/CDNs, `data:` URIs, and cross-origin WebSocket/XHR (a separate
|
|
922
|
+
* API or LiveKit host) — and the failure surfaces only in the browser at
|
|
923
|
+
* runtime, long after a deploy has gone green. So the framework says so once,
|
|
924
|
+
* naming the escape hatch. It NEVER fails the boot or a request — logging a
|
|
925
|
+
* heads-up must not be the reason the server or a request dies. Fires only when
|
|
926
|
+
* TINA4_CSP is ABSENT; setting it (even to empty) is an explicit opt-in.
|
|
927
|
+
*/
|
|
928
|
+
private static warnCspDefaultOnce(): void {
|
|
929
|
+
if (SecurityHeadersMiddleware.cspDefaultWarned) return;
|
|
930
|
+
SecurityHeadersMiddleware.cspDefaultWarned = true;
|
|
931
|
+
const message =
|
|
932
|
+
"TINA4_CSP is not set, so Tina4 is serving the default Content-Security-Policy " +
|
|
933
|
+
"\"default-src 'self'\" on every response. That default blocks runtime-injected " +
|
|
934
|
+
"inline styles, cross-origin fonts/scripts/CDNs, data: URIs, and cross-origin " +
|
|
935
|
+
"WebSocket/XHR (e.g. a separate API or LiveKit host). If your app uses any of " +
|
|
936
|
+
"these, set TINA4_CSP to a policy that allows them (see https://tina4.com); to " +
|
|
937
|
+
"silence this notice without changing behaviour, set TINA4_CSP=\"default-src 'self'\".";
|
|
938
|
+
try {
|
|
939
|
+
Log.warning(message);
|
|
940
|
+
} catch {
|
|
941
|
+
// Logging must never break a request.
|
|
942
|
+
console.warn(message);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* True when the client request is HTTPS. Proxy-aware and byte-parity with
|
|
948
|
+
* Python (request.is_secure_scheme), PHP (Request::isSecureScheme) and Ruby
|
|
949
|
+
* (Request.secure_scheme?): a TLS-terminating proxy forwards plain HTTP with
|
|
950
|
+
* `x-forwarded-proto`, whose FIRST hop is the client-facing scheme; falling
|
|
951
|
+
* back to the native TLS socket when no such header is present. Its name is
|
|
952
|
+
* not before- or after-prefixed, so hook discovery never calls it as a hook.
|
|
953
|
+
*/
|
|
954
|
+
private static isSecureRequest(req: Tina4Request): boolean {
|
|
955
|
+
const xfProto = (req.headers as Record<string, string | string[] | undefined>)[
|
|
956
|
+
"x-forwarded-proto"
|
|
957
|
+
];
|
|
958
|
+
const firstHop = (Array.isArray(xfProto) ? xfProto[0] : xfProto)
|
|
959
|
+
?.split(",")[0]
|
|
960
|
+
?.trim()
|
|
961
|
+
.toLowerCase();
|
|
962
|
+
if (firstHop) return firstHop === "https";
|
|
963
|
+
return Boolean((req.socket as { encrypted?: boolean } | undefined)?.encrypted);
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/**
|
|
968
|
+
* Class-based CSRF middleware using the before/after convention.
|
|
969
|
+
* Validates form tokens on state-changing requests (POST, PUT, PATCH, DELETE).
|
|
970
|
+
*
|
|
971
|
+
* OFF by default — a default app has NO CSRF gate because the middleware is
|
|
972
|
+
* NOT attached. Set TINA4_CSRF=true (or 1/yes/on) and the framework
|
|
973
|
+
* auto-attaches it at boot (see attachCsrfFromEnv); or register it explicitly
|
|
974
|
+
* via Router.use(CsrfMiddleware). Once attached, TINA4_CSRF=false (or 0/no) is
|
|
975
|
+
* the kill switch that disables enforcement again.
|
|
976
|
+
*
|
|
977
|
+
* Behaviour (identical to the Python master, feature 37):
|
|
978
|
+
* - Skips GET, HEAD, OPTIONS requests.
|
|
979
|
+
* - Skips routes marked .noAuth().
|
|
980
|
+
* - Fails CLOSED: with TINA4_SECRET unset the signing secret resolves to
|
|
981
|
+
* blank (there is NO built-in default), and a blank HMAC key is publicly
|
|
982
|
+
* reproducible — so no token can be trusted and every write is rejected
|
|
983
|
+
* (403). This is the SEC-01 / CSRF-DEC-01 no-default-secret guarantee.
|
|
984
|
+
* - Skips requests with a valid Authorization: Bearer header (API clients).
|
|
985
|
+
* - Checks request body formToken then X-Form-Token header.
|
|
986
|
+
* - Rejects if token found in query string formToken (log warning, 403).
|
|
987
|
+
* - Validates token with validToken using the resolved SECRET, and enforces
|
|
988
|
+
* that the token's `type` claim is "form" — a non-form JWT presented in the
|
|
989
|
+
* formToken slot is rejected (CSRF-DEC-02).
|
|
990
|
+
* - If token payload has session_id, verifies it matches request session.
|
|
991
|
+
* - Every rejection is 403 with the CSRF_INVALID envelope
|
|
992
|
+
* { error: true, code: "CSRF_INVALID", message, status: 403 }.
|
|
993
|
+
*
|
|
994
|
+
* Usage:
|
|
995
|
+
* Router.use(CsrfMiddleware);
|
|
996
|
+
*/
|
|
997
|
+
export class CsrfMiddleware {
|
|
998
|
+
static beforeCsrf(req: Tina4Request, res: Tina4Response): [Tina4Request, Tina4Response] {
|
|
999
|
+
// Every CSRF rejection carries the SAME 403 envelope across all four
|
|
1000
|
+
// frameworks (Python master's shape): a real client recognises a CSRF
|
|
1001
|
+
// failure by one stable code + status regardless of the framework.
|
|
1002
|
+
const reject = (message: string): [Tina4Request, Tina4Response] => {
|
|
1003
|
+
res({ error: true, code: "CSRF_INVALID", message, status: HTTP_FORBIDDEN }, HTTP_FORBIDDEN);
|
|
1004
|
+
return [req, res];
|
|
1005
|
+
};
|
|
1006
|
+
|
|
1007
|
+
// TINA4_CSRF=false (or 0/no) disables all CSRF checks, even when the
|
|
1008
|
+
// middleware is attached — the documented kill switch. Unset defaults to
|
|
1009
|
+
// enabled (the middleware only runs at all once attached).
|
|
1010
|
+
const csrfEnv = process.env.TINA4_CSRF;
|
|
1011
|
+
if (csrfEnv === "false" || csrfEnv === "0" || csrfEnv === "no") {
|
|
1012
|
+
return [req, res];
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// Skip safe HTTP methods
|
|
1016
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
1017
|
+
if (method === "GET" || method === "HEAD" || method === "OPTIONS") {
|
|
1018
|
+
return [req, res];
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// Skip routes marked noAuth
|
|
1022
|
+
const route = (req as any)._route ?? (req as any).route;
|
|
1023
|
+
if (route?.noAuth) {
|
|
1024
|
+
return [req, res];
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
// Resolve the signing secret ONCE, fail-closed — IDENTICAL to the validator
|
|
1028
|
+
// (auth.ts validToken: `secret ?? process.env.TINA4_SECRET ?? ""`). Blank
|
|
1029
|
+
// when TINA4_SECRET is unset; there is NO built-in default.
|
|
1030
|
+
const secret = process.env.TINA4_SECRET ?? "";
|
|
1031
|
+
|
|
1032
|
+
// BLANK-SECRET HARD-FAIL (SEC-01 / CSRF-DEC-01): a blank HMAC key is
|
|
1033
|
+
// publicly reproducible, so a token signed with it (or with the retired
|
|
1034
|
+
// public 'tina4-default-secret') is a forgery. Reject every write rather
|
|
1035
|
+
// than validate against a guessable key — fail closed, hard.
|
|
1036
|
+
if (secret === "") {
|
|
1037
|
+
return reject("CSRF token cannot be validated: TINA4_SECRET is not set");
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// Skip requests with a valid Bearer token (API clients). Pass the resolved
|
|
1041
|
+
// secret so the Bearer check uses the SAME key as the form-token check.
|
|
1042
|
+
const authHeader = req.headers.authorization ?? "";
|
|
1043
|
+
if (authHeader.startsWith("Bearer ")) {
|
|
1044
|
+
const bearerToken = authHeader.slice(7).trim();
|
|
1045
|
+
if (bearerToken && validToken(bearerToken, secret)) {
|
|
1046
|
+
return [req, res];
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// Reject if token is in query string (security risk — a URL leaks through
|
|
1051
|
+
// logs, referers and history).
|
|
1052
|
+
const query = (req as any).query ?? {};
|
|
1053
|
+
if (query.formToken) {
|
|
1054
|
+
console.warn("[Tina4 CSRF] Token found in query string — rejected for security");
|
|
1055
|
+
return reject("Form token must not be sent in the URL query string");
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
// Extract token: body first, then header
|
|
1059
|
+
let token: string | undefined;
|
|
1060
|
+
const body = (req as any).body;
|
|
1061
|
+
if (body && typeof body === "object" && body.formToken) {
|
|
1062
|
+
token = String(body.formToken);
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
if (!token) {
|
|
1066
|
+
token = (req.headers["x-form-token"] as string) ?? "";
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
if (!token) {
|
|
1070
|
+
return reject("Invalid or missing form token");
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
// Validate the token signature / expiry against the resolved secret.
|
|
1074
|
+
if (!validToken(token, secret)) {
|
|
1075
|
+
return reject("Invalid or missing form token");
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
const payload = getPayload(token) ?? {};
|
|
1079
|
+
|
|
1080
|
+
// TYPE ENFORCEMENT (CSRF-DEC-02): a valid signature is not enough. A
|
|
1081
|
+
// non-form JWT (e.g. an auth/session token) must never be accepted in the
|
|
1082
|
+
// formToken slot — the token's `type` claim MUST be "form".
|
|
1083
|
+
if (payload.type !== "form") {
|
|
1084
|
+
return reject("Invalid or missing form token");
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// Session binding — if token has session_id, verify it matches the request
|
|
1088
|
+
// session. A token minted for one session cannot be replayed against another.
|
|
1089
|
+
const tokenSessionId = payload.session_id as string | undefined;
|
|
1090
|
+
if (tokenSessionId) {
|
|
1091
|
+
const session = (req as any).session;
|
|
1092
|
+
let currentSessionId: string | undefined;
|
|
1093
|
+
if (session) {
|
|
1094
|
+
currentSessionId = session.session_id ?? session.sessionId ?? session.id;
|
|
1095
|
+
if (typeof currentSessionId === "function") {
|
|
1096
|
+
currentSessionId = undefined;
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
if (currentSessionId && tokenSessionId !== currentSessionId) {
|
|
1101
|
+
return reject("Invalid or missing form token");
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
return [req, res];
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
/**
|
|
1110
|
+
* Auto-attach CsrfMiddleware when TINA4_CSRF is enabled in the environment.
|
|
1111
|
+
*
|
|
1112
|
+
* CSRF is OFF by default: with TINA4_CSRF unset the middleware is never
|
|
1113
|
+
* attached, so a default app has no CSRF gate. Setting TINA4_CSRF to a truthy
|
|
1114
|
+
* value (true/1/yes/on, case-insensitive, trimmed) attaches it globally at boot
|
|
1115
|
+
* so every state-changing route is gated — the env flag is the switch, no code
|
|
1116
|
+
* change needed. Idempotent (MiddlewareRunner.use de-dupes). Returns true when
|
|
1117
|
+
* the middleware is now attached.
|
|
1118
|
+
*
|
|
1119
|
+
* The framework calls this once during startServer (after route discovery,
|
|
1120
|
+
* before listen); a false/0/no value still lets an explicit Router.use opt-in
|
|
1121
|
+
* be disabled at runtime by the kill switch in beforeCsrf. Mirrors Python's
|
|
1122
|
+
* attach_csrf_from_env.
|
|
1123
|
+
*/
|
|
1124
|
+
export function attachCsrfFromEnv(): boolean {
|
|
1125
|
+
const value = (process.env.TINA4_CSRF ?? "").trim().toLowerCase();
|
|
1126
|
+
if (value === "true" || value === "1" || value === "yes" || value === "on") {
|
|
1127
|
+
MiddlewareRunner.use(CsrfMiddleware);
|
|
1128
|
+
return true;
|
|
1129
|
+
}
|
|
1130
|
+
return false;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// Built-in request logger middleware.
|
|
1134
|
+
//
|
|
1135
|
+
// v3.13.14: routes through the Tina4 Log (was a bare console.log) so the
|
|
1136
|
+
// line gets the same timestamp/level treatment as every other log — human
|
|
1137
|
+
// in dev, structured JSON in production — and is gated by
|
|
1138
|
+
// requestLoggingEnabled() (on by default in dev, opt-in in prod via
|
|
1139
|
+
// TINA4_LOG_REQUESTS). Line format matches Python/PHP/Ruby:
|
|
1140
|
+
// METHOD /path -> STATUS (Nms)
|
|
1141
|
+
export function requestLogger(): Middleware {
|
|
1142
|
+
return (req, res, next) => {
|
|
1143
|
+
const start = Date.now();
|
|
1144
|
+
|
|
1145
|
+
res.raw.on("finish", () => {
|
|
1146
|
+
if (!requestLoggingEnabled()) return;
|
|
1147
|
+
const duration = Date.now() - start;
|
|
1148
|
+
const status = res.raw.statusCode;
|
|
1149
|
+
const method = req.method ?? "?";
|
|
1150
|
+
const url = req.url ?? "/";
|
|
1151
|
+
Log.info(`${method} ${url} -> ${status} (${duration}ms)`);
|
|
1152
|
+
});
|
|
1153
|
+
|
|
1154
|
+
next();
|
|
1155
|
+
};
|
|
1156
|
+
}
|