@apifuse/provider-sdk 2.2.0-beta.67 → 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 (77) hide show
  1. package/AUTHORING.md +34 -2
  2. package/CHANGELOG.md +8 -0
  3. package/README.md +18 -7
  4. package/bin/apifuse-record.ts +81 -21
  5. package/bin/apifuse-submit-check.ts +170 -28
  6. package/dist/cli/templates/provider/.agents/skills/health-checks-and-fail-closed/SKILL.md.tpl +138 -23
  7. package/dist/cli/templates/provider/README.md.tpl +17 -9
  8. package/dist/config/loader.d.ts +16 -0
  9. package/dist/config/loader.js +25 -8
  10. package/dist/error-resolution.d.ts +13 -0
  11. package/dist/error-resolution.js +72 -35
  12. package/dist/fixture-sanitization.js +159 -15
  13. package/dist/lint.d.ts +2 -0
  14. package/dist/lint.js +549 -11
  15. package/dist/runtime/diagnostic-redactor.d.ts +6 -1
  16. package/dist/runtime/diagnostic-redactor.js +16 -14
  17. package/dist/runtime/executor.js +3 -0
  18. package/dist/runtime/http.d.ts +2 -0
  19. package/dist/runtime/http.js +17 -103
  20. package/dist/runtime/instrumentation.js +4 -1
  21. package/dist/runtime/provider.d.ts +7 -0
  22. package/dist/runtime/provider.js +14 -0
  23. package/dist/runtime/redirect-hop-headers.d.ts +19 -0
  24. package/dist/runtime/redirect-hop-headers.js +58 -0
  25. package/dist/runtime/redirects.d.ts +33 -1
  26. package/dist/runtime/redirects.js +113 -0
  27. package/dist/runtime/resolver-span-attributes.d.ts +34 -0
  28. package/dist/runtime/resolver-span-attributes.js +93 -0
  29. package/dist/runtime/resolver-usage.d.ts +8 -0
  30. package/dist/runtime/resolver-usage.js +5 -11
  31. package/dist/runtime/resolver-vendors/browser.js +5 -4
  32. package/dist/runtime/resolver-vendors/capsolver.js +11 -10
  33. package/dist/runtime/resolver-vendors/twocaptcha.js +9 -8
  34. package/dist/runtime/resolver.js +7 -6
  35. package/dist/runtime/stealth-owned-headers.d.ts +21 -8
  36. package/dist/runtime/stealth-owned-headers.js +28 -8
  37. package/dist/runtime/stealth-telemetry.d.ts +1 -1
  38. package/dist/runtime/stealth.js +158 -46
  39. package/dist/runtime/trace.js +2 -2
  40. package/dist/server/index.d.ts +1 -0
  41. package/dist/server/serve-implementation.d.ts +2 -1
  42. package/dist/server/serve-implementation.js +45 -30
  43. package/dist/server/state-backend-report.d.ts +70 -0
  44. package/dist/server/state-backend-report.js +113 -0
  45. package/dist/testing/run.js +14 -7
  46. package/dist/trace-sanitization.js +1 -1
  47. package/dist/types.d.ts +30 -6
  48. package/package.json +2 -2
  49. package/src/cli/templates/provider/.agents/skills/health-checks-and-fail-closed/SKILL.md.tpl +138 -23
  50. package/src/cli/templates/provider/README.md.tpl +17 -9
  51. package/src/config/loader.ts +49 -12
  52. package/src/error-resolution.ts +89 -35
  53. package/src/fixture-sanitization.ts +175 -17
  54. package/src/lint.ts +595 -10
  55. package/src/runtime/diagnostic-redactor.ts +19 -13
  56. package/src/runtime/executor.ts +3 -0
  57. package/src/runtime/http.ts +24 -112
  58. package/src/runtime/instrumentation.ts +4 -1
  59. package/src/runtime/provider.ts +15 -0
  60. package/src/runtime/redirect-hop-headers.ts +71 -0
  61. package/src/runtime/redirects.ts +150 -1
  62. package/src/runtime/resolver-span-attributes.ts +139 -0
  63. package/src/runtime/resolver-usage.ts +8 -5
  64. package/src/runtime/resolver-vendors/browser.ts +5 -4
  65. package/src/runtime/resolver-vendors/capsolver.ts +11 -10
  66. package/src/runtime/resolver-vendors/twocaptcha.ts +9 -8
  67. package/src/runtime/resolver.ts +7 -6
  68. package/src/runtime/stealth-owned-headers.ts +34 -8
  69. package/src/runtime/stealth-telemetry.ts +6 -1
  70. package/src/runtime/stealth.ts +180 -58
  71. package/src/runtime/trace.ts +6 -2
  72. package/src/server/index.ts +4 -0
  73. package/src/server/serve-implementation.ts +58 -31
  74. package/src/server/state-backend-report.ts +185 -0
  75. package/src/testing/run.ts +15 -2
  76. package/src/trace-sanitization.ts +1 -1
  77. package/src/types.ts +30 -6
@@ -6,6 +6,7 @@ import {
6
6
  REDACTED_QUERY_VALUE,
7
7
  type SensitiveValueVariant,
8
8
  } from "./request-options.js";
9
+ import { RESOLVER_CLOSED_ENUM_SPAN_ATTRIBUTES } from "./resolver-span-attributes.js";
9
10
 
10
11
  export type DiagnosticRedactor = (text: string) => string;
11
12
  export const REDACTION_FAILED = "[REDACTION_FAILED]";
@@ -577,11 +578,19 @@ export function redactDiagnosticAttributeKey(key: string, redact?: DiagnosticRed
577
578
  export function redactCriticalDiagnosticText(text: string, redact?: DiagnosticRedactor): string {
578
579
  return redactDiagnosticText(text, redact ? (criticalRedactors.get(redact) ?? redact) : undefined);
579
580
  }
581
+ /**
582
+ * The redactor for one attribute value. A value its producer declares as a closed-enum literal for
583
+ * this key is a contract, like a typed number: exhaustion suppression skips it, while registered
584
+ * credentials still match. Any other value under the same key stays free text.
585
+ */
580
586
  export function diagnosticAttributeRedactor(
581
587
  key: string,
582
588
  redact?: DiagnosticRedactor,
589
+ value?: unknown,
583
590
  ): DiagnosticRedactor | undefined {
584
- if (CLOSED_ENUM_ATTRIBUTE_KEYS.has(key)) return diagnosticStructuredRedactor(redact);
591
+ if (typeof value === "string" && CLOSED_ENUM_ATTRIBUTE_VALUES.get(key)?.has(value)) {
592
+ return diagnosticStructuredRedactor(redact);
593
+ }
585
594
  return CRITICAL_ATTRIBUTE_KEYS.has(key) && redact
586
595
  ? (criticalRedactors.get(redact) ?? redact)
587
596
  : redact;
@@ -600,18 +609,15 @@ const CRITICAL_ATTRIBUTE_KEYS = new Set([
600
609
  "http.status_code",
601
610
  ]);
602
611
 
603
- // SDK-owned closed enums are contracts, like typed numbers and booleans. This
604
- // exempts only exhaustion suppression; registered credentials still match.
605
- const CLOSED_ENUM_ATTRIBUTE_KEYS = new Set([
606
- "outcome",
607
- "code",
608
- "errorClass",
609
- "phase",
610
- "cacheStatus",
611
- "identitySource",
612
- "taxonomy",
613
- "taxonomyVersion",
614
- ]);
612
+ // SDK-owned closed enums are contracts, like typed numbers and booleans. The table is the
613
+ // producers' own declaration of which span attributes are closed enums and of every literal each
614
+ // one can carry; it exempts only exhaustion suppression, and registered credentials still match.
615
+ const CLOSED_ENUM_ATTRIBUTE_VALUES: ReadonlyMap<string, ReadonlySet<string>> = new Map(
616
+ Object.entries(RESOLVER_CLOSED_ENUM_SPAN_ATTRIBUTES).map(([key, values]) => [
617
+ key,
618
+ new Set<string>(values),
619
+ ]),
620
+ );
615
621
 
616
622
  // Bigints normalize to strings for OTLP. Carry their type policy through internal
617
623
  // detached copies without treating arbitrary numeric-looking free text as typed.
@@ -39,6 +39,9 @@ export async function executeOperation<
39
39
  if (!operation) {
40
40
  throw new ProviderError(`Unknown operation: ${provider.id}/${operationId}`, {
41
41
  code: "NOT_FOUND",
42
+ // No upstream was asked: keep the registered NOT_FOUND code from
43
+ // attributing this to one.
44
+ category: "provider_error",
42
45
  fix: `Valid operations: ${Object.keys(provider.operations).join(", ")}`,
43
46
  });
44
47
  }
@@ -4,7 +4,7 @@ import {
4
4
  resolvePolicyTransportAttemptCap,
5
5
  resolveProxyConfigAsync,
6
6
  } from "../config/loader.js";
7
- import { HttpRedirectError, ProviderError, TransportError } from "../errors.js";
7
+ import { ProviderError, TransportError } from "../errors.js";
8
8
  import { parseSseStream, readableBytes, readableLines, readableTextChunks } from "../stream.js";
9
9
  import type {
10
10
  HttpAttemptContext,
@@ -36,7 +36,13 @@ import {
36
36
  type HttpTelemetrySink,
37
37
  type HttpAttemptTelemetryEvent,
38
38
  } from "./http-telemetry.js";
39
- import { evaluateRedirectHop, isRedirectStatus, resolveRedirectUrl } from "./redirects.js";
39
+ import {
40
+ invalidRedirectPolicy,
41
+ isRedirectStatus,
42
+ nextSameOriginRedirectHop,
43
+ normalizeRedirectPolicy,
44
+ type RedirectPolicyTransport,
45
+ } from "./redirects.js";
40
46
  import {
41
47
  normalizeHttpRequestBody,
42
48
  redactSensitiveError,
@@ -578,7 +584,8 @@ function normalizeHttpMethod(method: string): Uppercase<HttpMethod> {
578
584
  }
579
585
  }
580
586
 
581
- function isAbsoluteUrl(url: string): boolean {
587
+ /** Whether `url` carries its own scheme, so it needs no `upstream.baseUrl` to resolve. */
588
+ export function isAbsoluteUrl(url: string): boolean {
582
589
  return /^[a-z][a-z\d+\-.]*:/i.test(url);
583
590
  }
584
591
 
@@ -589,74 +596,24 @@ function resolveHttpUrl(baseUrl: string | undefined, url: string): string {
589
596
  type NativeFetchInit = RequestInit & { proxy?: string };
590
597
 
591
598
  const MAX_HTTP_REDIRECT_HOPS = 20;
592
- const HTTP_REDIRECT_POLICY_FIELDS = new Set(["mode", "maxHops"]);
599
+ const HTTP_REDIRECT_POLICY_TRANSPORT: RedirectPolicyTransport = {
600
+ label: "ctx.http",
601
+ maxHops: MAX_HTTP_REDIRECT_HOPS,
602
+ };
593
603
  const REDIRECT_BODY_HEADERS = new Set([
594
604
  "content-encoding",
595
605
  "content-language",
596
606
  "content-location",
597
607
  "content-type",
598
608
  ]);
599
- const MALFORMED_REDIRECT_TARGET = "[malformed redirect target]";
600
-
601
- function invalidHttpRedirectPolicy(message: string, cause?: Error): TransportError {
602
- return new TransportError(`Invalid ctx.http redirectPolicy: ${message}`, {
603
- code: "http_redirect_policy_invalid",
604
- ...(cause ? { cause } : {}),
605
- });
606
- }
607
-
608
- /** Snapshot untrusted caller input synchronously, before proxy resolution or fetch. */
609
- function normalizeHttpRedirectPolicy(value: unknown): HttpRedirectPolicy | undefined {
610
- if (value === undefined) return undefined;
611
- if (value === null || typeof value !== "object" || Array.isArray(value)) {
612
- throw invalidHttpRedirectPolicy("expected an object");
613
- }
614
-
615
- try {
616
- const keys = Reflect.ownKeys(value);
617
- for (const key of keys) {
618
- if (typeof key !== "string" || !HTTP_REDIRECT_POLICY_FIELDS.has(key)) {
619
- throw invalidHttpRedirectPolicy(`unknown field ${String(key)}`);
620
- }
621
- }
622
- for (const field of HTTP_REDIRECT_POLICY_FIELDS) {
623
- const descriptor = Object.getOwnPropertyDescriptor(value, field);
624
- if (!descriptor || !("value" in descriptor)) {
625
- throw invalidHttpRedirectPolicy(`${field} must be an own data property`);
626
- }
627
- }
628
-
629
- const record = value as Record<string, unknown>;
630
- if (record.mode !== "same-origin") {
631
- throw invalidHttpRedirectPolicy('mode must be "same-origin"');
632
- }
633
- if (
634
- typeof record.maxHops !== "number" ||
635
- !Number.isInteger(record.maxHops) ||
636
- record.maxHops < 0 ||
637
- record.maxHops > MAX_HTTP_REDIRECT_HOPS
638
- ) {
639
- throw invalidHttpRedirectPolicy(
640
- `maxHops must be an integer from 0 to ${MAX_HTTP_REDIRECT_HOPS}`,
641
- );
642
- }
643
-
644
- return { mode: "same-origin", maxHops: record.maxHops };
645
- } catch (error) {
646
- if (error instanceof TransportError) throw error;
647
- throw invalidHttpRedirectPolicy(
648
- "could not be inspected safely",
649
- error instanceof Error ? error : undefined,
650
- );
651
- }
652
- }
653
609
 
654
610
  function snapshotHttpRedirectPolicy(options: RequestOptions): HttpRedirectPolicy | undefined {
655
611
  try {
656
- return normalizeHttpRedirectPolicy(options.redirectPolicy);
612
+ return normalizeRedirectPolicy(options.redirectPolicy, HTTP_REDIRECT_POLICY_TRANSPORT);
657
613
  } catch (error) {
658
614
  if (error instanceof TransportError) throw error;
659
- throw invalidHttpRedirectPolicy(
615
+ throw invalidRedirectPolicy(
616
+ HTTP_REDIRECT_POLICY_TRANSPORT,
660
617
  "could not be read safely",
661
618
  error instanceof Error ? error : undefined,
662
619
  );
@@ -669,21 +626,6 @@ function withoutRedirectBodyHeaders(headers: HeadersInit | undefined): Headers {
669
626
  return nextHeaders;
670
627
  }
671
628
 
672
- function redirectDiagnosticTarget(value: string): string {
673
- try {
674
- const parsed = new URL(value);
675
- // Origin omits URL userinfo. Keeping only origin + path makes diagnostics
676
- // useful while structurally excluding every query value and fragment,
677
- // including attacker-chosen keys the provider did not declare sensitive.
678
- const redactedQuery = parsed.search ? "?[REDACTED]" : "";
679
- return parsed.origin === "null"
680
- ? `${parsed.protocol}<opaque-target>`
681
- : `${parsed.origin}${parsed.pathname}${redactedQuery}`;
682
- } catch {
683
- return MALFORMED_REDIRECT_TARGET;
684
- }
685
- }
686
-
687
629
  function discardRedirectResponseBody(response: Response): void {
688
630
  try {
689
631
  const cancellation = response.body?.cancel();
@@ -729,47 +671,17 @@ async function fetchWithHttpRedirectPolicy(
729
671
 
730
672
  const location = response.headers.get("location");
731
673
  discardRedirectResponseBody(response);
732
- let nextUrlString: string | undefined;
733
- try {
734
- nextUrlString = resolveRedirectUrl(location || undefined, currentUrl);
735
- } catch {
736
- const target = MALFORMED_REDIRECT_TARGET;
737
- throw new HttpRedirectError(`Redirect response has malformed Location target ${target}`, {
738
- reason: "missing_location",
739
- target,
740
- status: response.status,
741
- });
742
- }
743
-
744
- const decision = evaluateRedirectHop({
674
+ const decision = nextSameOriginRedirectHop({
675
+ policy,
676
+ initialOrigin,
677
+ currentUrl,
678
+ responseUrl: currentUrl,
745
679
  status: response.status,
746
680
  method,
747
- nextUrl: nextUrlString,
748
- shouldStop: nextUrlString ? new URL(nextUrlString).origin !== initialOrigin : false,
749
- redirectCount: followedHops + 1,
750
- maxHops: policy.maxHops,
681
+ location: location ?? undefined,
682
+ followedHops,
751
683
  visitedRequests,
752
684
  });
753
- if (decision.kind === "stop") {
754
- const target = decision.nextUrl ? redirectDiagnosticTarget(decision.nextUrl) : undefined;
755
- const message = (() => {
756
- switch (decision.reason) {
757
- case "stopped":
758
- return `Redirect policy refused cross-origin target ${target}`;
759
- case "max_hops":
760
- return `Redirect policy reached maxHops before target ${target}`;
761
- case "loop":
762
- return `Redirect loop refused target ${target}`;
763
- case "missing_location":
764
- return `Redirect response from ${redirectDiagnosticTarget(currentUrl)} is missing Location`;
765
- }
766
- })();
767
- throw new HttpRedirectError(message, {
768
- reason: decision.reason,
769
- ...(target ? { target } : {}),
770
- status: response.status,
771
- });
772
- }
773
685
  if (decision.nextMethod !== method) {
774
686
  body = undefined;
775
687
  headers = withoutRedirectBodyHeaders(headers);
@@ -26,6 +26,7 @@ import {
26
26
  type TraceContext,
27
27
  } from "./trace.js";
28
28
  import { RESOLVER_INSTRUMENTATION_METADATA } from "./resolver-shared.js";
29
+ import { resolverSpanAttributes } from "./resolver-span-attributes.js";
29
30
 
30
31
  export interface InstrumentationOptions extends CreateTraceContextOptions {}
31
32
 
@@ -707,7 +708,9 @@ function wrapNamespace<T extends object>(
707
708
  "resolver.solve",
708
709
  () => Reflect.apply(value, namespaceTarget, [args[0], args[1], recorder]),
709
710
  {
710
- attributes: challengeKind ? { challenge_kind: challengeKind } : undefined,
711
+ attributes: challengeKind
712
+ ? resolverSpanAttributes({ challenge_kind: challengeKind })
713
+ : undefined,
711
714
  },
712
715
  );
713
716
  };
@@ -13,3 +13,18 @@ export function getProviderBaseUrl(provider: ProviderDefinition): string | undef
13
13
 
14
14
  return undefined;
15
15
  }
16
+
17
+ /**
18
+ * The base `ctx.stealth` resolves relative request URLs (and seeds its cookie
19
+ * jar's default origin) against: the provider's first declared
20
+ * `upstream.baseUrl`, else `https://<allowedHosts[0]>`. `undefined` means the
21
+ * provider declares neither, and `serve` binds no stealth transport at all.
22
+ */
23
+ export function getProviderStealthBaseUrl(provider: ProviderDefinition): string | undefined {
24
+ const baseUrl = getProviderBaseUrl(provider);
25
+ if (baseUrl) {
26
+ return baseUrl;
27
+ }
28
+ const firstHost = provider.allowedHosts?.[0];
29
+ return firstHost ? `https://${firstHost}` : undefined;
30
+ }
@@ -0,0 +1,71 @@
1
+ import { getPublicSuffix } from "tough-cookie";
2
+
3
+ /**
4
+ * Request-body header names a redirect drops when it changes the method
5
+ * (Fetch "request-body-header name").
6
+ */
7
+ const REDIRECT_BODY_HEADERS: ReadonlySet<string> = new Set([
8
+ "content-encoding",
9
+ "content-language",
10
+ "content-location",
11
+ "content-type",
12
+ ]);
13
+
14
+ /**
15
+ * Credentials a redirect never carries to another origin. Fetch removes
16
+ * `Authorization` on a cross-origin redirect; `Proxy-Authorization` is a
17
+ * credential of the same kind.
18
+ */
19
+ const CROSS_ORIGIN_CREDENTIAL_HEADERS: ReadonlySet<string> = new Set([
20
+ "authorization",
21
+ "proxy-authorization",
22
+ ]);
23
+
24
+ /** One redirect hop: the URL the headers were sent to, and the next request. */
25
+ export interface RedirectHop {
26
+ readonly from: string;
27
+ readonly to: string;
28
+ readonly methodChanged: boolean;
29
+ }
30
+
31
+ /**
32
+ * Schemeful site (HTML "same site"): scheme plus registrable domain from the
33
+ * public suffix list, or the bare host when there is none (an IP address or
34
+ * a single-label host).
35
+ */
36
+ function schemefulSite(url: URL): string {
37
+ const host = url.hostname;
38
+ const registrable = getPublicSuffix(host, { allowSpecialUseDomain: true, ignoreError: true });
39
+ return `${url.protocol}//${registrable ?? host}`;
40
+ }
41
+
42
+ /**
43
+ * The caller headers the next redirect hop carries, derived from the headers
44
+ * the previous hop sent. Both stealth redirect walkers compute every hop's
45
+ * headers here, so the rules hold on every path:
46
+ *
47
+ * - a method change drops the request-body headers;
48
+ * - a cross-origin hop drops `Authorization` and `Proxy-Authorization`;
49
+ * - a cross-site hop drops an explicit `Cookie`, after which the session
50
+ * cookie jar supplies cookies by its own domain rules, as a browser does.
51
+ *
52
+ * A header dropped on one hop stays dropped for the rest of the chain.
53
+ */
54
+ export function redirectHopHeaders<T>(
55
+ hop: RedirectHop,
56
+ headers: Readonly<Record<string, T>>,
57
+ ): Record<string, T> {
58
+ const from = new URL(hop.from);
59
+ const to = new URL(hop.to);
60
+ const crossOrigin = from.origin !== to.origin;
61
+ const crossSite = schemefulSite(from) !== schemefulSite(to);
62
+ return Object.fromEntries(
63
+ Object.entries(headers).filter(([name]) => {
64
+ const lower = name.toLowerCase();
65
+ if (hop.methodChanged && REDIRECT_BODY_HEADERS.has(lower)) return false;
66
+ if (crossOrigin && CROSS_ORIGIN_CREDENTIAL_HEADERS.has(lower)) return false;
67
+ if (crossSite && lower === "cookie") return false;
68
+ return true;
69
+ }),
70
+ );
71
+ }
@@ -1,4 +1,5 @@
1
- import type { HttpRedirectFailureReason } from "../types.js";
1
+ import { HttpRedirectError, TransportError } from "../errors.js";
2
+ import type { HttpRedirectFailureReason, HttpRedirectPolicy } from "../types.js";
2
3
 
3
4
  const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
4
5
 
@@ -64,3 +65,151 @@ export function evaluateRedirectHop<TMethod extends string>(input: {
64
65
  }
65
66
  return { kind: "follow", nextMethod, nextUrl: input.nextUrl };
66
67
  }
68
+
69
+ const REDIRECT_POLICY_FIELDS = new Set(["mode", "maxHops"]);
70
+ export const MALFORMED_REDIRECT_TARGET = "[malformed redirect target]";
71
+
72
+ /** The transport a redirectPolicy belongs to: its name in diagnostics and its own hop limit. */
73
+ export interface RedirectPolicyTransport {
74
+ readonly label: string;
75
+ readonly maxHops: number;
76
+ }
77
+
78
+ export function invalidRedirectPolicy(
79
+ transport: RedirectPolicyTransport,
80
+ message: string,
81
+ cause?: Error,
82
+ ): TransportError {
83
+ return new TransportError(`Invalid ${transport.label} redirectPolicy: ${message}`, {
84
+ code: "http_redirect_policy_invalid",
85
+ ...(cause ? { cause } : {}),
86
+ });
87
+ }
88
+
89
+ /** Snapshot untrusted caller input synchronously, before proxy resolution or fetch. */
90
+ export function normalizeRedirectPolicy(
91
+ value: unknown,
92
+ transport: RedirectPolicyTransport,
93
+ ): HttpRedirectPolicy | undefined {
94
+ if (value === undefined) return undefined;
95
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
96
+ throw invalidRedirectPolicy(transport, "expected an object");
97
+ }
98
+
99
+ try {
100
+ const keys = Reflect.ownKeys(value);
101
+ for (const key of keys) {
102
+ if (typeof key !== "string" || !REDIRECT_POLICY_FIELDS.has(key)) {
103
+ throw invalidRedirectPolicy(transport, `unknown field ${String(key)}`);
104
+ }
105
+ }
106
+ for (const field of REDIRECT_POLICY_FIELDS) {
107
+ const descriptor = Object.getOwnPropertyDescriptor(value, field);
108
+ if (!descriptor || !("value" in descriptor)) {
109
+ throw invalidRedirectPolicy(transport, `${field} must be an own data property`);
110
+ }
111
+ }
112
+
113
+ const record = value as Record<string, unknown>;
114
+ if (record.mode !== "same-origin") {
115
+ throw invalidRedirectPolicy(transport, 'mode must be "same-origin"');
116
+ }
117
+ if (
118
+ typeof record.maxHops !== "number" ||
119
+ !Number.isInteger(record.maxHops) ||
120
+ record.maxHops < 0 ||
121
+ record.maxHops > transport.maxHops
122
+ ) {
123
+ throw invalidRedirectPolicy(
124
+ transport,
125
+ `maxHops must be an integer from 0 to ${transport.maxHops}`,
126
+ );
127
+ }
128
+
129
+ return { mode: "same-origin", maxHops: record.maxHops };
130
+ } catch (error) {
131
+ if (error instanceof TransportError) throw error;
132
+ throw invalidRedirectPolicy(
133
+ transport,
134
+ "could not be inspected safely",
135
+ error instanceof Error ? error : undefined,
136
+ );
137
+ }
138
+ }
139
+
140
+ export function redirectDiagnosticTarget(value: string): string {
141
+ try {
142
+ const parsed = new URL(value);
143
+ // Origin omits URL userinfo. Keeping only origin + path makes diagnostics
144
+ // useful while structurally excluding every query value and fragment,
145
+ // including attacker-chosen keys the provider did not declare sensitive.
146
+ const redactedQuery = parsed.search ? "?[REDACTED]" : "";
147
+ return parsed.origin === "null"
148
+ ? `${parsed.protocol}<opaque-target>`
149
+ : `${parsed.origin}${parsed.pathname}${redactedQuery}`;
150
+ } catch {
151
+ return MALFORMED_REDIRECT_TARGET;
152
+ }
153
+ }
154
+
155
+ /**
156
+ * One hop of a same-origin `redirectPolicy` walk, shared by ctx.http and
157
+ * ctx.stealth so both follow and refuse identically: returns the next request
158
+ * inside the initial origin, or throws the `HttpRedirectError` naming why the
159
+ * walk stopped (cross-origin target, maxHops, loop, missing or malformed
160
+ * Location) before any request leaves for that target.
161
+ */
162
+ export function nextSameOriginRedirectHop<TMethod extends string>(input: {
163
+ policy: HttpRedirectPolicy;
164
+ initialOrigin: string;
165
+ currentUrl: string;
166
+ responseUrl: string;
167
+ status: number;
168
+ method: TMethod;
169
+ location: string | undefined;
170
+ followedHops: number;
171
+ visitedRequests: ReadonlySet<string>;
172
+ }): { nextMethod: TMethod | "GET"; nextUrl: string } {
173
+ let nextUrl: string | undefined;
174
+ try {
175
+ nextUrl = resolveRedirectUrl(input.location || undefined, input.responseUrl);
176
+ } catch {
177
+ const target = MALFORMED_REDIRECT_TARGET;
178
+ throw new HttpRedirectError(`Redirect response has malformed Location target ${target}`, {
179
+ reason: "missing_location",
180
+ target,
181
+ status: input.status,
182
+ });
183
+ }
184
+
185
+ const decision = evaluateRedirectHop({
186
+ status: input.status,
187
+ method: input.method,
188
+ nextUrl,
189
+ shouldStop: nextUrl ? new URL(nextUrl).origin !== input.initialOrigin : false,
190
+ redirectCount: input.followedHops + 1,
191
+ maxHops: input.policy.maxHops,
192
+ visitedRequests: input.visitedRequests,
193
+ });
194
+ if (decision.kind === "follow") {
195
+ return { nextMethod: decision.nextMethod, nextUrl: decision.nextUrl };
196
+ }
197
+ const target = decision.nextUrl ? redirectDiagnosticTarget(decision.nextUrl) : undefined;
198
+ const message = (() => {
199
+ switch (decision.reason) {
200
+ case "stopped":
201
+ return `Redirect policy refused cross-origin target ${target}`;
202
+ case "max_hops":
203
+ return `Redirect policy reached maxHops before target ${target}`;
204
+ case "loop":
205
+ return `Redirect loop refused target ${target}`;
206
+ case "missing_location":
207
+ return `Redirect response from ${redirectDiagnosticTarget(input.currentUrl)} is missing Location`;
208
+ }
209
+ })();
210
+ throw new HttpRedirectError(message, {
211
+ reason: decision.reason,
212
+ ...(target ? { target } : {}),
213
+ status: input.status,
214
+ });
215
+ }
@@ -0,0 +1,139 @@
1
+ import type { ProxyUserAgentSource } from "../config/loader.js";
2
+ import type { ProviderChallengeKind, ProviderResolverVendor } from "../types.js";
3
+ import type { ResolverTelemetryPhase } from "./resolver-telemetry.js";
4
+ import type {
5
+ ResolverPaidUsageBilling,
6
+ ResolverPaidUsageEndpoint,
7
+ ResolverPaidUsageOutcome,
8
+ } from "./resolver-usage.js";
9
+ import type {
10
+ ResolverChallengeVerdictReason,
11
+ ResolverVendorUnavailableReason,
12
+ } from "./resolver-vendors/types.js";
13
+
14
+ // Type-only imports: the diagnostic redactor reads this table, so it must not pull the resolver
15
+ // runtime (or anything that imports the redactor) into module evaluation.
16
+
17
+ /** Every member of a closed string union, as runtime values; a missing or extra member fails tsc. */
18
+ function closedEnumValues<TUnion extends string>() {
19
+ return <const TValues extends readonly TUnion[]>(
20
+ values: TValues,
21
+ ..._missing: Exclude<TUnion, TValues[number]> extends never
22
+ ? []
23
+ : ["Missing closed-enum values", Exclude<TUnion, TValues[number]>]
24
+ ): TValues => values;
25
+ }
26
+
27
+ const RESOLVER_PAID_USAGE_OUTCOMES = closedEnumValues<ResolverPaidUsageOutcome>()([
28
+ "success",
29
+ "vendor_error",
30
+ "timeout",
31
+ "abandoned",
32
+ ]);
33
+ // `resolver.cache.invalidate` outcomes. The union stays private to resolver.ts; its producer's
34
+ // `resolverSpanAttributes` call fails to compile if it can emit an outcome missing here.
35
+ const RESOLVER_CACHE_INVALIDATION_OUTCOMES = [
36
+ "cache_disabled",
37
+ "entry_deleted",
38
+ "index_entry_deleted",
39
+ "not_cookie_solution",
40
+ "solution_not_cached",
41
+ ] as const;
42
+
43
+ /**
44
+ * Closed-enum span attributes emitted by the resolver, by attribute key. A value listed here is an
45
+ * SDK-declared literal, so fail-closed diagnostic redaction keeps it the way it keeps a typed
46
+ * number; any other value under the same key is still free text. Registered credentials are
47
+ * matched either way.
48
+ */
49
+ export const RESOLVER_CLOSED_ENUM_SPAN_ATTRIBUTES = {
50
+ vendor: closedEnumValues<ProviderResolverVendor>()([
51
+ "browser",
52
+ "capsolver",
53
+ "capmonster",
54
+ "2captcha",
55
+ "hypersolutions",
56
+ "custom",
57
+ ]),
58
+ challenge_kind: closedEnumValues<ProviderChallengeKind>()([
59
+ "turnstile",
60
+ "recaptcha_v2",
61
+ "recaptcha_v3",
62
+ "hcaptcha",
63
+ "cloudflare_interstitial",
64
+ "aws_waf",
65
+ "akamai_sec_cpt",
66
+ "akamai_sensor",
67
+ "akamai_sbsd",
68
+ ]),
69
+ endpoint: closedEnumValues<ResolverPaidUsageEndpoint>()([
70
+ "capsolver:create_task",
71
+ "twocaptcha:create_task",
72
+ "hyper:ip",
73
+ "hyper:sbsd_create",
74
+ ]),
75
+ billing: closedEnumValues<ResolverPaidUsageBilling>()(["metered", "unconfirmed"]),
76
+ outcome: [...RESOLVER_PAID_USAGE_OUTCOMES, ...RESOLVER_CACHE_INVALIDATION_OUTCOMES],
77
+ unavailability_reason: closedEnumValues<ResolverVendorUnavailableReason>()([
78
+ "missing_credentials",
79
+ "missing_proxy_identity",
80
+ "missing_client_profile",
81
+ "missing_challenge_input",
82
+ "missing_transport",
83
+ "allocation_exhausted",
84
+ "transport_failure",
85
+ "timeout",
86
+ "not_implemented",
87
+ ]),
88
+ verdict_reason: closedEnumValues<ResolverChallengeVerdictReason>()([
89
+ "human_puzzle",
90
+ "solve_failed",
91
+ ]),
92
+ transport_phase: closedEnumValues<ResolverTelemetryPhase>()([
93
+ "create_task",
94
+ "poll_result",
95
+ "cleanup",
96
+ "measure_ip",
97
+ "fetch_script",
98
+ "generate_payload",
99
+ "post_payload",
100
+ ]),
101
+ resolver_identity_source: closedEnumValues<ProxyUserAgentSource>()(["declared", "defaulted"]),
102
+ operation: closedEnumValues<"client.close" | "context.close">()([
103
+ "client.close",
104
+ "context.close",
105
+ ]),
106
+ } as const;
107
+
108
+ type ResolverClosedEnumSpanAttributes = typeof RESOLVER_CLOSED_ENUM_SPAN_ATTRIBUTES;
109
+ type Present<T> = Exclude<T, undefined>;
110
+ /** Properties typed as a finite union of string literals, never plain `string`. */
111
+ type FiniteStringKeys<T> = {
112
+ [K in keyof T]-?: [Present<T[K]>] extends [never]
113
+ ? never
114
+ : [Present<T[K]>] extends [string]
115
+ ? string extends Present<T[K]>
116
+ ? never
117
+ : K
118
+ : never;
119
+ }[keyof T];
120
+ type DeclaredClosedEnumAttributes<T> = {
121
+ [K in keyof T]: K extends FiniteStringKeys<T>
122
+ ? K extends keyof ResolverClosedEnumSpanAttributes
123
+ ? [Present<T[K]>] extends [ResolverClosedEnumSpanAttributes[K][number]]
124
+ ? T[K]
125
+ : never
126
+ : never
127
+ : T[K];
128
+ };
129
+
130
+ /**
131
+ * Resolver span attributes. Every property typed as a finite string union must be declared in
132
+ * `RESOLVER_CLOSED_ENUM_SPAN_ATTRIBUTES` with all of its members, so a new closed-enum attribute
133
+ * cannot silently become free text that fail-closed redaction suppresses.
134
+ */
135
+ export function resolverSpanAttributes<const T extends Record<string, unknown>>(
136
+ attributes: T & DeclaredClosedEnumAttributes<T>,
137
+ ): T {
138
+ return attributes;
139
+ }