package webhooks import ( "fmt" "time" svcerrors "github.com/sarathsp06/sparrow/internal/errors" "github.com/sarathsp06/sparrow/internal/webhooks/client" ) // WebhookRegistration represents a webhook registration with HTTP configuration type WebhookRegistration struct { ID string `db:"id" json:"id"` Consumer string `db:"consumer" json:"consumer"` Events StringArray `db:"events" json:"events"` URL string `db:"url" json:"url"` Headers JSONBMap `db:"headers" json:"headers"` Active bool `db:"active" json:"active"` Description string `db:"description" json:"description"` Health string `db:"health" json:"health"` // HTTP Configuration HTTPConfig WebhookHTTPConfig `json:"http_config"` // Ed25519EncryptedPrivateKey holds the envelope-encrypted Ed25519 private key. // Only populated on creation; used to derive the public key for API responses. Ed25519EncryptedPrivateKey []byte `json:"-"` // SignatureType controls which signing scheme is used: "hmac" (default) or "ed25519". SignatureType string `json:"signature_type"` // RequiresTransform: every subscription must carry an enabled transform. RequiresTransform bool `json:"requires_transform"` CreatedAt time.Time `db:"created_at" json:"created_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"` } // WebhookHTTPConfig contains HTTP-specific configuration for webhook delivery type WebhookHTTPConfig struct { MaxRetries *int `db:"max_retries" json:"max_retries"` RetryBackoffSeconds int `db:"retry_backoff_seconds" json:"retry_backoff_seconds"` // CaptureResponseBody controls the stored response body size limit per delivery attempt. // false (default): stores up to 1 KB. true: stores up to 1 MB. // The response body is always read regardless of this setting (required for HTTP connection reuse). // CaptureResponseBody, FollowRedirects, VerifySSL, and MaxRetries are pointers: // nil means "not explicitly set, use default"; non-nil (including a zero/false value) overrides it. CaptureResponseBody *bool `db:"capture_response_body" json:"capture_response_body"` FollowRedirects *bool `db:"follow_redirects" json:"follow_redirects"` VerifySSL *bool `db:"verify_ssl" json:"verify_ssl"` RequestTimeoutSeconds int `db:"request_timeout_seconds" json:"request_timeout_seconds"` ExpectedStatusCodes IntArray `db:"expected_status_codes" json:"expected_status_codes"` WebhookSecret string `db:"webhook_secret" json:"webhook_secret,omitempty"` UserAgent string `db:"user_agent" json:"user_agent"` ContentType string `db:"content_type" json:"content_type"` RateLimitRPS *float64 `db:"rate_limit_rps" json:"rate_limit_rps,omitempty"` } // DerefIntOr and DerefBoolOr resolve an optional "explicitly set" config // pointer to its concrete value, falling back to def when unset. Callers // should only need the fallback defensively: DefaultWebhookHTTPConfig + // ApplyConfig always leave these fields non-nil in practice. func DerefIntOr(p *int, def int) int { if p == nil { return def } return *p } func DerefBoolOr(p *bool, def bool) bool { if p == nil { return def } return *p } // Retry defaults and limits. With the schedule in queue.WebhookWorker.NextRetry // (base * 2^(N-1), capped at 24 hours), the defaults retry for about three days // (1, 2, 4, ... 1024 minutes, then 24 hours twice), like the multi-day windows // of hosted webhook senders: a receiver outage over a weekend still recovers on // its own. const ( DefaultMaxRetries = 13 DefaultRetryBackoffSeconds = 60 MaxMaxRetries = 20 MaxRetryBackoffSeconds = 3600 ) // DefaultWebhookHTTPConfig returns default HTTP configuration func DefaultWebhookHTTPConfig() WebhookHTTPConfig { return WebhookHTTPConfig{ MaxRetries: new(DefaultMaxRetries), RetryBackoffSeconds: DefaultRetryBackoffSeconds, CaptureResponseBody: new(false), // stores up to 1 KB of response body; set true for up to 1 MB FollowRedirects: new(true), VerifySSL: new(true), RequestTimeoutSeconds: 30, ExpectedStatusCodes: IntArray{200, 201, 202, 204}, UserAgent: "Sparrow-Webhook/1.0", ContentType: "application/json", } } // ValidateConfig validates the HTTP configuration func (config WebhookHTTPConfig) ValidateConfig() error { if config.MaxRetries != nil && (*config.MaxRetries < 0 || *config.MaxRetries > MaxMaxRetries) { return svcerrors.Errorf(svcerrors.InvalidArgument, "max_retries must be between 0 and %d, got %d", MaxMaxRetries, *config.MaxRetries) } if config.RetryBackoffSeconds <= 0 || config.RetryBackoffSeconds > MaxRetryBackoffSeconds { return svcerrors.Errorf(svcerrors.InvalidArgument, "retry_backoff_seconds must be between 1 and %d, got %d", MaxRetryBackoffSeconds, config.RetryBackoffSeconds) } if config.RequestTimeoutSeconds <= 0 || config.RequestTimeoutSeconds > maxTimeoutSeconds { return svcerrors.Errorf(svcerrors.InvalidArgument, "request_timeout_seconds must be between 1 and %d, got %d", maxTimeoutSeconds, config.RequestTimeoutSeconds) } if len(config.ExpectedStatusCodes) == 0 { return svcerrors.Error(svcerrors.InvalidArgument, "expected_status_codes cannot be empty") } for _, code := range config.ExpectedStatusCodes { if code < 100 || code > 599 { return svcerrors.Errorf(svcerrors.InvalidArgument, "invalid HTTP status code: %d", code) } } if config.ContentType == "" { return svcerrors.Error(svcerrors.InvalidArgument, "content_type cannot be empty") } // RateLimitRPS: nil is fine (no limit), but if set, must be positive. // Matches the DB constraint: CHECK (rate_limit_rps IS NULL OR rate_limit_rps > 0) if config.RateLimitRPS != nil && *config.RateLimitRPS <= 0 { return svcerrors.Errorf(svcerrors.InvalidArgument, "rate_limit_rps must be positive, got %f", *config.RateLimitRPS) } return nil } // ApplyConfig applies configuration from another config, only overriding non-zero/non-empty values func (config *WebhookHTTPConfig) ApplyConfig(other *WebhookHTTPConfig) { if other == nil { return } // MaxRetries is a pointer: nil means "not set, keep default"; non-nil overrides, // including an explicit 0 (no retries). if other.MaxRetries != nil { config.MaxRetries = other.MaxRetries } if other.RetryBackoffSeconds > 0 { config.RetryBackoffSeconds = other.RetryBackoffSeconds } if other.RequestTimeoutSeconds > 0 { config.RequestTimeoutSeconds = other.RequestTimeoutSeconds } // Override non-empty arrays if len(other.ExpectedStatusCodes) > 0 { config.ExpectedStatusCodes = other.ExpectedStatusCodes } // Override non-empty strings (empty string means not set, keep default) if other.WebhookSecret != "" { config.WebhookSecret = other.WebhookSecret } if other.UserAgent != "" { config.UserAgent = other.UserAgent } if other.ContentType != "" { config.ContentType = other.ContentType } // Booleans are pointers: nil means "not set, keep default"; non-nil overrides, // including an explicit false. if other.CaptureResponseBody != nil { config.CaptureResponseBody = other.CaptureResponseBody } if other.FollowRedirects != nil { config.FollowRedirects = other.FollowRedirects } if other.VerifySSL != nil { config.VerifySSL = other.VerifySSL } // RateLimitRPS is a pointer: nil means "don't change", non-nil overrides (including to set a limit) if other.RateLimitRPS != nil { config.RateLimitRPS = other.RateLimitRPS } } // StringArray is a wrapper for PostgreSQL string arrays type StringArray []string // IntArray is a wrapper for PostgreSQL integer arrays type IntArray []int // JSONBMap is a wrapper for PostgreSQL JSONB maps type JSONBMap map[string]any // WebhookRegistrationRequest represents a request to create/update a webhook registration type WebhookRegistrationRequest struct { ID string `json:"id,omitempty"` Consumer string `json:"consumer" validate:"required"` Events []string `json:"events" validate:"required,min=1"` URL string `json:"url" validate:"required,url"` Headers map[string]any `json:"headers,omitempty"` SecretHeaders map[string]string `json:"secret_headers,omitempty"` Active *bool `json:"active,omitempty"` Description string `json:"description,omitempty"` HTTPConfig *WebhookHTTPConfig `json:"http_config,omitempty"` RateLimitRPS *float64 `json:"rate_limit_rps,omitempty"` SignatureType string `json:"signature_type,omitempty"` // "hmac" (default) or "ed25519" // RequiresTransform marks a receiver that only accepts transformed // payloads; TransformTemplate is then required when Events is non-empty. RequiresTransform bool `json:"requires_transform,omitempty"` // TransformTemplate and TemplateSettings apply to every subscription // created from Events. An empty template leaves them untransformed. TransformTemplate string `json:"transform_template,omitempty"` TemplateSettings SubscriptionTemplateSettings `json:"-"` // TemplateMeta labels the template's first saved version on each // created subscription (manual or ai_draft, notes, who saved it). TemplateMeta TemplateSaveMeta `json:"-"` } // ToWebhookRegistration converts the request to a WebhookRegistration func (req WebhookRegistrationRequest) ToWebhookRegistration() (*WebhookRegistration, error) { webhook := &WebhookRegistration{ ID: req.ID, Consumer: req.Consumer, Events: StringArray(req.Events), URL: req.URL, Headers: JSONBMap(req.Headers), Description: req.Description, Active: true, Health: "unknown", SignatureType: req.SignatureType, } if req.Active != nil { webhook.Active = *req.Active } // Start with defaults; provided configuration overrides them. webhook.HTTPConfig = DefaultWebhookHTTPConfig() if req.HTTPConfig != nil { webhook.HTTPConfig.ApplyConfig(req.HTTPConfig) } // The top-level rate_limit_rps is accepted as a shorthand; // http_config.rate_limit_rps wins when both are set. if webhook.HTTPConfig.RateLimitRPS == nil { webhook.HTTPConfig.RateLimitRPS = req.RateLimitRPS } if err := webhook.HTTPConfig.ValidateConfig(); err != nil { return nil, fmt.Errorf("invalid HTTP config: %w", err) } return webhook, nil } // maxTimeoutSeconds bounds request_timeout_seconds and a subscription's // timeout override; the delivery job's River timeout is sized from it. const maxTimeoutSeconds = int(client.MaxRequestTimeout / time.Second) // validateSubscriptionTimeout checks a subscription's timeout override in // seconds; 0 means the webhook's request_timeout_seconds applies. func validateSubscriptionTimeout(timeout int) error { if timeout < 0 || timeout > maxTimeoutSeconds { return svcerrors.Errorf(svcerrors.InvalidArgument, "timeout must be between 0 and %d seconds, got %d", maxTimeoutSeconds, timeout) } return nil }