diff --git a/.changeset/hosted-webhook-ingress.md b/.changeset/hosted-webhook-ingress.md new file mode 100644 index 00000000000..612ee78b9e5 --- /dev/null +++ b/.changeset/hosted-webhook-ingress.md @@ -0,0 +1,11 @@ +--- +"@trigger.dev/core": minor +"@trigger.dev/sdk": minor +"trigger.dev": minor +--- + +Add hosted webhooks: receive and verify provider webhooks as a task, with no ingress or verification code of your own. + +- `webhook()` declares an endpoint that routes a verified, typed event to an `onEvent` handler. Choose a source with a preset (`webhooks.stripe()`, `webhooks.github()`, and others) or `webhooks.custom(config)`. Declared webhooks are discovered like tasks and synced to a hosted URL on deploy. +- `filter` gates which deliveries run, using a type-safe expression checked against the event at author time (`event.`/`header.`/`webhook.` paths, `&&`/`||`, comparison and `in`/`contains` operators, field-to-field comparison, and array quantifiers). A non-matching delivery is still recorded, not routed. +- HTTP API for listing webhook endpoints and deliveries, plus rotate-secret, enable/disable, and replay. diff --git a/docs/docs.json b/docs/docs.json index 17eed721e09..7c61f1b4e85 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -152,6 +152,16 @@ } ] }, + { + "group": "Webhooks", + "pages": [ + "webhooks/overview", + "webhooks/sources", + "webhooks/connect", + "webhooks/deliveries", + "webhooks/filters" + ] + }, { "group": "Configuration", "pages": [ diff --git a/docs/webhooks/connect.mdx b/docs/webhooks/connect.mdx new file mode 100644 index 00000000000..0f6430e117a --- /dev/null +++ b/docs/webhooks/connect.mdx @@ -0,0 +1,35 @@ +--- +title: "Connecting a provider" +description: "Point a provider at the webhook URL and set the signing secret." +sidebarTitle: "Connecting a provider" +--- + +When you deploy (or run `dev`), each webhook task gets an **endpoint** with a unique, unguessable webhook URL. Open the webhook in the dashboard, go to **Endpoints**, and open the endpoint to find its **Connect** panel. + + + + Copy it from the endpoint's Connect panel. On Trigger.dev Cloud it looks like + `https://webhooks.trigger.dev/webhooks/v1/ingest/`. A self-hosted instance serves it from that + instance's own base URL. This is what you give the provider as its webhook destination. + + + A webhook can't accept deliveries until its signing secret is set. Until then every request is + rejected. There are two flows, and the Connect panel shows the right one for the provider: + + - **The provider generates the secret** (Stripe, Svix): copy it from the provider and paste it + into **Set secret**. + - **You choose the secret** (GitHub, or a service you control): click **Generate secret** and + Trigger.dev mints a strong secret and shows it once. Paste that into the provider's webhook config. + + + Add the webhook URL as the destination in your provider's dashboard. The Connect panel + shows the exact signature scheme (header, algorithm, signing string) the provider should use. + + + + + The signing secret is stored encrypted and is never shown again after it's set. To rotate it, + use **Rotate secret** (or **Regenerate**) and update the provider with the new value. + + +Once a provider is sending events, watch them arrive on the [Deliveries](/webhooks/deliveries) page, which also explains what an [endpoint](/webhooks/deliveries#endpoints) is. diff --git a/docs/webhooks/deliveries.mdx b/docs/webhooks/deliveries.mdx new file mode 100644 index 00000000000..95fcbed6519 --- /dev/null +++ b/docs/webhooks/deliveries.mdx @@ -0,0 +1,48 @@ +--- +title: "Deliveries and endpoints" +description: "Observe inbound webhook requests, the runs they trigger, and their payloads in the dashboard." +sidebarTitle: "Deliveries & endpoints" +--- + +The dashboard surfaces two concepts under the **Webhooks** section. + +## Deliveries + +A delivery is a single inbound request that passed verification. The **Deliveries** page lists every +delivery across all your webhooks (much like the Runs page), and you can filter by webhook, status, +delivery id, or run id. + +Open a delivery to see: + +- Its **status** and the **run** it triggered (linked). +- The verified **event payload** and the inbound **request headers**, on separate tabs. +- The external delivery id, idempotency key, and timestamps. + + + Duplicate deliveries are deduplicated automatically. The idempotency key is the provider's event id + (e.g. the Stripe event id, or GitHub's `X-GitHub-Delivery`), so a provider retry of the same event + resolves to the original delivery and won't trigger a second run. + + +## Endpoints + +An endpoint is the connection instance for a webhook: its webhook URL, signing-secret state, +verification scheme, and delivery history. Each webhook's **Endpoints** tab lists its endpoints (a +declared webhook has one), and opening an endpoint shows its [Connect panel](/webhooks/connect) and +its scoped deliveries. + +## What happens to a request + + + + The signature, timestamp, and idempotency key are checked. A failure returns `400` and records + nothing. + + + A verified request becomes a delivery, with its parsed event and headers stored. + + + The delivery is routed to your webhook task, which runs and calls `onEvent`. The delivery's status + reflects that run's outcome. + + diff --git a/docs/webhooks/filters.mdx b/docs/webhooks/filters.mdx new file mode 100644 index 00000000000..e368456f96f --- /dev/null +++ b/docs/webhooks/filters.mdx @@ -0,0 +1,95 @@ +--- +title: "Filtering deliveries" +description: "Gate which verified webhook deliveries run, with a type-safe filter checked against the event." +sidebarTitle: "Filters" +--- + +By default every verified delivery runs your `onEvent`. A **filter** is a server-side predicate that decides whether a delivery is routed at all. A delivery that does not match is still received and recorded, it just does not run anything. + +Filtering happens at the endpoint, before any run is triggered, so a filtered-out event costs you nothing. + +## Adding a filter + +Pass a `filter` string to `webhook()`. It is a small expression checked, at build time, against the event shape from your [source](/webhooks/sources#typing-the-event): + +```ts +import { webhook, webhooks } from "@trigger.dev/sdk"; + +export const onOrder = webhook({ + id: "orders", + source: webhooks.stripe(), + // only route succeeded payment intents over $100 + filter: "event.type == 'payment_intent.succeeded' && event.data.object.amount >= 10000", + onEvent: async ({ event }) => { + // only runs for deliveries that matched + }, +}); +``` + +The filter is type-safe: referencing a field that does not exist, or comparing it to the wrong kind of literal, is a compile error, not a runtime surprise. + +## What a non-match does + +A delivery that does not match is **not dropped**. It still returns `200` to the provider and is recorded as a [delivery](/webhooks/deliveries) with the status `FILTERED` and a reason naming the clause that failed (and the value it saw). It just never triggers a run. This keeps a filtered delivery auditable: you can see in the dashboard that it arrived and why it was not routed. + + + If a filter throws while evaluating (for example, a malformed event), the delivery is routed rather + than dropped. Filters fail open so a filter bug never silently swallows real events. + + +## The expression language + +A filter is one or more `path operator value` clauses combined with `&&` and `||` (use parentheses to group). + +### Paths + +A path reads from one of three namespaces: + +- `event.*`: the verified, parsed request body, for example `event.data.object.amount`. +- `header.*`: an inbound request header, matched case-insensitively, for example `header.x-github-event`. +- `webhook.*`: endpoint metadata (`webhook.source`, `webhook.id`, `webhook.deliveryId`, and for per-tenant endpoints `webhook.externalRef` / `webhook.tenantId`). + +### Operators + +| Operator | Meaning | +| --- | --- | +| `==` `!=` | equality | +| `>` `<` `>=` `<=` | numeric comparison | +| `in` `not in` | membership in a list, for example `event.type in ['a','b']` | +| `startsWith` `endsWith` `contains` | string matching | + +Values are strings in single quotes (`'created'`), numbers (`10000`), booleans (`true`), or a list for `in` / `not in`. + +### Comparing two fields + +The right-hand side can be another path instead of a literal, so you can compare two fields of the same event: + +```ts +filter: "event.billing.country == event.shipping.country"; +``` + +### Matching inside a list + +`any` and `all` quantify over an array, testing a sub-path on each element: + +```ts +// route only if at least one line item has a positive quantity +filter: "event.items any ( quantity > 0 )"; +``` + +### Spacing + +The type checker reads the filter as a token stream, so a couple of spots are strict about spacing: keep `in` / `not in` lists unspaced (`['a','b']`, not `[ 'a', 'b' ]`) and put spaces around the quantifier parentheses (`any ( ... )`). + +To match only certain event types, write a clause against the field that carries the type: `event.type` for Stripe / Svix / Square / Discord, or the `x-github-event` header for GitHub (the filter DSL can read a `header.` namespace too): + +```ts +import { webhook, webhooks } from "@trigger.dev/sdk"; + +export const onGithub = webhook({ + id: "github", + source: webhooks.github(), + filter: "header.x-github-event in ['issues','pull_request']", + onEvent: async ({ event }) => {}, +}); +``` diff --git a/docs/webhooks/overview.mdx b/docs/webhooks/overview.mdx new file mode 100644 index 00000000000..66de43b08eb --- /dev/null +++ b/docs/webhooks/overview.mdx @@ -0,0 +1,84 @@ +--- +title: "Webhooks overview" +description: "Receive and verify webhooks from external providers as a task, with a hosted webhook URL." +sidebarTitle: "Overview" +--- + +A webhook is a task that runs when an external provider (Stripe, GitHub, Svix, your own service, …) sends an HTTP request. Trigger.dev gives each webhook a hosted webhook URL, verifies the incoming request's signature, and routes the verified event to your task's `onEvent` handler. + +You don't host an endpoint yourself, and you don't write verification code: you declare which provider the webhook is from, point the provider at the webhook URL, and set the signing secret. + +## Defining a webhook task + +A webhook is created with `webhook()`. It takes an `id`, a `source` (which provider, and how to verify it), and an `onEvent` handler: + +```ts +import { webhook, webhooks } from "@trigger.dev/sdk"; + +export const onStripeEvent = webhook({ + id: "stripe-events", + source: webhooks.stripe(), + onEvent: async ({ event, headers, ctx }) => { + // `event` is the verified, parsed body + console.log("Received", event.type, event.id); + + // `headers` is a standard Web Headers object + console.log(headers.get("stripe-signature")); + + // `ctx` is the usual run context + console.log(ctx.run.id); + }, +}); +``` + +`onEvent` receives: + +- **`event`**: the verified request body, parsed from JSON and typed by the source (see [Typing the event](/webhooks/sources#typing-the-event)). +- **`headers`**: the inbound request headers as a Web [`Headers`](https://developer.mozilla.org/en-US/docs/Web/API/Headers) object (case-insensitive `.get()` / `.has()`). +- **`ctx`**: the run context, the same one regular tasks receive. + + + A webhook is a first-class task kind. It runs on a real run (with retries, logs, and everything else + tasks get), and shows up in the dashboard alongside your other tasks. + + +## How it works + + + + Define a `webhook()` with a `source`. The source is a provider preset (like `webhooks.stripe()`) + or a `webhooks.custom()` config. See [Sources and verification](/webhooks/sources). + + + Deploying the webhook creates an endpoint with a hosted webhook URL. Set its signing secret and + point your provider at the URL. See [Connecting a provider](/webhooks/connect). + + + Each inbound request is verified, recorded as a delivery, and routed to a run that calls your + `onEvent`. See [Deliveries and endpoints](/webhooks/deliveries). + + + +## Beyond fan-out + +A few things build on the basic model: + +- **[Filters](/webhooks/filters)** gate which deliveries run. A non-matching delivery is recorded but never triggers a run. + + + + Provider presets, custom verification, and typing the event. + + + The webhook URL and signing secret. + + + Observe inbound requests in the dashboard. + + + Route only the deliveries you care about. + + + The other declarative task trigger. + + diff --git a/docs/webhooks/sources.mdx b/docs/webhooks/sources.mdx new file mode 100644 index 00000000000..6d3cbda469b --- /dev/null +++ b/docs/webhooks/sources.mdx @@ -0,0 +1,133 @@ +--- +title: "Sources and verification" +description: "Provider presets, custom verification config, and typing the webhook event." +sidebarTitle: "Sources & verification" +--- + +A webhook's `source` tells Trigger.dev which provider the request is from and how to verify it. Use a built-in preset, or `webhooks.custom()` for a provider without one. + +## Presets + +Built-in presets know the provider's signature scheme, so you don't configure anything: + + + +```ts Stripe +import { webhook, webhooks } from "@trigger.dev/sdk"; + +export const stripeWebhook = webhook({ + id: "stripe", + source: webhooks.stripe(), + onEvent: async ({ event }) => { + if (event.type === "payment_intent.succeeded") { + // ... + } + }, +}); +``` + +```ts GitHub +import { webhook, webhooks } from "@trigger.dev/sdk"; + +export const githubWebhook = webhook({ + id: "github", + source: webhooks.github(), + onEvent: async ({ event, headers }) => { + // GitHub puts the event type in a header + console.log(headers.get("x-github-event")); + }, +}); +``` + +```ts Svix +import { webhook, webhooks } from "@trigger.dev/sdk"; + +// Also covers Clerk, Resend, and other Svix-powered providers +export const svixWebhook = webhook({ + id: "svix", + source: webhooks.svix(), + onEvent: async ({ event }) => { + // ... + }, +}); +``` + + + +The available presets are `stripe()`, `github()`, `svix()`, `square()`, and `discord()`. + +## Custom providers + +For a provider without a preset, `webhooks.custom()` describes the scheme as data. For example, an HMAC-SHA256 signature over the raw body, in a custom header: + +```ts +import { webhook, webhooks } from "@trigger.dev/sdk"; + +export const customWebhook = webhook({ + id: "custom", + source: webhooks.custom<{ id: string; message: string }>({ + scheme: "hmac", + algorithm: "sha256", + encoding: "hex", + signatureHeader: "x-webhook-signature", + signingString: "raw", + idempotencyField: { from: "body", name: "id" }, + }), + onEvent: async ({ event }) => { + console.log(event.message); + }, +}); +``` + + + Reach for a preset first; drop to `custom()` for the long tail. A custom config can almost always + express a provider's scheme without any code. + + +## Typing the event + +Presets ship a sensible default event type, and they're generic, so you can plug in the provider's official type for full type-safety and autocomplete: + +```ts +import type Stripe from "stripe"; +import { webhook, webhooks } from "@trigger.dev/sdk"; + +export const stripeEvents = webhook({ + id: "stripe-events", + source: webhooks.stripe(), + onEvent: async ({ event }) => { + // `event` is now the full, discriminated Stripe.Event union + }, +}); +``` + +For `webhooks.custom()`, pass your own event type as `T`. + + + The event is typed but not re-validated against that type at runtime: once the signature is + verified, the body is trusted (the same model the official provider SDKs use). If you want runtime + validation, validate `event` inside `onEvent`. + + +## How verification works + +Every inbound request is verified before your task runs. Trigger.dev checks the signature, the +timestamp (for replay protection, where the provider supplies one), and derives an idempotency key. +When the preset maps a provider event id it uses that; otherwise it falls back to a hash of the raw +body, timestamp, and signature. A request that fails verification gets a `400` and never creates a run. + +Presets handle this for you. Under the hood, every scheme is one of: + +- **`hmac`**: HMAC over the raw body or a templated signing string, signature in a header. The header can be a raw value, a prefixed one (like GitHub's `sha256=…`), or a structured one (like Stripe's `t=…,v1=…`). +- **`shared-secret`**: a static token compared in a header, bearer, basic auth, or the body. +- **`url-secret`**: a secret in the URL path or query string. +- **`asymmetric`**: public-key signatures (Ed25519, ECDSA, RSA). You store the provider's public key instead of a shared secret. + + + `url-secret` places the secret in the request URL (path or query string), where it can be captured + by access logs, proxies, and tracing systems. Prefer a header-based scheme (`hmac` or + `shared-secret`) when the provider supports one. + + +Once a request is verified, see [Connecting a provider](/webhooks/connect) for how to point the +provider at the webhook URL and set the secret. diff --git a/packages/cli-v3/src/dev/devSupervisor.ts b/packages/cli-v3/src/dev/devSupervisor.ts index 6a0d1888afd..f748ae27467 100644 --- a/packages/cli-v3/src/dev/devSupervisor.ts +++ b/packages/cli-v3/src/dev/devSupervisor.ts @@ -391,6 +391,7 @@ class DevSupervisor implements WorkerRuntime { cliPackageVersion: manifest.cliPackageVersion, tasks: backgroundWorker.manifest.tasks, prompts: backgroundWorker.manifest.prompts, + webhooks: backgroundWorker.manifest.webhooks, queues: backgroundWorker.manifest.queues, contentHash: manifest.contentHash, sourceFiles, diff --git a/packages/cli-v3/src/entryPoints/dev-index-worker.ts b/packages/cli-v3/src/entryPoints/dev-index-worker.ts index 59228f0971d..e568182be28 100644 --- a/packages/cli-v3/src/entryPoints/dev-index-worker.ts +++ b/packages/cli-v3/src/entryPoints/dev-index-worker.ts @@ -17,6 +17,7 @@ import { readFile } from "node:fs/promises"; import sourceMapSupport from "source-map-support"; import { registerResources } from "../indexing/registerResources.js"; import { reportTaskIdCollisions } from "../indexing/reportTaskIdCollisions.js"; +import { reportWebhookIdCollisions } from "../indexing/reportWebhookIdCollisions.js"; import { env } from "std-env"; import { normalizeImportPath } from "../utilities/normalizeImportPath.js"; import { detectRuntimeVersion } from "@trigger.dev/core/v3/build"; @@ -127,6 +128,11 @@ if (await reportTaskIdCollisions(safeSend)) { process.exit(0); } +if (await reportWebhookIdCollisions(safeSend)) { + await new Promise((resolve) => setTimeout(resolve, 10)); + process.exit(0); +} + let tasks = await convertSchemasToJsonSchemas(resourceCatalog.listTaskManifests()); // If the config has retry defaults, we need to apply them to all tasks that don't have any retry settings @@ -195,6 +201,7 @@ await sendMessageInCatalog( tasks, prompts: convertPromptSchemasToJsonSchemas(resourceCatalog.listPromptManifests()), skills: resourceCatalog.listSkillManifests(), + webhooks: resourceCatalog.listWebhookManifests(), queues: resourceCatalog.listQueueManifests(), configPath: buildManifest.configPath, runtime: buildManifest.runtime, diff --git a/packages/cli-v3/src/entryPoints/managed-index-controller.ts b/packages/cli-v3/src/entryPoints/managed-index-controller.ts index 248785782a1..aa7adb514c1 100644 --- a/packages/cli-v3/src/entryPoints/managed-index-controller.ts +++ b/packages/cli-v3/src/entryPoints/managed-index-controller.ts @@ -103,6 +103,7 @@ async function indexDeployment({ tasks: workerManifest.tasks, prompts: workerManifest.prompts, queues: workerManifest.queues, + webhooks: workerManifest.webhooks, sourceFiles, runtime: workerManifest.runtime, runtimeVersion: workerManifest.runtimeVersion, diff --git a/packages/cli-v3/src/entryPoints/managed-index-worker.ts b/packages/cli-v3/src/entryPoints/managed-index-worker.ts index f463c4156e5..6dfaeac90a5 100644 --- a/packages/cli-v3/src/entryPoints/managed-index-worker.ts +++ b/packages/cli-v3/src/entryPoints/managed-index-worker.ts @@ -17,6 +17,7 @@ import { readFile } from "node:fs/promises"; import sourceMapSupport from "source-map-support"; import { registerResources } from "../indexing/registerResources.js"; import { reportTaskIdCollisions } from "../indexing/reportTaskIdCollisions.js"; +import { reportWebhookIdCollisions } from "../indexing/reportWebhookIdCollisions.js"; import { env } from "std-env"; import { normalizeImportPath } from "../utilities/normalizeImportPath.js"; import { detectRuntimeVersion } from "@trigger.dev/core/v3/build"; @@ -121,6 +122,11 @@ if (await reportTaskIdCollisions(safeSend)) { process.exit(0); } +if (await reportWebhookIdCollisions(safeSend)) { + await new Promise((resolve) => setTimeout(resolve, 10)); + process.exit(0); +} + let tasks = await convertSchemasToJsonSchemas(resourceCatalog.listTaskManifests()); // If the config has retry defaults, we need to apply them to all tasks that don't have any retry settings @@ -191,6 +197,7 @@ await sendMessageInCatalog( tasks, prompts: convertPromptSchemasToJsonSchemas(resourceCatalog.listPromptManifests()), skills: resourceCatalog.listSkillManifests(), + webhooks: resourceCatalog.listWebhookManifests(), queues: resourceCatalog.listQueueManifests(), configPath: buildManifest.configPath, runtime: buildManifest.runtime, diff --git a/packages/cli-v3/src/indexing/indexWorkerManifest.ts b/packages/cli-v3/src/indexing/indexWorkerManifest.ts index 7e8f7b1e002..33339fe9f17 100644 --- a/packages/cli-v3/src/indexing/indexWorkerManifest.ts +++ b/packages/cli-v3/src/indexing/indexWorkerManifest.ts @@ -1,6 +1,7 @@ import { execPathForRuntime } from "@trigger.dev/core/v3/build"; import { DuplicateTaskIdsError, + DuplicateWebhookIdsError, TaskIndexingImportError, TaskMetadataParseError, UncaughtExceptionError, @@ -94,6 +95,13 @@ export async function indexWorkerManifest({ child.kill("SIGKILL"); break; } + case "WEBHOOKS_FAILED_TO_INDEX": { + clearTimeout(timeout); + resolved = true; + reject(new DuplicateWebhookIdsError(message.payload.collisions)); + child.kill("SIGKILL"); + break; + } case "UNCAUGHT_EXCEPTION": { clearTimeout(timeout); resolved = true; diff --git a/packages/cli-v3/src/indexing/reportWebhookIdCollisions.ts b/packages/cli-v3/src/indexing/reportWebhookIdCollisions.ts new file mode 100644 index 00000000000..3a9c039ceda --- /dev/null +++ b/packages/cli-v3/src/indexing/reportWebhookIdCollisions.ts @@ -0,0 +1,28 @@ +import { indexerToWorkerMessages, resourceCatalog } from "@trigger.dev/core/v3"; +import { sendMessageInCatalog } from "@trigger.dev/core/v3/zodMessageHandler"; + +/** + * If the indexer registered any duplicate webhook ids (across files), report + * them to the parent via WEBHOOKS_FAILED_TO_INDEX and return true. Callers must + * stop indexing (skip INDEX_COMPLETE) when this returns true. + */ +export async function reportWebhookIdCollisions( + send: (message: unknown) => void +): Promise { + const collisions = resourceCatalog.listWebhookIdCollisions(); + + if (collisions.length === 0) { + return false; + } + + await sendMessageInCatalog( + indexerToWorkerMessages, + "WEBHOOKS_FAILED_TO_INDEX", + { collisions }, + async (msg) => { + send(msg); + } + ); + + return true; +} diff --git a/packages/core/src/v3/errors.ts b/packages/core/src/v3/errors.ts index b8decd0341c..8f76a304903 100644 --- a/packages/core/src/v3/errors.ts +++ b/packages/core/src/v3/errors.ts @@ -607,6 +607,37 @@ export class DuplicateTaskIdsError extends Error { } } +function formatDuplicateWebhookIds(collisions: TaskIdCollision[]): string { + const lines = collisions.map(({ id, filePaths }) => { + const distinct = Array.from(new Set(filePaths)); + + if (distinct.length === 1) { + return ` - "${id}" found more than once in ${distinct[0]}`; + } + + const last = distinct[distinct.length - 1]; + const head = distinct.slice(0, -1).join(", "); + + return ` - "${id}" found in ${head} and ${last}`; + }); + + return [ + "Duplicate webhook ids detected:", + "", + ...lines, + "", + "Webhook ids must be unique across your project. Please rename one of them.", + ].join("\n"); +} + +export class DuplicateWebhookIdsError extends Error { + constructor(public readonly collisions: TaskIdCollision[]) { + super(formatDuplicateWebhookIds(collisions)); + + this.name = "DuplicateWebhookIdsError"; + } +} + export class UnexpectedExitError extends Error { constructor( public code: number, diff --git a/packages/core/src/v3/schemas/messages.ts b/packages/core/src/v3/schemas/messages.ts index d70a032b479..2aecf9614e9 100644 --- a/packages/core/src/v3/schemas/messages.ts +++ b/packages/core/src/v3/schemas/messages.ts @@ -44,6 +44,10 @@ export const indexerToWorkerMessages = { version: z.literal("v1").default("v1"), collisions: z.array(z.object({ id: z.string(), filePaths: z.array(z.string()) })), }), + WEBHOOKS_FAILED_TO_INDEX: z.object({ + version: z.literal("v1").default("v1"), + collisions: z.array(z.object({ id: z.string(), filePaths: z.array(z.string()) })), + }), UNCAUGHT_EXCEPTION: UncaughtExceptionMessage, }; diff --git a/packages/trigger-sdk/src/v3/webhooks.ts b/packages/trigger-sdk/src/v3/webhooks.ts index 040b7ee6638..1e59c3d40de 100644 --- a/packages/trigger-sdk/src/v3/webhooks.ts +++ b/packages/trigger-sdk/src/v3/webhooks.ts @@ -1,5 +1,26 @@ -import { Webhook } from "@trigger.dev/core/v3"; +import { Webhook, resourceCatalog } from "@trigger.dev/core/v3"; +import type { + WebhookSource, + InferWebhookEvent, + AnyWebhookSource, + WebhookRunPayload, + ValidateWebhookFilter, + WebhookVerifierConfig, + StripeWebhookEvent, + GitHubWebhookEvent, + TaskRunContext, +} from "@trigger.dev/core/v3"; import { subtle } from "../imports/uncrypto.js"; +import { createTask, type Task } from "./shared.js"; +import { + discordVerifierConfig, + githubVerifierConfig, + squareVerifierConfig, + stripeVerifierConfig, + svixVerifierConfig, + webhookProviderConfigs, + type WebhookProviderId, +} from "@trigger.dev/core/webhooks"; /** * The type of error thrown when a webhook fails to parse or verify @@ -24,6 +45,226 @@ type ConstructEventOptions = { header: string | Buffer | Array; }; +// ── Source producers (presets carry the event type) ── +export const webhookSources = { + custom(config: WebhookVerifierConfig): WebhookSource { + // Roll-your-own webhooks: you control both ends, so offer paste AND generate. + return { + provider: "custom", + verifier: { kind: "config", config }, + secretProvisioning: "either", + }; + }, + + // Stripe: `Stripe-Signature: t=…,v1=…` (comma-kv), signed `{t}.{body}`, hex. + // Defaults to a minimal event shape; pass the official type for full typing: stripe(). + stripe(opts?: { toleranceSeconds?: number }): WebhookSource { + return { + provider: "stripe", + verifier: { kind: "preset", preset: "stripe", config: stripeVerifierConfig(opts) }, + secretProvisioning: "provider", + }; + }, + + // GitHub: `X-Hub-Signature-256: sha256=` (prefixed), signed raw body. + // Defaults to an open shape; pass your event type for full typing: github(). + github(): WebhookSource { + return { + provider: "github", + verifier: { kind: "preset", preset: "github", config: githubVerifierConfig() }, + secretProvisioning: "integrator", + }; + }, + + // Svix family (Svix, Clerk, Resend): `svix-signature: v1, v1,` (space-list), + // signed `{id}.{timestamp}.{body}`, base64; the `whsec_` secret is base64-decoded. + svix(): WebhookSource { + return { + provider: "svix", + verifier: { kind: "preset", preset: "svix", config: svixVerifierConfig() }, + secretProvisioning: "provider", + }; + }, + + // Square: bare base64 signature over `{notificationURL}{body}` (URL template var, no separator). + square(): WebhookSource { + return { + provider: "square", + verifier: { kind: "preset", preset: "square", config: squareVerifierConfig() }, + secretProvisioning: "provider", + }; + }, + + // Discord: asymmetric Ed25519 over `{timestamp}{body}`. The "secret" stored on the endpoint is + // the application PUBLIC KEY (hex by default). No shared secret. + discord(opts: { publicKeyEncoding?: "raw-hex" | "pem" } = {}): WebhookSource { + return { + provider: "discord", + verifier: { kind: "preset", preset: "discord", config: discordVerifierConfig(opts) }, + secretProvisioning: "provider", + }; + }, + + /** + * Per-provider producers over shared presets. Each is a thin wrapper: same verifier config as the + * preset it references, differing only in `provider` (routing + picker identity) and who provisions + * the secret. Pass the provider's own published type for full typing, e.g. clerk(). + */ + clerk(): WebhookSource { + return { + provider: "clerk", + verifier: { kind: "preset", preset: "svix", config: svixVerifierConfig() }, + secretProvisioning: "provider", + }; + }, + + resend(): WebhookSource { + return { + provider: "resend", + verifier: { kind: "preset", preset: "svix", config: svixVerifierConfig() }, + secretProvisioning: "provider", + }; + }, + + openai(): WebhookSource { + return { + provider: "openai", + verifier: { kind: "preset", preset: "svix", config: svixVerifierConfig() }, + secretProvisioning: "provider", + }; + }, + + replicate(): WebhookSource { + return { + provider: "replicate", + verifier: { kind: "preset", preset: "svix", config: svixVerifierConfig() }, + secretProvisioning: "provider", + }; + }, + + recallai(): WebhookSource { + return { + provider: "recall-ai", + verifier: { kind: "preset", preset: "svix", config: svixVerifierConfig() }, + secretProvisioning: "provider", + }; + }, + + brex(): WebhookSource { + return { + provider: "brex", + verifier: { kind: "preset", preset: "svix", config: svixVerifierConfig() }, + secretProvisioning: "provider", + }; + }, + + gitlab(): WebhookSource { + return { + provider: "gitlab", + verifier: { + kind: "config", + config: { scheme: "shared-secret", placement: "header", fieldName: "x-gitlab-token" }, + }, + secretProvisioning: "integrator", + }; + }, + + whatsapp(): WebhookSource { + return { + provider: "whatsapp", + verifier: { kind: "preset", preset: "github", config: githubVerifierConfig() }, + secretProvisioning: "integrator", + }; + }, +} as const; + +/** + * Per-provider producers generated from the core config table (kind "config"). Each carries the + * provider's own HMAC verifier config and stays in lockstep with the round-trip-tested configs. + */ +export type ProviderProducers = { + [K in WebhookProviderId]: () => WebhookSource; +}; + +export const providerProducers = Object.fromEntries( + Object.entries(webhookProviderConfigs) + .filter(([provider]) => provider !== "slack") + .map(([provider, entry]) => [ + provider, + () => ({ + provider, + verifier: { kind: "config" as const, config: entry.config() }, + secretProvisioning: entry.secretProvisioning, + }), + ]) +) as Omit; + +// ── webhook() entry: single-callback IoC, infers event from source ── +export type WebhookOnEventParams = { + /** The verified event body, typed by the source (a preset type, or the `` you supply). */ + event: TEvent; + /** The inbound request headers (case-insensitive, Web `Headers`). e.g. headers.get("x-github-event"). */ + headers: Headers; + ctx: TaskRunContext; +}; + +export type WebhookOptions = { + id: TIdentifier; + source: TSource; + /** + * Optional server-side filter (a type-safe string DSL checked against the event shape). A delivery + * that doesn't match is received and recorded but not routed (no run). e.g. + * `"event.action == 'created' && event.repository.private == false"`. + */ + filter?: string; + onEvent: (params: WebhookOnEventParams>) => Promise | void; +}; + +export type WebhookHandle = Task; + +export function webhook< + TIdentifier extends string, + TSource extends AnyWebhookSource, + const TFilter extends string = string, +>( + options: WebhookOptions & { + filter?: TFilter & ValidateWebhookFilter, TFilter>; + } +): WebhookHandle> { + const { id, source, onEvent, filter } = options; + + // 1. The task half: webhook IS a first-class task kind (triggerSource "webhook"). + // The platform delivers a { event, headers } envelope; unwrap it for onEvent. The handle's + // payload type stays the event (webhook tasks are triggered by the ingress, not tasks.trigger). + const task = createTask, void>({ + id, + triggerSource: "webhook", + run: async (payload, runOptions) => { + const envelope = payload as unknown as WebhookRunPayload>; + await onEvent({ + event: envelope.event, + headers: new Headers(envelope.headers ?? {}), + ctx: runOptions.ctx, + }); + }, + }); + + // 2. The endpoint half: register the verifier + default routing target (this task) + filter. + resourceCatalog.registerWebhookMetadata({ + id, + source: source.provider, + verifierArtifact: source.verifier, + routingTarget: { type: "task", taskId: id }, + secretProvisioning: source.secretProvisioning, + filter, + }); + + return task; +} + +// P2 seam (TYPE only): +export type { CreateWebhookEndpointParams } from "@trigger.dev/core/v3"; + /** * Interface describing the webhook utilities */ @@ -50,14 +291,43 @@ interface Webhooks { /** Header name used for webhook signatures */ SIGNATURE_HEADER_NAME: string; + custom: typeof webhookSources.custom; + stripe: typeof webhookSources.stripe; + github: typeof webhookSources.github; + svix: typeof webhookSources.svix; + square: typeof webhookSources.square; + discord: typeof webhookSources.discord; + clerk: typeof webhookSources.clerk; + resend: typeof webhookSources.resend; + openai: typeof webhookSources.openai; + replicate: typeof webhookSources.replicate; + recallai: typeof webhookSources.recallai; + brex: typeof webhookSources.brex; + gitlab: typeof webhookSources.gitlab; + whatsapp: typeof webhookSources.whatsapp; } /** * Webhook utilities for handling incoming webhook requests */ -export const webhooks: Webhooks = { +export const webhooks: Webhooks & Omit = { + ...providerProducers, constructEvent, SIGNATURE_HEADER_NAME, + custom: webhookSources.custom, + stripe: webhookSources.stripe, + github: webhookSources.github, + svix: webhookSources.svix, + square: webhookSources.square, + discord: webhookSources.discord, + clerk: webhookSources.clerk, + resend: webhookSources.resend, + openai: webhookSources.openai, + replicate: webhookSources.replicate, + recallai: webhookSources.recallai, + brex: webhookSources.brex, + gitlab: webhookSources.gitlab, + whatsapp: webhookSources.whatsapp, }; async function constructEvent(