@apifuse/provider-sdk 2.2.0-beta.46 → 2.2.0-beta.48

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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # @apifuse/provider-sdk Changelog
2
2
 
3
+ ## 2.2.0-beta.48
4
+
5
+ - Release candidate for main commit ba6636fa8dd53a35af3091ae6efd797f5ef3d836.
6
+
7
+ ## 2.2.0-beta.47
8
+
9
+ - Release candidate for main commit 0ff0c6c90153af3aab75978eb73040c9adaa2609.
10
+
3
11
  ## 2.2.0-beta.46
4
12
 
5
13
  - Release candidate for main commit 0340fbc51b4ad71689bb1ec61b9b818dedda4587.
@@ -4312,6 +4312,7 @@ function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
4312
4312
  const operations = Object.entries(provider.operations);
4313
4313
  const missing: string[] = [];
4314
4314
  const vacuous: string[] = [];
4315
+ const evidenceFreeScenario: string[] = [];
4315
4316
  const placeholder: string[] = [];
4316
4317
  const unsupported: string[] = [];
4317
4318
  const generatedStarter: string[] = [];
@@ -4323,8 +4324,13 @@ function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
4323
4324
  missing.push(operationId);
4324
4325
  continue;
4325
4326
  }
4326
- if (hasCheck && !hasUnsupported && hasOnlyVacuousHealthCases(operation.healthCheck)) {
4327
- vacuous.push(operationId);
4327
+ if (hasCheck && !hasUnsupported) {
4328
+ const vacuousClass = vacuousHealthCaseClass(operation.healthCheck);
4329
+ if (vacuousClass === "empty-assertions") {
4330
+ vacuous.push(operationId);
4331
+ } else if (vacuousClass === "evidence-free-scenario") {
4332
+ evidenceFreeScenario.push(operationId);
4333
+ }
4328
4334
  }
4329
4335
  if (hasUnsupported) {
4330
4336
  const reason = operation.healthCheckUnsupported?.reason ?? "";
@@ -4351,14 +4357,36 @@ function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
4351
4357
  );
4352
4358
  }
4353
4359
 
4354
- if (vacuous.length > 0) {
4360
+ if (vacuous.length > 0 || evidenceFreeScenario.length > 0) {
4361
+ const remediationParts: string[] = [];
4362
+ if (vacuous.length > 0) {
4363
+ remediationParts.push(
4364
+ `healthCheck.assertions for ${vacuous.join(", ")} is empty — assert on status and response shape (e.g. throw or return {status:'degraded'} when the upstream contract breaks), or declare healthCheckUnsupported with a specific reason if the operation genuinely cannot be probed.`,
4365
+ );
4366
+ }
4367
+ if (evidenceFreeScenario.length > 0) {
4368
+ remediationParts.push(
4369
+ `healthCheck scenario for ${evidenceFreeScenario.join(", ")} asserts nothing beyond transport — add an assert step whose expression reads the probe response (a field under data, an extracted value, or a quantifier over response rows), not just status_2xx or a bare response-envelope check.`,
4370
+ );
4371
+ }
4372
+ const message =
4373
+ evidenceFreeScenario.length === 0
4374
+ ? "One or more operations have healthCheck cases with empty assertions."
4375
+ : vacuous.length === 0
4376
+ ? "One or more operations have healthCheck scenarios without response evidence."
4377
+ : "One or more operations have healthCheck cases with empty assertions or evidence-free scenarios.";
4355
4378
  return blocker(
4356
4379
  "health-coverage",
4357
4380
  "health",
4358
- "One or more operations have healthCheck cases with empty assertions.",
4359
- `healthCheck.assertions for ${vacuous.join(", ")} is empty — assert on status and response shape (e.g. throw or return {status:'degraded'} when the upstream contract breaks), or declare healthCheckUnsupported with a specific reason if the operation genuinely cannot be probed.`,
4381
+ message,
4382
+ remediationParts.join(" "),
4360
4383
  CATEGORY_MAX_POINTS.health,
4361
- vacuous.map((operationId) => `${operationId}: empty healthCheck.assertions`),
4384
+ [
4385
+ ...vacuous.map((operationId) => `${operationId}: empty healthCheck.assertions`),
4386
+ ...evidenceFreeScenario.map(
4387
+ (operationId) => `${operationId}: healthCheck scenario asserts nothing beyond transport`,
4388
+ ),
4389
+ ],
4362
4390
  );
4363
4391
  }
4364
4392
 
@@ -4413,14 +4441,216 @@ function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
4413
4441
  );
4414
4442
  }
4415
4443
 
4416
- function hasOnlyVacuousHealthCases(
4444
+ type VacuousHealthCaseClass = "empty-assertions" | "evidence-free-scenario";
4445
+
4446
+ /**
4447
+ * Classify an operation whose every health case is vacuous. Returns undefined
4448
+ * when at least one case carries real coverage: an imperative case with a
4449
+ * non-vacuous assertions function, or a declarative case whose scenario reads
4450
+ * the probe response. When all cases are vacuous, the class picks the blocker
4451
+ * wording — "empty-assertions" whenever any vacuous case is imperative (the
4452
+ * pre-scenario behavior, kept byte-identical), "evidence-free-scenario" when
4453
+ * the suite is scenario-only.
4454
+ */
4455
+ function vacuousHealthCaseClass(
4417
4456
  healthCheck: ProviderDefinition["operations"][string]["healthCheck"],
4418
- ): boolean {
4457
+ ): VacuousHealthCaseClass | undefined {
4419
4458
  const cases = healthCheck?.cases;
4420
4459
  if (!Array.isArray(cases) || cases.length === 0) {
4460
+ return "empty-assertions";
4461
+ }
4462
+ let sawImperativeCase = false;
4463
+ for (const healthCase of cases) {
4464
+ if (healthCase?.scenario !== undefined) {
4465
+ if (scenarioCarriesResponseEvidence(healthCase.scenario)) {
4466
+ return undefined;
4467
+ }
4468
+ continue;
4469
+ }
4470
+ sawImperativeCase = true;
4471
+ if (!isVacuousAssertionFunction(healthCase?.assertions)) {
4472
+ return undefined;
4473
+ }
4474
+ }
4475
+ return sawImperativeCase ? "empty-assertions" : "evidence-free-scenario";
4476
+ }
4477
+
4478
+ /**
4479
+ * A declarative case (`scenario`) carries real coverage when at least one
4480
+ * `assert` step reads the probe response beyond the transport envelope. The
4481
+ * SDK's own definitions drive this judgement:
4482
+ *
4483
+ * - The assert step is the scenario's only verdict-bearing construct
4484
+ * (`AssertResult.passed`). A guard can only attribute
4485
+ * degraded/expected_absence and stop the run (an escape hatch, not a health
4486
+ * verdict), and an operation step does not intrinsically fail on a non-2xx
4487
+ * response — that is what the explicit `status_2xx` predicate exists for. So
4488
+ * a scenario without a semantic assert step proves nothing beyond the
4489
+ * operation merely executing, which is the same emptiness the imperative
4490
+ * vacuity check flags.
4491
+ * - Leaves that cannot observe upstream content carry no evidence: predicates
4492
+ * whose operands read nothing produced by a step (pure literals,
4493
+ * attempt/credential metadata, and the `status_code`/`request_id`/`kind`
4494
+ * envelope fields of a step result — this is what makes the legacy transport
4495
+ * pin `status_2xx` on `["status_code"]` non-evidence), predicates comparing
4496
+ * a reference against an identical copy of itself (`equals(x, x)` holds for
4497
+ * any response), and positive-polarity presence/type pins (`exists`,
4498
+ * `type_is`, `not(not_exists(...))`, zero-bound `array_length_gte`) on the
4499
+ * bare `data` root — the declarative spellings of the legacy transport-only
4500
+ * assertion (`status === 200` + `typeof data === "object"`), satisfied by
4501
+ * any well-formed response body. Polarity matters: `not(exists(data))`
4502
+ * ("this probe must return no body") is falsified by ordinary responses, so
4503
+ * the carve-out only applies where the pin appears un-negated.
4504
+ * - Everything else counts: paths under `data`, extract-step results,
4505
+ * `duration_ms` (parity with imperative `ctx.durationMs` reads),
4506
+ * `status_2xx` applied to a body path (envelope-style upstreams answer HTTP
4507
+ * 200 and carry the real code in the body), and quantifiers, which iterate
4508
+ * response rows by construction.
4509
+ *
4510
+ * Fail-open contract (same as isVacuousAssertionFunction): the provider module
4511
+ * already validated this scenario against HealthScenarioSchema in its own SDK
4512
+ * at import time, so any shape this walk does not recognize (unknown step
4513
+ * kind, expression kind, operator, or reference namespace) indicates a newer
4514
+ * grammar, not an empty probe — treat it as evidence rather than flag it.
4515
+ * The inverse direction is accepted and documented: a determined author can
4516
+ * still fabricate a read that never fails (a decorative deep path inside a
4517
+ * disjunction, a degenerate bound). That effort is comparable to writing the
4518
+ * real one-line assertion — the same accepted limitation as the imperative
4519
+ * check's decorative-ctx-read — and the live `--smoke` probe remains the
4520
+ * runtime defense.
4521
+ */
4522
+ export function scenarioCarriesResponseEvidence(scenario: unknown): boolean {
4523
+ if (!isRecord(scenario) || !Array.isArray(scenario.steps)) {
4524
+ return true;
4525
+ }
4526
+ let sawUnknownStepKind = false;
4527
+ for (const step of scenario.steps) {
4528
+ if (!isRecord(step)) {
4529
+ sawUnknownStepKind = true;
4530
+ continue;
4531
+ }
4532
+ if (step.kind === "assert") {
4533
+ if (expressionReadsResponse(step.expression)) {
4534
+ return true;
4535
+ }
4536
+ continue;
4537
+ }
4538
+ if (step.kind !== "operation" && step.kind !== "extract" && step.kind !== "guard") {
4539
+ sawUnknownStepKind = true;
4540
+ }
4541
+ }
4542
+ return sawUnknownStepKind;
4543
+ }
4544
+
4545
+ /**
4546
+ * Predicate operators of the current scenario grammar. An operator outside
4547
+ * this set indicates a newer grammar and fails open (counts as evidence),
4548
+ * because a future operator may source the response through fields this walk
4549
+ * does not know about.
4550
+ */
4551
+ const KNOWN_SCENARIO_PREDICATE_OPERATORS: ReadonlySet<string> = new Set([
4552
+ "exists",
4553
+ "not_exists",
4554
+ "non_empty",
4555
+ "is_true",
4556
+ "equals",
4557
+ "not_equals",
4558
+ "contains",
4559
+ "matches",
4560
+ "number_gt",
4561
+ "number_gte",
4562
+ "number_lt",
4563
+ "number_lte",
4564
+ "array_length_eq",
4565
+ "array_length_gte",
4566
+ "array_length_lte",
4567
+ "status_2xx",
4568
+ "type_is",
4569
+ ]);
4570
+
4571
+ function expressionReadsResponse(expression: unknown, negationDepth = 0): boolean {
4572
+ if (!isRecord(expression)) {
4421
4573
  return true;
4422
4574
  }
4423
- return cases.every((healthCase) => isVacuousAssertionFunction(healthCase?.assertions));
4575
+ if (expression.kind === "all" || expression.kind === "any") {
4576
+ return Array.isArray(expression.clauses)
4577
+ ? expression.clauses.some((clause) => expressionReadsResponse(clause, negationDepth))
4578
+ : true;
4579
+ }
4580
+ if (expression.kind === "not") {
4581
+ return expressionReadsResponse(expression.clause, negationDepth + 1);
4582
+ }
4583
+ if (expression.kind === "quantifier") {
4584
+ return true;
4585
+ }
4586
+ if (expression.kind !== "predicate" || typeof expression.operator !== "string") {
4587
+ return true;
4588
+ }
4589
+ const operator = expression.operator;
4590
+ if (!KNOWN_SCENARIO_PREDICATE_OPERATORS.has(operator)) {
4591
+ return true;
4592
+ }
4593
+ const actualReads = operandReadsStepOutput(expression.actual);
4594
+ const expectedReads = operandReadsStepOutput(expression.expected);
4595
+ if (!actualReads && !expectedReads) {
4596
+ return false;
4597
+ }
4598
+ // A predicate comparing a reference against an identical copy of itself
4599
+ // (equals(x, x) and friends) holds or fails identically for every response,
4600
+ // so the reads discriminate nothing.
4601
+ if (
4602
+ actualReads &&
4603
+ expectedReads &&
4604
+ JSON.stringify(expression.actual) === JSON.stringify(expression.expected)
4605
+ ) {
4606
+ return false;
4607
+ }
4608
+ // Presence/type pins on the whole `data` root are satisfied by any
4609
+ // well-formed JSON body — but only in positive polarity. Under an odd
4610
+ // number of `not` wrappers the same pin means "this probe must return no
4611
+ // body / a different shape", which ordinary responses falsify, so it is a
4612
+ // real (if unusual) constraint and stays evidence.
4613
+ const positivePolarity = negationDepth % 2 === 0;
4614
+ const bareEnvelopePin = positivePolarity
4615
+ ? operator === "exists" ||
4616
+ operator === "type_is" ||
4617
+ (operator === "array_length_gte" && expression.expected === 0)
4618
+ : operator === "not_exists";
4619
+ if (bareEnvelopePin && !expectedReads && isBareResponseEnvelope(expression.actual)) {
4620
+ return false;
4621
+ }
4622
+ return true;
4623
+ }
4624
+
4625
+ function operandReadsStepOutput(operand: unknown): boolean {
4626
+ if (!isRecord(operand) || !isRecord(operand.ref)) {
4627
+ return false;
4628
+ }
4629
+ const reference = operand.ref;
4630
+ if (reference.namespace === "attempt" || reference.namespace === "credentials") {
4631
+ return false;
4632
+ }
4633
+ if (reference.namespace !== "steps") {
4634
+ return true;
4635
+ }
4636
+ if (!Array.isArray(reference.path) || reference.path.length === 0) {
4637
+ return true;
4638
+ }
4639
+ const head = reference.path[0];
4640
+ return head !== "status_code" && head !== "request_id" && head !== "kind";
4641
+ }
4642
+
4643
+ function isBareResponseEnvelope(operand: unknown): boolean {
4644
+ if (!isRecord(operand) || !isRecord(operand.ref)) {
4645
+ return false;
4646
+ }
4647
+ const reference = operand.ref;
4648
+ return (
4649
+ reference.namespace === "steps" &&
4650
+ Array.isArray(reference.path) &&
4651
+ reference.path.length === 1 &&
4652
+ reference.path[0] === "data"
4653
+ );
4424
4654
  }
4425
4655
 
4426
4656
  function isVacuousAssertionFunction(assertions: unknown): boolean {
@@ -1 +1 @@
1
- export { APIFUSE__CDP_POOL__URL, APIFUSE__RESOLVER__2CAPTCHA__API_KEY, APIFUSE__RESOLVER__CAPMONSTER__API_KEY, APIFUSE__RESOLVER__CAPSOLVER__API_KEY, APIFUSE__RESOLVER__TIMEOUT_MS, bindResolverSignal, createResolverClient, createResolverClientFromEnv, createUnsupportedResolverClient, DEFAULT_RESOLVER_VENDOR_PREFERENCE, DEFAULT_RESOLVER_TIMEOUT_MS, invalidateResolverSolution, RESOLVER_ADAPTER_REGISTRY, RESOLVER_INSTRUMENTATION_METADATA, resolveProviderResolverVendors, type ResolverAdapterFactory, type ResolverInstrumentationMetadata, type ResolverRuntimeOptions, } from "./resolver.js";
1
+ export { APIFUSE__CDP_POOL__URL, APIFUSE__RESOLVER__2CAPTCHA__API_KEY, APIFUSE__RESOLVER__CAPMONSTER__API_KEY, APIFUSE__RESOLVER__CAPSOLVER__API_KEY, APIFUSE__RESOLVER__TIMEOUT_MS, bindResolverSignal, createResolverClient, createResolverClientFromEnv, createUnsupportedResolverClient, DEFAULT_RESOLVER_VENDOR_PREFERENCE, DEFAULT_RESOLVER_TIMEOUT_MS, getResolverSolutionSource, invalidateCachedResolverSolution, invalidateResolverSolution, RESOLVER_ADAPTER_REGISTRY, RESOLVER_INSTRUMENTATION_METADATA, resolveProviderResolverVendors, type ResolverAdapterFactory, type ResolverInstrumentationMetadata, type ResolverRuntimeOptions, type ResolverSolutionSource, } from "./resolver.js";
@@ -1 +1 @@
1
- export { APIFUSE__CDP_POOL__URL, APIFUSE__RESOLVER__2CAPTCHA__API_KEY, APIFUSE__RESOLVER__CAPMONSTER__API_KEY, APIFUSE__RESOLVER__CAPSOLVER__API_KEY, APIFUSE__RESOLVER__TIMEOUT_MS, bindResolverSignal, createResolverClient, createResolverClientFromEnv, createUnsupportedResolverClient, DEFAULT_RESOLVER_VENDOR_PREFERENCE, DEFAULT_RESOLVER_TIMEOUT_MS, invalidateResolverSolution, RESOLVER_ADAPTER_REGISTRY, RESOLVER_INSTRUMENTATION_METADATA, resolveProviderResolverVendors, } from "./resolver.js";
1
+ export { APIFUSE__CDP_POOL__URL, APIFUSE__RESOLVER__2CAPTCHA__API_KEY, APIFUSE__RESOLVER__CAPMONSTER__API_KEY, APIFUSE__RESOLVER__CAPSOLVER__API_KEY, APIFUSE__RESOLVER__TIMEOUT_MS, bindResolverSignal, createResolverClient, createResolverClientFromEnv, createUnsupportedResolverClient, DEFAULT_RESOLVER_VENDOR_PREFERENCE, DEFAULT_RESOLVER_TIMEOUT_MS, getResolverSolutionSource, invalidateCachedResolverSolution, invalidateResolverSolution, RESOLVER_ADAPTER_REGISTRY, RESOLVER_INSTRUMENTATION_METADATA, resolveProviderResolverVendors, } from "./resolver.js";
@@ -1,3 +1,14 @@
1
- import type { ResolverContext } from "../types.js";
1
+ import type { ChallengeSolution, ProviderChallenge, ResolverContext } from "../types.js";
2
+ import type { TraceRecorder } from "./trace.js";
2
3
  export declare const RESOLVER_INSTRUMENTATION_METADATA: unique symbol;
4
+ /**
5
+ * Internal solve surface. The instrumentation layer threads its recorder as a
6
+ * third argument so vendor-level spans (`resolver.vendor.*`) attach to the
7
+ * active trace. Every wrapper that re-exposes `solve` must forward the extra
8
+ * arguments; a wrapper typed against the public two-argument `ResolverContext`
9
+ * silently drops the recorder and vendor spans vanish.
10
+ */
11
+ export type ResolverSolveWithRecorder = {
12
+ solve(challenge: ProviderChallenge, signal?: AbortSignal, traceRecorder?: TraceRecorder): Promise<ChallengeSolution>;
13
+ };
3
14
  export declare function createUnsupportedResolverClient(reason?: string): ResolverContext;
@@ -7,6 +7,11 @@ const CAPSOLVER_VENDOR_ID = "capsolver";
7
7
  const DEFAULT_CAPSOLVER_BASE_URL = "https://api.capsolver.com";
8
8
  const DEFAULT_POLL_INTERVAL_MS = 2_000;
9
9
  const DEFAULT_TIMEOUT_MS = 120_000;
10
+ const SDK_ESTIMATED_COOKIE_TTL_MS_BY_CHALLENGE_KIND = {
11
+ // CapSolver omits expiry, so use one conservative hour despite measured
12
+ // AWS WAF lifetimes of days.
13
+ aws_waf: 60 * 60 * 1_000,
14
+ };
10
15
  class CapsolverSolveTimeoutError extends Error {
11
16
  constructor() {
12
17
  super("Capsolver resolver solve budget elapsed");
@@ -483,6 +488,7 @@ export function createCapsolverResolverVendorAdapter(options) {
483
488
  form: "cookies",
484
489
  cookies: { "aws-waf-token": solutionValue },
485
490
  userAgent: identity?.userAgent ?? getStealthProfile(DEFAULT_PROFILE).userAgent,
491
+ sdkEstimatedExpires: (now() + SDK_ESTIMATED_COOKIE_TTL_MS_BY_CHALLENGE_KIND.aws_waf) / 1_000,
486
492
  }
487
493
  : { form: "token", token: solutionValue };
488
494
  }
@@ -365,6 +365,7 @@ export function createTwoCaptchaResolverVendorAdapter(options) {
365
365
  if (!token?.trim()) {
366
366
  throw new ResolverVendorUnavailableError(TWOCAPTCHA_VENDOR_ID, "transport_failure", { phase });
367
367
  }
368
+ // AWS WAF remains a token solution here, so resolver cookie caching does not apply.
368
369
  return { form: "token", token };
369
370
  }
370
371
  };
@@ -31,6 +31,7 @@ export interface ResolverRuntimeOptions {
31
31
  readonly identityScope?: string;
32
32
  }) => ResolverVendorTransport;
33
33
  }
34
+ export type ResolverSolutionSource = "cache" | "vendor";
34
35
  export type ResolverInstrumentationMetadata = {
35
36
  readonly target: ResolverContext;
36
37
  readonly traceRecorder: TraceRecorder;
@@ -40,8 +41,19 @@ export declare const RESOLVER_ADAPTER_REGISTRY: Partial<Readonly<Record<Provider
40
41
  export declare function swapResolverAdapterFactoryForTests(vendor: ProviderResolverVendor, factory: ResolverAdapterFactory | undefined): () => void;
41
42
  /** Internal test seam; deliberately not re-exported from the package root. */
42
43
  export declare function swapResolverDefaultUserAgentForTests(resolver: (() => string | undefined) | undefined): () => void;
44
+ /**
45
+ * Identify whether this exact SDK-returned solution object came from the cache
46
+ * or a vendor solve. Returns undefined for copied or caller-created objects.
47
+ */
48
+ export declare function getResolverSolutionSource(solution: ChallengeSolution): ResolverSolutionSource | undefined;
43
49
  /** Remove the cached entry for the exact solution object returned by this resolver. */
44
50
  export declare function invalidateResolverSolution(resolver: ResolverContext, challenge: ProviderChallenge, solution: ChallengeSolution): Promise<void>;
51
+ /**
52
+ * Remove a solution only when it was returned from the resolver cache. Providers
53
+ * should call this when an upstream serves the same challenge after a cached
54
+ * solution was applied, so the next `solve()` mints a fresh solution.
55
+ */
56
+ export declare function invalidateCachedResolverSolution(resolver: ResolverContext, challenge: ProviderChallenge, solution: ChallengeSolution): Promise<boolean>;
45
57
  export declare function createResolverClient(options: {
46
58
  readonly kinds: readonly ProviderChallengeKind[];
47
59
  readonly adapters: readonly ResolverVendorAdapter[];
@@ -19,6 +19,7 @@ const RESOLVER_SOLUTION_INDEX_CACHE_NAMESPACE = "resolver-solution-index";
19
19
  const MIN_RESOLVER_CACHE_TTL_MS = 1_000;
20
20
  const resolverCaches = new WeakMap();
21
21
  const solutionIssuerDigests = new WeakMap();
22
+ const resolverSolutionSources = new WeakMap();
22
23
  const SAFE_CAUSE_MESSAGE_WORDS = new Set([
23
24
  "abort",
24
25
  "aborted",
@@ -376,7 +377,7 @@ function isResolverCacheIndex(value) {
376
377
  function solutionExpiryMs(solution) {
377
378
  if (solution.form !== "cookies")
378
379
  return undefined;
379
- const expires = solution.expires;
380
+ const expires = solution.expires ?? solution.sdkEstimatedExpires;
380
381
  if (typeof expires !== "number" || !Number.isFinite(expires))
381
382
  return undefined;
382
383
  return expires * 1_000;
@@ -386,14 +387,33 @@ function rememberSolutionIssuer(solution, issuerDigest) {
386
387
  solutionIssuerDigests.set(solution, issuerDigest);
387
388
  }
388
389
  }
390
+ function rememberSolutionSource(solution, source) {
391
+ if (typeof solution === "object" && solution !== null) {
392
+ resolverSolutionSources.set(solution, source);
393
+ }
394
+ }
395
+ /**
396
+ * Identify whether this exact SDK-returned solution object came from the cache
397
+ * or a vendor solve. Returns undefined for copied or caller-created objects.
398
+ */
399
+ export function getResolverSolutionSource(solution) {
400
+ return resolverSolutionSources.get(solution);
401
+ }
389
402
  async function readCachedSolution(cache, challenge, issuerDigest, now) {
390
403
  const cached = await cache.get(resolverSolutionCacheKey(cache, challenge, issuerDigest));
391
404
  if (!cached || !isCachedResolverSolution(cached.value))
392
405
  return undefined;
393
406
  if (cached.value.issuerDigest !== issuerDigest || cached.value.expiresAtMs <= now)
394
407
  return undefined;
395
- rememberSolutionIssuer(cached.value.solution, issuerDigest);
396
- return cached.value.solution;
408
+ if (cached.value.solution.form !== "cookies")
409
+ return undefined;
410
+ const solution = {
411
+ ...cached.value.solution,
412
+ cookies: { ...cached.value.solution.cookies },
413
+ };
414
+ rememberSolutionIssuer(solution, issuerDigest);
415
+ rememberSolutionSource(solution, "cache");
416
+ return solution;
397
417
  }
398
418
  async function findCachedSolution(cache, challenge, identity, identityScope) {
399
419
  const now = Date.now();
@@ -457,8 +477,7 @@ async function cacheResolverSolution(cache, challenge, solution, identity, ident
457
477
  { direct: true, expiresAtMs, issuerDigest },
458
478
  ], now);
459
479
  }
460
- /** Remove the cached entry for the exact solution object returned by this resolver. */
461
- export async function invalidateResolverSolution(resolver, challenge, solution) {
480
+ async function invalidateResolverSolutionWithOutcome(resolver, challenge, solution) {
462
481
  const metadata = resolver[RESOLVER_INSTRUMENTATION_METADATA];
463
482
  const cacheOwner = metadata?.target ?? resolver;
464
483
  const invalidate = async () => {
@@ -484,14 +503,28 @@ export async function invalidateResolverSolution(resolver, challenge, solution)
484
503
  return "index_entry_deleted";
485
504
  };
486
505
  if (!metadata) {
487
- await invalidate();
488
- return;
506
+ return await invalidate();
489
507
  }
490
- await metadata.traceRecorder.runSpan("resolver.cache.invalidate", invalidate, {
508
+ return await metadata.traceRecorder.runSpan("resolver.cache.invalidate", invalidate, {
491
509
  attributes: { challenge_kind: challenge.kind },
492
510
  onSuccess: (outcome) => ({ outcome }),
493
511
  });
494
512
  }
513
+ /** Remove the cached entry for the exact solution object returned by this resolver. */
514
+ export async function invalidateResolverSolution(resolver, challenge, solution) {
515
+ await invalidateResolverSolutionWithOutcome(resolver, challenge, solution);
516
+ }
517
+ /**
518
+ * Remove a solution only when it was returned from the resolver cache. Providers
519
+ * should call this when an upstream serves the same challenge after a cached
520
+ * solution was applied, so the next `solve()` mints a fresh solution.
521
+ */
522
+ export async function invalidateCachedResolverSolution(resolver, challenge, solution) {
523
+ if (getResolverSolutionSource(solution) !== "cache")
524
+ return false;
525
+ const outcome = await invalidateResolverSolutionWithOutcome(resolver, challenge, solution);
526
+ return outcome === "entry_deleted" || outcome === "index_entry_deleted";
527
+ }
495
528
  async function resolveResolverIdentity(proxyIntent) {
496
529
  const userAgentSource = proxyIntent.userAgent ? "declared" : "defaulted";
497
530
  let proxyUrl;
@@ -618,6 +651,7 @@ function createResolverChainClient(options) {
618
651
  await cacheResolverSolution(options.cache, challenge, solution, issuingIdentity, options.identityScope);
619
652
  }
620
653
  }
654
+ rememberSolutionSource(solution, "vendor");
621
655
  return solution;
622
656
  }
623
657
  catch (error) {
@@ -682,8 +716,8 @@ export function bindResolverSignal(resolver, defaultSignal) {
682
716
  if (!defaultSignal)
683
717
  return resolver;
684
718
  const boundResolver = {
685
- solve(challenge, signal = defaultSignal) {
686
- return resolver.solve(challenge, signal);
719
+ solve(challenge, signal = defaultSignal, traceRecorder) {
720
+ return resolver.solve(challenge, signal, traceRecorder);
687
721
  },
688
722
  };
689
723
  if (resolverCaches.has(resolver)) {
@@ -24,7 +24,7 @@ import { getProviderBaseUrl } from "../runtime/provider.js";
24
24
  import { createOcrClientFromEnv } from "../runtime/ocr.js";
25
25
  import { PROXY_AUTH_IP_DENIED_CODE, PROXY_EDGE_AUTH_REJECTED_CODE, PROXY_POOL_EXHAUSTED_CODE, } from "../runtime/proxy-errors.js";
26
26
  import { PROVIDER_TELEMETRY_HEADER, ProxyTelemetryCollector, } from "../runtime/proxy-telemetry.js";
27
- import { createUnsupportedResolverClient } from "../runtime/resolver-shared.js";
27
+ import { createUnsupportedResolverClient, } from "../runtime/resolver-shared.js";
28
28
  import { assertRequiredSecretsPresent, listMissingRequiredSecrets, MISSING_SECRET_CODE, } from "../runtime/secrets.js";
29
29
  import { createProviderRuntimeStateFromEnv, createUnsupportedProviderRuntimeState, } from "../runtime/state.js";
30
30
  import { StealthCookieJar } from "../runtime/stealth-cookies.js";
@@ -291,8 +291,10 @@ function bindResolverSignalWithoutRuntime(resolver, defaultSignal) {
291
291
  if (!defaultSignal)
292
292
  return resolver;
293
293
  return {
294
- solve(challenge, signal = defaultSignal) {
295
- return resolver.solve(challenge, signal);
294
+ solve(challenge, signal = defaultSignal, traceRecorder) {
295
+ // Forward the instrumentation trace recorder so resolver.vendor.* spans
296
+ // survive this wrapper. See ResolverSolveWithRecorder in resolver-shared.ts.
297
+ return resolver.solve(challenge, signal, traceRecorder);
296
298
  },
297
299
  };
298
300
  }
package/dist/types.d.ts CHANGED
@@ -338,6 +338,11 @@ export type ChallengeSolution = {
338
338
  readonly userAgent: string;
339
339
  /** Epoch seconds copied from the upstream cookie's own expiry attribute; never a constant. */
340
340
  readonly expires?: number;
341
+ /**
342
+ * Epoch seconds conservatively estimated by the SDK when a resolver vendor omits
343
+ * the upstream cookie's expiry. `expires` takes precedence when both are present.
344
+ */
345
+ readonly sdkEstimatedExpires?: number;
341
346
  };
342
347
  export interface ProviderResolverConfig {
343
348
  /** Optional ordered override for the SDK-owned vendor fallback chain. */
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.2.0-beta.46",
2
+ "version": "2.2.0-beta.48",
3
3
  "name": "@apifuse/provider-sdk",
4
4
  "private": false,
5
5
  "type": "module",
@@ -10,6 +10,8 @@ export {
10
10
  createUnsupportedResolverClient,
11
11
  DEFAULT_RESOLVER_VENDOR_PREFERENCE,
12
12
  DEFAULT_RESOLVER_TIMEOUT_MS,
13
+ getResolverSolutionSource,
14
+ invalidateCachedResolverSolution,
13
15
  invalidateResolverSolution,
14
16
  RESOLVER_ADAPTER_REGISTRY,
15
17
  RESOLVER_INSTRUMENTATION_METADATA,
@@ -17,4 +19,5 @@ export {
17
19
  type ResolverAdapterFactory,
18
20
  type ResolverInstrumentationMetadata,
19
21
  type ResolverRuntimeOptions,
22
+ type ResolverSolutionSource,
20
23
  } from "./resolver.js";
@@ -1,10 +1,26 @@
1
1
  import { ProviderError } from "../errors.js";
2
- import type { ResolverContext } from "../types.js";
2
+ import type { ChallengeSolution, ProviderChallenge, ResolverContext } from "../types.js";
3
+ import type { TraceRecorder } from "./trace.js";
3
4
 
4
5
  export const RESOLVER_INSTRUMENTATION_METADATA = Symbol.for(
5
6
  "@apifuse/provider-sdk/runtime/resolver-instrumentation-metadata",
6
7
  );
7
8
 
9
+ /**
10
+ * Internal solve surface. The instrumentation layer threads its recorder as a
11
+ * third argument so vendor-level spans (`resolver.vendor.*`) attach to the
12
+ * active trace. Every wrapper that re-exposes `solve` must forward the extra
13
+ * arguments; a wrapper typed against the public two-argument `ResolverContext`
14
+ * silently drops the recorder and vendor spans vanish.
15
+ */
16
+ export type ResolverSolveWithRecorder = {
17
+ solve(
18
+ challenge: ProviderChallenge,
19
+ signal?: AbortSignal,
20
+ traceRecorder?: TraceRecorder,
21
+ ): Promise<ChallengeSolution>;
22
+ };
23
+
8
24
  export function createUnsupportedResolverClient(reason?: string): ResolverContext {
9
25
  return {
10
26
  async solve() {
@@ -1,5 +1,5 @@
1
1
  import { getStealthProfile } from "../../stealth/profiles.js";
2
- import type { ChallengeSolution, ProviderChallenge } from "../../types.js";
2
+ import type { ChallengeSolution, ProviderChallenge, ProviderChallengeKind } from "../../types.js";
3
3
  import { redactSensitiveText } from "../request-options.js";
4
4
  import { DEFAULT_PROFILE } from "../stealth.js";
5
5
  import type { TraceRecorder } from "../trace.js";
@@ -17,6 +17,11 @@ const CAPSOLVER_VENDOR_ID = "capsolver" as const;
17
17
  const DEFAULT_CAPSOLVER_BASE_URL = "https://api.capsolver.com";
18
18
  const DEFAULT_POLL_INTERVAL_MS = 2_000;
19
19
  const DEFAULT_TIMEOUT_MS = 120_000;
20
+ const SDK_ESTIMATED_COOKIE_TTL_MS_BY_CHALLENGE_KIND = {
21
+ // CapSolver omits expiry, so use one conservative hour despite measured
22
+ // AWS WAF lifetimes of days.
23
+ aws_waf: 60 * 60 * 1_000,
24
+ } satisfies Partial<Record<ProviderChallengeKind, number>>;
20
25
 
21
26
  type Delay = (ms: number, signal: AbortSignal) => Promise<void>;
22
27
  type CapsolverOperationPhase = "create_task" | "poll_result";
@@ -657,6 +662,8 @@ export function createCapsolverResolverVendorAdapter(
657
662
  form: "cookies" as const,
658
663
  cookies: { "aws-waf-token": solutionValue },
659
664
  userAgent: identity?.userAgent ?? getStealthProfile(DEFAULT_PROFILE).userAgent,
665
+ sdkEstimatedExpires:
666
+ (now() + SDK_ESTIMATED_COOKIE_TTL_MS_BY_CHALLENGE_KIND.aws_waf) / 1_000,
660
667
  }
661
668
  : { form: "token" as const, token: solutionValue };
662
669
  }
@@ -497,6 +497,7 @@ export function createTwoCaptchaResolverVendorAdapter(
497
497
  { phase },
498
498
  );
499
499
  }
500
+ // AWS WAF remains a token solution here, so resolver cookie caching does not apply.
500
501
  return { form: "token" as const, token };
501
502
  }
502
503
  };
@@ -41,6 +41,7 @@ import {
41
41
  import {
42
42
  createUnsupportedResolverClient,
43
43
  RESOLVER_INSTRUMENTATION_METADATA,
44
+ type ResolverSolveWithRecorder,
44
45
  } from "./resolver-shared.js";
45
46
  import {
46
47
  APIFUSE__CDP_POOL__URL,
@@ -142,11 +143,21 @@ type ResolverCacheIndex = {
142
143
  }[];
143
144
  };
144
145
 
146
+ export type ResolverSolutionSource = "cache" | "vendor";
147
+
148
+ type ResolverSolutionInvalidationOutcome =
149
+ | "cache_disabled"
150
+ | "entry_deleted"
151
+ | "index_entry_deleted"
152
+ | "not_cookie_solution"
153
+ | "solution_not_cached";
154
+
145
155
  const RESOLVER_SOLUTION_CACHE_NAMESPACE = "resolver-solution";
146
156
  const RESOLVER_SOLUTION_INDEX_CACHE_NAMESPACE = "resolver-solution-index";
147
157
  const MIN_RESOLVER_CACHE_TTL_MS = 1_000;
148
158
  const resolverCaches = new WeakMap<object, ProviderCache | null>();
149
159
  const solutionIssuerDigests = new WeakMap<object, string>();
160
+ const resolverSolutionSources = new WeakMap<object, ResolverSolutionSource>();
150
161
  const SAFE_CAUSE_MESSAGE_WORDS: ReadonlySet<string> = new Set([
151
162
  "abort",
152
163
  "aborted",
@@ -596,7 +607,7 @@ function isResolverCacheIndex(value: unknown): value is ResolverCacheIndex {
596
607
 
597
608
  function solutionExpiryMs(solution: ChallengeSolution): number | undefined {
598
609
  if (solution.form !== "cookies") return undefined;
599
- const expires = solution.expires;
610
+ const expires = solution.expires ?? solution.sdkEstimatedExpires;
600
611
  if (typeof expires !== "number" || !Number.isFinite(expires)) return undefined;
601
612
  return expires * 1_000;
602
613
  }
@@ -607,6 +618,22 @@ function rememberSolutionIssuer(solution: ChallengeSolution, issuerDigest: strin
607
618
  }
608
619
  }
609
620
 
621
+ function rememberSolutionSource(solution: ChallengeSolution, source: ResolverSolutionSource): void {
622
+ if (typeof solution === "object" && solution !== null) {
623
+ resolverSolutionSources.set(solution, source);
624
+ }
625
+ }
626
+
627
+ /**
628
+ * Identify whether this exact SDK-returned solution object came from the cache
629
+ * or a vendor solve. Returns undefined for copied or caller-created objects.
630
+ */
631
+ export function getResolverSolutionSource(
632
+ solution: ChallengeSolution,
633
+ ): ResolverSolutionSource | undefined {
634
+ return resolverSolutionSources.get(solution);
635
+ }
636
+
610
637
  async function readCachedSolution(
611
638
  cache: ProviderCache,
612
639
  challenge: ProviderChallenge,
@@ -617,8 +644,14 @@ async function readCachedSolution(
617
644
  if (!cached || !isCachedResolverSolution(cached.value)) return undefined;
618
645
  if (cached.value.issuerDigest !== issuerDigest || cached.value.expiresAtMs <= now)
619
646
  return undefined;
620
- rememberSolutionIssuer(cached.value.solution, issuerDigest);
621
- return cached.value.solution;
647
+ if (cached.value.solution.form !== "cookies") return undefined;
648
+ const solution = {
649
+ ...cached.value.solution,
650
+ cookies: { ...cached.value.solution.cookies },
651
+ };
652
+ rememberSolutionIssuer(solution, issuerDigest);
653
+ rememberSolutionSource(solution, "cache");
654
+ return solution;
622
655
  }
623
656
 
624
657
  async function findCachedSolution(
@@ -719,25 +752,18 @@ async function cacheResolverSolution(
719
752
  );
720
753
  }
721
754
 
722
- /** Remove the cached entry for the exact solution object returned by this resolver. */
723
- export async function invalidateResolverSolution(
755
+ async function invalidateResolverSolutionWithOutcome(
724
756
  resolver: ResolverContext,
725
757
  challenge: ProviderChallenge,
726
758
  solution: ChallengeSolution,
727
- ): Promise<void> {
759
+ ): Promise<ResolverSolutionInvalidationOutcome> {
728
760
  const metadata = (
729
761
  resolver as ResolverContext & {
730
762
  readonly [RESOLVER_INSTRUMENTATION_METADATA]?: ResolverInstrumentationMetadata;
731
763
  }
732
764
  )[RESOLVER_INSTRUMENTATION_METADATA];
733
765
  const cacheOwner = metadata?.target ?? resolver;
734
- const invalidate = async (): Promise<
735
- | "cache_disabled"
736
- | "entry_deleted"
737
- | "index_entry_deleted"
738
- | "not_cookie_solution"
739
- | "solution_not_cached"
740
- > => {
766
+ const invalidate = async (): Promise<ResolverSolutionInvalidationOutcome> => {
741
767
  if (solution.form !== "cookies") return "not_cookie_solution";
742
768
  if (!resolverCaches.has(cacheOwner)) {
743
769
  throw new Error("Resolver cache registration lookup failed during solution invalidation");
@@ -763,15 +789,38 @@ export async function invalidateResolverSolution(
763
789
  };
764
790
 
765
791
  if (!metadata) {
766
- await invalidate();
767
- return;
792
+ return await invalidate();
768
793
  }
769
- await metadata.traceRecorder.runSpan("resolver.cache.invalidate", invalidate, {
794
+ return await metadata.traceRecorder.runSpan("resolver.cache.invalidate", invalidate, {
770
795
  attributes: { challenge_kind: challenge.kind },
771
796
  onSuccess: (outcome) => ({ outcome }),
772
797
  });
773
798
  }
774
799
 
800
+ /** Remove the cached entry for the exact solution object returned by this resolver. */
801
+ export async function invalidateResolverSolution(
802
+ resolver: ResolverContext,
803
+ challenge: ProviderChallenge,
804
+ solution: ChallengeSolution,
805
+ ): Promise<void> {
806
+ await invalidateResolverSolutionWithOutcome(resolver, challenge, solution);
807
+ }
808
+
809
+ /**
810
+ * Remove a solution only when it was returned from the resolver cache. Providers
811
+ * should call this when an upstream serves the same challenge after a cached
812
+ * solution was applied, so the next `solve()` mints a fresh solution.
813
+ */
814
+ export async function invalidateCachedResolverSolution(
815
+ resolver: ResolverContext,
816
+ challenge: ProviderChallenge,
817
+ solution: ChallengeSolution,
818
+ ): Promise<boolean> {
819
+ if (getResolverSolutionSource(solution) !== "cache") return false;
820
+ const outcome = await invalidateResolverSolutionWithOutcome(resolver, challenge, solution);
821
+ return outcome === "entry_deleted" || outcome === "index_entry_deleted";
822
+ }
823
+
775
824
  async function resolveResolverIdentity(
776
825
  proxyIntent: NonNullable<ResolverRuntimeOptions["proxyIntent"]>,
777
826
  ): Promise<{
@@ -937,6 +986,7 @@ function createResolverChainClient(options: {
937
986
  );
938
987
  }
939
988
  }
989
+ rememberSolutionSource(solution, "vendor");
940
990
  return solution;
941
991
  } catch (error) {
942
992
  signal.throwIfAborted();
@@ -1022,8 +1072,12 @@ export function bindResolverSignal(
1022
1072
  ): ResolverContext {
1023
1073
  if (!defaultSignal) return resolver;
1024
1074
  const boundResolver: ResolverContext = {
1025
- solve(challenge, signal = defaultSignal) {
1026
- return resolver.solve(challenge, signal);
1075
+ solve(challenge, signal = defaultSignal, traceRecorder?: TraceRecorder) {
1076
+ return (resolver as Partial<ResolverSolveWithRecorder> & ResolverContext).solve(
1077
+ challenge,
1078
+ signal,
1079
+ traceRecorder,
1080
+ );
1027
1081
  },
1028
1082
  };
1029
1083
  if (resolverCaches.has(resolver)) {
@@ -64,7 +64,10 @@ import {
64
64
  type ProxyTelemetryLogPayload,
65
65
  } from "../runtime/proxy-telemetry.js";
66
66
  import type * as ResolverRuntimeModule from "../runtime/resolver.js";
67
- import { createUnsupportedResolverClient } from "../runtime/resolver-shared.js";
67
+ import {
68
+ createUnsupportedResolverClient,
69
+ type ResolverSolveWithRecorder,
70
+ } from "../runtime/resolver-shared.js";
68
71
  import {
69
72
  assertRequiredSecretsPresent,
70
73
  listMissingRequiredSecrets,
@@ -77,7 +80,7 @@ import {
77
80
  import { StealthCookieJar } from "../runtime/stealth-cookies.js";
78
81
  import type * as StealthRuntimeModule from "../runtime/stealth.js";
79
82
  import { createSttClientFromEnv } from "../runtime/stt.js";
80
- import { createTraceContext } from "../runtime/trace.js";
83
+ import { createTraceContext, type TraceRecorder } from "../runtime/trace.js";
81
84
  import { resolveTraceConfigFromEnv } from "../runtime/trace-config.js";
82
85
  import { parseSchema } from "../schema.js";
83
86
  import {
@@ -493,8 +496,14 @@ function bindResolverSignalWithoutRuntime(
493
496
  ): ResolverContext {
494
497
  if (!defaultSignal) return resolver;
495
498
  return {
496
- solve(challenge, signal = defaultSignal) {
497
- return resolver.solve(challenge, signal);
499
+ solve(challenge, signal = defaultSignal, traceRecorder?: TraceRecorder) {
500
+ // Forward the instrumentation trace recorder so resolver.vendor.* spans
501
+ // survive this wrapper. See ResolverSolveWithRecorder in resolver-shared.ts.
502
+ return (resolver as Partial<ResolverSolveWithRecorder> & ResolverContext).solve(
503
+ challenge,
504
+ signal,
505
+ traceRecorder,
506
+ );
498
507
  },
499
508
  };
500
509
  }
package/src/types.ts CHANGED
@@ -409,6 +409,11 @@ export type ChallengeSolution =
409
409
  readonly userAgent: string;
410
410
  /** Epoch seconds copied from the upstream cookie's own expiry attribute; never a constant. */
411
411
  readonly expires?: number;
412
+ /**
413
+ * Epoch seconds conservatively estimated by the SDK when a resolver vendor omits
414
+ * the upstream cookie's expiry. `expires` takes precedence when both are present.
415
+ */
416
+ readonly sdkEstimatedExpires?: number;
412
417
  };
413
418
 
414
419
  export interface ProviderResolverConfig {