@revenexx/integrations-node-sdk 1.13.0 → 1.15.0

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.
@@ -92,6 +92,53 @@ function parsePackageMeta(raw) {
92
92
  icon: icon !== "" ? icon : void 0
93
93
  };
94
94
  }
95
+ var SLUG_PATTERN = "^[a-z][a-z0-9-]{0,62}$";
96
+ var NAMESPACED_SLUG_PATTERN = "^[a-z][a-z0-9-]{0,62}:[a-z][a-z0-9-]{0,62}$";
97
+ var SEMVER_PATTERN = "^\\d+\\.\\d+\\.\\d+(-[a-zA-Z0-9.-]+)?$";
98
+ var CONFIG_KEY_PATTERN = "^[a-zA-Z][a-zA-Z0-9-]*$";
99
+ var PATTERN_HINT = {
100
+ [SLUG_PATTERN]: 'lowercase kebab-case, such as "not-found"',
101
+ [NAMESPACED_SLUG_PATTERN]: '"<namespace>:<name>" in lowercase kebab-case, such as "core:http-request"',
102
+ [SEMVER_PATTERN]: 'a semantic version, such as "1.0.0"',
103
+ [CONFIG_KEY_PATTERN]: 'a letter, then letters, digits or "-", such as "postalCode"'
104
+ };
105
+ function nodeManifestProblems(nodes) {
106
+ const problems = [];
107
+ for (const [index, node] of nodes.entries()) {
108
+ const who = `node ${index + 1} (${typeof node.slug === "string" ? node.slug : "no slug"})`;
109
+ const check = (path, value, pattern) => {
110
+ if (value === void 0 || typeof value === "string" && new RegExp(pattern).test(value)) return;
111
+ problems.push(
112
+ `${who}: ${path} ${JSON.stringify(value)} is refused by the registry \u2014 it must match ${pattern} (${PATTERN_HINT[pattern]}).`
113
+ );
114
+ };
115
+ check("slug", node.slug, NAMESPACED_SLUG_PATTERN);
116
+ check("version", node.version, SEMVER_PATTERN);
117
+ for (const [i, port] of (node.outputs ?? []).entries()) {
118
+ check(`outputs[${i}].name`, port.name, SLUG_PATTERN);
119
+ check(`outputs[${i}].sourceFromConfig`, port.sourceFromConfig, SLUG_PATTERN);
120
+ check(`outputs[${i}].fallback.name`, port.fallback?.name, SLUG_PATTERN);
121
+ }
122
+ const checkField = (field, at) => {
123
+ check(`${at}.key`, field.key, CONFIG_KEY_PATTERN);
124
+ for (const [j, key] of (field.dependsOn ?? []).entries()) check(`${at}.dependsOn[${j}]`, key, CONFIG_KEY_PATTERN);
125
+ check(`${at}.showIf.key`, field.showIf?.key, CONFIG_KEY_PATTERN);
126
+ const types = field.credentialType;
127
+ if (Array.isArray(types)) {
128
+ for (const [j, type] of types.entries()) check(`${at}.credentialType[${j}]`, type, NAMESPACED_SLUG_PATTERN);
129
+ } else {
130
+ check(`${at}.credentialType`, types, NAMESPACED_SLUG_PATTERN);
131
+ }
132
+ walk(field.properties, `${at}.properties`);
133
+ if (field.items) checkField(field.items, `${at}.items`);
134
+ };
135
+ const walk = (fields, path) => {
136
+ for (const [i, field] of (fields ?? []).entries()) checkField(field, `${path}[${i}]`);
137
+ };
138
+ walk(node.config, "config");
139
+ }
140
+ return problems;
141
+ }
95
142
  function buildManifest(nodes, credentials = [], templates = []) {
96
143
  const manifest = {
97
144
  manifestVersion: MANIFEST_VERSION,
@@ -118,5 +165,10 @@ export {
118
165
  MANIFEST_VERSION,
119
166
  packageIconProblem,
120
167
  parsePackageMeta,
168
+ SLUG_PATTERN,
169
+ NAMESPACED_SLUG_PATTERN,
170
+ SEMVER_PATTERN,
171
+ CONFIG_KEY_PATTERN,
172
+ nodeManifestProblems,
121
173
  buildManifest
122
174
  };
package/dist/cli.cjs CHANGED
@@ -147,6 +147,53 @@ function parsePackageMeta(raw) {
147
147
  icon: icon !== "" ? icon : void 0
148
148
  };
149
149
  }
150
+ var SLUG_PATTERN = "^[a-z][a-z0-9-]{0,62}$";
151
+ var NAMESPACED_SLUG_PATTERN = "^[a-z][a-z0-9-]{0,62}:[a-z][a-z0-9-]{0,62}$";
152
+ var SEMVER_PATTERN = "^\\d+\\.\\d+\\.\\d+(-[a-zA-Z0-9.-]+)?$";
153
+ var CONFIG_KEY_PATTERN = "^[a-zA-Z][a-zA-Z0-9-]*$";
154
+ var PATTERN_HINT = {
155
+ [SLUG_PATTERN]: 'lowercase kebab-case, such as "not-found"',
156
+ [NAMESPACED_SLUG_PATTERN]: '"<namespace>:<name>" in lowercase kebab-case, such as "core:http-request"',
157
+ [SEMVER_PATTERN]: 'a semantic version, such as "1.0.0"',
158
+ [CONFIG_KEY_PATTERN]: 'a letter, then letters, digits or "-", such as "postalCode"'
159
+ };
160
+ function nodeManifestProblems(nodes) {
161
+ const problems = [];
162
+ for (const [index, node] of nodes.entries()) {
163
+ const who = `node ${index + 1} (${typeof node.slug === "string" ? node.slug : "no slug"})`;
164
+ const check = (path, value, pattern) => {
165
+ if (value === void 0 || typeof value === "string" && new RegExp(pattern).test(value)) return;
166
+ problems.push(
167
+ `${who}: ${path} ${JSON.stringify(value)} is refused by the registry \u2014 it must match ${pattern} (${PATTERN_HINT[pattern]}).`
168
+ );
169
+ };
170
+ check("slug", node.slug, NAMESPACED_SLUG_PATTERN);
171
+ check("version", node.version, SEMVER_PATTERN);
172
+ for (const [i, port] of (node.outputs ?? []).entries()) {
173
+ check(`outputs[${i}].name`, port.name, SLUG_PATTERN);
174
+ check(`outputs[${i}].sourceFromConfig`, port.sourceFromConfig, SLUG_PATTERN);
175
+ check(`outputs[${i}].fallback.name`, port.fallback?.name, SLUG_PATTERN);
176
+ }
177
+ const checkField = (field, at) => {
178
+ check(`${at}.key`, field.key, CONFIG_KEY_PATTERN);
179
+ for (const [j, key] of (field.dependsOn ?? []).entries()) check(`${at}.dependsOn[${j}]`, key, CONFIG_KEY_PATTERN);
180
+ check(`${at}.showIf.key`, field.showIf?.key, CONFIG_KEY_PATTERN);
181
+ const types = field.credentialType;
182
+ if (Array.isArray(types)) {
183
+ for (const [j, type] of types.entries()) check(`${at}.credentialType[${j}]`, type, NAMESPACED_SLUG_PATTERN);
184
+ } else {
185
+ check(`${at}.credentialType`, types, NAMESPACED_SLUG_PATTERN);
186
+ }
187
+ walk(field.properties, `${at}.properties`);
188
+ if (field.items) checkField(field.items, `${at}.items`);
189
+ };
190
+ const walk = (fields, path) => {
191
+ for (const [i, field] of (fields ?? []).entries()) checkField(field, `${path}[${i}]`);
192
+ };
193
+ walk(node.config, "config");
194
+ }
195
+ return problems;
196
+ }
150
197
  function buildManifest(nodes, credentials = [], templates = []) {
151
198
  const manifest = {
152
199
  manifestVersion: MANIFEST_VERSION,
@@ -213,6 +260,8 @@ async function runManifest() {
213
260
  console.warn(`\u26A0 ${w} This will be an error in the next major version.`);
214
261
  }
215
262
  const manifest = buildManifest(mod.NODES, credentials, templates);
263
+ const nameProblems = nodeManifestProblems(manifest.nodes);
264
+ if (nameProblems.length > 0) fail(nameProblems.join("\n"));
216
265
  const outDir = (0, import_node_path2.resolve)(projectRoot, "dist");
217
266
  const outFile = (0, import_node_path2.resolve)(outDir, "manifest.json");
218
267
  fs2.mkdirSync(outDir, { recursive: true });
package/dist/cli.js CHANGED
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  buildManifest,
4
+ nodeManifestProblems,
4
5
  outputWarnings,
5
6
  packageIconProblem,
6
7
  parsePackageMeta
7
- } from "./chunk-XO73F62E.js";
8
+ } from "./chunk-CA6JVLLF.js";
8
9
 
9
10
  // src/cli.ts
10
11
  import * as fs2 from "fs";
@@ -112,6 +113,8 @@ async function runManifest() {
112
113
  console.warn(`\u26A0 ${w} This will be an error in the next major version.`);
113
114
  }
114
115
  const manifest = buildManifest(mod.NODES, credentials, templates);
116
+ const nameProblems = nodeManifestProblems(manifest.nodes);
117
+ if (nameProblems.length > 0) fail(nameProblems.join("\n"));
115
118
  const outDir = resolve2(projectRoot, "dist");
116
119
  const outFile = resolve2(outDir, "manifest.json");
117
120
  fs2.mkdirSync(outDir, { recursive: true });
package/dist/fetch.d.ts CHANGED
@@ -65,6 +65,28 @@ export interface SafeFetchOptions extends RequestInit {
65
65
  /** Bind the request to the origin of the credential it carries. */
66
66
  credential?: SafeFetchCredential;
67
67
  }
68
+ /**
69
+ * Whether the request behind a failed {@link safeFetch} call may have reached
70
+ * the host — `false` only when the call is known to have put nothing on the wire
71
+ * on any hop of any attempt, and `true` for every other error, an unknown one
72
+ * included. A node that writes reads it to decide whether a create may be
73
+ * repeated: when this is `false` nothing was sent, so nothing can have been
74
+ * created.
75
+ *
76
+ * It reads `meta.sent === false`, which `safeFetch` puts on what it throws in
77
+ * that case: a refused address — before the lookup or at connect time — a lookup
78
+ * that ran out of time, a header or URL that cannot be sent, a credential bound
79
+ * to another origin, and a connection that was refused, could not be resolved or
80
+ * reached, or timed out while connecting. The last group arrives as the runtime's
81
+ * own `TypeError('fetch failed')`, unwrapped as before, with the marker added. A
82
+ * cancellation is the caller's own reason and is never marked.
83
+ */
84
+ export declare function mayHaveBeenSent(err: unknown): boolean;
85
+ /**
86
+ * Make a request through the SSRF guard, under a time budget and an optional
87
+ * retry budget. A failure that left nothing on the wire says so with
88
+ * `meta.sent = false` — read it through {@link mayHaveBeenSent}.
89
+ */
68
90
  export declare function safeFetch(url: string | URL, options?: SafeFetchOptions): Promise<Response>;
69
91
  /**
70
92
  * Read a response body into an ArrayBuffer while enforcing a hard byte cap.
package/dist/index.cjs CHANGED
@@ -33,6 +33,7 @@ __export(index_exports, {
33
33
  ApiKeyCredential: () => ApiKeyCredential,
34
34
  BaseCredential: () => BaseCredential,
35
35
  BasicAuthCredential: () => BasicAuthCredential,
36
+ CONFIG_KEY_PATTERN: () => CONFIG_KEY_PATTERN,
36
37
  DEFAULT_MAX_RESPONSE_BYTES: () => DEFAULT_MAX_RESPONSE_BYTES,
37
38
  DEFAULT_RETRY_ATTEMPTS: () => DEFAULT_RETRY_ATTEMPTS,
38
39
  DEFAULT_RETRY_DELAY_MS: () => DEFAULT_RETRY_DELAY_MS,
@@ -43,11 +44,14 @@ __export(index_exports, {
43
44
  MAX_RESPONSE_BYTES: () => MAX_RESPONSE_BYTES,
44
45
  MAX_RETRY_ATTEMPTS: () => MAX_RETRY_ATTEMPTS,
45
46
  MAX_TIMEOUT_MS: () => MAX_TIMEOUT_MS,
47
+ NAMESPACED_SLUG_PATTERN: () => NAMESPACED_SLUG_PATTERN,
46
48
  NodeError: () => NodeError,
47
49
  OAuth2AuthCodeCredential: () => OAuth2AuthCodeCredential,
48
50
  OAuth2ClientCredentialsCredential: () => OAuth2ClientCredentialsCredential,
49
51
  OPERATORS: () => OPERATORS,
50
52
  RetryableError: () => RetryableError,
53
+ SEMVER_PATTERN: () => SEMVER_PATTERN,
54
+ SLUG_PATTERN: () => SLUG_PATTERN,
51
55
  SimpleValueCredential: () => SimpleValueCredential,
52
56
  VALUELESS_OPERATORS: () => VALUELESS_OPERATORS,
53
57
  VPN_ERROR_CODES: () => VPN_ERROR_CODES,
@@ -66,6 +70,8 @@ __export(index_exports, {
66
70
  extractCredentialManifests: () => extractCredentialManifests,
67
71
  extractManifest: () => extractManifest,
68
72
  extractManifests: () => extractManifests,
73
+ guardRelaxedForLocalDev: () => guardRelaxedForLocalDev,
74
+ guardedLookup: () => guardedLookup,
69
75
  headerCrossesOrigin: () => headerCrossesOrigin,
70
76
  httpErrorResult: () => httpErrorResult,
71
77
  installAllowedTargets: () => installAllowedTargets,
@@ -77,6 +83,8 @@ __export(index_exports, {
77
83
  isOperator: () => isOperator,
78
84
  isVpnErrorCode: () => isVpnErrorCode,
79
85
  maxBytesConfigField: () => maxBytesConfigField,
86
+ mayHaveBeenSent: () => mayHaveBeenSent,
87
+ nodeManifestProblems: () => nodeManifestProblems,
80
88
  normalizeCredentialType: () => normalizeCredentialType,
81
89
  normalizeLocalized: () => normalizeLocalized,
82
90
  outputProblems: () => outputProblems,
@@ -370,6 +378,9 @@ function assertBranchNamed(node, result, config) {
370
378
  }
371
379
  }
372
380
 
381
+ // src/fetch.ts
382
+ var import_undici2 = require("undici");
383
+
373
384
  // src/ssrf.ts
374
385
  var import_node_diagnostics_channel = require("diagnostics_channel");
375
386
  var import_node_dns = __toESM(require("dns"), 1);
@@ -525,19 +536,25 @@ function guardRelaxedForLocalDev() {
525
536
  if (relaxed && !bypassNoticeLogged) {
526
537
  bypassNoticeLogged = true;
527
538
  console.warn(
528
- "[ssrf] RVNXX_SSRF_ALLOW_PRIVATE is set: allowing private/loopback fetch targets. Intended for local development only."
539
+ "[ssrf] RVNXX_SSRF_ALLOW_PRIVATE is set: allowing private/loopback targets for web requests and connections. Intended for local development only."
529
540
  );
530
541
  }
531
542
  return relaxed;
532
543
  }
544
+ function logBlocked(noun, host, address) {
545
+ console.warn(`[ssrf] blocked ${noun} to ${host}: resolves to private/reserved address ${address}`);
546
+ }
533
547
  function blockedError(host, address) {
534
548
  if (host === address) {
535
549
  return new NodeError("BLOCKED_ADDRESS", `Blocked request to private or reserved address ${address}`, {
536
550
  status: 0
537
551
  });
538
552
  }
539
- console.warn(`[ssrf] blocked request to ${host}: resolves to private/reserved address ${address}`);
540
- return new NodeError("BLOCKED_ADDRESS", `Blocked request to ${host}: resolves to a private or reserved address`, {
553
+ logBlocked("request", host, address);
554
+ return refusedName(host);
555
+ }
556
+ function refusedName(host, noun = "request") {
557
+ return new NodeError("BLOCKED_ADDRESS", `Blocked ${noun} to ${host}: resolves to a private or reserved address`, {
541
558
  status: 0
542
559
  });
543
560
  }
@@ -619,6 +636,16 @@ function unreadablePortError(host) {
619
636
  );
620
637
  }
621
638
  function judgingLookup(port) {
639
+ return lookupJudgedFor(port, { noun: "request" }, (hostname) => new NodeError("BLOCKED_ADDRESS", `Could not resolve host: ${hostname}`, { status: 0 }));
640
+ }
641
+ function guardedLookup(port, options = {}) {
642
+ return lookupJudgedFor(
643
+ port,
644
+ options,
645
+ (hostname) => Object.assign(new Error(`getaddrinfo ENOTFOUND ${hostname}`), { code: "ENOTFOUND", hostname })
646
+ );
647
+ }
648
+ function lookupJudgedFor(port, { refusal, unroutable, noun = "connection" }, emptyAnswer) {
622
649
  return (hostname, options, callback) => {
623
650
  import_node_dns.default.lookup(hostname, { ...options, all: true }, (err, addresses) => {
624
651
  if (err) {
@@ -626,15 +653,18 @@ function judgingLookup(port) {
626
653
  return;
627
654
  }
628
655
  if (addresses.length === 0) {
629
- callback(new NodeError("BLOCKED_ADDRESS", `Could not resolve host: ${hostname}`, { status: 0 }), "");
656
+ callback(emptyAnswer(hostname), "");
630
657
  return;
631
658
  }
632
659
  if (!guardRelaxedForLocalDev()) {
660
+ const host = hostname;
633
661
  for (const a of addresses) {
634
- if (isBlockedAddress(a.address) && !isAllowedTarget(a.address, port)) {
635
- callback(blockedError(normalizeHost(hostname), a.address), "");
636
- return;
637
- }
662
+ if (!isBlockedAddress(a.address)) continue;
663
+ const allowed = isAllowedTarget(a.address, port);
664
+ if (allowed && !unroutable) continue;
665
+ logBlocked(noun, host, a.address);
666
+ callback(allowed && unroutable ? unroutable(host) : refusal ? refusal(host) : refusedName(host, noun), "");
667
+ return;
638
668
  }
639
669
  }
640
670
  if (options.all) callback(null, addresses);
@@ -820,34 +850,132 @@ function credentialOriginRefusal(target, bound, hop) {
820
850
  { status: 0 }
821
851
  );
822
852
  }
823
- async function timedFetch(url, fetchOptions, ctxSignal, effectiveMs) {
853
+ function attemptDeadline(ctxSignal, effectiveMs) {
824
854
  const ac = new AbortController();
825
- const timer = setTimeout(() => ac.abort(), effectiveMs);
826
- const signal = ctxSignal ? AbortSignal.any([ctxSignal, ac.signal]) : ac.signal;
855
+ let timer;
856
+ const arm = (message) => {
857
+ clearTimeout(timer);
858
+ timer = setTimeout(() => ac.abort(new NodeError("TIMEOUT", message)), effectiveMs);
859
+ };
860
+ arm(`Request timed out after ${effectiveMs}ms`);
861
+ return {
862
+ signal: ctxSignal ? AbortSignal.any([ctxSignal, ac.signal]) : ac.signal,
863
+ expired: () => ac.signal.aborted,
864
+ reason: () => ac.signal.reason,
865
+ clear: () => clearTimeout(timer),
866
+ awaitChunk: () => arm(`The answer sent nothing for ${effectiveMs}ms`)
867
+ };
868
+ }
869
+ function attemptFailure(err, ctxSignal, deadline) {
870
+ if (ctxSignal?.aborted) return ctxSignal.reason;
871
+ if (deadline.expired()) return deadline.reason();
872
+ return err;
873
+ }
874
+ function answerUnderDeadline(res, ctxSignal, deadline) {
875
+ if (!res.body) return rebuilt(res, null) ?? asRuntimeResponse(res);
876
+ let reader;
877
+ const body = new ReadableStream(
878
+ {
879
+ async pull(controller) {
880
+ reader ??= res.body.getReader();
881
+ deadline.awaitChunk();
882
+ try {
883
+ const { done, value } = await reader.read();
884
+ if (done) controller.close();
885
+ else controller.enqueue(value);
886
+ } catch (err) {
887
+ controller.error(attemptFailure(err, ctxSignal, deadline));
888
+ } finally {
889
+ deadline.clear();
890
+ }
891
+ },
892
+ cancel(reason) {
893
+ deadline.clear();
894
+ return reader ? reader.cancel(reason) : res.body?.cancel(reason);
895
+ }
896
+ },
897
+ // Read nothing ahead of the caller: the limit times the caller's waits, not
898
+ // a buffer filling while nobody reads.
899
+ { highWaterMark: 0 }
900
+ );
901
+ return rebuilt(res, body) ?? asRuntimeResponse(res);
902
+ }
903
+ function rebuilt(res, body) {
904
+ try {
905
+ return withUrl(new Response(body, { status: res.status, statusText: res.statusText, headers: res.headers }), res.url);
906
+ } catch {
907
+ return void 0;
908
+ }
909
+ }
910
+ function asRuntimeResponse(res) {
911
+ return withUrl(withStatusLine(new Response(res.body, { headers: res.headers }), res), res.url);
912
+ }
913
+ function withStatusLine(answer, from) {
914
+ const clone = answer.clone.bind(answer);
915
+ Object.defineProperties(answer, {
916
+ status: { value: from.status },
917
+ statusText: { value: from.statusText },
918
+ ok: { value: from.ok },
919
+ clone: { value: () => withStatusLine(clone(), from), configurable: true }
920
+ });
921
+ return answer;
922
+ }
923
+ function withUrl(answer, url) {
924
+ const clone = answer.clone.bind(answer);
925
+ Object.defineProperty(answer, "url", { value: url });
926
+ Object.defineProperty(answer, "clone", { value: () => withUrl(clone(), url) });
927
+ return answer;
928
+ }
929
+ var runtimeFetch = globalThis.fetch;
930
+ function transport() {
931
+ const current = globalThis.fetch;
932
+ return current === runtimeFetch ? import_undici2.fetch : current;
933
+ }
934
+ var NEVER_CONNECTED = /* @__PURE__ */ new Set(["ENOTFOUND", "EAI_AGAIN", "UND_ERR_CONNECT_TIMEOUT"]);
935
+ var REFUSED_WHILE_CONNECTING = /* @__PURE__ */ new Set(["ECONNREFUSED", "EHOSTUNREACH", "ENETUNREACH"]);
936
+ function connectFailed(e) {
937
+ if (typeof e?.code !== "string") return false;
938
+ return NEVER_CONNECTED.has(e.code) || REFUSED_WHILE_CONNECTING.has(e.code) && e.syscall === "connect";
939
+ }
940
+ function neverConnected(err) {
941
+ const cause = err?.cause;
942
+ if (!cause) return false;
943
+ if (Array.isArray(cause.errors) && cause.errors.length > 0) return cause.errors.every(connectFailed);
944
+ return connectFailed(cause);
945
+ }
946
+ function markUnsent(err) {
947
+ if (typeof err !== "object" || err === null) return;
948
+ const meta = err.meta;
949
+ const sent = { ...typeof meta === "object" && meta !== null ? meta : {}, sent: false };
827
950
  try {
828
- return await fetch(url, { ...fetchOptions, signal, dispatcher: guardedDispatcher() });
951
+ Object.defineProperty(err, "meta", { value: sent, writable: true, configurable: true, enumerable: true });
952
+ } catch {
953
+ }
954
+ }
955
+ function mayHaveBeenSent(err) {
956
+ const meta = err?.meta;
957
+ return meta?.sent !== false;
958
+ }
959
+ async function timedFetch(url, fetchOptions, ctxSignal, deadline, wire) {
960
+ try {
961
+ const send = transport();
962
+ const res = await send(url, { ...fetchOptions, signal: deadline.signal, dispatcher: guardedDispatcher() });
963
+ wire.maybeSent = true;
964
+ return res;
829
965
  } catch (err) {
830
966
  const blocked = connectionRefusal(err);
831
967
  if (blocked) throw blocked;
832
- if (ctxSignal?.aborted) throw ctxSignal.reason;
833
- if (ac.signal.aborted) throw new NodeError("TIMEOUT", `Request timed out after ${effectiveMs}ms`);
834
- throw err;
835
- } finally {
836
- clearTimeout(timer);
968
+ if (!neverConnected(err)) wire.maybeSent = true;
969
+ throw attemptFailure(err, ctxSignal, deadline);
837
970
  }
838
971
  }
839
- async function guardUrl(url, ctxSignal, effectiveMs) {
840
- const ac = new AbortController();
841
- const timer = setTimeout(() => ac.abort(), effectiveMs);
842
- const signal = ctxSignal ? AbortSignal.any([ctxSignal, ac.signal]) : ac.signal;
972
+ async function guardUrl(url, ctxSignal, deadline, effectiveMs) {
843
973
  try {
844
- await assertPublicUrl(url, { signal });
974
+ await assertPublicUrl(url, { signal: deadline.signal });
845
975
  } catch (err) {
846
976
  if (ctxSignal?.aborted) throw ctxSignal.reason;
847
- if (ac.signal.aborted) throw new NodeError("TIMEOUT", `DNS resolution timed out after ${effectiveMs}ms`);
977
+ if (deadline.expired()) throw new NodeError("TIMEOUT", `DNS resolution timed out after ${effectiveMs}ms`);
848
978
  throw err;
849
- } finally {
850
- clearTimeout(timer);
851
979
  }
852
980
  }
853
981
  function hasContent(body) {
@@ -868,17 +996,28 @@ function refuseUserinfo(url, status = 0) {
868
996
  );
869
997
  }
870
998
  }
871
- async function guardedFetch(url, fetchOptions, ctxSignal, effectiveMs, binding) {
999
+ async function guardedFetch(url, fetchOptions, ctxSignal, effectiveMs, wire, binding) {
1000
+ const deadline = attemptDeadline(ctxSignal, effectiveMs);
1001
+ try {
1002
+ const res = await followHops(url, fetchOptions, ctxSignal, deadline, effectiveMs, wire, binding);
1003
+ deadline.clear();
1004
+ return answerUnderDeadline(res, ctxSignal, deadline);
1005
+ } catch (err) {
1006
+ deadline.clear();
1007
+ throw err;
1008
+ }
1009
+ }
1010
+ async function followHops(url, fetchOptions, ctxSignal, deadline, effectiveMs, wire, binding) {
872
1011
  let currentUrl = url;
873
1012
  let currentInit = { ...fetchOptions, redirect: "manual" };
874
- await guardUrl(currentUrl, ctxSignal, effectiveMs);
1013
+ await guardUrl(currentUrl, ctxSignal, deadline, effectiveMs);
875
1014
  for (let hop = 0; ; hop++) {
876
1015
  const release = guardConnectionsTo(
877
1016
  currentUrl instanceof URL ? currentUrl : new URL(currentUrl)
878
1017
  );
879
1018
  let res;
880
1019
  try {
881
- res = await timedFetch(currentUrl, currentInit, ctxSignal, effectiveMs);
1020
+ res = await timedFetch(currentUrl, currentInit, ctxSignal, deadline, wire);
882
1021
  } finally {
883
1022
  release();
884
1023
  }
@@ -920,7 +1059,7 @@ async function guardedFetch(url, fetchOptions, ctxSignal, effectiveMs, binding)
920
1059
  { status: 0 }
921
1060
  );
922
1061
  }
923
- await guardUrl(nextUrl, ctxSignal, effectiveMs);
1062
+ await guardUrl(nextUrl, ctxSignal, deadline, effectiveMs);
924
1063
  if (binding && nextUrl.origin !== binding.origin) {
925
1064
  for (const name of [...headers.keys()]) headers.delete(name);
926
1065
  } else if (nextUrl.origin !== base.origin) {
@@ -933,6 +1072,15 @@ async function guardedFetch(url, fetchOptions, ctxSignal, effectiveMs, binding)
933
1072
  }
934
1073
  }
935
1074
  async function safeFetch(url, options = {}) {
1075
+ const wire = { maybeSent: false };
1076
+ try {
1077
+ return await sendWithin(url, options, wire);
1078
+ } catch (err) {
1079
+ if (!wire.maybeSent && err !== options.signal?.reason) markUnsent(err);
1080
+ throw err;
1081
+ }
1082
+ }
1083
+ async function sendWithin(url, options, wire) {
936
1084
  const { timeoutMs = DEFAULT_TIMEOUT_MS, signal: ctxSignal, retry, credential, ...fetchOptions } = options;
937
1085
  const effectiveMs = Number.isFinite(timeoutMs) && timeoutMs > 0 ? Math.min(timeoutMs, MAX_TIMEOUT_MS) : DEFAULT_TIMEOUT_MS;
938
1086
  const rawAttempts = retry?.attempts ?? DEFAULT_RETRY_ATTEMPTS;
@@ -957,7 +1105,7 @@ async function safeFetch(url, options = {}) {
957
1105
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
958
1106
  if (ctxSignal?.aborted) throw ctxSignal.reason;
959
1107
  try {
960
- return await guardedFetch(url, fetchOptions, ctxSignal, effectiveMs, binding);
1108
+ return await guardedFetch(url, fetchOptions, ctxSignal, effectiveMs, wire, binding);
961
1109
  } catch (err) {
962
1110
  if (ctxSignal?.aborted) throw ctxSignal.reason;
963
1111
  if (err instanceof NodeError && (err.code === "BLOCKED_ADDRESS" || err.code === "TOO_MANY_REDIRECTS" || err.code === "CREDENTIAL_ORIGIN" || err.code === "INVALID_URL")) {
@@ -1068,8 +1216,8 @@ function timeoutConfigField(opts) {
1068
1216
  key: "timeoutMs",
1069
1217
  label: { en: "Timeout (ms)", de: "Zeitlimit (ms)" },
1070
1218
  description: {
1071
- en: "How long one attempt may take before it fails as a timeout. Counted per attempt and per redirect hop, not across the whole call.",
1072
- de: "Wie lange ein einzelner Versuch dauern darf, bevor er als Zeit\xFCberschreitung fehlschl\xE4gt. Gilt je Versuch und je Weiterleitung, nicht f\xFCr den gesamten Aufruf."
1219
+ en: "How long one attempt may wait for an answer to start, redirects included, and how long the answer may then go silent before it fails as a timeout. An answer that keeps arriving is not cut off. Each retry gets the time again.",
1220
+ de: "Wie lange ein einzelner Versuch auf den Beginn einer Antwort warten darf, Weiterleitungen eingeschlossen, und wie lange die Antwort danach stocken darf, bevor sie als Zeit\xFCberschreitung fehlschl\xE4gt. Eine Antwort, die weiter eintrifft, wird nicht abgebrochen. Jede Wiederholung bekommt die Zeit erneut."
1073
1221
  },
1074
1222
  type: "number",
1075
1223
  default: opts?.default ?? DEFAULT_TIMEOUT_MS,
@@ -1192,6 +1340,53 @@ function parsePackageMeta(raw) {
1192
1340
  icon: icon !== "" ? icon : void 0
1193
1341
  };
1194
1342
  }
1343
+ var SLUG_PATTERN = "^[a-z][a-z0-9-]{0,62}$";
1344
+ var NAMESPACED_SLUG_PATTERN = "^[a-z][a-z0-9-]{0,62}:[a-z][a-z0-9-]{0,62}$";
1345
+ var SEMVER_PATTERN = "^\\d+\\.\\d+\\.\\d+(-[a-zA-Z0-9.-]+)?$";
1346
+ var CONFIG_KEY_PATTERN = "^[a-zA-Z][a-zA-Z0-9-]*$";
1347
+ var PATTERN_HINT = {
1348
+ [SLUG_PATTERN]: 'lowercase kebab-case, such as "not-found"',
1349
+ [NAMESPACED_SLUG_PATTERN]: '"<namespace>:<name>" in lowercase kebab-case, such as "core:http-request"',
1350
+ [SEMVER_PATTERN]: 'a semantic version, such as "1.0.0"',
1351
+ [CONFIG_KEY_PATTERN]: 'a letter, then letters, digits or "-", such as "postalCode"'
1352
+ };
1353
+ function nodeManifestProblems(nodes) {
1354
+ const problems = [];
1355
+ for (const [index, node] of nodes.entries()) {
1356
+ const who = `node ${index + 1} (${typeof node.slug === "string" ? node.slug : "no slug"})`;
1357
+ const check = (path, value, pattern) => {
1358
+ if (value === void 0 || typeof value === "string" && new RegExp(pattern).test(value)) return;
1359
+ problems.push(
1360
+ `${who}: ${path} ${JSON.stringify(value)} is refused by the registry \u2014 it must match ${pattern} (${PATTERN_HINT[pattern]}).`
1361
+ );
1362
+ };
1363
+ check("slug", node.slug, NAMESPACED_SLUG_PATTERN);
1364
+ check("version", node.version, SEMVER_PATTERN);
1365
+ for (const [i, port] of (node.outputs ?? []).entries()) {
1366
+ check(`outputs[${i}].name`, port.name, SLUG_PATTERN);
1367
+ check(`outputs[${i}].sourceFromConfig`, port.sourceFromConfig, SLUG_PATTERN);
1368
+ check(`outputs[${i}].fallback.name`, port.fallback?.name, SLUG_PATTERN);
1369
+ }
1370
+ const checkField = (field, at) => {
1371
+ check(`${at}.key`, field.key, CONFIG_KEY_PATTERN);
1372
+ for (const [j, key] of (field.dependsOn ?? []).entries()) check(`${at}.dependsOn[${j}]`, key, CONFIG_KEY_PATTERN);
1373
+ check(`${at}.showIf.key`, field.showIf?.key, CONFIG_KEY_PATTERN);
1374
+ const types = field.credentialType;
1375
+ if (Array.isArray(types)) {
1376
+ for (const [j, type] of types.entries()) check(`${at}.credentialType[${j}]`, type, NAMESPACED_SLUG_PATTERN);
1377
+ } else {
1378
+ check(`${at}.credentialType`, types, NAMESPACED_SLUG_PATTERN);
1379
+ }
1380
+ walk(field.properties, `${at}.properties`);
1381
+ if (field.items) checkField(field.items, `${at}.items`);
1382
+ };
1383
+ const walk = (fields, path) => {
1384
+ for (const [i, field] of (fields ?? []).entries()) checkField(field, `${path}[${i}]`);
1385
+ };
1386
+ walk(node.config, "config");
1387
+ }
1388
+ return problems;
1389
+ }
1195
1390
  function buildManifest(nodes, credentials = [], templates = []) {
1196
1391
  const manifest = {
1197
1392
  manifestVersion: MANIFEST_VERSION,
@@ -1476,6 +1671,7 @@ function base64Url(buf) {
1476
1671
  ApiKeyCredential,
1477
1672
  BaseCredential,
1478
1673
  BasicAuthCredential,
1674
+ CONFIG_KEY_PATTERN,
1479
1675
  DEFAULT_MAX_RESPONSE_BYTES,
1480
1676
  DEFAULT_RETRY_ATTEMPTS,
1481
1677
  DEFAULT_RETRY_DELAY_MS,
@@ -1486,11 +1682,14 @@ function base64Url(buf) {
1486
1682
  MAX_RESPONSE_BYTES,
1487
1683
  MAX_RETRY_ATTEMPTS,
1488
1684
  MAX_TIMEOUT_MS,
1685
+ NAMESPACED_SLUG_PATTERN,
1489
1686
  NodeError,
1490
1687
  OAuth2AuthCodeCredential,
1491
1688
  OAuth2ClientCredentialsCredential,
1492
1689
  OPERATORS,
1493
1690
  RetryableError,
1691
+ SEMVER_PATTERN,
1692
+ SLUG_PATTERN,
1494
1693
  SimpleValueCredential,
1495
1694
  VALUELESS_OPERATORS,
1496
1695
  VPN_ERROR_CODES,
@@ -1509,6 +1708,8 @@ function base64Url(buf) {
1509
1708
  extractCredentialManifests,
1510
1709
  extractManifest,
1511
1710
  extractManifests,
1711
+ guardRelaxedForLocalDev,
1712
+ guardedLookup,
1512
1713
  headerCrossesOrigin,
1513
1714
  httpErrorResult,
1514
1715
  installAllowedTargets,
@@ -1520,6 +1721,8 @@ function base64Url(buf) {
1520
1721
  isOperator,
1521
1722
  isVpnErrorCode,
1522
1723
  maxBytesConfigField,
1724
+ mayHaveBeenSent,
1725
+ nodeManifestProblems,
1523
1726
  normalizeCredentialType,
1524
1727
  normalizeLocalized,
1525
1728
  outputProblems,
package/dist/index.d.ts CHANGED
@@ -8,8 +8,8 @@ export * from './nodeErrors.js';
8
8
  export * from './extract.js';
9
9
  export * from './branches.js';
10
10
  export * from './fetch.js';
11
- export { assertPublicUrl, isBlockedAddress } from './ssrf.js';
12
- export type { LookupAddress, LookupFn } from './ssrf.js';
11
+ export { assertPublicUrl, guardedLookup, guardRelaxedForLocalDev, isBlockedAddress } from './ssrf.js';
12
+ export type { GuardedLookupOptions, LookupAddress, LookupFn } from './ssrf.js';
13
13
  export { allowedTargets, installAllowedTargets, isAllowedTarget } from './allowedTargets.js';
14
14
  export * from './retry.js';
15
15
  export * from './manifest.js';
package/dist/index.js CHANGED
@@ -1,5 +1,9 @@
1
1
  import {
2
+ CONFIG_KEY_PATTERN,
2
3
  MANIFEST_VERSION,
4
+ NAMESPACED_SLUG_PATTERN,
5
+ SEMVER_PATTERN,
6
+ SLUG_PATTERN,
3
7
  assertBranchNamed,
4
8
  buildManifest,
5
9
  extractCredentialManifest,
@@ -7,11 +11,12 @@ import {
7
11
  extractManifest,
8
12
  extractManifests,
9
13
  isMultiPort,
14
+ nodeManifestProblems,
10
15
  outputProblems,
11
16
  outputWarnings,
12
17
  packageIconProblem,
13
18
  parsePackageMeta
14
- } from "./chunk-XO73F62E.js";
19
+ } from "./chunk-CA6JVLLF.js";
15
20
 
16
21
  // src/types.ts
17
22
  function isNodeWithIteration(node) {
@@ -212,6 +217,9 @@ function errorPort(opts = {}) {
212
217
  };
213
218
  }
214
219
 
220
+ // src/fetch.ts
221
+ import { fetch as undiciFetch } from "undici";
222
+
215
223
  // src/ssrf.ts
216
224
  import { subscribe } from "diagnostics_channel";
217
225
  import dns from "dns";
@@ -367,19 +375,25 @@ function guardRelaxedForLocalDev() {
367
375
  if (relaxed && !bypassNoticeLogged) {
368
376
  bypassNoticeLogged = true;
369
377
  console.warn(
370
- "[ssrf] RVNXX_SSRF_ALLOW_PRIVATE is set: allowing private/loopback fetch targets. Intended for local development only."
378
+ "[ssrf] RVNXX_SSRF_ALLOW_PRIVATE is set: allowing private/loopback targets for web requests and connections. Intended for local development only."
371
379
  );
372
380
  }
373
381
  return relaxed;
374
382
  }
383
+ function logBlocked(noun, host, address) {
384
+ console.warn(`[ssrf] blocked ${noun} to ${host}: resolves to private/reserved address ${address}`);
385
+ }
375
386
  function blockedError(host, address) {
376
387
  if (host === address) {
377
388
  return new NodeError("BLOCKED_ADDRESS", `Blocked request to private or reserved address ${address}`, {
378
389
  status: 0
379
390
  });
380
391
  }
381
- console.warn(`[ssrf] blocked request to ${host}: resolves to private/reserved address ${address}`);
382
- return new NodeError("BLOCKED_ADDRESS", `Blocked request to ${host}: resolves to a private or reserved address`, {
392
+ logBlocked("request", host, address);
393
+ return refusedName(host);
394
+ }
395
+ function refusedName(host, noun = "request") {
396
+ return new NodeError("BLOCKED_ADDRESS", `Blocked ${noun} to ${host}: resolves to a private or reserved address`, {
383
397
  status: 0
384
398
  });
385
399
  }
@@ -461,6 +475,16 @@ function unreadablePortError(host) {
461
475
  );
462
476
  }
463
477
  function judgingLookup(port) {
478
+ return lookupJudgedFor(port, { noun: "request" }, (hostname) => new NodeError("BLOCKED_ADDRESS", `Could not resolve host: ${hostname}`, { status: 0 }));
479
+ }
480
+ function guardedLookup(port, options = {}) {
481
+ return lookupJudgedFor(
482
+ port,
483
+ options,
484
+ (hostname) => Object.assign(new Error(`getaddrinfo ENOTFOUND ${hostname}`), { code: "ENOTFOUND", hostname })
485
+ );
486
+ }
487
+ function lookupJudgedFor(port, { refusal, unroutable, noun = "connection" }, emptyAnswer) {
464
488
  return (hostname, options, callback) => {
465
489
  dns.lookup(hostname, { ...options, all: true }, (err, addresses) => {
466
490
  if (err) {
@@ -468,15 +492,18 @@ function judgingLookup(port) {
468
492
  return;
469
493
  }
470
494
  if (addresses.length === 0) {
471
- callback(new NodeError("BLOCKED_ADDRESS", `Could not resolve host: ${hostname}`, { status: 0 }), "");
495
+ callback(emptyAnswer(hostname), "");
472
496
  return;
473
497
  }
474
498
  if (!guardRelaxedForLocalDev()) {
499
+ const host = hostname;
475
500
  for (const a of addresses) {
476
- if (isBlockedAddress(a.address) && !isAllowedTarget(a.address, port)) {
477
- callback(blockedError(normalizeHost(hostname), a.address), "");
478
- return;
479
- }
501
+ if (!isBlockedAddress(a.address)) continue;
502
+ const allowed = isAllowedTarget(a.address, port);
503
+ if (allowed && !unroutable) continue;
504
+ logBlocked(noun, host, a.address);
505
+ callback(allowed && unroutable ? unroutable(host) : refusal ? refusal(host) : refusedName(host, noun), "");
506
+ return;
480
507
  }
481
508
  }
482
509
  if (options.all) callback(null, addresses);
@@ -662,34 +689,132 @@ function credentialOriginRefusal(target, bound, hop) {
662
689
  { status: 0 }
663
690
  );
664
691
  }
665
- async function timedFetch(url, fetchOptions, ctxSignal, effectiveMs) {
692
+ function attemptDeadline(ctxSignal, effectiveMs) {
666
693
  const ac = new AbortController();
667
- const timer = setTimeout(() => ac.abort(), effectiveMs);
668
- const signal = ctxSignal ? AbortSignal.any([ctxSignal, ac.signal]) : ac.signal;
694
+ let timer;
695
+ const arm = (message) => {
696
+ clearTimeout(timer);
697
+ timer = setTimeout(() => ac.abort(new NodeError("TIMEOUT", message)), effectiveMs);
698
+ };
699
+ arm(`Request timed out after ${effectiveMs}ms`);
700
+ return {
701
+ signal: ctxSignal ? AbortSignal.any([ctxSignal, ac.signal]) : ac.signal,
702
+ expired: () => ac.signal.aborted,
703
+ reason: () => ac.signal.reason,
704
+ clear: () => clearTimeout(timer),
705
+ awaitChunk: () => arm(`The answer sent nothing for ${effectiveMs}ms`)
706
+ };
707
+ }
708
+ function attemptFailure(err, ctxSignal, deadline) {
709
+ if (ctxSignal?.aborted) return ctxSignal.reason;
710
+ if (deadline.expired()) return deadline.reason();
711
+ return err;
712
+ }
713
+ function answerUnderDeadline(res, ctxSignal, deadline) {
714
+ if (!res.body) return rebuilt(res, null) ?? asRuntimeResponse(res);
715
+ let reader;
716
+ const body = new ReadableStream(
717
+ {
718
+ async pull(controller) {
719
+ reader ??= res.body.getReader();
720
+ deadline.awaitChunk();
721
+ try {
722
+ const { done, value } = await reader.read();
723
+ if (done) controller.close();
724
+ else controller.enqueue(value);
725
+ } catch (err) {
726
+ controller.error(attemptFailure(err, ctxSignal, deadline));
727
+ } finally {
728
+ deadline.clear();
729
+ }
730
+ },
731
+ cancel(reason) {
732
+ deadline.clear();
733
+ return reader ? reader.cancel(reason) : res.body?.cancel(reason);
734
+ }
735
+ },
736
+ // Read nothing ahead of the caller: the limit times the caller's waits, not
737
+ // a buffer filling while nobody reads.
738
+ { highWaterMark: 0 }
739
+ );
740
+ return rebuilt(res, body) ?? asRuntimeResponse(res);
741
+ }
742
+ function rebuilt(res, body) {
669
743
  try {
670
- return await fetch(url, { ...fetchOptions, signal, dispatcher: guardedDispatcher() });
744
+ return withUrl(new Response(body, { status: res.status, statusText: res.statusText, headers: res.headers }), res.url);
745
+ } catch {
746
+ return void 0;
747
+ }
748
+ }
749
+ function asRuntimeResponse(res) {
750
+ return withUrl(withStatusLine(new Response(res.body, { headers: res.headers }), res), res.url);
751
+ }
752
+ function withStatusLine(answer, from) {
753
+ const clone = answer.clone.bind(answer);
754
+ Object.defineProperties(answer, {
755
+ status: { value: from.status },
756
+ statusText: { value: from.statusText },
757
+ ok: { value: from.ok },
758
+ clone: { value: () => withStatusLine(clone(), from), configurable: true }
759
+ });
760
+ return answer;
761
+ }
762
+ function withUrl(answer, url) {
763
+ const clone = answer.clone.bind(answer);
764
+ Object.defineProperty(answer, "url", { value: url });
765
+ Object.defineProperty(answer, "clone", { value: () => withUrl(clone(), url) });
766
+ return answer;
767
+ }
768
+ var runtimeFetch = globalThis.fetch;
769
+ function transport() {
770
+ const current = globalThis.fetch;
771
+ return current === runtimeFetch ? undiciFetch : current;
772
+ }
773
+ var NEVER_CONNECTED = /* @__PURE__ */ new Set(["ENOTFOUND", "EAI_AGAIN", "UND_ERR_CONNECT_TIMEOUT"]);
774
+ var REFUSED_WHILE_CONNECTING = /* @__PURE__ */ new Set(["ECONNREFUSED", "EHOSTUNREACH", "ENETUNREACH"]);
775
+ function connectFailed(e) {
776
+ if (typeof e?.code !== "string") return false;
777
+ return NEVER_CONNECTED.has(e.code) || REFUSED_WHILE_CONNECTING.has(e.code) && e.syscall === "connect";
778
+ }
779
+ function neverConnected(err) {
780
+ const cause = err?.cause;
781
+ if (!cause) return false;
782
+ if (Array.isArray(cause.errors) && cause.errors.length > 0) return cause.errors.every(connectFailed);
783
+ return connectFailed(cause);
784
+ }
785
+ function markUnsent(err) {
786
+ if (typeof err !== "object" || err === null) return;
787
+ const meta = err.meta;
788
+ const sent = { ...typeof meta === "object" && meta !== null ? meta : {}, sent: false };
789
+ try {
790
+ Object.defineProperty(err, "meta", { value: sent, writable: true, configurable: true, enumerable: true });
791
+ } catch {
792
+ }
793
+ }
794
+ function mayHaveBeenSent(err) {
795
+ const meta = err?.meta;
796
+ return meta?.sent !== false;
797
+ }
798
+ async function timedFetch(url, fetchOptions, ctxSignal, deadline, wire) {
799
+ try {
800
+ const send = transport();
801
+ const res = await send(url, { ...fetchOptions, signal: deadline.signal, dispatcher: guardedDispatcher() });
802
+ wire.maybeSent = true;
803
+ return res;
671
804
  } catch (err) {
672
805
  const blocked = connectionRefusal(err);
673
806
  if (blocked) throw blocked;
674
- if (ctxSignal?.aborted) throw ctxSignal.reason;
675
- if (ac.signal.aborted) throw new NodeError("TIMEOUT", `Request timed out after ${effectiveMs}ms`);
676
- throw err;
677
- } finally {
678
- clearTimeout(timer);
807
+ if (!neverConnected(err)) wire.maybeSent = true;
808
+ throw attemptFailure(err, ctxSignal, deadline);
679
809
  }
680
810
  }
681
- async function guardUrl(url, ctxSignal, effectiveMs) {
682
- const ac = new AbortController();
683
- const timer = setTimeout(() => ac.abort(), effectiveMs);
684
- const signal = ctxSignal ? AbortSignal.any([ctxSignal, ac.signal]) : ac.signal;
811
+ async function guardUrl(url, ctxSignal, deadline, effectiveMs) {
685
812
  try {
686
- await assertPublicUrl(url, { signal });
813
+ await assertPublicUrl(url, { signal: deadline.signal });
687
814
  } catch (err) {
688
815
  if (ctxSignal?.aborted) throw ctxSignal.reason;
689
- if (ac.signal.aborted) throw new NodeError("TIMEOUT", `DNS resolution timed out after ${effectiveMs}ms`);
816
+ if (deadline.expired()) throw new NodeError("TIMEOUT", `DNS resolution timed out after ${effectiveMs}ms`);
690
817
  throw err;
691
- } finally {
692
- clearTimeout(timer);
693
818
  }
694
819
  }
695
820
  function hasContent(body) {
@@ -710,17 +835,28 @@ function refuseUserinfo(url, status = 0) {
710
835
  );
711
836
  }
712
837
  }
713
- async function guardedFetch(url, fetchOptions, ctxSignal, effectiveMs, binding) {
838
+ async function guardedFetch(url, fetchOptions, ctxSignal, effectiveMs, wire, binding) {
839
+ const deadline = attemptDeadline(ctxSignal, effectiveMs);
840
+ try {
841
+ const res = await followHops(url, fetchOptions, ctxSignal, deadline, effectiveMs, wire, binding);
842
+ deadline.clear();
843
+ return answerUnderDeadline(res, ctxSignal, deadline);
844
+ } catch (err) {
845
+ deadline.clear();
846
+ throw err;
847
+ }
848
+ }
849
+ async function followHops(url, fetchOptions, ctxSignal, deadline, effectiveMs, wire, binding) {
714
850
  let currentUrl = url;
715
851
  let currentInit = { ...fetchOptions, redirect: "manual" };
716
- await guardUrl(currentUrl, ctxSignal, effectiveMs);
852
+ await guardUrl(currentUrl, ctxSignal, deadline, effectiveMs);
717
853
  for (let hop = 0; ; hop++) {
718
854
  const release = guardConnectionsTo(
719
855
  currentUrl instanceof URL ? currentUrl : new URL(currentUrl)
720
856
  );
721
857
  let res;
722
858
  try {
723
- res = await timedFetch(currentUrl, currentInit, ctxSignal, effectiveMs);
859
+ res = await timedFetch(currentUrl, currentInit, ctxSignal, deadline, wire);
724
860
  } finally {
725
861
  release();
726
862
  }
@@ -762,7 +898,7 @@ async function guardedFetch(url, fetchOptions, ctxSignal, effectiveMs, binding)
762
898
  { status: 0 }
763
899
  );
764
900
  }
765
- await guardUrl(nextUrl, ctxSignal, effectiveMs);
901
+ await guardUrl(nextUrl, ctxSignal, deadline, effectiveMs);
766
902
  if (binding && nextUrl.origin !== binding.origin) {
767
903
  for (const name of [...headers.keys()]) headers.delete(name);
768
904
  } else if (nextUrl.origin !== base.origin) {
@@ -775,6 +911,15 @@ async function guardedFetch(url, fetchOptions, ctxSignal, effectiveMs, binding)
775
911
  }
776
912
  }
777
913
  async function safeFetch(url, options = {}) {
914
+ const wire = { maybeSent: false };
915
+ try {
916
+ return await sendWithin(url, options, wire);
917
+ } catch (err) {
918
+ if (!wire.maybeSent && err !== options.signal?.reason) markUnsent(err);
919
+ throw err;
920
+ }
921
+ }
922
+ async function sendWithin(url, options, wire) {
778
923
  const { timeoutMs = DEFAULT_TIMEOUT_MS, signal: ctxSignal, retry, credential, ...fetchOptions } = options;
779
924
  const effectiveMs = Number.isFinite(timeoutMs) && timeoutMs > 0 ? Math.min(timeoutMs, MAX_TIMEOUT_MS) : DEFAULT_TIMEOUT_MS;
780
925
  const rawAttempts = retry?.attempts ?? DEFAULT_RETRY_ATTEMPTS;
@@ -799,7 +944,7 @@ async function safeFetch(url, options = {}) {
799
944
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
800
945
  if (ctxSignal?.aborted) throw ctxSignal.reason;
801
946
  try {
802
- return await guardedFetch(url, fetchOptions, ctxSignal, effectiveMs, binding);
947
+ return await guardedFetch(url, fetchOptions, ctxSignal, effectiveMs, wire, binding);
803
948
  } catch (err) {
804
949
  if (ctxSignal?.aborted) throw ctxSignal.reason;
805
950
  if (err instanceof NodeError && (err.code === "BLOCKED_ADDRESS" || err.code === "TOO_MANY_REDIRECTS" || err.code === "CREDENTIAL_ORIGIN" || err.code === "INVALID_URL")) {
@@ -910,8 +1055,8 @@ function timeoutConfigField(opts) {
910
1055
  key: "timeoutMs",
911
1056
  label: { en: "Timeout (ms)", de: "Zeitlimit (ms)" },
912
1057
  description: {
913
- en: "How long one attempt may take before it fails as a timeout. Counted per attempt and per redirect hop, not across the whole call.",
914
- de: "Wie lange ein einzelner Versuch dauern darf, bevor er als Zeit\xFCberschreitung fehlschl\xE4gt. Gilt je Versuch und je Weiterleitung, nicht f\xFCr den gesamten Aufruf."
1058
+ en: "How long one attempt may wait for an answer to start, redirects included, and how long the answer may then go silent before it fails as a timeout. An answer that keeps arriving is not cut off. Each retry gets the time again.",
1059
+ de: "Wie lange ein einzelner Versuch auf den Beginn einer Antwort warten darf, Weiterleitungen eingeschlossen, und wie lange die Antwort danach stocken darf, bevor sie als Zeit\xFCberschreitung fehlschl\xE4gt. Eine Antwort, die weiter eintrifft, wird nicht abgebrochen. Jede Wiederholung bekommt die Zeit erneut."
915
1060
  },
916
1061
  type: "number",
917
1062
  default: opts?.default ?? DEFAULT_TIMEOUT_MS,
@@ -1282,6 +1427,7 @@ export {
1282
1427
  ApiKeyCredential,
1283
1428
  BaseCredential,
1284
1429
  BasicAuthCredential,
1430
+ CONFIG_KEY_PATTERN,
1285
1431
  DEFAULT_MAX_RESPONSE_BYTES,
1286
1432
  DEFAULT_RETRY_ATTEMPTS,
1287
1433
  DEFAULT_RETRY_DELAY_MS,
@@ -1292,11 +1438,14 @@ export {
1292
1438
  MAX_RESPONSE_BYTES,
1293
1439
  MAX_RETRY_ATTEMPTS,
1294
1440
  MAX_TIMEOUT_MS,
1441
+ NAMESPACED_SLUG_PATTERN,
1295
1442
  NodeError,
1296
1443
  OAuth2AuthCodeCredential,
1297
1444
  OAuth2ClientCredentialsCredential,
1298
1445
  OPERATORS,
1299
1446
  RetryableError,
1447
+ SEMVER_PATTERN,
1448
+ SLUG_PATTERN,
1300
1449
  SimpleValueCredential,
1301
1450
  VALUELESS_OPERATORS,
1302
1451
  VPN_ERROR_CODES,
@@ -1315,6 +1464,8 @@ export {
1315
1464
  extractCredentialManifests,
1316
1465
  extractManifest,
1317
1466
  extractManifests,
1467
+ guardRelaxedForLocalDev,
1468
+ guardedLookup,
1318
1469
  headerCrossesOrigin,
1319
1470
  httpErrorResult,
1320
1471
  installAllowedTargets,
@@ -1326,6 +1477,8 @@ export {
1326
1477
  isOperator,
1327
1478
  isVpnErrorCode,
1328
1479
  maxBytesConfigField,
1480
+ mayHaveBeenSent,
1481
+ nodeManifestProblems,
1329
1482
  normalizeCredentialType,
1330
1483
  normalizeLocalized,
1331
1484
  outputProblems,
@@ -73,6 +73,31 @@ export declare function packageIconProblem(icon: string | undefined): string | u
73
73
  * enforces that on upload; this is a typed, lenient read for tooling.
74
74
  */
75
75
  export declare function parsePackageMeta(raw: unknown): NodePackageMeta;
76
+ /**
77
+ * The name patterns the registry enforces on a node's manifest when a package is
78
+ * uploaded (PO-744), as written in the integrations service's node schema
79
+ * (`app/Support/Schema/Versions/Node/V0DraftSchema.php`, read at `973b28c2`, 27
80
+ * October 2026), under the same names. A copy, not a shared source: a change on
81
+ * either side has to be made on both. They are exported as the schema's text, so
82
+ * a test on the other side can compare them byte for byte.
83
+ */
84
+ /** A port name, `sourceFromConfig` and `fallback.name`: lowercase kebab-case. */
85
+ export declare const SLUG_PATTERN = "^[a-z][a-z0-9-]{0,62}$";
86
+ /** A node's `slug`, and a credential type a setting names: `<namespace>:<slug>`. */
87
+ export declare const NAMESPACED_SLUG_PATTERN = "^[a-z][a-z0-9-]{0,62}:[a-z][a-z0-9-]{0,62}$";
88
+ /** A node's `version`. */
89
+ export declare const SEMVER_PATTERN = "^\\d+\\.\\d+\\.\\d+(-[a-zA-Z0-9.-]+)?$";
90
+ /** A setting's `key`, `dependsOn[]` and `showIf.key`: camelCase is allowed here, unlike in a port name. */
91
+ export declare const CONFIG_KEY_PATTERN = "^[a-zA-Z][a-zA-Z0-9-]*$";
92
+ /**
93
+ * Why the registry would refuse these nodes' names on upload, one line per value,
94
+ * or `[]` when it would accept them (PO-744). Each line names the node, the path
95
+ * of the field, the value and the pattern. Only values that are present are
96
+ * judged: a missing slug or version is the schema's other rule, and a port may
97
+ * leave its name out. Settings are walked into `properties` and `items` at any
98
+ * depth; the schema allows less, so none is missed.
99
+ */
100
+ export declare function nodeManifestProblems(nodes: readonly INodeDescription[]): string[];
76
101
  /**
77
102
  * Builds the manifest envelope from a package's `NODES` (and optional
78
103
  * `CREDENTIALS` / `TEMPLATES`) exports. The result is what gets written to
package/dist/ssrf.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type LookupFunction } from 'node:net';
1
2
  import { Agent } from 'undici';
2
3
  import { NodeError } from './errors.js';
3
4
  /**
@@ -40,6 +41,44 @@ export declare const ssrfResolver: {
40
41
  * Promised behaviour: specs/ssrf-guard.md (AC-9, AC-11, AC-19).
41
42
  */
42
43
  export declare function isBlockedAddress(ip: string): boolean;
44
+ export declare function guardRelaxedForLocalDev(): boolean;
45
+ /** How a connection that `safeFetch` does not make reports what {@link guardedLookup} decides. */
46
+ export interface GuardedLookupOptions {
47
+ /**
48
+ * The error a refused name fails with. It is handed to the caller and may reach
49
+ * a workflow author, so it names the host and never an address it resolved to.
50
+ * Default: `Blocked <noun> to <host>: resolves to a private or reserved
51
+ * address`, code `BLOCKED_ADDRESS` — `safeFetch`'s words with this lookup's noun.
52
+ */
53
+ refusal?: (host: string) => Error;
54
+ /**
55
+ * For a client a VPN route cannot carry: a private address on the run's
56
+ * allow-list is refused with this error instead of passing. Absent, the
57
+ * allow-list applies as it does to `safeFetch`.
58
+ */
59
+ unroutable?: (host: string) => Error;
60
+ /** The noun of the operator's log line, `[ssrf] blocked <noun> to …`, and of the default refusal. Default `'connection'`. */
61
+ noun?: 'request' | 'connection';
62
+ }
63
+ /**
64
+ * A socket lookup, shaped like `net.LookupFunction`, that judges every address a
65
+ * name resolves to by the rule `safeFetch` connects under (PO-698) — for a client
66
+ * that opens its own socket (FTP, SFTP, a plain `node:http` stream) and so never
67
+ * meets `safeFetch`. Pass it as the socket's `lookup` with the port it connects to.
68
+ *
69
+ * The check runs inside the socket's own name resolution, so the socket can only
70
+ * reach addresses that were judged, with no second lookup a changed DNS answer
71
+ * could slip through, and Node still tries every approved address in turn. A
72
+ * name is refused when any address is private or reserved and not on the run's
73
+ * allow-list for `port` (PO-828); the refusal is logged for the operator; the
74
+ * local-development switch ({@link guardRelaxedForLocalDev}) relaxes it. A name
75
+ * that does not resolve fails as the resolver said, not as a refusal.
76
+ *
77
+ * **Node calls no lookup for a host written as an address**, so a literal never
78
+ * reaches this. A caller judges a literal itself, with `isBlockedAddress` and
79
+ * `isAllowedTarget`, before it opens the socket.
80
+ */
81
+ export declare function guardedLookup(port: number, options?: GuardedLookupOptions): LookupFunction;
43
82
  /**
44
83
  * The dispatcher `safeFetch` hands every request to. One per process, so
45
84
  * keep-alive survives across calls; its pool holds only connections a `safeFetch`
package/dist/types.d.ts CHANGED
@@ -72,6 +72,12 @@ export interface IOutputField {
72
72
  export interface IOutputPort {
73
73
  kind: OutputKind;
74
74
  dataType: DataType;
75
+ /**
76
+ * The port's name, as a branch and an edge refer to it. Lowercase kebab-case —
77
+ * `not-found`, never `notFound` — because the registry refuses anything else
78
+ * on upload and `rvnxx-nodes manifest` stops on it (`SLUG_PATTERN`). A setting's
79
+ * `key` may be camelCase; a port name may not.
80
+ */
75
81
  name?: string;
76
82
  label?: LocalizedString;
77
83
  description?: LocalizedString;
@@ -95,6 +101,7 @@ export interface IOutputPort {
95
101
  */
96
102
  resolveOutputs?: boolean;
97
103
  fallback?: {
104
+ /** Lowercase kebab-case, as {@link IOutputPort.name}. */
98
105
  name: string;
99
106
  label?: LocalizedString;
100
107
  description?: LocalizedString;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@revenexx/integrations-node-sdk",
3
- "version": "1.13.0",
3
+ "version": "1.15.0",
4
4
  "description": "TypeScript interfaces and utilities for Revenexx integration nodes",
5
5
  "license": "MIT",
6
6
  "author": "revenexx GmbH",