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
@@ -951,6 +951,8 @@ var auth_exports = {};
951
951
  __export(auth_exports, {
952
952
  Auth: () => Auth,
953
953
  JWT_LEEWAY_SECONDS: () => JWT_LEEWAY_SECONDS,
954
+ MIN_SECRET_BYTES: () => MIN_SECRET_BYTES,
955
+ NON_IDENTITY_TOKEN_TYPES: () => NON_IDENTITY_TOKEN_TYPES,
954
956
  algorithmAvailable: () => algorithmAvailable,
955
957
  authMiddleware: () => authMiddleware,
956
958
  authenticateRequest: () => authenticateRequest,
@@ -960,7 +962,10 @@ __export(auth_exports, {
960
962
  getPayload: () => getPayload,
961
963
  getToken: () => getToken,
962
964
  hashPassword: () => hashPassword,
965
+ insecureSecretMessage: () => insecureSecretMessage,
966
+ isIdentityPayload: () => isIdentityPayload,
963
967
  refreshToken: () => refreshToken,
968
+ requireBootSecret: () => requireBootSecret,
964
969
  resolveAlgorithm: () => resolveAlgorithm,
965
970
  validToken: () => validToken,
966
971
  validateApiKey: () => validateApiKey
@@ -996,6 +1001,23 @@ async function _logWarning(message) {
996
1001
  function _warnBlankSecret() {
997
1002
  void _logWarning(BLANK_SECRET_WARNING);
998
1003
  }
1004
+ function isIdentityPayload(payload) {
1005
+ return payload != null && !NON_IDENTITY_TOKEN_TYPES.includes(payload.type);
1006
+ }
1007
+ function insecureSecretMessage(secret) {
1008
+ const length = Buffer.byteLength(secret ?? "", "utf8");
1009
+ if (length >= MIN_SECRET_BYTES) return null;
1010
+ const what = length === 0 ? "is not set" : `is ${length} bytes`;
1011
+ 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.`;
1012
+ }
1013
+ function requireBootSecret() {
1014
+ const algorithm = (process.env.TINA4_JWT_ALGORITHM ?? "").trim() || "HS256";
1015
+ if (RSA_SIGN_ALGORITHMS.has(algorithm)) return;
1016
+ const secret = process.env.TINA4_SECRET ?? "";
1017
+ if (secret === "" && _isDev()) return;
1018
+ const message = insecureSecretMessage(secret);
1019
+ if (message) throw new Error(message);
1020
+ }
999
1021
  function ensureDevSecret(cwd) {
1000
1022
  if (process.env.TINA4_SECRET) return null;
1001
1023
  if (!_isDev() || _isCi() || _isProduction()) {
@@ -1126,6 +1148,13 @@ function validToken(token, secret, algorithm) {
1126
1148
  const [h, p, sig] = parts;
1127
1149
  const header = JSON.parse(base64urlDecode(h).toString());
1128
1150
  if (header.alg !== resolvedAlgorithm) return null;
1151
+ if (HMAC_DIGESTS.has(resolvedAlgorithm)) {
1152
+ const weak = insecureSecretMessage(resolvedSecret);
1153
+ if (weak) {
1154
+ void _logWarning(weak);
1155
+ return null;
1156
+ }
1157
+ }
1129
1158
  const signingInput = `${h}.${p}`;
1130
1159
  if (!verifySignature(signingInput, sig, resolvedSecret, resolvedAlgorithm)) {
1131
1160
  return null;
@@ -1157,6 +1186,8 @@ function getPayload(token) {
1157
1186
  function sign(input, secret, algorithm) {
1158
1187
  const digest = HMAC_DIGESTS.get(algorithm);
1159
1188
  if (digest) {
1189
+ const weak = insecureSecretMessage(secret);
1190
+ if (weak) throw new Error(weak);
1160
1191
  return base64urlEncode(createHmac(digest, secret).update(input).digest());
1161
1192
  }
1162
1193
  const rsaAlgorithm = RSA_SIGN_ALGORITHMS.get(algorithm);
@@ -1214,17 +1245,17 @@ function authMiddleware(secret, algorithm) {
1214
1245
  return;
1215
1246
  }
1216
1247
  const token = authHeader.slice(7);
1217
- if (!validToken(token, secret, algorithm)) {
1248
+ const payload = validToken(token, secret, algorithm);
1249
+ if (!isIdentityPayload(payload)) {
1218
1250
  res({ error: "Unauthorized" }, 401);
1219
1251
  return;
1220
1252
  }
1221
- req2.auth = getPayload(token);
1253
+ req2.auth = payload;
1222
1254
  next();
1223
1255
  };
1224
1256
  }
1225
1257
  function refreshToken(token, expiresIn = 60) {
1226
- if (!validToken(token)) return null;
1227
- const payload = getPayload(token);
1258
+ const payload = validToken(token);
1228
1259
  if (!payload) return null;
1229
1260
  const { iat: _iat, exp: _exp, ...claims } = payload;
1230
1261
  return getToken(claims, expiresIn);
@@ -1233,7 +1264,8 @@ function authenticateRequest(headers, secret, algorithm) {
1233
1264
  const authHeader = headers.authorization ?? headers.Authorization ?? "";
1234
1265
  if (!authHeader.startsWith("Bearer ")) return null;
1235
1266
  const token = authHeader.slice(7);
1236
- if (validToken(token, secret, algorithm)) return getPayload(token);
1267
+ const payload = validToken(token, secret, algorithm);
1268
+ if (payload) return isIdentityPayload(payload) ? payload : null;
1237
1269
  if (validateApiKey(token)) {
1238
1270
  return { _auth: "api_key" };
1239
1271
  }
@@ -1247,12 +1279,14 @@ function validateApiKey(provided, expected) {
1247
1279
  if (a.length !== b.length) return false;
1248
1280
  return timingSafeEqual(a, b);
1249
1281
  }
1250
- var BLANK_SECRET_WARNING, HMAC_DIGESTS, RSA_SIGN_ALGORITHMS, KNOWN_ALGORITHMS, ALGORITHM_REMEDY, capabilityCache, JWT_LEEWAY_SECONDS, Auth;
1282
+ 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;
1251
1283
  var init_auth = __esm({
1252
1284
  "../core/src/auth.ts"() {
1253
1285
  "use strict";
1254
1286
  init_dotenv();
1255
1287
  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.";
1288
+ NON_IDENTITY_TOKEN_TYPES = ["form"];
1289
+ MIN_SECRET_BYTES = 32;
1256
1290
  HMAC_DIGESTS = /* @__PURE__ */ new Map([
1257
1291
  ["HS256", "sha256"],
1258
1292
  ["HS384", "sha384"],
@@ -10776,11 +10810,11 @@ async function eagerLoadBelongsToRelation(instances, model, relatedClass, relati
10776
10810
  const relatedPkColumn = relatedClass.getDbColumn(relatedPk);
10777
10811
  const related = await eagerQueryRelated(relatedClass, relatedPkColumn, values);
10778
10812
  if (nested.length > 0 && related.length > 0) await relatedClass._eagerLoad(related, nested);
10779
- const lookup = {};
10780
- for (const record of related) lookup[_joinKey(record[relatedPk])] = record;
10813
+ const lookup2 = {};
10814
+ for (const record of related) lookup2[_joinKey(record[relatedPk])] = record;
10781
10815
  for (const instance of instances) {
10782
10816
  const value = instance[fkProp];
10783
- relationshipCache(instance)[relationName] = value !== void 0 && value !== null ? lookup[_joinKey(value)] ?? null : null;
10817
+ relationshipCache(instance)[relationName] = value !== void 0 && value !== null ? lookup2[_joinKey(value)] ?? null : null;
10784
10818
  }
10785
10819
  }
10786
10820
  async function eagerLoadRelation(instances, model, relationName, nested) {
@@ -15422,9 +15456,9 @@ var init_cache = __esm({
15422
15456
  return entry.value;
15423
15457
  }
15424
15458
  async set(key, value, ttl) {
15425
- const expiresAt = ttl > 0 ? Date.now() + ttl * 1e3 : 0;
15459
+ const expiresAt2 = ttl > 0 ? Date.now() + ttl * 1e3 : 0;
15426
15460
  this.store.delete(key);
15427
- this.store.set(key, { value, expiresAt });
15461
+ this.store.set(key, { value, expiresAt: expiresAt2 });
15428
15462
  while (this.store.size > this.maxEntries) {
15429
15463
  const firstKey = this.store.keys().next().value;
15430
15464
  if (firstKey !== void 0) this.store.delete(firstKey);
@@ -15857,8 +15891,8 @@ ${s}\r
15857
15891
  const oldest = files.shift();
15858
15892
  if (oldest) fs.unlinkSync(path.join(this.dir, oldest.name));
15859
15893
  }
15860
- const expiresAt = ttl > 0 ? Date.now() / 1e3 + ttl : 0;
15861
- const entry = { key, value, expiresAt };
15894
+ const expiresAt2 = ttl > 0 ? Date.now() / 1e3 + ttl : 0;
15895
+ const entry = { key, value, expiresAt: expiresAt2 };
15862
15896
  fs.writeFileSync(this.keyPath(key), JSON.stringify(entry));
15863
15897
  } catch {
15864
15898
  }
@@ -17605,8 +17639,8 @@ function capCache(cache, maxEntries) {
17605
17639
  }
17606
17640
  function sweepExpiredCache(cache) {
17607
17641
  const now = Date.now();
17608
- for (const [key, [, expiresAt]] of cache) {
17609
- if (expiresAt <= now) cache.delete(key);
17642
+ for (const [key, [, expiresAt2]] of cache) {
17643
+ if (expiresAt2 <= now) cache.delete(key);
17610
17644
  }
17611
17645
  }
17612
17646
  function tokenize(source) {
@@ -18578,6 +18612,12 @@ function setFormTokenSessionId(sessionId) {
18578
18612
  }
18579
18613
  function _buildFormTokenJwt(descriptor = "") {
18580
18614
  const secret = process.env.TINA4_SECRET ?? "";
18615
+ const secretBytes = Buffer.byteLength(secret, "utf8");
18616
+ if (secretBytes < 32) {
18617
+ throw new Error(
18618
+ `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.`
18619
+ );
18620
+ }
18581
18621
  const ttlMinutes = parseInt(process.env.TINA4_TOKEN_LIMIT || "60", 10);
18582
18622
  const header = { alg: "HS256", typ: "JWT" };
18583
18623
  const now = Math.floor(Date.now() / 1e3);
@@ -20119,8 +20159,8 @@ var init_engine = __esm({
20119
20159
  sweepExpiredCache(this.fragmentCache);
20120
20160
  const cached = this.fragmentCache.get(cacheKey);
20121
20161
  if (cached) {
20122
- const [htmlContent, expiresAt] = cached;
20123
- if (Date.now() < expiresAt) {
20162
+ const [htmlContent, expiresAt2] = cached;
20163
+ if (Date.now() < expiresAt2) {
20124
20164
  let i2 = start2 + 1;
20125
20165
  let depth = 0;
20126
20166
  while (i2 < tokens.length) {
@@ -20913,7 +20953,7 @@ function csrfBearerIsValid(req2, secret) {
20913
20953
  const authHeader = req2.headers.authorization ?? "";
20914
20954
  if (!authHeader.startsWith("Bearer ")) return false;
20915
20955
  const bearerToken = authHeader.slice(7).trim();
20916
- return Boolean(bearerToken && validToken(bearerToken, secret));
20956
+ return Boolean(bearerToken && isIdentityPayload(validToken(bearerToken, secret)));
20917
20957
  }
20918
20958
  function csrfRequestToken(req2) {
20919
20959
  const query = req2.query ?? {};
@@ -22050,6 +22090,22 @@ var init_transport = __esm({
22050
22090
  }
22051
22091
  });
22052
22092
 
22093
+ // ../core/src/ssoIdentity.ts
22094
+ function liveSessionIdentity(stored) {
22095
+ if (!stored || typeof stored !== "object") return null;
22096
+ const identity = stored.identity;
22097
+ if (!identity || typeof identity !== "object" || !identity.issuer || !identity.subject) return null;
22098
+ const expiresAt2 = stored.expires_at ?? 0;
22099
+ if (typeof expiresAt2 !== "number") return null;
22100
+ if (expiresAt2 > 0 && Math.floor(Date.now() / 1e3) >= expiresAt2) return null;
22101
+ return identity;
22102
+ }
22103
+ var init_ssoIdentity = __esm({
22104
+ "../core/src/ssoIdentity.ts"() {
22105
+ "use strict";
22106
+ }
22107
+ });
22108
+
22053
22109
  // ../core/src/authGate.ts
22054
22110
  function enforceRouteAuth(req2, res, match, isDevAdmin) {
22055
22111
  if (!(match.secure === true && match.noAuth !== true && !isDevAdmin)) {
@@ -22057,38 +22113,50 @@ function enforceRouteAuth(req2, res, match, isDevAdmin) {
22057
22113
  }
22058
22114
  const authHeader = req2.headers.authorization ?? "";
22059
22115
  const headerToken = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : "";
22060
- let resolvedToken = "";
22116
+ let payload = null;
22061
22117
  let tokenSource = "";
22062
- if (headerToken && validToken(headerToken)) {
22063
- resolvedToken = headerToken;
22064
- tokenSource = "header";
22118
+ let resolvedToken = "";
22119
+ if (headerToken) {
22120
+ const candidate = validToken(headerToken);
22121
+ if (isIdentityPayload(candidate)) {
22122
+ payload = candidate;
22123
+ tokenSource = "header";
22124
+ resolvedToken = headerToken;
22125
+ }
22065
22126
  }
22066
- if (!resolvedToken) {
22127
+ if (!payload) {
22067
22128
  const bodyToken = req2.body?.formToken;
22068
- if (bodyToken && validToken(bodyToken)) {
22069
- resolvedToken = bodyToken;
22070
- tokenSource = "body";
22129
+ if (bodyToken) {
22130
+ const candidate = validToken(bodyToken);
22131
+ if (isIdentityPayload(candidate)) {
22132
+ payload = candidate;
22133
+ tokenSource = "body";
22134
+ resolvedToken = bodyToken;
22135
+ }
22071
22136
  }
22072
22137
  }
22073
- if (!resolvedToken) {
22074
- const sso = req2.session?.get?.("_tina4_sso");
22075
- const identity = sso?.identity;
22076
- if (identity?.issuer && identity?.subject) {
22138
+ if (!payload) {
22139
+ const identity = liveSessionIdentity(req2.session?.get?.("_tina4_sso"));
22140
+ if (identity) {
22077
22141
  req2.user = identity;
22078
22142
  return rbacForbidden(match, identity, res);
22079
22143
  }
22080
22144
  const sessionToken = req2.session?.get?.("token");
22081
- if (sessionToken && validToken(sessionToken)) {
22082
- resolvedToken = sessionToken;
22083
- tokenSource = "session";
22145
+ if (sessionToken) {
22146
+ const candidate = validToken(sessionToken);
22147
+ if (isIdentityPayload(candidate)) {
22148
+ payload = candidate;
22149
+ tokenSource = "session";
22150
+ resolvedToken = sessionToken;
22151
+ }
22084
22152
  }
22085
22153
  }
22086
- if (!resolvedToken) {
22154
+ if (!payload) {
22087
22155
  res.raw.writeHead(401, { "Content-Type": "application/json" });
22088
22156
  res.raw.end(JSON.stringify({ error: "Unauthorized" }));
22089
22157
  return true;
22090
22158
  }
22091
- req2.user = getPayload(resolvedToken) ?? {};
22159
+ req2.user = payload;
22092
22160
  if (tokenSource === "body") {
22093
22161
  const fresh = refreshToken(resolvedToken);
22094
22162
  if (fresh) {
@@ -22138,6 +22206,7 @@ var init_authGate = __esm({
22138
22206
  "../core/src/authGate.ts"() {
22139
22207
  "use strict";
22140
22208
  init_auth();
22209
+ init_ssoIdentity();
22141
22210
  }
22142
22211
  });
22143
22212
 
@@ -23231,8 +23300,8 @@ var init_mongoHandler = __esm({
23231
23300
  if (!result || result === "__EMPTY__") return null;
23232
23301
  try {
23233
23302
  const doc = JSON.parse(result);
23234
- const expiresAt = Number(doc?.expires_at ?? 0);
23235
- if (expiresAt > 0 && Date.now() / 1e3 > expiresAt) {
23303
+ const expiresAt2 = Number(doc?.expires_at ?? 0);
23304
+ if (expiresAt2 > 0 && Date.now() / 1e3 > expiresAt2) {
23236
23305
  this.destroy(sessionId);
23237
23306
  return null;
23238
23307
  }
@@ -23458,6 +23527,9 @@ parentPort.on("message", (message) => {
23458
23527
 
23459
23528
  // ../core/src/sessionHandlers/databaseHandler.ts
23460
23529
  import { DatabaseSync as DatabaseSync3 } from "node:sqlite";
23530
+ function sleepSync2(ms) {
23531
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
23532
+ }
23461
23533
  function column(row, name) {
23462
23534
  if (name in row) return row[name];
23463
23535
  return row[name.toUpperCase()];
@@ -23466,7 +23538,7 @@ function schemeOf2(url) {
23466
23538
  const match = url.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):/);
23467
23539
  return match ? match[1].toLowerCase() : "unknown";
23468
23540
  }
23469
- var CREATE_TABLE, DatabaseSessionHandler;
23541
+ var CREATE_TABLE, CREATE_ATTEMPTS, CREATE_RETRY_DELAY_MS, DatabaseSessionHandler;
23470
23542
  var init_databaseHandler = __esm({
23471
23543
  "../core/src/sessionHandlers/databaseHandler.ts"() {
23472
23544
  "use strict";
@@ -23546,6 +23618,8 @@ var init_databaseHandler = __esm({
23546
23618
  END
23547
23619
  `
23548
23620
  };
23621
+ CREATE_ATTEMPTS = 5;
23622
+ CREATE_RETRY_DELAY_MS = 50;
23549
23623
  DatabaseSessionHandler = class {
23550
23624
  sqliteHandle = null;
23551
23625
  /** Set for SQLite. Null when this handler talks to a networked engine. */
@@ -23586,6 +23660,7 @@ var init_databaseHandler = __esm({
23586
23660
  get sqlite() {
23587
23661
  if (this.sqliteHandle === null) {
23588
23662
  this.sqliteHandle = new DatabaseSync3(this.dbPath);
23663
+ this.sqliteHandle.exec("PRAGMA busy_timeout = 5000");
23589
23664
  this.sqliteHandle.exec("PRAGMA journal_mode = WAL");
23590
23665
  }
23591
23666
  return this.sqliteHandle;
@@ -23673,15 +23748,55 @@ var init_databaseHandler = __esm({
23673
23748
  sqlCommandSync(this.target, sql, params);
23674
23749
  }
23675
23750
  /**
23676
- * Ensure the session table exists (called once on first use).
23751
+ * Ensure the session table exists (called once on first use) - safely when
23752
+ * several processes race to create it.
23753
+ *
23754
+ * Every per-engine statement above is check-then-act, so two processes can
23755
+ * both find the table absent and both CREATE; the loser errors, and every
23756
+ * engine spells that differently (a pg_type unique violation on PostgreSQL,
23757
+ * Msg 2714 on SQL Server, an RDB$RELATIONS unique violation on Firebird). So
23758
+ * a failure is judged by RE-CHECKING whether the table now exists, never by
23759
+ * parsing the message - the rescue the Firebird note above has always
23760
+ * assumed, and which was not here. MEASURED on the lab before it: six real
23761
+ * processes racing lost one to five of them on PostgreSQL, SQLite and
23762
+ * Firebird.
23763
+ *
23764
+ * The re-check is RETRIED because the loser can fail BEFORE the winner has
23765
+ * committed. On MySQL, CREATE TABLE IF NOT EXISTS takes a shared metadata
23766
+ * lock, checks, then upgrades it to exclusive; two upgrades deadlock, and when
23767
+ * MySQL cannot back the victim off silently it answers 1213 "Deadlock found"
23768
+ * while the winner's table is still invisible (tina4-php CI run 35972320442).
23769
+ * Bounded, so a genuine failure (no CREATE grant) still throws. Same attempts
23770
+ * and delays in all four frameworks.
23677
23771
  */
23678
23772
  ensureTable() {
23679
23773
  if (this.initialized) return;
23680
23774
  const ddl = CREATE_TABLE[this.engine];
23681
- if (this.target === null) this.sqlite.exec(ddl);
23682
- else sqlCommandSync(this.target, ddl, []);
23775
+ for (let attempt = 1; ; attempt++) {
23776
+ try {
23777
+ if (this.target === null) this.sqlite.exec(ddl);
23778
+ else sqlCommandSync(this.target, ddl, []);
23779
+ break;
23780
+ } catch (error2) {
23781
+ if (this.tableExists()) break;
23782
+ if (attempt >= CREATE_ATTEMPTS) throw error2;
23783
+ sleepSync2(CREATE_RETRY_DELAY_MS * attempt);
23784
+ }
23785
+ }
23683
23786
  this.initialized = true;
23684
23787
  }
23788
+ /**
23789
+ * Whether tina4_session exists, asked the one way every engine answers alike:
23790
+ * a query against it that returns no rows succeeds only if it is there.
23791
+ */
23792
+ tableExists() {
23793
+ try {
23794
+ this.query("SELECT 1 FROM tina4_session WHERE 1 = 0", []);
23795
+ return true;
23796
+ } catch {
23797
+ return false;
23798
+ }
23799
+ }
23685
23800
  read(sessionId) {
23686
23801
  this.ensureTable();
23687
23802
  const rows = this.query(
@@ -23691,8 +23806,8 @@ var init_databaseHandler = __esm({
23691
23806
  const row = rows[0];
23692
23807
  if (!row) return null;
23693
23808
  const now = Date.now() / 1e3;
23694
- const expiresAt = Number(column(row, "expires_at") ?? 0);
23695
- if (expiresAt > 0 && expiresAt < now) {
23809
+ const expiresAt2 = Number(column(row, "expires_at") ?? 0);
23810
+ if (expiresAt2 > 0 && expiresAt2 < now) {
23696
23811
  this.destroy(sessionId);
23697
23812
  return null;
23698
23813
  }
@@ -23705,7 +23820,7 @@ var init_databaseHandler = __esm({
23705
23820
  write(sessionId, data, ttl) {
23706
23821
  this.ensureTable();
23707
23822
  const json = JSON.stringify(data);
23708
- const expiresAt = ttl > 0 ? Date.now() / 1e3 + ttl : 0;
23823
+ const expiresAt2 = ttl > 0 ? Date.now() / 1e3 + ttl : 0;
23709
23824
  const existing = this.query(
23710
23825
  "SELECT session_id FROM tina4_session WHERE session_id = ?",
23711
23826
  [sessionId]
@@ -23713,12 +23828,12 @@ var init_databaseHandler = __esm({
23713
23828
  if (existing.length > 0) {
23714
23829
  this.exec(
23715
23830
  "UPDATE tina4_session SET data = ?, expires_at = ? WHERE session_id = ?",
23716
- [json, expiresAt, sessionId]
23831
+ [json, expiresAt2, sessionId]
23717
23832
  );
23718
23833
  } else {
23719
23834
  this.exec(
23720
23835
  "INSERT INTO tina4_session (session_id, data, expires_at) VALUES (?, ?, ?)",
23721
- [sessionId, json, expiresAt]
23836
+ [sessionId, json, expiresAt2]
23722
23837
  );
23723
23838
  }
23724
23839
  }
@@ -27713,7 +27828,7 @@ function envTruthy(val) {
27713
27828
  return ["true", "1", "yes", "on"].includes(val.trim().toLowerCase());
27714
27829
  }
27715
27830
  function isLoopback(ip) {
27716
- if (ip == null || ip === "") return true;
27831
+ if (ip == null || ip === "") return false;
27717
27832
  let addr = ip.trim().toLowerCase();
27718
27833
  if (addr.startsWith("::ffff:")) addr = addr.slice(7);
27719
27834
  return addr === "::1" || addr === "localhost" || addr.startsWith("127.");
@@ -29304,6 +29419,7 @@ __export(graphql_exports, {
29304
29419
  graphqlAutoSchemaEnabled: () => graphqlAutoSchemaEnabled,
29305
29420
  graphqlEndpoint: () => graphqlEndpoint,
29306
29421
  graphqlMaxDepth: () => graphqlMaxDepth,
29422
+ graphqlMaxNodes: () => graphqlMaxNodes,
29307
29423
  tokenize: () => tokenize2
29308
29424
  });
29309
29425
  function tokenize2(source) {
@@ -29343,7 +29459,12 @@ function graphqlMaxDepth() {
29343
29459
  const n = parseInt(raw, 10);
29344
29460
  return Number.isNaN(n) ? 50 : n;
29345
29461
  }
29346
- var TOKEN_PATTERNS, ParseError, Parser, GraphQL, GraphQLType;
29462
+ function graphqlMaxNodes() {
29463
+ const raw = (process.env.TINA4_GRAPHQL_MAX_NODES ?? "1000").trim();
29464
+ const n = parseInt(raw, 10);
29465
+ return Number.isNaN(n) ? 1e3 : n;
29466
+ }
29467
+ var TOKEN_PATTERNS, ParseError, Parser, ComplexityError, GraphQL, GraphQLType;
29347
29468
  var init_graphql = __esm({
29348
29469
  "../core/src/graphql.ts"() {
29349
29470
  "use strict";
@@ -29382,9 +29503,16 @@ var init_graphql = __esm({
29382
29503
  Parser = class {
29383
29504
  tokens;
29384
29505
  pos;
29506
+ // F2: bound the parser's own recursion. A deeply nested query
29507
+ // ("{a{a{a...}}}") otherwise recurses in parseSelectionSet until the call
29508
+ // stack overflows — BEFORE the execution-time depth guard can run. Reusing
29509
+ // TINA4_GRAPHQL_MAX_DEPTH keeps parse and execution on one bound.
29510
+ depth = 0;
29511
+ maxDepth;
29385
29512
  constructor(tokens) {
29386
29513
  this.tokens = tokens;
29387
29514
  this.pos = 0;
29515
+ this.maxDepth = graphqlMaxDepth();
29388
29516
  }
29389
29517
  peek() {
29390
29518
  return this.pos < this.tokens.length ? this.tokens[this.pos] : null;
@@ -29466,6 +29594,17 @@ var init_graphql = __esm({
29466
29594
  };
29467
29595
  }
29468
29596
  parseSelectionSet() {
29597
+ this.depth++;
29598
+ if (this.maxDepth > 0 && this.depth > this.maxDepth) {
29599
+ throw new Error(`Query exceeds maximum depth of ${this.maxDepth}`);
29600
+ }
29601
+ try {
29602
+ return this.parseSelectionSetInner();
29603
+ } finally {
29604
+ this.depth--;
29605
+ }
29606
+ }
29607
+ parseSelectionSetInner() {
29469
29608
  this.expect("LBRACE");
29470
29609
  const selections = [];
29471
29610
  while (!this.match("RBRACE")) {
@@ -29620,6 +29759,8 @@ var init_graphql = __esm({
29620
29759
  return t;
29621
29760
  }
29622
29761
  };
29762
+ ComplexityError = class extends Error {
29763
+ };
29623
29764
  GraphQL = class _GraphQL {
29624
29765
  types = /* @__PURE__ */ new Map();
29625
29766
  queries = /* @__PURE__ */ new Map();
@@ -29642,6 +29783,12 @@ var init_graphql = __esm({
29642
29783
  * spread / inline fragment, so circular fragments are caught too.
29643
29784
  */
29644
29785
  maxDepth = graphqlMaxDepth();
29786
+ /**
29787
+ * Maximum expanded selection nodes per query (from `TINA4_GRAPHQL_MAX_NODES`,
29788
+ * default 1000; `<= 0` disables). Bounds query WIDTH — fragment bombs and
29789
+ * alias explosions — which the depth guard does not. See {@link graphqlMaxNodes}.
29790
+ */
29791
+ maxNodes = graphqlMaxNodes();
29645
29792
  /**
29646
29793
  * Decorator-style resolver registration.
29647
29794
  *
@@ -29789,6 +29936,16 @@ var init_graphql = __esm({
29789
29936
  return { data: null, errors: [{ message: "No operation found" }] };
29790
29937
  }
29791
29938
  const op = operations[0];
29939
+ if (this.maxNodes > 0) {
29940
+ try {
29941
+ this.assertComplexity(op.selections, fragments);
29942
+ } catch (e) {
29943
+ if (e instanceof ComplexityError) {
29944
+ return { data: null, errors: [{ message: e.message }] };
29945
+ }
29946
+ throw e;
29947
+ }
29948
+ }
29792
29949
  const resolvers = op.operation === "query" ? this.queries : this.mutations;
29793
29950
  for (const vdef of op.variables) {
29794
29951
  if (!(vdef.name in vars) && vdef.default !== void 0) {
@@ -29814,6 +29971,36 @@ var init_graphql = __esm({
29814
29971
  * instead of recursing until the interpreter stack overflows. Top-level
29815
29972
  * starts at depth 1; `maxDepth <= 0` disables the guard.
29816
29973
  */
29974
+ /**
29975
+ * Count the expanded selection nodes, throwing {@link ComplexityError} past
29976
+ * `maxNodes` (F2). Fragment spreads are expanded (so a fragment bomb is
29977
+ * counted, not the small unexpanded document) and every field — including
29978
+ * each alias — counts once. The count is capped by `maxNodes` and the walk by
29979
+ * `maxDepth`, so a circular fragment trips the budget instead of looping and
29980
+ * the check itself can never cost more than the budget.
29981
+ */
29982
+ assertComplexity(selections, fragments) {
29983
+ const depthCap = this.maxDepth > 0 ? this.maxDepth : 1e3;
29984
+ let count = 0;
29985
+ const walk2 = (sels, depth) => {
29986
+ if (depth > depthCap) return;
29987
+ for (const sel of sels) {
29988
+ count++;
29989
+ if (count > this.maxNodes) {
29990
+ throw new ComplexityError(`Query exceeds maximum complexity of ${this.maxNodes} nodes`);
29991
+ }
29992
+ if (sel.kind === "fragment_spread") {
29993
+ const frag = fragments.get(sel.name);
29994
+ if (frag) walk2(frag.selections, depth + 1);
29995
+ } else if (sel.kind === "inline_fragment") {
29996
+ walk2(sel.selections, depth + 1);
29997
+ } else if (sel.kind === "field" && sel.selections) {
29998
+ walk2(sel.selections, depth + 1);
29999
+ }
30000
+ }
30001
+ };
30002
+ walk2(selections, 1);
30003
+ }
29817
30004
  async resolveSelectionsInto(selections, resolvers, parent, variables, context, fragments, target, depth) {
29818
30005
  const errors = [];
29819
30006
  if (this.maxDepth > 0 && depth > this.maxDepth) {
@@ -31016,7 +31203,8 @@ function wsAuthorized(route, headers, queryString = "", subprotocol = "") {
31016
31203
  const token = wsToken(headers, queryString, subprotocol);
31017
31204
  if (!token) return [null, false];
31018
31205
  const payload = validToken(token);
31019
- return [payload, payload !== null];
31206
+ if (!isIdentityPayload(payload)) return [null, false];
31207
+ return [payload, true];
31020
31208
  }
31021
31209
  function offeredBearerSubprotocol(subprotocol) {
31022
31210
  return subprotocol.split(",").some((p) => p.trim().toLowerCase() === "bearer");
@@ -32211,12 +32399,36 @@ var init_mongoBackend = __esm({
32211
32399
  }
32212
32400
  }
32213
32401
  else if (operation === "size") {
32214
- const count = await col.countDocuments({
32215
- queue: queueName,
32216
- status: "pending",
32217
- });
32402
+ // ADR-0022 dec 7: size(status) must not answer a different question.
32403
+ // The dead aliases count the .dead_letter store (== deadLetters().length);
32404
+ // reserved/completed count by status; else pending. data = status.
32405
+ const status = data || "pending";
32406
+ let count: number;
32407
+ if (status === "dead" || status === "failed" || status === "dead_letter") {
32408
+ count = await col.countDocuments({ queue: queueName + ".dead_letter" });
32409
+ } else if (status === "reserved" || status === "completed") {
32410
+ count = await col.countDocuments({ queue: queueName, status });
32411
+ } else {
32412
+ count = await col.countDocuments({ queue: queueName, status: "pending" });
32413
+ }
32218
32414
  process.stdout.write(String(count));
32219
32415
  }
32416
+ else if (operation === "reject") {
32417
+ // Dead-letter NOW, no retry (ADR-0023). Floor attempts at maxRetries
32418
+ // so it reads as exhausted (parity with fail()'s dead-letter branch
32419
+ // and size("dead")). data = JSON { id, reason, maxRetries }.
32420
+ const info = JSON.parse(data);
32421
+ const doc = await col.findOne({ queue: queueName, id: info.id });
32422
+ if (doc) {
32423
+ const attempts = Math.max((doc.attempts || 0) + 1, info.maxRetries);
32424
+ await col.insertOne({
32425
+ ...doc, _id: undefined, attempts,
32426
+ status: "dead", queue: queueName + ".dead_letter", error: info.reason,
32427
+ });
32428
+ await col.deleteOne({ _id: doc._id, queue: queueName });
32429
+ }
32430
+ process.stdout.write("__OK__");
32431
+ }
32220
32432
  else if (operation === "clear") {
32221
32433
  await col.deleteMany({ queue: queueName });
32222
32434
  process.stdout.write("__CLEARED__");
@@ -32452,11 +32664,20 @@ var init_mongoBackend = __esm({
32452
32664
  return null;
32453
32665
  }
32454
32666
  }
32455
- size(queue) {
32456
- const result = this.execSync("size", queue);
32667
+ size(queue, status2 = "pending") {
32668
+ const result = this.execSync("size", queue, status2);
32457
32669
  const num = parseInt(result, 10);
32458
32670
  return isNaN(num) ? 0 : num;
32459
32671
  }
32672
+ /**
32673
+ * Dead-letter a job immediately — no retry (ADR-0023 reject()). Distinct from
32674
+ * fail(): fail() requeues until maxRetries is spent. attempts is floored at
32675
+ * maxRetries so deadLetters()/size("dead") (which filter/scope to the
32676
+ * dead-letter store) see it.
32677
+ */
32678
+ reject(queue, id, reason, maxRetries) {
32679
+ this.execSync("reject", queue, JSON.stringify({ id, reason, maxRetries }));
32680
+ }
32460
32681
  clear(queue) {
32461
32682
  this.execSync("clear", queue);
32462
32683
  }
@@ -32551,7 +32772,9 @@ function createJob(data, queue) {
32551
32772
  queue._failJob(job.topic, job, reason, queue.getMaxRetries());
32552
32773
  },
32553
32774
  reject(reason = "") {
32554
- job.fail(reason);
32775
+ job.status = "dead";
32776
+ job.error = reason;
32777
+ queue._rejectJob(job.topic, job, reason, queue.getMaxRetries());
32555
32778
  },
32556
32779
  retry(delaySeconds) {
32557
32780
  queue._retryJob(job.topic, job, delaySeconds);
@@ -33160,6 +33383,20 @@ var init_liteBackend = __esm({
33160
33383
  * dead-letter file -- two spellings of the same intent that previously
33161
33384
  * diverged.
33162
33385
  */
33386
+ /**
33387
+ * Reject a job permanently — dead-letter it NOW, no retry (ADR-0023).
33388
+ *
33389
+ * Unlike failJob(), which requeues until maxRetries is spent, rejectJob()
33390
+ * moves a known-poison job straight to the dead-letter store. attempts is
33391
+ * floored at maxRetries so deadLetters() (attempts >= maxRetries) returns it
33392
+ * and size("dead") agrees.
33393
+ */
33394
+ rejectJob(queue, job, reason, maxRetries) {
33395
+ this.clearReservation(queue, job.id);
33396
+ job.attempts = Math.max((job.attempts || 0) + 1, maxRetries);
33397
+ job.error = reason;
33398
+ this.deadLetter(queue, job, reason);
33399
+ }
33163
33400
  retryJob(queue, job, delaySeconds) {
33164
33401
  this.clearReservation(queue, job.id);
33165
33402
  try {
@@ -33381,7 +33618,7 @@ var init_queue = __esm({
33381
33618
  size(status2 = "pending") {
33382
33619
  const q = this.topic;
33383
33620
  if (this.externalBackend) {
33384
- return this.externalBackend.size(q);
33621
+ return this.externalBackend.size(q, status2);
33385
33622
  }
33386
33623
  return this.liteBackend.size(q, status2);
33387
33624
  }
@@ -33649,6 +33886,17 @@ var init_queue = __esm({
33649
33886
  }
33650
33887
  this.liteBackend.failJob(queue, job, error2, maxRetries, this._retryBackoff);
33651
33888
  }
33889
+ /**
33890
+ * Dead-letter a job immediately — no retry (ADR-0023 reject()). Distinct from
33891
+ * _failJob, which retries until maxRetries is spent.
33892
+ */
33893
+ _rejectJob(queue, job, reason, maxRetries) {
33894
+ if (this.externalBackend?.reject) {
33895
+ this.externalBackend.reject(queue, job.id, reason, maxRetries);
33896
+ return;
33897
+ }
33898
+ this.liteBackend.rejectJob(queue, job, reason, maxRetries);
33899
+ }
33652
33900
  /**
33653
33901
  * Re-queue a job back to the main queue directory with incremented attempts.
33654
33902
  */
@@ -39360,13 +39608,20 @@ var sso_exports = {};
39360
39608
  __export(sso_exports, {
39361
39609
  SSO: () => Sso,
39362
39610
  Sso: () => Sso,
39363
- SsoError: () => SsoError
39611
+ SsoError: () => SsoError,
39612
+ liveSessionIdentity: () => liveSessionIdentity
39364
39613
  });
39365
39614
  import { createHash as createHash9, randomBytes as randomBytes7, timingSafeEqual as timingSafeEqual3 } from "node:crypto";
39615
+ function expiresAt(tokens) {
39616
+ const lifetime = Number(tokens.expires_in ?? 0);
39617
+ return lifetime > 0 ? Math.floor(Date.now() / 1e3) + lifetime : 0;
39618
+ }
39366
39619
  var SsoError, Sso;
39367
39620
  var init_sso = __esm({
39368
39621
  "../core/src/sso.ts"() {
39369
39622
  "use strict";
39623
+ init_ssoIdentity();
39624
+ init_ssoIdentity();
39370
39625
  SsoError = class extends Error {
39371
39626
  };
39372
39627
  Sso = class _Sso {
@@ -39572,13 +39827,12 @@ var init_sso = __esm({
39572
39827
  access_token: tokens.access_token,
39573
39828
  refresh_token: tokens.refresh_token,
39574
39829
  id_token: tokens.id_token,
39575
- expires_at: Math.floor(Date.now() / 1e3) + Number(tokens.expires_in ?? 0)
39830
+ expires_at: expiresAt(tokens)
39576
39831
  });
39577
39832
  return { identity, return_to: _Sso.safeReturn(pending.return_to) };
39578
39833
  }
39579
39834
  identity(requestOrSession) {
39580
- const stored = this.session(requestOrSession)?.get(_Sso.SESSION_KEY);
39581
- const identity = stored?.identity ?? null;
39835
+ const identity = liveSessionIdentity(this.session(requestOrSession)?.get(_Sso.SESSION_KEY));
39582
39836
  if (identity && requestOrSession?.session) requestOrSession.user = identity;
39583
39837
  return identity;
39584
39838
  }
@@ -39605,7 +39859,7 @@ var init_sso = __esm({
39605
39859
  access_token: tokens.access_token,
39606
39860
  refresh_token: tokens.refresh_token ?? stored.refresh_token,
39607
39861
  id_token: tokens.id_token ?? stored.id_token,
39608
- expires_at: Math.floor(Date.now() / 1e3) + Number(tokens.expires_in ?? 0)
39862
+ expires_at: expiresAt(tokens)
39609
39863
  });
39610
39864
  return identity;
39611
39865
  } catch (error2) {
@@ -39747,12 +40001,12 @@ function bannerSurfaceLines(port, opts) {
39747
40001
  ];
39748
40002
  }
39749
40003
  function swaggerAdvertised() {
39750
- const TRUTHY2 = ["true", "1", "yes", "on"];
40004
+ const TRUTHY3 = ["true", "1", "yes", "on"];
39751
40005
  const raw = (process.env.TINA4_SWAGGER_ENABLED ?? "").trim().toLowerCase();
39752
40006
  if (raw === "") {
39753
- return TRUTHY2.includes((process.env.TINA4_DEBUG ?? "").trim().toLowerCase());
40007
+ return TRUTHY3.includes((process.env.TINA4_DEBUG ?? "").trim().toLowerCase());
39754
40008
  }
39755
- return TRUTHY2.includes(raw);
40009
+ return TRUTHY3.includes(raw);
39756
40010
  }
39757
40011
  async function autoMigrateOnStartup(migrationDir = "migrations", base = process.cwd()) {
39758
40012
  const dir = resolve20(base, migrationDir);
@@ -40671,8 +40925,9 @@ function wireLocaleGlobal(frondEngine, base) {
40671
40925
  async function startServer(config) {
40672
40926
  loadEnv(".env.local");
40673
40927
  loadEnv();
40674
- const { ensureDevSecret: ensureDevSecret2 } = await Promise.resolve().then(() => (init_auth(), auth_exports));
40928
+ const { ensureDevSecret: ensureDevSecret2, requireBootSecret: requireBootSecret2 } = await Promise.resolve().then(() => (init_auth(), auth_exports));
40675
40929
  ensureDevSecret2();
40930
+ requireBootSecret2();
40676
40931
  _checkLegacyEnvVars();
40677
40932
  const resolved = resolvePortAndHost(config);
40678
40933
  const host = resolved.host;
@@ -42337,6 +42592,178 @@ var init_service = __esm({
42337
42592
  }
42338
42593
  });
42339
42594
 
42595
+ // ../core/src/ssrf.ts
42596
+ import { isIP } from "node:net";
42597
+ import { lookup } from "node:dns/promises";
42598
+ function v4ToBytes(ip) {
42599
+ const parts = ip.split(".");
42600
+ if (parts.length !== 4) return null;
42601
+ const bytes = new Uint8Array(4);
42602
+ for (let i = 0; i < 4; i++) {
42603
+ const n = Number(parts[i]);
42604
+ if (!Number.isInteger(n) || n < 0 || n > 255 || !/^\d+$/.test(parts[i])) return null;
42605
+ bytes[i] = n;
42606
+ }
42607
+ return bytes;
42608
+ }
42609
+ function v6ToBytes(input) {
42610
+ let ip = input.split("%")[0];
42611
+ const lastColon = ip.lastIndexOf(":");
42612
+ const tail = ip.slice(lastColon + 1);
42613
+ if (tail.includes(".")) {
42614
+ const v4 = v4ToBytes(tail);
42615
+ if (!v4) return null;
42616
+ const hextetA = (v4[0] << 8 | v4[1]).toString(16);
42617
+ const hextetB = (v4[2] << 8 | v4[3]).toString(16);
42618
+ ip = ip.slice(0, lastColon + 1) + hextetA + ":" + hextetB;
42619
+ }
42620
+ const halves = ip.split("::");
42621
+ if (halves.length > 2) return null;
42622
+ const head = halves[0] ? halves[0].split(":") : [];
42623
+ const back = halves.length === 2 ? halves[1] ? halves[1].split(":") : [] : [];
42624
+ let groups;
42625
+ if (halves.length === 1) {
42626
+ if (head.length !== 8) return null;
42627
+ groups = head;
42628
+ } else {
42629
+ const missing = 8 - (head.length + back.length);
42630
+ if (missing < 0) return null;
42631
+ groups = [...head, ...Array(missing).fill("0"), ...back];
42632
+ }
42633
+ if (groups.length !== 8) return null;
42634
+ const bytes = new Uint8Array(16);
42635
+ for (let i = 0; i < 8; i++) {
42636
+ if (!/^[0-9a-fA-F]{1,4}$/.test(groups[i])) return null;
42637
+ const g = parseInt(groups[i], 16);
42638
+ bytes[i * 2] = g >> 8 & 255;
42639
+ bytes[i * 2 + 1] = g & 255;
42640
+ }
42641
+ return bytes;
42642
+ }
42643
+ function ipToBytes(ip) {
42644
+ const cleaned = ip.split("%")[0];
42645
+ const family = isIP(cleaned);
42646
+ if (family === 4) return v4ToBytes(cleaned);
42647
+ if (family === 6) {
42648
+ const bytes = v6ToBytes(cleaned);
42649
+ if (!bytes) return null;
42650
+ const isMapped = bytes.subarray(0, 10).every((b) => b === 0) && bytes[10] === 255 && bytes[11] === 255;
42651
+ return isMapped ? bytes.subarray(12) : bytes;
42652
+ }
42653
+ return null;
42654
+ }
42655
+ function parseCidr(cidr) {
42656
+ const [netStr, bitsStr] = cidr.split("/");
42657
+ const net4 = ipToBytes(netStr);
42658
+ if (!net4) return null;
42659
+ return { net: net4, bits: Number(bitsStr) };
42660
+ }
42661
+ function inCidr(packed, cidr) {
42662
+ if (packed.length !== cidr.net.length) return false;
42663
+ const whole = Math.floor(cidr.bits / 8);
42664
+ const remainder = cidr.bits % 8;
42665
+ for (let i = 0; i < whole; i++) {
42666
+ if (packed[i] !== cidr.net[i]) return false;
42667
+ }
42668
+ if (remainder === 0) return true;
42669
+ const mask = 255 << 8 - remainder & 255;
42670
+ return (packed[whole] & mask) === (cidr.net[whole] & mask);
42671
+ }
42672
+ function allowPrivateRequests() {
42673
+ return TRUTHY2.has((process.env[ALLOW_PRIVATE_ENV] ?? "").trim().toLowerCase());
42674
+ }
42675
+ function isBlockedAddress(ip) {
42676
+ const packed = ipToBytes(ip);
42677
+ if (!packed) return true;
42678
+ return BLOCKED.some((cidr) => inCidr(packed, cidr));
42679
+ }
42680
+ function matchesAllowList(host, port, resolved, allowHosts) {
42681
+ const hostLower = host.toLowerCase();
42682
+ for (const raw of allowHosts ?? []) {
42683
+ const entry = String(raw).trim().toLowerCase();
42684
+ if (!entry) continue;
42685
+ if (entry === hostLower || entry === `${hostLower}:${port}`) return true;
42686
+ if (entry.includes("/")) {
42687
+ const cidr = parseCidr(entry);
42688
+ if (cidr && resolved.some((ip) => {
42689
+ const packed = ipToBytes(ip);
42690
+ return packed !== null && inCidr(packed, cidr);
42691
+ })) {
42692
+ return true;
42693
+ }
42694
+ }
42695
+ }
42696
+ return false;
42697
+ }
42698
+ function logBlock(host, ip) {
42699
+ void Promise.resolve().then(() => (init_logger(), logger_exports)).then((mod) => mod.Log?.warning?.(`SSRF guard blocked outbound request to ${host} (${ip})`)).catch(() => void 0);
42700
+ }
42701
+ async function guardUrl(url, allowHosts) {
42702
+ let parsed;
42703
+ try {
42704
+ parsed = new URL(url);
42705
+ } catch {
42706
+ throw new SsrfError(`Blocked request: '${url}' is not a valid URL`);
42707
+ }
42708
+ const scheme = parsed.protocol.replace(/:$/, "").toLowerCase();
42709
+ if (scheme !== "http" && scheme !== "https") {
42710
+ throw new SsrfError(`Blocked request: URL scheme '${scheme || "(none)"}' is not http or https`);
42711
+ }
42712
+ const host = parsed.hostname.replace(/^\[|\]$/g, "");
42713
+ if (!host) {
42714
+ throw new SsrfError("Blocked request: URL has no host");
42715
+ }
42716
+ const port = parsed.port ? Number(parsed.port) : scheme === "https" ? 443 : 80;
42717
+ if (allowPrivateRequests()) return;
42718
+ let resolved;
42719
+ try {
42720
+ const results = await lookup(host, { all: true });
42721
+ resolved = results.map((r) => r.address);
42722
+ } catch {
42723
+ throw new SsrfError(`Blocked request to ${host}: cannot resolve host`);
42724
+ }
42725
+ if (resolved.length === 0) {
42726
+ throw new SsrfError(`Blocked request to ${host}: cannot resolve host`);
42727
+ }
42728
+ if (matchesAllowList(host, port, resolved, allowHosts)) return;
42729
+ for (const ip of resolved) {
42730
+ if (isBlockedAddress(ip)) {
42731
+ logBlock(host, ip);
42732
+ throw new SsrfError(
42733
+ `Blocked request to private/internal address ${ip} (host ${host}): set ${ALLOW_PRIVATE_ENV}=true to allow, or pass an allow-list.`
42734
+ );
42735
+ }
42736
+ }
42737
+ }
42738
+ var ALLOW_PRIVATE_ENV, TRUTHY2, SsrfError, BLOCKED_CIDRS, BLOCKED;
42739
+ var init_ssrf = __esm({
42740
+ "../core/src/ssrf.ts"() {
42741
+ "use strict";
42742
+ ALLOW_PRIVATE_ENV = "TINA4_ALLOW_PRIVATE_REQUESTS";
42743
+ TRUTHY2 = /* @__PURE__ */ new Set(["1", "true", "yes", "on"]);
42744
+ SsrfError = class extends Error {
42745
+ constructor(message) {
42746
+ super(message);
42747
+ this.name = "SsrfError";
42748
+ }
42749
+ };
42750
+ BLOCKED_CIDRS = [
42751
+ "0.0.0.0/8",
42752
+ "10.0.0.0/8",
42753
+ "127.0.0.0/8",
42754
+ "169.254.0.0/16",
42755
+ "172.16.0.0/12",
42756
+ "192.168.0.0/16",
42757
+ "100.64.0.0/10",
42758
+ "::1/128",
42759
+ "::/128",
42760
+ "fc00::/7",
42761
+ "fe80::/10"
42762
+ ];
42763
+ BLOCKED = BLOCKED_CIDRS.map(parseCidr).filter((c) => c !== null);
42764
+ }
42765
+ });
42766
+
42340
42767
  // ../core/src/api.ts
42341
42768
  import http from "node:http";
42342
42769
  import https from "node:https";
@@ -42493,6 +42920,7 @@ var init_api = __esm({
42493
42920
  "../core/src/api.ts"() {
42494
42921
  "use strict";
42495
42922
  init_version();
42923
+ init_ssrf();
42496
42924
  ApiStreamError = class extends Error {
42497
42925
  constructor(message, status2 = null) {
42498
42926
  super(message);
@@ -42547,6 +42975,7 @@ var init_api = __esm({
42547
42975
  transportFn;
42548
42976
  cookiesEnabled;
42549
42977
  cookies;
42978
+ allowHosts;
42550
42979
  /**
42551
42980
  * Construct an Api client.
42552
42981
  *
@@ -42586,6 +43015,7 @@ var init_api = __esm({
42586
43015
  this.transportFn = void 0;
42587
43016
  this.cookiesEnabled = false;
42588
43017
  this.cookies = {};
43018
+ this.allowHosts = [];
42589
43019
  if (typeof authHeaderOrOptions === "object" && authHeaderOrOptions !== null) {
42590
43020
  const opts = authHeaderOrOptions;
42591
43021
  this.authHeader = opts.authHeader ?? "";
@@ -42595,6 +43025,7 @@ var init_api = __esm({
42595
43025
  this.retryBackoff = opts.retryBackoff ?? 0.5;
42596
43026
  this.transportFn = opts.transport;
42597
43027
  this.cookiesEnabled = opts.cookies ?? false;
43028
+ this.allowHosts = opts.allowHosts ?? [];
42598
43029
  if (opts.bearerToken != null) {
42599
43030
  this.setBearerToken(opts.bearerToken);
42600
43031
  } else if (opts.username != null && opts.password != null) {
@@ -42908,7 +43339,12 @@ var init_api = __esm({
42908
43339
  * body-phase timeout is applied by the caller (streamBytes) via
42909
43340
  * `res.destroy()`.
42910
43341
  */
42911
- openStreamRequest(method, url, headers, data, connectSec) {
43342
+ async openStreamRequest(method, url, headers, data, connectSec) {
43343
+ try {
43344
+ await guardUrl(url, this.allowHosts);
43345
+ } catch (err) {
43346
+ throw new ApiStreamError(err instanceof Error ? err.message : String(err), null);
43347
+ }
42912
43348
  return new Promise((resolve29, reject) => {
42913
43349
  let parsed;
42914
43350
  try {
@@ -42967,10 +43403,11 @@ var init_api = __esm({
42967
43403
  */
42968
43404
  buildRequest(method, contentType, body, extraHeaders, targetUrl) {
42969
43405
  const headers = { "User-Agent": `Tina4/${TINA4_VERSION}`, ...this.headers };
42970
- if (this.authHeader) {
43406
+ const sameOriginAsBase = targetUrl === void 0 || sameOrigin(targetUrl, this.baseUrl);
43407
+ if (this.authHeader && sameOriginAsBase) {
42971
43408
  headers["Authorization"] = this.authHeader;
42972
43409
  }
42973
- if (this.cookiesEnabled) {
43410
+ if (this.cookiesEnabled && sameOriginAsBase) {
42974
43411
  const cookieHeader = this.cookieHeader();
42975
43412
  if (cookieHeader) {
42976
43413
  headers["Cookie"] = cookieHeader;
@@ -43071,7 +43508,12 @@ var init_api = __esm({
43071
43508
  * bearer token / session cookie never leaks to a host you didn't
43072
43509
  * authenticate to.
43073
43510
  */
43074
- performRequest(method, url, headers, data, redirectsLeft) {
43511
+ async performRequest(method, url, headers, data, redirectsLeft) {
43512
+ try {
43513
+ await guardUrl(url, this.allowHosts);
43514
+ } catch (err) {
43515
+ return { kind: "error", error: err instanceof Error ? err.message : String(err) };
43516
+ }
43075
43517
  return new Promise((resolve29) => {
43076
43518
  let parsed;
43077
43519
  try {
@@ -45829,6 +46271,7 @@ var CURVE, RECORD_SIZE, MAX_PAYLOAD, PushError, Push;
45829
46271
  var init_push = __esm({
45830
46272
  "../core/src/push.ts"() {
45831
46273
  "use strict";
46274
+ init_ssrf();
45832
46275
  CURVE = "prime256v1";
45833
46276
  RECORD_SIZE = 4096;
45834
46277
  MAX_PAYLOAD = RECORD_SIZE - 17;
@@ -45903,10 +46346,19 @@ var init_push = __esm({
45903
46346
  return { rawPublic, rawPrivate };
45904
46347
  }
45905
46348
  async deliver(endpoint, config, keys, body) {
46349
+ try {
46350
+ await guardUrl(endpoint.toString(), this.options.allowHosts);
46351
+ } catch (error2) {
46352
+ if (error2 instanceof SsrfError) throw new PushError(error2.message);
46353
+ throw error2;
46354
+ }
45906
46355
  let response;
45907
46356
  try {
45908
46357
  response = await fetch(endpoint, {
45909
46358
  method: "POST",
46359
+ // A real push service answers the POST directly; a redirect from a push
46360
+ // endpoint is not followed to a private address (ADR-0084).
46361
+ redirect: "manual",
45910
46362
  headers: {
45911
46363
  Authorization: `vapid t=${vapidToken(endpoint.toString(), config.subject, keys.rawPrivate, keys.rawPublic)}, k=${config.publicKey}`,
45912
46364
  "Content-Encoding": "aes128gcm",
@@ -47993,6 +48445,7 @@ var init_testClient = __esm({
47993
48445
  reqHeaders["content-length"] = String(Buffer.byteLength(rawBody));
47994
48446
  }
47995
48447
  const socket = new Socket();
48448
+ Object.defineProperty(socket, "remoteAddress", { value: "127.0.0.1" });
47996
48449
  const rawReq = new IncomingMessage2(socket);
47997
48450
  rawReq.method = method.toUpperCase();
47998
48451
  rawReq.url = path10;
@@ -48365,6 +48818,7 @@ var init_validator = __esm({
48365
48818
  var src_exports3 = {};
48366
48819
  __export(src_exports3, {
48367
48820
  AI_TOOLS: () => AI_TOOLS,
48821
+ ALLOW_PRIVATE_ENV: () => ALLOW_PRIVATE_ENV,
48368
48822
  APPLICATION_FORM: () => APPLICATION_FORM,
48369
48823
  APPLICATION_JSON: () => APPLICATION_JSON,
48370
48824
  APPLICATION_OCTET: () => APPLICATION_OCTET,
@@ -48472,6 +48926,7 @@ __export(src_exports3, {
48472
48926
  Session: () => Session,
48473
48927
  Sso: () => Sso,
48474
48928
  SsoError: () => SsoError,
48929
+ SsrfError: () => SsrfError,
48475
48930
  TAKEOVER_KILLED: () => TAKEOVER_KILLED,
48476
48931
  TAKEOVER_NOTHING: () => TAKEOVER_NOTHING,
48477
48932
  TAKEOVER_REFUSALS: () => TAKEOVER_REFUSALS,
@@ -48505,6 +48960,7 @@ __export(src_exports3, {
48505
48960
  addHtmlHelpers: () => addHtmlHelpers,
48506
48961
  algorithmAvailable: () => algorithmAvailable,
48507
48962
  allEnv: () => allEnv,
48963
+ allowPrivateRequests: () => allowPrivateRequests,
48508
48964
  any: () => any,
48509
48965
  attachCsrfFromEnv: () => attachCsrfFromEnv,
48510
48966
  authMiddleware: () => authMiddleware,
@@ -48570,6 +49026,7 @@ __export(src_exports3, {
48570
49026
  graphqlAutoSchemaEnabled: () => graphqlAutoSchemaEnabled,
48571
49027
  graphqlEndpoint: () => graphqlEndpoint,
48572
49028
  graphqlMaxDepth: () => graphqlMaxDepth,
49029
+ guardUrl: () => guardUrl,
48573
49030
  handle: () => handle,
48574
49031
  handleFeedbackTurn: () => handleFeedbackTurn,
48575
49032
  handleFeedbackWidgetJs: () => handleFeedbackWidgetJs,
@@ -48583,6 +49040,7 @@ __export(src_exports3, {
48583
49040
  installAll: () => installAll,
48584
49041
  installSelected: () => installSelected,
48585
49042
  isBannerSuppressed: () => isBannerSuppressed,
49043
+ isBlockedAddress: () => isBlockedAddress,
48586
49044
  isDebugMode: () => isDebugMode,
48587
49045
  isDev: () => isDev,
48588
49046
  isInstalled: () => isInstalled,
@@ -48702,6 +49160,7 @@ var init_src3 = __esm({
48702
49160
  init_service();
48703
49161
  init_cache();
48704
49162
  init_api();
49163
+ init_ssrf();
48705
49164
  init_context();
48706
49165
  init_events();
48707
49166
  init_devAdmin();