tina4-nodejs 3.13.94 → 3.13.96

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/CLAUDE.md +158 -30
  2. package/README.md +1 -1
  3. package/package.json +3 -1
  4. package/packages/cli/dist/bin.js +30911 -28444
  5. package/packages/cli/src/commands/metrics.ts +17 -11
  6. package/packages/cli/src/commands/serve.ts +10 -9
  7. package/packages/core/dist/index.js +30810 -28261
  8. package/packages/core/public/css/tina4.min.css +1 -1
  9. package/packages/core/src/ai.ts +7 -1
  10. package/packages/core/src/auth.ts +191 -39
  11. package/packages/core/src/background.ts +19 -19
  12. package/packages/core/src/cache.ts +492 -49
  13. package/packages/core/src/devAdmin.ts +79 -32
  14. package/packages/core/src/dispatchPipeline.ts +285 -0
  15. package/packages/core/src/dotenv.ts +185 -40
  16. package/packages/core/src/index.ts +6 -7
  17. package/packages/core/src/logger.ts +257 -36
  18. package/packages/core/src/mcp.ts +1 -1
  19. package/packages/core/src/messenger.ts +294 -106
  20. package/packages/core/src/metrics.ts +199 -961
  21. package/packages/core/src/middleware.ts +390 -123
  22. package/packages/core/src/queue.ts +188 -32
  23. package/packages/core/src/queueBackends/kafkaBackend.ts +1 -1
  24. package/packages/core/src/queueBackends/liteBackend.ts +13 -0
  25. package/packages/core/src/queueBackends/mongoBackend.ts +101 -9
  26. package/packages/core/src/queueBackends/rabbitmqBackend.ts +22 -4
  27. package/packages/core/src/rateLimiter.ts +10 -5
  28. package/packages/core/src/request.ts +34 -16
  29. package/packages/core/src/response.ts +46 -1
  30. package/packages/core/src/router.ts +29 -4
  31. package/packages/core/src/server.ts +886 -421
  32. package/packages/core/src/session.ts +244 -27
  33. package/packages/core/src/sessionHandlers/databaseHandler.ts +338 -48
  34. package/packages/core/src/sessionHandlers/memcachedHandler.ts +181 -0
  35. package/packages/core/src/sessionHandlers/mongoClient.ts +293 -208
  36. package/packages/core/src/sessionHandlers/mongoHandler.ts +88 -8
  37. package/packages/core/src/sessionHandlers/respClient.ts +16 -147
  38. package/packages/core/src/sessionHandlers/sqlClient.ts +290 -0
  39. package/packages/core/src/sessionHandlers/syncBridge.ts +190 -0
  40. package/packages/core/src/sessionHandlers/syncSocket.ts +236 -0
  41. package/packages/core/src/testClient.ts +18 -5
  42. package/packages/core/src/trustedProxy.ts +249 -0
  43. package/packages/core/src/types.ts +29 -5
  44. package/packages/core/src/websocket.ts +66 -0
  45. package/packages/orm/dist/index.js +22717 -20168
  46. package/packages/orm/src/adapters/firebird.ts +183 -56
  47. package/packages/orm/src/adapters/mongodb.ts +25 -4
  48. package/packages/orm/src/adapters/mssql.ts +114 -29
  49. package/packages/orm/src/adapters/mysql.ts +103 -40
  50. package/packages/orm/src/adapters/odbc.ts +44 -21
  51. package/packages/orm/src/adapters/postgres.ts +118 -26
  52. package/packages/orm/src/adapters/sqlDialect.ts +120 -0
  53. package/packages/orm/src/adapters/sqlite.ts +60 -24
  54. package/packages/orm/src/autoCrud.ts +12 -10
  55. package/packages/orm/src/baseModel.ts +135 -40
  56. package/packages/orm/src/cachedDatabase.ts +43 -19
  57. package/packages/orm/src/connectTimeout.ts +265 -0
  58. package/packages/orm/src/database.ts +241 -197
  59. package/packages/orm/src/databaseResult.ts +51 -28
  60. package/packages/orm/src/databaseUrl.ts +484 -0
  61. package/packages/orm/src/docstore.ts +386 -145
  62. package/packages/orm/src/index.ts +13 -6
  63. package/packages/orm/src/migration.ts +44 -11
  64. package/packages/orm/src/model.ts +4 -0
  65. package/packages/orm/src/queryBuilder.ts +47 -6
  66. package/packages/orm/src/sqlTranslator.ts +310 -4
  67. package/packages/orm/src/types.ts +21 -77
  68. package/packages/swagger/dist/index.js +78 -20
  69. package/packages/swagger/src/generator.ts +172 -29
  70. package/types/core/src/ai.d.ts +1 -1
  71. package/types/core/src/auth.d.ts +28 -5
  72. package/types/core/src/background.d.ts +3 -3
  73. package/types/core/src/cache.d.ts +15 -12
  74. package/types/core/src/dispatchPipeline.d.ts +117 -0
  75. package/types/core/src/dotenv.d.ts +38 -16
  76. package/types/core/src/index.d.ts +6 -9
  77. package/types/core/src/logger.d.ts +93 -16
  78. package/types/core/src/messenger.d.ts +47 -6
  79. package/types/core/src/metrics.d.ts +25 -61
  80. package/types/core/src/middleware.d.ts +134 -11
  81. package/types/core/src/queue.d.ts +54 -5
  82. package/types/core/src/queueBackends/kafkaBackend.d.ts +1 -1
  83. package/types/core/src/queueBackends/liteBackend.d.ts +9 -0
  84. package/types/core/src/queueBackends/mongoBackend.d.ts +24 -2
  85. package/types/core/src/queueBackends/rabbitmqBackend.d.ts +3 -3
  86. package/types/core/src/router.d.ts +14 -3
  87. package/types/core/src/server.d.ts +15 -4
  88. package/types/core/src/session.d.ts +87 -2
  89. package/types/core/src/sessionHandlers/databaseHandler.d.ts +60 -5
  90. package/types/core/src/sessionHandlers/memcachedHandler.d.ts +60 -0
  91. package/types/core/src/sessionHandlers/mongoClient.d.ts +16 -5
  92. package/types/core/src/sessionHandlers/mongoHandler.d.ts +51 -3
  93. package/types/core/src/sessionHandlers/respClient.d.ts +2 -2
  94. package/types/core/src/sessionHandlers/sqlClient.d.ts +39 -0
  95. package/types/core/src/sessionHandlers/syncBridge.d.ts +91 -0
  96. package/types/core/src/sessionHandlers/syncSocket.d.ts +49 -0
  97. package/types/core/src/trustedProxy.d.ts +44 -0
  98. package/types/core/src/types.d.ts +28 -5
  99. package/types/core/src/websocket.d.ts +26 -0
  100. package/types/orm/src/adapters/firebird.d.ts +55 -10
  101. package/types/orm/src/adapters/mongodb.d.ts +2 -2
  102. package/types/orm/src/adapters/mssql.d.ts +18 -11
  103. package/types/orm/src/adapters/mysql.d.ts +11 -10
  104. package/types/orm/src/adapters/odbc.d.ts +9 -12
  105. package/types/orm/src/adapters/postgres.d.ts +11 -10
  106. package/types/orm/src/adapters/sqlDialect.d.ts +71 -0
  107. package/types/orm/src/adapters/sqlite.d.ts +15 -3
  108. package/types/orm/src/baseModel.d.ts +45 -9
  109. package/types/orm/src/cachedDatabase.d.ts +18 -5
  110. package/types/orm/src/connectTimeout.d.ts +100 -0
  111. package/types/orm/src/database.d.ts +78 -28
  112. package/types/orm/src/databaseResult.d.ts +29 -15
  113. package/types/orm/src/databaseUrl.d.ts +125 -0
  114. package/types/orm/src/docstore.d.ts +102 -43
  115. package/types/orm/src/index.d.ts +6 -4
  116. package/types/orm/src/migration.d.ts +4 -3
  117. package/types/orm/src/queryBuilder.d.ts +23 -3
  118. package/types/orm/src/sqlTranslator.d.ts +126 -2
  119. package/types/orm/src/types.d.ts +21 -38
  120. package/packages/core/src/scss.ts +0 -623
  121. package/packages/core/src/sessionHandlers/redisHandler.ts +0 -219
  122. package/types/core/src/scss.d.ts +0 -19
  123. package/types/core/src/sessionHandlers/redisHandler.d.ts +0 -60
@@ -24,21 +24,21 @@
24
24
  * session.get("user"); // { name: "Alice" }
25
25
  * session.destroy();
26
26
  */
27
- import { randomBytes } from "node:crypto";
27
+ import { createHash, 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 { Log } from "./logger.js";
31
31
  import { isTruthy } from "./dotenv.js";
32
32
  import { respCommandSync } from "./sessionHandlers/respClient.js";
33
- import { RedisNpmSessionHandler } from "./sessionHandlers/redisHandler.js";
34
33
  import { ValkeySessionHandler } from "./sessionHandlers/valkeyHandler.js";
34
+ import { MemcachedSessionHandler } from "./sessionHandlers/memcachedHandler.js";
35
35
  import { MongoSessionHandler } from "./sessionHandlers/mongoHandler.js";
36
36
  import { DatabaseSessionHandler } from "./sessionHandlers/databaseHandler.js";
37
37
 
38
38
  // ── Types ─────────────────────────────────────────────────────────
39
39
 
40
40
  export interface SessionConfig {
41
- /** Session backend type: "file", "redis", "valkey", "mongo", "database" (or "db") */
41
+ /** Session backend type: "file", "redis", "valkey", "mongo", "memcached", "database" (or "db") */
42
42
  backend?: string;
43
43
  /** File storage path (default: "data/sessions") */
44
44
  path?: string;
@@ -62,6 +62,104 @@ interface SessionData {
62
62
  [key: string]: unknown;
63
63
  }
64
64
 
65
+ // ── Session id validation ─────────────────────────────────────────
66
+
67
+ /**
68
+ * A session id is OPAQUE — an unguessable lookup token and nothing else. It is
69
+ * never a filename, a path, a SQL fragment or a Redis key fragment, so the only
70
+ * characters it may contain are the ones every backend treats as inert.
71
+ *
72
+ * The alphabet is the RFC 4648 base64url set, which is exactly what all four
73
+ * frameworks already mint: Python `secrets.token_urlsafe(32)`, Ruby
74
+ * `SecureRandom.hex(32)`, PHP/Node `hex(16)`. Validation is therefore
75
+ * non-breaking for every id the family has ever issued, while rejecting the
76
+ * `.` and `/` that turn a cookie into a path traversal.
77
+ *
78
+ * The rule is the ALPHABET, not the length. The vulnerability was `.` and `/`
79
+ * turning a cookie into a path; entropy is not something this check can supply,
80
+ * because unguessability comes from the framework's own minting and an app that
81
+ * calls `start("my-session-id")` is a trusted caller managing its own id, not an
82
+ * attacker. So the floor is 1, and only the 128-character ceiling remains — it
83
+ * bounds what an attacker can push through a backend key.
84
+ */
85
+ const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
86
+
87
+ /**
88
+ * Is `sessionId` a well-formed opaque session identifier?
89
+ *
90
+ * Callers pass UNTRUSTED input here (the session cookie is attacker-chosen), so
91
+ * anything that is not a string of the opaque alphabet is rejected.
92
+ */
93
+ export function isValidSessionId(sessionId: unknown): boolean {
94
+ return typeof sessionId === "string" && SESSION_ID_PATTERN.test(sessionId);
95
+ }
96
+
97
+ // ── Backend name resolution ───────────────────────────────────────
98
+
99
+ /**
100
+ * Every accepted backend name, aliases included. Byte-identical membership in
101
+ * all four frameworks. Written once here so the switch below and the error
102
+ * message cannot disagree.
103
+ */
104
+ export const VALID_SESSION_BACKENDS = [
105
+ "file", "filesystem",
106
+ "redis",
107
+ "valkey",
108
+ "mongodb", "mongo",
109
+ "memcached", "memcache",
110
+ "database", "db",
111
+ ] as const;
112
+
113
+ /** Canonical name of each backend, for the error message (aliases omitted). */
114
+ export const CANONICAL_SESSION_BACKENDS = [
115
+ "file", "redis", "valkey", "mongodb", "memcached", "database",
116
+ ] as const;
117
+
118
+ /**
119
+ * Names that USED to work, mapped to the message a caller needs to migrate.
120
+ *
121
+ * A retired name must not fall into the generic "unknown backend" message: the
122
+ * operator had a working config, and telling them what replaced it is the whole
123
+ * point. Checked before the membership test so the specific message wins.
124
+ */
125
+ const RETIRED_SESSION_BACKENDS: Record<string, string> = {
126
+ "redis-npm":
127
+ 'TINA4_SESSION_BACKEND="redis-npm" was removed on 2026-07-31. Use "redis": '
128
+ + "it is the same Redis backend and reads the same TINA4_SESSION_REDIS_* "
129
+ + "settings, over a faster persistent connection.",
130
+ };
131
+
132
+ /**
133
+ * Normalise a backend name and reject anything unrecognised.
134
+ *
135
+ * An UNKNOWN name used to fall through to `default:` in the switch below, which
136
+ * is the file handler. A typo in TINA4_SESSION_BACKEND ("redsi") - or, before
137
+ * this normalisation existed, merely a capital ("Redis") - produced a running
138
+ * app writing sessions to local disk while the operator believed they were in
139
+ * Redis. Nothing logged, nothing failed, and the symptom surfaced much later as
140
+ * users being logged out whenever a request landed on another instance.
141
+ *
142
+ * A BLANK name still means file. An env var set to "" is a SET variable, so it
143
+ * never reaches the `??` default; rejecting blank would break every deployment
144
+ * that clears the var to take the default.
145
+ */
146
+ function resolveBackend(name: string): string {
147
+ const normalised = String(name).trim().toLowerCase();
148
+ if (normalised === "") return "file";
149
+
150
+ const retired = RETIRED_SESSION_BACKENDS[normalised];
151
+ if (retired) throw new Error(retired);
152
+
153
+ if (!(VALID_SESSION_BACKENDS as readonly string[]).includes(normalised)) {
154
+ throw new Error(
155
+ `Unknown session backend "${normalised}". `
156
+ + `Valid backends: ${CANONICAL_SESSION_BACKENDS.join(", ")}. `
157
+ + "Leave TINA4_SESSION_BACKEND unset for the file default.",
158
+ );
159
+ }
160
+ return normalised;
161
+ }
162
+
65
163
  // ── Session Handler Interface ─────────────────────────────────────
66
164
 
67
165
  /**
@@ -93,8 +191,38 @@ export class FileSessionHandler implements SessionHandler {
93
191
  }
94
192
  }
95
193
 
194
+ /**
195
+ * Derive the file backing a session id. TWO independent guards, both required.
196
+ *
197
+ * This is the one place a session id becomes a filesystem path, and it used to
198
+ * interpolate the id RAW: `join(storagePath, "../../OUTSIDE/appconfig.json")`
199
+ * left the session directory entirely, so a cookie could read an existing
200
+ * .json from anywhere on disk into `session.all()` and then OVERWRITE it on
201
+ * save. Reproduced on Node 24.9.0 / macOS.
202
+ *
203
+ * 1. VALIDATE — a malformed id is refused outright. It throws rather than
204
+ * returning null so a hostile id can never be mistaken for an ordinary
205
+ * cache miss (the Session layer catches it, logs it, and degrades).
206
+ * 2. HASH — the filename is a SHA-256 of the id, matching the Python master
207
+ * (`hashlib.sha256(session_id.encode()).hexdigest()`). A hex digest cannot
208
+ * contain a separator or a dot, so even an id that somehow passed the
209
+ * validator can only ever name a file inside `storagePath`.
210
+ *
211
+ * DEPLOY NOTE: hashing CHANGES the filename for every id, so existing on-disk
212
+ * sessions are orphaned and every logged-in user is logged out ONCE on the
213
+ * deploy that ships this. That is accepted: under strict session mode an old
214
+ * cookie is discarded on a read miss anyway, so the sessions were going to be
215
+ * dropped regardless.
216
+ */
96
217
  private filePath(id: string): string {
97
- return join(this.storagePath, `${id}.json`);
218
+ if (!isValidSessionId(id)) {
219
+ throw new Error(
220
+ `Invalid session id ${JSON.stringify(id)} — a session id is opaque and must match `
221
+ + `${SESSION_ID_PATTERN.source}. It is never a path, so it is refused rather than `
222
+ + "resolved to a file.",
223
+ );
224
+ }
225
+ return join(this.storagePath, `${createHash("sha256").update(id).digest("hex")}.json`);
98
226
  }
99
227
 
100
228
  read(sessionId: string): SessionData | null {
@@ -264,12 +392,19 @@ export class Session {
264
392
  * frameworks. Strict mode is the escape hatch (same as events/seeding).
265
393
  */
266
394
  private strict: boolean;
395
+ /**
396
+ * True when the LAST backend read RAISED rather than returning a miss.
397
+ * Lets `start()` tell "no such session" from "the store is unreachable".
398
+ */
399
+ private lastReadFailed = false;
267
400
 
268
401
  constructor(backend?: string, config?: SessionConfig) {
269
- const backendType = backend
270
- ?? config?.backend
271
- ?? process.env.TINA4_SESSION_BACKEND
272
- ?? "file";
402
+ const backendType = resolveBackend(
403
+ backend
404
+ ?? config?.backend
405
+ ?? process.env.TINA4_SESSION_BACKEND
406
+ ?? "file",
407
+ );
273
408
 
274
409
  this.ttl = config?.ttl
275
410
  ?? (process.env.TINA4_SESSION_TTL ? parseInt(process.env.TINA4_SESSION_TTL, 10) : 3600);
@@ -281,10 +416,11 @@ export class Session {
281
416
  case "redis":
282
417
  this.handler = new RedisSessionHandler(config);
283
418
  break;
284
- case "redis-npm": {
285
- this.handler = new RedisNpmSessionHandler(config);
286
- break;
287
- }
419
+ // `redis-npm` was RETIRED here on 2026-07-31 (a Node-only backend NAME for
420
+ // the optional `redis` npm driver, still running execFileSync per command).
421
+ // Its rejection now lives in RETIRED_SESSION_BACKENDS above, so the helpful
422
+ // migration message survives the generic unknown-name check rather than
423
+ // being swallowed by it.
288
424
  case "valkey": {
289
425
  this.handler = new ValkeySessionHandler(config);
290
426
  break;
@@ -294,15 +430,30 @@ export class Session {
294
430
  this.handler = new MongoSessionHandler(config);
295
431
  break;
296
432
  }
433
+ case "memcached":
434
+ case "memcache": {
435
+ this.handler = new MemcachedSessionHandler(config);
436
+ break;
437
+ }
297
438
  case "database":
298
439
  case "db": {
299
440
  this.handler = new DatabaseSessionHandler(config);
300
441
  break;
301
442
  }
302
443
  case "file":
303
- default:
444
+ case "filesystem":
304
445
  this.handler = new FileSessionHandler(config?.path);
305
446
  break;
447
+ default:
448
+ // Unreachable for a user's typo - resolveBackend already rejected it.
449
+ // Only a name that IS in VALID_SESSION_BACKENDS but has no case above can
450
+ // land here, which is a bug in this switch rather than a configuration
451
+ // error, so it must not be swallowed into a file handler either.
452
+ throw new Error(
453
+ `Session backend "${backendType}" is listed in VALID_SESSION_BACKENDS `
454
+ + "but has no handler case. This is a framework bug, not a "
455
+ + "configuration error.",
456
+ );
306
457
  }
307
458
  }
308
459
 
@@ -329,11 +480,23 @@ export class Session {
329
480
  Log.error(`Session backend ${op} failed (${handlerName}): ${message}`);
330
481
  }
331
482
 
332
- /** Read through the backend; on FAILURE log + degrade to empty (or re-throw under strict). */
483
+ /**
484
+ * Read through the backend; on FAILURE log + degrade to empty (or re-throw
485
+ * under strict).
486
+ *
487
+ * Sets {@link lastReadFailed} so `start()` can tell "the store answered, and
488
+ * has no such session" from "the store did not answer at all". Strict mode
489
+ * must discard an id only on the first: treating an outage as an unknown id
490
+ * rotates the session id on EVERY request for the whole outage, logging the
491
+ * entire userbase out over one Redis blip and orphaning their stored
492
+ * sessions. The policy is log-loud + degrade, never rotate.
493
+ */
333
494
  private safeRead(sessionId: string): SessionData | null {
495
+ this.lastReadFailed = false;
334
496
  try {
335
497
  return this.handler.read(sessionId);
336
498
  } catch (err) {
499
+ this.lastReadFailed = true;
337
500
  this.logBackendError("read", err);
338
501
  if (this.strict) throw err;
339
502
  return null;
@@ -366,27 +529,66 @@ export class Session {
366
529
 
367
530
  /**
368
531
  * Start or resume a session.
532
+ *
533
+ * `sessionId` is UNTRUSTED — it arrives from the session cookie, which the
534
+ * client fully controls. An id that is not a well-formed opaque identifier is
535
+ * DISCARDED and a fresh one minted, never adopted: adopting it let a cookie
536
+ * steer a filesystem path (a `tina4_session=../../OUTSIDE/appconfig` cookie
537
+ * read an existing .json from outside the session directory into
538
+ * `session.all()`, then OVERWROTE it on save) and let an attacker pre-plant a
539
+ * session id that survived the victim's login (session fixation).
540
+ *
541
+ * The check runs BEFORE the read, so a hostile id never reaches a handler at
542
+ * all. A legitimate id from any of the four frameworks passes unchanged.
543
+ *
544
+ * STRICT SESSION MODE (deliberate, and Node is the family reference for it):
545
+ * an id that is WELL-FORMED but UNKNOWN to the backend is also discarded and
546
+ * a fresh one minted — the `if (loaded)` below only adopts an id the store
547
+ * actually knows. That is OWASP's strict mode and PHP's own
548
+ * `session.use_strict_mode=1` default, and it is what stops an attacker
549
+ * planting a session id that survives the victim's login. The validation
550
+ * above sits IN FRONT of it; neither replaces the other.
551
+ *
369
552
  * @param sessionId - Existing session ID to resume (optional)
370
553
  * @returns The session ID
371
554
  */
372
555
  start(sessionId?: string): string {
556
+ if (sessionId !== undefined && !isValidSessionId(sessionId)) {
557
+ sessionId = undefined;
558
+ }
373
559
  if (sessionId) {
374
560
  const loaded = this.safeRead(sessionId);
561
+ if (!loaded && this.lastReadFailed) {
562
+ // The store did not ANSWER. That is not evidence the id is unknown, so
563
+ // keep it and degrade to an empty session rather than rotating.
564
+ this.sessionId = sessionId;
565
+ const now = Math.floor(Date.now() / 1000);
566
+ this.data = { _created: now, _accessed: now };
567
+ this.dirty = false;
568
+ return sessionId;
569
+ }
375
570
  if (loaded) {
376
- // Check TTL for file backend (Redis handles TTL natively)
571
+ // Expiry is the HANDLER's job, and it is decided from the record's own
572
+ // absolute deadline. There used to be a SECOND, relative check here —
573
+ // `loaded._accessed && (now - loaded._accessed) > this.ttl` followed by
574
+ // safeDestroy() — running in series with the handler's absolute one.
575
+ //
576
+ // That is the exact shape that made tina4-php's file backend destroy
577
+ // records on read (a missing stamp fed into a subtraction, failure branch
578
+ // deletes). It was defused here only by the leading `loaded._accessed &&`
579
+ // short-circuit, and it was a landmine: two expiry mechanisms on the same
580
+ // data, one of them the broken shape, and this one judged a stored record
581
+ // against whatever ttl the READER happened to carry rather than the
582
+ // deadline the record was written with.
377
583
  const now = Math.floor(Date.now() / 1000);
378
- if (loaded._accessed && (now - loaded._accessed) > this.ttl) {
379
- this.safeDestroy(sessionId);
380
- } else {
381
- this.sessionId = sessionId;
382
- this.data = loaded;
383
- this.data._accessed = now;
384
- this.dirty = false;
385
- // Refresh the accessed timestamp; a write failure here is logged but
386
- // must not abort the resume — the request still serves.
387
- this.safeWrite(this.sessionId, this.data, this.ttl);
388
- return sessionId;
389
- }
584
+ this.sessionId = sessionId;
585
+ this.data = loaded;
586
+ this.data._accessed = now;
587
+ this.dirty = false;
588
+ // Refresh the accessed timestamp; a write failure here is logged but
589
+ // must not abort the resume — the request still serves.
590
+ this.safeWrite(this.sessionId, this.data, this.ttl);
591
+ return sessionId;
390
592
  }
391
593
  }
392
594
 
@@ -634,6 +836,21 @@ export function sessionCookieName(): string {
634
836
  return process.env.TINA4_SESSION_NAME ?? "tina4_session";
635
837
  }
636
838
 
839
+ /**
840
+ * Is TINA4_SESSION_STRICT on? The single source of truth for the flag.
841
+ *
842
+ * Module level, not a Session field, because the REQUEST PATH has to be able to
843
+ * consult it when a Session could not be constructed at all - a handler whose
844
+ * constructor raises, or a refused TINA4_SESSION_BACKEND, both fail BEFORE
845
+ * there is an object to ask. That gap is why strict mode used to be inert on
846
+ * the request path. Parity with Python `session.session_strict_mode()`.
847
+ *
848
+ * TINA4_SESSION_STRICT re-throw instead of degrading (default: false)
849
+ */
850
+ export function sessionStrictMode(): boolean {
851
+ return isTruthy(process.env.TINA4_SESSION_STRICT);
852
+ }
853
+
637
854
  /**
638
855
  * Build the `Set-Cookie` header value for a Tina4 session. Centralised so
639
856
  * the auto-cookie path in server.ts and `Session.cookieHeader()` agree on