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
- Variables
- func SubstitutableFields(events []string) map[string][]string
- type Auth
- type ClientFactory
- type Deliverer
- func (d *Deliverer) Enqueue(id string, ev Event)
- func (d *Deliverer) Fire(ctx context.Context, principal, id, message string, data json.RawMessage) (FireResult, error)
- func (d *Deliverer) Forget(id string)
- func (d *Deliverer) List() []Listed
- func (d *Deliverer) Log(id string) []Delivery
- func (d *Deliverer) Run(ctx context.Context)
- func (d *Deliverer) Test(ctx context.Context, id string) (TestResult, error)
- type Delivery
- type Dispatcher
- type Event
- type FireResult
- type Header
- type Listed
- type ReservedToken
- type Signing
- type Store
- func (s *Store) Create(w Webhook) (Webhook, error)
- func (s *Store) Delete(id string) error
- func (s *Store) Get(id string) (Webhook, error)
- func (s *Store) List() []Webhook
- func (s *Store) Revision() uint64
- func (s *Store) SetState(id string, fn func(*Webhook)) (Webhook, error)
- func (s *Store) TriggerRevision() uint64
- func (s *Store) Update(id string, w Webhook) (Webhook, error)
- func (s *Store) UsedBy(triggerID string) []string
- type Template
- type TestResult
- type TriggerResolver
- type Webhook
Constants ¶
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 )
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.
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.
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.
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.
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.
const ( AuthNone = "none" AuthBearer = "bearer" AuthBasic = "basic" AuthHeader = "header" )
Authentication kinds.
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.
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.
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 ¶
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") )
var AuthKinds = []string{AuthNone, AuthBearer, AuthBasic, AuthHeader}
AuthKinds lists every authentication kind, in the order the editor offers them.
var Deliveries = []string{DeliveryEach, DeliveryBatch}
Deliveries lists every delivery mode.
var Formats = []string{FormatEnvelope, FormatSlack, FormatDiscord, FormatTemplate}
Formats lists every body format, in the order the editor offers them.
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 ¶
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 ¶
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 ¶
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 ¶
Forget drops a webhook's queue and log, for one that was deleted or disabled.
func (*Deliverer) List ¶
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) Test ¶
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 ¶
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) Get ¶
Get returns one webhook, secrets included. Callers serving the API must strip them; see handlers_webhooks.go.
func (*Store) SetState ¶
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 ¶
TriggerRevision reports the trigger store's counter, so the dispatcher recompiles when a trigger changes without the webhook itself being touched.
func (*Store) Update ¶
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.
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 ¶
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.
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 ¶
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 ¶
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 ¶
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 ¶
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.