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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "web-agent-bridge",
3
- "version": "3.14.0",
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);
@@ -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/revocations/feed.json — JSON Feed 1.1 of the transparency log.
11
- * GET /api/revocations/feed.xml Atom 1.0 of the transparency log.
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
- return {
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: new Date().toISOString(),
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
- return db.prepare(`SELECT * FROM site_revocations WHERE id = ?`).get(id);
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
- return db.prepare(`SELECT * FROM site_revocations WHERE id = ?`).get(revocationId);
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
- return db.prepare(`SELECT * FROM site_revocations WHERE id = ?`).get(revocationId);
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
+ };