@hookflo/tern 3.0.13-beta → 3.0.17-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.
package/dist/index.js CHANGED
@@ -134,7 +134,7 @@ class WebhookVerificationService {
134
134
  }
135
135
  static resolveCanonicalEventId(platform, metadata) {
136
136
  const rawId = this.resolveRawEventId(platform, metadata);
137
- return `${platform}:${rawId}`;
137
+ return `${platform}_${rawId}`;
138
138
  }
139
139
  static resolveRawEventId(platform, metadata) {
140
140
  const candidate = metadata?.id || metadata?.delivery || metadata?.requestId || metadata?.timestamp;
@@ -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,41 @@
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
+ try {
17
+ const decoded = Buffer.from(body, 'base64').toString('utf8');
18
+ return JSON.parse(decoded);
19
+ }
20
+ catch {
21
+ return undefined;
22
+ }
23
+ }
24
+ }
25
+ return undefined;
26
+ }
27
+ function resolvePlatform(message) {
28
+ const headers = message.headers;
29
+ const headerPlatform = headers?.['x-tern-platform'] ?? headers?.['X-Tern-Platform'];
30
+ if (typeof headerPlatform === 'string' && headerPlatform.trim().length > 0) {
31
+ return headerPlatform;
32
+ }
33
+ const body = parseBody(message.body);
34
+ const bodyPlatform = body?.platform;
35
+ if (typeof bodyPlatform === 'string' && bodyPlatform.trim().length > 0) {
36
+ return bodyPlatform;
37
+ }
38
+ return 'unknown';
39
+ }
5
40
  function createHeaders(token) {
6
41
  return {
7
42
  Authorization: `Bearer ${token}`,
@@ -23,31 +58,34 @@ function createTernControls(config) {
23
58
  headers: createHeaders(config.token),
24
59
  });
25
60
  if (!response.ok) {
26
- throw new Error(`[tern] Failed to fetch DLQ messages: ${response.status} ${response.statusText}`);
61
+ throw new Error(`[tern] Failed to fetch DLQ: ${response.status} ${response.statusText}`);
27
62
  }
28
63
  const data = await response.json();
29
64
  const messages = data.messages || [];
30
65
  return messages.map((message) => ({
31
66
  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,
67
+ dlqId: String(message.dlqId || ''),
68
+ platform: resolvePlatform(message),
69
+ payload: parseBody(message.body)?.payload,
34
70
  failedAt: String(message.updatedAt || message.createdAt || new Date().toISOString()),
35
71
  attempts: Number(message.retried || message.attempts || 0),
36
72
  error: typeof message.error === 'string' ? message.error : undefined,
37
73
  }));
38
74
  },
39
- async replay(messageId) {
40
- const response = await fetch(`${QSTASH_API_BASE}/dlq/${messageId}`, {
41
- method: 'POST',
75
+ async replay(dlqId) {
76
+ if (!dlqId || dlqId.trim() === '') {
77
+ throw new Error('[tern] replay() requires a dlqId, not a messageId. Get dlqId from controls.dlq()');
78
+ }
79
+ const response = await fetch(`${QSTASH_API_BASE}/dlq/${dlqId}`, {
80
+ method: 'DELETE',
42
81
  headers: createHeaders(config.token),
43
82
  });
44
83
  if (!response.ok) {
45
- throw new Error(`[tern] Failed to replay DLQ message ${messageId}: ${response.status} ${response.statusText}`);
84
+ throw new Error(`[tern] Failed to replay DLQ message ${dlqId}: ${response.status} ${response.statusText}`);
46
85
  }
47
- const data = await response.json();
48
86
  return {
49
87
  success: true,
50
- messageId: String(data.messageId || messageId),
88
+ messageId: dlqId,
51
89
  replayedAt: new Date().toISOString(),
52
90
  };
53
91
  },
@@ -64,7 +102,7 @@ function createTernControls(config) {
64
102
  const status = deriveStatus(String(message.state || message.status || 'retrying'));
65
103
  return {
66
104
  id: String(message.messageId || message.id || ''),
67
- platform: String(message.body && typeof message.body === 'object' ? message.body.platform || 'unknown' : 'unknown'),
105
+ platform: resolvePlatform(message),
68
106
  status,
69
107
  attempts: Number(message.retried || message.attempts || 0),
70
108
  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,17 @@ 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,
193
+ headers: {
194
+ 'x-tern-platform': platform,
195
+ },
154
196
  };
155
197
  if (queueConfig.retries !== undefined) {
156
198
  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.17-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",