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.
@@ -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 };
@@ -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, '&amp;')
688
+ .replace(/</g, '&lt;')
689
+ .replace(/>/g, '&gt;')
690
+ .replace(/"/g, '&quot;')
691
+ .replace(/'/g, '&#39;');
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 => `${l.id},"${l.email}","${l.source}","${l.created_at}"`).join('\n');
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" onclick="inspectEvent('${evt.id}')">Inspect</button>
934
- <button class="btn btn-primary btn-sm" onclick="replayEvent('${evt.id}')">Replay</button>
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 (data.success) {
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
- alert(`🔄 Dispatched replay for ${data.replayedCount} dead-letter events.`);
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
- ${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>` : ''}
1070
+ <div style="color: var(--text-muted);">${esc(att.attempted_at)}</div>
1071
+ ${errorHtml}
1072
+ ${bodyHtml}
1030
1073
  </div>
1031
- `).join('');
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" onclick="replayEvent('${evt.id}')">Replay Event</button>
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();