webhook

package
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Sep 12, 2026 License: MIT Imports: 17 Imported by: 0

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

View Source
const (
	DefaultListLimit = 50
	MaxListLimit     = 200
)

DefaultListLimit / MaxListLimit bound GET /v1/hooks/{id}/requests (SPEC-0005 "paginated/bounded").

View Source
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.

View Source
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).

View Source
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

View Source
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 BodyInfo

type BodyInfo struct {
	SHA256    string
	Size      int64
	Truncated bool
	Inline    bool
}

BodyInfo describes a captured request body opened for streaming download.

type BodyRef

type BodyRef struct {
	SHA256    string
	Size      int64
	Truncated bool
}

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

type RequestPage struct {
	Requests   []Request
	NextBefore int64
}

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

func (s *Service) Capture(ctx context.Context, publicID string, in CaptureInput) (*Request, error)

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

func (s *Service) CreateEndpoint(ctx context.Context, in EndpointInput) (*Endpoint, error)

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

func (s *Service) GetEndpoint(ctx context.Context, publicID string) (*Endpoint, error)

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

func (s *Service) GetRequest(ctx context.Context, publicID string, seq int64) (*Request, error)

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

func (s *Service) MaxBodyBytes() int64

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

type StreamEvent struct {
	Type    EventType
	Seq     int64
	Request *Request
}

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.

Jump to

Keyboard shortcuts

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