@terreno/api 0.13.3 → 0.14.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/dist/__tests__/versionCheckPlugin.test.js +136 -3
- package/dist/api.arrayOperations.test.js +1 -0
- package/dist/api.d.ts +15 -4
- package/dist/api.errors.test.js +1 -0
- package/dist/api.hooks.test.js +1 -0
- package/dist/api.js +153 -104
- package/dist/api.query.test.js +1 -0
- package/dist/api.test.js +174 -0
- package/dist/auth.d.ts +10 -5
- package/dist/auth.js +163 -90
- package/dist/auth.test.js +159 -0
- package/dist/betterAuthApp.test.js +1 -0
- package/dist/betterAuthSetup.d.ts +5 -6
- package/dist/betterAuthSetup.js +30 -17
- package/dist/betterAuthSetup.test.js +1 -0
- package/dist/config.d.ts +48 -0
- package/dist/config.js +257 -0
- package/dist/config.test.d.ts +1 -0
- package/dist/config.test.js +328 -0
- package/dist/configuration.test.js +1 -0
- package/dist/configurationApp.d.ts +1 -1
- package/dist/configurationApp.js +17 -13
- package/dist/configurationPlugin.test.js +1 -0
- package/dist/consentApp.test.js +1 -0
- package/dist/envConfigurationPlugin.d.ts +2 -0
- package/dist/envConfigurationPlugin.js +173 -0
- package/dist/envConfigurationPlugin.test.d.ts +1 -0
- package/dist/envConfigurationPlugin.test.js +322 -0
- package/dist/errors.d.ts +18 -7
- package/dist/errors.js +111 -12
- package/dist/errors.test.js +16 -1
- package/dist/example.js +19 -7
- package/dist/expressServer.d.ts +10 -9
- package/dist/expressServer.js +62 -53
- package/dist/expressServer.test.js +165 -2
- package/dist/githubAuth.d.ts +2 -1
- package/dist/githubAuth.js +41 -26
- package/dist/githubAuth.test.js +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/logger.d.ts +1 -1
- package/dist/logger.js +42 -20
- package/dist/models/versionConfig.d.ts +2 -0
- package/dist/models/versionConfig.js +8 -0
- package/dist/notifiers/googleChatNotifier.js +14 -16
- package/dist/notifiers/googleChatNotifier.test.js +1 -0
- package/dist/notifiers/slackNotifier.js +16 -14
- package/dist/notifiers/slackNotifier.test.js +41 -3
- package/dist/notifiers/zoomNotifier.js +7 -10
- package/dist/notifiers/zoomNotifier.test.js +1 -0
- package/dist/openApi.d.ts +1 -1
- package/dist/openApi.test.js +1 -0
- package/dist/openApiBuilder.d.ts +39 -6
- package/dist/openApiBuilder.js +1 -31
- package/dist/openApiBuilder.test.js +1 -0
- package/dist/openApiValidator.js +1 -0
- package/dist/openApiValidator.test.js +1 -0
- package/dist/permissions.d.ts +4 -4
- package/dist/permissions.js +67 -65
- package/dist/permissions.middleware.test.js +1 -0
- package/dist/permissions.test.js +1 -0
- package/dist/plugins.d.ts +5 -5
- package/dist/plugins.js +18 -9
- package/dist/plugins.test.js +1 -1
- package/dist/populate.d.ts +15 -8
- package/dist/populate.js +23 -24
- package/dist/populate.test.js +1 -0
- package/dist/realtime/changeStreamWatcher.d.ts +73 -0
- package/dist/realtime/changeStreamWatcher.js +724 -0
- package/dist/realtime/index.d.ts +6 -0
- package/dist/realtime/index.js +27 -0
- package/dist/realtime/queryMatcher.d.ts +14 -0
- package/dist/realtime/queryMatcher.js +250 -0
- package/dist/realtime/queryStore.d.ts +37 -0
- package/dist/realtime/queryStore.js +195 -0
- package/dist/realtime/realtime.test.d.ts +10 -0
- package/dist/realtime/realtime.test.js +3066 -0
- package/dist/realtime/realtimeApp.d.ts +93 -0
- package/dist/realtime/realtimeApp.js +560 -0
- package/dist/realtime/registry.d.ts +40 -0
- package/dist/realtime/registry.js +38 -0
- package/dist/realtime/socketUser.d.ts +10 -0
- package/dist/realtime/socketUser.js +17 -0
- package/dist/realtime/types.d.ts +100 -0
- package/dist/realtime/types.js +2 -0
- package/dist/requestContext.d.ts +37 -0
- package/dist/requestContext.js +344 -0
- package/dist/requestContext.test.d.ts +1 -0
- package/dist/requestContext.test.js +384 -0
- package/dist/terrenoApp.d.ts +8 -0
- package/dist/terrenoApp.js +50 -13
- package/dist/terrenoApp.test.js +194 -21
- package/dist/terrenoPlugin.d.ts +11 -0
- package/dist/tests/bunSetup.js +1 -0
- package/dist/tests.js +1 -1
- package/dist/transformers.d.ts +2 -2
- package/dist/transformers.js +5 -3
- package/dist/transformers.test.js +90 -0
- package/dist/types/consentResponse.d.ts +6 -3
- package/dist/versionCheckPlugin.d.ts +2 -0
- package/dist/versionCheckPlugin.js +18 -12
- package/package.json +4 -2
- package/src/__tests__/versionCheckPlugin.test.ts +94 -3
- package/src/api.arrayOperations.test.ts +1 -0
- package/src/api.errors.test.ts +1 -0
- package/src/api.hooks.test.ts +1 -0
- package/src/api.query.test.ts +1 -0
- package/src/api.test.ts +132 -0
- package/src/api.ts +199 -84
- package/src/auth.test.ts +160 -0
- package/src/auth.ts +120 -50
- package/src/betterAuthApp.test.ts +1 -0
- package/src/betterAuthSetup.test.ts +1 -0
- package/src/betterAuthSetup.ts +59 -22
- package/src/config.test.ts +255 -0
- package/src/config.ts +216 -0
- package/src/configuration.test.ts +1 -0
- package/src/configurationApp.ts +59 -24
- package/src/configurationPlugin.test.ts +1 -0
- package/src/consentApp.test.ts +1 -0
- package/src/envConfigurationPlugin.test.ts +143 -0
- package/src/envConfigurationPlugin.ts +100 -0
- package/src/errors.test.ts +19 -1
- package/src/errors.ts +118 -38
- package/src/example.ts +49 -21
- package/src/express.d.ts +18 -1
- package/src/expressServer.test.ts +147 -2
- package/src/expressServer.ts +80 -50
- package/src/githubAuth.test.ts +1 -0
- package/src/githubAuth.ts +59 -38
- package/src/index.ts +4 -0
- package/src/logger.ts +47 -17
- package/src/models/versionConfig.ts +13 -2
- package/src/notifiers/googleChatNotifier.test.ts +1 -0
- package/src/notifiers/googleChatNotifier.ts +7 -9
- package/src/notifiers/slackNotifier.test.ts +29 -3
- package/src/notifiers/slackNotifier.ts +9 -7
- package/src/notifiers/zoomNotifier.test.ts +1 -0
- package/src/notifiers/zoomNotifier.ts +8 -11
- package/src/openApi.test.ts +1 -0
- package/src/openApi.ts +4 -4
- package/src/openApiBuilder.test.ts +1 -0
- package/src/openApiBuilder.ts +14 -11
- package/src/openApiValidator.test.ts +1 -0
- package/src/openApiValidator.ts +3 -2
- package/src/permissions.middleware.test.ts +1 -0
- package/src/permissions.test.ts +1 -0
- package/src/permissions.ts +30 -25
- package/src/plugins.test.ts +1 -1
- package/src/plugins.ts +21 -14
- package/src/populate.test.ts +1 -0
- package/src/populate.ts +44 -36
- package/src/realtime/changeStreamWatcher.ts +572 -0
- package/src/realtime/index.ts +34 -0
- package/src/realtime/queryMatcher.ts +179 -0
- package/src/realtime/queryStore.ts +132 -0
- package/src/realtime/realtime.test.ts +2465 -0
- package/src/realtime/realtimeApp.ts +478 -0
- package/src/realtime/registry.ts +64 -0
- package/src/realtime/socketUser.ts +25 -0
- package/src/realtime/types.ts +112 -0
- package/src/requestContext.test.ts +321 -0
- package/src/requestContext.ts +368 -0
- package/src/terrenoApp.test.ts +137 -11
- package/src/terrenoApp.ts +64 -17
- package/src/terrenoPlugin.ts +12 -0
- package/src/tests/bunSetup.ts +1 -0
- package/src/tests.ts +7 -2
- package/src/transformers.test.ts +70 -2
- package/src/transformers.ts +15 -7
- package/src/types/consentResponse.ts +8 -10
- package/src/versionCheckPlugin.ts +15 -7
package/src/configurationApp.ts
CHANGED
|
@@ -17,7 +17,7 @@ const requireAdmin = (
|
|
|
17
17
|
_res: express.Response,
|
|
18
18
|
next: express.NextFunction
|
|
19
19
|
): void => {
|
|
20
|
-
if (!
|
|
20
|
+
if (!req.user?.admin) {
|
|
21
21
|
next(new APIError({status: 403, title: "Admin access required"}));
|
|
22
22
|
return;
|
|
23
23
|
}
|
|
@@ -32,7 +32,7 @@ interface ConfigFieldMeta {
|
|
|
32
32
|
required: boolean;
|
|
33
33
|
description?: string;
|
|
34
34
|
enum?: string[];
|
|
35
|
-
default?:
|
|
35
|
+
default?: unknown;
|
|
36
36
|
secret?: boolean;
|
|
37
37
|
widget?: string;
|
|
38
38
|
}
|
|
@@ -59,6 +59,7 @@ export interface ConfigurationMetaResponse {
|
|
|
59
59
|
*/
|
|
60
60
|
export interface ConfigurationAppOptions {
|
|
61
61
|
/** The Mongoose model with configurationPlugin applied. */
|
|
62
|
+
// biome-ignore lint/suspicious/noExplicitAny: Model<any> required for invariance — consumers pass arbitrary configuration models
|
|
62
63
|
model: Model<any>;
|
|
63
64
|
/** Base path for configuration routes. Defaults to "/configuration". */
|
|
64
65
|
basePath?: string;
|
|
@@ -70,8 +71,15 @@ export interface ConfigurationAppOptions {
|
|
|
70
71
|
* Extracts field metadata from an OpenAPI properties object, augmented with
|
|
71
72
|
* secret info from the Mongoose schema.
|
|
72
73
|
*/
|
|
74
|
+
interface OpenApiPropertyMeta {
|
|
75
|
+
type?: string;
|
|
76
|
+
default?: unknown;
|
|
77
|
+
description?: string;
|
|
78
|
+
enum?: string[];
|
|
79
|
+
}
|
|
80
|
+
|
|
73
81
|
const extractFieldMeta = (
|
|
74
|
-
properties: Record<string,
|
|
82
|
+
properties: Record<string, OpenApiPropertyMeta>,
|
|
75
83
|
required: string[],
|
|
76
84
|
schema: Schema,
|
|
77
85
|
prefix: string,
|
|
@@ -81,7 +89,9 @@ const extractFieldMeta = (
|
|
|
81
89
|
for (const [key, prop] of Object.entries(properties)) {
|
|
82
90
|
const fullPath = prefix ? `${prefix}.${key}` : key;
|
|
83
91
|
const schemaPath = schema.path(fullPath);
|
|
84
|
-
const opts = schemaPath?.options as
|
|
92
|
+
const opts = schemaPath?.options as
|
|
93
|
+
| {description?: string; secret?: boolean; default?: unknown}
|
|
94
|
+
| undefined;
|
|
85
95
|
|
|
86
96
|
fields[key] = {
|
|
87
97
|
default: prop.default,
|
|
@@ -112,17 +122,23 @@ const SECRET_REDACTED = "********";
|
|
|
112
122
|
* Replaces values at secret paths with a placeholder string.
|
|
113
123
|
*/
|
|
114
124
|
const redactSecrets = (
|
|
115
|
-
obj: Record<string,
|
|
125
|
+
obj: Record<string, unknown>,
|
|
116
126
|
secretFields: SecretFieldMeta[]
|
|
117
|
-
): Record<string,
|
|
118
|
-
const redacted = {...obj};
|
|
127
|
+
): Record<string, unknown> => {
|
|
128
|
+
const redacted: Record<string, unknown> = {...obj};
|
|
119
129
|
for (const field of secretFields) {
|
|
120
130
|
const parts = field.path.split(".");
|
|
121
|
-
let current:
|
|
131
|
+
let current: Record<string, unknown> | null = redacted;
|
|
122
132
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
123
|
-
if (current
|
|
124
|
-
|
|
125
|
-
|
|
133
|
+
if (!current) {
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
const part = parts[i];
|
|
137
|
+
const nested = current[part];
|
|
138
|
+
if (nested != null && typeof nested === "object") {
|
|
139
|
+
const copy = {...(nested as Record<string, unknown>)};
|
|
140
|
+
current[part] = copy;
|
|
141
|
+
current = copy;
|
|
126
142
|
} else {
|
|
127
143
|
current = null;
|
|
128
144
|
break;
|
|
@@ -206,8 +222,18 @@ export class ConfigurationApp implements TerrenoPlugin {
|
|
|
206
222
|
}
|
|
207
223
|
);
|
|
208
224
|
|
|
225
|
+
interface ConfigModelStatics {
|
|
226
|
+
getSecretFields?: () => SecretFieldMeta[];
|
|
227
|
+
getConfig: () => Promise<{toJSON: () => Record<string, unknown>}>;
|
|
228
|
+
updateConfig: (
|
|
229
|
+
body: Record<string, unknown>
|
|
230
|
+
) => Promise<{toJSON: () => Record<string, unknown>}>;
|
|
231
|
+
resolveSecrets: () => Promise<Map<string, string>>;
|
|
232
|
+
}
|
|
233
|
+
const ConfigStatics = ConfigModel as unknown as ConfigModelStatics;
|
|
234
|
+
|
|
209
235
|
// Discover secret fields once at registration time
|
|
210
|
-
const secretFields: SecretFieldMeta[] =
|
|
236
|
+
const secretFields: SecretFieldMeta[] = ConfigStatics.getSecretFields?.() ?? [];
|
|
211
237
|
|
|
212
238
|
// GET /configuration — current values (secrets redacted)
|
|
213
239
|
app.get(
|
|
@@ -215,7 +241,7 @@ export class ConfigurationApp implements TerrenoPlugin {
|
|
|
215
241
|
authenticateMiddleware(),
|
|
216
242
|
requireAdmin,
|
|
217
243
|
asyncHandler(async (_req: express.Request, res: express.Response) => {
|
|
218
|
-
const config = await
|
|
244
|
+
const config = await ConfigStatics.getConfig();
|
|
219
245
|
const data = redactSecrets(config.toJSON(), secretFields);
|
|
220
246
|
return res.json({data});
|
|
221
247
|
})
|
|
@@ -229,8 +255,8 @@ export class ConfigurationApp implements TerrenoPlugin {
|
|
|
229
255
|
asyncHandler(async (req: express.Request, res: express.Response) => {
|
|
230
256
|
// Strip internal system fields that should never be updated via the API
|
|
231
257
|
const {_singleton: _s, _id: _i, __v: _v, ...safeBody} = req.body;
|
|
232
|
-
const config = await
|
|
233
|
-
logger.info(`Configuration updated by ${
|
|
258
|
+
const config = await ConfigStatics.updateConfig(safeBody);
|
|
259
|
+
logger.info(`Configuration updated by ${req.user?.email ?? "unknown"}`);
|
|
234
260
|
const data = redactSecrets(config.toJSON(), secretFields);
|
|
235
261
|
return res.json({data});
|
|
236
262
|
})
|
|
@@ -242,13 +268,13 @@ export class ConfigurationApp implements TerrenoPlugin {
|
|
|
242
268
|
authenticateMiddleware(),
|
|
243
269
|
requireAdmin,
|
|
244
270
|
asyncHandler(async (_req: express.Request, res: express.Response) => {
|
|
245
|
-
const resolved: Map<string, string> = await
|
|
271
|
+
const resolved: Map<string, string> = await ConfigStatics.resolveSecrets();
|
|
246
272
|
if (resolved.size > 0) {
|
|
247
273
|
const updates: Record<string, unknown> = {};
|
|
248
274
|
for (const [path, value] of resolved) {
|
|
249
275
|
updates[path] = value;
|
|
250
276
|
}
|
|
251
|
-
await
|
|
277
|
+
await ConfigStatics.updateConfig(updates);
|
|
252
278
|
logger.info(`Refreshed ${resolved.size}/${secretFields.length} secrets`);
|
|
253
279
|
}
|
|
254
280
|
|
|
@@ -269,6 +295,7 @@ export class ConfigurationApp implements TerrenoPlugin {
|
|
|
269
295
|
* Top-level fields with subschemas become sections.
|
|
270
296
|
* Top-level scalar fields go into a "General" section.
|
|
271
297
|
*/
|
|
298
|
+
// biome-ignore lint/suspicious/noExplicitAny: Model<any> required for invariance with consumer-supplied configuration models
|
|
272
299
|
private buildMetadata(_model: Model<any>, schema: Schema): ConfigurationMetaResponse {
|
|
273
300
|
const sections: ConfigSectionMeta[] = [];
|
|
274
301
|
const generalFields: Record<string, ConfigFieldMeta> = {};
|
|
@@ -279,21 +306,21 @@ export class ConfigurationApp implements TerrenoPlugin {
|
|
|
279
306
|
return;
|
|
280
307
|
}
|
|
281
308
|
|
|
282
|
-
const subSchema = (schemaType as
|
|
309
|
+
const subSchema = (schemaType as unknown as {schema?: Schema}).schema;
|
|
283
310
|
|
|
284
311
|
if (subSchema) {
|
|
285
312
|
// This is a nested subschema — make it a section
|
|
286
313
|
const {properties, required} = getOpenApiSpecForModel({
|
|
287
314
|
modelName: pathName,
|
|
288
315
|
schema: subSchema,
|
|
289
|
-
} as
|
|
316
|
+
} as unknown as Model<unknown>);
|
|
290
317
|
|
|
291
318
|
// Filter out system fields from the subschema too
|
|
292
|
-
const filteredProperties: Record<string,
|
|
319
|
+
const filteredProperties: Record<string, OpenApiPropertyMeta> = {};
|
|
293
320
|
const filteredRequired: string[] = [];
|
|
294
321
|
for (const [key, val] of Object.entries(properties)) {
|
|
295
322
|
if (!SYSTEM_FIELDS.has(key)) {
|
|
296
|
-
filteredProperties[key] = val;
|
|
323
|
+
filteredProperties[key] = val as OpenApiPropertyMeta;
|
|
297
324
|
if (required.includes(key)) {
|
|
298
325
|
filteredRequired.push(key);
|
|
299
326
|
}
|
|
@@ -309,7 +336,7 @@ export class ConfigurationApp implements TerrenoPlugin {
|
|
|
309
336
|
);
|
|
310
337
|
|
|
311
338
|
// Get description from the parent path options
|
|
312
|
-
const opts = schemaType.options as
|
|
339
|
+
const opts = schemaType.options as {description?: string} | undefined;
|
|
313
340
|
|
|
314
341
|
sections.push({
|
|
315
342
|
description: opts?.description,
|
|
@@ -319,7 +346,15 @@ export class ConfigurationApp implements TerrenoPlugin {
|
|
|
319
346
|
});
|
|
320
347
|
} else {
|
|
321
348
|
// Scalar top-level field — goes into "General" section
|
|
322
|
-
const opts = schemaType.options as
|
|
349
|
+
const opts = schemaType.options as
|
|
350
|
+
| {
|
|
351
|
+
default?: unknown;
|
|
352
|
+
description?: string;
|
|
353
|
+
enum?: string[];
|
|
354
|
+
required?: boolean;
|
|
355
|
+
secret?: boolean;
|
|
356
|
+
}
|
|
357
|
+
| undefined;
|
|
323
358
|
const fullPath = pathName;
|
|
324
359
|
|
|
325
360
|
generalFields[pathName] = {
|
|
@@ -349,7 +384,7 @@ export class ConfigurationApp implements TerrenoPlugin {
|
|
|
349
384
|
return {sections};
|
|
350
385
|
}
|
|
351
386
|
|
|
352
|
-
private mongooseTypeToString(schemaType:
|
|
387
|
+
private mongooseTypeToString(schemaType: {instance?: string}): string {
|
|
353
388
|
const instance = schemaType.instance?.toLowerCase();
|
|
354
389
|
if (instance === "objectid") {
|
|
355
390
|
return "string";
|
package/src/consentApp.test.ts
CHANGED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// biome-ignore-all lint/suspicious/noExplicitAny: test model typing
|
|
2
|
+
import {afterEach, beforeEach, describe, expect, it} from "bun:test";
|
|
3
|
+
import mongoose, {Schema} from "mongoose";
|
|
4
|
+
|
|
5
|
+
import {Config} from "./config";
|
|
6
|
+
import {envConfigurationPlugin} from "./envConfigurationPlugin";
|
|
7
|
+
|
|
8
|
+
interface EnvDocShape {
|
|
9
|
+
env: Map<string, string>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const testSchema = new Schema<EnvDocShape>({}, {strict: "throw"});
|
|
13
|
+
testSchema.plugin(envConfigurationPlugin);
|
|
14
|
+
|
|
15
|
+
const TestEnvConfig =
|
|
16
|
+
(mongoose.models.TestEnvConfig as mongoose.Model<EnvDocShape>) ??
|
|
17
|
+
mongoose.model<EnvDocShape>("TestEnvConfig", testSchema);
|
|
18
|
+
|
|
19
|
+
const setupLoader = (): void => {
|
|
20
|
+
Config.setEnvLoader(async () => {
|
|
21
|
+
const doc = (await TestEnvConfig.findOne({}).lean()) as {
|
|
22
|
+
env?: Map<string, string> | Record<string, string>;
|
|
23
|
+
} | null;
|
|
24
|
+
if (!doc?.env) {
|
|
25
|
+
return {};
|
|
26
|
+
}
|
|
27
|
+
if (doc.env instanceof Map) {
|
|
28
|
+
const out: Record<string, string> = {};
|
|
29
|
+
for (const [k, v] of doc.env) {
|
|
30
|
+
out[k] = v;
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
return {...doc.env};
|
|
35
|
+
});
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
describe("envConfigurationPlugin", () => {
|
|
39
|
+
beforeEach(async () => {
|
|
40
|
+
Config.clearRegistryForTesting();
|
|
41
|
+
Config.clearOverrides();
|
|
42
|
+
Config.setCachedEnv(null);
|
|
43
|
+
Config.setEnvLoader(null);
|
|
44
|
+
Reflect.deleteProperty(process.env, "TERRENO_PLUGIN_KEY");
|
|
45
|
+
|
|
46
|
+
Config.register("TERRENO_PLUGIN_KEY", {default: "fallback"});
|
|
47
|
+
|
|
48
|
+
await mongoose.connection.db?.collection("testenvconfigs").deleteMany({});
|
|
49
|
+
setupLoader();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterEach(async () => {
|
|
53
|
+
Config.clearRegistryForTesting();
|
|
54
|
+
Config.clearOverrides();
|
|
55
|
+
Config.setCachedEnv(null);
|
|
56
|
+
Config.setEnvLoader(null);
|
|
57
|
+
Reflect.deleteProperty(process.env, "TERRENO_PLUGIN_KEY");
|
|
58
|
+
await mongoose.connection.db?.collection("testenvconfigs").deleteMany({});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("adds an env Map field to the schema", () => {
|
|
62
|
+
const doc = new TestEnvConfig();
|
|
63
|
+
expect(doc.env).toBeInstanceOf(Map);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("Config.refresh() loads values from the document", async () => {
|
|
67
|
+
const doc = new TestEnvConfig();
|
|
68
|
+
doc.env.set("TERRENO_PLUGIN_KEY", "fromDoc");
|
|
69
|
+
await doc.save();
|
|
70
|
+
|
|
71
|
+
Config.setCachedEnv(null);
|
|
72
|
+
await Config.refresh();
|
|
73
|
+
|
|
74
|
+
expect(Config.get("TERRENO_PLUGIN_KEY")).toBe("fromDoc");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("post-save hook refreshes the cache automatically", async () => {
|
|
78
|
+
const doc = new TestEnvConfig();
|
|
79
|
+
doc.env.set("TERRENO_PLUGIN_KEY", "first");
|
|
80
|
+
await doc.save();
|
|
81
|
+
expect(Config.get("TERRENO_PLUGIN_KEY")).toBe("first");
|
|
82
|
+
|
|
83
|
+
doc.env.set("TERRENO_PLUGIN_KEY", "second");
|
|
84
|
+
await doc.save();
|
|
85
|
+
expect(Config.get("TERRENO_PLUGIN_KEY")).toBe("second");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("post-findOneAndUpdate hook refreshes the cache", async () => {
|
|
89
|
+
const doc = new TestEnvConfig();
|
|
90
|
+
doc.env.set("TERRENO_PLUGIN_KEY", "initial");
|
|
91
|
+
await doc.save();
|
|
92
|
+
|
|
93
|
+
await TestEnvConfig.findOneAndUpdate(
|
|
94
|
+
{_id: doc._id},
|
|
95
|
+
{env: new Map([["TERRENO_PLUGIN_KEY", "updated"]])}
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
expect(Config.get("TERRENO_PLUGIN_KEY")).toBe("updated");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("post-updateOne hook refreshes the cache", async () => {
|
|
102
|
+
const doc = new TestEnvConfig();
|
|
103
|
+
doc.env.set("TERRENO_PLUGIN_KEY", "initial");
|
|
104
|
+
await doc.save();
|
|
105
|
+
|
|
106
|
+
await TestEnvConfig.updateOne(
|
|
107
|
+
{_id: doc._id},
|
|
108
|
+
{env: new Map([["TERRENO_PLUGIN_KEY", "updatedViaUpdateOne"]])}
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
expect(Config.get("TERRENO_PLUGIN_KEY")).toBe("updatedViaUpdateOne");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("empty-string env values fall through to process.env", async () => {
|
|
115
|
+
process.env.TERRENO_PLUGIN_KEY = "fromEnv";
|
|
116
|
+
const doc = new TestEnvConfig();
|
|
117
|
+
doc.env.set("TERRENO_PLUGIN_KEY", "");
|
|
118
|
+
await doc.save();
|
|
119
|
+
|
|
120
|
+
expect(Config.get("TERRENO_PLUGIN_KEY")).toBe("fromEnv");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("missing document yields registered defaults", async () => {
|
|
124
|
+
await Config.refresh();
|
|
125
|
+
expect(Config.get("TERRENO_PLUGIN_KEY")).toBe("fallback");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("refreshFromDoc handles null document via Mongoose hook when collection is empty", async () => {
|
|
129
|
+
// Ensure collection is empty — no documents to find
|
|
130
|
+
await mongoose.connection.db?.collection("testenvconfigs").deleteMany({});
|
|
131
|
+
|
|
132
|
+
// Override the cache so we can verify it gets cleared by the hook
|
|
133
|
+
Config.setCachedEnv({TERRENO_PLUGIN_KEY: "stale"});
|
|
134
|
+
expect(Config.get("TERRENO_PLUGIN_KEY")).toBe("stale");
|
|
135
|
+
|
|
136
|
+
// Trigger findOneAndUpdate hook on a non-existent doc — refreshFromDoc
|
|
137
|
+
// calls findOneOrNone which returns null, so mapToObject(undefined) runs
|
|
138
|
+
await TestEnvConfig.findOneAndUpdate({_id: new mongoose.Types.ObjectId()}, {$set: {__v: 1}});
|
|
139
|
+
|
|
140
|
+
// mapToObject(undefined) returns {}, so Config falls back to the registered default
|
|
141
|
+
expect(Config.get("TERRENO_PLUGIN_KEY")).toBe("fallback");
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type {Document, Model, Schema} from "mongoose";
|
|
2
|
+
|
|
3
|
+
import {Config} from "./config";
|
|
4
|
+
import {logger} from "./logger";
|
|
5
|
+
import {findOneOrNoneFor} from "./plugins";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Adds an admin-editable `env: Map<string, string>` field to a Mongoose schema
|
|
9
|
+
* and keeps the global `Config` cache in sync with it.
|
|
10
|
+
*
|
|
11
|
+
* Companion to `Config` (config.ts). Apply alongside `configurationPlugin`
|
|
12
|
+
* when you want a singleton configuration document whose `env` map backs the
|
|
13
|
+
* runtime Config registry:
|
|
14
|
+
*
|
|
15
|
+
* ```typescript
|
|
16
|
+
* const schema = new Schema({...});
|
|
17
|
+
* schema.plugin(configurationPlugin);
|
|
18
|
+
* schema.plugin(envConfigurationPlugin);
|
|
19
|
+
*
|
|
20
|
+
* export const EnvConfig = mongoose.model("EnvConfig", schema);
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* Apps still call `Config.setEnvLoader(...)` once at startup to wire the
|
|
24
|
+
* model into `Config.refresh()` — typically:
|
|
25
|
+
*
|
|
26
|
+
* ```typescript
|
|
27
|
+
* import {findOneOrNoneFor} from "@terreno/api";
|
|
28
|
+
*
|
|
29
|
+
* Config.setEnvLoader(async () => {
|
|
30
|
+
* const doc = await findOneOrNoneFor(EnvConfig, {});
|
|
31
|
+
* return doc?.env ? Object.fromEntries(doc.env) : {};
|
|
32
|
+
* });
|
|
33
|
+
* await Config.refresh();
|
|
34
|
+
* ```
|
|
35
|
+
*
|
|
36
|
+
* After that, the post-save / post-update hooks installed here keep the
|
|
37
|
+
* cache fresh whenever the document changes, so callers reading
|
|
38
|
+
* `Config.get("KEY")` see admin edits immediately.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
interface EnvDoc extends Document {
|
|
42
|
+
env?: Map<string, string> | Record<string, string>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const mapToObject = (
|
|
46
|
+
env: Map<string, string> | Record<string, string> | undefined
|
|
47
|
+
): Record<string, string> => {
|
|
48
|
+
if (!env) {
|
|
49
|
+
return {};
|
|
50
|
+
}
|
|
51
|
+
if (env instanceof Map) {
|
|
52
|
+
const out: Record<string, string> = {};
|
|
53
|
+
for (const [k, v] of env) {
|
|
54
|
+
out[k] = v;
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
return {...env};
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const refreshFromDoc = async (Model: Model<unknown>): Promise<void> => {
|
|
62
|
+
try {
|
|
63
|
+
// biome-ignore lint/suspicious/noExplicitAny: doc shape determined by consumer schema
|
|
64
|
+
const doc = (await findOneOrNoneFor(Model as Model<any>, {})) as
|
|
65
|
+
| (Document & {env?: Map<string, string> | Record<string, string>})
|
|
66
|
+
| null;
|
|
67
|
+
Config.setCachedEnv(mapToObject(doc?.env));
|
|
68
|
+
} catch (error) {
|
|
69
|
+
logger.warn(
|
|
70
|
+
`envConfigurationPlugin: failed to refresh Config cache: ${(error as Error).message}`
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// biome-ignore lint/suspicious/noExplicitAny: Schema generics must be loose to accept arbitrary consumer schemas
|
|
76
|
+
export const envConfigurationPlugin = (schema: Schema<any, any, any, any>): void => {
|
|
77
|
+
schema.add({
|
|
78
|
+
env: {
|
|
79
|
+
default: () => new Map<string, string>(),
|
|
80
|
+
description:
|
|
81
|
+
"Admin-editable overrides for runtime configuration. Keys are env-var names " +
|
|
82
|
+
"(e.g. EXPO_ACCESS_TOKEN) and values are stored as strings. Overrides win " +
|
|
83
|
+
"over process.env at read time via the Config registry.",
|
|
84
|
+
of: String,
|
|
85
|
+
type: Map,
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
schema.post("save", async function (this: EnvDoc) {
|
|
90
|
+
await refreshFromDoc(this.constructor as Model<unknown>);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
schema.post("findOneAndUpdate", async function (this: {model: Model<unknown>}) {
|
|
94
|
+
await refreshFromDoc(this.model);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
schema.post("updateOne", async function (this: {model: Model<unknown>}) {
|
|
98
|
+
await refreshFromDoc(this.model);
|
|
99
|
+
});
|
|
100
|
+
};
|
package/src/errors.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {beforeEach, describe, expect, it, mock} from "bun:test";
|
|
2
2
|
import * as Sentry from "@sentry/bun";
|
|
3
3
|
import type {NextFunction, Request, Response} from "express";
|
|
4
|
-
import {Schema} from "mongoose";
|
|
4
|
+
import mongoose, {Schema} from "mongoose";
|
|
5
5
|
|
|
6
6
|
import {
|
|
7
7
|
APIError,
|
|
@@ -299,4 +299,22 @@ describe("apiErrorMiddleware", () => {
|
|
|
299
299
|
expect(next).toHaveBeenCalledWith(err);
|
|
300
300
|
expect(res.status).not.toHaveBeenCalled();
|
|
301
301
|
});
|
|
302
|
+
|
|
303
|
+
it("converts Mongoose CastError to a 400 APIError response", () => {
|
|
304
|
+
const err = new mongoose.Error.CastError("Number", "not-a-number", "general.maxUploadSizeMb");
|
|
305
|
+
apiErrorMiddleware(err, req, res as unknown as Response, next as unknown as NextFunction);
|
|
306
|
+
expect(res.status).toHaveBeenCalledWith(400);
|
|
307
|
+
expect(res.json).toHaveBeenCalledWith(
|
|
308
|
+
expect.objectContaining({
|
|
309
|
+
meta: expect.objectContaining({
|
|
310
|
+
fields: expect.objectContaining({
|
|
311
|
+
"general.maxUploadSizeMb": expect.stringContaining("Expected Number"),
|
|
312
|
+
}),
|
|
313
|
+
}),
|
|
314
|
+
status: 400,
|
|
315
|
+
title: "Validation failed",
|
|
316
|
+
})
|
|
317
|
+
);
|
|
318
|
+
expect(next).not.toHaveBeenCalled();
|
|
319
|
+
});
|
|
302
320
|
});
|