mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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>
104 lines
5.0 KiB
PL/PgSQL
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;
|