wacrm/supabase/migrations/028_webhook_endpoints.sql
Arnas Donauskas c0f390c401 feat(api): outbound event webhooks — /api/v1/webhooks (#245)
Completes the public API roadmap (#245): register HTTPS endpoints to be
POSTed to when things happen in an account, so automations can react to
inbound activity instead of polling.

- Migration 028: account-scoped webhook_endpoints (secret stored
  AES-GCM-encrypted) + an atomic record_webhook_failure() function.
- Scope webhooks:manage; management API GET/POST /api/v1/webhooks and
  GET/PATCH/DELETE /api/v1/webhooks/{id} (secret returned once).
- Events message.received / conversation.created / message.status_updated
  dispatched from the inbound webhook handler's after() block.
- Signed delivery (X-Wacrm-Signature = HMAC-SHA256 over t.body), single
  best-effort attempt, auto-disable after consecutive failures.
- Versioned 0.4.0; docs/public-api.md Webhooks section; CHANGELOG.

Fixes from the adversarial code review of this diff:
- SSRF: block private/loopback/link-local (incl. cloud metadata) targets
  at delivery time + redirect:'manual' so a public URL can't bounce
  internal (src/lib/webhooks/ssrf.ts).
- Status mirror UPDATE no longer uses .select().maybeSingle() (message_id
  is non-unique); account resolved via a bounded limit(1) read.
- Atomic failure counter via SQL RPC (was a racy read-modify-write).
- conversation.created emitted before the reaction short-circuit.
- Per-delivery id added for receiver dedupe; docs state at-least-once /
  unordered delivery.
- Signature verify tolerates hex case / header whitespace.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 14:53:18 +03:00

104 lines
5.0 KiB
PL/PgSQL

-- ============================================================
-- 028_webhook_endpoints.sql — Outbound event webhooks (public API)
--
-- Lets an account register HTTPS endpoints that wacrm POSTs to when
-- something happens (an inbound message arrives, a delivery status
-- changes, a conversation is created). This is the "react to inbound"
-- half of the public API (#245): instead of polling
-- `GET /api/v1/conversations`, an automation subscribes once and is
-- pushed the events it cares about.
--
-- Design notes
-- - Account-scoped, never user-scoped (same as `api_keys`).
-- `created_by` records who registered it (audit); ON DELETE SET
-- NULL so removing a teammate doesn't drop their integration's
-- endpoint.
-- - `secret` is the HMAC signing key. UNLIKE `api_keys` (where we
-- store only a hash because the key is a bearer credential the
-- *client* presents), here *we* sign each outgoing payload with
-- the secret and the receiver verifies it — so we need the
-- plaintext at delivery time. We store it AES-256-GCM-encrypted
-- at rest (same `encrypt()`/`decrypt()` as `whatsapp_config.
-- access_token`), and return the plaintext to the creator exactly
-- once so they can configure their verifier.
-- - `events[]` is the subscription filter (free text[], validated
-- in the app layer against `src/lib/webhooks/events.ts` — a new
-- event type is a code change, not a migration, mirroring scopes).
-- - `failure_count` counts *consecutive* delivery failures; the
-- deliverer auto-sets `is_active = false` once it crosses a
-- threshold so a permanently-dead endpoint stops being retried.
-- A successful delivery resets it to 0.
--
-- RLS
-- Settings-class, mirroring `api_keys`: any member may read the
-- roster; only admin+ may create/update/delete. The delivery path
-- and the public-API management routes both use the service-role
-- client (an API caller has no `auth.uid()`), so RLS is the guard
-- for any dashboard UI that reads the table directly.
--
-- Idempotent — safe to run multiple times.
-- ============================================================
CREATE TABLE IF NOT EXISTS webhook_endpoints (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
account_id uuid NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
url text NOT NULL, -- HTTPS endpoint we POST to
secret text NOT NULL, -- AES-256-GCM-encrypted HMAC signing secret
events text[] NOT NULL DEFAULT '{}',
is_active boolean NOT NULL DEFAULT true,
last_delivery_at timestamptz, -- last successful delivery
failure_count integer NOT NULL DEFAULT 0, -- consecutive failures; reset to 0 on success
created_at timestamptz NOT NULL DEFAULT now()
);
-- Every delivery + management query filters by account_id.
CREATE INDEX IF NOT EXISTS webhook_endpoints_account_id_idx
ON webhook_endpoints (account_id);
ALTER TABLE webhook_endpoints ENABLE ROW LEVEL SECURITY;
-- SELECT: any member of the account (viewer+) can see the roster.
DROP POLICY IF EXISTS webhook_endpoints_select ON webhook_endpoints;
CREATE POLICY webhook_endpoints_select ON webhook_endpoints FOR SELECT
USING (is_account_member(account_id));
-- INSERT / UPDATE / DELETE: admin+ only (settings-class).
DROP POLICY IF EXISTS webhook_endpoints_insert ON webhook_endpoints;
CREATE POLICY webhook_endpoints_insert ON webhook_endpoints FOR INSERT
WITH CHECK (is_account_member(account_id, 'admin'));
DROP POLICY IF EXISTS webhook_endpoints_update ON webhook_endpoints;
CREATE POLICY webhook_endpoints_update ON webhook_endpoints FOR UPDATE
USING (is_account_member(account_id, 'admin'));
DROP POLICY IF EXISTS webhook_endpoints_delete ON webhook_endpoints;
CREATE POLICY webhook_endpoints_delete ON webhook_endpoints FOR DELETE
USING (is_account_member(account_id, 'admin'));
-- ============================================================
-- Atomic consecutive-failure counter.
--
-- The deliverer records failures through this function rather than a
-- read-modify-write: two deliveries to the same endpoint can run
-- concurrently (e.g. conversation.created + message.received for one
-- inbound message), and a client-side `count = count + 1` would lose
-- increments, so a dead endpoint might never reach the auto-disable
-- threshold. The `+ 1` and the disable decision happen in one UPDATE.
-- Only ever disables (never re-enables) — re-enabling is an explicit
-- PATCH by an admin, which resets the counter.
-- ============================================================
CREATE OR REPLACE FUNCTION public.record_webhook_failure(
endpoint_id uuid,
max_failures int
)
RETURNS void AS $$
UPDATE webhook_endpoints
SET failure_count = failure_count + 1,
is_active = CASE
WHEN failure_count + 1 >= max_failures THEN false
ELSE is_active
END
WHERE id = endpoint_id;
$$ LANGUAGE sql SECURITY DEFINER SET search_path = public;