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
@@ -950,6 +950,8 @@ var auth_exports = {};
950
950
  __export(auth_exports, {
951
951
  Auth: () => Auth,
952
952
  JWT_LEEWAY_SECONDS: () => JWT_LEEWAY_SECONDS,
953
+ MIN_SECRET_BYTES: () => MIN_SECRET_BYTES,
954
+ NON_IDENTITY_TOKEN_TYPES: () => NON_IDENTITY_TOKEN_TYPES,
953
955
  algorithmAvailable: () => algorithmAvailable,
954
956
  authMiddleware: () => authMiddleware,
955
957
  authenticateRequest: () => authenticateRequest,
@@ -959,7 +961,10 @@ __export(auth_exports, {
959
961
  getPayload: () => getPayload,
960
962
  getToken: () => getToken,
961
963
  hashPassword: () => hashPassword,
964
+ insecureSecretMessage: () => insecureSecretMessage,
965
+ isIdentityPayload: () => isIdentityPayload,
962
966
  refreshToken: () => refreshToken,
967
+ requireBootSecret: () => requireBootSecret,
963
968
  resolveAlgorithm: () => resolveAlgorithm,
964
969
  validToken: () => validToken,
965
970
  validateApiKey: () => validateApiKey
@@ -995,6 +1000,23 @@ async function _logWarning(message) {
995
1000
  function _warnBlankSecret() {
996
1001
  void _logWarning(BLANK_SECRET_WARNING);
997
1002
  }
1003
+ function isIdentityPayload(payload) {
1004
+ return payload != null && !NON_IDENTITY_TOKEN_TYPES.includes(payload.type);
1005
+ }
1006
+ function insecureSecretMessage(secret) {
1007
+ const length = Buffer.byteLength(secret ?? "", "utf8");
1008
+ if (length >= MIN_SECRET_BYTES) return null;
1009
+ const what = length === 0 ? "is not set" : `is ${length} bytes`;
1010
+ 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.`;
1011
+ }
1012
+ function requireBootSecret() {
1013
+ const algorithm = (process.env.TINA4_JWT_ALGORITHM ?? "").trim() || "HS256";
1014
+ if (RSA_SIGN_ALGORITHMS.has(algorithm)) return;
1015
+ const secret = process.env.TINA4_SECRET ?? "";
1016
+ if (secret === "" && _isDev()) return;
1017
+ const message = insecureSecretMessage(secret);
1018
+ if (message) throw new Error(message);
1019
+ }
998
1020
  function ensureDevSecret(cwd) {
999
1021
  if (process.env.TINA4_SECRET) return null;
1000
1022
  if (!_isDev() || _isCi() || _isProduction()) {
@@ -1125,6 +1147,13 @@ function validToken(token, secret, algorithm) {
1125
1147
  const [h, p, sig] = parts;
1126
1148
  const header = JSON.parse(base64urlDecode(h).toString());
1127
1149
  if (header.alg !== resolvedAlgorithm) return null;
1150
+ if (HMAC_DIGESTS.has(resolvedAlgorithm)) {
1151
+ const weak = insecureSecretMessage(resolvedSecret);
1152
+ if (weak) {
1153
+ void _logWarning(weak);
1154
+ return null;
1155
+ }
1156
+ }
1128
1157
  const signingInput = `${h}.${p}`;
1129
1158
  if (!verifySignature(signingInput, sig, resolvedSecret, resolvedAlgorithm)) {
1130
1159
  return null;
@@ -1156,6 +1185,8 @@ function getPayload(token) {
1156
1185
  function sign(input, secret, algorithm) {
1157
1186
  const digest = HMAC_DIGESTS.get(algorithm);
1158
1187
  if (digest) {
1188
+ const weak = insecureSecretMessage(secret);
1189
+ if (weak) throw new Error(weak);
1159
1190
  return base64urlEncode(createHmac(digest, secret).update(input).digest());
1160
1191
  }
1161
1192
  const rsaAlgorithm = RSA_SIGN_ALGORITHMS.get(algorithm);
@@ -1213,17 +1244,17 @@ function authMiddleware(secret, algorithm) {
1213
1244
  return;
1214
1245
  }
1215
1246
  const token = authHeader.slice(7);
1216
- if (!validToken(token, secret, algorithm)) {
1247
+ const payload = validToken(token, secret, algorithm);
1248
+ if (!isIdentityPayload(payload)) {
1217
1249
  res({ error: "Unauthorized" }, 401);
1218
1250
  return;
1219
1251
  }
1220
- req2.auth = getPayload(token);
1252
+ req2.auth = payload;
1221
1253
  next();
1222
1254
  };
1223
1255
  }
1224
1256
  function refreshToken(token, expiresIn = 60) {
1225
- if (!validToken(token)) return null;
1226
- const payload = getPayload(token);
1257
+ const payload = validToken(token);
1227
1258
  if (!payload) return null;
1228
1259
  const { iat: _iat, exp: _exp, ...claims } = payload;
1229
1260
  return getToken(claims, expiresIn);
@@ -1232,7 +1263,8 @@ function authenticateRequest(headers, secret, algorithm) {
1232
1263
  const authHeader = headers.authorization ?? headers.Authorization ?? "";
1233
1264
  if (!authHeader.startsWith("Bearer ")) return null;
1234
1265
  const token = authHeader.slice(7);
1235
- if (validToken(token, secret, algorithm)) return getPayload(token);
1266
+ const payload = validToken(token, secret, algorithm);
1267
+ if (payload) return isIdentityPayload(payload) ? payload : null;
1236
1268
  if (validateApiKey(token)) {
1237
1269
  return { _auth: "api_key" };
1238
1270
  }
@@ -1246,12 +1278,14 @@ function validateApiKey(provided, expected) {
1246
1278
  if (a.length !== b.length) return false;
1247
1279
  return timingSafeEqual(a, b);
1248
1280
  }
1249
- var BLANK_SECRET_WARNING, HMAC_DIGESTS, RSA_SIGN_ALGORITHMS, KNOWN_ALGORITHMS, ALGORITHM_REMEDY, capabilityCache, JWT_LEEWAY_SECONDS, Auth;
1281
+ 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;
1250
1282
  var init_auth = __esm({
1251
1283
  "src/auth.ts"() {
1252
1284
  "use strict";
1253
1285
  init_dotenv();
1254
1286
  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.";
1287
+ NON_IDENTITY_TOKEN_TYPES = ["form"];
1288
+ MIN_SECRET_BYTES = 32;
1255
1289
  HMAC_DIGESTS = /* @__PURE__ */ new Map([
1256
1290
  ["HS256", "sha256"],
1257
1291
  ["HS384", "sha384"],
@@ -10775,11 +10809,11 @@ async function eagerLoadBelongsToRelation(instances, model, relatedClass, relati
10775
10809
  const relatedPkColumn = relatedClass.getDbColumn(relatedPk);
10776
10810
  const related = await eagerQueryRelated(relatedClass, relatedPkColumn, values);
10777
10811
  if (nested.length > 0 && related.length > 0) await relatedClass._eagerLoad(related, nested);
10778
- const lookup = {};
10779
- for (const record of related) lookup[_joinKey(record[relatedPk])] = record;
10812
+ const lookup2 = {};
10813
+ for (const record of related) lookup2[_joinKey(record[relatedPk])] = record;
10780
10814
  for (const instance of instances) {
10781
10815
  const value = instance[fkProp];
10782
- relationshipCache(instance)[relationName] = value !== void 0 && value !== null ? lookup[_joinKey(value)] ?? null : null;
10816
+ relationshipCache(instance)[relationName] = value !== void 0 && value !== null ? lookup2[_joinKey(value)] ?? null : null;
10783
10817
  }
10784
10818
  }
10785
10819
  async function eagerLoadRelation(instances, model, relationName, nested) {
@@ -15421,9 +15455,9 @@ var init_cache = __esm({
15421
15455
  return entry.value;
15422
15456
  }
15423
15457
  async set(key, value, ttl) {
15424
- const expiresAt = ttl > 0 ? Date.now() + ttl * 1e3 : 0;
15458
+ const expiresAt2 = ttl > 0 ? Date.now() + ttl * 1e3 : 0;
15425
15459
  this.store.delete(key);
15426
- this.store.set(key, { value, expiresAt });
15460
+ this.store.set(key, { value, expiresAt: expiresAt2 });
15427
15461
  while (this.store.size > this.maxEntries) {
15428
15462
  const firstKey = this.store.keys().next().value;
15429
15463
  if (firstKey !== void 0) this.store.delete(firstKey);
@@ -15856,8 +15890,8 @@ ${s}\r
15856
15890
  const oldest = files.shift();
15857
15891
  if (oldest) fs.unlinkSync(path.join(this.dir, oldest.name));
15858
15892
  }
15859
- const expiresAt = ttl > 0 ? Date.now() / 1e3 + ttl : 0;
15860
- const entry = { key, value, expiresAt };
15893
+ const expiresAt2 = ttl > 0 ? Date.now() / 1e3 + ttl : 0;
15894
+ const entry = { key, value, expiresAt: expiresAt2 };
15861
15895
  fs.writeFileSync(this.keyPath(key), JSON.stringify(entry));
15862
15896
  } catch {
15863
15897
  }
@@ -17604,8 +17638,8 @@ function capCache(cache, maxEntries) {
17604
17638
  }
17605
17639
  function sweepExpiredCache(cache) {
17606
17640
  const now = Date.now();
17607
- for (const [key, [, expiresAt]] of cache) {
17608
- if (expiresAt <= now) cache.delete(key);
17641
+ for (const [key, [, expiresAt2]] of cache) {
17642
+ if (expiresAt2 <= now) cache.delete(key);
17609
17643
  }
17610
17644
  }
17611
17645
  function tokenize(source) {
@@ -18577,6 +18611,12 @@ function setFormTokenSessionId(sessionId) {
18577
18611
  }
18578
18612
  function _buildFormTokenJwt(descriptor = "") {
18579
18613
  const secret = process.env.TINA4_SECRET ?? "";
18614
+ const secretBytes = Buffer.byteLength(secret, "utf8");
18615
+ if (secretBytes < 32) {
18616
+ throw new Error(
18617
+ `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.`
18618
+ );
18619
+ }
18580
18620
  const ttlMinutes = parseInt(process.env.TINA4_TOKEN_LIMIT || "60", 10);
18581
18621
  const header = { alg: "HS256", typ: "JWT" };
18582
18622
  const now = Math.floor(Date.now() / 1e3);
@@ -20118,8 +20158,8 @@ var init_engine = __esm({
20118
20158
  sweepExpiredCache(this.fragmentCache);
20119
20159
  const cached = this.fragmentCache.get(cacheKey);
20120
20160
  if (cached) {
20121
- const [htmlContent, expiresAt] = cached;
20122
- if (Date.now() < expiresAt) {
20161
+ const [htmlContent, expiresAt2] = cached;
20162
+ if (Date.now() < expiresAt2) {
20123
20163
  let i2 = start2 + 1;
20124
20164
  let depth = 0;
20125
20165
  while (i2 < tokens.length) {
@@ -20912,7 +20952,7 @@ function csrfBearerIsValid(req2, secret) {
20912
20952
  const authHeader = req2.headers.authorization ?? "";
20913
20953
  if (!authHeader.startsWith("Bearer ")) return false;
20914
20954
  const bearerToken = authHeader.slice(7).trim();
20915
- return Boolean(bearerToken && validToken(bearerToken, secret));
20955
+ return Boolean(bearerToken && isIdentityPayload(validToken(bearerToken, secret)));
20916
20956
  }
20917
20957
  function csrfRequestToken(req2) {
20918
20958
  const query = req2.query ?? {};
@@ -22049,6 +22089,22 @@ var init_transport = __esm({
22049
22089
  }
22050
22090
  });
22051
22091
 
22092
+ // src/ssoIdentity.ts
22093
+ function liveSessionIdentity(stored) {
22094
+ if (!stored || typeof stored !== "object") return null;
22095
+ const identity = stored.identity;
22096
+ if (!identity || typeof identity !== "object" || !identity.issuer || !identity.subject) return null;
22097
+ const expiresAt2 = stored.expires_at ?? 0;
22098
+ if (typeof expiresAt2 !== "number") return null;
22099
+ if (expiresAt2 > 0 && Math.floor(Date.now() / 1e3) >= expiresAt2) return null;
22100
+ return identity;
22101
+ }
22102
+ var init_ssoIdentity = __esm({
22103
+ "src/ssoIdentity.ts"() {
22104
+ "use strict";
22105
+ }
22106
+ });
22107
+
22052
22108
  // src/authGate.ts
22053
22109
  function enforceRouteAuth(req2, res, match, isDevAdmin) {
22054
22110
  if (!(match.secure === true && match.noAuth !== true && !isDevAdmin)) {
@@ -22056,38 +22112,50 @@ function enforceRouteAuth(req2, res, match, isDevAdmin) {
22056
22112
  }
22057
22113
  const authHeader = req2.headers.authorization ?? "";
22058
22114
  const headerToken = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : "";
22059
- let resolvedToken = "";
22115
+ let payload = null;
22060
22116
  let tokenSource = "";
22061
- if (headerToken && validToken(headerToken)) {
22062
- resolvedToken = headerToken;
22063
- tokenSource = "header";
22117
+ let resolvedToken = "";
22118
+ if (headerToken) {
22119
+ const candidate = validToken(headerToken);
22120
+ if (isIdentityPayload(candidate)) {
22121
+ payload = candidate;
22122
+ tokenSource = "header";
22123
+ resolvedToken = headerToken;
22124
+ }
22064
22125
  }
22065
- if (!resolvedToken) {
22126
+ if (!payload) {
22066
22127
  const bodyToken = req2.body?.formToken;
22067
- if (bodyToken && validToken(bodyToken)) {
22068
- resolvedToken = bodyToken;
22069
- tokenSource = "body";
22128
+ if (bodyToken) {
22129
+ const candidate = validToken(bodyToken);
22130
+ if (isIdentityPayload(candidate)) {
22131
+ payload = candidate;
22132
+ tokenSource = "body";
22133
+ resolvedToken = bodyToken;
22134
+ }
22070
22135
  }
22071
22136
  }
22072
- if (!resolvedToken) {
22073
- const sso = req2.session?.get?.("_tina4_sso");
22074
- const identity = sso?.identity;
22075
- if (identity?.issuer && identity?.subject) {
22137
+ if (!payload) {
22138
+ const identity = liveSessionIdentity(req2.session?.get?.("_tina4_sso"));
22139
+ if (identity) {
22076
22140
  req2.user = identity;
22077
22141
  return rbacForbidden(match, identity, res);
22078
22142
  }
22079
22143
  const sessionToken = req2.session?.get?.("token");
22080
- if (sessionToken && validToken(sessionToken)) {
22081
- resolvedToken = sessionToken;
22082
- tokenSource = "session";
22144
+ if (sessionToken) {
22145
+ const candidate = validToken(sessionToken);
22146
+ if (isIdentityPayload(candidate)) {
22147
+ payload = candidate;
22148
+ tokenSource = "session";
22149
+ resolvedToken = sessionToken;
22150
+ }
22083
22151
  }
22084
22152
  }
22085
- if (!resolvedToken) {
22153
+ if (!payload) {
22086
22154
  res.raw.writeHead(401, { "Content-Type": "application/json" });
22087
22155
  res.raw.end(JSON.stringify({ error: "Unauthorized" }));
22088
22156
  return true;
22089
22157
  }
22090
- req2.user = getPayload(resolvedToken) ?? {};
22158
+ req2.user = payload;
22091
22159
  if (tokenSource === "body") {
22092
22160
  const fresh = refreshToken(resolvedToken);
22093
22161
  if (fresh) {
@@ -22137,6 +22205,7 @@ var init_authGate = __esm({
22137
22205
  "src/authGate.ts"() {
22138
22206
  "use strict";
22139
22207
  init_auth();
22208
+ init_ssoIdentity();
22140
22209
  }
22141
22210
  });
22142
22211
 
@@ -23210,8 +23279,8 @@ var init_mongoHandler = __esm({
23210
23279
  if (!result || result === "__EMPTY__") return null;
23211
23280
  try {
23212
23281
  const doc = JSON.parse(result);
23213
- const expiresAt = Number(doc?.expires_at ?? 0);
23214
- if (expiresAt > 0 && Date.now() / 1e3 > expiresAt) {
23282
+ const expiresAt2 = Number(doc?.expires_at ?? 0);
23283
+ if (expiresAt2 > 0 && Date.now() / 1e3 > expiresAt2) {
23215
23284
  this.destroy(sessionId);
23216
23285
  return null;
23217
23286
  }
@@ -23437,6 +23506,9 @@ parentPort.on("message", (message) => {
23437
23506
 
23438
23507
  // src/sessionHandlers/databaseHandler.ts
23439
23508
  import { DatabaseSync as DatabaseSync3 } from "node:sqlite";
23509
+ function sleepSync2(ms) {
23510
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
23511
+ }
23440
23512
  function column(row, name) {
23441
23513
  if (name in row) return row[name];
23442
23514
  return row[name.toUpperCase()];
@@ -23445,7 +23517,7 @@ function schemeOf2(url) {
23445
23517
  const match = url.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):/);
23446
23518
  return match ? match[1].toLowerCase() : "unknown";
23447
23519
  }
23448
- var CREATE_TABLE, DatabaseSessionHandler;
23520
+ var CREATE_TABLE, CREATE_ATTEMPTS, CREATE_RETRY_DELAY_MS, DatabaseSessionHandler;
23449
23521
  var init_databaseHandler = __esm({
23450
23522
  "src/sessionHandlers/databaseHandler.ts"() {
23451
23523
  "use strict";
@@ -23525,6 +23597,8 @@ var init_databaseHandler = __esm({
23525
23597
  END
23526
23598
  `
23527
23599
  };
23600
+ CREATE_ATTEMPTS = 5;
23601
+ CREATE_RETRY_DELAY_MS = 50;
23528
23602
  DatabaseSessionHandler = class {
23529
23603
  sqliteHandle = null;
23530
23604
  /** Set for SQLite. Null when this handler talks to a networked engine. */
@@ -23565,6 +23639,7 @@ var init_databaseHandler = __esm({
23565
23639
  get sqlite() {
23566
23640
  if (this.sqliteHandle === null) {
23567
23641
  this.sqliteHandle = new DatabaseSync3(this.dbPath);
23642
+ this.sqliteHandle.exec("PRAGMA busy_timeout = 5000");
23568
23643
  this.sqliteHandle.exec("PRAGMA journal_mode = WAL");
23569
23644
  }
23570
23645
  return this.sqliteHandle;
@@ -23652,15 +23727,55 @@ var init_databaseHandler = __esm({
23652
23727
  sqlCommandSync(this.target, sql, params);
23653
23728
  }
23654
23729
  /**
23655
- * Ensure the session table exists (called once on first use).
23730
+ * Ensure the session table exists (called once on first use) - safely when
23731
+ * several processes race to create it.
23732
+ *
23733
+ * Every per-engine statement above is check-then-act, so two processes can
23734
+ * both find the table absent and both CREATE; the loser errors, and every
23735
+ * engine spells that differently (a pg_type unique violation on PostgreSQL,
23736
+ * Msg 2714 on SQL Server, an RDB$RELATIONS unique violation on Firebird). So
23737
+ * a failure is judged by RE-CHECKING whether the table now exists, never by
23738
+ * parsing the message - the rescue the Firebird note above has always
23739
+ * assumed, and which was not here. MEASURED on the lab before it: six real
23740
+ * processes racing lost one to five of them on PostgreSQL, SQLite and
23741
+ * Firebird.
23742
+ *
23743
+ * The re-check is RETRIED because the loser can fail BEFORE the winner has
23744
+ * committed. On MySQL, CREATE TABLE IF NOT EXISTS takes a shared metadata
23745
+ * lock, checks, then upgrades it to exclusive; two upgrades deadlock, and when
23746
+ * MySQL cannot back the victim off silently it answers 1213 "Deadlock found"
23747
+ * while the winner's table is still invisible (tina4-php CI run 35972320442).
23748
+ * Bounded, so a genuine failure (no CREATE grant) still throws. Same attempts
23749
+ * and delays in all four frameworks.
23656
23750
  */
23657
23751
  ensureTable() {
23658
23752
  if (this.initialized) return;
23659
23753
  const ddl = CREATE_TABLE[this.engine];
23660
- if (this.target === null) this.sqlite.exec(ddl);
23661
- else sqlCommandSync(this.target, ddl, []);
23754
+ for (let attempt = 1; ; attempt++) {
23755
+ try {
23756
+ if (this.target === null) this.sqlite.exec(ddl);
23757
+ else sqlCommandSync(this.target, ddl, []);
23758
+ break;
23759
+ } catch (error2) {
23760
+ if (this.tableExists()) break;
23761
+ if (attempt >= CREATE_ATTEMPTS) throw error2;
23762
+ sleepSync2(CREATE_RETRY_DELAY_MS * attempt);
23763
+ }
23764
+ }
23662
23765
  this.initialized = true;
23663
23766
  }
23767
+ /**
23768
+ * Whether tina4_session exists, asked the one way every engine answers alike:
23769
+ * a query against it that returns no rows succeeds only if it is there.
23770
+ */
23771
+ tableExists() {
23772
+ try {
23773
+ this.query("SELECT 1 FROM tina4_session WHERE 1 = 0", []);
23774
+ return true;
23775
+ } catch {
23776
+ return false;
23777
+ }
23778
+ }
23664
23779
  read(sessionId) {
23665
23780
  this.ensureTable();
23666
23781
  const rows = this.query(
@@ -23670,8 +23785,8 @@ var init_databaseHandler = __esm({
23670
23785
  const row = rows[0];
23671
23786
  if (!row) return null;
23672
23787
  const now = Date.now() / 1e3;
23673
- const expiresAt = Number(column(row, "expires_at") ?? 0);
23674
- if (expiresAt > 0 && expiresAt < now) {
23788
+ const expiresAt2 = Number(column(row, "expires_at") ?? 0);
23789
+ if (expiresAt2 > 0 && expiresAt2 < now) {
23675
23790
  this.destroy(sessionId);
23676
23791
  return null;
23677
23792
  }
@@ -23684,7 +23799,7 @@ var init_databaseHandler = __esm({
23684
23799
  write(sessionId, data, ttl) {
23685
23800
  this.ensureTable();
23686
23801
  const json = JSON.stringify(data);
23687
- const expiresAt = ttl > 0 ? Date.now() / 1e3 + ttl : 0;
23802
+ const expiresAt2 = ttl > 0 ? Date.now() / 1e3 + ttl : 0;
23688
23803
  const existing = this.query(
23689
23804
  "SELECT session_id FROM tina4_session WHERE session_id = ?",
23690
23805
  [sessionId]
@@ -23692,12 +23807,12 @@ var init_databaseHandler = __esm({
23692
23807
  if (existing.length > 0) {
23693
23808
  this.exec(
23694
23809
  "UPDATE tina4_session SET data = ?, expires_at = ? WHERE session_id = ?",
23695
- [json, expiresAt, sessionId]
23810
+ [json, expiresAt2, sessionId]
23696
23811
  );
23697
23812
  } else {
23698
23813
  this.exec(
23699
23814
  "INSERT INTO tina4_session (session_id, data, expires_at) VALUES (?, ?, ?)",
23700
- [sessionId, json, expiresAt]
23815
+ [sessionId, json, expiresAt2]
23701
23816
  );
23702
23817
  }
23703
23818
  }
@@ -27692,7 +27807,7 @@ function envTruthy(val) {
27692
27807
  return ["true", "1", "yes", "on"].includes(val.trim().toLowerCase());
27693
27808
  }
27694
27809
  function isLoopback(ip) {
27695
- if (ip == null || ip === "") return true;
27810
+ if (ip == null || ip === "") return false;
27696
27811
  let addr = ip.trim().toLowerCase();
27697
27812
  if (addr.startsWith("::ffff:")) addr = addr.slice(7);
27698
27813
  return addr === "::1" || addr === "localhost" || addr.startsWith("127.");
@@ -29283,6 +29398,7 @@ __export(graphql_exports, {
29283
29398
  graphqlAutoSchemaEnabled: () => graphqlAutoSchemaEnabled,
29284
29399
  graphqlEndpoint: () => graphqlEndpoint,
29285
29400
  graphqlMaxDepth: () => graphqlMaxDepth,
29401
+ graphqlMaxNodes: () => graphqlMaxNodes,
29286
29402
  tokenize: () => tokenize2
29287
29403
  });
29288
29404
  function tokenize2(source) {
@@ -29322,7 +29438,12 @@ function graphqlMaxDepth() {
29322
29438
  const n = parseInt(raw, 10);
29323
29439
  return Number.isNaN(n) ? 50 : n;
29324
29440
  }
29325
- var TOKEN_PATTERNS, ParseError, Parser, GraphQL, GraphQLType;
29441
+ function graphqlMaxNodes() {
29442
+ const raw = (process.env.TINA4_GRAPHQL_MAX_NODES ?? "1000").trim();
29443
+ const n = parseInt(raw, 10);
29444
+ return Number.isNaN(n) ? 1e3 : n;
29445
+ }
29446
+ var TOKEN_PATTERNS, ParseError, Parser, ComplexityError, GraphQL, GraphQLType;
29326
29447
  var init_graphql = __esm({
29327
29448
  "src/graphql.ts"() {
29328
29449
  "use strict";
@@ -29361,9 +29482,16 @@ var init_graphql = __esm({
29361
29482
  Parser = class {
29362
29483
  tokens;
29363
29484
  pos;
29485
+ // F2: bound the parser's own recursion. A deeply nested query
29486
+ // ("{a{a{a...}}}") otherwise recurses in parseSelectionSet until the call
29487
+ // stack overflows — BEFORE the execution-time depth guard can run. Reusing
29488
+ // TINA4_GRAPHQL_MAX_DEPTH keeps parse and execution on one bound.
29489
+ depth = 0;
29490
+ maxDepth;
29364
29491
  constructor(tokens) {
29365
29492
  this.tokens = tokens;
29366
29493
  this.pos = 0;
29494
+ this.maxDepth = graphqlMaxDepth();
29367
29495
  }
29368
29496
  peek() {
29369
29497
  return this.pos < this.tokens.length ? this.tokens[this.pos] : null;
@@ -29445,6 +29573,17 @@ var init_graphql = __esm({
29445
29573
  };
29446
29574
  }
29447
29575
  parseSelectionSet() {
29576
+ this.depth++;
29577
+ if (this.maxDepth > 0 && this.depth > this.maxDepth) {
29578
+ throw new Error(`Query exceeds maximum depth of ${this.maxDepth}`);
29579
+ }
29580
+ try {
29581
+ return this.parseSelectionSetInner();
29582
+ } finally {
29583
+ this.depth--;
29584
+ }
29585
+ }
29586
+ parseSelectionSetInner() {
29448
29587
  this.expect("LBRACE");
29449
29588
  const selections = [];
29450
29589
  while (!this.match("RBRACE")) {
@@ -29599,6 +29738,8 @@ var init_graphql = __esm({
29599
29738
  return t;
29600
29739
  }
29601
29740
  };
29741
+ ComplexityError = class extends Error {
29742
+ };
29602
29743
  GraphQL = class _GraphQL {
29603
29744
  types = /* @__PURE__ */ new Map();
29604
29745
  queries = /* @__PURE__ */ new Map();
@@ -29621,6 +29762,12 @@ var init_graphql = __esm({
29621
29762
  * spread / inline fragment, so circular fragments are caught too.
29622
29763
  */
29623
29764
  maxDepth = graphqlMaxDepth();
29765
+ /**
29766
+ * Maximum expanded selection nodes per query (from `TINA4_GRAPHQL_MAX_NODES`,
29767
+ * default 1000; `<= 0` disables). Bounds query WIDTH — fragment bombs and
29768
+ * alias explosions — which the depth guard does not. See {@link graphqlMaxNodes}.
29769
+ */
29770
+ maxNodes = graphqlMaxNodes();
29624
29771
  /**
29625
29772
  * Decorator-style resolver registration.
29626
29773
  *
@@ -29768,6 +29915,16 @@ var init_graphql = __esm({
29768
29915
  return { data: null, errors: [{ message: "No operation found" }] };
29769
29916
  }
29770
29917
  const op = operations[0];
29918
+ if (this.maxNodes > 0) {
29919
+ try {
29920
+ this.assertComplexity(op.selections, fragments);
29921
+ } catch (e) {
29922
+ if (e instanceof ComplexityError) {
29923
+ return { data: null, errors: [{ message: e.message }] };
29924
+ }
29925
+ throw e;
29926
+ }
29927
+ }
29771
29928
  const resolvers = op.operation === "query" ? this.queries : this.mutations;
29772
29929
  for (const vdef of op.variables) {
29773
29930
  if (!(vdef.name in vars) && vdef.default !== void 0) {
@@ -29793,6 +29950,36 @@ var init_graphql = __esm({
29793
29950
  * instead of recursing until the interpreter stack overflows. Top-level
29794
29951
  * starts at depth 1; `maxDepth <= 0` disables the guard.
29795
29952
  */
29953
+ /**
29954
+ * Count the expanded selection nodes, throwing {@link ComplexityError} past
29955
+ * `maxNodes` (F2). Fragment spreads are expanded (so a fragment bomb is
29956
+ * counted, not the small unexpanded document) and every field — including
29957
+ * each alias — counts once. The count is capped by `maxNodes` and the walk by
29958
+ * `maxDepth`, so a circular fragment trips the budget instead of looping and
29959
+ * the check itself can never cost more than the budget.
29960
+ */
29961
+ assertComplexity(selections, fragments) {
29962
+ const depthCap = this.maxDepth > 0 ? this.maxDepth : 1e3;
29963
+ let count = 0;
29964
+ const walk2 = (sels, depth) => {
29965
+ if (depth > depthCap) return;
29966
+ for (const sel of sels) {
29967
+ count++;
29968
+ if (count > this.maxNodes) {
29969
+ throw new ComplexityError(`Query exceeds maximum complexity of ${this.maxNodes} nodes`);
29970
+ }
29971
+ if (sel.kind === "fragment_spread") {
29972
+ const frag = fragments.get(sel.name);
29973
+ if (frag) walk2(frag.selections, depth + 1);
29974
+ } else if (sel.kind === "inline_fragment") {
29975
+ walk2(sel.selections, depth + 1);
29976
+ } else if (sel.kind === "field" && sel.selections) {
29977
+ walk2(sel.selections, depth + 1);
29978
+ }
29979
+ }
29980
+ };
29981
+ walk2(selections, 1);
29982
+ }
29796
29983
  async resolveSelectionsInto(selections, resolvers, parent, variables, context, fragments, target, depth) {
29797
29984
  const errors = [];
29798
29985
  if (this.maxDepth > 0 && depth > this.maxDepth) {
@@ -30995,7 +31182,8 @@ function wsAuthorized(route, headers, queryString = "", subprotocol = "") {
30995
31182
  const token = wsToken(headers, queryString, subprotocol);
30996
31183
  if (!token) return [null, false];
30997
31184
  const payload = validToken(token);
30998
- return [payload, payload !== null];
31185
+ if (!isIdentityPayload(payload)) return [null, false];
31186
+ return [payload, true];
30999
31187
  }
31000
31188
  function offeredBearerSubprotocol(subprotocol) {
31001
31189
  return subprotocol.split(",").some((p) => p.trim().toLowerCase() === "bearer");
@@ -32190,12 +32378,36 @@ var init_mongoBackend = __esm({
32190
32378
  }
32191
32379
  }
32192
32380
  else if (operation === "size") {
32193
- const count = await col.countDocuments({
32194
- queue: queueName,
32195
- status: "pending",
32196
- });
32381
+ // ADR-0022 dec 7: size(status) must not answer a different question.
32382
+ // The dead aliases count the .dead_letter store (== deadLetters().length);
32383
+ // reserved/completed count by status; else pending. data = status.
32384
+ const status = data || "pending";
32385
+ let count: number;
32386
+ if (status === "dead" || status === "failed" || status === "dead_letter") {
32387
+ count = await col.countDocuments({ queue: queueName + ".dead_letter" });
32388
+ } else if (status === "reserved" || status === "completed") {
32389
+ count = await col.countDocuments({ queue: queueName, status });
32390
+ } else {
32391
+ count = await col.countDocuments({ queue: queueName, status: "pending" });
32392
+ }
32197
32393
  process.stdout.write(String(count));
32198
32394
  }
32395
+ else if (operation === "reject") {
32396
+ // Dead-letter NOW, no retry (ADR-0023). Floor attempts at maxRetries
32397
+ // so it reads as exhausted (parity with fail()'s dead-letter branch
32398
+ // and size("dead")). data = JSON { id, reason, maxRetries }.
32399
+ const info = JSON.parse(data);
32400
+ const doc = await col.findOne({ queue: queueName, id: info.id });
32401
+ if (doc) {
32402
+ const attempts = Math.max((doc.attempts || 0) + 1, info.maxRetries);
32403
+ await col.insertOne({
32404
+ ...doc, _id: undefined, attempts,
32405
+ status: "dead", queue: queueName + ".dead_letter", error: info.reason,
32406
+ });
32407
+ await col.deleteOne({ _id: doc._id, queue: queueName });
32408
+ }
32409
+ process.stdout.write("__OK__");
32410
+ }
32199
32411
  else if (operation === "clear") {
32200
32412
  await col.deleteMany({ queue: queueName });
32201
32413
  process.stdout.write("__CLEARED__");
@@ -32431,11 +32643,20 @@ var init_mongoBackend = __esm({
32431
32643
  return null;
32432
32644
  }
32433
32645
  }
32434
- size(queue) {
32435
- const result = this.execSync("size", queue);
32646
+ size(queue, status2 = "pending") {
32647
+ const result = this.execSync("size", queue, status2);
32436
32648
  const num = parseInt(result, 10);
32437
32649
  return isNaN(num) ? 0 : num;
32438
32650
  }
32651
+ /**
32652
+ * Dead-letter a job immediately — no retry (ADR-0023 reject()). Distinct from
32653
+ * fail(): fail() requeues until maxRetries is spent. attempts is floored at
32654
+ * maxRetries so deadLetters()/size("dead") (which filter/scope to the
32655
+ * dead-letter store) see it.
32656
+ */
32657
+ reject(queue, id, reason, maxRetries) {
32658
+ this.execSync("reject", queue, JSON.stringify({ id, reason, maxRetries }));
32659
+ }
32439
32660
  clear(queue) {
32440
32661
  this.execSync("clear", queue);
32441
32662
  }
@@ -32530,7 +32751,9 @@ function createJob(data, queue) {
32530
32751
  queue._failJob(job.topic, job, reason, queue.getMaxRetries());
32531
32752
  },
32532
32753
  reject(reason = "") {
32533
- job.fail(reason);
32754
+ job.status = "dead";
32755
+ job.error = reason;
32756
+ queue._rejectJob(job.topic, job, reason, queue.getMaxRetries());
32534
32757
  },
32535
32758
  retry(delaySeconds) {
32536
32759
  queue._retryJob(job.topic, job, delaySeconds);
@@ -33139,6 +33362,20 @@ var init_liteBackend = __esm({
33139
33362
  * dead-letter file -- two spellings of the same intent that previously
33140
33363
  * diverged.
33141
33364
  */
33365
+ /**
33366
+ * Reject a job permanently — dead-letter it NOW, no retry (ADR-0023).
33367
+ *
33368
+ * Unlike failJob(), which requeues until maxRetries is spent, rejectJob()
33369
+ * moves a known-poison job straight to the dead-letter store. attempts is
33370
+ * floored at maxRetries so deadLetters() (attempts >= maxRetries) returns it
33371
+ * and size("dead") agrees.
33372
+ */
33373
+ rejectJob(queue, job, reason, maxRetries) {
33374
+ this.clearReservation(queue, job.id);
33375
+ job.attempts = Math.max((job.attempts || 0) + 1, maxRetries);
33376
+ job.error = reason;
33377
+ this.deadLetter(queue, job, reason);
33378
+ }
33142
33379
  retryJob(queue, job, delaySeconds) {
33143
33380
  this.clearReservation(queue, job.id);
33144
33381
  try {
@@ -33360,7 +33597,7 @@ var init_queue = __esm({
33360
33597
  size(status2 = "pending") {
33361
33598
  const q = this.topic;
33362
33599
  if (this.externalBackend) {
33363
- return this.externalBackend.size(q);
33600
+ return this.externalBackend.size(q, status2);
33364
33601
  }
33365
33602
  return this.liteBackend.size(q, status2);
33366
33603
  }
@@ -33628,6 +33865,17 @@ var init_queue = __esm({
33628
33865
  }
33629
33866
  this.liteBackend.failJob(queue, job, error2, maxRetries, this._retryBackoff);
33630
33867
  }
33868
+ /**
33869
+ * Dead-letter a job immediately — no retry (ADR-0023 reject()). Distinct from
33870
+ * _failJob, which retries until maxRetries is spent.
33871
+ */
33872
+ _rejectJob(queue, job, reason, maxRetries) {
33873
+ if (this.externalBackend?.reject) {
33874
+ this.externalBackend.reject(queue, job.id, reason, maxRetries);
33875
+ return;
33876
+ }
33877
+ this.liteBackend.rejectJob(queue, job, reason, maxRetries);
33878
+ }
33631
33879
  /**
33632
33880
  * Re-queue a job back to the main queue directory with incremented attempts.
33633
33881
  */
@@ -39339,13 +39587,20 @@ var sso_exports = {};
39339
39587
  __export(sso_exports, {
39340
39588
  SSO: () => Sso,
39341
39589
  Sso: () => Sso,
39342
- SsoError: () => SsoError
39590
+ SsoError: () => SsoError,
39591
+ liveSessionIdentity: () => liveSessionIdentity
39343
39592
  });
39344
39593
  import { createHash as createHash9, randomBytes as randomBytes7, timingSafeEqual as timingSafeEqual3 } from "node:crypto";
39594
+ function expiresAt(tokens) {
39595
+ const lifetime = Number(tokens.expires_in ?? 0);
39596
+ return lifetime > 0 ? Math.floor(Date.now() / 1e3) + lifetime : 0;
39597
+ }
39345
39598
  var SsoError, Sso;
39346
39599
  var init_sso = __esm({
39347
39600
  "src/sso.ts"() {
39348
39601
  "use strict";
39602
+ init_ssoIdentity();
39603
+ init_ssoIdentity();
39349
39604
  SsoError = class extends Error {
39350
39605
  };
39351
39606
  Sso = class _Sso {
@@ -39551,13 +39806,12 @@ var init_sso = __esm({
39551
39806
  access_token: tokens.access_token,
39552
39807
  refresh_token: tokens.refresh_token,
39553
39808
  id_token: tokens.id_token,
39554
- expires_at: Math.floor(Date.now() / 1e3) + Number(tokens.expires_in ?? 0)
39809
+ expires_at: expiresAt(tokens)
39555
39810
  });
39556
39811
  return { identity, return_to: _Sso.safeReturn(pending.return_to) };
39557
39812
  }
39558
39813
  identity(requestOrSession) {
39559
- const stored = this.session(requestOrSession)?.get(_Sso.SESSION_KEY);
39560
- const identity = stored?.identity ?? null;
39814
+ const identity = liveSessionIdentity(this.session(requestOrSession)?.get(_Sso.SESSION_KEY));
39561
39815
  if (identity && requestOrSession?.session) requestOrSession.user = identity;
39562
39816
  return identity;
39563
39817
  }
@@ -39584,7 +39838,7 @@ var init_sso = __esm({
39584
39838
  access_token: tokens.access_token,
39585
39839
  refresh_token: tokens.refresh_token ?? stored.refresh_token,
39586
39840
  id_token: tokens.id_token ?? stored.id_token,
39587
- expires_at: Math.floor(Date.now() / 1e3) + Number(tokens.expires_in ?? 0)
39841
+ expires_at: expiresAt(tokens)
39588
39842
  });
39589
39843
  return identity;
39590
39844
  } catch (error2) {
@@ -39726,12 +39980,12 @@ function bannerSurfaceLines(port, opts) {
39726
39980
  ];
39727
39981
  }
39728
39982
  function swaggerAdvertised() {
39729
- const TRUTHY2 = ["true", "1", "yes", "on"];
39983
+ const TRUTHY3 = ["true", "1", "yes", "on"];
39730
39984
  const raw = (process.env.TINA4_SWAGGER_ENABLED ?? "").trim().toLowerCase();
39731
39985
  if (raw === "") {
39732
- return TRUTHY2.includes((process.env.TINA4_DEBUG ?? "").trim().toLowerCase());
39986
+ return TRUTHY3.includes((process.env.TINA4_DEBUG ?? "").trim().toLowerCase());
39733
39987
  }
39734
- return TRUTHY2.includes(raw);
39988
+ return TRUTHY3.includes(raw);
39735
39989
  }
39736
39990
  async function autoMigrateOnStartup(migrationDir = "migrations", base = process.cwd()) {
39737
39991
  const dir = resolve19(base, migrationDir);
@@ -40650,8 +40904,9 @@ function wireLocaleGlobal(frondEngine, base) {
40650
40904
  async function startServer(config) {
40651
40905
  loadEnv(".env.local");
40652
40906
  loadEnv();
40653
- const { ensureDevSecret: ensureDevSecret2 } = await Promise.resolve().then(() => (init_auth(), auth_exports));
40907
+ const { ensureDevSecret: ensureDevSecret2, requireBootSecret: requireBootSecret2 } = await Promise.resolve().then(() => (init_auth(), auth_exports));
40654
40908
  ensureDevSecret2();
40909
+ requireBootSecret2();
40655
40910
  _checkLegacyEnvVars();
40656
40911
  const resolved = resolvePortAndHost(config);
40657
40912
  const host = resolved.host;
@@ -42316,6 +42571,178 @@ var init_service = __esm({
42316
42571
  }
42317
42572
  });
42318
42573
 
42574
+ // src/ssrf.ts
42575
+ import { isIP } from "node:net";
42576
+ import { lookup } from "node:dns/promises";
42577
+ function v4ToBytes(ip) {
42578
+ const parts = ip.split(".");
42579
+ if (parts.length !== 4) return null;
42580
+ const bytes = new Uint8Array(4);
42581
+ for (let i = 0; i < 4; i++) {
42582
+ const n = Number(parts[i]);
42583
+ if (!Number.isInteger(n) || n < 0 || n > 255 || !/^\d+$/.test(parts[i])) return null;
42584
+ bytes[i] = n;
42585
+ }
42586
+ return bytes;
42587
+ }
42588
+ function v6ToBytes(input) {
42589
+ let ip = input.split("%")[0];
42590
+ const lastColon = ip.lastIndexOf(":");
42591
+ const tail = ip.slice(lastColon + 1);
42592
+ if (tail.includes(".")) {
42593
+ const v4 = v4ToBytes(tail);
42594
+ if (!v4) return null;
42595
+ const hextetA = (v4[0] << 8 | v4[1]).toString(16);
42596
+ const hextetB = (v4[2] << 8 | v4[3]).toString(16);
42597
+ ip = ip.slice(0, lastColon + 1) + hextetA + ":" + hextetB;
42598
+ }
42599
+ const halves = ip.split("::");
42600
+ if (halves.length > 2) return null;
42601
+ const head = halves[0] ? halves[0].split(":") : [];
42602
+ const back = halves.length === 2 ? halves[1] ? halves[1].split(":") : [] : [];
42603
+ let groups;
42604
+ if (halves.length === 1) {
42605
+ if (head.length !== 8) return null;
42606
+ groups = head;
42607
+ } else {
42608
+ const missing = 8 - (head.length + back.length);
42609
+ if (missing < 0) return null;
42610
+ groups = [...head, ...Array(missing).fill("0"), ...back];
42611
+ }
42612
+ if (groups.length !== 8) return null;
42613
+ const bytes = new Uint8Array(16);
42614
+ for (let i = 0; i < 8; i++) {
42615
+ if (!/^[0-9a-fA-F]{1,4}$/.test(groups[i])) return null;
42616
+ const g = parseInt(groups[i], 16);
42617
+ bytes[i * 2] = g >> 8 & 255;
42618
+ bytes[i * 2 + 1] = g & 255;
42619
+ }
42620
+ return bytes;
42621
+ }
42622
+ function ipToBytes(ip) {
42623
+ const cleaned = ip.split("%")[0];
42624
+ const family = isIP(cleaned);
42625
+ if (family === 4) return v4ToBytes(cleaned);
42626
+ if (family === 6) {
42627
+ const bytes = v6ToBytes(cleaned);
42628
+ if (!bytes) return null;
42629
+ const isMapped = bytes.subarray(0, 10).every((b) => b === 0) && bytes[10] === 255 && bytes[11] === 255;
42630
+ return isMapped ? bytes.subarray(12) : bytes;
42631
+ }
42632
+ return null;
42633
+ }
42634
+ function parseCidr(cidr) {
42635
+ const [netStr, bitsStr] = cidr.split("/");
42636
+ const net4 = ipToBytes(netStr);
42637
+ if (!net4) return null;
42638
+ return { net: net4, bits: Number(bitsStr) };
42639
+ }
42640
+ function inCidr(packed, cidr) {
42641
+ if (packed.length !== cidr.net.length) return false;
42642
+ const whole = Math.floor(cidr.bits / 8);
42643
+ const remainder = cidr.bits % 8;
42644
+ for (let i = 0; i < whole; i++) {
42645
+ if (packed[i] !== cidr.net[i]) return false;
42646
+ }
42647
+ if (remainder === 0) return true;
42648
+ const mask = 255 << 8 - remainder & 255;
42649
+ return (packed[whole] & mask) === (cidr.net[whole] & mask);
42650
+ }
42651
+ function allowPrivateRequests() {
42652
+ return TRUTHY2.has((process.env[ALLOW_PRIVATE_ENV] ?? "").trim().toLowerCase());
42653
+ }
42654
+ function isBlockedAddress(ip) {
42655
+ const packed = ipToBytes(ip);
42656
+ if (!packed) return true;
42657
+ return BLOCKED.some((cidr) => inCidr(packed, cidr));
42658
+ }
42659
+ function matchesAllowList(host, port, resolved, allowHosts) {
42660
+ const hostLower = host.toLowerCase();
42661
+ for (const raw of allowHosts ?? []) {
42662
+ const entry = String(raw).trim().toLowerCase();
42663
+ if (!entry) continue;
42664
+ if (entry === hostLower || entry === `${hostLower}:${port}`) return true;
42665
+ if (entry.includes("/")) {
42666
+ const cidr = parseCidr(entry);
42667
+ if (cidr && resolved.some((ip) => {
42668
+ const packed = ipToBytes(ip);
42669
+ return packed !== null && inCidr(packed, cidr);
42670
+ })) {
42671
+ return true;
42672
+ }
42673
+ }
42674
+ }
42675
+ return false;
42676
+ }
42677
+ function logBlock(host, ip) {
42678
+ void Promise.resolve().then(() => (init_logger(), logger_exports)).then((mod) => mod.Log?.warning?.(`SSRF guard blocked outbound request to ${host} (${ip})`)).catch(() => void 0);
42679
+ }
42680
+ async function guardUrl(url, allowHosts) {
42681
+ let parsed;
42682
+ try {
42683
+ parsed = new URL(url);
42684
+ } catch {
42685
+ throw new SsrfError(`Blocked request: '${url}' is not a valid URL`);
42686
+ }
42687
+ const scheme = parsed.protocol.replace(/:$/, "").toLowerCase();
42688
+ if (scheme !== "http" && scheme !== "https") {
42689
+ throw new SsrfError(`Blocked request: URL scheme '${scheme || "(none)"}' is not http or https`);
42690
+ }
42691
+ const host = parsed.hostname.replace(/^\[|\]$/g, "");
42692
+ if (!host) {
42693
+ throw new SsrfError("Blocked request: URL has no host");
42694
+ }
42695
+ const port = parsed.port ? Number(parsed.port) : scheme === "https" ? 443 : 80;
42696
+ if (allowPrivateRequests()) return;
42697
+ let resolved;
42698
+ try {
42699
+ const results = await lookup(host, { all: true });
42700
+ resolved = results.map((r) => r.address);
42701
+ } catch {
42702
+ throw new SsrfError(`Blocked request to ${host}: cannot resolve host`);
42703
+ }
42704
+ if (resolved.length === 0) {
42705
+ throw new SsrfError(`Blocked request to ${host}: cannot resolve host`);
42706
+ }
42707
+ if (matchesAllowList(host, port, resolved, allowHosts)) return;
42708
+ for (const ip of resolved) {
42709
+ if (isBlockedAddress(ip)) {
42710
+ logBlock(host, ip);
42711
+ throw new SsrfError(
42712
+ `Blocked request to private/internal address ${ip} (host ${host}): set ${ALLOW_PRIVATE_ENV}=true to allow, or pass an allow-list.`
42713
+ );
42714
+ }
42715
+ }
42716
+ }
42717
+ var ALLOW_PRIVATE_ENV, TRUTHY2, SsrfError, BLOCKED_CIDRS, BLOCKED;
42718
+ var init_ssrf = __esm({
42719
+ "src/ssrf.ts"() {
42720
+ "use strict";
42721
+ ALLOW_PRIVATE_ENV = "TINA4_ALLOW_PRIVATE_REQUESTS";
42722
+ TRUTHY2 = /* @__PURE__ */ new Set(["1", "true", "yes", "on"]);
42723
+ SsrfError = class extends Error {
42724
+ constructor(message) {
42725
+ super(message);
42726
+ this.name = "SsrfError";
42727
+ }
42728
+ };
42729
+ BLOCKED_CIDRS = [
42730
+ "0.0.0.0/8",
42731
+ "10.0.0.0/8",
42732
+ "127.0.0.0/8",
42733
+ "169.254.0.0/16",
42734
+ "172.16.0.0/12",
42735
+ "192.168.0.0/16",
42736
+ "100.64.0.0/10",
42737
+ "::1/128",
42738
+ "::/128",
42739
+ "fc00::/7",
42740
+ "fe80::/10"
42741
+ ];
42742
+ BLOCKED = BLOCKED_CIDRS.map(parseCidr).filter((c) => c !== null);
42743
+ }
42744
+ });
42745
+
42319
42746
  // src/api.ts
42320
42747
  import http from "node:http";
42321
42748
  import https from "node:https";
@@ -42472,6 +42899,7 @@ var init_api = __esm({
42472
42899
  "src/api.ts"() {
42473
42900
  "use strict";
42474
42901
  init_version();
42902
+ init_ssrf();
42475
42903
  ApiStreamError = class extends Error {
42476
42904
  constructor(message, status2 = null) {
42477
42905
  super(message);
@@ -42526,6 +42954,7 @@ var init_api = __esm({
42526
42954
  transportFn;
42527
42955
  cookiesEnabled;
42528
42956
  cookies;
42957
+ allowHosts;
42529
42958
  /**
42530
42959
  * Construct an Api client.
42531
42960
  *
@@ -42565,6 +42994,7 @@ var init_api = __esm({
42565
42994
  this.transportFn = void 0;
42566
42995
  this.cookiesEnabled = false;
42567
42996
  this.cookies = {};
42997
+ this.allowHosts = [];
42568
42998
  if (typeof authHeaderOrOptions === "object" && authHeaderOrOptions !== null) {
42569
42999
  const opts = authHeaderOrOptions;
42570
43000
  this.authHeader = opts.authHeader ?? "";
@@ -42574,6 +43004,7 @@ var init_api = __esm({
42574
43004
  this.retryBackoff = opts.retryBackoff ?? 0.5;
42575
43005
  this.transportFn = opts.transport;
42576
43006
  this.cookiesEnabled = opts.cookies ?? false;
43007
+ this.allowHosts = opts.allowHosts ?? [];
42577
43008
  if (opts.bearerToken != null) {
42578
43009
  this.setBearerToken(opts.bearerToken);
42579
43010
  } else if (opts.username != null && opts.password != null) {
@@ -42887,7 +43318,12 @@ var init_api = __esm({
42887
43318
  * body-phase timeout is applied by the caller (streamBytes) via
42888
43319
  * `res.destroy()`.
42889
43320
  */
42890
- openStreamRequest(method, url, headers, data, connectSec) {
43321
+ async openStreamRequest(method, url, headers, data, connectSec) {
43322
+ try {
43323
+ await guardUrl(url, this.allowHosts);
43324
+ } catch (err) {
43325
+ throw new ApiStreamError(err instanceof Error ? err.message : String(err), null);
43326
+ }
42891
43327
  return new Promise((resolve21, reject) => {
42892
43328
  let parsed;
42893
43329
  try {
@@ -42946,10 +43382,11 @@ var init_api = __esm({
42946
43382
  */
42947
43383
  buildRequest(method, contentType, body, extraHeaders, targetUrl) {
42948
43384
  const headers = { "User-Agent": `Tina4/${TINA4_VERSION}`, ...this.headers };
42949
- if (this.authHeader) {
43385
+ const sameOriginAsBase = targetUrl === void 0 || sameOrigin(targetUrl, this.baseUrl);
43386
+ if (this.authHeader && sameOriginAsBase) {
42950
43387
  headers["Authorization"] = this.authHeader;
42951
43388
  }
42952
- if (this.cookiesEnabled) {
43389
+ if (this.cookiesEnabled && sameOriginAsBase) {
42953
43390
  const cookieHeader = this.cookieHeader();
42954
43391
  if (cookieHeader) {
42955
43392
  headers["Cookie"] = cookieHeader;
@@ -43050,7 +43487,12 @@ var init_api = __esm({
43050
43487
  * bearer token / session cookie never leaks to a host you didn't
43051
43488
  * authenticate to.
43052
43489
  */
43053
- performRequest(method, url, headers, data, redirectsLeft) {
43490
+ async performRequest(method, url, headers, data, redirectsLeft) {
43491
+ try {
43492
+ await guardUrl(url, this.allowHosts);
43493
+ } catch (err) {
43494
+ return { kind: "error", error: err instanceof Error ? err.message : String(err) };
43495
+ }
43054
43496
  return new Promise((resolve21) => {
43055
43497
  let parsed;
43056
43498
  try {
@@ -45790,6 +46232,7 @@ var CURVE, RECORD_SIZE, MAX_PAYLOAD, PushError, Push;
45790
46232
  var init_push = __esm({
45791
46233
  "src/push.ts"() {
45792
46234
  "use strict";
46235
+ init_ssrf();
45793
46236
  CURVE = "prime256v1";
45794
46237
  RECORD_SIZE = 4096;
45795
46238
  MAX_PAYLOAD = RECORD_SIZE - 17;
@@ -45864,10 +46307,19 @@ var init_push = __esm({
45864
46307
  return { rawPublic, rawPrivate };
45865
46308
  }
45866
46309
  async deliver(endpoint, config, keys, body) {
46310
+ try {
46311
+ await guardUrl(endpoint.toString(), this.options.allowHosts);
46312
+ } catch (error2) {
46313
+ if (error2 instanceof SsrfError) throw new PushError(error2.message);
46314
+ throw error2;
46315
+ }
45867
46316
  let response;
45868
46317
  try {
45869
46318
  response = await fetch(endpoint, {
45870
46319
  method: "POST",
46320
+ // A real push service answers the POST directly; a redirect from a push
46321
+ // endpoint is not followed to a private address (ADR-0084).
46322
+ redirect: "manual",
45871
46323
  headers: {
45872
46324
  Authorization: `vapid t=${vapidToken(endpoint.toString(), config.subject, keys.rawPrivate, keys.rawPublic)}, k=${config.publicKey}`,
45873
46325
  "Content-Encoding": "aes128gcm",
@@ -47954,6 +48406,7 @@ var init_testClient = __esm({
47954
48406
  reqHeaders["content-length"] = String(Buffer.byteLength(rawBody));
47955
48407
  }
47956
48408
  const socket = new Socket();
48409
+ Object.defineProperty(socket, "remoteAddress", { value: "127.0.0.1" });
47957
48410
  const rawReq = new IncomingMessage2(socket);
47958
48411
  rawReq.method = method.toUpperCase();
47959
48412
  rawReq.url = path10;
@@ -48326,6 +48779,7 @@ var init_validator = __esm({
48326
48779
  var index_exports = {};
48327
48780
  __export(index_exports, {
48328
48781
  AI_TOOLS: () => AI_TOOLS,
48782
+ ALLOW_PRIVATE_ENV: () => ALLOW_PRIVATE_ENV,
48329
48783
  APPLICATION_FORM: () => APPLICATION_FORM,
48330
48784
  APPLICATION_JSON: () => APPLICATION_JSON,
48331
48785
  APPLICATION_OCTET: () => APPLICATION_OCTET,
@@ -48433,6 +48887,7 @@ __export(index_exports, {
48433
48887
  Session: () => Session,
48434
48888
  Sso: () => Sso,
48435
48889
  SsoError: () => SsoError,
48890
+ SsrfError: () => SsrfError,
48436
48891
  TAKEOVER_KILLED: () => TAKEOVER_KILLED,
48437
48892
  TAKEOVER_NOTHING: () => TAKEOVER_NOTHING,
48438
48893
  TAKEOVER_REFUSALS: () => TAKEOVER_REFUSALS,
@@ -48466,6 +48921,7 @@ __export(index_exports, {
48466
48921
  addHtmlHelpers: () => addHtmlHelpers,
48467
48922
  algorithmAvailable: () => algorithmAvailable,
48468
48923
  allEnv: () => allEnv,
48924
+ allowPrivateRequests: () => allowPrivateRequests,
48469
48925
  any: () => any,
48470
48926
  attachCsrfFromEnv: () => attachCsrfFromEnv,
48471
48927
  authMiddleware: () => authMiddleware,
@@ -48531,6 +48987,7 @@ __export(index_exports, {
48531
48987
  graphqlAutoSchemaEnabled: () => graphqlAutoSchemaEnabled,
48532
48988
  graphqlEndpoint: () => graphqlEndpoint,
48533
48989
  graphqlMaxDepth: () => graphqlMaxDepth,
48990
+ guardUrl: () => guardUrl,
48534
48991
  handle: () => handle,
48535
48992
  handleFeedbackTurn: () => handleFeedbackTurn,
48536
48993
  handleFeedbackWidgetJs: () => handleFeedbackWidgetJs,
@@ -48544,6 +49001,7 @@ __export(index_exports, {
48544
49001
  installAll: () => installAll,
48545
49002
  installSelected: () => installSelected,
48546
49003
  isBannerSuppressed: () => isBannerSuppressed,
49004
+ isBlockedAddress: () => isBlockedAddress,
48547
49005
  isDebugMode: () => isDebugMode,
48548
49006
  isDev: () => isDev,
48549
49007
  isInstalled: () => isInstalled,
@@ -48662,6 +49120,7 @@ var init_index = __esm({
48662
49120
  init_service();
48663
49121
  init_cache();
48664
49122
  init_api();
49123
+ init_ssrf();
48665
49124
  init_context();
48666
49125
  init_events();
48667
49126
  init_devAdmin();
@@ -48700,6 +49159,7 @@ var init_index = __esm({
48700
49159
  init_index();
48701
49160
  export {
48702
49161
  AI_TOOLS,
49162
+ ALLOW_PRIVATE_ENV,
48703
49163
  APPLICATION_FORM,
48704
49164
  APPLICATION_JSON,
48705
49165
  APPLICATION_OCTET,
@@ -48807,6 +49267,7 @@ export {
48807
49267
  Session,
48808
49268
  Sso,
48809
49269
  SsoError,
49270
+ SsrfError,
48810
49271
  TAKEOVER_KILLED,
48811
49272
  TAKEOVER_NOTHING,
48812
49273
  TAKEOVER_REFUSALS,
@@ -48840,6 +49301,7 @@ export {
48840
49301
  addHtmlHelpers,
48841
49302
  algorithmAvailable,
48842
49303
  allEnv,
49304
+ allowPrivateRequests,
48843
49305
  any,
48844
49306
  attachCsrfFromEnv,
48845
49307
  authMiddleware,
@@ -48905,6 +49367,7 @@ export {
48905
49367
  graphqlAutoSchemaEnabled,
48906
49368
  graphqlEndpoint,
48907
49369
  graphqlMaxDepth,
49370
+ guardUrl,
48908
49371
  handle,
48909
49372
  handleFeedbackTurn,
48910
49373
  handleFeedbackWidgetJs,
@@ -48918,6 +49381,7 @@ export {
48918
49381
  installAll,
48919
49382
  installSelected,
48920
49383
  isBannerSuppressed,
49384
+ isBlockedAddress,
48921
49385
  isDebugMode,
48922
49386
  isDev,
48923
49387
  isInstalled,