hookarmor 1.0.3 → 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/src/server.js CHANGED
@@ -1,393 +1,593 @@
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) => {
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
+ }
144
+ });
145
+
146
+ wss.on('connection', (socket) => {
147
+ socket.isAuthed = !apiKey || isDemoMode;
148
+ let authTimer = null;
149
+ if (!socket.isAuthed) {
150
+ authTimer = setTimeout(() => { if (!socket.isAuthed) socket.close(4001, 'Authentication required'); }, 15000);
151
+ if (authTimer.unref) authTimer.unref();
152
+ }
153
+ // Browsers cannot set headers on WebSocket upgrades, so the key arrives as the first message
154
+ socket.on('message', (raw) => {
155
+ try {
156
+ const msg = JSON.parse(String(raw));
157
+ if (msg.type === 'auth') {
158
+ if (tokenMatches(msg.token)) {
159
+ socket.isAuthed = true;
160
+ if (authTimer) clearTimeout(authTimer);
161
+ socket.send(JSON.stringify({ type: 'auth:ok' }));
162
+ } else {
163
+ socket.close(4001, 'Invalid API key');
164
+ }
165
+ }
166
+ } catch (e) {}
167
+ });
168
+ });
169
+
170
+ function broadcast(type, data) {
171
+ const payload = JSON.stringify({ type, data });
172
+ wss.clients.forEach(client => {
173
+ if (client.readyState === WebSocket.OPEN && client.isAuthed) {
174
+ client.send(payload);
175
+ }
176
+ });
177
+ }
178
+
179
+ // Hook dispatcher events to WebSocket broadcasts
180
+ dispatcher.on('delivery', (res) => {
181
+ broadcast('delivery', res);
182
+ });
183
+
184
+ // CORS is opt-in: the dashboard and landing page are same-origin and need none
185
+ if (corsOrigins.length > 0) {
186
+ app.use('/api', cors({ origin: corsOrigins }));
187
+ }
188
+
189
+ // Serve static UI assets
190
+ app.use('/static', express.static(path.join(__dirname, 'public')));
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
+
198
+ // Ingress endpoint for webhooks - uses raw body parser to preserve raw bytes
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
+
210
+ const endpointId = req.params.endpointId;
211
+ const endpoint = storage.getEndpoint(endpointId);
212
+
213
+ if (!endpoint) {
214
+ return res.status(404).json({ error: 'HookArmor endpoint not found' });
215
+ }
216
+
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 (_) {}
222
+ const headers = { ...req.headers };
223
+
224
+ // Detect provider & idempotency key
225
+ let provider = 'generic';
226
+ let eventType = null;
227
+ let idempotencyKey = headers['idempotency-key'] || headers['x-idempotency-key'] || null;
228
+
229
+ if (headers['stripe-signature']) {
230
+ provider = 'stripe';
231
+ try {
232
+ const parsed = JSON.parse(rawBody);
233
+ eventType = parsed.type;
234
+ if (parsed.id) idempotencyKey = parsed.id;
235
+ } catch (e) {}
236
+ } else if (headers['x-shopify-hmac-sha256'] || headers['x-shopify-topic']) {
237
+ provider = 'shopify';
238
+ eventType = headers['x-shopify-topic'];
239
+ if (headers['x-shopify-webhook-id']) idempotencyKey = headers['x-shopify-webhook-id'];
240
+ } else if (headers['x-github-event']) {
241
+ provider = 'github';
242
+ eventType = headers['x-github-event'];
243
+ if (headers['x-github-delivery']) idempotencyKey = headers['x-github-delivery'];
244
+ } else if (headers['clerk-signature'] || headers['svix-id']) {
245
+ provider = 'clerk/svix';
246
+ if (headers['svix-id']) idempotencyKey = headers['svix-id'];
247
+ try {
248
+ const parsed = JSON.parse(rawBody);
249
+ eventType = parsed.type;
250
+ } catch (e) {}
251
+ } else {
252
+ try {
253
+ const parsed = JSON.parse(rawBody);
254
+ eventType = parsed.event || parsed.type || parsed.action || null;
255
+ if (parsed.id) idempotencyKey = String(parsed.id);
256
+ } catch (e) {}
257
+ }
258
+ if (typeof eventType !== 'string') eventType = eventType == null ? null : String(eventType);
259
+ if (eventType) eventType = eventType.slice(0, 200);
260
+ if (idempotencyKey) idempotencyKey = String(idempotencyKey).slice(0, 255);
261
+
262
+ // 0. Verify signature on ingress if endpoint secret is configured
263
+ let verified = false;
264
+ if (endpoint.secret) {
265
+ const verification = Dispatcher.verifyIngressSignature(provider, rawBodyBuffer, headers, endpoint.secret);
266
+ if (!verification.valid) {
267
+ return res.status(400).json({
268
+ error: 'Webhook signature verification failed',
269
+ provider,
270
+ reason: verification.reason
271
+ });
272
+ }
273
+ verified = true;
274
+ }
275
+
276
+ let savedEvent;
277
+ try {
278
+ // Deduplication: if HookArmor already holds this event, ack without creating a second delivery
279
+ if (idempotencyKey) {
280
+ const existing = storage.findEventByIdempotencyKey(endpoint.id, idempotencyKey);
281
+ if (existing) {
282
+ return res.status(200).json({
283
+ received: true,
284
+ hookarmor_id: existing.id,
285
+ status: 'deduplicated',
286
+ message: `Idempotency key already received (current status: ${existing.status}). Suppressed duplicate delivery.`
287
+ });
288
+ }
289
+ }
290
+
291
+ const eventId = `evt_${Date.now()}_${crypto.randomBytes(6).toString('hex')}`;
292
+
293
+ // 1. Durably save event in SQLite, already claimed for its first delivery
294
+ savedEvent = storage.saveEvent({
295
+ id: eventId,
296
+ endpointId: endpoint.id,
297
+ idempotencyKey,
298
+ provider,
299
+ eventType,
300
+ headers,
301
+ rawBody: rawBodyBuffer,
302
+ status: 'replaying',
303
+ verified
304
+ });
305
+ } catch (err) {
306
+ // Not persisted: return an error so the provider keeps its own retry schedule
307
+ console.error('[Ingress] Failed to persist event:', err.message);
308
+ return res.status(503).json({ error: 'HookArmor could not persist the event; please retry' });
309
+ }
310
+
311
+ broadcast('event:received', {
312
+ id: savedEvent.id,
313
+ endpointId: endpoint.id,
314
+ provider,
315
+ eventType,
316
+ createdAt: savedEvent.created_at
317
+ });
318
+
319
+ // 2. Respond immediately with 200 OK to the sender so Stripe never times out or backs off
320
+ res.status(200).json({
321
+ received: true,
322
+ hookarmor_id: savedEvent.id,
323
+ status: 'buffered'
324
+ });
325
+
326
+ // 3. Dispatch to target destination asynchronously
327
+ setImmediate(() => {
328
+ dispatcher.dispatch(savedEvent, endpoint).catch((err) => {
329
+ console.error(`[Ingress] Dispatch error for ${savedEvent.id}:`, err);
330
+ });
331
+ });
332
+ });
333
+
334
+ // REST API: JSON Body Parser for Dashboard / Management
335
+ app.use(express.json({ limit: '100kb' }));
336
+
337
+ app.use('/api', (req, res, next) => {
338
+ // Public waitlist signup for the landing page (rate limited)
339
+ if (req.path === '/waitlist' && req.method === 'POST') {
340
+ if (!waitlistLimiter(req.ip)) return res.status(429).json({ error: 'Too many signups from this address; try again later' });
341
+ return next();
342
+ }
343
+
344
+ if (!apiKey) {
345
+ if (!isDemoMode) {
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)' });
352
+ }
353
+ return next();
354
+ }
355
+ }
356
+
357
+ // Demo mode (public marketing sandbox): read-only access without a key
358
+ if (isDemoMode) {
359
+ const isPublicDemoRead = req.method === 'GET' &&
360
+ (req.path === '/stats' || req.path === '/endpoints' || req.path === '/events' || /^\/events\/[^\/]+$/.test(req.path));
361
+ if (isPublicDemoRead) return next();
362
+ }
363
+
364
+ const authHeader = req.headers['authorization'] || '';
365
+ const token = authHeader.replace(/^Bearer\s+/i, '').trim() || req.headers['x-api-key'] || '';
366
+ if (!tokenMatches(token)) {
367
+ // Only wrong keys count towards the lockout, not anonymous page loads
368
+ if (token && !authFailureLimiter(req.ip)) {
369
+ return res.status(429).json({ error: 'Too many failed authentication attempts; try again later' });
370
+ }
371
+ return res.status(401).json({ error: 'Unauthorized: valid HookArmor API key required in header' });
372
+ }
373
+ next();
374
+ });
375
+
376
+ // State-changing calls must be JSON. Cross-site forms cannot send that content type without a
377
+ // CORS preflight, which this server does not grant.
378
+ app.use('/api', (req, res, next) => {
379
+ const contentType = String(req.headers['content-type'] || '').split(';')[0].trim().toLowerCase();
380
+ const isJson = contentType === 'application/json' || contentType.endsWith('+json');
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) {
384
+ return res.status(415).json({ error: 'Content-Type must be application/json' });
385
+ }
386
+ next();
387
+ });
388
+
389
+ // Get Stats
390
+ app.get('/api/stats', (req, res) => {
391
+ res.json(storage.getStats());
392
+ });
393
+
394
+ // List Endpoints (secrets are never returned)
395
+ app.get('/api/endpoints', (req, res) => {
396
+ res.json(storage.listEndpoints().map(publicEndpoint));
397
+ });
398
+
399
+ // Create or Update Endpoint
400
+ app.post('/api/endpoints', (req, res) => {
401
+ const { id, name, targetUrl, secret, alertWebhookUrl, autoRetry, maxRetries, concurrencyLimit, concurrency_limit } = req.body || {};
402
+ if (!id || !name || !targetUrl) {
403
+ return res.status(400).json({ error: 'id, name, and targetUrl are required' });
404
+ }
405
+ if (typeof id !== 'string' || !ENDPOINT_ID_PATTERN.test(id)) {
406
+ return res.status(400).json({ error: 'id must be 1-64 characters: letters, digits, "-" or "_"' });
407
+ }
408
+ if (typeof name !== 'string' || name.length > 200) {
409
+ return res.status(400).json({ error: 'name must be a string of at most 200 characters' });
410
+ }
411
+ if (secret !== undefined && (typeof secret !== 'string' || secret.length > 500)) {
412
+ return res.status(400).json({ error: 'secret must be a string of at most 500 characters' });
413
+ }
414
+ const targetCheck = checkUrl(String(targetUrl), strictSSRF);
415
+ if (!targetCheck.ok) {
416
+ return res.status(400).json({ error: `targetUrl rejected: ${targetCheck.reason} (cloud metadata${strictSSRF ? ', loopback and private network' : ''} addresses are blocked)` });
417
+ }
418
+ if (alertWebhookUrl) {
419
+ const alertCheck = checkUrl(String(alertWebhookUrl), strictSSRF);
420
+ if (!alertCheck.ok) {
421
+ return res.status(400).json({ error: `alertWebhookUrl rejected: ${alertCheck.reason}` });
422
+ }
423
+ }
424
+ const endpoint = storage.createEndpoint({
425
+ id,
426
+ name,
427
+ targetUrl: String(targetUrl),
428
+ secret,
429
+ alertWebhookUrl: alertWebhookUrl === undefined ? undefined : String(alertWebhookUrl || ''),
430
+ autoRetry: autoRetry !== undefined ? autoRetry : 1,
431
+ maxRetries: clampInt(maxRetries, 5, 1, 50),
432
+ concurrencyLimit: clampInt(concurrencyLimit || concurrency_limit, 5, 1, 100)
433
+ });
434
+ res.json(publicEndpoint(endpoint));
435
+ });
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
+
444
+ // List Events
445
+ app.get('/api/events', (req, res) => {
446
+ const { endpointId, status, limit, offset } = req.query;
447
+ const events = storage.listEvents({
448
+ endpointId: typeof endpointId === 'string' ? endpointId : null,
449
+ status: typeof status === 'string' ? status : null,
450
+ limit: clampInt(limit, 50, 1, 500),
451
+ offset: clampInt(offset, 0, 0, Number.MAX_SAFE_INTEGER)
452
+ });
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
+ })));
457
+ });
458
+
459
+ // Get single event with attempts
460
+ app.get('/api/events/:id', (req, res) => {
461
+ const event = storage.getEvent(req.params.id);
462
+ if (!event) return res.status(404).json({ error: 'Event not found' });
463
+ const attempts = storage.getAttempts(req.params.id);
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' });
476
+ });
477
+
478
+ // Replay a specific event
479
+ app.post('/api/events/:id/replay', async (req, res) => {
480
+ try {
481
+ const result = await dispatcher.replayEvent(req.params.id);
482
+ res.json(result);
483
+ } catch (err) {
484
+ const status = err.code === 'NOT_FOUND' ? 404 : (err.code === 'IN_FLIGHT' ? 409 : 500);
485
+ res.status(status).json({ error: err.message });
486
+ }
487
+ });
488
+
489
+ // Replay all failed events, including ones that exhausted automatic retries.
490
+ // Returns immediately; deliveries run in the background through the concurrency limiter.
491
+ app.post('/api/events/replay-all', (req, res) => {
492
+ try {
493
+ const { endpointId } = req.body || {};
494
+ const result = dispatcher.replayAllFailed(typeof endpointId === 'string' ? endpointId : null);
495
+ res.json(result);
496
+ } catch (err) {
497
+ res.status(500).json({ error: err.message });
498
+ }
499
+ });
500
+
501
+ // Waitlist Registration
502
+ app.post('/api/waitlist', (req, res) => {
503
+ const { email, source } = req.body || {};
504
+ const cleanEmail = typeof email === 'string' ? email.trim().toLowerCase() : '';
505
+ if (!cleanEmail || cleanEmail.length > 254 || !/^[^\s@<>"']+@[^\s@<>"']+\.[^\s@<>"']+$/.test(cleanEmail)) {
506
+ return res.status(400).json({ error: 'Valid email required' });
507
+ }
508
+ const cleanSource = typeof source === 'string' ? source.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 50) || 'landing_page' : 'landing_page';
509
+ const result = storage.addWaitlist(cleanEmail, cleanSource);
510
+ res.json({ success: true, message: 'Added to HookArmor Cloud beta waitlist!', email: result.email });
511
+ });
512
+
513
+ app.get('/api/waitlist', (req, res) => {
514
+ res.json(storage.listWaitlist());
515
+ });
516
+
517
+ // Mock Target Receiver (local simulation only; not mounted in production unless HOOKARMOR_ENABLE_MOCK=true)
518
+ if (mockEnabled) {
519
+ let mockTargetBehavior = {
520
+ statusCode: 200,
521
+ delayMs: 15,
522
+ responseBody: { status: 'mock_processed' }
523
+ };
524
+
525
+ app.post('/mock/target', (req, res) => {
526
+ setTimeout(() => {
527
+ res.status(mockTargetBehavior.statusCode).json(mockTargetBehavior.responseBody);
528
+ }, mockTargetBehavior.delayMs);
529
+ });
530
+
531
+ app.post('/mock/config', (req, res) => {
532
+ const body = req.body || {};
533
+ mockTargetBehavior = {
534
+ statusCode: clampInt(body.statusCode, mockTargetBehavior.statusCode, 100, 599),
535
+ delayMs: clampInt(body.delayMs, mockTargetBehavior.delayMs, 0, 30000),
536
+ responseBody: body.responseBody !== undefined ? body.responseBody : mockTargetBehavior.responseBody
537
+ };
538
+ res.json({ message: 'Mock target configuration updated', config: mockTargetBehavior });
539
+ });
540
+
541
+ app.get('/mock/config', (req, res) => {
542
+ res.json(mockTargetBehavior);
543
+ });
544
+ }
545
+
546
+ // SEO & Web Crawler Discovery
547
+ app.get('/robots.txt', (req, res) => {
548
+ res.type('text/plain');
549
+ res.sendFile('robots.txt', { root: path.join(__dirname, 'public') });
550
+ });
551
+
552
+ app.get('/sitemap.xml', (req, res) => {
553
+ res.type('application/xml');
554
+ res.sendFile('sitemap.xml', { root: path.join(__dirname, 'public') });
555
+ });
556
+
557
+ app.get('/og-preview.png', (req, res) => {
558
+ res.type('image/png');
559
+ res.sendFile('og-preview.png', { root: path.join(__dirname, 'public') });
560
+ });
561
+
562
+ // Root Route: Dashboard by default on self-hosted instances; Landing page only if explicitly enabled (§6.13)
563
+ app.get('/', (req, res) => {
564
+ if (process.env.HOOKARMOR_SERVE_LANDING === 'true') {
565
+ return res.sendFile('landing.html', { root: path.join(__dirname, 'public') });
566
+ }
567
+ res.sendFile('index.html', { root: path.join(__dirname, 'public') });
568
+ });
569
+
570
+ // Interactive Dashboard Routes
571
+ app.get(['/dashboard', '/app'], (req, res) => {
572
+ res.sendFile('index.html', { root: path.join(__dirname, 'public') });
573
+ });
574
+
575
+ // Engineering Blog Routes
576
+ app.get('/blog', (req, res) => {
577
+ res.sendFile('index.html', { root: path.join(__dirname, 'public', 'blog') });
578
+ });
579
+
580
+ app.get('/blog/:slug', (req, res) => {
581
+ const slug = req.params.slug.replace(/[^a-zA-Z0-9-_]/g, '');
582
+ const fileName = `${slug}.html`;
583
+ const filePath = path.join(__dirname, 'public', 'blog', fileName);
584
+ if (fs.existsSync(filePath)) {
585
+ return res.sendFile(fileName, { root: path.join(__dirname, 'public', 'blog') });
586
+ }
587
+ res.redirect('/blog');
588
+ });
589
+
590
+ return { app, server, storage, dispatcher, worker, mockEnabled, strictSSRF };
591
+ }
592
+
593
+ module.exports = { createServer };