hookarmor 1.0.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.
@@ -0,0 +1,1158 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>HookArmor - Webhook Dead-Letter Queue & Replay Sentinel</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
10
+ <style>
11
+ :root {
12
+ --bg-dark: #090d16;
13
+ --bg-card: #0f172a;
14
+ --bg-card-hover: #1e293b;
15
+ --border: #1e293b;
16
+ --border-focus: #3b82f6;
17
+ --text-main: #f8fafc;
18
+ --text-muted: #94a3b8;
19
+ --accent-blue: #3b82f6;
20
+ --accent-blue-glow: rgba(59, 130, 246, 0.25);
21
+ --accent-emerald: #10b981;
22
+ --accent-rose: #f43f5e;
23
+ --accent-amber: #f59e0b;
24
+ --accent-purple: #8b5cf6;
25
+ --font-sans: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, sans-serif;
26
+ --font-mono: 'JetBrains Mono', monospace;
27
+ }
28
+
29
+ * { box-sizing: border-box; margin: 0; padding: 0; }
30
+ body {
31
+ background-color: var(--bg-dark);
32
+ color: var(--text-main);
33
+ font-family: var(--font-sans);
34
+ min-height: 100vh;
35
+ display: flex;
36
+ flex-direction: column;
37
+ }
38
+
39
+ header {
40
+ border-bottom: 1px solid var(--border);
41
+ padding: 1rem 2rem;
42
+ display: flex;
43
+ align-items: center;
44
+ justify-content: space-between;
45
+ background: rgba(15, 23, 42, 0.7);
46
+ backdrop-filter: blur(12px);
47
+ position: sticky;
48
+ top: 0;
49
+ z-index: 40;
50
+ }
51
+
52
+ .brand {
53
+ display: flex;
54
+ align-items: center;
55
+ gap: 0.75rem;
56
+ text-decoration: none;
57
+ color: inherit;
58
+ }
59
+
60
+ .brand-icon {
61
+ width: 38px;
62
+ height: 38px;
63
+ background: linear-gradient(135deg, #3b82f6, #6366f1);
64
+ border-radius: 10px;
65
+ display: flex;
66
+ align-items: center;
67
+ justify-content: center;
68
+ font-weight: 800;
69
+ font-size: 1.25rem;
70
+ box-shadow: 0 0 20px rgba(99, 102, 241, 0.4);
71
+ }
72
+
73
+ .brand-text h1 {
74
+ font-size: 1.15rem;
75
+ font-weight: 800;
76
+ letter-spacing: -0.02em;
77
+ display: flex;
78
+ align-items: center;
79
+ gap: 0.5rem;
80
+ }
81
+
82
+ .brand-tag {
83
+ font-size: 0.65rem;
84
+ background: rgba(59, 130, 246, 0.15);
85
+ color: var(--accent-blue);
86
+ border: 1px solid rgba(59, 130, 246, 0.3);
87
+ padding: 0.15rem 0.45rem;
88
+ border-radius: 9999px;
89
+ font-weight: 700;
90
+ text-transform: uppercase;
91
+ }
92
+
93
+ .nav-actions {
94
+ display: flex;
95
+ gap: 0.75rem;
96
+ align-items: center;
97
+ }
98
+
99
+ .btn {
100
+ display: inline-flex;
101
+ align-items: center;
102
+ gap: 0.5rem;
103
+ padding: 0.55rem 1rem;
104
+ border-radius: 8px;
105
+ font-weight: 600;
106
+ font-size: 0.85rem;
107
+ cursor: pointer;
108
+ transition: all 0.15s ease;
109
+ border: 1px solid transparent;
110
+ text-decoration: none;
111
+ }
112
+
113
+ .btn-primary {
114
+ background: #2563eb;
115
+ color: white;
116
+ box-shadow: 0 4px 14px rgba(37, 99, 235, 0.3);
117
+ }
118
+ .btn-primary:hover { background: #1d4ed8; }
119
+
120
+ .btn-danger {
121
+ background: rgba(244, 63, 94, 0.15);
122
+ color: var(--accent-rose);
123
+ border: 1px solid rgba(244, 63, 94, 0.3);
124
+ }
125
+ .btn-danger:hover { background: rgba(244, 63, 94, 0.25); }
126
+
127
+ .btn-secondary {
128
+ background: var(--bg-card);
129
+ color: var(--text-main);
130
+ border: 1px solid var(--border);
131
+ }
132
+ .btn-secondary:hover { background: var(--bg-card-hover); border-color: #475569; }
133
+
134
+ .btn-sm {
135
+ padding: 0.35rem 0.65rem;
136
+ font-size: 0.78rem;
137
+ }
138
+
139
+ main {
140
+ padding: 2rem;
141
+ max-width: 1400px;
142
+ margin: 0 auto;
143
+ width: 100%;
144
+ display: flex;
145
+ flex-direction: column;
146
+ gap: 2rem;
147
+ }
148
+
149
+ /* Metric Grid */
150
+ .metric-grid {
151
+ display: grid;
152
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
153
+ gap: 1.25rem;
154
+ }
155
+
156
+ .metric-card {
157
+ background: var(--bg-card);
158
+ border: 1px solid var(--border);
159
+ border-radius: 12px;
160
+ padding: 1.25rem;
161
+ display: flex;
162
+ flex-direction: column;
163
+ gap: 0.5rem;
164
+ }
165
+
166
+ .metric-title {
167
+ font-size: 0.8rem;
168
+ color: var(--text-muted);
169
+ font-weight: 600;
170
+ text-transform: uppercase;
171
+ letter-spacing: 0.05em;
172
+ }
173
+
174
+ .metric-value {
175
+ font-size: 2rem;
176
+ font-weight: 800;
177
+ letter-spacing: -0.03em;
178
+ }
179
+
180
+ .metric-value.emerald { color: var(--accent-emerald); }
181
+ .metric-value.rose { color: var(--accent-rose); }
182
+ .metric-value.blue { color: var(--accent-blue); }
183
+
184
+ /* Layout Sections */
185
+ .dashboard-content {
186
+ display: grid;
187
+ grid-template-columns: 340px 1fr;
188
+ gap: 1.5rem;
189
+ }
190
+
191
+ @media (max-width: 1024px) {
192
+ .dashboard-content { grid-template-columns: 1fr; }
193
+ }
194
+
195
+ /* Sidebar Panels */
196
+ .panel {
197
+ background: var(--bg-card);
198
+ border: 1px solid var(--border);
199
+ border-radius: 12px;
200
+ padding: 1.25rem;
201
+ display: flex;
202
+ flex-direction: column;
203
+ gap: 1.25rem;
204
+ }
205
+
206
+ .panel-header {
207
+ display: flex;
208
+ align-items: center;
209
+ justify-content: space-between;
210
+ border-bottom: 1px solid var(--border);
211
+ padding-bottom: 0.75rem;
212
+ }
213
+
214
+ .panel-title {
215
+ font-size: 0.95rem;
216
+ font-weight: 700;
217
+ display: flex;
218
+ align-items: center;
219
+ gap: 0.5rem;
220
+ }
221
+
222
+ .form-group {
223
+ display: flex;
224
+ flex-direction: column;
225
+ gap: 0.4rem;
226
+ }
227
+
228
+ .form-group label {
229
+ font-size: 0.78rem;
230
+ font-weight: 600;
231
+ color: var(--text-muted);
232
+ }
233
+
234
+ .form-control {
235
+ background: #090d16;
236
+ border: 1px solid var(--border);
237
+ border-radius: 8px;
238
+ padding: 0.55rem 0.75rem;
239
+ color: var(--text-main);
240
+ font-family: var(--font-sans);
241
+ font-size: 0.85rem;
242
+ outline: none;
243
+ transition: border-color 0.15s;
244
+ }
245
+ .form-control:focus {
246
+ border-color: var(--border-focus);
247
+ }
248
+
249
+ .endpoint-item {
250
+ background: #090d16;
251
+ border: 1px solid var(--border);
252
+ border-radius: 8px;
253
+ padding: 0.85rem;
254
+ display: flex;
255
+ flex-direction: column;
256
+ gap: 0.4rem;
257
+ }
258
+
259
+ .endpoint-name {
260
+ font-weight: 700;
261
+ font-size: 0.9rem;
262
+ }
263
+
264
+ .endpoint-url {
265
+ font-family: var(--font-mono);
266
+ font-size: 0.75rem;
267
+ color: var(--accent-blue);
268
+ word-break: break-all;
269
+ background: rgba(59, 130, 246, 0.08);
270
+ padding: 0.35rem 0.5rem;
271
+ border-radius: 6px;
272
+ }
273
+
274
+ .target-url {
275
+ font-size: 0.75rem;
276
+ color: var(--text-muted);
277
+ word-break: break-all;
278
+ }
279
+
280
+ /* Event Stream Table */
281
+ .stream-container {
282
+ background: var(--bg-card);
283
+ border: 1px solid var(--border);
284
+ border-radius: 12px;
285
+ overflow: hidden;
286
+ display: flex;
287
+ flex-direction: column;
288
+ }
289
+
290
+ .stream-toolbar {
291
+ padding: 1rem 1.25rem;
292
+ display: flex;
293
+ align-items: center;
294
+ justify-content: space-between;
295
+ border-bottom: 1px solid var(--border);
296
+ gap: 1rem;
297
+ flex-wrap: wrap;
298
+ }
299
+
300
+ .stream-filter-tabs {
301
+ display: flex;
302
+ gap: 0.5rem;
303
+ }
304
+
305
+ .tab-btn {
306
+ background: transparent;
307
+ border: 1px solid var(--border);
308
+ color: var(--text-muted);
309
+ padding: 0.4rem 0.85rem;
310
+ border-radius: 6px;
311
+ font-size: 0.8rem;
312
+ font-weight: 600;
313
+ cursor: pointer;
314
+ transition: all 0.15s;
315
+ }
316
+
317
+ .tab-btn.active {
318
+ background: rgba(59, 130, 246, 0.15);
319
+ border-color: var(--accent-blue);
320
+ color: var(--text-main);
321
+ }
322
+
323
+ .table-responsive {
324
+ overflow-x: auto;
325
+ }
326
+
327
+ table {
328
+ width: 100%;
329
+ border-collapse: collapse;
330
+ text-align: left;
331
+ font-size: 0.85rem;
332
+ }
333
+
334
+ th {
335
+ background: #090d16;
336
+ color: var(--text-muted);
337
+ font-weight: 600;
338
+ font-size: 0.75rem;
339
+ text-transform: uppercase;
340
+ letter-spacing: 0.05em;
341
+ padding: 0.75rem 1rem;
342
+ border-bottom: 1px solid var(--border);
343
+ }
344
+
345
+ td {
346
+ padding: 0.85rem 1rem;
347
+ border-bottom: 1px solid var(--border);
348
+ vertical-align: middle;
349
+ }
350
+
351
+ tr:hover td {
352
+ background: rgba(30, 41, 59, 0.4);
353
+ }
354
+
355
+ /* Badges */
356
+ .badge {
357
+ display: inline-flex;
358
+ align-items: center;
359
+ gap: 0.35rem;
360
+ font-size: 0.75rem;
361
+ font-weight: 700;
362
+ padding: 0.2rem 0.55rem;
363
+ border-radius: 9999px;
364
+ text-transform: uppercase;
365
+ }
366
+
367
+ .badge-delivered {
368
+ background: rgba(16, 185, 129, 0.15);
369
+ color: var(--accent-emerald);
370
+ border: 1px solid rgba(16, 185, 129, 0.3);
371
+ }
372
+
373
+ .badge-failed {
374
+ background: rgba(244, 63, 94, 0.15);
375
+ color: var(--accent-rose);
376
+ border: 1px solid rgba(244, 63, 94, 0.3);
377
+ }
378
+
379
+ .badge-pending {
380
+ background: rgba(245, 158, 11, 0.15);
381
+ color: var(--accent-amber);
382
+ border: 1px solid rgba(245, 158, 11, 0.3);
383
+ }
384
+
385
+ .badge-provider {
386
+ font-family: var(--font-mono);
387
+ font-size: 0.72rem;
388
+ padding: 0.15rem 0.45rem;
389
+ border-radius: 4px;
390
+ font-weight: 600;
391
+ background: #1e293b;
392
+ color: #cbd5e1;
393
+ }
394
+ .badge-provider.stripe { background: rgba(99, 102, 241, 0.2); color: #818cf8; }
395
+ .badge-provider.shopify { background: rgba(16, 185, 129, 0.2); color: #34d399; }
396
+ .badge-provider.github { background: rgba(255, 255, 255, 0.1); color: #f1f5f9; }
397
+
398
+ .code-mono {
399
+ font-family: var(--font-mono);
400
+ font-size: 0.8rem;
401
+ }
402
+
403
+ /* Modal / Inspector Drawer */
404
+ .modal-backdrop {
405
+ display: none;
406
+ position: fixed;
407
+ inset: 0;
408
+ background: rgba(0, 0, 0, 0.75);
409
+ backdrop-filter: blur(4px);
410
+ z-index: 100;
411
+ align-items: center;
412
+ justify-content: center;
413
+ padding: 1.5rem;
414
+ }
415
+
416
+ .modal {
417
+ background: var(--bg-card);
418
+ border: 1px solid var(--border);
419
+ border-radius: 16px;
420
+ width: 100%;
421
+ max-width: 800px;
422
+ max-height: 90vh;
423
+ display: flex;
424
+ flex-direction: column;
425
+ overflow: hidden;
426
+ box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
427
+ }
428
+
429
+ .modal-header {
430
+ padding: 1.25rem 1.5rem;
431
+ border-bottom: 1px solid var(--border);
432
+ display: flex;
433
+ justify-content: space-between;
434
+ align-items: center;
435
+ }
436
+
437
+ .modal-body {
438
+ padding: 1.5rem;
439
+ overflow-y: auto;
440
+ display: flex;
441
+ flex-direction: column;
442
+ gap: 1.25rem;
443
+ }
444
+
445
+ .code-block {
446
+ background: #090d16;
447
+ border: 1px solid var(--border);
448
+ border-radius: 8px;
449
+ padding: 1rem;
450
+ font-family: var(--font-mono);
451
+ font-size: 0.8rem;
452
+ overflow-x: auto;
453
+ max-height: 250px;
454
+ white-space: pre-wrap;
455
+ color: #cbd5e1;
456
+ }
457
+ </style>
458
+ <meta property="og:image" content="https://hookarmour.dev/og-preview.png">
459
+ <meta property="og:image:width" content="1200">
460
+ <meta property="og:image:height" content="630">
461
+ <meta name="twitter:image" content="https://hookarmour.dev/og-preview.png">
462
+ </head>
463
+ <body>
464
+
465
+ <header>
466
+ <a href="/" class="brand">
467
+ <div class="brand-icon">🛡️</div>
468
+ <div class="brand-text">
469
+ <h1>HookArmor <span class="brand-tag">v1.0 Ready</span></h1>
470
+ </div>
471
+ </a>
472
+ <div class="nav-actions">
473
+ <div id="admin-badge-area" style="display: flex; align-items: center; gap: 0.5rem;"></div>
474
+ <button class="btn btn-secondary btn-sm" onclick="openSimulatorModal()">⚡ Test Simulator</button>
475
+ <button class="btn btn-danger btn-sm" id="replay-all-btn" onclick="replayAllFailed()">🔄 Replay All Dead-Letter</button>
476
+ </div>
477
+ </header>
478
+
479
+ <main>
480
+ <!-- Top Metrics -->
481
+ <div class="metric-grid">
482
+ <div class="metric-card">
483
+ <span class="metric-title">Total Ingested</span>
484
+ <span class="metric-value blue" id="metric-total">0</span>
485
+ </div>
486
+ <div class="metric-card">
487
+ <span class="metric-title">Delivered (2xx)</span>
488
+ <span class="metric-value emerald" id="metric-delivered">0</span>
489
+ </div>
490
+ <div class="metric-card">
491
+ <span class="metric-title">Dead-Letter Queue</span>
492
+ <span class="metric-value rose" id="metric-failed">0</span>
493
+ </div>
494
+ <div class="metric-card">
495
+ <span class="metric-title">Avg Latency</span>
496
+ <span class="metric-value" id="metric-latency">0 ms</span>
497
+ </div>
498
+ </div>
499
+
500
+ <!-- Main Grid -->
501
+ <div class="dashboard-content">
502
+ <!-- Left: Endpoints & Simulator Controls -->
503
+ <div style="display: flex; flex-direction: column; gap: 1.5rem;">
504
+ <div class="panel">
505
+ <div class="panel-header">
506
+ <span class="panel-title">🛡️ Registered Endpoints</span>
507
+ <button class="btn btn-secondary btn-sm" onclick="toggleNewEndpointForm()">+ New</button>
508
+ </div>
509
+
510
+ <div id="new-endpoint-form" style="display: none; flex-direction: column; gap: 0.75rem; background: #090d16; padding: 1rem; border-radius: 8px; border: 1px solid var(--border);">
511
+ <div class="form-group">
512
+ <label>Endpoint ID (slug)</label>
513
+ <input type="text" id="inp-ep-id" class="form-control" placeholder="stripe-production">
514
+ </div>
515
+ <div class="form-group">
516
+ <label>Display Name</label>
517
+ <input type="text" id="inp-ep-name" class="form-control" placeholder="Stripe Billing">
518
+ </div>
519
+ <div class="form-group">
520
+ <label>Target Destination URL</label>
521
+ <input type="text" id="inp-ep-target" class="form-control" placeholder="https://api.myco.com/webhooks/stripe">
522
+ </div>
523
+ <div class="form-group">
524
+ <label>Discord / Slack Alert Webhook</label>
525
+ <input type="text" id="inp-ep-alert" class="form-control" placeholder="https://discord.com/api/webhooks/...">
526
+ </div>
527
+ <button class="btn btn-primary btn-sm" onclick="saveEndpoint()">Save Endpoint</button>
528
+ </div>
529
+
530
+ <div id="endpoints-list" style="display: flex; flex-direction: column; gap: 0.75rem;">
531
+ <!-- Rendered by JS -->
532
+ </div>
533
+ </div>
534
+
535
+ <div class="panel">
536
+ <div class="panel-header">
537
+ <span class="panel-title">⚙️ Mock Target Harness</span>
538
+ </div>
539
+ <p style="font-size: 0.8rem; color: var(--text-muted);">
540
+ Test failure scenarios locally. Configure the built-in mock endpoint to simulate server crashes or timeouts.
541
+ </p>
542
+ <div class="form-group">
543
+ <label>Mock Response Code</label>
544
+ <select id="mock-status-select" class="form-control" onchange="updateMockConfig()">
545
+ <option value="200">200 OK (Normal processing)</option>
546
+ <option value="500">500 Internal Server Error (Server crash)</option>
547
+ <option value="502">502 Bad Gateway (Zero-downtime deploy reboot)</option>
548
+ <option value="504">504 Gateway Timeout (Database connection pool full)</option>
549
+ <option value="429">429 Too Many Requests (Rate limited)</option>
550
+ </select>
551
+ </div>
552
+ </div>
553
+ </div>
554
+
555
+ <!-- Right: Real-time Live Event Log -->
556
+ <div class="stream-container">
557
+ <div class="stream-toolbar">
558
+ <div class="stream-filter-tabs">
559
+ <button class="tab-btn active" onclick="setFilter(null, this)">All Events</button>
560
+ <button class="tab-btn" onclick="setFilter('failed', this)">Dead-Letter (Failed)</button>
561
+ <button class="tab-btn" onclick="setFilter('delivered', this)">Delivered</button>
562
+ </div>
563
+ <div style="font-size: 0.8rem; color: var(--text-muted);" id="live-indicator">
564
+ 🟢 Live Ingestion Connected
565
+ </div>
566
+ </div>
567
+
568
+ <div class="table-responsive">
569
+ <table>
570
+ <thead>
571
+ <tr>
572
+ <th>Status</th>
573
+ <th>Provider / Event</th>
574
+ <th>Endpoint</th>
575
+ <th>Latency</th>
576
+ <th>Attempts</th>
577
+ <th>Time</th>
578
+ <th>Action</th>
579
+ </tr>
580
+ </thead>
581
+ <tbody id="events-table-body">
582
+ <tr>
583
+ <td colspan="7" style="text-align: center; color: var(--text-muted); padding: 3rem;">
584
+ No webhook events received yet. Click <strong>Test Simulator</strong> above to send a mock event!
585
+ </td>
586
+ </tr>
587
+ </tbody>
588
+ </table>
589
+ </div>
590
+ </div>
591
+ </div>
592
+ </main>
593
+
594
+ <!-- Modal Inspector Drawer -->
595
+ <div class="modal-backdrop" id="inspector-modal">
596
+ <div class="modal">
597
+ <div class="modal-header">
598
+ <h3 id="modal-event-title">Event Inspection</h3>
599
+ <button class="btn btn-secondary btn-sm" onclick="closeInspectorModal()">✕</button>
600
+ </div>
601
+ <div class="modal-body" id="modal-event-body">
602
+ <!-- Filled by JS -->
603
+ </div>
604
+ </div>
605
+ </div>
606
+
607
+ <!-- Simulator Modal -->
608
+ <div class="modal-backdrop" id="simulator-modal">
609
+ <div class="modal">
610
+ <div class="modal-header">
611
+ <h3>⚡ Send Mock Webhook Test</h3>
612
+ <button class="btn btn-secondary btn-sm" onclick="closeSimulatorModal()">✕</button>
613
+ </div>
614
+ <div class="modal-body">
615
+ <div class="form-group">
616
+ <label>Select Target Endpoint</label>
617
+ <select id="sim-endpoint-select" class="form-control"></select>
618
+ </div>
619
+ <div class="form-group">
620
+ <label>Template</label>
621
+ <select id="sim-template-select" class="form-control" onchange="applySimTemplate()">
622
+ <option value="stripe_payment">Stripe: invoice.payment_succeeded ($199.00)</option>
623
+ <option value="stripe_sub_created">Stripe: customer.subscription.created</option>
624
+ <option value="shopify_order">Shopify: orders/create ($89.50)</option>
625
+ <option value="github_push">GitHub: push (refs/heads/main)</option>
626
+ </select>
627
+ </div>
628
+ <div class="form-group">
629
+ <label>Headers (JSON)</label>
630
+ <textarea id="sim-headers" class="form-control code-mono" style="height: 90px;"></textarea>
631
+ </div>
632
+ <div class="form-group">
633
+ <label>Payload (JSON)</label>
634
+ <textarea id="sim-payload" class="form-control code-mono" style="height: 140px;"></textarea>
635
+ </div>
636
+ <button class="btn btn-primary" onclick="executeSimulation()">🚀 Send Mock Webhook</button>
637
+ </div>
638
+ </div>
639
+ </div>
640
+
641
+ <!-- Beta Leads Admin Modal -->
642
+ <div class="modal-backdrop" id="leads-modal" style="display: none;">
643
+ <div class="modal" style="max-width: 780px;">
644
+ <div class="modal-header">
645
+ <h3>📋 Cloud Beta Waitlist Leads</h3>
646
+ <div style="display: flex; gap: 0.5rem; align-items: center;">
647
+ <button class="btn btn-primary btn-sm" onclick="exportLeadsCsv()">📥 Export CSV</button>
648
+ <button class="btn btn-secondary btn-sm" onclick="closeLeadsModal()">✕</button>
649
+ </div>
650
+ </div>
651
+ <div class="modal-body">
652
+ <p style="font-size: 0.85rem; color: var(--text-muted); margin-bottom: 1rem;">
653
+ Verified leads registered through the public Cloud Beta waitlist on hookarmour.dev (stored securely in SQLite WAL volume).
654
+ </p>
655
+ <div style="max-height: 420px; overflow-y: auto; border: 1px solid var(--border); border-radius: 8px;">
656
+ <table style="width: 100%; border-collapse: collapse;">
657
+ <thead>
658
+ <tr>
659
+ <th style="padding: 0.6rem 0.8rem; font-size: 0.75rem;">ID</th>
660
+ <th style="padding: 0.6rem 0.8rem; font-size: 0.75rem;">Email</th>
661
+ <th style="padding: 0.6rem 0.8rem; font-size: 0.75rem;">Source</th>
662
+ <th style="padding: 0.6rem 0.8rem; font-size: 0.75rem;">Registered At</th>
663
+ </tr>
664
+ </thead>
665
+ <tbody id="leads-table-body">
666
+ <!-- Rendered via JS -->
667
+ </tbody>
668
+ </table>
669
+ </div>
670
+ </div>
671
+ </div>
672
+ </div>
673
+
674
+ <script>
675
+ let currentFilter = null;
676
+ let cachedEndpoints = [];
677
+ let cachedLeads = [];
678
+ let ws = null;
679
+
680
+ function getApiKey() {
681
+ // Stored strictly in ephemeral sessionStorage (destroyed when tab is closed)
682
+ return sessionStorage.getItem('ha_admin_session') || '';
683
+ }
684
+
685
+ function getAuthHeaders(extraHeaders = {}) {
686
+ const key = getApiKey();
687
+ if (key) {
688
+ return { ...extraHeaders, 'Authorization': `Bearer ${key}` };
689
+ }
690
+ return extraHeaders;
691
+ }
692
+
693
+ function checkAdminAuth() {
694
+ // Strictly in-session auth only; never extract from or allow keys in URL
695
+ updateAdminUI();
696
+ }
697
+
698
+ async function updateAdminUI() {
699
+ const badgeArea = document.getElementById('admin-badge-area');
700
+ if (!badgeArea) return;
701
+ const key = getApiKey();
702
+ if (!key) {
703
+ badgeArea.innerHTML = `
704
+ <button class="btn btn-secondary btn-sm" onclick="promptApiKey()" style="opacity: 0.8; font-size: 0.75rem; padding: 0.35rem 0.6rem;">🔑 Admin Login</button>
705
+ `;
706
+ return;
707
+ }
708
+
709
+ try {
710
+ const res = await fetch('/api/waitlist', { headers: getAuthHeaders() });
711
+ if (res.ok) {
712
+ cachedLeads = await res.json();
713
+ badgeArea.innerHTML = `
714
+ <span class="badge" style="background: rgba(16, 185, 129, 0.15); color: #10b981; border: 1px solid rgba(16, 185, 129, 0.3); font-size: 0.72rem; padding: 0.2rem 0.5rem;">👑 Admin</span>
715
+ <button class="btn btn-outline btn-sm" onclick="openLeadsModal()" style="border-color: #3b82f6; color: #60a5fa; font-size: 0.75rem; padding: 0.35rem 0.65rem;">📋 Beta Leads (${cachedLeads.length})</button>
716
+ <button class="btn btn-secondary btn-sm" onclick="logoutAdmin()" title="End Admin Session" style="font-size: 0.75rem; padding: 0.35rem 0.5rem;">Lock</button>
717
+ `;
718
+ } else {
719
+ sessionStorage.removeItem('ha_admin_session');
720
+ badgeArea.innerHTML = `
721
+ <button class="btn btn-secondary btn-sm" onclick="promptApiKey()" style="opacity: 0.8; font-size: 0.75rem; padding: 0.35rem 0.6rem;">🔑 Admin Login</button>
722
+ `;
723
+ }
724
+ } catch (e) {
725
+ console.warn('Could not verify admin status', e);
726
+ }
727
+ }
728
+
729
+ function promptApiKey() {
730
+ const key = prompt('Enter HookArmor Admin Master Key (passed via secure header only):');
731
+ if (key && key.trim()) {
732
+ sessionStorage.setItem('ha_admin_session', key.trim());
733
+ updateAdminUI();
734
+ }
735
+ }
736
+
737
+ function logoutAdmin() {
738
+ sessionStorage.removeItem('ha_admin_session');
739
+ updateAdminUI();
740
+ }
741
+
742
+ async function openLeadsModal() {
743
+ const tbody = document.getElementById('leads-table-body');
744
+ if (!cachedLeads || cachedLeads.length === 0) {
745
+ const res = await fetch('/api/waitlist', { headers: getAuthHeaders() });
746
+ if (res.ok) cachedLeads = await res.json();
747
+ }
748
+ if (!cachedLeads || cachedLeads.length === 0) {
749
+ tbody.innerHTML = `<tr><td colspan="4" style="text-align: center; color: var(--text-muted); padding: 2rem;">No waitlist signups recorded yet.</td></tr>`;
750
+ } else {
751
+ tbody.innerHTML = cachedLeads.map(l => `
752
+ <tr>
753
+ <td style="padding: 0.6rem 0.8rem; font-family: var(--font-mono); font-size: 0.8rem;">#${l.id}</td>
754
+ <td style="padding: 0.6rem 0.8rem; font-weight: 600; color: #60a5fa;">${l.email}</td>
755
+ <td style="padding: 0.6rem 0.8rem; color: var(--text-muted); font-size: 0.8rem;">${l.source}</td>
756
+ <td style="padding: 0.6rem 0.8rem; color: var(--text-muted); font-size: 0.8rem;">${l.created_at}</td>
757
+ </tr>
758
+ `).join('');
759
+ }
760
+ document.getElementById('leads-modal').style.display = 'flex';
761
+ }
762
+
763
+ function closeLeadsModal() {
764
+ document.getElementById('leads-modal').style.display = 'none';
765
+ }
766
+
767
+ function exportLeadsCsv() {
768
+ if (!cachedLeads || cachedLeads.length === 0) {
769
+ alert('No leads to export.');
770
+ return;
771
+ }
772
+ const csvContent = 'id,email,source,created_at\n' + cachedLeads.map(l => `${l.id},"${l.email}","${l.source}","${l.created_at}"`).join('\n');
773
+ const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
774
+ const url = URL.createObjectURL(blob);
775
+ const link = document.createElement('a');
776
+ link.setAttribute('href', url);
777
+ link.setAttribute('download', `hookarmor_beta_leads_${new Date().toISOString().slice(0, 10)}.csv`);
778
+ document.body.appendChild(link);
779
+ link.click();
780
+ document.body.removeChild(link);
781
+ }
782
+
783
+ const templates = {
784
+ stripe_payment: {
785
+ headers: {
786
+ 'Content-Type': 'application/json',
787
+ 'stripe-signature': 't=1758513000,v1=9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c'
788
+ },
789
+ payload: {
790
+ id: 'evt_test_payment_123',
791
+ object: 'event',
792
+ type: 'invoice.payment_succeeded',
793
+ data: {
794
+ object: {
795
+ id: 'in_1MockPaymentSucceeded',
796
+ amount_paid: 19900,
797
+ currency: 'usd',
798
+ customer_email: 'alex.founder@startup.io',
799
+ status: 'paid'
800
+ }
801
+ }
802
+ }
803
+ },
804
+ stripe_sub_created: {
805
+ headers: {
806
+ 'Content-Type': 'application/json',
807
+ 'stripe-signature': 't=1758513000,v1=aa11bb22cc33dd44ee55ff6677889900'
808
+ },
809
+ payload: {
810
+ id: 'evt_test_sub_456',
811
+ object: 'event',
812
+ type: 'customer.subscription.created',
813
+ data: {
814
+ object: {
815
+ id: 'sub_ProAnnual123',
816
+ plan: { id: 'pro_annual', amount: 99000 },
817
+ customer: 'cus_Alex101',
818
+ status: 'active'
819
+ }
820
+ }
821
+ }
822
+ },
823
+ shopify_order: {
824
+ headers: {
825
+ 'Content-Type': 'application/json',
826
+ 'x-shopify-topic': 'orders/create',
827
+ 'x-shopify-hmac-sha256': 'dGVzdF9zaG9waWZ5X2htYWNfc2hhMjU2'
828
+ },
829
+ payload: {
830
+ id: 82098291194,
831
+ email: 'customer@buyer.com',
832
+ total_price: '89.50',
833
+ currency: 'USD',
834
+ financial_status: 'paid'
835
+ }
836
+ },
837
+ github_push: {
838
+ headers: {
839
+ 'Content-Type': 'application/json',
840
+ 'x-github-event': 'push'
841
+ },
842
+ payload: {
843
+ ref: 'refs/heads/main',
844
+ repository: { full_name: 'acme/backend' },
845
+ pusher: { name: 'devops-bot' }
846
+ }
847
+ }
848
+ };
849
+
850
+ async function loadStats() {
851
+ try {
852
+ const res = await fetch('/api/stats', { headers: getAuthHeaders() });
853
+ const data = await res.json();
854
+ document.getElementById('metric-total').textContent = data.total;
855
+ document.getElementById('metric-delivered').textContent = data.delivered;
856
+ document.getElementById('metric-failed').textContent = data.failed;
857
+ document.getElementById('metric-latency').textContent = `${data.avgLatencyMs} ms`;
858
+ } catch (e) {
859
+ console.error(e);
860
+ }
861
+ }
862
+
863
+ async function loadEndpoints() {
864
+ try {
865
+ const res = await fetch('/api/endpoints', { headers: getAuthHeaders() });
866
+ cachedEndpoints = await res.json();
867
+ const container = document.getElementById('endpoints-list');
868
+ const simSelect = document.getElementById('sim-endpoint-select');
869
+
870
+ if (cachedEndpoints.length === 0) {
871
+ container.innerHTML = `<div style="font-size: 0.8rem; color: var(--text-muted);">No endpoints yet. Click "+ New" above to create one.</div>`;
872
+ simSelect.innerHTML = `<option value="">No endpoints</option>`;
873
+ return;
874
+ }
875
+
876
+ container.innerHTML = cachedEndpoints.map(ep => `
877
+ <div class="endpoint-item">
878
+ <div class="endpoint-name">${ep.name}</div>
879
+ <div class="endpoint-url" title="Ingress URL">${window.location.origin}/in/${ep.id}</div>
880
+ <div class="target-url">🎯 <strong>Target:</strong> ${ep.target_url}</div>
881
+ <div style="font-size: 0.72rem; color: var(--text-muted); display: flex; gap: 0.75rem;">
882
+ <span>Auto-Retry: <strong>${ep.auto_retry ? 'Yes' : 'No'}</strong></span>
883
+ <span>Max: <strong>${ep.max_retries} attempts</strong></span>
884
+ </div>
885
+ </div>
886
+ `).join('');
887
+
888
+ simSelect.innerHTML = cachedEndpoints.map(ep => `
889
+ <option value="${ep.id}">${ep.name} (/in/${ep.id})</option>
890
+ `).join('');
891
+ } catch (e) {
892
+ console.error(e);
893
+ }
894
+ }
895
+
896
+ async function loadEvents() {
897
+ try {
898
+ let url = '/api/events?limit=50';
899
+ if (currentFilter) url += `&status=${currentFilter}`;
900
+ const res = await fetch(url, { headers: getAuthHeaders() });
901
+ const events = await res.json();
902
+ const tbody = document.getElementById('events-table-body');
903
+
904
+ if (events.length === 0) {
905
+ tbody.innerHTML = `
906
+ <tr>
907
+ <td colspan="7" style="text-align: center; color: var(--text-muted); padding: 3rem;">
908
+ No matching events found in Dead-Letter Queue.
909
+ </td>
910
+ </tr>
911
+ `;
912
+ return;
913
+ }
914
+
915
+ tbody.innerHTML = events.map(evt => {
916
+ const badgeClass = evt.status === 'delivered' ? 'badge-delivered' : (evt.status === 'failed' ? 'badge-failed' : 'badge-pending');
917
+ const time = new Date(evt.created_at).toLocaleTimeString();
918
+ const latency = evt.last_latency_ms ? `${evt.last_latency_ms}ms` : '-';
919
+ const providerClass = evt.provider.includes('stripe') ? 'stripe' : (evt.provider.includes('shopify') ? 'shopify' : (evt.provider.includes('github') ? 'github' : ''));
920
+
921
+ return `
922
+ <tr>
923
+ <td><span class="badge ${badgeClass}">${evt.status}</span></td>
924
+ <td>
925
+ <span class="badge-provider ${providerClass}">${evt.provider}</span>
926
+ <span style="font-weight: 600; margin-left: 0.35rem;">${evt.event_type || evt.id}</span>
927
+ </td>
928
+ <td><span class="code-mono" style="color: var(--text-muted);">${evt.endpoint_id}</span></td>
929
+ <td><span class="code-mono">${latency}</span></td>
930
+ <td><span class="code-mono">${evt.attempts}</span></td>
931
+ <td style="color: var(--text-muted);">${time}</td>
932
+ <td style="display: flex; gap: 0.5rem;">
933
+ <button class="btn btn-secondary btn-sm" onclick="inspectEvent('${evt.id}')">Inspect</button>
934
+ <button class="btn btn-primary btn-sm" onclick="replayEvent('${evt.id}')">Replay</button>
935
+ </td>
936
+ </tr>
937
+ `;
938
+ }).join('');
939
+ } catch (e) {
940
+ console.error(e);
941
+ }
942
+ }
943
+
944
+ function setFilter(status, btn) {
945
+ currentFilter = status;
946
+ document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
947
+ btn.classList.add('active');
948
+ loadEvents();
949
+ }
950
+
951
+ function toggleNewEndpointForm() {
952
+ const f = document.getElementById('new-endpoint-form');
953
+ f.style.display = f.style.display === 'none' ? 'flex' : 'none';
954
+ }
955
+
956
+ async function saveEndpoint() {
957
+ const id = document.getElementById('inp-ep-id').value.trim();
958
+ const name = document.getElementById('inp-ep-name').value.trim();
959
+ const targetUrl = document.getElementById('inp-ep-target').value.trim();
960
+ const alertWebhookUrl = document.getElementById('inp-ep-alert').value.trim();
961
+
962
+ if (!id || !name || !targetUrl) {
963
+ alert('Please fill out ID, Name, and Target URL');
964
+ return;
965
+ }
966
+
967
+ const res = await fetch('/api/endpoints', {
968
+ method: 'POST',
969
+ headers: getAuthHeaders({ 'Content-Type': 'application/json' }),
970
+ body: JSON.stringify({ id, name, targetUrl, alertWebhookUrl, autoRetry: 1, maxRetries: 5 })
971
+ });
972
+
973
+ if (res.status === 401) {
974
+ alert('Admin authorization required to create endpoints. Please click "Admin Login" in top right.');
975
+ return;
976
+ }
977
+
978
+ toggleNewEndpointForm();
979
+ await loadEndpoints();
980
+ }
981
+
982
+ async function replayEvent(id) {
983
+ try {
984
+ const res = await fetch(`/api/events/${id}/replay`, { method: 'POST', headers: getAuthHeaders() });
985
+ const data = await res.json();
986
+ if (data.success) {
987
+ alert(`✅ Event ${id} successfully replayed! (HTTP ${data.statusCode})`);
988
+ } else {
989
+ alert(`❌ Replay attempted: Destination returned HTTP ${data.statusCode} (${data.errorMessage || 'Failed'})`);
990
+ }
991
+ await loadEvents();
992
+ await loadStats();
993
+ } catch (e) {
994
+ alert('Replay failed: ' + e.message);
995
+ }
996
+ }
997
+
998
+ async function replayAllFailed() {
999
+ if (!confirm('Replay all failed Dead-Letter Queue events now?')) return;
1000
+ try {
1001
+ const res = await fetch('/api/events/replay-all', { method: 'POST', headers: getAuthHeaders({ 'Content-Type': 'application/json' }) });
1002
+ const data = await res.json();
1003
+ alert(`🔄 Dispatched replay for ${data.replayedCount} dead-letter events.`);
1004
+ await loadEvents();
1005
+ await loadStats();
1006
+ } catch (e) {
1007
+ alert('Bulk replay error: ' + e.message);
1008
+ }
1009
+ }
1010
+
1011
+ async function inspectEvent(id) {
1012
+ try {
1013
+ const res = await fetch(`/api/events/${id}`, { headers: getAuthHeaders() });
1014
+ const evt = await res.json();
1015
+ document.getElementById('modal-event-title').textContent = `Inspection: ${evt.id}`;
1016
+
1017
+ let attemptsHtml = '<p style="color: var(--text-muted); font-size: 0.85rem;">No attempts recorded.</p>';
1018
+ if (evt.attempts && evt.attempts.length > 0) {
1019
+ attemptsHtml = evt.attempts.map((att, i) => `
1020
+ <div style="background: #090d16; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border); font-size: 0.8rem;">
1021
+ <div style="display: flex; justify-content: space-between; margin-bottom: 0.35rem;">
1022
+ <strong>Attempt #${evt.attempts.length - i}</strong>
1023
+ <span class="badge ${att.status_code >= 200 && att.status_code < 300 ? 'badge-delivered' : 'badge-failed'}">
1024
+ HTTP ${att.status_code || 'Err'} (${att.latency_ms}ms)
1025
+ </span>
1026
+ </div>
1027
+ <div style="color: var(--text-muted);">${att.attempted_at}</div>
1028
+ ${att.error_message ? `<div style="color: var(--accent-rose); margin-top: 0.25rem;">${att.error_message}</div>` : ''}
1029
+ ${att.response_body ? `<div class="code-block" style="margin-top: 0.5rem; max-height: 120px;">${att.response_body}</div>` : ''}
1030
+ </div>
1031
+ `).join('');
1032
+ }
1033
+
1034
+ document.getElementById('modal-event-body').innerHTML = `
1035
+ <div style="display: flex; gap: 1rem; align-items: center;">
1036
+ <span class="badge ${evt.status === 'delivered' ? 'badge-delivered' : 'badge-failed'}">${evt.status}</span>
1037
+ <span style="font-weight: 700;">${evt.event_type || 'Custom Payload'}</span>
1038
+ <span style="color: var(--text-muted); font-size: 0.8rem;">Received: ${evt.created_at}</span>
1039
+ </div>
1040
+
1041
+ <div>
1042
+ <h4 style="font-size: 0.85rem; margin-bottom: 0.4rem; color: var(--text-muted);">Incoming Headers</h4>
1043
+ <div class="code-block">${JSON.stringify(evt.headers, null, 2)}</div>
1044
+ </div>
1045
+
1046
+ <div>
1047
+ <h4 style="font-size: 0.85rem; margin-bottom: 0.4rem; color: var(--text-muted);">Raw Body Payload</h4>
1048
+ <div class="code-block">${evt.raw_body}</div>
1049
+ </div>
1050
+
1051
+ <div>
1052
+ <h4 style="font-size: 0.85rem; margin-bottom: 0.4rem; color: var(--text-muted);">Delivery Attempt History</h4>
1053
+ <div style="display: flex; flex-direction: column; gap: 0.5rem;">${attemptsHtml}</div>
1054
+ </div>
1055
+
1056
+ <div style="display: flex; justify-content: flex-end; gap: 0.75rem;">
1057
+ <button class="btn btn-secondary" onclick="closeInspectorModal()">Close</button>
1058
+ <button class="btn btn-primary" onclick="replayEvent('${evt.id}')">Replay Event</button>
1059
+ </div>
1060
+ `;
1061
+ document.getElementById('inspector-modal').style.display = 'flex';
1062
+ } catch (e) {
1063
+ alert('Inspection error: ' + e.message);
1064
+ }
1065
+ }
1066
+
1067
+ function closeInspectorModal() {
1068
+ document.getElementById('inspector-modal').style.display = 'none';
1069
+ }
1070
+
1071
+ function openSimulatorModal() {
1072
+ applySimTemplate();
1073
+ document.getElementById('simulator-modal').style.display = 'flex';
1074
+ }
1075
+
1076
+ function closeSimulatorModal() {
1077
+ document.getElementById('simulator-modal').style.display = 'none';
1078
+ }
1079
+
1080
+ function applySimTemplate() {
1081
+ const tmplKey = document.getElementById('sim-template-select').value;
1082
+ const tmpl = templates[tmplKey];
1083
+ if (tmpl) {
1084
+ document.getElementById('sim-headers').value = JSON.stringify(tmpl.headers, null, 2);
1085
+ document.getElementById('sim-payload').value = JSON.stringify(tmpl.payload, null, 2);
1086
+ }
1087
+ }
1088
+
1089
+ async function executeSimulation() {
1090
+ const epId = document.getElementById('sim-endpoint-select').value;
1091
+ if (!epId) {
1092
+ alert('No endpoint selected!');
1093
+ return;
1094
+ }
1095
+
1096
+ let headers = {};
1097
+ try {
1098
+ headers = JSON.parse(document.getElementById('sim-headers').value);
1099
+ } catch (e) {
1100
+ alert('Invalid JSON headers');
1101
+ return;
1102
+ }
1103
+
1104
+ const payloadStr = document.getElementById('sim-payload').value;
1105
+
1106
+ try {
1107
+ const res = await fetch(`/in/${epId}`, {
1108
+ method: 'POST',
1109
+ headers,
1110
+ body: payloadStr
1111
+ });
1112
+ const data = await res.json();
1113
+ closeSimulatorModal();
1114
+ setTimeout(async () => {
1115
+ await loadStats();
1116
+ await loadEvents();
1117
+ }, 300);
1118
+ } catch (e) {
1119
+ alert('Simulation failed: ' + e.message);
1120
+ }
1121
+ }
1122
+
1123
+ async function updateMockConfig() {
1124
+ const statusCode = parseInt(document.getElementById('mock-status-select').value, 10);
1125
+ await fetch('/mock/config', {
1126
+ method: 'POST',
1127
+ headers: { 'Content-Type': 'application/json' },
1128
+ body: JSON.stringify({ statusCode })
1129
+ });
1130
+ }
1131
+
1132
+ function initWebSocket() {
1133
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
1134
+ ws = new WebSocket(`${protocol}//${window.location.host}/ws`);
1135
+ ws.onmessage = (e) => {
1136
+ const msg = JSON.parse(e.data);
1137
+ if (msg.type === 'event:received' || msg.type === 'delivery') {
1138
+ loadStats();
1139
+ loadEvents();
1140
+ }
1141
+ };
1142
+ ws.onclose = () => {
1143
+ setTimeout(initWebSocket, 2000);
1144
+ };
1145
+ }
1146
+
1147
+ // Startup
1148
+ window.addEventListener('DOMContentLoaded', async () => {
1149
+ checkAdminAuth();
1150
+ await loadEndpoints();
1151
+ await loadStats();
1152
+ await loadEvents();
1153
+ initWebSocket();
1154
+ setInterval(loadStats, 10000);
1155
+ });
1156
+ </script>
1157
+ </body>
1158
+ </html>