@hemia/core 0.0.10 → 0.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,12 @@
1
1
  import 'reflect-metadata';
2
2
  import express, { Router } from 'express';
3
- import { METADATA_KEYS, ParamType, ApiResponse, isRedirectResponse, ControllerRegistry } from '@hemia/common';
3
+ import { METADATA_KEYS, ParamType, ApiResponse, isRedirectResponse, ControllerRegistry, CustomHttpError } from '@hemia/common';
4
4
  import { TRACE_METADATA_KEY } from '@hemia/trace-manager';
5
5
  import { traceMiddleware } from '@hemia/app-context';
6
6
  import { plainToInstance } from 'class-transformer';
7
7
  import multer from 'multer';
8
8
  import { injectable, inject } from 'inversify';
9
- import { AUTH_SERVICE_ID, AuthService } from '@hemia/auth-sdk';
9
+ import { AUTH_SERVICE_ID, AuthService, OAuthService } from '@hemia/auth-sdk';
10
10
 
11
11
  class GuardsConsumer {
12
12
  /**
@@ -338,9 +338,9 @@ class AppFactory {
338
338
  const defaultHeaders = {
339
339
  'Access-Control-Allow-Origin': req.headers.origin || '*',
340
340
  'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,PATCH,OPTIONS',
341
- 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Api-Key, Language, X-No-Cookies, x-session',
341
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Api-Key, Language, X-No-Cookies, x-session, x-client-id, X-RateLimit-Limit, X-RateLimit-Remaining, Retry-After',
342
342
  'Access-Control-Allow-Credentials': 'true',
343
- 'Access-Control-Expose-Headers': 'Authorization, X-Correlation-Id, X-Trace-Id, x-session',
343
+ 'Access-Control-Expose-Headers': 'Authorization, X-Correlation-Id, X-Trace-Id, x-session, X-RateLimit-Limit, X-RateLimit-Remaining, Retry-After',
344
344
  };
345
345
  const headers = { ...defaultHeaders, ...(options.corsHeaders || {}) };
346
346
  Object.entries(headers).forEach(([key, value]) => {
@@ -392,6 +392,7 @@ class AppFactory {
392
392
  /**
393
393
  * Guardia de autorización que verifica roles y permisos definidos en los controladores y métodos.
394
394
  * Utiliza los metadatos definidos con los decoradores @Roles y @Permissions.
395
+ * Soporta wildcards en permisos jerárquicos (ej: account:*, account:email:*)
395
396
  */
396
397
  let AuthGuard = class AuthGuard {
397
398
  constructor(reflector) {
@@ -422,12 +423,36 @@ let AuthGuard = class AuthGuard {
422
423
  }
423
424
  if (requiredPermissions) {
424
425
  const userPermissions = Array.isArray(permissions) ? permissions : [];
425
- const hasPermission = requiredPermissions.some((perm) => userPermissions.includes(perm));
426
+ const hasPermission = requiredPermissions.some((requiredPerm) => this.checkPermission(requiredPerm, userPermissions));
426
427
  if (!hasPermission)
427
428
  return false;
428
429
  }
429
430
  return true;
430
431
  }
432
+ /**
433
+ * Verifica si el usuario tiene un permiso requerido, soportando wildcards
434
+ * @param requiredPermission Permiso requerido (ej: account:email:update)
435
+ * @param userPermissions Array de permisos del usuario
436
+ * @returns true si el usuario tiene el permiso
437
+ */
438
+ checkPermission(requiredPermission, userPermissions) {
439
+ const requiredParts = requiredPermission.split(':');
440
+ return userPermissions.some(userPerm => {
441
+ const userParts = userPerm.split(':');
442
+ for (let i = 0; i < requiredParts.length; i++) {
443
+ if (userParts[i] === '*') {
444
+ return true;
445
+ }
446
+ if (i >= userParts.length) {
447
+ return false;
448
+ }
449
+ if (userParts[i] !== requiredParts[i]) {
450
+ return false;
451
+ }
452
+ }
453
+ return true;
454
+ });
455
+ }
431
456
  };
432
457
  AuthGuard = __decorate([
433
458
  injectable(),
@@ -500,4 +525,112 @@ JWTGuard = __decorate([
500
525
  __metadata("design:paramtypes", [AuthService])
501
526
  ], JWTGuard);
502
527
 
503
- export { ApiKeyGuard, AppFactory, AuthGuard, GuardsConsumer, HemiaExecutionContext, JWTGuard, Reflector, ResponseSerializer, registerRoutes };
528
+ let PublicClientGuard = class PublicClientGuard {
529
+ constructor(oauthService) {
530
+ this.oauthService = oauthService;
531
+ }
532
+ async canActivate(context) {
533
+ const request = context.switchToHttp().getRequest();
534
+ const clientId = request.headers['x-client-id'] || request.body.client_id;
535
+ if (!clientId) {
536
+ return false;
537
+ }
538
+ const client = await this.oauthService.getByClientId(clientId);
539
+ if (!client || !client.isActive) {
540
+ return false;
541
+ }
542
+ request.oauthClient = client;
543
+ return true;
544
+ }
545
+ };
546
+ PublicClientGuard = __decorate([
547
+ injectable(),
548
+ __param(0, inject(OAuthService)),
549
+ __metadata("design:paramtypes", [OAuthService])
550
+ ], PublicClientGuard);
551
+
552
+ var ThrottleGuard_1;
553
+ let ThrottleGuard = ThrottleGuard_1 = class ThrottleGuard {
554
+ constructor() {
555
+ if (!ThrottleGuard_1.cleanupInterval) {
556
+ ThrottleGuard_1.cleanupInterval = setInterval(() => {
557
+ this.cleanup();
558
+ }, 60000);
559
+ ThrottleGuard_1.cleanupInterval.unref();
560
+ }
561
+ }
562
+ async canActivate(context) {
563
+ const request = context.switchToHttp().getRequest();
564
+ const handler = context.getHandler();
565
+ const options = Reflect.getMetadata(METADATA_KEYS.THROTTLE, handler);
566
+ if (!options) {
567
+ return true;
568
+ }
569
+ if (options.skipIf && options.skipIf(context)) {
570
+ return true;
571
+ }
572
+ const ip = this.getClientIp(request);
573
+ const endpointKey = `${context.getClass().name}:${handler.name}`;
574
+ const key = `throttle:${endpointKey}:${ip}`;
575
+ const now = Date.now();
576
+ const ttlMs = options.ttl * 1000;
577
+ let record = ThrottleGuard_1.storage.get(key);
578
+ if (!record || record.expiresAt < now) {
579
+ record = {
580
+ hits: 0,
581
+ expiresAt: now + ttlMs
582
+ };
583
+ }
584
+ record.hits++;
585
+ ThrottleGuard_1.storage.set(key, record);
586
+ if (record.hits > options.limit) {
587
+ const response = context.switchToHttp().getResponse();
588
+ const retryAfter = Math.ceil((record.expiresAt - now) / 1000);
589
+ this.setHeaders(response, options.limit, 0, retryAfter);
590
+ throw new CustomHttpError('Too Many Requests', 429, 'rate_limit_exceeded');
591
+ }
592
+ const response = context.switchToHttp().getResponse();
593
+ const remaining = Math.max(0, options.limit - record.hits);
594
+ this.setHeaders(response, options.limit, remaining, options.ttl);
595
+ return true;
596
+ }
597
+ /**
598
+ * Limpia las entradas expiradas para evitar fugas de memoria
599
+ */
600
+ cleanup() {
601
+ const now = Date.now();
602
+ ThrottleGuard_1.storage.forEach((value, key) => {
603
+ if (value.expiresAt < now) {
604
+ ThrottleGuard_1.storage.delete(key);
605
+ }
606
+ });
607
+ }
608
+ getClientIp(request) {
609
+ const headers = request.headers;
610
+ if (headers['cf-connecting-ip'])
611
+ return headers['cf-connecting-ip'];
612
+ if (headers['x-forwarded-for']) {
613
+ return headers['x-forwarded-for'].split(',')[0].trim();
614
+ }
615
+ if (headers['x-real-ip'])
616
+ return headers['x-real-ip'];
617
+ return request.socket?.remoteAddress || request.ip || 'unknown';
618
+ }
619
+ setHeaders(res, limit, remaining, resetOrRetry) {
620
+ if (res && typeof res.setHeader === 'function') {
621
+ res.setHeader('X-RateLimit-Limit', limit);
622
+ res.setHeader('X-RateLimit-Remaining', remaining);
623
+ if (remaining === 0) {
624
+ res.setHeader('Retry-After', resetOrRetry);
625
+ }
626
+ }
627
+ }
628
+ };
629
+ ThrottleGuard.storage = new Map();
630
+ ThrottleGuard.cleanupInterval = null;
631
+ ThrottleGuard = ThrottleGuard_1 = __decorate([
632
+ injectable(),
633
+ __metadata("design:paramtypes", [])
634
+ ], ThrottleGuard);
635
+
636
+ export { ApiKeyGuard, AppFactory, AuthGuard, GuardsConsumer, HemiaExecutionContext, JWTGuard, PublicClientGuard, Reflector, ResponseSerializer, ThrottleGuard, registerRoutes };
@@ -340,9 +340,9 @@ class AppFactory {
340
340
  const defaultHeaders = {
341
341
  'Access-Control-Allow-Origin': req.headers.origin || '*',
342
342
  'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,PATCH,OPTIONS',
343
- 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Api-Key, Language, X-No-Cookies, x-session',
343
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Api-Key, Language, X-No-Cookies, x-session, x-client-id, X-RateLimit-Limit, X-RateLimit-Remaining, Retry-After',
344
344
  'Access-Control-Allow-Credentials': 'true',
345
- 'Access-Control-Expose-Headers': 'Authorization, X-Correlation-Id, X-Trace-Id, x-session',
345
+ 'Access-Control-Expose-Headers': 'Authorization, X-Correlation-Id, X-Trace-Id, x-session, X-RateLimit-Limit, X-RateLimit-Remaining, Retry-After',
346
346
  };
347
347
  const headers = { ...defaultHeaders, ...(options.corsHeaders || {}) };
348
348
  Object.entries(headers).forEach(([key, value]) => {
@@ -394,6 +394,7 @@ class AppFactory {
394
394
  /**
395
395
  * Guardia de autorización que verifica roles y permisos definidos en los controladores y métodos.
396
396
  * Utiliza los metadatos definidos con los decoradores @Roles y @Permissions.
397
+ * Soporta wildcards en permisos jerárquicos (ej: account:*, account:email:*)
397
398
  */
398
399
  exports.AuthGuard = class AuthGuard {
399
400
  constructor(reflector) {
@@ -424,12 +425,36 @@ exports.AuthGuard = class AuthGuard {
424
425
  }
425
426
  if (requiredPermissions) {
426
427
  const userPermissions = Array.isArray(permissions) ? permissions : [];
427
- const hasPermission = requiredPermissions.some((perm) => userPermissions.includes(perm));
428
+ const hasPermission = requiredPermissions.some((requiredPerm) => this.checkPermission(requiredPerm, userPermissions));
428
429
  if (!hasPermission)
429
430
  return false;
430
431
  }
431
432
  return true;
432
433
  }
434
+ /**
435
+ * Verifica si el usuario tiene un permiso requerido, soportando wildcards
436
+ * @param requiredPermission Permiso requerido (ej: account:email:update)
437
+ * @param userPermissions Array de permisos del usuario
438
+ * @returns true si el usuario tiene el permiso
439
+ */
440
+ checkPermission(requiredPermission, userPermissions) {
441
+ const requiredParts = requiredPermission.split(':');
442
+ return userPermissions.some(userPerm => {
443
+ const userParts = userPerm.split(':');
444
+ for (let i = 0; i < requiredParts.length; i++) {
445
+ if (userParts[i] === '*') {
446
+ return true;
447
+ }
448
+ if (i >= userParts.length) {
449
+ return false;
450
+ }
451
+ if (userParts[i] !== requiredParts[i]) {
452
+ return false;
453
+ }
454
+ }
455
+ return true;
456
+ });
457
+ }
433
458
  };
434
459
  exports.AuthGuard = __decorate([
435
460
  inversify.injectable(),
@@ -502,6 +527,114 @@ exports.JWTGuard = __decorate([
502
527
  __metadata("design:paramtypes", [authSdk.AuthService])
503
528
  ], exports.JWTGuard);
504
529
 
530
+ exports.PublicClientGuard = class PublicClientGuard {
531
+ constructor(oauthService) {
532
+ this.oauthService = oauthService;
533
+ }
534
+ async canActivate(context) {
535
+ const request = context.switchToHttp().getRequest();
536
+ const clientId = request.headers['x-client-id'] || request.body.client_id;
537
+ if (!clientId) {
538
+ return false;
539
+ }
540
+ const client = await this.oauthService.getByClientId(clientId);
541
+ if (!client || !client.isActive) {
542
+ return false;
543
+ }
544
+ request.oauthClient = client;
545
+ return true;
546
+ }
547
+ };
548
+ exports.PublicClientGuard = __decorate([
549
+ inversify.injectable(),
550
+ __param(0, inversify.inject(authSdk.OAuthService)),
551
+ __metadata("design:paramtypes", [authSdk.OAuthService])
552
+ ], exports.PublicClientGuard);
553
+
554
+ var ThrottleGuard_1;
555
+ exports.ThrottleGuard = ThrottleGuard_1 = class ThrottleGuard {
556
+ constructor() {
557
+ if (!ThrottleGuard_1.cleanupInterval) {
558
+ ThrottleGuard_1.cleanupInterval = setInterval(() => {
559
+ this.cleanup();
560
+ }, 60000);
561
+ ThrottleGuard_1.cleanupInterval.unref();
562
+ }
563
+ }
564
+ async canActivate(context) {
565
+ const request = context.switchToHttp().getRequest();
566
+ const handler = context.getHandler();
567
+ const options = Reflect.getMetadata(common.METADATA_KEYS.THROTTLE, handler);
568
+ if (!options) {
569
+ return true;
570
+ }
571
+ if (options.skipIf && options.skipIf(context)) {
572
+ return true;
573
+ }
574
+ const ip = this.getClientIp(request);
575
+ const endpointKey = `${context.getClass().name}:${handler.name}`;
576
+ const key = `throttle:${endpointKey}:${ip}`;
577
+ const now = Date.now();
578
+ const ttlMs = options.ttl * 1000;
579
+ let record = ThrottleGuard_1.storage.get(key);
580
+ if (!record || record.expiresAt < now) {
581
+ record = {
582
+ hits: 0,
583
+ expiresAt: now + ttlMs
584
+ };
585
+ }
586
+ record.hits++;
587
+ ThrottleGuard_1.storage.set(key, record);
588
+ if (record.hits > options.limit) {
589
+ const response = context.switchToHttp().getResponse();
590
+ const retryAfter = Math.ceil((record.expiresAt - now) / 1000);
591
+ this.setHeaders(response, options.limit, 0, retryAfter);
592
+ throw new common.CustomHttpError('Too Many Requests', 429, 'rate_limit_exceeded');
593
+ }
594
+ const response = context.switchToHttp().getResponse();
595
+ const remaining = Math.max(0, options.limit - record.hits);
596
+ this.setHeaders(response, options.limit, remaining, options.ttl);
597
+ return true;
598
+ }
599
+ /**
600
+ * Limpia las entradas expiradas para evitar fugas de memoria
601
+ */
602
+ cleanup() {
603
+ const now = Date.now();
604
+ ThrottleGuard_1.storage.forEach((value, key) => {
605
+ if (value.expiresAt < now) {
606
+ ThrottleGuard_1.storage.delete(key);
607
+ }
608
+ });
609
+ }
610
+ getClientIp(request) {
611
+ const headers = request.headers;
612
+ if (headers['cf-connecting-ip'])
613
+ return headers['cf-connecting-ip'];
614
+ if (headers['x-forwarded-for']) {
615
+ return headers['x-forwarded-for'].split(',')[0].trim();
616
+ }
617
+ if (headers['x-real-ip'])
618
+ return headers['x-real-ip'];
619
+ return request.socket?.remoteAddress || request.ip || 'unknown';
620
+ }
621
+ setHeaders(res, limit, remaining, resetOrRetry) {
622
+ if (res && typeof res.setHeader === 'function') {
623
+ res.setHeader('X-RateLimit-Limit', limit);
624
+ res.setHeader('X-RateLimit-Remaining', remaining);
625
+ if (remaining === 0) {
626
+ res.setHeader('Retry-After', resetOrRetry);
627
+ }
628
+ }
629
+ }
630
+ };
631
+ exports.ThrottleGuard.storage = new Map();
632
+ exports.ThrottleGuard.cleanupInterval = null;
633
+ exports.ThrottleGuard = ThrottleGuard_1 = __decorate([
634
+ inversify.injectable(),
635
+ __metadata("design:paramtypes", [])
636
+ ], exports.ThrottleGuard);
637
+
505
638
  exports.AppFactory = AppFactory;
506
639
  exports.GuardsConsumer = GuardsConsumer;
507
640
  exports.HemiaExecutionContext = HemiaExecutionContext;
@@ -3,9 +3,17 @@ import { Reflector } from '../services';
3
3
  /**
4
4
  * Guardia de autorización que verifica roles y permisos definidos en los controladores y métodos.
5
5
  * Utiliza los metadatos definidos con los decoradores @Roles y @Permissions.
6
+ * Soporta wildcards en permisos jerárquicos (ej: account:*, account:email:*)
6
7
  */
7
8
  export declare class AuthGuard implements CanActivate {
8
9
  private reflector;
9
10
  constructor(reflector: Reflector);
10
11
  canActivate(context: ExecutionContext): boolean;
12
+ /**
13
+ * Verifica si el usuario tiene un permiso requerido, soportando wildcards
14
+ * @param requiredPermission Permiso requerido (ej: account:email:update)
15
+ * @param userPermissions Array de permisos del usuario
16
+ * @returns true si el usuario tiene el permiso
17
+ */
18
+ private checkPermission;
11
19
  }
@@ -2,3 +2,5 @@ export * from "./auth.guard";
2
2
  export * from "./guards-consumer";
3
3
  export * from "./api-key.guard";
4
4
  export * from "./jwt.guard";
5
+ export * from "./public-client.guard";
6
+ export * from "./throttle.guard";
@@ -0,0 +1,7 @@
1
+ import { CanActivate, ExecutionContext } from "@hemia/common";
2
+ import { OAuthService } from "@hemia/auth-sdk";
3
+ export declare class PublicClientGuard implements CanActivate {
4
+ private readonly oauthService;
5
+ constructor(oauthService: OAuthService);
6
+ canActivate(context: ExecutionContext): Promise<boolean>;
7
+ }
@@ -0,0 +1,13 @@
1
+ import { CanActivate, ExecutionContext } from "@hemia/common";
2
+ export declare class ThrottleGuard implements CanActivate {
3
+ private static storage;
4
+ private static cleanupInterval;
5
+ constructor();
6
+ canActivate(context: ExecutionContext): Promise<boolean>;
7
+ /**
8
+ * Limpia las entradas expiradas para evitar fugas de memoria
9
+ */
10
+ private cleanup;
11
+ private getClientIp;
12
+ private setHeaders;
13
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hemia/core",
3
- "version": "0.0.10",
3
+ "version": "0.0.13",
4
4
  "description": "Core utilities for Hemia projects",
5
5
  "main": "dist/hemia-core.js",
6
6
  "module": "dist/hemia-core.esm.js",
@@ -18,10 +18,10 @@
18
18
  "@rollup/plugin-commonjs": "^26.0.1",
19
19
  "@rollup/plugin-json": "^6.1.0",
20
20
  "@rollup/plugin-node-resolve": "^15.2.3",
21
- "@hemia/common": "^0.0.14",
21
+ "@hemia/common": "^0.0.15",
22
22
  "@hemia/app-context": "^0.0.6",
23
23
  "@hemia/trace-manager": "^0.0.9",
24
- "@hemia/auth-sdk": "^0.0.13",
24
+ "@hemia/auth-sdk": "^0.0.16",
25
25
  "@types/express": "^5.0.5",
26
26
  "express": "^5.1.0",
27
27
  "inversify": "^7.10.4",