package rest import ( "context" "log/slog" "strings" "time" "github.com/google/uuid" "github.com/sarathsp06/sparrow/internal/webhooks" "github.com/sarathsp06/sparrow/internal/webhooks/store" ) // conversionService is the slice of the service that webhook response // conversion consumes: secret masking plus batch subscription lookup. type conversionService interface { webhooks.SecretRevealer ListSubscriptionsByWebhookIDs(ctx context.Context, webhookIDs []uuid.UUID) ([]*store.EventSubscription, error) } // maskSecret masks a plaintext secret for API responses. Only the whsec_ // prefix (if present) stays visible; no plaintext secret bytes are exposed. func maskSecret(secret string) string { if secret == "" { return "" } const prefix = "whsec_" if rest, ok := strings.CutPrefix(secret, prefix); ok { return prefix + strings.Repeat("•", len(rest)) } return strings.Repeat("•", len(secret)) } // maskEncryptedSecret decrypts an encrypted webhook secret and masks it for // safe display in API responses. func maskEncryptedSecret(encrypted []byte, svc webhooks.SecretRevealer) string { if len(encrypted) == 0 { return "" } plaintext, err := svc.DecryptWebhookSecret(encrypted) if err != nil || plaintext == "" { return "••••••" } return maskSecret(plaintext) } // maskSecretHeaders decrypts encrypted secret headers and masks every value. func maskSecretHeaders(encrypted []byte, svc webhooks.SecretRevealer) map[string]string { if len(encrypted) == 0 { return nil } decrypted, err := svc.DecryptSecretHeaders(encrypted) if err != nil || len(decrypted) == 0 { return nil } masked := make(map[string]string, len(decrypted)) for k := range decrypted { masked[k] = "••••••" } return masked } // getWebhookEventsMap batch-fetches subscribed event names for multiple // webhooks in a single query, keyed by webhook id string. func getWebhookEventsMap(ctx context.Context, svc conversionService, regs []*store.WebhookRegistration) map[string][]string { if len(regs) == 0 { return map[string][]string{} } ids := make([]uuid.UUID, len(regs)) for i, r := range regs { ids[i] = r.ID } subs, err := svc.ListSubscriptionsByWebhookIDs(ctx, ids) if err != nil { slog.ErrorContext(ctx, "failed to batch-fetch subscriptions", "error", err) return map[string][]string{} } result := make(map[string][]string, len(ids)) for _, sub := range subs { key := sub.WebhookID.String() result[key] = append(result[key], sub.EventName) } return result } // WebhookHTTPConfigOut is the REST representation of per-webhook HTTP // delivery configuration. type WebhookHTTPConfigOut struct { MaxRetries int `json:"max_retries" doc:"Retries after the first attempt before a delivery is marked failed (up to max_retries + 1 attempts)."` RetryBackoffSeconds int `json:"retry_backoff_seconds" doc:"Base retry delay, in seconds; the delay after attempt N is retry_backoff_seconds * 2^(N-1), capped at 24 hours, minus a random 0-20%."` CaptureResponseBody bool `json:"capture_response_body" doc:"false: up to 1 KiB of each response body is stored; true: up to SPARROW_MAX_CAPTURED_RESPONSE_BYTES (default 1 MiB)."` FollowRedirects bool `json:"follow_redirects" doc:"Whether HTTP redirects returned by the endpoint are followed."` VerifySSL bool `json:"verify_ssl" doc:"Whether the endpoint's TLS certificate is verified."` RequestTimeoutSeconds int `json:"request_timeout_seconds" doc:"Per-attempt request timeout, in seconds."` ExpectedStatusCodes []int32 `json:"expected_status_codes,omitempty" doc:"HTTP status codes treated as a successful delivery."` WebhookSecret string `json:"webhook_secret,omitempty" doc:"HMAC signing secret. Shown in full only once, in the response to webhook registration; masked on every later read."` UserAgent string `json:"user_agent,omitempty" doc:"Stored value; deliveries currently always send User-Agent: Sparrow-Webhook/ unless headers override it."` ContentType string `json:"content_type,omitempty" doc:"Stored value; deliveries currently send Content-Type: application/json unless headers override it."` RateLimitRPS *float64 `json:"rate_limit_rps,omitempty" doc:"Delivery rate limit for this webhook, in requests per second; absent means no limit."` } // WebhookOut is the REST representation of a registered webhook (list/get). // Secrets are always masked; use the value returned at creation time to // retrieve the real secret once. type WebhookOut struct { WebhookID string `json:"webhook_id" doc:"Webhook id (UUID)."` Consumer string `json:"consumer" doc:"Consumer this webhook belongs to."` Events []string `json:"events" doc:"Event type names this webhook is currently subscribed to, derived from its subscriptions."` URL string `json:"url" doc:"HTTP endpoint deliveries are POSTed to."` Headers map[string]string `json:"headers,omitempty" doc:"Static HTTP headers sent with every delivery."` Active bool `json:"active" doc:"Whether deliveries are sent. false: paused by an operator or auto-disabled; new deliveries are held as paused."` Description string `json:"description,omitempty" doc:"Human-readable note about this webhook."` Health string `json:"health" enum:"healthy,degraded,unhealthy,unknown" doc:"Health label from the last health evaluation (see getWebhookHealth)."` SecretHeaders map[string]string `json:"secret_headers,omitempty" doc:"Encrypted secret header names, with values always masked. Update them via PATCH secret_headers; send an empty string to remove one."` SigningPublicKey string `json:"signing_public_key,omitempty" doc:"Hex-encoded Ed25519 public key for verifying the v1a, delivery signature. Safe to expose; there is no private-key equivalent to mask."` SignatureType string `json:"signature_type" enum:"hmac,ed25519" doc:"Signing scheme. \"hmac\" (default) signs every delivery with HMAC-SHA256 (v1,); \"ed25519\" adds an Ed25519 signature (v1a,) alongside the HMAC one."` HTTPConfig WebhookHTTPConfigOut `json:"http_config" doc:"Per-webhook HTTP delivery configuration."` RequiresTransform bool `json:"requires_transform" doc:"Whether every subscription must carry an enabled transform_template because the receiver only accepts transformed payloads."` AutoDisabledAt string `json:"auto_disabled_at,omitempty" format:"date-time" doc:"Set when Sparrow paused this webhook itself because its receiver kept failing (see SPARROW_AUTO_DISABLE_AFTER), RFC3339. Cleared when the webhook is resumed."` AutoDisabledReason string `json:"auto_disabled_reason,omitempty" doc:"Why Sparrow auto-disabled this webhook. Cleared when the webhook is resumed."` CreatedAt string `json:"created_at" format:"date-time" doc:"Creation timestamp, RFC3339."` UpdatedAt string `json:"updated_at" format:"date-time" doc:"Last-modified timestamp, RFC3339."` } func toWebhookOut(reg *store.WebhookRegistration, events []string, svc webhooks.SecretRevealer) WebhookOut { codes := make([]int32, len(reg.ExpectedStatusCodes)) for i, c := range reg.ExpectedStatusCodes { codes[i] = int32(c) //nolint:gosec // HTTP status codes, validated to 100-599 on save } return WebhookOut{ WebhookID: reg.ID.String(), Consumer: reg.Consumer, Events: events, URL: reg.URL, Headers: reg.Headers, Active: reg.Active, Description: reg.Description, Health: string(reg.Health), SecretHeaders: maskSecretHeaders(reg.SecretHeaders, svc), SigningPublicKey: svc.WebhookSigningPublicKeyHex(reg.Ed25519PrivateKey), SignatureType: string(reg.SignatureType), RequiresTransform: reg.RequiresTransform, HTTPConfig: WebhookHTTPConfigOut{ MaxRetries: reg.MaxRetries, RetryBackoffSeconds: reg.RetryBackoffSeconds, CaptureResponseBody: reg.CaptureResponseBody, FollowRedirects: reg.FollowRedirects, VerifySSL: reg.VerifySSL, RequestTimeoutSeconds: reg.RequestTimeoutSeconds, ExpectedStatusCodes: codes, WebhookSecret: maskEncryptedSecret(reg.WebhookSecret, svc), UserAgent: reg.UserAgent, ContentType: reg.ContentType, RateLimitRPS: reg.RateLimitRPS, }, AutoDisabledAt: formatOptionalTime(reg.AutoDisabledAt), AutoDisabledReason: derefString(reg.AutoDisabledReason), CreatedAt: reg.CreatedAt.Format(time.RFC3339Nano), UpdatedAt: reg.UpdatedAt.Format(time.RFC3339Nano), } } // formatOptionalTime formats t as RFC 3339, or "" when it is nil. func formatOptionalTime(t *time.Time) string { if t == nil { return "" } return t.Format(time.RFC3339Nano) } func derefString(s *string) string { if s == nil { return "" } return *s }