@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.
Files changed (175) hide show
  1. package/dist/__tests__/versionCheckPlugin.test.js +53 -3
  2. package/dist/api.arrayOperations.test.js +1 -0
  3. package/dist/api.asyncHandler.test.d.ts +1 -0
  4. package/dist/api.asyncHandler.test.js +236 -0
  5. package/dist/api.d.ts +15 -4
  6. package/dist/api.errors.test.js +1 -0
  7. package/dist/api.hooks.test.js +1 -0
  8. package/dist/api.js +153 -104
  9. package/dist/api.query.test.js +1 -0
  10. package/dist/api.test.js +174 -0
  11. package/dist/auth.d.ts +10 -5
  12. package/dist/auth.js +163 -90
  13. package/dist/auth.test.js +159 -0
  14. package/dist/betterAuthApp.test.js +1 -0
  15. package/dist/betterAuthSetup.d.ts +5 -6
  16. package/dist/betterAuthSetup.js +17 -14
  17. package/dist/betterAuthSetup.test.js +1 -0
  18. package/dist/config.d.ts +48 -0
  19. package/dist/config.js +248 -0
  20. package/dist/config.test.d.ts +1 -0
  21. package/dist/config.test.js +328 -0
  22. package/dist/configuration.test.js +1 -0
  23. package/dist/configurationApp.d.ts +1 -1
  24. package/dist/configurationApp.js +17 -13
  25. package/dist/configurationPlugin.test.js +1 -0
  26. package/dist/consentApp.test.js +1 -0
  27. package/dist/envConfigurationPlugin.d.ts +2 -0
  28. package/dist/envConfigurationPlugin.js +173 -0
  29. package/dist/envConfigurationPlugin.test.d.ts +1 -0
  30. package/dist/envConfigurationPlugin.test.js +322 -0
  31. package/dist/errors.d.ts +18 -7
  32. package/dist/errors.js +106 -10
  33. package/dist/errors.test.js +16 -1
  34. package/dist/example.js +16 -7
  35. package/dist/expressServer.d.ts +10 -9
  36. package/dist/expressServer.js +62 -53
  37. package/dist/expressServer.test.js +53 -2
  38. package/dist/githubAuth.d.ts +2 -1
  39. package/dist/githubAuth.js +41 -26
  40. package/dist/githubAuth.test.js +1 -0
  41. package/dist/index.d.ts +4 -0
  42. package/dist/index.js +4 -0
  43. package/dist/logger.d.ts +1 -1
  44. package/dist/logger.js +42 -20
  45. package/dist/models/versionConfig.d.ts +2 -0
  46. package/dist/models/versionConfig.js +8 -0
  47. package/dist/notifiers/googleChatNotifier.js +14 -16
  48. package/dist/notifiers/googleChatNotifier.test.js +1 -0
  49. package/dist/notifiers/slackNotifier.js +16 -14
  50. package/dist/notifiers/slackNotifier.test.js +41 -3
  51. package/dist/notifiers/zoomNotifier.js +7 -10
  52. package/dist/notifiers/zoomNotifier.test.js +1 -0
  53. package/dist/openApi.d.ts +1 -1
  54. package/dist/openApi.test.js +1 -0
  55. package/dist/openApiBuilder.d.ts +39 -6
  56. package/dist/openApiBuilder.js +1 -31
  57. package/dist/openApiBuilder.test.js +1 -0
  58. package/dist/openApiValidator.js +1 -0
  59. package/dist/openApiValidator.test.js +65 -0
  60. package/dist/permissions.d.ts +4 -4
  61. package/dist/permissions.js +67 -65
  62. package/dist/permissions.middleware.test.js +1 -0
  63. package/dist/permissions.test.js +1 -0
  64. package/dist/plugins.d.ts +5 -5
  65. package/dist/plugins.js +18 -9
  66. package/dist/plugins.test.js +1 -1
  67. package/dist/populate.d.ts +15 -8
  68. package/dist/populate.js +23 -24
  69. package/dist/populate.test.js +1 -0
  70. package/dist/realtime/changeStreamWatcher.d.ts +73 -0
  71. package/dist/realtime/changeStreamWatcher.js +720 -0
  72. package/dist/realtime/index.d.ts +6 -0
  73. package/dist/realtime/index.js +27 -0
  74. package/dist/realtime/queryMatcher.d.ts +14 -0
  75. package/dist/realtime/queryMatcher.js +250 -0
  76. package/dist/realtime/queryStore.d.ts +37 -0
  77. package/dist/realtime/queryStore.js +195 -0
  78. package/dist/realtime/realtime.test.d.ts +10 -0
  79. package/dist/realtime/realtime.test.js +2158 -0
  80. package/dist/realtime/realtimeApp.d.ts +93 -0
  81. package/dist/realtime/realtimeApp.js +560 -0
  82. package/dist/realtime/registry.d.ts +40 -0
  83. package/dist/realtime/registry.js +38 -0
  84. package/dist/realtime/socketUser.d.ts +10 -0
  85. package/dist/realtime/socketUser.js +17 -0
  86. package/dist/realtime/types.d.ts +100 -0
  87. package/dist/realtime/types.js +2 -0
  88. package/dist/requestContext.d.ts +37 -0
  89. package/dist/requestContext.js +344 -0
  90. package/dist/requestContext.test.d.ts +1 -0
  91. package/dist/requestContext.test.js +241 -0
  92. package/dist/terrenoApp.d.ts +8 -0
  93. package/dist/terrenoApp.js +50 -13
  94. package/dist/terrenoApp.test.js +194 -21
  95. package/dist/terrenoPlugin.d.ts +11 -0
  96. package/dist/tests/bunSetup.js +1 -0
  97. package/dist/tests.js +1 -1
  98. package/dist/transformers.d.ts +2 -2
  99. package/dist/transformers.js +5 -3
  100. package/dist/transformers.test.js +90 -0
  101. package/dist/types/consentResponse.d.ts +6 -3
  102. package/dist/versionCheckPlugin.d.ts +2 -0
  103. package/dist/versionCheckPlugin.js +18 -12
  104. package/package.json +4 -2
  105. package/src/__tests__/versionCheckPlugin.test.ts +37 -3
  106. package/src/api.arrayOperations.test.ts +1 -0
  107. package/src/api.asyncHandler.test.ts +177 -0
  108. package/src/api.errors.test.ts +1 -0
  109. package/src/api.hooks.test.ts +1 -0
  110. package/src/api.query.test.ts +1 -0
  111. package/src/api.test.ts +132 -0
  112. package/src/api.ts +199 -84
  113. package/src/auth.test.ts +160 -0
  114. package/src/auth.ts +120 -50
  115. package/src/betterAuthApp.test.ts +1 -0
  116. package/src/betterAuthSetup.test.ts +1 -0
  117. package/src/betterAuthSetup.ts +46 -19
  118. package/src/config.test.ts +255 -0
  119. package/src/config.ts +206 -0
  120. package/src/configuration.test.ts +1 -0
  121. package/src/configurationApp.ts +59 -24
  122. package/src/configurationPlugin.test.ts +1 -0
  123. package/src/consentApp.test.ts +1 -0
  124. package/src/envConfigurationPlugin.test.ts +143 -0
  125. package/src/envConfigurationPlugin.ts +100 -0
  126. package/src/errors.test.ts +19 -1
  127. package/src/errors.ts +94 -20
  128. package/src/example.ts +46 -21
  129. package/src/express.d.ts +18 -1
  130. package/src/expressServer.test.ts +50 -2
  131. package/src/expressServer.ts +80 -50
  132. package/src/githubAuth.test.ts +1 -0
  133. package/src/githubAuth.ts +59 -38
  134. package/src/index.ts +4 -0
  135. package/src/logger.ts +47 -17
  136. package/src/models/versionConfig.ts +13 -2
  137. package/src/notifiers/googleChatNotifier.test.ts +1 -0
  138. package/src/notifiers/googleChatNotifier.ts +7 -9
  139. package/src/notifiers/slackNotifier.test.ts +29 -3
  140. package/src/notifiers/slackNotifier.ts +9 -7
  141. package/src/notifiers/zoomNotifier.test.ts +1 -0
  142. package/src/notifiers/zoomNotifier.ts +8 -11
  143. package/src/openApi.test.ts +1 -0
  144. package/src/openApi.ts +4 -4
  145. package/src/openApiBuilder.test.ts +1 -0
  146. package/src/openApiBuilder.ts +14 -11
  147. package/src/openApiValidator.test.ts +59 -0
  148. package/src/openApiValidator.ts +3 -2
  149. package/src/permissions.middleware.test.ts +1 -0
  150. package/src/permissions.test.ts +1 -0
  151. package/src/permissions.ts +30 -25
  152. package/src/plugins.test.ts +1 -1
  153. package/src/plugins.ts +21 -14
  154. package/src/populate.test.ts +1 -0
  155. package/src/populate.ts +44 -36
  156. package/src/realtime/changeStreamWatcher.ts +568 -0
  157. package/src/realtime/index.ts +34 -0
  158. package/src/realtime/queryMatcher.ts +179 -0
  159. package/src/realtime/queryStore.ts +132 -0
  160. package/src/realtime/realtime.test.ts +1755 -0
  161. package/src/realtime/realtimeApp.ts +478 -0
  162. package/src/realtime/registry.ts +64 -0
  163. package/src/realtime/socketUser.ts +25 -0
  164. package/src/realtime/types.ts +112 -0
  165. package/src/requestContext.test.ts +196 -0
  166. package/src/requestContext.ts +368 -0
  167. package/src/terrenoApp.test.ts +137 -11
  168. package/src/terrenoApp.ts +64 -17
  169. package/src/terrenoPlugin.ts +12 -0
  170. package/src/tests/bunSetup.ts +1 -0
  171. package/src/tests.ts +7 -2
  172. package/src/transformers.test.ts +70 -2
  173. package/src/transformers.ts +15 -7
  174. package/src/types/consentResponse.ts +8 -10
  175. package/src/versionCheckPlugin.ts +15 -7
@@ -17,7 +17,7 @@ const requireAdmin = (
17
17
  _res: express.Response,
18
18
  next: express.NextFunction
19
19
  ): void => {
20
- if (!(req as any).user?.admin) {
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?: any;
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, any>,
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 any;
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, any>,
125
+ obj: Record<string, unknown>,
116
126
  secretFields: SecretFieldMeta[]
117
- ): Record<string, any> => {
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: any = redacted;
131
+ let current: Record<string, unknown> | null = redacted;
122
132
  for (let i = 0; i < parts.length - 1; i++) {
123
- if (current[parts[i]] != null && typeof current[parts[i]] === "object") {
124
- current[parts[i]] = {...current[parts[i]]};
125
- current = current[parts[i]];
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[] = (ConfigModel as any).getSecretFields?.() ?? [];
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 (ConfigModel as any).getConfig();
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 (ConfigModel as any).updateConfig(safeBody);
233
- logger.info(`Configuration updated by ${(req as any).user?.email ?? "unknown"}`);
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 (ConfigModel as any).resolveSecrets();
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 (ConfigModel as any).updateConfig(updates);
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 any).schema as Schema | undefined;
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 any);
316
+ } as unknown as Model<unknown>);
290
317
 
291
318
  // Filter out system fields from the subschema too
292
- const filteredProperties: Record<string, any> = {};
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 any;
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 any;
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: any): string {
387
+ private mongooseTypeToString(schemaType: {instance?: string}): string {
353
388
  const instance = schemaType.instance?.toLowerCase();
354
389
  if (instance === "objectid") {
355
390
  return "string";
@@ -1,3 +1,4 @@
1
+ // biome-ignore-all lint/suspicious/noExplicitAny: test mock typing
1
2
  import {afterAll, beforeAll, beforeEach, describe, expect, it} from "bun:test";
2
3
  import mongoose, {model, Schema} from "mongoose";
3
4
  import type {SecretProvider} from "./configurationPlugin";
@@ -1,3 +1,4 @@
1
+ // biome-ignore-all lint/suspicious/noExplicitAny: test mock typing
1
2
  import {afterEach, beforeEach, describe, expect, it} from "bun:test";
2
3
  import type express from "express";
3
4
  import supertest from "supertest";
@@ -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
+ };
@@ -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?: 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]: any} | undefined;
85
+ meta: {[id: string]: unknown} | undefined;
86
86
 
87
- error?: 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 logMessage = `APIError(${status}): ${title} ${detail ? detail : ""}${
124
- data.error?.stack ? `\n${data.error?.stack}` : ""
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: Error): error is APIError => {
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): {[id: string]: any} => {
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 (error[key]) {
202
- errorData[key] = error[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
- } else {
234
- next(err);
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
  };