feat(integration): outgoing webhooks / event subscriptions (LC-75) #171

Merged
nrupard merged 2 commits from feat/lc-75-outgoing-webhooks into main 2026-05-21 16:11:43 +02:00
Owner

Summary

Outgoing webhooks / event subscriptions (LC-75). An admin registers a delivery URL + event filter + scope; matching events POST a signed JSON body to the URL with retries, backoff, and auto-disable.

Design

  • Storage (migration 0042): outgoing_webhooks (scope, events, url, signing_secret, failure counters, disabled_at) + outgoing_webhook_deliveries (per-attempt bookkeeping, pruned to 50/webhook). signing_secret is plaintext - the server computes the per-delivery HMAC and the receiver holds the same value to verify; shown once, rotatable, never logged.
  • Producers: crate::outgoing::enqueue (best-effort, non-blocking) resolves the room's enclave, matches enabled webhooks by scope + event, inserts a delivery per match. Wired at message.posted (both finalizers), message.edited, message.deleted, reaction.added.
  • Delivery: poll-based bg loop (spawn_outgoing_webhook_dispatcher, 10s tick, no AppState field - mirrors the reminders/scheduled dispatchers) POSTs with X-LetsChat-Event / X-LetsChat-Timestamp / X-LetsChat-Signature: sha256=<hmac> over the raw body. 2xx -> delivered + reset; failure -> backoff (1s,4s,16s,1m,5m,30m, 6 attempts) then mark failed + bump consecutive_failures, auto-disabling at 5.
  • Payload (stable, versioned): {"version":"1","event":"...","room_id":N,"data":{...}}.
  • Admin UI (standalone, org-admin): /admin/outgoing-webhooks list/create (reveal secret once)/rotate/enable-disable/delete + per-webhook delivery history. docs/api.md documents it.

Acceptance criteria

  • Register URL + event filter + scope (global/enclave/room).
  • Matching events POST a signed body.
  • Failed deliveries retry with exponential backoff to a cap.
  • Auto-disable after N consecutive failures; admin can re-enable.
  • Signing secret rotatable.
  • Per-webhook delivery history visible.
  • Payload schema versioned ("version":"1").
  • URLs + secrets never logged.

Tests

outgoing_webhooks.rs: event/scope matching, signed delivery against a local receiver (verifies HMAC), 5xx retry/backoff, auto-disable, disabled-not-matched, rotate; + a sign() unit test. Migration 0042 appended to all hand-rolled chat lists. just check, just test-saas green; just test clean apart from the documented routes_uploads concurrency flake (passes isolated).

🤖 Generated with Claude Code

## Summary Outgoing webhooks / event subscriptions (LC-75). An admin registers a delivery URL + event filter + scope; matching events POST a signed JSON body to the URL with retries, backoff, and auto-disable. ## Design - **Storage** (migration `0042`): `outgoing_webhooks` (scope, events, url, signing_secret, failure counters, `disabled_at`) + `outgoing_webhook_deliveries` (per-attempt bookkeeping, pruned to 50/webhook). `signing_secret` is plaintext - the server computes the per-delivery HMAC and the receiver holds the same value to verify; shown once, rotatable, never logged. - **Producers**: `crate::outgoing::enqueue` (best-effort, non-blocking) resolves the room's enclave, matches enabled webhooks by scope + event, inserts a delivery per match. Wired at `message.posted` (both finalizers), `message.edited`, `message.deleted`, `reaction.added`. - **Delivery**: poll-based bg loop (`spawn_outgoing_webhook_dispatcher`, 10s tick, **no AppState field** - mirrors the reminders/scheduled dispatchers) POSTs with `X-LetsChat-Event` / `X-LetsChat-Timestamp` / `X-LetsChat-Signature: sha256=<hmac>` over the raw body. 2xx -> delivered + reset; failure -> backoff (1s,4s,16s,1m,5m,30m, 6 attempts) then mark failed + bump `consecutive_failures`, auto-disabling at 5. - **Payload** (stable, versioned): `{"version":"1","event":"...","room_id":N,"data":{...}}`. - **Admin UI** (standalone, org-admin): `/admin/outgoing-webhooks` list/create (reveal secret once)/rotate/enable-disable/delete + per-webhook delivery history. `docs/api.md` documents it. ## Acceptance criteria - [x] Register URL + event filter + scope (global/enclave/room). - [x] Matching events POST a signed body. - [x] Failed deliveries retry with exponential backoff to a cap. - [x] Auto-disable after N consecutive failures; admin can re-enable. - [x] Signing secret rotatable. - [x] Per-webhook delivery history visible. - [x] Payload schema versioned (`"version":"1"`). - [x] URLs + secrets never logged. ## Tests `outgoing_webhooks.rs`: event/scope matching, signed delivery against a local receiver (verifies HMAC), 5xx retry/backoff, auto-disable, disabled-not-matched, rotate; + a `sign()` unit test. Migration `0042` appended to all hand-rolled chat lists. `just check`, `just test-saas` green; `just test` clean apart from the documented `routes_uploads` concurrency flake (passes isolated). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(integration): outgoing webhooks / event subscriptions (LC-75)
All checks were successful
Check / clippy + fmt + tests (pull_request) Successful in 1m22s
a1dd501f90
An admin registers a delivery URL + event filter + scope (global / enclave / room). When a matching event fires, the server POSTs a signed JSON body to the URL with retries, exponential backoff, and auto-disable after repeated failures.

Storage (migration 0042, chat.db): outgoing_webhooks (scope, events, url, signing_secret, failure counters, disabled_at) + outgoing_webhook_deliveries (per-attempt bookkeeping with bounded retention). signing_secret is stored plaintext because the server needs it to compute the per-delivery HMAC and the receiver holds the same value to verify; it is shown once, rotatable, and never logged.

Event production: event handlers call crate::outgoing::enqueue (best-effort, never blocks the request) which resolves the room's enclave, finds enabled webhooks whose scope + event filter match, and inserts one delivery row each. Wired at message.posted (finalize_message_send + the incoming-webhook finalize), message.edited, message.deleted, and reaction.added.

Delivery: a poll-based background loop (spawn_outgoing_webhook_dispatcher in main, 10s tick, no AppState field) claims due deliveries and POSTs the body with X-LetsChat-Event, X-LetsChat-Timestamp, and X-LetsChat-Signature: sha256=<hmac> over the raw body keyed by the webhook's signing_secret. 2xx -> delivered + reset failure counter; non-2xx / network error -> reschedule with backoff (1s,4s,16s,1m,5m,30m, 6 attempts) then mark failed and bump consecutive_failures, auto-disabling at 5. Old delivery rows are pruned to 50/webhook each tick.

Payload schema is stable + versioned: {"version":"1","event":"...","room_id":N,"data":{...}}.

Admin UI (standalone, org-admin): /admin/outgoing-webhooks lists + creates (reveals the signing secret once), rotate-secret, enable/disable (re-enable clears the failure counter), delete, and a per-webhook delivery-history page. Linked from the admin nav. docs/api.md documents the payload, headers, signature, and retry policy.

Tests: outgoing_webhooks.rs covers event/scope matching, signed delivery against a local receiver (verifies the HMAC), 5xx retry/backoff, auto-disable at threshold, disabled-not-matched, and secret rotation; plus a sign() unit test. Migration 0042 appended to every hand-rolled chat migration list. just check, just test (one pre-existing routes_uploads concurrency flake, passes isolated), just test-saas all green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
fix(webhooks): SSRF guard + replay-resistant signature for outgoing webhooks (LC-75)
All checks were successful
Check / clippy + fmt + tests (pull_request) Successful in 1m17s
Create release / Create release from merged PR (pull_request) Has been skipped
69d5d62d9a
Address review findings:

- SSRF: the admin-supplied delivery URL is now validated with the LC-72 webhook_url_ok guard (rejects loopback / private / link-local / metadata hosts), not just an http(s) scheme check. Since failed-delivery response bodies are stored and shown to the admin, an unguarded URL was a read primitive against internal services.
- Replay resistance: the HMAC signature is now computed over "timestamp.body" instead of the body alone, so a captured delivery can't be replayed indefinitely; the receiver binds the signature to the timestamp it also checks.
- Scope SQL: enclave match uses `scope_id = ?` (was `IS ?`); with `=`, a message in a room with no enclave never matches enclave-scoped webhooks, and the operator is idiomatic.
- Dropped the unused signing_secret field from the admin list row view (it was never rendered; the secret is shown only on create/rotate).

Tests: signature assertion recomputes over timestamp.body; new scope_matching_room_enclave_global test covers room/enclave/global match + exclusion (previously only global was tested).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
nrupard deleted branch feat/lc-75-outgoing-webhooks 2026-05-21 16:11:43 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/lets-chat!171
No description provided.