@devmoods/express-extras 0.72.5 → 0.74.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/dist/auth/emailAndPassword.js +14 -8
- package/dist/auth/google.d.ts +2 -1
- package/dist/auth/google.js +14 -6
- package/dist/auth/passwordless.d.ts +1 -1
- package/dist/auth/passwordless.js +23 -11
- package/dist/auth/token.js +2 -1
- package/dist/auth/tokenStorage.d.ts +1 -1
- package/dist/auth/tokenStorage.js +1 -1
- package/dist/auth/types.d.ts +6 -2
- package/dist/auth.d.ts +3 -3
- package/dist/auth.js +54 -25
- package/dist/cache.d.ts +26 -5
- package/dist/cache.js +129 -66
- package/dist/composeMiddleware.js +2 -2
- package/dist/config.d.ts +2 -0
- package/dist/config.js +1 -0
- package/dist/csrf.js +4 -0
- package/dist/errors.d.ts +0 -2
- package/dist/errors.js +0 -11
- package/dist/index.d.ts +0 -1
- package/dist/index.js +0 -1
- package/dist/listen.d.ts +1 -0
- package/dist/listen.js +2 -2
- package/dist/logger.d.ts +3 -9
- package/dist/logger.js +16 -28
- package/dist/manage.js +3 -3
- package/dist/middleware.js +29 -2
- package/dist/utils.d.ts +0 -1
- package/dist/utils.js +0 -1
- package/dist/vite/ssr.js +10 -2
- package/package.json +11 -13
- package/dist/sentry.d.ts +0 -5
- package/dist/sentry.js +0 -38
|
@@ -6,7 +6,7 @@ import {} from './types.js';
|
|
|
6
6
|
import { Unauthorized } from '../errors.js';
|
|
7
7
|
import { getLogger } from '../logger.js';
|
|
8
8
|
import { jsonSchema } from '../validate.js';
|
|
9
|
-
const passwordSchema = { type: 'string', minLength:
|
|
9
|
+
const passwordSchema = { type: 'string', minLength: 8 };
|
|
10
10
|
const emailSchema = { type: 'string', minLength: 4 };
|
|
11
11
|
const logger = getLogger('auth');
|
|
12
12
|
export function EmailAndPasswordAuthProvider(options) {
|
|
@@ -35,7 +35,7 @@ export function EmailAndPasswordAuthProvider(options) {
|
|
|
35
35
|
catch (error) {
|
|
36
36
|
logger.error(error);
|
|
37
37
|
await context.jitter(timer.getDuration());
|
|
38
|
-
throw Unauthorized(
|
|
38
|
+
throw Unauthorized('Invalid email or password', { cause: error });
|
|
39
39
|
}
|
|
40
40
|
});
|
|
41
41
|
router.post('/change-password', {
|
|
@@ -73,11 +73,18 @@ export function EmailAndPasswordAuthProvider(options) {
|
|
|
73
73
|
}, async (req) => {
|
|
74
74
|
const { email } = req.body;
|
|
75
75
|
const timer = startTimer();
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
76
|
+
try {
|
|
77
|
+
const user = await options.login(email, skipPasswordCheck);
|
|
78
|
+
const temporaryToken = await context.createTemporaryToken(user);
|
|
79
|
+
await options.onEmailVerificationRequest?.(user, temporaryToken);
|
|
80
|
+
await context.jitter(timer.getDuration());
|
|
81
|
+
return 202;
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
logger.error(error);
|
|
85
|
+
await context.jitter(timer.getDuration());
|
|
86
|
+
return 202;
|
|
87
|
+
}
|
|
81
88
|
});
|
|
82
89
|
router.post('/verify-email', {
|
|
83
90
|
request: jsonSchema({
|
|
@@ -93,7 +100,6 @@ export function EmailAndPasswordAuthProvider(options) {
|
|
|
93
100
|
}
|
|
94
101
|
catch (error) {
|
|
95
102
|
logger.warn('failed to verify token', {
|
|
96
|
-
token,
|
|
97
103
|
user: context.filterUser(user),
|
|
98
104
|
error: error.message,
|
|
99
105
|
});
|
package/dist/auth/google.d.ts
CHANGED
|
@@ -7,7 +7,8 @@ interface GoogleAuthProviderOptions<UserModel = AnyUser> {
|
|
|
7
7
|
clientId?: string;
|
|
8
8
|
clientSecret?: string;
|
|
9
9
|
redirectUri?: string;
|
|
10
|
+
hostedDomain?: string;
|
|
10
11
|
getOrCreateUserFromUserIdentifier(userIdentifier: string, profile: GoogleProfile): Promise<UserModel>;
|
|
11
12
|
}
|
|
12
|
-
export declare function GoogleAuthProvider<UserModel extends AnyUser>({ clientId, clientSecret, redirectUri, getOrCreateUserFromUserIdentifier }: GoogleAuthProviderOptions<UserModel>): AuthProvider<UserModel>;
|
|
13
|
+
export declare function GoogleAuthProvider<UserModel extends AnyUser>({ clientId, clientSecret, redirectUri, hostedDomain, getOrCreateUserFromUserIdentifier }: GoogleAuthProviderOptions<UserModel>): AuthProvider<UserModel>;
|
|
13
14
|
export {};
|
package/dist/auth/google.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { config } from '@devmoods/config';
|
|
2
2
|
import { type } from 'arktype';
|
|
3
3
|
import { OAuth2Client } from 'google-auth-library';
|
|
4
|
-
import { BadRequest, Unauthorized } from '../errors.js';
|
|
5
|
-
import { getLogger } from '../logger.js';
|
|
6
4
|
import { createTokenResponse } from './token.js';
|
|
7
5
|
import {} from './types.js';
|
|
6
|
+
import { BadRequest, Unauthorized } from '../errors.js';
|
|
7
|
+
import { getLogger } from '../logger.js';
|
|
8
8
|
const logger = getLogger('auth');
|
|
9
|
-
export function GoogleAuthProvider({ clientId = config.GOOGLE_AUTH_CLIENT_ID, clientSecret = config.GOOGLE_AUTH_CLIENT_SECRET, redirectUri = `${config.PUBLIC_URL}/api/v1/auth/google/callback`, getOrCreateUserFromUserIdentifier, }) {
|
|
9
|
+
export function GoogleAuthProvider({ clientId = config.GOOGLE_AUTH_CLIENT_ID, clientSecret = config.GOOGLE_AUTH_CLIENT_SECRET, redirectUri = `${config.PUBLIC_URL}/api/v1/auth/google/callback`, hostedDomain, getOrCreateUserFromUserIdentifier, }) {
|
|
10
10
|
const client = new OAuth2Client({
|
|
11
11
|
clientId,
|
|
12
12
|
clientSecret,
|
|
@@ -79,10 +79,18 @@ export function GoogleAuthProvider({ clientId = config.GOOGLE_AUTH_CLIENT_ID, cl
|
|
|
79
79
|
throw Unauthorized('Invalid id token');
|
|
80
80
|
}
|
|
81
81
|
const payload = await verifyIdToken(idToken);
|
|
82
|
-
|
|
83
|
-
|
|
82
|
+
if (!payload.sub) {
|
|
83
|
+
throw Unauthorized('Token from Google is missing subject');
|
|
84
|
+
}
|
|
85
|
+
if (!payload.email || payload.email_verified !== true) {
|
|
86
|
+
throw Unauthorized('Token from Google contains unverified email');
|
|
87
|
+
}
|
|
88
|
+
if (hostedDomain && payload.hd !== hostedDomain) {
|
|
89
|
+
throw Unauthorized('Token from Google contains invalid hosted domain');
|
|
90
|
+
}
|
|
91
|
+
const user = await getOrCreateUserFromUserIdentifier(payload.sub, {
|
|
84
92
|
email: payload.email,
|
|
85
|
-
fullName: payload.name,
|
|
93
|
+
fullName: payload.name ?? '',
|
|
86
94
|
});
|
|
87
95
|
const accessToken = await context.login(req, res, user);
|
|
88
96
|
logger.info('google-login', { email: user.email });
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type AnyUser, type AuthProvider } from './types.js';
|
|
2
2
|
export interface PasswordlessAuthProviderOptions<UserModel = AnyUser> {
|
|
3
3
|
getUserByEmail(email: string): Promise<UserModel | null>;
|
|
4
|
-
onLoginRequest(user: UserModel, temporaryToken: string, otp: string): Promise<void>;
|
|
4
|
+
onLoginRequest(user: UserModel, challengeId: string, temporaryToken: string, otp: string): Promise<void>;
|
|
5
5
|
}
|
|
6
6
|
export declare function PasswordlessAuthProvider<UserModel extends AnyUser>(options: PasswordlessAuthProviderOptions<UserModel>): AuthProvider<UserModel>;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import * as crypto from 'node:crypto';
|
|
1
2
|
import { DoesNotExistError } from '@devmoods/postgres';
|
|
2
3
|
import { createTokenResponse } from './token.js';
|
|
3
4
|
import {} from './types.js';
|
|
5
|
+
import { BadRequest } from '../errors.js';
|
|
4
6
|
import { getLogger } from '../logger.js';
|
|
5
7
|
import { jsonSchema } from '../validate.js';
|
|
6
8
|
const logger = getLogger('auth');
|
|
@@ -25,20 +27,30 @@ export function PasswordlessAuthProvider(options) {
|
|
|
25
27
|
type: 'object',
|
|
26
28
|
properties: {
|
|
27
29
|
type: { type: 'string', enum: ['otp', 'token'] },
|
|
30
|
+
challengeId: { type: 'string' },
|
|
28
31
|
otp: { type: 'string' },
|
|
29
32
|
token: { type: 'string' },
|
|
30
33
|
},
|
|
31
34
|
required: ['type'],
|
|
32
35
|
}),
|
|
33
36
|
}, async (req, res) => {
|
|
34
|
-
const { token: temporaryToken, otp, type: tokenType } = req.body;
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
const { challengeId, token: temporaryToken, otp, type: tokenType, } = req.body;
|
|
38
|
+
if (tokenType === 'otp' && (!challengeId || !otp)) {
|
|
39
|
+
throw BadRequest('OTP logins require challengeId and otp');
|
|
40
|
+
}
|
|
41
|
+
if (tokenType === 'token' && !temporaryToken) {
|
|
42
|
+
throw BadRequest('Token logins require token');
|
|
43
|
+
}
|
|
44
|
+
const uid = tokenType === 'otp'
|
|
45
|
+
? await context.verifyOtp(String(challengeId), String(otp), null, false)
|
|
46
|
+
: await context.verifyTemporaryToken(String(temporaryToken), null, false);
|
|
40
47
|
const user = await context.getUserById(uid);
|
|
41
|
-
|
|
48
|
+
if (tokenType === 'otp') {
|
|
49
|
+
await context.verifyOtp(String(challengeId), String(otp), user);
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
await context.verifyTemporaryToken(String(temporaryToken), user);
|
|
53
|
+
}
|
|
42
54
|
const accessToken = await context.login(req, res, user);
|
|
43
55
|
logger.info('passwordless login', { tokenType, uid: user.id });
|
|
44
56
|
if (req.accepts('html')) {
|
|
@@ -62,13 +74,13 @@ export function PasswordlessAuthProvider(options) {
|
|
|
62
74
|
const user = await getUserOrNull(email);
|
|
63
75
|
if (!user) {
|
|
64
76
|
logger.warn('passwordless: user not found', { email });
|
|
65
|
-
return 202;
|
|
77
|
+
return [202, { challengeId: crypto.randomUUID() }];
|
|
66
78
|
}
|
|
67
79
|
const token = await context.createTemporaryToken(user);
|
|
68
|
-
const otp = await context.createOtp(user);
|
|
69
|
-
await options.onLoginRequest(user, token, otp);
|
|
80
|
+
const { challengeId, otp } = await context.createOtp(user);
|
|
81
|
+
await options.onLoginRequest(user, challengeId, token, otp);
|
|
70
82
|
logger.info('passwordless login requested', { email });
|
|
71
|
-
return 202;
|
|
83
|
+
return [202, { challengeId }];
|
|
72
84
|
});
|
|
73
85
|
},
|
|
74
86
|
};
|
package/dist/auth/token.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
1
2
|
import { config } from '@devmoods/config';
|
|
2
3
|
import {} from 'express';
|
|
3
4
|
import { SignJWT, jwtVerify } from 'jose';
|
|
@@ -27,7 +28,7 @@ export function createTokenResponse(accessToken) {
|
|
|
27
28
|
export async function createAccessToken(payload, version = '1', secret = config.AUTH_JWT_SECRET) {
|
|
28
29
|
const expiresIn = config.AUTH_TOKEN_EXPIRES;
|
|
29
30
|
const secretKey = new TextEncoder().encode(secret);
|
|
30
|
-
const token = await new SignJWT({ v: version, ...payload })
|
|
31
|
+
const token = await new SignJWT({ jti: randomUUID(), v: version, ...payload })
|
|
31
32
|
.setProtectedHeader({ alg: 'HS256' })
|
|
32
33
|
.setExpirationTime(`${expiresIn}s`)
|
|
33
34
|
.setIssuedAt(new Date())
|
package/dist/auth/types.d.ts
CHANGED
|
@@ -31,6 +31,10 @@ export interface DecodedTemporaryToken {
|
|
|
31
31
|
};
|
|
32
32
|
signature: string;
|
|
33
33
|
}
|
|
34
|
+
export interface OtpChallenge {
|
|
35
|
+
challengeId: string;
|
|
36
|
+
otp: string;
|
|
37
|
+
}
|
|
34
38
|
export interface AuthProviderContext<UserModel extends AnyUser> {
|
|
35
39
|
filterUser: AuthOptions<UserModel>['filterUser'];
|
|
36
40
|
tokenStorage: AuthOptions<UserModel>['tokenStorage'];
|
|
@@ -38,8 +42,8 @@ export interface AuthProviderContext<UserModel extends AnyUser> {
|
|
|
38
42
|
createTemporaryToken(user: UserModel | null): Promise<string>;
|
|
39
43
|
verifyTemporaryToken(token: string, user: UserModel | null, consume?: boolean): Promise<string>;
|
|
40
44
|
decodeTemporaryToken(token: string): DecodedTemporaryToken;
|
|
41
|
-
createOtp(user: UserModel): Promise<
|
|
42
|
-
verifyOtp(otp: string, user: UserModel | null, consume?: boolean): Promise<string>;
|
|
45
|
+
createOtp(user: UserModel): Promise<OtpChallenge>;
|
|
46
|
+
verifyOtp(challengeId: string, otp: string, user: UserModel | null, consume?: boolean): Promise<string>;
|
|
43
47
|
middleware: (options?: AuthMiddlewareOptions<UserModel>) => RequestHandler;
|
|
44
48
|
login: (req: Request, res: Response, user: UserModel, isRefresh?: boolean) => Promise<AccessToken>;
|
|
45
49
|
getUserById(id: string): Promise<UserModel>;
|
package/dist/auth.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type RequestHandler, type Response, type Request, type Router } from 'express';
|
|
2
|
-
import { type AnyUser, type AuthIsAllowedFn, type AuthMiddlewareOptions, type AuthOptions, type DecodedTemporaryToken } from './auth/types.js';
|
|
2
|
+
import { type AnyUser, type AuthIsAllowedFn, type AuthMiddlewareOptions, type AuthOptions, type DecodedTemporaryToken, type OtpChallenge } from './auth/types.js';
|
|
3
3
|
export * from './auth/types.js';
|
|
4
4
|
export * from './auth/emailAndPassword.js';
|
|
5
5
|
export * from './auth/passwordless.js';
|
|
@@ -15,8 +15,8 @@ export interface CreateAuth<UserModel extends AnyUser> {
|
|
|
15
15
|
createTemporaryToken(user: UserModel | null): Promise<string>;
|
|
16
16
|
verifyTemporaryToken(token: string, user: UserModel | null, consume?: boolean): Promise<string>;
|
|
17
17
|
decodeTemporaryToken(token: string): DecodedTemporaryToken;
|
|
18
|
-
createOtp(user: UserModel): Promise<
|
|
19
|
-
verifyOtp(otp: string, user: UserModel | null, consume?: boolean): Promise<string>;
|
|
18
|
+
createOtp(user: UserModel): Promise<OtpChallenge>;
|
|
19
|
+
verifyOtp(challengeId: string, otp: string, user: UserModel | null, consume?: boolean): Promise<string>;
|
|
20
20
|
logout: (req: Request, res: Response) => Promise<void>;
|
|
21
21
|
setSessionTokenVersion: (version: string) => void;
|
|
22
22
|
}
|
package/dist/auth.js
CHANGED
|
@@ -5,7 +5,7 @@ import { InvalidToken, PermissionDenied } from './auth/errors.js';
|
|
|
5
5
|
import { createAccessToken, decodeAccessToken, extractAccessTokenFromRequest, setAccessTokenCookie, tokenSchema, } from './auth/token.js';
|
|
6
6
|
import {} from './auth/types.js';
|
|
7
7
|
import { setRequestContext, useRequestContext } from './context.js';
|
|
8
|
-
import { Forbidden,
|
|
8
|
+
import { Forbidden, Unauthorized } from './errors.js';
|
|
9
9
|
import { getLogger } from './logger.js';
|
|
10
10
|
import { createRouter } from './router.js';
|
|
11
11
|
import { jsonSchema } from './validate.js';
|
|
@@ -17,14 +17,27 @@ export * from './auth/token.js';
|
|
|
17
17
|
export * from './auth/google.js';
|
|
18
18
|
export * from './auth/tokenStorage.js';
|
|
19
19
|
const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
|
|
20
|
+
const hashEquals = (left, right) => {
|
|
21
|
+
const leftBuffer = Buffer.from(left, 'hex');
|
|
22
|
+
const rightBuffer = Buffer.from(right, 'hex');
|
|
23
|
+
if (leftBuffer.length !== rightBuffer.length) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
return crypto.timingSafeEqual(leftBuffer, rightBuffer);
|
|
27
|
+
};
|
|
20
28
|
export function createAuth({ getUserById, filterUser, tokenStorage, providers, loginUrl = '/', onUserLoggedIn, sessionTokenVersion: initialSessionTokenVersion = '1', }) {
|
|
21
29
|
let sessionTokenVersion = initialSessionTokenVersion;
|
|
22
30
|
const useCurrentUser = () => useRequestContext('currentUser');
|
|
23
31
|
const setCurrentUser = (user) => setRequestContext('currentUser', user);
|
|
24
32
|
const login = async (req, res, user, isRefresh = false) => {
|
|
33
|
+
const previousToken = isRefresh ? extractAccessTokenFromRequest(req) : null;
|
|
34
|
+
const previousSessionId = previousToken ? sha256(previousToken) : null;
|
|
25
35
|
const accessToken = await createAccessToken({ uid: user.id }, sessionTokenVersion);
|
|
26
36
|
const sessionId = sha256(accessToken.token);
|
|
27
37
|
await tokenStorage.set(sessionId, String(user.id), accessToken.expiresIn);
|
|
38
|
+
if (previousSessionId && previousSessionId !== sessionId) {
|
|
39
|
+
await tokenStorage.invalidate(previousSessionId);
|
|
40
|
+
}
|
|
28
41
|
setAccessTokenCookie(res, accessToken.token);
|
|
29
42
|
setCurrentUser(user);
|
|
30
43
|
if (!isRefresh) {
|
|
@@ -77,29 +90,18 @@ export function createAuth({ getUserById, filterUser, tokenStorage, providers, l
|
|
|
77
90
|
const user = useCurrentUser();
|
|
78
91
|
return filterUser(user);
|
|
79
92
|
});
|
|
80
|
-
router.
|
|
93
|
+
router.post('/logout', {}, async (req, res) => {
|
|
81
94
|
await logout(req, res);
|
|
82
95
|
res.redirect(loginUrl);
|
|
83
96
|
});
|
|
84
|
-
/**
|
|
85
|
-
* Check that a reset token is valid.
|
|
86
|
-
*/
|
|
87
97
|
router.get('/reset/:token', {
|
|
88
98
|
params: jsonSchema({
|
|
89
99
|
type: 'object',
|
|
90
100
|
properties: { token: tokenSchema },
|
|
91
101
|
required: ['token'],
|
|
92
102
|
}),
|
|
93
|
-
}, async (
|
|
94
|
-
|
|
95
|
-
const value = await tokenStorage.get(token);
|
|
96
|
-
if (!value) {
|
|
97
|
-
logger.info('invalid reset token', { token });
|
|
98
|
-
throw NotFound();
|
|
99
|
-
}
|
|
100
|
-
return 200;
|
|
101
|
-
});
|
|
102
|
-
router.get('/token/refresh', {}, middleware(false), async (req, res) => {
|
|
103
|
+
}, async () => 200);
|
|
104
|
+
router.post('/token/refresh', {}, middleware(false), async (req, res) => {
|
|
103
105
|
const user = useCurrentUser();
|
|
104
106
|
if (!user) {
|
|
105
107
|
setAccessTokenCookie(res, null);
|
|
@@ -172,7 +174,7 @@ export function createAuth({ getUserById, filterUser, tokenStorage, providers, l
|
|
|
172
174
|
if (req.accepts('html')) {
|
|
173
175
|
return res.redirect(loginUrl);
|
|
174
176
|
}
|
|
175
|
-
return next(Unauthorized(
|
|
177
|
+
return next(Unauthorized('Invalid token', { cause: error }));
|
|
176
178
|
}
|
|
177
179
|
/* v8 ignore next -- @preserve */
|
|
178
180
|
next(error);
|
|
@@ -182,6 +184,7 @@ export function createAuth({ getUserById, filterUser, tokenStorage, providers, l
|
|
|
182
184
|
function protect(isAuthorized) {
|
|
183
185
|
return async function authorizationMiddleware(req, res, next) {
|
|
184
186
|
try {
|
|
187
|
+
// oxlint-disable-next-line eslint-plugin-react-hooks/rules-of-hooks
|
|
185
188
|
const user = useCurrentUser();
|
|
186
189
|
if (!user) {
|
|
187
190
|
throw new InvalidToken('User not logged in');
|
|
@@ -213,22 +216,48 @@ export function createAuth({ getUserById, filterUser, tokenStorage, providers, l
|
|
|
213
216
|
}
|
|
214
217
|
async function createOtp(user) {
|
|
215
218
|
const otp = crypto.randomInt(100000, 999999).toString();
|
|
216
|
-
|
|
217
|
-
|
|
219
|
+
const challengeId = crypto.randomUUID();
|
|
220
|
+
const otpHash = sha256(otp);
|
|
221
|
+
await tokenStorage.set(`otp:${challengeId}`, JSON.stringify({
|
|
222
|
+
uid: String(user.id),
|
|
223
|
+
otpHash,
|
|
224
|
+
attempts: 0,
|
|
225
|
+
maxAttempts: config.AUTH_OTP_MAX_ATTEMPTS,
|
|
226
|
+
}), config.AUTH_OTP_EXPIRES);
|
|
227
|
+
return { challengeId, otp };
|
|
218
228
|
}
|
|
219
|
-
async function verifyOtp(otp, user, consume = true) {
|
|
220
|
-
const
|
|
221
|
-
if (!
|
|
229
|
+
async function verifyOtp(challengeId, otp, user, consume = true) {
|
|
230
|
+
const storedChallenge = await tokenStorage.get(`otp:${challengeId}`);
|
|
231
|
+
if (!storedChallenge) {
|
|
222
232
|
throw new InvalidToken('Invalid OTP');
|
|
223
233
|
}
|
|
224
|
-
|
|
225
|
-
|
|
234
|
+
let parsedChallenge;
|
|
235
|
+
try {
|
|
236
|
+
parsedChallenge = JSON.parse(storedChallenge);
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
await tokenStorage.invalidate(`otp:${challengeId}`);
|
|
240
|
+
throw new InvalidToken('Invalid OTP', { cause: error });
|
|
241
|
+
}
|
|
242
|
+
if (user && parsedChallenge.uid !== String(user.id)) {
|
|
243
|
+
throw new InvalidToken('Invalid OTP');
|
|
244
|
+
}
|
|
245
|
+
const attemptedOtpHash = sha256(otp);
|
|
246
|
+
const matches = hashEquals(parsedChallenge.otpHash, attemptedOtpHash);
|
|
247
|
+
if (!matches) {
|
|
248
|
+
const attempts = parsedChallenge.attempts + 1;
|
|
249
|
+
if (attempts >= parsedChallenge.maxAttempts) {
|
|
250
|
+
await tokenStorage.invalidate(`otp:${challengeId}`);
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
await tokenStorage.set(`otp:${challengeId}`, JSON.stringify({ ...parsedChallenge, attempts }), config.AUTH_OTP_EXPIRES);
|
|
254
|
+
}
|
|
226
255
|
throw new InvalidToken('Invalid OTP');
|
|
227
256
|
}
|
|
228
257
|
if (consume) {
|
|
229
|
-
await tokenStorage.invalidate(otp);
|
|
258
|
+
await tokenStorage.invalidate(`otp:${challengeId}`);
|
|
230
259
|
}
|
|
231
|
-
return
|
|
260
|
+
return parsedChallenge.uid;
|
|
232
261
|
}
|
|
233
262
|
/**
|
|
234
263
|
* Create a short-lived token tied to a user that can be
|
package/dist/cache.d.ts
CHANGED
|
@@ -3,16 +3,37 @@ export interface CacheOptions {
|
|
|
3
3
|
key?: string;
|
|
4
4
|
expiresMs?: number | null;
|
|
5
5
|
}
|
|
6
|
-
export
|
|
7
|
-
get(key: string): Promise<any>;
|
|
8
|
-
set(key: string, value: any, expires?: number | null | undefined): Promise<void>;
|
|
9
|
-
clear(key?: string): Promise<void>;
|
|
6
|
+
export declare abstract class CacheAdapter {
|
|
7
|
+
abstract get(key: string): Promise<any>;
|
|
8
|
+
abstract set(key: string, value: any, expires?: number | null | undefined): Promise<void>;
|
|
9
|
+
abstract clear(key?: string): Promise<void>;
|
|
10
10
|
}
|
|
11
11
|
export interface RedisCacheAdapterOptions {
|
|
12
12
|
keyPrefix?: string;
|
|
13
13
|
}
|
|
14
|
+
export interface MemoryCacheAdapterOptions {
|
|
15
|
+
maxSize?: number;
|
|
16
|
+
}
|
|
17
|
+
export declare class RedisCacheAdapter extends CacheAdapter {
|
|
18
|
+
redis: RedisClient;
|
|
19
|
+
keyPrefix: string;
|
|
20
|
+
constructor(redis: RedisClient, { keyPrefix }?: RedisCacheAdapterOptions);
|
|
21
|
+
get(key: string): Promise<any>;
|
|
22
|
+
set(key: string, value: unknown, expires?: number | null): Promise<void>;
|
|
23
|
+
clear(key?: string): Promise<void>;
|
|
24
|
+
private withKeyPrefix;
|
|
25
|
+
}
|
|
14
26
|
export declare function createRedisCacheAdapter(redis: RedisClient, options?: RedisCacheAdapterOptions): CacheAdapter;
|
|
15
|
-
export declare
|
|
27
|
+
export declare class MemoryCacheAdapter extends CacheAdapter {
|
|
28
|
+
#private;
|
|
29
|
+
maxSize?: number;
|
|
30
|
+
constructor({ maxSize }?: MemoryCacheAdapterOptions);
|
|
31
|
+
get(key: string): Promise<unknown>;
|
|
32
|
+
set(key: string, value: unknown, expires?: number): Promise<void>;
|
|
33
|
+
clear(key?: string): Promise<void>;
|
|
34
|
+
private evictLeastRecentlyUsedEntries;
|
|
35
|
+
}
|
|
36
|
+
export declare function createMemoryCacheAdapter(options?: MemoryCacheAdapterOptions): CacheAdapter;
|
|
16
37
|
export type Cache = ReturnType<typeof createCache>;
|
|
17
38
|
export declare function createCache(adapter: CacheAdapter): {
|
|
18
39
|
set: (key: string, value: any, expires?: number | null | undefined) => Promise<void>;
|
package/dist/cache.js
CHANGED
|
@@ -1,62 +1,116 @@
|
|
|
1
1
|
import { config } from '@devmoods/config';
|
|
2
2
|
import {} from './redis.js';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
3
|
+
const MAX_CACHE_KEY_SIZE_BYTES = 256;
|
|
4
|
+
export class CacheAdapter {
|
|
5
|
+
}
|
|
6
|
+
export class RedisCacheAdapter extends CacheAdapter {
|
|
7
|
+
redis;
|
|
8
|
+
keyPrefix;
|
|
9
|
+
constructor(redis, { keyPrefix = `${config.REDIS_KEY_PREFIX}cache:`, } = {}) {
|
|
10
|
+
super();
|
|
11
|
+
this.redis = redis;
|
|
12
|
+
this.keyPrefix = keyPrefix;
|
|
13
|
+
}
|
|
14
|
+
async get(key) {
|
|
15
|
+
const value = await this.redis.get(this.withKeyPrefix(key));
|
|
16
|
+
if (value == null) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
return JSON.parse(value);
|
|
20
|
+
}
|
|
21
|
+
async set(key, value, expires) {
|
|
22
|
+
const serializedValue = JSON.stringify(value);
|
|
23
|
+
if (expires == null) {
|
|
24
|
+
await this.redis.set(this.withKeyPrefix(key), serializedValue);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
await this.redis.set(this.withKeyPrefix(key), serializedValue, {
|
|
28
|
+
PX: expires,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
async clear(key) {
|
|
32
|
+
if (key) {
|
|
33
|
+
await this.redis.del(this.withKeyPrefix(key));
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
let cursor = '0';
|
|
37
|
+
const pattern = `${this.keyPrefix}*`;
|
|
38
|
+
do {
|
|
39
|
+
const result = await this.redis.scan(cursor, {
|
|
40
|
+
MATCH: pattern,
|
|
41
|
+
COUNT: 100,
|
|
17
42
|
});
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
if (
|
|
21
|
-
await redis.
|
|
22
|
-
}
|
|
23
|
-
const keys = await redis.keys(`${keyPrefix}*`);
|
|
24
|
-
for (const existingKey of keys) {
|
|
25
|
-
await redis.del(existingKey);
|
|
43
|
+
cursor = result.cursor;
|
|
44
|
+
/* v8 ignore next -- @preserve */
|
|
45
|
+
if (result.keys.length > 0) {
|
|
46
|
+
await this.redis.unlink(result.keys);
|
|
26
47
|
}
|
|
27
|
-
}
|
|
28
|
-
}
|
|
48
|
+
} while (cursor !== '0');
|
|
49
|
+
}
|
|
50
|
+
withKeyPrefix(key) {
|
|
51
|
+
return `${this.keyPrefix}${key}`;
|
|
52
|
+
}
|
|
29
53
|
}
|
|
30
|
-
export function
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
return
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
+
export function createRedisCacheAdapter(redis, options = {}) {
|
|
55
|
+
return new RedisCacheAdapter(redis, options);
|
|
56
|
+
}
|
|
57
|
+
export class MemoryCacheAdapter extends CacheAdapter {
|
|
58
|
+
maxSize;
|
|
59
|
+
#cache = new Map();
|
|
60
|
+
constructor({ maxSize } = {}) {
|
|
61
|
+
super();
|
|
62
|
+
this.maxSize = maxSize;
|
|
63
|
+
}
|
|
64
|
+
async get(key) {
|
|
65
|
+
const entry = this.#cache.get(key);
|
|
66
|
+
if (entry == null) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
const [value, expires] = entry;
|
|
70
|
+
if (expires != null && Date.now() >= expires) {
|
|
71
|
+
this.#cache.delete(key);
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
// Mark as recently used by deleting the entry and adding it back to the cache
|
|
75
|
+
this.#cache.delete(key);
|
|
76
|
+
this.#cache.set(key, entry);
|
|
77
|
+
return value;
|
|
78
|
+
}
|
|
79
|
+
async set(key, value, expires) {
|
|
80
|
+
this.#cache.delete(key);
|
|
81
|
+
this.#cache.set(key, [
|
|
82
|
+
value,
|
|
83
|
+
expires == null ? null : Date.now() + expires,
|
|
84
|
+
]);
|
|
85
|
+
this.evictLeastRecentlyUsedEntries();
|
|
86
|
+
}
|
|
87
|
+
async clear(key) {
|
|
88
|
+
if (key) {
|
|
89
|
+
this.#cache.delete(key);
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
this.#cache.clear();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
evictLeastRecentlyUsedEntries() {
|
|
96
|
+
if (this.maxSize == null) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
while (this.#cache.size > this.maxSize) {
|
|
100
|
+
const oldestKey = this.#cache.keys().next().value;
|
|
101
|
+
if (oldestKey == null) {
|
|
102
|
+
return;
|
|
54
103
|
}
|
|
55
|
-
|
|
56
|
-
|
|
104
|
+
this.#cache.delete(oldestKey);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
export function createMemoryCacheAdapter(options = {}) {
|
|
109
|
+
return new MemoryCacheAdapter(options);
|
|
57
110
|
}
|
|
58
111
|
export function createCache(adapter) {
|
|
59
112
|
async function cached(key, options, fn) {
|
|
113
|
+
assertCacheKeySize(key);
|
|
60
114
|
const cachedValue = await adapter.get(key);
|
|
61
115
|
if (cachedValue != null) {
|
|
62
116
|
return cachedValue;
|
|
@@ -73,7 +127,9 @@ export function createCache(adapter) {
|
|
|
73
127
|
const baseKey = typeof options === 'object' && options != null
|
|
74
128
|
? options.key
|
|
75
129
|
: undefined;
|
|
76
|
-
|
|
130
|
+
const cacheKey = `${baseKey ?? Buffer.from(fn.toString()).toString('hex')}-${hashKey(args)}`;
|
|
131
|
+
assertCacheKeySize(cacheKey);
|
|
132
|
+
return cacheKey;
|
|
77
133
|
};
|
|
78
134
|
const wrapped = async (...args) => {
|
|
79
135
|
const cacheKey = getCacheKey(...args);
|
|
@@ -101,21 +157,28 @@ export function createCache(adapter) {
|
|
|
101
157
|
};
|
|
102
158
|
}
|
|
103
159
|
function hashKey(obj) {
|
|
104
|
-
return JSON.stringify(obj, (
|
|
105
|
-
if (value
|
|
106
|
-
return
|
|
107
|
-
}
|
|
108
|
-
if (typeof value === 'object') {
|
|
109
|
-
if (Array.isArray(value)) {
|
|
110
|
-
return value;
|
|
111
|
-
}
|
|
112
|
-
return Object.keys(value)
|
|
113
|
-
.sort()
|
|
114
|
-
.reduce((result, item) => {
|
|
115
|
-
result[item] = value[item];
|
|
116
|
-
return result;
|
|
117
|
-
}, {});
|
|
160
|
+
return JSON.stringify(obj, (_, value) => {
|
|
161
|
+
if (!isPlainObject(value)) {
|
|
162
|
+
return value;
|
|
118
163
|
}
|
|
119
|
-
return value
|
|
164
|
+
return Object.keys(value)
|
|
165
|
+
.sort()
|
|
166
|
+
.reduce((result, key) => {
|
|
167
|
+
result[key] = value[key];
|
|
168
|
+
return result;
|
|
169
|
+
}, {});
|
|
120
170
|
});
|
|
121
171
|
}
|
|
172
|
+
function assertCacheKeySize(key) {
|
|
173
|
+
if (Buffer.byteLength(key, 'utf8') <= MAX_CACHE_KEY_SIZE_BYTES) {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
throw new Error(`Cache key exceeds ${MAX_CACHE_KEY_SIZE_BYTES} bytes. Provide a shorter explicit key or reduce the size of the cache arguments.`);
|
|
177
|
+
}
|
|
178
|
+
function isPlainObject(value) {
|
|
179
|
+
if (Object.prototype.toString.call(value) !== '[object Object]') {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
const prototype = Object.getPrototypeOf(value);
|
|
183
|
+
return prototype === null || prototype === Object.prototype;
|
|
184
|
+
}
|
|
@@ -20,14 +20,14 @@ function generate(handlers) {
|
|
|
20
20
|
const stack = handlers.flat();
|
|
21
21
|
return function middleware(err, req, res, done) {
|
|
22
22
|
let index = -1;
|
|
23
|
-
//
|
|
23
|
+
// oxlint-disable-next-line @typescript-eslint/no-shadow
|
|
24
24
|
function dispatch(pos, err) {
|
|
25
25
|
const handler = stack[pos];
|
|
26
26
|
index = pos;
|
|
27
27
|
if (index === stack.length) {
|
|
28
28
|
return done(err);
|
|
29
29
|
}
|
|
30
|
-
//
|
|
30
|
+
// oxlint-disable-next-line @typescript-eslint/no-shadow
|
|
31
31
|
function next(err) {
|
|
32
32
|
/* v8 ignore next -- @preserve */
|
|
33
33
|
if (pos < index) {
|
package/dist/config.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ declare const app: Defs<{
|
|
|
9
9
|
AUTH_TOKEN_EXPIRES: number;
|
|
10
10
|
AUTH_TEMP_TOKEN_EXPIRES: number;
|
|
11
11
|
AUTH_OTP_EXPIRES: number;
|
|
12
|
+
AUTH_OTP_MAX_ATTEMPTS: number;
|
|
12
13
|
AUTH_JWT_SECRET: string;
|
|
13
14
|
AUTH_COOKIE_SAME_SITE: string;
|
|
14
15
|
REDIS_URL: string;
|
|
@@ -41,6 +42,7 @@ export declare const config: import("@devmoods/config").Defs<{
|
|
|
41
42
|
AUTH_TOKEN_EXPIRES: number;
|
|
42
43
|
AUTH_TEMP_TOKEN_EXPIRES: number;
|
|
43
44
|
AUTH_OTP_EXPIRES: number;
|
|
45
|
+
AUTH_OTP_MAX_ATTEMPTS: number;
|
|
44
46
|
AUTH_JWT_SECRET: string;
|
|
45
47
|
AUTH_COOKIE_SAME_SITE: string;
|
|
46
48
|
REDIS_URL: string;
|
package/dist/config.js
CHANGED
|
@@ -7,6 +7,7 @@ const app = define({
|
|
|
7
7
|
AUTH_TOKEN_EXPIRES: t.number(3600 * 24 * 30),
|
|
8
8
|
AUTH_TEMP_TOKEN_EXPIRES: t.number(3600),
|
|
9
9
|
AUTH_OTP_EXPIRES: t.number(5 * 60),
|
|
10
|
+
AUTH_OTP_MAX_ATTEMPTS: t.number(5),
|
|
10
11
|
AUTH_JWT_SECRET: t.string(),
|
|
11
12
|
AUTH_COOKIE_SAME_SITE: t.enum(['strict', 'lax', 'none'], 'strict'),
|
|
12
13
|
REDIS_URL: t.string(),
|
package/dist/csrf.js
CHANGED
|
@@ -43,6 +43,7 @@ export function useCsrfOptions() {
|
|
|
43
43
|
}
|
|
44
44
|
const generateTokenAndHash = (req, { overwrite = false } = {}) => {
|
|
45
45
|
const secret = getCsrfSecret();
|
|
46
|
+
// oxlint-disable-next-line eslint-plugin-react-hooks/rules-of-hooks
|
|
46
47
|
const { sessionIdentifier, cookieName } = useCsrfOptions();
|
|
47
48
|
const csrfCookie = req.cookies[cookieName];
|
|
48
49
|
if (typeof csrfCookie === 'string' && !overwrite) {
|
|
@@ -66,6 +67,7 @@ const generateTokenAndHash = (req, { overwrite = false } = {}) => {
|
|
|
66
67
|
export const generateCsrfToken = (req, res, options = {}) => {
|
|
67
68
|
const { csrfToken, csrfTokenHash } = generateTokenAndHash(req, options);
|
|
68
69
|
const cookieContent = `${csrfToken}|${csrfTokenHash}`;
|
|
70
|
+
// oxlint-disable-next-line eslint-plugin-react-hooks/rules-of-hooks
|
|
69
71
|
const { cookieName } = useCsrfOptions();
|
|
70
72
|
res.cookie(cookieName, cookieContent, {
|
|
71
73
|
sameSite: 'lax',
|
|
@@ -85,6 +87,7 @@ const validateTokenHash = ({ csrfToken, csrfTokenHash, secret, sessionIdentifier
|
|
|
85
87
|
return csrfTokenHash === expectedHash;
|
|
86
88
|
};
|
|
87
89
|
const validateRequest = (req) => {
|
|
90
|
+
// oxlint-disable-next-line eslint-plugin-react-hooks/rules-of-hooks
|
|
88
91
|
const { cookieName, sessionIdentifier } = useCsrfOptions();
|
|
89
92
|
const csrfCookie = req.cookies[cookieName];
|
|
90
93
|
if (typeof csrfCookie !== 'string') {
|
|
@@ -112,6 +115,7 @@ const validateRequest = (req) => {
|
|
|
112
115
|
}
|
|
113
116
|
};
|
|
114
117
|
export function doubleCsrfProtection(req) {
|
|
118
|
+
// oxlint-disable-next-line eslint-plugin-react-hooks/rules-of-hooks
|
|
115
119
|
return async () => {
|
|
116
120
|
const { exempt } = useCsrfOptions();
|
|
117
121
|
if (exempt) {
|
package/dist/errors.d.ts
CHANGED
|
@@ -35,8 +35,6 @@ export interface ErrorHandlerOptions {
|
|
|
35
35
|
}
|
|
36
36
|
/**
|
|
37
37
|
* Generic error handler for JSON-based apps.
|
|
38
|
-
*
|
|
39
|
-
* Composed together with the Sentry error handler.
|
|
40
38
|
*/
|
|
41
39
|
export declare function errorHandler({ onError }?: ErrorHandlerOptions): ErrorHandler<any, any, void>;
|
|
42
40
|
export {};
|
package/dist/errors.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { STATUS_CODES } from 'node:http';
|
|
2
2
|
import { DoesNotExistError, IntegrityError } from '@devmoods/postgres';
|
|
3
|
-
import * as Sentry from '@sentry/node';
|
|
4
3
|
import {} from 'express';
|
|
5
4
|
import { InvalidToken } from './auth/errors.js';
|
|
6
5
|
import { errors } from './composeMiddleware.js';
|
|
@@ -66,8 +65,6 @@ function _errorHandler({ onError }) {
|
|
|
66
65
|
}
|
|
67
66
|
/**
|
|
68
67
|
* Generic error handler for JSON-based apps.
|
|
69
|
-
*
|
|
70
|
-
* Composed together with the Sentry error handler.
|
|
71
68
|
*/
|
|
72
69
|
export function errorHandler({ onError } = {}) {
|
|
73
70
|
const logger = getLogger();
|
|
@@ -82,14 +79,6 @@ export function errorHandler({ onError } = {}) {
|
|
|
82
79
|
}
|
|
83
80
|
};
|
|
84
81
|
return errors([
|
|
85
|
-
Sentry.expressErrorHandler({
|
|
86
|
-
shouldHandleError(error) {
|
|
87
|
-
if (error instanceof ApiError && error.status < 500) {
|
|
88
|
-
return false;
|
|
89
|
-
}
|
|
90
|
-
return process.env.NODE_ENV === 'production';
|
|
91
|
-
},
|
|
92
|
-
}),
|
|
93
82
|
_errorHandler({
|
|
94
83
|
onError: onError || _defaultErrorHandler,
|
|
95
84
|
}),
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/listen.d.ts
CHANGED
|
@@ -14,6 +14,7 @@ export interface ExpressServerOptions {
|
|
|
14
14
|
onStart?(): Promise<void> | void;
|
|
15
15
|
onShutdown?(): Promise<void> | void;
|
|
16
16
|
beforeShutdown?(): Promise<void> | void;
|
|
17
|
+
afterShutdown?(): Promise<void> | void;
|
|
17
18
|
healthCheck?(type?: 'live' | 'ready'): Promise<unknown>;
|
|
18
19
|
gracePeriodMs?: number;
|
|
19
20
|
}
|
package/dist/listen.js
CHANGED
|
@@ -8,7 +8,6 @@ import express, {} from 'express';
|
|
|
8
8
|
import { BadRequest } from './errors.js';
|
|
9
9
|
import { getLogger } from './logger.js';
|
|
10
10
|
import { before, route } from './middleware.js';
|
|
11
|
-
import { envPort } from './utils.js';
|
|
12
11
|
const logger = getLogger('http');
|
|
13
12
|
const appTimer = startTimer();
|
|
14
13
|
export function stoppable(server, gracePeriodMs = Infinity) {
|
|
@@ -126,6 +125,7 @@ export function createExpressServer(app, options = {}) {
|
|
|
126
125
|
duration: timer.getDuration(),
|
|
127
126
|
gracefully,
|
|
128
127
|
});
|
|
128
|
+
await options.afterShutdown?.();
|
|
129
129
|
process.exit(0);
|
|
130
130
|
}
|
|
131
131
|
catch (error) {
|
|
@@ -174,7 +174,7 @@ export function createExpressServer(app, options = {}) {
|
|
|
174
174
|
configurable: true,
|
|
175
175
|
});
|
|
176
176
|
enhanced.start = async function start() {
|
|
177
|
-
const port =
|
|
177
|
+
const port = config.PORT;
|
|
178
178
|
await options.onStart?.();
|
|
179
179
|
await ctl.start(port);
|
|
180
180
|
logger.info('app listening', {
|
package/dist/logger.d.ts
CHANGED
|
@@ -1,20 +1,12 @@
|
|
|
1
1
|
import { type Writable } from 'node:stream';
|
|
2
|
+
import { type LoggerAdapter } from '@devmoods/observability';
|
|
2
3
|
export type LoggerContext = Record<string, any>;
|
|
3
|
-
export interface LoggerAdapter {
|
|
4
|
-
captureError(error: Error | null, attributes?: Record<string, any>): void;
|
|
5
|
-
write(chunk: Record<string, any>): boolean;
|
|
6
|
-
}
|
|
7
4
|
export declare class JsonStreamAdapter implements LoggerAdapter {
|
|
8
5
|
#private;
|
|
9
6
|
constructor(stream: Writable);
|
|
10
7
|
captureError(error: Error, attributes?: Record<string, any>): void;
|
|
11
8
|
write(log: Record<string, any>): boolean;
|
|
12
9
|
}
|
|
13
|
-
export declare class SentryLoggerAdapter implements LoggerAdapter {
|
|
14
|
-
constructor();
|
|
15
|
-
captureError(error: Error | null, attributes?: Record<string, any>): void;
|
|
16
|
-
write(log: Record<string, any>): boolean;
|
|
17
|
-
}
|
|
18
10
|
export interface LoggerOptions {
|
|
19
11
|
/**
|
|
20
12
|
* Name of the logger
|
|
@@ -36,6 +28,8 @@ export interface LoggerOptions {
|
|
|
36
28
|
export declare function setGlobalLoggerContext(getContext: () => LoggerContext): void;
|
|
37
29
|
export declare function clearGlobalLoggerContext(): void;
|
|
38
30
|
export type Logger = ReturnType<typeof createLogger>;
|
|
31
|
+
export declare function addDefaultLoggerOutput(output: LoggerAdapter): void;
|
|
32
|
+
export declare function clearDefaultLoggerOutputs(): void;
|
|
39
33
|
export declare function createLogger({ name, getContext, level, outputs }: LoggerOptions): {
|
|
40
34
|
debug(message: string, context?: LoggerContext): void;
|
|
41
35
|
info(message: string, context?: LoggerContext): void;
|
package/dist/logger.js
CHANGED
|
@@ -1,43 +1,19 @@
|
|
|
1
1
|
import * as os from 'node:os';
|
|
2
2
|
import {} from 'node:stream';
|
|
3
3
|
import { config } from '@devmoods/config';
|
|
4
|
-
import
|
|
4
|
+
import {} from '@devmoods/observability';
|
|
5
5
|
export class JsonStreamAdapter {
|
|
6
6
|
#stream;
|
|
7
7
|
constructor(stream) {
|
|
8
8
|
this.#stream = stream;
|
|
9
9
|
}
|
|
10
|
-
//
|
|
10
|
+
// oxlint-disable-next-line @typescript-eslint/no-unused-vars
|
|
11
11
|
captureError(error, attributes) { }
|
|
12
12
|
write(log) {
|
|
13
13
|
const chunk = `${JSON.stringify(log)}\n`;
|
|
14
14
|
return this.#stream.write(chunk);
|
|
15
15
|
}
|
|
16
16
|
}
|
|
17
|
-
export class SentryLoggerAdapter {
|
|
18
|
-
constructor() { }
|
|
19
|
-
captureError(error, attributes) {
|
|
20
|
-
/* v8 ignore next -- @preserve */
|
|
21
|
-
if (error == null) {
|
|
22
|
-
return;
|
|
23
|
-
}
|
|
24
|
-
/* v8 ignore next -- @preserve */
|
|
25
|
-
if (Sentry.isInitialized()) {
|
|
26
|
-
Sentry.captureException(error, {
|
|
27
|
-
tags: attributes,
|
|
28
|
-
});
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
write(log) {
|
|
32
|
-
const { message, ...attributes } = log;
|
|
33
|
-
/* v8 ignore next -- @preserve */
|
|
34
|
-
if (Sentry.isInitialized()) {
|
|
35
|
-
const fn = log.level;
|
|
36
|
-
Sentry.logger[fn](message, attributes);
|
|
37
|
-
}
|
|
38
|
-
return true;
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
17
|
const LEVELS = {
|
|
42
18
|
TRACE: 10,
|
|
43
19
|
DEBUG: 20,
|
|
@@ -73,7 +49,19 @@ function baseContext() {
|
|
|
73
49
|
}
|
|
74
50
|
return globalContext;
|
|
75
51
|
}
|
|
76
|
-
|
|
52
|
+
const defaultLoggerOutputs = [
|
|
53
|
+
new JsonStreamAdapter(process.stdout),
|
|
54
|
+
];
|
|
55
|
+
export function addDefaultLoggerOutput(output) {
|
|
56
|
+
if (defaultLoggerOutputs.includes(output)) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
defaultLoggerOutputs.push(output);
|
|
60
|
+
}
|
|
61
|
+
export function clearDefaultLoggerOutputs() {
|
|
62
|
+
defaultLoggerOutputs.length = 0;
|
|
63
|
+
}
|
|
64
|
+
export function createLogger({ name, getContext, level = config.LOG_LEVEL, outputs = defaultLoggerOutputs, }) {
|
|
77
65
|
if (_loggers.has(name)) {
|
|
78
66
|
throw new Error(`A logger named ${name} already exists`);
|
|
79
67
|
}
|
|
@@ -84,7 +72,7 @@ export function createLogger({ name, getContext, level = config.LOG_LEVEL, outpu
|
|
|
84
72
|
function getFinalContext(context) {
|
|
85
73
|
return Object.assign(baseContext(), getContext?.(), context);
|
|
86
74
|
}
|
|
87
|
-
//
|
|
75
|
+
// oxlint-disable-next-line @typescript-eslint/no-shadow
|
|
88
76
|
function log(level, message, context) {
|
|
89
77
|
if (level < logLevel) {
|
|
90
78
|
return;
|
package/dist/manage.js
CHANGED
|
@@ -33,13 +33,13 @@ export function registerDefaultCommands(app, program = defaultProgram) {
|
|
|
33
33
|
}
|
|
34
34
|
if (options.openapi) {
|
|
35
35
|
const routes = toOpenAPISpec(app);
|
|
36
|
-
//
|
|
36
|
+
// oxlint-disable-next-line no-console
|
|
37
37
|
console.log(JSON.stringify(routes, null, 2));
|
|
38
38
|
return;
|
|
39
39
|
}
|
|
40
40
|
const routes = parseRoutes(app);
|
|
41
41
|
if (options.json) {
|
|
42
|
-
//
|
|
42
|
+
// oxlint-disable-next-line no-console
|
|
43
43
|
console.log(JSON.stringify(routes, null, 2));
|
|
44
44
|
return;
|
|
45
45
|
}
|
|
@@ -60,7 +60,7 @@ export function registerDefaultCommands(app, program = defaultProgram) {
|
|
|
60
60
|
.option('--json', 'Output JSON')
|
|
61
61
|
.action(cli.wrap(async (options) => {
|
|
62
62
|
if (options.json) {
|
|
63
|
-
//
|
|
63
|
+
// oxlint-disable-next-line no-console
|
|
64
64
|
console.log(JSON.stringify(config, null, 2));
|
|
65
65
|
return;
|
|
66
66
|
}
|
package/dist/middleware.js
CHANGED
|
@@ -225,14 +225,41 @@ export function withTransaction(wrapper) {
|
|
|
225
225
|
try {
|
|
226
226
|
await wrapper.transaction(async () => {
|
|
227
227
|
await new Promise((resolve, reject) => {
|
|
228
|
-
|
|
229
|
-
|
|
228
|
+
const cleanup = () => {
|
|
229
|
+
res.off('finish', onFinish);
|
|
230
|
+
res.off('close', onClose);
|
|
231
|
+
res.off('error', onError);
|
|
232
|
+
};
|
|
233
|
+
const onFinish = () => {
|
|
234
|
+
cleanup();
|
|
235
|
+
if (res.statusCode >= 400) {
|
|
236
|
+
reject(new Error(`Request finished with status ${res.statusCode}; rolling back transaction.`));
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
resolve();
|
|
240
|
+
};
|
|
241
|
+
const onClose = () => {
|
|
242
|
+
cleanup();
|
|
243
|
+
if (!res.writableFinished) {
|
|
244
|
+
reject(new Error('Request closed before the response finished.'));
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
const onError = (error) => {
|
|
248
|
+
cleanup();
|
|
249
|
+
reject(error);
|
|
250
|
+
};
|
|
251
|
+
res.once('finish', onFinish);
|
|
252
|
+
res.once('close', onClose);
|
|
253
|
+
res.once('error', onError);
|
|
230
254
|
next();
|
|
231
255
|
});
|
|
232
256
|
});
|
|
233
257
|
}
|
|
234
258
|
catch (error) {
|
|
235
259
|
/* v8 ignore next -- @preserve */
|
|
260
|
+
if (res.headersSent || res.writableEnded) {
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
236
263
|
next(error);
|
|
237
264
|
}
|
|
238
265
|
};
|
package/dist/utils.d.ts
CHANGED
package/dist/utils.js
CHANGED
package/dist/vite/ssr.js
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
1
9
|
import * as fs from 'node:fs/promises';
|
|
2
10
|
import * as path from 'node:path';
|
|
3
11
|
import { config } from '@devmoods/config';
|
|
4
12
|
import express, {} from 'express';
|
|
5
13
|
import {} from 'vite';
|
|
6
|
-
import {} from '../composeMiddleware.js';
|
|
7
14
|
import {} from './types.js';
|
|
15
|
+
import {} from '../composeMiddleware.js';
|
|
8
16
|
import { generateCsrfToken } from '../csrf.js';
|
|
9
17
|
import { composeMiddleware, route, useCspNonce } from '../middleware.js';
|
|
10
18
|
import { createFetchRequest } from '../webapi.js';
|
|
@@ -38,7 +46,7 @@ export async function createSsrMiddleware({ root = process.cwd(), getInitialData
|
|
|
38
46
|
render = (await vite.ssrLoadModule('/src/entry-server.tsx')).render;
|
|
39
47
|
}
|
|
40
48
|
else {
|
|
41
|
-
render = (await import(path.join(root, 'dist/server/entry-server.js'))).render;
|
|
49
|
+
render = (await import(__rewriteRelativeImportExtension(path.join(root, 'dist/server/entry-server.js')))).render;
|
|
42
50
|
}
|
|
43
51
|
const rendered = await render(createFetchRequest(req, res), {
|
|
44
52
|
useDevServer,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@devmoods/express-extras",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.74.0",
|
|
4
4
|
"description": "Kubernetes-ready utilities for rapid Node.js application development",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -45,7 +45,8 @@
|
|
|
45
45
|
},
|
|
46
46
|
"scripts": {
|
|
47
47
|
"clean": "rm -rf dist",
|
|
48
|
-
"lint": "
|
|
48
|
+
"lint": "oxlint .",
|
|
49
|
+
"format": "oxfmt .",
|
|
49
50
|
"test": "NODE_ENV=test DOTENV_CONFIG_PATH=.env.test vitest",
|
|
50
51
|
"test:coverage": "pnpm run test --coverage",
|
|
51
52
|
"build": "pnpm run clean && tsgo",
|
|
@@ -68,15 +69,12 @@
|
|
|
68
69
|
"vite": "^8.0.8"
|
|
69
70
|
},
|
|
70
71
|
"dependencies": {
|
|
71
|
-
"@devmoods/config": "0.2.
|
|
72
|
+
"@devmoods/config": "0.2.4",
|
|
72
73
|
"@devmoods/fetch": "^4.3.0",
|
|
73
|
-
"@devmoods/observability": "0.
|
|
74
|
-
"@devmoods/postgres": "0.
|
|
75
|
-
"@hey-api/openapi-ts": "^0.
|
|
76
|
-
"@rolldown/plugin-babel": "^0.2.
|
|
77
|
-
"@sentry/node": "^10.48.0",
|
|
78
|
-
"@sentry/node-native": "^10.48.0",
|
|
79
|
-
"@sentry/profiling-node": "^10.48.0",
|
|
74
|
+
"@devmoods/observability": "0.3.0",
|
|
75
|
+
"@devmoods/postgres": "0.6.0",
|
|
76
|
+
"@hey-api/openapi-ts": "^0.96.0",
|
|
77
|
+
"@rolldown/plugin-babel": "^0.2.3",
|
|
80
78
|
"@standard-schema/spec": "^1.1.0",
|
|
81
79
|
"@types/bcrypt": "^6.0.0",
|
|
82
80
|
"@types/cookie-parser": "^1.4.10",
|
|
@@ -91,13 +89,13 @@
|
|
|
91
89
|
"chalk": "^5.6.2",
|
|
92
90
|
"commander": "^14.0.3",
|
|
93
91
|
"cookie-parser": "^1.4.7",
|
|
94
|
-
"dotenv": "^17.4.
|
|
92
|
+
"dotenv": "^17.4.2",
|
|
95
93
|
"express": "^4.21.2",
|
|
96
94
|
"google-auth-library": "^10.6.2",
|
|
97
95
|
"helmet": "^8.1.0",
|
|
98
96
|
"jose": "^6.2.2",
|
|
99
97
|
"nodemailer": "^8.0.5",
|
|
100
|
-
"redis": "^5.
|
|
98
|
+
"redis": "^5.12.1",
|
|
101
99
|
"sonda": "^0.11.1",
|
|
102
100
|
"tough-cookie": "^6.0.1",
|
|
103
101
|
"uuid": "^13.0.0",
|
|
@@ -113,5 +111,5 @@
|
|
|
113
111
|
"engines": {
|
|
114
112
|
"node": ">=20 <26"
|
|
115
113
|
},
|
|
116
|
-
"gitHead": "
|
|
114
|
+
"gitHead": "2c4100d80823bcf16bd962b4b8a26bd078522dfc"
|
|
117
115
|
}
|
package/dist/sentry.d.ts
DELETED
package/dist/sentry.js
DELETED
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
import { config } from '@devmoods/config';
|
|
2
|
-
/**
|
|
3
|
-
* Safely imports native Sentry integrations if available.
|
|
4
|
-
* Returns null if native modules are not available (e.g., on unsupported Node.js versions).
|
|
5
|
-
*/
|
|
6
|
-
async function getNativeIntegrations() {
|
|
7
|
-
const integrations = [];
|
|
8
|
-
/* v8 ignore next -- @preserve */
|
|
9
|
-
try {
|
|
10
|
-
const { nodeProfilingIntegration } = await import('@sentry/profiling-node');
|
|
11
|
-
integrations.push(nodeProfilingIntegration());
|
|
12
|
-
}
|
|
13
|
-
catch {
|
|
14
|
-
// Native profiling integration not available
|
|
15
|
-
}
|
|
16
|
-
/* v8 ignore next -- @preserve */
|
|
17
|
-
try {
|
|
18
|
-
const { eventLoopBlockIntegration } = await import('@sentry/node-native');
|
|
19
|
-
integrations.push(eventLoopBlockIntegration({
|
|
20
|
-
threshold: config.SENTRY_EVENT_LOOP_THRESHOLD,
|
|
21
|
-
}));
|
|
22
|
-
}
|
|
23
|
-
catch {
|
|
24
|
-
// Native event loop integration not available
|
|
25
|
-
}
|
|
26
|
-
return integrations;
|
|
27
|
-
}
|
|
28
|
-
export async function sentryOptions(options = {}) {
|
|
29
|
-
const nativeIntegrations = await getNativeIntegrations();
|
|
30
|
-
return {
|
|
31
|
-
dsn: config.SENTRY_DSN,
|
|
32
|
-
tracesSampleRate: config.SENTRY_TRACES_SAMPLE_RATE,
|
|
33
|
-
profileSessionSampleRate: config.SENTRY_PROFILES_SAMPLE_RATE,
|
|
34
|
-
integrations: nativeIntegrations,
|
|
35
|
-
enableLogs: true,
|
|
36
|
-
...options,
|
|
37
|
-
};
|
|
38
|
-
}
|