@terreno/api 0.19.0 → 0.20.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/CHANGELOG.md +25 -0
- package/dist/configuration.test.js +289 -10
- package/dist/configurationApp.d.ts +72 -5
- package/dist/configurationApp.js +168 -48
- package/dist/configurationPlugin.d.ts +64 -7
- package/dist/configurationPlugin.js +161 -39
- package/dist/configurationPlugin.test.js +238 -1
- package/dist/secretProviders.d.ts +79 -2
- package/dist/secretProviders.js +177 -9
- package/dist/secretProviders.test.d.ts +1 -0
- package/dist/secretProviders.test.js +391 -0
- package/package.json +1 -1
- package/src/configuration.test.ts +171 -7
- package/src/configurationApp.ts +213 -30
- package/src/configurationPlugin.test.ts +174 -2
- package/src/configurationPlugin.ts +157 -28
- package/src/secretProviders.test.ts +186 -0
- package/src/secretProviders.ts +145 -5
package/src/secretProviders.ts
CHANGED
|
@@ -28,7 +28,11 @@ interface SecretManagerModule {
|
|
|
28
28
|
export class EnvSecretProvider implements SecretProvider {
|
|
29
29
|
name = "env";
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
/**
|
|
32
|
+
* Resolve a secret from an environment variable. Environment variables have no
|
|
33
|
+
* versions, so the optional `version` parameter is ignored.
|
|
34
|
+
*/
|
|
35
|
+
async getSecret(secretName: string, _version?: string): Promise<string | null> {
|
|
32
36
|
// Convert secret name to env var format: "openai-api-key" → "OPENAI_API_KEY"
|
|
33
37
|
const envKey = secretName.replace(/[-.]/g, "_").toUpperCase();
|
|
34
38
|
const value = process.env[envKey] ?? null;
|
|
@@ -96,16 +100,28 @@ export class GcpSecretProvider implements SecretProvider {
|
|
|
96
100
|
return this.client;
|
|
97
101
|
}
|
|
98
102
|
|
|
99
|
-
|
|
103
|
+
/**
|
|
104
|
+
* Resolve a secret from Google Cloud Secret Manager.
|
|
105
|
+
*
|
|
106
|
+
* @param secretName - A short secret id (e.g. "openai-api-key") or a full
|
|
107
|
+
* resource path (e.g. "projects/p/secrets/s" or
|
|
108
|
+
* "projects/p/secrets/s/versions/3").
|
|
109
|
+
* @param version - Optional version to resolve when `secretName` is a short id
|
|
110
|
+
* (e.g. "3"). Defaults to "latest". Ignored when `secretName` already
|
|
111
|
+
* contains an explicit `/versions/...` suffix.
|
|
112
|
+
*/
|
|
113
|
+
async getSecret(secretName: string, version?: string): Promise<string | null> {
|
|
100
114
|
const client = await this.getClient();
|
|
101
115
|
|
|
116
|
+
const resolvedVersion = version ?? "latest";
|
|
102
117
|
let resourceName: string;
|
|
103
118
|
if (secretName.startsWith("projects/")) {
|
|
104
|
-
|
|
119
|
+
// Honor a full resource path. Only append a version when one is not present.
|
|
120
|
+
resourceName = secretName.includes("/versions/")
|
|
105
121
|
? secretName
|
|
106
|
-
: `${secretName}/versions
|
|
122
|
+
: `${secretName}/versions/${resolvedVersion}`;
|
|
107
123
|
} else {
|
|
108
|
-
resourceName = `projects/${this.projectId}/secrets/${secretName}/versions
|
|
124
|
+
resourceName = `projects/${this.projectId}/secrets/${secretName}/versions/${resolvedVersion}`;
|
|
109
125
|
}
|
|
110
126
|
|
|
111
127
|
try {
|
|
@@ -126,3 +142,127 @@ export class GcpSecretProvider implements SecretProvider {
|
|
|
126
142
|
}
|
|
127
143
|
}
|
|
128
144
|
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Secret provider that delegates to an ordered list of providers, returning the
|
|
148
|
+
* first non-null result.
|
|
149
|
+
*
|
|
150
|
+
* A provider that throws is warn-logged (secret name only — never the value) and
|
|
151
|
+
* resolution falls through to the next provider. This makes it easy to compose a
|
|
152
|
+
* primary provider with a fallback, e.g. GCP with an environment-variable
|
|
153
|
+
* fallback:
|
|
154
|
+
*
|
|
155
|
+
* @example
|
|
156
|
+
* ```typescript
|
|
157
|
+
* const provider = new CompositeSecretProvider([
|
|
158
|
+
* new GcpSecretProvider({projectId: "my-project"}),
|
|
159
|
+
* new EnvSecretProvider(),
|
|
160
|
+
* ]);
|
|
161
|
+
* const key = await provider.getSecret("openai-api-key");
|
|
162
|
+
* ```
|
|
163
|
+
*/
|
|
164
|
+
export class CompositeSecretProvider implements SecretProvider {
|
|
165
|
+
name: string;
|
|
166
|
+
private providers: SecretProvider[];
|
|
167
|
+
|
|
168
|
+
constructor(providers: SecretProvider[]) {
|
|
169
|
+
if (!providers || providers.length === 0) {
|
|
170
|
+
throw new APIError({
|
|
171
|
+
status: 500,
|
|
172
|
+
title: "CompositeSecretProvider requires at least one provider",
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
this.providers = providers;
|
|
176
|
+
this.name = `composite(${providers.map((p) => p.name).join(",")})`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async getSecret(secretName: string, version?: string): Promise<string | null> {
|
|
180
|
+
for (const provider of this.providers) {
|
|
181
|
+
try {
|
|
182
|
+
const value = await provider.getSecret(secretName, version);
|
|
183
|
+
if (value !== null) {
|
|
184
|
+
return value;
|
|
185
|
+
}
|
|
186
|
+
} catch (error: unknown) {
|
|
187
|
+
// Never log the secret value — only the name and which provider failed.
|
|
188
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
189
|
+
logger.warn(
|
|
190
|
+
`CompositeSecretProvider: provider ${provider.name} failed for secret ${secretName}: ${message}`
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Options for CachingSecretProvider.
|
|
200
|
+
*/
|
|
201
|
+
export interface CachingSecretProviderOptions {
|
|
202
|
+
/** Time-to-live for cached values, in milliseconds. Defaults to 60_000 (1 minute). */
|
|
203
|
+
ttlMs?: number;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
interface CacheEntry {
|
|
207
|
+
value: string | null;
|
|
208
|
+
expiresAt: number;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Secret provider that wraps any provider with an in-memory TTL cache.
|
|
213
|
+
*
|
|
214
|
+
* Cache entries are keyed by `secretName@version` so that pinned versions are
|
|
215
|
+
* cached independently. `null` results (secret not found) are cached too, to
|
|
216
|
+
* avoid hammering the underlying provider for missing secrets. Secret values are
|
|
217
|
+
* never logged.
|
|
218
|
+
*
|
|
219
|
+
* Use `clear()` to drop the entire cache (e.g. on rotation) or `clearKey()` to
|
|
220
|
+
* invalidate a single secret.
|
|
221
|
+
*
|
|
222
|
+
* @example
|
|
223
|
+
* ```typescript
|
|
224
|
+
* const provider = new CachingSecretProvider(
|
|
225
|
+
* new CompositeSecretProvider([gcp, env]),
|
|
226
|
+
* {ttlMs: 30_000}
|
|
227
|
+
* );
|
|
228
|
+
* ```
|
|
229
|
+
*/
|
|
230
|
+
export class CachingSecretProvider implements SecretProvider {
|
|
231
|
+
name: string;
|
|
232
|
+
private provider: SecretProvider;
|
|
233
|
+
private ttlMs: number;
|
|
234
|
+
private cache = new Map<string, CacheEntry>();
|
|
235
|
+
|
|
236
|
+
constructor(provider: SecretProvider, options?: CachingSecretProviderOptions) {
|
|
237
|
+
this.provider = provider;
|
|
238
|
+
this.ttlMs = options?.ttlMs ?? 60_000;
|
|
239
|
+
this.name = `caching(${provider.name})`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private cacheKey(secretName: string, version?: string): string {
|
|
243
|
+
return `${secretName}@${version ?? "latest"}`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async getSecret(secretName: string, version?: string): Promise<string | null> {
|
|
247
|
+
const key = this.cacheKey(secretName, version);
|
|
248
|
+
const now = Date.now();
|
|
249
|
+
const cached = this.cache.get(key);
|
|
250
|
+
if (cached && cached.expiresAt > now) {
|
|
251
|
+
return cached.value;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const value = await this.provider.getSecret(secretName, version);
|
|
255
|
+
this.cache.set(key, {expiresAt: now + this.ttlMs, value});
|
|
256
|
+
return value;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Clears the entire cache. Useful on secret rotation and in tests. */
|
|
260
|
+
clear(): void {
|
|
261
|
+
this.cache.clear();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Invalidates a single cached secret by name (and optional version). */
|
|
265
|
+
clearKey(secretName: string, version?: string): void {
|
|
266
|
+
this.cache.delete(this.cacheKey(secretName, version));
|
|
267
|
+
}
|
|
268
|
+
}
|