From 6146eed5d29d63a1f667f3fe12ebd143fbb8c0d1 Mon Sep 17 00:00:00 2001 From: Prateek Jain <49508975+Prateek32177@users.noreply.github.com> Date: Sun, 15 Mar 2026 16:30:01 +0530 Subject: [PATCH 1/9] fix: support twilioBaseUrl override and proxy-aware URL reconstruction --- README.md | 7 ++ src/adapters/shared.ts | 7 +- src/index.ts | 25 +++++- src/platforms/algorithms.ts | 69 +++++++++++++++ src/test.ts | 165 ++++++++++++++++++++++++++++++++++++ src/types.ts | 10 +++ src/verifiers/algorithms.ts | 119 ++++++++++++++++++++++++-- 7 files changed, 389 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index c2e43e0..7b4eec1 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,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 +407,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/shared.ts b/src/adapters/shared.ts index 4d6e590..9861980 100644 --- a/src/adapters/shared.ts +++ b/src/adapters/shared.ts @@ -122,8 +122,11 @@ export function toHeadersInit( export async function toWebRequest( request: MinimalNodeRequest, ): Promise { - 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..b4c0e89 100644 --- a/src/index.ts +++ b/src/index.ts @@ -63,13 +63,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) { @@ -246,6 +256,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 +301,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() || ''; 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..7320d61 100644 --- a/src/test.ts +++ b/src/test.ts @@ -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}`; @@ -992,6 +1018,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..57caa36 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' } @@ -190,6 +198,8 @@ export interface WebhookConfig { 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..75fa215 100644 --- a/src/verifiers/algorithms.ts +++ b/src/verifiers/algorithms.ts @@ -92,9 +92,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 +132,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 +168,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 +187,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 +232,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 +258,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 +381,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, @@ -385,6 +470,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); @@ -422,7 +527,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); From 5ed7572c3c573dd11d36d22f3665b189dcceeead Mon Sep 17 00:00:00 2001 From: Prateek Jain <49508975+Prateek32177@users.noreply.github.com> Date: Sun, 15 Mar 2026 16:43:25 +0530 Subject: [PATCH 2/9] feat: add explanatory signature failure messages across verifiers --- src/verifiers/algorithms.ts | 64 +++++++++++++++++++++++++++++-------- 1 file changed, 50 insertions(+), 14 deletions(-) diff --git a/src/verifiers/algorithms.ts b/src/verifiers/algorithms.ts index 75fa215..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 = {}; @@ -462,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, }; @@ -500,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, }; @@ -509,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, }; @@ -546,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, }; @@ -577,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, }; @@ -713,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, }; @@ -730,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, }; @@ -740,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, }; @@ -814,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, }; @@ -850,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, }; From dc4ff6eb013b07f895f212d28f7155f9c844ca19 Mon Sep 17 00:00:00 2001 From: Prateek Jain <49508975+Prateek32177@users.noreply.github.com> Date: Sun, 15 Mar 2026 16:50:56 +0530 Subject: [PATCH 3/9] refactor: remove normalization support and add Twilio usage docs --- README.md | 24 ++ src/adapters/cloudflare.ts | 4 +- src/adapters/express.ts | 3 - src/adapters/hono.ts | 4 +- src/adapters/nextjs.ts | 4 +- src/index.ts | 25 +- .../NORMALIZATION_INTEGRATION.md | 404 ------------------ src/normalization/index.ts | 90 ---- src/normalization/providers/payment/paypal.ts | 11 - .../providers/payment/razorpay.ts | 12 - src/normalization/providers/payment/stripe.ts | 12 - src/normalization/providers/registry.ts | 20 - src/normalization/simple.ts | 174 -------- src/normalization/storage/interface.ts | 18 - src/normalization/storage/memory.ts | 44 -- src/normalization/templates/base/auth.ts | 21 - src/normalization/templates/base/ecommerce.ts | 24 -- src/normalization/templates/base/payment.ts | 24 -- src/normalization/templates/registry.ts | 22 - src/normalization/transformer/engine.ts | 82 ---- src/normalization/transformer/validator.ts | 50 --- src/normalization/types.ts | 92 ---- src/test.ts | 57 +-- src/types.ts | 94 ---- 24 files changed, 33 insertions(+), 1282 deletions(-) delete mode 100644 src/normalization/NORMALIZATION_INTEGRATION.md delete mode 100644 src/normalization/index.ts delete mode 100644 src/normalization/providers/payment/paypal.ts delete mode 100644 src/normalization/providers/payment/razorpay.ts delete mode 100644 src/normalization/providers/payment/stripe.ts delete mode 100644 src/normalization/providers/registry.ts delete mode 100644 src/normalization/simple.ts delete mode 100644 src/normalization/storage/interface.ts delete mode 100644 src/normalization/storage/memory.ts delete mode 100644 src/normalization/templates/base/auth.ts delete mode 100644 src/normalization/templates/base/ecommerce.ts delete mode 100644 src/normalization/templates/base/payment.ts delete mode 100644 src/normalization/templates/registry.ts delete mode 100644 src/normalization/transformer/engine.ts delete mode 100644 src/normalization/transformer/validator.ts delete mode 100644 src/normalization/types.ts diff --git a/README.md b/README.md index 7b4eec1..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 | diff --git a/src/adapters/cloudflare.ts b/src/adapters/cloudflare.ts index 331d4c4..f31b11b 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; @@ -65,7 +64,6 @@ export function createWebhookHandler, TPayload = options.platform, secret, options.toleranceInSeconds, - options.normalize, ); if (!result.isValid) { diff --git a/src/adapters/express.ts b/src/adapters/express.ts index 8748c94..dfad9b6 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; @@ -93,7 +91,6 @@ export function createWebhookMiddleware( options.platform, options.secret, options.toleranceInSeconds, - options.normalize, ); if (!result.isValid) { diff --git a/src/adapters/hono.ts b/src/adapters/hono.ts index 6a786ce..f92c229 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; @@ -76,7 +75,6 @@ export function createWebhookHandler< options.platform, options.secret, options.toleranceInSeconds, - options.normalize, ); if (!result.isValid) { diff --git a/src/adapters/nextjs.ts b/src/adapters/nextjs.ts index f8ec0bf..18d9de1 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; @@ -57,7 +56,6 @@ export function createWebhookHandler, ) ?? undefined; - if (config.normalize) { - result.payload = normalizePayload(config.platform, result.payload, config.normalize); - } } return result as WebhookVerificationResult; @@ -98,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); @@ -116,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(); @@ -127,8 +119,7 @@ export class WebhookVerificationService { requestClone, detectedPlatform, secrets[detectedPlatform] as string, - toleranceInSeconds, - normalize, + toleranceInSeconds ); } @@ -147,8 +138,7 @@ export class WebhookVerificationService { requestClone, normalizedPlatform, secret as string, - toleranceInSeconds, - normalize, + toleranceInSeconds ); return { @@ -463,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/test.ts b/src/test.ts index 7320d61..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'; @@ -592,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 { diff --git a/src/types.ts b/src/types.ts index 57caa36..b012cf3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -84,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; @@ -196,8 +104,6 @@ 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; } From 37f816399f2f6ec90b8f6c1cdda06f2bb960d999 Mon Sep 17 00:00:00 2001 From: Prateek Jain <49508975+Prateek32177@users.noreply.github.com> Date: Sun, 15 Mar 2026 17:01:18 +0530 Subject: [PATCH 4/9] fix: support twilioBaseUrl across all framework adapters --- src/adapters/cloudflare.ts | 12 ++++++++---- src/adapters/express.ts | 12 ++++++++---- src/adapters/hono.ts | 12 ++++++++---- src/adapters/nextjs.ts | 12 ++++++++---- 4 files changed, 32 insertions(+), 16 deletions(-) diff --git a/src/adapters/cloudflare.ts b/src/adapters/cloudflare.ts index f31b11b..2125f18 100644 --- a/src/adapters/cloudflare.ts +++ b/src/adapters/cloudflare.ts @@ -10,6 +10,7 @@ export interface CloudflareWebhookHandlerOptions, secret?: string; secretEnv?: string; toleranceInSeconds?: number; + twilioBaseUrl?: string; queue?: QueueOption; alerts?: AlertConfig; alert?: Omit; @@ -59,11 +60,14 @@ export function createWebhookHandler, TPayload = return response; } - const result = await WebhookVerificationService.verifyWithPlatformConfig( + const result = await WebhookVerificationService.verify( request, - options.platform, - secret, - options.toleranceInSeconds, + { + 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 dfad9b6..93721bf 100644 --- a/src/adapters/express.ts +++ b/src/adapters/express.ts @@ -24,6 +24,7 @@ export interface ExpressWebhookMiddlewareOptions { platform: WebhookPlatform; secret: string; toleranceInSeconds?: number; + twilioBaseUrl?: string; queue?: QueueOption; alerts?: AlertConfig; alert?: Omit; @@ -86,11 +87,14 @@ export function createWebhookMiddleware( return; } - const result = await WebhookVerificationService.verifyWithPlatformConfig( + const result = await WebhookVerificationService.verify( webRequest, - options.platform, - options.secret, - options.toleranceInSeconds, + { + 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 f92c229..26e8a1a 100644 --- a/src/adapters/hono.ts +++ b/src/adapters/hono.ts @@ -21,6 +21,7 @@ export interface HonoWebhookHandlerOptions< platform: WebhookPlatform; secret: string; toleranceInSeconds?: number; + twilioBaseUrl?: string; queue?: QueueOption; alerts?: AlertConfig; alert?: Omit; @@ -70,11 +71,14 @@ export function createWebhookHandler< return response; } - const result = await WebhookVerificationService.verifyWithPlatformConfig( + const result = await WebhookVerificationService.verify( request, - options.platform, - options.secret, - options.toleranceInSeconds, + { + 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 18d9de1..8b1a460 100644 --- a/src/adapters/nextjs.ts +++ b/src/adapters/nextjs.ts @@ -9,6 +9,7 @@ export interface NextWebhookHandlerOptions; @@ -51,11 +52,14 @@ export function createWebhookHandler Date: Mon, 23 Mar 2026 23:09:18 +0530 Subject: [PATCH 5/9] Add canonical standardwebhooks platform and remove twilio/pagerduty (#54) --- README.md | 66 +++++++----------- src/adapters/cloudflare.ts | 2 - src/adapters/express.ts | 2 - src/adapters/hono.ts | 2 - src/adapters/nextjs.ts | 2 - src/index.ts | 10 +-- src/platforms/algorithms.ts | 75 +++++++++++++-------- src/test.ts | 131 +++++++++--------------------------- src/types.ts | 8 +-- src/verifiers/algorithms.ts | 55 ++------------- 10 files changed, 115 insertions(+), 238 deletions(-) diff --git a/README.md b/README.md index f43441d..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). @@ -79,27 +79,6 @@ 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. @@ -214,9 +193,8 @@ app.post('/webhooks/stripe', createWebhookHandler({ | **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 | -| **PagerDuty** | HMAC-SHA256 | ⚠️ Untested for now | -| **Twilio** | HMAC-SHA1 | ⚠️ Untested for now | | **Razorpay** | HMAC-SHA256 | 🔄 Pending | | **Vercel** | HMAC-SHA256 | 🔄 Pending | @@ -224,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. @@ -365,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. @@ -431,8 +415,6 @@ 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"`** diff --git a/src/adapters/cloudflare.ts b/src/adapters/cloudflare.ts index 2125f18..b565599 100644 --- a/src/adapters/cloudflare.ts +++ b/src/adapters/cloudflare.ts @@ -10,7 +10,6 @@ export interface CloudflareWebhookHandlerOptions, secret?: string; secretEnv?: string; toleranceInSeconds?: number; - twilioBaseUrl?: string; queue?: QueueOption; alerts?: AlertConfig; alert?: Omit; @@ -66,7 +65,6 @@ export function createWebhookHandler, TPayload = platform: options.platform, secret, toleranceInSeconds: options.toleranceInSeconds, - twilioBaseUrl: options.twilioBaseUrl, }, ); diff --git a/src/adapters/express.ts b/src/adapters/express.ts index 93721bf..6b57025 100644 --- a/src/adapters/express.ts +++ b/src/adapters/express.ts @@ -24,7 +24,6 @@ export interface ExpressWebhookMiddlewareOptions { platform: WebhookPlatform; secret: string; toleranceInSeconds?: number; - twilioBaseUrl?: string; queue?: QueueOption; alerts?: AlertConfig; alert?: Omit; @@ -93,7 +92,6 @@ export function createWebhookMiddleware( platform: options.platform, secret: options.secret, toleranceInSeconds: options.toleranceInSeconds, - twilioBaseUrl: options.twilioBaseUrl, }, ); diff --git a/src/adapters/hono.ts b/src/adapters/hono.ts index 26e8a1a..7df5d80 100644 --- a/src/adapters/hono.ts +++ b/src/adapters/hono.ts @@ -21,7 +21,6 @@ export interface HonoWebhookHandlerOptions< platform: WebhookPlatform; secret: string; toleranceInSeconds?: number; - twilioBaseUrl?: string; queue?: QueueOption; alerts?: AlertConfig; alert?: Omit; @@ -77,7 +76,6 @@ export function createWebhookHandler< platform: options.platform, secret: options.secret, toleranceInSeconds: options.toleranceInSeconds, - twilioBaseUrl: options.twilioBaseUrl, }, ); diff --git a/src/adapters/nextjs.ts b/src/adapters/nextjs.ts index 8b1a460..be3ca77 100644 --- a/src/adapters/nextjs.ts +++ b/src/adapters/nextjs.ts @@ -9,7 +9,6 @@ export interface NextWebhookHandlerOptions; @@ -58,7 +57,6 @@ export function createWebhookHandler 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, - }, + ...createStandardWebhooksConfig({ + id: 'webhook-id', + timestamp: 'webhook-timestamp', + signature: 'webhook-signature', + idAliases: ['svix-id'], + timestampAliases: ['svix-timestamp'], + signatureAliases: ['svix-signature'], + }), }, - description: 'Twilio webhooks use HMAC-SHA1 with base64 signatures (URL canonicalization required)', + description: 'Canonical Standard Webhooks implementation. Works for any platform using v1= HMAC-SHA256 signing regardless of header names.', }, custom: { diff --git a/src/test.ts b/src/test.ts index 7311cd5..337bf5b 100644 --- a/src/test.ts +++ b/src/test.ts @@ -1,5 +1,6 @@ import { createHmac, createHash, generateKeyPairSync, sign } from 'crypto'; 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,32 +115,12 @@ 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}`; @@ -963,25 +944,30 @@ async function runTests() { console.log(' ❌ Hono invalid signature test failed:', error); } - // Test 26: PagerDuty platform verification - console.log('\n26. Testing PagerDuty platform verification...'); + // Test 26: Standard Webhooks canonical platform with webhook-* headers + console.log('\n26. Testing standardwebhooks with webhook-* headers...'); try { - const payload = JSON.stringify({ messages: [{ event: 'incident.triggered' }] }); - const signature = createPagerDutySignature(payload, testSecret); + 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({ - 'x-pagerduty-signature': `${signature},v1=deadbeef`, + 'webhook-id': id, + 'webhook-timestamp': String(timestamp), + 'webhook-signature': `${signature} v1,invalid`, 'content-type': 'application/json', }, payload); const result = await WebhookVerificationService.verifyWithPlatformConfig( request, - 'pagerduty', - testSecret, + 'standardwebhooks', + standardSecret, ); - console.log(' ✅ PagerDuty:', trackCheck('pagerduty platform verifier', result.isValid, result.error) ? 'PASSED' : 'FAILED'); + console.log(' ✅ standardwebhooks (webhook-*):', trackCheck('standardwebhooks webhook headers', result.isValid, result.error) ? 'PASSED' : 'FAILED'); } catch (error) { - console.log(' ❌ PagerDuty platform verifier test failed:', error); + console.log(' ❌ standardwebhooks webhook-* test failed:', error); } // Test 27: Linear platform verification with replay protection @@ -1008,14 +994,14 @@ async function runTests() { console.log(' ❌ Linear platform verifier test failed:', error); } - // Test 28: Svix platform verification with replay protection - console.log('\n28. Testing Svix platform verification...'); + // 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 = createSvixSignature(payload, svixSecret, id, timestamp); + const signature = createStandardWebhooksSignature(payload, svixSecret, id, timestamp); const request = createMockRequest({ 'svix-id': id, @@ -1026,80 +1012,29 @@ async function runTests() { const result = await WebhookVerificationService.verifyWithPlatformConfig( request, - 'svix', + 'standardwebhooks', svixSecret, ); - console.log(' ✅ Svix:', trackCheck('svix platform verifier', result.isValid, result.error) ? 'PASSED' : 'FAILED'); + console.log(' ✅ standardwebhooks (svix-* aliases):', trackCheck('standardwebhooks svix aliases', result.isValid, result.error) ? 'PASSED' : 'FAILED'); } catch (error) { - console.log(' ❌ Svix platform verifier test failed:', error); + console.log(' ❌ standardwebhooks svix aliases test failed:', error); } - - // Test 29.5: Twilio verification with twilioBaseUrl override - console.log('\n29.5. Testing Twilio verification with twilioBaseUrl override...'); + // Test 29: standardwebhooks factory output matches dodopayments shape modulo aliases + console.log('\n29. Testing standardwebhooks structure parity with dodopayments...'); 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'); + 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(' ❌ Twilio platform verifier test failed:', error); + console.log(' ❌ standardwebhooks shape parity test failed:', error); } if (failedChecks.length > 0) { diff --git a/src/types.ts b/src/types.ts index b012cf3..0c3340d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -21,8 +21,7 @@ export type WebhookPlatform = | 'doppler' | 'sanity' | 'linear' - | 'pagerduty' - | 'twilio' + | 'standardwebhooks' | 'unknown'; export enum WebhookPlatformKeys { @@ -47,8 +46,7 @@ export enum WebhookPlatformKeys { Doppler = 'doppler', Sanity = 'sanity', Linear = 'linear', - PagerDuty = 'pagerduty', - Twilio = 'twilio', + StandardWebhooks = 'standardwebhooks', Custom = 'custom', Unknown = 'unknown' } @@ -104,8 +102,6 @@ export interface WebhookConfig { toleranceInSeconds?: number; // New fields for algorithm-based verification signatureConfig?: SignatureConfig; - // 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 e59397a..99e31fa 100644 --- a/src/verifiers/algorithms.ts +++ b/src/verifiers/algorithms.ts @@ -53,20 +53,17 @@ export abstract class AlgorithmBasedVerifier extends WebhookVerifier { 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 '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.`; - case 'pagerduty': - return `${genericHint} PagerDuty expects v1= signature values from x-pagerduty-signature.`; default: return genericHint; } @@ -97,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) { @@ -208,7 +207,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', 'twilio']; + const optionalTimestampPlatforms = ['vercel', 'sentry', 'grafana']; if (optionalTimestampPlatforms.includes(this.platform as string)) return false; // For all other platforms: infer from config @@ -227,19 +226,6 @@ 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": { @@ -277,7 +263,7 @@ export abstract class AlgorithmBasedVerifier extends WebhookVerifier { 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) { @@ -300,7 +286,7 @@ export abstract class AlgorithmBasedVerifier extends WebhookVerifier { if (customFormat.includes('{url}')) { return customFormat - .replace('{url}', this.platform === 'twilio' ? this.resolveTwilioSignatureUrl(request) : request.url) + .replace('{url}', request.url) .replace('{body}', rawBody); } @@ -444,23 +430,6 @@ export class GenericHMACVerifier extends AlgorithmBasedVerifier { 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, @@ -520,16 +489,6 @@ export class GenericHMACVerifier extends AlgorithmBasedVerifier { }; } - 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); From 53706b6a9b99e043c87b93a1ff920569da97afcc Mon Sep 17 00:00:00 2001 From: Prateek jain Date: Mon, 23 Mar 2026 22:30:07 +0530 Subject: [PATCH 6/9] 4.3.0-beta.1 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index cdd6809..44b62c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@hookflo/tern", - "version": "4.3.0", + "version": "4.3.0-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@hookflo/tern", - "version": "4.3.0", + "version": "4.3.0-beta.1", "license": "MIT", "dependencies": { "@upstash/qstash": "^2.9.0" diff --git a/package.json b/package.json index 432cd47..68e2eae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hookflo/tern", - "version": "4.3.0", + "version": "4.3.0-beta.1", "description": "A robust, scalable webhook verification framework supporting multiple platforms and signature algorithms", "main": "dist/index.js", "types": "dist/index.d.ts", From 69e0ddc2f301066b14de4a491d8e870d430e2547 Mon Sep 17 00:00:00 2001 From: Prateek jain Date: Mon, 23 Mar 2026 23:11:56 +0530 Subject: [PATCH 7/9] updates on svix --- src/platforms/algorithms.ts | 60 ++++++++++++++----------------------- src/types.ts | 1 - 2 files changed, 22 insertions(+), 39 deletions(-) diff --git a/src/platforms/algorithms.ts b/src/platforms/algorithms.ts index c4999fb..4709ae6 100644 --- a/src/platforms/algorithms.ts +++ b/src/platforms/algorithms.ts @@ -33,8 +33,12 @@ export function createStandardWebhooksConfig(headers: { ...STANDARD_WEBHOOKS_BASE.customConfig, idHeader: headers.id, ...(headers.idAliases && { idHeaderAliases: headers.idAliases }), - ...(headers.timestampAliases && { timestampHeaderAliases: headers.timestampAliases }), - ...(headers.signatureAliases && { signatureHeaderAliases: headers.signatureAliases }), + ...(headers.timestampAliases && { + timestampHeaderAliases: headers.timestampAliases, + }), + ...(headers.signatureAliases && { + signatureHeaderAliases: headers.signatureAliases, + }), }, }; } @@ -91,28 +95,6 @@ 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: { @@ -380,32 +362,34 @@ export const platformAlgorithmConfigs: Record< }, linear: { - platform: 'linear', + platform: "linear", signatureConfig: { - algorithm: 'hmac-sha256', - headerName: 'linear-signature', - headerFormat: 'raw', - payloadFormat: 'raw', + 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', + description: + "Linear webhooks use HMAC-SHA256 on the raw body with a 60s timestamp replay window", }, standardwebhooks: { - platform: 'standardwebhooks', + platform: "standardwebhooks", signatureConfig: { ...createStandardWebhooksConfig({ - id: 'webhook-id', - timestamp: 'webhook-timestamp', - signature: 'webhook-signature', - idAliases: ['svix-id'], - timestampAliases: ['svix-timestamp'], - signatureAliases: ['svix-signature'], + 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.', + description: + "Canonical Standard Webhooks implementation. Works for any platform using v1= HMAC-SHA256 signing regardless of header names.", }, custom: { diff --git a/src/types.ts b/src/types.ts index 0c3340d..7068cdc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,6 @@ export type WebhookPlatform = | 'custom' | 'clerk' - | 'svix' | 'github' | 'stripe' | 'shopify' From 3192f8cfa091449fbbcc3625a9925f59be7de9e6 Mon Sep 17 00:00:00 2001 From: Prateek jain Date: Mon, 23 Mar 2026 23:20:06 +0530 Subject: [PATCH 8/9] audit and version update --- package-lock.json | 10 +++++----- package.json | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 44b62c9..c7942d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@hookflo/tern", - "version": "4.3.0-beta.1", + "version": "4.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@hookflo/tern", - "version": "4.3.0-beta.1", + "version": "4.3.0", "license": "MIT", "dependencies": { "@upstash/qstash": "^2.9.0" @@ -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/package.json b/package.json index 68e2eae..432cd47 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hookflo/tern", - "version": "4.3.0-beta.1", + "version": "4.3.0", "description": "A robust, scalable webhook verification framework supporting multiple platforms and signature algorithms", "main": "dist/index.js", "types": "dist/index.d.ts", From fb2bf42b9c065e7d8953a639860c93f6299c74d6 Mon Sep 17 00:00:00 2001 From: Prateek jain Date: Mon, 23 Mar 2026 23:25:08 +0530 Subject: [PATCH 9/9] svix error foxed --- src/platforms/algorithms.ts | 22 +++++- src/types.ts | 129 ++++++++++++++++++------------------ 2 files changed, 86 insertions(+), 65 deletions(-) diff --git a/src/platforms/algorithms.ts b/src/platforms/algorithms.ts index 4709ae6..4ea639e 100644 --- a/src/platforms/algorithms.ts +++ b/src/platforms/algorithms.ts @@ -375,7 +375,27 @@ export const platformAlgorithmConfigs: Record< 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: { diff --git a/src/types.ts b/src/types.ts index 7068cdc..7f9aada 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,85 +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' - | 'linear' - | 'standardwebhooks' - | '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', - 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' + 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'; + | "MISSING_SIGNATURE" + | "INVALID_SIGNATURE" + | "TIMESTAMP_EXPIRED" + | "MISSING_TOKEN" + | "INVALID_TOKEN" + | "PLATFORM_NOT_SUPPORTED" + | "NORMALIZATION_ERROR" + | "VERIFICATION_ERROR"; export interface WebhookVerificationResult { isValid: boolean;