diff --git a/README.md b/README.md index c2e43e0..27ea6df 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![Zero Dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)](package.json) -Stop writing webhook verification from scratch. **Tern** handles signature verification for Stripe, GitHub, Clerk, Shopify, and 15+ more platforms — with one consistent API. +Stop writing webhook verification from scratch. **Tern** handles signature verification for Stripe, GitHub, Clerk, Shopify, and 15+ more platforms — with one consistent API. It also verifies **Standard Webhooks** (including Svix-style `svix-*` and canonical `webhook-*` headers) through a single `standardwebhooks` platform config. > Need reliable delivery too? Tern supports inbound webhook delivery via Upstash QStash — automatic retries, DLQ management, replay controls, and Slack/Discord alerting. Bring your own Upstash account (BYOK). @@ -171,6 +171,9 @@ app.post('/webhooks/stripe', createWebhookHandler({ ## Supported Platforms +> ⚠️ Normalization is no longer supported in Tern and has been removed from the public verification APIs. + + | Platform | Algorithm | Status | |---|---|---| | **Stripe** | HMAC-SHA256 | ✅ Tested | @@ -189,6 +192,9 @@ app.post('/webhooks/stripe', createWebhookHandler({ | **Grafana** | HMAC-SHA256 | ✅ Tested | | **Doppler** | HMAC-SHA256 | ✅ Tested | | **Sanity** | HMAC-SHA256 | ✅ Tested | +| **Svix** | HMAC-SHA256 | ⚠️ Untested for now | +| **Standard Webhooks** (`standardwebhooks`) | HMAC-SHA256 | ✅ Tested | +| **Linear** | HMAC-SHA256 | ⚠️ Untested for now | | **Razorpay** | HMAC-SHA256 | 🔄 Pending | | **Vercel** | HMAC-SHA256 | 🔄 Pending | @@ -196,7 +202,7 @@ app.post('/webhooks/stripe', createWebhookHandler({ ### Platform signature notes -- **Standard Webhooks style** platforms (Clerk, Dodo Payments, Polar, ReplicateAI) commonly use a secret that starts with `whsec_...`. +- **Standard Webhooks style** providers are supported via the canonical `standardwebhooks` platform (with aliases for both `webhook-*` and `svix-*` headers). Clerk, Dodo Payments, Polar, and ReplicateAI all follow this pattern and commonly use a secret that starts with `whsec_...`. - **ReplicateAI**: copy the webhook signing secret from your Replicate webhook settings and pass it directly as `secret`. - **fal.ai**: supports JWKS key resolution out of the box — use `secret: ''` for auto key resolution, or pass a PEM public key explicitly. @@ -337,25 +343,31 @@ const result = await WebhookVerificationService.verify(request, { }); ``` -### Svix / Standard Webhooks format (Clerk, Dodo Payments, ReplicateAI, etc.) +### Standard Webhooks config helpers (Svix-style and webhook-* headers) ```typescript -const svixConfig = { - platform: 'my-svix-platform', - secret: 'whsec_abc123...', +import { + createStandardWebhooksConfig, + STANDARD_WEBHOOKS_BASE, +} from '@hookflo/tern'; + +const signatureConfig = createStandardWebhooksConfig({ + id: 'webhook-id', + timestamp: 'webhook-timestamp', + signature: 'webhook-signature', + idAliases: ['svix-id'], + timestampAliases: ['svix-timestamp'], + signatureAliases: ['svix-signature'], +}); + +const result = await WebhookVerificationService.verify(request, { + platform: 'standardwebhooks', + secret: process.env.STANDARD_WEBHOOKS_SECRET!, signatureConfig: { - algorithm: 'hmac-sha256', - headerName: 'webhook-signature', - headerFormat: 'raw', - timestampHeader: 'webhook-timestamp', - timestampFormat: 'unix', - payloadFormat: 'custom', - customConfig: { - payloadFormat: '{id}.{timestamp}.{body}', - idHeader: 'webhook-id', - }, + ...STANDARD_WEBHOOKS_BASE, + ...signatureConfig, }, -}; +}); ``` See the [SignatureConfig type](https://tern.hookflo.com) for all options. @@ -403,6 +415,7 @@ interface WebhookVerificationResult { ## Troubleshooting + **`Module not found: Can't resolve "@hookflo/tern/nextjs"`** ```bash diff --git a/package-lock.json b/package-lock.json index cdd6809..c7942d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3556,9 +3556,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, diff --git a/src/adapters/cloudflare.ts b/src/adapters/cloudflare.ts index 331d4c4..b565599 100644 --- a/src/adapters/cloudflare.ts +++ b/src/adapters/cloudflare.ts @@ -1,4 +1,4 @@ -import { WebhookPlatform, NormalizeOptions } from '../types'; +import { WebhookPlatform } from '../types'; import { WebhookVerificationService } from '../index'; import { handleQueuedRequest, resolveQueueConfig } from '../upstash/queue'; import { QueueOption } from '../upstash/types'; @@ -10,7 +10,6 @@ export interface CloudflareWebhookHandlerOptions, secret?: string; secretEnv?: string; toleranceInSeconds?: number; - normalize?: boolean | NormalizeOptions; queue?: QueueOption; alerts?: AlertConfig; alert?: Omit; @@ -60,12 +59,13 @@ export function createWebhookHandler, TPayload = return response; } - const result = await WebhookVerificationService.verifyWithPlatformConfig( + const result = await WebhookVerificationService.verify( request, - options.platform, - secret, - options.toleranceInSeconds, - options.normalize, + { + platform: options.platform, + secret, + toleranceInSeconds: options.toleranceInSeconds, + }, ); if (!result.isValid) { diff --git a/src/adapters/express.ts b/src/adapters/express.ts index 8748c94..6b57025 100644 --- a/src/adapters/express.ts +++ b/src/adapters/express.ts @@ -1,7 +1,6 @@ import { WebhookPlatform, WebhookVerificationResult, - NormalizeOptions, } from '../types'; import { WebhookVerificationService } from '../index'; import { handleQueuedRequest, resolveQueueConfig } from '../upstash/queue'; @@ -25,7 +24,6 @@ export interface ExpressWebhookMiddlewareOptions { platform: WebhookPlatform; secret: string; toleranceInSeconds?: number; - normalize?: boolean | NormalizeOptions; queue?: QueueOption; alerts?: AlertConfig; alert?: Omit; @@ -88,12 +86,13 @@ export function createWebhookMiddleware( return; } - const result = await WebhookVerificationService.verifyWithPlatformConfig( + const result = await WebhookVerificationService.verify( webRequest, - options.platform, - options.secret, - options.toleranceInSeconds, - options.normalize, + { + platform: options.platform, + secret: options.secret, + toleranceInSeconds: options.toleranceInSeconds, + }, ); if (!result.isValid) { diff --git a/src/adapters/hono.ts b/src/adapters/hono.ts index 6a786ce..7df5d80 100644 --- a/src/adapters/hono.ts +++ b/src/adapters/hono.ts @@ -1,4 +1,4 @@ -import { WebhookPlatform, NormalizeOptions } from '../types'; +import { WebhookPlatform } from '../types'; import { WebhookVerificationService } from '../index'; import { handleQueuedRequest, resolveQueueConfig } from '../upstash/queue'; import { QueueOption } from '../upstash/types'; @@ -21,7 +21,6 @@ export interface HonoWebhookHandlerOptions< platform: WebhookPlatform; secret: string; toleranceInSeconds?: number; - normalize?: boolean | NormalizeOptions; queue?: QueueOption; alerts?: AlertConfig; alert?: Omit; @@ -71,12 +70,13 @@ export function createWebhookHandler< return response; } - const result = await WebhookVerificationService.verifyWithPlatformConfig( + const result = await WebhookVerificationService.verify( request, - options.platform, - options.secret, - options.toleranceInSeconds, - options.normalize, + { + platform: options.platform, + secret: options.secret, + toleranceInSeconds: options.toleranceInSeconds, + }, ); if (!result.isValid) { diff --git a/src/adapters/nextjs.ts b/src/adapters/nextjs.ts index f8ec0bf..be3ca77 100644 --- a/src/adapters/nextjs.ts +++ b/src/adapters/nextjs.ts @@ -1,4 +1,4 @@ -import { WebhookPlatform, NormalizeOptions } from '../types'; +import { WebhookPlatform } from '../types'; import { WebhookVerificationService } from '../index'; import { handleQueuedRequest, resolveQueueConfig } from '../upstash/queue'; import { QueueOption } from '../upstash/types'; @@ -9,7 +9,6 @@ export interface NextWebhookHandlerOptions; @@ -52,12 +51,13 @@ export function createWebhookHandler { - const protocol = request.protocol || 'https'; - const host = request.get?.('host') + const forwardedProto = getHeaderValue(request.headers, 'x-forwarded-proto')?.split(',')[0]?.trim(); + const protocol = forwardedProto || request.protocol || 'https'; + const forwardedHost = getHeaderValue(request.headers, 'x-forwarded-host')?.split(',')[0]?.trim(); + const host = forwardedHost + || request.get?.('host') || getHeaderValue(request.headers, 'host') || 'localhost'; const path = request.originalUrl || request.url || '/'; diff --git a/src/index.ts b/src/index.ts index 3997d65..6df2cf5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,6 @@ import { WebhookPlatform, SignatureConfig, MultiPlatformSecrets, - NormalizeOptions, WebhookErrorCode, } from './types'; import { createAlgorithmVerifier } from './verifiers/algorithms'; @@ -16,7 +15,6 @@ import { platformUsesAlgorithm, validateSignatureConfig, } from './platforms/algorithms'; -import { normalizePayload } from './normalization/simple'; import type { QueueOption } from './upstash/types'; import type { AlertConfig, SendAlertOptions } from './notifications/types'; import { dispatchWebhookAlert } from './notifications/dispatch'; @@ -38,9 +36,6 @@ export class WebhookVerificationService { result.payload as Record, ) ?? undefined; - if (config.normalize) { - result.payload = normalizePayload(config.platform, result.payload, config.normalize); - } } return result as WebhookVerificationResult; @@ -63,13 +58,20 @@ export class WebhookVerificationService { throw new Error('Signature config is required for algorithm-based verification'); } + const effectiveSignatureConfig: SignatureConfig = { + ...signatureConfig, + customConfig: { + ...(signatureConfig.customConfig || {}), + }, + }; + // Use custom verifiers for special cases (token-based, etc.) - if (signatureConfig.algorithm === 'custom') { - return createCustomVerifier(secret, signatureConfig, toleranceInSeconds); + if (effectiveSignatureConfig.algorithm === 'custom') { + return createCustomVerifier(secret, effectiveSignatureConfig, toleranceInSeconds); } // Use algorithm-based verifiers for standard algorithms - return createAlgorithmVerifier(secret, signatureConfig, config.platform, toleranceInSeconds); + return createAlgorithmVerifier(secret, effectiveSignatureConfig, config.platform, toleranceInSeconds); } private static getLegacyVerifier(config: WebhookConfig) { @@ -88,16 +90,14 @@ export class WebhookVerificationService { request: Request, platform: WebhookPlatform, secret: string, - toleranceInSeconds: number = 300, - normalize: boolean | NormalizeOptions = false, + toleranceInSeconds: number = 300 ): Promise> { const platformConfig = getPlatformAlgorithmConfig(platform); const config: WebhookConfig = { platform, secret, toleranceInSeconds, - signatureConfig: platformConfig.signatureConfig, - normalize, + signatureConfig: platformConfig.signatureConfig }; return this.verify(request, config); @@ -106,8 +106,7 @@ export class WebhookVerificationService { static async verifyAny( request: Request, secrets: MultiPlatformSecrets, - toleranceInSeconds: number = 300, - normalize: boolean | NormalizeOptions = false, + toleranceInSeconds: number = 300 ): Promise> { const requestClone = request.clone(); @@ -117,8 +116,7 @@ export class WebhookVerificationService { requestClone, detectedPlatform, secrets[detectedPlatform] as string, - toleranceInSeconds, - normalize, + toleranceInSeconds ); } @@ -137,8 +135,7 @@ export class WebhookVerificationService { requestClone, normalizedPlatform, secret as string, - toleranceInSeconds, - normalize, + toleranceInSeconds ); return { @@ -246,6 +243,9 @@ export class WebhookVerificationService { case 'workos': case 'sentry': case 'vercel': + case 'linear': + case 'svix': + case 'standardwebhooks': return this.pickString(payload?.id) || null; case 'doppler': return this.pickString(payload?.event?.id, metadata?.id) || null; @@ -287,7 +287,8 @@ export class WebhookVerificationService { 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('svix-signature')) return headers.has('svix-id') ? 'svix' : 'clerk'; + if (headers.has('linear-signature')) return 'linear'; if (headers.has('workos-signature')) return 'workos'; if (headers.has('webhook-signature')) { const userAgent = headers.get('user-agent')?.toLowerCase() || ''; @@ -443,14 +444,11 @@ export { platformUsesAlgorithm, getPlatformsUsingAlgorithm, validateSignatureConfig, + STANDARD_WEBHOOKS_BASE, + createStandardWebhooksConfig, } 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 * from './alerts'; diff --git a/src/normalization/NORMALIZATION_INTEGRATION.md b/src/normalization/NORMALIZATION_INTEGRATION.md deleted file mode 100644 index eb3adb1..0000000 --- a/src/normalization/NORMALIZATION_INTEGRATION.md +++ /dev/null @@ -1,404 +0,0 @@ -## Normalization: Next.js + Supabase Integration Guide - -This guide shows how to integrate Tern's normalization framework into a Next.js app and wire it to Supabase using a custom `StorageAdapter`. It also includes example API routes and UI usage to build a visual schema editor. - -### What the framework exposes - -- `Normalizer` class with methods: - - `getBaseTemplates()` - - `getProviders(category?)` - - `createSchema(input)` - - `updateSchema(schemaId, updates)` - - `getSchema(schemaId)` - - `transform({ rawPayload, provider, schemaId })` - - `validateSchema(schema)` -- `StorageAdapter` interface to implement persistence -- `InMemoryStorageAdapter` for local/dev use - -### Supabase schema (example) - -```sql --- webhook_schemas table -CREATE TABLE IF NOT EXISTS webhook_schemas ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - user_id uuid NOT NULL, - base_template_id text NOT NULL, - category text NOT NULL, - fields jsonb NOT NULL, - provider_mappings jsonb NOT NULL, - created_at timestamptz DEFAULT now(), - updated_at timestamptz DEFAULT now() -); - -CREATE INDEX IF NOT EXISTS idx_schemas_user ON webhook_schemas(user_id); -CREATE INDEX IF NOT EXISTS idx_schemas_category ON webhook_schemas(category); -``` - -### Implement a Supabase adapter - -Create `lib/supabaseStorageAdapter.ts` in your Next.js app: - -```ts -// lib/supabaseStorageAdapter.ts -import { createClient } from '@supabase/supabase-js'; -import type { - BaseTemplate, - CreateSchemaInput, - UpdateSchemaInput, - UserSchema, -} from '@tern/normalization'; -import type { StorageAdapter } from '@tern/normalization'; - -const supabase = createClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.SUPABASE_SERVICE_ROLE_KEY! // Service role for server-side adapters -); - -export class SupabaseStorageAdapter implements StorageAdapter { - async saveSchema(schema: UserSchema): Promise { - const { error } = await supabase.from('webhook_schemas').insert({ - id: schema.id, - user_id: schema.userId, - base_template_id: schema.baseTemplateId, - category: schema.category, - fields: schema.fields, - provider_mappings: schema.providerMappings, - created_at: schema.createdAt.toISOString(), - updated_at: schema.updatedAt.toISOString(), - }); - if (error) throw error; - } - - async getSchema(id: string): Promise { - const { data, error } = await supabase - .from('webhook_schemas') - .select('*') - .eq('id', id) - .maybeSingle(); - if (error) throw error; - if (!data) return null; - return this.rowToUserSchema(data); - } - - async updateSchema(id: string, updates: UpdateSchemaInput): Promise { - const { error } = await supabase - .from('webhook_schemas') - .update({ - ...(updates.fields ? { fields: updates.fields } : {}), - ...(updates.providerMappings ? { provider_mappings: updates.providerMappings } : {}), - updated_at: new Date().toISOString(), - }) - .eq('id', id); - if (error) throw error; - } - - async deleteSchema(id: string): Promise { - const { error } = await supabase.from('webhook_schemas').delete().eq('id', id); - if (error) throw error; - } - - async listSchemas(userId: string): Promise { - const { data, error } = await supabase - .from('webhook_schemas') - .select('*') - .eq('user_id', userId) - .order('created_at', { ascending: false }); - if (error) throw error; - return (data ?? []).map(this.rowToUserSchema); - } - - // Base templates are served from the framework's in-memory registry - async getBaseTemplate(id: string): Promise { - const { templateRegistry } = await import('@tern/normalization/dist/templates/registry'); - return templateRegistry.getById(id) ?? null; - } - - async listBaseTemplates(): Promise { - const { templateRegistry } = await import('@tern/normalization/dist/templates/registry'); - return templateRegistry.listAll(); - } - - private rowToUserSchema = (row: any): UserSchema => ({ - id: row.id, - userId: row.user_id, - baseTemplateId: row.base_template_id, - category: row.category, - fields: row.fields, - providerMappings: row.provider_mappings, - createdAt: new Date(row.created_at), - updatedAt: new Date(row.updated_at), - }); -} -``` - -### Initialize the Normalizer - -Create `lib/normalizer.ts`: - -```ts -// lib/normalizer.ts -import { Normalizer } from '@tern/normalization'; -import { SupabaseStorageAdapter } from './supabaseStorageAdapter'; - -export const normalizer = new Normalizer(new SupabaseStorageAdapter()); -``` - -### Next.js API routes: schema management - -Create `app/api/schemas/templates/route.ts`: - -```ts -// app/api/schemas/templates/route.ts -import { NextResponse } from 'next/server'; -import { normalizer } from '@/lib/normalizer'; - -export async function GET() { - const templates = await normalizer.getBaseTemplates(); - return NextResponse.json(templates); -} -``` - -Create `app/api/providers/[category]/route.ts`: - -```ts -// app/api/providers/[category]/route.ts -import { NextResponse } from 'next/server'; -import { normalizer } from '@/lib/normalizer'; - -export async function GET(_: Request, context: { params: { category: string } }) { - const providers = await normalizer.getProviders(context.params.category as any); - return NextResponse.json(providers); -} -``` - -Create `app/api/schemas/route.ts`: - -```ts -// app/api/schemas/route.ts -import { NextResponse } from 'next/server'; -import { normalizer } from '@/lib/normalizer'; - -export async function POST(req: Request) { - const body = await req.json(); - const schema = await normalizer.createSchema(body); - return NextResponse.json(schema); -} - -export async function GET(req: Request) { - const { searchParams } = new URL(req.url); - const id = searchParams.get('id'); - if (!id) return NextResponse.json({ error: 'id required' }, { status: 400 }); - const schema = await normalizer.getSchema(id); - return NextResponse.json(schema); -} -``` - -Create `app/api/schemas/[id]/route.ts`: - -```ts -// app/api/schemas/[id]/route.ts -import { NextResponse } from 'next/server'; -import { normalizer } from '@/lib/normalizer'; - -export async function PUT(req: Request, context: { params: { id: string } }) { - const updates = await req.json(); - await normalizer.updateSchema(context.params.id, updates); - return NextResponse.json({ success: true }); -} -``` - -Create `app/api/transform/route.ts` (runtime test/dry run): - -```ts -// app/api/transform/route.ts -import { NextResponse } from 'next/server'; -import { normalizer } from '@/lib/normalizer'; - -export async function POST(req: Request) { - const body = await req.json(); - const result = await normalizer.transform({ - rawPayload: body.rawPayload, - provider: body.provider, - schemaId: body.schemaId, - }); - return NextResponse.json(result); -} -``` - -### Example: Webhook handler using Normalizer - -Create a webhook route `app/api/webhooks/[provider]/route.ts`: - -```ts -// app/api/webhooks/[provider]/route.ts -import { NextResponse } from 'next/server'; -import { normalizer } from '@/lib/normalizer'; - -export async function POST(req: Request, context: { params: { provider: string } }) { - const provider = context.params.provider; - const rawPayload = await req.json(); - - // Resolve schemaId for the current tenant/user from auth/session - const schemaId = await resolveSchemaIdFromContext(); - - const result = await normalizer.transform({ rawPayload, provider, schemaId }); - - // Forward to user endpoint or process internally - await forwardToUserEndpoint(result.normalized); - - return NextResponse.json({ status: 'ok' }); -} - -async function resolveSchemaIdFromContext(): Promise { - // Implement tenant-aware lookup - return process.env.DEFAULT_SCHEMA_ID!; -} - -async function forwardToUserEndpoint(payload: unknown) { - // POST to user's configured webhook URL -} -``` - -### UI usage: minimal visual schema editor primitives - -Fetch templates and providers: - -```ts -// hooks/useTemplates.ts -export async function fetchTemplates() { - const res = await fetch('/api/schemas/templates'); - return res.json(); -} - -export async function fetchProviders(category: string) { - const res = await fetch(`/api/providers/${category}`); - return res.json(); -} -``` - -Create/update schema from the UI: - -```ts -// lib/schemaClient.ts -import type { CreateSchemaInput, UpdateSchemaInput } from '@tern/normalization'; - -export async function createSchema(input: CreateSchemaInput) { - const res = await fetch('/api/schemas', { method: 'POST', body: JSON.stringify(input) }); - return res.json(); -} - -export async function updateSchema(id: string, updates: UpdateSchemaInput) { - await fetch(`/api/schemas/${id}`, { method: 'PUT', body: JSON.stringify(updates) }); -} - -export async function getSchema(id: string) { - const res = await fetch(`/api/schemas?id=${id}`); - return res.json(); -} -``` - -Preview transformations in the editor: - -```ts -// lib/previewTransform.ts -export async function previewTransform(params: { rawPayload: unknown; provider: string; schemaId: string }) { - const res = await fetch('/api/transform', { - method: 'POST', - body: JSON.stringify(params), - }); - return res.json(); -} -``` - -### Minimal React components - -Template picker: - -```tsx -// components/TemplatePicker.tsx -import React from 'react'; -import { useEffect, useState } from 'react'; -import { fetchTemplates } from '@/hooks/useTemplates'; - -export function TemplatePicker({ onSelect }: { onSelect: (templateId: string) => void }) { - const [templates, setTemplates] = useState([]); - useEffect(() => { - fetchTemplates().then(setTemplates); - }, []); - return ( - - ); -} -``` - -Field mapper row (conceptual): - -```tsx -// components/FieldMapperRow.tsx -import React from 'react'; - -export function FieldMapperRow({ field, mappings, onChange }: { field: any; mappings: any[]; onChange: (m: any) => void }) { - const mapping = mappings.find((m) => m.schemaFieldId === field.id); - return ( -
-
{field.name}
- onChange({ ...mapping, schemaFieldId: field.id, providerPath: e.target.value })} - /> - onChange({ ...mapping, schemaFieldId: field.id, transform: e.target.value })} - /> -
- ); -} -``` - -### End-to-end flow to create a schema - -1. User selects a base template and category -2. UI fetches providers for that category -3. UI renders fields with mapping inputs (per provider) -4. On Save, POST to `/api/schemas` with `CreateSchemaInput` -5. Use `/api/transform` to preview with sample payloads -6. Hook your webhook route to `normalizer.transform` for runtime - -### Types for client payloads - -Import these interfaces in your app: - -```ts -import type { - CreateSchemaInput, - UpdateSchemaInput, - UserSchema, - ProviderMapping, - FieldMapping, -} from '@tern/normalization'; -``` - -### Security notes - -- Use a service role key only on the server (API routes, server actions). Never expose it to the browser. -- Gate schema read/write by authenticated `userId` to prevent cross-tenant access. -- Validate schema via `normalizer.validateSchema` before saving. - -### Performance notes - -- The transform engine is synchronous-per-request and fast; typical overhead is minimal. Cache `getSchema` results per schemaId to avoid repeated database trips. -- Prefer pre-validating schema changes to avoid runtime errors in `transform`. - -### Extending transforms - -- The default DSL supports `toUpperCase`, `toLowerCase`, `toNumber`, `divide:x`, `multiply:x`. -- To add custom transforms, fork and extend the engine or wrap transformed outputs in your route. - - diff --git a/src/normalization/index.ts b/src/normalization/index.ts deleted file mode 100644 index 62a0e80..0000000 --- a/src/normalization/index.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { - BaseTemplate, - CreateSchemaInput, - NormalizedResult, - ProviderInfo, - TemplateCategory, - TransformParams, - UpdateSchemaInput, - UserSchema, -} from './types'; -import { providerRegistry } from './providers/registry'; -import { templateRegistry } from './templates/registry'; -import { StorageAdapter } from './storage/interface'; -import { InMemoryStorageAdapter } from './storage/memory'; -import { NormalizationEngine } from './transformer/engine'; -import { SchemaValidator } from './transformer/validator'; - -export class Normalizer { - private engine: NormalizationEngine; - - constructor( - private readonly storage: StorageAdapter = new InMemoryStorageAdapter(), - ) { - this.engine = new NormalizationEngine(storage, new SchemaValidator()); - } - - async getBaseTemplates(): Promise { - return this.storage.listBaseTemplates(); - } - - async getProviders(category?: TemplateCategory): Promise { - return providerRegistry.list(category); - } - - async createSchema(input: CreateSchemaInput): Promise { - const schema: UserSchema = { - id: generateId(), - userId: input.userId, - baseTemplateId: input.baseTemplateId, - category: input.category, - fields: input.fields, - providerMappings: input.providerMappings, - createdAt: new Date(), - updatedAt: new Date(), - }; - await this.storage.saveSchema(schema); - return schema; - } - - async updateSchema( - schemaId: string, - updates: UpdateSchemaInput, - ): Promise { - await this.storage.updateSchema(schemaId, updates); - } - - async getSchema(id: string): Promise { - return this.storage.getSchema(id); - } - - async transform(params: TransformParams): Promise { - return this.engine.transform(params); - } - - async validateSchema( - schema: UserSchema, - ): Promise<{ valid: boolean; errors: string[] }> { - const base = (await this.storage.getBaseTemplate(schema.baseTemplateId)) - ?? templateRegistry.getById(schema.baseTemplateId); - if (!base) { - return { - valid: false, - errors: [`Base template not found: ${schema.baseTemplateId}`], - }; - } - const validator = new SchemaValidator(); - return validator.validateSchema(schema, base); - } -} - -function generateId(): string { - // Simple non-crypto unique ID generator for framework default - return ( - `sch_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}` - ); -} - -export * from './types'; -export * from './storage/interface'; -export { InMemoryStorageAdapter } from './storage/memory'; diff --git a/src/normalization/providers/payment/paypal.ts b/src/normalization/providers/payment/paypal.ts deleted file mode 100644 index e21debe..0000000 --- a/src/normalization/providers/payment/paypal.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { ProviderMapping } from '../../types'; - -export const paypalDefaultMapping: ProviderMapping = { - provider: 'paypal', - fieldMappings: [ - { schemaFieldId: 'event_type', providerPath: 'event_type' }, - { schemaFieldId: 'amount', providerPath: 'resource.amount.value', transform: 'toNumber' }, - { schemaFieldId: 'currency', providerPath: 'resource.amount.currency_code', transform: 'toUpperCase' }, - { schemaFieldId: 'transaction_id', providerPath: 'resource.id' }, - ], -}; diff --git a/src/normalization/providers/payment/razorpay.ts b/src/normalization/providers/payment/razorpay.ts deleted file mode 100644 index 1e7b03f..0000000 --- a/src/normalization/providers/payment/razorpay.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { ProviderMapping } from '../../types'; - -export const razorpayDefaultMapping: ProviderMapping = { - provider: 'razorpay', - fieldMappings: [ - { schemaFieldId: 'event_type', providerPath: 'event' }, - { schemaFieldId: 'amount', providerPath: 'payload.payment.entity.amount' }, - { schemaFieldId: 'currency', providerPath: 'payload.payment.entity.currency', transform: 'toUpperCase' }, - { schemaFieldId: 'transaction_id', providerPath: 'payload.payment.entity.id' }, - { schemaFieldId: 'customer_id', providerPath: 'payload.payment.entity.contact' }, - ], -}; diff --git a/src/normalization/providers/payment/stripe.ts b/src/normalization/providers/payment/stripe.ts deleted file mode 100644 index d33b39f..0000000 --- a/src/normalization/providers/payment/stripe.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { ProviderMapping } from '../../types'; - -export const stripeDefaultMapping: ProviderMapping = { - provider: 'stripe', - fieldMappings: [ - { schemaFieldId: 'event_type', providerPath: 'type' }, - { schemaFieldId: 'amount', providerPath: 'data.object.amount_received' }, - { schemaFieldId: 'currency', providerPath: 'data.object.currency', transform: 'toUpperCase' }, - { schemaFieldId: 'transaction_id', providerPath: 'data.object.id' }, - { schemaFieldId: 'customer_id', providerPath: 'data.object.customer' }, - ], -}; diff --git a/src/normalization/providers/registry.ts b/src/normalization/providers/registry.ts deleted file mode 100644 index 15b59f3..0000000 --- a/src/normalization/providers/registry.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { ProviderInfo } from '../types'; - -const providers: ProviderInfo[] = [ - { id: 'stripe', name: 'Stripe', category: 'payment' }, - { id: 'razorpay', name: 'Razorpay', category: 'payment' }, - { id: 'paypal', name: 'PayPal', category: 'payment' }, - { id: 'clerk', name: 'Clerk', category: 'auth' }, - { id: 'shopify', name: 'Shopify', category: 'ecommerce' }, - { id: 'woocommerce', name: 'WooCommerce', category: 'ecommerce' }, -]; - -export const providerRegistry = { - list(category?: ProviderInfo['category']): ProviderInfo[] { - if (!category) return providers; - return providers.filter((p) => p.category === category); - }, - getById(id: string): ProviderInfo | undefined { - return providers.find((p) => p.id === id); - }, -}; diff --git a/src/normalization/simple.ts b/src/normalization/simple.ts deleted file mode 100644 index d8711b9..0000000 --- a/src/normalization/simple.ts +++ /dev/null @@ -1,174 +0,0 @@ -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(), - }), - }, - 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/normalization/storage/interface.ts b/src/normalization/storage/interface.ts deleted file mode 100644 index 9d1cbc1..0000000 --- a/src/normalization/storage/interface.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { - BaseTemplate, CreateSchemaInput, UpdateSchemaInput, UserSchema, -} from '../types'; - -export interface StorageAdapter { - saveSchema(schema: UserSchema): Promise; - getSchema(id: string): Promise; - updateSchema(id: string, updates: UpdateSchemaInput): Promise; - deleteSchema(id: string): Promise; - listSchemas(userId: string): Promise; - - getBaseTemplate(id: string): Promise; - listBaseTemplates(): Promise; -} - -export interface NormalizationStorageOptions { - adapter: StorageAdapter; -} diff --git a/src/normalization/storage/memory.ts b/src/normalization/storage/memory.ts deleted file mode 100644 index 37daa72..0000000 --- a/src/normalization/storage/memory.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { - BaseTemplate, CreateSchemaInput, UpdateSchemaInput, UserSchema, -} from '../types'; -import { StorageAdapter } from './interface'; -import { templateRegistry } from '../templates/registry'; - -export class InMemoryStorageAdapter implements StorageAdapter { - private schemas = new Map(); - - async saveSchema(schema: UserSchema): Promise { - this.schemas.set(schema.id, schema); - } - - async getSchema(id: string): Promise { - return this.schemas.get(id) ?? null; - } - - async updateSchema(id: string, updates: UpdateSchemaInput): Promise { - const existing = this.schemas.get(id); - if (!existing) return; - const updated: UserSchema = { - ...existing, - ...updates, - updatedAt: new Date(), - } as UserSchema; - this.schemas.set(id, updated); - } - - async deleteSchema(id: string): Promise { - this.schemas.delete(id); - } - - async listSchemas(userId: string): Promise { - return Array.from(this.schemas.values()).filter((s) => s.userId === userId); - } - - async getBaseTemplate(id: string): Promise { - return templateRegistry.getById(id) ?? null; - } - - async listBaseTemplates(): Promise { - return templateRegistry.listAll(); - } -} diff --git a/src/normalization/templates/base/auth.ts b/src/normalization/templates/base/auth.ts deleted file mode 100644 index 0fe2d86..0000000 --- a/src/normalization/templates/base/auth.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { BaseTemplate } from '../../types'; - -export const authBaseTemplate: BaseTemplate = { - id: 'auth_v1', - category: 'auth', - version: '1.0.0', - fields: [ - { - id: 'event_type', name: 'event_type', type: 'string', required: true, - }, - { - id: 'user_id', name: 'user_id', type: 'string', required: true, - }, - { - id: 'email', name: 'email', type: 'string', required: false, - }, - { - id: 'status', name: 'status', type: 'string', required: true, - }, - ], -}; diff --git a/src/normalization/templates/base/ecommerce.ts b/src/normalization/templates/base/ecommerce.ts deleted file mode 100644 index 61d3428..0000000 --- a/src/normalization/templates/base/ecommerce.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { BaseTemplate } from '../../types'; - -export const ecommerceBaseTemplate: BaseTemplate = { - id: 'ecommerce_v1', - category: 'ecommerce', - version: '1.0.0', - fields: [ - { - id: 'event_type', name: 'event_type', type: 'string', required: true, - }, - { - id: 'order_id', name: 'order_id', type: 'string', required: true, - }, - { - id: 'total', name: 'total', type: 'number', required: true, - }, - { - id: 'currency', name: 'currency', type: 'string', required: true, - }, - { - id: 'customer_id', name: 'customer_id', type: 'string', required: false, - }, - ], -}; diff --git a/src/normalization/templates/base/payment.ts b/src/normalization/templates/base/payment.ts deleted file mode 100644 index f4eeb52..0000000 --- a/src/normalization/templates/base/payment.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { BaseTemplate } from '../../types'; - -export const paymentBaseTemplate: BaseTemplate = { - id: 'payment_v1', - category: 'payment', - version: '1.0.0', - fields: [ - { - id: 'event_type', name: 'event_type', type: 'string', required: true, description: 'Type of payment event', - }, - { - id: 'amount', name: 'amount', type: 'number', required: true, description: 'Amount in the smallest currency unit', - }, - { - id: 'currency', name: 'currency', type: 'string', required: true, description: 'Three-letter currency code', - }, - { - id: 'transaction_id', name: 'transaction_id', type: 'string', required: true, description: 'Unique transaction identifier', - }, - { - id: 'customer_id', name: 'customer_id', type: 'string', required: false, description: 'Customer identifier', - }, - ], -}; diff --git a/src/normalization/templates/registry.ts b/src/normalization/templates/registry.ts deleted file mode 100644 index 46661a6..0000000 --- a/src/normalization/templates/registry.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { BaseTemplate, TemplateCategory } from '../types'; -import { paymentBaseTemplate } from './base/payment'; -import { authBaseTemplate } from './base/auth'; -import { ecommerceBaseTemplate } from './base/ecommerce'; - -const templates: Record = { - [paymentBaseTemplate.id]: paymentBaseTemplate, - [authBaseTemplate.id]: authBaseTemplate, - [ecommerceBaseTemplate.id]: ecommerceBaseTemplate, -}; - -export const templateRegistry = { - getById(id: string): BaseTemplate | undefined { - return templates[id]; - }, - listByCategory(category: TemplateCategory): BaseTemplate[] { - return Object.values(templates).filter((t) => t.category === category); - }, - listAll(): BaseTemplate[] { - return Object.values(templates); - }, -}; diff --git a/src/normalization/transformer/engine.ts b/src/normalization/transformer/engine.ts deleted file mode 100644 index 2fc9f4c..0000000 --- a/src/normalization/transformer/engine.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { NormalizedResult, TransformParams, UserSchema } from '../types'; -import { StorageAdapter } from '../storage/interface'; -import { templateRegistry } from '../templates/registry'; -import { SchemaValidator } from './validator'; - -export class NormalizationEngine { - constructor(private readonly storage: StorageAdapter, private readonly validator = new SchemaValidator()) {} - - async transform(params: TransformParams): Promise { - const { rawPayload, provider, schemaId } = params; - - const schema = await this.storage.getSchema(schemaId); - if (!schema) throw new Error(`Schema not found: ${schemaId}`); - - const baseTemplate = await this.storage.getBaseTemplate(schema.baseTemplateId) || templateRegistry.getById(schema.baseTemplateId); - if (!baseTemplate) throw new Error(`Base template not found: ${schema.baseTemplateId}`); - - const validation = this.validator.validateSchema(schema, baseTemplate); - if (!validation.valid) { - throw new Error(`Invalid schema: ${validation.errors.join('; ')}`); - } - - const providerMapping = schema.providerMappings.find((m) => m.provider === provider); - if (!providerMapping) throw new Error(`No mapping found for provider: ${provider}`); - - const normalized: Record = {}; - - for (const field of schema.fields) { - if (!field.enabled) continue; - const mapping = providerMapping.fieldMappings.find((m) => m.schemaFieldId === field.id); - if (mapping) { - const value = this.extractValue(rawPayload as any, mapping.providerPath); - const finalValue = this.applyTransform(value, mapping.transform); - normalized[field.name] = finalValue ?? field.defaultValue; - } else if (field.required) { - if (field.defaultValue !== undefined) { - normalized[field.name] = field.defaultValue; - } else { - throw new Error(`Required field ${field.name} has no mapping`); - } - } - } - - const outValidation = this.validator.validateOutput(normalized, schema, baseTemplate); - if (!outValidation.valid) { - throw new Error(`Normalized output invalid: ${outValidation.errors.join('; ')}`); - } - - return { - normalized, - meta: { - provider, - schemaId, - schemaVersion: schema.baseTemplateId, - transformedAt: new Date(), - }, - }; - } - - private extractValue(obj: any, path: string): unknown { - if (!path) return undefined; - return path.split('.').reduce((acc, key) => (acc == null ? undefined : acc[key]), obj); - } - - private applyTransform(value: unknown, transform?: string): unknown { - if (transform == null) return value; - if (value == null) return value; - - if (transform === 'toUpperCase') return String(value).toUpperCase(); - if (transform === 'toLowerCase') return String(value).toLowerCase(); - if (transform === 'toNumber') return typeof value === 'number' ? value : Number(value); - if (transform.startsWith('divide:')) { - const denominator = Number(transform.split(':')[1]); - return Number(value) / denominator; - } - if (transform.startsWith('multiply:')) { - const factor = Number(transform.split(':')[1]); - return Number(value) * factor; - } - return value; - } -} diff --git a/src/normalization/transformer/validator.ts b/src/normalization/transformer/validator.ts deleted file mode 100644 index 0123714..0000000 --- a/src/normalization/transformer/validator.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { BaseTemplate, UserSchema } from '../types'; - -export class SchemaValidator { - validateSchema(userSchema: UserSchema, baseTemplate: BaseTemplate): { valid: boolean; errors: string[] } { - const errors: string[] = []; - - // Ensure required base fields exist and are enabled or have defaults - for (const baseField of baseTemplate.fields) { - if (!baseField.required) continue; - const userField = userSchema.fields.find((f) => f.id === baseField.id); - if (!userField) { - errors.push(`Missing required field in schema: ${baseField.id}`); - continue; - } - if (!userField.enabled && baseField.defaultValue === undefined) { - errors.push(`Required field disabled without default: ${baseField.id}`); - } - if (userField.type !== baseField.type) { - errors.push(`Type mismatch for field ${baseField.id}: expected ${baseField.type}, got ${userField.type}`); - } - } - - return { valid: errors.length === 0, errors }; - } - - validateOutput(output: Record, userSchema: UserSchema, baseTemplate: BaseTemplate): { valid: boolean; errors: string[] } { - const errors: string[] = []; - for (const field of userSchema.fields) { - if (!field.enabled) continue; - const value = (output as any)[field.name]; - if (value === undefined) { - if (field.required) errors.push(`Missing required field in output: ${field.name}`); - continue; - } - if (!this.matchesType(value, field.type)) { - errors.push(`Type mismatch for output field ${field.name}`); - } - } - return { valid: errors.length === 0, errors }; - } - - private matchesType(value: unknown, type: UserSchema['fields'][number]['type']): boolean { - if (type === 'number') return typeof value === 'number' && !Number.isNaN(value as number); - if (type === 'string') return typeof value === 'string'; - if (type === 'boolean') return typeof value === 'boolean'; - if (type === 'object') return typeof value === 'object' && value !== null && !Array.isArray(value); - if (type === 'array') return Array.isArray(value); - return true; - } -} diff --git a/src/normalization/types.ts b/src/normalization/types.ts deleted file mode 100644 index 614ec21..0000000 --- a/src/normalization/types.ts +++ /dev/null @@ -1,92 +0,0 @@ -export type TemplateCategory = 'payment' | 'auth' | 'ecommerce'; - -export interface TemplateField { - id: string; - name: string; - type: 'string' | 'number' | 'boolean' | 'object' | 'array'; - required: boolean; - description?: string; - defaultValue?: unknown; -} - -export interface BaseTemplate { - id: string; // e.g., payment_v1 - category: TemplateCategory; - version: string; // semver - fields: TemplateField[]; -} - -export interface UserSchemaField { - id: string; // references BaseTemplate.fields.id or custom - name: string; - type: TemplateField['type']; - required: boolean; - enabled: boolean; - defaultValue?: unknown; -} - -export interface FieldMapping { - schemaFieldId: string; // links to UserSchemaField.id - providerPath: string; // dot-notation path (a.b.c) - transform?: string; // simple DSL e.g., divide:100 -} - -export interface ProviderMapping { - provider: string; // e.g., 'stripe' - fieldMappings: FieldMapping[]; -} - -export interface UserSchema { - id: string; - userId: string; - baseTemplateId: string; - category: TemplateCategory; - fields: UserSchemaField[]; - providerMappings: ProviderMapping[]; - createdAt: Date; - updatedAt: Date; -} - -export interface NormalizedPayloadMeta { - provider: string; - schemaId: string; - schemaVersion: string; // baseTemplateId - transformedAt: Date; -} - -export interface NormalizedResult { - normalized: Record; - meta: NormalizedPayloadMeta; -} - -export interface CreateSchemaInput { - userId: string; - baseTemplateId: string; - category: TemplateCategory; - fields: UserSchemaField[]; - providerMappings: ProviderMapping[]; -} - -export interface UpdateSchemaInput { - fields?: UserSchemaField[]; - providerMappings?: ProviderMapping[]; -} - -export interface ProviderInfoField { - path: string; - type?: TemplateField['type']; - description?: string; -} - -export interface ProviderInfo { - id: string; - name: string; - category: TemplateCategory; - samplePaths?: ProviderInfoField[]; -} - -export interface TransformParams { - rawPayload: unknown; - provider: string; - schemaId: string; -} diff --git a/src/platforms/algorithms.ts b/src/platforms/algorithms.ts index e7e9245..4ea639e 100644 --- a/src/platforms/algorithms.ts +++ b/src/platforms/algorithms.ts @@ -4,6 +4,45 @@ import { SignatureConfig, } from "../types"; +export const STANDARD_WEBHOOKS_BASE = { + algorithm: "hmac-sha256" as const, + headerFormat: "raw" as const, + timestampFormat: "unix" as const, + payloadFormat: "custom" as const, + customConfig: { + signatureFormat: "v1={signature}", + payloadFormat: "{id}.{timestamp}.{body}", + encoding: "base64", + secretEncoding: "base64", + }, +}; + +export function createStandardWebhooksConfig(headers: { + id: string; + timestamp: string; + signature: string; + idAliases?: string[]; + timestampAliases?: string[]; + signatureAliases?: string[]; +}): SignatureConfig { + return { + ...STANDARD_WEBHOOKS_BASE, + headerName: headers.signature, + timestampHeader: headers.timestamp, + customConfig: { + ...STANDARD_WEBHOOKS_BASE.customConfig, + idHeader: headers.id, + ...(headers.idAliases && { idHeaderAliases: headers.idAliases }), + ...(headers.timestampAliases && { + timestampHeaderAliases: headers.timestampAliases, + }), + ...(headers.signatureAliases && { + signatureHeaderAliases: headers.signatureAliases, + }), + }, + }; +} + export const platformAlgorithmConfigs: Record< WebhookPlatform, PlatformAlgorithmConfig @@ -322,6 +361,57 @@ export const platformAlgorithmConfigs: Record< "Sanity webhooks use Stripe-compatible HMAC-SHA256 with base64 encoded signature and plain UTF-8 secret", }, + linear: { + platform: "linear", + signatureConfig: { + algorithm: "hmac-sha256", + headerName: "linear-signature", + headerFormat: "raw", + payloadFormat: "raw", + customConfig: { + replayToleranceMs: 60_000, + }, + }, + description: + "Linear webhooks use HMAC-SHA256 on the raw body with a 60s timestamp replay window", + }, + svix: { + platform: "svix", + signatureConfig: { + algorithm: "hmac-sha256", + headerName: "svix-signature", + headerFormat: "raw", + timestampHeader: "svix-timestamp", + timestampFormat: "unix", + payloadFormat: "custom", + customConfig: { + signatureFormat: "v1={signature}", + payloadFormat: "{id}.{timestamp}.{body}", + encoding: "base64", + secretEncoding: "base64", + idHeader: "svix-id", + idHeaderAliases: ["webhook-id"], + timestampHeaderAliases: ["webhook-timestamp"], + }, + }, + description: "Svix webhooks use HMAC-SHA256 with Standard Webhooks format", + }, + standardwebhooks: { + platform: "standardwebhooks", + signatureConfig: { + ...createStandardWebhooksConfig({ + id: "webhook-id", + timestamp: "webhook-timestamp", + signature: "webhook-signature", + idAliases: ["svix-id"], + timestampAliases: ["svix-timestamp"], + signatureAliases: ["svix-signature"], + }), + }, + description: + "Canonical Standard Webhooks implementation. Works for any platform using v1= HMAC-SHA256 signing regardless of header names.", + }, + custom: { platform: "custom", signatureConfig: { diff --git a/src/test.ts b/src/test.ts index 98bc356..337bf5b 100644 --- a/src/test.ts +++ b/src/test.ts @@ -1,5 +1,6 @@ import { createHmac, createHash, generateKeyPairSync, sign } from 'crypto'; -import { WebhookVerificationService, getPlatformsByCategory } from './index'; +import { WebhookVerificationService } from './index'; +import { platformAlgorithmConfigs } from './platforms/algorithms'; import { normalizeAlertOptions } from './notifications/utils'; import { buildSlackPayload } from './notifications/channels/slack'; import { buildDiscordPayload } from './notifications/channels/discord'; @@ -114,6 +115,12 @@ function createSanitySignature(body: string, secret: string, timestamp: number): return `t=${timestamp},v1=${hmac.digest('base64')}`; } +function createLinearSignature(body: string, secret: string): string { + const hmac = createHmac('sha256', secret); + hmac.update(body); + return hmac.digest('hex'); +} + function createFalPayloadToSign(body: string, requestId: string, userId: string, timestamp: string): string { const bodyHash = createHash('sha256').update(body).digest('hex'); return `${requestId}\n${userId}\n${timestamp}\n${bodyHash}`; @@ -566,61 +573,6 @@ async function runTests() { console.log(' ❌ verifyAny diagnostics 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); - } - // Test 13: Razorpay console.log('\n13. Testing Razorpay webhook...'); try { @@ -992,6 +944,99 @@ async function runTests() { console.log(' ❌ Hono invalid signature test failed:', error); } + // Test 26: Standard Webhooks canonical platform with webhook-* headers + console.log('\n26. Testing standardwebhooks with webhook-* headers...'); + try { + const payload = JSON.stringify({ type: 'invoice.paid' }); + const id = 'msg_standard_001'; + const timestamp = Math.floor(Date.now() / 1000); + const standardSecret = `whsec_${Buffer.from(testSecret).toString('base64')}`; + const signature = createStandardWebhooksSignature(payload, standardSecret, id, timestamp); + const request = createMockRequest({ + 'webhook-id': id, + 'webhook-timestamp': String(timestamp), + 'webhook-signature': `${signature} v1,invalid`, + 'content-type': 'application/json', + }, payload); + + const result = await WebhookVerificationService.verifyWithPlatformConfig( + request, + 'standardwebhooks', + standardSecret, + ); + + console.log(' ✅ standardwebhooks (webhook-*):', trackCheck('standardwebhooks webhook headers', result.isValid, result.error) ? 'PASSED' : 'FAILED'); + } catch (error) { + console.log(' ❌ standardwebhooks webhook-* test failed:', error); + } + + // Test 27: Linear platform verification with replay protection + console.log('\n27. Testing Linear platform verification...'); + try { + const payload = JSON.stringify({ + action: 'Issue', + webhookTimestamp: Date.now(), + }); + const signature = createLinearSignature(payload, testSecret); + const request = createMockRequest({ + 'linear-signature': signature, + 'content-type': 'application/json', + }, payload); + + const result = await WebhookVerificationService.verifyWithPlatformConfig( + request, + 'linear', + testSecret, + ); + + console.log(' ✅ Linear:', trackCheck('linear platform verifier', result.isValid, result.error) ? 'PASSED' : 'FAILED'); + } catch (error) { + console.log(' ❌ Linear platform verifier test failed:', error); + } + + // Test 28: Standard Webhooks canonical platform with svix-* aliases + console.log('\n28. Testing standardwebhooks with svix-* aliases...'); + try { + const id = 'msg_2LJC7S5QfRZk9k9bM2QxWjv1l3U'; + const timestamp = Math.floor(Date.now() / 1000); + const payload = JSON.stringify({ type: 'invoice.paid' }); + const svixSecret = `whsec_${Buffer.from(testSecret).toString('base64')}`; + const signature = createStandardWebhooksSignature(payload, svixSecret, id, timestamp); + + const request = createMockRequest({ + 'svix-id': id, + 'svix-timestamp': String(timestamp), + 'svix-signature': `${signature} v1,invalid`, + 'content-type': 'application/json', + }, payload); + + const result = await WebhookVerificationService.verifyWithPlatformConfig( + request, + 'standardwebhooks', + svixSecret, + ); + + console.log(' ✅ standardwebhooks (svix-* aliases):', trackCheck('standardwebhooks svix aliases', result.isValid, result.error) ? 'PASSED' : 'FAILED'); + } catch (error) { + console.log(' ❌ standardwebhooks svix aliases test failed:', error); + } + + // Test 29: standardwebhooks factory output matches dodopayments shape modulo aliases + console.log('\n29. Testing standardwebhooks structure parity with dodopayments...'); + try { + const { + idHeaderAliases, + timestampHeaderAliases, + signatureHeaderAliases, + ...standardCustomBase + } = platformAlgorithmConfigs.standardwebhooks.signatureConfig.customConfig || {}; + const dodoCustomConfig = platformAlgorithmConfigs.dodopayments.signatureConfig.customConfig || {}; + const pass = JSON.stringify(standardCustomBase) === JSON.stringify(dodoCustomConfig); + console.log(' ✅ standardwebhooks shape parity:', trackCheck('standardwebhooks shape parity', pass) ? 'PASSED' : 'FAILED'); + } catch (error) { + console.log(' ❌ standardwebhooks shape parity test failed:', error); + } + if (failedChecks.length > 0) { throw new Error(`Test checks failed: ${failedChecks.join(', ')}`); } diff --git a/src/types.ts b/src/types.ts index 8af4f33..7f9aada 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,172 +1,86 @@ export type WebhookPlatform = - | 'custom' - | 'clerk' - | 'github' - | 'stripe' - | 'shopify' - | 'vercel' - | 'polar' - | 'dodopayments' - | 'gitlab' - | 'paddle' - | 'razorpay' - | 'lemonsqueezy' - | 'workos' - | 'woocommerce' - | 'replicateai' - | 'falai' - | 'sentry' - | 'grafana' - | 'doppler' - | 'sanity' - | 'unknown'; + | "custom" + | "clerk" + | "svix" + | "github" + | "stripe" + | "shopify" + | "vercel" + | "polar" + | "dodopayments" + | "gitlab" + | "paddle" + | "razorpay" + | "lemonsqueezy" + | "workos" + | "woocommerce" + | "replicateai" + | "falai" + | "sentry" + | "grafana" + | "doppler" + | "sanity" + | "linear" + | "standardwebhooks" + | "unknown"; export enum WebhookPlatformKeys { - GitHub = 'github', - Stripe = 'stripe', - Clerk = 'clerk', - DodoPayments = 'dodopayments', - Shopify = 'shopify', - Vercel = 'vercel', - Polar = 'polar', - GitLab = 'gitlab', - Paddle = 'paddle', - Razorpay = 'razorpay', - LemonSqueezy = 'lemonsqueezy', - WorkOS = 'workos', - WooCommerce = 'woocommerce', - ReplicateAI = 'replicateai', - FalAI = 'falai', - Sentry = 'sentry', - Grafana = 'grafana', - Doppler = 'doppler', - Sanity = 'sanity', - Custom = 'custom', - Unknown = 'unknown' + GitHub = "github", + Stripe = "stripe", + Clerk = "clerk", + Svix = "svix", + DodoPayments = "dodopayments", + Shopify = "shopify", + Vercel = "vercel", + Polar = "polar", + GitLab = "gitlab", + Paddle = "paddle", + Razorpay = "razorpay", + LemonSqueezy = "lemonsqueezy", + WorkOS = "workos", + WooCommerce = "woocommerce", + ReplicateAI = "replicateai", + FalAI = "falai", + Sentry = "sentry", + Grafana = "grafana", + Doppler = "doppler", + Sanity = "sanity", + Linear = "linear", + StandardWebhooks = "standardwebhooks", + Custom = "custom", + Unknown = "unknown", } // Algorithm types for the scalable framework export type SignatureAlgorithm = - | 'hmac-sha256' - | 'hmac-sha1' - | 'hmac-sha512' - | 'rsa-sha256' - | 'ed25519' - | 'custom'; + | "hmac-sha256" + | "hmac-sha1" + | "hmac-sha512" + | "rsa-sha256" + | "ed25519" + | "custom"; export interface SignatureConfig { algorithm: SignatureAlgorithm; headerName: string; - headerFormat?: 'raw' | 'prefixed' | 'comma-separated'; + headerFormat?: "raw" | "prefixed" | "comma-separated"; prefix?: string; // e.g., "sha256=" for GitHub timestampHeader?: string; - timestampFormat?: 'unix' | 'iso' | 'custom'; - payloadFormat?: 'raw' | 'timestamped' | 'json-stringified' | 'custom'; + timestampFormat?: "unix" | "iso" | "custom"; + payloadFormat?: "raw" | "timestamped" | "json-stringified" | "custom"; idHeader?: string; customConfig?: Record; } 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; -} + | "MISSING_SIGNATURE" + | "INVALID_SIGNATURE" + | "TIMESTAMP_EXPIRED" + | "MISSING_TOKEN" + | "INVALID_TOKEN" + | "PLATFORM_NOT_SUPPORTED" + | "NORMALIZATION_ERROR" + | "VERIFICATION_ERROR"; export interface WebhookVerificationResult { isValid: boolean; @@ -188,8 +102,6 @@ export interface WebhookConfig { toleranceInSeconds?: number; // New fields for algorithm-based verification signatureConfig?: SignatureConfig; - // Optional payload normalization - normalize?: boolean | NormalizeOptions; } export interface MultiPlatformSecrets { diff --git a/src/verifiers/algorithms.ts b/src/verifiers/algorithms.ts index 13f8e94..99e31fa 100644 --- a/src/verifiers/algorithms.ts +++ b/src/verifiers/algorithms.ts @@ -36,6 +36,43 @@ export abstract class AlgorithmBasedVerifier extends WebhookVerifier { abstract verify(request: Request): Promise; + protected getMissingSignatureMessage(): string { + return `Missing signature header: ${this.config.headerName}. Ensure your webhook provider sends this header and your adapter forwards it unchanged.`; + } + + protected getMissingTimestampMessage(): string { + const timestampHeader = this.config.timestampHeader || this.config.customConfig?.timestampHeader || 'timestamp'; + return `Missing required timestamp for webhook verification. Verify header '${timestampHeader}' is present and passed through by your framework/proxy.`; + } + + protected getTimestampExpiredMessage(): string { + return 'Webhook timestamp expired. Check server clock drift and increase tolerance only if your provider allows it.'; + } + + protected getInvalidSignatureMessage(): string { + const genericHint = `Invalid signature for ${this.platform}. Confirm webhook secret, raw request body handling, and signature header formatting.`; + + switch (this.platform) { + case 'stripe': + return `${genericHint} Stripe signatures require the exact raw body and Stripe-Signature timestamp/value pair.`; + case 'github': + return `${genericHint} GitHub signatures must include the sha256= prefix from x-hub-signature-256.`; + case 'svix': + case 'standardwebhooks': + case 'clerk': + case 'dodopayments': + case 'replicateai': + case 'polar': + return `${genericHint} Standard Webhooks payload must be signed as id.timestamp.body and secrets may need whsec_ base64 decoding.`; + default: + return genericHint; + } + } + + protected getVerificationErrorMessage(error: Error): string { + return `${this.platform} verification error: ${error.message}. Check webhook secret configuration and ensure your framework preserves raw body + headers.`; + } + protected parseDelimitedHeader(headerValue: string): Record { const parts = headerValue.split(/[;,]/); const values: Record = {}; @@ -57,7 +94,9 @@ export abstract class AlgorithmBasedVerifier extends WebhookVerifier { } protected extractSignatures(request: Request): string[] { - const headerValue = request.headers.get(this.config.headerName); + const headerValue: string | null = request.headers.get(this.config.headerName) + || this.config.customConfig?.signatureHeaderAliases?.map((alias: string) => request.headers.get(alias)).find(Boolean) + || null; if (!headerValue) return []; switch (this.config.headerFormat) { @@ -92,9 +131,33 @@ export abstract class AlgorithmBasedVerifier extends WebhookVerifier { // Accept "v1=" variants used by some providers/docs. if (sig.startsWith("v1=")) { - const [, value] = sig.split("=", 2); - if (value) { - normalized.push(value.trim()); + if (this.config.customConfig?.comparePrefixed) { + for (const fragment of sig.split(',')) { + const candidate = fragment.trim(); + if (candidate.startsWith('v1=')) { + normalized.push(candidate); + } + } + } else { + const [, value] = sig.split("=", 2); + if (value) { + normalized.push(value.trim()); + } + } + continue; + } + + for (const fragment of sig.split(',')) { + const candidate = fragment.trim(); + if (candidate.startsWith('v1=')) { + if (this.config.customConfig?.comparePrefixed) { + normalized.push(candidate); + } else { + const [, value] = candidate.split('=', 2); + if (value) { + normalized.push(value.trim()); + } + } } } } @@ -108,7 +171,9 @@ export abstract class AlgorithmBasedVerifier extends WebhookVerifier { protected extractTimestamp(request: Request): number | null { if (!this.config.timestampHeader) return null; - const timestampHeader = request.headers.get(this.config.timestampHeader); + const timestampHeader = request.headers.get(this.config.timestampHeader) + || this.config.customConfig?.timestampHeaderAliases?.map((alias: string) => request.headers.get(alias)).find(Boolean) + || null; if (!timestampHeader) return null; switch (this.config.timestampFormat) { @@ -193,12 +258,12 @@ export abstract class AlgorithmBasedVerifier extends WebhookVerifier { if (customFormat.includes("{id}") && customFormat.includes("{timestamp}")) { const id = request.headers.get( this.config.customConfig.idHeader || "x-webhook-id", - ); + ) || this.config.customConfig?.idHeaderAliases?.map((alias: string) => request.headers.get(alias)).find(Boolean); const timestamp = request.headers.get( this.config.timestampHeader || this.config.customConfig?.timestampHeader || "x-webhook-timestamp", - ); + ) || this.config.customConfig?.timestampHeaderAliases?.map((alias: string) => request.headers.get(alias)).find(Boolean); // if either is missing payload will be malformed — fail explicitly if (!id || !timestamp) { @@ -219,6 +284,12 @@ export abstract class AlgorithmBasedVerifier extends WebhookVerifier { .replace("{body}", rawBody); } + if (customFormat.includes('{url}')) { + return customFormat + .replace('{url}', request.url) + .replace('{body}', rawBody); + } + if ( customFormat.includes("{timestamp}") && customFormat.includes("{body}") @@ -336,6 +407,29 @@ export abstract class AlgorithmBasedVerifier extends WebhookVerifier { } export class GenericHMACVerifier extends AlgorithmBasedVerifier { + private validateLinearReplayWindow(rawBody: string): string | null { + if (this.platform !== 'linear') return null; + + try { + const parsed = JSON.parse(rawBody) as Record; + const rawTimestamp = parsed.webhookTimestamp; + const timestampMs = Number(rawTimestamp); + + if (!Number.isFinite(timestampMs)) { + return 'Missing or invalid Linear webhookTimestamp'; + } + + const replayToleranceMs = this.config.customConfig?.replayToleranceMs || 60_000; + if (Math.abs(Date.now() - timestampMs) > replayToleranceMs) { + return 'Linear webhook timestamp is outside the replay window'; + } + } catch { + return 'Linear webhook replay check requires JSON payload'; + } + + return null; + } + private resolveSentryPayloadCandidates( rawBody: string, request: Request, @@ -377,7 +471,7 @@ export class GenericHMACVerifier extends AlgorithmBasedVerifier { if (signatures.length === 0) { return { isValid: false, - error: `Missing signature header: ${this.config.headerName}`, + error: this.getMissingSignatureMessage(), errorCode: "MISSING_SIGNATURE", platform: this.platform, }; @@ -385,6 +479,16 @@ export class GenericHMACVerifier extends AlgorithmBasedVerifier { const rawBody = await request.text(); + const linearReplayError = this.validateLinearReplayWindow(rawBody); + if (linearReplayError) { + return { + isValid: false, + error: linearReplayError, + errorCode: 'TIMESTAMP_EXPIRED', + platform: this.platform, + }; + } + let timestamp: number | null = null; if (this.config.headerFormat === "comma-separated") { timestamp = this.extractTimestampFromSignature(request); @@ -395,7 +499,7 @@ export class GenericHMACVerifier extends AlgorithmBasedVerifier { if (this.requiresTimestamp() && !timestamp) { return { isValid: false, - error: 'Missing required timestamp for webhook verification', + error: this.getMissingTimestampMessage(), errorCode: 'MISSING_SIGNATURE', platform: this.platform, }; @@ -404,7 +508,7 @@ export class GenericHMACVerifier extends AlgorithmBasedVerifier { if (timestamp && !this.isTimestampValid(timestamp)) { return { isValid: false, - error: "Webhook timestamp expired", + error: this.getTimestampExpiredMessage(), errorCode: "TIMESTAMP_EXPIRED", platform: this.platform, }; @@ -422,7 +526,7 @@ export class GenericHMACVerifier extends AlgorithmBasedVerifier { for (const signature of signatures) { if (this.config.customConfig?.encoding === "base64") { isValid = this.verifyHMACWithBase64(payload, signature, algorithm); - } else if (this.config.headerFormat === "prefixed") { + } else if (this.config.headerFormat === "prefixed" || this.config.customConfig?.comparePrefixed) { isValid = this.verifyHMACWithPrefix(payload, signature, algorithm); } else { isValid = this.verifyHMAC(payload, signature, algorithm); @@ -441,7 +545,7 @@ export class GenericHMACVerifier extends AlgorithmBasedVerifier { if (!isValid) { return { isValid: false, - error: "Invalid signature", + error: this.getInvalidSignatureMessage(), errorCode: "INVALID_SIGNATURE", platform: this.platform, }; @@ -472,9 +576,7 @@ export class GenericHMACVerifier extends AlgorithmBasedVerifier { } catch (error) { return { isValid: false, - error: `${this.platform} verification error: ${ - (error as Error).message - }`, + error: this.getVerificationErrorMessage(error as Error), errorCode: "VERIFICATION_ERROR", platform: this.platform, }; @@ -608,7 +710,7 @@ export class Ed25519Verifier extends AlgorithmBasedVerifier { if (signatures.length === 0) { return { isValid: false, - error: `Missing signature header: ${this.config.headerName}`, + error: this.getMissingSignatureMessage(), errorCode: "MISSING_SIGNATURE", platform: this.platform, }; @@ -625,7 +727,7 @@ export class Ed25519Verifier extends AlgorithmBasedVerifier { if (!timestampStr) { return { isValid: false, - error: 'Missing required timestamp for webhook verification', + error: this.getMissingTimestampMessage(), errorCode: 'MISSING_SIGNATURE', platform: this.platform, }; @@ -635,7 +737,7 @@ export class Ed25519Verifier extends AlgorithmBasedVerifier { if (!this.isTimestampValid(timestamp)) { return { isValid: false, - error: "Webhook timestamp expired", + error: this.getTimestampExpiredMessage(), errorCode: "TIMESTAMP_EXPIRED", platform: this.platform, }; @@ -709,7 +811,7 @@ export class Ed25519Verifier extends AlgorithmBasedVerifier { if (!isValid) { return { isValid: false, - error: "Invalid signature", + error: this.getInvalidSignatureMessage(), errorCode: "INVALID_SIGNATURE", platform: this.platform, }; @@ -745,9 +847,7 @@ export class Ed25519Verifier extends AlgorithmBasedVerifier { } catch (error) { return { isValid: false, - error: `${this.platform} verification error: ${ - (error as Error).message - }`, + error: this.getVerificationErrorMessage(error as Error), errorCode: "VERIFICATION_ERROR", platform: this.platform, };