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