@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.
- package/CLAUDE.md +12 -4
- package/README.md +1 -1
- package/bin/swl-mcp-server.js +187 -187
- package/bin/swl-webhook-server.js +198 -0
- package/comandos/swl/.evolved.json +22 -22
- package/comandos/swl/adoptar-proyecto.md +21 -1
- package/comandos/swl/claudemd.md +14 -1
- package/comandos/swl/contribuir.md +233 -233
- package/comandos/swl/exportar-vault.md +108 -0
- package/comandos/swl/nuevo-proyecto.md +24 -2
- package/gateway/adapters/base.js +109 -0
- package/gateway/adapters/discord.js +167 -0
- package/gateway/adapters/email.js +221 -0
- package/gateway/adapters/slack.js +192 -0
- package/gateway/adapters/telegram.js +183 -0
- package/gateway/adapters/webhook.js +113 -0
- package/gateway/adapters/whatsapp.js +214 -0
- package/gateway/agent-executor.js +322 -0
- package/gateway/command-relay.js +271 -0
- package/gateway/cron/jobs.js +263 -0
- package/gateway/cron/scheduler.js +322 -0
- package/gateway/cron/store.js +335 -0
- package/gateway/index.js +320 -0
- package/gateway/lib/event-channel.js +191 -0
- package/gateway/session.js +131 -0
- package/gateway/webhook-server.js +324 -0
- package/habilidades/backend-production-resilience/SKILL.md +288 -288
- package/habilidades/benchmark-memoria/SKILL.md +186 -186
- package/habilidades/build-errors-nextjs/SKILL.md +55 -1
- package/habilidades/diagrama-arquitectura/assets/template.html +276 -276
- package/habilidades/doubt-driven-review/SKILL.md +171 -171
- package/habilidades/doubt-driven-review/recursos/EXAMPLES.md +130 -130
- package/habilidades/eval-framework/SKILL.md +212 -212
- package/habilidades/extractor-de-aprendizajes/SKILL.md +20 -10
- package/habilidades/harness-claude-code/SKILL.md +299 -299
- package/habilidades/infra-github-actions/SKILL.md +166 -166
- package/habilidades/legacy-code-rescue/SKILL.md +267 -267
- package/habilidades/manejo-errores/.evolved.json +8 -8
- package/habilidades/meta-skills-estandar/recursos/convencion-examples.md +93 -93
- package/habilidades/meta-skills-estandar/recursos/skills-as-agents.md +163 -163
- package/habilidades/nextjs-testing/SKILL.md +89 -5
- package/habilidades/node-experto/SKILL.md +37 -1
- package/habilidades/patrones-python/SKILL.md +229 -229
- package/habilidades/patrones-python/recursos/patrones-avanzados.md +469 -469
- package/habilidades/planear-fase/SKILL.md +319 -319
- package/habilidades/react-experto/SKILL.md +45 -4
- package/habilidades/release-semver/.evolved.json +8 -8
- package/habilidades/tdd-workflow/SKILL.md +36 -4
- package/habilidades/testing-python/SKILL.md +340 -340
- package/hooks/claudemd-bloat-detector.js +161 -161
- package/hooks/inyeccion-contexto.js +8 -3
- package/hooks/lib/agent-routing.js +107 -107
- package/hooks/lib/auto-consolidator.js +335 -335
- package/hooks/lib/error-classifier.js +308 -308
- package/hooks/lib/merkle-audit.js +96 -96
- package/hooks/lib/provenance-tracker.js +191 -191
- package/hooks/lib/rate-limit-ip.js +177 -0
- package/hooks/lib/rate-limit-tracker.js +253 -253
- package/hooks/lib/resource-quota.js +122 -122
- package/hooks/lib/retry-jitter.js +165 -165
- package/hooks/lib/skill-auditor.js +588 -588
- package/hooks/lib/sync-status.js +228 -228
- package/hooks/lib/taint-tracker.js +107 -107
- package/hooks/lib/text-similarity.js +241 -241
- package/hooks/lib/toon-compressor.js +245 -245
- package/hooks/lib/webhook-dedup.js +184 -0
- package/hooks/lib/webhook-verify.js +123 -0
- package/hooks/proteccion-rutas.js +120 -15
- package/hooks/registro-turnos.js +209 -209
- package/hooks/sugerir-regenerar-inventario.js +170 -170
- package/hooks/validar-formato-post-subagente.js +140 -140
- package/hooks/validar-memoria-hook.js +218 -218
- package/instintos/prompt-appendices.yaml +57 -57
- package/manifiestos/agent-output-schemas.json +57 -57
- package/manifiestos/modulos.json +1 -0
- package/manifiestos/skills-lock.json +34 -34
- package/package.json +5 -3
- package/plantillas/auditor-veto-template.md +105 -105
- package/plantillas/github-workflows/README.md +47 -47
- package/plantillas/github-workflows/release-please.yml +44 -44
- package/plantillas/github-workflows/swl-ci.yml +107 -107
- package/plantillas/github-workflows/swl-security.yml +51 -51
- package/plugin.json +1 -1
- package/reglas/analisis-previo-tareas-grandes.md +172 -172
- package/reglas/arreglar-al-detectar.md +147 -147
- package/reglas/fragmentos-compartidos.md +152 -152
- package/reglas/harness-claude-code.md +213 -213
- package/reglas/usar-context7.md +226 -226
- package/reglas/usar-sistema-swl.md +251 -0
- package/schemas/diary-entry.schema.json +80 -80
- package/scripts/benchmark-memoria.js +167 -167
- package/scripts/comandos/skills.js +251 -2
- package/scripts/configurar-branch-protection.js +418 -418
- package/scripts/detectar-aprendizajes-duplicados.js +151 -151
- package/scripts/field-report.js +199 -199
- package/scripts/generar-checklists-consolidados.js +273 -273
- package/scripts/generar-inventario.js +420 -420
- package/scripts/generar-matriz-lenguajes.js +271 -271
- package/scripts/lib/artefactos-python.js +43 -43
- package/scripts/lib/benchmark-metrics.js +160 -160
- package/scripts/lib/budget-enforcer.js +252 -252
- package/scripts/lib/configurar-ci.js +380 -380
- package/scripts/lib/contadores-inventario.js +217 -217
- package/scripts/lib/detectar-stack-detallado.js +307 -307
- package/scripts/lib/diary-entry.js +234 -234
- package/scripts/lib/eval-metrics-store.js +218 -218
- package/scripts/lib/eval-quality.js +171 -171
- package/scripts/lib/eval-schemas.js +144 -144
- package/scripts/lib/eval-self-correct.js +106 -106
- package/scripts/lib/eval-validator.js +185 -185
- package/scripts/lib/jaccard-similarity.js +98 -98
- package/scripts/lib/longmemeval-runner.js +125 -125
- package/scripts/lib/npm-version.js +261 -261
- package/scripts/lib/paquetes-conocidos.js +50 -50
- package/scripts/lib/prompt-builder.js +264 -264
- package/scripts/lib/rrf-fusion.js +175 -175
- package/scripts/lib/scoring-instintos.js +277 -277
- package/scripts/lib/semantic-search.js +252 -252
- package/scripts/limpiar-artefactos-python.js +131 -131
- package/scripts/mcp-server/README.md +128 -128
- package/scripts/mcp-server/handlers.js +206 -206
- package/scripts/migrar-csv-a-array.js +168 -168
- package/scripts/migrar-fase-dominio.js +201 -201
- package/scripts/publicar.js +511 -511
- package/scripts/run-eval.js +141 -141
- package/scripts/validar-manifest.js +195 -195
- package/scripts/validar-userland-vacio.js +110 -110
- 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
|
+
};
|