hookarmor 1.0.3 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +303 -260
- package/bin/hookarmor.js +168 -133
- package/package.json +52 -52
- package/src/alerter.js +72 -63
- package/src/dispatcher.js +409 -356
- package/src/netguard.js +117 -0
- package/src/public/index.html +96 -40
- package/src/server.js +544 -393
- package/src/storage.js +337 -266
- package/src/worker.js +72 -62
package/src/netguard.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
const net = require('net');
|
|
2
|
+
const dns = require('dns');
|
|
3
|
+
|
|
4
|
+
// Outbound address policy for delivery targets and alert webhooks.
|
|
5
|
+
// 'metadata' addresses are always blocked; 'private' ones only in strict mode.
|
|
6
|
+
|
|
7
|
+
function parseIPv4(ip) {
|
|
8
|
+
const parts = ip.split('.');
|
|
9
|
+
if (parts.length !== 4) return null;
|
|
10
|
+
const octets = parts.map(Number);
|
|
11
|
+
return octets.every((o) => Number.isInteger(o) && o >= 0 && o <= 255) ? octets : null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Expand an IPv6 address (with optional embedded IPv4 tail) into 8 16-bit groups
|
|
15
|
+
function parseIPv6(ip) {
|
|
16
|
+
let addr = ip.replace(/^\[|\]$/g, '').split('%')[0];
|
|
17
|
+
const v4Tail = addr.match(/(\d+\.\d+\.\d+\.\d+)$/);
|
|
18
|
+
if (v4Tail) {
|
|
19
|
+
const o = parseIPv4(v4Tail[1]);
|
|
20
|
+
if (!o) return null;
|
|
21
|
+
addr = addr.slice(0, -v4Tail[1].length) + `${((o[0] << 8) | o[1]).toString(16)}:${((o[2] << 8) | o[3]).toString(16)}`;
|
|
22
|
+
}
|
|
23
|
+
const halves = addr.split('::');
|
|
24
|
+
if (halves.length > 2) return null;
|
|
25
|
+
const head = halves[0] ? halves[0].split(':') : [];
|
|
26
|
+
const tail = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
|
|
27
|
+
const fill = halves.length === 2 ? 8 - head.length - tail.length : 0;
|
|
28
|
+
const groups = [...head, ...Array(fill).fill('0'), ...tail].map((g) => parseInt(g, 16));
|
|
29
|
+
return groups.length === 8 && groups.every((g) => g >= 0 && g <= 0xffff) ? groups : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function classifyIPv4([a, b, c, d]) {
|
|
33
|
+
if (a === 169 && b === 254) return 'metadata'; // link-local, incl. AWS/GCP/Azure metadata
|
|
34
|
+
if (a === 100 && b === 100 && c === 100 && d === 200) return 'metadata'; // Alibaba metadata
|
|
35
|
+
if (
|
|
36
|
+
a === 0 || a === 10 || a === 127 ||
|
|
37
|
+
(a === 172 && b >= 16 && b <= 31) ||
|
|
38
|
+
(a === 192 && b === 168) ||
|
|
39
|
+
(a === 100 && b >= 64 && b <= 127) || // carrier-grade NAT
|
|
40
|
+
(a === 198 && (b === 18 || b === 19)) ||
|
|
41
|
+
a >= 224 // multicast, reserved, broadcast
|
|
42
|
+
) return 'private';
|
|
43
|
+
return 'public';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function classifyIp(ip) {
|
|
47
|
+
const host = ip.replace(/^\[|\]$/g, '');
|
|
48
|
+
if (net.isIPv4(host)) return classifyIPv4(parseIPv4(host));
|
|
49
|
+
if (!net.isIPv6(host)) return 'invalid';
|
|
50
|
+
const g = parseIPv6(host);
|
|
51
|
+
if (!g) return 'invalid';
|
|
52
|
+
const v4 = [g[6] >> 8, g[6] & 0xff, g[7] >> 8, g[7] & 0xff];
|
|
53
|
+
// IPv4-mapped (::ffff:0:0/96) and NAT64 (64:ff9b::/96) carry an IPv4 address
|
|
54
|
+
if (g.slice(0, 5).every((x) => x === 0) && g[5] === 0xffff) return classifyIPv4(v4);
|
|
55
|
+
if (g[0] === 0x64 && g[1] === 0xff9b && g.slice(2, 6).every((x) => x === 0)) return classifyIPv4(v4);
|
|
56
|
+
if (g[0] === 0xfd00 && g[1] === 0x0ec2 && g.slice(2, 7).every((x) => x === 0) && g[7] === 0x254) return 'metadata'; // AWS IMDS IPv6
|
|
57
|
+
if (g.every((x) => x === 0)) return 'private'; // ::
|
|
58
|
+
if (g.slice(0, 7).every((x) => x === 0) && g[7] === 1) return 'private'; // ::1
|
|
59
|
+
if ((g[0] & 0xfe00) === 0xfc00) return 'private'; // fc00::/7 unique local
|
|
60
|
+
if ((g[0] & 0xffc0) === 0xfe80) return 'private'; // fe80::/10 link-local
|
|
61
|
+
if ((g[0] & 0xff00) === 0xff00) return 'private'; // multicast
|
|
62
|
+
return 'public';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isBlockedIp(ip, strict) {
|
|
66
|
+
const cls = classifyIp(ip);
|
|
67
|
+
return cls === 'invalid' || cls === 'metadata' || (strict && cls === 'private');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Synchronous check of the URL itself (scheme, literal IPs, well-known internal names).
|
|
71
|
+
// Hostnames are re-checked after DNS resolution at request time via guardedLookup().
|
|
72
|
+
function checkUrl(urlString, strict = false) {
|
|
73
|
+
let parsed;
|
|
74
|
+
try {
|
|
75
|
+
parsed = new URL(urlString);
|
|
76
|
+
} catch (e) {
|
|
77
|
+
return { ok: false, reason: 'Invalid URL' };
|
|
78
|
+
}
|
|
79
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
80
|
+
return { ok: false, reason: 'Only http and https URLs are allowed' };
|
|
81
|
+
}
|
|
82
|
+
const host = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
83
|
+
if (net.isIP(host)) {
|
|
84
|
+
return isBlockedIp(host, strict) ? { ok: false, reason: `Address ${host} is not allowed` } : { ok: true };
|
|
85
|
+
}
|
|
86
|
+
if (host === 'metadata.google.internal' || host === 'metadata') {
|
|
87
|
+
return { ok: false, reason: `Host ${host} is not allowed` };
|
|
88
|
+
}
|
|
89
|
+
if (strict && (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.internal') || host.endsWith('.local'))) {
|
|
90
|
+
return { ok: false, reason: `Host ${host} is not allowed` };
|
|
91
|
+
}
|
|
92
|
+
return { ok: true };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// DNS lookup for http.request that refuses blocked addresses, so a public hostname
|
|
96
|
+
// cannot resolve (or re-resolve) to an internal address at delivery time.
|
|
97
|
+
function guardedLookup(strict = false) {
|
|
98
|
+
return (hostname, options, callback) => {
|
|
99
|
+
if (typeof options === 'function') {
|
|
100
|
+
callback = options;
|
|
101
|
+
options = {};
|
|
102
|
+
}
|
|
103
|
+
dns.lookup(hostname, options, (err, address, family) => {
|
|
104
|
+
if (err) return callback(err);
|
|
105
|
+
const list = Array.isArray(address) ? address : [{ address, family }];
|
|
106
|
+
const blocked = list.find((a) => isBlockedIp(a.address, strict));
|
|
107
|
+
if (blocked) {
|
|
108
|
+
const e = new Error(`Destination ${hostname} resolves to blocked address ${blocked.address}`);
|
|
109
|
+
e.code = 'EBLOCKEDADDRESS';
|
|
110
|
+
return callback(e);
|
|
111
|
+
}
|
|
112
|
+
callback(null, address, family);
|
|
113
|
+
});
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = { checkUrl, guardedLookup, isBlockedIp, classifyIp };
|
package/src/public/index.html
CHANGED
|
@@ -524,6 +524,10 @@
|
|
|
524
524
|
<label>Discord / Slack Alert Webhook</label>
|
|
525
525
|
<input type="text" id="inp-ep-alert" class="form-control" placeholder="https://discord.com/api/webhooks/...">
|
|
526
526
|
</div>
|
|
527
|
+
<div class="form-group">
|
|
528
|
+
<label>Provider Signing Secret (optional; leave blank to keep the current one)</label>
|
|
529
|
+
<input type="password" id="inp-ep-secret" class="form-control" placeholder="whsec_..." autocomplete="off">
|
|
530
|
+
</div>
|
|
527
531
|
<button class="btn btn-primary btn-sm" onclick="saveEndpoint()">Save Endpoint</button>
|
|
528
532
|
</div>
|
|
529
533
|
|
|
@@ -677,6 +681,23 @@
|
|
|
677
681
|
let cachedLeads = [];
|
|
678
682
|
let ws = null;
|
|
679
683
|
|
|
684
|
+
// Escape untrusted values (webhook payloads, headers, target responses, signups) before HTML insertion
|
|
685
|
+
function esc(value) {
|
|
686
|
+
return String(value === undefined || value === null ? '' : value)
|
|
687
|
+
.replace(/&/g, '&')
|
|
688
|
+
.replace(/</g, '<')
|
|
689
|
+
.replace(/>/g, '>')
|
|
690
|
+
.replace(/"/g, '"')
|
|
691
|
+
.replace(/'/g, ''');
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// CSV cell: quote, double inner quotes, and neutralise spreadsheet formula prefixes (=+-@)
|
|
695
|
+
function csvCell(value) {
|
|
696
|
+
let v = String(value === undefined || value === null ? '' : value);
|
|
697
|
+
if (/^[=+\-@\t\r]/.test(v)) v = "'" + v;
|
|
698
|
+
return `"${v.replace(/"/g, '""')}"`;
|
|
699
|
+
}
|
|
700
|
+
|
|
680
701
|
function getApiKey() {
|
|
681
702
|
// Stored strictly in ephemeral sessionStorage (destroyed when tab is closed)
|
|
682
703
|
return sessionStorage.getItem('ha_admin_session') || '';
|
|
@@ -731,6 +752,10 @@
|
|
|
731
752
|
if (key && key.trim()) {
|
|
732
753
|
sessionStorage.setItem('ha_admin_session', key.trim());
|
|
733
754
|
updateAdminUI();
|
|
755
|
+
if (ws) ws.close();
|
|
756
|
+
loadEndpoints();
|
|
757
|
+
loadStats();
|
|
758
|
+
loadEvents();
|
|
734
759
|
}
|
|
735
760
|
}
|
|
736
761
|
|
|
@@ -750,10 +775,10 @@
|
|
|
750
775
|
} else {
|
|
751
776
|
tbody.innerHTML = cachedLeads.map(l => `
|
|
752
777
|
<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>
|
|
778
|
+
<td style="padding: 0.6rem 0.8rem; font-family: var(--font-mono); font-size: 0.8rem;">#${esc(l.id)}</td>
|
|
779
|
+
<td style="padding: 0.6rem 0.8rem; font-weight: 600; color: #60a5fa;">${esc(l.email)}</td>
|
|
780
|
+
<td style="padding: 0.6rem 0.8rem; color: var(--text-muted); font-size: 0.8rem;">${esc(l.source)}</td>
|
|
781
|
+
<td style="padding: 0.6rem 0.8rem; color: var(--text-muted); font-size: 0.8rem;">${esc(l.created_at)}</td>
|
|
757
782
|
</tr>
|
|
758
783
|
`).join('');
|
|
759
784
|
}
|
|
@@ -769,7 +794,7 @@
|
|
|
769
794
|
alert('No leads to export.');
|
|
770
795
|
return;
|
|
771
796
|
}
|
|
772
|
-
const csvContent = 'id,email,source,created_at\n' + cachedLeads.map(l =>
|
|
797
|
+
const csvContent = 'id,email,source,created_at\n' + cachedLeads.map(l => [l.id, l.email, l.source, l.created_at].map(csvCell).join(',')).join('\n');
|
|
773
798
|
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
|
774
799
|
const url = URL.createObjectURL(blob);
|
|
775
800
|
const link = document.createElement('a');
|
|
@@ -875,18 +900,19 @@
|
|
|
875
900
|
|
|
876
901
|
container.innerHTML = cachedEndpoints.map(ep => `
|
|
877
902
|
<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>
|
|
903
|
+
<div class="endpoint-name">${esc(ep.name)}</div>
|
|
904
|
+
<div class="endpoint-url" title="Ingress URL">${esc(window.location.origin)}/in/${esc(ep.id)}</div>
|
|
905
|
+
<div class="target-url">🎯 <strong>Target:</strong> ${esc(ep.target_url)}</div>
|
|
881
906
|
<div style="font-size: 0.72rem; color: var(--text-muted); display: flex; gap: 0.75rem;">
|
|
882
907
|
<span>Auto-Retry: <strong>${ep.auto_retry ? 'Yes' : 'No'}</strong></span>
|
|
883
|
-
<span>Max: <strong>${ep.max_retries} attempts</strong></span>
|
|
908
|
+
<span>Max: <strong>${esc(ep.max_retries)} attempts</strong></span>
|
|
909
|
+
<span>Signed: <strong>${ep.has_secret ? 'Yes' : 'No'}</strong></span>
|
|
884
910
|
</div>
|
|
885
911
|
</div>
|
|
886
912
|
`).join('');
|
|
887
913
|
|
|
888
914
|
simSelect.innerHTML = cachedEndpoints.map(ep => `
|
|
889
|
-
<option value="${ep.id}">${ep.name} (/in/${ep.id})</option>
|
|
915
|
+
<option value="${esc(ep.id)}">${esc(ep.name)} (/in/${esc(ep.id)})</option>
|
|
890
916
|
`).join('');
|
|
891
917
|
} catch (e) {
|
|
892
918
|
console.error(e);
|
|
@@ -896,7 +922,7 @@
|
|
|
896
922
|
async function loadEvents() {
|
|
897
923
|
try {
|
|
898
924
|
let url = '/api/events?limit=50';
|
|
899
|
-
if (currentFilter) url += `&status=${currentFilter}`;
|
|
925
|
+
if (currentFilter) url += `&status=${encodeURIComponent(currentFilter)}`;
|
|
900
926
|
const res = await fetch(url, { headers: getAuthHeaders() });
|
|
901
927
|
const events = await res.json();
|
|
902
928
|
const tbody = document.getElementById('events-table-body');
|
|
@@ -916,22 +942,22 @@
|
|
|
916
942
|
const badgeClass = evt.status === 'delivered' ? 'badge-delivered' : (evt.status === 'failed' ? 'badge-failed' : 'badge-pending');
|
|
917
943
|
const time = new Date(evt.created_at).toLocaleTimeString();
|
|
918
944
|
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' : ''));
|
|
945
|
+
const providerClass = String(evt.provider || '').includes('stripe') ? 'stripe' : (evt.provider.includes('shopify') ? 'shopify' : (evt.provider.includes('github') ? 'github' : ''));
|
|
920
946
|
|
|
921
947
|
return `
|
|
922
948
|
<tr>
|
|
923
|
-
<td><span class="badge ${badgeClass}">${evt.status}</span></td>
|
|
949
|
+
<td><span class="badge ${badgeClass}">${esc(evt.status)}</span></td>
|
|
924
950
|
<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>
|
|
951
|
+
<span class="badge-provider ${providerClass}">${esc(evt.provider)}</span>
|
|
952
|
+
<span style="font-weight: 600; margin-left: 0.35rem;">${esc(evt.event_type || evt.id)}</span>
|
|
927
953
|
</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>
|
|
954
|
+
<td><span class="code-mono" style="color: var(--text-muted);">${esc(evt.endpoint_id)}</span></td>
|
|
955
|
+
<td><span class="code-mono">${esc(latency)}</span></td>
|
|
956
|
+
<td><span class="code-mono">${esc(evt.attempts)}</span></td>
|
|
957
|
+
<td style="color: var(--text-muted);">${esc(time)}</td>
|
|
932
958
|
<td style="display: flex; gap: 0.5rem;">
|
|
933
|
-
<button class="btn btn-secondary btn-sm"
|
|
934
|
-
<button class="btn btn-primary btn-sm"
|
|
959
|
+
<button class="btn btn-secondary btn-sm" data-action="inspect" data-id="${esc(evt.id)}">Inspect</button>
|
|
960
|
+
<button class="btn btn-primary btn-sm" data-action="replay" data-id="${esc(evt.id)}">Replay</button>
|
|
935
961
|
</td>
|
|
936
962
|
</tr>
|
|
937
963
|
`;
|
|
@@ -958,6 +984,7 @@
|
|
|
958
984
|
const name = document.getElementById('inp-ep-name').value.trim();
|
|
959
985
|
const targetUrl = document.getElementById('inp-ep-target').value.trim();
|
|
960
986
|
const alertWebhookUrl = document.getElementById('inp-ep-alert').value.trim();
|
|
987
|
+
const secret = document.getElementById('inp-ep-secret').value.trim();
|
|
961
988
|
|
|
962
989
|
if (!id || !name || !targetUrl) {
|
|
963
990
|
alert('Please fill out ID, Name, and Target URL');
|
|
@@ -967,13 +994,19 @@
|
|
|
967
994
|
const res = await fetch('/api/endpoints', {
|
|
968
995
|
method: 'POST',
|
|
969
996
|
headers: getAuthHeaders({ 'Content-Type': 'application/json' }),
|
|
970
|
-
body: JSON.stringify({ id, name, targetUrl, alertWebhookUrl, autoRetry: 1, maxRetries: 5 })
|
|
997
|
+
body: JSON.stringify({ id, name, targetUrl, alertWebhookUrl, autoRetry: 1, maxRetries: 5, ...(secret ? { secret } : {}) })
|
|
971
998
|
});
|
|
972
999
|
|
|
973
1000
|
if (res.status === 401) {
|
|
974
1001
|
alert('Admin authorization required to create endpoints. Please click "Admin Login" in top right.');
|
|
975
1002
|
return;
|
|
976
1003
|
}
|
|
1004
|
+
if (!res.ok) {
|
|
1005
|
+
const data = await res.json().catch(() => ({}));
|
|
1006
|
+
alert(`Could not save endpoint: ${data.error || 'HTTP ' + res.status}`);
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
document.getElementById('inp-ep-secret').value = '';
|
|
977
1010
|
|
|
978
1011
|
toggleNewEndpointForm();
|
|
979
1012
|
await loadEndpoints();
|
|
@@ -981,9 +1014,11 @@
|
|
|
981
1014
|
|
|
982
1015
|
async function replayEvent(id) {
|
|
983
1016
|
try {
|
|
984
|
-
const res = await fetch(`/api/events/${id}/replay`, { method: 'POST', headers: getAuthHeaders() });
|
|
1017
|
+
const res = await fetch(`/api/events/${encodeURIComponent(id)}/replay`, { method: 'POST', headers: getAuthHeaders({ 'Content-Type': 'application/json' }), body: '{}' });
|
|
985
1018
|
const data = await res.json();
|
|
986
|
-
if (
|
|
1019
|
+
if (!res.ok) {
|
|
1020
|
+
alert(`Replay not started: ${data.error || 'HTTP ' + res.status}`);
|
|
1021
|
+
} else if (data.success) {
|
|
987
1022
|
alert(`✅ Event ${id} successfully replayed! (HTTP ${data.statusCode})`);
|
|
988
1023
|
} else {
|
|
989
1024
|
alert(`❌ Replay attempted: Destination returned HTTP ${data.statusCode} (${data.errorMessage || 'Failed'})`);
|
|
@@ -998,9 +1033,14 @@
|
|
|
998
1033
|
async function replayAllFailed() {
|
|
999
1034
|
if (!confirm('Replay all failed Dead-Letter Queue events now?')) return;
|
|
1000
1035
|
try {
|
|
1001
|
-
const res = await fetch('/api/events/replay-all', { method: 'POST', headers: getAuthHeaders({ 'Content-Type': 'application/json' }) });
|
|
1036
|
+
const res = await fetch('/api/events/replay-all', { method: 'POST', headers: getAuthHeaders({ 'Content-Type': 'application/json' }), body: '{}' });
|
|
1002
1037
|
const data = await res.json();
|
|
1003
|
-
|
|
1038
|
+
if (!res.ok) {
|
|
1039
|
+
alert(`Bulk replay not started: ${data.error || 'HTTP ' + res.status}`);
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
const more = data.remaining > 0 ? ` (${data.remaining} more still queued; run again to continue)` : '';
|
|
1043
|
+
alert(`🔄 Dispatched replay for ${data.replayedCount} dead-letter events.${more}`);
|
|
1004
1044
|
await loadEvents();
|
|
1005
1045
|
await loadStats();
|
|
1006
1046
|
} catch (e) {
|
|
@@ -1010,42 +1050,46 @@
|
|
|
1010
1050
|
|
|
1011
1051
|
async function inspectEvent(id) {
|
|
1012
1052
|
try {
|
|
1013
|
-
const res = await fetch(`/api/events/${id}`, { headers: getAuthHeaders() });
|
|
1053
|
+
const res = await fetch(`/api/events/${encodeURIComponent(id)}`, { headers: getAuthHeaders() });
|
|
1014
1054
|
const evt = await res.json();
|
|
1015
1055
|
document.getElementById('modal-event-title').textContent = `Inspection: ${evt.id}`;
|
|
1016
1056
|
|
|
1017
1057
|
let attemptsHtml = '<p style="color: var(--text-muted); font-size: 0.85rem;">No attempts recorded.</p>';
|
|
1018
1058
|
if (evt.attempts && evt.attempts.length > 0) {
|
|
1019
|
-
attemptsHtml = evt.attempts.map((att, i) =>
|
|
1059
|
+
attemptsHtml = evt.attempts.map((att, i) => {
|
|
1060
|
+
const errorHtml = att.error_message ? `<div style="color: var(--accent-rose); margin-top: 0.25rem;">${esc(att.error_message)}</div>` : '';
|
|
1061
|
+
const bodyHtml = att.response_body ? `<div class="code-block" style="margin-top: 0.5rem; max-height: 120px;">${esc(att.response_body)}</div>` : '';
|
|
1062
|
+
return `
|
|
1020
1063
|
<div style="background: #090d16; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border); font-size: 0.8rem;">
|
|
1021
1064
|
<div style="display: flex; justify-content: space-between; margin-bottom: 0.35rem;">
|
|
1022
1065
|
<strong>Attempt #${evt.attempts.length - i}</strong>
|
|
1023
1066
|
<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)
|
|
1067
|
+
HTTP ${esc(att.status_code || 'Err')} (${esc(att.latency_ms)}ms)
|
|
1025
1068
|
</span>
|
|
1026
1069
|
</div>
|
|
1027
|
-
<div style="color: var(--text-muted);">${att.attempted_at}</div>
|
|
1028
|
-
${
|
|
1029
|
-
${
|
|
1070
|
+
<div style="color: var(--text-muted);">${esc(att.attempted_at)}</div>
|
|
1071
|
+
${errorHtml}
|
|
1072
|
+
${bodyHtml}
|
|
1030
1073
|
</div>
|
|
1031
|
-
|
|
1074
|
+
`;
|
|
1075
|
+
}).join('');
|
|
1032
1076
|
}
|
|
1033
1077
|
|
|
1034
1078
|
document.getElementById('modal-event-body').innerHTML = `
|
|
1035
1079
|
<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>
|
|
1080
|
+
<span class="badge ${evt.status === 'delivered' ? 'badge-delivered' : 'badge-failed'}">${esc(evt.status)}</span>
|
|
1081
|
+
<span style="font-weight: 700;">${esc(evt.event_type || 'Custom Payload')}</span>
|
|
1082
|
+
<span style="color: var(--text-muted); font-size: 0.8rem;">Received: ${esc(evt.created_at)}</span>
|
|
1039
1083
|
</div>
|
|
1040
1084
|
|
|
1041
1085
|
<div>
|
|
1042
1086
|
<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>
|
|
1087
|
+
<div class="code-block">${esc(JSON.stringify(evt.headers, null, 2))}</div>
|
|
1044
1088
|
</div>
|
|
1045
1089
|
|
|
1046
1090
|
<div>
|
|
1047
1091
|
<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>
|
|
1092
|
+
<div class="code-block">${esc(evt.raw_body)}</div>
|
|
1049
1093
|
</div>
|
|
1050
1094
|
|
|
1051
1095
|
<div>
|
|
@@ -1055,7 +1099,7 @@
|
|
|
1055
1099
|
|
|
1056
1100
|
<div style="display: flex; justify-content: flex-end; gap: 0.75rem;">
|
|
1057
1101
|
<button class="btn btn-secondary" onclick="closeInspectorModal()">Close</button>
|
|
1058
|
-
<button class="btn btn-primary"
|
|
1102
|
+
<button class="btn btn-primary" data-action="replay" data-id="${esc(evt.id)}">Replay Event</button>
|
|
1059
1103
|
</div>
|
|
1060
1104
|
`;
|
|
1061
1105
|
document.getElementById('inspector-modal').style.display = 'flex';
|
|
@@ -1104,7 +1148,7 @@
|
|
|
1104
1148
|
const payloadStr = document.getElementById('sim-payload').value;
|
|
1105
1149
|
|
|
1106
1150
|
try {
|
|
1107
|
-
const res = await fetch(`/in/${epId}`, {
|
|
1151
|
+
const res = await fetch(`/in/${encodeURIComponent(epId)}`, {
|
|
1108
1152
|
method: 'POST',
|
|
1109
1153
|
headers,
|
|
1110
1154
|
body: payloadStr
|
|
@@ -1132,6 +1176,10 @@
|
|
|
1132
1176
|
function initWebSocket() {
|
|
1133
1177
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
1134
1178
|
ws = new WebSocket(`${protocol}//${window.location.host}/ws`);
|
|
1179
|
+
ws.onopen = () => {
|
|
1180
|
+
const key = getApiKey();
|
|
1181
|
+
if (key) ws.send(JSON.stringify({ type: 'auth', token: key }));
|
|
1182
|
+
};
|
|
1135
1183
|
ws.onmessage = (e) => {
|
|
1136
1184
|
const msg = JSON.parse(e.data);
|
|
1137
1185
|
if (msg.type === 'event:received' || msg.type === 'delivery') {
|
|
@@ -1144,6 +1192,14 @@
|
|
|
1144
1192
|
};
|
|
1145
1193
|
}
|
|
1146
1194
|
|
|
1195
|
+
// Event-table and inspector buttons carry ids in data attributes instead of inline handlers
|
|
1196
|
+
document.addEventListener('click', (e) => {
|
|
1197
|
+
const btn = e.target.closest('[data-action]');
|
|
1198
|
+
if (!btn) return;
|
|
1199
|
+
if (btn.dataset.action === 'inspect') inspectEvent(btn.dataset.id);
|
|
1200
|
+
if (btn.dataset.action === 'replay') replayEvent(btn.dataset.id);
|
|
1201
|
+
});
|
|
1202
|
+
|
|
1147
1203
|
// Startup
|
|
1148
1204
|
window.addEventListener('DOMContentLoaded', async () => {
|
|
1149
1205
|
checkAdminAuth();
|