@devmoods/express-extras 0.73.0 → 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.
@@ -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: 4 };
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(error.message, { cause: error });
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
- const user = await options.login(email, skipPasswordCheck);
77
- const temporaryToken = await context.createTemporaryToken(user);
78
- await options.onEmailVerificationRequest?.(user, temporaryToken);
79
- await context.jitter(timer.getDuration());
80
- return 202;
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
  });
@@ -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 {};
@@ -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
- logger.info('google payload', { payload });
83
- const user = await getOrCreateUserFromUserIdentifier(payload?.sub, {
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
- const verify = tokenType === 'otp'
36
- ? context.verifyOtp
37
- : context.verifyTemporaryToken;
38
- const tokenValue = String(tokenType === 'otp' ? otp : temporaryToken);
39
- const uid = await verify(tokenValue, null, false);
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
- await verify(tokenValue, user);
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
  };
@@ -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())
@@ -1,5 +1,5 @@
1
- import { type RedisClient } from '../redis.js';
2
1
  import { TokenStorage } from './types.js';
2
+ import { type RedisClient } from '../redis.js';
3
3
  export declare class RedisTokenStorage extends TokenStorage {
4
4
  redis: RedisClient;
5
5
  keyPrefix: string;
@@ -1,6 +1,6 @@
1
1
  import { config } from '@devmoods/config';
2
- import {} from '../redis.js';
3
2
  import { TokenStorage } from './types.js';
3
+ import {} from '../redis.js';
4
4
  export class RedisTokenStorage extends TokenStorage {
5
5
  redis;
6
6
  keyPrefix;
@@ -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<string>;
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<string>;
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
@@ -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) {
@@ -161,7 +174,7 @@ export function createAuth({ getUserById, filterUser, tokenStorage, providers, l
161
174
  if (req.accepts('html')) {
162
175
  return res.redirect(loginUrl);
163
176
  }
164
- return next(Unauthorized(`Invalid token: ${error.message}`, { cause: error }));
177
+ return next(Unauthorized('Invalid token', { cause: error }));
165
178
  }
166
179
  /* v8 ignore next -- @preserve */
167
180
  next(error);
@@ -171,6 +184,7 @@ export function createAuth({ getUserById, filterUser, tokenStorage, providers, l
171
184
  function protect(isAuthorized) {
172
185
  return async function authorizationMiddleware(req, res, next) {
173
186
  try {
187
+ // oxlint-disable-next-line eslint-plugin-react-hooks/rules-of-hooks
174
188
  const user = useCurrentUser();
175
189
  if (!user) {
176
190
  throw new InvalidToken('User not logged in');
@@ -202,22 +216,48 @@ export function createAuth({ getUserById, filterUser, tokenStorage, providers, l
202
216
  }
203
217
  async function createOtp(user) {
204
218
  const otp = crypto.randomInt(100000, 999999).toString();
205
- await tokenStorage.set(otp, String(user.id), config.AUTH_OTP_EXPIRES);
206
- return otp;
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 };
207
228
  }
208
- async function verifyOtp(otp, user, consume = true) {
209
- const storedUid = await tokenStorage.get(otp);
210
- if (!storedUid) {
229
+ async function verifyOtp(challengeId, otp, user, consume = true) {
230
+ const storedChallenge = await tokenStorage.get(`otp:${challengeId}`);
231
+ if (!storedChallenge) {
211
232
  throw new InvalidToken('Invalid OTP');
212
233
  }
213
- /* c8 ignore next 3 */
214
- if (user && storedUid !== user.id) {
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
+ }
215
255
  throw new InvalidToken('Invalid OTP');
216
256
  }
217
257
  if (consume) {
218
- await tokenStorage.invalidate(otp);
258
+ await tokenStorage.invalidate(`otp:${challengeId}`);
219
259
  }
220
- return storedUid;
260
+ return parsedChallenge.uid;
221
261
  }
222
262
  /**
223
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 interface CacheAdapter {
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 function createMemoryCacheAdapter(): CacheAdapter;
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
- export function createRedisCacheAdapter(redis, options = {}) {
4
- const { keyPrefix = `${config.REDIS_KEY_PREFIX}cache:` } = options;
5
- const withKeyPrefix = (key) => `${keyPrefix}${key}`;
6
- return {
7
- async get(key) {
8
- const value = await redis.get(withKeyPrefix(key));
9
- if (value == null) {
10
- return null;
11
- }
12
- return JSON.parse(value);
13
- },
14
- async set(key, value, expires) {
15
- await redis.set(withKeyPrefix(key), JSON.stringify(value), {
16
- PX: expires,
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
- async clear(key) {
20
- if (key) {
21
- await redis.del(withKeyPrefix(key));
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 createMemoryCacheAdapter() {
31
- const cache = new Map();
32
- return {
33
- async get(key) {
34
- const entry = cache.get(key);
35
- if (entry == null) {
36
- return null;
37
- }
38
- const [value, expires] = entry;
39
- if (expires != null && Date.now() >= expires) {
40
- cache.delete(key);
41
- return null;
42
- }
43
- return value;
44
- },
45
- async set(key, value, expires) {
46
- cache.set(key, [value, expires == null ? null : Date.now() + expires]);
47
- },
48
- async clear(key) {
49
- if (key) {
50
- cache.delete(key);
51
- }
52
- else {
53
- cache.clear();
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
- return `${baseKey ?? Buffer.from(fn.toString()).toString('hex')}-${hashKey(args)}`;
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, (key, value) => {
105
- if (value === null || value === undefined) {
106
- return String(value);
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
- // eslint-disable-next-line @typescript-eslint/no-shadow
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
- // eslint-disable-next-line @typescript-eslint/no-shadow
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/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) {
@@ -175,7 +174,7 @@ export function createExpressServer(app, options = {}) {
175
174
  configurable: true,
176
175
  });
177
176
  enhanced.start = async function start() {
178
- const port = app.get('port') ?? envPort(0);
177
+ const port = config.PORT;
179
178
  await options.onStart?.();
180
179
  await ctl.start(port);
181
180
  logger.info('app listening', {
package/dist/logger.js CHANGED
@@ -7,7 +7,7 @@ export class JsonStreamAdapter {
7
7
  constructor(stream) {
8
8
  this.#stream = stream;
9
9
  }
10
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
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`;
@@ -72,7 +72,7 @@ export function createLogger({ name, getContext, level = config.LOG_LEVEL, outpu
72
72
  function getFinalContext(context) {
73
73
  return Object.assign(baseContext(), getContext?.(), context);
74
74
  }
75
- // eslint-disable-next-line @typescript-eslint/no-shadow
75
+ // oxlint-disable-next-line @typescript-eslint/no-shadow
76
76
  function log(level, message, context) {
77
77
  if (level < logLevel) {
78
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
- // eslint-disable-next-line no-console
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
- // eslint-disable-next-line no-console
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
- // eslint-disable-next-line no-console
63
+ // oxlint-disable-next-line no-console
64
64
  console.log(JSON.stringify(config, null, 2));
65
65
  return;
66
66
  }
@@ -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
- res.on('finish', resolve);
229
- res.on('error', reject);
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
@@ -10,4 +10,3 @@ export declare namespace readEnv {
10
10
  }
11
11
  declare function readEnvNumber(key: string, defaultValue: number): number;
12
12
  declare function readEnvBoolean(key: string, defaultValue: boolean): boolean;
13
- export declare const envPort: (defaultPort?: number) => number;
package/dist/utils.js CHANGED
@@ -13,4 +13,3 @@ function readEnvNumber(key, defaultValue) {
13
13
  function readEnvBoolean(key, defaultValue) {
14
14
  return ['true', '1', 't'].includes(readEnv(key, `${defaultValue}`).toLowerCase());
15
15
  }
16
- export const envPort = (defaultPort = 3000) => readEnv.number('PORT', defaultPort);
package/dist/vite/ssr.js CHANGED
@@ -11,8 +11,8 @@ import * as path from 'node:path';
11
11
  import { config } from '@devmoods/config';
12
12
  import express, {} from 'express';
13
13
  import {} from 'vite';
14
- import {} from '../composeMiddleware.js';
15
14
  import {} from './types.js';
15
+ import {} from '../composeMiddleware.js';
16
16
  import { generateCsrfToken } from '../csrf.js';
17
17
  import { composeMiddleware, route, useCspNonce } from '../middleware.js';
18
18
  import { createFetchRequest } from '../webapi.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devmoods/express-extras",
3
- "version": "0.73.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": "eslint",
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,10 +69,10 @@
68
69
  "vite": "^8.0.8"
69
70
  },
70
71
  "dependencies": {
71
- "@devmoods/config": "0.2.3",
72
+ "@devmoods/config": "0.2.4",
72
73
  "@devmoods/fetch": "^4.3.0",
73
- "@devmoods/observability": "0.2.0",
74
- "@devmoods/postgres": "0.5.0",
74
+ "@devmoods/observability": "0.3.0",
75
+ "@devmoods/postgres": "0.6.0",
75
76
  "@hey-api/openapi-ts": "^0.96.0",
76
77
  "@rolldown/plugin-babel": "^0.2.3",
77
78
  "@standard-schema/spec": "^1.1.0",
@@ -94,7 +95,7 @@
94
95
  "helmet": "^8.1.0",
95
96
  "jose": "^6.2.2",
96
97
  "nodemailer": "^8.0.5",
97
- "redis": "^5.11.0",
98
+ "redis": "^5.12.1",
98
99
  "sonda": "^0.11.1",
99
100
  "tough-cookie": "^6.0.1",
100
101
  "uuid": "^13.0.0",
@@ -110,5 +111,5 @@
110
111
  "engines": {
111
112
  "node": ">=20 <26"
112
113
  },
113
- "gitHead": "ea4f51756aca98bd050cf98128bf4d4b3da1bf38"
114
+ "gitHead": "2c4100d80823bcf16bd962b4b8a26bd078522dfc"
114
115
  }