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