tina4-nodejs 3.13.142 → 3.13.144

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
@@ -17430,6 +17430,73 @@ var init_trustedProxy = __esm({
17430
17430
  }
17431
17431
  });
17432
17432
 
17433
+ // src/csp.ts
17434
+ import { AsyncLocalStorage as AsyncLocalStorage3 } from "node:async_hooks";
17435
+ import { randomBytes as randomBytes4 } from "node:crypto";
17436
+ function generateNonce() {
17437
+ return randomBytes4(16).toString("base64");
17438
+ }
17439
+ function runWithCspNonce(nonce, fn) {
17440
+ return nonceStore.run({ nonce }, fn);
17441
+ }
17442
+ function setCurrentNonce(nonce) {
17443
+ const store = nonceStore.getStore();
17444
+ if (store) store.nonce = nonce;
17445
+ else nonceFallback = nonce;
17446
+ }
17447
+ function clearCurrentNonce() {
17448
+ setCurrentNonce(void 0);
17449
+ }
17450
+ function currentCspNonce() {
17451
+ const store = nonceStore.getStore();
17452
+ let value = store ? store.nonce : nonceFallback;
17453
+ if (!value) {
17454
+ value = generateNonce();
17455
+ if (store) store.nonce = value;
17456
+ else nonceFallback = value;
17457
+ }
17458
+ return value;
17459
+ }
17460
+ function cspNonce() {
17461
+ return currentCspNonce();
17462
+ }
17463
+ function injectNonceIntoCsp(csp, nonce) {
17464
+ const token = `'nonce-${nonce}'`;
17465
+ const directives = [];
17466
+ for (const rawPart of csp.split(";")) {
17467
+ const part = rawPart.trim();
17468
+ if (!part) continue;
17469
+ const spaceAt = part.search(/\s/);
17470
+ const name = (spaceAt === -1 ? part : part.slice(0, spaceAt)).toLowerCase();
17471
+ const value = spaceAt === -1 ? "" : part.slice(spaceAt + 1).trim();
17472
+ directives.push([name, value]);
17473
+ }
17474
+ const defaultEntry = directives.find(([name]) => name === "default-src");
17475
+ const defaultValue = defaultEntry && defaultEntry[1] ? defaultEntry[1] : "'self'";
17476
+ for (const directive of ["style-src", "script-src"]) {
17477
+ const existing = directives.find(([name]) => name === directive);
17478
+ if (existing) {
17479
+ if (!existing[1].split(/\s+/).includes(token)) {
17480
+ existing[1] = `${existing[1]} ${token}`.trim();
17481
+ }
17482
+ } else {
17483
+ directives.push([directive, `${defaultValue} ${token}`.trim()]);
17484
+ }
17485
+ }
17486
+ return directives.map(([name, value]) => value ? `${name} ${value}` : name).join("; ");
17487
+ }
17488
+ function resolveCspHeader(nonce) {
17489
+ const csp = process.env.TINA4_CSP || "default-src 'self'";
17490
+ return injectNonceIntoCsp(csp, nonce);
17491
+ }
17492
+ var nonceStore, nonceFallback;
17493
+ var init_csp = __esm({
17494
+ "src/csp.ts"() {
17495
+ "use strict";
17496
+ nonceStore = new AsyncLocalStorage3();
17497
+ }
17498
+ });
17499
+
17433
17500
  // ../frond/src/engine.ts
17434
17501
  var engine_exports = {};
17435
17502
  __export(engine_exports, {
@@ -17439,9 +17506,10 @@ __export(engine_exports, {
17439
17506
  expressionFormCache: () => expressionFormCache,
17440
17507
  filterChainCache: () => filterChainCache,
17441
17508
  pathParseCache: () => pathParseCache,
17509
+ setCspNonceProvider: () => setCspNonceProvider,
17442
17510
  setFormTokenSessionId: () => setFormTokenSessionId
17443
17511
  });
17444
- import { createHash as createHash3, createHmac as createHmac3, randomBytes as randomBytes4 } from "node:crypto";
17512
+ import { createHash as createHash3, createHmac as createHmac3, randomBytes as randomBytes5 } from "node:crypto";
17445
17513
  import { readFileSync as readFileSync6, existsSync as existsSync7, statSync as statSync6, realpathSync } from "node:fs";
17446
17514
  import { join as join10, resolve as resolve6, isAbsolute as isAbsolute4, sep as sep3 } from "node:path";
17447
17515
  function jsonText(value) {
@@ -18508,6 +18576,9 @@ function _b64url(data) {
18508
18576
  function setFormTokenSessionId(sessionId) {
18509
18577
  _formTokenSessionId = sessionId || "";
18510
18578
  }
18579
+ function setCspNonceProvider(fn) {
18580
+ _cspNonceProvider = fn;
18581
+ }
18511
18582
  function _buildFormTokenJwt(descriptor = "") {
18512
18583
  const secret = process.env.TINA4_SECRET ?? "";
18513
18584
  const secretBytes = Buffer.byteLength(secret, "utf8");
@@ -18519,7 +18590,7 @@ function _buildFormTokenJwt(descriptor = "") {
18519
18590
  const ttlMinutes = parseInt(process.env.TINA4_TOKEN_LIMIT || "60", 10);
18520
18591
  const header = { alg: "HS256", typ: "JWT" };
18521
18592
  const now = Math.floor(Date.now() / 1e3);
18522
- const payload = { type: "form", nonce: randomBytes4(8).toString("hex"), iat: now, exp: now + ttlMinutes * 60 };
18593
+ const payload = { type: "form", nonce: randomBytes5(8).toString("hex"), iat: now, exp: now + ttlMinutes * 60 };
18523
18594
  if (descriptor) {
18524
18595
  if (descriptor.includes("|")) {
18525
18596
  const [ctx, ref] = descriptor.split("|", 2);
@@ -18546,7 +18617,7 @@ function _generateFormToken(descriptor = "") {
18546
18617
  function _generateFormTokenValue(descriptor = "") {
18547
18618
  return new SafeString(_buildFormTokenJwt(descriptor));
18548
18619
  }
18549
- var SafeString, KNOWN_TAGS, TERMINATOR_TAGS, GATEABLE_TAGS, BLOCK_TAG_ENDS, JSON_UNSAFE_RE, JSON_UNSAFE_MAP, NUMERIC_RE, METHOD_CALL_RE, FN_CALL_RE, IS_NOT_RE, IS_RE, NOT_IN_RE, IN_RE, DIVISIBLE_BY_RE, FILTER_WITH_ARGS_RE, FILTER_COMPARISON_RE, TITLE_WORD_RE, STRIP_TAGS_RE, FAST_FILTERS, FORMAT_RE, LEADING_WS_RE, TRAILING_WS_RE, THOUSANDS_RE, LIVE_RE, LIVE_WS_RE, LIVE_SRC_RE, EXTENDS_RE, EXTENDS_RE_GLOBAL, filterChainCache, pathParseCache, TEMPLATE_CACHE_MAX, MEMO_CACHE_MAX, TOKEN_RE, RAW_BLOCK_RE, EXPR_NOT_MATCHED, ARITHMETIC_OPERATIONS, EXPR_EVALUATORS, expressionFormCache, VarRef, JS_SAFE_RE, MEDIA_TYPE_RE, SANDBOX_EXPR_KEYWORDS, SANDBOX_IDENT_RE, SANDBOX_STRING_RE, UNSAFE_KEYS, BUILTIN_FILTERS, _formTokenSessionId, Frond;
18620
+ var SafeString, KNOWN_TAGS, TERMINATOR_TAGS, GATEABLE_TAGS, BLOCK_TAG_ENDS, JSON_UNSAFE_RE, JSON_UNSAFE_MAP, NUMERIC_RE, METHOD_CALL_RE, FN_CALL_RE, IS_NOT_RE, IS_RE, NOT_IN_RE, IN_RE, DIVISIBLE_BY_RE, FILTER_WITH_ARGS_RE, FILTER_COMPARISON_RE, TITLE_WORD_RE, STRIP_TAGS_RE, FAST_FILTERS, FORMAT_RE, LEADING_WS_RE, TRAILING_WS_RE, THOUSANDS_RE, LIVE_RE, LIVE_WS_RE, LIVE_SRC_RE, EXTENDS_RE, EXTENDS_RE_GLOBAL, filterChainCache, pathParseCache, TEMPLATE_CACHE_MAX, MEMO_CACHE_MAX, TOKEN_RE, RAW_BLOCK_RE, EXPR_NOT_MATCHED, ARITHMETIC_OPERATIONS, EXPR_EVALUATORS, expressionFormCache, VarRef, JS_SAFE_RE, MEDIA_TYPE_RE, SANDBOX_EXPR_KEYWORDS, SANDBOX_IDENT_RE, SANDBOX_STRING_RE, UNSAFE_KEYS, BUILTIN_FILTERS, _formTokenSessionId, _cspNonceProvider, Frond;
18550
18621
  var init_engine = __esm({
18551
18622
  "../frond/src/engine.ts"() {
18552
18623
  "use strict";
@@ -18900,6 +18971,7 @@ var init_engine = __esm({
18900
18971
  js_escape: (v) => new SafeString(jsEscape(v))
18901
18972
  };
18902
18973
  _formTokenSessionId = "";
18974
+ _cspNonceProvider = () => "";
18903
18975
  Frond = class _Frond {
18904
18976
  // ── Class-level registries ──────────────────────────────────
18905
18977
  // Persist globals, filters, and tests across hot-reloads and at
@@ -19053,6 +19125,7 @@ var init_engine = __esm({
19053
19125
  this.globals.formTokenValue = (descriptor) => _generateFormTokenValue(descriptor || "");
19054
19126
  this.globals.form_token_value = (descriptor) => _generateFormTokenValue(descriptor || "");
19055
19127
  this.globals.dump = (value) => renderDump(value);
19128
+ this.globals.csp_nonce = () => _cspNonceProvider();
19056
19129
  for (const [k, v] of _Frond.classFilters) this.filters[k] = v;
19057
19130
  for (const [k, v] of _Frond.classGlobals) this.globals[k] = v;
19058
19131
  for (const [k, v] of _Frond.classTests) this.tests[k] = v;
@@ -20316,7 +20389,8 @@ async function getFrond() {
20316
20389
  const dir = _defaultTemplatesDir ?? nodePath.resolve(process.cwd(), "src/templates");
20317
20390
  let engine = _frondCache.get(dir);
20318
20391
  if (!engine) {
20319
- const { Frond: Frond2 } = await Promise.resolve().then(() => (init_engine(), engine_exports));
20392
+ const { Frond: Frond2, setCspNonceProvider: setCspNonceProvider2 } = await Promise.resolve().then(() => (init_engine(), engine_exports));
20393
+ setCspNonceProvider2(currentCspNonce);
20320
20394
  engine = new Frond2(dir);
20321
20395
  _frondCache.set(dir, engine);
20322
20396
  }
@@ -20327,7 +20401,8 @@ async function getFrameworkFrond() {
20327
20401
  const frameworkDir = nodePath.resolve(nodePath.dirname(import.meta.url.replace("file://", "")), "..", "templates");
20328
20402
  if (!_frameworkFrond && fs2.existsSync(frameworkDir)) {
20329
20403
  try {
20330
- const { Frond: Frond2 } = await Promise.resolve().then(() => (init_engine(), engine_exports));
20404
+ const { Frond: Frond2, setCspNonceProvider: setCspNonceProvider2 } = await Promise.resolve().then(() => (init_engine(), engine_exports));
20405
+ setCspNonceProvider2(currentCspNonce);
20331
20406
  _frameworkFrond = new Frond2(frameworkDir);
20332
20407
  } catch {
20333
20408
  return null;
@@ -20427,6 +20502,7 @@ function createResponse(res) {
20427
20502
  return response;
20428
20503
  };
20429
20504
  response.raw = res;
20505
+ response.cspNonce = currentCspNonce();
20430
20506
  response.json = function(data, status2) {
20431
20507
  if (res.headersSent) return response;
20432
20508
  if (status2 !== void 0) res.statusCode = status2;
@@ -20572,7 +20648,8 @@ function createResponse(res) {
20572
20648
  };
20573
20649
  response.render = async function(templateName, data, status2, templateDir) {
20574
20650
  try {
20575
- const { Frond: Frond2 } = await Promise.resolve().then(() => (init_engine(), engine_exports));
20651
+ const { Frond: Frond2, setCspNonceProvider: setCspNonceProvider2 } = await Promise.resolve().then(() => (init_engine(), engine_exports));
20652
+ setCspNonceProvider2(currentCspNonce);
20576
20653
  const dir = templateDir ?? _defaultTemplatesDir ?? nodePath.resolve(process.cwd(), "src/templates");
20577
20654
  let engine = _frondCache.get(dir);
20578
20655
  if (!engine) {
@@ -20690,6 +20767,7 @@ var HEADER_TOKEN, _frondCache, _defaultTemplatesDir, _globalFrond, _frameworkFro
20690
20767
  var init_response = __esm({
20691
20768
  "src/response.ts"() {
20692
20769
  "use strict";
20770
+ init_csp();
20693
20771
  HEADER_TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
20694
20772
  _frondCache = /* @__PURE__ */ new Map();
20695
20773
  _defaultTemplatesDir = null;
@@ -20901,6 +20979,7 @@ var init_middleware = __esm({
20901
20979
  init_dotenv();
20902
20980
  init_router();
20903
20981
  init_trustedProxy();
20982
+ init_csp();
20904
20983
  init_response();
20905
20984
  MiddlewareChain = class {
20906
20985
  middlewares = [];
@@ -21333,12 +21412,18 @@ var init_middleware = __esm({
21333
21412
  * overrides, WITHOUT Strict-Transport-Security, which depends on the
21334
21413
  * request's scheme. Also what a transport rejection carries (ADR-0068
21335
21414
  * section 4), written before the scheme is known.
21415
+ *
21416
+ * Content-Security-Policy always carries a per-response nonce in style-src
21417
+ * AND script-src (ADR-0088), read from the request async-context (minted on
21418
+ * first access), so the framework's own inline content runs under the strict
21419
+ * default without 'unsafe-inline'. The nonce is random per response, so a
21420
+ * caller comparing this header must assert its STRUCTURE, never a byte string.
21336
21421
  */
21337
21422
  static canonicalHeaders() {
21338
21423
  return {
21339
21424
  "X-Frame-Options": process.env.TINA4_FRAME_OPTIONS ?? "SAMEORIGIN",
21340
21425
  "X-Content-Type-Options": "nosniff",
21341
- "Content-Security-Policy": process.env.TINA4_CSP ?? "default-src 'self'",
21426
+ "Content-Security-Policy": resolveCspHeader(currentCspNonce()),
21342
21427
  "Referrer-Policy": process.env.TINA4_REFERRER_POLICY ?? "strict-origin-when-cross-origin",
21343
21428
  "X-XSS-Protection": "0",
21344
21429
  "Permissions-Policy": process.env.TINA4_PERMISSIONS_POLICY ?? "camera=(), microphone=(), geolocation=()"
@@ -21380,7 +21465,7 @@ var init_middleware = __esm({
21380
21465
  static warnCspDefaultOnce() {
21381
21466
  if (_SecurityHeadersMiddleware.cspDefaultWarned) return;
21382
21467
  _SecurityHeadersMiddleware.cspDefaultWarned = true;
21383
- const message = `TINA4_CSP is not set, so Tina4 is serving the default Content-Security-Policy "default-src 'self'" on every response. That default blocks runtime-injected inline styles, cross-origin fonts/scripts/CDNs, data: URIs, and cross-origin WebSocket/XHR (e.g. a separate API or LiveKit host). If your app uses any of these, set TINA4_CSP to a policy that allows them (see https://tina4.com); to silence this notice without changing behaviour, set TINA4_CSP="default-src 'self'".`;
21468
+ const message = `TINA4_CSP is not set, so Tina4 is serving the default Content-Security-Policy "default-src 'self'" on every response. The framework injects a per-response nonce into style-src and script-src, so its own inline <style>/<script> (and your templates using the csp_nonce() Frond global) work under this policy. The default still blocks cross-origin fonts/scripts/CDNs, data: URIs, and cross-origin WebSocket/XHR (e.g. a separate API or LiveKit host). If your app uses any of these, set TINA4_CSP to a policy that allows them (see https://tina4.com); to silence this notice without changing behaviour, set TINA4_CSP="default-src 'self'".`;
21384
21469
  try {
21385
21470
  Log.warning(message);
21386
21471
  } catch {
@@ -23773,7 +23858,7 @@ __export(session_exports, {
23773
23858
  sessionCookieName: () => sessionCookieName,
23774
23859
  sessionStrictMode: () => sessionStrictMode
23775
23860
  });
23776
- import { createHash as createHash5, randomBytes as randomBytes5 } from "node:crypto";
23861
+ import { createHash as createHash5, randomBytes as randomBytes6 } from "node:crypto";
23777
23862
  import { existsSync as existsSync10, mkdirSync as mkdirSync10, readFileSync as readFileSync8, writeFileSync as writeFileSync7, unlinkSync as unlinkSync5, readdirSync as readdirSync6 } from "node:fs";
23778
23863
  import { join as join14 } from "node:path";
23779
23864
  function isValidSessionId(sessionId) {
@@ -23791,6 +23876,20 @@ function resolveBackend(name) {
23791
23876
  }
23792
23877
  return normalised;
23793
23878
  }
23879
+ function fingerprint(value) {
23880
+ try {
23881
+ return JSON.stringify(value);
23882
+ } catch {
23883
+ return void 0;
23884
+ }
23885
+ }
23886
+ function fingerprints(data) {
23887
+ const prints = /* @__PURE__ */ new Map();
23888
+ for (const [key, value] of Object.entries(data)) {
23889
+ prints.set(key, fingerprint(value));
23890
+ }
23891
+ return prints;
23892
+ }
23794
23893
  function isSecureScheme(forwardedProto, socketEncrypted) {
23795
23894
  const forwarded = (forwardedProto ?? "").trim();
23796
23895
  if (forwarded !== "") {
@@ -23988,6 +24087,29 @@ var init_session = __esm({
23988
24087
  * Lets `start()` tell "no such session" from "the store is unreachable".
23989
24088
  */
23990
24089
  lastReadFailed = false;
24090
+ /**
24091
+ * What this request last saw of the stored record, one fingerprint per key:
24092
+ * taken by start() and again by every save. save() compares the live data
24093
+ * against it to find the keys THIS request changed.
24094
+ */
24095
+ loaded = /* @__PURE__ */ new Map();
24096
+ /**
24097
+ * True when this request is working on a record the store holds: start()
24098
+ * adopted one, or a save has written one since. A save never re-creates such
24099
+ * a record once another request has removed it.
24100
+ */
24101
+ stored = false;
24102
+ /**
24103
+ * True when clear() ran since the last save: the save then replaces the
24104
+ * stored record instead of merging into it.
24105
+ */
24106
+ cleared = false;
24107
+ /**
24108
+ * True once this request found its session ended by another request (the
24109
+ * record it loaded is gone). It stays ended for the rest of the request:
24110
+ * regenerate() mints nothing either.
24111
+ */
24112
+ ended = false;
23991
24113
  constructor(backend, config) {
23992
24114
  const backendType = resolveBackend(
23993
24115
  backend ?? config?.backend ?? process.env.TINA4_SESSION_BACKEND ?? "file"
@@ -24132,6 +24254,10 @@ var init_session = __esm({
24132
24254
  const now2 = Math.floor(Date.now() / 1e3);
24133
24255
  this.data = { _created: now2, _accessed: now2 };
24134
24256
  this.dirty = false;
24257
+ this.stored = false;
24258
+ this.loaded = /* @__PURE__ */ new Map();
24259
+ this.cleared = false;
24260
+ this.ended = false;
24135
24261
  return sessionId;
24136
24262
  }
24137
24263
  if (loaded) {
@@ -24141,14 +24267,22 @@ var init_session = __esm({
24141
24267
  this.data._accessed = now2;
24142
24268
  this.dirty = false;
24143
24269
  this.safeWrite(this.sessionId, this.data, this.ttl);
24270
+ this.stored = true;
24271
+ this.loaded = fingerprints(this.data);
24272
+ this.cleared = false;
24273
+ this.ended = false;
24144
24274
  return sessionId;
24145
24275
  }
24146
24276
  }
24147
- this.sessionId = randomBytes5(16).toString("hex");
24277
+ this.sessionId = randomBytes6(16).toString("hex");
24148
24278
  const now = Math.floor(Date.now() / 1e3);
24149
24279
  this.data = { _created: now, _accessed: now };
24150
24280
  this.dirty = false;
24151
24281
  this.safeWrite(this.sessionId, this.data, this.ttl);
24282
+ this.stored = false;
24283
+ this.loaded = /* @__PURE__ */ new Map();
24284
+ this.cleared = false;
24285
+ this.ended = false;
24152
24286
  return this.sessionId;
24153
24287
  }
24154
24288
  /**
@@ -24189,9 +24323,7 @@ var init_session = __esm({
24189
24323
  if (this.sessionId) {
24190
24324
  this.safeDestroy(this.sessionId);
24191
24325
  }
24192
- this.sessionId = null;
24193
- this.data = null;
24194
- this.dirty = false;
24326
+ this.forget();
24195
24327
  }
24196
24328
  /**
24197
24329
  * Get all session data (excluding internal keys).
@@ -24214,6 +24346,7 @@ var init_session = __esm({
24214
24346
  const now = Math.floor(Date.now() / 1e3);
24215
24347
  this.data = { _created: this.data._created, _accessed: now };
24216
24348
  this.dirty = true;
24349
+ this.cleared = true;
24217
24350
  this.save();
24218
24351
  }
24219
24352
  /**
@@ -24230,17 +24363,36 @@ var init_session = __esm({
24230
24363
  * session fixation — the pre-auth ID is destroyed and the data is carried
24231
24364
  * onto a fresh, unguessable ID. A backend destroy/write failure is logged
24232
24365
  * (never silent) but does not throw under the default policy.
24366
+ *
24367
+ * What is carried is the session as it is stored now with this request's
24368
+ * own changes applied, the same merge save() does. If another request ended
24369
+ * the session after this one loaded it (a logout, or a regenerate of its
24370
+ * own), it stays ended: nothing is carried, no id is minted and null is
24371
+ * returned, so no cookie goes out to replace the one that request sent.
24233
24372
  */
24234
24373
  regenerate() {
24374
+ if (this.ended) return null;
24375
+ if (this.sessionId && this.stored && this.data) {
24376
+ const current = this.safeRead(this.sessionId);
24377
+ if (!this.lastReadFailed) {
24378
+ if (!current) {
24379
+ this.forget();
24380
+ this.ended = true;
24381
+ return null;
24382
+ }
24383
+ if (!this.cleared) this.data = this.merged(current);
24384
+ }
24385
+ }
24235
24386
  const oldId = this.sessionId;
24236
24387
  const oldData = this.data;
24237
24388
  if (oldId) {
24238
24389
  this.safeDestroy(oldId);
24239
24390
  }
24240
- this.sessionId = randomBytes5(16).toString("hex");
24391
+ this.sessionId = randomBytes6(16).toString("hex");
24241
24392
  this.data = oldData ?? { _created: Math.floor(Date.now() / 1e3), _accessed: Math.floor(Date.now() / 1e3) };
24242
24393
  this.data._accessed = Math.floor(Date.now() / 1e3);
24243
24394
  this.dirty = true;
24395
+ this.stored = false;
24244
24396
  this.save();
24245
24397
  return this.sessionId;
24246
24398
  }
@@ -24316,17 +24468,84 @@ var init_session = __esm({
24316
24468
  *
24317
24469
  * Returns true on a successful persist, false if the backend was unreachable
24318
24470
  * (logged). The dirty flag is cleared only on success so a later save()
24319
- * retries once the backend recovers. A nothing-to-persist call returns true.
24471
+ * retries once the backend recovers. A call with no session returns true.
24472
+ *
24473
+ * Other requests on the same session run while this one does, so the save
24474
+ * writes only THIS request's changes, onto the record as it is stored now.
24475
+ * Writing back the whole snapshot start() loaded undid whatever another
24476
+ * request did in between: a logout (destroy, clear, regenerate) or a
24477
+ * privilege change made with set(). A record removed since this request
24478
+ * loaded it is never re-created: the session has ended, for this request
24479
+ * too. A request that has not loaded a stored record (a new session, the new
24480
+ * id regenerate() mints) writes its data whole, as before.
24481
+ *
24482
+ * A session the store already holds is re-written even when this request
24483
+ * changed nothing: that write re-stamps the backend deadline to
24484
+ * now + TINA4_SESSION_TTL, so a session expires after that span of INACTIVITY
24485
+ * rather than that long after its last CHANGE (ADR-0087 — a session's expiry
24486
+ * slides on activity). The re-write is the re-read, merged record, so sliding
24487
+ * the deadline is concurrent-safe: it never clobbers another request's change,
24488
+ * never re-creates an ended record, and TINA4_SESSION_TTL=0 still re-stamps a
24489
+ * never-expires deadline (ADR-0027). A request with no stored session and no
24490
+ * change writes nothing — there is no deadline to move.
24320
24491
  */
24321
24492
  save() {
24322
24493
  if (!this.sessionId || !this.data) return true;
24323
- if (!this.dirty) return true;
24324
- if (this.safeWrite(this.sessionId, this.data, this.ttl)) {
24494
+ if (!this.dirty && !this.stored) return true;
24495
+ let record = this.data;
24496
+ if (this.stored) {
24497
+ const current = this.safeRead(this.sessionId);
24498
+ if (this.lastReadFailed) return false;
24499
+ if (!current) {
24500
+ this.forget();
24501
+ this.ended = true;
24502
+ return true;
24503
+ }
24504
+ if (!this.cleared) record = this.merged(current);
24505
+ }
24506
+ if (this.safeWrite(this.sessionId, record, this.ttl)) {
24325
24507
  this.dirty = false;
24508
+ this.stored = true;
24509
+ this.cleared = false;
24510
+ this.loaded = fingerprints(this.data);
24326
24511
  return true;
24327
24512
  }
24328
24513
  return false;
24329
24514
  }
24515
+ /**
24516
+ * The stored record with this request's changes applied: every key whose
24517
+ * value differs from what this request loaded or last saved, and the
24518
+ * removal of every key it deleted. Keys it did not touch keep whatever is
24519
+ * stored now.
24520
+ */
24521
+ merged(current) {
24522
+ const data = this.data;
24523
+ const record = { ...current };
24524
+ for (const [key, value] of Object.entries(data)) {
24525
+ const print = fingerprint(value);
24526
+ if (print === void 0 || !this.loaded.has(key) || this.loaded.get(key) !== print) {
24527
+ record[key] = value;
24528
+ }
24529
+ }
24530
+ for (const key of this.loaded.keys()) {
24531
+ if (!Object.prototype.hasOwnProperty.call(data, key)) {
24532
+ delete record[key];
24533
+ }
24534
+ }
24535
+ return record;
24536
+ }
24537
+ /**
24538
+ * End the session for this request without touching the store: no id and
24539
+ * no data, so nothing more is written and no cookie goes out for it.
24540
+ */
24541
+ forget() {
24542
+ this.sessionId = null;
24543
+ this.data = null;
24544
+ this.dirty = false;
24545
+ this.stored = false;
24546
+ this.cleared = false;
24547
+ this.loaded = /* @__PURE__ */ new Map();
24548
+ }
24330
24549
  };
24331
24550
  }
24332
24551
  });
@@ -24695,11 +24914,11 @@ function formatSourceBlock(filename, lineno) {
24695
24914
  const lines = readSourceLines(filename, lineno);
24696
24915
  if (lines.length === 0) return "";
24697
24916
  const rows = lines.map(([num, text, isError]) => {
24698
- const bg = isError ? `background:${ERROR_LINE_BG};` : "";
24917
+ const rowClass = isError ? "eo-line eo-line-err" : "eo-line";
24699
24918
  const marker = isError ? "&#x25b6;" : " ";
24700
- return `<div style="${bg}display:flex;padding:1px 0;"><span style="color:${YELLOW};min-width:3.5em;text-align:right;padding-right:1em;user-select:none;">${num}</span><span style="color:${RED};width:1.2em;user-select:none;">${marker}</span><span style="color:${TEXT};white-space:pre-wrap;tab-size:4;">${esc(text)}</span></div>`;
24919
+ return `<div class="${rowClass}"><span class="eo-ln">${num}</span><span class="eo-marker">${marker}</span><span class="eo-code">${esc(text)}</span></div>`;
24701
24920
  }).join("\n");
24702
- return `<div style="background:${SURFACE};border-radius:6px;padding:12px;overflow-x:auto;font-family:'SF Mono','Fira Code','Consolas',monospace;font-size:13px;line-height:1.6;">` + rows + `</div>`;
24921
+ return `<div class="eo-source">` + rows + `</div>`;
24703
24922
  }
24704
24923
  function formatFrame(frame, capturedAt = 0) {
24705
24924
  const source = frame.file && frame.line > 0 ? formatSourceBlock(frame.file, frame.line) : "";
@@ -24711,23 +24930,23 @@ function formatFrame(frame, capturedAt = 0) {
24711
24930
  if (mtime > capturedAt + 0.5) {
24712
24931
  const d = new Date(mtime * 1e3);
24713
24932
  const mtimeIso = `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}:${String(d.getUTCSeconds()).padStart(2, "0")}`;
24714
- staleBadge = ` <span style="background:${PEACH};color:${BG};padding:1px 8px;border-radius:3px;font-size:11px;font-weight:700;margin-left:6px;">FILE MODIFIED @ ${mtimeIso} UTC &mdash; source may not match what failed</span>`;
24933
+ staleBadge = ` <span class="eo-stale">FILE MODIFIED @ ${mtimeIso} UTC &mdash; source may not match what failed</span>`;
24715
24934
  }
24716
24935
  } catch {
24717
24936
  }
24718
24937
  }
24719
- return `<div style="margin-bottom:16px;"><div style="margin-bottom:4px;"><span style="color:${BLUE};">${esc(frame.file)}</span><span style="color:${SUBTEXT};"> : </span><span style="color:${YELLOW};">${frame.line}</span><span style="color:${SUBTEXT};"> in </span><span style="color:${GREEN};">${esc(frame.func)}</span>` + staleBadge + `</div>` + source + `</div>`;
24938
+ return `<div class="eo-frame"><div class="eo-frame-head"><span class="eo-file">${esc(frame.file)}</span><span class="eo-sep"> : </span><span class="eo-lineno">${frame.line}</span><span class="eo-sep"> in </span><span class="eo-fn">${esc(frame.func)}</span>` + staleBadge + `</div>` + source + `</div>`;
24720
24939
  }
24721
24940
  function collapsible(title, content, openByDefault = false) {
24722
24941
  const open = openByDefault ? " open" : "";
24723
- return `<details style="margin-top:16px;"${open}><summary style="cursor:pointer;color:${LAVENDER};font-weight:600;font-size:15px;padding:8px 0;user-select:none;">${esc(title)}</summary><div style="padding:8px 0;">${content}</div></details>`;
24942
+ return `<details class="eo-details"${open}><summary class="eo-summary">${esc(title)}</summary><div class="eo-details-body">${content}</div></details>`;
24724
24943
  }
24725
24944
  function table(pairs) {
24726
- if (pairs.length === 0) return `<span style="color:${SUBTEXT};">None</span>`;
24945
+ if (pairs.length === 0) return `<span class="eo-none">None</span>`;
24727
24946
  const rows = pairs.map(
24728
- ([key, val]) => `<tr><td style="color:${PEACH};padding:4px 16px 4px 0;vertical-align:top;white-space:nowrap;">${esc(key)}</td><td style="color:${TEXT};padding:4px 0;word-break:break-all;">${esc(val)}</td></tr>`
24947
+ ([key, val]) => `<tr><td class="eo-key">${esc(key)}</td><td class="eo-val">${esc(val)}</td></tr>`
24729
24948
  ).join("");
24730
- return `<table style="border-collapse:collapse;width:100%;">${rows}</table>`;
24949
+ return `<table class="eo-table">${rows}</table>`;
24731
24950
  }
24732
24951
  function renderErrorOverlay(error2, request) {
24733
24952
  const capturedAt = Date.now() / 1e3;
@@ -24740,7 +24959,7 @@ function renderErrorOverlay(error2, request) {
24740
24959
  }
24741
24960
  const hidden = frames.length - Math.min(frames.length, MAX_FRAMES);
24742
24961
  if (hidden > 0) {
24743
- framesHtml += `<div style="color:${SUBTEXT};padding:8px 0;font-size:13px;">&#8230; ${hidden} more stack frames hidden (truncated at ${MAX_FRAMES})</div>`;
24962
+ framesHtml += `<div class="eo-hidden-frames">&#8230; ${hidden} more stack frames hidden (truncated at ${MAX_FRAMES})</div>`;
24744
24963
  }
24745
24964
  const requestPairs = [];
24746
24965
  if (request != null) {
@@ -24777,37 +24996,72 @@ function renderErrorOverlay(error2, request) {
24777
24996
  ];
24778
24997
  const envSection = collapsible("Environment", table(envPairs));
24779
24998
  const stackSection = collapsible("Stack Trace", framesHtml, true);
24999
+ const nonce = currentCspNonce();
24780
25000
  return `<!DOCTYPE html>
24781
25001
  <html lang="en">
24782
25002
  <head>
24783
25003
  <meta charset="utf-8">
24784
25004
  <meta name="viewport" content="width=device-width,initial-scale=1">
24785
25005
  <title>Tina4 Error \u2014 ${esc(excType)}</title>
24786
- <style>
24787
- *{margin:0;padding:0;box-sizing:border-box;}
24788
- body{background:${BG};color:${TEXT};font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;padding:24px;line-height:1.5;}
25006
+ <style nonce="${nonce}">
25007
+ ${overlayStylesheet()}
24789
25008
  </style>
24790
25009
  </head>
24791
25010
  <body>
24792
- <div style="max-width:960px;margin:0 auto;">
24793
- <div style="margin-bottom:24px;">
24794
- <div style="display:flex;align-items:center;gap:12px;margin-bottom:12px;">
24795
- <span style="background:${RED};color:${BG};padding:4px 12px;border-radius:4px;font-weight:700;font-size:13px;text-transform:uppercase;">Error</span>
24796
- <span style="color:${SUBTEXT};font-size:14px;">Tina4 Debug Overlay</span>
25011
+ <div class="eo-wrap">
25012
+ <div class="eo-header">
25013
+ <div class="eo-badge-row">
25014
+ <span class="eo-badge">Error</span>
25015
+ <span class="eo-sub">Tina4 Debug Overlay</span>
24797
25016
  </div>
24798
- <h1 style="color:${RED};font-size:28px;font-weight:700;margin-bottom:8px;">${esc(excType)}</h1>
24799
- <p style="color:${TEXT};font-size:18px;font-family:'SF Mono','Fira Code','Consolas',monospace;background:${SURFACE};padding:12px 16px;border-radius:6px;border-left:4px solid ${RED};">${esc(excMsg)}</p>
25017
+ <h1 class="eo-type">${esc(excType)}</h1>
25018
+ <p class="eo-msg">${esc(excMsg)}</p>
24800
25019
  </div>
24801
25020
  ${stackSection}
24802
25021
  ${requestSection}
24803
25022
  ${envSection}
24804
- <div style="margin-top:32px;padding-top:16px;border-top:1px solid ${OVERLAY};color:${SUBTEXT};font-size:12px;">
25023
+ <div class="eo-footer">
24805
25024
  Tina4 Debug Overlay &mdash; This page is only shown in debug mode. Set TINA4_DEBUG=false in production.
24806
25025
  </div>
24807
25026
  </div>
24808
25027
  </body>
24809
25028
  </html>`;
24810
25029
  }
25030
+ function overlayStylesheet() {
25031
+ return `
25032
+ *{margin:0;padding:0;box-sizing:border-box;}
25033
+ body{background:${BG};color:${TEXT};font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;padding:24px;line-height:1.5;}
25034
+ .eo-wrap{max-width:960px;margin:0 auto;}
25035
+ .eo-header{margin-bottom:24px;}
25036
+ .eo-badge-row{display:flex;align-items:center;gap:12px;margin-bottom:12px;}
25037
+ .eo-badge{background:${RED};color:${BG};padding:4px 12px;border-radius:4px;font-weight:700;font-size:13px;text-transform:uppercase;}
25038
+ .eo-sub{color:${SUBTEXT};font-size:14px;}
25039
+ .eo-type{color:${RED};font-size:28px;font-weight:700;margin-bottom:8px;}
25040
+ .eo-msg{color:${TEXT};font-size:18px;font-family:'SF Mono','Fira Code','Consolas',monospace;background:${SURFACE};padding:12px 16px;border-radius:6px;border-left:4px solid ${RED};}
25041
+ .eo-footer{margin-top:32px;padding-top:16px;border-top:1px solid ${OVERLAY};color:${SUBTEXT};font-size:12px;}
25042
+ .eo-source{background:${SURFACE};border-radius:6px;padding:12px;overflow-x:auto;font-family:'SF Mono','Fira Code','Consolas',monospace;font-size:13px;line-height:1.6;}
25043
+ .eo-line{display:flex;padding:1px 0;}
25044
+ .eo-line-err{background:${ERROR_LINE_BG};}
25045
+ .eo-ln{color:${YELLOW};min-width:3.5em;text-align:right;padding-right:1em;user-select:none;}
25046
+ .eo-marker{color:${RED};width:1.2em;user-select:none;}
25047
+ .eo-code{color:${TEXT};white-space:pre-wrap;tab-size:4;}
25048
+ .eo-frame{margin-bottom:16px;}
25049
+ .eo-frame-head{margin-bottom:4px;}
25050
+ .eo-file{color:${BLUE};}
25051
+ .eo-sep{color:${SUBTEXT};}
25052
+ .eo-lineno{color:${YELLOW};}
25053
+ .eo-fn{color:${GREEN};}
25054
+ .eo-stale{background:${PEACH};color:${BG};padding:1px 8px;border-radius:3px;font-size:11px;font-weight:700;margin-left:6px;}
25055
+ .eo-details{margin-top:16px;}
25056
+ .eo-summary{cursor:pointer;color:${LAVENDER};font-weight:600;font-size:15px;padding:8px 0;user-select:none;}
25057
+ .eo-details-body{padding:8px 0;}
25058
+ .eo-table{border-collapse:collapse;width:100%;}
25059
+ .eo-key{color:${PEACH};padding:4px 16px 4px 0;vertical-align:top;white-space:nowrap;}
25060
+ .eo-val{color:${TEXT};padding:4px 0;word-break:break-all;}
25061
+ .eo-none{color:${SUBTEXT};}
25062
+ .eo-hidden-frames{color:${SUBTEXT};padding:8px 0;font-size:13px;}
25063
+ `;
25064
+ }
24811
25065
  function isDebugMode() {
24812
25066
  return isTruthy(process.env.TINA4_DEBUG);
24813
25067
  }
@@ -24816,6 +25070,7 @@ var init_errorOverlay = __esm({
24816
25070
  "src/errorOverlay.ts"() {
24817
25071
  "use strict";
24818
25072
  init_dotenv();
25073
+ init_csp();
24819
25074
  MAX_FRAMES = 50;
24820
25075
  SENSITIVE_KEY_RE = /password|passwd|secret|token|authorization|cookie|key/i;
24821
25076
  REDACTED2 = "[redacted]";
@@ -26666,7 +26921,11 @@ export default async function (req: Tina4Request, res: Tina4Response) {
26666
26921
  `{% extends "base.twig" %}
26667
26922
  {% block title %}Login{% endblock %}
26668
26923
  {% block content %}
26669
- <div class="container mt-4" style="max-width:400px">
26924
+ {# No inline style attribute: a nonce lets this <style> block run under the
26925
+ strict default Content-Security-Policy. csp_nonce() is a built-in Frond global
26926
+ that returns this response's nonce (ADR-0088). #}
26927
+ <style nonce="{{ csp_nonce() }}">.auth-form{max-width:400px;margin:0 auto}</style>
26928
+ <div class="container mt-4 auth-form">
26670
26929
  <h1>Login</h1>
26671
26930
  <form method="post" action="/api/auth/login">
26672
26931
  {{ form_token() }}
@@ -26690,7 +26949,8 @@ export default async function (req: Tina4Request, res: Tina4Response) {
26690
26949
  `{% extends "base.twig" %}
26691
26950
  {% block title %}Register{% endblock %}
26692
26951
  {% block content %}
26693
- <div class="container mt-4" style="max-width:400px">
26952
+ <style nonce="{{ csp_nonce() }}">.auth-form{max-width:400px;margin:0 auto}</style>
26953
+ <div class="container mt-4 auth-form">
26694
26954
  <h1>Register</h1>
26695
26955
  <form method="post" action="/api/auth/register">
26696
26956
  {{ form_token() }}
@@ -27627,7 +27887,7 @@ import * as path3 from "node:path";
27627
27887
  import * as os from "node:os";
27628
27888
  import { spawnSync as spawnSync2 } from "node:child_process";
27629
27889
  import { createRequire as createRequire8 } from "node:module";
27630
- import { randomBytes as randomBytes6 } from "node:crypto";
27890
+ import { randomBytes as randomBytes7 } from "node:crypto";
27631
27891
  function reqSibling(pkg) {
27632
27892
  return req(`../../${pkg}/src/index.ts`);
27633
27893
  }
@@ -28949,7 +29209,7 @@ var init_mcp = __esm({
28949
29209
  // ── Session lifecycle + protocol negotiation ──────────────────
28950
29210
  /** Mint a new session id and remember it. Called on `initialize`. */
28951
29211
  openSession() {
28952
- const sid = randomBytes6(16).toString("hex");
29212
+ const sid = randomBytes7(16).toString("hex");
28953
29213
  this._sessions.set(sid, Date.now());
28954
29214
  return sid;
28955
29215
  }
@@ -36725,8 +36985,8 @@ var init_devAdmin = __esm({
36725
36985
  * Duplicate errors (same message) increment count and update last_seen.
36726
36986
  */
36727
36987
  static capture(errorType, message, traceback = "", file = "", line = 0) {
36728
- const fingerprint = `${errorType}|${message}|${file}|${line}`;
36729
- const existing = this.errors.find((e) => e.fingerprint === fingerprint);
36988
+ const fingerprint2 = `${errorType}|${message}|${file}|${line}`;
36989
+ const existing = this.errors.find((e) => e.fingerprint === fingerprint2);
36730
36990
  const now = (/* @__PURE__ */ new Date()).toISOString();
36731
36991
  if (existing) {
36732
36992
  existing.count = (existing.count || 1) + 1;
@@ -36739,7 +36999,7 @@ var init_devAdmin = __esm({
36739
36999
  message,
36740
37000
  stack: traceback || void 0,
36741
37001
  resolved: false,
36742
- ...{ fingerprint, error_type: errorType, file, line, count: 1, first_seen: now, last_seen: now }
37002
+ ...{ fingerprint: fingerprint2, error_type: errorType, file, line, count: 1, first_seen: now, last_seen: now }
36743
37003
  });
36744
37004
  if (this.errors.length > this.maxErrors) {
36745
37005
  this.errors = this.errors.slice(-this.maxErrors);
@@ -39418,7 +39678,7 @@ function swaggerEnabled() {
39418
39678
  }
39419
39679
  function createSwaggerRoutes(getSpec) {
39420
39680
  const serveUi = async (_req, res) => {
39421
- res.html(SWAGGER_UI_HTML("/swagger/openapi.json"));
39681
+ res.html(SWAGGER_UI_HTML("/swagger/openapi.json", res.cspNonce));
39422
39682
  };
39423
39683
  return [
39424
39684
  {
@@ -39462,13 +39722,13 @@ var SWAGGER_UI_HTML;
39462
39722
  var init_ui = __esm({
39463
39723
  "../swagger/src/ui.ts"() {
39464
39724
  "use strict";
39465
- SWAGGER_UI_HTML = (specUrl) => `<!DOCTYPE html>
39725
+ SWAGGER_UI_HTML = (specUrl, nonce) => `<!DOCTYPE html>
39466
39726
  <html lang="en">
39467
39727
  <head>
39468
39728
  <meta charset="UTF-8">
39469
39729
  <title>Tina4 API Documentation</title>
39470
39730
  <link rel="stylesheet" href="${swaggerUiCdn()}/swagger-ui.css">
39471
- <style>
39731
+ <style nonce="${nonce}">
39472
39732
  body { margin: 0; background: #fafafa; }
39473
39733
  .topbar { display: none !important; }
39474
39734
  </style>
@@ -39476,7 +39736,7 @@ var init_ui = __esm({
39476
39736
  <body>
39477
39737
  <div id="swagger-ui"></div>
39478
39738
  <script src="${swaggerUiCdn()}/swagger-ui-bundle.js"></script>
39479
- <script>
39739
+ <script nonce="${nonce}">
39480
39740
  SwaggerUIBundle({
39481
39741
  url: "${specUrl}",
39482
39742
  dom_id: '#swagger-ui',
@@ -39516,7 +39776,7 @@ __export(sso_exports, {
39516
39776
  SsoError: () => SsoError,
39517
39777
  liveSessionIdentity: () => liveSessionIdentity
39518
39778
  });
39519
- import { createHash as createHash9, randomBytes as randomBytes7, timingSafeEqual as timingSafeEqual3 } from "node:crypto";
39779
+ import { createHash as createHash9, randomBytes as randomBytes8, timingSafeEqual as timingSafeEqual3 } from "node:crypto";
39520
39780
  function expiresAt(tokens) {
39521
39781
  const lifetime = Number(tokens.expires_in ?? 0);
39522
39782
  return lifetime > 0 ? Math.floor(Date.now() / 1e3) + lifetime : 0;
@@ -39644,9 +39904,9 @@ var init_sso = __esm({
39644
39904
  async login(requestOrSession, returnTo = "/") {
39645
39905
  const session = this.session(requestOrSession);
39646
39906
  if (!session) throw new SsoError("SSO login requires a Tina4 Session");
39647
- const state = randomBytes7(32).toString("base64url");
39648
- const nonce = randomBytes7(32).toString("base64url");
39649
- const verifier = randomBytes7(64).toString("base64url");
39907
+ const state = randomBytes8(32).toString("base64url");
39908
+ const nonce = randomBytes8(32).toString("base64url");
39909
+ const verifier = randomBytes8(64).toString("base64url");
39650
39910
  const challenge = createHash9("sha256").update(verifier).digest("base64url");
39651
39911
  session.set(_Sso.PENDING_KEY, { state, nonce, verifier, return_to: _Sso.safeReturn(returnTo), created_at: Math.floor(Date.now() / 1e3) });
39652
39912
  const metadata = await this.discover();
@@ -39874,7 +40134,7 @@ var init_docsAutoDiscovery = __esm({
39874
40134
  });
39875
40135
 
39876
40136
  // src/server.ts
39877
- import { randomBytes as randomBytes8 } from "node:crypto";
40137
+ import { randomBytes as randomBytes9 } from "node:crypto";
39878
40138
  import { resolve as resolve19, dirname as dirname13, join as join30, relative as relative10 } from "node:path";
39879
40139
  import { existsSync as existsSync25, readdirSync as readdirSync17, readFileSync as readFileSync23, statSync as statSync18 } from "node:fs";
39880
40140
  import { isatty } from "node:tty";
@@ -40054,7 +40314,8 @@ function isDevMode() {
40054
40314
  }
40055
40315
  async function renderErrorPage(code, data, templatesDir) {
40056
40316
  try {
40057
- const { Frond: Frond2 } = await Promise.resolve().then(() => (init_engine(), engine_exports));
40317
+ const { Frond: Frond2, setCspNonceProvider: setCspNonceProvider2 } = await Promise.resolve().then(() => (init_engine(), engine_exports));
40318
+ setCspNonceProvider2(currentCspNonce);
40058
40319
  const templateFile = `errors/${code}.twig`;
40059
40320
  const getCachedFrond = (dir) => {
40060
40321
  let instance = frondCache.get(dir);
@@ -40170,6 +40431,7 @@ function resolveTemplate(pathname, templatesDir) {
40170
40431
  }
40171
40432
  function renderLandingPage(port = 7148) {
40172
40433
  const version = TINA4_VERSION;
40434
+ const nonce = currentCspNonce();
40173
40435
  const galleryItems = [
40174
40436
  { 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" },
40175
40437
  { id: "orm", icon: "&#128451;", name: "ORM", desc: "Product model with CRUD endpoints", accent: "accent-green", tryUrl: "/api/gallery/products" },
@@ -40184,7 +40446,7 @@ function renderLandingPage(port = 7148) {
40184
40446
  const isDeployed = deployed[item.id] === true;
40185
40447
  const tryBtn = isDeployed ? `<a href="${item.tryUrl}" class="gbtn gbtn-try" target="_blank">Try It</a>` : "";
40186
40448
  const viewBtn = isDeployed ? `<a href="${item.tryUrl}" class="gbtn gbtn-view" target="_blank">View</a>` : "";
40187
- const deployBtn = isDeployed ? `<span class="gbtn gbtn-deployed">Deployed</span>` : `<button class="gbtn gbtn-deploy" onclick="deployGallery('${item.id}')">Deploy</button>`;
40449
+ const deployBtn = isDeployed ? `<span class="gbtn gbtn-deployed">Deployed</span>` : `<button class="gbtn gbtn-deploy" data-deploy="${item.id}">Deploy</button>`;
40188
40450
  return `<div class="gallery-card">
40189
40451
  <div class="accent ${item.accent}"></div>
40190
40452
  <div class="icon">${item.icon}</div>
@@ -40199,7 +40461,7 @@ function renderLandingPage(port = 7148) {
40199
40461
  <meta charset="utf-8">
40200
40462
  <meta name="viewport" content="width=device-width, initial-scale=1">
40201
40463
  <title>Tina4NodeJs</title>
40202
- <style>
40464
+ <style nonce="${nonce}">
40203
40465
  *{margin:0;padding:0;box-sizing:border-box}
40204
40466
  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}
40205
40467
  .bg-watermark{position:fixed;bottom:-5%;right:-5%;width:45%;opacity:0.04;pointer-events:none;z-index:0}
@@ -40238,6 +40500,11 @@ h1{font-size:3rem;font-weight:700;margin-bottom:0.25rem;letter-spacing:-1px}
40238
40500
  .gbtn-deployed{background:transparent;border:1px solid #22c55e;color:#22c55e;cursor:default;font-size:0.7rem}
40239
40501
  @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)}}
40240
40502
  .star-wiggle{display:inline-block;transform-origin:center}
40503
+ .gallery-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:1rem}
40504
+ .tok-comment{color:#64748b}
40505
+ .tok-kw{color:#c084fc}
40506
+ .tok-str{color:#4ade80}
40507
+ .tok-fn{color:#38bdf8}
40241
40508
  </style>
40242
40509
  </head>
40243
40510
  <body>
@@ -40263,23 +40530,23 @@ h1{font-size:3rem;font-weight:700;margin-bottom:0.25rem;letter-spacing:-1px}
40263
40530
  <div class="section">
40264
40531
  <div class="card">
40265
40532
  <h2>Getting Started</h2>
40266
- <pre class="code-block"><code><span style="color:#64748b">// app.ts</span>
40267
- <span style="color:#c084fc">import</span> { startServer, Router } <span style="color:#c084fc">from</span> <span style="color:#4ade80">"tina4-nodejs"</span>;
40533
+ <pre class="code-block"><code><span class="tok-comment">// app.ts</span>
40534
+ <span class="tok-kw">import</span> { startServer, Router } <span class="tok-kw">from</span> <span class="tok-str">"tina4-nodejs"</span>;
40268
40535
 
40269
- 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; {
40270
- <span style="color:#c084fc">return</span> res.json({ message: <span style="color:#4ade80">"Hello World!"</span> });
40536
+ 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; {
40537
+ <span class="tok-kw">return</span> res.json({ message: <span class="tok-str">"Hello World!"</span> });
40271
40538
  });
40272
40539
 
40273
- startServer({ port: 7148 }); <span style="color:#64748b">// starts on port 7148</span></code></pre>
40540
+ startServer({ port: 7148 }); <span class="tok-comment">// starts on port 7148</span></code></pre>
40274
40541
  </div>
40275
40542
  </div>
40276
40543
  <div class="gallery">
40277
40544
  <h2 id="gallery">What You Can Build</h2>
40278
- <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:1rem;">
40545
+ <div class="gallery-cards">
40279
40546
  ${galleryCards}
40280
40547
  </div>
40281
40548
  </div>
40282
- <script>
40549
+ <script nonce="${nonce}">
40283
40550
  function deployGallery(name) {
40284
40551
  if (!confirm('Deploy the "' + name + '" gallery example into your project? This will copy files into src/.')) return;
40285
40552
  fetch('/__dev/api/gallery/deploy', {
@@ -40314,6 +40581,12 @@ function deployGallery(name) {
40314
40581
  }
40315
40582
  setTimeout(doWiggle,3000);
40316
40583
  })();
40584
+ // CSP-clean wiring: no inline onclick handlers \u2014 bind every Deploy button from
40585
+ // its data-deploy attribute (a nonce covers script elements, not event-handler
40586
+ // attributes).
40587
+ document.querySelectorAll('.gbtn-deploy[data-deploy]').forEach(function(btn){
40588
+ btn.addEventListener('click', function(){ deployGallery(btn.dataset.deploy); });
40589
+ });
40317
40590
  </script>
40318
40591
  </body>
40319
40592
  </html>`;
@@ -40530,7 +40803,7 @@ async function renderDispatchError(err, req2, res, templatesDir) {
40530
40803
  }
40531
40804
  }
40532
40805
  }
40533
- const requestId = Log.getRequestId() ?? randomBytes8(4).toString("hex");
40806
+ const requestId = Log.getRequestId() ?? randomBytes9(4).toString("hex");
40534
40807
  if (wantsJson(req2)) {
40535
40808
  const body = negotiatedErrorBody(500, "Internal Server Error", requestId);
40536
40809
  res.raw.writeHead(500, { "Content-Type": "application/json" });
@@ -40601,7 +40874,7 @@ function serveStaticAsset(ctx) {
40601
40874
  return false;
40602
40875
  }
40603
40876
  async function serveNotFound(ctx) {
40604
- const requestId = Log.getRequestId() ?? randomBytes8(4).toString("hex");
40877
+ const requestId = Log.getRequestId() ?? randomBytes9(4).toString("hex");
40605
40878
  if (wantsJson(ctx.req)) {
40606
40879
  const body = negotiatedErrorBody(404, "Not Found", requestId);
40607
40880
  ctx.res.raw.writeHead(404, httpReason(404), { "Content-Type": "application/json" });
@@ -40624,7 +40897,8 @@ async function buildDispatchContext(router, base) {
40624
40897
  const templatesDir = resolve19(root, "src/templates");
40625
40898
  let frondEngine = null;
40626
40899
  try {
40627
- const { Frond: Frond2 } = await Promise.resolve().then(() => (init_engine(), engine_exports));
40900
+ const { Frond: Frond2, setCspNonceProvider: setCspNonceProvider2 } = await Promise.resolve().then(() => (init_engine(), engine_exports));
40901
+ setCspNonceProvider2(currentCspNonce);
40628
40902
  frondEngine = new Frond2(templatesDir);
40629
40903
  } catch {
40630
40904
  }
@@ -40734,9 +41008,13 @@ async function dispatchInner(ctx, rawReq, rawRes, requestId) {
40734
41008
  }
40735
41009
  }
40736
41010
  async function runDispatch(ctx, rawReq, rawRes) {
40737
- const requestId = Log.sanitizeRequestId(rawReq.headers["x-request-id"]) ?? randomBytes8(4).toString("hex");
41011
+ const requestId = Log.sanitizeRequestId(rawReq.headers["x-request-id"]) ?? randomBytes9(4).toString("hex");
40738
41012
  if (!rawRes.headersSent) rawRes.setHeader("x-request-id", requestId);
40739
- return Log.runWithRequestId(requestId, () => dispatchInner(ctx, rawReq, rawRes, requestId));
41013
+ const requestNonce = generateNonce();
41014
+ return Log.runWithRequestId(
41015
+ requestId,
41016
+ () => runWithCspNonce(requestNonce, () => dispatchInner(ctx, rawReq, rawRes, requestId))
41017
+ );
40740
41018
  }
40741
41019
  function loopbackBindHosts(host) {
40742
41020
  const normalized = host.trim().replace(/^\[|\]$/g, "").toLowerCase();
@@ -40863,7 +41141,8 @@ async function startServer(config) {
40863
41141
  let frondEngine = null;
40864
41142
  setDefaultTemplatesDir(templatesDir);
40865
41143
  try {
40866
- const { Frond: Frond2 } = await Promise.resolve().then(() => (init_engine(), engine_exports));
41144
+ const { Frond: Frond2, setCspNonceProvider: setCspNonceProvider2 } = await Promise.resolve().then(() => (init_engine(), engine_exports));
41145
+ setCspNonceProvider2(currentCspNonce);
40867
41146
  frondEngine = new Frond2(templatesDir);
40868
41147
  router.addRoute({
40869
41148
  method: "GET",
@@ -41004,10 +41283,12 @@ async function startServer(config) {
41004
41283
  }
41005
41284
  });
41006
41285
  aiServer.on("error", (err) => {
41007
- if (err.code === "EADDRINUSE") {
41286
+ if (err?.code === "EADDRINUSE") {
41008
41287
  Log.warning(`Test port ${testPort} in use \u2014 skipping`);
41009
- aiServer = null;
41288
+ } else {
41289
+ Log.error(`Test port ${testPort} bind failed (${err?.code ?? err}) \u2014 skipping`);
41010
41290
  }
41291
+ aiServer = null;
41011
41292
  });
41012
41293
  aiServer.listen(testPort, host);
41013
41294
  }
@@ -41222,6 +41503,7 @@ var init_server = __esm({
41222
41503
  init_static();
41223
41504
  init_dotenv();
41224
41505
  init_errorOverlay();
41506
+ init_csp();
41225
41507
  init_health();
41226
41508
  init_rateLimiter();
41227
41509
  init_logger();
@@ -41471,7 +41753,7 @@ var init_mqttMessage = __esm({
41471
41753
  // src/mqtt.ts
41472
41754
  import net2 from "node:net";
41473
41755
  import tls from "node:tls";
41474
- import { randomBytes as randomBytes9 } from "node:crypto";
41756
+ import { randomBytes as randomBytes10 } from "node:crypto";
41475
41757
  import { existsSync as existsSync26, readFileSync as readFileSync24 } from "node:fs";
41476
41758
  var MqttError, MqttTimeoutError, CONNECT, CONNACK, PUBLISH, PUBACK, SUBSCRIBE, SUBACK, PINGREQ, PINGRESP, DISCONNECT, PROTOCOL_LEVEL, DEFAULT_PORT, DEFAULT_TLS_PORT, DEFAULT_URL, DEFAULT_KEEPALIVE, SUBSCRIPTION_REFUSED, MAX_REMAINING_LENGTH, QOS2_REFUSED_MESSAGE, CONNACK_RETURN_CODES, Mqtt;
41477
41759
  var init_mqtt = __esm({
@@ -41559,7 +41841,7 @@ var init_mqtt = __esm({
41559
41841
  this.caFile = options.caFile ?? (Env.str("TINA4_MQTT_CA_FILE") || null);
41560
41842
  this.tlsVerify = options.tlsVerify ?? Env.bool("TINA4_MQTT_TLS_VERIFY", true);
41561
41843
  let cid = options.clientId ?? (Env.str("TINA4_MQTT_CLIENT_ID") || null);
41562
- if (cid === null || cid === "") cid = "tina4-" + randomBytes9(8).toString("hex");
41844
+ if (cid === null || cid === "") cid = "tina4-" + randomBytes10(8).toString("hex");
41563
41845
  this.clientId = cid;
41564
41846
  this.keepalive = options.keepalive ?? Env.int("TINA4_MQTT_KEEPALIVE", DEFAULT_KEEPALIVE);
41565
41847
  this.cleanSession = options.cleanSession ?? true;
@@ -42673,7 +42955,7 @@ var init_ssrf = __esm({
42673
42955
  import http from "node:http";
42674
42956
  import https from "node:https";
42675
42957
  import { URL as URL2 } from "node:url";
42676
- import { randomBytes as randomBytes10 } from "node:crypto";
42958
+ import { randomBytes as randomBytes11 } from "node:crypto";
42677
42959
  import { promises as fsp, createWriteStream } from "node:fs";
42678
42960
  import { basename as basename6 } from "node:path";
42679
42961
  import { pipeline } from "node:stream/promises";
@@ -43065,7 +43347,7 @@ var init_api = __esm({
43065
43347
  return { http_code: null, body: null, headers: {}, error: "upload requires filePath or fileBytes" };
43066
43348
  }
43067
43349
  const partContentType = guessContentType(uploadName);
43068
- const boundary = "----Tina4Boundary" + randomBytes10(16).toString("hex");
43350
+ const boundary = "----Tina4Boundary" + randomBytes11(16).toString("hex");
43069
43351
  const bodyBuffer = buildMultipartBody(boundary, fieldName, uploadName, content, partContentType, extraFields);
43070
43352
  const contentType = `multipart/form-data; boundary=${boundary}`;
43071
43353
  return this.execute("POST", this.buildUrl(path10), bodyBuffer, contentType, headers);
@@ -46029,7 +46311,7 @@ import {
46029
46311
  createHmac as createHmac4,
46030
46312
  createPrivateKey,
46031
46313
  createSign as createSign2,
46032
- randomBytes as randomBytes11
46314
+ randomBytes as randomBytes12
46033
46315
  } from "node:crypto";
46034
46316
  function encodeBase64Url(value) {
46035
46317
  return Buffer.from(value).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
@@ -46109,7 +46391,7 @@ function encryptPayload(payload, subscription) {
46109
46391
  ]);
46110
46392
  const prkKey = hmac(authSecret, sharedSecret);
46111
46393
  const ikm = hkdfExpand(prkKey, keyInfo, 32);
46112
- const salt = randomBytes11(16);
46394
+ const salt = randomBytes12(16);
46113
46395
  const contentPrk = hmac(salt, ikm);
46114
46396
  const cek = hkdfExpand(contentPrk, Buffer.from("Content-Encoding: aes128gcm\0", "ascii"), 16);
46115
46397
  const nonce = hkdfExpand(contentPrk, Buffer.from("Content-Encoding: nonce\0", "ascii"), 12);
@@ -48866,6 +49148,7 @@ __export(index_exports, {
48866
49148
  cacheStats: () => cacheStats,
48867
49149
  checkPassword: () => checkPassword,
48868
49150
  clearCache: () => clearCache,
49151
+ clearCurrentNonce: () => clearCurrentNonce,
48869
49152
  computeAcceptKey: () => computeAcceptKey,
48870
49153
  container: () => container,
48871
49154
  cors: () => cors,
@@ -48877,6 +49160,8 @@ __export(index_exports, {
48877
49160
  createMessenger: () => createMessenger,
48878
49161
  createRequest: () => createRequest,
48879
49162
  createResponse: () => createResponse,
49163
+ cspNonce: () => cspNonce,
49164
+ currentCspNonce: () => currentCspNonce,
48880
49165
  decodeRequest: () => decodeRequest,
48881
49166
  defaultContext: () => defaultContext,
48882
49167
  defaultRouter: () => defaultRouter,
@@ -48902,6 +49187,7 @@ __export(index_exports, {
48902
49187
  feedbackWhitelist: () => feedbackWhitelist,
48903
49188
  fts5Supported: () => fts5Supported,
48904
49189
  generateContext: () => generateContext,
49190
+ generateNonce: () => generateNonce,
48905
49191
  generateVapidKeys: () => generateVapidKeys,
48906
49192
  get: () => get,
48907
49193
  getDefaultDevServer: () => getDefaultDevServer,
@@ -48924,6 +49210,7 @@ __export(index_exports, {
48924
49210
  httpReason: () => httpReason,
48925
49211
  inContainer: () => inContainer,
48926
49212
  injectFeedbackWidget: () => injectFeedbackWidget,
49213
+ injectNonceIntoCsp: () => injectNonceIntoCsp,
48927
49214
  installAll: () => installAll,
48928
49215
  installSelected: () => installSelected,
48929
49216
  isBannerSuppressed: () => isBannerSuppressed,
@@ -48978,6 +49265,7 @@ __export(index_exports, {
48978
49265
  resetTrustedProxyCache: () => resetTrustedProxyCache,
48979
49266
  resolveAlgorithm: () => resolveAlgorithm,
48980
49267
  resolveClientIp: () => resolveClientIp,
49268
+ resolveCspHeader: () => resolveCspHeader,
48981
49269
  resolveFrameworkVersion: () => resolveFrameworkVersion,
48982
49270
  resolvePortAndHost: () => resolvePortAndHost,
48983
49271
  resolveStringMiddleware: () => resolveStringMiddleware,
@@ -48985,12 +49273,14 @@ __export(index_exports, {
48985
49273
  responseCache: () => responseCache,
48986
49274
  runAll: () => runAll,
48987
49275
  runRouteMiddlewares: () => runRouteMiddlewares,
49276
+ runWithCspNonce: () => runWithCspNonce,
48988
49277
  runtimeDir: () => runtimeDir,
48989
49278
  saveUpload: () => saveUpload,
48990
49279
  schemaFromParams: () => schemaFromParams,
48991
49280
  selectablePids: () => selectablePids,
48992
49281
  serveWebSocketRoute: () => serveWebSocketRoute,
48993
49282
  sessionCookieName: () => sessionCookieName,
49283
+ setCurrentNonce: () => setCurrentNonce,
48994
49284
  setDefaultTemplatesDir: () => setDefaultTemplatesDir,
48995
49285
  setFrond: () => setFrond,
48996
49286
  showMenu: () => showMenu,
@@ -49056,6 +49346,7 @@ var init_index = __esm({
49056
49346
  init_wsdl();
49057
49347
  init_htmlElement();
49058
49348
  init_errorOverlay();
49349
+ init_csp();
49059
49350
  init_ai();
49060
49351
  init_sso();
49061
49352
  init_push();
@@ -49246,6 +49537,7 @@ export {
49246
49537
  cacheStats,
49247
49538
  checkPassword,
49248
49539
  clearCache,
49540
+ clearCurrentNonce,
49249
49541
  computeAcceptKey,
49250
49542
  container,
49251
49543
  cors,
@@ -49257,6 +49549,8 @@ export {
49257
49549
  createMessenger,
49258
49550
  createRequest,
49259
49551
  createResponse,
49552
+ cspNonce,
49553
+ currentCspNonce,
49260
49554
  decodeRequest,
49261
49555
  defaultContext,
49262
49556
  defaultRouter,
@@ -49282,6 +49576,7 @@ export {
49282
49576
  feedbackWhitelist,
49283
49577
  fts5Supported,
49284
49578
  generateContext,
49579
+ generateNonce,
49285
49580
  generateVapidKeys,
49286
49581
  get,
49287
49582
  getDefaultDevServer,
@@ -49304,6 +49599,7 @@ export {
49304
49599
  httpReason,
49305
49600
  inContainer,
49306
49601
  injectFeedbackWidget,
49602
+ injectNonceIntoCsp,
49307
49603
  installAll,
49308
49604
  installSelected,
49309
49605
  isBannerSuppressed,
@@ -49358,6 +49654,7 @@ export {
49358
49654
  resetTrustedProxyCache,
49359
49655
  resolveAlgorithm,
49360
49656
  resolveClientIp,
49657
+ resolveCspHeader,
49361
49658
  resolveFrameworkVersion,
49362
49659
  resolvePortAndHost,
49363
49660
  resolveStringMiddleware,
@@ -49365,12 +49662,14 @@ export {
49365
49662
  responseCache,
49366
49663
  runAll,
49367
49664
  runRouteMiddlewares,
49665
+ runWithCspNonce,
49368
49666
  runtimeDir,
49369
49667
  saveUpload,
49370
49668
  schemaFromParams,
49371
49669
  selectablePids,
49372
49670
  serveWebSocketRoute,
49373
49671
  sessionCookieName,
49672
+ setCurrentNonce,
49374
49673
  setDefaultTemplatesDir,
49375
49674
  setFrond,
49376
49675
  showMenu,