@terreno/api 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +170 -0
  3. package/biome.jsonc +22 -0
  4. package/bunfig.toml +4 -0
  5. package/dist/api.d.ts +227 -0
  6. package/dist/api.js +1024 -0
  7. package/dist/api.test.d.ts +1 -0
  8. package/dist/api.test.js +2143 -0
  9. package/dist/auth.d.ts +50 -0
  10. package/dist/auth.js +512 -0
  11. package/dist/auth.test.d.ts +1 -0
  12. package/dist/auth.test.js +778 -0
  13. package/dist/errors.d.ts +75 -0
  14. package/dist/errors.js +216 -0
  15. package/dist/example.d.ts +1 -0
  16. package/dist/example.js +118 -0
  17. package/dist/expressServer.d.ts +35 -0
  18. package/dist/expressServer.js +436 -0
  19. package/dist/index.d.ts +14 -0
  20. package/dist/index.js +30 -0
  21. package/dist/logger.d.ts +23 -0
  22. package/dist/logger.js +249 -0
  23. package/dist/middleware.d.ts +10 -0
  24. package/dist/middleware.js +52 -0
  25. package/dist/notifiers/googleChatNotifier.d.ts +5 -0
  26. package/dist/notifiers/googleChatNotifier.js +130 -0
  27. package/dist/notifiers/googleChatNotifier.test.d.ts +1 -0
  28. package/dist/notifiers/googleChatNotifier.test.js +260 -0
  29. package/dist/notifiers/index.d.ts +3 -0
  30. package/dist/notifiers/index.js +19 -0
  31. package/dist/notifiers/slackNotifier.d.ts +5 -0
  32. package/dist/notifiers/slackNotifier.js +130 -0
  33. package/dist/notifiers/slackNotifier.test.d.ts +1 -0
  34. package/dist/notifiers/slackNotifier.test.js +259 -0
  35. package/dist/notifiers/zoomNotifier.d.ts +34 -0
  36. package/dist/notifiers/zoomNotifier.js +181 -0
  37. package/dist/notifiers/zoomNotifier.test.d.ts +1 -0
  38. package/dist/notifiers/zoomNotifier.test.js +370 -0
  39. package/dist/openApi.d.ts +60 -0
  40. package/dist/openApi.js +441 -0
  41. package/dist/openApi.test.d.ts +1 -0
  42. package/dist/openApi.test.js +445 -0
  43. package/dist/openApiBuilder.d.ts +419 -0
  44. package/dist/openApiBuilder.js +424 -0
  45. package/dist/openApiBuilder.test.d.ts +1 -0
  46. package/dist/openApiBuilder.test.js +509 -0
  47. package/dist/openApiEtag.d.ts +7 -0
  48. package/dist/openApiEtag.js +38 -0
  49. package/dist/permissions.d.ts +26 -0
  50. package/dist/permissions.js +331 -0
  51. package/dist/permissions.test.d.ts +1 -0
  52. package/dist/permissions.test.js +413 -0
  53. package/dist/plugins.d.ts +67 -0
  54. package/dist/plugins.js +315 -0
  55. package/dist/plugins.test.d.ts +1 -0
  56. package/dist/plugins.test.js +639 -0
  57. package/dist/populate.d.ts +14 -0
  58. package/dist/populate.js +315 -0
  59. package/dist/populate.test.d.ts +1 -0
  60. package/dist/populate.test.js +133 -0
  61. package/dist/response.d.ts +0 -0
  62. package/dist/response.js +1 -0
  63. package/dist/tests/bunSetup.d.ts +1 -0
  64. package/dist/tests/bunSetup.js +297 -0
  65. package/dist/tests/index.d.ts +1 -0
  66. package/dist/tests/index.js +17 -0
  67. package/dist/tests.d.ts +99 -0
  68. package/dist/tests.js +273 -0
  69. package/dist/transformers.d.ts +25 -0
  70. package/dist/transformers.js +217 -0
  71. package/dist/transformers.test.d.ts +1 -0
  72. package/dist/transformers.test.js +370 -0
  73. package/dist/utils.d.ts +11 -0
  74. package/dist/utils.js +143 -0
  75. package/dist/utils.test.d.ts +1 -0
  76. package/dist/utils.test.js +14 -0
  77. package/index.ts +1 -0
  78. package/package.json +88 -0
  79. package/src/__snapshots__/openApi.test.ts.snap +4814 -0
  80. package/src/__snapshots__/openApiBuilder.test.ts.snap +1485 -0
  81. package/src/api.test.ts +1661 -0
  82. package/src/api.ts +1036 -0
  83. package/src/auth.test.ts +550 -0
  84. package/src/auth.ts +408 -0
  85. package/src/errors.ts +225 -0
  86. package/src/example.ts +99 -0
  87. package/src/express.d.ts +5 -0
  88. package/src/expressServer.ts +387 -0
  89. package/src/index.ts +14 -0
  90. package/src/logger.ts +190 -0
  91. package/src/middleware.ts +18 -0
  92. package/src/notifiers/googleChatNotifier.test.ts +114 -0
  93. package/src/notifiers/googleChatNotifier.ts +47 -0
  94. package/src/notifiers/index.ts +3 -0
  95. package/src/notifiers/slackNotifier.test.ts +113 -0
  96. package/src/notifiers/slackNotifier.ts +55 -0
  97. package/src/notifiers/zoomNotifier.test.ts +207 -0
  98. package/src/notifiers/zoomNotifier.ts +111 -0
  99. package/src/openApi.test.ts +331 -0
  100. package/src/openApi.ts +494 -0
  101. package/src/openApiBuilder.test.ts +442 -0
  102. package/src/openApiBuilder.ts +636 -0
  103. package/src/openApiEtag.ts +40 -0
  104. package/src/permissions.test.ts +219 -0
  105. package/src/permissions.ts +228 -0
  106. package/src/plugins.test.ts +390 -0
  107. package/src/plugins.ts +289 -0
  108. package/src/populate.test.ts +65 -0
  109. package/src/populate.ts +258 -0
  110. package/src/response.ts +0 -0
  111. package/src/tests/bunSetup.ts +234 -0
  112. package/src/tests/index.ts +1 -0
  113. package/src/tests.ts +218 -0
  114. package/src/transformers.test.ts +202 -0
  115. package/src/transformers.ts +170 -0
  116. package/src/utils.test.ts +14 -0
  117. package/src/utils.ts +47 -0
  118. package/tsconfig.json +60 -0
  119. package/types.d.ts +17 -0
package/src/auth.ts ADDED
@@ -0,0 +1,408 @@
1
+ import express from "express";
2
+ import jwt, {type JwtPayload} from "jsonwebtoken";
3
+ import type {Model, ObjectId} from "mongoose";
4
+ import ms, {type StringValue} from "ms";
5
+ import passport from "passport";
6
+ import {Strategy as AnonymousStrategy} from "passport-anonymous";
7
+ import {
8
+ type JwtFromRequestFunction,
9
+ Strategy as JwtStrategy,
10
+ type StrategyOptions,
11
+ } from "passport-jwt";
12
+ import {Strategy as LocalStrategy} from "passport-local";
13
+
14
+ import {APIError, apiErrorMiddleware} from "./errors";
15
+ import type {AuthOptions} from "./expressServer";
16
+ import {logger} from "./logger";
17
+
18
+ export interface User {
19
+ _id: ObjectId | string;
20
+ id: string;
21
+ // Whether the user should be treated as an admin or not.
22
+ // Admins can have extra abilities in permissions declarations
23
+ admin: boolean;
24
+ /**
25
+ * We support anonymous users, which do not yet have login information.
26
+ * This can be helpful for pre-signup users.
27
+ */
28
+ isAnonymous?: boolean;
29
+ }
30
+
31
+ export interface UserModel extends Model<User> {
32
+ createAnonymousUser?: (id?: string) => Promise<User>;
33
+ // Allows additional setup during signup. This will be passed the rest of req.body from the signup
34
+ postCreate?: (body: any) => Promise<void>;
35
+
36
+ createStrategy(): any;
37
+ serializeUser(): any;
38
+ deserializeUser(): any;
39
+ findByUsername(username: string, findOpts: any): any;
40
+ }
41
+
42
+ export function authenticateMiddleware(anonymous = false) {
43
+ const strategies = ["jwt"];
44
+ if (anonymous) {
45
+ strategies.push("anonymous");
46
+ }
47
+ return passport.authenticate(strategies, {
48
+ failureMessage: false, // this is just avoiding storing the message in the session
49
+ failWithError: true,
50
+ session: false,
51
+ });
52
+ }
53
+
54
+ export async function signupUser(
55
+ userModel: UserModel,
56
+ email: string,
57
+ password: string,
58
+ body?: any
59
+ ) {
60
+ // Strip email and password from the body. They can cause mongoose to throw an error if strict is
61
+ // set.
62
+ const {email: _email, password: _password, ...bodyRest} = body;
63
+
64
+ try {
65
+ const user = await (userModel as any).register({email, ...bodyRest}, password);
66
+
67
+ if (user.postCreate) {
68
+ try {
69
+ await user.postCreate(bodyRest);
70
+ } catch (error: any) {
71
+ logger.error(`Error in user.postCreate: ${error}`);
72
+ throw error;
73
+ }
74
+ }
75
+ await user.save();
76
+ return user;
77
+ } catch (error: any) {
78
+ throw new APIError({title: error.message});
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Generates both an access token (JWT) and a refresh token for a given user.
84
+ *
85
+ * This function:
86
+ * - Signs the user's `_id` into a short-lived JWT (`token`)
87
+ * and a long-lived refresh token (`refreshToken`).
88
+ * - Supports custom expiration logic
89
+ * and payload customization via `AuthOptions`.
90
+ * - Reads token secrets, issuer,
91
+ * and default expirations from environment variables.
92
+ * - Returns `{ token, refreshToken }`,
93
+ * or `{ token: null, refreshToken: null }` if the user is missing.
94
+ *
95
+ * It is exported to allow external implementations (such as OAuth integrations or other
96
+ * authentication providers) to reuse and customize the same token generation logic.
97
+ * This ensures consistent and secure token issuance across different authentication flows.
98
+ */
99
+ export const generateTokens = async (user: any, authOptions?: AuthOptions) => {
100
+ const tokenSecretOrKey = process.env.TOKEN_SECRET;
101
+ if (!tokenSecretOrKey) {
102
+ throw new Error("TOKEN_SECRET must be set in env.");
103
+ }
104
+ if (!user?._id) {
105
+ logger.warn("No user found for token generation");
106
+ return {refreshToken: null, token: null};
107
+ }
108
+ let payload: Record<string, any> = {id: user._id.toString()};
109
+ if (authOptions?.generateJWTPayload) {
110
+ payload = {...authOptions.generateJWTPayload(user), ...payload};
111
+ }
112
+ const tokenOptions: jwt.SignOptions = {
113
+ expiresIn: "15m",
114
+ };
115
+ if (authOptions?.generateTokenExpiration) {
116
+ tokenOptions.expiresIn = authOptions.generateTokenExpiration(user);
117
+ } else if (process.env.TOKEN_EXPIRES_IN) {
118
+ try {
119
+ // this call to ms is purely for validation of the env variable. If it is invalid,
120
+ // we want to be able to log the error and use the default.
121
+ ms(process.env.TOKEN_EXPIRES_IN as StringValue);
122
+ tokenOptions.expiresIn = process.env.TOKEN_EXPIRES_IN as StringValue;
123
+ } catch (error) {
124
+ // This error will result in using the default value above of 15m.
125
+ logger.error(error as string);
126
+ }
127
+ }
128
+ if (process.env.TOKEN_ISSUER) {
129
+ tokenOptions.issuer = process.env.TOKEN_ISSUER;
130
+ }
131
+
132
+ const token = jwt.sign(payload, tokenSecretOrKey, tokenOptions);
133
+ const refreshTokenSecretOrKey = process.env.REFRESH_TOKEN_SECRET;
134
+ let refreshToken;
135
+ if (refreshTokenSecretOrKey) {
136
+ const refreshTokenOptions: jwt.SignOptions = {
137
+ expiresIn: "30d",
138
+ };
139
+ if (authOptions?.generateRefreshTokenExpiration) {
140
+ refreshTokenOptions.expiresIn = authOptions.generateRefreshTokenExpiration(user);
141
+ } else if (process.env.REFRESH_TOKEN_EXPIRES_IN) {
142
+ try {
143
+ // this call to ms is purely for validation of the env variable. If it is invalid,
144
+ // we want to be able to log the error and use the default.
145
+ ms(process.env.REFRESH_TOKEN_EXPIRES_IN as StringValue);
146
+ refreshTokenOptions.expiresIn = process.env.REFRESH_TOKEN_EXPIRES_IN as StringValue;
147
+ } catch (error) {
148
+ // This error will result in using the default value above of 30d.
149
+ logger.error(error as string);
150
+ }
151
+ }
152
+ refreshToken = jwt.sign(payload, refreshTokenSecretOrKey, refreshTokenOptions);
153
+ } else {
154
+ logger.info("REFRESH_TOKEN_SECRET not set so refresh tokens will not be issued");
155
+ }
156
+ return {refreshToken, token};
157
+ };
158
+
159
+ // TODO allow customization
160
+ export function setupAuth(app: express.Application, userModel: UserModel) {
161
+ passport.use(new AnonymousStrategy());
162
+ passport.use(userModel.createStrategy());
163
+ passport.use(
164
+ "signup",
165
+ new LocalStrategy(
166
+ {
167
+ passReqToCallback: true,
168
+ passwordField: "password",
169
+ usernameField: "email",
170
+ },
171
+ async (req, email, password, done) => {
172
+ try {
173
+ done(undefined, await signupUser(userModel, email, password, req.body));
174
+ } catch (error) {
175
+ return done(error);
176
+ }
177
+ }
178
+ ) as passport.Strategy
179
+ );
180
+
181
+ if (!userModel.createStrategy) {
182
+ throw new Error("setupAuth userModel must have .createStrategy()");
183
+ }
184
+
185
+ const customTokenExtractor: JwtFromRequestFunction = (req) => {
186
+ let token: string | null = null;
187
+ if (req?.cookies?.jwt) {
188
+ token = req.cookies.jwt;
189
+ } else if (req?.headers?.authorization) {
190
+ token = req?.headers?.authorization.split(" ")[1];
191
+ }
192
+ return token;
193
+ };
194
+
195
+ if (process.env.TOKEN_SECRET) {
196
+ if (process.env.NODE_ENV !== "test") {
197
+ logger.debug("Setting up JWT Authentication");
198
+ }
199
+
200
+ const secretOrKey = process.env.TOKEN_SECRET;
201
+ if (!secretOrKey) {
202
+ throw new Error("TOKEN_SECRET must be set in env.");
203
+ }
204
+ const jwtOpts: StrategyOptions = {
205
+ issuer: process.env.TOKEN_ISSUER,
206
+ jwtFromRequest: customTokenExtractor,
207
+ secretOrKey,
208
+ };
209
+ passport.use(
210
+ "jwt",
211
+ new JwtStrategy(jwtOpts, async (jwtPayload: JwtPayload, done) => {
212
+ let user;
213
+ if (!jwtPayload) {
214
+ return done(null, false);
215
+ }
216
+ try {
217
+ user = await userModel.findById(jwtPayload.id);
218
+ } catch (error) {
219
+ logger.warn(`[jwt] Error finding user from id: ${error}`);
220
+ return done(error, false);
221
+ }
222
+ if (user) {
223
+ return done(null, user);
224
+ }
225
+ if (userModel.createAnonymousUser) {
226
+ logger.info("[jwt] Creating anonymous user");
227
+ user = await userModel.createAnonymousUser();
228
+ return done(null, user);
229
+ }
230
+ logger.info("[jwt] No user found from token");
231
+ return done(null, false);
232
+ }) as passport.Strategy
233
+ );
234
+ }
235
+
236
+ // Adds req.user to the request. This may wind up duplicating requests with passport,
237
+ // but passport doesn't give us req.user early enough.
238
+ async function decodeJWTMiddleware(req, res, next) {
239
+ if (!process.env.TOKEN_SECRET) {
240
+ return next();
241
+ }
242
+
243
+ // Allow requests with a "Secret" prefix to pass through since this is a string value,
244
+ // not a jwt that needs to be decoded
245
+ if (req?.headers?.authorization?.split(" ")[0] === "Secret") {
246
+ return next();
247
+ }
248
+
249
+ const token = customTokenExtractor(req);
250
+
251
+ // For some reason, our app will happily put null into the authorization header when logging
252
+ // out then back in.
253
+ if (!token || token === "null" || token === "undefined") {
254
+ return next();
255
+ }
256
+
257
+ let decoded;
258
+
259
+ try {
260
+ decoded = jwt.verify(token, process.env.TOKEN_SECRET, {
261
+ issuer: process.env.TOKEN_ISSUER,
262
+ }) as jwt.JwtPayload;
263
+ } catch (error: any) {
264
+ const userText = req.user?._id ? ` for user ${req.user._id} ` : "";
265
+ const details = `[jwt] Error decoding token${userText}: ${error}, expired at ${error?.expiredAt}, current time: ${Date.now()}`;
266
+ logger.debug(details);
267
+ return res.status(401).json({details, message: error?.message});
268
+ }
269
+ if (decoded.id) {
270
+ try {
271
+ req.user = await userModel.findById(decoded.id);
272
+ if (req.user?.disabled) {
273
+ logger.warn(`[jwt] User ${req.user.id} is disabled`);
274
+ return res.status(401).json({status: 401, title: "User is disabled"});
275
+ }
276
+ } catch (error) {
277
+ logger.warn(`[jwt] Error finding user from id: ${error}`);
278
+ }
279
+ }
280
+ return next();
281
+ }
282
+ app.use(decodeJWTMiddleware);
283
+ app.use(express.urlencoded({extended: false}) as any);
284
+ }
285
+
286
+ export function addAuthRoutes(
287
+ app: express.Application,
288
+ userModel: UserModel,
289
+ authOptions?: AuthOptions
290
+ ): void {
291
+ const router = express.Router();
292
+ router.post("/login", async (req, res, next) => {
293
+ passport.authenticate("local", {session: false}, async (err: any, user: any, info: any) => {
294
+ if (err) {
295
+ logger.error(`Error logging in: ${err}`);
296
+ return next(err);
297
+ }
298
+ if (!user) {
299
+ logger.warn(`Invalid login: ${info}`);
300
+ return res.status(401).json({message: info?.message});
301
+ }
302
+ logger.info(`User logged in: ${user._id}, type: ${(user as any).type || "N/A"}`);
303
+ const tokens = await generateTokens(user, authOptions);
304
+ return res.json({
305
+ data: {refreshToken: tokens.refreshToken, token: tokens.token, userId: user?._id},
306
+ });
307
+ })(req, res, next);
308
+ });
309
+
310
+ router.post("/refresh_token", async (req, res) => {
311
+ if (!req.body.refreshToken) {
312
+ logger.error(
313
+ `No refresh token provided, must provide refreshToken in body, user id: ${req.user?.id}`
314
+ );
315
+ return res
316
+ .status(401)
317
+ .json({message: "No refresh token provided, must provide refreshToken in body"});
318
+ }
319
+ if (!process.env.REFRESH_TOKEN_SECRET) {
320
+ logger.error(`No REFRESH_TOKEN_SECRET set, cannot refresh token, user id: ${req.user?.id}`);
321
+ return res.status(401).json({message: "No REFRESH_TOKEN_SECRET set, cannot refresh token"});
322
+ }
323
+ const refreshTokenSecretOrKey = process.env.REFRESH_TOKEN_SECRET;
324
+ let decoded;
325
+ try {
326
+ decoded = jwt.verify(req.body.refreshToken, refreshTokenSecretOrKey) as JwtPayload;
327
+ } catch (error: any) {
328
+ logger.error(`Error refreshing token for user ${req.user?.id}: ${error}`);
329
+ return res.status(401).json({message: error?.message});
330
+ }
331
+ if (decoded?.id) {
332
+ const user = await userModel.findById(decoded.id);
333
+ const tokens = await generateTokens(user, authOptions);
334
+ logger.debug(`Refreshed token for ${user?.id}`);
335
+ return res.json({data: {refreshToken: tokens.refreshToken, token: tokens.token}});
336
+ }
337
+ logger.error(`Invalid refresh token, user id: ${req.user?.id}`);
338
+ return res.status(401).json({message: "Invalid refresh token"});
339
+ });
340
+
341
+ const signupDisabled = process.env.SIGNUP_DISABLED === "true";
342
+ if (!signupDisabled) {
343
+ router.post(
344
+ "/signup",
345
+ passport.authenticate("signup", {failWithError: true, session: false}),
346
+ async (req: any, res: any) => {
347
+ const tokens = await generateTokens(req.user, authOptions);
348
+ return res.json({
349
+ data: {refreshToken: tokens.refreshToken, token: tokens.token, userId: req.user._id},
350
+ });
351
+ }
352
+ );
353
+ }
354
+ app.set("etag", false);
355
+ app.use("/auth", router);
356
+ }
357
+
358
+ export function addMeRoutes(
359
+ app: express.Application,
360
+ userModel: UserModel,
361
+ _authOptions?: AuthOptions
362
+ ): void {
363
+ const router = express.Router();
364
+ router.get("/me", authenticateMiddleware(), async (req, res) => {
365
+ if (!req.user?.id) {
366
+ logger.debug("Not user found for /me");
367
+ return res.sendStatus(401);
368
+ }
369
+ const data = await userModel.findById(req.user.id);
370
+ if (!data) {
371
+ logger.debug("Not user data found for /me");
372
+ return res.sendStatus(404);
373
+ }
374
+ const dataObject = data.toObject();
375
+ (dataObject as any).id = data._id;
376
+ return res.json({data: dataObject});
377
+ });
378
+
379
+ router.patch("/me", authenticateMiddleware(), async (req, res) => {
380
+ if (!req.user?.id) {
381
+ return res.sendStatus(401);
382
+ }
383
+ const doc = await userModel.findById(req.user.id);
384
+ if (!doc) {
385
+ return res.sendStatus(404);
386
+ }
387
+ // TODO support limited updates for profile.
388
+ // try {
389
+ // body = transform(req.body, "update", req.user);
390
+ // } catch (e) {
391
+ // return res.status(403).send({message: (e as any).message});
392
+ // }
393
+ try {
394
+ Object.assign(doc, req.body);
395
+ await doc.save();
396
+
397
+ const dataObject = doc.toObject();
398
+ (dataObject as any).id = doc._id;
399
+ return res.json({data: dataObject});
400
+ } catch (error) {
401
+ return res.status(403).send({message: (error as any).message});
402
+ }
403
+ });
404
+
405
+ app.set("etag", false);
406
+ app.use("/auth", router);
407
+ app.use(apiErrorMiddleware);
408
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,225 @@
1
+ // https://jsonapi.org/format/#errors
2
+ import * as Sentry from "@sentry/node";
3
+ import type {NextFunction, Request, Response} from "express";
4
+ import {Schema} from "mongoose";
5
+
6
+ import {logger} from "./logger";
7
+
8
+ export interface APIErrorConstructor {
9
+ // Required. A short, human-readable summary of the problem that SHOULD NOT change from
10
+ // occurrence to occurrence of the problem, except for purposes of localization.
11
+ title: string;
12
+
13
+ // error messages to be displayed by a field in a form. this isn't in the JSONAPI spec.
14
+ // It will be folded into `meta` as `meta.fields` in the actual error payload.
15
+ // This is helpful to add it to the TS interface for ApiError.
16
+ fields?: {[id: string]: string};
17
+
18
+ // A unique identifier for this particular occurrence of the problem.
19
+ id?: string;
20
+ // A links object containing the following members:
21
+ links?: {about?: string; type?: string} | undefined;
22
+ // The HTTP status code applicable to this problem. defaults to 500. must be between 400 and 599.
23
+ status?: number;
24
+ // An application-specific error code, expressed as a string value.
25
+ code?: string;
26
+
27
+ // A human-readable explanation specific to this occurrence of the problem. Like title,
28
+ // this field’s value can be localized.
29
+ detail?: string;
30
+ // An object containing references to the source of the error,
31
+ // optionally including any of the following members:
32
+ source?: {
33
+ // pointer: a JSON Pointer [RFC6901] to the value in the request document that caused the error
34
+ // [e.g. "/data" for a primary data object, or "/data/attributes/title" for a specific
35
+ // attribute]. This MUST point to a value in the request document that exists; if it doesn’t,
36
+ // the client SHOULD simply ignore the pointer.
37
+ pointer?: string;
38
+ // a string indicating which URI query parameter caused the error.
39
+ parameter?: string;
40
+ // a string indicating the name of a single request header which caused the error.
41
+ header?: string;
42
+ };
43
+ // A meta object containing non-standard meta-information about the error.
44
+ meta?: {[id: string]: string};
45
+ error?: Error;
46
+ // If true, this error will not be sent to external error reporting tools like Sentry.
47
+ disableExternalErrorTracking?: boolean;
48
+ }
49
+
50
+ /**
51
+ * APIError is a simple way to throw an error in an API route and control what is shown and the
52
+ * HTTP code displayed. It follows the JSONAPI spec to standardize the fields,
53
+ * allowing the UI to show more consistent, better error messages.
54
+ *
55
+ * ```ts
56
+ * throw new APIError({
57
+ * title: "Only an admin can update that!",
58
+ * status: 403,
59
+ * code: "update-admin-error",
60
+ * detail: "You must be an admin to change that field"
61
+ * });
62
+ * ```
63
+ */
64
+ export class APIError extends Error {
65
+ title: string;
66
+
67
+ id: string | undefined;
68
+
69
+ links: {about?: string; type?: string} | undefined;
70
+
71
+ status: number;
72
+
73
+ code: string | undefined;
74
+
75
+ detail: string | undefined;
76
+
77
+ source:
78
+ | {
79
+ pointer?: string;
80
+ parameter?: string;
81
+ header?: string;
82
+ }
83
+ | undefined;
84
+
85
+ meta: {[id: string]: any} | undefined;
86
+
87
+ error?: Error;
88
+
89
+ disableExternalErrorTracking?: boolean;
90
+
91
+ constructor(data: APIErrorConstructor) {
92
+ // Include details in when the error is printed to the console or sent to Sentry.
93
+ super(
94
+ `${data.title}${data.detail ? `: ${data.detail}` : ""}${
95
+ data.error ? `\n${data.error.stack}` : ""
96
+ }`
97
+ );
98
+ this.name = "APIError";
99
+
100
+ let {title, id, links, status, code, detail, source, meta, fields, error} = data;
101
+
102
+ if (!status) {
103
+ status = 500;
104
+ } else if (status && (status < 400 || status > 599)) {
105
+ logger.error(`Invalid ApiError status code: ${status}, using 500`);
106
+ status = 500;
107
+ }
108
+ this.status = status;
109
+
110
+ this.title = title;
111
+ this.id = id;
112
+ this.links = links;
113
+
114
+ this.code = code;
115
+ this.detail = detail;
116
+ this.source = source;
117
+ this.meta = meta ?? {};
118
+ this.disableExternalErrorTracking = data.disableExternalErrorTracking;
119
+ if (fields) {
120
+ this.meta.fields = fields;
121
+ }
122
+ this.error = error;
123
+ logger.error(
124
+ `APIError(${status}): ${title} ${detail ? detail : ""}${
125
+ data.error?.stack ? `\n${data.error?.stack}` : ""
126
+ }`
127
+ );
128
+ }
129
+ }
130
+
131
+ // This can be attached to any schema to store errors compatible with the JSONAPI spec.
132
+ // Lazily initialize to avoid module loading order issues with Bun where mongoose
133
+ // may not be fully initialized when this module loads.
134
+
135
+ // Create an errors field for storing error information in a JSONAPI compatible form directly on a
136
+ // model.
137
+ export function errorsPlugin(schema: Schema): void {
138
+ const errorSchema = new Schema({
139
+ code: String,
140
+ detail: String,
141
+ id: String,
142
+ links: {
143
+ about: String,
144
+ type: String,
145
+ },
146
+ meta: Schema.Types.Mixed,
147
+ source: {
148
+ header: String,
149
+ parameter: String,
150
+ pointer: String,
151
+ },
152
+ status: Number,
153
+ title: {required: true, type: String},
154
+ });
155
+
156
+ schema.add({apiErrors: errorSchema});
157
+ }
158
+
159
+ export function isAPIError(error: Error): error is APIError {
160
+ return error.name === "APIError";
161
+ }
162
+
163
+ /**
164
+ * Safely extracts the disableExternalErrorTracking property from an error.
165
+ * Works with both APIError instances and regular Error objects that may have
166
+ * this property attached.
167
+ */
168
+ export function getDisableExternalErrorTracking(error: unknown): boolean | undefined {
169
+ if (error instanceof Error) {
170
+ if (isAPIError(error)) {
171
+ return error.disableExternalErrorTracking;
172
+ }
173
+ }
174
+ if (error && typeof error === "object" && "disableExternalErrorTracking" in error) {
175
+ return (error as {disableExternalErrorTracking?: boolean}).disableExternalErrorTracking;
176
+ }
177
+ return undefined;
178
+ }
179
+
180
+ // Creates an APIError body to send to clients as JSON. Errors don't have a toJSON defined,
181
+ // and we want to strip out things like message, name, and stack for the client.
182
+ // There is almost certainly a more elegant solution to this.
183
+ export function getAPIErrorBody(error: APIError): {[id: string]: any} {
184
+ const errorData = {status: error.status, title: error.title};
185
+ for (const key of [
186
+ "id",
187
+ "links",
188
+ "status",
189
+ "code",
190
+ "detail",
191
+ "source",
192
+ "meta",
193
+ "disableExternalErrorTracking",
194
+ ]) {
195
+ if (error[key]) {
196
+ errorData[key] = error[key];
197
+ }
198
+ }
199
+ return errorData;
200
+ }
201
+
202
+ export function apiUnauthorizedMiddleware(
203
+ err: Error,
204
+ _req: Request,
205
+ res: Response,
206
+ next: NextFunction
207
+ ) {
208
+ if (err.message === "Unauthorized") {
209
+ // not using the actual APIError class here because we don't want to log it as an error.
210
+ res.status(401).json({status: 401, title: "Unauthorized"}).send();
211
+ } else {
212
+ next(err);
213
+ }
214
+ }
215
+
216
+ export function apiErrorMiddleware(err: Error, _req: Request, res: Response, next: NextFunction) {
217
+ if (isAPIError(err)) {
218
+ if (!err.disableExternalErrorTracking) {
219
+ Sentry.captureException(err);
220
+ }
221
+ res.status(err.status).json(getAPIErrorBody(err)).send();
222
+ } else {
223
+ next(err);
224
+ }
225
+ }