@blokjs/trigger-webhook 0.2.1 → 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.
package/src/index.ts CHANGED
@@ -1,80 +1,51 @@
1
1
  /**
2
2
  * @blokjs/trigger-webhook
3
3
  *
4
- * Webhook trigger for Blok workflows.
5
- * Handle webhook events from external services.
6
- *
7
- * Supported Services:
8
- * - GitHub (push, pull_request, issues, releases, etc.)
9
- * - Stripe (payment_intent, checkout.session, customer, etc.)
10
- * - Shopify (orders, products, customers, etc.)
11
- * - Custom webhooks (any service with signature verification)
12
- *
13
- * Features:
14
- * - Signature verification (HMAC-SHA256)
15
- * - Event type filtering
16
- * - Source-specific handlers
17
- * - Custom source registration
18
- *
19
- * @example
20
- * ```typescript
21
- * import { WebhookTrigger } from "@blokjs/trigger-webhook";
22
- *
23
- * class MyWebhookTrigger extends WebhookTrigger {
24
- * protected nodes = myNodes;
25
- * protected workflows = myWorkflows;
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
+ * ]
26
29
  * }
27
- *
28
- * const trigger = new MyWebhookTrigger();
29
- * await trigger.listen();
30
- *
31
- * // In your HTTP endpoint handler:
32
- * app.post("/webhooks/:source", async (req, res) => {
33
- * const rawBody = JSON.stringify(req.body);
34
- * const result = await trigger.handleWebhook(
35
- * req.params.source,
36
- * rawBody,
37
- * req.headers as Record<string, string>
38
- * );
39
- * res.status(200).json({ received: true });
40
- * });
41
30
  * ```
42
31
  *
43
- * Workflow Definition:
44
- * ```typescript
45
- * Workflow({ name: "github-push", version: "1.0.0" })
46
- * .addTrigger("webhook", {
47
- * source: "github",
48
- * events: ["push", "pull_request.*"],
49
- * secret: process.env.GITHUB_WEBHOOK_SECRET,
50
- * })
51
- * .addStep({ ... });
52
- * ```
53
- *
54
- * Custom Source Handler:
55
- * ```typescript
56
- * import { WebhookTrigger } from "@blokjs/trigger-webhook";
57
- *
58
- * WebhookTrigger.registerSourceHandler("my-service", {
59
- * getEventType: (headers, body) => body.event_type,
60
- * getSignature: (headers) => headers["x-my-signature"],
61
- * verifySignature: (rawBody, signature, secret) => {
62
- * // Your verification logic
63
- * return { valid: true };
64
- * },
65
- * getEventId: (headers, body) => body.id,
66
- * });
67
- * ```
32
+ * See [additional-triggers-plan.mdx](../../../docs/c/devtools/additional-triggers-plan.mdx#webhook-trigger)
33
+ * for the full design.
68
34
  */
69
35
 
70
- // Core exports
71
- export {
72
- WebhookTrigger,
73
- sourceHandlers,
74
- type WebhookEvent,
75
- type VerificationResult,
76
- type WebhookSourceHandler,
77
- } from "./WebhookTrigger";
36
+ import WebhookTrigger, { _getActiveWebhookTrigger, _setActiveWebhookTrigger } from "./WebhookTrigger";
78
37
 
79
- // Re-export types from helper for convenience
38
+ export default WebhookTrigger;
39
+ export { WebhookTrigger, _getActiveWebhookTrigger, _setActiveWebhookTrigger };
40
+ export type { WebhookTriggerConfig } from "./WebhookTrigger";
80
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";
@@ -0,0 +1,316 @@
1
+ /**
2
+ * Verifier unit tests — one suite per built-in provider plus the
3
+ * custom HMAC builder. Each suite covers: happy-path verification,
4
+ * signature mismatch, missing signature header, and (for providers
5
+ * with timestamp-bound signing) clock drift rejection.
6
+ */
7
+
8
+ import { createHmac } from "node:crypto";
9
+ import { describe, expect, it } from "vitest";
10
+
11
+ import {
12
+ buildCustomVerifier,
13
+ githubVerifier,
14
+ shopifyVerifier,
15
+ slackVerifier,
16
+ stripeVerifier,
17
+ svixVerifier,
18
+ } from "./verifiers";
19
+
20
+ const SECRET = "shhh-its-a-secret-1234567890";
21
+
22
+ function nowSec(): number {
23
+ return Math.floor(Date.now() / 1000);
24
+ }
25
+
26
+ function hmacHex(data: string, secret = SECRET): string {
27
+ return createHmac("sha256", secret).update(data).digest("hex");
28
+ }
29
+
30
+ function hmacBase64(data: string, secret = SECRET): string {
31
+ return createHmac("sha256", secret).update(data).digest("base64");
32
+ }
33
+
34
+ describe("githubVerifier", () => {
35
+ const body = JSON.stringify({ ref: "refs/heads/main", action: "opened" });
36
+
37
+ it("accepts a valid X-Hub-Signature-256 over rawBody", () => {
38
+ const result = githubVerifier.verify({
39
+ headers: {
40
+ "x-hub-signature-256": `sha256=${hmacHex(body)}`,
41
+ "x-github-event": "push",
42
+ "x-github-delivery": "delivery-uuid-1",
43
+ },
44
+ rawBody: body,
45
+ parsedBody: JSON.parse(body),
46
+ secret: SECRET,
47
+ toleranceSec: 300,
48
+ });
49
+ expect(result.ok).toBe(true);
50
+ if (result.ok) {
51
+ expect(result.eventId).toBe("delivery-uuid-1");
52
+ expect(result.eventType).toBe("push");
53
+ }
54
+ });
55
+
56
+ it("rejects on signature mismatch", () => {
57
+ const result = githubVerifier.verify({
58
+ headers: { "x-hub-signature-256": "sha256=deadbeef" },
59
+ rawBody: body,
60
+ parsedBody: {},
61
+ secret: SECRET,
62
+ toleranceSec: 300,
63
+ });
64
+ expect(result.ok).toBe(false);
65
+ if (!result.ok) expect(result.reason).toBe("signature_mismatch");
66
+ });
67
+
68
+ it("rejects when X-Hub-Signature-256 header is missing", () => {
69
+ const result = githubVerifier.verify({
70
+ headers: {},
71
+ rawBody: body,
72
+ parsedBody: {},
73
+ secret: SECRET,
74
+ toleranceSec: 300,
75
+ });
76
+ expect(result.ok).toBe(false);
77
+ if (!result.ok) expect(result.reason).toBe("missing_signature");
78
+ });
79
+ });
80
+
81
+ describe("stripeVerifier", () => {
82
+ const body = JSON.stringify({ id: "evt_123", type: "invoice.paid" });
83
+
84
+ it("accepts a valid Stripe-Signature within tolerance", () => {
85
+ const ts = String(nowSec());
86
+ const sig = hmacHex(`${ts}.${body}`);
87
+ const result = stripeVerifier.verify({
88
+ headers: { "stripe-signature": `t=${ts},v1=${sig}` },
89
+ rawBody: body,
90
+ parsedBody: JSON.parse(body),
91
+ secret: SECRET,
92
+ toleranceSec: 300,
93
+ });
94
+ expect(result.ok).toBe(true);
95
+ if (result.ok) {
96
+ expect(result.eventId).toBe("evt_123");
97
+ expect(result.eventType).toBe("invoice.paid");
98
+ }
99
+ });
100
+
101
+ it("rejects timestamps outside the tolerance window", () => {
102
+ const ts = String(nowSec() - 10_000);
103
+ const sig = hmacHex(`${ts}.${body}`);
104
+ const result = stripeVerifier.verify({
105
+ headers: { "stripe-signature": `t=${ts},v1=${sig}` },
106
+ rawBody: body,
107
+ parsedBody: JSON.parse(body),
108
+ secret: SECRET,
109
+ toleranceSec: 300,
110
+ });
111
+ expect(result.ok).toBe(false);
112
+ if (!result.ok) expect(result.reason).toBe("timestamp_drift");
113
+ });
114
+
115
+ it("rejects on signature mismatch", () => {
116
+ const ts = String(nowSec());
117
+ const result = stripeVerifier.verify({
118
+ headers: { "stripe-signature": `t=${ts},v1=ffffffff` },
119
+ rawBody: body,
120
+ parsedBody: JSON.parse(body),
121
+ secret: SECRET,
122
+ toleranceSec: 300,
123
+ });
124
+ expect(result.ok).toBe(false);
125
+ if (!result.ok) expect(result.reason).toBe("signature_mismatch");
126
+ });
127
+
128
+ it("rejects when t= or v1= component is missing", () => {
129
+ const result = stripeVerifier.verify({
130
+ headers: { "stripe-signature": "v1=abc" },
131
+ rawBody: body,
132
+ parsedBody: JSON.parse(body),
133
+ secret: SECRET,
134
+ toleranceSec: 300,
135
+ });
136
+ expect(result.ok).toBe(false);
137
+ if (!result.ok) expect(result.reason).toBe("bad_format");
138
+ });
139
+ });
140
+
141
+ describe("slackVerifier", () => {
142
+ const body = JSON.stringify({ event: { type: "message" }, event_id: "Ev123" });
143
+
144
+ it("accepts a valid X-Slack-Signature", () => {
145
+ const ts = String(nowSec());
146
+ const sig = `v0=${hmacHex(`v0:${ts}:${body}`)}`;
147
+ const result = slackVerifier.verify({
148
+ headers: {
149
+ "x-slack-signature": sig,
150
+ "x-slack-request-timestamp": ts,
151
+ },
152
+ rawBody: body,
153
+ parsedBody: JSON.parse(body),
154
+ secret: SECRET,
155
+ toleranceSec: 300,
156
+ });
157
+ expect(result.ok).toBe(true);
158
+ if (result.ok) {
159
+ expect(result.eventId).toBe("Ev123");
160
+ expect(result.eventType).toBe("message");
161
+ }
162
+ });
163
+
164
+ it("rejects when X-Slack-Request-Timestamp header is missing", () => {
165
+ const result = slackVerifier.verify({
166
+ headers: { "x-slack-signature": "v0=abc" },
167
+ rawBody: body,
168
+ parsedBody: {},
169
+ secret: SECRET,
170
+ toleranceSec: 300,
171
+ });
172
+ expect(result.ok).toBe(false);
173
+ if (!result.ok) expect(result.reason).toBe("missing_timestamp");
174
+ });
175
+ });
176
+
177
+ describe("shopifyVerifier", () => {
178
+ const body = JSON.stringify({ id: 999, line_items: [] });
179
+
180
+ it("accepts a valid base64 X-Shopify-Hmac-Sha256", () => {
181
+ const result = shopifyVerifier.verify({
182
+ headers: {
183
+ "x-shopify-hmac-sha256": hmacBase64(body),
184
+ "x-shopify-topic": "orders/create",
185
+ "x-shopify-webhook-id": "shopify-webhook-1",
186
+ },
187
+ rawBody: body,
188
+ parsedBody: JSON.parse(body),
189
+ secret: SECRET,
190
+ toleranceSec: 300,
191
+ });
192
+ expect(result.ok).toBe(true);
193
+ if (result.ok) {
194
+ expect(result.eventId).toBe("shopify-webhook-1");
195
+ expect(result.eventType).toBe("orders/create");
196
+ }
197
+ });
198
+
199
+ it("rejects on signature mismatch", () => {
200
+ const result = shopifyVerifier.verify({
201
+ headers: { "x-shopify-hmac-sha256": "deadbeef==" },
202
+ rawBody: body,
203
+ parsedBody: {},
204
+ secret: SECRET,
205
+ toleranceSec: 300,
206
+ });
207
+ expect(result.ok).toBe(false);
208
+ if (!result.ok) expect(result.reason).toBe("signature_mismatch");
209
+ });
210
+ });
211
+
212
+ describe("svixVerifier (Standard Webhooks)", () => {
213
+ const body = JSON.stringify({ type: "user.created", data: { id: "user_42" } });
214
+ // Svix expects the secret base64-decoded — encode our test secret first.
215
+ const svixSecret = Buffer.from(SECRET).toString("base64");
216
+
217
+ it("accepts a valid webhook-signature (v1 scheme)", () => {
218
+ const webhookId = "msg_abc";
219
+ const webhookTs = String(nowSec());
220
+ const signed = `${webhookId}.${webhookTs}.${body}`;
221
+ const expected = createHmac("sha256", Buffer.from(svixSecret, "base64")).update(signed).digest("base64");
222
+ const result = svixVerifier.verify({
223
+ headers: {
224
+ "webhook-id": webhookId,
225
+ "webhook-timestamp": webhookTs,
226
+ "webhook-signature": `v1,${expected}`,
227
+ },
228
+ rawBody: body,
229
+ parsedBody: JSON.parse(body),
230
+ secret: svixSecret,
231
+ toleranceSec: 300,
232
+ });
233
+ expect(result.ok).toBe(true);
234
+ if (result.ok) {
235
+ expect(result.eventId).toBe(webhookId);
236
+ expect(result.eventType).toBe("user.created");
237
+ }
238
+ });
239
+
240
+ it("rejects when any of the three headers is missing", () => {
241
+ const result = svixVerifier.verify({
242
+ headers: { "webhook-id": "msg", "webhook-timestamp": String(nowSec()) },
243
+ rawBody: body,
244
+ parsedBody: JSON.parse(body),
245
+ secret: svixSecret,
246
+ toleranceSec: 300,
247
+ });
248
+ expect(result.ok).toBe(false);
249
+ if (!result.ok) expect(result.reason).toBe("missing_signature");
250
+ });
251
+ });
252
+
253
+ describe("buildCustomVerifier (custom signature scheme)", () => {
254
+ const body = JSON.stringify({ type: "ping" });
255
+
256
+ it("accepts a valid signature with `{hex}` format placeholder", () => {
257
+ const verifier = buildCustomVerifier({
258
+ scheme: "hmac-sha256",
259
+ header: "X-Acme-Signature",
260
+ format: "sha256={hex}",
261
+ secretEnv: "ACME_SECRET",
262
+ tolerance: 300,
263
+ });
264
+ const result = verifier.verify({
265
+ headers: { "x-acme-signature": `sha256=${hmacHex(body)}` },
266
+ rawBody: body,
267
+ parsedBody: JSON.parse(body),
268
+ secret: SECRET,
269
+ toleranceSec: 300,
270
+ });
271
+ expect(result.ok).toBe(true);
272
+ });
273
+
274
+ it("accepts a valid signature with timestamp + tolerance", () => {
275
+ const verifier = buildCustomVerifier({
276
+ scheme: "hmac-sha256",
277
+ header: "X-Acme-Signature",
278
+ format: "{hex}",
279
+ secretEnv: "ACME_SECRET",
280
+ tolerance: 300,
281
+ timestampHeader: "X-Acme-Timestamp",
282
+ });
283
+ const ts = String(nowSec());
284
+ const sig = hmacHex(`${ts}.${body}`);
285
+ const result = verifier.verify({
286
+ headers: { "x-acme-signature": sig, "x-acme-timestamp": ts },
287
+ rawBody: body,
288
+ parsedBody: JSON.parse(body),
289
+ secret: SECRET,
290
+ toleranceSec: 300,
291
+ });
292
+ expect(result.ok).toBe(true);
293
+ });
294
+
295
+ it("rejects when timestamp drifts beyond tolerance", () => {
296
+ const verifier = buildCustomVerifier({
297
+ scheme: "hmac-sha256",
298
+ header: "X-Acme-Signature",
299
+ format: "{hex}",
300
+ secretEnv: "ACME_SECRET",
301
+ tolerance: 60,
302
+ timestampHeader: "X-Acme-Timestamp",
303
+ });
304
+ const ts = String(nowSec() - 600);
305
+ const sig = hmacHex(`${ts}.${body}`);
306
+ const result = verifier.verify({
307
+ headers: { "x-acme-signature": sig, "x-acme-timestamp": ts },
308
+ rawBody: body,
309
+ parsedBody: JSON.parse(body),
310
+ secret: SECRET,
311
+ toleranceSec: 60,
312
+ });
313
+ expect(result.ok).toBe(false);
314
+ if (!result.ok) expect(result.reason).toBe("timestamp_drift");
315
+ });
316
+ });