arckode-framework 1.3.1 → 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 (65) 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/claude-md-stub.ts +21 -8
  19. package/cli/stubs/module/core.ts +162 -0
  20. package/cli/stubs/module/data.ts +171 -0
  21. package/cli/stubs/module/index.ts +5 -0
  22. package/cli/stubs/module/service.ts +198 -0
  23. package/cli/stubs/module/types.ts +12 -0
  24. package/cli/stubs/module-stub.ts +2 -552
  25. package/kernel/auth.ts +114 -0
  26. package/kernel/cache.ts +37 -0
  27. package/kernel/config.ts +129 -0
  28. package/kernel/container.ts +64 -0
  29. package/kernel/db/orm-migrate.ts +136 -0
  30. package/kernel/db/orm-repository.ts +45 -0
  31. package/kernel/db/orm-utils.ts +93 -0
  32. package/kernel/db/orm.ts +254 -0
  33. package/kernel/db/transactor.ts +17 -0
  34. package/kernel/db/types.ts +72 -0
  35. package/kernel/errors.ts +102 -0
  36. package/kernel/framework.default.ts +41 -0
  37. package/kernel/framework.ts +8 -2144
  38. package/kernel/http/router.ts +131 -0
  39. package/kernel/http/server.ts +303 -0
  40. package/kernel/http/types.ts +56 -0
  41. package/kernel/index.ts +25 -0
  42. package/kernel/logger.ts +50 -0
  43. package/kernel/middlewares.ts +38 -21
  44. package/kernel/modules/create-module.ts +5 -0
  45. package/kernel/modules/system.ts +149 -0
  46. package/kernel/modules/types.ts +46 -0
  47. package/kernel/seeds.ts +48 -0
  48. package/kernel/static.ts +11 -2
  49. package/kernel/testing.ts +8 -3
  50. package/kernel/validator.ts +116 -0
  51. package/modules/events/index.ts +19 -3
  52. package/modules/mail/index.ts +14 -2
  53. package/modules/storage/local-adapter.ts +19 -5
  54. package/modules/ws/index.ts +123 -18
  55. package/package.json +8 -11
  56. package/skills/auth/SKILL.md +36 -220
  57. package/skills/cli/SKILL.md +32 -251
  58. package/skills/config/SKILL.md +30 -239
  59. package/skills/connectors/SKILL.md +32 -295
  60. package/skills/helpers/SKILL.md +26 -195
  61. package/skills/middlewares/SKILL.md +30 -267
  62. package/skills/orm/SKILL.md +42 -349
  63. package/skills/realtime/SKILL.md +22 -297
  64. package/skills/services/SKILL.md +40 -183
  65. package/skills/testing/SKILL.md +34 -266
@@ -1,552 +1,2 @@
1
- // cli/stubs/module-stub.ts — Plantillas para generar módulos completos
2
- // Usadas por el generador. Cada stub produce código 100% correcto.
3
-
4
- export interface ModuleStubParams {
5
- name: string // PascalCase: Producto
6
- module: string // kebab-case: productos
7
- fields: { name: string; type: 'string' | 'number' | 'boolean' | 'date'; required: boolean; default?: unknown }[]
8
- relations?: { type: 'hasMany' | 'belongsTo'; model: string; foreignKey: string }[]
9
- softDelete?: boolean
10
- }
11
-
12
- export function indexStub(p: ModuleStubParams): string {
13
- const actions = ['list', 'getById', 'create', 'update', 'delete']
14
- const events = [`on${p.name}Created`, `on${p.name}Updated`, `on${p.name}Deleted`]
15
-
16
- return `// ${p.module}/index.ts — PUERTA PÚBLICA
17
- // Solo esto es visible para otros módulos y conectores.
18
- // ⚠ REGLA: Append-only. No sacar ni modificar exports existentes.
19
-
20
- import { createModule, OrmRepository } from 'arckode-framework'
21
- import { register${p.name}Models } from './model'
22
- import { ${p.name}Service } from './service'
23
- import { ${p.name}Controller } from './controller'
24
- import type { ${p.name}DTO } from './types'
25
-
26
- export { ${p.name}Service }
27
- export type { ${p.name}DTO, Create${p.name}DTO, Update${p.name}DTO, ${p.name}Query, ${p.name}Paginated } from './types'
28
- export type { ${p.name}Sockets } from './sockets'
29
- export { ${p.name}Validator, Create${p.name}Schema, Update${p.name}Schema } from './validators/schema'
30
-
31
- export function ${p.name}Module() {
32
- return createModule({
33
- name: '${p.module}',
34
- version: '1.0.0',
35
- description: 'Módulo de ${p.module}',
36
-
37
- contract: {
38
- name: '${p.module}',
39
- version: '1.0.0',
40
- description: 'Módulo de ${p.module}',
41
- actions: ${JSON.stringify(actions)},
42
- events: ${JSON.stringify(events)},
43
- tables: ['${p.module}'],
44
- dependencies: [],
45
- rules: ['No importar de otros módulos'],
46
- },
47
-
48
- create({ logger, orm, cache, router, auth }) {
49
- // Registrar modelo(s) — delegado a model.ts
50
- register${p.name}Models(orm)
51
-
52
- const repo = new OrmRepository<${p.name}DTO>(orm, '${p.name}')
53
- const log = logger.child('${p.module}')
54
- const service = new ${p.name}Service(repo, log, cache)
55
- const controller = new ${p.name}Controller(service, log)
56
-
57
- // Rutas públicas por defecto — agregar [auth.authenticate()] para proteger
58
- router.get('/${p.module}', (req) => controller.index(req))
59
- router.get('/${p.module}/:id', (req) => controller.show(req))
60
- router.post('/${p.module}', (req) => controller.store(req))
61
- router.put('/${p.module}/:id', (req) => controller.update(req))
62
- router.delete('/${p.module}/:id', (req) => controller.destroy(req))
63
-
64
- log.info('Módulo ${p.module} listo')
65
- return service
66
- },
67
- })
68
- }
69
- `
70
- }
71
-
72
- // ─── model.ts — Definición de DB (schema) ───
73
- // Separado de types.ts porque conceptualmente describe la PERSISTENCIA (la DB),
74
- // no el contrato de TypeScript (DTOs). Cambia con migraciones, no con la API.
75
- export function modelStub(p: ModuleStubParams): string {
76
- const modelFields = p.fields
77
- .filter(f => !['id', 'createdAt', 'updatedAt', 'deletedAt'].includes(f.name))
78
- .map(f => {
79
- const parts = [` ${f.name}: { type: '${f.type}'`]
80
- if (f.required) parts.push(`, required: true`)
81
- if (f.default !== undefined) parts.push(`, default: ${JSON.stringify(f.default)}`)
82
- parts.push(` }`)
83
- return parts.join('')
84
- })
85
- .join(',\n')
86
-
87
- return `// ${p.module}/model.ts — Schema de base de datos
88
- // Responsabilidad ÚNICA: describir la estructura física de la tabla.
89
- // Importado por index.ts → orm.define() para registrar el modelo.
90
- //
91
- // Si el módulo tiene 3+ tablas, agregá registerModels(orm) acá y delegá desde index.ts.
92
-
93
- import type { ModelDefinition, ORM } from 'arckode-framework'
94
-
95
- export const ${p.name}Model: ModelDefinition = {
96
- table: '${p.module}',
97
- fields: {
98
- ${modelFields}
99
- },
100
- timestamps: true,
101
- ${p.softDelete ? ' softDelete: true,' : ''}
102
- }
103
-
104
- // Helper opcional — usalo si el módulo registra 3+ tablas
105
- // (mantiene index.ts limpio, enfocado en wiring)
106
- export function register${p.name}Models(orm: ORM): void {
107
- orm.define('${p.name}', ${p.name}Model)
108
- }
109
- `
110
- }
111
-
112
- export function typesStub(p: ModuleStubParams): string {
113
- const fields = p.fields.map(f => ` ${f.name}${f.required ? '' : '?'}: ${mapTS(f.type)}`).join('\n')
114
- const filterFields = p.fields.map(f => ` ${f.name}?: ${mapTS(f.type)}`).join('\n')
115
-
116
- return `// ${p.module}/types.ts — DTOs y tipos de queries
117
- // Responsabilidad ÚNICA: contrato TypeScript del módulo (cómo se ven los datos).
118
- // El schema de DB vive en ./model.ts — son conceptos distintos.
119
-
120
- export interface ${p.name}DTO {
121
- id: string
122
- ${fields}
123
- createdAt: string
124
- updatedAt: string
125
- ${p.softDelete ? ' deletedAt: string | null' : ''}
126
- }
127
-
128
- export interface Create${p.name}DTO {
129
- ${p.fields.filter(f => !f.name.match(/^(id|createdAt|updatedAt|deletedAt)$/)).map(f => ` ${f.name}${f.required ? '' : '?'}: ${mapTS(f.type)}`).join('\n')}
130
- }
131
-
132
- export interface Update${p.name}DTO {
133
- ${p.fields.filter(f => !f.name.match(/^(id|createdAt|updatedAt|deletedAt)$/)).map(f => ` ${f.name}?: ${mapTS(f.type)}`).join('\n')}
134
- }
135
-
136
- // ─── Consultas y paginación ────────────────────────────
137
- export interface ${p.name}Query {
138
- ${filterFields}
139
- page?: number
140
- limit?: number
141
- sortBy?: string
142
- sortOrder?: 'asc' | 'desc'
143
- search?: string
144
- }
145
-
146
- export interface ${p.name}Paginated {
147
- data: ${p.name}DTO[]
148
- pagination: {
149
- page: number
150
- limit: number
151
- total: number
152
- totalPages: number
153
- hasNext: boolean
154
- hasPrev: boolean
155
- }
156
- }
157
- `
158
- }
159
-
160
- export function socketsStub(p: ModuleStubParams): string {
161
- return `// ${p.module}/sockets.ts — Hooks OPCIONALES hacia otros módulos
162
- // Los sockets son opcionales. El módulo funciona sin ellos.
163
- // Un conector puede pasar sockets para reaccionar a eventos del módulo.
164
-
165
- import type { ${p.name}DTO } from './types'
166
-
167
- export interface ${p.name}Sockets {
168
- on${p.name}Created?: (data: ${p.name}DTO) => Promise<void>
169
- on${p.name}Updated?: (data: ${p.name}DTO) => Promise<void>
170
- on${p.name}Deleted?: (id: string) => Promise<void>
171
- }
172
- `
173
- }
174
-
175
- export function serviceStub(p: ModuleStubParams): string {
176
- const hasSearch = p.fields.some(f => f.type === 'string')
177
-
178
- return `// ${p.module}/service.ts — Facade pública del módulo
179
- // Responsabilidad ÚNICA: casos de uso del módulo.
180
- // NO sabe de HTTP. NO importa de otros módulos.
181
- // Recibe dependencias por constructor (Dependency Inversion).
182
- //
183
- // Si este archivo supera 200 líneas → extraer casos de uso a ./usecases/{caso}.ts
184
- // y dejar acá solo el orquestador que delega.
185
- //
186
- // IMPORTANTE: depende de RepositoryAdapter<${p.name}DTO>, no del ORM directamente.
187
- // Esto permite swapear SQL → MongoDB → Prisma en composition-root.ts sin tocar este archivo.
188
-
189
- import type { RepositoryAdapter, Logger, CacheAdapter } from 'arckode-framework'
190
- import { NotFoundError } from 'arckode-framework'
191
- import type { ${p.name}DTO, Create${p.name}DTO, Update${p.name}DTO, ${p.name}Query, ${p.name}Paginated } from './types'
192
- import type { ${p.name}Sockets } from './sockets'
193
-
194
- export class ${p.name}Service {
195
- private sockets: ${p.name}Sockets = {}
196
-
197
- constructor(
198
- private readonly repo: RepositoryAdapter<${p.name}DTO>,
199
- private readonly logger: Logger,
200
- private readonly cache: CacheAdapter,
201
- ) {}
202
-
203
- // ACUMULA handlers — nunca pisa el anterior.
204
- // Si dos conectores registran el mismo evento, ambos corren en cadena (secuencial).
205
- // Para ejecución paralela independiente → usar EventBus en composition-root.ts.
206
- setSockets(s: Partial<${p.name}Sockets>): void {
207
- const next = s as Record<string, any>
208
- const cur = this.sockets as Record<string, any>
209
- for (const key of Object.keys(next)) {
210
- const h = next[key]
211
- if (!h) continue
212
- const prev = cur[key]
213
- cur[key] = prev ? async (...a: any[]) => { await prev(...a); await h(...a) } : h
214
- }
215
- }
216
-
217
- async list(query?: ${p.name}Query): Promise<${p.name}Paginated> {
218
- this.logger.info('Listando ${p.module}', { query })
219
-
220
- const page = query?.page ?? 1
221
- const limit = query?.limit ?? 20
222
- const offset = (page - 1) * limit
223
- const filters: Record<string, unknown> = {}
224
-
225
- ${p.fields.filter(f => f.name !== 'id').slice(0, 3).map(f => `if (query?.${f.name} !== undefined) filters.${f.name} = query.${f.name}`).join('\n ')}
226
-
227
- const result = await this.repo.paginate(filters, { limit, offset })
228
-
229
- return {
230
- data: result.data,
231
- pagination: {
232
- page,
233
- limit,
234
- total: result.total,
235
- totalPages: result.pages,
236
- hasNext: offset + limit < result.total,
237
- hasPrev: page > 1,
238
- },
239
- }
240
- }
241
-
242
- async getById(id: string): Promise<${p.name}DTO> {
243
- this.logger.info('Obteniendo ${p.module}', { id })
244
- const item = await this.repo.findById(id)
245
- if (!item) throw new NotFoundError('${p.name} no encontrado')
246
- // IDOR: si el recurso tiene userId u ownerId, descomentar y adaptar:
247
- // auth.assertOwnership(item.userId as string, currentUser.id, currentUser.role)
248
- return item
249
- }
250
-
251
- async create(dto: Create${p.name}DTO): Promise<${p.name}DTO> {
252
- this.logger.info('Creando ${p.module}')
253
- const item = await this.repo.create(dto as Omit<${p.name}DTO, 'id'>)
254
- await this.sockets.on${p.name}Created?.(item)
255
- await this.cache.delete('${p.module}:list')
256
- return item
257
- }
258
-
259
- async update(id: string, dto: Update${p.name}DTO): Promise<${p.name}DTO> {
260
- this.logger.info('Actualizando ${p.module}', { id })
261
- const item = await this.repo.update(id, dto as Partial<Omit<${p.name}DTO, 'id'>>)
262
- if (!item) throw new NotFoundError('${p.name} no encontrado')
263
- await this.sockets.on${p.name}Updated?.(item)
264
- await this.cache.delete('${p.module}:list')
265
- return item
266
- }
267
-
268
- async delete(id: string): Promise<void> {
269
- this.logger.info('Eliminando ${p.module}', { id })
270
- const deleted = await this.repo.delete(id)
271
- if (!deleted) throw new NotFoundError('${p.name} no encontrado')
272
- await this.sockets.on${p.name}Deleted?.(id)
273
- await this.cache.delete('${p.module}:list')
274
- }
275
- }
276
- `
277
- }
278
-
279
- export function controllerStub(p: ModuleStubParams): string {
280
- return `// ${p.module}/controller.ts — Adaptador HTTP del módulo
281
- // Responsabilidad ÚNICA: traducir request → service → response.
282
- // SIN lógica de negocio. SIN llamadas directas al ORM. (REGLA #12)
283
- // Toda mutación (POST/PUT/PATCH) DEBE pasar por validateSchema(). (REGLA #11)
284
-
285
- import type { HttpRequest, Logger } from 'arckode-framework'
286
- import { validateSchema } from 'arckode-framework'
287
- import type { ${p.name}Service } from './service'
288
- import { Create${p.name}Schema, Update${p.name}Schema } from './validators/schema'
289
-
290
- export class ${p.name}Controller {
291
- constructor(
292
- private readonly service: ${p.name}Service,
293
- private readonly logger: Logger,
294
- ) {}
295
-
296
- async index(req: HttpRequest) {
297
- this.logger.info('GET /${p.module}')
298
- const result = await this.service.list(req.query as any)
299
- return { status: 200, body: result }
300
- }
301
-
302
- async show(req: HttpRequest) {
303
- this.logger.info('GET /${p.module}/:id', { id: req.params.id })
304
- const item = await this.service.getById(req.params.id)
305
- return { status: 200, body: item }
306
- }
307
-
308
- async store(req: HttpRequest) {
309
- this.logger.info('POST /${p.module}')
310
- const data = validateSchema(Create${p.name}Schema, req.body)
311
- const item = await this.service.create(data as any)
312
- return { status: 201, body: item }
313
- }
314
-
315
- async update(req: HttpRequest) {
316
- this.logger.info('PUT /${p.module}/:id', { id: req.params.id })
317
- const data = validateSchema(Update${p.name}Schema, req.body)
318
- const item = await this.service.update(req.params.id, data as any)
319
- return { status: 200, body: item }
320
- }
321
-
322
- async destroy(req: HttpRequest) {
323
- this.logger.info('DELETE /${p.module}/:id', { id: req.params.id })
324
- await this.service.delete(req.params.id)
325
- return { status: 204, body: null }
326
- }
327
- }
328
- `
329
- }
330
-
331
- export function validatorStub(p: ModuleStubParams): string {
332
- const createRules = p.fields
333
- .filter(f => f.name !== 'id')
334
- .map(f => {
335
- const parts = [` ${f.name}: { type: '${f.type}' as const`]
336
- if (f.required && f.name !== 'createdAt' && f.name !== 'updatedAt') parts.push(', required: true')
337
- if (f.type === 'string' && f.required) parts.push(', min: 2, max: 200')
338
- parts.push(' }')
339
- return parts.join('')
340
- })
341
- .join(',\n')
342
-
343
- const updateRules = p.fields
344
- .filter(f => f.name !== 'id' && f.name !== 'createdAt')
345
- .map(f => {
346
- const parts = [` ${f.name}: { type: '${f.type}' as const`]
347
- if (f.type === 'string') parts.push(', min: 2, max: 200')
348
- parts.push(' }')
349
- return parts.join('')
350
- })
351
- .join(',\n')
352
-
353
- return `// ${p.module}/validators/schema.ts — Validación de entrada
354
- // Schemas planos, sin dependencias externas.
355
-
356
- import type { ValidationRule } from 'arckode-framework'
357
-
358
- export const Create${p.name}Schema: Record<string, ValidationRule> = {
359
- ${createRules}
360
- }
361
-
362
- export const Update${p.name}Schema: Record<string, ValidationRule> = {
363
- ${updateRules}
364
- }
365
-
366
- // Schema compuesto para usar en validación directa
367
- export const ${p.name}Validator = {
368
- create: Create${p.name}Schema,
369
- update: Update${p.name}Schema,
370
- }
371
- `
372
- }
373
-
374
- export function testStub(p: ModuleStubParams): string {
375
- return `// ${p.module}/tests/service.test.ts — Tests del servicio
376
- // Usa RepositoryAdapter mock — sin dependencia de SQLite ni Postgres.
377
-
378
- import { describe, it, expect } from 'bun:test'
379
- import type { RepositoryAdapter, CacheAdapter } from 'arckode-framework'
380
- import { silentLogger } from 'arckode-framework/testing'
381
- import { ${p.name}Service } from '../service'
382
- import type { ${p.name}DTO } from '../types'
383
-
384
- // silentLogger es una factory function — SIEMPRE llamarla con ()
385
- const log = silentLogger()
386
- const silentCache: CacheAdapter = { get: async () => null, set: async () => {}, delete: async () => {}, clear: async () => {}, flush: async () => {} }
387
-
388
- function makeRepo(overrides: Partial<RepositoryAdapter<${p.name}DTO>> = {}): RepositoryAdapter<${p.name}DTO> {
389
- return {
390
- findMany: async () => [],
391
- findById: async () => null,
392
- findOne: async () => null,
393
- create: async (data) => ({ id: 'test-id', ...data } as ${p.name}DTO),
394
- update: async (id, data) => ({ id, ...data } as ${p.name}DTO),
395
- delete: async () => true,
396
- count: async () => 0,
397
- paginate: async () => ({ data: [], total: 0, limit: 20, offset: 0, pages: 0 }),
398
- ...overrides,
399
- }
400
- }
401
-
402
- describe('${p.name}Service', () => {
403
- describe('getById', () => {
404
- it('lanza NotFound si el item no existe', async () => {
405
- const service = new ${p.name}Service(makeRepo(), log, silentCache)
406
- await expect(service.getById('no-existe')).rejects.toThrow('${p.name} no encontrado')
407
- })
408
-
409
- it('retorna el item si existe', async () => {
410
- const item = { id: '1' } as ${p.name}DTO
411
- const service = new ${p.name}Service(makeRepo({ findById: async () => item }), log, silentCache)
412
- expect(await service.getById('1')).toEqual(item)
413
- })
414
- })
415
-
416
- describe('create', () => {
417
- it('crea y retorna el item', async () => {
418
- const service = new ${p.name}Service(makeRepo(), log, silentCache)
419
- const result = await service.create({} as any)
420
- expect(result.id).toBe('test-id')
421
- })
422
- })
423
-
424
- describe('delete', () => {
425
- it('lanza NotFound si el item no existe', async () => {
426
- const service = new ${p.name}Service(makeRepo({ delete: async () => false }), log, silentCache)
427
- await expect(service.delete('no-existe')).rejects.toThrow('${p.name} no encontrado')
428
- })
429
- })
430
- })
431
- `
432
- }
433
-
434
- export function migrationStub(p: ModuleStubParams): string {
435
- // Tipos ANSI SQL — compatibles con SQLite, MySQL y Postgres sin modificaciones
436
- const sqlType = (f: ModuleStubParams['fields'][0]): string => {
437
- if (f.name === 'id') return 'VARCHAR(36)'
438
- const map: Record<string, string> = {
439
- string: 'VARCHAR(255)',
440
- number: 'DECIMAL(15,4)',
441
- boolean: 'BOOLEAN',
442
- date: 'TIMESTAMP',
443
- json: 'TEXT',
444
- }
445
- return map[f.type] ?? 'TEXT'
446
- }
447
-
448
- const cols = p.fields.map(f => {
449
- const pk = f.name === 'id' ? ' PRIMARY KEY' : ''
450
- const req = f.required && f.name !== 'id' ? ' NOT NULL' : ''
451
- const def = f.default !== undefined ? ` DEFAULT ${typeof f.default === 'string' ? `'${f.default}'` : f.default}` : ''
452
- return ` ${f.name} ${sqlType(f)}${pk}${req}${def}`
453
- }).join('\n')
454
-
455
- return `// migrations/${Date.now()}_create_${p.module}.ts
456
- // Migración generada por arckode make:module
457
- // SQL ANSI — compatible con SQLite, MySQL y Postgres sin modificaciones.
458
-
459
- export async function up(db: any): Promise<void> {
460
- await db.run(\`
461
- CREATE TABLE IF NOT EXISTS ${p.module} (
462
- ${cols},
463
- createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
464
- updatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP${p.softDelete ? ',\n deletedAt TIMESTAMP NULL' : ''}
465
- )
466
- \`)
467
- }
468
-
469
- export async function down(db: any): Promise<void> {
470
- await db.run(\`DROP TABLE IF EXISTS ${p.module}\`)
471
- }
472
- `
473
- }
474
-
475
- export function seedStub(p: ModuleStubParams): string {
476
- const sampleData = p.fields
477
- .filter(f => f.name !== 'id' && f.name !== 'createdAt' && f.name !== 'updatedAt')
478
- .map(f => {
479
- if (f.name === 'nombre' || f.name === 'name') return ` ${f.name}: '${p.name} de ejemplo'`
480
- if (f.name === 'email') return ` ${f.name}: 'ejemplo@correo.com'`
481
- if (f.name === 'activo' || f.name === 'active') return ` ${f.name}: true`
482
- if (f.type === 'number') return ` ${f.name}: 100`
483
- if (f.type === 'boolean') return ` ${f.name}: false`
484
- return ` ${f.name}: 'valor'`
485
- })
486
- .join('\n')
487
-
488
- return `// seeds/${p.module}.ts — Datos de prueba
489
-
490
- export async function seed${p.name}(orm: any): Promise<void> {
491
- const items = [
492
- {
493
- ${sampleData}
494
- },
495
- {
496
- ${sampleData.replace(`'${p.name} de ejemplo'`, `'${p.name} de ejemplo 2'`).replace(`'ejemplo@correo.com'`, `'ejemplo2@correo.com'`)}
497
- },
498
- ]
499
-
500
- await Promise.all(items.map(item => orm.create('${p.name}', item)))
501
-
502
- console.log(' ✓ ${p.name} seeded: ' + items.length + ' items')
503
- }
504
- `
505
- }
506
-
507
- // ─── repository.ts — OPCIONAL ────────────────────────────
508
- // NO se genera por default. Crealo a mano cuando:
509
- // 1. El service repite los mismos filtros en varios métodos
510
- // (ej: findOne({ user_id, currency }) en 4 lugares)
511
- // 2. Necesitás queries con JOIN, IN, LIKE — el ORM builtin no las soporta
512
- // 3. Querés expresar la query en lenguaje del DOMINIO
513
- // (ej: findActiveByUser vs findMany({ user_id, status: 'active' }))
514
- //
515
- // Si solo hacés CRUD pelado → NO necesitás repository.ts.
516
- // Pasás el OrmRepository<T> directo al service.
517
- export function repositoryStub(p: ModuleStubParams): string {
518
- return `// ${p.module}/repository.ts — Repository de dominio (OPCIONAL)
519
- // Encapsula queries con NOMBRES DEL DOMINIO en lugar de nombres de columna.
520
- //
521
- // Antes: service.findOne({ user_id: userId, currency }) ← service conoce la DB
522
- // Ahora: service.findByUserAndCurrency(userId, currency) ← service habla el dominio
523
- //
524
- // Implementa RepositoryAdapter<${p.name}DTO> si necesitás queries complejas (JOIN, IN).
525
-
526
- import type { RepositoryAdapter } from 'arckode-framework'
527
- import type { ${p.name}DTO } from './types'
528
-
529
- export class ${p.name}Repository {
530
- constructor(private readonly base: RepositoryAdapter<${p.name}DTO>) {}
531
-
532
- // Métodos genéricos delegados al adapter base
533
- findById(id: string) { return this.base.findById(id) }
534
- findMany(filters?: Record<string, unknown>) { return this.base.findMany(filters) }
535
- create(data: Omit<${p.name}DTO, 'id'>) { return this.base.create(data) }
536
- update(id: string, data: Partial<Omit<${p.name}DTO, 'id'>>) { return this.base.update(id, data) }
537
- delete(id: string) { return this.base.delete(id) }
538
-
539
- // ─── Métodos específicos del dominio ───
540
- // Agregá acá las queries que se repiten en el service
541
- //
542
- // async findActiveByUser(userId: string): Promise<${p.name}DTO[]> {
543
- // return this.base.findMany({ user_id: userId, active: true })
544
- // }
545
- }
546
- `
547
- }
548
-
549
- function mapTS(type: string): string {
550
- const map: Record<string, string> = { string: 'string', number: 'number', boolean: 'boolean', date: 'string' }
551
- return map[type] ?? 'unknown'
552
- }
1
+ // cli/stubs/module-stub.ts — Re-export shim (módulos reales en cli/stubs/module/)
2
+ export * from './module/index'
package/kernel/auth.ts ADDED
@@ -0,0 +1,114 @@
1
+ import { AuthError, ForbiddenError } from './errors'
2
+ import type { Logger } from './logger'
3
+ import type { MiddlewareHandler } from './http/types'
4
+
5
+ export interface JwtAdapter {
6
+ sign(payload: Record<string, unknown>, secret: string, expiresIn: string): string
7
+ verify(token: string, secret: string): Record<string, unknown>
8
+ }
9
+
10
+ export class Auth {
11
+ private readonly refreshSecret: string
12
+
13
+ constructor(
14
+ private jwt: JwtAdapter,
15
+ private secret: string,
16
+ private logger: Logger,
17
+ private expiresIn: string = '24h',
18
+ private refreshExpiresIn: string = '30d',
19
+ ) {
20
+ this.refreshSecret = secret + '__refresh__'
21
+ }
22
+
23
+ createToken(payload: { id: string; role: string }, expiresIn?: string): string {
24
+ const ttl = expiresIn ?? this.expiresIn
25
+ const token = this.jwt.sign({ id: payload.id, role: payload.role, type: 'access' }, this.secret, ttl)
26
+ this.logger.debug('Token creado', { userId: payload.id, expiresIn: ttl })
27
+ return token
28
+ }
29
+
30
+ createRefreshToken(payload: { id: string; role: string }): string {
31
+ const token = this.jwt.sign(
32
+ { id: payload.id, role: payload.role, type: 'refresh', jti: crypto.randomUUID() },
33
+ this.refreshSecret,
34
+ this.refreshExpiresIn,
35
+ )
36
+ this.logger.debug('Refresh token creado', { userId: payload.id })
37
+ return token
38
+ }
39
+
40
+ refresh(refreshToken: string): { accessToken: string; refreshToken: string } {
41
+ try {
42
+ const payload = this.jwt.verify(refreshToken, this.refreshSecret)
43
+ if (payload.type !== 'refresh') throw new AuthError('Invalid token type')
44
+ const user = { id: payload.id as string, role: payload.role as string }
45
+ return {
46
+ accessToken: this.createToken(user),
47
+ refreshToken: this.createRefreshToken(user),
48
+ }
49
+ } catch (e) {
50
+ if (e instanceof AuthError) throw e
51
+ throw new AuthError('Invalid or expired refresh token')
52
+ }
53
+ }
54
+
55
+ verifyToken(token: string): { id: string; role: string } {
56
+ try {
57
+ const payload = this.jwt.verify(token, this.secret)
58
+ if (payload.type !== 'access') throw new AuthError('Invalid token type')
59
+ return { id: payload.id as string, role: payload.role as string }
60
+ } catch (e) {
61
+ if (e instanceof AuthError) throw e
62
+ throw new AuthError('Invalid or expired token')
63
+ }
64
+ }
65
+
66
+ authenticate(...allowedRoles: string[]): MiddlewareHandler {
67
+ return async (req, next) => {
68
+ const header = req.headers['authorization']
69
+ if (!header?.startsWith('Bearer ')) {
70
+ throw new AuthError('Authentication token required')
71
+ }
72
+
73
+ const user = this.verifyToken(header.slice(7))
74
+
75
+ if (allowedRoles.length > 0 && !allowedRoles.includes(user.role)) {
76
+ throw new ForbiddenError(`Required role: ${allowedRoles.join(' or ')}`)
77
+ }
78
+
79
+ req.user = user
80
+ return next()
81
+ }
82
+ }
83
+
84
+ assertOwnership(
85
+ resourceOwnerId: string,
86
+ requestingUserId: string,
87
+ requestingUserRole?: string,
88
+ adminRole = 'admin',
89
+ ): void {
90
+ if (requestingUserId === resourceOwnerId) return
91
+ if (requestingUserRole === adminRole) return
92
+ throw new ForbiddenError('Forbidden: resource belongs to another user')
93
+ }
94
+
95
+ async hashPassword(password: string): Promise<string> {
96
+ const { scrypt, randomBytes } = await import('node:crypto')
97
+ const salt = randomBytes(16).toString('hex')
98
+ const hash = await new Promise<Buffer>((resolve, reject) =>
99
+ scrypt(password, salt, 64, (err, key) => (err ? reject(err) : resolve(key))),
100
+ )
101
+ return `${salt}:${hash.toString('hex')}`
102
+ }
103
+
104
+ async comparePassword(password: string, stored: string): Promise<boolean> {
105
+ const { scrypt, timingSafeEqual } = await import('node:crypto')
106
+ const [salt, hash] = stored.split(':')
107
+ if (!salt || !hash) return false
108
+ const storedBuf = Buffer.from(hash, 'hex')
109
+ const attempt = await new Promise<Buffer>((resolve, reject) =>
110
+ scrypt(password, salt, 64, (err, key) => (err ? reject(err) : resolve(key))),
111
+ )
112
+ return timingSafeEqual(storedBuf, attempt)
113
+ }
114
+ }