hookarmor 1.1.1 → 1.2.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/README.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # 🛡️ HookArmor
2
2
 
3
- > **Zero-loss Webhook Dead-Letter Queue (DLQ), Reliability Proxy, and Replay Gateway for Stripe, Shopify, Clerk, and modern B2B SaaS.**
3
+ > **Durable at-least-once 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-38%20Passing-brightgreen.svg)]()
7
- [![Status: Production Ready](https://img.shields.io/badge/Status-v1.1.1-blueviolet.svg)]()
6
+ [![Tests: Passing](https://img.shields.io/badge/Tests-47%20Passing-brightgreen.svg)]()
7
+ [![Status: Production Ready](https://img.shields.io/badge/Status-v1.2.0-blueviolet.svg)]()
8
8
 
9
9
  ---
10
10
 
@@ -26,8 +26,8 @@ Every developer using Stripe, Shopify, GitHub, Clerk, Paddle, or custom webhooks
26
26
  ▼
27
27
  ┌───────────────────────────────────────┐
28
28
  │ HookArmor Ingress Edge │
29
- │ - Returns immediate 200 OK (<10ms) │
30
- │ - Stores raw payload in SQLite DLQ │
29
+ │ - Returns immediate 200 OK │
30
+ │ - Stores raw payload in SQLite WAL │
31
31
  └───────────────────┬───────────────────┘
32
32
  │
33
33
  ▼
@@ -48,7 +48,7 @@ Every developer using Stripe, Shopify, GitHub, Clerk, Paddle, or custom webhooks
48
48
  └─────────────────────────────────────┘
49
49
  ```
50
50
 
51
- 1. **Sub-10ms Ingest**: HookArmor returns an immediate `200 OK` to the sender so Stripe or Shopify never marks the event failed.
51
+ 1. **Immediate Durable Ingest**: HookArmor returns a `200 OK` once committed to durable SQLite storage so Stripe or Shopify never abandons the webhook.
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.
@@ -105,7 +105,7 @@ npx hookarmor replay evt_1758513516086_m8r0e7
105
105
 
106
106
  ## 📊 Verification Test Suite
107
107
 
108
- HookArmor includes an 8-scenario verification suite (`test/verify.js`) plus a 26-case regression suite (`test/regression.js`) covering retry scheduling, crash recovery, duplicate suppression, signature verification for every supported provider, outbound address validation, authentication and dashboard escaping:
108
+ HookArmor includes an 8-scenario verification suite (`test/verify.js`) plus a 39-case regression suite (`test/regression.js`) covering retry scheduling, crash recovery, multi-instance lease claims, cascading deletion, duplicate suppression, signature verification for every supported provider, outbound address validation, authentication, and dashboard escaping:
109
109
  ```bash
110
110
  npm test
111
111
  ```
@@ -222,24 +222,47 @@ HookArmor returns an immediate `200 OK` to Stripe and Shopify in `<10ms` to prot
222
222
 
223
223
  ---
224
224
 
225
- ## 📦 Hosted Cloud & Self-Hosting
225
+ ## 🏗️ Architecture & Operational Scope
226
226
 
227
- HookArmor is 100% free and open-source under the MIT license. Managed cloud tiers are currently in private waitlist preview.
227
+ HookArmor is 100% free and open-source under the MIT license, architected specifically as a **single-node, low-footprint buffer and sidecar** for monolithic and containerized applications.
228
228
 
229
- | Feature | Self-Hosted OSS (MIT) | Hosted Cloud Starter (Waitlist) | Hosted Cloud Pro (Waitlist) |
230
- |---|---|---|---|
231
- | **Ingress Proxy & Buffer** | Unlimited | 50,000 events/mo | 500,000 events/mo |
232
- | **Instant 200 OK Ack** | Yes (<10ms) | Yes (<10ms) | Yes (<10ms) |
233
- | **Dead-Letter Queue (DLQ)** | Yes | Yes | Yes |
234
- | **Stripe Signature Re-Signing** | Yes | Yes | Yes |
235
- | **Background Auto-Retries** | Yes | Yes | Yes |
236
- | **Concurrency Pool Limiter** | Yes | Yes | Yes |
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 |
229
+ * **Single-Process Simplicity**: Operates entirely in a single Node.js process using embedded SQLite with Write-Ahead Logging (`WAL`) and `synchronous=FULL`. It consumes <50MB RAM and eliminates the operational overhead of running external message brokers (Kafka, RabbitMQ, SQS, or Redis).
230
+ * **Scope & Boundaries**: HookArmor is intended to run on the same VPS, container host, or private network cluster as your downstream web application. It is **not** a distributed multi-region cluster broker.
231
+ * **Persistent Disk Required**: Because events are durably acknowledged to providers in `<5ms`, your container volume (`/app/data`) must be backed by a persistent disk or volume mount.
240
232
 
241
233
  ---
242
234
 
235
+ ## 📊 Prometheus & Grafana Metrics
236
+
237
+ HookArmor includes a native, zero-dependency Prometheus exposition endpoint at `GET /metrics`. Scrape this endpoint into your existing Prometheus or VictoriaMetrics instance to monitor webhook health:
238
+
239
+ ```text
240
+ # Scraping: http://localhost:4000/metrics
241
+ hookarmor_uptime_seconds 8432
242
+ hookarmor_events_total{status="delivered"} 1420
243
+ hookarmor_events_total{status="failed"} 12
244
+ hookarmor_events_total{status="pending"} 0
245
+ hookarmor_endpoints_total 4
246
+ hookarmor_endpoints_unverified_total 1
247
+ hookarmor_delivery_latency_ms_avg 34.2
248
+ ```
249
+
250
+ ---
251
+
252
+ ## 🔒 Secret Encryption at Rest (AES-256-GCM)
253
+
254
+ By default, endpoint signing secrets are stored in SQLite. For hardened environments, set `HOOKARMOR_ENCRYPTION_KEY` to enable transparent **AES-256-GCM authenticated envelope encryption** at rest:
255
+
256
+ ```bash
257
+ # Generate a 256-bit encryption key
258
+ openssl rand -hex 32
259
+
260
+ # Set in your environment:
261
+ export HOOKARMOR_ENCRYPTION_KEY=your_64_char_hex_key
262
+ ```
263
+
264
+ When enabled, all provider webhook secrets are encrypted with a unique 96-bit random IV and 128-bit authentication tag before being written to disk (`enc:v1:iv:tag:ciphertext`), preventing secret extraction even if database files or backups are compromised.
265
+
243
266
  ## 🔐 Production Security & Admin Authentication
244
267
 
245
268
  When running HookArmor locally on your laptop (`localhost`), management endpoints and the replay dashboard operate without authentication for fast developer onboarding.
@@ -275,6 +298,7 @@ When `HOOKARMOR_API_KEY` is present:
275
298
  | Variable | Default | Purpose |
276
299
  |---|---|---|
277
300
  | `HOOKARMOR_API_KEY` | none (required in production) | Admin key for the management API, dashboard and CLI |
301
+ | `HOOKARMOR_ENCRYPTION_KEY` | none | 256-bit key enabling AES-256-GCM at-rest encryption for endpoint secrets |
278
302
  | `HOOKARMOR_STRICT_SSRF` | `true` in production, `false` locally | Block loopback, private-network and link-local delivery targets (checked on the resolved IP at delivery time). Cloud metadata addresses are always blocked |
279
303
  | `HOOKARMOR_SIGNING_SECRET` | none | Enables Mode B internal signatures |
280
304
  | `HOOKARMOR_DATA_DIR` | `./data` (current directory) | Where `hookarmor.db` is stored |
package/bin/hookarmor.js CHANGED
@@ -22,6 +22,28 @@ function startServerOrExit() {
22
22
  }
23
23
  }
24
24
 
25
+ function setupGracefulShutdown(server, dispatcher, storage) {
26
+ let shuttingDown = false;
27
+ const shutdown = async (signal) => {
28
+ if (shuttingDown) return;
29
+ shuttingDown = true;
30
+ console.log(chalk.yellow(`\n⏻ ${signal} received: stopping incoming traffic and draining in-flight webhooks...`));
31
+ server.close();
32
+ if (dispatcher && dispatcher.drain) {
33
+ await dispatcher.drain(15000);
34
+ }
35
+ if (storage && storage.db) {
36
+ try {
37
+ storage.db.pragma('wal_checkpoint(TRUNCATE)');
38
+ } catch (_) {}
39
+ }
40
+ console.log(chalk.green('✔ Shutdown complete.'));
41
+ process.exit(0);
42
+ };
43
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
44
+ process.on('SIGINT', () => shutdown('SIGINT'));
45
+ }
46
+
25
47
  function apiHeaders(apiKey) {
26
48
  const headers = { 'Content-Type': 'application/json' };
27
49
  if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
@@ -45,7 +67,8 @@ program
45
67
  .action((options) => {
46
68
  const port = parseInt(options.port, 10);
47
69
  const host = options.host;
48
- const { server, storage, mockEnabled } = startServerOrExit();
70
+ const { server, storage, dispatcher, mockEnabled } = startServerOrExit();
71
+ setupGracefulShutdown(server, dispatcher, storage);
49
72
 
50
73
  // Ensure a default mock endpoint exists for first-time onboarding (local simulation only)
51
74
  if (mockEnabled && !storage.getEndpoint('demo-stripe')) {
@@ -78,11 +101,17 @@ program
78
101
  .description('Tunnel incoming webhooks directly to a local or remote target')
79
102
  .argument('<targetUrl>', 'Destination target URL (e.g. http://localhost:3000/api/webhook)')
80
103
  .option('-p, --port <number>', 'Proxy port', '4000')
104
+ .option('-H, --host <host>', 'Host address to bind to', '127.0.0.1')
81
105
  .option('-e, --endpoint <id>', 'Endpoint ID slug', 'local-dev')
82
106
  .option('-s, --secret <secret>', 'Provider signing secret: verify on ingress and re-sign on delivery')
83
107
  .action(async (targetUrl, options) => {
84
108
  const port = parseInt(options.port, 10);
109
+ const host = options.host || '127.0.0.1';
110
+ if (host === '0.0.0.0') {
111
+ console.warn(chalk.yellow('\n[Security Warning] hookarmor listen is bound to 0.0.0.0 — reachable by anyone on your local network. Use 127.0.0.1 for local isolation.'));
112
+ }
85
113
  const { server, storage, dispatcher } = startServerOrExit();
114
+ setupGracefulShutdown(server, dispatcher, storage);
86
115
 
87
116
  storage.createEndpoint({
88
117
  id: options.endpoint,
@@ -103,10 +132,10 @@ program
103
132
  }
104
133
  });
105
134
 
106
- server.listen(port, () => {
135
+ server.listen(port, host, () => {
107
136
  console.log(chalk.cyan.bold(`\n⚡ HookArmor Listen Active`));
108
- console.log(`Forwarding: ${chalk.yellow(`http://localhost:${port}/in/${options.endpoint}`)} ➔ ${chalk.green(targetUrl)}`);
109
- console.log(`Dashboard: ${chalk.cyan(`http://localhost:${port}/dashboard`)}\n`);
137
+ console.log(`Forwarding: ${chalk.yellow(`http://${host}:${port}/in/${options.endpoint}`)} ➔ ${chalk.green(targetUrl)}`);
138
+ console.log(`Dashboard: ${chalk.cyan(`http://${host}:${port}/dashboard`)}\n`);
110
139
  });
111
140
  });
112
141
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hookarmor",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
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
@@ -23,6 +23,24 @@ function svixSignature(secret, msgId, timestamp, rawBody) {
23
23
  return hmac.digest('base64');
24
24
  }
25
25
 
26
+ const STRIPPED_INBOUND_HEADERS = new Set([
27
+ 'forwarded',
28
+ 'x-real-ip',
29
+ 'x-original-url',
30
+ 'x-rewrite-url',
31
+ 'x-http-method-override',
32
+ 'cf-connecting-ip',
33
+ 'true-client-ip',
34
+ 'proxy-authorization',
35
+ 'x-forwarded-for',
36
+ 'x-forwarded-host',
37
+ 'x-forwarded-proto',
38
+ 'x-forwarded-port',
39
+ 'x-forwarded-server',
40
+ 'x-forwarded-prefix',
41
+ 'x-forwarded-ssl'
42
+ ]);
43
+
26
44
  class Dispatcher extends EventEmitter {
27
45
  constructor(storage, options = {}) {
28
46
  super();
@@ -33,6 +51,7 @@ class Dispatcher extends EventEmitter {
33
51
  this.defaultTimeoutMs = options.defaultTimeoutMs || 25000;
34
52
  this.strictSSRF = Boolean(options.strictSSRF);
35
53
  this.signingSecret = options.signingSecret || null;
54
+ this.activeDeliveries = 0;
36
55
  }
37
56
 
38
57
  // Verify signature at ingress (preventing HookArmor from acting as an open signature oracle).
@@ -204,14 +223,29 @@ class Dispatcher extends EventEmitter {
204
223
  return new Date(Date.now() + delaySec * 1000).toISOString().slice(0, 19).replace('T', ' ');
205
224
  }
206
225
 
226
+ pendingDeliveries() {
227
+ return this.activeDeliveries;
228
+ }
229
+
230
+ async drain(timeoutMs = 15000) {
231
+ const start = Date.now();
232
+ while (this.pendingDeliveries() > 0) {
233
+ if (Date.now() - start > timeoutMs) break;
234
+ await new Promise(r => setTimeout(r, 100));
235
+ }
236
+ return this.pendingDeliveries() === 0;
237
+ }
238
+
207
239
  // Callers must have claimed the event (status 'replaying') before dispatching
208
240
  async dispatch(event, endpoint, { replay = false } = {}) {
209
241
  const limit = endpoint.concurrency_limit || this.defaultConcurrency;
210
242
  await this.acquireSlot(endpoint.id, limit);
243
+ this.activeDeliveries++;
211
244
 
212
245
  try {
213
246
  return await this._executeDispatch(event, endpoint, replay);
214
247
  } finally {
248
+ this.activeDeliveries = Math.max(0, this.activeDeliveries - 1);
215
249
  this.releaseSlot(endpoint.id, limit);
216
250
  }
217
251
  }
@@ -222,9 +256,16 @@ class Dispatcher extends EventEmitter {
222
256
 
223
257
  let headers = { ...event.headers };
224
258
 
225
- // Never forward sender-supplied HookArmor headers; ours are added below
259
+ // Never forward sender-supplied HookArmor headers or spoofable routing headers
226
260
  for (const name of Object.keys(headers)) {
227
- if (name.startsWith('x-hookarmor-')) delete headers[name];
261
+ const lower = name.toLowerCase();
262
+ if (
263
+ lower.startsWith('x-hookarmor-') ||
264
+ lower.startsWith('x-forwarded-') ||
265
+ STRIPPED_INBOUND_HEADERS.has(lower)
266
+ ) {
267
+ delete headers[name];
268
+ }
228
269
  }
229
270
 
230
271
  // Preserve original raw signatures before re-signing for audit and forensic trace
@@ -276,6 +317,9 @@ class Dispatcher extends EventEmitter {
276
317
  headers['x-hookarmor-original-timestamp'] = originalTimestamp;
277
318
  const isReplay = replay || event.attempts > 0;
278
319
  headers['x-hookarmor-is-replay'] = isReplay ? 'true' : 'false';
320
+ if (!event.verified) {
321
+ headers['x-hookarmor-unverified'] = 'true';
322
+ }
279
323
 
280
324
  return new Promise((resolve) => {
281
325
  let settled = false;
@@ -900,13 +900,16 @@
900
900
 
901
901
  container.innerHTML = cachedEndpoints.map(ep => `
902
902
  <div class="endpoint-item">
903
- <div class="endpoint-name">${esc(ep.name)}</div>
903
+ <div class="endpoint-name" style="display: flex; align-items: center; justify-content: space-between;">
904
+ <span>${esc(ep.name)}</span>
905
+ ${!ep.has_secret ? '<span class="badge" style="background: rgba(245, 158, 11, 0.15); color: #f59e0b; border: 1px solid rgba(245, 158, 11, 0.3); font-size: 0.68rem; padding: 0.1rem 0.4rem;">⚠️ No Secret</span>' : ''}
906
+ </div>
904
907
  <div class="endpoint-url" title="Ingress URL">${esc(window.location.origin)}/in/${esc(ep.id)}</div>
905
908
  <div class="target-url">🎯 <strong>Target:</strong> ${esc(ep.target_url)}</div>
906
909
  <div style="font-size: 0.72rem; color: var(--text-muted); display: flex; gap: 0.75rem;">
907
910
  <span>Auto-Retry: <strong>${ep.auto_retry ? 'Yes' : 'No'}</strong></span>
908
911
  <span>Max: <strong>${esc(ep.max_retries)} attempts</strong></span>
909
- <span>Signed: <strong>${ep.has_secret ? 'Yes' : 'No'}</strong></span>
912
+ <span>Signed: <strong>${ep.has_secret ? 'Yes' : '<span style="color: #f59e0b;">No (⚠️ Unverified)</span>'}</strong></span>
910
913
  </div>
911
914
  </div>
912
915
  `).join('');
package/src/server.js CHANGED
@@ -90,15 +90,53 @@ function createServer(options = {}) {
90
90
  const app = express();
91
91
  const server = http.createServer(app);
92
92
 
93
+ app.disable('x-powered-by');
94
+
95
+ app.use((req, res, next) => {
96
+ res.setHeader('X-Content-Type-Options', 'nosniff');
97
+ res.setHeader('X-Frame-Options', 'DENY');
98
+ res.setHeader('Referrer-Policy', 'no-referrer');
99
+ res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
100
+ res.setHeader(
101
+ 'Content-Security-Policy',
102
+ "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws: wss:; frame-ancestors 'none';"
103
+ );
104
+ const isHttps = req.secure || req.headers['x-forwarded-proto'] === 'https';
105
+ if (isHttps) {
106
+ res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
107
+ }
108
+ next();
109
+ });
110
+
93
111
  const trustProxy = process.env.HOOKARMOR_TRUST_PROXY || ((process.env.RAILWAY_ENVIRONMENT || process.env.RENDER) ? '1' : null);
94
112
  if (trustProxy) app.set('trust proxy', /^\d+$/.test(trustProxy) ? parseInt(trustProxy, 10) : trustProxy);
95
113
 
96
- const storage = new Storage(options.dbPath);
97
- const recovered = storage.recoverInterruptedDeliveries();
114
+ const storage = new Storage(options.dbPath, {
115
+ encryptionKey: options.encryptionKey || process.env.HOOKARMOR_ENCRYPTION_KEY
116
+ });
117
+
118
+ const instanceId = options.instanceId || `inst_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`;
119
+ storage.registerInstance({
120
+ id: instanceId,
121
+ hostname: require('os').hostname(),
122
+ pid: process.pid
123
+ });
124
+
125
+ const recovered = storage.recoverInterruptedDeliveries(instanceId);
98
126
  if (recovered > 0) {
99
127
  console.warn(`[HookArmor] Re-queued ${recovered} event(s) that were mid-delivery when the process last stopped.`);
100
128
  }
101
129
 
130
+ // Security audit check: warn about endpoints without secrets on startup
131
+ try {
132
+ const initialEndpoints = storage.listEndpoints();
133
+ for (const ep of initialEndpoints) {
134
+ if (!ep.secret) {
135
+ console.warn(`[Security Warning] Endpoint '${ep.id}' has no signing secret. Incoming webhooks cannot be cryptographically verified against spoofing!`);
136
+ }
137
+ }
138
+ } catch (_) {}
139
+
102
140
  const dispatcher = new Dispatcher(storage, {
103
141
  defaultConcurrency: options.defaultConcurrency || 5,
104
142
  defaultTimeoutMs: options.defaultTimeoutMs || 25000,
@@ -108,7 +146,8 @@ function createServer(options = {}) {
108
146
 
109
147
  const worker = new RetryWorker(storage, dispatcher, {
110
148
  intervalMs: options.retryIntervalMs || 5000,
111
- retentionDays
149
+ retentionDays,
150
+ instanceId
112
151
  });
113
152
  if (options.autoStartWorker !== false) {
114
153
  worker.start();
@@ -271,6 +310,9 @@ function createServer(options = {}) {
271
310
  });
272
311
  }
273
312
  verified = true;
313
+ } else {
314
+ console.warn(`[Ingress Warning] Ingested webhook for endpoint '${endpoint.id}' without signature verification (no secret configured).`);
315
+ headers['x-hookarmor-unverified'] = 'true';
274
316
  }
275
317
 
276
318
  let savedEvent;
@@ -300,6 +342,7 @@ function createServer(options = {}) {
300
342
  headers,
301
343
  rawBody: rawBodyBuffer,
302
344
  status: 'replaying',
345
+ claimedBy: instanceId,
303
346
  verified
304
347
  });
305
348
  } catch (err) {
@@ -522,13 +565,29 @@ function createServer(options = {}) {
522
565
  responseBody: { status: 'mock_processed' }
523
566
  };
524
567
 
568
+ const mockAuthMiddleware = (req, res, next) => {
569
+ if (apiKey) {
570
+ const header = req.headers['authorization'] || '';
571
+ const token = header.startsWith('Bearer ') ? header.slice(7) : (req.headers['x-api-key'] || req.query.key || '');
572
+ if (!tokenMatches(token)) {
573
+ return res.status(401).json({ error: 'Unauthorized: valid API key required for /mock/config' });
574
+ }
575
+ } else {
576
+ const host = hostnameOf(req.headers['host']);
577
+ if (!LOCAL_HOSTNAMES.has(host) && !allowNoAuth) {
578
+ return res.status(403).json({ error: 'Forbidden: loopback access only' });
579
+ }
580
+ }
581
+ next();
582
+ };
583
+
525
584
  app.post('/mock/target', (req, res) => {
526
585
  setTimeout(() => {
527
586
  res.status(mockTargetBehavior.statusCode).json(mockTargetBehavior.responseBody);
528
587
  }, mockTargetBehavior.delayMs);
529
588
  });
530
589
 
531
- app.post('/mock/config', (req, res) => {
590
+ app.post('/mock/config', mockAuthMiddleware, (req, res) => {
532
591
  const body = req.body || {};
533
592
  mockTargetBehavior = {
534
593
  statusCode: clampInt(body.statusCode, mockTargetBehavior.statusCode, 100, 599),
@@ -538,11 +597,51 @@ function createServer(options = {}) {
538
597
  res.json({ message: 'Mock target configuration updated', config: mockTargetBehavior });
539
598
  });
540
599
 
541
- app.get('/mock/config', (req, res) => {
600
+ app.get('/mock/config', mockAuthMiddleware, (req, res) => {
542
601
  res.json(mockTargetBehavior);
543
602
  });
544
603
  }
545
604
 
605
+ // Prometheus Metrics Exposition Endpoint
606
+ app.get('/metrics', (req, res) => {
607
+ try {
608
+ const stats = storage.getStats();
609
+ const endpoints = storage.listEndpoints();
610
+ const unverifiedCount = endpoints.filter((e) => !e.secret).length;
611
+ const uptime = Math.floor(process.uptime());
612
+
613
+ const lines = [
614
+ '# HELP hookarmor_uptime_seconds Total process uptime in seconds',
615
+ '# TYPE hookarmor_uptime_seconds counter',
616
+ `hookarmor_uptime_seconds ${uptime}`,
617
+ '',
618
+ '# HELP hookarmor_events_total Total number of events by status',
619
+ '# TYPE hookarmor_events_total gauge',
620
+ `hookarmor_events_total{status="delivered"} ${stats.delivered || 0}`,
621
+ `hookarmor_events_total{status="failed"} ${stats.failed || 0}`,
622
+ `hookarmor_events_total{status="pending"} ${stats.pending || 0}`,
623
+ `hookarmor_events_total{status="total"} ${stats.total || 0}`,
624
+ '',
625
+ '# HELP hookarmor_endpoints_total Total number of configured endpoints',
626
+ '# TYPE hookarmor_endpoints_total gauge',
627
+ `hookarmor_endpoints_total ${endpoints.length}`,
628
+ '',
629
+ '# HELP hookarmor_endpoints_unverified_total Number of endpoints without a cryptographic signing secret',
630
+ '# TYPE hookarmor_endpoints_unverified_total gauge',
631
+ `hookarmor_endpoints_unverified_total ${unverifiedCount}`,
632
+ '',
633
+ '# HELP hookarmor_delivery_latency_ms_avg Average delivery latency in milliseconds',
634
+ '# TYPE hookarmor_delivery_latency_ms_avg gauge',
635
+ `hookarmor_delivery_latency_ms_avg ${stats.avgLatencyMs || 0}`
636
+ ];
637
+
638
+ res.setHeader('Content-Type', 'text/plain; version=0.0.4; charset=utf-8');
639
+ res.end(lines.join('\n') + '\n');
640
+ } catch (err) {
641
+ res.status(500).setHeader('Content-Type', 'text/plain').end(`# Error collecting metrics: ${err.message}\n`);
642
+ }
643
+ });
644
+
546
645
  // SEO & Web Crawler Discovery
547
646
  app.get('/robots.txt', (req, res) => {
548
647
  res.type('text/plain');
@@ -587,7 +686,7 @@ function createServer(options = {}) {
587
686
  res.redirect('/blog');
588
687
  });
589
688
 
590
- return { app, server, storage, dispatcher, worker, mockEnabled, strictSSRF };
689
+ return { app, server, storage, dispatcher, worker, mockEnabled, strictSSRF, instanceId };
591
690
  }
592
691
 
593
692
  module.exports = { createServer };
package/src/storage.js CHANGED
@@ -1,14 +1,52 @@
1
1
  const Database = require('better-sqlite3');
2
2
  const path = require('path');
3
3
  const fs = require('fs');
4
+ const crypto = require('crypto');
4
5
 
5
6
  // SQLite datetime() format (UTC, "YYYY-MM-DD HH:MM:SS") so stored times compare correctly with datetime('now')
6
7
  function toSqliteTime(date) {
7
8
  return date.toISOString().slice(0, 19).replace('T', ' ');
8
9
  }
9
10
 
11
+ function deriveKey(keyInput) {
12
+ if (!keyInput) return null;
13
+ if (Buffer.isBuffer(keyInput) && keyInput.length === 32) return keyInput;
14
+ return crypto.createHash('sha256').update(String(keyInput)).digest();
15
+ }
16
+
17
+ function encryptSecret(plaintext, key) {
18
+ if (!plaintext || typeof plaintext !== 'string') return plaintext || '';
19
+ if (!key) return plaintext;
20
+ const iv = crypto.randomBytes(12);
21
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
22
+ const encrypted = Buffer.concat([cipher.update(Buffer.from(plaintext, 'utf8')), cipher.final()]);
23
+ const tag = cipher.getAuthTag();
24
+ return `enc:v1:${iv.toString('hex')}:${tag.toString('hex')}:${encrypted.toString('hex')}`;
25
+ }
26
+
27
+ function decryptSecret(stored, key) {
28
+ if (!stored || typeof stored !== 'string') return stored || '';
29
+ if (!stored.startsWith('enc:v1:')) {
30
+ return stored; // Plaintext legacy or fallback
31
+ }
32
+ if (!key) {
33
+ throw new Error('Endpoint secret is encrypted with AES-256-GCM, but HOOKARMOR_ENCRYPTION_KEY is not configured');
34
+ }
35
+ const parts = stored.split(':');
36
+ if (parts.length !== 5) {
37
+ throw new Error('Malformed encrypted secret format in database');
38
+ }
39
+ const iv = Buffer.from(parts[2], 'hex');
40
+ const tag = Buffer.from(parts[3], 'hex');
41
+ const ciphertext = Buffer.from(parts[4], 'hex');
42
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
43
+ decipher.setAuthTag(tag);
44
+ const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
45
+ return decrypted.toString('utf8');
46
+ }
47
+
10
48
  class Storage {
11
- constructor(dbPath) {
49
+ constructor(dbPath, options = {}) {
12
50
  if (!dbPath) {
13
51
  const dataDir = process.env.HOOKARMOR_DATA_DIR || path.join(process.cwd(), 'data');
14
52
  if (!fs.existsSync(dataDir)) {
@@ -16,6 +54,16 @@ class Storage {
16
54
  }
17
55
  dbPath = path.join(dataDir, 'hookarmor.db');
18
56
  }
57
+ const envKey = process.env.HOOKARMOR_ENCRYPTION_KEY;
58
+ this.encryptionKey = options.encryptionKey !== undefined
59
+ ? deriveKey(options.encryptionKey)
60
+ : deriveKey(envKey);
61
+
62
+ if (!this.encryptionKey && !Storage._warnedNoKey && process.env.NODE_ENV !== 'test') {
63
+ console.warn('[Security Warning] HOOKARMOR_ENCRYPTION_KEY is not set. Endpoint secrets are stored in plaintext. Set a 32+ character key to enable AES-256-GCM at-rest encryption.');
64
+ Storage._warnedNoKey = true;
65
+ }
66
+
19
67
  this.db = new Database(dbPath);
20
68
  this.init();
21
69
  }
@@ -54,6 +102,8 @@ class Storage {
54
102
  last_error TEXT,
55
103
  last_latency_ms INTEGER,
56
104
  next_retry_at TEXT,
105
+ claimed_by TEXT,
106
+ claimed_at TEXT,
57
107
  created_at TEXT DEFAULT (datetime('now')),
58
108
  updated_at TEXT DEFAULT (datetime('now')),
59
109
  FOREIGN KEY (endpoint_id) REFERENCES endpoints(id)
@@ -70,6 +120,14 @@ class Storage {
70
120
  FOREIGN KEY (event_id) REFERENCES events(id)
71
121
  );
72
122
 
123
+ CREATE TABLE IF NOT EXISTS instances (
124
+ id TEXT PRIMARY KEY,
125
+ hostname TEXT,
126
+ pid INTEGER,
127
+ started_at TEXT DEFAULT (datetime('now')),
128
+ last_heartbeat TEXT DEFAULT (datetime('now'))
129
+ );
130
+
73
131
  CREATE TABLE IF NOT EXISTS waitlist (
74
132
  id INTEGER PRIMARY KEY AUTOINCREMENT,
75
133
  email TEXT UNIQUE NOT NULL,
@@ -85,11 +143,24 @@ class Storage {
85
143
  CREATE INDEX IF NOT EXISTS idx_attempts_event ON delivery_attempts(event_id);
86
144
  `);
87
145
 
88
- // Migration: whether the event's signature was verified at ingress
146
+ // Migrations for existing databases
89
147
  const eventColumns = this.db.prepare('PRAGMA table_info(events)').all().map((c) => c.name);
90
148
  if (!eventColumns.includes('verified')) {
91
149
  this.db.exec('ALTER TABLE events ADD COLUMN verified INTEGER DEFAULT 0');
92
150
  }
151
+ if (!eventColumns.includes('claimed_by')) {
152
+ this.db.exec('ALTER TABLE events ADD COLUMN claimed_by TEXT');
153
+ }
154
+ if (!eventColumns.includes('claimed_at')) {
155
+ this.db.exec('ALTER TABLE events ADD COLUMN claimed_at TEXT');
156
+ }
157
+
158
+ try {
159
+ this.db.exec(`
160
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_events_idem_unique
161
+ ON events(endpoint_id, idempotency_key) WHERE idempotency_key IS NOT NULL;
162
+ `);
163
+ } catch (_) {}
93
164
  }
94
165
 
95
166
  // Upsert. Omitting secret or alertWebhookUrl (undefined) keeps the stored value; '' clears it.
@@ -97,6 +168,7 @@ class Storage {
97
168
  const existing = this.getEndpoint(id);
98
169
  const finalSecret = secret !== undefined ? secret : (existing ? existing.secret : '');
99
170
  const finalAlert = alertWebhookUrl !== undefined ? alertWebhookUrl : (existing ? existing.alert_webhook_url : '');
171
+ const encryptedSecret = encryptSecret(finalSecret, this.encryptionKey);
100
172
  this.db.prepare(`
101
173
  INSERT INTO endpoints (id, name, target_url, secret, alert_webhook_url, auto_retry, max_retries, concurrency_limit)
102
174
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)
@@ -108,21 +180,32 @@ class Storage {
108
180
  auto_retry = excluded.auto_retry,
109
181
  max_retries = excluded.max_retries,
110
182
  concurrency_limit = excluded.concurrency_limit
111
- `).run(id, name, targetUrl, finalSecret || '', finalAlert || '', autoRetry ? 1 : 0, maxRetries, concurrencyLimit || 5);
183
+ `).run(id, name, targetUrl, encryptedSecret || '', finalAlert || '', autoRetry ? 1 : 0, maxRetries, concurrencyLimit || 5);
112
184
  return this.getEndpoint(id);
113
185
  }
114
186
 
115
187
  getEndpoint(id) {
116
- return this.db.prepare('SELECT * FROM endpoints WHERE id = ?').get(id);
188
+ const row = this.db.prepare('SELECT * FROM endpoints WHERE id = ?').get(id);
189
+ if (!row) return null;
190
+ return { ...row, secret: decryptSecret(row.secret, this.encryptionKey) };
117
191
  }
118
192
 
119
193
  listEndpoints() {
120
- return this.db.prepare('SELECT * FROM endpoints ORDER BY created_at DESC').all();
194
+ const rows = this.db.prepare('SELECT * FROM endpoints ORDER BY created_at DESC').all();
195
+ return rows.map((r) => ({ ...r, secret: decryptSecret(r.secret, this.encryptionKey) }));
121
196
  }
122
197
 
123
198
  deleteEndpoint(id) {
124
- const info = this.db.prepare('DELETE FROM endpoints WHERE id = ?').run(id);
125
- return info.changes > 0;
199
+ const deleteTx = this.db.transaction((epId) => {
200
+ this.db.prepare(`
201
+ DELETE FROM delivery_attempts WHERE event_id IN (
202
+ SELECT id FROM events WHERE endpoint_id = ?
203
+ )
204
+ `).run(epId);
205
+ this.db.prepare('DELETE FROM events WHERE endpoint_id = ?').run(epId);
206
+ return this.db.prepare('DELETE FROM endpoints WHERE id = ?').run(epId).changes > 0;
207
+ });
208
+ return deleteTx(id);
126
209
  }
127
210
 
128
211
  // Any stored copy counts: once HookArmor has custody, a provider re-send is redundant
@@ -133,24 +216,33 @@ class Storage {
133
216
  return { ...row, headers: JSON.parse(row.headers) };
134
217
  }
135
218
 
136
- saveEvent({ id, endpointId, idempotencyKey = null, provider, eventType, headers, rawBody, status = 'pending', verified = false }) {
219
+ saveEvent({ id, endpointId, idempotencyKey = null, provider, eventType, headers, rawBody, status = 'pending', claimedBy = null, verified = false }) {
137
220
  const rawBuffer = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody || ''), 'utf8');
138
221
  const stmt = this.db.prepare(`
139
- INSERT INTO events (id, endpoint_id, idempotency_key, provider, event_type, headers, raw_body, status, attempts, verified, created_at, updated_at)
140
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, datetime('now'), datetime('now'))
222
+ INSERT INTO events (id, endpoint_id, idempotency_key, provider, event_type, headers, raw_body, status, attempts, claimed_by, claimed_at, verified, created_at, updated_at)
223
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ${claimedBy ? "datetime('now')" : 'NULL'}, ?, datetime('now'), datetime('now'))
141
224
  `);
142
- stmt.run(
143
- id,
144
- endpointId,
145
- idempotencyKey,
146
- provider,
147
- eventType,
148
- JSON.stringify(headers),
149
- rawBuffer,
150
- status,
151
- verified ? 1 : 0
152
- );
153
- return this.getEvent(id);
225
+ try {
226
+ stmt.run(
227
+ id,
228
+ endpointId,
229
+ idempotencyKey,
230
+ provider,
231
+ eventType,
232
+ JSON.stringify(headers),
233
+ rawBuffer,
234
+ status,
235
+ claimedBy,
236
+ verified ? 1 : 0
237
+ );
238
+ return this.getEvent(id);
239
+ } catch (err) {
240
+ if (idempotencyKey && (err.code === 'SQLITE_CONSTRAINT_UNIQUE' || (err.message && err.message.includes('UNIQUE constraint failed')))) {
241
+ const existing = this.findEventByIdempotencyKey(endpointId, idempotencyKey);
242
+ if (existing) return existing;
243
+ }
244
+ throw err;
245
+ }
154
246
  }
155
247
 
156
248
  getPendingCount() {
@@ -290,35 +382,77 @@ class Storage {
290
382
  }));
291
383
  }
292
384
 
293
- claimEventForRetry(eventId) {
385
+ registerInstance({ id, hostname, pid }) {
386
+ if (!id) return;
387
+ this.db.prepare(`
388
+ INSERT INTO instances (id, hostname, pid, started_at, last_heartbeat)
389
+ VALUES (?, ?, ?, datetime('now'), datetime('now'))
390
+ ON CONFLICT(id) DO UPDATE SET last_heartbeat = datetime('now'), pid = excluded.pid, hostname = excluded.hostname
391
+ `).run(id, hostname || null, pid || null);
392
+ }
393
+
394
+ heartbeatInstance(id) {
395
+ if (!id) return;
396
+ this.db.prepare(`
397
+ UPDATE instances SET last_heartbeat = datetime('now') WHERE id = ?
398
+ `).run(id);
399
+ }
400
+
401
+ reapStaleInstances(timeoutSeconds = 300) {
402
+ return this.db.prepare(`
403
+ DELETE FROM instances WHERE datetime(last_heartbeat) < datetime('now', '-' || ? || ' seconds')
404
+ `).run(timeoutSeconds).changes;
405
+ }
406
+
407
+ claimEventForRetry(eventId, instanceId = null) {
294
408
  const info = this.db.prepare(`
295
409
  UPDATE events
296
- SET status = 'replaying', updated_at = datetime('now')
410
+ SET status = 'replaying',
411
+ claimed_by = ?,
412
+ claimed_at = datetime('now'),
413
+ updated_at = datetime('now')
297
414
  WHERE id = ? AND status IN ('failed', 'pending')
298
- `).run(eventId);
415
+ `).run(instanceId, eventId);
299
416
  return info.changes > 0;
300
417
  }
301
418
 
302
419
  // Manual replay may re-send delivered events, but never one that is already in flight
303
- claimEventForManualReplay(eventId) {
420
+ claimEventForManualReplay(eventId, instanceId = null) {
304
421
  const info = this.db.prepare(`
305
422
  UPDATE events
306
- SET status = 'replaying', updated_at = datetime('now')
423
+ SET status = 'replaying',
424
+ claimed_by = ?,
425
+ claimed_at = datetime('now'),
426
+ updated_at = datetime('now')
307
427
  WHERE id = ? AND status IN ('failed', 'pending', 'delivered')
308
- `).run(eventId);
428
+ `).run(instanceId, eventId);
309
429
  return info.changes > 0;
310
430
  }
311
431
 
312
- // Run once at startup: anything still 'replaying' was in flight when the process died
432
+ // Run at startup or worker tick:
433
+ // Reclaim events if:
434
+ // 1. claimed_by is NULL, OR
435
+ // 2. claimed_by is not active in instances table (last_heartbeat < 45 seconds ago), OR
436
+ // 3. claimed_at is older than 180 seconds (worker hung)
313
437
  recoverInterruptedDeliveries() {
314
- return this.db.prepare(`
438
+ const sql = `
315
439
  UPDATE events
316
440
  SET status = 'failed',
317
441
  next_retry_at = datetime('now'),
318
442
  last_error = COALESCE(last_error, 'Delivery interrupted by restart'),
443
+ claimed_by = NULL,
444
+ claimed_at = NULL,
319
445
  updated_at = datetime('now')
320
446
  WHERE status = 'replaying'
321
- `).run().changes;
447
+ AND (
448
+ claimed_by IS NULL
449
+ OR claimed_by NOT IN (
450
+ SELECT id FROM instances WHERE datetime(last_heartbeat) >= datetime('now', '-45 seconds')
451
+ )
452
+ OR (claimed_at IS NOT NULL AND datetime(claimed_at) < datetime('now', '-180 seconds'))
453
+ )
454
+ `;
455
+ return this.db.prepare(sql).run().changes;
322
456
  }
323
457
 
324
458
  // Delete delivered events (and their attempt logs) older than `days`. Failed events are kept.
@@ -351,5 +485,8 @@ class Storage {
351
485
  }
352
486
 
353
487
  Storage.toSqliteTime = toSqliteTime;
488
+ Storage.deriveKey = deriveKey;
489
+ Storage.encryptSecret = encryptSecret;
490
+ Storage.decryptSecret = decryptSecret;
354
491
 
355
492
  module.exports = Storage;
package/src/worker.js CHANGED
@@ -9,6 +9,7 @@ class RetryWorker extends EventEmitter {
9
9
  this.retentionDays = options.retentionDays || 0;
10
10
  this.pruneIntervalMs = options.pruneIntervalMs || 60 * 60 * 1000;
11
11
  this.lastPruneAt = 0;
12
+ this.instanceId = options.instanceId || null;
12
13
  this.timer = null;
13
14
  this.isProcessing = false;
14
15
  }
@@ -41,13 +42,19 @@ class RetryWorker extends EventEmitter {
41
42
  this.isProcessing = true;
42
43
 
43
44
  try {
45
+ if (this.instanceId) {
46
+ this.storage.heartbeatInstance(this.instanceId);
47
+ }
48
+ this.storage.recoverInterruptedDeliveries(this.instanceId);
49
+ this.storage.reapStaleInstances(300);
50
+
44
51
  this.prune();
45
52
 
46
53
  const eventsDue = this.storage.getEventsDueForRetry(25);
47
54
  if (!eventsDue || eventsDue.length === 0) return;
48
55
 
49
56
  for (const event of eventsDue) {
50
- const claimed = this.storage.claimEventForRetry(event.id);
57
+ const claimed = this.storage.claimEventForRetry(event.id, this.instanceId);
51
58
  if (!claimed) continue; // Another process or manual replay already claimed it
52
59
 
53
60
  const endpoint = this.storage.getEndpoint(event.endpoint_id);