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.
- package/adapters/jwt.ts +6 -4
- package/adapters/mysql.ts +7 -2
- package/adapters/postgres.ts +37 -0
- package/adapters/sqlite.ts +7 -1
- package/adapters/vendor.d.ts +48 -0
- package/cli/analyze/checks.ts +333 -0
- package/cli/analyze/index.ts +44 -0
- package/cli/analyze/report.ts +107 -0
- package/cli/analyze/types.ts +46 -0
- package/cli/analyze/utils.ts +36 -0
- package/cli/analyze.ts +2 -647
- package/cli/commands/db-migrate.ts +213 -89
- package/cli/commands/db-seed.ts +97 -32
- package/cli/commands/db-utils.ts +192 -0
- package/cli/commands/new.ts +175 -0
- package/cli/commands/routes.ts +94 -0
- package/cli/index.ts +57 -404
- package/cli/stubs/module/core.ts +162 -0
- package/cli/stubs/module/data.ts +171 -0
- package/cli/stubs/module/index.ts +5 -0
- package/cli/stubs/module/service.ts +198 -0
- package/cli/stubs/module/types.ts +12 -0
- package/cli/stubs/module-stub.ts +2 -552
- package/kernel/auth.ts +114 -0
- package/kernel/cache.ts +37 -0
- package/kernel/config.ts +129 -0
- package/kernel/container.ts +64 -0
- package/kernel/db/orm-migrate.ts +136 -0
- package/kernel/db/orm-repository.ts +45 -0
- package/kernel/db/orm-utils.ts +93 -0
- package/kernel/db/orm.ts +254 -0
- package/kernel/db/transactor.ts +17 -0
- package/kernel/db/types.ts +72 -0
- package/kernel/errors.ts +102 -0
- package/kernel/framework.default.ts +41 -0
- package/kernel/framework.ts +8 -2144
- package/kernel/http/router.ts +131 -0
- package/kernel/http/server.ts +303 -0
- package/kernel/http/types.ts +56 -0
- package/kernel/index.ts +25 -0
- package/kernel/logger.ts +50 -0
- package/kernel/middlewares.ts +19 -7
- package/kernel/modules/create-module.ts +5 -0
- package/kernel/modules/system.ts +149 -0
- package/kernel/modules/types.ts +46 -0
- package/kernel/seeds.ts +48 -0
- package/kernel/static.ts +11 -2
- package/kernel/testing.ts +8 -3
- package/kernel/validator.ts +116 -0
- package/modules/events/index.ts +19 -3
- package/modules/mail/index.ts +14 -2
- package/modules/storage/local-adapter.ts +19 -5
- package/modules/ws/index.ts +123 -18
- package/package.json +8 -11
- package/skills/auth/SKILL.md +36 -220
- package/skills/cli/SKILL.md +32 -251
- package/skills/config/SKILL.md +30 -239
- package/skills/connectors/SKILL.md +32 -295
- package/skills/helpers/SKILL.md +26 -195
- package/skills/middlewares/SKILL.md +30 -280
- package/skills/orm/SKILL.md +42 -349
- package/skills/realtime/SKILL.md +22 -297
- package/skills/services/SKILL.md +40 -183
- package/skills/testing/SKILL.md +34 -266
package/modules/ws/index.ts
CHANGED
|
@@ -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,
|
|
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: () =>
|
|
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', (
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
socket.write(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
208
|
+
} else {
|
|
209
|
+
payload = buffer.slice(offset, offset + payloadLength)
|
|
125
210
|
}
|
|
126
211
|
|
|
127
|
-
|
|
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
|
+
"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/
|
|
30
|
-
"kernel/
|
|
31
|
-
"
|
|
32
|
-
"
|
|
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",
|
package/skills/auth/SKILL.md
CHANGED
|
@@ -1,243 +1,59 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Auth — JWT, Roles, Ownership
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
## Stack
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
`jsonwebtoken` (JWT RS256) + middleware `authMw` + helper `auth.assertOwnership()`.
|
|
6
6
|
|
|
7
|
-
##
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
---
|
|
86
|
-
|
|
87
|
-
## 3. PROTEGER RUTAS
|
|
13
|
+
## authMw — JWT Verify
|
|
88
14
|
|
|
89
15
|
```ts
|
|
90
|
-
//
|
|
91
|
-
|
|
92
|
-
//
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
---
|
|
109
|
-
|
|
110
|
-
## 4. ACCESO AL USUARIO ACTUAL
|
|
21
|
+
## assertOwnership — Anti IDOR
|
|
111
22
|
|
|
112
23
|
```ts
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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 |
|