@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
package/src/githubAuth.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import type express from "express";
2
2
  import {Router} from "express";
3
+ import type {Schema} from "mongoose";
3
4
  import passport from "passport";
4
5
  import {Strategy as GitHubStrategy, type Profile} from "passport-github2";
5
- import {generateTokens, type UserModel} from "./auth";
6
+ import {generateTokens, type User, type UserModel} from "./auth";
6
7
  import {APIError} from "./errors";
7
8
  import type {AuthOptions} from "./expressServer";
8
9
  import {logger} from "./logger";
@@ -32,7 +33,9 @@ export interface GitHubAuthOptions {
32
33
  profile: Profile,
33
34
  accessToken: string,
34
35
  refreshToken: string,
36
+ // biome-ignore lint/suspicious/noExplicitAny: user shape varies per consumer's User model
35
37
  existingUser?: any
38
+ // biome-ignore lint/suspicious/noExplicitAny: passport user value remains untyped
36
39
  ) => Promise<any>;
37
40
  }
38
41
 
@@ -59,12 +62,19 @@ export interface GitHubUserFields {
59
62
  * userSchema.plugin(githubUserPlugin);
60
63
  * ```
61
64
  */
62
- export const githubUserPlugin = (schema: any): void => {
65
+ // biome-ignore lint/suspicious/noExplicitAny: Schema generics must be loose to accept arbitrary consumer schemas
66
+ export const githubUserPlugin = (schema: Schema<any, any, any, any>): void => {
63
67
  schema.add({
64
- githubAvatarUrl: {type: String},
65
- githubId: {index: true, sparse: true, type: String, unique: true},
66
- githubProfileUrl: {type: String},
67
- githubUsername: {type: String},
68
+ githubAvatarUrl: {description: "GitHub avatar image URL", type: String},
69
+ githubId: {
70
+ description: "GitHub user ID",
71
+ index: true,
72
+ sparse: true,
73
+ type: String,
74
+ unique: true,
75
+ },
76
+ githubProfileUrl: {description: "GitHub profile URL", type: String},
77
+ githubUsername: {description: "GitHub username", type: String},
68
78
  });
69
79
  };
70
80
 
@@ -81,6 +91,7 @@ export const setupGitHubAuth = (
81
91
 
82
92
  passport.use(
83
93
  "github",
94
+ // biome-ignore lint/suspicious/noExplicitAny: passport-github2's typed constructor overloads don't match passReqToCallback variant
84
95
  new (GitHubStrategy as any)(
85
96
  {
86
97
  callbackURL: githubOptions.callbackURL,
@@ -89,12 +100,12 @@ export const setupGitHubAuth = (
89
100
  passReqToCallback: true,
90
101
  scope,
91
102
  },
92
- (async (
93
- req: any,
103
+ async (
104
+ req: express.Request,
94
105
  accessToken: string,
95
106
  refreshToken: string,
96
107
  profile: Profile,
97
- done: (error: any, user?: any) => void
108
+ done: (error: unknown, user?: unknown) => void
98
109
  ) => {
99
110
  try {
100
111
  const existingUser = req.user;
@@ -137,10 +148,11 @@ export const setupGitHubAuth = (
137
148
  // Link GitHub to existing user
138
149
  const user = await userModel.findById(existingUser._id);
139
150
  if (user) {
140
- (user as any).githubId = githubId;
141
- (user as any).githubUsername = profile.username;
142
- (user as any).githubProfileUrl = profile.profileUrl;
143
- (user as any).githubAvatarUrl = profile.photos?.[0]?.value;
151
+ const userWithGitHub = user as unknown as GitHubUserFields;
152
+ userWithGitHub.githubId = githubId;
153
+ userWithGitHub.githubUsername = profile.username;
154
+ userWithGitHub.githubProfileUrl = profile.profileUrl;
155
+ userWithGitHub.githubAvatarUrl = profile.photos?.[0]?.value;
144
156
  await user.save();
145
157
  return done(null, user);
146
158
  }
@@ -161,10 +173,11 @@ export const setupGitHubAuth = (
161
173
  if (existingEmailUser) {
162
174
  // If account linking is allowed, link GitHub to existing email account
163
175
  if (githubOptions.allowAccountLinking !== false) {
164
- (existingEmailUser as any).githubId = githubId;
165
- (existingEmailUser as any).githubUsername = profile.username;
166
- (existingEmailUser as any).githubProfileUrl = profile.profileUrl;
167
- (existingEmailUser as any).githubAvatarUrl = profile.photos?.[0]?.value;
176
+ const emailUserWithGitHub = existingEmailUser as unknown as GitHubUserFields;
177
+ emailUserWithGitHub.githubId = githubId;
178
+ emailUserWithGitHub.githubUsername = profile.username;
179
+ emailUserWithGitHub.githubProfileUrl = profile.profileUrl;
180
+ emailUserWithGitHub.githubAvatarUrl = profile.photos?.[0]?.value;
168
181
  await existingEmailUser.save();
169
182
  return done(null, existingEmailUser);
170
183
  }
@@ -186,7 +199,7 @@ export const setupGitHubAuth = (
186
199
  githubId,
187
200
  githubProfileUrl: profile.profileUrl,
188
201
  githubUsername: profile.username,
189
- } as any);
202
+ } as unknown as Partial<User>);
190
203
 
191
204
  await newUser.save();
192
205
  return done(null, newUser);
@@ -194,7 +207,7 @@ export const setupGitHubAuth = (
194
207
  logger.error(`GitHub auth error: ${error}`);
195
208
  return done(error);
196
209
  }
197
- }) as any
210
+ }
198
211
  ) as passport.Strategy
199
212
  );
200
213
  };
@@ -223,8 +236,9 @@ export const addGitHubAuthRoutes = (
223
236
  // Store the return URL in session or query for redirect after auth
224
237
  const returnTo = req.query.returnTo as string | undefined;
225
238
  if (returnTo) {
226
- (req as any).session = (req as any).session || {};
227
- (req as any).session.returnTo = returnTo;
239
+ const reqWithSession = req as express.Request & {session?: {returnTo?: string}};
240
+ reqWithSession.session = reqWithSession.session ?? {};
241
+ reqWithSession.session.returnTo = returnTo;
228
242
  }
229
243
  next();
230
244
  },
@@ -241,16 +255,17 @@ export const addGitHubAuthRoutes = (
241
255
  async (req: express.Request, res: express.Response) => {
242
256
  try {
243
257
  const tokens = await generateTokens(req.user, authOptions);
244
- const returnTo = (req as any).session?.returnTo;
258
+ const returnTo = (req as express.Request & {session?: {returnTo?: string}}).session
259
+ ?.returnTo;
245
260
 
246
261
  // If there's a return URL, redirect with tokens as query params
247
262
  if (returnTo) {
248
263
  const url = new URL(returnTo);
249
- url.searchParams.set("token", tokens.token || "");
264
+ url.searchParams.set("token", tokens.token ?? "");
250
265
  if (tokens.refreshToken) {
251
266
  url.searchParams.set("refreshToken", tokens.refreshToken);
252
267
  }
253
- url.searchParams.set("userId", (req.user as any)?._id?.toString() || "");
268
+ url.searchParams.set("userId", req.user?._id ? String(req.user._id) : "");
254
269
  return res.redirect(url.toString());
255
270
  }
256
271
 
@@ -259,7 +274,7 @@ export const addGitHubAuthRoutes = (
259
274
  data: {
260
275
  refreshToken: tokens.refreshToken,
261
276
  token: tokens.token,
262
- userId: (req.user as any)?._id,
277
+ userId: req.user?._id,
263
278
  },
264
279
  });
265
280
  } catch (error) {
@@ -280,14 +295,18 @@ export const addGitHubAuthRoutes = (
280
295
  "/github/link",
281
296
  (req: express.Request, res: express.Response, next: express.NextFunction): void => {
282
297
  // Require JWT authentication for linking
283
- passport.authenticate("jwt", {session: false}, (err: any, user: any) => {
284
- if (err || !user) {
285
- res.status(401).json({message: "Authentication required to link GitHub account"});
286
- return;
298
+ passport.authenticate(
299
+ "jwt",
300
+ {session: false},
301
+ (err: unknown, user: User | false | null) => {
302
+ if (err || !user) {
303
+ res.status(401).json({message: "Authentication required to link GitHub account"});
304
+ return;
305
+ }
306
+ req.user = user as unknown as express.Request["user"];
307
+ next();
287
308
  }
288
- req.user = user;
289
- next();
290
- })(req, res, next);
309
+ )(req, res, next);
291
310
  },
292
311
  passport.authenticate("github", {session: false})
293
312
  );
@@ -303,14 +322,15 @@ export const addGitHubAuthRoutes = (
303
322
 
304
323
  try {
305
324
  // Explicitly select hash and salt fields which may be hidden by default
306
- const user = await userModel.findById((req.user as any)._id).select("+hash +salt");
325
+ const user = await userModel.findById(req.user._id).select("+hash +salt");
307
326
  if (!user) {
308
327
  return res.status(404).json({message: "User not found"});
309
328
  }
310
329
 
311
330
  // Check if user has other authentication methods before unlinking
312
331
  // passport-local-mongoose stores password in hash and salt fields
313
- const hasPassword = !!(user as any).hash || !!(user as any).salt;
332
+ const userWithAuth = user as unknown as {hash?: string; salt?: string};
333
+ const hasPassword = !!userWithAuth.hash || !!userWithAuth.salt;
314
334
  if (!hasPassword) {
315
335
  return res.status(400).json({
316
336
  message:
@@ -318,10 +338,11 @@ export const addGitHubAuthRoutes = (
318
338
  });
319
339
  }
320
340
 
321
- (user as any).githubId = undefined;
322
- (user as any).githubUsername = undefined;
323
- (user as any).githubProfileUrl = undefined;
324
- (user as any).githubAvatarUrl = undefined;
341
+ const userWithGitHub = user as unknown as GitHubUserFields;
342
+ userWithGitHub.githubId = undefined;
343
+ userWithGitHub.githubUsername = undefined;
344
+ userWithGitHub.githubProfileUrl = undefined;
345
+ userWithGitHub.githubAvatarUrl = undefined;
325
346
  await user.save();
326
347
 
327
348
  return res.json({data: {message: "GitHub account unlinked successfully"}});
package/src/index.ts CHANGED
@@ -3,9 +3,11 @@ export * from "./auth";
3
3
  export * from "./betterAuth";
4
4
  export * from "./betterAuthApp";
5
5
  export * from "./betterAuthSetup";
6
+ export * from "./config";
6
7
  export * from "./configurationApp";
7
8
  export * from "./configurationPlugin";
8
9
  export * from "./consentApp";
10
+ export * from "./envConfigurationPlugin";
9
11
  export * from "./errors";
10
12
  export * from "./expressServer";
11
13
  export * from "./githubAuth";
@@ -22,6 +24,8 @@ export * from "./openApiValidator";
22
24
  export * from "./permissions";
23
25
  export * from "./plugins";
24
26
  export * from "./populate";
27
+ export * from "./realtime";
28
+ export * from "./requestContext";
25
29
  export * from "./scriptRunner";
26
30
  export * from "./secretProviders";
27
31
  export * from "./syncConsents";
package/src/logger.ts CHANGED
@@ -2,38 +2,62 @@ import fs from "node:fs";
2
2
  import {inspect} from "node:util";
3
3
  import * as Sentry from "@sentry/bun";
4
4
  import winston from "winston";
5
+ import {getCurrentLogContext} from "./requestContext";
5
6
 
6
- function isPrimitive(val: any) {
7
+ const isPrimitive = (val: unknown) => {
7
8
  return val === null || (typeof val !== "object" && typeof val !== "function");
8
- }
9
+ };
9
10
 
10
- function formatWithInspect(val: any) {
11
+ const formatWithInspect = (val: unknown) => {
11
12
  const prefix = isPrimitive(val) ? "" : "\n";
12
13
  const shouldFormat = typeof val !== "string";
13
14
 
14
15
  return prefix + (shouldFormat ? inspect(val, {colors: true, depth: null}) : val);
15
- }
16
+ };
17
+
18
+ const addRequestContextFormat = winston.format((info) => {
19
+ const context = getCurrentLogContext();
20
+ return {...context, ...info};
21
+ });
22
+
23
+ const formatContext = (info: winston.Logform.TransformableInfo): string => {
24
+ const contextParts = [
25
+ info.requestId ? `requestId=${info.requestId}` : undefined,
26
+ info.jobId ? `jobId=${info.jobId}` : undefined,
27
+ info.sessionId ? `sessionId=${info.sessionId}` : undefined,
28
+ info.userId ? `userId=${info.userId}` : undefined,
29
+ info.traceId ? `traceId=${info.traceId}` : undefined,
30
+ ].filter(Boolean);
31
+
32
+ if (contextParts.length === 0) {
33
+ return "";
34
+ }
35
+ return ` ${contextParts.join(" ")}`;
36
+ };
16
37
 
17
38
  // Winston doesn't operate like console.log by default, e.g. `logger.error('error',
18
39
  // error)` only prints the message and no args. Add handling for all the args,
19
40
  // while also supporting splat logging.
20
- function printf(timestamp = false) {
41
+ const printf = (timestamp = false) => {
21
42
  return (info: winston.Logform.TransformableInfo) => {
22
43
  const msg = formatWithInspect(info.message);
23
- const splatArgs = (info[Symbol.for("splat") as any] || []) as any[];
24
- const rest = splatArgs.map((data: any) => formatWithInspect(data)).join(" ");
44
+ const splatKey = Symbol.for("splat") as unknown as keyof winston.Logform.TransformableInfo;
45
+ const splatArgs = (info[splatKey] || []) as unknown[];
46
+ const rest = splatArgs.map((data) => formatWithInspect(data)).join(" ");
47
+ const context = formatContext(info);
25
48
  if (timestamp) {
26
- return `${info.timestamp} - ${info.level}: ${msg} ${rest}`;
49
+ return `${info.timestamp} - ${info.level}: ${msg}${context} ${rest}`;
27
50
  }
28
- return `${info.level}: ${msg} ${rest}`;
51
+ return `${info.level}: ${msg}${context} ${rest}`;
29
52
  };
30
- }
53
+ };
31
54
 
32
55
  // Setup a global, default rejection handler.
33
56
  winston.add(
34
57
  new winston.transports.Console({
35
58
  debugStdout: true,
36
59
  format: winston.format.combine(
60
+ addRequestContextFormat(),
37
61
  winston.format.colorize(),
38
62
  winston.format.simple(),
39
63
  winston.format.printf(printf(false))
@@ -46,11 +70,13 @@ winston.add(
46
70
 
47
71
  // Setup a default console logger.
48
72
  export const winstonLogger = winston.createLogger({
73
+ format: addRequestContextFormat(),
49
74
  level: "debug",
50
75
  transports: [
51
76
  new winston.transports.Console({
52
77
  debugStdout: true,
53
78
  format: winston.format.combine(
79
+ addRequestContextFormat(),
54
80
  winston.format.colorize(),
55
81
  winston.format.simple(),
56
82
  winston.format.printf(printf(false))
@@ -63,11 +89,15 @@ export const winstonLogger = winston.createLogger({
63
89
  });
64
90
 
65
91
  // Helper function to send logs to Sentry if enabled
66
- function sendToSentry(message: string, level: "debug" | "info" | "warn" | "error") {
92
+ const sendToSentry = (message: string, level: "debug" | "info" | "warn" | "error"): void => {
67
93
  if (process.env.USE_SENTRY_LOGGING === "true" && Sentry.logger) {
68
- Sentry.logger[level](message);
94
+ const logWithContext = Sentry.logger[level] as (
95
+ message: string,
96
+ attributes?: Record<string, unknown>
97
+ ) => void;
98
+ logWithContext(message, getCurrentLogContext());
69
99
  }
70
- }
100
+ };
71
101
 
72
102
  export const logger = {
73
103
  // simple way to log a caught exception. e.g. promise().catch(logger.catch)
@@ -117,10 +147,10 @@ export interface LoggingOptions {
117
147
  logSlowRequestsWriteMs?: number;
118
148
  }
119
149
 
120
- export function setupLogging(options?: LoggingOptions) {
150
+ export const setupLogging = (options?: LoggingOptions): void => {
121
151
  winstonLogger.clear();
122
152
  if (!options?.disableConsoleLogging) {
123
- const formats: any[] = [winston.format.simple()];
153
+ const formats: winston.Logform.Format[] = [addRequestContextFormat(), winston.format.simple()];
124
154
  if (!options?.disableConsoleColors) {
125
155
  formats.push(winston.format.colorize());
126
156
  }
@@ -143,7 +173,7 @@ export function setupLogging(options?: LoggingOptions) {
143
173
  colorize: false,
144
174
  compress: true,
145
175
  dirname: logDirectory,
146
- format: winston.format.simple(),
176
+ format: winston.format.combine(addRequestContextFormat(), winston.format.simple()),
147
177
  // 30 days of retention
148
178
  maxFiles: 30,
149
179
  // 50MB max file size
@@ -187,4 +217,4 @@ export function setupLogging(options?: LoggingOptions) {
187
217
  winstonLogger.add(transport);
188
218
  }
189
219
  }
190
- }
220
+ };
@@ -1,7 +1,7 @@
1
1
  import mongoose, {type Document} from "mongoose";
2
2
 
3
3
  import type {APIErrorConstructor} from "../errors";
4
- import {createdUpdatedPlugin, findOneOrNone} from "../plugins";
4
+ import {createdUpdatedPlugin, findExactlyOne, findOneOrNone, isDeletedPlugin} from "../plugins";
5
5
 
6
6
  export interface VersionConfigDocument extends mongoose.Document {
7
7
  webWarningVersion: number;
@@ -11,6 +11,8 @@ export interface VersionConfigDocument extends mongoose.Document {
11
11
  warningMessage: string;
12
12
  requiredMessage: string;
13
13
  updateUrl?: string;
14
+ /** How often clients should poll for version updates, in minutes. Defaults to 1440 (24 hours). */
15
+ pollingIntervalMinutes: number;
14
16
  created?: Date;
15
17
  updated?: Date;
16
18
  }
@@ -22,7 +24,7 @@ export interface VersionConfigModel extends mongoose.Model<VersionConfigDocument
22
24
  ): Promise<(Document & VersionConfigDocument) | null>;
23
25
  }
24
26
 
25
- const versionConfigSchema = new mongoose.Schema<VersionConfigDocument>(
27
+ const versionConfigSchema = new mongoose.Schema<VersionConfigDocument, VersionConfigModel>(
26
28
  {
27
29
  mobileRequiredVersion: {
28
30
  default: 0,
@@ -36,6 +38,13 @@ const versionConfigSchema = new mongoose.Schema<VersionConfigDocument>(
36
38
  min: 0,
37
39
  type: Number,
38
40
  },
41
+ pollingIntervalMinutes: {
42
+ default: 1440,
43
+ description:
44
+ "How often clients poll for version updates, in minutes (default: 1440 = 24 hours)",
45
+ min: 1,
46
+ type: Number,
47
+ },
39
48
  requiredMessage: {
40
49
  default: "This version is no longer supported. Please update to continue.",
41
50
  description: "Message shown on the blocking screen",
@@ -84,7 +93,9 @@ const versionConfigSchema = new mongoose.Schema<VersionConfigDocument>(
84
93
  versionConfigSchema.index({_singleton: 1}, {unique: true});
85
94
 
86
95
  versionConfigSchema.plugin(createdUpdatedPlugin);
96
+ versionConfigSchema.plugin(isDeletedPlugin);
87
97
  versionConfigSchema.plugin(findOneOrNone);
98
+ versionConfigSchema.plugin(findExactlyOne);
88
99
 
89
100
  export const VersionConfig = mongoose.model<VersionConfigDocument, VersionConfigModel>(
90
101
  "VersionConfig",
@@ -1,3 +1,4 @@
1
+ // biome-ignore-all lint/suspicious/noExplicitAny: test mock typing
1
2
  import {afterAll, afterEach, beforeEach, describe, expect, it, type Mock, spyOn} from "bun:test";
2
3
  import * as Sentry from "@sentry/bun";
3
4
  import axios from "axios";
@@ -1,18 +1,17 @@
1
1
  import * as Sentry from "@sentry/bun";
2
2
  import axios from "axios";
3
3
 
4
- import {APIError} from "../errors";
4
+ import {APIError, errorMessage} from "../errors";
5
5
  import {logger} from "../logger";
6
6
 
7
7
  export const sendToGoogleChat = async (
8
8
  messageText: string,
9
9
  {channel, shouldThrow = false, env}: {channel?: string; shouldThrow?: boolean; env?: string} = {}
10
- ) => {
10
+ ): Promise<void> => {
11
11
  const chatWebhooksString = process.env.GOOGLE_CHAT_WEBHOOKS;
12
12
  if (!chatWebhooksString) {
13
13
  const msg = "GOOGLE_CHAT_WEBHOOKS not set. Google Chat message not sent";
14
- Sentry.captureException(new Error(msg));
15
- logger.error(msg);
14
+ Sentry.captureException(new APIError({status: 500, title: msg}));
16
15
  return;
17
16
  }
18
17
  const chatWebhooks = JSON.parse(chatWebhooksString ?? "{}");
@@ -22,8 +21,7 @@ export const sendToGoogleChat = async (
22
21
 
23
22
  if (!chatWebhookUrl) {
24
23
  const msg = `No webhook url set in env for ${chatChannel}. Google Chat message not sent`;
25
- Sentry.captureException(new Error(msg));
26
- logger.error(msg);
24
+ Sentry.captureException(new APIError({status: 500, title: msg}));
27
25
  return;
28
26
  }
29
27
 
@@ -35,13 +33,13 @@ export const sendToGoogleChat = async (
35
33
  try {
36
34
  await axios.post(chatWebhookUrl, {text: formattedMessageText});
37
35
  } catch (error: unknown) {
38
- const errorObj = error as {text?: string; message?: string};
39
- logger.error(`Error posting to Google Chat: ${errorObj.text ?? errorObj.message}`);
36
+ const message = errorMessage(error);
37
+ logger.error(`Error posting to Google Chat: ${message}`);
40
38
  Sentry.captureException(error);
41
39
  if (shouldThrow) {
42
40
  throw new APIError({
43
41
  status: 500,
44
- title: `Error posting to Google Chat: ${errorObj.text ?? errorObj.message}`,
42
+ title: `Error posting to Google Chat: ${message}`,
45
43
  });
46
44
  }
47
45
  }
@@ -2,6 +2,7 @@ import {afterAll, afterEach, beforeEach, describe, expect, it, type Mock, spyOn}
2
2
  import * as Sentry from "@sentry/bun";
3
3
  import axios from "axios";
4
4
 
5
+ import {APIError} from "../errors";
5
6
  import {sendToSlack} from "./slackNotifier";
6
7
 
7
8
  describe("sendToSlack", () => {
@@ -10,7 +11,7 @@ describe("sendToSlack", () => {
10
11
  const ORIGINAL_ENV = process.env;
11
12
 
12
13
  beforeEach(() => {
13
- mockAxiosPost = spyOn(axios, "post").mockResolvedValue({status: 200} as any);
14
+ mockAxiosPost = spyOn(axios, "post").mockResolvedValue({status: 200});
14
15
  process.env = {...ORIGINAL_ENV};
15
16
  process.env.SLACK_WEBHOOKS = undefined;
16
17
  (Sentry.captureException as Mock<typeof Sentry.captureException>).mockClear();
@@ -85,6 +86,30 @@ describe("sendToSlack", () => {
85
86
  expect(payload).toEqual({text: "[STG] status ok"});
86
87
  });
87
88
 
89
+ it("reports to Sentry and returns early when channel has no webhook and no default", async () => {
90
+ process.env.SLACK_WEBHOOKS = JSON.stringify({ops: "https://slack.example/ops"});
91
+
92
+ await sendToSlack("orphan message", {slackChannel: "alerts"});
93
+ expect(mockAxiosPost.mock.calls.length).toBe(0);
94
+ expect(
95
+ (Sentry.captureException as Mock<typeof Sentry.captureException>).mock.calls.length
96
+ ).toBe(1);
97
+ const captured = (Sentry.captureException as Mock<typeof Sentry.captureException>).mock
98
+ .calls[0][0] as APIError;
99
+ expect(captured).toBeInstanceOf(APIError);
100
+ expect(captured.title).toContain("alerts");
101
+ });
102
+
103
+ it("posts directly using the url parameter without env lookup", async () => {
104
+ mockAxiosPost.mockResolvedValue({status: 200});
105
+
106
+ await sendToSlack("direct msg", {url: "https://direct.example/hook"});
107
+ expect(mockAxiosPost.mock.calls.length).toBe(1);
108
+ const [url, payload] = mockAxiosPost.mock.calls[0];
109
+ expect(url).toBe("https://direct.example/hook");
110
+ expect(payload).toEqual({text: "direct msg"});
111
+ });
112
+
88
113
  it("captures error and throws APIError when shouldThrow=true", async () => {
89
114
  process.env.SLACK_WEBHOOKS = JSON.stringify({
90
115
  default: "https://slack.example/default",
@@ -95,8 +120,9 @@ describe("sendToSlack", () => {
95
120
  await sendToSlack("err", {shouldThrow: true});
96
121
  throw new Error("Expected sendToSlack to throw APIError");
97
122
  } catch (error) {
98
- expect((error as any).name).toBe("APIError");
99
- expect((error as any).title).toMatch(/Error posting to slack/i);
123
+ const apiError = error as APIError;
124
+ expect(apiError.name).toBe("APIError");
125
+ expect(apiError.title).toMatch(/Error posting to slack/i);
100
126
  }
101
127
  expect(mockAxiosPost.mock.calls.length).toBe(1);
102
128
  });
@@ -1,7 +1,7 @@
1
1
  import * as Sentry from "@sentry/bun";
2
2
  import axios from "axios";
3
3
 
4
- import {APIError} from "../errors";
4
+ import {APIError, errorMessage} from "../errors";
5
5
  import {logger} from "../logger";
6
6
  // Convenience method to send data to a Slack webhook.
7
7
  // If `url` is provided, it will be used directly instead of looking up from environment.
@@ -15,7 +15,7 @@ export const sendToSlack = async (
15
15
  env,
16
16
  url,
17
17
  }: {slackChannel?: string; shouldThrow?: boolean; env?: string; url?: string} = {}
18
- ) => {
18
+ ): Promise<void> => {
19
19
  let slackWebhookUrl = url;
20
20
 
21
21
  if (!slackWebhookUrl) {
@@ -37,9 +37,11 @@ export const sendToSlack = async (
37
37
 
38
38
  if (!slackWebhookUrl) {
39
39
  Sentry.captureException(
40
- new Error(`No webhook url set in env for ${channel}. Slack message not sent`)
40
+ new APIError({
41
+ status: 500,
42
+ title: `No webhook url set in env for ${channel}. Slack message not sent`,
43
+ })
41
44
  );
42
- logger.debug(`No webhook url set in env for ${channel}.`);
43
45
  return;
44
46
  }
45
47
  }
@@ -54,13 +56,13 @@ export const sendToSlack = async (
54
56
  text: formattedText,
55
57
  });
56
58
  } catch (error: unknown) {
57
- const errorObj = error as {text?: string; message?: string};
58
- logger.error(`Error posting to slack: ${errorObj.text ?? errorObj.message}`);
59
+ const message = errorMessage(error);
60
+ logger.error(`Error posting to slack: ${message}`);
59
61
  Sentry.captureException(error);
60
62
  if (shouldThrow) {
61
63
  throw new APIError({
62
64
  status: 500,
63
- title: `Error posting to slack: ${errorObj.text ?? errorObj.message}`,
65
+ title: `Error posting to slack: ${message}`,
64
66
  });
65
67
  }
66
68
  }
@@ -1,3 +1,4 @@
1
+ // biome-ignore-all lint/suspicious/noExplicitAny: test mock typing
1
2
  import {afterAll, afterEach, beforeEach, describe, expect, it, type Mock, spyOn} from "bun:test";
2
3
  import * as Sentry from "@sentry/bun";
3
4
  import axios from "axios";
@@ -1,7 +1,7 @@
1
1
  import * as Sentry from "@sentry/bun";
2
2
  import axios from "axios";
3
3
 
4
- import {APIError} from "../errors";
4
+ import {APIError, errorMessage} from "../errors";
5
5
  import {logger} from "../logger";
6
6
 
7
7
  /**
@@ -32,12 +32,11 @@ import {logger} from "../logger";
32
32
  export const sendToZoom = async (
33
33
  {header, body, subheader}: {header: string; body: string; subheader?: string},
34
34
  {channel, shouldThrow = false, env}: {channel: string; shouldThrow?: boolean; env?: string}
35
- ) => {
35
+ ): Promise<void> => {
36
36
  const zoomWebhooksString = process.env.ZOOM_CHAT_WEBHOOKS;
37
37
  if (!zoomWebhooksString) {
38
38
  const msg = "ZOOM_CHAT_WEBHOOKS not set. Zoom message not sent";
39
- Sentry.captureException(new Error(msg));
40
- logger.error(msg);
39
+ Sentry.captureException(new APIError({status: 500, title: msg}));
41
40
  return;
42
41
  }
43
42
  const zoomWebhooks: Record<string, {channel: string; verificationToken: string}> = JSON.parse(
@@ -49,8 +48,7 @@ export const sendToZoom = async (
49
48
 
50
49
  if (!zoomWebhookUrl) {
51
50
  const msg = `No webhook url set in env for ${zoomChannel}. Zoom message not sent`;
52
- Sentry.captureException(new Error(msg));
53
- logger.error(msg);
51
+ Sentry.captureException(new APIError({status: 500, title: msg}));
54
52
  return;
55
53
  }
56
54
 
@@ -58,8 +56,7 @@ export const sendToZoom = async (
58
56
  zoomWebhooks[zoomChannel]?.verificationToken ?? zoomWebhooks.default?.verificationToken;
59
57
  if (!zoomToken) {
60
58
  const msg = `No verification token set in env for ${zoomChannel}. Zoom message not sent`;
61
- Sentry.captureException(new Error(msg));
62
- logger.error(msg);
59
+ Sentry.captureException(new APIError({status: 500, title: msg}));
63
60
  return;
64
61
  }
65
62
 
@@ -96,13 +93,13 @@ export const sendToZoom = async (
96
93
  }
97
94
  );
98
95
  } catch (error: unknown) {
99
- const errorMessage = error instanceof Error ? error.message : String(error);
100
- logger.error(`Error posting to Zoom: ${errorMessage}`);
96
+ const message = errorMessage(error);
97
+ logger.error(`Error posting to Zoom: ${message}`);
101
98
  Sentry.captureException(error);
102
99
  if (shouldThrow) {
103
100
  throw new APIError({
104
101
  status: 500,
105
- title: `Error posting to Zoom: ${errorMessage}`,
102
+ title: `Error posting to Zoom: ${message}`,
106
103
  });
107
104
  }
108
105
  }
@@ -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 type {Router} from "express";