hookarmor 1.0.3 → 1.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/src/dispatcher.js CHANGED
@@ -1,356 +1,409 @@
1
- const http = require('http');
2
- const https = require('https');
3
- const crypto = require('crypto');
4
- const EventEmitter = require('events');
5
- const Alerter = require('./alerter');
6
-
7
- class Dispatcher extends EventEmitter {
8
- constructor(storage, options = {}) {
9
- super();
10
- this.storage = storage;
11
- this.defaultConcurrency = options.defaultConcurrency || 5;
12
- this.inFlightCount = new Map();
13
- this.waitQueues = new Map();
14
- this.defaultTimeoutMs = options.defaultTimeoutMs || 25000;
15
- }
16
-
17
- // Verify signature at ingress (preventing HookArmor from acting as an open signature oracle)
18
- static verifyIngressSignature(provider, rawBody, headers, secret, toleranceSec = 300) {
19
- if (!secret) return { valid: true };
20
-
21
- try {
22
- if (provider === 'stripe') {
23
- const sigHeader = headers['stripe-signature'];
24
- if (!sigHeader) return { valid: false, reason: 'Missing Stripe-Signature header' };
25
-
26
- const parts = sigHeader.split(',').reduce((acc, item) => {
27
- const [k, v] = item.split('=');
28
- if (k && v) acc[k.trim()] = v.trim();
29
- return acc;
30
- }, {});
31
-
32
- if (!parts.t || !parts.v1) return { valid: false, reason: 'Malformed Stripe-Signature header' };
33
-
34
- const timestamp = parseInt(parts.t, 10);
35
- const now = Math.floor(Date.now() / 1000);
36
- if (Math.abs(now - timestamp) > toleranceSec) {
37
- return { valid: false, reason: `Timestamp outside tolerance (${Math.abs(now - timestamp)}s > ${toleranceSec}s)` };
38
- }
39
-
40
- const expectedSig = crypto
41
- .createHmac('sha256', secret)
42
- .update(`${parts.t}.${rawBody}`)
43
- .digest('hex');
44
-
45
- const expectedBuf = Buffer.from(expectedSig);
46
- const actualBuf = Buffer.from(parts.v1);
47
- if (expectedBuf.length !== actualBuf.length || !crypto.timingSafeEqual(expectedBuf, actualBuf)) {
48
- return { valid: false, reason: 'Signature mismatch' };
49
- }
50
- return { valid: true };
51
- }
52
-
53
- if (provider === 'shopify') {
54
- const hmac = headers['x-shopify-hmac-sha256'];
55
- if (!hmac) return { valid: false, reason: 'Missing X-Shopify-Hmac-Sha256 header' };
56
-
57
- const expectedSig = crypto
58
- .createHmac('sha256', secret)
59
- .update(rawBody)
60
- .digest('base64');
61
-
62
- const expectedBuf = Buffer.from(expectedSig);
63
- const actualBuf = Buffer.from(hmac);
64
- if (expectedBuf.length !== actualBuf.length || !crypto.timingSafeEqual(expectedBuf, actualBuf)) {
65
- return { valid: false, reason: 'Shopify HMAC mismatch' };
66
- }
67
- return { valid: true };
68
- }
69
-
70
- if (provider === 'github') {
71
- const sig = headers['x-hub-signature-256'];
72
- if (!sig) return { valid: false, reason: 'Missing X-Hub-Signature-256 header' };
73
-
74
- const expectedSig = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
75
- const expectedBuf = Buffer.from(expectedSig);
76
- const actualBuf = Buffer.from(sig);
77
- if (expectedBuf.length !== actualBuf.length || !crypto.timingSafeEqual(expectedBuf, actualBuf)) {
78
- return { valid: false, reason: 'GitHub signature mismatch' };
79
- }
80
- return { valid: true };
81
- }
82
-
83
- return { valid: true };
84
- } catch (err) {
85
- return { valid: false, reason: err.message };
86
- }
87
- }
88
-
89
- // Fresh re-signing on forward/replay to defeat the 5-minute Stripe expiration trap
90
- signHeaders(provider, rawBody, headers, secret) {
91
- if (!secret) return headers;
92
-
93
- const modified = { ...headers };
94
- try {
95
- if (provider === 'stripe') {
96
- const freshTimestamp = Math.floor(Date.now() / 1000);
97
- const freshSignature = crypto
98
- .createHmac('sha256', secret)
99
- .update(`${freshTimestamp}.${rawBody}`)
100
- .digest('hex');
101
- modified['stripe-signature'] = `t=${freshTimestamp},v1=${freshSignature}`;
102
- } else if (provider === 'shopify') {
103
- const freshHmac = crypto
104
- .createHmac('sha256', secret)
105
- .update(rawBody)
106
- .digest('base64');
107
- modified['x-shopify-hmac-sha256'] = freshHmac;
108
- } else if (provider === 'github') {
109
- const freshSig = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
110
- modified['x-hub-signature-256'] = freshSig;
111
- }
112
- } catch (e) {}
113
-
114
- return modified;
115
- }
116
-
117
- async acquireSlot(endpointId, limit) {
118
- const current = this.inFlightCount.get(endpointId) || 0;
119
- if (current < limit) {
120
- this.inFlightCount.set(endpointId, current + 1);
121
- return;
122
- }
123
-
124
- return new Promise(resolve => {
125
- if (!this.waitQueues.has(endpointId)) this.waitQueues.set(endpointId, []);
126
- this.waitQueues.get(endpointId).push(resolve);
127
- });
128
- }
129
-
130
- releaseSlot(endpointId, limit) {
131
- const queue = this.waitQueues.get(endpointId);
132
- if (queue && queue.length > 0) {
133
- const next = queue.shift();
134
- next();
135
- } else {
136
- const current = this.inFlightCount.get(endpointId) || 1;
137
- this.inFlightCount.set(endpointId, Math.max(0, current - 1));
138
- }
139
- }
140
-
141
- // Calculate exponential backoff with jitter: 30s, 2m, 10m, 30m, 2h
142
- calculateNextRetry(attempt) {
143
- const intervals = [30, 120, 600, 1800, 7200]; // seconds
144
- const base = intervals[Math.min(attempt, intervals.length - 1)];
145
- const jitter = Math.floor(Math.random() * (base * 0.2));
146
- const delaySec = base + jitter;
147
- return new Date(Date.now() + delaySec * 1000).toISOString();
148
- }
149
-
150
- async dispatch(event, endpoint) {
151
- const limit = endpoint.concurrency_limit || this.defaultConcurrency;
152
- await this.acquireSlot(endpoint.id, limit);
153
-
154
- try {
155
- return await this._executeDispatch(event, endpoint);
156
- } finally {
157
- this.releaseSlot(endpoint.id, limit);
158
- }
159
- }
160
-
161
- async _executeDispatch(event, endpoint) {
162
- const startTime = Date.now();
163
- const targetUrl = endpoint.target_url;
164
-
165
- let headers = { ...event.headers };
166
-
167
- // Preserve original raw signatures before re-signing for audit and forensic trace
168
- if (event.headers && event.headers['stripe-signature']) {
169
- headers['x-hookarmor-original-stripe-signature'] = event.headers['stripe-signature'];
170
- }
171
- if (event.headers && event.headers['x-shopify-hmac-sha256']) {
172
- headers['x-hookarmor-original-shopify-hmac'] = event.headers['x-shopify-hmac-sha256'];
173
- }
174
-
175
- // Re-sign outbound headers if endpoint secret is configured (Transparent Zero-Code-Change Mode)
176
- if (endpoint.secret) {
177
- headers = this.signHeaders(event.provider, event.raw_body, headers, endpoint.secret);
178
- }
179
-
180
- // Optional Isolated Trust Domain mode: if HOOKARMOR_SIGNING_SECRET is provided
181
- const internalSecret = process.env.HOOKARMOR_SIGNING_SECRET || endpoint.internal_secret;
182
- if (internalSecret) {
183
- const freshTs = Math.floor(Date.now() / 1000);
184
- const internalSig = crypto.createHmac('sha256', internalSecret).update(`${freshTs}.${event.raw_body}`).digest('hex');
185
- headers['x-hookarmor-signature'] = `t=${freshTs},v1=${internalSig}`;
186
- }
187
-
188
- // Remove hop-by-hop and encoding headers
189
- delete headers['host'];
190
- delete headers['connection'];
191
- delete headers['content-length'];
192
- delete headers['content-encoding'];
193
- delete headers['transfer-encoding'];
194
-
195
- const rawBuffer = Buffer.from(event.raw_body, 'utf8');
196
- headers['content-length'] = rawBuffer.length;
197
- headers['x-hookarmor-delivery-id'] = event.id;
198
- headers['x-hookarmor-attempt'] = String(event.attempts + 1);
199
- headers['x-hookarmor-original-timestamp'] = event.created_at;
200
- const isReplay = (event.attempts > 0 || event.status === 'replaying');
201
- headers['x-hookarmor-is-replay'] = isReplay ? 'true' : 'false';
202
-
203
- return new Promise((resolve) => {
204
- try {
205
- const url = new URL(targetUrl);
206
- const isHttps = url.protocol === 'https:';
207
- const client = isHttps ? https : http;
208
-
209
- const req = client.request(url, {
210
- method: 'POST',
211
- headers,
212
- timeout: this.defaultTimeoutMs
213
- }, (res) => {
214
- let responseBody = '';
215
- res.setEncoding('utf8');
216
- res.on('data', chunk => {
217
- if (responseBody.length < 4000) {
218
- responseBody += chunk;
219
- }
220
- });
221
-
222
- res.on('end', async () => {
223
- const latencyMs = Date.now() - startTime;
224
- const statusCode = res.statusCode || 0;
225
- const isSuccess = statusCode >= 200 && statusCode < 300;
226
-
227
- let nextRetryAt = null;
228
- if (!isSuccess && endpoint.auto_retry && (event.attempts + 1) < endpoint.max_retries) {
229
- nextRetryAt = this.calculateNextRetry(event.attempts);
230
- }
231
-
232
- const updatedEvent = this.storage.recordAttempt({
233
- eventId: event.id,
234
- statusCode,
235
- responseBody,
236
- errorMessage: isSuccess ? '' : `Destination returned HTTP ${statusCode}`,
237
- latencyMs,
238
- nextRetryAt
239
- });
240
-
241
- const result = {
242
- eventId: event.id,
243
- endpointId: endpoint.id,
244
- success: isSuccess,
245
- statusCode,
246
- latencyMs,
247
- responseBody,
248
- nextRetryAt
249
- };
250
-
251
- this.emit('delivery', result);
252
-
253
- if (!isSuccess) {
254
- await Alerter.sendAlert(endpoint.alert_webhook_url, {
255
- event,
256
- endpoint,
257
- attempt: { statusCode, errorMessage: `HTTP ${statusCode}`, latencyMs }
258
- });
259
- }
260
-
261
- resolve(result);
262
- });
263
- });
264
-
265
- req.on('timeout', async () => {
266
- req.destroy(new Error(`Connection timed out after ${this.defaultTimeoutMs}ms`));
267
- });
268
-
269
- req.on('error', async (err) => {
270
- const latencyMs = Date.now() - startTime;
271
- let nextRetryAt = null;
272
- if (endpoint.auto_retry && (event.attempts + 1) < endpoint.max_retries) {
273
- nextRetryAt = this.calculateNextRetry(event.attempts);
274
- }
275
-
276
- const updatedEvent = this.storage.recordAttempt({
277
- eventId: event.id,
278
- statusCode: 0,
279
- responseBody: '',
280
- errorMessage: err.message,
281
- latencyMs,
282
- nextRetryAt
283
- });
284
-
285
- const result = {
286
- eventId: event.id,
287
- endpointId: endpoint.id,
288
- success: false,
289
- statusCode: 0,
290
- latencyMs,
291
- errorMessage: err.message,
292
- nextRetryAt
293
- };
294
-
295
- this.emit('delivery', result);
296
-
297
- await Alerter.sendAlert(endpoint.alert_webhook_url, {
298
- event,
299
- endpoint,
300
- attempt: { statusCode: 0, errorMessage: err.message, latencyMs }
301
- });
302
-
303
- resolve(result);
304
- });
305
-
306
- req.write(rawBuffer);
307
- req.end();
308
- } catch (err) {
309
- const latencyMs = Date.now() - startTime;
310
- this.storage.recordAttempt({
311
- eventId: event.id,
312
- statusCode: 0,
313
- responseBody: '',
314
- errorMessage: err.message,
315
- latencyMs
316
- });
317
-
318
- const result = {
319
- eventId: event.id,
320
- endpointId: endpoint.id,
321
- success: false,
322
- statusCode: 0,
323
- latencyMs,
324
- errorMessage: err.message
325
- };
326
-
327
- this.emit('delivery', result);
328
- resolve(result);
329
- }
330
- });
331
- }
332
-
333
- async replayEvent(eventId) {
334
- const event = this.storage.getEvent(eventId);
335
- if (!event) throw new Error(`Event not found: ${eventId}`);
336
- const endpoint = this.storage.getEndpoint(event.endpoint_id);
337
- if (!endpoint) throw new Error(`Endpoint not found: ${event.endpoint_id}`);
338
-
339
- return this.dispatch(event, endpoint);
340
- }
341
-
342
- async replayAllFailed(endpointId = null) {
343
- const failedEvents = this.storage.getFailedEventsToRetry(endpointId);
344
- const results = [];
345
- for (const event of failedEvents) {
346
- const endpoint = this.storage.getEndpoint(event.endpoint_id);
347
- if (endpoint) {
348
- const res = await this.dispatch(event, endpoint);
349
- results.push(res);
350
- }
351
- }
352
- return results;
353
- }
354
- }
355
-
356
- module.exports = Dispatcher;
1
+ const http = require('http');
2
+ const https = require('https');
3
+ const crypto = require('crypto');
4
+ const EventEmitter = require('events');
5
+ const Alerter = require('./alerter');
6
+ const { checkUrl, guardedLookup } = require('./netguard');
7
+
8
+ function safeEqual(a, b) {
9
+ const bufA = Buffer.from(String(a));
10
+ const bufB = Buffer.from(String(b));
11
+ return bufA.length === bufB.length && crypto.timingSafeEqual(bufA, bufB);
12
+ }
13
+
14
+ // Svix (used by Clerk) secrets are "whsec_" + base64 key bytes
15
+ function svixKey(secret) {
16
+ return Buffer.from(String(secret).replace(/^whsec_/, ''), 'base64');
17
+ }
18
+
19
+ function svixSignature(secret, msgId, timestamp, rawBody) {
20
+ return crypto.createHmac('sha256', svixKey(secret)).update(`${msgId}.${timestamp}.${rawBody}`).digest('base64');
21
+ }
22
+
23
+ class Dispatcher extends EventEmitter {
24
+ constructor(storage, options = {}) {
25
+ super();
26
+ this.storage = storage;
27
+ this.defaultConcurrency = options.defaultConcurrency || 5;
28
+ this.inFlightCount = new Map();
29
+ this.waitQueues = new Map();
30
+ this.defaultTimeoutMs = options.defaultTimeoutMs || 25000;
31
+ this.strictSSRF = Boolean(options.strictSSRF);
32
+ this.signingSecret = options.signingSecret || null;
33
+ }
34
+
35
+ // Verify signature at ingress (preventing HookArmor from acting as an open signature oracle).
36
+ // When a secret is configured, events without a supported signature scheme are rejected.
37
+ static verifyIngressSignature(provider, rawBody, headers, secret, toleranceSec = 300) {
38
+ if (!secret) return { valid: true };
39
+
40
+ try {
41
+ if (provider === 'stripe') {
42
+ const sigHeader = headers['stripe-signature'];
43
+ if (!sigHeader) return { valid: false, reason: 'Missing Stripe-Signature header' };
44
+
45
+ // Stripe may send several v1 signatures (e.g. while a secret is being rolled)
46
+ let timestampStr = null;
47
+ const signatures = [];
48
+ for (const item of sigHeader.split(',')) {
49
+ const idx = item.indexOf('=');
50
+ if (idx === -1) continue;
51
+ const k = item.slice(0, idx).trim();
52
+ const v = item.slice(idx + 1).trim();
53
+ if (k === 't') timestampStr = v;
54
+ if (k === 'v1' && v) signatures.push(v);
55
+ }
56
+
57
+ if (!timestampStr || signatures.length === 0) return { valid: false, reason: 'Malformed Stripe-Signature header' };
58
+
59
+ const timestamp = parseInt(timestampStr, 10);
60
+ const now = Math.floor(Date.now() / 1000);
61
+ if (!Number.isFinite(timestamp) || Math.abs(now - timestamp) > toleranceSec) {
62
+ return { valid: false, reason: `Timestamp outside tolerance (${toleranceSec}s)` };
63
+ }
64
+
65
+ const expectedSig = crypto
66
+ .createHmac('sha256', secret)
67
+ .update(`${timestampStr}.${rawBody}`)
68
+ .digest('hex');
69
+
70
+ if (!signatures.some((sig) => safeEqual(expectedSig, sig))) {
71
+ return { valid: false, reason: 'Signature mismatch' };
72
+ }
73
+ return { valid: true };
74
+ }
75
+
76
+ if (provider === 'shopify') {
77
+ const hmac = headers['x-shopify-hmac-sha256'];
78
+ if (!hmac) return { valid: false, reason: 'Missing X-Shopify-Hmac-Sha256 header' };
79
+
80
+ const expectedSig = crypto
81
+ .createHmac('sha256', secret)
82
+ .update(rawBody)
83
+ .digest('base64');
84
+
85
+ if (!safeEqual(expectedSig, hmac)) {
86
+ return { valid: false, reason: 'Shopify HMAC mismatch' };
87
+ }
88
+ return { valid: true };
89
+ }
90
+
91
+ if (provider === 'github') {
92
+ const sig = headers['x-hub-signature-256'];
93
+ if (!sig) return { valid: false, reason: 'Missing X-Hub-Signature-256 header' };
94
+
95
+ const expectedSig = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
96
+ if (!safeEqual(expectedSig, sig)) {
97
+ return { valid: false, reason: 'GitHub signature mismatch' };
98
+ }
99
+ return { valid: true };
100
+ }
101
+
102
+ if (provider === 'clerk/svix') {
103
+ const msgId = headers['svix-id'];
104
+ const timestampStr = headers['svix-timestamp'];
105
+ const sigHeader = headers['svix-signature'];
106
+ if (!msgId || !timestampStr || !sigHeader) {
107
+ return { valid: false, reason: 'Missing svix-id, svix-timestamp or svix-signature header' };
108
+ }
109
+
110
+ const timestamp = parseInt(timestampStr, 10);
111
+ const now = Math.floor(Date.now() / 1000);
112
+ if (!Number.isFinite(timestamp) || Math.abs(now - timestamp) > toleranceSec) {
113
+ return { valid: false, reason: `Timestamp outside tolerance (${toleranceSec}s)` };
114
+ }
115
+
116
+ const expectedSig = svixSignature(secret, msgId, timestampStr, rawBody);
117
+ // Header is a space-separated list of "v1,<base64>" entries
118
+ const matches = sigHeader.split(' ').some((entry) => {
119
+ const [version, sig] = entry.split(',');
120
+ return version === 'v1' && sig && safeEqual(expectedSig, sig);
121
+ });
122
+ if (!matches) return { valid: false, reason: 'Svix signature mismatch' };
123
+ return { valid: true };
124
+ }
125
+
126
+ return { valid: false, reason: 'Endpoint has a secret but the request carries no supported signature (Stripe, Shopify, GitHub, Svix/Clerk)' };
127
+ } catch (err) {
128
+ return { valid: false, reason: err.message };
129
+ }
130
+ }
131
+
132
+ // Fresh re-signing on forward/replay to defeat the 5-minute Stripe expiration trap
133
+ signHeaders(provider, rawBody, headers, secret) {
134
+ if (!secret) return headers;
135
+
136
+ const modified = { ...headers };
137
+ try {
138
+ if (provider === 'stripe') {
139
+ const freshTimestamp = Math.floor(Date.now() / 1000);
140
+ const freshSignature = crypto
141
+ .createHmac('sha256', secret)
142
+ .update(`${freshTimestamp}.${rawBody}`)
143
+ .digest('hex');
144
+ modified['stripe-signature'] = `t=${freshTimestamp},v1=${freshSignature}`;
145
+ } else if (provider === 'shopify') {
146
+ const freshHmac = crypto
147
+ .createHmac('sha256', secret)
148
+ .update(rawBody)
149
+ .digest('base64');
150
+ modified['x-shopify-hmac-sha256'] = freshHmac;
151
+ } else if (provider === 'github') {
152
+ const freshSig = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
153
+ modified['x-hub-signature-256'] = freshSig;
154
+ } else if (provider === 'clerk/svix' && modified['svix-id']) {
155
+ const freshTimestamp = String(Math.floor(Date.now() / 1000));
156
+ modified['svix-timestamp'] = freshTimestamp;
157
+ modified['svix-signature'] = `v1,${svixSignature(secret, modified['svix-id'], freshTimestamp, rawBody)}`;
158
+ }
159
+ } catch (e) {}
160
+
161
+ return modified;
162
+ }
163
+
164
+ async acquireSlot(endpointId, limit) {
165
+ const current = this.inFlightCount.get(endpointId) || 0;
166
+ if (current < limit) {
167
+ this.inFlightCount.set(endpointId, current + 1);
168
+ return;
169
+ }
170
+
171
+ return new Promise(resolve => {
172
+ if (!this.waitQueues.has(endpointId)) this.waitQueues.set(endpointId, []);
173
+ this.waitQueues.get(endpointId).push(resolve);
174
+ });
175
+ }
176
+
177
+ releaseSlot(endpointId, limit) {
178
+ const queue = this.waitQueues.get(endpointId);
179
+ if (queue && queue.length > 0) {
180
+ const next = queue.shift();
181
+ next();
182
+ } else {
183
+ const current = this.inFlightCount.get(endpointId) || 1;
184
+ this.inFlightCount.set(endpointId, Math.max(0, current - 1));
185
+ }
186
+ }
187
+
188
+ // Calculate exponential backoff with jitter: 30s, 2m, 10m, 30m, 2h
189
+ calculateNextRetry(attempt) {
190
+ const intervals = [30, 120, 600, 1800, 7200]; // seconds
191
+ const base = intervals[Math.min(attempt, intervals.length - 1)];
192
+ const jitter = Math.floor(Math.random() * (base * 0.2));
193
+ const delaySec = base + jitter;
194
+ // SQLite datetime format, so the worker's comparison against datetime('now') is correct
195
+ return new Date(Date.now() + delaySec * 1000).toISOString().slice(0, 19).replace('T', ' ');
196
+ }
197
+
198
+ // Callers must have claimed the event (status 'replaying') before dispatching
199
+ async dispatch(event, endpoint, { replay = false } = {}) {
200
+ const limit = endpoint.concurrency_limit || this.defaultConcurrency;
201
+ await this.acquireSlot(endpoint.id, limit);
202
+
203
+ try {
204
+ return await this._executeDispatch(event, endpoint, replay);
205
+ } finally {
206
+ this.releaseSlot(endpoint.id, limit);
207
+ }
208
+ }
209
+
210
+ async _executeDispatch(event, endpoint, replay = false) {
211
+ const startTime = Date.now();
212
+ const targetUrl = endpoint.target_url;
213
+
214
+ let headers = { ...event.headers };
215
+
216
+ // Never forward sender-supplied HookArmor headers; ours are added below
217
+ for (const name of Object.keys(headers)) {
218
+ if (name.startsWith('x-hookarmor-')) delete headers[name];
219
+ }
220
+
221
+ // Preserve original raw signatures before re-signing for audit and forensic trace
222
+ if (event.headers && event.headers['stripe-signature']) {
223
+ headers['x-hookarmor-original-stripe-signature'] = event.headers['stripe-signature'];
224
+ }
225
+ if (event.headers && event.headers['x-shopify-hmac-sha256']) {
226
+ headers['x-hookarmor-original-shopify-hmac'] = event.headers['x-shopify-hmac-sha256'];
227
+ }
228
+ if (event.headers && event.headers['svix-signature']) {
229
+ headers['x-hookarmor-original-svix-signature'] = event.headers['svix-signature'];
230
+ }
231
+
232
+ // Re-sign outbound headers if endpoint secret is configured (Transparent Zero-Code-Change Mode)
233
+ if (endpoint.secret) {
234
+ headers = this.signHeaders(event.provider, event.raw_body, headers, endpoint.secret);
235
+ }
236
+
237
+ // Isolated Trust Domain mode: vouch only for events whose signature was verified at ingress
238
+ if (this.signingSecret && event.verified) {
239
+ const freshTs = Math.floor(Date.now() / 1000);
240
+ const internalSig = crypto.createHmac('sha256', this.signingSecret).update(`${freshTs}.${event.raw_body}`).digest('hex');
241
+ headers['x-hookarmor-signature'] = `t=${freshTs},v1=${internalSig}`;
242
+ }
243
+
244
+ // Remove hop-by-hop and encoding headers
245
+ delete headers['host'];
246
+ delete headers['connection'];
247
+ delete headers['content-length'];
248
+ delete headers['content-encoding'];
249
+ delete headers['transfer-encoding'];
250
+ delete headers['expect'];
251
+
252
+ const rawBuffer = Buffer.from(event.raw_body, 'utf8');
253
+ headers['content-length'] = String(rawBuffer.length);
254
+ headers['x-hookarmor-delivery-id'] = event.id;
255
+ headers['x-hookarmor-attempt'] = String(event.attempts + 1);
256
+ headers['x-hookarmor-original-timestamp'] = event.created_at;
257
+ const isReplay = replay || event.attempts > 0;
258
+ headers['x-hookarmor-is-replay'] = isReplay ? 'true' : 'false';
259
+
260
+ return new Promise((resolve) => {
261
+ let settled = false;
262
+
263
+ // Single exit point: every outcome records exactly one attempt and resolves exactly once
264
+ const finish = async ({ statusCode = 0, responseBody = '', errorMessage = '' }) => {
265
+ if (settled) return;
266
+ settled = true;
267
+
268
+ const latencyMs = Date.now() - startTime;
269
+ const isSuccess = statusCode >= 200 && statusCode < 300;
270
+
271
+ let nextRetryAt = null;
272
+ if (!isSuccess && endpoint.auto_retry && (event.attempts + 1) < endpoint.max_retries) {
273
+ nextRetryAt = this.calculateNextRetry(event.attempts);
274
+ }
275
+
276
+ const message = isSuccess ? '' : (errorMessage || `Destination returned HTTP ${statusCode}`);
277
+ try {
278
+ this.storage.recordAttempt({
279
+ eventId: event.id,
280
+ statusCode,
281
+ responseBody,
282
+ errorMessage: message,
283
+ latencyMs,
284
+ nextRetryAt
285
+ });
286
+ } catch (err) {
287
+ console.error(`[Dispatcher] Failed to record attempt for ${event.id}:`, err.message);
288
+ }
289
+
290
+ const result = {
291
+ eventId: event.id,
292
+ endpointId: endpoint.id,
293
+ success: isSuccess,
294
+ statusCode,
295
+ latencyMs,
296
+ responseBody,
297
+ errorMessage: message || undefined,
298
+ nextRetryAt
299
+ };
300
+
301
+ this.emit('delivery', result);
302
+
303
+ if (!isSuccess) {
304
+ // Alert on initial failure and on final exhaustion to prevent notification flood (§6.6)
305
+ const isFirstFailure = (event.attempts === 0);
306
+ const isExhausted = !nextRetryAt;
307
+ if (isFirstFailure || isExhausted) {
308
+ await Alerter.sendAlert(endpoint.alert_webhook_url, {
309
+ event,
310
+ endpoint,
311
+ attempt: { statusCode, errorMessage: message, latencyMs, isExhausted }
312
+ }, { strictSSRF: this.strictSSRF });
313
+ }
314
+ }
315
+
316
+ resolve(result);
317
+ };
318
+
319
+ try {
320
+ const check = checkUrl(targetUrl, this.strictSSRF);
321
+ if (!check.ok) {
322
+ finish({ errorMessage: `Delivery blocked: ${check.reason}` });
323
+ return;
324
+ }
325
+
326
+ const url = new URL(targetUrl);
327
+ const isHttps = url.protocol === 'https:';
328
+ const client = isHttps ? https : http;
329
+
330
+ let gotResponse = false;
331
+ const req = client.request(url, {
332
+ method: 'POST',
333
+ headers,
334
+ timeout: this.defaultTimeoutMs,
335
+ lookup: guardedLookup(this.strictSSRF)
336
+ }, (res) => {
337
+ gotResponse = true;
338
+ let responseBody = '';
339
+ res.setEncoding('utf8');
340
+ res.on('data', chunk => {
341
+ if (responseBody.length < 4000) {
342
+ responseBody += chunk;
343
+ }
344
+ });
345
+ res.on('end', () => finish({ statusCode: res.statusCode || 0, responseBody }));
346
+ res.on('error', (err) => finish({ errorMessage: err.message }));
347
+ res.on('aborted', () => finish({ errorMessage: 'Response aborted by destination' }));
348
+ res.on('close', () => {
349
+ if (!res.complete) finish({ errorMessage: 'Response closed before it completed' });
350
+ });
351
+ });
352
+
353
+ req.on('timeout', () => {
354
+ req.destroy(new Error(`Connection timed out after ${this.defaultTimeoutMs}ms`));
355
+ });
356
+
357
+ req.on('error', (err) => finish({ errorMessage: err.message }));
358
+ req.on('close', () => {
359
+ if (!gotResponse) finish({ errorMessage: 'Connection closed before a response was received' });
360
+ });
361
+
362
+ req.write(rawBuffer);
363
+ req.end();
364
+ } catch (err) {
365
+ finish({ errorMessage: err.message });
366
+ }
367
+ });
368
+ }
369
+
370
+ async replayEvent(eventId) {
371
+ const event = this.storage.getEvent(eventId);
372
+ if (!event) {
373
+ const err = new Error(`Event not found: ${eventId}`);
374
+ err.code = 'NOT_FOUND';
375
+ throw err;
376
+ }
377
+ const endpoint = this.storage.getEndpoint(event.endpoint_id);
378
+ if (!endpoint) {
379
+ const err = new Error(`Endpoint not found: ${event.endpoint_id}`);
380
+ err.code = 'NOT_FOUND';
381
+ throw err;
382
+ }
383
+ if (!this.storage.claimEventForManualReplay(eventId)) {
384
+ const err = new Error(`Event ${eventId} is already being delivered`);
385
+ err.code = 'IN_FLIGHT';
386
+ throw err;
387
+ }
388
+
389
+ return this.dispatch(event, endpoint, { replay: true });
390
+ }
391
+
392
+ // Claims every failed event (including ones that exhausted automatic retries) and delivers
393
+ // them in the background through the per-endpoint concurrency limiter.
394
+ replayAllFailed(endpointId = null, batchSize = 500) {
395
+ const failedEvents = this.storage.getFailedEventsForReplay(endpointId, batchSize);
396
+ const claimed = [];
397
+ for (const event of failedEvents) {
398
+ const endpoint = this.storage.getEndpoint(event.endpoint_id);
399
+ if (!endpoint || !this.storage.claimEventForRetry(event.id)) continue;
400
+ claimed.push(event.id);
401
+ this.dispatch(event, endpoint, { replay: true }).catch((err) => {
402
+ console.error(`[Dispatcher] Replay error for ${event.id}:`, err.message);
403
+ });
404
+ }
405
+ return { replayedCount: claimed.length, eventIds: claimed, remaining: this.storage.countFailed(endpointId) };
406
+ }
407
+ }
408
+
409
+ module.exports = Dispatcher;