@devmoods/express-extras 0.23.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -6
- package/dist/auth.d.ts +50 -12
- package/dist/auth.js +117 -88
- package/dist/errors.js +4 -1
- package/dist/faktory.d.ts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/listen.js +3 -2
- package/dist/middleware.d.ts +1 -1
- package/dist/postgres.d.ts +2 -1
- package/dist/postgres.js +3 -2
- package/dist/redis.js +2 -1
- package/dist/sentry.js +6 -2
- package/dist/setupTests.d.ts +1 -0
- package/dist/setupTests.js +9 -0
- package/dist/slack.js +2 -1
- package/dist/utils.d.ts +10 -0
- package/dist/utils.js +14 -0
- package/dist/validate.d.ts +1 -1
- package/dist/validate.js +4 -3
- package/package.json +11 -9
package/README.md
CHANGED
|
@@ -44,12 +44,7 @@ FORCE_EXIT_TIMEOUT=
|
|
|
44
44
|
|
|
45
45
|
APP_JWT_SECRET=123456
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
PGUSER=my_database
|
|
49
|
-
PGPASSWORD=
|
|
50
|
-
PGDATABASE=my_database_dev
|
|
51
|
-
PGPORT=5432
|
|
52
|
-
|
|
47
|
+
POSTGRES_URL=postgresql://user:pass@localhost:5432/my_database
|
|
53
48
|
REDIS_URL=
|
|
54
49
|
|
|
55
50
|
SLACK_WEBHOOK_URL=
|
package/dist/auth.d.ts
CHANGED
|
@@ -1,27 +1,54 @@
|
|
|
1
1
|
import { type Request, type RequestHandler, type Response, Router } from 'express';
|
|
2
2
|
import type { RedisClient } from './redis.js';
|
|
3
|
+
/**
|
|
4
|
+
* Returns a signed JWT token.
|
|
5
|
+
*/
|
|
3
6
|
export declare function createAuthToken(payload: any, secret?: string): string;
|
|
4
|
-
|
|
5
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Decode the given JWT token.
|
|
9
|
+
*/
|
|
10
|
+
export declare function decodeAuthToken(token: string): {
|
|
11
|
+
[x: string]: any;
|
|
12
|
+
iss?: string | undefined;
|
|
13
|
+
sub?: string | undefined;
|
|
14
|
+
aud?: string | string[] | undefined;
|
|
15
|
+
exp?: number | undefined;
|
|
16
|
+
nbf?: number | undefined;
|
|
17
|
+
iat?: number | undefined;
|
|
18
|
+
jti?: string | undefined;
|
|
19
|
+
};
|
|
20
|
+
export interface EmailAndPasswordOptions<UserModel = AnyUser> {
|
|
21
|
+
login: (email: string, verifyPassword: (realPassword: string) => Promise<boolean>) => Promise<UserModel>;
|
|
22
|
+
onPasswordResetRequest?(user: UserModel, temporaryToken: string): void | Promise<void>;
|
|
23
|
+
onPasswordChanged?(user: UserModel, newPassword: string): Promise<void>;
|
|
24
|
+
onEmailVerificationRequest?(user: UserModel, temporaryToken: string): Promise<void>;
|
|
25
|
+
onEmailVerified?(user: UserModel): Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
export declare function EmailAndPasswordAuthProvider<UserModel extends AnyUser>(options: EmailAndPasswordOptions<UserModel>): AuthProvider<UserModel>;
|
|
28
|
+
export interface AuthProviderContext<UserModel extends AnyUser> {
|
|
29
|
+
filterUser: AuthOptions<UserModel>['filterUser'];
|
|
30
|
+
redis: AuthOptions<UserModel>['redis'];
|
|
31
|
+
setCurrentUser: (user: UserModel) => void;
|
|
32
|
+
createTemporaryToken(user: UserModel): Promise<string>;
|
|
33
|
+
middleware: (authorize?: boolean) => RequestHandler;
|
|
34
|
+
}
|
|
35
|
+
export type AnyUser = Record<string, any>;
|
|
36
|
+
export interface AuthProvider<UserModel extends AnyUser> {
|
|
37
|
+
setupRoutes(router: Router, context: AuthProviderContext<UserModel>): void;
|
|
6
38
|
}
|
|
7
|
-
export interface AuthOptions<UserModel extends
|
|
39
|
+
export interface AuthOptions<UserModel extends AnyUser> {
|
|
8
40
|
getUserByToken(token: Record<string, any>): Promise<UserModel>;
|
|
9
|
-
|
|
41
|
+
providers: AuthProvider<UserModel>[];
|
|
10
42
|
redis: RedisClient;
|
|
11
43
|
filterUser(user: UserModel): Partial<UserModel>;
|
|
12
|
-
tokenExpiry?: number;
|
|
13
|
-
onPasswordResetRequest?(user: UserModel, temporaryToken: string): void | Promise<void>;
|
|
14
|
-
onPasswordChanged?(user: UserModel, newPassword: string): Promise<void>;
|
|
15
|
-
onEmailVerificationRequest?(user: UserModel, verificationToken: string): Promise<void>;
|
|
16
|
-
onEmailVerified?(user: UserModel): Promise<void>;
|
|
17
44
|
}
|
|
18
|
-
export interface CreateAuth<UserModel extends
|
|
45
|
+
export interface CreateAuth<UserModel extends AnyUser> {
|
|
19
46
|
createRouter: () => Router;
|
|
20
47
|
useCurrentUser: () => UserModel | undefined;
|
|
21
48
|
middleware: (authorize?: boolean) => RequestHandler;
|
|
22
|
-
|
|
49
|
+
createTemporaryToken(user: UserModel): Promise<string>;
|
|
23
50
|
}
|
|
24
|
-
export declare function createAuth<UserModel extends
|
|
51
|
+
export declare function createAuth<UserModel extends AnyUser>({ getUserByToken, filterUser, redis, providers, }: AuthOptions<UserModel>): CreateAuth<UserModel>;
|
|
25
52
|
/**
|
|
26
53
|
* Extract a Bearer token from the HTTP Authorization header
|
|
27
54
|
* of `req`.
|
|
@@ -35,6 +62,17 @@ export declare function parseAuthorizationHeader(req: Request): string | null;
|
|
|
35
62
|
* 2. Cookie named `access_token`
|
|
36
63
|
*/
|
|
37
64
|
export declare function extractAccessTokenFromRequest(req: Request): string | null;
|
|
65
|
+
/**
|
|
66
|
+
* Set or expire the access token cookie.
|
|
67
|
+
*
|
|
68
|
+
* If the provided token is `null` the cookie will be cleared.
|
|
69
|
+
*/
|
|
38
70
|
export declare function setAccessTokenCookie(res: Response, token: string | null): void;
|
|
71
|
+
/**
|
|
72
|
+
* Compare two password hashes using bcrypt.
|
|
73
|
+
*/
|
|
39
74
|
export declare function checkIfPasswordsMatch(realPassword: string, attemptedPassword: string): Promise<boolean>;
|
|
75
|
+
/**
|
|
76
|
+
* Returns a hash of a password suitable for storage.
|
|
77
|
+
*/
|
|
40
78
|
export declare function hashPassword(password: string): Promise<string>;
|
package/dist/auth.js
CHANGED
|
@@ -8,23 +8,43 @@ import { BadRequest, NotFound, Unauthorized } from './errors.js';
|
|
|
8
8
|
import { getLogger } from './logger.js';
|
|
9
9
|
import { route } from './middleware.js';
|
|
10
10
|
import { validateBody, validateParams, } from './validate.js';
|
|
11
|
+
import { readEnv } from './utils.js';
|
|
11
12
|
const logger = getLogger('auth');
|
|
12
|
-
const TOKEN_EXPIRES_IN = 3600 * 48;
|
|
13
|
-
const TEMP_TOKEN_EXIRES_IN = 3600;
|
|
14
|
-
|
|
13
|
+
const TOKEN_EXPIRES_IN = readEnv.number('AUTH_TOKEN_EXPIRES', 3600 * 48);
|
|
14
|
+
const TEMP_TOKEN_EXIRES_IN = readEnv.number('AUTH_TEMP_TOKEN_EXPIRES', 3600);
|
|
15
|
+
const AUTH_SECRET = readEnv('AUTH_JWT_SECRET', '');
|
|
16
|
+
const tokenSchema = {
|
|
17
|
+
type: 'string',
|
|
18
|
+
minLength: 50,
|
|
19
|
+
maxLength: 256,
|
|
20
|
+
};
|
|
21
|
+
const passwordSchema = { type: 'string', minLength: 8 };
|
|
22
|
+
const emailSchema = { type: 'string', minLength: 4 };
|
|
23
|
+
/**
|
|
24
|
+
* Returns a signed JWT token.
|
|
25
|
+
*/
|
|
26
|
+
export function createAuthToken(payload, secret = AUTH_SECRET) {
|
|
15
27
|
return jwt.sign(payload, secret, { expiresIn: TOKEN_EXPIRES_IN });
|
|
16
28
|
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
29
|
+
/**
|
|
30
|
+
* Decode the given JWT token.
|
|
31
|
+
*/
|
|
32
|
+
export function decodeAuthToken(token) {
|
|
33
|
+
try {
|
|
34
|
+
const decoded = jwt.verify(token.trim(), AUTH_SECRET);
|
|
35
|
+
if (typeof decoded === 'string') {
|
|
36
|
+
throw new Error('decoded value not an object');
|
|
37
|
+
}
|
|
38
|
+
return { ...decoded };
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
throw new Error(`Failed to decode auth token: ${error.message}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export function EmailAndPasswordAuthProvider(options) {
|
|
20
45
|
const skipPasswordCheck = async () => true;
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
router.get('/me', middleware(), route(() => {
|
|
24
|
-
const user = useCurrentUser();
|
|
25
|
-
return filterUser(user);
|
|
26
|
-
}));
|
|
27
|
-
if (emailAndPassword) {
|
|
46
|
+
return {
|
|
47
|
+
setupRoutes(router, context) {
|
|
28
48
|
router.post('/login', validateBody({
|
|
29
49
|
type: 'object',
|
|
30
50
|
properties: {
|
|
@@ -34,11 +54,11 @@ export function createAuth({ getUserByToken, methods: { emailAndPassword }, filt
|
|
|
34
54
|
required: ['email', 'password'],
|
|
35
55
|
}), route(async ({ body: { email, password } }, res) => {
|
|
36
56
|
try {
|
|
37
|
-
const user = await
|
|
38
|
-
const token = createAuthToken(filterUser(user));
|
|
39
|
-
await redis.set(token, '1', { EX:
|
|
57
|
+
const user = await options.login(email, (realPassword) => checkIfPasswordsMatch(realPassword, password));
|
|
58
|
+
const token = createAuthToken(context.filterUser(user));
|
|
59
|
+
await context.redis.set(token, '1', { EX: TOKEN_EXPIRES_IN });
|
|
40
60
|
setAccessTokenCookie(res, token);
|
|
41
|
-
setCurrentUser(user);
|
|
61
|
+
context.setCurrentUser(user);
|
|
42
62
|
logger.info('access token created');
|
|
43
63
|
return [201, { token }];
|
|
44
64
|
}
|
|
@@ -47,38 +67,21 @@ export function createAuth({ getUserByToken, methods: { emailAndPassword }, filt
|
|
|
47
67
|
throw Unauthorized(error.message);
|
|
48
68
|
}
|
|
49
69
|
}));
|
|
50
|
-
}
|
|
51
|
-
router.get('/logout', route(async (req, res) => {
|
|
52
|
-
const token = extractAccessTokenFromRequest(req);
|
|
53
|
-
if (token) {
|
|
54
|
-
await redis.del(token);
|
|
55
|
-
}
|
|
56
|
-
setAccessTokenCookie(res, null);
|
|
57
|
-
res.redirect('/');
|
|
58
|
-
}));
|
|
59
|
-
const tokenSchema = {
|
|
60
|
-
type: 'string',
|
|
61
|
-
minLength: 50,
|
|
62
|
-
maxLength: 256,
|
|
63
|
-
};
|
|
64
|
-
const passwordSchema = { type: 'string', minLength: 8 };
|
|
65
|
-
const emailSchema = { type: 'string', minLength: 4 };
|
|
66
|
-
if (emailAndPassword) {
|
|
67
70
|
router.post('/change-password', validateBody({
|
|
68
71
|
type: 'object',
|
|
69
72
|
properties: { token: tokenSchema, password: passwordSchema },
|
|
70
73
|
required: ['token', 'password'],
|
|
71
74
|
}), route(async (req) => {
|
|
72
75
|
const { password, token } = req.body;
|
|
73
|
-
const email = await redis.get(token);
|
|
76
|
+
const email = await context.redis.get(token);
|
|
74
77
|
if (!email) {
|
|
75
78
|
logger.info('invalid reset token', { token });
|
|
76
79
|
throw BadRequest('invalid reset token');
|
|
77
80
|
}
|
|
78
|
-
const user = await
|
|
79
|
-
setCurrentUser(user);
|
|
80
|
-
await onPasswordChanged?.(user, await hashPassword(password));
|
|
81
|
-
await redis.del(token);
|
|
81
|
+
const user = await options.login(email, skipPasswordCheck);
|
|
82
|
+
context.setCurrentUser(user);
|
|
83
|
+
await options.onPasswordChanged?.(user, await hashPassword(password));
|
|
84
|
+
await context.redis.del(token);
|
|
82
85
|
logger.info('user changed password');
|
|
83
86
|
return 200;
|
|
84
87
|
}));
|
|
@@ -88,15 +91,9 @@ export function createAuth({ getUserByToken, methods: { emailAndPassword }, filt
|
|
|
88
91
|
required: ['email'],
|
|
89
92
|
}), route(async (req) => {
|
|
90
93
|
const { email } = req.body;
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const user = await emailAndPassword(email, skipPasswordCheck);
|
|
95
|
-
const verificationToken = crypto.randomBytes(32).toString('hex');
|
|
96
|
-
await redis.set(verificationToken, user.email, {
|
|
97
|
-
EX: TEMP_TOKEN_EXIRES_IN,
|
|
98
|
-
});
|
|
99
|
-
await onEmailVerificationRequest?.(user, verificationToken);
|
|
94
|
+
const user = await options.login(email, skipPasswordCheck);
|
|
95
|
+
const temporaryToken = await context.createTemporaryToken(user);
|
|
96
|
+
await options.onEmailVerificationRequest?.(user, temporaryToken);
|
|
100
97
|
return 202;
|
|
101
98
|
}));
|
|
102
99
|
router.post('/verify-email', validateBody({
|
|
@@ -105,7 +102,7 @@ export function createAuth({ getUserByToken, methods: { emailAndPassword }, filt
|
|
|
105
102
|
required: ['token', 'email'],
|
|
106
103
|
}), route(async (req) => {
|
|
107
104
|
const { token, email } = req.body;
|
|
108
|
-
const storedEmail = await redis.get(token);
|
|
105
|
+
const storedEmail = await context.redis.get(token);
|
|
109
106
|
if (!storedEmail) {
|
|
110
107
|
logger.info('invalid verification token', { token });
|
|
111
108
|
throw BadRequest('invalid verification token');
|
|
@@ -117,14 +114,57 @@ export function createAuth({ getUserByToken, methods: { emailAndPassword }, filt
|
|
|
117
114
|
});
|
|
118
115
|
throw BadRequest('invalid verification token');
|
|
119
116
|
}
|
|
120
|
-
const user = await
|
|
121
|
-
setCurrentUser(user);
|
|
122
|
-
await onEmailVerified?.(user);
|
|
123
|
-
await redis.del(token);
|
|
117
|
+
const user = await options.login(storedEmail, skipPasswordCheck);
|
|
118
|
+
context.setCurrentUser(user);
|
|
119
|
+
await options.onEmailVerified?.(user);
|
|
120
|
+
await context.redis.del(token);
|
|
124
121
|
logger.info('user verified');
|
|
125
122
|
return 200;
|
|
126
123
|
}));
|
|
127
|
-
|
|
124
|
+
router.post('/reset', context.middleware(false), validateBody({
|
|
125
|
+
type: 'object',
|
|
126
|
+
properties: { email: emailSchema },
|
|
127
|
+
required: ['email'],
|
|
128
|
+
}), route(async (req) => {
|
|
129
|
+
const { email } = req.body;
|
|
130
|
+
try {
|
|
131
|
+
const user = await options.login(email, skipPasswordCheck);
|
|
132
|
+
const temporaryToken = await context.createTemporaryToken(user);
|
|
133
|
+
await options.onPasswordResetRequest?.(user, temporaryToken);
|
|
134
|
+
logger.info('password reset request', { email });
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
logger.error(error, 'auth reset failed');
|
|
138
|
+
}
|
|
139
|
+
return 202;
|
|
140
|
+
}));
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
export function createAuth({ getUserByToken, filterUser = (x) => x, redis, providers, }) {
|
|
145
|
+
const useCurrentUser = () => useContext('currentUser');
|
|
146
|
+
const setCurrentUser = (user) => setContext('currentUser', user);
|
|
147
|
+
const providerContext = {
|
|
148
|
+
filterUser,
|
|
149
|
+
redis,
|
|
150
|
+
setCurrentUser,
|
|
151
|
+
createTemporaryToken,
|
|
152
|
+
middleware,
|
|
153
|
+
};
|
|
154
|
+
const createRouter = () => {
|
|
155
|
+
const router = Router();
|
|
156
|
+
router.get('/me', middleware(), route(() => {
|
|
157
|
+
const user = useCurrentUser();
|
|
158
|
+
return filterUser(user);
|
|
159
|
+
}));
|
|
160
|
+
router.get('/logout', route(async (req, res) => {
|
|
161
|
+
const token = extractAccessTokenFromRequest(req);
|
|
162
|
+
if (token) {
|
|
163
|
+
await redis.del(token);
|
|
164
|
+
}
|
|
165
|
+
setAccessTokenCookie(res, null);
|
|
166
|
+
res.redirect('/');
|
|
167
|
+
}));
|
|
128
168
|
/**
|
|
129
169
|
* Check that a reset token is valid.
|
|
130
170
|
*/
|
|
@@ -141,32 +181,6 @@ export function createAuth({ getUserByToken, methods: { emailAndPassword }, filt
|
|
|
141
181
|
}
|
|
142
182
|
return 200;
|
|
143
183
|
}));
|
|
144
|
-
/**
|
|
145
|
-
* Request password reset
|
|
146
|
-
*/
|
|
147
|
-
router.post('/reset', middleware(false), validateBody({
|
|
148
|
-
type: 'object',
|
|
149
|
-
properties: { email: emailSchema },
|
|
150
|
-
required: ['email'],
|
|
151
|
-
}), route(async (req) => {
|
|
152
|
-
const { email } = req.body;
|
|
153
|
-
if (!emailAndPassword) {
|
|
154
|
-
throw new Error('Not applicable for login method');
|
|
155
|
-
}
|
|
156
|
-
try {
|
|
157
|
-
const user = await emailAndPassword(email, skipPasswordCheck);
|
|
158
|
-
const temporaryToken = crypto.randomBytes(32).toString('hex');
|
|
159
|
-
await redis.set(temporaryToken, user.email, {
|
|
160
|
-
EX: TEMP_TOKEN_EXIRES_IN,
|
|
161
|
-
});
|
|
162
|
-
await onPasswordResetRequest?.(user, temporaryToken);
|
|
163
|
-
logger.info('password reset request', { email });
|
|
164
|
-
}
|
|
165
|
-
catch (error) {
|
|
166
|
-
logger.error(error, 'auth reset failed');
|
|
167
|
-
}
|
|
168
|
-
return 202;
|
|
169
|
-
}));
|
|
170
184
|
router.get('/token/refresh', middleware(false), route(async (req, res) => {
|
|
171
185
|
const user = useCurrentUser();
|
|
172
186
|
if (!user) {
|
|
@@ -174,11 +188,14 @@ export function createAuth({ getUserByToken, methods: { emailAndPassword }, filt
|
|
|
174
188
|
}
|
|
175
189
|
const { exp, iat, ...rest } = user;
|
|
176
190
|
const token = createAuthToken(rest);
|
|
177
|
-
await redis.set(token, '1', { EX:
|
|
191
|
+
await redis.set(token, '1', { EX: TOKEN_EXPIRES_IN });
|
|
178
192
|
setAccessTokenCookie(res, token);
|
|
179
193
|
logger.info('refreshed token', { user });
|
|
180
194
|
return { token };
|
|
181
195
|
}));
|
|
196
|
+
for (const provider of providers) {
|
|
197
|
+
provider.setupRoutes(router, providerContext);
|
|
198
|
+
}
|
|
182
199
|
return router;
|
|
183
200
|
};
|
|
184
201
|
function middleware(authorize = true) {
|
|
@@ -199,11 +216,8 @@ export function createAuth({ getUserByToken, methods: { emailAndPassword }, filt
|
|
|
199
216
|
}
|
|
200
217
|
throw new InvalidToken('token expired');
|
|
201
218
|
}
|
|
202
|
-
const decoded =
|
|
203
|
-
|
|
204
|
-
throw new Error('Failed to decode token');
|
|
205
|
-
}
|
|
206
|
-
const user = await getUserByToken({ ...decoded });
|
|
219
|
+
const decoded = decodeAuthToken(token);
|
|
220
|
+
const user = await getUserByToken(decoded);
|
|
207
221
|
setCurrentUser(user);
|
|
208
222
|
next();
|
|
209
223
|
}
|
|
@@ -214,7 +228,11 @@ export function createAuth({ getUserByToken, methods: { emailAndPassword }, filt
|
|
|
214
228
|
}
|
|
215
229
|
};
|
|
216
230
|
}
|
|
217
|
-
|
|
231
|
+
/**
|
|
232
|
+
* Create a short-lived token tied to a user that can be
|
|
233
|
+
* used for different kinds of verification.
|
|
234
|
+
*/
|
|
235
|
+
async function createTemporaryToken(user) {
|
|
218
236
|
try {
|
|
219
237
|
const temporaryToken = crypto.randomBytes(32).toString('hex');
|
|
220
238
|
await redis.set(temporaryToken, user.email, {
|
|
@@ -231,7 +249,7 @@ export function createAuth({ getUserByToken, methods: { emailAndPassword }, filt
|
|
|
231
249
|
createRouter,
|
|
232
250
|
useCurrentUser,
|
|
233
251
|
middleware,
|
|
234
|
-
|
|
252
|
+
createTemporaryToken,
|
|
235
253
|
};
|
|
236
254
|
}
|
|
237
255
|
/**
|
|
@@ -274,6 +292,11 @@ export function extractAccessTokenFromRequest(req) {
|
|
|
274
292
|
}
|
|
275
293
|
return null;
|
|
276
294
|
}
|
|
295
|
+
/**
|
|
296
|
+
* Set or expire the access token cookie.
|
|
297
|
+
*
|
|
298
|
+
* If the provided token is `null` the cookie will be cleared.
|
|
299
|
+
*/
|
|
277
300
|
export function setAccessTokenCookie(res, token) {
|
|
278
301
|
if (token == null) {
|
|
279
302
|
res.cookie('access_token', v4(), {
|
|
@@ -294,6 +317,9 @@ class InvalidToken extends Error {
|
|
|
294
317
|
this.name = 'Invalid Token';
|
|
295
318
|
}
|
|
296
319
|
}
|
|
320
|
+
/**
|
|
321
|
+
* Compare two password hashes using bcrypt.
|
|
322
|
+
*/
|
|
297
323
|
export async function checkIfPasswordsMatch(realPassword, attemptedPassword) {
|
|
298
324
|
if (!realPassword) {
|
|
299
325
|
throw new Error('User has no defined password');
|
|
@@ -304,6 +330,9 @@ export async function checkIfPasswordsMatch(realPassword, attemptedPassword) {
|
|
|
304
330
|
}
|
|
305
331
|
return true;
|
|
306
332
|
}
|
|
333
|
+
/**
|
|
334
|
+
* Returns a hash of a password suitable for storage.
|
|
335
|
+
*/
|
|
307
336
|
export function hashPassword(password) {
|
|
308
337
|
return bcrypt.hash(password, 10);
|
|
309
338
|
}
|
package/dist/errors.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { STATUS_CODES } from 'node:http';
|
|
2
2
|
import * as Sentry from '@sentry/node';
|
|
3
3
|
import { errors } from 'compose-middleware';
|
|
4
|
-
import { IntegrityError } from './postgres.js';
|
|
4
|
+
import { DoesNotExistError, IntegrityError } from './postgres.js';
|
|
5
5
|
export class ApiError extends Error {
|
|
6
6
|
}
|
|
7
7
|
export const BadRequest = (reason) => makeError(400, 'Invalid Params', { reason });
|
|
@@ -41,6 +41,9 @@ function convertToAPIError(error) {
|
|
|
41
41
|
if (error instanceof IntegrityError) {
|
|
42
42
|
return Conflict(error.message);
|
|
43
43
|
}
|
|
44
|
+
if (error instanceof DoesNotExistError) {
|
|
45
|
+
return NotFound(error.message);
|
|
46
|
+
}
|
|
44
47
|
return error;
|
|
45
48
|
}
|
|
46
49
|
function _errorHandler({ onError }) {
|
package/dist/faktory.d.ts
CHANGED
|
@@ -11,7 +11,7 @@ export declare function createJobs({ queueName }: JobsOptions): {
|
|
|
11
11
|
};
|
|
12
12
|
queueName: string;
|
|
13
13
|
};
|
|
14
|
-
export type
|
|
14
|
+
export type Job = ReturnType<ReturnType<typeof createJobs>['job']>;
|
|
15
15
|
export interface FaktoryWorkerOptions {
|
|
16
16
|
jobs: unknown[];
|
|
17
17
|
queueName: string;
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -11,5 +11,5 @@ export * from './redis.js';
|
|
|
11
11
|
export * from './routes.js';
|
|
12
12
|
export * from './sentry.js';
|
|
13
13
|
export * from './slack.js';
|
|
14
|
+
export * from './utils.js';
|
|
14
15
|
export * from './validate.js';
|
|
15
|
-
export const envPort = (defaultPort = 3000) => parseInt(process.env.PORT, 10) || defaultPort;
|
package/dist/listen.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { createTerminus } from '@godaddy/terminus';
|
|
2
2
|
import { getLogger } from './logger.js';
|
|
3
|
+
import { readEnv } from './utils.js';
|
|
3
4
|
const logger = getLogger('http');
|
|
4
|
-
const READINESS_PERIOD_MS =
|
|
5
|
-
const FORCE_EXIT_TIMEOUT =
|
|
5
|
+
const READINESS_PERIOD_MS = readEnv.number('READINESS_PERIOD_MS', 0);
|
|
6
|
+
const FORCE_EXIT_TIMEOUT = readEnv.number('FORCE_EXIT_TIMEOUT', 5000);
|
|
6
7
|
/**
|
|
7
8
|
* Listen on the given port and setup proper signal
|
|
8
9
|
* handlers for terminating the process properly.
|
package/dist/middleware.d.ts
CHANGED
|
@@ -29,4 +29,4 @@ export declare function after(): RequestHandler;
|
|
|
29
29
|
/**
|
|
30
30
|
* Alias of `route()`
|
|
31
31
|
*/
|
|
32
|
-
export declare function asyncMiddleware<RequestBody, RequestQuery =
|
|
32
|
+
export declare function asyncMiddleware<RequestBody, RequestQuery = object>(fn: RequestHandler<Record<string, string>, any, RequestBody, RequestQuery>): RequestHandler<Record<string, string>, any, RequestBody, RequestQuery>;
|
package/dist/postgres.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import pg, { type PoolClient, type QueryResult, type QueryResultRow } from 'pg';
|
|
|
2
2
|
import { SQL, type SQLStatement } from 'sql-template-strings';
|
|
3
3
|
export interface PostgresOptions extends pg.PoolConfig {
|
|
4
4
|
onError?: (error: Error, client: any) => void;
|
|
5
|
+
url?: pg.PoolConfig['connectionString'];
|
|
5
6
|
}
|
|
6
7
|
export declare class DoesNotExistError extends Error {
|
|
7
8
|
constructor(message: string);
|
|
@@ -31,7 +32,7 @@ export declare class Transaction extends Connection {
|
|
|
31
32
|
*
|
|
32
33
|
* @param config
|
|
33
34
|
*/
|
|
34
|
-
export declare function createPostgres({ onError, ...config }?: PostgresOptions): {
|
|
35
|
+
export declare function createPostgres({ onError, url, ...config }?: PostgresOptions): {
|
|
35
36
|
insert: <T extends pg.QueryResultRow>(text: string | SQLStatement, values?: Value[] | undefined) => Promise<pg.QueryResult<T>>;
|
|
36
37
|
query: <T_1 extends pg.QueryResultRow>(text: string | SQLStatement, values?: Value[] | undefined) => Promise<pg.QueryResult<T_1>>;
|
|
37
38
|
first: <T_2>(text: string | SQLStatement, values?: Value[] | undefined) => Promise<T_2 | null>;
|
package/dist/postgres.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import pg from 'pg';
|
|
2
2
|
import { SQL } from 'sql-template-strings';
|
|
3
|
+
import { readEnv } from './utils.js';
|
|
3
4
|
export class DoesNotExistError extends Error {
|
|
4
5
|
constructor(message) {
|
|
5
6
|
super(message);
|
|
@@ -67,8 +68,8 @@ export class Transaction extends Connection {
|
|
|
67
68
|
*
|
|
68
69
|
* @param config
|
|
69
70
|
*/
|
|
70
|
-
export function createPostgres({ onError = () => { }, ...config } = {}) {
|
|
71
|
-
const pool = new pg.Pool(config);
|
|
71
|
+
export function createPostgres({ onError = () => { }, url = readEnv('POSTGRES_URL'), ...config } = {}) {
|
|
72
|
+
const pool = new pg.Pool({ connectionString: url, ...config });
|
|
72
73
|
pool.on('error', onError);
|
|
73
74
|
async function withConnection(callback) {
|
|
74
75
|
const client = await pool.connect();
|
package/dist/redis.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createClient } from 'redis';
|
|
2
|
-
|
|
2
|
+
import { readEnv } from './utils.js';
|
|
3
|
+
export function createRedis({ url = readEnv('REDIS_URL', ''), onError, }) {
|
|
3
4
|
const client = createClient({ url });
|
|
4
5
|
client.on('error', onError);
|
|
5
6
|
return client;
|
package/dist/sentry.js
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import * as Sentry from '@sentry/node';
|
|
2
|
+
import { attachSentryClientToLoggers } from './logger.js';
|
|
3
|
+
import { readEnv } from './utils.js';
|
|
2
4
|
/**
|
|
3
5
|
* Create a pre-configured Sentry client
|
|
4
6
|
*/
|
|
5
7
|
export function createSentry(options = {}) {
|
|
8
|
+
const client = Sentry;
|
|
6
9
|
return {
|
|
7
|
-
client
|
|
10
|
+
client,
|
|
8
11
|
init(app, getOptions = (defaultOptions) => defaultOptions) {
|
|
12
|
+
attachSentryClientToLoggers(client);
|
|
9
13
|
Sentry.init(getOptions(createSentryOptions(app, options)));
|
|
10
14
|
},
|
|
11
15
|
};
|
|
@@ -15,7 +19,7 @@ export function createSentry(options = {}) {
|
|
|
15
19
|
*/
|
|
16
20
|
export function createSentryOptions(app, options = {}) {
|
|
17
21
|
return {
|
|
18
|
-
dsn:
|
|
22
|
+
dsn: readEnv('SENTRY_DSN'),
|
|
19
23
|
integrations: [
|
|
20
24
|
new Sentry.Integrations.Http({ tracing: true }),
|
|
21
25
|
app && new Sentry.Integrations.Express({ app }),
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import 'dotenv/config';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import 'dotenv/config';
|
|
2
|
+
import * as crypto from 'node:crypto';
|
|
3
|
+
// @ts-ignore
|
|
4
|
+
import createFetchMock from 'vitest-fetch-mock';
|
|
5
|
+
import { vi } from 'vitest';
|
|
6
|
+
const fetchMocker = createFetchMock(vi);
|
|
7
|
+
fetchMocker.enableMocks();
|
|
8
|
+
// @ts-ignore
|
|
9
|
+
globalThis.crypto = crypto;
|
package/dist/slack.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createFetch } from '@devmoods/fetch';
|
|
2
2
|
import { getLogger } from './logger.js';
|
|
3
|
+
import { readEnv } from './utils.js';
|
|
3
4
|
const logger = getLogger('slack');
|
|
4
5
|
/**
|
|
5
6
|
* Send messages to Slack using Incoming Webhooks
|
|
@@ -14,7 +15,7 @@ const logger = getLogger('slack');
|
|
|
14
15
|
*
|
|
15
16
|
* See https://api.slack.com/messaging/webhooks
|
|
16
17
|
*/
|
|
17
|
-
export function createSlack({ webhookUrl =
|
|
18
|
+
export function createSlack({ webhookUrl = readEnv('SLACK_WEBHOOK_URL', ''), dryRun = process.env.NODE_ENV !== 'production', } = {}) {
|
|
18
19
|
const fetch = createFetch();
|
|
19
20
|
/**
|
|
20
21
|
* Post a message to Slack
|
package/dist/utils.d.ts
CHANGED
|
@@ -5,3 +5,13 @@
|
|
|
5
5
|
export declare function startTimer(): {
|
|
6
6
|
getDuration(): number;
|
|
7
7
|
};
|
|
8
|
+
export declare function readEnv(key: string): string | undefined;
|
|
9
|
+
export declare function readEnv(key: string, defaultValue: string): string;
|
|
10
|
+
export declare namespace readEnv {
|
|
11
|
+
var number: typeof readEnvNumber;
|
|
12
|
+
var boolean: typeof readEnvBoolean;
|
|
13
|
+
}
|
|
14
|
+
declare function readEnvNumber(key: string, defaultValue: number): number;
|
|
15
|
+
declare function readEnvBoolean(key: string, defaultValue: boolean): boolean;
|
|
16
|
+
export declare const envPort: (defaultPort?: number) => number;
|
|
17
|
+
export {};
|
package/dist/utils.js
CHANGED
|
@@ -11,3 +11,17 @@ export function startTimer() {
|
|
|
11
11
|
},
|
|
12
12
|
};
|
|
13
13
|
}
|
|
14
|
+
export function readEnv(key, defaultValue) {
|
|
15
|
+
const value = process.env[key];
|
|
16
|
+
return value ?? defaultValue;
|
|
17
|
+
}
|
|
18
|
+
readEnv.number = readEnvNumber;
|
|
19
|
+
readEnv.boolean = readEnvBoolean;
|
|
20
|
+
function readEnvNumber(key, defaultValue) {
|
|
21
|
+
const str = readEnv(key, `${defaultValue}`);
|
|
22
|
+
return parseFloat(str);
|
|
23
|
+
}
|
|
24
|
+
function readEnvBoolean(key, defaultValue) {
|
|
25
|
+
return ['true', '1', 't'].includes(readEnv(key, `${defaultValue}`).toLowerCase());
|
|
26
|
+
}
|
|
27
|
+
export const envPort = (defaultPort = 3000) => readEnv.number('PORT', defaultPort);
|
package/dist/validate.d.ts
CHANGED
package/dist/validate.js
CHANGED
|
@@ -45,7 +45,7 @@ export function compileValidateSchema(schema, options = {}) {
|
|
|
45
45
|
errors: null,
|
|
46
46
|
};
|
|
47
47
|
}
|
|
48
|
-
const errors = transformAjvErrors(validate.errors)
|
|
48
|
+
const errors = transformAjvErrors(validate.errors);
|
|
49
49
|
validate.errors = null;
|
|
50
50
|
return {
|
|
51
51
|
isValid: false,
|
|
@@ -65,7 +65,7 @@ export function validateSchema(data, schema, options = {}) {
|
|
|
65
65
|
errors: null,
|
|
66
66
|
};
|
|
67
67
|
}
|
|
68
|
-
const errors = transformAjvErrors(ajv.errors)
|
|
68
|
+
const errors = transformAjvErrors(ajv.errors);
|
|
69
69
|
ajv.errors = null;
|
|
70
70
|
return {
|
|
71
71
|
isValid: false,
|
|
@@ -73,8 +73,9 @@ export function validateSchema(data, schema, options = {}) {
|
|
|
73
73
|
};
|
|
74
74
|
}
|
|
75
75
|
function transformAjvErrors(errors) {
|
|
76
|
+
/* c8 ignore next 3 */
|
|
76
77
|
if (errors == null) {
|
|
77
|
-
return
|
|
78
|
+
return {};
|
|
78
79
|
}
|
|
79
80
|
return errors
|
|
80
81
|
.map((error) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@devmoods/express-extras",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
4
4
|
"description": "Kubernetes-ready utilities for rapid Node.js application development",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -24,15 +24,16 @@
|
|
|
24
24
|
"license": "ISC",
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@devmoods/eslint-config": "^1.2.0",
|
|
27
|
-
"@sentry/types": "^7.
|
|
27
|
+
"@sentry/types": "^7.63.0",
|
|
28
28
|
"@types/express-serve-static-core": "^4.17.35",
|
|
29
|
-
"@types/node": "20.4.
|
|
29
|
+
"@types/node": "20.4.10",
|
|
30
30
|
"@types/supertest": "^2.0.12",
|
|
31
|
-
"@typescript-eslint/eslint-plugin": "^6.
|
|
32
|
-
"@typescript-eslint/parser": "^6.
|
|
31
|
+
"@typescript-eslint/eslint-plugin": "^6.3.0",
|
|
32
|
+
"@typescript-eslint/parser": "^6.3.0",
|
|
33
|
+
"@vitest/coverage-v8": "^0.34.1",
|
|
33
34
|
"@vitest/expect": "^0.34.1",
|
|
34
35
|
"chalk": "^5.3.0",
|
|
35
|
-
"eslint": "^8.
|
|
36
|
+
"eslint": "^8.47.0",
|
|
36
37
|
"eslint-plugin-jsx-a11y": "^6.7.1",
|
|
37
38
|
"eslint-plugin-prettier": "^5.0.0",
|
|
38
39
|
"eslint-plugin-react": "^7.33.1",
|
|
@@ -44,12 +45,13 @@
|
|
|
44
45
|
"supertest": "6.3.3",
|
|
45
46
|
"ts-node": "10.9.1",
|
|
46
47
|
"typescript": "5.1.6",
|
|
47
|
-
"vitest": "^0.34.1"
|
|
48
|
+
"vitest": "^0.34.1",
|
|
49
|
+
"vitest-fetch-mock": "^0.2.2"
|
|
48
50
|
},
|
|
49
51
|
"dependencies": {
|
|
50
52
|
"@devmoods/fetch": "^3.3.0",
|
|
51
53
|
"@godaddy/terminus": "^4.12.1",
|
|
52
|
-
"@sentry/node": "^7.
|
|
54
|
+
"@sentry/node": "^7.63.0",
|
|
53
55
|
"@types/bcrypt": "^5.0.0",
|
|
54
56
|
"@types/body-parser": "^1.19.2",
|
|
55
57
|
"@types/cookie-parser": "^1.4.3",
|
|
@@ -101,7 +103,7 @@
|
|
|
101
103
|
"start:worker": "ts-node --esm example/worker.ts",
|
|
102
104
|
"clean": "rm -rf dist",
|
|
103
105
|
"lint": "eslint src",
|
|
104
|
-
"test": "NODE_ENV=test vitest",
|
|
106
|
+
"test": "NODE_ENV=test DOTENV_CONFIG_PATH=.env.test vitest",
|
|
105
107
|
"build": "pnpm run clean && tsc --project tsconfig.build.json",
|
|
106
108
|
"prettier": "prettier --write '**/*.{js,css,md,ts,tsx}'",
|
|
107
109
|
"import-sort": "import-sort --write '**/*.{js,jsx,ts,tsx}'",
|