web-agent-bridge 3.15.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/index.js +3 -0
- package/server/migrations/025_revocation_webhooks.sql +55 -0
- package/server/routes/webhooks.js +75 -0
- package/server/services/revocations.js +33 -3
- package/server/services/webhooks.js +373 -0
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>
|
package/server/index.js
CHANGED
|
@@ -327,6 +327,9 @@ app.use('/api/agent', apiLimiter, require('./routes/agent-prompt'));
|
|
|
327
327
|
// (apiLimiter already applies via /api mount above; do not stack it here.)
|
|
328
328
|
app.use('/api', require('./routes/network'));
|
|
329
329
|
|
|
330
|
+
// ── Webhook Subscriptions v3.16.0 (Phase 4) — instant push for revocations ──
|
|
331
|
+
app.use('/api/webhooks', apiLimiter, require('./routes/webhooks'));
|
|
332
|
+
|
|
330
333
|
// ── WAB Commercial Foundations v3.8.0 (Partners · Trust Graph API · Governance SaaS · Enterprise Mesh) ──
|
|
331
334
|
app.use('/api/partners', apiLimiter, require('./routes/partners'));
|
|
332
335
|
app.use('/api/keys', apiLimiter, require('./routes/api-keys'));
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
-- ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
-- Migration 025 — Webhook Subscriptions for Revocations (v3.16.0)
|
|
3
|
+
--
|
|
4
|
+
-- Lets ecosystem participants (agent frameworks, security tools, allow-list
|
|
5
|
+
-- mirrors) subscribe to instant push notifications for revocation events
|
|
6
|
+
-- instead of polling /api/trusted-domains.json every hour.
|
|
7
|
+
--
|
|
8
|
+
-- Delivery is best-effort with retries (3 attempts: t+0, t+30s, t+5m).
|
|
9
|
+
-- Every delivery is HMAC-SHA256 signed using the subscription secret.
|
|
10
|
+
-- ─────────────────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
CREATE TABLE IF NOT EXISTS webhook_subscriptions (
|
|
13
|
+
id TEXT PRIMARY KEY, -- whsub_<ulid>
|
|
14
|
+
user_id TEXT NOT NULL,
|
|
15
|
+
url TEXT NOT NULL, -- HTTPS endpoint
|
|
16
|
+
secret TEXT NOT NULL, -- shared HMAC secret (base64, 32 bytes)
|
|
17
|
+
events TEXT NOT NULL DEFAULT 'revocation.opened,revocation.reinstated,revocation.appeal_decided',
|
|
18
|
+
active INTEGER NOT NULL DEFAULT 1,
|
|
19
|
+
description TEXT,
|
|
20
|
+
last_success_at TEXT,
|
|
21
|
+
last_error_at TEXT,
|
|
22
|
+
last_error TEXT,
|
|
23
|
+
consecutive_failures INTEGER NOT NULL DEFAULT 0,
|
|
24
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
25
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
CREATE INDEX IF NOT EXISTS idx_webhook_subs_user
|
|
29
|
+
ON webhook_subscriptions(user_id, active);
|
|
30
|
+
|
|
31
|
+
CREATE INDEX IF NOT EXISTS idx_webhook_subs_active
|
|
32
|
+
ON webhook_subscriptions(active);
|
|
33
|
+
|
|
34
|
+
CREATE TABLE IF NOT EXISTS webhook_deliveries (
|
|
35
|
+
id TEXT PRIMARY KEY, -- whd_<ulid>
|
|
36
|
+
subscription_id TEXT NOT NULL,
|
|
37
|
+
event_id TEXT NOT NULL, -- evt_<ulid>
|
|
38
|
+
event_type TEXT NOT NULL,
|
|
39
|
+
payload TEXT NOT NULL, -- raw JSON body sent
|
|
40
|
+
status TEXT NOT NULL DEFAULT 'pending'
|
|
41
|
+
CHECK (status IN ('pending','success','failed')),
|
|
42
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
43
|
+
last_status_code INTEGER,
|
|
44
|
+
last_error TEXT,
|
|
45
|
+
next_retry_at TEXT,
|
|
46
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
47
|
+
delivered_at TEXT,
|
|
48
|
+
FOREIGN KEY (subscription_id) REFERENCES webhook_subscriptions(id) ON DELETE CASCADE
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_sub
|
|
52
|
+
ON webhook_deliveries(subscription_id, created_at DESC);
|
|
53
|
+
|
|
54
|
+
CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_status
|
|
55
|
+
ON webhook_deliveries(status, next_retry_at);
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhook Subscriptions API (v3.16.0 — Phase 4)
|
|
3
|
+
*
|
|
4
|
+
* POST /api/webhooks — create subscription (returns secret once)
|
|
5
|
+
* GET /api/webhooks — list user's subscriptions
|
|
6
|
+
* GET /api/webhooks/:id — get one
|
|
7
|
+
* PATCH /api/webhooks/:id — update url/events/active/description
|
|
8
|
+
* DELETE /api/webhooks/:id — delete
|
|
9
|
+
* GET /api/webhooks/:id/deliveries — recent delivery log
|
|
10
|
+
* GET /api/webhooks/events — list supported event types
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
const express = require('express');
|
|
16
|
+
const router = express.Router();
|
|
17
|
+
|
|
18
|
+
const { authenticateToken } = require('../middleware/auth');
|
|
19
|
+
const webhooks = require('../services/webhooks');
|
|
20
|
+
|
|
21
|
+
function _handle(res, fn) {
|
|
22
|
+
try {
|
|
23
|
+
const out = fn();
|
|
24
|
+
res.json({ ok: true, data: out });
|
|
25
|
+
} catch (e) {
|
|
26
|
+
const status = e.statusCode || 500;
|
|
27
|
+
res.status(status).json({ ok: false, error: e.code || 'internal_error', message: e.message });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
router.get('/events', (req, res) => {
|
|
32
|
+
res.json({ ok: true, data: { events: Array.from(webhooks.VALID_EVENTS) } });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
router.post('/', authenticateToken, express.json({ limit: '8kb' }), (req, res) => {
|
|
36
|
+
_handle(res, () => webhooks.createSubscription({
|
|
37
|
+
userId: req.user.id,
|
|
38
|
+
url: req.body.url,
|
|
39
|
+
events: req.body.events,
|
|
40
|
+
description: req.body.description,
|
|
41
|
+
}));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
router.get('/', authenticateToken, (req, res) => {
|
|
45
|
+
_handle(res, () => webhooks.listSubscriptions(req.user.id));
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
router.get('/:id', authenticateToken, (req, res) => {
|
|
49
|
+
_handle(res, () => webhooks.getSubscription({ id: req.params.id, userId: req.user.id }));
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
router.patch('/:id', authenticateToken, express.json({ limit: '8kb' }), (req, res) => {
|
|
53
|
+
_handle(res, () => webhooks.updateSubscription({
|
|
54
|
+
id: req.params.id,
|
|
55
|
+
userId: req.user.id,
|
|
56
|
+
url: req.body.url,
|
|
57
|
+
events: req.body.events,
|
|
58
|
+
active: req.body.active,
|
|
59
|
+
description: req.body.description,
|
|
60
|
+
}));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
router.delete('/:id', authenticateToken, (req, res) => {
|
|
64
|
+
_handle(res, () => webhooks.deleteSubscription({ id: req.params.id, userId: req.user.id }));
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
router.get('/:id/deliveries', authenticateToken, (req, res) => {
|
|
68
|
+
_handle(res, () => webhooks.listDeliveries({
|
|
69
|
+
subscriptionId: req.params.id,
|
|
70
|
+
userId: req.user.id,
|
|
71
|
+
limit: parseInt(req.query.limit, 10) || 50,
|
|
72
|
+
}));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
module.exports = router;
|
|
@@ -33,6 +33,29 @@ const { db } = require('../models/db');
|
|
|
33
33
|
const { canonicalize } = require('./canonical-json');
|
|
34
34
|
const { auditLog } = require('./security');
|
|
35
35
|
|
|
36
|
+
function _emitWebhook(eventType, row) {
|
|
37
|
+
try {
|
|
38
|
+
const webhooks = require('./webhooks');
|
|
39
|
+
webhooks.emit(eventType, { revocation: _publicRevocationView(row) });
|
|
40
|
+
} catch (e) {
|
|
41
|
+
if (process.env.NODE_ENV !== 'test') {
|
|
42
|
+
console.warn('[revocations] webhook emit failed:', e.message);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function _publicRevocationView(r) {
|
|
48
|
+
if (!r) return null;
|
|
49
|
+
return {
|
|
50
|
+
id: r.id, domain: r.domain, type: r.type,
|
|
51
|
+
reason_code: r.reason_code, reason_text: r.reason_text,
|
|
52
|
+
evidence_url: r.evidence_url,
|
|
53
|
+
decided_at: r.decided_at, appeal_deadline: r.appeal_deadline,
|
|
54
|
+
status: r.status, finalized_at: r.finalized_at,
|
|
55
|
+
reinstated_at: r.reinstated_at,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
36
59
|
const APPEAL_WINDOW_DAYS = Number(process.env.WAB_REVOCATION_APPEAL_DAYS || 7);
|
|
37
60
|
const APPEAL_WINDOW_MS = APPEAL_WINDOW_DAYS * 24 * 60 * 60 * 1000;
|
|
38
61
|
const OPERATOR_KEY_B64 = process.env.WAB_OPERATOR_ED25519_PRIV || '';
|
|
@@ -139,7 +162,9 @@ function openRevocation({ siteId, type, reasonCode, reasonText, decidedBy, evide
|
|
|
139
162
|
severity: type === 'revoked' ? 'critical' : 'warning',
|
|
140
163
|
});
|
|
141
164
|
|
|
142
|
-
|
|
165
|
+
const inserted = db.prepare(`SELECT * FROM site_revocations WHERE id = ?`).get(id);
|
|
166
|
+
if (type !== 'owner_disable') _emitWebhook('revocation.opened', inserted);
|
|
167
|
+
return inserted;
|
|
143
168
|
}
|
|
144
169
|
|
|
145
170
|
/**
|
|
@@ -253,7 +278,10 @@ function decideAppeal({ revocationId, decision, decisionReason, adminId }) {
|
|
|
253
278
|
severity: decision === 'rejected' ? 'warning' : 'info',
|
|
254
279
|
});
|
|
255
280
|
|
|
256
|
-
|
|
281
|
+
const updated = db.prepare(`SELECT * FROM site_revocations WHERE id = ?`).get(revocationId);
|
|
282
|
+
_emitWebhook('revocation.appeal_decided', { ...updated, _decision: decision });
|
|
283
|
+
if (decision === 'upheld') _emitWebhook('revocation.reinstated', updated);
|
|
284
|
+
return updated;
|
|
257
285
|
}
|
|
258
286
|
|
|
259
287
|
/**
|
|
@@ -280,7 +308,9 @@ function reinstate({ revocationId, actorId, actorType = 'admin', reason }) {
|
|
|
280
308
|
details: { domain: rev.domain, reason: reason || null },
|
|
281
309
|
});
|
|
282
310
|
|
|
283
|
-
|
|
311
|
+
const updated = db.prepare(`SELECT * FROM site_revocations WHERE id = ?`).get(revocationId);
|
|
312
|
+
_emitWebhook('revocation.reinstated', updated);
|
|
313
|
+
return updated;
|
|
284
314
|
}
|
|
285
315
|
|
|
286
316
|
/**
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhook Subscriptions Service (v3.16.0 — Phase 4)
|
|
3
|
+
* ───────────────────────────────────────────────────────────────────────────
|
|
4
|
+
* Lets users subscribe HTTPS endpoints to revocation events for instant push
|
|
5
|
+
* delivery instead of polling /api/trusted-domains.json.
|
|
6
|
+
*
|
|
7
|
+
* Events:
|
|
8
|
+
* revocation.opened — a new suspension/revocation is issued
|
|
9
|
+
* revocation.reinstated — a revocation is lifted
|
|
10
|
+
* revocation.appeal_decided — an admin ruled on an appeal
|
|
11
|
+
*
|
|
12
|
+
* Each delivery is signed with HMAC-SHA256:
|
|
13
|
+
* X-WAB-Webhook-Signature: t=<unix_ts>,v1=<hex>
|
|
14
|
+
* where hex = HMAC_SHA256(secret, `${t}.${body}`)
|
|
15
|
+
*
|
|
16
|
+
* Retry policy: 3 attempts at t+0, t+30s, t+5m.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
const crypto = require('crypto');
|
|
22
|
+
const { db } = require('../models/db');
|
|
23
|
+
const { canonicalize } = require('./canonical-json');
|
|
24
|
+
const signer = require('./operator-signer');
|
|
25
|
+
|
|
26
|
+
const VALID_EVENTS = new Set([
|
|
27
|
+
'revocation.opened',
|
|
28
|
+
'revocation.reinstated',
|
|
29
|
+
'revocation.appeal_decided',
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
const RETRY_DELAYS_MS = [0, 30_000, 300_000];
|
|
33
|
+
const REQUEST_TIMEOUT_MS = Number(process.env.WAB_WEBHOOK_TIMEOUT_MS || 8000);
|
|
34
|
+
const MAX_SUBS_PER_USER = Number(process.env.WAB_WEBHOOK_MAX_PER_USER || 10);
|
|
35
|
+
|
|
36
|
+
function _ulid(prefix) {
|
|
37
|
+
return `${prefix}_${Date.now().toString(36)}${crypto.randomBytes(8).toString('hex')}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function _normalizeUrl(url) {
|
|
41
|
+
if (!url || typeof url !== 'string') throw _err('url required', 'bad_request', 400);
|
|
42
|
+
let u;
|
|
43
|
+
try { u = new URL(url); } catch (_) { throw _err('invalid url', 'bad_request', 400); }
|
|
44
|
+
if (u.protocol !== 'https:' && !(process.env.NODE_ENV === 'test' && u.protocol === 'http:')) {
|
|
45
|
+
throw _err('url must be https', 'bad_request', 400);
|
|
46
|
+
}
|
|
47
|
+
return u.toString();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function _err(msg, code, status) {
|
|
51
|
+
const e = new Error(msg); e.code = code; e.statusCode = status; return e;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function _normalizeEvents(events) {
|
|
55
|
+
if (!events) return Array.from(VALID_EVENTS);
|
|
56
|
+
const list = Array.isArray(events) ? events : String(events).split(',').map((s) => s.trim()).filter(Boolean);
|
|
57
|
+
if (!list.length) return Array.from(VALID_EVENTS);
|
|
58
|
+
for (const e of list) {
|
|
59
|
+
if (!VALID_EVENTS.has(e)) throw _err(`unknown event: ${e}`, 'bad_event', 400);
|
|
60
|
+
}
|
|
61
|
+
return list;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function _publicView(row) {
|
|
65
|
+
if (!row) return null;
|
|
66
|
+
return {
|
|
67
|
+
id: row.id,
|
|
68
|
+
url: row.url,
|
|
69
|
+
events: (row.events || '').split(',').filter(Boolean),
|
|
70
|
+
active: !!row.active,
|
|
71
|
+
description: row.description || null,
|
|
72
|
+
last_success_at: row.last_success_at,
|
|
73
|
+
last_error_at: row.last_error_at,
|
|
74
|
+
last_error: row.last_error,
|
|
75
|
+
consecutive_failures: row.consecutive_failures || 0,
|
|
76
|
+
created_at: row.created_at,
|
|
77
|
+
updated_at: row.updated_at,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function createSubscription({ userId, url, events, description }) {
|
|
82
|
+
if (!userId) throw _err('userId required', 'bad_request', 400);
|
|
83
|
+
const normalized = _normalizeUrl(url);
|
|
84
|
+
const eventList = _normalizeEvents(events);
|
|
85
|
+
const existing = db.prepare(
|
|
86
|
+
`SELECT COUNT(*) AS n FROM webhook_subscriptions WHERE user_id = ? AND active = 1`,
|
|
87
|
+
).get(userId).n;
|
|
88
|
+
if (existing >= MAX_SUBS_PER_USER) {
|
|
89
|
+
throw _err(`max ${MAX_SUBS_PER_USER} active subscriptions per user`, 'limit_exceeded', 429);
|
|
90
|
+
}
|
|
91
|
+
const id = _ulid('whsub');
|
|
92
|
+
const secret = crypto.randomBytes(32).toString('base64');
|
|
93
|
+
db.prepare(`
|
|
94
|
+
INSERT INTO webhook_subscriptions (id, user_id, url, secret, events, description)
|
|
95
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
96
|
+
`).run(id, String(userId), normalized, secret, eventList.join(','), description || null);
|
|
97
|
+
const row = db.prepare(`SELECT * FROM webhook_subscriptions WHERE id = ?`).get(id);
|
|
98
|
+
// Return secret only on create — never on list/get.
|
|
99
|
+
return { ..._publicView(row), secret };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function listSubscriptions(userId) {
|
|
103
|
+
return db.prepare(`
|
|
104
|
+
SELECT * FROM webhook_subscriptions WHERE user_id = ? ORDER BY created_at DESC
|
|
105
|
+
`).all(String(userId)).map(_publicView);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function getSubscription({ id, userId }) {
|
|
109
|
+
const row = db.prepare(`SELECT * FROM webhook_subscriptions WHERE id = ?`).get(id);
|
|
110
|
+
if (!row) throw _err('not found', 'not_found', 404);
|
|
111
|
+
if (String(row.user_id) !== String(userId)) throw _err('forbidden', 'forbidden', 403);
|
|
112
|
+
return _publicView(row);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function updateSubscription({ id, userId, url, events, active, description }) {
|
|
116
|
+
const row = db.prepare(`SELECT * FROM webhook_subscriptions WHERE id = ?`).get(id);
|
|
117
|
+
if (!row) throw _err('not found', 'not_found', 404);
|
|
118
|
+
if (String(row.user_id) !== String(userId)) throw _err('forbidden', 'forbidden', 403);
|
|
119
|
+
const patch = {};
|
|
120
|
+
if (url !== undefined) patch.url = _normalizeUrl(url);
|
|
121
|
+
if (events !== undefined) patch.events = _normalizeEvents(events).join(',');
|
|
122
|
+
if (active !== undefined) patch.active = active ? 1 : 0;
|
|
123
|
+
if (description !== undefined) patch.description = description || null;
|
|
124
|
+
if (!Object.keys(patch).length) return _publicView(row);
|
|
125
|
+
const sets = Object.keys(patch).map((k) => `${k} = ?`).join(', ');
|
|
126
|
+
db.prepare(`UPDATE webhook_subscriptions SET ${sets}, updated_at = datetime('now') WHERE id = ?`)
|
|
127
|
+
.run(...Object.values(patch), id);
|
|
128
|
+
return _publicView(db.prepare(`SELECT * FROM webhook_subscriptions WHERE id = ?`).get(id));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function deleteSubscription({ id, userId }) {
|
|
132
|
+
const row = db.prepare(`SELECT user_id FROM webhook_subscriptions WHERE id = ?`).get(id);
|
|
133
|
+
if (!row) throw _err('not found', 'not_found', 404);
|
|
134
|
+
if (String(row.user_id) !== String(userId)) throw _err('forbidden', 'forbidden', 403);
|
|
135
|
+
db.prepare(`DELETE FROM webhook_subscriptions WHERE id = ?`).run(id);
|
|
136
|
+
return { id, deleted: true };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function listDeliveries({ subscriptionId, userId, limit = 50 }) {
|
|
140
|
+
const sub = db.prepare(`SELECT user_id FROM webhook_subscriptions WHERE id = ?`).get(subscriptionId);
|
|
141
|
+
if (!sub) throw _err('not found', 'not_found', 404);
|
|
142
|
+
if (String(sub.user_id) !== String(userId)) throw _err('forbidden', 'forbidden', 403);
|
|
143
|
+
return db.prepare(`
|
|
144
|
+
SELECT id, event_id, event_type, status, attempts, last_status_code,
|
|
145
|
+
last_error, next_retry_at, created_at, delivered_at
|
|
146
|
+
FROM webhook_deliveries
|
|
147
|
+
WHERE subscription_id = ?
|
|
148
|
+
ORDER BY created_at DESC LIMIT ?
|
|
149
|
+
`).all(subscriptionId, Math.min(Math.max(limit, 1), 200));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── Dispatch ────────────────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
function _sign(secret, body) {
|
|
155
|
+
const t = Math.floor(Date.now() / 1000);
|
|
156
|
+
const mac = crypto.createHmac('sha256', secret).update(`${t}.${body}`).digest('hex');
|
|
157
|
+
return { header: `t=${t},v1=${mac}`, t, mac };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function _httpPost(url, body, headers) {
|
|
161
|
+
const ctrl = new AbortController();
|
|
162
|
+
const timer = setTimeout(() => ctrl.abort(), REQUEST_TIMEOUT_MS);
|
|
163
|
+
try {
|
|
164
|
+
const res = await fetch(url, {
|
|
165
|
+
method: 'POST',
|
|
166
|
+
headers: { 'Content-Type': 'application/json', ...headers },
|
|
167
|
+
body,
|
|
168
|
+
signal: ctrl.signal,
|
|
169
|
+
redirect: 'manual',
|
|
170
|
+
});
|
|
171
|
+
let text = '';
|
|
172
|
+
try { text = await res.text(); } catch (_) {}
|
|
173
|
+
return { ok: res.ok, status: res.status, body: text.slice(0, 512) };
|
|
174
|
+
} finally {
|
|
175
|
+
clearTimeout(timer);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function _attemptDelivery(deliveryId, subscription, body) {
|
|
180
|
+
const { header } = _sign(subscription.secret, body);
|
|
181
|
+
const headers = {
|
|
182
|
+
'X-WAB-Webhook-Signature': header,
|
|
183
|
+
'X-WAB-Webhook-Event': subscription._eventType,
|
|
184
|
+
'X-WAB-Webhook-Id': subscription._eventId,
|
|
185
|
+
'X-WAB-Webhook-Delivery': deliveryId,
|
|
186
|
+
'User-Agent': 'web-agent-bridge-webhooks/1.0',
|
|
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
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
const res = await _httpPost(subscription.url, body, headers);
|
|
194
|
+
if (res.ok) {
|
|
195
|
+
db.prepare(`
|
|
196
|
+
UPDATE webhook_deliveries
|
|
197
|
+
SET status = 'success', attempts = attempts + 1,
|
|
198
|
+
last_status_code = ?, last_error = NULL, delivered_at = datetime('now'),
|
|
199
|
+
next_retry_at = NULL
|
|
200
|
+
WHERE id = ?
|
|
201
|
+
`).run(res.status, deliveryId);
|
|
202
|
+
db.prepare(`
|
|
203
|
+
UPDATE webhook_subscriptions
|
|
204
|
+
SET last_success_at = datetime('now'), consecutive_failures = 0,
|
|
205
|
+
updated_at = datetime('now')
|
|
206
|
+
WHERE id = ?
|
|
207
|
+
`).run(subscription.id);
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
_recordFailure(deliveryId, subscription.id, res.status, `HTTP ${res.status}: ${res.body}`);
|
|
211
|
+
return false;
|
|
212
|
+
} catch (e) {
|
|
213
|
+
_recordFailure(deliveryId, subscription.id, null, String(e.message || e));
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function _recordFailure(deliveryId, subscriptionId, statusCode, errMsg) {
|
|
219
|
+
const row = db.prepare(`SELECT attempts FROM webhook_deliveries WHERE id = ?`).get(deliveryId);
|
|
220
|
+
const attempts = (row ? row.attempts : 0) + 1;
|
|
221
|
+
const isFinal = attempts >= RETRY_DELAYS_MS.length;
|
|
222
|
+
db.prepare(`
|
|
223
|
+
UPDATE webhook_deliveries
|
|
224
|
+
SET status = ?, attempts = ?, last_status_code = ?, last_error = ?,
|
|
225
|
+
next_retry_at = ?
|
|
226
|
+
WHERE id = ?
|
|
227
|
+
`).run(
|
|
228
|
+
isFinal ? 'failed' : 'pending',
|
|
229
|
+
attempts,
|
|
230
|
+
statusCode,
|
|
231
|
+
errMsg.slice(0, 1024),
|
|
232
|
+
isFinal ? null : new Date(Date.now() + RETRY_DELAYS_MS[attempts]).toISOString(),
|
|
233
|
+
deliveryId,
|
|
234
|
+
);
|
|
235
|
+
db.prepare(`
|
|
236
|
+
UPDATE webhook_subscriptions
|
|
237
|
+
SET last_error_at = datetime('now'), last_error = ?,
|
|
238
|
+
consecutive_failures = consecutive_failures + 1,
|
|
239
|
+
updated_at = datetime('now')
|
|
240
|
+
WHERE id = ?
|
|
241
|
+
`).run(errMsg.slice(0, 512), subscriptionId);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function _scheduleRetry(deliveryId, subscription, body, attempt) {
|
|
245
|
+
const delay = RETRY_DELAYS_MS[attempt];
|
|
246
|
+
if (delay === undefined) return;
|
|
247
|
+
const t = setTimeout(async () => {
|
|
248
|
+
const ok = await _attemptDelivery(deliveryId, subscription, body);
|
|
249
|
+
if (!ok) _scheduleRetry(deliveryId, subscription, body, attempt + 1);
|
|
250
|
+
}, delay);
|
|
251
|
+
if (t.unref) t.unref();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Emit an event to all matching active subscriptions. Non-blocking — schedules
|
|
256
|
+
* deliveries via setImmediate so callers (revocation flows) return fast.
|
|
257
|
+
*/
|
|
258
|
+
function emit(eventType, data) {
|
|
259
|
+
if (!VALID_EVENTS.has(eventType)) return 0;
|
|
260
|
+
const subs = db.prepare(`
|
|
261
|
+
SELECT * FROM webhook_subscriptions
|
|
262
|
+
WHERE active = 1 AND (',' || events || ',') LIKE ?
|
|
263
|
+
`).all(`%,${eventType},%`);
|
|
264
|
+
if (!subs.length) return 0;
|
|
265
|
+
|
|
266
|
+
const eventId = _ulid('evt');
|
|
267
|
+
const payload = {
|
|
268
|
+
id: eventId,
|
|
269
|
+
type: eventType,
|
|
270
|
+
created_at: new Date().toISOString(),
|
|
271
|
+
data,
|
|
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
|
+
}
|
|
287
|
+
const body = JSON.stringify(payload);
|
|
288
|
+
|
|
289
|
+
for (const sub of subs) {
|
|
290
|
+
const deliveryId = _ulid('whd');
|
|
291
|
+
db.prepare(`
|
|
292
|
+
INSERT INTO webhook_deliveries
|
|
293
|
+
(id, subscription_id, event_id, event_type, payload, status, attempts)
|
|
294
|
+
VALUES (?, ?, ?, ?, ?, 'pending', 0)
|
|
295
|
+
`).run(deliveryId, sub.id, eventId, eventType, body);
|
|
296
|
+
const enriched = {
|
|
297
|
+
...sub,
|
|
298
|
+
_eventId: eventId,
|
|
299
|
+
_eventType: eventType,
|
|
300
|
+
_operatorSignature: operatorSignature,
|
|
301
|
+
};
|
|
302
|
+
setImmediate(async () => {
|
|
303
|
+
const ok = await _attemptDelivery(deliveryId, enriched, body);
|
|
304
|
+
if (!ok) _scheduleRetry(deliveryId, enriched, body, 1);
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
return subs.length;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Verify a delivery signature server-side (for tests + receiver SDKs). */
|
|
311
|
+
function verifySignature({ secret, header, body, toleranceSec = 300 }) {
|
|
312
|
+
if (!header || typeof header !== 'string') return false;
|
|
313
|
+
const parts = Object.fromEntries(
|
|
314
|
+
header.split(',').map((p) => p.split('=').map((s) => s.trim())),
|
|
315
|
+
);
|
|
316
|
+
const t = Number(parts.t);
|
|
317
|
+
const v1 = parts.v1;
|
|
318
|
+
if (!t || !v1) return false;
|
|
319
|
+
if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSec) return false;
|
|
320
|
+
const expected = crypto.createHmac('sha256', secret).update(`${t}.${body}`).digest('hex');
|
|
321
|
+
try {
|
|
322
|
+
return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(v1, 'hex'));
|
|
323
|
+
} catch (_) { return false; }
|
|
324
|
+
}
|
|
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
|
+
|
|
360
|
+
module.exports = {
|
|
361
|
+
createSubscription,
|
|
362
|
+
listSubscriptions,
|
|
363
|
+
getSubscription,
|
|
364
|
+
updateSubscription,
|
|
365
|
+
deleteSubscription,
|
|
366
|
+
listDeliveries,
|
|
367
|
+
emit,
|
|
368
|
+
verifySignature,
|
|
369
|
+
verifyOperatorSignature,
|
|
370
|
+
VALID_EVENTS,
|
|
371
|
+
// exposed for tests
|
|
372
|
+
_attemptDelivery,
|
|
373
|
+
};
|