@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.
- package/dist/adapters/express.d.ts +1 -0
- package/dist/adapters/express.js +10 -1
- package/dist/adapters/shared.d.ts +1 -0
- package/dist/adapters/shared.js +10 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +77 -65
- package/dist/upstash/queue.js +81 -28
- package/dist/verifiers/algorithms.d.ts +1 -0
- package/dist/verifiers/algorithms.js +58 -13
- package/package.json +2 -2
- package/dist/adapters/testExpress.d.ts +0 -1
- package/dist/adapters/testExpress.js +0 -83
- package/dist/test-compiled.d.ts +0 -1
- package/dist/test-compiled.js +0 -4
- package/dist/test.d.ts +0 -2
- package/dist/test.js +0 -668
|
@@ -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>;
|
package/dist/adapters/express.js
CHANGED
|
@@ -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
|
|
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>;
|
package/dist/adapters/shared.js
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const result = await this.verifyWithPlatformConfig(requestClone,
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
|
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
|
-
|
|
275
|
-
|
|
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: `
|
|
308
|
+
eventId: `custom_${idHeader}`,
|
|
297
309
|
metadata: {
|
|
298
310
|
id: idHeader,
|
|
299
311
|
algorithm: 'token-based',
|
package/dist/upstash/queue.js
CHANGED
|
@@ -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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
91
|
+
const ReceiverCtor = resolveModuleExport(modules, 'Receiver')
|
|
96
92
|
?? resolveNestedConstructor(modules, 'Receiver');
|
|
97
|
-
if (typeof
|
|
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
|
|
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
|
|
108
|
-
const resolvedClientExport = clientExport
|
|
103
|
+
const ClientCtor = resolveModuleExport(modules, 'Client')
|
|
109
104
|
?? resolveNestedConstructor(modules, 'Client');
|
|
110
|
-
if (typeof
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
|
264
|
+
return nonRetryableResponse('Invalid QStash signature', 401);
|
|
223
265
|
}
|
|
224
266
|
}
|
|
225
|
-
catch {
|
|
226
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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(
|
|
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
|
|
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
|
|
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 {};
|