diff --git a/README.md b/README.md index c2e43e0..f43441d 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,27 @@ const result = await WebhookVerificationService.verifyAny(request, { console.log(`Verified ${result.platform} webhook`); ``` +### Twilio example + +```typescript +import { WebhookVerificationService } from '@hookflo/tern'; + +export async function POST(request: Request) { + const result = await WebhookVerificationService.verify(request, { + platform: 'twilio', + secret: process.env.TWILIO_AUTH_TOKEN!, + // Optional when behind proxies/CDNs if request.url differs from the public Twilio URL: + twilioBaseUrl: 'https://yourdomain.com/api/webhooks/twilio', + }); + + if (!result.isValid) { + return Response.json({ error: result.error }, { status: 400 }); + } + + return Response.json({ ok: true }); +} +``` + ### Core SDK (runtime-agnostic) Use Tern without framework adapters in any runtime that supports the Web `Request` API. @@ -171,6 +192,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 +213,10 @@ app.post('/webhooks/stripe', createWebhookHandler({ | **Grafana** | HMAC-SHA256 | ✅ Tested | | **Doppler** | HMAC-SHA256 | ✅ Tested | | **Sanity** | HMAC-SHA256 | ✅ Tested | +| **Svix** | HMAC-SHA256 | ⚠️ Untested for now | +| **Linear** | HMAC-SHA256 | ⚠️ Untested for now | +| **PagerDuty** | HMAC-SHA256 | ⚠️ Untested for now | +| **Twilio** | HMAC-SHA1 | ⚠️ Untested for now | | **Razorpay** | HMAC-SHA256 | 🔄 Pending | | **Vercel** | HMAC-SHA256 | 🔄 Pending | @@ -403,6 +431,9 @@ interface WebhookVerificationResult { ## Troubleshooting +- **Twilio invalid signature behind proxies/CDNs**: if your runtime `request.url` differs from the public Twilio webhook URL, pass `twilioBaseUrl` in `WebhookVerificationService.verify(...)` for platform `twilio`. + + **`Module not found: Can't resolve "@hookflo/tern/nextjs"`** ```bash diff --git a/src/adapters/cloudflare.ts b/src/adapters/cloudflare.ts index 331d4c4..2125f18 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,7 @@ export interface CloudflareWebhookHandlerOptions, secret?: string; secretEnv?: string; toleranceInSeconds?: number; - normalize?: boolean | NormalizeOptions; + twilioBaseUrl?: string; queue?: QueueOption; alerts?: AlertConfig; alert?: Omit; @@ -60,12 +60,14 @@ 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, + twilioBaseUrl: options.twilioBaseUrl, + }, ); if (!result.isValid) { diff --git a/src/adapters/express.ts b/src/adapters/express.ts index 8748c94..93721bf 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,7 @@ export interface ExpressWebhookMiddlewareOptions { platform: WebhookPlatform; secret: string; toleranceInSeconds?: number; - normalize?: boolean | NormalizeOptions; + twilioBaseUrl?: string; queue?: QueueOption; alerts?: AlertConfig; alert?: Omit; @@ -88,12 +87,14 @@ 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, + twilioBaseUrl: options.twilioBaseUrl, + }, ); if (!result.isValid) { diff --git a/src/adapters/hono.ts b/src/adapters/hono.ts index 6a786ce..26e8a1a 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,7 @@ export interface HonoWebhookHandlerOptions< platform: WebhookPlatform; secret: string; toleranceInSeconds?: number; - normalize?: boolean | NormalizeOptions; + twilioBaseUrl?: string; queue?: QueueOption; alerts?: AlertConfig; alert?: Omit; @@ -71,12 +71,14 @@ 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, + twilioBaseUrl: options.twilioBaseUrl, + }, ); if (!result.isValid) { diff --git a/src/adapters/nextjs.ts b/src/adapters/nextjs.ts index f8ec0bf..8b1a460 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,7 @@ export interface NextWebhookHandlerOptions; @@ -52,12 +52,14 @@ 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..586a36f 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,23 @@ export class WebhookVerificationService { throw new Error('Signature config is required for algorithm-based verification'); } + const effectiveSignatureConfig: SignatureConfig = { + ...signatureConfig, + customConfig: { + ...(signatureConfig.customConfig || {}), + ...(config.platform === 'twilio' && config.twilioBaseUrl + ? { twilioBaseUrl: config.twilioBaseUrl } + : {}), + }, + }; + // 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 +93,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 +109,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 +119,7 @@ export class WebhookVerificationService { requestClone, detectedPlatform, secrets[detectedPlatform] as string, - toleranceInSeconds, - normalize, + toleranceInSeconds ); } @@ -137,8 +138,7 @@ export class WebhookVerificationService { requestClone, normalizedPlatform, secret as string, - toleranceInSeconds, - normalize, + toleranceInSeconds ); return { @@ -246,6 +246,10 @@ export class WebhookVerificationService { case 'workos': case 'sentry': case 'vercel': + case 'linear': + case 'pagerduty': + case 'twilio': + case 'svix': return this.pickString(payload?.id) || null; case 'doppler': return this.pickString(payload?.event?.id, metadata?.id) || null; @@ -287,7 +291,10 @@ 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('x-pagerduty-signature')) return 'pagerduty'; + if (headers.has('x-twilio-signature')) return 'twilio'; if (headers.has('workos-signature')) return 'workos'; if (headers.has('webhook-signature')) { const userAgent = headers.get('user-agent')?.toLowerCase() || ''; @@ -446,11 +453,6 @@ export { } 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..9e501ba 100644 --- a/src/platforms/algorithms.ts +++ b/src/platforms/algorithms.ts @@ -56,6 +56,28 @@ export const platformAlgorithmConfigs: Record< description: "Clerk webhooks use HMAC-SHA256 with base64 encoding", }, + 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', + }, + dodopayments: { platform: "dodopayments", signatureConfig: { @@ -322,6 +344,53 @@ 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', + }, + + pagerduty: { + platform: 'pagerduty', + signatureConfig: { + algorithm: 'hmac-sha256', + headerName: 'x-pagerduty-signature', + headerFormat: 'raw', + payloadFormat: 'raw', + prefix: 'v1=', + customConfig: { + signatureFormat: 'v1={signature}', + comparePrefixed: true, + }, + }, + description: 'PagerDuty webhooks use HMAC-SHA256 with v1= signatures', + }, + + twilio: { + platform: 'twilio', + signatureConfig: { + algorithm: 'hmac-sha1', + headerName: 'x-twilio-signature', + headerFormat: 'raw', + payloadFormat: 'custom', + customConfig: { + payloadFormat: '{url}', + encoding: 'base64', + secretEncoding: 'utf8', + validateBodySHA256: true, + }, + }, + description: 'Twilio webhooks use HMAC-SHA1 with base64 signatures (URL canonicalization required)', + }, + custom: { platform: "custom", signatureConfig: { diff --git a/src/test.ts b/src/test.ts index 98bc356..7311cd5 100644 --- a/src/test.ts +++ b/src/test.ts @@ -1,5 +1,5 @@ import { createHmac, createHash, generateKeyPairSync, sign } from 'crypto'; -import { WebhookVerificationService, getPlatformsByCategory } from './index'; +import { WebhookVerificationService } from './index'; import { normalizeAlertOptions } from './notifications/utils'; import { buildSlackPayload } from './notifications/channels/slack'; import { buildDiscordPayload } from './notifications/channels/discord'; @@ -114,6 +114,32 @@ function createSanitySignature(body: string, secret: string, timestamp: number): return `t=${timestamp},v1=${hmac.digest('base64')}`; } +function createPagerDutySignature(body: string, secret: string): string { + const hmac = createHmac('sha256', secret); + hmac.update(body); + return `v1=${hmac.digest('hex')}`; +} + +function createLinearSignature(body: string, secret: string): string { + const hmac = createHmac('sha256', secret); + hmac.update(body); + return hmac.digest('hex'); +} + +function createSvixSignature(body: string, secret: string, id: string, timestamp: number): string { + const signedContent = `${id}.${timestamp}.${body}`; + const secretBytes = new Uint8Array(Buffer.from(secret.split('whsec_')[1], 'base64')); + const hmac = createHmac('sha256', secretBytes); + hmac.update(signedContent); + return `v1,${hmac.digest('base64')}`; +} + +function createTwilioSignature(url: string, authToken: string): string { + const hmac = createHmac('sha1', authToken); + hmac.update(url); + return hmac.digest('base64'); +} + 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 +592,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 +963,145 @@ async function runTests() { console.log(' ❌ Hono invalid signature test failed:', error); } + // Test 26: PagerDuty platform verification + console.log('\n26. Testing PagerDuty platform verification...'); + try { + const payload = JSON.stringify({ messages: [{ event: 'incident.triggered' }] }); + const signature = createPagerDutySignature(payload, testSecret); + const request = createMockRequest({ + 'x-pagerduty-signature': `${signature},v1=deadbeef`, + 'content-type': 'application/json', + }, payload); + + const result = await WebhookVerificationService.verifyWithPlatformConfig( + request, + 'pagerduty', + testSecret, + ); + + console.log(' ✅ PagerDuty:', trackCheck('pagerduty platform verifier', result.isValid, result.error) ? 'PASSED' : 'FAILED'); + } catch (error) { + console.log(' ❌ PagerDuty platform verifier 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: Svix platform verification with replay protection + console.log('\n28. Testing Svix platform verification...'); + 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 = createSvixSignature(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, + 'svix', + svixSecret, + ); + + console.log(' ✅ Svix:', trackCheck('svix platform verifier', result.isValid, result.error) ? 'PASSED' : 'FAILED'); + } catch (error) { + console.log(' ❌ Svix platform verifier test failed:', error); + } + + + // Test 29.5: Twilio verification with twilioBaseUrl override + console.log('\n29.5. Testing Twilio verification with twilioBaseUrl override...'); + try { + const payload = JSON.stringify({ messageSid: 'SM123', status: 'delivered' }); + const bodySha256 = createHash('sha256').update(payload).digest('hex'); + const publicUrl = `https://prateekjn.me/api/webhooks/stripe?bodySHA256=${bodySha256}`; + const internalUrl = `http://127.0.0.1:3000/internal/webhook?bodySHA256=${bodySha256}`; + const signature = createTwilioSignature(publicUrl, testSecret); + + const request = new Request(internalUrl, { + method: 'POST', + headers: { + 'x-twilio-signature': signature, + 'content-type': 'application/json', + }, + body: payload, + }); + + const withoutOverride = await WebhookVerificationService.verifyWithPlatformConfig( + request.clone(), + 'twilio', + testSecret, + ); + + const withOverride = await WebhookVerificationService.verify( + request, + { + platform: 'twilio', + secret: testSecret, + twilioBaseUrl: 'https://prateekjn.me/api/webhooks/stripe', + }, + ); + + const pass = !withoutOverride.isValid && withOverride.isValid; + console.log(' ✅ Twilio base URL override:', trackCheck('twilio base url override', pass, withOverride.error || withoutOverride.error) ? 'PASSED' : 'FAILED'); + } catch (error) { + console.log(' ❌ Twilio base URL override test failed:', error); + } + + // Test 29: Twilio platform verification (JSON + bodySHA256) + console.log('\n29. Testing Twilio platform verification...'); + try { + const payload = JSON.stringify({ callSid: 'CA123', status: 'completed' }); + const bodySha256 = createHash('sha256').update(payload).digest('hex'); + const url = `https://example.com/twilio/webhook?bodySHA256=${bodySha256}`; + const signature = createTwilioSignature(url, testSecret); + const request = new Request(url, { + method: 'POST', + headers: { + 'x-twilio-signature': signature, + 'content-type': 'application/json', + }, + body: payload, + }); + + const result = await WebhookVerificationService.verifyWithPlatformConfig( + request, + 'twilio', + testSecret, + ); + + console.log(' ✅ Twilio:', trackCheck('twilio platform verifier', result.isValid, result.error) ? 'PASSED' : 'FAILED'); + } catch (error) { + console.log(' ❌ Twilio platform verifier 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..b012cf3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,7 @@ export type WebhookPlatform = | 'custom' | 'clerk' + | 'svix' | 'github' | 'stripe' | 'shopify' @@ -19,12 +20,16 @@ export type WebhookPlatform = | 'grafana' | 'doppler' | 'sanity' + | 'linear' + | 'pagerduty' + | 'twilio' | 'unknown'; export enum WebhookPlatformKeys { GitHub = 'github', Stripe = 'stripe', Clerk = 'clerk', + Svix = 'svix', DodoPayments = 'dodopayments', Shopify = 'shopify', Vercel = 'vercel', @@ -41,6 +46,9 @@ export enum WebhookPlatformKeys { Grafana = 'grafana', Doppler = 'doppler', Sanity = 'sanity', + Linear = 'linear', + PagerDuty = 'pagerduty', + Twilio = 'twilio', Custom = 'custom', Unknown = 'unknown' } @@ -76,98 +84,6 @@ export type WebhookErrorCode = | '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; @@ -188,8 +104,8 @@ export interface WebhookConfig { toleranceInSeconds?: number; // New fields for algorithm-based verification signatureConfig?: SignatureConfig; - // Optional payload normalization - normalize?: boolean | NormalizeOptions; + // Optional override for Twilio signature URL construction (useful behind proxies/CDNs) + twilioBaseUrl?: string; } export interface MultiPlatformSecrets { diff --git a/src/verifiers/algorithms.ts b/src/verifiers/algorithms.ts index 13f8e94..e59397a 100644 --- a/src/verifiers/algorithms.ts +++ b/src/verifiers/algorithms.ts @@ -36,6 +36,46 @@ 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 'twilio': + return `${genericHint} Twilio also requires the exact public URL used for signing (including query params like bodySHA256). Use twilioBaseUrl if your runtime URL is rewritten behind a proxy.`; + 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 '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.`; + case 'pagerduty': + return `${genericHint} PagerDuty expects v1= signature values from x-pagerduty-signature.`; + 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 = {}; @@ -92,9 +132,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 +172,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) { @@ -142,7 +208,7 @@ export abstract class AlgorithmBasedVerifier extends WebhookVerifier { // These platforms have timestampHeader in config but timestamp // is optional in their spec — validate only if present, never mandate - const optionalTimestampPlatforms = ['vercel', 'sentry', 'grafana']; + const optionalTimestampPlatforms = ['vercel', 'sentry', 'grafana', 'twilio']; if (optionalTimestampPlatforms.includes(this.platform as string)) return false; // For all other platforms: infer from config @@ -161,6 +227,19 @@ export abstract class AlgorithmBasedVerifier extends WebhookVerifier { return false; } + protected resolveTwilioSignatureUrl(request: Request): string { + const overrideBaseUrl = this.config.customConfig?.twilioBaseUrl as string | undefined; + if (!overrideBaseUrl) { + return request.url; + } + + const requestUrl = new URL(request.url); + const baseUrl = new URL(overrideBaseUrl); + baseUrl.search = requestUrl.search; + + return baseUrl.toString(); + } + protected formatPayload(rawBody: string, request: Request): string { switch (this.config.payloadFormat) { case "timestamped": { @@ -193,7 +272,7 @@ 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 || @@ -219,6 +298,12 @@ export abstract class AlgorithmBasedVerifier extends WebhookVerifier { .replace("{body}", rawBody); } + if (customFormat.includes('{url}')) { + return customFormat + .replace('{url}', this.platform === 'twilio' ? this.resolveTwilioSignatureUrl(request) : request.url) + .replace('{body}', rawBody); + } + if ( customFormat.includes("{timestamp}") && customFormat.includes("{body}") @@ -336,6 +421,46 @@ 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 validateTwilioBodyHash(rawBody: string, request: Request): string | null { + if (this.platform !== 'twilio' || !this.config.customConfig?.validateBodySHA256) { + return null; + } + + const url = new URL(this.resolveTwilioSignatureUrl(request)); + const bodySha = url.searchParams.get('bodySHA256'); + if (!bodySha) return null; + + const computed = createHash('sha256').update(rawBody).digest('hex'); + if (!this.safeCompare(computed, bodySha)) { + return 'Twilio bodySHA256 query param does not match payload hash'; + } + + return null; + } + private resolveSentryPayloadCandidates( rawBody: string, request: Request, @@ -377,7 +502,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 +510,26 @@ 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, + }; + } + + const twilioBodyHashError = this.validateTwilioBodyHash(rawBody, request); + if (twilioBodyHashError) { + return { + isValid: false, + error: twilioBodyHashError, + errorCode: 'INVALID_SIGNATURE', + platform: this.platform, + }; + } + let timestamp: number | null = null; if (this.config.headerFormat === "comma-separated") { timestamp = this.extractTimestampFromSignature(request); @@ -395,7 +540,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 +549,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 +567,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 +586,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 +617,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 +751,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 +768,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 +778,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 +852,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 +888,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, };