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
@@ -17431,6 +17431,73 @@ var init_trustedProxy = __esm({
17431
17431
  }
17432
17432
  });
17433
17433
 
17434
+ // ../core/src/csp.ts
17435
+ import { AsyncLocalStorage as AsyncLocalStorage3 } from "node:async_hooks";
17436
+ import { randomBytes as randomBytes4 } from "node:crypto";
17437
+ function generateNonce() {
17438
+ return randomBytes4(16).toString("base64");
17439
+ }
17440
+ function runWithCspNonce(nonce, fn) {
17441
+ return nonceStore.run({ nonce }, fn);
17442
+ }
17443
+ function setCurrentNonce(nonce) {
17444
+ const store = nonceStore.getStore();
17445
+ if (store) store.nonce = nonce;
17446
+ else nonceFallback = nonce;
17447
+ }
17448
+ function clearCurrentNonce() {
17449
+ setCurrentNonce(void 0);
17450
+ }
17451
+ function currentCspNonce() {
17452
+ const store = nonceStore.getStore();
17453
+ let value = store ? store.nonce : nonceFallback;
17454
+ if (!value) {
17455
+ value = generateNonce();
17456
+ if (store) store.nonce = value;
17457
+ else nonceFallback = value;
17458
+ }
17459
+ return value;
17460
+ }
17461
+ function cspNonce() {
17462
+ return currentCspNonce();
17463
+ }
17464
+ function injectNonceIntoCsp(csp, nonce) {
17465
+ const token = `'nonce-${nonce}'`;
17466
+ const directives = [];
17467
+ for (const rawPart of csp.split(";")) {
17468
+ const part = rawPart.trim();
17469
+ if (!part) continue;
17470
+ const spaceAt = part.search(/\s/);
17471
+ const name = (spaceAt === -1 ? part : part.slice(0, spaceAt)).toLowerCase();
17472
+ const value = spaceAt === -1 ? "" : part.slice(spaceAt + 1).trim();
17473
+ directives.push([name, value]);
17474
+ }
17475
+ const defaultEntry = directives.find(([name]) => name === "default-src");
17476
+ const defaultValue = defaultEntry && defaultEntry[1] ? defaultEntry[1] : "'self'";
17477
+ for (const directive of ["style-src", "script-src"]) {
17478
+ const existing = directives.find(([name]) => name === directive);
17479
+ if (existing) {
17480
+ if (!existing[1].split(/\s+/).includes(token)) {
17481
+ existing[1] = `${existing[1]} ${token}`.trim();
17482
+ }
17483
+ } else {
17484
+ directives.push([directive, `${defaultValue} ${token}`.trim()]);
17485
+ }
17486
+ }
17487
+ return directives.map(([name, value]) => value ? `${name} ${value}` : name).join("; ");
17488
+ }
17489
+ function resolveCspHeader(nonce) {
17490
+ const csp = process.env.TINA4_CSP || "default-src 'self'";
17491
+ return injectNonceIntoCsp(csp, nonce);
17492
+ }
17493
+ var nonceStore, nonceFallback;
17494
+ var init_csp = __esm({
17495
+ "../core/src/csp.ts"() {
17496
+ "use strict";
17497
+ nonceStore = new AsyncLocalStorage3();
17498
+ }
17499
+ });
17500
+
17434
17501
  // ../frond/src/engine.ts
17435
17502
  var engine_exports = {};
17436
17503
  __export(engine_exports, {
@@ -17440,9 +17507,10 @@ __export(engine_exports, {
17440
17507
  expressionFormCache: () => expressionFormCache,
17441
17508
  filterChainCache: () => filterChainCache,
17442
17509
  pathParseCache: () => pathParseCache,
17510
+ setCspNonceProvider: () => setCspNonceProvider,
17443
17511
  setFormTokenSessionId: () => setFormTokenSessionId
17444
17512
  });
17445
- import { createHash as createHash3, createHmac as createHmac3, randomBytes as randomBytes4 } from "node:crypto";
17513
+ import { createHash as createHash3, createHmac as createHmac3, randomBytes as randomBytes5 } from "node:crypto";
17446
17514
  import { readFileSync as readFileSync6, existsSync as existsSync8, statSync as statSync6, realpathSync } from "node:fs";
17447
17515
  import { join as join11, resolve as resolve7, isAbsolute as isAbsolute4, sep as sep3 } from "node:path";
17448
17516
  function jsonText(value) {
@@ -18509,6 +18577,9 @@ function _b64url(data) {
18509
18577
  function setFormTokenSessionId(sessionId) {
18510
18578
  _formTokenSessionId = sessionId || "";
18511
18579
  }
18580
+ function setCspNonceProvider(fn) {
18581
+ _cspNonceProvider = fn;
18582
+ }
18512
18583
  function _buildFormTokenJwt(descriptor = "") {
18513
18584
  const secret = process.env.TINA4_SECRET ?? "";
18514
18585
  const secretBytes = Buffer.byteLength(secret, "utf8");
@@ -18520,7 +18591,7 @@ function _buildFormTokenJwt(descriptor = "") {
18520
18591
  const ttlMinutes = parseInt(process.env.TINA4_TOKEN_LIMIT || "60", 10);
18521
18592
  const header = { alg: "HS256", typ: "JWT" };
18522
18593
  const now = Math.floor(Date.now() / 1e3);
18523
- const payload = { type: "form", nonce: randomBytes4(8).toString("hex"), iat: now, exp: now + ttlMinutes * 60 };
18594
+ const payload = { type: "form", nonce: randomBytes5(8).toString("hex"), iat: now, exp: now + ttlMinutes * 60 };
18524
18595
  if (descriptor) {
18525
18596
  if (descriptor.includes("|")) {
18526
18597
  const [ctx, ref] = descriptor.split("|", 2);
@@ -18547,7 +18618,7 @@ function _generateFormToken(descriptor = "") {
18547
18618
  function _generateFormTokenValue(descriptor = "") {
18548
18619
  return new SafeString(_buildFormTokenJwt(descriptor));
18549
18620
  }
18550
- 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;
18621
+ 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;
18551
18622
  var init_engine = __esm({
18552
18623
  "../frond/src/engine.ts"() {
18553
18624
  "use strict";
@@ -18901,6 +18972,7 @@ var init_engine = __esm({
18901
18972
  js_escape: (v) => new SafeString(jsEscape(v))
18902
18973
  };
18903
18974
  _formTokenSessionId = "";
18975
+ _cspNonceProvider = () => "";
18904
18976
  Frond = class _Frond {
18905
18977
  // ── Class-level registries ──────────────────────────────────
18906
18978
  // Persist globals, filters, and tests across hot-reloads and at
@@ -19054,6 +19126,7 @@ var init_engine = __esm({
19054
19126
  this.globals.formTokenValue = (descriptor) => _generateFormTokenValue(descriptor || "");
19055
19127
  this.globals.form_token_value = (descriptor) => _generateFormTokenValue(descriptor || "");
19056
19128
  this.globals.dump = (value) => renderDump(value);
19129
+ this.globals.csp_nonce = () => _cspNonceProvider();
19057
19130
  for (const [k, v] of _Frond.classFilters) this.filters[k] = v;
19058
19131
  for (const [k, v] of _Frond.classGlobals) this.globals[k] = v;
19059
19132
  for (const [k, v] of _Frond.classTests) this.tests[k] = v;
@@ -20317,7 +20390,8 @@ async function getFrond() {
20317
20390
  const dir = _defaultTemplatesDir ?? nodePath.resolve(process.cwd(), "src/templates");
20318
20391
  let engine = _frondCache.get(dir);
20319
20392
  if (!engine) {
20320
- const { Frond: Frond2 } = await Promise.resolve().then(() => (init_engine(), engine_exports));
20393
+ const { Frond: Frond2, setCspNonceProvider: setCspNonceProvider2 } = await Promise.resolve().then(() => (init_engine(), engine_exports));
20394
+ setCspNonceProvider2(currentCspNonce);
20321
20395
  engine = new Frond2(dir);
20322
20396
  _frondCache.set(dir, engine);
20323
20397
  }
@@ -20328,7 +20402,8 @@ async function getFrameworkFrond() {
20328
20402
  const frameworkDir = nodePath.resolve(nodePath.dirname(import.meta.url.replace("file://", "")), "..", "templates");
20329
20403
  if (!_frameworkFrond && fs2.existsSync(frameworkDir)) {
20330
20404
  try {
20331
- const { Frond: Frond2 } = await Promise.resolve().then(() => (init_engine(), engine_exports));
20405
+ const { Frond: Frond2, setCspNonceProvider: setCspNonceProvider2 } = await Promise.resolve().then(() => (init_engine(), engine_exports));
20406
+ setCspNonceProvider2(currentCspNonce);
20332
20407
  _frameworkFrond = new Frond2(frameworkDir);
20333
20408
  } catch {
20334
20409
  return null;
@@ -20428,6 +20503,7 @@ function createResponse(res) {
20428
20503
  return response;
20429
20504
  };
20430
20505
  response.raw = res;
20506
+ response.cspNonce = currentCspNonce();
20431
20507
  response.json = function(data, status2) {
20432
20508
  if (res.headersSent) return response;
20433
20509
  if (status2 !== void 0) res.statusCode = status2;
@@ -20573,7 +20649,8 @@ function createResponse(res) {
20573
20649
  };
20574
20650
  response.render = async function(templateName, data, status2, templateDir) {
20575
20651
  try {
20576
- const { Frond: Frond2 } = await Promise.resolve().then(() => (init_engine(), engine_exports));
20652
+ const { Frond: Frond2, setCspNonceProvider: setCspNonceProvider2 } = await Promise.resolve().then(() => (init_engine(), engine_exports));
20653
+ setCspNonceProvider2(currentCspNonce);
20577
20654
  const dir = templateDir ?? _defaultTemplatesDir ?? nodePath.resolve(process.cwd(), "src/templates");
20578
20655
  let engine = _frondCache.get(dir);
20579
20656
  if (!engine) {
@@ -20691,6 +20768,7 @@ var HEADER_TOKEN, _frondCache, _defaultTemplatesDir, _globalFrond, _frameworkFro
20691
20768
  var init_response = __esm({
20692
20769
  "../core/src/response.ts"() {
20693
20770
  "use strict";
20771
+ init_csp();
20694
20772
  HEADER_TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
20695
20773
  _frondCache = /* @__PURE__ */ new Map();
20696
20774
  _defaultTemplatesDir = null;
@@ -20902,6 +20980,7 @@ var init_middleware = __esm({
20902
20980
  init_dotenv();
20903
20981
  init_router();
20904
20982
  init_trustedProxy();
20983
+ init_csp();
20905
20984
  init_response();
20906
20985
  MiddlewareChain = class {
20907
20986
  middlewares = [];
@@ -21334,12 +21413,18 @@ var init_middleware = __esm({
21334
21413
  * overrides, WITHOUT Strict-Transport-Security, which depends on the
21335
21414
  * request's scheme. Also what a transport rejection carries (ADR-0068
21336
21415
  * section 4), written before the scheme is known.
21416
+ *
21417
+ * Content-Security-Policy always carries a per-response nonce in style-src
21418
+ * AND script-src (ADR-0088), read from the request async-context (minted on
21419
+ * first access), so the framework's own inline content runs under the strict
21420
+ * default without 'unsafe-inline'. The nonce is random per response, so a
21421
+ * caller comparing this header must assert its STRUCTURE, never a byte string.
21337
21422
  */
21338
21423
  static canonicalHeaders() {
21339
21424
  return {
21340
21425
  "X-Frame-Options": process.env.TINA4_FRAME_OPTIONS ?? "SAMEORIGIN",
21341
21426
  "X-Content-Type-Options": "nosniff",
21342
- "Content-Security-Policy": process.env.TINA4_CSP ?? "default-src 'self'",
21427
+ "Content-Security-Policy": resolveCspHeader(currentCspNonce()),
21343
21428
  "Referrer-Policy": process.env.TINA4_REFERRER_POLICY ?? "strict-origin-when-cross-origin",
21344
21429
  "X-XSS-Protection": "0",
21345
21430
  "Permissions-Policy": process.env.TINA4_PERMISSIONS_POLICY ?? "camera=(), microphone=(), geolocation=()"
@@ -21381,7 +21466,7 @@ var init_middleware = __esm({
21381
21466
  static warnCspDefaultOnce() {
21382
21467
  if (_SecurityHeadersMiddleware.cspDefaultWarned) return;
21383
21468
  _SecurityHeadersMiddleware.cspDefaultWarned = true;
21384
- 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'".`;
21469
+ 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'".`;
21385
21470
  try {
21386
21471
  Log.warning(message);
21387
21472
  } catch {
@@ -23794,7 +23879,7 @@ __export(session_exports, {
23794
23879
  sessionCookieName: () => sessionCookieName,
23795
23880
  sessionStrictMode: () => sessionStrictMode
23796
23881
  });
23797
- import { createHash as createHash5, randomBytes as randomBytes5 } from "node:crypto";
23882
+ import { createHash as createHash5, randomBytes as randomBytes6 } from "node:crypto";
23798
23883
  import { existsSync as existsSync11, mkdirSync as mkdirSync11, readFileSync as readFileSync8, writeFileSync as writeFileSync8, unlinkSync as unlinkSync5, readdirSync as readdirSync6 } from "node:fs";
23799
23884
  import { join as join15 } from "node:path";
23800
23885
  function isValidSessionId(sessionId) {
@@ -23812,6 +23897,20 @@ function resolveBackend(name) {
23812
23897
  }
23813
23898
  return normalised;
23814
23899
  }
23900
+ function fingerprint(value) {
23901
+ try {
23902
+ return JSON.stringify(value);
23903
+ } catch {
23904
+ return void 0;
23905
+ }
23906
+ }
23907
+ function fingerprints(data) {
23908
+ const prints = /* @__PURE__ */ new Map();
23909
+ for (const [key, value] of Object.entries(data)) {
23910
+ prints.set(key, fingerprint(value));
23911
+ }
23912
+ return prints;
23913
+ }
23815
23914
  function isSecureScheme(forwardedProto, socketEncrypted) {
23816
23915
  const forwarded = (forwardedProto ?? "").trim();
23817
23916
  if (forwarded !== "") {
@@ -24009,6 +24108,29 @@ var init_session = __esm({
24009
24108
  * Lets `start()` tell "no such session" from "the store is unreachable".
24010
24109
  */
24011
24110
  lastReadFailed = false;
24111
+ /**
24112
+ * What this request last saw of the stored record, one fingerprint per key:
24113
+ * taken by start() and again by every save. save() compares the live data
24114
+ * against it to find the keys THIS request changed.
24115
+ */
24116
+ loaded = /* @__PURE__ */ new Map();
24117
+ /**
24118
+ * True when this request is working on a record the store holds: start()
24119
+ * adopted one, or a save has written one since. A save never re-creates such
24120
+ * a record once another request has removed it.
24121
+ */
24122
+ stored = false;
24123
+ /**
24124
+ * True when clear() ran since the last save: the save then replaces the
24125
+ * stored record instead of merging into it.
24126
+ */
24127
+ cleared = false;
24128
+ /**
24129
+ * True once this request found its session ended by another request (the
24130
+ * record it loaded is gone). It stays ended for the rest of the request:
24131
+ * regenerate() mints nothing either.
24132
+ */
24133
+ ended = false;
24012
24134
  constructor(backend, config) {
24013
24135
  const backendType = resolveBackend(
24014
24136
  backend ?? config?.backend ?? process.env.TINA4_SESSION_BACKEND ?? "file"
@@ -24153,6 +24275,10 @@ var init_session = __esm({
24153
24275
  const now2 = Math.floor(Date.now() / 1e3);
24154
24276
  this.data = { _created: now2, _accessed: now2 };
24155
24277
  this.dirty = false;
24278
+ this.stored = false;
24279
+ this.loaded = /* @__PURE__ */ new Map();
24280
+ this.cleared = false;
24281
+ this.ended = false;
24156
24282
  return sessionId;
24157
24283
  }
24158
24284
  if (loaded) {
@@ -24162,14 +24288,22 @@ var init_session = __esm({
24162
24288
  this.data._accessed = now2;
24163
24289
  this.dirty = false;
24164
24290
  this.safeWrite(this.sessionId, this.data, this.ttl);
24291
+ this.stored = true;
24292
+ this.loaded = fingerprints(this.data);
24293
+ this.cleared = false;
24294
+ this.ended = false;
24165
24295
  return sessionId;
24166
24296
  }
24167
24297
  }
24168
- this.sessionId = randomBytes5(16).toString("hex");
24298
+ this.sessionId = randomBytes6(16).toString("hex");
24169
24299
  const now = Math.floor(Date.now() / 1e3);
24170
24300
  this.data = { _created: now, _accessed: now };
24171
24301
  this.dirty = false;
24172
24302
  this.safeWrite(this.sessionId, this.data, this.ttl);
24303
+ this.stored = false;
24304
+ this.loaded = /* @__PURE__ */ new Map();
24305
+ this.cleared = false;
24306
+ this.ended = false;
24173
24307
  return this.sessionId;
24174
24308
  }
24175
24309
  /**
@@ -24210,9 +24344,7 @@ var init_session = __esm({
24210
24344
  if (this.sessionId) {
24211
24345
  this.safeDestroy(this.sessionId);
24212
24346
  }
24213
- this.sessionId = null;
24214
- this.data = null;
24215
- this.dirty = false;
24347
+ this.forget();
24216
24348
  }
24217
24349
  /**
24218
24350
  * Get all session data (excluding internal keys).
@@ -24235,6 +24367,7 @@ var init_session = __esm({
24235
24367
  const now = Math.floor(Date.now() / 1e3);
24236
24368
  this.data = { _created: this.data._created, _accessed: now };
24237
24369
  this.dirty = true;
24370
+ this.cleared = true;
24238
24371
  this.save();
24239
24372
  }
24240
24373
  /**
@@ -24251,17 +24384,36 @@ var init_session = __esm({
24251
24384
  * session fixation — the pre-auth ID is destroyed and the data is carried
24252
24385
  * onto a fresh, unguessable ID. A backend destroy/write failure is logged
24253
24386
  * (never silent) but does not throw under the default policy.
24387
+ *
24388
+ * What is carried is the session as it is stored now with this request's
24389
+ * own changes applied, the same merge save() does. If another request ended
24390
+ * the session after this one loaded it (a logout, or a regenerate of its
24391
+ * own), it stays ended: nothing is carried, no id is minted and null is
24392
+ * returned, so no cookie goes out to replace the one that request sent.
24254
24393
  */
24255
24394
  regenerate() {
24395
+ if (this.ended) return null;
24396
+ if (this.sessionId && this.stored && this.data) {
24397
+ const current = this.safeRead(this.sessionId);
24398
+ if (!this.lastReadFailed) {
24399
+ if (!current) {
24400
+ this.forget();
24401
+ this.ended = true;
24402
+ return null;
24403
+ }
24404
+ if (!this.cleared) this.data = this.merged(current);
24405
+ }
24406
+ }
24256
24407
  const oldId = this.sessionId;
24257
24408
  const oldData = this.data;
24258
24409
  if (oldId) {
24259
24410
  this.safeDestroy(oldId);
24260
24411
  }
24261
- this.sessionId = randomBytes5(16).toString("hex");
24412
+ this.sessionId = randomBytes6(16).toString("hex");
24262
24413
  this.data = oldData ?? { _created: Math.floor(Date.now() / 1e3), _accessed: Math.floor(Date.now() / 1e3) };
24263
24414
  this.data._accessed = Math.floor(Date.now() / 1e3);
24264
24415
  this.dirty = true;
24416
+ this.stored = false;
24265
24417
  this.save();
24266
24418
  return this.sessionId;
24267
24419
  }
@@ -24337,17 +24489,84 @@ var init_session = __esm({
24337
24489
  *
24338
24490
  * Returns true on a successful persist, false if the backend was unreachable
24339
24491
  * (logged). The dirty flag is cleared only on success so a later save()
24340
- * retries once the backend recovers. A nothing-to-persist call returns true.
24492
+ * retries once the backend recovers. A call with no session returns true.
24493
+ *
24494
+ * Other requests on the same session run while this one does, so the save
24495
+ * writes only THIS request's changes, onto the record as it is stored now.
24496
+ * Writing back the whole snapshot start() loaded undid whatever another
24497
+ * request did in between: a logout (destroy, clear, regenerate) or a
24498
+ * privilege change made with set(). A record removed since this request
24499
+ * loaded it is never re-created: the session has ended, for this request
24500
+ * too. A request that has not loaded a stored record (a new session, the new
24501
+ * id regenerate() mints) writes its data whole, as before.
24502
+ *
24503
+ * A session the store already holds is re-written even when this request
24504
+ * changed nothing: that write re-stamps the backend deadline to
24505
+ * now + TINA4_SESSION_TTL, so a session expires after that span of INACTIVITY
24506
+ * rather than that long after its last CHANGE (ADR-0087 — a session's expiry
24507
+ * slides on activity). The re-write is the re-read, merged record, so sliding
24508
+ * the deadline is concurrent-safe: it never clobbers another request's change,
24509
+ * never re-creates an ended record, and TINA4_SESSION_TTL=0 still re-stamps a
24510
+ * never-expires deadline (ADR-0027). A request with no stored session and no
24511
+ * change writes nothing — there is no deadline to move.
24341
24512
  */
24342
24513
  save() {
24343
24514
  if (!this.sessionId || !this.data) return true;
24344
- if (!this.dirty) return true;
24345
- if (this.safeWrite(this.sessionId, this.data, this.ttl)) {
24515
+ if (!this.dirty && !this.stored) return true;
24516
+ let record = this.data;
24517
+ if (this.stored) {
24518
+ const current = this.safeRead(this.sessionId);
24519
+ if (this.lastReadFailed) return false;
24520
+ if (!current) {
24521
+ this.forget();
24522
+ this.ended = true;
24523
+ return true;
24524
+ }
24525
+ if (!this.cleared) record = this.merged(current);
24526
+ }
24527
+ if (this.safeWrite(this.sessionId, record, this.ttl)) {
24346
24528
  this.dirty = false;
24529
+ this.stored = true;
24530
+ this.cleared = false;
24531
+ this.loaded = fingerprints(this.data);
24347
24532
  return true;
24348
24533
  }
24349
24534
  return false;
24350
24535
  }
24536
+ /**
24537
+ * The stored record with this request's changes applied: every key whose
24538
+ * value differs from what this request loaded or last saved, and the
24539
+ * removal of every key it deleted. Keys it did not touch keep whatever is
24540
+ * stored now.
24541
+ */
24542
+ merged(current) {
24543
+ const data = this.data;
24544
+ const record = { ...current };
24545
+ for (const [key, value] of Object.entries(data)) {
24546
+ const print = fingerprint(value);
24547
+ if (print === void 0 || !this.loaded.has(key) || this.loaded.get(key) !== print) {
24548
+ record[key] = value;
24549
+ }
24550
+ }
24551
+ for (const key of this.loaded.keys()) {
24552
+ if (!Object.prototype.hasOwnProperty.call(data, key)) {
24553
+ delete record[key];
24554
+ }
24555
+ }
24556
+ return record;
24557
+ }
24558
+ /**
24559
+ * End the session for this request without touching the store: no id and
24560
+ * no data, so nothing more is written and no cookie goes out for it.
24561
+ */
24562
+ forget() {
24563
+ this.sessionId = null;
24564
+ this.data = null;
24565
+ this.dirty = false;
24566
+ this.stored = false;
24567
+ this.cleared = false;
24568
+ this.loaded = /* @__PURE__ */ new Map();
24569
+ }
24351
24570
  };
24352
24571
  }
24353
24572
  });
@@ -24716,11 +24935,11 @@ function formatSourceBlock(filename, lineno) {
24716
24935
  const lines = readSourceLines(filename, lineno);
24717
24936
  if (lines.length === 0) return "";
24718
24937
  const rows = lines.map(([num, text, isError]) => {
24719
- const bg = isError ? `background:${ERROR_LINE_BG};` : "";
24938
+ const rowClass = isError ? "eo-line eo-line-err" : "eo-line";
24720
24939
  const marker = isError ? "&#x25b6;" : " ";
24721
- 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>`;
24940
+ return `<div class="${rowClass}"><span class="eo-ln">${num}</span><span class="eo-marker">${marker}</span><span class="eo-code">${esc(text)}</span></div>`;
24722
24941
  }).join("\n");
24723
- 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>`;
24942
+ return `<div class="eo-source">` + rows + `</div>`;
24724
24943
  }
24725
24944
  function formatFrame(frame, capturedAt = 0) {
24726
24945
  const source = frame.file && frame.line > 0 ? formatSourceBlock(frame.file, frame.line) : "";
@@ -24732,23 +24951,23 @@ function formatFrame(frame, capturedAt = 0) {
24732
24951
  if (mtime > capturedAt + 0.5) {
24733
24952
  const d = new Date(mtime * 1e3);
24734
24953
  const mtimeIso = `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}:${String(d.getUTCSeconds()).padStart(2, "0")}`;
24735
- 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>`;
24954
+ staleBadge = ` <span class="eo-stale">FILE MODIFIED @ ${mtimeIso} UTC &mdash; source may not match what failed</span>`;
24736
24955
  }
24737
24956
  } catch {
24738
24957
  }
24739
24958
  }
24740
- 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>`;
24959
+ 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>`;
24741
24960
  }
24742
24961
  function collapsible(title, content, openByDefault = false) {
24743
24962
  const open = openByDefault ? " open" : "";
24744
- 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>`;
24963
+ return `<details class="eo-details"${open}><summary class="eo-summary">${esc(title)}</summary><div class="eo-details-body">${content}</div></details>`;
24745
24964
  }
24746
24965
  function table(pairs) {
24747
- if (pairs.length === 0) return `<span style="color:${SUBTEXT};">None</span>`;
24966
+ if (pairs.length === 0) return `<span class="eo-none">None</span>`;
24748
24967
  const rows = pairs.map(
24749
- ([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>`
24968
+ ([key, val]) => `<tr><td class="eo-key">${esc(key)}</td><td class="eo-val">${esc(val)}</td></tr>`
24750
24969
  ).join("");
24751
- return `<table style="border-collapse:collapse;width:100%;">${rows}</table>`;
24970
+ return `<table class="eo-table">${rows}</table>`;
24752
24971
  }
24753
24972
  function renderErrorOverlay(error2, request) {
24754
24973
  const capturedAt = Date.now() / 1e3;
@@ -24761,7 +24980,7 @@ function renderErrorOverlay(error2, request) {
24761
24980
  }
24762
24981
  const hidden = frames.length - Math.min(frames.length, MAX_FRAMES);
24763
24982
  if (hidden > 0) {
24764
- framesHtml += `<div style="color:${SUBTEXT};padding:8px 0;font-size:13px;">&#8230; ${hidden} more stack frames hidden (truncated at ${MAX_FRAMES})</div>`;
24983
+ framesHtml += `<div class="eo-hidden-frames">&#8230; ${hidden} more stack frames hidden (truncated at ${MAX_FRAMES})</div>`;
24765
24984
  }
24766
24985
  const requestPairs = [];
24767
24986
  if (request != null) {
@@ -24798,37 +25017,72 @@ function renderErrorOverlay(error2, request) {
24798
25017
  ];
24799
25018
  const envSection = collapsible("Environment", table(envPairs));
24800
25019
  const stackSection = collapsible("Stack Trace", framesHtml, true);
25020
+ const nonce = currentCspNonce();
24801
25021
  return `<!DOCTYPE html>
24802
25022
  <html lang="en">
24803
25023
  <head>
24804
25024
  <meta charset="utf-8">
24805
25025
  <meta name="viewport" content="width=device-width,initial-scale=1">
24806
25026
  <title>Tina4 Error \u2014 ${esc(excType)}</title>
24807
- <style>
24808
- *{margin:0;padding:0;box-sizing:border-box;}
24809
- body{background:${BG};color:${TEXT};font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;padding:24px;line-height:1.5;}
25027
+ <style nonce="${nonce}">
25028
+ ${overlayStylesheet()}
24810
25029
  </style>
24811
25030
  </head>
24812
25031
  <body>
24813
- <div style="max-width:960px;margin:0 auto;">
24814
- <div style="margin-bottom:24px;">
24815
- <div style="display:flex;align-items:center;gap:12px;margin-bottom:12px;">
24816
- <span style="background:${RED};color:${BG};padding:4px 12px;border-radius:4px;font-weight:700;font-size:13px;text-transform:uppercase;">Error</span>
24817
- <span style="color:${SUBTEXT};font-size:14px;">Tina4 Debug Overlay</span>
25032
+ <div class="eo-wrap">
25033
+ <div class="eo-header">
25034
+ <div class="eo-badge-row">
25035
+ <span class="eo-badge">Error</span>
25036
+ <span class="eo-sub">Tina4 Debug Overlay</span>
24818
25037
  </div>
24819
- <h1 style="color:${RED};font-size:28px;font-weight:700;margin-bottom:8px;">${esc(excType)}</h1>
24820
- <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>
25038
+ <h1 class="eo-type">${esc(excType)}</h1>
25039
+ <p class="eo-msg">${esc(excMsg)}</p>
24821
25040
  </div>
24822
25041
  ${stackSection}
24823
25042
  ${requestSection}
24824
25043
  ${envSection}
24825
- <div style="margin-top:32px;padding-top:16px;border-top:1px solid ${OVERLAY};color:${SUBTEXT};font-size:12px;">
25044
+ <div class="eo-footer">
24826
25045
  Tina4 Debug Overlay &mdash; This page is only shown in debug mode. Set TINA4_DEBUG=false in production.
24827
25046
  </div>
24828
25047
  </div>
24829
25048
  </body>
24830
25049
  </html>`;
24831
25050
  }
25051
+ function overlayStylesheet() {
25052
+ return `
25053
+ *{margin:0;padding:0;box-sizing:border-box;}
25054
+ body{background:${BG};color:${TEXT};font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;padding:24px;line-height:1.5;}
25055
+ .eo-wrap{max-width:960px;margin:0 auto;}
25056
+ .eo-header{margin-bottom:24px;}
25057
+ .eo-badge-row{display:flex;align-items:center;gap:12px;margin-bottom:12px;}
25058
+ .eo-badge{background:${RED};color:${BG};padding:4px 12px;border-radius:4px;font-weight:700;font-size:13px;text-transform:uppercase;}
25059
+ .eo-sub{color:${SUBTEXT};font-size:14px;}
25060
+ .eo-type{color:${RED};font-size:28px;font-weight:700;margin-bottom:8px;}
25061
+ .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};}
25062
+ .eo-footer{margin-top:32px;padding-top:16px;border-top:1px solid ${OVERLAY};color:${SUBTEXT};font-size:12px;}
25063
+ .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;}
25064
+ .eo-line{display:flex;padding:1px 0;}
25065
+ .eo-line-err{background:${ERROR_LINE_BG};}
25066
+ .eo-ln{color:${YELLOW};min-width:3.5em;text-align:right;padding-right:1em;user-select:none;}
25067
+ .eo-marker{color:${RED};width:1.2em;user-select:none;}
25068
+ .eo-code{color:${TEXT};white-space:pre-wrap;tab-size:4;}
25069
+ .eo-frame{margin-bottom:16px;}
25070
+ .eo-frame-head{margin-bottom:4px;}
25071
+ .eo-file{color:${BLUE};}
25072
+ .eo-sep{color:${SUBTEXT};}
25073
+ .eo-lineno{color:${YELLOW};}
25074
+ .eo-fn{color:${GREEN};}
25075
+ .eo-stale{background:${PEACH};color:${BG};padding:1px 8px;border-radius:3px;font-size:11px;font-weight:700;margin-left:6px;}
25076
+ .eo-details{margin-top:16px;}
25077
+ .eo-summary{cursor:pointer;color:${LAVENDER};font-weight:600;font-size:15px;padding:8px 0;user-select:none;}
25078
+ .eo-details-body{padding:8px 0;}
25079
+ .eo-table{border-collapse:collapse;width:100%;}
25080
+ .eo-key{color:${PEACH};padding:4px 16px 4px 0;vertical-align:top;white-space:nowrap;}
25081
+ .eo-val{color:${TEXT};padding:4px 0;word-break:break-all;}
25082
+ .eo-none{color:${SUBTEXT};}
25083
+ .eo-hidden-frames{color:${SUBTEXT};padding:8px 0;font-size:13px;}
25084
+ `;
25085
+ }
24832
25086
  function isDebugMode() {
24833
25087
  return isTruthy(process.env.TINA4_DEBUG);
24834
25088
  }
@@ -24837,6 +25091,7 @@ var init_errorOverlay = __esm({
24837
25091
  "../core/src/errorOverlay.ts"() {
24838
25092
  "use strict";
24839
25093
  init_dotenv();
25094
+ init_csp();
24840
25095
  MAX_FRAMES = 50;
24841
25096
  SENSITIVE_KEY_RE = /password|passwd|secret|token|authorization|cookie|key/i;
24842
25097
  REDACTED2 = "[redacted]";
@@ -26710,7 +26965,11 @@ export default async function (req: Tina4Request, res: Tina4Response) {
26710
26965
  `{% extends "base.twig" %}
26711
26966
  {% block title %}Login{% endblock %}
26712
26967
  {% block content %}
26713
- <div class="container mt-4" style="max-width:400px">
26968
+ {# No inline style attribute: a nonce lets this <style> block run under the
26969
+ strict default Content-Security-Policy. csp_nonce() is a built-in Frond global
26970
+ that returns this response's nonce (ADR-0088). #}
26971
+ <style nonce="{{ csp_nonce() }}">.auth-form{max-width:400px;margin:0 auto}</style>
26972
+ <div class="container mt-4 auth-form">
26714
26973
  <h1>Login</h1>
26715
26974
  <form method="post" action="/api/auth/login">
26716
26975
  {{ form_token() }}
@@ -26734,7 +26993,8 @@ export default async function (req: Tina4Request, res: Tina4Response) {
26734
26993
  `{% extends "base.twig" %}
26735
26994
  {% block title %}Register{% endblock %}
26736
26995
  {% block content %}
26737
- <div class="container mt-4" style="max-width:400px">
26996
+ <style nonce="{{ csp_nonce() }}">.auth-form{max-width:400px;margin:0 auto}</style>
26997
+ <div class="container mt-4 auth-form">
26738
26998
  <h1>Register</h1>
26739
26999
  <form method="post" action="/api/auth/register">
26740
27000
  {{ form_token() }}
@@ -27671,7 +27931,7 @@ import * as path3 from "node:path";
27671
27931
  import * as os from "node:os";
27672
27932
  import { spawnSync as spawnSync2 } from "node:child_process";
27673
27933
  import { createRequire as createRequire8 } from "node:module";
27674
- import { randomBytes as randomBytes6 } from "node:crypto";
27934
+ import { randomBytes as randomBytes7 } from "node:crypto";
27675
27935
  function reqSibling(pkg) {
27676
27936
  return req(`../../${pkg}/src/index.ts`);
27677
27937
  }
@@ -28993,7 +29253,7 @@ var init_mcp = __esm({
28993
29253
  // ── Session lifecycle + protocol negotiation ──────────────────
28994
29254
  /** Mint a new session id and remember it. Called on `initialize`. */
28995
29255
  openSession() {
28996
- const sid = randomBytes6(16).toString("hex");
29256
+ const sid = randomBytes7(16).toString("hex");
28997
29257
  this._sessions.set(sid, Date.now());
28998
29258
  return sid;
28999
29259
  }
@@ -36769,8 +37029,8 @@ var init_devAdmin = __esm({
36769
37029
  * Duplicate errors (same message) increment count and update last_seen.
36770
37030
  */
36771
37031
  static capture(errorType, message, traceback = "", file = "", line = 0) {
36772
- const fingerprint = `${errorType}|${message}|${file}|${line}`;
36773
- const existing = this.errors.find((e) => e.fingerprint === fingerprint);
37032
+ const fingerprint2 = `${errorType}|${message}|${file}|${line}`;
37033
+ const existing = this.errors.find((e) => e.fingerprint === fingerprint2);
36774
37034
  const now = (/* @__PURE__ */ new Date()).toISOString();
36775
37035
  if (existing) {
36776
37036
  existing.count = (existing.count || 1) + 1;
@@ -36783,7 +37043,7 @@ var init_devAdmin = __esm({
36783
37043
  message,
36784
37044
  stack: traceback || void 0,
36785
37045
  resolved: false,
36786
- ...{ fingerprint, error_type: errorType, file, line, count: 1, first_seen: now, last_seen: now }
37046
+ ...{ fingerprint: fingerprint2, error_type: errorType, file, line, count: 1, first_seen: now, last_seen: now }
36787
37047
  });
36788
37048
  if (this.errors.length > this.maxErrors) {
36789
37049
  this.errors = this.errors.slice(-this.maxErrors);
@@ -39462,7 +39722,7 @@ function swaggerEnabled() {
39462
39722
  }
39463
39723
  function createSwaggerRoutes(getSpec) {
39464
39724
  const serveUi = async (_req, res) => {
39465
- res.html(SWAGGER_UI_HTML("/swagger/openapi.json"));
39725
+ res.html(SWAGGER_UI_HTML("/swagger/openapi.json", res.cspNonce));
39466
39726
  };
39467
39727
  return [
39468
39728
  {
@@ -39506,13 +39766,13 @@ var SWAGGER_UI_HTML;
39506
39766
  var init_ui = __esm({
39507
39767
  "../swagger/src/ui.ts"() {
39508
39768
  "use strict";
39509
- SWAGGER_UI_HTML = (specUrl) => `<!DOCTYPE html>
39769
+ SWAGGER_UI_HTML = (specUrl, nonce) => `<!DOCTYPE html>
39510
39770
  <html lang="en">
39511
39771
  <head>
39512
39772
  <meta charset="UTF-8">
39513
39773
  <title>Tina4 API Documentation</title>
39514
39774
  <link rel="stylesheet" href="${swaggerUiCdn()}/swagger-ui.css">
39515
- <style>
39775
+ <style nonce="${nonce}">
39516
39776
  body { margin: 0; background: #fafafa; }
39517
39777
  .topbar { display: none !important; }
39518
39778
  </style>
@@ -39520,7 +39780,7 @@ var init_ui = __esm({
39520
39780
  <body>
39521
39781
  <div id="swagger-ui"></div>
39522
39782
  <script src="${swaggerUiCdn()}/swagger-ui-bundle.js"></script>
39523
- <script>
39783
+ <script nonce="${nonce}">
39524
39784
  SwaggerUIBundle({
39525
39785
  url: "${specUrl}",
39526
39786
  dom_id: '#swagger-ui',
@@ -39560,7 +39820,7 @@ __export(sso_exports, {
39560
39820
  SsoError: () => SsoError,
39561
39821
  liveSessionIdentity: () => liveSessionIdentity
39562
39822
  });
39563
- import { createHash as createHash9, randomBytes as randomBytes7, timingSafeEqual as timingSafeEqual3 } from "node:crypto";
39823
+ import { createHash as createHash9, randomBytes as randomBytes8, timingSafeEqual as timingSafeEqual3 } from "node:crypto";
39564
39824
  function expiresAt(tokens) {
39565
39825
  const lifetime = Number(tokens.expires_in ?? 0);
39566
39826
  return lifetime > 0 ? Math.floor(Date.now() / 1e3) + lifetime : 0;
@@ -39688,9 +39948,9 @@ var init_sso = __esm({
39688
39948
  async login(requestOrSession, returnTo = "/") {
39689
39949
  const session = this.session(requestOrSession);
39690
39950
  if (!session) throw new SsoError("SSO login requires a Tina4 Session");
39691
- const state = randomBytes7(32).toString("base64url");
39692
- const nonce = randomBytes7(32).toString("base64url");
39693
- const verifier = randomBytes7(64).toString("base64url");
39951
+ const state = randomBytes8(32).toString("base64url");
39952
+ const nonce = randomBytes8(32).toString("base64url");
39953
+ const verifier = randomBytes8(64).toString("base64url");
39694
39954
  const challenge = createHash9("sha256").update(verifier).digest("base64url");
39695
39955
  session.set(_Sso.PENDING_KEY, { state, nonce, verifier, return_to: _Sso.safeReturn(returnTo), created_at: Math.floor(Date.now() / 1e3) });
39696
39956
  const metadata = await this.discover();
@@ -39918,7 +40178,7 @@ var init_docsAutoDiscovery = __esm({
39918
40178
  });
39919
40179
 
39920
40180
  // ../core/src/server.ts
39921
- import { randomBytes as randomBytes8 } from "node:crypto";
40181
+ import { randomBytes as randomBytes9 } from "node:crypto";
39922
40182
  import { resolve as resolve20, dirname as dirname14, join as join32, relative as relative10 } from "node:path";
39923
40183
  import { existsSync as existsSync26, readdirSync as readdirSync17, readFileSync as readFileSync23, statSync as statSync19 } from "node:fs";
39924
40184
  import { isatty } from "node:tty";
@@ -40098,7 +40358,8 @@ function isDevMode() {
40098
40358
  }
40099
40359
  async function renderErrorPage(code, data, templatesDir) {
40100
40360
  try {
40101
- const { Frond: Frond2 } = await Promise.resolve().then(() => (init_engine(), engine_exports));
40361
+ const { Frond: Frond2, setCspNonceProvider: setCspNonceProvider2 } = await Promise.resolve().then(() => (init_engine(), engine_exports));
40362
+ setCspNonceProvider2(currentCspNonce);
40102
40363
  const templateFile = `errors/${code}.twig`;
40103
40364
  const getCachedFrond = (dir) => {
40104
40365
  let instance = frondCache.get(dir);
@@ -40214,6 +40475,7 @@ function resolveTemplate(pathname, templatesDir) {
40214
40475
  }
40215
40476
  function renderLandingPage(port = 7148) {
40216
40477
  const version = TINA4_VERSION;
40478
+ const nonce = currentCspNonce();
40217
40479
  const galleryItems = [
40218
40480
  { 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" },
40219
40481
  { id: "orm", icon: "&#128451;", name: "ORM", desc: "Product model with CRUD endpoints", accent: "accent-green", tryUrl: "/api/gallery/products" },
@@ -40228,7 +40490,7 @@ function renderLandingPage(port = 7148) {
40228
40490
  const isDeployed = deployed[item.id] === true;
40229
40491
  const tryBtn = isDeployed ? `<a href="${item.tryUrl}" class="gbtn gbtn-try" target="_blank">Try It</a>` : "";
40230
40492
  const viewBtn = isDeployed ? `<a href="${item.tryUrl}" class="gbtn gbtn-view" target="_blank">View</a>` : "";
40231
- const deployBtn = isDeployed ? `<span class="gbtn gbtn-deployed">Deployed</span>` : `<button class="gbtn gbtn-deploy" onclick="deployGallery('${item.id}')">Deploy</button>`;
40493
+ const deployBtn = isDeployed ? `<span class="gbtn gbtn-deployed">Deployed</span>` : `<button class="gbtn gbtn-deploy" data-deploy="${item.id}">Deploy</button>`;
40232
40494
  return `<div class="gallery-card">
40233
40495
  <div class="accent ${item.accent}"></div>
40234
40496
  <div class="icon">${item.icon}</div>
@@ -40243,7 +40505,7 @@ function renderLandingPage(port = 7148) {
40243
40505
  <meta charset="utf-8">
40244
40506
  <meta name="viewport" content="width=device-width, initial-scale=1">
40245
40507
  <title>Tina4NodeJs</title>
40246
- <style>
40508
+ <style nonce="${nonce}">
40247
40509
  *{margin:0;padding:0;box-sizing:border-box}
40248
40510
  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}
40249
40511
  .bg-watermark{position:fixed;bottom:-5%;right:-5%;width:45%;opacity:0.04;pointer-events:none;z-index:0}
@@ -40282,6 +40544,11 @@ h1{font-size:3rem;font-weight:700;margin-bottom:0.25rem;letter-spacing:-1px}
40282
40544
  .gbtn-deployed{background:transparent;border:1px solid #22c55e;color:#22c55e;cursor:default;font-size:0.7rem}
40283
40545
  @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)}}
40284
40546
  .star-wiggle{display:inline-block;transform-origin:center}
40547
+ .gallery-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:1rem}
40548
+ .tok-comment{color:#64748b}
40549
+ .tok-kw{color:#c084fc}
40550
+ .tok-str{color:#4ade80}
40551
+ .tok-fn{color:#38bdf8}
40285
40552
  </style>
40286
40553
  </head>
40287
40554
  <body>
@@ -40307,23 +40574,23 @@ h1{font-size:3rem;font-weight:700;margin-bottom:0.25rem;letter-spacing:-1px}
40307
40574
  <div class="section">
40308
40575
  <div class="card">
40309
40576
  <h2>Getting Started</h2>
40310
- <pre class="code-block"><code><span style="color:#64748b">// app.ts</span>
40311
- <span style="color:#c084fc">import</span> { startServer, Router } <span style="color:#c084fc">from</span> <span style="color:#4ade80">"tina4-nodejs"</span>;
40577
+ <pre class="code-block"><code><span class="tok-comment">// app.ts</span>
40578
+ <span class="tok-kw">import</span> { startServer, Router } <span class="tok-kw">from</span> <span class="tok-str">"tina4-nodejs"</span>;
40312
40579
 
40313
- 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; {
40314
- <span style="color:#c084fc">return</span> res.json({ message: <span style="color:#4ade80">"Hello World!"</span> });
40580
+ 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; {
40581
+ <span class="tok-kw">return</span> res.json({ message: <span class="tok-str">"Hello World!"</span> });
40315
40582
  });
40316
40583
 
40317
- startServer({ port: 7148 }); <span style="color:#64748b">// starts on port 7148</span></code></pre>
40584
+ startServer({ port: 7148 }); <span class="tok-comment">// starts on port 7148</span></code></pre>
40318
40585
  </div>
40319
40586
  </div>
40320
40587
  <div class="gallery">
40321
40588
  <h2 id="gallery">What You Can Build</h2>
40322
- <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:1rem;">
40589
+ <div class="gallery-cards">
40323
40590
  ${galleryCards}
40324
40591
  </div>
40325
40592
  </div>
40326
- <script>
40593
+ <script nonce="${nonce}">
40327
40594
  function deployGallery(name) {
40328
40595
  if (!confirm('Deploy the "' + name + '" gallery example into your project? This will copy files into src/.')) return;
40329
40596
  fetch('/__dev/api/gallery/deploy', {
@@ -40358,6 +40625,12 @@ function deployGallery(name) {
40358
40625
  }
40359
40626
  setTimeout(doWiggle,3000);
40360
40627
  })();
40628
+ // CSP-clean wiring: no inline onclick handlers \u2014 bind every Deploy button from
40629
+ // its data-deploy attribute (a nonce covers script elements, not event-handler
40630
+ // attributes).
40631
+ document.querySelectorAll('.gbtn-deploy[data-deploy]').forEach(function(btn){
40632
+ btn.addEventListener('click', function(){ deployGallery(btn.dataset.deploy); });
40633
+ });
40361
40634
  </script>
40362
40635
  </body>
40363
40636
  </html>`;
@@ -40574,7 +40847,7 @@ async function renderDispatchError(err, req2, res, templatesDir) {
40574
40847
  }
40575
40848
  }
40576
40849
  }
40577
- const requestId = Log.getRequestId() ?? randomBytes8(4).toString("hex");
40850
+ const requestId = Log.getRequestId() ?? randomBytes9(4).toString("hex");
40578
40851
  if (wantsJson(req2)) {
40579
40852
  const body = negotiatedErrorBody(500, "Internal Server Error", requestId);
40580
40853
  res.raw.writeHead(500, { "Content-Type": "application/json" });
@@ -40645,7 +40918,7 @@ function serveStaticAsset(ctx) {
40645
40918
  return false;
40646
40919
  }
40647
40920
  async function serveNotFound(ctx) {
40648
- const requestId = Log.getRequestId() ?? randomBytes8(4).toString("hex");
40921
+ const requestId = Log.getRequestId() ?? randomBytes9(4).toString("hex");
40649
40922
  if (wantsJson(ctx.req)) {
40650
40923
  const body = negotiatedErrorBody(404, "Not Found", requestId);
40651
40924
  ctx.res.raw.writeHead(404, httpReason(404), { "Content-Type": "application/json" });
@@ -40668,7 +40941,8 @@ async function buildDispatchContext(router, base) {
40668
40941
  const templatesDir = resolve20(root, "src/templates");
40669
40942
  let frondEngine = null;
40670
40943
  try {
40671
- const { Frond: Frond2 } = await Promise.resolve().then(() => (init_engine(), engine_exports));
40944
+ const { Frond: Frond2, setCspNonceProvider: setCspNonceProvider2 } = await Promise.resolve().then(() => (init_engine(), engine_exports));
40945
+ setCspNonceProvider2(currentCspNonce);
40672
40946
  frondEngine = new Frond2(templatesDir);
40673
40947
  } catch {
40674
40948
  }
@@ -40778,9 +41052,13 @@ async function dispatchInner(ctx, rawReq, rawRes, requestId) {
40778
41052
  }
40779
41053
  }
40780
41054
  async function runDispatch(ctx, rawReq, rawRes) {
40781
- const requestId = Log.sanitizeRequestId(rawReq.headers["x-request-id"]) ?? randomBytes8(4).toString("hex");
41055
+ const requestId = Log.sanitizeRequestId(rawReq.headers["x-request-id"]) ?? randomBytes9(4).toString("hex");
40782
41056
  if (!rawRes.headersSent) rawRes.setHeader("x-request-id", requestId);
40783
- return Log.runWithRequestId(requestId, () => dispatchInner(ctx, rawReq, rawRes, requestId));
41057
+ const requestNonce = generateNonce();
41058
+ return Log.runWithRequestId(
41059
+ requestId,
41060
+ () => runWithCspNonce(requestNonce, () => dispatchInner(ctx, rawReq, rawRes, requestId))
41061
+ );
40784
41062
  }
40785
41063
  function loopbackBindHosts(host) {
40786
41064
  const normalized = host.trim().replace(/^\[|\]$/g, "").toLowerCase();
@@ -40907,7 +41185,8 @@ async function startServer(config) {
40907
41185
  let frondEngine = null;
40908
41186
  setDefaultTemplatesDir(templatesDir);
40909
41187
  try {
40910
- const { Frond: Frond2 } = await Promise.resolve().then(() => (init_engine(), engine_exports));
41188
+ const { Frond: Frond2, setCspNonceProvider: setCspNonceProvider2 } = await Promise.resolve().then(() => (init_engine(), engine_exports));
41189
+ setCspNonceProvider2(currentCspNonce);
40911
41190
  frondEngine = new Frond2(templatesDir);
40912
41191
  router.addRoute({
40913
41192
  method: "GET",
@@ -41048,10 +41327,12 @@ async function startServer(config) {
41048
41327
  }
41049
41328
  });
41050
41329
  aiServer.on("error", (err) => {
41051
- if (err.code === "EADDRINUSE") {
41330
+ if (err?.code === "EADDRINUSE") {
41052
41331
  Log.warning(`Test port ${testPort} in use \u2014 skipping`);
41053
- aiServer = null;
41332
+ } else {
41333
+ Log.error(`Test port ${testPort} bind failed (${err?.code ?? err}) \u2014 skipping`);
41054
41334
  }
41335
+ aiServer = null;
41055
41336
  });
41056
41337
  aiServer.listen(testPort, host);
41057
41338
  }
@@ -41266,6 +41547,7 @@ var init_server = __esm({
41266
41547
  init_static();
41267
41548
  init_dotenv();
41268
41549
  init_errorOverlay();
41550
+ init_csp();
41269
41551
  init_health();
41270
41552
  init_rateLimiter();
41271
41553
  init_logger();
@@ -41515,7 +41797,7 @@ var init_mqttMessage = __esm({
41515
41797
  // ../core/src/mqtt.ts
41516
41798
  import net2 from "node:net";
41517
41799
  import tls from "node:tls";
41518
- import { randomBytes as randomBytes9 } from "node:crypto";
41800
+ import { randomBytes as randomBytes10 } from "node:crypto";
41519
41801
  import { existsSync as existsSync27, readFileSync as readFileSync24 } from "node:fs";
41520
41802
  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;
41521
41803
  var init_mqtt = __esm({
@@ -41603,7 +41885,7 @@ var init_mqtt = __esm({
41603
41885
  this.caFile = options.caFile ?? (Env.str("TINA4_MQTT_CA_FILE") || null);
41604
41886
  this.tlsVerify = options.tlsVerify ?? Env.bool("TINA4_MQTT_TLS_VERIFY", true);
41605
41887
  let cid = options.clientId ?? (Env.str("TINA4_MQTT_CLIENT_ID") || null);
41606
- if (cid === null || cid === "") cid = "tina4-" + randomBytes9(8).toString("hex");
41888
+ if (cid === null || cid === "") cid = "tina4-" + randomBytes10(8).toString("hex");
41607
41889
  this.clientId = cid;
41608
41890
  this.keepalive = options.keepalive ?? Env.int("TINA4_MQTT_KEEPALIVE", DEFAULT_KEEPALIVE);
41609
41891
  this.cleanSession = options.cleanSession ?? true;
@@ -42717,7 +42999,7 @@ var init_ssrf = __esm({
42717
42999
  import http from "node:http";
42718
43000
  import https from "node:https";
42719
43001
  import { URL as URL2 } from "node:url";
42720
- import { randomBytes as randomBytes10 } from "node:crypto";
43002
+ import { randomBytes as randomBytes11 } from "node:crypto";
42721
43003
  import { promises as fsp, createWriteStream } from "node:fs";
42722
43004
  import { basename as basename7 } from "node:path";
42723
43005
  import { pipeline } from "node:stream/promises";
@@ -43109,7 +43391,7 @@ var init_api = __esm({
43109
43391
  return { http_code: null, body: null, headers: {}, error: "upload requires filePath or fileBytes" };
43110
43392
  }
43111
43393
  const partContentType = guessContentType(uploadName);
43112
- const boundary = "----Tina4Boundary" + randomBytes10(16).toString("hex");
43394
+ const boundary = "----Tina4Boundary" + randomBytes11(16).toString("hex");
43113
43395
  const bodyBuffer = buildMultipartBody(boundary, fieldName, uploadName, content, partContentType, extraFields);
43114
43396
  const contentType = `multipart/form-data; boundary=${boundary}`;
43115
43397
  return this.execute("POST", this.buildUrl(path10), bodyBuffer, contentType, headers);
@@ -46091,7 +46373,7 @@ import {
46091
46373
  createHmac as createHmac4,
46092
46374
  createPrivateKey,
46093
46375
  createSign as createSign2,
46094
- randomBytes as randomBytes11
46376
+ randomBytes as randomBytes12
46095
46377
  } from "node:crypto";
46096
46378
  function encodeBase64Url(value) {
46097
46379
  return Buffer.from(value).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
@@ -46171,7 +46453,7 @@ function encryptPayload(payload, subscription) {
46171
46453
  ]);
46172
46454
  const prkKey = hmac(authSecret, sharedSecret);
46173
46455
  const ikm = hkdfExpand(prkKey, keyInfo, 32);
46174
- const salt = randomBytes11(16);
46456
+ const salt = randomBytes12(16);
46175
46457
  const contentPrk = hmac(salt, ikm);
46176
46458
  const cek = hkdfExpand(contentPrk, Buffer.from("Content-Encoding: aes128gcm\0", "ascii"), 16);
46177
46459
  const nonce = hkdfExpand(contentPrk, Buffer.from("Content-Encoding: nonce\0", "ascii"), 12);
@@ -48928,6 +49210,7 @@ __export(src_exports3, {
48928
49210
  cacheStats: () => cacheStats,
48929
49211
  checkPassword: () => checkPassword,
48930
49212
  clearCache: () => clearCache,
49213
+ clearCurrentNonce: () => clearCurrentNonce,
48931
49214
  computeAcceptKey: () => computeAcceptKey,
48932
49215
  container: () => container,
48933
49216
  cors: () => cors,
@@ -48939,6 +49222,8 @@ __export(src_exports3, {
48939
49222
  createMessenger: () => createMessenger,
48940
49223
  createRequest: () => createRequest,
48941
49224
  createResponse: () => createResponse,
49225
+ cspNonce: () => cspNonce,
49226
+ currentCspNonce: () => currentCspNonce,
48942
49227
  decodeRequest: () => decodeRequest,
48943
49228
  defaultContext: () => defaultContext,
48944
49229
  defaultRouter: () => defaultRouter,
@@ -48964,6 +49249,7 @@ __export(src_exports3, {
48964
49249
  feedbackWhitelist: () => feedbackWhitelist,
48965
49250
  fts5Supported: () => fts5Supported,
48966
49251
  generateContext: () => generateContext,
49252
+ generateNonce: () => generateNonce,
48967
49253
  generateVapidKeys: () => generateVapidKeys,
48968
49254
  get: () => get,
48969
49255
  getDefaultDevServer: () => getDefaultDevServer,
@@ -48986,6 +49272,7 @@ __export(src_exports3, {
48986
49272
  httpReason: () => httpReason,
48987
49273
  inContainer: () => inContainer,
48988
49274
  injectFeedbackWidget: () => injectFeedbackWidget,
49275
+ injectNonceIntoCsp: () => injectNonceIntoCsp,
48989
49276
  installAll: () => installAll,
48990
49277
  installSelected: () => installSelected,
48991
49278
  isBannerSuppressed: () => isBannerSuppressed,
@@ -49040,6 +49327,7 @@ __export(src_exports3, {
49040
49327
  resetTrustedProxyCache: () => resetTrustedProxyCache,
49041
49328
  resolveAlgorithm: () => resolveAlgorithm,
49042
49329
  resolveClientIp: () => resolveClientIp,
49330
+ resolveCspHeader: () => resolveCspHeader,
49043
49331
  resolveFrameworkVersion: () => resolveFrameworkVersion,
49044
49332
  resolvePortAndHost: () => resolvePortAndHost,
49045
49333
  resolveStringMiddleware: () => resolveStringMiddleware,
@@ -49047,12 +49335,14 @@ __export(src_exports3, {
49047
49335
  responseCache: () => responseCache,
49048
49336
  runAll: () => runAll,
49049
49337
  runRouteMiddlewares: () => runRouteMiddlewares,
49338
+ runWithCspNonce: () => runWithCspNonce,
49050
49339
  runtimeDir: () => runtimeDir,
49051
49340
  saveUpload: () => saveUpload,
49052
49341
  schemaFromParams: () => schemaFromParams,
49053
49342
  selectablePids: () => selectablePids,
49054
49343
  serveWebSocketRoute: () => serveWebSocketRoute,
49055
49344
  sessionCookieName: () => sessionCookieName,
49345
+ setCurrentNonce: () => setCurrentNonce,
49056
49346
  setDefaultTemplatesDir: () => setDefaultTemplatesDir,
49057
49347
  setFrond: () => setFrond,
49058
49348
  showMenu: () => showMenu,
@@ -49119,6 +49409,7 @@ var init_src3 = __esm({
49119
49409
  init_wsdl();
49120
49410
  init_htmlElement();
49121
49411
  init_errorOverlay();
49412
+ init_csp();
49122
49413
  init_ai();
49123
49414
  init_sso();
49124
49415
  init_push();
@@ -49327,6 +49618,15 @@ export default async function (req: Tina4Request, res: Tina4Response): Promise<v
49327
49618
  <p>The Intelligent Native Application 4ramework</p>
49328
49619
  </body>
49329
49620
  </html>
49621
+ `
49622
+ );
49623
+ writeFileSync(
49624
+ join(targetDir, "public/style.css"),
49625
+ `body { font-family: -apple-system, sans-serif; max-width: 600px; margin: 80px auto; padding: 0 20px; color: #333; }
49626
+ h1 { font-size: 2.5em; margin-bottom: 0.2em; }
49627
+ p { color: #666; font-size: 1.1em; }
49628
+ code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; font-size: 0.9em; }
49629
+ a { color: #2563eb; }
49330
49630
  `
49331
49631
  );
49332
49632
  writeFileSync(
@@ -49335,13 +49635,7 @@ export default async function (req: Tina4Request, res: Tina4Response): Promise<v
49335
49635
  <html>
49336
49636
  <head>
49337
49637
  <title>Tina4</title>
49338
- <style>
49339
- body { font-family: -apple-system, sans-serif; max-width: 600px; margin: 80px auto; padding: 0 20px; color: #333; }
49340
- h1 { font-size: 2.5em; margin-bottom: 0.2em; }
49341
- p { color: #666; font-size: 1.1em; }
49342
- code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; font-size: 0.9em; }
49343
- a { color: #2563eb; }
49344
- </style>
49638
+ <link rel="stylesheet" href="/style.css">
49345
49639
  </head>
49346
49640
  <body>
49347
49641
  <h1>tina4</h1>