@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,316 +0,0 @@
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
- });
package/src/verifiers.ts DELETED
@@ -1,357 +0,0 @@
1
- /**
2
- * Webhook signature verifiers — one strategy per supported provider.
3
- *
4
- * Each verifier reads the raw request bytes (NOT the JSON-parsed body)
5
- * and the provider-specific signature header, computes the expected
6
- * HMAC against a shared secret, and constant-time compares. On match,
7
- * returns `{ ok: true, eventId, eventType }`. On mismatch / missing
8
- * header / drift, returns `{ ok: false, reason, message }`.
9
- *
10
- * Constant-time comparison via `crypto.timingSafeEqual` is mandatory —
11
- * a naive `===` compare leaks the expected HMAC byte by byte through
12
- * timing variance and a network-adjacent attacker can recover the
13
- * secret in ~256 requests per byte.
14
- *
15
- * Built-in providers + their signature shapes:
16
- *
17
- * - **github**: `X-Hub-Signature-256: sha256=<hex>` over rawBody.
18
- * Event id from `X-GitHub-Delivery`; event type
19
- * from `X-GitHub-Event` header.
20
- * - **stripe**: `Stripe-Signature: t=<ts>,v1=<hex>` over
21
- * `<ts>.<rawBody>` with a 5-minute drift window.
22
- * Event id + type from body.id / body.type.
23
- * - **slack**: `X-Slack-Signature: v0=<hex>` over
24
- * `v0:<X-Slack-Request-Timestamp>:<rawBody>` with a
25
- * 5-minute drift window. Event type from
26
- * body.event.type; event id from body.event_id.
27
- * - **shopify**: `X-Shopify-Hmac-Sha256: <base64>` over rawBody.
28
- * Event type from `X-Shopify-Topic`; event id from
29
- * `X-Shopify-Webhook-Id`.
30
- * - **svix**: Standard Webhooks. `webhook-signature:
31
- * v1,<base64>` over `<webhook-id>.<webhook-timestamp>.<rawBody>`
32
- * with a 5-minute drift window. Event id from
33
- * `webhook-id`; event type from body.type.
34
- *
35
- * Custom (unknown provider) verifier is built dynamically by
36
- * `buildCustomVerifier()` from the workflow's `signature` config.
37
- */
38
-
39
- import { createHmac, timingSafeEqual } from "node:crypto";
40
-
41
- /** Successful verification result — workflow may proceed. */
42
- export interface VerifyOk {
43
- ok: true;
44
- /** Provider-specific event id (used for replay-protection cache key). */
45
- eventId: string;
46
- /** Provider-specific event type (used for the allowlist check). */
47
- eventType: string;
48
- }
49
-
50
- /** Verification failure — trigger returns 401 with structured reason. */
51
- export interface VerifyError {
52
- ok: false;
53
- /** Stable discriminator — log/alert dashboards branch on this. */
54
- reason:
55
- | "missing_signature"
56
- | "missing_timestamp"
57
- | "missing_secret"
58
- | "bad_format"
59
- | "timestamp_drift"
60
- | "signature_mismatch";
61
- /** Human-readable error message. Safe to surface to the sender. */
62
- message: string;
63
- }
64
-
65
- export type VerifyResult = VerifyOk | VerifyError;
66
-
67
- /** Inputs every verifier receives. */
68
- export interface VerifyInput {
69
- headers: Record<string, string>;
70
- rawBody: string;
71
- parsedBody: unknown;
72
- secret: string;
73
- toleranceSec: number;
74
- }
75
-
76
- export interface Verifier {
77
- verify(input: VerifyInput): VerifyResult;
78
- }
79
-
80
- const DEFAULT_TOLERANCE_SEC = 300;
81
-
82
- function safeEqualString(a: string, b: string): boolean {
83
- const aBuf = Buffer.from(a);
84
- const bBuf = Buffer.from(b);
85
- if (aBuf.length !== bBuf.length) return false;
86
- return timingSafeEqual(aBuf, bBuf);
87
- }
88
-
89
- function hmacHex(algo: "sha256" | "sha1" | "sha512", secret: string, data: string): string {
90
- return createHmac(algo, secret).update(data).digest("hex");
91
- }
92
-
93
- function hmacBase64(algo: "sha256" | "sha1" | "sha512", secret: string, data: string): string {
94
- return createHmac(algo, secret).update(data).digest("base64");
95
- }
96
-
97
- function isWithinTolerance(timestampSec: number, toleranceSec: number): boolean {
98
- const nowSec = Math.floor(Date.now() / 1000);
99
- return Math.abs(nowSec - timestampSec) <= toleranceSec;
100
- }
101
-
102
- function getEventTypeFromBody(parsedBody: unknown, key = "type"): string | undefined {
103
- if (!parsedBody || typeof parsedBody !== "object") return undefined;
104
- const value = (parsedBody as Record<string, unknown>)[key];
105
- return typeof value === "string" ? value : undefined;
106
- }
107
-
108
- // =============================================================================
109
- // Built-in providers
110
- // =============================================================================
111
-
112
- export const githubVerifier: Verifier = {
113
- verify({ headers, rawBody, secret }) {
114
- if (!secret) return { ok: false, reason: "missing_secret", message: "GitHub: secret not configured" };
115
- const sig = headers["x-hub-signature-256"];
116
- if (!sig) return { ok: false, reason: "missing_signature", message: "GitHub: X-Hub-Signature-256 header missing" };
117
- const expected = `sha256=${hmacHex("sha256", secret, rawBody)}`;
118
- if (!safeEqualString(sig, expected)) {
119
- return { ok: false, reason: "signature_mismatch", message: "GitHub: signature mismatch" };
120
- }
121
- return {
122
- ok: true,
123
- eventId: headers["x-github-delivery"] ?? "",
124
- eventType: headers["x-github-event"] ?? "unknown",
125
- };
126
- },
127
- };
128
-
129
- export const stripeVerifier: Verifier = {
130
- verify({ headers, rawBody, parsedBody, secret, toleranceSec }) {
131
- if (!secret) return { ok: false, reason: "missing_secret", message: "Stripe: secret not configured" };
132
- const sig = headers["stripe-signature"];
133
- if (!sig) return { ok: false, reason: "missing_signature", message: "Stripe: Stripe-Signature header missing" };
134
-
135
- // Format: t=1234567890,v1=<hex>,v0=<hex>,...
136
- const parts = Object.fromEntries(
137
- sig
138
- .split(",")
139
- .map((p) => p.trim())
140
- .map((p) => {
141
- const idx = p.indexOf("=");
142
- return idx === -1 ? [p, ""] : [p.slice(0, idx), p.slice(idx + 1)];
143
- }),
144
- );
145
- const ts = parts.t;
146
- const v1 = parts.v1;
147
- if (!ts || !v1) {
148
- return {
149
- ok: false,
150
- reason: "bad_format",
151
- message: "Stripe: signature missing t= or v1= component",
152
- };
153
- }
154
- const tsNum = Number.parseInt(ts, 10);
155
- if (!Number.isFinite(tsNum)) {
156
- return { ok: false, reason: "bad_format", message: "Stripe: t= is not numeric" };
157
- }
158
- if (!isWithinTolerance(tsNum, toleranceSec || DEFAULT_TOLERANCE_SEC)) {
159
- return {
160
- ok: false,
161
- reason: "timestamp_drift",
162
- message: `Stripe: timestamp drift exceeds ${toleranceSec || DEFAULT_TOLERANCE_SEC}s tolerance`,
163
- };
164
- }
165
- const expected = hmacHex("sha256", secret, `${ts}.${rawBody}`);
166
- if (!safeEqualString(v1, expected)) {
167
- return { ok: false, reason: "signature_mismatch", message: "Stripe: signature mismatch" };
168
- }
169
- const eventId = (parsedBody as { id?: unknown } | null)?.id;
170
- const eventType = getEventTypeFromBody(parsedBody);
171
- return {
172
- ok: true,
173
- eventId: typeof eventId === "string" ? eventId : "",
174
- eventType: eventType ?? "unknown",
175
- };
176
- },
177
- };
178
-
179
- export const slackVerifier: Verifier = {
180
- verify({ headers, rawBody, parsedBody, secret, toleranceSec }) {
181
- if (!secret) return { ok: false, reason: "missing_secret", message: "Slack: secret not configured" };
182
- const sig = headers["x-slack-signature"];
183
- if (!sig) return { ok: false, reason: "missing_signature", message: "Slack: X-Slack-Signature header missing" };
184
- const ts = headers["x-slack-request-timestamp"];
185
- if (!ts) {
186
- return {
187
- ok: false,
188
- reason: "missing_timestamp",
189
- message: "Slack: X-Slack-Request-Timestamp header missing",
190
- };
191
- }
192
- const tsNum = Number.parseInt(ts, 10);
193
- if (!Number.isFinite(tsNum)) {
194
- return { ok: false, reason: "bad_format", message: "Slack: timestamp is not numeric" };
195
- }
196
- if (!isWithinTolerance(tsNum, toleranceSec || DEFAULT_TOLERANCE_SEC)) {
197
- return {
198
- ok: false,
199
- reason: "timestamp_drift",
200
- message: `Slack: timestamp drift exceeds ${toleranceSec || DEFAULT_TOLERANCE_SEC}s tolerance`,
201
- };
202
- }
203
- const expected = `v0=${hmacHex("sha256", secret, `v0:${ts}:${rawBody}`)}`;
204
- if (!safeEqualString(sig, expected)) {
205
- return { ok: false, reason: "signature_mismatch", message: "Slack: signature mismatch" };
206
- }
207
- const event = (parsedBody as { event?: { type?: unknown }; event_id?: unknown } | null) ?? {};
208
- const eventType = typeof event.event?.type === "string" ? event.event.type : "unknown";
209
- const eventId = typeof event.event_id === "string" ? event.event_id : "";
210
- return { ok: true, eventId, eventType };
211
- },
212
- };
213
-
214
- export const shopifyVerifier: Verifier = {
215
- verify({ headers, rawBody, secret }) {
216
- if (!secret) return { ok: false, reason: "missing_secret", message: "Shopify: secret not configured" };
217
- const sig = headers["x-shopify-hmac-sha256"];
218
- if (!sig) {
219
- return { ok: false, reason: "missing_signature", message: "Shopify: X-Shopify-Hmac-Sha256 header missing" };
220
- }
221
- const expected = hmacBase64("sha256", secret, rawBody);
222
- if (!safeEqualString(sig, expected)) {
223
- return { ok: false, reason: "signature_mismatch", message: "Shopify: signature mismatch" };
224
- }
225
- return {
226
- ok: true,
227
- eventId: headers["x-shopify-webhook-id"] ?? "",
228
- eventType: headers["x-shopify-topic"] ?? "unknown",
229
- };
230
- },
231
- };
232
-
233
- export const svixVerifier: Verifier = {
234
- verify({ headers, rawBody, parsedBody, secret, toleranceSec }) {
235
- if (!secret) return { ok: false, reason: "missing_secret", message: "Svix: secret not configured" };
236
- const webhookId = headers["webhook-id"];
237
- const webhookTs = headers["webhook-timestamp"];
238
- const webhookSig = headers["webhook-signature"];
239
- if (!webhookId || !webhookTs || !webhookSig) {
240
- return {
241
- ok: false,
242
- reason: "missing_signature",
243
- message: "Svix/Standard Webhooks: missing webhook-id, webhook-timestamp, or webhook-signature header",
244
- };
245
- }
246
- const tsNum = Number.parseInt(webhookTs, 10);
247
- if (!Number.isFinite(tsNum)) {
248
- return { ok: false, reason: "bad_format", message: "Svix: webhook-timestamp is not numeric" };
249
- }
250
- if (!isWithinTolerance(tsNum, toleranceSec || DEFAULT_TOLERANCE_SEC)) {
251
- return {
252
- ok: false,
253
- reason: "timestamp_drift",
254
- message: `Svix: timestamp drift exceeds ${toleranceSec || DEFAULT_TOLERANCE_SEC}s tolerance`,
255
- };
256
- }
257
- const signed = `${webhookId}.${webhookTs}.${rawBody}`;
258
- // Strip optional `whsec_` prefix Svix recommends for secrets.
259
- const rawSecret = secret.startsWith("whsec_") ? secret.slice("whsec_".length) : secret;
260
- // Svix encodes the secret as base64 — decode before HMAC.
261
- const secretBuf = Buffer.from(rawSecret, "base64");
262
- const expected = createHmac("sha256", secretBuf).update(signed).digest("base64");
263
- // webhook-signature may include multiple versions: `v1,base64 v1,base64 ...`
264
- const sigs = webhookSig.split(" ").map((s) => {
265
- const idx = s.indexOf(",");
266
- return idx === -1 ? s : s.slice(idx + 1);
267
- });
268
- const matched = sigs.some((s) => safeEqualString(s, expected));
269
- if (!matched) {
270
- return { ok: false, reason: "signature_mismatch", message: "Svix: signature mismatch" };
271
- }
272
- return {
273
- ok: true,
274
- eventId: webhookId,
275
- eventType: getEventTypeFromBody(parsedBody) ?? "unknown",
276
- };
277
- },
278
- };
279
-
280
- // =============================================================================
281
- // Custom verifier — built from the workflow's `signature: { ... }` config
282
- // =============================================================================
283
-
284
- export interface CustomSignatureConfig {
285
- scheme: "hmac-sha256" | "hmac-sha1" | "hmac-sha512";
286
- header: string;
287
- format: string;
288
- secretEnv: string;
289
- tolerance: number;
290
- timestampHeader?: string;
291
- }
292
-
293
- export function buildCustomVerifier(config: CustomSignatureConfig): Verifier {
294
- const algo: "sha256" | "sha1" | "sha512" =
295
- config.scheme === "hmac-sha1" ? "sha1" : config.scheme === "hmac-sha512" ? "sha512" : "sha256";
296
- const headerLower = config.header.toLowerCase();
297
- const tsHeaderLower = config.timestampHeader?.toLowerCase();
298
-
299
- return {
300
- verify({ headers, rawBody, parsedBody, secret, toleranceSec }) {
301
- if (!secret) return { ok: false, reason: "missing_secret", message: `${config.header}: secret not configured` };
302
- const sig = headers[headerLower];
303
- if (!sig) {
304
- return { ok: false, reason: "missing_signature", message: `${config.header}: header missing` };
305
- }
306
-
307
- let signedString = rawBody;
308
- if (tsHeaderLower) {
309
- const ts = headers[tsHeaderLower];
310
- if (!ts) {
311
- return {
312
- ok: false,
313
- reason: "missing_timestamp",
314
- message: `${config.timestampHeader}: header missing`,
315
- };
316
- }
317
- const tsNum = Number.parseInt(ts, 10);
318
- if (!Number.isFinite(tsNum)) {
319
- return {
320
- ok: false,
321
- reason: "bad_format",
322
- message: `${config.timestampHeader}: not numeric`,
323
- };
324
- }
325
- if (!isWithinTolerance(tsNum, toleranceSec || config.tolerance)) {
326
- return {
327
- ok: false,
328
- reason: "timestamp_drift",
329
- message: `Timestamp drift exceeds ${toleranceSec || config.tolerance}s tolerance`,
330
- };
331
- }
332
- signedString = `${ts}.${rawBody}`;
333
- }
334
-
335
- const hex = hmacHex(algo, secret, signedString);
336
- const base64 = hmacBase64(algo, secret, signedString);
337
- const expected = config.format.replace("{hex}", hex).replace("{base64}", base64);
338
-
339
- if (!safeEqualString(sig, expected)) {
340
- return { ok: false, reason: "signature_mismatch", message: `${config.header}: signature mismatch` };
341
- }
342
- return {
343
- ok: true,
344
- eventId: "",
345
- eventType: getEventTypeFromBody(parsedBody) ?? "unknown",
346
- };
347
- },
348
- };
349
- }
350
-
351
- export const BUILTIN_VERIFIERS: Record<string, Verifier> = {
352
- github: githubVerifier,
353
- stripe: stripeVerifier,
354
- slack: slackVerifier,
355
- shopify: shopifyVerifier,
356
- svix: svixVerifier,
357
- };
package/tsconfig.json DELETED
@@ -1,32 +0,0 @@
1
- {
2
- "ts-node": {
3
- "transpileOnly": true
4
- },
5
- "compilerOptions": {
6
- "target": "ES2022",
7
- "module": "es2022",
8
- "lib": ["ES2022"],
9
- "declaration": true,
10
- "strict": true,
11
- "noImplicitAny": true,
12
- "strictNullChecks": true,
13
- "noImplicitThis": true,
14
- "alwaysStrict": true,
15
- "noUnusedLocals": false,
16
- "noUnusedParameters": false,
17
- "noImplicitReturns": true,
18
- "noFallthroughCasesInSwitch": false,
19
- "inlineSourceMap": true,
20
- "inlineSources": true,
21
- "experimentalDecorators": true,
22
- "emitDecoratorMetadata": true,
23
- "skipLibCheck": true,
24
- "esModuleInterop": true,
25
- "resolveJsonModule": true,
26
- "outDir": "./dist",
27
- "rootDir": "./src",
28
- "moduleResolution": "bundler"
29
- },
30
- "include": ["src/**/*"],
31
- "exclude": ["node_modules", "dist", "**/*.test.ts"]
32
- }