hookarmor 1.2.0 → 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
@@ -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**.
@@ -228,16 +228,16 @@ HookArmor is 100% free and open-source under the MIT license, architected specif
228
228
 
229
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
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.
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.
232
232
 
233
233
  ---
234
234
 
235
235
  ## 📊 Prometheus & Grafana Metrics
236
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:
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
238
 
239
239
  ```text
240
- # Scraping: http://localhost:4000/metrics
240
+ # Scraping: http://localhost:4000/metrics (Pass Authorization: Bearer <key> if enabled)
241
241
  hookarmor_uptime_seconds 8432
242
242
  hookarmor_events_total{status="delivered"} 1420
243
243
  hookarmor_events_total{status="failed"} 12
package/bin/hookarmor.js CHANGED
@@ -22,7 +22,7 @@ function startServerOrExit() {
22
22
  }
23
23
  }
24
24
 
25
- function setupGracefulShutdown(server, dispatcher, storage) {
25
+ function setupGracefulShutdown(server, dispatcher, storage, instanceId) {
26
26
  let shuttingDown = false;
27
27
  const shutdown = async (signal) => {
28
28
  if (shuttingDown) return;
@@ -30,11 +30,20 @@ function setupGracefulShutdown(server, dispatcher, storage) {
30
30
  console.log(chalk.yellow(`\n⏻ ${signal} received: stopping incoming traffic and draining in-flight webhooks...`));
31
31
  server.close();
32
32
  if (dispatcher && dispatcher.drain) {
33
- await dispatcher.drain(15000);
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
+ }
34
37
  }
35
- if (storage && storage.db) {
38
+ if (storage) {
36
39
  try {
37
- storage.db.pragma('wal_checkpoint(TRUNCATE)');
40
+ if (instanceId) {
41
+ storage.releaseClaimsOwnedBy(instanceId);
42
+ storage.deregisterInstance(instanceId);
43
+ }
44
+ if (storage.db) {
45
+ storage.db.pragma('wal_checkpoint(TRUNCATE)');
46
+ }
38
47
  } catch (_) {}
39
48
  }
40
49
  console.log(chalk.green('✔ Shutdown complete.'));
@@ -67,8 +76,8 @@ program
67
76
  .action((options) => {
68
77
  const port = parseInt(options.port, 10);
69
78
  const host = options.host;
70
- const { server, storage, dispatcher, mockEnabled } = startServerOrExit();
71
- setupGracefulShutdown(server, dispatcher, storage);
79
+ const { server, storage, dispatcher, mockEnabled, instanceId } = startServerOrExit();
80
+ setupGracefulShutdown(server, dispatcher, storage, instanceId);
72
81
 
73
82
  // Ensure a default mock endpoint exists for first-time onboarding (local simulation only)
74
83
  if (mockEnabled && !storage.getEndpoint('demo-stripe')) {
@@ -110,8 +119,8 @@ program
110
119
  if (host === '0.0.0.0') {
111
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.'));
112
121
  }
113
- const { server, storage, dispatcher } = startServerOrExit();
114
- setupGracefulShutdown(server, dispatcher, storage);
122
+ const { server, storage, dispatcher, instanceId } = startServerOrExit();
123
+ setupGracefulShutdown(server, dispatcher, storage, instanceId);
115
124
 
116
125
  storage.createEndpoint({
117
126
  id: options.endpoint,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hookarmor",
3
- "version": "1.2.0",
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
@@ -224,7 +224,11 @@ class Dispatcher extends EventEmitter {
224
224
  }
225
225
 
226
226
  pendingDeliveries() {
227
- return this.activeDeliveries;
227
+ let queued = 0;
228
+ for (const q of this.waitQueues.values()) {
229
+ queued += q.length;
230
+ }
231
+ return this.activeDeliveries + queued;
228
232
  }
229
233
 
230
234
  async drain(timeoutMs = 15000) {
@@ -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
@@ -127,6 +127,9 @@ function createServer(options = {}) {
127
127
  console.warn(`[HookArmor] Re-queued ${recovered} event(s) that were mid-delivery when the process last stopped.`);
128
128
  }
129
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
+
130
133
  // Security audit check: warn about endpoints without secrets on startup
131
134
  try {
132
135
  const initialEndpoints = storage.listEndpoints();
@@ -186,7 +189,7 @@ function createServer(options = {}) {
186
189
  socket.isAuthed = !apiKey || isDemoMode;
187
190
  let authTimer = null;
188
191
  if (!socket.isAuthed) {
189
- authTimer = setTimeout(() => { if (!socket.isAuthed) socket.close(4001, 'Authentication required'); }, 15000);
192
+ authTimer = setTimeout(() => { if (!socket.isAuthed) socket.close(4001, 'Authentication required'); }, 5000);
190
193
  if (authTimer.unref) authTimer.unref();
191
194
  }
192
195
  // Browsers cannot set headers on WebSocket upgrades, so the key arrives as the first message
@@ -568,7 +571,7 @@ function createServer(options = {}) {
568
571
  const mockAuthMiddleware = (req, res, next) => {
569
572
  if (apiKey) {
570
573
  const header = req.headers['authorization'] || '';
571
- const token = header.startsWith('Bearer ') ? header.slice(7) : (req.headers['x-api-key'] || req.query.key || '');
574
+ const token = header.startsWith('Bearer ') ? header.slice(7) : (req.headers['x-api-key'] || '');
572
575
  if (!tokenMatches(token)) {
573
576
  return res.status(401).json({ error: 'Unauthorized: valid API key required for /mock/config' });
574
577
  }
@@ -602,8 +605,24 @@ function createServer(options = {}) {
602
605
  });
603
606
  }
604
607
 
605
- // Prometheus Metrics Exposition Endpoint
608
+ // Prometheus Metrics Exposition Endpoint (Requires API key if configured; otherwise loopback only)
606
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
+
607
626
  try {
608
627
  const stats = storage.getStats();
609
628
  const endpoints = storage.listEndpoints();
@@ -642,6 +661,15 @@ function createServer(options = {}) {
642
661
  }
643
662
  });
644
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
+
645
673
  // SEO & Web Crawler Discovery
646
674
  app.get('/robots.txt', (req, res) => {
647
675
  res.type('text/plain');
package/src/storage.js CHANGED
@@ -429,11 +429,53 @@ class Storage {
429
429
  return info.changes > 0;
430
430
  }
431
431
 
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
+
432
474
  // Run at startup or worker tick:
433
475
  // Reclaim events if:
434
476
  // 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)
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).
437
479
  recoverInterruptedDeliveries() {
438
480
  const sql = `
439
481
  UPDATE events
@@ -449,7 +491,6 @@ class Storage {
449
491
  OR claimed_by NOT IN (
450
492
  SELECT id FROM instances WHERE datetime(last_heartbeat) >= datetime('now', '-45 seconds')
451
493
  )
452
- OR (claimed_at IS NOT NULL AND datetime(claimed_at) < datetime('now', '-180 seconds'))
453
494
  )
454
495
  `;
455
496
  return this.db.prepare(sql).run().changes;
@@ -482,6 +523,14 @@ class Storage {
482
523
  listWaitlist() {
483
524
  return this.db.prepare('SELECT * FROM waitlist ORDER BY created_at DESC').all();
484
525
  }
526
+
527
+ close() {
528
+ if (this.db) {
529
+ try {
530
+ this.db.close();
531
+ } catch (_) {}
532
+ }
533
+ }
485
534
  }
486
535
 
487
536
  Storage.toSqliteTime = toSqliteTime;