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