@blokjs/trigger-webhook 0.2.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,480 +1,459 @@
1
1
  /**
2
- * WebhookTrigger - Handle webhook events from external services
2
+ * WebhookTrigger v0.7 PR 4 — Inbound webhook trigger that mounts
3
+ * verified POST routes on the shared Hono app. One route per workflow
4
+ * whose `trigger.webhook` config is registered.
3
5
  *
4
- * Extends TriggerBase to process webhook events from:
5
- * - GitHub (push, pull_request, issues, etc.)
6
- * - Stripe (payment_intent, checkout.session, etc.)
7
- * - Shopify (orders, products, customers)
8
- * - Custom webhooks
6
+ * **Authoring surface (built-in provider):**
9
7
  *
10
- * Features:
11
- * - Signature verification for security
12
- * - Event type filtering
13
- * - Retry support
14
- * - Dead letter handling
8
+ * ```json
9
+ * {
10
+ * "name": "stripe-events",
11
+ * "trigger": {
12
+ * "webhook": {
13
+ * "provider": "stripe",
14
+ * "path": "/webhooks/stripe",
15
+ * "secretEnv": "STRIPE_WEBHOOK_SECRET",
16
+ * "namespace": "stripe",
17
+ * "idempotencyKey": "js/ctx.request.body.id"
18
+ * }
19
+ * },
20
+ * "steps": [
21
+ * { "id": "dispatch", "subworkflow": "js/ctx.request.body.type", "inputs": { "stripeEvent": "js/ctx.request.body" } }
22
+ * ]
23
+ * }
24
+ * ```
25
+ *
26
+ * **Pipeline (per inbound request):**
27
+ *
28
+ * 1. Read raw body — verifiers MUST sign the bytes that crossed
29
+ * the wire, not the JSON-re-stringified body (Stripe / GitHub /
30
+ * Slack all sign raw bytes).
31
+ * 2. Verify the signature via the per-provider strategy
32
+ * (`verifiers.ts`). On failure, return 401 with structured
33
+ * `{ error, reason, message }`.
34
+ * 3. Replay check: if `idempotencyKey` is configured, look up
35
+ * `(workflowName, eventId)` in the idempotency cache (same store
36
+ * as Tier 1 step caching). On hit, return 200 with
37
+ * `{ status: "duplicate", eventId }` and DON'T run the workflow.
38
+ * 4. Events allowlist: if `events: [...]` is configured, skip
39
+ * workflow runs whose event type isn't in the list — return
40
+ * 200 with `{ status: "ignored", eventType }` so the sender
41
+ * doesn't retry.
42
+ * 5. Run the workflow through `TriggerBase.run` so middleware,
43
+ * tracing, retries, concurrency, etc. apply uniformly.
44
+ * 6. Cache the eventId so a retry within the TTL window returns
45
+ * the duplicate response.
46
+ *
47
+ * **Hono integration:** identical to WebSocket and SSE — accepts the
48
+ * shared `Hono<any, any, any>` app and an optional `HttpTriggerLike`
49
+ * exposing `addPreCatchAllHook` so webhook routes mount BEFORE the
50
+ * legacy `/:workflow{.+}` catch-all and win Hono's first-match
51
+ * dispatch.
52
+ *
53
+ * See [additional-triggers-plan.mdx](../../../docs/c/devtools/additional-triggers-plan.mdx#webhook-trigger)
54
+ * for the full v0.7 design.
15
55
  */
16
56
 
17
- import crypto from "node:crypto";
18
- import type { HelperResponse, WebhookTriggerOpts } from "@blokjs/helper";
19
57
  import {
20
58
  DefaultLogger,
21
- type GlobalOptions,
22
- type BlokService,
23
- NodeMap,
59
+ RunTracker,
60
+ type GlobalOptions as RunnerGlobalOptions,
24
61
  TriggerBase,
25
- type TriggerResponse,
62
+ WorkflowRegistry,
26
63
  } from "@blokjs/runner";
27
64
  import type { Context, RequestContext } from "@blokjs/shared";
28
65
  import { type Span, SpanStatusCode, metrics, trace } from "@opentelemetry/api";
66
+ import type { Hono, Context as HonoContext } from "hono";
29
67
  import { v4 as uuid } from "uuid";
30
68
 
31
- /**
32
- * Webhook event structure
33
- */
34
- export interface WebhookEvent {
35
- /** Unique event ID */
36
- id: string;
37
- /** Source service (github, stripe, shopify, custom) */
38
- source: string;
39
- /** Event type (e.g., push, payment_intent.succeeded) */
40
- eventType: string;
41
- /** Event payload */
42
- payload: unknown;
43
- /** Request headers */
44
- headers: Record<string, string>;
45
- /** Signature (if provided) */
46
- signature?: string;
47
- /** Timestamp */
48
- timestamp: Date;
49
- /** Raw request body */
50
- rawBody: string;
51
- }
52
-
53
- /**
54
- * Signature verification result
55
- */
56
- export interface VerificationResult {
57
- valid: boolean;
58
- error?: string;
59
- }
60
-
61
- /**
62
- * Webhook source handlers
63
- */
64
- export interface WebhookSourceHandler {
65
- /** Extract event type from request */
66
- getEventType(headers: Record<string, string>, body: unknown): string;
67
- /** Get signature from request */
68
- getSignature(headers: Record<string, string>): string | undefined;
69
- /** Verify signature */
70
- verifySignature(rawBody: string, signature: string, secret: string): VerificationResult;
71
- /** Get event ID */
72
- getEventId(headers: Record<string, string>, body: unknown): string;
73
- }
74
-
75
- /**
76
- * Workflow model with webhook trigger configuration
77
- */
78
- interface WebhookWorkflowModel {
79
- path: string;
80
- config: {
81
- name: string;
82
- version: string;
83
- trigger?: {
84
- webhook?: WebhookTriggerOpts;
85
- [key: string]: unknown;
86
- };
87
- [key: string]: unknown;
69
+ import { BUILTIN_VERIFIERS, type Verifier, type VerifyResult, buildCustomVerifier } from "./verifiers";
70
+
71
+ // -----------------------------------------------------------------------------
72
+ // Types
73
+ // -----------------------------------------------------------------------------
74
+
75
+ interface WebhookTriggerConfig {
76
+ provider?: "github" | "stripe" | "slack" | "shopify" | "svix";
77
+ path?: string;
78
+ events?: string[];
79
+ secretEnv?: string;
80
+ signature?: {
81
+ scheme?: "hmac-sha256" | "hmac-sha1" | "hmac-sha512";
82
+ header: string;
83
+ format?: string;
84
+ secretEnv: string;
85
+ tolerance?: number;
86
+ timestampHeader?: string;
88
87
  };
88
+ tolerance?: number;
89
+ idempotencyKey?: string;
90
+ namespace?: string;
91
+ middleware?: string[];
89
92
  }
90
93
 
91
- /**
92
- * Built-in source handlers
93
- */
94
- const sourceHandlers: Record<string, WebhookSourceHandler> = {
95
- github: {
96
- getEventType: (headers) => headers["x-github-event"] || "unknown",
97
- getSignature: (headers) => headers["x-hub-signature-256"] || headers["x-hub-signature"],
98
- verifySignature: (rawBody, signature, secret) => {
99
- const hmac = crypto.createHmac("sha256", secret);
100
- const digest = `sha256=${hmac.update(rawBody).digest("hex")}`;
101
- const sigBuffer = Buffer.from(signature);
102
- const digestBuffer = Buffer.from(digest);
103
- // Length check first to avoid timing attack on length
104
- if (sigBuffer.length !== digestBuffer.length) {
105
- return { valid: false, error: "Invalid GitHub signature" };
106
- }
107
- const valid = crypto.timingSafeEqual(sigBuffer, digestBuffer);
108
- return { valid, error: valid ? undefined : "Invalid GitHub signature" };
109
- },
110
- getEventId: (headers) => headers["x-github-delivery"] || uuid(),
111
- },
112
-
113
- stripe: {
114
- getEventType: (_, body) => (body as { type?: string })?.type || "unknown",
115
- getSignature: (headers) => headers["stripe-signature"],
116
- verifySignature: (rawBody, signature, secret) => {
117
- // Stripe signature format: t=timestamp,v1=signature
118
- const parts = signature.split(",").reduce(
119
- (acc, part) => {
120
- const [key, value] = part.split("=");
121
- acc[key] = value;
122
- return acc;
123
- },
124
- {} as Record<string, string>,
125
- );
126
-
127
- const timestamp = parts.t;
128
- const expectedSig = parts.v1;
94
+ interface HttpTriggerLike {
95
+ addPreCatchAllHook(cb: () => void | Promise<void>): void;
96
+ }
129
97
 
130
- if (!timestamp || !expectedSig) {
131
- return { valid: false, error: "Invalid Stripe signature format" };
132
- }
98
+ const DEFAULT_TOLERANCE_SEC = 300;
99
+ const DEFAULT_REPLAY_TTL_MS = 5 * 60 * 1000; // 5 min — match Stripe / Svix default.
100
+ const REPLAY_NAMESPACE = "__webhook__";
133
101
 
134
- const payload = `${timestamp}.${rawBody}`;
135
- const hmac = crypto.createHmac("sha256", secret);
136
- const computedSig = hmac.update(payload).digest("hex");
102
+ // -----------------------------------------------------------------------------
103
+ // Trigger class
104
+ // -----------------------------------------------------------------------------
137
105
 
138
- const sigBuffer = Buffer.from(expectedSig);
139
- const computedBuffer = Buffer.from(computedSig);
140
- if (sigBuffer.length !== computedBuffer.length) {
141
- return { valid: false, error: "Invalid Stripe signature" };
142
- }
143
- const valid = crypto.timingSafeEqual(sigBuffer, computedBuffer);
144
- return { valid, error: valid ? undefined : "Invalid Stripe signature" };
145
- },
146
- getEventId: (_, body) => (body as { id?: string })?.id || uuid(),
147
- },
148
-
149
- shopify: {
150
- getEventType: (headers) => headers["x-shopify-topic"] || "unknown",
151
- getSignature: (headers) => headers["x-shopify-hmac-sha256"],
152
- verifySignature: (rawBody, signature, secret) => {
153
- const hmac = crypto.createHmac("sha256", secret);
154
- const digest = hmac.update(rawBody, "utf8").digest("base64");
155
- const sigBuffer = Buffer.from(signature, "base64");
156
- const digestBuffer = Buffer.from(digest, "base64");
157
- if (sigBuffer.length !== digestBuffer.length) {
158
- return { valid: false, error: "Invalid Shopify signature" };
159
- }
160
- const valid = crypto.timingSafeEqual(sigBuffer, digestBuffer);
161
- return { valid, error: valid ? undefined : "Invalid Shopify signature" };
162
- },
163
- getEventId: (headers) => headers["x-shopify-webhook-id"] || uuid(),
164
- },
165
-
166
- custom: {
167
- getEventType: (headers, body) => headers["x-event-type"] || (body as { event?: string })?.event || "custom",
168
- getSignature: (headers) => headers["x-signature"] || headers["x-webhook-signature"],
169
- verifySignature: (rawBody, signature, secret) => {
170
- // Default: HMAC-SHA256
171
- const hmac = crypto.createHmac("sha256", secret);
172
- const digest = hmac.update(rawBody).digest("hex");
173
- const valid = signature === digest || signature === `sha256=${digest}`;
174
- return { valid, error: valid ? undefined : "Invalid signature" };
175
- },
176
- getEventId: (headers, body) => headers["x-event-id"] || (body as { id?: string })?.id || uuid(),
177
- },
178
- };
179
-
180
- /**
181
- * WebhookTrigger - Handle webhook events
182
- */
183
- export abstract class WebhookTrigger extends TriggerBase {
184
- protected nodeMap: GlobalOptions = {} as GlobalOptions;
106
+ export default class WebhookTrigger extends TriggerBase {
107
+ protected nodeMap: RunnerGlobalOptions = {} as RunnerGlobalOptions;
108
+ protected readonly logger = new DefaultLogger();
185
109
  protected readonly tracer = trace.getTracer(
186
110
  process.env.PROJECT_NAME || "trigger-webhook-workflow",
187
111
  process.env.PROJECT_VERSION || "0.0.1",
188
112
  );
189
- protected readonly logger = new DefaultLogger();
190
- protected webhookWorkflows: WebhookWorkflowModel[] = [];
191
-
192
- // Subclasses provide these
193
- protected abstract nodes: Record<string, BlokService<unknown>>;
194
- protected abstract workflows: Record<string, HelperResponse>;
195
113
 
196
- constructor() {
114
+ private readonly meter = metrics.getMeter("blok");
115
+ private readonly counterReceived = this.meter.createCounter("blok_webhook_received_total", {
116
+ description: "Webhook deliveries received (cumulative).",
117
+ unit: "1",
118
+ });
119
+ private readonly counterRejected = this.meter.createCounter("blok_webhook_rejected_total", {
120
+ description: "Webhook deliveries rejected (signature failure, allowlist miss, replay).",
121
+ unit: "1",
122
+ });
123
+ private readonly counterAccepted = this.meter.createCounter("blok_webhook_accepted_total", {
124
+ description: "Webhook deliveries that triggered a workflow run.",
125
+ unit: "1",
126
+ });
127
+
128
+ // biome-ignore lint/suspicious/noExplicitAny: Hono's generic propagation
129
+ private readonly app: Hono<any, any, any>;
130
+ private readonly httpTrigger: HttpTriggerLike | null;
131
+
132
+ private wired = false;
133
+
134
+ // biome-ignore lint/suspicious/noExplicitAny: matches `app` field's any generic
135
+ constructor(app: Hono<any, any, any>, httpTrigger?: HttpTriggerLike) {
197
136
  super();
198
- this.loadNodes();
199
- this.loadWorkflows();
137
+ this.app = app;
138
+ this.httpTrigger = httpTrigger ?? null;
139
+ _setActiveWebhookTrigger(this);
200
140
  }
201
141
 
202
142
  /**
203
- * Load nodes into the node map
143
+ * Inject the runner's GlobalOptions (nodes + workflows). Called by
144
+ * the orchestrator AFTER constructing the trigger but BEFORE
145
+ * `listen()`. Shares HttpTrigger's nodeMap so per-request workflow
146
+ * runs resolve helpers + sub-workflows through the same registry.
204
147
  */
205
- loadNodes(): void {
206
- this.nodeMap.nodes = new NodeMap();
207
- const nodeKeys = Object.keys(this.nodes);
208
- for (const key of nodeKeys) {
209
- this.nodeMap.nodes.addNode(key, this.nodes[key]);
210
- }
211
- }
212
-
213
- /**
214
- * Load workflows into the workflow map
215
- */
216
- loadWorkflows(): void {
217
- this.nodeMap.workflows = this.workflows;
148
+ setNodeMap(nodeMap: RunnerGlobalOptions): void {
149
+ this.nodeMap = nodeMap;
218
150
  }
219
151
 
220
- /**
221
- * Initialize webhook trigger (call after loading workflows)
222
- */
223
152
  async listen(): Promise<number> {
224
153
  const startTime = this.startCounter();
225
-
226
- // Find all workflows with webhook triggers
227
- this.webhookWorkflows = this.getWebhookWorkflows();
228
-
229
- if (this.webhookWorkflows.length === 0) {
230
- this.logger.log("No workflows with webhook triggers found");
231
- } else {
232
- this.logger.log(`Webhook trigger initialized. ${this.webhookWorkflows.length} workflow(s) registered`);
154
+ if (this.wired) {
155
+ this.logger.log("[blok][webhook] listen() called twice; ignoring");
156
+ return this.endCounter(startTime);
233
157
  }
158
+ this.wired = true;
234
159
 
235
- // Enable HMR in development mode
236
- if (process.env.BLOK_HMR === "true" || process.env.NODE_ENV === "development") {
237
- await this.enableHotReload();
160
+ if (this.httpTrigger) {
161
+ this.httpTrigger.addPreCatchAllHook(() => {
162
+ this.registerRoutesFromRegistry();
163
+ });
164
+ } else {
165
+ this.registerRoutesFromRegistry();
238
166
  }
239
167
 
240
168
  return this.endCounter(startTime);
241
169
  }
242
170
 
243
- /**
244
- * Stop the webhook trigger
245
- */
246
171
  async stop(): Promise<void> {
247
- this.webhookWorkflows = [];
248
- this.logger.log("Webhook trigger stopped");
172
+ this.wired = false;
173
+ if (_getActiveWebhookTrigger() === this) _setActiveWebhookTrigger(null);
174
+ this.destroyMonitoring();
175
+ this.logger.log("[blok][webhook] stopped");
249
176
  }
250
177
 
251
- protected override async onHmrWorkflowChange(): Promise<void> {
252
- this.loadWorkflows();
253
- this.webhookWorkflows = this.getWebhookWorkflows();
254
- this.logger.log(`[HMR] Webhook workflows reloaded. ${this.webhookWorkflows.length} workflow(s) registered`);
255
- }
178
+ // ---------------------------------------------------------------------------
179
+ // Route registration
180
+ // ---------------------------------------------------------------------------
256
181
 
257
- /**
258
- * Process an incoming webhook request
259
- * Call this from your HTTP endpoint handler
260
- */
261
- async handleWebhook(
262
- source: string,
263
- rawBody: string,
264
- headers: Record<string, string>,
265
- ): Promise<TriggerResponse | null> {
266
- const handler = sourceHandlers[source] || sourceHandlers.custom;
267
-
268
- // Parse body
269
- let body: unknown;
270
- try {
271
- body = JSON.parse(rawBody);
272
- } catch {
273
- body = rawBody;
182
+ private registerRoutesFromRegistry(): void {
183
+ const workflows = this.getWebhookWorkflows();
184
+ if (workflows.length === 0) {
185
+ this.logger.log("[blok][webhook] no workflows with trigger.webhook found");
186
+ return;
187
+ }
188
+ this.logger.log(`[blok][webhook] registering ${workflows.length} webhook route(s):`);
189
+ for (const entry of workflows) {
190
+ this.registerWebhookRoute(entry);
274
191
  }
192
+ }
275
193
 
276
- // Create webhook event
277
- const event: WebhookEvent = {
278
- id: handler.getEventId(headers, body),
279
- source,
280
- eventType: handler.getEventType(headers, body),
281
- payload: body,
282
- headers,
283
- signature: handler.getSignature(headers),
284
- timestamp: new Date(),
285
- rawBody,
286
- };
194
+ private registerWebhookRoute(entry: { workflowName: string; config: WebhookTriggerConfig }): void {
195
+ const { workflowName, config } = entry;
196
+ const path = config.path ?? (config.provider ? `/webhooks/${config.provider}` : `/webhooks/${workflowName}`);
197
+ const label = config.provider ?? "custom";
198
+ this.logger.log(`[blok][webhook] POST ${path} ← ${workflowName} (${label})`);
287
199
 
288
- // Find matching workflow
289
- const workflow = this.findMatchingWorkflow(event);
290
- if (!workflow) {
291
- this.logger.log(`No matching workflow for webhook: ${source}/${event.eventType}`);
292
- return null;
200
+ this.app.post(path, (c: HonoContext) => this.handleRequest(c, workflowName, path, config));
201
+ }
202
+
203
+ private async handleRequest(
204
+ c: HonoContext,
205
+ workflowName: string,
206
+ path: string,
207
+ config: WebhookTriggerConfig,
208
+ ): Promise<Response> {
209
+ this.counterReceived.add(1, { workflow_name: workflowName });
210
+
211
+ // 1. Capture raw body BEFORE parsing — verifiers sign the wire bytes.
212
+ const rawBody = await c.req.text();
213
+ let parsedBody: unknown = {};
214
+ if (rawBody.length > 0) {
215
+ try {
216
+ parsedBody = JSON.parse(rawBody) as unknown;
217
+ } catch {
218
+ // Non-JSON body — leave parsed as the raw text. Slack
219
+ // challenges & Shopify can post non-JSON occasionally.
220
+ parsedBody = rawBody;
221
+ }
293
222
  }
294
223
 
295
- const config = workflow.config.trigger?.webhook as WebhookTriggerOpts;
224
+ const headers = Object.fromEntries(c.req.raw.headers);
225
+ const pathParams = c.req.param() as Record<string, string>;
226
+ const queryParams = Object.fromEntries(new URL(c.req.url).searchParams);
296
227
 
297
- // Verify signature if secret is configured
298
- if (config.secret && event.signature) {
299
- const verification = handler.verifySignature(rawBody, event.signature, config.secret);
300
- if (!verification.valid) {
301
- this.logger.error(`Webhook signature verification failed: ${verification.error}`);
302
- throw new Error(`Signature verification failed: ${verification.error}`);
303
- }
304
- } else if (config.secret && !event.signature) {
305
- this.logger.error("Webhook signature missing but secret is configured");
306
- throw new Error("Signature missing");
228
+ // 2. Pick the verifier.
229
+ const verifier = this.resolveVerifier(workflowName, config);
230
+ if (!verifier) {
231
+ this.counterRejected.add(1, { workflow_name: workflowName, reason: "no_verifier" });
232
+ return c.json({ error: "Configuration", reason: "no_verifier", message: "No verifier configured" }, 500);
307
233
  }
308
234
 
309
- return this.executeWorkflow(event, workflow, config);
310
- }
235
+ // 3. Resolve the secret from the env var.
236
+ const secretEnv = config.secretEnv ?? config.signature?.secretEnv;
237
+ const secret = secretEnv ? (process.env[secretEnv] ?? "") : "";
311
238
 
312
- /**
313
- * Get all workflows that have webhook triggers
314
- */
315
- protected getWebhookWorkflows(): WebhookWorkflowModel[] {
316
- const workflows: WebhookWorkflowModel[] = [];
239
+ // 4. Verify.
240
+ const toleranceSec = config.tolerance ?? config.signature?.tolerance ?? DEFAULT_TOLERANCE_SEC;
241
+ const result: VerifyResult = verifier.verify({
242
+ headers,
243
+ rawBody,
244
+ parsedBody,
245
+ secret,
246
+ toleranceSec,
247
+ });
317
248
 
318
- for (const [path, workflow] of Object.entries(this.nodeMap.workflows || {})) {
319
- const workflowConfig = (workflow as unknown as { _config: WebhookWorkflowModel["config"] })._config;
249
+ if (!result.ok) {
250
+ this.counterRejected.add(1, { workflow_name: workflowName, reason: result.reason });
251
+ this.logger.error(
252
+ `[blok][webhook] ${workflowName}: verify failed reason=${result.reason} message="${result.message}"`,
253
+ );
254
+ return c.json({ error: "Unauthorized", reason: result.reason, message: result.message }, 401);
255
+ }
320
256
 
321
- if (workflowConfig?.trigger) {
322
- const triggerType = Object.keys(workflowConfig.trigger)[0];
257
+ // 5. Events allowlist — verified-but-out-of-scope returns 200 (no retry).
258
+ if (Array.isArray(config.events) && config.events.length > 0 && !config.events.includes(result.eventType)) {
259
+ this.counterRejected.add(1, { workflow_name: workflowName, reason: "event_not_allowed" });
260
+ return c.json({ status: "ignored", reason: "event_not_allowed", eventType: result.eventType }, 200);
261
+ }
323
262
 
324
- if (triggerType === "webhook" && workflowConfig.trigger.webhook) {
325
- workflows.push({
326
- path,
327
- config: workflowConfig,
328
- });
329
- }
263
+ // 6. Replay protection via the idempotency cache.
264
+ if (config.idempotencyKey && result.eventId) {
265
+ const tracker = RunTracker.getInstance();
266
+ const store = tracker.getStore();
267
+ const cached = store.getIdempotencyCache(REPLAY_NAMESPACE, workflowName, result.eventId);
268
+ if (cached) {
269
+ this.counterRejected.add(1, { workflow_name: workflowName, reason: "replay" });
270
+ return c.json(
271
+ {
272
+ status: "duplicate",
273
+ eventId: result.eventId,
274
+ eventType: result.eventType,
275
+ firstSeenRunId: cached.sourceRunId,
276
+ },
277
+ 200,
278
+ );
330
279
  }
331
280
  }
332
281
 
333
- return workflows;
282
+ // 7. Verified + new event — dispatch the workflow.
283
+ this.counterAccepted.add(1, { workflow_name: workflowName });
284
+ const requestId = uuid();
285
+ const dispatchOutcome = await this.dispatchWorkflow({
286
+ workflowName,
287
+ path,
288
+ config,
289
+ requestId,
290
+ headers,
291
+ body: parsedBody,
292
+ rawBody,
293
+ pathParams,
294
+ queryParams,
295
+ eventId: result.eventId,
296
+ eventType: result.eventType,
297
+ });
298
+
299
+ // 8. Cache event id AFTER successful dispatch so retries on the
300
+ // same delivery are deduped. We cache even on workflow failure
301
+ // — webhook senders should not retry deliveries they've
302
+ // already delivered (the workflow's own retry / DLQ owns that).
303
+ if (config.idempotencyKey && result.eventId) {
304
+ const tracker = RunTracker.getInstance();
305
+ const store = tracker.getStore();
306
+ store.setIdempotencyCache(REPLAY_NAMESPACE, workflowName, result.eventId, {
307
+ data: { eventId: result.eventId, eventType: result.eventType },
308
+ cachedAt: Date.now(),
309
+ expiresAt: Date.now() + DEFAULT_REPLAY_TTL_MS,
310
+ sourceRunId: requestId,
311
+ sourceNodeRunId: requestId,
312
+ });
313
+ }
314
+
315
+ // 9. Shape the response. Workflow's `ctx.response` lands in the
316
+ // body for parity with HTTP triggers.
317
+ return c.json(
318
+ {
319
+ status: "ok",
320
+ eventId: result.eventId,
321
+ eventType: result.eventType,
322
+ runId: requestId,
323
+ response: dispatchOutcome,
324
+ },
325
+ 200,
326
+ );
334
327
  }
335
328
 
336
- /**
337
- * Find workflow matching the webhook event
338
- */
339
- protected findMatchingWorkflow(event: WebhookEvent): WebhookWorkflowModel | null {
340
- for (const workflow of this.webhookWorkflows) {
341
- const config = workflow.config.trigger?.webhook;
342
- if (!config) continue;
343
-
344
- // Check source match
345
- if (config.source !== event.source) continue;
346
-
347
- // Check event type match
348
- if (config.events && config.events.length > 0) {
349
- const matches = config.events.some((pattern) => {
350
- // Support wildcards (e.g., "push", "pull_request.*")
351
- if (pattern === "*") return true;
352
- if (pattern.endsWith(".*")) {
353
- const prefix = pattern.slice(0, -2);
354
- return event.eventType.startsWith(prefix);
355
- }
356
- return pattern === event.eventType;
357
- });
358
- if (!matches) continue;
329
+ private resolveVerifier(workflowName: string, config: WebhookTriggerConfig): Verifier | null {
330
+ if (config.provider) {
331
+ const v = BUILTIN_VERIFIERS[config.provider];
332
+ if (!v) {
333
+ this.logger.error(`[blok][webhook] ${workflowName}: unknown provider "${config.provider}"`);
334
+ return null;
359
335
  }
360
-
361
- return workflow;
336
+ return v;
337
+ }
338
+ if (config.signature) {
339
+ return buildCustomVerifier({
340
+ scheme: config.signature.scheme ?? "hmac-sha256",
341
+ header: config.signature.header,
342
+ format: config.signature.format ?? "{hex}",
343
+ secretEnv: config.signature.secretEnv,
344
+ tolerance: config.signature.tolerance ?? DEFAULT_TOLERANCE_SEC,
345
+ timestampHeader: config.signature.timestampHeader,
346
+ });
362
347
  }
363
-
364
348
  return null;
365
349
  }
366
350
 
367
- /**
368
- * Execute a workflow for a webhook event
369
- */
370
- protected async executeWorkflow(
371
- event: WebhookEvent,
372
- workflow: WebhookWorkflowModel,
373
- _config: WebhookTriggerOpts,
374
- ): Promise<TriggerResponse> {
375
- const executionId = uuid();
376
-
377
- const defaultMeter = metrics.getMeter("default");
378
- const webhookExecutions = defaultMeter.createCounter("webhook_executions", {
379
- description: "Webhook executions",
380
- });
381
- const webhookErrors = defaultMeter.createCounter("webhook_errors", {
382
- description: "Webhook execution errors",
383
- });
384
-
385
- return new Promise((resolve) => {
386
- this.tracer.startActiveSpan(`webhook:${event.source}/${event.eventType}`, async (span: Span) => {
387
- try {
388
- const start = performance.now();
389
-
390
- // Initialize configuration for this workflow
391
- await this.configuration.init(workflow.path, this.nodeMap);
392
-
393
- // Create context
394
- const ctx: Context = this.createContext(undefined, workflow.path, executionId);
395
-
396
- // Populate request with webhook event
397
- ctx.request = {
398
- body: event.payload,
399
- headers: event.headers,
400
- query: {},
401
- params: {
402
- source: event.source,
403
- eventType: event.eventType,
404
- eventId: event.id,
405
- },
406
- } as unknown as RequestContext;
407
-
408
- // Store webhook context in vars
409
- if (!ctx.vars) ctx.vars = {};
410
- ctx.vars._webhook_event = {
411
- id: event.id,
412
- source: event.source,
413
- eventType: event.eventType,
414
- timestamp: event.timestamp.toISOString(),
415
- hasSignature: String(!!event.signature),
416
- };
417
-
418
- ctx.logger.log(`Processing webhook: ${event.source}/${event.eventType} (${event.id})`);
419
-
420
- // Execute workflow
421
- const response: TriggerResponse = await this.run(ctx);
422
- const end = performance.now();
423
-
424
- // Set span attributes
425
- span.setAttribute("success", true);
426
- span.setAttribute("event_id", event.id);
427
- span.setAttribute("source", event.source);
428
- span.setAttribute("event_type", event.eventType);
429
- span.setAttribute("workflow_path", workflow.path);
430
- span.setAttribute("elapsed_ms", end - start);
431
- span.setStatus({ code: SpanStatusCode.OK });
432
-
433
- // Record metrics
434
- webhookExecutions.add(1, {
435
- env: process.env.NODE_ENV,
436
- source: event.source,
437
- event_type: event.eventType,
438
- workflow_name: this.configuration.name,
439
- success: "true",
440
- });
441
-
442
- ctx.logger.log(`Webhook processed in ${(end - start).toFixed(2)}ms: ${event.id}`);
443
-
444
- resolve(response);
445
- } catch (error) {
446
- const errorMessage = (error as Error).message;
447
-
448
- // Set span error
449
- span.setAttribute("success", false);
450
- span.recordException(error as Error);
451
- span.setStatus({ code: SpanStatusCode.ERROR, message: errorMessage });
452
-
453
- // Record error metrics
454
- webhookErrors.add(1, {
455
- env: process.env.NODE_ENV,
456
- source: event.source,
457
- event_type: event.eventType,
458
- workflow_name: this.configuration?.name || "unknown",
459
- });
460
-
461
- this.logger.error(`Webhook failed ${event.id}: ${errorMessage}`, (error as Error).stack);
462
-
463
- throw error;
464
- } finally {
465
- span.end();
351
+ // ---------------------------------------------------------------------------
352
+ // Workflow dispatch
353
+ // ---------------------------------------------------------------------------
354
+
355
+ private async dispatchWorkflow(opts: {
356
+ workflowName: string;
357
+ path: string;
358
+ config: WebhookTriggerConfig;
359
+ requestId: string;
360
+ headers: Record<string, string>;
361
+ body: unknown;
362
+ rawBody: string;
363
+ pathParams: Record<string, string>;
364
+ queryParams: Record<string, string>;
365
+ eventId: string;
366
+ eventType: string;
367
+ }): Promise<unknown> {
368
+ const { workflowName, requestId, headers, body, rawBody, pathParams, queryParams, eventId, eventType } = opts;
369
+
370
+ return this.tracer.startActiveSpan(`webhook:${workflowName}`, async (span: Span) => {
371
+ try {
372
+ const registry = WorkflowRegistry.getInstance();
373
+ const entry = registry.get(workflowName);
374
+ if (!entry) {
375
+ throw new Error(`[blok][webhook] workflow "${workflowName}" not found in registry`);
466
376
  }
467
- });
377
+ await this.configuration.init(workflowName, this.nodeMap, entry.workflow);
378
+
379
+ const ctx: Context = this.createContext(undefined, workflowName, requestId);
380
+ ctx.request = {
381
+ body,
382
+ rawBody,
383
+ headers,
384
+ params: pathParams,
385
+ query: queryParams,
386
+ } as unknown as RequestContext;
387
+
388
+ // Stamp webhook metadata onto ctx so polymorphic dispatch
389
+ // can read namespace + event metadata uniformly.
390
+ (ctx as Record<string, unknown>)._webhook = {
391
+ eventId,
392
+ eventType,
393
+ namespace: opts.config.namespace,
394
+ };
395
+
396
+ await this.applyMiddlewareChain(ctx, this.nodeMap);
397
+ await this.run(ctx);
398
+
399
+ span.setAttribute("workflow_name", workflowName);
400
+ span.setAttribute("event_id", eventId);
401
+ span.setAttribute("event_type", eventType);
402
+ span.setStatus({ code: SpanStatusCode.OK });
403
+ return ctx.response;
404
+ } catch (err) {
405
+ const msg = err instanceof Error ? err.message : String(err);
406
+ span.recordException(err as Error);
407
+ span.setStatus({ code: SpanStatusCode.ERROR, message: msg });
408
+ this.logger.error(`[blok][webhook] workflow ${workflowName} failed: ${msg}`);
409
+ return { error: msg };
410
+ } finally {
411
+ span.end();
412
+ }
468
413
  });
469
414
  }
470
415
 
471
- /**
472
- * Register a custom source handler
473
- */
474
- static registerSourceHandler(source: string, handler: WebhookSourceHandler): void {
475
- sourceHandlers[source] = handler;
416
+ // ---------------------------------------------------------------------------
417
+ // Introspection
418
+ // ---------------------------------------------------------------------------
419
+
420
+ getStats(): { workflowsRegistered: number } {
421
+ return { workflowsRegistered: this.getWebhookWorkflows().length };
422
+ }
423
+
424
+ private getWebhookWorkflows(): Array<{ workflowName: string; config: WebhookTriggerConfig }> {
425
+ const registry = WorkflowRegistry.getInstance();
426
+ const out: Array<{ workflowName: string; config: WebhookTriggerConfig }> = [];
427
+ for (const entry of registry.list()) {
428
+ const wf = entry.workflow as { trigger?: { webhook?: WebhookTriggerConfig } } | undefined;
429
+ const cfg = wf?.trigger?.webhook;
430
+ if (!cfg) continue;
431
+ // Skip configs missing both provider AND signature — they can't
432
+ // verify anything. Authors get a structured error at boot.
433
+ if (!cfg.provider && !cfg.signature) {
434
+ this.logger.error(
435
+ `[blok][webhook] workflow "${entry.name}" has trigger.webhook with neither \`provider\` nor \`signature\` — skipping. Add one to enable signature verification.`,
436
+ );
437
+ continue;
438
+ }
439
+ out.push({ workflowName: entry.name, config: cfg });
440
+ }
441
+ return out;
476
442
  }
477
443
  }
478
444
 
479
- export default WebhookTrigger;
480
- export { sourceHandlers };
445
+ // -----------------------------------------------------------------------------
446
+ // Singleton accessor (mirrors WS / SSE)
447
+ // -----------------------------------------------------------------------------
448
+
449
+ let activeTrigger: WebhookTrigger | null = null;
450
+
451
+ export function _setActiveWebhookTrigger(trigger: WebhookTrigger | null): void {
452
+ activeTrigger = trigger;
453
+ }
454
+
455
+ export function _getActiveWebhookTrigger(): WebhookTrigger | null {
456
+ return activeTrigger;
457
+ }
458
+
459
+ export type { WebhookTriggerConfig };