hookarmor 1.1.1 → 1.2.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/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
  ```
@@ -211,7 +211,7 @@ HookArmor supports two forwarding architectures depending on your team's securit
211
211
 
212
212
  ## ⚠️ Production Durability: The Early 200 OK Tradeoff
213
213
 
214
- HookArmor returns an immediate `200 OK` to Stripe and Shopify in `<10ms` to protect your ingress latency and prevent providers from marking your endpoint failed during downstream outages.
214
+ HookArmor returns an immediate `200 OK` to Stripe and Shopify (sub-10ms when idle; measured p50 64ms, p90 153ms under concurrency 50 with `synchronous=FULL` fsync commits, sustaining ~360 events/s) to protect your ingress latency and prevent providers from marking your endpoint failed during downstream outages.
215
215
 
216
216
  **What this means for production operators:**
217
217
  * Once HookArmor returns `200 OK`, Stripe considers the webhook delivered and **halts its external retry backup**.
@@ -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 with immediate fsync commits, 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`. When `HOOKARMOR_API_KEY` is configured, requests must supply the key via `Authorization: Bearer <key>` or `x-api-key`. Scrape this endpoint into your existing Prometheus or VictoriaMetrics instance to monitor webhook health:
238
+
239
+ ```text
240
+ # Scraping: http://localhost:4000/metrics (Pass Authorization: Bearer <key> if enabled)
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,37 @@ function startServerOrExit() {
22
22
  }
23
23
  }
24
24
 
25
+ function setupGracefulShutdown(server, dispatcher, storage, instanceId) {
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
+ const clean = await dispatcher.drain(15000);
34
+ if (!clean) {
35
+ console.warn(chalk.yellow(`⚠️ Drain timed out with ${dispatcher.pendingDeliveries()} deliveries still pending.`));
36
+ }
37
+ }
38
+ if (storage) {
39
+ try {
40
+ if (instanceId) {
41
+ storage.releaseClaimsOwnedBy(instanceId);
42
+ storage.deregisterInstance(instanceId);
43
+ }
44
+ if (storage.db) {
45
+ storage.db.pragma('wal_checkpoint(TRUNCATE)');
46
+ }
47
+ } catch (_) {}
48
+ }
49
+ console.log(chalk.green('✔ Shutdown complete.'));
50
+ process.exit(0);
51
+ };
52
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
53
+ process.on('SIGINT', () => shutdown('SIGINT'));
54
+ }
55
+
25
56
  function apiHeaders(apiKey) {
26
57
  const headers = { 'Content-Type': 'application/json' };
27
58
  if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
@@ -45,7 +76,8 @@ program
45
76
  .action((options) => {
46
77
  const port = parseInt(options.port, 10);
47
78
  const host = options.host;
48
- const { server, storage, mockEnabled } = startServerOrExit();
79
+ const { server, storage, dispatcher, mockEnabled, instanceId } = startServerOrExit();
80
+ setupGracefulShutdown(server, dispatcher, storage, instanceId);
49
81
 
50
82
  // Ensure a default mock endpoint exists for first-time onboarding (local simulation only)
51
83
  if (mockEnabled && !storage.getEndpoint('demo-stripe')) {
@@ -78,11 +110,17 @@ program
78
110
  .description('Tunnel incoming webhooks directly to a local or remote target')
79
111
  .argument('<targetUrl>', 'Destination target URL (e.g. http://localhost:3000/api/webhook)')
80
112
  .option('-p, --port <number>', 'Proxy port', '4000')
113
+ .option('-H, --host <host>', 'Host address to bind to', '127.0.0.1')
81
114
  .option('-e, --endpoint <id>', 'Endpoint ID slug', 'local-dev')
82
115
  .option('-s, --secret <secret>', 'Provider signing secret: verify on ingress and re-sign on delivery')
83
116
  .action(async (targetUrl, options) => {
84
117
  const port = parseInt(options.port, 10);
85
- const { server, storage, dispatcher } = startServerOrExit();
118
+ const host = options.host || '127.0.0.1';
119
+ if (host === '0.0.0.0') {
120
+ 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.'));
121
+ }
122
+ const { server, storage, dispatcher, instanceId } = startServerOrExit();
123
+ setupGracefulShutdown(server, dispatcher, storage, instanceId);
86
124
 
87
125
  storage.createEndpoint({
88
126
  id: options.endpoint,
@@ -103,10 +141,10 @@ program
103
141
  }
104
142
  });
105
143
 
106
- server.listen(port, () => {
144
+ server.listen(port, host, () => {
107
145
  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`);
146
+ console.log(`Forwarding: ${chalk.yellow(`http://${host}:${port}/in/${options.endpoint}`)} ➔ ${chalk.green(targetUrl)}`);
147
+ console.log(`Dashboard: ${chalk.cyan(`http://${host}:${port}/dashboard`)}\n`);
110
148
  });
111
149
  });
112
150
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hookarmor",
3
- "version": "1.1.1",
3
+ "version": "1.2.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
@@ -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,33 @@ 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
+ let queued = 0;
228
+ for (const q of this.waitQueues.values()) {
229
+ queued += q.length;
230
+ }
231
+ return this.activeDeliveries + queued;
232
+ }
233
+
234
+ async drain(timeoutMs = 15000) {
235
+ const start = Date.now();
236
+ while (this.pendingDeliveries() > 0) {
237
+ if (Date.now() - start > timeoutMs) break;
238
+ await new Promise(r => setTimeout(r, 100));
239
+ }
240
+ return this.pendingDeliveries() === 0;
241
+ }
242
+
207
243
  // Callers must have claimed the event (status 'replaying') before dispatching
208
244
  async dispatch(event, endpoint, { replay = false } = {}) {
209
245
  const limit = endpoint.concurrency_limit || this.defaultConcurrency;
210
246
  await this.acquireSlot(endpoint.id, limit);
247
+ this.activeDeliveries++;
211
248
 
212
249
  try {
213
250
  return await this._executeDispatch(event, endpoint, replay);
214
251
  } finally {
252
+ this.activeDeliveries = Math.max(0, this.activeDeliveries - 1);
215
253
  this.releaseSlot(endpoint.id, limit);
216
254
  }
217
255
  }
@@ -222,9 +260,16 @@ class Dispatcher extends EventEmitter {
222
260
 
223
261
  let headers = { ...event.headers };
224
262
 
225
- // Never forward sender-supplied HookArmor headers; ours are added below
263
+ // Never forward sender-supplied HookArmor headers or spoofable routing headers
226
264
  for (const name of Object.keys(headers)) {
227
- if (name.startsWith('x-hookarmor-')) delete headers[name];
265
+ const lower = name.toLowerCase();
266
+ if (
267
+ lower.startsWith('x-hookarmor-') ||
268
+ lower.startsWith('x-forwarded-') ||
269
+ STRIPPED_INBOUND_HEADERS.has(lower)
270
+ ) {
271
+ delete headers[name];
272
+ }
228
273
  }
229
274
 
230
275
  // Preserve original raw signatures before re-signing for audit and forensic trace
@@ -276,6 +321,9 @@ class Dispatcher extends EventEmitter {
276
321
  headers['x-hookarmor-original-timestamp'] = originalTimestamp;
277
322
  const isReplay = replay || event.attempts > 0;
278
323
  headers['x-hookarmor-is-replay'] = isReplay ? 'true' : 'false';
324
+ if (!event.verified) {
325
+ headers['x-hookarmor-unverified'] = 'true';
326
+ }
279
327
 
280
328
  return new Promise((resolve) => {
281
329
  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('');
@@ -486,25 +486,42 @@
486
486
  <li>✓ Web Dashboard & Inspector</li>
487
487
  <li>✓ Terminal CLI runner</li>
488
488
  <li>✓ Local Dev Tunneling</li>
489
- <li>✓ Community Support</li>
489
+ <li>✓ Unlimited Self-Hosted Events</li>
490
+ <li>✓ MIT Licensed & Community Support</li>
490
491
  </ul>
491
492
  <a href="https://github.com/pkdoddamani/hookarmor" class="btn btn-outline" style="margin-top: auto;" target="_blank">Download on GitHub</a>
492
493
  </div>
493
494
 
495
+ <div class="pricing-card">
496
+ <h3>Developer Cloud</h3>
497
+ <div class="price">$0 <span>/ month</span></div>
498
+ <p style="color: var(--text-muted); font-size: 0.85rem;">Zero-setup managed buffer for side projects and prototypes.</p>
499
+ <ul class="feature-list">
500
+ <li>✓ <strong>10,000 events / month</strong></li>
501
+ <li>✓ <strong>7-day retention</strong> in DLQ</li>
502
+ <li>✓ 2 Webhook Endpoints</li>
503
+ <li>✓ Instant Discord & Slack Alerts</li>
504
+ <li>✓ Automatic Exponential Retries</li>
505
+ <li>✓ 1-Click UI & API Replay</li>
506
+ </ul>
507
+ <a href="#waitlist-form" onclick="focusWaitlist();" class="btn btn-outline" style="margin-top: auto;">Join Free Waitlist</a>
508
+ </div>
509
+
494
510
  <div class="pricing-card featured">
495
- <div class="featured-badge">MOST POPULAR</div>
511
+ <div class="featured-badge">FOUNDER LOCK — $19/MO</div>
496
512
  <h3>Starter Cloud</h3>
497
- <div class="price">$29 <span>/ month</span></div>
513
+ <div class="price">$19 <span>/ month</span></div>
498
514
  <p style="color: var(--text-muted); font-size: 0.85rem;">For growing B2B SaaS apps and indie founders.</p>
499
515
  <ul class="feature-list">
500
- <li>✓ <strong>50,000 events / month</strong></li>
516
+ <li>✓ <strong>100,000 events / month</strong></li>
501
517
  <li>✓ <strong>30-day retention</strong> in DLQ</li>
502
- <li>✓ 5 Webhook Endpoints</li>
518
+ <li>✓ 10 Webhook Endpoints</li>
503
519
  <li>✓ Instant Discord & Slack Alerts</li>
504
520
  <li>✓ Automatic Exponential Retries</li>
505
521
  <li>✓ 1-Click UI & API Replay</li>
522
+ <li>✓ <strong>Rate locked for life</strong> (First 50)</li>
506
523
  </ul>
507
- <a href="#waitlist-form" onclick="focusWaitlist();" class="btn btn-primary" style="margin-top: auto;">Get Started</a>
524
+ <a href="#waitlist-form" onclick="focusWaitlist();" class="btn btn-primary" style="margin-top: auto;">Lock In $19/mo</a>
508
525
  </div>
509
526
 
510
527
  <div class="pricing-card">
@@ -512,12 +529,12 @@
512
529
  <div class="price">$79 <span>/ month</span></div>
513
530
  <p style="color: var(--text-muted); font-size: 0.85rem;">For high-volume e-commerce and scale-ups.</p>
514
531
  <ul class="feature-list">
515
- <li>✓ <strong>250,000 events / month</strong></li>
532
+ <li>✓ <strong>500,000 events / month</strong></li>
516
533
  <li>✓ <strong>90-day retention</strong> in DLQ</li>
517
534
  <li>✓ Unlimited Endpoints</li>
518
535
  <li>✓ Per-Endpoint Concurrency Limiting</li>
519
- <li>✓ Team Workspaces (Beta Roadmap)</li>
520
536
  <li>✓ High-Concurrency Queue Worker</li>
537
+ <li>✓ Priority Outbound Bandwidth</li>
521
538
  </ul>
522
539
  <a href="#waitlist-form" onclick="focusWaitlist();" class="btn btn-outline" style="margin-top: auto;">Upgrade to Scale</a>
523
540
  </div>
package/src/server.js CHANGED
@@ -90,15 +90,56 @@ 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
+ // Fail-fast verification: ensure all endpoint secrets can be read with the active encryption key
131
+ storage.assertSecretsReadable(Boolean(process.env.HOOKARMOR_ALLOW_UNREADABLE_SECRETS === 'true'));
132
+
133
+ // Security audit check: warn about endpoints without secrets on startup
134
+ try {
135
+ const initialEndpoints = storage.listEndpoints();
136
+ for (const ep of initialEndpoints) {
137
+ if (!ep.secret) {
138
+ console.warn(`[Security Warning] Endpoint '${ep.id}' has no signing secret. Incoming webhooks cannot be cryptographically verified against spoofing!`);
139
+ }
140
+ }
141
+ } catch (_) {}
142
+
102
143
  const dispatcher = new Dispatcher(storage, {
103
144
  defaultConcurrency: options.defaultConcurrency || 5,
104
145
  defaultTimeoutMs: options.defaultTimeoutMs || 25000,
@@ -108,7 +149,8 @@ function createServer(options = {}) {
108
149
 
109
150
  const worker = new RetryWorker(storage, dispatcher, {
110
151
  intervalMs: options.retryIntervalMs || 5000,
111
- retentionDays
152
+ retentionDays,
153
+ instanceId
112
154
  });
113
155
  if (options.autoStartWorker !== false) {
114
156
  worker.start();
@@ -147,7 +189,7 @@ function createServer(options = {}) {
147
189
  socket.isAuthed = !apiKey || isDemoMode;
148
190
  let authTimer = null;
149
191
  if (!socket.isAuthed) {
150
- authTimer = setTimeout(() => { if (!socket.isAuthed) socket.close(4001, 'Authentication required'); }, 15000);
192
+ authTimer = setTimeout(() => { if (!socket.isAuthed) socket.close(4001, 'Authentication required'); }, 5000);
151
193
  if (authTimer.unref) authTimer.unref();
152
194
  }
153
195
  // Browsers cannot set headers on WebSocket upgrades, so the key arrives as the first message
@@ -271,6 +313,9 @@ function createServer(options = {}) {
271
313
  });
272
314
  }
273
315
  verified = true;
316
+ } else {
317
+ console.warn(`[Ingress Warning] Ingested webhook for endpoint '${endpoint.id}' without signature verification (no secret configured).`);
318
+ headers['x-hookarmor-unverified'] = 'true';
274
319
  }
275
320
 
276
321
  let savedEvent;
@@ -300,6 +345,7 @@ function createServer(options = {}) {
300
345
  headers,
301
346
  rawBody: rawBodyBuffer,
302
347
  status: 'replaying',
348
+ claimedBy: instanceId,
303
349
  verified
304
350
  });
305
351
  } catch (err) {
@@ -522,13 +568,29 @@ function createServer(options = {}) {
522
568
  responseBody: { status: 'mock_processed' }
523
569
  };
524
570
 
571
+ const mockAuthMiddleware = (req, res, next) => {
572
+ if (apiKey) {
573
+ const header = req.headers['authorization'] || '';
574
+ const token = header.startsWith('Bearer ') ? header.slice(7) : (req.headers['x-api-key'] || '');
575
+ if (!tokenMatches(token)) {
576
+ return res.status(401).json({ error: 'Unauthorized: valid API key required for /mock/config' });
577
+ }
578
+ } else {
579
+ const host = hostnameOf(req.headers['host']);
580
+ if (!LOCAL_HOSTNAMES.has(host) && !allowNoAuth) {
581
+ return res.status(403).json({ error: 'Forbidden: loopback access only' });
582
+ }
583
+ }
584
+ next();
585
+ };
586
+
525
587
  app.post('/mock/target', (req, res) => {
526
588
  setTimeout(() => {
527
589
  res.status(mockTargetBehavior.statusCode).json(mockTargetBehavior.responseBody);
528
590
  }, mockTargetBehavior.delayMs);
529
591
  });
530
592
 
531
- app.post('/mock/config', (req, res) => {
593
+ app.post('/mock/config', mockAuthMiddleware, (req, res) => {
532
594
  const body = req.body || {};
533
595
  mockTargetBehavior = {
534
596
  statusCode: clampInt(body.statusCode, mockTargetBehavior.statusCode, 100, 599),
@@ -538,11 +600,76 @@ function createServer(options = {}) {
538
600
  res.json({ message: 'Mock target configuration updated', config: mockTargetBehavior });
539
601
  });
540
602
 
541
- app.get('/mock/config', (req, res) => {
603
+ app.get('/mock/config', mockAuthMiddleware, (req, res) => {
542
604
  res.json(mockTargetBehavior);
543
605
  });
544
606
  }
545
607
 
608
+ // Prometheus Metrics Exposition Endpoint (Requires API key if configured; otherwise loopback only)
609
+ app.get('/metrics', (req, res) => {
610
+ if (apiKey) {
611
+ const authHeader = req.headers['authorization'] || '';
612
+ const token = authHeader.replace(/^Bearer\s+/i, '').trim() || req.headers['x-api-key'] || '';
613
+ if (!tokenMatches(token)) {
614
+ res.setHeader('Content-Type', 'text/plain; version=0.0.4; charset=utf-8');
615
+ return res.status(401).end('# Unauthorized: valid API key required for /metrics\n');
616
+ }
617
+ } else if (!isDemoMode) {
618
+ const remote = req.socket && req.socket.remoteAddress;
619
+ const isLoopbackRemote = !remote || remote === '127.0.0.1' || remote === '::1' || remote === '::ffff:127.0.0.1';
620
+ if (!isLoopbackRemote || !LOCAL_HOSTNAMES.has(hostnameOf(req.headers.host))) {
621
+ res.setHeader('Content-Type', 'text/plain; version=0.0.4; charset=utf-8');
622
+ return res.status(403).end('# Forbidden: loopback only without API key\n');
623
+ }
624
+ }
625
+
626
+ try {
627
+ const stats = storage.getStats();
628
+ const endpoints = storage.listEndpoints();
629
+ const unverifiedCount = endpoints.filter((e) => !e.secret).length;
630
+ const uptime = Math.floor(process.uptime());
631
+
632
+ const lines = [
633
+ '# HELP hookarmor_uptime_seconds Total process uptime in seconds',
634
+ '# TYPE hookarmor_uptime_seconds counter',
635
+ `hookarmor_uptime_seconds ${uptime}`,
636
+ '',
637
+ '# HELP hookarmor_events_total Total number of events by status',
638
+ '# TYPE hookarmor_events_total gauge',
639
+ `hookarmor_events_total{status="delivered"} ${stats.delivered || 0}`,
640
+ `hookarmor_events_total{status="failed"} ${stats.failed || 0}`,
641
+ `hookarmor_events_total{status="pending"} ${stats.pending || 0}`,
642
+ `hookarmor_events_total{status="total"} ${stats.total || 0}`,
643
+ '',
644
+ '# HELP hookarmor_endpoints_total Total number of configured endpoints',
645
+ '# TYPE hookarmor_endpoints_total gauge',
646
+ `hookarmor_endpoints_total ${endpoints.length}`,
647
+ '',
648
+ '# HELP hookarmor_endpoints_unverified_total Number of endpoints without a cryptographic signing secret',
649
+ '# TYPE hookarmor_endpoints_unverified_total gauge',
650
+ `hookarmor_endpoints_unverified_total ${unverifiedCount}`,
651
+ '',
652
+ '# HELP hookarmor_delivery_latency_ms_avg Average delivery latency in milliseconds',
653
+ '# TYPE hookarmor_delivery_latency_ms_avg gauge',
654
+ `hookarmor_delivery_latency_ms_avg ${stats.avgLatencyMs || 0}`
655
+ ];
656
+
657
+ res.setHeader('Content-Type', 'text/plain; version=0.0.4; charset=utf-8');
658
+ res.end(lines.join('\n') + '\n');
659
+ } catch (err) {
660
+ res.status(500).setHeader('Content-Type', 'text/plain').end(`# Error collecting metrics: ${err.message}\n`);
661
+ }
662
+ });
663
+
664
+ // Health and Version Check Endpoint
665
+ app.get('/healthz', (req, res) => {
666
+ res.json({
667
+ status: 'ok',
668
+ version: require('../package.json').version,
669
+ uptime: Math.floor(process.uptime())
670
+ });
671
+ });
672
+
546
673
  // SEO & Web Crawler Discovery
547
674
  app.get('/robots.txt', (req, res) => {
548
675
  res.type('text/plain');
@@ -587,7 +714,7 @@ function createServer(options = {}) {
587
714
  res.redirect('/blog');
588
715
  });
589
716
 
590
- return { app, server, storage, dispatcher, worker, mockEnabled, strictSSRF };
717
+ return { app, server, storage, dispatcher, worker, mockEnabled, strictSSRF, instanceId };
591
718
  }
592
719
 
593
720
  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,118 @@ 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
+ // Release claims owned by a terminating instance so a successor can process them immediately
433
+ releaseClaimsOwnedBy(instanceId) {
434
+ if (!instanceId) return 0;
435
+ const stmt = this.db.prepare(`
436
+ UPDATE events
437
+ SET status = 'pending',
438
+ claimed_by = NULL,
439
+ claimed_at = NULL,
440
+ updated_at = datetime('now')
441
+ WHERE status = 'replaying' AND claimed_by = ?
442
+ `);
443
+ return stmt.run(instanceId).changes;
444
+ }
445
+
446
+ // Deregister an instance on clean shutdown
447
+ deregisterInstance(instanceId) {
448
+ if (!instanceId) return 0;
449
+ return this.db.prepare('DELETE FROM instances WHERE id = ?').run(instanceId).changes;
450
+ }
451
+
452
+ // Fail-fast assertion: verify all configured endpoint secrets can be decrypted
453
+ assertSecretsReadable(allowUnreadable = false) {
454
+ const endpoints = this.db.prepare('SELECT id, secret FROM endpoints').all();
455
+ const unreadable = [];
456
+ for (const ep of endpoints) {
457
+ if (!ep.secret) continue;
458
+ try {
459
+ decryptSecret(ep.secret, this.encryptionKey);
460
+ } catch (err) {
461
+ unreadable.push(ep.id);
462
+ }
463
+ }
464
+ if (unreadable.length > 0 && !allowUnreadable) {
465
+ throw new Error(
466
+ `Failed to decrypt signing secrets for endpoint(s): ${unreadable.join(', ')}. ` +
467
+ `HOOKARMOR_ENCRYPTION_KEY may be missing or invalid. ` +
468
+ `Set HOOKARMOR_ALLOW_UNREADABLE_SECRETS=true to start anyway.`
469
+ );
470
+ }
471
+ return unreadable.length === 0;
472
+ }
473
+
474
+ // Run at startup or worker tick:
475
+ // Reclaim events if:
476
+ // 1. claimed_by is NULL, OR
477
+ // 2. claimed_by is not active in instances table (last_heartbeat < 45 seconds ago)
478
+ // NOTE: Claims held by live heartbeating instances are never reclaimed merely due to age (N1 fix).
313
479
  recoverInterruptedDeliveries() {
314
- return this.db.prepare(`
480
+ const sql = `
315
481
  UPDATE events
316
482
  SET status = 'failed',
317
483
  next_retry_at = datetime('now'),
318
484
  last_error = COALESCE(last_error, 'Delivery interrupted by restart'),
485
+ claimed_by = NULL,
486
+ claimed_at = NULL,
319
487
  updated_at = datetime('now')
320
488
  WHERE status = 'replaying'
321
- `).run().changes;
489
+ AND (
490
+ claimed_by IS NULL
491
+ OR claimed_by NOT IN (
492
+ SELECT id FROM instances WHERE datetime(last_heartbeat) >= datetime('now', '-45 seconds')
493
+ )
494
+ )
495
+ `;
496
+ return this.db.prepare(sql).run().changes;
322
497
  }
323
498
 
324
499
  // Delete delivered events (and their attempt logs) older than `days`. Failed events are kept.
@@ -348,8 +523,19 @@ class Storage {
348
523
  listWaitlist() {
349
524
  return this.db.prepare('SELECT * FROM waitlist ORDER BY created_at DESC').all();
350
525
  }
526
+
527
+ close() {
528
+ if (this.db) {
529
+ try {
530
+ this.db.close();
531
+ } catch (_) {}
532
+ }
533
+ }
351
534
  }
352
535
 
353
536
  Storage.toSqliteTime = toSqliteTime;
537
+ Storage.deriveKey = deriveKey;
538
+ Storage.encryptSecret = encryptSecret;
539
+ Storage.decryptSecret = decryptSecret;
354
540
 
355
541
  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);