@hookflo/tern 2.0.3-experimental.0 → 2.2.3

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/README.md CHANGED
@@ -210,7 +210,15 @@ const result = await WebhookVerificationService.verify(request, stripeConfig);
210
210
 
211
211
  ### Other Platforms
212
212
  - **Dodo Payments**: HMAC-SHA256
213
- - **Shopify**: HMAC-SHA256
213
+ - **Paddle**: HMAC-SHA256
214
+ - **Razorpay**: HMAC-SHA256
215
+ - **Lemon Squeezy**: HMAC-SHA256
216
+ - **Auth0**: HMAC-SHA256
217
+ - **WorkOS**: HMAC-SHA256 (`workos-signature`, `t/v1`)
218
+ - **WooCommerce**: HMAC-SHA256 (base64 signature)
219
+ - **ReplicateAI**: HMAC-SHA256 (Standard Webhooks style)
220
+ - **fal.ai**: ED25519 (`x-fal-webhook-signature`)
221
+ - **Shopify**: HMAC-SHA256 (base64 signature)
214
222
  - **Vercel**: HMAC-SHA256
215
223
  - **Polar**: HMAC-SHA256
216
224
  - **Supabase**: Token-based authentication
@@ -369,6 +377,58 @@ export default {
369
377
  };
370
378
  ```
371
379
 
380
+
381
+ ### Are new platforms available in framework middlewares automatically?
382
+
383
+ Yes. All built-in platforms are available in:
384
+ - `createWebhookMiddleware` (`@hookflo/tern/express`)
385
+ - `createWebhookHandler` (`@hookflo/tern/nextjs`)
386
+ - `createWebhookHandler` (`@hookflo/tern/cloudflare`)
387
+
388
+ You only change `platform` and `secret` per route.
389
+
390
+ ### Platform route examples (Express / Next.js / Cloudflare)
391
+
392
+ ```typescript
393
+ // Express (Razorpay)
394
+ app.post('/webhooks/razorpay', createWebhookMiddleware({
395
+ platform: 'razorpay',
396
+ secret: process.env.RAZORPAY_WEBHOOK_SECRET!,
397
+ }), (req, res) => res.json({ ok: true }));
398
+
399
+ // Next.js (WorkOS)
400
+ export const POST = createWebhookHandler({
401
+ platform: 'workos',
402
+ secret: process.env.WORKOS_WEBHOOK_SECRET!,
403
+ handler: async (payload) => ({ received: true, type: payload.type }),
404
+ });
405
+
406
+ // Cloudflare (Lemon Squeezy)
407
+ const handleLemonSqueezy = createWebhookHandler({
408
+ platform: 'lemonsqueezy',
409
+ secretEnv: 'LEMON_SQUEEZY_WEBHOOK_SECRET',
410
+ handler: async () => ({ received: true }),
411
+ });
412
+ ```
413
+
414
+ ### fal.ai production usage
415
+
416
+ fal.ai uses **ED25519** (`x-fal-webhook-signature`) and signs:
417
+ `{request-id}.{user-id}.{timestamp}.{sha256(body)}`.
418
+
419
+ Use one of these strategies:
420
+ 1. **Public key as `secret`** (recommended for framework adapters).
421
+ 2. **JWKS auto-resolution** via the built-in fal.ai config (`x-fal-webhook-key-id` + fal JWKS URL).
422
+
423
+ ```typescript
424
+ // Next.js with explicit public key PEM as secret
425
+ export const POST = createWebhookHandler({
426
+ platform: 'falai',
427
+ secret: process.env.FAL_PUBLIC_KEY_PEM!,
428
+ handler: async (payload, metadata) => ({ received: true, requestId: metadata.requestId }),
429
+ });
430
+ ```
431
+
372
432
  ## API Reference
373
433
 
374
434
  ### WebhookVerificationService
@@ -545,4 +605,3 @@ export const POST = createWebhookHandler({
545
605
  handler: async (payload) => ({ received: true, event: payload.event ?? payload.type }),
546
606
  });
547
607
  ```
548
-
@@ -12,7 +12,7 @@ function createWebhookHandler(options) {
12
12
  }
13
13
  const result = await index_1.WebhookVerificationService.verifyWithPlatformConfig(request, options.platform, secret, options.toleranceInSeconds, options.normalize);
14
14
  if (!result.isValid) {
15
- return Response.json({ error: result.error, platform: result.platform }, { status: 400 });
15
+ return Response.json({ error: result.error, errorCode: result.errorCode, platform: result.platform, metadata: result.metadata }, { status: 400 });
16
16
  }
17
17
  const data = await options.handler(result.payload, env, result.metadata || {});
18
18
  return Response.json(data);
@@ -9,7 +9,7 @@ function createWebhookMiddleware(options) {
9
9
  const webRequest = await (0, shared_1.toWebRequest)(req);
10
10
  const result = await index_1.WebhookVerificationService.verifyWithPlatformConfig(webRequest, options.platform, options.secret, options.toleranceInSeconds, options.normalize);
11
11
  if (!result.isValid) {
12
- res.status(400).json({ error: result.error, platform: result.platform });
12
+ res.status(400).json({ error: result.error, errorCode: result.errorCode, platform: result.platform, metadata: result.metadata });
13
13
  return;
14
14
  }
15
15
  req.webhook = result;
@@ -7,7 +7,7 @@ function createWebhookHandler(options) {
7
7
  try {
8
8
  const result = await index_1.WebhookVerificationService.verifyWithPlatformConfig(request, options.platform, options.secret, options.toleranceInSeconds, options.normalize);
9
9
  if (!result.isValid) {
10
- return Response.json({ error: result.error, platform: result.platform }, { status: 400 });
10
+ return Response.json({ error: result.error, errorCode: result.errorCode, platform: result.platform, metadata: result.metadata }, { status: 400 });
11
11
  }
12
12
  const data = await options.handler(result.payload, result.metadata || {});
13
13
  return Response.json(data);
package/dist/index.js CHANGED
@@ -79,6 +79,7 @@ class WebhookVerificationService {
79
79
  if (detectedPlatform !== 'unknown' && secrets[detectedPlatform]) {
80
80
  return this.verifyWithPlatformConfig(requestClone, detectedPlatform, secrets[detectedPlatform], toleranceInSeconds, normalize);
81
81
  }
82
+ const failedAttempts = [];
82
83
  for (const [platform, secret] of Object.entries(secrets)) {
83
84
  if (!secret) {
84
85
  continue;
@@ -87,12 +88,25 @@ class WebhookVerificationService {
87
88
  if (result.isValid) {
88
89
  return result;
89
90
  }
91
+ failedAttempts.push({
92
+ platform: platform.toLowerCase(),
93
+ error: result.error,
94
+ errorCode: result.errorCode,
95
+ });
90
96
  }
97
+ const details = failedAttempts
98
+ .map((attempt) => `${attempt.platform}: ${attempt.error || 'verification failed'}`)
99
+ .join('; ');
91
100
  return {
92
101
  isValid: false,
93
- error: 'Unable to verify webhook with provided platform secrets',
94
- errorCode: 'VERIFICATION_ERROR',
102
+ error: details
103
+ ? `Unable to verify webhook with provided platform secrets. Attempts -> ${details}`
104
+ : 'Unable to verify webhook with provided platform secrets',
105
+ errorCode: failedAttempts.find((attempt) => attempt.errorCode)?.errorCode || 'VERIFICATION_ERROR',
95
106
  platform: detectedPlatform,
107
+ metadata: {
108
+ attempts: failedAttempts,
109
+ },
96
110
  };
97
111
  }
98
112
  static detectPlatform(request) {
@@ -103,12 +117,32 @@ class WebhookVerificationService {
103
117
  return 'github';
104
118
  if (headers.has('svix-signature'))
105
119
  return 'clerk';
106
- if (headers.has('webhook-signature'))
120
+ if (headers.has('workos-signature'))
121
+ return 'workos';
122
+ if (headers.has('webhook-signature')) {
123
+ const userAgent = headers.get('user-agent')?.toLowerCase() || '';
124
+ if (userAgent.includes('polar'))
125
+ return 'polar';
126
+ if (userAgent.includes('replicate'))
127
+ return 'replicateai';
107
128
  return 'dodopayments';
129
+ }
108
130
  if (headers.has('x-gitlab-token'))
109
131
  return 'gitlab';
110
132
  if (headers.has('x-polar-signature'))
111
133
  return 'polar';
134
+ if (headers.has('paddle-signature'))
135
+ return 'paddle';
136
+ if (headers.has('x-razorpay-signature'))
137
+ return 'razorpay';
138
+ if (headers.has('x-signature'))
139
+ return 'lemonsqueezy';
140
+ if (headers.has('x-auth0-signature'))
141
+ return 'auth0';
142
+ if (headers.has('x-wc-webhook-signature'))
143
+ return 'woocommerce';
144
+ if (headers.has('x-fal-signature') || headers.has('x-fal-webhook-signature'))
145
+ return 'falai';
112
146
  if (headers.has('x-shopify-hmac-sha256'))
113
147
  return 'shopify';
114
148
  if (headers.has('x-vercel-signature'))
@@ -45,6 +45,7 @@ exports.platformAlgorithmConfigs = {
45
45
  signatureFormat: 'v1={signature}',
46
46
  payloadFormat: '{id}.{timestamp}.{body}',
47
47
  encoding: 'base64',
48
+ secretEncoding: 'base64',
48
49
  idHeader: 'svix-id',
49
50
  },
50
51
  },
@@ -63,6 +64,7 @@ exports.platformAlgorithmConfigs = {
63
64
  signatureFormat: 'v1={signature}',
64
65
  payloadFormat: '{id}.{timestamp}.{body}',
65
66
  encoding: 'base64',
67
+ secretEncoding: 'base64',
66
68
  idHeader: 'webhook-id',
67
69
  },
68
70
  },
@@ -74,10 +76,13 @@ exports.platformAlgorithmConfigs = {
74
76
  algorithm: 'hmac-sha256',
75
77
  headerName: 'x-shopify-hmac-sha256',
76
78
  headerFormat: 'raw',
77
- timestampHeader: 'x-shopify-shop-domain',
78
79
  payloadFormat: 'raw',
80
+ customConfig: {
81
+ encoding: 'base64',
82
+ secretEncoding: 'utf8',
83
+ },
79
84
  },
80
- description: 'Shopify webhooks use HMAC-SHA256',
85
+ description: 'Shopify webhooks use HMAC-SHA256 with base64 encoded signature',
81
86
  },
82
87
  vercel: {
83
88
  platform: 'vercel',
@@ -95,13 +100,20 @@ exports.platformAlgorithmConfigs = {
95
100
  platform: 'polar',
96
101
  signatureConfig: {
97
102
  algorithm: 'hmac-sha256',
98
- headerName: 'x-polar-signature',
103
+ headerName: 'webhook-signature',
99
104
  headerFormat: 'raw',
100
- timestampHeader: 'x-polar-timestamp',
105
+ timestampHeader: 'webhook-timestamp',
101
106
  timestampFormat: 'unix',
102
- payloadFormat: 'raw',
107
+ payloadFormat: 'custom',
108
+ customConfig: {
109
+ signatureFormat: 'v1={signature}',
110
+ payloadFormat: '{id}.{timestamp}.{body}',
111
+ encoding: 'base64',
112
+ secretEncoding: 'base64',
113
+ idHeader: 'webhook-id',
114
+ },
103
115
  },
104
- description: 'Polar webhooks use HMAC-SHA256',
116
+ description: 'Polar webhooks use HMAC-SHA256 with Standard Webhooks format',
105
117
  },
106
118
  supabase: {
107
119
  platform: 'supabase',
@@ -131,6 +143,116 @@ exports.platformAlgorithmConfigs = {
131
143
  },
132
144
  description: 'GitLab webhooks use HMAC-SHA256 with X-Gitlab-Token header',
133
145
  },
146
+ paddle: {
147
+ platform: 'paddle',
148
+ signatureConfig: {
149
+ algorithm: 'hmac-sha256',
150
+ headerName: 'paddle-signature',
151
+ headerFormat: 'comma-separated',
152
+ payloadFormat: 'custom',
153
+ customConfig: {
154
+ timestampKey: 'ts',
155
+ signatureKey: 'h1',
156
+ payloadFormat: '{timestamp}:{body}',
157
+ },
158
+ },
159
+ description: 'Paddle webhooks use HMAC-SHA256 with Paddle-Signature (ts/h1) header format',
160
+ },
161
+ razorpay: {
162
+ platform: 'razorpay',
163
+ signatureConfig: {
164
+ algorithm: 'hmac-sha256',
165
+ headerName: 'x-razorpay-signature',
166
+ headerFormat: 'raw',
167
+ payloadFormat: 'raw',
168
+ },
169
+ description: 'Razorpay webhooks use HMAC-SHA256 with X-Razorpay-Signature header',
170
+ },
171
+ lemonsqueezy: {
172
+ platform: 'lemonsqueezy',
173
+ signatureConfig: {
174
+ algorithm: 'hmac-sha256',
175
+ headerName: 'x-signature',
176
+ headerFormat: 'raw',
177
+ payloadFormat: 'raw',
178
+ },
179
+ description: 'Lemon Squeezy webhooks use HMAC-SHA256 with X-Signature header',
180
+ },
181
+ auth0: {
182
+ platform: 'auth0',
183
+ signatureConfig: {
184
+ algorithm: 'hmac-sha256',
185
+ headerName: 'x-auth0-signature',
186
+ headerFormat: 'raw',
187
+ payloadFormat: 'raw',
188
+ },
189
+ description: 'Auth0 webhooks use HMAC-SHA256 with X-Auth0-Signature header',
190
+ },
191
+ workos: {
192
+ platform: 'workos',
193
+ signatureConfig: {
194
+ algorithm: 'hmac-sha256',
195
+ headerName: 'workos-signature',
196
+ headerFormat: 'comma-separated',
197
+ payloadFormat: 'custom',
198
+ customConfig: {
199
+ timestampKey: 't',
200
+ signatureKey: 'v1',
201
+ payloadFormat: '{timestamp}.{body}',
202
+ },
203
+ },
204
+ description: 'WorkOS webhooks use HMAC-SHA256 with WorkOS-Signature (t/v1) format',
205
+ },
206
+ woocommerce: {
207
+ platform: 'woocommerce',
208
+ signatureConfig: {
209
+ algorithm: 'hmac-sha256',
210
+ headerName: 'x-wc-webhook-signature',
211
+ headerFormat: 'raw',
212
+ payloadFormat: 'raw',
213
+ customConfig: {
214
+ encoding: 'base64',
215
+ secretEncoding: 'utf8',
216
+ },
217
+ },
218
+ description: 'WooCommerce webhooks use HMAC-SHA256 with base64 encoded signature',
219
+ },
220
+ replicateai: {
221
+ platform: 'replicateai',
222
+ signatureConfig: {
223
+ algorithm: 'hmac-sha256',
224
+ headerName: 'webhook-signature',
225
+ headerFormat: 'raw',
226
+ timestampHeader: 'webhook-timestamp',
227
+ timestampFormat: 'unix',
228
+ payloadFormat: 'custom',
229
+ customConfig: {
230
+ signatureFormat: 'v1={signature}',
231
+ payloadFormat: '{id}.{timestamp}.{body}',
232
+ encoding: 'base64',
233
+ secretEncoding: 'base64',
234
+ idHeader: 'webhook-id',
235
+ },
236
+ },
237
+ description: 'Replicate webhooks use HMAC-SHA256 with Standard Webhooks (svix-style) format',
238
+ },
239
+ falai: {
240
+ platform: 'falai',
241
+ signatureConfig: {
242
+ algorithm: 'ed25519',
243
+ headerName: 'x-fal-webhook-signature',
244
+ headerFormat: 'raw',
245
+ payloadFormat: 'custom',
246
+ customConfig: {
247
+ requestIdHeader: 'x-fal-request-id',
248
+ userIdHeader: 'x-fal-user-id',
249
+ timestampHeader: 'x-fal-webhook-timestamp',
250
+ kidHeader: 'x-fal-webhook-key-id',
251
+ jwksUrl: 'https://rest.alpha.fal.ai/.well-known/jwks.json',
252
+ },
253
+ },
254
+ description: 'fal.ai webhooks use ED25519 with a signed request/user/timestamp/body-hash payload',
255
+ },
134
256
  custom: {
135
257
  platform: 'custom',
136
258
  signatureConfig: {
@@ -178,8 +300,9 @@ function validateSignatureConfig(config) {
178
300
  case 'hmac-sha512':
179
301
  return true;
180
302
  case 'rsa-sha256':
181
- case 'ed25519':
182
303
  return !!config.customConfig?.publicKey;
304
+ case 'ed25519':
305
+ return !!config.customConfig?.publicKey || !!config.customConfig?.jwksUrl;
183
306
  case 'custom':
184
307
  return !!config.customConfig;
185
308
  default:
package/dist/test.js CHANGED
@@ -35,6 +35,40 @@ function createClerkSignature(body, secret, id, timestamp) {
35
35
  hmac.update(signedContent);
36
36
  return `v1,${hmac.digest('base64')}`;
37
37
  }
38
+ function createStandardWebhooksSignature(body, secret, id, timestamp) {
39
+ const signedContent = `${id}.${timestamp}.${body}`;
40
+ const base64Secret = secret.includes('_') ? secret.split('_')[1] : secret;
41
+ const secretBytes = new Uint8Array(Buffer.from(base64Secret, 'base64'));
42
+ const hmac = (0, crypto_1.createHmac)('sha256', secretBytes);
43
+ hmac.update(signedContent);
44
+ return `v1,${hmac.digest('base64')}`;
45
+ }
46
+ function createPaddleSignature(body, secret, timestamp) {
47
+ const signedPayload = `${timestamp}:${body}`;
48
+ const hmac = (0, crypto_1.createHmac)('sha256', secret);
49
+ hmac.update(signedPayload);
50
+ return `ts=${timestamp};h1=${hmac.digest('hex')}`;
51
+ }
52
+ function createShopifySignature(body, secret) {
53
+ const hmac = (0, crypto_1.createHmac)('sha256', secret);
54
+ hmac.update(body);
55
+ return hmac.digest('base64');
56
+ }
57
+ function createWooCommerceSignature(body, secret) {
58
+ const hmac = (0, crypto_1.createHmac)('sha256', secret);
59
+ hmac.update(body);
60
+ return hmac.digest('base64');
61
+ }
62
+ function createWorkOSSignature(body, secret, timestamp) {
63
+ const signedPayload = `${timestamp}.${body}`;
64
+ const hmac = (0, crypto_1.createHmac)('sha256', secret);
65
+ hmac.update(signedPayload);
66
+ return `t=${timestamp},v1=${hmac.digest('hex')}`;
67
+ }
68
+ function createFalPayloadToSign(body, requestId, userId, timestamp) {
69
+ const bodyHash = (0, crypto_1.createHash)('sha256').update(body).digest('hex');
70
+ return `${requestId}.${userId}.${timestamp}.${bodyHash}`;
71
+ }
38
72
  async function runTests() {
39
73
  console.log('🧪 Running Webhook Verification Tests...\n');
40
74
  // Test 1: Stripe Webhook
@@ -248,6 +282,24 @@ async function runTests() {
248
282
  catch (error) {
249
283
  console.log(' ❌ verifyAny test failed:', error);
250
284
  }
285
+ // Test 10.5: verifyAny error diagnostics
286
+ console.log('\n10.5. Testing verifyAny error diagnostics...');
287
+ try {
288
+ const unknownRequest = createMockRequest({
289
+ 'content-type': 'application/json',
290
+ });
291
+ const invalidVerifyAny = await index_1.WebhookVerificationService.verifyAny(unknownRequest, {
292
+ stripe: testSecret,
293
+ shopify: testSecret,
294
+ });
295
+ const hasDetailedErrors = Boolean(invalidVerifyAny.error
296
+ && invalidVerifyAny.error.includes('Attempts ->')
297
+ && invalidVerifyAny.metadata?.attempts?.length === 2);
298
+ console.log(' ✅ verifyAny diagnostics:', hasDetailedErrors ? 'PASSED' : 'FAILED');
299
+ }
300
+ catch (error) {
301
+ console.log(' ❌ verifyAny diagnostics test failed:', error);
302
+ }
251
303
  // Test 11: Normalization for Stripe
252
304
  console.log('\n11. Testing payload normalization...');
253
305
  try {
@@ -289,6 +341,195 @@ async function runTests() {
289
341
  catch (error) {
290
342
  console.log(' ❌ Category registry test failed:', error);
291
343
  }
344
+ // Test 13: Razorpay
345
+ console.log('\n13. Testing Razorpay webhook...');
346
+ try {
347
+ const hmac = (0, crypto_1.createHmac)('sha256', testSecret);
348
+ hmac.update(testBody);
349
+ const signature = hmac.digest('hex');
350
+ const request = createMockRequest({
351
+ 'x-razorpay-signature': signature,
352
+ 'content-type': 'application/json',
353
+ });
354
+ const result = await index_1.WebhookVerificationService.verifyWithPlatformConfig(request, 'razorpay', testSecret);
355
+ console.log(' ✅ Razorpay:', result.isValid ? 'PASSED' : 'FAILED');
356
+ }
357
+ catch (error) {
358
+ console.log(' ❌ Razorpay test failed:', error);
359
+ }
360
+ // Test 14: Lemon Squeezy
361
+ console.log('\n14. Testing Lemon Squeezy webhook...');
362
+ try {
363
+ const hmac = (0, crypto_1.createHmac)('sha256', testSecret);
364
+ hmac.update(testBody);
365
+ const signature = hmac.digest('hex');
366
+ const request = createMockRequest({
367
+ 'x-signature': signature,
368
+ 'content-type': 'application/json',
369
+ });
370
+ const result = await index_1.WebhookVerificationService.verifyWithPlatformConfig(request, 'lemonsqueezy', testSecret);
371
+ console.log(' ✅ Lemon Squeezy:', result.isValid ? 'PASSED' : 'FAILED');
372
+ }
373
+ catch (error) {
374
+ console.log(' ❌ Lemon Squeezy test failed:', error);
375
+ }
376
+ // Test 15: Paddle
377
+ console.log('\n15. Testing Paddle webhook...');
378
+ try {
379
+ const timestamp = Math.floor(Date.now() / 1000);
380
+ const signature = createPaddleSignature(testBody, testSecret, timestamp);
381
+ const request = createMockRequest({
382
+ 'paddle-signature': signature,
383
+ 'content-type': 'application/json',
384
+ });
385
+ const result = await index_1.WebhookVerificationService.verifyWithPlatformConfig(request, 'paddle', testSecret);
386
+ console.log(' ✅ Paddle:', result.isValid ? 'PASSED' : 'FAILED');
387
+ }
388
+ catch (error) {
389
+ console.log(' ❌ Paddle test failed:', error);
390
+ }
391
+ // Test 16: Auth0
392
+ console.log('\n16. Testing Auth0 webhook...');
393
+ try {
394
+ const hmac = (0, crypto_1.createHmac)('sha256', testSecret);
395
+ hmac.update(testBody);
396
+ const signature = hmac.digest('hex');
397
+ const request = createMockRequest({
398
+ 'x-auth0-signature': signature,
399
+ 'content-type': 'application/json',
400
+ });
401
+ const result = await index_1.WebhookVerificationService.verifyWithPlatformConfig(request, 'auth0', testSecret);
402
+ console.log(' ✅ Auth0:', result.isValid ? 'PASSED' : 'FAILED');
403
+ }
404
+ catch (error) {
405
+ console.log(' ❌ Auth0 test failed:', error);
406
+ }
407
+ // Test 17: WorkOS
408
+ console.log('\n17. Testing WorkOS webhook...');
409
+ try {
410
+ const timestamp = Math.floor(Date.now() / 1000);
411
+ const signature = createWorkOSSignature(testBody, testSecret, timestamp);
412
+ const request = createMockRequest({
413
+ 'workos-signature': signature,
414
+ 'content-type': 'application/json',
415
+ });
416
+ const result = await index_1.WebhookVerificationService.verifyWithPlatformConfig(request, 'workos', testSecret);
417
+ console.log(' ✅ WorkOS:', result.isValid ? 'PASSED' : 'FAILED');
418
+ }
419
+ catch (error) {
420
+ console.log(' ❌ WorkOS test failed:', error);
421
+ }
422
+ // Test 17.5: Shopify
423
+ console.log('\n17.5. Testing Shopify webhook...');
424
+ try {
425
+ const signature = createShopifySignature(testBody, testSecret);
426
+ const request = createMockRequest({
427
+ 'x-shopify-hmac-sha256': signature,
428
+ 'content-type': 'application/json',
429
+ });
430
+ const result = await index_1.WebhookVerificationService.verifyWithPlatformConfig(request, 'shopify', testSecret);
431
+ console.log(' ✅ Shopify:', result.isValid ? 'PASSED' : 'FAILED');
432
+ if (!result.isValid) {
433
+ console.log(' ❌ Error:', result.error);
434
+ }
435
+ }
436
+ catch (error) {
437
+ console.log(' ❌ Shopify test failed:', error);
438
+ }
439
+ // Test 18: WooCommerce
440
+ console.log('\n18. Testing WooCommerce webhook...');
441
+ try {
442
+ const signature = createWooCommerceSignature(testBody, testSecret);
443
+ const request = createMockRequest({
444
+ 'x-wc-webhook-signature': signature,
445
+ 'content-type': 'application/json',
446
+ });
447
+ const result = await index_1.WebhookVerificationService.verifyWithPlatformConfig(request, 'woocommerce', testSecret);
448
+ console.log(' ✅ WooCommerce:', result.isValid ? 'PASSED' : 'FAILED');
449
+ }
450
+ catch (error) {
451
+ console.log(' ❌ WooCommerce test failed:', error);
452
+ }
453
+ // Test 18.5: Polar (Standard Webhooks)
454
+ console.log('\n18.5. Testing Polar webhook...');
455
+ try {
456
+ const secret = `whsec_${Buffer.from(testSecret).toString('base64')}`;
457
+ const webhookId = 'polar-webhook-id-1';
458
+ const timestamp = Math.floor(Date.now() / 1000);
459
+ const signature = createStandardWebhooksSignature(testBody, secret, webhookId, timestamp);
460
+ const request = createMockRequest({
461
+ 'webhook-signature': signature,
462
+ 'webhook-id': webhookId,
463
+ 'webhook-timestamp': timestamp.toString(),
464
+ 'user-agent': 'Polar.sh Webhooks',
465
+ 'content-type': 'application/json',
466
+ });
467
+ const result = await index_1.WebhookVerificationService.verifyWithPlatformConfig(request, 'polar', secret);
468
+ const detectedPlatform = index_1.WebhookVerificationService.detectPlatform(request);
469
+ console.log(' ✅ Polar verification:', result.isValid ? 'PASSED' : 'FAILED');
470
+ console.log(' ✅ Polar auto-detect:', detectedPlatform === 'polar' ? 'PASSED' : 'FAILED');
471
+ }
472
+ catch (error) {
473
+ console.log(' ❌ Polar test failed:', error);
474
+ }
475
+ // Test 19: Replicate
476
+ console.log('\n19. Testing Replicate webhook...');
477
+ try {
478
+ const secret = `whsec_${Buffer.from(testSecret).toString('base64')}`;
479
+ const webhookId = 'replicate-webhook-id-1';
480
+ const timestamp = Math.floor(Date.now() / 1000);
481
+ const signature = createStandardWebhooksSignature(testBody, secret, webhookId, timestamp);
482
+ const request = createMockRequest({
483
+ 'webhook-signature': signature,
484
+ 'webhook-id': webhookId,
485
+ 'webhook-timestamp': timestamp.toString(),
486
+ 'user-agent': 'Replicate-Webhooks/1.0',
487
+ 'content-type': 'application/json',
488
+ });
489
+ const result = await index_1.WebhookVerificationService.verifyWithPlatformConfig(request, 'replicateai', secret);
490
+ console.log(' ✅ Replicate:', result.isValid ? 'PASSED' : 'FAILED');
491
+ }
492
+ catch (error) {
493
+ console.log(' ❌ Replicate test failed:', error);
494
+ }
495
+ // Test 20: fal.ai
496
+ console.log('\n20. Testing fal.ai webhook...');
497
+ try {
498
+ const { privateKey, publicKey } = (0, crypto_1.generateKeyPairSync)('ed25519');
499
+ const requestId = 'fal-request-id';
500
+ const userId = 'fal-user-id';
501
+ const timestamp = Math.floor(Date.now() / 1000).toString();
502
+ const payloadToSign = createFalPayloadToSign(testBody, requestId, userId, timestamp);
503
+ const payloadBytes = new Uint8Array(Buffer.from(payloadToSign));
504
+ const signature = (0, crypto_1.sign)(null, payloadBytes, privateKey).toString('base64');
505
+ const request = createMockRequest({
506
+ 'x-fal-webhook-signature': signature,
507
+ 'x-fal-request-id': requestId,
508
+ 'x-fal-user-id': userId,
509
+ 'x-fal-webhook-timestamp': timestamp,
510
+ 'content-type': 'application/json',
511
+ });
512
+ const result = await index_1.WebhookVerificationService.verify(request, {
513
+ platform: 'falai',
514
+ secret: '',
515
+ signatureConfig: {
516
+ algorithm: 'ed25519',
517
+ headerName: 'x-fal-webhook-signature',
518
+ headerFormat: 'raw',
519
+ payloadFormat: 'custom',
520
+ customConfig: {
521
+ publicKey: publicKey.export({ type: 'spki', format: 'pem' }).toString(),
522
+ requestIdHeader: 'x-fal-request-id',
523
+ userIdHeader: 'x-fal-user-id',
524
+ timestampHeader: 'x-fal-webhook-timestamp',
525
+ },
526
+ },
527
+ });
528
+ console.log(' ✅ fal.ai:', result.isValid ? 'PASSED' : 'FAILED');
529
+ }
530
+ catch (error) {
531
+ console.log(' ❌ fal.ai test failed:', error);
532
+ }
292
533
  console.log('\n🎉 All tests completed!');
293
534
  }
294
535
  // Run tests if this file is executed directly
package/dist/types.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type WebhookPlatform = 'custom' | 'clerk' | 'supabase' | 'github' | 'stripe' | 'shopify' | 'vercel' | 'polar' | 'dodopayments' | 'gitlab' | 'unknown';
1
+ export type WebhookPlatform = 'custom' | 'clerk' | 'supabase' | 'github' | 'stripe' | 'shopify' | 'vercel' | 'polar' | 'dodopayments' | 'gitlab' | 'paddle' | 'razorpay' | 'lemonsqueezy' | 'auth0' | 'workos' | 'woocommerce' | 'replicateai' | 'falai' | 'unknown';
2
2
  export declare enum WebhookPlatformKeys {
3
3
  GitHub = "github",
4
4
  Stripe = "stripe",
@@ -9,6 +9,14 @@ export declare enum WebhookPlatformKeys {
9
9
  Polar = "polar",
10
10
  Supabase = "supabase",
11
11
  GitLab = "gitlab",
12
+ Paddle = "paddle",
13
+ Razorpay = "razorpay",
14
+ LemonSqueezy = "lemonsqueezy",
15
+ Auth0 = "auth0",
16
+ WorkOS = "workos",
17
+ WooCommerce = "woocommerce",
18
+ ReplicateAI = "replicateai",
19
+ FalAI = "falai",
12
20
  Custom = "custom",
13
21
  Unknown = "unknown"
14
22
  }
package/dist/types.js CHANGED
@@ -12,6 +12,14 @@ var WebhookPlatformKeys;
12
12
  WebhookPlatformKeys["Polar"] = "polar";
13
13
  WebhookPlatformKeys["Supabase"] = "supabase";
14
14
  WebhookPlatformKeys["GitLab"] = "gitlab";
15
+ WebhookPlatformKeys["Paddle"] = "paddle";
16
+ WebhookPlatformKeys["Razorpay"] = "razorpay";
17
+ WebhookPlatformKeys["LemonSqueezy"] = "lemonsqueezy";
18
+ WebhookPlatformKeys["Auth0"] = "auth0";
19
+ WebhookPlatformKeys["WorkOS"] = "workos";
20
+ WebhookPlatformKeys["WooCommerce"] = "woocommerce";
21
+ WebhookPlatformKeys["ReplicateAI"] = "replicateai";
22
+ WebhookPlatformKeys["FalAI"] = "falai";
15
23
  WebhookPlatformKeys["Custom"] = "custom";
16
24
  WebhookPlatformKeys["Unknown"] = "unknown";
17
25
  })(WebhookPlatformKeys || (exports.WebhookPlatformKeys = WebhookPlatformKeys = {}));
package/dist/utils.js CHANGED
@@ -112,9 +112,34 @@ function detectPlatformFromHeaders(headers) {
112
112
  return 'clerk';
113
113
  }
114
114
  // Dodo Payments
115
+ if (headerMap.has('workos-signature')) {
116
+ return 'workos';
117
+ }
115
118
  if (headerMap.has('webhook-signature')) {
119
+ const userAgent = headerMap.get('user-agent') || '';
120
+ if (userAgent.includes('replicate')) {
121
+ return 'replicateai';
122
+ }
116
123
  return 'dodopayments';
117
124
  }
125
+ if (headerMap.has('paddle-signature')) {
126
+ return 'paddle';
127
+ }
128
+ if (headerMap.has('x-razorpay-signature')) {
129
+ return 'razorpay';
130
+ }
131
+ if (headerMap.has('x-signature')) {
132
+ return 'lemonsqueezy';
133
+ }
134
+ if (headerMap.has('x-auth0-signature')) {
135
+ return 'auth0';
136
+ }
137
+ if (headerMap.has('x-wc-webhook-signature')) {
138
+ return 'woocommerce';
139
+ }
140
+ if (headerMap.has('x-fal-signature') || headerMap.has('x-fal-webhook-signature')) {
141
+ return 'falai';
142
+ }
118
143
  // Shopify
119
144
  if (headerMap.has('x-shopify-hmac-sha256')) {
120
145
  return 'shopify';
@@ -127,6 +152,12 @@ function detectPlatformFromHeaders(headers) {
127
152
  if (headerMap.has('x-polar-signature')) {
128
153
  return 'polar';
129
154
  }
155
+ if (headerMap.has('webhook-signature')) {
156
+ const userAgent = (headerMap.get('user-agent') || '').toLowerCase();
157
+ if (userAgent.includes('polar')) {
158
+ return 'polar';
159
+ }
160
+ }
130
161
  // Supabase
131
162
  if (headerMap.has('x-webhook-token')) {
132
163
  return 'supabase';
@@ -5,6 +5,7 @@ export declare abstract class AlgorithmBasedVerifier extends WebhookVerifier {
5
5
  protected platform: WebhookPlatform;
6
6
  constructor(secret: string, config: SignatureConfig, platform: WebhookPlatform, toleranceInSeconds?: number);
7
7
  abstract verify(request: Request): Promise<WebhookVerificationResult>;
8
+ protected parseDelimitedHeader(headerValue: string): Record<string, string>;
8
9
  protected extractSignature(request: Request): string | null;
9
10
  protected extractTimestamp(request: Request): number | null;
10
11
  protected extractTimestampFromSignature(request: Request): number | null;
@@ -18,6 +19,11 @@ export declare abstract class AlgorithmBasedVerifier extends WebhookVerifier {
18
19
  export declare class GenericHMACVerifier extends AlgorithmBasedVerifier {
19
20
  verify(request: Request): Promise<WebhookVerificationResult>;
20
21
  }
22
+ export declare class Ed25519Verifier extends AlgorithmBasedVerifier {
23
+ private resolvePublicKey;
24
+ private buildFalPayload;
25
+ verify(request: Request): Promise<WebhookVerificationResult>;
26
+ }
21
27
  export declare class HMACSHA256Verifier extends GenericHMACVerifier {
22
28
  constructor(secret: string, config: SignatureConfig, platform?: WebhookPlatform, toleranceInSeconds?: number);
23
29
  }
@@ -1,37 +1,49 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.HMACSHA512Verifier = exports.HMACSHA1Verifier = exports.HMACSHA256Verifier = exports.GenericHMACVerifier = exports.AlgorithmBasedVerifier = void 0;
3
+ exports.HMACSHA512Verifier = exports.HMACSHA1Verifier = exports.HMACSHA256Verifier = exports.Ed25519Verifier = exports.GenericHMACVerifier = exports.AlgorithmBasedVerifier = void 0;
4
4
  exports.createAlgorithmVerifier = createAlgorithmVerifier;
5
5
  const crypto_1 = require("crypto");
6
6
  const base_1 = require("./base");
7
+ const ed25519KeyCache = new Map();
7
8
  class AlgorithmBasedVerifier extends base_1.WebhookVerifier {
8
9
  constructor(secret, config, platform, toleranceInSeconds = 300) {
9
10
  super(secret, toleranceInSeconds);
10
11
  this.config = config;
11
12
  this.platform = platform;
12
13
  }
14
+ parseDelimitedHeader(headerValue) {
15
+ const parts = headerValue.split(/[;,]/);
16
+ const values = {};
17
+ for (const part of parts) {
18
+ const trimmed = part.trim();
19
+ if (!trimmed)
20
+ continue;
21
+ const equalIndex = trimmed.indexOf('=');
22
+ if (equalIndex === -1)
23
+ continue;
24
+ const key = trimmed.slice(0, equalIndex).trim();
25
+ const value = trimmed.slice(equalIndex + 1).trim();
26
+ if (key && value) {
27
+ values[key] = value;
28
+ }
29
+ }
30
+ return values;
31
+ }
13
32
  extractSignature(request) {
14
33
  const headerValue = request.headers.get(this.config.headerName);
15
34
  if (!headerValue)
16
35
  return null;
17
36
  switch (this.config.headerFormat) {
18
37
  case 'prefixed':
19
- // For GitHub, return the full signature including prefix for comparison
20
38
  return headerValue;
21
- case 'comma-separated':
22
- // Handle comma-separated format like Stripe: "t=1234567890,v1=abc123"
23
- const parts = headerValue.split(',');
24
- const sigMap = {};
25
- for (const part of parts) {
26
- const [key, value] = part.split('=');
27
- if (key && value) {
28
- sigMap[key] = value;
29
- }
30
- }
31
- return sigMap.v1 || sigMap.signature || null;
39
+ case 'comma-separated': {
40
+ const sigMap = this.parseDelimitedHeader(headerValue);
41
+ const signatureKey = this.config.customConfig?.signatureKey || 'v1';
42
+ return sigMap[signatureKey] || sigMap.signature || sigMap.v1 || null;
43
+ }
32
44
  case 'raw':
33
45
  default:
34
- if (this.platform === 'clerk' || this.platform === 'dodopayments') {
46
+ if (this.config.customConfig?.signatureFormat?.includes('v1=')) {
35
47
  const signatures = headerValue.split(' ');
36
48
  for (const sig of signatures) {
37
49
  const [version, signature] = sig.split(',');
@@ -56,37 +68,29 @@ class AlgorithmBasedVerifier extends base_1.WebhookVerifier {
56
68
  case 'iso':
57
69
  return Math.floor(new Date(timestampHeader).getTime() / 1000);
58
70
  case 'custom':
59
- // Custom timestamp parsing logic can be added here
60
71
  return parseInt(timestampHeader, 10);
61
72
  default:
62
73
  return parseInt(timestampHeader, 10);
63
74
  }
64
75
  }
65
76
  extractTimestampFromSignature(request) {
66
- // For platforms like Stripe where timestamp is embedded in signature
67
- if (this.config.headerFormat === 'comma-separated') {
68
- const headerValue = request.headers.get(this.config.headerName);
69
- if (!headerValue)
70
- return null;
71
- const parts = headerValue.split(',');
72
- const sigMap = {};
73
- for (const part of parts) {
74
- const [key, value] = part.split('=');
75
- if (key && value) {
76
- sigMap[key] = value;
77
- }
78
- }
79
- return sigMap.t ? parseInt(sigMap.t, 10) : null;
77
+ if (this.config.headerFormat !== 'comma-separated') {
78
+ return null;
80
79
  }
81
- return null;
80
+ const headerValue = request.headers.get(this.config.headerName);
81
+ if (!headerValue)
82
+ return null;
83
+ const sigMap = this.parseDelimitedHeader(headerValue);
84
+ const timestampKey = this.config.customConfig?.timestampKey || 't';
85
+ return sigMap[timestampKey] ? parseInt(sigMap[timestampKey], 10) : null;
82
86
  }
83
87
  formatPayload(rawBody, request) {
84
88
  switch (this.config.payloadFormat) {
85
- case 'timestamped':
86
- // For Stripe, timestamp is embedded in signature
89
+ case 'timestamped': {
87
90
  const timestamp = this.extractTimestampFromSignature(request)
88
91
  || this.extractTimestamp(request);
89
92
  return timestamp ? `${timestamp}.${rawBody}` : rawBody;
93
+ }
90
94
  case 'custom':
91
95
  return this.formatCustomPayload(rawBody, request);
92
96
  case 'raw':
@@ -99,7 +103,6 @@ class AlgorithmBasedVerifier extends base_1.WebhookVerifier {
99
103
  return rawBody;
100
104
  }
101
105
  const customFormat = this.config.customConfig.payloadFormat;
102
- // Handle Clerk-style format: {id}.{timestamp}.{body}
103
106
  if (customFormat.includes('{id}') && customFormat.includes('{timestamp}')) {
104
107
  const id = request.headers.get(this.config.customConfig.idHeader || 'x-webhook-id');
105
108
  const timestamp = request.headers.get(this.config.timestampHeader || 'x-webhook-timestamp');
@@ -108,10 +111,10 @@ class AlgorithmBasedVerifier extends base_1.WebhookVerifier {
108
111
  .replace('{timestamp}', timestamp || '')
109
112
  .replace('{body}', rawBody);
110
113
  }
111
- // Handle Stripe-style format: {timestamp}.{body}
112
114
  if (customFormat.includes('{timestamp}')
113
115
  && customFormat.includes('{body}')) {
114
- const timestamp = this.extractTimestamp(request);
116
+ const timestamp = this.extractTimestampFromSignature(request)
117
+ || this.extractTimestamp(request);
115
118
  return customFormat
116
119
  .replace('{timestamp}', timestamp?.toString() || '')
117
120
  .replace('{body}', rawBody);
@@ -131,9 +134,15 @@ class AlgorithmBasedVerifier extends base_1.WebhookVerifier {
131
134
  return this.safeCompare(signature, expectedSignature);
132
135
  }
133
136
  verifyHMACWithBase64(payload, signature, algorithm = 'sha256') {
134
- // For platforms like Clerk that use base64 encoding
135
- const secretBytes = new Uint8Array(Buffer.from(this.secret.split('_')[1], 'base64'));
136
- const hmac = (0, crypto_1.createHmac)(algorithm, secretBytes);
137
+ const secretEncoding = this.config.customConfig?.secretEncoding || 'base64';
138
+ let secretMaterial = this.secret;
139
+ if (secretEncoding === 'base64') {
140
+ const base64Secret = this.secret.includes('_')
141
+ ? this.secret.split('_').slice(1).join('_')
142
+ : this.secret;
143
+ secretMaterial = new Uint8Array(Buffer.from(base64Secret, 'base64'));
144
+ }
145
+ const hmac = (0, crypto_1.createHmac)(algorithm, secretMaterial);
137
146
  hmac.update(payload);
138
147
  const expectedSignature = hmac.digest('base64');
139
148
  return this.safeCompare(signature, expectedSignature);
@@ -142,41 +151,38 @@ class AlgorithmBasedVerifier extends base_1.WebhookVerifier {
142
151
  const metadata = {
143
152
  algorithm: this.config.algorithm,
144
153
  };
145
- // Add timestamp if available
146
- const timestamp = this.extractTimestamp(request);
154
+ const timestamp = this.extractTimestamp(request) || this.extractTimestampFromSignature(request);
147
155
  if (timestamp) {
148
156
  metadata.timestamp = timestamp.toString();
149
157
  }
150
- // Add platform-specific metadata
151
158
  switch (this.platform) {
152
159
  case 'github':
153
160
  metadata.event = request.headers.get('x-github-event');
154
161
  metadata.delivery = request.headers.get('x-github-delivery');
155
162
  break;
156
- case 'stripe':
157
- // Extract Stripe-specific metadata from signature
163
+ case 'stripe': {
158
164
  const headerValue = request.headers.get(this.config.headerName);
159
165
  if (headerValue && this.config.headerFormat === 'comma-separated') {
160
- const parts = headerValue.split(',');
161
- const sigMap = {};
162
- for (const part of parts) {
163
- const [key, value] = part.split('=');
164
- if (key && value) {
165
- sigMap[key] = value;
166
- }
167
- }
166
+ const sigMap = this.parseDelimitedHeader(headerValue);
168
167
  metadata.id = sigMap.id;
169
168
  }
170
169
  break;
170
+ }
171
171
  case 'clerk':
172
- metadata.id = request.headers.get('svix-id');
172
+ case 'dodopayments':
173
+ case 'replicateai':
174
+ metadata.id = request.headers.get(this.config.customConfig?.idHeader || 'webhook-id');
175
+ break;
176
+ case 'workos':
177
+ metadata.id = request.headers.get(this.config.customConfig?.idHeader || 'webhook-id');
178
+ break;
179
+ default:
173
180
  break;
174
181
  }
175
182
  return metadata;
176
183
  }
177
184
  }
178
185
  exports.AlgorithmBasedVerifier = AlgorithmBasedVerifier;
179
- // Generic HMAC Verifier that handles all HMAC-based algorithms
180
186
  class GenericHMACVerifier extends AlgorithmBasedVerifier {
181
187
  async verify(request) {
182
188
  try {
@@ -190,17 +196,13 @@ class GenericHMACVerifier extends AlgorithmBasedVerifier {
190
196
  };
191
197
  }
192
198
  const rawBody = await request.text();
193
- // Extract timestamp based on platform configuration
194
199
  let timestamp = null;
195
200
  if (this.config.headerFormat === 'comma-separated') {
196
- // For platforms like Stripe where timestamp is embedded in signature
197
201
  timestamp = this.extractTimestampFromSignature(request);
198
202
  }
199
203
  else {
200
- // For platforms with separate timestamp header
201
204
  timestamp = this.extractTimestamp(request);
202
205
  }
203
- // Validate timestamp if required
204
206
  if (timestamp && !this.isTimestampValid(timestamp)) {
205
207
  return {
206
208
  isValid: false,
@@ -209,21 +211,16 @@ class GenericHMACVerifier extends AlgorithmBasedVerifier {
209
211
  platform: this.platform,
210
212
  };
211
213
  }
212
- // Format payload according to platform requirements
213
214
  const payload = this.formatPayload(rawBody, request);
214
- // Verify signature based on platform configuration
215
215
  let isValid = false;
216
216
  const algorithm = this.config.algorithm.replace('hmac-', '');
217
217
  if (this.config.customConfig?.encoding === 'base64') {
218
- // For platforms like Clerk that use base64 encoding
219
218
  isValid = this.verifyHMACWithBase64(payload, signature, algorithm);
220
219
  }
221
220
  else if (this.config.headerFormat === 'prefixed') {
222
- // For platforms like GitHub that use prefixed signatures
223
221
  isValid = this.verifyHMACWithPrefix(payload, signature, algorithm);
224
222
  }
225
223
  else {
226
- // Standard HMAC verification
227
224
  isValid = this.verifyHMAC(payload, signature, algorithm);
228
225
  }
229
226
  if (!isValid) {
@@ -234,7 +231,6 @@ class GenericHMACVerifier extends AlgorithmBasedVerifier {
234
231
  platform: this.platform,
235
232
  };
236
233
  }
237
- // Parse payload
238
234
  let parsedPayload;
239
235
  try {
240
236
  parsedPayload = JSON.parse(rawBody);
@@ -242,7 +238,6 @@ class GenericHMACVerifier extends AlgorithmBasedVerifier {
242
238
  catch (e) {
243
239
  parsedPayload = rawBody;
244
240
  }
245
- // Extract platform-specific metadata
246
241
  const metadata = this.extractMetadata(request);
247
242
  return {
248
243
  isValid: true,
@@ -262,7 +257,115 @@ class GenericHMACVerifier extends AlgorithmBasedVerifier {
262
257
  }
263
258
  }
264
259
  exports.GenericHMACVerifier = GenericHMACVerifier;
265
- // Legacy verifiers for backward compatibility
260
+ class Ed25519Verifier extends AlgorithmBasedVerifier {
261
+ async resolvePublicKey(request) {
262
+ const configPublicKey = this.config.customConfig?.publicKey;
263
+ if (configPublicKey) {
264
+ return configPublicKey;
265
+ }
266
+ if (this.secret && this.secret.trim().length > 0) {
267
+ return this.secret;
268
+ }
269
+ const jwksUrl = this.config.customConfig?.jwksUrl;
270
+ const kidHeader = this.config.customConfig?.kidHeader;
271
+ const kid = kidHeader ? request.headers.get(kidHeader) : null;
272
+ if (!jwksUrl || !kid) {
273
+ return null;
274
+ }
275
+ const cacheKey = `${jwksUrl}:${kid}`;
276
+ if (ed25519KeyCache.has(cacheKey)) {
277
+ return ed25519KeyCache.get(cacheKey);
278
+ }
279
+ const response = await fetch(jwksUrl);
280
+ if (!response.ok) {
281
+ return null;
282
+ }
283
+ const body = await response.json();
284
+ const key = body.keys?.find((entry) => entry.kid === kid);
285
+ if (!key) {
286
+ return null;
287
+ }
288
+ const keyObject = (0, crypto_1.createPublicKey)({ key, format: 'jwk' });
289
+ const pem = keyObject.export({ type: 'spki', format: 'pem' }).toString();
290
+ ed25519KeyCache.set(cacheKey, pem);
291
+ return pem;
292
+ }
293
+ buildFalPayload(rawBody, request) {
294
+ const requestIdHeader = this.config.customConfig?.requestIdHeader || 'x-fal-request-id';
295
+ const userIdHeader = this.config.customConfig?.userIdHeader || 'x-fal-user-id';
296
+ const timestampHeader = this.config.customConfig?.timestampHeader || 'x-fal-webhook-timestamp';
297
+ const requestId = request.headers.get(requestIdHeader) || '';
298
+ const userId = request.headers.get(userIdHeader) || '';
299
+ const timestamp = request.headers.get(timestampHeader) || '';
300
+ const bodyHash = (0, crypto_1.createHash)('sha256').update(rawBody).digest('hex');
301
+ return `${requestId}.${userId}.${timestamp}.${bodyHash}`;
302
+ }
303
+ async verify(request) {
304
+ try {
305
+ const signature = this.extractSignature(request);
306
+ if (!signature) {
307
+ return {
308
+ isValid: false,
309
+ error: `Missing signature header: ${this.config.headerName}`,
310
+ errorCode: 'MISSING_SIGNATURE',
311
+ platform: this.platform,
312
+ };
313
+ }
314
+ const rawBody = await request.text();
315
+ const payload = this.platform === 'falai'
316
+ ? this.buildFalPayload(rawBody, request)
317
+ : this.formatPayload(rawBody, request);
318
+ const publicKey = await this.resolvePublicKey(request);
319
+ if (!publicKey) {
320
+ return {
321
+ isValid: false,
322
+ error: 'Missing public key for ED25519 verification',
323
+ errorCode: 'VERIFICATION_ERROR',
324
+ platform: this.platform,
325
+ };
326
+ }
327
+ const signatureBytes = new Uint8Array(Buffer.from(signature, 'base64'));
328
+ const keyObject = (0, crypto_1.createPublicKey)(publicKey);
329
+ const payloadBytes = new Uint8Array(Buffer.from(payload));
330
+ const isValid = (0, crypto_1.verify)(null, payloadBytes, keyObject, signatureBytes);
331
+ if (!isValid) {
332
+ return {
333
+ isValid: false,
334
+ error: 'Invalid signature',
335
+ errorCode: 'INVALID_SIGNATURE',
336
+ platform: this.platform,
337
+ };
338
+ }
339
+ let parsedPayload;
340
+ try {
341
+ parsedPayload = JSON.parse(rawBody);
342
+ }
343
+ catch (e) {
344
+ parsedPayload = rawBody;
345
+ }
346
+ return {
347
+ isValid: true,
348
+ platform: this.platform,
349
+ payload: parsedPayload,
350
+ metadata: {
351
+ algorithm: this.config.algorithm,
352
+ requestId: request.headers.get(this.config.customConfig?.requestIdHeader || 'x-fal-request-id'),
353
+ userId: request.headers.get(this.config.customConfig?.userIdHeader || 'x-fal-user-id'),
354
+ timestamp: request.headers.get(this.config.customConfig?.timestampHeader || 'x-fal-webhook-timestamp') || undefined,
355
+ },
356
+ };
357
+ }
358
+ catch (error) {
359
+ return {
360
+ isValid: false,
361
+ error: `${this.platform} verification error: ${error.message}`,
362
+ errorCode: 'VERIFICATION_ERROR',
363
+ platform: this.platform,
364
+ };
365
+ }
366
+ }
367
+ }
368
+ exports.Ed25519Verifier = Ed25519Verifier;
266
369
  class HMACSHA256Verifier extends GenericHMACVerifier {
267
370
  constructor(secret, config, platform = 'unknown', toleranceInSeconds = 300) {
268
371
  super(secret, config, platform, toleranceInSeconds);
@@ -281,17 +384,16 @@ class HMACSHA512Verifier extends GenericHMACVerifier {
281
384
  }
282
385
  }
283
386
  exports.HMACSHA512Verifier = HMACSHA512Verifier;
284
- // Factory function to create verifiers based on algorithm
285
387
  function createAlgorithmVerifier(secret, config, platform = 'unknown', toleranceInSeconds = 300) {
286
388
  switch (config.algorithm) {
287
389
  case 'hmac-sha256':
288
390
  case 'hmac-sha1':
289
391
  case 'hmac-sha512':
290
392
  return new GenericHMACVerifier(secret, config, platform, toleranceInSeconds);
291
- case 'rsa-sha256':
292
393
  case 'ed25519':
394
+ return new Ed25519Verifier(secret, config, platform, toleranceInSeconds);
395
+ case 'rsa-sha256':
293
396
  case 'custom':
294
- // These can be implemented as needed
295
397
  throw new Error(`Algorithm ${config.algorithm} not yet implemented`);
296
398
  default:
297
399
  throw new Error(`Unknown algorithm: ${config.algorithm}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hookflo/tern",
3
- "version": "2.0.3-experimental.0",
3
+ "version": "2.2.3",
4
4
  "description": "A robust, scalable webhook verification framework supporting multiple platforms and signature algorithms",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",