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
@@ -5406,6 +5406,73 @@ var init_trustedProxy = __esm({
5406
5406
  }
5407
5407
  });
5408
5408
 
5409
+ // ../core/src/csp.ts
5410
+ import { AsyncLocalStorage as AsyncLocalStorage2 } from "node:async_hooks";
5411
+ import { randomBytes as randomBytes2 } from "node:crypto";
5412
+ function generateNonce() {
5413
+ return randomBytes2(16).toString("base64");
5414
+ }
5415
+ function runWithCspNonce(nonce, fn) {
5416
+ return nonceStore.run({ nonce }, fn);
5417
+ }
5418
+ function setCurrentNonce(nonce) {
5419
+ const store = nonceStore.getStore();
5420
+ if (store) store.nonce = nonce;
5421
+ else nonceFallback = nonce;
5422
+ }
5423
+ function clearCurrentNonce() {
5424
+ setCurrentNonce(void 0);
5425
+ }
5426
+ function currentCspNonce() {
5427
+ const store = nonceStore.getStore();
5428
+ let value = store ? store.nonce : nonceFallback;
5429
+ if (!value) {
5430
+ value = generateNonce();
5431
+ if (store) store.nonce = value;
5432
+ else nonceFallback = value;
5433
+ }
5434
+ return value;
5435
+ }
5436
+ function cspNonce() {
5437
+ return currentCspNonce();
5438
+ }
5439
+ function injectNonceIntoCsp(csp, nonce) {
5440
+ const token = `'nonce-${nonce}'`;
5441
+ const directives = [];
5442
+ for (const rawPart of csp.split(";")) {
5443
+ const part = rawPart.trim();
5444
+ if (!part) continue;
5445
+ const spaceAt = part.search(/\s/);
5446
+ const name = (spaceAt === -1 ? part : part.slice(0, spaceAt)).toLowerCase();
5447
+ const value = spaceAt === -1 ? "" : part.slice(spaceAt + 1).trim();
5448
+ directives.push([name, value]);
5449
+ }
5450
+ const defaultEntry = directives.find(([name]) => name === "default-src");
5451
+ const defaultValue = defaultEntry && defaultEntry[1] ? defaultEntry[1] : "'self'";
5452
+ for (const directive of ["style-src", "script-src"]) {
5453
+ const existing = directives.find(([name]) => name === directive);
5454
+ if (existing) {
5455
+ if (!existing[1].split(/\s+/).includes(token)) {
5456
+ existing[1] = `${existing[1]} ${token}`.trim();
5457
+ }
5458
+ } else {
5459
+ directives.push([directive, `${defaultValue} ${token}`.trim()]);
5460
+ }
5461
+ }
5462
+ return directives.map(([name, value]) => value ? `${name} ${value}` : name).join("; ");
5463
+ }
5464
+ function resolveCspHeader(nonce) {
5465
+ const csp = process.env.TINA4_CSP || "default-src 'self'";
5466
+ return injectNonceIntoCsp(csp, nonce);
5467
+ }
5468
+ var nonceStore, nonceFallback;
5469
+ var init_csp = __esm({
5470
+ "../core/src/csp.ts"() {
5471
+ "use strict";
5472
+ nonceStore = new AsyncLocalStorage2();
5473
+ }
5474
+ });
5475
+
5409
5476
  // ../frond/src/engine.ts
5410
5477
  var engine_exports = {};
5411
5478
  __export(engine_exports, {
@@ -5415,9 +5482,10 @@ __export(engine_exports, {
5415
5482
  expressionFormCache: () => expressionFormCache,
5416
5483
  filterChainCache: () => filterChainCache,
5417
5484
  pathParseCache: () => pathParseCache,
5485
+ setCspNonceProvider: () => setCspNonceProvider,
5418
5486
  setFormTokenSessionId: () => setFormTokenSessionId
5419
5487
  });
5420
- import { createHash as createHash3, createHmac as createHmac2, randomBytes as randomBytes2 } from "node:crypto";
5488
+ import { createHash as createHash3, createHmac as createHmac2, randomBytes as randomBytes3 } from "node:crypto";
5421
5489
  import { readFileSync as readFileSync4, existsSync as existsSync5, statSync as statSync4, realpathSync } from "node:fs";
5422
5490
  import { join as join5, resolve as resolve2, isAbsolute as isAbsolute2, sep } from "node:path";
5423
5491
  function jsonText(value) {
@@ -6484,6 +6552,9 @@ function _b64url(data) {
6484
6552
  function setFormTokenSessionId(sessionId) {
6485
6553
  _formTokenSessionId = sessionId || "";
6486
6554
  }
6555
+ function setCspNonceProvider(fn) {
6556
+ _cspNonceProvider = fn;
6557
+ }
6487
6558
  function _buildFormTokenJwt(descriptor = "") {
6488
6559
  const secret = process.env.TINA4_SECRET ?? "";
6489
6560
  const secretBytes = Buffer.byteLength(secret, "utf8");
@@ -6495,7 +6566,7 @@ function _buildFormTokenJwt(descriptor = "") {
6495
6566
  const ttlMinutes = parseInt(process.env.TINA4_TOKEN_LIMIT || "60", 10);
6496
6567
  const header = { alg: "HS256", typ: "JWT" };
6497
6568
  const now = Math.floor(Date.now() / 1e3);
6498
- const payload = { type: "form", nonce: randomBytes2(8).toString("hex"), iat: now, exp: now + ttlMinutes * 60 };
6569
+ const payload = { type: "form", nonce: randomBytes3(8).toString("hex"), iat: now, exp: now + ttlMinutes * 60 };
6499
6570
  if (descriptor) {
6500
6571
  if (descriptor.includes("|")) {
6501
6572
  const [ctx, ref] = descriptor.split("|", 2);
@@ -6522,7 +6593,7 @@ function _generateFormToken(descriptor = "") {
6522
6593
  function _generateFormTokenValue(descriptor = "") {
6523
6594
  return new SafeString(_buildFormTokenJwt(descriptor));
6524
6595
  }
6525
- 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;
6596
+ 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;
6526
6597
  var init_engine = __esm({
6527
6598
  "../frond/src/engine.ts"() {
6528
6599
  "use strict";
@@ -6876,6 +6947,7 @@ var init_engine = __esm({
6876
6947
  js_escape: (v) => new SafeString(jsEscape(v))
6877
6948
  };
6878
6949
  _formTokenSessionId = "";
6950
+ _cspNonceProvider = () => "";
6879
6951
  Frond = class _Frond {
6880
6952
  // ── Class-level registries ──────────────────────────────────
6881
6953
  // Persist globals, filters, and tests across hot-reloads and at
@@ -7029,6 +7101,7 @@ var init_engine = __esm({
7029
7101
  this.globals.formTokenValue = (descriptor) => _generateFormTokenValue(descriptor || "");
7030
7102
  this.globals.form_token_value = (descriptor) => _generateFormTokenValue(descriptor || "");
7031
7103
  this.globals.dump = (value) => renderDump(value);
7104
+ this.globals.csp_nonce = () => _cspNonceProvider();
7032
7105
  for (const [k, v] of _Frond.classFilters) this.filters[k] = v;
7033
7106
  for (const [k, v] of _Frond.classGlobals) this.globals[k] = v;
7034
7107
  for (const [k, v] of _Frond.classTests) this.tests[k] = v;
@@ -8292,7 +8365,8 @@ async function getFrond() {
8292
8365
  const dir = _defaultTemplatesDir ?? nodePath.resolve(process.cwd(), "src/templates");
8293
8366
  let engine = _frondCache.get(dir);
8294
8367
  if (!engine) {
8295
- const { Frond: Frond2 } = await Promise.resolve().then(() => (init_engine(), engine_exports));
8368
+ const { Frond: Frond2, setCspNonceProvider: setCspNonceProvider2 } = await Promise.resolve().then(() => (init_engine(), engine_exports));
8369
+ setCspNonceProvider2(currentCspNonce);
8296
8370
  engine = new Frond2(dir);
8297
8371
  _frondCache.set(dir, engine);
8298
8372
  }
@@ -8303,7 +8377,8 @@ async function getFrameworkFrond() {
8303
8377
  const frameworkDir = nodePath.resolve(nodePath.dirname(import.meta.url.replace("file://", "")), "..", "templates");
8304
8378
  if (!_frameworkFrond && fs2.existsSync(frameworkDir)) {
8305
8379
  try {
8306
- const { Frond: Frond2 } = await Promise.resolve().then(() => (init_engine(), engine_exports));
8380
+ const { Frond: Frond2, setCspNonceProvider: setCspNonceProvider2 } = await Promise.resolve().then(() => (init_engine(), engine_exports));
8381
+ setCspNonceProvider2(currentCspNonce);
8307
8382
  _frameworkFrond = new Frond2(frameworkDir);
8308
8383
  } catch {
8309
8384
  return null;
@@ -8403,6 +8478,7 @@ function createResponse(res) {
8403
8478
  return response;
8404
8479
  };
8405
8480
  response.raw = res;
8481
+ response.cspNonce = currentCspNonce();
8406
8482
  response.json = function(data, status2) {
8407
8483
  if (res.headersSent) return response;
8408
8484
  if (status2 !== void 0) res.statusCode = status2;
@@ -8548,7 +8624,8 @@ function createResponse(res) {
8548
8624
  };
8549
8625
  response.render = async function(templateName, data, status2, templateDir) {
8550
8626
  try {
8551
- const { Frond: Frond2 } = await Promise.resolve().then(() => (init_engine(), engine_exports));
8627
+ const { Frond: Frond2, setCspNonceProvider: setCspNonceProvider2 } = await Promise.resolve().then(() => (init_engine(), engine_exports));
8628
+ setCspNonceProvider2(currentCspNonce);
8552
8629
  const dir = templateDir ?? _defaultTemplatesDir ?? nodePath.resolve(process.cwd(), "src/templates");
8553
8630
  let engine = _frondCache.get(dir);
8554
8631
  if (!engine) {
@@ -8666,6 +8743,7 @@ var HEADER_TOKEN, _frondCache, _defaultTemplatesDir, _globalFrond, _frameworkFro
8666
8743
  var init_response = __esm({
8667
8744
  "../core/src/response.ts"() {
8668
8745
  "use strict";
8746
+ init_csp();
8669
8747
  HEADER_TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
8670
8748
  _frondCache = /* @__PURE__ */ new Map();
8671
8749
  _defaultTemplatesDir = null;
@@ -8877,6 +8955,7 @@ var init_middleware = __esm({
8877
8955
  init_dotenv();
8878
8956
  init_router();
8879
8957
  init_trustedProxy();
8958
+ init_csp();
8880
8959
  init_response();
8881
8960
  MiddlewareChain = class {
8882
8961
  middlewares = [];
@@ -9309,12 +9388,18 @@ var init_middleware = __esm({
9309
9388
  * overrides, WITHOUT Strict-Transport-Security, which depends on the
9310
9389
  * request's scheme. Also what a transport rejection carries (ADR-0068
9311
9390
  * section 4), written before the scheme is known.
9391
+ *
9392
+ * Content-Security-Policy always carries a per-response nonce in style-src
9393
+ * AND script-src (ADR-0088), read from the request async-context (minted on
9394
+ * first access), so the framework's own inline content runs under the strict
9395
+ * default without 'unsafe-inline'. The nonce is random per response, so a
9396
+ * caller comparing this header must assert its STRUCTURE, never a byte string.
9312
9397
  */
9313
9398
  static canonicalHeaders() {
9314
9399
  return {
9315
9400
  "X-Frame-Options": process.env.TINA4_FRAME_OPTIONS ?? "SAMEORIGIN",
9316
9401
  "X-Content-Type-Options": "nosniff",
9317
- "Content-Security-Policy": process.env.TINA4_CSP ?? "default-src 'self'",
9402
+ "Content-Security-Policy": resolveCspHeader(currentCspNonce()),
9318
9403
  "Referrer-Policy": process.env.TINA4_REFERRER_POLICY ?? "strict-origin-when-cross-origin",
9319
9404
  "X-XSS-Protection": "0",
9320
9405
  "Permissions-Policy": process.env.TINA4_PERMISSIONS_POLICY ?? "camera=(), microphone=(), geolocation=()"
@@ -9356,7 +9441,7 @@ var init_middleware = __esm({
9356
9441
  static warnCspDefaultOnce() {
9357
9442
  if (_SecurityHeadersMiddleware.cspDefaultWarned) return;
9358
9443
  _SecurityHeadersMiddleware.cspDefaultWarned = true;
9359
- 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'".`;
9444
+ 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'".`;
9360
9445
  try {
9361
9446
  Log.warning(message);
9362
9447
  } catch {
@@ -11749,7 +11834,7 @@ __export(session_exports, {
11749
11834
  sessionCookieName: () => sessionCookieName,
11750
11835
  sessionStrictMode: () => sessionStrictMode
11751
11836
  });
11752
- import { createHash as createHash5, randomBytes as randomBytes3 } from "node:crypto";
11837
+ import { createHash as createHash5, randomBytes as randomBytes4 } from "node:crypto";
11753
11838
  import { existsSync as existsSync8, mkdirSync as mkdirSync6, readFileSync as readFileSync6, writeFileSync as writeFileSync5, unlinkSync as unlinkSync4, readdirSync as readdirSync3 } from "node:fs";
11754
11839
  import { join as join9 } from "node:path";
11755
11840
  function isValidSessionId(sessionId) {
@@ -11767,6 +11852,20 @@ function resolveBackend(name) {
11767
11852
  }
11768
11853
  return normalised;
11769
11854
  }
11855
+ function fingerprint(value) {
11856
+ try {
11857
+ return JSON.stringify(value);
11858
+ } catch {
11859
+ return void 0;
11860
+ }
11861
+ }
11862
+ function fingerprints(data) {
11863
+ const prints = /* @__PURE__ */ new Map();
11864
+ for (const [key, value] of Object.entries(data)) {
11865
+ prints.set(key, fingerprint(value));
11866
+ }
11867
+ return prints;
11868
+ }
11770
11869
  function isSecureScheme(forwardedProto, socketEncrypted) {
11771
11870
  const forwarded = (forwardedProto ?? "").trim();
11772
11871
  if (forwarded !== "") {
@@ -11964,6 +12063,29 @@ var init_session = __esm({
11964
12063
  * Lets `start()` tell "no such session" from "the store is unreachable".
11965
12064
  */
11966
12065
  lastReadFailed = false;
12066
+ /**
12067
+ * What this request last saw of the stored record, one fingerprint per key:
12068
+ * taken by start() and again by every save. save() compares the live data
12069
+ * against it to find the keys THIS request changed.
12070
+ */
12071
+ loaded = /* @__PURE__ */ new Map();
12072
+ /**
12073
+ * True when this request is working on a record the store holds: start()
12074
+ * adopted one, or a save has written one since. A save never re-creates such
12075
+ * a record once another request has removed it.
12076
+ */
12077
+ stored = false;
12078
+ /**
12079
+ * True when clear() ran since the last save: the save then replaces the
12080
+ * stored record instead of merging into it.
12081
+ */
12082
+ cleared = false;
12083
+ /**
12084
+ * True once this request found its session ended by another request (the
12085
+ * record it loaded is gone). It stays ended for the rest of the request:
12086
+ * regenerate() mints nothing either.
12087
+ */
12088
+ ended = false;
11967
12089
  constructor(backend, config) {
11968
12090
  const backendType = resolveBackend(
11969
12091
  backend ?? config?.backend ?? process.env.TINA4_SESSION_BACKEND ?? "file"
@@ -12108,6 +12230,10 @@ var init_session = __esm({
12108
12230
  const now2 = Math.floor(Date.now() / 1e3);
12109
12231
  this.data = { _created: now2, _accessed: now2 };
12110
12232
  this.dirty = false;
12233
+ this.stored = false;
12234
+ this.loaded = /* @__PURE__ */ new Map();
12235
+ this.cleared = false;
12236
+ this.ended = false;
12111
12237
  return sessionId;
12112
12238
  }
12113
12239
  if (loaded) {
@@ -12117,14 +12243,22 @@ var init_session = __esm({
12117
12243
  this.data._accessed = now2;
12118
12244
  this.dirty = false;
12119
12245
  this.safeWrite(this.sessionId, this.data, this.ttl);
12246
+ this.stored = true;
12247
+ this.loaded = fingerprints(this.data);
12248
+ this.cleared = false;
12249
+ this.ended = false;
12120
12250
  return sessionId;
12121
12251
  }
12122
12252
  }
12123
- this.sessionId = randomBytes3(16).toString("hex");
12253
+ this.sessionId = randomBytes4(16).toString("hex");
12124
12254
  const now = Math.floor(Date.now() / 1e3);
12125
12255
  this.data = { _created: now, _accessed: now };
12126
12256
  this.dirty = false;
12127
12257
  this.safeWrite(this.sessionId, this.data, this.ttl);
12258
+ this.stored = false;
12259
+ this.loaded = /* @__PURE__ */ new Map();
12260
+ this.cleared = false;
12261
+ this.ended = false;
12128
12262
  return this.sessionId;
12129
12263
  }
12130
12264
  /**
@@ -12165,9 +12299,7 @@ var init_session = __esm({
12165
12299
  if (this.sessionId) {
12166
12300
  this.safeDestroy(this.sessionId);
12167
12301
  }
12168
- this.sessionId = null;
12169
- this.data = null;
12170
- this.dirty = false;
12302
+ this.forget();
12171
12303
  }
12172
12304
  /**
12173
12305
  * Get all session data (excluding internal keys).
@@ -12190,6 +12322,7 @@ var init_session = __esm({
12190
12322
  const now = Math.floor(Date.now() / 1e3);
12191
12323
  this.data = { _created: this.data._created, _accessed: now };
12192
12324
  this.dirty = true;
12325
+ this.cleared = true;
12193
12326
  this.save();
12194
12327
  }
12195
12328
  /**
@@ -12206,17 +12339,36 @@ var init_session = __esm({
12206
12339
  * session fixation — the pre-auth ID is destroyed and the data is carried
12207
12340
  * onto a fresh, unguessable ID. A backend destroy/write failure is logged
12208
12341
  * (never silent) but does not throw under the default policy.
12342
+ *
12343
+ * What is carried is the session as it is stored now with this request's
12344
+ * own changes applied, the same merge save() does. If another request ended
12345
+ * the session after this one loaded it (a logout, or a regenerate of its
12346
+ * own), it stays ended: nothing is carried, no id is minted and null is
12347
+ * returned, so no cookie goes out to replace the one that request sent.
12209
12348
  */
12210
12349
  regenerate() {
12350
+ if (this.ended) return null;
12351
+ if (this.sessionId && this.stored && this.data) {
12352
+ const current = this.safeRead(this.sessionId);
12353
+ if (!this.lastReadFailed) {
12354
+ if (!current) {
12355
+ this.forget();
12356
+ this.ended = true;
12357
+ return null;
12358
+ }
12359
+ if (!this.cleared) this.data = this.merged(current);
12360
+ }
12361
+ }
12211
12362
  const oldId = this.sessionId;
12212
12363
  const oldData = this.data;
12213
12364
  if (oldId) {
12214
12365
  this.safeDestroy(oldId);
12215
12366
  }
12216
- this.sessionId = randomBytes3(16).toString("hex");
12367
+ this.sessionId = randomBytes4(16).toString("hex");
12217
12368
  this.data = oldData ?? { _created: Math.floor(Date.now() / 1e3), _accessed: Math.floor(Date.now() / 1e3) };
12218
12369
  this.data._accessed = Math.floor(Date.now() / 1e3);
12219
12370
  this.dirty = true;
12371
+ this.stored = false;
12220
12372
  this.save();
12221
12373
  return this.sessionId;
12222
12374
  }
@@ -12292,17 +12444,84 @@ var init_session = __esm({
12292
12444
  *
12293
12445
  * Returns true on a successful persist, false if the backend was unreachable
12294
12446
  * (logged). The dirty flag is cleared only on success so a later save()
12295
- * retries once the backend recovers. A nothing-to-persist call returns true.
12447
+ * retries once the backend recovers. A call with no session returns true.
12448
+ *
12449
+ * Other requests on the same session run while this one does, so the save
12450
+ * writes only THIS request's changes, onto the record as it is stored now.
12451
+ * Writing back the whole snapshot start() loaded undid whatever another
12452
+ * request did in between: a logout (destroy, clear, regenerate) or a
12453
+ * privilege change made with set(). A record removed since this request
12454
+ * loaded it is never re-created: the session has ended, for this request
12455
+ * too. A request that has not loaded a stored record (a new session, the new
12456
+ * id regenerate() mints) writes its data whole, as before.
12457
+ *
12458
+ * A session the store already holds is re-written even when this request
12459
+ * changed nothing: that write re-stamps the backend deadline to
12460
+ * now + TINA4_SESSION_TTL, so a session expires after that span of INACTIVITY
12461
+ * rather than that long after its last CHANGE (ADR-0087 — a session's expiry
12462
+ * slides on activity). The re-write is the re-read, merged record, so sliding
12463
+ * the deadline is concurrent-safe: it never clobbers another request's change,
12464
+ * never re-creates an ended record, and TINA4_SESSION_TTL=0 still re-stamps a
12465
+ * never-expires deadline (ADR-0027). A request with no stored session and no
12466
+ * change writes nothing — there is no deadline to move.
12296
12467
  */
12297
12468
  save() {
12298
12469
  if (!this.sessionId || !this.data) return true;
12299
- if (!this.dirty) return true;
12300
- if (this.safeWrite(this.sessionId, this.data, this.ttl)) {
12470
+ if (!this.dirty && !this.stored) return true;
12471
+ let record = this.data;
12472
+ if (this.stored) {
12473
+ const current = this.safeRead(this.sessionId);
12474
+ if (this.lastReadFailed) return false;
12475
+ if (!current) {
12476
+ this.forget();
12477
+ this.ended = true;
12478
+ return true;
12479
+ }
12480
+ if (!this.cleared) record = this.merged(current);
12481
+ }
12482
+ if (this.safeWrite(this.sessionId, record, this.ttl)) {
12301
12483
  this.dirty = false;
12484
+ this.stored = true;
12485
+ this.cleared = false;
12486
+ this.loaded = fingerprints(this.data);
12302
12487
  return true;
12303
12488
  }
12304
12489
  return false;
12305
12490
  }
12491
+ /**
12492
+ * The stored record with this request's changes applied: every key whose
12493
+ * value differs from what this request loaded or last saved, and the
12494
+ * removal of every key it deleted. Keys it did not touch keep whatever is
12495
+ * stored now.
12496
+ */
12497
+ merged(current) {
12498
+ const data = this.data;
12499
+ const record = { ...current };
12500
+ for (const [key, value] of Object.entries(data)) {
12501
+ const print = fingerprint(value);
12502
+ if (print === void 0 || !this.loaded.has(key) || this.loaded.get(key) !== print) {
12503
+ record[key] = value;
12504
+ }
12505
+ }
12506
+ for (const key of this.loaded.keys()) {
12507
+ if (!Object.prototype.hasOwnProperty.call(data, key)) {
12508
+ delete record[key];
12509
+ }
12510
+ }
12511
+ return record;
12512
+ }
12513
+ /**
12514
+ * End the session for this request without touching the store: no id and
12515
+ * no data, so nothing more is written and no cookie goes out for it.
12516
+ */
12517
+ forget() {
12518
+ this.sessionId = null;
12519
+ this.data = null;
12520
+ this.dirty = false;
12521
+ this.stored = false;
12522
+ this.cleared = false;
12523
+ this.loaded = /* @__PURE__ */ new Map();
12524
+ }
12306
12525
  };
12307
12526
  }
12308
12527
  });
@@ -12671,11 +12890,11 @@ function formatSourceBlock(filename, lineno) {
12671
12890
  const lines = readSourceLines(filename, lineno);
12672
12891
  if (lines.length === 0) return "";
12673
12892
  const rows = lines.map(([num, text, isError]) => {
12674
- const bg = isError ? `background:${ERROR_LINE_BG};` : "";
12893
+ const rowClass = isError ? "eo-line eo-line-err" : "eo-line";
12675
12894
  const marker = isError ? "&#x25b6;" : " ";
12676
- 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>`;
12895
+ return `<div class="${rowClass}"><span class="eo-ln">${num}</span><span class="eo-marker">${marker}</span><span class="eo-code">${esc(text)}</span></div>`;
12677
12896
  }).join("\n");
12678
- 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>`;
12897
+ return `<div class="eo-source">` + rows + `</div>`;
12679
12898
  }
12680
12899
  function formatFrame(frame, capturedAt = 0) {
12681
12900
  const source = frame.file && frame.line > 0 ? formatSourceBlock(frame.file, frame.line) : "";
@@ -12687,23 +12906,23 @@ function formatFrame(frame, capturedAt = 0) {
12687
12906
  if (mtime > capturedAt + 0.5) {
12688
12907
  const d = new Date(mtime * 1e3);
12689
12908
  const mtimeIso = `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}:${String(d.getUTCSeconds()).padStart(2, "0")}`;
12690
- 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>`;
12909
+ staleBadge = ` <span class="eo-stale">FILE MODIFIED @ ${mtimeIso} UTC &mdash; source may not match what failed</span>`;
12691
12910
  }
12692
12911
  } catch {
12693
12912
  }
12694
12913
  }
12695
- 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>`;
12914
+ 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>`;
12696
12915
  }
12697
12916
  function collapsible(title, content, openByDefault = false) {
12698
12917
  const open = openByDefault ? " open" : "";
12699
- 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>`;
12918
+ return `<details class="eo-details"${open}><summary class="eo-summary">${esc(title)}</summary><div class="eo-details-body">${content}</div></details>`;
12700
12919
  }
12701
12920
  function table(pairs) {
12702
- if (pairs.length === 0) return `<span style="color:${SUBTEXT};">None</span>`;
12921
+ if (pairs.length === 0) return `<span class="eo-none">None</span>`;
12703
12922
  const rows = pairs.map(
12704
- ([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>`
12923
+ ([key, val]) => `<tr><td class="eo-key">${esc(key)}</td><td class="eo-val">${esc(val)}</td></tr>`
12705
12924
  ).join("");
12706
- return `<table style="border-collapse:collapse;width:100%;">${rows}</table>`;
12925
+ return `<table class="eo-table">${rows}</table>`;
12707
12926
  }
12708
12927
  function renderErrorOverlay(error2, request) {
12709
12928
  const capturedAt = Date.now() / 1e3;
@@ -12716,7 +12935,7 @@ function renderErrorOverlay(error2, request) {
12716
12935
  }
12717
12936
  const hidden = frames.length - Math.min(frames.length, MAX_FRAMES);
12718
12937
  if (hidden > 0) {
12719
- framesHtml += `<div style="color:${SUBTEXT};padding:8px 0;font-size:13px;">&#8230; ${hidden} more stack frames hidden (truncated at ${MAX_FRAMES})</div>`;
12938
+ framesHtml += `<div class="eo-hidden-frames">&#8230; ${hidden} more stack frames hidden (truncated at ${MAX_FRAMES})</div>`;
12720
12939
  }
12721
12940
  const requestPairs = [];
12722
12941
  if (request != null) {
@@ -12753,37 +12972,72 @@ function renderErrorOverlay(error2, request) {
12753
12972
  ];
12754
12973
  const envSection = collapsible("Environment", table(envPairs));
12755
12974
  const stackSection = collapsible("Stack Trace", framesHtml, true);
12975
+ const nonce = currentCspNonce();
12756
12976
  return `<!DOCTYPE html>
12757
12977
  <html lang="en">
12758
12978
  <head>
12759
12979
  <meta charset="utf-8">
12760
12980
  <meta name="viewport" content="width=device-width,initial-scale=1">
12761
12981
  <title>Tina4 Error \u2014 ${esc(excType)}</title>
12762
- <style>
12763
- *{margin:0;padding:0;box-sizing:border-box;}
12764
- body{background:${BG};color:${TEXT};font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;padding:24px;line-height:1.5;}
12982
+ <style nonce="${nonce}">
12983
+ ${overlayStylesheet()}
12765
12984
  </style>
12766
12985
  </head>
12767
12986
  <body>
12768
- <div style="max-width:960px;margin:0 auto;">
12769
- <div style="margin-bottom:24px;">
12770
- <div style="display:flex;align-items:center;gap:12px;margin-bottom:12px;">
12771
- <span style="background:${RED};color:${BG};padding:4px 12px;border-radius:4px;font-weight:700;font-size:13px;text-transform:uppercase;">Error</span>
12772
- <span style="color:${SUBTEXT};font-size:14px;">Tina4 Debug Overlay</span>
12987
+ <div class="eo-wrap">
12988
+ <div class="eo-header">
12989
+ <div class="eo-badge-row">
12990
+ <span class="eo-badge">Error</span>
12991
+ <span class="eo-sub">Tina4 Debug Overlay</span>
12773
12992
  </div>
12774
- <h1 style="color:${RED};font-size:28px;font-weight:700;margin-bottom:8px;">${esc(excType)}</h1>
12775
- <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>
12993
+ <h1 class="eo-type">${esc(excType)}</h1>
12994
+ <p class="eo-msg">${esc(excMsg)}</p>
12776
12995
  </div>
12777
12996
  ${stackSection}
12778
12997
  ${requestSection}
12779
12998
  ${envSection}
12780
- <div style="margin-top:32px;padding-top:16px;border-top:1px solid ${OVERLAY};color:${SUBTEXT};font-size:12px;">
12999
+ <div class="eo-footer">
12781
13000
  Tina4 Debug Overlay &mdash; This page is only shown in debug mode. Set TINA4_DEBUG=false in production.
12782
13001
  </div>
12783
13002
  </div>
12784
13003
  </body>
12785
13004
  </html>`;
12786
13005
  }
13006
+ function overlayStylesheet() {
13007
+ return `
13008
+ *{margin:0;padding:0;box-sizing:border-box;}
13009
+ body{background:${BG};color:${TEXT};font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;padding:24px;line-height:1.5;}
13010
+ .eo-wrap{max-width:960px;margin:0 auto;}
13011
+ .eo-header{margin-bottom:24px;}
13012
+ .eo-badge-row{display:flex;align-items:center;gap:12px;margin-bottom:12px;}
13013
+ .eo-badge{background:${RED};color:${BG};padding:4px 12px;border-radius:4px;font-weight:700;font-size:13px;text-transform:uppercase;}
13014
+ .eo-sub{color:${SUBTEXT};font-size:14px;}
13015
+ .eo-type{color:${RED};font-size:28px;font-weight:700;margin-bottom:8px;}
13016
+ .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};}
13017
+ .eo-footer{margin-top:32px;padding-top:16px;border-top:1px solid ${OVERLAY};color:${SUBTEXT};font-size:12px;}
13018
+ .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;}
13019
+ .eo-line{display:flex;padding:1px 0;}
13020
+ .eo-line-err{background:${ERROR_LINE_BG};}
13021
+ .eo-ln{color:${YELLOW};min-width:3.5em;text-align:right;padding-right:1em;user-select:none;}
13022
+ .eo-marker{color:${RED};width:1.2em;user-select:none;}
13023
+ .eo-code{color:${TEXT};white-space:pre-wrap;tab-size:4;}
13024
+ .eo-frame{margin-bottom:16px;}
13025
+ .eo-frame-head{margin-bottom:4px;}
13026
+ .eo-file{color:${BLUE};}
13027
+ .eo-sep{color:${SUBTEXT};}
13028
+ .eo-lineno{color:${YELLOW};}
13029
+ .eo-fn{color:${GREEN};}
13030
+ .eo-stale{background:${PEACH};color:${BG};padding:1px 8px;border-radius:3px;font-size:11px;font-weight:700;margin-left:6px;}
13031
+ .eo-details{margin-top:16px;}
13032
+ .eo-summary{cursor:pointer;color:${LAVENDER};font-weight:600;font-size:15px;padding:8px 0;user-select:none;}
13033
+ .eo-details-body{padding:8px 0;}
13034
+ .eo-table{border-collapse:collapse;width:100%;}
13035
+ .eo-key{color:${PEACH};padding:4px 16px 4px 0;vertical-align:top;white-space:nowrap;}
13036
+ .eo-val{color:${TEXT};padding:4px 0;word-break:break-all;}
13037
+ .eo-none{color:${SUBTEXT};}
13038
+ .eo-hidden-frames{color:${SUBTEXT};padding:8px 0;font-size:13px;}
13039
+ `;
13040
+ }
12787
13041
  function isDebugMode() {
12788
13042
  return isTruthy(process.env.TINA4_DEBUG);
12789
13043
  }
@@ -12792,6 +13046,7 @@ var init_errorOverlay = __esm({
12792
13046
  "../core/src/errorOverlay.ts"() {
12793
13047
  "use strict";
12794
13048
  init_dotenv();
13049
+ init_csp();
12795
13050
  MAX_FRAMES = 50;
12796
13051
  SENSITIVE_KEY_RE = /password|passwd|secret|token|authorization|cookie|key/i;
12797
13052
  REDACTED2 = "[redacted]";
@@ -14642,7 +14897,11 @@ export default async function (req: Tina4Request, res: Tina4Response) {
14642
14897
  `{% extends "base.twig" %}
14643
14898
  {% block title %}Login{% endblock %}
14644
14899
  {% block content %}
14645
- <div class="container mt-4" style="max-width:400px">
14900
+ {# No inline style attribute: a nonce lets this <style> block run under the
14901
+ strict default Content-Security-Policy. csp_nonce() is a built-in Frond global
14902
+ that returns this response's nonce (ADR-0088). #}
14903
+ <style nonce="{{ csp_nonce() }}">.auth-form{max-width:400px;margin:0 auto}</style>
14904
+ <div class="container mt-4 auth-form">
14646
14905
  <h1>Login</h1>
14647
14906
  <form method="post" action="/api/auth/login">
14648
14907
  {{ form_token() }}
@@ -14666,7 +14925,8 @@ export default async function (req: Tina4Request, res: Tina4Response) {
14666
14925
  `{% extends "base.twig" %}
14667
14926
  {% block title %}Register{% endblock %}
14668
14927
  {% block content %}
14669
- <div class="container mt-4" style="max-width:400px">
14928
+ <style nonce="{{ csp_nonce() }}">.auth-form{max-width:400px;margin:0 auto}</style>
14929
+ <div class="container mt-4 auth-form">
14670
14930
  <h1>Register</h1>
14671
14931
  <form method="post" action="/api/auth/register">
14672
14932
  {{ form_token() }}
@@ -15603,7 +15863,7 @@ import * as path3 from "node:path";
15603
15863
  import * as os from "node:os";
15604
15864
  import { spawnSync as spawnSync2 } from "node:child_process";
15605
15865
  import { createRequire as createRequire2 } from "node:module";
15606
- import { randomBytes as randomBytes4 } from "node:crypto";
15866
+ import { randomBytes as randomBytes5 } from "node:crypto";
15607
15867
  function reqSibling(pkg) {
15608
15868
  return req(`../../${pkg}/src/index.ts`);
15609
15869
  }
@@ -16925,7 +17185,7 @@ var init_mcp = __esm({
16925
17185
  // ── Session lifecycle + protocol negotiation ──────────────────
16926
17186
  /** Mint a new session id and remember it. Called on `initialize`. */
16927
17187
  openSession() {
16928
- const sid = randomBytes4(16).toString("hex");
17188
+ const sid = randomBytes5(16).toString("hex");
16929
17189
  this._sessions.set(sid, Date.now());
16930
17190
  return sid;
16931
17191
  }
@@ -24701,8 +24961,8 @@ var init_devAdmin = __esm({
24701
24961
  * Duplicate errors (same message) increment count and update last_seen.
24702
24962
  */
24703
24963
  static capture(errorType, message, traceback = "", file = "", line = 0) {
24704
- const fingerprint = `${errorType}|${message}|${file}|${line}`;
24705
- const existing = this.errors.find((e) => e.fingerprint === fingerprint);
24964
+ const fingerprint2 = `${errorType}|${message}|${file}|${line}`;
24965
+ const existing = this.errors.find((e) => e.fingerprint === fingerprint2);
24706
24966
  const now = (/* @__PURE__ */ new Date()).toISOString();
24707
24967
  if (existing) {
24708
24968
  existing.count = (existing.count || 1) + 1;
@@ -24715,7 +24975,7 @@ var init_devAdmin = __esm({
24715
24975
  message,
24716
24976
  stack: traceback || void 0,
24717
24977
  resolved: false,
24718
- ...{ fingerprint, error_type: errorType, file, line, count: 1, first_seen: now, last_seen: now }
24978
+ ...{ fingerprint: fingerprint2, error_type: errorType, file, line, count: 1, first_seen: now, last_seen: now }
24719
24979
  });
24720
24980
  if (this.errors.length > this.maxErrors) {
24721
24981
  this.errors = this.errors.slice(-this.maxErrors);
@@ -27394,7 +27654,7 @@ function swaggerEnabled() {
27394
27654
  }
27395
27655
  function createSwaggerRoutes(getSpec) {
27396
27656
  const serveUi = async (_req, res) => {
27397
- res.html(SWAGGER_UI_HTML("/swagger/openapi.json"));
27657
+ res.html(SWAGGER_UI_HTML("/swagger/openapi.json", res.cspNonce));
27398
27658
  };
27399
27659
  return [
27400
27660
  {
@@ -27438,13 +27698,13 @@ var SWAGGER_UI_HTML;
27438
27698
  var init_ui = __esm({
27439
27699
  "../swagger/src/ui.ts"() {
27440
27700
  "use strict";
27441
- SWAGGER_UI_HTML = (specUrl) => `<!DOCTYPE html>
27701
+ SWAGGER_UI_HTML = (specUrl, nonce) => `<!DOCTYPE html>
27442
27702
  <html lang="en">
27443
27703
  <head>
27444
27704
  <meta charset="UTF-8">
27445
27705
  <title>Tina4 API Documentation</title>
27446
27706
  <link rel="stylesheet" href="${swaggerUiCdn()}/swagger-ui.css">
27447
- <style>
27707
+ <style nonce="${nonce}">
27448
27708
  body { margin: 0; background: #fafafa; }
27449
27709
  .topbar { display: none !important; }
27450
27710
  </style>
@@ -27452,7 +27712,7 @@ var init_ui = __esm({
27452
27712
  <body>
27453
27713
  <div id="swagger-ui"></div>
27454
27714
  <script src="${swaggerUiCdn()}/swagger-ui-bundle.js"></script>
27455
- <script>
27715
+ <script nonce="${nonce}">
27456
27716
  SwaggerUIBundle({
27457
27717
  url: "${specUrl}",
27458
27718
  dom_id: '#swagger-ui',
@@ -27492,7 +27752,7 @@ __export(sso_exports, {
27492
27752
  SsoError: () => SsoError,
27493
27753
  liveSessionIdentity: () => liveSessionIdentity
27494
27754
  });
27495
- import { createHash as createHash9, randomBytes as randomBytes5, timingSafeEqual as timingSafeEqual3 } from "node:crypto";
27755
+ import { createHash as createHash9, randomBytes as randomBytes6, timingSafeEqual as timingSafeEqual3 } from "node:crypto";
27496
27756
  function expiresAt(tokens) {
27497
27757
  const lifetime = Number(tokens.expires_in ?? 0);
27498
27758
  return lifetime > 0 ? Math.floor(Date.now() / 1e3) + lifetime : 0;
@@ -27620,9 +27880,9 @@ var init_sso = __esm({
27620
27880
  async login(requestOrSession, returnTo = "/") {
27621
27881
  const session = this.session(requestOrSession);
27622
27882
  if (!session) throw new SsoError("SSO login requires a Tina4 Session");
27623
- const state = randomBytes5(32).toString("base64url");
27624
- const nonce = randomBytes5(32).toString("base64url");
27625
- const verifier = randomBytes5(64).toString("base64url");
27883
+ const state = randomBytes6(32).toString("base64url");
27884
+ const nonce = randomBytes6(32).toString("base64url");
27885
+ const verifier = randomBytes6(64).toString("base64url");
27626
27886
  const challenge = createHash9("sha256").update(verifier).digest("base64url");
27627
27887
  session.set(_Sso.PENDING_KEY, { state, nonce, verifier, return_to: _Sso.safeReturn(returnTo), created_at: Math.floor(Date.now() / 1e3) });
27628
27888
  const metadata = await this.discover();
@@ -27850,7 +28110,7 @@ var init_docsAutoDiscovery = __esm({
27850
28110
  });
27851
28111
 
27852
28112
  // ../core/src/server.ts
27853
- import { randomBytes as randomBytes6 } from "node:crypto";
28113
+ import { randomBytes as randomBytes7 } from "node:crypto";
27854
28114
  import { resolve as resolve15, dirname as dirname11, join as join25, relative as relative10 } from "node:path";
27855
28115
  import { existsSync as existsSync23, readdirSync as readdirSync14, readFileSync as readFileSync21, statSync as statSync16 } from "node:fs";
27856
28116
  import { isatty } from "node:tty";
@@ -28030,7 +28290,8 @@ function isDevMode() {
28030
28290
  }
28031
28291
  async function renderErrorPage(code, data, templatesDir) {
28032
28292
  try {
28033
- const { Frond: Frond2 } = await Promise.resolve().then(() => (init_engine(), engine_exports));
28293
+ const { Frond: Frond2, setCspNonceProvider: setCspNonceProvider2 } = await Promise.resolve().then(() => (init_engine(), engine_exports));
28294
+ setCspNonceProvider2(currentCspNonce);
28034
28295
  const templateFile = `errors/${code}.twig`;
28035
28296
  const getCachedFrond = (dir) => {
28036
28297
  let instance = frondCache.get(dir);
@@ -28146,6 +28407,7 @@ function resolveTemplate(pathname, templatesDir) {
28146
28407
  }
28147
28408
  function renderLandingPage(port = 7148) {
28148
28409
  const version = TINA4_VERSION;
28410
+ const nonce = currentCspNonce();
28149
28411
  const galleryItems = [
28150
28412
  { 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" },
28151
28413
  { id: "orm", icon: "&#128451;", name: "ORM", desc: "Product model with CRUD endpoints", accent: "accent-green", tryUrl: "/api/gallery/products" },
@@ -28160,7 +28422,7 @@ function renderLandingPage(port = 7148) {
28160
28422
  const isDeployed = deployed[item.id] === true;
28161
28423
  const tryBtn = isDeployed ? `<a href="${item.tryUrl}" class="gbtn gbtn-try" target="_blank">Try It</a>` : "";
28162
28424
  const viewBtn = isDeployed ? `<a href="${item.tryUrl}" class="gbtn gbtn-view" target="_blank">View</a>` : "";
28163
- const deployBtn = isDeployed ? `<span class="gbtn gbtn-deployed">Deployed</span>` : `<button class="gbtn gbtn-deploy" onclick="deployGallery('${item.id}')">Deploy</button>`;
28425
+ const deployBtn = isDeployed ? `<span class="gbtn gbtn-deployed">Deployed</span>` : `<button class="gbtn gbtn-deploy" data-deploy="${item.id}">Deploy</button>`;
28164
28426
  return `<div class="gallery-card">
28165
28427
  <div class="accent ${item.accent}"></div>
28166
28428
  <div class="icon">${item.icon}</div>
@@ -28175,7 +28437,7 @@ function renderLandingPage(port = 7148) {
28175
28437
  <meta charset="utf-8">
28176
28438
  <meta name="viewport" content="width=device-width, initial-scale=1">
28177
28439
  <title>Tina4NodeJs</title>
28178
- <style>
28440
+ <style nonce="${nonce}">
28179
28441
  *{margin:0;padding:0;box-sizing:border-box}
28180
28442
  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}
28181
28443
  .bg-watermark{position:fixed;bottom:-5%;right:-5%;width:45%;opacity:0.04;pointer-events:none;z-index:0}
@@ -28214,6 +28476,11 @@ h1{font-size:3rem;font-weight:700;margin-bottom:0.25rem;letter-spacing:-1px}
28214
28476
  .gbtn-deployed{background:transparent;border:1px solid #22c55e;color:#22c55e;cursor:default;font-size:0.7rem}
28215
28477
  @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)}}
28216
28478
  .star-wiggle{display:inline-block;transform-origin:center}
28479
+ .gallery-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:1rem}
28480
+ .tok-comment{color:#64748b}
28481
+ .tok-kw{color:#c084fc}
28482
+ .tok-str{color:#4ade80}
28483
+ .tok-fn{color:#38bdf8}
28217
28484
  </style>
28218
28485
  </head>
28219
28486
  <body>
@@ -28239,23 +28506,23 @@ h1{font-size:3rem;font-weight:700;margin-bottom:0.25rem;letter-spacing:-1px}
28239
28506
  <div class="section">
28240
28507
  <div class="card">
28241
28508
  <h2>Getting Started</h2>
28242
- <pre class="code-block"><code><span style="color:#64748b">// app.ts</span>
28243
- <span style="color:#c084fc">import</span> { startServer, Router } <span style="color:#c084fc">from</span> <span style="color:#4ade80">"tina4-nodejs"</span>;
28509
+ <pre class="code-block"><code><span class="tok-comment">// app.ts</span>
28510
+ <span class="tok-kw">import</span> { startServer, Router } <span class="tok-kw">from</span> <span class="tok-str">"tina4-nodejs"</span>;
28244
28511
 
28245
- 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; {
28246
- <span style="color:#c084fc">return</span> res.json({ message: <span style="color:#4ade80">"Hello World!"</span> });
28512
+ 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; {
28513
+ <span class="tok-kw">return</span> res.json({ message: <span class="tok-str">"Hello World!"</span> });
28247
28514
  });
28248
28515
 
28249
- startServer({ port: 7148 }); <span style="color:#64748b">// starts on port 7148</span></code></pre>
28516
+ startServer({ port: 7148 }); <span class="tok-comment">// starts on port 7148</span></code></pre>
28250
28517
  </div>
28251
28518
  </div>
28252
28519
  <div class="gallery">
28253
28520
  <h2 id="gallery">What You Can Build</h2>
28254
- <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:1rem;">
28521
+ <div class="gallery-cards">
28255
28522
  ${galleryCards}
28256
28523
  </div>
28257
28524
  </div>
28258
- <script>
28525
+ <script nonce="${nonce}">
28259
28526
  function deployGallery(name) {
28260
28527
  if (!confirm('Deploy the "' + name + '" gallery example into your project? This will copy files into src/.')) return;
28261
28528
  fetch('/__dev/api/gallery/deploy', {
@@ -28290,6 +28557,12 @@ function deployGallery(name) {
28290
28557
  }
28291
28558
  setTimeout(doWiggle,3000);
28292
28559
  })();
28560
+ // CSP-clean wiring: no inline onclick handlers \u2014 bind every Deploy button from
28561
+ // its data-deploy attribute (a nonce covers script elements, not event-handler
28562
+ // attributes).
28563
+ document.querySelectorAll('.gbtn-deploy[data-deploy]').forEach(function(btn){
28564
+ btn.addEventListener('click', function(){ deployGallery(btn.dataset.deploy); });
28565
+ });
28293
28566
  </script>
28294
28567
  </body>
28295
28568
  </html>`;
@@ -28506,7 +28779,7 @@ async function renderDispatchError(err, req2, res, templatesDir) {
28506
28779
  }
28507
28780
  }
28508
28781
  }
28509
- const requestId = Log.getRequestId() ?? randomBytes6(4).toString("hex");
28782
+ const requestId = Log.getRequestId() ?? randomBytes7(4).toString("hex");
28510
28783
  if (wantsJson(req2)) {
28511
28784
  const body = negotiatedErrorBody(500, "Internal Server Error", requestId);
28512
28785
  res.raw.writeHead(500, { "Content-Type": "application/json" });
@@ -28577,7 +28850,7 @@ function serveStaticAsset(ctx) {
28577
28850
  return false;
28578
28851
  }
28579
28852
  async function serveNotFound(ctx) {
28580
- const requestId = Log.getRequestId() ?? randomBytes6(4).toString("hex");
28853
+ const requestId = Log.getRequestId() ?? randomBytes7(4).toString("hex");
28581
28854
  if (wantsJson(ctx.req)) {
28582
28855
  const body = negotiatedErrorBody(404, "Not Found", requestId);
28583
28856
  ctx.res.raw.writeHead(404, httpReason(404), { "Content-Type": "application/json" });
@@ -28600,7 +28873,8 @@ async function buildDispatchContext(router, base) {
28600
28873
  const templatesDir = resolve15(root, "src/templates");
28601
28874
  let frondEngine = null;
28602
28875
  try {
28603
- const { Frond: Frond2 } = await Promise.resolve().then(() => (init_engine(), engine_exports));
28876
+ const { Frond: Frond2, setCspNonceProvider: setCspNonceProvider2 } = await Promise.resolve().then(() => (init_engine(), engine_exports));
28877
+ setCspNonceProvider2(currentCspNonce);
28604
28878
  frondEngine = new Frond2(templatesDir);
28605
28879
  } catch {
28606
28880
  }
@@ -28710,9 +28984,13 @@ async function dispatchInner(ctx, rawReq, rawRes, requestId) {
28710
28984
  }
28711
28985
  }
28712
28986
  async function runDispatch(ctx, rawReq, rawRes) {
28713
- const requestId = Log.sanitizeRequestId(rawReq.headers["x-request-id"]) ?? randomBytes6(4).toString("hex");
28987
+ const requestId = Log.sanitizeRequestId(rawReq.headers["x-request-id"]) ?? randomBytes7(4).toString("hex");
28714
28988
  if (!rawRes.headersSent) rawRes.setHeader("x-request-id", requestId);
28715
- return Log.runWithRequestId(requestId, () => dispatchInner(ctx, rawReq, rawRes, requestId));
28989
+ const requestNonce = generateNonce();
28990
+ return Log.runWithRequestId(
28991
+ requestId,
28992
+ () => runWithCspNonce(requestNonce, () => dispatchInner(ctx, rawReq, rawRes, requestId))
28993
+ );
28716
28994
  }
28717
28995
  function loopbackBindHosts(host) {
28718
28996
  const normalized = host.trim().replace(/^\[|\]$/g, "").toLowerCase();
@@ -28839,7 +29117,8 @@ async function startServer(config) {
28839
29117
  let frondEngine = null;
28840
29118
  setDefaultTemplatesDir(templatesDir);
28841
29119
  try {
28842
- const { Frond: Frond2 } = await Promise.resolve().then(() => (init_engine(), engine_exports));
29120
+ const { Frond: Frond2, setCspNonceProvider: setCspNonceProvider2 } = await Promise.resolve().then(() => (init_engine(), engine_exports));
29121
+ setCspNonceProvider2(currentCspNonce);
28843
29122
  frondEngine = new Frond2(templatesDir);
28844
29123
  router.addRoute({
28845
29124
  method: "GET",
@@ -28980,10 +29259,12 @@ async function startServer(config) {
28980
29259
  }
28981
29260
  });
28982
29261
  aiServer.on("error", (err) => {
28983
- if (err.code === "EADDRINUSE") {
29262
+ if (err?.code === "EADDRINUSE") {
28984
29263
  Log.warning(`Test port ${testPort} in use \u2014 skipping`);
28985
- aiServer = null;
29264
+ } else {
29265
+ Log.error(`Test port ${testPort} bind failed (${err?.code ?? err}) \u2014 skipping`);
28986
29266
  }
29267
+ aiServer = null;
28987
29268
  });
28988
29269
  aiServer.listen(testPort, host);
28989
29270
  }
@@ -29198,6 +29479,7 @@ var init_server = __esm({
29198
29479
  init_static();
29199
29480
  init_dotenv();
29200
29481
  init_errorOverlay();
29482
+ init_csp();
29201
29483
  init_health();
29202
29484
  init_rateLimiter();
29203
29485
  init_logger();
@@ -29978,7 +30260,7 @@ var init_mqttMessage = __esm({
29978
30260
  // ../core/src/mqtt.ts
29979
30261
  import net2 from "node:net";
29980
30262
  import tls from "node:tls";
29981
- import { randomBytes as randomBytes7 } from "node:crypto";
30263
+ import { randomBytes as randomBytes8 } from "node:crypto";
29982
30264
  import { existsSync as existsSync25, readFileSync as readFileSync22 } from "node:fs";
29983
30265
  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;
29984
30266
  var init_mqtt = __esm({
@@ -30066,7 +30348,7 @@ var init_mqtt = __esm({
30066
30348
  this.caFile = options.caFile ?? (Env.str("TINA4_MQTT_CA_FILE") || null);
30067
30349
  this.tlsVerify = options.tlsVerify ?? Env.bool("TINA4_MQTT_TLS_VERIFY", true);
30068
30350
  let cid = options.clientId ?? (Env.str("TINA4_MQTT_CLIENT_ID") || null);
30069
- if (cid === null || cid === "") cid = "tina4-" + randomBytes7(8).toString("hex");
30351
+ if (cid === null || cid === "") cid = "tina4-" + randomBytes8(8).toString("hex");
30070
30352
  this.clientId = cid;
30071
30353
  this.keepalive = options.keepalive ?? Env.int("TINA4_MQTT_KEEPALIVE", DEFAULT_KEEPALIVE);
30072
30354
  this.cleanSession = options.cleanSession ?? true;
@@ -31180,7 +31462,7 @@ var init_ssrf = __esm({
31180
31462
  import http from "node:http";
31181
31463
  import https from "node:https";
31182
31464
  import { URL as URL2 } from "node:url";
31183
- import { randomBytes as randomBytes8 } from "node:crypto";
31465
+ import { randomBytes as randomBytes9 } from "node:crypto";
31184
31466
  import { promises as fsp, createWriteStream } from "node:fs";
31185
31467
  import { basename as basename6 } from "node:path";
31186
31468
  import { pipeline } from "node:stream/promises";
@@ -31572,7 +31854,7 @@ var init_api = __esm({
31572
31854
  return { http_code: null, body: null, headers: {}, error: "upload requires filePath or fileBytes" };
31573
31855
  }
31574
31856
  const partContentType = guessContentType(uploadName);
31575
- const boundary = "----Tina4Boundary" + randomBytes8(16).toString("hex");
31857
+ const boundary = "----Tina4Boundary" + randomBytes9(16).toString("hex");
31576
31858
  const bodyBuffer = buildMultipartBody(boundary, fieldName, uploadName, content, partContentType, extraFields);
31577
31859
  const contentType = `multipart/form-data; boundary=${boundary}`;
31578
31860
  return this.execute("POST", this.buildUrl(path10), bodyBuffer, contentType, headers);
@@ -34536,7 +34818,7 @@ import {
34536
34818
  createHmac as createHmac3,
34537
34819
  createPrivateKey,
34538
34820
  createSign as createSign2,
34539
- randomBytes as randomBytes9
34821
+ randomBytes as randomBytes10
34540
34822
  } from "node:crypto";
34541
34823
  function encodeBase64Url(value) {
34542
34824
  return Buffer.from(value).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
@@ -34616,7 +34898,7 @@ function encryptPayload(payload, subscription) {
34616
34898
  ]);
34617
34899
  const prkKey = hmac(authSecret, sharedSecret);
34618
34900
  const ikm = hkdfExpand(prkKey, keyInfo, 32);
34619
- const salt = randomBytes9(16);
34901
+ const salt = randomBytes10(16);
34620
34902
  const contentPrk = hmac(salt, ikm);
34621
34903
  const cek = hkdfExpand(contentPrk, Buffer.from("Content-Encoding: aes128gcm\0", "ascii"), 16);
34622
34904
  const nonce = hkdfExpand(contentPrk, Buffer.from("Content-Encoding: nonce\0", "ascii"), 12);
@@ -37373,6 +37655,7 @@ __export(src_exports2, {
37373
37655
  cacheStats: () => cacheStats,
37374
37656
  checkPassword: () => checkPassword,
37375
37657
  clearCache: () => clearCache,
37658
+ clearCurrentNonce: () => clearCurrentNonce,
37376
37659
  computeAcceptKey: () => computeAcceptKey,
37377
37660
  container: () => container,
37378
37661
  cors: () => cors,
@@ -37384,6 +37667,8 @@ __export(src_exports2, {
37384
37667
  createMessenger: () => createMessenger,
37385
37668
  createRequest: () => createRequest,
37386
37669
  createResponse: () => createResponse,
37670
+ cspNonce: () => cspNonce,
37671
+ currentCspNonce: () => currentCspNonce,
37387
37672
  decodeRequest: () => decodeRequest,
37388
37673
  defaultContext: () => defaultContext,
37389
37674
  defaultRouter: () => defaultRouter,
@@ -37409,6 +37694,7 @@ __export(src_exports2, {
37409
37694
  feedbackWhitelist: () => feedbackWhitelist,
37410
37695
  fts5Supported: () => fts5Supported,
37411
37696
  generateContext: () => generateContext,
37697
+ generateNonce: () => generateNonce,
37412
37698
  generateVapidKeys: () => generateVapidKeys,
37413
37699
  get: () => get,
37414
37700
  getDefaultDevServer: () => getDefaultDevServer,
@@ -37431,6 +37717,7 @@ __export(src_exports2, {
37431
37717
  httpReason: () => httpReason,
37432
37718
  inContainer: () => inContainer,
37433
37719
  injectFeedbackWidget: () => injectFeedbackWidget,
37720
+ injectNonceIntoCsp: () => injectNonceIntoCsp,
37434
37721
  installAll: () => installAll,
37435
37722
  installSelected: () => installSelected,
37436
37723
  isBannerSuppressed: () => isBannerSuppressed,
@@ -37485,6 +37772,7 @@ __export(src_exports2, {
37485
37772
  resetTrustedProxyCache: () => resetTrustedProxyCache,
37486
37773
  resolveAlgorithm: () => resolveAlgorithm,
37487
37774
  resolveClientIp: () => resolveClientIp,
37775
+ resolveCspHeader: () => resolveCspHeader,
37488
37776
  resolveFrameworkVersion: () => resolveFrameworkVersion,
37489
37777
  resolvePortAndHost: () => resolvePortAndHost,
37490
37778
  resolveStringMiddleware: () => resolveStringMiddleware,
@@ -37492,12 +37780,14 @@ __export(src_exports2, {
37492
37780
  responseCache: () => responseCache,
37493
37781
  runAll: () => runAll,
37494
37782
  runRouteMiddlewares: () => runRouteMiddlewares,
37783
+ runWithCspNonce: () => runWithCspNonce,
37495
37784
  runtimeDir: () => runtimeDir,
37496
37785
  saveUpload: () => saveUpload,
37497
37786
  schemaFromParams: () => schemaFromParams,
37498
37787
  selectablePids: () => selectablePids,
37499
37788
  serveWebSocketRoute: () => serveWebSocketRoute,
37500
37789
  sessionCookieName: () => sessionCookieName,
37790
+ setCurrentNonce: () => setCurrentNonce,
37501
37791
  setDefaultTemplatesDir: () => setDefaultTemplatesDir,
37502
37792
  setFrond: () => setFrond,
37503
37793
  showMenu: () => showMenu,
@@ -37564,6 +37854,7 @@ var init_src2 = __esm({
37564
37854
  init_wsdl();
37565
37855
  init_htmlElement();
37566
37856
  init_errorOverlay();
37857
+ init_csp();
37567
37858
  init_ai();
37568
37859
  init_sso();
37569
37860
  init_push();
@@ -41730,7 +42021,7 @@ __export(database_exports, {
41730
42021
  stripTrailingSemicolons: () => stripTrailingSemicolons,
41731
42022
  wrapWithCache: () => wrapWithCache
41732
42023
  });
41733
- import { AsyncLocalStorage as AsyncLocalStorage2 } from "node:async_hooks";
42024
+ import { AsyncLocalStorage as AsyncLocalStorage3 } from "node:async_hooks";
41734
42025
  function stripTrailingSemicolons(sql) {
41735
42026
  if (!sql) return sql;
41736
42027
  let stripped = sql.replace(/\s+$/, "");
@@ -42144,9 +42435,9 @@ var init_database = __esm({
42144
42435
  leases = /* @__PURE__ */ new Map();
42145
42436
  discardedSlots = /* @__PURE__ */ new Set();
42146
42437
  repairs = /* @__PURE__ */ new Map();
42147
- leaseOwner = new AsyncLocalStorage2();
42148
- operationStore = new AsyncLocalStorage2();
42149
- txStore = new AsyncLocalStorage2();
42438
+ leaseOwner = new AsyncLocalStorage3();
42439
+ operationStore = new AsyncLocalStorage3();
42440
+ txStore = new AsyncLocalStorage3();
42150
42441
  /**
42151
42442
  * Create a Database wrapping an existing adapter.
42152
42443
  * For creating a Database from a URL, use the async static factories:
@@ -46897,7 +47188,7 @@ var init_seeder = __esm({
46897
47188
 
46898
47189
  // src/docstore.ts
46899
47190
  import { DatabaseSync as DatabaseSync4 } from "node:sqlite";
46900
- import { randomBytes as randomBytes10 } from "node:crypto";
47191
+ import { randomBytes as randomBytes11 } from "node:crypto";
46901
47192
  import { mkdirSync as mkdirSync20 } from "node:fs";
46902
47193
  import { dirname as dirname14, isAbsolute as isAbsolute6, join as join32 } from "node:path";
46903
47194
  function iso(d) {
@@ -47254,8 +47545,8 @@ var init_docstore = __esm({
47254
47545
  OID_RE = /^[0-9a-fA-F]{24}$/;
47255
47546
  ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?$/;
47256
47547
  ObjectId = class _ObjectId {
47257
- static _counter = randomBytes10(3).readUIntBE(0, 3);
47258
- static _process = randomBytes10(5);
47548
+ static _counter = randomBytes11(3).readUIntBE(0, 3);
47549
+ static _process = randomBytes11(5);
47259
47550
  _bytes;
47260
47551
  constructor(oid) {
47261
47552
  if (oid === void 0 || oid === null) {
@@ -48452,7 +48743,7 @@ var init_attachment = __esm({
48452
48743
  });
48453
48744
 
48454
48745
  // src/realtime/storage.ts
48455
- import { randomBytes as randomBytes11 } from "node:crypto";
48746
+ import { randomBytes as randomBytes12 } from "node:crypto";
48456
48747
  import { mkdirSync as mkdirSync21, readFileSync as readFileSync26, writeFileSync as writeFileSync18, unlinkSync as unlinkSync8, statSync as statSync19 } from "node:fs";
48457
48748
  import { resolve as resolve20, sep as sep8 } from "node:path";
48458
48749
  import { createRequire as createRequire8 } from "node:module";
@@ -48472,7 +48763,7 @@ function storageKey(filename = "") {
48472
48763
  const clean = raw.replace(UNSAFE, "").slice(0, 12);
48473
48764
  if (clean) ext = `.${clean}`;
48474
48765
  }
48475
- return `${randomBytes11(16).toString("hex")}${ext}`;
48766
+ return `${randomBytes12(16).toString("hex")}${ext}`;
48476
48767
  }
48477
48768
  function selectStorage(storage) {
48478
48769
  if (storage) return storage;