@hookflo/tern 2.2.10 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -37
- package/dist/adapters/cloudflare.d.ts +3 -3
- package/dist/adapters/cloudflare.js +1 -1
- package/dist/adapters/express.d.ts +2 -2
- package/dist/adapters/nextjs.d.ts +3 -3
- package/dist/adapters/nextjs.js +1 -1
- package/dist/adapters/shared.js +20 -20
- package/dist/adapters/testExpress.js +24 -25
- package/dist/examples.js +2 -2
- package/dist/index.d.ts +3 -0
- package/dist/index.js +26 -6
- package/dist/normalization/providers/registry.js +0 -2
- package/dist/normalization/simple.js +0 -12
- package/dist/platforms/algorithms.d.ts +1 -1
- package/dist/platforms/algorithms.js +217 -184
- package/dist/test.js +124 -29
- package/dist/types.d.ts +8 -4
- package/dist/types.js +4 -2
- package/dist/utils.js +0 -7
- package/dist/verifiers/algorithms.d.ts +1 -0
- package/dist/verifiers/algorithms.js +77 -14
- package/dist/verifiers/base.d.ts +1 -1
- package/dist/verifiers/custom-algorithms.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -17,6 +17,8 @@ npm install @hookflo/tern
|
|
|
17
17
|
|
|
18
18
|
Tern is a zero-dependency TypeScript framework for robust webhook verification across multiple platforms and algorithms.
|
|
19
19
|
|
|
20
|
+
**Runtime requirements:** Node.js 18+ (or any runtime with Web Crypto + Fetch APIs, such as Deno and Cloudflare Workers).
|
|
21
|
+
|
|
20
22
|
<img width="1396" height="470" style="border-radius: 10px" alt="tern bird nature" src="https://github.com/user-attachments/assets/5f0da3e6-1aba-4f88-a9d7-9d8698845c39" />
|
|
21
23
|
|
|
22
24
|
## Features
|
|
@@ -24,7 +26,7 @@ Tern is a zero-dependency TypeScript framework for robust webhook verification a
|
|
|
24
26
|
- **Algorithm Agnostic**: Decouples platform logic from signature verification — verify based on cryptographic algorithm, not hardcoded platform rules.
|
|
25
27
|
Supports HMAC-SHA256, HMAC-SHA1, HMAC-SHA512, and custom algorithms
|
|
26
28
|
|
|
27
|
-
- **Platform Specific**: Accurate implementations for **Stripe, GitHub,
|
|
29
|
+
- **Platform Specific**: Accurate implementations for **Stripe, GitHub, Clerk**, and other platforms
|
|
28
30
|
- **Flexible Configuration**: Custom signature configurations for any webhook format
|
|
29
31
|
- **Type Safe**: Full TypeScript support with comprehensive type definitions
|
|
30
32
|
- **Framework Agnostic**: Works with Express.js, Next.js, Cloudflare Workers, and more
|
|
@@ -56,20 +58,16 @@ npm install @hookflo/tern
|
|
|
56
58
|
### Basic Usage
|
|
57
59
|
|
|
58
60
|
```typescript
|
|
59
|
-
import { WebhookVerificationService
|
|
60
|
-
|
|
61
|
-
// Method 1: Using the service (recommended)
|
|
62
|
-
const result = await WebhookVerificationService.verifyWithPlatformConfig(
|
|
63
|
-
request,
|
|
64
|
-
'stripe',
|
|
65
|
-
'whsec_your_stripe_webhook_secret'
|
|
66
|
-
);
|
|
61
|
+
import { WebhookVerificationService } from '@hookflo/tern';
|
|
67
62
|
|
|
68
|
-
|
|
69
|
-
|
|
63
|
+
const result = await WebhookVerificationService.verify(request, {
|
|
64
|
+
platform: 'stripe',
|
|
65
|
+
secret: 'whsec_your_stripe_webhook_secret',
|
|
66
|
+
toleranceInSeconds: 300,
|
|
67
|
+
});
|
|
70
68
|
|
|
71
69
|
if (result.isValid) {
|
|
72
|
-
console.log('Webhook verified!', result.payload);
|
|
70
|
+
console.log('Webhook verified!', result.eventId, result.payload);
|
|
73
71
|
} else {
|
|
74
72
|
console.log('Verification failed:', result.error);
|
|
75
73
|
}
|
|
@@ -147,21 +145,6 @@ console.log(result.payload);
|
|
|
147
145
|
// }
|
|
148
146
|
```
|
|
149
147
|
|
|
150
|
-
### Platform-Specific Usage
|
|
151
|
-
|
|
152
|
-
```typescript
|
|
153
|
-
import { platformManager } from '@hookflo/tern';
|
|
154
|
-
|
|
155
|
-
// Run tests for a specific platform
|
|
156
|
-
const testsPassed = await platformManager.runPlatformTests('stripe');
|
|
157
|
-
|
|
158
|
-
// Get platform configuration
|
|
159
|
-
const config = platformManager.getConfig('stripe');
|
|
160
|
-
|
|
161
|
-
// Get platform documentation
|
|
162
|
-
const docs = platformManager.getDocumentation('stripe');
|
|
163
|
-
```
|
|
164
|
-
|
|
165
148
|
### Platform-Specific Configurations
|
|
166
149
|
|
|
167
150
|
```typescript
|
|
@@ -213,20 +196,22 @@ const result = await WebhookVerificationService.verify(request, stripeConfig);
|
|
|
213
196
|
- **Paddle**: HMAC-SHA256 OK Tested
|
|
214
197
|
- **Razorpay**: HMAC-SHA256 Pending
|
|
215
198
|
- **Lemon Squeezy**: HMAC-SHA256 OK Tested
|
|
216
|
-
- **Auth0**: HMAC-SHA256 Pending
|
|
217
199
|
- **WorkOS**: HMAC-SHA256 (`workos-signature`, `t/v1`) OK Tested
|
|
218
200
|
- **WooCommerce**: HMAC-SHA256 (base64 signature) Pending
|
|
219
201
|
- **ReplicateAI**: HMAC-SHA256 (Standard Webhooks style) OK Tested
|
|
202
|
+
- **Sentry**: HMAC-SHA256 (`sentry-hook-signature`) with JSON-stringified payload + issue-alert fallback
|
|
203
|
+
- **Grafana (v12+)**: HMAC-SHA256 (`x-grafana-alerting-signature`) with optional timestamped payload
|
|
204
|
+
- **Doppler**: HMAC-SHA256 (`x-doppler-signature`, `sha256=` prefix)
|
|
205
|
+
- **Sanity**: Stripe-compatible HMAC-SHA256 (`sanity-webhook-signature`, `t=/v1=`)
|
|
220
206
|
- **fal.ai**: ED25519 (`x-fal-webhook-signature`)
|
|
221
207
|
- **Shopify**: HMAC-SHA256 (base64 signature) OK Tested
|
|
222
208
|
- **Vercel**: HMAC-SHA256 Pending
|
|
223
209
|
- **Polar**: HMAC-SHA256 OK Tested
|
|
224
|
-
- **Supabase**: Token-based authentication Pending
|
|
225
210
|
- **GitLab**: Token-based authentication OK Tested
|
|
226
211
|
|
|
227
212
|
## Custom Platform Configuration
|
|
228
213
|
|
|
229
|
-
This framework is fully configuration-driven. You can verify webhooks from any provider—even if it is not built-in—by supplying a custom configuration object. This allows you to support new or proprietary platforms instantly, without waiting for a library update.
|
|
214
|
+
This framework is fully configuration-driven. `timestampHeader` is optional and only needed for providers that send timestamp separately from the signature. You can verify webhooks from any provider—even if it is not built-in—by supplying a custom configuration object. This allows you to support new or proprietary platforms instantly, without waiting for a library update.
|
|
230
215
|
|
|
231
216
|
### Example: Standard HMAC-SHA256 Webhook
|
|
232
217
|
|
|
@@ -240,6 +225,7 @@ const acmeConfig = {
|
|
|
240
225
|
algorithm: 'hmac-sha256',
|
|
241
226
|
headerName: 'x-acme-signature',
|
|
242
227
|
headerFormat: 'raw',
|
|
228
|
+
// Optional: only include when provider sends timestamp in a separate header
|
|
243
229
|
timestampHeader: 'x-acme-timestamp',
|
|
244
230
|
timestampFormat: 'unix',
|
|
245
231
|
payloadFormat: 'timestamped', // signs as {timestamp}.{body}
|
|
@@ -275,14 +261,22 @@ const result = await WebhookVerificationService.verify(request, svixConfig);
|
|
|
275
261
|
|
|
276
262
|
You can configure any combination of algorithm, header, payload, and encoding. See the `SignatureConfig` type for all options.
|
|
277
263
|
|
|
278
|
-
|
|
264
|
+
For `platform: 'custom'`, default config remains compatible with token-style providers through `signatureConfig.customConfig` (`type: 'token-based'`, `idHeader: 'x-webhook-id'`), and you can override it per provider.
|
|
265
|
+
|
|
266
|
+
## Verified Platforms (continuously tested)
|
|
279
267
|
- **Stripe**
|
|
280
|
-
- **
|
|
281
|
-
- **Github**
|
|
268
|
+
- **GitHub**
|
|
282
269
|
- **Clerk**
|
|
283
270
|
- **Dodo Payments**
|
|
271
|
+
- **GitLab**
|
|
272
|
+
- **WorkOS**
|
|
273
|
+
- **Lemon Squeezy**
|
|
274
|
+
- **Paddle**
|
|
275
|
+
- **Shopify**
|
|
276
|
+
- **Polar**
|
|
277
|
+
- **ReplicateAI**
|
|
284
278
|
|
|
285
|
-
|
|
279
|
+
Other listed platforms are supported but may have lighter coverage depending on release cycle.
|
|
286
280
|
|
|
287
281
|
|
|
288
282
|
## Custom Configurations
|
|
@@ -445,9 +439,11 @@ Simplified verification using platform-specific configurations with optional pay
|
|
|
445
439
|
|
|
446
440
|
Auto-detects platform from headers and verifies against one or more provider secrets.
|
|
447
441
|
|
|
448
|
-
#### `
|
|
442
|
+
#### `verifyTokenAuth(request: Request, webhookId: string, webhookToken: string): Promise<WebhookVerificationResult>`
|
|
443
|
+
|
|
444
|
+
Verifies token-based webhooks.
|
|
449
445
|
|
|
450
|
-
|
|
446
|
+
> `verifyTokenBased(...)` remains available as a backward-compatible alias and still works for existing integrations.
|
|
451
447
|
|
|
452
448
|
#### `getPlatformsByCategory(category: 'payment' | 'auth' | 'ecommerce' | 'infrastructure'): WebhookPlatform[]`
|
|
453
449
|
|
|
@@ -464,9 +460,10 @@ interface WebhookVerificationResult {
|
|
|
464
460
|
errorCode?: WebhookErrorCode;
|
|
465
461
|
platform: WebhookPlatform;
|
|
466
462
|
payload?: any;
|
|
463
|
+
eventId?: string; // canonical ID, e.g. 'stripe:evt_123'
|
|
467
464
|
metadata?: {
|
|
468
465
|
timestamp?: string;
|
|
469
|
-
id?: string | null;
|
|
466
|
+
id?: string | null; // raw provider ID (legacy)
|
|
470
467
|
[key: string]: any;
|
|
471
468
|
};
|
|
472
469
|
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { WebhookPlatform, NormalizeOptions } from '../types';
|
|
2
|
-
export interface CloudflareWebhookHandlerOptions<TEnv = Record<string, unknown>, TResponse = unknown> {
|
|
2
|
+
export interface CloudflareWebhookHandlerOptions<TEnv = Record<string, unknown>, TPayload = any, TMetadata extends Record<string, unknown> = Record<string, unknown>, TResponse = unknown> {
|
|
3
3
|
platform: WebhookPlatform;
|
|
4
4
|
secret?: string;
|
|
5
5
|
secretEnv?: string;
|
|
6
6
|
toleranceInSeconds?: number;
|
|
7
7
|
normalize?: boolean | NormalizeOptions;
|
|
8
8
|
onError?: (error: Error) => void;
|
|
9
|
-
handler: (payload:
|
|
9
|
+
handler: (payload: TPayload, env: TEnv, metadata: TMetadata) => Promise<TResponse> | TResponse;
|
|
10
10
|
}
|
|
11
|
-
export declare function createWebhookHandler<TEnv = Record<string, unknown>, TResponse = unknown>(options: CloudflareWebhookHandlerOptions<TEnv, TResponse>): (request: Request, env: TEnv) => Promise<Response>;
|
|
11
|
+
export declare function createWebhookHandler<TEnv = Record<string, unknown>, TPayload = any, TMetadata extends Record<string, unknown> = Record<string, unknown>, TResponse = unknown>(options: CloudflareWebhookHandlerOptions<TEnv, TPayload, TMetadata, TResponse>): (request: Request, env: TEnv) => Promise<Response>;
|
|
@@ -14,7 +14,7 @@ function createWebhookHandler(options) {
|
|
|
14
14
|
if (!result.isValid) {
|
|
15
15
|
return Response.json({ error: result.error, errorCode: result.errorCode, platform: result.platform, metadata: result.metadata }, { status: 400 });
|
|
16
16
|
}
|
|
17
|
-
const data = await options.handler(result.payload, env, result.metadata || {});
|
|
17
|
+
const data = await options.handler(result.payload, env, (result.metadata || {}));
|
|
18
18
|
return Response.json(data);
|
|
19
19
|
}
|
|
20
20
|
catch (error) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { WebhookPlatform, WebhookVerificationResult, NormalizeOptions } from
|
|
2
|
-
import { MinimalNodeRequest } from
|
|
1
|
+
import { WebhookPlatform, WebhookVerificationResult, NormalizeOptions } from '../types';
|
|
2
|
+
import { MinimalNodeRequest } from './shared';
|
|
3
3
|
export interface ExpressLikeResponse {
|
|
4
4
|
status: (code: number) => ExpressLikeResponse;
|
|
5
5
|
json: (payload: unknown) => unknown;
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { WebhookPlatform, NormalizeOptions } from '../types';
|
|
2
|
-
export interface NextWebhookHandlerOptions<TResponse = unknown> {
|
|
2
|
+
export interface NextWebhookHandlerOptions<TPayload = any, TMetadata extends Record<string, unknown> = Record<string, unknown>, TResponse = unknown> {
|
|
3
3
|
platform: WebhookPlatform;
|
|
4
4
|
secret: string;
|
|
5
5
|
toleranceInSeconds?: number;
|
|
6
6
|
normalize?: boolean | NormalizeOptions;
|
|
7
7
|
onError?: (error: Error) => void;
|
|
8
|
-
handler: (payload:
|
|
8
|
+
handler: (payload: TPayload, metadata: TMetadata) => Promise<TResponse> | TResponse;
|
|
9
9
|
}
|
|
10
|
-
export declare function createWebhookHandler<TResponse = unknown>(options: NextWebhookHandlerOptions<TResponse>): (request: Request) => Promise<Response>;
|
|
10
|
+
export declare function createWebhookHandler<TPayload = any, TMetadata extends Record<string, unknown> = Record<string, unknown>, TResponse = unknown>(options: NextWebhookHandlerOptions<TPayload, TMetadata, TResponse>): (request: Request) => Promise<Response>;
|
package/dist/adapters/nextjs.js
CHANGED
|
@@ -9,7 +9,7 @@ function createWebhookHandler(options) {
|
|
|
9
9
|
if (!result.isValid) {
|
|
10
10
|
return Response.json({ error: result.error, errorCode: result.errorCode, platform: result.platform, metadata: result.metadata }, { status: 400 });
|
|
11
11
|
}
|
|
12
|
-
const data = await options.handler(result.payload, result.metadata || {});
|
|
12
|
+
const data = await options.handler(result.payload, (result.metadata || {}));
|
|
13
13
|
return Response.json(data);
|
|
14
14
|
}
|
|
15
15
|
catch (error) {
|
package/dist/adapters/shared.js
CHANGED
|
@@ -6,7 +6,7 @@ exports.toWebRequest = toWebRequest;
|
|
|
6
6
|
function getHeaderValue(headers, name) {
|
|
7
7
|
const value = headers[name.toLowerCase()] ?? headers[name];
|
|
8
8
|
if (Array.isArray(value)) {
|
|
9
|
-
return value.join(
|
|
9
|
+
return value.join(',');
|
|
10
10
|
}
|
|
11
11
|
return value;
|
|
12
12
|
}
|
|
@@ -15,11 +15,11 @@ async function readIncomingMessageBodyAsBuffer(request) {
|
|
|
15
15
|
return new Uint8Array(0);
|
|
16
16
|
const chunks = [];
|
|
17
17
|
return new Promise((resolve, reject) => {
|
|
18
|
-
request.on?.(
|
|
18
|
+
request.on?.('data', (chunk) => {
|
|
19
19
|
if (chunk instanceof Uint8Array) {
|
|
20
20
|
chunks.push(chunk);
|
|
21
21
|
}
|
|
22
|
-
else if (typeof chunk ===
|
|
22
|
+
else if (typeof chunk === 'string') {
|
|
23
23
|
chunks.push(new TextEncoder().encode(chunk));
|
|
24
24
|
}
|
|
25
25
|
else if (chunk != null) {
|
|
@@ -31,7 +31,7 @@ async function readIncomingMessageBodyAsBuffer(request) {
|
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
});
|
|
34
|
-
request.on?.(
|
|
34
|
+
request.on?.('end', () => {
|
|
35
35
|
const totalLength = chunks.reduce((sum, c) => sum + c.byteLength, 0);
|
|
36
36
|
const result = new Uint8Array(totalLength);
|
|
37
37
|
let offset = 0;
|
|
@@ -41,22 +41,22 @@ async function readIncomingMessageBodyAsBuffer(request) {
|
|
|
41
41
|
}
|
|
42
42
|
resolve(result);
|
|
43
43
|
});
|
|
44
|
-
request.on?.(
|
|
44
|
+
request.on?.('error', (err) => reject(err));
|
|
45
45
|
});
|
|
46
46
|
}
|
|
47
47
|
async function extractRawBody(request) {
|
|
48
|
-
const body = request
|
|
48
|
+
const { body } = request;
|
|
49
49
|
if (body instanceof Uint8Array) {
|
|
50
50
|
return body;
|
|
51
51
|
}
|
|
52
|
-
if (typeof body ===
|
|
52
|
+
if (typeof body === 'string') {
|
|
53
53
|
return new TextEncoder().encode(body);
|
|
54
54
|
}
|
|
55
|
-
if (body !== null && body !== undefined && typeof body ===
|
|
56
|
-
console.warn(
|
|
57
|
-
|
|
58
|
-
'Add express.raw({ type: "*/*" }) before Tern on webhook routes, '
|
|
59
|
-
|
|
55
|
+
if (body !== null && body !== undefined && typeof body === 'object') {
|
|
56
|
+
console.warn('[Tern] Warning: request body is already parsed as JSON. '
|
|
57
|
+
+ 'Signature verification may fail. '
|
|
58
|
+
+ 'Add express.raw({ type: "*/*" }) before Tern on webhook routes, '
|
|
59
|
+
+ 'or register webhook routes before app.use(express.json()).');
|
|
60
60
|
return new TextEncoder().encode(JSON.stringify(body));
|
|
61
61
|
}
|
|
62
62
|
// nothing ran — read raw stream directly
|
|
@@ -73,7 +73,7 @@ function toHeadersInit(headers) {
|
|
|
73
73
|
continue;
|
|
74
74
|
}
|
|
75
75
|
if (Array.isArray(value)) {
|
|
76
|
-
normalized.set(key, value.join(
|
|
76
|
+
normalized.set(key, value.join(','));
|
|
77
77
|
continue;
|
|
78
78
|
}
|
|
79
79
|
normalized.set(key, value);
|
|
@@ -81,17 +81,17 @@ function toHeadersInit(headers) {
|
|
|
81
81
|
return normalized;
|
|
82
82
|
}
|
|
83
83
|
async function toWebRequest(request) {
|
|
84
|
-
const protocol = request.protocol ||
|
|
85
|
-
const host = request.get?.(
|
|
86
|
-
getHeaderValue(request.headers,
|
|
87
|
-
|
|
88
|
-
const path = request.originalUrl || request.url ||
|
|
84
|
+
const protocol = request.protocol || 'https';
|
|
85
|
+
const host = request.get?.('host')
|
|
86
|
+
|| getHeaderValue(request.headers, 'host')
|
|
87
|
+
|| 'localhost';
|
|
88
|
+
const path = request.originalUrl || request.url || '/';
|
|
89
89
|
const rawBody = await extractRawBody(request);
|
|
90
90
|
const init = {
|
|
91
|
-
method: request.method ||
|
|
91
|
+
method: request.method || 'POST',
|
|
92
92
|
headers: toHeadersInit(request.headers),
|
|
93
93
|
body: toArrayBuffer(rawBody),
|
|
94
|
-
duplex:
|
|
94
|
+
duplex: 'half',
|
|
95
95
|
};
|
|
96
96
|
return new Request(`${protocol}://${host}${path}`, init);
|
|
97
97
|
}
|
|
@@ -6,79 +6,78 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
const crypto_1 = __importDefault(require("crypto"));
|
|
7
7
|
const shared_1 = require("./shared");
|
|
8
8
|
const __1 = __importDefault(require(".."));
|
|
9
|
-
const secret =
|
|
10
|
-
const payload = JSON.stringify({ type:
|
|
11
|
-
"\n";
|
|
9
|
+
const secret = 'whsec_test_secret';
|
|
10
|
+
const payload = `${JSON.stringify({ type: 'payment_intent.succeeded', data: { amount: 2000 } })}\n`;
|
|
12
11
|
function buildStripeSignature(body, secret) {
|
|
13
12
|
const timestamp = Math.floor(Date.now() / 1000);
|
|
14
13
|
const signed = `${timestamp}.${body}`;
|
|
15
14
|
const hmac = crypto_1.default
|
|
16
|
-
.createHmac(
|
|
15
|
+
.createHmac('sha256', secret.replace('whsec_', ''))
|
|
17
16
|
.update(signed)
|
|
18
|
-
.digest(
|
|
17
|
+
.digest('hex');
|
|
19
18
|
return `t=${timestamp},v1=${hmac}`;
|
|
20
19
|
}
|
|
21
20
|
// ── Test 1: raw stream — no middleware ran ───────────────────
|
|
22
21
|
async function testRawStream() {
|
|
23
22
|
const sig = buildStripeSignature(payload, secret); // ✓ fixed: was missing secret arg
|
|
24
|
-
const chunks = [Buffer.from(payload,
|
|
23
|
+
const chunks = [Buffer.from(payload, 'utf8')];
|
|
25
24
|
const req = {
|
|
26
|
-
method:
|
|
25
|
+
method: 'POST',
|
|
27
26
|
headers: {
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
'stripe-signature': sig,
|
|
28
|
+
'content-type': 'application/json',
|
|
30
29
|
},
|
|
31
30
|
body: undefined,
|
|
32
31
|
on: (event, cb) => {
|
|
33
|
-
if (event ===
|
|
32
|
+
if (event === 'data') {
|
|
34
33
|
for (const chunk of chunks)
|
|
35
34
|
cb(chunk);
|
|
36
35
|
}
|
|
37
|
-
if (event ===
|
|
36
|
+
if (event === 'end') {
|
|
38
37
|
cb();
|
|
39
38
|
}
|
|
40
39
|
},
|
|
41
40
|
};
|
|
42
41
|
const webReq = await (0, shared_1.toWebRequest)(req);
|
|
43
|
-
const result = await __1.default.verifyWithPlatformConfig(webReq,
|
|
44
|
-
console.log(
|
|
42
|
+
const result = await __1.default.verifyWithPlatformConfig(webReq, 'stripe', secret);
|
|
43
|
+
console.log('Test 1 Raw stream (no middleware):', result.isValid ? '✓ PASS' : `✗ FAIL — ${result.error}`);
|
|
45
44
|
}
|
|
46
45
|
// ── Test 2: Buffer body — express.raw() ran ──────────────────
|
|
47
46
|
async function testBufferBody() {
|
|
48
47
|
const sig = buildStripeSignature(payload, secret);
|
|
49
48
|
const req = {
|
|
50
|
-
method:
|
|
49
|
+
method: 'POST',
|
|
51
50
|
headers: {
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
'content-type': 'application/json',
|
|
52
|
+
'stripe-signature': sig,
|
|
54
53
|
},
|
|
55
|
-
body: Buffer.from(payload,
|
|
54
|
+
body: Buffer.from(payload, 'utf8'),
|
|
56
55
|
};
|
|
57
56
|
const webReq = await (0, shared_1.toWebRequest)(req);
|
|
58
|
-
const result = await __1.default.verifyWithPlatformConfig(webReq,
|
|
59
|
-
console.log(
|
|
57
|
+
const result = await __1.default.verifyWithPlatformConfig(webReq, 'stripe', secret);
|
|
58
|
+
console.log('Test 2 Buffer body (express.raw):', result.isValid ? '✓ PASS' : `✗ FAIL — ${result.error}`);
|
|
60
59
|
}
|
|
61
60
|
// ── Test 3: parsed object — express.json() ran ───────────────
|
|
62
61
|
async function testParsedObject() {
|
|
63
62
|
// signature computed against raw payload including \n
|
|
64
63
|
const sig = buildStripeSignature(payload, secret);
|
|
65
64
|
const req = {
|
|
66
|
-
method:
|
|
65
|
+
method: 'POST',
|
|
67
66
|
headers: {
|
|
68
|
-
|
|
69
|
-
|
|
67
|
+
'content-type': 'application/json',
|
|
68
|
+
'stripe-signature': sig,
|
|
70
69
|
},
|
|
71
70
|
body: JSON.parse(payload), // \n gone, bytes lost
|
|
72
71
|
};
|
|
73
72
|
const webReq = await (0, shared_1.toWebRequest)(req);
|
|
74
|
-
const result = await __1.default.verifyWithPlatformConfig(webReq,
|
|
73
|
+
const result = await __1.default.verifyWithPlatformConfig(webReq, 'stripe', secret);
|
|
75
74
|
// should FAIL — \n was in original signature but lost after JSON.parse
|
|
76
|
-
console.log(
|
|
75
|
+
console.log('Test 3 Parsed object (express.json):', !result.isValid ? '✓ FAILS AS EXPECTED' : '✗ SHOULD HAVE FAILED');
|
|
77
76
|
}
|
|
78
77
|
async function run() {
|
|
79
78
|
await testRawStream(); // no middleware → should PASS
|
|
80
79
|
await testBufferBody(); // express.raw() → should PASS
|
|
81
80
|
await testParsedObject(); // express.json() → should FAIL as expected
|
|
82
|
-
console.log(
|
|
81
|
+
console.log('\nDone. Test 3 failing is correct behavior.');
|
|
83
82
|
}
|
|
84
83
|
run();
|
package/dist/examples.js
CHANGED
|
@@ -113,7 +113,7 @@ async function exampleSimplifiedPlatform() {
|
|
|
113
113
|
console.error('Error:', error);
|
|
114
114
|
}
|
|
115
115
|
}
|
|
116
|
-
// Example 4: Token-based authentication
|
|
116
|
+
// Example 4: Token-based authentication helper
|
|
117
117
|
async function exampleTokenBased() {
|
|
118
118
|
console.log('\n🔧 Example 4: Token-Based Authentication\n');
|
|
119
119
|
const request = new Request('https://your-app.com/webhook', {
|
|
@@ -126,7 +126,7 @@ async function exampleTokenBased() {
|
|
|
126
126
|
body: JSON.stringify({ event: 'database.insert' }),
|
|
127
127
|
});
|
|
128
128
|
try {
|
|
129
|
-
const result = await index_1.WebhookVerificationService.
|
|
129
|
+
const result = await index_1.WebhookVerificationService.verifyTokenAuth(request, 'webhook_123', 'token_456');
|
|
130
130
|
if (result.isValid) {
|
|
131
131
|
console.log('✅ Token-based webhook verified!');
|
|
132
132
|
console.log('Webhook ID:', result.metadata?.id);
|
package/dist/index.d.ts
CHANGED
|
@@ -6,10 +6,13 @@ export declare class WebhookVerificationService {
|
|
|
6
6
|
private static getLegacyVerifier;
|
|
7
7
|
static verifyWithPlatformConfig<TPayload = unknown>(request: Request, platform: WebhookPlatform, secret: string, toleranceInSeconds?: number, normalize?: boolean | NormalizeOptions): Promise<WebhookVerificationResult<TPayload>>;
|
|
8
8
|
static verifyAny<TPayload = unknown>(request: Request, secrets: MultiPlatformSecrets, toleranceInSeconds?: number, normalize?: boolean | NormalizeOptions): Promise<WebhookVerificationResult<TPayload>>;
|
|
9
|
+
private static resolveCanonicalEventId;
|
|
10
|
+
private static resolveRawEventId;
|
|
9
11
|
static detectPlatform(request: Request): WebhookPlatform;
|
|
10
12
|
static getPlatformsUsingAlgorithm(algorithm: string): WebhookPlatform[];
|
|
11
13
|
static platformUsesAlgorithm(platform: WebhookPlatform, algorithm: string): boolean;
|
|
12
14
|
static validateSignatureConfig(config: SignatureConfig): boolean;
|
|
15
|
+
static verifyTokenAuth<TPayload = unknown>(request: Request, webhookId: string, webhookToken: string): Promise<WebhookVerificationResult<TPayload>>;
|
|
13
16
|
static verifyTokenBased<TPayload = unknown>(request: Request, webhookId: string, webhookToken: string): Promise<WebhookVerificationResult<TPayload>>;
|
|
14
17
|
}
|
|
15
18
|
export * from './types';
|
package/dist/index.js
CHANGED
|
@@ -26,6 +26,7 @@ class WebhookVerificationService {
|
|
|
26
26
|
// Ensure the platform is set correctly in the result
|
|
27
27
|
if (result.isValid) {
|
|
28
28
|
result.platform = config.platform;
|
|
29
|
+
result.eventId = this.resolveCanonicalEventId(config.platform, result.metadata);
|
|
29
30
|
if (config.normalize) {
|
|
30
31
|
result.payload = (0, simple_1.normalizePayload)(config.platform, result.payload, config.normalize);
|
|
31
32
|
}
|
|
@@ -109,6 +110,17 @@ class WebhookVerificationService {
|
|
|
109
110
|
},
|
|
110
111
|
};
|
|
111
112
|
}
|
|
113
|
+
static resolveCanonicalEventId(platform, metadata) {
|
|
114
|
+
const rawId = this.resolveRawEventId(platform, metadata);
|
|
115
|
+
return `${platform}:${rawId}`;
|
|
116
|
+
}
|
|
117
|
+
static resolveRawEventId(platform, metadata) {
|
|
118
|
+
const candidate = metadata?.id || metadata?.delivery || metadata?.requestId || metadata?.timestamp;
|
|
119
|
+
if (candidate !== undefined && candidate !== null && `${candidate}`.trim().length > 0) {
|
|
120
|
+
return `${candidate}`.trim();
|
|
121
|
+
}
|
|
122
|
+
return `generated-${Date.now().toString(36)}-${platform}`;
|
|
123
|
+
}
|
|
112
124
|
static detectPlatform(request) {
|
|
113
125
|
const headers = request.headers;
|
|
114
126
|
if (headers.has('stripe-signature'))
|
|
@@ -137,18 +149,22 @@ class WebhookVerificationService {
|
|
|
137
149
|
return 'razorpay';
|
|
138
150
|
if (headers.has('x-signature'))
|
|
139
151
|
return 'lemonsqueezy';
|
|
140
|
-
if (headers.has('x-auth0-signature'))
|
|
141
|
-
return 'auth0';
|
|
142
152
|
if (headers.has('x-wc-webhook-signature'))
|
|
143
153
|
return 'woocommerce';
|
|
144
154
|
if (headers.has('x-fal-signature') || headers.has('x-fal-webhook-signature'))
|
|
145
155
|
return 'falai';
|
|
156
|
+
if (headers.has('sentry-hook-signature'))
|
|
157
|
+
return 'sentry';
|
|
158
|
+
if (headers.has('x-grafana-alerting-signature'))
|
|
159
|
+
return 'grafana';
|
|
160
|
+
if (headers.has('x-doppler-signature'))
|
|
161
|
+
return 'doppler';
|
|
162
|
+
if (headers.has('sanity-webhook-signature'))
|
|
163
|
+
return 'sanity';
|
|
146
164
|
if (headers.has('x-shopify-hmac-sha256'))
|
|
147
165
|
return 'shopify';
|
|
148
166
|
if (headers.has('x-vercel-signature'))
|
|
149
167
|
return 'vercel';
|
|
150
|
-
if (headers.has('x-webhook-token') && headers.has('x-webhook-id'))
|
|
151
|
-
return 'supabase';
|
|
152
168
|
return 'unknown';
|
|
153
169
|
}
|
|
154
170
|
// Helper method to get all platforms using a specific algorithm
|
|
@@ -163,8 +179,8 @@ class WebhookVerificationService {
|
|
|
163
179
|
static validateSignatureConfig(config) {
|
|
164
180
|
return (0, algorithms_2.validateSignatureConfig)(config);
|
|
165
181
|
}
|
|
166
|
-
// Simple token-based verification for
|
|
167
|
-
static async
|
|
182
|
+
// Simple token-based verification helper for token-auth providers
|
|
183
|
+
static async verifyTokenAuth(request, webhookId, webhookToken) {
|
|
168
184
|
try {
|
|
169
185
|
const idHeader = request.headers.get('x-webhook-id');
|
|
170
186
|
const tokenHeader = request.headers.get('x-webhook-token');
|
|
@@ -198,6 +214,7 @@ class WebhookVerificationService {
|
|
|
198
214
|
isValid: true,
|
|
199
215
|
platform: 'custom',
|
|
200
216
|
payload: payload,
|
|
217
|
+
eventId: `custom:${idHeader}`,
|
|
201
218
|
metadata: {
|
|
202
219
|
id: idHeader,
|
|
203
220
|
algorithm: 'token-based',
|
|
@@ -213,6 +230,9 @@ class WebhookVerificationService {
|
|
|
213
230
|
};
|
|
214
231
|
}
|
|
215
232
|
}
|
|
233
|
+
static async verifyTokenBased(request, webhookId, webhookToken) {
|
|
234
|
+
return this.verifyTokenAuth(request, webhookId, webhookToken);
|
|
235
|
+
}
|
|
216
236
|
}
|
|
217
237
|
exports.WebhookVerificationService = WebhookVerificationService;
|
|
218
238
|
__exportStar(require("./types"), exports);
|
|
@@ -6,8 +6,6 @@ const providers = [
|
|
|
6
6
|
{ id: 'razorpay', name: 'Razorpay', category: 'payment' },
|
|
7
7
|
{ id: 'paypal', name: 'PayPal', category: 'payment' },
|
|
8
8
|
{ id: 'clerk', name: 'Clerk', category: 'auth' },
|
|
9
|
-
{ id: 'auth0', name: 'Auth0', category: 'auth' },
|
|
10
|
-
{ id: 'supabase', name: 'Supabase', category: 'auth' },
|
|
11
9
|
{ id: 'shopify', name: 'Shopify', category: 'ecommerce' },
|
|
12
10
|
{ id: 'woocommerce', name: 'WooCommerce', category: 'ecommerce' },
|
|
13
11
|
];
|
|
@@ -57,18 +57,6 @@ const platformNormalizers = {
|
|
|
57
57
|
occurred_at: new Date().toISOString(),
|
|
58
58
|
}),
|
|
59
59
|
},
|
|
60
|
-
supabase: {
|
|
61
|
-
platform: 'supabase',
|
|
62
|
-
category: 'auth',
|
|
63
|
-
normalize: (payload) => ({
|
|
64
|
-
category: 'auth',
|
|
65
|
-
event: readPath(payload, 'type') || readPath(payload, 'event') || 'auth.unknown',
|
|
66
|
-
user_id: readPath(payload, 'record.id') || readPath(payload, 'id'),
|
|
67
|
-
email: readPath(payload, 'record.email') || readPath(payload, 'email'),
|
|
68
|
-
metadata: {},
|
|
69
|
-
occurred_at: new Date().toISOString(),
|
|
70
|
-
}),
|
|
71
|
-
},
|
|
72
60
|
vercel: {
|
|
73
61
|
platform: 'vercel',
|
|
74
62
|
category: 'infrastructure',
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { PlatformAlgorithmConfig, WebhookPlatform, SignatureConfig } from
|
|
1
|
+
import { PlatformAlgorithmConfig, WebhookPlatform, SignatureConfig } from '../types';
|
|
2
2
|
export declare const platformAlgorithmConfigs: Record<WebhookPlatform, PlatformAlgorithmConfig>;
|
|
3
3
|
export declare function getPlatformAlgorithmConfig(platform: WebhookPlatform): PlatformAlgorithmConfig;
|
|
4
4
|
export declare function platformUsesAlgorithm(platform: WebhookPlatform, algorithm: string): boolean;
|