@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.
@@ -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
@@ -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, NotFound, Unauthorized } from './errors.js';
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.get('/logout', {}, async (req, res) => {
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 (req) => {
94
- const { token } = req.params;
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(`Invalid token: ${error.message}`, { cause: error }));
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
- await tokenStorage.set(otp, String(user.id), config.AUTH_OTP_EXPIRES);
217
- 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 };
218
228
  }
219
- async function verifyOtp(otp, user, consume = true) {
220
- const storedUid = await tokenStorage.get(otp);
221
- if (!storedUid) {
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
- /* c8 ignore next 3 */
225
- 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
+ }
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 storedUid;
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 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/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
@@ -19,7 +19,6 @@ export * from './rate-limit.js';
19
19
  export * from './redis.js';
20
20
  export * from './router.js';
21
21
  export * from './routes.js';
22
- export * from './sentry.js';
23
22
  export * from './slack.js';
24
23
  export * from './utils.js';
25
24
  export * from './validate.js';
package/dist/index.js CHANGED
@@ -20,7 +20,6 @@ export * from './rate-limit.js';
20
20
  export * from './redis.js';
21
21
  export * from './router.js';
22
22
  export * from './routes.js';
23
- export * from './sentry.js';
24
23
  export * from './slack.js';
25
24
  export * from './utils.js';
26
25
  export * from './validate.js';
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 = app.get('port') ?? envPort(0);
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 * as Sentry from '@sentry/node';
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
- // 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`;
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
- export function createLogger({ name, getContext, level = config.LOG_LEVEL, outputs = [new JsonStreamAdapter(process.stdout), new SentryLoggerAdapter()], }) {
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
- // eslint-disable-next-line @typescript-eslint/no-shadow
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
- // 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
@@ -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.72.5",
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,15 +69,12 @@
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.1.4",
74
- "@devmoods/postgres": "0.4.2",
75
- "@hey-api/openapi-ts": "^0.95.0",
76
- "@rolldown/plugin-babel": "^0.2.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.1",
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.11.0",
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": "e29bcf8284033887f836a8ca7486b2b80e00852e"
114
+ "gitHead": "2c4100d80823bcf16bd962b4b8a26bd078522dfc"
117
115
  }
package/dist/sentry.d.ts DELETED
@@ -1,5 +0,0 @@
1
- import type * as Sentry from '@sentry/node';
2
- export type { Sentry };
3
- export interface SentryOptions extends Sentry.NodeOptions {
4
- }
5
- export declare function sentryOptions(options?: SentryOptions): Promise<SentryOptions>;
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
- }