@apifuse/provider-sdk 2.2.0-beta.25 → 2.2.0-beta.27

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 (51) hide show
  1. package/AUTHORING.md +7 -6
  2. package/CHANGELOG.md +9 -1
  3. package/README.md +3 -3
  4. package/bin/apifuse-check.ts +62 -3
  5. package/bin/apifuse-pack-check.ts +8 -2
  6. package/bin/apifuse-pack-smoke.ts +43 -2
  7. package/bin/apifuse-pack-types.ts +58 -0
  8. package/dist/auth.js +29 -0
  9. package/dist/cli/templates/provider/README.md.tpl +4 -4
  10. package/dist/contract-serialization.js +4 -8
  11. package/dist/declaration-validation.d.ts +23 -0
  12. package/dist/declaration-validation.js +159 -0
  13. package/dist/define.d.ts +1 -1
  14. package/dist/define.js +13 -2
  15. package/dist/index.d.ts +1 -0
  16. package/dist/lint.js +85 -3
  17. package/dist/runtime/resolver-vendors/bindings.d.ts +42 -2
  18. package/dist/runtime/resolver-vendors/bindings.js +31 -6
  19. package/dist/runtime/resolver-vendors/browser.d.ts +3 -7
  20. package/dist/runtime/resolver-vendors/browser.js +7 -22
  21. package/dist/runtime/resolver-vendors/hosts.d.ts +2 -0
  22. package/dist/runtime/resolver-vendors/hosts.js +33 -0
  23. package/dist/runtime/resolver-vendors/twocaptcha.d.ts +23 -0
  24. package/dist/runtime/resolver-vendors/twocaptcha.js +264 -0
  25. package/dist/runtime/resolver-vendors/types.d.ts +44 -3
  26. package/dist/runtime/resolver-vendors/types.js +10 -0
  27. package/dist/runtime/resolver.d.ts +17 -2
  28. package/dist/runtime/resolver.js +237 -15
  29. package/dist/runtime/stealth.d.ts +26 -4
  30. package/dist/runtime/stealth.js +224 -114
  31. package/dist/server/serve.js +8 -0
  32. package/dist/stealth/profiles.js +16 -7
  33. package/dist/types.d.ts +34 -1
  34. package/package.json +2 -2
  35. package/src/auth.ts +40 -0
  36. package/src/cli/templates/provider/README.md.tpl +4 -4
  37. package/src/contract-serialization.ts +5 -7
  38. package/src/declaration-validation.ts +202 -0
  39. package/src/define.ts +23 -2
  40. package/src/index.ts +1 -0
  41. package/src/lint.ts +98 -3
  42. package/src/runtime/resolver-vendors/bindings.ts +40 -15
  43. package/src/runtime/resolver-vendors/browser.ts +9 -31
  44. package/src/runtime/resolver-vendors/hosts.ts +38 -0
  45. package/src/runtime/resolver-vendors/twocaptcha.ts +366 -0
  46. package/src/runtime/resolver-vendors/types.ts +54 -0
  47. package/src/runtime/resolver.ts +304 -24
  48. package/src/runtime/stealth.ts +317 -136
  49. package/src/server/serve.ts +8 -0
  50. package/src/stealth/profiles.ts +17 -7
  51. package/src/types.ts +36 -3
@@ -11,15 +11,20 @@ import type {
11
11
  ResolverContext,
12
12
  } from "../types.js";
13
13
  import {
14
+ resolverChallengeAllowsDirectCache,
15
+ resolverChallengeIsCacheable,
14
16
  resolverChallengeIsIdentityScoped,
15
17
  resolverChallengeIssuingIdentity,
16
18
  } from "./resolver-vendors/bindings.js";
17
19
  import { createBrowserResolverVendorAdapter } from "./resolver-vendors/browser.js";
20
+ import { assertResolverHostAllowed } from "./resolver-vendors/hosts.js";
21
+ import { createTwoCaptchaResolverVendorAdapter } from "./resolver-vendors/twocaptcha.js";
18
22
  import {
19
23
  RESOLVER_VENDOR_CAPABILITIES,
20
24
  type ResolverIdentity,
21
25
  type ResolverIssuingIdentity,
22
26
  type ResolverVendorAdapter,
27
+ type ResolverVendorTransport,
23
28
  ResolverVendorUnavailableError,
24
29
  type ResolverVendorUnavailableReason,
25
30
  resolverVendorSupports,
@@ -50,6 +55,13 @@ type ResolvedResolverVendor =
50
55
  type ResolverChainAttempt = {
51
56
  readonly vendor: ProviderResolverVendor;
52
57
  readonly reason: ResolverVendorUnavailableReason;
58
+ readonly cause?: {
59
+ readonly name: string;
60
+ readonly message: string;
61
+ };
62
+ readonly upstreamHost?: string;
63
+ readonly phase?: string;
64
+ readonly round?: number;
53
65
  };
54
66
 
55
67
  type ResolverChainClient = ResolverContext & {
@@ -65,6 +77,14 @@ export interface ResolverRuntimeOptions {
65
77
  readonly cache?: ProviderCache;
66
78
  /** Server-owned context/proxy scope used only for identity-bound cache entries. */
67
79
  readonly identityScope?: string;
80
+ /** SDK-owned transport already bound to the resolved proxy lease and client profile. */
81
+ readonly transport?: ResolverVendorTransport;
82
+ /** Creates an SDK-owned transport bound to the declared profile and server-owned scope. */
83
+ readonly createTransport?: (input: {
84
+ readonly clientProfile?: string;
85
+ /** Server-owned proxy/context scope; the SDK never accepts a caller-built identity. */
86
+ readonly identityScope?: string;
87
+ }) => ResolverVendorTransport;
68
88
  }
69
89
 
70
90
  type CachedResolverSolution = {
@@ -86,6 +106,42 @@ const RESOLVER_SOLUTION_INDEX_CACHE_NAMESPACE = "resolver-solution-index";
86
106
  const MIN_RESOLVER_CACHE_TTL_MS = 1_000;
87
107
  const resolverCaches = new WeakMap<object, ProviderCache | null>();
88
108
  const solutionIssuerDigests = new WeakMap<object, string>();
109
+ const SAFE_CAUSE_MESSAGE_WORDS: ReadonlySet<string> = new Set([
110
+ "abort",
111
+ "aborted",
112
+ "at",
113
+ "closed",
114
+ "connect",
115
+ "connection",
116
+ "dns",
117
+ "during",
118
+ "econnrefused",
119
+ "econnreset",
120
+ "error",
121
+ "etimedout",
122
+ "failed",
123
+ "failure",
124
+ "fetch",
125
+ "from",
126
+ "get",
127
+ "lookup",
128
+ "network",
129
+ "post",
130
+ "reading",
131
+ "refused",
132
+ "request",
133
+ "reset",
134
+ "response",
135
+ "socket",
136
+ "timed",
137
+ "timeout",
138
+ "tls",
139
+ "to",
140
+ "unavailable",
141
+ "upstream",
142
+ "while",
143
+ "writing",
144
+ ]);
89
145
 
90
146
  export const RESOLVER_INSTRUMENTATION_METADATA = Symbol.for(
91
147
  "@apifuse/provider-sdk/runtime/resolver-instrumentation-metadata",
@@ -96,15 +152,20 @@ export type ResolverInstrumentationMetadata = {
96
152
  readonly traceRecorder: TraceRecorder;
97
153
  };
98
154
 
99
- type ResolverAdapterFactory = (
155
+ export type ResolverAdapterFactory = (
100
156
  configuration: string,
101
157
  timeoutMs: number,
102
158
  allowedHosts: readonly string[],
103
159
  ) => ResolverVendorAdapter;
104
160
 
105
- export const RESOLVER_ADAPTER_REGISTRY: Partial<
106
- Readonly<Record<ProviderResolverVendor, ResolverAdapterFactory>>
107
- > = {
161
+ const resolverAdapterRegistry: Partial<Record<ProviderResolverVendor, ResolverAdapterFactory>> = {
162
+ "2captcha"(configuration, timeoutMs, allowedHosts) {
163
+ return createTwoCaptchaResolverVendorAdapter({
164
+ allowedHosts,
165
+ apiKey: configuration,
166
+ timeoutMs,
167
+ });
168
+ },
108
169
  browser(configuration, timeoutMs, allowedHosts) {
109
170
  return createBrowserResolverVendorAdapter({
110
171
  allowedHosts,
@@ -114,10 +175,29 @@ export const RESOLVER_ADAPTER_REGISTRY: Partial<
114
175
  },
115
176
  };
116
177
 
178
+ export const RESOLVER_ADAPTER_REGISTRY: Partial<
179
+ Readonly<Record<ProviderResolverVendor, ResolverAdapterFactory>>
180
+ > = resolverAdapterRegistry;
181
+
182
+ export function swapResolverAdapterFactoryForTests(
183
+ vendor: ProviderResolverVendor,
184
+ factory: ResolverAdapterFactory | undefined,
185
+ ): () => void {
186
+ const original = resolverAdapterRegistry[vendor];
187
+ if (factory === undefined) delete resolverAdapterRegistry[vendor];
188
+ else resolverAdapterRegistry[vendor] = factory;
189
+ let restored = false;
190
+ return () => {
191
+ if (restored) return;
192
+ restored = true;
193
+ if (original === undefined) delete resolverAdapterRegistry[vendor];
194
+ else resolverAdapterRegistry[vendor] = original;
195
+ };
196
+ }
197
+
117
198
  // This is the sole allowlist for declared vendors whose registry entry may be absent.
118
199
  // Remove a vendor here when its adapter is registered.
119
200
  const KNOWN_UNIMPLEMENTED_RESOLVER_VENDORS: ReadonlySet<ProviderResolverVendor> = new Set([
120
- "2captcha",
121
201
  "capsolver",
122
202
  "capmonster",
123
203
  "custom",
@@ -178,8 +258,9 @@ function createAdapter(
178
258
  vendor: ResolvedResolverVendor,
179
259
  timeoutMs: number,
180
260
  allowedHosts: readonly string[],
261
+ adapterFactories: Partial<Readonly<Record<ProviderResolverVendor, ResolverAdapterFactory>>>,
181
262
  ): ResolverVendorAdapter {
182
- const factory = RESOLVER_ADAPTER_REGISTRY[vendor.vendor];
263
+ const factory = adapterFactories[vendor.vendor];
183
264
  if (!factory && !KNOWN_UNIMPLEMENTED_RESOLVER_VENDORS.has(vendor.vendor)) {
184
265
  throw new Error(
185
266
  `Resolver adapter factory is missing for implemented vendor "${vendor.vendor}"`,
@@ -216,6 +297,135 @@ function throwExhausted(attempts: readonly ResolverChainAttempt[]): never {
216
297
  });
217
298
  }
218
299
 
300
+ function assertClientProfileTransportContract(
301
+ clientProfile: string | undefined,
302
+ transport: ResolverVendorTransport | undefined,
303
+ ): void {
304
+ if (clientProfile === undefined || transport === undefined) return;
305
+ throw new ProviderError(
306
+ `Resolver client profile "${clientProfile}" cannot be applied to a pre-bound transport`,
307
+ {
308
+ code: "RESOLVER_CLIENT_PROFILE_TRANSPORT_CONFLICT",
309
+ fix: "Remove the pre-bound transport and provide createTransport({ clientProfile, identityScope }) so the SDK can apply the provider-declared profile.",
310
+ },
311
+ );
312
+ }
313
+
314
+ function adapterRequiresTransport(
315
+ adapter: ResolverVendorAdapter,
316
+ kind: ProviderChallengeKind,
317
+ ): boolean {
318
+ return typeof adapter.requiresTransport === "function"
319
+ ? adapter.requiresTransport(kind)
320
+ : adapter.requiresTransport === true;
321
+ }
322
+
323
+ function sanitizeDiagnosticUrl(rawUrl: string): string {
324
+ try {
325
+ const parsed = new URL(rawUrl);
326
+ return `${parsed.protocol}//${parsed.host}`;
327
+ } catch {
328
+ return "[REDACTED_URL]";
329
+ }
330
+ }
331
+
332
+ function sanitizeCauseMessage(message: string): string {
333
+ const withoutUrls = message.replace(/\b[a-z][a-z\d+.-]*:\/\/[^\s"'<>]+/gi, sanitizeDiagnosticUrl);
334
+ const withoutAssignments = withoutUrls.replace(/(?:^|\s)[^\s=]+=[^\s]*/g, " [REDACTED]");
335
+ const safeTokens = withoutAssignments
336
+ .replaceAll("\r", " ")
337
+ .replaceAll("\n", " ")
338
+ .split(/\s+/)
339
+ .filter(Boolean)
340
+ .map((token) => {
341
+ if (token === "[REDACTED]" || /^[a-z][a-z\d+.-]*:\/\/[^\s]+$/i.test(token)) return token;
342
+ const word = token.replace(/^[^a-z\d]+|[^a-z\d]+$/gi, "");
343
+ return word.length > 0 && word.length <= 32 && SAFE_CAUSE_MESSAGE_WORDS.has(word.toLowerCase())
344
+ ? token
345
+ : "[REDACTED]";
346
+ });
347
+ return safeTokens
348
+ .filter((token, index) => token !== "[REDACTED]" || safeTokens[index - 1] !== token)
349
+ .join(" ")
350
+ .slice(0, 512);
351
+ }
352
+
353
+ function restrictResolverTransport(
354
+ transport: ResolverVendorTransport,
355
+ allowedHosts: readonly string[],
356
+ ): ResolverVendorTransport {
357
+ return {
358
+ async fetch(url, init) {
359
+ // Empty declarations remain deny-by-default, matching the adapter-factory/browser path.
360
+ assertResolverHostAllowed(url, allowedHosts);
361
+ const response = await transport.fetch(url, { ...init, redirect: "manual" });
362
+ const hasLocationHeader = Object.keys(response.headers).some(
363
+ (name) => name.toLowerCase() === "location",
364
+ );
365
+ if (response.status >= 300 && response.status < 400 && hasLocationHeader) {
366
+ throw new ProviderError("Resolver transport refused a redirect response", {
367
+ code: "RESOLVER_HOST_NOT_ALLOWED",
368
+ fix: "Use a non-redirecting http or https URL on a host declared in allowedHosts.",
369
+ });
370
+ }
371
+ return response;
372
+ },
373
+ };
374
+ }
375
+
376
+ function safeCause(error: ResolverVendorUnavailableError): ResolverChainAttempt["cause"] {
377
+ if (error.cause === undefined) return undefined;
378
+ const cause = error.cause;
379
+ const rawName = cause instanceof Error ? cause.name : "Error";
380
+ return {
381
+ name: /^[a-z\d_.:-]{1,64}$/i.test(rawName) ? rawName : "Error",
382
+ message: sanitizeCauseMessage(cause instanceof Error ? cause.message : String(cause)),
383
+ };
384
+ }
385
+
386
+ function safeUpstreamHost(host: string | undefined): string | undefined {
387
+ if (host === undefined) return undefined;
388
+ const trimmed = host.trim();
389
+ if (/^[a-z\d.-]+$/i.test(trimmed)) return trimmed.toLowerCase();
390
+ try {
391
+ return new URL(trimmed).hostname.toLowerCase();
392
+ } catch {
393
+ return undefined;
394
+ }
395
+ }
396
+
397
+ function safePhase(phase: string | undefined): string | undefined {
398
+ return phase !== undefined && /^[a-z\d_.:-]{1,64}$/i.test(phase) ? phase : undefined;
399
+ }
400
+
401
+ function unavailableAttempt(error: ResolverVendorUnavailableError): ResolverChainAttempt {
402
+ const cause = safeCause(error);
403
+ const upstreamHost = safeUpstreamHost(error.upstreamHost);
404
+ const phase = safePhase(error.phase);
405
+ const round =
406
+ Number.isSafeInteger(error.round) && (error.round as number) > 0 ? error.round : undefined;
407
+ return {
408
+ vendor: error.vendor,
409
+ reason: error.reason,
410
+ ...(cause ? { cause } : {}),
411
+ ...(upstreamHost ? { upstreamHost } : {}),
412
+ ...(phase ? { phase } : {}),
413
+ ...(round !== undefined ? { round } : {}),
414
+ };
415
+ }
416
+
417
+ function unavailableSpanAttributes(error: ResolverVendorUnavailableError): Record<string, unknown> {
418
+ const attempt = unavailableAttempt(error);
419
+ return {
420
+ unavailability_reason: error.reason,
421
+ cause_name: attempt.cause?.name,
422
+ cause_message: attempt.cause?.message,
423
+ upstream_host: attempt.upstreamHost,
424
+ transport_phase: attempt.phase,
425
+ transport_round: attempt.round,
426
+ };
427
+ }
428
+
219
429
  function challengeOrigin(challenge: ProviderChallenge): string {
220
430
  return new URL(challenge.pageUrl).origin;
221
431
  }
@@ -299,7 +509,7 @@ function isResolverCacheIndex(value: unknown): value is ResolverCacheIndex {
299
509
 
300
510
  function solutionExpiryMs(solution: ChallengeSolution): number | undefined {
301
511
  if (solution.form !== "cookies") return undefined;
302
- const expires = (solution as ChallengeSolution & { readonly expires?: unknown }).expires;
512
+ const expires = solution.expires;
303
513
  if (typeof expires !== "number" || !Number.isFinite(expires)) return undefined;
304
514
  return expires * 1_000;
305
515
  }
@@ -374,13 +584,14 @@ async function writeResolverCacheIndex(
374
584
  await cache.set(indexKey, { entries: liveEntries } satisfies ResolverCacheIndex, { ttlMs });
375
585
  }
376
586
 
377
- async function cacheBrowserSolution(
587
+ async function cacheResolverSolution(
378
588
  cache: ProviderCache,
379
589
  challenge: ProviderChallenge,
380
590
  solution: ChallengeSolution,
381
591
  identity: ResolverIssuingIdentity,
382
592
  identityScope: string | undefined,
383
593
  ): Promise<void> {
594
+ if (!resolverChallengeIsCacheable(challenge)) return;
384
595
  const expiresAtMs = solutionExpiryMs(solution);
385
596
  const now = Date.now();
386
597
  if (expiresAtMs === undefined) return;
@@ -391,6 +602,13 @@ async function cacheBrowserSolution(
391
602
  identityScope !== undefined && resolverChallengeIsIdentityScoped(challenge)
392
603
  ? resolverIdentityScopeDigest(identityScope)
393
604
  : undefined;
605
+ if (
606
+ !resolverChallengeAllowsDirectCache(challenge) &&
607
+ scopedDigest === undefined &&
608
+ identity.proxyUrl === undefined
609
+ ) {
610
+ return;
611
+ }
394
612
  const issuerDigest = scopedDigest ?? resolverIdentityDigest(identity);
395
613
  await cache.set(
396
614
  resolverSolutionCacheKey(cache, challenge, issuerDigest),
@@ -474,7 +692,12 @@ function createResolverChainClient(options: {
474
692
  readonly cache?: ProviderCache;
475
693
  readonly identity?: ResolverIdentity;
476
694
  readonly identityScope?: string;
695
+ readonly transport?: ResolverVendorTransport;
696
+ readonly createTransport?: ResolverRuntimeOptions["createTransport"];
697
+ readonly clientProfile?: string;
698
+ readonly allowedHosts?: readonly string[];
477
699
  }): ResolverChainClient {
700
+ assertClientProfileTransportContract(options.clientProfile, options.transport);
478
701
  const client: ResolverChainClient = {
479
702
  async solve(
480
703
  challenge: ProviderChallenge,
@@ -492,7 +715,7 @@ function createResolverChainClient(options: {
492
715
  const supportingEntries = options.entries.filter((entry) => entry.supports(challenge.kind));
493
716
  if (supportingEntries.length === 0) throwUnsupportedKind(challenge.kind);
494
717
  signal.throwIfAborted();
495
- if (options.cache && supportingEntries.some((entry) => entry.id === "browser")) {
718
+ if (options.cache && resolverChallengeIsCacheable(challenge)) {
496
719
  const cached = await findCachedSolution(
497
720
  options.cache,
498
721
  challenge,
@@ -506,29 +729,52 @@ function createResolverChainClient(options: {
506
729
  for (const entry of supportingEntries) {
507
730
  const adapter = entry.createAdapter();
508
731
  try {
509
- const solveAttempt = () =>
510
- adapter.solve(challenge, options.identity, signal, traceRecorder);
732
+ const solveAttempt = () => {
733
+ const requiresTransport = adapterRequiresTransport(adapter, challenge.kind);
734
+ const unrestrictedTransport =
735
+ options.transport ??
736
+ (requiresTransport
737
+ ? options.createTransport?.({
738
+ clientProfile: options.clientProfile,
739
+ identityScope: options.identityScope,
740
+ })
741
+ : undefined);
742
+ if (requiresTransport && unrestrictedTransport === undefined) {
743
+ throw new ResolverVendorUnavailableError(adapter.id, "missing_transport");
744
+ }
745
+ const transport = unrestrictedTransport
746
+ ? restrictResolverTransport(unrestrictedTransport, options.allowedHosts ?? [])
747
+ : undefined;
748
+ return adapter.solve(challenge, options.identity, signal, traceRecorder, transport);
749
+ };
511
750
  const solution = traceRecorder
512
751
  ? await traceRecorder.runSpan("resolver.vendor.attempt", solveAttempt, {
513
752
  attributes: {
514
753
  vendor: adapter.id,
515
754
  challenge_kind: challenge.kind,
755
+ client_profile: options.clientProfile,
516
756
  },
517
757
  onError(error) {
518
758
  return error instanceof ResolverVendorUnavailableError
519
- ? { unavailability_reason: error.reason }
759
+ ? unavailableSpanAttributes(error)
520
760
  : undefined;
521
761
  },
522
762
  })
523
763
  : await solveAttempt();
524
- if (options.cache && entry.id === "browser" && solution.form === "cookies") {
525
- const issuingIdentity = adapter.getIssuingIdentity?.(
526
- solution,
527
- options.identity,
528
- challenge,
529
- );
764
+ if (
765
+ options.cache &&
766
+ resolverChallengeIsCacheable(challenge) &&
767
+ solution.form === "cookies" &&
768
+ solutionExpiryMs(solution) !== undefined
769
+ ) {
770
+ const issuingIdentity =
771
+ adapter.getIssuingIdentity?.(solution, options.identity, challenge) ??
772
+ resolverChallengeIssuingIdentity(challenge, {
773
+ ...(options.identity ? { proxyUrl: options.identity.proxyUrl } : {}),
774
+ userAgent: solution.userAgent,
775
+ });
530
776
  if (issuingIdentity) {
531
- await cacheBrowserSolution(
777
+ await cacheResolverSolution(
532
778
  options.cache,
533
779
  challenge,
534
780
  solution,
@@ -541,7 +787,7 @@ function createResolverChainClient(options: {
541
787
  } catch (error) {
542
788
  signal.throwIfAborted();
543
789
  if (!(error instanceof ResolverVendorUnavailableError)) throw error;
544
- attempts.push({ vendor: adapter.id, reason: error.reason });
790
+ attempts.push(unavailableAttempt(error));
545
791
  }
546
792
  }
547
793
 
@@ -558,6 +804,10 @@ export function createResolverClient(options: {
558
804
  readonly unavailableReason?: string;
559
805
  readonly cache?: ProviderCache;
560
806
  readonly identity?: ResolverIdentity;
807
+ readonly transport?: ResolverVendorTransport;
808
+ readonly createTransport?: ResolverRuntimeOptions["createTransport"];
809
+ readonly clientProfile?: string;
810
+ readonly allowedHosts?: readonly string[];
561
811
  }): ResolverChainClient {
562
812
  return createResolverChainClient({
563
813
  kinds: options.kinds,
@@ -569,6 +819,10 @@ export function createResolverClient(options: {
569
819
  unavailableReason: options.unavailableReason,
570
820
  cache: options.cache,
571
821
  identity: options.identity,
822
+ transport: options.transport,
823
+ createTransport: options.createTransport,
824
+ clientProfile: options.clientProfile,
825
+ allowedHosts: options.allowedHosts,
572
826
  });
573
827
  }
574
828
 
@@ -626,14 +880,16 @@ export function bindResolverSignal(
626
880
  return boundResolver;
627
881
  }
628
882
 
629
- export function createResolverClientFromEnv(
883
+ function createResolverClientFromEnvInternal(
630
884
  config: ProviderResolverConfig | undefined,
631
- env: EnvLike = process.env,
632
- options: ResolverRuntimeOptions = {},
885
+ env: EnvLike,
886
+ options: ResolverRuntimeOptions,
887
+ adapterFactories: Partial<Readonly<Record<ProviderResolverVendor, ResolverAdapterFactory>>>,
633
888
  ): ResolverContext {
634
889
  if (!config) {
635
890
  return createUnsupportedResolverClient("Provider does not declare resolver capability");
636
891
  }
892
+ assertClientProfileTransportContract(config.clientProfile, options.transport);
637
893
 
638
894
  if (config.vendors.length === 0) {
639
895
  return createResolverChainClient({
@@ -645,6 +901,7 @@ export function createResolverClientFromEnv(
645
901
 
646
902
  const timeoutValue = readPositiveIntegerEnv(env, APIFUSE__RESOLVER__TIMEOUT_MS);
647
903
  const timeoutMs = timeoutValue === undefined ? DEFAULT_RESOLVER_TIMEOUT_MS : Number(timeoutValue);
904
+ const allowedHosts = [...(options.allowedHosts ?? [])];
648
905
 
649
906
  return createResolverChainClient({
650
907
  kinds: config.kinds,
@@ -658,11 +915,34 @@ export function createResolverClientFromEnv(
658
915
  createAdapter(
659
916
  resolveVendorAvailability(vendor, env),
660
917
  timeoutMs,
661
- options.allowedHosts ?? [],
918
+ allowedHosts,
919
+ adapterFactories,
662
920
  ),
663
921
  };
664
922
  }),
665
923
  cache: options.cache,
666
924
  identityScope: options.identityScope,
925
+ transport: options.transport,
926
+ createTransport: options.createTransport,
927
+ clientProfile: config.clientProfile,
928
+ allowedHosts,
667
929
  });
668
930
  }
931
+
932
+ export function createResolverClientFromEnv(
933
+ config: ProviderResolverConfig | undefined,
934
+ env: EnvLike = process.env,
935
+ options: ResolverRuntimeOptions = {},
936
+ ): ResolverContext {
937
+ return createResolverClientFromEnvInternal(config, env, options, RESOLVER_ADAPTER_REGISTRY);
938
+ }
939
+
940
+ /** Internal test seam; deliberately not re-exported from the package root. */
941
+ export function createResolverClientFromEnvForTests(
942
+ config: ProviderResolverConfig | undefined,
943
+ env: EnvLike,
944
+ options: ResolverRuntimeOptions,
945
+ adapterFactories: Partial<Readonly<Record<ProviderResolverVendor, ResolverAdapterFactory>>>,
946
+ ): ResolverContext {
947
+ return createResolverClientFromEnvInternal(config, env, options, adapterFactories);
948
+ }