@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.
- package/dist/WebhookTrigger.d.ts +100 -117
- package/dist/WebhookTrigger.js +315 -316
- package/dist/index.d.ts +36 -65
- package/dist/index.js +34 -71
- package/dist/verifiers.d.ts +80 -0
- package/dist/verifiers.js +294 -0
- package/package.json +6 -4
- package/src/WebhookTrigger.integration.test.ts +162 -0
- package/src/WebhookTrigger.test.ts +232 -143
- package/src/WebhookTrigger.ts +386 -407
- package/src/index.ts +41 -70
- package/src/verifiers.test.ts +316 -0
- package/src/verifiers.ts +357 -0
package/src/index.ts
CHANGED
|
@@ -1,80 +1,51 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @blokjs/trigger-webhook
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* -
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
-
*
|
|
44
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|