Documentation
¶
Overview ¶
Package webhook is the core service for the webhook (requestbin) share type: it provisions a capture endpoint as an ordinary Cairn artifact, holds its captured requests in a bounded, seq-ordered ring buffer — metadata in PostgreSQL, oversized bodies spilled to content-addressed object storage — and evicts the oldest record whenever a capture would push the buffer past its cap. Capture is the core method the public, anonymous-write ingress (internal/httpapi's `ANY /h/{id}` route, issue #84) calls for every real inbound request; this package's own integration tests also call it directly to simulate captures without going through HTTP.
The REST (and later MCP) surfaces are thin adapters over this one package (ADR-0003), exactly mirroring internal/trajectory's split for the other live share type.
Governing: ADR-0010 (Live Webhook Endpoints and Real-Time Stream Capture), ADR-0008 (Storage & Content Model), SPEC-0005 (Webhook Inspector).
Index ¶
- Constants
- Variables
- type BodyInfo
- type BodyRef
- type CaptureInput
- type Endpoint
- type EndpointInput
- type EventType
- type Options
- type Request
- type RequestPage
- type Service
- func (s *Service) Capture(ctx context.Context, publicID string, in CaptureInput) (*Request, error)
- func (s *Service) CreateEndpoint(ctx context.Context, in EndpointInput) (*Endpoint, error)
- func (s *Service) GetEndpoint(ctx context.Context, publicID string) (*Endpoint, error)
- func (s *Service) GetRequest(ctx context.Context, publicID string, seq int64) (*Request, error)
- func (s *Service) ListRequests(ctx context.Context, publicID string, before int64, limit int) (RequestPage, error)
- func (s *Service) MaxBodyBytes() int64
- func (s *Service) OpenRequestBody(ctx context.Context, publicID string, seq int64) (io.ReadCloser, BodyInfo, error)
- func (s *Service) RequestsAfter(ctx context.Context, publicID string, afterSeq int64) ([]Request, error)
- func (s *Service) Subscribe(endpoint string) *Subscription
- type StreamEvent
- type Subscription
Constants ¶
const ( DefaultListLimit = 50 MaxListLimit = 200 )
DefaultListLimit / MaxListLimit bound GET /v1/hooks/{id}/requests (SPEC-0005 "paginated/bounded").
const DefaultMaxBodyBytes = 5 << 20
DefaultMaxBodyBytes is the hard per-captured-request body cap Capture enforces by default (SPEC-0005 "Request Body Size Limits"). The open ingress transport (internal/httpapi) reads this back via MaxBodyBytes so its own pre-buffering 413 cap can never drift from the cap Capture itself applies — one number, not two independently maintained ones.
const DefaultRequestCap = 500
DefaultRequestCap is the per-endpoint ring-buffer size: at most the last N captured requests are retained; overflow evicts the oldest (SPEC-0005 "Ring-Buffer Retention and Caps", e.g. N = 500).
const DefaultResponseStatus = 200
DefaultResponseStatus is the fixed, benign status the open ingress (internal/httpapi's `ANY /h/{id}` route) records for every capture unless the endpoint is configured otherwise — data Cairn returns, never behavior an inbound payload can steer (SPEC-0005 "Fixed Benign Response").
Variables ¶
var ( // ErrEndpointNotFound is returned uniformly for an unknown, unauthorized, or // expired endpoint id so probing leaks no signal (ADR-0007 link-capability, // SPEC-0005 "Unguessable ID & No Enumeration"). ErrEndpointNotFound = errs.New(errs.CodeNotFound, "webhook endpoint not found") // ErrRequestNotFound is returned uniformly for an unknown or evicted // captured-request seq. ErrRequestNotFound = errs.New(errs.CodeNotFound, "captured request not found") )
Sentinel domain errors callers distinguish, each mapped to a stable code by a transport adapter via errs.CodeOf without string matching (SPEC-0005 "Error Handling Standards").
Functions ¶
This section is empty.
Types ¶
type BodyRef ¶
BodyRef references a captured request's oversized body stored as a content-addressed blob (SPEC-0005 "Body stored verbatim and content-addressed"). It is fetched lazily on expand.
type CaptureInput ¶
type CaptureInput struct {
Method string
Path string
Query string
Headers map[string][]string
ContentType string
Body []byte
Status int
}
CaptureInput is one inbound request as the open ingress presents it to Capture. Headers is a sanitized-on-write multi-map (SPEC-0005 "Header Hygiene & Ephemerality as Containment"); Status is the fixed response Cairn decided to return, recorded as data rather than derived from the payload (SPEC-0005 "Fixed Benign Response").
type Endpoint ¶
type Endpoint struct {
PublicID string
Title string
RequestCap int
Provenance artifact.Provenance
Access artifact.AccessPolicy
ExpiresAt time.Time
CreatedAt time.Time
}
Endpoint is a webhook endpoint: its artifact envelope plus the ring-buffer cap the owner configured (or the default).
type EndpointInput ¶
type EndpointInput struct {
Title string
RequestCap int
Provenance artifact.Provenance
Access artifact.AccessPolicy
ExpiresAt time.Time
}
EndpointInput is the input to CreateEndpoint. Provenance, Access, and ExpiresAt are the ordinary artifact envelope a webhook endpoint gets for free (SPEC-0002); RequestCap defaults to DefaultRequestCap when zero.
type EventType ¶
type EventType string
EventType classifies a live stream event. Unlike internal/trajectory's hub, a webhook endpoint has no open/closed lifecycle transition to signal — it stays live until its artifact TTL expires, at which point captures and reads alike fall back to the uniform not-found (SPEC-0005 "Expired endpoint stops capturing") — so EventRequest is the only event this hub ever publishes.
const EventRequest EventType = "request"
EventRequest carries one freshly captured request and its seq (the SSE id: and MCP resume cursor).
type Options ¶
type Options struct {
// InlineThresholdBytes is the size at or below which a captured body is
// stored inline; above it the body spills to a blob (default 16 KiB).
InlineThresholdBytes int64
// MaxBodyBytes caps a single captured request body (default 5 MiB); a body
// past it is rejected with payload_too_large.
MaxBodyBytes int64
// NewID overrides public-id generation; tests inject forced collisions.
NewID func() (string, error)
// Now overrides the clock, for deterministic tests.
Now func() time.Time
}
Options configures a Service. Zero values fall back to safe defaults.
type Request ¶
type Request struct {
Seq int64
ReceivedAt time.Time
Method string
Path string
Query string
Headers map[string][]string
Status int
ContentType string
BodySize int64
// Exactly one of Inline / Ref describes the body (both zero => empty body).
Inline []byte
Ref *BodyRef
}
Request is one captured request record: the seq-ordered, PostgreSQL-resident metadata the inspector queries for the status mix and method counts without ever reading a body (SPEC-0005 "Metadata queryable without reading the body"), plus its body disposition. Seq is the request's stable identity — monotonic per endpoint, never reused even across ring-buffer eviction — and is the webhook_request anchor target (SPEC-0005).
type RequestPage ¶
RequestPage is one keyset page of a webhook's captured-request buffer, newest-first (SPEC-0005 "the live request list (newest prepended)"). NextBefore is the seq to pass as Before for the next (older) page; zero when there is no further page.
type Service ¶
type Service struct {
// contains filtered or unexported fields
}
Service is the webhook core service. It shares the store's Postgres pool and object store so a webhook endpoint is an ordinary artifact in the same database and transaction domain (ADR-0012 one binary, one core), exactly mirroring internal/trajectory.Service's construction.
func NewService ¶
func NewService(pool *pgxpool.Pool, obj objectstore.ObjectStore, opts Options) *Service
NewService constructs a Service over a Postgres pool and an object store.
func (*Service) Capture ¶
Capture records one accepted inbound request against an endpoint: it assigns the next monotonic seq, inserts the request row (headers sanitized, body inlined or spilled), and — atomically in the same transaction — evicts whatever now sits past the ring-buffer cap (SPEC-0005 "Ring-Buffer Retention and Caps", "Capture-and-evict is atomic", "Concurrent captures keep seq monotonic"). It is the core method the public open ingress (internal/httpapi's `ANY /h/{id}` route, issue #84) calls for every real inbound request; this package's own tests also call it directly to simulate captures without going through HTTP.
An unknown or expired endpoint id returns the uniform ErrEndpointNotFound (ADR-0007 link-capability) so probing an id leaks no signal (SPEC-0005 "Unguessable ID & No Enumeration").
func (*Service) CreateEndpoint ¶
CreateEndpoint provisions a webhook capture endpoint: it mints the artifact envelope and the hook row (ring-buffer cap, seq counter starting at zero) in one transaction, so a partial endpoint is never visible (SPEC-0005 "Webhook Endpoint and Two Addresses", "Database Operation Standards").
func (*Service) GetEndpoint ¶
GetEndpoint resolves an endpoint by its public id and returns its metadata. It returns ErrEndpointNotFound uniformly for unknown, unauthorized, or expired ids (ADR-0007 link-capability).
func (*Service) GetRequest ¶
GetRequest fetches one captured request's full detail (metadata + body ref) by its endpoint id and seq. An unknown/expired endpoint is ErrEndpointNotFound; a well-formed but unknown or evicted seq is ErrRequestNotFound (SPEC-0005 "GET /v1/hooks/{id}/requests/{seq}").
func (*Service) ListRequests ¶
func (s *Service) ListRequests(ctx context.Context, publicID string, before int64, limit int) (RequestPage, error)
ListRequests returns a keyset page of an endpoint's captured requests in descending seq order (newest first). Before, when non-zero, returns only requests with seq < Before, so a caller pages strictly backward through history; Limit is clamped to (0, MaxListLimit], defaulting to DefaultListLimit (SPEC-0005 "GET /v1/hooks/{id}/requests: List captured requests (keyset paginated, seq order)"). An unknown/expired endpoint id is the uniform ErrEndpointNotFound.
func (*Service) MaxBodyBytes ¶
MaxBodyBytes reports the hard per-captured-request body cap this Service enforces (default DefaultMaxBodyBytes, or Options.MaxBodyBytes when set). The open ingress transport reads this to size its own pre-buffering 413 cap, so the HTTP-layer limit can never silently drift from what Capture itself will accept (SPEC-0005 "Request Body Size Limits").
func (*Service) OpenRequestBody ¶
func (s *Service) OpenRequestBody(ctx context.Context, publicID string, seq int64) (io.ReadCloser, BodyInfo, error)
OpenRequestBody opens a captured request's raw body for lazy streaming — the deferred fetch the inspector performs only when a request is expanded (SPEC-0005 "Bodies MUST be fetched lazily when a request is expanded"). The caller must Close the returned reader.
func (*Service) RequestsAfter ¶
func (s *Service) RequestsAfter(ctx context.Context, publicID string, afterSeq int64) ([]Request, error)
RequestsAfter returns an endpoint's captured requests whose seq is greater than afterSeq, in ascending seq (ingest) order — the SSE/MCP replay read: a fresh subscriber passes afterSeq = 0 to load the buffer currently retained, a reconnecting subscriber passes its Last-Event-ID so the stream resumes with no loss or duplication (SPEC-0005 "Late joiner sees history then tail"). Because seq is never reused even across ring-buffer eviction, a resume cursor older than the oldest retained request simply yields the current buffer from its start — no distinct error, exactly what the ring buffer already dropped for every other reader. An unknown, unauthorized, or expired endpoint is the uniform ErrEndpointNotFound (ADR-0007 link-capability).
func (*Service) Subscribe ¶
func (s *Service) Subscribe(endpoint string) *Subscription
Subscribe registers a live tail for an endpoint's capture stream. It does not itself verify the endpoint exists — the SSE/MCP handler subscribes BEFORE the replay read so a capture landing between the replay snapshot and the tail loop is buffered, not lost (SPEC-0005 "Late joiner sees history then tail"), then resolves the endpoint (uniform not-found) for the replay. The caller MUST Close the returned handle.
type StreamEvent ¶
StreamEvent is one fan-out unit delivered to a live subscriber: the captured Request and its Seq (SPEC-0005 "One Stream, Two Transports").
type Subscription ¶
type Subscription struct {
// contains filtered or unexported fields
}
Subscription is a live tail handle over an endpoint's capture stream, returned by Service.Subscribe. The caller drives Events until Lagged fires or its context is cancelled, then calls Close.
func (*Subscription) Close ¶
func (s *Subscription) Close()
Close unsubscribes the tail. It is safe to call from a deferred call site.
func (*Subscription) Events ¶
func (s *Subscription) Events() <-chan StreamEvent
Events is the channel of live captured-request events for the endpoint.
func (*Subscription) Lagged ¶
func (s *Subscription) Lagged() <-chan struct{}
Lagged closes when this subscriber fell too far behind and was dropped; the reader should tear down and let the client resume from its Last-Event-ID.