web-agent-bridge 3.16.0 → 3.17.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "web-agent-bridge",
3
- "version": "3.16.0",
3
+ "version": "3.17.0",
4
4
  "description": "Agent Transaction Bridge — the trust + transaction layer for agentic commerce. Signed intent contracts, idempotent transactions, Ed25519-verifiable receipts, explicit compensation. Plus the original WAB stack: sovereign browser, ShieldQR, SSL health, DNS discovery, agent mesh, and unified gateway for safe AI–website interaction.",
5
5
  "author": "Web Agent Bridge <dev@webagentbridge.com>",
6
6
  "main": "server/index.js",
@@ -39,6 +39,7 @@
39
39
  <a href="#" data-view="analytics">📈 Analytics</a>
40
40
  <a href="/providers">🔗 DNS Providers</a>
41
41
  <a href="/dashboard/shieldlink">🔗 ShieldLink</a>
42
+ <a href="/webhooks.html">📡 Webhooks</a>
42
43
  <a href="#" data-view="billing">💳 Billing</a>
43
44
  <a href="#" data-view="settings">⚙️ Settings</a>
44
45
  <a href="/docs" style="margin-top:20px;">📖 Documentation</a>
@@ -0,0 +1,181 @@
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" />
6
+ <title>Webhooks · Web Agent Bridge</title>
7
+ <link rel="stylesheet" href="/css/dashboard.css" />
8
+ <style>
9
+ body { background: #f7f8fa; color: #111; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; }
10
+ .wrap { max-width: 980px; margin: 0 auto; padding: 32px 24px; }
11
+ h1 { margin: 0 0 8px; font-size: 28px; }
12
+ .lede { color: #555; margin: 0 0 28px; }
13
+ .card { background: #fff; border: 1px solid #e6e7ea; border-radius: 12px; padding: 20px; margin-bottom: 20px; box-shadow: 0 1px 2px rgba(0,0,0,.03); }
14
+ .row { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
15
+ input, select, button { font: inherit; padding: 9px 12px; border-radius: 8px; border: 1px solid #cfd1d6; background: #fff; }
16
+ input { flex: 1 1 280px; min-width: 240px; }
17
+ button { background: #111; color: #fff; border-color: #111; cursor: pointer; }
18
+ button.ghost { background: #fff; color: #111; }
19
+ button.danger { background: #c0392b; border-color: #c0392b; }
20
+ table { width: 100%; border-collapse: collapse; }
21
+ th, td { padding: 10px 8px; text-align: left; border-bottom: 1px solid #eee; font-size: 14px; vertical-align: top; }
22
+ th { color: #666; font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: .03em; }
23
+ code, .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; }
24
+ .pill { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; }
25
+ .pill.ok { background: #e7f6ee; color: #146c43; }
26
+ .pill.off { background: #fdecea; color: #b71c1c; }
27
+ .pill.warn { background: #fff4e5; color: #8a5a00; }
28
+ .secret-box { background: #fff8e1; border: 1px solid #f1d27a; border-radius: 8px; padding: 12px; margin: 10px 0; }
29
+ .muted { color: #777; font-size: 13px; }
30
+ details summary { cursor: pointer; color: #2563eb; font-size: 13px; }
31
+ .empty { text-align: center; color: #888; padding: 28px; }
32
+ </style>
33
+ </head>
34
+ <body>
35
+ <div class="wrap">
36
+ <h1>Webhooks</h1>
37
+ <p class="lede">Subscribe to instant push notifications for revocation events. Each payload is HMAC-signed with your subscription secret and Ed25519-signed by the operator.</p>
38
+
39
+ <div class="card">
40
+ <h3 style="margin-top:0">Create subscription</h3>
41
+ <form id="createForm" class="row" autocomplete="off">
42
+ <input id="urlInput" type="url" placeholder="https://your-endpoint/webhooks" required />
43
+ <input id="descInput" type="text" placeholder="Optional description" style="flex: 0 1 220px" />
44
+ <button type="submit">Subscribe</button>
45
+ </form>
46
+ <p class="muted" style="margin: 10px 0 0">Receives <code>revocation.opened</code>, <code>revocation.reinstated</code>, <code>revocation.appeal_decided</code>. <a href="/api/webhooks/events" target="_blank">View events</a> · <a href="/api/operator-key.json" target="_blank">Operator public key</a></p>
47
+ <div id="secretBox" class="secret-box" style="display:none"></div>
48
+ </div>
49
+
50
+ <div class="card">
51
+ <div class="row" style="justify-content: space-between; margin-bottom: 12px">
52
+ <h3 style="margin: 0">Your subscriptions</h3>
53
+ <button class="ghost" onclick="loadSubs()">Refresh</button>
54
+ </div>
55
+ <table>
56
+ <thead><tr><th>URL</th><th>Events</th><th>Status</th><th>Last result</th><th></th></tr></thead>
57
+ <tbody id="subsBody"><tr><td colspan="5" class="empty">Loading…</td></tr></tbody>
58
+ </table>
59
+ </div>
60
+
61
+ <div class="card">
62
+ <h3 style="margin-top:0">Verifying a delivery</h3>
63
+ <p class="muted">Every request carries two signatures:</p>
64
+ <ol class="muted" style="line-height: 1.7">
65
+ <li><strong>HMAC-SHA256</strong> per subscription — header <code>X-WAB-Webhook-Signature: t=&lt;unix&gt;,v1=&lt;hex&gt;</code>. Recompute over <code>${'`${t}.${rawBody}`'}</code> with your secret.</li>
66
+ <li><strong>Ed25519 operator signature</strong> — header <code>X-WAB-Operator-Signature</code> + embedded <code>signature.value</code>. Fetch the public key at <code>/api/operator-key.json</code> and verify over the RFC 8785 canonicalisation of the envelope minus the <code>signature</code> field.</li>
67
+ </ol>
68
+ </div>
69
+ </div>
70
+
71
+ <script>
72
+ const TOKEN = localStorage.getItem('wab_token');
73
+ if (!TOKEN) { location.href = '/login.html?next=/webhooks.html'; }
74
+
75
+ const H = { 'Authorization': `Bearer ${TOKEN}`, 'Content-Type': 'application/json' };
76
+
77
+ async function api(method, path, body) {
78
+ const opts = { method, headers: H };
79
+ if (body !== undefined) opts.body = JSON.stringify(body);
80
+ const r = await fetch(path, opts);
81
+ const j = await r.json().catch(() => ({}));
82
+ if (!r.ok) throw new Error(j.message || j.error || `HTTP ${r.status}`);
83
+ return j.data;
84
+ }
85
+
86
+ function escape(s) {
87
+ return String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;' })[c]);
88
+ }
89
+
90
+ function pill(active, lastError) {
91
+ if (!active) return '<span class="pill off">disabled</span>';
92
+ if (lastError) return '<span class="pill warn">warning</span>';
93
+ return '<span class="pill ok">active</span>';
94
+ }
95
+
96
+ async function loadSubs() {
97
+ const body = document.getElementById('subsBody');
98
+ body.innerHTML = '<tr><td colspan="5" class="empty">Loading…</td></tr>';
99
+ try {
100
+ const subs = await api('GET', '/api/webhooks');
101
+ if (!subs.length) {
102
+ body.innerHTML = '<tr><td colspan="5" class="empty">No subscriptions yet.</td></tr>';
103
+ return;
104
+ }
105
+ body.innerHTML = subs.map((s) => `
106
+ <tr>
107
+ <td><div class="mono">${escape(s.url)}</div>${s.description ? `<div class="muted">${escape(s.description)}</div>` : ''}</td>
108
+ <td><div class="muted">${s.events.map(escape).join('<br>')}</div></td>
109
+ <td>${pill(s.active, s.last_error)}</td>
110
+ <td>
111
+ ${s.last_success_at ? `<div class="muted">ok: ${escape(s.last_success_at)}</div>` : ''}
112
+ ${s.last_error_at ? `<div class="muted">err: ${escape(s.last_error_at)}</div>` : ''}
113
+ ${s.consecutive_failures ? `<div class="muted">${s.consecutive_failures} consecutive failures</div>` : ''}
114
+ </td>
115
+ <td style="text-align:right; white-space: nowrap">
116
+ <button class="ghost" onclick="toggle('${s.id}', ${!s.active})">${s.active ? 'Pause' : 'Resume'}</button>
117
+ <button class="ghost" onclick="showDeliveries('${s.id}')">Deliveries</button>
118
+ <button class="danger" onclick="del('${s.id}')">Delete</button>
119
+ </td>
120
+ </tr>
121
+ <tr id="deliv-${s.id}" style="display:none"><td colspan="5"><div id="deliv-body-${s.id}" class="muted">Loading deliveries…</div></td></tr>
122
+ `).join('');
123
+ } catch (e) {
124
+ body.innerHTML = `<tr><td colspan="5" class="empty">Error: ${escape(e.message)}</td></tr>`;
125
+ }
126
+ }
127
+
128
+ async function showDeliveries(id) {
129
+ const row = document.getElementById('deliv-' + id);
130
+ const target = document.getElementById('deliv-body-' + id);
131
+ if (row.style.display === 'table-row') { row.style.display = 'none'; return; }
132
+ row.style.display = 'table-row';
133
+ try {
134
+ const list = await api('GET', `/api/webhooks/${id}/deliveries?limit=20`);
135
+ if (!list.length) { target.innerHTML = 'No deliveries yet.'; return; }
136
+ target.innerHTML = '<table>' +
137
+ '<thead><tr><th>Event</th><th>Status</th><th>Attempts</th><th>Code</th><th>When</th><th>Error</th></tr></thead><tbody>' +
138
+ list.map((d) => `<tr>
139
+ <td class="mono">${escape(d.event_type)}</td>
140
+ <td>${escape(d.status)}</td>
141
+ <td>${d.attempts}</td>
142
+ <td>${d.last_status_code || ''}</td>
143
+ <td class="muted">${escape(d.delivered_at || d.created_at)}</td>
144
+ <td class="muted" style="max-width: 320px; word-break: break-word">${escape(d.last_error || '')}</td>
145
+ </tr>`).join('') + '</tbody></table>';
146
+ } catch (e) {
147
+ target.innerHTML = 'Error: ' + escape(e.message);
148
+ }
149
+ }
150
+
151
+ async function toggle(id, active) {
152
+ try { await api('PATCH', '/api/webhooks/' + id, { active }); loadSubs(); }
153
+ catch (e) { alert(e.message); }
154
+ }
155
+
156
+ async function del(id) {
157
+ if (!confirm('Delete this subscription?')) return;
158
+ try { await api('DELETE', '/api/webhooks/' + id); loadSubs(); }
159
+ catch (e) { alert(e.message); }
160
+ }
161
+
162
+ document.getElementById('createForm').addEventListener('submit', async (ev) => {
163
+ ev.preventDefault();
164
+ const url = document.getElementById('urlInput').value.trim();
165
+ const description = document.getElementById('descInput').value.trim() || null;
166
+ try {
167
+ const sub = await api('POST', '/api/webhooks', { url, description });
168
+ const box = document.getElementById('secretBox');
169
+ box.style.display = 'block';
170
+ box.innerHTML = '<strong>Subscription created.</strong> Save this secret now — it will not be shown again:' +
171
+ `<div class="mono" style="margin-top:8px; word-break: break-all; user-select: all">${escape(sub.secret)}</div>`;
172
+ document.getElementById('urlInput').value = '';
173
+ document.getElementById('descInput').value = '';
174
+ loadSubs();
175
+ } catch (e) { alert(e.message); }
176
+ });
177
+
178
+ loadSubs();
179
+ </script>
180
+ </body>
181
+ </html>
@@ -20,6 +20,8 @@
20
20
 
21
21
  const crypto = require('crypto');
22
22
  const { db } = require('../models/db');
23
+ const { canonicalize } = require('./canonical-json');
24
+ const signer = require('./operator-signer');
23
25
 
24
26
  const VALID_EVENTS = new Set([
25
27
  'revocation.opened',
@@ -183,6 +185,10 @@ async function _attemptDelivery(deliveryId, subscription, body) {
183
185
  'X-WAB-Webhook-Delivery': deliveryId,
184
186
  'User-Agent': 'web-agent-bridge-webhooks/1.0',
185
187
  };
188
+ if (subscription._operatorSignature) {
189
+ headers['X-WAB-Operator-Signature'] = subscription._operatorSignature;
190
+ headers['X-WAB-Operator-Key-Url'] = '/api/operator-key.json';
191
+ }
186
192
  try {
187
193
  const res = await _httpPost(subscription.url, body, headers);
188
194
  if (res.ok) {
@@ -264,6 +270,20 @@ function emit(eventType, data) {
264
270
  created_at: new Date().toISOString(),
265
271
  data,
266
272
  };
273
+ // Ed25519-sign the canonical form of the payload (RFC 8785). Receivers verify
274
+ // with the public key published at /api/operator-key.json. If no operator key
275
+ // is configured, we still deliver — the HMAC signature alone is sufficient for
276
+ // per-subscription auth.
277
+ const operatorSignature = signer.isConfigured() ? signer.sign(payload) : null;
278
+ if (operatorSignature) {
279
+ payload.signature = {
280
+ alg: signer.ALGORITHM,
281
+ value: operatorSignature,
282
+ key_url: '/api/operator-key.json',
283
+ canonicalization: 'RFC8785',
284
+ signed_fields: ['id', 'type', 'created_at', 'data'],
285
+ };
286
+ }
267
287
  const body = JSON.stringify(payload);
268
288
 
269
289
  for (const sub of subs) {
@@ -273,7 +293,12 @@ function emit(eventType, data) {
273
293
  (id, subscription_id, event_id, event_type, payload, status, attempts)
274
294
  VALUES (?, ?, ?, ?, ?, 'pending', 0)
275
295
  `).run(deliveryId, sub.id, eventId, eventType, body);
276
- const enriched = { ...sub, _eventId: eventId, _eventType: eventType };
296
+ const enriched = {
297
+ ...sub,
298
+ _eventId: eventId,
299
+ _eventType: eventType,
300
+ _operatorSignature: operatorSignature,
301
+ };
277
302
  setImmediate(async () => {
278
303
  const ok = await _attemptDelivery(deliveryId, enriched, body);
279
304
  if (!ok) _scheduleRetry(deliveryId, enriched, body, 1);
@@ -298,6 +323,40 @@ function verifySignature({ secret, header, body, toleranceSec = 300 }) {
298
323
  } catch (_) { return false; }
299
324
  }
300
325
 
326
+ /**
327
+ * Verify the operator (Ed25519) signature embedded inside an event body.
328
+ *
329
+ * The receiver passes the parsed JSON envelope (or the raw body string + we
330
+ * parse). We strip the `signature` field, RFC8785-canonicalise the remainder,
331
+ * and verify with the operator public key (32-byte raw, base64).
332
+ *
333
+ * verifyOperatorSignature({ body, publicKeyB64 }) -> true | false
334
+ */
335
+ function verifyOperatorSignature({ body, publicKeyB64 }) {
336
+ if (!body || !publicKeyB64) return false;
337
+ let envelope;
338
+ try {
339
+ envelope = typeof body === 'string' ? JSON.parse(body) : body;
340
+ } catch (_) { return false; }
341
+ const sigObj = envelope.signature;
342
+ if (!sigObj || sigObj.alg !== 'ed25519' || !sigObj.value) return false;
343
+
344
+ const { signature: _omit, ...signed } = envelope;
345
+ const canon = canonicalize(signed);
346
+
347
+ try {
348
+ const raw = Buffer.from(publicKeyB64, 'base64');
349
+ if (raw.length !== 32) return false;
350
+ // Reconstruct SPKI DER for Ed25519: 12-byte prefix + 32-byte raw.
351
+ const spki = Buffer.concat([
352
+ Buffer.from('302a300506032b6570032100', 'hex'),
353
+ raw,
354
+ ]);
355
+ const pub = crypto.createPublicKey({ key: spki, format: 'der', type: 'spki' });
356
+ return crypto.verify(null, Buffer.from(canon, 'utf8'), pub, Buffer.from(sigObj.value, 'base64'));
357
+ } catch (_) { return false; }
358
+ }
359
+
301
360
  module.exports = {
302
361
  createSubscription,
303
362
  listSubscriptions,
@@ -307,6 +366,7 @@ module.exports = {
307
366
  listDeliveries,
308
367
  emit,
309
368
  verifySignature,
369
+ verifyOperatorSignature,
310
370
  VALID_EVENTS,
311
371
  // exposed for tests
312
372
  _attemptDelivery,