tina4-nodejs 3.13.37 → 3.13.39
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 +65 -20
- package/README.md +6 -6
- package/package.json +5 -3
- package/packages/cli/src/bin.ts +7 -0
- package/packages/cli/src/commands/init.ts +1 -0
- package/packages/cli/src/commands/metrics.ts +154 -0
- package/packages/cli/src/commands/routes.ts +3 -3
- package/packages/core/src/api.ts +64 -1
- package/packages/core/src/auth.ts +112 -2
- package/packages/core/src/cache.ts +2 -2
- package/packages/core/src/devAdmin.ts +66 -44
- package/packages/core/src/devMailbox.ts +4 -0
- package/packages/core/src/dotenv.ts +13 -4
- package/packages/core/src/events.ts +86 -4
- package/packages/core/src/graphql.ts +182 -128
- package/packages/core/src/htmlElement.ts +62 -3
- package/packages/core/src/index.ts +21 -10
- package/packages/core/src/logger.ts +85 -28
- package/packages/core/src/mcp.test.ts +1 -1
- package/packages/core/src/mcp.ts +25 -8
- package/packages/core/src/messenger.ts +111 -11
- package/packages/core/src/metrics.ts +557 -98
- package/packages/core/src/middleware.ts +130 -40
- package/packages/core/src/plan.ts +1 -1
- package/packages/core/src/queue.ts +1 -1
- package/packages/core/src/queueBackends/kafkaBackend.ts +98 -1
- package/packages/core/src/queueBackends/mongoBackend.ts +1 -1
- package/packages/core/src/queueBackends/rabbitmqBackend.ts +1 -1
- package/packages/core/src/rateLimiter.ts +1 -1
- package/packages/core/src/response.ts +90 -6
- package/packages/core/src/router.ts +56 -8
- package/packages/core/src/server.ts +138 -23
- package/packages/core/src/session.ts +130 -18
- package/packages/core/src/sessionHandlers/databaseHandler.ts +10 -0
- package/packages/core/src/sessionHandlers/mongoHandler.ts +21 -4
- package/packages/core/src/sessionHandlers/redisHandler.ts +28 -7
- package/packages/core/src/sessionHandlers/valkeyHandler.ts +27 -8
- package/packages/core/src/testClient.ts +1 -1
- package/packages/core/src/types.ts +17 -2
- package/packages/core/src/websocket.ts +666 -42
- package/packages/core/src/websocketBackplane.ts +210 -10
- package/packages/core/src/websocketConnection.ts +6 -0
- package/packages/core/src/wsdl.ts +55 -21
- package/packages/orm/src/adapters/pg-types.d.ts +60 -0
- package/packages/orm/src/adapters/postgres.ts +26 -4
- package/packages/orm/src/adapters/sqlite.ts +112 -13
- package/packages/orm/src/baseModel.ts +175 -25
- package/packages/orm/src/cachedDatabase.ts +15 -6
- package/packages/orm/src/database.ts +257 -55
- package/packages/orm/src/index.ts +6 -1
- package/packages/orm/src/migration.ts +151 -24
- package/packages/orm/src/queryBuilder.ts +14 -2
- package/packages/orm/src/seeder.ts +443 -65
- package/packages/orm/src/types.ts +7 -0
- package/packages/orm/src/validation.ts +14 -0
- package/packages/swagger/src/ui.ts +1 -1
|
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
|
|
|
6
6
|
import { execFileSync, exec } from "node:child_process";
|
|
7
7
|
import cluster from "node:cluster";
|
|
8
8
|
import os from "node:os";
|
|
9
|
+
import type { Socket } from "node:net";
|
|
9
10
|
import type { Tina4Config, Tina4Request, Tina4Response } from "./types.js";
|
|
10
11
|
import { Router, defaultRouter, runRouteMiddlewares } from "./router.js";
|
|
11
12
|
import { validToken, getPayload, refreshToken } from "./auth.js";
|
|
@@ -19,7 +20,7 @@ import { createHealthRoutes } from "./health.js";
|
|
|
19
20
|
import { rateLimiter } from "./rateLimiter.js";
|
|
20
21
|
import { Log } from "./logger.js";
|
|
21
22
|
import { DevAdmin, RequestInspector, WsTracker } from "./devAdmin.js";
|
|
22
|
-
import { devReloadWs } from "./websocket.js";
|
|
23
|
+
import { devReloadWs, serveWebSocketRoute, wsRouteManager } from "./websocket.js";
|
|
23
24
|
import { feedbackEnabled, injectFeedbackWidget } from "./feedback.js";
|
|
24
25
|
import { I18n } from "./i18n.js";
|
|
25
26
|
import { stopAllBackgroundTasks } from "./background.js";
|
|
@@ -33,6 +34,72 @@ const BUILTIN_ERROR_TEMPLATES_DIR = resolve(__dirname, "..", "templates");
|
|
|
33
34
|
/** Built-in public directory for framework-bundled static assets. */
|
|
34
35
|
const BUILTIN_PUBLIC_DIR = resolve(__dirname, "..", "public");
|
|
35
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Apply pending DB migrations on startup — NON-BREAKING.
|
|
39
|
+
*
|
|
40
|
+
* When a `migrations/` folder exists (with at least one `.sql` file, excluding
|
|
41
|
+
* `.down.sql`) and `TINA4_AUTO_MIGRATE` is not disabled (default "true";
|
|
42
|
+
* false/0/no/off disable), pending migrations are applied during boot so the
|
|
43
|
+
* schema is current with no manual `tina4 migrate` step. A failure here is
|
|
44
|
+
* logged LOUD via `Log.error` and the service STILL starts — a bad migration
|
|
45
|
+
* must never take the backend down. (The explicit `tina4 migrate` CLI stays
|
|
46
|
+
* fail-fast so CI still gets a non-zero exit. Only this startup hook swallows.)
|
|
47
|
+
*
|
|
48
|
+
* Disable with `TINA4_AUTO_MIGRATE=false` — e.g. multi-instance production that
|
|
49
|
+
* migrates as a separate deploy step (concurrent first-apply can race).
|
|
50
|
+
*
|
|
51
|
+
* @param migrationDir - migrations directory (default "migrations", relative to base)
|
|
52
|
+
* @param base - project root used to resolve the migrations directory
|
|
53
|
+
*/
|
|
54
|
+
export async function autoMigrateOnStartup(
|
|
55
|
+
migrationDir = "migrations",
|
|
56
|
+
base = process.cwd(),
|
|
57
|
+
): Promise<void> {
|
|
58
|
+
const dir = resolve(base, migrationDir);
|
|
59
|
+
|
|
60
|
+
// Gate 1: a migrations/ folder with at least one .sql (non-down) file.
|
|
61
|
+
if (!existsSync(dir)) return;
|
|
62
|
+
let hasSql = false;
|
|
63
|
+
try {
|
|
64
|
+
hasSql = readdirSync(dir).some((f) => f.endsWith(".sql") && !f.endsWith(".down.sql"));
|
|
65
|
+
} catch {
|
|
66
|
+
return; // unreadable dir → nothing to do (silent)
|
|
67
|
+
}
|
|
68
|
+
if (!hasSql) return;
|
|
69
|
+
|
|
70
|
+
// Gate 2: TINA4_AUTO_MIGRATE not falsy (default "true").
|
|
71
|
+
const flag = process.env.TINA4_AUTO_MIGRATE;
|
|
72
|
+
if (flag != null && !isTruthy(flag)) {
|
|
73
|
+
Log.debug("TINA4_AUTO_MIGRATE is off — skipping startup migrations");
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Gate 3: a DB adapter must be resolvable. (initDatabase() has already run by
|
|
78
|
+
// the time this is called from startServer.)
|
|
79
|
+
let orm: typeof import("../../orm/src/index.js");
|
|
80
|
+
try {
|
|
81
|
+
orm = await import("../../orm/src/index.js");
|
|
82
|
+
orm.getAdapter(); // throws if no adapter configured
|
|
83
|
+
} catch (err) {
|
|
84
|
+
Log.debug(`Startup migrations skipped (no database configured): ${err instanceof Error ? err.message : String(err)}`);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Run the EXISTING migrate runner inside try/catch — NEVER re-raise out of
|
|
89
|
+
// the startup hook (non-breaking).
|
|
90
|
+
try {
|
|
91
|
+
const result = await orm.migrate(undefined, { migrationsDir: dir });
|
|
92
|
+
if (result.applied.length > 0) {
|
|
93
|
+
Log.info(`Applied ${result.applied.length} pending migration(s) on startup`);
|
|
94
|
+
}
|
|
95
|
+
} catch (err) {
|
|
96
|
+
Log.error(
|
|
97
|
+
`Startup auto-migration failed: ${err instanceof Error ? err.message : String(err)} — ` +
|
|
98
|
+
"the service is starting anyway. Run `tina4 migrate` to retry.",
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
36
103
|
/** Read version from root package.json so the banner always matches the published version. */
|
|
37
104
|
function readPackageVersion(): string {
|
|
38
105
|
try {
|
|
@@ -675,9 +742,23 @@ export async function startServer(config?: Tina4Config): Promise<{
|
|
|
675
742
|
router: Router;
|
|
676
743
|
port: number;
|
|
677
744
|
}> {
|
|
678
|
-
// Load
|
|
745
|
+
// Load env early so TINA4_DEBUG is available for the cluster decision.
|
|
746
|
+
// Precedence MUST be: real environment (set before boot) > .env.local > .env.
|
|
747
|
+
// loadEnv(override=false) is first-wins (only sets a key not already present),
|
|
748
|
+
// so load .env.local BEFORE .env — both with override=false. A real env var
|
|
749
|
+
// set before boot is already present and wins over both; .env.local fills
|
|
750
|
+
// local-only keys; .env fills whatever neither set. Loading .env.local with
|
|
751
|
+
// override=true would let a stray gitignored .env.local clobber an explicitly
|
|
752
|
+
// set real env var (e.g. a production TINA4_SECRET) — never do that.
|
|
753
|
+
loadEnv(".env.local");
|
|
679
754
|
loadEnv();
|
|
680
755
|
|
|
756
|
+
// Auto-generate a per-machine dev secret to a gitignored .env.local when one
|
|
757
|
+
// is missing (dev only, never CI/prod). Must run after env load and before
|
|
758
|
+
// any auth use. Local import avoids a load-time cycle through auth.
|
|
759
|
+
const { ensureDevSecret } = await import("./auth.js");
|
|
760
|
+
ensureDevSecret();
|
|
761
|
+
|
|
681
762
|
// Refuse to boot with pre-3.12 un-prefixed env vars set.
|
|
682
763
|
_checkLegacyEnvVars();
|
|
683
764
|
|
|
@@ -869,6 +950,12 @@ ${reset}
|
|
|
869
950
|
} catch (err) {
|
|
870
951
|
console.warn(`\n ORM not available (install @tina4/orm to enable):`, err);
|
|
871
952
|
}
|
|
953
|
+
|
|
954
|
+
// Auto-run pending migrations on startup — AFTER initDatabase()/model sync,
|
|
955
|
+
// BEFORE the server listens. Non-breaking: a failure is logged and boot
|
|
956
|
+
// continues (the helper never throws). Gated on a migrations/ dir + the
|
|
957
|
+
// TINA4_AUTO_MIGRATE flag (default on) + a resolvable DB adapter.
|
|
958
|
+
await autoMigrateOnStartup("migrations", base);
|
|
872
959
|
}
|
|
873
960
|
|
|
874
961
|
// Initialize Swagger — gated on TINA4_SWAGGER_ENABLED (default: enabled
|
|
@@ -972,8 +1059,8 @@ ${reset}
|
|
|
972
1059
|
rawRes.setHeader("Content-Length", String(accumulated));
|
|
973
1060
|
}
|
|
974
1061
|
const realCb = typeof chunk === "function" ? chunk : cb;
|
|
975
|
-
return origEnd(undefined, undefined, realCb);
|
|
976
1062
|
void origWrite; // referenced to keep tsc happy
|
|
1063
|
+
return typeof realCb === "function" ? origEnd(realCb) : origEnd();
|
|
977
1064
|
}) as typeof rawRes.end;
|
|
978
1065
|
}
|
|
979
1066
|
|
|
@@ -1151,7 +1238,14 @@ ${reset}
|
|
|
1151
1238
|
];
|
|
1152
1239
|
if (globalMiddleware.length > 0) {
|
|
1153
1240
|
const [, , proceed] = await MiddlewareRunner.runBefore(globalMiddleware, req, res);
|
|
1154
|
-
if (!proceed || res.raw.writableEnded)
|
|
1241
|
+
if (!proceed || res.raw.writableEnded) {
|
|
1242
|
+
// AFTER-ON-4xx RULE (M2): after_* ALWAYS run even when a before_*
|
|
1243
|
+
// short-circuited (4xx / clean 500 / response ended), so they can
|
|
1244
|
+
// still add headers / logging. Run them, then stop the handler.
|
|
1245
|
+
await MiddlewareRunner.runAfter(globalMiddleware, req, res);
|
|
1246
|
+
if (!res.raw.writableEnded) res.raw.end();
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1155
1249
|
}
|
|
1156
1250
|
|
|
1157
1251
|
// Run per-route middlewares if any
|
|
@@ -1393,31 +1487,52 @@ ${reset}
|
|
|
1393
1487
|
// posts /__dev/api/reload to the MAIN port. Matches Python (master).
|
|
1394
1488
|
const server = createServer(dispatch);
|
|
1395
1489
|
|
|
1396
|
-
// WebSocket
|
|
1397
|
-
//
|
|
1398
|
-
//
|
|
1399
|
-
//
|
|
1400
|
-
//
|
|
1490
|
+
// WebSocket upgrade handling on the MAIN port. Two responsibilities:
|
|
1491
|
+
//
|
|
1492
|
+
// 1. WebSocket-primary DevReload (debug only): accept and hold /__dev_reload
|
|
1493
|
+
// upgrades so POST /__dev/api/reload can push an instant reload. Mirrors
|
|
1494
|
+
// Python's _register_dev_reload_ws + _ws_manager.broadcast(path=…).
|
|
1495
|
+
//
|
|
1496
|
+
// 2. USER WS ROUTES (always): a route registered via Router.websocket() /
|
|
1497
|
+
// WebSocketServer.route() is dispatched to a live open/message/close
|
|
1498
|
+
// lifecycle on the real connection — parity with Python/PHP/Ruby. Per-route
|
|
1499
|
+
// auth is enforced here on the upgrade (serveWebSocketRoute): a @secured /
|
|
1500
|
+
// .secure() route rejects a missing/invalid JWT before accepting; public
|
|
1501
|
+
// routes pass. Previously the integrated server only handled /__dev_reload,
|
|
1502
|
+
// so user WS routes never reached a live connection.
|
|
1503
|
+
//
|
|
1504
|
+
// Tracking goes through WsTracker so connections show in the dev-admin list.
|
|
1401
1505
|
if (isDevMode()) {
|
|
1402
1506
|
devReloadWs.setTracker(
|
|
1403
1507
|
(remoteAddress, p) => WsTracker.add(remoteAddress, p),
|
|
1404
1508
|
(id) => { WsTracker.remove(id); },
|
|
1405
1509
|
);
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
return;
|
|
1411
|
-
}
|
|
1412
|
-
// Not a dev-reload upgrade — refuse cleanly rather than leaving it hanging.
|
|
1413
|
-
try {
|
|
1414
|
-
socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
|
|
1415
|
-
socket.destroy();
|
|
1416
|
-
} catch {
|
|
1417
|
-
/* socket already gone */
|
|
1418
|
-
}
|
|
1419
|
-
});
|
|
1510
|
+
wsRouteManager.setTracker(
|
|
1511
|
+
(remoteAddress, p) => WsTracker.add(remoteAddress, p),
|
|
1512
|
+
(id) => { WsTracker.remove(id); },
|
|
1513
|
+
);
|
|
1420
1514
|
}
|
|
1515
|
+
server.on("upgrade", (req: IncomingMessage, socket: Socket, head: Buffer) => {
|
|
1516
|
+
const upPath = (req.url ?? "/").split("?")[0];
|
|
1517
|
+
// Dev-reload channel (debug only).
|
|
1518
|
+
if (isDevMode() && upPath === "/__dev_reload") {
|
|
1519
|
+
devReloadWs.handleUpgrade(req, socket, head);
|
|
1520
|
+
return;
|
|
1521
|
+
}
|
|
1522
|
+
// User-registered WS route — enforces per-route auth, then drives the
|
|
1523
|
+
// open/message/close lifecycle on this connection. Returns false only when
|
|
1524
|
+
// no WS route matches this path.
|
|
1525
|
+
if (serveWebSocketRoute(req, socket, head)) {
|
|
1526
|
+
return;
|
|
1527
|
+
}
|
|
1528
|
+
// No dev-reload and no matching user route — refuse cleanly.
|
|
1529
|
+
try {
|
|
1530
|
+
socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
|
|
1531
|
+
socket.destroy();
|
|
1532
|
+
} catch {
|
|
1533
|
+
/* socket already gone */
|
|
1534
|
+
}
|
|
1535
|
+
});
|
|
1421
1536
|
|
|
1422
1537
|
return new Promise((resolvePromise) => {
|
|
1423
1538
|
server.listen(port, host, () => {
|
|
@@ -28,6 +28,8 @@ import { randomBytes } from "node:crypto";
|
|
|
28
28
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, readdirSync } from "node:fs";
|
|
29
29
|
import { join } from "node:path";
|
|
30
30
|
import { execFileSync } from "node:child_process";
|
|
31
|
+
import { Log } from "./logger.js";
|
|
32
|
+
import { isTruthy } from "./dotenv.js";
|
|
31
33
|
import { RedisNpmSessionHandler } from "./sessionHandlers/redisHandler.js";
|
|
32
34
|
import { ValkeySessionHandler } from "./sessionHandlers/valkeyHandler.js";
|
|
33
35
|
import { MongoSessionHandler } from "./sessionHandlers/mongoHandler.js";
|
|
@@ -196,7 +198,10 @@ export class RedisSessionHandler implements SessionHandler {
|
|
|
196
198
|
/**
|
|
197
199
|
* Execute a Redis command synchronously via a short-lived TCP connection.
|
|
198
200
|
*
|
|
199
|
-
* Returns the
|
|
201
|
+
* Returns the command result string. A genuine key miss yields `""`. A
|
|
202
|
+
* transport/connection FAILURE (server unreachable, AUTH error, timeout)
|
|
203
|
+
* THROWS so the Session boundary can distinguish "not found" (silent) from
|
|
204
|
+
* "backend failed" (log-loud + degrade). Backend-failure policy parity.
|
|
200
205
|
*/
|
|
201
206
|
private execSync(args: string[]): string {
|
|
202
207
|
const script = `
|
|
@@ -270,18 +275,24 @@ export class RedisSessionHandler implements SessionHandler {
|
|
|
270
275
|
setTimeout(() => { sock.destroy(); process.exit(1); }, 3000);
|
|
271
276
|
`;
|
|
272
277
|
|
|
278
|
+
let result: string;
|
|
273
279
|
try {
|
|
274
|
-
|
|
280
|
+
result = execFileSync(process.execPath, ["-e", script], {
|
|
275
281
|
encoding: "utf-8",
|
|
276
282
|
timeout: 5000,
|
|
277
283
|
stdio: ["pipe", "pipe", "pipe"],
|
|
278
284
|
});
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
285
|
+
} catch (err) {
|
|
286
|
+
// Non-zero exit = the child hit a socket error / AUTH failure / timeout.
|
|
287
|
+
// That is a transport FAILURE, not a key miss — surface it so the
|
|
288
|
+
// Session boundary logs + degrades (or re-throws under strict mode).
|
|
289
|
+
throw new Error(`Redis command failed: ${(err as Error).message}`);
|
|
290
|
+
}
|
|
291
|
+
if (result === "__NULL__") return ""; // genuine key miss
|
|
292
|
+
if (result.startsWith("__ERR__")) {
|
|
293
|
+
throw new Error(`Redis error: ${result.slice("__ERR__".length)}`);
|
|
284
294
|
}
|
|
295
|
+
return result;
|
|
285
296
|
}
|
|
286
297
|
|
|
287
298
|
private key(sessionId: string): string {
|
|
@@ -290,7 +301,7 @@ export class RedisSessionHandler implements SessionHandler {
|
|
|
290
301
|
|
|
291
302
|
read(sessionId: string): SessionData | null {
|
|
292
303
|
const raw = this.execSync(["GET", this.key(sessionId)]);
|
|
293
|
-
if (!raw) return null;
|
|
304
|
+
if (!raw) return null; // key miss — normal "no session yet", NOT an error
|
|
294
305
|
try {
|
|
295
306
|
return JSON.parse(raw) as SessionData;
|
|
296
307
|
} catch {
|
|
@@ -323,6 +334,20 @@ export class Session {
|
|
|
323
334
|
private ttl: number;
|
|
324
335
|
private sessionId: string | null = null;
|
|
325
336
|
private data: SessionData | null = null;
|
|
337
|
+
/**
|
|
338
|
+
* Dirty flag — set when data changes, cleared only on a successful write.
|
|
339
|
+
* Retained on a failed write so a later save() retries once the backend
|
|
340
|
+
* recovers (mirrors the Python `_dirty` semantics).
|
|
341
|
+
*/
|
|
342
|
+
private dirty = false;
|
|
343
|
+
/**
|
|
344
|
+
* Backend-failure policy: log-loud + degrade (default), or re-raise when
|
|
345
|
+
* TINA4_SESSION_STRICT is truthy. A read failure logs + yields an empty
|
|
346
|
+
* session, a write failure logs + returns false (best-effort, dirty
|
|
347
|
+
* retained), destroy/gc failures log + swallow. Parity across all four
|
|
348
|
+
* frameworks. Strict mode is the escape hatch (same as events/seeding).
|
|
349
|
+
*/
|
|
350
|
+
private strict: boolean;
|
|
326
351
|
|
|
327
352
|
constructor(backend?: string, config?: SessionConfig) {
|
|
328
353
|
const backendType = backend
|
|
@@ -333,6 +358,8 @@ export class Session {
|
|
|
333
358
|
this.ttl = config?.ttl
|
|
334
359
|
?? (process.env.TINA4_SESSION_TTL ? parseInt(process.env.TINA4_SESSION_TTL, 10) : 3600);
|
|
335
360
|
|
|
361
|
+
this.strict = isTruthy(process.env.TINA4_SESSION_STRICT);
|
|
362
|
+
|
|
336
363
|
// Select handler based on backend type
|
|
337
364
|
switch (backendType) {
|
|
338
365
|
case "redis":
|
|
@@ -370,6 +397,57 @@ export class Session {
|
|
|
370
397
|
this.handler = handler;
|
|
371
398
|
}
|
|
372
399
|
|
|
400
|
+
// ── Backend-failure policy: log-loud + degrade ─────────────────────
|
|
401
|
+
//
|
|
402
|
+
// The handlers themselves stay honest — they raise when the backend
|
|
403
|
+
// (Redis/Valkey/Mongo/DB) is unreachable, and return null/empty WITHOUT
|
|
404
|
+
// raising for a genuine "no session yet" miss. The Session layer is the
|
|
405
|
+
// single place that decides the resilience policy so every backend behaves
|
|
406
|
+
// the same: a transient outage logs + degrades rather than 500-ing every
|
|
407
|
+
// request (cascade outage) or vanishing silently (data loss). A genuinely
|
|
408
|
+
// empty result is NOT an error and never reaches these logs.
|
|
409
|
+
|
|
410
|
+
private logBackendError(op: string, err: unknown): void {
|
|
411
|
+
const handlerName = (this.handler as object)?.constructor?.name ?? "SessionHandler";
|
|
412
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
413
|
+
Log.error(`Session backend ${op} failed (${handlerName}): ${message}`);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Read through the backend; on FAILURE log + degrade to empty (or re-throw under strict). */
|
|
417
|
+
private safeRead(sessionId: string): SessionData | null {
|
|
418
|
+
try {
|
|
419
|
+
return this.handler.read(sessionId);
|
|
420
|
+
} catch (err) {
|
|
421
|
+
this.logBackendError("read", err);
|
|
422
|
+
if (this.strict) throw err;
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Write through the backend; on FAILURE log + return false (or re-throw under strict). */
|
|
428
|
+
private safeWrite(sessionId: string, data: SessionData, ttl: number): boolean {
|
|
429
|
+
try {
|
|
430
|
+
this.handler.write(sessionId, data, ttl);
|
|
431
|
+
return true;
|
|
432
|
+
} catch (err) {
|
|
433
|
+
this.logBackendError("write", err);
|
|
434
|
+
if (this.strict) throw err;
|
|
435
|
+
return false;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Destroy through the backend; on FAILURE log + swallow (or re-throw under strict). */
|
|
440
|
+
private safeDestroy(sessionId: string): boolean {
|
|
441
|
+
try {
|
|
442
|
+
this.handler.destroy(sessionId);
|
|
443
|
+
return true;
|
|
444
|
+
} catch (err) {
|
|
445
|
+
this.logBackendError("destroy", err);
|
|
446
|
+
if (this.strict) throw err;
|
|
447
|
+
return false;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
373
451
|
/**
|
|
374
452
|
* Start or resume a session.
|
|
375
453
|
* @param sessionId - Existing session ID to resume (optional)
|
|
@@ -377,17 +455,20 @@ export class Session {
|
|
|
377
455
|
*/
|
|
378
456
|
start(sessionId?: string): string {
|
|
379
457
|
if (sessionId) {
|
|
380
|
-
const loaded = this.
|
|
458
|
+
const loaded = this.safeRead(sessionId);
|
|
381
459
|
if (loaded) {
|
|
382
460
|
// Check TTL for file backend (Redis handles TTL natively)
|
|
383
461
|
const now = Math.floor(Date.now() / 1000);
|
|
384
462
|
if (loaded._accessed && (now - loaded._accessed) > this.ttl) {
|
|
385
|
-
this.
|
|
463
|
+
this.safeDestroy(sessionId);
|
|
386
464
|
} else {
|
|
387
465
|
this.sessionId = sessionId;
|
|
388
466
|
this.data = loaded;
|
|
389
467
|
this.data._accessed = now;
|
|
390
|
-
this.
|
|
468
|
+
this.dirty = false;
|
|
469
|
+
// Refresh the accessed timestamp; a write failure here is logged but
|
|
470
|
+
// must not abort the resume — the request still serves.
|
|
471
|
+
this.safeWrite(this.sessionId, this.data, this.ttl);
|
|
391
472
|
return sessionId;
|
|
392
473
|
}
|
|
393
474
|
}
|
|
@@ -397,7 +478,8 @@ export class Session {
|
|
|
397
478
|
this.sessionId = randomBytes(16).toString("hex");
|
|
398
479
|
const now = Math.floor(Date.now() / 1000);
|
|
399
480
|
this.data = { _created: now, _accessed: now };
|
|
400
|
-
this.
|
|
481
|
+
this.dirty = false;
|
|
482
|
+
this.safeWrite(this.sessionId, this.data, this.ttl);
|
|
401
483
|
return this.sessionId;
|
|
402
484
|
}
|
|
403
485
|
|
|
@@ -418,6 +500,7 @@ export class Session {
|
|
|
418
500
|
set(key: string, value: unknown): void {
|
|
419
501
|
if (!this.data) return;
|
|
420
502
|
this.data[key] = value;
|
|
503
|
+
this.dirty = true;
|
|
421
504
|
this.save();
|
|
422
505
|
}
|
|
423
506
|
|
|
@@ -427,18 +510,23 @@ export class Session {
|
|
|
427
510
|
delete(key: string): void {
|
|
428
511
|
if (!this.data) return;
|
|
429
512
|
delete this.data[key];
|
|
513
|
+
this.dirty = true;
|
|
430
514
|
this.save();
|
|
431
515
|
}
|
|
432
516
|
|
|
433
517
|
/**
|
|
434
518
|
* Destroy the entire session.
|
|
519
|
+
*
|
|
520
|
+
* A backend failure is logged (never silent) but does not throw under the
|
|
521
|
+
* default policy — local state is cleared regardless so the request proceeds.
|
|
435
522
|
*/
|
|
436
523
|
destroy(): void {
|
|
437
524
|
if (this.sessionId) {
|
|
438
|
-
this.
|
|
525
|
+
this.safeDestroy(this.sessionId);
|
|
439
526
|
}
|
|
440
527
|
this.sessionId = null;
|
|
441
528
|
this.data = null;
|
|
529
|
+
this.dirty = false;
|
|
442
530
|
}
|
|
443
531
|
|
|
444
532
|
/**
|
|
@@ -462,6 +550,7 @@ export class Session {
|
|
|
462
550
|
if (!this.data) return;
|
|
463
551
|
const now = Math.floor(Date.now() / 1000);
|
|
464
552
|
this.data = { _created: this.data._created, _accessed: now };
|
|
553
|
+
this.dirty = true;
|
|
465
554
|
this.save();
|
|
466
555
|
}
|
|
467
556
|
|
|
@@ -475,6 +564,11 @@ export class Session {
|
|
|
475
564
|
|
|
476
565
|
/**
|
|
477
566
|
* Regenerate the session ID (keeps data, new ID).
|
|
567
|
+
*
|
|
568
|
+
* Call this right after a successful login or any privilege change to defeat
|
|
569
|
+
* session fixation — the pre-auth ID is destroyed and the data is carried
|
|
570
|
+
* onto a fresh, unguessable ID. A backend destroy/write failure is logged
|
|
571
|
+
* (never silent) but does not throw under the default policy.
|
|
478
572
|
*/
|
|
479
573
|
regenerate(): string {
|
|
480
574
|
const oldId = this.sessionId;
|
|
@@ -482,13 +576,14 @@ export class Session {
|
|
|
482
576
|
|
|
483
577
|
// Remove old session
|
|
484
578
|
if (oldId) {
|
|
485
|
-
this.
|
|
579
|
+
this.safeDestroy(oldId);
|
|
486
580
|
}
|
|
487
581
|
|
|
488
582
|
// New ID, keep data
|
|
489
583
|
this.sessionId = randomBytes(16).toString("hex");
|
|
490
584
|
this.data = oldData ?? { _created: Math.floor(Date.now() / 1000), _accessed: Math.floor(Date.now() / 1000) };
|
|
491
585
|
this.data._accessed = Math.floor(Date.now() / 1000);
|
|
586
|
+
this.dirty = true;
|
|
492
587
|
this.save();
|
|
493
588
|
return this.sessionId;
|
|
494
589
|
}
|
|
@@ -510,6 +605,7 @@ export class Session {
|
|
|
510
605
|
if (!this.data || !(flashKey in this.data)) return undefined;
|
|
511
606
|
const stored = this.data[flashKey];
|
|
512
607
|
delete this.data[flashKey];
|
|
608
|
+
this.dirty = true;
|
|
513
609
|
this.save();
|
|
514
610
|
return stored;
|
|
515
611
|
}
|
|
@@ -545,19 +641,35 @@ export class Session {
|
|
|
545
641
|
/**
|
|
546
642
|
* Run garbage collection on the session backend.
|
|
547
643
|
* Removes expired file/database sessions. Redis/Valkey/Mongo handle TTL natively.
|
|
644
|
+
*
|
|
645
|
+
* A backend failure is logged (never silent) but does not throw under the
|
|
646
|
+
* default policy (re-raises under TINA4_SESSION_STRICT=true).
|
|
548
647
|
*/
|
|
549
648
|
gc(): void {
|
|
550
|
-
if (this.handler.gc)
|
|
649
|
+
if (!this.handler.gc) return;
|
|
650
|
+
try {
|
|
551
651
|
this.handler.gc(this.ttl);
|
|
652
|
+
} catch (err) {
|
|
653
|
+
this.logBackendError("gc", err);
|
|
654
|
+
if (this.strict) throw err;
|
|
552
655
|
}
|
|
553
656
|
}
|
|
554
657
|
|
|
555
658
|
/**
|
|
556
659
|
* Persist session data to the backend.
|
|
660
|
+
*
|
|
661
|
+
* Returns true on a successful persist, false if the backend was unreachable
|
|
662
|
+
* (logged). The dirty flag is cleared only on success so a later save()
|
|
663
|
+
* retries once the backend recovers. A nothing-to-persist call returns true.
|
|
557
664
|
*/
|
|
558
|
-
save():
|
|
559
|
-
if (!this.sessionId || !this.data) return;
|
|
560
|
-
|
|
665
|
+
save(): boolean {
|
|
666
|
+
if (!this.sessionId || !this.data) return true;
|
|
667
|
+
if (!this.dirty) return true;
|
|
668
|
+
if (this.safeWrite(this.sessionId, this.data, this.ttl)) {
|
|
669
|
+
this.dirty = false;
|
|
670
|
+
return true;
|
|
671
|
+
}
|
|
672
|
+
return false; // write failed (logged); dirty RETAINED for retry
|
|
561
673
|
}
|
|
562
674
|
}
|
|
563
675
|
|
|
@@ -22,6 +22,16 @@ interface SessionData {
|
|
|
22
22
|
export interface DatabaseSessionConfig {
|
|
23
23
|
/** SQLite database file path (default: extracted from TINA4_DATABASE_URL or "data/tina4_sessions.db") */
|
|
24
24
|
dbPath?: string;
|
|
25
|
+
// Unified SessionConfig fields are tolerated (and ignored) so the central
|
|
26
|
+
// Session can forward its config object without a structural mismatch.
|
|
27
|
+
backend?: string;
|
|
28
|
+
path?: string;
|
|
29
|
+
ttl?: number;
|
|
30
|
+
redisHost?: string;
|
|
31
|
+
redisPort?: number;
|
|
32
|
+
redisPassword?: string;
|
|
33
|
+
redisPrefix?: string;
|
|
34
|
+
redisDb?: number;
|
|
25
35
|
}
|
|
26
36
|
|
|
27
37
|
/**
|
|
@@ -30,6 +30,16 @@ export interface MongoSessionConfig {
|
|
|
30
30
|
password?: string;
|
|
31
31
|
database?: string;
|
|
32
32
|
collection?: string;
|
|
33
|
+
// Unified SessionConfig fields are tolerated (and ignored) so the central
|
|
34
|
+
// Session can forward its config object without a structural mismatch.
|
|
35
|
+
backend?: string;
|
|
36
|
+
path?: string;
|
|
37
|
+
ttl?: number;
|
|
38
|
+
redisHost?: string;
|
|
39
|
+
redisPort?: number;
|
|
40
|
+
redisPassword?: string;
|
|
41
|
+
redisPrefix?: string;
|
|
42
|
+
redisDb?: number;
|
|
33
43
|
}
|
|
34
44
|
|
|
35
45
|
/**
|
|
@@ -79,6 +89,11 @@ export class MongoSessionHandler implements SessionHandler {
|
|
|
79
89
|
* Uses the MongoDB wire protocol (OP_MSG) to communicate with the server.
|
|
80
90
|
* For simplicity, we use the `runCommand` approach with JSON serialization
|
|
81
91
|
* of BSON documents using a minimal BSON encoder.
|
|
92
|
+
*
|
|
93
|
+
* Returns the command response string (or `__EMPTY__` for an absent doc).
|
|
94
|
+
* A transport/connection FAILURE (server unreachable, socket error, timeout)
|
|
95
|
+
* THROWS so the Session boundary can distinguish "not found" (silent) from
|
|
96
|
+
* "backend failed" (log-loud + degrade). Backend-failure policy parity.
|
|
82
97
|
*/
|
|
83
98
|
private execSync(command: string, args: string): string {
|
|
84
99
|
const script = `
|
|
@@ -226,14 +241,16 @@ export class MongoSessionHandler implements SessionHandler {
|
|
|
226
241
|
`;
|
|
227
242
|
|
|
228
243
|
try {
|
|
229
|
-
|
|
244
|
+
return execFileSync(process.execPath, ["-e", script], {
|
|
230
245
|
encoding: "utf-8",
|
|
231
246
|
timeout: 8000,
|
|
232
247
|
stdio: ["pipe", "pipe", "pipe"],
|
|
233
248
|
});
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
249
|
+
} catch (err) {
|
|
250
|
+
// Non-zero exit = the child hit a socket error / timeout. That is a
|
|
251
|
+
// transport FAILURE, not an absent document — surface it so the Session
|
|
252
|
+
// boundary logs + degrades (or re-throws under strict mode).
|
|
253
|
+
throw new Error(`MongoDB command failed: ${(err as Error).message}`);
|
|
237
254
|
}
|
|
238
255
|
}
|
|
239
256
|
|
|
@@ -32,6 +32,16 @@ export interface RedisNpmSessionConfig {
|
|
|
32
32
|
password?: string;
|
|
33
33
|
prefix?: string;
|
|
34
34
|
db?: number;
|
|
35
|
+
// Unified SessionConfig fields are tolerated (and ignored) so the central
|
|
36
|
+
// Session can forward its config object without a structural mismatch.
|
|
37
|
+
backend?: string;
|
|
38
|
+
path?: string;
|
|
39
|
+
ttl?: number;
|
|
40
|
+
redisHost?: string;
|
|
41
|
+
redisPort?: number;
|
|
42
|
+
redisPassword?: string;
|
|
43
|
+
redisPrefix?: string;
|
|
44
|
+
redisDb?: number;
|
|
35
45
|
}
|
|
36
46
|
|
|
37
47
|
/**
|
|
@@ -78,6 +88,11 @@ export class RedisNpmSessionHandler implements SessionHandler {
|
|
|
78
88
|
*
|
|
79
89
|
* Attempts to use the `redis` npm package first. If unavailable, falls
|
|
80
90
|
* back to raw TCP (RESP protocol) — same as valkeyHandler.ts.
|
|
91
|
+
*
|
|
92
|
+
* Returns the command result string. A genuine key miss yields `""`. A
|
|
93
|
+
* transport/connection FAILURE (server unreachable, AUTH error, timeout)
|
|
94
|
+
* THROWS so the Session boundary can distinguish "not found" (silent) from
|
|
95
|
+
* "backend failed" (log-loud + degrade). Backend-failure policy parity.
|
|
81
96
|
*/
|
|
82
97
|
private execSync(args: string[]): string {
|
|
83
98
|
const script = `
|
|
@@ -187,18 +202,24 @@ export class RedisNpmSessionHandler implements SessionHandler {
|
|
|
187
202
|
}
|
|
188
203
|
`;
|
|
189
204
|
|
|
205
|
+
let result: string;
|
|
190
206
|
try {
|
|
191
|
-
|
|
207
|
+
result = execFileSync(process.execPath, ["-e", script], {
|
|
192
208
|
encoding: "utf-8",
|
|
193
209
|
timeout: 5000,
|
|
194
210
|
stdio: ["pipe", "pipe", "pipe"],
|
|
195
211
|
});
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
212
|
+
} catch (err) {
|
|
213
|
+
// Non-zero exit = the child hit a socket error / AUTH failure / timeout.
|
|
214
|
+
// That is a transport FAILURE, not a key miss — surface it so the
|
|
215
|
+
// Session boundary logs + degrades (or re-throws under strict mode).
|
|
216
|
+
throw new Error(`Redis command failed: ${(err as Error).message}`);
|
|
217
|
+
}
|
|
218
|
+
if (result === "__NULL__") return ""; // genuine key miss
|
|
219
|
+
if (result.startsWith("__ERR__")) {
|
|
220
|
+
throw new Error(`Redis error: ${result.slice("__ERR__".length)}`);
|
|
201
221
|
}
|
|
222
|
+
return result;
|
|
202
223
|
}
|
|
203
224
|
|
|
204
225
|
private key(sessionId: string): string {
|
|
@@ -207,7 +228,7 @@ export class RedisNpmSessionHandler implements SessionHandler {
|
|
|
207
228
|
|
|
208
229
|
read(sessionId: string): SessionData | null {
|
|
209
230
|
const raw = this.execSync(["GET", this.key(sessionId)]);
|
|
210
|
-
if (!raw) return null;
|
|
231
|
+
if (!raw) return null; // key miss — normal "no session yet", NOT an error
|
|
211
232
|
try {
|
|
212
233
|
return JSON.parse(raw) as SessionData;
|
|
213
234
|
} catch {
|