hookarmor 1.0.0 โ†’ 1.0.2

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
@@ -153,6 +153,63 @@ HookArmor solves this automatically:
153
153
 
154
154
  ---
155
155
 
156
+ ## ๐Ÿ›ก๏ธ Provenance Headers & Out-of-Order Replay Protection
157
+
158
+ When replaying dead-letter events hours or days later, applying payloads out of order can accidentally clobber newer database state (e.g., an older `customer.subscription.updated` event overwriting a newer cancellation).
159
+
160
+ To protect downstream handlers against state drift, HookArmor injects explicit provenance headers on every forward and replay:
161
+
162
+ * `x-hookarmor-delivery-id`: Unique HookArmor event delivery ID.
163
+ * `x-hookarmor-attempt`: Current delivery attempt number (e.g., `1` on initial, `2+` on retries).
164
+ * `x-hookarmor-original-timestamp`: The exact ISO-8601 timestamp when the webhook originally arrived at the ingress edge.
165
+ * `x-hookarmor-is-replay`: Set to `'true'` during automated retries and manual dashboard replays (`'false'` on initial delivery).
166
+ * `x-hookarmor-original-stripe-signature`: Preserves the authentic original Stripe signature for audit logs and forensic verification.
167
+
168
+ **Recommended Downstream Handler Pattern:**
169
+ ```javascript
170
+ app.post('/api/webhooks/stripe', express.raw({ type: 'application/json' }), (req, res) => {
171
+ // Standard verification passes 100% of the time
172
+ const event = stripe.webhooks.constructEvent(req.body, req.headers['stripe-signature'], endpointSecret);
173
+
174
+ // If this is a replay, verify you are not clobbering newer database state:
175
+ if (req.headers['x-hookarmor-is-replay'] === 'true') {
176
+ const originalTime = new Date(req.headers['x-hookarmor-original-timestamp']).getTime();
177
+ // Fetch latest object from Stripe API or check if local DB already has a newer updated_at timestamp
178
+ }
179
+
180
+ res.json({ received: true });
181
+ });
182
+ ```
183
+
184
+ ---
185
+
186
+ ## ๐Ÿ›๏ธ Dual Trust Boundaries: Transparent vs. Isolated Mode
187
+
188
+ HookArmor supports two forwarding architectures depending on your team's security posture:
189
+
190
+ 1. **Mode A: Transparent Proxy (Default ยท Zero Code Changes)**
191
+ * HookArmor re-signs the outbound request using your provider secret with `t=now`.
192
+ * Your existing application code (`stripe.webhooks.constructEvent()`) runs unmodified.
193
+ * Original signatures are preserved in `x-hookarmor-original-stripe-signature`.
194
+
195
+ 2. **Mode B: Isolated Trust Boundary (Enterprise Security)**
196
+ * Set the `HOOKARMOR_SIGNING_SECRET` environment variable.
197
+ * HookArmor attaches an isolated `x-hookarmor-signature: t=..., v1=...` HMAC header using your internal secret.
198
+ * Downstream handlers verify HookArmor as the internal sender, maintaining strict separation between external Stripe signatures and internal relay deliveries.
199
+
200
+ ---
201
+
202
+ ## โš ๏ธ Production Durability: The Early 200 OK Tradeoff
203
+
204
+ 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.
205
+
206
+ **What this means for production operators:**
207
+ * Once HookArmor returns `200 OK`, Stripe considers the webhook delivered and **halts its external retry backup**.
208
+ * HookArmor's embedded SQLite database assumes **sole custody** of that event.
209
+ * **Persistent storage is mandatory:** When deploying via Docker, you must mount the data volume (`-v $(pwd)/data:/app/data`) and ensure host-level backups are active.
210
+
211
+ ---
212
+
156
213
  ## ๐Ÿ“ฆ Hosted Cloud & Self-Hosting
157
214
 
158
215
  | Feature | Self-Hosted (MIT) | Hosted Cloud Starter ($29/mo) | Hosted Cloud Pro ($79/mo) |
@@ -169,5 +226,27 @@ HookArmor solves this automatically:
169
226
 
170
227
  ---
171
228
 
229
+ ## ๐Ÿ” Production Security & Admin Authentication
230
+
231
+ When running HookArmor locally on your laptop (`localhost`), management endpoints and the replay dashboard operate without authentication for fast developer onboarding.
232
+
233
+ > [!WARNING]
234
+ > **When deploying HookArmor to a public server or self-hosted cloud container (Railway, Render, VPS), you MUST set the `HOOKARMOR_API_KEY` environment variable.**
235
+
236
+ ```bash
237
+ # Generate a strong 256-bit random key
238
+ openssl rand -hex 32
239
+
240
+ # Set in your production environment / Docker container:
241
+ HOOKARMOR_API_KEY=ha_sec_your_secure_random_key_here
242
+ ```
243
+
244
+ When `HOOKARMOR_API_KEY` is present:
245
+ * Management endpoints (`/api/endpoints`, `/api/events/replay-all`, and waitlist exports) strictly reject unauthenticated requests and require an `Authorization: Bearer <key>` header.
246
+ * Query parameter authentication (`?api_key=...`) is strictly prohibited to avoid leaking tokens into browser history and proxy access logs.
247
+ * The web dashboard displays an **Admin Login** prompt storing your key only in ephemeral session memory.
248
+
249
+ ---
250
+
172
251
  ## ๐Ÿ“„ License
173
252
  MIT License. Created by the HookArmor Team.
package/bin/hookarmor.js CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  const { Command } = require('commander');
4
- const chalk = require('chalk');
4
+ const _chalk = require('chalk');
5
+ const chalk = _chalk.default || _chalk;
5
6
  const { createServer } = require('../src/server');
6
7
  const http = require('http');
7
8
 
@@ -34,7 +35,7 @@ program
34
35
  }
35
36
 
36
37
  server.listen(port, () => {
37
- console.log(chalk.bold.blue('\n๐Ÿ›ก๏ธ HookArmor Webhook Sentinel is LIVE!'));
38
+ console.log(chalk.blue.bold('\n๐Ÿ›ก๏ธ HookArmor Webhook Sentinel is LIVE!'));
38
39
  console.log(chalk.gray('โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€'));
39
40
  console.log(`๐Ÿ“ก Ingress Gateway : ${chalk.cyan(`http://localhost:${port}/in/:endpointId`)}`);
40
41
  console.log(`๐Ÿ“Š Web Dashboard : ${chalk.green.bold(`http://localhost:${port}`)}`);
@@ -73,7 +74,7 @@ program
73
74
  });
74
75
 
75
76
  server.listen(port, () => {
76
- console.log(chalk.bold.cyan(`\nโšก HookArmor Listen Active`));
77
+ console.log(chalk.cyan.bold(`\nโšก HookArmor Listen Active`));
77
78
  console.log(`Forwarding: ${chalk.yellow(`http://localhost:${port}/in/${options.endpoint}`)} โž” ${chalk.green(targetUrl)}`);
78
79
  console.log(`Dashboard: ${chalk.cyan(`http://localhost:${port}`)}\n`);
79
80
  });
@@ -117,7 +118,7 @@ program
117
118
  try {
118
119
  const res = await fetch(`${baseUrl}/api/stats`);
119
120
  const data = await res.json();
120
- console.log(chalk.bold.blue('\n๐Ÿ›ก๏ธ HookArmor Metrics'));
121
+ console.log(chalk.blue.bold('\n๐Ÿ›ก๏ธ HookArmor Metrics'));
121
122
  console.log(chalk.gray('โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€'));
122
123
  console.log(`Total Ingested : ${chalk.cyan(data.total)}`);
123
124
  console.log(`Delivered : ${chalk.green(data.delivered)}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hookarmor",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
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": {
@@ -18,7 +18,7 @@
18
18
  },
19
19
  "repository": {
20
20
  "type": "git",
21
- "url": "https://github.com/pkdoddamani/hookarmor.git"
21
+ "url": "git+https://github.com/pkdoddamani/hookarmor.git"
22
22
  },
23
23
  "homepage": "https://hookarmour.dev",
24
24
  "bugs": {
package/src/dispatcher.js CHANGED
@@ -164,11 +164,27 @@ class Dispatcher extends EventEmitter {
164
164
 
165
165
  let headers = { ...event.headers };
166
166
 
167
- // Re-sign outbound headers if endpoint secret is configured
167
+ // Preserve original raw signatures before re-signing for audit and forensic trace
168
+ if (event.headers && event.headers['stripe-signature']) {
169
+ headers['x-hookarmor-original-stripe-signature'] = event.headers['stripe-signature'];
170
+ }
171
+ if (event.headers && event.headers['x-shopify-hmac-sha256']) {
172
+ headers['x-hookarmor-original-shopify-hmac'] = event.headers['x-shopify-hmac-sha256'];
173
+ }
174
+
175
+ // Re-sign outbound headers if endpoint secret is configured (Transparent Zero-Code-Change Mode)
168
176
  if (endpoint.secret) {
169
177
  headers = this.signHeaders(event.provider, event.raw_body, headers, endpoint.secret);
170
178
  }
171
179
 
180
+ // Optional Isolated Trust Domain mode: if HOOKARMOR_SIGNING_SECRET is provided
181
+ const internalSecret = process.env.HOOKARMOR_SIGNING_SECRET || endpoint.internal_secret;
182
+ if (internalSecret) {
183
+ const freshTs = Math.floor(Date.now() / 1000);
184
+ const internalSig = crypto.createHmac('sha256', internalSecret).update(`${freshTs}.${event.raw_body}`).digest('hex');
185
+ headers['x-hookarmor-signature'] = `t=${freshTs},v1=${internalSig}`;
186
+ }
187
+
172
188
  // Remove hop-by-hop and encoding headers
173
189
  delete headers['host'];
174
190
  delete headers['connection'];
@@ -180,6 +196,9 @@ class Dispatcher extends EventEmitter {
180
196
  headers['content-length'] = rawBuffer.length;
181
197
  headers['x-hookarmor-delivery-id'] = event.id;
182
198
  headers['x-hookarmor-attempt'] = String(event.attempts + 1);
199
+ headers['x-hookarmor-original-timestamp'] = event.created_at;
200
+ const isReplay = (event.attempts > 0 || event.status === 'replaying');
201
+ headers['x-hookarmor-is-replay'] = isReplay ? 'true' : 'false';
183
202
 
184
203
  return new Promise((resolve) => {
185
204
  try {
@@ -371,13 +371,13 @@
371
371
  </div>
372
372
  </div>
373
373
 
374
- <div class="terminal-box" style="flex-direction: column; align-items: flex-start; gap: 0.5rem; width: 100%; max-width: 620px;">
374
+ <div class="terminal-box" style="flex-direction: column; align-items: flex-start; gap: 0.5rem; width: 100%; max-width: 520px;">
375
375
  <div style="display: flex; justify-content: space-between; width: 100%; align-items: center;">
376
- <span style="font-size: 0.75rem; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; font-family: var(--font-sans); font-weight: 600;">Self-Host Quickstart (Zero Dependencies)</span>
377
- <button class="copy-btn" onclick="navigator.clipboard.writeText('git clone https://github.com/pkdoddamani/hookarmor.git && cd hookarmor && npm install && npm start')">Copy</button>
376
+ <span style="font-size: 0.75rem; color: #10b981; text-transform: uppercase; letter-spacing: 0.05em; font-family: var(--font-sans); font-weight: 700;">โ— Verified on npm (Zero Config)</span>
377
+ <button class="copy-btn" onclick="navigator.clipboard.writeText('npx hookarmor start')">Copy</button>
378
378
  </div>
379
- <div style="color: #38bdf8; font-size: 0.85rem; word-break: break-all;">
380
- $ git clone https://github.com/pkdoddamani/hookarmor.git && cd hookarmor && npm start
379
+ <div style="color: #38bdf8; font-size: 1.05rem; font-weight: 600;">
380
+ $ npx hookarmor start
381
381
  </div>
382
382
  </div>
383
383
 
package/src/server.js CHANGED
@@ -322,39 +322,44 @@ function createServer(options = {}) {
322
322
  // SEO & Web Crawler Discovery
323
323
  app.get('/robots.txt', (req, res) => {
324
324
  res.type('text/plain');
325
- res.sendFile(path.join(__dirname, 'public', 'robots.txt'));
325
+ res.sendFile('robots.txt', { root: path.join(__dirname, 'public') });
326
326
  });
327
327
 
328
328
  app.get('/sitemap.xml', (req, res) => {
329
329
  res.type('application/xml');
330
- res.sendFile(path.join(__dirname, 'public', 'sitemap.xml'));
330
+ res.sendFile('sitemap.xml', { root: path.join(__dirname, 'public') });
331
331
  });
332
332
 
333
333
  app.get('/og-preview.png', (req, res) => {
334
334
  res.type('image/png');
335
- res.sendFile(path.join(__dirname, 'public', 'og-preview.png'));
335
+ res.sendFile('og-preview.png', { root: path.join(__dirname, 'public') });
336
336
  });
337
337
 
338
- // Landing Page Route
338
+ // Root Route: Dashboard if run locally via CLI; Landing page on cloud deployment
339
339
  app.get('/', (req, res) => {
340
- res.sendFile(path.join(__dirname, 'public', 'landing.html'));
340
+ const isCloudProduction = Boolean(process.env.RAILWAY_ENVIRONMENT || process.env.RENDER || process.env.NODE_ENV === 'production');
341
+ if (!isCloudProduction) {
342
+ return res.sendFile('index.html', { root: path.join(__dirname, 'public') });
343
+ }
344
+ res.sendFile('landing.html', { root: path.join(__dirname, 'public') });
341
345
  });
342
346
 
343
347
  // Interactive Dashboard Routes
344
348
  app.get(['/dashboard', '/app'], (req, res) => {
345
- res.sendFile(path.join(__dirname, 'public', 'index.html'));
349
+ res.sendFile('index.html', { root: path.join(__dirname, 'public') });
346
350
  });
347
351
 
348
352
  // Engineering Blog Routes
349
353
  app.get('/blog', (req, res) => {
350
- res.sendFile(path.join(__dirname, 'public', 'blog', 'index.html'));
354
+ res.sendFile('index.html', { root: path.join(__dirname, 'public', 'blog') });
351
355
  });
352
356
 
353
357
  app.get('/blog/:slug', (req, res) => {
354
358
  const slug = req.params.slug.replace(/[^a-zA-Z0-9-_]/g, '');
355
- const filePath = path.join(__dirname, 'public', 'blog', `${slug}.html`);
359
+ const fileName = `${slug}.html`;
360
+ const filePath = path.join(__dirname, 'public', 'blog', fileName);
356
361
  if (fs.existsSync(filePath)) {
357
- return res.sendFile(filePath);
362
+ return res.sendFile(fileName, { root: path.join(__dirname, 'public', 'blog') });
358
363
  }
359
364
  res.redirect('/blog');
360
365
  });