tina4-nodejs 3.13.141 → 3.13.143

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 (67) hide show
  1. package/CLAUDE.md +28 -2
  2. package/package.json +1 -1
  3. package/packages/cli/dist/bin.js +817 -538
  4. package/packages/cli/src/bin.ts +4 -14
  5. package/packages/cli/src/commands/build.ts +2 -18
  6. package/packages/cli/src/commands/generate.ts +9 -24
  7. package/packages/cli/src/commands/init.ts +18 -8
  8. package/packages/cli/src/commands/lint.ts +3 -13
  9. package/packages/cli/src/commands/migrate.ts +2 -6
  10. package/packages/cli/src/commands/migrateRollback.ts +2 -6
  11. package/packages/cli/src/commands/migrateStatus.ts +2 -6
  12. package/packages/cli/src/commands/queue.ts +2 -30
  13. package/packages/cli/src/util.ts +90 -0
  14. package/packages/core/dist/index.js +592 -262
  15. package/packages/core/gallery/auth/src/routes/gallery/auth/get.ts +27 -11
  16. package/packages/core/gallery/queue/src/lib/queueDb.ts +11 -0
  17. package/packages/core/gallery/queue/src/routes/api/gallery/queue/consume/post.ts +2 -5
  18. package/packages/core/gallery/queue/src/routes/api/gallery/queue/fail/post.ts +2 -5
  19. package/packages/core/gallery/queue/src/routes/gallery/queue/get.ts +20 -7
  20. package/packages/core/gallery/templates/src/templates/gallery_page.twig +34 -15
  21. package/packages/core/src/csp.ts +138 -0
  22. package/packages/core/src/devAdmin.ts +3 -12
  23. package/packages/core/src/dispatchPipeline.ts +2 -18
  24. package/packages/core/src/errorOverlay.ts +80 -37
  25. package/packages/core/src/etag.ts +24 -0
  26. package/packages/core/src/feedback.ts +2 -11
  27. package/packages/core/src/index.ts +10 -0
  28. package/packages/core/src/middleware.ts +20 -6
  29. package/packages/core/src/response.ts +14 -3
  30. package/packages/core/src/server.ts +52 -15
  31. package/packages/core/src/session.ts +174 -71
  32. package/packages/core/src/sessionHandlers/respSessionHandler.ts +94 -0
  33. package/packages/core/src/sessionHandlers/valkeyHandler.ts +22 -73
  34. package/packages/core/src/sso.ts +1 -1
  35. package/packages/core/src/static.ts +1 -13
  36. package/packages/core/src/types.ts +8 -0
  37. package/packages/core/src/upstreamResponse.ts +31 -0
  38. package/packages/core/src/websocket.ts +48 -7
  39. package/packages/core/templates/errors/302.twig +1 -1
  40. package/packages/core/templates/errors/401.twig +2 -2
  41. package/packages/core/templates/errors/403.twig +1 -1
  42. package/packages/core/templates/errors/404.twig +1 -1
  43. package/packages/core/templates/errors/500.twig +1 -1
  44. package/packages/core/templates/errors/502.twig +2 -2
  45. package/packages/core/templates/errors/503.twig +2 -2
  46. package/packages/core/templates/errors/base.twig +4 -1
  47. package/packages/frond/dist/index.js +2 -0
  48. package/packages/frond/src/engine.ts +23 -0
  49. package/packages/orm/dist/index.js +593 -271
  50. package/packages/orm/src/graph/adapters/bolt.ts +8 -29
  51. package/packages/orm/src/graph/adapters/rowMapping.ts +57 -0
  52. package/packages/orm/src/graph/adapters/ultipa.ts +8 -30
  53. package/packages/swagger/dist/index.js +4 -4
  54. package/packages/swagger/src/ui.ts +7 -4
  55. package/types/cli/src/util.d.ts +28 -0
  56. package/types/core/src/csp.d.ts +42 -0
  57. package/types/core/src/etag.d.ts +9 -0
  58. package/types/core/src/index.d.ts +1 -0
  59. package/types/core/src/middleware.d.ts +6 -0
  60. package/types/core/src/session.d.ts +64 -21
  61. package/types/core/src/sessionHandlers/respSessionHandler.d.ts +52 -0
  62. package/types/core/src/sessionHandlers/valkeyHandler.d.ts +2 -26
  63. package/types/core/src/types.d.ts +8 -0
  64. package/types/core/src/upstreamResponse.d.ts +10 -0
  65. package/types/core/src/websocket.d.ts +21 -0
  66. package/types/frond/src/engine.d.ts +7 -0
  67. package/types/orm/src/graph/adapters/rowMapping.d.ts +26 -0
@@ -37,7 +37,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, readdir
37
37
  import { join } from "node:path";
38
38
  import { Log } from "./logger.js";
39
39
  import { isTruthy } from "./dotenv.js";
40
- import { respCommandSync } from "./sessionHandlers/respClient.js";
40
+ import { RespSessionHandler } from "./sessionHandlers/respSessionHandler.js";
41
41
  import { ValkeySessionHandler } from "./sessionHandlers/valkeyHandler.js";
42
42
  import { MemcachedSessionHandler } from "./sessionHandlers/memcachedHandler.js";
43
43
  import { MongoSessionHandler } from "./sessionHandlers/mongoHandler.js";
@@ -304,81 +304,59 @@ export class FileSessionHandler implements SessionHandler {
304
304
  *
305
305
  * Or pass via SessionConfig.
306
306
  */
307
- export class RedisSessionHandler implements SessionHandler {
308
- private host: string;
309
- private port: number;
310
- private password: string;
311
- private prefix: string;
312
- private db: number;
313
-
307
+ export class RedisSessionHandler extends RespSessionHandler {
314
308
  constructor(config?: SessionConfig) {
315
- this.host = config?.redisHost
316
- ?? process.env.TINA4_SESSION_REDIS_HOST
317
- ?? "127.0.0.1";
318
- this.port = config?.redisPort
319
- ?? (process.env.TINA4_SESSION_REDIS_PORT
320
- ? parseInt(process.env.TINA4_SESSION_REDIS_PORT, 10)
321
- : 6379);
322
- this.password = config?.redisPassword
323
- ?? process.env.TINA4_SESSION_REDIS_PASSWORD
324
- ?? "";
325
- this.prefix = config?.redisPrefix
326
- ?? process.env.TINA4_SESSION_REDIS_PREFIX
327
- ?? "tina4:session:";
328
- this.db = config?.redisDb
329
- ?? (process.env.TINA4_SESSION_REDIS_DB
330
- ? parseInt(process.env.TINA4_SESSION_REDIS_DB, 10)
331
- : 0);
332
- }
333
-
334
- /**
335
- * Execute a Redis command synchronously against the live server.
336
- *
337
- * Delegates to the shared {@link respCommandSync} transport: a genuine key miss
338
- * yields `""`, and a transport/connection FAILURE (server unreachable, rejected
339
- * AUTH, timeout) THROWS so the Session boundary can distinguish "not found"
340
- * (silent) from "backend failed" (log-loud + degrade). Backend-failure parity.
341
- */
342
- private execSync(args: string[]): string {
343
- return respCommandSync(
344
- { host: this.host, port: this.port, password: this.password, db: this.db },
345
- args,
309
+ super(
310
+ {
311
+ host: config?.redisHost
312
+ ?? process.env.TINA4_SESSION_REDIS_HOST
313
+ ?? "127.0.0.1",
314
+ port: config?.redisPort
315
+ ?? (process.env.TINA4_SESSION_REDIS_PORT
316
+ ? parseInt(process.env.TINA4_SESSION_REDIS_PORT, 10)
317
+ : 6379),
318
+ password: config?.redisPassword
319
+ ?? process.env.TINA4_SESSION_REDIS_PASSWORD
320
+ ?? "",
321
+ prefix: config?.redisPrefix
322
+ ?? process.env.TINA4_SESSION_REDIS_PREFIX
323
+ ?? "tina4:session:",
324
+ db: config?.redisDb
325
+ ?? (process.env.TINA4_SESSION_REDIS_DB
326
+ ? parseInt(process.env.TINA4_SESSION_REDIS_DB, 10)
327
+ : 0),
328
+ },
346
329
  "Redis",
347
330
  );
348
331
  }
332
+ }
349
333
 
350
- private key(sessionId: string): string {
351
- return `${this.prefix}${sessionId}`;
352
- }
334
+ // ── Flash data prefix ─────────────────────────────────────────────
353
335
 
354
- read(sessionId: string): SessionData | null {
355
- const raw = this.execSync(["GET", this.key(sessionId)]);
356
- if (!raw) return null; // key miss — normal "no session yet", NOT an error
357
- try {
358
- return JSON.parse(raw) as SessionData;
359
- } catch {
360
- return null;
361
- }
362
- }
336
+ const FLASH_PREFIX = "_flash_";
363
337
 
364
- write(sessionId: string, data: SessionData, ttl: number = 0): void {
365
- const json = JSON.stringify(data);
366
- if (ttl > 0) {
367
- this.execSync(["SETEX", this.key(sessionId), String(ttl), json]);
368
- } else {
369
- this.execSync(["SET", this.key(sessionId), json]);
370
- }
338
+ /**
339
+ * The stored form of a value, for telling whether a request changed it.
340
+ * undefined when it cannot be serialised (a BigInt, a cycle): that always
341
+ * counts as changed, because writing a value again is harmless and missing a
342
+ * change is not.
343
+ */
344
+ function fingerprint(value: unknown): string | undefined {
345
+ try {
346
+ return JSON.stringify(value);
347
+ } catch {
348
+ return undefined;
371
349
  }
350
+ }
372
351
 
373
- destroy(sessionId: string): void {
374
- this.execSync(["DEL", this.key(sessionId)]);
352
+ function fingerprints(data: SessionData): Map<string, string | undefined> {
353
+ const prints = new Map<string, string | undefined>();
354
+ for (const [key, value] of Object.entries(data)) {
355
+ prints.set(key, fingerprint(value));
375
356
  }
357
+ return prints;
376
358
  }
377
359
 
378
- // ── Flash data prefix ─────────────────────────────────────────────
379
-
380
- const FLASH_PREFIX = "_flash_";
381
-
382
360
  // ── Session Class ─────────────────────────────────────────────────
383
361
 
384
362
  export class Session {
@@ -405,6 +383,29 @@ export class Session {
405
383
  * Lets `start()` tell "no such session" from "the store is unreachable".
406
384
  */
407
385
  private lastReadFailed = false;
386
+ /**
387
+ * What this request last saw of the stored record, one fingerprint per key:
388
+ * taken by start() and again by every save. save() compares the live data
389
+ * against it to find the keys THIS request changed.
390
+ */
391
+ private loaded = new Map<string, string | undefined>();
392
+ /**
393
+ * True when this request is working on a record the store holds: start()
394
+ * adopted one, or a save has written one since. A save never re-creates such
395
+ * a record once another request has removed it.
396
+ */
397
+ private stored = false;
398
+ /**
399
+ * True when clear() ran since the last save: the save then replaces the
400
+ * stored record instead of merging into it.
401
+ */
402
+ private cleared = false;
403
+ /**
404
+ * True once this request found its session ended by another request (the
405
+ * record it loaded is gone). It stays ended for the rest of the request:
406
+ * regenerate() mints nothing either.
407
+ */
408
+ private ended = false;
408
409
 
409
410
  constructor(backend?: string, config?: SessionConfig) {
410
411
  const backendType = resolveBackend(
@@ -573,6 +574,11 @@ export class Session {
573
574
  const now = Math.floor(Date.now() / 1000);
574
575
  this.data = { _created: now, _accessed: now };
575
576
  this.dirty = false;
577
+ // Not a record this request has seen, so its first save writes whole.
578
+ this.stored = false;
579
+ this.loaded = new Map();
580
+ this.cleared = false;
581
+ this.ended = false;
576
582
  return sessionId;
577
583
  }
578
584
  if (loaded) {
@@ -596,6 +602,10 @@ export class Session {
596
602
  // Refresh the accessed timestamp; a write failure here is logged but
597
603
  // must not abort the resume — the request still serves.
598
604
  this.safeWrite(this.sessionId, this.data, this.ttl);
605
+ this.stored = true;
606
+ this.loaded = fingerprints(this.data);
607
+ this.cleared = false;
608
+ this.ended = false;
599
609
  return sessionId;
600
610
  }
601
611
  }
@@ -606,6 +616,10 @@ export class Session {
606
616
  this.data = { _created: now, _accessed: now };
607
617
  this.dirty = false;
608
618
  this.safeWrite(this.sessionId, this.data, this.ttl);
619
+ this.stored = false;
620
+ this.loaded = new Map();
621
+ this.cleared = false;
622
+ this.ended = false;
609
623
  return this.sessionId;
610
624
  }
611
625
 
@@ -650,9 +664,7 @@ export class Session {
650
664
  if (this.sessionId) {
651
665
  this.safeDestroy(this.sessionId);
652
666
  }
653
- this.sessionId = null;
654
- this.data = null;
655
- this.dirty = false;
667
+ this.forget();
656
668
  }
657
669
 
658
670
  /**
@@ -677,6 +689,7 @@ export class Session {
677
689
  const now = Math.floor(Date.now() / 1000);
678
690
  this.data = { _created: this.data._created, _accessed: now };
679
691
  this.dirty = true;
692
+ this.cleared = true;
680
693
  this.save();
681
694
  }
682
695
 
@@ -695,8 +708,27 @@ export class Session {
695
708
  * session fixation — the pre-auth ID is destroyed and the data is carried
696
709
  * onto a fresh, unguessable ID. A backend destroy/write failure is logged
697
710
  * (never silent) but does not throw under the default policy.
711
+ *
712
+ * What is carried is the session as it is stored now with this request's
713
+ * own changes applied, the same merge save() does. If another request ended
714
+ * the session after this one loaded it (a logout, or a regenerate of its
715
+ * own), it stays ended: nothing is carried, no id is minted and null is
716
+ * returned, so no cookie goes out to replace the one that request sent.
698
717
  */
699
- regenerate(): string {
718
+ regenerate(): string | null {
719
+ if (this.ended) return null;
720
+ if (this.sessionId && this.stored && this.data) {
721
+ const current = this.safeRead(this.sessionId);
722
+ if (!this.lastReadFailed) {
723
+ if (!current) {
724
+ this.forget();
725
+ this.ended = true;
726
+ return null;
727
+ }
728
+ if (!this.cleared) this.data = this.merged(current);
729
+ }
730
+ }
731
+
700
732
  const oldId = this.sessionId;
701
733
  const oldData = this.data;
702
734
 
@@ -710,6 +742,8 @@ export class Session {
710
742
  this.data = oldData ?? { _created: Math.floor(Date.now() / 1000), _accessed: Math.floor(Date.now() / 1000) };
711
743
  this.data._accessed = Math.floor(Date.now() / 1000);
712
744
  this.dirty = true;
745
+ // The new id has no record yet: write everything to it.
746
+ this.stored = false;
713
747
  this.save();
714
748
  return this.sessionId;
715
749
  }
@@ -793,17 +827,86 @@ export class Session {
793
827
  *
794
828
  * Returns true on a successful persist, false if the backend was unreachable
795
829
  * (logged). The dirty flag is cleared only on success so a later save()
796
- * retries once the backend recovers. A nothing-to-persist call returns true.
830
+ * retries once the backend recovers. A call with no session returns true.
831
+ *
832
+ * Other requests on the same session run while this one does, so the save
833
+ * writes only THIS request's changes, onto the record as it is stored now.
834
+ * Writing back the whole snapshot start() loaded undid whatever another
835
+ * request did in between: a logout (destroy, clear, regenerate) or a
836
+ * privilege change made with set(). A record removed since this request
837
+ * loaded it is never re-created: the session has ended, for this request
838
+ * too. A request that has not loaded a stored record (a new session, the new
839
+ * id regenerate() mints) writes its data whole, as before.
840
+ *
841
+ * A session the store already holds is re-written even when this request
842
+ * changed nothing: that write re-stamps the backend deadline to
843
+ * now + TINA4_SESSION_TTL, so a session expires after that span of INACTIVITY
844
+ * rather than that long after its last CHANGE (ADR-0087 — a session's expiry
845
+ * slides on activity). The re-write is the re-read, merged record, so sliding
846
+ * the deadline is concurrent-safe: it never clobbers another request's change,
847
+ * never re-creates an ended record, and TINA4_SESSION_TTL=0 still re-stamps a
848
+ * never-expires deadline (ADR-0027). A request with no stored session and no
849
+ * change writes nothing — there is no deadline to move.
797
850
  */
798
851
  save(): boolean {
799
852
  if (!this.sessionId || !this.data) return true;
800
- if (!this.dirty) return true;
801
- if (this.safeWrite(this.sessionId, this.data, this.ttl)) {
853
+ if (!this.dirty && !this.stored) return true;
854
+ let record: SessionData = this.data;
855
+ if (this.stored) {
856
+ const current = this.safeRead(this.sessionId);
857
+ if (this.lastReadFailed) return false; // nothing written; dirty RETAINED for retry
858
+ if (!current) {
859
+ this.forget();
860
+ this.ended = true;
861
+ return true;
862
+ }
863
+ if (!this.cleared) record = this.merged(current);
864
+ }
865
+ if (this.safeWrite(this.sessionId, record, this.ttl)) {
802
866
  this.dirty = false;
867
+ this.stored = true;
868
+ this.cleared = false;
869
+ this.loaded = fingerprints(this.data);
803
870
  return true;
804
871
  }
805
872
  return false; // write failed (logged); dirty RETAINED for retry
806
873
  }
874
+
875
+ /**
876
+ * The stored record with this request's changes applied: every key whose
877
+ * value differs from what this request loaded or last saved, and the
878
+ * removal of every key it deleted. Keys it did not touch keep whatever is
879
+ * stored now.
880
+ */
881
+ private merged(current: SessionData): SessionData {
882
+ const data = this.data as SessionData;
883
+ const record: SessionData = { ...current };
884
+ for (const [key, value] of Object.entries(data)) {
885
+ const print = fingerprint(value);
886
+ if (print === undefined || !this.loaded.has(key) || this.loaded.get(key) !== print) {
887
+ record[key] = value;
888
+ }
889
+ }
890
+ for (const key of this.loaded.keys()) {
891
+ if (!Object.prototype.hasOwnProperty.call(data, key)) {
892
+ delete record[key];
893
+ }
894
+ }
895
+ return record;
896
+ }
897
+
898
+ /**
899
+ * End the session for this request without touching the store: no id and
900
+ * no data, so nothing more is written and no cookie goes out for it.
901
+ */
902
+ private forget(): void {
903
+ this.sessionId = null;
904
+ this.data = null;
905
+ this.dirty = false;
906
+ this.stored = false;
907
+ this.cleared = false;
908
+ this.loaded = new Map();
909
+ }
807
910
  }
808
911
 
809
912
  /**
@@ -0,0 +1,94 @@
1
+ /*
2
+ Copyright (c) 2026 Code Infinity
3
+ SPDX-License-Identifier: MPL-2.0
4
+ This Source Code Form is subject to the terms of the Mozilla Public
5
+ License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+ */
8
+
9
+ /**
10
+ * Tina4 RESP Session Handler base — the shared store logic behind Redis and
11
+ * Valkey, zero dependencies.
12
+ *
13
+ * Redis and Valkey speak the same RESP protocol, so the read/write/destroy
14
+ * transport is byte-for-byte identical between them — only the configuration
15
+ * variable names and the log label differ. That difference lives entirely in
16
+ * each subclass constructor; everything the session store actually DOES lives
17
+ * here, once. A subclass resolves its own host/port/password/prefix/db from
18
+ * config or environment, then hands a {@link RespConnection} plus a label to
19
+ * `super()`.
20
+ */
21
+ import type { SessionHandler } from "../session.js";
22
+ import { respCommandSync } from "./respClient.js";
23
+
24
+ interface SessionData {
25
+ _created: number;
26
+ _accessed: number;
27
+ [key: string]: unknown;
28
+ }
29
+
30
+ /** Resolved connection details a RESP session handler needs. */
31
+ export interface RespConnection {
32
+ host: string;
33
+ port: number;
34
+ password: string;
35
+ prefix: string;
36
+ db: number;
37
+ }
38
+
39
+ /**
40
+ * RESP (Redis/Valkey) session handler using raw TCP — no external client.
41
+ *
42
+ * Stores session data as JSON strings with a server-side TTL for automatic
43
+ * expiry. A genuine key miss yields `null`; a transport/connection FAILURE
44
+ * (server unreachable, rejected AUTH, timeout) THROWS so the Session boundary
45
+ * can tell "not found" (silent) from "backend failed" (log-loud + degrade).
46
+ */
47
+ export abstract class RespSessionHandler implements SessionHandler {
48
+ protected constructor(
49
+ private readonly conn: RespConnection,
50
+ private readonly label: string,
51
+ ) {}
52
+
53
+ /**
54
+ * Execute a RESP command synchronously against the live server.
55
+ *
56
+ * Delegates to the shared {@link respCommandSync} transport: a genuine key
57
+ * miss yields `""`, and a transport/connection FAILURE THROWS so the caller
58
+ * can distinguish "not found" from "backend failed". Backend-failure parity.
59
+ */
60
+ private execSync(args: string[]): string {
61
+ return respCommandSync(
62
+ { host: this.conn.host, port: this.conn.port, password: this.conn.password, db: this.conn.db },
63
+ args,
64
+ this.label,
65
+ );
66
+ }
67
+
68
+ private key(sessionId: string): string {
69
+ return `${this.conn.prefix}${sessionId}`;
70
+ }
71
+
72
+ read(sessionId: string): SessionData | null {
73
+ const raw = this.execSync(["GET", this.key(sessionId)]);
74
+ if (!raw) return null; // key miss — normal "no session yet", NOT an error
75
+ try {
76
+ return JSON.parse(raw) as SessionData;
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
81
+
82
+ write(sessionId: string, data: SessionData, ttl: number = 0): void {
83
+ const json = JSON.stringify(data);
84
+ if (ttl > 0) {
85
+ this.execSync(["SETEX", this.key(sessionId), String(ttl), json]);
86
+ } else {
87
+ this.execSync(["SET", this.key(sessionId), json]);
88
+ }
89
+ }
90
+
91
+ destroy(sessionId: string): void {
92
+ this.execSync(["DEL", this.key(sessionId)]);
93
+ }
94
+ }
@@ -19,14 +19,7 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
19
19
  * TINA4_SESSION_VALKEY_PREFIX (default: "tina4:session:")
20
20
  * TINA4_SESSION_VALKEY_DB (default: 0)
21
21
  */
22
- import type { SessionHandler } from "../session.js";
23
- import { respCommandSync } from "./respClient.js";
24
-
25
- interface SessionData {
26
- _created: number;
27
- _accessed: number;
28
- [key: string]: unknown;
29
- }
22
+ import { RespSessionHandler } from "./respSessionHandler.js";
30
23
 
31
24
  export interface ValkeySessionConfig {
32
25
  host?: string;
@@ -55,73 +48,29 @@ export interface ValkeySessionConfig {
55
48
  * Valkey uses the same RESP protocol as Redis, so this handler is functionally
56
49
  * identical to RedisSessionHandler but with VALKEY config variable names.
57
50
  */
58
- export class ValkeySessionHandler implements SessionHandler {
59
- private host: string;
60
- private port: number;
61
- private password: string;
62
- private prefix: string;
63
- private db: number;
64
-
51
+ export class ValkeySessionHandler extends RespSessionHandler {
65
52
  constructor(config?: ValkeySessionConfig) {
66
- this.host = config?.host
67
- ?? process.env.TINA4_SESSION_VALKEY_HOST
68
- ?? "127.0.0.1";
69
- this.port = config?.port
70
- ?? (process.env.TINA4_SESSION_VALKEY_PORT
71
- ? parseInt(process.env.TINA4_SESSION_VALKEY_PORT, 10)
72
- : 6379);
73
- this.password = config?.password
74
- ?? process.env.TINA4_SESSION_VALKEY_PASSWORD
75
- ?? "";
76
- this.prefix = config?.prefix
77
- ?? process.env.TINA4_SESSION_VALKEY_PREFIX
78
- ?? "tina4:session:";
79
- this.db = config?.db
80
- ?? (process.env.TINA4_SESSION_VALKEY_DB
81
- ? parseInt(process.env.TINA4_SESSION_VALKEY_DB, 10)
82
- : 0);
83
- }
84
-
85
- /**
86
- * Execute a RESP command synchronously against the live Valkey server.
87
- *
88
- * Delegates to the shared {@link respCommandSync} transport: a genuine key miss
89
- * yields `""`, and a transport/connection FAILURE (server unreachable, rejected
90
- * AUTH, timeout) THROWS so the Session boundary can distinguish "not found"
91
- * (silent) from "backend failed" (log-loud + degrade). Backend-failure parity.
92
- */
93
- private execSync(args: string[]): string {
94
- return respCommandSync(
95
- { host: this.host, port: this.port, password: this.password, db: this.db },
96
- args,
53
+ super(
54
+ {
55
+ host: config?.host
56
+ ?? process.env.TINA4_SESSION_VALKEY_HOST
57
+ ?? "127.0.0.1",
58
+ port: config?.port
59
+ ?? (process.env.TINA4_SESSION_VALKEY_PORT
60
+ ? parseInt(process.env.TINA4_SESSION_VALKEY_PORT, 10)
61
+ : 6379),
62
+ password: config?.password
63
+ ?? process.env.TINA4_SESSION_VALKEY_PASSWORD
64
+ ?? "",
65
+ prefix: config?.prefix
66
+ ?? process.env.TINA4_SESSION_VALKEY_PREFIX
67
+ ?? "tina4:session:",
68
+ db: config?.db
69
+ ?? (process.env.TINA4_SESSION_VALKEY_DB
70
+ ? parseInt(process.env.TINA4_SESSION_VALKEY_DB, 10)
71
+ : 0),
72
+ },
97
73
  "Valkey",
98
74
  );
99
75
  }
100
-
101
- private key(sessionId: string): string {
102
- return `${this.prefix}${sessionId}`;
103
- }
104
-
105
- read(sessionId: string): SessionData | null {
106
- const raw = this.execSync(["GET", this.key(sessionId)]);
107
- if (!raw) return null; // key miss — normal "no session yet", NOT an error
108
- try {
109
- return JSON.parse(raw) as SessionData;
110
- } catch {
111
- return null;
112
- }
113
- }
114
-
115
- write(sessionId: string, data: SessionData, ttl: number): void {
116
- const json = JSON.stringify(data);
117
- if (ttl > 0) {
118
- this.execSync(["SETEX", this.key(sessionId), String(ttl), json]);
119
- } else {
120
- this.execSync(["SET", this.key(sessionId), json]);
121
- }
122
- }
123
-
124
- destroy(sessionId: string): void {
125
- this.execSync(["DEL", this.key(sessionId)]);
126
- }
127
76
  }
@@ -26,7 +26,7 @@ type SessionLike = {
26
26
  get(key: string): unknown;
27
27
  set(key: string, value: unknown): void;
28
28
  delete(key: string): void;
29
- regenerate(): string;
29
+ regenerate(): string | null;
30
30
  destroy(): void;
31
31
  };
32
32
 
@@ -10,6 +10,7 @@ import { readFileSync, realpathSync, statSync, type Stats } from "node:fs";
10
10
  import { join, extname, sep } from "node:path";
11
11
  import { gzipSync } from "node:zlib";
12
12
  import type { Tina4Request, Tina4Response } from "./types.js";
13
+ import { etagMatches } from "./etag.js";
13
14
 
14
15
  /** Content-type prefixes that benefit from gzip. Mirrors the Python master's `_is_compressible`. */
15
16
  const COMPRESSIBLE_PREFIXES = [
@@ -200,16 +201,3 @@ function isNotModified(req: Tina4Request, etag: string, stat: Stats): boolean {
200
201
  return false;
201
202
  }
202
203
 
203
- /**
204
- * Weak comparison of an `If-None-Match` value against our ETag. The header may
205
- * be `*`, a single tag, or a comma-separated list; the `W/` weak prefix is
206
- * stripped on both sides before comparing (RFC 7232 §2.3.2 weak comparison).
207
- */
208
- function etagMatches(ifNoneMatch: string, etag: string): boolean {
209
- const strip = (tag: string) => tag.trim().replace(/^W\//, "");
210
- const target = strip(etag);
211
- return ifNoneMatch.split(",").some((candidate) => {
212
- const trimmed = candidate.trim();
213
- return trimmed === "*" || strip(trimmed) === target;
214
- });
215
- }
@@ -126,6 +126,14 @@ export interface Tina4ResponseMethods {
126
126
  stream(source: AsyncIterable<string | Buffer>, contentType?: string): Promise<Tina4Response>;
127
127
  /** The underlying ServerResponse for advanced use */
128
128
  raw: ServerResponse;
129
+ /**
130
+ * This response's Content-Security-Policy nonce (ADR-0088). `runDispatch()`
131
+ * mints one per request and publishes it on the async-context (csp.ts) so the
132
+ * framework's inline `<style>`/`<script>`, the Frond `csp_nonce()` global, and
133
+ * the value the security middleware names in the header all agree. Stamp it on
134
+ * any inline block a route emits: `<script nonce="${res.cspNonce}">`.
135
+ */
136
+ cspNonce: string;
129
137
  }
130
138
 
131
139
  /**
@@ -0,0 +1,31 @@
1
+ /*
2
+ Copyright (c) 2026 Code Infinity
3
+ SPDX-License-Identifier: MPL-2.0
4
+ This Source Code Form is subject to the terms of the Mozilla Public
5
+ License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+ */
8
+
9
+ import type { Tina4Response } from "./types.js";
10
+
11
+ /**
12
+ * Relay a drained upstream (proxied) response to the client.
13
+ *
14
+ * The body is read to a string once, then returned as JSON when it parses and
15
+ * as raw text with the upstream Content-Type otherwise, keeping the upstream
16
+ * status either way. Shared by the dev-admin supervisor proxy and the feedback
17
+ * agent proxy so both pass a backend response through identically.
18
+ */
19
+ export async function relayUpstreamResponse(res: Tina4Response, upstream: Response): Promise<void> {
20
+ const raw = await upstream.text();
21
+ const status = upstream.status || 200;
22
+ try {
23
+ res.json(JSON.parse(raw), status);
24
+ } catch {
25
+ // Non-JSON upstream — pass through as text with the same status.
26
+ res.raw.writeHead(status, {
27
+ "Content-Type": upstream.headers.get("content-type") ?? "text/plain; charset=utf-8",
28
+ });
29
+ res.raw.end(raw);
30
+ }
31
+ }