@triophore/falconjs 1.0.0 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/FalconAuthPlugin.js +31 -24
- package/falcon.js +114 -58
- package/package.json +1 -1
- package/test_new_logic.js +63 -0
- package/test_recursive_routes/auth/login.js +1 -0
- package/test_recursive_routes/index.js +1 -0
package/FalconAuthPlugin.js
CHANGED
|
@@ -63,7 +63,9 @@ class FalconAuth {
|
|
|
63
63
|
socketio: Joi.object({
|
|
64
64
|
enabled: Joi.boolean().default(true),
|
|
65
65
|
timeout: Joi.number().default(5000)
|
|
66
|
-
}).default({ enabled: true, timeout: 5000 })
|
|
66
|
+
}).default({ enabled: true, timeout: 5000 }),
|
|
67
|
+
|
|
68
|
+
default: Joi.string().optional()
|
|
67
69
|
});
|
|
68
70
|
|
|
69
71
|
const { error, value } = schema.validate(options);
|
|
@@ -95,10 +97,15 @@ class FalconAuth {
|
|
|
95
97
|
await this.registerJWKS(server);
|
|
96
98
|
}
|
|
97
99
|
|
|
98
|
-
// Set default strategy
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
100
|
+
// Set default strategy
|
|
101
|
+
if (this.options.default) {
|
|
102
|
+
server.auth.default(this.options.default);
|
|
103
|
+
} else {
|
|
104
|
+
// Auto-set default strategy if only one is configured (and no default specified)
|
|
105
|
+
const strategies = Object.keys(this.options).filter(k => k !== 'socketio' && k !== 'default');
|
|
106
|
+
if (strategies.length === 1) {
|
|
107
|
+
server.auth.default(strategies[0]);
|
|
108
|
+
}
|
|
102
109
|
}
|
|
103
110
|
}
|
|
104
111
|
|
|
@@ -279,15 +286,15 @@ class FalconAuth {
|
|
|
279
286
|
|
|
280
287
|
extractSocketToken(socket) {
|
|
281
288
|
const { token, auth, authorization } = socket.handshake.auth || {};
|
|
282
|
-
|
|
289
|
+
|
|
283
290
|
if (token) return token;
|
|
284
291
|
if (auth) return auth;
|
|
285
|
-
|
|
292
|
+
|
|
286
293
|
const authHeader = socket.handshake.headers.authorization;
|
|
287
294
|
if (authHeader?.startsWith('Bearer ')) {
|
|
288
295
|
return authHeader.substring(7);
|
|
289
296
|
}
|
|
290
|
-
|
|
297
|
+
|
|
291
298
|
return socket.handshake.query.token;
|
|
292
299
|
}
|
|
293
300
|
|
|
@@ -297,14 +304,14 @@ class FalconAuth {
|
|
|
297
304
|
try {
|
|
298
305
|
const cacheKey = `auth:custom:${this.hashToken(token)}`;
|
|
299
306
|
let user = await this.getFromCache(cacheKey);
|
|
300
|
-
|
|
307
|
+
|
|
301
308
|
if (!user) {
|
|
302
309
|
user = await this.options.custom.validate(token, null, this.context);
|
|
303
310
|
if (user) {
|
|
304
311
|
await this.setCache(cacheKey, user, 300);
|
|
305
312
|
}
|
|
306
313
|
}
|
|
307
|
-
|
|
314
|
+
|
|
308
315
|
if (user) return user;
|
|
309
316
|
} catch (error) {
|
|
310
317
|
this.logger.debug('Custom socket auth failed:', error.message);
|
|
@@ -318,21 +325,21 @@ class FalconAuth {
|
|
|
318
325
|
const decoded = jwt.verify(token, this.options.jwt.secret, {
|
|
319
326
|
algorithms: this.options.jwt.algorithms
|
|
320
327
|
});
|
|
321
|
-
|
|
328
|
+
|
|
322
329
|
const cacheKey = `auth:jwt:${decoded.sub || decoded.id}`;
|
|
323
330
|
let user = null;
|
|
324
|
-
|
|
331
|
+
|
|
325
332
|
if (this.options.jwt.cache.enabled) {
|
|
326
333
|
user = await this.getFromCache(cacheKey);
|
|
327
334
|
}
|
|
328
|
-
|
|
335
|
+
|
|
329
336
|
if (!user) {
|
|
330
337
|
user = await this.options.jwt.validate(decoded, null, this.context);
|
|
331
338
|
if (user && this.options.jwt.cache.enabled) {
|
|
332
339
|
await this.setCache(cacheKey, user, this.options.jwt.cache.ttl);
|
|
333
340
|
}
|
|
334
341
|
}
|
|
335
|
-
|
|
342
|
+
|
|
336
343
|
if (user) return user;
|
|
337
344
|
} catch (error) {
|
|
338
345
|
this.logger.debug('JWT socket auth failed:', error.message);
|
|
@@ -348,21 +355,21 @@ class FalconAuth {
|
|
|
348
355
|
issuer: this.options.jwks.issuer,
|
|
349
356
|
audience: this.options.jwks.audience
|
|
350
357
|
});
|
|
351
|
-
|
|
358
|
+
|
|
352
359
|
const cacheKey = `auth:jwks:${payload.sub}`;
|
|
353
360
|
let user = null;
|
|
354
|
-
|
|
361
|
+
|
|
355
362
|
if (this.options.jwks.cache.enabled) {
|
|
356
363
|
user = await this.getFromCache(cacheKey);
|
|
357
364
|
}
|
|
358
|
-
|
|
365
|
+
|
|
359
366
|
if (!user) {
|
|
360
367
|
user = await this.options.jwks.validate(payload, null, this.context);
|
|
361
368
|
if (user && this.options.jwks.cache.enabled) {
|
|
362
369
|
await this.setCache(cacheKey, user, this.options.jwks.cache.ttl);
|
|
363
370
|
}
|
|
364
371
|
}
|
|
365
|
-
|
|
372
|
+
|
|
366
373
|
if (user) return user;
|
|
367
374
|
}
|
|
368
375
|
} catch (error) {
|
|
@@ -378,12 +385,12 @@ class FalconAuth {
|
|
|
378
385
|
if (customExtractor) {
|
|
379
386
|
return customExtractor(request);
|
|
380
387
|
}
|
|
381
|
-
|
|
388
|
+
|
|
382
389
|
const authHeader = request.headers.authorization;
|
|
383
390
|
if (authHeader?.startsWith('Bearer ')) {
|
|
384
391
|
return authHeader.substring(7);
|
|
385
392
|
}
|
|
386
|
-
|
|
393
|
+
|
|
387
394
|
return request.query.token || request.payload?.token;
|
|
388
395
|
}
|
|
389
396
|
|
|
@@ -394,7 +401,7 @@ class FalconAuth {
|
|
|
394
401
|
|
|
395
402
|
async getFromCache(key) {
|
|
396
403
|
if (!this.redis) return null;
|
|
397
|
-
|
|
404
|
+
|
|
398
405
|
try {
|
|
399
406
|
const cached = await this.redis.get(key);
|
|
400
407
|
return cached ? JSON.parse(cached) : null;
|
|
@@ -406,7 +413,7 @@ class FalconAuth {
|
|
|
406
413
|
|
|
407
414
|
async setCache(key, value, ttl) {
|
|
408
415
|
if (!this.redis) return;
|
|
409
|
-
|
|
416
|
+
|
|
410
417
|
try {
|
|
411
418
|
await this.redis.setEx(key, ttl, JSON.stringify(value));
|
|
412
419
|
} catch (error) {
|
|
@@ -461,10 +468,10 @@ const plugin = {
|
|
|
461
468
|
register: async function (server, options) {
|
|
462
469
|
// Get context from server app (passed from Falcon.js)
|
|
463
470
|
const context = server.app.falconContext || {};
|
|
464
|
-
|
|
471
|
+
|
|
465
472
|
const falconAuth = new FalconAuth(options, context);
|
|
466
473
|
await falconAuth.registerHapi(server);
|
|
467
|
-
|
|
474
|
+
|
|
468
475
|
// Expose auth instance for Socket.IO use
|
|
469
476
|
server.app.falconAuth = falconAuth;
|
|
470
477
|
}
|
package/falcon.js
CHANGED
|
@@ -469,49 +469,74 @@ class Falcon {
|
|
|
469
469
|
* @param {string[]} files - Array to collect file paths (used for recursion)
|
|
470
470
|
* @returns {Promise<string[]>} Array of JavaScript file paths
|
|
471
471
|
*/
|
|
472
|
-
async #findJsFilesRecursively(dir
|
|
473
|
-
|
|
472
|
+
async #findJsFilesRecursively(dir) {
|
|
473
|
+
let files = [];
|
|
474
|
+
try {
|
|
475
|
+
const items = await fs.promises.readdir(dir, { withFileTypes: true });
|
|
474
476
|
|
|
475
|
-
|
|
476
|
-
|
|
477
|
+
for (const item of items) {
|
|
478
|
+
const fullPath = path.join(dir, item.name);
|
|
477
479
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
480
|
+
if (item.isDirectory() && !item.name.startsWith('.')) {
|
|
481
|
+
// Recursively find files and concat
|
|
482
|
+
const nestedFiles = await this.#findJsFilesRecursively(fullPath);
|
|
483
|
+
files = files.concat(nestedFiles);
|
|
484
|
+
}
|
|
485
|
+
else if (item.isFile() && item.name.endsWith('.js') && !item.name.endsWith('.test.js')) {
|
|
486
|
+
files.push(fullPath);
|
|
487
|
+
}
|
|
483
488
|
}
|
|
489
|
+
} catch (err) {
|
|
490
|
+
this.CONTEXT["logger"].error(`Error scanning directory ${dir}:`, err.message);
|
|
484
491
|
}
|
|
485
492
|
|
|
486
493
|
return files;
|
|
487
494
|
}
|
|
488
495
|
|
|
489
496
|
/**
|
|
490
|
-
*
|
|
491
|
-
*
|
|
497
|
+
* Parses route file path to extract method and route path.
|
|
498
|
+
* Validates against [METHOD]#filename.js pattern.
|
|
492
499
|
*
|
|
493
500
|
* @private
|
|
494
501
|
* @param {string} relativePath - Relative path from routes directory
|
|
495
|
-
* @returns {
|
|
502
|
+
* @returns {Object|null} Object { method, path } or null if invalid
|
|
496
503
|
*/
|
|
497
|
-
#
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
const baseName = path.basename(relativePath, '.js');
|
|
506
|
-
if (baseName !== 'index') {
|
|
507
|
-
let segment = baseName
|
|
508
|
-
.replace(/^_/, ':')
|
|
509
|
-
.replace(/_/g, '-')
|
|
510
|
-
.replace(/^\[([^\]]+)\]$/, ':$1');
|
|
511
|
-
routePath = path.posix.join(routePath, segment);
|
|
504
|
+
#parseRouteFile(relativePath) {
|
|
505
|
+
const filename = path.basename(relativePath);
|
|
506
|
+
|
|
507
|
+
// Check for [METHOD]# pattern
|
|
508
|
+
// Allows any methods characters, will be validated by Hapi or logic if needed, but user said ANY is valid
|
|
509
|
+
const match = filename.match(/^\[([A-Z]+)\]#(.*)\.js$/i);
|
|
510
|
+
if (!match) {
|
|
511
|
+
return null;
|
|
512
512
|
}
|
|
513
513
|
|
|
514
|
-
|
|
514
|
+
const method = match[1].toUpperCase();
|
|
515
|
+
const rawName = match[2];
|
|
516
|
+
|
|
517
|
+
let dirname = path.dirname(relativePath);
|
|
518
|
+
if (dirname === '.') dirname = '';
|
|
519
|
+
|
|
520
|
+
// Convert dirname params [param] -> {param}
|
|
521
|
+
let routePath = '/' + dirname
|
|
522
|
+
.split('/')
|
|
523
|
+
.map(segment => segment.replace(/^\[(.+)\]$/, '{$1}'))
|
|
524
|
+
.join('/');
|
|
525
|
+
|
|
526
|
+
// Clean up path
|
|
527
|
+
routePath = routePath.replace(/\/+/g, '/');
|
|
528
|
+
|
|
529
|
+
// Append filename (unless index)
|
|
530
|
+
if (rawName !== 'index') {
|
|
531
|
+
routePath = path.posix.join(routePath, rawName);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Ensure no trailing slash (unless root)
|
|
535
|
+
if (routePath !== '/') {
|
|
536
|
+
routePath = routePath.replace(/\/$/, '');
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
return { method, path: routePath };
|
|
515
540
|
}
|
|
516
541
|
|
|
517
542
|
/**
|
|
@@ -526,12 +551,15 @@ class Falcon {
|
|
|
526
551
|
#findBestValidatorName(baseName, relativePath, validators) {
|
|
527
552
|
const capitalize = (str) => str.charAt(0).toUpperCase() + str.slice(1);
|
|
528
553
|
|
|
554
|
+
// Adjust baseName for validator search since we stripped [METHOD]#
|
|
555
|
+
const realBaseName = baseName.replace(/^\[[A-Z]+\]#/, '');
|
|
556
|
+
|
|
529
557
|
const candidates = [
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
path.dirname(relativePath).split(path.sep).pop() +
|
|
533
|
-
capitalize(
|
|
534
|
-
|
|
558
|
+
realBaseName,
|
|
559
|
+
realBaseName + 'Payload',
|
|
560
|
+
path.dirname(relativePath).split(path.sep).pop() + realBaseName,
|
|
561
|
+
capitalize(realBaseName),
|
|
562
|
+
realBaseName.replace(/Handler$/i, ''),
|
|
535
563
|
];
|
|
536
564
|
for (const c of candidates) {
|
|
537
565
|
if (validators[c]) return c;
|
|
@@ -585,8 +613,8 @@ class Falcon {
|
|
|
585
613
|
}
|
|
586
614
|
|
|
587
615
|
/**
|
|
588
|
-
* Loads routes recursively from the routes directory
|
|
589
|
-
*
|
|
616
|
+
* Loads routes recursively from the routes directory using dynamic file naming conventions.
|
|
617
|
+
* Pattern: [METHOD]#filename.js
|
|
590
618
|
*
|
|
591
619
|
* @private
|
|
592
620
|
* @async
|
|
@@ -619,45 +647,73 @@ class Falcon {
|
|
|
619
647
|
// Register Swagger first (needs to be before routes)
|
|
620
648
|
await this.#registerSwagger();
|
|
621
649
|
|
|
622
|
-
this.CONTEXT["logger"].info("Loading routes with
|
|
650
|
+
this.CONTEXT["logger"].info("Loading routes with dynamic naming convention...");
|
|
623
651
|
|
|
624
652
|
const routeFiles = await this.#findJsFilesRecursively(routesDir);
|
|
625
653
|
|
|
626
654
|
for (const filePath of routeFiles) {
|
|
627
655
|
const relativePath = path.relative(routesDir, filePath);
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
656
|
+
|
|
657
|
+
const parsed = this.#parseRouteFile(relativePath);
|
|
658
|
+
if (!parsed) {
|
|
659
|
+
// Discard if not matching pattern
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const { method, path: routePath } = parsed;
|
|
664
|
+
this.CONTEXT["logger"].info(`Route found: [${method}] ${routePath} (${relativePath})`);
|
|
631
665
|
|
|
632
666
|
try {
|
|
633
667
|
const routeModule = require(filePath);
|
|
634
|
-
const register = typeof routeModule === 'function' ? routeModule : routeModule.route?.bind(routeModule);
|
|
635
668
|
|
|
636
|
-
|
|
669
|
+
// Flexible export: can be a handler function, or an object with handler/options
|
|
670
|
+
// OR legacy support: function that takes context (but we need to manually register)
|
|
637
671
|
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
672
|
+
let handler, options = {};
|
|
673
|
+
|
|
674
|
+
if (typeof routeModule === 'function') {
|
|
675
|
+
// Assume it's a handler(request, h)
|
|
676
|
+
handler = routeModule;
|
|
677
|
+
} else if (typeof routeModule === 'object') {
|
|
678
|
+
if (routeModule.handler) handler = routeModule.handler;
|
|
679
|
+
if (routeModule.options) options = routeModule.options;
|
|
680
|
+
|
|
681
|
+
// If user exported .route function (legacy), we can't really use it easily with new params
|
|
682
|
+
// unless we assume they migrated. We'll stick to handler/options pattern for new files.
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
if (!handler) {
|
|
686
|
+
this.CONTEXT["logger"].warn(`Skipping ${relativePath}: No handler exported`);
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
641
689
|
|
|
642
690
|
// Auto-generate tag from folder
|
|
643
691
|
const tag = path.dirname(relativePath).split(path.sep)[0] || 'api';
|
|
644
692
|
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
693
|
+
// Find validator attempt
|
|
694
|
+
const baseName = path.basename(filePath, '.js');
|
|
695
|
+
const validatorName = this.#findBestValidatorName(baseName, relativePath, validators);
|
|
696
|
+
if (validatorName && validators[validatorName]) {
|
|
697
|
+
// Merge into options.validate if not present
|
|
698
|
+
options.validate = options.validate || {};
|
|
699
|
+
if (!options.validate.payload && !options.validate.query && !options.validate.params) {
|
|
700
|
+
// Heuristic: default to payload for POST/PUT/PATCH, query for others?
|
|
701
|
+
// Or just user provided. For now let's leave flexible or auto-attach if user wants.
|
|
702
|
+
// The old logic attached it to ctx.route.validator.
|
|
703
|
+
}
|
|
704
|
+
}
|
|
656
705
|
|
|
657
|
-
|
|
706
|
+
// Register the route
|
|
707
|
+
this.httpServer.route({
|
|
708
|
+
method: method,
|
|
709
|
+
path: routePath,
|
|
710
|
+
handler: handler,
|
|
711
|
+
options: {
|
|
712
|
+
tags: [tag],
|
|
713
|
+
...options
|
|
714
|
+
}
|
|
715
|
+
});
|
|
658
716
|
|
|
659
|
-
const valMark = validator ? 'Validated + Documented' : 'Documented';
|
|
660
|
-
this.CONTEXT["logger"].info(`${valMark} ${routePath} ← ${relativePath}`);
|
|
661
717
|
} catch (err) {
|
|
662
718
|
this.CONTEXT["logger"].error(`Route failed: ${filePath}`, err.message);
|
|
663
719
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
|
|
3
|
+
function parseRoute(relativePath) {
|
|
4
|
+
const filename = path.basename(relativePath);
|
|
5
|
+
|
|
6
|
+
// 1. Check for [METHOD]# pattern
|
|
7
|
+
const match = filename.match(/^\[([A-Z]+)\]#(.*)\.js$/i);
|
|
8
|
+
if (!match) {
|
|
9
|
+
return null; // Ignore file
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const method = match[1].toUpperCase();
|
|
13
|
+
const rawName = match[2]; // filename without [METHOD]# and .js
|
|
14
|
+
|
|
15
|
+
// 2. Build Path
|
|
16
|
+
let dirname = path.dirname(relativePath);
|
|
17
|
+
if (dirname === '.') dirname = '';
|
|
18
|
+
|
|
19
|
+
// Convert dirname params [param] -> {param}
|
|
20
|
+
let routePath = '/' + dirname
|
|
21
|
+
.split('/')
|
|
22
|
+
.map(segment => segment.replace(/^\[(.+)\]$/, '{$1}')) // [id] -> {id}
|
|
23
|
+
.join('/');
|
|
24
|
+
|
|
25
|
+
// Clean up path
|
|
26
|
+
routePath = routePath.replace(/\/+/g, '/');
|
|
27
|
+
|
|
28
|
+
// Append filename (unless index)
|
|
29
|
+
if (rawName !== 'index') {
|
|
30
|
+
routePath = path.posix.join(routePath, rawName);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Ensure no trailing slash (unless root)
|
|
34
|
+
if (routePath !== '/') {
|
|
35
|
+
routePath = routePath.replace(/\/$/, '');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
method,
|
|
40
|
+
path: routePath
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const tests = [
|
|
45
|
+
'files.js', // Should be ignored
|
|
46
|
+
'[GET]#file.js', // -> /file, GET
|
|
47
|
+
'[POST]#index.js', // -> /, POST
|
|
48
|
+
'folder_name/[GET]#file.js', // -> /folder_name/file, GET
|
|
49
|
+
'folder_name/[param]/[PUT]#file.js', // -> /folder_name/{param}/file, PUT
|
|
50
|
+
'users/[id]/[GET]#details.js', // -> /users/{id}/details, GET
|
|
51
|
+
'users/[id]/[GET]#index.js', // -> /users/{id}, GET
|
|
52
|
+
'api/v1/[DELETE]#user.js' // -> /api/v1/user, DELETE
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
console.log('--- Testing Route Parsing ---');
|
|
56
|
+
tests.forEach(t => {
|
|
57
|
+
const result = parseRoute(t);
|
|
58
|
+
if (result) {
|
|
59
|
+
console.log(`${t} -> ${result.method} ${result.path}`);
|
|
60
|
+
} else {
|
|
61
|
+
console.log(`${t} -> IGNORED`);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module.exports = {}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module.exports = {}
|