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.
- package/CLAUDE.md +28 -2
- package/package.json +1 -1
- package/packages/cli/dist/bin.js +817 -538
- package/packages/cli/src/bin.ts +4 -14
- package/packages/cli/src/commands/build.ts +2 -18
- package/packages/cli/src/commands/generate.ts +9 -24
- package/packages/cli/src/commands/init.ts +18 -8
- package/packages/cli/src/commands/lint.ts +3 -13
- package/packages/cli/src/commands/migrate.ts +2 -6
- package/packages/cli/src/commands/migrateRollback.ts +2 -6
- package/packages/cli/src/commands/migrateStatus.ts +2 -6
- package/packages/cli/src/commands/queue.ts +2 -30
- package/packages/cli/src/util.ts +90 -0
- package/packages/core/dist/index.js +592 -262
- package/packages/core/gallery/auth/src/routes/gallery/auth/get.ts +27 -11
- package/packages/core/gallery/queue/src/lib/queueDb.ts +11 -0
- package/packages/core/gallery/queue/src/routes/api/gallery/queue/consume/post.ts +2 -5
- package/packages/core/gallery/queue/src/routes/api/gallery/queue/fail/post.ts +2 -5
- package/packages/core/gallery/queue/src/routes/gallery/queue/get.ts +20 -7
- package/packages/core/gallery/templates/src/templates/gallery_page.twig +34 -15
- package/packages/core/src/csp.ts +138 -0
- package/packages/core/src/devAdmin.ts +3 -12
- package/packages/core/src/dispatchPipeline.ts +2 -18
- package/packages/core/src/errorOverlay.ts +80 -37
- package/packages/core/src/etag.ts +24 -0
- package/packages/core/src/feedback.ts +2 -11
- package/packages/core/src/index.ts +10 -0
- package/packages/core/src/middleware.ts +20 -6
- package/packages/core/src/response.ts +14 -3
- package/packages/core/src/server.ts +52 -15
- package/packages/core/src/session.ts +174 -71
- package/packages/core/src/sessionHandlers/respSessionHandler.ts +94 -0
- package/packages/core/src/sessionHandlers/valkeyHandler.ts +22 -73
- package/packages/core/src/sso.ts +1 -1
- package/packages/core/src/static.ts +1 -13
- package/packages/core/src/types.ts +8 -0
- package/packages/core/src/upstreamResponse.ts +31 -0
- package/packages/core/src/websocket.ts +48 -7
- package/packages/core/templates/errors/302.twig +1 -1
- package/packages/core/templates/errors/401.twig +2 -2
- package/packages/core/templates/errors/403.twig +1 -1
- package/packages/core/templates/errors/404.twig +1 -1
- package/packages/core/templates/errors/500.twig +1 -1
- package/packages/core/templates/errors/502.twig +2 -2
- package/packages/core/templates/errors/503.twig +2 -2
- package/packages/core/templates/errors/base.twig +4 -1
- package/packages/frond/dist/index.js +2 -0
- package/packages/frond/src/engine.ts +23 -0
- package/packages/orm/dist/index.js +593 -271
- package/packages/orm/src/graph/adapters/bolt.ts +8 -29
- package/packages/orm/src/graph/adapters/rowMapping.ts +57 -0
- package/packages/orm/src/graph/adapters/ultipa.ts +8 -30
- package/packages/swagger/dist/index.js +4 -4
- package/packages/swagger/src/ui.ts +7 -4
- package/types/cli/src/util.d.ts +28 -0
- package/types/core/src/csp.d.ts +42 -0
- package/types/core/src/etag.d.ts +9 -0
- package/types/core/src/index.d.ts +1 -0
- package/types/core/src/middleware.d.ts +6 -0
- package/types/core/src/session.d.ts +64 -21
- package/types/core/src/sessionHandlers/respSessionHandler.d.ts +52 -0
- package/types/core/src/sessionHandlers/valkeyHandler.d.ts +2 -26
- package/types/core/src/types.d.ts +8 -0
- package/types/core/src/upstreamResponse.d.ts +10 -0
- package/types/core/src/websocket.d.ts +21 -0
- package/types/frond/src/engine.d.ts +7 -0
- 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 {
|
|
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
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
351
|
-
return `${this.prefix}${sessionId}`;
|
|
352
|
-
}
|
|
334
|
+
// ── Flash data prefix ─────────────────────────────────────────────
|
|
353
335
|
|
|
354
|
-
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
374
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
}
|
package/packages/core/src/sso.ts
CHANGED
|
@@ -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
|
+
}
|