@hookflo/tern 4.0.2 → 4.0.3

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
@@ -48,7 +48,7 @@ class WebhookVerificationService {
48
48
  // Ensure the platform is set correctly in the result
49
49
  if (result.isValid) {
50
50
  result.platform = config.platform;
51
- result.eventId = this.resolveCanonicalEventId(config.platform, result.metadata, result.payload);
51
+ result.eventId = this.resolveCanonicalEventId(config.platform, result.metadata, result.payload) ?? undefined;
52
52
  if (config.normalize) {
53
53
  result.payload = (0, simple_1.normalizePayload)(config.platform, result.payload, config.normalize);
54
54
  }
@@ -134,6 +134,8 @@ class WebhookVerificationService {
134
134
  }
135
135
  static resolveCanonicalEventId(platform, metadata, payload) {
136
136
  const rawId = this.resolveRawEventId(platform, metadata, payload);
137
+ if (!rawId)
138
+ return null;
137
139
  return `${platform}_${rawId}`;
138
140
  }
139
141
  static pickString(...candidates) {
@@ -149,56 +151,52 @@ class WebhookVerificationService {
149
151
  return undefined;
150
152
  }
151
153
  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);
154
+ switch (platform) {
155
+ case 'stripe':
156
+ return this.pickString(payload?.request?.idempotency_key, payload?.id) || null;
157
+ case 'github':
158
+ return this.pickString(metadata?.delivery) || null;
159
+ case 'clerk':
160
+ return this.pickString(metadata?.id) || null;
161
+ case 'shopify':
162
+ return this.pickString(metadata?.id, payload?.id) || null;
163
+ case 'paddle':
164
+ return this.pickString(payload?.event_id, payload?.data?.id) || null;
165
+ case 'polar':
166
+ return this.pickString(payload?.data?.id, payload?.id) || null;
167
+ case 'dodopayments':
168
+ return this.pickString(payload?.data?.payment_id, payload?.data?.subscription_id, payload?.data?.id) || null;
169
+ case 'falai':
170
+ return this.pickString(payload?.request_id) || null;
171
+ case 'replicateai':
172
+ case 'replicate':
173
+ return this.pickString(payload?.id) || null;
174
+ case 'workos':
175
+ case 'sentry':
176
+ case 'vercel':
177
+ return this.pickString(payload?.id) || null;
178
+ case 'doppler':
179
+ return this.pickString(payload?.event?.id, metadata?.id) || null;
180
+ case 'sanity':
181
+ return this.pickString(payload?.transactionId, payload?._id) || null;
182
+ case 'razorpay':
183
+ return this.pickString(payload?.payload?.payment?.entity?.id, payload?.payload?.order?.entity?.id, payload?.payload?.subscription?.entity?.id) || null;
184
+ case 'lemonsqueezy': {
185
+ const dataId = this.pickString(payload?.data?.id);
186
+ if (!dataId)
187
+ return null;
188
+ const eventName = this.pickString(payload?.meta?.event_name);
189
+ return eventName ? `${eventName}_${dataId}` : null;
197
190
  }
198
- })();
199
- if (candidate)
200
- return candidate;
201
- return `generated-missing-${platform}`;
191
+ case 'gitlab':
192
+ return this.pickString(payload?.object_attributes?.id?.toString()) || null;
193
+ case 'grafana':
194
+ return this.pickString(payload?.groupKey, payload?.alerts?.[0]?.fingerprint) || null;
195
+ case 'woocommerce':
196
+ return this.pickString(payload?.id?.toString()) || null;
197
+ default:
198
+ return this.pickString(payload?.idempotency_key, payload?.event_id, payload?.webhook_id, payload?.id, metadata?.id, metadata?.delivery) || null;
199
+ }
202
200
  }
203
201
  static detectPlatform(request) {
204
202
  const headers = request.headers;
@@ -157,9 +157,7 @@ async function resolveDeduplicationId(request, verificationResult) {
157
157
  headers.get('x-github-delivery') ||
158
158
  headers.get('idempotency-key') ||
159
159
  headers.get('upstash-deduplication-id') ||
160
- (verificationResult.eventId && !verificationResult.eventId.includes('generated-missing-')
161
- ? verificationResult.eventId
162
- : null) ||
160
+ verificationResult.eventId ||
163
161
  (typeof verificationResult.metadata?.id === 'string' ? verificationResult.metadata.id : null) ||
164
162
  payloadId ||
165
163
  payloadRequestId ||
@@ -185,7 +183,7 @@ async function handleReceive(request, platform, secret, queueConfig, toleranceIn
185
183
  metadata: verificationResult.metadata || {},
186
184
  };
187
185
  const deduplicationId = await resolveDeduplicationId(request, verificationResult);
188
- if (process.env.NODE_ENV == 'production') {
186
+ if (process.env.NODE_ENV !== 'production') {
189
187
  console.log(`[tern] deduplication-id: ${deduplicationId} (platform: ${platform})`);
190
188
  }
191
189
  const publishPayload = {
@@ -199,11 +197,60 @@ async function handleReceive(request, platform, secret, queueConfig, toleranceIn
199
197
  if (queueConfig.retries !== undefined) {
200
198
  publishPayload.retries = queueConfig.retries;
201
199
  }
202
- await client.publishJSON(publishPayload);
203
- return new Response(JSON.stringify({ queued: true }), {
204
- status: 200,
205
- headers: { 'Content-Type': 'application/json' },
206
- });
200
+ try {
201
+ await client.publishJSON(publishPayload);
202
+ return new Response(JSON.stringify({ queued: true }), {
203
+ status: 200,
204
+ headers: { 'Content-Type': 'application/json' },
205
+ });
206
+ }
207
+ catch (error) {
208
+ const status = error?.status ?? 0;
209
+ const errorBody = error?.body
210
+ ? (() => {
211
+ try {
212
+ return JSON.parse(error.body);
213
+ }
214
+ catch {
215
+ return {};
216
+ }
217
+ })()
218
+ : {};
219
+ console.error('[tern] QStash publish failed:', {
220
+ status,
221
+ message: String(error),
222
+ });
223
+ if (status === 400) {
224
+ return new Response(JSON.stringify({
225
+ error: 'Invalid payload — could not enqueue',
226
+ detail: errorBody?.error,
227
+ }), {
228
+ status: 489,
229
+ headers: {
230
+ 'Content-Type': 'application/json',
231
+ 'Upstash-NonRetryable-Error': 'true',
232
+ },
233
+ });
234
+ }
235
+ if (status === 401) {
236
+ return new Response(JSON.stringify({
237
+ error: '[tern] QStash token invalid — check QSTASH_TOKEN in env',
238
+ }), {
239
+ status: 503,
240
+ headers: { 'Content-Type': 'application/json' },
241
+ });
242
+ }
243
+ if (status === 429) {
244
+ return new Response(JSON.stringify({ error: 'QStash rate limited — retry shortly' }), {
245
+ status: 503,
246
+ headers: { 'Content-Type': 'application/json' },
247
+ });
248
+ }
249
+ return new Response(JSON.stringify({ error: 'Queue temporarily unavailable' }), {
250
+ status: 503,
251
+ headers: { 'Content-Type': 'application/json' },
252
+ });
253
+ }
207
254
  }
208
255
  async function handleProcess(request, handler, queueConfig) {
209
256
  const signature = request.headers.get('upstash-signature');
@@ -219,11 +266,22 @@ async function handleProcess(request, handler, queueConfig) {
219
266
  url: request.url,
220
267
  });
221
268
  if (verification === false) {
222
- return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' } });
269
+ return nonRetryableResponse('Invalid QStash signature', 401);
223
270
  }
224
271
  }
225
- catch {
226
- return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' } });
272
+ catch (error) {
273
+ const message = String(error).toLowerCase();
274
+ if (message.includes('invalid signature') ||
275
+ message.includes('signature') ||
276
+ message.includes('unauthorized') ||
277
+ message.includes('forbidden')) {
278
+ return nonRetryableResponse('Invalid QStash signature', 401);
279
+ }
280
+ console.error('[tern] QStash signature verification error:', String(error));
281
+ return new Response(JSON.stringify({ error: 'Signature verification temporarily unavailable' }), {
282
+ status: 503,
283
+ headers: { 'Content-Type': 'application/json' },
284
+ });
227
285
  }
228
286
  if (!handler) {
229
287
  return nonRetryableResponse('Queue processing requires a handler function');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hookflo/tern",
3
- "version": "4.0.2",
3
+ "version": "4.0.3",
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",