@hookflo/tern 4.0.2 → 4.1.0

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.
@@ -16,5 +16,6 @@ export interface ExpressWebhookMiddlewareOptions {
16
16
  normalize?: boolean | NormalizeOptions;
17
17
  queue?: QueueOption;
18
18
  onError?: (error: Error) => void;
19
+ strictRawBody?: boolean;
19
20
  }
20
21
  export declare function createWebhookMiddleware(options: ExpressWebhookMiddlewareOptions): (req: ExpressLikeRequest, res: ExpressLikeResponse, next: ExpressLikeNext) => Promise<void>;
@@ -7,6 +7,15 @@ const shared_1 = require("./shared");
7
7
  function createWebhookMiddleware(options) {
8
8
  return async (req, res, next) => {
9
9
  try {
10
+ const strictRawBody = options.strictRawBody ?? true;
11
+ if (strictRawBody && (0, shared_1.hasParsedBody)(req)) {
12
+ res.status(400).json({
13
+ error: 'Webhook request body must be raw bytes. Configure express.raw({ type: "*/*" }) before this middleware.',
14
+ errorCode: 'VERIFICATION_ERROR',
15
+ platform: options.platform,
16
+ });
17
+ return;
18
+ }
10
19
  const webRequest = await (0, shared_1.toWebRequest)(req);
11
20
  if (options.queue) {
12
21
  const queueConfig = (0, queue_1.resolveQueueConfig)(options.queue);
@@ -17,7 +26,7 @@ function createWebhookMiddleware(options) {
17
26
  toleranceInSeconds: options.toleranceInSeconds ?? 300,
18
27
  });
19
28
  const bodyText = await queueResponse.text();
20
- let body = undefined;
29
+ let body;
21
30
  if (bodyText) {
22
31
  try {
23
32
  body = JSON.parse(bodyText);
@@ -8,6 +8,7 @@ export interface MinimalNodeRequest {
8
8
  url?: string;
9
9
  on?: (event: string, cb: (chunk?: unknown) => void) => void;
10
10
  }
11
+ export declare function hasParsedBody(request: MinimalNodeRequest): boolean;
11
12
  export declare function extractRawBody(request: MinimalNodeRequest): Promise<Uint8Array>;
12
13
  export declare function toHeadersInit(headers: Record<string, string | string[] | undefined>): HeadersInit;
13
14
  export declare function toWebRequest(request: MinimalNodeRequest): Promise<Request>;
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.hasParsedBody = hasParsedBody;
3
4
  exports.extractRawBody = extractRawBody;
4
5
  exports.toHeadersInit = toHeadersInit;
5
6
  exports.toWebRequest = toWebRequest;
@@ -10,6 +11,14 @@ function getHeaderValue(headers, name) {
10
11
  }
11
12
  return value;
12
13
  }
14
+ function hasParsedBody(request) {
15
+ const { body } = request;
16
+ return body !== null
17
+ && body !== undefined
18
+ && typeof body === 'object'
19
+ && !(body instanceof Uint8Array)
20
+ && !(body instanceof ArrayBuffer);
21
+ }
13
22
  async function readIncomingMessageBodyAsBuffer(request) {
14
23
  if (!request.on)
15
24
  return new Uint8Array(0);
@@ -52,7 +61,7 @@ async function extractRawBody(request) {
52
61
  if (typeof body === 'string') {
53
62
  return new TextEncoder().encode(body);
54
63
  }
55
- if (body !== null && body !== undefined && typeof body === 'object') {
64
+ if (hasParsedBody(request)) {
56
65
  console.warn('[Tern] Warning: request body is already parsed as JSON. '
57
66
  + 'Signature verification may fail. '
58
67
  + 'Add express.raw({ type: "*/*" }) before Tern on webhook routes, '
package/dist/index.d.ts CHANGED
@@ -8,6 +8,7 @@ export declare class WebhookVerificationService {
8
8
  static verifyWithPlatformConfig<TPayload = unknown>(request: Request, platform: WebhookPlatform, secret: string, toleranceInSeconds?: number, normalize?: boolean | NormalizeOptions): Promise<WebhookVerificationResult<TPayload>>;
9
9
  static verifyAny<TPayload = unknown>(request: Request, secrets: MultiPlatformSecrets, toleranceInSeconds?: number, normalize?: boolean | NormalizeOptions): Promise<WebhookVerificationResult<TPayload>>;
10
10
  private static resolveCanonicalEventId;
11
+ private static safeCompare;
11
12
  private static pickString;
12
13
  private static resolveRawEventId;
13
14
  static detectPlatform(request: Request): WebhookPlatform;
package/dist/index.js CHANGED
@@ -37,6 +37,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.getPlatformsByCategory = exports.getPlatformNormalizationCategory = exports.normalizePayload = exports.createCustomVerifier = exports.createAlgorithmVerifier = exports.validateSignatureConfig = exports.getPlatformsUsingAlgorithm = exports.platformUsesAlgorithm = exports.getPlatformAlgorithmConfig = exports.WebhookVerificationService = void 0;
40
+ const crypto_1 = require("crypto");
40
41
  const algorithms_1 = require("./verifiers/algorithms");
41
42
  const custom_algorithms_1 = require("./verifiers/custom-algorithms");
42
43
  const algorithms_2 = require("./platforms/algorithms");
@@ -48,7 +49,7 @@ class WebhookVerificationService {
48
49
  // Ensure the platform is set correctly in the result
49
50
  if (result.isValid) {
50
51
  result.platform = config.platform;
51
- result.eventId = this.resolveCanonicalEventId(config.platform, result.metadata, result.payload);
52
+ result.eventId = this.resolveCanonicalEventId(config.platform, result.metadata, result.payload) ?? undefined;
52
53
  if (config.normalize) {
53
54
  result.payload = (0, simple_1.normalizePayload)(config.platform, result.payload, config.normalize);
54
55
  }
@@ -103,18 +104,25 @@ class WebhookVerificationService {
103
104
  return this.verifyWithPlatformConfig(requestClone, detectedPlatform, secrets[detectedPlatform], toleranceInSeconds, normalize);
104
105
  }
105
106
  const failedAttempts = [];
106
- for (const [platform, secret] of Object.entries(secrets)) {
107
- if (!secret) {
108
- continue;
109
- }
110
- const result = await this.verifyWithPlatformConfig(requestClone, platform.toLowerCase(), secret, toleranceInSeconds, normalize);
111
- if (result.isValid) {
112
- return result;
113
- }
107
+ const verificationResults = await Promise.all(Object.entries(secrets)
108
+ .filter(([, secret]) => Boolean(secret))
109
+ .map(async ([platform, secret]) => {
110
+ const normalizedPlatform = platform.toLowerCase();
111
+ const result = await this.verifyWithPlatformConfig(requestClone, normalizedPlatform, secret, toleranceInSeconds, normalize);
112
+ return {
113
+ platform: normalizedPlatform,
114
+ result,
115
+ };
116
+ }));
117
+ const firstValid = verificationResults.find((item) => item.result.isValid);
118
+ if (firstValid) {
119
+ return firstValid.result;
120
+ }
121
+ for (const item of verificationResults) {
114
122
  failedAttempts.push({
115
- platform: platform.toLowerCase(),
116
- error: result.error,
117
- errorCode: result.errorCode,
123
+ platform: item.platform,
124
+ error: item.result.error,
125
+ errorCode: item.result.errorCode,
118
126
  });
119
127
  }
120
128
  const details = failedAttempts
@@ -134,8 +142,16 @@ class WebhookVerificationService {
134
142
  }
135
143
  static resolveCanonicalEventId(platform, metadata, payload) {
136
144
  const rawId = this.resolveRawEventId(platform, metadata, payload);
145
+ if (!rawId)
146
+ return null;
137
147
  return `${platform}_${rawId}`;
138
148
  }
149
+ static safeCompare(a, b) {
150
+ if (a.length !== b.length) {
151
+ return false;
152
+ }
153
+ return (0, crypto_1.timingSafeEqual)(new TextEncoder().encode(a), new TextEncoder().encode(b));
154
+ }
139
155
  static pickString(...candidates) {
140
156
  for (const candidate of candidates) {
141
157
  if (candidate === undefined || candidate === null) {
@@ -149,59 +165,55 @@ class WebhookVerificationService {
149
165
  return undefined;
150
166
  }
151
167
  static resolveRawEventId(platform, metadata, payload) {
152
- const candidate = (() => {
153
- switch (platform) {
154
- case 'stripe':
155
- return this.pickString(payload?.request?.idempotency_key, payload?.id, metadata?.id);
156
- case 'github':
157
- return this.pickString(metadata?.delivery, metadata?.id, payload?.id);
158
- case 'clerk':
159
- return this.pickString(metadata?.id, payload?.id);
160
- case 'shopify':
161
- return this.pickString(metadata?.id, payload?.id);
162
- case 'polar':
163
- return this.pickString(payload?.data?.id, payload?.id, metadata?.id);
164
- case 'dodopayments':
165
- return this.pickString(payload?.data?.payment_id, payload?.data?.subscription_id, payload?.data?.id, metadata?.id);
166
- case 'gitlab':
167
- return this.pickString(payload?.object_attributes?.id, payload?.project?.id, metadata?.id);
168
- case 'paddle':
169
- return this.pickString(payload?.event_id, payload?.data?.id, metadata?.id);
170
- case 'razorpay':
171
- return this.pickString(payload?.payload?.payment?.entity?.id, payload?.payload?.order?.entity?.id, payload?.payload?.subscription?.entity?.id, payload?.id, metadata?.id);
172
- case 'lemonsqueezy': {
173
- const eventName = this.pickString(payload?.meta?.event_name);
174
- const dataId = this.pickString(payload?.data?.id, payload?.id);
175
- if (eventName && dataId) {
176
- return `${eventName}_${dataId}`;
177
- }
178
- return this.pickString(dataId, metadata?.id);
179
- }
180
- case 'workos':
181
- case 'vercel':
182
- case 'replicateai':
183
- case 'sentry':
184
- return this.pickString(payload?.id, metadata?.id);
185
- case 'woocommerce':
186
- return this.pickString(payload?.id, metadata?.id);
187
- case 'falai':
188
- return this.pickString(payload?.request_id, metadata?.id);
189
- case 'grafana':
190
- return this.pickString(payload?.groupKey, payload?.alerts?.[0]?.fingerprint, metadata?.id);
191
- case 'doppler':
192
- return this.pickString(payload?.event?.id, metadata?.id);
193
- case 'sanity':
194
- return this.pickString(payload?.transactionId, payload?._id, metadata?.id);
195
- default:
196
- return this.pickString(payload?.idempotency_key, payload?.event_id, payload?.webhook_id, payload?.request_id, payload?.id, payload?.data?.id, metadata?.id, metadata?.delivery, metadata?.requestId);
168
+ switch (platform) {
169
+ case 'stripe':
170
+ return this.pickString(payload?.request?.idempotency_key, payload?.id) || null;
171
+ case 'github':
172
+ return this.pickString(metadata?.delivery) || null;
173
+ case 'clerk':
174
+ return this.pickString(metadata?.id) || null;
175
+ case 'shopify':
176
+ return this.pickString(metadata?.id, payload?.id) || null;
177
+ case 'paddle':
178
+ return this.pickString(payload?.event_id, payload?.data?.id) || null;
179
+ case 'polar':
180
+ return this.pickString(payload?.data?.id, payload?.id) || null;
181
+ case 'dodopayments':
182
+ return this.pickString(payload?.data?.payment_id, payload?.data?.subscription_id, payload?.data?.id) || null;
183
+ case 'falai':
184
+ return this.pickString(payload?.request_id) || null;
185
+ case 'replicateai':
186
+ case 'replicate':
187
+ return this.pickString(payload?.id) || null;
188
+ case 'workos':
189
+ case 'sentry':
190
+ case 'vercel':
191
+ return this.pickString(payload?.id) || null;
192
+ case 'doppler':
193
+ return this.pickString(payload?.event?.id, metadata?.id) || null;
194
+ case 'sanity':
195
+ return this.pickString(payload?.transactionId, payload?.['_id']) || null;
196
+ case 'razorpay':
197
+ return this.pickString(payload?.payload?.payment?.entity?.id, payload?.payload?.order?.entity?.id, payload?.payload?.subscription?.entity?.id) || null;
198
+ case 'lemonsqueezy': {
199
+ const dataId = this.pickString(payload?.data?.id);
200
+ if (!dataId)
201
+ return null;
202
+ const eventName = this.pickString(payload?.meta?.event_name);
203
+ return eventName ? `${eventName}_${dataId}` : null;
197
204
  }
198
- })();
199
- if (candidate)
200
- return candidate;
201
- return `generated-missing-${platform}`;
205
+ case 'gitlab':
206
+ return this.pickString(payload?.object_attributes?.id?.toString()) || null;
207
+ case 'grafana':
208
+ return this.pickString(payload?.groupKey, payload?.alerts?.[0]?.fingerprint) || null;
209
+ case 'woocommerce':
210
+ return this.pickString(payload?.id?.toString()) || null;
211
+ default:
212
+ return this.pickString(payload?.idempotency_key, payload?.event_id, payload?.webhook_id, payload?.id, metadata?.id, metadata?.delivery) || null;
213
+ }
202
214
  }
203
215
  static detectPlatform(request) {
204
- const headers = request.headers;
216
+ const { headers } = request;
205
217
  if (headers.has('stripe-signature'))
206
218
  return 'stripe';
207
219
  if (headers.has('x-hub-signature-256'))
@@ -271,8 +283,8 @@ class WebhookVerificationService {
271
283
  platform: 'custom',
272
284
  };
273
285
  }
274
- // Simple comparison - we don't actually verify, just check if tokens match
275
- const isValid = idHeader === webhookId && tokenHeader === webhookToken;
286
+ const isValid = this.safeCompare(idHeader, webhookId)
287
+ && this.safeCompare(tokenHeader, webhookToken);
276
288
  if (!isValid) {
277
289
  return {
278
290
  isValid: false,
@@ -293,7 +305,7 @@ class WebhookVerificationService {
293
305
  isValid: true,
294
306
  platform: 'custom',
295
307
  payload: payload,
296
- eventId: `custom:${idHeader}`,
308
+ eventId: `custom_${idHeader}`,
297
309
  metadata: {
298
310
  id: idHeader,
299
311
  algorithm: 'token-based',
@@ -39,16 +39,12 @@ exports.handleProcess = handleProcess;
39
39
  exports.handleQueuedRequest = handleQueuedRequest;
40
40
  const QStash = __importStar(require("@upstash/qstash"));
41
41
  const index_1 = require("../index");
42
- async function dynamicImport(modulePath) {
43
- return new Function('modulePath', 'return import(modulePath);')(modulePath);
44
- }
45
42
  async function loadQStashModules() {
46
43
  const optionalImports = await Promise.allSettled([
47
- dynamicImport('@upstash/qstash'),
48
- dynamicImport('@upstash/qstash/nextjs'),
49
- dynamicImport('@upstash/qstash/nuxt'),
50
- dynamicImport('@upstash/qstash/sveltekit'),
51
- dynamicImport('@upstash/qstash/cloudflare'),
44
+ Promise.resolve().then(() => __importStar(require('@upstash/qstash'))),
45
+ Promise.resolve().then(() => __importStar(require('@upstash/qstash/nextjs'))),
46
+ Promise.resolve().then(() => __importStar(require('@upstash/qstash/nuxt'))),
47
+ Promise.resolve().then(() => __importStar(require('@upstash/qstash/cloudflare'))),
52
48
  ]);
53
49
  return [
54
50
  QStash,
@@ -92,25 +88,24 @@ function resolveNestedConstructor(modules, key) {
92
88
  }
93
89
  async function createQStashReceiver(queueConfig) {
94
90
  const modules = await loadQStashModules();
95
- const receiverExport = resolveModuleExport(modules, 'Receiver')
91
+ const ReceiverCtor = resolveModuleExport(modules, 'Receiver')
96
92
  ?? resolveNestedConstructor(modules, 'Receiver');
97
- if (typeof receiverExport !== 'function') {
93
+ if (typeof ReceiverCtor !== 'function') {
98
94
  throw new Error('[tern] Incompatible @upstash/qstash version: Receiver export not found. Ensure @upstash/qstash is installed and up-to-date.');
99
95
  }
100
- return new receiverExport({
96
+ return new ReceiverCtor({
101
97
  currentSigningKey: queueConfig.signingKey,
102
98
  nextSigningKey: queueConfig.nextSigningKey,
103
99
  });
104
100
  }
105
101
  async function createQStashClient(queueConfig) {
106
102
  const modules = await loadQStashModules();
107
- const clientExport = resolveModuleExport(modules, 'Client');
108
- const resolvedClientExport = clientExport
103
+ const ClientCtor = resolveModuleExport(modules, 'Client')
109
104
  ?? resolveNestedConstructor(modules, 'Client');
110
- if (typeof resolvedClientExport !== 'function') {
105
+ if (typeof ClientCtor !== 'function') {
111
106
  throw new Error('[tern] Incompatible @upstash/qstash version: Client export not found. Ensure @upstash/qstash is installed and up-to-date.');
112
107
  }
113
- return new resolvedClientExport({ token: queueConfig.token });
108
+ return new ClientCtor({ token: queueConfig.token });
114
109
  }
115
110
  function nonRetryableResponse(message, status = 489) {
116
111
  return new Response(JSON.stringify({ error: message }), {
@@ -147,7 +142,7 @@ function toStableJson(value) {
147
142
  }
148
143
  async function resolveDeduplicationId(request, verificationResult) {
149
144
  const payload = verificationResult.payload;
150
- const headers = request.headers;
145
+ const { headers } = request;
151
146
  const payloadId = typeof payload?.id === 'string' ? payload.id : null;
152
147
  const payloadRequestId = typeof payload?.request_id === 'string' ? payload.request_id : null;
153
148
  const nestedPayloadId = payload?.data && typeof payload.data === 'object' && typeof payload.data.id === 'string'
@@ -157,9 +152,7 @@ async function resolveDeduplicationId(request, verificationResult) {
157
152
  headers.get('x-github-delivery') ||
158
153
  headers.get('idempotency-key') ||
159
154
  headers.get('upstash-deduplication-id') ||
160
- (verificationResult.eventId && !verificationResult.eventId.includes('generated-missing-')
161
- ? verificationResult.eventId
162
- : null) ||
155
+ verificationResult.eventId ||
163
156
  (typeof verificationResult.metadata?.id === 'string' ? verificationResult.metadata.id : null) ||
164
157
  payloadId ||
165
158
  payloadRequestId ||
@@ -185,7 +178,7 @@ async function handleReceive(request, platform, secret, queueConfig, toleranceIn
185
178
  metadata: verificationResult.metadata || {},
186
179
  };
187
180
  const deduplicationId = await resolveDeduplicationId(request, verificationResult);
188
- if (process.env.NODE_ENV == 'production') {
181
+ if (process.env.NODE_ENV !== 'production') {
189
182
  console.log(`[tern] deduplication-id: ${deduplicationId} (platform: ${platform})`);
190
183
  }
191
184
  const publishPayload = {
@@ -199,11 +192,60 @@ async function handleReceive(request, platform, secret, queueConfig, toleranceIn
199
192
  if (queueConfig.retries !== undefined) {
200
193
  publishPayload.retries = queueConfig.retries;
201
194
  }
202
- await client.publishJSON(publishPayload);
203
- return new Response(JSON.stringify({ queued: true }), {
204
- status: 200,
205
- headers: { 'Content-Type': 'application/json' },
206
- });
195
+ try {
196
+ await client.publishJSON(publishPayload);
197
+ return new Response(JSON.stringify({ queued: true }), {
198
+ status: 200,
199
+ headers: { 'Content-Type': 'application/json' },
200
+ });
201
+ }
202
+ catch (error) {
203
+ const status = error?.status ?? 0;
204
+ const errorBody = error?.body
205
+ ? (() => {
206
+ try {
207
+ return JSON.parse(error.body);
208
+ }
209
+ catch {
210
+ return {};
211
+ }
212
+ })()
213
+ : {};
214
+ console.error('[tern] QStash publish failed:', {
215
+ status,
216
+ message: String(error),
217
+ });
218
+ if (status === 400) {
219
+ return new Response(JSON.stringify({
220
+ error: 'Invalid payload — could not enqueue',
221
+ detail: errorBody?.error,
222
+ }), {
223
+ status: 489,
224
+ headers: {
225
+ 'Content-Type': 'application/json',
226
+ 'Upstash-NonRetryable-Error': 'true',
227
+ },
228
+ });
229
+ }
230
+ if (status === 401) {
231
+ return new Response(JSON.stringify({
232
+ error: '[tern] QStash token invalid — check QSTASH_TOKEN in env',
233
+ }), {
234
+ status: 503,
235
+ headers: { 'Content-Type': 'application/json' },
236
+ });
237
+ }
238
+ if (status === 429) {
239
+ return new Response(JSON.stringify({ error: 'QStash rate limited — retry shortly' }), {
240
+ status: 503,
241
+ headers: { 'Content-Type': 'application/json' },
242
+ });
243
+ }
244
+ return new Response(JSON.stringify({ error: 'Queue temporarily unavailable' }), {
245
+ status: 503,
246
+ headers: { 'Content-Type': 'application/json' },
247
+ });
248
+ }
207
249
  }
208
250
  async function handleProcess(request, handler, queueConfig) {
209
251
  const signature = request.headers.get('upstash-signature');
@@ -219,11 +261,22 @@ async function handleProcess(request, handler, queueConfig) {
219
261
  url: request.url,
220
262
  });
221
263
  if (verification === false) {
222
- return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' } });
264
+ return nonRetryableResponse('Invalid QStash signature', 401);
223
265
  }
224
266
  }
225
- catch {
226
- return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' } });
267
+ catch (error) {
268
+ const message = String(error).toLowerCase();
269
+ if (message.includes('invalid signature') ||
270
+ message.includes('signature') ||
271
+ message.includes('unauthorized') ||
272
+ message.includes('forbidden')) {
273
+ return nonRetryableResponse('Invalid QStash signature', 401);
274
+ }
275
+ console.error('[tern] QStash signature verification error:', String(error));
276
+ return new Response(JSON.stringify({ error: 'Signature verification temporarily unavailable' }), {
277
+ status: 503,
278
+ headers: { 'Content-Type': 'application/json' },
279
+ });
227
280
  }
228
281
  if (!handler) {
229
282
  return nonRetryableResponse('Queue processing requires a handler function');
@@ -9,6 +9,7 @@ export declare abstract class AlgorithmBasedVerifier extends WebhookVerifier {
9
9
  protected extractSignatures(request: Request): string[];
10
10
  protected extractTimestamp(request: Request): number | null;
11
11
  protected extractTimestampFromSignature(request: Request): number | null;
12
+ protected requiresTimestamp(): boolean;
12
13
  protected formatPayload(rawBody: string, request: Request): string;
13
14
  protected formatCustomPayload(rawBody: string, request: Request): string;
14
15
  protected verifyHMAC(payload: string, signature: string, algorithm?: string): boolean;
@@ -102,6 +102,29 @@ class AlgorithmBasedVerifier extends base_1.WebhookVerifier {
102
102
  const timestampKey = this.config.customConfig?.timestampKey || "t";
103
103
  return sigMap[timestampKey] ? parseInt(sigMap[timestampKey], 10) : null;
104
104
  }
105
+ requiresTimestamp() {
106
+ // fal.ai timestamp is part of the signed payload itself — always required
107
+ if (this.platform === 'falai')
108
+ return true;
109
+ // These platforms have timestampHeader in config but timestamp
110
+ // is optional in their spec — validate only if present, never mandate
111
+ const optionalTimestampPlatforms = ['vercel', 'sentry', 'grafana'];
112
+ if (optionalTimestampPlatforms.includes(this.platform))
113
+ return false;
114
+ // For all other platforms: infer from config
115
+ if (this.config.timestampHeader)
116
+ return true;
117
+ if (this.config.payloadFormat === 'timestamped')
118
+ return true;
119
+ if (this.config.payloadFormat === 'custom'
120
+ && this.config.customConfig?.payloadFormat
121
+ && `${this.config.customConfig.payloadFormat}`.includes('{timestamp}'))
122
+ return true;
123
+ if (this.config.headerFormat === 'comma-separated'
124
+ && this.config.customConfig?.timestampKey)
125
+ return true;
126
+ return false;
127
+ }
105
128
  formatPayload(rawBody, request) {
106
129
  switch (this.config.payloadFormat) {
107
130
  case "timestamped": {
@@ -273,6 +296,14 @@ class GenericHMACVerifier extends AlgorithmBasedVerifier {
273
296
  else {
274
297
  timestamp = this.extractTimestamp(request);
275
298
  }
299
+ if (this.requiresTimestamp() && !timestamp) {
300
+ return {
301
+ isValid: false,
302
+ error: 'Missing required timestamp for webhook verification',
303
+ errorCode: 'MISSING_SIGNATURE',
304
+ platform: this.platform,
305
+ };
306
+ }
276
307
  if (timestamp && !this.isTimestampValid(timestamp)) {
277
308
  return {
278
309
  isValid: false,
@@ -357,7 +388,15 @@ class Ed25519Verifier extends AlgorithmBasedVerifier {
357
388
  if (cached && Date.now() < cached.expiresAt) {
358
389
  return cached.pems;
359
390
  }
360
- const response = await fetch(jwksUrl);
391
+ const abortController = new AbortController();
392
+ const timeout = setTimeout(() => abortController.abort(), 3000);
393
+ let response;
394
+ try {
395
+ response = await fetch(jwksUrl, { signal: abortController.signal });
396
+ }
397
+ finally {
398
+ clearTimeout(timeout);
399
+ }
361
400
  if (!response.ok) {
362
401
  throw new Error(`Failed to fetch JWKS from ${jwksUrl}: ${response.status}`);
363
402
  }
@@ -396,7 +435,7 @@ class Ed25519Verifier extends AlgorithmBasedVerifier {
396
435
  * 2. Non-empty secret passed directly (user-provided PEM)
397
436
  * 3. JWKS URL in config (fal.ai — fetches all keys, tries each)
398
437
  */
399
- async resolvePublicKeys(request) {
438
+ async resolvePublicKeys() {
400
439
  // 1. explicit public key in config
401
440
  const configPublicKey = this.config.customConfig?.publicKey;
402
441
  if (configPublicKey) {
@@ -449,16 +488,22 @@ class Ed25519Verifier extends AlgorithmBasedVerifier {
449
488
  const timestampHeader = this.config.customConfig?.timestampHeader ||
450
489
  "x-fal-webhook-timestamp";
451
490
  const timestampStr = request.headers.get(timestampHeader);
452
- if (timestampStr) {
453
- const timestamp = parseInt(timestampStr, 10);
454
- if (!this.isTimestampValid(timestamp)) {
455
- return {
456
- isValid: false,
457
- error: "Webhook timestamp expired",
458
- errorCode: "TIMESTAMP_EXPIRED",
459
- platform: this.platform,
460
- };
461
- }
491
+ if (!timestampStr) {
492
+ return {
493
+ isValid: false,
494
+ error: 'Missing required timestamp for webhook verification',
495
+ errorCode: 'MISSING_SIGNATURE',
496
+ platform: this.platform,
497
+ };
498
+ }
499
+ const timestamp = parseInt(timestampStr, 10);
500
+ if (!this.isTimestampValid(timestamp)) {
501
+ return {
502
+ isValid: false,
503
+ error: "Webhook timestamp expired",
504
+ errorCode: "TIMESTAMP_EXPIRED",
505
+ platform: this.platform,
506
+ };
462
507
  }
463
508
  }
464
509
  const payload = this.platform === "falai"
@@ -467,7 +512,7 @@ class Ed25519Verifier extends AlgorithmBasedVerifier {
467
512
  // resolve all public keys
468
513
  let publicKeys;
469
514
  try {
470
- publicKeys = await this.resolvePublicKeys(request);
515
+ publicKeys = await this.resolvePublicKeys();
471
516
  }
472
517
  catch (error) {
473
518
  return {
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@hookflo/tern",
3
- "version": "4.0.2",
3
+ "version": "4.1.0",
4
4
  "description": "A robust, scalable webhook verification framework supporting multiple platforms and signature algorithms",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
7
  "scripts": {
8
8
  "build": "tsc",
9
9
  "dev": "tsc --watch",
10
- "test": "node dist/test.js",
10
+ "test": "ts-node src/test.ts",
11
11
  "lint": "eslint src/**/*.ts",
12
12
  "lint:fix": "eslint 'src/**/*.ts' --fix",
13
13
  "format": "prettier --write src/**/*.ts",
@@ -1 +0,0 @@
1
- export {};