{"version":3,"file":"webhook-server.mjs","names":[],"sources":["../../../src/services/webhook-server.ts"],"sourcesContent":["/**\n * Webhook Server — inbound HTTP server for external event ingestion.\n *\n * Accepts webhooks from external services (GitHub, Stripe, monitoring, etc.)\n * and converts them into event bus events that can trigger plan workflows.\n *\n * Security:\n * - Disabled by default: only starts if OPENCLAWNCH_WEBHOOK_PORT is set\n * - Binds to localhost (127.0.0.1) by default — set OPENCLAWNCH_WEBHOOK_HOST=0.0.0.0 for external\n * - Per-route HMAC-SHA256 signature verification (secret per webhook)\n * - Rate limiting: per-IP and per-route\n * - Payload size cap: 64KB default\n * - No direct tool execution — only fires events on the event bus\n */\n\nimport { createServer, type Server, type IncomingMessage, type ServerResponse } from 'node:http';\nimport { createHmac, timingSafeEqual } from 'node:crypto';\nimport { getWebhookRoutes, type WebhookRoute } from './webhook-routes.js';\n\n// ─── Types ──────────────────────────────────────────────────────────────\n\nexport interface WebhookServerConfig {\n  port: number;\n  host: string;\n  maxPayloadBytes: number;\n  rateLimitPerMinute: number;\n}\n\nexport interface WebhookEvent {\n  type: 'webhook_received';\n  route: string;\n  source: string;\n  payload: unknown;\n  headers: Record<string, string>;\n  receivedAt: number;\n}\n\ntype WebhookEventHandler = (event: WebhookEvent) => void | Promise<void>;\n\n// ─── Rate Limiter ───────────────────────────────────────────────────────\n\nclass RateLimiter {\n  private hits = new Map<string, number[]>();\n  private windowMs = 60_000;\n  private maxPerWindow: number;\n\n  constructor(maxPerMinute: number) {\n    this.maxPerWindow = maxPerMinute;\n  }\n\n  check(key: string): boolean {\n    const now = Date.now();\n    const timestamps = this.hits.get(key) ?? [];\n    const recent = timestamps.filter(t => now - t < this.windowMs);\n\n    if (recent.length >= this.maxPerWindow) return false;\n\n    recent.push(now);\n    this.hits.set(key, recent);\n    return true;\n  }\n\n  /** Clean up expired entries. */\n  prune(): void {\n    const now = Date.now();\n    for (const [key, timestamps] of this.hits.entries()) {\n      const recent = timestamps.filter(t => now - t < this.windowMs);\n      if (recent.length === 0) {\n        this.hits.delete(key);\n      } else {\n        this.hits.set(key, recent);\n      }\n    }\n  }\n}\n\n// ─── HMAC Verification ──────────────────────────────────────────────────\n\nfunction verifyHmac(payload: string, signature: string, secret: string): boolean {\n  try {\n    // Support common signature formats:\n    // \"sha256=abc123\" (GitHub), \"abc123\" (raw), \"v0=abc123\" (Slack)\n    let algo = 'sha256';\n    let sig = signature;\n\n    if (signature.startsWith('sha256=')) {\n      sig = signature.slice(7);\n    } else if (signature.startsWith('sha1=')) {\n      algo = 'sha1';\n      sig = signature.slice(5);\n    } else if (signature.startsWith('v0=')) {\n      sig = signature.slice(3);\n    }\n\n    const expected = createHmac(algo, secret).update(payload).digest('hex');\n    const sigBuf = Buffer.from(sig, 'hex');\n    const expectedBuf = Buffer.from(expected, 'hex');\n\n    if (sigBuf.length !== expectedBuf.length) return false;\n    return timingSafeEqual(sigBuf, expectedBuf);\n  } catch {\n    return false;\n  }\n}\n\n// ─── Server ─────────────────────────────────────────────────────────────\n\nexport class WebhookServer {\n  private server: Server | null = null;\n  private config: WebhookServerConfig;\n  private rateLimiter: RateLimiter;\n  private pruneInterval: ReturnType<typeof setInterval> | null = null;\n  private handler: WebhookEventHandler | null = null;\n\n  constructor(config?: Partial<WebhookServerConfig>) {\n    this.config = {\n      port: config?.port ?? parseInt(process.env.OPENCLAWNCH_WEBHOOK_PORT ?? '0', 10),\n      host: config?.host ?? (process.env.OPENCLAWNCH_WEBHOOK_HOST ?? '127.0.0.1'),\n      maxPayloadBytes: config?.maxPayloadBytes ?? 65_536, // 64KB\n      rateLimitPerMinute: config?.rateLimitPerMinute ?? 60,\n    };\n    this.rateLimiter = new RateLimiter(this.config.rateLimitPerMinute);\n  }\n\n  /** Register the event handler for incoming webhooks. */\n  onEvent(handler: WebhookEventHandler): void {\n    this.handler = handler;\n  }\n\n  /** Start the server. Returns false if port is not configured. */\n  async start(): Promise<boolean> {\n    if (this.config.port <= 0) return false;\n    if (this.server) return true; // already running\n\n    return new Promise((resolve) => {\n      this.server = createServer((req, res) => this.handleRequest(req, res));\n\n      this.server.listen(this.config.port, this.config.host, () => {\n        // Prune rate limiter every 5 minutes\n        this.pruneInterval = setInterval(() => this.rateLimiter.prune(), 300_000);\n        resolve(true);\n      });\n\n      this.server.on('error', () => {\n        resolve(false);\n      });\n    });\n  }\n\n  /** Stop the server. */\n  async stop(): Promise<void> {\n    if (this.pruneInterval) {\n      clearInterval(this.pruneInterval);\n      this.pruneInterval = null;\n    }\n    return new Promise((resolve) => {\n      if (!this.server) { resolve(); return; }\n      this.server.close(() => {\n        this.server = null;\n        resolve();\n      });\n    });\n  }\n\n  isRunning(): boolean {\n    return this.server !== null && this.server.listening;\n  }\n\n  getConfig(): WebhookServerConfig {\n    return { ...this.config };\n  }\n\n  // ── Request Handler ─────────────────────────────────────────────────\n\n  private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {\n    // Only accept POST\n    if (req.method !== 'POST') {\n      res.writeHead(405, { 'Content-Type': 'text/plain' });\n      res.end('Method Not Allowed');\n      return;\n    }\n\n    // Rate limit by IP\n    const ip = req.socket.remoteAddress ?? 'unknown';\n    if (!this.rateLimiter.check(`ip:${ip}`)) {\n      res.writeHead(429, { 'Content-Type': 'text/plain' });\n      res.end('Too Many Requests');\n      return;\n    }\n\n    // Parse URL path — strip leading /webhook/ prefix if present\n    const path = (req.url ?? '/').replace(/^\\/webhook\\/?/, '/').replace(/\\/$/, '') || '/';\n\n    // Rate limit by route\n    if (!this.rateLimiter.check(`route:${path}`)) {\n      res.writeHead(429, { 'Content-Type': 'text/plain' });\n      res.end('Too Many Requests');\n      return;\n    }\n\n    // Look up route\n    const routes = getWebhookRoutes();\n    const route = routes.getByPath(path);\n    if (!route || !route.enabled) {\n      res.writeHead(404, { 'Content-Type': 'text/plain' });\n      res.end('Not Found');\n      return;\n    }\n\n    // Read body with size limit\n    const body = await this.readBody(req);\n    if (body === null) {\n      res.writeHead(413, { 'Content-Type': 'text/plain' });\n      res.end('Payload Too Large');\n      return;\n    }\n\n    // Verify HMAC signature\n    if (route.secret) {\n      const sigHeader =\n        req.headers['x-hub-signature-256'] ??    // GitHub\n        req.headers['x-hub-signature'] ??         // GitHub (SHA-1)\n        req.headers['x-signature'] ??             // Generic\n        req.headers['stripe-signature'] ??        // Stripe (handled differently but captured)\n        req.headers['x-webhook-signature'] ??     // Common\n        '';\n      const signature = Array.isArray(sigHeader) ? sigHeader[0] ?? '' : sigHeader;\n\n      if (!signature || !verifyHmac(body, signature, route.secret)) {\n        res.writeHead(401, { 'Content-Type': 'text/plain' });\n        res.end('Unauthorized: Invalid signature');\n        return;\n      }\n    }\n\n    // Parse payload\n    let payload: unknown;\n    try {\n      payload = JSON.parse(body);\n    } catch {\n      payload = { raw: body };\n    }\n\n    // Build event\n    const event: WebhookEvent = {\n      type: 'webhook_received',\n      route: route.name,\n      source: route.source,\n      payload,\n      headers: Object.fromEntries(\n        Object.entries(req.headers)\n          .filter(([, v]) => typeof v === 'string')\n          .map(([k, v]) => [k, v as string])\n      ),\n      receivedAt: Date.now(),\n    };\n\n    // Record hit\n    routes.recordHit(route.id);\n\n    // Fire event\n    if (this.handler) {\n      try {\n        await this.handler(event);\n      } catch { /* handler errors don't affect HTTP response */ }\n    }\n\n    res.writeHead(200, { 'Content-Type': 'application/json' });\n    res.end(JSON.stringify({ ok: true, route: route.name }));\n  }\n\n  private readBody(req: IncomingMessage): Promise<string | null> {\n    return new Promise((resolve) => {\n      const chunks: Buffer[] = [];\n      let size = 0;\n\n      req.on('data', (chunk: Buffer) => {\n        size += chunk.length;\n        if (size > this.config.maxPayloadBytes) {\n          req.destroy();\n          resolve(null);\n          return;\n        }\n        chunks.push(chunk);\n      });\n\n      req.on('end', () => {\n        resolve(Buffer.concat(chunks).toString('utf8'));\n      });\n\n      req.on('error', () => {\n        resolve(null);\n      });\n    });\n  }\n}\n\n// ─── Singleton ──────────────────────────────────────────────────────────\n\nlet instance: WebhookServer | null = null;\n\nexport function getWebhookServer(config?: Partial<WebhookServerConfig>): WebhookServer {\n  if (!instance) {\n    instance = new WebhookServer(config);\n  }\n  return instance;\n}\n\nexport function resetWebhookServer(): void {\n  if (instance) {\n    instance.stop().catch(() => {});\n    instance = null;\n  }\n}\n"],"mappings":"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"}