hookarmor 1.1.0 → 1.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 HookArmor Team
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -3,8 +3,8 @@
3
3
  > **Zero-loss Webhook Dead-Letter Queue (DLQ), Reliability Proxy, and Replay Gateway for Stripe, Shopify, Clerk, and modern B2B SaaS.**
4
4
 
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
6
- [![Tests: Passing](https://img.shields.io/badge/Tests-34%20Passing-brightgreen.svg)]()
7
- [![Status: Production Ready](https://img.shields.io/badge/Status-v1.1.0-blueviolet.svg)]()
6
+ [![Tests: Passing](https://img.shields.io/badge/Tests-38%20Passing-brightgreen.svg)]()
7
+ [![Status: Production Ready](https://img.shields.io/badge/Status-v1.1.1-blueviolet.svg)]()
8
8
 
9
9
  ---
10
10
 
@@ -52,8 +52,8 @@ Every developer using Stripe, Shopify, GitHub, Clerk, Paddle, or custom webhooks
52
52
  2. **Cryptographic Header & Signature Preservation**: Forwards exact raw bytes, `stripe-signature`, `x-shopify-hmac-sha256`, and timestamps.
53
53
  3. **Dead-Letter Queue (DLQ)**: If your server returns 500, 502, 504, 429, or times out, HookArmor safely preserves the raw event with full error diagnostics.
54
54
  4. **Instant Alerts**: Sends immediate Slack / Discord webhooks when an endpoint begins failing.
55
- 5. **1-Click Bulk Replay**: As soon as you push your code fix, hit **Replay All Dead-Letter** in the Web UI or run `hookarmor replay --failed` to restore all customer transactions in 2 seconds.
56
- 6. **Local Dev Tunnel**: `hookarmor listen http://localhost:3000/api/webhooks` to replay production webhooks straight into localhost debuggers.
55
+ 5. **1-Click Bulk Replay**: As soon as you push your code fix, hit **Replay All Dead-Letter** in the Web UI or run `hookarmor replay --failed` to re-deliver customer transactions.
56
+ 6. **Local Dev Relay**: `hookarmor listen http://localhost:3000/api/webhooks` to proxy webhooks straight into local dev servers with automatic re-signing.
57
57
 
58
58
  ---
59
59
 
@@ -224,7 +224,9 @@ HookArmor returns an immediate `200 OK` to Stripe and Shopify in `<10ms` to prot
224
224
 
225
225
  ## šŸ“¦ Hosted Cloud & Self-Hosting
226
226
 
227
- | Feature | Self-Hosted (MIT) | Hosted Cloud Starter ($29/mo) | Hosted Cloud Pro ($79/mo) |
227
+ HookArmor is 100% free and open-source under the MIT license. Managed cloud tiers are currently in private waitlist preview.
228
+
229
+ | Feature | Self-Hosted OSS (MIT) | Hosted Cloud Starter (Waitlist) | Hosted Cloud Pro (Waitlist) |
228
230
  |---|---|---|---|
229
231
  | **Ingress Proxy & Buffer** | Unlimited | 50,000 events/mo | 500,000 events/mo |
230
232
  | **Instant 200 OK Ack** | Yes (<10ms) | Yes (<10ms) | Yes (<10ms) |
@@ -232,9 +234,9 @@ HookArmor returns an immediate `200 OK` to Stripe and Shopify in `<10ms` to prot
232
234
  | **Stripe Signature Re-Signing** | Yes | Yes | Yes |
233
235
  | **Background Auto-Retries** | Yes | Yes | Yes |
234
236
  | **Concurrency Pool Limiter** | Yes | Yes | Yes |
235
- | **Event Retention** | Local Disk | 30 days | 90 days |
236
- | **Alerting** | Discord / Slack Webhook | Discord / Slack Webhook | Priority alerts + PagerDuty |
237
- | **Infrastructure** | Your own server | Fully managed & redundant | Fully managed & redundant |
237
+ | **Event Retention** | Local Disk (Configurable) | 30 days | 90 days |
238
+ | **Alerting** | Discord / Slack Webhooks | Discord / Slack Webhooks | Priority Webhooks |
239
+ | **Infrastructure** | Single-node Docker / VPS | Fully managed & redundant | Fully managed & redundant |
238
240
 
239
241
  ---
240
242
 
package/bin/hookarmor.js CHANGED
@@ -41,8 +41,10 @@ program
41
41
  .command('start')
42
42
  .description('Start HookArmor server with web dashboard and ingress proxy')
43
43
  .option('-p, --port <number>', 'Port to listen on', process.env.PORT || '4000')
44
+ .option('-H, --host <host>', 'Host address to bind to', process.env.HOOKARMOR_HOST || process.env.HOST || (process.env.HOOKARMOR_API_KEY || process.env.NODE_ENV === 'production' ? '0.0.0.0' : '127.0.0.1'))
44
45
  .action((options) => {
45
46
  const port = parseInt(options.port, 10);
47
+ const host = options.host;
46
48
  const { server, storage, mockEnabled } = startServerOrExit();
47
49
 
48
50
  // Ensure a default mock endpoint exists for first-time onboarding (local simulation only)
@@ -57,13 +59,14 @@ program
57
59
  });
58
60
  }
59
61
 
60
- server.listen(port, () => {
62
+ server.listen(port, host, () => {
61
63
  console.log(chalk.blue.bold('\nšŸ›”ļø HookArmor Webhook Sentinel is LIVE!'));
62
64
  console.log(chalk.gray('─────────────────────────────────────────'));
63
- console.log(`šŸ“” Ingress Gateway : ${chalk.cyan(`http://localhost:${port}/in/:endpointId`)}`);
64
- console.log(`šŸ“Š Web Dashboard : ${chalk.green.bold(`http://localhost:${port}/dashboard`)}`);
65
+ const displayHost = (host === '0.0.0.0' || host === '127.0.0.1') ? 'localhost' : host;
66
+ console.log(`šŸ“” Ingress Gateway : ${chalk.cyan(`http://${displayHost}:${port}/in/:endpointId`)}`);
67
+ console.log(`šŸ“Š Web Dashboard : ${chalk.green.bold(`http://${displayHost}:${port}/dashboard`)}`);
65
68
  if (mockEnabled) {
66
- console.log(`āš™ļø Mock Receiver : ${chalk.yellow(`http://localhost:${port}/mock/target`)}`);
69
+ console.log(`āš™ļø Mock Receiver : ${chalk.yellow(`http://${displayHost}:${port}/mock/target`)}`);
67
70
  }
68
71
  console.log(chalk.gray('─────────────────────────────────────────'));
69
72
  console.log(chalk.white('Press Ctrl+C to stop.\n'));
@@ -117,7 +120,7 @@ program
117
120
  .action(async (eventId, options) => {
118
121
  const baseUrl = options.url.replace(/\/$/, '');
119
122
  try {
120
- if (options.failed || !eventId) {
123
+ if (options.failed) {
121
124
  console.log(chalk.yellow('šŸ”„ Replaying all dead-letter events...'));
122
125
  const res = await fetch(`${baseUrl}/api/events/replay-all`, { method: 'POST', headers: apiHeaders(options.apiKey), body: '{}' });
123
126
  const data = await readJson(res);
@@ -125,7 +128,7 @@ program
125
128
  if (data.remaining > 0) {
126
129
  console.log(chalk.yellow(` ${data.remaining} failed events still queued; run the command again to continue.`));
127
130
  }
128
- } else {
131
+ } else if (eventId) {
129
132
  console.log(chalk.yellow(`šŸ”„ Replaying event ${eventId}...`));
130
133
  const res = await fetch(`${baseUrl}/api/events/${encodeURIComponent(eventId)}/replay`, { method: 'POST', headers: apiHeaders(options.apiKey), body: '{}' });
131
134
  const data = await readJson(res);
@@ -135,6 +138,10 @@ program
135
138
  console.log(chalk.red(`āŒ Replay failed: HTTP ${data.statusCode} (${data.errorMessage || 'Target rejected'})`));
136
139
  process.exitCode = 1;
137
140
  }
141
+ } else {
142
+ console.error(chalk.red('āœ– Missing argument: specify an [eventId] or pass --failed to replay all dead-letter events.'));
143
+ console.log(chalk.gray(' Usage: hookarmor replay <eventId> OR hookarmor replay --failed\n'));
144
+ process.exitCode = 1;
138
145
  }
139
146
  } catch (err) {
140
147
  console.error(chalk.red(`Replay request to ${baseUrl} failed: ${err.message}`));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hookarmor",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "description": "Zero-loss Webhook Dead-Letter Queue (DLQ), Reliability Proxy, and Replay Gateway for Stripe, Shopify, Clerk, and B2B SaaS.",
5
5
  "main": "index.js",
6
6
  "bin": {
package/src/dispatcher.js CHANGED
@@ -17,7 +17,10 @@ function svixKey(secret) {
17
17
  }
18
18
 
19
19
  function svixSignature(secret, msgId, timestamp, rawBody) {
20
- return crypto.createHmac('sha256', svixKey(secret)).update(`${msgId}.${timestamp}.${rawBody}`).digest('base64');
20
+ const hmac = crypto.createHmac('sha256', svixKey(secret));
21
+ hmac.update(Buffer.from(`${msgId}.${timestamp}.`, 'utf8'));
22
+ hmac.update(Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody || ''), 'utf8'));
23
+ return hmac.digest('base64');
21
24
  }
22
25
 
23
26
  class Dispatcher extends EventEmitter {
@@ -62,10 +65,10 @@ class Dispatcher extends EventEmitter {
62
65
  return { valid: false, reason: `Timestamp outside tolerance (${toleranceSec}s)` };
63
66
  }
64
67
 
65
- const expectedSig = crypto
66
- .createHmac('sha256', secret)
67
- .update(`${timestampStr}.${rawBody}`)
68
- .digest('hex');
68
+ const hmac = crypto.createHmac('sha256', secret);
69
+ hmac.update(Buffer.from(`${timestampStr}.`, 'utf8'));
70
+ hmac.update(Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody || ''), 'utf8'));
71
+ const expectedSig = hmac.digest('hex');
69
72
 
70
73
  if (!signatures.some((sig) => safeEqual(expectedSig, sig))) {
71
74
  return { valid: false, reason: 'Signature mismatch' };
@@ -79,7 +82,7 @@ class Dispatcher extends EventEmitter {
79
82
 
80
83
  const expectedSig = crypto
81
84
  .createHmac('sha256', secret)
82
- .update(rawBody)
85
+ .update(Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody || ''), 'utf8'))
83
86
  .digest('base64');
84
87
 
85
88
  if (!safeEqual(expectedSig, hmac)) {
@@ -92,7 +95,10 @@ class Dispatcher extends EventEmitter {
92
95
  const sig = headers['x-hub-signature-256'];
93
96
  if (!sig) return { valid: false, reason: 'Missing X-Hub-Signature-256 header' };
94
97
 
95
- const expectedSig = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
98
+ const expectedSig = 'sha256=' + crypto
99
+ .createHmac('sha256', secret)
100
+ .update(Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody || ''), 'utf8'))
101
+ .digest('hex');
96
102
  if (!safeEqual(expectedSig, sig)) {
97
103
  return { valid: false, reason: 'GitHub signature mismatch' };
98
104
  }
@@ -137,19 +143,22 @@ class Dispatcher extends EventEmitter {
137
143
  try {
138
144
  if (provider === 'stripe') {
139
145
  const freshTimestamp = Math.floor(Date.now() / 1000);
140
- const freshSignature = crypto
141
- .createHmac('sha256', secret)
142
- .update(`${freshTimestamp}.${rawBody}`)
143
- .digest('hex');
146
+ const hmac = crypto.createHmac('sha256', secret);
147
+ hmac.update(Buffer.from(`${freshTimestamp}.`, 'utf8'));
148
+ hmac.update(Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody || ''), 'utf8'));
149
+ const freshSignature = hmac.digest('hex');
144
150
  modified['stripe-signature'] = `t=${freshTimestamp},v1=${freshSignature}`;
145
151
  } else if (provider === 'shopify') {
146
152
  const freshHmac = crypto
147
153
  .createHmac('sha256', secret)
148
- .update(rawBody)
154
+ .update(Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody || ''), 'utf8'))
149
155
  .digest('base64');
150
156
  modified['x-shopify-hmac-sha256'] = freshHmac;
151
157
  } else if (provider === 'github') {
152
- const freshSig = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
158
+ const freshSig = 'sha256=' + crypto
159
+ .createHmac('sha256', secret)
160
+ .update(Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody || ''), 'utf8'))
161
+ .digest('hex');
153
162
  modified['x-hub-signature-256'] = freshSig;
154
163
  } else if (provider === 'clerk/svix' && modified['svix-id']) {
155
164
  const freshTimestamp = String(Math.floor(Date.now() / 1000));
@@ -229,15 +238,22 @@ class Dispatcher extends EventEmitter {
229
238
  headers['x-hookarmor-original-svix-signature'] = event.headers['svix-signature'];
230
239
  }
231
240
 
241
+ const rawBuffer = Buffer.isBuffer(event.raw_body)
242
+ ? event.raw_body
243
+ : Buffer.from(String(event.raw_body || ''), 'utf8');
244
+
232
245
  // Re-sign outbound headers if endpoint secret is configured (Transparent Zero-Code-Change Mode)
233
246
  if (endpoint.secret) {
234
- headers = this.signHeaders(event.provider, event.raw_body, headers, endpoint.secret);
247
+ headers = this.signHeaders(event.provider, rawBuffer, headers, endpoint.secret);
235
248
  }
236
249
 
237
250
  // Isolated Trust Domain mode: vouch only for events whose signature was verified at ingress
238
251
  if (this.signingSecret && event.verified) {
239
252
  const freshTs = Math.floor(Date.now() / 1000);
240
- const internalSig = crypto.createHmac('sha256', this.signingSecret).update(`${freshTs}.${event.raw_body}`).digest('hex');
253
+ const hmac = crypto.createHmac('sha256', this.signingSecret);
254
+ hmac.update(Buffer.from(`${freshTs}.`, 'utf8'));
255
+ hmac.update(rawBuffer);
256
+ const internalSig = hmac.digest('hex');
241
257
  headers['x-hookarmor-signature'] = `t=${freshTs},v1=${internalSig}`;
242
258
  }
243
259
 
@@ -249,11 +265,15 @@ class Dispatcher extends EventEmitter {
249
265
  delete headers['transfer-encoding'];
250
266
  delete headers['expect'];
251
267
 
252
- const rawBuffer = Buffer.from(event.raw_body, 'utf8');
253
268
  headers['content-length'] = String(rawBuffer.length);
254
269
  headers['x-hookarmor-delivery-id'] = event.id;
255
270
  headers['x-hookarmor-attempt'] = String(event.attempts + 1);
256
- headers['x-hookarmor-original-timestamp'] = event.created_at;
271
+ // Strict ISO-8601 UTC timestamp with Z to prevent client-side local timezone parsing skew
272
+ const createdAtStr = String(event.created_at || '');
273
+ const originalTimestamp = createdAtStr.includes('T')
274
+ ? (createdAtStr.endsWith('Z') ? createdAtStr : `${createdAtStr}Z`)
275
+ : (createdAtStr ? `${createdAtStr.replace(' ', 'T')}Z` : new Date().toISOString());
276
+ headers['x-hookarmor-original-timestamp'] = originalTimestamp;
257
277
  const isReplay = replay || event.attempts > 0;
258
278
  headers['x-hookarmor-is-replay'] = isReplay ? 'true' : 'false';
259
279
 
@@ -464,7 +464,7 @@
464
464
  <div class="card-icon" style="background: rgba(56, 189, 248, 0.1); color: #38bdf8;">šŸ’»</div>
465
465
  <h3>Zero-Friction Local Tunnel</h3>
466
466
  <p>
467
- Run <code>node bin/hookarmor.js listen http://localhost:3000/api/webhook</code> to test production events against your local IDE with hot reloading and zero ngrok hassles.
467
+ Run <code>hookarmor listen http://localhost:3000/api/webhook</code> to proxy and replay captured webhook events straight into your local server with automatic signature verification.
468
468
  </p>
469
469
  </div>
470
470
  </div>
@@ -515,7 +515,7 @@
515
515
  <li>āœ“ <strong>250,000 events / month</strong></li>
516
516
  <li>āœ“ <strong>90-day retention</strong> in DLQ</li>
517
517
  <li>āœ“ Unlimited Endpoints</li>
518
- <li>āœ“ Automated Circuit Breakers</li>
518
+ <li>āœ“ Per-Endpoint Concurrency Limiting</li>
519
519
  <li>āœ“ Team Workspaces (Beta Roadmap)</li>
520
520
  <li>āœ“ High-Concurrency Queue Worker</li>
521
521
  </ul>
package/src/server.js CHANGED
@@ -135,7 +135,12 @@ function createServer(options = {}) {
135
135
  path: '/ws',
136
136
  maxPayload: 4096,
137
137
  // Without an API key the feed is only served to localhost (blocks DNS-rebinding pages)
138
- verifyClient: (info) => Boolean(apiKey) || LOCAL_HOSTNAMES.has(hostnameOf(info.req.headers.host))
138
+ verifyClient: (info) => {
139
+ if (apiKey) return true;
140
+ const remote = info.req.socket && info.req.socket.remoteAddress;
141
+ const isLoopbackRemote = !remote || remote === '127.0.0.1' || remote === '::1' || remote === '::ffff:127.0.0.1';
142
+ return isLoopbackRemote && LOCAL_HOSTNAMES.has(hostnameOf(info.req.headers.host));
143
+ }
139
144
  });
140
145
 
141
146
  wss.on('connection', (socket) => {
@@ -184,8 +189,24 @@ function createServer(options = {}) {
184
189
  // Serve static UI assets
185
190
  app.use('/static', express.static(path.join(__dirname, 'public')));
186
191
 
192
+ const ingressLimiter = createRateLimiter({
193
+ limit: clampInt(process.env.HOOKARMOR_INGRESS_RATE_LIMIT, 600, 1, 50000),
194
+ windowMs: 60000
195
+ });
196
+ const maxPendingEvents = clampInt(process.env.HOOKARMOR_MAX_PENDING_EVENTS, 50000, 100, 1000000);
197
+
187
198
  // Ingress endpoint for webhooks - uses raw body parser to preserve raw bytes
188
199
  app.post('/in/:endpointId', express.raw({ type: '*/*', limit: maxBody }), (req, res) => {
200
+ // Ingress rate limiting per IP
201
+ if (!ingressLimiter(req.ip)) {
202
+ return res.status(429).json({ error: 'Too many ingress requests from this IP; rate limit exceeded' });
203
+ }
204
+
205
+ // Storage capacity backpressure protection
206
+ if (storage.getPendingCount && storage.getPendingCount() >= maxPendingEvents) {
207
+ return res.status(503).json({ error: 'Ingress queue capacity reached. System is under high load; retry shortly.' });
208
+ }
209
+
189
210
  const endpointId = req.params.endpointId;
190
211
  const endpoint = storage.getEndpoint(endpointId);
191
212
 
@@ -193,8 +214,11 @@ function createServer(options = {}) {
193
214
  return res.status(404).json({ error: 'HookArmor endpoint not found' });
194
215
  }
195
216
 
196
- const rawBodyBuffer = Buffer.isBuffer(req.body) ? req.body : Buffer.from('');
197
- const rawBody = rawBodyBuffer.toString('utf8');
217
+ const rawBodyBuffer = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || '');
218
+ let rawBody = '';
219
+ try {
220
+ rawBody = rawBodyBuffer.toString('utf8');
221
+ } catch (_) {}
198
222
  const headers = { ...req.headers };
199
223
 
200
224
  // Detect provider & idempotency key
@@ -238,7 +262,7 @@ function createServer(options = {}) {
238
262
  // 0. Verify signature on ingress if endpoint secret is configured
239
263
  let verified = false;
240
264
  if (endpoint.secret) {
241
- const verification = Dispatcher.verifyIngressSignature(provider, rawBody, headers, endpoint.secret);
265
+ const verification = Dispatcher.verifyIngressSignature(provider, rawBodyBuffer, headers, endpoint.secret);
242
266
  if (!verification.valid) {
243
267
  return res.status(400).json({
244
268
  error: 'Webhook signature verification failed',
@@ -274,7 +298,7 @@ function createServer(options = {}) {
274
298
  provider,
275
299
  eventType,
276
300
  headers,
277
- rawBody,
301
+ rawBody: rawBodyBuffer,
278
302
  status: 'replaying',
279
303
  verified
280
304
  });
@@ -319,10 +343,12 @@ function createServer(options = {}) {
319
343
 
320
344
  if (!apiKey) {
321
345
  if (!isDemoMode) {
322
- // Open local-dev mode: only answer to localhost names, so a web page cannot reach this
323
- // API through DNS rebinding
324
- if (!LOCAL_HOSTNAMES.has(hostnameOf(req.headers.host))) {
325
- return res.status(403).json({ error: 'Without HOOKARMOR_API_KEY the management API is only served on localhost' });
346
+ // Open local-dev mode: only answer to loopback connections and localhost host names,
347
+ // so LAN attackers or web pages through DNS rebinding cannot reach this API
348
+ const remote = req.socket && req.socket.remoteAddress;
349
+ const isLoopbackRemote = !remote || remote === '127.0.0.1' || remote === '::1' || remote === '::ffff:127.0.0.1';
350
+ if (!isLoopbackRemote || !LOCAL_HOSTNAMES.has(hostnameOf(req.headers.host))) {
351
+ return res.status(403).json({ error: 'Without HOOKARMOR_API_KEY the management API is only accessible locally from loopback interfaces (localhost/127.0.0.1)' });
326
352
  }
327
353
  return next();
328
354
  }
@@ -352,7 +378,9 @@ function createServer(options = {}) {
352
378
  app.use('/api', (req, res, next) => {
353
379
  const contentType = String(req.headers['content-type'] || '').split(';')[0].trim().toLowerCase();
354
380
  const isJson = contentType === 'application/json' || contentType.endsWith('+json');
355
- if (req.method !== 'GET' && req.method !== 'HEAD' && req.method !== 'OPTIONS' && !isJson) {
381
+ const isBodyMethod = req.method === 'POST' || req.method === 'PUT' || req.method === 'PATCH';
382
+ const hasBody = Boolean(req.headers['content-length'] && req.headers['content-length'] !== '0');
383
+ if ((isBodyMethod || hasBody) && !isJson) {
356
384
  return res.status(415).json({ error: 'Content-Type must be application/json' });
357
385
  }
358
386
  next();
@@ -406,6 +434,13 @@ function createServer(options = {}) {
406
434
  res.json(publicEndpoint(endpoint));
407
435
  });
408
436
 
437
+ // Delete an endpoint
438
+ app.delete('/api/endpoints/:id', (req, res) => {
439
+ const deleted = storage.deleteEndpoint(req.params.id);
440
+ if (!deleted) return res.status(404).json({ error: 'Endpoint not found' });
441
+ res.json({ success: true, message: 'Endpoint deleted' });
442
+ });
443
+
409
444
  // List Events
410
445
  app.get('/api/events', (req, res) => {
411
446
  const { endpointId, status, limit, offset } = req.query;
@@ -415,7 +450,10 @@ function createServer(options = {}) {
415
450
  limit: clampInt(limit, 50, 1, 500),
416
451
  offset: clampInt(offset, 0, 0, Number.MAX_SAFE_INTEGER)
417
452
  });
418
- res.json(events);
453
+ res.json(events.map(e => ({
454
+ ...e,
455
+ raw_body: Buffer.isBuffer(e.raw_body) ? e.raw_body.toString('utf8') : String(e.raw_body || '')
456
+ })));
419
457
  });
420
458
 
421
459
  // Get single event with attempts
@@ -423,7 +461,18 @@ function createServer(options = {}) {
423
461
  const event = storage.getEvent(req.params.id);
424
462
  if (!event) return res.status(404).json({ error: 'Event not found' });
425
463
  const attempts = storage.getAttempts(req.params.id);
426
- res.json({ ...event, attempts });
464
+ res.json({
465
+ ...event,
466
+ raw_body: Buffer.isBuffer(event.raw_body) ? event.raw_body.toString('utf8') : String(event.raw_body || ''),
467
+ attempts
468
+ });
469
+ });
470
+
471
+ // Delete a specific event and its attempts
472
+ app.delete('/api/events/:id', (req, res) => {
473
+ const deleted = storage.deleteEvent(req.params.id);
474
+ if (!deleted) return res.status(404).json({ error: 'Event not found' });
475
+ res.json({ success: true, message: 'Event deleted' });
427
476
  });
428
477
 
429
478
  // Replay a specific event
package/src/storage.js CHANGED
@@ -120,6 +120,11 @@ class Storage {
120
120
  return this.db.prepare('SELECT * FROM endpoints ORDER BY created_at DESC').all();
121
121
  }
122
122
 
123
+ deleteEndpoint(id) {
124
+ const info = this.db.prepare('DELETE FROM endpoints WHERE id = ?').run(id);
125
+ return info.changes > 0;
126
+ }
127
+
123
128
  // Any stored copy counts: once HookArmor has custody, a provider re-send is redundant
124
129
  findEventByIdempotencyKey(endpointId, idempotencyKey) {
125
130
  if (!idempotencyKey) return null;
@@ -129,6 +134,7 @@ class Storage {
129
134
  }
130
135
 
131
136
  saveEvent({ id, endpointId, idempotencyKey = null, provider, eventType, headers, rawBody, status = 'pending', verified = false }) {
137
+ const rawBuffer = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody || ''), 'utf8');
132
138
  const stmt = this.db.prepare(`
133
139
  INSERT INTO events (id, endpoint_id, idempotency_key, provider, event_type, headers, raw_body, status, attempts, verified, created_at, updated_at)
134
140
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, datetime('now'), datetime('now'))
@@ -140,13 +146,17 @@ class Storage {
140
146
  provider,
141
147
  eventType,
142
148
  JSON.stringify(headers),
143
- rawBody,
149
+ rawBuffer,
144
150
  status,
145
151
  verified ? 1 : 0
146
152
  );
147
153
  return this.getEvent(id);
148
154
  }
149
155
 
156
+ getPendingCount() {
157
+ return this.db.prepare("SELECT count(*) as count FROM events WHERE status IN ('pending', 'replaying')").get().count;
158
+ }
159
+
150
160
  recordAttempt({ eventId, statusCode, responseBody = '', errorMessage = '', latencyMs = 0, nextRetryAt = null }) {
151
161
  const insertAttempt = this.db.prepare(`
152
162
  INSERT INTO delivery_attempts (event_id, status_code, response_body, error_message, latency_ms, attempted_at)
@@ -188,6 +198,14 @@ class Storage {
188
198
  return this.db.prepare('SELECT * FROM delivery_attempts WHERE event_id = ? ORDER BY id DESC').all(eventId);
189
199
  }
190
200
 
201
+ deleteEvent(id) {
202
+ const deleteTx = this.db.transaction((eventId) => {
203
+ this.db.prepare('DELETE FROM delivery_attempts WHERE event_id = ?').run(eventId);
204
+ return this.db.prepare('DELETE FROM events WHERE id = ?').run(eventId).changes > 0;
205
+ });
206
+ return deleteTx(id);
207
+ }
208
+
191
209
  listEvents({ endpointId = null, status = null, limit = 50, offset = 0 } = {}) {
192
210
  let sql = 'SELECT * FROM events WHERE 1=1';
193
211
  const params = [];