@hookflo/tern 2.0.3-experimental.0 → 2.2.0

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,6 +210,14 @@ const result = await WebhookVerificationService.verify(request, stripeConfig);
210
210
 
211
211
  ### Other Platforms
212
212
  - **Dodo Payments**: 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`)
213
221
  - **Shopify**: HMAC-SHA256
214
222
  - **Vercel**: HMAC-SHA256
215
223
  - **Polar**: HMAC-SHA256
@@ -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
-
package/dist/index.js CHANGED
@@ -103,12 +103,30 @@ class WebhookVerificationService {
103
103
  return 'github';
104
104
  if (headers.has('svix-signature'))
105
105
  return 'clerk';
106
- if (headers.has('webhook-signature'))
106
+ if (headers.has('workos-signature'))
107
+ return 'workos';
108
+ if (headers.has('webhook-signature')) {
109
+ const userAgent = headers.get('user-agent')?.toLowerCase() || '';
110
+ if (userAgent.includes('replicate'))
111
+ return 'replicateai';
107
112
  return 'dodopayments';
113
+ }
108
114
  if (headers.has('x-gitlab-token'))
109
115
  return 'gitlab';
110
116
  if (headers.has('x-polar-signature'))
111
117
  return 'polar';
118
+ if (headers.has('paddle-signature'))
119
+ return 'paddle';
120
+ if (headers.has('x-razorpay-signature'))
121
+ return 'razorpay';
122
+ if (headers.has('x-signature'))
123
+ return 'lemonsqueezy';
124
+ if (headers.has('x-auth0-signature'))
125
+ return 'auth0';
126
+ if (headers.has('x-wc-webhook-signature'))
127
+ return 'woocommerce';
128
+ if (headers.has('x-fal-signature') || headers.has('x-fal-webhook-signature'))
129
+ return 'falai';
112
130
  if (headers.has('x-shopify-hmac-sha256'))
113
131
  return 'shopify';
114
132
  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
  },
@@ -131,6 +133,116 @@ exports.platformAlgorithmConfigs = {
131
133
  },
132
134
  description: 'GitLab webhooks use HMAC-SHA256 with X-Gitlab-Token header',
133
135
  },
136
+ paddle: {
137
+ platform: 'paddle',
138
+ signatureConfig: {
139
+ algorithm: 'hmac-sha256',
140
+ headerName: 'paddle-signature',
141
+ headerFormat: 'comma-separated',
142
+ payloadFormat: 'custom',
143
+ customConfig: {
144
+ timestampKey: 'ts',
145
+ signatureKey: 'h1',
146
+ payloadFormat: '{timestamp}:{body}',
147
+ },
148
+ },
149
+ description: 'Paddle webhooks use HMAC-SHA256 with Paddle-Signature (ts/h1) header format',
150
+ },
151
+ razorpay: {
152
+ platform: 'razorpay',
153
+ signatureConfig: {
154
+ algorithm: 'hmac-sha256',
155
+ headerName: 'x-razorpay-signature',
156
+ headerFormat: 'raw',
157
+ payloadFormat: 'raw',
158
+ },
159
+ description: 'Razorpay webhooks use HMAC-SHA256 with X-Razorpay-Signature header',
160
+ },
161
+ lemonsqueezy: {
162
+ platform: 'lemonsqueezy',
163
+ signatureConfig: {
164
+ algorithm: 'hmac-sha256',
165
+ headerName: 'x-signature',
166
+ headerFormat: 'raw',
167
+ payloadFormat: 'raw',
168
+ },
169
+ description: 'Lemon Squeezy webhooks use HMAC-SHA256 with X-Signature header',
170
+ },
171
+ auth0: {
172
+ platform: 'auth0',
173
+ signatureConfig: {
174
+ algorithm: 'hmac-sha256',
175
+ headerName: 'x-auth0-signature',
176
+ headerFormat: 'raw',
177
+ payloadFormat: 'raw',
178
+ },
179
+ description: 'Auth0 webhooks use HMAC-SHA256 with X-Auth0-Signature header',
180
+ },
181
+ workos: {
182
+ platform: 'workos',
183
+ signatureConfig: {
184
+ algorithm: 'hmac-sha256',
185
+ headerName: 'workos-signature',
186
+ headerFormat: 'comma-separated',
187
+ payloadFormat: 'custom',
188
+ customConfig: {
189
+ timestampKey: 't',
190
+ signatureKey: 'v1',
191
+ payloadFormat: '{timestamp}.{body}',
192
+ },
193
+ },
194
+ description: 'WorkOS webhooks use HMAC-SHA256 with WorkOS-Signature (t/v1) format',
195
+ },
196
+ woocommerce: {
197
+ platform: 'woocommerce',
198
+ signatureConfig: {
199
+ algorithm: 'hmac-sha256',
200
+ headerName: 'x-wc-webhook-signature',
201
+ headerFormat: 'raw',
202
+ payloadFormat: 'raw',
203
+ customConfig: {
204
+ encoding: 'base64',
205
+ secretEncoding: 'utf8',
206
+ },
207
+ },
208
+ description: 'WooCommerce webhooks use HMAC-SHA256 with base64 encoded signature',
209
+ },
210
+ replicateai: {
211
+ platform: 'replicateai',
212
+ signatureConfig: {
213
+ algorithm: 'hmac-sha256',
214
+ headerName: 'webhook-signature',
215
+ headerFormat: 'raw',
216
+ timestampHeader: 'webhook-timestamp',
217
+ timestampFormat: 'unix',
218
+ payloadFormat: 'custom',
219
+ customConfig: {
220
+ signatureFormat: 'v1={signature}',
221
+ payloadFormat: '{id}.{timestamp}.{body}',
222
+ encoding: 'base64',
223
+ secretEncoding: 'base64',
224
+ idHeader: 'webhook-id',
225
+ },
226
+ },
227
+ description: 'Replicate webhooks use HMAC-SHA256 with Standard Webhooks (svix-style) format',
228
+ },
229
+ falai: {
230
+ platform: 'falai',
231
+ signatureConfig: {
232
+ algorithm: 'ed25519',
233
+ headerName: 'x-fal-webhook-signature',
234
+ headerFormat: 'raw',
235
+ payloadFormat: 'custom',
236
+ customConfig: {
237
+ requestIdHeader: 'x-fal-request-id',
238
+ userIdHeader: 'x-fal-user-id',
239
+ timestampHeader: 'x-fal-webhook-timestamp',
240
+ kidHeader: 'x-fal-webhook-key-id',
241
+ jwksUrl: 'https://rest.alpha.fal.ai/.well-known/jwks.json',
242
+ },
243
+ },
244
+ description: 'fal.ai webhooks use ED25519 with a signed request/user/timestamp/body-hash payload',
245
+ },
134
246
  custom: {
135
247
  platform: 'custom',
136
248
  signatureConfig: {
@@ -178,8 +290,9 @@ function validateSignatureConfig(config) {
178
290
  case 'hmac-sha512':
179
291
  return true;
180
292
  case 'rsa-sha256':
181
- case 'ed25519':
182
293
  return !!config.customConfig?.publicKey;
294
+ case 'ed25519':
295
+ return !!config.customConfig?.publicKey || !!config.customConfig?.jwksUrl;
183
296
  case 'custom':
184
297
  return !!config.customConfig;
185
298
  default:
package/dist/test.js CHANGED
@@ -35,6 +35,35 @@ 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 createWooCommerceSignature(body, secret) {
53
+ const hmac = (0, crypto_1.createHmac)('sha256', secret);
54
+ hmac.update(body);
55
+ return hmac.digest('base64');
56
+ }
57
+ function createWorkOSSignature(body, secret, timestamp) {
58
+ const signedPayload = `${timestamp}.${body}`;
59
+ const hmac = (0, crypto_1.createHmac)('sha256', secret);
60
+ hmac.update(signedPayload);
61
+ return `t=${timestamp},v1=${hmac.digest('hex')}`;
62
+ }
63
+ function createFalPayloadToSign(body, requestId, userId, timestamp) {
64
+ const bodyHash = (0, crypto_1.createHash)('sha256').update(body).digest('hex');
65
+ return `${requestId}.${userId}.${timestamp}.${bodyHash}`;
66
+ }
38
67
  async function runTests() {
39
68
  console.log('🧪 Running Webhook Verification Tests...\n');
40
69
  // Test 1: Stripe Webhook
@@ -289,6 +318,156 @@ async function runTests() {
289
318
  catch (error) {
290
319
  console.log(' ❌ Category registry test failed:', error);
291
320
  }
321
+ // Test 13: Razorpay
322
+ console.log('\n13. Testing Razorpay webhook...');
323
+ try {
324
+ const hmac = (0, crypto_1.createHmac)('sha256', testSecret);
325
+ hmac.update(testBody);
326
+ const signature = hmac.digest('hex');
327
+ const request = createMockRequest({
328
+ 'x-razorpay-signature': signature,
329
+ 'content-type': 'application/json',
330
+ });
331
+ const result = await index_1.WebhookVerificationService.verifyWithPlatformConfig(request, 'razorpay', testSecret);
332
+ console.log(' ✅ Razorpay:', result.isValid ? 'PASSED' : 'FAILED');
333
+ }
334
+ catch (error) {
335
+ console.log(' ❌ Razorpay test failed:', error);
336
+ }
337
+ // Test 14: Lemon Squeezy
338
+ console.log('\n14. Testing Lemon Squeezy webhook...');
339
+ try {
340
+ const hmac = (0, crypto_1.createHmac)('sha256', testSecret);
341
+ hmac.update(testBody);
342
+ const signature = hmac.digest('hex');
343
+ const request = createMockRequest({
344
+ 'x-signature': signature,
345
+ 'content-type': 'application/json',
346
+ });
347
+ const result = await index_1.WebhookVerificationService.verifyWithPlatformConfig(request, 'lemonsqueezy', testSecret);
348
+ console.log(' ✅ Lemon Squeezy:', result.isValid ? 'PASSED' : 'FAILED');
349
+ }
350
+ catch (error) {
351
+ console.log(' ❌ Lemon Squeezy test failed:', error);
352
+ }
353
+ // Test 15: Paddle
354
+ console.log('\n15. Testing Paddle webhook...');
355
+ try {
356
+ const timestamp = Math.floor(Date.now() / 1000);
357
+ const signature = createPaddleSignature(testBody, testSecret, timestamp);
358
+ const request = createMockRequest({
359
+ 'paddle-signature': signature,
360
+ 'content-type': 'application/json',
361
+ });
362
+ const result = await index_1.WebhookVerificationService.verifyWithPlatformConfig(request, 'paddle', testSecret);
363
+ console.log(' ✅ Paddle:', result.isValid ? 'PASSED' : 'FAILED');
364
+ }
365
+ catch (error) {
366
+ console.log(' ❌ Paddle test failed:', error);
367
+ }
368
+ // Test 16: Auth0
369
+ console.log('\n16. Testing Auth0 webhook...');
370
+ try {
371
+ const hmac = (0, crypto_1.createHmac)('sha256', testSecret);
372
+ hmac.update(testBody);
373
+ const signature = hmac.digest('hex');
374
+ const request = createMockRequest({
375
+ 'x-auth0-signature': signature,
376
+ 'content-type': 'application/json',
377
+ });
378
+ const result = await index_1.WebhookVerificationService.verifyWithPlatformConfig(request, 'auth0', testSecret);
379
+ console.log(' ✅ Auth0:', result.isValid ? 'PASSED' : 'FAILED');
380
+ }
381
+ catch (error) {
382
+ console.log(' ❌ Auth0 test failed:', error);
383
+ }
384
+ // Test 17: WorkOS
385
+ console.log('\n17. Testing WorkOS webhook...');
386
+ try {
387
+ const timestamp = Math.floor(Date.now() / 1000);
388
+ const signature = createWorkOSSignature(testBody, testSecret, timestamp);
389
+ const request = createMockRequest({
390
+ 'workos-signature': signature,
391
+ 'content-type': 'application/json',
392
+ });
393
+ const result = await index_1.WebhookVerificationService.verifyWithPlatformConfig(request, 'workos', testSecret);
394
+ console.log(' ✅ WorkOS:', result.isValid ? 'PASSED' : 'FAILED');
395
+ }
396
+ catch (error) {
397
+ console.log(' ❌ WorkOS test failed:', error);
398
+ }
399
+ // Test 18: WooCommerce
400
+ console.log('\n18. Testing WooCommerce webhook...');
401
+ try {
402
+ const signature = createWooCommerceSignature(testBody, testSecret);
403
+ const request = createMockRequest({
404
+ 'x-wc-webhook-signature': signature,
405
+ 'content-type': 'application/json',
406
+ });
407
+ const result = await index_1.WebhookVerificationService.verifyWithPlatformConfig(request, 'woocommerce', testSecret);
408
+ console.log(' ✅ WooCommerce:', result.isValid ? 'PASSED' : 'FAILED');
409
+ }
410
+ catch (error) {
411
+ console.log(' ❌ WooCommerce test failed:', error);
412
+ }
413
+ // Test 19: Replicate
414
+ console.log('\n19. Testing Replicate webhook...');
415
+ try {
416
+ const secret = `whsec_${Buffer.from(testSecret).toString('base64')}`;
417
+ const webhookId = 'replicate-webhook-id-1';
418
+ const timestamp = Math.floor(Date.now() / 1000);
419
+ const signature = createStandardWebhooksSignature(testBody, secret, webhookId, timestamp);
420
+ const request = createMockRequest({
421
+ 'webhook-signature': signature,
422
+ 'webhook-id': webhookId,
423
+ 'webhook-timestamp': timestamp.toString(),
424
+ 'user-agent': 'Replicate-Webhooks/1.0',
425
+ 'content-type': 'application/json',
426
+ });
427
+ const result = await index_1.WebhookVerificationService.verifyWithPlatformConfig(request, 'replicateai', secret);
428
+ console.log(' ✅ Replicate:', result.isValid ? 'PASSED' : 'FAILED');
429
+ }
430
+ catch (error) {
431
+ console.log(' ❌ Replicate test failed:', error);
432
+ }
433
+ // Test 20: fal.ai
434
+ console.log('\n20. Testing fal.ai webhook...');
435
+ try {
436
+ const { privateKey, publicKey } = (0, crypto_1.generateKeyPairSync)('ed25519');
437
+ const requestId = 'fal-request-id';
438
+ const userId = 'fal-user-id';
439
+ const timestamp = Math.floor(Date.now() / 1000).toString();
440
+ const payloadToSign = createFalPayloadToSign(testBody, requestId, userId, timestamp);
441
+ const payloadBytes = new Uint8Array(Buffer.from(payloadToSign));
442
+ const signature = (0, crypto_1.sign)(null, payloadBytes, privateKey).toString('base64');
443
+ const request = createMockRequest({
444
+ 'x-fal-webhook-signature': signature,
445
+ 'x-fal-request-id': requestId,
446
+ 'x-fal-user-id': userId,
447
+ 'x-fal-webhook-timestamp': timestamp,
448
+ 'content-type': 'application/json',
449
+ });
450
+ const result = await index_1.WebhookVerificationService.verify(request, {
451
+ platform: 'falai',
452
+ secret: '',
453
+ signatureConfig: {
454
+ algorithm: 'ed25519',
455
+ headerName: 'x-fal-webhook-signature',
456
+ headerFormat: 'raw',
457
+ payloadFormat: 'custom',
458
+ customConfig: {
459
+ publicKey: publicKey.export({ type: 'spki', format: 'pem' }).toString(),
460
+ requestIdHeader: 'x-fal-request-id',
461
+ userIdHeader: 'x-fal-user-id',
462
+ timestampHeader: 'x-fal-webhook-timestamp',
463
+ },
464
+ },
465
+ });
466
+ console.log(' ✅ fal.ai:', result.isValid ? 'PASSED' : 'FAILED');
467
+ }
468
+ catch (error) {
469
+ console.log(' ❌ fal.ai test failed:', error);
470
+ }
292
471
  console.log('\n🎉 All tests completed!');
293
472
  }
294
473
  // 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';
@@ -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,11 @@ 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
+ const secretMaterial = secretEncoding === 'base64'
139
+ ? new Uint8Array(Buffer.from(this.secret.includes('_') ? this.secret.split('_')[1] : this.secret, 'base64'))
140
+ : this.secret;
141
+ const hmac = (0, crypto_1.createHmac)(algorithm, secretMaterial);
137
142
  hmac.update(payload);
138
143
  const expectedSignature = hmac.digest('base64');
139
144
  return this.safeCompare(signature, expectedSignature);
@@ -142,41 +147,38 @@ class AlgorithmBasedVerifier extends base_1.WebhookVerifier {
142
147
  const metadata = {
143
148
  algorithm: this.config.algorithm,
144
149
  };
145
- // Add timestamp if available
146
- const timestamp = this.extractTimestamp(request);
150
+ const timestamp = this.extractTimestamp(request) || this.extractTimestampFromSignature(request);
147
151
  if (timestamp) {
148
152
  metadata.timestamp = timestamp.toString();
149
153
  }
150
- // Add platform-specific metadata
151
154
  switch (this.platform) {
152
155
  case 'github':
153
156
  metadata.event = request.headers.get('x-github-event');
154
157
  metadata.delivery = request.headers.get('x-github-delivery');
155
158
  break;
156
- case 'stripe':
157
- // Extract Stripe-specific metadata from signature
159
+ case 'stripe': {
158
160
  const headerValue = request.headers.get(this.config.headerName);
159
161
  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
- }
162
+ const sigMap = this.parseDelimitedHeader(headerValue);
168
163
  metadata.id = sigMap.id;
169
164
  }
170
165
  break;
166
+ }
171
167
  case 'clerk':
172
- metadata.id = request.headers.get('svix-id');
168
+ case 'dodopayments':
169
+ case 'replicateai':
170
+ metadata.id = request.headers.get(this.config.customConfig?.idHeader || 'webhook-id');
171
+ break;
172
+ case 'workos':
173
+ metadata.id = request.headers.get(this.config.customConfig?.idHeader || 'webhook-id');
174
+ break;
175
+ default:
173
176
  break;
174
177
  }
175
178
  return metadata;
176
179
  }
177
180
  }
178
181
  exports.AlgorithmBasedVerifier = AlgorithmBasedVerifier;
179
- // Generic HMAC Verifier that handles all HMAC-based algorithms
180
182
  class GenericHMACVerifier extends AlgorithmBasedVerifier {
181
183
  async verify(request) {
182
184
  try {
@@ -190,17 +192,13 @@ class GenericHMACVerifier extends AlgorithmBasedVerifier {
190
192
  };
191
193
  }
192
194
  const rawBody = await request.text();
193
- // Extract timestamp based on platform configuration
194
195
  let timestamp = null;
195
196
  if (this.config.headerFormat === 'comma-separated') {
196
- // For platforms like Stripe where timestamp is embedded in signature
197
197
  timestamp = this.extractTimestampFromSignature(request);
198
198
  }
199
199
  else {
200
- // For platforms with separate timestamp header
201
200
  timestamp = this.extractTimestamp(request);
202
201
  }
203
- // Validate timestamp if required
204
202
  if (timestamp && !this.isTimestampValid(timestamp)) {
205
203
  return {
206
204
  isValid: false,
@@ -209,21 +207,16 @@ class GenericHMACVerifier extends AlgorithmBasedVerifier {
209
207
  platform: this.platform,
210
208
  };
211
209
  }
212
- // Format payload according to platform requirements
213
210
  const payload = this.formatPayload(rawBody, request);
214
- // Verify signature based on platform configuration
215
211
  let isValid = false;
216
212
  const algorithm = this.config.algorithm.replace('hmac-', '');
217
213
  if (this.config.customConfig?.encoding === 'base64') {
218
- // For platforms like Clerk that use base64 encoding
219
214
  isValid = this.verifyHMACWithBase64(payload, signature, algorithm);
220
215
  }
221
216
  else if (this.config.headerFormat === 'prefixed') {
222
- // For platforms like GitHub that use prefixed signatures
223
217
  isValid = this.verifyHMACWithPrefix(payload, signature, algorithm);
224
218
  }
225
219
  else {
226
- // Standard HMAC verification
227
220
  isValid = this.verifyHMAC(payload, signature, algorithm);
228
221
  }
229
222
  if (!isValid) {
@@ -234,7 +227,6 @@ class GenericHMACVerifier extends AlgorithmBasedVerifier {
234
227
  platform: this.platform,
235
228
  };
236
229
  }
237
- // Parse payload
238
230
  let parsedPayload;
239
231
  try {
240
232
  parsedPayload = JSON.parse(rawBody);
@@ -242,7 +234,6 @@ class GenericHMACVerifier extends AlgorithmBasedVerifier {
242
234
  catch (e) {
243
235
  parsedPayload = rawBody;
244
236
  }
245
- // Extract platform-specific metadata
246
237
  const metadata = this.extractMetadata(request);
247
238
  return {
248
239
  isValid: true,
@@ -262,7 +253,115 @@ class GenericHMACVerifier extends AlgorithmBasedVerifier {
262
253
  }
263
254
  }
264
255
  exports.GenericHMACVerifier = GenericHMACVerifier;
265
- // Legacy verifiers for backward compatibility
256
+ class Ed25519Verifier extends AlgorithmBasedVerifier {
257
+ async resolvePublicKey(request) {
258
+ const configPublicKey = this.config.customConfig?.publicKey;
259
+ if (configPublicKey) {
260
+ return configPublicKey;
261
+ }
262
+ if (this.secret && this.secret.trim().length > 0) {
263
+ return this.secret;
264
+ }
265
+ const jwksUrl = this.config.customConfig?.jwksUrl;
266
+ const kidHeader = this.config.customConfig?.kidHeader;
267
+ const kid = kidHeader ? request.headers.get(kidHeader) : null;
268
+ if (!jwksUrl || !kid) {
269
+ return null;
270
+ }
271
+ const cacheKey = `${jwksUrl}:${kid}`;
272
+ if (ed25519KeyCache.has(cacheKey)) {
273
+ return ed25519KeyCache.get(cacheKey);
274
+ }
275
+ const response = await fetch(jwksUrl);
276
+ if (!response.ok) {
277
+ return null;
278
+ }
279
+ const body = await response.json();
280
+ const key = body.keys?.find((entry) => entry.kid === kid);
281
+ if (!key) {
282
+ return null;
283
+ }
284
+ const keyObject = (0, crypto_1.createPublicKey)({ key, format: 'jwk' });
285
+ const pem = keyObject.export({ type: 'spki', format: 'pem' }).toString();
286
+ ed25519KeyCache.set(cacheKey, pem);
287
+ return pem;
288
+ }
289
+ buildFalPayload(rawBody, request) {
290
+ const requestIdHeader = this.config.customConfig?.requestIdHeader || 'x-fal-request-id';
291
+ const userIdHeader = this.config.customConfig?.userIdHeader || 'x-fal-user-id';
292
+ const timestampHeader = this.config.customConfig?.timestampHeader || 'x-fal-webhook-timestamp';
293
+ const requestId = request.headers.get(requestIdHeader) || '';
294
+ const userId = request.headers.get(userIdHeader) || '';
295
+ const timestamp = request.headers.get(timestampHeader) || '';
296
+ const bodyHash = (0, crypto_1.createHash)('sha256').update(rawBody).digest('hex');
297
+ return `${requestId}.${userId}.${timestamp}.${bodyHash}`;
298
+ }
299
+ async verify(request) {
300
+ try {
301
+ const signature = this.extractSignature(request);
302
+ if (!signature) {
303
+ return {
304
+ isValid: false,
305
+ error: `Missing signature header: ${this.config.headerName}`,
306
+ errorCode: 'MISSING_SIGNATURE',
307
+ platform: this.platform,
308
+ };
309
+ }
310
+ const rawBody = await request.text();
311
+ const payload = this.platform === 'falai'
312
+ ? this.buildFalPayload(rawBody, request)
313
+ : this.formatPayload(rawBody, request);
314
+ const publicKey = await this.resolvePublicKey(request);
315
+ if (!publicKey) {
316
+ return {
317
+ isValid: false,
318
+ error: 'Missing public key for ED25519 verification',
319
+ errorCode: 'VERIFICATION_ERROR',
320
+ platform: this.platform,
321
+ };
322
+ }
323
+ const signatureBytes = new Uint8Array(Buffer.from(signature, 'base64'));
324
+ const keyObject = (0, crypto_1.createPublicKey)(publicKey);
325
+ const payloadBytes = new Uint8Array(Buffer.from(payload));
326
+ const isValid = (0, crypto_1.verify)(null, payloadBytes, keyObject, signatureBytes);
327
+ if (!isValid) {
328
+ return {
329
+ isValid: false,
330
+ error: 'Invalid signature',
331
+ errorCode: 'INVALID_SIGNATURE',
332
+ platform: this.platform,
333
+ };
334
+ }
335
+ let parsedPayload;
336
+ try {
337
+ parsedPayload = JSON.parse(rawBody);
338
+ }
339
+ catch (e) {
340
+ parsedPayload = rawBody;
341
+ }
342
+ return {
343
+ isValid: true,
344
+ platform: this.platform,
345
+ payload: parsedPayload,
346
+ metadata: {
347
+ algorithm: this.config.algorithm,
348
+ requestId: request.headers.get(this.config.customConfig?.requestIdHeader || 'x-fal-request-id'),
349
+ userId: request.headers.get(this.config.customConfig?.userIdHeader || 'x-fal-user-id'),
350
+ timestamp: request.headers.get(this.config.customConfig?.timestampHeader || 'x-fal-webhook-timestamp') || undefined,
351
+ },
352
+ };
353
+ }
354
+ catch (error) {
355
+ return {
356
+ isValid: false,
357
+ error: `${this.platform} verification error: ${error.message}`,
358
+ errorCode: 'VERIFICATION_ERROR',
359
+ platform: this.platform,
360
+ };
361
+ }
362
+ }
363
+ }
364
+ exports.Ed25519Verifier = Ed25519Verifier;
266
365
  class HMACSHA256Verifier extends GenericHMACVerifier {
267
366
  constructor(secret, config, platform = 'unknown', toleranceInSeconds = 300) {
268
367
  super(secret, config, platform, toleranceInSeconds);
@@ -281,17 +380,16 @@ class HMACSHA512Verifier extends GenericHMACVerifier {
281
380
  }
282
381
  }
283
382
  exports.HMACSHA512Verifier = HMACSHA512Verifier;
284
- // Factory function to create verifiers based on algorithm
285
383
  function createAlgorithmVerifier(secret, config, platform = 'unknown', toleranceInSeconds = 300) {
286
384
  switch (config.algorithm) {
287
385
  case 'hmac-sha256':
288
386
  case 'hmac-sha1':
289
387
  case 'hmac-sha512':
290
388
  return new GenericHMACVerifier(secret, config, platform, toleranceInSeconds);
291
- case 'rsa-sha256':
292
389
  case 'ed25519':
390
+ return new Ed25519Verifier(secret, config, platform, toleranceInSeconds);
391
+ case 'rsa-sha256':
293
392
  case 'custom':
294
- // These can be implemented as needed
295
393
  throw new Error(`Algorithm ${config.algorithm} not yet implemented`);
296
394
  default:
297
395
  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.0",
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",