@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.
- package/LICENSE +202 -0
- package/README.md +170 -0
- package/biome.jsonc +22 -0
- package/bunfig.toml +4 -0
- package/dist/api.d.ts +227 -0
- package/dist/api.js +1024 -0
- package/dist/api.test.d.ts +1 -0
- package/dist/api.test.js +2143 -0
- package/dist/auth.d.ts +50 -0
- package/dist/auth.js +512 -0
- package/dist/auth.test.d.ts +1 -0
- package/dist/auth.test.js +778 -0
- package/dist/errors.d.ts +75 -0
- package/dist/errors.js +216 -0
- package/dist/example.d.ts +1 -0
- package/dist/example.js +118 -0
- package/dist/expressServer.d.ts +35 -0
- package/dist/expressServer.js +436 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +30 -0
- package/dist/logger.d.ts +23 -0
- package/dist/logger.js +249 -0
- package/dist/middleware.d.ts +10 -0
- package/dist/middleware.js +52 -0
- package/dist/notifiers/googleChatNotifier.d.ts +5 -0
- package/dist/notifiers/googleChatNotifier.js +130 -0
- package/dist/notifiers/googleChatNotifier.test.d.ts +1 -0
- package/dist/notifiers/googleChatNotifier.test.js +260 -0
- package/dist/notifiers/index.d.ts +3 -0
- package/dist/notifiers/index.js +19 -0
- package/dist/notifiers/slackNotifier.d.ts +5 -0
- package/dist/notifiers/slackNotifier.js +130 -0
- package/dist/notifiers/slackNotifier.test.d.ts +1 -0
- package/dist/notifiers/slackNotifier.test.js +259 -0
- package/dist/notifiers/zoomNotifier.d.ts +34 -0
- package/dist/notifiers/zoomNotifier.js +181 -0
- package/dist/notifiers/zoomNotifier.test.d.ts +1 -0
- package/dist/notifiers/zoomNotifier.test.js +370 -0
- package/dist/openApi.d.ts +60 -0
- package/dist/openApi.js +441 -0
- package/dist/openApi.test.d.ts +1 -0
- package/dist/openApi.test.js +445 -0
- package/dist/openApiBuilder.d.ts +419 -0
- package/dist/openApiBuilder.js +424 -0
- package/dist/openApiBuilder.test.d.ts +1 -0
- package/dist/openApiBuilder.test.js +509 -0
- package/dist/openApiEtag.d.ts +7 -0
- package/dist/openApiEtag.js +38 -0
- package/dist/permissions.d.ts +26 -0
- package/dist/permissions.js +331 -0
- package/dist/permissions.test.d.ts +1 -0
- package/dist/permissions.test.js +413 -0
- package/dist/plugins.d.ts +67 -0
- package/dist/plugins.js +315 -0
- package/dist/plugins.test.d.ts +1 -0
- package/dist/plugins.test.js +639 -0
- package/dist/populate.d.ts +14 -0
- package/dist/populate.js +315 -0
- package/dist/populate.test.d.ts +1 -0
- package/dist/populate.test.js +133 -0
- package/dist/response.d.ts +0 -0
- package/dist/response.js +1 -0
- package/dist/tests/bunSetup.d.ts +1 -0
- package/dist/tests/bunSetup.js +297 -0
- package/dist/tests/index.d.ts +1 -0
- package/dist/tests/index.js +17 -0
- package/dist/tests.d.ts +99 -0
- package/dist/tests.js +273 -0
- package/dist/transformers.d.ts +25 -0
- package/dist/transformers.js +217 -0
- package/dist/transformers.test.d.ts +1 -0
- package/dist/transformers.test.js +370 -0
- package/dist/utils.d.ts +11 -0
- package/dist/utils.js +143 -0
- package/dist/utils.test.d.ts +1 -0
- package/dist/utils.test.js +14 -0
- package/index.ts +1 -0
- package/package.json +88 -0
- package/src/__snapshots__/openApi.test.ts.snap +4814 -0
- package/src/__snapshots__/openApiBuilder.test.ts.snap +1485 -0
- package/src/api.test.ts +1661 -0
- package/src/api.ts +1036 -0
- package/src/auth.test.ts +550 -0
- package/src/auth.ts +408 -0
- package/src/errors.ts +225 -0
- package/src/example.ts +99 -0
- package/src/express.d.ts +5 -0
- package/src/expressServer.ts +387 -0
- package/src/index.ts +14 -0
- package/src/logger.ts +190 -0
- package/src/middleware.ts +18 -0
- package/src/notifiers/googleChatNotifier.test.ts +114 -0
- package/src/notifiers/googleChatNotifier.ts +47 -0
- package/src/notifiers/index.ts +3 -0
- package/src/notifiers/slackNotifier.test.ts +113 -0
- package/src/notifiers/slackNotifier.ts +55 -0
- package/src/notifiers/zoomNotifier.test.ts +207 -0
- package/src/notifiers/zoomNotifier.ts +111 -0
- package/src/openApi.test.ts +331 -0
- package/src/openApi.ts +494 -0
- package/src/openApiBuilder.test.ts +442 -0
- package/src/openApiBuilder.ts +636 -0
- package/src/openApiEtag.ts +40 -0
- package/src/permissions.test.ts +219 -0
- package/src/permissions.ts +228 -0
- package/src/plugins.test.ts +390 -0
- package/src/plugins.ts +289 -0
- package/src/populate.test.ts +65 -0
- package/src/populate.ts +258 -0
- package/src/response.ts +0 -0
- package/src/tests/bunSetup.ts +234 -0
- package/src/tests/index.ts +1 -0
- package/src/tests.ts +218 -0
- package/src/transformers.test.ts +202 -0
- package/src/transformers.ts +170 -0
- package/src/utils.test.ts +14 -0
- package/src/utils.ts +47 -0
- package/tsconfig.json +60 -0
- 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
|
+
}
|