@apifuse/provider-sdk 2.2.0-beta.68 → 2.2.0-beta.69

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 (52) hide show
  1. package/AUTHORING.md +6 -1
  2. package/CHANGELOG.md +4 -0
  3. package/README.md +10 -3
  4. package/dist/fixture-sanitization.js +159 -15
  5. package/dist/lint.js +56 -9
  6. package/dist/runtime/diagnostic-redactor.d.ts +6 -1
  7. package/dist/runtime/diagnostic-redactor.js +16 -14
  8. package/dist/runtime/http.js +15 -102
  9. package/dist/runtime/instrumentation.js +4 -1
  10. package/dist/runtime/redirect-hop-headers.d.ts +19 -0
  11. package/dist/runtime/redirect-hop-headers.js +58 -0
  12. package/dist/runtime/redirects.d.ts +33 -1
  13. package/dist/runtime/redirects.js +113 -0
  14. package/dist/runtime/resolver-span-attributes.d.ts +34 -0
  15. package/dist/runtime/resolver-span-attributes.js +93 -0
  16. package/dist/runtime/resolver-usage.d.ts +8 -0
  17. package/dist/runtime/resolver-usage.js +5 -11
  18. package/dist/runtime/resolver-vendors/browser.js +5 -4
  19. package/dist/runtime/resolver-vendors/capsolver.js +11 -10
  20. package/dist/runtime/resolver-vendors/twocaptcha.js +9 -8
  21. package/dist/runtime/resolver.js +7 -6
  22. package/dist/runtime/stealth-owned-headers.d.ts +21 -8
  23. package/dist/runtime/stealth-owned-headers.js +28 -8
  24. package/dist/runtime/stealth-telemetry.d.ts +1 -1
  25. package/dist/runtime/stealth.js +158 -46
  26. package/dist/runtime/trace.js +2 -2
  27. package/dist/server/serve-implementation.js +2 -1
  28. package/dist/testing/run.js +14 -7
  29. package/dist/trace-sanitization.js +1 -1
  30. package/dist/types.d.ts +30 -6
  31. package/package.json +2 -2
  32. package/src/fixture-sanitization.ts +175 -17
  33. package/src/lint.ts +54 -9
  34. package/src/runtime/diagnostic-redactor.ts +19 -13
  35. package/src/runtime/http.ts +22 -111
  36. package/src/runtime/instrumentation.ts +4 -1
  37. package/src/runtime/redirect-hop-headers.ts +71 -0
  38. package/src/runtime/redirects.ts +150 -1
  39. package/src/runtime/resolver-span-attributes.ts +139 -0
  40. package/src/runtime/resolver-usage.ts +8 -5
  41. package/src/runtime/resolver-vendors/browser.ts +5 -4
  42. package/src/runtime/resolver-vendors/capsolver.ts +11 -10
  43. package/src/runtime/resolver-vendors/twocaptcha.ts +9 -8
  44. package/src/runtime/resolver.ts +7 -6
  45. package/src/runtime/stealth-owned-headers.ts +34 -8
  46. package/src/runtime/stealth-telemetry.ts +6 -1
  47. package/src/runtime/stealth.ts +180 -58
  48. package/src/runtime/trace.ts +6 -2
  49. package/src/server/serve-implementation.ts +2 -1
  50. package/src/testing/run.ts +15 -2
  51. package/src/trace-sanitization.ts +1 -1
  52. package/src/types.ts +30 -6
@@ -0,0 +1,58 @@
1
+ import { getPublicSuffix } from "tough-cookie";
2
+ /**
3
+ * Request-body header names a redirect drops when it changes the method
4
+ * (Fetch "request-body-header name").
5
+ */
6
+ const REDIRECT_BODY_HEADERS = new Set([
7
+ "content-encoding",
8
+ "content-language",
9
+ "content-location",
10
+ "content-type",
11
+ ]);
12
+ /**
13
+ * Credentials a redirect never carries to another origin. Fetch removes
14
+ * `Authorization` on a cross-origin redirect; `Proxy-Authorization` is a
15
+ * credential of the same kind.
16
+ */
17
+ const CROSS_ORIGIN_CREDENTIAL_HEADERS = new Set([
18
+ "authorization",
19
+ "proxy-authorization",
20
+ ]);
21
+ /**
22
+ * Schemeful site (HTML "same site"): scheme plus registrable domain from the
23
+ * public suffix list, or the bare host when there is none (an IP address or
24
+ * a single-label host).
25
+ */
26
+ function schemefulSite(url) {
27
+ const host = url.hostname;
28
+ const registrable = getPublicSuffix(host, { allowSpecialUseDomain: true, ignoreError: true });
29
+ return `${url.protocol}//${registrable ?? host}`;
30
+ }
31
+ /**
32
+ * The caller headers the next redirect hop carries, derived from the headers
33
+ * the previous hop sent. Both stealth redirect walkers compute every hop's
34
+ * headers here, so the rules hold on every path:
35
+ *
36
+ * - a method change drops the request-body headers;
37
+ * - a cross-origin hop drops `Authorization` and `Proxy-Authorization`;
38
+ * - a cross-site hop drops an explicit `Cookie`, after which the session
39
+ * cookie jar supplies cookies by its own domain rules, as a browser does.
40
+ *
41
+ * A header dropped on one hop stays dropped for the rest of the chain.
42
+ */
43
+ export function redirectHopHeaders(hop, headers) {
44
+ const from = new URL(hop.from);
45
+ const to = new URL(hop.to);
46
+ const crossOrigin = from.origin !== to.origin;
47
+ const crossSite = schemefulSite(from) !== schemefulSite(to);
48
+ return Object.fromEntries(Object.entries(headers).filter(([name]) => {
49
+ const lower = name.toLowerCase();
50
+ if (hop.methodChanged && REDIRECT_BODY_HEADERS.has(lower))
51
+ return false;
52
+ if (crossOrigin && CROSS_ORIGIN_CREDENTIAL_HEADERS.has(lower))
53
+ return false;
54
+ if (crossSite && lower === "cookie")
55
+ return false;
56
+ return true;
57
+ }));
58
+ }
@@ -1,4 +1,5 @@
1
- import type { HttpRedirectFailureReason } from "../types.js";
1
+ import { TransportError } from "../errors.js";
2
+ import type { HttpRedirectFailureReason, HttpRedirectPolicy } from "../types.js";
2
3
  export type RedirectHopDecision<TMethod extends string> = {
3
4
  kind: "follow";
4
5
  nextMethod: TMethod | "GET";
@@ -27,3 +28,34 @@ export declare function evaluateRedirectHop<TMethod extends string>(input: {
27
28
  maxHops: number;
28
29
  visitedRequests: ReadonlySet<string>;
29
30
  }): RedirectHopDecision<TMethod>;
31
+ export declare const MALFORMED_REDIRECT_TARGET = "[malformed redirect target]";
32
+ /** The transport a redirectPolicy belongs to: its name in diagnostics and its own hop limit. */
33
+ export interface RedirectPolicyTransport {
34
+ readonly label: string;
35
+ readonly maxHops: number;
36
+ }
37
+ export declare function invalidRedirectPolicy(transport: RedirectPolicyTransport, message: string, cause?: Error): TransportError;
38
+ /** Snapshot untrusted caller input synchronously, before proxy resolution or fetch. */
39
+ export declare function normalizeRedirectPolicy(value: unknown, transport: RedirectPolicyTransport): HttpRedirectPolicy | undefined;
40
+ export declare function redirectDiagnosticTarget(value: string): string;
41
+ /**
42
+ * One hop of a same-origin `redirectPolicy` walk, shared by ctx.http and
43
+ * ctx.stealth so both follow and refuse identically: returns the next request
44
+ * inside the initial origin, or throws the `HttpRedirectError` naming why the
45
+ * walk stopped (cross-origin target, maxHops, loop, missing or malformed
46
+ * Location) before any request leaves for that target.
47
+ */
48
+ export declare function nextSameOriginRedirectHop<TMethod extends string>(input: {
49
+ policy: HttpRedirectPolicy;
50
+ initialOrigin: string;
51
+ currentUrl: string;
52
+ responseUrl: string;
53
+ status: number;
54
+ method: TMethod;
55
+ location: string | undefined;
56
+ followedHops: number;
57
+ visitedRequests: ReadonlySet<string>;
58
+ }): {
59
+ nextMethod: TMethod | "GET";
60
+ nextUrl: string;
61
+ };
@@ -1,3 +1,4 @@
1
+ import { HttpRedirectError, TransportError } from "../errors.js";
1
2
  const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
2
3
  export function isRedirectStatus(status) {
3
4
  return REDIRECT_STATUSES.has(status);
@@ -34,3 +35,115 @@ export function evaluateRedirectHop(input) {
34
35
  }
35
36
  return { kind: "follow", nextMethod, nextUrl: input.nextUrl };
36
37
  }
38
+ const REDIRECT_POLICY_FIELDS = new Set(["mode", "maxHops"]);
39
+ export const MALFORMED_REDIRECT_TARGET = "[malformed redirect target]";
40
+ export function invalidRedirectPolicy(transport, message, cause) {
41
+ return new TransportError(`Invalid ${transport.label} redirectPolicy: ${message}`, {
42
+ code: "http_redirect_policy_invalid",
43
+ ...(cause ? { cause } : {}),
44
+ });
45
+ }
46
+ /** Snapshot untrusted caller input synchronously, before proxy resolution or fetch. */
47
+ export function normalizeRedirectPolicy(value, transport) {
48
+ if (value === undefined)
49
+ return undefined;
50
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
51
+ throw invalidRedirectPolicy(transport, "expected an object");
52
+ }
53
+ try {
54
+ const keys = Reflect.ownKeys(value);
55
+ for (const key of keys) {
56
+ if (typeof key !== "string" || !REDIRECT_POLICY_FIELDS.has(key)) {
57
+ throw invalidRedirectPolicy(transport, `unknown field ${String(key)}`);
58
+ }
59
+ }
60
+ for (const field of REDIRECT_POLICY_FIELDS) {
61
+ const descriptor = Object.getOwnPropertyDescriptor(value, field);
62
+ if (!descriptor || !("value" in descriptor)) {
63
+ throw invalidRedirectPolicy(transport, `${field} must be an own data property`);
64
+ }
65
+ }
66
+ const record = value;
67
+ if (record.mode !== "same-origin") {
68
+ throw invalidRedirectPolicy(transport, 'mode must be "same-origin"');
69
+ }
70
+ if (typeof record.maxHops !== "number" ||
71
+ !Number.isInteger(record.maxHops) ||
72
+ record.maxHops < 0 ||
73
+ record.maxHops > transport.maxHops) {
74
+ throw invalidRedirectPolicy(transport, `maxHops must be an integer from 0 to ${transport.maxHops}`);
75
+ }
76
+ return { mode: "same-origin", maxHops: record.maxHops };
77
+ }
78
+ catch (error) {
79
+ if (error instanceof TransportError)
80
+ throw error;
81
+ throw invalidRedirectPolicy(transport, "could not be inspected safely", error instanceof Error ? error : undefined);
82
+ }
83
+ }
84
+ export function redirectDiagnosticTarget(value) {
85
+ try {
86
+ const parsed = new URL(value);
87
+ // Origin omits URL userinfo. Keeping only origin + path makes diagnostics
88
+ // useful while structurally excluding every query value and fragment,
89
+ // including attacker-chosen keys the provider did not declare sensitive.
90
+ const redactedQuery = parsed.search ? "?[REDACTED]" : "";
91
+ return parsed.origin === "null"
92
+ ? `${parsed.protocol}<opaque-target>`
93
+ : `${parsed.origin}${parsed.pathname}${redactedQuery}`;
94
+ }
95
+ catch {
96
+ return MALFORMED_REDIRECT_TARGET;
97
+ }
98
+ }
99
+ /**
100
+ * One hop of a same-origin `redirectPolicy` walk, shared by ctx.http and
101
+ * ctx.stealth so both follow and refuse identically: returns the next request
102
+ * inside the initial origin, or throws the `HttpRedirectError` naming why the
103
+ * walk stopped (cross-origin target, maxHops, loop, missing or malformed
104
+ * Location) before any request leaves for that target.
105
+ */
106
+ export function nextSameOriginRedirectHop(input) {
107
+ let nextUrl;
108
+ try {
109
+ nextUrl = resolveRedirectUrl(input.location || undefined, input.responseUrl);
110
+ }
111
+ catch {
112
+ const target = MALFORMED_REDIRECT_TARGET;
113
+ throw new HttpRedirectError(`Redirect response has malformed Location target ${target}`, {
114
+ reason: "missing_location",
115
+ target,
116
+ status: input.status,
117
+ });
118
+ }
119
+ const decision = evaluateRedirectHop({
120
+ status: input.status,
121
+ method: input.method,
122
+ nextUrl,
123
+ shouldStop: nextUrl ? new URL(nextUrl).origin !== input.initialOrigin : false,
124
+ redirectCount: input.followedHops + 1,
125
+ maxHops: input.policy.maxHops,
126
+ visitedRequests: input.visitedRequests,
127
+ });
128
+ if (decision.kind === "follow") {
129
+ return { nextMethod: decision.nextMethod, nextUrl: decision.nextUrl };
130
+ }
131
+ const target = decision.nextUrl ? redirectDiagnosticTarget(decision.nextUrl) : undefined;
132
+ const message = (() => {
133
+ switch (decision.reason) {
134
+ case "stopped":
135
+ return `Redirect policy refused cross-origin target ${target}`;
136
+ case "max_hops":
137
+ return `Redirect policy reached maxHops before target ${target}`;
138
+ case "loop":
139
+ return `Redirect loop refused target ${target}`;
140
+ case "missing_location":
141
+ return `Redirect response from ${redirectDiagnosticTarget(input.currentUrl)} is missing Location`;
142
+ }
143
+ })();
144
+ throw new HttpRedirectError(message, {
145
+ reason: decision.reason,
146
+ ...(target ? { target } : {}),
147
+ status: input.status,
148
+ });
149
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Closed-enum span attributes emitted by the resolver, by attribute key. A value listed here is an
3
+ * SDK-declared literal, so fail-closed diagnostic redaction keeps it the way it keeps a typed
4
+ * number; any other value under the same key is still free text. Registered credentials are
5
+ * matched either way.
6
+ */
7
+ export declare const RESOLVER_CLOSED_ENUM_SPAN_ATTRIBUTES: {
8
+ readonly vendor: readonly ["browser", "capsolver", "capmonster", "2captcha", "hypersolutions", "custom"];
9
+ readonly challenge_kind: readonly ["turnstile", "recaptcha_v2", "recaptcha_v3", "hcaptcha", "cloudflare_interstitial", "aws_waf", "akamai_sec_cpt", "akamai_sensor", "akamai_sbsd"];
10
+ readonly endpoint: readonly ["capsolver:create_task", "twocaptcha:create_task", "hyper:ip", "hyper:sbsd_create"];
11
+ readonly billing: readonly ["metered", "unconfirmed"];
12
+ readonly outcome: readonly ["success", "vendor_error", "timeout", "abandoned", "cache_disabled", "entry_deleted", "index_entry_deleted", "not_cookie_solution", "solution_not_cached"];
13
+ readonly unavailability_reason: readonly ["missing_credentials", "missing_proxy_identity", "missing_client_profile", "missing_challenge_input", "missing_transport", "allocation_exhausted", "transport_failure", "timeout", "not_implemented"];
14
+ readonly verdict_reason: readonly ["human_puzzle", "solve_failed"];
15
+ readonly transport_phase: readonly ["create_task", "poll_result", "cleanup", "measure_ip", "fetch_script", "generate_payload", "post_payload"];
16
+ readonly resolver_identity_source: readonly ["declared", "defaulted"];
17
+ readonly operation: readonly ["client.close", "context.close"];
18
+ };
19
+ type ResolverClosedEnumSpanAttributes = typeof RESOLVER_CLOSED_ENUM_SPAN_ATTRIBUTES;
20
+ type Present<T> = Exclude<T, undefined>;
21
+ /** Properties typed as a finite union of string literals, never plain `string`. */
22
+ type FiniteStringKeys<T> = {
23
+ [K in keyof T]-?: [Present<T[K]>] extends [never] ? never : [Present<T[K]>] extends [string] ? string extends Present<T[K]> ? never : K : never;
24
+ }[keyof T];
25
+ type DeclaredClosedEnumAttributes<T> = {
26
+ [K in keyof T]: K extends FiniteStringKeys<T> ? K extends keyof ResolverClosedEnumSpanAttributes ? [Present<T[K]>] extends [ResolverClosedEnumSpanAttributes[K][number]] ? T[K] : never : never : T[K];
27
+ };
28
+ /**
29
+ * Resolver span attributes. Every property typed as a finite string union must be declared in
30
+ * `RESOLVER_CLOSED_ENUM_SPAN_ATTRIBUTES` with all of its members, so a new closed-enum attribute
31
+ * cannot silently become free text that fail-closed redaction suppresses.
32
+ */
33
+ export declare function resolverSpanAttributes<const T extends Record<string, unknown>>(attributes: T & DeclaredClosedEnumAttributes<T>): T;
34
+ export {};
@@ -0,0 +1,93 @@
1
+ // Type-only imports: the diagnostic redactor reads this table, so it must not pull the resolver
2
+ // runtime (or anything that imports the redactor) into module evaluation.
3
+ /** Every member of a closed string union, as runtime values; a missing or extra member fails tsc. */
4
+ function closedEnumValues() {
5
+ return (values, ..._missing) => values;
6
+ }
7
+ const RESOLVER_PAID_USAGE_OUTCOMES = closedEnumValues()([
8
+ "success",
9
+ "vendor_error",
10
+ "timeout",
11
+ "abandoned",
12
+ ]);
13
+ // `resolver.cache.invalidate` outcomes. The union stays private to resolver.ts; its producer's
14
+ // `resolverSpanAttributes` call fails to compile if it can emit an outcome missing here.
15
+ const RESOLVER_CACHE_INVALIDATION_OUTCOMES = [
16
+ "cache_disabled",
17
+ "entry_deleted",
18
+ "index_entry_deleted",
19
+ "not_cookie_solution",
20
+ "solution_not_cached",
21
+ ];
22
+ /**
23
+ * Closed-enum span attributes emitted by the resolver, by attribute key. A value listed here is an
24
+ * SDK-declared literal, so fail-closed diagnostic redaction keeps it the way it keeps a typed
25
+ * number; any other value under the same key is still free text. Registered credentials are
26
+ * matched either way.
27
+ */
28
+ export const RESOLVER_CLOSED_ENUM_SPAN_ATTRIBUTES = {
29
+ vendor: closedEnumValues()([
30
+ "browser",
31
+ "capsolver",
32
+ "capmonster",
33
+ "2captcha",
34
+ "hypersolutions",
35
+ "custom",
36
+ ]),
37
+ challenge_kind: closedEnumValues()([
38
+ "turnstile",
39
+ "recaptcha_v2",
40
+ "recaptcha_v3",
41
+ "hcaptcha",
42
+ "cloudflare_interstitial",
43
+ "aws_waf",
44
+ "akamai_sec_cpt",
45
+ "akamai_sensor",
46
+ "akamai_sbsd",
47
+ ]),
48
+ endpoint: closedEnumValues()([
49
+ "capsolver:create_task",
50
+ "twocaptcha:create_task",
51
+ "hyper:ip",
52
+ "hyper:sbsd_create",
53
+ ]),
54
+ billing: closedEnumValues()(["metered", "unconfirmed"]),
55
+ outcome: [...RESOLVER_PAID_USAGE_OUTCOMES, ...RESOLVER_CACHE_INVALIDATION_OUTCOMES],
56
+ unavailability_reason: closedEnumValues()([
57
+ "missing_credentials",
58
+ "missing_proxy_identity",
59
+ "missing_client_profile",
60
+ "missing_challenge_input",
61
+ "missing_transport",
62
+ "allocation_exhausted",
63
+ "transport_failure",
64
+ "timeout",
65
+ "not_implemented",
66
+ ]),
67
+ verdict_reason: closedEnumValues()([
68
+ "human_puzzle",
69
+ "solve_failed",
70
+ ]),
71
+ transport_phase: closedEnumValues()([
72
+ "create_task",
73
+ "poll_result",
74
+ "cleanup",
75
+ "measure_ip",
76
+ "fetch_script",
77
+ "generate_payload",
78
+ "post_payload",
79
+ ]),
80
+ resolver_identity_source: closedEnumValues()(["declared", "defaulted"]),
81
+ operation: closedEnumValues()([
82
+ "client.close",
83
+ "context.close",
84
+ ]),
85
+ };
86
+ /**
87
+ * Resolver span attributes. Every property typed as a finite string union must be declared in
88
+ * `RESOLVER_CLOSED_ENUM_SPAN_ATTRIBUTES` with all of its members, so a new closed-enum attribute
89
+ * cannot silently become free text that fail-closed redaction suppresses.
90
+ */
91
+ export function resolverSpanAttributes(attributes) {
92
+ return attributes;
93
+ }
@@ -4,6 +4,14 @@ import type { TraceRecorder } from "./trace.js";
4
4
  export declare const RESOLVER_PAID_USAGE_SPAN = "resolver.usage";
5
5
  export type ResolverPaidUsageOutcome = "success" | "vendor_error" | "timeout" | "abandoned";
6
6
  export type ResolverPaidUsageEndpoint = "capsolver:create_task" | "twocaptcha:create_task" | "hyper:ip" | "hyper:sbsd_create";
7
+ /**
8
+ * How one call to the endpoint is counted. `metered` endpoints are the vendors' task or
9
+ * payload creation calls: the SDK counts one unit per call (the vendor ledger stays
10
+ * authoritative for refunds of failed tasks). Hyper's `/ip` reflector is authenticated
11
+ * but has no published price, so it is recorded with 0 units and `billing: "unconfirmed"`
12
+ * rather than invented as a paid unit; the span still proves the call was made.
13
+ */
14
+ export type ResolverPaidUsageBilling = "metered" | "unconfirmed";
7
15
  /**
8
16
  * Records one `resolver.usage` span around a paid vendor call. Callers validate credentials
9
17
  * and transport first, so a span means a potentially billable request was attempted (a
@@ -1,11 +1,5 @@
1
+ import { resolverSpanAttributes } from "./resolver-span-attributes.js";
1
2
  export const RESOLVER_PAID_USAGE_SPAN = "resolver.usage";
2
- /**
3
- * How one call to the endpoint is counted. `metered` endpoints are the vendors' task or
4
- * payload creation calls: the SDK counts one unit per call (the vendor ledger stays
5
- * authoritative for refunds of failed tasks). Hyper's `/ip` reflector is authenticated
6
- * but has no published price, so it is recorded with 0 units and `billing: "unconfirmed"`
7
- * rather than invented as a paid unit; the span still proves the call was made.
8
- */
9
3
  const ENDPOINT_BILLING = {
10
4
  "capsolver:create_task": { billable_units: 1, billing: "metered" },
11
5
  "twocaptcha:create_task": { billable_units: 1, billing: "metered" },
@@ -42,7 +36,7 @@ export async function recordPaidResolverCreate(options) {
42
36
  }
43
37
  return options.create();
44
38
  }
45
- const baseAttributes = {
39
+ const baseAttributes = resolverSpanAttributes({
46
40
  vendor: options.vendor,
47
41
  challenge_kind: options.kind,
48
42
  endpoint: options.endpoint,
@@ -51,10 +45,10 @@ export async function recordPaidResolverCreate(options) {
51
45
  ...(options.usage ? { vendor_index: options.usage.vendorIndex } : {}),
52
46
  ...(options.round === undefined ? {} : { round: options.round }),
53
47
  resolver_identity_scope: options.usage?.resolverIdentityScope,
54
- };
48
+ });
55
49
  return options.traceRecorder.runSpan(RESOLVER_PAID_USAGE_SPAN, options.create, {
56
50
  attributes: baseAttributes,
57
- onSuccess: () => ({ outcome: "success" }),
58
- onError: (error) => ({ outcome: errorOutcome(error, options.signal) }),
51
+ onSuccess: () => resolverSpanAttributes({ outcome: "success" }),
52
+ onError: (error) => resolverSpanAttributes({ outcome: errorOutcome(error, options.signal) }),
59
53
  });
60
54
  }
@@ -1,5 +1,6 @@
1
1
  import { isProviderError } from "../../errors.js";
2
2
  import { createBrowserClient } from "../browser.js";
3
+ import { resolverSpanAttributes } from "../resolver-span-attributes.js";
3
4
  import { resolverChallengeIssuingIdentity } from "./bindings.js";
4
5
  import { assertResolverHostAllowed, normalizedResolverHostname } from "./hosts.js";
5
6
  import { ResolverVendorUnavailableError, } from "./types.js";
@@ -141,15 +142,15 @@ async function runBoundedCleanup(cleanup, timeoutMs, challengeKind, operation, t
141
142
  }
142
143
  await traceRecorder
143
144
  .runSpan("resolver.vendor.cleanup", boundedCleanup, {
144
- attributes: {
145
+ attributes: resolverSpanAttributes({
145
146
  vendor: BROWSER_VENDOR_ID,
146
147
  challenge_kind: challengeKind,
147
148
  operation,
148
- },
149
+ }),
149
150
  onError(error) {
150
- return {
151
+ return resolverSpanAttributes({
151
152
  error_message: error instanceof Error ? error.message : String(error),
152
- };
153
+ });
153
154
  },
154
155
  })
155
156
  .catch(() => undefined);
@@ -2,6 +2,7 @@ import { getStealthProfile } from "../../stealth/profiles.js";
2
2
  import { redactSensitiveText } from "../request-options.js";
3
3
  import { DEFAULT_STEALTH_PROFILE } from "../stealth.js";
4
4
  import { recordPaidResolverCreate } from "../resolver-usage.js";
5
+ import { resolverSpanAttributes } from "../resolver-span-attributes.js";
5
6
  import { assertResolverHostAllowed } from "./hosts.js";
6
7
  import { ResolverChallengeVerdictError, ResolverVendorUnavailableError, recordResolverVendorPoll, resolverVendorSupports, } from "./types.js";
7
8
  const CAPSOLVER_VENDOR_ID = "capsolver";
@@ -263,7 +264,7 @@ function endpoint(baseUrl, path) {
263
264
  }
264
265
  function spanErrorAttributes(error, phase) {
265
266
  if (error instanceof ResolverVendorUnavailableError) {
266
- return {
267
+ return resolverSpanAttributes({
267
268
  unavailability_reason: error.reason,
268
269
  transport_phase: error.phase,
269
270
  ...(error instanceof CapsolverUnavailableError
@@ -275,21 +276,21 @@ function spanErrorAttributes(error, phase) {
275
276
  response_length: error.responseLength,
276
277
  }
277
278
  : {}),
278
- };
279
+ });
279
280
  }
280
281
  if (error instanceof CapsolverVerdictError) {
281
- return {
282
+ return resolverSpanAttributes({
282
283
  verdict_reason: error.reason,
283
284
  transport_phase: error.phase,
284
285
  vendor_error_code: error.errorCode,
285
286
  vendor_error_description: error.errorDescription,
286
- };
287
+ });
287
288
  }
288
289
  if (error instanceof CapsolverSolveTimeoutError) {
289
- return {
290
+ return resolverSpanAttributes({
290
291
  unavailability_reason: "timeout",
291
292
  transport_phase: phase,
292
- };
293
+ });
293
294
  }
294
295
  return undefined;
295
296
  }
@@ -436,10 +437,10 @@ export function createCapsolverResolverVendorAdapter(options) {
436
437
  };
437
438
  const taskId = traceRecorder
438
439
  ? await traceRecorder.runSpan("resolver.vendor.create_task", createTask, {
439
- attributes: {
440
+ attributes: resolverSpanAttributes({
440
441
  vendor: CAPSOLVER_VENDOR_ID,
441
442
  challenge_kind: challenge.kind,
442
- },
443
+ }),
443
444
  onError: (error) => spanErrorAttributes(error, "create_task"),
444
445
  })
445
446
  : await createTask();
@@ -506,10 +507,10 @@ export function createCapsolverResolverVendorAdapter(options) {
506
507
  };
507
508
  return traceRecorder
508
509
  ? await traceRecorder.runSpan("resolver.vendor.poll_result", pollResult, {
509
- attributes: {
510
+ attributes: resolverSpanAttributes({
510
511
  vendor: CAPSOLVER_VENDOR_ID,
511
512
  challenge_kind: challenge.kind,
512
- },
513
+ }),
513
514
  onError: (error) => spanErrorAttributes(error, "poll_result"),
514
515
  })
515
516
  : await pollResult();
@@ -1,4 +1,5 @@
1
1
  import { recordPaidResolverCreate } from "../resolver-usage.js";
2
+ import { resolverSpanAttributes } from "../resolver-span-attributes.js";
2
3
  import { assertResolverHostAllowed } from "./hosts.js";
3
4
  import { ResolverChallengeVerdictError, ResolverVendorUnavailableError, recordResolverVendorPoll, resolverVendorSupports, } from "./types.js";
4
5
  const TWOCAPTCHA_VENDOR_ID = "2captcha";
@@ -198,19 +199,19 @@ function endpoint(baseUrl, path) {
198
199
  }
199
200
  function spanErrorAttributes(error, phase) {
200
201
  if (error instanceof ResolverVendorUnavailableError) {
201
- return {
202
+ return resolverSpanAttributes({
202
203
  unavailability_reason: error.reason,
203
204
  transport_phase: error.phase,
204
- };
205
+ });
205
206
  }
206
207
  // The solve budget raises TwoCaptchaSolveTimeoutError inside the spanned closure and
207
208
  // the outer catch converts it only after the span has been finalized, so the span has
208
209
  // to recognize it here or slow solves lose exactly the attribution these spans add.
209
210
  if (error instanceof TwoCaptchaSolveTimeoutError) {
210
- return {
211
+ return resolverSpanAttributes({
211
212
  unavailability_reason: "timeout",
212
213
  transport_phase: phase,
213
- };
214
+ });
214
215
  }
215
216
  return undefined;
216
217
  }
@@ -345,10 +346,10 @@ export function createTwoCaptchaResolverVendorAdapter(options) {
345
346
  };
346
347
  const taskId = traceRecorder
347
348
  ? await traceRecorder.runSpan("resolver.vendor.create_task", createTask, {
348
- attributes: {
349
+ attributes: resolverSpanAttributes({
349
350
  vendor: TWOCAPTCHA_VENDOR_ID,
350
351
  challenge_kind: challenge.kind,
351
- },
352
+ }),
352
353
  onError: (error) => spanErrorAttributes(error, "create_task"),
353
354
  })
354
355
  : await createTask();
@@ -387,10 +388,10 @@ export function createTwoCaptchaResolverVendorAdapter(options) {
387
388
  };
388
389
  return traceRecorder
389
390
  ? await traceRecorder.runSpan("resolver.vendor.poll_result", pollResult, {
390
- attributes: {
391
+ attributes: resolverSpanAttributes({
391
392
  vendor: TWOCAPTCHA_VENDOR_ID,
392
393
  challenge_kind: challenge.kind,
393
- },
394
+ }),
394
395
  onError: (error) => spanErrorAttributes(error, "poll_result"),
395
396
  })
396
397
  : await pollResult();
@@ -13,6 +13,7 @@ import { createTwoCaptchaResolverVendorAdapter } from "./resolver-vendors/twocap
13
13
  import { RESOLVER_VENDOR_CAPABILITIES, ResolverVendorUnavailableError, attachResolverVendorPollObserver, resolveProviderResolverVendors, resolverVendorSupports, } from "./resolver-vendors/types.js";
14
14
  import { createUnsupportedResolverClient, inheritResolverTelemetryBinding, registerResolverTelemetryBinding, RESOLVER_INSTRUMENTATION_METADATA, } from "./resolver-shared.js";
15
15
  import { APIFUSE__CDP_POOL__URL, APIFUSE__RESOLVER__2CAPTCHA__API_KEY, APIFUSE__RESOLVER__CAPMONSTER__API_KEY, APIFUSE__RESOLVER__CAPSOLVER__API_KEY, APIFUSE__RESOLVER__HYPERSOLUTIONS__API_KEY, APIFUSE__RESOLVER__TIMEOUT_MS, DEFAULT_RESOLVER_TIMEOUT_MS, kindRequiresClientProfile, } from "./resolver-config.js";
16
+ import { resolverSpanAttributes } from "./resolver-span-attributes.js";
16
17
  import { DEFAULT_STEALTH_PROFILE } from "./stealth.js";
17
18
  export { createUnsupportedResolverClient, RESOLVER_INSTRUMENTATION_METADATA, } from "./resolver-shared.js";
18
19
  export { APIFUSE__CDP_POOL__URL, APIFUSE__RESOLVER__2CAPTCHA__API_KEY, APIFUSE__RESOLVER__CAPMONSTER__API_KEY, APIFUSE__RESOLVER__CAPSOLVER__API_KEY, APIFUSE__RESOLVER__HYPERSOLUTIONS__API_KEY, APIFUSE__RESOLVER__TIMEOUT_MS, DEFAULT_RESOLVER_TIMEOUT_MS, } from "./resolver-config.js";
@@ -356,7 +357,7 @@ function unavailableAttempt(error, forTelemetry = false) {
356
357
  }
357
358
  function unavailableSpanAttributes(error) {
358
359
  const attempt = unavailableAttempt(error);
359
- return {
360
+ return resolverSpanAttributes({
360
361
  unavailability_reason: error.reason,
361
362
  missing_fields: error.missingFields,
362
363
  cause_name: attempt.cause?.name,
@@ -364,7 +365,7 @@ function unavailableSpanAttributes(error) {
364
365
  upstream_host: attempt.upstreamHost,
365
366
  transport_phase: attempt.phase,
366
367
  transport_round: attempt.round,
367
- };
368
+ });
368
369
  }
369
370
  function resolverTelemetryPhase(value) {
370
371
  return value === "create_task" ||
@@ -657,8 +658,8 @@ async function invalidateResolverSolutionWithOutcome(resolver, challenge, soluti
657
658
  return await invalidate();
658
659
  }
659
660
  return await metadata.traceRecorder.runSpan("resolver.cache.invalidate", invalidate, {
660
- attributes: { challenge_kind: challenge.kind },
661
- onSuccess: (outcome) => ({ outcome }),
661
+ attributes: resolverSpanAttributes({ challenge_kind: challenge.kind }),
662
+ onSuccess: (outcome) => resolverSpanAttributes({ outcome }),
662
663
  });
663
664
  }
664
665
  /** Remove the cached entry for the exact solution object returned by this resolver. */
@@ -864,12 +865,12 @@ function createResolverChainClient(options) {
864
865
  };
865
866
  solution = traceRecorder
866
867
  ? await traceRecorder.runSpan("resolver.vendor.attempt", solveAttempt, {
867
- attributes: {
868
+ attributes: resolverSpanAttributes({
868
869
  vendor: adapter.id,
869
870
  challenge_kind: challenge.kind,
870
871
  client_profile: options.clientProfile,
871
872
  resolver_identity_source: identityResolution.userAgentSource,
872
- },
873
+ }),
873
874
  onError(error) {
874
875
  return error instanceof ResolverVendorUnavailableError
875
876
  ? unavailableSpanAttributes(error)
@@ -3,18 +3,24 @@
3
3
  *
4
4
  * `ctx.stealth` composes these from the Chrome emulation and the request class,
5
5
  * so a caller value is rejected at request time with `SDKError` code
6
- * `STEALTH_HEADER_OVERRIDE_UNSUPPORTED` (`assertCallerHeadersSupported` in
7
- * stealth.ts). The `browser-version-literal` lint reports a subset of these
8
- * names — the `sec-fetch-*` and `sec-ch-ua*` families — in files that use
9
- * `ctx.stealth` (`isReportedOwnedHeaderName` in lint.ts). Both read this
10
- * module, so the lint cannot claim a rejection the runtime does not perform;
11
- * it reports deliberately less than the runtime rejects (host, connection,
6
+ * `STEALTH_HEADER_OVERRIDE_UNSUPPORTED` ({@link assertCallerHeadersSupported}).
7
+ * The offline test harness (`runStandardTests`) calls the same function on
8
+ * every `ctx.stealth` request it serves, so a provider test fails exactly where
9
+ * production would, whichever file or variable the header came from. That is
10
+ * the cross-file enforcement.
11
+ *
12
+ * The `browser-version-literal` lint reports a subset of these names — the
13
+ * `sec-fetch-*` and `sec-ch-ua*` families — in files that use `ctx.stealth`
14
+ * (`isReportedOwnedHeaderName` in lint.ts). Both read this module, so the lint
15
+ * cannot claim a rejection the runtime does not perform; it reports
16
+ * deliberately less than the runtime rejects (host, connection,
12
17
  * accept-encoding and a non-literal user-agent stay silent — see that
13
18
  * predicate for why), so a green `apifuse check` is not proof that no owned
14
19
  * header is set.
15
20
  *
16
- * Leaf module by design: no imports, so the lazily loaded CLI lint path does
17
- * not pull the stealth runtime in.
21
+ * Kept off the stealth runtime: the only import is `errors.js`, whose own
22
+ * imports are type-only, so the lazily loaded CLI lint path does not pull the
23
+ * stealth runtime in.
18
24
  */
19
25
  export declare const SDK_OWNED_EXACT_CHROME_HEADERS: ReadonlySet<string>;
20
26
  export declare const SDK_OWNED_CHROME_HEADER_PREFIX = "sec-fetch-";
@@ -24,3 +30,10 @@ export declare const SDK_OWNED_CHROME_HEADER_PREFIX = "sec-fetch-";
24
30
  * `sec-fetch-*` name.
25
31
  */
26
32
  export declare function isStealthOwnedHeaderName(name: string): boolean;
33
+ /**
34
+ * Throws `SDKError` `STEALTH_HEADER_OVERRIDE_UNSUPPORTED` for the first caller
35
+ * header `ctx.stealth` owns. An `undefined` value is skipped, as the transport
36
+ * drops it before sending. The stealth runtime and the offline test harness
37
+ * both call this, so the two cannot disagree about what is rejected.
38
+ */
39
+ export declare function assertCallerHeadersSupported(headers: Readonly<Record<string, string | readonly string[] | undefined>>): void;