tina4-nodejs 3.13.138 → 3.13.139

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 (39) hide show
  1. package/CLAUDE.md +2 -2
  2. package/package.json +1 -1
  3. package/packages/cli/dist/bin.js +529 -70
  4. package/packages/core/dist/index.js +534 -70
  5. package/packages/core/src/api.ts +33 -4
  6. package/packages/core/src/auth.ts +73 -6
  7. package/packages/core/src/authGate.ts +27 -20
  8. package/packages/core/src/graphql.ts +92 -0
  9. package/packages/core/src/index.ts +1 -0
  10. package/packages/core/src/job.ts +8 -1
  11. package/packages/core/src/mcp.ts +3 -1
  12. package/packages/core/src/middleware.ts +4 -2
  13. package/packages/core/src/push.ts +16 -0
  14. package/packages/core/src/queue.ts +15 -2
  15. package/packages/core/src/queueBackends/liteBackend.ts +15 -0
  16. package/packages/core/src/queueBackends/mongoBackend.ts +40 -6
  17. package/packages/core/src/server.ts +6 -1
  18. package/packages/core/src/sessionHandlers/databaseHandler.ts +60 -3
  19. package/packages/core/src/sso.ts +12 -4
  20. package/packages/core/src/ssoIdentity.ts +19 -0
  21. package/packages/core/src/ssrf.ts +221 -0
  22. package/packages/core/src/testClient.ts +3 -0
  23. package/packages/core/src/websocket.ts +4 -2
  24. package/packages/frond/dist/index.js +6 -0
  25. package/packages/frond/src/engine.ts +9 -0
  26. package/packages/orm/dist/index.js +529 -70
  27. package/types/core/src/api.d.ts +6 -0
  28. package/types/core/src/auth.d.ts +30 -0
  29. package/types/core/src/graphql.d.ts +25 -0
  30. package/types/core/src/index.d.ts +1 -0
  31. package/types/core/src/job.d.ts +1 -0
  32. package/types/core/src/push.d.ts +5 -0
  33. package/types/core/src/queue.d.ts +7 -1
  34. package/types/core/src/queueBackends/liteBackend.d.ts +9 -0
  35. package/types/core/src/queueBackends/mongoBackend.d.ts +8 -1
  36. package/types/core/src/sessionHandlers/databaseHandler.d.ts +25 -1
  37. package/types/core/src/sso.d.ts +1 -0
  38. package/types/core/src/ssoIdentity.d.ts +10 -0
  39. package/types/core/src/ssrf.d.ts +19 -0
@@ -2612,6 +2612,8 @@ var auth_exports = {};
2612
2612
  __export(auth_exports, {
2613
2613
  Auth: () => Auth,
2614
2614
  JWT_LEEWAY_SECONDS: () => JWT_LEEWAY_SECONDS,
2615
+ MIN_SECRET_BYTES: () => MIN_SECRET_BYTES,
2616
+ NON_IDENTITY_TOKEN_TYPES: () => NON_IDENTITY_TOKEN_TYPES,
2615
2617
  algorithmAvailable: () => algorithmAvailable,
2616
2618
  authMiddleware: () => authMiddleware,
2617
2619
  authenticateRequest: () => authenticateRequest,
@@ -2621,7 +2623,10 @@ __export(auth_exports, {
2621
2623
  getPayload: () => getPayload,
2622
2624
  getToken: () => getToken,
2623
2625
  hashPassword: () => hashPassword,
2626
+ insecureSecretMessage: () => insecureSecretMessage,
2627
+ isIdentityPayload: () => isIdentityPayload,
2624
2628
  refreshToken: () => refreshToken,
2629
+ requireBootSecret: () => requireBootSecret,
2625
2630
  resolveAlgorithm: () => resolveAlgorithm,
2626
2631
  validToken: () => validToken,
2627
2632
  validateApiKey: () => validateApiKey
@@ -2657,6 +2662,23 @@ async function _logWarning(message) {
2657
2662
  function _warnBlankSecret() {
2658
2663
  void _logWarning(BLANK_SECRET_WARNING);
2659
2664
  }
2665
+ function isIdentityPayload(payload) {
2666
+ return payload != null && !NON_IDENTITY_TOKEN_TYPES.includes(payload.type);
2667
+ }
2668
+ function insecureSecretMessage(secret) {
2669
+ const length = Buffer.byteLength(secret ?? "", "utf8");
2670
+ if (length >= MIN_SECRET_BYTES) return null;
2671
+ const what = length === 0 ? "is not set" : `is ${length} bytes`;
2672
+ return `Auth: TINA4_SECRET ${what}; an HMAC JWT secret must be at least ${MIN_SECRET_BYTES} bytes. Generate one with \`openssl rand -hex 32\` and set TINA4_SECRET in your environment or .env.`;
2673
+ }
2674
+ function requireBootSecret() {
2675
+ const algorithm = (process.env.TINA4_JWT_ALGORITHM ?? "").trim() || "HS256";
2676
+ if (RSA_SIGN_ALGORITHMS.has(algorithm)) return;
2677
+ const secret = process.env.TINA4_SECRET ?? "";
2678
+ if (secret === "" && _isDev()) return;
2679
+ const message = insecureSecretMessage(secret);
2680
+ if (message) throw new Error(message);
2681
+ }
2660
2682
  function ensureDevSecret(cwd) {
2661
2683
  if (process.env.TINA4_SECRET) return null;
2662
2684
  if (!_isDev() || _isCi() || _isProduction()) {
@@ -2787,6 +2809,13 @@ function validToken(token, secret, algorithm) {
2787
2809
  const [h, p, sig] = parts;
2788
2810
  const header = JSON.parse(base64urlDecode(h).toString());
2789
2811
  if (header.alg !== resolvedAlgorithm) return null;
2812
+ if (HMAC_DIGESTS.has(resolvedAlgorithm)) {
2813
+ const weak = insecureSecretMessage(resolvedSecret);
2814
+ if (weak) {
2815
+ void _logWarning(weak);
2816
+ return null;
2817
+ }
2818
+ }
2790
2819
  const signingInput = `${h}.${p}`;
2791
2820
  if (!verifySignature(signingInput, sig, resolvedSecret, resolvedAlgorithm)) {
2792
2821
  return null;
@@ -2818,6 +2847,8 @@ function getPayload(token) {
2818
2847
  function sign(input, secret, algorithm) {
2819
2848
  const digest = HMAC_DIGESTS.get(algorithm);
2820
2849
  if (digest) {
2850
+ const weak = insecureSecretMessage(secret);
2851
+ if (weak) throw new Error(weak);
2821
2852
  return base64urlEncode(createHmac(digest, secret).update(input).digest());
2822
2853
  }
2823
2854
  const rsaAlgorithm = RSA_SIGN_ALGORITHMS.get(algorithm);
@@ -2875,17 +2906,17 @@ function authMiddleware(secret, algorithm) {
2875
2906
  return;
2876
2907
  }
2877
2908
  const token = authHeader.slice(7);
2878
- if (!validToken(token, secret, algorithm)) {
2909
+ const payload = validToken(token, secret, algorithm);
2910
+ if (!isIdentityPayload(payload)) {
2879
2911
  res({ error: "Unauthorized" }, 401);
2880
2912
  return;
2881
2913
  }
2882
- req2.auth = getPayload(token);
2914
+ req2.auth = payload;
2883
2915
  next();
2884
2916
  };
2885
2917
  }
2886
2918
  function refreshToken(token, expiresIn = 60) {
2887
- if (!validToken(token)) return null;
2888
- const payload = getPayload(token);
2919
+ const payload = validToken(token);
2889
2920
  if (!payload) return null;
2890
2921
  const { iat: _iat, exp: _exp, ...claims } = payload;
2891
2922
  return getToken(claims, expiresIn);
@@ -2894,7 +2925,8 @@ function authenticateRequest(headers, secret, algorithm) {
2894
2925
  const authHeader = headers.authorization ?? headers.Authorization ?? "";
2895
2926
  if (!authHeader.startsWith("Bearer ")) return null;
2896
2927
  const token = authHeader.slice(7);
2897
- if (validToken(token, secret, algorithm)) return getPayload(token);
2928
+ const payload = validToken(token, secret, algorithm);
2929
+ if (payload) return isIdentityPayload(payload) ? payload : null;
2898
2930
  if (validateApiKey(token)) {
2899
2931
  return { _auth: "api_key" };
2900
2932
  }
@@ -2908,12 +2940,14 @@ function validateApiKey(provided, expected) {
2908
2940
  if (a.length !== b.length) return false;
2909
2941
  return timingSafeEqual(a, b);
2910
2942
  }
2911
- var BLANK_SECRET_WARNING, HMAC_DIGESTS, RSA_SIGN_ALGORITHMS, KNOWN_ALGORITHMS, ALGORITHM_REMEDY, capabilityCache, JWT_LEEWAY_SECONDS, Auth;
2943
+ var BLANK_SECRET_WARNING, NON_IDENTITY_TOKEN_TYPES, MIN_SECRET_BYTES, HMAC_DIGESTS, RSA_SIGN_ALGORITHMS, KNOWN_ALGORITHMS, ALGORITHM_REMEDY, capabilityCache, JWT_LEEWAY_SECONDS, Auth;
2912
2944
  var init_auth = __esm({
2913
2945
  "../core/src/auth.ts"() {
2914
2946
  "use strict";
2915
2947
  init_dotenv();
2916
2948
  BLANK_SECRET_WARNING = "Auth: TINA4_SECRET is not set \u2014 JWT signing is insecure. Set TINA4_SECRET to a random value (e.g. `openssl rand -hex 32`) in your environment or .env before serving traffic. For LOCAL DEV, set TINA4_DEBUG=true and a per-machine secret is generated automatically into .env.local (gitignored). Seeing this warning means the run was NOT detected as dev \u2014 typically a container or CI without TINA4_DEBUG set, or TINA4_ENV=production.";
2949
+ NON_IDENTITY_TOKEN_TYPES = ["form"];
2950
+ MIN_SECRET_BYTES = 32;
2917
2951
  HMAC_DIGESTS = /* @__PURE__ */ new Map([
2918
2952
  ["HS256", "sha256"],
2919
2953
  ["HS384", "sha384"],
@@ -3296,9 +3330,9 @@ var init_cache = __esm({
3296
3330
  return entry.value;
3297
3331
  }
3298
3332
  async set(key, value, ttl) {
3299
- const expiresAt = ttl > 0 ? Date.now() + ttl * 1e3 : 0;
3333
+ const expiresAt2 = ttl > 0 ? Date.now() + ttl * 1e3 : 0;
3300
3334
  this.store.delete(key);
3301
- this.store.set(key, { value, expiresAt });
3335
+ this.store.set(key, { value, expiresAt: expiresAt2 });
3302
3336
  while (this.store.size > this.maxEntries) {
3303
3337
  const firstKey = this.store.keys().next().value;
3304
3338
  if (firstKey !== void 0) this.store.delete(firstKey);
@@ -3731,8 +3765,8 @@ ${s}\r
3731
3765
  const oldest = files.shift();
3732
3766
  if (oldest) fs.unlinkSync(path.join(this.dir, oldest.name));
3733
3767
  }
3734
- const expiresAt = ttl > 0 ? Date.now() / 1e3 + ttl : 0;
3735
- const entry = { key, value, expiresAt };
3768
+ const expiresAt2 = ttl > 0 ? Date.now() / 1e3 + ttl : 0;
3769
+ const entry = { key, value, expiresAt: expiresAt2 };
3736
3770
  fs.writeFileSync(this.keyPath(key), JSON.stringify(entry));
3737
3771
  } catch {
3738
3772
  }
@@ -5479,8 +5513,8 @@ function capCache(cache, maxEntries) {
5479
5513
  }
5480
5514
  function sweepExpiredCache(cache) {
5481
5515
  const now = Date.now();
5482
- for (const [key, [, expiresAt]] of cache) {
5483
- if (expiresAt <= now) cache.delete(key);
5516
+ for (const [key, [, expiresAt2]] of cache) {
5517
+ if (expiresAt2 <= now) cache.delete(key);
5484
5518
  }
5485
5519
  }
5486
5520
  function tokenize(source) {
@@ -6452,6 +6486,12 @@ function setFormTokenSessionId(sessionId) {
6452
6486
  }
6453
6487
  function _buildFormTokenJwt(descriptor = "") {
6454
6488
  const secret = process.env.TINA4_SECRET ?? "";
6489
+ const secretBytes = Buffer.byteLength(secret, "utf8");
6490
+ if (secretBytes < 32) {
6491
+ throw new Error(
6492
+ `Auth: TINA4_SECRET ${secretBytes === 0 ? "is not set" : `is ${secretBytes} bytes`}; an HMAC JWT secret must be at least 32 bytes. Generate one with \`openssl rand -hex 32\` and set TINA4_SECRET in your environment or .env.`
6493
+ );
6494
+ }
6455
6495
  const ttlMinutes = parseInt(process.env.TINA4_TOKEN_LIMIT || "60", 10);
6456
6496
  const header = { alg: "HS256", typ: "JWT" };
6457
6497
  const now = Math.floor(Date.now() / 1e3);
@@ -7993,8 +8033,8 @@ var init_engine = __esm({
7993
8033
  sweepExpiredCache(this.fragmentCache);
7994
8034
  const cached = this.fragmentCache.get(cacheKey);
7995
8035
  if (cached) {
7996
- const [htmlContent, expiresAt] = cached;
7997
- if (Date.now() < expiresAt) {
8036
+ const [htmlContent, expiresAt2] = cached;
8037
+ if (Date.now() < expiresAt2) {
7998
8038
  let i2 = start2 + 1;
7999
8039
  let depth = 0;
8000
8040
  while (i2 < tokens.length) {
@@ -8787,7 +8827,7 @@ function csrfBearerIsValid(req2, secret) {
8787
8827
  const authHeader = req2.headers.authorization ?? "";
8788
8828
  if (!authHeader.startsWith("Bearer ")) return false;
8789
8829
  const bearerToken = authHeader.slice(7).trim();
8790
- return Boolean(bearerToken && validToken(bearerToken, secret));
8830
+ return Boolean(bearerToken && isIdentityPayload(validToken(bearerToken, secret)));
8791
8831
  }
8792
8832
  function csrfRequestToken(req2) {
8793
8833
  const query = req2.query ?? {};
@@ -9924,6 +9964,22 @@ var init_transport = __esm({
9924
9964
  }
9925
9965
  });
9926
9966
 
9967
+ // ../core/src/ssoIdentity.ts
9968
+ function liveSessionIdentity(stored) {
9969
+ if (!stored || typeof stored !== "object") return null;
9970
+ const identity = stored.identity;
9971
+ if (!identity || typeof identity !== "object" || !identity.issuer || !identity.subject) return null;
9972
+ const expiresAt2 = stored.expires_at ?? 0;
9973
+ if (typeof expiresAt2 !== "number") return null;
9974
+ if (expiresAt2 > 0 && Math.floor(Date.now() / 1e3) >= expiresAt2) return null;
9975
+ return identity;
9976
+ }
9977
+ var init_ssoIdentity = __esm({
9978
+ "../core/src/ssoIdentity.ts"() {
9979
+ "use strict";
9980
+ }
9981
+ });
9982
+
9927
9983
  // ../core/src/authGate.ts
9928
9984
  function enforceRouteAuth(req2, res, match, isDevAdmin) {
9929
9985
  if (!(match.secure === true && match.noAuth !== true && !isDevAdmin)) {
@@ -9931,38 +9987,50 @@ function enforceRouteAuth(req2, res, match, isDevAdmin) {
9931
9987
  }
9932
9988
  const authHeader = req2.headers.authorization ?? "";
9933
9989
  const headerToken = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : "";
9934
- let resolvedToken = "";
9990
+ let payload = null;
9935
9991
  let tokenSource = "";
9936
- if (headerToken && validToken(headerToken)) {
9937
- resolvedToken = headerToken;
9938
- tokenSource = "header";
9992
+ let resolvedToken = "";
9993
+ if (headerToken) {
9994
+ const candidate = validToken(headerToken);
9995
+ if (isIdentityPayload(candidate)) {
9996
+ payload = candidate;
9997
+ tokenSource = "header";
9998
+ resolvedToken = headerToken;
9999
+ }
9939
10000
  }
9940
- if (!resolvedToken) {
10001
+ if (!payload) {
9941
10002
  const bodyToken = req2.body?.formToken;
9942
- if (bodyToken && validToken(bodyToken)) {
9943
- resolvedToken = bodyToken;
9944
- tokenSource = "body";
10003
+ if (bodyToken) {
10004
+ const candidate = validToken(bodyToken);
10005
+ if (isIdentityPayload(candidate)) {
10006
+ payload = candidate;
10007
+ tokenSource = "body";
10008
+ resolvedToken = bodyToken;
10009
+ }
9945
10010
  }
9946
10011
  }
9947
- if (!resolvedToken) {
9948
- const sso = req2.session?.get?.("_tina4_sso");
9949
- const identity = sso?.identity;
9950
- if (identity?.issuer && identity?.subject) {
10012
+ if (!payload) {
10013
+ const identity = liveSessionIdentity(req2.session?.get?.("_tina4_sso"));
10014
+ if (identity) {
9951
10015
  req2.user = identity;
9952
10016
  return rbacForbidden(match, identity, res);
9953
10017
  }
9954
10018
  const sessionToken = req2.session?.get?.("token");
9955
- if (sessionToken && validToken(sessionToken)) {
9956
- resolvedToken = sessionToken;
9957
- tokenSource = "session";
10019
+ if (sessionToken) {
10020
+ const candidate = validToken(sessionToken);
10021
+ if (isIdentityPayload(candidate)) {
10022
+ payload = candidate;
10023
+ tokenSource = "session";
10024
+ resolvedToken = sessionToken;
10025
+ }
9958
10026
  }
9959
10027
  }
9960
- if (!resolvedToken) {
10028
+ if (!payload) {
9961
10029
  res.raw.writeHead(401, { "Content-Type": "application/json" });
9962
10030
  res.raw.end(JSON.stringify({ error: "Unauthorized" }));
9963
10031
  return true;
9964
10032
  }
9965
- req2.user = getPayload(resolvedToken) ?? {};
10033
+ req2.user = payload;
9966
10034
  if (tokenSource === "body") {
9967
10035
  const fresh = refreshToken(resolvedToken);
9968
10036
  if (fresh) {
@@ -10012,6 +10080,7 @@ var init_authGate = __esm({
10012
10080
  "../core/src/authGate.ts"() {
10013
10081
  "use strict";
10014
10082
  init_auth();
10083
+ init_ssoIdentity();
10015
10084
  }
10016
10085
  });
10017
10086
 
@@ -11085,8 +11154,8 @@ var init_mongoHandler = __esm({
11085
11154
  if (!result || result === "__EMPTY__") return null;
11086
11155
  try {
11087
11156
  const doc = JSON.parse(result);
11088
- const expiresAt = Number(doc?.expires_at ?? 0);
11089
- if (expiresAt > 0 && Date.now() / 1e3 > expiresAt) {
11157
+ const expiresAt2 = Number(doc?.expires_at ?? 0);
11158
+ if (expiresAt2 > 0 && Date.now() / 1e3 > expiresAt2) {
11090
11159
  this.destroy(sessionId);
11091
11160
  return null;
11092
11161
  }
@@ -11312,6 +11381,9 @@ parentPort.on("message", (message) => {
11312
11381
 
11313
11382
  // ../core/src/sessionHandlers/databaseHandler.ts
11314
11383
  import { DatabaseSync } from "node:sqlite";
11384
+ function sleepSync2(ms) {
11385
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
11386
+ }
11315
11387
  function column(row, name) {
11316
11388
  if (name in row) return row[name];
11317
11389
  return row[name.toUpperCase()];
@@ -11320,7 +11392,7 @@ function schemeOf2(url) {
11320
11392
  const match = url.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):/);
11321
11393
  return match ? match[1].toLowerCase() : "unknown";
11322
11394
  }
11323
- var CREATE_TABLE, DatabaseSessionHandler;
11395
+ var CREATE_TABLE, CREATE_ATTEMPTS, CREATE_RETRY_DELAY_MS, DatabaseSessionHandler;
11324
11396
  var init_databaseHandler = __esm({
11325
11397
  "../core/src/sessionHandlers/databaseHandler.ts"() {
11326
11398
  "use strict";
@@ -11400,6 +11472,8 @@ var init_databaseHandler = __esm({
11400
11472
  END
11401
11473
  `
11402
11474
  };
11475
+ CREATE_ATTEMPTS = 5;
11476
+ CREATE_RETRY_DELAY_MS = 50;
11403
11477
  DatabaseSessionHandler = class {
11404
11478
  sqliteHandle = null;
11405
11479
  /** Set for SQLite. Null when this handler talks to a networked engine. */
@@ -11440,6 +11514,7 @@ var init_databaseHandler = __esm({
11440
11514
  get sqlite() {
11441
11515
  if (this.sqliteHandle === null) {
11442
11516
  this.sqliteHandle = new DatabaseSync(this.dbPath);
11517
+ this.sqliteHandle.exec("PRAGMA busy_timeout = 5000");
11443
11518
  this.sqliteHandle.exec("PRAGMA journal_mode = WAL");
11444
11519
  }
11445
11520
  return this.sqliteHandle;
@@ -11527,15 +11602,55 @@ var init_databaseHandler = __esm({
11527
11602
  sqlCommandSync(this.target, sql, params);
11528
11603
  }
11529
11604
  /**
11530
- * Ensure the session table exists (called once on first use).
11605
+ * Ensure the session table exists (called once on first use) - safely when
11606
+ * several processes race to create it.
11607
+ *
11608
+ * Every per-engine statement above is check-then-act, so two processes can
11609
+ * both find the table absent and both CREATE; the loser errors, and every
11610
+ * engine spells that differently (a pg_type unique violation on PostgreSQL,
11611
+ * Msg 2714 on SQL Server, an RDB$RELATIONS unique violation on Firebird). So
11612
+ * a failure is judged by RE-CHECKING whether the table now exists, never by
11613
+ * parsing the message - the rescue the Firebird note above has always
11614
+ * assumed, and which was not here. MEASURED on the lab before it: six real
11615
+ * processes racing lost one to five of them on PostgreSQL, SQLite and
11616
+ * Firebird.
11617
+ *
11618
+ * The re-check is RETRIED because the loser can fail BEFORE the winner has
11619
+ * committed. On MySQL, CREATE TABLE IF NOT EXISTS takes a shared metadata
11620
+ * lock, checks, then upgrades it to exclusive; two upgrades deadlock, and when
11621
+ * MySQL cannot back the victim off silently it answers 1213 "Deadlock found"
11622
+ * while the winner's table is still invisible (tina4-php CI run 35972320442).
11623
+ * Bounded, so a genuine failure (no CREATE grant) still throws. Same attempts
11624
+ * and delays in all four frameworks.
11531
11625
  */
11532
11626
  ensureTable() {
11533
11627
  if (this.initialized) return;
11534
11628
  const ddl = CREATE_TABLE[this.engine];
11535
- if (this.target === null) this.sqlite.exec(ddl);
11536
- else sqlCommandSync(this.target, ddl, []);
11629
+ for (let attempt = 1; ; attempt++) {
11630
+ try {
11631
+ if (this.target === null) this.sqlite.exec(ddl);
11632
+ else sqlCommandSync(this.target, ddl, []);
11633
+ break;
11634
+ } catch (error2) {
11635
+ if (this.tableExists()) break;
11636
+ if (attempt >= CREATE_ATTEMPTS) throw error2;
11637
+ sleepSync2(CREATE_RETRY_DELAY_MS * attempt);
11638
+ }
11639
+ }
11537
11640
  this.initialized = true;
11538
11641
  }
11642
+ /**
11643
+ * Whether tina4_session exists, asked the one way every engine answers alike:
11644
+ * a query against it that returns no rows succeeds only if it is there.
11645
+ */
11646
+ tableExists() {
11647
+ try {
11648
+ this.query("SELECT 1 FROM tina4_session WHERE 1 = 0", []);
11649
+ return true;
11650
+ } catch {
11651
+ return false;
11652
+ }
11653
+ }
11539
11654
  read(sessionId) {
11540
11655
  this.ensureTable();
11541
11656
  const rows = this.query(
@@ -11545,8 +11660,8 @@ var init_databaseHandler = __esm({
11545
11660
  const row = rows[0];
11546
11661
  if (!row) return null;
11547
11662
  const now = Date.now() / 1e3;
11548
- const expiresAt = Number(column(row, "expires_at") ?? 0);
11549
- if (expiresAt > 0 && expiresAt < now) {
11663
+ const expiresAt2 = Number(column(row, "expires_at") ?? 0);
11664
+ if (expiresAt2 > 0 && expiresAt2 < now) {
11550
11665
  this.destroy(sessionId);
11551
11666
  return null;
11552
11667
  }
@@ -11559,7 +11674,7 @@ var init_databaseHandler = __esm({
11559
11674
  write(sessionId, data, ttl) {
11560
11675
  this.ensureTable();
11561
11676
  const json = JSON.stringify(data);
11562
- const expiresAt = ttl > 0 ? Date.now() / 1e3 + ttl : 0;
11677
+ const expiresAt2 = ttl > 0 ? Date.now() / 1e3 + ttl : 0;
11563
11678
  const existing = this.query(
11564
11679
  "SELECT session_id FROM tina4_session WHERE session_id = ?",
11565
11680
  [sessionId]
@@ -11567,12 +11682,12 @@ var init_databaseHandler = __esm({
11567
11682
  if (existing.length > 0) {
11568
11683
  this.exec(
11569
11684
  "UPDATE tina4_session SET data = ?, expires_at = ? WHERE session_id = ?",
11570
- [json, expiresAt, sessionId]
11685
+ [json, expiresAt2, sessionId]
11571
11686
  );
11572
11687
  } else {
11573
11688
  this.exec(
11574
11689
  "INSERT INTO tina4_session (session_id, data, expires_at) VALUES (?, ?, ?)",
11575
- [sessionId, json, expiresAt]
11690
+ [sessionId, json, expiresAt2]
11576
11691
  );
11577
11692
  }
11578
11693
  }
@@ -15567,7 +15682,7 @@ function envTruthy(val) {
15567
15682
  return ["true", "1", "yes", "on"].includes(val.trim().toLowerCase());
15568
15683
  }
15569
15684
  function isLoopback(ip) {
15570
- if (ip == null || ip === "") return true;
15685
+ if (ip == null || ip === "") return false;
15571
15686
  let addr = ip.trim().toLowerCase();
15572
15687
  if (addr.startsWith("::ffff:")) addr = addr.slice(7);
15573
15688
  return addr === "::1" || addr === "localhost" || addr.startsWith("127.");
@@ -17158,6 +17273,7 @@ __export(graphql_exports, {
17158
17273
  graphqlAutoSchemaEnabled: () => graphqlAutoSchemaEnabled,
17159
17274
  graphqlEndpoint: () => graphqlEndpoint,
17160
17275
  graphqlMaxDepth: () => graphqlMaxDepth,
17276
+ graphqlMaxNodes: () => graphqlMaxNodes,
17161
17277
  tokenize: () => tokenize2
17162
17278
  });
17163
17279
  function tokenize2(source) {
@@ -17197,7 +17313,12 @@ function graphqlMaxDepth() {
17197
17313
  const n = parseInt(raw, 10);
17198
17314
  return Number.isNaN(n) ? 50 : n;
17199
17315
  }
17200
- var TOKEN_PATTERNS, ParseError, Parser, GraphQL, GraphQLType;
17316
+ function graphqlMaxNodes() {
17317
+ const raw = (process.env.TINA4_GRAPHQL_MAX_NODES ?? "1000").trim();
17318
+ const n = parseInt(raw, 10);
17319
+ return Number.isNaN(n) ? 1e3 : n;
17320
+ }
17321
+ var TOKEN_PATTERNS, ParseError, Parser, ComplexityError, GraphQL, GraphQLType;
17201
17322
  var init_graphql = __esm({
17202
17323
  "../core/src/graphql.ts"() {
17203
17324
  "use strict";
@@ -17236,9 +17357,16 @@ var init_graphql = __esm({
17236
17357
  Parser = class {
17237
17358
  tokens;
17238
17359
  pos;
17360
+ // F2: bound the parser's own recursion. A deeply nested query
17361
+ // ("{a{a{a...}}}") otherwise recurses in parseSelectionSet until the call
17362
+ // stack overflows — BEFORE the execution-time depth guard can run. Reusing
17363
+ // TINA4_GRAPHQL_MAX_DEPTH keeps parse and execution on one bound.
17364
+ depth = 0;
17365
+ maxDepth;
17239
17366
  constructor(tokens) {
17240
17367
  this.tokens = tokens;
17241
17368
  this.pos = 0;
17369
+ this.maxDepth = graphqlMaxDepth();
17242
17370
  }
17243
17371
  peek() {
17244
17372
  return this.pos < this.tokens.length ? this.tokens[this.pos] : null;
@@ -17320,6 +17448,17 @@ var init_graphql = __esm({
17320
17448
  };
17321
17449
  }
17322
17450
  parseSelectionSet() {
17451
+ this.depth++;
17452
+ if (this.maxDepth > 0 && this.depth > this.maxDepth) {
17453
+ throw new Error(`Query exceeds maximum depth of ${this.maxDepth}`);
17454
+ }
17455
+ try {
17456
+ return this.parseSelectionSetInner();
17457
+ } finally {
17458
+ this.depth--;
17459
+ }
17460
+ }
17461
+ parseSelectionSetInner() {
17323
17462
  this.expect("LBRACE");
17324
17463
  const selections = [];
17325
17464
  while (!this.match("RBRACE")) {
@@ -17474,6 +17613,8 @@ var init_graphql = __esm({
17474
17613
  return t;
17475
17614
  }
17476
17615
  };
17616
+ ComplexityError = class extends Error {
17617
+ };
17477
17618
  GraphQL = class _GraphQL {
17478
17619
  types = /* @__PURE__ */ new Map();
17479
17620
  queries = /* @__PURE__ */ new Map();
@@ -17496,6 +17637,12 @@ var init_graphql = __esm({
17496
17637
  * spread / inline fragment, so circular fragments are caught too.
17497
17638
  */
17498
17639
  maxDepth = graphqlMaxDepth();
17640
+ /**
17641
+ * Maximum expanded selection nodes per query (from `TINA4_GRAPHQL_MAX_NODES`,
17642
+ * default 1000; `<= 0` disables). Bounds query WIDTH — fragment bombs and
17643
+ * alias explosions — which the depth guard does not. See {@link graphqlMaxNodes}.
17644
+ */
17645
+ maxNodes = graphqlMaxNodes();
17499
17646
  /**
17500
17647
  * Decorator-style resolver registration.
17501
17648
  *
@@ -17643,6 +17790,16 @@ var init_graphql = __esm({
17643
17790
  return { data: null, errors: [{ message: "No operation found" }] };
17644
17791
  }
17645
17792
  const op = operations[0];
17793
+ if (this.maxNodes > 0) {
17794
+ try {
17795
+ this.assertComplexity(op.selections, fragments);
17796
+ } catch (e) {
17797
+ if (e instanceof ComplexityError) {
17798
+ return { data: null, errors: [{ message: e.message }] };
17799
+ }
17800
+ throw e;
17801
+ }
17802
+ }
17646
17803
  const resolvers = op.operation === "query" ? this.queries : this.mutations;
17647
17804
  for (const vdef of op.variables) {
17648
17805
  if (!(vdef.name in vars) && vdef.default !== void 0) {
@@ -17668,6 +17825,36 @@ var init_graphql = __esm({
17668
17825
  * instead of recursing until the interpreter stack overflows. Top-level
17669
17826
  * starts at depth 1; `maxDepth <= 0` disables the guard.
17670
17827
  */
17828
+ /**
17829
+ * Count the expanded selection nodes, throwing {@link ComplexityError} past
17830
+ * `maxNodes` (F2). Fragment spreads are expanded (so a fragment bomb is
17831
+ * counted, not the small unexpanded document) and every field — including
17832
+ * each alias — counts once. The count is capped by `maxNodes` and the walk by
17833
+ * `maxDepth`, so a circular fragment trips the budget instead of looping and
17834
+ * the check itself can never cost more than the budget.
17835
+ */
17836
+ assertComplexity(selections, fragments) {
17837
+ const depthCap = this.maxDepth > 0 ? this.maxDepth : 1e3;
17838
+ let count = 0;
17839
+ const walk2 = (sels, depth) => {
17840
+ if (depth > depthCap) return;
17841
+ for (const sel of sels) {
17842
+ count++;
17843
+ if (count > this.maxNodes) {
17844
+ throw new ComplexityError(`Query exceeds maximum complexity of ${this.maxNodes} nodes`);
17845
+ }
17846
+ if (sel.kind === "fragment_spread") {
17847
+ const frag = fragments.get(sel.name);
17848
+ if (frag) walk2(frag.selections, depth + 1);
17849
+ } else if (sel.kind === "inline_fragment") {
17850
+ walk2(sel.selections, depth + 1);
17851
+ } else if (sel.kind === "field" && sel.selections) {
17852
+ walk2(sel.selections, depth + 1);
17853
+ }
17854
+ }
17855
+ };
17856
+ walk2(selections, 1);
17857
+ }
17671
17858
  async resolveSelectionsInto(selections, resolvers, parent, variables, context, fragments, target, depth) {
17672
17859
  const errors = [];
17673
17860
  if (this.maxDepth > 0 && depth > this.maxDepth) {
@@ -18870,7 +19057,8 @@ function wsAuthorized(route, headers, queryString = "", subprotocol = "") {
18870
19057
  const token = wsToken(headers, queryString, subprotocol);
18871
19058
  if (!token) return [null, false];
18872
19059
  const payload = validToken(token);
18873
- return [payload, payload !== null];
19060
+ if (!isIdentityPayload(payload)) return [null, false];
19061
+ return [payload, true];
18874
19062
  }
18875
19063
  function offeredBearerSubprotocol(subprotocol) {
18876
19064
  return subprotocol.split(",").some((p) => p.trim().toLowerCase() === "bearer");
@@ -20065,12 +20253,36 @@ var init_mongoBackend = __esm({
20065
20253
  }
20066
20254
  }
20067
20255
  else if (operation === "size") {
20068
- const count = await col.countDocuments({
20069
- queue: queueName,
20070
- status: "pending",
20071
- });
20256
+ // ADR-0022 dec 7: size(status) must not answer a different question.
20257
+ // The dead aliases count the .dead_letter store (== deadLetters().length);
20258
+ // reserved/completed count by status; else pending. data = status.
20259
+ const status = data || "pending";
20260
+ let count: number;
20261
+ if (status === "dead" || status === "failed" || status === "dead_letter") {
20262
+ count = await col.countDocuments({ queue: queueName + ".dead_letter" });
20263
+ } else if (status === "reserved" || status === "completed") {
20264
+ count = await col.countDocuments({ queue: queueName, status });
20265
+ } else {
20266
+ count = await col.countDocuments({ queue: queueName, status: "pending" });
20267
+ }
20072
20268
  process.stdout.write(String(count));
20073
20269
  }
20270
+ else if (operation === "reject") {
20271
+ // Dead-letter NOW, no retry (ADR-0023). Floor attempts at maxRetries
20272
+ // so it reads as exhausted (parity with fail()'s dead-letter branch
20273
+ // and size("dead")). data = JSON { id, reason, maxRetries }.
20274
+ const info = JSON.parse(data);
20275
+ const doc = await col.findOne({ queue: queueName, id: info.id });
20276
+ if (doc) {
20277
+ const attempts = Math.max((doc.attempts || 0) + 1, info.maxRetries);
20278
+ await col.insertOne({
20279
+ ...doc, _id: undefined, attempts,
20280
+ status: "dead", queue: queueName + ".dead_letter", error: info.reason,
20281
+ });
20282
+ await col.deleteOne({ _id: doc._id, queue: queueName });
20283
+ }
20284
+ process.stdout.write("__OK__");
20285
+ }
20074
20286
  else if (operation === "clear") {
20075
20287
  await col.deleteMany({ queue: queueName });
20076
20288
  process.stdout.write("__CLEARED__");
@@ -20306,11 +20518,20 @@ var init_mongoBackend = __esm({
20306
20518
  return null;
20307
20519
  }
20308
20520
  }
20309
- size(queue) {
20310
- const result = this.execSync("size", queue);
20521
+ size(queue, status2 = "pending") {
20522
+ const result = this.execSync("size", queue, status2);
20311
20523
  const num = parseInt(result, 10);
20312
20524
  return isNaN(num) ? 0 : num;
20313
20525
  }
20526
+ /**
20527
+ * Dead-letter a job immediately — no retry (ADR-0023 reject()). Distinct from
20528
+ * fail(): fail() requeues until maxRetries is spent. attempts is floored at
20529
+ * maxRetries so deadLetters()/size("dead") (which filter/scope to the
20530
+ * dead-letter store) see it.
20531
+ */
20532
+ reject(queue, id, reason, maxRetries) {
20533
+ this.execSync("reject", queue, JSON.stringify({ id, reason, maxRetries }));
20534
+ }
20314
20535
  clear(queue) {
20315
20536
  this.execSync("clear", queue);
20316
20537
  }
@@ -20405,7 +20626,9 @@ function createJob(data, queue) {
20405
20626
  queue._failJob(job.topic, job, reason, queue.getMaxRetries());
20406
20627
  },
20407
20628
  reject(reason = "") {
20408
- job.fail(reason);
20629
+ job.status = "dead";
20630
+ job.error = reason;
20631
+ queue._rejectJob(job.topic, job, reason, queue.getMaxRetries());
20409
20632
  },
20410
20633
  retry(delaySeconds) {
20411
20634
  queue._retryJob(job.topic, job, delaySeconds);
@@ -21014,6 +21237,20 @@ var init_liteBackend = __esm({
21014
21237
  * dead-letter file -- two spellings of the same intent that previously
21015
21238
  * diverged.
21016
21239
  */
21240
+ /**
21241
+ * Reject a job permanently — dead-letter it NOW, no retry (ADR-0023).
21242
+ *
21243
+ * Unlike failJob(), which requeues until maxRetries is spent, rejectJob()
21244
+ * moves a known-poison job straight to the dead-letter store. attempts is
21245
+ * floored at maxRetries so deadLetters() (attempts >= maxRetries) returns it
21246
+ * and size("dead") agrees.
21247
+ */
21248
+ rejectJob(queue, job, reason, maxRetries) {
21249
+ this.clearReservation(queue, job.id);
21250
+ job.attempts = Math.max((job.attempts || 0) + 1, maxRetries);
21251
+ job.error = reason;
21252
+ this.deadLetter(queue, job, reason);
21253
+ }
21017
21254
  retryJob(queue, job, delaySeconds) {
21018
21255
  this.clearReservation(queue, job.id);
21019
21256
  try {
@@ -21235,7 +21472,7 @@ var init_queue = __esm({
21235
21472
  size(status2 = "pending") {
21236
21473
  const q = this.topic;
21237
21474
  if (this.externalBackend) {
21238
- return this.externalBackend.size(q);
21475
+ return this.externalBackend.size(q, status2);
21239
21476
  }
21240
21477
  return this.liteBackend.size(q, status2);
21241
21478
  }
@@ -21503,6 +21740,17 @@ var init_queue = __esm({
21503
21740
  }
21504
21741
  this.liteBackend.failJob(queue, job, error2, maxRetries, this._retryBackoff);
21505
21742
  }
21743
+ /**
21744
+ * Dead-letter a job immediately — no retry (ADR-0023 reject()). Distinct from
21745
+ * _failJob, which retries until maxRetries is spent.
21746
+ */
21747
+ _rejectJob(queue, job, reason, maxRetries) {
21748
+ if (this.externalBackend?.reject) {
21749
+ this.externalBackend.reject(queue, job.id, reason, maxRetries);
21750
+ return;
21751
+ }
21752
+ this.liteBackend.rejectJob(queue, job, reason, maxRetries);
21753
+ }
21506
21754
  /**
21507
21755
  * Re-queue a job back to the main queue directory with incremented attempts.
21508
21756
  */
@@ -27214,13 +27462,20 @@ var sso_exports = {};
27214
27462
  __export(sso_exports, {
27215
27463
  SSO: () => Sso,
27216
27464
  Sso: () => Sso,
27217
- SsoError: () => SsoError
27465
+ SsoError: () => SsoError,
27466
+ liveSessionIdentity: () => liveSessionIdentity
27218
27467
  });
27219
27468
  import { createHash as createHash9, randomBytes as randomBytes5, timingSafeEqual as timingSafeEqual3 } from "node:crypto";
27469
+ function expiresAt(tokens) {
27470
+ const lifetime = Number(tokens.expires_in ?? 0);
27471
+ return lifetime > 0 ? Math.floor(Date.now() / 1e3) + lifetime : 0;
27472
+ }
27220
27473
  var SsoError, Sso;
27221
27474
  var init_sso = __esm({
27222
27475
  "../core/src/sso.ts"() {
27223
27476
  "use strict";
27477
+ init_ssoIdentity();
27478
+ init_ssoIdentity();
27224
27479
  SsoError = class extends Error {
27225
27480
  };
27226
27481
  Sso = class _Sso {
@@ -27426,13 +27681,12 @@ var init_sso = __esm({
27426
27681
  access_token: tokens.access_token,
27427
27682
  refresh_token: tokens.refresh_token,
27428
27683
  id_token: tokens.id_token,
27429
- expires_at: Math.floor(Date.now() / 1e3) + Number(tokens.expires_in ?? 0)
27684
+ expires_at: expiresAt(tokens)
27430
27685
  });
27431
27686
  return { identity, return_to: _Sso.safeReturn(pending.return_to) };
27432
27687
  }
27433
27688
  identity(requestOrSession) {
27434
- const stored = this.session(requestOrSession)?.get(_Sso.SESSION_KEY);
27435
- const identity = stored?.identity ?? null;
27689
+ const identity = liveSessionIdentity(this.session(requestOrSession)?.get(_Sso.SESSION_KEY));
27436
27690
  if (identity && requestOrSession?.session) requestOrSession.user = identity;
27437
27691
  return identity;
27438
27692
  }
@@ -27459,7 +27713,7 @@ var init_sso = __esm({
27459
27713
  access_token: tokens.access_token,
27460
27714
  refresh_token: tokens.refresh_token ?? stored.refresh_token,
27461
27715
  id_token: tokens.id_token ?? stored.id_token,
27462
- expires_at: Math.floor(Date.now() / 1e3) + Number(tokens.expires_in ?? 0)
27716
+ expires_at: expiresAt(tokens)
27463
27717
  });
27464
27718
  return identity;
27465
27719
  } catch (error2) {
@@ -27601,12 +27855,12 @@ function bannerSurfaceLines(port, opts) {
27601
27855
  ];
27602
27856
  }
27603
27857
  function swaggerAdvertised() {
27604
- const TRUTHY2 = ["true", "1", "yes", "on"];
27858
+ const TRUTHY3 = ["true", "1", "yes", "on"];
27605
27859
  const raw = (process.env.TINA4_SWAGGER_ENABLED ?? "").trim().toLowerCase();
27606
27860
  if (raw === "") {
27607
- return TRUTHY2.includes((process.env.TINA4_DEBUG ?? "").trim().toLowerCase());
27861
+ return TRUTHY3.includes((process.env.TINA4_DEBUG ?? "").trim().toLowerCase());
27608
27862
  }
27609
- return TRUTHY2.includes(raw);
27863
+ return TRUTHY3.includes(raw);
27610
27864
  }
27611
27865
  async function autoMigrateOnStartup(migrationDir = "migrations", base = process.cwd()) {
27612
27866
  const dir = resolve15(base, migrationDir);
@@ -28525,8 +28779,9 @@ function wireLocaleGlobal(frondEngine, base) {
28525
28779
  async function startServer(config) {
28526
28780
  loadEnv(".env.local");
28527
28781
  loadEnv();
28528
- const { ensureDevSecret: ensureDevSecret2 } = await Promise.resolve().then(() => (init_auth(), auth_exports));
28782
+ const { ensureDevSecret: ensureDevSecret2, requireBootSecret: requireBootSecret2 } = await Promise.resolve().then(() => (init_auth(), auth_exports));
28529
28783
  ensureDevSecret2();
28784
+ requireBootSecret2();
28530
28785
  _checkLegacyEnvVars();
28531
28786
  const resolved = resolvePortAndHost(config);
28532
28787
  const host = resolved.host;
@@ -30722,6 +30977,178 @@ var init_service = __esm({
30722
30977
  }
30723
30978
  });
30724
30979
 
30980
+ // ../core/src/ssrf.ts
30981
+ import { isIP } from "node:net";
30982
+ import { lookup } from "node:dns/promises";
30983
+ function v4ToBytes(ip) {
30984
+ const parts = ip.split(".");
30985
+ if (parts.length !== 4) return null;
30986
+ const bytes = new Uint8Array(4);
30987
+ for (let i = 0; i < 4; i++) {
30988
+ const n = Number(parts[i]);
30989
+ if (!Number.isInteger(n) || n < 0 || n > 255 || !/^\d+$/.test(parts[i])) return null;
30990
+ bytes[i] = n;
30991
+ }
30992
+ return bytes;
30993
+ }
30994
+ function v6ToBytes(input) {
30995
+ let ip = input.split("%")[0];
30996
+ const lastColon = ip.lastIndexOf(":");
30997
+ const tail = ip.slice(lastColon + 1);
30998
+ if (tail.includes(".")) {
30999
+ const v4 = v4ToBytes(tail);
31000
+ if (!v4) return null;
31001
+ const hextetA = (v4[0] << 8 | v4[1]).toString(16);
31002
+ const hextetB = (v4[2] << 8 | v4[3]).toString(16);
31003
+ ip = ip.slice(0, lastColon + 1) + hextetA + ":" + hextetB;
31004
+ }
31005
+ const halves = ip.split("::");
31006
+ if (halves.length > 2) return null;
31007
+ const head = halves[0] ? halves[0].split(":") : [];
31008
+ const back = halves.length === 2 ? halves[1] ? halves[1].split(":") : [] : [];
31009
+ let groups;
31010
+ if (halves.length === 1) {
31011
+ if (head.length !== 8) return null;
31012
+ groups = head;
31013
+ } else {
31014
+ const missing = 8 - (head.length + back.length);
31015
+ if (missing < 0) return null;
31016
+ groups = [...head, ...Array(missing).fill("0"), ...back];
31017
+ }
31018
+ if (groups.length !== 8) return null;
31019
+ const bytes = new Uint8Array(16);
31020
+ for (let i = 0; i < 8; i++) {
31021
+ if (!/^[0-9a-fA-F]{1,4}$/.test(groups[i])) return null;
31022
+ const g = parseInt(groups[i], 16);
31023
+ bytes[i * 2] = g >> 8 & 255;
31024
+ bytes[i * 2 + 1] = g & 255;
31025
+ }
31026
+ return bytes;
31027
+ }
31028
+ function ipToBytes(ip) {
31029
+ const cleaned = ip.split("%")[0];
31030
+ const family = isIP(cleaned);
31031
+ if (family === 4) return v4ToBytes(cleaned);
31032
+ if (family === 6) {
31033
+ const bytes = v6ToBytes(cleaned);
31034
+ if (!bytes) return null;
31035
+ const isMapped = bytes.subarray(0, 10).every((b) => b === 0) && bytes[10] === 255 && bytes[11] === 255;
31036
+ return isMapped ? bytes.subarray(12) : bytes;
31037
+ }
31038
+ return null;
31039
+ }
31040
+ function parseCidr(cidr) {
31041
+ const [netStr, bitsStr] = cidr.split("/");
31042
+ const net4 = ipToBytes(netStr);
31043
+ if (!net4) return null;
31044
+ return { net: net4, bits: Number(bitsStr) };
31045
+ }
31046
+ function inCidr(packed, cidr) {
31047
+ if (packed.length !== cidr.net.length) return false;
31048
+ const whole = Math.floor(cidr.bits / 8);
31049
+ const remainder = cidr.bits % 8;
31050
+ for (let i = 0; i < whole; i++) {
31051
+ if (packed[i] !== cidr.net[i]) return false;
31052
+ }
31053
+ if (remainder === 0) return true;
31054
+ const mask = 255 << 8 - remainder & 255;
31055
+ return (packed[whole] & mask) === (cidr.net[whole] & mask);
31056
+ }
31057
+ function allowPrivateRequests() {
31058
+ return TRUTHY2.has((process.env[ALLOW_PRIVATE_ENV] ?? "").trim().toLowerCase());
31059
+ }
31060
+ function isBlockedAddress(ip) {
31061
+ const packed = ipToBytes(ip);
31062
+ if (!packed) return true;
31063
+ return BLOCKED.some((cidr) => inCidr(packed, cidr));
31064
+ }
31065
+ function matchesAllowList(host, port, resolved, allowHosts) {
31066
+ const hostLower = host.toLowerCase();
31067
+ for (const raw of allowHosts ?? []) {
31068
+ const entry = String(raw).trim().toLowerCase();
31069
+ if (!entry) continue;
31070
+ if (entry === hostLower || entry === `${hostLower}:${port}`) return true;
31071
+ if (entry.includes("/")) {
31072
+ const cidr = parseCidr(entry);
31073
+ if (cidr && resolved.some((ip) => {
31074
+ const packed = ipToBytes(ip);
31075
+ return packed !== null && inCidr(packed, cidr);
31076
+ })) {
31077
+ return true;
31078
+ }
31079
+ }
31080
+ }
31081
+ return false;
31082
+ }
31083
+ function logBlock(host, ip) {
31084
+ void Promise.resolve().then(() => (init_logger(), logger_exports)).then((mod) => mod.Log?.warning?.(`SSRF guard blocked outbound request to ${host} (${ip})`)).catch(() => void 0);
31085
+ }
31086
+ async function guardUrl(url, allowHosts) {
31087
+ let parsed;
31088
+ try {
31089
+ parsed = new URL(url);
31090
+ } catch {
31091
+ throw new SsrfError(`Blocked request: '${url}' is not a valid URL`);
31092
+ }
31093
+ const scheme = parsed.protocol.replace(/:$/, "").toLowerCase();
31094
+ if (scheme !== "http" && scheme !== "https") {
31095
+ throw new SsrfError(`Blocked request: URL scheme '${scheme || "(none)"}' is not http or https`);
31096
+ }
31097
+ const host = parsed.hostname.replace(/^\[|\]$/g, "");
31098
+ if (!host) {
31099
+ throw new SsrfError("Blocked request: URL has no host");
31100
+ }
31101
+ const port = parsed.port ? Number(parsed.port) : scheme === "https" ? 443 : 80;
31102
+ if (allowPrivateRequests()) return;
31103
+ let resolved;
31104
+ try {
31105
+ const results = await lookup(host, { all: true });
31106
+ resolved = results.map((r) => r.address);
31107
+ } catch {
31108
+ throw new SsrfError(`Blocked request to ${host}: cannot resolve host`);
31109
+ }
31110
+ if (resolved.length === 0) {
31111
+ throw new SsrfError(`Blocked request to ${host}: cannot resolve host`);
31112
+ }
31113
+ if (matchesAllowList(host, port, resolved, allowHosts)) return;
31114
+ for (const ip of resolved) {
31115
+ if (isBlockedAddress(ip)) {
31116
+ logBlock(host, ip);
31117
+ throw new SsrfError(
31118
+ `Blocked request to private/internal address ${ip} (host ${host}): set ${ALLOW_PRIVATE_ENV}=true to allow, or pass an allow-list.`
31119
+ );
31120
+ }
31121
+ }
31122
+ }
31123
+ var ALLOW_PRIVATE_ENV, TRUTHY2, SsrfError, BLOCKED_CIDRS, BLOCKED;
31124
+ var init_ssrf = __esm({
31125
+ "../core/src/ssrf.ts"() {
31126
+ "use strict";
31127
+ ALLOW_PRIVATE_ENV = "TINA4_ALLOW_PRIVATE_REQUESTS";
31128
+ TRUTHY2 = /* @__PURE__ */ new Set(["1", "true", "yes", "on"]);
31129
+ SsrfError = class extends Error {
31130
+ constructor(message) {
31131
+ super(message);
31132
+ this.name = "SsrfError";
31133
+ }
31134
+ };
31135
+ BLOCKED_CIDRS = [
31136
+ "0.0.0.0/8",
31137
+ "10.0.0.0/8",
31138
+ "127.0.0.0/8",
31139
+ "169.254.0.0/16",
31140
+ "172.16.0.0/12",
31141
+ "192.168.0.0/16",
31142
+ "100.64.0.0/10",
31143
+ "::1/128",
31144
+ "::/128",
31145
+ "fc00::/7",
31146
+ "fe80::/10"
31147
+ ];
31148
+ BLOCKED = BLOCKED_CIDRS.map(parseCidr).filter((c) => c !== null);
31149
+ }
31150
+ });
31151
+
30725
31152
  // ../core/src/api.ts
30726
31153
  import http from "node:http";
30727
31154
  import https from "node:https";
@@ -30878,6 +31305,7 @@ var init_api = __esm({
30878
31305
  "../core/src/api.ts"() {
30879
31306
  "use strict";
30880
31307
  init_version();
31308
+ init_ssrf();
30881
31309
  ApiStreamError = class extends Error {
30882
31310
  constructor(message, status2 = null) {
30883
31311
  super(message);
@@ -30932,6 +31360,7 @@ var init_api = __esm({
30932
31360
  transportFn;
30933
31361
  cookiesEnabled;
30934
31362
  cookies;
31363
+ allowHosts;
30935
31364
  /**
30936
31365
  * Construct an Api client.
30937
31366
  *
@@ -30971,6 +31400,7 @@ var init_api = __esm({
30971
31400
  this.transportFn = void 0;
30972
31401
  this.cookiesEnabled = false;
30973
31402
  this.cookies = {};
31403
+ this.allowHosts = [];
30974
31404
  if (typeof authHeaderOrOptions === "object" && authHeaderOrOptions !== null) {
30975
31405
  const opts = authHeaderOrOptions;
30976
31406
  this.authHeader = opts.authHeader ?? "";
@@ -30980,6 +31410,7 @@ var init_api = __esm({
30980
31410
  this.retryBackoff = opts.retryBackoff ?? 0.5;
30981
31411
  this.transportFn = opts.transport;
30982
31412
  this.cookiesEnabled = opts.cookies ?? false;
31413
+ this.allowHosts = opts.allowHosts ?? [];
30983
31414
  if (opts.bearerToken != null) {
30984
31415
  this.setBearerToken(opts.bearerToken);
30985
31416
  } else if (opts.username != null && opts.password != null) {
@@ -31293,7 +31724,12 @@ var init_api = __esm({
31293
31724
  * body-phase timeout is applied by the caller (streamBytes) via
31294
31725
  * `res.destroy()`.
31295
31726
  */
31296
- openStreamRequest(method, url, headers, data, connectSec) {
31727
+ async openStreamRequest(method, url, headers, data, connectSec) {
31728
+ try {
31729
+ await guardUrl(url, this.allowHosts);
31730
+ } catch (err) {
31731
+ throw new ApiStreamError(err instanceof Error ? err.message : String(err), null);
31732
+ }
31297
31733
  return new Promise((resolve21, reject) => {
31298
31734
  let parsed;
31299
31735
  try {
@@ -31352,10 +31788,11 @@ var init_api = __esm({
31352
31788
  */
31353
31789
  buildRequest(method, contentType, body, extraHeaders, targetUrl) {
31354
31790
  const headers = { "User-Agent": `Tina4/${TINA4_VERSION}`, ...this.headers };
31355
- if (this.authHeader) {
31791
+ const sameOriginAsBase = targetUrl === void 0 || sameOrigin(targetUrl, this.baseUrl);
31792
+ if (this.authHeader && sameOriginAsBase) {
31356
31793
  headers["Authorization"] = this.authHeader;
31357
31794
  }
31358
- if (this.cookiesEnabled) {
31795
+ if (this.cookiesEnabled && sameOriginAsBase) {
31359
31796
  const cookieHeader = this.cookieHeader();
31360
31797
  if (cookieHeader) {
31361
31798
  headers["Cookie"] = cookieHeader;
@@ -31456,7 +31893,12 @@ var init_api = __esm({
31456
31893
  * bearer token / session cookie never leaks to a host you didn't
31457
31894
  * authenticate to.
31458
31895
  */
31459
- performRequest(method, url, headers, data, redirectsLeft) {
31896
+ async performRequest(method, url, headers, data, redirectsLeft) {
31897
+ try {
31898
+ await guardUrl(url, this.allowHosts);
31899
+ } catch (err) {
31900
+ return { kind: "error", error: err instanceof Error ? err.message : String(err) };
31901
+ }
31460
31902
  return new Promise((resolve21) => {
31461
31903
  let parsed;
31462
31904
  try {
@@ -34196,6 +34638,7 @@ var CURVE, RECORD_SIZE, MAX_PAYLOAD, PushError, Push;
34196
34638
  var init_push = __esm({
34197
34639
  "../core/src/push.ts"() {
34198
34640
  "use strict";
34641
+ init_ssrf();
34199
34642
  CURVE = "prime256v1";
34200
34643
  RECORD_SIZE = 4096;
34201
34644
  MAX_PAYLOAD = RECORD_SIZE - 17;
@@ -34270,10 +34713,19 @@ var init_push = __esm({
34270
34713
  return { rawPublic, rawPrivate };
34271
34714
  }
34272
34715
  async deliver(endpoint, config, keys, body) {
34716
+ try {
34717
+ await guardUrl(endpoint.toString(), this.options.allowHosts);
34718
+ } catch (error2) {
34719
+ if (error2 instanceof SsrfError) throw new PushError(error2.message);
34720
+ throw error2;
34721
+ }
34273
34722
  let response;
34274
34723
  try {
34275
34724
  response = await fetch(endpoint, {
34276
34725
  method: "POST",
34726
+ // A real push service answers the POST directly; a redirect from a push
34727
+ // endpoint is not followed to a private address (ADR-0084).
34728
+ redirect: "manual",
34277
34729
  headers: {
34278
34730
  Authorization: `vapid t=${vapidToken(endpoint.toString(), config.subject, keys.rawPrivate, keys.rawPublic)}, k=${config.publicKey}`,
34279
34731
  "Content-Encoding": "aes128gcm",
@@ -36360,6 +36812,7 @@ var init_testClient = __esm({
36360
36812
  reqHeaders["content-length"] = String(Buffer.byteLength(rawBody));
36361
36813
  }
36362
36814
  const socket = new Socket();
36815
+ Object.defineProperty(socket, "remoteAddress", { value: "127.0.0.1" });
36363
36816
  const rawReq = new IncomingMessage2(socket);
36364
36817
  rawReq.method = method.toUpperCase();
36365
36818
  rawReq.url = path10;
@@ -36732,6 +37185,7 @@ var init_validator = __esm({
36732
37185
  var src_exports2 = {};
36733
37186
  __export(src_exports2, {
36734
37187
  AI_TOOLS: () => AI_TOOLS,
37188
+ ALLOW_PRIVATE_ENV: () => ALLOW_PRIVATE_ENV,
36735
37189
  APPLICATION_FORM: () => APPLICATION_FORM,
36736
37190
  APPLICATION_JSON: () => APPLICATION_JSON,
36737
37191
  APPLICATION_OCTET: () => APPLICATION_OCTET,
@@ -36839,6 +37293,7 @@ __export(src_exports2, {
36839
37293
  Session: () => Session,
36840
37294
  Sso: () => Sso,
36841
37295
  SsoError: () => SsoError,
37296
+ SsrfError: () => SsrfError,
36842
37297
  TAKEOVER_KILLED: () => TAKEOVER_KILLED,
36843
37298
  TAKEOVER_NOTHING: () => TAKEOVER_NOTHING,
36844
37299
  TAKEOVER_REFUSALS: () => TAKEOVER_REFUSALS,
@@ -36872,6 +37327,7 @@ __export(src_exports2, {
36872
37327
  addHtmlHelpers: () => addHtmlHelpers,
36873
37328
  algorithmAvailable: () => algorithmAvailable,
36874
37329
  allEnv: () => allEnv,
37330
+ allowPrivateRequests: () => allowPrivateRequests,
36875
37331
  any: () => any,
36876
37332
  attachCsrfFromEnv: () => attachCsrfFromEnv,
36877
37333
  authMiddleware: () => authMiddleware,
@@ -36937,6 +37393,7 @@ __export(src_exports2, {
36937
37393
  graphqlAutoSchemaEnabled: () => graphqlAutoSchemaEnabled,
36938
37394
  graphqlEndpoint: () => graphqlEndpoint,
36939
37395
  graphqlMaxDepth: () => graphqlMaxDepth,
37396
+ guardUrl: () => guardUrl,
36940
37397
  handle: () => handle,
36941
37398
  handleFeedbackTurn: () => handleFeedbackTurn,
36942
37399
  handleFeedbackWidgetJs: () => handleFeedbackWidgetJs,
@@ -36950,6 +37407,7 @@ __export(src_exports2, {
36950
37407
  installAll: () => installAll,
36951
37408
  installSelected: () => installSelected,
36952
37409
  isBannerSuppressed: () => isBannerSuppressed,
37410
+ isBlockedAddress: () => isBlockedAddress,
36953
37411
  isDebugMode: () => isDebugMode,
36954
37412
  isDev: () => isDev,
36955
37413
  isInstalled: () => isInstalled,
@@ -37069,6 +37527,7 @@ var init_src2 = __esm({
37069
37527
  init_service();
37070
37528
  init_cache();
37071
37529
  init_api();
37530
+ init_ssrf();
37072
37531
  init_context();
37073
37532
  init_events();
37074
37533
  init_devAdmin();
@@ -44910,11 +45369,11 @@ async function eagerLoadBelongsToRelation(instances, model, relatedClass, relati
44910
45369
  const relatedPkColumn = relatedClass.getDbColumn(relatedPk);
44911
45370
  const related = await eagerQueryRelated(relatedClass, relatedPkColumn, values);
44912
45371
  if (nested.length > 0 && related.length > 0) await relatedClass._eagerLoad(related, nested);
44913
- const lookup = {};
44914
- for (const record of related) lookup[_joinKey(record[relatedPk])] = record;
45372
+ const lookup2 = {};
45373
+ for (const record of related) lookup2[_joinKey(record[relatedPk])] = record;
44915
45374
  for (const instance of instances) {
44916
45375
  const value = instance[fkProp];
44917
- relationshipCache(instance)[relationName] = value !== void 0 && value !== null ? lookup[_joinKey(value)] ?? null : null;
45376
+ relationshipCache(instance)[relationName] = value !== void 0 && value !== null ? lookup2[_joinKey(value)] ?? null : null;
44918
45377
  }
44919
45378
  }
44920
45379
  async function eagerLoadRelation(instances, model, relationName, nested) {