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
- type Endpoint
- type Event
- type FakeSecretResolver
- type FakeSender
- type FakeVerifier
- type HMACVerifier
- type HTTPSender
- type InboundHandler
- type InboundIn
- type Option
- type SecretResolver
- type Sender
- type SentCall
- type Service
- func (s *Service) DispatchOutbound(ctx context.Context, plat database.TxManager, tenantID uuid.UUID, ...) error
- func (s *Service) HandleInbound(ctx context.Context, db database.TenantDB, in InboundIn) error
- func (s *Service) ProcessInbound(ctx context.Context, plat database.TxManager, tenantID uuid.UUID, ...) error
- func (s *Service) RegisterHandler(eventType string, h InboundHandler)
- func (s *Service) RegisterVerifier(providerKey string, v Verifier)
- func (s *Service) RetryOutbound(ctx context.Context, plat database.TxManager, tenantID uuid.UUID, ...) error
- type Verifier
Constants ¶
const ( DirectionInbound = "inbound" DirectionOutbound = "outbound" )
Direction values mirror the DB check constraint.
const ( StatusPending = "pending" StatusProcessed = "processed" StatusDelivered = "delivered" StatusFailed = "failed" StatusDead = "dead" )
DeliveryStatus values mirror the DB check constraint.
const BreakerCooldown = 5 * time.Minute
BreakerCooldown is the half-open probe interval after the circuit opens.
const BreakerFailureThreshold = 5
BreakerFailureThreshold is the number of consecutive delivery failures that opens the circuit breaker.
const MaxAttempts = 5
MaxAttempts is the DLQ ceiling for both inbound processing and outbound delivery.
const OutboundTimeout = 10 * time.Second
OutboundTimeout is the per-delivery HTTP call timeout.
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.
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.
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.
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.
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.
func NewHTTPSender ¶
func NewHTTPSender() *HTTPSender
NewHTTPSender returns the production Sender backed by net/http.
type InboundHandler ¶
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 ¶
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 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 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 ¶
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.
- Look up the Verifier for in.ProviderKey; resolve the endpoint secret.
- Verify signature — on failure insert a signature_ok=false audit row (best-effort in the same tx) and return KindUnauthenticated.
- Reject timestamps outside ±5 m of now → KindValidation.
- 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 ¶
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.