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 +1 -1
- package/public/dashboard.html +1 -0
- package/public/webhooks.html +181 -0
- package/server/services/webhooks.js +61 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "web-agent-bridge",
|
|
3
|
-
"version": "3.
|
|
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",
|
package/public/dashboard.html
CHANGED
|
@@ -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=<unix>,v1=<hex></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) => ({ '&':'&','<':'<','>':'>','"':'"' })[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 = {
|
|
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,
|