@miguelmorales13/nestkit 0.5.1 → 0.6.0

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/README.md CHANGED
@@ -628,6 +628,81 @@ necesitás otra lógica de roles al alta (ej. el primer usuario de un tenant nue
628
628
  `BaseAuthService` y sobreescribí `register()`, o llamá `users.create()` directo con los roles que
629
629
  quieras desde tu propio controller.
630
630
 
631
+ ### `auth/oauth` — entrar con Google o Facebook, sin Passport
632
+
633
+ Lo de arriba es email + contraseña. Esto es lo otro: alta y acceso delegados a un proveedor,
634
+ vinculación de cuentas, y sesiones con refresh rotativo de un solo uso guardado hasheado. Son
635
+ módulos separados y se pueden usar los dos, uno, o ninguno.
636
+
637
+ **No usa Passport, a propósito.** Su paso de redirección llama a `res.setHeader()`, que no existe
638
+ en el reply de Fastify, así que el inicio del flujo hay que armarlo a mano igual; y su mapeo de
639
+ perfiles es una tabla de traducción no documentada que reenvía tal cual los nombres de campo que no
640
+ reconoce —así es como `photos.type(large)` llega a la Graph API de Facebook pidiendo los álbumes
641
+ subidos por la persona, que necesitan un permiso que la app no tiene, y el callback muere con un
642
+ 500. El flujo entero son unas cuarenta líneas de `fetch` y cada campo que se manda se ve en
643
+ `oauth-provider.ts`.
644
+
645
+ Un proveedor es un objeto, no una clase que heredar: agregar Apple o GitHub es declarar cuatro URLs
646
+ y una función que mapee su JSON a `OAuthProfile`.
647
+
648
+ ```ts
649
+ import {
650
+ OAuthAuthModule, OAUTH_STORE, REFRESH_TOKEN_STORE,
651
+ googleProvider, facebookProvider, createOAuthController,
652
+ } from '@miguelmorales13/nestkit/auth/oauth';
653
+
654
+ @Module({
655
+ imports: [
656
+ MiStoreModule, // exporta la clase que implementa los puertos
657
+ OAuthAuthModule.forRoot({
658
+ imports: [MiStoreModule],
659
+ providers: [
660
+ { provide: OAUTH_STORE, useExisting: MiStore },
661
+ { provide: REFRESH_TOKEN_STORE, useExisting: MiStore },
662
+ ],
663
+ options: {
664
+ providers: [googleProvider(), facebookProvider()],
665
+ webUrl: process.env.WEB_URL!,
666
+ cookie: { path: '/api/auth' }, // acotá la cookie a las rutas que la usan
667
+ mapUser: (u) => ({ id: u.id, email: u.email, name: u.name }),
668
+ },
669
+ }),
670
+ ],
671
+ controllers: [class AuthController extends createOAuthController() {}],
672
+ })
673
+ export class AuthModule {}
674
+ ```
675
+
676
+ Eso da `GET /auth/:provider`, `GET /auth/:provider/callback`, `POST /auth/refresh`,
677
+ `POST /auth/logout`, `GET /auth/me`, `GET /auth/linked-accounts` y `POST /auth/link-intent`. El
678
+ proveedor va como parámetro de ruta y no una pareja de rutas por proveedor, así que sumar Apple
679
+ después toca solo configuración.
680
+
681
+ **Los dos puertos van separados** (`OAuthStorePort` para identidades, `RefreshTokenStorePort` para
682
+ sesiones) porque responden preguntas distintas y un proyecto puede querer las sesiones en Redis. Si
683
+ van a la misma base, una sola clase implementa los dos y se atan con `useExisting`, como arriba.
684
+
685
+ `revokeIfActive` **tiene que ser atómico** —un UPDATE condicional, no un leer-y-escribir—: esa
686
+ fila es lo único que impide que un refresh token robado se use dos veces.
687
+
688
+ Personalizable sin tocar la librería: nombre y path de la cookie, TTLs, `sameSite`, a dónde vuelve
689
+ el navegador tras entrar / fallar / vincular, si un login cierra las demás sesiones
690
+ (`singleSession`, por defecto sí), qué campos del usuario salen por la API (`mapUser`), el guard de
691
+ las rutas con sesión (`guard`, si la app ya tiene el suyo) y todos los textos que ve el usuario.
692
+
693
+ `syncProfile` es la opción que casi siempre hay que pasar en cuanto la app deja elegir foto de
694
+ perfil: por defecto resincroniza nombre y foto del proveedor en cada login, lo que deshace en
695
+ silencio la elección de la persona la próxima vez que entre.
696
+
697
+ Sin email no hay alta. Facebook puede legítimamente no darlo —cuenta registrada con teléfono, o
698
+ permiso retirado en la pantalla de consentimiento—, y en ese caso se corta con un mensaje que la
699
+ persona puede accionar, no con un fallo interno. `OAuthCallbackFilter` se encarga de que cualquier
700
+ error del callback devuelva al navegador a la pantalla de acceso: sin él la persona se queda
701
+ mirando un JSON en una página en blanco, porque no llegó ahí por una petición de la app sino
702
+ redirigida desde el proveedor.
703
+
704
+ Requiere cookies en el adaptador HTTP: `@fastify/cookie` o `cookie-parser`.
705
+
631
706
  ### `email/resend` y `email/nodemailer` — dos transportes, elegís uno por proyecto
632
707
 
633
708
  Mismo patrón que `database/postgres` vs `database/supabase`: dos módulos independientes, no una
@@ -741,6 +816,8 @@ usa Bunny Stream/TUS para video grande.
741
816
  | `@miguelmorales13/nestkit/whatsapp` | `WhatsAppModule`, `WHATSAPP_CLIENT`, `WhatsAppClient` |
742
817
  | `@miguelmorales13/nestkit/stripe` | `StripeModule`, `STRIPE_CLIENT`, `createStripeWebhookController` |
743
818
  | `@miguelmorales13/nestkit/auth` | `AuthUserPort`, `BaseAuthService`, `createAuthController`, `TokenService`, `JwtAuthGuard`, `RolesGuard`, `RequireTenantGuard`, `CurrentUser`, `CurrentTenant`, `Roles` |
819
+ | `@miguelmorales13/nestkit/auth/oauth` | `OAuthAuthModule`, `OAuthAuthService`, `createOAuthController`, `googleProvider`, `facebookProvider`, `OAuthStorePort`, `RefreshTokenStorePort` |
820
+ | `@miguelmorales13/nestkit/umami` | `UmamiModule`, `UmamiService` — lectura de la API de analítica |
744
821
  | `@miguelmorales13/nestkit/storage` | `StorageModule`, `STORAGE`, `StoragePort`, adaptadores `LocalStorage`/`S3Storage`/`BunnyStorage` |
745
822
  | `@miguelmorales13/nestkit/email/resend` | `ResendModule`, `RESEND_CLIENT` |
746
823
  | `@miguelmorales13/nestkit/email/nodemailer` | `NodemailerModule`, `NODEMAILER_TRANSPORT` |
@@ -749,5 +826,7 @@ usa Bunny Stream/TUS para video grande.
749
826
 
750
827
  ## Estado del paquete
751
828
 
752
- `0.5.1`. Sin adaptador Mongo real. Sin tests unitarios propios — es un paquete nuevo; la primera
753
- integración real (ej. en `hr-pymes-saas` o `cuota`) es la que ejercita el código de verdad.
829
+ `0.6.0`. Sin adaptador Mongo real. Sin tests unitarios propios.
830
+
831
+ `auth/oauth` y `umami` sí tienen un consumidor real: **nutrimx** corre su acceso con Google y
832
+ Facebook sobre este módulo en producción. El resto sigue sin ejercitarse de verdad.
@@ -1,4 +1,9 @@
1
- "use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } } function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; }
1
+ "use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } } function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; }
2
+
3
+
4
+
5
+ var _chunkNVCI3CQIcjs = require('../chunk-NVCI3CQI.cjs');
6
+
2
7
 
3
8
 
4
9
 
@@ -19,65 +24,8 @@ function verifyPassword(plain, passwordHash) {
19
24
  return _bcryptjs.compare.call(void 0, plain, passwordHash);
20
25
  }
21
26
 
22
- // src/auth/token.service.ts
23
- var _jsonwebtoken = require('jsonwebtoken'); var _jsonwebtoken2 = _interopRequireDefault(_jsonwebtoken);
24
- function requireEnv(name) {
25
- const value = process.env[name];
26
- if (!value) {
27
- throw new Error(`TokenService: ${name} environment variable is not set`);
28
- }
29
- return value;
30
- }
31
- var TokenService = class {
32
- signAccessToken(payload) {
33
- return _jsonwebtoken2.default.sign(payload, requireEnv("JWT_ACCESS_SECRET"), {
34
- expiresIn: _nullishCoalesce(process.env.JWT_ACCESS_EXPIRES_IN, () => ( "15m"))
35
- });
36
- }
37
- signRefreshToken(payload) {
38
- return _jsonwebtoken2.default.sign(payload, requireEnv("JWT_REFRESH_SECRET"), {
39
- expiresIn: _nullishCoalesce(process.env.JWT_REFRESH_EXPIRES_IN, () => ( "30d"))
40
- });
41
- }
42
- verifyAccessToken(token) {
43
- try {
44
- return _jsonwebtoken2.default.verify(token, requireEnv("JWT_ACCESS_SECRET"));
45
- } catch (e) {
46
- throw new (0, _chunkFDNGAYTZcjs.UnauthorizedAppException)("Invalid or expired access token");
47
- }
48
- }
49
- verifyRefreshToken(token) {
50
- try {
51
- return _jsonwebtoken2.default.verify(token, requireEnv("JWT_REFRESH_SECRET"));
52
- } catch (e2) {
53
- throw new (0, _chunkFDNGAYTZcjs.UnauthorizedAppException)("Invalid or expired refresh token");
54
- }
55
- }
56
- };
57
-
58
- // src/auth/jwt-auth.guard.ts
59
- var _common = require('@nestjs/common');
60
- var JwtAuthGuard = class {
61
- constructor() {
62
- this.tokens = new TokenService();
63
- }
64
- canActivate(context) {
65
- const req = context.switchToHttp().getRequest();
66
- const header = req.headers.authorization;
67
- const token = _optionalChain([header, 'optionalAccess', _2 => _2.startsWith, 'call', _3 => _3("Bearer ")]) ? header.slice("Bearer ".length) : void 0;
68
- if (!token) {
69
- throw new (0, _chunkFDNGAYTZcjs.UnauthorizedAppException)("Missing Authorization bearer token");
70
- }
71
- req.user = this.tokens.verifyAccessToken(token);
72
- return true;
73
- }
74
- };
75
- JwtAuthGuard = exports.JwtAuthGuard = _chunk2REOCMUDcjs.__decorateClass.call(void 0, [
76
- _common.Injectable.call(void 0, )
77
- ], JwtAuthGuard);
78
-
79
27
  // src/auth/roles.decorator.ts
80
-
28
+ var _common = require('@nestjs/common');
81
29
  var ROLES_KEY = "nestkit:roles";
82
30
  var Roles = (...roles) => _common.SetMetadata.call(void 0, ROLES_KEY, roles);
83
31
 
@@ -95,7 +43,7 @@ var RolesGuard = class {
95
43
  ]);
96
44
  if (!required || required.length === 0) return true;
97
45
  const req = context.switchToHttp().getRequest();
98
- const has = _nullishCoalesce(_optionalChain([req, 'access', _4 => _4.user, 'optionalAccess', _5 => _5.roles, 'access', _6 => _6.some, 'call', _7 => _7((role) => required.includes(role))]), () => ( false));
46
+ const has = _nullishCoalesce(_optionalChain([req, 'access', _2 => _2.user, 'optionalAccess', _3 => _3.roles, 'access', _4 => _4.some, 'call', _5 => _5((role) => required.includes(role))]), () => ( false));
99
47
  if (!has) {
100
48
  throw new (0, _chunkFDNGAYTZcjs.ForbiddenAppException)("Insufficient role");
101
49
  }
@@ -106,16 +54,10 @@ RolesGuard = exports.RolesGuard = _chunk2REOCMUDcjs.__decorateClass.call(void 0,
106
54
  _common.Injectable.call(void 0, )
107
55
  ], RolesGuard);
108
56
 
109
- // src/auth/current-user.decorator.ts
110
-
111
- var CurrentUser = _common.createParamDecorator.call(void 0,
112
- (_, ctx) => ctx.switchToHttp().getRequest().user
113
- );
114
-
115
57
  // src/auth/current-tenant.decorator.ts
116
58
 
117
59
  var CurrentTenant = _common.createParamDecorator.call(void 0,
118
- (_, ctx) => _optionalChain([ctx, 'access', _8 => _8.switchToHttp, 'call', _9 => _9(), 'access', _10 => _10.getRequest, 'call', _11 => _11(), 'access', _12 => _12.user, 'optionalAccess', _13 => _13.tenantId])
60
+ (_, ctx) => _optionalChain([ctx, 'access', _6 => _6.switchToHttp, 'call', _7 => _7(), 'access', _8 => _8.getRequest, 'call', _9 => _9(), 'access', _10 => _10.user, 'optionalAccess', _11 => _11.tenantId])
119
61
  );
120
62
 
121
63
  // src/auth/require-tenant.guard.ts
@@ -123,7 +65,7 @@ var CurrentTenant = _common.createParamDecorator.call(void 0,
123
65
  var RequireTenantGuard = class {
124
66
  canActivate(context) {
125
67
  const req = context.switchToHttp().getRequest();
126
- if (!_optionalChain([req, 'access', _14 => _14.user, 'optionalAccess', _15 => _15.tenantId])) {
68
+ if (!_optionalChain([req, 'access', _12 => _12.user, 'optionalAccess', _13 => _13.tenantId])) {
127
69
  throw new (0, _chunkFDNGAYTZcjs.ForbiddenAppException)("This action requires a company account");
128
70
  }
129
71
  return true;
@@ -164,7 +106,7 @@ _chunk2REOCMUDcjs.__decorateClass.call(void 0, [
164
106
 
165
107
  // src/auth/auth.service.ts
166
108
  var BaseAuthService = class {
167
- constructor(users, tokens = new TokenService()) {
109
+ constructor(users, tokens = new (0, _chunkNVCI3CQIcjs.TokenService)()) {
168
110
  this.users = users;
169
111
  this.tokens = tokens;
170
112
  }
@@ -262,4 +204,4 @@ function createAuthController(serviceToken, options = {}) {
262
204
 
263
205
 
264
206
 
265
- exports.BaseAuthService = BaseAuthService; exports.CurrentTenant = CurrentTenant; exports.CurrentUser = CurrentUser; exports.JwtAuthGuard = JwtAuthGuard; exports.LoginDto = LoginDto; exports.ROLES_KEY = ROLES_KEY; exports.RefreshDto = RefreshDto; exports.RegisterDto = RegisterDto; exports.RequireTenantGuard = RequireTenantGuard; exports.Roles = Roles; exports.RolesGuard = RolesGuard; exports.TokenService = TokenService; exports.createAuthController = createAuthController; exports.hashPassword = hashPassword; exports.verifyPassword = verifyPassword;
207
+ exports.BaseAuthService = BaseAuthService; exports.CurrentTenant = CurrentTenant; exports.CurrentUser = _chunkNVCI3CQIcjs.CurrentUser; exports.JwtAuthGuard = _chunkNVCI3CQIcjs.JwtAuthGuard; exports.LoginDto = LoginDto; exports.ROLES_KEY = ROLES_KEY; exports.RefreshDto = RefreshDto; exports.RegisterDto = RegisterDto; exports.RequireTenantGuard = RequireTenantGuard; exports.Roles = Roles; exports.RolesGuard = RolesGuard; exports.TokenService = _chunkNVCI3CQIcjs.TokenService; exports.createAuthController = createAuthController; exports.hashPassword = hashPassword; exports.verifyPassword = verifyPassword;
@@ -1,3 +1,8 @@
1
+ import {
2
+ CurrentUser,
3
+ JwtAuthGuard,
4
+ TokenService
5
+ } from "../chunk-A3B2EY4V.js";
1
6
  import {
2
7
  ConflictAppException,
3
8
  ForbiddenAppException,
@@ -19,70 +24,13 @@ function verifyPassword(plain, passwordHash) {
19
24
  return compare(plain, passwordHash);
20
25
  }
21
26
 
22
- // src/auth/token.service.ts
23
- import jwt from "jsonwebtoken";
24
- function requireEnv(name) {
25
- const value = process.env[name];
26
- if (!value) {
27
- throw new Error(`TokenService: ${name} environment variable is not set`);
28
- }
29
- return value;
30
- }
31
- var TokenService = class {
32
- signAccessToken(payload) {
33
- return jwt.sign(payload, requireEnv("JWT_ACCESS_SECRET"), {
34
- expiresIn: process.env.JWT_ACCESS_EXPIRES_IN ?? "15m"
35
- });
36
- }
37
- signRefreshToken(payload) {
38
- return jwt.sign(payload, requireEnv("JWT_REFRESH_SECRET"), {
39
- expiresIn: process.env.JWT_REFRESH_EXPIRES_IN ?? "30d"
40
- });
41
- }
42
- verifyAccessToken(token) {
43
- try {
44
- return jwt.verify(token, requireEnv("JWT_ACCESS_SECRET"));
45
- } catch {
46
- throw new UnauthorizedAppException("Invalid or expired access token");
47
- }
48
- }
49
- verifyRefreshToken(token) {
50
- try {
51
- return jwt.verify(token, requireEnv("JWT_REFRESH_SECRET"));
52
- } catch {
53
- throw new UnauthorizedAppException("Invalid or expired refresh token");
54
- }
55
- }
56
- };
57
-
58
- // src/auth/jwt-auth.guard.ts
59
- import { Injectable } from "@nestjs/common";
60
- var JwtAuthGuard = class {
61
- constructor() {
62
- this.tokens = new TokenService();
63
- }
64
- canActivate(context) {
65
- const req = context.switchToHttp().getRequest();
66
- const header = req.headers.authorization;
67
- const token = header?.startsWith("Bearer ") ? header.slice("Bearer ".length) : void 0;
68
- if (!token) {
69
- throw new UnauthorizedAppException("Missing Authorization bearer token");
70
- }
71
- req.user = this.tokens.verifyAccessToken(token);
72
- return true;
73
- }
74
- };
75
- JwtAuthGuard = __decorateClass([
76
- Injectable()
77
- ], JwtAuthGuard);
78
-
79
27
  // src/auth/roles.decorator.ts
80
28
  import { SetMetadata } from "@nestjs/common";
81
29
  var ROLES_KEY = "nestkit:roles";
82
30
  var Roles = (...roles) => SetMetadata(ROLES_KEY, roles);
83
31
 
84
32
  // src/auth/roles.guard.ts
85
- import { Injectable as Injectable2 } from "@nestjs/common";
33
+ import { Injectable } from "@nestjs/common";
86
34
  import "@nestjs/core";
87
35
  var RolesGuard = class {
88
36
  constructor(reflector) {
@@ -103,23 +51,17 @@ var RolesGuard = class {
103
51
  }
104
52
  };
105
53
  RolesGuard = __decorateClass([
106
- Injectable2()
54
+ Injectable()
107
55
  ], RolesGuard);
108
56
 
109
- // src/auth/current-user.decorator.ts
110
- import { createParamDecorator } from "@nestjs/common";
111
- var CurrentUser = createParamDecorator(
112
- (_, ctx) => ctx.switchToHttp().getRequest().user
113
- );
114
-
115
57
  // src/auth/current-tenant.decorator.ts
116
- import { createParamDecorator as createParamDecorator2 } from "@nestjs/common";
117
- var CurrentTenant = createParamDecorator2(
58
+ import { createParamDecorator } from "@nestjs/common";
59
+ var CurrentTenant = createParamDecorator(
118
60
  (_, ctx) => ctx.switchToHttp().getRequest().user?.tenantId
119
61
  );
120
62
 
121
63
  // src/auth/require-tenant.guard.ts
122
- import { Injectable as Injectable3 } from "@nestjs/common";
64
+ import { Injectable as Injectable2 } from "@nestjs/common";
123
65
  var RequireTenantGuard = class {
124
66
  canActivate(context) {
125
67
  const req = context.switchToHttp().getRequest();
@@ -130,7 +72,7 @@ var RequireTenantGuard = class {
130
72
  }
131
73
  };
132
74
  RequireTenantGuard = __decorateClass([
133
- Injectable3()
75
+ Injectable2()
134
76
  ], RequireTenantGuard);
135
77
 
136
78
  // src/auth/auth.dto.ts
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The little the OAuth controller needs from the HTTP layer, described
3
+ * structurally so the module works on Fastify and Express without depending
4
+ * on either.
5
+ *
6
+ * Cookie support is not built in on either adapter — the app has to register
7
+ * `@fastify/cookie` or `cookie-parser`. Fastify names the methods
8
+ * `setCookie`/`clearCookie`, Express names them `cookie`/`clearCookie`; the
9
+ * helpers below paper over exactly that difference and nothing else.
10
+ */
11
+ export interface CookieOptions {
12
+ httpOnly?: boolean;
13
+ secure?: boolean;
14
+ sameSite?: 'lax' | 'strict' | 'none';
15
+ path?: string;
16
+ expires?: Date;
17
+ }
18
+ export interface ReplyLike {
19
+ redirect: ((url: string, statusCode?: number) => unknown) & ((...args: never[]) => unknown);
20
+ setCookie?(name: string, value: string, options?: CookieOptions): unknown;
21
+ cookie?(name: string, value: string, options?: CookieOptions): unknown;
22
+ clearCookie?(name: string, options?: CookieOptions): unknown;
23
+ }
24
+ export interface RequestLike {
25
+ cookies?: Record<string, string | undefined>;
26
+ query?: Record<string, unknown>;
27
+ }
28
+ export declare function setCookie(reply: ReplyLike, name: string, value: string, options: CookieOptions): void;
29
+ export declare function clearCookie(reply: ReplyLike, name: string, options: CookieOptions): void;
30
+ /**
31
+ * Redirects with an explicit 302.
32
+ *
33
+ * Not optional: Nest's Fastify adapter answers `reply.redirect(url)` with a
34
+ * 200 carrying a Location header, which no browser follows — the sign-in
35
+ * simply stops on a blank page. Express ignores the second argument when the
36
+ * first is a URL, so the same call is correct on both.
37
+ */
38
+ export declare function redirect(reply: ReplyLike, url: string): void;