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 +4 -4
- package/bin/hookarmor.js +17 -8
- package/package.json +1 -1
- package/src/dispatcher.js +5 -1
- package/src/public/landing.html +25 -8
- package/src/server.js +31 -3
- package/src/storage.js +52 -3
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
|
|
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
|
|
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
|
|
38
|
+
if (storage) {
|
|
36
39
|
try {
|
|
37
|
-
|
|
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
package/src/dispatcher.js
CHANGED
|
@@ -224,7 +224,11 @@ class Dispatcher extends EventEmitter {
|
|
|
224
224
|
}
|
|
225
225
|
|
|
226
226
|
pendingDeliveries() {
|
|
227
|
-
|
|
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) {
|
package/src/public/landing.html
CHANGED
|
@@ -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>✓
|
|
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">
|
|
511
|
+
<div class="featured-badge">FOUNDER LOCK — $19/MO</div>
|
|
496
512
|
<h3>Starter Cloud</h3>
|
|
497
|
-
<div class="price">$
|
|
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>
|
|
516
|
+
<li>✓ <strong>100,000 events / month</strong></li>
|
|
501
517
|
<li>✓ <strong>30-day retention</strong> in DLQ</li>
|
|
502
|
-
<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;">
|
|
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>
|
|
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'); },
|
|
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'] ||
|
|
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)
|
|
436
|
-
//
|
|
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;
|