arckode-framework 1.3.2 → 1.4.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.
Files changed (64) hide show
  1. package/adapters/jwt.ts +6 -4
  2. package/adapters/mysql.ts +7 -2
  3. package/adapters/postgres.ts +37 -0
  4. package/adapters/sqlite.ts +7 -1
  5. package/adapters/vendor.d.ts +48 -0
  6. package/cli/analyze/checks.ts +333 -0
  7. package/cli/analyze/index.ts +44 -0
  8. package/cli/analyze/report.ts +107 -0
  9. package/cli/analyze/types.ts +46 -0
  10. package/cli/analyze/utils.ts +36 -0
  11. package/cli/analyze.ts +2 -647
  12. package/cli/commands/db-migrate.ts +213 -89
  13. package/cli/commands/db-seed.ts +97 -32
  14. package/cli/commands/db-utils.ts +192 -0
  15. package/cli/commands/new.ts +175 -0
  16. package/cli/commands/routes.ts +94 -0
  17. package/cli/index.ts +57 -404
  18. package/cli/stubs/module/core.ts +162 -0
  19. package/cli/stubs/module/data.ts +171 -0
  20. package/cli/stubs/module/index.ts +5 -0
  21. package/cli/stubs/module/service.ts +198 -0
  22. package/cli/stubs/module/types.ts +12 -0
  23. package/cli/stubs/module-stub.ts +2 -552
  24. package/kernel/auth.ts +114 -0
  25. package/kernel/cache.ts +37 -0
  26. package/kernel/config.ts +129 -0
  27. package/kernel/container.ts +64 -0
  28. package/kernel/db/orm-migrate.ts +136 -0
  29. package/kernel/db/orm-repository.ts +45 -0
  30. package/kernel/db/orm-utils.ts +93 -0
  31. package/kernel/db/orm.ts +254 -0
  32. package/kernel/db/transactor.ts +17 -0
  33. package/kernel/db/types.ts +72 -0
  34. package/kernel/errors.ts +102 -0
  35. package/kernel/framework.default.ts +41 -0
  36. package/kernel/framework.ts +8 -2144
  37. package/kernel/http/router.ts +131 -0
  38. package/kernel/http/server.ts +303 -0
  39. package/kernel/http/types.ts +56 -0
  40. package/kernel/index.ts +25 -0
  41. package/kernel/logger.ts +50 -0
  42. package/kernel/middlewares.ts +19 -7
  43. package/kernel/modules/create-module.ts +5 -0
  44. package/kernel/modules/system.ts +149 -0
  45. package/kernel/modules/types.ts +46 -0
  46. package/kernel/seeds.ts +48 -0
  47. package/kernel/static.ts +11 -2
  48. package/kernel/testing.ts +8 -3
  49. package/kernel/validator.ts +116 -0
  50. package/modules/events/index.ts +19 -3
  51. package/modules/mail/index.ts +14 -2
  52. package/modules/storage/local-adapter.ts +19 -5
  53. package/modules/ws/index.ts +123 -18
  54. package/package.json +8 -11
  55. package/skills/auth/SKILL.md +36 -220
  56. package/skills/cli/SKILL.md +32 -251
  57. package/skills/config/SKILL.md +30 -239
  58. package/skills/connectors/SKILL.md +32 -295
  59. package/skills/helpers/SKILL.md +26 -195
  60. package/skills/middlewares/SKILL.md +30 -280
  61. package/skills/orm/SKILL.md +42 -349
  62. package/skills/realtime/SKILL.md +22 -297
  63. package/skills/services/SKILL.md +40 -183
  64. package/skills/testing/SKILL.md +34 -266
@@ -22,12 +22,15 @@ export interface WSAdapter {
22
22
  // ─── WebSocket nativo (sin dependencias externas) ───
23
23
  // Usa el servidor HTTP de Node. Para producción usar `ws` o `uWebSockets`.
24
24
 
25
+ /** Máximo payload por frame. Un frame más grande cierra la conexión (DoS protection). */
26
+ const WS_MAX_PAYLOAD = 1 * 1024 * 1024 // 1 MB
27
+
25
28
  export class WSServer implements WSAdapter {
26
29
  private clients = new Map<string, WSClient>()
30
+ private buffers = new Map<string, Buffer>()
27
31
 
28
32
  attach(server: HttpServer): void {
29
- server.on('upgrade', (req, socket, head) => {
30
- // Implementación básica de WebSocket handshake
33
+ server.on('upgrade', (req, socket, _head) => {
31
34
  const key = req.headers['sec-websocket-key']
32
35
  if (!key) { socket.destroy(); return }
33
36
 
@@ -45,21 +48,34 @@ export class WSServer implements WSAdapter {
45
48
  const client: WSClient = {
46
49
  id: clientId,
47
50
  send: (data) => this.sendFrame(socket, JSON.stringify(data)),
48
- close: () => socket.end(),
51
+ close: () => {
52
+ this.sendCloseFrame(socket)
53
+ socket.end()
54
+ },
49
55
  onMessage: () => {},
50
56
  onClose: () => {},
51
57
  }
52
58
 
53
59
  this.clients.set(clientId, client)
60
+ this.buffers.set(clientId, Buffer.alloc(0))
54
61
  console.log(`[WS] Client connected: ${clientId}`)
55
62
 
56
- socket.on('data', (buffer) => {
57
- const message = this.parseFrame(buffer)
58
- if (message) client.onMessage(message)
63
+ socket.on('data', (chunk: Buffer) => {
64
+ let buf = Buffer.concat([this.buffers.get(clientId) ?? Buffer.alloc(0), chunk])
65
+ while (buf.length > 0) {
66
+ const frameLen = this.getFrameLength(buf)
67
+ if (frameLen === -1) break // frame incompleto — esperar más datos
68
+ const frame = buf.slice(0, frameLen)
69
+ buf = buf.slice(frameLen)
70
+ const result = this.parseFrame(frame, socket)
71
+ if (result !== null) client.onMessage(result)
72
+ }
73
+ this.buffers.set(clientId, buf)
59
74
  })
60
75
 
61
76
  socket.on('close', () => {
62
77
  this.clients.delete(clientId)
78
+ this.buffers.delete(clientId)
63
79
  client.onClose()
64
80
  console.log(`[WS] Client disconnected: ${clientId}`)
65
81
  })
@@ -89,42 +105,131 @@ export class WSServer implements WSAdapter {
89
105
  return createHash('sha1').update(key + GUID).digest('base64')
90
106
  }
91
107
 
108
+ private buildFrame(firstByte: number, payload: Buffer): Buffer {
109
+ let headerLen = 2
110
+ if (payload.length > 65535) headerLen += 8
111
+ else if (payload.length > 125) headerLen += 2
112
+
113
+ const frame = Buffer.alloc(headerLen + payload.length)
114
+ frame[0] = firstByte
115
+
116
+ if (payload.length > 65535) {
117
+ frame[1] = 127
118
+ frame.writeBigUInt64BE(BigInt(payload.length), 2)
119
+ } else if (payload.length > 125) {
120
+ frame[1] = 126
121
+ frame.writeUInt16BE(payload.length, 2)
122
+ } else {
123
+ frame[1] = payload.length
124
+ }
125
+
126
+ payload.copy(frame, headerLen)
127
+ return frame
128
+ }
129
+
92
130
  private sendFrame(socket: any, data: string): void {
93
- const buffer = Buffer.from(data, 'utf-8')
94
- const frame = Buffer.alloc(2 + buffer.length)
95
- frame[0] = 0x81 // FIN + texto
96
- frame[1] = buffer.length
97
- buffer.copy(frame, 2)
98
- socket.write(frame)
131
+ const payload = Buffer.from(data, 'utf-8')
132
+ socket.write(this.buildFrame(0x81, payload)) // FIN + opcode text
133
+ }
134
+
135
+ private sendPongFrame(socket: any, payload: Buffer): void {
136
+ socket.write(this.buildFrame(0x8A, payload)) // FIN + opcode pong
99
137
  }
100
138
 
101
- private parseFrame(buffer: Buffer): string | null {
139
+ private sendCloseFrame(socket: any): void {
140
+ socket.write(this.buildFrame(0x88, Buffer.alloc(0))) // FIN + opcode close
141
+ }
142
+
143
+ /**
144
+ * Retorna la longitud total del frame (header + payload) si el buffer contiene
145
+ * al menos un frame completo, o -1 si el frame está incompleto (fragmentación TCP).
146
+ */
147
+ private getFrameLength(buf: Buffer): number {
148
+ if (buf.length < 2) return -1
149
+ const masked = ((buf[1] ?? 0) & 0x80) !== 0
150
+ let payloadLength = (buf[1] ?? 0) & 0x7F
151
+ let offset = 2
152
+
153
+ if (payloadLength === 126) {
154
+ if (buf.length < 4) return -1
155
+ payloadLength = buf.readUInt16BE(2)
156
+ offset = 4
157
+ } else if (payloadLength === 127) {
158
+ if (buf.length < 10) return -1
159
+ payloadLength = Number(buf.readBigUInt64BE(2))
160
+ offset = 10
161
+ }
162
+
163
+ if (masked) offset += 4
164
+ const total = offset + payloadLength
165
+ return buf.length >= total ? total : -1
166
+ }
167
+
168
+ /**
169
+ * Parsea un frame RFC 6455. Retorna el texto del payload o null.
170
+ * - Ping → responde con pong (RFC 6455 §5.5.2)
171
+ * - Close → responde con close y destruye el socket
172
+ * - Oversized → destruye el socket (DoS protection)
173
+ */
174
+ private parseFrame(buffer: Buffer, socket?: any): string | null {
102
175
  if (buffer.length < 2) return null
176
+
103
177
  const opcode = (buffer[0] ?? 0) & 0x0F
104
- if (opcode !== 0x01) return null // solo texto
105
178
  const masked = ((buffer[1] ?? 0) & 0x80) !== 0
106
179
  let payloadLength = (buffer[1] ?? 0) & 0x7F
107
180
  let offset = 2
108
181
 
109
182
  if (payloadLength === 126) {
183
+ if (buffer.length < 4) return null
110
184
  payloadLength = buffer.readUInt16BE(2)
111
185
  offset = 4
112
186
  } else if (payloadLength === 127) {
113
- payloadLength = Number(buffer.readBigUInt64BE(2))
187
+ if (buffer.length < 10) return null
188
+ const bigLen = buffer.readBigUInt64BE(2)
189
+ payloadLength = Number(bigLen)
114
190
  offset = 10
115
191
  }
116
192
 
193
+ // DoS protection: rechazar frames con payload mayor al límite configurado
194
+ if (payloadLength > WS_MAX_PAYLOAD) {
195
+ if (socket) { this.sendCloseFrame(socket); socket.destroy() }
196
+ return null
197
+ }
198
+
199
+ let payload: Buffer
117
200
  if (masked) {
201
+ if (buffer.length < offset + 4) return null
118
202
  const mask = buffer.slice(offset, offset + 4)
119
203
  offset += 4
120
- const payload = Buffer.alloc(payloadLength)
204
+ payload = Buffer.alloc(payloadLength)
121
205
  for (let i = 0; i < payloadLength; i++) {
122
206
  payload[i] = (buffer[offset + i] ?? 0) ^ (mask[i % 4] ?? 0)
123
207
  }
124
- return payload.toString('utf-8')
208
+ } else {
209
+ payload = buffer.slice(offset, offset + payloadLength)
125
210
  }
126
211
 
127
- return buffer.slice(offset, offset + payloadLength).toString('utf-8')
212
+ // RFC 6455 control frames
213
+ if (opcode === 0x08) {
214
+ // Close — responder y cerrar limpiamente
215
+ if (socket) { this.sendCloseFrame(socket); socket.destroy() }
216
+ return null
217
+ }
218
+
219
+ if (opcode === 0x09) {
220
+ // Ping — responder con pong obligatorio (RFC 6455 §5.5.2)
221
+ if (socket) this.sendPongFrame(socket, payload)
222
+ return null
223
+ }
224
+
225
+ if (opcode === 0x0A) {
226
+ // Pong — ignorar (respuesta a nuestro ping)
227
+ return null
228
+ }
229
+
230
+ if (opcode !== 0x01) return null // solo texto (no binario ni fragmentado)
231
+
232
+ return payload.toString('utf-8')
128
233
  }
129
234
  }
130
235
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arckode-framework",
3
- "version": "1.3.2",
3
+ "version": "1.4.0",
4
4
  "description": "AI-first TypeScript/Bun framework. Modular, SOLID, zero magic. The AI reads the composition root and knows everything.",
5
5
  "type": "module",
6
6
  "main": "./kernel/framework.ts",
@@ -19,22 +19,19 @@
19
19
  "./modules/storage": "./modules/storage/index.ts",
20
20
  "./modules/storage/local-adapter": "./modules/storage/local-adapter.ts",
21
21
  "./modules/queue": "./modules/queue/index.ts",
22
- "./testing": "./kernel/testing.ts"
22
+ "./testing": "./kernel/testing.ts",
23
+ "./cli/commands/db-migrate": "./cli/commands/db-migrate.ts",
24
+ "./cli/commands/db-seed": "./cli/commands/db-seed.ts"
23
25
  },
24
26
  "bin": {
25
27
  "arckode": "bin/arckode.js"
26
28
  },
27
29
  "files": [
28
30
  "bin/",
29
- "kernel/framework.ts",
30
- "kernel/middlewares.ts",
31
- "kernel/static.ts",
32
- "kernel/testing.ts",
33
- "adapters/jwt.ts",
34
- "adapters/mysql.ts",
35
- "adapters/postgres.ts",
36
- "adapters/redis-cache.ts",
37
- "adapters/sqlite.ts",
31
+ "kernel/",
32
+ "!kernel/__tests__/",
33
+ "adapters/",
34
+ "!adapters/__tests__/",
38
35
  "modules/events/index.ts",
39
36
  "modules/mail/",
40
37
  "modules/queue/index.ts",
@@ -1,243 +1,59 @@
1
- # SKILL: Arckode Auth — JWT, Roles, Passwords y IDOR
1
+ # Auth — JWT, Roles, Ownership
2
2
 
3
- > Activar cuando: implementar login, registro, proteger rutas, verificar permisos, manejar passwords.
3
+ ## Stack
4
4
 
5
- ---
5
+ `jsonwebtoken` (JWT RS256) + middleware `authMw` + helper `auth.assertOwnership()`.
6
6
 
7
- ## 1. SETUP EN COMPOSITION-ROOT
7
+ ## Flujo
8
8
 
9
- ```ts
10
- import { Auth } from 'arckode-framework'
11
- import { jwtTokenAdapter } from 'arckode-framework/adapters/jwt'
12
-
13
- const auth = new Auth(
14
- jwtTokenAdapter,
15
- config.get('JWT_SECRET'), // REQUERIDO — string largo y aleatorio
16
- logger,
17
- {
18
- accessTokenExpiry: '15m', // default: '15m'
19
- refreshTokenExpiry: '7d', // default: '7d'
20
- adminRole: 'admin', // default: 'admin'
21
- }
22
- )
23
-
24
- // Pasar al System y a los módulos que lo necesiten
25
- const system = new System({ config, logger, orm, router, http, cache, auth })
26
- ```
27
-
28
- **Config requerida en .env:**
29
- ```env
30
- JWT_SECRET=una-cadena-larga-y-aleatoria-de-al-menos-32-chars
31
- JWT_REFRESH_SECRET=otra-cadena-distinta-para-refresh-tokens
32
9
  ```
33
-
34
- ---
35
-
36
- ## 2. REGISTRO Y LOGIN (patrón completo)
37
-
38
- ```ts
39
- // En el módulo de autenticación — service.ts (al root del módulo)
40
- export class AuthService {
41
- constructor(
42
- private repo: RepositoryAdapter<UserDTO>,
43
- private auth: Auth,
44
- private logger: Logger,
45
- ) {}
46
-
47
- async registrar(dto: RegisterDTO): Promise<{ accessToken: string; refreshToken: string }> {
48
- const existente = await this.repo.findOne({ email: dto.email })
49
- if (existente) throw new ConflictError('El email ya está registrado')
50
-
51
- const hashedPassword = await this.auth.hashPassword(dto.password)
52
- const user = await this.repo.create({
53
- email: dto.email,
54
- password: hashedPassword,
55
- role: 'user',
56
- } as Omit<UserDTO, 'id'>)
57
-
58
- return {
59
- accessToken: this.auth.createToken({ id: user.id, role: user.role }),
60
- refreshToken: this.auth.createRefreshToken({ id: user.id, role: user.role }),
61
- }
62
- }
63
-
64
- async login(dto: LoginDTO): Promise<{ accessToken: string; refreshToken: string }> {
65
- const user = await this.repo.findOne({ email: dto.email })
66
- if (!user) throw new AuthError('Credenciales inválidas')
67
-
68
- const ok = await this.auth.comparePassword(dto.password, user.password as string)
69
- if (!ok) throw new AuthError('Credenciales inválidas')
70
-
71
- return {
72
- accessToken: this.auth.createToken({ id: user.id, role: user.role }),
73
- refreshToken: this.auth.createRefreshToken({ id: user.id, role: user.role }),
74
- }
75
- }
76
-
77
- async refresh(refreshToken: string): Promise<{ accessToken: string; refreshToken: string }> {
78
- return this.auth.refresh(refreshToken)
79
- }
80
- }
10
+ Login → JWT (access + refresh) → authMw verify → req.user → assertOwnership()
81
11
  ```
82
12
 
83
- **Crítico:** Siempre usar el mismo mensaje de error para usuario no encontrado y contraseña incorrecta previene user enumeration.
84
-
85
- ---
86
-
87
- ## 3. PROTEGER RUTAS
13
+ ## authMwJWT Verify
88
14
 
89
15
  ```ts
90
- // En index.ts del módulo — al registrar rutas:
91
-
92
- // Requiere cualquier usuario autenticado
93
- router.get('/perfil', [auth.authenticate()], req => controller.perfil(req))
94
-
95
- // Requiere rol específico
96
- router.post('/admin/config', [auth.authenticate('admin')], req => controller.setConfig(req))
97
-
98
- // Múltiples roles permitidos
99
- router.get('/reportes', [auth.authenticate('admin', 'supervisor')], req => controller.reportes(req))
100
-
101
- // Ruta pública (sin middleware)
102
- router.post('/auth/login', req => controller.login(req))
103
- router.post('/auth/registro', req => controller.registrar(req))
16
+ // en router
17
+ router.get('/pedidos/:id', authMw, pedidosController.findById)
18
+ // req.user = { id, role, ... }
104
19
  ```
105
20
 
106
- **El middleware agrega `req.user` con `{ id: string, role: string }` para handlers autenticados.**
107
-
108
- ---
109
-
110
- ## 4. ACCESO AL USUARIO ACTUAL
21
+ ## assertOwnership Anti IDOR
111
22
 
112
23
  ```ts
113
- // En el controller req.user está disponible en rutas protegidas
114
- async perfil(req: HttpRequest): Promise<HttpResponse> {
115
- const user = req.user! // { id: string, role: string }
116
- const data = await this.service.getPerfil(user.id, user)
117
- return { status: 200, body: data }
118
- }
24
+ // LLAMAR después de findById SIEMPRE
25
+ const pedido = await pedidosService.findById(id)
26
+ auth.assertOwnership(pedido, req.user)
27
+ // compara pedido.usuarioId === req.user.id
119
28
  ```
120
29
 
121
- ---
30
+ | Recurso | Campo owner |
31
+ |---------|-------------|
32
+ | Pedido | `usuarioId` |
33
+ | Wallet | `userId` |
34
+ | Profile | `userId` |
122
35
 
123
- ## 5. PREVENCIÓN DE IDOR (obligatorio)
124
-
125
- Todo `findById` que devuelve datos de un usuario específico **DEBE** verificar ownership:
36
+ ## Roles y Permisos
126
37
 
127
38
  ```ts
128
- // PROHIBIDO — cualquier usuario autenticado puede ver datos de otro
129
- async getMiPedido(id: string): Promise<PedidoDTO> {
130
- const pedido = await this.repo.findById(id)
131
- if (!pedido) throw new NotFoundError('Pedido no encontrado')
132
- return pedido // IDOR: usuario B puede ver pedidos de usuario A con ID correcto
39
+ export enum Role {
40
+ ADMIN = 'admin',
41
+ USER = 'user',
42
+ MODERATOR = 'moderator',
133
43
  }
134
-
135
- // ✅ OBLIGATORIO — verificar ownership
136
- async getMiPedido(id: string, currentUser: { id: string; role: string }): Promise<PedidoDTO> {
137
- const pedido = await this.repo.findById(id)
138
- if (!pedido) throw new NotFoundError('Pedido no encontrado')
139
-
140
- // Lanza ForbiddenError si currentUser.id !== pedido.usuarioId
141
- // EXCEPTO si currentUser.role === adminRole (admin bypassa el check)
142
- this.auth.assertOwnership(pedido.usuarioId as string, currentUser.id, currentUser.role)
143
-
144
- return pedido
44
+ export const Permissions: Record<Role, string[]> = {
45
+ [Role.ADMIN]: ['*'],
46
+ [Role.MODERATOR]: ['pedidos:read', 'users:read'],
47
+ [Role.USER]: ['pedidos:read', 'pedidos:write'],
145
48
  }
146
49
  ```
147
50
 
148
- **`arckode analyze` detecta:** `IDOR_RISK` — findById sin assertOwnership.
149
-
150
- ---
151
-
152
- ## 6. ROLES Y AUTORIZACIÓN
153
-
154
- ```ts
155
- // Verificar rol manualmente en service (cuando la lógica es más compleja)
156
- async cancelarPedido(id: string, currentUser: { id: string; role: string }): Promise<void> {
157
- const pedido = await this.repo.findById(id)
158
- if (!pedido) throw new NotFoundError('Pedido no encontrado')
159
-
160
- // Solo el dueño o admin puede cancelar
161
- this.auth.assertOwnership(pedido.usuarioId as string, currentUser.id, currentUser.role)
162
-
163
- // Solo se puede cancelar si está pendiente (lógica de negocio)
164
- if (pedido.estado !== 'pendiente') {
165
- throw new ConflictError('Solo se pueden cancelar pedidos pendientes')
166
- }
167
-
168
- await this.repo.update(id, { estado: 'cancelado' })
169
- }
170
- ```
171
-
172
- ---
173
-
174
- ## 7. HASHING DE PASSWORDS
175
-
176
- ```ts
177
- // Hashear al registrar o cambiar contraseña
178
- const hash = await auth.hashPassword(plainPassword)
179
-
180
- // Verificar al hacer login
181
- const matches = await auth.comparePassword(plainPassword, storedHash)
182
-
183
- // Usar timing-safe comparison internamente (previene timing attacks)
184
- ```
185
-
186
- **Algoritmo:** scrypt + salt aleatorio. No implementar hashing propio.
187
-
188
- ---
189
-
190
- ## 8. REFRESH DE TOKEN
191
-
192
- ```ts
193
- // En el controller de auth
194
- async refreshToken(req: HttpRequest): Promise<HttpResponse> {
195
- const { refreshToken } = req.body as { refreshToken: string }
196
- if (!refreshToken) throw new ValidationError('refreshToken requerido')
197
-
198
- const tokens = await this.service.refresh(refreshToken)
199
- return { status: 200, body: tokens }
200
- }
201
-
202
- // En el service (delegar a auth)
203
- async refresh(refreshToken: string) {
204
- return this.auth.refresh(refreshToken) // lanza AuthError si expiró o es inválido
205
- }
206
- ```
207
-
208
- **Importante:** El refresh token tiene `type: 'refresh'` en el payload. `auth.verifyToken()` (para access tokens) rechaza tokens con `type: 'refresh'` — son tokens distintos por diseño.
209
-
210
- ---
211
-
212
- ## 9. NUNCA EXPONER AL CLIENTE
213
-
214
- ```ts
215
- // ❌ PROHIBIDO en responses HTTP
216
- {
217
- password: user.password, // hash de contraseña
218
- jwtSecret: 'el-secreto', // secreto JWT
219
- internalId: 'uuid-interno', // IDs internos innecesarios
220
- stackTrace: error.stack, // stack trace de error
221
- }
222
-
223
- // ✅ Proyectar solo lo necesario en el DTO de respuesta
224
- export interface UserPublicDTO {
225
- id: string
226
- email: string
227
- role: string
228
- createdAt: string
229
- // SIN password, SIN tokens internos
230
- }
231
- ```
232
-
233
- ---
234
-
235
- ## 10. CHECKLIST AUTH
51
+ ## Errores silenciosos
236
52
 
237
- - [ ] `JWT_SECRET` en .env, no hardcodeado
238
- - [ ] Login usa mismo mensaje para "usuario no existe" y "contraseña incorrecta"
239
- - [ ] Passwords hasheados con `auth.hashPassword()` al guardar
240
- - [ ] `auth.authenticate()` en array de middlewares: `[auth.authenticate('admin')]`
241
- - [ ] Todo `findById` de recurso de usuario tiene `assertOwnership()`
242
- - [ ] El DTO de respuesta nunca incluye password, tokens ni stack traces
243
- - [ ] Refresh token endpoint separado del login
53
+ | Error | Señal | Fix |
54
+ |-------|-------|-----|
55
+ | IDOR: user ve pedido ajeno | Faltó `assertOwnership` | Siempre después de `findById` |
56
+ | Token expirado sin refresh | 401 sin renewal | Refresh token + interceptor |
57
+ | Role check en controller | `if (req.user.role !== 'admin')` | Helper `auth.hasRole(role)` |
58
+ | JWT sin secret fuerte | `'secret123'` | `JWT_SECRET` de 256 bits+ |
59
+ | Refresh token sin rotación | Refresh reutilizable | Rotar en cada refresh |