hanzio 1.1.0 → 1.2.0
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/README.md +70 -4
- package/dist/api-wrapper/builders.d.ts +13 -2
- package/dist/api-wrapper/errors.d.ts +1 -0
- package/dist/api-wrapper/headers.d.ts +2 -1
- package/dist/api-wrapper/transport.d.ts +8 -5
- package/dist/api-wrapper/types.d.ts +1 -0
- package/dist/cache/index.d.ts +26 -0
- package/dist/secrets/core.d.ts +47 -0
- package/dist/secrets/deadline.d.ts +6 -0
- package/dist/secrets/environment.d.ts +9 -0
- package/dist/secrets/errors.d.ts +11 -0
- package/dist/secrets/index.d.ts +13 -25
- package/dist/secrets/infisical-cli.d.ts +6 -0
- package/dist/secrets/infisical-config.d.ts +17 -0
- package/dist/secrets/infisical-session.d.ts +4 -0
- package/dist/secrets/infisical.d.ts +8 -13
- package/dist/secrets/loaders.d.ts +5 -12
- package/dist/secrets/vite.d.ts +7 -3
- package/dist/secrets/worker-loader.d.ts +9 -0
- package/dist/secrets/worker.d.ts +3 -0
- package/dist/src/api-wrapper/index.js +152 -122
- package/dist/src/index.js +3173 -2713
- package/dist/src/jwt/index.js +2 -2
- package/dist/src/p-queue/index.js +3 -3
- package/dist/src/secrets/index.js +670 -402
- package/dist/src/secrets/infisical.js +451 -168
- package/dist/src/secrets/loaders.js +467 -174
- package/dist/src/secrets/vite.js +33 -11
- package/dist/src/secrets/worker.js +275 -0
- package/dist/src/sitemap/index.js +1 -1
- package/dist/src/zod/index.js +7 -7
- package/dist/typedswitch/index.d.ts +9 -5
- package/package.json +12 -3
- package/src/api-wrapper/builders.ts +68 -43
- package/src/api-wrapper/create-client.ts +82 -62
- package/src/api-wrapper/errors.ts +10 -0
- package/src/api-wrapper/headers.ts +14 -1
- package/src/api-wrapper/redaction.ts +1 -0
- package/src/api-wrapper/transport.ts +34 -38
- package/src/api-wrapper/types.ts +1 -0
- package/src/cache/index.ts +228 -61
- package/src/secrets/core.ts +348 -0
- package/src/secrets/deadline.ts +69 -0
- package/src/secrets/environment.ts +69 -0
- package/src/secrets/errors.ts +29 -0
- package/src/secrets/index.ts +79 -210
- package/src/secrets/infisical-cli.ts +99 -0
- package/src/secrets/infisical-config.ts +70 -0
- package/src/secrets/infisical-session.ts +42 -0
- package/src/secrets/infisical.ts +214 -66
- package/src/secrets/loaders.ts +24 -48
- package/src/secrets/vite.ts +44 -15
- package/src/secrets/worker-loader.ts +56 -0
- package/src/secrets/worker.ts +4 -0
- package/src/typedswitch/index.ts +80 -69
package/src/cache/index.ts
CHANGED
|
@@ -9,6 +9,25 @@ type CachedFunction<T extends AnyFunction> = {
|
|
|
9
9
|
clearCache: () => void
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
export type CacheStoreEntry = {
|
|
13
|
+
value: unknown
|
|
14
|
+
timestamp: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Contract for external cache stores (e.g. a Redis adapter).
|
|
19
|
+
* Serialization is the adapter's responsibility; this library never imports
|
|
20
|
+
* the underlying client.
|
|
21
|
+
*/
|
|
22
|
+
export type CacheStore = {
|
|
23
|
+
get: (key: string) => Promise<CacheStoreEntry | undefined>
|
|
24
|
+
/** `ttlMs` maps to the store's native expiry (e.g. Redis `PX`). */
|
|
25
|
+
set: (key: string, entry: CacheStoreEntry, ttlMs: number) => Promise<void>
|
|
26
|
+
delete: (key: string) => Promise<void>
|
|
27
|
+
/** Optional; called by clearCache when defined. */
|
|
28
|
+
clear?: () => Promise<void>
|
|
29
|
+
}
|
|
30
|
+
|
|
12
31
|
export type CacheFunctionOptions<T extends AnyFunction> = {
|
|
13
32
|
name: string
|
|
14
33
|
fn: T
|
|
@@ -18,6 +37,13 @@ export type CacheFunctionOptions<T extends AnyFunction> = {
|
|
|
18
37
|
cacheKeyFn?: (...args: Parameters<T>) => unknown[]
|
|
19
38
|
/** Called when a background refresh fails (default: silent). */
|
|
20
39
|
onBackgroundRefreshError?: (error: unknown, context: { name: string }) => void
|
|
40
|
+
/**
|
|
41
|
+
* External cache store (e.g. a Redis adapter). Only supported for async
|
|
42
|
+
* functions; when omitted the built-in in-memory cache is used.
|
|
43
|
+
*/
|
|
44
|
+
store?: ReturnType<T> extends Promise<unknown> ? CacheStore : never
|
|
45
|
+
/** Called when a store get/set/delete/clear fails (default: silent). */
|
|
46
|
+
onStoreError?: (error: unknown, context: { name: string }) => void
|
|
21
47
|
}
|
|
22
48
|
|
|
23
49
|
export function cacheFunction<T extends AnyFunction>(
|
|
@@ -30,115 +56,256 @@ export function cacheFunction<T extends AnyFunction>(
|
|
|
30
56
|
refreshInBackground = false,
|
|
31
57
|
cacheKeyArgs,
|
|
32
58
|
cacheKeyFn,
|
|
33
|
-
onBackgroundRefreshError
|
|
59
|
+
onBackgroundRefreshError,
|
|
60
|
+
store,
|
|
61
|
+
onStoreError
|
|
34
62
|
} = options
|
|
35
63
|
|
|
64
|
+
const externalStore = store as CacheStore | undefined
|
|
65
|
+
|
|
36
66
|
const cache = new Map<string, CacheEntry<ReturnType<T>>>()
|
|
37
67
|
const pendingPromises = new Map<string, Promise<unknown>>()
|
|
38
68
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
69
|
+
// Stale entries are kept for an extra TTL so refreshInBackground can serve
|
|
70
|
+
// them while revalidating.
|
|
71
|
+
const entryLifetimeMs = refreshInBackground ? cacheTimeMs * 2 : cacheTimeMs
|
|
72
|
+
|
|
73
|
+
let cleanupInterval: ReturnType<typeof setInterval> | undefined
|
|
74
|
+
|
|
75
|
+
const stopCleanup = () => {
|
|
76
|
+
if (cleanupInterval !== undefined) {
|
|
77
|
+
clearInterval(cleanupInterval)
|
|
78
|
+
cleanupInterval = undefined
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Created lazily on first write and unref'd so it never keeps the process
|
|
83
|
+
// alive. Stops itself once the cache is empty and restarts on the next
|
|
84
|
+
// write, so clearCache does not permanently disable sweeping.
|
|
85
|
+
const ensureCleanup = () => {
|
|
86
|
+
if (cleanupInterval !== undefined) {
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
cleanupInterval = setInterval(
|
|
90
|
+
() => {
|
|
91
|
+
const now = Date.now()
|
|
92
|
+
for (const [key, entry] of cache.entries()) {
|
|
93
|
+
if (now - entry.timestamp >= entryLifetimeMs) {
|
|
94
|
+
cache.delete(key)
|
|
95
|
+
}
|
|
45
96
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
97
|
+
if (cache.size === 0) {
|
|
98
|
+
stopCleanup()
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
Math.min(cacheTimeMs / 2, 5 * 60 * 1000)
|
|
102
|
+
)
|
|
103
|
+
cleanupInterval.unref?.()
|
|
104
|
+
}
|
|
50
105
|
|
|
51
|
-
const
|
|
106
|
+
const buildKey = (args: Parameters<T>): string => {
|
|
52
107
|
const keyArgs = cacheKeyFn
|
|
53
108
|
? cacheKeyFn(...args)
|
|
54
109
|
: cacheKeyArgs
|
|
55
110
|
? cacheKeyArgs.map((i) => args[i])
|
|
56
111
|
: args
|
|
57
|
-
|
|
58
|
-
|
|
112
|
+
return `${name}:${JSON.stringify(keyArgs)}`
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const saveEntry = (key: string, value: ReturnType<T>) => {
|
|
116
|
+
cache.set(key, { value, timestamp: Date.now() })
|
|
117
|
+
ensureCleanup()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const fetchAndPopulate = (
|
|
121
|
+
key: string,
|
|
122
|
+
args: Parameters<T>
|
|
123
|
+
): ReturnType<T> => {
|
|
124
|
+
const result = fn(...args)
|
|
125
|
+
|
|
126
|
+
if (!isPromiseLike(result)) {
|
|
127
|
+
saveEntry(key, result as ReturnType<T>)
|
|
128
|
+
return result as ReturnType<T>
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const promise = (result as Promise<ReturnType<T>>)
|
|
132
|
+
.then((value) => {
|
|
133
|
+
saveEntry(key, value)
|
|
134
|
+
pendingPromises.delete(key)
|
|
135
|
+
return value
|
|
136
|
+
})
|
|
137
|
+
.catch((error: unknown) => {
|
|
138
|
+
pendingPromises.delete(key)
|
|
139
|
+
throw error
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
pendingPromises.set(key, promise)
|
|
143
|
+
return promise as ReturnType<T>
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const startBackgroundRefresh = (
|
|
147
|
+
key: string,
|
|
148
|
+
args: Parameters<T>,
|
|
149
|
+
staleValue: ReturnType<T>
|
|
150
|
+
) => {
|
|
151
|
+
const result = fn(...args)
|
|
152
|
+
|
|
153
|
+
if (!isPromiseLike(result)) {
|
|
154
|
+
saveEntry(key, result as ReturnType<T>)
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const promise = (result as Promise<ReturnType<T>>)
|
|
159
|
+
.then((value) => {
|
|
160
|
+
saveEntry(key, value)
|
|
161
|
+
pendingPromises.delete(key)
|
|
162
|
+
return value
|
|
163
|
+
})
|
|
164
|
+
.catch((error: unknown) => {
|
|
165
|
+
pendingPromises.delete(key)
|
|
166
|
+
onBackgroundRefreshError?.(error, { name })
|
|
167
|
+
// Anyone awaiting this promise gets the stale value instead of
|
|
168
|
+
// undefined or a rejection.
|
|
169
|
+
return staleValue
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
pendingPromises.set(key, promise)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const saveToStore = async (
|
|
176
|
+
cacheStore: CacheStore,
|
|
177
|
+
key: string,
|
|
178
|
+
value: unknown
|
|
179
|
+
) => {
|
|
180
|
+
try {
|
|
181
|
+
await cacheStore.set(
|
|
182
|
+
key,
|
|
183
|
+
{ value, timestamp: Date.now() },
|
|
184
|
+
entryLifetimeMs
|
|
185
|
+
)
|
|
186
|
+
} catch (error) {
|
|
187
|
+
onStoreError?.(error, { name })
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const callWithStore = (
|
|
192
|
+
cacheStore: CacheStore,
|
|
193
|
+
args: Parameters<T>
|
|
194
|
+
): Promise<unknown> => {
|
|
195
|
+
const key = buildKey(args)
|
|
59
196
|
|
|
60
197
|
const pending = pendingPromises.get(key)
|
|
61
|
-
|
|
62
|
-
|
|
198
|
+
// Background refresh callers can still use an entry from the store.
|
|
199
|
+
if (pending && !refreshInBackground) {
|
|
200
|
+
return pending
|
|
63
201
|
}
|
|
64
202
|
|
|
65
|
-
|
|
203
|
+
return (async () => {
|
|
204
|
+
let entry: CacheStoreEntry | undefined
|
|
205
|
+
try {
|
|
206
|
+
entry = await cacheStore.get(key)
|
|
207
|
+
} catch (error) {
|
|
208
|
+
onStoreError?.(error, { name })
|
|
209
|
+
}
|
|
66
210
|
|
|
67
|
-
|
|
68
|
-
|
|
211
|
+
if (entry) {
|
|
212
|
+
const isFresh = Date.now() - entry.timestamp < cacheTimeMs
|
|
69
213
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
} else {
|
|
73
|
-
if (isCacheFresh && !refreshInBackground) {
|
|
74
|
-
return cached.value
|
|
214
|
+
if (isFresh) {
|
|
215
|
+
return entry.value
|
|
75
216
|
}
|
|
76
217
|
|
|
77
218
|
if (refreshInBackground) {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const backgroundUpdate = fn(...args)
|
|
84
|
-
|
|
85
|
-
if (isPromiseLike(backgroundUpdate)) {
|
|
86
|
-
const backgroundPromise = (
|
|
87
|
-
backgroundUpdate as Promise<ReturnType<T>>
|
|
88
|
-
)
|
|
89
|
-
.then((resolvedValue) => {
|
|
90
|
-
cache.set(key, { value: resolvedValue, timestamp: now })
|
|
219
|
+
if (!pendingPromises.has(key)) {
|
|
220
|
+
const staleValue = entry.value
|
|
221
|
+
const refresh = (fn(...args) as Promise<unknown>)
|
|
222
|
+
.then(async (value) => {
|
|
223
|
+
await saveToStore(cacheStore, key, value)
|
|
91
224
|
pendingPromises.delete(key)
|
|
92
|
-
return
|
|
225
|
+
return value
|
|
93
226
|
})
|
|
94
227
|
.catch((error: unknown) => {
|
|
95
228
|
pendingPromises.delete(key)
|
|
96
229
|
onBackgroundRefreshError?.(error, { name })
|
|
230
|
+
return staleValue
|
|
97
231
|
})
|
|
98
|
-
|
|
99
|
-
pendingPromises.set(key, backgroundPromise)
|
|
100
|
-
} else {
|
|
101
|
-
cache.set(key, {
|
|
102
|
-
value: backgroundUpdate as ReturnType<T>,
|
|
103
|
-
timestamp: now
|
|
104
|
-
})
|
|
232
|
+
pendingPromises.set(key, refresh)
|
|
105
233
|
}
|
|
106
|
-
|
|
107
|
-
return cached.value
|
|
234
|
+
return entry.value
|
|
108
235
|
}
|
|
109
236
|
|
|
110
|
-
|
|
111
|
-
|
|
237
|
+
try {
|
|
238
|
+
await cacheStore.delete(key)
|
|
239
|
+
} catch (error) {
|
|
240
|
+
onStoreError?.(error, { name })
|
|
112
241
|
}
|
|
113
242
|
}
|
|
114
|
-
}
|
|
115
243
|
|
|
116
|
-
|
|
244
|
+
// A concurrent caller may have started a fetch while we awaited the
|
|
245
|
+
// store lookup.
|
|
246
|
+
const existing = pendingPromises.get(key)
|
|
247
|
+
if (existing) {
|
|
248
|
+
return existing
|
|
249
|
+
}
|
|
117
250
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
cache.set(key, { value: resolvedValue, timestamp: now })
|
|
251
|
+
const promise = (fn(...args) as Promise<unknown>)
|
|
252
|
+
.then(async (value) => {
|
|
253
|
+
await saveToStore(cacheStore, key, value)
|
|
122
254
|
pendingPromises.delete(key)
|
|
123
|
-
return
|
|
255
|
+
return value
|
|
124
256
|
})
|
|
125
|
-
.catch((error) => {
|
|
257
|
+
.catch((error: unknown) => {
|
|
126
258
|
pendingPromises.delete(key)
|
|
127
259
|
throw error
|
|
128
260
|
})
|
|
129
261
|
|
|
130
262
|
pendingPromises.set(key, promise)
|
|
131
|
-
return promise
|
|
263
|
+
return promise
|
|
264
|
+
})()
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const wrappedFn = (...args: Parameters<T>): ReturnType<T> => {
|
|
268
|
+
if (externalStore) {
|
|
269
|
+
return callWithStore(externalStore, args) as ReturnType<T>
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const key = buildKey(args)
|
|
273
|
+
const cached = cache.get(key)
|
|
274
|
+
|
|
275
|
+
if (cached) {
|
|
276
|
+
const isFresh = Date.now() - cached.timestamp < cacheTimeMs
|
|
277
|
+
|
|
278
|
+
if (isFresh) {
|
|
279
|
+
return cached.value
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (refreshInBackground) {
|
|
283
|
+
if (!pendingPromises.has(key)) {
|
|
284
|
+
startBackgroundRefresh(key, args, cached.value)
|
|
285
|
+
}
|
|
286
|
+
return cached.value
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
cache.delete(key)
|
|
132
290
|
}
|
|
133
291
|
|
|
134
|
-
|
|
135
|
-
|
|
292
|
+
const pending = pendingPromises.get(key)
|
|
293
|
+
if (pending) {
|
|
294
|
+
return pending as ReturnType<T>
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return fetchAndPopulate(key, args)
|
|
136
298
|
}
|
|
137
299
|
|
|
138
300
|
wrappedFn.clearCache = () => {
|
|
139
301
|
cache.clear()
|
|
140
302
|
pendingPromises.clear()
|
|
141
|
-
|
|
303
|
+
stopCleanup()
|
|
304
|
+
if (externalStore?.clear) {
|
|
305
|
+
externalStore.clear().catch((error: unknown) => {
|
|
306
|
+
onStoreError?.(error, { name })
|
|
307
|
+
})
|
|
308
|
+
}
|
|
142
309
|
}
|
|
143
310
|
|
|
144
311
|
return wrappedFn
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import { withSecretDeadline, type SecretRequestOptions } from './deadline'
|
|
2
|
+
import {
|
|
3
|
+
resolveSecretEnvironment,
|
|
4
|
+
type EnvironmentVariables,
|
|
5
|
+
type SecretEnvironmentOption
|
|
6
|
+
} from './environment'
|
|
7
|
+
import { SecretLoadError } from './errors'
|
|
8
|
+
|
|
9
|
+
export type { SecretEnvironment, SecretEnvironmentOption } from './environment'
|
|
10
|
+
export { SecretLoadError } from './errors'
|
|
11
|
+
|
|
12
|
+
export type SecretLogAttributes = {
|
|
13
|
+
readonly environment: string
|
|
14
|
+
readonly count: number
|
|
15
|
+
readonly durationMs: number
|
|
16
|
+
readonly source?: string
|
|
17
|
+
readonly sources?: Readonly<Record<string, number>>
|
|
18
|
+
readonly code?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type SecretLogger = {
|
|
22
|
+
info: (message: string, attributes: SecretLogAttributes) => void
|
|
23
|
+
error: (message: string, attributes: SecretLogAttributes) => void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type SecretSchema<K extends string, Parsed> = {
|
|
27
|
+
parseAsync: (values: Record<K, string>) => Promise<Parsed>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type CommonSecretSetOptions<
|
|
31
|
+
K extends string,
|
|
32
|
+
Parsed = Record<K, string>
|
|
33
|
+
> = SecretRequestOptions & {
|
|
34
|
+
readonly environment?: SecretEnvironmentOption
|
|
35
|
+
readonly writeToProcessEnv?: boolean
|
|
36
|
+
readonly logger?: SecretLogger | false
|
|
37
|
+
readonly schema?: SecretSchema<K, Parsed>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type CustomLoaderDefineSecretSetOptions<
|
|
41
|
+
K extends string,
|
|
42
|
+
Parsed = Record<K, string>
|
|
43
|
+
> = CommonSecretSetOptions<K, Parsed> & {
|
|
44
|
+
readonly loader: SecretSetLoader<K>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type SecretSetLoaderContext<K extends string> = {
|
|
48
|
+
readonly keys: readonly K[]
|
|
49
|
+
readonly environment: string
|
|
50
|
+
readonly signal?: AbortSignal
|
|
51
|
+
readonly timeoutMs?: number
|
|
52
|
+
|
|
53
|
+
/** Report the source actually selected by a dynamic loader. */
|
|
54
|
+
readonly reportSource?: (source: string) => void
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type SecretSetLoader<K extends string> = ((
|
|
58
|
+
context: SecretSetLoaderContext<K>
|
|
59
|
+
) => Promise<Partial<Record<K, string>>>) & {
|
|
60
|
+
readonly source?: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type SecretSet<K extends string, Parsed = Record<K, string>> = {
|
|
64
|
+
secret: (key: K) => string
|
|
65
|
+
secrets: () => Record<K, string>
|
|
66
|
+
parsed: () => Parsed
|
|
67
|
+
sources: () => Record<K, string>
|
|
68
|
+
reload: (options?: SecretRequestOptions) => Promise<SecretSet<K, Parsed>>
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Shared across bundled entry points and package copies in the same JS realm.
|
|
72
|
+
// Only helper-written values are registered; original environment overrides aren't.
|
|
73
|
+
const registryKey = Symbol.for('hanzio.secrets.environmentWrites.v1')
|
|
74
|
+
const shared = globalThis as typeof globalThis & {
|
|
75
|
+
[registryKey]?: WeakMap<EnvironmentVariables, Map<string, string>>
|
|
76
|
+
}
|
|
77
|
+
const environmentWrites =
|
|
78
|
+
shared[registryKey] ??
|
|
79
|
+
new WeakMap<EnvironmentVariables, Map<string, string>>()
|
|
80
|
+
|
|
81
|
+
shared[registryKey] = environmentWrites
|
|
82
|
+
|
|
83
|
+
const defaultLogger: SecretLogger = {
|
|
84
|
+
info: (message, attrs) =>
|
|
85
|
+
console.log(
|
|
86
|
+
`${message} environment=${attrs.environment} count=${attrs.count} source=${attrs.source} durationMs=${attrs.durationMs}`,
|
|
87
|
+
{ sources: attrs.sources }
|
|
88
|
+
),
|
|
89
|
+
error: (message, attrs) => console.error(message, attrs)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function readOverrides<K extends string>(
|
|
93
|
+
keys: readonly K[],
|
|
94
|
+
env?: EnvironmentVariables
|
|
95
|
+
): Partial<Record<K, string>> {
|
|
96
|
+
if (!env) {
|
|
97
|
+
return {}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const writes = environmentWrites.get(env)
|
|
101
|
+
|
|
102
|
+
return Object.fromEntries(
|
|
103
|
+
keys.flatMap((key) => {
|
|
104
|
+
const value = Object.hasOwn(env, key) ? env[key] : undefined
|
|
105
|
+
|
|
106
|
+
// A matching helper write is a mirror, not an explicit user override.
|
|
107
|
+
if (writes?.has(key)) {
|
|
108
|
+
if (writes.get(key) === value) {
|
|
109
|
+
return []
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// An external change gives ownership back to the environment.
|
|
113
|
+
writes.delete(key)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return value ? [[key, value]] : []
|
|
117
|
+
})
|
|
118
|
+
) as Partial<Record<K, string>>
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function defineSecretSet<
|
|
122
|
+
const Keys extends readonly string[],
|
|
123
|
+
Parsed = Record<Keys[number], string>
|
|
124
|
+
>(
|
|
125
|
+
keys: Keys,
|
|
126
|
+
options: CustomLoaderDefineSecretSetOptions<Keys[number], Parsed>,
|
|
127
|
+
environmentVariables?: EnvironmentVariables
|
|
128
|
+
): Promise<SecretSet<Keys[number], Parsed>> {
|
|
129
|
+
type K = Keys[number]
|
|
130
|
+
|
|
131
|
+
const keySet = [...new Set(keys)] as K[]
|
|
132
|
+
const environment = resolveSecretEnvironment(
|
|
133
|
+
options.environment,
|
|
134
|
+
environmentVariables
|
|
135
|
+
)
|
|
136
|
+
const logger =
|
|
137
|
+
options.logger === false ? undefined : (options.logger ?? defaultLogger)
|
|
138
|
+
|
|
139
|
+
// These snapshots change together only after an entire load succeeds.
|
|
140
|
+
let cached: Record<K, string>
|
|
141
|
+
let parsed: Parsed
|
|
142
|
+
let sources: Record<K, string>
|
|
143
|
+
let inFlight: Promise<void> | undefined
|
|
144
|
+
|
|
145
|
+
const log = (
|
|
146
|
+
level: 'info' | 'error',
|
|
147
|
+
message: string,
|
|
148
|
+
attributes: SecretLogAttributes
|
|
149
|
+
) => {
|
|
150
|
+
try {
|
|
151
|
+
logger?.[level](message, attributes)
|
|
152
|
+
} catch {
|
|
153
|
+
/* Logging cannot change a load result. */
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Build, validate, and commit one snapshot under the caller’s deadline.
|
|
158
|
+
const loadSnapshot = async (
|
|
159
|
+
request: SecretRequestOptions,
|
|
160
|
+
signal: AbortSignal
|
|
161
|
+
) => {
|
|
162
|
+
// Fetch only keys that lack genuine local overrides.
|
|
163
|
+
const local = readOverrides(keySet, environmentVariables)
|
|
164
|
+
const missing = keySet.filter((key) => !Object.hasOwn(local, key))
|
|
165
|
+
let loaderSource = options.loader.source ?? 'custom loader'
|
|
166
|
+
let loaded: Partial<Record<K, string>> = {}
|
|
167
|
+
|
|
168
|
+
if (missing.length) {
|
|
169
|
+
try {
|
|
170
|
+
loaded = await options.loader({
|
|
171
|
+
keys: missing,
|
|
172
|
+
environment,
|
|
173
|
+
signal,
|
|
174
|
+
timeoutMs: request.timeoutMs ?? options.timeoutMs ?? 30_000,
|
|
175
|
+
reportSource: (source) => {
|
|
176
|
+
loaderSource = source
|
|
177
|
+
}
|
|
178
|
+
})
|
|
179
|
+
} catch (error) {
|
|
180
|
+
if (error instanceof SecretLoadError) {
|
|
181
|
+
throw error
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
throw new SecretLoadError('LOADER', 'Secret loader failed.')
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Honor overrides changed while the loader was running.
|
|
189
|
+
const overrides = readOverrides(keySet, environmentVariables)
|
|
190
|
+
const next = Object.fromEntries(
|
|
191
|
+
keySet.map((key) => {
|
|
192
|
+
if (Object.hasOwn(overrides, key)) {
|
|
193
|
+
return [key, overrides[key]]
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (Object.hasOwn(loaded, key)) {
|
|
197
|
+
return [key, loaded[key]]
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return [key, undefined]
|
|
201
|
+
})
|
|
202
|
+
) as Record<K, string>
|
|
203
|
+
const missingKeys = keySet.filter(
|
|
204
|
+
(key) => typeof next[key] !== 'string' || !next[key]
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
if (missingKeys.length) {
|
|
208
|
+
throw new SecretLoadError(
|
|
209
|
+
'MISSING_SECRETS',
|
|
210
|
+
`Missing secrets: ${missingKeys.join(', ')}`
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Validate the merged snapshot before touching the cache or environment.
|
|
215
|
+
let nextParsed: Parsed
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
nextParsed = options.schema
|
|
219
|
+
? await options.schema.parseAsync({ ...next })
|
|
220
|
+
: ({ ...next } as Parsed)
|
|
221
|
+
|
|
222
|
+
// Validate copyability before committing anything.
|
|
223
|
+
nextParsed = structuredClone(nextParsed)
|
|
224
|
+
} catch {
|
|
225
|
+
throw new SecretLoadError(
|
|
226
|
+
'VALIDATION',
|
|
227
|
+
'Secret schema validation failed. Check the configured schema; values are omitted.'
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// A custom loader or schema may ignore cancellation while awaiting.
|
|
232
|
+
// Its late result must never replace the last successful snapshot.
|
|
233
|
+
signal.throwIfAborted()
|
|
234
|
+
|
|
235
|
+
const nextSources = Object.fromEntries(
|
|
236
|
+
keySet.map((key) => [
|
|
237
|
+
key,
|
|
238
|
+
Object.hasOwn(overrides, key) ? 'process.env' : loaderSource
|
|
239
|
+
])
|
|
240
|
+
) as Record<K, string>
|
|
241
|
+
|
|
242
|
+
// Register only fetched values so future loads can identify our mirrors.
|
|
243
|
+
if (options.writeToProcessEnv !== false && environmentVariables) {
|
|
244
|
+
let writes = environmentWrites.get(environmentVariables)
|
|
245
|
+
|
|
246
|
+
if (!writes) {
|
|
247
|
+
writes = new Map()
|
|
248
|
+
environmentWrites.set(environmentVariables, writes)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
for (const key of keySet) {
|
|
252
|
+
if (Object.hasOwn(overrides, key)) {
|
|
253
|
+
continue
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
environmentVariables[key] = next[key]
|
|
257
|
+
writes.set(key, next[key])
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
cached = next
|
|
262
|
+
parsed = nextParsed
|
|
263
|
+
sources = nextSources
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const load = (request: SecretRequestOptions = {}): Promise<void> => {
|
|
267
|
+
// Concurrent reloads share the first caller's deadline and cancellation.
|
|
268
|
+
if (inFlight) {
|
|
269
|
+
return inFlight
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
inFlight = (async () => {
|
|
273
|
+
const start = Date.now()
|
|
274
|
+
|
|
275
|
+
try {
|
|
276
|
+
await withSecretDeadline(
|
|
277
|
+
{
|
|
278
|
+
timeoutMs: request.timeoutMs ?? options.timeoutMs,
|
|
279
|
+
signal: request.signal ?? options.signal
|
|
280
|
+
},
|
|
281
|
+
(signal) => loadSnapshot(request, signal)
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
// Report the sources of committed values, not attempted fetches.
|
|
285
|
+
const sourceCounts = new Map<string, number>()
|
|
286
|
+
|
|
287
|
+
for (const key of keySet) {
|
|
288
|
+
const source = sources[key]
|
|
289
|
+
|
|
290
|
+
sourceCounts.set(source, (sourceCounts.get(source) ?? 0) + 1)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const counts = Object.fromEntries(sourceCounts)
|
|
294
|
+
|
|
295
|
+
log('info', 'Loaded secrets', {
|
|
296
|
+
environment,
|
|
297
|
+
count: keySet.length,
|
|
298
|
+
durationMs: Date.now() - start,
|
|
299
|
+
source: Object.keys(counts).join(' + ') || 'none',
|
|
300
|
+
sources: counts
|
|
301
|
+
})
|
|
302
|
+
} catch (error) {
|
|
303
|
+
const safe =
|
|
304
|
+
error instanceof SecretLoadError
|
|
305
|
+
? error
|
|
306
|
+
: new SecretLoadError('LOADER', 'Secret loading failed.')
|
|
307
|
+
|
|
308
|
+
log('error', 'Secret loading failed', {
|
|
309
|
+
environment,
|
|
310
|
+
count: keySet.length,
|
|
311
|
+
durationMs: Date.now() - start,
|
|
312
|
+
code: safe.code
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
throw safe
|
|
316
|
+
}
|
|
317
|
+
})().finally(() => {
|
|
318
|
+
inFlight = undefined
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
return inFlight
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const secretSet: SecretSet<K, Parsed> = {
|
|
325
|
+
secret(key) {
|
|
326
|
+
if (!Object.hasOwn(cached, key)) {
|
|
327
|
+
throw new SecretLoadError('MISSING_SECRETS', `Secret ${key} not found`)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return cached[key]
|
|
331
|
+
},
|
|
332
|
+
|
|
333
|
+
// Return copies so consumers cannot mutate the internal snapshots.
|
|
334
|
+
secrets: () => ({ ...cached }),
|
|
335
|
+
parsed: () => structuredClone(parsed),
|
|
336
|
+
sources: () => ({ ...sources }),
|
|
337
|
+
|
|
338
|
+
async reload(request) {
|
|
339
|
+
await load(request)
|
|
340
|
+
|
|
341
|
+
return secretSet
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
await load()
|
|
346
|
+
|
|
347
|
+
return secretSet
|
|
348
|
+
}
|