@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 +10 -1
- package/dist/runtime/auth-flow.d.ts +3 -1
- package/dist/runtime/auth-flow.js +1 -0
- package/dist/runtime/browser.js +6 -0
- package/dist/runtime/choice.js +9 -48
- package/dist/runtime/resolver-vendors/browser.js +1 -1
- package/dist/runtime/resolver-vendors/twocaptcha.js +15 -9
- package/dist/runtime/resolver-vendors/types.d.ts +4 -1
- package/dist/runtime/resolver-vendors/types.js +10 -1
- package/dist/runtime/resolver.js +13 -2
- package/dist/server/serve-implementation.d.ts +2 -1
- package/dist/server/serve-implementation.js +26 -15
- package/dist/testing/run.js +1 -0
- package/dist/types.d.ts +16 -2
- package/package.json +3 -2
- package/src/runtime/auth-flow.ts +4 -0
- package/src/runtime/browser.ts +8 -0
- package/src/runtime/choice.ts +9 -60
- package/src/runtime/resolver-vendors/browser.ts +1 -1
- package/src/runtime/resolver-vendors/twocaptcha.ts +15 -11
- package/src/runtime/resolver-vendors/types.ts +16 -1
- package/src/runtime/resolver.ts +18 -2
- package/src/server/serve-implementation.ts +74 -15
- package/src/testing/run.ts +1 -0
- package/src/types.ts +16 -2
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
|
|
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),
|
package/dist/runtime/browser.js
CHANGED
|
@@ -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);
|
package/dist/runtime/choice.js
CHANGED
|
@@ -117,10 +117,9 @@ export function createProviderChoiceContext(options) {
|
|
|
117
117
|
consumeMode,
|
|
118
118
|
});
|
|
119
119
|
}
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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) =>
|
|
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
|
|
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.
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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, "
|
|
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
|
-
|
|
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
|
}
|
package/dist/runtime/resolver.js
CHANGED
|
@@ -176,10 +176,17 @@ function throwUnsupportedKind(kind) {
|
|
|
176
176
|
});
|
|
177
177
|
}
|
|
178
178
|
function throwExhausted(attempts) {
|
|
179
|
-
const
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
}
|
package/dist/testing/run.js
CHANGED
|
@@ -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.
|
|
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,
|
|
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.
|
|
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",
|
package/src/runtime/auth-flow.ts
CHANGED
|
@@ -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),
|
package/src/runtime/browser.ts
CHANGED
|
@@ -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);
|
package/src/runtime/choice.ts
CHANGED
|
@@ -263,10 +263,9 @@ export function createProviderChoiceContext(
|
|
|
263
263
|
);
|
|
264
264
|
}
|
|
265
265
|
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
//
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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, "
|
|
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
|
-
|
|
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
|
}
|
package/src/runtime/resolver.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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(
|
|
575
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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,
|
package/src/testing/run.ts
CHANGED
|
@@ -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.
|
|
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,
|
|
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;
|