@terreno/api 0.13.2 → 0.14.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/dist/__tests__/versionCheckPlugin.test.js +53 -3
- package/dist/api.arrayOperations.test.js +1 -0
- package/dist/api.asyncHandler.test.d.ts +1 -0
- package/dist/api.asyncHandler.test.js +236 -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 +17 -14
- package/dist/betterAuthSetup.test.js +1 -0
- package/dist/config.d.ts +48 -0
- package/dist/config.js +248 -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 +106 -10
- package/dist/errors.test.js +16 -1
- package/dist/example.js +16 -7
- package/dist/expressServer.d.ts +10 -9
- package/dist/expressServer.js +62 -53
- package/dist/expressServer.test.js +53 -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 +65 -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 +720 -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 +2158 -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 +241 -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 +37 -3
- package/src/api.arrayOperations.test.ts +1 -0
- package/src/api.asyncHandler.test.ts +177 -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 +46 -19
- package/src/config.test.ts +255 -0
- package/src/config.ts +206 -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 +94 -20
- package/src/example.ts +46 -21
- package/src/express.d.ts +18 -1
- package/src/expressServer.test.ts +50 -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 +59 -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 +568 -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 +1755 -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 +196 -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
|
});
|
package/src/errors.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// https://jsonapi.org/format/#errors
|
|
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 {logger} from "./logger";
|
|
7
7
|
|
|
@@ -42,7 +42,7 @@ export interface APIErrorConstructor {
|
|
|
42
42
|
};
|
|
43
43
|
// A meta object containing non-standard meta-information about the error.
|
|
44
44
|
meta?: {[id: string]: string};
|
|
45
|
-
error?:
|
|
45
|
+
error?: unknown;
|
|
46
46
|
// If true, this error will not be sent to external error reporting tools like Sentry.
|
|
47
47
|
disableExternalErrorTracking?: boolean;
|
|
48
48
|
}
|
|
@@ -82,19 +82,17 @@ export class APIError extends Error {
|
|
|
82
82
|
}
|
|
83
83
|
| undefined;
|
|
84
84
|
|
|
85
|
-
meta: {[id: string]:
|
|
85
|
+
meta: {[id: string]: unknown} | undefined;
|
|
86
86
|
|
|
87
|
-
error?:
|
|
87
|
+
error?: unknown;
|
|
88
88
|
|
|
89
89
|
disableExternalErrorTracking?: boolean;
|
|
90
90
|
|
|
91
91
|
constructor(data: APIErrorConstructor) {
|
|
92
|
+
const errorStack =
|
|
93
|
+
data.error instanceof Error && data.error.stack ? `\n${data.error.stack}` : "";
|
|
92
94
|
// Include details in when the error is printed to the console or sent to Sentry.
|
|
93
|
-
super(
|
|
94
|
-
`${data.title}${data.detail ? `: ${data.detail}` : ""}${
|
|
95
|
-
data.error ? `\n${data.error.stack}` : ""
|
|
96
|
-
}`
|
|
97
|
-
);
|
|
95
|
+
super(`${data.title}${data.detail ? `: ${data.detail}` : ""}${errorStack}`);
|
|
98
96
|
this.name = "APIError";
|
|
99
97
|
|
|
100
98
|
let {title, id, links, status, code, detail, source, meta, fields, error} = data;
|
|
@@ -120,9 +118,9 @@ export class APIError extends Error {
|
|
|
120
118
|
this.meta.fields = fields;
|
|
121
119
|
}
|
|
122
120
|
this.error = error;
|
|
123
|
-
const
|
|
124
|
-
data.error
|
|
125
|
-
}`;
|
|
121
|
+
const dataErrorStack =
|
|
122
|
+
data.error instanceof Error && data.error.stack ? `\n${data.error.stack}` : "";
|
|
123
|
+
const logMessage = `APIError(${status}): ${title} ${detail ? detail : ""}${dataErrorStack}`;
|
|
126
124
|
if (data.disableExternalErrorTracking) {
|
|
127
125
|
logger.warn(logMessage);
|
|
128
126
|
} else {
|
|
@@ -162,8 +160,24 @@ export const errorsPlugin = (schema: Schema): void => {
|
|
|
162
160
|
schema.add({apiErrors: errorSchema});
|
|
163
161
|
};
|
|
164
162
|
|
|
165
|
-
export const isAPIError = (error:
|
|
166
|
-
return error.name === "APIError";
|
|
163
|
+
export const isAPIError = (error: unknown): error is APIError => {
|
|
164
|
+
return error instanceof Error && error.name === "APIError";
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/** Extract a human-readable message from an unknown error. */
|
|
168
|
+
export const errorMessage = (error: unknown): string => {
|
|
169
|
+
if (error instanceof Error) {
|
|
170
|
+
return error.message;
|
|
171
|
+
}
|
|
172
|
+
return String(error);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/** Extract a stack trace string from an unknown error. */
|
|
176
|
+
export const errorStack = (error: unknown): string => {
|
|
177
|
+
if (error instanceof Error && error.stack) {
|
|
178
|
+
return error.stack;
|
|
179
|
+
}
|
|
180
|
+
return String(error);
|
|
167
181
|
};
|
|
168
182
|
|
|
169
183
|
/**
|
|
@@ -186,8 +200,9 @@ export const getDisableExternalErrorTracking = (error: unknown): boolean | undef
|
|
|
186
200
|
// Creates an APIError body to send to clients as JSON. Errors don't have a toJSON defined,
|
|
187
201
|
// and we want to strip out things like message, name, and stack for the client.
|
|
188
202
|
// There is almost certainly a more elegant solution to this.
|
|
189
|
-
export const getAPIErrorBody = (error: APIError):
|
|
190
|
-
const errorData = {status: error.status, title: error.title};
|
|
203
|
+
export const getAPIErrorBody = (error: APIError): Record<string, unknown> => {
|
|
204
|
+
const errorData: Record<string, unknown> = {status: error.status, title: error.title};
|
|
205
|
+
const indexable = error as unknown as Record<string, unknown>;
|
|
191
206
|
for (const key of [
|
|
192
207
|
"id",
|
|
193
208
|
"links",
|
|
@@ -198,8 +213,8 @@ export const getAPIErrorBody = (error: APIError): {[id: string]: any} => {
|
|
|
198
213
|
"meta",
|
|
199
214
|
"disableExternalErrorTracking",
|
|
200
215
|
]) {
|
|
201
|
-
if (
|
|
202
|
-
errorData[key] =
|
|
216
|
+
if (indexable[key]) {
|
|
217
|
+
errorData[key] = indexable[key];
|
|
203
218
|
}
|
|
204
219
|
}
|
|
205
220
|
return errorData;
|
|
@@ -219,6 +234,40 @@ export const apiUnauthorizedMiddleware = (
|
|
|
219
234
|
}
|
|
220
235
|
};
|
|
221
236
|
|
|
237
|
+
/**
|
|
238
|
+
* Converts Mongoose validation/cast errors into client-friendly APIErrors.
|
|
239
|
+
*/
|
|
240
|
+
export const mongooseErrorToAPIError = (err: Error): APIError | null => {
|
|
241
|
+
if (err instanceof mongoose.Error.ValidationError) {
|
|
242
|
+
const fields: {[id: string]: string} = {};
|
|
243
|
+
for (const [path, subErr] of Object.entries(err.errors)) {
|
|
244
|
+
fields[path] = subErr.message;
|
|
245
|
+
}
|
|
246
|
+
return new APIError({
|
|
247
|
+
detail: err.message,
|
|
248
|
+
disableExternalErrorTracking: true,
|
|
249
|
+
fields,
|
|
250
|
+
status: 400,
|
|
251
|
+
title: "Validation failed",
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (err instanceof mongoose.Error.CastError) {
|
|
256
|
+
const path = err.path ?? "field";
|
|
257
|
+
return new APIError({
|
|
258
|
+
detail: `Invalid value for ${path}`,
|
|
259
|
+
disableExternalErrorTracking: true,
|
|
260
|
+
fields: {
|
|
261
|
+
[path]: `Expected ${err.kind ?? "a valid value"}, got ${JSON.stringify(err.value)}`,
|
|
262
|
+
},
|
|
263
|
+
status: 400,
|
|
264
|
+
title: "Validation failed",
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return null;
|
|
269
|
+
};
|
|
270
|
+
|
|
222
271
|
export const apiErrorMiddleware = (
|
|
223
272
|
err: Error,
|
|
224
273
|
_req: Request,
|
|
@@ -230,7 +279,32 @@ export const apiErrorMiddleware = (
|
|
|
230
279
|
Sentry.captureException(err);
|
|
231
280
|
}
|
|
232
281
|
res.status(err.status).json(getAPIErrorBody(err)).send();
|
|
233
|
-
|
|
234
|
-
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const mongooseError = mongooseErrorToAPIError(err);
|
|
286
|
+
if (mongooseError) {
|
|
287
|
+
res.status(mongooseError.status).json(getAPIErrorBody(mongooseError)).send();
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
next(err);
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Final Express error handler for unexpected errors. Always returns JSON so
|
|
296
|
+
* clients (e.g. RTK Query) can parse the response.
|
|
297
|
+
*/
|
|
298
|
+
export const apiFallthroughErrorMiddleware = (
|
|
299
|
+
err: Error,
|
|
300
|
+
_req: Request,
|
|
301
|
+
res: Response,
|
|
302
|
+
_next: NextFunction
|
|
303
|
+
): void => {
|
|
304
|
+
logger.error(`Fallthrough error: ${err}${err.stack ? `\n${err.stack}` : ""}`);
|
|
305
|
+
Sentry.captureException(err);
|
|
306
|
+
if (res.headersSent) {
|
|
307
|
+
return;
|
|
235
308
|
}
|
|
309
|
+
res.status(500).json({status: 500, title: "Internal server error"}).send();
|
|
236
310
|
};
|