@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
@@ -14,6 +14,7 @@ import type {UserModel} from "./auth";
14
14
  import type {BetterAuthConfig, BetterAuthSessionData, BetterAuthUser} from "./betterAuth";
15
15
  import {logger} from "./logger";
16
16
  import {findOneOrNoneFor} from "./plugins";
17
+ import {updateRequestContextFromRequest} from "./requestContext";
17
18
 
18
19
  /**
19
20
  * The Better Auth instance type.
@@ -23,9 +24,15 @@ export type BetterAuthInstance = ReturnType<typeof betterAuth>;
23
24
  /**
24
25
  * Options for creating a Better Auth instance.
25
26
  */
27
+ // Minimal shape we use from the MongoDB native client returned by mongoose connection
28
+ export interface MongoClientLike {
29
+ // biome-ignore lint/suspicious/noExplicitAny: the MongoDB driver Db type is opaque to this layer; it is passed straight to better-auth's adapter
30
+ db: () => any;
31
+ }
32
+
26
33
  export interface CreateBetterAuthOptions {
27
34
  config: BetterAuthConfig;
28
- mongoClient: any;
35
+ mongoClient: MongoClientLike;
29
36
  userModel?: UserModel;
30
37
  }
31
38
 
@@ -88,7 +95,7 @@ export const createBetterAuth = (options: CreateBetterAuthOptions): BetterAuthIn
88
95
  trustedOrigins: config.trustedOrigins ?? [],
89
96
  });
90
97
 
91
- return auth as any;
98
+ return auth as BetterAuthInstance;
92
99
  };
93
100
 
94
101
  /**
@@ -108,31 +115,38 @@ export const createBetterAuthSessionMiddleware = (
108
115
  if (session?.user && session?.session) {
109
116
  const betterAuthUser = session.user as BetterAuthUser;
110
117
 
118
+ const reqWithSession = req as Request & {
119
+ user?: Request["user"];
120
+ betterAuthSession?: BetterAuthSessionData;
121
+ };
111
122
  if (userModel) {
112
123
  // Look up the application user by betterAuthId
113
124
  const appUser = await findOneOrNoneFor(userModel, {
114
125
  betterAuthId: betterAuthUser.id,
115
126
  });
116
127
  if (appUser) {
117
- (req as any).user = appUser;
118
- (req as any).betterAuthSession = session;
128
+ reqWithSession.user = appUser as unknown as Request["user"];
129
+ reqWithSession.betterAuthSession = session as unknown as BetterAuthSessionData;
130
+ updateRequestContextFromRequest(req);
119
131
  } else {
120
132
  // User exists in Better Auth but not synced yet - create them
121
133
  const newUser = await syncBetterAuthUser(userModel, betterAuthUser);
122
- (req as any).user = newUser;
123
- (req as any).betterAuthSession = session;
134
+ reqWithSession.user = newUser as unknown as Request["user"];
135
+ reqWithSession.betterAuthSession = session as unknown as BetterAuthSessionData;
136
+ updateRequestContextFromRequest(req);
124
137
  }
125
138
  } else {
126
139
  // No user model - just attach the Better Auth user directly
127
- (req as any).user = {
140
+ reqWithSession.user = {
128
141
  _id: betterAuthUser.id,
129
142
  admin: false,
130
143
  betterAuthId: betterAuthUser.id,
131
144
  email: betterAuthUser.email,
132
145
  id: betterAuthUser.id,
133
146
  name: betterAuthUser.name,
134
- };
135
- (req as any).betterAuthSession = session;
147
+ } as unknown as Request["user"];
148
+ reqWithSession.betterAuthSession = session as unknown as BetterAuthSessionData;
149
+ updateRequestContextFromRequest(req);
136
150
  }
137
151
  }
138
152
 
@@ -148,15 +162,27 @@ export const createBetterAuthSessionMiddleware = (
148
162
  * Syncs a Better Auth user to the application User model.
149
163
  * Creates or updates the user as needed.
150
164
  */
165
+ // Loose shape used when mutating Mongoose user documents during Better Auth sync.
166
+ // The fields are added by the consumer's user schema (via baseUserPlugin or similar).
167
+ interface MutableUserDoc {
168
+ email?: string;
169
+ name?: string;
170
+ betterAuthId?: string;
171
+ oauthProvider?: string | null;
172
+ id?: string;
173
+ save: () => Promise<unknown>;
174
+ }
175
+
151
176
  export const syncBetterAuthUser = async (
152
177
  userModel: UserModel,
153
178
  betterAuthUser: BetterAuthUser,
154
179
  oauthProvider?: string
180
+ // biome-ignore lint/suspicious/noExplicitAny: return is a consumer-defined user document; tests inspect varied fields
155
181
  ): Promise<any> => {
156
182
  try {
157
- const existingUser: any = await findOneOrNoneFor(userModel, {
183
+ const existingUser = (await findOneOrNoneFor(userModel, {
158
184
  betterAuthId: betterAuthUser.id,
159
- });
185
+ })) as unknown as MutableUserDoc | null;
160
186
 
161
187
  if (existingUser) {
162
188
  // Update existing user if needed
@@ -169,9 +195,9 @@ export const syncBetterAuthUser = async (
169
195
  }
170
196
 
171
197
  // Check if user exists by email (migration case)
172
- const userByEmail: any = await findOneOrNoneFor(userModel, {
198
+ const userByEmail = (await findOneOrNoneFor(userModel, {
173
199
  email: betterAuthUser.email,
174
- });
200
+ })) as unknown as MutableUserDoc | null;
175
201
  if (userByEmail) {
176
202
  // Link existing user to Better Auth
177
203
  userByEmail.betterAuthId = betterAuthUser.id;
@@ -184,14 +210,15 @@ export const syncBetterAuthUser = async (
184
210
 
185
211
  // Use Better Auth ID as _id when it's a valid ObjectId (MongoDB adapter) so frontend IDs match
186
212
  const useAsId = mongoose.isValidObjectId(betterAuthUser.id) ? {_id: betterAuthUser.id} : {};
187
- const newUser: any = new (userModel as any)({
213
+ // biome-ignore lint/suspicious/noExplicitAny: userModel is generic across consumers — constructor args are runtime-validated
214
+ const newUser = new (userModel as any)({
188
215
  ...useAsId,
189
216
  admin: false,
190
217
  betterAuthId: betterAuthUser.id,
191
218
  email: betterAuthUser.email,
192
219
  name: betterAuthUser.name || betterAuthUser.email.split("@")[0],
193
220
  oauthProvider: oauthProvider || null,
194
- });
221
+ }) as MutableUserDoc;
195
222
  await newUser.save();
196
223
  logger.info(`Created new user from Better Auth: ${newUser.id}`);
197
224
  return newUser;
@@ -222,9 +249,9 @@ export const mountBetterAuthRoutes = (
222
249
  /**
223
250
  * Gets the MongoDB client from the mongoose connection.
224
251
  */
225
- export const getMongoClientFromMongoose = (): any => {
252
+ export const getMongoClientFromMongoose = (): MongoClientLike => {
226
253
  const connection = mongoose.connection;
227
- const client = (connection as any).client;
254
+ const client = (connection as unknown as {client?: MongoClientLike}).client;
228
255
  if (!client) {
229
256
  throw new Error("Mongoose is not connected. Ensure MongoDB connection is established first.");
230
257
  }
@@ -249,12 +276,12 @@ export const setupBetterAuthUserSync = (_auth: BetterAuthInstance, _userModel: U
249
276
  * Extracts Better Auth session data from the request.
250
277
  */
251
278
  export const getBetterAuthSession = (req: Request): BetterAuthSessionData | null => {
252
- return (req as any).betterAuthSession ?? null;
279
+ return (req as Request & {betterAuthSession?: BetterAuthSessionData}).betterAuthSession ?? null;
253
280
  };
254
281
 
255
282
  /**
256
283
  * Checks if the request has a valid Better Auth session.
257
284
  */
258
285
  export const hasBetterAuthSession = (req: Request): boolean => {
259
- return Boolean((req as any).betterAuthSession);
286
+ return Boolean((req as Request & {betterAuthSession?: BetterAuthSessionData}).betterAuthSession);
260
287
  };
@@ -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,206 @@
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
+ const overrides = new Map<string, string | undefined>();
25
+
26
+ let cachedEnv: Record<string, string> | null = null;
27
+
28
+ let envLoader: (() => Promise<Record<string, string>>) | null = null;
29
+
30
+ export interface ConfigRegistration {
31
+ /** Default returned when neither override, cache, nor process.env supplies a value. */
32
+ default?: string;
33
+ /** Documentation surfaced in the admin UI. */
34
+ description?: string;
35
+ /** Marks the key as a secret so admin UI can mask the value. */
36
+ secret?: boolean;
37
+ }
38
+
39
+ // Null-prototype object so lookups don't resolve inherited keys like
40
+ // `constructor` / `toString` as accidentally-registered entries.
41
+ const REGISTRY: Record<string, ConfigRegistration> = Object.create(null);
42
+
43
+ /**
44
+ * Registers a configuration key, its default, and metadata. Re-registration
45
+ * of the same key throws so duplicates surface at boot.
46
+ */
47
+ const register = (key: string, registration: ConfigRegistration = {}): void => {
48
+ if (REGISTRY[key]) {
49
+ throw new Error(`Config key "${key}" registered more than once`);
50
+ }
51
+ REGISTRY[key] = registration;
52
+ };
53
+
54
+ /**
55
+ * Returns the configured string value for `key`, applying the resolution
56
+ * order documented at the top of this file. Returns `undefined` if no
57
+ * source supplies a value and no default was registered.
58
+ */
59
+ const getString = (key: string): string | undefined => {
60
+ if (overrides.has(key)) {
61
+ return overrides.get(key);
62
+ }
63
+ if (cachedEnv && key in cachedEnv) {
64
+ const v = cachedEnv[key];
65
+ if (v !== undefined && v !== "") {
66
+ return v;
67
+ }
68
+ }
69
+ // Guard against process.env keys like "toString" / "constructor" inheriting
70
+ // a function from Object.prototype. Only accept string values.
71
+ const fromProcess = process.env[key];
72
+ if (typeof fromProcess === "string" && fromProcess !== "") {
73
+ return fromProcess;
74
+ }
75
+ return REGISTRY[key]?.default;
76
+ };
77
+
78
+ /**
79
+ * Returns the configured value as a number. Throws if a value is present but
80
+ * not finite — silent NaN propagation has bitten apps before.
81
+ */
82
+ const getNumber = (key: string): number | undefined => {
83
+ const raw = getString(key);
84
+ if (raw === undefined) {
85
+ return undefined;
86
+ }
87
+ // Number() rejects partially-numeric strings like "5000ms" (returns NaN)
88
+ // whereas parseFloat would silently truncate to 5000.
89
+ const parsed = Number(raw);
90
+ if (!Number.isFinite(parsed)) {
91
+ throw new Error(`Config key "${key}" is not a valid number: ${JSON.stringify(raw)}`);
92
+ }
93
+ return parsed;
94
+ };
95
+
96
+ /**
97
+ * Returns true iff the string value equals "true" (case-insensitive). Mirrors
98
+ * the existing `process.env.X === "true"` idiom.
99
+ */
100
+ const getBoolean = (key: string): boolean => {
101
+ const raw = getString(key);
102
+ return raw !== undefined && raw.toLowerCase() === "true";
103
+ };
104
+
105
+ /**
106
+ * Parses a JSON-encoded config value. Returns undefined if unset; throws on
107
+ * malformed JSON so misconfiguration fails loud at the call site rather than
108
+ * producing silent runtime errors later.
109
+ */
110
+ const getJSON = <T = unknown>(key: string): T | undefined => {
111
+ const raw = getString(key);
112
+ if (raw === undefined) {
113
+ return undefined;
114
+ }
115
+ try {
116
+ return JSON.parse(raw) as T;
117
+ } catch (error) {
118
+ throw new Error(`Config key "${key}" is not valid JSON: ${(error as Error).message}`);
119
+ }
120
+ };
121
+
122
+ /**
123
+ * Registers a loader that returns the env map (typically backed by an
124
+ * admin-editable Mongoose document). Called once at app startup before
125
+ * the first `Config.refresh()`.
126
+ */
127
+ const setEnvLoader = (loader: (() => Promise<Record<string, string>>) | null): void => {
128
+ envLoader = loader;
129
+ };
130
+
131
+ /**
132
+ * Reloads the in-memory cache by invoking the registered env loader. No-op
133
+ * (clears cache) if no loader has been registered.
134
+ */
135
+ const refresh = async (): Promise<void> => {
136
+ if (!envLoader) {
137
+ cachedEnv = {};
138
+ return;
139
+ }
140
+ cachedEnv = await envLoader();
141
+ };
142
+
143
+ /** Replaces the cache directly. Intended for the envConfigurationPlugin and tests. */
144
+ const setCachedEnv = (env: Record<string, string> | null): void => {
145
+ cachedEnv = env;
146
+ };
147
+
148
+ /**
149
+ * Sets an in-process override for `key`. Highest precedence — wins over
150
+ * the cached env map. Intended for tests and bootstrap helpers.
151
+ */
152
+ const setOverride = (key: string, value: string | undefined): void => {
153
+ overrides.set(key, value);
154
+ };
155
+
156
+ /** Clears every override. Call from afterEach in tests. */
157
+ const clearOverrides = (): void => {
158
+ overrides.clear();
159
+ };
160
+
161
+ /** Returns the registered default (if any) for `key`. */
162
+ const getDefault = (key: string): string | undefined => {
163
+ return REGISTRY[key]?.default;
164
+ };
165
+
166
+ /** Returns the registration metadata for `key`, including secret/description. */
167
+ const getRegistration = (key: string): ConfigRegistration | undefined => {
168
+ return REGISTRY[key];
169
+ };
170
+
171
+ /** Returns the registered keys, sorted. Used by the admin UI. */
172
+ const getRegisteredKeys = (): string[] => {
173
+ return Object.keys(REGISTRY).sort();
174
+ };
175
+
176
+ /** Returns true if `key` was registered. */
177
+ const isRegistered = (key: string): boolean => {
178
+ return key in REGISTRY;
179
+ };
180
+
181
+ /** Removes every registered key. Intended for tests. */
182
+ const clearRegistryForTesting = (): void => {
183
+ for (const key of Object.keys(REGISTRY)) {
184
+ delete REGISTRY[key];
185
+ }
186
+ };
187
+
188
+ export const Config = {
189
+ clearOverrides,
190
+ clearRegistryForTesting,
191
+ get: getString,
192
+ getBoolean,
193
+ getDefault,
194
+ getJSON,
195
+ getNumber,
196
+ getRegisteredKeys,
197
+ getRegistration,
198
+ isRegistered,
199
+ refresh,
200
+ register,
201
+ setCachedEnv,
202
+ setEnvLoader,
203
+ setOverride,
204
+ };
205
+
206
+ 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";