tina4-nodejs 3.13.142 → 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 (37) hide show
  1. package/CLAUDE.md +28 -2
  2. package/package.json +1 -1
  3. package/packages/cli/dist/bin.js +379 -85
  4. package/packages/cli/src/commands/generate.ts +7 -2
  5. package/packages/cli/src/commands/init.ts +18 -8
  6. package/packages/core/dist/index.js +377 -78
  7. package/packages/core/gallery/auth/src/routes/gallery/auth/get.ts +27 -11
  8. package/packages/core/gallery/queue/src/routes/gallery/queue/get.ts +20 -7
  9. package/packages/core/gallery/templates/src/templates/gallery_page.twig +34 -15
  10. package/packages/core/src/csp.ts +138 -0
  11. package/packages/core/src/errorOverlay.ts +80 -37
  12. package/packages/core/src/index.ts +10 -0
  13. package/packages/core/src/middleware.ts +20 -6
  14. package/packages/core/src/response.ts +14 -3
  15. package/packages/core/src/server.ts +52 -15
  16. package/packages/core/src/session.ts +154 -7
  17. package/packages/core/src/sso.ts +1 -1
  18. package/packages/core/src/types.ts +8 -0
  19. package/packages/core/templates/errors/302.twig +1 -1
  20. package/packages/core/templates/errors/401.twig +2 -2
  21. package/packages/core/templates/errors/403.twig +1 -1
  22. package/packages/core/templates/errors/404.twig +1 -1
  23. package/packages/core/templates/errors/500.twig +1 -1
  24. package/packages/core/templates/errors/502.twig +2 -2
  25. package/packages/core/templates/errors/503.twig +2 -2
  26. package/packages/core/templates/errors/base.twig +4 -1
  27. package/packages/frond/dist/index.js +2 -0
  28. package/packages/frond/src/engine.ts +23 -0
  29. package/packages/orm/dist/index.js +378 -87
  30. package/packages/swagger/dist/index.js +4 -4
  31. package/packages/swagger/src/ui.ts +7 -4
  32. package/types/core/src/csp.d.ts +42 -0
  33. package/types/core/src/index.d.ts +1 -0
  34. package/types/core/src/middleware.d.ts +6 -0
  35. package/types/core/src/session.d.ts +62 -2
  36. package/types/core/src/types.d.ts +8 -0
  37. package/types/frond/src/engine.d.ts +7 -0
@@ -13,6 +13,7 @@ import { Log } from "./logger.js";
13
13
  import { isTruthy } from "./dotenv.js";
14
14
  import { defaultRouter, type Router } from "./router.js";
15
15
  import { resolveClientIp } from "./trustedProxy.js";
16
+ import { currentCspNonce, resolveCspHeader } from "./csp.js";
16
17
  import { getFrond, getFrameworkFrond, wantsJson, negotiatedErrorBody } from "./response.js";
17
18
 
18
19
  /**
@@ -951,12 +952,18 @@ export class SecurityHeadersMiddleware {
951
952
  * overrides, WITHOUT Strict-Transport-Security, which depends on the
952
953
  * request's scheme. Also what a transport rejection carries (ADR-0068
953
954
  * section 4), written before the scheme is known.
955
+ *
956
+ * Content-Security-Policy always carries a per-response nonce in style-src
957
+ * AND script-src (ADR-0088), read from the request async-context (minted on
958
+ * first access), so the framework's own inline content runs under the strict
959
+ * default without 'unsafe-inline'. The nonce is random per response, so a
960
+ * caller comparing this header must assert its STRUCTURE, never a byte string.
954
961
  */
955
962
  static canonicalHeaders(): Record<string, string> {
956
963
  return {
957
964
  "X-Frame-Options": process.env.TINA4_FRAME_OPTIONS ?? "SAMEORIGIN",
958
965
  "X-Content-Type-Options": "nosniff",
959
- "Content-Security-Policy": process.env.TINA4_CSP ?? "default-src 'self'",
966
+ "Content-Security-Policy": resolveCspHeader(currentCspNonce()),
960
967
  "Referrer-Policy": process.env.TINA4_REFERRER_POLICY ?? "strict-origin-when-cross-origin",
961
968
  "X-XSS-Protection": "0",
962
969
  "Permissions-Policy": process.env.TINA4_PERMISSIONS_POLICY ?? "camera=(), microphone=(), geolocation=()",
@@ -985,6 +992,10 @@ export class SecurityHeadersMiddleware {
985
992
  if (process.env.TINA4_CSP === undefined) {
986
993
  SecurityHeadersMiddleware.warnCspDefaultOnce();
987
994
  }
995
+ // Content-Security-Policy carries a per-response nonce in style-src AND
996
+ // script-src (ADR-0088), already resolved in canonical, so the framework's
997
+ // own inline <style>/<script> (and user templates using the Frond
998
+ // csp_nonce() global) run under the strict policy without 'unsafe-inline'.
988
999
  for (const name of ["Content-Security-Policy", "Referrer-Policy", "X-XSS-Protection", "Permissions-Policy"]) {
989
1000
  res.header(name, canonical[name]);
990
1001
  }
@@ -1012,11 +1023,14 @@ export class SecurityHeadersMiddleware {
1012
1023
  SecurityHeadersMiddleware.cspDefaultWarned = true;
1013
1024
  const message =
1014
1025
  "TINA4_CSP is not set, so Tina4 is serving the default Content-Security-Policy " +
1015
- "\"default-src 'self'\" on every response. That default blocks runtime-injected " +
1016
- "inline styles, cross-origin fonts/scripts/CDNs, data: URIs, and cross-origin " +
1017
- "WebSocket/XHR (e.g. a separate API or LiveKit host). If your app uses any of " +
1018
- "these, set TINA4_CSP to a policy that allows them (see https://tina4.com); to " +
1019
- "silence this notice without changing behaviour, set TINA4_CSP=\"default-src 'self'\".";
1026
+ "\"default-src 'self'\" on every response. The framework injects a per-response " +
1027
+ "nonce into style-src and script-src, so its own inline <style>/<script> (and " +
1028
+ "your templates using the csp_nonce() Frond global) work under this policy. The " +
1029
+ "default still blocks cross-origin fonts/scripts/CDNs, data: URIs, and " +
1030
+ "cross-origin WebSocket/XHR (e.g. a separate API or LiveKit host). If your app " +
1031
+ "uses any of these, set TINA4_CSP to a policy that allows them (see " +
1032
+ "https://tina4.com); to silence this notice without changing behaviour, set " +
1033
+ "TINA4_CSP=\"default-src 'self'\".";
1020
1034
  try {
1021
1035
  Log.warning(message);
1022
1036
  } catch {
@@ -11,6 +11,7 @@ import fs from "node:fs";
11
11
  import nodePath from "node:path";
12
12
  import { once } from "node:events";
13
13
  import type { Tina4Response, CookieOptions } from "./types.js";
14
+ import { currentCspNonce } from "./csp.js";
14
15
 
15
16
  /**
16
17
  * Best-effort close of a streaming source on client disconnect or a mid-stream
@@ -93,7 +94,8 @@ export async function getFrond(): Promise<InstanceType<any>> {
93
94
  const dir = _defaultTemplatesDir ?? nodePath.resolve(process.cwd(), "src/templates");
94
95
  let engine = _frondCache.get(dir);
95
96
  if (!engine) {
96
- const { Frond } = await import("../../frond/src/engine.js");
97
+ const { Frond, setCspNonceProvider } = await import("../../frond/src/engine.js");
98
+ setCspNonceProvider(currentCspNonce);
97
99
  engine = new Frond(dir);
98
100
  _frondCache.set(dir, engine);
99
101
  }
@@ -109,7 +111,8 @@ export async function getFrameworkFrond(): Promise<InstanceType<any> | null> {
109
111
  const frameworkDir = nodePath.resolve(nodePath.dirname(import.meta.url.replace("file://", "")), "..", "templates");
110
112
  if (!_frameworkFrond && fs.existsSync(frameworkDir)) {
111
113
  try {
112
- const { Frond } = await import("../../frond/src/engine.js");
114
+ const { Frond, setCspNonceProvider } = await import("../../frond/src/engine.js");
115
+ setCspNonceProvider(currentCspNonce);
113
116
  _frameworkFrond = new Frond(frameworkDir);
114
117
  } catch { return null; }
115
118
  }
@@ -262,6 +265,13 @@ export function createResponse(res: ServerResponse): Tina4Response {
262
265
  // ── Attach the underlying ServerResponse ──
263
266
  response.raw = res;
264
267
 
268
+ // ── Per-response CSP nonce (ADR-0088) ──
269
+ // Read the request-scoped nonce (csp.ts), minting one on first access. Inside
270
+ // a request this is the SAME value runDispatch() established and the security
271
+ // middleware names in the Content-Security-Policy header, so a route emitting
272
+ // an inline block as `<script nonce="${res.cspNonce}">` stays CSP-clean.
273
+ response.cspNonce = currentCspNonce();
274
+
265
275
  // ── Explicit methods ──
266
276
 
267
277
  response.json = function (data: unknown, status?: number): Tina4Response {
@@ -450,7 +460,8 @@ export function createResponse(res: ServerResponse): Tina4Response {
450
460
  templateDir?: string,
451
461
  ): Promise<Tina4Response> {
452
462
  try {
453
- const { Frond } = await import("../../frond/src/engine.js");
463
+ const { Frond, setCspNonceProvider } = await import("../../frond/src/engine.js");
464
+ setCspNonceProvider(currentCspNonce);
454
465
  const dir = templateDir ?? _defaultTemplatesDir ?? nodePath.resolve(process.cwd(), "src/templates");
455
466
  let engine = _frondCache.get(dir);
456
467
  if (!engine) {
@@ -42,6 +42,7 @@ import { MiddlewareChain, MiddlewareRunner, cors, requestLogger, isMiddlewareCla
42
42
  import { tryServeStatic } from "./static.js";
43
43
  import { loadEnv, isTruthy } from "./dotenv.js";
44
44
  import { isDebugMode } from "./errorOverlay.js";
45
+ import { generateNonce, runWithCspNonce, currentCspNonce } from "./csp.js";
45
46
  import { createHealthRoutes } from "./health.js";
46
47
  import { rateLimiter } from "./rateLimiter.js";
47
48
  import { Log } from "./logger.js";
@@ -493,7 +494,8 @@ async function renderErrorPage(
493
494
  templatesDir: string,
494
495
  ): Promise<string | null> {
495
496
  try {
496
- const { Frond } = await import("../../frond/src/engine.js");
497
+ const { Frond, setCspNonceProvider } = await import("../../frond/src/engine.js");
498
+ setCspNonceProvider(currentCspNonce);
497
499
  const templateFile = `errors/${code}.twig`;
498
500
 
499
501
  // Helper: get-or-create a cached Frond instance for a directory
@@ -706,6 +708,10 @@ export function resolveTemplate(pathname: string, templatesDir: string): string
706
708
 
707
709
  function renderLandingPage(port: number = 7148): string {
708
710
  const version = TINA4_VERSION;
711
+ // The framework's welcome page runs under the strict default CSP. Every inline
712
+ // <style>/<script> below carries this response's nonce (ADR-0088); there are no
713
+ // style= or onclick= attributes (a nonce covers an element, never an attribute).
714
+ const nonce = currentCspNonce();
709
715
 
710
716
  const galleryItems = [
711
717
  { id: "rest-api", icon: "&#128640;", name: "REST API", desc: "A simple JSON API with GET and POST endpoints", accent: "accent-blue", tryUrl: "/api/gallery/hello" },
@@ -729,7 +735,7 @@ function renderLandingPage(port: number = 7148): string {
729
735
  : "";
730
736
  const deployBtn = isDeployed
731
737
  ? `<span class="gbtn gbtn-deployed">Deployed</span>`
732
- : `<button class="gbtn gbtn-deploy" onclick="deployGallery('${item.id}')">Deploy</button>`;
738
+ : `<button class="gbtn gbtn-deploy" data-deploy="${item.id}">Deploy</button>`;
733
739
  return `<div class="gallery-card">
734
740
  <div class="accent ${item.accent}"></div>
735
741
  <div class="icon">${item.icon}</div>
@@ -745,7 +751,7 @@ function renderLandingPage(port: number = 7148): string {
745
751
  <meta charset="utf-8">
746
752
  <meta name="viewport" content="width=device-width, initial-scale=1">
747
753
  <title>Tina4NodeJs</title>
748
- <style>
754
+ <style nonce="${nonce}">
749
755
  *{margin:0;padding:0;box-sizing:border-box}
750
756
  body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#0f172a;color:#e2e8f0;min-height:100vh;display:flex;flex-direction:column;align-items:center;position:relative}
751
757
  .bg-watermark{position:fixed;bottom:-5%;right:-5%;width:45%;opacity:0.04;pointer-events:none;z-index:0}
@@ -784,6 +790,11 @@ h1{font-size:3rem;font-weight:700;margin-bottom:0.25rem;letter-spacing:-1px}
784
790
  .gbtn-deployed{background:transparent;border:1px solid #22c55e;color:#22c55e;cursor:default;font-size:0.7rem}
785
791
  @keyframes wiggle{0%{transform:rotate(0deg)}15%{transform:rotate(14deg)}30%{transform:rotate(-10deg)}45%{transform:rotate(8deg)}60%{transform:rotate(-4deg)}75%{transform:rotate(2deg)}100%{transform:rotate(0deg)}}
786
792
  .star-wiggle{display:inline-block;transform-origin:center}
793
+ .gallery-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:1rem}
794
+ .tok-comment{color:#64748b}
795
+ .tok-kw{color:#c084fc}
796
+ .tok-str{color:#4ade80}
797
+ .tok-fn{color:#38bdf8}
787
798
  </style>
788
799
  </head>
789
800
  <body>
@@ -809,23 +820,23 @@ h1{font-size:3rem;font-weight:700;margin-bottom:0.25rem;letter-spacing:-1px}
809
820
  <div class="section">
810
821
  <div class="card">
811
822
  <h2>Getting Started</h2>
812
- <pre class="code-block"><code><span style="color:#64748b">// app.ts</span>
813
- <span style="color:#c084fc">import</span> { startServer, Router } <span style="color:#c084fc">from</span> <span style="color:#4ade80">"tina4-nodejs"</span>;
823
+ <pre class="code-block"><code><span class="tok-comment">// app.ts</span>
824
+ <span class="tok-kw">import</span> { startServer, Router } <span class="tok-kw">from</span> <span class="tok-str">"tina4-nodejs"</span>;
814
825
 
815
- Router.get(<span style="color:#4ade80">"/hello"</span>, <span style="color:#c084fc">async</span> (<span style="color:#38bdf8">req</span>, <span style="color:#38bdf8">res</span>) =&gt; {
816
- <span style="color:#c084fc">return</span> res.json({ message: <span style="color:#4ade80">"Hello World!"</span> });
826
+ Router.get(<span class="tok-str">"/hello"</span>, <span class="tok-kw">async</span> (<span class="tok-fn">req</span>, <span class="tok-fn">res</span>) =&gt; {
827
+ <span class="tok-kw">return</span> res.json({ message: <span class="tok-str">"Hello World!"</span> });
817
828
  });
818
829
 
819
- startServer({ port: 7148 }); <span style="color:#64748b">// starts on port 7148</span></code></pre>
830
+ startServer({ port: 7148 }); <span class="tok-comment">// starts on port 7148</span></code></pre>
820
831
  </div>
821
832
  </div>
822
833
  <div class="gallery">
823
834
  <h2 id="gallery">What You Can Build</h2>
824
- <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:1rem;">
835
+ <div class="gallery-cards">
825
836
  ${galleryCards}
826
837
  </div>
827
838
  </div>
828
- <script>
839
+ <script nonce="${nonce}">
829
840
  function deployGallery(name) {
830
841
  if (!confirm('Deploy the "' + name + '" gallery example into your project? This will copy files into src/.')) return;
831
842
  fetch('/__dev/api/gallery/deploy', {
@@ -860,6 +871,12 @@ function deployGallery(name) {
860
871
  }
861
872
  setTimeout(doWiggle,3000);
862
873
  })();
874
+ // CSP-clean wiring: no inline onclick handlers — bind every Deploy button from
875
+ // its data-deploy attribute (a nonce covers script elements, not event-handler
876
+ // attributes).
877
+ document.querySelectorAll('.gbtn-deploy[data-deploy]').forEach(function(btn){
878
+ btn.addEventListener('click', function(){ deployGallery(btn.dataset.deploy); });
879
+ });
863
880
  </script>
864
881
  </body>
865
882
  </html>`;
@@ -1685,7 +1702,8 @@ export async function buildDispatchContext(router: Router, base?: string): Promi
1685
1702
 
1686
1703
  let frondEngine: DispatchContext["frondEngine"] = null;
1687
1704
  try {
1688
- const { Frond } = await import("../../frond/src/engine.js");
1705
+ const { Frond, setCspNonceProvider } = await import("../../frond/src/engine.js");
1706
+ setCspNonceProvider(currentCspNonce);
1689
1707
  frondEngine = new Frond(templatesDir);
1690
1708
  } catch {
1691
1709
  // Frond not available — template-route fallback stays inert, same guard startServer() uses.
@@ -1891,7 +1909,14 @@ export async function runDispatch(
1891
1909
  // requests interleaving on the one event loop never read each other's id.
1892
1910
  const requestId = Log.sanitizeRequestId(rawReq.headers["x-request-id"]) ?? randomBytes(4).toString("hex");
1893
1911
  if (!rawRes.headersSent) rawRes.setHeader("x-request-id", requestId);
1894
- return Log.runWithRequestId(requestId, () => dispatchInner(ctx, rawReq, rawRes, requestId));
1912
+ // One CSP nonce per request (ADR-0088), established in the SAME async scope as
1913
+ // the request id so the inline HTML body, the Frond csp_nonce() global, and the
1914
+ // security middleware's Content-Security-Policy header all name one value - and
1915
+ // two requests interleaving on the one event loop never share it.
1916
+ const requestNonce = generateNonce();
1917
+ return Log.runWithRequestId(requestId, () =>
1918
+ runWithCspNonce(requestNonce, () => dispatchInner(ctx, rawReq, rawRes, requestId)),
1919
+ );
1895
1920
  }
1896
1921
 
1897
1922
  /**
@@ -2128,7 +2153,8 @@ export async function startServer(config?: Tina4Config): Promise<{
2128
2153
  let frondEngine: any = null;
2129
2154
  setDefaultTemplatesDir(templatesDir);
2130
2155
  try {
2131
- const { Frond } = await import("../../frond/src/engine.js");
2156
+ const { Frond, setCspNonceProvider } = await import("../../frond/src/engine.js");
2157
+ setCspNonceProvider(currentCspNonce);
2132
2158
  frondEngine = new Frond(templatesDir);
2133
2159
 
2134
2160
  // Always-on Frond {% live %} refresh endpoint. Re-renders a server-rendered
@@ -2367,10 +2393,21 @@ export async function startServer(config?: Tina4Config): Promise<{
2367
2393
  });
2368
2394
 
2369
2395
  aiServer.on("error", (err: any) => {
2370
- if (err.code === "EADDRINUSE") {
2396
+ // The auxiliary AI/test port is a debug convenience, never
2397
+ // load-bearing: ANY bind failure must degrade to no-aux-port, logged
2398
+ // LOUD, never silent. The old handler logged only EADDRINUSE and
2399
+ // silently swallowed every other code (EADDRNOTAVAIL, EACCES, ...),
2400
+ // leaving `aiServer` a dangling non-listening object. The 'error'
2401
+ // listener's presence already stops Node throwing, so the main server
2402
+ // never crashes; this just makes the degrade visible and resets the
2403
+ // handle on every failure. (The synchronous ERR_SOCKET_BAD_PORT for
2404
+ // an out-of-range derived port is handled separately by aiPortInRange.)
2405
+ if (err?.code === "EADDRINUSE") {
2371
2406
  Log.warning(`Test port ${testPort} in use — skipping`);
2372
- aiServer = null;
2407
+ } else {
2408
+ Log.error(`Test port ${testPort} bind failed (${err?.code ?? err}) — skipping`);
2373
2409
  }
2410
+ aiServer = null;
2374
2411
  });
2375
2412
 
2376
2413
  aiServer.listen(testPort, host);
@@ -335,6 +335,28 @@ export class RedisSessionHandler extends RespSessionHandler {
335
335
 
336
336
  const FLASH_PREFIX = "_flash_";
337
337
 
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;
349
+ }
350
+ }
351
+
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));
356
+ }
357
+ return prints;
358
+ }
359
+
338
360
  // ── Session Class ─────────────────────────────────────────────────
339
361
 
340
362
  export class Session {
@@ -361,6 +383,29 @@ export class Session {
361
383
  * Lets `start()` tell "no such session" from "the store is unreachable".
362
384
  */
363
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;
364
409
 
365
410
  constructor(backend?: string, config?: SessionConfig) {
366
411
  const backendType = resolveBackend(
@@ -529,6 +574,11 @@ export class Session {
529
574
  const now = Math.floor(Date.now() / 1000);
530
575
  this.data = { _created: now, _accessed: now };
531
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;
532
582
  return sessionId;
533
583
  }
534
584
  if (loaded) {
@@ -552,6 +602,10 @@ export class Session {
552
602
  // Refresh the accessed timestamp; a write failure here is logged but
553
603
  // must not abort the resume — the request still serves.
554
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;
555
609
  return sessionId;
556
610
  }
557
611
  }
@@ -562,6 +616,10 @@ export class Session {
562
616
  this.data = { _created: now, _accessed: now };
563
617
  this.dirty = false;
564
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;
565
623
  return this.sessionId;
566
624
  }
567
625
 
@@ -606,9 +664,7 @@ export class Session {
606
664
  if (this.sessionId) {
607
665
  this.safeDestroy(this.sessionId);
608
666
  }
609
- this.sessionId = null;
610
- this.data = null;
611
- this.dirty = false;
667
+ this.forget();
612
668
  }
613
669
 
614
670
  /**
@@ -633,6 +689,7 @@ export class Session {
633
689
  const now = Math.floor(Date.now() / 1000);
634
690
  this.data = { _created: this.data._created, _accessed: now };
635
691
  this.dirty = true;
692
+ this.cleared = true;
636
693
  this.save();
637
694
  }
638
695
 
@@ -651,8 +708,27 @@ export class Session {
651
708
  * session fixation — the pre-auth ID is destroyed and the data is carried
652
709
  * onto a fresh, unguessable ID. A backend destroy/write failure is logged
653
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.
654
717
  */
655
- 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
+
656
732
  const oldId = this.sessionId;
657
733
  const oldData = this.data;
658
734
 
@@ -666,6 +742,8 @@ export class Session {
666
742
  this.data = oldData ?? { _created: Math.floor(Date.now() / 1000), _accessed: Math.floor(Date.now() / 1000) };
667
743
  this.data._accessed = Math.floor(Date.now() / 1000);
668
744
  this.dirty = true;
745
+ // The new id has no record yet: write everything to it.
746
+ this.stored = false;
669
747
  this.save();
670
748
  return this.sessionId;
671
749
  }
@@ -749,17 +827,86 @@ export class Session {
749
827
  *
750
828
  * Returns true on a successful persist, false if the backend was unreachable
751
829
  * (logged). The dirty flag is cleared only on success so a later save()
752
- * 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.
753
850
  */
754
851
  save(): boolean {
755
852
  if (!this.sessionId || !this.data) return true;
756
- if (!this.dirty) return true;
757
- 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)) {
758
866
  this.dirty = false;
867
+ this.stored = true;
868
+ this.cleared = false;
869
+ this.loaded = fingerprints(this.data);
759
870
  return true;
760
871
  }
761
872
  return false; // write failed (logged); dirty RETAINED for retry
762
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
+ }
763
910
  }
764
911
 
765
912
  /**
@@ -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
 
@@ -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
  /**
@@ -17,6 +17,6 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
17
17
  <div class="spinner"></div>
18
18
  <div class="error-title">Redirecting…</div>
19
19
  <div class="error-msg">You are being redirected to a new location.</div>
20
- <div class="error-path" style="color:var(--primary)">{{ redirect_url }}</div>
20
+ <div class="error-path c-primary">{{ redirect_url }}</div>
21
21
  <a href="{{ redirect_url }}" class="error-home">Click here if not redirected</a>
22
22
  {% endblock %}
@@ -9,9 +9,9 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
9
9
  {% extends "errors/base.twig" %}
10
10
  {% block title %}401 — Unauthorized{% endblock %}
11
11
  {% block content %}
12
- <div class="error-code" style="color:var(--danger)">401</div>
12
+ <div class="error-code c-danger">401</div>
13
13
  <div class="error-title">Unauthorized</div>
14
14
  <div class="error-msg">You need to sign in to access this resource.</div>
15
- <div class="error-path" style="color:var(--danger)">{{ path }}</div>
15
+ <div class="error-path c-danger">{{ path }}</div>
16
16
  <a href="/" class="error-home">Go Home</a>
17
17
  {% endblock %}
@@ -12,7 +12,7 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
12
12
  <meta charset="utf-8">
13
13
  <meta name="viewport" content="width=device-width, initial-scale=1">
14
14
  <title>403 — Forbidden</title>
15
- <style>
15
+ <style nonce="{{ csp_nonce() }}">
16
16
  * { box-sizing: border-box; margin: 0; padding: 0; }
17
17
  body { font-family: system-ui, -apple-system, sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
18
18
  .error-card { background: #1e293b; border: 1px solid #334155; border-radius: 1rem; padding: 3rem; text-align: center; max-width: 520px; width: 90%; }
@@ -12,7 +12,7 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
12
12
  <meta charset="utf-8">
13
13
  <meta name="viewport" content="width=device-width, initial-scale=1">
14
14
  <title>404 — Not Found</title>
15
- <style>
15
+ <style nonce="{{ csp_nonce() }}">
16
16
  * { box-sizing: border-box; margin: 0; padding: 0; }
17
17
  body { font-family: system-ui, -apple-system, sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
18
18
  .error-card { background: #1e293b; border: 1px solid #334155; border-radius: 1rem; padding: 3rem; text-align: center; max-width: 520px; width: 90%; }
@@ -12,7 +12,7 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
12
12
  <meta charset="utf-8">
13
13
  <meta name="viewport" content="width=device-width, initial-scale=1">
14
14
  <title>500 — Server Error</title>
15
- <style>
15
+ <style nonce="{{ csp_nonce() }}">
16
16
  * { box-sizing: border-box; margin: 0; padding: 0; }
17
17
  body { font-family: system-ui, -apple-system, sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
18
18
  .error-card { background: #1e293b; border: 1px solid #334155; border-radius: 1rem; padding: 3rem; text-align: left; max-width: 700px; width: 90%; }
@@ -9,9 +9,9 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
9
9
  {% extends "errors/base.twig" %}
10
10
  {% block title %}502 — Bad Gateway{% endblock %}
11
11
  {% block content %}
12
- <div class="error-code" style="color:var(--danger)">502</div>
12
+ <div class="error-code c-danger">502</div>
13
13
  <div class="error-title">Bad Gateway</div>
14
14
  <div class="error-msg">The upstream server returned an invalid response.</div>
15
- <div class="error-path" style="color:var(--danger)">{{ path }}</div>
15
+ <div class="error-path c-danger">{{ path }}</div>
16
16
  <a href="/" class="error-home">Go Home</a>
17
17
  {% endblock %}
@@ -12,9 +12,9 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
12
12
  .error-code { color: var(--warn); }
13
13
  {% endblock %}
14
14
  {% block content %}
15
- <div class="error-code" style="color:var(--warn)">503</div>
15
+ <div class="error-code c-warn">503</div>
16
16
  <div class="error-title">Service Unavailable</div>
17
17
  <div class="error-msg">The server is temporarily unavailable. Please try again shortly.</div>
18
- <div class="error-path" style="color:var(--warn)">{{ path }}</div>
18
+ <div class="error-path c-warn">{{ path }}</div>
19
19
  <a href="/" class="error-home">Go Home</a>
20
20
  {% endblock %}
@@ -13,7 +13,7 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
13
13
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
14
14
  {% block meta %}{% endblock %}
15
15
  <title>{% block title %}Error{% endblock %}</title>
16
- <style>
16
+ <style nonce="{{ csp_nonce() }}">
17
17
  :root {
18
18
  --bg: #0f172a; --surface: #1e293b; --border: #334155;
19
19
  --text: #e2e8f0; --muted: #94a3b8; --primary: #3b82f6;
@@ -31,6 +31,9 @@ body { font-family: var(--font); background: var(--bg); color: var(--text); min-
31
31
  .error-home { display: inline-block; margin-top: 1.5rem; padding: 0.5rem 1.5rem; background: var(--primary); color: #fff; text-decoration: none; border-radius: 0.5rem; font-size: 0.85rem; }
32
32
  .error-home:hover { opacity: 0.9; }
33
33
  .logo { width: 3rem; height: 3rem; margin-bottom: 1rem; opacity: 0.5; }
34
+ .c-danger { color: var(--danger); }
35
+ .c-warn { color: var(--warn); }
36
+ .c-primary { color: var(--primary); }
34
37
  {% block extra_styles %}{% endblock %}
35
38
  </style>
36
39
  </head>
@@ -1413,6 +1413,7 @@ function _b64url(data) {
1413
1413
  return data.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
1414
1414
  }
1415
1415
  var _formTokenSessionId = "";
1416
+ var _cspNonceProvider = () => "";
1416
1417
  function _buildFormTokenJwt(descriptor = "") {
1417
1418
  const secret = process.env.TINA4_SECRET ?? "";
1418
1419
  const secretBytes = Buffer.byteLength(secret, "utf8");
@@ -1604,6 +1605,7 @@ var Frond = class _Frond {
1604
1605
  this.globals.formTokenValue = (descriptor) => _generateFormTokenValue(descriptor || "");
1605
1606
  this.globals.form_token_value = (descriptor) => _generateFormTokenValue(descriptor || "");
1606
1607
  this.globals.dump = (value) => renderDump(value);
1608
+ this.globals.csp_nonce = () => _cspNonceProvider();
1607
1609
  for (const [k, v] of _Frond.classFilters) this.filters[k] = v;
1608
1610
  for (const [k, v] of _Frond.classGlobals) this.globals[k] = v;
1609
1611
  for (const [k, v] of _Frond.classTests) this.tests[k] = v;