tina4-nodejs 3.9.0 → 3.9.1
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/package.json +1 -1
- package/packages/cli/src/bin.ts +0 -0
- package/packages/cli/src/commands/init.ts +39 -0
- package/packages/core/src/index.ts +1 -1
- package/packages/core/src/middleware.ts +130 -2
- package/packages/core/src/queue.ts +62 -14
- package/packages/core/src/router.ts +17 -1
- package/packages/core/src/server.ts +16 -0
- package/packages/core/src/service.ts +3 -2
- package/packages/core/src/types.ts +3 -0
- package/packages/frond/src/engine.ts +19 -1
- package/packages/orm/src/database.ts +27 -4
- package/packages/swagger/src/generator.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tina4-nodejs",
|
|
3
|
-
"version": "3.9.
|
|
3
|
+
"version": "3.9.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "This is not a framework. Tina4 for Node.js/TypeScript — zero deps, 38 built-in features.",
|
|
6
6
|
"keywords": ["tina4", "framework", "web", "api", "orm", "graphql", "websocket", "typescript"],
|
package/packages/cli/src/bin.ts
CHANGED
|
File without changes
|
|
@@ -91,6 +91,45 @@ data/
|
|
|
91
91
|
`
|
|
92
92
|
);
|
|
93
93
|
|
|
94
|
+
// Dockerfile
|
|
95
|
+
if (!existsSync(join(targetDir, "Dockerfile"))) {
|
|
96
|
+
writeFileSync(
|
|
97
|
+
join(targetDir, "Dockerfile"),
|
|
98
|
+
`# Build stage
|
|
99
|
+
FROM node:22-alpine AS build
|
|
100
|
+
WORKDIR /app
|
|
101
|
+
COPY package*.json ./
|
|
102
|
+
RUN npm ci --production
|
|
103
|
+
COPY . .
|
|
104
|
+
|
|
105
|
+
# Runtime stage
|
|
106
|
+
FROM node:22-alpine
|
|
107
|
+
WORKDIR /app
|
|
108
|
+
COPY --from=build /app .
|
|
109
|
+
ENV HOST=0.0.0.0
|
|
110
|
+
ENV PORT=7148
|
|
111
|
+
EXPOSE 7148
|
|
112
|
+
CMD ["npx", "tsx", "app.ts"]
|
|
113
|
+
`
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// .dockerignore
|
|
118
|
+
if (!existsSync(join(targetDir, ".dockerignore"))) {
|
|
119
|
+
writeFileSync(
|
|
120
|
+
join(targetDir, ".dockerignore"),
|
|
121
|
+
`node_modules
|
|
122
|
+
dist
|
|
123
|
+
.git
|
|
124
|
+
.claude
|
|
125
|
+
.env
|
|
126
|
+
*.log
|
|
127
|
+
test
|
|
128
|
+
tmp
|
|
129
|
+
`
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
94
133
|
// Sample route: GET /api/hello
|
|
95
134
|
writeFileSync(
|
|
96
135
|
join(targetDir, "src/routes/api/hello/get.ts"),
|
|
@@ -17,7 +17,7 @@ export { Router, RouteGroup, RouteRef, defaultRouter, runRouteMiddlewares } from
|
|
|
17
17
|
export { get, post, put, patch, del, any, websocket, del as delete } from "./router.js";
|
|
18
18
|
export type { RouteInfo } from "./router.js";
|
|
19
19
|
export { discoverRoutes } from "./routeDiscovery.js";
|
|
20
|
-
export { MiddlewareChain, MiddlewareRunner, cors, requestLogger, CorsMiddleware, RateLimiterMiddleware, RequestLogger, SecurityHeadersMiddleware } from "./middleware.js";
|
|
20
|
+
export { MiddlewareChain, MiddlewareRunner, cors, requestLogger, CorsMiddleware, RateLimiterMiddleware, RequestLogger, SecurityHeadersMiddleware, CsrfMiddleware } from "./middleware.js";
|
|
21
21
|
export type { CorsConfig } from "./middleware.js";
|
|
22
22
|
export { createRequest, parseBody } from "./request.js";
|
|
23
23
|
export { createResponse, errorResponse } from "./response.js";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Tina4Request, Tina4Response, Middleware } from "./types.js";
|
|
2
|
+
import { validToken } from "./auth.js";
|
|
2
3
|
|
|
3
4
|
export class MiddlewareChain {
|
|
4
5
|
private middlewares: Middleware[] = [];
|
|
@@ -146,7 +147,7 @@ export function cors(config?: CorsConfig): Middleware {
|
|
|
146
147
|
|
|
147
148
|
const headersRaw = config?.headers
|
|
148
149
|
?? process.env.TINA4_CORS_HEADERS
|
|
149
|
-
?? "Content-Type,
|
|
150
|
+
?? "Content-Type,Authorization,X-Request-ID";
|
|
150
151
|
const allowedHeaders = Array.isArray(headersRaw)
|
|
151
152
|
? headersRaw.join(", ")
|
|
152
153
|
: headersRaw;
|
|
@@ -205,7 +206,9 @@ export class CorsMiddleware {
|
|
|
205
206
|
?? "GET, POST, PUT, DELETE, PATCH, OPTIONS";
|
|
206
207
|
|
|
207
208
|
const allowedHeaders = process.env.TINA4_CORS_HEADERS
|
|
208
|
-
?? "Content-Type,
|
|
209
|
+
?? "Content-Type,Authorization,X-Request-ID";
|
|
210
|
+
|
|
211
|
+
const credentials = process.env.TINA4_CORS_CREDENTIALS ?? "true";
|
|
209
212
|
|
|
210
213
|
const maxAge = process.env.TINA4_CORS_MAX_AGE
|
|
211
214
|
? parseInt(process.env.TINA4_CORS_MAX_AGE, 10)
|
|
@@ -226,6 +229,11 @@ export class CorsMiddleware {
|
|
|
226
229
|
res.header("Access-Control-Allow-Methods", allowedMethods);
|
|
227
230
|
res.header("Access-Control-Allow-Headers", allowedHeaders);
|
|
228
231
|
|
|
232
|
+
// Add credentials header when enabled and origin is not wildcard
|
|
233
|
+
if (credentials === "true" && originHeader !== "*") {
|
|
234
|
+
res.header("Access-Control-Allow-Credentials", "true");
|
|
235
|
+
}
|
|
236
|
+
|
|
229
237
|
if (req.method === "OPTIONS") {
|
|
230
238
|
res.header("Access-Control-Max-Age", String(maxAge));
|
|
231
239
|
res(null, 204);
|
|
@@ -402,6 +410,126 @@ export class SecurityHeadersMiddleware {
|
|
|
402
410
|
}
|
|
403
411
|
}
|
|
404
412
|
|
|
413
|
+
/**
|
|
414
|
+
* Class-based CSRF middleware using the before/after convention.
|
|
415
|
+
* Validates form tokens on state-changing requests (POST, PUT, PATCH, DELETE).
|
|
416
|
+
*
|
|
417
|
+
* Off by default — only active when TINA4_CSRF=true in .env or when
|
|
418
|
+
* registered explicitly via Router.use(CsrfMiddleware).
|
|
419
|
+
*
|
|
420
|
+
* Behaviour:
|
|
421
|
+
* - Skips GET, HEAD, OPTIONS requests.
|
|
422
|
+
* - Skips routes marked .noAuth().
|
|
423
|
+
* - Skips requests with a valid Authorization: Bearer header (API clients).
|
|
424
|
+
* - Checks request body formToken then X-Form-Token header.
|
|
425
|
+
* - Rejects if token found in query string formToken (log warning, 403).
|
|
426
|
+
* - Validates token with validToken using SECRET env var.
|
|
427
|
+
* - If token payload has session_id, verifies it matches request session.
|
|
428
|
+
* - Returns 403 on failure.
|
|
429
|
+
*
|
|
430
|
+
* Usage:
|
|
431
|
+
* Router.use(CsrfMiddleware);
|
|
432
|
+
*/
|
|
433
|
+
export class CsrfMiddleware {
|
|
434
|
+
static beforeCsrf(req: Tina4Request, res: Tina4Response): [Tina4Request, Tina4Response] {
|
|
435
|
+
// Skip CSRF validation entirely if disabled via env
|
|
436
|
+
const csrfEnv = process.env.TINA4_CSRF;
|
|
437
|
+
if (csrfEnv === "false" || csrfEnv === "0" || csrfEnv === "no") {
|
|
438
|
+
return [req, res];
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Skip safe HTTP methods
|
|
442
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
443
|
+
if (method === "GET" || method === "HEAD" || method === "OPTIONS") {
|
|
444
|
+
return [req, res];
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Skip routes marked noAuth
|
|
448
|
+
const route = (req as any)._route ?? (req as any).route;
|
|
449
|
+
if (route?.noAuth) {
|
|
450
|
+
return [req, res];
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Skip requests with valid Bearer token (API clients)
|
|
454
|
+
const authHeader = req.headers.authorization ?? "";
|
|
455
|
+
if (authHeader.startsWith("Bearer ")) {
|
|
456
|
+
const bearerToken = authHeader.slice(7).trim();
|
|
457
|
+
if (bearerToken) {
|
|
458
|
+
const secret = process.env.SECRET || "tina4-default-secret";
|
|
459
|
+
const payload = validToken(bearerToken, secret);
|
|
460
|
+
if (payload !== null) {
|
|
461
|
+
return [req, res];
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Reject if token is in query string (security risk)
|
|
467
|
+
const query = (req as any).query ?? {};
|
|
468
|
+
if (query.formToken) {
|
|
469
|
+
console.warn("[Tina4 CSRF] Token found in query string — rejected for security");
|
|
470
|
+
res({
|
|
471
|
+
error: "CSRF_INVALID",
|
|
472
|
+
message: "Form token must not be sent in the URL query string",
|
|
473
|
+
}, 403);
|
|
474
|
+
return [req, res];
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Extract token: body first, then header
|
|
478
|
+
let token: string | undefined;
|
|
479
|
+
const body = (req as any).body;
|
|
480
|
+
if (body && typeof body === "object" && body.formToken) {
|
|
481
|
+
token = String(body.formToken);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
if (!token) {
|
|
485
|
+
token = (req.headers["x-form-token"] as string) ?? "";
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if (!token) {
|
|
489
|
+
res({
|
|
490
|
+
error: "CSRF_INVALID",
|
|
491
|
+
message: "Invalid or missing form token",
|
|
492
|
+
}, 403);
|
|
493
|
+
return [req, res];
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Validate the token
|
|
497
|
+
const secret = process.env.SECRET || "tina4-default-secret";
|
|
498
|
+
const payload = validToken(token, secret);
|
|
499
|
+
|
|
500
|
+
if (payload === null) {
|
|
501
|
+
res({
|
|
502
|
+
error: "CSRF_INVALID",
|
|
503
|
+
message: "Invalid or missing form token",
|
|
504
|
+
}, 403);
|
|
505
|
+
return [req, res];
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Session binding — if token has session_id, verify it matches
|
|
509
|
+
const tokenSessionId = payload.session_id as string | undefined;
|
|
510
|
+
if (tokenSessionId) {
|
|
511
|
+
const session = (req as any).session;
|
|
512
|
+
let currentSessionId: string | undefined;
|
|
513
|
+
if (session) {
|
|
514
|
+
currentSessionId = session.session_id ?? session.sessionId ?? session.id;
|
|
515
|
+
if (typeof currentSessionId === "function") {
|
|
516
|
+
currentSessionId = undefined;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (currentSessionId && tokenSessionId !== currentSessionId) {
|
|
521
|
+
res({
|
|
522
|
+
error: "CSRF_INVALID",
|
|
523
|
+
message: "Invalid or missing form token",
|
|
524
|
+
}, 403);
|
|
525
|
+
return [req, res];
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
return [req, res];
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
405
533
|
// Built-in request logger middleware (function form — kept for backwards compat)
|
|
406
534
|
export function requestLogger(): Middleware {
|
|
407
535
|
return (req, res, next) => {
|
|
@@ -48,6 +48,8 @@ export interface QueueJob {
|
|
|
48
48
|
createdAt: string;
|
|
49
49
|
attempts: number;
|
|
50
50
|
delayUntil: string | null;
|
|
51
|
+
priority: number;
|
|
52
|
+
topic: string;
|
|
51
53
|
error?: string;
|
|
52
54
|
/** Mark this job as completed. */
|
|
53
55
|
complete(): void;
|
|
@@ -55,12 +57,15 @@ export interface QueueJob {
|
|
|
55
57
|
fail(reason?: string): void;
|
|
56
58
|
/** Reject this job with a reason. Alias for fail(). */
|
|
57
59
|
reject(reason?: string): void;
|
|
60
|
+
/** Re-queue this job with incremented attempts and optional delay. */
|
|
61
|
+
retry(delaySeconds?: number): void;
|
|
58
62
|
}
|
|
59
63
|
|
|
60
64
|
/** Create a QueueJob with lifecycle methods bound to a Queue instance. */
|
|
61
|
-
function createJob(data: Omit<QueueJob, "complete" | "fail" | "reject">, queue: Queue, topic: string): QueueJob {
|
|
65
|
+
function createJob(data: Omit<QueueJob, "complete" | "fail" | "reject" | "retry">, queue: Queue, topic: string): QueueJob {
|
|
62
66
|
const job: QueueJob = {
|
|
63
67
|
...data,
|
|
68
|
+
topic,
|
|
64
69
|
complete() {
|
|
65
70
|
job.status = "completed";
|
|
66
71
|
},
|
|
@@ -73,6 +78,9 @@ function createJob(data: Omit<QueueJob, "complete" | "fail" | "reject">, queue:
|
|
|
73
78
|
reject(reason = "") {
|
|
74
79
|
job.fail(reason);
|
|
75
80
|
},
|
|
81
|
+
retry(delaySeconds?: number) {
|
|
82
|
+
queue._retryJob(topic, job, delaySeconds);
|
|
83
|
+
},
|
|
76
84
|
};
|
|
77
85
|
return job;
|
|
78
86
|
}
|
|
@@ -160,25 +168,32 @@ export class Queue {
|
|
|
160
168
|
* Add a job to the queue. Returns job ID.
|
|
161
169
|
*
|
|
162
170
|
* Can be called as:
|
|
163
|
-
* queue.push(payload)
|
|
164
|
-
* queue.push(payload, delay)
|
|
165
|
-
* queue.push(
|
|
171
|
+
* queue.push(payload) — uses constructor topic
|
|
172
|
+
* queue.push(payload, delay) — uses constructor topic with delay
|
|
173
|
+
* queue.push(payload, delay, priority) — uses constructor topic with delay and priority
|
|
174
|
+
* queue.push("queueName", payload) — legacy: explicit queue name
|
|
175
|
+
* queue.push("queueName", payload, delay) — legacy with delay
|
|
176
|
+
*
|
|
177
|
+
* @param priority — Higher value = higher priority. Default 0.
|
|
166
178
|
*/
|
|
167
|
-
push(queueOrPayload: string | unknown, payloadOrDelay?: unknown, delay?: number): string {
|
|
179
|
+
push(queueOrPayload: string | unknown, payloadOrDelay?: unknown, delay?: number, priority?: number): string {
|
|
168
180
|
let queue: string;
|
|
169
181
|
let payload: unknown;
|
|
170
182
|
let actualDelay: number | undefined;
|
|
183
|
+
let actualPriority: number;
|
|
171
184
|
|
|
172
185
|
if (typeof queueOrPayload === "string" && payloadOrDelay !== undefined && typeof payloadOrDelay !== "number") {
|
|
173
|
-
// Legacy: push("queueName", payload, delay?)
|
|
186
|
+
// Legacy: push("queueName", payload, delay?, priority?)
|
|
174
187
|
queue = queueOrPayload;
|
|
175
188
|
payload = payloadOrDelay;
|
|
176
189
|
actualDelay = delay;
|
|
190
|
+
actualPriority = priority ?? 0;
|
|
177
191
|
} else {
|
|
178
|
-
// Unified: push(payload) or push(payload, delay)
|
|
192
|
+
// Unified: push(payload) or push(payload, delay) or push(payload, delay, priority)
|
|
179
193
|
queue = this.topic;
|
|
180
194
|
payload = queueOrPayload;
|
|
181
195
|
actualDelay = typeof payloadOrDelay === "number" ? payloadOrDelay : delay;
|
|
196
|
+
actualPriority = typeof payloadOrDelay === "number" ? (delay ?? 0) : (priority ?? 0);
|
|
182
197
|
}
|
|
183
198
|
|
|
184
199
|
if (this.externalBackend) {
|
|
@@ -189,13 +204,14 @@ export class Queue {
|
|
|
189
204
|
const now = new Date().toISOString();
|
|
190
205
|
this.seq++;
|
|
191
206
|
|
|
192
|
-
const job
|
|
207
|
+
const job = {
|
|
193
208
|
id,
|
|
194
209
|
payload,
|
|
195
|
-
status: "pending",
|
|
210
|
+
status: "pending" as const,
|
|
196
211
|
createdAt: now,
|
|
197
212
|
attempts: 0,
|
|
198
213
|
delayUntil: actualDelay ? new Date(Date.now() + actualDelay * 1000).toISOString() : null,
|
|
214
|
+
priority: actualPriority,
|
|
199
215
|
};
|
|
200
216
|
|
|
201
217
|
const prefix = `${Date.now()}-${String(this.seq).padStart(6, "0")}`;
|
|
@@ -240,6 +256,8 @@ export class Queue {
|
|
|
240
256
|
if (job.delayUntil && job.delayUntil > now) continue;
|
|
241
257
|
|
|
242
258
|
job.status = "reserved";
|
|
259
|
+
job.topic = q;
|
|
260
|
+
job.priority = job.priority ?? 0;
|
|
243
261
|
writeFileSync(filePath, JSON.stringify(job, null, 2));
|
|
244
262
|
|
|
245
263
|
try {
|
|
@@ -248,7 +266,7 @@ export class Queue {
|
|
|
248
266
|
// Already consumed by another worker
|
|
249
267
|
}
|
|
250
268
|
|
|
251
|
-
return job;
|
|
269
|
+
return createJob(job as any, this, q);
|
|
252
270
|
}
|
|
253
271
|
|
|
254
272
|
return null;
|
|
@@ -302,14 +320,29 @@ export class Queue {
|
|
|
302
320
|
}
|
|
303
321
|
|
|
304
322
|
/**
|
|
305
|
-
* Count
|
|
323
|
+
* Count jobs in a queue filtered by status.
|
|
324
|
+
*
|
|
325
|
+
* @param queue — Queue/topic name (defaults to constructor topic)
|
|
326
|
+
* @param status — Job status to count: "pending" (default) or "failed"
|
|
306
327
|
*/
|
|
307
|
-
size(queue?: string): number {
|
|
328
|
+
size(queue?: string, status: string = "pending"): number {
|
|
308
329
|
const q = queue ?? this.topic;
|
|
309
330
|
|
|
310
331
|
if (this.externalBackend) {
|
|
311
332
|
return this.externalBackend.size(q);
|
|
312
333
|
}
|
|
334
|
+
|
|
335
|
+
if (status === "failed") {
|
|
336
|
+
const failedDir = this.ensureFailedDir(q);
|
|
337
|
+
let files: string[];
|
|
338
|
+
try {
|
|
339
|
+
files = readdirSync(failedDir).filter(f => f.endsWith(".queue-data"));
|
|
340
|
+
} catch {
|
|
341
|
+
return 0;
|
|
342
|
+
}
|
|
343
|
+
return files.length;
|
|
344
|
+
}
|
|
345
|
+
|
|
313
346
|
const dir = this.ensureDir(q);
|
|
314
347
|
let files: string[];
|
|
315
348
|
try {
|
|
@@ -321,8 +354,8 @@ export class Queue {
|
|
|
321
354
|
let count = 0;
|
|
322
355
|
for (const file of files) {
|
|
323
356
|
try {
|
|
324
|
-
const job
|
|
325
|
-
if (job.status ===
|
|
357
|
+
const job = JSON.parse(readFileSync(join(dir, file), "utf-8"));
|
|
358
|
+
if (job.status === status) count++;
|
|
326
359
|
} catch {
|
|
327
360
|
// skip corrupt files
|
|
328
361
|
}
|
|
@@ -660,4 +693,19 @@ export class Queue {
|
|
|
660
693
|
|
|
661
694
|
writeFileSync(join(failedDir, `${job.id}.queue-data`), JSON.stringify(job, null, 2));
|
|
662
695
|
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Re-queue a job back to the main queue directory with incremented attempts.
|
|
699
|
+
*/
|
|
700
|
+
_retryJob(queue: string, job: QueueJob, delaySeconds?: number): void {
|
|
701
|
+
const dir = this.ensureDir(queue);
|
|
702
|
+
job.status = "pending";
|
|
703
|
+
job.attempts = (job.attempts || 0) + 1;
|
|
704
|
+
job.error = undefined;
|
|
705
|
+
job.delayUntil = delaySeconds ? new Date(Date.now() + delaySeconds * 1000).toISOString() : null;
|
|
706
|
+
|
|
707
|
+
this.seq++;
|
|
708
|
+
const prefix = `${Date.now()}-${String(this.seq).padStart(6, "0")}`;
|
|
709
|
+
writeFileSync(join(dir, `${prefix}_${job.id}.queue-data`), JSON.stringify(job, null, 2));
|
|
710
|
+
}
|
|
663
711
|
}
|
|
@@ -9,6 +9,7 @@ interface MatchResult {
|
|
|
9
9
|
template?: string;
|
|
10
10
|
secure?: boolean;
|
|
11
11
|
cached?: boolean;
|
|
12
|
+
noAuth?: boolean;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
interface CompiledRoute {
|
|
@@ -21,6 +22,7 @@ interface CompiledRoute {
|
|
|
21
22
|
middlewares?: Middleware[];
|
|
22
23
|
secure?: boolean;
|
|
23
24
|
cached?: boolean;
|
|
25
|
+
noAuth?: boolean;
|
|
24
26
|
cacheStore?: Map<string, { data: unknown; expires: number }>;
|
|
25
27
|
cacheTtl?: number;
|
|
26
28
|
template?: string;
|
|
@@ -41,6 +43,12 @@ export class RouteRef {
|
|
|
41
43
|
return this;
|
|
42
44
|
}
|
|
43
45
|
|
|
46
|
+
/** Opt out of secure-by-default auth (for public write routes). */
|
|
47
|
+
noAuth(): this {
|
|
48
|
+
this.route.noAuth = true;
|
|
49
|
+
return this;
|
|
50
|
+
}
|
|
51
|
+
|
|
44
52
|
/** Mark this route's response as cacheable. */
|
|
45
53
|
cache(): this {
|
|
46
54
|
this.route.cached = true;
|
|
@@ -105,6 +113,11 @@ export class Router {
|
|
|
105
113
|
routes.splice(existingIndex, 1);
|
|
106
114
|
}
|
|
107
115
|
|
|
116
|
+
// Write methods (POST/PUT/PATCH/DELETE) are secure by default
|
|
117
|
+
const WRITE_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
118
|
+
const isWrite = WRITE_METHODS.has(method);
|
|
119
|
+
const secureDefault = isWrite ? (definition.secure ?? true) : definition.secure;
|
|
120
|
+
|
|
108
121
|
const compiled: CompiledRoute = {
|
|
109
122
|
pattern: definition.pattern,
|
|
110
123
|
regex,
|
|
@@ -113,8 +126,9 @@ export class Router {
|
|
|
113
126
|
meta: definition.meta,
|
|
114
127
|
filePath: definition.filePath,
|
|
115
128
|
middlewares: definition.middlewares,
|
|
116
|
-
secure:
|
|
129
|
+
secure: secureDefault,
|
|
117
130
|
cached: definition.cached,
|
|
131
|
+
noAuth: definition.noAuth,
|
|
118
132
|
template: definition.template,
|
|
119
133
|
};
|
|
120
134
|
routes.push(compiled);
|
|
@@ -201,6 +215,7 @@ export class Router {
|
|
|
201
215
|
template: route.template,
|
|
202
216
|
secure: route.secure,
|
|
203
217
|
cached: route.cached,
|
|
218
|
+
noAuth: route.noAuth,
|
|
204
219
|
};
|
|
205
220
|
}
|
|
206
221
|
}
|
|
@@ -225,6 +240,7 @@ export class Router {
|
|
|
225
240
|
template: route.template,
|
|
226
241
|
secure: route.secure,
|
|
227
242
|
cached: route.cached,
|
|
243
|
+
noAuth: route.noAuth,
|
|
228
244
|
});
|
|
229
245
|
}
|
|
230
246
|
}
|
|
@@ -7,6 +7,7 @@ import cluster from "node:cluster";
|
|
|
7
7
|
import os from "node:os";
|
|
8
8
|
import type { Tina4Config, Tina4Request, Tina4Response } from "./types.js";
|
|
9
9
|
import { Router, defaultRouter, runRouteMiddlewares } from "./router.js";
|
|
10
|
+
import { validToken } from "./auth.js";
|
|
10
11
|
import { discoverRoutes } from "./routeDiscovery.js";
|
|
11
12
|
import { createRequest, parseBody } from "./request.js";
|
|
12
13
|
import { createResponse } from "./response.js";
|
|
@@ -683,6 +684,21 @@ ${reset}
|
|
|
683
684
|
if (!proceed || res.raw.writableEnded) return;
|
|
684
685
|
}
|
|
685
686
|
|
|
687
|
+
// Auth enforcement: secure routes require a valid Bearer token
|
|
688
|
+
if (match.secure === true && match.noAuth !== true) {
|
|
689
|
+
const authHeader = req.headers.authorization ?? "";
|
|
690
|
+
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : "";
|
|
691
|
+
const secret = process.env.SECRET || "";
|
|
692
|
+
const payload = token ? validToken(token, secret) : null;
|
|
693
|
+
|
|
694
|
+
if (!payload) {
|
|
695
|
+
res.raw.writeHead(401, { "Content-Type": "application/json" });
|
|
696
|
+
res.raw.end(JSON.stringify({ error: "Unauthorized" }));
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
req.user = payload;
|
|
700
|
+
}
|
|
701
|
+
|
|
686
702
|
// Inject path params by name into handler arguments, then request/response
|
|
687
703
|
let result: unknown;
|
|
688
704
|
const routeParams = req.params || {};
|
|
@@ -128,10 +128,11 @@ async function executeHandler(svc: RegisteredService): Promise<void> {
|
|
|
128
128
|
}
|
|
129
129
|
|
|
130
130
|
function startCronService(svc: RegisteredService): void {
|
|
131
|
-
const
|
|
132
|
-
process.env.
|
|
131
|
+
const sleepSeconds = parseInt(
|
|
132
|
+
process.env.TINA4_SERVICE_SLEEP ?? "5",
|
|
133
133
|
10,
|
|
134
134
|
);
|
|
135
|
+
const checkIntervalMs = sleepSeconds * 1000;
|
|
135
136
|
let lastMinuteRun = -1;
|
|
136
137
|
|
|
137
138
|
svc.timerId = setInterval(() => {
|
|
@@ -24,6 +24,7 @@ export interface Tina4Request extends IncomingMessage {
|
|
|
24
24
|
ip: string;
|
|
25
25
|
files: UploadedFile[];
|
|
26
26
|
session: Tina4Session;
|
|
27
|
+
user?: Record<string, unknown>;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
export interface CookieOptions {
|
|
@@ -84,6 +85,8 @@ export interface RouteDefinition {
|
|
|
84
85
|
secure?: boolean;
|
|
85
86
|
/** Whether this route's response should be cached */
|
|
86
87
|
cached?: boolean;
|
|
88
|
+
/** Opt out of secure-by-default auth on write routes */
|
|
89
|
+
noAuth?: boolean;
|
|
87
90
|
}
|
|
88
91
|
|
|
89
92
|
export interface RouteMeta {
|
|
@@ -802,9 +802,22 @@ function _b64url(data: Buffer): string {
|
|
|
802
802
|
*
|
|
803
803
|
* @returns `<input type="hidden" name="formToken" value="TOKEN">`
|
|
804
804
|
*/
|
|
805
|
+
/**
|
|
806
|
+
* Module-level session ID holder — set by the server before rendering
|
|
807
|
+
* templates so that form_token() can bind tokens to the current session.
|
|
808
|
+
*/
|
|
809
|
+
let _formTokenSessionId: string = "";
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* Set the session ID used by formToken() / form_token() for CSRF session binding.
|
|
813
|
+
*/
|
|
814
|
+
export function setFormTokenSessionId(sessionId: string): void {
|
|
815
|
+
_formTokenSessionId = sessionId || "";
|
|
816
|
+
}
|
|
817
|
+
|
|
805
818
|
function _generateFormToken(descriptor: string = ""): SafeString {
|
|
806
819
|
const secret = process.env.SECRET || "tina4-default-secret";
|
|
807
|
-
const ttlMinutes = parseInt(process.env.TINA4_TOKEN_LIMIT || "
|
|
820
|
+
const ttlMinutes = parseInt(process.env.TINA4_TOKEN_LIMIT || "60", 10);
|
|
808
821
|
|
|
809
822
|
const header = { alg: "HS256", typ: "JWT" };
|
|
810
823
|
const now = Math.floor(Date.now() / 1000);
|
|
@@ -820,6 +833,11 @@ function _generateFormToken(descriptor: string = ""): SafeString {
|
|
|
820
833
|
}
|
|
821
834
|
}
|
|
822
835
|
|
|
836
|
+
// Include session_id for CSRF session binding
|
|
837
|
+
if (_formTokenSessionId) {
|
|
838
|
+
payload.session_id = _formTokenSessionId;
|
|
839
|
+
}
|
|
840
|
+
|
|
823
841
|
const h = _b64url(Buffer.from(JSON.stringify(header)));
|
|
824
842
|
const p = _b64url(Buffer.from(JSON.stringify(payload)));
|
|
825
843
|
const sigInput = `${h}.${p}`;
|
|
@@ -205,6 +205,9 @@ export class Database {
|
|
|
205
205
|
/** Factory for creating new adapters (used by pool) */
|
|
206
206
|
private adapterFactory: (() => Promise<DatabaseAdapter>) | null = null;
|
|
207
207
|
|
|
208
|
+
/** Whether to automatically commit after each write operation */
|
|
209
|
+
private autoCommit: boolean = process.env.TINA4_AUTOCOMMIT === "true";
|
|
210
|
+
|
|
208
211
|
/**
|
|
209
212
|
* Create a Database wrapping an existing adapter.
|
|
210
213
|
* For creating a Database from a URL, use the async static factories:
|
|
@@ -305,22 +308,42 @@ export class Database {
|
|
|
305
308
|
|
|
306
309
|
/** Execute a statement (INSERT, UPDATE, DELETE, DDL). */
|
|
307
310
|
execute(sql: string, params?: unknown[]): unknown {
|
|
308
|
-
|
|
311
|
+
const adapter = this.getNextAdapter();
|
|
312
|
+
const result = adapter.execute(sql, params);
|
|
313
|
+
if (this.autoCommit) {
|
|
314
|
+
try { adapter.commit(); } catch { /* no active transaction */ }
|
|
315
|
+
}
|
|
316
|
+
return result;
|
|
309
317
|
}
|
|
310
318
|
|
|
311
319
|
/** Insert a row into a table. */
|
|
312
320
|
insert(table: string, data: Record<string, unknown>): DatabaseWriteResult {
|
|
313
|
-
|
|
321
|
+
const adapter = this.getNextAdapter();
|
|
322
|
+
const result = adapter.insert(table, data);
|
|
323
|
+
if (this.autoCommit) {
|
|
324
|
+
try { adapter.commit(); } catch { /* no active transaction */ }
|
|
325
|
+
}
|
|
326
|
+
return result;
|
|
314
327
|
}
|
|
315
328
|
|
|
316
329
|
/** Update rows in a table matching filter. */
|
|
317
330
|
update(table: string, data: Record<string, unknown>, filter?: Record<string, unknown>): DatabaseWriteResult {
|
|
318
|
-
|
|
331
|
+
const adapter = this.getNextAdapter();
|
|
332
|
+
const result = adapter.update(table, data, filter ?? {});
|
|
333
|
+
if (this.autoCommit) {
|
|
334
|
+
try { adapter.commit(); } catch { /* no active transaction */ }
|
|
335
|
+
}
|
|
336
|
+
return result;
|
|
319
337
|
}
|
|
320
338
|
|
|
321
339
|
/** Delete rows from a table matching filter. */
|
|
322
340
|
delete(table: string, filter?: Record<string, unknown>): DatabaseWriteResult {
|
|
323
|
-
|
|
341
|
+
const adapter = this.getNextAdapter();
|
|
342
|
+
const result = adapter.delete(table, filter ?? {});
|
|
343
|
+
if (this.autoCommit) {
|
|
344
|
+
try { adapter.commit(); } catch { /* no active transaction */ }
|
|
345
|
+
}
|
|
346
|
+
return result;
|
|
324
347
|
}
|
|
325
348
|
|
|
326
349
|
/** Close all database connections (pool or single). */
|