diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..968a217 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,258 @@ +# Tern Architecture & Platform Feature Guide + +This document explains Tern as an **"Axios for webhook verification"**: a unified API over many provider-specific signature schemes and payload formats. + +--- + +## 1) High-Level Architecture + +```mermaid +flowchart TD + A[Incoming Webhook Request] --> B[Framework Adapter Layer] + B --> C[WebhookVerificationService] + C --> D{Signature Config Provided?} + + D -- Yes --> E[Algorithm-Based Verifier Factory] + D -- No --> F[Platform Config Registry] + F --> E + + E --> G[GenericHMACVerifier / Custom Verifier] + G --> H[Signature Extraction + Timestamp Validation + Safe Compare] + H --> I{Valid?} + + I -- No --> J[Verification Result: isValid=false + errorCode] + I -- Yes --> K[Payload Parse] + K --> L{Normalization Enabled?} + + L -- No --> M[Verification Result: Raw Payload] + L -- Yes --> N[Category-Aware Normalization Registry] + N --> O[Typed Normalized Payload + _platform + _raw] + O --> P[Verification Result: isValid=true] +``` + +### Key Layers + +1. **Framework Adapters** (`express`, `nextjs`, `cloudflare`) + - Converts framework-native request shapes to Web `Request`. + - Handles raw-body extraction safely for signature verification. + +2. **Verification Service** + - Main public entrypoint. + - Supports: + - `verify()` (full config) + - `verifyWithPlatformConfig()` (easy mode) + - `verifyAny()` (multi-provider detection/verification) + +3. **Verifier Engines** + - `GenericHMACVerifier`: shared logic for HMAC families. + - `TokenBasedVerifier`: custom/token style verifications. + +4. **Platform Algorithm Registry** + - Declarative config per provider: + - header name/format + - payload signing shape + - timestamp behavior + - algorithm mapping + +5. **Normalization Registry** + - Category-aware normalization (`payment`, `auth`, `ecommerce`, `infrastructure`). + - Produces strongly-typed normalized shapes and migration-safe fields. + +6. **Foundational Error Taxonomy** + - Stable error codes for deterministic handling: + - `MISSING_SIGNATURE` + - `INVALID_SIGNATURE` + - `TIMESTAMP_EXPIRED` + - `MISSING_TOKEN` + - `INVALID_TOKEN` + - `VERIFICATION_ERROR` + - plus reserved/foundation codes for platform/normalization failures. + +--- + +## 2) Internal Module Map + +```mermaid +flowchart LR + subgraph PublicAPI + IDX[src/index.ts] + TYPES[src/types.ts] + end + + subgraph Adapters + A1[src/adapters/shared.ts] + A2[src/adapters/express.ts] + A3[src/adapters/nextjs.ts] + A4[src/adapters/cloudflare.ts] + end + + subgraph VerificationCore + PREG[src/platforms/algorithms.ts] + VALG[src/verifiers/algorithms.ts] + VCUS[src/verifiers/custom-algorithms.ts] + end + + subgraph Normalization + NORM[src/normalization/simple.ts] + end + + IDX --> PREG + IDX --> VALG + IDX --> VCUS + IDX --> NORM + A2 --> A1 + A3 --> IDX + A4 --> IDX + A2 --> IDX + IDX --> TYPES + NORM --> TYPES + PREG --> TYPES + VALG --> TYPES + VCUS --> TYPES +``` + +--- + +## 3) Request Lifecycle (Detailed) + +```mermaid +sequenceDiagram + autonumber + participant App as Your App Route + participant Adapter as Framework Adapter + participant Service as WebhookVerificationService + participant Registry as Platform Algorithm Registry + participant Verifier as Verifier Engine + participant Normalizer as Normalization Registry + + App->>Adapter: Receive webhook + Adapter->>Adapter: Extract raw body + map headers + Adapter->>Service: verifyWithPlatformConfig(...) / verifyAny(...) + + Service->>Registry: resolve platform signature config + Registry-->>Service: SignatureConfig + Service->>Verifier: create verifier from config + + Verifier->>Verifier: extract signature/timestamp + Verifier->>Verifier: compute expected signature + Verifier->>Verifier: safe compare + tolerance check + + alt invalid + Verifier-->>Service: isValid=false + errorCode + Service-->>Adapter: failure result + Adapter-->>App: 400/500 response + else valid + Verifier-->>Service: isValid=true + parsed payload + opt normalize enabled + Service->>Normalizer: normalizePayload(platform, payload, options) + Normalizer-->>Service: typed normalized payload + end + Service-->>Adapter: success result + Adapter-->>App: handler continues + end +``` + +--- + +## 4) Platform Support Matrix (Current) + +| Platform | Verification Mode | Signature/Token Header | Algorithm/Mechanism | Timestamp Strategy | Normalization Category | Notes | +|---|---|---|---|---|---|---| +| Stripe | Signature | `stripe-signature` | HMAC-SHA256 | Embedded in signature (`t=...`) | `payment` | Supports normalized payment shape | +| GitHub | Signature | `x-hub-signature-256` | HMAC-SHA256 with `sha256=` prefix | None | (raw/fallback) | Strong verifier support | +| Clerk | Signature | `svix-signature` | HMAC-SHA256 (base64 secret derivation) | `svix-timestamp` | `auth` | Svix-style payload format | +| Dodo Payments | Signature | `webhook-signature` | HMAC-SHA256 (svix-style/base64) | `webhook-timestamp` | (raw/fallback) | Verifier implemented | +| Shopify | Signature | `x-shopify-hmac-sha256` | HMAC-SHA256 | none/custom | (raw/fallback) | Platform config present | +| Vercel | Signature | `x-vercel-signature` | HMAC-SHA256 | `x-vercel-timestamp` | `infrastructure` | Typed normalization present | +| Polar | Signature | `x-polar-signature` | HMAC-SHA256 | `x-polar-timestamp` | `payment` | Typed normalization present | +| Supabase | Token | `x-webhook-token` (+ `x-webhook-id`) | token compare (custom) | N/A | `auth` | Typed normalization present | +| GitLab | Token | `X-Gitlab-Token` | token compare (custom) | N/A | (raw/fallback) | Verifier implemented | +| Custom/Unknown | Configurable | user-defined | configurable | configurable | fallback | Extension path for new platforms | + +--- + +## 5) Normalization Categories (Current Typed Models) + +### Payment (`PaymentWebhookNormalized`) +- Standardized fields include: + - `event` (`payment.succeeded`, etc.) + - `amount`, `currency` + - `customer_id`, `transaction_id` + - `metadata`, `occurred_at` + - `_platform`, `_raw` + +### Auth (`AuthWebhookNormalized`) +- Standardized fields include: + - `event` (`user.created`, etc.) + - `user_id`, `email`, `phone` + - `metadata`, `occurred_at` + - `_platform`, `_raw` + +### Infrastructure (`InfrastructureWebhookNormalized`) +- Standardized fields include: + - `event` + - `project_id`, `deployment_id`, `status` + - `metadata`, `occurred_at` + - `_platform`, `_raw` + +### Ecommerce +- Typed interface defined for schema consistency. +- Platform-specific normalizers can be added incrementally. + +--- + +## 6) Foundational Error Taxonomy + +Tern now returns a machine-readable `errorCode` for deterministic handling and observability. + +| Error Code | Meaning | Typical Action | +|---|---|---| +| `MISSING_SIGNATURE` | Required signature header missing | Return `400`, inspect middleware/body parsing | +| `INVALID_SIGNATURE` | Signature mismatch | Return `400`, audit secrets/incoming body integrity | +| `TIMESTAMP_EXPIRED` | Signed request outside tolerance window | Return `400`, sync clock + check retries | +| `MISSING_TOKEN` | Token header absent for token-based platform | Return `400`, validate sender config | +| `INVALID_TOKEN` | Token mismatch | Return `400`, rotate/check tokens | +| `VERIFICATION_ERROR` | Catch-all verification failure | Return `500/400` depending on context | +| `PLATFORM_NOT_SUPPORTED` | Reserved foundation for unsupported platform | return safe error / fallback path | +| `NORMALIZATION_ERROR` | Reserved foundation for mapping failures | fallback to raw payload | + +--- + +## 7) Framework Adapters: What They Solve + +### Express Adapter +- Solves body-parser pitfalls by preserving a raw-body path before verification. +- Attaches verified result to request object (`req.webhook`). + +### Next.js Adapter +- Provides minimal route-handler wrapper with consistent JSON responses. +- Keeps verification logic out of route handler business logic. + +### Cloudflare Adapter +- Handles edge request semantics + env-based secret retrieval (`secretEnv`). +- Keeps same core verification API semantics as Node adapters. + +--- + +## 8) Extension Blueprint (How to Add a Platform) + +1. Add verification config in `src/platforms/algorithms.ts`. +2. If custom logic is needed, extend custom verifier path. +3. Add normalization spec in `src/normalization/simple.ts` with category assignment. +4. Add tests in `src/test.ts`: + - valid signature/token + - invalid signature/token + - missing header + - normalization shape checks +5. Add docs entry to matrix and usage examples. + +--- + +## 9) Product Geist (TL;DR) + +Tern is designed as: +- **One verification API** across many providers. +- **One normalization contract** per business category. +- **One adapter model** across server/runtime frameworks. + +This gives teams migration leverage and consistent webhook handling with minimal dependency and minimal per-provider lock-in. diff --git a/README.md b/README.md index 8106977..6cf5a91 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,12 @@ Supports HMAC-SHA256, HMAC-SHA1, HMAC-SHA512, and custom algorithms - **Flexible Configuration**: Custom signature configurations for any webhook format - **Type Safe**: Full TypeScript support with comprehensive type definitions - **Framework Agnostic**: Works with Express.js, Next.js, Cloudflare Workers, and more +- **Body-Parser Safe Adapters**: Read raw request bodies correctly to avoid signature mismatch issues +- **Multi-Provider Verification**: Verify and auto-detect across multiple providers with one API +- **Payload Normalization**: Opt-in normalized event shape to reduce provider lock-in +- **Category-aware Migration**: Normalize within provider categories (payment/auth/infrastructure) for safe platform switching +- **Strong Typed Normalized Schemas**: Category types like `PaymentWebhookNormalized` and `AuthWebhookNormalized` for safe migrations +- **Foundational Error Taxonomy**: Stable `errorCode` values (`INVALID_SIGNATURE`, `MISSING_SIGNATURE`, etc.) ## Why Tern? @@ -69,6 +75,78 @@ if (result.isValid) { } ``` +### Universal Verification (auto-detect platform) + +```typescript +import { WebhookVerificationService } from '@hookflo/tern'; + +const result = await WebhookVerificationService.verifyAny(request, { + stripe: process.env.STRIPE_WEBHOOK_SECRET, + github: process.env.GITHUB_WEBHOOK_SECRET, + clerk: process.env.CLERK_WEBHOOK_SECRET, +}); + +if (result.isValid) { + console.log(`Verified ${result.platform} webhook`); +} +``` + +### Category-aware Payload Normalization + +### Strongly-Typed Normalized Payloads + +```typescript +import { + WebhookVerificationService, + PaymentWebhookNormalized, +} from '@hookflo/tern'; + +const result = await WebhookVerificationService.verifyWithPlatformConfig( + request, + 'stripe', + process.env.STRIPE_WEBHOOK_SECRET!, + 300, + { enabled: true, category: 'payment' }, +); + +if (result.isValid && result.payload?.event === 'payment.succeeded') { + // result.payload is strongly typed + console.log(result.payload.amount, result.payload.customer_id); +} +``` + +```typescript +import { WebhookVerificationService, getPlatformsByCategory } from '@hookflo/tern'; + +// Discover migration-compatible providers in the same category +const paymentPlatforms = getPlatformsByCategory('payment'); +// ['stripe', 'polar', ...] + +const result = await WebhookVerificationService.verifyWithPlatformConfig( + request, + 'stripe', + process.env.STRIPE_WEBHOOK_SECRET!, + 300, + { + enabled: true, + category: 'payment', + includeRaw: true, + }, +); + +console.log(result.payload); +// { +// event: 'payment.succeeded', +// amount: 5000, +// currency: 'USD', +// customer_id: 'cus_123', +// transaction_id: 'pi_123', +// provider: 'stripe', +// category: 'payment', +// _raw: {...} +// } +``` + ### Platform-Specific Usage ```typescript @@ -236,80 +314,59 @@ const timestampedConfig = { ## Framework Integration -### Express.js +### Express.js middleware (body-parser safe) ```typescript -app.post('/webhooks/stripe', async (req, res) => { - const result = await WebhookVerificationService.verifyWithPlatformConfig( - req, - 'stripe', - process.env.STRIPE_WEBHOOK_SECRET - ); - - if (!result.isValid) { - return res.status(400).json({ error: result.error }); - } - - // Process the webhook - console.log('Stripe event:', result.payload.type); - res.json({ received: true }); -}); +import express from 'express'; +import { createWebhookMiddleware } from '@hookflo/tern/express'; + +const app = express(); + +app.post( + '/webhooks/stripe', + createWebhookMiddleware({ + platform: 'stripe', + secret: process.env.STRIPE_WEBHOOK_SECRET!, + normalize: true, + }), + (req, res) => { + const event = (req as any).webhook.payload; + res.json({ received: true, event: event.event }); + }, +); ``` -### Next.js API Route +### Next.js App Router ```typescript -// pages/api/webhooks/github.js -export default async function handler(req, res) { - if (req.method !== 'POST') { - return res.status(405).json({ error: 'Method not allowed' }); - } - - const result = await WebhookVerificationService.verifyWithPlatformConfig( - req, - 'github', - process.env.GITHUB_WEBHOOK_SECRET - ); - - if (!result.isValid) { - return res.status(400).json({ error: result.error }); - } +import { createWebhookHandler } from '@hookflo/tern/nextjs'; - // Handle GitHub webhook - const event = req.headers['x-github-event']; - console.log('GitHub event:', event); - - res.json({ received: true }); -} +export const POST = createWebhookHandler({ + platform: 'github', + secret: process.env.GITHUB_WEBHOOK_SECRET!, + handler: async (payload) => ({ received: true, event: payload.event ?? payload.type }), +}); ``` ### Cloudflare Workers ```typescript -addEventListener('fetch', event => { - event.respondWith(handleRequest(event.request)); +import { createWebhookHandler } from '@hookflo/tern/cloudflare'; + +const handleStripe = createWebhookHandler({ + platform: 'stripe', + secretEnv: 'STRIPE_WEBHOOK_SECRET', + handler: async (payload) => ({ received: true, event: payload.event ?? payload.type }), }); -async function handleRequest(request) { - if (request.url.includes('/webhooks/clerk')) { - const result = await WebhookVerificationService.verifyWithPlatformConfig( - request, - 'clerk', - CLERK_WEBHOOK_SECRET - ); - - if (!result.isValid) { - return new Response(JSON.stringify({ error: result.error }), { - status: 400, - headers: { 'Content-Type': 'application/json' } - }); +export default { + async fetch(request: Request, env: Record) { + if (new URL(request.url).pathname === '/webhooks/stripe') { + return handleStripe(request, env); } - - // Process Clerk webhook - console.log('Clerk event:', result.payload.type); - return new Response(JSON.stringify({ received: true })); - } -} + return new Response('Not Found', { status: 404 }); + }, +}; ``` ## API Reference @@ -320,14 +377,22 @@ async function handleRequest(request) { Verifies a webhook using the provided configuration. -#### `verifyWithPlatformConfig(request: Request, platform: WebhookPlatform, secret: string, toleranceInSeconds?: number): Promise` +#### `verifyWithPlatformConfig(request: Request, platform: WebhookPlatform, secret: string, toleranceInSeconds?: number, normalize?: boolean | NormalizeOptions): Promise` + +Simplified verification using platform-specific configurations with optional payload normalization. + +#### `verifyAny(request: Request, secrets: Record, toleranceInSeconds?: number, normalize?: boolean | NormalizeOptions): Promise` -Simplified verification using platform-specific configurations. +Auto-detects platform from headers and verifies against one or more provider secrets. #### `verifyTokenBased(request: Request, webhookId: string, webhookToken: string): Promise` Verifies token-based webhooks (like Supabase). +#### `getPlatformsByCategory(category: 'payment' | 'auth' | 'ecommerce' | 'infrastructure'): WebhookPlatform[]` + +Returns built-in providers that normalize into a shared schema for the given migration category. + ### Types #### `WebhookVerificationResult` @@ -336,6 +401,7 @@ Verifies token-based webhooks (like Supabase). interface WebhookVerificationResult { isValid: boolean; error?: string; + errorCode?: WebhookErrorCode; platform: WebhookPlatform; payload?: any; metadata?: { @@ -354,6 +420,7 @@ interface WebhookConfig { secret: string; toleranceInSeconds?: number; signatureConfig?: SignatureConfig; + normalize?: boolean | NormalizeOptions; } ``` @@ -427,4 +494,55 @@ MIT License - see [LICENSE](./LICENSE) for details. - [Documentation](./USAGE.md) - [Framework Summary](./FRAMEWORK_SUMMARY.md) +- [Architecture Guide](./ARCHITECTURE.md) - [Issues](https://github.com/Hookflo/tern/issues) + + +## Troubleshooting + +### `Module not found: Can't resolve "@hookflo/tern/nextjs"` + +If this happens in a Next.js project, it usually means one of these: + +1. You installed an older published package version that does not include subpath exports yet. +2. Lockfile still points to an old tarball/version. +3. `node_modules` cache is stale after upgrading. + +Fix steps: + +```bash +# in your Next.js app +npm i @hookflo/tern@latest +rm -rf node_modules package-lock.json .next +npm i +``` + +Then verify resolution: + +```bash +node -e "console.log(require.resolve('@hookflo/tern/nextjs'))" +``` + +If you are testing this repo locally before publish: + +```bash +# inside /workspace/tern +npm run build +npm pack + +# inside your other project +npm i /path/to/hookflo-tern-.tgz +``` + +Minimal Next.js App Router usage: + +```ts +import { createWebhookHandler } from '@hookflo/tern/nextjs'; + +export const POST = createWebhookHandler({ + platform: 'stripe', + secret: process.env.STRIPE_WEBHOOK_SECRET!, + handler: async (payload) => ({ received: true, event: payload.event ?? payload.type }), +}); +``` + diff --git a/package-lock.json b/package-lock.json index f7a151d..8ff9c5b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@hookflo/tern", - "version": "2.0.0", + "version": "2.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@hookflo/tern", - "version": "2.0.0", + "version": "2.0.2", "license": "MIT", "devDependencies": { "@types/express": "^5.0.3", diff --git a/package.json b/package.json index a97bae3..5d8ae54 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hookflo/tern", - "version": "2.0.0", + "version": "2.0.2", "description": "A robust, scalable webhook verification framework supporting multiple platforms and signature algorithms", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -70,5 +70,48 @@ ], "publishConfig": { "access": "public" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "require": "./dist/index.js", + "import": "./dist/index.js", + "default": "./dist/index.js" + }, + "./express": { + "types": "./dist/express.d.ts", + "require": "./dist/express.js", + "import": "./dist/express.js", + "default": "./dist/express.js" + }, + "./nextjs": { + "types": "./dist/nextjs.d.ts", + "require": "./dist/nextjs.js", + "import": "./dist/nextjs.js", + "default": "./dist/nextjs.js" + }, + "./cloudflare": { + "types": "./dist/cloudflare.d.ts", + "require": "./dist/cloudflare.js", + "import": "./dist/cloudflare.js", + "default": "./dist/cloudflare.js" + }, + "./package.json": "./package.json" + }, + "typesVersions": { + "*": { + "express": [ + "dist/express.d.ts" + ], + "nextjs": [ + "dist/nextjs.d.ts" + ], + "cloudflare": [ + "dist/cloudflare.d.ts" + ], + "*": [ + "dist/index.d.ts" + ] + } } -} \ No newline at end of file +} diff --git a/src/adapters/cloudflare.ts b/src/adapters/cloudflare.ts new file mode 100644 index 0000000..fa1f92e --- /dev/null +++ b/src/adapters/cloudflare.ts @@ -0,0 +1,45 @@ +import { WebhookPlatform, NormalizeOptions } from '../types'; +import { WebhookVerificationService } from '../index'; + +export interface CloudflareWebhookHandlerOptions, TResponse = unknown> { + platform: WebhookPlatform; + secret?: string; + secretEnv?: string; + toleranceInSeconds?: number; + normalize?: boolean | NormalizeOptions; + onError?: (error: Error) => void; + handler: (payload: any, env: TEnv, metadata: Record) => Promise | TResponse; +} + +export function createWebhookHandler, TResponse = unknown>( + options: CloudflareWebhookHandlerOptions, +) { + return async (request: Request, env: TEnv): Promise => { + try { + const secret = options.secret + || (options.secretEnv ? (env as any)[options.secretEnv] : undefined); + + if (!secret) { + return Response.json({ error: 'Webhook secret is not configured' }, { status: 500 }); + } + + const result = await WebhookVerificationService.verifyWithPlatformConfig( + request, + options.platform, + secret, + options.toleranceInSeconds, + options.normalize, + ); + + if (!result.isValid) { + return Response.json({ error: result.error, platform: result.platform }, { status: 400 }); + } + + const data = await options.handler(result.payload, env, result.metadata || {}); + return Response.json(data); + } catch (error) { + options.onError?.(error as Error); + return Response.json({ error: (error as Error).message }, { status: 500 }); + } + }; +} diff --git a/src/adapters/express.ts b/src/adapters/express.ts new file mode 100644 index 0000000..a4aea9b --- /dev/null +++ b/src/adapters/express.ts @@ -0,0 +1,53 @@ +import { WebhookPlatform, WebhookVerificationResult, NormalizeOptions } from '../types'; +import { WebhookVerificationService } from '../index'; +import { toWebRequest, MinimalNodeRequest } from './shared'; + +export interface ExpressLikeResponse { + status: (code: number) => ExpressLikeResponse; + json: (payload: unknown) => unknown; +} + +export interface ExpressLikeRequest extends MinimalNodeRequest { + webhook?: WebhookVerificationResult; +} + +export type ExpressLikeNext = () => void; + +export interface ExpressWebhookMiddlewareOptions { + platform: WebhookPlatform; + secret: string; + toleranceInSeconds?: number; + normalize?: boolean | NormalizeOptions; + onError?: (error: Error) => void; +} + +export function createWebhookMiddleware(options: ExpressWebhookMiddlewareOptions) { + return async ( + req: ExpressLikeRequest, + res: ExpressLikeResponse, + next: ExpressLikeNext, + ): Promise => { + try { + const webRequest = await toWebRequest(req); + + const result = await WebhookVerificationService.verifyWithPlatformConfig( + webRequest, + options.platform, + options.secret, + options.toleranceInSeconds, + options.normalize, + ); + + if (!result.isValid) { + res.status(400).json({ error: result.error, platform: result.platform }); + return; + } + + req.webhook = result; + next(); + } catch (error) { + options.onError?.(error as Error); + res.status(500).json({ error: (error as Error).message }); + } + }; +} diff --git a/src/adapters/index.ts b/src/adapters/index.ts new file mode 100644 index 0000000..3fed5b5 --- /dev/null +++ b/src/adapters/index.ts @@ -0,0 +1,18 @@ +export { + createWebhookMiddleware, + ExpressWebhookMiddlewareOptions, + ExpressLikeRequest, + ExpressLikeResponse, +} from './express'; + +export { + createWebhookHandler as createNextjsWebhookHandler, + NextWebhookHandlerOptions, +} from './nextjs'; + +export { + createWebhookHandler as createCloudflareWebhookHandler, + CloudflareWebhookHandlerOptions, +} from './cloudflare'; + +export { toWebRequest, extractRawBody } from './shared'; diff --git a/src/adapters/nextjs.ts b/src/adapters/nextjs.ts new file mode 100644 index 0000000..141f848 --- /dev/null +++ b/src/adapters/nextjs.ts @@ -0,0 +1,37 @@ +import { WebhookPlatform, NormalizeOptions } from '../types'; +import { WebhookVerificationService } from '../index'; + +export interface NextWebhookHandlerOptions { + platform: WebhookPlatform; + secret: string; + toleranceInSeconds?: number; + normalize?: boolean | NormalizeOptions; + onError?: (error: Error) => void; + handler: (payload: any, metadata: Record) => Promise | TResponse; +} + +export function createWebhookHandler( + options: NextWebhookHandlerOptions, +) { + return async (request: Request): Promise => { + try { + const result = await WebhookVerificationService.verifyWithPlatformConfig( + request, + options.platform, + options.secret, + options.toleranceInSeconds, + options.normalize, + ); + + if (!result.isValid) { + return Response.json({ error: result.error, platform: result.platform }, { status: 400 }); + } + + const data = await options.handler(result.payload, result.metadata || {}); + return Response.json(data); + } catch (error) { + options.onError?.(error as Error); + return Response.json({ error: (error as Error).message }, { status: 500 }); + } + }; +} diff --git a/src/adapters/shared.ts b/src/adapters/shared.ts new file mode 100644 index 0000000..a1f5a88 --- /dev/null +++ b/src/adapters/shared.ts @@ -0,0 +1,93 @@ +export interface MinimalNodeRequest { + method?: string; + headers: Record; + body?: unknown; + protocol?: string; + get?: (name: string) => string | undefined; + originalUrl?: string; + url?: string; + on?: (event: string, cb: (chunk?: any) => void) => void; +} + +function getHeaderValue( + headers: Record, + name: string, +): string | undefined { + const value = headers[name.toLowerCase()] ?? headers[name]; + if (Array.isArray(value)) { + return value.join(','); + } + return value; +} + +async function readIncomingMessageBody(request: MinimalNodeRequest): Promise { + if (!request.on) { + return ''; + } + + const chunks: string[] = []; + + return new Promise((resolve, reject) => { + request.on?.('data', (chunk) => { + if (typeof chunk === 'string') { + chunks.push(chunk); + return; + } + chunks.push(Buffer.from(chunk ?? '').toString('utf8')); + }); + request.on?.('end', () => resolve(chunks.join(''))); + request.on?.('error', reject); + }); +} + +export async function extractRawBody(request: MinimalNodeRequest): Promise { + const body = request.body; + + if (typeof body === 'string') { + return body; + } + + if (Buffer.isBuffer(body)) { + return body.toString('utf8'); + } + + if (body && typeof body === 'object') { + return JSON.stringify(body); + } + + return readIncomingMessageBody(request); +} + +export function toHeadersInit( + headers: Record, +): HeadersInit { + const normalized = new Headers(); + + for (const [key, value] of Object.entries(headers)) { + if (!value) { + continue; + } + + if (Array.isArray(value)) { + normalized.set(key, value.join(',')); + continue; + } + + normalized.set(key, value); + } + + return normalized; +} + +export async function toWebRequest(request: MinimalNodeRequest): Promise { + const protocol = request.protocol || 'https'; + const host = request.get?.('host') || getHeaderValue(request.headers, 'host') || 'localhost'; + const path = request.originalUrl || request.url || '/'; + const rawBody = await extractRawBody(request); + + return new Request(`${protocol}://${host}${path}`, { + method: request.method || 'POST', + headers: toHeadersInit(request.headers), + body: rawBody, + }); +} diff --git a/src/cloudflare.ts b/src/cloudflare.ts new file mode 100644 index 0000000..864eafe --- /dev/null +++ b/src/cloudflare.ts @@ -0,0 +1,2 @@ +export { createWebhookHandler } from './adapters/cloudflare'; +export type { CloudflareWebhookHandlerOptions } from './adapters/cloudflare'; diff --git a/src/express.ts b/src/express.ts new file mode 100644 index 0000000..0bd0cd4 --- /dev/null +++ b/src/express.ts @@ -0,0 +1,6 @@ +export { createWebhookMiddleware } from './adapters/express'; +export type { + ExpressWebhookMiddlewareOptions, + ExpressLikeRequest, + ExpressLikeResponse, +} from './adapters/express'; diff --git a/src/index.ts b/src/index.ts index d7342db..fb2d168 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,30 +3,40 @@ import { WebhookVerificationResult, WebhookPlatform, SignatureConfig, + MultiPlatformSecrets, + NormalizeOptions, } from './types'; import { createAlgorithmVerifier } from './verifiers/algorithms'; import { createCustomVerifier } from './verifiers/custom-algorithms'; -import { getPlatformAlgorithmConfig } from './platforms/algorithms'; +import { + getPlatformAlgorithmConfig, + getPlatformsUsingAlgorithm, + platformUsesAlgorithm, + validateSignatureConfig, +} from './platforms/algorithms'; +import { normalizePayload } from './normalization/simple'; export class WebhookVerificationService { - static async verify( + static async verify( request: Request, config: WebhookConfig, - ): Promise { + ): Promise> { const verifier = this.getVerifier(config); - const result = await verifier.verify(request); + const result = await verifier.verify(request.clone()); // Ensure the platform is set correctly in the result if (result.isValid) { result.platform = config.platform; + + if (config.normalize) { + result.payload = normalizePayload(config.platform, result.payload, config.normalize); + } } - return result; + return result as WebhookVerificationResult; } private static getVerifier(config: WebhookConfig) { - const platform = config.platform.toLowerCase() as WebhookPlatform; - // If a custom signature config is provided, use the new algorithm-based framework if (config.signatureConfig) { return this.createAlgorithmBasedVerifier(config); @@ -53,10 +63,8 @@ export class WebhookVerificationService { } private static getLegacyVerifier(config: WebhookConfig) { - const platform = config.platform.toLowerCase() as WebhookPlatform; - // For legacy support, we'll use the algorithm-based approach - const platformConfig = getPlatformAlgorithmConfig(platform); + const platformConfig = getPlatformAlgorithmConfig(config.platform); const configWithSignature: WebhookConfig = { ...config, signatureConfig: platformConfig.signatureConfig, @@ -66,47 +74,107 @@ export class WebhookVerificationService { } // New method to create verifier using platform algorithm config - static async verifyWithPlatformConfig( + static async verifyWithPlatformConfig( request: Request, platform: WebhookPlatform, secret: string, toleranceInSeconds: number = 300, - ): Promise { + normalize: boolean | NormalizeOptions = false, + ): Promise> { const platformConfig = getPlatformAlgorithmConfig(platform); const config: WebhookConfig = { platform, secret, toleranceInSeconds, signatureConfig: platformConfig.signatureConfig, + normalize, }; - return await this.verify(request, config); + return this.verify(request, config); + } + + static async verifyAny( + request: Request, + secrets: MultiPlatformSecrets, + toleranceInSeconds: number = 300, + normalize: boolean | NormalizeOptions = false, + ): Promise> { + const requestClone = request.clone(); + + const detectedPlatform = this.detectPlatform(requestClone); + if (detectedPlatform !== 'unknown' && secrets[detectedPlatform]) { + return this.verifyWithPlatformConfig( + requestClone, + detectedPlatform, + secrets[detectedPlatform] as string, + toleranceInSeconds, + normalize, + ); + } + + for (const [platform, secret] of Object.entries(secrets)) { + if (!secret) { + continue; + } + + const result = await this.verifyWithPlatformConfig( + requestClone, + platform.toLowerCase() as WebhookPlatform, + secret, + toleranceInSeconds, + normalize, + ); + + if (result.isValid) { + return result; + } + } + + return { + isValid: false, + error: 'Unable to verify webhook with provided platform secrets', + errorCode: 'VERIFICATION_ERROR', + platform: detectedPlatform, + }; + } + + static detectPlatform(request: Request): WebhookPlatform { + const headers = request.headers; + + if (headers.has('stripe-signature')) return 'stripe'; + if (headers.has('x-hub-signature-256')) return 'github'; + if (headers.has('svix-signature')) return 'clerk'; + if (headers.has('webhook-signature')) return 'dodopayments'; + if (headers.has('x-gitlab-token')) return 'gitlab'; + if (headers.has('x-polar-signature')) return 'polar'; + if (headers.has('x-shopify-hmac-sha256')) return 'shopify'; + if (headers.has('x-vercel-signature')) return 'vercel'; + if (headers.has('x-webhook-token') && headers.has('x-webhook-id')) return 'supabase'; + + return 'unknown'; } // Helper method to get all platforms using a specific algorithm static getPlatformsUsingAlgorithm(algorithm: string): WebhookPlatform[] { - const { getPlatformsUsingAlgorithm } = require('./platforms/algorithms'); return getPlatformsUsingAlgorithm(algorithm); } // Helper method to check if a platform uses a specific algorithm static platformUsesAlgorithm(platform: WebhookPlatform, algorithm: string): boolean { - const { platformUsesAlgorithm } = require('./platforms/algorithms'); return platformUsesAlgorithm(platform, algorithm); } // Helper method to validate signature config static validateSignatureConfig(config: SignatureConfig): boolean { - const { validateSignatureConfig } = require('./platforms/algorithms'); return validateSignatureConfig(config); } // Simple token-based verification for platforms like Supabase - static async verifyTokenBased( + static async verifyTokenBased( request: Request, webhookId: string, webhookToken: string, - ): Promise { + ): Promise> { try { const idHeader = request.headers.get('x-webhook-id'); const tokenHeader = request.headers.get('x-webhook-token'); @@ -115,6 +183,7 @@ export class WebhookVerificationService { return { isValid: false, error: 'Missing required headers: x-webhook-id and x-webhook-token', + errorCode: 'MISSING_TOKEN', platform: 'custom', }; } @@ -126,6 +195,7 @@ export class WebhookVerificationService { return { isValid: false, error: 'Invalid webhook ID or token', + errorCode: 'INVALID_TOKEN', platform: 'custom', }; } @@ -141,7 +211,7 @@ export class WebhookVerificationService { return { isValid: true, platform: 'custom', - payload, + payload: payload as TPayload, metadata: { id: idHeader, algorithm: 'token-based', @@ -151,6 +221,7 @@ export class WebhookVerificationService { return { isValid: false, error: `Token-based verification error: ${(error as Error).message}`, + errorCode: 'VERIFICATION_ERROR', platform: 'custom', }; } @@ -159,9 +230,18 @@ export class WebhookVerificationService { export * from './types'; export { - getPlatformAlgorithmConfig, platformUsesAlgorithm, getPlatformsUsingAlgorithm, validateSignatureConfig, + getPlatformAlgorithmConfig, + platformUsesAlgorithm, + getPlatformsUsingAlgorithm, + validateSignatureConfig, } from './platforms/algorithms'; export { createAlgorithmVerifier } from './verifiers/algorithms'; export { createCustomVerifier } from './verifiers/custom-algorithms'; +export { + normalizePayload, + getPlatformNormalizationCategory, + getPlatformsByCategory, +} from './normalization/simple'; +export * from './adapters'; export default WebhookVerificationService; diff --git a/src/nextjs.ts b/src/nextjs.ts new file mode 100644 index 0000000..cd26266 --- /dev/null +++ b/src/nextjs.ts @@ -0,0 +1,2 @@ +export { createWebhookHandler } from './adapters/nextjs'; +export type { NextWebhookHandlerOptions } from './adapters/nextjs'; diff --git a/src/normalization/simple.ts b/src/normalization/simple.ts new file mode 100644 index 0000000..d13d21b --- /dev/null +++ b/src/normalization/simple.ts @@ -0,0 +1,186 @@ +import { + AnyNormalizedWebhook, + NormalizeOptions, + NormalizationCategory, + WebhookPlatform, + PaymentWebhookNormalized, + AuthWebhookNormalized, + InfrastructureWebhookNormalized, + UnknownNormalizedWebhook, +} from '../types'; + +type PlatformNormalizationFn = (payload: any) => Omit; + +interface PlatformNormalizationSpec { + platform: WebhookPlatform; + category: NormalizationCategory; + normalize: PlatformNormalizationFn; +} + +function readPath(payload: Record, path: string): any { + return path.split('.').reduce((acc, key) => { + if (acc === undefined || acc === null) { + return undefined; + } + return acc[key]; + }, payload as any); +} + +const platformNormalizers: Partial>> = { + stripe: { + platform: 'stripe', + category: 'payment', + normalize: (payload): Omit => ({ + category: 'payment', + event: readPath(payload, 'type') === 'payment_intent.succeeded' + ? 'payment.succeeded' + : 'payment.unknown', + amount: readPath(payload, 'data.object.amount_received') + ?? readPath(payload, 'data.object.amount'), + currency: String(readPath(payload, 'data.object.currency') ?? '').toUpperCase() || undefined, + customer_id: readPath(payload, 'data.object.customer'), + transaction_id: readPath(payload, 'data.object.id'), + metadata: {}, + occurred_at: new Date().toISOString(), + }), + }, + polar: { + platform: 'polar', + category: 'payment', + normalize: (payload): Omit => ({ + category: 'payment', + event: readPath(payload, 'event') === 'payment.completed' + ? 'payment.succeeded' + : 'payment.unknown', + amount: readPath(payload, 'payload.amount_cents'), + currency: String(readPath(payload, 'payload.currency_code') ?? '').toUpperCase() || undefined, + customer_id: readPath(payload, 'payload.customer_id'), + transaction_id: readPath(payload, 'payload.transaction_id'), + metadata: {}, + occurred_at: new Date().toISOString(), + }), + }, + clerk: { + platform: 'clerk', + category: 'auth', + normalize: (payload): Omit => ({ + category: 'auth', + event: readPath(payload, 'type') || 'auth.unknown', + user_id: readPath(payload, 'data.id'), + email: readPath(payload, 'data.email_addresses.0.email_address'), + metadata: {}, + occurred_at: new Date().toISOString(), + }), + }, + supabase: { + platform: 'supabase', + category: 'auth', + normalize: (payload): Omit => ({ + category: 'auth', + event: readPath(payload, 'type') || readPath(payload, 'event') || 'auth.unknown', + user_id: readPath(payload, 'record.id') || readPath(payload, 'id'), + email: readPath(payload, 'record.email') || readPath(payload, 'email'), + metadata: {}, + occurred_at: new Date().toISOString(), + }), + }, + vercel: { + platform: 'vercel', + category: 'infrastructure', + normalize: (payload): Omit => ({ + category: 'infrastructure', + event: readPath(payload, 'type') || 'deployment.unknown', + project_id: readPath(payload, 'payload.project.id'), + deployment_id: readPath(payload, 'payload.deployment.id'), + status: 'unknown', + metadata: {}, + occurred_at: new Date().toISOString(), + }), + }, +}; + +export function getPlatformNormalizationCategory(platform: WebhookPlatform): NormalizationCategory | null { + return platformNormalizers[platform]?.category || null; +} + +export function getPlatformsByCategory(category: NormalizationCategory): WebhookPlatform[] { + return Object.values(platformNormalizers) + .filter((spec): spec is PlatformNormalizationSpec => !!spec) + .filter((spec) => spec.category === category) + .map((spec) => spec.platform); +} + +interface ResolvedNormalizeOptions { + enabled: boolean; + category?: NormalizationCategory; + includeRaw: boolean; +} + +function resolveNormalizeOptions(normalize?: boolean | NormalizeOptions): ResolvedNormalizeOptions { + if (typeof normalize === 'boolean') { + return { + enabled: normalize, + category: undefined, + includeRaw: true, + }; + } + + return { + enabled: normalize?.enabled ?? true, + category: normalize?.category, + includeRaw: normalize?.includeRaw ?? true, + }; +} + +function buildUnknownNormalizedPayload( + platform: WebhookPlatform, + payload: any, + category: NormalizationCategory | undefined, + includeRaw: boolean, + warning?: string, +): UnknownNormalizedWebhook { + return { + category: category || 'infrastructure', + event: payload?.type ?? payload?.event ?? 'unknown', + _platform: platform, + _raw: includeRaw ? payload : undefined, + warning, + occurred_at: new Date().toISOString(), + }; +} + +export function normalizePayload( + platform: WebhookPlatform, + payload: any, + normalize?: boolean | NormalizeOptions, +): AnyNormalizedWebhook | unknown { + const options = resolveNormalizeOptions(normalize); + if (!options.enabled) { + return payload; + } + + const spec = platformNormalizers[platform]; + const inferredCategory = spec?.category; + + if (!spec) { + return buildUnknownNormalizedPayload(platform, payload, options.category, options.includeRaw); + } + + if (options.category && options.category !== inferredCategory) { + return buildUnknownNormalizedPayload( + platform, + payload, + inferredCategory, + options.includeRaw, + `Requested normalization category '${options.category}' does not match platform category '${inferredCategory}'`, + ); + } + + const normalized = spec.normalize(payload); + + return { + ...normalized, + _platform: platform, + _raw: options.includeRaw ? payload : undefined, + } as AnyNormalizedWebhook; +} diff --git a/src/test.ts b/src/test.ts index 0f66edf..af3d80a 100644 --- a/src/test.ts +++ b/src/test.ts @@ -1,5 +1,5 @@ import { createHmac } from 'crypto'; -import { WebhookVerificationService } from './index'; +import { WebhookVerificationService, getPlatformsByCategory } from './index'; const testSecret = 'whsec_test_secret_key_12345'; const testBody = JSON.stringify({ event: 'test', data: { id: '123' } }); @@ -34,6 +34,7 @@ function createGitLabSignature(body: string, secret: string): string { return secret; } + function createClerkSignature(body: string, secret: string, id: string, timestamp: number): string { const signedContent = `${id}.${timestamp}.${body}`; const secretBytes = new Uint8Array(Buffer.from(secret.split('_')[1], 'base64')); @@ -233,7 +234,11 @@ async function runTests() { testSecret, ); - console.log(' ✅ Invalid signature correctly rejected:', !invalidResult.isValid ? 'PASSED' : 'FAILED'); + const invalidSigPassed = !invalidResult.isValid && ( + invalidResult.errorCode === 'INVALID_SIGNATURE' + || invalidResult.errorCode === 'TIMESTAMP_EXPIRED' + ); + console.log(' ✅ Invalid signature correctly rejected:', invalidSigPassed ? 'PASSED' : 'FAILED'); if (invalidResult.isValid) { console.log(' ❌ Should have been rejected'); } @@ -254,7 +259,8 @@ async function runTests() { testSecret, ); - console.log(' ✅ Missing headers correctly rejected:', !missingHeaderResult.isValid ? 'PASSED' : 'FAILED'); + const missingHeaderPassed = !missingHeaderResult.isValid && missingHeaderResult.errorCode === 'MISSING_SIGNATURE'; + console.log(' ✅ Missing headers correctly rejected:', missingHeaderPassed ? 'PASSED' : 'FAILED'); if (missingHeaderResult.isValid) { console.log(' ❌ Should have been rejected'); } @@ -308,6 +314,85 @@ try { console.log(' ❌ GitLab invalid token test failed:', error); } + + + // Test 10: verifyAny should auto-detect Stripe + console.log('\n10. Testing verifyAny auto-detection...'); + try { + const timestamp = Math.floor(Date.now() / 1000); + const stripeSignature = createStripeSignature(testBody, testSecret, timestamp); + + const request = createMockRequest({ + 'stripe-signature': stripeSignature, + 'content-type': 'application/json', + }); + + const result = await WebhookVerificationService.verifyAny(request, { + stripe: testSecret, + github: 'wrong-secret', + }); + + console.log(' ✅ verifyAny:', result.isValid && result.platform === 'stripe' ? 'PASSED' : 'FAILED'); + } catch (error) { + console.log(' ❌ verifyAny test failed:', error); + } + + // Test 11: Normalization for Stripe + console.log('\n11. Testing payload normalization...'); + try { + const normalizedStripeBody = JSON.stringify({ + type: 'payment_intent.succeeded', + data: { + object: { + id: 'pi_123', + amount: 5000, + currency: 'usd', + customer: 'cus_456', + }, + }, + }); + + const timestamp = Math.floor(Date.now() / 1000); + const stripeSignature = createStripeSignature(normalizedStripeBody, testSecret, timestamp); + + const request = createMockRequest( + { + 'stripe-signature': stripeSignature, + 'content-type': 'application/json', + }, + normalizedStripeBody, + ); + + const result = await WebhookVerificationService.verifyWithPlatformConfig( + request, + 'stripe', + testSecret, + 300, + true, + ); + + const payload = result.payload as Record; + const passed = result.isValid + && payload.event === 'payment.succeeded' + && payload.currency === 'USD' + && payload.transaction_id === 'pi_123'; + + console.log(' ✅ Normalization:', passed ? 'PASSED' : 'FAILED'); + } catch (error) { + console.log(' ❌ Normalization test failed:', error); + } + + + // Test 12: Category-aware normalization registry + console.log('\n12. Testing category-based platform registry...'); + try { + const paymentPlatforms = getPlatformsByCategory('payment'); + const hasStripeAndPolar = paymentPlatforms.includes('stripe') && paymentPlatforms.includes('polar'); + console.log(' ✅ Category registry:', hasStripeAndPolar ? 'PASSED' : 'FAILED'); + } catch (error) { + console.log(' ❌ Category registry test failed:', error); + } + console.log('\n🎉 All tests completed!'); } diff --git a/src/types.ts b/src/types.ts index ff3997e..07da81d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -21,7 +21,7 @@ export enum WebhookPlatformKeys { Polar = 'polar', Supabase = 'supabase', GitLab = 'gitlab', - Custom ='custom', + Custom = 'custom', Unknown = 'unknown' } @@ -45,11 +45,114 @@ export interface SignatureConfig { customConfig?: Record; } -export interface WebhookVerificationResult { +export type WebhookErrorCode = + | 'MISSING_SIGNATURE' + | 'INVALID_SIGNATURE' + | 'TIMESTAMP_EXPIRED' + | 'MISSING_TOKEN' + | 'INVALID_TOKEN' + | 'PLATFORM_NOT_SUPPORTED' + | 'NORMALIZATION_ERROR' + | 'VERIFICATION_ERROR'; + +export type NormalizationCategory = 'payment' | 'auth' | 'ecommerce' | 'infrastructure'; + +export interface BaseNormalizedWebhook { + category: NormalizationCategory; + event: string; + _platform: WebhookPlatform | string; + _raw: unknown; + occurred_at?: string; +} + +export type PaymentWebhookEvent = + | 'payment.succeeded' + | 'payment.failed' + | 'payment.refunded' + | 'subscription.created' + | 'subscription.cancelled' + | 'payment.unknown'; + +export interface PaymentWebhookNormalized extends BaseNormalizedWebhook { + category: 'payment'; + event: PaymentWebhookEvent; + amount?: number; + currency?: string; + customer_id?: string; + transaction_id?: string; + subscription_id?: string; + refund_amount?: number; + failure_reason?: string; + metadata?: Record; +} + +export type AuthWebhookEvent = + | 'user.created' + | 'user.updated' + | 'user.deleted' + | 'session.started' + | 'session.ended' + | 'auth.unknown'; + +export interface AuthWebhookNormalized extends BaseNormalizedWebhook { + category: 'auth'; + event: AuthWebhookEvent; + user_id?: string; + email?: string; + phone?: string; + metadata?: Record; +} + +export interface EcommerceWebhookNormalized extends BaseNormalizedWebhook { + category: 'ecommerce'; + event: string; + order_id?: string; + customer_id?: string; + amount?: number; + currency?: string; + metadata?: Record; +} + +export interface InfrastructureWebhookNormalized extends BaseNormalizedWebhook { + category: 'infrastructure'; + event: string; + project_id?: string; + deployment_id?: string; + status?: 'queued' | 'building' | 'ready' | 'error' | 'unknown'; + metadata?: Record; +} + +export interface UnknownNormalizedWebhook extends BaseNormalizedWebhook { + event: string; + warning?: string; +} + +export type NormalizedPayloadByCategory = { + payment: PaymentWebhookNormalized; + auth: AuthWebhookNormalized; + ecommerce: EcommerceWebhookNormalized; + infrastructure: InfrastructureWebhookNormalized; +}; + +export type AnyNormalizedWebhook = + | PaymentWebhookNormalized + | AuthWebhookNormalized + | EcommerceWebhookNormalized + | InfrastructureWebhookNormalized + | UnknownNormalizedWebhook; + +export interface NormalizeOptions { + enabled?: boolean; + category?: NormalizationCategory; + includeRaw?: boolean; +} + +export interface WebhookVerificationResult { isValid: boolean; error?: string; + errorCode?: WebhookErrorCode; platform: WebhookPlatform; - payload?: any; + payload?: TPayload; metadata?: { timestamp?: string; id?: string | null; @@ -63,6 +166,12 @@ export interface WebhookConfig { toleranceInSeconds?: number; // New fields for algorithm-based verification signatureConfig?: SignatureConfig; + // Optional payload normalization + normalize?: boolean | NormalizeOptions; +} + +export interface MultiPlatformSecrets { + [platform: string]: string | undefined; } // New interface for platform algorithm mapping diff --git a/src/verifiers/algorithms.ts b/src/verifiers/algorithms.ts index f0c7309..c163ed2 100644 --- a/src/verifiers/algorithms.ts +++ b/src/verifiers/algorithms.ts @@ -239,6 +239,7 @@ export class GenericHMACVerifier extends AlgorithmBasedVerifier { return { isValid: false, error: `Missing signature header: ${this.config.headerName}`, + errorCode: 'MISSING_SIGNATURE', platform: this.platform, }; } @@ -260,6 +261,7 @@ export class GenericHMACVerifier extends AlgorithmBasedVerifier { return { isValid: false, error: 'Webhook timestamp expired', + errorCode: 'TIMESTAMP_EXPIRED', platform: this.platform, }; } @@ -286,6 +288,7 @@ export class GenericHMACVerifier extends AlgorithmBasedVerifier { return { isValid: false, error: 'Invalid signature', + errorCode: 'INVALID_SIGNATURE', platform: this.platform, }; } @@ -313,6 +316,7 @@ export class GenericHMACVerifier extends AlgorithmBasedVerifier { error: `${this.platform} verification error: ${ (error as Error).message }`, + errorCode: 'VERIFICATION_ERROR', platform: this.platform, }; } diff --git a/src/verifiers/custom-algorithms.ts b/src/verifiers/custom-algorithms.ts index 7077813..7192b82 100644 --- a/src/verifiers/custom-algorithms.ts +++ b/src/verifiers/custom-algorithms.ts @@ -25,6 +25,7 @@ export class TokenBasedVerifier extends WebhookVerifier { return { isValid: false, error: `Missing token header: ${this.config.headerName}`, + errorCode: 'MISSING_TOKEN', platform: 'custom', }; } @@ -36,6 +37,7 @@ export class TokenBasedVerifier extends WebhookVerifier { return { isValid: false, error: 'Invalid token', + errorCode: 'INVALID_TOKEN', platform: 'custom', }; } @@ -61,6 +63,7 @@ export class TokenBasedVerifier extends WebhookVerifier { return { isValid: false, error: `Token-based verification error: ${(error as Error).message}`, + errorCode: 'VERIFICATION_ERROR', platform: 'custom', }; }