webhook

package
v0.0.0-...-6f161fa Latest Latest
Warning

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

Go to latest
Published: Aug 28, 2026 License: GPL-3.0 Imports: 29 Imported by: 0

Documentation

Overview

Package webhook delivers Joro's own events to an endpoint the operator configured.

A webhook adds exactly one idea — a delivery target — and borrows the rest. internal/trigger already answers *when*: a webhook names trigger references the same way an automation does, and the same Store -> Resolve -> Compile -> Subject -> Matches path decides whether an event is worth a delivery. What is new here is where the bytes go, and the two rules below are what make that safe to hand to something other than the operator.

The destination is fixed before any wire byte

This is internal/localcmd's argv rule in a second setting. A capability names a webhook by *id*; the URL, the headers and the secrets are never a capability argument, never a capability result, and never in an MCP tool schema. An agent chooses among endpoints the operator already created and cannot create, edit, or resolve one — there is no create or edit capability, which is the whole of that guarantee, since "webhook." is not a reserved prefix in internal/capability and nothing else would refuse one. AllowAutomations is the second half: a webhook is invocable from a run only because the operator ticked it, which is the same shape as an operator arming a command automation.

The body's shape is fixed before any event value

A custom body is authored as a JSON *document*, parsed at save time, and rendered by walking the decoded structure and substituting only inside string leaves, then re-marshalling. Keys, nesting, types and array lengths therefore cannot be moved by a target-controlled host header or finding name, and escaping is total because encoding/json does it rather than a quoting rule written here. See template.go, which is where the walk lives.

Delivery does not go through Joro's proxy

httptools.SendViaProxy would capture the webhook URL and payload into History, scan Joro's own outbound secret with the detect engine, let Match & Replace rewrite it, and let an enabled intercept stall it in the operator's queue. A direct client avoids all four — and it is also why a webhook watching request.captured cannot feed itself, since its own deliveries never become captures.

Index

Constants

View Source
const (
	// MaxMessageLen bounds the one-line message a script supplies. Long enough for a real
	// finding summary, short enough that a notification channel is not a file transfer.
	MaxMessageLen = 2 << 10

	// MaxDataBytes bounds the structured payload an envelope carries. Ignored by the presets
	// and by templates, which have {{MESSAGE}} instead.
	MaxDataBytes = 8 << 10
)
View Source
const (
	TokenEvent    = "EVENT"
	TokenTrigger  = "TRIGGER"
	TokenWebhook  = "WEBHOOK"
	TokenTime     = "TIME"
	TokenSummary  = "SUMMARY"
	TokenMessage  = "MESSAGE"
	TokenInstance = "INSTANCE"
)

The reserved tokens, available on every event because Joro supplies them rather than the event doing so.

View Source
const (
	MaxIDLen       = 64
	MaxNameLen     = 80
	MaxDescLen     = 400
	MaxURLLen      = 2048
	MaxHeaders     = 20
	MaxHeaderLen   = 1024
	MaxSecretLen   = 512
	MaxTemplateLen = 16 << 10

	// MaxBodyBytes caps a rendered body. Past it the delivery is refused rather than
	// truncated: half a JSON document is not a smaller notification, it is a broken one.
	MaxBodyBytes = 256 << 10

	// MaxValueLen caps one substituted value. A finding name or a URL is far under it; the
	// cap exists so a field that turns out to be long cannot be the whole body.
	MaxValueLen = 4 << 10

	// MaxTriggerRefs bounds how many triggers one webhook watches.
	MaxTriggerRefs = 8
)

Bounds on one webhook. Small for the same reason internal/trigger's are: matching runs on the goroutine draining Joro's event bus, and these multiply with the number of enabled webhooks.

View Source
const (
	DefaultTimeoutMs     = 10_000
	MaxTimeoutMs         = 60_000
	DefaultRetries       = 2
	MaxRetries           = 5
	DefaultMinIntervalMs = 1_000
	MaxMinIntervalMs     = 3_600_000

	// MaxBatch bounds how many events one batched delivery carries.
	MaxBatch = 50
)

Delivery policy bounds. Defaults are what a webhook gets when it names none.

View Source
const (
	FormatEnvelope = "envelope"
	FormatSlack    = "slack"
	FormatDiscord  = "discord"
	FormatTemplate = "template"
)

Body formats.

The three presets exist because they are the shapes an operator would otherwise have to look up, and getting one wrong produces a 400 from a service rather than an error from Joro. FormatTemplate is the escape hatch and the only one that reads the Template field.

View Source
const (
	DeliveryEach  = "each"
	DeliveryBatch = "batch"
)

Delivery modes: one request per event, or one carrying the batch.

A template renders one event's fields, so it implies DeliveryEach and Validate says so rather than silently rendering the first of fifty.

View Source
const (
	AuthNone   = "none"
	AuthBearer = "bearer"
	AuthBasic  = "basic"
	AuthHeader = "header"
)

Authentication kinds.

View Source
const (
	HeaderEvent     = "X-Joro-Event"
	HeaderTrigger   = "X-Joro-Trigger"
	HeaderDelivery  = "X-Joro-Delivery"
	HeaderTimestamp = "X-Joro-Timestamp"

	// DefaultSignatureHeader carries the HMAC when signing is on. Configurable because a
	// receiver written for another producer may already look somewhere else.
	DefaultSignatureHeader = "X-Joro-Signature"
)

The headers every delivery carries, so a receiver can route and de-duplicate without parsing the body. Named after the convention the services this talks to already use.

View Source
const FileVersion = 1

FileVersion is the on-disk schema version. Bump it only alongside a migration; there is no backfill machinery here for the reason triggers.json has none — a definition an operator relies on must not inherit "helpfully add the new default" semantics.

View Source
const MaxWebhooks = 50

MaxWebhooks bounds the file. Smaller than MaxTriggers because each one holds a compiled filter set and a delivery queue, and because a hundred notification endpoints is not a configuration anyone meant.

Variables

View Source
var (
	// ErrNotFound means no webhook has that id.
	ErrNotFound = errors.New("no such webhook")
	// ErrExists means one already does.
	ErrExists = errors.New("a webhook with that id already exists")
)

AuthKinds lists every authentication kind, in the order the editor offers them.

Deliveries lists every delivery mode.

Formats lists every body format, in the order the editor offers them.

View Source
var Methods = []string{"POST", "PUT", "PATCH"}

Methods lists the request methods a webhook may use. Only the three that carry a body: a webhook with nothing to say is not a webhook.

Functions

func SubstitutableFields

func SubstitutableFields(events []string) map[string][]string

SubstitutableFields lists the event fields a template may name, per event, for the editor.

Types

type Auth

type Auth struct {
	Kind string `json:"kind"`

	// Token is the bearer token, the AuthHeader value, or the basic password.
	Token string `json:"token,omitempty"`

	// User is the basic username. Not a secret on its own, so it is returned by the API
	// where Token is not.
	User string `json:"user,omitempty"`

	// Header names the header for AuthHeader.
	Header string `json:"header,omitempty"`
}

Auth is how a delivery authenticates.

A separate field rather than "write it yourself in Headers", because the editor can then label the secret and the API can withhold it. AuthHeader is the general case and exists so a service with its own scheme does not need a code change.

type ClientFactory

type ClientFactory func(insecure bool) http.Client

ClientFactory returns the client one delivery uses. A function rather than a client so the per-webhook InsecureTLS opt-in is honored without this package building a tls.Config, which is the rule internal/proxy/tlsconfig.go states.

type Deliverer

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

Deliverer paces, renders and sends.

func NewDeliverer

func NewDeliverer(store *Store, client ClientFactory, instance string, broadcast chan<- any) *Deliverer

NewDeliverer returns a deliverer. broadcast may be nil.

func (*Deliverer) Enqueue

func (d *Deliverer) Enqueue(id string, ev Event)

Enqueue adds one matched event to a webhook's queue. Called on the dispatcher goroutine, so it never blocks and never does I/O.

func (*Deliverer) Fire

func (d *Deliverer) Fire(ctx context.Context, principal, id, message string, data json.RawMessage) (FireResult, error)

Fire sends one delivery on behalf of a run.

Synchronous, unlike an event-driven delivery, and deliberately so: a script that notified a channel should be able to say whether it landed, and the alternative is a fire-and-forget call whose only failure signal is the operator noticing nothing arrived. It is bounded by the webhook's own timeout and does not retry — a retry loop inside a capability call is a run's budget being spent on waiting.

func (*Deliverer) Forget

func (d *Deliverer) Forget(id string)

Forget drops a webhook's queue and log, for one that was deleted or disabled.

func (*Deliverer) List

func (d *Deliverer) List() []Listed

List returns the webhooks a run may fire, sorted by name.

Only the ones the operator opted in: a run has no reason to know about an endpoint it cannot reach, and listing one would invite a script to report it as unavailable rather than simply not existing.

func (*Deliverer) Log

func (d *Deliverer) Log(id string) []Delivery

Log returns a webhook's recent attempts, newest first.

func (*Deliverer) Run

func (d *Deliverer) Run(ctx context.Context)

Run starts the pump and the worker pool, and blocks until ctx is cancelled.

func (*Deliverer) Test

func (d *Deliverer) Test(ctx context.Context, id string) (TestResult, error)

Test renders a webhook against a sample of the first event it watches and delivers it.

The sample is built from the field catalog rather than replayed from captured traffic, so a test says something on a fresh Joro with nothing in History and on a webhook watching an event that has no corpus at all — a finished campaign, a finished run. What it verifies is the half an operator gets wrong: the rendered bytes, the headers, the signature, and whether the endpoint accepts them.

type Delivery

type Delivery struct {
	ID      string    `json:"id"`
	At      time.Time `json:"at"`
	Event   string    `json:"event"`
	Trigger string    `json:"trigger,omitempty"`

	// Events is how many events this delivery carried, Dropped how many the queue lost
	// before it.
	Events  int `json:"events"`
	Dropped int `json:"dropped,omitempty"`

	Attempts   int    `json:"attempts"`
	Status     int    `json:"status,omitempty"`
	DurationMs int64  `json:"durationMs"`
	Error      string `json:"error,omitempty"`
}

Delivery is one attempt, as the editor lists it.

type Dispatcher

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

Dispatcher watches Joro's events and hands matches to the deliverer.

func NewDispatcher

func NewDispatcher(store *Store, deliver *Deliverer) *Dispatcher

NewDispatcher returns a dispatcher and wires the deliverer to read its compiled set.

func (*Dispatcher) Observe

func (d *Dispatcher) Observe(ev any)

Observe classifies one event and enqueues a delivery for every webhook whose conditions it satisfies.

Exported because automation.completed never reaches the bus — a per-run broadcast would be a firehose an agent controls, so jsautomation reports a finished run in process. The API wires that path to this method, which is why the vocabulary here is the catalog's rather than any producer's own struct.

func (*Dispatcher) Run

func (d *Dispatcher) Run(ctx context.Context, events <-chan any)

Run is the dispatcher loop. events is a subscription to Joro's event bus; it is read here rather than in its own goroutine so that observing an event and deciding on it are serialized, and the armed set needs no lock against a second path.

type Event

type Event struct {
	// On is the event kind. Ref is the trigger reference that matched, which is what
	// distinguishes two triggers watching one event.
	On  string `json:"-"`
	Ref string `json:"-"`

	At time.Time `json:"-"`

	// Fields is the catalog projection: every non-bytes field the event actually carried.
	Fields map[string]any `json:"-"`

	// Summary is Joro's one-liner, computed once here so the presets and the envelope agree.
	Summary string `json:"-"`
}

Event is one thing that happened, projected down to what a delivery may say about it.

type FireResult

type FireResult struct {
	Webhook    string `json:"webhook"`
	Status     int    `json:"status"`
	DurationMs int64  `json:"durationMs"`
}

FireResult is what a script is told about its delivery.

type Header struct {
	Name  string `json:"name"`
	Value string `json:"value"`
}

Header is one custom header. Value is a secret as often as not — an API key, a channel token — so it lives under the same 0600 file and the same write-only API rule as Auth.

type Listed

type Listed struct {
	ID          string `json:"id"`
	Name        string `json:"name"`
	Description string `json:"description,omitempty"`
	Enabled     bool   `json:"enabled"`
}

Listed is one webhook as a script may see it. No URL, no headers, no secrets: knowing where a notification goes is not something a run needs in order to send one, and it is the half an exfiltration would want.

type ReservedToken

type ReservedToken struct {
	Name        string `json:"name"`
	Token       string `json:"token"`
	Description string `json:"description"`
}

ReservedToken describes one reserved placeholder for the editor's reference table. Held beside the resolver that implements it, so a token cannot be documented one way and substituted another.

func ReservedTokens

func ReservedTokens() []ReservedToken

ReservedTokens describes every placeholder Joro supplies itself.

type Signing

type Signing struct {
	Enabled bool   `json:"enabled"`
	Secret  string `json:"secret,omitempty"`
	Header  string `json:"header,omitempty"`
}

Signing is HMAC-SHA256 over the timestamp and the body, so a receiver can prove a delivery came from this Joro and is not a replay.

The signed string is "<unix seconds>.<body>" rather than the body alone, which is what makes the timestamp header meaningful: a receiver that checks only the body signature will accept a captured delivery forever.

type Store

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

Store holds the operator's webhooks.

func NewStore

func NewStore(dir string, triggers TriggerResolver) (*Store, error)

NewStore opens the webhook file, creating nothing until the first write.

A file that will not parse is a hard error rather than a silent empty set, for the reason trigger.NewStore gives: the two look nothing alike from the operator's side. An empty store is a Joro that quietly stopped notifying anyone; a loud failure says why.

func (*Store) Create

func (s *Store) Create(w Webhook) (Webhook, error)

Create stores a new webhook.

func (*Store) Delete

func (s *Store) Delete(id string) error

Delete removes a webhook.

func (*Store) Get

func (s *Store) Get(id string) (Webhook, error)

Get returns one webhook, secrets included. Callers serving the API must strip them; see handlers_webhooks.go.

func (*Store) List

func (s *Store) List() []Webhook

List returns every webhook, sorted by name, each with Problem filled in.

func (*Store) Revision

func (s *Store) Revision() uint64

Revision reports the mutation counter.

func (*Store) SetState

func (s *Store) SetState(id string, fn func(*Webhook)) (Webhook, error)

SetState changes what Joro decided about a webhook rather than what the operator did — the breaker pausing one. Enabled is left alone so resuming restores the operator's intent.

func (*Store) TriggerRevision

func (s *Store) TriggerRevision() uint64

TriggerRevision reports the trigger store's counter, so the dispatcher recompiles when a trigger changes without the webhook itself being touched.

func (*Store) Update

func (s *Store) Update(id string, w Webhook) (Webhook, error)

Update replaces a stored webhook. The id is frozen, as a trigger's and an automation's are.

A secret the caller left empty keeps what is stored, so the API can withhold secrets on the way out without a round trip silently clearing them. An operator clearing one on purpose changes the auth kind or turns signing off, both of which are visible acts.

func (*Store) UsedBy

func (s *Store) UsedBy(triggerID string) []string

UsedBy names the webhooks referencing a trigger, so deleting one that is still in use is refused rather than leaving a webhook silently poisoned.

type Template

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

Template is a parsed body template, held so a webhook's document is decoded once per edit rather than once per delivery.

func ParseTemplate

func ParseTemplate(src string, events []string) (*Template, error)

ParseTemplate decodes and checks a template against the vocabulary of the events this webhook watches.

events is the union of what its triggers resolve to. A token valid for one of them and not another is accepted — host is on both a capture and a finding, severity only on a finding — because the alternative is refusing every template a webhook on two events could write. The event that does not carry it renders it empty, which the editor says beside each field.

func (*Template) Render

func (t *Template) Render(c renderContext) ([]byte, error)

Render substitutes into the template and returns the body.

type TestResult

type TestResult struct {
	Body       string `json:"body"`
	Status     int    `json:"status"`
	DurationMs int64  `json:"durationMs"`
	Error      string `json:"error,omitempty"`
}

TestResult is what the editor shows for a dry run: the exact bytes sent and what came back.

type TriggerResolver

type TriggerResolver interface {
	Resolve(ref string) (trigger.Trigger, error)
	Revision() uint64
}

TriggerResolver resolves a reference to its definition. Satisfied by *trigger.Store, and nil-tolerated: without one only the built-in events exist, which is a Joro that has never had a custom trigger rather than an error.

type Webhook

type Webhook struct {
	ID          string `json:"id"`
	Name        string `json:"name"`
	Description string `json:"description,omitempty"`

	// Enabled is the operator's switch. Paused is Joro's: the breaker sets it when a webhook
	// exceeds its rate, and it is separate so resuming restores what the operator wanted
	// rather than asking them to remember it. Both persist.
	Enabled      bool   `json:"enabled"`
	Paused       bool   `json:"paused,omitempty"`
	PausedReason string `json:"pausedReason,omitempty"`

	// Triggers are references, resolved exactly as an automation's are: a built-in event
	// name, or a custom trigger id. An unresolvable reference never fires and never means
	// "no filter" — see dispatch.go.
	Triggers []string `json:"triggers"`

	URL     string   `json:"url"`
	Method  string   `json:"method"`
	Headers []Header `json:"headers,omitempty"`
	Auth    Auth     `json:"auth"`
	Signing Signing  `json:"signing"`

	Format   string `json:"format"`
	Template string `json:"template,omitempty"`
	Delivery string `json:"delivery"`

	// Retries is how many times a failed delivery is repeated, and zero means none. It is
	// the one field here where zero is a choice rather than an omission, which is why
	// Normalize does not fill it the way it fills the two around it — a zero timeout or a
	// zero interval means nothing, so those take the default. DefaultRetries is what the
	// editor seeds a new webhook with instead.
	TimeoutMs     int `json:"timeoutMs,omitempty"`
	Retries       int `json:"retries,omitempty"`
	MinIntervalMs int `json:"minIntervalMs,omitempty"`

	// InsecureTLS skips certificate verification for this one endpoint.
	//
	// Off by default and deliberately per-webhook rather than global: a webhook URL is
	// frequently the credential itself, so posting one over an unverified connection is a
	// real downgrade. It exists for an internal receiver with a self-signed cert, which is
	// the case that would otherwise push an operator to a worse workaround.
	InsecureTLS bool `json:"insecureTls,omitempty"`

	// AllowAutomations lets a sandboxed run fire this webhook by id. Off by default; see the
	// package doc.
	AllowAutomations bool `json:"allowAutomations,omitempty"`

	// Problem is computed on the way out and never persisted, the same way a trigger's is:
	// it carries why a stored webhook will not deliver, because the operator's only other
	// signal would be notifications that quietly stopped.
	Problem string `json:"problem,omitempty"`
}

Webhook is one configured endpoint.

func (*Webhook) HasSecrets

func (w *Webhook) HasSecrets() bool

HasSecrets reports whether this webhook holds anything the API must withhold.

func (*Webhook) Normalize

func (w *Webhook) Normalize()

Normalize trims and fills defaults. Called before Validate so a webhook that omits an optional field is accepted rather than corrected by the operator.

func (*Webhook) Validate

func (w *Webhook) Validate() error

Validate reports why a webhook cannot be stored.

This is the write path only, and the asymmetry with Compile is the same one internal/trigger documents: reject what you can explain to someone who is standing there, refuse to act on what you cannot. A webhook already on disk that fails here still loads and still lists, with Problem saying why it will not deliver.

func (*Webhook) ValidateTemplate

func (w *Webhook) ValidateTemplate(events []string) error

ValidateTemplate checks the body template against the vocabulary of the events this webhook's triggers resolve to.

Separate from Validate because it needs the trigger store to resolve a reference, and this type deliberately knows nothing about one — the same reason Manifest.Validate does not check that a trigger reference resolves. Store.Create and Store.Update call both.

Jump to

Keyboard shortcuts

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