@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.
- package/AUTHORING.md +6 -1
- package/CHANGELOG.md +4 -0
- package/README.md +10 -3
- package/dist/fixture-sanitization.js +159 -15
- package/dist/lint.js +56 -9
- package/dist/runtime/diagnostic-redactor.d.ts +6 -1
- package/dist/runtime/diagnostic-redactor.js +16 -14
- package/dist/runtime/http.js +15 -102
- package/dist/runtime/instrumentation.js +4 -1
- package/dist/runtime/redirect-hop-headers.d.ts +19 -0
- package/dist/runtime/redirect-hop-headers.js +58 -0
- package/dist/runtime/redirects.d.ts +33 -1
- package/dist/runtime/redirects.js +113 -0
- package/dist/runtime/resolver-span-attributes.d.ts +34 -0
- package/dist/runtime/resolver-span-attributes.js +93 -0
- package/dist/runtime/resolver-usage.d.ts +8 -0
- package/dist/runtime/resolver-usage.js +5 -11
- package/dist/runtime/resolver-vendors/browser.js +5 -4
- package/dist/runtime/resolver-vendors/capsolver.js +11 -10
- package/dist/runtime/resolver-vendors/twocaptcha.js +9 -8
- package/dist/runtime/resolver.js +7 -6
- package/dist/runtime/stealth-owned-headers.d.ts +21 -8
- package/dist/runtime/stealth-owned-headers.js +28 -8
- package/dist/runtime/stealth-telemetry.d.ts +1 -1
- package/dist/runtime/stealth.js +158 -46
- package/dist/runtime/trace.js +2 -2
- package/dist/server/serve-implementation.js +2 -1
- package/dist/testing/run.js +14 -7
- package/dist/trace-sanitization.js +1 -1
- package/dist/types.d.ts +30 -6
- package/package.json +2 -2
- package/src/fixture-sanitization.ts +175 -17
- package/src/lint.ts +54 -9
- package/src/runtime/diagnostic-redactor.ts +19 -13
- package/src/runtime/http.ts +22 -111
- package/src/runtime/instrumentation.ts +4 -1
- package/src/runtime/redirect-hop-headers.ts +71 -0
- package/src/runtime/redirects.ts +150 -1
- package/src/runtime/resolver-span-attributes.ts +139 -0
- package/src/runtime/resolver-usage.ts +8 -5
- package/src/runtime/resolver-vendors/browser.ts +5 -4
- package/src/runtime/resolver-vendors/capsolver.ts +11 -10
- package/src/runtime/resolver-vendors/twocaptcha.ts +9 -8
- package/src/runtime/resolver.ts +7 -6
- package/src/runtime/stealth-owned-headers.ts +34 -8
- package/src/runtime/stealth-telemetry.ts +6 -1
- package/src/runtime/stealth.ts +180 -58
- package/src/runtime/trace.ts +6 -2
- package/src/server/serve-implementation.ts +2 -1
- package/src/testing/run.ts +15 -2
- package/src/trace-sanitization.ts +1 -1
- 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
|
|
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();
|
package/dist/runtime/resolver.js
CHANGED
|
@@ -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` (
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
*
|
|
17
|
-
*
|
|
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;
|