{{ message }}
feat(events): outbound webhook subscription bus, HMAC + SSRF-guarded (plan 44) - #2235
Merged
Conversation
Generalize outbound webhooks from alerts-only to a configurable,
user-scoped bus: subscribe a URL to connection.created/connection.deleted
and receive HMAC-signed, retried, SSRF-guarded deliveries with a
dead-letter log.
- lib/events/{event-types,outbound-bus,subscription-store}.ts: emitEvent()
fans out to a user's enabled subscriptions matching the event type;
deliver() SSRF-guards via validateHostUrl (not createHostValidationFetch,
which throws on Workers for non-IP hosts), HMAC-SHA256 signs the raw
body, retries 3x with 0/2s/8s backoff (5xx/429 only), and records
delivered/failed/dead outcomes. Never throws; producers call it
fire-and-forget since this app has no waitUntil plumbed to routes.
- D1 migration 0010: webhook_subscriptions + webhook_deliveries, owner-
guarded UPDATE/DELETE (same IDOR class plan 04 fixed), proven against
real SQLite in subscription-store.sql.test.ts.
- routes/api/v1/webhooks/subscriptions(/$id, /$id/test, /$id/deliveries):
user-scoped CRUD + send-test + dead-letter view, gated behind Clerk + D1
like user-connections (fails closed on self-hosted/OSS — no behavior
change).
- Emit hooks wired at user-connections create/delete (the one producer
with a real per-user owner). alert.*/insight.* are intentionally not
wired: the health-alert cron and insights generator run against
env-configured hosts with no per-user owner anywhere in this codebase,
so subscribing to them would need a bigger ownership model this plan
doesn't ask for.
- New "Webhooks" tab in HealthSettingsDialog: list/add/delete/toggle,
reveal-once secret, send test, recent deliveries.
Co-Authored-By: duyetbot <bot@duyet.net>
duyet
enabled auto-merge (squash)
July 3, 2026 13:18
…ent-bus-outbound # Conflicts: # apps/dashboard/src/components/health/health-settings-dialog.tsx
- webhook-subscriptions-panel.tsx: onToggled callback took an unused boolean param, which didn't match refetch's RefetchOptions signature - subscription-store.ts: cast eventTypes to readonly string[] before .includes(eventType: string) instead of narrowing the param type - subscriptions/$id.ts: declare eventTypes as possibly undefined so TS doesn't flag it as used-before-assigned when body.eventTypes is absent Co-Authored-By: duyetbot <bot@duyet.net>
Contributor
☁️ Cloudflare Preview Deployment
|
…iled #2242) Resolves conflicts in user-connections.ts / user-connections/$id.ts where this branch's outbound webhook bus (emitEvent) and main's audit log (logEvent/logSessionEvent) both hook the connection create/delete path — kept both, audit log first then webhook emit. Adds emitEvent mocks to user-connections.test.ts and user-connections/$id.test.ts (main's audit-log tests didn't know about the webhook bus, so the route now transitively pulled in @chm/platform's cloudflare:workers binding under bun test). Co-Authored-By: duyetbot <bot@duyet.net>
Contributor
☁️ Cloudflare Preview Deployment
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

What (plan 44 — outbound webhook event bus)
emitEvent(userId, evt)fans out to a user's enabled subscriptions;deliver()SSRF-guards the destination (validateHostUrl), HMAC-SHA256-signs the raw body, retries ≤3× with bounded 0/2/8s backoff (5xx/429 only; 4xx + SSRF-block dead-letter immediately), and records every outcome. Never throws (producers call it unawaited — nowaitUntilplumbing).lib/events/*+0010_webhook_subscriptions.sql; 4 user-scoped routes (list/create, patch/delete, test-ping, deliveries); "Webhooks" settings tab;webhookSubscriptionsfeature flag.connection.created/connection.deletedare wired —alert.*/insight.*have no per-user owner model in this codebase, so they're intentionally not offered (plan authorizes "only list event types actually emitted").bun test src/lib/events→ 18 pass (fan-out with independently-verified HMAC, bounded retry incl. 429/4xx/network edges, SSRF rejection = zero fetch, owner-guarded UPDATE/DELETE vs real SQLite). Full suite 4849 pass.bun run check/depcruiseclean.🔶 Held for review — reasons
health/webhook.tsproxy (mirrors, not bypasses). Warrants a security read of: thesubscription-store.tsownership guards, and the plaintext webhook secret at rest in D1 (matches the plan's literal schema; lower blast radius than CH creds, but flag it).0010collision —0010_webhook_subscriptions.sqljoins plans 27/22 and held chore(deps): update docker/metadata-action digest to 6d6eaf3 #56 all at0010; renumber at merge.Note:
plans/README.mdnot edited (central reconciliation at merge).Co-Authored-By: duyetbot bot@duyet.net