hookarmor 1.0.2 → 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,370 +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) {
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
- return false;
26
- } catch (e) {
27
- return true;
28
- }
29
- }
30
-
31
- function createServer(options = {}) {
32
- const app = express();
33
- const server = http.createServer(app);
34
- const wss = new WebSocketServer({ server, path: '/ws' });
35
-
36
- const storage = new Storage(options.dbPath);
37
- const dispatcher = new Dispatcher(storage, {
38
- defaultConcurrency: options.defaultConcurrency || 5,
39
- defaultTimeoutMs: options.defaultTimeoutMs || 25000
40
- });
41
-
42
- const worker = new RetryWorker(storage, dispatcher, {
43
- intervalMs: options.retryIntervalMs || 5000
44
- });
45
- if (options.autoStartWorker !== false) {
46
- worker.start();
47
- }
48
-
49
- server.on('close', () => {
50
- worker.stop();
51
- });
52
-
53
- // Broadcast helper for real-time WebSocket dashboard
54
- function broadcast(type, data) {
55
- const payload = JSON.stringify({ type, data });
56
- wss.clients.forEach(client => {
57
- if (client.readyState === WebSocket.OPEN) {
58
- client.send(payload);
59
- }
60
- });
61
- }
62
-
63
- // Hook dispatcher events to WebSocket broadcasts
64
- dispatcher.on('delivery', (res) => {
65
- broadcast('delivery', res);
66
- });
67
-
68
- // Enable CORS
69
- app.use(cors());
70
-
71
- // Serve static UI assets
72
- app.use('/static', express.static(path.join(__dirname, 'public')));
73
-
74
- // Ingress endpoint for webhooks - uses raw body parser to preserve raw bytes
75
- app.post('/in/:endpointId', express.raw({ type: '*/*', limit: '10mb' }), async (req, res) => {
76
- const endpointId = req.params.endpointId;
77
- const endpoint = storage.getEndpoint(endpointId);
78
-
79
- if (!endpoint) {
80
- return res.status(404).json({ error: `HookArmor endpoint not found: ${endpointId}` });
81
- }
82
-
83
- const rawBodyBuffer = req.body || Buffer.from('');
84
- const rawBody = rawBodyBuffer.toString('utf8');
85
- const headers = { ...req.headers };
86
-
87
- // Detect provider & idempotency key
88
- let provider = 'generic';
89
- let eventType = null;
90
- let idempotencyKey = headers['idempotency-key'] || headers['x-idempotency-key'] || null;
91
-
92
- if (headers['stripe-signature']) {
93
- provider = 'stripe';
94
- try {
95
- const parsed = JSON.parse(rawBody);
96
- eventType = parsed.type;
97
- if (parsed.id) idempotencyKey = parsed.id;
98
- } catch (e) {}
99
- } else if (headers['x-shopify-hmac-sha256'] || headers['x-shopify-topic']) {
100
- provider = 'shopify';
101
- eventType = headers['x-shopify-topic'];
102
- if (headers['x-shopify-webhook-id']) idempotencyKey = headers['x-shopify-webhook-id'];
103
- } else if (headers['x-github-event']) {
104
- provider = 'github';
105
- eventType = headers['x-github-event'];
106
- if (headers['x-github-delivery']) idempotencyKey = headers['x-github-delivery'];
107
- } else if (headers['clerk-signature'] || headers['svix-id']) {
108
- provider = 'clerk/svix';
109
- if (headers['svix-id']) idempotencyKey = headers['svix-id'];
110
- try {
111
- const parsed = JSON.parse(rawBody);
112
- eventType = parsed.type;
113
- } catch (e) {}
114
- } else {
115
- try {
116
- const parsed = JSON.parse(rawBody);
117
- eventType = parsed.event || parsed.type || parsed.action || null;
118
- if (parsed.id) idempotencyKey = String(parsed.id);
119
- } catch (e) {}
120
- }
121
-
122
- // 0. Verify signature on ingress if endpoint secret is configured
123
- if (endpoint.secret) {
124
- const verification = Dispatcher.verifyIngressSignature(provider, rawBody, headers, endpoint.secret);
125
- if (!verification.valid) {
126
- return res.status(400).json({
127
- error: 'Webhook signature verification failed',
128
- provider,
129
- reason: verification.reason
130
- });
131
- }
132
- }
133
-
134
- // Deduplication check: if this event ID was already delivered, ack immediately without duplicating downstream side effects
135
- if (idempotencyKey) {
136
- const existing = storage.findEventByIdempotencyKey(endpoint.id, idempotencyKey);
137
- if (existing && existing.status === 'delivered') {
138
- return res.status(200).json({
139
- received: true,
140
- hookarmor_id: existing.id,
141
- status: 'deduplicated',
142
- message: `Idempotency key ${idempotencyKey} already delivered. Suppressed duplicate execution.`
143
- });
144
- }
145
- }
146
-
147
- const eventId = `evt_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
148
-
149
- // 1. Durably save event in SQLite
150
- const savedEvent = storage.saveEvent({
151
- id: eventId,
152
- endpointId: endpoint.id,
153
- idempotencyKey,
154
- provider,
155
- eventType,
156
- headers,
157
- rawBody,
158
- status: 'pending'
159
- });
160
-
161
- broadcast('event:received', {
162
- id: eventId,
163
- endpointId: endpoint.id,
164
- provider,
165
- eventType,
166
- createdAt: savedEvent.created_at
167
- });
168
-
169
- // 2. Respond immediately with 200 OK to the sender so Stripe never times out or backs off
170
- res.status(200).json({
171
- received: true,
172
- hookarmor_id: eventId,
173
- status: 'buffered'
174
- });
175
-
176
- // 3. Dispatch to target destination asynchronously
177
- setImmediate(async () => {
178
- try {
179
- await dispatcher.dispatch(savedEvent, endpoint);
180
- } catch (err) {
181
- console.error(`[Ingress] Dispatch error for ${eventId}:`, err);
182
- }
183
- });
184
- });
185
-
186
- // REST API: JSON Body Parser for Dashboard / Management
187
- app.use(express.json());
188
-
189
- // Management API Authentication Middleware (optional, active when HOOKARMOR_API_KEY is configured)
190
- const apiKey = options.apiKey || process.env.HOOKARMOR_API_KEY || null;
191
- app.use('/api', (req, res, next) => {
192
- // Keep public waitlist signup open for landing page
193
- if (req.path === '/waitlist' && req.method === 'POST') return next();
194
-
195
- // Allow public read-only demo access & replay simulation so prospective users can explore live
196
- const isPublicDemo =
197
- (req.method === 'GET' && (req.path === '/stats' || req.path === '/endpoints' || req.path === '/events' || /^\/events\/[^\/]+$/.test(req.path))) ||
198
- (req.method === 'POST' && (req.path === '/events/replay-all' || /^\/events\/[^\/]+\/replay$/.test(req.path)));
199
-
200
- if (isPublicDemo) return next();
201
-
202
- if (!apiKey) return next();
203
-
204
- // Accept API token strictly via Authorization or X-Api-Key headers (NEVER query string URL)
205
- const authHeader = req.headers['authorization'] || '';
206
- const token = authHeader.replace(/^Bearer\s+/i, '').trim() || req.headers['x-api-key'] || '';
207
- if (!token || token !== apiKey) {
208
- return res.status(401).json({ error: 'Unauthorized: valid HookArmor API key required in header' });
209
- }
210
- next();
211
- });
212
-
213
- // Get Stats
214
- app.get('/api/stats', (req, res) => {
215
- res.json(storage.getStats());
216
- });
217
-
218
- // List Endpoints
219
- app.get('/api/endpoints', (req, res) => {
220
- res.json(storage.listEndpoints());
221
- });
222
-
223
- // Create or Update Endpoint
224
- app.post('/api/endpoints', (req, res) => {
225
- const { id, name, targetUrl, secret, alertWebhookUrl, autoRetry, maxRetries, concurrencyLimit, concurrency_limit } = req.body;
226
- if (!id || !name || !targetUrl) {
227
- return res.status(400).json({ error: 'id, name, and targetUrl are required' });
228
- }
229
- if (isPrivateOrMetadataUrl(targetUrl)) {
230
- return res.status(400).json({ error: 'targetUrl cannot target cloud metadata or link-local addresses (SSRF blocked)' });
231
- }
232
- const endpoint = storage.createEndpoint({
233
- id,
234
- name,
235
- targetUrl,
236
- secret,
237
- alertWebhookUrl,
238
- autoRetry: autoRetry !== undefined ? autoRetry : 1,
239
- maxRetries: maxRetries || 5,
240
- concurrencyLimit: concurrencyLimit || concurrency_limit || 5
241
- });
242
- res.json(endpoint);
243
- });
244
-
245
- // List Events
246
- app.get('/api/events', (req, res) => {
247
- const { endpointId, status, limit, offset } = req.query;
248
- const events = storage.listEvents({
249
- endpointId,
250
- status,
251
- limit: parseInt(limit, 10) || 50,
252
- offset: parseInt(offset, 10) || 0
253
- });
254
- res.json(events);
255
- });
256
-
257
- // Get single event with attempts
258
- app.get('/api/events/:id', (req, res) => {
259
- const event = storage.getEvent(req.params.id);
260
- if (!event) return res.status(404).json({ error: 'Event not found' });
261
- const attempts = storage.getAttempts(req.params.id);
262
- res.json({ ...event, attempts });
263
- });
264
-
265
- // Replay a specific event
266
- app.post('/api/events/:id/replay', async (req, res) => {
267
- try {
268
- const result = await dispatcher.replayEvent(req.params.id);
269
- res.json(result);
270
- } catch (err) {
271
- res.status(500).json({ error: err.message });
272
- }
273
- });
274
-
275
- // Replay all failed events (optionally filtered by endpoint)
276
- app.post('/api/events/replay-all', async (req, res) => {
277
- try {
278
- const { endpointId } = req.body || {};
279
- const results = await dispatcher.replayAllFailed(endpointId);
280
- res.json({ replayedCount: results.length, results });
281
- } catch (err) {
282
- res.status(500).json({ error: err.message });
283
- }
284
- });
285
-
286
- // Waitlist Registration
287
- app.post('/api/waitlist', (req, res) => {
288
- const { email, source } = req.body || {};
289
- if (!email || !email.includes('@')) {
290
- return res.status(400).json({ error: 'Valid email required' });
291
- }
292
- const result = storage.addWaitlist(email.trim().toLowerCase(), source || 'landing_page');
293
- res.json({ success: true, message: 'Added to HookArmor Cloud beta waitlist!', email: result.email });
294
- });
295
-
296
- app.get('/api/waitlist', (req, res) => {
297
- res.json(storage.listWaitlist());
298
- });
299
-
300
- // Mock Target Receiver (for self-testing and local simulation)
301
- let mockTargetBehavior = {
302
- statusCode: 200,
303
- delayMs: 15,
304
- responseBody: { status: 'mock_processed' }
305
- };
306
-
307
- app.post('/mock/target', (req, res) => {
308
- setTimeout(() => {
309
- res.status(mockTargetBehavior.statusCode).json(mockTargetBehavior.responseBody);
310
- }, mockTargetBehavior.delayMs);
311
- });
312
-
313
- app.post('/mock/config', (req, res) => {
314
- mockTargetBehavior = { ...mockTargetBehavior, ...req.body };
315
- res.json({ message: 'Mock target configuration updated', config: mockTargetBehavior });
316
- });
317
-
318
- app.get('/mock/config', (req, res) => {
319
- res.json(mockTargetBehavior);
320
- });
321
-
322
- // SEO & Web Crawler Discovery
323
- app.get('/robots.txt', (req, res) => {
324
- res.type('text/plain');
325
- res.sendFile('robots.txt', { root: path.join(__dirname, 'public') });
326
- });
327
-
328
- app.get('/sitemap.xml', (req, res) => {
329
- res.type('application/xml');
330
- res.sendFile('sitemap.xml', { root: path.join(__dirname, 'public') });
331
- });
332
-
333
- app.get('/og-preview.png', (req, res) => {
334
- res.type('image/png');
335
- res.sendFile('og-preview.png', { root: path.join(__dirname, 'public') });
336
- });
337
-
338
- // Root Route: Dashboard if run locally via CLI; Landing page on cloud deployment
339
- app.get('/', (req, res) => {
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') });
345
- });
346
-
347
- // Interactive Dashboard Routes
348
- app.get(['/dashboard', '/app'], (req, res) => {
349
- res.sendFile('index.html', { root: path.join(__dirname, 'public') });
350
- });
351
-
352
- // Engineering Blog Routes
353
- app.get('/blog', (req, res) => {
354
- res.sendFile('index.html', { root: path.join(__dirname, 'public', 'blog') });
355
- });
356
-
357
- app.get('/blog/:slug', (req, res) => {
358
- const slug = req.params.slug.replace(/[^a-zA-Z0-9-_]/g, '');
359
- const fileName = `${slug}.html`;
360
- const filePath = path.join(__dirname, 'public', 'blog', fileName);
361
- if (fs.existsSync(filePath)) {
362
- return res.sendFile(fileName, { root: path.join(__dirname, 'public', 'blog') });
363
- }
364
- res.redirect('/blog');
365
- });
366
-
367
- return { app, server, storage, dispatcher, worker };
368
- }
369
-
370
- 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 };