@juspay/neurolink 9.87.0 → 9.87.2

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.
@@ -3,6 +3,15 @@
3
3
  *
4
4
  * Runtime helper lives in `src/lib/utils/safeFetch.ts`.
5
5
  */
6
+ /**
7
+ * One validated address the pinned connect layer is allowed to dial.
8
+ * Produced by `ssrfGuard.ts:validateAndResolveUrl`, consumed by
9
+ * `safeFetch.ts:buildPinnedAgent`.
10
+ */
11
+ export type PinnedAddress = {
12
+ ip: string;
13
+ family: 4 | 6;
14
+ };
6
15
  export type SafeDownloadOptions = {
7
16
  /** Hard cap on response size in bytes. Pass MAX_VIDEO_BYTES/MAX_AUDIO_BYTES/MAX_IMAGE_BYTES from sizeGuard. */
8
17
  maxBytes: number;
@@ -4,9 +4,9 @@
4
4
  * Combines:
5
5
  * - `assertSafeUrl` (validates and rejects blocked IPs)
6
6
  * - undici `Agent` with custom `connect.lookup` so the actual connection
7
- * uses the IP we validated (closes the DNS-rebinding window where the
8
- * resolver returns a public IP for the guard but a private IP for the
9
- * real request).
7
+ * only ever dials addresses we validated (closes the DNS-rebinding window
8
+ * where the resolver returns a public IP for the guard but a private IP
9
+ * for the real request).
10
10
  * - `readBoundedBuffer` for size cap.
11
11
  * - `redirect: "manual"` so a 3xx → private-IP redirect can't bypass
12
12
  * the guard.
@@ -4,9 +4,9 @@
4
4
  * Combines:
5
5
  * - `assertSafeUrl` (validates and rejects blocked IPs)
6
6
  * - undici `Agent` with custom `connect.lookup` so the actual connection
7
- * uses the IP we validated (closes the DNS-rebinding window where the
8
- * resolver returns a public IP for the guard but a private IP for the
9
- * real request).
7
+ * only ever dials addresses we validated (closes the DNS-rebinding window
8
+ * where the resolver returns a public IP for the guard but a private IP
9
+ * for the real request).
10
10
  * - `readBoundedBuffer` for size cap.
11
11
  * - `redirect: "manual"` so a 3xx → private-IP redirect can't bypass
12
12
  * the guard.
@@ -21,21 +21,39 @@ import { readBoundedBuffer } from "./sizeGuard.js";
21
21
  import { validateAndResolveUrl } from "./ssrfGuard.js";
22
22
  const DEFAULT_TIMEOUT_MS = 60_000;
23
23
  /**
24
- * Build a once-off undici Agent whose connect lookup resolves `hostname` to a
25
- * fixed IP. This pins the actual TCP connection to the IP we validated,
26
- * removing the DNS-rebinding window.
24
+ * Build a once-off undici Agent whose connect lookup resolves `hostname` to
25
+ * the validated address set. The actual TCP connection can only ever go to
26
+ * an address the SSRF guard cleared, removing the DNS-rebinding window.
27
+ *
28
+ * The full set (IPv4 first) matters: pinning a single address turns one
29
+ * unroutable family into a hard connect timeout. On IPv4-only networks where
30
+ * the OS resolver prefers AAAA, that broke every safeDownload (Replicate /
31
+ * Runway / Kling asset fetches) while plain curl of the same URL succeeded —
32
+ * curl races both families (Happy Eyeballs); a one-address pin cannot.
27
33
  */
28
- function buildPinnedAgent(hostname, ip, family) {
34
+ function buildPinnedAgent(hostname, addresses) {
35
+ const primary = addresses[0];
36
+ if (!primary) {
37
+ throw new Error(`safeFetch: no validated addresses to pin for "${hostname}"`);
38
+ }
29
39
  return new Agent({
30
40
  connect: {
31
- lookup: (host, _options, callback) => {
41
+ lookup: (host, options, callback) => {
32
42
  if (host.toLowerCase() !== hostname.toLowerCase()) {
33
43
  // The host the connect layer asks for differs from the URL host —
34
44
  // this happens for absolute Host headers etc. Reject defensively.
35
45
  callback(new Error(`safeFetch: refusing to resolve "${host}" — expected "${hostname}"`), "", 0);
36
46
  return;
37
47
  }
38
- callback(null, ip, family);
48
+ // Node ≥20 enables autoSelectFamily (Happy Eyeballs) by default, and
49
+ // its lookup contract passes `all: true` expecting an address ARRAY.
50
+ // Hand it every validated address so it can race families and fall
51
+ // back when one is unroutable.
52
+ if (options?.all) {
53
+ callback(null, addresses.map((a) => ({ address: a.ip, family: a.family })));
54
+ return;
55
+ }
56
+ callback(null, primary.ip, primary.family);
39
57
  },
40
58
  },
41
59
  });
@@ -47,10 +65,10 @@ function buildPinnedAgent(hostname, ip, family) {
47
65
  * is encountered, or the HTTP status indicates failure.
48
66
  */
49
67
  export async function safeDownload(url, options) {
50
- const { url: validatedUrl, ip, family } = await validateAndResolveUrl(url);
68
+ const { url: validatedUrl, addresses } = await validateAndResolveUrl(url);
51
69
  const parsed = new URL(validatedUrl);
52
70
  const hostname = parsed.hostname.replace(/^\[|\]$/g, "");
53
- const agent = buildPinnedAgent(hostname, ip, family);
71
+ const agent = buildPinnedAgent(hostname, addresses);
54
72
  const timeoutCtrl = new AbortController();
55
73
  const timeoutId = setTimeout(() => timeoutCtrl.abort(), options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
56
74
  const composedSignal = options.signal
@@ -24,6 +24,7 @@
24
24
  *
25
25
  * @module utils/ssrfGuard
26
26
  */
27
+ import type { PinnedAddress } from "../types/index.js";
27
28
  /**
28
29
  * Assert that `url` is safe to fetch server-side.
29
30
  *
@@ -35,12 +36,18 @@
35
36
  */
36
37
  export declare function assertSafeUrl(url: string): Promise<void>;
37
38
  /**
38
- * Validate `url` and return the resolved IP that should be used for the
39
- * actual fetch (companion to `safeFetch.ts:safeDownload`).
39
+ * Validate `url` and return the resolved address set that should be used for
40
+ * the actual fetch (companion to `safeFetch.ts:safeDownload`).
40
41
  *
41
- * For IP-literal hosts, returns the normalised IP and family. For hostnames,
42
- * returns the first acceptable IP from the resolver. Same throw semantics as
43
- * {@link assertSafeUrl}.
42
+ * For IP-literal hosts, `addresses` is the normalised IP as a singleton. For
43
+ * hostnames it is **every** validated address, IPv4 first, so the connect
44
+ * layer can race families (Happy Eyeballs). Pinning a single OS-preferred
45
+ * address breaks every download on IPv4-only networks whenever the resolver
46
+ * prefers AAAA: the pinned connection has no second address to fall back to,
47
+ * so it times out where plain `curl` (which races both families) succeeds.
48
+ * `ip`/`family` mirror the first entry for callers that need a single pin.
49
+ *
50
+ * Same throw semantics as {@link assertSafeUrl}.
44
51
  *
45
52
  * This is the canonical entry point for binary downloads where DNS-rebinding
46
53
  * pinning matters — see `safeFetch.ts`.
@@ -49,4 +56,5 @@ export declare function validateAndResolveUrl(url: string): Promise<{
49
56
  url: string;
50
57
  ip: string;
51
58
  family: 4 | 6;
59
+ addresses: readonly PinnedAddress[];
52
60
  }>;
@@ -306,6 +306,17 @@ export async function assertSafeUrl(url) {
306
306
  }
307
307
  // Hostname — resolve BOTH A and AAAA. Reject if either family yields a
308
308
  // blocked address (closes off the "publish AAAA public, A private" attack).
309
+ await resolveAndValidateHost(url, host);
310
+ }
311
+ /**
312
+ * Resolve `host` (both A and AAAA) and validate every returned address
313
+ * against the block lists. Returns the full validated set so callers that
314
+ * pin connections can offer the connect layer more than one address.
315
+ *
316
+ * @throws {Error} when resolution fails entirely or any address is blocked
317
+ * (all-must-pass — the caller may connect to any address in the set).
318
+ */
319
+ async function resolveAndValidateHost(url, host) {
309
320
  const [a, aaaa] = await Promise.allSettled([
310
321
  lookup(host, { family: 4, all: true }),
311
322
  lookup(host, { family: 6, all: true }),
@@ -354,14 +365,21 @@ export async function assertSafeUrl(url) {
354
365
  throw new Error(`URL "${url}" rejected: hostname ${host} resolves to ${addr} (IPv6 ${reason})`);
355
366
  }
356
367
  }
368
+ return { v4: v4Addresses, v6: v6Addresses };
357
369
  }
358
370
  /**
359
- * Validate `url` and return the resolved IP that should be used for the
360
- * actual fetch (companion to `safeFetch.ts:safeDownload`).
371
+ * Validate `url` and return the resolved address set that should be used for
372
+ * the actual fetch (companion to `safeFetch.ts:safeDownload`).
373
+ *
374
+ * For IP-literal hosts, `addresses` is the normalised IP as a singleton. For
375
+ * hostnames it is **every** validated address, IPv4 first, so the connect
376
+ * layer can race families (Happy Eyeballs). Pinning a single OS-preferred
377
+ * address breaks every download on IPv4-only networks whenever the resolver
378
+ * prefers AAAA: the pinned connection has no second address to fall back to,
379
+ * so it times out where plain `curl` (which races both families) succeeds.
380
+ * `ip`/`family` mirror the first entry for callers that need a single pin.
361
381
  *
362
- * For IP-literal hosts, returns the normalised IP and family. For hostnames,
363
- * returns the first acceptable IP from the resolver. Same throw semantics as
364
- * {@link assertSafeUrl}.
382
+ * Same throw semantics as {@link assertSafeUrl}.
365
383
  *
366
384
  * This is the canonical entry point for binary downloads where DNS-rebinding
367
385
  * pinning matters — see `safeFetch.ts`.
@@ -384,7 +402,7 @@ export async function validateAndResolveUrl(url) {
384
402
  if (isBlockedIPv4(v4)) {
385
403
  throw new Error(`URL "${url}" rejected: IPv4 ${host} → ${v4} is in a blocked range`);
386
404
  }
387
- return { url, ip: v4, family: 4 };
405
+ return { url, ip: v4, family: 4, addresses: [{ ip: v4, family: 4 }] };
388
406
  }
389
407
  if (host.includes(":")) {
390
408
  const v4FromMapped = extractIPv4FromMapped(host);
@@ -392,7 +410,12 @@ export async function validateAndResolveUrl(url) {
392
410
  if (isBlockedIPv4(v4FromMapped)) {
393
411
  throw new Error(`URL "${url}" rejected: IPv4-mapped IPv6 ${host} → ${v4FromMapped} is in a blocked range`);
394
412
  }
395
- return { url, ip: v4FromMapped, family: 4 };
413
+ return {
414
+ url,
415
+ ip: v4FromMapped,
416
+ family: 4,
417
+ addresses: [{ ip: v4FromMapped, family: 4 }],
418
+ };
396
419
  }
397
420
  const expanded = expandIPv6(host);
398
421
  if (!expanded) {
@@ -401,11 +424,23 @@ export async function validateAndResolveUrl(url) {
401
424
  if (isBlockedIPv6(expanded)) {
402
425
  throw new Error(`URL "${url}" rejected: IPv6 ${host} is in a blocked range`);
403
426
  }
404
- return { url, ip: host, family: 6 };
405
- }
406
- // Hostname — resolve and pick a safe address
407
- await assertSafeUrl(url);
408
- const result = await lookup(host);
409
- return { url, ip: result.address, family: result.family };
427
+ return { url, ip: host, family: 6, addresses: [{ ip: host, family: 6 }] };
428
+ }
429
+ // Hostname — resolve once, validate every address, and return the whole
430
+ // set. Validating the same answer we hand to the connect layer also
431
+ // removes the rebinding window the previous separate `lookup()` left
432
+ // between validation and pinning.
433
+ const { v4: v4Addresses, v6: v6Addresses } = await resolveAndValidateHost(url, host);
434
+ const addresses = [
435
+ ...v4Addresses.map((ip) => ({ ip, family: 4 })),
436
+ ...v6Addresses.map((ip) => ({ ip, family: 6 })),
437
+ ];
438
+ const first = addresses[0];
439
+ if (!first) {
440
+ // Both lookups "succeeded" but returned zero addresses — treat exactly
441
+ // like a resolution failure rather than pinning nothing.
442
+ throw new Error(`URL "${url}" rejected: hostname ${host} resolved to an empty address set`);
443
+ }
444
+ return { url, ip: first.ip, family: first.family, addresses };
410
445
  }
411
446
  //# sourceMappingURL=ssrfGuard.js.map
@@ -3,6 +3,15 @@
3
3
  *
4
4
  * Runtime helper lives in `src/lib/utils/safeFetch.ts`.
5
5
  */
6
+ /**
7
+ * One validated address the pinned connect layer is allowed to dial.
8
+ * Produced by `ssrfGuard.ts:validateAndResolveUrl`, consumed by
9
+ * `safeFetch.ts:buildPinnedAgent`.
10
+ */
11
+ export type PinnedAddress = {
12
+ ip: string;
13
+ family: 4 | 6;
14
+ };
6
15
  export type SafeDownloadOptions = {
7
16
  /** Hard cap on response size in bytes. Pass MAX_VIDEO_BYTES/MAX_AUDIO_BYTES/MAX_IMAGE_BYTES from sizeGuard. */
8
17
  maxBytes: number;
@@ -4,9 +4,9 @@
4
4
  * Combines:
5
5
  * - `assertSafeUrl` (validates and rejects blocked IPs)
6
6
  * - undici `Agent` with custom `connect.lookup` so the actual connection
7
- * uses the IP we validated (closes the DNS-rebinding window where the
8
- * resolver returns a public IP for the guard but a private IP for the
9
- * real request).
7
+ * only ever dials addresses we validated (closes the DNS-rebinding window
8
+ * where the resolver returns a public IP for the guard but a private IP
9
+ * for the real request).
10
10
  * - `readBoundedBuffer` for size cap.
11
11
  * - `redirect: "manual"` so a 3xx → private-IP redirect can't bypass
12
12
  * the guard.
@@ -4,9 +4,9 @@
4
4
  * Combines:
5
5
  * - `assertSafeUrl` (validates and rejects blocked IPs)
6
6
  * - undici `Agent` with custom `connect.lookup` so the actual connection
7
- * uses the IP we validated (closes the DNS-rebinding window where the
8
- * resolver returns a public IP for the guard but a private IP for the
9
- * real request).
7
+ * only ever dials addresses we validated (closes the DNS-rebinding window
8
+ * where the resolver returns a public IP for the guard but a private IP
9
+ * for the real request).
10
10
  * - `readBoundedBuffer` for size cap.
11
11
  * - `redirect: "manual"` so a 3xx → private-IP redirect can't bypass
12
12
  * the guard.
@@ -21,21 +21,39 @@ import { readBoundedBuffer } from "./sizeGuard.js";
21
21
  import { validateAndResolveUrl } from "./ssrfGuard.js";
22
22
  const DEFAULT_TIMEOUT_MS = 60_000;
23
23
  /**
24
- * Build a once-off undici Agent whose connect lookup resolves `hostname` to a
25
- * fixed IP. This pins the actual TCP connection to the IP we validated,
26
- * removing the DNS-rebinding window.
24
+ * Build a once-off undici Agent whose connect lookup resolves `hostname` to
25
+ * the validated address set. The actual TCP connection can only ever go to
26
+ * an address the SSRF guard cleared, removing the DNS-rebinding window.
27
+ *
28
+ * The full set (IPv4 first) matters: pinning a single address turns one
29
+ * unroutable family into a hard connect timeout. On IPv4-only networks where
30
+ * the OS resolver prefers AAAA, that broke every safeDownload (Replicate /
31
+ * Runway / Kling asset fetches) while plain curl of the same URL succeeded —
32
+ * curl races both families (Happy Eyeballs); a one-address pin cannot.
27
33
  */
28
- function buildPinnedAgent(hostname, ip, family) {
34
+ function buildPinnedAgent(hostname, addresses) {
35
+ const primary = addresses[0];
36
+ if (!primary) {
37
+ throw new Error(`safeFetch: no validated addresses to pin for "${hostname}"`);
38
+ }
29
39
  return new Agent({
30
40
  connect: {
31
- lookup: (host, _options, callback) => {
41
+ lookup: (host, options, callback) => {
32
42
  if (host.toLowerCase() !== hostname.toLowerCase()) {
33
43
  // The host the connect layer asks for differs from the URL host —
34
44
  // this happens for absolute Host headers etc. Reject defensively.
35
45
  callback(new Error(`safeFetch: refusing to resolve "${host}" — expected "${hostname}"`), "", 0);
36
46
  return;
37
47
  }
38
- callback(null, ip, family);
48
+ // Node ≥20 enables autoSelectFamily (Happy Eyeballs) by default, and
49
+ // its lookup contract passes `all: true` expecting an address ARRAY.
50
+ // Hand it every validated address so it can race families and fall
51
+ // back when one is unroutable.
52
+ if (options?.all) {
53
+ callback(null, addresses.map((a) => ({ address: a.ip, family: a.family })));
54
+ return;
55
+ }
56
+ callback(null, primary.ip, primary.family);
39
57
  },
40
58
  },
41
59
  });
@@ -47,10 +65,10 @@ function buildPinnedAgent(hostname, ip, family) {
47
65
  * is encountered, or the HTTP status indicates failure.
48
66
  */
49
67
  export async function safeDownload(url, options) {
50
- const { url: validatedUrl, ip, family } = await validateAndResolveUrl(url);
68
+ const { url: validatedUrl, addresses } = await validateAndResolveUrl(url);
51
69
  const parsed = new URL(validatedUrl);
52
70
  const hostname = parsed.hostname.replace(/^\[|\]$/g, "");
53
- const agent = buildPinnedAgent(hostname, ip, family);
71
+ const agent = buildPinnedAgent(hostname, addresses);
54
72
  const timeoutCtrl = new AbortController();
55
73
  const timeoutId = setTimeout(() => timeoutCtrl.abort(), options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
56
74
  const composedSignal = options.signal
@@ -24,6 +24,7 @@
24
24
  *
25
25
  * @module utils/ssrfGuard
26
26
  */
27
+ import type { PinnedAddress } from "../types/index.js";
27
28
  /**
28
29
  * Assert that `url` is safe to fetch server-side.
29
30
  *
@@ -35,12 +36,18 @@
35
36
  */
36
37
  export declare function assertSafeUrl(url: string): Promise<void>;
37
38
  /**
38
- * Validate `url` and return the resolved IP that should be used for the
39
- * actual fetch (companion to `safeFetch.ts:safeDownload`).
39
+ * Validate `url` and return the resolved address set that should be used for
40
+ * the actual fetch (companion to `safeFetch.ts:safeDownload`).
40
41
  *
41
- * For IP-literal hosts, returns the normalised IP and family. For hostnames,
42
- * returns the first acceptable IP from the resolver. Same throw semantics as
43
- * {@link assertSafeUrl}.
42
+ * For IP-literal hosts, `addresses` is the normalised IP as a singleton. For
43
+ * hostnames it is **every** validated address, IPv4 first, so the connect
44
+ * layer can race families (Happy Eyeballs). Pinning a single OS-preferred
45
+ * address breaks every download on IPv4-only networks whenever the resolver
46
+ * prefers AAAA: the pinned connection has no second address to fall back to,
47
+ * so it times out where plain `curl` (which races both families) succeeds.
48
+ * `ip`/`family` mirror the first entry for callers that need a single pin.
49
+ *
50
+ * Same throw semantics as {@link assertSafeUrl}.
44
51
  *
45
52
  * This is the canonical entry point for binary downloads where DNS-rebinding
46
53
  * pinning matters — see `safeFetch.ts`.
@@ -49,4 +56,5 @@ export declare function validateAndResolveUrl(url: string): Promise<{
49
56
  url: string;
50
57
  ip: string;
51
58
  family: 4 | 6;
59
+ addresses: readonly PinnedAddress[];
52
60
  }>;
@@ -306,6 +306,17 @@ export async function assertSafeUrl(url) {
306
306
  }
307
307
  // Hostname — resolve BOTH A and AAAA. Reject if either family yields a
308
308
  // blocked address (closes off the "publish AAAA public, A private" attack).
309
+ await resolveAndValidateHost(url, host);
310
+ }
311
+ /**
312
+ * Resolve `host` (both A and AAAA) and validate every returned address
313
+ * against the block lists. Returns the full validated set so callers that
314
+ * pin connections can offer the connect layer more than one address.
315
+ *
316
+ * @throws {Error} when resolution fails entirely or any address is blocked
317
+ * (all-must-pass — the caller may connect to any address in the set).
318
+ */
319
+ async function resolveAndValidateHost(url, host) {
309
320
  const [a, aaaa] = await Promise.allSettled([
310
321
  lookup(host, { family: 4, all: true }),
311
322
  lookup(host, { family: 6, all: true }),
@@ -354,14 +365,21 @@ export async function assertSafeUrl(url) {
354
365
  throw new Error(`URL "${url}" rejected: hostname ${host} resolves to ${addr} (IPv6 ${reason})`);
355
366
  }
356
367
  }
368
+ return { v4: v4Addresses, v6: v6Addresses };
357
369
  }
358
370
  /**
359
- * Validate `url` and return the resolved IP that should be used for the
360
- * actual fetch (companion to `safeFetch.ts:safeDownload`).
371
+ * Validate `url` and return the resolved address set that should be used for
372
+ * the actual fetch (companion to `safeFetch.ts:safeDownload`).
373
+ *
374
+ * For IP-literal hosts, `addresses` is the normalised IP as a singleton. For
375
+ * hostnames it is **every** validated address, IPv4 first, so the connect
376
+ * layer can race families (Happy Eyeballs). Pinning a single OS-preferred
377
+ * address breaks every download on IPv4-only networks whenever the resolver
378
+ * prefers AAAA: the pinned connection has no second address to fall back to,
379
+ * so it times out where plain `curl` (which races both families) succeeds.
380
+ * `ip`/`family` mirror the first entry for callers that need a single pin.
361
381
  *
362
- * For IP-literal hosts, returns the normalised IP and family. For hostnames,
363
- * returns the first acceptable IP from the resolver. Same throw semantics as
364
- * {@link assertSafeUrl}.
382
+ * Same throw semantics as {@link assertSafeUrl}.
365
383
  *
366
384
  * This is the canonical entry point for binary downloads where DNS-rebinding
367
385
  * pinning matters — see `safeFetch.ts`.
@@ -384,7 +402,7 @@ export async function validateAndResolveUrl(url) {
384
402
  if (isBlockedIPv4(v4)) {
385
403
  throw new Error(`URL "${url}" rejected: IPv4 ${host} → ${v4} is in a blocked range`);
386
404
  }
387
- return { url, ip: v4, family: 4 };
405
+ return { url, ip: v4, family: 4, addresses: [{ ip: v4, family: 4 }] };
388
406
  }
389
407
  if (host.includes(":")) {
390
408
  const v4FromMapped = extractIPv4FromMapped(host);
@@ -392,7 +410,12 @@ export async function validateAndResolveUrl(url) {
392
410
  if (isBlockedIPv4(v4FromMapped)) {
393
411
  throw new Error(`URL "${url}" rejected: IPv4-mapped IPv6 ${host} → ${v4FromMapped} is in a blocked range`);
394
412
  }
395
- return { url, ip: v4FromMapped, family: 4 };
413
+ return {
414
+ url,
415
+ ip: v4FromMapped,
416
+ family: 4,
417
+ addresses: [{ ip: v4FromMapped, family: 4 }],
418
+ };
396
419
  }
397
420
  const expanded = expandIPv6(host);
398
421
  if (!expanded) {
@@ -401,10 +424,22 @@ export async function validateAndResolveUrl(url) {
401
424
  if (isBlockedIPv6(expanded)) {
402
425
  throw new Error(`URL "${url}" rejected: IPv6 ${host} is in a blocked range`);
403
426
  }
404
- return { url, ip: host, family: 6 };
405
- }
406
- // Hostname — resolve and pick a safe address
407
- await assertSafeUrl(url);
408
- const result = await lookup(host);
409
- return { url, ip: result.address, family: result.family };
427
+ return { url, ip: host, family: 6, addresses: [{ ip: host, family: 6 }] };
428
+ }
429
+ // Hostname — resolve once, validate every address, and return the whole
430
+ // set. Validating the same answer we hand to the connect layer also
431
+ // removes the rebinding window the previous separate `lookup()` left
432
+ // between validation and pinning.
433
+ const { v4: v4Addresses, v6: v6Addresses } = await resolveAndValidateHost(url, host);
434
+ const addresses = [
435
+ ...v4Addresses.map((ip) => ({ ip, family: 4 })),
436
+ ...v6Addresses.map((ip) => ({ ip, family: 6 })),
437
+ ];
438
+ const first = addresses[0];
439
+ if (!first) {
440
+ // Both lookups "succeeded" but returned zero addresses — treat exactly
441
+ // like a resolution failure rather than pinning nothing.
442
+ throw new Error(`URL "${url}" rejected: hostname ${host} resolved to an empty address set`);
443
+ }
444
+ return { url, ip: first.ip, family: first.family, addresses };
410
445
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/neurolink",
3
- "version": "9.87.0",
3
+ "version": "9.87.2",
4
4
  "packageManager": "pnpm@10.15.1",
5
5
  "description": "TypeScript AI SDK with 24+ LLM providers behind one consistent API. MCP-native (58+ servers), voice TTS/STT/realtime, RAG, agents, memory, context compaction. OpenAI · Anthropic · Gemini · Bedrock · Azure · Ollama · DeepSeek · NVIDIA NIM and more.",
6
6
  "author": {