web-agent-bridge 3.14.0 → 3.16.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/server/index.js +3 -0
- package/server/migrations/025_revocation_webhooks.sql +55 -0
- package/server/routes/network.js +124 -7
- package/server/routes/webhooks.js +75 -0
- package/server/services/operator-signer.js +98 -0
- package/server/services/revocations.js +33 -3
- package/server/services/webhooks.js +313 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "web-agent-bridge",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.16.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/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);
|
package/server/routes/network.js
CHANGED
|
@@ -1,25 +1,46 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Network-effect public endpoints (v3.14.0).
|
|
4
|
+
* Network-effect public endpoints (v3.14.0 + signed snapshots v3.15.0).
|
|
5
5
|
*
|
|
6
|
-
* GET /api/trusted-domains.json — snapshot of currently-attested,
|
|
6
|
+
* GET /api/trusted-domains.json — signed snapshot of currently-attested,
|
|
7
7
|
* non-revoked WAB sites. Cached 1 hour. Designed for agent bootstrap
|
|
8
8
|
* and third-party crawlers building "verified web" indexes.
|
|
9
9
|
* GET /api/trusted-domains.txt — same data, newline-separated domains.
|
|
10
|
-
* GET /api/
|
|
11
|
-
* GET /api/
|
|
10
|
+
* GET /api/trusted-domains/archive.json — manifest of available daily snapshots.
|
|
11
|
+
* GET /api/trusted-domains/:date.json — historical signed snapshot.
|
|
12
|
+
* GET /api/transparency/feed.json — JSON Feed 1.1 of the transparency log.
|
|
13
|
+
* GET /api/transparency/feed.xml — Atom 1.0 of the transparency log.
|
|
14
|
+
* GET /api/operator-key.json — operator Ed25519 public key (b64 + JWK).
|
|
12
15
|
*
|
|
13
16
|
* Mounted at /api in server/index.js.
|
|
14
17
|
*/
|
|
15
18
|
|
|
16
19
|
const express = require('express');
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
const crypto = require('crypto');
|
|
17
23
|
const router = express.Router();
|
|
18
24
|
const { db } = require('../models/db');
|
|
25
|
+
const { canonicalize } = require('../services/canonical-json');
|
|
26
|
+
const signer = require('../services/operator-signer');
|
|
19
27
|
|
|
20
28
|
const SNAPSHOT_TTL_MS = 60 * 60 * 1000; // 1h
|
|
29
|
+
const ARCHIVE_DIR = process.env.WAB_SNAPSHOT_DIR ||
|
|
30
|
+
path.join(__dirname, '..', '..',
|
|
31
|
+
(process.env.NODE_ENV === 'test' ? 'data-test' : 'data'),
|
|
32
|
+
'snapshots');
|
|
33
|
+
|
|
21
34
|
let _snapshotCache = { ts: 0, data: null };
|
|
22
35
|
|
|
36
|
+
function _ensureArchiveDir() {
|
|
37
|
+
try { fs.mkdirSync(ARCHIVE_DIR, { recursive: true }); } catch (_) { /* ignore */ }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function _todayUtc() {
|
|
41
|
+
return new Date().toISOString().slice(0, 10); // YYYY-MM-DD
|
|
42
|
+
}
|
|
43
|
+
|
|
23
44
|
function buildSnapshot() {
|
|
24
45
|
// Active sites that have no active blocking revocation.
|
|
25
46
|
let rows = [];
|
|
@@ -37,16 +58,18 @@ function buildSnapshot() {
|
|
|
37
58
|
ORDER BY s.created_at ASC
|
|
38
59
|
`).all();
|
|
39
60
|
} catch (_) {
|
|
40
|
-
// site_revocations may not yet exist on a very first boot
|
|
41
61
|
rows = db.prepare(`
|
|
42
62
|
SELECT id, domain, name, description, tier, created_at
|
|
43
63
|
FROM sites WHERE active = 1 ORDER BY created_at ASC
|
|
44
64
|
`).all();
|
|
45
65
|
}
|
|
46
66
|
|
|
47
|
-
|
|
67
|
+
const generated_at = new Date().toISOString();
|
|
68
|
+
const date = generated_at.slice(0, 10);
|
|
69
|
+
const payload = {
|
|
48
70
|
schema: 'wab-trusted-domains/v1',
|
|
49
|
-
generated_at
|
|
71
|
+
generated_at,
|
|
72
|
+
date,
|
|
50
73
|
total: rows.length,
|
|
51
74
|
domains: rows.map(r => ({
|
|
52
75
|
domain: r.domain,
|
|
@@ -57,6 +80,29 @@ function buildSnapshot() {
|
|
|
57
80
|
badge_url: 'https://webagentbridge.com/api/discovery/badge/' + r.domain + '.svg'
|
|
58
81
|
}))
|
|
59
82
|
};
|
|
83
|
+
|
|
84
|
+
// Hash + sign over the canonical bytes of `payload` (without signature fields).
|
|
85
|
+
const canonical = canonicalize(payload);
|
|
86
|
+
const content_hash = 'sha256:' + crypto.createHash('sha256').update(canonical, 'utf8').digest('hex');
|
|
87
|
+
const signature = signer.sign(payload);
|
|
88
|
+
|
|
89
|
+
const out = Object.assign({}, payload, {
|
|
90
|
+
content_hash,
|
|
91
|
+
signature: signature
|
|
92
|
+
? { alg: signer.ALGORITHM, value: signature, key_url: '/api/operator-key.json' }
|
|
93
|
+
: null,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Persist today's snapshot to disk for time-machine queries (idempotent overwrite).
|
|
97
|
+
try {
|
|
98
|
+
_ensureArchiveDir();
|
|
99
|
+
const file = path.join(ARCHIVE_DIR, date + '.json');
|
|
100
|
+
fs.writeFileSync(file, JSON.stringify(out, null, 2) + '\n', 'utf8');
|
|
101
|
+
} catch (e) {
|
|
102
|
+
console.warn('[network] snapshot archive write failed (non-fatal):', e.message);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return out;
|
|
60
106
|
}
|
|
61
107
|
|
|
62
108
|
function getSnapshot() {
|
|
@@ -73,6 +119,8 @@ router.get('/trusted-domains.json', (req, res) => {
|
|
|
73
119
|
const snap = getSnapshot();
|
|
74
120
|
res.set('Cache-Control', 'public, max-age=3600, s-maxage=3600');
|
|
75
121
|
res.set('X-WAB-Snapshot-Schema', snap.schema);
|
|
122
|
+
res.set('X-WAB-Snapshot-Hash', snap.content_hash);
|
|
123
|
+
if (snap.signature) res.set('X-WAB-Snapshot-Signature', snap.signature.value);
|
|
76
124
|
res.json(snap);
|
|
77
125
|
});
|
|
78
126
|
|
|
@@ -83,6 +131,75 @@ router.get('/trusted-domains.txt', (req, res) => {
|
|
|
83
131
|
res.send(snap.domains.map(d => d.domain).join('\n') + '\n');
|
|
84
132
|
});
|
|
85
133
|
|
|
134
|
+
// ── Daily archive ────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
function _listArchiveDates() {
|
|
137
|
+
try {
|
|
138
|
+
_ensureArchiveDir();
|
|
139
|
+
return fs.readdirSync(ARCHIVE_DIR)
|
|
140
|
+
.filter(f => /^\d{4}-\d{2}-\d{2}\.json$/.test(f))
|
|
141
|
+
.map(f => f.replace(/\.json$/, ''))
|
|
142
|
+
.sort();
|
|
143
|
+
} catch (_) { return []; }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
router.get('/trusted-domains/archive.json', (req, res) => {
|
|
147
|
+
// Touch today's snapshot to ensure at least today is archived.
|
|
148
|
+
getSnapshot();
|
|
149
|
+
const dates = _listArchiveDates();
|
|
150
|
+
const manifest = {
|
|
151
|
+
schema: 'wab-trusted-domains-archive/v1',
|
|
152
|
+
generated_at: new Date().toISOString(),
|
|
153
|
+
total: dates.length,
|
|
154
|
+
snapshots: dates.map(d => ({
|
|
155
|
+
date: d,
|
|
156
|
+
url: '/api/trusted-domains/' + d + '.json',
|
|
157
|
+
})),
|
|
158
|
+
};
|
|
159
|
+
const sig = signer.sign(manifest);
|
|
160
|
+
if (sig) manifest.signature = { alg: signer.ALGORITHM, value: sig, key_url: '/api/operator-key.json' };
|
|
161
|
+
res.set('Cache-Control', 'public, max-age=3600, s-maxage=3600');
|
|
162
|
+
res.json(manifest);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
router.get('/trusted-domains/:date.json', (req, res) => {
|
|
166
|
+
const date = req.params.date;
|
|
167
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
|
168
|
+
return res.status(400).json({ error: 'invalid_date', hint: 'Use YYYY-MM-DD.' });
|
|
169
|
+
}
|
|
170
|
+
// Serve today live (so a fresh boot doesn't return 404 before the first snapshot).
|
|
171
|
+
if (date === _todayUtc()) {
|
|
172
|
+
return res.json(getSnapshot());
|
|
173
|
+
}
|
|
174
|
+
_ensureArchiveDir();
|
|
175
|
+
const file = path.join(ARCHIVE_DIR, date + '.json');
|
|
176
|
+
if (!fs.existsSync(file)) {
|
|
177
|
+
return res.status(404).json({ error: 'snapshot_not_found', date });
|
|
178
|
+
}
|
|
179
|
+
res.set('Cache-Control', 'public, max-age=86400, s-maxage=86400, immutable');
|
|
180
|
+
res.type('application/json; charset=utf-8');
|
|
181
|
+
res.send(fs.readFileSync(file, 'utf8'));
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// ── Operator public key ──────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
router.get('/operator-key.json', (req, res) => {
|
|
187
|
+
const pub = signer.publicKey();
|
|
188
|
+
if (!pub) {
|
|
189
|
+
return res.status(503).json({ error: 'signing_not_configured' });
|
|
190
|
+
}
|
|
191
|
+
res.set('Cache-Control', 'public, max-age=86400, s-maxage=86400');
|
|
192
|
+
res.json({
|
|
193
|
+
schema: 'wab-operator-key/v1',
|
|
194
|
+
alg: signer.ALGORITHM,
|
|
195
|
+
public_key_b64: pub.b64,
|
|
196
|
+
jwk: pub.jwk,
|
|
197
|
+
issued_at: new Date().toISOString(),
|
|
198
|
+
notice: 'Use this key to verify signatures on /api/trusted-domains.json and /api/trusted-domains/*.json.',
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
|
|
86
203
|
// ── Revocation feeds ─────────────────────────────────────────────────
|
|
87
204
|
|
|
88
205
|
function listRecentRevocations(limit) {
|
|
@@ -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;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Operator signing service — Ed25519 + RFC 8785.
|
|
5
|
+
*
|
|
6
|
+
* Loads WAB_OPERATOR_ED25519_PRIV (PKCS8 base64-DER) once and exposes:
|
|
7
|
+
* sign(payload) — returns base64 Ed25519 signature over canonicalize(payload).
|
|
8
|
+
* publicKey() — returns the operator's public key as {b64, jwk} (raw 32-byte b64 + JWK).
|
|
9
|
+
* isConfigured() — whether a signing key is available.
|
|
10
|
+
* ALGORITHM — 'ed25519' constant.
|
|
11
|
+
*
|
|
12
|
+
* The same private key is already used by services/revocations.js to sign revocation
|
|
13
|
+
* decisions; this module unifies usage so other surfaces (snapshots, manifests) can
|
|
14
|
+
* sign with the same identity.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const crypto = require('crypto');
|
|
18
|
+
const { canonicalize } = require('./canonical-json');
|
|
19
|
+
|
|
20
|
+
const ALGORITHM = 'ed25519';
|
|
21
|
+
const PRIV_B64 = process.env.WAB_OPERATOR_ED25519_PRIV || '';
|
|
22
|
+
|
|
23
|
+
let _priv = null;
|
|
24
|
+
let _pub = null;
|
|
25
|
+
|
|
26
|
+
function _load() {
|
|
27
|
+
if (_priv || !PRIV_B64) return;
|
|
28
|
+
try {
|
|
29
|
+
const der = Buffer.from(PRIV_B64, 'base64');
|
|
30
|
+
_priv = crypto.createPrivateKey({ key: der, format: 'der', type: 'pkcs8' });
|
|
31
|
+
const pubKey = crypto.createPublicKey(_priv);
|
|
32
|
+
const rawDer = pubKey.export({ format: 'der', type: 'spki' });
|
|
33
|
+
// SPKI for Ed25519 is 44 bytes; raw key is the last 32.
|
|
34
|
+
const raw = rawDer.slice(-32);
|
|
35
|
+
_pub = {
|
|
36
|
+
b64: raw.toString('base64'),
|
|
37
|
+
jwk: {
|
|
38
|
+
kty: 'OKP',
|
|
39
|
+
crv: 'Ed25519',
|
|
40
|
+
x: raw.toString('base64url'),
|
|
41
|
+
alg: 'EdDSA',
|
|
42
|
+
use: 'sig',
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
} catch (e) {
|
|
46
|
+
console.warn('[operator-signer] key load failed (non-fatal):', e.message);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isConfigured() {
|
|
51
|
+
_load();
|
|
52
|
+
return !!_priv;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function sign(payload) {
|
|
56
|
+
_load();
|
|
57
|
+
if (!_priv) return null;
|
|
58
|
+
try {
|
|
59
|
+
const sig = crypto.sign(null, Buffer.from(canonicalize(payload), 'utf8'), _priv);
|
|
60
|
+
return sig.toString('base64');
|
|
61
|
+
} catch (e) {
|
|
62
|
+
console.warn('[operator-signer] sign failed:', e.message);
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function publicKey() {
|
|
68
|
+
_load();
|
|
69
|
+
return _pub;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Verify a signature against a payload using the operator's public key.
|
|
74
|
+
* Returns true/false. Convenience helper for tests and self-verification.
|
|
75
|
+
*/
|
|
76
|
+
function verify(payload, signatureB64) {
|
|
77
|
+
_load();
|
|
78
|
+
if (!_pub) return false;
|
|
79
|
+
try {
|
|
80
|
+
const der = Buffer.from(PRIV_B64, 'base64');
|
|
81
|
+
const priv = crypto.createPrivateKey({ key: der, format: 'der', type: 'pkcs8' });
|
|
82
|
+
const pubKey = crypto.createPublicKey(priv);
|
|
83
|
+
return crypto.verify(
|
|
84
|
+
null,
|
|
85
|
+
Buffer.from(canonicalize(payload), 'utf8'),
|
|
86
|
+
pubKey,
|
|
87
|
+
Buffer.from(signatureB64, 'base64')
|
|
88
|
+
);
|
|
89
|
+
} catch (_) { return false; }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = {
|
|
93
|
+
ALGORITHM,
|
|
94
|
+
sign,
|
|
95
|
+
verify,
|
|
96
|
+
publicKey,
|
|
97
|
+
isConfigured,
|
|
98
|
+
};
|
|
@@ -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,313 @@
|
|
|
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
|
+
|
|
24
|
+
const VALID_EVENTS = new Set([
|
|
25
|
+
'revocation.opened',
|
|
26
|
+
'revocation.reinstated',
|
|
27
|
+
'revocation.appeal_decided',
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
const RETRY_DELAYS_MS = [0, 30_000, 300_000];
|
|
31
|
+
const REQUEST_TIMEOUT_MS = Number(process.env.WAB_WEBHOOK_TIMEOUT_MS || 8000);
|
|
32
|
+
const MAX_SUBS_PER_USER = Number(process.env.WAB_WEBHOOK_MAX_PER_USER || 10);
|
|
33
|
+
|
|
34
|
+
function _ulid(prefix) {
|
|
35
|
+
return `${prefix}_${Date.now().toString(36)}${crypto.randomBytes(8).toString('hex')}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function _normalizeUrl(url) {
|
|
39
|
+
if (!url || typeof url !== 'string') throw _err('url required', 'bad_request', 400);
|
|
40
|
+
let u;
|
|
41
|
+
try { u = new URL(url); } catch (_) { throw _err('invalid url', 'bad_request', 400); }
|
|
42
|
+
if (u.protocol !== 'https:' && !(process.env.NODE_ENV === 'test' && u.protocol === 'http:')) {
|
|
43
|
+
throw _err('url must be https', 'bad_request', 400);
|
|
44
|
+
}
|
|
45
|
+
return u.toString();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function _err(msg, code, status) {
|
|
49
|
+
const e = new Error(msg); e.code = code; e.statusCode = status; return e;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function _normalizeEvents(events) {
|
|
53
|
+
if (!events) return Array.from(VALID_EVENTS);
|
|
54
|
+
const list = Array.isArray(events) ? events : String(events).split(',').map((s) => s.trim()).filter(Boolean);
|
|
55
|
+
if (!list.length) return Array.from(VALID_EVENTS);
|
|
56
|
+
for (const e of list) {
|
|
57
|
+
if (!VALID_EVENTS.has(e)) throw _err(`unknown event: ${e}`, 'bad_event', 400);
|
|
58
|
+
}
|
|
59
|
+
return list;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function _publicView(row) {
|
|
63
|
+
if (!row) return null;
|
|
64
|
+
return {
|
|
65
|
+
id: row.id,
|
|
66
|
+
url: row.url,
|
|
67
|
+
events: (row.events || '').split(',').filter(Boolean),
|
|
68
|
+
active: !!row.active,
|
|
69
|
+
description: row.description || null,
|
|
70
|
+
last_success_at: row.last_success_at,
|
|
71
|
+
last_error_at: row.last_error_at,
|
|
72
|
+
last_error: row.last_error,
|
|
73
|
+
consecutive_failures: row.consecutive_failures || 0,
|
|
74
|
+
created_at: row.created_at,
|
|
75
|
+
updated_at: row.updated_at,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function createSubscription({ userId, url, events, description }) {
|
|
80
|
+
if (!userId) throw _err('userId required', 'bad_request', 400);
|
|
81
|
+
const normalized = _normalizeUrl(url);
|
|
82
|
+
const eventList = _normalizeEvents(events);
|
|
83
|
+
const existing = db.prepare(
|
|
84
|
+
`SELECT COUNT(*) AS n FROM webhook_subscriptions WHERE user_id = ? AND active = 1`,
|
|
85
|
+
).get(userId).n;
|
|
86
|
+
if (existing >= MAX_SUBS_PER_USER) {
|
|
87
|
+
throw _err(`max ${MAX_SUBS_PER_USER} active subscriptions per user`, 'limit_exceeded', 429);
|
|
88
|
+
}
|
|
89
|
+
const id = _ulid('whsub');
|
|
90
|
+
const secret = crypto.randomBytes(32).toString('base64');
|
|
91
|
+
db.prepare(`
|
|
92
|
+
INSERT INTO webhook_subscriptions (id, user_id, url, secret, events, description)
|
|
93
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
94
|
+
`).run(id, String(userId), normalized, secret, eventList.join(','), description || null);
|
|
95
|
+
const row = db.prepare(`SELECT * FROM webhook_subscriptions WHERE id = ?`).get(id);
|
|
96
|
+
// Return secret only on create — never on list/get.
|
|
97
|
+
return { ..._publicView(row), secret };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function listSubscriptions(userId) {
|
|
101
|
+
return db.prepare(`
|
|
102
|
+
SELECT * FROM webhook_subscriptions WHERE user_id = ? ORDER BY created_at DESC
|
|
103
|
+
`).all(String(userId)).map(_publicView);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function getSubscription({ id, userId }) {
|
|
107
|
+
const row = db.prepare(`SELECT * FROM webhook_subscriptions WHERE id = ?`).get(id);
|
|
108
|
+
if (!row) throw _err('not found', 'not_found', 404);
|
|
109
|
+
if (String(row.user_id) !== String(userId)) throw _err('forbidden', 'forbidden', 403);
|
|
110
|
+
return _publicView(row);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function updateSubscription({ id, userId, url, events, active, description }) {
|
|
114
|
+
const row = db.prepare(`SELECT * FROM webhook_subscriptions WHERE id = ?`).get(id);
|
|
115
|
+
if (!row) throw _err('not found', 'not_found', 404);
|
|
116
|
+
if (String(row.user_id) !== String(userId)) throw _err('forbidden', 'forbidden', 403);
|
|
117
|
+
const patch = {};
|
|
118
|
+
if (url !== undefined) patch.url = _normalizeUrl(url);
|
|
119
|
+
if (events !== undefined) patch.events = _normalizeEvents(events).join(',');
|
|
120
|
+
if (active !== undefined) patch.active = active ? 1 : 0;
|
|
121
|
+
if (description !== undefined) patch.description = description || null;
|
|
122
|
+
if (!Object.keys(patch).length) return _publicView(row);
|
|
123
|
+
const sets = Object.keys(patch).map((k) => `${k} = ?`).join(', ');
|
|
124
|
+
db.prepare(`UPDATE webhook_subscriptions SET ${sets}, updated_at = datetime('now') WHERE id = ?`)
|
|
125
|
+
.run(...Object.values(patch), id);
|
|
126
|
+
return _publicView(db.prepare(`SELECT * FROM webhook_subscriptions WHERE id = ?`).get(id));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function deleteSubscription({ id, userId }) {
|
|
130
|
+
const row = db.prepare(`SELECT user_id FROM webhook_subscriptions WHERE id = ?`).get(id);
|
|
131
|
+
if (!row) throw _err('not found', 'not_found', 404);
|
|
132
|
+
if (String(row.user_id) !== String(userId)) throw _err('forbidden', 'forbidden', 403);
|
|
133
|
+
db.prepare(`DELETE FROM webhook_subscriptions WHERE id = ?`).run(id);
|
|
134
|
+
return { id, deleted: true };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function listDeliveries({ subscriptionId, userId, limit = 50 }) {
|
|
138
|
+
const sub = db.prepare(`SELECT user_id FROM webhook_subscriptions WHERE id = ?`).get(subscriptionId);
|
|
139
|
+
if (!sub) throw _err('not found', 'not_found', 404);
|
|
140
|
+
if (String(sub.user_id) !== String(userId)) throw _err('forbidden', 'forbidden', 403);
|
|
141
|
+
return db.prepare(`
|
|
142
|
+
SELECT id, event_id, event_type, status, attempts, last_status_code,
|
|
143
|
+
last_error, next_retry_at, created_at, delivered_at
|
|
144
|
+
FROM webhook_deliveries
|
|
145
|
+
WHERE subscription_id = ?
|
|
146
|
+
ORDER BY created_at DESC LIMIT ?
|
|
147
|
+
`).all(subscriptionId, Math.min(Math.max(limit, 1), 200));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── Dispatch ────────────────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
function _sign(secret, body) {
|
|
153
|
+
const t = Math.floor(Date.now() / 1000);
|
|
154
|
+
const mac = crypto.createHmac('sha256', secret).update(`${t}.${body}`).digest('hex');
|
|
155
|
+
return { header: `t=${t},v1=${mac}`, t, mac };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function _httpPost(url, body, headers) {
|
|
159
|
+
const ctrl = new AbortController();
|
|
160
|
+
const timer = setTimeout(() => ctrl.abort(), REQUEST_TIMEOUT_MS);
|
|
161
|
+
try {
|
|
162
|
+
const res = await fetch(url, {
|
|
163
|
+
method: 'POST',
|
|
164
|
+
headers: { 'Content-Type': 'application/json', ...headers },
|
|
165
|
+
body,
|
|
166
|
+
signal: ctrl.signal,
|
|
167
|
+
redirect: 'manual',
|
|
168
|
+
});
|
|
169
|
+
let text = '';
|
|
170
|
+
try { text = await res.text(); } catch (_) {}
|
|
171
|
+
return { ok: res.ok, status: res.status, body: text.slice(0, 512) };
|
|
172
|
+
} finally {
|
|
173
|
+
clearTimeout(timer);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function _attemptDelivery(deliveryId, subscription, body) {
|
|
178
|
+
const { header } = _sign(subscription.secret, body);
|
|
179
|
+
const headers = {
|
|
180
|
+
'X-WAB-Webhook-Signature': header,
|
|
181
|
+
'X-WAB-Webhook-Event': subscription._eventType,
|
|
182
|
+
'X-WAB-Webhook-Id': subscription._eventId,
|
|
183
|
+
'X-WAB-Webhook-Delivery': deliveryId,
|
|
184
|
+
'User-Agent': 'web-agent-bridge-webhooks/1.0',
|
|
185
|
+
};
|
|
186
|
+
try {
|
|
187
|
+
const res = await _httpPost(subscription.url, body, headers);
|
|
188
|
+
if (res.ok) {
|
|
189
|
+
db.prepare(`
|
|
190
|
+
UPDATE webhook_deliveries
|
|
191
|
+
SET status = 'success', attempts = attempts + 1,
|
|
192
|
+
last_status_code = ?, last_error = NULL, delivered_at = datetime('now'),
|
|
193
|
+
next_retry_at = NULL
|
|
194
|
+
WHERE id = ?
|
|
195
|
+
`).run(res.status, deliveryId);
|
|
196
|
+
db.prepare(`
|
|
197
|
+
UPDATE webhook_subscriptions
|
|
198
|
+
SET last_success_at = datetime('now'), consecutive_failures = 0,
|
|
199
|
+
updated_at = datetime('now')
|
|
200
|
+
WHERE id = ?
|
|
201
|
+
`).run(subscription.id);
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
_recordFailure(deliveryId, subscription.id, res.status, `HTTP ${res.status}: ${res.body}`);
|
|
205
|
+
return false;
|
|
206
|
+
} catch (e) {
|
|
207
|
+
_recordFailure(deliveryId, subscription.id, null, String(e.message || e));
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function _recordFailure(deliveryId, subscriptionId, statusCode, errMsg) {
|
|
213
|
+
const row = db.prepare(`SELECT attempts FROM webhook_deliveries WHERE id = ?`).get(deliveryId);
|
|
214
|
+
const attempts = (row ? row.attempts : 0) + 1;
|
|
215
|
+
const isFinal = attempts >= RETRY_DELAYS_MS.length;
|
|
216
|
+
db.prepare(`
|
|
217
|
+
UPDATE webhook_deliveries
|
|
218
|
+
SET status = ?, attempts = ?, last_status_code = ?, last_error = ?,
|
|
219
|
+
next_retry_at = ?
|
|
220
|
+
WHERE id = ?
|
|
221
|
+
`).run(
|
|
222
|
+
isFinal ? 'failed' : 'pending',
|
|
223
|
+
attempts,
|
|
224
|
+
statusCode,
|
|
225
|
+
errMsg.slice(0, 1024),
|
|
226
|
+
isFinal ? null : new Date(Date.now() + RETRY_DELAYS_MS[attempts]).toISOString(),
|
|
227
|
+
deliveryId,
|
|
228
|
+
);
|
|
229
|
+
db.prepare(`
|
|
230
|
+
UPDATE webhook_subscriptions
|
|
231
|
+
SET last_error_at = datetime('now'), last_error = ?,
|
|
232
|
+
consecutive_failures = consecutive_failures + 1,
|
|
233
|
+
updated_at = datetime('now')
|
|
234
|
+
WHERE id = ?
|
|
235
|
+
`).run(errMsg.slice(0, 512), subscriptionId);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function _scheduleRetry(deliveryId, subscription, body, attempt) {
|
|
239
|
+
const delay = RETRY_DELAYS_MS[attempt];
|
|
240
|
+
if (delay === undefined) return;
|
|
241
|
+
const t = setTimeout(async () => {
|
|
242
|
+
const ok = await _attemptDelivery(deliveryId, subscription, body);
|
|
243
|
+
if (!ok) _scheduleRetry(deliveryId, subscription, body, attempt + 1);
|
|
244
|
+
}, delay);
|
|
245
|
+
if (t.unref) t.unref();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Emit an event to all matching active subscriptions. Non-blocking — schedules
|
|
250
|
+
* deliveries via setImmediate so callers (revocation flows) return fast.
|
|
251
|
+
*/
|
|
252
|
+
function emit(eventType, data) {
|
|
253
|
+
if (!VALID_EVENTS.has(eventType)) return 0;
|
|
254
|
+
const subs = db.prepare(`
|
|
255
|
+
SELECT * FROM webhook_subscriptions
|
|
256
|
+
WHERE active = 1 AND (',' || events || ',') LIKE ?
|
|
257
|
+
`).all(`%,${eventType},%`);
|
|
258
|
+
if (!subs.length) return 0;
|
|
259
|
+
|
|
260
|
+
const eventId = _ulid('evt');
|
|
261
|
+
const payload = {
|
|
262
|
+
id: eventId,
|
|
263
|
+
type: eventType,
|
|
264
|
+
created_at: new Date().toISOString(),
|
|
265
|
+
data,
|
|
266
|
+
};
|
|
267
|
+
const body = JSON.stringify(payload);
|
|
268
|
+
|
|
269
|
+
for (const sub of subs) {
|
|
270
|
+
const deliveryId = _ulid('whd');
|
|
271
|
+
db.prepare(`
|
|
272
|
+
INSERT INTO webhook_deliveries
|
|
273
|
+
(id, subscription_id, event_id, event_type, payload, status, attempts)
|
|
274
|
+
VALUES (?, ?, ?, ?, ?, 'pending', 0)
|
|
275
|
+
`).run(deliveryId, sub.id, eventId, eventType, body);
|
|
276
|
+
const enriched = { ...sub, _eventId: eventId, _eventType: eventType };
|
|
277
|
+
setImmediate(async () => {
|
|
278
|
+
const ok = await _attemptDelivery(deliveryId, enriched, body);
|
|
279
|
+
if (!ok) _scheduleRetry(deliveryId, enriched, body, 1);
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
return subs.length;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Verify a delivery signature server-side (for tests + receiver SDKs). */
|
|
286
|
+
function verifySignature({ secret, header, body, toleranceSec = 300 }) {
|
|
287
|
+
if (!header || typeof header !== 'string') return false;
|
|
288
|
+
const parts = Object.fromEntries(
|
|
289
|
+
header.split(',').map((p) => p.split('=').map((s) => s.trim())),
|
|
290
|
+
);
|
|
291
|
+
const t = Number(parts.t);
|
|
292
|
+
const v1 = parts.v1;
|
|
293
|
+
if (!t || !v1) return false;
|
|
294
|
+
if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSec) return false;
|
|
295
|
+
const expected = crypto.createHmac('sha256', secret).update(`${t}.${body}`).digest('hex');
|
|
296
|
+
try {
|
|
297
|
+
return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(v1, 'hex'));
|
|
298
|
+
} catch (_) { return false; }
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
module.exports = {
|
|
302
|
+
createSubscription,
|
|
303
|
+
listSubscriptions,
|
|
304
|
+
getSubscription,
|
|
305
|
+
updateSubscription,
|
|
306
|
+
deleteSubscription,
|
|
307
|
+
listDeliveries,
|
|
308
|
+
emit,
|
|
309
|
+
verifySignature,
|
|
310
|
+
VALID_EVENTS,
|
|
311
|
+
// exposed for tests
|
|
312
|
+
_attemptDelivery,
|
|
313
|
+
};
|