@apifuse/provider-sdk 2.2.0-beta.58 → 2.2.0-beta.59

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 (106) hide show
  1. package/AUTHORING.md +9 -0
  2. package/CHANGELOG.md +4 -0
  3. package/README.md +18 -2
  4. package/bin/apifuse-pack-types.ts +49 -0
  5. package/bin/apifuse-record.ts +6 -0
  6. package/dist/cli/templates/provider/README.md.tpl +15 -3
  7. package/dist/config/loader.d.ts +7 -0
  8. package/dist/config/loader.js +30 -6
  9. package/dist/declaration-validation.d.ts +2 -1
  10. package/dist/declaration-validation.js +39 -0
  11. package/dist/define.d.ts +4 -4
  12. package/dist/define.js +25 -1
  13. package/dist/engine.d.ts +16 -0
  14. package/dist/engine.js +56 -1
  15. package/dist/error-resolution.js +13 -0
  16. package/dist/index.d.ts +6 -4
  17. package/dist/index.js +4 -2
  18. package/dist/lint.js +2 -2
  19. package/dist/provider.d.ts +3 -3
  20. package/dist/provider.js +2 -2
  21. package/dist/runtime/bounded-expiring-map.d.ts +21 -0
  22. package/dist/runtime/bounded-expiring-map.js +64 -0
  23. package/dist/runtime/cache.d.ts +2 -0
  24. package/dist/runtime/cache.js +8 -1
  25. package/dist/runtime/egress-lease.d.ts +63 -0
  26. package/dist/runtime/egress-lease.js +233 -0
  27. package/dist/runtime/http.js +1 -0
  28. package/dist/runtime/proxy-retry-policy.d.ts +4 -1
  29. package/dist/runtime/proxy-retry-policy.js +11 -2
  30. package/dist/runtime/request-telemetry.d.ts +7 -9
  31. package/dist/runtime/resolver-config.d.ts +7 -0
  32. package/dist/runtime/resolver-config.js +8 -0
  33. package/dist/runtime/resolver-public.d.ts +3 -1
  34. package/dist/runtime/resolver-public.js +1 -1
  35. package/dist/runtime/resolver-shared.d.ts +9 -0
  36. package/dist/runtime/resolver-shared.js +57 -0
  37. package/dist/runtime/resolver-telemetry.d.ts +134 -0
  38. package/dist/runtime/resolver-telemetry.js +204 -0
  39. package/dist/runtime/resolver-usage.d.ts +24 -0
  40. package/dist/runtime/resolver-usage.js +60 -0
  41. package/dist/runtime/resolver-vendors/bindings.d.ts +5 -0
  42. package/dist/runtime/resolver-vendors/bindings.js +5 -0
  43. package/dist/runtime/resolver-vendors/capsolver.d.ts +2 -2
  44. package/dist/runtime/resolver-vendors/capsolver.js +21 -9
  45. package/dist/runtime/resolver-vendors/hypersolutions.d.ts +27 -0
  46. package/dist/runtime/resolver-vendors/hypersolutions.js +369 -0
  47. package/dist/runtime/resolver-vendors/twocaptcha.d.ts +2 -2
  48. package/dist/runtime/resolver-vendors/twocaptcha.js +26 -10
  49. package/dist/runtime/resolver-vendors/types.d.ts +35 -5
  50. package/dist/runtime/resolver-vendors/types.js +20 -10
  51. package/dist/runtime/resolver.d.ts +5 -1
  52. package/dist/runtime/resolver.js +397 -109
  53. package/dist/runtime/stealth-akamai-sbsd.d.ts +54 -0
  54. package/dist/runtime/stealth-akamai-sbsd.js +142 -0
  55. package/dist/runtime/stealth.js +547 -53
  56. package/dist/schema.d.ts +9 -0
  57. package/dist/schema.js +16 -0
  58. package/dist/server/index.d.ts +2 -0
  59. package/dist/server/index.js +1 -0
  60. package/dist/server/self-test.d.ts +4 -0
  61. package/dist/server/self-test.js +10 -1
  62. package/dist/server/serve-implementation.d.ts +7 -1
  63. package/dist/server/serve-implementation.js +181 -53
  64. package/dist/server/types.d.ts +6 -0
  65. package/dist/server/types.js +14 -0
  66. package/dist/stealth/profiles.d.ts +7 -1
  67. package/dist/stealth/profiles.js +9 -0
  68. package/dist/testing/run.js +6 -0
  69. package/dist/types.d.ts +106 -10
  70. package/package.json +1 -1
  71. package/src/cli/templates/provider/README.md.tpl +15 -3
  72. package/src/config/loader.ts +34 -6
  73. package/src/declaration-validation.ts +47 -1
  74. package/src/define.ts +45 -3
  75. package/src/engine.ts +75 -1
  76. package/src/error-resolution.ts +13 -0
  77. package/src/index.ts +32 -1
  78. package/src/lint.ts +2 -2
  79. package/src/provider.ts +9 -0
  80. package/src/runtime/bounded-expiring-map.ts +67 -0
  81. package/src/runtime/cache.ts +10 -1
  82. package/src/runtime/egress-lease.ts +336 -0
  83. package/src/runtime/http.ts +1 -0
  84. package/src/runtime/proxy-retry-policy.ts +12 -1
  85. package/src/runtime/request-telemetry.ts +5 -7
  86. package/src/runtime/resolver-config.ts +12 -0
  87. package/src/runtime/resolver-public.ts +21 -0
  88. package/src/runtime/resolver-shared.ts +75 -0
  89. package/src/runtime/resolver-telemetry.ts +362 -0
  90. package/src/runtime/resolver-usage.ts +96 -0
  91. package/src/runtime/resolver-vendors/bindings.ts +5 -0
  92. package/src/runtime/resolver-vendors/capsolver.ts +22 -3
  93. package/src/runtime/resolver-vendors/hypersolutions.ts +480 -0
  94. package/src/runtime/resolver-vendors/twocaptcha.ts +26 -12
  95. package/src/runtime/resolver-vendors/types.ts +60 -12
  96. package/src/runtime/resolver.ts +465 -124
  97. package/src/runtime/stealth-akamai-sbsd.ts +207 -0
  98. package/src/runtime/stealth.ts +727 -95
  99. package/src/schema.ts +17 -0
  100. package/src/server/index.ts +21 -0
  101. package/src/server/self-test.ts +13 -0
  102. package/src/server/serve-implementation.ts +268 -48
  103. package/src/server/types.ts +14 -0
  104. package/src/stealth/profiles.ts +10 -0
  105. package/src/testing/run.ts +6 -0
  106. package/src/types.ts +124 -19
@@ -10,6 +10,7 @@ export const SDK_OWNED_PROVIDER_ERROR_CODES = new Set([
10
10
  "BROWSER_CDP_POOL_REQUIRED",
11
11
  "BROWSER_RUNTIME_UNSUPPORTED",
12
12
  "STEALTH_RUNTIME_UNSUPPORTED",
13
+ "STEALTH_HEADER_OVERRIDE_UNSUPPORTED",
13
14
  "SSE_EVENT_UNDECLARED",
14
15
  "STREAM_EVENT_TOO_LARGE",
15
16
  "STREAM_CHUNK_TOO_LARGE",
@@ -51,6 +52,13 @@ export const SDK_OWNED_PROVIDER_ERROR_CODES = new Set([
51
52
  "http_redirect_missing_location",
52
53
  "http_redirect_loop",
53
54
  "transport_invalid_url",
55
+ "EGRESS_LEASE_INVALID",
56
+ "EGRESS_LEASE_BINDING_INVALID",
57
+ "EGRESS_LEASE_KEY_MISSING",
58
+ "EGRESS_LEASE_KEY_WEAK",
59
+ "STEALTH_BODY_UNSUPPORTED",
60
+ "REPLAY_SESSION_MISMATCH",
61
+ "REPLAY_ALREADY_ATTEMPTED",
54
62
  "retry_exhausted",
55
63
  "auth_abort_unsafe_data",
56
64
  "credentials_auth_missing_credential_keys",
@@ -117,6 +125,11 @@ export const SDK_STATUS_MAPPED_PROVIDER_ERROR_CODES: ReadonlyMap<string, Provide
117
125
  // contract): the upstream evaluated the request and said no under its
118
126
  // own rules — a conflict with upstream state, never a 5xx.
119
127
  ["UPSTREAM_REJECTED", 409],
128
+ // Only an unverifiable or foreign handle is the caller's: key and binding
129
+ // faults stay unmapped (500) because the engine host, not the caller, owns them.
130
+ ["EGRESS_LEASE_INVALID", 409],
131
+ ["REPLAY_SESSION_MISMATCH", 409],
132
+ ["REPLAY_ALREADY_ATTEMPTED", 409],
120
133
  ["UPSTREAM_ERROR", 502],
121
134
  ["BLOCKED", 502],
122
135
  ["OCR_UNAVAILABLE", 503],
package/src/index.ts CHANGED
@@ -198,17 +198,43 @@ export {
198
198
  type TelemetryKey,
199
199
  type TenantNeutral,
200
200
  } from "./runtime/request-telemetry.js";
201
+ export { APIFUSE__ENGINE__CEREMONY_LEASE_KEY } from "./runtime/egress-lease.js";
202
+ export {
203
+ ResolverTelemetryCollector,
204
+ type ResolverCacheReadTelemetryEvent,
205
+ type ResolverCacheWriteTelemetryEvent,
206
+ type ResolverFailoverTelemetryEvent,
207
+ type ResolverIdentityTelemetryEvent,
208
+ type ResolverOutcomeTelemetryEvent,
209
+ type ResolverTelemetryAttemptOutcome,
210
+ type ResolverAttemptSample,
211
+ type ResolverTelemetryCacheStatus,
212
+ type ResolverTelemetryCacheWriteReason,
213
+ type ResolverTelemetryErrorClass,
214
+ type ResolverTelemetryHeaderPayload,
215
+ type ResolverTelemetryIdentitySource,
216
+ type ResolverTelemetryLogPayload,
217
+ type ResolverTelemetryOutcome,
218
+ type ResolverTelemetryPhase,
219
+ type ResolverTelemetrySink,
220
+ type ResolverVendorAttemptTelemetryEvent,
221
+ } from "./runtime/resolver-telemetry.js";
201
222
  export {
202
223
  APIFUSE__CDP_POOL__URL,
203
224
  APIFUSE__RESOLVER__2CAPTCHA__API_KEY,
204
225
  APIFUSE__RESOLVER__CAPMONSTER__API_KEY,
205
226
  APIFUSE__RESOLVER__CAPSOLVER__API_KEY,
227
+ APIFUSE__RESOLVER__HYPERSOLUTIONS__API_KEY,
206
228
  APIFUSE__RESOLVER__TIMEOUT_MS,
207
229
  DEFAULT_RESOLVER_TIMEOUT_MS,
208
230
  } from "./runtime/resolver-config.js";
209
231
  export { createUnsupportedResolverClient } from "./runtime/resolver-shared.js";
210
232
  export type { ResolverRuntimeOptions } from "./runtime/resolver.js";
211
- export type { ResolverVendorTransport } from "./runtime/resolver-vendors/types.js";
233
+ export type {
234
+ ResolverPaidUsageContext,
235
+ ResolverVendorTransport,
236
+ ResolverVendorUnavailableReason,
237
+ } from "./runtime/resolver-vendors/types.js";
212
238
  export {
213
239
  assertRequiredSecretsPresent,
214
240
  listMissingRequiredSecrets,
@@ -250,6 +276,8 @@ export {
250
276
  type TraceContext,
251
277
  } from "./runtime/trace.js";
252
278
  export {
279
+ APIFUSE_CONTENT_PROVENANCE_META_KEY,
280
+ APIFUSE_CONTENT_TRUST_META_KEY,
253
281
  APIFUSE_DESCRIPTION_KEY_META_KEY,
254
282
  APIFUSE_REDACTION_MARKER,
255
283
  APIFUSE_SENSITIVE_KIND_META_KEY,
@@ -264,6 +292,7 @@ export {
264
292
  type SensitiveFieldOptions,
265
293
  type SensitivePath,
266
294
  sensitive,
295
+ untrustedContent,
267
296
  z,
268
297
  } from "./schema.js";
269
298
  export {
@@ -432,6 +461,7 @@ export type {
432
461
  ProviderRuntimeTarget,
433
462
  ProviderRuntimeState,
434
463
  ProviderSecretDeclaration,
464
+ ProviderStealthConfig,
435
465
  ProviderStateDurationString,
436
466
  ProviderStateNamespace,
437
467
  ProviderStreamEvent,
@@ -455,6 +485,7 @@ export type {
455
485
  StateWriteOptions,
456
486
  StealthClient,
457
487
  StealthBrowser,
488
+ StealthChallengeClassification,
458
489
  StealthCookieStore,
459
490
  StealthCookieStoreV1,
460
491
  StealthFetchOptions,
package/src/lint.ts CHANGED
@@ -1032,9 +1032,9 @@ function browserVersionLiteralMessage(finding: BrowserVersionLiteralFinding): st
1032
1032
  case "profile":
1033
1033
  return `Hardcoded stealth profile "${finding.literal}" pins a browser version and will rot. Select the browser and OS structurally, for example stealth: { browser: "chrome", os: "macos" }.`;
1034
1034
  case "user-agent":
1035
- return `Hardcoded User-Agent browser version "${finding.literal}" can disagree with the stealth TLS fingerprint. Remove the literal and derive it from the structured profile, for example getStealthProfile({ browser: "chrome", os: "macos" }).userAgent.`;
1035
+ return `Hardcoded User-Agent browser version "${finding.literal}" can disagree with the stealth TLS fingerprint. Remove the literal. ctx.stealth owns User-Agent and rejects a caller value with STEALTH_HEADER_OVERRIDE_UNSUPPORTED, so omit the header there and select the profile with stealth: { browser: "chrome", os: "macos" }. For ctx.http only, derive it from getStealthProfile({ browser: "chrome", os: "macos" }).userAgent.`;
1036
1036
  case "sec-ch-ua":
1037
- return 'Hardcoded sec-ch-ua versions can disagree with the stealth TLS fingerprint. Remove the literal and let ctx.stealth generate client hints from stealth: { browser: "chrome", os: "macos" }; derive any explicit User-Agent with getStealthProfile({ browser: "chrome", os: "macos" }).userAgent.';
1037
+ return 'Hardcoded sec-ch-ua versions can disagree with the stealth TLS fingerprint. Remove the literal and let ctx.stealth generate client hints from stealth: { browser: "chrome", os: "macos" }. ctx.stealth also owns User-Agent, so omit that header there as well; for ctx.http only, derive it from getStealthProfile({ browser: "chrome", os: "macos" }).userAgent.';
1038
1038
  }
1039
1039
  }
1040
1040
 
package/src/provider.ts CHANGED
@@ -133,9 +133,13 @@ export {
133
133
  createProviderEnvironment,
134
134
  createInProcessProviderEngine,
135
135
  ENGINE_OWNED_PROXY_CREDENTIAL_ENV_NAMES,
136
+ ENGINE_OWNED_RESOLVER_CREDENTIAL_ENV_NAMES,
137
+ ENGINE_OWNED_RUNTIME_ENV_NAMES,
136
138
  ENGINE_OWNED_TELEMETRY_ENV_NAMES,
137
139
  isEngineOwnedEnvName,
138
140
  isEngineOwnedProxyCredentialName,
141
+ isEngineOwnedResolverCredentialName,
142
+ isEngineOwnedRuntimeEnvName,
139
143
  isEngineOwnedTelemetryEnvName,
140
144
  PROVIDER_CAPABILITY_KEYS,
141
145
  PROVIDER_ENGINE_PROTOCOL_VERSION,
@@ -164,6 +168,8 @@ export {
164
168
  PROVIDER_RUNTIME_CHOICE_TOKEN_MASTER_SECRET_ENV,
165
169
  } from "./runtime/choice.js";
166
170
  export {
171
+ APIFUSE_CONTENT_PROVENANCE_META_KEY,
172
+ APIFUSE_CONTENT_TRUST_META_KEY,
167
173
  APIFUSE_DESCRIPTION_KEY_META_KEY,
168
174
  APIFUSE_REDACTION_MARKER,
169
175
  APIFUSE_SENSITIVE_KIND_META_KEY,
@@ -178,6 +184,7 @@ export {
178
184
  type SensitiveFieldOptions,
179
185
  type SensitivePath,
180
186
  sensitive,
187
+ untrustedContent,
181
188
  z,
182
189
  } from "./schema.js";
183
190
  export type {
@@ -261,6 +268,7 @@ export type {
261
268
  ProviderResolvedFile,
262
269
  ProviderRuntimeTarget,
263
270
  ProviderRuntimeState,
271
+ ProviderStealthConfig,
264
272
  ProviderStateDurationString,
265
273
  ProviderStateNamespace,
266
274
  ProviderSupportLevel,
@@ -273,6 +281,7 @@ export type {
273
281
  StateNamespaceScope,
274
282
  StateValue,
275
283
  StateWriteOptions,
284
+ StealthChallengeClassification,
276
285
  } from "./types.js";
277
286
  export {
278
287
  NativeEgressGrantExpiredError,
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Map whose entries carry an expiry and whose size is capped.
3
+ *
4
+ * Expired entries are dropped when read and swept on every insert, so a
5
+ * process that keeps minting new keys does not retain dead values. When the
6
+ * cap is still exceeded after the sweep, the least recently used entry is
7
+ * evicted and counted in `capacityEvictions` so callers can tell the two
8
+ * kinds of removal apart. Callers pass `now` explicitly so a single timestamp
9
+ * is used across a lookup-then-store sequence.
10
+ */
11
+ export class BoundedExpiringMap<K, V> {
12
+ readonly #entries = new Map<K, V>();
13
+ readonly #maxEntries: number;
14
+ readonly #expiresAt: (value: V) => number;
15
+ #capacityEvictions = 0;
16
+
17
+ constructor(maxEntries: number, expiresAt: (value: V) => number) {
18
+ if (!Number.isInteger(maxEntries) || maxEntries <= 0) {
19
+ throw new RangeError("maxEntries must be a positive integer");
20
+ }
21
+ this.#maxEntries = maxEntries;
22
+ this.#expiresAt = expiresAt;
23
+ }
24
+
25
+ get size(): number {
26
+ return this.#entries.size;
27
+ }
28
+
29
+ /** Entries removed to satisfy `maxEntries`; expiry removals are not counted. */
30
+ get capacityEvictions(): number {
31
+ return this.#capacityEvictions;
32
+ }
33
+
34
+ get(key: K, now: number): V | undefined {
35
+ if (!this.#entries.has(key)) return undefined;
36
+ const value = this.#entries.get(key) as V;
37
+ this.#entries.delete(key);
38
+ if (this.#expiresAt(value) <= now) return undefined;
39
+ // Re-insert so Map iteration order doubles as the LRU order.
40
+ this.#entries.set(key, value);
41
+ return value;
42
+ }
43
+
44
+ set(key: K, value: V, now: number): void {
45
+ for (const [existingKey, existing] of this.#entries) {
46
+ if (this.#expiresAt(existing) <= now) this.#entries.delete(existingKey);
47
+ }
48
+ this.#entries.delete(key);
49
+ if (this.#expiresAt(value) <= now) return;
50
+ this.#entries.set(key, value);
51
+ if (this.#entries.size > this.#maxEntries) {
52
+ const oldest = this.#entries.keys().next();
53
+ if (!oldest.done) {
54
+ this.#entries.delete(oldest.value);
55
+ this.#capacityEvictions += 1;
56
+ }
57
+ }
58
+ }
59
+
60
+ delete(key: K): boolean {
61
+ return this.#entries.delete(key);
62
+ }
63
+
64
+ clear(): void {
65
+ this.#entries.clear();
66
+ }
67
+ }
@@ -589,6 +589,13 @@ export function createProviderCache(options: ProviderCacheOptions): ProviderCach
589
589
  };
590
590
  }
591
591
 
592
+ const bypassCaches = new WeakSet<ProviderCache>();
593
+
594
+ /** Internal cache capability, separate from hit/miss metadata. */
595
+ export function isProviderCacheBypassed(cache: ProviderCache): boolean {
596
+ return bypassCaches.has(cache);
597
+ }
598
+
592
599
  export function createBypassProviderCache(
593
600
  options: Pick<ProviderCacheOptions, "providerId">,
594
601
  ): ProviderCache {
@@ -597,7 +604,7 @@ export function createBypassProviderCache(
597
604
  const events: ProviderCacheLookupMeta[] = [];
598
605
  const secretScopedKeys = new Set<string>();
599
606
 
600
- return {
607
+ const cache: ProviderCache = {
601
608
  key(namespace, parts, keyOptions?: ProviderCacheKeyOptions) {
602
609
  const extra = new Set((keyOptions?.redactFields ?? []).map((field) => field.toLowerCase()));
603
610
  const normalized = normalizeKeyPart(parts, extra, pepper);
@@ -643,6 +650,8 @@ export function createBypassProviderCache(
643
650
  };
644
651
  },
645
652
  };
653
+ bypassCaches.add(cache);
654
+ return cache;
646
655
  }
647
656
 
648
657
  export function resetProviderCacheForTests(): void {
@@ -0,0 +1,336 @@
1
+ import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
2
+
3
+ import type { ProxyVendorName } from "../config/loader.js";
4
+ import { SDKError } from "../errors.js";
5
+
6
+ /**
7
+ * AES-256-GCM key for the engine ceremony egress lease handle (ADR-0010 v1.1).
8
+ * Engine host only: the `APIFUSE__ENGINE__` family is rejected from provider
9
+ * secrets and filtered from provider env projections. Supply at least 32 random
10
+ * bytes (for example `openssl rand -base64 32`). Rotating the key fails
11
+ * verification of every outstanding handle (`EGRESS_LEASE_INVALID`, 409): the
12
+ * affected ceremonies restart; they do not expire.
13
+ */
14
+ export const APIFUSE__ENGINE__CEREMONY_LEASE_KEY = "APIFUSE__ENGINE__CEREMONY_LEASE_KEY";
15
+
16
+ /** Internal option key shared only by the server assembler and stealth runtime. */
17
+ export const ENGINE_CEREMONY_EGRESS_LEASE = Symbol.for(
18
+ "@apifuse/provider-sdk/engine-ceremony-egress-lease",
19
+ );
20
+
21
+ const HANDLE_VERSION = 1;
22
+ const HANDLE_MAX_BYTES = 16_384;
23
+ const AEAD_NONCE_BYTES = 12;
24
+ const AEAD_TAG_BYTES = 16;
25
+ const AEAD_ALGORITHM = "aes-256-gcm";
26
+ const AEAD_KEY_DOMAIN = "apifuse:ceremony-egress-lease:v1\0";
27
+ const LEASE_KEY_MIN_BYTES = 32;
28
+
29
+ export type CeremonyEgressBinding = {
30
+ readonly vendor: ProxyVendorName;
31
+ readonly proxyUrl: string;
32
+ readonly poolIndex: number;
33
+ readonly affinityKey: string;
34
+ readonly refreshEpoch: number;
35
+ /**
36
+ * Vendor session lifetime the endpoint was allocated for (`proxy.session.lifetimeMinutes`,
37
+ * Smartproxy `life`, NodeMaven SID window). It is the upper bound of the lease, counted
38
+ * from the mint (the first successful attempt), so a Smartproxy lease can outlive the
39
+ * vendor session by the extraction-cache reuse window; expiry then drops and rebinds.
40
+ * The bound is not a vendor guarantee (ADR-0010: a raw Smartproxy endpoint is not a hard
41
+ * lease): the stealth runtime releases the binding as soon as the endpoint fails at the
42
+ * transport, so a dead endpoint is left before the lifetime elapses.
43
+ */
44
+ readonly lifetimeMinutes: number;
45
+ };
46
+
47
+ type CeremonyEgressLeasePayloadV1 = CeremonyEgressBinding & {
48
+ readonly version: typeof HANDLE_VERSION;
49
+ readonly tenantId: string;
50
+ readonly providerId: string;
51
+ readonly flowId: string;
52
+ readonly mintedAtMs: number;
53
+ readonly expiresAtMs: number;
54
+ };
55
+
56
+ type CeremonyEgressLeaseScopeV1 = Pick<
57
+ CeremonyEgressLeasePayloadV1,
58
+ "tenantId" | "providerId" | "flowId" | "affinityKey"
59
+ >;
60
+
61
+ export type CeremonyEgressLeaseRuntime = {
62
+ /** Exact egress bound to this ceremony; undefined until the first successful attempt binds one. */
63
+ readonly binding: CeremonyEgressBinding | undefined;
64
+ /**
65
+ * Drops the binding once its vendor session lifetime has elapsed so the next attempt
66
+ * selects and binds a fresh endpoint. Returns true when a binding was dropped: the
67
+ * caller must also discard challenge state that was only valid for that endpoint.
68
+ */
69
+ dropExpiredBinding(): boolean;
70
+ /**
71
+ * Drops the binding unconditionally; the stealth runtime calls this when the bound
72
+ * endpoint fails at the transport. Same return and caller obligation as
73
+ * `dropExpiredBinding`.
74
+ */
75
+ dropBinding(): boolean;
76
+ /**
77
+ * Number of bindings dropped so far (expiry or release). Every stealth session created
78
+ * from one client shares the lease; a session compares this to what it last observed to
79
+ * retire endpoint-bound challenge state and replay records after another session moved
80
+ * the ceremony.
81
+ */
82
+ readonly generation: number;
83
+ bind(binding: CeremonyEgressBinding): void;
84
+ handle(): string | undefined;
85
+ };
86
+
87
+ /** The caller presented a handle this engine cannot verify for this ceremony (409). */
88
+ function invalidLease(): never {
89
+ throw new SDKError("The engine ceremony egress lease is invalid", {
90
+ code: "EGRESS_LEASE_INVALID",
91
+ fix: "Restart the authentication ceremony to obtain a new engine-owned egress lease.",
92
+ });
93
+ }
94
+
95
+ /** The engine violated its own binding invariant (500); never attributable to the caller. */
96
+ function bindingFault(message: string): never {
97
+ throw new SDKError(message, {
98
+ code: "EGRESS_LEASE_BINDING_INVALID",
99
+ fix: "This is an SDK or provider-runtime fault; the ceremony lease cannot bind the selected egress.",
100
+ });
101
+ }
102
+
103
+ function encryptionKey(key: string): Buffer {
104
+ return createHash("sha256").update(AEAD_KEY_DOMAIN, "utf8").update(key, "utf8").digest();
105
+ }
106
+
107
+ /** Fixed field order and JSON.stringify's compact form are the canonical AAD encoding. */
108
+ function scopeAad(scope: CeremonyEgressLeaseScopeV1): Buffer {
109
+ return Buffer.from(
110
+ JSON.stringify({
111
+ version: HANDLE_VERSION,
112
+ tenantId: scope.tenantId,
113
+ providerId: scope.providerId,
114
+ flowId: scope.flowId,
115
+ affinityKey: scope.affinityKey,
116
+ }),
117
+ "utf8",
118
+ );
119
+ }
120
+
121
+ function encodeHandle(
122
+ payload: CeremonyEgressLeasePayloadV1,
123
+ key: string,
124
+ scope: CeremonyEgressLeaseScopeV1,
125
+ ): string {
126
+ const nonce = randomBytes(AEAD_NONCE_BYTES);
127
+ const cipher = createCipheriv(AEAD_ALGORITHM, encryptionKey(key), nonce, {
128
+ authTagLength: AEAD_TAG_BYTES,
129
+ });
130
+ cipher.setAAD(scopeAad(scope));
131
+ const ciphertext = Buffer.concat([
132
+ cipher.update(JSON.stringify(payload), "utf8"),
133
+ cipher.final(),
134
+ ]);
135
+ return [
136
+ `v${HANDLE_VERSION}`,
137
+ nonce.toString("base64url"),
138
+ ciphertext.toString("base64url"),
139
+ cipher.getAuthTag().toString("base64url"),
140
+ ].join(".");
141
+ }
142
+
143
+ function isFiniteInteger(value: unknown): value is number {
144
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
145
+ }
146
+
147
+ function isPositiveFinite(value: unknown): value is number {
148
+ return typeof value === "number" && Number.isFinite(value) && value > 0;
149
+ }
150
+
151
+ function decodeHandle(
152
+ handle: string,
153
+ key: string,
154
+ scope: CeremonyEgressLeaseScopeV1,
155
+ ): CeremonyEgressLeasePayloadV1 {
156
+ if (Buffer.byteLength(handle, "utf8") > HANDLE_MAX_BYTES) return invalidLease();
157
+ const parts = handle.split(".");
158
+ if (
159
+ parts.length !== 4 ||
160
+ parts[0] !== `v${HANDLE_VERSION}` ||
161
+ parts.slice(1).some((part) => !/^[A-Za-z0-9_-]+$/u.test(part))
162
+ ) {
163
+ return invalidLease();
164
+ }
165
+ let parsed: unknown;
166
+ try {
167
+ const nonce = Buffer.from(parts[1]!, "base64url");
168
+ const ciphertext = Buffer.from(parts[2]!, "base64url");
169
+ const tag = Buffer.from(parts[3]!, "base64url");
170
+ if (
171
+ nonce.byteLength !== AEAD_NONCE_BYTES ||
172
+ tag.byteLength !== AEAD_TAG_BYTES ||
173
+ ciphertext.byteLength === 0
174
+ ) {
175
+ return invalidLease();
176
+ }
177
+ const decipher = createDecipheriv(AEAD_ALGORITHM, encryptionKey(key), nonce, {
178
+ authTagLength: AEAD_TAG_BYTES,
179
+ });
180
+ decipher.setAAD(scopeAad(scope));
181
+ decipher.setAuthTag(tag);
182
+ const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
183
+ parsed = JSON.parse(plaintext.toString("utf8"));
184
+ } catch {
185
+ return invalidLease();
186
+ }
187
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return invalidLease();
188
+ const candidate = parsed as Partial<CeremonyEgressLeasePayloadV1>;
189
+ if (
190
+ candidate.version !== HANDLE_VERSION ||
191
+ (candidate.vendor !== "smartproxy" && candidate.vendor !== "nodemaven") ||
192
+ typeof candidate.proxyUrl !== "string" ||
193
+ !candidate.proxyUrl ||
194
+ !isFiniteInteger(candidate.poolIndex) ||
195
+ typeof candidate.affinityKey !== "string" ||
196
+ !candidate.affinityKey ||
197
+ !isFiniteInteger(candidate.refreshEpoch) ||
198
+ typeof candidate.tenantId !== "string" ||
199
+ !candidate.tenantId ||
200
+ typeof candidate.providerId !== "string" ||
201
+ !candidate.providerId ||
202
+ typeof candidate.flowId !== "string" ||
203
+ !candidate.flowId ||
204
+ !isFiniteInteger(candidate.mintedAtMs) ||
205
+ !isFiniteInteger(candidate.expiresAtMs) ||
206
+ candidate.expiresAtMs <= candidate.mintedAtMs ||
207
+ !isPositiveFinite(candidate.lifetimeMinutes)
208
+ ) {
209
+ return invalidLease();
210
+ }
211
+ try {
212
+ const proxy = new URL(candidate.proxyUrl);
213
+ if (proxy.protocol !== "http:" && proxy.protocol !== "socks5:") return invalidLease();
214
+ } catch {
215
+ return invalidLease();
216
+ }
217
+ return candidate as CeremonyEgressLeasePayloadV1;
218
+ }
219
+
220
+ function bindingOf(payload: CeremonyEgressLeasePayloadV1): CeremonyEgressBinding {
221
+ return {
222
+ vendor: payload.vendor,
223
+ proxyUrl: payload.proxyUrl,
224
+ poolIndex: payload.poolIndex,
225
+ affinityKey: payload.affinityKey,
226
+ refreshEpoch: payload.refreshEpoch,
227
+ lifetimeMinutes: payload.lifetimeMinutes,
228
+ };
229
+ }
230
+
231
+ export function createCeremonyEgressLeaseRuntime(options: {
232
+ readonly tenantId?: string;
233
+ readonly providerId: string;
234
+ readonly flowId: string;
235
+ readonly affinityKey: string;
236
+ readonly handle?: string;
237
+ readonly environment?: Readonly<Record<string, string | undefined>>;
238
+ readonly now?: () => number;
239
+ }): CeremonyEgressLeaseRuntime {
240
+ const environment = options.environment ?? process.env;
241
+ const now = options.now ?? Date.now;
242
+ if (typeof options.tenantId !== "string" || !options.tenantId.trim()) {
243
+ throw new SDKError("A tenant is required for an engine ceremony egress lease", {
244
+ code: "EGRESS_LEASE_INVALID",
245
+ fix: "Restart the authentication ceremony with its tenant identity.",
246
+ });
247
+ }
248
+ const scope: CeremonyEgressLeaseScopeV1 = {
249
+ tenantId: options.tenantId,
250
+ providerId: options.providerId,
251
+ flowId: options.flowId,
252
+ affinityKey: options.affinityKey,
253
+ };
254
+ const key = environment[APIFUSE__ENGINE__CEREMONY_LEASE_KEY]?.trim();
255
+ if (!key) {
256
+ throw new SDKError("The engine ceremony egress lease key is not configured", {
257
+ code: "EGRESS_LEASE_KEY_MISSING",
258
+ fix: `Configure ${APIFUSE__ENGINE__CEREMONY_LEASE_KEY} in the engine host.`,
259
+ });
260
+ }
261
+ // The key is the whole authenticity boundary of a handle (a verified handle's proxyUrl
262
+ // becomes the ceremony egress), so the documented minimum is enforced, not advisory.
263
+ if (Buffer.byteLength(key, "utf8") < LEASE_KEY_MIN_BYTES) {
264
+ throw new SDKError(
265
+ `The engine ceremony egress lease key is shorter than ${LEASE_KEY_MIN_BYTES} bytes`,
266
+ {
267
+ code: "EGRESS_LEASE_KEY_WEAK",
268
+ fix: `Set ${APIFUSE__ENGINE__CEREMONY_LEASE_KEY} to at least ${LEASE_KEY_MIN_BYTES} random bytes (for example \`openssl rand -base64 32\`).`,
269
+ },
270
+ );
271
+ }
272
+ let payload: CeremonyEgressLeasePayloadV1 | undefined;
273
+ let currentHandle: string | undefined;
274
+ let generation = 0;
275
+
276
+ const dropBinding = (): boolean => {
277
+ if (!payload) return false;
278
+ payload = undefined;
279
+ currentHandle = undefined;
280
+ generation += 1;
281
+ return true;
282
+ };
283
+ const dropExpiredBinding = (): boolean =>
284
+ payload !== undefined && now() >= payload.expiresAtMs ? dropBinding() : false;
285
+
286
+ if (options.handle !== undefined) {
287
+ // Scope mismatch fails GCM authentication because scope is AAD; there is
288
+ // deliberately no post-decrypt scope comparison fallback.
289
+ payload = decodeHandle(options.handle, key, scope);
290
+ currentHandle = options.handle;
291
+ // An expired but authentic handle is not a fault: the ceremony continues on a
292
+ // freshly selected endpoint and the next turn receives the new handle.
293
+ dropExpiredBinding();
294
+ }
295
+
296
+ return {
297
+ get binding() {
298
+ return payload ? bindingOf(payload) : undefined;
299
+ },
300
+ dropExpiredBinding,
301
+ dropBinding,
302
+ get generation() {
303
+ return generation;
304
+ },
305
+ bind(binding) {
306
+ if (payload) {
307
+ // The stealth runtime reuses the bound endpoint while a binding exists and
308
+ // never rebinds it; a second bind is an engine invariant break.
309
+ return bindingFault("The ceremony egress lease is already bound");
310
+ }
311
+ if (
312
+ binding.affinityKey !== options.affinityKey ||
313
+ !binding.proxyUrl ||
314
+ (binding.vendor !== "smartproxy" && binding.vendor !== "nodemaven") ||
315
+ !isFiniteInteger(binding.poolIndex) ||
316
+ !isPositiveFinite(binding.lifetimeMinutes)
317
+ ) {
318
+ return bindingFault("The selected egress cannot be bound to the ceremony lease");
319
+ }
320
+ const mintedAtMs = now();
321
+ payload = {
322
+ version: HANDLE_VERSION,
323
+ tenantId: scope.tenantId,
324
+ providerId: options.providerId,
325
+ flowId: options.flowId,
326
+ ...binding,
327
+ mintedAtMs,
328
+ expiresAtMs: mintedAtMs + binding.lifetimeMinutes * 60_000,
329
+ };
330
+ currentHandle = encodeHandle(payload, key, scope);
331
+ },
332
+ handle() {
333
+ return currentHandle;
334
+ },
335
+ };
336
+ }
@@ -654,6 +654,7 @@ async function resolveNativeProxy(
654
654
  // supported here, so a socks5 policy fails loudly rather than downgrading.
655
655
  transportProtocols: ["http"],
656
656
  telemetry: clientOptions.telemetry,
657
+ engineCredentials: clientOptions.engineCredentials,
657
658
  });
658
659
  if (resolvedProxy.shouldWarn) {
659
660
  warn(
@@ -7,6 +7,7 @@ import {
7
7
  HttpRetryPreset,
8
8
  HttpRetryUnsafeMethodPolicy,
9
9
  } from "../types.js";
10
+ import { isSensitiveKey } from "./request-options.js";
10
11
 
11
12
  export type NormalizedProxyTransportRetryOptions = Required<
12
13
  Pick<
@@ -364,10 +365,20 @@ export function validateUnsafeProxyTransportRetryMethods(
364
365
  export function isProxyTransportRetryMethod(
365
366
  method: HttpMethod | string,
366
367
  options: NormalizedProxyTransportRetryOptions,
368
+ request?: {
369
+ readonly body?: unknown;
370
+ readonly headers?: Readonly<Record<string, string | readonly string[] | undefined>>;
371
+ },
367
372
  ): boolean {
368
- return options.methods
373
+ const methodAllowed = options.methods
369
374
  .map((allowedMethod) => allowedMethod.toUpperCase())
370
375
  .includes(method.toUpperCase());
376
+ if (!methodAllowed || request === undefined) return methodAllowed;
377
+ if (request.body !== undefined) return false;
378
+ return !Object.keys(request.headers ?? {}).some((name) => {
379
+ const normalized = name.toLowerCase();
380
+ return normalized === "cookie" || normalized === "proxy-authorization" || isSensitiveKey(name);
381
+ });
371
382
  }
372
383
 
373
384
  export function shouldRetryProxyTransportError(
@@ -1,6 +1,7 @@
1
1
  import { PROVIDER_OBSERVABILITY_TAXONOMY_VERSION } from "../observability.js";
2
2
  import type { ProxyTelemetrySink } from "../config/loader.js";
3
3
  import type { ProxyTelemetryLogPayload } from "./proxy-telemetry.js";
4
+ import type { ResolverTelemetryLogPayload } from "./resolver-telemetry.js";
4
5
  import type { Span, TraceContext } from "./trace.js";
5
6
 
6
7
  export const PROVIDER_TELEMETRY_HEADER = "X-ApiFuse-Provider-Telemetry";
@@ -57,9 +58,7 @@ export type GatewayIngestible<T> = 0 extends 1 & T
57
58
  ? never
58
59
  : unknown extends U
59
60
  ? never
60
- : [U] extends [
61
- number | boolean | ClosedEnum<string> | GatewayIngestible<U>,
62
- ]
61
+ : [U] extends [number | boolean | ClosedEnum<string> | GatewayIngestible<U>]
63
62
  ? T[K]
64
63
  : never
65
64
  : [NonNullable<T[K]>] extends [object]
@@ -86,9 +85,7 @@ export type TenantNeutral<T> = 0 extends 1 & T
86
85
  ? never
87
86
  : unknown extends T[K]
88
87
  ? never
89
- : [NonNullable<T[K]>] extends [
90
- string[] & { readonly __tenantOpaqueCacheKeys: true },
91
- ]
88
+ : [NonNullable<T[K]>] extends [string[] & { readonly __tenantOpaqueCacheKeys: true }]
92
89
  ? K extends "keys"
93
90
  ? T[K]
94
91
  : never
@@ -123,7 +120,8 @@ export interface TelemetryContributor<Log extends object, Header extends object>
123
120
 
124
121
  export type RequestTelemetryLogPayload = {
125
122
  proxy?: ProxyTelemetryLogPayload;
126
- } & Partial<Record<Exclude<TelemetryKey, "proxy">, object>>;
123
+ resolver?: ResolverTelemetryLogPayload;
124
+ } & Partial<Record<Exclude<TelemetryKey, "proxy" | "resolver">, object>>;
127
125
 
128
126
  type RegisteredTelemetryContributor = {
129
127
  readonly key: TelemetryKey;