@devmoods/express-extras 0.48.1 → 0.49.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.
@@ -1,7 +1,6 @@
1
1
  import { type AnyUser, type AuthProvider } from './types.js';
2
2
  export interface EmailAndPasswordOptions<UserModel = AnyUser> {
3
- getUserById: (id: string) => Promise<UserModel>;
4
- login: (email: string, verifyPassword: (realPassword: string) => Promise<boolean>) => Promise<UserModel>;
3
+ login: (email: string, verifyPassword: (realPassword: string) => Promise<string>) => Promise<UserModel>;
5
4
  onPasswordResetRequest?(user: UserModel, temporaryToken: string): void | Promise<void>;
6
5
  onPasswordChanged?(user: UserModel, newPassword: string): Promise<void>;
7
6
  onEmailVerificationRequest?(user: UserModel, temporaryToken: string): Promise<void>;
@@ -9,10 +8,16 @@ export interface EmailAndPasswordOptions<UserModel = AnyUser> {
9
8
  }
10
9
  export declare function EmailAndPasswordAuthProvider<UserModel extends AnyUser>(options: EmailAndPasswordOptions<UserModel>): AuthProvider<UserModel>;
11
10
  /**
12
- * Compare two password hashes using bcrypt.
11
+ * Compare password against a stored hash.
12
+ *
13
+ * Supports both bcrypt and argon2id. A new argon2id hash will be returned
14
+ * when using bcrypt to allow gradually migrating. If the returned hash
15
+ * does not equal the currently stored hash in your db, you should update it.
16
+ *
17
+ * Returns a password hash.
13
18
  */
14
- export declare function checkIfPasswordsMatch(realPassword: string, attemptedPassword: string): Promise<boolean>;
19
+ export declare function checkIfPasswordsMatch(hashedUserPassword: string, attemptedPassword: string): Promise<string>;
15
20
  /**
16
21
  * Returns a hash of a password suitable for storage.
17
22
  */
18
- export declare function hashPassword(password: string): Promise<string>;
23
+ export declare function hashPassword(password: string, useArgon2Id?: boolean): Promise<string>;
@@ -1,15 +1,17 @@
1
+ import argon2 from 'argon2';
1
2
  import bcrypt from 'bcrypt';
2
- import { tokenSchema } from './token.js';
3
+ import { createTokenResponse, tokenSchema } from './token.js';
3
4
  import {} from './types.js';
4
5
  import { Unauthorized } from '../errors.js';
5
6
  import { getLogger } from '../logger.js';
6
7
  import { route } from '../middleware.js';
8
+ import { startTimer } from '../utils.js';
7
9
  import { validateBody } from '../validate.js';
8
10
  const passwordSchema = { type: 'string', minLength: 4 };
9
11
  const emailSchema = { type: 'string', minLength: 4 };
10
12
  const logger = getLogger('auth');
11
13
  export function EmailAndPasswordAuthProvider(options) {
12
- const skipPasswordCheck = async () => true;
14
+ const skipPasswordCheck = async () => '';
13
15
  return {
14
16
  setupRoutes(router, context) {
15
17
  router.post('/login', validateBody({
@@ -19,15 +21,19 @@ export function EmailAndPasswordAuthProvider(options) {
19
21
  password: passwordSchema,
20
22
  },
21
23
  required: ['email', 'password'],
22
- }), route(async ({ body: { email, password } }, res) => {
24
+ }), route(async (req, res) => {
25
+ const { email, password } = req.body;
26
+ const timer = startTimer();
23
27
  try {
24
28
  const user = await options.login(email, (realPassword) => checkIfPasswordsMatch(realPassword, password));
25
- const { token } = await context.login(res, user);
29
+ const accessToken = await context.login(req, res, user);
30
+ await context.jitter(timer.getDuration());
26
31
  logger.info('access token created');
27
- return [201, { token }];
32
+ return createTokenResponse(accessToken);
28
33
  }
29
34
  catch (error) {
30
35
  logger.error(error);
36
+ await context.jitter(timer.getDuration());
31
37
  throw Unauthorized(error.message);
32
38
  }
33
39
  }));
@@ -38,7 +44,7 @@ export function EmailAndPasswordAuthProvider(options) {
38
44
  }), route(async (req) => {
39
45
  const { password, token } = req.body;
40
46
  const decoded = context.decodeTemporaryToken(token);
41
- const user = await options.getUserById(decoded.payload.uid);
47
+ const user = await context.getUserById(decoded.payload.uid);
42
48
  try {
43
49
  await context.verifyTemporaryToken(token, user);
44
50
  }
@@ -56,9 +62,11 @@ export function EmailAndPasswordAuthProvider(options) {
56
62
  required: ['email'],
57
63
  }), route(async (req) => {
58
64
  const { email } = req.body;
65
+ const timer = startTimer();
59
66
  const user = await options.login(email, skipPasswordCheck);
60
67
  const temporaryToken = await context.createTemporaryToken(user);
61
68
  await options.onEmailVerificationRequest?.(user, temporaryToken);
69
+ await context.jitter(timer.getDuration());
62
70
  return 202;
63
71
  }));
64
72
  router.post('/verify-email', validateBody({
@@ -71,7 +79,12 @@ export function EmailAndPasswordAuthProvider(options) {
71
79
  try {
72
80
  await context.verifyTemporaryToken(token, user);
73
81
  }
74
- catch {
82
+ catch (error) {
83
+ logger.warn('failed to verify token', {
84
+ token,
85
+ user: context.filterUser(user),
86
+ error: error.message,
87
+ });
75
88
  throw Unauthorized('Invalid token');
76
89
  }
77
90
  context.setCurrentUser(user);
@@ -85,9 +98,11 @@ export function EmailAndPasswordAuthProvider(options) {
85
98
  required: ['email'],
86
99
  }), route(async (req) => {
87
100
  const { email } = req.body;
101
+ const timer = startTimer();
88
102
  try {
89
103
  const user = await options.login(email, skipPasswordCheck);
90
104
  const temporaryToken = await context.createTemporaryToken(user);
105
+ await context.jitter(timer.getDuration());
91
106
  await options.onPasswordResetRequest?.(user, temporaryToken);
92
107
  logger.info('password reset request', { email });
93
108
  }
@@ -100,21 +115,41 @@ export function EmailAndPasswordAuthProvider(options) {
100
115
  };
101
116
  }
102
117
  /**
103
- * Compare two password hashes using bcrypt.
118
+ * Compare password against a stored hash.
119
+ *
120
+ * Supports both bcrypt and argon2id. A new argon2id hash will be returned
121
+ * when using bcrypt to allow gradually migrating. If the returned hash
122
+ * does not equal the currently stored hash in your db, you should update it.
123
+ *
124
+ * Returns a password hash.
104
125
  */
105
- export async function checkIfPasswordsMatch(realPassword, attemptedPassword) {
106
- if (!realPassword) {
126
+ export async function checkIfPasswordsMatch(hashedUserPassword, attemptedPassword) {
127
+ if (!hashedUserPassword) {
107
128
  throw new Error('User has no defined password');
108
129
  }
109
- const validPassword = await bcrypt.compare(attemptedPassword, realPassword);
110
- if (!validPassword) {
111
- throw new Error(`Passwords do not match`);
130
+ // Migrate from bcrypt to argon2id
131
+ if (hashedUserPassword.startsWith('$2')) {
132
+ const validPassword = await bcrypt.compare(attemptedPassword, hashedUserPassword);
133
+ if (!validPassword) {
134
+ throw new Error(`Passwords do not match`);
135
+ }
136
+ // Return the new argon2id hash
137
+ return hashPassword(attemptedPassword);
112
138
  }
113
- return true;
139
+ else {
140
+ const validPassword = await argon2.verify(hashedUserPassword, attemptedPassword);
141
+ if (!validPassword) {
142
+ throw new Error(`Passwords do not match`);
143
+ }
144
+ }
145
+ return hashedUserPassword;
114
146
  }
115
147
  /**
116
148
  * Returns a hash of a password suitable for storage.
117
149
  */
118
- export function hashPassword(password) {
150
+ export function hashPassword(password, useArgon2Id = true) {
151
+ if (useArgon2Id) {
152
+ return argon2.hash(password);
153
+ }
119
154
  return bcrypt.hash(password, 10);
120
155
  }
@@ -1,34 +1,29 @@
1
+ import { createTokenResponse } from './token.js';
1
2
  import {} from './types.js';
2
3
  import { Unauthorized } from '../errors.js';
3
4
  import { getLogger } from '../logger.js';
4
5
  import { route } from '../middleware.js';
5
- import { validateBody, validateQuery } from '../validate.js';
6
+ import { validateBody } from '../validate.js';
6
7
  const logger = getLogger('auth');
7
8
  export function PasswordlessAuthProvider(options) {
8
9
  return {
9
10
  setupRoutes(router, context) {
10
- router.use('/passwordless/login/:token', validateQuery({
11
- type: 'object',
12
- properties: {
13
- email: { type: 'string', minLength: 3 },
14
- },
15
- required: ['email'],
16
- }), route(async (req, res) => {
11
+ router.post('/passwordless/login/:token', route(async (req, res) => {
17
12
  const { token: temporaryToken } = req.params;
18
- const { email } = req.query;
19
- const user = await options.getUserByEmail(email);
13
+ const decoded = context.decodeTemporaryToken(temporaryToken);
14
+ const user = await context.getUserById(decoded.payload.uid);
20
15
  try {
21
16
  await context.verifyTemporaryToken(temporaryToken, user);
22
17
  }
23
18
  catch {
24
19
  throw Unauthorized('Invalid token');
25
20
  }
26
- const { token } = await context.login(res, user);
21
+ const accessToken = await context.login(req, res, user);
27
22
  logger.info('passwordless login');
28
23
  if (req.accepts('html')) {
29
24
  return res.redirect('/');
30
25
  }
31
- return { token };
26
+ return createTokenResponse(accessToken);
32
27
  }));
33
28
  /**
34
29
  * Request a one-time password for login.
@@ -1,14 +1,24 @@
1
1
  import { type Response, type Request } from 'express';
2
2
  import { type ValidationSchema } from '../validate.js';
3
3
  export declare const tokenSchema: ValidationSchema;
4
+ export declare function createTokenResponse(accessToken: AccessToken): readonly [201, {
5
+ readonly token: string;
6
+ }, {
7
+ readonly 'Cache-Control': "no-store";
8
+ }];
9
+ export type AccessToken = ReturnType<typeof createAccessToken>;
4
10
  /**
5
11
  * Returns a signed JWT token.
6
12
  */
7
- export declare function createAuthToken(payload: any, secret?: string): string;
13
+ export declare function createAccessToken(payload: any, version?: string, secret?: string): {
14
+ token: string;
15
+ tokenType: "Bearer";
16
+ expiresIn: number;
17
+ };
8
18
  /**
9
19
  * Decode the given JWT token.
10
20
  */
11
- export declare function decodeAuthToken(token: string, secret?: string): {
21
+ export declare function decodeAccessToken(token: string, secret?: string): {
12
22
  [x: string]: any;
13
23
  iss?: string | undefined;
14
24
  sub?: string | undefined;
@@ -4,23 +4,37 @@ import { v4 } from 'uuid';
4
4
  import { InvalidToken } from './errors.js';
5
5
  import { config } from '../config.js';
6
6
  import {} from '../validate.js';
7
+ const ACCESS_TOKEN_COOKIE_NAME = 'access_token';
7
8
  export const tokenSchema = {
8
9
  type: 'string',
9
10
  minLength: 6,
10
11
  maxLength: 256,
11
12
  };
13
+ export function createTokenResponse(accessToken) {
14
+ return [
15
+ 201,
16
+ {
17
+ token: accessToken.token,
18
+ },
19
+ {
20
+ 'Cache-Control': 'no-store',
21
+ },
22
+ ];
23
+ }
12
24
  /**
13
25
  * Returns a signed JWT token.
14
26
  */
15
- export function createAuthToken(payload, secret = config.value.AUTH_SECRET) {
16
- return jwt.sign(payload, secret, {
17
- expiresIn: config.value.AUTH_TOKEN_EXPIRES_IN,
27
+ export function createAccessToken(payload, version = '1', secret = config.value.AUTH_SECRET) {
28
+ const expiresIn = config.value.AUTH_TOKEN_EXPIRES_IN;
29
+ const token = jwt.sign({ v: version, ...payload }, secret, {
30
+ expiresIn,
18
31
  });
32
+ return { token, tokenType: 'Bearer', expiresIn };
19
33
  }
20
34
  /**
21
35
  * Decode the given JWT token.
22
36
  */
23
- export function decodeAuthToken(token, secret = config.value.AUTH_SECRET) {
37
+ export function decodeAccessToken(token, secret = config.value.AUTH_SECRET) {
24
38
  try {
25
39
  const decoded = jwt.verify(token.trim(), secret);
26
40
  /* c8 ignore next 3 */
@@ -70,8 +84,8 @@ export function extractAccessTokenFromRequest(req) {
70
84
  if (authorizationHeader) {
71
85
  return authorizationHeader;
72
86
  }
73
- if (req.cookies && 'access_token' in req.cookies) {
74
- return req.cookies['access_token'];
87
+ if (req.cookies && ACCESS_TOKEN_COOKIE_NAME in req.cookies) {
88
+ return req.cookies[ACCESS_TOKEN_COOKIE_NAME];
75
89
  }
76
90
  return null;
77
91
  }
@@ -82,14 +96,14 @@ export function extractAccessTokenFromRequest(req) {
82
96
  */
83
97
  export function setAccessTokenCookie(res, token) {
84
98
  if (token == null) {
85
- res.cookie('access_token', v4(), {
99
+ res.cookie(ACCESS_TOKEN_COOKIE_NAME, v4(), {
86
100
  expires: new Date(1970, 0, 1),
87
101
  sameSite: 'strict',
88
102
  secure: process.env.NODE_ENV === 'production',
89
103
  });
90
104
  }
91
105
  else {
92
- res.cookie('access_token', token, {
106
+ res.cookie(ACCESS_TOKEN_COOKIE_NAME, token, {
93
107
  httpOnly: true,
94
108
  sameSite: 'strict',
95
109
  secure: process.env.NODE_ENV === 'production',
@@ -1,32 +1,46 @@
1
- import { type RequestHandler, type Router, type Response } from 'express';
2
- import { type RedisClient } from '../redis.js';
1
+ import { type RequestHandler, type Router, type Request, type Response } from 'express';
2
+ import { type AccessToken } from './token.js';
3
3
  export interface AnyUser {
4
4
  id: string | number;
5
5
  [key: string]: any;
6
6
  }
7
+ export declare abstract class TokenStorage {
8
+ abstract set(token: string, userId: string, expiresIn: number): Promise<void>;
9
+ abstract get(token: string): Promise<string | null>;
10
+ abstract invalidate(token: string): Promise<void>;
11
+ }
12
+ export interface AuthLoginMetadata {
13
+ ip: string;
14
+ userAgent: string;
15
+ sessionId: string;
16
+ }
7
17
  export interface AuthOptions<UserModel extends AnyUser> {
8
- getUserByToken(token: Record<string, any>): Promise<UserModel>;
18
+ getUserById(id: string): Promise<UserModel>;
9
19
  providers: AuthProvider<UserModel>[];
10
- redis: RedisClient;
20
+ tokenStorage: TokenStorage;
11
21
  filterUser(user: UserModel): Partial<UserModel>;
12
22
  loginUrl?: string;
23
+ onUserLoggedIn?: (user: UserModel, metadata: AuthLoginMetadata) => Promise<void>;
24
+ sessionTokenVersion?: string;
25
+ }
26
+ export interface DecodedTemporaryToken {
27
+ payload: {
28
+ v: '1';
29
+ uid: string;
30
+ };
31
+ signature: string;
13
32
  }
14
33
  export interface AuthProviderContext<UserModel extends AnyUser> {
15
34
  filterUser: AuthOptions<UserModel>['filterUser'];
16
- redis: AuthOptions<UserModel>['redis'];
35
+ tokenStorage: AuthOptions<UserModel>['tokenStorage'];
17
36
  setCurrentUser: (user: UserModel) => void;
18
37
  createTemporaryToken(user: UserModel): Promise<string>;
19
38
  verifyTemporaryToken(token: string, user: UserModel): Promise<void>;
20
- decodeTemporaryToken(token: string): {
21
- payload: {
22
- uid: string;
23
- };
24
- signature: string;
25
- };
39
+ decodeTemporaryToken(token: string): DecodedTemporaryToken;
26
40
  middleware: (options?: AuthMiddlewareOptions<UserModel>) => RequestHandler;
27
- login: (res: Response, user: UserModel) => Promise<{
28
- token: string;
29
- }>;
41
+ login: (req: Request, res: Response, user: UserModel) => Promise<AccessToken>;
42
+ getUserById(id: string): Promise<UserModel>;
43
+ jitter(duration?: number): Promise<void>;
30
44
  }
31
45
  export interface AuthProvider<UserModel extends AnyUser> {
32
46
  setupRoutes(router: Router, context: AuthProviderContext<UserModel>): void;
@@ -1,2 +1,4 @@
1
1
  import {} from 'express';
2
- import {} from '../redis.js';
2
+ import {} from './token.js';
3
+ export class TokenStorage {
4
+ }
package/dist/auth.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type RequestHandler, Router } from 'express';
2
- import { type AnyUser, type AuthMiddlewareOptions, type AuthOptions } from './auth/types.js';
2
+ import { TokenStorage, type AnyUser, type AuthMiddlewareOptions, type AuthOptions, type DecodedTemporaryToken } from './auth/types.js';
3
+ import { type RedisClient } from './redis.js';
3
4
  export * from './auth/types.js';
4
5
  export * from './auth/emailAndPassword.js';
5
6
  export * from './auth/passwordless.js';
@@ -10,11 +11,14 @@ export interface CreateAuth<UserModel extends AnyUser> {
10
11
  middleware: (options?: AuthMiddlewareOptions<UserModel>) => RequestHandler;
11
12
  createTemporaryToken(user: UserModel): Promise<string>;
12
13
  verifyTemporaryToken(token: string, user: UserModel): Promise<void>;
13
- decodeTemporaryToken(token: string): {
14
- payload: {
15
- uid: string;
16
- };
17
- signature: string;
18
- };
14
+ decodeTemporaryToken(token: string): DecodedTemporaryToken;
19
15
  }
20
- export declare function createAuth<UserModel extends AnyUser>({ getUserByToken, filterUser, redis, providers, loginUrl, }: AuthOptions<UserModel>): CreateAuth<UserModel>;
16
+ export declare class RedisTokenStorage extends TokenStorage {
17
+ private readonly redis;
18
+ private readonly keyPrefix;
19
+ constructor(redis: RedisClient, keyPrefix?: string);
20
+ set(token: string, userId: string, expiresIn: number): Promise<void>;
21
+ get(token: string): Promise<string | null>;
22
+ invalidate(token: string): Promise<void>;
23
+ }
24
+ export declare function createAuth<UserModel extends AnyUser>({ getUserById, filterUser, tokenStorage, providers, loginUrl, onUserLoggedIn, sessionTokenVersion, }: AuthOptions<UserModel>): CreateAuth<UserModel>;
package/dist/auth.js CHANGED
@@ -1,41 +1,75 @@
1
1
  import * as crypto from 'node:crypto';
2
- import { Router } from 'express';
2
+ import { Router, } from 'express';
3
3
  import { InvalidToken, PermissionDenied } from './auth/errors.js';
4
- import { createAuthToken, decodeAuthToken, extractAccessTokenFromRequest, setAccessTokenCookie, tokenSchema, } from './auth/token.js';
5
- import {} from './auth/types.js';
4
+ import { createAccessToken, decodeAccessToken, extractAccessTokenFromRequest, setAccessTokenCookie, tokenSchema, } from './auth/token.js';
5
+ import { TokenStorage, } from './auth/types.js';
6
6
  import { config } from './config.js';
7
7
  import { setContext, useContext } from './context.js';
8
8
  import { Forbidden, NotFound, Unauthorized } from './errors.js';
9
9
  import { getLogger } from './logger.js';
10
10
  import { route } from './middleware.js';
11
+ import {} from './redis.js';
11
12
  import { validateParams } from './validate.js';
12
13
  const logger = getLogger('auth');
13
14
  export * from './auth/types.js';
14
15
  export * from './auth/emailAndPassword.js';
15
16
  export * from './auth/passwordless.js';
16
17
  export * from './auth/token.js';
17
- export function createAuth({ getUserByToken, filterUser, redis, providers, loginUrl = '/', }) {
18
+ export class RedisTokenStorage extends TokenStorage {
19
+ constructor(redis, keyPrefix = 'auth:') {
20
+ super();
21
+ this.redis = redis;
22
+ this.keyPrefix = keyPrefix;
23
+ }
24
+ async set(token, userId, expiresIn) {
25
+ await this.redis.set(`${this.keyPrefix}${token}`, userId, {
26
+ EX: expiresIn,
27
+ });
28
+ }
29
+ async get(token) {
30
+ return this.redis.get(`${this.keyPrefix}${token}`);
31
+ }
32
+ async invalidate(token) {
33
+ await this.redis.del(`${this.keyPrefix}${token}`);
34
+ }
35
+ }
36
+ const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
37
+ export function createAuth({ getUserById, filterUser, tokenStorage, providers, loginUrl = '/', onUserLoggedIn, sessionTokenVersion = '1', }) {
18
38
  const useCurrentUser = () => useContext('currentUser');
19
39
  const setCurrentUser = (user) => setContext('currentUser', user);
20
- const login = async (res, user) => {
21
- const token = createAuthToken(filterUser(user));
22
- await redis.set(token, '1', { EX: config.value.AUTH_TOKEN_EXPIRES_IN });
23
- setAccessTokenCookie(res, token);
40
+ const login = async (req, res, user) => {
41
+ const accessToken = createAccessToken({ uid: user.id }, sessionTokenVersion);
42
+ const sessionId = sha256(accessToken.token);
43
+ await tokenStorage.set(sessionId, String(user.id), accessToken.expiresIn);
44
+ setAccessTokenCookie(res, accessToken.token);
24
45
  setCurrentUser(user);
25
- return { token };
46
+ await onUserLoggedIn?.(user, {
47
+ ip: req.ip ?? '',
48
+ userAgent: req.headers['user-agent'] ?? '',
49
+ sessionId,
50
+ });
51
+ return accessToken;
52
+ };
53
+ const logout = async (res, sessionId) => {
54
+ await tokenStorage.invalidate(sessionId);
55
+ setAccessTokenCookie(res, null);
26
56
  };
57
+ // Make timing attacks harder
58
+ const jitter = (duration = 0) => new Promise((resolve) => void setTimeout(resolve, Math.max((0.5 - duration) * 1000, 200)));
27
59
  /**
28
60
  * API for auth providers to use
29
61
  */
30
62
  const providerContext = {
31
63
  filterUser,
32
- redis,
64
+ tokenStorage,
33
65
  setCurrentUser,
34
66
  createTemporaryToken,
35
67
  verifyTemporaryToken,
36
68
  decodeTemporaryToken,
37
69
  middleware,
38
70
  login,
71
+ getUserById,
72
+ jitter,
39
73
  };
40
74
  const createRouter = () => {
41
75
  const router = Router();
@@ -46,9 +80,8 @@ export function createAuth({ getUserByToken, filterUser, redis, providers, login
46
80
  router.get('/logout', route(async (req, res) => {
47
81
  const token = extractAccessTokenFromRequest(req);
48
82
  if (token) {
49
- await redis.del(token);
83
+ await logout(res, sha256(token));
50
84
  }
51
- setAccessTokenCookie(res, null);
52
85
  res.redirect(loginUrl);
53
86
  }));
54
87
  /**
@@ -60,8 +93,8 @@ export function createAuth({ getUserByToken, filterUser, redis, providers, login
60
93
  required: ['token'],
61
94
  }), route(async (req) => {
62
95
  const { token } = req.params;
63
- const email = await redis.get(token);
64
- if (!email) {
96
+ const value = await tokenStorage.get(token);
97
+ if (!value) {
65
98
  logger.info('invalid reset token', { token });
66
99
  throw NotFound();
67
100
  }
@@ -73,7 +106,7 @@ export function createAuth({ getUserByToken, filterUser, redis, providers, login
73
106
  setAccessTokenCookie(res, null);
74
107
  throw Unauthorized();
75
108
  }
76
- const { token } = await providerContext.login(res, user);
109
+ const { token } = await providerContext.login(req, res, user);
77
110
  logger.info('refreshed token');
78
111
  return { token, user: filterUser(user) };
79
112
  }));
@@ -97,20 +130,27 @@ export function createAuth({ getUserByToken, filterUser, redis, providers, login
97
130
  }
98
131
  throw new InvalidToken('token missing');
99
132
  }
100
- const valid = await redis.get(token);
101
- if (!valid) {
133
+ const sessionId = sha256(token);
134
+ const value = await tokenStorage.get(sessionId);
135
+ if (!value) {
102
136
  if (!authenticate) {
103
137
  logger.warn('request with invalid access token');
104
138
  return next();
105
139
  }
106
- throw new InvalidToken('token expired');
140
+ await logout(res, sessionId);
141
+ throw new InvalidToken('session token expired');
142
+ }
143
+ const decoded = decodeAccessToken(token);
144
+ if (decoded.v !== sessionTokenVersion) {
145
+ await logout(res, token);
146
+ throw new InvalidToken('Invalid token version');
107
147
  }
108
- const decoded = decodeAuthToken(token);
109
148
  let user;
110
149
  try {
111
- user = await getUserByToken(decoded);
150
+ user = await getUserById(decoded.uid);
112
151
  }
113
152
  catch {
153
+ await logout(res, token);
114
154
  throw new InvalidToken('User not found');
115
155
  }
116
156
  if (options.isAuthorized && !(await options.isAuthorized(user))) {
@@ -131,7 +171,7 @@ export function createAuth({ getUserByToken, filterUser, redis, providers, login
131
171
  if (req.accepts('html')) {
132
172
  return res.redirect(loginUrl);
133
173
  }
134
- return next(Unauthorized('Invalid token'));
174
+ return next(Unauthorized(`Invalid token: ${error.message}`));
135
175
  }
136
176
  next(error);
137
177
  }
@@ -143,17 +183,18 @@ export function createAuth({ getUserByToken, filterUser, redis, providers, login
143
183
  */
144
184
  async function createTemporaryToken(user) {
145
185
  try {
146
- const randomBytes = crypto.randomBytes(8).toString('hex');
147
- const payload = { uid: user.id, r: randomBytes };
186
+ const randomBytes = crypto.randomBytes(32).toString('hex');
187
+ const payload = { v: '1', uid: user.id, r: randomBytes };
148
188
  const signature = crypto
149
189
  .createHmac('sha256', config.value.AUTH_SECRET)
150
190
  .update(JSON.stringify(payload))
151
191
  .digest('base64')
152
192
  .slice(0, 12);
153
193
  const temporaryToken = Buffer.from(JSON.stringify({ payload, signature })).toString('base64url');
154
- await redis.set(temporaryToken, user.id, {
155
- EX: config.value.AUTH_TEMP_TOKEN_EXPIRES_IN,
156
- });
194
+ if (!user.id) {
195
+ throw new Error('User ID is required to create a token');
196
+ }
197
+ await tokenStorage.set(temporaryToken, String(user.id), config.value.AUTH_TEMP_TOKEN_EXPIRES_IN);
157
198
  return temporaryToken;
158
199
  }
159
200
  catch (error) {
@@ -175,13 +216,13 @@ export function createAuth({ getUserByToken, filterUser, redis, providers, login
175
216
  throw new InvalidToken('Invalid signature');
176
217
  }
177
218
  if (decoded.payload.uid !== user.id) {
178
- throw new InvalidToken('Invalid user');
219
+ throw new InvalidToken(`Invalid user ${user.id}`);
179
220
  }
180
- const storedUid = await redis.get(token);
221
+ const storedUid = await tokenStorage.get(token);
181
222
  if (storedUid !== user.id) {
182
- throw new InvalidToken('Invalid user');
223
+ throw new InvalidToken(`Invalid user ${user.id}`);
183
224
  }
184
- await redis.del(token);
225
+ await tokenStorage.invalidate(token);
185
226
  }
186
227
  return {
187
228
  createRouter,
package/dist/cache.d.ts CHANGED
@@ -13,6 +13,7 @@ export interface RedisCacheAdapterOptions {
13
13
  }
14
14
  export declare function createRedisCacheAdapter(redis: RedisClient, options?: RedisCacheAdapterOptions): CacheAdapter;
15
15
  export declare function createMemoryCacheAdapter(): CacheAdapter;
16
+ export type Cache = ReturnType<typeof createCache>;
16
17
  export declare function createCache(adapter: CacheAdapter): {
17
18
  set: (key: string, value: any, expires?: number | null | undefined) => Promise<void>;
18
19
  get: (key: string) => Promise<any>;
package/dist/config.d.ts CHANGED
@@ -17,6 +17,15 @@ export declare const config: {
17
17
  GRACE_PERIOD_MS: number;
18
18
  SLACK_WEBHOOK_URL: string;
19
19
  VITE_USE_DEV_SERVER: boolean;
20
+ /**
21
+ * Email configuration
22
+ */
23
+ EMAIL_HOST: string;
24
+ EMAIL_PORT: string;
25
+ EMAIL_USER: string;
26
+ EMAIL_PASS: string;
27
+ EMAIL_SENDER_NAME: string;
28
+ EMAIL_SENDER_ADDRESS: string;
20
29
  };
21
30
  };
22
31
  export declare function extendConfig<T extends Record<string, unknown>>(appConfig: T): {
@@ -38,5 +47,14 @@ export declare function extendConfig<T extends Record<string, unknown>>(appConfi
38
47
  GRACE_PERIOD_MS: number;
39
48
  SLACK_WEBHOOK_URL: string;
40
49
  VITE_USE_DEV_SERVER: boolean;
50
+ /**
51
+ * Email configuration
52
+ */
53
+ EMAIL_HOST: string;
54
+ EMAIL_PORT: string;
55
+ EMAIL_USER: string;
56
+ EMAIL_PASS: string;
57
+ EMAIL_SENDER_NAME: string;
58
+ EMAIL_SENDER_ADDRESS: string;
41
59
  } & T;
42
60
  };
package/dist/config.js CHANGED
@@ -20,6 +20,15 @@ const baseConfig = {
20
20
  GRACE_PERIOD_MS: readEnv.number('GRACE_PERIOD_MS', 20000),
21
21
  SLACK_WEBHOOK_URL: readEnv('SLACK_WEBHOOK_URL', ''),
22
22
  VITE_USE_DEV_SERVER: readEnv.boolean('VITE_USE_DEV_SERVER', false),
23
+ /**
24
+ * Email configuration
25
+ */
26
+ EMAIL_HOST: readEnv('EMAIL_HOST', ''),
27
+ EMAIL_PORT: readEnv('EMAIL_PORT', '587'),
28
+ EMAIL_USER: readEnv('EMAIL_USER', ''),
29
+ EMAIL_PASS: readEnv('EMAIL_PASS', ''),
30
+ EMAIL_SENDER_NAME: readEnv('EMAIL_SENDER_NAME', ''),
31
+ EMAIL_SENDER_ADDRESS: readEnv('EMAIL_SENDER_ADDRESS', ''),
23
32
  };
24
33
  export const config = lazy(() => baseConfig);
25
34
  export function extendConfig(appConfig) {
package/dist/email.d.ts CHANGED
@@ -4,14 +4,14 @@ export interface EmailOptions {
4
4
  port?: string;
5
5
  user?: string;
6
6
  pass?: string;
7
- senderName: string;
8
- senderAddress: string;
7
+ senderName?: string;
8
+ senderAddress?: string;
9
9
  onEmailSent?: (payload: Record<string, any>) => void | Promise<void>;
10
10
  }
11
11
  export interface SendEmailOptions extends nodemailer.SendMailOptions {
12
12
  message: string;
13
13
  }
14
- export declare function createEmail({ host, port, user, pass, senderName, senderAddress, onEmailSent, }: EmailOptions): {
14
+ export declare function createEmail({ host, port, user, pass, senderName, senderAddress, onEmailSent, }?: EmailOptions): {
15
15
  send: ({ to, subject, message, ...rest }: SendEmailOptions) => Promise<void>;
16
16
  transporter: nodemailer.Transporter<import("nodemailer/lib/smtp-transport/index.js").SentMessageInfo, import("nodemailer/lib/smtp-transport/index.js").Options>;
17
17
  };
package/dist/email.js CHANGED
@@ -1,13 +1,17 @@
1
1
  import * as nodemailer from 'nodemailer';
2
+ import { config } from './config.js';
2
3
  import { getLogger } from './logger.js';
3
4
  const logger = getLogger('email');
4
- export function createEmail({ host, port, user, pass, senderName, senderAddress, onEmailSent, }) {
5
+ export function createEmail({ host = config.value.EMAIL_HOST, port = config.value.EMAIL_PORT, user = config.value.EMAIL_USER, pass = config.value.EMAIL_PASS, senderName = config.value.EMAIL_SENDER_NAME, senderAddress = config.value.EMAIL_SENDER_ADDRESS, onEmailSent, } = {}) {
5
6
  const transporter = nodemailer.createTransport({
6
7
  host,
7
8
  port: parseInt(port || '587', 10),
8
9
  secure: false,
9
- auth: { user, pass },
10
+ auth: user && pass ? { user, pass } : undefined,
10
11
  });
12
+ if (!senderName || !senderAddress) {
13
+ throw new Error('Email: missing sender name or address');
14
+ }
11
15
  const send = async ({ to, subject, message, ...rest }) => {
12
16
  const payload = {
13
17
  from: `${senderName} <${senderAddress}>`,
package/dist/listen.js CHANGED
@@ -4,6 +4,7 @@ import {} from 'node:net';
4
4
  import { delay } from '@devmoods/fetch';
5
5
  import {} from 'express';
6
6
  import { config } from './config.js';
7
+ import { BadRequest } from './errors.js';
7
8
  import { getLogger } from './logger.js';
8
9
  import { before, route } from './middleware.js';
9
10
  import { envPort, startTimer } from './utils.js';
@@ -100,8 +101,9 @@ export function createExpressServer(app, options = {}) {
100
101
  for (const signal of ['SIGTERM', 'SIGINT']) {
101
102
  process.on(signal, onSignal);
102
103
  }
104
+ const healthTypes = ['live', 'ready'];
103
105
  if (!app.get('__healthAttached')) {
104
- app.get('/health', before(), validateQuery({
106
+ app.get('/health/:type?', before(), validateQuery({
105
107
  type: 'object',
106
108
  properties: {
107
109
  type: { type: 'string', enum: ['live', 'ready'] },
@@ -112,7 +114,13 @@ export function createExpressServer(app, options = {}) {
112
114
  if (stopping) {
113
115
  throw new Error();
114
116
  }
115
- const type = req.query.type;
117
+ const validateType = (type) => {
118
+ return healthTypes.includes(type);
119
+ };
120
+ const type = req.params.type || req.query.type || 'ready';
121
+ if (!validateType(type)) {
122
+ throw BadRequest('Invalid health type');
123
+ }
116
124
  const meta = await options.healthCheck?.(type);
117
125
  return { status: 'ok', type, meta, timestamp };
118
126
  }
@@ -1,12 +1,8 @@
1
1
  import { type ServerResponse, type IncomingMessage } from 'node:http';
2
- import bodyParser from 'body-parser';
3
2
  import { compose } from 'compose-middleware';
4
- import { type NextFunction, type RequestHandler, type Request, type Response } from 'express';
3
+ import { type NextFunction, type RequestHandler, type Request, type Response, json as jsonParser, urlencoded as urlencodedParser } from 'express';
5
4
  import { type CsrfMiddlewareOptions } from './csrf.js';
6
- type UrlencodedParser = typeof bodyParser.urlencoded;
7
- type JsonParser = typeof bodyParser.json;
8
- export declare const urlencodedParser: UrlencodedParser;
9
- export declare const jsonParser: JsonParser;
5
+ export { urlencodedParser, jsonParser };
10
6
  export { asyncMiddleware as route };
11
7
  export { compose as composeMiddleware };
12
8
  type ContentSecurityPolicyDirectiveValue = string | ((req: IncomingMessage, res: ServerResponse) => string);
@@ -1,23 +1,21 @@
1
1
  import * as crypto from 'node:crypto';
2
2
  import {} from 'node:http';
3
- import bodyParser from 'body-parser';
4
3
  import { compose } from 'compose-middleware';
5
4
  import cookieParser from 'cookie-parser';
6
- import {} from 'express';
5
+ import { json as jsonParser, urlencoded as urlencodedParser, } from 'express';
7
6
  import helmet from 'helmet';
8
7
  import { setContext, useContext, withRequestContext } from './context.js';
9
8
  import { csrf, doubleCsrfProtection, } from './csrf.js';
10
9
  import { NotFound } from './errors.js';
11
10
  import { getLogger } from './logger.js';
12
11
  import { startTimer } from './utils.js';
13
- export const urlencodedParser = bodyParser.urlencoded;
14
- export const jsonParser = bodyParser.json;
12
+ export { urlencodedParser, jsonParser };
15
13
  const logger = getLogger('http');
16
14
  export { asyncMiddleware as route };
17
15
  export { compose as composeMiddleware };
18
16
  const CSP_NONCE_KEY = 'csp-nonce';
19
17
  export function useCspNonce() {
20
- return useContext(CSP_NONCE_KEY) ?? 'fuck';
18
+ return useContext(CSP_NONCE_KEY) ?? '__CSP_NOT_SET__';
21
19
  }
22
20
  /**
23
21
  * Register middleware that must come before the app routes.
@@ -67,12 +65,16 @@ export function before({ cookies = false, helmet: useHelmet = true, json: useJso
67
65
  },
68
66
  }),
69
67
  useJson && jsonParser(),
68
+ urlencodedParser({ extended: true }),
70
69
  (cookies || useCsrf) && cookieParser(),
71
70
  analyticsProxy(),
72
71
  ].filter(Boolean));
73
72
  }
74
73
  function logRequestDuration() {
75
74
  return (req, res, next) => {
75
+ if (req.path.startsWith('/src')) {
76
+ return next();
77
+ }
76
78
  const timer = startTimer();
77
79
  logger.info('http request', {
78
80
  method: req.method,
package/dist/utils.d.ts CHANGED
@@ -1,10 +1,15 @@
1
1
  import { type EventEmitter } from 'node:events';
2
2
  /**
3
3
  * Start a high resolution timer
4
- *
5
4
  */
6
5
  export declare function startTimer(): {
6
+ /**
7
+ * Reset the timer
8
+ */
7
9
  reset(): void;
10
+ /**
11
+ * Get duration in seconds
12
+ */
8
13
  getDuration(): number;
9
14
  };
10
15
  export declare function readEnv(key: string): string | undefined;
package/dist/utils.js CHANGED
@@ -1,14 +1,19 @@
1
1
  import {} from 'node:events';
2
2
  /**
3
3
  * Start a high resolution timer
4
- *
5
4
  */
6
5
  export function startTimer() {
7
6
  let start = process.hrtime();
8
7
  return {
8
+ /**
9
+ * Reset the timer
10
+ */
9
11
  reset() {
10
12
  start = process.hrtime();
11
13
  },
14
+ /**
15
+ * Get duration in seconds
16
+ */
12
17
  getDuration() {
13
18
  const elapsed = process.hrtime(start);
14
19
  return (elapsed[0] * 1e9 + elapsed[1]) / 1e9;
@@ -1,6 +1,7 @@
1
1
  import { type UserConfig } from 'vite';
2
- export declare function defineViteConfig({ base, reactCompiler, ssr, }: {
2
+ export declare function defineViteConfig({ base, reactCompiler, ssr, analyze, }: {
3
3
  base?: string;
4
4
  reactCompiler?: boolean;
5
5
  ssr?: boolean;
6
+ analyze?: boolean;
6
7
  }): UserConfig;
@@ -1,9 +1,10 @@
1
1
  /// <reference types="vitest" />
2
2
  import * as path from 'node:path';
3
3
  import react from '@vitejs/plugin-react';
4
+ import Sonda from 'sonda/vite';
4
5
  import { defineConfig } from 'vite';
5
6
  import { readEnv } from '../utils.js';
6
- export function defineViteConfig({ base = '', reactCompiler = false, ssr = false, }) {
7
+ export function defineViteConfig({ base = '', reactCompiler = false, ssr = false, analyze = true, }) {
7
8
  const port = readEnv.number('VITE_PORT', 5173);
8
9
  return defineConfig({
9
10
  plugins: [
@@ -16,11 +17,18 @@ export function defineViteConfig({ base = '', reactCompiler = false, ssr = false
16
17
  }
17
18
  : {}),
18
19
  }),
20
+ Sonda({
21
+ enabled: analyze,
22
+ open: false,
23
+ detailed: true,
24
+ sources: true,
25
+ }),
19
26
  ],
20
27
  base,
21
28
  build: {
22
29
  chunkSizeWarningLimit: 750,
23
30
  manifest: true,
31
+ sourcemap: true,
24
32
  rollupOptions: {
25
33
  input: {
26
34
  index: path.resolve(process.cwd(), 'src/entry-client.tsx'),
@@ -41,6 +49,7 @@ export function defineViteConfig({ base = '', reactCompiler = false, ssr = false
41
49
  test: {
42
50
  setupFiles: ['./src/setupTests'],
43
51
  include: ['src/**/*.{test,spec}.?(c|m)[jt]s?(x)'],
52
+ globals: true,
44
53
  },
45
54
  css: {
46
55
  preprocessorOptions: {
@@ -1,7 +1,6 @@
1
- import express from 'express';
2
1
  export interface SsrMiddleWareOptions {
3
2
  root?: string;
4
3
  getInitialData?: (req: string) => Promise<unknown>;
5
4
  useDevServer?: boolean;
6
5
  }
7
- export declare function createSsrMiddleware({ root, getInitialData, useDevServer, }?: SsrMiddleWareOptions): Promise<express.RequestHandler<import("express-serve-static-core").ParamsDictionary, any, any, import("qs").ParsedQs, Record<string, any>>[]>;
6
+ export declare function createSsrMiddleware({ root, getInitialData, useDevServer, }?: SsrMiddleWareOptions): Promise<import("compose-middleware").RequestHandler<unknown, unknown, void>>;
package/dist/vite/ssr.js CHANGED
@@ -4,7 +4,7 @@ import express, {} from 'express';
4
4
  import { createServer as createViteServer } from 'vite';
5
5
  import {} from './types.js';
6
6
  import { config } from '../config.js';
7
- import { route, useCspNonce } from '../middleware.js';
7
+ import { composeMiddleware, route, useCspNonce } from '../middleware.js';
8
8
  export async function createSsrMiddleware({ root = process.cwd(), getInitialData = async () => ({}), useDevServer = config.value.VITE_USE_DEV_SERVER, } = {}) {
9
9
  const resolve = (p) => path.resolve(root, p);
10
10
  let vite;
@@ -30,6 +30,7 @@ export async function createSsrMiddleware({ root = process.cwd(), getInitialData
30
30
  const result = await import(path.join(root, 'dist/server/entry-server.js'));
31
31
  render = result.render;
32
32
  }
33
+ const nonce = useCspNonce();
33
34
  const rendered = await render(req, res, {
34
35
  useDevServer,
35
36
  getInitialData,
@@ -40,7 +41,7 @@ export async function createSsrMiddleware({ root = process.cwd(), getInitialData
40
41
  {{ ssr-js }}
41
42
  {{ ssr-css }}
42
43
  `,
43
- nonce: useCspNonce(),
44
+ nonce,
44
45
  preamble: `
45
46
  import RefreshRuntime from "/@react-refresh"
46
47
  RefreshRuntime.injectIntoGlobalHook(window)
@@ -59,6 +60,8 @@ export async function createSsrMiddleware({ root = process.cwd(), getInitialData
59
60
  let html = rendered.html;
60
61
  const context = Object.assign({}, rendered.context, await readManifest());
61
62
  const ssrPlaceholders = Object.fromEntries(Object.entries(context).map(([k, v]) => [`ssr-${k}`, v.toString()]));
63
+ // Add nonce to all bare script tags
64
+ html = html.replace(/<script>/g, `<script nonce="${nonce}">`);
62
65
  html = replaceSsrPlaceholders(html, ssrPlaceholders);
63
66
  html = `<!DOCTYPE html>\n${html}`;
64
67
  return [200, html, { 'Content-type': 'text/html' }];
@@ -75,7 +78,7 @@ export async function createSsrMiddleware({ root = process.cwd(), getInitialData
75
78
  throw error;
76
79
  }
77
80
  }));
78
- return middlewares;
81
+ return composeMiddleware(middlewares);
79
82
  }
80
83
  function replaceSsrPlaceholders(template, values) {
81
84
  return template.replace(/\{\{\s*([\w-]+)\s*\}\}/g, (match, variableName) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devmoods/express-extras",
3
- "version": "0.48.1",
3
+ "version": "0.49.0",
4
4
  "description": "Kubernetes-ready utilities for rapid Node.js application development",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -8,26 +8,26 @@
8
8
  "exports": {
9
9
  ".": {
10
10
  "import": {
11
- "default": "./dist/index.js",
12
- "types": "./dist/index.d.ts"
11
+ "types": "./dist/index.d.ts",
12
+ "default": "./dist/index.js"
13
13
  }
14
14
  },
15
15
  "./vite": {
16
16
  "import": {
17
- "default": "./dist/vite.js",
18
- "types": "./dist/vite/types.js"
17
+ "types": "./dist/vite/types.js",
18
+ "default": "./dist/vite.js"
19
19
  }
20
20
  },
21
21
  "./vite/ssr": {
22
22
  "import": {
23
- "default": "./dist/vite/ssr.js",
24
- "types": "./dist/vite/ssr.d.ts"
23
+ "types": "./dist/vite/ssr.d.ts",
24
+ "default": "./dist/vite/ssr.js"
25
25
  }
26
26
  },
27
27
  "./vite/types": {
28
28
  "import": {
29
- "default": "./dist/vite/types.js",
30
- "types": "./dist/vite/types.d.ts"
29
+ "types": "./dist/vite/types.d.ts",
30
+ "default": "./dist/vite/types.js"
31
31
  }
32
32
  }
33
33
  },
@@ -46,7 +46,7 @@
46
46
  "devDependencies": {
47
47
  "@devmoods/eslint-config": "^2.5.0",
48
48
  "@types/koa-compose": "^3.2.8",
49
- "@types/node": "22.10.2",
49
+ "@types/node": "22.10.5",
50
50
  "@types/supertest": "^6.0.2",
51
51
  "@vitejs/plugin-react": "^4.3.4",
52
52
  "@vitest/coverage-v8": "^2.1.8",
@@ -54,20 +54,20 @@
54
54
  "eslint": "^9.17.0",
55
55
  "faktory-worker": "^4.6.0",
56
56
  "prettier": "3.4.2",
57
+ "sonda": "^0.7.1",
57
58
  "supertest": "7.0.0",
58
59
  "ts-node": "10.9.2",
59
60
  "tsx": "^4.19.2",
60
- "typescript": "5.7.2",
61
- "vite": "^6.0.5",
61
+ "typescript": "5.7.3",
62
+ "vite": "^6.0.7",
62
63
  "vitest": "^2.1.8",
63
64
  "vitest-fail-on-console": "^0.7.1"
64
65
  },
65
66
  "dependencies": {
66
67
  "@devmoods/fetch": "^4.0.1",
67
- "@sentry/node": "^8.47.0",
68
- "@sentry/profiling-node": "^8.47.0",
68
+ "@sentry/node": "^8.48.0",
69
+ "@sentry/profiling-node": "^8.48.0",
69
70
  "@types/bcrypt": "^5.0.2",
70
- "@types/body-parser": "^1.19.5",
71
71
  "@types/cookie-parser": "^1.4.8",
72
72
  "@types/express": "^5.0.0",
73
73
  "@types/jsonwebtoken": "^9.0.7",
@@ -76,10 +76,10 @@
76
76
  "@types/superagent": "^8.1.9",
77
77
  "@types/uuid": "^10.0.0",
78
78
  "ajv": "^8.17.1",
79
+ "argon2": "^0.41.1",
79
80
  "bcrypt": "^5.1.1",
80
- "body-parser": "^1.20.3",
81
81
  "chalk": "^5.4.1",
82
- "commander": "^12.1.0",
82
+ "commander": "^13.0.0",
83
83
  "compose-middleware": "^5.0.1",
84
84
  "cookie-parser": "^1.4.7",
85
85
  "dotenv": "^16.4.7",
@@ -89,8 +89,8 @@
89
89
  "nodemailer": "^6.9.16",
90
90
  "pg": "^8.13.1",
91
91
  "redis": "^4.7.0",
92
- "sql-formatter": "^15.4.8",
93
- "uuid": "^11.0.3"
92
+ "sql-formatter": "^15.4.9",
93
+ "uuid": "^11.0.4"
94
94
  },
95
95
  "prettier": {
96
96
  "singleQuote": true
@@ -101,7 +101,7 @@
101
101
  "vite": ">=6.0.5"
102
102
  },
103
103
  "engines": {
104
- "node": ">=20.3.0 <23"
104
+ "node": ">=20 <24"
105
105
  },
106
106
  "scripts": {
107
107
  "start": "pushd example; pnpm run start --watch; popd example;",