@hemia/core 0.0.12 → 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]) => {
@@ -525,4 +525,112 @@ JWTGuard = __decorate([
525
525
  __metadata("design:paramtypes", [AuthService])
526
526
  ], JWTGuard);
527
527
 
528
- 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]) => {
@@ -527,6 +527,114 @@ exports.JWTGuard = __decorate([
527
527
  __metadata("design:paramtypes", [authSdk.AuthService])
528
528
  ], exports.JWTGuard);
529
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
+
530
638
  exports.AppFactory = AppFactory;
531
639
  exports.GuardsConsumer = GuardsConsumer;
532
640
  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.13",
4
4
  "description": "Core utilities for Hemia projects",
5
5
  "main": "dist/hemia-core.js",
6
6
  "module": "dist/hemia-core.esm.js",
@@ -21,7 +21,7 @@
21
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.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",