Files
synapbus/internal/webhooks/service.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

143 lines
4.1 KiB
Go

package webhooks
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"log/slog"
)
const (
// MaxWebhooksPerAgent is the maximum number of webhooks per agent.
MaxWebhooksPerAgent = 3
// MaxDepth is the maximum webhook chain depth for loop prevention.
MaxDepth = 5
// AutoDisableThreshold is the consecutive failure count that triggers auto-disable.
AutoDisableThreshold = 50
)
// WebhookService provides business logic for webhook operations.
type WebhookService struct {
store WebhookStore
allowHTTP bool
allowPrivate bool
logger *slog.Logger
}
// NewWebhookService creates a new webhook service.
func NewWebhookService(store WebhookStore, allowHTTP, allowPrivate bool) *WebhookService {
return &WebhookService{
store: store,
allowHTTP: allowHTTP,
allowPrivate: allowPrivate,
logger: slog.Default().With("component", "webhooks"),
}
}
// RegisterWebhook registers a new webhook for an agent.
func (s *WebhookService) RegisterWebhook(ctx context.Context, agentName, url string, events []string, secret string) (*Webhook, error) {
// Validate events
for _, e := range events {
if !IsValidEvent(e) {
return nil, fmt.Errorf("invalid event type: %q (valid: %v)", e, ValidEvents)
}
}
if len(events) == 0 {
return nil, fmt.Errorf("at least one event type is required")
}
// Validate URL
if err := ValidateWebhookURL(url, s.allowHTTP, s.allowPrivate); err != nil {
return nil, fmt.Errorf("invalid webhook URL: %w", err)
}
// Check max webhooks
count, err := s.store.CountWebhooksByAgent(ctx, agentName)
if err != nil {
return nil, fmt.Errorf("count webhooks: %w", err)
}
if count >= MaxWebhooksPerAgent {
return nil, fmt.Errorf("maximum webhooks reached (%d/%d); delete one before registering another", count, MaxWebhooksPerAgent)
}
// Hash the secret for storage
secretHash := hashSecret(secret)
wh := &Webhook{
AgentName: agentName,
URL: url,
Events: events,
SecretHash: secretHash,
Status: WebhookStatusActive,
}
id, err := s.store.InsertWebhook(ctx, wh)
if err != nil {
return nil, fmt.Errorf("insert webhook: %w", err)
}
s.logger.Info("webhook registered",
"id", id,
"agent", agentName,
"url", url,
"events", events,
)
return wh, nil
}
// ListWebhooks returns all webhooks for an agent.
func (s *WebhookService) ListWebhooks(ctx context.Context, agentName string) ([]*Webhook, error) {
return s.store.GetWebhooksByAgent(ctx, agentName)
}
// DeleteWebhook deletes a webhook owned by the agent.
func (s *WebhookService) DeleteWebhook(ctx context.Context, agentName string, webhookID int64) error {
if err := s.store.DeleteWebhook(ctx, webhookID, agentName); err != nil {
return fmt.Errorf("delete webhook: %w", err)
}
s.logger.Info("webhook deleted",
"id", webhookID,
"agent", agentName,
)
return nil
}
// GetActiveWebhooksForEvent returns active webhooks for an agent subscribed to a specific event.
func (s *WebhookService) GetActiveWebhooksForEvent(ctx context.Context, agentName, event string) ([]*Webhook, error) {
return s.store.GetActiveWebhooksByEvent(ctx, agentName, event)
}
// RecordSuccess records a successful delivery: resets consecutive failures.
func (s *WebhookService) RecordSuccess(ctx context.Context, webhookID int64) error {
return s.store.ResetConsecutiveFailures(ctx, webhookID)
}
// RecordFailure increments the failure counter and auto-disables if threshold reached.
func (s *WebhookService) RecordFailure(ctx context.Context, webhookID int64) error {
count, err := s.store.IncrementConsecutiveFailures(ctx, webhookID)
if err != nil {
return err
}
if count >= AutoDisableThreshold {
s.logger.Warn("auto-disabling webhook after consecutive failures",
"webhook_id", webhookID,
"failures", count,
)
return s.store.UpdateWebhookStatus(ctx, webhookID, WebhookStatusDisabled)
}
return nil
}
// Store returns the underlying webhook store (for delivery engine access).
func (s *WebhookService) Store() WebhookStore {
return s.store
}
// hashSecret computes SHA-256 hex digest of the secret for storage.
func hashSecret(secret string) string {
h := sha256.Sum256([]byte(secret))
return hex.EncodeToString(h[:])
}