@terreno/api 0.19.0 → 0.20.1
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/auth.js +2 -2
- 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 +178 -9
- package/dist/secretProviders.test.d.ts +1 -0
- package/dist/secretProviders.test.js +391 -0
- package/package.json +1 -1
- package/src/auth.ts +2 -2
- 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 +147 -5
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
import {afterAll, beforeAll, beforeEach, describe, expect, it} from "bun:test";
|
|
3
3
|
import mongoose, {model, Schema} from "mongoose";
|
|
4
4
|
import type {SecretProvider} from "./configurationPlugin";
|
|
5
|
-
import {configurationPlugin} from "./configurationPlugin";
|
|
5
|
+
import {configurationPlugin, flattenToDotPaths} from "./configurationPlugin";
|
|
6
|
+
import {isDeletedPlugin} from "./plugins";
|
|
6
7
|
|
|
7
8
|
// --- Test schema with secret fields ---
|
|
8
9
|
|
|
@@ -65,13 +66,52 @@ const simpleSchema = new Schema({
|
|
|
65
66
|
simpleSchema.plugin(configurationPlugin);
|
|
66
67
|
const SimpleConfigModel = model("SimpleConfiguration", simpleSchema) as any;
|
|
67
68
|
|
|
69
|
+
// --- Schema opting into the _singleton unique index ---
|
|
70
|
+
|
|
71
|
+
const indexedSchema = new Schema({
|
|
72
|
+
value: {default: "default", description: "A value", type: String},
|
|
73
|
+
});
|
|
74
|
+
indexedSchema.plugin(configurationPlugin, {enforceSingletonIndex: true});
|
|
75
|
+
const IndexedConfigModel = model("IndexedConfiguration", indexedSchema) as any;
|
|
76
|
+
|
|
77
|
+
// --- Soft-delete-aware schema ---
|
|
78
|
+
|
|
79
|
+
const softDeleteSchema = new Schema({
|
|
80
|
+
value: {default: "default", description: "A value", type: String},
|
|
81
|
+
});
|
|
82
|
+
softDeleteSchema.plugin(configurationPlugin);
|
|
83
|
+
softDeleteSchema.plugin(isDeletedPlugin);
|
|
84
|
+
const SoftDeleteConfigModel = model("SoftDeleteConfiguration", softDeleteSchema) as any;
|
|
85
|
+
|
|
86
|
+
// --- Schema with a validated field (enum) for runValidators coverage ---
|
|
87
|
+
|
|
88
|
+
const validatedSchema = new Schema({
|
|
89
|
+
level: {
|
|
90
|
+
default: "low",
|
|
91
|
+
description: "Severity level",
|
|
92
|
+
enum: ["low", "medium", "high"],
|
|
93
|
+
type: String,
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
validatedSchema.plugin(configurationPlugin);
|
|
97
|
+
const ValidatedConfigModel = model("ValidatedConfiguration", validatedSchema) as any;
|
|
98
|
+
|
|
68
99
|
describe("configurationPlugin", () => {
|
|
69
100
|
describe("schema setup", () => {
|
|
70
|
-
it("
|
|
101
|
+
it("does not add a _singleton index by default", () => {
|
|
71
102
|
const indexes = SimpleConfigModel.schema.indexes();
|
|
72
103
|
const singletonIndex = indexes.find(
|
|
73
104
|
([fields]: [Record<string, any>]) => fields._singleton !== undefined
|
|
74
105
|
);
|
|
106
|
+
expect(singletonIndex).toBeUndefined();
|
|
107
|
+
expect(SimpleConfigModel.schema.path("_singleton")).toBeUndefined();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("adds a _singleton field with unique index when enforceSingletonIndex is true", () => {
|
|
111
|
+
const indexes = IndexedConfigModel.schema.indexes();
|
|
112
|
+
const singletonIndex = indexes.find(
|
|
113
|
+
([fields]: [Record<string, any>]) => fields._singleton !== undefined
|
|
114
|
+
);
|
|
75
115
|
expect(singletonIndex).toBeDefined();
|
|
76
116
|
expect(singletonIndex[1].unique).toBe(true);
|
|
77
117
|
});
|
|
@@ -168,6 +208,50 @@ describe("configurationPlugin", () => {
|
|
|
168
208
|
expect(resolved.size).toBe(1);
|
|
169
209
|
expect(resolved.get("apiKey")).toBe("resolved-key");
|
|
170
210
|
});
|
|
211
|
+
|
|
212
|
+
it("passes the discovered secret version to the provider", async () => {
|
|
213
|
+
const versionedSchema = new Schema({
|
|
214
|
+
token: {
|
|
215
|
+
default: "",
|
|
216
|
+
description: "Pinned secret",
|
|
217
|
+
secret: true,
|
|
218
|
+
secretName: "pinned-token",
|
|
219
|
+
secretVersion: "5",
|
|
220
|
+
type: String,
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
versionedSchema.plugin(configurationPlugin);
|
|
224
|
+
const VersionedModel = model("VersionedConfiguration", versionedSchema) as any;
|
|
225
|
+
|
|
226
|
+
const received: Array<{name: string; version?: string}> = [];
|
|
227
|
+
const provider: SecretProvider = {
|
|
228
|
+
getSecret: async (name: string, version?: string) => {
|
|
229
|
+
received.push({name, version});
|
|
230
|
+
return "value";
|
|
231
|
+
},
|
|
232
|
+
name: "versioned-provider",
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
await VersionedModel.resolveSecrets(provider);
|
|
236
|
+
expect(received).toEqual([{name: "pinned-token", version: "5"}]);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe("flattenToDotPaths", () => {
|
|
241
|
+
it("flattens nested plain objects into dotted paths", () => {
|
|
242
|
+
expect(flattenToDotPaths({a: {b: 1}})).toEqual([["a.b", 1]]);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("treats arrays as leaves", () => {
|
|
246
|
+
expect(flattenToDotPaths({a: [1, 2]})).toEqual([["a", [1, 2]]]);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("treats null as a leaf and keeps top-level keys", () => {
|
|
250
|
+
expect(flattenToDotPaths({a: null, b: "x"})).toEqual([
|
|
251
|
+
["a", null],
|
|
252
|
+
["b", "x"],
|
|
253
|
+
]);
|
|
254
|
+
});
|
|
171
255
|
});
|
|
172
256
|
|
|
173
257
|
describe("singleton behavior (requires MongoDB)", () => {
|
|
@@ -258,6 +342,22 @@ describe("configurationPlugin", () => {
|
|
|
258
342
|
expect(config.value).toBe("custom");
|
|
259
343
|
});
|
|
260
344
|
|
|
345
|
+
it("runs schema validators on updateConfig (rejects invalid enum)", async () => {
|
|
346
|
+
if (!dbConnected) {
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
try {
|
|
350
|
+
await ValidatedConfigModel.collection.drop();
|
|
351
|
+
} catch {
|
|
352
|
+
// Collection may not exist yet
|
|
353
|
+
}
|
|
354
|
+
await ValidatedConfigModel.getConfig();
|
|
355
|
+
await expect(ValidatedConfigModel.updateConfig({level: "bogus"})).rejects.toThrow();
|
|
356
|
+
// A valid value still applies.
|
|
357
|
+
const ok = await ValidatedConfigModel.updateConfig({level: "high"});
|
|
358
|
+
expect(ok.level).toBe("high");
|
|
359
|
+
});
|
|
360
|
+
|
|
261
361
|
it("prevents deleteOne", async () => {
|
|
262
362
|
if (!dbConnected) {
|
|
263
363
|
return;
|
|
@@ -297,4 +397,76 @@ describe("configurationPlugin", () => {
|
|
|
297
397
|
}
|
|
298
398
|
});
|
|
299
399
|
});
|
|
400
|
+
|
|
401
|
+
describe("soft-delete-aware singleton (requires MongoDB)", () => {
|
|
402
|
+
let dbConnected = false;
|
|
403
|
+
|
|
404
|
+
beforeAll(async () => {
|
|
405
|
+
try {
|
|
406
|
+
if (mongoose.connection.readyState === 1) {
|
|
407
|
+
dbConnected = true;
|
|
408
|
+
} else {
|
|
409
|
+
await mongoose.connect("mongodb://127.0.0.1/terreno-config-test", {
|
|
410
|
+
connectTimeoutMS: 3000,
|
|
411
|
+
serverSelectionTimeoutMS: 3000,
|
|
412
|
+
});
|
|
413
|
+
dbConnected = true;
|
|
414
|
+
}
|
|
415
|
+
} catch {
|
|
416
|
+
dbConnected = false;
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
beforeEach(async () => {
|
|
421
|
+
if (!dbConnected) {
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
try {
|
|
425
|
+
await SoftDeleteConfigModel.collection.drop();
|
|
426
|
+
} catch {
|
|
427
|
+
// Collection may not exist yet
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it("operates on the non-deleted singleton", async () => {
|
|
432
|
+
if (!dbConnected) {
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
const config = await SoftDeleteConfigModel.getConfig();
|
|
436
|
+
expect(config.value).toBe("default");
|
|
437
|
+
const updated = await SoftDeleteConfigModel.updateConfig({value: "live"});
|
|
438
|
+
expect(updated.value).toBe("live");
|
|
439
|
+
expect(updated.deleted).toBe(false);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it("allows a new singleton after the existing one is soft-deleted", async () => {
|
|
443
|
+
if (!dbConnected) {
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
const first = await SoftDeleteConfigModel.getConfig();
|
|
447
|
+
// Soft delete by setting deleted: true (allowed)
|
|
448
|
+
first.deleted = true;
|
|
449
|
+
await first.save();
|
|
450
|
+
|
|
451
|
+
// A new non-deleted singleton can now be created
|
|
452
|
+
const second = await SoftDeleteConfigModel.getConfig();
|
|
453
|
+
expect(second.deleted).toBe(false);
|
|
454
|
+
expect(second._id.toString()).not.toBe(first._id.toString());
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it("does not let updateConfig touch a soft-deleted document", async () => {
|
|
458
|
+
if (!dbConnected) {
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
const first = await SoftDeleteConfigModel.getConfig();
|
|
462
|
+
first.deleted = true;
|
|
463
|
+
await first.save();
|
|
464
|
+
|
|
465
|
+
// updateConfig creates and targets a fresh non-deleted singleton
|
|
466
|
+
const updated = await SoftDeleteConfigModel.updateConfig({value: "fresh"});
|
|
467
|
+
expect(updated.deleted).toBe(false);
|
|
468
|
+
expect(updated.value).toBe("fresh");
|
|
469
|
+
expect(updated._id.toString()).not.toBe(first._id.toString());
|
|
470
|
+
});
|
|
471
|
+
});
|
|
300
472
|
});
|
|
@@ -11,6 +11,12 @@ export interface SecretFieldMeta {
|
|
|
11
11
|
path: string;
|
|
12
12
|
secretProvider?: string;
|
|
13
13
|
secretName: string;
|
|
14
|
+
/**
|
|
15
|
+
* Optional secret version to pin resolution to. When omitted the provider
|
|
16
|
+
* resolves the latest version. Discovered from the `secretVersion` schema
|
|
17
|
+
* path option.
|
|
18
|
+
*/
|
|
19
|
+
version?: string;
|
|
14
20
|
}
|
|
15
21
|
|
|
16
22
|
/**
|
|
@@ -18,7 +24,15 @@ export interface SecretFieldMeta {
|
|
|
18
24
|
*/
|
|
19
25
|
export interface SecretProvider {
|
|
20
26
|
name: string;
|
|
21
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Resolve a secret value by name. Returns `null` when the secret is not found.
|
|
29
|
+
*
|
|
30
|
+
* @param secretName - The secret identifier (short name or provider-specific path).
|
|
31
|
+
* @param version - Optional version to pin resolution to. Providers that do not
|
|
32
|
+
* support versioning (e.g. environment variables) ignore this parameter. When
|
|
33
|
+
* omitted, the latest version is resolved.
|
|
34
|
+
*/
|
|
35
|
+
getSecret(secretName: string, version?: string): Promise<string | null>;
|
|
22
36
|
}
|
|
23
37
|
|
|
24
38
|
/**
|
|
@@ -30,6 +44,18 @@ export interface ConfigurationPluginOptions {
|
|
|
30
44
|
* Typically set during app startup so the model can resolve secrets on demand.
|
|
31
45
|
*/
|
|
32
46
|
secretProvider?: SecretProvider;
|
|
47
|
+
/**
|
|
48
|
+
* When `true`, adds a `_singleton` sentinel field with a unique index to
|
|
49
|
+
* enforce the singleton constraint at the database level.
|
|
50
|
+
*
|
|
51
|
+
* Defaults to `false`. Leave this off when the consuming app already enforces
|
|
52
|
+
* a single non-deleted document via the pre-save guard (the default behavior)
|
|
53
|
+
* or via its own indexes/soft-delete plugin, to avoid double-enforcement and
|
|
54
|
+
* conflicting indexes.
|
|
55
|
+
*
|
|
56
|
+
* @defaultValue false
|
|
57
|
+
*/
|
|
58
|
+
enforceSingletonIndex?: boolean;
|
|
33
59
|
}
|
|
34
60
|
|
|
35
61
|
// ---------------------------------------------------------------------------
|
|
@@ -75,14 +101,26 @@ export interface ConfigurationStatics<T extends object> {
|
|
|
75
101
|
getConfig(): Promise<T & Document>;
|
|
76
102
|
/** Get a specific value by dot-notation key. */
|
|
77
103
|
getConfig<P extends Paths<T>>(key: P): Promise<PathValue<T, P>>;
|
|
78
|
-
/**
|
|
104
|
+
/**
|
|
105
|
+
* Update the singleton configuration document.
|
|
106
|
+
*
|
|
107
|
+
* The patch is flattened into MongoDB dotted paths and applied with
|
|
108
|
+
* `findOneAndUpdate({$set})`. This preserves sibling fields inside nested
|
|
109
|
+
* subdocuments when a partial nested patch is supplied, and tolerates legacy /
|
|
110
|
+
* out-of-schema fields already persisted on the document (unlike a full
|
|
111
|
+
* `doc.save()`, which throws under `strict: "throw"`).
|
|
112
|
+
*/
|
|
79
113
|
updateConfig(updates: DeepPartial<T>): Promise<T & Document>;
|
|
80
114
|
/** Get secret field metadata discovered from the schema. */
|
|
81
115
|
getSecretFields(): SecretFieldMeta[];
|
|
82
116
|
/**
|
|
83
117
|
* Resolve all secret field values from a provider.
|
|
84
118
|
* Uses the provider passed here, or falls back to the one configured in the plugin options.
|
|
85
|
-
* Returns
|
|
119
|
+
* Returns an **in-memory** map of path -> value for programmatic use (startup
|
|
120
|
+
* self-checks, request-time resolution).
|
|
121
|
+
*
|
|
122
|
+
* This method never persists resolved values. Secret material must never be
|
|
123
|
+
* written to the configuration document.
|
|
86
124
|
*/
|
|
87
125
|
resolveSecrets(provider?: SecretProvider): Promise<Map<string, string>>;
|
|
88
126
|
}
|
|
@@ -103,6 +141,47 @@ export interface ConfigurationStatics<T extends object> {
|
|
|
103
141
|
*/
|
|
104
142
|
export interface ConfigurationModel<T extends object> extends Model<T>, ConfigurationStatics<T> {}
|
|
105
143
|
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
// Helpers
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Flattens a nested patch into MongoDB-style dotted paths, recursing into plain
|
|
150
|
+
* objects only; arrays and other values are treated as leaves.
|
|
151
|
+
*
|
|
152
|
+
* @example
|
|
153
|
+
* flattenToDotPaths({a: {b: 1}}) // => [["a.b", 1]]
|
|
154
|
+
*/
|
|
155
|
+
export const flattenToDotPaths = (
|
|
156
|
+
obj: Record<string, unknown>,
|
|
157
|
+
prefix = ""
|
|
158
|
+
): Array<[string, unknown]> => {
|
|
159
|
+
const out: Array<[string, unknown]> = [];
|
|
160
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
161
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
162
|
+
const isPlainObject =
|
|
163
|
+
value !== null &&
|
|
164
|
+
typeof value === "object" &&
|
|
165
|
+
!Array.isArray(value) &&
|
|
166
|
+
Object.getPrototypeOf(value) === Object.prototype;
|
|
167
|
+
if (isPlainObject) {
|
|
168
|
+
out.push(...flattenToDotPaths(value as Record<string, unknown>, path));
|
|
169
|
+
} else {
|
|
170
|
+
out.push([path, value]);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Builds the filter used to locate the singleton document. When the schema is
|
|
178
|
+
* soft-delete aware (has a `deleted` path, e.g. via `isDeletedPlugin`), the
|
|
179
|
+
* singleton is "the one non-deleted document"; otherwise any document matches.
|
|
180
|
+
*/
|
|
181
|
+
const buildSingletonFilter = (schema: Schema): Record<string, unknown> => {
|
|
182
|
+
return schema.path("deleted") ? {deleted: false} : {};
|
|
183
|
+
};
|
|
184
|
+
|
|
106
185
|
// ---------------------------------------------------------------------------
|
|
107
186
|
// Plugin
|
|
108
187
|
// ---------------------------------------------------------------------------
|
|
@@ -111,13 +190,23 @@ export interface ConfigurationModel<T extends object> extends Model<T>, Configur
|
|
|
111
190
|
* Mongoose schema plugin that adds singleton configuration behavior.
|
|
112
191
|
*
|
|
113
192
|
* Adds:
|
|
114
|
-
* - Pre-save hook enforcing exactly one document
|
|
193
|
+
* - Pre-save hook enforcing exactly one non-deleted document (soft-delete aware
|
|
194
|
+
* when the schema has a `deleted` path, e.g. via `isDeletedPlugin`)
|
|
115
195
|
* - `getConfig()` static: fetches or creates the singleton (full doc or keyed value)
|
|
116
|
-
* - `updateConfig(updates)` static: patches the singleton
|
|
196
|
+
* - `updateConfig(updates)` static: patches the singleton via `findOneAndUpdate({$set})`
|
|
197
|
+
* with dotted paths (preserves sibling subdoc fields; tolerates legacy fields)
|
|
117
198
|
* - `getSecretFields()` static: returns metadata for fields with `secret: true`
|
|
118
|
-
* - `resolveSecrets(provider?)` static:
|
|
199
|
+
* - `resolveSecrets(provider?)` static: resolves secret values into an in-memory map,
|
|
200
|
+
* using the plugin provider by default (never persists values)
|
|
201
|
+
* - Hard-delete blockers (`deleteOne`/`deleteMany`/`findOneAndDelete`); soft deletes
|
|
202
|
+
* (setting `deleted: true`) are allowed
|
|
203
|
+
*
|
|
204
|
+
* Soft deletes are allowed and a soft-deleted document does not block creating a
|
|
205
|
+
* new singleton. The `_singleton` unique index is opt-in via
|
|
206
|
+
* `enforceSingletonIndex` (default off).
|
|
119
207
|
*
|
|
120
|
-
* Mark fields as secrets using schema path options
|
|
208
|
+
* Mark fields as secrets using schema path options. Pin a version with the
|
|
209
|
+
* optional `secretVersion` option:
|
|
121
210
|
* ```typescript
|
|
122
211
|
* const configSchema = new Schema({
|
|
123
212
|
* apiKey: {
|
|
@@ -125,6 +214,7 @@ export interface ConfigurationModel<T extends object> extends Model<T>, Configur
|
|
|
125
214
|
* description: "Third-party API key",
|
|
126
215
|
* secret: true,
|
|
127
216
|
* secretName: "my-api-key",
|
|
217
|
+
* secretVersion: "3", // optional — resolves "latest" when omitted
|
|
128
218
|
* },
|
|
129
219
|
* });
|
|
130
220
|
* configSchema.plugin(configurationPlugin, {secretProvider: new EnvSecretProvider()});
|
|
@@ -136,24 +226,31 @@ export const configurationPlugin = (schema: Schema, options?: ConfigurationPlugi
|
|
|
136
226
|
// Apply findOneOrNone so the singleton lookup avoids bare Model.findOne (idempotent).
|
|
137
227
|
findOneOrNone(schema);
|
|
138
228
|
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
229
|
+
// Optionally add a sentinel field with a unique index to enforce the singleton
|
|
230
|
+
// at the database level. This is opt-in (default off) so it does not conflict
|
|
231
|
+
// with consumers that already enforce a single non-deleted document via the
|
|
232
|
+
// pre-save guard below or via their own soft-delete plugin/indexes.
|
|
233
|
+
if (pluginOptions.enforceSingletonIndex) {
|
|
234
|
+
schema.add({
|
|
235
|
+
_singleton: {
|
|
236
|
+
default: "config",
|
|
237
|
+
description: "Sentinel field enforcing singleton constraint",
|
|
238
|
+
immutable: true,
|
|
239
|
+
select: false,
|
|
240
|
+
type: String,
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
schema.index({_singleton: 1}, {unique: true});
|
|
244
|
+
}
|
|
151
245
|
|
|
152
|
-
// Enforce singleton: only one document allowed (application-level
|
|
246
|
+
// Enforce singleton: only one non-deleted document allowed (application-level
|
|
247
|
+
// guard). Soft-delete-aware: a soft-deleted document does not block creating a
|
|
248
|
+
// new singleton.
|
|
153
249
|
schema.pre("save", async function () {
|
|
154
250
|
if (this.isNew) {
|
|
251
|
+
const filter = buildSingletonFilter(schema);
|
|
155
252
|
// Cheap existence check — no document needs to be returned.
|
|
156
|
-
const existing = await (this.constructor as Model<unknown>).exists(
|
|
253
|
+
const existing = await (this.constructor as Model<unknown>).exists(filter);
|
|
157
254
|
if (existing) {
|
|
158
255
|
throw new APIError({
|
|
159
256
|
status: 409,
|
|
@@ -183,9 +280,10 @@ export const configurationPlugin = (schema: Schema, options?: ConfigurationPlugi
|
|
|
183
280
|
|
|
184
281
|
// Static: get the singleton configuration document or a value at a path (race-safe via upsert)
|
|
185
282
|
schema.statics.getConfig = async function (key?: string): Promise<unknown> {
|
|
283
|
+
const singletonFilter = buildSingletonFilter(this.schema);
|
|
186
284
|
const findSingleton = (): Promise<Document | null> =>
|
|
187
285
|
(this as unknown as FindOneOrNonePlugin<unknown>).findOneOrNone(
|
|
188
|
-
|
|
286
|
+
singletonFilter
|
|
189
287
|
) as Promise<Document | null>;
|
|
190
288
|
let config: Document | null = await findSingleton();
|
|
191
289
|
if (!config) {
|
|
@@ -222,14 +320,44 @@ export const configurationPlugin = (schema: Schema, options?: ConfigurationPlugi
|
|
|
222
320
|
return value;
|
|
223
321
|
};
|
|
224
322
|
|
|
225
|
-
// Static: update the singleton configuration document
|
|
323
|
+
// Static: update the singleton configuration document via $set dotted paths.
|
|
324
|
+
// Flattening to dotted paths preserves sibling subdoc fields and tolerates
|
|
325
|
+
// legacy/out-of-schema fields already persisted on the document.
|
|
226
326
|
schema.statics.updateConfig = async function (
|
|
227
327
|
updates: Record<string, unknown>
|
|
228
328
|
): Promise<unknown> {
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
329
|
+
const singletonFilter = buildSingletonFilter(this.schema);
|
|
330
|
+
const setFields: Record<string, unknown> = {};
|
|
331
|
+
for (const [path, value] of flattenToDotPaths(updates)) {
|
|
332
|
+
setFields[path] = value;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Nothing to set — return the current singleton (creating it if missing).
|
|
336
|
+
if (Object.keys(setFields).length === 0) {
|
|
337
|
+
return (this as unknown as ConfigurationModel<Record<string, unknown>>).getConfig();
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// runValidators keeps schema validation (enum/min/custom validators) on the
|
|
341
|
+
// patched paths, matching the prior doc.save() behavior. Legacy/out-of-schema
|
|
342
|
+
// fields already on the document are untouched (not in $set), so they are not
|
|
343
|
+
// re-validated.
|
|
344
|
+
const updated = await this.findOneAndUpdate(
|
|
345
|
+
singletonFilter,
|
|
346
|
+
{$set: setFields},
|
|
347
|
+
{new: true, runValidators: true}
|
|
348
|
+
);
|
|
349
|
+
if (updated) {
|
|
350
|
+
return updated;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// No singleton yet — create one (with subdocument defaults applied), then
|
|
354
|
+
// apply the patch.
|
|
355
|
+
await (this as unknown as ConfigurationModel<Record<string, unknown>>).getConfig();
|
|
356
|
+
return this.findOneAndUpdate(
|
|
357
|
+
singletonFilter,
|
|
358
|
+
{$set: setFields},
|
|
359
|
+
{new: true, runValidators: true}
|
|
360
|
+
).orFail();
|
|
233
361
|
};
|
|
234
362
|
|
|
235
363
|
// Static: discover secret fields from schema options
|
|
@@ -243,6 +371,7 @@ export const configurationPlugin = (schema: Schema, options?: ConfigurationPlugi
|
|
|
243
371
|
path: prefix ? `${prefix}.${pathName}` : pathName,
|
|
244
372
|
secretName: (opts.secretName as string) ?? pathName,
|
|
245
373
|
secretProvider: opts.secretProvider as string | undefined,
|
|
374
|
+
version: opts.secretVersion as string | undefined,
|
|
246
375
|
});
|
|
247
376
|
}
|
|
248
377
|
// Recurse into subschemas
|
|
@@ -275,7 +404,7 @@ export const configurationPlugin = (schema: Schema, options?: ConfigurationPlugi
|
|
|
275
404
|
|
|
276
405
|
const results = await Promise.allSettled(
|
|
277
406
|
secrets.map(async (meta: SecretFieldMeta) => {
|
|
278
|
-
const value = await resolvedProvider.getSecret(meta.secretName);
|
|
407
|
+
const value = await resolvedProvider.getSecret(meta.secretName, meta.version);
|
|
279
408
|
if (value !== null) {
|
|
280
409
|
resolved.set(meta.path, value);
|
|
281
410
|
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import {beforeEach, describe, expect, it} from "bun:test";
|
|
2
|
+
|
|
3
|
+
import type {SecretProvider} from "./configurationPlugin";
|
|
4
|
+
import {CachingSecretProvider, CompositeSecretProvider, EnvSecretProvider} from "./secretProviders";
|
|
5
|
+
|
|
6
|
+
describe("EnvSecretProvider", () => {
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
delete process.env.MY_SECRET_KEY;
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("resolves from a SCREAMING_SNAKE_CASE env var", async () => {
|
|
12
|
+
process.env.MY_SECRET_KEY = "from-env";
|
|
13
|
+
const provider = new EnvSecretProvider();
|
|
14
|
+
expect(await provider.getSecret("my-secret-key")).toBe("from-env");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("returns null when the env var is missing", async () => {
|
|
18
|
+
const provider = new EnvSecretProvider();
|
|
19
|
+
expect(await provider.getSecret("my-secret-key")).toBeNull();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("ignores the version parameter", async () => {
|
|
23
|
+
process.env.MY_SECRET_KEY = "value";
|
|
24
|
+
const provider = new EnvSecretProvider();
|
|
25
|
+
expect(await provider.getSecret("my-secret-key", "5")).toBe("value");
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("CompositeSecretProvider", () => {
|
|
30
|
+
it("throws when constructed with no providers", () => {
|
|
31
|
+
expect(() => new CompositeSecretProvider([])).toThrow();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("returns the first non-null result", async () => {
|
|
35
|
+
const a: SecretProvider = {getSecret: async () => null, name: "a"};
|
|
36
|
+
const b: SecretProvider = {getSecret: async () => "from-b", name: "b"};
|
|
37
|
+
const c: SecretProvider = {getSecret: async () => "from-c", name: "c"};
|
|
38
|
+
const provider = new CompositeSecretProvider([a, b, c]);
|
|
39
|
+
expect(await provider.getSecret("x")).toBe("from-b");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("falls through to the next provider when one throws", async () => {
|
|
43
|
+
const failing: SecretProvider = {
|
|
44
|
+
getSecret: async () => {
|
|
45
|
+
throw new Error("provider down");
|
|
46
|
+
},
|
|
47
|
+
name: "failing",
|
|
48
|
+
};
|
|
49
|
+
const fallback: SecretProvider = {getSecret: async () => "from-fallback", name: "fallback"};
|
|
50
|
+
const provider = new CompositeSecretProvider([failing, fallback]);
|
|
51
|
+
expect(await provider.getSecret("x")).toBe("from-fallback");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("returns null when every provider yields null", async () => {
|
|
55
|
+
const a: SecretProvider = {getSecret: async () => null, name: "a"};
|
|
56
|
+
const b: SecretProvider = {getSecret: async () => null, name: "b"};
|
|
57
|
+
const provider = new CompositeSecretProvider([a, b]);
|
|
58
|
+
expect(await provider.getSecret("x")).toBeNull();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("forwards the version parameter to each provider", async () => {
|
|
62
|
+
const seen: Array<string | undefined> = [];
|
|
63
|
+
const a: SecretProvider = {
|
|
64
|
+
getSecret: async (_name, version) => {
|
|
65
|
+
seen.push(version);
|
|
66
|
+
return null;
|
|
67
|
+
},
|
|
68
|
+
name: "a",
|
|
69
|
+
};
|
|
70
|
+
const b: SecretProvider = {
|
|
71
|
+
getSecret: async (_name, version) => {
|
|
72
|
+
seen.push(version);
|
|
73
|
+
return "value";
|
|
74
|
+
},
|
|
75
|
+
name: "b",
|
|
76
|
+
};
|
|
77
|
+
const provider = new CompositeSecretProvider([a, b]);
|
|
78
|
+
await provider.getSecret("x", "7");
|
|
79
|
+
expect(seen).toEqual(["7", "7"]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("builds a composite name from the underlying providers", () => {
|
|
83
|
+
const provider = new CompositeSecretProvider([
|
|
84
|
+
{getSecret: async () => null, name: "gcp"},
|
|
85
|
+
{getSecret: async () => null, name: "env"},
|
|
86
|
+
]);
|
|
87
|
+
expect(provider.name).toBe("composite(gcp,env)");
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("CachingSecretProvider", () => {
|
|
92
|
+
it("memoizes a value within the TTL (single underlying call)", async () => {
|
|
93
|
+
let calls = 0;
|
|
94
|
+
const underlying: SecretProvider = {
|
|
95
|
+
getSecret: async () => {
|
|
96
|
+
calls++;
|
|
97
|
+
return "value";
|
|
98
|
+
},
|
|
99
|
+
name: "underlying",
|
|
100
|
+
};
|
|
101
|
+
const provider = new CachingSecretProvider(underlying, {ttlMs: 10_000});
|
|
102
|
+
expect(await provider.getSecret("x")).toBe("value");
|
|
103
|
+
expect(await provider.getSecret("x")).toBe("value");
|
|
104
|
+
expect(calls).toBe(1);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("re-fetches after clear()", async () => {
|
|
108
|
+
let calls = 0;
|
|
109
|
+
const underlying: SecretProvider = {
|
|
110
|
+
getSecret: async () => {
|
|
111
|
+
calls++;
|
|
112
|
+
return `value-${calls}`;
|
|
113
|
+
},
|
|
114
|
+
name: "underlying",
|
|
115
|
+
};
|
|
116
|
+
const provider = new CachingSecretProvider(underlying, {ttlMs: 10_000});
|
|
117
|
+
expect(await provider.getSecret("x")).toBe("value-1");
|
|
118
|
+
provider.clear();
|
|
119
|
+
expect(await provider.getSecret("x")).toBe("value-2");
|
|
120
|
+
expect(calls).toBe(2);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("re-fetches after the TTL expires", async () => {
|
|
124
|
+
let calls = 0;
|
|
125
|
+
const underlying: SecretProvider = {
|
|
126
|
+
getSecret: async () => {
|
|
127
|
+
calls++;
|
|
128
|
+
return `value-${calls}`;
|
|
129
|
+
},
|
|
130
|
+
name: "underlying",
|
|
131
|
+
};
|
|
132
|
+
const provider = new CachingSecretProvider(underlying, {ttlMs: 1});
|
|
133
|
+
expect(await provider.getSecret("x")).toBe("value-1");
|
|
134
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
135
|
+
expect(await provider.getSecret("x")).toBe("value-2");
|
|
136
|
+
expect(calls).toBe(2);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("caches different versions independently", async () => {
|
|
140
|
+
const seen: Array<string | undefined> = [];
|
|
141
|
+
const underlying: SecretProvider = {
|
|
142
|
+
getSecret: async (_name, version) => {
|
|
143
|
+
seen.push(version);
|
|
144
|
+
return `v-${version ?? "latest"}`;
|
|
145
|
+
},
|
|
146
|
+
name: "underlying",
|
|
147
|
+
};
|
|
148
|
+
const provider = new CachingSecretProvider(underlying, {ttlMs: 10_000});
|
|
149
|
+
expect(await provider.getSecret("x", "1")).toBe("v-1");
|
|
150
|
+
expect(await provider.getSecret("x", "2")).toBe("v-2");
|
|
151
|
+
// Cached hits, no additional underlying calls.
|
|
152
|
+
expect(await provider.getSecret("x", "1")).toBe("v-1");
|
|
153
|
+
expect(seen).toEqual(["1", "2"]);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("clearKey invalidates a single secret", async () => {
|
|
157
|
+
let calls = 0;
|
|
158
|
+
const underlying: SecretProvider = {
|
|
159
|
+
getSecret: async () => {
|
|
160
|
+
calls++;
|
|
161
|
+
return `value-${calls}`;
|
|
162
|
+
},
|
|
163
|
+
name: "underlying",
|
|
164
|
+
};
|
|
165
|
+
const provider = new CachingSecretProvider(underlying, {ttlMs: 10_000});
|
|
166
|
+
await provider.getSecret("x");
|
|
167
|
+
provider.clearKey("x");
|
|
168
|
+
await provider.getSecret("x");
|
|
169
|
+
expect(calls).toBe(2);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("caches null results", async () => {
|
|
173
|
+
let calls = 0;
|
|
174
|
+
const underlying: SecretProvider = {
|
|
175
|
+
getSecret: async () => {
|
|
176
|
+
calls++;
|
|
177
|
+
return null;
|
|
178
|
+
},
|
|
179
|
+
name: "underlying",
|
|
180
|
+
};
|
|
181
|
+
const provider = new CachingSecretProvider(underlying, {ttlMs: 10_000});
|
|
182
|
+
expect(await provider.getSecret("missing")).toBeNull();
|
|
183
|
+
expect(await provider.getSecret("missing")).toBeNull();
|
|
184
|
+
expect(calls).toBe(1);
|
|
185
|
+
});
|
|
186
|
+
});
|