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
@@ -0,0 +1,149 @@
1
+ import { InternalError, ModuleRuleError, NotFoundError } from '../errors'
2
+ import type { Logger } from '../logger'
3
+ import type { ConfigStore } from '../config'
4
+ import type { Container } from '../container'
5
+ import type { ORM } from '../db/orm'
6
+ import type { Router } from '../http/router'
7
+ import type { ServerAdapter } from '../http/server'
8
+ import type { CacheAdapter } from '../cache'
9
+ import type { Auth } from '../auth'
10
+ import type {
11
+ ConnectorContext,
12
+ ConnectorDefinition,
13
+ ModuleDefinition,
14
+ ModuleDependencies,
15
+ SocketsAware,
16
+ } from './types'
17
+
18
+ export class System {
19
+ public readonly logger: Logger
20
+ public readonly config: ConfigStore
21
+ public readonly container: Container
22
+ public readonly orm: ORM
23
+ public readonly router: Router
24
+ public readonly http: ServerAdapter
25
+ public readonly cache: CacheAdapter
26
+ public readonly auth?: Auth
27
+
28
+ private modules = new Map<string, { definition: ModuleDefinition<unknown>; instance: unknown; sockets?: Record<string, unknown> }>()
29
+ private connectors: { name: string; fn: ConnectorDefinition }[] = []
30
+ private initialized = false
31
+
32
+ constructor(params: {
33
+ config: ConfigStore
34
+ container: Container
35
+ logger: Logger
36
+ orm: ORM
37
+ router: Router
38
+ http: ServerAdapter
39
+ cache: CacheAdapter
40
+ auth?: Auth
41
+ }) {
42
+ this.config = params.config
43
+ this.container = params.container
44
+ this.logger = params.logger
45
+ this.orm = params.orm
46
+ this.router = params.router
47
+ this.http = params.http
48
+ this.cache = params.cache
49
+ this.auth = params.auth
50
+ }
51
+
52
+ addModule<T>(definition: ModuleDefinition<T>): this {
53
+ if (this.initialized) throw new InternalError(`Cannot add module "${definition.name}" after initialization`)
54
+ const c = definition.contract
55
+ if (!c.name?.trim()) throw new ModuleRuleError(definition.name, 'contract.name no puede estar vacío')
56
+ if (!c.version?.trim()) throw new ModuleRuleError(definition.name, 'contract.version no puede estar vacío')
57
+ if (!c.description?.trim()) throw new ModuleRuleError(definition.name, 'contract.description no puede estar vacío')
58
+ for (const dep of c.dependencies ?? []) {
59
+ if (!this.modules.has(dep)) {
60
+ throw new ModuleRuleError(definition.name, `dependencia "${dep}" no está registrada — registrá los módulos en orden de dependencia`)
61
+ }
62
+ }
63
+ this.modules.set(definition.name, { definition, instance: undefined })
64
+ return this
65
+ }
66
+
67
+ addConnector(name: string, fn: ConnectorDefinition): this {
68
+ this.connectors.push({ name, fn })
69
+ return this
70
+ }
71
+
72
+ resolveModule<T>(name: string): T {
73
+ const entry = this.modules.get(name)
74
+ if (!entry?.instance) throw new NotFoundError(`Module "${name}" not found or not initialized`)
75
+ return entry.instance as T
76
+ }
77
+
78
+ getModule<T>(name: string): T {
79
+ return this.resolveModule<T>(name)
80
+ }
81
+
82
+ init(): void {
83
+ if (this.initialized) return
84
+ this.initialized = true
85
+
86
+ this.container.init()
87
+
88
+ for (const [name, entry] of this.modules) {
89
+ const deps: ModuleDependencies = {
90
+ logger: this.logger.child(name),
91
+ orm: this.orm,
92
+ router: this.router,
93
+ config: this.config,
94
+ cache: this.cache,
95
+ auth: this.auth,
96
+ }
97
+
98
+ entry.instance = entry.definition.create(deps)
99
+ this.logger.info(`Module initialized: ${name} v${entry.definition.version}`)
100
+ }
101
+
102
+ for (const connector of this.connectors) {
103
+ const ctx: ConnectorContext = {
104
+ resolveModule: <T>(name: string, sockets?: Record<string, unknown>): T => {
105
+ const instance = this.resolveModule<Record<string, unknown>>(name)
106
+ if (sockets && typeof instance === 'object' && instance) {
107
+ if ('setSockets' in instance && typeof (instance as unknown as SocketsAware).setSockets === 'function') {
108
+ ;(instance as unknown as SocketsAware).setSockets(sockets)
109
+ }
110
+ }
111
+ return instance as unknown as T
112
+ },
113
+ }
114
+
115
+ connector.fn(ctx)
116
+ this.logger.info(`Connector executed: ${connector.name}`)
117
+ }
118
+ }
119
+
120
+ async start(): Promise<void> {
121
+ this.router.setLogger(this.logger)
122
+ this.init()
123
+ await this.http.start((req) => this.router.resolve(req.method, req.path, req))
124
+ this.logger.info('══════════════════════════════════')
125
+ this.logger.info(' System started successfully')
126
+ this.logger.info(` Modules: ${[...this.modules.keys()].join(', ')}`)
127
+ this.logger.info(` Connectors: ${this.connectors.map(c => c.name).join(', ')}`)
128
+ this.logger.info('══════════════════════════════════')
129
+ }
130
+
131
+ async stop(): Promise<void> {
132
+ this.logger.info('Shutting down system...')
133
+
134
+ for (const [name, entry] of this.modules) {
135
+ if (entry.definition.onStop && entry.instance) {
136
+ try {
137
+ await entry.definition.onStop(entry.instance)
138
+ this.logger.info(`Module stopped: ${name}`)
139
+ } catch (e) {
140
+ this.logger.error(`Error stopping module "${name}"`, { error: String(e) })
141
+ }
142
+ }
143
+ }
144
+
145
+ await this.http.stop()
146
+ await this.container.destroy()
147
+ this.logger.info('System stopped')
148
+ }
149
+ }
@@ -0,0 +1,46 @@
1
+ import type { Logger } from '../logger'
2
+ import type { ORM } from '../db/orm'
3
+ import type { Router } from '../http/router'
4
+ import type { ConfigStore } from '../config'
5
+ import type { CacheAdapter } from '../cache'
6
+ import type { Auth } from '../auth'
7
+
8
+ export interface ModuleContract {
9
+ name: string
10
+ version: string
11
+ description: string
12
+ actions: string[]
13
+ events: string[]
14
+ tables: string[]
15
+ dependencies: string[]
16
+ rules: string[]
17
+ }
18
+
19
+ export interface ModuleDependencies {
20
+ logger: Logger
21
+ orm: ORM
22
+ router: Router
23
+ config: ConfigStore
24
+ cache: CacheAdapter
25
+ auth?: Auth
26
+ }
27
+
28
+ export interface SocketsAware {
29
+ setSockets(sockets: Record<string, unknown>): void
30
+ }
31
+
32
+ export interface ModuleDefinition<TModule> {
33
+ name: string
34
+ version: string
35
+ description: string
36
+ contract: ModuleContract
37
+ create(deps: ModuleDependencies): TModule
38
+ validate?(instance: TModule): void
39
+ onStop?(instance: TModule): Promise<void>
40
+ }
41
+
42
+ export interface ConnectorContext {
43
+ resolveModule<T>(name: string, sockets?: Record<string, unknown>): T
44
+ }
45
+
46
+ export type ConnectorDefinition = (ctx: ConnectorContext) => void | Promise<void>
@@ -0,0 +1,48 @@
1
+ import { NotFoundError } from './errors'
2
+ import type { Logger } from './logger'
3
+ import type { ORM } from './db/orm'
4
+
5
+ export type SeedFunction = (orm: ORM) => Promise<void>
6
+
7
+ export class SeedRunner {
8
+ private seeds: { name: string; run: SeedFunction }[] = []
9
+
10
+ constructor(
11
+ private orm: ORM,
12
+ private logger: Logger,
13
+ ) {}
14
+
15
+ add(name: string, run: SeedFunction): this {
16
+ this.seeds.push({ name, run })
17
+ return this
18
+ }
19
+
20
+ async runAll(): Promise<void> {
21
+ for (const seed of this.seeds) {
22
+ try {
23
+ await seed.run(this.orm)
24
+ this.logger.info(`Seed executed: ${seed.name}`)
25
+ } catch (error) {
26
+ this.logger.error(`Error in seed "${seed.name}"`, { error: String(error) })
27
+ throw error
28
+ }
29
+ }
30
+ }
31
+
32
+ async runOne(name: string): Promise<void> {
33
+ const seed = this.seeds.find(s => s.name === name)
34
+ if (!seed) throw new NotFoundError(`Seed "${name}" not found`)
35
+
36
+ try {
37
+ await seed.run(this.orm)
38
+ this.logger.info(`Seed executed: ${name}`)
39
+ } catch (error) {
40
+ this.logger.error(`Error in seed "${name}"`, { error: String(error) })
41
+ throw error
42
+ }
43
+ }
44
+
45
+ get list(): string[] {
46
+ return this.seeds.map(s => s.name)
47
+ }
48
+ }
package/kernel/static.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // SOLID: responsabilidad ÚNICA de servir archivos estáticos
4
4
 
5
5
  import { readFile, access } from 'node:fs/promises'
6
- import { join, extname } from 'node:path'
6
+ import { join, extname, resolve, sep } from 'node:path'
7
7
  import type { Router, MiddlewareHandler } from './framework'
8
8
 
9
9
  export interface StaticOptions {
@@ -37,9 +37,18 @@ export function serveStatic(
37
37
  ): void {
38
38
  const { prefix = '', fallback, cacheControl = 'public, max-age=3600' } = options
39
39
 
40
+ const resolvedBase = resolve(basePath)
41
+
40
42
  // GET /* → intenta servir archivo estático
41
43
  router.get(`${prefix}/:path(*)`, async (req) => {
42
- const filePath = join(basePath, req.params['path'] || 'index.html')
44
+ const requestedPath = req.params['path'] || 'index.html'
45
+ const filePath = join(basePath, requestedPath)
46
+
47
+ // Path traversal protection: %2e%2e%2f decodificado puede escapar basePath
48
+ const resolvedFile = resolve(filePath)
49
+ if (!resolvedFile.startsWith(resolvedBase + sep) && resolvedFile !== resolvedBase) {
50
+ return { status: 403, body: { error: 'Forbidden' } }
51
+ }
43
52
 
44
53
  try {
45
54
  await access(filePath)
package/kernel/testing.ts CHANGED
@@ -4,10 +4,15 @@
4
4
  // Diseño: sin dependencias externas, compatible con bun:test, Jest y Vitest.
5
5
 
6
6
  import { request as httpRequest } from 'node:http'
7
- import type { HttpRequest, HttpResponse, MiddlewareHandler } from './framework'
7
+ import type { HttpRequest, HttpResponse, MiddlewareHandler, LoggerTransport } from './framework'
8
8
  import { Router, ORM, Logger, NodeServer } from './framework'
9
9
  import type { DbAdapter } from './framework'
10
10
 
11
+ // Transport que descarta todo — silencia ABSOLUTAMENTE todos los logs en tests
12
+ class NoopTransport implements LoggerTransport {
13
+ write(): void {}
14
+ }
15
+
11
16
  // ═══════════════════════════════════════════════════════════════
12
17
  // TEST CLIENT — hace requests a un Router sin levantar HTTP
13
18
  // ═══════════════════════════════════════════════════════════════
@@ -140,14 +145,14 @@ export function mockAuth(opts: MockAuthOptions = {}): MiddlewareHandler {
140
145
  // ═══════════════════════════════════════════════════════════════
141
146
 
142
147
  /**
143
- * Logger que no imprime nada. Úsalo en tests para silenciar output.
148
+ * Logger que no imprime nada — ni errores. Úsalo en tests para silenciar output.
144
149
  *
145
150
  * @example
146
151
  * const logger = silentLogger()
147
152
  * const service = new MiService(orm, logger)
148
153
  */
149
154
  export function silentLogger(): Logger {
150
- return new Logger('test', 'error')
155
+ return new Logger('test', 'error', [new NoopTransport()])
151
156
  }
152
157
 
153
158
  // ═══════════════════════════════════════════════════════════════
@@ -0,0 +1,116 @@
1
+ import { ValidationError } from './errors'
2
+
3
+ export type ValidatorType = 'string' | 'number' | 'boolean' | 'email' | 'url' | 'date'
4
+
5
+ export interface ValidationRule {
6
+ type: ValidatorType
7
+ required?: boolean
8
+ min?: number
9
+ max?: number
10
+ pattern?: RegExp
11
+ enum?: string[]
12
+ message?: string
13
+ sanitize?: boolean | 'html'
14
+ }
15
+
16
+ export type ValidationSchema = Record<string, ValidationRule>
17
+
18
+ export function validateSchema(schema: ValidationSchema, input: unknown): Record<string, unknown> {
19
+ const errors: Record<string, string[]> = {}
20
+ const output: Record<string, unknown> = {}
21
+
22
+ if (typeof input !== 'object' || input === null) {
23
+ throw new ValidationError('Request body must be an object')
24
+ }
25
+
26
+ const data = input as Record<string, unknown>
27
+
28
+ for (const [field, rule] of Object.entries(schema)) {
29
+ const value = data[field]
30
+ const fieldErrors: string[] = []
31
+
32
+ if (value === undefined || value === null) {
33
+ if (rule.required) {
34
+ fieldErrors.push(rule.message ?? `${field} is required`)
35
+ errors[field] = fieldErrors
36
+ }
37
+ continue
38
+ }
39
+
40
+ switch (rule.type) {
41
+ case 'string': {
42
+ if (typeof value !== 'string') {
43
+ fieldErrors.push(`${field} must be a string`)
44
+ } else {
45
+ let sanitized = value.trim().replace(/\s+/g, ' ')
46
+ if (rule.sanitize === 'html') {
47
+ sanitized = sanitized
48
+ .replace(/&/g, '&amp;')
49
+ .replace(/</g, '&lt;')
50
+ .replace(/>/g, '&gt;')
51
+ .replace(/"/g, '&quot;')
52
+ .replace(/'/g, '&#x27;')
53
+ }
54
+ if (rule.min !== undefined && sanitized.length < rule.min) fieldErrors.push(`Minimum ${rule.min} characters`)
55
+ if (rule.max !== undefined && sanitized.length > rule.max) fieldErrors.push(`Maximum ${rule.max} characters`)
56
+ if (rule.pattern && !rule.pattern.test(sanitized)) fieldErrors.push(rule.message ?? 'Invalid format')
57
+ if (rule.enum && !rule.enum.includes(sanitized)) fieldErrors.push(`Must be one of: ${rule.enum.join(', ')}`)
58
+ output[field] = sanitized
59
+ }
60
+ break
61
+ }
62
+ case 'number': {
63
+ const num = typeof value === 'string' ? Number(value) : value
64
+ if (typeof num !== 'number' || isNaN(num)) {
65
+ fieldErrors.push(`${field} must be a number`)
66
+ } else {
67
+ if (rule.min !== undefined && num < rule.min) fieldErrors.push(`Minimum ${rule.min}`)
68
+ if (rule.max !== undefined && num > rule.max) fieldErrors.push(`Maximum ${rule.max}`)
69
+ output[field] = num
70
+ }
71
+ break
72
+ }
73
+ case 'boolean': {
74
+ if (typeof value !== 'boolean') {
75
+ fieldErrors.push(`${field} must be a boolean`)
76
+ } else {
77
+ output[field] = value
78
+ }
79
+ break
80
+ }
81
+ case 'email': {
82
+ if (typeof value !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
83
+ fieldErrors.push(rule.message ?? `${field} is not a valid email`)
84
+ } else {
85
+ output[field] = value
86
+ }
87
+ break
88
+ }
89
+ case 'url': {
90
+ if (typeof value !== 'string') { fieldErrors.push(`${field} must be a string`) }
91
+ else {
92
+ try { new URL(value); output[field] = value } catch { fieldErrors.push(rule.message ?? `${field} is not a valid URL`) }
93
+ }
94
+ break
95
+ }
96
+ case 'date': {
97
+ if (typeof value !== 'string' || isNaN(Date.parse(value))) {
98
+ fieldErrors.push(rule.message ?? `${field} is not a valid date`)
99
+ } else {
100
+ output[field] = value
101
+ }
102
+ break
103
+ }
104
+ }
105
+
106
+ if (fieldErrors.length > 0) {
107
+ errors[field] = fieldErrors
108
+ }
109
+ }
110
+
111
+ if (Object.keys(errors).length > 0) {
112
+ throw new ValidationError('Validation error', errors)
113
+ }
114
+
115
+ return output
116
+ }
@@ -18,12 +18,28 @@ export interface EventBusAdapter {
18
18
  unsubscribe(name: string, handler: EventHandler): void
19
19
  }
20
20
 
21
+ export interface EventBusOptions {
22
+ adapter?: EventBusAdapter
23
+ onError?: (event: string, error: unknown) => void
24
+ }
25
+
21
26
  export class EventBus {
22
27
  private handlers = new Map<string, Set<EventHandler>>()
23
28
  private history: EventMessage[] = []
24
29
  private maxHistory = 100
25
-
26
- constructor(private adapter?: EventBusAdapter) {}
30
+ private onError: (event: string, error: unknown) => void
31
+ private adapter?: EventBusAdapter
32
+
33
+ constructor(adapterOrOptions?: EventBusAdapter | EventBusOptions) {
34
+ if (adapterOrOptions && 'publish' in adapterOrOptions) {
35
+ this.adapter = adapterOrOptions
36
+ this.onError = (ev, e) => console.error(`[EventBus] Error en handler para "${ev}":`, e)
37
+ } else {
38
+ const opts = (adapterOrOptions ?? {}) as EventBusOptions
39
+ this.adapter = opts.adapter
40
+ this.onError = opts.onError ?? ((ev, e) => console.error(`[EventBus] Error en handler para "${ev}":`, e))
41
+ }
42
+ }
27
43
 
28
44
  async emit(name: string, data: unknown, source: string = 'system'): Promise<void> {
29
45
  const event: EventMessage = {
@@ -42,7 +58,7 @@ export class EventBus {
42
58
  const localHandlers = this.handlers.get(name)
43
59
  if (localHandlers) {
44
60
  await Promise.allSettled([...localHandlers].map(h => h(event).catch(e => {
45
- console.error(`[EventBus] Error en handler para "${name}":`, e)
61
+ this.onError(name, e)
46
62
  })))
47
63
  }
48
64
 
@@ -34,18 +34,30 @@ export class MailService {
34
34
  }
35
35
 
36
36
  async sendWelcome(email: string, name: string): Promise<void> {
37
+ const safeName = MailService.escapeHtml(name)
37
38
  await this.send({
38
39
  to: { address: email, name },
39
40
  subject: 'Welcome',
40
- html: `<h1>Welcome, ${name}!</h1><p>Thanks for signing up.</p>`,
41
+ html: `<h1>Welcome, ${safeName}!</h1><p>Thanks for signing up.</p>`,
41
42
  })
42
43
  }
43
44
 
44
45
  async sendPasswordReset(email: string, token: string): Promise<void> {
46
+ const safeToken = MailService.escapeHtml(token)
45
47
  await this.send({
46
48
  to: { address: email },
47
49
  subject: 'Password reset',
48
- html: `<p>Use this token to reset your password: <b>${token}</b></p>`,
50
+ html: `<p>Use this token to reset your password: <b>${safeToken}</b></p>`,
49
51
  })
50
52
  }
53
+
54
+ /** Escapa caracteres HTML especiales en strings que se insertan en plantillas de email. */
55
+ static escapeHtml(str: string): string {
56
+ return str
57
+ .replace(/&/g, '&amp;')
58
+ .replace(/</g, '&lt;')
59
+ .replace(/>/g, '&gt;')
60
+ .replace(/"/g, '&quot;')
61
+ .replace(/'/g, '&#x27;')
62
+ }
51
63
  }
@@ -2,23 +2,36 @@
2
2
  // Almacena archivos en el sistema de archivos local
3
3
 
4
4
  import { mkdir, writeFile, unlink } from 'node:fs/promises'
5
- import { join, extname } from 'node:path'
5
+ import { join, extname, resolve, sep } from 'node:path'
6
6
  import type { StorageAdapter, FileUpload, StoredFile } from './index'
7
7
 
8
8
  export class LocalStorageAdapter implements StorageAdapter {
9
+ private readonly resolvedBase: string
10
+
9
11
  constructor(
10
12
  private basePath: string = './uploads',
11
13
  private baseUrl: string = '/uploads',
12
- ) {}
14
+ ) {
15
+ this.resolvedBase = resolve(basePath)
16
+ }
17
+
18
+ /** Resuelve un subpath de forma segura. Lanza si intenta escapar del basePath. */
19
+ private resolveSafe(subpath: string): string {
20
+ const full = resolve(join(this.basePath, subpath))
21
+ if (!full.startsWith(this.resolvedBase + sep) && full !== this.resolvedBase) {
22
+ throw new Error(`Path traversal detectado: "${subpath}" escapa del directorio de uploads`)
23
+ }
24
+ return full
25
+ }
13
26
 
14
27
  async upload(file: FileUpload, directory?: string): Promise<StoredFile> {
15
28
  const dir = directory ?? 'general'
16
29
  const ext = extname(file.originalName)
17
30
  const filename = `${Date.now()}-${Math.random().toString(36).slice(2)}${ext}`
18
31
  const relativePath = `${dir}/${filename}`
19
- const fullPath = join(this.basePath, relativePath)
32
+ const fullPath = this.resolveSafe(relativePath)
20
33
 
21
- await mkdir(join(this.basePath, dir), { recursive: true })
34
+ await mkdir(resolve(join(this.basePath, dir)), { recursive: true })
22
35
  await writeFile(fullPath, file.buffer)
23
36
 
24
37
  return {
@@ -31,11 +44,12 @@ export class LocalStorageAdapter implements StorageAdapter {
31
44
  }
32
45
 
33
46
  async delete(path: string): Promise<void> {
34
- const fullPath = join(this.basePath, path)
47
+ const fullPath = this.resolveSafe(path)
35
48
  await unlink(fullPath).catch(() => {})
36
49
  }
37
50
 
38
51
  getUrl(path: string): string {
52
+ // Solo construye la URL — no accede al sistema de archivos, no hay riesgo de traversal.
39
53
  return `${this.baseUrl}/${path}`
40
54
  }
41
55
  }