@hemia/core 0.0.12 → 0.0.14

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, HttpError, 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
  /**
@@ -227,7 +227,9 @@ async function registerRoutes(app, container, controllerIdentifiers, onTraceFini
227
227
  console.error(`Error en ${route.method.toUpperCase()} ${basePath}${route.path}:`, error);
228
228
  const status = error.statusCode || error.status || 500;
229
229
  const message = error.message || 'Internal Server Error';
230
- const errorResponse = ApiResponse.error(message, error.stack, status);
230
+ const errorDetail = error instanceof HttpError ? error.error : undefined;
231
+ const isDev = process.env.NODE_ENV === 'development';
232
+ const errorResponse = ApiResponse.error(message, errorDetail || (isDev ? error.stack : undefined), status);
231
233
  const { status: errStatus, ...errorData } = errorResponse;
232
234
  res.status(errStatus).json(errorData);
233
235
  }
@@ -338,9 +340,9 @@ class AppFactory {
338
340
  const defaultHeaders = {
339
341
  'Access-Control-Allow-Origin': req.headers.origin || '*',
340
342
  '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',
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',
342
344
  'Access-Control-Allow-Credentials': 'true',
343
- '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',
344
346
  };
345
347
  const headers = { ...defaultHeaders, ...(options.corsHeaders || {}) };
346
348
  Object.entries(headers).forEach(([key, value]) => {
@@ -525,4 +527,112 @@ JWTGuard = __decorate([
525
527
  __metadata("design:paramtypes", [AuthService])
526
528
  ], JWTGuard);
527
529
 
528
- export { ApiKeyGuard, AppFactory, AuthGuard, GuardsConsumer, HemiaExecutionContext, JWTGuard, Reflector, ResponseSerializer, registerRoutes };
530
+ let 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
+ PublicClientGuard = __decorate([
549
+ injectable(),
550
+ __param(0, inject(OAuthService)),
551
+ __metadata("design:paramtypes", [OAuthService])
552
+ ], PublicClientGuard);
553
+
554
+ var ThrottleGuard_1;
555
+ let 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(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 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
+ ThrottleGuard.storage = new Map();
632
+ ThrottleGuard.cleanupInterval = null;
633
+ ThrottleGuard = ThrottleGuard_1 = __decorate([
634
+ injectable(),
635
+ __metadata("design:paramtypes", [])
636
+ ], ThrottleGuard);
637
+
638
+ export { ApiKeyGuard, AppFactory, AuthGuard, GuardsConsumer, HemiaExecutionContext, JWTGuard, PublicClientGuard, Reflector, ResponseSerializer, ThrottleGuard, registerRoutes };
@@ -229,7 +229,9 @@ async function registerRoutes(app, container, controllerIdentifiers, onTraceFini
229
229
  console.error(`Error en ${route.method.toUpperCase()} ${basePath}${route.path}:`, error);
230
230
  const status = error.statusCode || error.status || 500;
231
231
  const message = error.message || 'Internal Server Error';
232
- const errorResponse = common.ApiResponse.error(message, error.stack, status);
232
+ const errorDetail = error instanceof common.HttpError ? error.error : undefined;
233
+ const isDev = process.env.NODE_ENV === 'development';
234
+ const errorResponse = common.ApiResponse.error(message, errorDetail || (isDev ? error.stack : undefined), status);
233
235
  const { status: errStatus, ...errorData } = errorResponse;
234
236
  res.status(errStatus).json(errorData);
235
237
  }
@@ -340,9 +342,9 @@ class AppFactory {
340
342
  const defaultHeaders = {
341
343
  'Access-Control-Allow-Origin': req.headers.origin || '*',
342
344
  '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',
345
+ '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
346
  'Access-Control-Allow-Credentials': 'true',
345
- 'Access-Control-Expose-Headers': 'Authorization, X-Correlation-Id, X-Trace-Id, x-session',
347
+ 'Access-Control-Expose-Headers': 'Authorization, X-Correlation-Id, X-Trace-Id, x-session, X-RateLimit-Limit, X-RateLimit-Remaining, Retry-After',
346
348
  };
347
349
  const headers = { ...defaultHeaders, ...(options.corsHeaders || {}) };
348
350
  Object.entries(headers).forEach(([key, value]) => {
@@ -527,6 +529,114 @@ exports.JWTGuard = __decorate([
527
529
  __metadata("design:paramtypes", [authSdk.AuthService])
528
530
  ], exports.JWTGuard);
529
531
 
532
+ exports.PublicClientGuard = class PublicClientGuard {
533
+ constructor(oauthService) {
534
+ this.oauthService = oauthService;
535
+ }
536
+ async canActivate(context) {
537
+ const request = context.switchToHttp().getRequest();
538
+ const clientId = request.headers['x-client-id'] || request.body.client_id;
539
+ if (!clientId) {
540
+ return false;
541
+ }
542
+ const client = await this.oauthService.getByClientId(clientId);
543
+ if (!client || !client.isActive) {
544
+ return false;
545
+ }
546
+ request.oauthClient = client;
547
+ return true;
548
+ }
549
+ };
550
+ exports.PublicClientGuard = __decorate([
551
+ inversify.injectable(),
552
+ __param(0, inversify.inject(authSdk.OAuthService)),
553
+ __metadata("design:paramtypes", [authSdk.OAuthService])
554
+ ], exports.PublicClientGuard);
555
+
556
+ var ThrottleGuard_1;
557
+ exports.ThrottleGuard = ThrottleGuard_1 = class ThrottleGuard {
558
+ constructor() {
559
+ if (!ThrottleGuard_1.cleanupInterval) {
560
+ ThrottleGuard_1.cleanupInterval = setInterval(() => {
561
+ this.cleanup();
562
+ }, 60000);
563
+ ThrottleGuard_1.cleanupInterval.unref();
564
+ }
565
+ }
566
+ async canActivate(context) {
567
+ const request = context.switchToHttp().getRequest();
568
+ const handler = context.getHandler();
569
+ const options = Reflect.getMetadata(common.METADATA_KEYS.THROTTLE, handler);
570
+ if (!options) {
571
+ return true;
572
+ }
573
+ if (options.skipIf && options.skipIf(context)) {
574
+ return true;
575
+ }
576
+ const ip = this.getClientIp(request);
577
+ const endpointKey = `${context.getClass().name}:${handler.name}`;
578
+ const key = `throttle:${endpointKey}:${ip}`;
579
+ const now = Date.now();
580
+ const ttlMs = options.ttl * 1000;
581
+ let record = ThrottleGuard_1.storage.get(key);
582
+ if (!record || record.expiresAt < now) {
583
+ record = {
584
+ hits: 0,
585
+ expiresAt: now + ttlMs
586
+ };
587
+ }
588
+ record.hits++;
589
+ ThrottleGuard_1.storage.set(key, record);
590
+ if (record.hits > options.limit) {
591
+ const response = context.switchToHttp().getResponse();
592
+ const retryAfter = Math.ceil((record.expiresAt - now) / 1000);
593
+ this.setHeaders(response, options.limit, 0, retryAfter);
594
+ throw new common.CustomHttpError('Too Many Requests', 429, 'rate_limit_exceeded');
595
+ }
596
+ const response = context.switchToHttp().getResponse();
597
+ const remaining = Math.max(0, options.limit - record.hits);
598
+ this.setHeaders(response, options.limit, remaining, options.ttl);
599
+ return true;
600
+ }
601
+ /**
602
+ * Limpia las entradas expiradas para evitar fugas de memoria
603
+ */
604
+ cleanup() {
605
+ const now = Date.now();
606
+ ThrottleGuard_1.storage.forEach((value, key) => {
607
+ if (value.expiresAt < now) {
608
+ ThrottleGuard_1.storage.delete(key);
609
+ }
610
+ });
611
+ }
612
+ getClientIp(request) {
613
+ const headers = request.headers;
614
+ if (headers['cf-connecting-ip'])
615
+ return headers['cf-connecting-ip'];
616
+ if (headers['x-forwarded-for']) {
617
+ return headers['x-forwarded-for'].split(',')[0].trim();
618
+ }
619
+ if (headers['x-real-ip'])
620
+ return headers['x-real-ip'];
621
+ return request.socket?.remoteAddress || request.ip || 'unknown';
622
+ }
623
+ setHeaders(res, limit, remaining, resetOrRetry) {
624
+ if (res && typeof res.setHeader === 'function') {
625
+ res.setHeader('X-RateLimit-Limit', limit);
626
+ res.setHeader('X-RateLimit-Remaining', remaining);
627
+ if (remaining === 0) {
628
+ res.setHeader('Retry-After', resetOrRetry);
629
+ }
630
+ }
631
+ }
632
+ };
633
+ exports.ThrottleGuard.storage = new Map();
634
+ exports.ThrottleGuard.cleanupInterval = null;
635
+ exports.ThrottleGuard = ThrottleGuard_1 = __decorate([
636
+ inversify.injectable(),
637
+ __metadata("design:paramtypes", [])
638
+ ], exports.ThrottleGuard);
639
+
530
640
  exports.AppFactory = AppFactory;
531
641
  exports.GuardsConsumer = GuardsConsumer;
532
642
  exports.HemiaExecutionContext = HemiaExecutionContext;
@@ -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.12",
3
+ "version": "0.0.14",
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.15",
21
+ "@hemia/common": "^0.0.16",
22
22
  "@hemia/app-context": "^0.0.6",
23
23
  "@hemia/trace-manager": "^0.0.9",
24
- "@hemia/auth-sdk": "^0.0.14",
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",