diff --git a/components/eventdock/README.md b/components/eventdock/README.md new file mode 100644 index 0000000000000..6fd55249726e7 --- /dev/null +++ b/components/eventdock/README.md @@ -0,0 +1,38 @@ +# EventDock + +[EventDock](https://eventdock.app) is a webhook reliability layer. It receives +your incoming webhooks at a stable ingest URL, **retries** failed deliveries with +exponential backoff (up to 7 attempts over several hours), **de-duplicates** +repeats, parks permanent failures in a **Dead Letter Queue**, and forwards +successful events to a destination you configure. + +These Pipedream components let you put EventDock in front of your workflows so a +brief outage, deploy, or error never means a **lost** webhook. + +> **Free tier: 5,000 events/month.** [Sign up at eventdock.app](https://eventdock.app). + +## Components + +| Component | Type | What it does | +|---|---|---| +| **New Reliable Webhook Event (Instant)** | source | Creates an EventDock endpoint pointing at this source's Pipedream URL, then emits one event per reliable delivery. Point your provider at the EventDock ingest URL. | +| **Reliable HTTP Forward** | action | Sends a payload to a destination URL *through* EventDock so the delivery is buffered, retried, and DLQ'd on permanent failure. | + +## Authentication + +Both components use an **EventDock API key** (starts with `evdk_`), created in +the EventDock dashboard under **Settings → API Keys**. Pipedream sends it as a +Bearer token. The default API base URL is `https://api.eventdock.app`; override +with the `EVENTDOCK_BASE_URL` environment variable only for self-hosted/staging. + +## How the source works + +``` +Provider ──▶ EventDock ingest URL ──▶ (buffer · retry · de-dupe · DLQ) ──▶ Pipedream source URL ──▶ your workflow +``` + +On deploy the source calls `POST /v1/endpoints` with `upstream_url` set to its +own `$.interface.http` endpoint, and logs the **ingest URL** to configure in your +provider. On teardown it soft-deletes the endpoint (`DELETE /v1/endpoints/:id`). +The EventDock event id is used as the **dedupe key**, so a retried delivery of +the same event never double-fires your workflow. diff --git a/components/eventdock/actions/reliable-http-forward/README.md b/components/eventdock/actions/reliable-http-forward/README.md new file mode 100644 index 0000000000000..77be660625fe1 --- /dev/null +++ b/components/eventdock/actions/reliable-http-forward/README.md @@ -0,0 +1,37 @@ +# Reliable HTTP Forward + +## Overview + +Sends a payload to a destination URL **through [EventDock](https://eventdock.app)** +so the delivery is buffered, **retried** with exponential backoff, and parked in a +**Dead Letter Queue** if the destination stays down. Use it anywhere you'd +normally do an HTTP POST but can't afford to silently lose the request. + +On the first run for a given destination URL it creates a reusable EventDock +endpoint; subsequent runs reuse it. + +## Example use cases + +- Forward processed data to a **flaky internal API** with automatic retries. +- Fan results out to a partner webhook that occasionally returns 5xx. +- Replace a bare `$.send.http(...)` with a delivery you can **replay** from the + EventDock dashboard if it permanently fails. + +## Getting started + +1. Add this action to a workflow step and connect your **EventDock** account + ([free tier](https://eventdock.app) = 5,000 events/month). +2. Set the **Destination URL** (where the payload should ultimately land) and the + **Payload** (the JSON body to deliver). +3. Run it. The action ensures an EventDock endpoint exists for that destination, + POSTs the payload to EventDock's ingest URL, and returns the **endpoint id** + and **event id**. EventDock then handles reliable delivery to your + destination. + +## Troubleshooting + +- **Endpoint limit reached**: the free tier allows 3 endpoints; this action + creates one per distinct destination URL. Reuse destinations or upgrade. +- **Delivery not arriving at the destination?** Check the event in the EventDock + dashboard — if it failed all retries it's in the DLQ and can be replayed. +- **Invalid upstream URL**: the destination must be a valid absolute URL. diff --git a/components/eventdock/actions/reliable-http-forward/reliable-http-forward.mjs b/components/eventdock/actions/reliable-http-forward/reliable-http-forward.mjs new file mode 100644 index 0000000000000..4e376f876432a --- /dev/null +++ b/components/eventdock/actions/reliable-http-forward/reliable-http-forward.mjs @@ -0,0 +1,162 @@ +import eventdock from "../../eventdock.app.mjs"; +import { axios } from "@pipedream/platform"; +import { + isPrivateIpLiteral, + normalizeHostLiteral, +} from "../../common/ssrf.mjs"; + +export default { + key: "eventdock-reliable-http-forward", + name: "Reliable HTTP Forward", + description: + "Send a payload to a destination URL *through EventDock* so the delivery is buffered, retried with exponential backoff, and parked in a DLQ if the destination stays down. Returns the EventDock event id. On the first run for a given destination this creates a reusable EventDock endpoint. [See the docs](https://eventdock.app/docs)", + version: "0.0.1", + type: "action", + annotations: { + destructiveHint: false, + openWorldHint: true, + readOnlyHint: false, + }, + props: { + eventdock, + destinationUrl: { + type: "string", + label: "Destination URL", + description: + "The final URL EventDock should deliver the payload to (with retries). For example your app's API or another webhook receiver. Example: `https://api.example.com/webhook`", + }, + payload: { + type: "object", + label: "Payload", + description: + "The JSON body to forward. EventDock stores it, then attempts delivery to the destination URL, retrying on failure. Example: `{ \"event\": \"user.created\", \"id\": 123 }`", + }, + endpointName: { + type: "string", + label: "Endpoint Name", + description: + "Optional name for the EventDock endpoint created for this destination. Defaults to the destination host.", + optional: true, + }, + debug: { + type: "boolean", + label: "Debug", + description: + "Include the EventDock ingest URL in this step's output. Off by default — the ingest URL is a capability URL (anyone with it can post events to your endpoint), so it's withheld from step exports unless you explicitly need it.", + optional: true, + default: false, + }, + }, + methods: { + /** + * Validate the destination is a well-formed http(s) URL before we do any + * work. This is a client-side check for fast feedback — the EventDock BACKEND + * is the authoritative SSRF guard: it re-resolves DNS and re-validates the + * upstream_url, blocking private/internal/link-local ranges (the same probe + * logic the platform applies to every webhook destination) regardless of what + * we send. We do NOT resolve DNS here (it can change between this check and + * delivery — that's the backend's job), but we DO reject the full set of + * private/reserved IP *literals* (IPv4 + IPv6) via the ported isPrivateIpLiteral + * so an obvious 127.0.0.1 / 10.x / 169.254.169.254 / ::1 / fc00:: target is + * caught immediately rather than slipping past a partial check. + */ + validateDestinationUrl(destinationUrl) { + let parsed; + try { + parsed = new URL(destinationUrl); + } catch { + throw new Error( + `Destination URL is not a valid URL: ${destinationUrl}`, + ); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error( + `Destination URL must use http:// or https:// (got "${parsed.protocol}").`, + ); + } + const host = normalizeHostLiteral(parsed.hostname); + // Reject internal-looking hostnames regardless of DNS. + const internalName = + host === "localhost" || + host === "ip6-localhost" || + host === "ip6-loopback" || + host.endsWith(".localhost") || + host.endsWith(".internal") || + host.endsWith(".local") || + host.endsWith(".intranet") || + host.endsWith(".corp") || + host.endsWith(".home") || + host.endsWith(".lan"); + // Reject the full set of private/reserved IP literals (IPv4 + IPv6). + if (internalName || isPrivateIpLiteral(host)) { + throw new Error( + `Destination URL "${host}" looks internal/non-routable. EventDock only delivers to public destinations.`, + ); + } + return parsed; + }, + /** + * Find an existing, active EventDock endpoint whose upstream_url matches the + * destination so repeated runs reuse one endpoint instead of creating many. + * This action always creates `generic` endpoints, so we also require the + * provider to be `generic` — that way we never reuse a provider-specific + * endpoint (e.g. a stripe endpoint created by the source for signature + * verification) that happens to share the same upstream URL, which would + * apply the wrong verification settings to a plain forward. + */ + async findEndpointForDestination($, destinationUrl) { + const { endpoints = [] } = await this.eventdock.listEndpoints({ $ }); + return endpoints.find( + (ep) => + ep.upstream_url === destinationUrl && + ep.status !== "deleted" && + (ep.provider || "generic") === "generic", + ); + }, + async ensureEndpoint($, destinationUrl) { + const existing = await this.findEndpointForDestination($, destinationUrl); + if (existing) { + return existing; + } + const host = this.validateDestinationUrl(destinationUrl).host; + return this.eventdock.createEndpoint({ + $, + name: this.endpointName || `Pipedream forward → ${host}`, + upstreamUrl: destinationUrl, + provider: "generic", + }); + }, + }, + async run({ $ }) { + // Client-side sanity check (fast feedback). EventDock's backend is the + // authoritative SSRF guard and re-validates the destination on its side. + this.validateDestinationUrl(this.destinationUrl); + + const endpoint = await this.ensureEndpoint($, this.destinationUrl); + + // POST the payload to EventDock's ingest URL. EventDock accepts (202), + // stores it, then handles the reliable delivery to destinationUrl for us. + const ingestResponse = await axios($, { + method: "POST", + url: endpoint.ingest_url, + headers: { "Content-Type": "application/json" }, + data: this.payload, + }); + + $.export( + "$summary", + `Queued reliable delivery to ${this.destinationUrl} (event ${ingestResponse?.event_id ?? "accepted"})`, + ); + + const result = { + endpoint_id: endpoint.id, + ...ingestResponse, + }; + // The ingest URL is a capability URL — only surface it when explicitly + // requested via the Debug flag, so it doesn't leak into step exports/logs. + if (this.debug) { + result.ingest_url = endpoint.ingest_url; + } + return result; + }, +}; diff --git a/components/eventdock/common/common-webhook.mjs b/components/eventdock/common/common-webhook.mjs new file mode 100644 index 0000000000000..29c3ebfdf838d --- /dev/null +++ b/components/eventdock/common/common-webhook.mjs @@ -0,0 +1,183 @@ +import eventdock from "../eventdock.app.mjs"; + +/** + * Shared base for EventDock webhook sources. + * + * On `activate()` it creates an EventDock endpoint whose upstream destination is + * this source's Pipedream HTTP endpoint (`this.http.endpoint`). Your provider + * points at the EventDock ingest URL; EventDock buffers, retries, de-dupes, and + * delivers reliable events to this source, which emits them into your workflow. + * + * On `deactivate()` it soft-deletes the EventDock endpoint so it doesn't leak + * against your plan's endpoint limit. + */ +export default { + props: { + eventdock, + // `http` gives us a stable public URL to register as the EventDock upstream. + http: { + type: "$.interface.http", + customResponse: true, + }, + db: "$.service.db", + }, + hooks: { + async activate() { + const provider = this.provider || "generic"; + const upstreamUrl = this.http.endpoint; + + // Idempotent activate: a redeploy / re-activate reuses an existing, + // non-deleted endpoint already pointing at this source's URL instead of + // orphaning the old one and creating a fresh duplicate. Mirrors the n8n + // node's checkExists/find-or-create lifecycle. + // + // CRITICAL (verification correctness): we only reuse an endpoint when the + // upstream_url AND the provider match. Matching on upstream_url alone would + // silently reuse an endpoint whose `provider` (and therefore its signature- + // verification behaviour) is stale after the user switched providers — e.g. + // flipping stripe -> github but still verifying against the old scheme. The + // EventDock API treats `provider` as immutable (no PATCH path for it), so a + // provider change means: create a fresh endpoint, then soft-delete the + // stale one to avoid orphan buildup against the plan's endpoint limit. + let endpoint = await this._findReusableEndpoint(upstreamUrl, provider); + if (endpoint) { + // Same upstream + same provider: reuse — but push the (possibly rotated) + // provider_secret so verification settings never go stale. The list API + // never returns the secret, so we can't diff it; PATCHing is the safe, + // idempotent way to guarantee the current secret is in effect. (No-op for + // the generic provider, which has no signature verification.) + if (provider !== "generic" && this.providerSecret) { + await this.eventdock.updateEndpoint({ + endpointId: endpoint.id, + provider_secret: this.providerSecret, + }); + } + } else { + // Soft-delete any stale endpoint that points at the SAME upstream but a + // DIFFERENT provider, so we don't accumulate orphans on provider switch. + await this._deleteStaleUpstreamEndpoints(upstreamUrl, provider); + endpoint = await this.eventdock.createEndpoint({ + name: this.getEndpointName(), + upstreamUrl, + provider, + providerSecret: this.providerSecret, + }); + } + + this._setEndpointId(endpoint.id); + this._setIngestUrl(endpoint.ingest_url); + // Log the endpoint id only — the ingest URL is a capability URL (anyone + // with it can post events), so we don't print it to the deploy logs. + console.log( + `EventDock endpoint ready: ${endpoint.id}. Find its ingest URL in the EventDock dashboard to point your ${provider} webhooks at.`, + ); + }, + async deactivate() { + const endpointId = this._getEndpointId(); + if (!endpointId) { + return; + } + try { + await this.eventdock.deleteEndpoint({ endpointId }); + console.log(`EventDock endpoint deleted: ${endpointId}`); + } catch (err) { + // Don't block teardown if the endpoint was already removed. + console.log(`Could not delete EventDock endpoint ${endpointId}: ${err?.message ?? err}`); + } finally { + // Clear the stored id (and cached ingest URL) so a later re-activate + // doesn't think a now-deleted endpoint still exists. + this._clearEndpointId(); + } + }, + }, + methods: { + _getEndpointId() { + return this.db.get("endpointId"); + }, + _setEndpointId(id) { + this.db.set("endpointId", id); + }, + _setIngestUrl(url) { + this.db.set("ingestUrl", url); + }, + _clearEndpointId() { + this.db.set("endpointId", undefined); + this.db.set("ingestUrl", undefined); + }, + /** + * List EventDock endpoints and return the first non-deleted one whose + * upstream_url AND provider both match, or undefined. Matching on BOTH (not + * upstream_url alone) is what keeps activate() from reusing an endpoint with + * stale verification settings after a provider switch. + */ + async _findReusableEndpoint(upstreamUrl, provider) { + const { endpoints = [] } = await this.eventdock.listEndpoints(); + return endpoints.find( + (ep) => + ep.upstream_url === upstreamUrl && + ep.status !== "deleted" && + (ep.provider || "generic") === provider, + ); + }, + /** + * Soft-delete any non-deleted endpoint that points at the same upstream URL + * but a DIFFERENT provider. Called before creating a fresh endpoint on a + * provider switch so stale endpoints don't pile up against the plan limit. + */ + async _deleteStaleUpstreamEndpoints(upstreamUrl, provider) { + const { endpoints = [] } = await this.eventdock.listEndpoints(); + const stale = endpoints.filter( + (ep) => + ep.upstream_url === upstreamUrl && + ep.status !== "deleted" && + (ep.provider || "generic") !== provider, + ); + for (const ep of stale) { + try { + await this.eventdock.deleteEndpoint({ endpointId: ep.id }); + } catch (err) { + // Best-effort cleanup — don't block activation if a stale endpoint + // can't be removed (e.g. already gone / transient API error). + console.log( + `Could not soft-delete stale EventDock endpoint ${ep.id}: ${err?.message ?? err}`, + ); + } + } + }, + getEndpointName() { + return `Pipedream · EventDock ${this.provider || "generic"} source`; + }, + /** + * Builds the EventDock metadata object from the X-EventDock-* headers that + * the delivery worker attaches to every forwarded request. + */ + getEventDockMeta(headers = {}) { + // Pipedream lower-cases header keys. + const attempt = headers["x-eventdock-attempt"]; + const attemptNum = attempt !== undefined ? Number(attempt) : null; + return { + eventId: headers["x-eventdock-event-id"] ?? null, + attempt: attemptNum, + ingestTimestamp: + headers["x-eventdock-timestamp"] !== undefined + ? Number(headers["x-eventdock-timestamp"]) + : null, + correlationId: headers["x-eventdock-correlation-id"] ?? null, + isRetry: attemptNum !== null ? attemptNum > 0 : null, + }; + }, + generateMeta(meta) { + // Use the EventDock event id as the dedupe key when present so a retried + // delivery of the same event never double-fires the workflow. + const id = meta.eventId ?? `evt_${Date.now()}_${Math.random().toString(36).slice(2)}`; + const summary = meta.eventId + ? `Event ${meta.eventId}${meta.isRetry ? ` (retry #${meta.attempt})` : ""}` + : "EventDock delivery"; + return { + id, + summary, + ts: meta.ingestTimestamp ?? Date.now(), + }; + }, + }, +}; diff --git a/components/eventdock/common/ssrf.mjs b/components/eventdock/common/ssrf.mjs new file mode 100644 index 0000000000000..fd0b8b94301a1 --- /dev/null +++ b/components/eventdock/common/ssrf.mjs @@ -0,0 +1,193 @@ +// SSRF IP-literal classification for the EventDock Pipedream components. +// +// Ported logic-for-logic from the EventDock backend's canonical validator: +// eventdock-platform/apps/worker/src/routes/webhook-probe.ts +// (parseIPv4 / isPrivateIPv4 / expandIPv6 / isPrivateIPv6 / isPrivateIpLiteral). +// +// Keeping this in lock-step with the backend means the client-side check in the +// "Reliable HTTP Forward" action rejects exactly the same private / loopback / +// link-local / CGNAT / metadata / documentation / IPv6 ULA ranges the platform +// does — no partial, drifting subset. The EventDock BACKEND remains the +// authoritative guard (it re-resolves DNS and re-validates server-side); this is +// a fast client-side check that catches obvious literal-IP mistakes early. + +/** Parse a dotted-quad IPv4 string into 4 octets, or null if not a valid IPv4. */ +export function parseIPv4(host) { + const m = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (!m) return null; + const octets = [ + Number(m[1]), + Number(m[2]), + Number(m[3]), + Number(m[4]), + ]; + if (octets.some((o) => o > 255)) return null; + return octets; +} + +/** + * Returns true if the given IPv4 octets fall in a private, loopback, + * link-local, reserved, or otherwise non-publicly-routable range. + */ +export function isPrivateIPv4(octets) { + const [ + a, + b, + ] = octets; + + if (a === 0) return true; // 0.0.0.0/8 — "this network" + if (a === 10) return true; // 10.0.0.0/8 — RFC1918 private + if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 — CGNAT (RFC6598) + if (a === 127) return true; // 127.0.0.0/8 — loopback + if (a === 169 && b === 254) return true; // 169.254.0.0/16 — link-local (incl. metadata) + if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 — RFC1918 private + if (a === 192 && b === 0 && octets[2] === 0) return true; // 192.0.0.0/24 — IETF + if (a === 192 && b === 0 && octets[2] === 2) return true; // 192.0.2.0/24 — TEST-NET-1 + if (a === 192 && b === 168) return true; // 192.168.0.0/16 — RFC1918 private + if (a === 198 && (b === 18 || b === 19)) return true; // 198.18.0.0/15 — benchmarking + if (a === 198 && b === 51 && octets[2] === 100) return true; // 198.51.100.0/24 — TEST-NET-2 + if (a === 203 && b === 0 && octets[2] === 113) return true; // 203.0.113.0/24 — TEST-NET-3 + if (a >= 224) return true; // 224/4 multicast, 240/4 reserved, 255.255.255.255 broadcast + + return false; +} + +/** + * Expand an IPv6 address (possibly with "::") into 8 16-bit groups, or null if + * it cannot be parsed. Handles IPv4-mapped/embedded forms by converting the + * trailing dotted-quad into two hextets. + */ +export function expandIPv6(input) { + let host = input.trim(); + const pct = host.indexOf("%"); // strip zone id (fe80::1%eth0) + if (pct !== -1) host = host.slice(0, pct); + if (host.length === 0) return null; + if (!host.includes(":")) return null; + + // Normalise an embedded IPv4 suffix (::ffff:127.0.0.1) into two hex groups. + const lastColon = host.lastIndexOf(":"); + const tail = host.slice(lastColon + 1); + const v4 = parseIPv4(tail); + if (v4) { + const hi = ((v4[0] << 8) | v4[1]).toString(16); + const lo = ((v4[2] << 8) | v4[3]).toString(16); + host = `${host.slice(0, lastColon + 1)}${hi}:${lo}`; + } + + const doubleColon = host.split("::"); + if (doubleColon.length > 2) return null; // more than one "::" is invalid + + const toGroups = (s) => (s === "" ? [] : s.split(":")); + + let groups; + if (doubleColon.length === 2) { + const head = toGroups(doubleColon[0]); + const back = toGroups(doubleColon[1]); + const fill = 8 - head.length - back.length; + if (fill < 0) return null; + groups = [ + ...head, + ...Array(fill).fill("0"), + ...back, + ]; + } else { + groups = toGroups(host); + } + + const nums = []; + for (const g of groups) { + if (g === "") return null; + if (!/^[0-9a-fA-F]{1,4}$/.test(g)) return null; + nums.push(parseInt(g, 16)); + } + if (nums.length !== 8) return null; + return nums; +} + +/** + * Returns true if the given IPv6 address is loopback, link-local, unique-local, + * unspecified, or an IPv4-mapped/embedded address that maps to a private IPv4. + */ +export function isPrivateIPv6(groups) { + if (groups.length !== 8) return true; // fail closed + + const [ + g0, + g1, + g2, + g3, + g4, + g5, + g6, + g7, + ] = groups; + + // :: (unspecified) and ::1 (loopback) + if (g0 === 0 && g1 === 0 && g2 === 0 && g3 === 0 && g4 === 0 && g5 === 0 && g6 === 0) { + if (g7 === 0 || g7 === 1) return true; + } + + // IPv4-mapped (::ffff:a.b.c.d) and IPv4-compatible (::a.b.c.d). + if (g0 === 0 && g1 === 0 && g2 === 0 && g3 === 0 && g4 === 0) { + if (g5 === 0xffff || g5 === 0) { + const a = (g6 >> 8) & 0xff; + const b = g6 & 0xff; + const c = (g7 >> 8) & 0xff; + const d = g7 & 0xff; + if (isPrivateIPv4([ + a, + b, + c, + d, + ])) return true; + if (g5 === 0 && !(a === 0 && b === 0)) return true; // deprecated ::a.b.c.d + } + } + + if ((g0 & 0xffc0) === 0xfe80) return true; // fe80::/10 — link-local + if ((g0 & 0xfe00) === 0xfc00) return true; // fc00::/7 — unique local (fc00::/fd00::) + if ((g0 & 0xff00) === 0xff00) return true; // ff00::/8 — multicast + if (g0 === 0x2001 && g1 === 0x0db8) return true; // 2001:db8::/32 — documentation + + // 64:ff9b::/96 NAT64 well-known prefix wraps an embedded IPv4. + if (g0 === 0x0064 && g1 === 0xff9b && g2 === 0 && g3 === 0 && g4 === 0 && g5 === 0) { + const a = (g6 >> 8) & 0xff; + const b = g6 & 0xff; + const c = (g7 >> 8) & 0xff; + const d = g7 & 0xff; + if (isPrivateIPv4([ + a, + b, + c, + d, + ])) return true; + } + + return false; +} + +/** + * Normalise a hostname for literal classification: lowercase, strip a trailing + * FQDN dot, and strip IPv6 brackets. (The host already comes from `URL`, so it's + * a bare host with no scheme/port.) + */ +export function normalizeHostLiteral(host) { + let h = String(host).trim().toLowerCase(); + if (h.endsWith(".")) h = h.slice(0, -1); + if (h.startsWith("[") && h.endsWith("]")) h = h.slice(1, -1); + return h; +} + +/** + * Classify a string that may be an IP literal. Returns true if it is a + * private/reserved literal (must be blocked). If it is not a recognisable IP + * literal it returns false (it is a hostname — DNS is the backend's job). + */ +export function isPrivateIpLiteral(host) { + const h = normalizeHostLiteral(host); + const v4 = parseIPv4(h); + if (v4) return isPrivateIPv4(v4); + const v6 = expandIPv6(h); + if (v6) return isPrivateIPv6(v6); + return false; +} diff --git a/components/eventdock/eventdock.app.mjs b/components/eventdock/eventdock.app.mjs new file mode 100644 index 0000000000000..883569a8b07d9 --- /dev/null +++ b/components/eventdock/eventdock.app.mjs @@ -0,0 +1,151 @@ +import { axios } from "@pipedream/platform"; + +export default { + type: "app", + app: "eventdock", + propDefinitions: { + // EventDock API key (custom-auth field surfaced as `this.$auth.api_key`). + // Declared as a secret string so Pipedream masks it in the UI, logs, and + // step exports. The actual auth config lives in the Pipedream app registry; + // this definition documents the field and enforces secret masking wherever + // a component references it as a prop. + apiKey: { + type: "string", + label: "API Key", + description: + "Your EventDock API key (starts with `evdk_`). Create one in the EventDock dashboard under Settings → API Keys.", + secret: true, + }, + provider: { + type: "string", + label: "Provider", + description: + "The webhook source. Pick a known provider to unlock EventDock signature verification and provider-aware de-duplication, or `generic` for any other source.", + options: [ + { label: "Generic (any source)", value: "generic" }, + { label: "Stripe", value: "stripe" }, + { label: "Shopify", value: "shopify" }, + { label: "GitHub", value: "github" }, + { label: "Twilio", value: "twilio" }, + ], + default: "generic", + }, + providerSecret: { + type: "string", + label: "Signing Secret", + description: + "Optional. The provider signing secret (e.g. Stripe webhook secret). When set for a known provider, EventDock verifies each incoming signature before accepting and forwarding the event. Ignored for the `generic` provider.", + secret: true, + optional: true, + }, + }, + methods: { + /** + * EventDock API base URL. Override via the `EVENTDOCK_BASE_URL` env var only + * for self-hosted / staging deployments. + */ + _baseUrl() { + return (process.env.EVENTDOCK_BASE_URL || "https://api.eventdock.app").replace(/\/+$/, ""); + }, + _headers() { + return { + "Authorization": `Bearer ${this.$auth.api_key}`, + "Content-Type": "application/json", + }; + }, + _makeRequest({ + $ = this, path, ...opts + }) { + return axios($, { + url: `${this._baseUrl()}${path}`, + headers: this._headers(), + ...opts, + }); + }, + /** + * POST /v1/endpoints — create an EventDock endpoint whose upstream + * destination is the given URL (the Pipedream source's HTTP endpoint). + */ + createEndpoint({ + name, upstreamUrl, provider, providerSecret, ...opts + }) { + const data = { + name, + upstream_url: upstreamUrl, + provider, + ...(provider !== "generic" && providerSecret + ? { provider_secret: providerSecret } + : {}), + }; + return this._makeRequest({ + method: "POST", + path: "/v1/endpoints", + data, + ...opts, + }); + }, + /** + * PATCH /v1/endpoints/:id — update mutable fields on an existing endpoint. + * The backend accepts name / upstream_url / status / provider_secret. It does + * NOT accept `provider` (the provider is immutable once an endpoint is + * created), so a provider CHANGE must be handled by create-new + delete-old, + * not by patching. We use this to push a ROTATED provider_secret onto an + * otherwise-matching endpoint so its signature-verification settings stay + * current without orphaning the endpoint. + */ + updateEndpoint({ + endpointId, ...data + }) { + const { + $, ...body + } = data; + return this._makeRequest({ + ...($ ? { $ } : {}), + method: "PATCH", + path: `/v1/endpoints/${encodeURIComponent(endpointId)}`, + data: body, + }); + }, + /** + * DELETE /v1/endpoints/:id — soft-delete the endpoint on source teardown. + */ + deleteEndpoint({ + endpointId, ...opts + }) { + return this._makeRequest({ + method: "DELETE", + path: `/v1/endpoints/${encodeURIComponent(endpointId)}`, + ...opts, + }); + }, + /** + * GET /v1/endpoints — list active endpoints for the authenticated tenant. + */ + listEndpoints(opts = {}) { + return this._makeRequest({ + method: "GET", + path: "/v1/endpoints", + ...opts, + }); + }, + /** + * GET /v1/usage — cheap, read-only call used to validate the API key. + */ + getUsage(opts = {}) { + return this._makeRequest({ + method: "GET", + path: "/v1/usage", + ...opts, + }); + }, + /** + * Cheap auth test, mirroring the n8n credential test: hits the read-only, + * side-effect-free GET /v1/usage endpoint. Returns true on 200; throws (via + * axios) on 401/invalid key so callers can surface a clear auth error. + */ + async testAuth(opts = {}) { + await this.getUsage(opts); + return true; + }, + }, +}; diff --git a/components/eventdock/package.json b/components/eventdock/package.json new file mode 100644 index 0000000000000..5b1c5586e278d --- /dev/null +++ b/components/eventdock/package.json @@ -0,0 +1,21 @@ +{ + "name": "@pipedream/eventdock", + "version": "0.0.1", + "description": "Pipedream components for EventDock — reliable, retried, de-duplicated webhook delivery", + "main": "eventdock.app.mjs", + "keywords": [ + "pipedream", + "eventdock", + "webhook", + "webhooks", + "reliability" + ], + "homepage": "https://pipedream.com/apps/eventdock", + "author": "EventDock (https://eventdock.app)", + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@pipedream/platform": "^3.0.0" + } +} diff --git a/components/eventdock/sources/reliable-webhook/README.md b/components/eventdock/sources/reliable-webhook/README.md new file mode 100644 index 0000000000000..30bff2b767881 --- /dev/null +++ b/components/eventdock/sources/reliable-webhook/README.md @@ -0,0 +1,41 @@ +# New Reliable Webhook Event (Instant) + +## Overview + +Emits a Pipedream event for **each reliable webhook delivery** from +[EventDock](https://eventdock.app). Instead of pointing a provider's webhook +directly at a Pipedream HTTP trigger (where a brief outage or error loses the +event), you point it at EventDock. EventDock buffers, retries with exponential +backoff, de-duplicates, and parks permanent failures in a Dead Letter Queue — +then delivers a clean event into this source. + +## Example use cases + +- **Stripe → Pipedream** without losing payment events during a deploy or error. +- **Shopify order webhooks** that must never be dropped, with automatic retries. +- **GitHub webhooks** with edge signature verification and de-duplication. +- Any **generic** webhook source you want to make durable. + +## Getting started + +1. Add this source to a workflow and connect your **EventDock** account + (API key from the EventDock dashboard → Settings → API Keys; + [free tier](https://eventdock.app) = 5,000 events/month). +2. Pick a **Provider** (`generic`, or `stripe`/`shopify`/`github`/`twilio` to + unlock signature verification + provider-aware de-dup). Optionally add the + **Signing Secret** for a known provider. +3. Deploy the source. It creates an EventDock endpoint and **logs the ingest + URL** (e.g. `https://api.eventdock.app/in/`). +4. Configure that **ingest URL** in your provider's webhook settings. +5. Each reliable delivery now triggers your workflow. The original payload is in + `event.body`; EventDock metadata (event id, attempt, `isRetry`) is in + `event.eventdock`. + +## Troubleshooting + +- **No events arriving?** Confirm your provider is pointed at the EventDock + **ingest URL** (logged on deploy), not at the old Pipedream URL. +- **Endpoint limit reached** on create: the EventDock free tier allows 3 + endpoints. Remove unused endpoints or upgrade. +- **Signature rejected**: for a known provider, ensure the **Signing Secret** + matches the one your provider is signing with, or switch to `generic`. diff --git a/components/eventdock/sources/reliable-webhook/reliable-webhook.mjs b/components/eventdock/sources/reliable-webhook/reliable-webhook.mjs new file mode 100644 index 0000000000000..d1814baed9261 --- /dev/null +++ b/components/eventdock/sources/reliable-webhook/reliable-webhook.mjs @@ -0,0 +1,67 @@ +import common from "../../common/common-webhook.mjs"; + +export default { + ...common, + key: "eventdock-reliable-webhook", + name: "New Reliable Webhook Event (Instant)", + description: + "Emit an event for each reliable webhook delivery from EventDock. EventDock receives your raw provider webhooks (Stripe, Shopify, GitHub, Twilio, or any generic source), retries failures with exponential backoff, holds dead letters in a DLQ, and de-duplicates — then forwards clean events here. [See the docs](https://eventdock.app/docs)", + version: "0.0.1", + type: "source", + dedupe: "unique", + props: { + ...common.props, + provider: { + propDefinition: [ + common.props.eventdock, + "provider", + ], + }, + providerSecret: { + propDefinition: [ + common.props.eventdock, + "providerSecret", + ], + }, + }, + async run(event) { + const { + body, headers = {}, + } = event; + + const meta = this.getEventDockMeta(headers); + + // Emit FIRST, then ack. If $emit throws OR rejects (e.g. Pipedream-side + // failure), we must NOT tell EventDock the delivery succeeded — otherwise the + // event is silently lost. We AWAIT $emit so an *async* emit failure is caught + // here (an un-awaited emit could reject after we'd already acked 200, losing + // the event). Responding non-2xx lets EventDock retry. The `unique` dedupe + // (keyed on the EventDock event id via generateMeta) makes that retry safe: a + // re-delivery of the same event won't double-fire downstream. + try { + await this.$emit( + { + body, + headers, + eventdock: meta, + deliveredAt: new Date().toISOString(), + }, + this.generateMeta(meta), + ); + } catch (err) { + // Tell EventDock the delivery failed so it retries (any non-2xx). + this.http.respond({ + status: 500, + body: { received: false }, + }); + throw err; + } + + // Only now, after a successful emit, ack so EventDock marks the delivery + // successful and stops retrying. (EventDock treats any 2xx as delivered.) + this.http.respond({ + status: 200, + body: { received: true }, + }); + }, +};