@apifuse/provider-sdk 2.2.0-beta.24 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # @apifuse/provider-sdk Changelog
2
2
 
3
+ ## 2.2.0-beta.25
4
+
5
+ - Release candidate for main commit e6df658b95c0728b671fd221919ec2f85f82b0b7.
6
+
3
7
  ## 2.2.0-beta.24
4
8
 
5
9
  - Release candidate for main commit 0f21a2959dcbb9b96fe426259259d0870cfc537f.
@@ -1691,12 +1691,25 @@ function isScannableProviderSourceFile(relativePath: string): boolean {
1691
1691
  );
1692
1692
  }
1693
1693
 
1694
+ const EXCLUDED_SOURCE_DIRECTORY_SEGMENTS = new Set([
1695
+ ".git",
1696
+ ".worktree",
1697
+ "node_modules",
1698
+ "dist",
1699
+ "build",
1700
+ "coverage",
1701
+ ]);
1702
+
1694
1703
  // `.agents`/`.apifuse` stay IN scope on purpose: managed content there is
1695
1704
  // markdown/JSON (never scannable), while a planted `.ts`/`.sh` under those
1696
1705
  // directories must not become a scan-exempt hiding place for secrets, raw
1697
- // fetch, or vendor imports.
1706
+ // fetch, or vendor imports. Nested dependency/build output directories under
1707
+ // them use the same segment exclusions; `.worktree` is excluded because task
1708
+ // worktrees are duplicate repository checkouts, not provider source.
1698
1709
  function shouldScanSourceDirectory(relativePath: string): boolean {
1699
- return ![".git", "node_modules", "dist", "build", "coverage"].includes(relativePath);
1710
+ return relativePath
1711
+ .split("/")
1712
+ .every((segment) => !EXCLUDED_SOURCE_DIRECTORY_SEGMENTS.has(segment));
1700
1713
  }
1701
1714
 
1702
1715
  function isExcludedTestSource(relativePath: string): boolean {
package/dist/index.d.ts CHANGED
@@ -17,7 +17,7 @@ export * from "./recipes/rest-api.js";
17
17
  export { createFlowContext, createScratchpad } from "./runtime/auth-flow.js";
18
18
  export type { BrowserClientOptions } from "./runtime/browser.js";
19
19
  export { BrowserClient, createBrowserClient } from "./runtime/browser.js";
20
- export { createBypassProviderCache, createProviderCache, type ProviderCacheOptions, resetProviderCacheForTests, } from "./runtime/cache.js";
20
+ export { APIFUSE__CACHE__KEY_PEPPER_ENV, createBypassProviderCache, createProviderCache, type ProviderCacheOptions, resetProviderCacheForTests, } from "./runtime/cache.js";
21
21
  export { type CreateProviderChoiceContextOptions, createProviderChoiceContext, createTestProviderChoiceContext, PROVIDER_RUNTIME_CHOICE_TOKEN_MASTER_SECRET_ENV, } from "./runtime/choice.js";
22
22
  export { type CreateCredentialContextOptions, createCredentialContext, } from "./runtime/credential.js";
23
23
  export { createEnvContext } from "./runtime/env.js";
package/dist/index.js CHANGED
@@ -15,7 +15,7 @@ export * from "./recipes/gov-api.js";
15
15
  export * from "./recipes/rest-api.js";
16
16
  export { createFlowContext, createScratchpad } from "./runtime/auth-flow.js";
17
17
  export { BrowserClient, createBrowserClient } from "./runtime/browser.js";
18
- export { createBypassProviderCache, createProviderCache, resetProviderCacheForTests, } from "./runtime/cache.js";
18
+ export { APIFUSE__CACHE__KEY_PEPPER_ENV, createBypassProviderCache, createProviderCache, resetProviderCacheForTests, } from "./runtime/cache.js";
19
19
  export { createProviderChoiceContext, createTestProviderChoiceContext, PROVIDER_RUNTIME_CHOICE_TOKEN_MASTER_SECRET_ENV, } from "./runtime/choice.js";
20
20
  export { createCredentialContext, } from "./runtime/credential.js";
21
21
  export { createEnvContext } from "./runtime/env.js";
@@ -5,6 +5,7 @@ export type ProviderCacheOptions = {
5
5
  memoryMaxEntries?: number;
6
6
  now?: () => number;
7
7
  };
8
+ export declare const APIFUSE__CACHE__KEY_PEPPER_ENV = "APIFUSE__CACHE__KEY_PEPPER";
8
9
  export declare function createProviderCache(options: ProviderCacheOptions): ProviderCache;
9
10
  export declare function createBypassProviderCache(options: Pick<ProviderCacheOptions, "providerId">): ProviderCache;
10
11
  export declare function resetProviderCacheForTests(): void;
@@ -1,9 +1,12 @@
1
- import { createHash } from "node:crypto";
1
+ import { createHash, createHmac } from "node:crypto";
2
2
  import { providerCacheRedisUrlFromEnv } from "../config/loader.js";
3
+ import { ProviderError } from "../errors.js";
3
4
  import { createProviderRedisClient, ensureRedisReady, withRedisTimeout, } from "./redis.js";
5
+ export const APIFUSE__CACHE__KEY_PEPPER_ENV = "APIFUSE__CACHE__KEY_PEPPER";
4
6
  const DEFAULT_PREFIX = "apifuse:provider-cache:v1";
5
7
  const DEFAULT_MEMORY_MAX_ENTRIES = 1_000;
6
8
  const DEFAULT_REDIS_TIMEOUT_MS = 150;
9
+ const SECRET_SCOPED_KEY_MARKER = "[secret-scoped";
7
10
  const SECRET_FIELD_NAMES = new Set([
8
11
  "authorization",
9
12
  "cookie",
@@ -18,6 +21,7 @@ const SECRET_FIELD_NAMES = new Set([
18
21
  "refresh_token",
19
22
  ]);
20
23
  const sharedBackends = new Map();
24
+ let warnedAboutUnpepperedSecretKeys = false;
21
25
  function backendKey(redisUrl) {
22
26
  return redisUrl ?? "memory";
23
27
  }
@@ -55,20 +59,157 @@ function shouldRedactField(name, extra) {
55
59
  normalized.includes("password") ||
56
60
  normalized.includes("secret"));
57
61
  }
58
- function normalizeKeyPart(value, extra) {
62
+ function unsupportedSecretValue(path, reason) {
63
+ throw new ProviderError(`Secret cache-key values must be JSON-safe; ${reason} at ${path}.`, {
64
+ code: "CACHE_KEY_SECRET_VALUE_UNSUPPORTED",
65
+ fix: "Convert the secret cache-key selector to JSON-safe primitives, arrays, or plain objects.",
66
+ });
67
+ }
68
+ function assertJsonSafeSecretValue(value, reportedPath, ancestors = new Set()) {
69
+ if (value === undefined)
70
+ return;
71
+ if (value === null || typeof value === "string" || typeof value === "boolean")
72
+ return;
73
+ if (typeof value === "number") {
74
+ if (!Number.isFinite(value))
75
+ unsupportedSecretValue(reportedPath, "non-finite numbers are unsupported");
76
+ if (Object.is(value, -0))
77
+ unsupportedSecretValue(reportedPath, "negative zero is unsupported");
78
+ return;
79
+ }
80
+ if (typeof value !== "object") {
81
+ unsupportedSecretValue(reportedPath, `${typeof value} values are unsupported`);
82
+ }
83
+ if (ancestors.has(value))
84
+ unsupportedSecretValue(reportedPath, "cyclic values are unsupported");
85
+ ancestors.add(value);
86
+ try {
87
+ if (Array.isArray(value)) {
88
+ if (Object.getOwnPropertySymbols(value).length > 0) {
89
+ unsupportedSecretValue(reportedPath, "symbol-keyed array properties are unsupported");
90
+ }
91
+ const expectedNames = new Set(["length"]);
92
+ for (let index = 0; index < value.length; index += 1) {
93
+ const key = String(index);
94
+ expectedNames.add(key);
95
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
96
+ if (!descriptor)
97
+ unsupportedSecretValue(reportedPath, "sparse arrays are unsupported");
98
+ if (!descriptor.enumerable || !("value" in descriptor)) {
99
+ unsupportedSecretValue(reportedPath, "array accessors are unsupported");
100
+ }
101
+ assertJsonSafeSecretValue(descriptor.value, reportedPath, ancestors);
102
+ }
103
+ if (Object.getOwnPropertyNames(value).some((name) => !expectedNames.has(name))) {
104
+ unsupportedSecretValue(reportedPath, "custom array properties are unsupported");
105
+ }
106
+ return;
107
+ }
108
+ const prototype = Object.getPrototypeOf(value);
109
+ if (prototype !== Object.prototype && prototype !== null) {
110
+ unsupportedSecretValue(reportedPath, "non-plain objects are unsupported");
111
+ }
112
+ if (Object.getOwnPropertySymbols(value).length > 0) {
113
+ unsupportedSecretValue(reportedPath, "symbol-keyed properties are unsupported");
114
+ }
115
+ for (const key of Object.getOwnPropertyNames(value)) {
116
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
117
+ if (!descriptor?.enumerable || !("value" in descriptor)) {
118
+ unsupportedSecretValue(reportedPath, "non-enumerable properties and accessors are unsupported");
119
+ }
120
+ assertJsonSafeSecretValue(descriptor.value, reportedPath, ancestors);
121
+ }
122
+ }
123
+ finally {
124
+ ancestors.delete(value);
125
+ }
126
+ }
127
+ function containsUndefined(value) {
128
+ if (value === undefined)
129
+ return true;
130
+ if (Array.isArray(value))
131
+ return value.some(containsUndefined);
132
+ if (isRecord(value))
133
+ return Object.values(value).some(containsUndefined);
134
+ return false;
135
+ }
136
+ function tagSecretValue(value) {
137
+ if (value === undefined)
138
+ return ["undefined"];
139
+ if (value === null)
140
+ return ["null"];
141
+ if (typeof value === "string")
142
+ return ["string", value];
143
+ if (typeof value === "number")
144
+ return ["number", value];
145
+ if (typeof value === "boolean")
146
+ return ["boolean", value];
147
+ if (Array.isArray(value))
148
+ return ["array", value.map(tagSecretValue)];
149
+ return [
150
+ "object",
151
+ Object.entries(value).map(([key, entry]) => [
152
+ key,
153
+ tagSecretValue(entry),
154
+ ]),
155
+ ];
156
+ }
157
+ function serializeSecretValue(value) {
158
+ if (!containsUndefined(value))
159
+ return JSON.stringify([value]);
160
+ return `undefined-v1:${JSON.stringify(tagSecretValue(value))}`;
161
+ }
162
+ function warnAboutUnpepperedSecretKey() {
163
+ if (warnedAboutUnpepperedSecretKeys)
164
+ return;
165
+ warnedAboutUnpepperedSecretKeys = true;
166
+ console.warn(JSON.stringify({
167
+ level: "warn",
168
+ event: "provider_cache_secret_key_unpeppered",
169
+ message: `Secret-bearing cache keys are using unkeyed SHA-256 because ${APIFUSE__CACHE__KEY_PEPPER_ENV} is not configured.`,
170
+ }));
171
+ }
172
+ function normalizeKeyPart(value, extra, pepper) {
59
173
  if (Array.isArray(value)) {
60
- return value.map((entry) => normalizeKeyPart(entry, extra));
174
+ const entries = value.map((entry) => normalizeKeyPart(entry, extra, pepper));
175
+ return {
176
+ value: entries.map((entry) => entry.value),
177
+ secretScoped: entries.some((entry) => entry.secretScoped),
178
+ };
61
179
  }
62
180
  if (isRecord(value)) {
63
- const normalized = {};
181
+ const normalized = Object.create(null);
182
+ let secretScoped = false;
64
183
  for (const key of Object.keys(value).sort()) {
65
- if (shouldRedactField(key, extra))
66
- continue;
67
- normalized[key] = normalizeKeyPart(value[key], extra);
184
+ const part = shouldRedactField(key, extra)
185
+ ? hashSecretValue(value[key], key, extra, pepper)
186
+ : normalizeKeyPart(value[key], extra, pepper);
187
+ normalized[key] = part.value;
188
+ secretScoped ||= part.secretScoped;
68
189
  }
69
- return normalized;
190
+ return { value: normalized, secretScoped };
70
191
  }
71
- return value;
192
+ return { value, secretScoped: false };
193
+ }
194
+ function hashSecretValue(value, fieldName, extra, pepper) {
195
+ assertJsonSafeSecretValue(value, `${fieldName} (inside secret value)`);
196
+ const canonical = serializeSecretValue(normalizeKeyPart(value, extra, pepper).value);
197
+ if (pepper === undefined) {
198
+ warnAboutUnpepperedSecretKey();
199
+ const digest = createHash("sha256").update(canonical).digest("hex");
200
+ return { value: `sha256:${digest}`, secretScoped: true };
201
+ }
202
+ const digest = createHmac("sha256", pepper).update(canonical).digest("hex");
203
+ return { value: `hmac-sha256:${digest}`, secretScoped: true };
204
+ }
205
+ function metadataKeys(events, secretScopedKeys) {
206
+ let secretScopedIndex = 0;
207
+ return Array.from(new Set(events.map((event) => event.key))).map((key) => {
208
+ if (!secretScopedKeys.has(key))
209
+ return key;
210
+ secretScopedIndex += 1;
211
+ return `${SECRET_SCOPED_KEY_MARKER}#${secretScopedIndex}]`;
212
+ });
72
213
  }
73
214
  function stableHash(value) {
74
215
  return createHash("sha256").update(JSON.stringify(value)).digest("hex").slice(0, 32);
@@ -138,10 +279,13 @@ async function withRedisFallback(operation) {
138
279
  }
139
280
  export function createProviderCache(options) {
140
281
  const redisUrl = options.redisUrl ?? providerCacheRedisUrlFromEnv();
282
+ const configuredPepper = process.env[APIFUSE__CACHE__KEY_PEPPER_ENV];
283
+ const pepper = configuredPepper && configuredPepper.length > 0 ? configuredPepper : undefined;
141
284
  const backend = getSharedBackend(redisUrl);
142
285
  const memoryMaxEntries = Math.max(1, options.memoryMaxEntries ?? DEFAULT_MEMORY_MAX_ENTRIES);
143
286
  const now = options.now ?? Date.now;
144
287
  const events = [];
288
+ const secretScopedKeys = new Set();
145
289
  function record(meta) {
146
290
  events.push(meta);
147
291
  }
@@ -266,8 +410,11 @@ export function createProviderCache(options) {
266
410
  return {
267
411
  key(namespace, parts, keyOptions) {
268
412
  const extra = new Set((keyOptions?.redactFields ?? []).map((field) => field.toLowerCase()));
269
- const normalized = normalizeKeyPart(parts, extra);
270
- return `${DEFAULT_PREFIX}:${options.providerId}:${namespace}:${stableHash(normalized)}`;
413
+ const normalized = normalizeKeyPart(parts, extra, pepper);
414
+ const key = `${DEFAULT_PREFIX}:${options.providerId}:${namespace}:${stableHash(normalized.value)}`;
415
+ if (normalized.secretScoped)
416
+ secretScopedKeys.add(key);
417
+ return key;
271
418
  },
272
419
  async get(key) {
273
420
  const result = await read(key);
@@ -312,19 +459,25 @@ export function createProviderCache(options) {
312
459
  return {
313
460
  hit: events.some((event) => event.hit),
314
461
  stale: events.some((event) => event.stale),
315
- keys: Array.from(new Set(events.map((event) => event.key))),
462
+ keys: metadataKeys(events, secretScopedKeys),
316
463
  source: sourceSummary(events),
317
464
  };
318
465
  },
319
466
  };
320
467
  }
321
468
  export function createBypassProviderCache(options) {
469
+ const configuredPepper = process.env[APIFUSE__CACHE__KEY_PEPPER_ENV];
470
+ const pepper = configuredPepper && configuredPepper.length > 0 ? configuredPepper : undefined;
322
471
  const events = [];
472
+ const secretScopedKeys = new Set();
323
473
  return {
324
474
  key(namespace, parts, keyOptions) {
325
475
  const extra = new Set((keyOptions?.redactFields ?? []).map((field) => field.toLowerCase()));
326
- const normalized = normalizeKeyPart(parts, extra);
327
- return `${DEFAULT_PREFIX}:${options.providerId}:${namespace}:${stableHash(normalized)}`;
476
+ const normalized = normalizeKeyPart(parts, extra, pepper);
477
+ const key = `${DEFAULT_PREFIX}:${options.providerId}:${namespace}:${stableHash(normalized.value)}`;
478
+ if (normalized.secretScoped)
479
+ secretScopedKeys.add(key);
480
+ return key;
328
481
  },
329
482
  async get(_key) {
330
483
  return null;
@@ -352,7 +505,7 @@ export function createBypassProviderCache(options) {
352
505
  return {
353
506
  hit: false,
354
507
  stale: false,
355
- keys: Array.from(new Set(events.map((event) => event.key))),
508
+ keys: metadataKeys(events, secretScopedKeys),
356
509
  source: sourceSummary(events),
357
510
  };
358
511
  },
@@ -365,4 +518,5 @@ export function resetProviderCacheForTests() {
365
518
  backend.redis?.disconnect();
366
519
  }
367
520
  sharedBackends.clear();
521
+ warnedAboutUnpepperedSecretKeys = false;
368
522
  }
package/dist/types.d.ts CHANGED
@@ -1332,9 +1332,9 @@ export interface NativeProviderConfig {
1332
1332
  }
1333
1333
  export interface ProviderCacheKeyOptions {
1334
1334
  /**
1335
- * Additional field names to omit from stable key material. The SDK always
1336
- * omits known secret-bearing names such as serviceKey, authorization,
1337
- * cookie, token, password, and secret.
1335
+ * Additional field names whose values are hashed in stable key material. The
1336
+ * SDK always hashes values under known secret-bearing names such as serviceKey,
1337
+ * authorization, cookie, token, password, and secret.
1338
1338
  */
1339
1339
  redactFields?: string[];
1340
1340
  }
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.2.0-beta.24",
2
+ "version": "2.2.0-beta.25",
3
3
  "name": "@apifuse/provider-sdk",
4
4
  "private": false,
5
5
  "type": "module",
package/src/index.ts CHANGED
@@ -52,6 +52,7 @@ export { createFlowContext, createScratchpad } from "./runtime/auth-flow.js";
52
52
  export type { BrowserClientOptions } from "./runtime/browser.js";
53
53
  export { BrowserClient, createBrowserClient } from "./runtime/browser.js";
54
54
  export {
55
+ APIFUSE__CACHE__KEY_PEPPER_ENV,
55
56
  createBypassProviderCache,
56
57
  createProviderCache,
57
58
  type ProviderCacheOptions,
@@ -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
  }
package/src/types.ts CHANGED
@@ -1615,9 +1615,9 @@ export interface NativeProviderConfig {
1615
1615
 
1616
1616
  export interface ProviderCacheKeyOptions {
1617
1617
  /**
1618
- * Additional field names to omit from stable key material. The SDK always
1619
- * omits known secret-bearing names such as serviceKey, authorization,
1620
- * cookie, token, password, and secret.
1618
+ * Additional field names whose values are hashed in stable key material. The
1619
+ * SDK always hashes values under known secret-bearing names such as serviceKey,
1620
+ * authorization, cookie, token, password, and secret.
1621
1621
  */
1622
1622
  redactFields?: string[];
1623
1623
  }