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

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 (53) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/bin/apifuse-dev.ts +2 -0
  3. package/bin/apifuse-pack-types.ts +234 -38
  4. package/bin/apifuse-perf.ts +15 -12
  5. package/bin/apifuse-record.ts +2 -0
  6. package/bin/apifuse-submit-check.ts +15 -2
  7. package/dist/config/loader.d.ts +8 -19
  8. package/dist/config/loader.js +28 -86
  9. package/dist/define.d.ts +4 -1
  10. package/dist/define.js +64 -6
  11. package/dist/index.d.ts +4 -3
  12. package/dist/index.js +2 -1
  13. package/dist/provider.d.ts +1 -1
  14. package/dist/runtime/auth-flow.js +2 -0
  15. package/dist/runtime/browser.js +50 -0
  16. package/dist/runtime/cache.d.ts +1 -0
  17. package/dist/runtime/cache.js +169 -15
  18. package/dist/runtime/http.js +0 -1
  19. package/dist/runtime/instrumentation.js +26 -1
  20. package/dist/runtime/resolver-vendors/bindings.d.ts +8 -0
  21. package/dist/runtime/resolver-vendors/bindings.js +15 -0
  22. package/dist/runtime/resolver-vendors/browser.d.ts +24 -0
  23. package/dist/runtime/resolver-vendors/browser.js +287 -0
  24. package/dist/runtime/resolver-vendors/types.d.ts +42 -0
  25. package/dist/runtime/resolver-vendors/types.js +57 -0
  26. package/dist/runtime/resolver.d.ts +39 -0
  27. package/dist/runtime/resolver.js +414 -0
  28. package/dist/runtime/state.d.ts +3 -0
  29. package/dist/runtime/state.js +245 -141
  30. package/dist/runtime/stealth.js +3 -6
  31. package/dist/server/serve.d.ts +4 -1
  32. package/dist/server/serve.js +35 -8
  33. package/dist/testing/run.js +7 -0
  34. package/dist/types.d.ts +115 -7
  35. package/package.json +1 -1
  36. package/src/config/loader.ts +35 -111
  37. package/src/define.ts +105 -8
  38. package/src/index.ts +21 -1
  39. package/src/provider.ts +1 -0
  40. package/src/runtime/auth-flow.ts +2 -0
  41. package/src/runtime/browser.ts +69 -0
  42. package/src/runtime/cache.ts +189 -14
  43. package/src/runtime/http.ts +0 -1
  44. package/src/runtime/instrumentation.ts +36 -2
  45. package/src/runtime/resolver-vendors/bindings.ts +31 -0
  46. package/src/runtime/resolver-vendors/browser.ts +420 -0
  47. package/src/runtime/resolver-vendors/types.ts +113 -0
  48. package/src/runtime/resolver.ts +668 -0
  49. package/src/runtime/state.ts +323 -166
  50. package/src/runtime/stealth.ts +3 -6
  51. package/src/server/serve.ts +73 -5
  52. package/src/testing/run.ts +8 -0
  53. package/src/types.ts +133 -7
package/src/define.ts CHANGED
@@ -20,21 +20,24 @@ import type {
20
20
  HealthJourneySchedule,
21
21
  HealthScheduleRandomization,
22
22
  InferSchemaOutput,
23
+ NativeProviderConfig,
23
24
  OperationDefinition,
24
25
  OperationHandlerResult,
25
26
  OperationHttpStreamTransport,
26
27
  OperationSseTransport,
27
28
  OperationTransport,
28
29
  OperationWebSocketTransport,
29
- NativeProviderConfig,
30
- ProviderOcrConfig,
31
30
  ProviderAccessConfig,
31
+ ProviderChallengeKind,
32
32
  ProviderDefinition,
33
+ ProviderOcrConfig,
33
34
  ProviderDeploymentOverrides,
34
35
  ProviderHealthMonitorConfig,
35
36
  ProviderProxyConfig,
36
37
  ProviderProxyProvider,
37
38
  ProviderPublicProfile,
39
+ ProviderResolverConfig,
40
+ ProviderResolverVendor,
38
41
  ProviderReviewed,
39
42
  ProviderSecretDeclaration,
40
43
  ProviderStreamEvent,
@@ -126,6 +129,30 @@ const VALID_PROVIDER_PROXY_AFFINITIES = [
126
129
  ] as const;
127
130
  const VALID_PROVIDER_OCR_MODES = ["optional", "required"] as const;
128
131
  const VALID_PROVIDER_STT_MODES = ["optional", "required"] as const;
132
+ function exhaustiveLiteralArray<TUnion extends string>() {
133
+ return <const TValues extends readonly TUnion[]>(
134
+ values: TValues,
135
+ ..._missing: Exclude<TUnion, TValues[number]> extends never
136
+ ? []
137
+ : ["Missing runtime values", Exclude<TUnion, TValues[number]>]
138
+ ): TValues => values;
139
+ }
140
+
141
+ export const VALID_PROVIDER_RESOLVER_VENDORS = exhaustiveLiteralArray<ProviderResolverVendor>()([
142
+ "browser",
143
+ "capsolver",
144
+ "capmonster",
145
+ "2captcha",
146
+ "custom",
147
+ ] as const);
148
+ export const VALID_PROVIDER_CHALLENGE_KINDS = exhaustiveLiteralArray<ProviderChallengeKind>()([
149
+ "turnstile",
150
+ "recaptcha_v2",
151
+ "recaptcha_v3",
152
+ "hcaptcha",
153
+ "cloudflare_interstitial",
154
+ "aws_waf",
155
+ ] as const);
129
156
  const SMARTPROXY_APP_KEY_SECRET = "APIFUSE__PROXY__SMARTPROXY_APP_KEY";
130
157
  const NODEMAVEN_USERNAME_SECRET = "APIFUSE__PROXY__NODEMAVEN_USERNAME";
131
158
  const NODEMAVEN_PASSWORD_SECRET = "APIFUSE__PROXY__NODEMAVEN_PASSWORD";
@@ -134,9 +161,8 @@ const NODEMAVEN_PASSWORD_SECRET = "APIFUSE__PROXY__NODEMAVEN_PASSWORD";
134
161
  // credential fails at build/validation time rather than during a live outage: a
135
162
  // declared-but-uncredentialed fallback leg is a silently dead SPOF, which is
136
163
  // exactly the failure class the multi-vendor chain exists to remove. Vendors
137
- // absent from this map (e.g. `custom`/`decodo`, whose credentials come from the
138
- // `APIFUSE__PROXY__URL` bring-your-own escape hatch, not provider secrets) impose
139
- // no declaration requirement.
164
+ // absent from this map (the deprecated `custom`/`decodo` values have no managed
165
+ // adapter) impose no declaration requirement.
140
166
  const VENDOR_REQUIRED_SECRETS: Partial<Record<ProviderProxyProvider, readonly string[]>> = {
141
167
  smartproxy: [SMARTPROXY_APP_KEY_SECRET],
142
168
  nodemaven: [NODEMAVEN_USERNAME_SECRET, NODEMAVEN_PASSWORD_SECRET],
@@ -252,6 +278,7 @@ export interface ProviderConfig<TOperations extends Record<string, ProviderOpera
252
278
  proxy?: ProviderProxyConfig;
253
279
  ocr?: ProviderOcrConfig;
254
280
  stt?: ProviderSttConfig;
281
+ resolver?: ProviderResolverConfig;
255
282
  browser?: { engine: BrowserEngine };
256
283
  auth?: AuthConfig;
257
284
  reviewed?: ProviderReviewed;
@@ -681,9 +708,21 @@ function validateProviderProxy(config: {
681
708
  const deprecatedVendors = vendorChain.filter(
682
709
  (vendor) => vendor === "decodo" || vendor === "custom",
683
710
  );
711
+ if (
712
+ proxy.mode === "required" &&
713
+ vendorChain.length > 0 &&
714
+ deprecatedVendors.length === vendorChain.length
715
+ ) {
716
+ throw new ValidationError(
717
+ `Provider "${config.id}" requires proxy egress but declares only deprecated proxy vendor(s): ${deprecatedVendors.join(", ")}.`,
718
+ {
719
+ fix: `Use proxy.provider or proxy.providers with "smartproxy" and/or "nodemaven".`,
720
+ },
721
+ );
722
+ }
684
723
  if (deprecatedVendors.length > 0) {
685
724
  console.warn(
686
- `[provider-sdk] Provider "${config.id}" uses deprecated proxy vendor(s): ${deprecatedVendors.join(", ")}. Use "smartproxy"/"nodemaven", or the APIFUSE__PROXY__URL bring-your-own escape hatch.`,
725
+ `[provider-sdk] Provider "${config.id}" uses deprecated proxy vendor(s): ${deprecatedVendors.join(", ")}. Use "smartproxy"/"nodemaven".`,
687
726
  );
688
727
  }
689
728
  }
@@ -712,6 +751,53 @@ function validateProviderOcr(config: { id: string; ocr?: ProviderOcrConfig }): v
712
751
  assertLiteralField(ocr.mode, "ocr.mode", VALID_PROVIDER_OCR_MODES, config.id);
713
752
  }
714
753
 
754
+ function validateProviderResolver(config: { id: string; resolver?: ProviderResolverConfig }): void {
755
+ const resolver = config.resolver;
756
+ if (resolver === undefined) return;
757
+ if (!resolver || typeof resolver !== "object" || Array.isArray(resolver)) {
758
+ throw new ValidationError(`Provider "${config.id}" has invalid resolver: must be an object.`, {
759
+ fix: `Set resolver for provider "${config.id}" to { vendors: ["2captcha"], kinds: ["turnstile"] }.`,
760
+ });
761
+ }
762
+ rejectUnknownFields(resolver, new Set(["vendors", "kinds"]), "resolver", config.id);
763
+ validateResolverLiteralArray(
764
+ resolver.vendors,
765
+ "resolver.vendors",
766
+ VALID_PROVIDER_RESOLVER_VENDORS,
767
+ config.id,
768
+ );
769
+ validateResolverLiteralArray(
770
+ resolver.kinds,
771
+ "resolver.kinds",
772
+ VALID_PROVIDER_CHALLENGE_KINDS,
773
+ config.id,
774
+ );
775
+ }
776
+
777
+ function validateResolverLiteralArray<TValue extends string>(
778
+ value: readonly TValue[],
779
+ field: string,
780
+ validValues: readonly TValue[],
781
+ providerId: string,
782
+ ): void {
783
+ if (!Array.isArray(value)) {
784
+ throw new ValidationError(`Provider "${providerId}" has invalid ${field}: must be an array.`, {
785
+ fix: `Set ${field} for provider "${providerId}" to an array containing only: ${validValues.join(", ")}.`,
786
+ });
787
+ }
788
+ for (const [index, item] of value.entries()) {
789
+ if (typeof item === "string" && validValues.some((validValue) => validValue === item)) {
790
+ continue;
791
+ }
792
+ throw new ValidationError(
793
+ `Provider "${providerId}" has invalid ${field}[${index}]: ${JSON.stringify(item)}. Expected one of: ${validValues.join(", ")}`,
794
+ {
795
+ fix: `Set ${field}[${index}] for provider "${providerId}" to one of ${validValues.map((validValue) => `"${validValue}"`).join(", ")}.`,
796
+ },
797
+ );
798
+ }
799
+ }
800
+
715
801
  function validateOperationIds(
716
802
  providerId: string,
717
803
  operations: Record<string, ProviderOperation>,
@@ -1309,7 +1395,12 @@ function suggestField(unknown: string, candidates: ReadonlySet<string>): string
1309
1395
  return best;
1310
1396
  }
1311
1397
 
1312
- function rejectUnknownFields(value: object, allowed: ReadonlySet<string>, fieldPath: string): void {
1398
+ function rejectUnknownFields(
1399
+ value: object,
1400
+ allowed: ReadonlySet<string>,
1401
+ fieldPath: string,
1402
+ providerId?: string,
1403
+ ): void {
1313
1404
  for (const key of Object.keys(value)) {
1314
1405
  if (allowed.has(key)) continue;
1315
1406
  const hint = suggestField(key, allowed);
@@ -1317,7 +1408,11 @@ function rejectUnknownFields(value: object, allowed: ReadonlySet<string>, fieldP
1317
1408
  hint
1318
1409
  ? `Unknown field "${key}" on ${fieldPath}. Did you mean "${hint}"?`
1319
1410
  : `Unknown field "${key}" on ${fieldPath}.`,
1320
- { fix: `Remove ${fieldPath}.${key} or rename it.` },
1411
+ {
1412
+ fix: providerId
1413
+ ? `Remove ${fieldPath}.${key} from provider "${providerId}" or rename it.`
1414
+ : `Remove ${fieldPath}.${key} or rename it.`,
1415
+ },
1321
1416
  );
1322
1417
  }
1323
1418
  }
@@ -2403,6 +2498,7 @@ export function defineProvider<
2403
2498
  validateProviderProxy(config);
2404
2499
  validateProviderOcr(config);
2405
2500
  validateProviderStt(config);
2501
+ validateProviderResolver(config);
2406
2502
  if (config.runtime === "browser" && !config.browser)
2407
2503
  throw new ProviderError(
2408
2504
  `Provider "${config.id}" must define browser.engine when runtime is "browser"`,
@@ -2428,6 +2524,7 @@ export function defineProvider<
2428
2524
  proxy: config.proxy,
2429
2525
  ocr: config.ocr,
2430
2526
  stt: config.stt,
2527
+ resolver: config.resolver,
2431
2528
  browser: config.browser,
2432
2529
  auth: config.auth,
2433
2530
  reviewed: config.reviewed,
package/src/index.ts CHANGED
@@ -6,7 +6,6 @@ export * from "./choice-token.js";
6
6
  export type {
7
7
  ApiFuseConfig,
8
8
  BrowserConfig,
9
- ProxyConfig,
10
9
  ProxyProtocol,
11
10
  ProxyResolutionOptions,
12
11
  ProxyResolutionSource,
@@ -53,6 +52,7 @@ export { createFlowContext, createScratchpad } from "./runtime/auth-flow.js";
53
52
  export type { BrowserClientOptions } from "./runtime/browser.js";
54
53
  export { BrowserClient, createBrowserClient } from "./runtime/browser.js";
55
54
  export {
55
+ APIFUSE__CACHE__KEY_PEPPER_ENV,
56
56
  createBypassProviderCache,
57
57
  createProviderCache,
58
58
  type ProviderCacheOptions,
@@ -101,6 +101,18 @@ export {
101
101
  } from "./runtime/instrumentation.js";
102
102
  export { type PrevalidateResult, prevalidate } from "./runtime/prevalidate.js";
103
103
  export { getProviderBaseUrl } from "./runtime/provider.js";
104
+ export {
105
+ APIFUSE__CDP_POOL__URL,
106
+ APIFUSE__RESOLVER__2CAPTCHA__API_KEY,
107
+ APIFUSE__RESOLVER__CAPMONSTER__API_KEY,
108
+ APIFUSE__RESOLVER__CAPSOLVER__API_KEY,
109
+ APIFUSE__RESOLVER__TIMEOUT_MS,
110
+ createResolverClientFromEnv,
111
+ createUnsupportedResolverClient,
112
+ DEFAULT_RESOLVER_TIMEOUT_MS,
113
+ invalidateResolverSolution,
114
+ type ResolverRuntimeOptions,
115
+ } from "./runtime/resolver.js";
104
116
  export {
105
117
  assertRequiredSecretsPresent,
106
118
  listMissingRequiredSecrets,
@@ -177,6 +189,7 @@ export type {
177
189
  AuthMode,
178
190
  AuthTurn,
179
191
  Bcp47Locale,
192
+ BrowserCookie,
180
193
  BrowserEngine,
181
194
  BrowserOptions,
182
195
  BrowserResourceBody,
@@ -185,6 +198,7 @@ export type {
185
198
  BrowserResourcePolicy,
186
199
  BrowserResourceRequest,
187
200
  BrowserResourceRoute,
201
+ ChallengeSolution,
188
202
  ConnectionMode,
189
203
  ContextDeclaration,
190
204
  CookieJar,
@@ -285,6 +299,8 @@ export type {
285
299
  ProviderChoiceContext,
286
300
  ProviderChoiceIssueOptions,
287
301
  ProviderChoiceParseOptions,
302
+ ProviderChallenge,
303
+ ProviderChallengeKind,
288
304
  ProviderContext,
289
305
  ProviderDefinition,
290
306
  ProviderDeploymentOverrides,
@@ -309,6 +325,8 @@ export type {
309
325
  ProviderPublicProfile,
310
326
  ProviderReviewed,
311
327
  ProviderResolvedFile,
328
+ ProviderResolverConfig,
329
+ ProviderResolverVendor,
312
330
  ProviderRuntimeState,
313
331
  ProviderSecretDeclaration,
314
332
  ProviderStateDurationString,
@@ -319,6 +337,7 @@ export type {
319
337
  ProviderSupportLevel,
320
338
  RequestOptions,
321
339
  RedirectRunReason,
340
+ ResolverContext,
322
341
  Rfc3339Instant,
323
342
  SchemaLike,
324
343
  SmsOrigin,
@@ -328,6 +347,7 @@ export type {
328
347
  StandardSchemaV1,
329
348
  StateCasResult,
330
349
  StateNamespaceOptions,
350
+ StateNamespaceScope,
331
351
  StateValue,
332
352
  StateWriteOptions,
333
353
  StealthClient,
package/src/provider.ts CHANGED
@@ -162,6 +162,7 @@ export type {
162
162
  StandardSchemaV1,
163
163
  StateCasResult,
164
164
  StateNamespaceOptions,
165
+ StateNamespaceScope,
165
166
  StateValue,
166
167
  StateWriteOptions,
167
168
  } from "./types.js";
@@ -10,6 +10,7 @@ import type {
10
10
  SttContext,
11
11
  } from "../types.js";
12
12
  import { createUnsupportedOcrClient } from "./ocr.js";
13
+ import { createUnsupportedResolverClient } from "./resolver.js";
13
14
  import { createUnsupportedSttClient } from "./stt.js";
14
15
 
15
16
  function normalizeAllowedKeys(allowedKeys: string[]): Set<string> {
@@ -75,6 +76,7 @@ export function createFlowContext(options: {
75
76
  context: createScratchpad(options.allowedKeys, options.initialContext),
76
77
  ocr: options.ocr ?? createUnsupportedOcrClient(),
77
78
  stt: options.stt ?? createUnsupportedSttClient(),
79
+ resolver: createUnsupportedResolverClient("Resolver is not available in auth flow context"),
78
80
  auth: createAuthFlowHelpers(),
79
81
  };
80
82
  }
@@ -6,6 +6,7 @@ import type {
6
6
  BrowserChallengeRequest,
7
7
  BrowserChallengeResult,
8
8
  BrowserClient as BrowserClientContract,
9
+ BrowserCookie,
9
10
  BrowserEngine,
10
11
  BrowserFrame,
11
12
  BrowserLocator,
@@ -545,6 +546,10 @@ class PlaywrightBrowserPage implements BrowserPageContract {
545
546
  await this.page.close();
546
547
  }
547
548
 
549
+ async cookies(): Promise<readonly BrowserCookie[]> {
550
+ return (await this.page.context().cookies()).map(toBrowserCookie);
551
+ }
552
+
548
553
  async withResourcePolicy<T>(policy: BrowserResourcePolicy, run: () => Promise<T>): Promise<T> {
549
554
  const allowedMethods = new Set(policy.allowedMethods ?? DEFAULT_RESOURCE_METHODS);
550
555
  const handler = async (route: Route): Promise<void> => {
@@ -845,6 +850,64 @@ function isRecord(value: unknown): value is Record<string, unknown> {
845
850
  return value !== null && typeof value === "object" && !Array.isArray(value);
846
851
  }
847
852
 
853
+ function isBrowserCookieSameSite(value: unknown): value is BrowserCookie["sameSite"] {
854
+ return value === "Strict" || value === "Lax" || value === "None";
855
+ }
856
+
857
+ function toBrowserCookie(cookie: {
858
+ readonly name: string;
859
+ readonly value: string;
860
+ readonly domain: string;
861
+ readonly path: string;
862
+ readonly expires?: number;
863
+ readonly httpOnly: boolean;
864
+ readonly secure: boolean;
865
+ readonly sameSite?: unknown;
866
+ }): BrowserCookie {
867
+ return {
868
+ name: cookie.name,
869
+ value: cookie.value,
870
+ domain: cookie.domain,
871
+ path: cookie.path,
872
+ ...(cookie.expires !== undefined && cookie.expires > 0 ? { expires: cookie.expires } : {}),
873
+ httpOnly: cookie.httpOnly,
874
+ secure: cookie.secure,
875
+ ...(isBrowserCookieSameSite(cookie.sameSite) ? { sameSite: cookie.sameSite } : {}),
876
+ };
877
+ }
878
+
879
+ function parseCdpCookies(value: unknown): readonly BrowserCookie[] {
880
+ if (!Array.isArray(value)) {
881
+ throw new Error("CDP Network.getCookies returned an invalid cookie list");
882
+ }
883
+
884
+ return value.map((cookie) => {
885
+ if (
886
+ !isRecord(cookie) ||
887
+ typeof cookie.name !== "string" ||
888
+ typeof cookie.value !== "string" ||
889
+ typeof cookie.domain !== "string" ||
890
+ typeof cookie.path !== "string" ||
891
+ typeof cookie.expires !== "number" ||
892
+ typeof cookie.httpOnly !== "boolean" ||
893
+ typeof cookie.secure !== "boolean"
894
+ ) {
895
+ throw new Error("CDP Network.getCookies returned an invalid cookie");
896
+ }
897
+
898
+ return toBrowserCookie({
899
+ name: cookie.name,
900
+ value: cookie.value,
901
+ domain: cookie.domain,
902
+ path: cookie.path,
903
+ expires: cookie.expires,
904
+ httpOnly: cookie.httpOnly,
905
+ secure: cookie.secure,
906
+ sameSite: cookie.sameSite,
907
+ });
908
+ });
909
+ }
910
+
848
911
  function parsePoolAcquireResponse(value: unknown): PoolAcquireResponse {
849
912
  if (
850
913
  !isRecord(value) ||
@@ -1200,6 +1263,12 @@ class CdpPoolBrowserPage implements BrowserPageContract {
1200
1263
  return Buffer.from(String(result.data ?? ""), "base64");
1201
1264
  }
1202
1265
 
1266
+ async cookies(): Promise<readonly BrowserCookie[]> {
1267
+ await this.initialize();
1268
+ const result = await this.pageClient.send("Network.getCookies");
1269
+ return parseCdpCookies(result.cookies);
1270
+ }
1271
+
1203
1272
  async close(): Promise<void> {
1204
1273
  if (this.closed) {
1205
1274
  return;
@@ -1,6 +1,7 @@
1
- import { createHash } from "node:crypto";
1
+ import { createHash, createHmac } from "node:crypto";
2
2
 
3
3
  import { providerCacheRedisUrlFromEnv } from "../config/loader.js";
4
+ import { ProviderError } from "../errors.js";
4
5
  import type {
5
6
  ProviderCache,
6
7
  ProviderCacheGetOrSetOptions,
@@ -43,9 +44,12 @@ export type ProviderCacheOptions = {
43
44
  now?: () => number;
44
45
  };
45
46
 
47
+ export const APIFUSE__CACHE__KEY_PEPPER_ENV = "APIFUSE__CACHE__KEY_PEPPER";
48
+
46
49
  const DEFAULT_PREFIX = "apifuse:provider-cache:v1";
47
50
  const DEFAULT_MEMORY_MAX_ENTRIES = 1_000;
48
51
  const DEFAULT_REDIS_TIMEOUT_MS = 150;
52
+ const SECRET_SCOPED_KEY_MARKER = "[secret-scoped";
49
53
  const SECRET_FIELD_NAMES = new Set([
50
54
  "authorization",
51
55
  "cookie",
@@ -61,6 +65,7 @@ const SECRET_FIELD_NAMES = new Set([
61
65
  ]);
62
66
 
63
67
  const sharedBackends = new Map<string, SharedCacheBackend>();
68
+ let warnedAboutUnpepperedSecretKeys = false;
64
69
 
65
70
  function backendKey(redisUrl: string | undefined): string {
66
71
  return redisUrl ?? "memory";
@@ -107,19 +112,178 @@ function shouldRedactField(name: string, extra: Set<string>): boolean {
107
112
  );
108
113
  }
109
114
 
110
- function normalizeKeyPart(value: unknown, extra: Set<string>): unknown {
115
+ type NormalizedKeyPart = {
116
+ value: unknown;
117
+ secretScoped: boolean;
118
+ };
119
+
120
+ function unsupportedSecretValue(path: string, reason: string): never {
121
+ throw new ProviderError(`Secret cache-key values must be JSON-safe; ${reason} at ${path}.`, {
122
+ code: "CACHE_KEY_SECRET_VALUE_UNSUPPORTED",
123
+ fix: "Convert the secret cache-key selector to JSON-safe primitives, arrays, or plain objects.",
124
+ });
125
+ }
126
+
127
+ function assertJsonSafeSecretValue(
128
+ value: unknown,
129
+ reportedPath: string,
130
+ ancestors = new Set<object>(),
131
+ ): void {
132
+ if (value === undefined) return;
133
+ if (value === null || typeof value === "string" || typeof value === "boolean") return;
134
+ if (typeof value === "number") {
135
+ if (!Number.isFinite(value))
136
+ unsupportedSecretValue(reportedPath, "non-finite numbers are unsupported");
137
+ if (Object.is(value, -0))
138
+ unsupportedSecretValue(reportedPath, "negative zero is unsupported");
139
+ return;
140
+ }
141
+ if (typeof value !== "object") {
142
+ unsupportedSecretValue(reportedPath, `${typeof value} values are unsupported`);
143
+ }
144
+ if (ancestors.has(value))
145
+ unsupportedSecretValue(reportedPath, "cyclic values are unsupported");
146
+
147
+ ancestors.add(value);
148
+ try {
149
+ if (Array.isArray(value)) {
150
+ if (Object.getOwnPropertySymbols(value).length > 0) {
151
+ unsupportedSecretValue(reportedPath, "symbol-keyed array properties are unsupported");
152
+ }
153
+ const expectedNames = new Set(["length"]);
154
+ for (let index = 0; index < value.length; index += 1) {
155
+ const key = String(index);
156
+ expectedNames.add(key);
157
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
158
+ if (!descriptor)
159
+ unsupportedSecretValue(reportedPath, "sparse arrays are unsupported");
160
+ if (!descriptor.enumerable || !("value" in descriptor)) {
161
+ unsupportedSecretValue(reportedPath, "array accessors are unsupported");
162
+ }
163
+ assertJsonSafeSecretValue(descriptor.value, reportedPath, ancestors);
164
+ }
165
+ if (Object.getOwnPropertyNames(value).some((name) => !expectedNames.has(name))) {
166
+ unsupportedSecretValue(reportedPath, "custom array properties are unsupported");
167
+ }
168
+ return;
169
+ }
170
+
171
+ const prototype = Object.getPrototypeOf(value);
172
+ if (prototype !== Object.prototype && prototype !== null) {
173
+ unsupportedSecretValue(reportedPath, "non-plain objects are unsupported");
174
+ }
175
+ if (Object.getOwnPropertySymbols(value).length > 0) {
176
+ unsupportedSecretValue(reportedPath, "symbol-keyed properties are unsupported");
177
+ }
178
+ for (const key of Object.getOwnPropertyNames(value)) {
179
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
180
+ if (!descriptor?.enumerable || !("value" in descriptor)) {
181
+ unsupportedSecretValue(
182
+ reportedPath,
183
+ "non-enumerable properties and accessors are unsupported",
184
+ );
185
+ }
186
+ assertJsonSafeSecretValue(descriptor.value, reportedPath, ancestors);
187
+ }
188
+ } finally {
189
+ ancestors.delete(value);
190
+ }
191
+ }
192
+
193
+ function containsUndefined(value: unknown): boolean {
194
+ if (value === undefined) return true;
195
+ if (Array.isArray(value)) return value.some(containsUndefined);
196
+ if (isRecord(value)) return Object.values(value).some(containsUndefined);
197
+ return false;
198
+ }
199
+
200
+ function tagSecretValue(value: unknown): unknown {
201
+ if (value === undefined) return ["undefined"];
202
+ if (value === null) return ["null"];
203
+ if (typeof value === "string") return ["string", value];
204
+ if (typeof value === "number") return ["number", value];
205
+ if (typeof value === "boolean") return ["boolean", value];
206
+ if (Array.isArray(value)) return ["array", value.map(tagSecretValue)];
207
+ return [
208
+ "object",
209
+ Object.entries(value as Record<string, unknown>).map(([key, entry]) => [
210
+ key,
211
+ tagSecretValue(entry),
212
+ ]),
213
+ ];
214
+ }
215
+
216
+ function serializeSecretValue(value: unknown): string {
217
+ if (!containsUndefined(value)) return JSON.stringify([value]);
218
+ return `undefined-v1:${JSON.stringify(tagSecretValue(value))}`;
219
+ }
220
+
221
+ function warnAboutUnpepperedSecretKey(): void {
222
+ if (warnedAboutUnpepperedSecretKeys) return;
223
+ warnedAboutUnpepperedSecretKeys = true;
224
+ console.warn(
225
+ JSON.stringify({
226
+ level: "warn",
227
+ event: "provider_cache_secret_key_unpeppered",
228
+ message: `Secret-bearing cache keys are using unkeyed SHA-256 because ${APIFUSE__CACHE__KEY_PEPPER_ENV} is not configured.`,
229
+ }),
230
+ );
231
+ }
232
+
233
+ function normalizeKeyPart(
234
+ value: unknown,
235
+ extra: Set<string>,
236
+ pepper: string | undefined,
237
+ ): NormalizedKeyPart {
111
238
  if (Array.isArray(value)) {
112
- return value.map((entry) => normalizeKeyPart(entry, extra));
239
+ const entries = value.map((entry) => normalizeKeyPart(entry, extra, pepper));
240
+ return {
241
+ value: entries.map((entry) => entry.value),
242
+ secretScoped: entries.some((entry) => entry.secretScoped),
243
+ };
113
244
  }
114
245
  if (isRecord(value)) {
115
- const normalized: Record<string, unknown> = {};
246
+ const normalized: Record<string, unknown> = Object.create(null);
247
+ let secretScoped = false;
116
248
  for (const key of Object.keys(value).sort()) {
117
- if (shouldRedactField(key, extra)) continue;
118
- normalized[key] = normalizeKeyPart(value[key], extra);
249
+ const part = shouldRedactField(key, extra)
250
+ ? hashSecretValue(value[key], key, extra, pepper)
251
+ : normalizeKeyPart(value[key], extra, pepper);
252
+ normalized[key] = part.value;
253
+ secretScoped ||= part.secretScoped;
119
254
  }
120
- return normalized;
255
+ return { value: normalized, secretScoped };
256
+ }
257
+ return { value, secretScoped: false };
258
+ }
259
+
260
+ function hashSecretValue(
261
+ value: unknown,
262
+ fieldName: string,
263
+ extra: Set<string>,
264
+ pepper: string | undefined,
265
+ ): NormalizedKeyPart {
266
+ assertJsonSafeSecretValue(value, `${fieldName} (inside secret value)`);
267
+ const canonical = serializeSecretValue(normalizeKeyPart(value, extra, pepper).value);
268
+ if (pepper === undefined) {
269
+ warnAboutUnpepperedSecretKey();
270
+ const digest = createHash("sha256").update(canonical).digest("hex");
271
+ return { value: `sha256:${digest}`, secretScoped: true };
121
272
  }
122
- return value;
273
+ const digest = createHmac("sha256", pepper).update(canonical).digest("hex");
274
+ return { value: `hmac-sha256:${digest}`, secretScoped: true };
275
+ }
276
+
277
+ function metadataKeys(
278
+ events: ProviderCacheLookupMeta[],
279
+ secretScopedKeys: Set<string>,
280
+ ): string[] {
281
+ let secretScopedIndex = 0;
282
+ return Array.from(new Set(events.map((event) => event.key))).map((key) => {
283
+ if (!secretScopedKeys.has(key)) return key;
284
+ secretScopedIndex += 1;
285
+ return `${SECRET_SCOPED_KEY_MARKER}#${secretScopedIndex}]`;
286
+ });
123
287
  }
124
288
 
125
289
  function stableHash(value: unknown): string {
@@ -198,10 +362,13 @@ async function withRedisFallback<T>(operation: () => Promise<T>): Promise<T | un
198
362
 
199
363
  export function createProviderCache(options: ProviderCacheOptions): ProviderCache {
200
364
  const redisUrl = options.redisUrl ?? providerCacheRedisUrlFromEnv();
365
+ const configuredPepper = process.env[APIFUSE__CACHE__KEY_PEPPER_ENV];
366
+ const pepper = configuredPepper && configuredPepper.length > 0 ? configuredPepper : undefined;
201
367
  const backend = getSharedBackend(redisUrl);
202
368
  const memoryMaxEntries = Math.max(1, options.memoryMaxEntries ?? DEFAULT_MEMORY_MAX_ENTRIES);
203
369
  const now = options.now ?? Date.now;
204
370
  const events: ProviderCacheLookupMeta[] = [];
371
+ const secretScopedKeys = new Set<string>();
205
372
 
206
373
  function record(meta: ProviderCacheLookupMeta): void {
207
374
  events.push(meta);
@@ -353,8 +520,10 @@ export function createProviderCache(options: ProviderCacheOptions): ProviderCach
353
520
  return {
354
521
  key(namespace, parts, keyOptions?: ProviderCacheKeyOptions) {
355
522
  const extra = new Set((keyOptions?.redactFields ?? []).map((field) => field.toLowerCase()));
356
- const normalized = normalizeKeyPart(parts, extra);
357
- return `${DEFAULT_PREFIX}:${options.providerId}:${namespace}:${stableHash(normalized)}`;
523
+ const normalized = normalizeKeyPart(parts, extra, pepper);
524
+ const key = `${DEFAULT_PREFIX}:${options.providerId}:${namespace}:${stableHash(normalized.value)}`;
525
+ if (normalized.secretScoped) secretScopedKeys.add(key);
526
+ return key;
358
527
  },
359
528
 
360
529
  async get<T = unknown>(key: string): Promise<ProviderCacheResult<T> | null> {
@@ -413,7 +582,7 @@ export function createProviderCache(options: ProviderCacheOptions): ProviderCach
413
582
  return {
414
583
  hit: events.some((event) => event.hit),
415
584
  stale: events.some((event) => event.stale),
416
- keys: Array.from(new Set(events.map((event) => event.key))),
585
+ keys: metadataKeys(events, secretScopedKeys),
417
586
  source: sourceSummary(events),
418
587
  };
419
588
  },
@@ -423,13 +592,18 @@ export function createProviderCache(options: ProviderCacheOptions): ProviderCach
423
592
  export function createBypassProviderCache(
424
593
  options: Pick<ProviderCacheOptions, "providerId">,
425
594
  ): ProviderCache {
595
+ const configuredPepper = process.env[APIFUSE__CACHE__KEY_PEPPER_ENV];
596
+ const pepper = configuredPepper && configuredPepper.length > 0 ? configuredPepper : undefined;
426
597
  const events: ProviderCacheLookupMeta[] = [];
598
+ const secretScopedKeys = new Set<string>();
427
599
 
428
600
  return {
429
601
  key(namespace, parts, keyOptions?: ProviderCacheKeyOptions) {
430
602
  const extra = new Set((keyOptions?.redactFields ?? []).map((field) => field.toLowerCase()));
431
- const normalized = normalizeKeyPart(parts, extra);
432
- return `${DEFAULT_PREFIX}:${options.providerId}:${namespace}:${stableHash(normalized)}`;
603
+ const normalized = normalizeKeyPart(parts, extra, pepper);
604
+ const key = `${DEFAULT_PREFIX}:${options.providerId}:${namespace}:${stableHash(normalized.value)}`;
605
+ if (normalized.secretScoped) secretScopedKeys.add(key);
606
+ return key;
433
607
  },
434
608
 
435
609
  async get<T = unknown>(_key: string): Promise<ProviderCacheResult<T> | null> {
@@ -464,7 +638,7 @@ export function createBypassProviderCache(
464
638
  return {
465
639
  hit: false,
466
640
  stale: false,
467
- keys: Array.from(new Set(events.map((event) => event.key))),
641
+ keys: metadataKeys(events, secretScopedKeys),
468
642
  source: sourceSummary(events),
469
643
  };
470
644
  },
@@ -478,4 +652,5 @@ export function resetProviderCacheForTests(): void {
478
652
  backend.redis?.disconnect();
479
653
  }
480
654
  sharedBackends.clear();
655
+ warnedAboutUnpepperedSecretKeys = false;
481
656
  }
@@ -517,7 +517,6 @@ async function resolveNativeProxy(
517
517
  const resolvedProxy = await resolveProxyConfigAsync({
518
518
  proxy: options.proxy ?? clientOptions.proxy,
519
519
  upstream: clientOptions.upstream,
520
- apifuseConfig: clientOptions.apifuseConfig,
521
520
  proxyPolicy: clientOptions.proxyPolicy,
522
521
  affinityKey: clientOptions.affinityKey,
523
522
  proxyAttempt: computeProxyAttemptIndex({