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

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 (98) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/bin/apifuse-dev.ts +34 -5
  3. package/bin/apifuse-pack-smoke.ts +1 -1
  4. package/bin/apifuse-pack-types.ts +26 -2
  5. package/bin/apifuse-perf.ts +2 -4
  6. package/bin/apifuse-record.ts +39 -6
  7. package/dist/auth-turn/index.d.ts +1 -1
  8. package/dist/auth-turn/index.js +1 -1
  9. package/dist/auth.d.ts +14 -0
  10. package/dist/auth.js +38 -0
  11. package/dist/ceremonies/index.js +52 -11
  12. package/dist/config/loader.d.ts +3 -1
  13. package/dist/config/loader.js +4 -2
  14. package/dist/index.d.ts +8 -7
  15. package/dist/index.js +5 -7
  16. package/dist/provider.d.ts +2 -1
  17. package/dist/provider.js +1 -1
  18. package/dist/runtime/auth-flow.js +1 -1
  19. package/dist/runtime/browser.js +45 -2
  20. package/dist/runtime/http.d.ts +1 -0
  21. package/dist/runtime/http.js +135 -12
  22. package/dist/runtime/instrumentation.js +1 -1
  23. package/dist/runtime/native-network-errors.d.ts +33 -0
  24. package/dist/runtime/native-network-errors.js +69 -0
  25. package/dist/runtime/native-network.d.ts +2 -33
  26. package/dist/runtime/native-network.js +2 -68
  27. package/dist/runtime/proxy-telemetry.js +3 -0
  28. package/dist/runtime/redis.d.ts +1 -1
  29. package/dist/runtime/redis.js +4 -2
  30. package/dist/runtime/resolver-config.d.ts +6 -0
  31. package/dist/runtime/resolver-config.js +6 -0
  32. package/dist/runtime/resolver-public.d.ts +1 -0
  33. package/dist/runtime/resolver-public.js +1 -0
  34. package/dist/runtime/resolver-shared.d.ts +3 -0
  35. package/dist/runtime/resolver-shared.js +12 -0
  36. package/dist/runtime/resolver-vendors/browser.js +14 -4
  37. package/dist/runtime/resolver-vendors/twocaptcha.d.ts +2 -1
  38. package/dist/runtime/resolver-vendors/twocaptcha.js +157 -53
  39. package/dist/runtime/resolver-vendors/types.d.ts +2 -2
  40. package/dist/runtime/resolver-vendors/types.js +3 -1
  41. package/dist/runtime/resolver.d.ts +15 -10
  42. package/dist/runtime/resolver.js +92 -23
  43. package/dist/runtime/state.js +5 -115
  44. package/dist/runtime/stealth-cookies.d.ts +20 -0
  45. package/dist/runtime/stealth-cookies.js +111 -0
  46. package/dist/runtime/stealth.d.ts +1 -0
  47. package/dist/runtime/stealth.js +8 -131
  48. package/dist/serve.d.ts +1 -1
  49. package/dist/serve.js +1 -1
  50. package/dist/server/index.d.ts +1 -1
  51. package/dist/server/index.js +1 -1
  52. package/dist/server/self-test.d.ts +13 -0
  53. package/dist/server/self-test.js +124 -46
  54. package/dist/server/serve-implementation.d.ts +199 -0
  55. package/dist/server/serve-implementation.js +2072 -0
  56. package/dist/server/serve.d.ts +1 -187
  57. package/dist/server/serve.js +1 -1827
  58. package/dist/stateful/errors.d.ts +5 -0
  59. package/dist/stateful/errors.js +10 -0
  60. package/dist/stateful/stateful-provider-session-routing.d.ts +1 -5
  61. package/dist/stateful/stateful-provider-session-routing.js +2 -10
  62. package/dist/stream.js +7 -1
  63. package/dist/testing/index.d.ts +1 -0
  64. package/dist/testing/index.js +1 -0
  65. package/package.json +27 -2
  66. package/src/auth-turn/index.ts +1 -1
  67. package/src/auth.ts +78 -0
  68. package/src/ceremonies/index.ts +68 -18
  69. package/src/config/loader.ts +8 -2
  70. package/src/index.ts +18 -24
  71. package/src/provider.ts +12 -14
  72. package/src/runtime/auth-flow.ts +1 -1
  73. package/src/runtime/browser.ts +50 -2
  74. package/src/runtime/http.ts +155 -11
  75. package/src/runtime/instrumentation.ts +1 -1
  76. package/src/runtime/native-network-errors.ts +99 -0
  77. package/src/runtime/native-network.ts +16 -97
  78. package/src/runtime/proxy-telemetry.ts +5 -0
  79. package/src/runtime/redis.ts +7 -2
  80. package/src/runtime/resolver-config.ts +6 -0
  81. package/src/runtime/resolver-public.ts +18 -0
  82. package/src/runtime/resolver-shared.ts +17 -0
  83. package/src/runtime/resolver-vendors/browser.ts +14 -4
  84. package/src/runtime/resolver-vendors/twocaptcha.ts +190 -56
  85. package/src/runtime/resolver-vendors/types.ts +8 -2
  86. package/src/runtime/resolver.ts +140 -28
  87. package/src/runtime/state.ts +5 -144
  88. package/src/runtime/stealth-cookies.ts +132 -0
  89. package/src/runtime/stealth.ts +15 -158
  90. package/src/serve.ts +6 -1
  91. package/src/server/index.ts +1 -0
  92. package/src/server/self-test.ts +184 -59
  93. package/src/server/serve-implementation.ts +3042 -0
  94. package/src/server/serve.ts +1 -2661
  95. package/src/stateful/errors.ts +12 -0
  96. package/src/stateful/stateful-provider-session-routing.ts +2 -11
  97. package/src/stream.ts +8 -1
  98. package/src/testing/index.ts +1 -0
@@ -1,8 +1,10 @@
1
1
  import type { ChallengeSolution, ProviderChallenge } from "../../types.js";
2
+ import type { TraceRecorder } from "../trace.js";
2
3
  import { assertResolverHostAllowed } from "./hosts.js";
3
4
  import {
4
5
  type ResolverIdentity,
5
6
  type ResolverVendorAdapter,
7
+ ResolverChallengeVerdictError,
6
8
  ResolverVendorUnavailableError,
7
9
  resolverVendorSupports,
8
10
  } from "./types.js";
@@ -34,6 +36,7 @@ export interface TwoCaptchaResolverVendorAdapter extends ResolverVendorAdapter {
34
36
  challenge: ProviderChallenge,
35
37
  identity: ResolverIdentity | undefined,
36
38
  signal: AbortSignal,
39
+ traceRecorder?: TraceRecorder,
37
40
  ): Promise<Extract<ChallengeSolution, { readonly form: "token" }>>;
38
41
  }
39
42
 
@@ -62,10 +65,53 @@ function abortReason(signal: AbortSignal): unknown {
62
65
  return signal.reason ?? new DOMException("The operation was aborted", "AbortError");
63
66
  }
64
67
 
68
+ function containsSensitiveValue(value: unknown, sensitiveValues: readonly string[]): boolean {
69
+ const secrets = sensitiveValues.filter((secret) => secret.length > 0);
70
+ if (secrets.length === 0) return false;
71
+ const seen = new Set<object>();
72
+
73
+ const inspect = (candidate: unknown): boolean => {
74
+ if (typeof candidate === "string") {
75
+ return secrets.some((secret) => candidate.includes(secret));
76
+ }
77
+ if (
78
+ candidate === null ||
79
+ (typeof candidate !== "object" && typeof candidate !== "function")
80
+ ) {
81
+ return false;
82
+ }
83
+ if (seen.has(candidate)) return false;
84
+ seen.add(candidate);
85
+
86
+ try {
87
+ for (const property of Reflect.ownKeys(candidate)) {
88
+ if (typeof property === "string" && inspect(property)) return true;
89
+ const descriptor = Object.getOwnPropertyDescriptor(candidate, property);
90
+ if (!descriptor) return true;
91
+ if ("value" in descriptor && inspect(descriptor.value)) return true;
92
+ if (descriptor.get !== undefined || descriptor.set !== undefined) return true;
93
+ }
94
+ } catch {
95
+ return true;
96
+ }
97
+ return false;
98
+ };
99
+
100
+ return inspect(value);
101
+ }
102
+
103
+ function safeCauseOptions(
104
+ error: unknown,
105
+ sensitiveValues: readonly string[],
106
+ ): { readonly cause?: unknown } {
107
+ return containsSensitiveValue(error, sensitiveValues) ? {} : { cause: error };
108
+ }
109
+
65
110
  function raceWithAbort<T>(
66
111
  operation: () => Promise<T>,
67
112
  signal: AbortSignal,
68
113
  phase?: TwoCaptchaOperationPhase,
114
+ sensitiveValues: readonly string[] = [],
69
115
  ): Promise<T> {
70
116
  if (signal.aborted) return Promise.reject(abortReason(signal));
71
117
 
@@ -89,7 +135,7 @@ function raceWithAbort<T>(
89
135
  }
90
136
  reject(
91
137
  new ResolverVendorUnavailableError(TWOCAPTCHA_VENDOR_ID, "transport_failure", {
92
- cause: error,
138
+ ...safeCauseOptions(error, sensitiveValues),
93
139
  phase,
94
140
  }),
95
141
  );
@@ -156,10 +202,17 @@ function isAllocationExhausted(payload: JsonRecord): boolean {
156
202
  );
157
203
  }
158
204
 
205
+ function isNegativeVerdict(payload: JsonRecord): boolean {
206
+ return errorText(payload, "errorCode").toLowerCase() === "error_captcha_unsolvable";
207
+ }
208
+
159
209
  function unavailableForPayload(
160
210
  payload: JsonRecord,
161
211
  phase: TwoCaptchaOperationPhase,
162
- ): ResolverVendorUnavailableError {
212
+ ): ResolverVendorUnavailableError | ResolverChallengeVerdictError {
213
+ if (isNegativeVerdict(payload)) {
214
+ return new ResolverChallengeVerdictError(TWOCAPTCHA_VENDOR_ID, "solve_failed");
215
+ }
163
216
  return new ResolverVendorUnavailableError(
164
217
  TWOCAPTCHA_VENDOR_ID,
165
218
  isAllocationExhausted(payload) ? "allocation_exhausted" : "transport_failure",
@@ -173,6 +226,7 @@ async function postJson(
173
226
  body: JsonRecord,
174
227
  signal: AbortSignal,
175
228
  phase: TwoCaptchaOperationPhase,
229
+ sensitiveValues: readonly string[],
176
230
  ): Promise<{ readonly ok: boolean; readonly payload: JsonRecord }> {
177
231
  const response = await raceWithAbort(
178
232
  () =>
@@ -185,6 +239,7 @@ async function postJson(
185
239
  }),
186
240
  signal,
187
241
  phase,
242
+ sensitiveValues,
188
243
  );
189
244
 
190
245
  let responseText: string;
@@ -220,9 +275,12 @@ function taskIdFrom(payload: JsonRecord): string | number | undefined {
220
275
  return typeof taskId === "string" || typeof taskId === "number" ? taskId : undefined;
221
276
  }
222
277
 
223
- function tokenFrom(payload: JsonRecord): string | undefined {
278
+ function tokenFrom(payload: JsonRecord, challenge: ProviderChallenge): string | undefined {
224
279
  const solution = payload.solution;
225
280
  if (!isJsonRecord(solution)) return undefined;
281
+ if (challenge.kind === "aws_waf") {
282
+ return typeof solution.existing_token === "string" ? solution.existing_token : undefined;
283
+ }
226
284
  if (typeof solution.gRecaptchaResponse === "string") return solution.gRecaptchaResponse;
227
285
  return typeof solution.token === "string" ? solution.token : undefined;
228
286
  }
@@ -231,6 +289,28 @@ function endpoint(baseUrl: string, path: string): string {
231
289
  return `${baseUrl.replace(/\/+$/u, "")}/${path}`;
232
290
  }
233
291
 
292
+ function spanErrorAttributes(
293
+ error: unknown,
294
+ phase: TwoCaptchaOperationPhase,
295
+ ): Record<string, unknown> | undefined {
296
+ if (error instanceof ResolverVendorUnavailableError) {
297
+ return {
298
+ unavailability_reason: error.reason,
299
+ transport_phase: error.phase,
300
+ };
301
+ }
302
+ // The solve budget raises TwoCaptchaSolveTimeoutError inside the spanned closure and
303
+ // the outer catch converts it only after the span has been finalized, so the span has
304
+ // to recognize it here or slow solves lose exactly the attribution these spans add.
305
+ if (error instanceof TwoCaptchaSolveTimeoutError) {
306
+ return {
307
+ unavailability_reason: "timeout",
308
+ transport_phase: phase,
309
+ };
310
+ }
311
+ return undefined;
312
+ }
313
+
234
314
  export function createTwoCaptchaResolverVendorAdapter(
235
315
  options: TwoCaptchaResolverVendorOptions,
236
316
  ): TwoCaptchaResolverVendorAdapter {
@@ -248,7 +328,7 @@ export function createTwoCaptchaResolverVendorAdapter(
248
328
  return resolverVendorSupports(TWOCAPTCHA_VENDOR_ID, kind);
249
329
  },
250
330
 
251
- async solve(challenge, identity, callerSignal) {
331
+ async solve(challenge, identity, callerSignal, traceRecorder) {
252
332
  const apiKey = options.apiKey?.trim();
253
333
  if (!apiKey) {
254
334
  throw new ResolverVendorUnavailableError(TWOCAPTCHA_VENDOR_ID, "missing_credentials", {
@@ -258,8 +338,18 @@ export function createTwoCaptchaResolverVendorAdapter(
258
338
  if (!resolverVendorSupports(TWOCAPTCHA_VENDOR_ID, challenge.kind)) {
259
339
  throw new TypeError(`2captcha resolver does not support ${challenge.kind}`);
260
340
  }
261
- if (challenge.kind !== "recaptcha_v2") {
262
- // AWS WAF remains deferred because its challenge variant has no required site key.
341
+ if (challenge.kind !== "recaptcha_v2" && challenge.kind !== "aws_waf") {
342
+ throw new ResolverVendorUnavailableError(TWOCAPTCHA_VENDOR_ID, "not_implemented", {
343
+ phase: "create_task",
344
+ });
345
+ }
346
+ if (
347
+ challenge.kind === "aws_waf" &&
348
+ (!challenge.siteKey?.trim() ||
349
+ !challenge.captchaScript?.trim() ||
350
+ !challenge.context?.trim() ||
351
+ !challenge.iv?.trim())
352
+ ) {
263
353
  throw new ResolverVendorUnavailableError(TWOCAPTCHA_VENDOR_ID, "not_implemented", {
264
354
  phase: "create_task",
265
355
  });
@@ -285,62 +375,101 @@ export function createTwoCaptchaResolverVendorAdapter(
285
375
  let phase: TwoCaptchaOperationPhase = "create_task";
286
376
 
287
377
  try {
288
- const createResult = await postJson(
289
- fetchImpl,
290
- endpoint(baseUrl, "createTask"),
291
- {
292
- clientKey: apiKey,
293
- task: {
294
- type: proxy ? "RecaptchaV2Task" : "RecaptchaV2TaskProxyless",
295
- websiteURL: challenge.pageUrl,
296
- websiteKey: challenge.siteKey,
297
- isInvisible: false,
298
- ...(identity ? { userAgent: identity.userAgent } : {}),
299
- ...(proxy ?? {}),
300
- },
301
- },
302
- solveController.signal,
303
- phase,
304
- );
305
- const taskId = taskIdFrom(createResult.payload);
306
- if (!createResult.ok || createResult.payload.errorId !== 0 || taskId === undefined) {
307
- throw unavailableForPayload(createResult.payload, phase);
308
- }
309
-
310
- phase = "poll_result";
311
- while (true) {
312
- callerSignal.throwIfAborted();
313
- const remainingMs = timeoutMs - (now() - startedAt);
314
- if (remainingMs <= 0) throw new TwoCaptchaSolveTimeoutError();
315
- await delay(Math.min(pollIntervalMs, remainingMs), solveController.signal);
316
- callerSignal.throwIfAborted();
317
- if (now() - startedAt >= timeoutMs) throw new TwoCaptchaSolveTimeoutError();
318
-
319
- const pollResult = await postJson(
378
+ const createTask = async () => {
379
+ const task =
380
+ challenge.kind === "aws_waf"
381
+ ? {
382
+ type: proxy ? "AmazonTask" : "AmazonTaskProxyless",
383
+ websiteURL: challenge.pageUrl,
384
+ websiteKey: challenge.siteKey,
385
+ captchaScript: challenge.captchaScript,
386
+ context: challenge.context,
387
+ iv: challenge.iv,
388
+ ...(identity ? { userAgent: identity.userAgent } : {}),
389
+ ...(proxy ?? {}),
390
+ }
391
+ : {
392
+ type: proxy ? "RecaptchaV2Task" : "RecaptchaV2TaskProxyless",
393
+ websiteURL: challenge.pageUrl,
394
+ websiteKey: challenge.siteKey,
395
+ isInvisible: false,
396
+ ...(identity ? { userAgent: identity.userAgent } : {}),
397
+ ...(proxy ?? {}),
398
+ };
399
+ const createResult = await postJson(
320
400
  fetchImpl,
321
- endpoint(baseUrl, "getTaskResult"),
322
- { clientKey: apiKey, taskId },
401
+ endpoint(baseUrl, "createTask"),
402
+ { clientKey: apiKey, task },
323
403
  solveController.signal,
324
404
  phase,
405
+ [apiKey],
325
406
  );
326
- if (!pollResult.ok || pollResult.payload.errorId !== 0) {
327
- throw unavailableForPayload(pollResult.payload, phase);
328
- }
329
- if (pollResult.payload.status === "processing") continue;
330
- if (pollResult.payload.status !== "ready") {
331
- throw new ResolverVendorUnavailableError(TWOCAPTCHA_VENDOR_ID, "transport_failure", {
332
- phase,
333
- });
407
+ const taskId = taskIdFrom(createResult.payload);
408
+ if (!createResult.ok || createResult.payload.errorId !== 0 || taskId === undefined) {
409
+ throw unavailableForPayload(createResult.payload, phase);
334
410
  }
411
+ return taskId;
412
+ };
413
+ const taskId = traceRecorder
414
+ ? await traceRecorder.runSpan("resolver.vendor.create_task", createTask, {
415
+ attributes: {
416
+ vendor: TWOCAPTCHA_VENDOR_ID,
417
+ challenge_kind: challenge.kind,
418
+ },
419
+ onError: (error) => spanErrorAttributes(error, "create_task"),
420
+ })
421
+ : await createTask();
335
422
 
336
- const token = tokenFrom(pollResult.payload);
337
- if (!token?.trim()) {
338
- throw new ResolverVendorUnavailableError(TWOCAPTCHA_VENDOR_ID, "transport_failure", {
423
+ phase = "poll_result";
424
+ const pollResult = async () => {
425
+ while (true) {
426
+ callerSignal.throwIfAborted();
427
+ const remainingMs = timeoutMs - (now() - startedAt);
428
+ if (remainingMs <= 0) throw new TwoCaptchaSolveTimeoutError();
429
+ await delay(Math.min(pollIntervalMs, remainingMs), solveController.signal);
430
+ callerSignal.throwIfAborted();
431
+ if (now() - startedAt >= timeoutMs) throw new TwoCaptchaSolveTimeoutError();
432
+
433
+ const pollResult = await postJson(
434
+ fetchImpl,
435
+ endpoint(baseUrl, "getTaskResult"),
436
+ { clientKey: apiKey, taskId },
437
+ solveController.signal,
339
438
  phase,
340
- });
439
+ [apiKey],
440
+ );
441
+ if (!pollResult.ok || pollResult.payload.errorId !== 0) {
442
+ throw unavailableForPayload(pollResult.payload, phase);
443
+ }
444
+ if (pollResult.payload.status === "processing") continue;
445
+ if (pollResult.payload.status !== "ready") {
446
+ throw new ResolverVendorUnavailableError(
447
+ TWOCAPTCHA_VENDOR_ID,
448
+ "transport_failure",
449
+ { phase },
450
+ );
451
+ }
452
+
453
+ const token = tokenFrom(pollResult.payload, challenge);
454
+ if (!token?.trim()) {
455
+ throw new ResolverVendorUnavailableError(
456
+ TWOCAPTCHA_VENDOR_ID,
457
+ "transport_failure",
458
+ { phase },
459
+ );
460
+ }
461
+ return { form: "token" as const, token };
341
462
  }
342
- return { form: "token", token };
343
- }
463
+ };
464
+ return traceRecorder
465
+ ? await traceRecorder.runSpan("resolver.vendor.poll_result", pollResult, {
466
+ attributes: {
467
+ vendor: TWOCAPTCHA_VENDOR_ID,
468
+ challenge_kind: challenge.kind,
469
+ },
470
+ onError: (error) => spanErrorAttributes(error, "poll_result"),
471
+ })
472
+ : await pollResult();
344
473
  } catch (error) {
345
474
  if (callerSignal.aborted) throw abortReason(callerSignal);
346
475
  if (
@@ -352,9 +481,14 @@ export function createTwoCaptchaResolverVendorAdapter(
352
481
  phase,
353
482
  });
354
483
  }
355
- if (error instanceof ResolverVendorUnavailableError) throw error;
484
+ if (
485
+ error instanceof ResolverVendorUnavailableError ||
486
+ error instanceof ResolverChallengeVerdictError
487
+ ) {
488
+ throw error;
489
+ }
356
490
  throw new ResolverVendorUnavailableError(TWOCAPTCHA_VENDOR_ID, "transport_failure", {
357
- cause: error,
491
+ ...safeCauseOptions(error, [apiKey]),
358
492
  phase,
359
493
  });
360
494
  } finally {
@@ -112,13 +112,15 @@ export interface ResolverVendorAdapter {
112
112
 
113
113
  export type ResolverVendorUnavailableReason =
114
114
  | "missing_credentials"
115
+ | "missing_proxy_identity"
116
+ | "missing_client_profile"
115
117
  | "missing_transport"
116
118
  | "allocation_exhausted"
117
119
  | "transport_failure"
118
120
  | "timeout"
119
121
  | "not_implemented";
120
122
 
121
- export type ResolverChallengeVerdictReason = "human_puzzle";
123
+ export type ResolverChallengeVerdictReason = "human_puzzle" | "solve_failed";
122
124
 
123
125
  type ResolverErrorOptions = {
124
126
  /** Raw cause; adapters must not place bodies, cookies, headers, credentials, or proxy URLs here. */
@@ -158,7 +160,11 @@ export class ResolverChallengeVerdictError extends Error {
158
160
  readonly reason: ResolverChallengeVerdictReason,
159
161
  options: ResolverErrorOptions = {},
160
162
  ) {
161
- super(`Resolver vendor ${vendor} returned a challenge verdict: ${reason}`);
163
+ super(
164
+ reason === "solve_failed"
165
+ ? `Resolver vendor ${vendor} attempted the challenge but did not solve it`
166
+ : `Resolver vendor ${vendor} returned a challenge verdict: ${reason}`,
167
+ );
162
168
  this.name = "ResolverChallengeVerdictError";
163
169
  if (options.cause !== undefined) {
164
170
  this.cause = options.cause;
@@ -1,11 +1,18 @@
1
1
  import { createHash } from "node:crypto";
2
2
 
3
+ import {
4
+ resolveProxyConfigAsync,
5
+ type ProxyResolutionOptions,
6
+ type ProxyUserAgentSource,
7
+ } from "../config/loader.js";
3
8
  import { ProviderError } from "../errors.js";
9
+ import { getStealthProfile } from "../stealth/profiles.js";
4
10
  import type {
5
11
  ChallengeSolution,
6
12
  ProviderCache,
7
13
  ProviderChallenge,
8
14
  ProviderChallengeKind,
15
+ ProviderProxyMode,
9
16
  ProviderResolverConfig,
10
17
  ProviderResolverVendor,
11
18
  ResolverContext,
@@ -29,14 +36,33 @@ import {
29
36
  type ResolverVendorUnavailableReason,
30
37
  resolverVendorSupports,
31
38
  } from "./resolver-vendors/types.js";
39
+ import {
40
+ createUnsupportedResolverClient,
41
+ RESOLVER_INSTRUMENTATION_METADATA,
42
+ } from "./resolver-shared.js";
43
+ import {
44
+ APIFUSE__CDP_POOL__URL,
45
+ APIFUSE__RESOLVER__2CAPTCHA__API_KEY,
46
+ APIFUSE__RESOLVER__CAPMONSTER__API_KEY,
47
+ APIFUSE__RESOLVER__CAPSOLVER__API_KEY,
48
+ APIFUSE__RESOLVER__TIMEOUT_MS,
49
+ DEFAULT_RESOLVER_TIMEOUT_MS,
50
+ } from "./resolver-config.js";
51
+ import { DEFAULT_PROFILE } from "./stealth.js";
32
52
  import type { TraceRecorder } from "./trace.js";
33
53
 
34
- export const APIFUSE__RESOLVER__2CAPTCHA__API_KEY = "APIFUSE__RESOLVER__2CAPTCHA__API_KEY";
35
- export const APIFUSE__RESOLVER__CAPSOLVER__API_KEY = "APIFUSE__RESOLVER__CAPSOLVER__API_KEY";
36
- export const APIFUSE__RESOLVER__CAPMONSTER__API_KEY = "APIFUSE__RESOLVER__CAPMONSTER__API_KEY";
37
- export const APIFUSE__RESOLVER__TIMEOUT_MS = "APIFUSE__RESOLVER__TIMEOUT_MS";
38
- export const APIFUSE__CDP_POOL__URL = "APIFUSE__CDP_POOL__URL";
39
- export const DEFAULT_RESOLVER_TIMEOUT_MS = 180_000;
54
+ export {
55
+ createUnsupportedResolverClient,
56
+ RESOLVER_INSTRUMENTATION_METADATA,
57
+ } from "./resolver-shared.js";
58
+ export {
59
+ APIFUSE__CDP_POOL__URL,
60
+ APIFUSE__RESOLVER__2CAPTCHA__API_KEY,
61
+ APIFUSE__RESOLVER__CAPMONSTER__API_KEY,
62
+ APIFUSE__RESOLVER__CAPSOLVER__API_KEY,
63
+ APIFUSE__RESOLVER__TIMEOUT_MS,
64
+ DEFAULT_RESOLVER_TIMEOUT_MS,
65
+ } from "./resolver-config.js";
40
66
 
41
67
  type EnvLike = Record<string, string | undefined>;
42
68
 
@@ -75,6 +101,14 @@ type ResolverChainClient = ResolverContext & {
75
101
  export interface ResolverRuntimeOptions {
76
102
  readonly allowedHosts?: readonly string[];
77
103
  readonly cache?: ProviderCache;
104
+ /** Inputs for SDK-owned lazy proxy resolution. The SDK never accepts a caller-built identity. */
105
+ readonly proxyIntent?: {
106
+ readonly mode: ProviderProxyMode;
107
+ readonly upstream: NonNullable<ProxyResolutionOptions["upstream"]>;
108
+ readonly affinityKey?: ProxyResolutionOptions["affinityKey"];
109
+ readonly telemetry?: ProxyResolutionOptions["telemetry"];
110
+ readonly userAgent?: string;
111
+ };
78
112
  /** Server-owned context/proxy scope used only for identity-bound cache entries. */
79
113
  readonly identityScope?: string;
80
114
  /** SDK-owned transport already bound to the resolved proxy lease and client profile. */
@@ -143,10 +177,6 @@ const SAFE_CAUSE_MESSAGE_WORDS: ReadonlySet<string> = new Set([
143
177
  "writing",
144
178
  ]);
145
179
 
146
- export const RESOLVER_INSTRUMENTATION_METADATA = Symbol.for(
147
- "@apifuse/provider-sdk/runtime/resolver-instrumentation-metadata",
148
- );
149
-
150
180
  export type ResolverInstrumentationMetadata = {
151
181
  readonly target: ResolverContext;
152
182
  readonly traceRecorder: TraceRecorder;
@@ -195,6 +225,24 @@ export function swapResolverAdapterFactoryForTests(
195
225
  };
196
226
  }
197
227
 
228
+ let resolveDefaultResolverUserAgent: () => string | undefined = () =>
229
+ getStealthProfile(DEFAULT_PROFILE).userAgent;
230
+
231
+ /** Internal test seam; deliberately not re-exported from the package root. */
232
+ export function swapResolverDefaultUserAgentForTests(
233
+ resolver: (() => string | undefined) | undefined,
234
+ ): () => void {
235
+ const original = resolveDefaultResolverUserAgent;
236
+ resolveDefaultResolverUserAgent =
237
+ resolver ?? (() => getStealthProfile(DEFAULT_PROFILE).userAgent);
238
+ let restored = false;
239
+ return () => {
240
+ if (restored) return;
241
+ restored = true;
242
+ resolveDefaultResolverUserAgent = original;
243
+ };
244
+ }
245
+
198
246
  // This is the sole allowlist for declared vendors whose registry entry may be absent.
199
247
  // Remove a vendor here when its adapter is registered.
200
248
  const KNOWN_UNIMPLEMENTED_RESOLVER_VENDORS: ReadonlySet<ProviderResolverVendor> = new Set([
@@ -323,6 +371,7 @@ function adapterRequiresTransport(
323
371
  function sanitizeDiagnosticUrl(rawUrl: string): string {
324
372
  try {
325
373
  const parsed = new URL(rawUrl);
374
+ if (parsed.username || parsed.password) return "[REDACTED_PROXY_URL]";
326
375
  return `${parsed.protocol}//${parsed.host}`;
327
376
  } catch {
328
377
  return "[REDACTED_URL]";
@@ -338,9 +387,16 @@ function sanitizeCauseMessage(message: string): string {
338
387
  .split(/\s+/)
339
388
  .filter(Boolean)
340
389
  .map((token) => {
341
- if (token === "[REDACTED]" || /^[a-z][a-z\d+.-]*:\/\/[^\s]+$/i.test(token)) return token;
390
+ if (
391
+ token === "[REDACTED]" ||
392
+ token === "[REDACTED_PROXY_URL]" ||
393
+ /^[a-z][a-z\d+.-]*:\/\/[^\s]+$/i.test(token)
394
+ )
395
+ return token;
342
396
  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())
397
+ return word.length > 0 &&
398
+ word.length <= 32 &&
399
+ SAFE_CAUSE_MESSAGE_WORDS.has(word.toLowerCase())
344
400
  ? token
345
401
  : "[REDACTED]";
346
402
  });
@@ -685,12 +741,58 @@ export async function invalidateResolverSolution(
685
741
  });
686
742
  }
687
743
 
744
+ async function resolveResolverIdentity(
745
+ proxyIntent: NonNullable<ResolverRuntimeOptions["proxyIntent"]>,
746
+ ): Promise<{
747
+ readonly identity?: ResolverIdentity;
748
+ readonly unavailableReason?: ResolverVendorUnavailableReason;
749
+ readonly userAgentSource?: ProxyUserAgentSource;
750
+ }> {
751
+ const userAgentSource: ProxyUserAgentSource = proxyIntent.userAgent ? "declared" : "defaulted";
752
+ let proxyUrl: string | undefined;
753
+ try {
754
+ const resolved = await resolveProxyConfigAsync({
755
+ upstream: proxyIntent.upstream,
756
+ affinityKey: proxyIntent.affinityKey,
757
+ telemetry: proxyIntent.telemetry
758
+ ? {
759
+ ...proxyIntent.telemetry,
760
+ recordProxyResolution(event) {
761
+ proxyIntent.telemetry?.recordProxyResolution({
762
+ ...event,
763
+ userAgentSource,
764
+ });
765
+ },
766
+ }
767
+ : undefined,
768
+ });
769
+ proxyUrl = resolved.url;
770
+ if (!proxyUrl) return { unavailableReason: "missing_proxy_identity", userAgentSource };
771
+ } catch {
772
+ // Lease failures contain infrastructure detail that must not cross the resolver
773
+ // boundary. A required policy is classified by the existing fail-closed guard.
774
+ return { unavailableReason: "missing_proxy_identity", userAgentSource };
775
+ }
776
+
777
+ try {
778
+ const userAgent = proxyIntent.userAgent || resolveDefaultResolverUserAgent();
779
+ if (!userAgent) return { unavailableReason: "missing_client_profile", userAgentSource };
780
+ return {
781
+ identity: { proxyUrl, userAgent },
782
+ userAgentSource,
783
+ };
784
+ } catch {
785
+ return { unavailableReason: "missing_client_profile", userAgentSource };
786
+ }
787
+ }
788
+
688
789
  function createResolverChainClient(options: {
689
790
  readonly kinds: readonly ProviderChallengeKind[];
690
791
  readonly entries: readonly ResolverChainEntry[];
691
792
  readonly unavailableReason?: string;
692
793
  readonly cache?: ProviderCache;
693
794
  readonly identity?: ResolverIdentity;
795
+ readonly proxyIntent?: ResolverRuntimeOptions["proxyIntent"];
694
796
  readonly identityScope?: string;
695
797
  readonly transport?: ResolverVendorTransport;
696
798
  readonly createTransport?: ResolverRuntimeOptions["createTransport"];
@@ -715,16 +817,33 @@ function createResolverChainClient(options: {
715
817
  const supportingEntries = options.entries.filter((entry) => entry.supports(challenge.kind));
716
818
  if (supportingEntries.length === 0) throwUnsupportedKind(challenge.kind);
717
819
  signal.throwIfAborted();
820
+ const identityResolution = options.proxyIntent
821
+ ? await resolveResolverIdentity(options.proxyIntent)
822
+ : { identity: options.identity };
823
+ const identity = identityResolution.identity;
824
+ signal.throwIfAborted();
825
+ // Resolve a required proxy lease before consulting the cache. Solutions minted
826
+ // under a previous release are shared and long-lived, but a portable cached token
827
+ // must not bypass the upstream admission policy when no lease can be resolved.
828
+ const requiredProxyIdentityMissing =
829
+ options.proxyIntent?.mode === "required" && identity === undefined;
830
+ if (requiredProxyIdentityMissing) {
831
+ throwExhausted(
832
+ supportingEntries.map((entry) => ({
833
+ vendor: entry.id,
834
+ reason: identityResolution.unavailableReason ?? "missing_proxy_identity",
835
+ })),
836
+ );
837
+ }
718
838
  if (options.cache && resolverChallengeIsCacheable(challenge)) {
719
839
  const cached = await findCachedSolution(
720
840
  options.cache,
721
841
  challenge,
722
- options.identity,
842
+ identity,
723
843
  options.identityScope,
724
844
  );
725
845
  if (cached) return cached;
726
846
  }
727
-
728
847
  const attempts: ResolverChainAttempt[] = [];
729
848
  for (const entry of supportingEntries) {
730
849
  const adapter = entry.createAdapter();
@@ -745,7 +864,7 @@ function createResolverChainClient(options: {
745
864
  const transport = unrestrictedTransport
746
865
  ? restrictResolverTransport(unrestrictedTransport, options.allowedHosts ?? [])
747
866
  : undefined;
748
- return adapter.solve(challenge, options.identity, signal, traceRecorder, transport);
867
+ return adapter.solve(challenge, identity, signal, traceRecorder, transport);
749
868
  };
750
869
  const solution = traceRecorder
751
870
  ? await traceRecorder.runSpan("resolver.vendor.attempt", solveAttempt, {
@@ -753,6 +872,7 @@ function createResolverChainClient(options: {
753
872
  vendor: adapter.id,
754
873
  challenge_kind: challenge.kind,
755
874
  client_profile: options.clientProfile,
875
+ resolver_identity_source: identityResolution.userAgentSource,
756
876
  },
757
877
  onError(error) {
758
878
  return error instanceof ResolverVendorUnavailableError
@@ -768,9 +888,9 @@ function createResolverChainClient(options: {
768
888
  solutionExpiryMs(solution) !== undefined
769
889
  ) {
770
890
  const issuingIdentity =
771
- adapter.getIssuingIdentity?.(solution, options.identity, challenge) ??
891
+ adapter.getIssuingIdentity?.(solution, identity, challenge) ??
772
892
  resolverChallengeIssuingIdentity(challenge, {
773
- ...(options.identity ? { proxyUrl: options.identity.proxyUrl } : {}),
893
+ ...(identity ? { proxyUrl: identity.proxyUrl } : {}),
774
894
  userAgent: solution.userAgent,
775
895
  });
776
896
  if (issuingIdentity) {
@@ -804,6 +924,7 @@ export function createResolverClient(options: {
804
924
  readonly unavailableReason?: string;
805
925
  readonly cache?: ProviderCache;
806
926
  readonly identity?: ResolverIdentity;
927
+ readonly proxyIntent?: ResolverRuntimeOptions["proxyIntent"];
807
928
  readonly transport?: ResolverVendorTransport;
808
929
  readonly createTransport?: ResolverRuntimeOptions["createTransport"];
809
930
  readonly clientProfile?: string;
@@ -819,6 +940,7 @@ export function createResolverClient(options: {
819
940
  unavailableReason: options.unavailableReason,
820
941
  cache: options.cache,
821
942
  identity: options.identity,
943
+ proxyIntent: options.proxyIntent,
822
944
  transport: options.transport,
823
945
  createTransport: options.createTransport,
824
946
  clientProfile: options.clientProfile,
@@ -853,17 +975,6 @@ function resolveVendorAvailability(
853
975
  : { vendor, available: false, reason: "missing_credentials" };
854
976
  }
855
977
 
856
- export function createUnsupportedResolverClient(reason?: string): ResolverContext {
857
- return {
858
- async solve() {
859
- throw new ProviderError(reason ?? "Resolver runtime is not configured", {
860
- code: "RESOLVER_UNAVAILABLE",
861
- fix: "Declare resolver on the provider definition and configure vendor credentials.",
862
- });
863
- },
864
- };
865
- }
866
-
867
978
  export function bindResolverSignal(
868
979
  resolver: ResolverContext,
869
980
  defaultSignal: AbortSignal | undefined,
@@ -921,6 +1032,7 @@ function createResolverClientFromEnvInternal(
921
1032
  };
922
1033
  }),
923
1034
  cache: options.cache,
1035
+ proxyIntent: options.proxyIntent,
924
1036
  identityScope: options.identityScope,
925
1037
  transport: options.transport,
926
1038
  createTransport: options.createTransport,