@amirabaris/webhook-kit 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -525,14 +525,14 @@ try {
525
525
 
526
526
  All errors inherit from `WebhookKitError` and include machine-readable `code` properties:
527
527
 
528
- | Error Class | `code` | Root Cause & Mitigation |
529
- | ----------------------------- | ------------------------------- | -------------------------------------------------------------------------------------------------- |
530
- | `SignatureVerificationError` | `SIGNATURE_VERIFICATION_FAILED` | HMAC digest mismatch or missing signing secret. Check that your webhook signing secret is correct. |
531
- | `TimestampToleranceError` | `TIMESTAMP_TOLERANCE_EXCEEDED` | Payload timestamp exceeds `toleranceSeconds` window. Protects against replay attacks. |
532
- | `ReplayAttackError` | `WEBHOOK_REPLAY_DETECTED` | Event ID already exists in `store`. Prevents duplicate webhook processing within the TTL window. |
533
- | `InvalidSignatureHeaderError` | `INVALID_SIGNATURE_HEADER` | Header is missing, empty, or lacks required prefix (`sha256=`, `t=...,v1=...`). |
534
- | `PayloadExtractionError` | `PAYLOAD_EXTRACTION_FAILED` | Body is an unsupported format or input stream was prematurely closed. |
535
- | `WebhookKitError` | `WEBHOOK_KIT_ERROR` | Base class for all webhook-kit errors. |
528
+ | Error Class | `code` | Root Cause & Mitigation |
529
+ | ----------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------ |
530
+ | `SignatureVerificationError` | `SIGNATURE_VERIFICATION_FAILED` | HMAC digest mismatch, missing secret, or invalid format (e.g., non-hex, wrong length). |
531
+ | `TimestampToleranceError` | `TIMESTAMP_TOLERANCE_EXCEEDED` | Payload timestamp is outside `toleranceSeconds` window, or is non-finite (`NaN`/`Infinity`). |
532
+ | `ReplayAttackError` | `WEBHOOK_REPLAY_DETECTED` | Event ID already exists in `store`. Prevents duplicate webhook processing within the TTL window. |
533
+ | `InvalidSignatureHeaderError` | `INVALID_SIGNATURE_HEADER` | Header is missing, empty, or lacks required prefix (`sha256=`, `t=...,v1=...`). |
534
+ | `PayloadExtractionError` | `PAYLOAD_EXTRACTION_FAILED` | Body is an unsupported format or input stream was prematurely closed. |
535
+ | `WebhookKitError` | `WEBHOOK_KIT_ERROR` | Base class for all webhook-kit errors. |
536
536
 
537
537
  ### Replay Attack Mitigation & Idempotency Store (`WebhookReplayStore`)
538
538
 
@@ -541,15 +541,18 @@ Replay attacks occur when an attacker (or a retry storm) replays a valid webhook
541
541
  `webhook-kit` combines two layers of replay protection:
542
542
 
543
543
  1. **Timestamp Tolerance**:
544
- - **Stripe**: Parses `t=...` from `Stripe-Signature` and validates against `toleranceSeconds` (default: 300s).
544
+ - **Stripe**: Parses `t=...` from `Stripe-Signature` and validates against `toleranceSeconds` (default: 300s). Fractional timestamps are safely floored.
545
545
  - **Generic**: Automatically inspects `x-webhook-timestamp` (or explicit `timestamp` option) and validates against `toleranceSeconds`.
546
- 2. **Idempotency / Replay Store (`store: { has(eventId), set(eventId, ttl) }`)**:
547
- - Pass any synchronous or asynchronous store implementing `WebhookReplayStore` (`has(eventId)` and `set(eventId, ttlSeconds)`).
548
- - `webhook-kit` automatically extracts the event ID from `options.eventId`, standard delivery headers (`X-GitHub-Delivery`, `X-Shopify-Webhook-Id`, `X-Webhook-Id`), or `payload.id` **after** cryptographic HMAC verification succeeds, and rejects duplicates with `ReplayAttackError` (`409 Conflict` in `createWebhookHandler`).
546
+ - **Strict Numeric Hardening**: Both `timestamp` and `toleranceSeconds` must be positive, finite numbers. Non-finite values (`NaN`, `Infinity`, `-Infinity`) or negative numbers immediately reject with `TimestampToleranceError` to prevent tolerance check bypasses.
547
+ 2. **Idempotency / Replay Store (`store: { has(eventId), set(eventId, ttl), setNx?(eventId, ttl) }`)**:
548
+ - Pass any synchronous or asynchronous store implementing `WebhookReplayStore`.
549
+ - Supports atomic `setNx` (_set-if-not-exists_) to eliminate race conditions under concurrent deliveries. If `setNx` is omitted, `webhook-kit` automatically serializes in-flight checks.
550
+ - `webhook-kit` automatically extracts the event ID from `options.eventId`, standard delivery headers (`svix-id`, `webhook-id`, `X-GitHub-Delivery`, `X-Shopify-Webhook-Id`, `X-Webhook-Id`), or `payload.id` **after** cryptographic HMAC verification succeeds, and rejects duplicates with `ReplayAttackError` (`409 Conflict` in `createWebhookHandler`).
551
+ - Supports both string and numeric event IDs (e.g. Shopify numeric webhook IDs) seamlessly.
549
552
 
550
553
  #### Default In-Memory TTL Store (`createInMemoryReplayStore`)
551
554
 
552
- For single-instance Node, Bun, or Deno servers, use the built-in zero-dependency TTL store with automatic expiration and LRU capacity bounding:
555
+ For single-instance Node, Bun, or Deno servers, use the built-in zero-dependency TTL store with atomic `setNx`, automatic expiration, and LRU capacity bounding:
553
556
 
554
557
  ```ts
555
558
  import {
@@ -574,22 +577,34 @@ export const POST = createWebhookHandler({
574
577
 
575
578
  #### Distributed Store Hooks (Redis / Upstash / Cloudflare KV)
576
579
 
577
- For multi-instance or serverless edge deployments, wire a 6-line `WebhookReplayStore` adapter into Redis, Upstash, or Cloudflare Workers KV:
580
+ For multi-instance or serverless edge deployments, wire a lightweight `WebhookReplayStore` adapter into Redis, Upstash, or Cloudflare Workers KV. Provide `setNx` (e.g. Redis `SET key value EX ttl NX`) for hardware-level atomic de-duplication:
578
581
 
579
582
  ```ts
580
583
  import type { WebhookReplayStore } from "@amirabaris/webhook-kit";
581
584
 
582
- // 1. Node / Bun Redis (ioredis or node-redis)
585
+ // 1. Node / Bun Redis (ioredis or node-redis) with atomic setNx
583
586
  const redisStore: WebhookReplayStore = {
584
587
  has: async (id) => (await redis.exists(`wh:replay:${id}`)) === 1,
585
588
  set: async (id, ttl) => await redis.set(`wh:replay:${id}`, "1", "EX", ttl),
589
+ // Optional atomic set-if-not-exists for distributed lock-free deduplication
590
+ setNx: async (id, ttl) => {
591
+ const res = await redis.set(`wh:replay:${id}`, "1", "EX", ttl, "NX");
592
+ return res === "OK";
593
+ },
586
594
  };
587
595
 
588
- // 2. Upstash Redis (@upstash/redis — Edge & Next.js)
596
+ // 2. Upstash Redis (@upstash/redis — Edge & Next.js) with atomic setNx
589
597
  const upstashStore: WebhookReplayStore = {
590
598
  has: async (id) => (await upstash.exists(`wh:replay:${id}`)) === 1,
591
599
  set: async (id, ttl) =>
592
600
  await upstash.set(`wh:replay:${id}`, "1", { ex: ttl }),
601
+ setNx: async (id, ttl) => {
602
+ const res = await upstash.set(`wh:replay:${id}`, "1", {
603
+ ex: ttl,
604
+ nx: true,
605
+ });
606
+ return res !== null;
607
+ },
593
608
  };
594
609
 
595
610
  // 3. Cloudflare Workers KV ( passed via Hono / Worker env )
@@ -618,10 +633,12 @@ flowchart LR
618
633
 
619
634
  `webhook-kit` is organized into modular layers:
620
635
 
621
- - **`crypto/`**: Web Cryptography API (`crypto.subtle`) primitives and constant-time buffer comparison.
622
- - **`utils/`**: Stream and body extraction that preserves raw payload bytes without consuming or mutating input requests.
623
- - **`providers/`**: Pluggable provider-specific format parsers (Stripe timestamped `t=...,v1=...`, GitHub `sha256=...`, Shopify Base64, etc.).
624
- - **`errors/`**: Strongly typed error classes (`SignatureVerificationError`, `TimestampToleranceError`, `PayloadExtractionError`).
636
+ - **`crypto/`**: Web Cryptography API (`crypto.subtle`) primitives, key LRU caching, and constant-time buffer comparison (`timingSafeEqual`).
637
+ - **`utils/`**: Stream and header utilities (`extractRawBody`, `extractHeaderValue`) preserving raw bytes without consuming input requests.
638
+ - **`providers/`**: Pluggable provider-specific format parsers and verifiers (Stripe timestamped `t=...,v1=...`, GitHub `sha256=...`, Shopify Base64).
639
+ - **`store/`**: Idempotency and replay attack prevention (`InMemoryReplayStore`, `checkAndRecordReplay`, atomic `setNx`).
640
+ - **`handler/`**: Declarative universal route handler (`createWebhookHandler`) for Next.js, Hono, and WinterCG runtimes.
641
+ - **`errors.ts`**: Strongly typed error class hierarchy (`SignatureVerificationError`, `TimestampToleranceError`, `ReplayAttackError`, `InvalidSignatureHeaderError`, `PayloadExtractionError`).
625
642
 
626
643
  ---
627
644
 
@@ -28,6 +28,8 @@ const AGENT_RULES_CONTENT = `${START_MARKER}
28
28
  - **NEVER parse JSON before verifying signatures**: In frameworks like Next.js, Hono, or Express, parsing JSON reorders keys and strips whitespace, invalidating cryptographic HMAC checks. Always pass the raw \`Request\` or raw text string.
29
29
  - **Constant-Time Verification**: Never compare signatures with \`===\` or \`!==\`. Always use \`timingSafeEqual\` or \`verifyWebhookSignature\` from \`@amirabaris/webhook-kit\`.
30
30
  - **Zero Node-Only Crypto**: Web standard environments (Cloudflare Workers, Next.js Edge, Bun, Deno) do not support \`node:crypto\`. Rely on \`@amirabaris/webhook-kit\` which uses \`crypto.subtle\`.
31
+ - **Strict Format & Tolerance Hardening**: Validate signature format (e.g. strict 64-char hex or 44-char Base64) before crypto checks. Enforce positive, finite numbers for timestamps and \`toleranceSeconds\` (\`NaN\` and negative tolerance are rejected).
32
+ - **Replay Protection & Store**: Deduplicate retries with \`store: createInMemoryReplayStore()\` or distributed \`WebhookReplayStore\` adapters (\`has\`, \`set\`, optional atomic \`setNx\`), supporting both string and numeric event IDs.
31
33
  - **Conventional Commits**: Follow \`type(scope): message\` (e.g. \`feat(stripe): add tolerance check\`).
32
34
  ${END_MARKER}`;
33
35