@devmoods/express-extras 0.24.0 → 0.26.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 -1
- package/dist/auth.d.ts +49 -11
- package/dist/auth.js +110 -77
- package/dist/cache.d.ts +14 -0
- package/dist/cache.js +64 -0
- package/dist/errors.js +4 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/logger.d.ts +3 -1
- package/dist/logger.js +4 -2
- package/dist/middleware.d.ts +1 -1
- package/dist/middleware.js +3 -1
- package/package.json +5 -5
package/README.md
CHANGED
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
|
-
|
|
6
|
-
|
|
7
|
-
export
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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>;
|
|
13
22
|
onPasswordResetRequest?(user: UserModel, temporaryToken: string): void | Promise<void>;
|
|
14
23
|
onPasswordChanged?(user: UserModel, newPassword: string): Promise<void>;
|
|
15
24
|
onEmailVerificationRequest?(user: UserModel, temporaryToken: string): Promise<void>;
|
|
16
25
|
onEmailVerified?(user: UserModel): Promise<void>;
|
|
17
26
|
}
|
|
18
|
-
export
|
|
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;
|
|
38
|
+
}
|
|
39
|
+
export interface AuthOptions<UserModel extends AnyUser> {
|
|
40
|
+
getUserByToken(token: Record<string, any>): Promise<UserModel>;
|
|
41
|
+
providers: AuthProvider<UserModel>[];
|
|
42
|
+
redis: RedisClient;
|
|
43
|
+
filterUser(user: UserModel): Partial<UserModel>;
|
|
44
|
+
}
|
|
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
|
@@ -13,20 +13,38 @@ const logger = getLogger('auth');
|
|
|
13
13
|
const TOKEN_EXPIRES_IN = readEnv.number('AUTH_TOKEN_EXPIRES', 3600 * 48);
|
|
14
14
|
const TEMP_TOKEN_EXIRES_IN = readEnv.number('AUTH_TEMP_TOKEN_EXPIRES', 3600);
|
|
15
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
|
+
*/
|
|
16
26
|
export function createAuthToken(payload, secret = AUTH_SECRET) {
|
|
17
27
|
return jwt.sign(payload, secret, { expiresIn: TOKEN_EXPIRES_IN });
|
|
18
28
|
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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) {
|
|
22
45
|
const skipPasswordCheck = async () => true;
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
router.get('/me', middleware(), route(() => {
|
|
26
|
-
const user = useCurrentUser();
|
|
27
|
-
return filterUser(user);
|
|
28
|
-
}));
|
|
29
|
-
if (emailAndPassword) {
|
|
46
|
+
return {
|
|
47
|
+
setupRoutes(router, context) {
|
|
30
48
|
router.post('/login', validateBody({
|
|
31
49
|
type: 'object',
|
|
32
50
|
properties: {
|
|
@@ -36,11 +54,11 @@ export function createAuth({ getUserByToken, methods: { emailAndPassword }, filt
|
|
|
36
54
|
required: ['email', 'password'],
|
|
37
55
|
}), route(async ({ body: { email, password } }, res) => {
|
|
38
56
|
try {
|
|
39
|
-
const user = await
|
|
40
|
-
const token = createAuthToken(filterUser(user));
|
|
41
|
-
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 });
|
|
42
60
|
setAccessTokenCookie(res, token);
|
|
43
|
-
setCurrentUser(user);
|
|
61
|
+
context.setCurrentUser(user);
|
|
44
62
|
logger.info('access token created');
|
|
45
63
|
return [201, { token }];
|
|
46
64
|
}
|
|
@@ -49,38 +67,21 @@ export function createAuth({ getUserByToken, methods: { emailAndPassword }, filt
|
|
|
49
67
|
throw Unauthorized(error.message);
|
|
50
68
|
}
|
|
51
69
|
}));
|
|
52
|
-
}
|
|
53
|
-
router.get('/logout', route(async (req, res) => {
|
|
54
|
-
const token = extractAccessTokenFromRequest(req);
|
|
55
|
-
if (token) {
|
|
56
|
-
await redis.del(token);
|
|
57
|
-
}
|
|
58
|
-
setAccessTokenCookie(res, null);
|
|
59
|
-
res.redirect('/');
|
|
60
|
-
}));
|
|
61
|
-
const tokenSchema = {
|
|
62
|
-
type: 'string',
|
|
63
|
-
minLength: 50,
|
|
64
|
-
maxLength: 256,
|
|
65
|
-
};
|
|
66
|
-
const passwordSchema = { type: 'string', minLength: 8 };
|
|
67
|
-
const emailSchema = { type: 'string', minLength: 4 };
|
|
68
|
-
if (emailAndPassword) {
|
|
69
70
|
router.post('/change-password', validateBody({
|
|
70
71
|
type: 'object',
|
|
71
72
|
properties: { token: tokenSchema, password: passwordSchema },
|
|
72
73
|
required: ['token', 'password'],
|
|
73
74
|
}), route(async (req) => {
|
|
74
75
|
const { password, token } = req.body;
|
|
75
|
-
const email = await redis.get(token);
|
|
76
|
+
const email = await context.redis.get(token);
|
|
76
77
|
if (!email) {
|
|
77
78
|
logger.info('invalid reset token', { token });
|
|
78
79
|
throw BadRequest('invalid reset token');
|
|
79
80
|
}
|
|
80
|
-
const user = await
|
|
81
|
-
setCurrentUser(user);
|
|
82
|
-
await onPasswordChanged?.(user, await hashPassword(password));
|
|
83
|
-
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);
|
|
84
85
|
logger.info('user changed password');
|
|
85
86
|
return 200;
|
|
86
87
|
}));
|
|
@@ -90,12 +91,9 @@ export function createAuth({ getUserByToken, methods: { emailAndPassword }, filt
|
|
|
90
91
|
required: ['email'],
|
|
91
92
|
}), route(async (req) => {
|
|
92
93
|
const { email } = req.body;
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const user = await emailAndPassword(email, skipPasswordCheck);
|
|
97
|
-
const temporaryToken = await createTemporaryToken(user);
|
|
98
|
-
await onEmailVerificationRequest?.(user, temporaryToken);
|
|
94
|
+
const user = await options.login(email, skipPasswordCheck);
|
|
95
|
+
const temporaryToken = await context.createTemporaryToken(user);
|
|
96
|
+
await options.onEmailVerificationRequest?.(user, temporaryToken);
|
|
99
97
|
return 202;
|
|
100
98
|
}));
|
|
101
99
|
router.post('/verify-email', validateBody({
|
|
@@ -104,7 +102,7 @@ export function createAuth({ getUserByToken, methods: { emailAndPassword }, filt
|
|
|
104
102
|
required: ['token', 'email'],
|
|
105
103
|
}), route(async (req) => {
|
|
106
104
|
const { token, email } = req.body;
|
|
107
|
-
const storedEmail = await redis.get(token);
|
|
105
|
+
const storedEmail = await context.redis.get(token);
|
|
108
106
|
if (!storedEmail) {
|
|
109
107
|
logger.info('invalid verification token', { token });
|
|
110
108
|
throw BadRequest('invalid verification token');
|
|
@@ -116,14 +114,57 @@ export function createAuth({ getUserByToken, methods: { emailAndPassword }, filt
|
|
|
116
114
|
});
|
|
117
115
|
throw BadRequest('invalid verification token');
|
|
118
116
|
}
|
|
119
|
-
const user = await
|
|
120
|
-
setCurrentUser(user);
|
|
121
|
-
await onEmailVerified?.(user);
|
|
122
|
-
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);
|
|
123
121
|
logger.info('user verified');
|
|
124
122
|
return 200;
|
|
125
123
|
}));
|
|
126
|
-
|
|
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
|
+
}));
|
|
127
168
|
/**
|
|
128
169
|
* Check that a reset token is valid.
|
|
129
170
|
*/
|
|
@@ -140,29 +181,6 @@ export function createAuth({ getUserByToken, methods: { emailAndPassword }, filt
|
|
|
140
181
|
}
|
|
141
182
|
return 200;
|
|
142
183
|
}));
|
|
143
|
-
/**
|
|
144
|
-
* Request password reset
|
|
145
|
-
*/
|
|
146
|
-
router.post('/reset', middleware(false), validateBody({
|
|
147
|
-
type: 'object',
|
|
148
|
-
properties: { email: emailSchema },
|
|
149
|
-
required: ['email'],
|
|
150
|
-
}), route(async (req) => {
|
|
151
|
-
const { email } = req.body;
|
|
152
|
-
if (!emailAndPassword) {
|
|
153
|
-
throw new Error('Not applicable for login method');
|
|
154
|
-
}
|
|
155
|
-
try {
|
|
156
|
-
const user = await emailAndPassword(email, skipPasswordCheck);
|
|
157
|
-
const temporaryToken = await createTemporaryToken(user);
|
|
158
|
-
await onPasswordResetRequest?.(user, temporaryToken);
|
|
159
|
-
logger.info('password reset request', { email });
|
|
160
|
-
}
|
|
161
|
-
catch (error) {
|
|
162
|
-
logger.error(error, 'auth reset failed');
|
|
163
|
-
}
|
|
164
|
-
return 202;
|
|
165
|
-
}));
|
|
166
184
|
router.get('/token/refresh', middleware(false), route(async (req, res) => {
|
|
167
185
|
const user = useCurrentUser();
|
|
168
186
|
if (!user) {
|
|
@@ -170,11 +188,14 @@ export function createAuth({ getUserByToken, methods: { emailAndPassword }, filt
|
|
|
170
188
|
}
|
|
171
189
|
const { exp, iat, ...rest } = user;
|
|
172
190
|
const token = createAuthToken(rest);
|
|
173
|
-
await redis.set(token, '1', { EX:
|
|
191
|
+
await redis.set(token, '1', { EX: TOKEN_EXPIRES_IN });
|
|
174
192
|
setAccessTokenCookie(res, token);
|
|
175
193
|
logger.info('refreshed token', { user });
|
|
176
194
|
return { token };
|
|
177
195
|
}));
|
|
196
|
+
for (const provider of providers) {
|
|
197
|
+
provider.setupRoutes(router, providerContext);
|
|
198
|
+
}
|
|
178
199
|
return router;
|
|
179
200
|
};
|
|
180
201
|
function middleware(authorize = true) {
|
|
@@ -195,11 +216,8 @@ export function createAuth({ getUserByToken, methods: { emailAndPassword }, filt
|
|
|
195
216
|
}
|
|
196
217
|
throw new InvalidToken('token expired');
|
|
197
218
|
}
|
|
198
|
-
const decoded =
|
|
199
|
-
|
|
200
|
-
throw new Error('Failed to decode token');
|
|
201
|
-
}
|
|
202
|
-
const user = await getUserByToken({ ...decoded });
|
|
219
|
+
const decoded = decodeAuthToken(token);
|
|
220
|
+
const user = await getUserByToken(decoded);
|
|
203
221
|
setCurrentUser(user);
|
|
204
222
|
next();
|
|
205
223
|
}
|
|
@@ -210,6 +228,10 @@ export function createAuth({ getUserByToken, methods: { emailAndPassword }, filt
|
|
|
210
228
|
}
|
|
211
229
|
};
|
|
212
230
|
}
|
|
231
|
+
/**
|
|
232
|
+
* Create a short-lived token tied to a user that can be
|
|
233
|
+
* used for different kinds of verification.
|
|
234
|
+
*/
|
|
213
235
|
async function createTemporaryToken(user) {
|
|
214
236
|
try {
|
|
215
237
|
const temporaryToken = crypto.randomBytes(32).toString('hex');
|
|
@@ -270,6 +292,11 @@ export function extractAccessTokenFromRequest(req) {
|
|
|
270
292
|
}
|
|
271
293
|
return null;
|
|
272
294
|
}
|
|
295
|
+
/**
|
|
296
|
+
* Set or expire the access token cookie.
|
|
297
|
+
*
|
|
298
|
+
* If the provided token is `null` the cookie will be cleared.
|
|
299
|
+
*/
|
|
273
300
|
export function setAccessTokenCookie(res, token) {
|
|
274
301
|
if (token == null) {
|
|
275
302
|
res.cookie('access_token', v4(), {
|
|
@@ -290,6 +317,9 @@ class InvalidToken extends Error {
|
|
|
290
317
|
this.name = 'Invalid Token';
|
|
291
318
|
}
|
|
292
319
|
}
|
|
320
|
+
/**
|
|
321
|
+
* Compare two password hashes using bcrypt.
|
|
322
|
+
*/
|
|
293
323
|
export async function checkIfPasswordsMatch(realPassword, attemptedPassword) {
|
|
294
324
|
if (!realPassword) {
|
|
295
325
|
throw new Error('User has no defined password');
|
|
@@ -300,6 +330,9 @@ export async function checkIfPasswordsMatch(realPassword, attemptedPassword) {
|
|
|
300
330
|
}
|
|
301
331
|
return true;
|
|
302
332
|
}
|
|
333
|
+
/**
|
|
334
|
+
* Returns a hash of a password suitable for storage.
|
|
335
|
+
*/
|
|
303
336
|
export function hashPassword(password) {
|
|
304
337
|
return bcrypt.hash(password, 10);
|
|
305
338
|
}
|
package/dist/cache.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type RedisClient } from './redis.js';
|
|
2
|
+
export interface CacheAdapter {
|
|
3
|
+
get(key: string): Promise<any>;
|
|
4
|
+
set(key: string, value: any, expires?: number | null | undefined): Promise<void>;
|
|
5
|
+
clear(key?: string): Promise<void>;
|
|
6
|
+
}
|
|
7
|
+
export declare function createRedisCacheAdapter(redis: RedisClient): CacheAdapter;
|
|
8
|
+
export declare function createMemoryCacheAdapter(): CacheAdapter;
|
|
9
|
+
export declare function createCache(adapter: CacheAdapter): {
|
|
10
|
+
set: (key: string, value: any, expires?: number | null | undefined) => Promise<void>;
|
|
11
|
+
get: (key: string) => Promise<any>;
|
|
12
|
+
clear: (key?: string | undefined) => Promise<void>;
|
|
13
|
+
wrap: <T>(key: string, expiresMs: number | null | undefined, fn: () => Promise<T>) => Promise<T>;
|
|
14
|
+
};
|
package/dist/cache.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export function createRedisCacheAdapter(redis) {
|
|
2
|
+
return {
|
|
3
|
+
async get(key) {
|
|
4
|
+
const value = await redis.get(key);
|
|
5
|
+
if (value == null) {
|
|
6
|
+
return null;
|
|
7
|
+
}
|
|
8
|
+
return JSON.parse(value);
|
|
9
|
+
},
|
|
10
|
+
async set(key, value, expires) {
|
|
11
|
+
await redis.set(key, JSON.stringify(value), { PX: expires });
|
|
12
|
+
},
|
|
13
|
+
async clear(key) {
|
|
14
|
+
if (key) {
|
|
15
|
+
await redis.del(key);
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export function createMemoryCacheAdapter() {
|
|
21
|
+
const cache = new Map();
|
|
22
|
+
return {
|
|
23
|
+
async get(key) {
|
|
24
|
+
const entry = cache.get(key);
|
|
25
|
+
if (entry == null) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
const [value, expires] = entry;
|
|
29
|
+
if (expires != null && Date.now() >= expires) {
|
|
30
|
+
cache.delete(key);
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
return value;
|
|
34
|
+
},
|
|
35
|
+
async set(key, value, expires) {
|
|
36
|
+
cache.set(key, [value, expires == null ? null : Date.now() + expires]);
|
|
37
|
+
},
|
|
38
|
+
async clear(key) {
|
|
39
|
+
if (key) {
|
|
40
|
+
cache.delete(key);
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
cache.clear();
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
export function createCache(adapter) {
|
|
49
|
+
async function cached(key, expiresMs, fn) {
|
|
50
|
+
const cachedValue = await adapter.get(key);
|
|
51
|
+
if (cachedValue != null) {
|
|
52
|
+
return cachedValue;
|
|
53
|
+
}
|
|
54
|
+
const result = await fn();
|
|
55
|
+
await adapter.set(key, result, expiresMs);
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
set: adapter.set.bind(adapter),
|
|
60
|
+
get: adapter.get.bind(adapter),
|
|
61
|
+
clear: adapter.clear.bind(adapter),
|
|
62
|
+
wrap: cached,
|
|
63
|
+
};
|
|
64
|
+
}
|
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/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/logger.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/// <reference types="node" resolution-mode="require"/>
|
|
1
2
|
import type { CreateSentry } from './sentry.js';
|
|
2
3
|
export type LoggerContext = Record<string, any>;
|
|
3
4
|
export interface LoggerOptions {
|
|
@@ -13,11 +14,12 @@ export interface LoggerOptions {
|
|
|
13
14
|
* Defaults to `process.env.NODE_ENV === 'test'`
|
|
14
15
|
*/
|
|
15
16
|
silent?: () => boolean;
|
|
17
|
+
output?: NodeJS.WriteStream;
|
|
16
18
|
}
|
|
17
19
|
export declare function attachSentryClientToLoggers(sentryClient: CreateSentry['client']): void;
|
|
18
20
|
export declare function setGlobalLoggerContext(getContext: () => LoggerContext): void;
|
|
19
21
|
export type Logger = ReturnType<typeof createLogger>;
|
|
20
|
-
export declare function createLogger({ name, getContext, silent, }: LoggerOptions): {
|
|
22
|
+
export declare function createLogger({ name, getContext, silent, output, }: LoggerOptions): {
|
|
21
23
|
debug(message: string, context?: LoggerContext): void;
|
|
22
24
|
info(message: string, context?: LoggerContext): void;
|
|
23
25
|
warn(message: string, context?: LoggerContext): void;
|
package/dist/logger.js
CHANGED
|
@@ -24,12 +24,13 @@ function defaultGetContext() {
|
|
|
24
24
|
..._globalGetContext?.(),
|
|
25
25
|
};
|
|
26
26
|
}
|
|
27
|
-
export function createLogger({ name, getContext, silent = () => process.env.NODE_ENV === 'test', }) {
|
|
27
|
+
export function createLogger({ name, getContext, silent = () => process.env.NODE_ENV === 'test', output = process.stdout, }) {
|
|
28
28
|
if (_loggers.has(name)) {
|
|
29
29
|
throw new Error(`A logger named ${name} already exists`);
|
|
30
30
|
}
|
|
31
31
|
const hostname = os.hostname();
|
|
32
32
|
const pid = process.pid;
|
|
33
|
+
const env = process.env.NODE_ENV;
|
|
33
34
|
const logLevel = silent() ? LEVELS.FATAL + 1 : LEVELS.INFO;
|
|
34
35
|
function getFinalContext(context) {
|
|
35
36
|
return {
|
|
@@ -41,6 +42,7 @@ export function createLogger({ name, getContext, silent = () => process.env.NODE
|
|
|
41
42
|
function log(level, message, context) {
|
|
42
43
|
const payload = JSON.stringify({
|
|
43
44
|
level: LEVELS_TO_NAME[level],
|
|
45
|
+
env,
|
|
44
46
|
hostname,
|
|
45
47
|
pid,
|
|
46
48
|
name,
|
|
@@ -50,7 +52,7 @@ export function createLogger({ name, getContext, silent = () => process.env.NODE
|
|
|
50
52
|
v: 1,
|
|
51
53
|
});
|
|
52
54
|
if (level >= logLevel) {
|
|
53
|
-
|
|
55
|
+
output.write(payload + '\n');
|
|
54
56
|
}
|
|
55
57
|
}
|
|
56
58
|
const api = {
|
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/middleware.js
CHANGED
|
@@ -104,7 +104,7 @@ export function after() {
|
|
|
104
104
|
* Alias of `route()`
|
|
105
105
|
*/
|
|
106
106
|
export function asyncMiddleware(fn) {
|
|
107
|
-
|
|
107
|
+
const handler = (req, res, next) => {
|
|
108
108
|
return Promise.resolve(fn(req, res, next))
|
|
109
109
|
.then((result) => {
|
|
110
110
|
if (Array.isArray(result) &&
|
|
@@ -125,4 +125,6 @@ export function asyncMiddleware(fn) {
|
|
|
125
125
|
})
|
|
126
126
|
.catch(next);
|
|
127
127
|
};
|
|
128
|
+
Object.defineProperty(handler, 'name', { value: fn.name });
|
|
129
|
+
return handler;
|
|
128
130
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@devmoods/express-extras",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.0",
|
|
4
4
|
"description": "Kubernetes-ready utilities for rapid Node.js application development",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -24,16 +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.64.0",
|
|
28
28
|
"@types/express-serve-static-core": "^4.17.35",
|
|
29
|
-
"@types/node": "20.
|
|
29
|
+
"@types/node": "20.5.0",
|
|
30
30
|
"@types/supertest": "^2.0.12",
|
|
31
31
|
"@typescript-eslint/eslint-plugin": "^6.3.0",
|
|
32
32
|
"@typescript-eslint/parser": "^6.3.0",
|
|
33
33
|
"@vitest/coverage-v8": "^0.34.1",
|
|
34
34
|
"@vitest/expect": "^0.34.1",
|
|
35
35
|
"chalk": "^5.3.0",
|
|
36
|
-
"eslint": "^8.
|
|
36
|
+
"eslint": "^8.47.0",
|
|
37
37
|
"eslint-plugin-jsx-a11y": "^6.7.1",
|
|
38
38
|
"eslint-plugin-prettier": "^5.0.0",
|
|
39
39
|
"eslint-plugin-react": "^7.33.1",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"dependencies": {
|
|
52
52
|
"@devmoods/fetch": "^3.3.0",
|
|
53
53
|
"@godaddy/terminus": "^4.12.1",
|
|
54
|
-
"@sentry/node": "^7.
|
|
54
|
+
"@sentry/node": "^7.64.0",
|
|
55
55
|
"@types/bcrypt": "^5.0.0",
|
|
56
56
|
"@types/body-parser": "^1.19.2",
|
|
57
57
|
"@types/cookie-parser": "^1.4.3",
|