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
|
@@ -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>
|
package/kernel/seeds.ts
ADDED
|
@@ -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
|
|
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, '&')
|
|
49
|
+
.replace(/</g, '<')
|
|
50
|
+
.replace(/>/g, '>')
|
|
51
|
+
.replace(/"/g, '"')
|
|
52
|
+
.replace(/'/g, ''')
|
|
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
|
+
}
|
package/modules/events/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
61
|
+
this.onError(name, e)
|
|
46
62
|
})))
|
|
47
63
|
}
|
|
48
64
|
|
package/modules/mail/index.ts
CHANGED
|
@@ -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, ${
|
|
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>${
|
|
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, '&')
|
|
58
|
+
.replace(/</g, '<')
|
|
59
|
+
.replace(/>/g, '>')
|
|
60
|
+
.replace(/"/g, '"')
|
|
61
|
+
.replace(/'/g, ''')
|
|
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 =
|
|
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 =
|
|
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
|
}
|