@blokjs/trigger-webhook 0.6.17 → 0.6.19

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,459 +0,0 @@
1
- /**
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.
5
- *
6
- * **Authoring surface (built-in provider):**
7
- *
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.
55
- */
56
-
57
- import {
58
- DefaultLogger,
59
- RunTracker,
60
- type GlobalOptions as RunnerGlobalOptions,
61
- TriggerBase,
62
- WorkflowRegistry,
63
- } from "@blokjs/runner";
64
- import type { Context, RequestContext } from "@blokjs/shared";
65
- import { type Span, SpanStatusCode, metrics, trace } from "@opentelemetry/api";
66
- import type { Hono, Context as HonoContext } from "hono";
67
- import { v4 as uuid } from "uuid";
68
-
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;
87
- };
88
- tolerance?: number;
89
- idempotencyKey?: string;
90
- namespace?: string;
91
- middleware?: string[];
92
- }
93
-
94
- interface HttpTriggerLike {
95
- addPreCatchAllHook(cb: () => void | Promise<void>): void;
96
- }
97
-
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__";
101
-
102
- // -----------------------------------------------------------------------------
103
- // Trigger class
104
- // -----------------------------------------------------------------------------
105
-
106
- export default class WebhookTrigger extends TriggerBase {
107
- protected nodeMap: RunnerGlobalOptions = {} as RunnerGlobalOptions;
108
- protected readonly logger = new DefaultLogger();
109
- protected readonly tracer = trace.getTracer(
110
- process.env.PROJECT_NAME || "trigger-webhook-workflow",
111
- process.env.PROJECT_VERSION || "0.0.1",
112
- );
113
-
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) {
136
- super();
137
- this.app = app;
138
- this.httpTrigger = httpTrigger ?? null;
139
- _setActiveWebhookTrigger(this);
140
- }
141
-
142
- /**
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.
147
- */
148
- setNodeMap(nodeMap: RunnerGlobalOptions): void {
149
- this.nodeMap = nodeMap;
150
- }
151
-
152
- async listen(): Promise<number> {
153
- const startTime = this.startCounter();
154
- if (this.wired) {
155
- this.logger.log("[blok][webhook] listen() called twice; ignoring");
156
- return this.endCounter(startTime);
157
- }
158
- this.wired = true;
159
-
160
- if (this.httpTrigger) {
161
- this.httpTrigger.addPreCatchAllHook(() => {
162
- this.registerRoutesFromRegistry();
163
- });
164
- } else {
165
- this.registerRoutesFromRegistry();
166
- }
167
-
168
- return this.endCounter(startTime);
169
- }
170
-
171
- async stop(): Promise<void> {
172
- this.wired = false;
173
- if (_getActiveWebhookTrigger() === this) _setActiveWebhookTrigger(null);
174
- this.destroyMonitoring();
175
- this.logger.log("[blok][webhook] stopped");
176
- }
177
-
178
- // ---------------------------------------------------------------------------
179
- // Route registration
180
- // ---------------------------------------------------------------------------
181
-
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);
191
- }
192
- }
193
-
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})`);
199
-
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
- }
222
- }
223
-
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);
227
-
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);
233
- }
234
-
235
- // 3. Resolve the secret from the env var.
236
- const secretEnv = config.secretEnv ?? config.signature?.secretEnv;
237
- const secret = secretEnv ? (process.env[secretEnv] ?? "") : "";
238
-
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
- });
248
-
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
- }
256
-
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
- }
262
-
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
- );
279
- }
280
- }
281
-
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
- );
327
- }
328
-
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;
335
- }
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
- });
347
- }
348
- return null;
349
- }
350
-
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`);
376
- }
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
- }
413
- });
414
- }
415
-
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;
442
- }
443
- }
444
-
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 };
package/src/index.ts DELETED
@@ -1,51 +0,0 @@
1
- /**
2
- * @blokjs/trigger-webhook
3
- *
4
- * Inbound webhook trigger for Blok workflows. Mounts verified POST
5
- * routes on the shared Hono server alongside HTTP, WebSocket, and
6
- * SSE routes — same port, same middleware chain, same Studio
7
- * tracing. Verifies provider signatures (GitHub, Stripe, Slack,
8
- * Shopify, Svix/Standard Webhooks) or a custom HMAC scheme, applies
9
- * replay protection via the idempotency cache, and dispatches the
10
- * workflow.
11
- *
12
- * v0.7+ usage (just add the trigger to your workflow):
13
- *
14
- * ```json
15
- * {
16
- * "name": "stripe-events",
17
- * "trigger": {
18
- * "webhook": {
19
- * "provider": "stripe",
20
- * "path": "/webhooks/stripe",
21
- * "secretEnv": "STRIPE_WEBHOOK_SECRET",
22
- * "namespace": "stripe",
23
- * "idempotencyKey": "js/ctx.request.body.id"
24
- * }
25
- * },
26
- * "steps": [
27
- * { "id": "dispatch", "subworkflow": "js/ctx.request.body.type", "inputs": { "stripeEvent": "js/ctx.request.body" } }
28
- * ]
29
- * }
30
- * ```
31
- *
32
- * See [additional-triggers-plan.mdx](../../../docs/c/devtools/additional-triggers-plan.mdx#webhook-trigger)
33
- * for the full design.
34
- */
35
-
36
- import WebhookTrigger, { _getActiveWebhookTrigger, _setActiveWebhookTrigger } from "./WebhookTrigger";
37
-
38
- export default WebhookTrigger;
39
- export { WebhookTrigger, _getActiveWebhookTrigger, _setActiveWebhookTrigger };
40
- export type { WebhookTriggerConfig } from "./WebhookTrigger";
41
- export type { WebhookTriggerOpts } from "@blokjs/helper";
42
- export {
43
- BUILTIN_VERIFIERS,
44
- buildCustomVerifier,
45
- githubVerifier,
46
- shopifyVerifier,
47
- slackVerifier,
48
- stripeVerifier,
49
- svixVerifier,
50
- } from "./verifiers";
51
- export type { CustomSignatureConfig, VerifyError, VerifyInput, VerifyOk, VerifyResult, Verifier } from "./verifiers";