@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 +4 -0
- package/bin/apifuse-submit-check.ts +15 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/runtime/cache.d.ts +1 -0
- package/dist/runtime/cache.js +169 -15
- package/dist/types.d.ts +3 -3
- package/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/runtime/cache.ts +189 -14
- package/src/types.ts +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -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
|
|
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";
|
package/dist/runtime/cache.d.ts
CHANGED
|
@@ -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;
|
package/dist/runtime/cache.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
|
1336
|
-
*
|
|
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
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,
|
package/src/runtime/cache.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
|
1619
|
-
*
|
|
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
|
}
|