@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/dist/index.mjs CHANGED
@@ -62,18 +62,78 @@ var ReplayAttackError = class extends WebhookKitError {
62
62
  this.eventId = eventId;
63
63
  }
64
64
  };
65
- // src/store.ts
65
+ // src/utils/headers.ts
66
+ /**
67
+ * Extracts a header value from a plain headers record case-insensitively.
68
+ *
69
+ * @param record - Plain headers record object.
70
+ * @param lowerTarget - Pre-lowercased target header name.
71
+ * @returns Header value string or undefined.
72
+ */
73
+ function extractFromRecord(record, lowerTarget) {
74
+ for (const key of Object.keys(record)) if (key.toLowerCase() === lowerTarget) {
75
+ const value = record[key];
76
+ return Array.isArray(value) ? value[0] : value;
77
+ }
78
+ }
79
+ /**
80
+ * Case-insensitively extracts a header value from a `Headers` instance or plain record.
81
+ *
82
+ * @param headers - Web standard `Headers` instance or record.
83
+ * @param targetName - Lowercase target header name.
84
+ * @returns Header value string or undefined.
85
+ */
86
+ function extractHeaderValue$1(headers, targetName) {
87
+ if (!headers || typeof headers !== "object") return;
88
+ if (typeof Headers !== "undefined" && headers instanceof Headers) return headers.get(targetName) ?? void 0;
89
+ return extractFromRecord(headers, targetName.toLowerCase());
90
+ }
91
+ // src/store/store.ts
66
92
  const DEFAULT_REPLAY_STORE_MAX_SIZE = 1e4;
67
93
  const DEFAULT_REPLAY_STORE_TTL_SECONDS = 300;
68
94
  const GENERIC_EVENT_ID_HEADERS = [
69
95
  "x-webhook-id",
70
96
  "webhook-id",
97
+ "svix-id",
71
98
  "x-github-delivery",
72
99
  "x-shopify-webhook-id",
73
100
  "x-event-id",
74
101
  "x-idempotency-key"
75
102
  ];
76
103
  /**
104
+ * Tracks in-flight replay check promises per store instance to prevent TOCTOU race conditions
105
+ * during concurrent duplicate deliveries across stores that only implement `has` and `set`.
106
+ */
107
+ const inFlightPerStore = /* @__PURE__ */ new WeakMap();
108
+ /**
109
+ * Sanitizes and bounds the maximum capacity for `InMemoryReplayStore`.
110
+ *
111
+ * @param size - Candidate capacity option.
112
+ * @returns Positive bounded integer capacity.
113
+ */
114
+ function sanitizeCapacity(size) {
115
+ return typeof size === "number" && Number.isFinite(size) && size > 0 ? Math.floor(size) : DEFAULT_REPLAY_STORE_MAX_SIZE;
116
+ }
117
+ /**
118
+ * Sanitizes and bounds the default TTL for `InMemoryReplayStore`.
119
+ *
120
+ * @param ttl - Candidate default TTL option.
121
+ * @returns Positive finite TTL in seconds.
122
+ */
123
+ function sanitizeTtl(ttl) {
124
+ return typeof ttl === "number" && Number.isFinite(ttl) && ttl > 0 ? ttl : 300;
125
+ }
126
+ /**
127
+ * Resolves the effective TTL for a store insert from explicit TTL or default fallback.
128
+ *
129
+ * @param ttl - Explicit TTL in seconds.
130
+ * @param defaultTtl - Default TTL fallback.
131
+ * @returns Resolved finite TTL in seconds.
132
+ */
133
+ function resolveInsertTtl(ttl, defaultTtl) {
134
+ return typeof ttl === "number" && Number.isFinite(ttl) ? ttl : defaultTtl;
135
+ }
136
+ /**
77
137
  * Default in-memory TTL replay store backed by a JavaScript `Map`.
78
138
  * Automatically evicts expired entries on lookup and enforces an upper capacity bound
79
139
  * with LRU (Least Recently Used) eviction.
@@ -83,9 +143,10 @@ var InMemoryReplayStore = class {
83
143
  defaultTtlSeconds;
84
144
  now;
85
145
  entries = /* @__PURE__ */ new Map();
146
+ lastPruneMs = 0;
86
147
  constructor(options = {}) {
87
- this.maxSize = options.maxSize ?? DEFAULT_REPLAY_STORE_MAX_SIZE;
88
- this.defaultTtlSeconds = options.defaultTtlSeconds ?? DEFAULT_REPLAY_STORE_TTL_SECONDS;
148
+ this.maxSize = sanitizeCapacity(options.maxSize);
149
+ this.defaultTtlSeconds = sanitizeTtl(options.defaultTtlSeconds);
89
150
  this.now = options.now ?? Date.now;
90
151
  }
91
152
  /**
@@ -95,6 +156,7 @@ var InMemoryReplayStore = class {
95
156
  * @returns `true` if `eventId` is present and unexpired, `false` otherwise.
96
157
  */
97
158
  has(eventId) {
159
+ if (typeof eventId !== "string" || eventId.length === 0) return false;
98
160
  const expiresAt = this.entries.get(eventId);
99
161
  if (expiresAt === void 0) return false;
100
162
  if (this.now() >= expiresAt) {
@@ -110,22 +172,35 @@ var InMemoryReplayStore = class {
110
172
  * @param ttlSeconds - Time-to-live in seconds before expiration.
111
173
  */
112
174
  set(eventId, ttlSeconds = this.defaultTtlSeconds) {
113
- const effectiveTtl = Number.isFinite(ttlSeconds) ? ttlSeconds : this.defaultTtlSeconds;
175
+ if (typeof eventId !== "string" || eventId.length === 0) return;
176
+ const effectiveTtl = resolveInsertTtl(ttlSeconds, this.defaultTtlSeconds);
114
177
  if (effectiveTtl <= 0) {
115
178
  this.entries.delete(eventId);
116
179
  return;
117
180
  }
118
- if (this.entries.has(eventId)) this.entries.delete(eventId);
119
- else if (this.entries.size >= this.maxSize) this.evictExpiredOrOldest();
181
+ this.prepareInsertSlot(eventId);
120
182
  this.entries.set(eventId, this.now() + effectiveTtl * 1e3);
121
183
  }
122
184
  /**
185
+ * Atomically records `eventId` with TTL if it does not already exist in the store (set-if-not-exists).
186
+ *
187
+ * @param eventId - Unique webhook event or delivery identifier.
188
+ * @param ttlSeconds - Time-to-live in seconds before expiration.
189
+ * @returns `true` if newly recorded, `false` if already present in the store.
190
+ */
191
+ setNx(eventId, ttlSeconds = this.defaultTtlSeconds) {
192
+ if (this.has(eventId)) return false;
193
+ this.set(eventId, ttlSeconds);
194
+ return true;
195
+ }
196
+ /**
123
197
  * Removes a specific `eventId` from the store.
124
198
  *
125
199
  * @param eventId - Unique webhook event or delivery identifier.
126
200
  * @returns `true` if the entry existed and was removed.
127
201
  */
128
202
  delete(eventId) {
203
+ if (typeof eventId !== "string" || eventId.length === 0) return false;
129
204
  return this.entries.delete(eventId);
130
205
  }
131
206
  /**
@@ -160,8 +235,36 @@ var InMemoryReplayStore = class {
160
235
  get capacity() {
161
236
  return this.maxSize;
162
237
  }
238
+ /**
239
+ * Prepares a map slot for insertion by evicting an existing key or pruning capacity.
240
+ *
241
+ * @param eventId - Candidate event ID key.
242
+ */
243
+ prepareInsertSlot(eventId) {
244
+ if (this.entries.has(eventId)) {
245
+ this.entries.delete(eventId);
246
+ return;
247
+ }
248
+ if (this.entries.size >= this.maxSize) this.evictExpiredOrOldest();
249
+ }
250
+ /**
251
+ * Fast-path check from the head of the insertion-ordered Map to purge expired entries.
252
+ *
253
+ * @param currentMs - Current time in milliseconds.
254
+ */
255
+ evictHeadExpired(currentMs) {
256
+ for (const [key, expiresAt] of this.entries) if (currentMs >= expiresAt) {
257
+ this.entries.delete(key);
258
+ if (this.entries.size < this.maxSize) return;
259
+ } else break;
260
+ }
163
261
  evictExpiredOrOldest() {
164
- this.prune();
262
+ const currentMs = this.now();
263
+ this.evictHeadExpired(currentMs);
264
+ if (this.entries.size >= this.maxSize && (currentMs - this.lastPruneMs >= 1e3 || currentMs < this.lastPruneMs)) {
265
+ this.lastPruneMs = currentMs;
266
+ this.prune();
267
+ }
165
268
  if (this.entries.size < this.maxSize) return;
166
269
  const oldestKey = this.entries.keys().next().value;
167
270
  if (oldestKey !== void 0) this.entries.delete(oldestKey);
@@ -177,22 +280,6 @@ function createInMemoryReplayStore(options) {
177
280
  return new InMemoryReplayStore(options);
178
281
  }
179
282
  /**
180
- * Case-insensitively extracts a single header value from a `Headers` instance or plain record.
181
- *
182
- * @param headers - Web standard `Headers` instance or record.
183
- * @param targetName - Lowercase target header name.
184
- * @returns Header value string or undefined.
185
- */
186
- function getHeaderCaseInsensitive(headers, targetName) {
187
- if (typeof Headers !== "undefined" && headers instanceof Headers) return headers.get(targetName) ?? void 0;
188
- const record = headers;
189
- const lowerTarget = targetName.toLowerCase();
190
- for (const key of Object.keys(record)) if (key.toLowerCase() === lowerTarget) {
191
- const value = record[key];
192
- return Array.isArray(value) ? value[0] : value;
193
- }
194
- }
195
- /**
196
283
  * Searches candidate header names in a `Headers` or record object for a non-empty value.
197
284
  *
198
285
  * @param headers - Headers source.
@@ -202,11 +289,46 @@ function getHeaderCaseInsensitive(headers, targetName) {
202
289
  function findHeaderValue(headers, candidateNames) {
203
290
  if (!headers) return;
204
291
  for (const name of candidateNames) {
205
- const trimmed = getHeaderCaseInsensitive(headers, name)?.trim();
292
+ const trimmed = extractHeaderValue$1(headers, name)?.trim();
206
293
  if (trimmed) return trimmed;
207
294
  }
208
295
  }
209
296
  /**
297
+ * Determines whether a candidate value is a non-null, non-array object record.
298
+ *
299
+ * @param value - Candidate value.
300
+ * @returns `true` if value is a plain object record.
301
+ */
302
+ function isPlainRecord(value) {
303
+ return typeof value === "object" && value !== null && !Array.isArray(value);
304
+ }
305
+ /**
306
+ * Parses a raw JSON string into a record object, ignoring non-object outputs.
307
+ *
308
+ * @param rawBody - Raw body text.
309
+ * @returns Parsed object record or null.
310
+ */
311
+ function parseJsonRecord(rawBody) {
312
+ if (typeof rawBody !== "string" || rawBody.length === 0) return null;
313
+ try {
314
+ const parsed = JSON.parse(rawBody);
315
+ return isPlainRecord(parsed) ? parsed : null;
316
+ } catch {
317
+ return null;
318
+ }
319
+ }
320
+ /**
321
+ * Resolves a record object from either an already-parsed payload or a raw JSON string.
322
+ *
323
+ * @param payload - Parsed payload value.
324
+ * @param rawBody - Raw body string.
325
+ * @returns Object record or null.
326
+ */
327
+ function resolvePayloadObject(payload, rawBody) {
328
+ if (isPlainRecord(payload)) return payload;
329
+ return parseJsonRecord(rawBody);
330
+ }
331
+ /**
210
332
  * Extracts a string or finite number `id` property from a parsed payload or raw JSON body.
211
333
  *
212
334
  * @param payload - Already parsed payload object if available.
@@ -221,23 +343,6 @@ function extractIdFromPayload(payload, rawBody) {
221
343
  if (typeof idValue === "number" && Number.isFinite(idValue)) return String(idValue);
222
344
  }
223
345
  /**
224
- * Resolves a record object from either an already-parsed payload or a raw JSON string.
225
- *
226
- * @param payload - Parsed payload value.
227
- * @param rawBody - Raw body string.
228
- * @returns Object record or null.
229
- */
230
- function resolvePayloadObject(payload, rawBody) {
231
- if (typeof payload === "object" && payload !== null) return payload;
232
- if (!rawBody) return null;
233
- try {
234
- const parsed = JSON.parse(rawBody);
235
- return typeof parsed === "object" && parsed !== null ? parsed : null;
236
- } catch {
237
- return null;
238
- }
239
- }
240
- /**
241
346
  * Extracts a matching header value from a Web Standard `Request` body if provided.
242
347
  *
243
348
  * @param body - Candidate raw body input.
@@ -248,6 +353,19 @@ function findHeaderInRequest(body, headerCandidates) {
248
353
  if (typeof Request !== "undefined" && body instanceof Request) return findHeaderValue(body.headers, headerCandidates);
249
354
  }
250
355
  /**
356
+ * Sanitizes an explicitly provided `eventId` option into a non-empty string.
357
+ *
358
+ * @param eventId - Candidate event ID.
359
+ * @returns Trimmed event ID or undefined.
360
+ */
361
+ function resolveExplicitEventId(eventId) {
362
+ if (typeof eventId === "string") {
363
+ const trimmed = eventId.trim();
364
+ return trimmed.length > 0 ? trimmed : void 0;
365
+ }
366
+ if (typeof eventId === "number" && Number.isFinite(eventId)) return String(eventId);
367
+ }
368
+ /**
251
369
  * Resolves the unique webhook event ID from explicit options, delivery headers, or payload `id`.
252
370
  *
253
371
  * @param options - Source options containing explicit `eventId`, `headers`, `body`, `payload`, and `rawBody`.
@@ -255,7 +373,7 @@ function findHeaderInRequest(body, headerCandidates) {
255
373
  * @returns Resolved non-empty event ID string or `undefined`.
256
374
  */
257
375
  function resolveWebhookEventId(options, headerCandidates = GENERIC_EVENT_ID_HEADERS) {
258
- const explicitId = options.eventId?.trim();
376
+ const explicitId = resolveExplicitEventId(options.eventId);
259
377
  if (explicitId) return explicitId;
260
378
  const fromHeaders = findHeaderValue(options.headers, headerCandidates) ?? findHeaderInRequest(options.body, headerCandidates);
261
379
  if (fromHeaders) return fromHeaders;
@@ -269,26 +387,78 @@ function resolveWebhookEventId(options, headerCandidates = GENERIC_EVENT_ID_HEAD
269
387
  * @returns Positive TTL in seconds (defaults to 300s).
270
388
  */
271
389
  function resolveEffectiveTtl(storeTtlSeconds, toleranceSeconds) {
272
- if (storeTtlSeconds !== void 0 && storeTtlSeconds > 0) return storeTtlSeconds;
273
- if (toleranceSeconds !== void 0 && toleranceSeconds > 0) return toleranceSeconds;
274
- return DEFAULT_REPLAY_STORE_TTL_SECONDS;
390
+ if (typeof storeTtlSeconds === "number" && Number.isFinite(storeTtlSeconds) && storeTtlSeconds > 0) return storeTtlSeconds;
391
+ if (typeof toleranceSeconds === "number" && Number.isFinite(toleranceSeconds) && toleranceSeconds > 0) return toleranceSeconds;
392
+ return 300;
393
+ }
394
+ /**
395
+ * Checks and records an event ID using a store's basic `has` and `set` primitives.
396
+ *
397
+ * @param store - Webhook replay store.
398
+ * @param eventId - Resolved event identifier.
399
+ * @param ttlSeconds - Time-to-live in seconds.
400
+ * @returns `ReplayAttackError` if already seen, or `null` on success.
401
+ */
402
+ async function executeStoreCheckAndRecord(store, eventId, ttlSeconds) {
403
+ if (await store.has(eventId)) return new ReplayAttackError(eventId);
404
+ await store.set(eventId, ttlSeconds);
405
+ return null;
406
+ }
407
+ /**
408
+ * Synchronizes concurrent in-flight checks for the same `eventId` across stores lacking atomic operations.
409
+ *
410
+ * @param store - Webhook replay store.
411
+ * @param eventId - Resolved event identifier.
412
+ * @param ttlSeconds - Time-to-live in seconds.
413
+ * @returns `ReplayAttackError` if duplicate or already tracked, or `null` on success.
414
+ */
415
+ async function handleInFlightReplayCheck(store, eventId, ttlSeconds) {
416
+ let storeInFlight = inFlightPerStore.get(store);
417
+ if (!storeInFlight) {
418
+ storeInFlight = /* @__PURE__ */ new Map();
419
+ inFlightPerStore.set(store, storeInFlight);
420
+ }
421
+ const existing = storeInFlight.get(eventId);
422
+ if (existing) {
423
+ const result = await existing;
424
+ return result === null ? new ReplayAttackError(eventId) : result;
425
+ }
426
+ const checkPromise = executeStoreCheckAndRecord(store, eventId, ttlSeconds);
427
+ storeInFlight.set(eventId, checkPromise);
428
+ try {
429
+ return await checkPromise;
430
+ } finally {
431
+ storeInFlight.delete(eventId);
432
+ }
275
433
  }
276
434
  /**
277
435
  * Verifies that `eventId` has not already been seen in `store`, and records it with a TTL if unseen.
436
+ * Automatically leverages atomic `setNx` when supported, or serializes in-flight checks to prevent
437
+ * TOCTOU race conditions under high concurrency.
278
438
  *
279
439
  * @param options - Store instance, resolved `eventId`, and TTL configuration.
280
440
  * @returns `ReplayAttackError` if `eventId` is already present in `store`, or `null` if unseen / no store.
281
441
  */
282
442
  async function checkAndRecordReplay(options) {
283
- const { store, eventId } = options;
284
- if (!store || !eventId) return null;
285
- if (await store.has(eventId)) return new ReplayAttackError(eventId);
443
+ const { store } = options;
444
+ if (!store) return null;
445
+ const resolvedId = resolveExplicitEventId(options.eventId);
446
+ if (!resolvedId) return null;
286
447
  const ttlSeconds = resolveEffectiveTtl(options.storeTtlSeconds, options.toleranceSeconds);
287
- await store.set(eventId, ttlSeconds);
288
- return null;
448
+ if (typeof store.setNx === "function") return await store.setNx(resolvedId, ttlSeconds) ? null : new ReplayAttackError(resolvedId);
449
+ return handleInFlightReplayCheck(store, resolvedId, ttlSeconds);
289
450
  }
290
451
  // src/crypto/subtle.ts
291
452
  /**
453
+ * Sanitizes and bounds the maximum capacity for `CryptoKeyLruCache`.
454
+ *
455
+ * @param size - Candidate capacity option.
456
+ * @returns Positive bounded integer capacity.
457
+ */
458
+ function sanitizeCacheCapacity(size) {
459
+ return typeof size === "number" && Number.isFinite(size) && size > 0 ? Math.floor(size) : 256;
460
+ }
461
+ /**
292
462
  * Lightweight LRU (Least Recently Used) cache backed by a JavaScript `Map`.
293
463
  * Reuses compiled WebCrypto keys across concurrent requests and evicts the
294
464
  * oldest entries when capacity is reached.
@@ -297,7 +467,7 @@ var CryptoKeyLruCache = class {
297
467
  maxSize;
298
468
  map = /* @__PURE__ */ new Map();
299
469
  constructor(maxSize = 256) {
300
- this.maxSize = maxSize;
470
+ this.maxSize = sanitizeCacheCapacity(maxSize);
301
471
  }
302
472
  get(key) {
303
473
  const item = this.map.get(key);
@@ -384,7 +554,7 @@ function getOrCreateCryptoKey(subtle, normalizedHash, secretBytes, cache = defau
384
554
  if (cachedPromise) return cachedPromise;
385
555
  const keyPromise = importHmacKey(subtle, normalizedHash, secretBytes);
386
556
  keyPromise.catch(() => {
387
- cache.delete(cacheKey);
557
+ if (cache.get(cacheKey) === keyPromise) cache.delete(cacheKey);
388
558
  });
389
559
  cache.set(cacheKey, keyPromise);
390
560
  return keyPromise;
@@ -513,6 +683,7 @@ function bytesToHex(bytes) {
513
683
  */
514
684
  function hexToBytes(hex) {
515
685
  const cleanHex = hex.startsWith("0x") ? hex.slice(2) : hex;
686
+ if (!/^[0-9a-fA-F]*$/.test(cleanHex)) throw new TypeError("Invalid hexadecimal string: contains non-hexadecimal characters");
516
687
  const normalizedHex = cleanHex.length % 2 === 1 ? `0${cleanHex}` : cleanHex;
517
688
  const bytes = new Uint8Array(normalizedHex.length / 2);
518
689
  for (let i = 0; i < normalizedHex.length; i += 2) bytes[i / 2] = parseInt(normalizedHex.slice(i, i + 2), 16);
@@ -546,6 +717,75 @@ function base64ToBytes(base64) {
546
717
  for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
547
718
  return bytes;
548
719
  }
720
+ // src/providers/common/assertions.ts
721
+ /**
722
+ * Asserts that a header value is a non-empty string.
723
+ *
724
+ * @param header - Candidate header value.
725
+ * @param errorMessage - Error message to throw if header is invalid or missing.
726
+ */
727
+ function assertNonEmptyHeader(header, errorMessage) {
728
+ if (typeof header !== "string" || !header.trim()) throw new InvalidSignatureHeaderError(errorMessage);
729
+ }
730
+ // src/providers/github/signature.ts
731
+ const GITHUB_SIGNATURE_PREFIX = "sha256=";
732
+ const GITHUB_SIGNATURE_HEX_REGEX = /^[0-9a-fA-F]{64}$/;
733
+ const GITHUB_EVENT_ID_HEADERS = [
734
+ "x-github-delivery",
735
+ "x-webhook-id",
736
+ "webhook-id"
737
+ ];
738
+ /**
739
+ * Parses an `X-Hub-Signature-256` header, validating and extracting the hex signature.
740
+ *
741
+ * @param header - Raw `X-Hub-Signature-256` header string (e.g. `sha256=d57c68d1...`).
742
+ * @returns Hexadecimal signature string after the `sha256=` prefix.
743
+ * @throws {InvalidSignatureHeaderError} If header is missing, empty, or lacks a valid sha256= prefix.
744
+ */
745
+ function parseGithubSignature(header) {
746
+ assertNonEmptyHeader(header, "Missing or empty X-Hub-Signature-256 header");
747
+ const trimmed = header.trim();
748
+ if (!trimmed.toLowerCase().startsWith("sha256=")) throw new InvalidSignatureHeaderError("Invalid X-Hub-Signature-256 header format: expected 'sha256=' prefix");
749
+ const signature = trimmed.slice(7).trim();
750
+ if (!signature || !GITHUB_SIGNATURE_HEX_REGEX.test(signature)) throw new InvalidSignatureHeaderError("Invalid X-Hub-Signature-256 header: missing or malformed 64-character hex signature after prefix");
751
+ return signature;
752
+ }
753
+ /**
754
+ * Resolves the raw signature header string from options, headers record, or request.
755
+ *
756
+ * @param options - GitHub verification options.
757
+ * @returns GitHub signature header string or undefined if not found.
758
+ */
759
+ function resolveGithubSignatureHeader(options) {
760
+ if (options.signature) return options.signature;
761
+ if (options.headers) return extractHeaderValue$1(options.headers, "x-hub-signature-256");
762
+ if (typeof Request !== "undefined" && options.body instanceof Request) return options.body.headers.get("x-hub-signature-256") ?? void 0;
763
+ }
764
+ /**
765
+ * Validates preconditions for GitHub webhook verification.
766
+ *
767
+ * @param options - GitHub verification options.
768
+ * @param rawHeader - Resolved header string.
769
+ * @returns Error if preconditions fail, null otherwise.
770
+ */
771
+ function validateGithubPreconditions(options, rawHeader) {
772
+ if (!options.secret) return new SignatureVerificationError("Missing GitHub webhook signing secret");
773
+ if (!rawHeader) return new InvalidSignatureHeaderError("Missing X-Hub-Signature-256 header");
774
+ return null;
775
+ }
776
+ /**
777
+ * Safely parses the raw signature header without uncaught exceptions.
778
+ *
779
+ * @param rawHeader - Raw header string.
780
+ * @returns Extracted signature string or error object.
781
+ */
782
+ function safeParseGithubHeader(rawHeader) {
783
+ try {
784
+ return { signature: parseGithubSignature(rawHeader) };
785
+ } catch (err) {
786
+ return { error: err instanceof WebhookKitError ? err : new InvalidSignatureHeaderError(String(err)) };
787
+ }
788
+ }
549
789
  // src/utils/extract-body.ts
550
790
  /**
551
791
  * Checks whether an unknown value is a binary BufferSource (ArrayBuffer or ArrayBufferView).
@@ -641,101 +881,23 @@ async function extractRawBody(body) {
641
881
  if (isReadableStream(body)) return readReadableStream(body);
642
882
  throw new PayloadExtractionError("Unsupported body type. Expected string, Uint8Array, ArrayBuffer, ReadableStream, or Request.");
643
883
  }
644
- // src/providers/github.ts
645
- const GITHUB_SIGNATURE_PREFIX = "sha256=";
646
- const GITHUB_EVENT_ID_HEADERS = [
647
- "x-github-delivery",
648
- "x-webhook-id",
649
- "webhook-id"
650
- ];
651
- /**
652
- * Asserts that a header value is a non-empty string.
653
- *
654
- * @param header - Candidate header value.
655
- */
656
- function assertNonEmptyHeader$2(header) {
657
- if (typeof header !== "string" || !header.trim()) throw new InvalidSignatureHeaderError("Missing or empty X-Hub-Signature-256 header");
658
- }
659
- /**
660
- * Parses an `X-Hub-Signature-256` header, validating and extracting the hex signature.
661
- *
662
- * @param header - Raw `X-Hub-Signature-256` header string (e.g. `sha256=d57c68d1...`).
663
- * @returns Hexadecimal signature string after the `sha256=` prefix.
664
- * @throws {InvalidSignatureHeaderError} If header is missing, empty, or lacks a valid sha256= prefix.
665
- */
666
- function parseGithubSignature(header) {
667
- assertNonEmptyHeader$2(header);
668
- const trimmed = header.trim();
669
- if (!trimmed.toLowerCase().startsWith(GITHUB_SIGNATURE_PREFIX)) throw new InvalidSignatureHeaderError("Invalid X-Hub-Signature-256 header format: expected 'sha256=' prefix");
670
- const signature = trimmed.slice(7).trim();
671
- if (!signature) throw new InvalidSignatureHeaderError("Invalid X-Hub-Signature-256 header: missing signature hash after prefix");
672
- return signature;
673
- }
674
- /**
675
- * Case-insensitively extracts a header value from a `Headers` instance or plain record.
676
- *
677
- * @param headers - Web standard `Headers` instance or record.
678
- * @param targetName - Lowercase target header name.
679
- * @returns Header value string or undefined.
680
- */
681
- function extractHeaderValue$3(headers, targetName) {
682
- if (typeof Headers !== "undefined" && headers instanceof Headers) return headers.get(targetName) ?? void 0;
683
- const record = headers;
684
- const lowerTarget = targetName.toLowerCase();
685
- for (const key of Object.keys(record)) if (key.toLowerCase() === lowerTarget) {
686
- const value = record[key];
687
- return Array.isArray(value) ? value[0] : value;
688
- }
689
- }
690
- /**
691
- * Resolves the raw signature header string from options, headers record, or request.
692
- *
693
- * @param options - GitHub verification options.
694
- * @returns GitHub signature header string or undefined if not found.
695
- */
696
- function resolveSignatureHeader$2(options) {
697
- if (options.signature) return options.signature;
698
- if (options.headers) return extractHeaderValue$3(options.headers, "x-hub-signature-256");
699
- if (typeof Request !== "undefined" && options.body instanceof Request) return options.body.headers.get("x-hub-signature-256") ?? void 0;
700
- }
701
- /**
702
- * Validates preconditions for GitHub webhook verification.
703
- *
704
- * @param options - GitHub verification options.
705
- * @param rawHeader - Resolved header string.
706
- * @returns Error if preconditions fail, null otherwise.
707
- */
708
- function validatePreconditions$2(options, rawHeader) {
709
- if (!options.secret) return new SignatureVerificationError("Missing GitHub webhook signing secret");
710
- if (!rawHeader) return new InvalidSignatureHeaderError("Missing X-Hub-Signature-256 header");
711
- return null;
712
- }
713
- /**
714
- * Safely parses the raw signature header without uncaught exceptions.
715
- *
716
- * @param rawHeader - Raw header string.
717
- * @returns Extracted signature string or error object.
718
- */
719
- function safeParseHeader$2(rawHeader) {
720
- try {
721
- return { signature: parseGithubSignature(rawHeader) };
722
- } catch (err) {
723
- return { error: err instanceof WebhookKitError ? err : new InvalidSignatureHeaderError(String(err)) };
724
- }
725
- }
884
+ // src/providers/common/helpers.ts
726
885
  /**
727
886
  * Constructs a failed WebhookVerificationResult or throws if throwOnError is set.
728
887
  *
729
888
  * @param rawBody - Raw body string.
730
889
  * @param error - WebhookKitError instance.
731
890
  * @param throwOnError - Optional flag to throw immediately.
891
+ * @param timestamp - Optional parsed timestamp.
892
+ * @param eventId - Optional webhook event ID.
732
893
  * @returns Failed verification result.
733
894
  */
734
- function failVerification$3(rawBody, error, throwOnError, eventId) {
895
+ function failVerification(rawBody, error, throwOnError, timestamp, eventId) {
735
896
  if (throwOnError) throw error;
736
897
  return {
737
898
  valid: false,
738
899
  rawBody,
900
+ timestamp,
739
901
  eventId,
740
902
  error
741
903
  };
@@ -746,13 +908,14 @@ function failVerification$3(rawBody, error, throwOnError, eventId) {
746
908
  * @param rawBody - Raw body payload string.
747
909
  * @returns Parsed payload or undefined.
748
910
  */
749
- function tryParseJson$3(rawBody) {
911
+ function tryParseJson(rawBody) {
750
912
  try {
751
913
  return JSON.parse(rawBody);
752
914
  } catch {
753
915
  return;
754
916
  }
755
917
  }
918
+ // src/providers/github/verify.ts
756
919
  /**
757
920
  * Verifies a GitHub webhook payload signature against `X-Hub-Signature-256`.
758
921
  *
@@ -763,13 +926,13 @@ function tryParseJson$3(rawBody) {
763
926
  */
764
927
  async function verifyGithubWebhook(options) {
765
928
  const rawBody = await extractRawBody(options.body);
766
- const rawHeader = resolveSignatureHeader$2(options);
767
- const preconditionError = validatePreconditions$2(options, rawHeader);
768
- if (preconditionError) return failVerification$3(rawBody, preconditionError, options.throwOnError);
769
- const parsed = safeParseHeader$2(rawHeader);
770
- if ("error" in parsed) return failVerification$3(rawBody, parsed.error, options.throwOnError);
771
- if (!timingSafeEqual((await computeSubtleHmac("SHA-256", options.secret, rawBody, "hex")).toLowerCase(), parsed.signature.toLowerCase())) return failVerification$3(rawBody, new SignatureVerificationError("GitHub signature verification failed: signature does not match expected HMAC"), options.throwOnError);
772
- const payload = options.parseJson !== false ? tryParseJson$3(rawBody) : void 0;
929
+ const rawHeader = resolveGithubSignatureHeader(options);
930
+ const preconditionError = validateGithubPreconditions(options, rawHeader);
931
+ if (preconditionError) return failVerification(rawBody, preconditionError, options.throwOnError);
932
+ const parsed = safeParseGithubHeader(rawHeader);
933
+ if ("error" in parsed) return failVerification(rawBody, parsed.error, options.throwOnError);
934
+ if (!timingSafeEqual((await computeSubtleHmac("SHA-256", options.secret, rawBody, "hex")).toLowerCase(), parsed.signature.toLowerCase())) return failVerification(rawBody, new SignatureVerificationError("GitHub signature verification failed: signature does not match expected HMAC"), options.throwOnError);
935
+ const payload = options.parseJson !== false ? tryParseJson(rawBody) : void 0;
773
936
  const eventId = resolveWebhookEventId({
774
937
  eventId: options.eventId,
775
938
  headers: options.headers,
@@ -783,7 +946,7 @@ async function verifyGithubWebhook(options) {
783
946
  storeTtlSeconds: options.storeTtlSeconds,
784
947
  toleranceSeconds: options.toleranceSeconds
785
948
  });
786
- if (replayError) return failVerification$3(rawBody, replayError, options.throwOnError, eventId);
949
+ if (replayError) return failVerification(rawBody, replayError, options.throwOnError, void 0, eventId);
787
950
  return {
788
951
  valid: true,
789
952
  rawBody,
@@ -812,7 +975,7 @@ const githubPlugin = {
812
975
  name: "github",
813
976
  verify: verifyGithubWebhook
814
977
  };
815
- // src/providers/shopify.ts
978
+ // src/providers/shopify/signature.ts
816
979
  const BASE64_REGEX = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
817
980
  const SHOPIFY_EVENT_ID_HEADERS = [
818
981
  "x-shopify-webhook-id",
@@ -820,14 +983,6 @@ const SHOPIFY_EVENT_ID_HEADERS = [
820
983
  "webhook-id"
821
984
  ];
822
985
  /**
823
- * Asserts that a header value is a non-empty string.
824
- *
825
- * @param header - Candidate header value.
826
- */
827
- function assertNonEmptyHeader$1(header) {
828
- if (typeof header !== "string" || !header.trim()) throw new InvalidSignatureHeaderError("Missing or empty X-Shopify-Hmac-Sha256 header");
829
- }
830
- /**
831
986
  * Parses an `X-Shopify-Hmac-Sha256` header, validating and normalizing the Base64 signature.
832
987
  *
833
988
  * @param header - Raw `X-Shopify-Hmac-Sha256` Base64 header string.
@@ -835,36 +990,20 @@ function assertNonEmptyHeader$1(header) {
835
990
  * @throws {InvalidSignatureHeaderError} If header is missing, empty, or not valid Base64.
836
991
  */
837
992
  function parseShopifySignature(header) {
838
- assertNonEmptyHeader$1(header);
993
+ assertNonEmptyHeader(header, "Missing or empty X-Shopify-Hmac-Sha256 header");
839
994
  const trimmed = header.trim();
840
- if (!BASE64_REGEX.test(trimmed)) throw new InvalidSignatureHeaderError("Invalid X-Shopify-Hmac-Sha256 header format: expected Base64-encoded string");
995
+ if (trimmed.length !== 44 || !BASE64_REGEX.test(trimmed)) throw new InvalidSignatureHeaderError("Invalid X-Shopify-Hmac-Sha256 header format: expected 44-character Base64-encoded SHA-256 digest");
841
996
  return trimmed;
842
997
  }
843
998
  /**
844
- * Case-insensitively extracts a header value from a `Headers` instance or plain record.
845
- *
846
- * @param headers - Web standard `Headers` instance or record.
847
- * @param targetName - Lowercase target header name.
848
- * @returns Header value string or undefined.
849
- */
850
- function extractHeaderValue$2(headers, targetName) {
851
- if (typeof Headers !== "undefined" && headers instanceof Headers) return headers.get(targetName) ?? void 0;
852
- const record = headers;
853
- const lowerTarget = targetName.toLowerCase();
854
- for (const key of Object.keys(record)) if (key.toLowerCase() === lowerTarget) {
855
- const value = record[key];
856
- return Array.isArray(value) ? value[0] : value;
857
- }
858
- }
859
- /**
860
999
  * Resolves the raw signature header string from options, headers record, or request.
861
1000
  *
862
1001
  * @param options - Shopify verification options.
863
1002
  * @returns Shopify signature header string or undefined if not found.
864
1003
  */
865
- function resolveSignatureHeader$1(options) {
1004
+ function resolveShopifySignatureHeader(options) {
866
1005
  if (options.signature) return options.signature;
867
- if (options.headers) return extractHeaderValue$2(options.headers, "x-shopify-hmac-sha256");
1006
+ if (options.headers) return extractHeaderValue$1(options.headers, "x-shopify-hmac-sha256");
868
1007
  if (typeof Request !== "undefined" && options.body instanceof Request) return options.body.headers.get("x-shopify-hmac-sha256") ?? void 0;
869
1008
  }
870
1009
  /**
@@ -874,7 +1013,7 @@ function resolveSignatureHeader$1(options) {
874
1013
  * @param rawHeader - Resolved header string.
875
1014
  * @returns Error if preconditions fail, null otherwise.
876
1015
  */
877
- function validatePreconditions$1(options, rawHeader) {
1016
+ function validateShopifyPreconditions(options, rawHeader) {
878
1017
  if (!options.secret) return new SignatureVerificationError("Missing Shopify webhook signing secret");
879
1018
  if (!rawHeader) return new InvalidSignatureHeaderError("Missing X-Shopify-Hmac-Sha256 header");
880
1019
  return null;
@@ -885,43 +1024,14 @@ function validatePreconditions$1(options, rawHeader) {
885
1024
  * @param rawHeader - Raw header string.
886
1025
  * @returns Extracted Base64 signature string or error object.
887
1026
  */
888
- function safeParseHeader$1(rawHeader) {
1027
+ function safeParseShopifyHeader(rawHeader) {
889
1028
  try {
890
1029
  return { signature: parseShopifySignature(rawHeader) };
891
1030
  } catch (err) {
892
1031
  return { error: err instanceof WebhookKitError ? err : new InvalidSignatureHeaderError(String(err)) };
893
1032
  }
894
1033
  }
895
- /**
896
- * Constructs a failed WebhookVerificationResult or throws if throwOnError is set.
897
- *
898
- * @param rawBody - Raw body string.
899
- * @param error - WebhookKitError instance.
900
- * @param throwOnError - Optional flag to throw immediately.
901
- * @returns Failed verification result.
902
- */
903
- function failVerification$2(rawBody, error, throwOnError, eventId) {
904
- if (throwOnError) throw error;
905
- return {
906
- valid: false,
907
- rawBody,
908
- eventId,
909
- error
910
- };
911
- }
912
- /**
913
- * Attempts to parse the raw body as JSON.
914
- *
915
- * @param rawBody - Raw body payload string.
916
- * @returns Parsed payload or undefined.
917
- */
918
- function tryParseJson$2(rawBody) {
919
- try {
920
- return JSON.parse(rawBody);
921
- } catch {
922
- return;
923
- }
924
- }
1034
+ // src/providers/shopify/verify.ts
925
1035
  /**
926
1036
  * Verifies a Shopify webhook payload signature against `X-Shopify-Hmac-Sha256`.
927
1037
  *
@@ -932,13 +1042,13 @@ function tryParseJson$2(rawBody) {
932
1042
  */
933
1043
  async function verifyShopifyWebhook(options) {
934
1044
  const rawBody = await extractRawBody(options.body);
935
- const rawHeader = resolveSignatureHeader$1(options);
936
- const preconditionError = validatePreconditions$1(options, rawHeader);
937
- if (preconditionError) return failVerification$2(rawBody, preconditionError, options.throwOnError);
938
- const parsed = safeParseHeader$1(rawHeader);
939
- if ("error" in parsed) return failVerification$2(rawBody, parsed.error, options.throwOnError);
940
- if (!timingSafeEqual(await computeSubtleHmac("SHA-256", options.secret, rawBody, "base64"), parsed.signature)) return failVerification$2(rawBody, new SignatureVerificationError("Shopify signature verification failed: signature does not match expected HMAC"), options.throwOnError);
941
- const payload = options.parseJson !== false ? tryParseJson$2(rawBody) : void 0;
1045
+ const rawHeader = resolveShopifySignatureHeader(options);
1046
+ const preconditionError = validateShopifyPreconditions(options, rawHeader);
1047
+ if (preconditionError) return failVerification(rawBody, preconditionError, options.throwOnError);
1048
+ const parsed = safeParseShopifyHeader(rawHeader);
1049
+ if ("error" in parsed) return failVerification(rawBody, parsed.error, options.throwOnError);
1050
+ if (!timingSafeEqual(await computeSubtleHmac("SHA-256", options.secret, rawBody, "base64"), parsed.signature)) return failVerification(rawBody, new SignatureVerificationError("Shopify signature verification failed: signature does not match expected HMAC"), options.throwOnError);
1051
+ const payload = options.parseJson !== false ? tryParseJson(rawBody) : void 0;
942
1052
  const eventId = resolveWebhookEventId({
943
1053
  eventId: options.eventId,
944
1054
  headers: options.headers,
@@ -952,7 +1062,7 @@ async function verifyShopifyWebhook(options) {
952
1062
  storeTtlSeconds: options.storeTtlSeconds,
953
1063
  toleranceSeconds: options.toleranceSeconds
954
1064
  });
955
- if (replayError) return failVerification$2(rawBody, replayError, options.throwOnError, eventId);
1065
+ if (replayError) return failVerification(rawBody, replayError, options.throwOnError, void 0, eventId);
956
1066
  return {
957
1067
  valid: true,
958
1068
  rawBody,
@@ -981,40 +1091,13 @@ const shopifyPlugin = {
981
1091
  name: "shopify",
982
1092
  verify: verifyShopifyWebhook
983
1093
  };
984
- // src/providers/stripe.ts
1094
+ // src/providers/stripe/signature.ts
985
1095
  const STRIPE_EVENT_ID_HEADERS = [
986
1096
  "x-stripe-webhook-id",
987
1097
  "x-webhook-id",
988
1098
  "webhook-id"
989
1099
  ];
990
1100
  /**
991
- * Case-insensitively extracts a header value from a `Headers` instance or plain record.
992
- *
993
- * @param headers - Web standard `Headers` instance or record.
994
- * @param targetName - Lowercase target header name.
995
- * @returns Header value string or undefined.
996
- */
997
- function extractHeaderValue$1(headers, targetName) {
998
- if (typeof Headers !== "undefined" && headers instanceof Headers) return headers.get(targetName) ?? void 0;
999
- const record = headers;
1000
- const lowerTarget = targetName.toLowerCase();
1001
- for (const key of Object.keys(record)) if (key.toLowerCase() === lowerTarget) {
1002
- const value = record[key];
1003
- return Array.isArray(value) ? value[0] : value;
1004
- }
1005
- }
1006
- /**
1007
- * Resolves the raw signature header string from options, headers record, or request.
1008
- *
1009
- * @param options - Stripe verification options.
1010
- * @returns Stripe signature header string or undefined if not found.
1011
- */
1012
- function resolveSignatureHeader(options) {
1013
- if (options.signature) return options.signature;
1014
- if (options.headers) return extractHeaderValue$1(options.headers, "stripe-signature");
1015
- if (typeof Request !== "undefined" && options.body instanceof Request) return options.body.headers.get("stripe-signature") ?? void 0;
1016
- }
1017
- /**
1018
1101
  * Splits a header tag pair into key and value.
1019
1102
  *
1020
1103
  * @param item - Header entry (e.g. `t=1492774577` or `v1=...`).
@@ -1049,14 +1132,6 @@ function processHeaderTag(key, value, state) {
1049
1132
  }
1050
1133
  }
1051
1134
  /**
1052
- * Asserts that a header value is a non-empty string.
1053
- *
1054
- * @param header - Candidate header value.
1055
- */
1056
- function assertNonEmptyHeader(header) {
1057
- if (typeof header !== "string" || !header.trim()) throw new InvalidSignatureHeaderError("Missing or empty Stripe-Signature header");
1058
- }
1059
- /**
1060
1135
  * Collects timestamp and signature schemes across comma-separated header entries.
1061
1136
  *
1062
1137
  * @param header - Non-empty header string.
@@ -1092,7 +1167,7 @@ function validateParsedState(timestamp, signaturesCount) {
1092
1167
  * @throws {InvalidSignatureHeaderError} If header format is invalid or missing required tags.
1093
1168
  */
1094
1169
  function parseStripeSignature(header) {
1095
- assertNonEmptyHeader(header);
1170
+ assertNonEmptyHeader(header, "Missing or empty Stripe-Signature header");
1096
1171
  const state = collectHeaderState(header);
1097
1172
  validateParsedState(state.timestamp, state.signatures.length);
1098
1173
  return {
@@ -1102,6 +1177,17 @@ function parseStripeSignature(header) {
1102
1177
  };
1103
1178
  }
1104
1179
  /**
1180
+ * Asserts that a timestamp number is a positive finite number.
1181
+ *
1182
+ * @param val - Candidate timestamp value.
1183
+ * @param name - Descriptive parameter name for error messages.
1184
+ * @param eventTimestamp - Event timestamp for error attribution.
1185
+ * @param tolerance - Tolerance window for error attribution.
1186
+ */
1187
+ function assertPositiveFiniteTimestamp(val, name, eventTimestamp, tolerance) {
1188
+ if (val !== void 0 && (!Number.isFinite(val) || val <= 0)) throw new TimestampToleranceError(eventTimestamp, tolerance, `Invalid ${name}: expected a positive finite number`);
1189
+ }
1190
+ /**
1105
1191
  * Validates that a webhook timestamp falls within the allowed tolerance window.
1106
1192
  *
1107
1193
  * @param timestamp - Unix timestamp in seconds from the signature header.
@@ -1110,34 +1196,23 @@ function parseStripeSignature(header) {
1110
1196
  * @throws {TimestampToleranceError} If timestamp falls outside the tolerance window.
1111
1197
  */
1112
1198
  function validateTimestampTolerance(timestamp, toleranceSeconds = 300, currentTimestamp) {
1199
+ if (!Number.isFinite(toleranceSeconds)) throw new TimestampToleranceError(timestamp, 0, "Invalid timestamp tolerance value: expected a finite number");
1113
1200
  if (toleranceSeconds <= 0) return;
1201
+ assertPositiveFiniteTimestamp(timestamp, "webhook timestamp", timestamp, toleranceSeconds);
1202
+ assertPositiveFiniteTimestamp(currentTimestamp, "current timestamp", timestamp, toleranceSeconds);
1114
1203
  const now = currentTimestamp ?? Math.floor(Date.now() / 1e3);
1115
1204
  if (Math.abs(now - timestamp) > toleranceSeconds) throw new TimestampToleranceError(timestamp, toleranceSeconds);
1116
1205
  }
1117
1206
  /**
1118
- * Compares an expected HMAC hex signature against candidate signatures in constant time.
1119
- *
1120
- * @param expectedSignature - Computed hex signature.
1121
- * @param candidates - List of candidate hex signatures from the header.
1122
- * @returns `true` if any candidate matches the expected signature.
1123
- */
1124
- function matchesAnySignature(expectedSignature, candidates) {
1125
- let matched = false;
1126
- for (const candidate of candidates) if (timingSafeEqual(expectedSignature, candidate)) matched = true;
1127
- return matched;
1128
- }
1129
- /**
1130
- * Attempts to parse the raw body as JSON.
1207
+ * Resolves the raw signature header string from options, headers record, or request.
1131
1208
  *
1132
- * @param rawBody - Raw body payload string.
1133
- * @returns Parsed payload or undefined.
1209
+ * @param options - Stripe verification options.
1210
+ * @returns Stripe signature header string or undefined if not found.
1134
1211
  */
1135
- function tryParseJson$1(rawBody) {
1136
- try {
1137
- return JSON.parse(rawBody);
1138
- } catch {
1139
- return;
1140
- }
1212
+ function resolveStripeSignatureHeader(options) {
1213
+ if (options.signature) return options.signature;
1214
+ if (options.headers) return extractHeaderValue$1(options.headers, "stripe-signature");
1215
+ if (typeof Request !== "undefined" && options.body instanceof Request) return options.body.headers.get("stripe-signature") ?? void 0;
1141
1216
  }
1142
1217
  /**
1143
1218
  * Validates preconditions for Stripe webhook verification.
@@ -1146,7 +1221,7 @@ function tryParseJson$1(rawBody) {
1146
1221
  * @param rawHeader - Resolved header string.
1147
1222
  * @returns Error if preconditions fail, null otherwise.
1148
1223
  */
1149
- function validatePreconditions(options, rawHeader) {
1224
+ function validateStripePreconditions(options, rawHeader) {
1150
1225
  if (!options.secret) return new SignatureVerificationError("Missing Stripe webhook signing secret");
1151
1226
  if (!rawHeader) return new InvalidSignatureHeaderError("Missing Stripe-Signature header");
1152
1227
  return null;
@@ -1157,7 +1232,7 @@ function validatePreconditions(options, rawHeader) {
1157
1232
  * @param rawHeader - Raw header string.
1158
1233
  * @returns Parsed header or error object.
1159
1234
  */
1160
- function safeParseHeader(rawHeader) {
1235
+ function safeParseStripeHeader(rawHeader) {
1161
1236
  try {
1162
1237
  return parseStripeSignature(rawHeader);
1163
1238
  } catch (err) {
@@ -1171,7 +1246,7 @@ function safeParseHeader(rawHeader) {
1171
1246
  * @param options - Stripe verification options.
1172
1247
  * @returns TimestampToleranceError if exceeded, null otherwise.
1173
1248
  */
1174
- function checkTolerance(timestamp, options) {
1249
+ function checkStripeTolerance(timestamp, options) {
1175
1250
  const tolerance = options.toleranceSeconds ?? 300;
1176
1251
  try {
1177
1252
  validateTimestampTolerance(timestamp, tolerance, options.currentTimestamp);
@@ -1181,24 +1256,19 @@ function checkTolerance(timestamp, options) {
1181
1256
  }
1182
1257
  }
1183
1258
  /**
1184
- * Constructs a failed WebhookVerificationResult or throws if throwOnError is set.
1259
+ * Compares an expected HMAC hex signature against candidate signatures in constant time.
1185
1260
  *
1186
- * @param rawBody - Raw body string.
1187
- * @param error - WebhookKitError instance.
1188
- * @param throwOnError - Optional flag to throw immediately.
1189
- * @param timestamp - Optional parsed timestamp.
1190
- * @returns Failed verification result.
1261
+ * @param expectedSignature - Computed hex signature.
1262
+ * @param candidates - List of candidate hex signatures from the header.
1263
+ * @returns `true` if any candidate matches the expected signature.
1191
1264
  */
1192
- function failVerification$1(rawBody, error, throwOnError, timestamp, eventId) {
1193
- if (throwOnError) throw error;
1194
- return {
1195
- valid: false,
1196
- rawBody,
1197
- timestamp,
1198
- eventId,
1199
- error
1200
- };
1265
+ function matchesAnyStripeSignature(expectedSignature, candidates) {
1266
+ let matched = false;
1267
+ const expectedLower = expectedSignature.trim().toLowerCase();
1268
+ for (const candidate of candidates) if (timingSafeEqual(expectedLower, candidate.trim().toLowerCase())) matched = true;
1269
+ return matched;
1201
1270
  }
1271
+ // src/providers/stripe/verify.ts
1202
1272
  /**
1203
1273
  * Verifies a Stripe webhook event signature.
1204
1274
  *
@@ -1210,17 +1280,17 @@ function failVerification$1(rawBody, error, throwOnError, timestamp, eventId) {
1210
1280
  */
1211
1281
  async function verifyStripeWebhook(options) {
1212
1282
  const rawBody = await extractRawBody(options.body);
1213
- const rawHeader = resolveSignatureHeader(options);
1214
- const preconditionError = validatePreconditions(options, rawHeader);
1215
- if (preconditionError) return failVerification$1(rawBody, preconditionError, options.throwOnError);
1216
- const parsedHeader = safeParseHeader(rawHeader);
1217
- if ("error" in parsedHeader) return failVerification$1(rawBody, parsedHeader.error, options.throwOnError);
1283
+ const rawHeader = resolveStripeSignatureHeader(options);
1284
+ const preconditionError = validateStripePreconditions(options, rawHeader);
1285
+ if (preconditionError) return failVerification(rawBody, preconditionError, options.throwOnError);
1286
+ const parsedHeader = safeParseStripeHeader(rawHeader);
1287
+ if ("error" in parsedHeader) return failVerification(rawBody, parsedHeader.error, options.throwOnError);
1218
1288
  const { timestamp, signatures } = parsedHeader;
1219
- const toleranceError = checkTolerance(timestamp, options);
1220
- if (toleranceError) return failVerification$1(rawBody, toleranceError, options.throwOnError, timestamp);
1289
+ const toleranceError = checkStripeTolerance(timestamp, options);
1290
+ if (toleranceError) return failVerification(rawBody, toleranceError, options.throwOnError, timestamp);
1221
1291
  const signedPayload = `${timestamp}.${rawBody}`;
1222
- if (!matchesAnySignature(await computeSubtleHmac("SHA-256", options.secret, signedPayload, "hex"), signatures)) return failVerification$1(rawBody, new SignatureVerificationError("Stripe signature verification failed: signature does not match expected HMAC"), options.throwOnError, timestamp);
1223
- const payload = options.parseJson !== false ? tryParseJson$1(rawBody) : void 0;
1292
+ if (!matchesAnyStripeSignature(await computeSubtleHmac("SHA-256", options.secret, signedPayload, "hex"), signatures)) return failVerification(rawBody, new SignatureVerificationError("Stripe signature verification failed: signature does not match expected HMAC"), options.throwOnError, timestamp);
1293
+ const payload = options.parseJson !== false ? tryParseJson(rawBody) : void 0;
1224
1294
  const eventId = resolveWebhookEventId({
1225
1295
  eventId: options.eventId,
1226
1296
  headers: options.headers,
@@ -1234,7 +1304,7 @@ async function verifyStripeWebhook(options) {
1234
1304
  storeTtlSeconds: options.storeTtlSeconds,
1235
1305
  toleranceSeconds: options.toleranceSeconds
1236
1306
  });
1237
- if (replayError) return failVerification$1(rawBody, replayError, options.throwOnError, timestamp, eventId);
1307
+ if (replayError) return failVerification(rawBody, replayError, options.throwOnError, timestamp, eventId);
1238
1308
  return {
1239
1309
  valid: true,
1240
1310
  rawBody,
@@ -1348,8 +1418,8 @@ function resolveRawTimestamp(options) {
1348
1418
  * @returns Positive integer timestamp or `null` if invalid.
1349
1419
  */
1350
1420
  function parseTimestampValue(rawTimestamp) {
1351
- const parsed = typeof rawTimestamp === "number" ? rawTimestamp : parseInt(String(rawTimestamp).trim(), 10);
1352
- if (!Number.isInteger(parsed) || parsed <= 0) return null;
1421
+ const parsed = typeof rawTimestamp === "number" ? Math.floor(rawTimestamp) : parseInt(String(rawTimestamp).trim(), 10);
1422
+ if (!Number.isFinite(parsed) || parsed <= 0) return null;
1353
1423
  return parsed;
1354
1424
  }
1355
1425
  /**
@@ -1433,7 +1503,7 @@ async function matchesGenericHmac(options, rawBody, rawSignature, timestamp) {
1433
1503
  * @param timestamp - Optional parsed timestamp.
1434
1504
  * @returns Failed verification result.
1435
1505
  */
1436
- function failVerification(rawBody, error, throwOnError, timestamp, eventId) {
1506
+ function failVerification$1(rawBody, error, throwOnError, timestamp, eventId) {
1437
1507
  if (throwOnError) throw error;
1438
1508
  return {
1439
1509
  valid: false,
@@ -1449,7 +1519,7 @@ function failVerification(rawBody, error, throwOnError, timestamp, eventId) {
1449
1519
  * @param rawBody - Raw body string.
1450
1520
  * @returns Parsed JSON value or `undefined`.
1451
1521
  */
1452
- function tryParseJson(rawBody) {
1522
+ function tryParseJson$1(rawBody) {
1453
1523
  try {
1454
1524
  return JSON.parse(rawBody);
1455
1525
  } catch {
@@ -1484,7 +1554,7 @@ async function runPluginVerification(plugin, options, enforceStoreAfterPlugin =
1484
1554
  storeTtlSeconds: options.storeTtlSeconds,
1485
1555
  toleranceSeconds: options.toleranceSeconds
1486
1556
  });
1487
- if (replayError) return failVerification(result.rawBody, replayError, options.throwOnError, result.timestamp, eventId);
1557
+ if (replayError) return failVerification$1(result.rawBody, replayError, options.throwOnError, result.timestamp, eventId);
1488
1558
  return {
1489
1559
  ...result,
1490
1560
  eventId
@@ -1501,7 +1571,7 @@ async function delegateToProvider(options) {
1501
1571
  if (typeof provider === "object") return runPluginVerification(provider, options, true);
1502
1572
  const normalizedName = String(provider).toLowerCase().trim();
1503
1573
  const builtin = PROVIDER_REGISTRY[normalizedName];
1504
- if (!builtin) return failVerification(await extractRawBody(options.body), new WebhookKitError(`Unsupported webhook provider: "${String(provider)}"`, "UNSUPPORTED_PROVIDER"), options.throwOnError);
1574
+ if (!builtin) return failVerification$1(await extractRawBody(options.body), new WebhookKitError(`Unsupported webhook provider: "${String(provider)}"`, "UNSUPPORTED_PROVIDER"), options.throwOnError);
1505
1575
  return runPluginVerification(builtin, options);
1506
1576
  }
1507
1577
  /**
@@ -1513,11 +1583,11 @@ async function delegateToProvider(options) {
1513
1583
  async function verifyGenericWebhook(options) {
1514
1584
  const rawBody = await extractRawBody(options.body);
1515
1585
  const rawSignature = resolveGenericSignature(options);
1516
- if (!hasSecret(options.secret) || !rawSignature) return failVerification(rawBody, new SignatureVerificationError("Missing signature or secret"), options.throwOnError);
1586
+ if (!hasSecret(options.secret) || !rawSignature) return failVerification$1(rawBody, new SignatureVerificationError("Missing signature or secret"), options.throwOnError);
1517
1587
  const tsResult = verifyGenericTimestamp(options);
1518
- if (tsResult.error) return failVerification(rawBody, tsResult.error, options.throwOnError);
1519
- if (!await matchesGenericHmac(options, rawBody, rawSignature, tsResult.timestamp)) return failVerification(rawBody, new SignatureVerificationError(), options.throwOnError, tsResult.timestamp);
1520
- const payload = options.parseJson !== false ? tryParseJson(rawBody) : void 0;
1588
+ if (tsResult.error) return failVerification$1(rawBody, tsResult.error, options.throwOnError);
1589
+ if (!await matchesGenericHmac(options, rawBody, rawSignature, tsResult.timestamp)) return failVerification$1(rawBody, new SignatureVerificationError(), options.throwOnError, tsResult.timestamp);
1590
+ const payload = options.parseJson !== false ? tryParseJson$1(rawBody) : void 0;
1521
1591
  const eventId = resolveWebhookEventId({
1522
1592
  eventId: options.eventId,
1523
1593
  headers: options.headers,
@@ -1531,7 +1601,7 @@ async function verifyGenericWebhook(options) {
1531
1601
  storeTtlSeconds: options.storeTtlSeconds,
1532
1602
  toleranceSeconds: options.toleranceSeconds
1533
1603
  });
1534
- if (replayError) return failVerification(rawBody, replayError, options.throwOnError, tsResult.timestamp, eventId);
1604
+ if (replayError) return failVerification$1(rawBody, replayError, options.throwOnError, tsResult.timestamp, eventId);
1535
1605
  return {
1536
1606
  valid: true,
1537
1607
  rawBody,
@@ -1552,7 +1622,7 @@ async function assertWebhookSignature(options) {
1552
1622
  if (!result.valid) throw result.error;
1553
1623
  return result.payload;
1554
1624
  }
1555
- // src/handler.ts
1625
+ // src/handler/handler.ts
1556
1626
  const JSON_CONTENT_TYPE = "application/json; charset=utf-8";
1557
1627
  const DEFAULT_SUCCESS_BODY = JSON.stringify({ received: true });
1558
1628
  /**
@@ -1733,6 +1803,6 @@ function createWebhookHandler(options) {
1733
1803
  }));
1734
1804
  };
1735
1805
  }
1736
- export { CryptoKeyLruCache, InMemoryReplayStore, InvalidSignatureHeaderError, PROVIDER_REGISTRY, PayloadExtractionError, ReplayAttackError, SignatureVerificationError, TimestampToleranceError, WebhookKitError, assertGithubWebhook, assertShopifyWebhook, assertStripeWebhook, assertWebhookSignature, base64ToBytes, bytesToBase64, bytesToHex, checkAndRecordReplay, clearCryptoKeyCache, computeSubtleHmac, createInMemoryReplayStore, createWebhookHandler, extractRawBody, getCachedCryptoKey, getCryptoKeyCacheSize, githubPlugin, hexToBytes, parseGithubSignature, parseShopifySignature, parseStripeSignature, resolveWebhookEventId, shopifyPlugin, stripePlugin, timingSafeEqual, validateTimestampTolerance, verifyGithubWebhook, verifyShopifyWebhook, verifyStripeWebhook, verifyWebhookSignature };
1806
+ export { BASE64_REGEX, CryptoKeyLruCache, DEFAULT_REPLAY_STORE_MAX_SIZE, DEFAULT_REPLAY_STORE_TTL_SECONDS, GENERIC_EVENT_ID_HEADERS, GITHUB_EVENT_ID_HEADERS, GITHUB_SIGNATURE_HEX_REGEX, GITHUB_SIGNATURE_PREFIX, InMemoryReplayStore, InvalidSignatureHeaderError, PROVIDER_REGISTRY, PayloadExtractionError, ReplayAttackError, SHOPIFY_EVENT_ID_HEADERS, STRIPE_EVENT_ID_HEADERS, SignatureVerificationError, TimestampToleranceError, WebhookKitError, assertGithubWebhook, assertNonEmptyHeader, assertShopifyWebhook, assertStripeWebhook, assertWebhookSignature, base64ToBytes, bytesToBase64, bytesToHex, checkAndRecordReplay, checkStripeTolerance, clearCryptoKeyCache, computeSubtleHmac, createInMemoryReplayStore, createWebhookHandler, extractRawBody, failVerification, getCachedCryptoKey, getCryptoKeyCacheSize, githubPlugin, hexToBytes, matchesAnyStripeSignature, parseGithubSignature, parseShopifySignature, parseStripeSignature, resolveGithubSignatureHeader, resolveShopifySignatureHeader, resolveStripeSignatureHeader, resolveWebhookEventId, safeParseGithubHeader, safeParseShopifyHeader, safeParseStripeHeader, shopifyPlugin, stripePlugin, timingSafeEqual, tryParseJson, validateGithubPreconditions, validateShopifyPreconditions, validateStripePreconditions, validateTimestampTolerance, verifyGithubWebhook, verifyShopifyWebhook, verifyStripeWebhook, verifyWebhookSignature };
1737
1807
 
1738
1808
  //# sourceMappingURL=index.mjs.map