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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/AUTHORING.md +7 -6
  2. package/CHANGELOG.md +9 -1
  3. package/README.md +3 -3
  4. package/bin/apifuse-check.ts +62 -3
  5. package/bin/apifuse-pack-check.ts +8 -2
  6. package/bin/apifuse-pack-smoke.ts +43 -2
  7. package/bin/apifuse-pack-types.ts +58 -0
  8. package/dist/auth.js +29 -0
  9. package/dist/cli/templates/provider/README.md.tpl +4 -4
  10. package/dist/contract-serialization.js +4 -8
  11. package/dist/declaration-validation.d.ts +23 -0
  12. package/dist/declaration-validation.js +159 -0
  13. package/dist/define.d.ts +1 -1
  14. package/dist/define.js +13 -2
  15. package/dist/index.d.ts +1 -0
  16. package/dist/lint.js +85 -3
  17. package/dist/runtime/resolver-vendors/bindings.d.ts +42 -2
  18. package/dist/runtime/resolver-vendors/bindings.js +31 -6
  19. package/dist/runtime/resolver-vendors/browser.d.ts +3 -7
  20. package/dist/runtime/resolver-vendors/browser.js +7 -22
  21. package/dist/runtime/resolver-vendors/hosts.d.ts +2 -0
  22. package/dist/runtime/resolver-vendors/hosts.js +33 -0
  23. package/dist/runtime/resolver-vendors/twocaptcha.d.ts +23 -0
  24. package/dist/runtime/resolver-vendors/twocaptcha.js +264 -0
  25. package/dist/runtime/resolver-vendors/types.d.ts +44 -3
  26. package/dist/runtime/resolver-vendors/types.js +10 -0
  27. package/dist/runtime/resolver.d.ts +17 -2
  28. package/dist/runtime/resolver.js +237 -15
  29. package/dist/runtime/stealth.d.ts +26 -4
  30. package/dist/runtime/stealth.js +224 -114
  31. package/dist/server/serve.js +8 -0
  32. package/dist/stealth/profiles.js +16 -7
  33. package/dist/types.d.ts +34 -1
  34. package/package.json +2 -2
  35. package/src/auth.ts +40 -0
  36. package/src/cli/templates/provider/README.md.tpl +4 -4
  37. package/src/contract-serialization.ts +5 -7
  38. package/src/declaration-validation.ts +202 -0
  39. package/src/define.ts +23 -2
  40. package/src/index.ts +1 -0
  41. package/src/lint.ts +98 -3
  42. package/src/runtime/resolver-vendors/bindings.ts +40 -15
  43. package/src/runtime/resolver-vendors/browser.ts +9 -31
  44. package/src/runtime/resolver-vendors/hosts.ts +38 -0
  45. package/src/runtime/resolver-vendors/twocaptcha.ts +366 -0
  46. package/src/runtime/resolver-vendors/types.ts +54 -0
  47. package/src/runtime/resolver.ts +304 -24
  48. package/src/runtime/stealth.ts +317 -136
  49. package/src/server/serve.ts +8 -0
  50. package/src/stealth/profiles.ts +17 -7
  51. package/src/types.ts +36 -3
package/src/auth.ts CHANGED
@@ -1,4 +1,9 @@
1
1
  import { AuthError, ProviderError } from "./errors.js";
2
+ import {
3
+ declarationInvalidError,
4
+ DECLARATION_RULE_IDS,
5
+ type DeclarationViolation,
6
+ } from "./declaration-validation.js";
2
7
  import type {
3
8
  AuthAbortData,
4
9
  AuthConfig,
@@ -656,6 +661,7 @@ export function defineCredentialsAuth<
656
661
  string,
657
662
  CredentialsAuthChallengeDefinition<CredentialsAuthFields, TCredentialKeys, string>
658
663
  >;
664
+ validateCredentialsAuthChallenges(challenges);
659
665
 
660
666
  return {
661
667
  auth: {
@@ -726,3 +732,37 @@ export function defineCredentialsAuth<
726
732
  },
727
733
  };
728
734
  }
735
+
736
+ function validateCredentialsAuthChallenges(
737
+ challenges: Record<
738
+ string,
739
+ CredentialsAuthChallengeDefinition<CredentialsAuthFields, readonly string[], string>
740
+ >,
741
+ ): void {
742
+ const violations: DeclarationViolation[] = [];
743
+ for (const [challengeId, challenge] of Object.entries(challenges)) {
744
+ const fieldCount =
745
+ challenge.fields && typeof challenge.fields === "object"
746
+ ? Object.keys(challenge.fields).length
747
+ : 0;
748
+ const fieldsDeclared =
749
+ challenge.fields !== undefined && challenge.fields !== null;
750
+ const hasFields = fieldCount > 0;
751
+ const hasVerify = typeof challenge.verify === "function";
752
+ const hasPoll = typeof challenge.poll === "function";
753
+ const isInteractive = hasFields && hasVerify && !hasPoll;
754
+ const isPolling = !fieldsDeclared && !hasVerify && hasPoll;
755
+ const isHybrid = hasFields && hasVerify && hasPoll;
756
+ const emptyFieldsDeclared = fieldsDeclared && !hasFields;
757
+ if (!emptyFieldsDeclared && (isInteractive || isPolling || isHybrid)) continue;
758
+
759
+ const path = `challenges.${challengeId}`;
760
+ violations.push({
761
+ ruleId: DECLARATION_RULE_IDS.challengeShape,
762
+ path,
763
+ message: "challenge must be interactive, polling, or an explicit hybrid.",
764
+ fix: `Give ${path} non-empty fields plus verify, poll alone, or all three for a hybrid.`,
765
+ });
766
+ }
767
+ if (violations.length > 0) throw declarationInvalidError(violations);
768
+ }
@@ -114,10 +114,10 @@ Structured errors return an `error` object with `code`, `message`,
114
114
  - Auth flow: call `/auth/start`, then `/auth/continue` with the same `flowId`;
115
115
  carry returned `contextPatch` values into the next request's `context`.
116
116
  - Stealth/browser runtime: keep access-sensitive operations on `ctx.stealth.fetch()` with an
117
- SDK stealth `profile`; the TypeScript stealth runtime uses `impit` internally.
118
- `ctx.stealth` supports Chrome/Firefox-style profiles. For TypeScript browser
119
- Providers or Safari-specific behavior use `browser.engine: "playwright-stealth"`
120
- (`nodriver` is Python-runtime only), then install local Chromium with
117
+ SDK stealth `profile`; the TypeScript stealth runtime uses `wreq-js` internally
118
+ and supports Chrome, Firefox, and Safari profiles. Use `ctx.browser` only when
119
+ the provider needs browser execution; TypeScript browser Providers use
120
+ `browser.engine: "playwright-stealth"` (`nodriver` is Python-runtime only). Install local Chromium with
121
121
  `bunx playwright install chromium` or set `APIFUSE__CDP_POOL__URL`.
122
122
 
123
123
  ## Next steps
@@ -64,14 +64,12 @@ function isZodSchema(schema: SchemaLike): schema is ZodType {
64
64
  return schema instanceof z.ZodType;
65
65
  }
66
66
 
67
- function zodJsonSchema(schema: ZodType): JsonValue | undefined {
68
- try {
69
- const jsonSchema = z.toJSONSchema(schema);
70
- return toJsonValue(jsonSchema);
71
- } catch (error) {
72
- if (error instanceof Error) return undefined;
73
- throw error;
67
+ function zodJsonSchema(schema: ZodType): JsonValue {
68
+ const jsonSchema = toJsonValue(z.toJSONSchema(schema));
69
+ if (jsonSchema === undefined) {
70
+ throw new TypeError("z.toJSONSchema() returned a non-JSON value");
74
71
  }
72
+ return jsonSchema;
75
73
  }
76
74
 
77
75
  function getSchemaTypeName(schema: SchemaLike): string | undefined {
@@ -0,0 +1,202 @@
1
+ import { describeSchema } from "./contract-serialization.js";
2
+ import { ProviderError } from "./errors.js";
3
+ import type {
4
+ HealthJourneyDefinition,
5
+ ProviderDefinition,
6
+ ProviderProxyPolicy,
7
+ ProviderProxyProvider,
8
+ } from "./types.js";
9
+
10
+ export const DECLARATION_INVALID_CODE = "DECLARATION_INVALID";
11
+
12
+ export const DECLARATION_RULE_IDS = {
13
+ challengeShape: "credentials-challenge-shape",
14
+ journeyExecutable: "health-journey-executable",
15
+ schemaSerializable: "operation-schema-serializable",
16
+ proxyExplicitPolicy: "proxy-explicit-policy",
17
+ proxyVendorExclusive: "proxy-vendor-fields-exclusive",
18
+ proxyNoMixedVendors: "proxy-no-mixed-vendors",
19
+ proxySmartproxyGeo: "proxy-smartproxy-country-only",
20
+ operationUpstreamProxy: "operation-upstream-proxy-unsupported",
21
+ } as const;
22
+
23
+ export type DeclarationRuleId =
24
+ (typeof DECLARATION_RULE_IDS)[keyof typeof DECLARATION_RULE_IDS];
25
+
26
+ export type DeclarationViolation = {
27
+ ruleId: DeclarationRuleId;
28
+ path: string;
29
+ message: string;
30
+ fix: string;
31
+ };
32
+
33
+ export function declarationInvalidError(
34
+ violations: readonly DeclarationViolation[],
35
+ ): ProviderError {
36
+ const summary = violations
37
+ .map((violation) => `${violation.path} [${violation.ruleId}]: ${violation.message}`)
38
+ .join("\n");
39
+ return new ProviderError(
40
+ `Provider declaration is invalid (${violations.length} violation${violations.length === 1 ? "" : "s"}).${summary ? `\n${summary}` : ""}`,
41
+ {
42
+ code: DECLARATION_INVALID_CODE,
43
+ details: { violations: [...violations] },
44
+ fix: "Apply every violation's fix hint, then validate the declaration again.",
45
+ },
46
+ );
47
+ }
48
+
49
+ /** Enforces declaration rules whose runtime behavior would otherwise fail open. */
50
+ export function validateFailClosedDeclaration(provider: ProviderDefinition): void {
51
+ const violations: DeclarationViolation[] = [];
52
+ validateHealthDeclaration(provider, violations);
53
+ validateSchemaDeclaration(provider, violations);
54
+ validateProxyDeclaration(provider, violations);
55
+ validateOperationDeclaration(provider, violations);
56
+ if (violations.length > 0) throw declarationInvalidError(violations);
57
+ }
58
+
59
+ function validateHealthDeclaration(
60
+ provider: ProviderDefinition,
61
+ violations: DeclarationViolation[],
62
+ ): void {
63
+ for (const [index, journey] of (provider.healthJourneys ?? []).entries()) {
64
+ if (!journey || typeof journey !== "object") continue;
65
+ if (typeof journey.run !== "function") {
66
+ const journeyPath = healthJourneyPath(journey, index);
67
+ violations.push({
68
+ ruleId: DECLARATION_RULE_IDS.journeyExecutable,
69
+ path: `${journeyPath}.run`,
70
+ message: "coversOperations cannot provide health coverage without executable run logic.",
71
+ fix: `Add an async run(ctx) implementation to ${journeyPath}.`,
72
+ });
73
+ }
74
+ }
75
+
76
+ // NOTE: healthCheck.cases[].enabled is intentionally NOT validated here.
77
+ // self-test.ts reports a gated case as status "skipped" with skipReason
78
+ // "disabled", so the skip is visible in results rather than silent — it is a
79
+ // supported conditional-execution feature, not a class-1 silent no-op.
80
+ }
81
+
82
+ function healthJourneyPath(journey: HealthJourneyDefinition, index: number): string {
83
+ return typeof journey.id === "string" && journey.id.length > 0
84
+ ? `healthJourneys.${journey.id}`
85
+ : `healthJourneys[${index}]`;
86
+ }
87
+
88
+ function validateSchemaDeclaration(
89
+ provider: ProviderDefinition,
90
+ violations: DeclarationViolation[],
91
+ ): void {
92
+ for (const [operationId, operation] of Object.entries(provider.operations ?? {})) {
93
+ const schemaEntries: Array<[string, unknown]> = [
94
+ [`operations.${operationId}.input`, operation.input],
95
+ [`operations.${operationId}.output`, operation.output],
96
+ ];
97
+ // SSE event schemas reach contract extraction the same way input/output do,
98
+ // so a transform-bearing event schema would abort extraction at runtime while
99
+ // passing declaration checks. Validate them under the same rule.
100
+ const transport = operation.transport;
101
+ if (transport?.kind === "sse") {
102
+ for (const [eventName, eventSchema] of Object.entries(transport.events ?? {})) {
103
+ schemaEntries.push([
104
+ `operations.${operationId}.transport.events.${eventName}`,
105
+ eventSchema,
106
+ ]);
107
+ }
108
+ }
109
+ for (const [path, schema] of schemaEntries) {
110
+ try {
111
+ describeSchema(schema as Parameters<typeof describeSchema>[0]);
112
+ } catch (error) {
113
+ const reason = error instanceof Error ? error.message : String(error);
114
+ violations.push({
115
+ ruleId: DECLARATION_RULE_IDS.schemaSerializable,
116
+ path,
117
+ message: `schema conversion to JSON Schema failed: ${reason}`,
118
+ fix: `Replace unsupported constructs in ${path} so z.toJSONSchema() succeeds.`,
119
+ });
120
+ }
121
+ }
122
+ }
123
+ }
124
+
125
+ const MANAGED_PROXY_VENDORS = new Set<ProviderProxyProvider>(["smartproxy", "nodemaven"]);
126
+ const STATIC_PROXY_VENDORS = new Set<ProviderProxyProvider>(["custom", "decodo"]);
127
+
128
+ function validateProxyDeclaration(
129
+ provider: ProviderDefinition,
130
+ violations: DeclarationViolation[],
131
+ ): void {
132
+ if (provider.proxy === true) {
133
+ violations.push({
134
+ ruleId: DECLARATION_RULE_IDS.proxyExplicitPolicy,
135
+ path: "proxy",
136
+ message: "proxy: true does not require resolvable proxy egress.",
137
+ fix: 'Replace proxy: true with an explicit policy such as proxy: { mode: "required", providers: ["smartproxy"] }.',
138
+ });
139
+ return;
140
+ }
141
+ if (!provider.proxy || typeof provider.proxy !== "object") return;
142
+
143
+ const policy = provider.proxy as ProviderProxyPolicy;
144
+ const hasProvider = policy.provider !== undefined;
145
+ const hasProviders = policy.providers !== undefined;
146
+ if (hasProvider && hasProviders) {
147
+ violations.push({
148
+ ruleId: DECLARATION_RULE_IDS.proxyVendorExclusive,
149
+ path: "proxy",
150
+ message: "provider and providers are ambiguous when declared together.",
151
+ fix: "Keep either proxy.provider or proxy.providers, and remove the other field.",
152
+ });
153
+ }
154
+
155
+ const vendors = declaredProxyVendors(policy);
156
+ if (
157
+ vendors.some((vendor) => MANAGED_PROXY_VENDORS.has(vendor)) &&
158
+ vendors.some((vendor) => STATIC_PROXY_VENDORS.has(vendor))
159
+ ) {
160
+ violations.push({
161
+ ruleId: DECLARATION_RULE_IDS.proxyNoMixedVendors,
162
+ path: hasProviders ? "proxy.providers" : "proxy.provider",
163
+ message: "managed and deprecated static proxy vendors cannot share a chain.",
164
+ fix: "Use only smartproxy/nodemaven vendors, or only deprecated static markers, in one policy.",
165
+ });
166
+ }
167
+
168
+ if (vendors.includes("smartproxy")) {
169
+ for (const field of ["subdivision", "city"] as const) {
170
+ if (policy.geo?.[field] === undefined) continue;
171
+ const path = `proxy.geo.${field}`;
172
+ violations.push({
173
+ ruleId: DECLARATION_RULE_IDS.proxySmartproxyGeo,
174
+ path,
175
+ message: `smartproxy cannot honor ${field}-level geo targeting.`,
176
+ fix: `Remove ${path} or use a vendor chain that can honor it.`,
177
+ });
178
+ }
179
+ }
180
+ }
181
+
182
+ function declaredProxyVendors(policy: ProviderProxyPolicy): ProviderProxyProvider[] {
183
+ const vendors = [...(policy.providers ?? [])];
184
+ if (policy.provider !== undefined) vendors.push(policy.provider);
185
+ return vendors;
186
+ }
187
+
188
+ function validateOperationDeclaration(
189
+ provider: ProviderDefinition,
190
+ violations: DeclarationViolation[],
191
+ ): void {
192
+ for (const [operationId, operation] of Object.entries(provider.operations ?? {})) {
193
+ if (!operation.upstream?.proxy) continue;
194
+ const path = `operations.${operationId}.upstream.proxy`;
195
+ violations.push({
196
+ ruleId: DECLARATION_RULE_IDS.operationUpstreamProxy,
197
+ path,
198
+ message: "operation-level proxy policy is not wired into operation execution.",
199
+ fix: `Remove ${path} and declare the effective policy at provider.proxy.`,
200
+ });
201
+ }
202
+ }
package/src/define.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import ms from "ms";
2
2
 
3
+ import { validateFailClosedDeclaration } from "./declaration-validation.js";
3
4
  import { SDK_RUNTIME_OWNED_ERROR_CODES } from "./error-resolution.js";
4
5
  import { ProviderError, ValidationError } from "./errors.js";
5
6
  import {
@@ -152,6 +153,8 @@ export const VALID_PROVIDER_CHALLENGE_KINDS = exhaustiveLiteralArray<ProviderCha
152
153
  "hcaptcha",
153
154
  "cloudflare_interstitial",
154
155
  "aws_waf",
156
+ "akamai_sec_cpt",
157
+ "akamai_sensor",
155
158
  ] as const);
156
159
  const SMARTPROXY_APP_KEY_SECRET = "APIFUSE__PROXY__SMARTPROXY_APP_KEY";
157
160
  const NODEMAVEN_USERNAME_SECRET = "APIFUSE__PROXY__NODEMAVEN_USERNAME";
@@ -759,7 +762,12 @@ function validateProviderResolver(config: { id: string; resolver?: ProviderResol
759
762
  fix: `Set resolver for provider "${config.id}" to { vendors: ["2captcha"], kinds: ["turnstile"] }.`,
760
763
  });
761
764
  }
762
- rejectUnknownFields(resolver, new Set(["vendors", "kinds"]), "resolver", config.id);
765
+ rejectUnknownFields(
766
+ resolver,
767
+ new Set(["vendors", "kinds", "clientProfile"]),
768
+ "resolver",
769
+ config.id,
770
+ );
763
771
  validateResolverLiteralArray(
764
772
  resolver.vendors,
765
773
  "resolver.vendors",
@@ -772,6 +780,17 @@ function validateProviderResolver(config: { id: string; resolver?: ProviderResol
772
780
  VALID_PROVIDER_CHALLENGE_KINDS,
773
781
  config.id,
774
782
  );
783
+ if (
784
+ resolver.clientProfile !== undefined &&
785
+ (typeof resolver.clientProfile !== "string" || !resolver.clientProfile.trim())
786
+ ) {
787
+ throw new ValidationError(
788
+ `Provider "${config.id}" has invalid resolver.clientProfile: must be a non-empty string.`,
789
+ {
790
+ fix: `Set resolver.clientProfile for provider "${config.id}" to a transport-owned profile name.`,
791
+ },
792
+ );
793
+ }
775
794
  }
776
795
 
777
796
  function validateResolverLiteralArray<TValue extends string>(
@@ -2511,7 +2530,7 @@ export function defineProvider<
2511
2530
  `Provider "${config.id}" cannot define browser config unless runtime is "browser"`,
2512
2531
  { fix: 'Set runtime: "browser" or remove the browser config' },
2513
2532
  );
2514
- return {
2533
+ const provider: ProviderDefinition & { operations: OperationMapConfig<TOperations> } = {
2515
2534
  id: config.id,
2516
2535
  version: config.version,
2517
2536
  runtime: config.runtime,
@@ -2540,4 +2559,6 @@ export function defineProvider<
2540
2559
  healthProbe: config.healthProbe ?? config.healthMonitor,
2541
2560
  healthJourneys: config.healthJourneys,
2542
2561
  };
2562
+ validateFailClosedDeclaration(provider);
2563
+ return provider;
2543
2564
  }
package/src/index.ts CHANGED
@@ -113,6 +113,7 @@ export {
113
113
  invalidateResolverSolution,
114
114
  type ResolverRuntimeOptions,
115
115
  } from "./runtime/resolver.js";
116
+ export type { ResolverVendorTransport } from "./runtime/resolver-vendors/types.js";
116
117
  export {
117
118
  assertRequiredSecretsPresent,
118
119
  listMissingRequiredSecrets,
package/src/lint.ts CHANGED
@@ -27,9 +27,97 @@ type ProviderAuthLike = {
27
27
  exchange?: unknown;
28
28
  };
29
29
 
30
+ // Operations that perform an auth-lifecycle action belong on the single
31
+ // `auth.flow` interface, never on a provider operation:
32
+ // - entry (login / signin / authenticate) => auth.flow.start/continue
33
+ // - exit (logout / signout / disconnect) => auth.flow.abort
34
+ //
35
+ // Matching works on `-`/`_` separated segments rather than a raw substring or a
36
+ // leading anchor, so `shop-logout`, `shop_logout` and `user-sign-out-everywhere`
37
+ // are all recognised: a domain prefix does not make the operation any less of an
38
+ // auth-lifecycle action, and operation ids may use either separator.
39
+ //
40
+ // Vocabulary is split into two tiers because auth words collide with ordinary
41
+ // domain verbs. Measured against the live fleet plus synthetic domain ids:
42
+ // - `authorize-payment`, `revoke-invitation`, `unlink-record`,
43
+ // `disconnect-device` are domain actions that never touch the connection
44
+ // credential, so these verbs are NOT matched as segments;
45
+ // - the same verbs as a complete operation id (`authorize`, `revoke`) do
46
+ // refer to the credential itself, so they are matched only in that form.
47
+ // `exchange`, `callback`, `connect`, `session`, `token`, `credential`,
48
+ // `password` and `otp` stay out entirely for the same reason.
49
+ const AUTH_LIFECYCLE_SEGMENT_WORDS = new Set([
50
+ "login",
51
+ "logout",
52
+ "signin",
53
+ "signout",
54
+ "signup",
55
+ "authenticate",
56
+ "reauth",
57
+ "auth",
58
+ ]);
59
+
60
+ // Ambiguous as a prefix, unambiguous when they are the whole operation id.
61
+ const AUTH_LIFECYCLE_WHOLE_ID_WORDS = new Set([
62
+ "authorize",
63
+ "revoke",
64
+ "unlink",
65
+ "disconnect",
66
+ ]);
67
+
68
+ // A verb stem followed by a direction word across two segments: `sign-out`,
69
+ // `user_sign_up_flow`, and the spelled-out `log-in` / `shop-log-out` forms
70
+ // (their fused equivalents `login`/`logout` live in the segment set above).
71
+ // `sign` pairs match anywhere; `log` pairs match only at the END of the id,
72
+ // because mid-id `log` is the noun in domain phrases measured against real
73
+ // fleets (`audit-log-in-range`, `change-log-out-of-band` are reads of a log,
74
+ // while `shop-log-out` is a logout).
75
+ const AUTH_DIRECTION_PAIRS: ReadonlyMap<
76
+ string,
77
+ { directions: ReadonlySet<string>; endOnly: boolean }
78
+ > = new Map([
79
+ ["sign", { directions: new Set(["in", "out", "up"]), endOnly: false }],
80
+ ["log", { directions: new Set(["in", "out"]), endOnly: true }],
81
+ ]);
82
+
83
+ // Legacy anchored form kept for token-plumbing words whose bare use is only
84
+ // auth-related when it leads the operation id (`exchange-code`, `refresh`).
30
85
  const AUTH_OPERATION_ID_PATTERN =
31
86
  /^(?:auth[-_])?(?:login|exchange|continue|refresh|callback)(?:[-_]|$)/i;
32
87
 
88
+ function isAuthLifecycleOperationId(operationId: string, authMode: string): boolean {
89
+ const segments = operationId.toLowerCase().split(/[-_]+/).filter(Boolean);
90
+
91
+ if (segments.some((segment) => AUTH_LIFECYCLE_SEGMENT_WORDS.has(segment))) return true;
92
+
93
+ // A verb stem + direction spread across two segments (`sign-out`,
94
+ // `sign_up`, `shop-log-out`); see AUTH_DIRECTION_PAIRS for positioning.
95
+ if (
96
+ segments.some((segment, index) => {
97
+ const pair = AUTH_DIRECTION_PAIRS.get(segment);
98
+ if (pair === undefined || index + 1 >= segments.length) return false;
99
+ if (!pair.directions.has(segments[index + 1] as string)) return false;
100
+ return pair.endOnly ? index + 2 === segments.length : true;
101
+ })
102
+ ) {
103
+ return true;
104
+ }
105
+
106
+ if (segments.length === 1 && AUTH_LIFECYCLE_WHOLE_ID_WORDS.has(segments[0] as string)) {
107
+ return true;
108
+ }
109
+
110
+ // The legacy anchored pattern keeps its original scope. It matches ordinary
111
+ // domain ids such as `exchange-rates` and `refresh-catalog`, so extending it
112
+ // to `oauth2_proxied` would spread that behavior to providers it never
113
+ // applied to; proxied providers are covered by the segment tiers above.
114
+ if (authMode === "credentials" || authMode === "oauth2") {
115
+ return AUTH_OPERATION_ID_PATTERN.test(operationId);
116
+ }
117
+
118
+ return false;
119
+ }
120
+
33
121
  type ProviderContractMetaLike = {
34
122
  publicSchemaFieldNames?: "normalized";
35
123
  };
@@ -1265,14 +1353,21 @@ export function lintProvider(
1265
1353
 
1266
1354
  if (provider.operations) {
1267
1355
  const authMode = provider.auth?.mode;
1268
- if (authMode === "credentials" || authMode === "oauth2") {
1356
+ // Every authenticated mode owns an auth.flow; `oauth2_proxied` was
1357
+ // previously exempt, which let auth-lifecycle operations ship on
1358
+ // proxied providers unchecked.
1359
+ if (
1360
+ authMode === "credentials" ||
1361
+ authMode === "oauth2" ||
1362
+ authMode === "oauth2_proxied"
1363
+ ) {
1269
1364
  for (const operationKey of Object.keys(provider.operations)) {
1270
- if (AUTH_OPERATION_ID_PATTERN.test(operationKey)) {
1365
+ if (isAuthLifecycleOperationId(operationKey, authMode)) {
1271
1366
  diagnostics.push({
1272
1367
  rule: "auth-operation-unsupported",
1273
1368
  level: "error",
1274
1369
  field: `operations.${operationKey}`,
1275
- message: `Provider "${provider.id ?? "unknown"}" operation "${operationKey}" looks like a login/token/session exchange endpoint. Authenticated providers must expose login through the single auth.flow interface because Gateway persists only auth.flow complete turn data.credential as the connection credential. Move this logic into auth.flow.continue instead of a provider operation.`,
1370
+ message: `Provider "${provider.id ?? "unknown"}" operation "${operationKey}" performs an auth-lifecycle action (login, logout, token exchange or similar). Authenticated providers must expose the whole credential lifecycle through the single auth.flow interface because Gateway persists only auth.flow complete turn data.credential as the connection credential, and an operation that mutates the session outside that interface leaves the stored connection stale. Move sign-in logic into auth.flow.start/continue and sign-out/disconnect logic into auth.flow.abort (served by POST /auth/disconnect) instead of a provider operation.`,
1276
1371
  });
1277
1372
  }
1278
1373
  }
@@ -1,30 +1,55 @@
1
1
  import type { ProviderChallenge, ProviderChallengeKind } from "../../types.js";
2
2
  import type { ResolverIssuingIdentity } from "./types.js";
3
3
 
4
+ type ResolverChallengeBinding = {
5
+ readonly cacheable: boolean;
6
+ readonly identityBinding: "none" | "identity_scoped" | "portable";
7
+ readonly directCacheable: boolean;
8
+ };
9
+
10
+ // An IP-bound artifact minted without any recorded egress identity is unsafe to
11
+ // share. The Akamai kinds therefore reject direct caching, while Cloudflare
12
+ // keeps its pre-existing direct-cache behavior pending measurement.
4
13
  export const RESOLVER_CHALLENGE_BINDINGS = {
5
- aws_waf: "portable",
6
- cloudflare_interstitial: "identity_scoped",
7
- } as const satisfies Partial<
8
- Readonly<Record<ProviderChallengeKind, "identity_scoped" | "portable">>
9
- >;
14
+ turnstile: { cacheable: false, identityBinding: "none", directCacheable: false },
15
+ recaptcha_v2: { cacheable: false, identityBinding: "none", directCacheable: false },
16
+ recaptcha_v3: { cacheable: false, identityBinding: "none", directCacheable: false },
17
+ hcaptcha: { cacheable: false, identityBinding: "none", directCacheable: false },
18
+ cloudflare_interstitial: {
19
+ cacheable: true,
20
+ identityBinding: "identity_scoped",
21
+ directCacheable: true,
22
+ },
23
+ aws_waf: { cacheable: true, identityBinding: "portable", directCacheable: true },
24
+ akamai_sec_cpt: {
25
+ cacheable: true,
26
+ identityBinding: "identity_scoped",
27
+ directCacheable: false,
28
+ },
29
+ akamai_sensor: {
30
+ cacheable: true,
31
+ identityBinding: "identity_scoped",
32
+ directCacheable: false,
33
+ },
34
+ } as const satisfies Readonly<Record<ProviderChallengeKind, ResolverChallengeBinding>>;
35
+
36
+ export function resolverChallengeIsCacheable(challenge: ProviderChallenge): boolean {
37
+ return RESOLVER_CHALLENGE_BINDINGS[challenge.kind].cacheable;
38
+ }
39
+
40
+ export function resolverChallengeAllowsDirectCache(challenge: ProviderChallenge): boolean {
41
+ return RESOLVER_CHALLENGE_BINDINGS[challenge.kind].directCacheable;
42
+ }
10
43
 
11
44
  export function resolverChallengeIsIdentityScoped(challenge: ProviderChallenge): boolean {
12
- return (
13
- RESOLVER_CHALLENGE_BINDINGS[challenge.kind as keyof typeof RESOLVER_CHALLENGE_BINDINGS] ===
14
- "identity_scoped"
15
- );
45
+ return RESOLVER_CHALLENGE_BINDINGS[challenge.kind].identityBinding === "identity_scoped";
16
46
  }
17
47
 
18
48
  export function resolverChallengeIssuingIdentity(
19
49
  challenge: ProviderChallenge,
20
50
  identity: ResolverIssuingIdentity,
21
51
  ): ResolverIssuingIdentity {
22
- const binding = (
23
- RESOLVER_CHALLENGE_BINDINGS as Partial<
24
- Readonly<Record<ProviderChallengeKind, "identity_scoped" | "portable">>
25
- >
26
- )[challenge.kind];
27
- if (binding === "portable") {
52
+ if (RESOLVER_CHALLENGE_BINDINGS[challenge.kind].identityBinding === "portable") {
28
53
  return { userAgent: identity.userAgent };
29
54
  }
30
55
  return identity;
@@ -1,4 +1,4 @@
1
- import { isProviderError, ProviderError } from "../../errors.js";
1
+ import { isProviderError } from "../../errors.js";
2
2
  import type {
3
3
  BrowserClient,
4
4
  BrowserCookie,
@@ -9,6 +9,7 @@ import type {
9
9
  import { type BrowserClientOptions, createBrowserClient } from "../browser.js";
10
10
  import type { TraceRecorder } from "../trace.js";
11
11
  import { resolverChallengeIssuingIdentity } from "./bindings.js";
12
+ import { assertResolverHostAllowed, normalizedResolverHostname } from "./hosts.js";
12
13
  import {
13
14
  type ResolverIdentity,
14
15
  type ResolverVendorAdapter,
@@ -35,11 +36,6 @@ export interface BrowserResolverVendorOptions {
35
36
  readonly createClient?: BrowserClientFactory;
36
37
  }
37
38
 
38
- export type BrowserResolverSolution = Extract<ChallengeSolution, { readonly form: "cookies" }> & {
39
- /** Unix seconds from the cookie that proved the challenge cleared. */
40
- readonly expires?: number;
41
- };
42
-
43
39
  export interface BrowserResolverVendorAdapter extends ResolverVendorAdapter {
44
40
  readonly id: "browser";
45
41
  solve(
@@ -47,7 +43,7 @@ export interface BrowserResolverVendorAdapter extends ResolverVendorAdapter {
47
43
  identity: ResolverIdentity | undefined,
48
44
  signal: AbortSignal,
49
45
  traceRecorder?: TraceRecorder,
50
- ): Promise<BrowserResolverSolution>;
46
+ ): Promise<Extract<ChallengeSolution, { readonly form: "cookies" }>>;
51
47
  }
52
48
 
53
49
  class BrowserSolveTimeoutError extends Error {
@@ -155,38 +151,20 @@ function isSupportedKind(kind: string): kind is SupportedBrowserChallengeKind {
155
151
  return Object.hasOwn(SUCCESS_COOKIE_NAMES, kind);
156
152
  }
157
153
 
158
- function normalizedHostname(hostname: string): string {
159
- return hostname.trim().toLowerCase().replace(/\.$/, "");
160
- }
161
-
162
- function assertChallengeHostAllowed(pageUrl: string, allowedHosts: readonly string[]): void {
163
- const challengeHost = normalizedHostname(new URL(pageUrl).hostname);
164
- const isAllowed = allowedHosts.some((host) => {
165
- const declaredHost = normalizedHostname(host);
166
- return declaredHost.length > 0 && !declaredHost.includes("*") && declaredHost === challengeHost;
167
- });
168
- if (isAllowed) return;
169
-
170
- throw new ProviderError(`Resolver challenge host "${challengeHost}" is not declared`, {
171
- code: "RESOLVER_HOST_NOT_ALLOWED",
172
- fix: "Add the exact challenge hostname to the provider's allowedHosts declaration.",
173
- });
174
- }
175
-
176
154
  function cookieDomainSpecificity(cookie: BrowserCookie): number {
177
- return normalizedHostname(cookie.domain.replace(/^\./, "")).length;
155
+ return normalizedResolverHostname(cookie.domain.replace(/^\./, "")).length;
178
156
  }
179
157
 
180
158
  function isHostOnlyCookieFor(cookie: BrowserCookie, hostname: string): boolean {
181
159
  return (
182
160
  !cookie.domain.startsWith(".") &&
183
- normalizedHostname(cookie.domain) === normalizedHostname(hostname)
161
+ normalizedResolverHostname(cookie.domain) === normalizedResolverHostname(hostname)
184
162
  );
185
163
  }
186
164
 
187
165
  function cookieAppliesToUrl(cookie: BrowserCookie, url: URL): boolean {
188
- const cookieDomain = normalizedHostname(cookie.domain.replace(/^\./, ""));
189
- const requestHostname = normalizedHostname(url.hostname);
166
+ const cookieDomain = normalizedResolverHostname(cookie.domain.replace(/^\./, ""));
167
+ const requestHostname = normalizedResolverHostname(url.hostname);
190
168
  const domainMatches =
191
169
  cookieDomain.length > 0 &&
192
170
  (requestHostname === cookieDomain ||
@@ -226,7 +204,7 @@ async function solveInPage(
226
204
  successCookieName: string,
227
205
  pollIntervalMs: number,
228
206
  signal: AbortSignal,
229
- ): Promise<BrowserResolverSolution> {
207
+ ): Promise<Extract<ChallengeSolution, { readonly form: "cookies" }>> {
230
208
  await raceWithAbort(() => page.goto(pageUrl), signal);
231
209
 
232
210
  while (true) {
@@ -345,7 +323,7 @@ export function createBrowserResolverVendorAdapter(
345
323
  if (!isSupportedKind(challenge.kind)) {
346
324
  throw new TypeError(`Browser resolver does not support ${challenge.kind}`);
347
325
  }
348
- assertChallengeHostAllowed(challenge.pageUrl, options.allowedHosts);
326
+ assertResolverHostAllowed(challenge.pageUrl, options.allowedHosts);
349
327
  const challengeKind = challenge.kind;
350
328
  callerSignal.throwIfAborted();
351
329