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

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 (51) hide show
  1. package/AUTHORING.md +7 -6
  2. package/CHANGELOG.md +9 -1
  3. package/README.md +3 -3
  4. package/bin/apifuse-check.ts +62 -3
  5. package/bin/apifuse-pack-check.ts +8 -2
  6. package/bin/apifuse-pack-smoke.ts +43 -2
  7. package/bin/apifuse-pack-types.ts +58 -0
  8. package/dist/auth.js +29 -0
  9. package/dist/cli/templates/provider/README.md.tpl +4 -4
  10. package/dist/contract-serialization.js +4 -8
  11. package/dist/declaration-validation.d.ts +23 -0
  12. package/dist/declaration-validation.js +159 -0
  13. package/dist/define.d.ts +1 -1
  14. package/dist/define.js +13 -2
  15. package/dist/index.d.ts +1 -0
  16. package/dist/lint.js +85 -3
  17. package/dist/runtime/resolver-vendors/bindings.d.ts +42 -2
  18. package/dist/runtime/resolver-vendors/bindings.js +31 -6
  19. package/dist/runtime/resolver-vendors/browser.d.ts +3 -7
  20. package/dist/runtime/resolver-vendors/browser.js +7 -22
  21. package/dist/runtime/resolver-vendors/hosts.d.ts +2 -0
  22. package/dist/runtime/resolver-vendors/hosts.js +33 -0
  23. package/dist/runtime/resolver-vendors/twocaptcha.d.ts +23 -0
  24. package/dist/runtime/resolver-vendors/twocaptcha.js +264 -0
  25. package/dist/runtime/resolver-vendors/types.d.ts +44 -3
  26. package/dist/runtime/resolver-vendors/types.js +10 -0
  27. package/dist/runtime/resolver.d.ts +17 -2
  28. package/dist/runtime/resolver.js +237 -15
  29. package/dist/runtime/stealth.d.ts +26 -4
  30. package/dist/runtime/stealth.js +224 -114
  31. package/dist/server/serve.js +8 -0
  32. package/dist/stealth/profiles.js +16 -7
  33. package/dist/types.d.ts +34 -1
  34. package/package.json +2 -2
  35. package/src/auth.ts +40 -0
  36. package/src/cli/templates/provider/README.md.tpl +4 -4
  37. package/src/contract-serialization.ts +5 -7
  38. package/src/declaration-validation.ts +202 -0
  39. package/src/define.ts +23 -2
  40. package/src/index.ts +1 -0
  41. package/src/lint.ts +98 -3
  42. package/src/runtime/resolver-vendors/bindings.ts +40 -15
  43. package/src/runtime/resolver-vendors/browser.ts +9 -31
  44. package/src/runtime/resolver-vendors/hosts.ts +38 -0
  45. package/src/runtime/resolver-vendors/twocaptcha.ts +366 -0
  46. package/src/runtime/resolver-vendors/types.ts +54 -0
  47. package/src/runtime/resolver.ts +304 -24
  48. package/src/runtime/stealth.ts +317 -136
  49. package/src/server/serve.ts +8 -0
  50. package/src/stealth/profiles.ts +17 -7
  51. package/src/types.ts +36 -3
package/dist/index.d.ts CHANGED
@@ -30,6 +30,7 @@ export { type InstrumentationOptions, type InstrumentedProviderContext, wrapWith
30
30
  export { type PrevalidateResult, prevalidate } from "./runtime/prevalidate.js";
31
31
  export { getProviderBaseUrl } from "./runtime/provider.js";
32
32
  export { APIFUSE__CDP_POOL__URL, APIFUSE__RESOLVER__2CAPTCHA__API_KEY, APIFUSE__RESOLVER__CAPMONSTER__API_KEY, APIFUSE__RESOLVER__CAPSOLVER__API_KEY, APIFUSE__RESOLVER__TIMEOUT_MS, createResolverClientFromEnv, createUnsupportedResolverClient, DEFAULT_RESOLVER_TIMEOUT_MS, invalidateResolverSolution, type ResolverRuntimeOptions, } from "./runtime/resolver.js";
33
+ export type { ResolverVendorTransport } from "./runtime/resolver-vendors/types.js";
33
34
  export { assertRequiredSecretsPresent, listMissingRequiredSecrets, MISSING_SECRET_CODE, } from "./runtime/secrets.js";
34
35
  export { createUnsupportedProviderRuntimeState, UnsupportedProviderStateError, } from "./runtime/state.js";
35
36
  export { createStealthClient } from "./runtime/stealth.js";
package/dist/lint.js CHANGED
@@ -1,7 +1,84 @@
1
1
  import { SDK_RUNTIME_OWNED_ERROR_CODES, SDK_STATUS_MAPPED_PROVIDER_ERROR_CODES, } from "./error-resolution.js";
2
2
  import { lintPublicSchemaFieldNames } from "./public-schema-field-lint.js";
3
3
  import { APIFUSE_DESCRIPTION_KEY_META_KEY, APIFUSE_SENSITIVE_META_KEY } from "./schema.js";
4
+ // Operations that perform an auth-lifecycle action belong on the single
5
+ // `auth.flow` interface, never on a provider operation:
6
+ // - entry (login / signin / authenticate) => auth.flow.start/continue
7
+ // - exit (logout / signout / disconnect) => auth.flow.abort
8
+ //
9
+ // Matching works on `-`/`_` separated segments rather than a raw substring or a
10
+ // leading anchor, so `shop-logout`, `shop_logout` and `user-sign-out-everywhere`
11
+ // are all recognised: a domain prefix does not make the operation any less of an
12
+ // auth-lifecycle action, and operation ids may use either separator.
13
+ //
14
+ // Vocabulary is split into two tiers because auth words collide with ordinary
15
+ // domain verbs. Measured against the live fleet plus synthetic domain ids:
16
+ // - `authorize-payment`, `revoke-invitation`, `unlink-record`,
17
+ // `disconnect-device` are domain actions that never touch the connection
18
+ // credential, so these verbs are NOT matched as segments;
19
+ // - the same verbs as a complete operation id (`authorize`, `revoke`) do
20
+ // refer to the credential itself, so they are matched only in that form.
21
+ // `exchange`, `callback`, `connect`, `session`, `token`, `credential`,
22
+ // `password` and `otp` stay out entirely for the same reason.
23
+ const AUTH_LIFECYCLE_SEGMENT_WORDS = new Set([
24
+ "login",
25
+ "logout",
26
+ "signin",
27
+ "signout",
28
+ "signup",
29
+ "authenticate",
30
+ "reauth",
31
+ "auth",
32
+ ]);
33
+ // Ambiguous as a prefix, unambiguous when they are the whole operation id.
34
+ const AUTH_LIFECYCLE_WHOLE_ID_WORDS = new Set([
35
+ "authorize",
36
+ "revoke",
37
+ "unlink",
38
+ "disconnect",
39
+ ]);
40
+ // A verb stem followed by a direction word across two segments: `sign-out`,
41
+ // `user_sign_up_flow`, and the spelled-out `log-in` / `shop-log-out` forms
42
+ // (their fused equivalents `login`/`logout` live in the segment set above).
43
+ // `sign` pairs match anywhere; `log` pairs match only at the END of the id,
44
+ // because mid-id `log` is the noun in domain phrases measured against real
45
+ // fleets (`audit-log-in-range`, `change-log-out-of-band` are reads of a log,
46
+ // while `shop-log-out` is a logout).
47
+ const AUTH_DIRECTION_PAIRS = new Map([
48
+ ["sign", { directions: new Set(["in", "out", "up"]), endOnly: false }],
49
+ ["log", { directions: new Set(["in", "out"]), endOnly: true }],
50
+ ]);
51
+ // Legacy anchored form kept for token-plumbing words whose bare use is only
52
+ // auth-related when it leads the operation id (`exchange-code`, `refresh`).
4
53
  const AUTH_OPERATION_ID_PATTERN = /^(?:auth[-_])?(?:login|exchange|continue|refresh|callback)(?:[-_]|$)/i;
54
+ function isAuthLifecycleOperationId(operationId, authMode) {
55
+ const segments = operationId.toLowerCase().split(/[-_]+/).filter(Boolean);
56
+ if (segments.some((segment) => AUTH_LIFECYCLE_SEGMENT_WORDS.has(segment)))
57
+ return true;
58
+ // A verb stem + direction spread across two segments (`sign-out`,
59
+ // `sign_up`, `shop-log-out`); see AUTH_DIRECTION_PAIRS for positioning.
60
+ if (segments.some((segment, index) => {
61
+ const pair = AUTH_DIRECTION_PAIRS.get(segment);
62
+ if (pair === undefined || index + 1 >= segments.length)
63
+ return false;
64
+ if (!pair.directions.has(segments[index + 1]))
65
+ return false;
66
+ return pair.endOnly ? index + 2 === segments.length : true;
67
+ })) {
68
+ return true;
69
+ }
70
+ if (segments.length === 1 && AUTH_LIFECYCLE_WHOLE_ID_WORDS.has(segments[0])) {
71
+ return true;
72
+ }
73
+ // The legacy anchored pattern keeps its original scope. It matches ordinary
74
+ // domain ids such as `exchange-rates` and `refresh-catalog`, so extending it
75
+ // to `oauth2_proxied` would spread that behavior to providers it never
76
+ // applied to; proxied providers are covered by the segment tiers above.
77
+ if (authMode === "credentials" || authMode === "oauth2") {
78
+ return AUTH_OPERATION_ID_PATTERN.test(operationId);
79
+ }
80
+ return false;
81
+ }
5
82
  function lintAllowedHosts(providerId, allowedHosts) {
6
83
  const prefix = providerId ? `Provider "${providerId}"` : "Provider";
7
84
  if (!allowedHosts) {
@@ -957,14 +1034,19 @@ export function lintProvider(provider, options = {}) {
957
1034
  ];
958
1035
  if (provider.operations) {
959
1036
  const authMode = provider.auth?.mode;
960
- if (authMode === "credentials" || authMode === "oauth2") {
1037
+ // Every authenticated mode owns an auth.flow; `oauth2_proxied` was
1038
+ // previously exempt, which let auth-lifecycle operations ship on
1039
+ // proxied providers unchecked.
1040
+ if (authMode === "credentials" ||
1041
+ authMode === "oauth2" ||
1042
+ authMode === "oauth2_proxied") {
961
1043
  for (const operationKey of Object.keys(provider.operations)) {
962
- if (AUTH_OPERATION_ID_PATTERN.test(operationKey)) {
1044
+ if (isAuthLifecycleOperationId(operationKey, authMode)) {
963
1045
  diagnostics.push({
964
1046
  rule: "auth-operation-unsupported",
965
1047
  level: "error",
966
1048
  field: `operations.${operationKey}`,
967
- message: `Provider "${provider.id ?? "unknown"}" operation "${operationKey}" looks like a login/token/session exchange endpoint. Authenticated providers must expose login through the single auth.flow interface because Gateway persists only auth.flow complete turn data.credential as the connection credential. Move this logic into auth.flow.continue instead of a provider operation.`,
1049
+ message: `Provider "${provider.id ?? "unknown"}" operation "${operationKey}" performs an auth-lifecycle action (login, logout, token exchange or similar). Authenticated providers must expose the whole credential lifecycle through the single auth.flow interface because Gateway persists only auth.flow complete turn data.credential as the connection credential, and an operation that mutates the session outside that interface leaves the stored connection stale. Move sign-in logic into auth.flow.start/continue and sign-out/disconnect logic into auth.flow.abort (served by POST /auth/disconnect) instead of a provider operation.`,
968
1050
  });
969
1051
  }
970
1052
  }
@@ -1,8 +1,48 @@
1
1
  import type { ProviderChallenge } from "../../types.js";
2
2
  import type { ResolverIssuingIdentity } from "./types.js";
3
3
  export declare const RESOLVER_CHALLENGE_BINDINGS: {
4
- readonly aws_waf: "portable";
5
- readonly cloudflare_interstitial: "identity_scoped";
4
+ readonly turnstile: {
5
+ readonly cacheable: false;
6
+ readonly identityBinding: "none";
7
+ readonly directCacheable: false;
8
+ };
9
+ readonly recaptcha_v2: {
10
+ readonly cacheable: false;
11
+ readonly identityBinding: "none";
12
+ readonly directCacheable: false;
13
+ };
14
+ readonly recaptcha_v3: {
15
+ readonly cacheable: false;
16
+ readonly identityBinding: "none";
17
+ readonly directCacheable: false;
18
+ };
19
+ readonly hcaptcha: {
20
+ readonly cacheable: false;
21
+ readonly identityBinding: "none";
22
+ readonly directCacheable: false;
23
+ };
24
+ readonly cloudflare_interstitial: {
25
+ readonly cacheable: true;
26
+ readonly identityBinding: "identity_scoped";
27
+ readonly directCacheable: true;
28
+ };
29
+ readonly aws_waf: {
30
+ readonly cacheable: true;
31
+ readonly identityBinding: "portable";
32
+ readonly directCacheable: true;
33
+ };
34
+ readonly akamai_sec_cpt: {
35
+ readonly cacheable: true;
36
+ readonly identityBinding: "identity_scoped";
37
+ readonly directCacheable: false;
38
+ };
39
+ readonly akamai_sensor: {
40
+ readonly cacheable: true;
41
+ readonly identityBinding: "identity_scoped";
42
+ readonly directCacheable: false;
43
+ };
6
44
  };
45
+ export declare function resolverChallengeIsCacheable(challenge: ProviderChallenge): boolean;
46
+ export declare function resolverChallengeAllowsDirectCache(challenge: ProviderChallenge): boolean;
7
47
  export declare function resolverChallengeIsIdentityScoped(challenge: ProviderChallenge): boolean;
8
48
  export declare function resolverChallengeIssuingIdentity(challenge: ProviderChallenge, identity: ResolverIssuingIdentity): ResolverIssuingIdentity;
@@ -1,14 +1,39 @@
1
+ // An IP-bound artifact minted without any recorded egress identity is unsafe to
2
+ // share. The Akamai kinds therefore reject direct caching, while Cloudflare
3
+ // keeps its pre-existing direct-cache behavior pending measurement.
1
4
  export const RESOLVER_CHALLENGE_BINDINGS = {
2
- aws_waf: "portable",
3
- cloudflare_interstitial: "identity_scoped",
5
+ turnstile: { cacheable: false, identityBinding: "none", directCacheable: false },
6
+ recaptcha_v2: { cacheable: false, identityBinding: "none", directCacheable: false },
7
+ recaptcha_v3: { cacheable: false, identityBinding: "none", directCacheable: false },
8
+ hcaptcha: { cacheable: false, identityBinding: "none", directCacheable: false },
9
+ cloudflare_interstitial: {
10
+ cacheable: true,
11
+ identityBinding: "identity_scoped",
12
+ directCacheable: true,
13
+ },
14
+ aws_waf: { cacheable: true, identityBinding: "portable", directCacheable: true },
15
+ akamai_sec_cpt: {
16
+ cacheable: true,
17
+ identityBinding: "identity_scoped",
18
+ directCacheable: false,
19
+ },
20
+ akamai_sensor: {
21
+ cacheable: true,
22
+ identityBinding: "identity_scoped",
23
+ directCacheable: false,
24
+ },
4
25
  };
26
+ export function resolverChallengeIsCacheable(challenge) {
27
+ return RESOLVER_CHALLENGE_BINDINGS[challenge.kind].cacheable;
28
+ }
29
+ export function resolverChallengeAllowsDirectCache(challenge) {
30
+ return RESOLVER_CHALLENGE_BINDINGS[challenge.kind].directCacheable;
31
+ }
5
32
  export function resolverChallengeIsIdentityScoped(challenge) {
6
- return (RESOLVER_CHALLENGE_BINDINGS[challenge.kind] ===
7
- "identity_scoped");
33
+ return RESOLVER_CHALLENGE_BINDINGS[challenge.kind].identityBinding === "identity_scoped";
8
34
  }
9
35
  export function resolverChallengeIssuingIdentity(challenge, identity) {
10
- const binding = RESOLVER_CHALLENGE_BINDINGS[challenge.kind];
11
- if (binding === "portable") {
36
+ if (RESOLVER_CHALLENGE_BINDINGS[challenge.kind].identityBinding === "portable") {
12
37
  return { userAgent: identity.userAgent };
13
38
  }
14
39
  return identity;
@@ -10,15 +10,11 @@ export interface BrowserResolverVendorOptions {
10
10
  readonly allowedHosts: readonly string[];
11
11
  readonly createClient?: BrowserClientFactory;
12
12
  }
13
- export type BrowserResolverSolution = Extract<ChallengeSolution, {
14
- readonly form: "cookies";
15
- }> & {
16
- /** Unix seconds from the cookie that proved the challenge cleared. */
17
- readonly expires?: number;
18
- };
19
13
  export interface BrowserResolverVendorAdapter extends ResolverVendorAdapter {
20
14
  readonly id: "browser";
21
- solve(challenge: ProviderChallenge, identity: ResolverIdentity | undefined, signal: AbortSignal, traceRecorder?: TraceRecorder): Promise<BrowserResolverSolution>;
15
+ solve(challenge: ProviderChallenge, identity: ResolverIdentity | undefined, signal: AbortSignal, traceRecorder?: TraceRecorder): Promise<Extract<ChallengeSolution, {
16
+ readonly form: "cookies";
17
+ }>>;
22
18
  }
23
19
  export declare function createBrowserResolverVendorAdapter(options: BrowserResolverVendorOptions): BrowserResolverVendorAdapter;
24
20
  export {};
@@ -1,6 +1,7 @@
1
- import { isProviderError, ProviderError } from "../../errors.js";
1
+ import { isProviderError } from "../../errors.js";
2
2
  import { createBrowserClient } from "../browser.js";
3
3
  import { resolverChallengeIssuingIdentity } from "./bindings.js";
4
+ import { assertResolverHostAllowed, normalizedResolverHostname } from "./hosts.js";
4
5
  import { ResolverVendorUnavailableError, } from "./types.js";
5
6
  const BROWSER_VENDOR_ID = "browser";
6
7
  const DEFAULT_COOKIE_POLL_INTERVAL_MS = 100;
@@ -94,32 +95,16 @@ async function abortableDelay(ms, signal) {
94
95
  function isSupportedKind(kind) {
95
96
  return Object.hasOwn(SUCCESS_COOKIE_NAMES, kind);
96
97
  }
97
- function normalizedHostname(hostname) {
98
- return hostname.trim().toLowerCase().replace(/\.$/, "");
99
- }
100
- function assertChallengeHostAllowed(pageUrl, allowedHosts) {
101
- const challengeHost = normalizedHostname(new URL(pageUrl).hostname);
102
- const isAllowed = allowedHosts.some((host) => {
103
- const declaredHost = normalizedHostname(host);
104
- return declaredHost.length > 0 && !declaredHost.includes("*") && declaredHost === challengeHost;
105
- });
106
- if (isAllowed)
107
- return;
108
- throw new ProviderError(`Resolver challenge host "${challengeHost}" is not declared`, {
109
- code: "RESOLVER_HOST_NOT_ALLOWED",
110
- fix: "Add the exact challenge hostname to the provider's allowedHosts declaration.",
111
- });
112
- }
113
98
  function cookieDomainSpecificity(cookie) {
114
- return normalizedHostname(cookie.domain.replace(/^\./, "")).length;
99
+ return normalizedResolverHostname(cookie.domain.replace(/^\./, "")).length;
115
100
  }
116
101
  function isHostOnlyCookieFor(cookie, hostname) {
117
102
  return (!cookie.domain.startsWith(".") &&
118
- normalizedHostname(cookie.domain) === normalizedHostname(hostname));
103
+ normalizedResolverHostname(cookie.domain) === normalizedResolverHostname(hostname));
119
104
  }
120
105
  function cookieAppliesToUrl(cookie, url) {
121
- const cookieDomain = normalizedHostname(cookie.domain.replace(/^\./, ""));
122
- const requestHostname = normalizedHostname(url.hostname);
106
+ const cookieDomain = normalizedResolverHostname(cookie.domain.replace(/^\./, ""));
107
+ const requestHostname = normalizedResolverHostname(url.hostname);
123
108
  const domainMatches = cookieDomain.length > 0 &&
124
109
  (requestHostname === cookieDomain ||
125
110
  (cookie.domain.startsWith(".") && requestHostname.endsWith(`.${cookieDomain}`)));
@@ -226,7 +211,7 @@ export function createBrowserResolverVendorAdapter(options) {
226
211
  if (!isSupportedKind(challenge.kind)) {
227
212
  throw new TypeError(`Browser resolver does not support ${challenge.kind}`);
228
213
  }
229
- assertChallengeHostAllowed(challenge.pageUrl, options.allowedHosts);
214
+ assertResolverHostAllowed(challenge.pageUrl, options.allowedHosts);
230
215
  const challengeKind = challenge.kind;
231
216
  callerSignal.throwIfAborted();
232
217
  const solveController = new AbortController();
@@ -0,0 +1,2 @@
1
+ export declare function normalizedResolverHostname(hostname: string): string;
2
+ export declare function assertResolverHostAllowed(targetUrl: string, allowedHosts: readonly string[]): void;
@@ -0,0 +1,33 @@
1
+ import { ProviderError } from "../../errors.js";
2
+ export function normalizedResolverHostname(hostname) {
3
+ return hostname.trim().toLowerCase().replace(/\.$/, "");
4
+ }
5
+ export function assertResolverHostAllowed(targetUrl, allowedHosts) {
6
+ let targetUrlObject;
7
+ try {
8
+ targetUrlObject = new URL(targetUrl);
9
+ }
10
+ catch {
11
+ throw new ProviderError("Resolver target URL is invalid", {
12
+ code: "RESOLVER_HOST_NOT_ALLOWED",
13
+ fix: "Use a valid URL whose exact hostname appears in the provider's allowedHosts declaration.",
14
+ });
15
+ }
16
+ if (targetUrlObject.protocol !== "http:" && targetUrlObject.protocol !== "https:") {
17
+ throw new ProviderError(`Resolver target URL scheme "${targetUrlObject.protocol}" is not allowed`, {
18
+ code: "RESOLVER_HOST_NOT_ALLOWED",
19
+ fix: "Use an http or https URL whose exact hostname appears in the provider's allowedHosts declaration.",
20
+ });
21
+ }
22
+ const targetHost = normalizedResolverHostname(targetUrlObject.hostname);
23
+ const isAllowed = allowedHosts.some((host) => {
24
+ const declaredHost = normalizedResolverHostname(host);
25
+ return declaredHost.length > 0 && !declaredHost.includes("*") && declaredHost === targetHost;
26
+ });
27
+ if (isAllowed)
28
+ return;
29
+ throw new ProviderError(`Resolver target host "${targetHost}" is not declared`, {
30
+ code: "RESOLVER_HOST_NOT_ALLOWED",
31
+ fix: "Add the exact target hostname to the provider's allowedHosts declaration.",
32
+ });
33
+ }
@@ -0,0 +1,23 @@
1
+ import type { ChallengeSolution, ProviderChallenge } from "../../types.js";
2
+ import { type ResolverIdentity, type ResolverVendorAdapter } from "./types.js";
3
+ type Delay = (ms: number, signal: AbortSignal) => Promise<void>;
4
+ export interface TwoCaptchaResolverVendorOptions {
5
+ readonly apiKey?: string;
6
+ readonly timeoutMs?: number;
7
+ readonly pollIntervalMs?: number;
8
+ readonly allowedHosts: readonly string[];
9
+ readonly fetchImpl?: typeof fetch;
10
+ readonly baseUrl?: string;
11
+ /** Test-only clock override; supplying it disables the real-time deadline timer. */
12
+ readonly now?: () => number;
13
+ /** Test-only delay override used with `now` to exercise polling without sleeping. */
14
+ readonly delay?: Delay;
15
+ }
16
+ export interface TwoCaptchaResolverVendorAdapter extends ResolverVendorAdapter {
17
+ readonly id: "2captcha";
18
+ solve(challenge: ProviderChallenge, identity: ResolverIdentity | undefined, signal: AbortSignal): Promise<Extract<ChallengeSolution, {
19
+ readonly form: "token";
20
+ }>>;
21
+ }
22
+ export declare function createTwoCaptchaResolverVendorAdapter(options: TwoCaptchaResolverVendorOptions): TwoCaptchaResolverVendorAdapter;
23
+ export {};
@@ -0,0 +1,264 @@
1
+ import { assertResolverHostAllowed } from "./hosts.js";
2
+ import { ResolverVendorUnavailableError, resolverVendorSupports, } from "./types.js";
3
+ const TWOCAPTCHA_VENDOR_ID = "2captcha";
4
+ const DEFAULT_TWOCAPTCHA_BASE_URL = "https://api.2captcha.com";
5
+ const DEFAULT_POLL_INTERVAL_MS = 3_000;
6
+ const DEFAULT_TIMEOUT_MS = 180_000;
7
+ class TwoCaptchaSolveTimeoutError extends Error {
8
+ constructor() {
9
+ super("2captcha resolver solve budget elapsed");
10
+ this.name = "TwoCaptchaSolveTimeoutError";
11
+ }
12
+ }
13
+ function isJsonRecord(value) {
14
+ return value !== null && typeof value === "object" && !Array.isArray(value);
15
+ }
16
+ function abortReason(signal) {
17
+ return signal.reason ?? new DOMException("The operation was aborted", "AbortError");
18
+ }
19
+ function raceWithAbort(operation, signal, phase) {
20
+ if (signal.aborted)
21
+ return Promise.reject(abortReason(signal));
22
+ return new Promise((resolve, reject) => {
23
+ const cleanup = () => signal.removeEventListener("abort", onAbort);
24
+ const onAbort = () => {
25
+ cleanup();
26
+ reject(abortReason(signal));
27
+ };
28
+ signal.addEventListener("abort", onAbort, { once: true });
29
+ operation().then((value) => {
30
+ cleanup();
31
+ resolve(value);
32
+ }, (error) => {
33
+ cleanup();
34
+ if (phase === undefined) {
35
+ reject(error);
36
+ return;
37
+ }
38
+ reject(new ResolverVendorUnavailableError(TWOCAPTCHA_VENDOR_ID, "transport_failure", {
39
+ cause: error,
40
+ phase,
41
+ }));
42
+ });
43
+ });
44
+ }
45
+ async function abortableDelay(ms, signal) {
46
+ let timer;
47
+ try {
48
+ await raceWithAbort(() => new Promise((resolve) => {
49
+ timer = setTimeout(resolve, ms);
50
+ }), signal);
51
+ }
52
+ finally {
53
+ if (timer !== undefined)
54
+ clearTimeout(timer);
55
+ }
56
+ }
57
+ function parseProxyConfiguration(proxyUrl) {
58
+ try {
59
+ const url = new URL(proxyUrl);
60
+ const protocol = url.protocol.slice(0, -1).toLowerCase();
61
+ const proxyType = protocol === "socks4" || protocol === "socks5"
62
+ ? protocol
63
+ : protocol === "http" || protocol === "https"
64
+ ? "http"
65
+ : undefined;
66
+ const defaultPort = proxyType === "http" ? 80 : 1080;
67
+ const proxyPort = Number(url.port || defaultPort);
68
+ if (!proxyType || !url.hostname || !Number.isInteger(proxyPort) || proxyPort <= 0) {
69
+ return undefined;
70
+ }
71
+ const proxyLogin = url.username ? decodeURIComponent(url.username) : undefined;
72
+ const proxyPassword = url.password ? decodeURIComponent(url.password) : undefined;
73
+ return {
74
+ proxyType,
75
+ proxyAddress: url.hostname,
76
+ proxyPort,
77
+ ...(proxyLogin ? { proxyLogin } : {}),
78
+ ...(proxyPassword ? { proxyPassword } : {}),
79
+ };
80
+ }
81
+ catch {
82
+ return undefined;
83
+ }
84
+ }
85
+ function errorText(payload, key) {
86
+ const value = payload[key];
87
+ return typeof value === "string" ? value : "";
88
+ }
89
+ function isAllocationExhausted(payload) {
90
+ const code = errorText(payload, "errorCode").toLowerCase();
91
+ const description = errorText(payload, "errorDescription").toLowerCase();
92
+ return (code === "error_zero_balance" ||
93
+ /(?:insufficient|zero|no|not enough)\s+(?:balance|funds|credit)/u.test(`${code} ${description}`));
94
+ }
95
+ function unavailableForPayload(payload, phase) {
96
+ return new ResolverVendorUnavailableError(TWOCAPTCHA_VENDOR_ID, isAllocationExhausted(payload) ? "allocation_exhausted" : "transport_failure", { phase });
97
+ }
98
+ async function postJson(fetchImpl, url, body, signal, phase) {
99
+ const response = await raceWithAbort(() => fetchImpl(url, {
100
+ method: "POST",
101
+ headers: { "content-type": "application/json" },
102
+ body: JSON.stringify(body),
103
+ signal,
104
+ redirect: "error",
105
+ }), signal, phase);
106
+ let responseText;
107
+ try {
108
+ responseText = await raceWithAbort(() => response.text(), signal, phase);
109
+ }
110
+ catch (error) {
111
+ if (error instanceof ResolverVendorUnavailableError)
112
+ throw error;
113
+ throw new ResolverVendorUnavailableError(TWOCAPTCHA_VENDOR_ID, "transport_failure", {
114
+ cause: error,
115
+ phase,
116
+ });
117
+ }
118
+ let payload;
119
+ try {
120
+ payload = JSON.parse(responseText);
121
+ }
122
+ catch {
123
+ // JSON parse errors may contain response-body excerpts, so do not retain them as causes.
124
+ throw new ResolverVendorUnavailableError(TWOCAPTCHA_VENDOR_ID, "transport_failure", {
125
+ phase,
126
+ });
127
+ }
128
+ if (!isJsonRecord(payload)) {
129
+ throw new ResolverVendorUnavailableError(TWOCAPTCHA_VENDOR_ID, "transport_failure", {
130
+ phase,
131
+ });
132
+ }
133
+ return { ok: response.ok, payload };
134
+ }
135
+ function taskIdFrom(payload) {
136
+ const taskId = payload.taskId;
137
+ return typeof taskId === "string" || typeof taskId === "number" ? taskId : undefined;
138
+ }
139
+ function tokenFrom(payload) {
140
+ const solution = payload.solution;
141
+ if (!isJsonRecord(solution))
142
+ return undefined;
143
+ if (typeof solution.gRecaptchaResponse === "string")
144
+ return solution.gRecaptchaResponse;
145
+ return typeof solution.token === "string" ? solution.token : undefined;
146
+ }
147
+ function endpoint(baseUrl, path) {
148
+ return `${baseUrl.replace(/\/+$/u, "")}/${path}`;
149
+ }
150
+ export function createTwoCaptchaResolverVendorAdapter(options) {
151
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
152
+ const baseUrl = options.baseUrl ?? DEFAULT_TWOCAPTCHA_BASE_URL;
153
+ const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
154
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
155
+ const now = options.now ?? Date.now;
156
+ const delay = options.delay ?? abortableDelay;
157
+ return {
158
+ id: TWOCAPTCHA_VENDOR_ID,
159
+ supports(kind) {
160
+ return resolverVendorSupports(TWOCAPTCHA_VENDOR_ID, kind);
161
+ },
162
+ async solve(challenge, identity, callerSignal) {
163
+ const apiKey = options.apiKey?.trim();
164
+ if (!apiKey) {
165
+ throw new ResolverVendorUnavailableError(TWOCAPTCHA_VENDOR_ID, "missing_credentials", {
166
+ phase: "create_task",
167
+ });
168
+ }
169
+ if (!resolverVendorSupports(TWOCAPTCHA_VENDOR_ID, challenge.kind)) {
170
+ throw new TypeError(`2captcha resolver does not support ${challenge.kind}`);
171
+ }
172
+ if (challenge.kind !== "recaptcha_v2") {
173
+ // AWS WAF remains deferred because its challenge variant has no required site key.
174
+ throw new ResolverVendorUnavailableError(TWOCAPTCHA_VENDOR_ID, "not_implemented", {
175
+ phase: "create_task",
176
+ });
177
+ }
178
+ assertResolverHostAllowed(challenge.pageUrl, options.allowedHosts);
179
+ callerSignal.throwIfAborted();
180
+ const proxy = identity ? parseProxyConfiguration(identity.proxyUrl) : undefined;
181
+ if (identity && !proxy) {
182
+ throw new ResolverVendorUnavailableError(TWOCAPTCHA_VENDOR_ID, "transport_failure", {
183
+ phase: "create_task",
184
+ });
185
+ }
186
+ const solveController = new AbortController();
187
+ const onCallerAbort = () => solveController.abort(abortReason(callerSignal));
188
+ callerSignal.addEventListener("abort", onCallerAbort, { once: true });
189
+ const timeout = options.now
190
+ ? undefined
191
+ : setTimeout(() => solveController.abort(new TwoCaptchaSolveTimeoutError()), timeoutMs);
192
+ const startedAt = now();
193
+ let phase = "create_task";
194
+ try {
195
+ const createResult = await postJson(fetchImpl, endpoint(baseUrl, "createTask"), {
196
+ clientKey: apiKey,
197
+ task: {
198
+ type: proxy ? "RecaptchaV2Task" : "RecaptchaV2TaskProxyless",
199
+ websiteURL: challenge.pageUrl,
200
+ websiteKey: challenge.siteKey,
201
+ isInvisible: false,
202
+ ...(identity ? { userAgent: identity.userAgent } : {}),
203
+ ...(proxy ?? {}),
204
+ },
205
+ }, solveController.signal, phase);
206
+ const taskId = taskIdFrom(createResult.payload);
207
+ if (!createResult.ok || createResult.payload.errorId !== 0 || taskId === undefined) {
208
+ throw unavailableForPayload(createResult.payload, phase);
209
+ }
210
+ phase = "poll_result";
211
+ while (true) {
212
+ callerSignal.throwIfAborted();
213
+ const remainingMs = timeoutMs - (now() - startedAt);
214
+ if (remainingMs <= 0)
215
+ throw new TwoCaptchaSolveTimeoutError();
216
+ await delay(Math.min(pollIntervalMs, remainingMs), solveController.signal);
217
+ callerSignal.throwIfAborted();
218
+ if (now() - startedAt >= timeoutMs)
219
+ throw new TwoCaptchaSolveTimeoutError();
220
+ const pollResult = await postJson(fetchImpl, endpoint(baseUrl, "getTaskResult"), { clientKey: apiKey, taskId }, solveController.signal, phase);
221
+ if (!pollResult.ok || pollResult.payload.errorId !== 0) {
222
+ throw unavailableForPayload(pollResult.payload, phase);
223
+ }
224
+ if (pollResult.payload.status === "processing")
225
+ continue;
226
+ if (pollResult.payload.status !== "ready") {
227
+ throw new ResolverVendorUnavailableError(TWOCAPTCHA_VENDOR_ID, "transport_failure", {
228
+ phase,
229
+ });
230
+ }
231
+ const token = tokenFrom(pollResult.payload);
232
+ if (!token?.trim()) {
233
+ throw new ResolverVendorUnavailableError(TWOCAPTCHA_VENDOR_ID, "transport_failure", {
234
+ phase,
235
+ });
236
+ }
237
+ return { form: "token", token };
238
+ }
239
+ }
240
+ catch (error) {
241
+ if (callerSignal.aborted)
242
+ throw abortReason(callerSignal);
243
+ if (error instanceof TwoCaptchaSolveTimeoutError ||
244
+ solveController.signal.reason instanceof TwoCaptchaSolveTimeoutError) {
245
+ throw new ResolverVendorUnavailableError(TWOCAPTCHA_VENDOR_ID, "timeout", {
246
+ cause: error,
247
+ phase,
248
+ });
249
+ }
250
+ if (error instanceof ResolverVendorUnavailableError)
251
+ throw error;
252
+ throw new ResolverVendorUnavailableError(TWOCAPTCHA_VENDOR_ID, "transport_failure", {
253
+ cause: error,
254
+ phase,
255
+ });
256
+ }
257
+ finally {
258
+ if (timeout !== undefined)
259
+ clearTimeout(timeout);
260
+ callerSignal.removeEventListener("abort", onCallerAbort);
261
+ }
262
+ },
263
+ };
264
+ }