Files
synapbus/internal/api/webhooks_handler.go
T
Algis DumbrisandClaude Opus 4.6 42775df65f feat: webhooks & Kubernetes Job runner for event-driven agents
- Webhook registration via MCP (register_webhook, list_webhooks, delete_webhook)
- HMAC-SHA256 payload signing, SSRF-safe HTTP client, loop detection (depth 5)
- 8-worker goroutine delivery pool with exponential backoff retry (1s/5s/30s)
- Dead letter queue with auto-purge, auto-disable after 50 consecutive failures
- Per-agent rate limiting (60 deliveries/min)
- K8s Job runner (register_k8s_handler, list_k8s_handlers, delete_k8s_handler)
- Auto-detect in-cluster via InClusterConfig, NoopRunner fallback
- REST API for webhook deliveries, dead letters, K8s job runs and logs
- Web UI: webhook management, K8s handler pages, dead letters view
- MultiDispatcher fan-out pattern for webhook + K8s event dispatch
- SQLite migration 009: webhooks, webhook_deliveries, k8s_handlers, k8s_job_runs
- 51 tests across 9 test packages, all passing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 18:14:04 +02:00

234 lines
6.9 KiB
Go

package api
import (
"log/slog"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/synapbus/synapbus/internal/agents"
"github.com/synapbus/synapbus/internal/webhooks"
)
// WebhooksHandler handles REST API requests for webhooks and deliveries.
type WebhooksHandler struct {
webhookService *webhooks.WebhookService
webhookStore webhooks.WebhookStore
agentService *agents.AgentService
logger *slog.Logger
}
// NewWebhooksHandler creates a new webhooks handler.
func NewWebhooksHandler(ws *webhooks.WebhookService, store webhooks.WebhookStore, agentService *agents.AgentService) *WebhooksHandler {
return &WebhooksHandler{
webhookService: ws,
webhookStore: store,
agentService: agentService,
logger: slog.Default().With("component", "api.webhooks"),
}
}
// ListWebhooks handles GET /api/webhooks?agent={name}.
func (h *WebhooksHandler) ListWebhooks(w http.ResponseWriter, r *http.Request) {
ownerID, ok := OwnerIDFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
return
}
ownedAgents, err := h.agentService.ListAgents(r.Context(), ownerID)
if err != nil {
h.logger.Error("list agents failed", "error", err)
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to list agents"))
return
}
agentFilter := r.URL.Query().Get("agent")
var allWebhooks []*webhooks.Webhook
for _, agent := range ownedAgents {
if agentFilter != "" && agent.Name != agentFilter {
continue
}
whs, err := h.webhookService.ListWebhooks(r.Context(), agent.Name)
if err != nil {
h.logger.Error("list webhooks failed", "agent", agent.Name, "error", err)
continue
}
allWebhooks = append(allWebhooks, whs...)
}
if allWebhooks == nil {
allWebhooks = []*webhooks.Webhook{}
}
writeJSON(w, http.StatusOK, map[string]any{
"webhooks": allWebhooks,
"total": len(allWebhooks),
})
}
// WebhookDeliveries handles GET /api/webhooks/{id}/deliveries?status={status}&limit={n}.
func (h *WebhooksHandler) WebhookDeliveries(w http.ResponseWriter, r *http.Request) {
ownerID, ok := OwnerIDFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
return
}
webhookID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeJSON(w, http.StatusBadRequest, errorBody("invalid_id", "Invalid webhook ID"))
return
}
// Get the webhook to verify ownership
wh, err := h.webhookStore.GetWebhookByID(r.Context(), webhookID)
if err != nil {
writeJSON(w, http.StatusNotFound, errorBody("not_found", "Webhook not found"))
return
}
if !h.isAgentOwnedBy(r, wh.AgentName, ownerID) {
writeJSON(w, http.StatusForbidden, errorBody("forbidden", "You do not have access to this webhook"))
return
}
status := r.URL.Query().Get("status")
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 {
limit = 50
}
deliveries, err := h.webhookStore.GetDeliveriesByAgent(r.Context(), wh.AgentName, status, limit)
if err != nil {
h.logger.Error("get deliveries failed", "error", err)
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to get deliveries"))
return
}
// Filter deliveries to only those belonging to this webhook
var filtered []*webhooks.WebhookDelivery
for _, d := range deliveries {
if d.WebhookID == webhookID {
filtered = append(filtered, d)
}
}
if filtered == nil {
filtered = []*webhooks.WebhookDelivery{}
}
writeJSON(w, http.StatusOK, map[string]any{
"deliveries": filtered,
"total": len(filtered),
})
}
// DeadLetters handles GET /api/deliveries/dead-letters?agent={name}&limit={n}.
func (h *WebhooksHandler) DeadLetters(w http.ResponseWriter, r *http.Request) {
ownerID, ok := OwnerIDFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
return
}
ownedAgents, err := h.agentService.ListAgents(r.Context(), ownerID)
if err != nil {
h.logger.Error("list agents failed", "error", err)
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to list agents"))
return
}
agentFilter := r.URL.Query().Get("agent")
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 {
limit = 50
}
var allDeadLetters []*webhooks.WebhookDelivery
for _, agent := range ownedAgents {
if agentFilter != "" && agent.Name != agentFilter {
continue
}
deliveries, err := h.webhookStore.GetDeliveriesByAgent(r.Context(), agent.Name, webhooks.DeliveryStatusDeadLettered, limit)
if err != nil {
h.logger.Error("get dead-lettered deliveries failed", "agent", agent.Name, "error", err)
continue
}
allDeadLetters = append(allDeadLetters, deliveries...)
}
if allDeadLetters == nil {
allDeadLetters = []*webhooks.WebhookDelivery{}
}
// Trim to limit
if len(allDeadLetters) > limit {
allDeadLetters = allDeadLetters[:limit]
}
writeJSON(w, http.StatusOK, map[string]any{
"dead_letters": allDeadLetters,
"total": len(allDeadLetters),
})
}
// RetryDelivery handles POST /api/deliveries/{id}/retry.
func (h *WebhooksHandler) RetryDelivery(w http.ResponseWriter, r *http.Request) {
ownerID, ok := OwnerIDFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
return
}
deliveryID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeJSON(w, http.StatusBadRequest, errorBody("invalid_id", "Invalid delivery ID"))
return
}
delivery, err := h.webhookStore.GetDeliveryByID(r.Context(), deliveryID)
if err != nil {
writeJSON(w, http.StatusNotFound, errorBody("not_found", "Delivery not found"))
return
}
if !h.isAgentOwnedBy(r, delivery.AgentName, ownerID) {
writeJSON(w, http.StatusForbidden, errorBody("forbidden", "You do not have access to this delivery"))
return
}
if delivery.Status != webhooks.DeliveryStatusDeadLettered {
writeJSON(w, http.StatusBadRequest, errorBody("invalid_status", "Only dead-lettered deliveries can be retried"))
return
}
// Reset delivery to pending for re-processing
err = h.webhookStore.UpdateDeliveryStatus(r.Context(), deliveryID, webhooks.DeliveryStatusPending, 0, "", nil, nil)
if err != nil {
h.logger.Error("retry delivery failed", "error", err)
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to retry delivery"))
return
}
// Reset attempt counter
if err := h.webhookStore.UpdateDeliveryAttempts(r.Context(), deliveryID, 0); err != nil {
h.logger.Error("reset delivery attempts failed", "error", err)
// Non-fatal: the status was already reset
}
writeJSON(w, http.StatusOK, map[string]any{"retried": true})
}
func (h *WebhooksHandler) isAgentOwnedBy(r *http.Request, agentName string, ownerID int64) bool {
if agentName == "" {
return false
}
agent, err := h.agentService.GetAgent(r.Context(), agentName)
if err != nil {
return false
}
return agent.OwnerID == ownerID
}