@apifuse/provider-sdk 2.2.0-beta.33 → 2.2.0-beta.35

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.35
4
+
5
+ - Release candidate for main commit 027fa0087b883a6281bc6d8bdaaf6d0be382000f.
6
+
7
+ ## 2.2.0-beta.34
8
+
9
+ - Release candidate for main commit 383a97e7d0ce995d098d5f95c9ffdcbeed13de31.
10
+
3
11
  ## 2.2.0-beta.33
4
12
 
5
13
  - Release candidate for main commit e7d854ec3e859702b22e2b37eaa2ae6bd4545c80.
@@ -50,7 +58,8 @@
50
58
 
51
59
  ## Unreleased
52
60
 
53
- - Server-stored provider choices now issue word-format tokens unconditionally. The runtime issuance setting and legacy issuance path were removed; parsing remains dual-read until the legacy-token TTL horizon closes.
61
+ - Server-stored provider choices now issue word-format tokens unconditionally. The runtime issuance setting and legacy issuance path were removed.
62
+ - **Breaking:** Legacy encrypted server-handle choice tokens are no longer parsed. They are rejected with the uniform choice-not-found error; server-stored choices are now word-only. Encrypted inline choice tokens remain supported.
54
63
  - Added the `thrown-error-code-undeclared` authoring lint (warning level): `apifuse check` now statically flags literal `ProviderError`/`ValidationError` codes that are neither SDK-registered nor declared in any operation's `docs.errorCodes`, surfacing the runtime `unregistered_provider_error_code` signal at check time. The canonical SDK code→status mapping moved to `SDK_STATUS_MAPPED_PROVIDER_ERROR_CODES` in `error-resolution.ts`, shared by the runtime status resolver and the lint.
55
64
 
56
65
  ## 2.2.0-beta.21
@@ -1,4 +1,4 @@
1
- import type { ContextScratchpad, EnvContext, FlowContext, HttpClient, OcrContext, StealthClient, SttContext } from "../types.js";
1
+ import type { ContextScratchpad, EnvContext, FlowContext, HttpClient, OcrContext, ProviderRuntimeState, StealthClient, SttContext } from "../types.js";
2
2
  export declare function createScratchpad(allowedKeys: string[], initial?: Record<string, unknown>): ContextScratchpad;
3
3
  export declare function createFlowContext(options: {
4
4
  flowId?: string;
@@ -9,6 +9,8 @@ export declare function createFlowContext(options: {
9
9
  providerId: string;
10
10
  connectionId?: string;
11
11
  externalRef?: string;
12
+ /** Host-agnostic: callers pass an already-scoped runtime state, which this helper forwards verbatim. */
13
+ state?: ProviderRuntimeState;
12
14
  allowedKeys: string[];
13
15
  initialContext?: Record<string, unknown>;
14
16
  ocr?: OcrContext;
@@ -40,6 +40,7 @@ export function createFlowContext(options) {
40
40
  tenantId: options.tenantId,
41
41
  providerId: options.providerId,
42
42
  http: options.http,
43
+ state: options.state,
43
44
  stealth: options.stealth,
44
45
  env: options.env,
45
46
  context: createScratchpad(options.allowedKeys, options.initialContext),
@@ -557,6 +557,9 @@ class PlaywrightBrowserPage {
557
557
  }
558
558
  return await this.page.evaluate(fn);
559
559
  }
560
+ async userAgent() {
561
+ return await this.evaluate("navigator.userAgent");
562
+ }
560
563
  async waitForSelector(selector, options) {
561
564
  await this.page.waitForSelector(selector, options);
562
565
  }
@@ -1117,6 +1120,9 @@ class CdpPoolBrowserPage {
1117
1120
  await this.initialize();
1118
1121
  return await this.evaluateWithContext(fn);
1119
1122
  }
1123
+ async userAgent() {
1124
+ return await this.evaluate("navigator.userAgent");
1125
+ }
1120
1126
  async evaluateInFrame(frameId, fn) {
1121
1127
  await this.initialize();
1122
1128
  const contextId = await this.getFrameExecutionContextId(frameId);
@@ -117,10 +117,9 @@ export function createProviderChoiceContext(options) {
117
117
  consumeMode,
118
118
  });
119
119
  }
120
- // Legacy encrypted-envelope compatibility fallback. Removal is gated on
121
- // the last legacy mint plus the maximum issued TTL; see ADR 0006.
122
- // A structurally valid word token returns above, so lookup, expiry,
123
- // consumption, and binding failures can never enter this branch.
120
+ // Inline choices continue to use the encrypted envelope. A structurally
121
+ // valid word token returns above, so lookup, expiry, consumption, and
122
+ // binding failures can never enter this branch.
124
123
  try {
125
124
  const [actualPrefix, tokenKid, encodedIv, encryptedPayload, authTag, signature] = parseManagedChoiceTokenParts(parseOptions.token);
126
125
  if (actualPrefix !== parseOptions.prefix ||
@@ -166,15 +165,12 @@ export function createProviderChoiceContext(options) {
166
165
  required: true,
167
166
  }),
168
167
  });
169
- const payload = isServerChoiceHandlePayload(envelope.payload)
170
- ? parseLegacyServerStoredChoice({
171
- handle: envelope.payload,
172
- storage: parseOptions.storage,
173
- contextState: options.state,
174
- })
175
- : envelope.payload;
168
+ if (isServerChoiceHandlePayload(envelope.payload)) {
169
+ throw wordChoiceNotFoundError();
170
+ }
171
+ const payload = envelope.payload;
176
172
  const parsed = consumeMode === "explicit"
177
- ? Promise.resolve(payload).then((resolvedPayload) => createLegacyExplicitParseResult({
173
+ ? Promise.resolve(payload).then((resolvedPayload) => createInlineExplicitParseResult({
178
174
  payload: resolvedPayload,
179
175
  replayKey: digestChoiceReplayKey(parseOptions.token),
180
176
  onConsume: () => emitChoiceTelemetry(options.onTelemetry, {
@@ -274,7 +270,7 @@ function emitChoiceTelemetry(onTelemetry, event) {
274
270
  // Observability must never change provider token semantics.
275
271
  }
276
272
  }
277
- function createLegacyExplicitParseResult(options) {
273
+ function createInlineExplicitParseResult(options) {
278
274
  return {
279
275
  status: "active",
280
276
  payload: options.payload,
@@ -502,41 +498,6 @@ async function consumeWordServerStoredChoice(options) {
502
498
  throw wordChoiceNotFoundError();
503
499
  }
504
500
  }
505
- async function parseLegacyServerStoredChoice(options) {
506
- const storage = resolveParseStorage(options.storage);
507
- const namespace = resolveChoiceStateNamespace({
508
- storage,
509
- contextState: options.contextState,
510
- });
511
- // Reading a server-stored choice back deserializes a persisted value. A
512
- // corrupt/undecodable value would otherwise surface as a raw JSON.parse
513
- // SyntaxError (or another unexpected throwable) that escapes the choice error
514
- // taxonomy, gets masked as internal_error 500, and is treated as retryable by
515
- // the hub -> reservation restart loop (2026-07-22 catchtable RCA, candidate A).
516
- // Convert any non-branded throwable into a branded invalid_payload so it maps
517
- // to a clean, non-retryable 400. Branded ProviderChoiceTokenError and genuine
518
- // ProviderError (e.g. Redis-unavailable / state-unavailable) pass through so
519
- // their category/retryable semantics are preserved.
520
- let record;
521
- try {
522
- record = await namespace.get(optionsStateKey(options.handle.state_id));
523
- }
524
- catch (error) {
525
- if (error instanceof ProviderChoiceTokenError || isProviderError(error)) {
526
- throw error;
527
- }
528
- throw new ProviderChoiceTokenError("invalid_payload", "Provider choice token state payload could not be decoded.");
529
- }
530
- if (!record) {
531
- throw new ProviderChoiceTokenError("invalid_payload", "Provider choice token state payload is missing.");
532
- }
533
- const serializedPayload = serializeChoicePayload(record.value);
534
- assertPayloadDigestMatches({
535
- actual: digestChoicePayload(serializedPayload),
536
- expected: options.handle.payload_digest,
537
- });
538
- return record.value;
539
- }
540
501
  function generateChoiceWordSequence(wordCount) {
541
502
  return Array.from({ length: wordCount }, () => choiceWordAt(randomInt(CHOICE_WORDLIST_SIZE))).join("-");
542
503
  }
@@ -156,7 +156,7 @@ async function solveInPage(page, challengeKind, pageUrl, allowedHosts, successCo
156
156
  },
157
157
  ],
158
158
  }, async () => {
159
- const userAgent = await raceWithAbort(() => page.evaluate("navigator.userAgent"), signal);
159
+ const userAgent = await raceWithAbort(() => page.userAgent(), signal);
160
160
  await raceWithAbort(() => page.goto(pageUrl), signal);
161
161
  while (true) {
162
162
  const cookies = await raceWithAbort(() => page.cookies(), signal);
@@ -235,17 +235,23 @@ export function createTwoCaptchaResolverVendorAdapter(options) {
235
235
  if (!resolverVendorSupports(TWOCAPTCHA_VENDOR_ID, challenge.kind)) {
236
236
  throw new TypeError(`2captcha resolver does not support ${challenge.kind}`);
237
237
  }
238
- if (challenge.kind === "aws_waf" &&
239
- (!challenge.siteKey?.trim() ||
240
- !challenge.captchaScript?.trim() ||
241
- !challenge.context?.trim() ||
242
- !challenge.iv?.trim())) {
243
- throw new ResolverVendorUnavailableError(TWOCAPTCHA_VENDOR_ID, "not_implemented", {
244
- phase: "create_task",
245
- });
238
+ if (challenge.kind === "aws_waf") {
239
+ const missingFields = [
240
+ ...(challenge.siteKey?.trim() ? [] : ["siteKey"]),
241
+ ...(challenge.captchaScript?.trim() ? [] : ["captchaScript"]),
242
+ ...(challenge.context?.trim() ? [] : ["context"]),
243
+ ...(challenge.iv?.trim() ? [] : ["iv"]),
244
+ ];
245
+ if (missingFields.length > 0) {
246
+ throw new ResolverVendorUnavailableError(TWOCAPTCHA_VENDOR_ID, "missing_challenge_input", {
247
+ missingFields,
248
+ phase: "create_task",
249
+ });
250
+ }
246
251
  }
247
252
  if (challenge.kind === "recaptcha_v3" && challenge.minScore === undefined) {
248
- throw new ResolverVendorUnavailableError(TWOCAPTCHA_VENDOR_ID, "transport_failure", {
253
+ throw new ResolverVendorUnavailableError(TWOCAPTCHA_VENDOR_ID, "missing_challenge_input", {
254
+ missingFields: ["minScore"],
249
255
  phase: "create_task",
250
256
  });
251
257
  }
@@ -55,11 +55,13 @@ export interface ResolverVendorAdapter {
55
55
  getIssuingIdentity?(solution: ChallengeSolution, requestedIdentity: ResolverIdentity | undefined, challenge: ProviderChallenge): ResolverIssuingIdentity | undefined;
56
56
  solve(challenge: ProviderChallenge, identity: ResolverIdentity | undefined, signal: AbortSignal, traceRecorder?: TraceRecorder, transport?: ResolverVendorTransport): Promise<ChallengeSolution>;
57
57
  }
58
- export type ResolverVendorUnavailableReason = "missing_credentials" | "missing_proxy_identity" | "missing_client_profile" | "missing_transport" | "allocation_exhausted" | "transport_failure" | "timeout" | "not_implemented";
58
+ export type ResolverVendorUnavailableReason = "missing_credentials" | "missing_proxy_identity" | "missing_client_profile" | "missing_challenge_input" | "missing_transport" | "allocation_exhausted" | "transport_failure" | "timeout" | "not_implemented";
59
59
  export type ResolverChallengeVerdictReason = "human_puzzle" | "solve_failed";
60
60
  type ResolverErrorOptions = {
61
61
  /** Raw cause; adapters must not place bodies, cookies, headers, credentials, or proxy URLs here. */
62
62
  readonly cause?: unknown;
63
+ /** Names of challenge fields required by this adapter but absent from this call's input. */
64
+ readonly missingFields?: readonly string[];
63
65
  /** Upstream hostname only; never a URL. */
64
66
  readonly upstreamHost?: string;
65
67
  /** Adapter-defined sensor-loop phase, such as fetch_script or post_sensor. */
@@ -70,6 +72,7 @@ type ResolverErrorOptions = {
70
72
  export declare class ResolverVendorUnavailableError extends Error {
71
73
  readonly vendor: ProviderResolverVendor;
72
74
  readonly reason: ResolverVendorUnavailableReason;
75
+ readonly missingFields?: readonly string[];
73
76
  readonly upstreamHost?: string;
74
77
  readonly phase?: string;
75
78
  readonly round?: number;
@@ -39,14 +39,23 @@ export function resolverVendorSupports(vendor, kind) {
39
39
  export class ResolverVendorUnavailableError extends Error {
40
40
  vendor;
41
41
  reason;
42
+ missingFields;
42
43
  upstreamHost;
43
44
  phase;
44
45
  round;
45
46
  constructor(vendor, reason, options = {}) {
46
- super(`Resolver vendor ${vendor} is unavailable: ${reason}`);
47
+ const missingFields = reason === "missing_challenge_input"
48
+ ? options.missingFields?.filter((field) => /^[A-Za-z][A-Za-z0-9_]*$/u.test(field))
49
+ : undefined;
50
+ super(reason === "missing_challenge_input" && missingFields !== undefined && missingFields.length > 0
51
+ ? `Resolver vendor ${vendor} cannot use incomplete challenge input; missing fields: ${missingFields.join(", ")}`
52
+ : `Resolver vendor ${vendor} is unavailable: ${reason}`);
47
53
  this.vendor = vendor;
48
54
  this.reason = reason;
49
55
  this.name = "ResolverVendorUnavailableError";
56
+ if (missingFields !== undefined && missingFields.length > 0) {
57
+ this.missingFields = Object.freeze([...missingFields]);
58
+ }
50
59
  if (options.cause !== undefined) {
51
60
  this.cause = options.cause;
52
61
  }
@@ -176,10 +176,17 @@ function throwUnsupportedKind(kind) {
176
176
  });
177
177
  }
178
178
  function throwExhausted(attempts) {
179
- const summary = attempts.map(({ vendor, reason }) => `${vendor}: ${reason}`).join(", ");
179
+ const hasMissingChallengeInput = attempts.some((attempt) => attempt.reason === "missing_challenge_input");
180
+ const summary = attempts
181
+ .map(({ vendor, reason, missingFields }) => missingFields === undefined || missingFields.length === 0
182
+ ? `${vendor}: ${reason}`
183
+ : `${vendor}: ${reason} (missing fields: ${missingFields.join(", ")})`)
184
+ .join(", ");
180
185
  throw new ProviderError(`Resolver vendor chain exhausted: ${summary}`, {
181
186
  code: "RESOLVER_CHAIN_EXHAUSTED",
182
- fix: "Configure another supporting resolver vendor or restore an unavailable vendor.",
187
+ fix: hasMissingChallengeInput
188
+ ? "Capture the named challenge fields or configure another supporting resolver vendor."
189
+ : "Configure another supporting resolver vendor or restore an unavailable vendor.",
183
190
  details: attempts,
184
191
  });
185
192
  }
@@ -283,6 +290,7 @@ function unavailableAttempt(error) {
283
290
  return {
284
291
  vendor: error.vendor,
285
292
  reason: error.reason,
293
+ ...(error.missingFields ? { missingFields: [...error.missingFields] } : {}),
286
294
  ...(cause ? { cause } : {}),
287
295
  ...(upstreamHost ? { upstreamHost } : {}),
288
296
  ...(phase ? { phase } : {}),
@@ -293,6 +301,7 @@ function unavailableSpanAttributes(error) {
293
301
  const attempt = unavailableAttempt(error);
294
302
  return {
295
303
  unavailability_reason: error.reason,
304
+ missing_fields: error.missingFields,
296
305
  cause_name: attempt.cause?.name,
297
306
  cause_message: attempt.cause?.message,
298
307
  upstream_host: attempt.upstreamHost,
@@ -611,6 +620,8 @@ function createResolverChainClient(options) {
611
620
  signal.throwIfAborted();
612
621
  if (!(error instanceof ResolverVendorUnavailableError))
613
622
  throw error;
623
+ // Every vendor-unavailable result, including missing_challenge_input, falls
624
+ // through so another adapter can solve with a different input contract.
614
625
  attempts.push(unavailableAttempt(error));
615
626
  }
616
627
  }
@@ -3,7 +3,7 @@ import { z } from "zod";
3
3
  import { type ProviderErrorCategory } from "../observability.js";
4
4
  import type { OcrContext, ProviderContext, ProviderDefinition, ProviderRuntimeState, ResolverContext, SttContext } from "../types.js";
5
5
  import type { SelfTestCancellationLogEvent } from "./self-test.js";
6
- import { type OperationRequest } from "./types.js";
6
+ import { type AuthFlowRequest, type OperationRequest } from "./types.js";
7
7
  /** Compact SDK-owned error classification emitted separately from the public response body. */
8
8
  export declare const ERROR_OBSERVABILITY_HEADER = "X-ApiFuse-Error-Observability";
9
9
  export type ErrorObservabilityDetails = {
@@ -63,6 +63,7 @@ export type ProviderServerOperationExecutorInput = {
63
63
  export type ProviderServerOperationExecutor = (input: ProviderServerOperationExecutorInput) => Promise<unknown>;
64
64
  export declare function resolveProviderProxyAffinityKey(provider: ProviderDefinition, request: OperationRequest, operationId: string): string;
65
65
  export declare function resolveProviderResolverIdentityScope(provider: ProviderDefinition, affinityKey: string, contextId: string): string;
66
+ export declare function resolveAuthFlowProxyAffinityKey(provider: ProviderDefinition, request: Pick<AuthFlowRequest, "connection" | "connectionId" | "externalRef" | "tenantId" | "providerId">): string;
66
67
  type ProviderRequestCost = {
67
68
  durationMs: number;
68
69
  cpuUserMicros: number;
@@ -343,7 +343,14 @@ export function resolveProviderResolverIdentityScope(provider, affinityKey, cont
343
343
  });
344
344
  }
345
345
  function resolveOperationConnectionId(request) {
346
- return request.connection?.id ?? request.connectionId;
346
+ // An empty string is a malformed identifier, not an identity: treat it as
347
+ // absent so it can never override a valid id or key a real scope. Requests
348
+ // without any usable id fall back to the documented missing-connection
349
+ // sentinel scope instead of scoping context/affinity/state under "".
350
+ return normalizeConnectionId(request.connection?.id) ?? normalizeConnectionId(request.connectionId);
351
+ }
352
+ function normalizeConnectionId(id) {
353
+ return id === "" ? undefined : id;
347
354
  }
348
355
  function resolveNativeProxyPolicy(provider) {
349
356
  if (typeof provider.proxy === "object")
@@ -497,7 +504,14 @@ function createFlowContextStore(allowedKeys, initialContext = {}) {
497
504
  },
498
505
  };
499
506
  }
500
- function createAuthFlowContext(provider, request, options, signal) {
507
+ export function resolveAuthFlowProxyAffinityKey(provider, request) {
508
+ return (resolveOperationConnectionId(request) ??
509
+ request.externalRef ??
510
+ request.tenantId ??
511
+ request.providerId ??
512
+ provider.id);
513
+ }
514
+ function createAuthFlowContext(provider, request, options, state, signal) {
501
515
  const baseUrl = getProviderBaseUrl(provider);
502
516
  const stealthBaseUrl = getProviderStealthBaseUrl(provider);
503
517
  const stealthProfile = getProviderStealthProfile(provider);
@@ -506,11 +520,7 @@ function createAuthFlowContext(provider, request, options, signal) {
506
520
  const flowContextStore = createFlowContextStore(provider.context?.keys ?? Object.keys(contextData), contextData);
507
521
  const proxyClientOptions = {
508
522
  upstream: { proxy: provider.proxy },
509
- affinityKey: request.connectionId ??
510
- request.externalRef ??
511
- request.tenantId ??
512
- request.providerId ??
513
- provider.id,
523
+ affinityKey: resolveAuthFlowProxyAffinityKey(provider, request),
514
524
  };
515
525
  const resolverIdentityScope = resolveProviderResolverIdentityScope(provider, proxyClientOptions.affinityKey, request.requestId);
516
526
  const stealthClientOptions = {
@@ -531,7 +541,7 @@ function createAuthFlowContext(provider, request, options, signal) {
531
541
  return {
532
542
  context: {
533
543
  flowId: request.flowId,
534
- connectionId: request.connectionId,
544
+ connectionId: resolveOperationConnectionId(request),
535
545
  externalRef: request.externalRef,
536
546
  tenantId: request.tenantId ?? "",
537
547
  providerId: request.providerId ?? provider.id,
@@ -539,6 +549,7 @@ function createAuthFlowContext(provider, request, options, signal) {
539
549
  ...proxyClientOptions,
540
550
  ...(signal ? { signal } : {}),
541
551
  }),
552
+ state: state.forConnection(resolveOperationConnectionId(request)),
542
553
  stealth: stealthBaseUrl
543
554
  ? capabilityModules.stealth
544
555
  ? stealthProfile
@@ -1359,7 +1370,7 @@ function responseWithProviderTelemetry(response, proxyTelemetry) {
1359
1370
  statusText: response.statusText,
1360
1371
  });
1361
1372
  }
1362
- async function handleAuthFlow(provider, request, route, options, signal) {
1373
+ async function handleAuthFlow(provider, request, route, options, state, signal) {
1363
1374
  const flow = provider.auth?.flow;
1364
1375
  if (!flow) {
1365
1376
  throw new ProviderError("Auth flow is not configured", {
@@ -1371,7 +1382,7 @@ async function handleAuthFlow(provider, request, route, options, signal) {
1371
1382
  // any flow code runs instead of at whatever point the ceremony first reads
1372
1383
  // the env. `abort` stays exempt: a user must always be able to cancel a
1373
1384
  // stranded flow even when provisioning is broken.
1374
- const { context, getPatch } = createAuthFlowContext(provider, request, options, signal);
1385
+ const { context, getPatch } = createAuthFlowContext(provider, request, options, state, signal);
1375
1386
  try {
1376
1387
  if (route !== "abort") {
1377
1388
  assertRequiredSecretsPresent(provider, context.env);
@@ -1745,7 +1756,7 @@ function createServerAppWithCapabilityModules(provider, serverOptions, capabilit
1745
1756
  .json()
1746
1757
  .catch(() => undefined);
1747
1758
  const body = withAuthRequestHeaders(AuthFlowRequestSchema.parse(rawBody), c.req.raw.headers);
1748
- const response = await handleAuthFlow(provider, body, "start", options, c.req.raw.signal);
1759
+ const response = await handleAuthFlow(provider, body, "start", options, state, c.req.raw.signal);
1749
1760
  logProviderSuccess(logger, provider, "auth", "start", body.requestId, response instanceof Response ? response.status : 200, finishRequestCost(requestCost));
1750
1761
  return response instanceof Response ? response : c.json(response);
1751
1762
  }
@@ -1765,7 +1776,7 @@ function createServerAppWithCapabilityModules(provider, serverOptions, capabilit
1765
1776
  .json()
1766
1777
  .catch(() => undefined);
1767
1778
  const body = withAuthRequestHeaders(AuthFlowRequestSchema.parse(rawBody), c.req.raw.headers);
1768
- const response = await handleAuthFlow(provider, body, "continue", options, c.req.raw.signal);
1779
+ const response = await handleAuthFlow(provider, body, "continue", options, state, c.req.raw.signal);
1769
1780
  logProviderSuccess(logger, provider, "auth", "continue", body.requestId, response instanceof Response ? response.status : 200, finishRequestCost(requestCost));
1770
1781
  return response instanceof Response ? response : c.json(response);
1771
1782
  }
@@ -1785,7 +1796,7 @@ function createServerAppWithCapabilityModules(provider, serverOptions, capabilit
1785
1796
  .json()
1786
1797
  .catch(() => undefined);
1787
1798
  const body = withAuthRequestHeaders(AuthFlowRequestSchema.parse(rawBody), c.req.raw.headers);
1788
- const response = await handleAuthFlow(provider, body, "poll", options, c.req.raw.signal);
1799
+ const response = await handleAuthFlow(provider, body, "poll", options, state, c.req.raw.signal);
1789
1800
  logProviderSuccess(logger, provider, "auth", "poll", body.requestId, response instanceof Response ? response.status : 200, finishRequestCost(requestCost));
1790
1801
  return response instanceof Response ? response : c.json(response);
1791
1802
  }
@@ -1805,7 +1816,7 @@ function createServerAppWithCapabilityModules(provider, serverOptions, capabilit
1805
1816
  .json()
1806
1817
  .catch(() => undefined);
1807
1818
  const body = withAuthRequestHeaders(AuthFlowRequestSchema.parse(rawBody), c.req.raw.headers);
1808
- const response = await handleAuthFlow(provider, body, "refresh", options, c.req.raw.signal);
1819
+ const response = await handleAuthFlow(provider, body, "refresh", options, state, c.req.raw.signal);
1809
1820
  logProviderSuccess(logger, provider, "auth", "refresh", body.requestId, response instanceof Response ? response.status : 200, finishRequestCost(requestCost));
1810
1821
  return response instanceof Response ? response : c.json(response);
1811
1822
  }
@@ -1825,7 +1836,7 @@ function createServerAppWithCapabilityModules(provider, serverOptions, capabilit
1825
1836
  .json()
1826
1837
  .catch(() => undefined);
1827
1838
  const body = withAuthRequestHeaders(AuthFlowRequestSchema.parse(rawBody), c.req.raw.headers);
1828
- const response = await handleAuthFlow(provider, body, "abort", options, c.req.raw.signal);
1839
+ const response = await handleAuthFlow(provider, body, "abort", options, state, c.req.raw.signal);
1829
1840
  logProviderSuccess(logger, provider, "auth", "disconnect", body.requestId, response instanceof Response ? response.status : 200, finishRequestCost(requestCost));
1830
1841
  return response instanceof Response ? response : c.json(response);
1831
1842
  }
@@ -230,6 +230,7 @@ function createUpstreamContext(provider, operationName, upstreamStub) {
230
230
  id: `standard-test-${operationName}`,
231
231
  url: async () => currentUrl,
232
232
  title: async () => currentResponse?.text.match(/<title[^>]*>([^<]*)<\/title>/i)?.[1] ?? "",
233
+ userAgent: async () => String((await browserAction("userAgent")).data),
233
234
  content: async () => currentResponse?.text ?? "",
234
235
  evaluate: async (fn) => (await browserAction("evaluate", typeof fn === "string" ? fn : String(fn))).data,
235
236
  locator: (selector) => ({
package/dist/types.d.ts CHANGED
@@ -1487,6 +1487,8 @@ export type BrowserResourcePolicy = {
1487
1487
  };
1488
1488
  export interface BrowserPage extends BrowserFrame {
1489
1489
  close(): Promise<void>;
1490
+ /** Returns the user agent used by this page's browser context. */
1491
+ userAgent(): Promise<string>;
1490
1492
  /**
1491
1493
  * Reads the browser context's cookie jar, including httpOnly cookies.
1492
1494
  * Cookie expiry values are Unix seconds and are absent for session cookies.
@@ -1579,7 +1581,7 @@ export type ProviderChoiceExplicitParseResult = {
1579
1581
  readonly payload: Record<string, unknown>;
1580
1582
  /** Stable, opaque key for provider-owned idempotency records. */
1581
1583
  readonly replayKey: string;
1582
- /** Atomically claims a word token. Legacy managed tokens report unsupported. */
1584
+ /** Atomically claims a word token. Inline tokens report unsupported. */
1583
1585
  consume(): Promise<ProviderChoiceConsumeResult>;
1584
1586
  } | {
1585
1587
  readonly status: "consumed";
@@ -1626,7 +1628,7 @@ export interface ProviderChoiceParseOptions {
1626
1628
  futureToleranceMs?: number;
1627
1629
  bind?: ProviderChoiceBindingOptions;
1628
1630
  storage?: ProviderChoiceStorageOptions;
1629
- /** Defaults to never, matching legacy managed-token parse semantics. */
1631
+ /** Defaults to never, preserving reusable choice-token parse semantics. */
1630
1632
  consume?: ProviderChoiceConsumeMode;
1631
1633
  }
1632
1634
  export interface ProviderChoiceContext {
@@ -1747,6 +1749,18 @@ export interface FlowContext {
1747
1749
  tenantId: string;
1748
1750
  providerId: string;
1749
1751
  http: HttpClient;
1752
+ /** Durable connection-scoped runtime state. Present when the host runtime
1753
+ * supplies one; auth ceremonies must fail closed when absent rather than
1754
+ * fall back to bypassable in-process storage.
1755
+ *
1756
+ * Scoped via `ProviderRuntimeState.forConnection`: requests that resolve no
1757
+ * connection id (pre-connection ceremonies such as first-time logins) share
1758
+ * the documented isolated missing-connection scope. That sharing is the
1759
+ * intended semantic — it lets counters keyed by caller identity (e.g. a
1760
+ * login email) persist across separate ceremonies for the same caller.
1761
+ * Flows storing entries in that scope MUST key them by caller identity;
1762
+ * un-keyed entries would be shared across all connectionless ceremonies. */
1763
+ readonly state?: ProviderRuntimeState;
1750
1764
  /** Present when the selected runtime supplies native network capabilities. */
1751
1765
  readonly native?: NativeProviderContext;
1752
1766
  stealth: StealthClient;
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.2.0-beta.33",
2
+ "version": "2.2.0-beta.35",
3
3
  "name": "@apifuse/provider-sdk",
4
4
  "private": false,
5
5
  "type": "module",
@@ -106,10 +106,11 @@
106
106
  "lint": "biome lint .",
107
107
  "lint:fix": "biome lint --write",
108
108
  "lint:deprecated": "bun run scripts/lint-deprecated-usage.ts",
109
+ "lint:test-typesafety": "bun scripts/check-test-typesafety.ts",
109
110
  "format": "biome format --write",
110
111
  "type-check": "tsc --noEmit",
111
112
  "test": "bun test",
112
- "check": "bun run lint && bun run type-check && bun run lint:deprecated && bun run build",
113
+ "check": "bun run lint && bun run type-check && bun run lint:deprecated && bun run lint:test-typesafety && bun run build",
113
114
  "pack:check": "bun run build && bun bin/apifuse-pack-check.ts",
114
115
  "pack:smoke": "bun run build && bun bin/apifuse-pack-smoke.ts",
115
116
  "pack:types": "bun run build && bun bin/apifuse-pack-types.ts",
@@ -6,6 +6,7 @@ import type {
6
6
  FlowContext,
7
7
  HttpClient,
8
8
  OcrContext,
9
+ ProviderRuntimeState,
9
10
  StealthClient,
10
11
  SttContext,
11
12
  } from "../types.js";
@@ -59,6 +60,8 @@ export function createFlowContext(options: {
59
60
  providerId: string;
60
61
  connectionId?: string;
61
62
  externalRef?: string;
63
+ /** Host-agnostic: callers pass an already-scoped runtime state, which this helper forwards verbatim. */
64
+ state?: ProviderRuntimeState;
62
65
  allowedKeys: string[];
63
66
  initialContext?: Record<string, unknown>;
64
67
  ocr?: OcrContext;
@@ -71,6 +74,7 @@ export function createFlowContext(options: {
71
74
  tenantId: options.tenantId,
72
75
  providerId: options.providerId,
73
76
  http: options.http,
77
+ state: options.state,
74
78
  stealth: options.stealth,
75
79
  env: options.env,
76
80
  context: createScratchpad(options.allowedKeys, options.initialContext),
@@ -816,6 +816,10 @@ class PlaywrightBrowserPage implements BrowserPageContract {
816
816
  return await this.page.evaluate(fn);
817
817
  }
818
818
 
819
+ async userAgent(): Promise<string> {
820
+ return await this.evaluate<string>("navigator.userAgent");
821
+ }
822
+
819
823
  async waitForSelector(selector: string, options?: { timeout?: number }): Promise<void> {
820
824
  await this.page.waitForSelector(selector, options);
821
825
  }
@@ -1509,6 +1513,10 @@ class CdpPoolBrowserPage implements BrowserPageContract {
1509
1513
  return await this.evaluateWithContext<T>(fn);
1510
1514
  }
1511
1515
 
1516
+ async userAgent(): Promise<string> {
1517
+ return await this.evaluate<string>("navigator.userAgent");
1518
+ }
1519
+
1512
1520
  async evaluateInFrame<T>(frameId: string, fn: string | (() => T)): Promise<T> {
1513
1521
  await this.initialize();
1514
1522
  const contextId = await this.getFrameExecutionContextId(frameId);
@@ -263,10 +263,9 @@ export function createProviderChoiceContext(
263
263
  );
264
264
  }
265
265
 
266
- // Legacy encrypted-envelope compatibility fallback. Removal is gated on
267
- // the last legacy mint plus the maximum issued TTL; see ADR 0006.
268
- // A structurally valid word token returns above, so lookup, expiry,
269
- // consumption, and binding failures can never enter this branch.
266
+ // Inline choices continue to use the encrypted envelope. A structurally
267
+ // valid word token returns above, so lookup, expiry, consumption, and
268
+ // binding failures can never enter this branch.
270
269
  try {
271
270
  const [actualPrefix, tokenKid, encodedIv, encryptedPayload, authTag, signature] =
272
271
  parseManagedChoiceTokenParts(parseOptions.token);
@@ -321,17 +320,14 @@ export function createProviderChoiceContext(
321
320
  required: true,
322
321
  }),
323
322
  });
324
- const payload = isServerChoiceHandlePayload(envelope.payload)
325
- ? parseLegacyServerStoredChoice({
326
- handle: envelope.payload,
327
- storage: parseOptions.storage,
328
- contextState: options.state,
329
- })
330
- : envelope.payload;
323
+ if (isServerChoiceHandlePayload(envelope.payload)) {
324
+ throw wordChoiceNotFoundError();
325
+ }
326
+ const payload = envelope.payload;
331
327
  const parsed =
332
328
  consumeMode === "explicit"
333
329
  ? Promise.resolve(payload).then((resolvedPayload) =>
334
- createLegacyExplicitParseResult({
330
+ createInlineExplicitParseResult({
335
331
  payload: resolvedPayload,
336
332
  replayKey: digestChoiceReplayKey(parseOptions.token),
337
333
  onConsume: () =>
@@ -471,7 +467,7 @@ function emitChoiceTelemetry(
471
467
  }
472
468
  }
473
469
 
474
- function createLegacyExplicitParseResult(options: {
470
+ function createInlineExplicitParseResult(options: {
475
471
  readonly payload: ProviderChoiceTokenPayload;
476
472
  readonly replayKey: string;
477
473
  readonly onConsume: () => void;
@@ -759,53 +755,6 @@ async function consumeWordServerStoredChoice(options: {
759
755
  }
760
756
  }
761
757
 
762
- async function parseLegacyServerStoredChoice(options: {
763
- readonly handle: ServerChoiceHandlePayload;
764
- readonly storage?: ProviderChoiceStorageOptions;
765
- readonly contextState?: ProviderRuntimeState;
766
- }): Promise<ProviderChoiceTokenPayload> {
767
- const storage = resolveParseStorage(options.storage);
768
- const namespace = resolveChoiceStateNamespace({
769
- storage,
770
- contextState: options.contextState,
771
- });
772
- // Reading a server-stored choice back deserializes a persisted value. A
773
- // corrupt/undecodable value would otherwise surface as a raw JSON.parse
774
- // SyntaxError (or another unexpected throwable) that escapes the choice error
775
- // taxonomy, gets masked as internal_error 500, and is treated as retryable by
776
- // the hub -> reservation restart loop (2026-07-22 catchtable RCA, candidate A).
777
- // Convert any non-branded throwable into a branded invalid_payload so it maps
778
- // to a clean, non-retryable 400. Branded ProviderChoiceTokenError and genuine
779
- // ProviderError (e.g. Redis-unavailable / state-unavailable) pass through so
780
- // their category/retryable semantics are preserved.
781
- let record: StateValue<ProviderChoiceTokenPayload> | null;
782
- try {
783
- record = await namespace.get<ProviderChoiceTokenPayload>(
784
- optionsStateKey(options.handle.state_id),
785
- );
786
- } catch (error) {
787
- if (error instanceof ProviderChoiceTokenError || isProviderError(error)) {
788
- throw error;
789
- }
790
- throw new ProviderChoiceTokenError(
791
- "invalid_payload",
792
- "Provider choice token state payload could not be decoded.",
793
- );
794
- }
795
- if (!record) {
796
- throw new ProviderChoiceTokenError(
797
- "invalid_payload",
798
- "Provider choice token state payload is missing.",
799
- );
800
- }
801
- const serializedPayload = serializeChoicePayload(record.value);
802
- assertPayloadDigestMatches({
803
- actual: digestChoicePayload(serializedPayload),
804
- expected: options.handle.payload_digest,
805
- });
806
- return record.value;
807
- }
808
-
809
758
  function generateChoiceWordSequence(wordCount: number): string {
810
759
  return Array.from({ length: wordCount }, () =>
811
760
  choiceWordAt(randomInt(CHOICE_WORDLIST_SIZE)),
@@ -242,7 +242,7 @@ async function solveInPage(
242
242
  },
243
243
  async () => {
244
244
  const userAgent = await raceWithAbort(
245
- () => page.evaluate<string>("navigator.userAgent"),
245
+ () => page.userAgent(),
246
246
  signal,
247
247
  );
248
248
  await raceWithAbort(() => page.goto(pageUrl), signal);
@@ -338,19 +338,23 @@ export function createTwoCaptchaResolverVendorAdapter(
338
338
  if (!resolverVendorSupports(TWOCAPTCHA_VENDOR_ID, challenge.kind)) {
339
339
  throw new TypeError(`2captcha resolver does not support ${challenge.kind}`);
340
340
  }
341
- if (
342
- challenge.kind === "aws_waf" &&
343
- (!challenge.siteKey?.trim() ||
344
- !challenge.captchaScript?.trim() ||
345
- !challenge.context?.trim() ||
346
- !challenge.iv?.trim())
347
- ) {
348
- throw new ResolverVendorUnavailableError(TWOCAPTCHA_VENDOR_ID, "not_implemented", {
349
- phase: "create_task",
350
- });
341
+ if (challenge.kind === "aws_waf") {
342
+ const missingFields = [
343
+ ...(challenge.siteKey?.trim() ? [] : ["siteKey"]),
344
+ ...(challenge.captchaScript?.trim() ? [] : ["captchaScript"]),
345
+ ...(challenge.context?.trim() ? [] : ["context"]),
346
+ ...(challenge.iv?.trim() ? [] : ["iv"]),
347
+ ];
348
+ if (missingFields.length > 0) {
349
+ throw new ResolverVendorUnavailableError(TWOCAPTCHA_VENDOR_ID, "missing_challenge_input", {
350
+ missingFields,
351
+ phase: "create_task",
352
+ });
353
+ }
351
354
  }
352
355
  if (challenge.kind === "recaptcha_v3" && challenge.minScore === undefined) {
353
- throw new ResolverVendorUnavailableError(TWOCAPTCHA_VENDOR_ID, "transport_failure", {
356
+ throw new ResolverVendorUnavailableError(TWOCAPTCHA_VENDOR_ID, "missing_challenge_input", {
357
+ missingFields: ["minScore"],
354
358
  phase: "create_task",
355
359
  });
356
360
  }
@@ -117,6 +117,7 @@ export type ResolverVendorUnavailableReason =
117
117
  | "missing_credentials"
118
118
  | "missing_proxy_identity"
119
119
  | "missing_client_profile"
120
+ | "missing_challenge_input"
120
121
  | "missing_transport"
121
122
  | "allocation_exhausted"
122
123
  | "transport_failure"
@@ -128,6 +129,8 @@ export type ResolverChallengeVerdictReason = "human_puzzle" | "solve_failed";
128
129
  type ResolverErrorOptions = {
129
130
  /** Raw cause; adapters must not place bodies, cookies, headers, credentials, or proxy URLs here. */
130
131
  readonly cause?: unknown;
132
+ /** Names of challenge fields required by this adapter but absent from this call's input. */
133
+ readonly missingFields?: readonly string[];
131
134
  /** Upstream hostname only; never a URL. */
132
135
  readonly upstreamHost?: string;
133
136
  /** Adapter-defined sensor-loop phase, such as fetch_script or post_sensor. */
@@ -137,6 +140,7 @@ type ResolverErrorOptions = {
137
140
  };
138
141
 
139
142
  export class ResolverVendorUnavailableError extends Error {
143
+ readonly missingFields?: readonly string[];
140
144
  readonly upstreamHost?: string;
141
145
  readonly phase?: string;
142
146
  readonly round?: number;
@@ -146,8 +150,19 @@ export class ResolverVendorUnavailableError extends Error {
146
150
  readonly reason: ResolverVendorUnavailableReason,
147
151
  options: ResolverErrorOptions = {},
148
152
  ) {
149
- super(`Resolver vendor ${vendor} is unavailable: ${reason}`);
153
+ const missingFields =
154
+ reason === "missing_challenge_input"
155
+ ? options.missingFields?.filter((field) => /^[A-Za-z][A-Za-z0-9_]*$/u.test(field))
156
+ : undefined;
157
+ super(
158
+ reason === "missing_challenge_input" && missingFields !== undefined && missingFields.length > 0
159
+ ? `Resolver vendor ${vendor} cannot use incomplete challenge input; missing fields: ${missingFields.join(", ")}`
160
+ : `Resolver vendor ${vendor} is unavailable: ${reason}`,
161
+ );
150
162
  this.name = "ResolverVendorUnavailableError";
163
+ if (missingFields !== undefined && missingFields.length > 0) {
164
+ this.missingFields = Object.freeze([...missingFields]);
165
+ }
151
166
  if (options.cause !== undefined) {
152
167
  this.cause = options.cause;
153
168
  }
@@ -82,6 +82,7 @@ type ResolvedResolverVendor =
82
82
  type ResolverChainAttempt = {
83
83
  readonly vendor: ProviderResolverVendor;
84
84
  readonly reason: ResolverVendorUnavailableReason;
85
+ readonly missingFields?: readonly string[];
85
86
  readonly cause?: {
86
87
  readonly name: string;
87
88
  readonly message: string;
@@ -347,10 +348,21 @@ function throwUnsupportedKind(kind: ProviderChallengeKind): never {
347
348
  }
348
349
 
349
350
  function throwExhausted(attempts: readonly ResolverChainAttempt[]): never {
350
- const summary = attempts.map(({ vendor, reason }) => `${vendor}: ${reason}`).join(", ");
351
+ const hasMissingChallengeInput = attempts.some(
352
+ (attempt) => attempt.reason === "missing_challenge_input",
353
+ );
354
+ const summary = attempts
355
+ .map(({ vendor, reason, missingFields }) =>
356
+ missingFields === undefined || missingFields.length === 0
357
+ ? `${vendor}: ${reason}`
358
+ : `${vendor}: ${reason} (missing fields: ${missingFields.join(", ")})`,
359
+ )
360
+ .join(", ");
351
361
  throw new ProviderError(`Resolver vendor chain exhausted: ${summary}`, {
352
362
  code: "RESOLVER_CHAIN_EXHAUSTED",
353
- fix: "Configure another supporting resolver vendor or restore an unavailable vendor.",
363
+ fix: hasMissingChallengeInput
364
+ ? "Capture the named challenge fields or configure another supporting resolver vendor."
365
+ : "Configure another supporting resolver vendor or restore an unavailable vendor.",
354
366
  details: attempts,
355
367
  });
356
368
  }
@@ -473,6 +485,7 @@ function unavailableAttempt(error: ResolverVendorUnavailableError): ResolverChai
473
485
  return {
474
486
  vendor: error.vendor,
475
487
  reason: error.reason,
488
+ ...(error.missingFields ? { missingFields: [...error.missingFields] } : {}),
476
489
  ...(cause ? { cause } : {}),
477
490
  ...(upstreamHost ? { upstreamHost } : {}),
478
491
  ...(phase ? { phase } : {}),
@@ -484,6 +497,7 @@ function unavailableSpanAttributes(error: ResolverVendorUnavailableError): Recor
484
497
  const attempt = unavailableAttempt(error);
485
498
  return {
486
499
  unavailability_reason: error.reason,
500
+ missing_fields: error.missingFields,
487
501
  cause_name: attempt.cause?.name,
488
502
  cause_message: attempt.cause?.message,
489
503
  upstream_host: attempt.upstreamHost,
@@ -917,6 +931,8 @@ function createResolverChainClient(options: {
917
931
  } catch (error) {
918
932
  signal.throwIfAborted();
919
933
  if (!(error instanceof ResolverVendorUnavailableError)) throw error;
934
+ // Every vendor-unavailable result, including missing_challenge_input, falls
935
+ // through so another adapter can solve with a different input contract.
920
936
  attempts.push(unavailableAttempt(error));
921
937
  }
922
938
  }
@@ -571,8 +571,18 @@ export function resolveProviderResolverIdentityScope(
571
571
  });
572
572
  }
573
573
 
574
- function resolveOperationConnectionId(request: OperationRequest): string | undefined {
575
- return request.connection?.id ?? request.connectionId;
574
+ function resolveOperationConnectionId(
575
+ request: Pick<OperationRequest, "connection" | "connectionId">,
576
+ ): string | undefined {
577
+ // An empty string is a malformed identifier, not an identity: treat it as
578
+ // absent so it can never override a valid id or key a real scope. Requests
579
+ // without any usable id fall back to the documented missing-connection
580
+ // sentinel scope instead of scoping context/affinity/state under "".
581
+ return normalizeConnectionId(request.connection?.id) ?? normalizeConnectionId(request.connectionId);
582
+ }
583
+
584
+ function normalizeConnectionId(id: string | undefined): string | undefined {
585
+ return id === "" ? undefined : id;
576
586
  }
577
587
 
578
588
  function resolveNativeProxyPolicy(provider: ProviderDefinition): ProviderProxyPolicy | undefined {
@@ -776,10 +786,27 @@ function createFlowContextStore(
776
786
  };
777
787
  }
778
788
 
789
+ export function resolveAuthFlowProxyAffinityKey(
790
+ provider: ProviderDefinition,
791
+ request: Pick<
792
+ AuthFlowRequest,
793
+ "connection" | "connectionId" | "externalRef" | "tenantId" | "providerId"
794
+ >,
795
+ ): string {
796
+ return (
797
+ resolveOperationConnectionId(request) ??
798
+ request.externalRef ??
799
+ request.tenantId ??
800
+ request.providerId ??
801
+ provider.id
802
+ );
803
+ }
804
+
779
805
  function createAuthFlowContext(
780
806
  provider: ProviderDefinition,
781
807
  request: AuthFlowRequest,
782
808
  options: ProviderServerRuntimeOptions,
809
+ state: ProviderRuntimeState,
783
810
  signal?: AbortSignal,
784
811
  ): {
785
812
  context: FlowContext;
@@ -796,12 +823,7 @@ function createAuthFlowContext(
796
823
  );
797
824
  const proxyClientOptions = {
798
825
  upstream: { proxy: provider.proxy },
799
- affinityKey:
800
- request.connectionId ??
801
- request.externalRef ??
802
- request.tenantId ??
803
- request.providerId ??
804
- provider.id,
826
+ affinityKey: resolveAuthFlowProxyAffinityKey(provider, request),
805
827
  };
806
828
  const resolverIdentityScope = resolveProviderResolverIdentityScope(
807
829
  provider,
@@ -836,7 +858,7 @@ function createAuthFlowContext(
836
858
  return {
837
859
  context: {
838
860
  flowId: request.flowId,
839
- connectionId: request.connectionId,
861
+ connectionId: resolveOperationConnectionId(request),
840
862
  externalRef: request.externalRef,
841
863
  tenantId: request.tenantId ?? "",
842
864
  providerId: request.providerId ?? provider.id,
@@ -844,6 +866,7 @@ function createAuthFlowContext(
844
866
  ...proxyClientOptions,
845
867
  ...(signal ? { signal } : {}),
846
868
  }),
869
+ state: state.forConnection(resolveOperationConnectionId(request)),
847
870
  stealth: stealthBaseUrl
848
871
  ? capabilityModules.stealth
849
872
  ? stealthProfile
@@ -2005,6 +2028,7 @@ async function handleAuthFlow(
2005
2028
  request: AuthFlowRequest,
2006
2029
  route: AuthRoute,
2007
2030
  options: ProviderServerRuntimeOptions,
2031
+ state: ProviderRuntimeState,
2008
2032
  signal?: AbortSignal,
2009
2033
  ): Promise<Response | AuthFlowResponse> {
2010
2034
  const flow = provider.auth?.flow;
@@ -2019,7 +2043,7 @@ async function handleAuthFlow(
2019
2043
  // any flow code runs instead of at whatever point the ceremony first reads
2020
2044
  // the env. `abort` stays exempt: a user must always be able to cancel a
2021
2045
  // stranded flow even when provisioning is broken.
2022
- const { context, getPatch } = createAuthFlowContext(provider, request, options, signal);
2046
+ const { context, getPatch } = createAuthFlowContext(provider, request, options, state, signal);
2023
2047
  try {
2024
2048
  if (route !== "abort") {
2025
2049
  assertRequiredSecretsPresent(provider, context.env);
@@ -2540,7 +2564,14 @@ function createServerAppWithCapabilityModules(
2540
2564
  .json()
2541
2565
  .catch(() => undefined);
2542
2566
  const body = withAuthRequestHeaders(AuthFlowRequestSchema.parse(rawBody), c.req.raw.headers);
2543
- const response = await handleAuthFlow(provider, body, "start", options, c.req.raw.signal);
2567
+ const response = await handleAuthFlow(
2568
+ provider,
2569
+ body,
2570
+ "start",
2571
+ options,
2572
+ state,
2573
+ c.req.raw.signal,
2574
+ );
2544
2575
  logProviderSuccess(
2545
2576
  logger,
2546
2577
  provider,
@@ -2580,7 +2611,14 @@ function createServerAppWithCapabilityModules(
2580
2611
  .json()
2581
2612
  .catch(() => undefined);
2582
2613
  const body = withAuthRequestHeaders(AuthFlowRequestSchema.parse(rawBody), c.req.raw.headers);
2583
- const response = await handleAuthFlow(provider, body, "continue", options, c.req.raw.signal);
2614
+ const response = await handleAuthFlow(
2615
+ provider,
2616
+ body,
2617
+ "continue",
2618
+ options,
2619
+ state,
2620
+ c.req.raw.signal,
2621
+ );
2584
2622
  logProviderSuccess(
2585
2623
  logger,
2586
2624
  provider,
@@ -2620,7 +2658,14 @@ function createServerAppWithCapabilityModules(
2620
2658
  .json()
2621
2659
  .catch(() => undefined);
2622
2660
  const body = withAuthRequestHeaders(AuthFlowRequestSchema.parse(rawBody), c.req.raw.headers);
2623
- const response = await handleAuthFlow(provider, body, "poll", options, c.req.raw.signal);
2661
+ const response = await handleAuthFlow(
2662
+ provider,
2663
+ body,
2664
+ "poll",
2665
+ options,
2666
+ state,
2667
+ c.req.raw.signal,
2668
+ );
2624
2669
  logProviderSuccess(
2625
2670
  logger,
2626
2671
  provider,
@@ -2660,7 +2705,14 @@ function createServerAppWithCapabilityModules(
2660
2705
  .json()
2661
2706
  .catch(() => undefined);
2662
2707
  const body = withAuthRequestHeaders(AuthFlowRequestSchema.parse(rawBody), c.req.raw.headers);
2663
- const response = await handleAuthFlow(provider, body, "refresh", options, c.req.raw.signal);
2708
+ const response = await handleAuthFlow(
2709
+ provider,
2710
+ body,
2711
+ "refresh",
2712
+ options,
2713
+ state,
2714
+ c.req.raw.signal,
2715
+ );
2664
2716
  logProviderSuccess(
2665
2717
  logger,
2666
2718
  provider,
@@ -2700,7 +2752,14 @@ function createServerAppWithCapabilityModules(
2700
2752
  .json()
2701
2753
  .catch(() => undefined);
2702
2754
  const body = withAuthRequestHeaders(AuthFlowRequestSchema.parse(rawBody), c.req.raw.headers);
2703
- const response = await handleAuthFlow(provider, body, "abort", options, c.req.raw.signal);
2755
+ const response = await handleAuthFlow(
2756
+ provider,
2757
+ body,
2758
+ "abort",
2759
+ options,
2760
+ state,
2761
+ c.req.raw.signal,
2762
+ );
2704
2763
  logProviderSuccess(
2705
2764
  logger,
2706
2765
  provider,
@@ -377,6 +377,7 @@ function createUpstreamContext(
377
377
  id: `standard-test-${operationName}`,
378
378
  url: async () => currentUrl,
379
379
  title: async () => currentResponse?.text.match(/<title[^>]*>([^<]*)<\/title>/i)?.[1] ?? "",
380
+ userAgent: async () => String((await browserAction("userAgent")).data),
380
381
  content: async () => currentResponse?.text ?? "",
381
382
  evaluate: async <T>(fn: string | (() => T)) =>
382
383
  (await browserAction("evaluate", typeof fn === "string" ? fn : String(fn))).data as T,
package/src/types.ts CHANGED
@@ -1807,6 +1807,8 @@ export type BrowserResourcePolicy = {
1807
1807
 
1808
1808
  export interface BrowserPage extends BrowserFrame {
1809
1809
  close(): Promise<void>;
1810
+ /** Returns the user agent used by this page's browser context. */
1811
+ userAgent(): Promise<string>;
1810
1812
  /**
1811
1813
  * Reads the browser context's cookie jar, including httpOnly cookies.
1812
1814
  * Cookie expiry values are Unix seconds and are absent for session cookies.
@@ -1914,7 +1916,7 @@ export type ProviderChoiceExplicitParseResult =
1914
1916
  readonly payload: Record<string, unknown>;
1915
1917
  /** Stable, opaque key for provider-owned idempotency records. */
1916
1918
  readonly replayKey: string;
1917
- /** Atomically claims a word token. Legacy managed tokens report unsupported. */
1919
+ /** Atomically claims a word token. Inline tokens report unsupported. */
1918
1920
  consume(): Promise<ProviderChoiceConsumeResult>;
1919
1921
  }
1920
1922
  | {
@@ -1970,7 +1972,7 @@ export interface ProviderChoiceParseOptions {
1970
1972
  futureToleranceMs?: number;
1971
1973
  bind?: ProviderChoiceBindingOptions;
1972
1974
  storage?: ProviderChoiceStorageOptions;
1973
- /** Defaults to never, matching legacy managed-token parse semantics. */
1975
+ /** Defaults to never, preserving reusable choice-token parse semantics. */
1974
1976
  consume?: ProviderChoiceConsumeMode;
1975
1977
  }
1976
1978
 
@@ -2124,6 +2126,18 @@ export interface FlowContext {
2124
2126
  tenantId: string;
2125
2127
  providerId: string;
2126
2128
  http: HttpClient;
2129
+ /** Durable connection-scoped runtime state. Present when the host runtime
2130
+ * supplies one; auth ceremonies must fail closed when absent rather than
2131
+ * fall back to bypassable in-process storage.
2132
+ *
2133
+ * Scoped via `ProviderRuntimeState.forConnection`: requests that resolve no
2134
+ * connection id (pre-connection ceremonies such as first-time logins) share
2135
+ * the documented isolated missing-connection scope. That sharing is the
2136
+ * intended semantic — it lets counters keyed by caller identity (e.g. a
2137
+ * login email) persist across separate ceremonies for the same caller.
2138
+ * Flows storing entries in that scope MUST key them by caller identity;
2139
+ * un-keyed entries would be shared across all connectionless ceremonies. */
2140
+ readonly state?: ProviderRuntimeState;
2127
2141
  /** Present when the selected runtime supplies native network capabilities. */
2128
2142
  readonly native?: NativeProviderContext;
2129
2143
  stealth: StealthClient;