@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.
Files changed (172) hide show
  1. package/dist/__tests__/versionCheckPlugin.test.js +136 -3
  2. package/dist/api.arrayOperations.test.js +1 -0
  3. package/dist/api.d.ts +15 -4
  4. package/dist/api.errors.test.js +1 -0
  5. package/dist/api.hooks.test.js +1 -0
  6. package/dist/api.js +153 -104
  7. package/dist/api.query.test.js +1 -0
  8. package/dist/api.test.js +174 -0
  9. package/dist/auth.d.ts +10 -5
  10. package/dist/auth.js +163 -90
  11. package/dist/auth.test.js +159 -0
  12. package/dist/betterAuthApp.test.js +1 -0
  13. package/dist/betterAuthSetup.d.ts +5 -6
  14. package/dist/betterAuthSetup.js +30 -17
  15. package/dist/betterAuthSetup.test.js +1 -0
  16. package/dist/config.d.ts +48 -0
  17. package/dist/config.js +257 -0
  18. package/dist/config.test.d.ts +1 -0
  19. package/dist/config.test.js +328 -0
  20. package/dist/configuration.test.js +1 -0
  21. package/dist/configurationApp.d.ts +1 -1
  22. package/dist/configurationApp.js +17 -13
  23. package/dist/configurationPlugin.test.js +1 -0
  24. package/dist/consentApp.test.js +1 -0
  25. package/dist/envConfigurationPlugin.d.ts +2 -0
  26. package/dist/envConfigurationPlugin.js +173 -0
  27. package/dist/envConfigurationPlugin.test.d.ts +1 -0
  28. package/dist/envConfigurationPlugin.test.js +322 -0
  29. package/dist/errors.d.ts +18 -7
  30. package/dist/errors.js +111 -12
  31. package/dist/errors.test.js +16 -1
  32. package/dist/example.js +19 -7
  33. package/dist/expressServer.d.ts +10 -9
  34. package/dist/expressServer.js +62 -53
  35. package/dist/expressServer.test.js +165 -2
  36. package/dist/githubAuth.d.ts +2 -1
  37. package/dist/githubAuth.js +41 -26
  38. package/dist/githubAuth.test.js +1 -0
  39. package/dist/index.d.ts +4 -0
  40. package/dist/index.js +4 -0
  41. package/dist/logger.d.ts +1 -1
  42. package/dist/logger.js +42 -20
  43. package/dist/models/versionConfig.d.ts +2 -0
  44. package/dist/models/versionConfig.js +8 -0
  45. package/dist/notifiers/googleChatNotifier.js +14 -16
  46. package/dist/notifiers/googleChatNotifier.test.js +1 -0
  47. package/dist/notifiers/slackNotifier.js +16 -14
  48. package/dist/notifiers/slackNotifier.test.js +41 -3
  49. package/dist/notifiers/zoomNotifier.js +7 -10
  50. package/dist/notifiers/zoomNotifier.test.js +1 -0
  51. package/dist/openApi.d.ts +1 -1
  52. package/dist/openApi.test.js +1 -0
  53. package/dist/openApiBuilder.d.ts +39 -6
  54. package/dist/openApiBuilder.js +1 -31
  55. package/dist/openApiBuilder.test.js +1 -0
  56. package/dist/openApiValidator.js +1 -0
  57. package/dist/openApiValidator.test.js +1 -0
  58. package/dist/permissions.d.ts +4 -4
  59. package/dist/permissions.js +67 -65
  60. package/dist/permissions.middleware.test.js +1 -0
  61. package/dist/permissions.test.js +1 -0
  62. package/dist/plugins.d.ts +5 -5
  63. package/dist/plugins.js +18 -9
  64. package/dist/plugins.test.js +1 -1
  65. package/dist/populate.d.ts +15 -8
  66. package/dist/populate.js +23 -24
  67. package/dist/populate.test.js +1 -0
  68. package/dist/realtime/changeStreamWatcher.d.ts +73 -0
  69. package/dist/realtime/changeStreamWatcher.js +724 -0
  70. package/dist/realtime/index.d.ts +6 -0
  71. package/dist/realtime/index.js +27 -0
  72. package/dist/realtime/queryMatcher.d.ts +14 -0
  73. package/dist/realtime/queryMatcher.js +250 -0
  74. package/dist/realtime/queryStore.d.ts +37 -0
  75. package/dist/realtime/queryStore.js +195 -0
  76. package/dist/realtime/realtime.test.d.ts +10 -0
  77. package/dist/realtime/realtime.test.js +3066 -0
  78. package/dist/realtime/realtimeApp.d.ts +93 -0
  79. package/dist/realtime/realtimeApp.js +560 -0
  80. package/dist/realtime/registry.d.ts +40 -0
  81. package/dist/realtime/registry.js +38 -0
  82. package/dist/realtime/socketUser.d.ts +10 -0
  83. package/dist/realtime/socketUser.js +17 -0
  84. package/dist/realtime/types.d.ts +100 -0
  85. package/dist/realtime/types.js +2 -0
  86. package/dist/requestContext.d.ts +37 -0
  87. package/dist/requestContext.js +344 -0
  88. package/dist/requestContext.test.d.ts +1 -0
  89. package/dist/requestContext.test.js +384 -0
  90. package/dist/terrenoApp.d.ts +8 -0
  91. package/dist/terrenoApp.js +50 -13
  92. package/dist/terrenoApp.test.js +194 -21
  93. package/dist/terrenoPlugin.d.ts +11 -0
  94. package/dist/tests/bunSetup.js +1 -0
  95. package/dist/tests.js +1 -1
  96. package/dist/transformers.d.ts +2 -2
  97. package/dist/transformers.js +5 -3
  98. package/dist/transformers.test.js +90 -0
  99. package/dist/types/consentResponse.d.ts +6 -3
  100. package/dist/versionCheckPlugin.d.ts +2 -0
  101. package/dist/versionCheckPlugin.js +18 -12
  102. package/package.json +4 -2
  103. package/src/__tests__/versionCheckPlugin.test.ts +94 -3
  104. package/src/api.arrayOperations.test.ts +1 -0
  105. package/src/api.errors.test.ts +1 -0
  106. package/src/api.hooks.test.ts +1 -0
  107. package/src/api.query.test.ts +1 -0
  108. package/src/api.test.ts +132 -0
  109. package/src/api.ts +199 -84
  110. package/src/auth.test.ts +160 -0
  111. package/src/auth.ts +120 -50
  112. package/src/betterAuthApp.test.ts +1 -0
  113. package/src/betterAuthSetup.test.ts +1 -0
  114. package/src/betterAuthSetup.ts +59 -22
  115. package/src/config.test.ts +255 -0
  116. package/src/config.ts +216 -0
  117. package/src/configuration.test.ts +1 -0
  118. package/src/configurationApp.ts +59 -24
  119. package/src/configurationPlugin.test.ts +1 -0
  120. package/src/consentApp.test.ts +1 -0
  121. package/src/envConfigurationPlugin.test.ts +143 -0
  122. package/src/envConfigurationPlugin.ts +100 -0
  123. package/src/errors.test.ts +19 -1
  124. package/src/errors.ts +118 -38
  125. package/src/example.ts +49 -21
  126. package/src/express.d.ts +18 -1
  127. package/src/expressServer.test.ts +147 -2
  128. package/src/expressServer.ts +80 -50
  129. package/src/githubAuth.test.ts +1 -0
  130. package/src/githubAuth.ts +59 -38
  131. package/src/index.ts +4 -0
  132. package/src/logger.ts +47 -17
  133. package/src/models/versionConfig.ts +13 -2
  134. package/src/notifiers/googleChatNotifier.test.ts +1 -0
  135. package/src/notifiers/googleChatNotifier.ts +7 -9
  136. package/src/notifiers/slackNotifier.test.ts +29 -3
  137. package/src/notifiers/slackNotifier.ts +9 -7
  138. package/src/notifiers/zoomNotifier.test.ts +1 -0
  139. package/src/notifiers/zoomNotifier.ts +8 -11
  140. package/src/openApi.test.ts +1 -0
  141. package/src/openApi.ts +4 -4
  142. package/src/openApiBuilder.test.ts +1 -0
  143. package/src/openApiBuilder.ts +14 -11
  144. package/src/openApiValidator.test.ts +1 -0
  145. package/src/openApiValidator.ts +3 -2
  146. package/src/permissions.middleware.test.ts +1 -0
  147. package/src/permissions.test.ts +1 -0
  148. package/src/permissions.ts +30 -25
  149. package/src/plugins.test.ts +1 -1
  150. package/src/plugins.ts +21 -14
  151. package/src/populate.test.ts +1 -0
  152. package/src/populate.ts +44 -36
  153. package/src/realtime/changeStreamWatcher.ts +572 -0
  154. package/src/realtime/index.ts +34 -0
  155. package/src/realtime/queryMatcher.ts +179 -0
  156. package/src/realtime/queryStore.ts +132 -0
  157. package/src/realtime/realtime.test.ts +2465 -0
  158. package/src/realtime/realtimeApp.ts +478 -0
  159. package/src/realtime/registry.ts +64 -0
  160. package/src/realtime/socketUser.ts +25 -0
  161. package/src/realtime/types.ts +112 -0
  162. package/src/requestContext.test.ts +321 -0
  163. package/src/requestContext.ts +368 -0
  164. package/src/terrenoApp.test.ts +137 -11
  165. package/src/terrenoApp.ts +64 -17
  166. package/src/terrenoPlugin.ts +12 -0
  167. package/src/tests/bunSetup.ts +1 -0
  168. package/src/tests.ts +7 -2
  169. package/src/transformers.test.ts +70 -2
  170. package/src/transformers.ts +15 -7
  171. package/src/types/consentResponse.ts +8 -10
  172. package/src/versionCheckPlugin.ts +15 -7
@@ -12,8 +12,10 @@ import type {Application, NextFunction, Request, Response} from "express";
12
12
  import mongoose from "mongoose";
13
13
  import type {UserModel} from "./auth";
14
14
  import type {BetterAuthConfig, BetterAuthSessionData, BetterAuthUser} from "./betterAuth";
15
+ import {APIError} from "./errors";
15
16
  import {logger} from "./logger";
16
17
  import {findOneOrNoneFor} from "./plugins";
18
+ import {updateRequestContextFromRequest} from "./requestContext";
17
19
 
18
20
  /**
19
21
  * The Better Auth instance type.
@@ -23,9 +25,15 @@ export type BetterAuthInstance = ReturnType<typeof betterAuth>;
23
25
  /**
24
26
  * Options for creating a Better Auth instance.
25
27
  */
28
+ // Minimal shape we use from the MongoDB native client returned by mongoose connection
29
+ export interface MongoClientLike {
30
+ // biome-ignore lint/suspicious/noExplicitAny: the MongoDB driver Db type is opaque to this layer; it is passed straight to better-auth's adapter
31
+ db: () => any;
32
+ }
33
+
26
34
  export interface CreateBetterAuthOptions {
27
35
  config: BetterAuthConfig;
28
- mongoClient: any;
36
+ mongoClient: MongoClientLike;
29
37
  userModel?: UserModel;
30
38
  }
31
39
 
@@ -37,12 +45,18 @@ export const createBetterAuth = (options: CreateBetterAuthOptions): BetterAuthIn
37
45
 
38
46
  const secret = config.secret || process.env.BETTER_AUTH_SECRET;
39
47
  if (!secret) {
40
- throw new Error("BETTER_AUTH_SECRET must be set in env or config.secret must be provided.");
48
+ throw new APIError({
49
+ status: 500,
50
+ title: "BETTER_AUTH_SECRET must be set in env or config.secret must be provided.",
51
+ });
41
52
  }
42
53
 
43
54
  const baseURL = config.baseURL || process.env.BETTER_AUTH_URL;
44
55
  if (!baseURL) {
45
- throw new Error("BETTER_AUTH_URL must be set in env or config.baseURL must be provided.");
56
+ throw new APIError({
57
+ status: 500,
58
+ title: "BETTER_AUTH_URL must be set in env or config.baseURL must be provided.",
59
+ });
46
60
  }
47
61
 
48
62
  const basePath = config.basePath ?? "/api/auth";
@@ -88,7 +102,7 @@ export const createBetterAuth = (options: CreateBetterAuthOptions): BetterAuthIn
88
102
  trustedOrigins: config.trustedOrigins ?? [],
89
103
  });
90
104
 
91
- return auth as any;
105
+ return auth as BetterAuthInstance;
92
106
  };
93
107
 
94
108
  /**
@@ -108,31 +122,38 @@ export const createBetterAuthSessionMiddleware = (
108
122
  if (session?.user && session?.session) {
109
123
  const betterAuthUser = session.user as BetterAuthUser;
110
124
 
125
+ const reqWithSession = req as Request & {
126
+ user?: Request["user"];
127
+ betterAuthSession?: BetterAuthSessionData;
128
+ };
111
129
  if (userModel) {
112
130
  // Look up the application user by betterAuthId
113
131
  const appUser = await findOneOrNoneFor(userModel, {
114
132
  betterAuthId: betterAuthUser.id,
115
133
  });
116
134
  if (appUser) {
117
- (req as any).user = appUser;
118
- (req as any).betterAuthSession = session;
135
+ reqWithSession.user = appUser as unknown as Request["user"];
136
+ reqWithSession.betterAuthSession = session as unknown as BetterAuthSessionData;
137
+ updateRequestContextFromRequest(req);
119
138
  } else {
120
139
  // User exists in Better Auth but not synced yet - create them
121
140
  const newUser = await syncBetterAuthUser(userModel, betterAuthUser);
122
- (req as any).user = newUser;
123
- (req as any).betterAuthSession = session;
141
+ reqWithSession.user = newUser as unknown as Request["user"];
142
+ reqWithSession.betterAuthSession = session as unknown as BetterAuthSessionData;
143
+ updateRequestContextFromRequest(req);
124
144
  }
125
145
  } else {
126
146
  // No user model - just attach the Better Auth user directly
127
- (req as any).user = {
147
+ reqWithSession.user = {
128
148
  _id: betterAuthUser.id,
129
149
  admin: false,
130
150
  betterAuthId: betterAuthUser.id,
131
151
  email: betterAuthUser.email,
132
152
  id: betterAuthUser.id,
133
153
  name: betterAuthUser.name,
134
- };
135
- (req as any).betterAuthSession = session;
154
+ } as unknown as Request["user"];
155
+ reqWithSession.betterAuthSession = session as unknown as BetterAuthSessionData;
156
+ updateRequestContextFromRequest(req);
136
157
  }
137
158
  }
138
159
 
@@ -148,15 +169,27 @@ export const createBetterAuthSessionMiddleware = (
148
169
  * Syncs a Better Auth user to the application User model.
149
170
  * Creates or updates the user as needed.
150
171
  */
172
+ // Loose shape used when mutating Mongoose user documents during Better Auth sync.
173
+ // The fields are added by the consumer's user schema (via baseUserPlugin or similar).
174
+ interface MutableUserDoc {
175
+ email?: string;
176
+ name?: string;
177
+ betterAuthId?: string;
178
+ oauthProvider?: string | null;
179
+ id?: string;
180
+ save: () => Promise<unknown>;
181
+ }
182
+
151
183
  export const syncBetterAuthUser = async (
152
184
  userModel: UserModel,
153
185
  betterAuthUser: BetterAuthUser,
154
186
  oauthProvider?: string
187
+ // biome-ignore lint/suspicious/noExplicitAny: return is a consumer-defined user document; tests inspect varied fields
155
188
  ): Promise<any> => {
156
189
  try {
157
- const existingUser: any = await findOneOrNoneFor(userModel, {
190
+ const existingUser = (await findOneOrNoneFor(userModel, {
158
191
  betterAuthId: betterAuthUser.id,
159
- });
192
+ })) as unknown as MutableUserDoc | null;
160
193
 
161
194
  if (existingUser) {
162
195
  // Update existing user if needed
@@ -169,9 +202,9 @@ export const syncBetterAuthUser = async (
169
202
  }
170
203
 
171
204
  // Check if user exists by email (migration case)
172
- const userByEmail: any = await findOneOrNoneFor(userModel, {
205
+ const userByEmail = (await findOneOrNoneFor(userModel, {
173
206
  email: betterAuthUser.email,
174
- });
207
+ })) as unknown as MutableUserDoc | null;
175
208
  if (userByEmail) {
176
209
  // Link existing user to Better Auth
177
210
  userByEmail.betterAuthId = betterAuthUser.id;
@@ -184,14 +217,15 @@ export const syncBetterAuthUser = async (
184
217
 
185
218
  // Use Better Auth ID as _id when it's a valid ObjectId (MongoDB adapter) so frontend IDs match
186
219
  const useAsId = mongoose.isValidObjectId(betterAuthUser.id) ? {_id: betterAuthUser.id} : {};
187
- const newUser: any = new (userModel as any)({
220
+ // biome-ignore lint/suspicious/noExplicitAny: userModel is generic across consumers — constructor args are runtime-validated
221
+ const newUser = new (userModel as any)({
188
222
  ...useAsId,
189
223
  admin: false,
190
224
  betterAuthId: betterAuthUser.id,
191
225
  email: betterAuthUser.email,
192
226
  name: betterAuthUser.name || betterAuthUser.email.split("@")[0],
193
227
  oauthProvider: oauthProvider || null,
194
- });
228
+ }) as MutableUserDoc;
195
229
  await newUser.save();
196
230
  logger.info(`Created new user from Better Auth: ${newUser.id}`);
197
231
  return newUser;
@@ -222,11 +256,14 @@ export const mountBetterAuthRoutes = (
222
256
  /**
223
257
  * Gets the MongoDB client from the mongoose connection.
224
258
  */
225
- export const getMongoClientFromMongoose = (): any => {
259
+ export const getMongoClientFromMongoose = (): MongoClientLike => {
226
260
  const connection = mongoose.connection;
227
- const client = (connection as any).client;
261
+ const client = (connection as unknown as {client?: MongoClientLike}).client;
228
262
  if (!client) {
229
- throw new Error("Mongoose is not connected. Ensure MongoDB connection is established first.");
263
+ throw new APIError({
264
+ status: 500,
265
+ title: "Mongoose is not connected. Ensure MongoDB connection is established first.",
266
+ });
230
267
  }
231
268
  return client;
232
269
  };
@@ -249,12 +286,12 @@ export const setupBetterAuthUserSync = (_auth: BetterAuthInstance, _userModel: U
249
286
  * Extracts Better Auth session data from the request.
250
287
  */
251
288
  export const getBetterAuthSession = (req: Request): BetterAuthSessionData | null => {
252
- return (req as any).betterAuthSession ?? null;
289
+ return (req as Request & {betterAuthSession?: BetterAuthSessionData}).betterAuthSession ?? null;
253
290
  };
254
291
 
255
292
  /**
256
293
  * Checks if the request has a valid Better Auth session.
257
294
  */
258
295
  export const hasBetterAuthSession = (req: Request): boolean => {
259
- return Boolean((req as any).betterAuthSession);
296
+ return Boolean((req as Request & {betterAuthSession?: BetterAuthSessionData}).betterAuthSession);
260
297
  };
@@ -0,0 +1,255 @@
1
+ import {afterEach, beforeEach, describe, expect, it} from "bun:test";
2
+
3
+ import {Config} from "./config";
4
+
5
+ const KEYS = [
6
+ "TERRENO_CFG_STRING",
7
+ "TERRENO_CFG_DEFAULTED",
8
+ "TERRENO_CFG_NUM",
9
+ "TERRENO_CFG_BOOL",
10
+ "TERRENO_CFG_JSON",
11
+ "TERRENO_CFG_UNDEFAULTED",
12
+ "TERRENO_CFG_UNREGISTERED",
13
+ ];
14
+
15
+ const resetEnv = (): void => {
16
+ for (const key of KEYS) {
17
+ Reflect.deleteProperty(process.env, key);
18
+ }
19
+ };
20
+
21
+ describe("Config", () => {
22
+ beforeEach(() => {
23
+ Config.clearRegistryForTesting();
24
+ Config.clearOverrides();
25
+ Config.setCachedEnv(null);
26
+ Config.setEnvLoader(null);
27
+ resetEnv();
28
+
29
+ Config.register("TERRENO_CFG_STRING");
30
+ Config.register("TERRENO_CFG_DEFAULTED", {default: "fallback"});
31
+ Config.register("TERRENO_CFG_NUM", {default: "1000"});
32
+ Config.register("TERRENO_CFG_BOOL", {default: "false"});
33
+ Config.register("TERRENO_CFG_JSON", {default: "{}"});
34
+ Config.register("TERRENO_CFG_UNDEFAULTED");
35
+ });
36
+
37
+ afterEach(() => {
38
+ resetEnv();
39
+ Config.clearRegistryForTesting();
40
+ Config.clearOverrides();
41
+ Config.setCachedEnv(null);
42
+ Config.setEnvLoader(null);
43
+ });
44
+
45
+ describe("resolution order", () => {
46
+ it("returns the registered default when nothing is set", () => {
47
+ expect(Config.get("TERRENO_CFG_DEFAULTED")).toBe("fallback");
48
+ });
49
+
50
+ it("returns process.env when set and no cache/override is present", () => {
51
+ process.env.TERRENO_CFG_STRING = "fromEnv";
52
+ expect(Config.get("TERRENO_CFG_STRING")).toBe("fromEnv");
53
+ });
54
+
55
+ it("cache wins over process.env", () => {
56
+ process.env.TERRENO_CFG_STRING = "fromEnv";
57
+ Config.setCachedEnv({TERRENO_CFG_STRING: "fromCache"});
58
+ expect(Config.get("TERRENO_CFG_STRING")).toBe("fromCache");
59
+ });
60
+
61
+ it("override wins over cache and process.env", () => {
62
+ process.env.TERRENO_CFG_STRING = "fromEnv";
63
+ Config.setCachedEnv({TERRENO_CFG_STRING: "fromCache"});
64
+ Config.setOverride("TERRENO_CFG_STRING", "fromOverride");
65
+ expect(Config.get("TERRENO_CFG_STRING")).toBe("fromOverride");
66
+ });
67
+
68
+ it("falls through empty-string cache values to process.env", () => {
69
+ process.env.TERRENO_CFG_STRING = "fromEnv";
70
+ Config.setCachedEnv({TERRENO_CFG_STRING: ""});
71
+ expect(Config.get("TERRENO_CFG_STRING")).toBe("fromEnv");
72
+ });
73
+
74
+ it("falls through empty-string process.env to default", () => {
75
+ process.env.TERRENO_CFG_DEFAULTED = "";
76
+ expect(Config.get("TERRENO_CFG_DEFAULTED")).toBe("fallback");
77
+ });
78
+
79
+ it("returns undefined for an unregistered key with nothing set", () => {
80
+ expect(Config.get("TERRENO_CFG_UNREGISTERED")).toBeUndefined();
81
+ });
82
+
83
+ it("returns process.env for an unregistered key when set", () => {
84
+ process.env.TERRENO_CFG_UNREGISTERED = "value";
85
+ expect(Config.get("TERRENO_CFG_UNREGISTERED")).toBe("value");
86
+ });
87
+
88
+ it("setOverride(key, undefined) is treated as 'force unset'", () => {
89
+ process.env.TERRENO_CFG_STRING = "fromEnv";
90
+ Config.setOverride("TERRENO_CFG_STRING", undefined);
91
+ expect(Config.get("TERRENO_CFG_STRING")).toBeUndefined();
92
+ });
93
+ });
94
+
95
+ describe("getNumber", () => {
96
+ it("parses numeric process.env values", () => {
97
+ process.env.TERRENO_CFG_NUM = "5000";
98
+ expect(Config.getNumber("TERRENO_CFG_NUM")).toBe(5000);
99
+ });
100
+
101
+ it("parses the registered default", () => {
102
+ expect(Config.getNumber("TERRENO_CFG_NUM")).toBe(1000);
103
+ });
104
+
105
+ it("returns undefined when no value is available", () => {
106
+ expect(Config.getNumber("TERRENO_CFG_UNDEFAULTED")).toBeUndefined();
107
+ });
108
+
109
+ it("throws on non-numeric values", () => {
110
+ process.env.TERRENO_CFG_NUM = "not-a-number";
111
+ expect(() => Config.getNumber("TERRENO_CFG_NUM")).toThrow(/not a valid number/);
112
+ });
113
+
114
+ it("throws on partially-numeric strings like '5000ms'", () => {
115
+ process.env.TERRENO_CFG_NUM = "5000ms";
116
+ expect(() => Config.getNumber("TERRENO_CFG_NUM")).toThrow(/not a valid number/);
117
+ });
118
+
119
+ it("supports floats", () => {
120
+ process.env.TERRENO_CFG_NUM = "3.14";
121
+ expect(Config.getNumber("TERRENO_CFG_NUM")).toBe(3.14);
122
+ });
123
+ });
124
+
125
+ describe("getBoolean", () => {
126
+ it("returns true for 'true'", () => {
127
+ process.env.TERRENO_CFG_BOOL = "true";
128
+ expect(Config.getBoolean("TERRENO_CFG_BOOL")).toBe(true);
129
+ });
130
+
131
+ it("returns true for 'TRUE' (case-insensitive)", () => {
132
+ process.env.TERRENO_CFG_BOOL = "TRUE";
133
+ expect(Config.getBoolean("TERRENO_CFG_BOOL")).toBe(true);
134
+ });
135
+
136
+ it("returns false for 'false'", () => {
137
+ process.env.TERRENO_CFG_BOOL = "false";
138
+ expect(Config.getBoolean("TERRENO_CFG_BOOL")).toBe(false);
139
+ });
140
+
141
+ it("returns false when unset and default is 'false'", () => {
142
+ expect(Config.getBoolean("TERRENO_CFG_BOOL")).toBe(false);
143
+ });
144
+
145
+ it("returns false for any non-true string", () => {
146
+ process.env.TERRENO_CFG_BOOL = "yes";
147
+ expect(Config.getBoolean("TERRENO_CFG_BOOL")).toBe(false);
148
+ });
149
+ });
150
+
151
+ describe("getJSON", () => {
152
+ it("parses valid JSON", () => {
153
+ process.env.TERRENO_CFG_JSON = JSON.stringify({hook: "https://example.com"});
154
+ expect(Config.getJSON<Record<string, string>>("TERRENO_CFG_JSON")).toEqual({
155
+ hook: "https://example.com",
156
+ });
157
+ });
158
+
159
+ it("parses the registered default", () => {
160
+ expect(Config.getJSON<Record<string, unknown>>("TERRENO_CFG_JSON")).toEqual({});
161
+ });
162
+
163
+ it("returns undefined when nothing is set and no default exists", () => {
164
+ expect(Config.getJSON("TERRENO_CFG_UNDEFAULTED")).toBeUndefined();
165
+ });
166
+
167
+ it("throws on malformed JSON instead of silently returning undefined", () => {
168
+ process.env.TERRENO_CFG_JSON = "{not json";
169
+ expect(() => Config.getJSON("TERRENO_CFG_JSON")).toThrow(/not valid JSON/);
170
+ });
171
+ });
172
+
173
+ describe("registry", () => {
174
+ it("exposes registered keys in sorted order", () => {
175
+ const keys = Config.getRegisteredKeys();
176
+ const sorted = [...keys].sort();
177
+ expect(keys).toEqual(sorted);
178
+ });
179
+
180
+ it("isRegistered returns true for known keys", () => {
181
+ expect(Config.isRegistered("TERRENO_CFG_STRING")).toBe(true);
182
+ });
183
+
184
+ it("isRegistered returns false for unknown keys", () => {
185
+ expect(Config.isRegistered("NOT_A_REAL_KEY")).toBe(false);
186
+ });
187
+
188
+ it("getDefault returns the registered default", () => {
189
+ expect(Config.getDefault("TERRENO_CFG_DEFAULTED")).toBe("fallback");
190
+ });
191
+
192
+ it("getDefault returns undefined for keys with no default", () => {
193
+ expect(Config.getDefault("TERRENO_CFG_STRING")).toBeUndefined();
194
+ });
195
+
196
+ it("getRegistration exposes secret + description metadata", () => {
197
+ Config.register("TERRENO_CFG_WITH_META", {
198
+ description: "A secret thing",
199
+ secret: true,
200
+ });
201
+ const meta = Config.getRegistration("TERRENO_CFG_WITH_META");
202
+ expect(meta?.secret).toBe(true);
203
+ expect(meta?.description).toBe("A secret thing");
204
+ });
205
+
206
+ it("re-registering the same key throws", () => {
207
+ expect(() => Config.register("TERRENO_CFG_STRING")).toThrow(/registered more than once/);
208
+ });
209
+
210
+ it("does not treat Object prototype keys as registered", () => {
211
+ expect(Config.isRegistered("constructor")).toBe(false);
212
+ expect(Config.isRegistered("toString")).toBe(false);
213
+ expect(Config.isRegistered("hasOwnProperty")).toBe(false);
214
+ // And re-registering one of these names should still succeed.
215
+ expect(() => Config.register("toString", {default: "x"})).not.toThrow();
216
+ expect(Config.get("toString")).toBe("x");
217
+ });
218
+ });
219
+
220
+ describe("clearOverrides", () => {
221
+ it("removes all overrides", () => {
222
+ Config.setOverride("TERRENO_CFG_STRING", "x");
223
+ Config.setOverride("TERRENO_CFG_DEFAULTED", "y");
224
+ Config.clearOverrides();
225
+ expect(Config.get("TERRENO_CFG_STRING")).toBeUndefined();
226
+ expect(Config.get("TERRENO_CFG_DEFAULTED")).toBe("fallback");
227
+ });
228
+ });
229
+
230
+ describe("refresh", () => {
231
+ it("loads values via the registered env loader", async () => {
232
+ Config.setEnvLoader(async () => ({TERRENO_CFG_STRING: "fromLoader"}));
233
+ await Config.refresh();
234
+ expect(Config.get("TERRENO_CFG_STRING")).toBe("fromLoader");
235
+ });
236
+
237
+ it("clears the cache when no loader is registered", async () => {
238
+ Config.setCachedEnv({TERRENO_CFG_STRING: "stale"});
239
+ Config.setEnvLoader(null);
240
+ await Config.refresh();
241
+ expect(Config.get("TERRENO_CFG_STRING")).toBeUndefined();
242
+ });
243
+
244
+ it("updates the cache on each refresh", async () => {
245
+ let payload: Record<string, string> = {TERRENO_CFG_STRING: "first"};
246
+ Config.setEnvLoader(async () => payload);
247
+ await Config.refresh();
248
+ expect(Config.get("TERRENO_CFG_STRING")).toBe("first");
249
+
250
+ payload = {TERRENO_CFG_STRING: "second"};
251
+ await Config.refresh();
252
+ expect(Config.get("TERRENO_CFG_STRING")).toBe("second");
253
+ });
254
+ });
255
+ });
package/src/config.ts ADDED
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Runtime configuration registry with a fixed resolution order:
3
+ *
4
+ * 1. In-process override (Config.setOverride) — highest, for tests/bootstrap
5
+ * 2. Cached env map — typically loaded from an admin-editable Mongoose document
6
+ * 3. process.env
7
+ * 4. Registered default
8
+ *
9
+ * Why a registry: every key migrating off raw `process.env` declares its type
10
+ * and default in one place. That keeps the admin UI honest (no surprise keys),
11
+ * gives synchronous access without scattered `?? "default"` literals at call
12
+ * sites, and lets tests assert behavior against a single source of truth.
13
+ *
14
+ * Why sync: hundreds of call sites read configuration during request handling
15
+ * and module init; an async API would force enormous refactors. Callers load
16
+ * the env map once via `Config.refresh()` after Mongo connects, then read
17
+ * synchronously from cache.
18
+ *
19
+ * The mechanism is agnostic to where the env map comes from. Apps wire up
20
+ * their backing store with `Config.setEnvLoader(fn)`. The optional
21
+ * `envConfigurationPlugin` provides a drop-in Mongoose schema integration.
22
+ */
23
+
24
+ import {APIError} from "./errors";
25
+
26
+ const overrides = new Map<string, string | undefined>();
27
+
28
+ let cachedEnv: Record<string, string> | null = null;
29
+
30
+ let envLoader: (() => Promise<Record<string, string>>) | null = null;
31
+
32
+ export interface ConfigRegistration {
33
+ /** Default returned when neither override, cache, nor process.env supplies a value. */
34
+ default?: string;
35
+ /** Documentation surfaced in the admin UI. */
36
+ description?: string;
37
+ /** Marks the key as a secret so admin UI can mask the value. */
38
+ secret?: boolean;
39
+ }
40
+
41
+ // Null-prototype object so lookups don't resolve inherited keys like
42
+ // `constructor` / `toString` as accidentally-registered entries.
43
+ const REGISTRY: Record<string, ConfigRegistration> = Object.create(null);
44
+
45
+ /**
46
+ * Registers a configuration key, its default, and metadata. Re-registration
47
+ * of the same key throws so duplicates surface at boot.
48
+ */
49
+ const register = (key: string, registration: ConfigRegistration = {}): void => {
50
+ if (REGISTRY[key]) {
51
+ throw new APIError({status: 500, title: `Config key "${key}" registered more than once`});
52
+ }
53
+ REGISTRY[key] = registration;
54
+ };
55
+
56
+ /**
57
+ * Returns the configured string value for `key`, applying the resolution
58
+ * order documented at the top of this file. Returns `undefined` if no
59
+ * source supplies a value and no default was registered.
60
+ */
61
+ const getString = (key: string): string | undefined => {
62
+ if (overrides.has(key)) {
63
+ return overrides.get(key);
64
+ }
65
+ if (cachedEnv && key in cachedEnv) {
66
+ const v = cachedEnv[key];
67
+ if (v !== undefined && v !== "") {
68
+ return v;
69
+ }
70
+ }
71
+ // Guard against process.env keys like "toString" / "constructor" inheriting
72
+ // a function from Object.prototype. Only accept string values.
73
+ const fromProcess = process.env[key];
74
+ if (typeof fromProcess === "string" && fromProcess !== "") {
75
+ return fromProcess;
76
+ }
77
+ return REGISTRY[key]?.default;
78
+ };
79
+
80
+ /**
81
+ * Returns the configured value as a number. Throws if a value is present but
82
+ * not finite — silent NaN propagation has bitten apps before.
83
+ */
84
+ const getNumber = (key: string): number | undefined => {
85
+ const raw = getString(key);
86
+ if (raw === undefined) {
87
+ return undefined;
88
+ }
89
+ // Number() rejects partially-numeric strings like "5000ms" (returns NaN)
90
+ // whereas parseFloat would silently truncate to 5000.
91
+ const parsed = Number(raw);
92
+ if (!Number.isFinite(parsed)) {
93
+ throw new APIError({
94
+ error: new Error(`Config key "${key}" is not a valid number: ${JSON.stringify(raw)}`),
95
+ status: 500,
96
+ title: `Config key "${key}" is not a valid number`,
97
+ });
98
+ }
99
+ return parsed;
100
+ };
101
+
102
+ /**
103
+ * Returns true iff the string value equals "true" (case-insensitive). Mirrors
104
+ * the existing `process.env.X === "true"` idiom.
105
+ */
106
+ const getBoolean = (key: string): boolean => {
107
+ const raw = getString(key);
108
+ return raw !== undefined && raw.toLowerCase() === "true";
109
+ };
110
+
111
+ /**
112
+ * Parses a JSON-encoded config value. Returns undefined if unset; throws on
113
+ * malformed JSON so misconfiguration fails loud at the call site rather than
114
+ * producing silent runtime errors later.
115
+ */
116
+ const getJSON = <T = unknown>(key: string): T | undefined => {
117
+ const raw = getString(key);
118
+ if (raw === undefined) {
119
+ return undefined;
120
+ }
121
+ try {
122
+ return JSON.parse(raw) as T;
123
+ } catch (error) {
124
+ throw new APIError({
125
+ error: new Error(`Config key "${key}" is not valid JSON: ${(error as Error).message}`),
126
+ status: 500,
127
+ title: `Config key "${key}" is not valid JSON`,
128
+ });
129
+ }
130
+ };
131
+
132
+ /**
133
+ * Registers a loader that returns the env map (typically backed by an
134
+ * admin-editable Mongoose document). Called once at app startup before
135
+ * the first `Config.refresh()`.
136
+ */
137
+ const setEnvLoader = (loader: (() => Promise<Record<string, string>>) | null): void => {
138
+ envLoader = loader;
139
+ };
140
+
141
+ /**
142
+ * Reloads the in-memory cache by invoking the registered env loader. No-op
143
+ * (clears cache) if no loader has been registered.
144
+ */
145
+ const refresh = async (): Promise<void> => {
146
+ if (!envLoader) {
147
+ cachedEnv = {};
148
+ return;
149
+ }
150
+ cachedEnv = await envLoader();
151
+ };
152
+
153
+ /** Replaces the cache directly. Intended for the envConfigurationPlugin and tests. */
154
+ const setCachedEnv = (env: Record<string, string> | null): void => {
155
+ cachedEnv = env;
156
+ };
157
+
158
+ /**
159
+ * Sets an in-process override for `key`. Highest precedence — wins over
160
+ * the cached env map. Intended for tests and bootstrap helpers.
161
+ */
162
+ const setOverride = (key: string, value: string | undefined): void => {
163
+ overrides.set(key, value);
164
+ };
165
+
166
+ /** Clears every override. Call from afterEach in tests. */
167
+ const clearOverrides = (): void => {
168
+ overrides.clear();
169
+ };
170
+
171
+ /** Returns the registered default (if any) for `key`. */
172
+ const getDefault = (key: string): string | undefined => {
173
+ return REGISTRY[key]?.default;
174
+ };
175
+
176
+ /** Returns the registration metadata for `key`, including secret/description. */
177
+ const getRegistration = (key: string): ConfigRegistration | undefined => {
178
+ return REGISTRY[key];
179
+ };
180
+
181
+ /** Returns the registered keys, sorted. Used by the admin UI. */
182
+ const getRegisteredKeys = (): string[] => {
183
+ return Object.keys(REGISTRY).sort();
184
+ };
185
+
186
+ /** Returns true if `key` was registered. */
187
+ const isRegistered = (key: string): boolean => {
188
+ return key in REGISTRY;
189
+ };
190
+
191
+ /** Removes every registered key. Intended for tests. */
192
+ const clearRegistryForTesting = (): void => {
193
+ for (const key of Object.keys(REGISTRY)) {
194
+ delete REGISTRY[key];
195
+ }
196
+ };
197
+
198
+ export const Config = {
199
+ clearOverrides,
200
+ clearRegistryForTesting,
201
+ get: getString,
202
+ getBoolean,
203
+ getDefault,
204
+ getJSON,
205
+ getNumber,
206
+ getRegisteredKeys,
207
+ getRegistration,
208
+ isRegistered,
209
+ refresh,
210
+ register,
211
+ setCachedEnv,
212
+ setEnvLoader,
213
+ setOverride,
214
+ };
215
+
216
+ export type ConfigType = typeof Config;
@@ -1,3 +1,4 @@
1
+ // biome-ignore-all lint/suspicious/noExplicitAny: test mock typing
1
2
  import {beforeEach, describe, expect, it} from "bun:test";
2
3
  import type express from "express";
3
4
  import mongoose, {Schema} from "mongoose";