@morojs/moro 1.5.15 → 1.5.17

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.
@@ -285,64 +285,6 @@ export class MoroHttpServer {
285
285
  // Execute handler
286
286
  await route.handler(httpReq, httpRes);
287
287
  } catch (error) {
288
- // Handle JWT-specific errors gracefully
289
- if (
290
- error instanceof Error &&
291
- (error.name === 'TokenExpiredError' ||
292
- error.name === 'JsonWebTokenError' ||
293
- error.name === 'NotBeforeError')
294
- ) {
295
- this.logger.debug('JWT authentication error', 'RequestHandler', {
296
- errorType: error.name,
297
- errorMessage: error.message,
298
- requestPath: req.url,
299
- requestMethod: req.method,
300
- requestId: httpReq.requestId,
301
- });
302
-
303
- if (!httpRes.headersSent) {
304
- if (typeof httpRes.status === 'function' && typeof httpRes.json === 'function') {
305
- if (error.name === 'TokenExpiredError') {
306
- httpRes.status(401).json({
307
- success: false,
308
- error: 'Token expired',
309
- message: 'Your session has expired. Please sign in again.',
310
- requestId: httpReq.requestId,
311
- expiredAt: (error as any).expiredAt,
312
- });
313
- } else if (error.name === 'JsonWebTokenError') {
314
- httpRes.status(401).json({
315
- success: false,
316
- error: 'Invalid token',
317
- message: 'The provided authentication token is invalid.',
318
- requestId: httpReq.requestId,
319
- });
320
- } else if (error.name === 'NotBeforeError') {
321
- httpRes.status(401).json({
322
- success: false,
323
- error: 'Token not ready',
324
- message: 'The authentication token is not yet valid.',
325
- requestId: httpReq.requestId,
326
- availableAt: (error as any).date,
327
- });
328
- }
329
- } else {
330
- // Fallback for non-enhanced response objects
331
- httpRes.statusCode = 401;
332
- httpRes.setHeader('Content-Type', 'application/json');
333
- httpRes.end(
334
- JSON.stringify({
335
- success: false,
336
- error: 'Authentication failed',
337
- message: 'JWT authentication error occurred.',
338
- requestId: httpReq.requestId,
339
- })
340
- );
341
- }
342
- }
343
- return;
344
- }
345
-
346
288
  // Debug: Log the actual error and where it came from
347
289
  this.logger.debug('Request error details', 'RequestHandler', {
348
290
  errorType: typeof error,
@@ -1074,101 +1016,11 @@ export class MoroHttpServer {
1074
1016
  if (!nextCalled) next();
1075
1017
  })
1076
1018
  .catch(error => {
1077
- // Handle JWT errors in async middleware
1078
- if (
1079
- error instanceof Error &&
1080
- (error.name === 'TokenExpiredError' ||
1081
- error.name === 'JsonWebTokenError' ||
1082
- error.name === 'NotBeforeError')
1083
- ) {
1084
- if (!res.headersSent) {
1085
- if (typeof res.status === 'function' && typeof res.json === 'function') {
1086
- if (error.name === 'TokenExpiredError') {
1087
- res.status(401).json({
1088
- success: false,
1089
- error: 'Token expired',
1090
- message: 'Your session has expired. Please sign in again.',
1091
- expiredAt: (error as any).expiredAt,
1092
- });
1093
- } else if (error.name === 'JsonWebTokenError') {
1094
- res.status(401).json({
1095
- success: false,
1096
- error: 'Invalid token',
1097
- message: 'The provided authentication token is invalid.',
1098
- });
1099
- } else if (error.name === 'NotBeforeError') {
1100
- res.status(401).json({
1101
- success: false,
1102
- error: 'Token not ready',
1103
- message: 'The authentication token is not yet valid.',
1104
- availableAt: (error as any).date,
1105
- });
1106
- }
1107
- } else {
1108
- res.statusCode = 401;
1109
- res.setHeader('Content-Type', 'application/json');
1110
- res.end(
1111
- JSON.stringify({
1112
- success: false,
1113
- error: 'Authentication failed',
1114
- message: 'JWT authentication error occurred.',
1115
- })
1116
- );
1117
- }
1118
- }
1119
- resolve(); // Continue middleware chain
1120
- } else {
1121
- reject(error);
1122
- }
1019
+ reject(error);
1123
1020
  });
1124
1021
  }
1125
1022
  } catch (error) {
1126
- // Handle JWT errors in sync middleware
1127
- if (
1128
- error instanceof Error &&
1129
- (error.name === 'TokenExpiredError' ||
1130
- error.name === 'JsonWebTokenError' ||
1131
- error.name === 'NotBeforeError')
1132
- ) {
1133
- if (!res.headersSent) {
1134
- if (typeof res.status === 'function' && typeof res.json === 'function') {
1135
- if (error.name === 'TokenExpiredError') {
1136
- res.status(401).json({
1137
- success: false,
1138
- error: 'Token expired',
1139
- message: 'Your session has expired. Please sign in again.',
1140
- expiredAt: (error as any).expiredAt,
1141
- });
1142
- } else if (error.name === 'JsonWebTokenError') {
1143
- res.status(401).json({
1144
- success: false,
1145
- error: 'Invalid token',
1146
- message: 'The provided authentication token is invalid.',
1147
- });
1148
- } else if (error.name === 'NotBeforeError') {
1149
- res.status(401).json({
1150
- success: false,
1151
- error: 'Token not ready',
1152
- message: 'The authentication token is not yet valid.',
1153
- availableAt: (error as any).date,
1154
- });
1155
- }
1156
- } else {
1157
- res.statusCode = 401;
1158
- res.setHeader('Content-Type', 'application/json');
1159
- res.end(
1160
- JSON.stringify({
1161
- success: false,
1162
- error: 'Authentication failed',
1163
- message: 'JWT authentication error occurred.',
1164
- })
1165
- );
1166
- }
1167
- }
1168
- resolve(); // Continue middleware chain
1169
- } else {
1170
- reject(error);
1171
- }
1023
+ reject(error);
1172
1024
  }
1173
1025
  });
1174
1026
  }
@@ -12,6 +12,7 @@ import {
12
12
  CredentialsProvider,
13
13
  EmailProvider,
14
14
  } from '../../../types/auth';
15
+ import { safeVerifyJWT, createAuthErrorResponse } from './jwt-helpers';
15
16
 
16
17
  const logger = createFrameworkLogger('AuthMiddleware');
17
18
 
@@ -186,27 +187,59 @@ export const auth = (options: AuthOptions): MiddlewareInterface => ({
186
187
  session = await authInstance.getSession({ req: { ...req, token } });
187
188
  }
188
189
  } catch (error: any) {
189
- // Handle specific JWT errors gracefully
190
+ // Handle specific JWT errors gracefully and return proper HTTP responses
190
191
  if (error.name === 'TokenExpiredError') {
191
192
  logger.debug('JWT token expired', 'TokenValidation', {
192
193
  message: error.message,
193
194
  expiredAt: error.expiredAt,
194
195
  });
196
+
197
+ // If this is a protected route request, return a proper 401 response
198
+ if (req.headers.accept?.includes('application/json')) {
199
+ return res.status(401).json(
200
+ createAuthErrorResponse({
201
+ type: 'expired',
202
+ message: error.message,
203
+ expiredAt: error.expiredAt,
204
+ })
205
+ );
206
+ }
195
207
  } else if (error.name === 'JsonWebTokenError') {
196
208
  logger.debug('Invalid JWT token format', 'TokenValidation', {
197
209
  message: error.message,
198
210
  });
211
+
212
+ // If this is a protected route request, return a proper 401 response
213
+ if (req.headers.accept?.includes('application/json')) {
214
+ return res.status(401).json(
215
+ createAuthErrorResponse({
216
+ type: 'invalid',
217
+ message: error.message,
218
+ })
219
+ );
220
+ }
199
221
  } else if (error.name === 'NotBeforeError') {
200
222
  logger.debug('JWT token not active yet', 'TokenValidation', {
201
223
  message: error.message,
202
224
  date: error.date,
203
225
  });
226
+
227
+ // If this is a protected route request, return a proper 401 response
228
+ if (req.headers.accept?.includes('application/json')) {
229
+ return res.status(401).json(
230
+ createAuthErrorResponse({
231
+ type: 'malformed',
232
+ message: error.message,
233
+ date: error.date,
234
+ })
235
+ );
236
+ }
204
237
  } else {
205
238
  logger.debug('JWT token validation failed', 'TokenValidation', {
206
239
  error: error.message || error,
207
240
  });
208
241
  }
209
- // Continue with unauthenticated state - don't throw
242
+ // Continue with unauthenticated state for non-API requests
210
243
  }
211
244
  }
212
245
 
@@ -298,44 +331,34 @@ async function initializeAuthJS(config: AuthOptions): Promise<any> {
298
331
  },
299
332
 
300
333
  verifyJWT: async (token: string) => {
301
- // Require jsonwebtoken for JWT verification
302
- let jwt: any;
303
- try {
304
- jwt = require('jsonwebtoken');
305
- } catch (error) {
306
- throw new Error(
307
- 'JWT verification requires the "jsonwebtoken" package. ' +
308
- 'Please install it with: npm install jsonwebtoken @types/jsonwebtoken'
309
- );
310
- }
334
+ const secret = process.env.JWT_SECRET || config.jwt?.secret || config.secret || '';
335
+
336
+ // Use the safe JWT verification function
337
+ const result = safeVerifyJWT(token, secret);
338
+
339
+ if (!result.success) {
340
+ // Create a custom error that includes the structured error information
341
+ const customError = new Error(result.error?.message || 'JWT verification failed');
342
+
343
+ // Add the error type information for upstream handling
344
+ (customError as any).jwtErrorType = result.error?.type;
345
+ (customError as any).jwtErrorDetails = result.error;
346
+
347
+ // Map the safe error types back to standard JWT error names for compatibility
348
+ if (result.error?.type === 'expired') {
349
+ customError.name = 'TokenExpiredError';
350
+ (customError as any).expiredAt = result.error.expiredAt;
351
+ } else if (result.error?.type === 'invalid') {
352
+ customError.name = 'JsonWebTokenError';
353
+ } else if (result.error?.type === 'malformed') {
354
+ customError.name = 'NotBeforeError';
355
+ (customError as any).date = result.error.date;
356
+ }
311
357
 
312
- const secret = process.env.JWT_SECRET || config.jwt?.secret || config.secret;
313
- if (!secret) {
314
- throw new Error(
315
- 'JWT verification requires a secret. ' +
316
- 'Please set JWT_SECRET environment variable, or provide jwt.secret or secret in auth config.'
317
- );
358
+ throw customError;
318
359
  }
319
360
 
320
- try {
321
- const decoded = jwt.verify(token, secret);
322
- return decoded;
323
- } catch (error: any) {
324
- // Handle specific JWT errors gracefully
325
- if (error.name === 'TokenExpiredError') {
326
- // Token expired - handled gracefully by auth middleware
327
- throw error;
328
- } else if (error.name === 'JsonWebTokenError') {
329
- // Invalid token format
330
- throw error;
331
- } else if (error.name === 'NotBeforeError') {
332
- // Token not active yet
333
- throw error;
334
- } else {
335
- // Other JWT errors
336
- throw new Error(`JWT verification failed: ${error.message}`);
337
- }
338
- }
361
+ return result.payload;
339
362
  },
340
363
 
341
364
  signIn: async (provider?: string, options?: any) => {
@@ -178,7 +178,7 @@ export function createAuthErrorResponse(error: JWTVerificationResult['error']) {
178
178
  }
179
179
 
180
180
  /**
181
- * Example usage for custom middleware:
181
+ * Example usage for custom middleware with elegant error handling:
182
182
  *
183
183
  * ```typescript
184
184
  * import { safeVerifyJWT, extractJWTFromHeader, createAuthErrorResponse } from '@morojs/moro';
@@ -197,6 +197,7 @@ export function createAuthErrorResponse(error: JWTVerificationResult['error']) {
197
197
  * const result = safeVerifyJWT(token, process.env.JWT_SECRET!);
198
198
  *
199
199
  * if (!result.success) {
200
+ * // This provides elegant, user-friendly error messages instead of stack traces
200
201
  * const errorResponse = createAuthErrorResponse(result.error);
201
202
  * return res.status(401).json(errorResponse);
202
203
  * }
@@ -212,4 +213,28 @@ export function createAuthErrorResponse(error: JWTVerificationResult['error']) {
212
213
  * next();
213
214
  * };
214
215
  * ```
216
+ *
217
+ * Benefits of using safeVerifyJWT vs raw jsonwebtoken.verify():
218
+ *
219
+ * ❌ Raw approach (shows ugly error messages to users):
220
+ * ```typescript
221
+ * try {
222
+ * const decoded = jwt.verify(token, secret);
223
+ * req.user = decoded;
224
+ * } catch (error) {
225
+ * // This exposes technical details and stack traces to users:
226
+ * // "Invalid token: TokenExpiredError: jwt expired at /node_modules/jsonwebtoken/verify.js:190:21..."
227
+ * throw error; // BAD - exposes internal details
228
+ * }
229
+ * ```
230
+ *
231
+ * ✅ Safe approach (shows clean, user-friendly messages):
232
+ * ```typescript
233
+ * const result = safeVerifyJWT(token, secret);
234
+ * if (!result.success) {
235
+ * // This returns clean messages like:
236
+ * // { "error": "Token expired", "message": "Your session has expired. Please sign in again." }
237
+ * return res.status(401).json(createAuthErrorResponse(result.error));
238
+ * }
239
+ * ```
215
240
  */
@@ -1,11 +1,10 @@
1
1
  // Auto-discovery system for Moro modules
2
2
  import { readdirSync, statSync } from 'fs';
3
- import { join, extname, relative } from 'path';
3
+ import { join, extname, relative, isAbsolute } from 'path';
4
4
  import { ModuleConfig } from '../../types/module';
5
5
  import { DiscoveryOptions } from '../../types/discovery';
6
6
  import { ModuleDefaultsConfig } from '../../types/config';
7
7
  import { createFrameworkLogger } from '../logger';
8
- import { minimatch } from 'minimatch';
9
8
 
10
9
  export class ModuleDiscovery {
11
10
  private baseDir: string;
@@ -219,19 +218,32 @@ export class ModuleDiscovery {
219
218
  const fullPath = join(this.baseDir, searchPath);
220
219
 
221
220
  try {
222
- if (!statSync(fullPath).isDirectory()) {
221
+ const stat = statSync(fullPath);
222
+
223
+ if (!stat.isDirectory()) {
223
224
  return modules;
224
225
  }
226
+ } catch (error) {
227
+ return modules;
228
+ }
225
229
 
226
- const files = this.findMatchingFiles(fullPath, config);
230
+ try {
231
+ const files = await this.findMatchingFilesWithGlob(
232
+ fullPath,
233
+ config.patterns,
234
+ config.ignorePatterns,
235
+ config.maxDepth
236
+ );
227
237
 
228
238
  for (const filePath of files) {
229
239
  try {
230
- const module = await this.loadModule(filePath);
240
+ // Convert relative path to absolute path for import
241
+ const absolutePath = join(this.baseDir, filePath);
242
+ const module = await this.loadModule(absolutePath);
231
243
  if (module && this.validateAdvancedModule(module, config)) {
232
244
  modules.push(module);
233
245
  this.discoveryLogger.info(
234
- `Auto-discovered module: ${module.name}@${module.version} from ${relative(this.baseDir, filePath)}`
246
+ `Auto-discovered module: ${module.name}@${module.version} from ${filePath}`
235
247
  );
236
248
  }
237
249
  } catch (error) {
@@ -302,14 +314,154 @@ export class ModuleDiscovery {
302
314
  return files;
303
315
  }
304
316
 
317
+ // Use native Node.js glob to find matching files
318
+ private async findMatchingFilesWithGlob(
319
+ searchPath: string,
320
+ patterns: string[],
321
+ ignorePatterns: string[],
322
+ maxDepth: number = 5
323
+ ): Promise<string[]> {
324
+ // Force fallback in CI environments or if Node.js version is uncertain
325
+ const isCI = process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true';
326
+
327
+ if (isCI) {
328
+ return this.findMatchingFilesFallback(searchPath, patterns, ignorePatterns, maxDepth);
329
+ }
330
+
331
+ const allFiles: string[] = [];
332
+
333
+ try {
334
+ // Try to use native fs.glob if available (Node.js 20+)
335
+ const { glob } = await import('fs/promises');
336
+
337
+ // Check if glob is actually a function and test it
338
+ if (typeof glob !== 'function') {
339
+ return this.findMatchingFilesFallback(searchPath, patterns, ignorePatterns, maxDepth);
340
+ }
341
+
342
+ // Test glob with a simple pattern first
343
+ try {
344
+ const testIterator = glob(join(searchPath, '*'));
345
+ let testCount = 0;
346
+ for await (const _ of testIterator) {
347
+ testCount++;
348
+ if (testCount > 0) break; // Just test that it works
349
+ }
350
+ } catch (testError) {
351
+ return this.findMatchingFilesFallback(searchPath, patterns, ignorePatterns, maxDepth);
352
+ }
353
+
354
+ for (const pattern of patterns) {
355
+ const fullPattern = join(searchPath, pattern);
356
+ try {
357
+ // fs.glob returns an AsyncIterator, need to collect results
358
+ const globIterator = glob(fullPattern);
359
+ const files: string[] = [];
360
+
361
+ for await (const file of globIterator) {
362
+ const filePath = typeof file === 'string' ? file : (file as any).name || String(file);
363
+ const relativePath = relative(this.baseDir, filePath);
364
+
365
+ // Check if file should be ignored and within max depth
366
+ if (
367
+ !this.shouldIgnore(relativePath, ignorePatterns) &&
368
+ this.isWithinMaxDepth(relativePath, searchPath, maxDepth)
369
+ ) {
370
+ files.push(relativePath);
371
+ }
372
+ }
373
+
374
+ allFiles.push(...files);
375
+ } catch (error) {
376
+ // If any glob call fails, fall back to manual discovery
377
+ this.discoveryLogger.warn(`Glob pattern failed: ${pattern}`, String(error));
378
+ return this.findMatchingFilesFallback(searchPath, patterns, ignorePatterns, maxDepth);
379
+ }
380
+ }
381
+ } catch (error) {
382
+ // fs.glob not available, fall back to manual file discovery
383
+ this.discoveryLogger.debug('Native fs.glob not available, using fallback');
384
+ return this.findMatchingFilesFallback(searchPath, patterns, ignorePatterns, maxDepth);
385
+ }
386
+
387
+ return [...new Set(allFiles)]; // Remove duplicates
388
+ }
389
+
390
+ // Fallback for Node.js versions without fs.glob
391
+ private async findMatchingFilesFallback(
392
+ searchPath: string,
393
+ patterns: string[],
394
+ ignorePatterns: string[],
395
+ maxDepth: number = 5
396
+ ): Promise<string[]> {
397
+ const config = {
398
+ patterns,
399
+ ignorePatterns,
400
+ maxDepth,
401
+ recursive: true,
402
+ } as ModuleDefaultsConfig['autoDiscovery'];
403
+
404
+ // Handle both absolute and relative paths
405
+ const fullSearchPath = isAbsolute(searchPath) ? searchPath : join(this.baseDir, searchPath);
406
+
407
+ // Check if search path exists
408
+ try {
409
+ const { access } = await import('fs/promises');
410
+ await access(fullSearchPath);
411
+ } catch (e) {
412
+ return [];
413
+ }
414
+
415
+ // Get files and convert to relative paths
416
+ const files = this.findMatchingFiles(fullSearchPath, config);
417
+ const relativeFiles = files.map(file => relative(this.baseDir, file));
418
+
419
+ return relativeFiles;
420
+ }
421
+
422
+ // Simple pattern matching for fallback (basic glob support)
423
+ private matchesSimplePattern(path: string, pattern: string): boolean {
424
+ try {
425
+ // Normalize path separators
426
+ const normalizedPath = path.replace(/\\/g, '/');
427
+ const normalizedPattern = pattern.replace(/\\/g, '/');
428
+
429
+ // Convert simple glob patterns to regex
430
+ const regexPattern = normalizedPattern
431
+ .replace(/\*\*/g, '___DOUBLESTAR___') // Temporarily replace ** BEFORE escaping
432
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape regex chars
433
+ .replace(/\\\*/g, '[^/]*') // * matches anything except /
434
+ .replace(/___DOUBLESTAR___/g, '.*') // ** matches anything including /
435
+ .replace(/\\\?/g, '[^/]') // ? matches single character except /
436
+ .replace(/\\\{([^}]+)\\\}/g, '($1)') // {ts,js} -> (ts|js)
437
+ .replace(/,/g, '|'); // Convert comma to OR
438
+
439
+ const regex = new RegExp(`^${regexPattern}$`, 'i');
440
+ const result = regex.test(normalizedPath);
441
+
442
+ return result;
443
+ } catch (error) {
444
+ this.discoveryLogger.warn(`Pattern matching error for "${pattern}": ${String(error)}`);
445
+ return false;
446
+ }
447
+ }
448
+
305
449
  // Check if path should be ignored
306
450
  private shouldIgnore(path: string, ignorePatterns: string[]): boolean {
307
- return ignorePatterns.some(pattern => minimatch(path, pattern));
451
+ return ignorePatterns.some(pattern => this.matchesSimplePattern(path, pattern));
308
452
  }
309
453
 
310
454
  // Check if path matches any of the patterns
311
455
  private matchesPatterns(path: string, patterns: string[]): boolean {
312
- return patterns.some(pattern => minimatch(path, pattern));
456
+ return patterns.some(pattern => this.matchesSimplePattern(path, pattern));
457
+ }
458
+
459
+ // Check if file is within max depth (for glob results)
460
+ private isWithinMaxDepth(relativePath: string, searchPath: string, maxDepth: number): boolean {
461
+ // Count directory separators to determine depth
462
+ const pathFromSearch = relative(searchPath, join(this.baseDir, relativePath));
463
+ const depth = pathFromSearch.split('/').length - 1; // -1 because file itself doesn't count as depth
464
+ return depth <= maxDepth;
313
465
  }
314
466
 
315
467
  // Remove duplicate modules