Hive Hive
Sign in

feat(server): add outbound webhooks

GitHub issue · Closed

Metadata
Source
tuist/tuist #10748
Updated
Jun 24, 2026
Details

Implements Webhooks for Tuist Events as a first-class account resource. Owners register HTTPS endpoints from the dashboard, subscribe each one to the Tuist events it cares about, and Tuist takes care of signing, retries, and the per-delivery audit log.

What’s in this PR

Domain
  • Tuist.Webhooks.WebhookEndpoint schema with account_id, name, url, signing_secret, signing_secret_last_four, and event_types. Both url and signing_secret use Tuist.Vault.Binary so they’re Cloak-encrypted at rest on bytea columns; signing_secret_last_four mirrors the cleartext tail so the dashboard never decrypts the full key just to render the masked preview.
  • Tuist.Webhooks context: list_endpoints/1, list_endpoints_subscribed_to/2, get_account_endpoint/2, create_endpoint/2, update_endpoint/2, rotate_signing_secret/1, delete_endpoint/1, plus the delivery-side helpers list_deliveries/2, delivery_stats/2, deliveries_timeseries/2, get_delivery_attempt/2. All dashboard reads go through a narrow @dashboard_fields projection that omits signing_secret; only the delivery worker’s get_endpoint/1 keeps the full load.
  • Event catalog: test_case.created, test_case.updated, preview.created, preview.deleted. event_groups/0 returns them grouped by resource so the create modal can render a “Select all Test cases events” toggle next to the individual checkboxes.
  • Tuist.Webhooks.Dispatcher: call site for the rest of the codebase. dispatch_test_case_created/2, dispatch_test_case_event/3, dispatch_preview_created/1, dispatch_preview_deleted/1 — each one resolves the account, looks up subscribed endpoints, builds the envelope, and enqueues a delivery job per match. Single-endpoint enqueue failures are swallowed so one bad subscriber can’t block delivery to the others.
Signing + delivery
  • Tuist.Webhooks.Signature: HMAC-SHA256 helpers (sign/3, verify/4, generate_secret/0). Signed string is \"<unix_ts>.<raw_json>\", header is Tuist-Signature: t=<ts>,v1=<hex_sig>, with a 5-minute replay tolerance and constant-time comparison. New secrets are minted as tuist_webhook_<base64url(32 random bytes)> — matches the tuist_<scope>_<random> convention used by project tokens, SCIM tokens, and account tokens.
  • Tuist.Webhooks.Workers.DeliveryWorker: dedicated Oban worker on its own :webhooks queue (concurrency 20). max_attempts: 7 covers the initial send plus six retries on the 1m → 5m → 30m → 2h → 8h → 24h schedule. 10-second request timeout, redirects disabled (otherwise a 3xx to a private/metadata address would bypass the SSRF guard). Endpoint is re-read on every attempt so URL / secret edits and rotations take effect on the next retry; a deleted endpoint is :discard instead of retrying forever.
  • Tuist.OAuth2.SSRFGuard.pin/1 + connect_options/1 are applied before each request. Loopback, RFC1918, link-local, ULA, and cloud-metadata destinations are rejected before bytes leave the process, and the rejection is still recorded as a failed attempt so it shows up in the dashboard.
Delivery audit log (ClickHouse)
  • webhook_delivery_attempts lives in ClickHouse — append-only with bursty fan-out is exactly the workload CH’s MergeTree handles best, and the time-bucketed chart queries get dramatically faster than the equivalent PG aggregates would.
  • MergeTree engine, PARTITION BY toYYYYMM(inserted_at), ORDER BY (webhook_endpoint_id, inserted_at). Skip indexes on event_type and status accelerate the dashboard’s filter paths. Retention is unbounded, matching other customer-facing audit data (build_runs, test_case_runs, runner_jobs).
  • Writes go through Tuist.Ingestion.BufferableDeliveryAttempt.Buffer.insert/1 accumulates RowBinary in memory and flushes on size or interval, keeping the worker hot path async and avoiding “too many parts” at fan-out time.
  • Each row captures id, event_id, event_type, attempt, status (delivered/failed), the request body, request headers (JSON-encoded), response status / headers / body, error string, and duration_ms. Response bodies truncated at 64 KiB so a chatty upstream can’t bloat the row. response_status: 0 is the sentinel for “no HTTP response received” (network error, SSRF reject).
Dashboard
  • Webhooks tab on the account settings: lists endpoints with name, URL, masked signing secret (tuist_webhook_•••••XYZ suffix only, read from the cleartext signing_secret_last_four column), the count of subscribed events, and a kebab with Rotate secret / Delete endpoint. Whole row navigates to the detail page; a custom StopRowNavigate JS hook stops the kebab click from being captured by LiveView’s window-level bindNav (and replays the menu items’ phx-click so confirm dialogs and event dispatch still work from inside the row link).
  • Create endpoint modal: name + HTTPS URL + grouped event checkboxes with per-group select-all. On submit the new signing secret is shown once via a Clipboard hook; closing the modal removes it from the LiveView assigns. The form body lives in a shared TuistWeb.Components.WebhookEndpointForm component used by both the create and edit modals.
  • Endpoint detail page:
    • Summary card (URL, masked signing secret, subscribed events, created date) with Edit endpoint and a kebab for Rotate secret / Delete endpoint.
    • Event deliveries chart (ECharts line with Total + Failed series, interactive legend toggles, configurable date range).
    • Events table with cursor pagination (Flop, page size 20), filterable by status, event type, and event-id search; rows navigate to per-event detail.
  • Event detail page: Summary card (status, response code, attempt, duration, sent timestamp, destination), Request card with headers + pretty-printed JSON body, Response card with status / headers / body.
API docs
  • Six new OpenApiSpex schemas under TuistWeb.API.Schemas.Webhook (WebhookTestCase, WebhookPreview, plus a WebhookXxxEvent envelope per supported event type) registered into components.schemas via OpenApiSpex.add_schemas/2.
  • A top-level Webhook events OpenAPI tag carries a markdown description with a table of supported events, anchored to each event’s schema — Redoc renders it as a sidebar section on tuist.dev/api/docs. Description lives on the tag (not info.description) so Swift OpenAPI Generator doesn’t splat it onto the CLI’s Client.swift docblock.
  • dispatcher_test.exs casts every emitted payload against the matching schema so drift between the snapshot builders and the documented contract fails CI.
Integrations into existing flows
  • Tuist.Tests ingestion path calls Dispatcher.dispatch_test_case_created/2 for newly-observed test cases and dispatch_test_case_event/3 on flagged transitions (marked_flaky, muted, unskipped, …). Both fan-outs share the same "first run" filter as the first_run TestCaseEvent audit row, so the webhook and the audit log agree by construction.
  • Tuist.AppBuilds calls dispatch_preview_created/1 after a preview’s app build finishes uploading, and dispatch_preview_deleted/1 after a preview row is removed.
Ops / migrations
  • priv/repo/migrations/20260513120000_add_webhook_endpoints_table.exs — single consolidated PG migration that creates the table with the final shape (bytea-encrypted url + signing_secret, signing_secret_last_four, event_types, indexed on account_id).
  • priv/ingest_repo/migrations/20260515160000_create_webhook_delivery_attempts_table.exs — CH MergeTree with the partition / ordering described above.
  • priv/repo/seeds.exs adds two seeded endpoints (Notion automation, Slack relay) with 1500 attempts each on a 97/3 delivered/failed mix so the chart and table render with realistic data locally.
Docs + compliance
  • New customer-facing guide at /en/guides/integrations/webhooks (linked from the Integrations sidebar): event catalog, payload envelope, header reference, signature verification with a Node example (raw-body + constant-time compare), retry schedule, security notes. References the per-event OpenAPI schemas on tuist.dev/api/docs for the payload contract.
  • Changelog entry 2026.05.15-webhooks.md with the endpoint detail screenshot as the hero image.
  • server/data-export.md documents both new tables. Endpoint URL and signing secret are listed as non-exportable bearer credentials (encrypted at rest, often contain path / query tokens). Delivery attempts are exportable; retained indefinitely on the CH side.
  • dashboard_account.pot extracted for the new strings; no .po changes (handled by tuistit).

Tests

  • Tuist.WebhooksTest — context-level CRUD, masking, time-series bucketing, rotation, secret encryption round-trip.
  • Tuist.Webhooks.SignatureTest — sign / verify, tolerance, tampered-payload rejection, malformed-header rejection.
  • Tuist.Webhooks.DispatcherTest — fan-out, account-scope isolation, no-op when there are zero subscribers, envelope shape per event type, payload-against-schema conformance for every event (catches drift between dispatcher and OpenAPI schema at CI time).
  • Tuist.Webhooks.Workers.DeliveryWorkerTest — 2xx → :delivered, 5xx → :failed + retry, transport error → :failed, SSRF rejection records an attempt without making the request, deleted endpoint discards.
  • TuistWeb.WebhooksLiveTest — index render, create flow with secret disclosure, rotate, delete.
  • TuistWeb.WebhookLiveTest — detail render, filters, chart data, event navigation, pagination.

How to test locally — end-to-end with a real receiver

The full path including SSRF guard, real HMAC, real Oban queue, real retry backoff — five minutes of setup.

1. Start a public tunnel in one terminal:

cloudflared tunnel --url http://localhost:4000

Copy the https://…trycloudflare.com URL it prints.

2. Register the endpoint. Sign in to the dev server (tuistrocks@tuist.dev / tuistrocks), open /<account>/webhooks, click Add endpoint, paste the cloudflared URL, tick preview.created (easiest to trigger), save. Copy the displayed tuist_webhook_… secret — you only see it once.

3. Start the receiver in a second terminal — a zero-deps Node script lives at /tmp/tuist-webhook-receiver.mjs (see the script body in commit history if you want it in-repo). It captures the raw body, verifies the Tuist-Signature header in constant time, and prints the decoded payload:

TUIST_WEBHOOK_SECRET='tuist_webhook_…' node /tmp/tuist-webhook-receiver.mjs

4. Fire an event from a project pointing at the dev server:

tuist share <scheme> # uploads a build → fires preview.created

5. Verify in the receiver terminal:

──────────────────────────────────────────
2026-05-18T09:04:09Z preview.created id=b0a1d0e6-…
signature verified
{ \"id\": \"b0a1d0e6-…\", \"type\": \"preview.created\", \"created\": 1779…, \"object\": { } }

The dashboard chart’s Total line ticks up and the events table gains a new row; clicking it shows the exact request body + headers + response we just round-tripped.

Failure paths worth testing:

  • Kill the receiver mid-trigger → dashboard records a failed delivery, retry schedule (1m → 5m → …) kicks in; bring it back and watch the retry succeed.
  • Tamper with the secret env var → receiver returns 401, dashboard records failed with status 401.
  • Register a http://127.0.0.1:4000 URL instead of the tunnel → SSRFGuard rejects before sending, attempt recorded with blocked: private_ip_resolved.

Run the test suite

mix test test/tuist/webhooks_test.exs test/tuist/webhooks/ test/tuist_web/live/webhooks_live_test.exs test/tuist_web/live/webhook_live_test.exs

🤖 Generated with Claude Code

Comments
GA
github-actions[bot] May 13, 2026

🚨 TruffleHog Secret Scan Failed

Verified secrets were detected in this pull request.

Please take the following actions:

  1. Rotate the exposed credential(s) immediately - assume they are compromised
  2. Remove the secret from your code - use environment variables or a secrets manager instead
  3. If the secret was committed previously, you may need to rewrite git history using git filter-repo or similar tools

For more information, check the workflow run logs.

F
fortmarek May 18, 2026

When clicking “select all”, there’s a slight delay, likely because we are going to the server and back to the client to update the state. I’d investigate what it’d take to update those optimistically somehow.

This is a local server issue, you can observe that across all Noora components.