@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
@@ -1,5 +1,6 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import { allSensitiveValueVariants, normalizePercentEscapes, REDACTED_QUERY_VALUE, } from "./request-options.js";
3
+ import { RESOLVER_CLOSED_ENUM_SPAN_ATTRIBUTES } from "./resolver-span-attributes.js";
3
4
  export const REDACTION_FAILED = "[REDACTION_FAILED]";
4
5
  // No supported credential source needs 1–3 character secrets. Registering these
5
6
  // common tokens damages unrelated IDs and diagnostics even with token boundaries.
@@ -492,9 +493,15 @@ export function redactDiagnosticAttributeKey(key, redact) {
492
493
  export function redactCriticalDiagnosticText(text, redact) {
493
494
  return redactDiagnosticText(text, redact ? (criticalRedactors.get(redact) ?? redact) : undefined);
494
495
  }
495
- export function diagnosticAttributeRedactor(key, redact) {
496
- if (CLOSED_ENUM_ATTRIBUTE_KEYS.has(key))
496
+ /**
497
+ * The redactor for one attribute value. A value its producer declares as a closed-enum literal for
498
+ * this key is a contract, like a typed number: exhaustion suppression skips it, while registered
499
+ * credentials still match. Any other value under the same key stays free text.
500
+ */
501
+ export function diagnosticAttributeRedactor(key, redact, value) {
502
+ if (typeof value === "string" && CLOSED_ENUM_ATTRIBUTE_VALUES.get(key)?.has(value)) {
497
503
  return diagnosticStructuredRedactor(redact);
504
+ }
498
505
  return CRITICAL_ATTRIBUTE_KEYS.has(key) && redact
499
506
  ? (criticalRedactors.get(redact) ?? redact)
500
507
  : redact;
@@ -512,18 +519,13 @@ const CRITICAL_ATTRIBUTE_KEYS = new Set([
512
519
  "operation",
513
520
  "http.status_code",
514
521
  ]);
515
- // SDK-owned closed enums are contracts, like typed numbers and booleans. This
516
- // exempts only exhaustion suppression; registered credentials still match.
517
- const CLOSED_ENUM_ATTRIBUTE_KEYS = new Set([
518
- "outcome",
519
- "code",
520
- "errorClass",
521
- "phase",
522
- "cacheStatus",
523
- "identitySource",
524
- "taxonomy",
525
- "taxonomyVersion",
526
- ]);
522
+ // SDK-owned closed enums are contracts, like typed numbers and booleans. The table is the
523
+ // producers' own declaration of which span attributes are closed enums and of every literal each
524
+ // one can carry; it exempts only exhaustion suppression, and registered credentials still match.
525
+ const CLOSED_ENUM_ATTRIBUTE_VALUES = new Map(Object.entries(RESOLVER_CLOSED_ENUM_SPAN_ATTRIBUTES).map(([key, values]) => [
526
+ key,
527
+ new Set(values),
528
+ ]));
527
529
  // Bigints normalize to strings for OTLP. Carry their type policy through internal
528
530
  // detached copies without treating arbitrary numeric-looking free text as typed.
529
531
  const bigintAttributes = new WeakMap();
@@ -22,6 +22,9 @@ export async function executeOperation(provider, operationId, ctx, input, _optio
22
22
  if (!operation) {
23
23
  throw new ProviderError(`Unknown operation: ${provider.id}/${operationId}`, {
24
24
  code: "NOT_FOUND",
25
+ // No upstream was asked: keep the registered NOT_FOUND code from
26
+ // attributing this to one.
27
+ category: "provider_error",
25
28
  fix: `Valid operations: ${Object.keys(provider.operations).join(", ")}`,
26
29
  });
27
30
  }
@@ -37,4 +37,6 @@ export type HttpClientOptions = ProxyResolutionOptions & {
37
37
  httpTelemetry?: HttpTelemetrySink;
38
38
  signal?: AbortSignal;
39
39
  };
40
+ /** Whether `url` carries its own scheme, so it needs no `upstream.baseUrl` to resolve. */
41
+ export declare function isAbsoluteUrl(url: string): boolean;
40
42
  export declare function createHttpClient(baseUrl?: string, clientOptions?: HttpClientOptions): HttpClient;
@@ -1,9 +1,9 @@
1
1
  import { policyRotatesTransportVendorChain, resolvePolicyTransportAttemptCap, resolveProxyConfigAsync, } from "../config/loader.js";
2
- import { HttpRedirectError, ProviderError, TransportError } from "../errors.js";
2
+ import { ProviderError, TransportError } from "../errors.js";
3
3
  import { parseSseStream, readableBytes, readableLines, readableTextChunks } from "../stream.js";
4
4
  import { computeProxyAttemptIndex, computeProxyTransportRetryDelayMs, createDefaultProxyTransportRetryOptions, isProxyTransportRetryMethod, normalizeProxyTransportRetryOptions, proxyTransportRetryErrorCode, proxyTransportRetryErrorStatus, shouldRetryProxyTransportAttempt, validateUnsafeProxyTransportRetryMethods, } from "./proxy-retry-policy.js";
5
5
  import { httpTelemetryErrorCode, } from "./http-telemetry.js";
6
- import { evaluateRedirectHop, isRedirectStatus, resolveRedirectUrl } from "./redirects.js";
6
+ import { invalidRedirectPolicy, isRedirectStatus, nextSameOriginRedirectHop, normalizeRedirectPolicy, } from "./redirects.js";
7
7
  import { normalizeHttpRequestBody, redactSensitiveError, redactSensitiveRequestError, serializeRequestUrl, } from "./request-options.js";
8
8
  import { startHttpTelemetry } from "./http-telemetry-guard.js";
9
9
  import { scopedHttpTelemetry } from "./http-telemetry-binding.js";
@@ -401,73 +401,32 @@ function normalizeHttpMethod(method) {
401
401
  });
402
402
  }
403
403
  }
404
- function isAbsoluteUrl(url) {
404
+ /** Whether `url` carries its own scheme, so it needs no `upstream.baseUrl` to resolve. */
405
+ export function isAbsoluteUrl(url) {
405
406
  return /^[a-z][a-z\d+\-.]*:/i.test(url);
406
407
  }
407
408
  function resolveHttpUrl(baseUrl, url) {
408
409
  return new URL(url, baseUrl ?? DEFAULT_HTTP_BASE_URL).toString();
409
410
  }
410
411
  const MAX_HTTP_REDIRECT_HOPS = 20;
411
- const HTTP_REDIRECT_POLICY_FIELDS = new Set(["mode", "maxHops"]);
412
+ const HTTP_REDIRECT_POLICY_TRANSPORT = {
413
+ label: "ctx.http",
414
+ maxHops: MAX_HTTP_REDIRECT_HOPS,
415
+ };
412
416
  const REDIRECT_BODY_HEADERS = new Set([
413
417
  "content-encoding",
414
418
  "content-language",
415
419
  "content-location",
416
420
  "content-type",
417
421
  ]);
418
- const MALFORMED_REDIRECT_TARGET = "[malformed redirect target]";
419
- function invalidHttpRedirectPolicy(message, cause) {
420
- return new TransportError(`Invalid ctx.http redirectPolicy: ${message}`, {
421
- code: "http_redirect_policy_invalid",
422
- ...(cause ? { cause } : {}),
423
- });
424
- }
425
- /** Snapshot untrusted caller input synchronously, before proxy resolution or fetch. */
426
- function normalizeHttpRedirectPolicy(value) {
427
- if (value === undefined)
428
- return undefined;
429
- if (value === null || typeof value !== "object" || Array.isArray(value)) {
430
- throw invalidHttpRedirectPolicy("expected an object");
431
- }
432
- try {
433
- const keys = Reflect.ownKeys(value);
434
- for (const key of keys) {
435
- if (typeof key !== "string" || !HTTP_REDIRECT_POLICY_FIELDS.has(key)) {
436
- throw invalidHttpRedirectPolicy(`unknown field ${String(key)}`);
437
- }
438
- }
439
- for (const field of HTTP_REDIRECT_POLICY_FIELDS) {
440
- const descriptor = Object.getOwnPropertyDescriptor(value, field);
441
- if (!descriptor || !("value" in descriptor)) {
442
- throw invalidHttpRedirectPolicy(`${field} must be an own data property`);
443
- }
444
- }
445
- const record = value;
446
- if (record.mode !== "same-origin") {
447
- throw invalidHttpRedirectPolicy('mode must be "same-origin"');
448
- }
449
- if (typeof record.maxHops !== "number" ||
450
- !Number.isInteger(record.maxHops) ||
451
- record.maxHops < 0 ||
452
- record.maxHops > MAX_HTTP_REDIRECT_HOPS) {
453
- throw invalidHttpRedirectPolicy(`maxHops must be an integer from 0 to ${MAX_HTTP_REDIRECT_HOPS}`);
454
- }
455
- return { mode: "same-origin", maxHops: record.maxHops };
456
- }
457
- catch (error) {
458
- if (error instanceof TransportError)
459
- throw error;
460
- throw invalidHttpRedirectPolicy("could not be inspected safely", error instanceof Error ? error : undefined);
461
- }
462
- }
463
422
  function snapshotHttpRedirectPolicy(options) {
464
423
  try {
465
- return normalizeHttpRedirectPolicy(options.redirectPolicy);
424
+ return normalizeRedirectPolicy(options.redirectPolicy, HTTP_REDIRECT_POLICY_TRANSPORT);
466
425
  }
467
426
  catch (error) {
468
427
  if (error instanceof TransportError)
469
428
  throw error;
470
- throw invalidHttpRedirectPolicy("could not be read safely", error instanceof Error ? error : undefined);
429
+ throw invalidRedirectPolicy(HTTP_REDIRECT_POLICY_TRANSPORT, "could not be read safely", error instanceof Error ? error : undefined);
471
430
  }
472
431
  }
473
432
  function withoutRedirectBodyHeaders(headers) {
@@ -476,21 +435,6 @@ function withoutRedirectBodyHeaders(headers) {
476
435
  nextHeaders.delete(name);
477
436
  return nextHeaders;
478
437
  }
479
- function redirectDiagnosticTarget(value) {
480
- try {
481
- const parsed = new URL(value);
482
- // Origin omits URL userinfo. Keeping only origin + path makes diagnostics
483
- // useful while structurally excluding every query value and fragment,
484
- // including attacker-chosen keys the provider did not declare sensitive.
485
- const redactedQuery = parsed.search ? "?[REDACTED]" : "";
486
- return parsed.origin === "null"
487
- ? `${parsed.protocol}<opaque-target>`
488
- : `${parsed.origin}${parsed.pathname}${redactedQuery}`;
489
- }
490
- catch {
491
- return MALFORMED_REDIRECT_TARGET;
492
- }
493
- }
494
438
  function discardRedirectResponseBody(response) {
495
439
  try {
496
440
  const cancellation = response.body?.cancel();
@@ -531,47 +475,17 @@ async function fetchWithHttpRedirectPolicy(requestUrl, requestInit, policy) {
531
475
  return response;
532
476
  const location = response.headers.get("location");
533
477
  discardRedirectResponseBody(response);
534
- let nextUrlString;
535
- try {
536
- nextUrlString = resolveRedirectUrl(location || undefined, currentUrl);
537
- }
538
- catch {
539
- const target = MALFORMED_REDIRECT_TARGET;
540
- throw new HttpRedirectError(`Redirect response has malformed Location target ${target}`, {
541
- reason: "missing_location",
542
- target,
543
- status: response.status,
544
- });
545
- }
546
- const decision = evaluateRedirectHop({
478
+ const decision = nextSameOriginRedirectHop({
479
+ policy,
480
+ initialOrigin,
481
+ currentUrl,
482
+ responseUrl: currentUrl,
547
483
  status: response.status,
548
484
  method,
549
- nextUrl: nextUrlString,
550
- shouldStop: nextUrlString ? new URL(nextUrlString).origin !== initialOrigin : false,
551
- redirectCount: followedHops + 1,
552
- maxHops: policy.maxHops,
485
+ location: location ?? undefined,
486
+ followedHops,
553
487
  visitedRequests,
554
488
  });
555
- if (decision.kind === "stop") {
556
- const target = decision.nextUrl ? redirectDiagnosticTarget(decision.nextUrl) : undefined;
557
- const message = (() => {
558
- switch (decision.reason) {
559
- case "stopped":
560
- return `Redirect policy refused cross-origin target ${target}`;
561
- case "max_hops":
562
- return `Redirect policy reached maxHops before target ${target}`;
563
- case "loop":
564
- return `Redirect loop refused target ${target}`;
565
- case "missing_location":
566
- return `Redirect response from ${redirectDiagnosticTarget(currentUrl)} is missing Location`;
567
- }
568
- })();
569
- throw new HttpRedirectError(message, {
570
- reason: decision.reason,
571
- ...(target ? { target } : {}),
572
- status: response.status,
573
- });
574
- }
575
489
  if (decision.nextMethod !== method) {
576
490
  body = undefined;
577
491
  headers = withoutRedirectBodyHeaders(headers);
@@ -4,6 +4,7 @@ import { readableBytes, readableLines, readableTextChunks } from "../stream.js";
4
4
  import { parseHttpRequestInvocation, isSensitiveKey, redactSensitiveError, redactSensitiveText, redactUrlQueryParams, requestOptionsFromHttpInvocation, serializeRequestUrl, } from "./request-options.js";
5
5
  import { createTraceContext, getTraceRecorder, } from "./trace.js";
6
6
  import { RESOLVER_INSTRUMENTATION_METADATA } from "./resolver-shared.js";
7
+ import { resolverSpanAttributes } from "./resolver-span-attributes.js";
7
8
  const BROWSER_PAGE_METHODS = new Set([
8
9
  "goto",
9
10
  "fill",
@@ -485,7 +486,9 @@ function wrapNamespace(namespace, target, trace, shouldInstrument) {
485
486
  ? challenge.kind
486
487
  : undefined;
487
488
  return recorder.runSpan("resolver.solve", () => Reflect.apply(value, namespaceTarget, [args[0], args[1], recorder]), {
488
- attributes: challengeKind ? { challenge_kind: challengeKind } : undefined,
489
+ attributes: challengeKind
490
+ ? resolverSpanAttributes({ challenge_kind: challengeKind })
491
+ : undefined,
489
492
  });
490
493
  };
491
494
  wrappedMethods.set(property, wrapped);
@@ -1,2 +1,9 @@
1
1
  import type { ProviderDefinition } from "../types.js";
2
2
  export declare function getProviderBaseUrl(provider: ProviderDefinition): string | undefined;
3
+ /**
4
+ * The base `ctx.stealth` resolves relative request URLs (and seeds its cookie
5
+ * jar's default origin) against: the provider's first declared
6
+ * `upstream.baseUrl`, else `https://<allowedHosts[0]>`. `undefined` means the
7
+ * provider declares neither, and `serve` binds no stealth transport at all.
8
+ */
9
+ export declare function getProviderStealthBaseUrl(provider: ProviderDefinition): string | undefined;
@@ -8,3 +8,17 @@ export function getProviderBaseUrl(provider) {
8
8
  }
9
9
  return undefined;
10
10
  }
11
+ /**
12
+ * The base `ctx.stealth` resolves relative request URLs (and seeds its cookie
13
+ * jar's default origin) against: the provider's first declared
14
+ * `upstream.baseUrl`, else `https://<allowedHosts[0]>`. `undefined` means the
15
+ * provider declares neither, and `serve` binds no stealth transport at all.
16
+ */
17
+ export function getProviderStealthBaseUrl(provider) {
18
+ const baseUrl = getProviderBaseUrl(provider);
19
+ if (baseUrl) {
20
+ return baseUrl;
21
+ }
22
+ const firstHost = provider.allowedHosts?.[0];
23
+ return firstHost ? `https://${firstHost}` : undefined;
24
+ }
@@ -0,0 +1,19 @@
1
+ /** One redirect hop: the URL the headers were sent to, and the next request. */
2
+ export interface RedirectHop {
3
+ readonly from: string;
4
+ readonly to: string;
5
+ readonly methodChanged: boolean;
6
+ }
7
+ /**
8
+ * The caller headers the next redirect hop carries, derived from the headers
9
+ * the previous hop sent. Both stealth redirect walkers compute every hop's
10
+ * headers here, so the rules hold on every path:
11
+ *
12
+ * - a method change drops the request-body headers;
13
+ * - a cross-origin hop drops `Authorization` and `Proxy-Authorization`;
14
+ * - a cross-site hop drops an explicit `Cookie`, after which the session
15
+ * cookie jar supplies cookies by its own domain rules, as a browser does.
16
+ *
17
+ * A header dropped on one hop stays dropped for the rest of the chain.
18
+ */
19
+ export declare function redirectHopHeaders<T>(hop: RedirectHop, headers: Readonly<Record<string, T>>): Record<string, T>;
@@ -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
+ }