diff --git a/README.md b/README.md index 368e1bb..9874271 100644 --- a/README.md +++ b/README.md @@ -1,322 +1,108 @@ -# Tern - Algorithm Agnostic Webhook Verification Framework +# Tern β€” Webhook Verification for Every Platform -A robust, algorithm-agnostic webhook verification framework that supports multiple platforms with accurate signature verification and payload retrieval. -The same framework that secures webhook verification at [Hookflo](https://hookflo.com). +**When Stripe, Shopify, Clerk or any other platform sends a webhook to your server, how do you know it's real and not a forged request?** Tern checks the signature for you β€” one simplified TypeScript SDK, any provider, no boilerplate. -⭐ Star this repo to support the project and help others discover it! +[![npm version](https://img.shields.io/npm/v/@hookflo/tern)](https://www.npmjs.com/package/@hookflo/tern) +[![TypeScript](https://img.shields.io/badge/TypeScript-Strict-blue)](https://www.typescriptlang.org/) +[![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. -πŸ’¬ Join the discussion & contribute in our Discord: [Hookflo Community](https://discord.com/invite/SNmCjU97nr) +> 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). ```bash npm install @hookflo/tern ``` -[![npm version](https://img.shields.io/npm/v/@hookflo/tern)](https://www.npmjs.com/package/@hookflo/tern) -[![TypeScript](https://img.shields.io/badge/TypeScript-Strict-blue)](https://www.typescriptlang.org/) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) - -Tern is a zero-dependency TypeScript framework for robust webhook verification across multiple platforms and algorithms. +> The same framework powering webhook verification at [Hookflo](https://hookflo.com). -**Runtime requirements:** Node.js 18+ (or any runtime with Web Crypto + Fetch APIs, such as Deno and Cloudflare Workers). +⭐ Star this repo to help others discover it Β· πŸ’¬ [Join our Discord](https://discord.com/invite/SNmCjU97nr) -tern bird nature +Tern – Webhook Verification Framework -## Features +**Navigation** -- **Algorithm Agnostic**: Decouples platform logic from signature verification β€” verify based on cryptographic algorithm, not hardcoded platform rules. -Supports HMAC-SHA256, HMAC-SHA1, HMAC-SHA512, and custom algorithms +[The Problem](#the-problem) Β· [Quick Start](#quick-start) Β· [Framework Integrations](#framework-integrations) Β· [Supported Platforms](#supported-platforms) Β· [Key Features](#key-features) Β· [Reliable Delivery & Alerting](#reliable-delivery--alerting) Β· [Custom Config](#custom-platform-configuration) Β· [API Reference](#api-reference) Β· [Troubleshooting](#troubleshooting) Β· [Contributing](#contributing) Β· [Support](#support) -- **Platform Specific**: Accurate implementations for **Stripe, GitHub, Clerk**, and other platforms -- **Flexible Configuration**: Custom signature configurations for any webhook format -- **Type Safe**: Full TypeScript support with comprehensive type definitions -- **Framework Agnostic**: Works with Express.js, Next.js, Cloudflare Workers, and more -- **Body-Parser Safe Adapters**: Read raw request bodies correctly to avoid signature mismatch issues -- **Multi-Provider Verification**: Verify and auto-detect across multiple providers with one API -- **Payload Normalization**: Opt-in normalized event shape to reduce provider lock-in -- **Category-aware Migration**: Normalize within provider categories (payment/auth/infrastructure) for safe platform switching -- **Strong Typed Normalized Schemas**: Category types like `PaymentWebhookNormalized` and `AuthWebhookNormalized` for safe migrations -- **Foundational Error Taxonomy**: Stable `errorCode` values (`INVALID_SIGNATURE`, `MISSING_SIGNATURE`, etc.) +## The Problem -## Why Tern? +Every webhook provider has a different signature format. You end up writing β€” and maintaining β€” the same verification boilerplate over and over: -Most webhook verifiers are tightly coupled to specific platforms or hardcoded logic. Tern introduces a flexible, scalable, algorithm-first approach that: - -- Works across all major platforms -- Supports custom signing logic -- Keeps your code clean and modular -- Avoids unnecessary dependencies -- Is written in strict, modern TypeScript +```typescript +// ❌ Without Tern β€” different logic for every provider +const stripeSignature = req.headers['stripe-signature']; +const parts = stripeSignature.split(','); +// ... 30 more lines just for Stripe -## Installation +const githubSignature = req.headers['x-hub-signature-256']; +// ... completely different 20 lines for GitHub +``` -```bash -npm install @hookflo/tern +```typescript +// βœ… With Tern β€” one API for everything +const result = await WebhookVerificationService.verify(request, { + platform: 'stripe', + secret: process.env.STRIPE_WEBHOOK_SECRET!, +}); ``` ## Quick Start -### Basic Usage +### Verify a single platform ```typescript import { WebhookVerificationService } from '@hookflo/tern'; const result = await WebhookVerificationService.verify(request, { platform: 'stripe', - secret: 'whsec_your_stripe_webhook_secret', + secret: process.env.STRIPE_WEBHOOK_SECRET!, toleranceInSeconds: 300, }); if (result.isValid) { - console.log('Webhook verified!', result.eventId, result.payload); + console.log('Verified!', result.eventId, result.payload); } else { - console.log('Verification failed:', result.error); + console.log('Failed:', result.error, result.errorCode); } ``` -### Universal Verification (auto-detect platform) +### Auto-detect platform ```typescript -import { WebhookVerificationService } from '@hookflo/tern'; - const result = await WebhookVerificationService.verifyAny(request, { stripe: process.env.STRIPE_WEBHOOK_SECRET, github: process.env.GITHUB_WEBHOOK_SECRET, clerk: process.env.CLERK_WEBHOOK_SECRET, }); -if (result.isValid) { - console.log(`Verified ${result.platform} webhook`); -} +console.log(`Verified ${result.platform} webhook`); ``` -### Category-aware Payload Normalization +### Core SDK (runtime-agnostic) -### Strongly-Typed Normalized Payloads +Use Tern without framework adapters in any runtime that supports the Web `Request` API. ```typescript -import { - WebhookVerificationService, - PaymentWebhookNormalized, -} from '@hookflo/tern'; +import { WebhookVerificationService } from '@hookflo/tern'; -const result = await WebhookVerificationService.verifyWithPlatformConfig( +const verified = await WebhookVerificationService.verifyWithPlatformConfig( request, - 'stripe', - process.env.STRIPE_WEBHOOK_SECRET!, + 'workos', + process.env.WORKOS_WEBHOOK_SECRET!, 300, - { enabled: true, category: 'payment' }, ); -if (result.isValid && result.payload?.event === 'payment.succeeded') { - // result.payload is strongly typed - console.log(result.payload.amount, result.payload.customer_id); +if (!verified.isValid) { + return new Response(JSON.stringify({ error: verified.error }), { status: 400 }); } -``` - -```typescript -import { WebhookVerificationService, getPlatformsByCategory } from '@hookflo/tern'; - -// Discover migration-compatible providers in the same category -const paymentPlatforms = getPlatformsByCategory('payment'); -// ['stripe', 'polar', ...] -const result = await WebhookVerificationService.verifyWithPlatformConfig( - request, - 'stripe', - process.env.STRIPE_WEBHOOK_SECRET!, - 300, - { - enabled: true, - category: 'payment', - includeRaw: true, - }, -); - -console.log(result.payload); -// { -// event: 'payment.succeeded', -// amount: 5000, -// currency: 'USD', -// customer_id: 'cus_123', -// transaction_id: 'pi_123', -// provider: 'stripe', -// category: 'payment', -// _raw: {...} -// } +// verified.payload + verified.metadata available here ``` -### Platform-Specific Configurations - -```typescript -import { WebhookVerificationService } from '@hookflo/tern'; - -// Stripe webhook -const stripeConfig = { - platform: 'stripe', - secret: 'whsec_your_stripe_webhook_secret', - toleranceInSeconds: 300, -}; - -// GitHub webhook -const githubConfig = { - platform: 'github', - secret: 'your_github_webhook_secret', - toleranceInSeconds: 300, -}; - -// Clerk webhook -const clerkConfig = { - platform: 'clerk', - secret: 'whsec_your_clerk_webhook_secret', - toleranceInSeconds: 300, -}; +## Framework Integrations -const result = await WebhookVerificationService.verify(request, stripeConfig); -``` - -## Supported Platforms - -### Stripe OK Tested -- **Signature Format**: `t={timestamp},v1={signature}` -- **Algorithm**: HMAC-SHA256 -- **Payload Format**: `{timestamp}.{body}` - -### GitHub -- **Signature Format**: `sha256={signature}` -- **Algorithm**: HMAC-SHA256 -- **Payload Format**: Raw body - -### Clerk -- **Signature Format**: `v1,{signature}` (space-separated) -- **Algorithm**: HMAC-SHA256 with base64 encoding -- **Payload Format**: `{id}.{timestamp}.{body}` - -### Other Platforms -- **Dodo Payments**: HMAC-SHA256 OK Tested -- **Paddle**: HMAC-SHA256 OK Tested -- **Razorpay**: HMAC-SHA256 Pending -- **Lemon Squeezy**: HMAC-SHA256 OK Tested -- **WorkOS**: HMAC-SHA256 (`workos-signature`, `t/v1`) OK Tested -- **WooCommerce**: HMAC-SHA256 (base64 signature) Pending -- **ReplicateAI**: HMAC-SHA256 (Standard Webhooks style) OK Tested -- **Sentry**: HMAC-SHA256 (`sentry-hook-signature`) with JSON-stringified payload + issue-alert fallback -- **Grafana (v12+)**: HMAC-SHA256 (`x-grafana-alerting-signature`) with optional timestamped payload -- **Doppler**: HMAC-SHA256 (`x-doppler-signature`, `sha256=` prefix) -- **Sanity**: Stripe-compatible HMAC-SHA256 (`sanity-webhook-signature`, `t=/v1=`) -- **fal.ai**: ED25519 (`x-fal-webhook-signature`) -- **Shopify**: HMAC-SHA256 (base64 signature) OK Tested -- **Vercel**: HMAC-SHA256 Pending -- **Polar**: HMAC-SHA256 OK Tested -- **GitLab**: Token-based authentication OK Tested - -## Custom Platform Configuration - -This framework is fully configuration-driven. `timestampHeader` is optional and only needed for providers that send timestamp separately from the signature. You can verify webhooks from any providerβ€”even if it is not built-inβ€”by supplying a custom configuration object. This allows you to support new or proprietary platforms instantly, without waiting for a library update. - -### Example: Standard HMAC-SHA256 Webhook - -```typescript -import { WebhookVerificationService } from '@hookflo/tern'; - -const acmeConfig = { - platform: 'acmepay', - secret: 'acme_secret', - signatureConfig: { - algorithm: 'hmac-sha256', - headerName: 'x-acme-signature', - headerFormat: 'raw', - // Optional: only include when provider sends timestamp in a separate header - timestampHeader: 'x-acme-timestamp', - timestampFormat: 'unix', - payloadFormat: 'timestamped', // signs as {timestamp}.{body} - } -}; - -const result = await WebhookVerificationService.verify(request, acmeConfig); -``` - -### Example: Svix/Standard Webhooks (Clerk, Dodo Payments, etc.) - -```typescript -const svixConfig = { - platform: 'my-svix-platform', - secret: 'whsec_abc123...', - signatureConfig: { - algorithm: 'hmac-sha256', - headerName: 'webhook-signature', - headerFormat: 'raw', - timestampHeader: 'webhook-timestamp', - timestampFormat: 'unix', - payloadFormat: 'custom', - customConfig: { - payloadFormat: '{id}.{timestamp}.{body}', - idHeader: 'webhook-id', - // encoding: 'base64' // only if the provider uses base64, otherwise omit - } - } -}; - -const result = await WebhookVerificationService.verify(request, svixConfig); -``` - -You can configure any combination of algorithm, header, payload, and encoding. See the `SignatureConfig` type for all options. - -For `platform: 'custom'`, default config remains compatible with token-style providers through `signatureConfig.customConfig` (`type: 'token-based'`, `idHeader: 'x-webhook-id'`), and you can override it per provider. - -## Verified Platforms (continuously tested) -- **Stripe** -- **GitHub** -- **Clerk** -- **Dodo Payments** -- **GitLab** -- **WorkOS** -- **Lemon Squeezy** -- **Paddle** -- **Shopify** -- **Polar** -- **ReplicateAI** - -Other listed platforms are supported but may have lighter coverage depending on release cycle. - - -## Custom Configurations - -### Custom HMAC-SHA256 - -```typescript -const customConfig = { - platform: 'custom', - secret: 'your_custom_secret', - signatureConfig: { - algorithm: 'hmac-sha256', - headerName: 'x-custom-signature', - headerFormat: 'prefixed', - prefix: 'sha256=', - payloadFormat: 'raw', - }, -}; -``` - -### Custom Timestamped Payload - -```typescript -const timestampedConfig = { - platform: 'custom', - secret: 'your_custom_secret', - signatureConfig: { - algorithm: 'hmac-sha256', - headerName: 'x-webhook-signature', - headerFormat: 'raw', - timestampHeader: 'x-webhook-timestamp', - timestampFormat: 'unix', - payloadFormat: 'timestamped', - }, -}; -``` - -## Framework Integration - -### Express.js middleware (body-parser safe) +### Express.js ```typescript import express from 'express'; @@ -326,14 +112,14 @@ const app = express(); app.post( '/webhooks/stripe', + express.raw({ type: '*/*' }), createWebhookMiddleware({ platform: 'stripe', secret: process.env.STRIPE_WEBHOOK_SECRET!, - normalize: true, }), (req, res) => { - const event = (req as any).webhook.payload; - res.json({ received: true, event: event.event }); + const event = (req as any).webhook?.payload; + res.json({ received: true, event }); }, ); ``` @@ -346,7 +132,7 @@ import { createWebhookHandler } from '@hookflo/tern/nextjs'; export const POST = createWebhookHandler({ platform: 'github', secret: process.env.GITHUB_WEBHOOK_SECRET!, - handler: async (payload) => ({ received: true, event: payload.event ?? payload.type }), + handler: async (payload, metadata) => ({ received: true, delivery: metadata.delivery }), }); ``` @@ -355,250 +141,282 @@ export const POST = createWebhookHandler({ ```typescript import { createWebhookHandler } from '@hookflo/tern/cloudflare'; -const handleStripe = createWebhookHandler({ +export const onRequestPost = createWebhookHandler({ platform: 'stripe', secretEnv: 'STRIPE_WEBHOOK_SECRET', - handler: async (payload) => ({ received: true, event: payload.event ?? payload.type }), + handler: async (payload) => ({ received: true, payload }), }); - -export default { - async fetch(request: Request, env: Record) { - if (new URL(request.url).pathname === '/webhooks/stripe') { - return handleStripe(request, env); - } - return new Response('Not Found', { status: 404 }); - }, -}; ``` +> All built-in platforms work across Express, Next.js, and Cloudflare adapters. You only change `platform` and `secret` per route. -### Are new platforms available in framework middlewares automatically? - -Yes. All built-in platforms are available in: -- `createWebhookMiddleware` (`@hookflo/tern/express`) -- `createWebhookHandler` (`@hookflo/tern/nextjs`) -- `createWebhookHandler` (`@hookflo/tern/cloudflare`) - -You only change `platform` and `secret` per route. +## Supported Platforms -### Platform route examples (Express / Next.js / Cloudflare) +| Platform | Algorithm | Status | +|---|---|---| +| **Stripe** | HMAC-SHA256 | βœ… Tested | +| **GitHub** | HMAC-SHA256 | βœ… Tested | +| **Clerk** | HMAC-SHA256 (base64) | βœ… Tested | +| **Shopify** | HMAC-SHA256 (base64) | βœ… Tested | +| **Dodo Payments** | HMAC-SHA256 | βœ… Tested | +| **Paddle** | HMAC-SHA256 | βœ… Tested | +| **Lemon Squeezy** | HMAC-SHA256 | βœ… Tested | +| **Polar** | HMAC-SHA256 | βœ… Tested | +| **WorkOS** | HMAC-SHA256 | βœ… Tested | +| **ReplicateAI** | HMAC-SHA256 | βœ… Tested | +| **GitLab** | Token-based | βœ… Tested | +| **fal.ai** | ED25519 | βœ… Tested | +| **Sentry** | HMAC-SHA256 | βœ… Tested | +| **Grafana** | HMAC-SHA256 | βœ… Tested | +| **Doppler** | HMAC-SHA256 | βœ… Tested | +| **Sanity** | HMAC-SHA256 | βœ… Tested | +| **Razorpay** | HMAC-SHA256 | πŸ”„ Pending | +| **Vercel** | HMAC-SHA256 | πŸ”„ Pending | + +> Don't see your platform? [Use custom config](#custom-platform-configuration) or [open an issue](https://github.com/Hookflo/tern/issues). + +### Platform signature notes + +- **Standard Webhooks style** platforms (Clerk, Dodo Payments, Polar, ReplicateAI) 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. + +### Note on fal.ai + +fal.ai uses **ED25519** signing. Pass an **empty string** as the webhook secret β€” the public key is resolved automatically via JWKS from fal's infrastructure. ```typescript -// Express (Razorpay) -app.post('/webhooks/razorpay', createWebhookMiddleware({ - platform: 'razorpay', - secret: process.env.RAZORPAY_WEBHOOK_SECRET!, -}), (req, res) => res.json({ ok: true })); - -// Next.js (WorkOS) -export const POST = createWebhookHandler({ - platform: 'workos', - secret: process.env.WORKOS_WEBHOOK_SECRET!, - handler: async (payload) => ({ received: true, type: payload.type }), -}); - -// Cloudflare (Lemon Squeezy) -const handleLemonSqueezy = createWebhookHandler({ - platform: 'lemonsqueezy', - secretEnv: 'LEMON_SQUEEZY_WEBHOOK_SECRET', - handler: async () => ({ received: true }), -}); -``` - -### fal.ai production usage - -fal.ai uses **ED25519** (`x-fal-webhook-signature`) and signs: -`{request-id}.{user-id}.{timestamp}.{sha256(body)}`. - -Use one of these strategies: -1. **Public key as `secret`** (recommended for framework adapters). -2. **JWKS auto-resolution** via the built-in fal.ai config (`x-fal-webhook-key-id` + fal JWKS URL). +import { createWebhookHandler } from '@hookflo/tern/nextjs'; -```typescript -// Next.js with explicit public key PEM as secret export const POST = createWebhookHandler({ platform: 'falai', - secret: process.env.FAL_PUBLIC_KEY_PEM!, + secret: '', // fal.ai resolves the public key automatically handler: async (payload, metadata) => ({ received: true, requestId: metadata.requestId }), }); ``` -## API Reference - -### WebhookVerificationService +## Key Features -#### `verify(request: Request, config: WebhookConfig): Promise` +- **Queue + Retry Support** β€” optional Upstash QStash-based reliable inbound webhook delivery with automatic retries and deduplication +- **DLQ + Replay Controls** β€” list failed events, replay DLQ messages, and trigger replay-aware alerts +- **Alerting** β€” built-in Slack + Discord alerts through adapters and controls +- **Auto Platform Detection** β€” detect and verify across multiple providers via `verifyAny` with diagnostics on failure +- **Algorithm Agnostic** β€” HMAC-SHA256, HMAC-SHA1, HMAC-SHA512, ED25519, and custom algorithms +- **Zero Dependencies** β€” no bloat, no supply chain risk +- **Framework Agnostic** β€” works with Express, Next.js, Cloudflare Workers, Deno, and any runtime with Web Crypto +- **Body-Parser Safe** β€” reads raw bodies correctly to prevent signature mismatch +- **Strong TypeScript** β€” strict types, full inference, comprehensive type definitions +- **Stable Error Codes** β€” `INVALID_SIGNATURE`, `MISSING_SIGNATURE`, `TIMESTAMP_EXPIRED`, and more -Verifies a webhook using the provided configuration. +## Reliable Delivery & Alerting -#### `verifyWithPlatformConfig(request: Request, platform: WebhookPlatform, secret: string, toleranceInSeconds?: number, normalize?: boolean | NormalizeOptions): Promise` +Tern supports both immediate and queue-based webhook processing. Queue mode is **optional and opt-in** β€” bring your own Upstash account (BYOK). -Simplified verification using platform-specific configurations with optional payload normalization. +### Non-queue mode (default) -#### `verifyAny(request: Request, secrets: Record, toleranceInSeconds?: number, normalize?: boolean | NormalizeOptions): Promise` - -Auto-detects platform from headers and verifies against one or more provider secrets. +```typescript +import { createWebhookHandler } from '@hookflo/tern/nextjs'; -#### `verifyTokenAuth(request: Request, webhookId: string, webhookToken: string): Promise` +export const POST = createWebhookHandler({ + platform: 'stripe', + secret: process.env.STRIPE_WEBHOOK_SECRET!, + handler: async (payload) => { + return { ok: true }; + }, +}); +``` -Verifies token-based webhooks. +### Queue mode (opt-in) -> `verifyTokenBased(...)` remains available as a backward-compatible alias and still works for existing integrations. +```typescript +import { createWebhookHandler } from '@hookflo/tern/nextjs'; -#### `getPlatformsByCategory(category: 'payment' | 'auth' | 'ecommerce' | 'infrastructure'): WebhookPlatform[]` +export const POST = createWebhookHandler({ + platform: 'stripe', + secret: process.env.STRIPE_WEBHOOK_SECRET!, + queue: true, + handler: async (payload, metadata) => { + return { processed: true, eventId: metadata.id }; + }, +}); +``` -Returns built-in providers that normalize into a shared schema for the given migration category. +### Upstash Queue Setup -### Types +1. Create a QStash project at [console.upstash.com/qstash](https://console.upstash.com/qstash) +2. Copy your keys: `QSTASH_TOKEN`, `QSTASH_CURRENT_SIGNING_KEY`, `QSTASH_NEXT_SIGNING_KEY` +3. Add them to your environment and set `queue: true` +4. Enable queue with `queue: true` (or explicit queue config). -#### `WebhookVerificationResult` +Direct queue config option: ```typescript -interface WebhookVerificationResult { - isValid: boolean; - error?: string; - errorCode?: WebhookErrorCode; - platform: WebhookPlatform; - payload?: any; - eventId?: string; // canonical ID, e.g. 'stripe:evt_123' - metadata?: { - timestamp?: string; - id?: string | null; // raw provider ID (legacy) - [key: string]: any; - }; +queue: { + token: process.env.QSTASH_TOKEN!, + signingKey: process.env.QSTASH_CURRENT_SIGNING_KEY!, + nextSigningKey: process.env.QSTASH_NEXT_SIGNING_KEY!, + retries: 5, } ``` -#### `WebhookConfig` +### Simple alerting ```typescript -interface WebhookConfig { - platform: WebhookPlatform; - secret: string; - toleranceInSeconds?: number; - signatureConfig?: SignatureConfig; - normalize?: boolean | NormalizeOptions; -} -``` - -## Testing - -### Run All Tests - -```bash -npm test -``` - -### Platform-Specific Testing - -```bash -# Test a specific platform -npm run test:platform stripe +import { createWebhookHandler } from '@hookflo/tern/nextjs'; -# Test all platforms -npm run test:all +export const POST = createWebhookHandler({ + platform: 'stripe', + secret: process.env.STRIPE_WEBHOOK_SECRET!, + alerts: { + slack: { webhookUrl: process.env.SLACK_WEBHOOK_URL! }, + discord: { webhookUrl: process.env.DISCORD_WEBHOOK_URL! }, + }, + handler: async () => ({ ok: true }), +}); ``` -### Documentation and Analysis +### DLQ-aware alerting and replay -```bash -# Fetch platform documentation -npm run docs:fetch +```typescript +import { createTernControls } from '@hookflo/tern/upstash'; -# Generate diffs between versions -npm run docs:diff +const controls = createTernControls({ + token: process.env.QSTASH_TOKEN!, + notifications: { + slackWebhookUrl: process.env.SLACK_WEBHOOK_URL, + discordWebhookUrl: process.env.DISCORD_WEBHOOK_URL, + }, +}); -# Analyze changes and generate reports -npm run docs:analyze +const dlqMessages = await controls.dlq(); +if (dlqMessages.length > 0) { + await controls.alert({ + dlq: true, + dlqId: dlqMessages[0].dlqId, + severity: 'warning', + message: 'Replay attempted for failed event', + }); +} ``` -## Examples - -See the [examples.ts](./src/examples.ts) file for comprehensive usage examples. - -## Contributing - -We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for detailed information on how to: +## Custom Platform Configuration -- Set up your development environment -- Add new platforms -- Write tests -- Submit pull requests -- Follow our code style guidelines +Not built-in? Configure any webhook provider without waiting for a library update. -### Quick Start for Contributors +```typescript +const result = await WebhookVerificationService.verify(request, { + platform: 'acmepay', + secret: 'acme_secret', + signatureConfig: { + algorithm: 'hmac-sha256', + headerName: 'x-acme-signature', + headerFormat: 'raw', + timestampHeader: 'x-acme-timestamp', + timestampFormat: 'unix', + payloadFormat: 'timestamped', + }, +}); +``` -1. Fork the repository -2. Clone your fork: `git clone https://github.com/your-username/tern.git` -3. Create a feature branch: `git checkout -b feature/your-feature-name` -4. Make your changes -5. Run tests: `npm test` -6. Submit a pull request +### Svix / Standard Webhooks format (Clerk, Dodo Payments, ReplicateAI, etc.) -### Adding a New Platform +```typescript +const svixConfig = { + platform: 'my-svix-platform', + secret: 'whsec_abc123...', + signatureConfig: { + algorithm: 'hmac-sha256', + headerName: 'webhook-signature', + headerFormat: 'raw', + timestampHeader: 'webhook-timestamp', + timestampFormat: 'unix', + payloadFormat: 'custom', + customConfig: { + payloadFormat: '{id}.{timestamp}.{body}', + idHeader: 'webhook-id', + }, + }, +}; +``` -See our [Platform Development Guide](CONTRIBUTING.md#adding-new-platforms) for step-by-step instructions on adding support for new webhook platforms. +See the [SignatureConfig type](https://tern.hookflo.com) for all options. -## Code of Conduct +## API Reference -This project adheres to our [Code of Conduct](CODE_OF_CONDUCT.md). Please read it before contributing. +### `WebhookVerificationService` -## πŸ“„ License +| Method | Description | +|---|---| +| `verify(request, config)` | Verify with full config object | +| `verifyWithPlatformConfig(request, platform, secret, tolerance?)` | Shorthand for built-in platforms | +| `verifyAny(request, secrets, tolerance?)` | Auto-detect platform and verify | +| `verifyTokenAuth(request, webhookId, webhookToken)` | Token-based verification | +| `verifyTokenBased(request, webhookId, webhookToken)` | Alias for `verifyTokenAuth` | +| `handleWithQueue(request, options)` | Core SDK helper for queue receive/process | -MIT License - see [LICENSE](./LICENSE) for details. +### `@hookflo/tern/upstash` -## πŸ”— Links +| Export | Description | +|---|---| +| `createTernControls(config)` | Read DLQ/events, replay, and send alerts | +| `handleQueuedRequest(request, options)` | Route request between receive/process modes | +| `handleReceive(request, platform, secret, queueConfig, tolerance)` | Verify webhook and enqueue to QStash | +| `handleProcess(request, handler, queueConfig)` | Verify QStash signature and process payload | +| `resolveQueueConfig(queue)` | Resolve `queue: true` from env or explicit object | -- [Documentation](./USAGE.md) -- [Framework Summary](./FRAMEWORK_SUMMARY.md) -- [Architecture Guide](./ARCHITECTURE.md) -- [Issues](https://github.com/Hookflo/tern/issues) +### `WebhookVerificationResult` +```typescript +interface WebhookVerificationResult { + isValid: boolean; + error?: string; + errorCode?: string; + platform: WebhookPlatform; + payload?: any; + eventId?: string; + metadata?: { + timestamp?: string; + id?: string | null; + [key: string]: any; + }; +} +``` ## Troubleshooting -### `Module not found: Can't resolve "@hookflo/tern/nextjs"` - -If this happens in a Next.js project, it usually means one of these: - -1. You installed an older published package version that does not include subpath exports yet. -2. Lockfile still points to an old tarball/version. -3. `node_modules` cache is stale after upgrading. - -Fix steps: +**`Module not found: Can't resolve "@hookflo/tern/nextjs"`** ```bash -# in your Next.js app npm i @hookflo/tern@latest rm -rf node_modules package-lock.json .next npm i ``` -Then verify resolution: +**Signature verification failing?** + +Make sure you're passing the **raw** request body β€” not a parsed JSON object. Tern's framework adapters handle this automatically. If you're using the core service directly, ensure body parsers aren't consuming the stream before Tern does. + +## Contributing + +Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for how to add platforms, write tests, and submit PRs. ```bash -node -e "console.log(require.resolve('@hookflo/tern/nextjs'))" +git clone https://github.com/Hookflo/tern.git +cd tern +npm install +npm test ``` -If you are testing this repo locally before publish: +## Support -```bash -# inside /workspace/tern -npm run build -npm pack +Have a question, running into an issue, or want to request a platform? We're happy to help. -# inside your other project -npm i /path/to/hookflo-tern-.tgz -``` +Join the conversation on [Discord](https://discord.com/invite/SNmCjU97nr) or [open an issue](https://github.com/Hookflo/tern/issues) on GitHub β€” all questions, bug reports, and platform requests are welcome. -Minimal Next.js App Router usage: +## Links -```ts -import { createWebhookHandler } from '@hookflo/tern/nextjs'; +[Detailed Usage & Docs](https://tern.hookflo.com) Β· [npm Package](https://www.npmjs.com/package/@hookflo/tern) Β· [Discord Community](https://discord.com/invite/SNmCjU97nr) Β· [Issues](https://github.com/Hookflo/tern/issues) -export const POST = createWebhookHandler({ - platform: 'stripe', - secret: process.env.STRIPE_WEBHOOK_SECRET!, - handler: async (payload) => ({ received: true, event: payload.event ?? payload.type }), -}); -``` +## License + +MIT Β© [Hookflo](https://hookflo.com) \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 6226de3..9180059 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@hookflo/tern", - "version": "4.1.1", + "version": "4.2.9-beta", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@hookflo/tern", - "version": "4.1.1", + "version": "4.2.9-beta", "license": "MIT", "dependencies": { "@upstash/qstash": "^2.9.0" diff --git a/package.json b/package.json index 1841264..efb2ef5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hookflo/tern", - "version": "4.1.1", + "version": "4.2.9-beta", "description": "A robust, scalable webhook verification framework supporting multiple platforms and signature algorithms", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/adapters/cloudflare.ts b/src/adapters/cloudflare.ts index efd3ba3..331d4c4 100644 --- a/src/adapters/cloudflare.ts +++ b/src/adapters/cloudflare.ts @@ -2,6 +2,8 @@ import { WebhookPlatform, NormalizeOptions } from '../types'; import { WebhookVerificationService } from '../index'; import { handleQueuedRequest, resolveQueueConfig } from '../upstash/queue'; import { QueueOption } from '../upstash/types'; +import { dispatchWebhookAlert } from '../notifications/dispatch'; +import type { AlertConfig, SendAlertOptions } from '../notifications/types'; export interface CloudflareWebhookHandlerOptions, TPayload = any, TMetadata extends Record = Record, TResponse = unknown> { platform: WebhookPlatform; @@ -10,6 +12,8 @@ export interface CloudflareWebhookHandlerOptions, toleranceInSeconds?: number; normalize?: boolean | NormalizeOptions; queue?: QueueOption; + alerts?: AlertConfig; + alert?: Omit; onError?: (error: Error) => void; handler: (payload: TPayload, env: TEnv, metadata: TMetadata) => Promise | TResponse; } @@ -28,13 +32,32 @@ export function createWebhookHandler, TPayload = if (options.queue) { const queueConfig = resolveQueueConfig(options.queue); - return handleQueuedRequest(request, { + const response = await handleQueuedRequest(request, { platform: options.platform, secret, queueConfig, handler: (payload: unknown, metadata: Record) => options.handler(payload as TPayload, env, metadata as TMetadata), toleranceInSeconds: options.toleranceInSeconds ?? 300, }); + + if (response.ok) { + let eventId: string | undefined; + try { + const body = await response.clone().json() as Record; + eventId = typeof body.eventId === 'string' ? body.eventId : undefined; + } catch { + eventId = undefined; + } + + await dispatchWebhookAlert({ + alerts: options.alerts, + source: options.platform, + eventId, + alert: options.alert, + }); + } + + return response; } const result = await WebhookVerificationService.verifyWithPlatformConfig( @@ -49,6 +72,13 @@ export function createWebhookHandler, TPayload = return Response.json({ error: result.error, errorCode: result.errorCode, platform: result.platform, metadata: result.metadata }, { status: 400 }); } + await dispatchWebhookAlert({ + alerts: options.alerts, + source: options.platform, + eventId: result.eventId, + alert: options.alert, + }); + const data = await options.handler(result.payload as TPayload, env, (result.metadata || {}) as TMetadata); return Response.json(data); } catch (error) { diff --git a/src/adapters/express.ts b/src/adapters/express.ts index e32a6e7..8748c94 100644 --- a/src/adapters/express.ts +++ b/src/adapters/express.ts @@ -7,6 +7,8 @@ import { WebhookVerificationService } from '../index'; import { handleQueuedRequest, resolveQueueConfig } from '../upstash/queue'; import { QueueOption } from '../upstash/types'; import { toWebRequest, MinimalNodeRequest, hasParsedBody } from './shared'; +import { dispatchWebhookAlert } from '../notifications/dispatch'; +import type { AlertConfig, SendAlertOptions } from '../notifications/types'; export interface ExpressLikeResponse { status: (code: number) => ExpressLikeResponse; @@ -25,6 +27,8 @@ export interface ExpressWebhookMiddlewareOptions { toleranceInSeconds?: number; normalize?: boolean | NormalizeOptions; queue?: QueueOption; + alerts?: AlertConfig; + alert?: Omit; onError?: (error: Error) => void; strictRawBody?: boolean; } @@ -70,6 +74,17 @@ export function createWebhookMiddleware( } res.status(queueResponse.status).json(body ?? {}); + + if (queueResponse.ok) { + const queueResult = body && typeof body === 'object' ? body as Record : undefined; + await dispatchWebhookAlert({ + alerts: options.alerts, + source: options.platform, + eventId: typeof queueResult?.eventId === 'string' ? queueResult.eventId : undefined, + alert: options.alert, + }); + } + return; } @@ -92,6 +107,14 @@ export function createWebhookMiddleware( } req.webhook = result; + + await dispatchWebhookAlert({ + alerts: options.alerts, + source: options.platform, + eventId: result.eventId, + alert: options.alert, + }); + next(); } catch (error) { options.onError?.(error as Error); diff --git a/src/adapters/nextjs.ts b/src/adapters/nextjs.ts index f55e6bd..f8ec0bf 100644 --- a/src/adapters/nextjs.ts +++ b/src/adapters/nextjs.ts @@ -2,6 +2,8 @@ import { WebhookPlatform, NormalizeOptions } from '../types'; import { WebhookVerificationService } from '../index'; import { handleQueuedRequest, resolveQueueConfig } from '../upstash/queue'; import { QueueOption } from '../upstash/types'; +import { dispatchWebhookAlert } from '../notifications/dispatch'; +import type { AlertConfig, SendAlertOptions } from '../notifications/types'; export interface NextWebhookHandlerOptions = Record, TResponse = unknown> { platform: WebhookPlatform; @@ -9,6 +11,8 @@ export interface NextWebhookHandlerOptions; onError?: (error: Error) => void; handler: (payload: TPayload, metadata: TMetadata) => Promise | TResponse; } @@ -20,13 +24,32 @@ export function createWebhookHandler) => Promise | unknown, toleranceInSeconds: options.toleranceInSeconds ?? 300, }); + + if (response.ok) { + let eventId: string | undefined; + try { + const body = await response.clone().json() as Record; + eventId = typeof body.eventId === 'string' ? body.eventId : undefined; + } catch { + eventId = undefined; + } + + await dispatchWebhookAlert({ + alerts: options.alerts, + source: options.platform, + eventId, + alert: options.alert, + }); + } + + return response; } const result = await WebhookVerificationService.verifyWithPlatformConfig( @@ -41,6 +64,13 @@ export function createWebhookHandler( @@ -390,6 +392,8 @@ export class WebhookVerificationService { platform: WebhookPlatform; secret: string; queue: QueueOption; + alerts?: AlertConfig; + alert?: Omit; handler: (payload: unknown, metadata: Record) => Promise | unknown; toleranceInSeconds?: number; }, @@ -397,13 +401,32 @@ export class WebhookVerificationService { const { resolveQueueConfig, handleQueuedRequest } = await import('./upstash/queue'); const queueConfig = resolveQueueConfig(options.queue); - return handleQueuedRequest(request, { + const response = await handleQueuedRequest(request, { platform: options.platform, secret: options.secret, queueConfig, handler: options.handler, toleranceInSeconds: options.toleranceInSeconds ?? 300, }); + + if (response.ok) { + let eventId: string | undefined; + try { + const body = await response.clone().json() as Record; + eventId = typeof body.eventId === 'string' ? body.eventId : undefined; + } catch { + eventId = undefined; + } + + await dispatchWebhookAlert({ + alerts: options.alerts, + source: options.platform, + eventId, + alert: options.alert, + }); + } + + return response; } static async verifyTokenBased( request: Request, @@ -429,5 +452,6 @@ export { getPlatformsByCategory, } from './normalization/simple'; export * from './adapters'; +export * from './alerts'; export default WebhookVerificationService; diff --git a/src/notifications/channels/discord/build-payload.ts b/src/notifications/channels/discord/build-payload.ts new file mode 100644 index 0000000..8406078 --- /dev/null +++ b/src/notifications/channels/discord/build-payload.ts @@ -0,0 +1,64 @@ +import type { AlertPayloadBuilderInput } from "../../types"; +import { compactMetadata } from "../../utils"; +import { severityColorMap } from "../../constants"; + +export function buildDiscordPayload(input: AlertPayloadBuilderInput) { + const isDLQ = input.dlq; + + const fallbackTitle = isDLQ ? "Dead Letter Queue β€” Event Failed" : "Webhook Received"; + const title = input.title?.trim() ? input.title : fallbackTitle; + + const fallbackDescription = isDLQ + ? "Event exhausted all retries. Manual replay required." + : "Event verified and queued for processing."; + const description = input.message?.trim() ? input.message : fallbackDescription; + + const fields: Array<{ name: string; value: string; inline?: boolean }> = []; + + if (input.source) { + fields.push({ name: "Platform", value: input.source, inline: true }); + } + + fields.push({ + name: "Severity", + value: input.severity.toLowerCase(), + inline: true, + }); + + if (isDLQ) { + fields.push({ name: "Queue", value: "dlq", inline: true }); + } + + if (input.eventId) { + fields.push({ + name: isDLQ ? "DLQ ID" : "Event ID", + value: `\`${input.eventId}\``, + inline: false, + }); + } + + const metadataString = compactMetadata(input.metadata); + if (metadataString) { + fields.push({ name: "Details", value: `\`\`\`${metadataString}\`\`\`` }); + } + + const replayLine = input.replayUrl + ? `\n\n[${input.replayLabel ?? "Replay Event"}](${input.replayUrl})` + : ""; + + const footerText = + input.branding === false ? undefined : "Alert from Tern Β· tern.hookflo.com"; + + return { + embeds: [ + { + title, + description: `${description}${replayLine}`, + color: parseInt(severityColorMap[input.severity].replace("#", ""), 16), + fields, + footer: footerText ? { text: footerText } : undefined, + timestamp: new Date().toISOString(), + }, + ], + }; +} diff --git a/src/notifications/channels/discord/index.ts b/src/notifications/channels/discord/index.ts new file mode 100644 index 0000000..540f5ba --- /dev/null +++ b/src/notifications/channels/discord/index.ts @@ -0,0 +1 @@ +export { buildDiscordPayload } from './build-payload'; diff --git a/src/notifications/channels/slack/build-payload.ts b/src/notifications/channels/slack/build-payload.ts new file mode 100644 index 0000000..84258b1 --- /dev/null +++ b/src/notifications/channels/slack/build-payload.ts @@ -0,0 +1,89 @@ +import type { AlertPayloadBuilderInput } from "../../types"; +import { compactMetadata } from "../../utils"; +import { TERN_BRAND_URL } from "../../constants"; + +export function buildSlackPayload(input: AlertPayloadBuilderInput) { + const isDLQ = input.dlq; + + const fallbackTitle = isDLQ ? "Dead Letter Queue β€” Event Failed" : "Webhook Received"; + const title = input.title?.trim() ? input.title : fallbackTitle; + + const fallbackMessage = isDLQ + ? "Event exhausted all retries. Manual replay required." + : "Event verified and queued for processing."; + const message = input.message?.trim() ? input.message : fallbackMessage; + + const fields = [ + input.source + ? { type: "mrkdwn", text: `*Platform*\n${input.source}` } + : null, + { + type: "mrkdwn", + text: `*Severity*\n${input.severity.toLowerCase()}`, + }, + isDLQ ? { type: "mrkdwn", text: "*Queue*\ndlq" } : null, + input.eventId + ? { + type: "mrkdwn", + text: `*${isDLQ ? "DLQ ID" : "Event ID"}*\n\`${input.eventId}\``, + } + : null, + ].filter(Boolean); + + const blocks: Record[] = [ + { + type: "section", + text: { + type: "mrkdwn", + text: `*${title}*\n${message}`, + }, + fields, + }, + ]; + + const metadataString = compactMetadata(input.metadata); + if (metadataString) { + blocks.push({ + type: "section", + text: { + type: "mrkdwn", + text: `\`\`\`${metadataString}\`\`\``, + }, + }); + } + + if (input.replayUrl) { + blocks.push({ + type: "actions", + elements: [ + { + type: "button", + text: { + type: "plain_text", + text: input.replayLabel ?? "Replay Event", + emoji: false, + }, + url: input.replayUrl, + style: "danger", + }, + ], + }); + } + + if (input.branding !== false) { + blocks.push({ + type: "context", + elements: [ + { + type: "mrkdwn", + text: `Alert from <${TERN_BRAND_URL}|Tern>`, + }, + ], + }); + } + + return { + text: title, + blocks, + }; +} diff --git a/src/notifications/channels/slack/index.ts b/src/notifications/channels/slack/index.ts new file mode 100644 index 0000000..6655ba4 --- /dev/null +++ b/src/notifications/channels/slack/index.ts @@ -0,0 +1 @@ +export { buildSlackPayload } from './build-payload'; diff --git a/src/notifications/constants.ts b/src/notifications/constants.ts new file mode 100644 index 0000000..e096ae5 --- /dev/null +++ b/src/notifications/constants.ts @@ -0,0 +1,15 @@ +import type { AlertSeverity } from './types'; + +export const TERN_BRAND_URL = 'https://tern.hookflo.com'; +export const DEFAULT_DLQ_TITLE = 'Dead Letter Queue β€” Event Failed'; +export const DEFAULT_ALERT_TITLE = 'Webhook Received'; +export const DEFAULT_REPLAY_LABEL = 'Replay DLQ Event'; +export const DEFAULT_DLQ_MESSAGE = 'Event exhausted all retries. Manual replay required.'; +export const DEFAULT_ALERT_MESSAGE = 'Event verified and queued for processing.'; + +export const severityColorMap: Record = { + info: '#3B82F6', + warning: '#F59E0B', + error: '#EF4444', + critical: '#7C3AED', +}; diff --git a/src/notifications/dispatch.ts b/src/notifications/dispatch.ts new file mode 100644 index 0000000..1fb629a --- /dev/null +++ b/src/notifications/dispatch.ts @@ -0,0 +1,19 @@ +import type { AlertConfig, SendAlertOptions } from './types'; +import { sendAlert } from './send-alert'; + +export interface DispatchWebhookAlertInput { + alerts?: AlertConfig; + source?: string; + eventId?: string; + alert?: Omit; +} + +export async function dispatchWebhookAlert(input: DispatchWebhookAlertInput): Promise { + if (!input.alerts) return; + + await sendAlert(input.alerts, { + ...(input.alert || {}), + source: input.source, + eventId: input.eventId, + }); +} diff --git a/src/notifications/index.ts b/src/notifications/index.ts new file mode 100644 index 0000000..ae08a57 --- /dev/null +++ b/src/notifications/index.ts @@ -0,0 +1,12 @@ +import { normalizeAlertOptions, resolveDestinations } from './utils'; +import { buildSlackPayload } from './channels/slack'; +import { buildDiscordPayload } from './channels/discord'; + +export * from './types'; + +export const notificationInternals = { + resolveDestinations, + normalizeAlertOptions, + buildSlackPayload, + buildDiscordPayload, +}; diff --git a/src/notifications/send-alert.ts b/src/notifications/send-alert.ts new file mode 100644 index 0000000..301b091 --- /dev/null +++ b/src/notifications/send-alert.ts @@ -0,0 +1,87 @@ +import type { + AlertConfig, + SendAlertOptions, + SendAlertResult, + SendAlertSummary, +} from './types'; +import { resolveDestinations, normalizeAlertOptions } from './utils'; +import { buildSlackPayload } from './channels/slack'; +import { buildDiscordPayload } from './channels/discord'; + +async function postWebhook( + webhookUrl: string, + body: unknown, +): Promise<{ ok: boolean; status?: number; error?: string }> { + try { + const response = await fetch(webhookUrl, { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + return { + ok: false, + status: response.status, + error: `Webhook call failed with ${response.status}`, + }; + } + + return { ok: true, status: response.status }; + } catch (error) { + return { + ok: false, + error: (error as Error).message, + }; + } +} + +function buildPayload(channel: 'slack' | 'discord', options: SendAlertOptions) { + const normalized = normalizeAlertOptions(options); + return channel === 'slack' ? buildSlackPayload(normalized) : buildDiscordPayload(normalized); +} + +export async function sendAlert( + config: AlertConfig, + options: SendAlertOptions, +): Promise { + const destinations = resolveDestinations(config); + + if (destinations.length === 0) { + return { + success: false, + total: 0, + delivered: 0, + results: [{ + channel: 'slack', + webhookUrl: '', + ok: false, + error: 'No valid alert webhook destinations configured', + }], + }; + } + + const results = await Promise.all(destinations.map(async (destination): Promise => { + const payload = buildPayload(destination.channel, options); + const response = await postWebhook(destination.webhookUrl, payload); + + return { + channel: destination.channel, + webhookUrl: destination.webhookUrl, + ok: response.ok, + status: response.status, + error: response.error, + }; + })); + + const delivered = results.filter((result) => result.ok).length; + + return { + success: delivered === results.length, + total: results.length, + delivered, + results, + }; +} diff --git a/src/notifications/types.ts b/src/notifications/types.ts new file mode 100644 index 0000000..1c3dc57 --- /dev/null +++ b/src/notifications/types.ts @@ -0,0 +1,57 @@ +export type AlertSeverity = 'info' | 'warning' | 'error' | 'critical'; +export type AlertChannel = 'slack' | 'discord'; + +export interface AlertChannelConfig { + webhookUrl: string; + enabled?: boolean; +} + +export interface AlertConfig { + slack?: AlertChannelConfig; + discord?: AlertChannelConfig; +} + +export interface SendAlertOptions { + dlq?: boolean; + dlqId?: string; + eventId?: string; + source?: string; + + // Optional overrides when needed. + title?: string; + message?: string; + severity?: AlertSeverity; + replayUrl?: string; + replayLabel?: string; + metadata?: Record; + branding?: boolean; +} + +export interface SendAlertResult { + channel: AlertChannel; + webhookUrl: string; + ok: boolean; + status?: number; + error?: string; +} + +export interface SendAlertSummary { + success: boolean; + total: number; + delivered: number; + results: SendAlertResult[]; +} + +export interface AlertDestination { + channel: AlertChannel; + webhookUrl: string; +} + +export interface AlertPayloadBuilderInput extends SendAlertOptions { + title: string; + message: string; + severity: AlertSeverity; + replayLabel: string; + eventId?: string; + metadata?: Record; +} diff --git a/src/notifications/utils.ts b/src/notifications/utils.ts new file mode 100644 index 0000000..8e1c558 --- /dev/null +++ b/src/notifications/utils.ts @@ -0,0 +1,141 @@ +import type { + AlertConfig, + AlertDestination, + AlertPayloadBuilderInput, + SendAlertOptions, +} from './types'; +import { + DEFAULT_ALERT_MESSAGE, + DEFAULT_ALERT_TITLE, + DEFAULT_DLQ_MESSAGE, + DEFAULT_DLQ_TITLE, + DEFAULT_REPLAY_LABEL, +} from './constants'; + +export function compactMetadata(metadata?: Record): string | undefined { + if (!metadata || Object.keys(metadata).length === 0) return undefined; + + const entries = Object.entries(metadata).slice(0, 8); + return entries.map(([key, value]) => `${key}: ${String(value)}`).join('\n'); +} + +function asNonEmptyString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim().length > 0 ? value : undefined; +} + +function asObject(value: unknown): Record | undefined { + return value && typeof value === 'object' ? value as Record : undefined; +} + +function pickNonEmptyString(...values: unknown[]): string | undefined { + for (const value of values) { + const normalized = asNonEmptyString(value); + if (normalized) return normalized; + } + + return undefined; +} + +function resolveSourceFromObject(value: unknown): string | undefined { + const object = asObject(value); + if (!object) return undefined; + + return pickNonEmptyString( + object.platform, + object.provider, + object.source, + object.service, + object.origin, + object.type, + ); +} + +function resolveEventIdFromObject(value: unknown): string | undefined { + const object = asObject(value); + if (!object) return undefined; + + return pickNonEmptyString( + object.eventId, + object.event_id, + object.id, + object.request_id, + object.webhook_id, + object.messageId, + object.message_id, + ); +} + +function resolveSource(options: SendAlertOptions): string | undefined { + const metadata = options.metadata || {}; + const metadataPayload = asObject(metadata.payload); + const metadataEvent = asObject(metadata.event); + const metadataData = asObject(metadata.data); + const metadataBody = asObject(metadata.body); + + return pickNonEmptyString( + options.source, + metadata.source, + metadata.platform, + metadata.provider, + metadata.eventSource, + resolveSourceFromObject(metadataPayload), + resolveSourceFromObject(metadataEvent), + resolveSourceFromObject(metadataData), + resolveSourceFromObject(metadataBody), + ); +} + +function resolveEventId(options: SendAlertOptions): string | undefined { + const metadata = options.metadata || {}; + const metadataPayload = asObject(metadata.payload); + const metadataEvent = asObject(metadata.event); + const metadataData = asObject(metadata.data); + + return pickNonEmptyString( + options.eventId, + options.dlqId, + metadata.eventId, + metadata.event_id, + metadata.messageId, + metadata.message_id, + metadata.webhookId, + metadata.webhook_id, + metadata.id, + resolveEventIdFromObject(metadataPayload), + resolveEventIdFromObject(metadataEvent), + resolveEventIdFromObject(metadataData), + ); +} + +export function resolveDestinations(config: AlertConfig): AlertDestination[] { + const destinations: AlertDestination[] = []; + + if (config.slack?.enabled !== false && config.slack?.webhookUrl) { + destinations.push({ channel: 'slack', webhookUrl: config.slack.webhookUrl }); + } + + if (config.discord?.enabled !== false && config.discord?.webhookUrl) { + destinations.push({ channel: 'discord', webhookUrl: config.discord.webhookUrl }); + } + + return destinations; +} + +export function normalizeAlertOptions(options: SendAlertOptions): AlertPayloadBuilderInput { + const isDlq = options.dlq === true; + const severity = options.severity || (isDlq ? 'error' : 'info'); + const title = options.title || (isDlq ? DEFAULT_DLQ_TITLE : DEFAULT_ALERT_TITLE); + const message = options.message || (isDlq ? DEFAULT_DLQ_MESSAGE : DEFAULT_ALERT_MESSAGE); + const replayLabel = options.replayLabel || DEFAULT_REPLAY_LABEL; + + return { + ...options, + dlq: isDlq, + source: resolveSource(options) || 'unknown', + title, + message, + severity, + replayLabel, + eventId: resolveEventId(options), + }; +} diff --git a/src/test.ts b/src/test.ts index 6271e2c..6252d76 100644 --- a/src/test.ts +++ b/src/test.ts @@ -1,5 +1,8 @@ import { createHmac, createHash, generateKeyPairSync, sign } from 'crypto'; import { WebhookVerificationService, getPlatformsByCategory } from './index'; +import { normalizeAlertOptions } from './notifications/utils'; +import { buildSlackPayload } from './notifications/channels/slack'; +import { buildDiscordPayload } from './notifications/channels/discord'; const testSecret = 'whsec_test_secret_key_12345'; const testBody = JSON.stringify({ event: 'test', data: { id: '123' } }); @@ -842,6 +845,73 @@ async function runTests() { console.log(' ❌ handleWithQueue test failed:', error); } + // Test 22: Custom alert title/message payload passthrough + console.log('\n22. Testing custom alert title/message payload...'); + try { + const title = 'Alert Recieved'; + const message = 'Alert received in handler'; + const normalized = normalizeAlertOptions({ + source: 'stripe', + eventId: 'evt_123', + title, + message, + }); + + const slackPayload = buildSlackPayload(normalized) as Record; + const slackBlocks = (slackPayload.blocks || []) as Array>; + const slackPrimarySection = (slackBlocks[0]?.text || {}) as Record; + const slackText = String(slackPrimarySection.text || ''); + + const discordPayload = buildDiscordPayload(normalized) as Record; + const discordEmbeds = (discordPayload.embeds || []) as Array>; + const discordPrimary = (discordEmbeds[0] || {}) as Record; + + const pass = + String(slackPayload.text || '') === title + && slackText.includes(title) + && slackText.includes(message) + && String(discordPrimary.title || '') === title + && String(discordPrimary.description || '') === message; + + console.log(' βœ… Alert custom title/message:', trackCheck('alert custom title/message', pass) ? 'PASSED' : 'FAILED'); + } catch (error) { + console.log(' ❌ Alert custom title/message test failed:', error); + } + + // Test 23: Alert payload fallback compatibility when title/message are empty + console.log('\n23. Testing alert title/message fallback compatibility...'); + try { + const normalized = normalizeAlertOptions({ + source: 'stripe', + eventId: 'evt_456', + title: ' ', + message: '', + }); + + const slackPayload = buildSlackPayload(normalized) as Record; + const slackBlocks = (slackPayload.blocks || []) as Array>; + const slackPrimarySection = (slackBlocks[0]?.text || {}) as Record; + const slackText = String(slackPrimarySection.text || ''); + + const discordPayload = buildDiscordPayload(normalized) as Record; + const discordEmbeds = (discordPayload.embeds || []) as Array>; + const discordPrimary = (discordEmbeds[0] || {}) as Record; + + const fallbackTitle = 'Webhook Received'; + const fallbackMessage = 'Event verified and queued for processing.'; + + const pass = + String(slackPayload.text || '') === fallbackTitle + && slackText.includes(fallbackTitle) + && slackText.includes(fallbackMessage) + && String(discordPrimary.title || '') === fallbackTitle + && String(discordPrimary.description || '') === fallbackMessage; + + console.log(' βœ… Alert title/message fallback:', trackCheck('alert title/message fallback', pass) ? 'PASSED' : 'FAILED'); + } catch (error) { + console.log(' ❌ Alert title/message fallback test failed:', error); + } + if (failedChecks.length > 0) { throw new Error(`Test checks failed: ${failedChecks.join(', ')}`); } diff --git a/src/upstash/controls.ts b/src/upstash/controls.ts index 40051be..80b7964 100644 --- a/src/upstash/controls.ts +++ b/src/upstash/controls.ts @@ -2,9 +2,12 @@ import { DLQMessage, EventFilter, ReplayResult, + TernControls, TernControlsConfig, TernEvent, + ControlAlertOptions, } from './types'; +import { sendAlert } from '../notifications/send-alert'; const QSTASH_API_BASE = 'https://qstash.upstash.io/v2'; @@ -63,7 +66,13 @@ function deriveStatus(value: string): 'delivered' | 'failed' | 'retrying' { return 'retrying'; } -export function createTernControls(config: TernControlsConfig) { +function withoutUndefined>(value: T): Record { + return Object.fromEntries( + Object.entries(value).filter(([, entryValue]) => entryValue !== undefined), + ); +} + +export function createTernControls(config: TernControlsConfig): TernControls { return { async dlq(): Promise { const response = await fetch(`${QSTASH_API_BASE}/dlq`, { @@ -140,5 +149,71 @@ export function createTernControls(config: TernControlsConfig) { return statusFiltered.slice(0, filter.limit ?? 20); }, + + async alert(options: ControlAlertOptions = {}) { + let replayMeta: Record = {}; + let resolvedSource = options.source; + let resolvedEventId = options.eventId; + + if (options.dlq && (!resolvedSource || !resolvedEventId)) { + try { + const dlqMessages = await this.dlq(); + const matchingMessage = dlqMessages.find((message) => message.dlqId === options.dlqId); + + resolvedSource = resolvedSource || matchingMessage?.platform; + resolvedEventId = resolvedEventId || matchingMessage?.id; + } catch (error) { + replayMeta = { + ...replayMeta, + dlqLookupError: (error as Error).message, + }; + } + } + + if (options.dlq) { + if (!options.dlqId || options.dlqId.trim() === '') { + throw new Error('[tern] controls.alert() with dlq: true requires dlqId.'); + } + + try { + const replay = await this.replay(options.dlqId); + replayMeta = { + replayAttempted: true, + replaySuccess: replay.success, + replayedAt: replay.replayedAt, + replayDlqId: options.dlqId, + }; + } catch (error) { + replayMeta = { + replayAttempted: true, + replaySuccess: false, + replayDlqId: options.dlqId, + replayError: (error as Error).message, + }; + } + } + + return sendAlert( + { + slack: config.notifications?.slackWebhookUrl + ? { webhookUrl: config.notifications.slackWebhookUrl } + : undefined, + discord: config.notifications?.discordWebhookUrl + ? { webhookUrl: config.notifications.discordWebhookUrl } + : undefined, + }, + { + ...options, + source: resolvedSource, + eventId: resolvedEventId || options.dlqId, + metadata: withoutUndefined({ + ...(options.metadata || {}), + source: resolvedSource || options.metadata?.source, + eventId: resolvedEventId || options.metadata?.eventId, + ...replayMeta, + }), + }, + ); + }, }; } diff --git a/src/upstash/index.ts b/src/upstash/index.ts index c9127be..1e8a25d 100644 --- a/src/upstash/index.ts +++ b/src/upstash/index.ts @@ -6,12 +6,14 @@ export { resolveQueueConfig, } from './queue'; export type { + ControlAlertOptions, DLQMessage, EventFilter, QueueOption, QueuedMessage, ReplayResult, ResolvedQueueConfig, + TernControls, TernControlsConfig, TernEvent, } from './types'; diff --git a/src/upstash/queue.ts b/src/upstash/queue.ts index a12ca4c..880bbff 100644 --- a/src/upstash/queue.ts +++ b/src/upstash/queue.ts @@ -256,7 +256,11 @@ export async function handleReceive( try { await client.publishJSON(publishPayload); - return new Response(JSON.stringify({ queued: true }), { + return new Response(JSON.stringify({ + queued: true, + platform, + eventId: verificationResult.eventId, + }), { status: 200, headers: { 'Content-Type': 'application/json' }, }); diff --git a/src/upstash/types.ts b/src/upstash/types.ts index 965eb11..f7d106d 100644 --- a/src/upstash/types.ts +++ b/src/upstash/types.ts @@ -1,4 +1,5 @@ import { WebhookPlatform } from '../types'; +import type { SendAlertOptions, SendAlertSummary } from '../notifications/types'; export type QueueOption = | true @@ -18,6 +19,10 @@ export interface ResolvedQueueConfig { export interface TernControlsConfig { token: string; + notifications?: { + slackWebhookUrl?: string; + discordWebhookUrl?: string; + }; } export interface DLQMessage { @@ -55,3 +60,20 @@ export interface QueuedMessage { payload: unknown; metadata: Record; } + +export type ControlAlertOptions = + | (SendAlertOptions & { + dlq: true; + dlqId: string; + }) + | (SendAlertOptions & { + dlq?: false; + dlqId?: never; + }); + +export interface TernControls { + dlq: () => Promise; + replay: (dlqId: string) => Promise; + events: (filter?: EventFilter) => Promise; + alert: (options?: ControlAlertOptions) => Promise; +}