hookarmor 1.0.3 → 1.1.0

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/src/server.js CHANGED
@@ -1,393 +1,544 @@
1
- const express = require('express');
2
- const cors = require('cors');
3
- const { randomUUID } = require('crypto');
4
- const path = require('path');
5
- const fs = require('fs');
6
- const http = require('http');
7
- const { WebSocketServer, WebSocket } = require('ws');
8
-
9
- const Storage = require('./storage');
10
- const Dispatcher = require('./dispatcher');
11
- const RetryWorker = require('./worker');
12
-
13
- function isPrivateOrMetadataUrl(urlString, strictSSRF = false) {
14
- try {
15
- const parsed = new URL(urlString);
16
- const hostname = parsed.hostname.toLowerCase();
17
- if (
18
- hostname === '169.254.169.254' ||
19
- hostname === 'metadata.google.internal' ||
20
- hostname === '100.100.100.200' ||
21
- hostname.startsWith('169.254.')
22
- ) {
23
- return true;
24
- }
25
- if (strictSSRF) {
26
- if (
27
- hostname === 'localhost' ||
28
- hostname === '127.0.0.1' ||
29
- hostname === '::1' ||
30
- hostname === '0.0.0.0' ||
31
- hostname.startsWith('10.') ||
32
- hostname.startsWith('192.168.') ||
33
- /^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(hostname)
34
- ) {
35
- return true;
36
- }
37
- }
38
- return false;
39
- } catch (e) {
40
- return true;
41
- }
42
- }
43
-
44
- function createServer(options = {}) {
45
- const app = express();
46
- const server = http.createServer(app);
47
- const wss = new WebSocketServer({ server, path: '/ws' });
48
-
49
- const storage = new Storage(options.dbPath);
50
- const dispatcher = new Dispatcher(storage, {
51
- defaultConcurrency: options.defaultConcurrency || 5,
52
- defaultTimeoutMs: options.defaultTimeoutMs || 25000
53
- });
54
-
55
- const worker = new RetryWorker(storage, dispatcher, {
56
- intervalMs: options.retryIntervalMs || 5000
57
- });
58
- if (options.autoStartWorker !== false) {
59
- worker.start();
60
- }
61
-
62
- server.on('close', () => {
63
- worker.stop();
64
- });
65
-
66
- // Broadcast helper for real-time WebSocket dashboard
67
- function broadcast(type, data) {
68
- const payload = JSON.stringify({ type, data });
69
- wss.clients.forEach(client => {
70
- if (client.readyState === WebSocket.OPEN) {
71
- client.send(payload);
72
- }
73
- });
74
- }
75
-
76
- // Hook dispatcher events to WebSocket broadcasts
77
- dispatcher.on('delivery', (res) => {
78
- broadcast('delivery', res);
79
- });
80
-
81
- // Enable CORS
82
- app.use(cors());
83
-
84
- // Serve static UI assets
85
- app.use('/static', express.static(path.join(__dirname, 'public')));
86
-
87
- // Ingress endpoint for webhooks - uses raw body parser to preserve raw bytes
88
- app.post('/in/:endpointId', express.raw({ type: '*/*', limit: '10mb' }), async (req, res) => {
89
- const endpointId = req.params.endpointId;
90
- const endpoint = storage.getEndpoint(endpointId);
91
-
92
- if (!endpoint) {
93
- return res.status(404).json({ error: `HookArmor endpoint not found: ${endpointId}` });
94
- }
95
-
96
- const rawBodyBuffer = req.body || Buffer.from('');
97
- const rawBody = rawBodyBuffer.toString('utf8');
98
- const headers = { ...req.headers };
99
-
100
- // Detect provider & idempotency key
101
- let provider = 'generic';
102
- let eventType = null;
103
- let idempotencyKey = headers['idempotency-key'] || headers['x-idempotency-key'] || null;
104
-
105
- if (headers['stripe-signature']) {
106
- provider = 'stripe';
107
- try {
108
- const parsed = JSON.parse(rawBody);
109
- eventType = parsed.type;
110
- if (parsed.id) idempotencyKey = parsed.id;
111
- } catch (e) {}
112
- } else if (headers['x-shopify-hmac-sha256'] || headers['x-shopify-topic']) {
113
- provider = 'shopify';
114
- eventType = headers['x-shopify-topic'];
115
- if (headers['x-shopify-webhook-id']) idempotencyKey = headers['x-shopify-webhook-id'];
116
- } else if (headers['x-github-event']) {
117
- provider = 'github';
118
- eventType = headers['x-github-event'];
119
- if (headers['x-github-delivery']) idempotencyKey = headers['x-github-delivery'];
120
- } else if (headers['clerk-signature'] || headers['svix-id']) {
121
- provider = 'clerk/svix';
122
- if (headers['svix-id']) idempotencyKey = headers['svix-id'];
123
- try {
124
- const parsed = JSON.parse(rawBody);
125
- eventType = parsed.type;
126
- } catch (e) {}
127
- } else {
128
- try {
129
- const parsed = JSON.parse(rawBody);
130
- eventType = parsed.event || parsed.type || parsed.action || null;
131
- if (parsed.id) idempotencyKey = String(parsed.id);
132
- } catch (e) {}
133
- }
134
-
135
- // 0. Verify signature on ingress if endpoint secret is configured
136
- if (endpoint.secret) {
137
- const verification = Dispatcher.verifyIngressSignature(provider, rawBody, headers, endpoint.secret);
138
- if (!verification.valid) {
139
- return res.status(400).json({
140
- error: 'Webhook signature verification failed',
141
- provider,
142
- reason: verification.reason
143
- });
144
- }
145
- }
146
-
147
- // Deduplication check: if this event ID was already delivered, ack immediately without duplicating downstream side effects
148
- if (idempotencyKey) {
149
- const existing = storage.findEventByIdempotencyKey(endpoint.id, idempotencyKey);
150
- if (existing && existing.status === 'delivered') {
151
- return res.status(200).json({
152
- received: true,
153
- hookarmor_id: existing.id,
154
- status: 'deduplicated',
155
- message: `Idempotency key ${idempotencyKey} already delivered. Suppressed duplicate execution.`
156
- });
157
- }
158
- }
159
-
160
- const eventId = `evt_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
161
-
162
- // 1. Durably save event in SQLite
163
- const savedEvent = storage.saveEvent({
164
- id: eventId,
165
- endpointId: endpoint.id,
166
- idempotencyKey,
167
- provider,
168
- eventType,
169
- headers,
170
- rawBody,
171
- status: 'pending'
172
- });
173
-
174
- broadcast('event:received', {
175
- id: eventId,
176
- endpointId: endpoint.id,
177
- provider,
178
- eventType,
179
- createdAt: savedEvent.created_at
180
- });
181
-
182
- // 2. Respond immediately with 200 OK to the sender so Stripe never times out or backs off
183
- res.status(200).json({
184
- received: true,
185
- hookarmor_id: eventId,
186
- status: 'buffered'
187
- });
188
-
189
- // 3. Dispatch to target destination asynchronously
190
- setImmediate(async () => {
191
- try {
192
- await dispatcher.dispatch(savedEvent, endpoint);
193
- } catch (err) {
194
- console.error(`[Ingress] Dispatch error for ${eventId}:`, err);
195
- }
196
- });
197
- });
198
-
199
- // REST API: JSON Body Parser for Dashboard / Management
200
- app.use(express.json());
201
-
202
- // Management API Authentication Middleware (active when HOOKARMOR_API_KEY is configured)
203
- const apiKey = options.apiKey || process.env.HOOKARMOR_API_KEY || null;
204
- const isDemoMode = options.demoMode !== undefined
205
- ? options.demoMode
206
- : (process.env.HOOKARMOR_DEMO_MODE === 'true');
207
- const strictSSRF = options.strictSSRF !== undefined
208
- ? options.strictSSRF
209
- : (process.env.HOOKARMOR_STRICT_SSRF === 'true');
210
-
211
- app.use('/api', (req, res, next) => {
212
- // Keep public waitlist signup open for landing page
213
- if (req.path === '/waitlist' && req.method === 'POST') return next();
214
-
215
- // If apiKey is NOT configured and demo mode is NOT forced, allow open local dev access
216
- if (!apiKey && !isDemoMode) return next();
217
-
218
- // If explicit demo mode is active (e.g. public marketing demo sandbox), allow read-only & replay simulation
219
- if (isDemoMode) {
220
- const isPublicDemo =
221
- (req.method === 'GET' && (req.path === '/stats' || req.path === '/endpoints' || req.path === '/events' || /^\/events\/[^\/]+$/.test(req.path))) ||
222
- (req.method === 'POST' && (req.path === '/events/replay-all' || /^\/events\/[^\/]+\/replay$/.test(req.path)));
223
-
224
- if (isPublicDemo) return next();
225
- }
226
-
227
- // When apiKey is configured (production mode), strictly require authentication
228
- const authHeader = req.headers['authorization'] || '';
229
- const token = authHeader.replace(/^Bearer\s+/i, '').trim() || req.headers['x-api-key'] || '';
230
- if (!token || token !== apiKey) {
231
- return res.status(401).json({ error: 'Unauthorized: valid HookArmor API key required in header' });
232
- }
233
- next();
234
- });
235
-
236
- // Get Stats
237
- app.get('/api/stats', (req, res) => {
238
- res.json(storage.getStats());
239
- });
240
-
241
- // List Endpoints
242
- app.get('/api/endpoints', (req, res) => {
243
- res.json(storage.listEndpoints());
244
- });
245
-
246
- // Create or Update Endpoint
247
- app.post('/api/endpoints', (req, res) => {
248
- const { id, name, targetUrl, secret, alertWebhookUrl, autoRetry, maxRetries, concurrencyLimit, concurrency_limit } = req.body;
249
- if (!id || !name || !targetUrl) {
250
- return res.status(400).json({ error: 'id, name, and targetUrl are required' });
251
- }
252
- if (isPrivateOrMetadataUrl(targetUrl, strictSSRF)) {
253
- return res.status(400).json({ error: 'targetUrl cannot target cloud metadata or private network addresses (SSRF blocked)' });
254
- }
255
- const endpoint = storage.createEndpoint({
256
- id,
257
- name,
258
- targetUrl,
259
- secret,
260
- alertWebhookUrl,
261
- autoRetry: autoRetry !== undefined ? autoRetry : 1,
262
- maxRetries: maxRetries || 5,
263
- concurrencyLimit: concurrencyLimit || concurrency_limit || 5
264
- });
265
- res.json(endpoint);
266
- });
267
-
268
- // List Events
269
- app.get('/api/events', (req, res) => {
270
- const { endpointId, status, limit, offset } = req.query;
271
- const events = storage.listEvents({
272
- endpointId,
273
- status,
274
- limit: parseInt(limit, 10) || 50,
275
- offset: parseInt(offset, 10) || 0
276
- });
277
- res.json(events);
278
- });
279
-
280
- // Get single event with attempts
281
- app.get('/api/events/:id', (req, res) => {
282
- const event = storage.getEvent(req.params.id);
283
- if (!event) return res.status(404).json({ error: 'Event not found' });
284
- const attempts = storage.getAttempts(req.params.id);
285
- res.json({ ...event, attempts });
286
- });
287
-
288
- // Replay a specific event
289
- app.post('/api/events/:id/replay', async (req, res) => {
290
- try {
291
- const result = await dispatcher.replayEvent(req.params.id);
292
- res.json(result);
293
- } catch (err) {
294
- res.status(500).json({ error: err.message });
295
- }
296
- });
297
-
298
- // Replay all failed events (optionally filtered by endpoint)
299
- app.post('/api/events/replay-all', async (req, res) => {
300
- try {
301
- const { endpointId } = req.body || {};
302
- const results = await dispatcher.replayAllFailed(endpointId);
303
- res.json({ replayedCount: results.length, results });
304
- } catch (err) {
305
- res.status(500).json({ error: err.message });
306
- }
307
- });
308
-
309
- // Waitlist Registration
310
- app.post('/api/waitlist', (req, res) => {
311
- const { email, source } = req.body || {};
312
- if (!email || !email.includes('@')) {
313
- return res.status(400).json({ error: 'Valid email required' });
314
- }
315
- const result = storage.addWaitlist(email.trim().toLowerCase(), source || 'landing_page');
316
- res.json({ success: true, message: 'Added to HookArmor Cloud beta waitlist!', email: result.email });
317
- });
318
-
319
- app.get('/api/waitlist', (req, res) => {
320
- res.json(storage.listWaitlist());
321
- });
322
-
323
- // Mock Target Receiver (for self-testing and local simulation)
324
- let mockTargetBehavior = {
325
- statusCode: 200,
326
- delayMs: 15,
327
- responseBody: { status: 'mock_processed' }
328
- };
329
-
330
- app.post('/mock/target', (req, res) => {
331
- setTimeout(() => {
332
- res.status(mockTargetBehavior.statusCode).json(mockTargetBehavior.responseBody);
333
- }, mockTargetBehavior.delayMs);
334
- });
335
-
336
- app.post('/mock/config', (req, res) => {
337
- mockTargetBehavior = { ...mockTargetBehavior, ...req.body };
338
- res.json({ message: 'Mock target configuration updated', config: mockTargetBehavior });
339
- });
340
-
341
- app.get('/mock/config', (req, res) => {
342
- res.json(mockTargetBehavior);
343
- });
344
-
345
- // SEO & Web Crawler Discovery
346
- app.get('/robots.txt', (req, res) => {
347
- res.type('text/plain');
348
- res.sendFile('robots.txt', { root: path.join(__dirname, 'public') });
349
- });
350
-
351
- app.get('/sitemap.xml', (req, res) => {
352
- res.type('application/xml');
353
- res.sendFile('sitemap.xml', { root: path.join(__dirname, 'public') });
354
- });
355
-
356
- app.get('/og-preview.png', (req, res) => {
357
- res.type('image/png');
358
- res.sendFile('og-preview.png', { root: path.join(__dirname, 'public') });
359
- });
360
-
361
- // Root Route: Dashboard if run locally via CLI; Landing page on cloud deployment
362
- app.get('/', (req, res) => {
363
- const isCloudProduction = Boolean(process.env.RAILWAY_ENVIRONMENT || process.env.RENDER || process.env.NODE_ENV === 'production');
364
- if (!isCloudProduction) {
365
- return res.sendFile('index.html', { root: path.join(__dirname, 'public') });
366
- }
367
- res.sendFile('landing.html', { root: path.join(__dirname, 'public') });
368
- });
369
-
370
- // Interactive Dashboard Routes
371
- app.get(['/dashboard', '/app'], (req, res) => {
372
- res.sendFile('index.html', { root: path.join(__dirname, 'public') });
373
- });
374
-
375
- // Engineering Blog Routes
376
- app.get('/blog', (req, res) => {
377
- res.sendFile('index.html', { root: path.join(__dirname, 'public', 'blog') });
378
- });
379
-
380
- app.get('/blog/:slug', (req, res) => {
381
- const slug = req.params.slug.replace(/[^a-zA-Z0-9-_]/g, '');
382
- const fileName = `${slug}.html`;
383
- const filePath = path.join(__dirname, 'public', 'blog', fileName);
384
- if (fs.existsSync(filePath)) {
385
- return res.sendFile(fileName, { root: path.join(__dirname, 'public', 'blog') });
386
- }
387
- res.redirect('/blog');
388
- });
389
-
390
- return { app, server, storage, dispatcher, worker };
391
- }
392
-
393
- module.exports = { createServer };
1
+ const express = require('express');
2
+ const cors = require('cors');
3
+ const crypto = require('crypto');
4
+ const path = require('path');
5
+ const fs = require('fs');
6
+ const http = require('http');
7
+ const { WebSocketServer, WebSocket } = require('ws');
8
+
9
+ const Storage = require('./storage');
10
+ const Dispatcher = require('./dispatcher');
11
+ const RetryWorker = require('./worker');
12
+ const { checkUrl } = require('./netguard');
13
+
14
+ const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
15
+ const ENDPOINT_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
16
+
17
+ function envFlag(name) {
18
+ const v = process.env[name];
19
+ if (v === undefined || v === '') return undefined;
20
+ return v === 'true' || v === '1';
21
+ }
22
+
23
+ function hostnameOf(hostHeader = '') {
24
+ const h = String(hostHeader).toLowerCase();
25
+ if (h.startsWith('[')) return h.slice(0, h.indexOf(']') + 1);
26
+ return h.split(':')[0];
27
+ }
28
+
29
+ function clampInt(value, fallback, min, max) {
30
+ const n = parseInt(value, 10);
31
+ if (!Number.isFinite(n)) return fallback;
32
+ return Math.min(Math.max(n, min), max);
33
+ }
34
+
35
+ // Fixed-window in-memory rate limiter keyed by client IP
36
+ function createRateLimiter({ limit, windowMs }) {
37
+ const hits = new Map();
38
+ return (key) => {
39
+ const now = Date.now();
40
+ if (hits.size > 10000) {
41
+ for (const [k, v] of hits) if (v.resetAt <= now) hits.delete(k);
42
+ }
43
+ const entry = hits.get(key);
44
+ if (!entry || entry.resetAt <= now) {
45
+ hits.set(key, { count: 1, resetAt: now + windowMs });
46
+ return true;
47
+ }
48
+ entry.count++;
49
+ return entry.count <= limit;
50
+ };
51
+ }
52
+
53
+ function publicEndpoint(ep) {
54
+ if (!ep) return ep;
55
+ const { secret, ...rest } = ep;
56
+ return { ...rest, has_secret: Boolean(secret) };
57
+ }
58
+
59
+ function createServer(options = {}) {
60
+ const isProduction = options.production !== undefined
61
+ ? options.production
62
+ : Boolean(process.env.NODE_ENV === 'production' || process.env.RAILWAY_ENVIRONMENT || process.env.RENDER);
63
+ const apiKey = options.apiKey || process.env.HOOKARMOR_API_KEY || null;
64
+ const isDemoMode = options.demoMode !== undefined
65
+ ? options.demoMode
66
+ : (process.env.HOOKARMOR_DEMO_MODE === 'true');
67
+ const allowNoAuth = options.allowNoAuth !== undefined ? options.allowNoAuth : envFlag('HOOKARMOR_ALLOW_NO_AUTH') === true;
68
+ // Strict outbound address checks default on in production (block loopback/private networks)
69
+ const strictSSRF = options.strictSSRF !== undefined
70
+ ? options.strictSSRF
71
+ : (envFlag('HOOKARMOR_STRICT_SSRF') !== undefined ? envFlag('HOOKARMOR_STRICT_SSRF') : isProduction);
72
+ const mockEnabled = options.enableMock !== undefined
73
+ ? options.enableMock
74
+ : (envFlag('HOOKARMOR_ENABLE_MOCK') !== undefined ? envFlag('HOOKARMOR_ENABLE_MOCK') : !isProduction);
75
+ const corsOrigins = options.corsOrigins || (process.env.HOOKARMOR_CORS_ORIGINS
76
+ ? process.env.HOOKARMOR_CORS_ORIGINS.split(',').map((s) => s.trim()).filter(Boolean)
77
+ : []);
78
+ const maxBody = options.maxBodySize || process.env.HOOKARMOR_MAX_BODY || '10mb';
79
+ const retentionDays = options.retentionDays !== undefined
80
+ ? options.retentionDays
81
+ : clampInt(process.env.HOOKARMOR_RETENTION_DAYS, 0, 0, 3650);
82
+
83
+ if (isProduction && !apiKey && !allowNoAuth) {
84
+ throw new Error(
85
+ 'HOOKARMOR_API_KEY is required in production: without it the management API (events, endpoints, replays) is open to anyone. ' +
86
+ 'Generate one with `openssl rand -hex 32`. Set HOOKARMOR_ALLOW_NO_AUTH=true only if the instance is not reachable from untrusted networks.'
87
+ );
88
+ }
89
+
90
+ const app = express();
91
+ const server = http.createServer(app);
92
+
93
+ const trustProxy = process.env.HOOKARMOR_TRUST_PROXY || ((process.env.RAILWAY_ENVIRONMENT || process.env.RENDER) ? '1' : null);
94
+ if (trustProxy) app.set('trust proxy', /^\d+$/.test(trustProxy) ? parseInt(trustProxy, 10) : trustProxy);
95
+
96
+ const storage = new Storage(options.dbPath);
97
+ const recovered = storage.recoverInterruptedDeliveries();
98
+ if (recovered > 0) {
99
+ console.warn(`[HookArmor] Re-queued ${recovered} event(s) that were mid-delivery when the process last stopped.`);
100
+ }
101
+
102
+ const dispatcher = new Dispatcher(storage, {
103
+ defaultConcurrency: options.defaultConcurrency || 5,
104
+ defaultTimeoutMs: options.defaultTimeoutMs || 25000,
105
+ strictSSRF,
106
+ signingSecret: options.signingSecret || process.env.HOOKARMOR_SIGNING_SECRET || null
107
+ });
108
+
109
+ const worker = new RetryWorker(storage, dispatcher, {
110
+ intervalMs: options.retryIntervalMs || 5000,
111
+ retentionDays
112
+ });
113
+ if (options.autoStartWorker !== false) {
114
+ worker.start();
115
+ }
116
+
117
+ server.on('close', () => {
118
+ worker.stop();
119
+ });
120
+
121
+ function tokenMatches(token) {
122
+ if (!apiKey || !token) return false;
123
+ // Hash both sides so the comparison is constant-time regardless of length
124
+ const a = crypto.createHash('sha256').update(String(token)).digest();
125
+ const b = crypto.createHash('sha256').update(String(apiKey)).digest();
126
+ return crypto.timingSafeEqual(a, b);
127
+ }
128
+
129
+ const authFailureLimiter = createRateLimiter({ limit: options.authFailureLimit || 30, windowMs: 10 * 60 * 1000 });
130
+ const waitlistLimiter = createRateLimiter({ limit: options.waitlistRateLimit || 5, windowMs: 10 * 60 * 1000 });
131
+
132
+ // ---------------------------------------------------------------- live WebSocket feed
133
+ const wss = new WebSocketServer({
134
+ server,
135
+ path: '/ws',
136
+ maxPayload: 4096,
137
+ // Without an API key the feed is only served to localhost (blocks DNS-rebinding pages)
138
+ verifyClient: (info) => Boolean(apiKey) || LOCAL_HOSTNAMES.has(hostnameOf(info.req.headers.host))
139
+ });
140
+
141
+ wss.on('connection', (socket) => {
142
+ socket.isAuthed = !apiKey || isDemoMode;
143
+ let authTimer = null;
144
+ if (!socket.isAuthed) {
145
+ authTimer = setTimeout(() => { if (!socket.isAuthed) socket.close(4001, 'Authentication required'); }, 15000);
146
+ if (authTimer.unref) authTimer.unref();
147
+ }
148
+ // Browsers cannot set headers on WebSocket upgrades, so the key arrives as the first message
149
+ socket.on('message', (raw) => {
150
+ try {
151
+ const msg = JSON.parse(String(raw));
152
+ if (msg.type === 'auth') {
153
+ if (tokenMatches(msg.token)) {
154
+ socket.isAuthed = true;
155
+ if (authTimer) clearTimeout(authTimer);
156
+ socket.send(JSON.stringify({ type: 'auth:ok' }));
157
+ } else {
158
+ socket.close(4001, 'Invalid API key');
159
+ }
160
+ }
161
+ } catch (e) {}
162
+ });
163
+ });
164
+
165
+ function broadcast(type, data) {
166
+ const payload = JSON.stringify({ type, data });
167
+ wss.clients.forEach(client => {
168
+ if (client.readyState === WebSocket.OPEN && client.isAuthed) {
169
+ client.send(payload);
170
+ }
171
+ });
172
+ }
173
+
174
+ // Hook dispatcher events to WebSocket broadcasts
175
+ dispatcher.on('delivery', (res) => {
176
+ broadcast('delivery', res);
177
+ });
178
+
179
+ // CORS is opt-in: the dashboard and landing page are same-origin and need none
180
+ if (corsOrigins.length > 0) {
181
+ app.use('/api', cors({ origin: corsOrigins }));
182
+ }
183
+
184
+ // Serve static UI assets
185
+ app.use('/static', express.static(path.join(__dirname, 'public')));
186
+
187
+ // Ingress endpoint for webhooks - uses raw body parser to preserve raw bytes
188
+ app.post('/in/:endpointId', express.raw({ type: '*/*', limit: maxBody }), (req, res) => {
189
+ const endpointId = req.params.endpointId;
190
+ const endpoint = storage.getEndpoint(endpointId);
191
+
192
+ if (!endpoint) {
193
+ return res.status(404).json({ error: 'HookArmor endpoint not found' });
194
+ }
195
+
196
+ const rawBodyBuffer = Buffer.isBuffer(req.body) ? req.body : Buffer.from('');
197
+ const rawBody = rawBodyBuffer.toString('utf8');
198
+ const headers = { ...req.headers };
199
+
200
+ // Detect provider & idempotency key
201
+ let provider = 'generic';
202
+ let eventType = null;
203
+ let idempotencyKey = headers['idempotency-key'] || headers['x-idempotency-key'] || null;
204
+
205
+ if (headers['stripe-signature']) {
206
+ provider = 'stripe';
207
+ try {
208
+ const parsed = JSON.parse(rawBody);
209
+ eventType = parsed.type;
210
+ if (parsed.id) idempotencyKey = parsed.id;
211
+ } catch (e) {}
212
+ } else if (headers['x-shopify-hmac-sha256'] || headers['x-shopify-topic']) {
213
+ provider = 'shopify';
214
+ eventType = headers['x-shopify-topic'];
215
+ if (headers['x-shopify-webhook-id']) idempotencyKey = headers['x-shopify-webhook-id'];
216
+ } else if (headers['x-github-event']) {
217
+ provider = 'github';
218
+ eventType = headers['x-github-event'];
219
+ if (headers['x-github-delivery']) idempotencyKey = headers['x-github-delivery'];
220
+ } else if (headers['clerk-signature'] || headers['svix-id']) {
221
+ provider = 'clerk/svix';
222
+ if (headers['svix-id']) idempotencyKey = headers['svix-id'];
223
+ try {
224
+ const parsed = JSON.parse(rawBody);
225
+ eventType = parsed.type;
226
+ } catch (e) {}
227
+ } else {
228
+ try {
229
+ const parsed = JSON.parse(rawBody);
230
+ eventType = parsed.event || parsed.type || parsed.action || null;
231
+ if (parsed.id) idempotencyKey = String(parsed.id);
232
+ } catch (e) {}
233
+ }
234
+ if (typeof eventType !== 'string') eventType = eventType == null ? null : String(eventType);
235
+ if (eventType) eventType = eventType.slice(0, 200);
236
+ if (idempotencyKey) idempotencyKey = String(idempotencyKey).slice(0, 255);
237
+
238
+ // 0. Verify signature on ingress if endpoint secret is configured
239
+ let verified = false;
240
+ if (endpoint.secret) {
241
+ const verification = Dispatcher.verifyIngressSignature(provider, rawBody, headers, endpoint.secret);
242
+ if (!verification.valid) {
243
+ return res.status(400).json({
244
+ error: 'Webhook signature verification failed',
245
+ provider,
246
+ reason: verification.reason
247
+ });
248
+ }
249
+ verified = true;
250
+ }
251
+
252
+ let savedEvent;
253
+ try {
254
+ // Deduplication: if HookArmor already holds this event, ack without creating a second delivery
255
+ if (idempotencyKey) {
256
+ const existing = storage.findEventByIdempotencyKey(endpoint.id, idempotencyKey);
257
+ if (existing) {
258
+ return res.status(200).json({
259
+ received: true,
260
+ hookarmor_id: existing.id,
261
+ status: 'deduplicated',
262
+ message: `Idempotency key already received (current status: ${existing.status}). Suppressed duplicate delivery.`
263
+ });
264
+ }
265
+ }
266
+
267
+ const eventId = `evt_${Date.now()}_${crypto.randomBytes(6).toString('hex')}`;
268
+
269
+ // 1. Durably save event in SQLite, already claimed for its first delivery
270
+ savedEvent = storage.saveEvent({
271
+ id: eventId,
272
+ endpointId: endpoint.id,
273
+ idempotencyKey,
274
+ provider,
275
+ eventType,
276
+ headers,
277
+ rawBody,
278
+ status: 'replaying',
279
+ verified
280
+ });
281
+ } catch (err) {
282
+ // Not persisted: return an error so the provider keeps its own retry schedule
283
+ console.error('[Ingress] Failed to persist event:', err.message);
284
+ return res.status(503).json({ error: 'HookArmor could not persist the event; please retry' });
285
+ }
286
+
287
+ broadcast('event:received', {
288
+ id: savedEvent.id,
289
+ endpointId: endpoint.id,
290
+ provider,
291
+ eventType,
292
+ createdAt: savedEvent.created_at
293
+ });
294
+
295
+ // 2. Respond immediately with 200 OK to the sender so Stripe never times out or backs off
296
+ res.status(200).json({
297
+ received: true,
298
+ hookarmor_id: savedEvent.id,
299
+ status: 'buffered'
300
+ });
301
+
302
+ // 3. Dispatch to target destination asynchronously
303
+ setImmediate(() => {
304
+ dispatcher.dispatch(savedEvent, endpoint).catch((err) => {
305
+ console.error(`[Ingress] Dispatch error for ${savedEvent.id}:`, err);
306
+ });
307
+ });
308
+ });
309
+
310
+ // REST API: JSON Body Parser for Dashboard / Management
311
+ app.use(express.json({ limit: '100kb' }));
312
+
313
+ app.use('/api', (req, res, next) => {
314
+ // Public waitlist signup for the landing page (rate limited)
315
+ if (req.path === '/waitlist' && req.method === 'POST') {
316
+ if (!waitlistLimiter(req.ip)) return res.status(429).json({ error: 'Too many signups from this address; try again later' });
317
+ return next();
318
+ }
319
+
320
+ if (!apiKey) {
321
+ if (!isDemoMode) {
322
+ // Open local-dev mode: only answer to localhost names, so a web page cannot reach this
323
+ // API through DNS rebinding
324
+ if (!LOCAL_HOSTNAMES.has(hostnameOf(req.headers.host))) {
325
+ return res.status(403).json({ error: 'Without HOOKARMOR_API_KEY the management API is only served on localhost' });
326
+ }
327
+ return next();
328
+ }
329
+ }
330
+
331
+ // Demo mode (public marketing sandbox): read-only access without a key
332
+ if (isDemoMode) {
333
+ const isPublicDemoRead = req.method === 'GET' &&
334
+ (req.path === '/stats' || req.path === '/endpoints' || req.path === '/events' || /^\/events\/[^\/]+$/.test(req.path));
335
+ if (isPublicDemoRead) return next();
336
+ }
337
+
338
+ const authHeader = req.headers['authorization'] || '';
339
+ const token = authHeader.replace(/^Bearer\s+/i, '').trim() || req.headers['x-api-key'] || '';
340
+ if (!tokenMatches(token)) {
341
+ // Only wrong keys count towards the lockout, not anonymous page loads
342
+ if (token && !authFailureLimiter(req.ip)) {
343
+ return res.status(429).json({ error: 'Too many failed authentication attempts; try again later' });
344
+ }
345
+ return res.status(401).json({ error: 'Unauthorized: valid HookArmor API key required in header' });
346
+ }
347
+ next();
348
+ });
349
+
350
+ // State-changing calls must be JSON. Cross-site forms cannot send that content type without a
351
+ // CORS preflight, which this server does not grant.
352
+ app.use('/api', (req, res, next) => {
353
+ const contentType = String(req.headers['content-type'] || '').split(';')[0].trim().toLowerCase();
354
+ const isJson = contentType === 'application/json' || contentType.endsWith('+json');
355
+ if (req.method !== 'GET' && req.method !== 'HEAD' && req.method !== 'OPTIONS' && !isJson) {
356
+ return res.status(415).json({ error: 'Content-Type must be application/json' });
357
+ }
358
+ next();
359
+ });
360
+
361
+ // Get Stats
362
+ app.get('/api/stats', (req, res) => {
363
+ res.json(storage.getStats());
364
+ });
365
+
366
+ // List Endpoints (secrets are never returned)
367
+ app.get('/api/endpoints', (req, res) => {
368
+ res.json(storage.listEndpoints().map(publicEndpoint));
369
+ });
370
+
371
+ // Create or Update Endpoint
372
+ app.post('/api/endpoints', (req, res) => {
373
+ const { id, name, targetUrl, secret, alertWebhookUrl, autoRetry, maxRetries, concurrencyLimit, concurrency_limit } = req.body || {};
374
+ if (!id || !name || !targetUrl) {
375
+ return res.status(400).json({ error: 'id, name, and targetUrl are required' });
376
+ }
377
+ if (typeof id !== 'string' || !ENDPOINT_ID_PATTERN.test(id)) {
378
+ return res.status(400).json({ error: 'id must be 1-64 characters: letters, digits, "-" or "_"' });
379
+ }
380
+ if (typeof name !== 'string' || name.length > 200) {
381
+ return res.status(400).json({ error: 'name must be a string of at most 200 characters' });
382
+ }
383
+ if (secret !== undefined && (typeof secret !== 'string' || secret.length > 500)) {
384
+ return res.status(400).json({ error: 'secret must be a string of at most 500 characters' });
385
+ }
386
+ const targetCheck = checkUrl(String(targetUrl), strictSSRF);
387
+ if (!targetCheck.ok) {
388
+ return res.status(400).json({ error: `targetUrl rejected: ${targetCheck.reason} (cloud metadata${strictSSRF ? ', loopback and private network' : ''} addresses are blocked)` });
389
+ }
390
+ if (alertWebhookUrl) {
391
+ const alertCheck = checkUrl(String(alertWebhookUrl), strictSSRF);
392
+ if (!alertCheck.ok) {
393
+ return res.status(400).json({ error: `alertWebhookUrl rejected: ${alertCheck.reason}` });
394
+ }
395
+ }
396
+ const endpoint = storage.createEndpoint({
397
+ id,
398
+ name,
399
+ targetUrl: String(targetUrl),
400
+ secret,
401
+ alertWebhookUrl: alertWebhookUrl === undefined ? undefined : String(alertWebhookUrl || ''),
402
+ autoRetry: autoRetry !== undefined ? autoRetry : 1,
403
+ maxRetries: clampInt(maxRetries, 5, 1, 50),
404
+ concurrencyLimit: clampInt(concurrencyLimit || concurrency_limit, 5, 1, 100)
405
+ });
406
+ res.json(publicEndpoint(endpoint));
407
+ });
408
+
409
+ // List Events
410
+ app.get('/api/events', (req, res) => {
411
+ const { endpointId, status, limit, offset } = req.query;
412
+ const events = storage.listEvents({
413
+ endpointId: typeof endpointId === 'string' ? endpointId : null,
414
+ status: typeof status === 'string' ? status : null,
415
+ limit: clampInt(limit, 50, 1, 500),
416
+ offset: clampInt(offset, 0, 0, Number.MAX_SAFE_INTEGER)
417
+ });
418
+ res.json(events);
419
+ });
420
+
421
+ // Get single event with attempts
422
+ app.get('/api/events/:id', (req, res) => {
423
+ const event = storage.getEvent(req.params.id);
424
+ if (!event) return res.status(404).json({ error: 'Event not found' });
425
+ const attempts = storage.getAttempts(req.params.id);
426
+ res.json({ ...event, attempts });
427
+ });
428
+
429
+ // Replay a specific event
430
+ app.post('/api/events/:id/replay', async (req, res) => {
431
+ try {
432
+ const result = await dispatcher.replayEvent(req.params.id);
433
+ res.json(result);
434
+ } catch (err) {
435
+ const status = err.code === 'NOT_FOUND' ? 404 : (err.code === 'IN_FLIGHT' ? 409 : 500);
436
+ res.status(status).json({ error: err.message });
437
+ }
438
+ });
439
+
440
+ // Replay all failed events, including ones that exhausted automatic retries.
441
+ // Returns immediately; deliveries run in the background through the concurrency limiter.
442
+ app.post('/api/events/replay-all', (req, res) => {
443
+ try {
444
+ const { endpointId } = req.body || {};
445
+ const result = dispatcher.replayAllFailed(typeof endpointId === 'string' ? endpointId : null);
446
+ res.json(result);
447
+ } catch (err) {
448
+ res.status(500).json({ error: err.message });
449
+ }
450
+ });
451
+
452
+ // Waitlist Registration
453
+ app.post('/api/waitlist', (req, res) => {
454
+ const { email, source } = req.body || {};
455
+ const cleanEmail = typeof email === 'string' ? email.trim().toLowerCase() : '';
456
+ if (!cleanEmail || cleanEmail.length > 254 || !/^[^\s@<>"']+@[^\s@<>"']+\.[^\s@<>"']+$/.test(cleanEmail)) {
457
+ return res.status(400).json({ error: 'Valid email required' });
458
+ }
459
+ const cleanSource = typeof source === 'string' ? source.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 50) || 'landing_page' : 'landing_page';
460
+ const result = storage.addWaitlist(cleanEmail, cleanSource);
461
+ res.json({ success: true, message: 'Added to HookArmor Cloud beta waitlist!', email: result.email });
462
+ });
463
+
464
+ app.get('/api/waitlist', (req, res) => {
465
+ res.json(storage.listWaitlist());
466
+ });
467
+
468
+ // Mock Target Receiver (local simulation only; not mounted in production unless HOOKARMOR_ENABLE_MOCK=true)
469
+ if (mockEnabled) {
470
+ let mockTargetBehavior = {
471
+ statusCode: 200,
472
+ delayMs: 15,
473
+ responseBody: { status: 'mock_processed' }
474
+ };
475
+
476
+ app.post('/mock/target', (req, res) => {
477
+ setTimeout(() => {
478
+ res.status(mockTargetBehavior.statusCode).json(mockTargetBehavior.responseBody);
479
+ }, mockTargetBehavior.delayMs);
480
+ });
481
+
482
+ app.post('/mock/config', (req, res) => {
483
+ const body = req.body || {};
484
+ mockTargetBehavior = {
485
+ statusCode: clampInt(body.statusCode, mockTargetBehavior.statusCode, 100, 599),
486
+ delayMs: clampInt(body.delayMs, mockTargetBehavior.delayMs, 0, 30000),
487
+ responseBody: body.responseBody !== undefined ? body.responseBody : mockTargetBehavior.responseBody
488
+ };
489
+ res.json({ message: 'Mock target configuration updated', config: mockTargetBehavior });
490
+ });
491
+
492
+ app.get('/mock/config', (req, res) => {
493
+ res.json(mockTargetBehavior);
494
+ });
495
+ }
496
+
497
+ // SEO & Web Crawler Discovery
498
+ app.get('/robots.txt', (req, res) => {
499
+ res.type('text/plain');
500
+ res.sendFile('robots.txt', { root: path.join(__dirname, 'public') });
501
+ });
502
+
503
+ app.get('/sitemap.xml', (req, res) => {
504
+ res.type('application/xml');
505
+ res.sendFile('sitemap.xml', { root: path.join(__dirname, 'public') });
506
+ });
507
+
508
+ app.get('/og-preview.png', (req, res) => {
509
+ res.type('image/png');
510
+ res.sendFile('og-preview.png', { root: path.join(__dirname, 'public') });
511
+ });
512
+
513
+ // Root Route: Dashboard by default on self-hosted instances; Landing page only if explicitly enabled (§6.13)
514
+ app.get('/', (req, res) => {
515
+ if (process.env.HOOKARMOR_SERVE_LANDING === 'true') {
516
+ return res.sendFile('landing.html', { root: path.join(__dirname, 'public') });
517
+ }
518
+ res.sendFile('index.html', { root: path.join(__dirname, 'public') });
519
+ });
520
+
521
+ // Interactive Dashboard Routes
522
+ app.get(['/dashboard', '/app'], (req, res) => {
523
+ res.sendFile('index.html', { root: path.join(__dirname, 'public') });
524
+ });
525
+
526
+ // Engineering Blog Routes
527
+ app.get('/blog', (req, res) => {
528
+ res.sendFile('index.html', { root: path.join(__dirname, 'public', 'blog') });
529
+ });
530
+
531
+ app.get('/blog/:slug', (req, res) => {
532
+ const slug = req.params.slug.replace(/[^a-zA-Z0-9-_]/g, '');
533
+ const fileName = `${slug}.html`;
534
+ const filePath = path.join(__dirname, 'public', 'blog', fileName);
535
+ if (fs.existsSync(filePath)) {
536
+ return res.sendFile(fileName, { root: path.join(__dirname, 'public', 'blog') });
537
+ }
538
+ res.redirect('/blog');
539
+ });
540
+
541
+ return { app, server, storage, dispatcher, worker, mockEnabled, strictSSRF };
542
+ }
543
+
544
+ module.exports = { createServer };