webhook

package
v1.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 10, 2026 License: Apache-2.0 Imports: 21 Imported by: 0

Documentation

Overview

Package webhook implements wowapi's webhook subsystem: inbound signature verification + replay protection + async processing, and outbound signed HTTP delivery with per-endpoint circuit breakers. Contract: docs/blueprint/07 §6.

Index

Constants

View Source
const (
	DirectionInbound  = "inbound"
	DirectionOutbound = "outbound"
)

Direction values mirror the DB check constraint.

View Source
const (
	StatusPending   = "pending"
	StatusProcessed = "processed"
	StatusDelivered = "delivered"
	StatusFailed    = "failed"
	StatusDead      = "dead"
)

DeliveryStatus values mirror the DB check constraint.

View Source
const BreakerCooldown = 5 * time.Minute

BreakerCooldown is the half-open probe interval after the circuit opens.

View Source
const BreakerFailureThreshold = 5

BreakerFailureThreshold is the number of consecutive delivery failures that opens the circuit breaker.

View Source
const MaxAttempts = 5

MaxAttempts is the DLQ ceiling for both inbound processing and outbound delivery.

View Source
const OutboundTimeout = 10 * time.Second

OutboundTimeout is the per-delivery HTTP call timeout.

View Source
const TimestampWindow = 5 * time.Minute

TimestampWindow is the replay-protection window (±5 m per blueprint 07 §6).

Variables

This section is empty.

Functions

This section is empty.

Types

type Endpoint

type Endpoint struct {
	ID               uuid.UUID
	TenantID         uuid.UUID
	Direction        string
	ProviderID       *uuid.UUID
	URL              *string
	SecretRef        string
	SignatureScheme  string
	SubscribedEvents []string
	Status           string
}

Endpoint is the service-layer view of a webhook_endpoints row.

type Event

type Event struct {
	ID              uuid.UUID
	TenantID        uuid.UUID
	EndpointID      uuid.UUID
	Direction       string
	ExternalEventID string
	EventType       string
	Payload         json.RawMessage
	SignatureOk     *bool
	ReceivedAt      time.Time
	DeliveryStatus  string
	Attempts        int
	NextAttemptAt   *time.Time
	LastError       *string
}

Event is the service-layer view of a webhook_events row.

type FakeSecretResolver

type FakeSecretResolver struct {
	Secret string
}

FakeSecretResolver is a test double that returns a fixed secret for any ref.

func (*FakeSecretResolver) Resolve

func (r *FakeSecretResolver) Resolve(_ context.Context, _ string) (string, error)

Resolve returns the configured Secret regardless of ref.

type FakeSender

type FakeSender struct {
	// StatusCode is returned from every Post call (default 200 when zero).
	StatusCode int
	// Err is returned from every Post call when non-nil.
	Err error

	// Calls accumulates the arguments of every Post invocation.
	Calls []SentCall
}

FakeSender is a test double that records every Post call and returns a pre-configured status code and optional error.

func (*FakeSender) Post

func (f *FakeSender) Post(_ context.Context, url string, body []byte, headers map[string]string) (int, error)

Post records the call and returns the pre-configured response.

type FakeVerifier

type FakeVerifier struct {
	// Secret is the expected value in the "X-Test-Sig" header.
	Secret string
}

FakeVerifier is a test double that passes when the header "X-Test-Sig" equals the pre-configured Secret, and fails otherwise.

func (FakeVerifier) Verify

func (v FakeVerifier) Verify(_ string, _ []byte, headers map[string]string) error

Verify passes when headers["X-Test-Sig"] == v.Secret, fails otherwise.

type HMACVerifier

type HMACVerifier struct {
	// SignatureHeader is the header name carrying the signature.
	// Defaults to "X-Signature" when empty.
	SignatureHeader string
}

HMACVerifier implements Verifier using HMAC-SHA256. The expected signature is read from the header named by SignatureHeader (default "X-Signature"), which may carry a "sha256=" prefix that is stripped before comparison. The signature is the lowercase-hex HMAC-SHA256 of the raw request body keyed by the endpoint secret.

NOTE: this verifies the common EXTERNAL-provider scheme — HMAC over the body alone. It is intentionally NOT the same construction as our OUTBOUND signing (signPayload in service.go), which authenticates "<timestamp>.<body>" so the X-Timestamp header is covered (SEC-52). A provider that signs a timestamped payload needs its own Verifier registered under its provider key.

func (HMACVerifier) Verify

func (v HMACVerifier) Verify(secret string, body []byte, headers map[string]string) error

Verify computes HMAC-SHA256(secret, body) and compares it to the value in SignatureHeader using a constant-time comparison. Returns KindUnauthenticated on mismatch or missing header.

type HTTPSender

type HTTPSender struct {
	// contains filtered or unexported fields
}

HTTPSender implements Sender using a standard net/http client. The client carries OutboundTimeout as its hard ceiling; the caller's context deadline may further constrain it.

Outbound webhook URLs are USER-CONFIGURABLE (tenants register their own endpoints), so by default the underlying client is kernel/httpclient's SSRF-safe client (backlog B2): loopback, link-local (incl. the cloud metadata address), RFC1918/ULA private, and unspecified addresses are all refused at dial time. WithHTTPClientConfig and WithSSRFProtectionDisabled are the escape hatches for intentional internal targets — wire them from config.WebhookOutbound (kernel/config), never hard-code an override.

func NewHTTPSender

func NewHTTPSender(opts ...HTTPSenderOption) *HTTPSender

NewHTTPSender returns the production Sender backed by net/http. By default it is SSRF-safe (kernel/httpclient, dial-time address-class blocking); pass WithHTTPClientConfig for an allowlist or WithSSRFProtectionDisabled to opt out entirely (local/dev only).

func (*HTTPSender) Post

func (s *HTTPSender) Post(ctx context.Context, url string, body []byte, headers map[string]string) (int, error)

Post sends a POST request with the given body and headers and returns the HTTP status code on success.

type HTTPSenderOption added in v1.1.0

type HTTPSenderOption func(*httpSenderCfg)

HTTPSenderOption customizes NewHTTPSender.

func WithHTTPClientConfig added in v1.1.0

func WithHTTPClientConfig(cfg httpclient.Config) HTTPSenderOption

WithHTTPClientConfig sets the SSRF-guard config (allowlist hosts/CIDRs, timeout) the default sender's client is built with. Corresponds to config.WebhookOutbound.AllowedHosts/AllowedCIDRs.

func WithSSRFProtectionDisabled added in v1.1.0

func WithSSRFProtectionDisabled() HTTPSenderOption

WithSSRFProtectionDisabled removes the SSRF guard entirely, falling back to a bare net/http client. Corresponds to config.WebhookOutbound.SSRFProtectionDisabled — Validate() refuses that config key in prod, so this option should only ever be reached in local/dev wiring.

type InboundHandler

type InboundHandler func(ctx context.Context, db database.TenantDB, e Event) error

InboundHandler processes a verified, persisted inbound webhook event. Registered per event_type; called asynchronously by ProcessInbound.

type InboundIn

type InboundIn struct {
	EndpointID      uuid.UUID
	ProviderKey     string // key to look up the registered Verifier
	RawBody         []byte
	Headers         map[string]string
	ExternalEventID string
	EventType       string
	Timestamp       time.Time // provider-supplied timestamp from headers
}

InboundIn is the input envelope the HTTP layer fills when it receives a provider webhook POST.

type Option

type Option func(*Service)

Option customizes a Service at construction.

func WithMetrics

func WithMetrics(m observability.Metrics) Option

WithMetrics wires an observability sink so the outbound breaker state is exported as the webhook_breaker_state gauge (0=closed, 1=open, 2=half-open).

type SecretResolver

type SecretResolver interface {
	Resolve(ctx context.Context, ref string) (string, error)
}

SecretResolver resolves a secret_ref string (as stored in the secret_ref column) to its plaintext value. Only the composition root wires a real implementation; the kernel/secrets adapter satisfies this interface.

type Sender

type Sender interface {
	Post(ctx context.Context, url string, body []byte, headers map[string]string) (statusCode int, err error)
}

Sender delivers a signed HTTP POST to a webhook URL.

type SentCall

type SentCall struct {
	URL     string
	Body    []byte
	Headers map[string]string
}

SentCall records one Post invocation.

type Service

type Service struct {
	// contains filtered or unexported fields
}

Service is the webhook framework. HandleInbound runs on the caller's tenant DB (app_rt); ProcessInbound and DispatchOutbound run on a platform TxManager (app_platform, tenant-bound), following the document.Service pattern.

func New

func New(sender Sender, secrets SecretResolver, idgen model.IDGen, opts ...Option) *Service

New wires the Service. sender, secrets, and idgen are required.

func NewWithClock

func NewWithClock(sender Sender, secrets SecretResolver, idgen model.IDGen, nowFn func() time.Time, opts ...Option) *Service

NewWithClock is like New but accepts an injectable clock — used by tests that exercise the circuit breaker and retry/DLQ paths deterministically.

func (*Service) DispatchOutbound

func (s *Service) DispatchOutbound(ctx context.Context, plat database.TxManager, tenantID uuid.UUID, ev outbox.Event, now time.Time) error

DispatchOutbound fans ev to all active (or degraded) outbound endpoints for the event's tenant whose subscribed_events contain ev.Type. For each matching endpoint it upserts a delivery row, checks the circuit breaker, signs the body, POSTs via Sender, then records the outcome. Open-circuit endpoints are skipped silently (their delivery rows stay pending). Runs as app_platform.

SEC/H2: the delivery tenant is authoritative from ev.TenantID, never the decoupled tenantID param. Without this, a caller passing B's id with A's event would look up B's endpoints and sign A's payload with B's secret — a cross-tenant leak. When ev carries a tenant (relay/writer sets it), a disagreeing tenantID is rejected fail-closed (KindValidation); the event's tenant then binds the whole dispatch. A zero ev.TenantID falls back to the passed tenantID (legacy callers that pre-bind the tenant themselves).

func (*Service) HandleInbound

func (s *Service) HandleInbound(ctx context.Context, db database.TenantDB, in InboundIn) error

HandleInbound verifies, replay-checks, and persists an inbound webhook event. Runs inside the caller's app_rt tenant transaction (db). On success returns nil (ack fast); actual processing is async via ProcessInbound.

  1. Look up the Verifier for in.ProviderKey; resolve the endpoint secret.
  2. Verify signature — on failure insert a signature_ok=false audit row (best-effort in the same tx) and return KindUnauthenticated.
  3. Reject timestamps outside ±5 m of now → KindValidation.
  4. Insert a pending row; UNIQUE(endpoint_id, external_event_id) detects replays → KindConflict (idempotent ack).

func (*Service) ProcessInbound

func (s *Service) ProcessInbound(ctx context.Context, plat database.TxManager, tenantID uuid.UUID, now time.Time) error

ProcessInbound claims pending inbound events for tenantID and runs registered handlers. Runs as app_platform (plat is a TxManager over the platform pool). Advances delivery_status: pending/failed → processed on success, or increments attempts toward dead on handler error.

func (*Service) RegisterHandler

func (s *Service) RegisterHandler(eventType string, h InboundHandler)

RegisterHandler registers an InboundHandler for the given event type. Only one handler per event type; call before serving.

func (*Service) RegisterVerifier

func (s *Service) RegisterVerifier(providerKey string, v Verifier)

RegisterVerifier registers a Verifier for the given provider key. Call before serving requests.

func (*Service) RetryOutbound

func (s *Service) RetryOutbound(ctx context.Context, plat database.TxManager, tenantID uuid.UUID, now time.Time) error

RetryOutbound re-delivers previously-failed outbound webhook events for tenantID whose backoff has elapsed. Runs as app_platform (plat is a TxManager over the platform pool), mirroring ProcessInbound. Without this, DispatchOutbound would leave a 'failed' row untouched and the outbox relay — having marked its source event dispatched — would never re-drive delivery (ARCH-70). It claims failed rows FOR UPDATE SKIP LOCKED, re-runs deliverToEndpoint for each, and advances status to delivered / failed+backoff / dead at the ceiling.

type Verifier

type Verifier interface {
	Verify(secret string, body []byte, headers map[string]string) error
}

Verifier verifies a provider's signature over a raw body + headers. Implementations are registered per provider key.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL