Artifact 6b4b6fdb42449deba354c2484b18c0d128e880af13b0d45482960edc94370254:
- File
internal/webhook/webhook.go
— part of check-in
[550b7af81c]
at
2026-08-16 19:38:20
on branch trunk
— webhooks: the standard outbound surface (0001 Principle 6)
Activity events reach third parties over signed HTTP, so an integrator never has to speak NATS. Subscriptions live in forge OLTP; the signing secret is printed once at creation and never readable again.
Delivery is durable rather than a fire-and-forget goroutine: each (event, subscription) is one JetStream message, acked on 2xx, NAKed with growing backoff, MaxDeliver as the dead-letter boundary. An endpoint down during a deploy still gets its events — proven live by writing with the receiver stopped and seeing the delivery land when it returned.
0003 §3 names dagnats for this class of work; it runs on the JetStream quarry already embeds, so this uses that directly and leaves dagnats as the upgrade path when v3 brings the engine in for CI.
Mentions are not forwarded: they address a person, not a repository. (user: dmestas, size: 8226) [annotate] [blame] [check-ins using]
// Package webhook is Principle 6's standard outbound surface: quarry's // activity, delivered to third-party endpoints over plain signed HTTP, // so an integrator never has to speak NATS to react to a forge event. // // Delivery is durable by way of JetStream rather than an in-process // retry loop: an endpoint that is down during a deploy must not cost // its owner the events, and a quarry restart must not either. Each // (event, subscription) pair is one stream message; the consumer acks // on success, NAKs with backoff otherwise, and JetStream's MaxDeliver // is the dead-letter boundary. 0003 §3 names dagnats as the durable // mechanism for this class of work; dagnats runs on the same JetStream // quarry already embeds, and using it directly keeps the dependency // surface unchanged until v3 brings the engine in for CI. // // This package is a data plane; the arch gate restricts its importers. package webhook import ( "bytes" "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "log" "net/http" "time" "github.com/nats-io/nats.go" ) const ( // StreamName carries pending deliveries. StreamName = "QUARRYHOOK" subjectAll = "quarry.hook.>" durable = "hook-dispatcher" // maxDeliver bounds attempts per delivery. Past it JetStream stops // redelivering and the message is logged as dead-lettered — an // endpoint that has failed this many times is broken, not flaky. maxDeliver = 6 // deliverTimeout is per attempt. A receiver that cannot answer in // this long is treated as failed and retried. deliverTimeout = 10 * time.Second ) // Event is the payload a subscriber receives. It is deliberately the // same shape quarry raises internally for activity, so the webhook // surface and the notify surface never drift into two vocabularies. type Event struct { ID string `json:"id"` // idempotency key; stable across redeliveries Repo string `json:"repo"` // Kind string `json:"kind"` // wiki.save, ticket.new, forum.post, … Actor string `json:"actor"` // authenticated author, or "anonymous" Summary string `json:"summary"` // one line, human-readable Link string `json:"link"` // site-relative path to the thing At string `json:"at"` // RFC3339, when quarry raised it } // delivery is one queued attempt: the event plus which subscription it // is for. The secret is NOT carried on the stream — it is looked up at // send time, so rotating a secret takes effect on pending deliveries // and the stream never stores a key. type delivery struct { SubID int64 `json:"sub"` Event Event `json:"event"` } // Subscriptions is the lookup the dispatcher needs; the OLTP database // satisfies it. type Subscriptions interface { ActiveWebhooks(repo string) ([]Sub, error) SubByID(id int64) (Sub, bool) } // Sub is one endpoint the dispatcher can deliver to. type Sub struct { ID int64 URL string Secret string Active bool } // Dispatcher queues and delivers events. type Dispatcher struct { js nats.JetStreamContext subs Subscriptions http *http.Client sub *nats.Subscription } // New prepares the delivery stream and returns a dispatcher. Start must // be called to begin delivering. func New(js nats.JetStreamContext, subs Subscriptions) (*Dispatcher, error) { // Work-queue retention is right here and wrong for ZITEVT: a // delivery has exactly one consumer and is finished once acked, // where an artifact event must be folded independently by every // node. cfg := &nats.StreamConfig{ Name: StreamName, Subjects: []string{subjectAll}, Retention: nats.WorkQueuePolicy, MaxAge: 24 * time.Hour, // an undeliverable event stops being interesting } if _, err := js.AddStream(cfg); err != nil && err != nats.ErrStreamNameAlreadyInUse { return nil, fmt.Errorf("webhook: stream: %w", err) } return &Dispatcher{js: js, subs: subs, http: &http.Client{Timeout: deliverTimeout}}, nil } // Raise queues one event for every active subscription on its repo. // Queueing is a publish, so it is fast enough to call from an op; the // actual HTTP happens in the worker. No subscriptions means no work. func (d *Dispatcher) Raise(e Event) error { if d == nil { return nil } subs, err := d.subs.ActiveWebhooks(e.Repo) if err != nil { return err } for _, s := range subs { body, err := json.Marshal(delivery{SubID: s.ID, Event: e}) if err != nil { return err } // The idempotency key covers (event, subscription): a redelivered // stream message must not become a second event for the same // endpoint, and two endpoints must each get their own. msgID := fmt.Sprintf("%s:%d", e.ID, s.ID) if _, err := d.js.Publish("quarry.hook."+e.Repo, body, nats.MsgId(msgID)); err != nil { return err } } return nil } // Start begins the delivery worker. It returns once subscribed; the // worker runs until ctx is cancelled. func (d *Dispatcher) Start(ctx context.Context) error { if d == nil { return nil } sub, err := d.js.PullSubscribe(subjectAll, durable, nats.MaxDeliver(maxDeliver), nats.AckWait(deliverTimeout+5*time.Second)) if err != nil { return fmt.Errorf("webhook: subscribe: %w", err) } d.sub = sub go d.run(ctx) return nil } func (d *Dispatcher) run(ctx context.Context) { for { if ctx.Err() != nil { return } msgs, err := d.sub.Fetch(16, nats.MaxWait(2*time.Second)) if err != nil { continue // timeout is the common case: nothing to deliver } for _, m := range msgs { d.deliver(ctx, m) } } } func (d *Dispatcher) deliver(ctx context.Context, m *nats.Msg) { var dl delivery if err := json.Unmarshal(m.Data, &dl); err != nil { // Undecodable: retrying cannot help, so drop it rather than // occupying a redelivery slot forever. log.Printf("webhook: undecodable delivery, dropping: %v", err) m.Term() return } sub, ok := d.subs.SubByID(dl.SubID) if !ok || !sub.Active { // Deleted or paused between queueing and delivery. Not an error: // the subscriber asked to stop hearing from us. m.Term() return } if err := d.post(ctx, sub, dl.Event); err != nil { meta, _ := m.Metadata() if meta != nil && meta.NumDelivered >= maxDeliver { log.Printf("webhook: giving up on subscription %d (%s) after %d attempts: %v", sub.ID, sub.URL, meta.NumDelivered, err) m.Term() return } // Backoff grows with attempts so a flapping endpoint is not // hammered; JetStream redelivers after the delay. delay := time.Duration(1<<min(uint(deliveredCount(m)), 5)) * time.Second m.NakWithDelay(delay) return } m.Ack() } func deliveredCount(m *nats.Msg) int { meta, err := m.Metadata() if err != nil || meta == nil { return 1 } return int(meta.NumDelivered) } // post sends one event and reports whether the endpoint accepted it. // Any 2xx is success; everything else is retried. func (d *Dispatcher) post(ctx context.Context, s Sub, e Event) error { body, err := json.Marshal(e) if err != nil { return err } req, err := http.NewRequestWithContext(ctx, "POST", s.URL, bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", "quarry-webhook") req.Header.Set("X-Quarry-Event", e.Kind) req.Header.Set("X-Quarry-Repo", e.Repo) req.Header.Set("X-Quarry-Delivery", e.ID) req.Header.Set("X-Quarry-Signature", Sign(s.Secret, body)) resp, err := d.http.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode > 299 { return fmt.Errorf("endpoint answered %d", resp.StatusCode) } return nil } // Sign returns the signature header value for a body: HMAC-SHA256 over // the EXACT bytes sent, so a receiver verifies before parsing and never // has to reproduce quarry's JSON formatting. func Sign(secret string, body []byte) string { mac := hmac.New(sha256.New, []byte(secret)) mac.Write(body) return "sha256=" + hex.EncodeToString(mac.Sum(nil)) } // Verify is the check a receiver performs; exported so quarry's own // tests and any Go integrator share one implementation. func Verify(secret string, body []byte, signature string) bool { return hmac.Equal([]byte(Sign(secret, body)), []byte(signature)) }