@terreno/api 0.13.3 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/versionCheckPlugin.test.js +136 -3
- package/dist/api.arrayOperations.test.js +1 -0
- package/dist/api.d.ts +15 -4
- package/dist/api.errors.test.js +1 -0
- package/dist/api.hooks.test.js +1 -0
- package/dist/api.js +153 -104
- package/dist/api.query.test.js +1 -0
- package/dist/api.test.js +174 -0
- package/dist/auth.d.ts +10 -5
- package/dist/auth.js +163 -90
- package/dist/auth.test.js +159 -0
- package/dist/betterAuthApp.test.js +1 -0
- package/dist/betterAuthSetup.d.ts +5 -6
- package/dist/betterAuthSetup.js +30 -17
- package/dist/betterAuthSetup.test.js +1 -0
- package/dist/config.d.ts +48 -0
- package/dist/config.js +257 -0
- package/dist/config.test.d.ts +1 -0
- package/dist/config.test.js +328 -0
- package/dist/configuration.test.js +1 -0
- package/dist/configurationApp.d.ts +1 -1
- package/dist/configurationApp.js +17 -13
- package/dist/configurationPlugin.test.js +1 -0
- package/dist/consentApp.test.js +1 -0
- package/dist/envConfigurationPlugin.d.ts +2 -0
- package/dist/envConfigurationPlugin.js +173 -0
- package/dist/envConfigurationPlugin.test.d.ts +1 -0
- package/dist/envConfigurationPlugin.test.js +322 -0
- package/dist/errors.d.ts +18 -7
- package/dist/errors.js +111 -12
- package/dist/errors.test.js +16 -1
- package/dist/example.js +19 -7
- package/dist/expressServer.d.ts +10 -9
- package/dist/expressServer.js +62 -53
- package/dist/expressServer.test.js +165 -2
- package/dist/githubAuth.d.ts +2 -1
- package/dist/githubAuth.js +41 -26
- package/dist/githubAuth.test.js +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/logger.d.ts +1 -1
- package/dist/logger.js +42 -20
- package/dist/models/versionConfig.d.ts +2 -0
- package/dist/models/versionConfig.js +8 -0
- package/dist/notifiers/googleChatNotifier.js +14 -16
- package/dist/notifiers/googleChatNotifier.test.js +1 -0
- package/dist/notifiers/slackNotifier.js +16 -14
- package/dist/notifiers/slackNotifier.test.js +41 -3
- package/dist/notifiers/zoomNotifier.js +7 -10
- package/dist/notifiers/zoomNotifier.test.js +1 -0
- package/dist/openApi.d.ts +1 -1
- package/dist/openApi.test.js +1 -0
- package/dist/openApiBuilder.d.ts +39 -6
- package/dist/openApiBuilder.js +1 -31
- package/dist/openApiBuilder.test.js +1 -0
- package/dist/openApiValidator.js +1 -0
- package/dist/openApiValidator.test.js +1 -0
- package/dist/permissions.d.ts +4 -4
- package/dist/permissions.js +67 -65
- package/dist/permissions.middleware.test.js +1 -0
- package/dist/permissions.test.js +1 -0
- package/dist/plugins.d.ts +5 -5
- package/dist/plugins.js +18 -9
- package/dist/plugins.test.js +1 -1
- package/dist/populate.d.ts +15 -8
- package/dist/populate.js +23 -24
- package/dist/populate.test.js +1 -0
- package/dist/realtime/changeStreamWatcher.d.ts +73 -0
- package/dist/realtime/changeStreamWatcher.js +724 -0
- package/dist/realtime/index.d.ts +6 -0
- package/dist/realtime/index.js +27 -0
- package/dist/realtime/queryMatcher.d.ts +14 -0
- package/dist/realtime/queryMatcher.js +250 -0
- package/dist/realtime/queryStore.d.ts +37 -0
- package/dist/realtime/queryStore.js +195 -0
- package/dist/realtime/realtime.test.d.ts +10 -0
- package/dist/realtime/realtime.test.js +3066 -0
- package/dist/realtime/realtimeApp.d.ts +93 -0
- package/dist/realtime/realtimeApp.js +560 -0
- package/dist/realtime/registry.d.ts +40 -0
- package/dist/realtime/registry.js +38 -0
- package/dist/realtime/socketUser.d.ts +10 -0
- package/dist/realtime/socketUser.js +17 -0
- package/dist/realtime/types.d.ts +100 -0
- package/dist/realtime/types.js +2 -0
- package/dist/requestContext.d.ts +37 -0
- package/dist/requestContext.js +344 -0
- package/dist/requestContext.test.d.ts +1 -0
- package/dist/requestContext.test.js +384 -0
- package/dist/terrenoApp.d.ts +8 -0
- package/dist/terrenoApp.js +50 -13
- package/dist/terrenoApp.test.js +194 -21
- package/dist/terrenoPlugin.d.ts +11 -0
- package/dist/tests/bunSetup.js +1 -0
- package/dist/tests.js +1 -1
- package/dist/transformers.d.ts +2 -2
- package/dist/transformers.js +5 -3
- package/dist/transformers.test.js +90 -0
- package/dist/types/consentResponse.d.ts +6 -3
- package/dist/versionCheckPlugin.d.ts +2 -0
- package/dist/versionCheckPlugin.js +18 -12
- package/package.json +4 -2
- package/src/__tests__/versionCheckPlugin.test.ts +94 -3
- package/src/api.arrayOperations.test.ts +1 -0
- package/src/api.errors.test.ts +1 -0
- package/src/api.hooks.test.ts +1 -0
- package/src/api.query.test.ts +1 -0
- package/src/api.test.ts +132 -0
- package/src/api.ts +199 -84
- package/src/auth.test.ts +160 -0
- package/src/auth.ts +120 -50
- package/src/betterAuthApp.test.ts +1 -0
- package/src/betterAuthSetup.test.ts +1 -0
- package/src/betterAuthSetup.ts +59 -22
- package/src/config.test.ts +255 -0
- package/src/config.ts +216 -0
- package/src/configuration.test.ts +1 -0
- package/src/configurationApp.ts +59 -24
- package/src/configurationPlugin.test.ts +1 -0
- package/src/consentApp.test.ts +1 -0
- package/src/envConfigurationPlugin.test.ts +143 -0
- package/src/envConfigurationPlugin.ts +100 -0
- package/src/errors.test.ts +19 -1
- package/src/errors.ts +118 -38
- package/src/example.ts +49 -21
- package/src/express.d.ts +18 -1
- package/src/expressServer.test.ts +147 -2
- package/src/expressServer.ts +80 -50
- package/src/githubAuth.test.ts +1 -0
- package/src/githubAuth.ts +59 -38
- package/src/index.ts +4 -0
- package/src/logger.ts +47 -17
- package/src/models/versionConfig.ts +13 -2
- package/src/notifiers/googleChatNotifier.test.ts +1 -0
- package/src/notifiers/googleChatNotifier.ts +7 -9
- package/src/notifiers/slackNotifier.test.ts +29 -3
- package/src/notifiers/slackNotifier.ts +9 -7
- package/src/notifiers/zoomNotifier.test.ts +1 -0
- package/src/notifiers/zoomNotifier.ts +8 -11
- package/src/openApi.test.ts +1 -0
- package/src/openApi.ts +4 -4
- package/src/openApiBuilder.test.ts +1 -0
- package/src/openApiBuilder.ts +14 -11
- package/src/openApiValidator.test.ts +1 -0
- package/src/openApiValidator.ts +3 -2
- package/src/permissions.middleware.test.ts +1 -0
- package/src/permissions.test.ts +1 -0
- package/src/permissions.ts +30 -25
- package/src/plugins.test.ts +1 -1
- package/src/plugins.ts +21 -14
- package/src/populate.test.ts +1 -0
- package/src/populate.ts +44 -36
- package/src/realtime/changeStreamWatcher.ts +572 -0
- package/src/realtime/index.ts +34 -0
- package/src/realtime/queryMatcher.ts +179 -0
- package/src/realtime/queryStore.ts +132 -0
- package/src/realtime/realtime.test.ts +2465 -0
- package/src/realtime/realtimeApp.ts +478 -0
- package/src/realtime/registry.ts +64 -0
- package/src/realtime/socketUser.ts +25 -0
- package/src/realtime/types.ts +112 -0
- package/src/requestContext.test.ts +321 -0
- package/src/requestContext.ts +368 -0
- package/src/terrenoApp.test.ts +137 -11
- package/src/terrenoApp.ts +64 -17
- package/src/terrenoPlugin.ts +12 -0
- package/src/tests/bunSetup.ts +1 -0
- package/src/tests.ts +7 -2
- package/src/transformers.test.ts +70 -2
- package/src/transformers.ts +15 -7
- package/src/types/consentResponse.ts +8 -10
- package/src/versionCheckPlugin.ts +15 -7
package/src/betterAuthSetup.ts
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
118
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 = ():
|
|
259
|
+
export const getMongoClientFromMongoose = (): MongoClientLike => {
|
|
226
260
|
const connection = mongoose.connection;
|
|
227
|
-
const client = (connection as
|
|
261
|
+
const client = (connection as unknown as {client?: MongoClientLike}).client;
|
|
228
262
|
if (!client) {
|
|
229
|
-
throw new
|
|
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
|
|
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
|
|
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;
|