@hookflo/tern 3.0.13-beta → 3.0.15-beta

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.
@@ -1,6 +1,6 @@
1
1
  import { DLQMessage, EventFilter, ReplayResult, TernControlsConfig, TernEvent } from './types';
2
2
  export declare function createTernControls(config: TernControlsConfig): {
3
3
  dlq(): Promise<DLQMessage[]>;
4
- replay(messageId: string): Promise<ReplayResult>;
4
+ replay(dlqId: string): Promise<ReplayResult>;
5
5
  events(filter?: EventFilter): Promise<TernEvent[]>;
6
6
  };
@@ -2,6 +2,36 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.createTernControls = createTernControls;
4
4
  const QSTASH_API_BASE = 'https://qstash.upstash.io/v2';
5
+ function parseBody(body) {
6
+ if (!body)
7
+ return undefined;
8
+ if (typeof body === 'object') {
9
+ return body;
10
+ }
11
+ if (typeof body === 'string') {
12
+ try {
13
+ return JSON.parse(body);
14
+ }
15
+ catch {
16
+ return undefined;
17
+ }
18
+ }
19
+ return undefined;
20
+ }
21
+ function resolvePlatform(message) {
22
+ const body = parseBody(message.body);
23
+ const bodyPlatform = body?.platform;
24
+ if (typeof bodyPlatform === 'string' && bodyPlatform.trim().length > 0) {
25
+ return bodyPlatform;
26
+ }
27
+ const urlGroup = message.urlGroup;
28
+ const endpoint = typeof urlGroup?.endpoint === 'string' ? urlGroup.endpoint : '';
29
+ const routePlatform = endpoint.split('/').filter(Boolean).pop();
30
+ if (routePlatform && routePlatform !== 'webhooks') {
31
+ return routePlatform;
32
+ }
33
+ return 'unknown';
34
+ }
5
35
  function createHeaders(token) {
6
36
  return {
7
37
  Authorization: `Bearer ${token}`,
@@ -23,31 +53,34 @@ function createTernControls(config) {
23
53
  headers: createHeaders(config.token),
24
54
  });
25
55
  if (!response.ok) {
26
- throw new Error(`[tern] Failed to fetch DLQ messages: ${response.status} ${response.statusText}`);
56
+ throw new Error(`[tern] Failed to fetch DLQ: ${response.status} ${response.statusText}`);
27
57
  }
28
58
  const data = await response.json();
29
59
  const messages = data.messages || [];
30
60
  return messages.map((message) => ({
31
61
  id: String(message.messageId || message.id || ''),
32
- platform: String(message.body && typeof message.body === 'object' ? message.body.platform || 'unknown' : 'unknown'),
33
- payload: message.body && typeof message.body === 'object' ? message.body.payload : undefined,
62
+ dlqId: String(message.dlqId || ''),
63
+ platform: resolvePlatform(message),
64
+ payload: parseBody(message.body)?.payload,
34
65
  failedAt: String(message.updatedAt || message.createdAt || new Date().toISOString()),
35
66
  attempts: Number(message.retried || message.attempts || 0),
36
67
  error: typeof message.error === 'string' ? message.error : undefined,
37
68
  }));
38
69
  },
39
- async replay(messageId) {
40
- const response = await fetch(`${QSTASH_API_BASE}/dlq/${messageId}`, {
41
- method: 'POST',
70
+ async replay(dlqId) {
71
+ if (!dlqId || dlqId.trim() === '') {
72
+ throw new Error('[tern] replay() requires a dlqId, not a messageId. Get dlqId from controls.dlq()');
73
+ }
74
+ const response = await fetch(`${QSTASH_API_BASE}/dlq/${dlqId}`, {
75
+ method: 'DELETE',
42
76
  headers: createHeaders(config.token),
43
77
  });
44
78
  if (!response.ok) {
45
- throw new Error(`[tern] Failed to replay DLQ message ${messageId}: ${response.status} ${response.statusText}`);
79
+ throw new Error(`[tern] Failed to replay DLQ message ${dlqId}: ${response.status} ${response.statusText}`);
46
80
  }
47
- const data = await response.json();
48
81
  return {
49
82
  success: true,
50
- messageId: String(data.messageId || messageId),
83
+ messageId: dlqId,
51
84
  replayedAt: new Date().toISOString(),
52
85
  };
53
86
  },
@@ -64,7 +97,7 @@ function createTernControls(config) {
64
97
  const status = deriveStatus(String(message.state || message.status || 'retrying'));
65
98
  return {
66
99
  id: String(message.messageId || message.id || ''),
67
- platform: String(message.body && typeof message.body === 'object' ? message.body.platform || 'unknown' : 'unknown'),
100
+ platform: resolvePlatform(message),
68
101
  status,
69
102
  attempts: Number(message.retried || message.attempts || 0),
70
103
  createdAt: String(message.createdAt || new Date().toISOString()),
@@ -133,6 +133,44 @@ function resolveQueueConfig(queue) {
133
133
  }
134
134
  return queue;
135
135
  }
136
+ function toStableJson(value) {
137
+ if (Array.isArray(value)) {
138
+ return `[${value.map((item) => toStableJson(item)).join(',')}]`;
139
+ }
140
+ if (value && typeof value === 'object') {
141
+ const entries = Object.entries(value)
142
+ .sort(([a], [b]) => a.localeCompare(b))
143
+ .map(([key, nested]) => `${JSON.stringify(key)}:${toStableJson(nested)}`);
144
+ return `{${entries.join(',')}}`;
145
+ }
146
+ return JSON.stringify(value);
147
+ }
148
+ async function resolveDeduplicationId(request, verificationResult) {
149
+ const payload = verificationResult.payload;
150
+ const headers = request.headers;
151
+ const payloadId = typeof payload?.id === 'string' ? payload.id : null;
152
+ const payloadRequestId = typeof payload?.request_id === 'string' ? payload.request_id : null;
153
+ const nestedPayloadId = payload?.data && typeof payload.data === 'object' && typeof payload.data.id === 'string'
154
+ ? payload.data.id
155
+ : null;
156
+ const explicit = headers.get('x-webhook-id') ||
157
+ headers.get('x-github-delivery') ||
158
+ headers.get('idempotency-key') ||
159
+ headers.get('upstash-deduplication-id') ||
160
+ verificationResult.eventId ||
161
+ (typeof verificationResult.metadata?.id === 'string' ? verificationResult.metadata.id : null) ||
162
+ payloadId ||
163
+ payloadRequestId ||
164
+ nestedPayloadId;
165
+ if (explicit)
166
+ return explicit;
167
+ const raw = toStableJson(payload || {});
168
+ const buffer = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(raw));
169
+ const hash = Array.from(new Uint8Array(buffer))
170
+ .map((byte) => byte.toString(16).padStart(2, '0'))
171
+ .join('');
172
+ return hash.slice(0, 32);
173
+ }
136
174
  async function handleReceive(request, platform, secret, queueConfig, toleranceInSeconds) {
137
175
  const verificationResult = await index_1.WebhookVerificationService.verifyWithPlatformConfig(request.clone(), platform, secret, toleranceInSeconds);
138
176
  if (!verificationResult.isValid) {
@@ -144,13 +182,14 @@ async function handleReceive(request, platform, secret, queueConfig, toleranceIn
144
182
  payload: verificationResult.payload,
145
183
  metadata: verificationResult.metadata || {},
146
184
  };
147
- const idempotencyKey = request.headers.get('idempotency-key') ||
148
- request.headers.get('x-webhook-id') ||
149
- undefined;
185
+ const deduplicationId = await resolveDeduplicationId(request, verificationResult);
186
+ if (process.env.NODE_ENV !== 'production') {
187
+ console.log(`[tern] deduplication-id: ${deduplicationId} (platform: ${platform})`);
188
+ }
150
189
  const publishPayload = {
151
190
  url: request.url,
152
191
  body: queuedMessage,
153
- deduplicationId: idempotencyKey,
192
+ deduplicationId,
154
193
  };
155
194
  if (queueConfig.retries !== undefined) {
156
195
  publishPayload.retries = queueConfig.retries;
@@ -16,6 +16,7 @@ export interface TernControlsConfig {
16
16
  }
17
17
  export interface DLQMessage {
18
18
  id: string;
19
+ dlqId: string;
19
20
  platform: string;
20
21
  payload: unknown;
21
22
  failedAt: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hookflo/tern",
3
- "version": "3.0.13-beta",
3
+ "version": "3.0.15-beta",
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",