CULTIVA IA Platform β€” CMA Webhook Handler
webhook-handler.js Β· Generated with webhooks-agentes-gestionados-claude skill
Endpoint activo
⚑ Generated with: webhooks-agentes-gestionados-claude · hookdeck/webhook-skills
8
Eventos CMA cubiertos
3
Integraciones activas
5min
Ventana anti-replay
0
Vulnerabilidades (grep+LLM)
Flujo de verificaciΓ³n
🌐
Anthropic
envia POST
β†’
πŸ“¦
express.raw()
cuerpo crudo
β†’
πŸ”
Verificar
HMAC-SHA256
β†’
⏱
Anti-replay
< 5 min
β†’
πŸ”€
Switch
event.data.type
β†’
πŸ—„
Update
jobs DB
β†’
πŸ’¬
Notificar
Slack
β†’
βœ…
200 OK
{received: true}
webhook-handler.js
πŸ“„ webhook-handler.js JavaScript Β· Node 20
πŸ“‹ Copiar βœ“ ProducciΓ³n
// Generated with: claude-managed-agents-webhooks skill
// https://github.com/hookdeck/webhook-skills
// Cliente: CULTIVA IA Platform β€” orquestaciΓ³n de agentes SEO/contenido

const express = require('express');
const crypto  = require('crypto');
const https   = require('https');
const { Pool } = require('pg');

const app  = express();
const db   = new Pool({ connectionString: process.env.DATABASE_URL });
const PORT = process.env.PORT || 3000;

// ─────────────────────────────────────────────────────────
// 1. VerificaciΓ³n de firma Standard Webhooks (HMAC-SHA256)
//    Spec: https://www.standardwebhooks.com/
// ─────────────────────────────────────────────────────────
function verifyClaudeSignature(payload, webhookId, webhookTimestamp, webhookSignature, secret) {
  if (!webhookId || !webhookTimestamp || !webhookSignature || !webhookSignature.includes(',')) {
    return false;
  }

  // ProtecciΓ³n anti-replay: rechazar eventos con mΓ‘s de 5 min de antigΓΌedad
  const currentTime   = Math.floor(Date.now() / 1000);
  const timestampDiff = currentTime - parseInt(webhookTimestamp);
  if (timestampDiff > 300 || timestampDiff < -300) {
    return false;
  }

  // Contenido firmado: "{webhookId}.{webhookTimestamp}.{rawBody}"
  const payloadStr    = payload instanceof Buffer ? payload.toString('utf8') : payload;
  const signedContent = `${webhookId}.${webhookTimestamp}.${payloadStr}`;

  // whsec_ es prefijo de una clave base64 de 32 bytes
  const secretKey   = secret.startsWith('whsec_') ? secret.slice(6) : secret;
  const secretBytes = Buffer.from(secretKey, 'base64');

  const expectedSig = crypto
    .createHmac('sha256', secretBytes)
    .update(signedContent, 'utf8')
    .digest('base64');

  // Soporta mΓΊltiples firmas "v1,sig1 v1,sig2" (rotaciΓ³n de claves)
  return webhookSignature.split(' ').some(pair => {
    const [version, signature] = pair.split(',');
    if (version !== 'v1' || !signature) return false;
    try {
      return crypto.timingSafeEqual(
        Buffer.from(signature),
        Buffer.from(expectedSig)
      );
    } catch { return false; }
  });
}

// ─────────────────────────────────────────────────────────
// 2. Helpers: PostgreSQL jobs + Slack notifications
// ─────────────────────────────────────────────────────────
async function updateJobStatus(sessionId, status, meta = {}) {
  await db.query(
    `UPDATE jobs
       SET status = $1, updated_at = NOW(), meta = meta || $2
     WHERE cma_session_id = $3`,
    [status, JSON.stringify(meta), sessionId]
  );
}

async function notifySlack(message, color = 'good') {
  const body = JSON.stringify({
    attachments: [{ color: color, text: message, footer: 'CULTIVA IA Β· CMA Webhooks' }]
  });
  await new Promise((resolve, reject) => {
    const url = new URL(process.env.SLACK_WEBHOOK_URL);
    const req = https.request({ hostname: url.hostname, path: url.pathname,
      method: 'POST', headers: { 'Content-Type': 'application/json' }
    }, res => res.on('data', () => {}).on('end', resolve));
    req.on('error', reject);
    req.end(body);
  });
}

// ─────────────────────────────────────────────────────────
// 3. Endpoint webhook β€” DEBE usar express.raw()
//    Si usas express.json() el body se parsea y la firma falla
// ─────────────────────────────────────────────────────────
app.post(
  '/webhooks/claude-managed-agents',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const webhookId        = req.headers['webhook-id'];
    const webhookTimestamp = req.headers['webhook-timestamp'];
    const webhookSignature = req.headers['webhook-signature'];

    if (!verifyClaudeSignature(
      req.body, webhookId, webhookTimestamp, webhookSignature,
      process.env.ANTHROPIC_WEBHOOK_SIGNING_KEY
    )) {
      console.warn('[CMA] Firma invΓ‘lida β€” posible replay o clave incorrecta');
      return res.status(400).json({ error: 'Invalid signature' });
    }

    const event     = JSON.parse(req.body.toString());
    const eventType = event.data?.type;   // ← data.type, NO top-level type
    const sessionId = event.data?.id;
    console.log(`[CMA] ${eventType} β†’ ${sessionId}`);

    switch (eventType) {

      // Agente terminΓ³ su turno, espera input (Γ©xito mΓ‘s frecuente)
      case 'session.status_idled':
        await updateJobStatus(sessionId, 'idled');
        await notifySlack(
          `:white_check_mark: Agente inactivo y listo\n*SesiΓ³n:* ${sessionId}`,
          'good'
        );
        break;

      // Error terminal β€” requiere intervenciΓ³n manual
      case 'session.status_terminated':
        await updateJobStatus(sessionId, 'terminated', { terminated_at: new Date().toISOString() });
        await notifySlack(
          `:x: SesiΓ³n terminada con error\n*SesiΓ³n:* ${sessionId}`,
          'danger'
        );
        break;

      // Agente coordinador abriΓ³ un subagente
      case 'session.thread_created':
        console.log(`[CMA] Nuevo hilo multiagente: ${sessionId}`);
        await updateJobStatus(sessionId, 'running_multiagent');
        break;

      // Credencial OAuth de vault no pudo refrescarse β†’ alerta ops
      case 'vault_credential.refresh_failed':
        await notifySlack(
          `:warning: Vault credential refresh FAILED\n*ID:* ${sessionId}\nRevisa el vault en Anthropic Console`,
          'warning'
        );
        break;

      case 'session.status_run_started':
        await updateJobStatus(sessionId, 'running');
        break;

      case 'session.thread_idled':
      case 'session.thread_terminated':
        console.log(`[CMA] Thread lifecycle: ${eventType} β†’ ${sessionId}`);
        break;

      default:
        console.log(`[CMA] Evento no manejado: ${eventType}`);
    }

    return res.status(200).json({ received: true });
  }
);

// Health check
app.get('/health', (_req, res) => res.json({ status: 'ok', service: 'cultiva-cma-webhooks' }));

app.listen(PORT, () =>
  console.log(`[CMA] Webhook handler escuchando en :${PORT}/webhooks/claude-managed-agents`)
);
Eventos manejados
session.status_idled
El agente terminΓ³ su turno y espera input del usuario o aprobaciΓ³n de herramienta.
πŸ—„ jobs.status = idled  πŸ’¬ Slack OK
session.status_terminated
La sesiΓ³n alcanzΓ³ un error terminal. Requiere intervenciΓ³n manual.
πŸ—„ jobs.status = terminated  πŸ”΄ Slack DANGER
session.thread_created
El coordinador abriΓ³ un hilo de subagente (arquitectura multiagente).
πŸ—„ jobs.status = running_multiagent
vault_credential.refresh_failed
OAuth MCP no pudo refrescar el token. La herramienta quedarΓ‘ sin acceso.
⚠ Slack WARNING al equipo ops
session.status_run_started
El agente iniciΓ³ ejecuciΓ³n; se activa en cada transiciΓ³n a estado running.
πŸ—„ jobs.status = running
session.thread_idled / thread_terminated
Ciclo de vida de hilos secundarios. Log estructurado para trazabilidad.
πŸ“‹ console.log estructurado
Variables de entorno
πŸ”‘
ANTHROPIC_WEBHOOK_SIGNING_KEY
whsec_[base64-32bytes] β€” Console β†’ Webhooks
πŸ€–
ANTHROPIC_API_KEY
sk-ant-api03-... β€” para client.beta.sessions.retrieve()
πŸ—„
DATABASE_URL
postgres://user:pass@host:5432/cultiva_platform
πŸ’¬
SLACK_WEBHOOK_URL
https://hooks.slack.com/services/T.../B.../...
Desarrollo local
πŸ’»Terminalbash
# Tunnel sin cuenta (Hookdeck CLI)
npx hookdeck-cli listen 3000 claude-managed-agents \
  --path /webhooks/claude-managed-agents

# Resultado: URL pΓΊblica β†’ https://events.hookdeck.com/e/src_...
# Pegar esa URL en Anthropic Console β†’ Settings β†’ Webhooks β†’ Add endpoint