@saulwade/swl-ses 1.3.8 → 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 (128) hide show
  1. package/CLAUDE.md +12 -4
  2. package/README.md +1 -1
  3. package/bin/swl-mcp-server.js +187 -187
  4. package/bin/swl-webhook-server.js +198 -0
  5. package/comandos/swl/.evolved.json +22 -22
  6. package/comandos/swl/adoptar-proyecto.md +21 -1
  7. package/comandos/swl/claudemd.md +14 -1
  8. package/comandos/swl/contribuir.md +233 -233
  9. package/comandos/swl/exportar-vault.md +108 -0
  10. package/comandos/swl/nuevo-proyecto.md +24 -2
  11. package/gateway/adapters/base.js +109 -0
  12. package/gateway/adapters/discord.js +167 -0
  13. package/gateway/adapters/email.js +221 -0
  14. package/gateway/adapters/slack.js +192 -0
  15. package/gateway/adapters/telegram.js +183 -0
  16. package/gateway/adapters/webhook.js +113 -0
  17. package/gateway/adapters/whatsapp.js +214 -0
  18. package/gateway/agent-executor.js +322 -0
  19. package/gateway/command-relay.js +271 -0
  20. package/gateway/cron/jobs.js +263 -0
  21. package/gateway/cron/scheduler.js +322 -0
  22. package/gateway/cron/store.js +335 -0
  23. package/gateway/index.js +320 -0
  24. package/gateway/lib/event-channel.js +191 -0
  25. package/gateway/session.js +131 -0
  26. package/gateway/webhook-server.js +324 -0
  27. package/habilidades/backend-production-resilience/SKILL.md +288 -288
  28. package/habilidades/benchmark-memoria/SKILL.md +186 -186
  29. package/habilidades/build-errors-nextjs/SKILL.md +55 -1
  30. package/habilidades/diagrama-arquitectura/assets/template.html +276 -276
  31. package/habilidades/doubt-driven-review/SKILL.md +171 -171
  32. package/habilidades/doubt-driven-review/recursos/EXAMPLES.md +130 -130
  33. package/habilidades/eval-framework/SKILL.md +212 -212
  34. package/habilidades/extractor-de-aprendizajes/SKILL.md +20 -10
  35. package/habilidades/harness-claude-code/SKILL.md +299 -299
  36. package/habilidades/infra-github-actions/SKILL.md +166 -166
  37. package/habilidades/legacy-code-rescue/SKILL.md +267 -267
  38. package/habilidades/manejo-errores/.evolved.json +8 -8
  39. package/habilidades/meta-skills-estandar/recursos/convencion-examples.md +93 -93
  40. package/habilidades/meta-skills-estandar/recursos/skills-as-agents.md +163 -163
  41. package/habilidades/nextjs-testing/SKILL.md +89 -5
  42. package/habilidades/node-experto/SKILL.md +37 -1
  43. package/habilidades/patrones-python/SKILL.md +229 -229
  44. package/habilidades/patrones-python/recursos/patrones-avanzados.md +469 -469
  45. package/habilidades/planear-fase/SKILL.md +319 -319
  46. package/habilidades/react-experto/SKILL.md +45 -4
  47. package/habilidades/release-semver/.evolved.json +8 -8
  48. package/habilidades/tdd-workflow/SKILL.md +36 -4
  49. package/habilidades/testing-python/SKILL.md +340 -340
  50. package/hooks/claudemd-bloat-detector.js +161 -161
  51. package/hooks/inyeccion-contexto.js +8 -3
  52. package/hooks/lib/agent-routing.js +107 -107
  53. package/hooks/lib/auto-consolidator.js +335 -335
  54. package/hooks/lib/error-classifier.js +308 -308
  55. package/hooks/lib/merkle-audit.js +96 -96
  56. package/hooks/lib/provenance-tracker.js +191 -191
  57. package/hooks/lib/rate-limit-ip.js +177 -0
  58. package/hooks/lib/rate-limit-tracker.js +253 -253
  59. package/hooks/lib/resource-quota.js +122 -122
  60. package/hooks/lib/retry-jitter.js +165 -165
  61. package/hooks/lib/skill-auditor.js +588 -588
  62. package/hooks/lib/sync-status.js +228 -228
  63. package/hooks/lib/taint-tracker.js +107 -107
  64. package/hooks/lib/text-similarity.js +241 -241
  65. package/hooks/lib/toon-compressor.js +245 -245
  66. package/hooks/lib/webhook-dedup.js +184 -0
  67. package/hooks/lib/webhook-verify.js +123 -0
  68. package/hooks/proteccion-rutas.js +120 -15
  69. package/hooks/registro-turnos.js +209 -209
  70. package/hooks/sugerir-regenerar-inventario.js +170 -170
  71. package/hooks/validar-formato-post-subagente.js +140 -140
  72. package/hooks/validar-memoria-hook.js +218 -218
  73. package/instintos/prompt-appendices.yaml +57 -57
  74. package/manifiestos/agent-output-schemas.json +57 -57
  75. package/manifiestos/modulos.json +1 -0
  76. package/manifiestos/skills-lock.json +34 -34
  77. package/package.json +5 -3
  78. package/plantillas/auditor-veto-template.md +105 -105
  79. package/plantillas/github-workflows/README.md +47 -47
  80. package/plantillas/github-workflows/release-please.yml +44 -44
  81. package/plantillas/github-workflows/swl-ci.yml +107 -107
  82. package/plantillas/github-workflows/swl-security.yml +51 -51
  83. package/plugin.json +1 -1
  84. package/reglas/analisis-previo-tareas-grandes.md +172 -172
  85. package/reglas/arreglar-al-detectar.md +147 -147
  86. package/reglas/fragmentos-compartidos.md +152 -152
  87. package/reglas/harness-claude-code.md +213 -213
  88. package/reglas/usar-context7.md +226 -226
  89. package/reglas/usar-sistema-swl.md +251 -0
  90. package/schemas/diary-entry.schema.json +80 -80
  91. package/scripts/benchmark-memoria.js +167 -167
  92. package/scripts/comandos/skills.js +251 -2
  93. package/scripts/configurar-branch-protection.js +418 -418
  94. package/scripts/detectar-aprendizajes-duplicados.js +151 -151
  95. package/scripts/field-report.js +199 -199
  96. package/scripts/generar-checklists-consolidados.js +273 -273
  97. package/scripts/generar-inventario.js +420 -420
  98. package/scripts/generar-matriz-lenguajes.js +271 -271
  99. package/scripts/lib/artefactos-python.js +43 -43
  100. package/scripts/lib/benchmark-metrics.js +160 -160
  101. package/scripts/lib/budget-enforcer.js +252 -252
  102. package/scripts/lib/configurar-ci.js +380 -380
  103. package/scripts/lib/contadores-inventario.js +217 -217
  104. package/scripts/lib/detectar-stack-detallado.js +307 -307
  105. package/scripts/lib/diary-entry.js +234 -234
  106. package/scripts/lib/eval-metrics-store.js +218 -218
  107. package/scripts/lib/eval-quality.js +171 -171
  108. package/scripts/lib/eval-schemas.js +144 -144
  109. package/scripts/lib/eval-self-correct.js +106 -106
  110. package/scripts/lib/eval-validator.js +185 -185
  111. package/scripts/lib/jaccard-similarity.js +98 -98
  112. package/scripts/lib/longmemeval-runner.js +125 -125
  113. package/scripts/lib/npm-version.js +261 -261
  114. package/scripts/lib/paquetes-conocidos.js +50 -50
  115. package/scripts/lib/prompt-builder.js +264 -264
  116. package/scripts/lib/rrf-fusion.js +175 -175
  117. package/scripts/lib/scoring-instintos.js +277 -277
  118. package/scripts/lib/semantic-search.js +252 -252
  119. package/scripts/limpiar-artefactos-python.js +131 -131
  120. package/scripts/mcp-server/README.md +128 -128
  121. package/scripts/mcp-server/handlers.js +206 -206
  122. package/scripts/migrar-csv-a-array.js +168 -168
  123. package/scripts/migrar-fase-dominio.js +201 -201
  124. package/scripts/publicar.js +511 -511
  125. package/scripts/run-eval.js +141 -141
  126. package/scripts/validar-manifest.js +195 -195
  127. package/scripts/validar-userland-vacio.js +110 -110
  128. package/scripts/verificar-release.js +110 -0
@@ -0,0 +1,191 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * event-channel.js
5
+ *
6
+ * Pub/sub event channel para CommandRelay y otros componentes del gateway.
7
+ *
8
+ * Patrón adaptado de `temp/obsidian-agent-client-master/src/acp/acp-handler.ts`
9
+ * (Set<listeners> con callback unsubscribe). Diferencias:
10
+ * - Zero-deps Node.js (sin RxJS, sin EventEmitter de node — Set nativo).
11
+ * - Backward compat: si no hay listeners, comportamiento idéntico al actual.
12
+ * - Tipos de evento explícitos para evitar typos.
13
+ *
14
+ * Casos de uso en SWL:
15
+ * - CommandRelay emite eventos al recibir/aceptar/rechazar/procesar comandos.
16
+ * - Múltiples adaptadores (Telegram, Discord) pueden suscribirse simultáneamente.
17
+ * - El consumidor /swl:inbox puede mostrar progreso sin polling.
18
+ *
19
+ * Uso:
20
+ * const channel = new EventChannel();
21
+ * const off = channel.on('cmd:queued', (event) => console.log(event));
22
+ * channel.emit({ type: 'cmd:queued', commandId: 'cmd-abc', userId: '123' });
23
+ * off(); // unsubscribe
24
+ *
25
+ * @module gateway/lib/event-channel
26
+ */
27
+
28
+ // ── tipos de evento ──────────────────────────────────────────────────────────
29
+
30
+ /**
31
+ * Tipos válidos de evento. Se usan strings para serializar fácilmente
32
+ * a JSONL si se requiere persistencia (no obligatorio).
33
+ */
34
+ const EVENTS = Object.freeze({
35
+ // Lifecycle de comandos en CommandRelay
36
+ CMD_RECEIVED: 'cmd:received', // mensaje llegó al relay
37
+ CMD_REJECTED: 'cmd:rejected', // rechazado por validación (auth/rate/dedup)
38
+ CMD_QUEUED: 'cmd:queued', // encolado en .planning/inbox/
39
+ CMD_PROCESSED: 'cmd:processed', // marcado como procesado por consumidor
40
+
41
+ // Lifecycle de notificaciones
42
+ NOTIFICATION_SENT: 'notification:sent',
43
+ NOTIFICATION_FAILED: 'notification:failed',
44
+ });
45
+
46
+ // ── implementación ────────────────────────────────────────────────────────────
47
+
48
+ /**
49
+ * Canal de eventos pub/sub con escucha por tipo o wildcard.
50
+ *
51
+ * Mantiene dos estructuras:
52
+ * - listenersByType: Map<eventType, Set<callback>>
53
+ * - wildcardListeners: Set<callback> (escuchan TODOS los eventos)
54
+ *
55
+ * Una excepción en un listener NO interrumpe la propagación a los demás —
56
+ * patrón de aislamiento del mismo origen (acp-handler.ts:47-50).
57
+ */
58
+ class EventChannel {
59
+ constructor() {
60
+ this.listenersByType = new Map();
61
+ this.wildcardListeners = new Set();
62
+ this.errorHandler = null; // opcional: callback para errores en listeners
63
+ }
64
+
65
+ /**
66
+ * Suscribe un listener a un tipo de evento (o '*' para todos).
67
+ *
68
+ * @param {string} eventType - tipo de evento o '*'
69
+ * @param {function} callback - recibe el objeto event
70
+ * @returns {function} función de unsubscribe (idempotente)
71
+ */
72
+ on(eventType, callback) {
73
+ if (typeof callback !== 'function') {
74
+ throw new TypeError('callback debe ser función');
75
+ }
76
+ if (typeof eventType !== 'string' || !eventType) {
77
+ throw new TypeError('eventType debe ser string no vacío');
78
+ }
79
+
80
+ if (eventType === '*') {
81
+ this.wildcardListeners.add(callback);
82
+ return () => this.wildcardListeners.delete(callback);
83
+ }
84
+
85
+ if (!this.listenersByType.has(eventType)) {
86
+ this.listenersByType.set(eventType, new Set());
87
+ }
88
+ const set = this.listenersByType.get(eventType);
89
+ set.add(callback);
90
+ return () => set.delete(callback);
91
+ }
92
+
93
+ /**
94
+ * Suscribe un listener que se invoca UNA sola vez y luego se desuscribe.
95
+ */
96
+ once(eventType, callback) {
97
+ const off = this.on(eventType, (event) => {
98
+ off();
99
+ callback(event);
100
+ });
101
+ return off;
102
+ }
103
+
104
+ /**
105
+ * Emite un evento a todos los listeners suscriptos al tipo + wildcards.
106
+ * El evento debe tener al menos `type`. Se enriquece con `ts` (ISO).
107
+ *
108
+ * @param {object} event - objeto con `type` y datos arbitrarios
109
+ * @returns {number} cantidad de listeners notificados
110
+ */
111
+ emit(event) {
112
+ if (!event || typeof event !== 'object') {
113
+ throw new TypeError('event debe ser objeto');
114
+ }
115
+ if (typeof event.type !== 'string' || !event.type) {
116
+ throw new TypeError('event.type es obligatorio');
117
+ }
118
+
119
+ const enriched = { ts: new Date().toISOString(), ...event };
120
+ let notified = 0;
121
+
122
+ // Listeners específicos del tipo
123
+ const typedSet = this.listenersByType.get(enriched.type);
124
+ if (typedSet) {
125
+ for (const cb of typedSet) {
126
+ notified++;
127
+ this._safeInvoke(cb, enriched);
128
+ }
129
+ }
130
+
131
+ // Listeners wildcard
132
+ for (const cb of this.wildcardListeners) {
133
+ notified++;
134
+ this._safeInvoke(cb, enriched);
135
+ }
136
+
137
+ return notified;
138
+ }
139
+
140
+ /**
141
+ * Cuenta de listeners para un tipo (o total si no se pasa tipo).
142
+ */
143
+ listenerCount(eventType) {
144
+ if (eventType == null) {
145
+ let total = this.wildcardListeners.size;
146
+ for (const set of this.listenersByType.values()) total += set.size;
147
+ return total;
148
+ }
149
+ if (eventType === '*') return this.wildcardListeners.size;
150
+ const set = this.listenersByType.get(eventType);
151
+ return (set ? set.size : 0) + this.wildcardListeners.size;
152
+ }
153
+
154
+ /**
155
+ * Limpia todos los listeners. Útil para tests.
156
+ */
157
+ clear() {
158
+ this.listenersByType.clear();
159
+ this.wildcardListeners.clear();
160
+ }
161
+
162
+ /**
163
+ * Asigna un handler para errores en listeners.
164
+ * Si no se asigna, los errores se silencian (no rompen la propagación).
165
+ */
166
+ onError(handler) {
167
+ if (typeof handler !== 'function') {
168
+ throw new TypeError('handler debe ser función');
169
+ }
170
+ this.errorHandler = handler;
171
+ }
172
+
173
+ // ── internos ────────────────────────────────────────────────────────────────
174
+
175
+ _safeInvoke(cb, event) {
176
+ try {
177
+ cb(event);
178
+ } catch (err) {
179
+ if (this.errorHandler) {
180
+ try { this.errorHandler(err, event); } catch (_) { /* silencioso */ }
181
+ }
182
+ }
183
+ }
184
+ }
185
+
186
+ // ── exports ───────────────────────────────────────────────────────────────────
187
+
188
+ module.exports = {
189
+ EventChannel,
190
+ EVENTS,
191
+ };
@@ -0,0 +1,131 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Gateway Session — Gestión de sesiones por plataforma.
5
+ *
6
+ * Mantiene un mapeo entre usuarios de plataformas externas y sesiones SWL.
7
+ * Permite que un usuario de Telegram tenga una "sesión" persistente con el gateway.
8
+ *
9
+ * Persistencia en .planning/gateway-sessions.json.
10
+ *
11
+ * @module gateway/session
12
+ */
13
+
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+
17
+ const { atomicWriteJSON } = require('../hooks/lib/atomic-write');
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Constantes
21
+ // ---------------------------------------------------------------------------
22
+
23
+ const SESSIONS_FILE = '.planning/gateway-sessions.json';
24
+ const INACTIVITY_MS = 30 * 60 * 1000; // 30 minutos
25
+ const MAX_SESSIONS = 100;
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // API
29
+ // ---------------------------------------------------------------------------
30
+
31
+ /**
32
+ * Obtiene o crea una sesión para un usuario de plataforma.
33
+ *
34
+ * @param {string} baseDir
35
+ * @param {string} platformUserId - Identificador único: "telegram:12345"
36
+ * @returns {object} Sesión con lastActivity actualizada.
37
+ */
38
+ function getOrCreateSession(baseDir, platformUserId) {
39
+ const sessions = _loadSessions(baseDir);
40
+ const now = Date.now();
41
+
42
+ let session = sessions[platformUserId];
43
+
44
+ if (session) {
45
+ // Verificar timeout de inactividad
46
+ if (now - session.lastActivity > INACTIVITY_MS) {
47
+ // Sesión expirada — crear nueva
48
+ session = _newSession(platformUserId);
49
+ } else {
50
+ session.lastActivity = now;
51
+ }
52
+ } else {
53
+ session = _newSession(platformUserId);
54
+ }
55
+
56
+ sessions[platformUserId] = session;
57
+
58
+ // Limitar número total de sesiones (FIFO)
59
+ const keys = Object.keys(sessions);
60
+ if (keys.length > MAX_SESSIONS) {
61
+ // Eliminar las más antiguas
62
+ const sorted = keys.sort((a, b) => sessions[a].lastActivity - sessions[b].lastActivity);
63
+ for (let i = 0; i < keys.length - MAX_SESSIONS; i++) {
64
+ delete sessions[sorted[i]];
65
+ }
66
+ }
67
+
68
+ _saveSessions(baseDir, sessions);
69
+ return session;
70
+ }
71
+
72
+ /**
73
+ * Lista todas las sesiones activas (no expiradas).
74
+ *
75
+ * @param {string} baseDir
76
+ * @returns {object[]}
77
+ */
78
+ function listActiveSessions(baseDir) {
79
+ const sessions = _loadSessions(baseDir);
80
+ const now = Date.now();
81
+
82
+ return Object.entries(sessions)
83
+ .filter(([, s]) => now - s.lastActivity <= INACTIVITY_MS)
84
+ .map(([userId, s]) => ({ userId, ...s }));
85
+ }
86
+
87
+ /**
88
+ * Invalida una sesión específica.
89
+ *
90
+ * @param {string} baseDir
91
+ * @param {string} platformUserId
92
+ */
93
+ function invalidateSession(baseDir, platformUserId) {
94
+ const sessions = _loadSessions(baseDir);
95
+ delete sessions[platformUserId];
96
+ _saveSessions(baseDir, sessions);
97
+ }
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Helpers internos
101
+ // ---------------------------------------------------------------------------
102
+
103
+ function _newSession(platformUserId) {
104
+ const [platform, userId] = platformUserId.split(':');
105
+ return {
106
+ id: `gw-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
107
+ platform,
108
+ userId,
109
+ createdAt: Date.now(),
110
+ lastActivity: Date.now(),
111
+ context: {},
112
+ };
113
+ }
114
+
115
+ function _loadSessions(baseDir) {
116
+ const p = path.join(baseDir, SESSIONS_FILE);
117
+ try {
118
+ if (!fs.existsSync(p)) return {};
119
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
120
+ } catch (_) {
121
+ return {};
122
+ }
123
+ }
124
+
125
+ function _saveSessions(baseDir, sessions) {
126
+ const dir = path.dirname(path.join(baseDir, SESSIONS_FILE));
127
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
128
+ atomicWriteJSON(path.join(baseDir, SESSIONS_FILE), sessions);
129
+ }
130
+
131
+ module.exports = { getOrCreateSession, listActiveSessions, invalidateSession };
@@ -0,0 +1,324 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * webhook-server.js — Servidor HTTP local para webhooks entrantes firmados.
5
+ *
6
+ * Orquesta las tres librerías de las fases anteriores:
7
+ *
8
+ * webhook-verify → valida HMAC SHA-256 (GitHub) o Bearer (generic)
9
+ * rate-limit-ip → token bucket per-IP
10
+ * webhook-dedup → idempotencia por event-id en ledger JSONL
11
+ *
12
+ * Endpoints:
13
+ *
14
+ * POST /webhooks/github — requiere X-Hub-Signature-256 + secret
15
+ * POST /webhooks/generic — requiere Authorization: Bearer + secret
16
+ * GET /healthz — liveness (sin auth)
17
+ * * — 404
18
+ *
19
+ * Pipeline por request:
20
+ *
21
+ * 1. IP allowlist (si está configurada)
22
+ * 2. Rate limit per-IP (barato; aborta antes de HMAC costoso)
23
+ * 3. Lectura de body con cap de tamaño
24
+ * 4. HMAC verify (GitHub) o Bearer verify (generic)
25
+ * 5. Extracción de event-id (X-GitHub-Delivery o X-Request-ID o hash del body)
26
+ * 6. Dedup (rechazo idempotente: 200 OK pero no escribe)
27
+ * 7. Escritura a .planning/inbox/cmd-*.json (schema compatible con /swl:inbox)
28
+ *
29
+ * Diseño:
30
+ *
31
+ * - `http` nativo de Node, sin Express ni Fastify (zero-deps).
32
+ * - Función factory `crearServidor(deps)` que retorna http.Server sin
33
+ * arrancar. El bootstrap CLI (Fase 4) llama `.listen()`. Esto permite
34
+ * tests con puerto efímero `0` y dependencias mock.
35
+ * - Configuración 100 % vía objeto `deps` inyectado — sin lectura directa
36
+ * de process.env (eso lo hace el bootstrap CLI).
37
+ * - Bind por defecto `127.0.0.1` (ADR-0017 decisión #1). Cambiar requiere
38
+ * intencionalidad explícita en el bootstrap.
39
+ *
40
+ * @module gateway/webhook-server
41
+ */
42
+
43
+ const http = require('http');
44
+ const crypto = require('crypto');
45
+ const fs = require('fs');
46
+ const path = require('path');
47
+
48
+ const { verifyGithubSignature, verifyBearer } = require('../hooks/lib/webhook-verify');
49
+
50
+ const HEADER_GITHUB_SIG = 'x-hub-signature-256';
51
+ const HEADER_GITHUB_DELIVERY = 'x-hub-delivery';
52
+ const HEADER_GITHUB_DELIVERY_ALT = 'x-github-delivery';
53
+ const HEADER_GITHUB_EVENT = 'x-github-event';
54
+ const HEADER_AUTHORIZATION = 'authorization';
55
+ const HEADER_REQUEST_ID = 'x-request-id';
56
+
57
+ /**
58
+ * Crea un servidor HTTP listo para arrancar con `.listen()`.
59
+ *
60
+ * @param {object} deps Dependencias inyectadas.
61
+ * @param {string} deps.inboxDir Ruta absoluta al directorio inbox.
62
+ * @param {object} deps.dedup Instancia de WebhookDedup.
63
+ * @param {object} deps.rateLimiter Instancia de RateLimiterIP.
64
+ * @param {string|null} deps.githubSecret Secreto HMAC (null = endpoint deshabilitado).
65
+ * @param {string|null} deps.bearerSecret Token Bearer (null = endpoint deshabilitado).
66
+ * @param {number} deps.maxPayloadBytes Tope de tamaño del body.
67
+ * @param {Array<string>|null} deps.allowIps Lista de IPs permitidas (null = todas).
68
+ * @param {function} [deps.logger] Función de logging (default: console.error).
69
+ * @returns {http.Server}
70
+ */
71
+ function crearServidor(deps) {
72
+ validarDeps(deps);
73
+ const logger = deps.logger || ((nivel, msg, extra) => {
74
+ const linea = JSON.stringify({ nivel, msg, ...(extra || {}), ts: new Date().toISOString() });
75
+ if (nivel === 'error' || nivel === 'warn') console.error(linea);
76
+ else console.log(linea);
77
+ });
78
+
79
+ const server = http.createServer((req, res) => {
80
+ manejarRequest(req, res, deps, logger).catch(err => {
81
+ logger('error', 'manejarRequest threw', { err: err.message });
82
+ enviarRespuesta(res, 500, { error: 'internal-error' });
83
+ });
84
+ });
85
+
86
+ return server;
87
+ }
88
+
89
+ async function manejarRequest(req, res, deps, logger) {
90
+ const url = req.url || '';
91
+ const metodo = req.method || 'GET';
92
+ const ip = obtenerIp(req);
93
+
94
+ // 1. Healthz (sin auth, sin rate limit)
95
+ if (metodo === 'GET' && url === '/healthz') {
96
+ return enviarRespuesta(res, 200, { status: 'ok' });
97
+ }
98
+
99
+ // 2. Solo POST para endpoints de webhook
100
+ if (metodo !== 'POST') {
101
+ return enviarRespuesta(res, 404, { error: 'not-found' });
102
+ }
103
+
104
+ // 3. Path conocido
105
+ const proveedor = identificarProveedor(url);
106
+ if (!proveedor) {
107
+ return enviarRespuesta(res, 404, { error: 'not-found' });
108
+ }
109
+
110
+ // 4. IP allowlist
111
+ if (deps.allowIps && deps.allowIps.length > 0 && !ipPermitida(ip, deps.allowIps)) {
112
+ logger('warn', 'ip-blocked', { ip, url });
113
+ return enviarRespuesta(res, 403, { error: 'forbidden' });
114
+ }
115
+
116
+ // 5. Rate limit
117
+ if (!deps.rateLimiter.permite(ip)) {
118
+ logger('warn', 'rate-limit', { ip, url });
119
+ return enviarRespuesta(res, 429, { error: 'rate-limit-exceeded' });
120
+ }
121
+
122
+ // 6. Endpoint habilitado (secreto configurado)
123
+ const secretoRequerido = proveedor === 'github' ? deps.githubSecret : deps.bearerSecret;
124
+ if (!secretoRequerido) {
125
+ logger('warn', 'endpoint-disabled', { proveedor });
126
+ return enviarRespuesta(res, 404, { error: 'not-found' });
127
+ }
128
+
129
+ // 7. Leer body con cap
130
+ let body;
131
+ try {
132
+ body = await leerBody(req, deps.maxPayloadBytes);
133
+ } catch (err) {
134
+ if (err.code === 'PAYLOAD_TOO_LARGE') {
135
+ return enviarRespuesta(res, 413, { error: 'payload-too-large' });
136
+ }
137
+ logger('error', 'read-body-error', { err: err.message });
138
+ return enviarRespuesta(res, 400, { error: 'bad-request' });
139
+ }
140
+
141
+ // 8. Verify firma
142
+ const autorizado = proveedor === 'github'
143
+ ? verifyGithubSignature(body, req.headers[HEADER_GITHUB_SIG], deps.githubSecret)
144
+ : verifyBearer(req.headers[HEADER_AUTHORIZATION], deps.bearerSecret);
145
+
146
+ if (!autorizado) {
147
+ logger('warn', 'unauthorized', { ip, proveedor });
148
+ return enviarRespuesta(res, 401, { error: 'unauthorized' });
149
+ }
150
+
151
+ // 9. Extraer event-id
152
+ const eventId = extraerEventId(req, body, proveedor);
153
+
154
+ // 10. Dedup
155
+ if (deps.dedup.yaVisto(eventId)) {
156
+ logger('info', 'duplicate', { proveedor, eventId });
157
+ return enviarRespuesta(res, 200, { status: 'duplicate', event_id: eventId });
158
+ }
159
+
160
+ // 11. Escribir al inbox
161
+ let bodyParseado;
162
+ try {
163
+ bodyParseado = JSON.parse(body.toString('utf8'));
164
+ } catch (_) {
165
+ // Body no es JSON — guardarlo como string
166
+ bodyParseado = body.toString('utf8');
167
+ }
168
+
169
+ const eventoGithub = req.headers[HEADER_GITHUB_EVENT];
170
+ const cmdId = `cmd-${Date.now().toString(36)}-${crypto.randomBytes(3).toString('hex')}`;
171
+ const cmd = {
172
+ id: cmdId,
173
+ platform: 'webhook',
174
+ userId: proveedor,
175
+ userName: `webhook-${proveedor}`,
176
+ texto: resumirEvento(proveedor, eventoGithub, bodyParseado),
177
+ recibidoEn: new Date().toISOString(),
178
+ estado: 'pending',
179
+ chatId: null,
180
+ comando: null,
181
+ webhookProvider: proveedor,
182
+ webhookEventId: eventId,
183
+ webhookEventType: eventoGithub || null,
184
+ webhookBody: bodyParseado,
185
+ };
186
+
187
+ try {
188
+ fs.mkdirSync(deps.inboxDir, { recursive: true });
189
+ fs.writeFileSync(path.join(deps.inboxDir, `${cmdId}.json`), JSON.stringify(cmd, null, 2), 'utf8');
190
+ deps.dedup.registrar(eventId, proveedor);
191
+ } catch (err) {
192
+ logger('error', 'inbox-write-error', { err: err.message });
193
+ return enviarRespuesta(res, 500, { error: 'write-error' });
194
+ }
195
+
196
+ logger('info', 'accepted', { proveedor, eventId, cmdId });
197
+ return enviarRespuesta(res, 200, { status: 'accepted', event_id: eventId, cmd_id: cmdId });
198
+ }
199
+
200
+ // ---------------------------------------------------------------------------
201
+ // Helpers
202
+ // ---------------------------------------------------------------------------
203
+
204
+ function validarDeps(deps) {
205
+ if (!deps || typeof deps !== 'object') throw new Error('webhook-server: deps requerido');
206
+ if (!deps.inboxDir || typeof deps.inboxDir !== 'string') {
207
+ throw new Error('webhook-server: deps.inboxDir requerido');
208
+ }
209
+ if (!deps.dedup || typeof deps.dedup.yaVisto !== 'function') {
210
+ throw new Error('webhook-server: deps.dedup inválido');
211
+ }
212
+ if (!deps.rateLimiter || typeof deps.rateLimiter.permite !== 'function') {
213
+ throw new Error('webhook-server: deps.rateLimiter inválido');
214
+ }
215
+ if (!Number.isFinite(deps.maxPayloadBytes) || deps.maxPayloadBytes <= 0) {
216
+ throw new Error('webhook-server: deps.maxPayloadBytes requerido y positivo');
217
+ }
218
+ }
219
+
220
+ function identificarProveedor(url) {
221
+ // url puede traer query string; comparar solo el path
222
+ const pathOnly = url.split('?')[0];
223
+ if (pathOnly === '/webhooks/github') return 'github';
224
+ if (pathOnly === '/webhooks/generic') return 'generic';
225
+ return null;
226
+ }
227
+
228
+ function obtenerIp(req) {
229
+ // En este servidor sin proxy, socket.remoteAddress es la IP real.
230
+ // Si se pone detrás de reverse proxy, hay que leer X-Forwarded-For — pero
231
+ // eso es responsabilidad del proxy y requiere config explícita (no implementado).
232
+ const ip = req.socket && req.socket.remoteAddress;
233
+ if (!ip) return '0.0.0.0';
234
+ // Normalizar IPv4-mapped IPv6 (::ffff:127.0.0.1 → 127.0.0.1)
235
+ return ip.startsWith('::ffff:') ? ip.slice(7) : ip;
236
+ }
237
+
238
+ function ipPermitida(ip, allowIps) {
239
+ // Comparación exacta. CIDR queda fuera de scope de Fase 3 — si se necesita,
240
+ // el bootstrap usa una librería de CIDR matching. Aquí: equality match.
241
+ return allowIps.includes(ip);
242
+ }
243
+
244
+ function leerBody(req, maxBytes) {
245
+ return new Promise((resolve, reject) => {
246
+ const chunks = [];
247
+ let total = 0;
248
+ let abortado = false;
249
+
250
+ req.on('data', chunk => {
251
+ if (abortado) return;
252
+ total += chunk.length;
253
+ if (total > maxBytes) {
254
+ abortado = true;
255
+ // Pausar la lectura — NO destruir el socket. Destruir cerraría la
256
+ // conexión antes de que el handler pueda enviar la respuesta 413,
257
+ // causando ECONNRESET en el cliente. Con pause, el socket queda
258
+ // vivo, res.end() envía la respuesta y luego Node cierra el socket.
259
+ req.pause();
260
+ const err = new Error('payload too large');
261
+ err.code = 'PAYLOAD_TOO_LARGE';
262
+ return reject(err);
263
+ }
264
+ chunks.push(chunk);
265
+ });
266
+ req.on('end', () => {
267
+ if (!abortado) resolve(Buffer.concat(chunks));
268
+ });
269
+ req.on('error', err => {
270
+ if (!abortado) reject(err);
271
+ });
272
+ });
273
+ }
274
+
275
+ function extraerEventId(req, body, proveedor) {
276
+ if (proveedor === 'github') {
277
+ const id = req.headers[HEADER_GITHUB_DELIVERY] || req.headers[HEADER_GITHUB_DELIVERY_ALT];
278
+ if (id) return String(id);
279
+ }
280
+ const reqId = req.headers[HEADER_REQUEST_ID];
281
+ if (reqId) return String(reqId);
282
+ // Fallback: hash del body. Mismo body = mismo id = idempotente.
283
+ return 'sha256-' + crypto.createHash('sha256').update(body).digest('hex');
284
+ }
285
+
286
+ function resumirEvento(proveedor, eventoGithub, body) {
287
+ if (proveedor === 'github' && eventoGithub) {
288
+ // Resumen útil para que /swl:inbox decida qué hacer
289
+ const ref = body && typeof body === 'object' ? body.ref : null;
290
+ const action = body && typeof body === 'object' ? body.action : null;
291
+ if (ref) return `github:${eventoGithub} ${ref}`;
292
+ if (action) return `github:${eventoGithub} ${action}`;
293
+ return `github:${eventoGithub}`;
294
+ }
295
+ if (proveedor === 'generic') {
296
+ if (body && typeof body === 'object' && body.command) {
297
+ return `generic:${String(body.command)}`;
298
+ }
299
+ const raw = typeof body === 'string' ? body : JSON.stringify(body);
300
+ return `generic:${raw.slice(0, 200)}`;
301
+ }
302
+ return `${proveedor}:event`;
303
+ }
304
+
305
+ function enviarRespuesta(res, status, body) {
306
+ const payload = JSON.stringify(body);
307
+ res.writeHead(status, {
308
+ 'Content-Type': 'application/json',
309
+ 'Content-Length': Buffer.byteLength(payload),
310
+ });
311
+ res.end(payload);
312
+ }
313
+
314
+ module.exports = {
315
+ crearServidor,
316
+ // exportar helpers para tests de unidad si crece la necesidad
317
+ _internals: {
318
+ identificarProveedor,
319
+ obtenerIp,
320
+ ipPermitida,
321
+ extraerEventId,
322
+ resumirEvento,
323
+ },
324
+ };