web-agent-bridge 3.16.0 → 3.20.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.
Files changed (61) hide show
  1. package/README.ar.md +27 -8
  2. package/README.md +95 -0
  3. package/bin/wab-init.js +38 -0
  4. package/package.json +1 -1
  5. package/public/atp-semantics.html +216 -0
  6. package/public/benchmarks.html +151 -0
  7. package/public/dashboard.html +1 -0
  8. package/public/docs.html +113 -43
  9. package/public/index.html +142 -8
  10. package/public/key-rotation.html +184 -0
  11. package/public/llms.txt +54 -0
  12. package/public/notary.html +94 -0
  13. package/public/observatory.html +103 -0
  14. package/public/research.html +57 -0
  15. package/public/researchers.html +113 -0
  16. package/public/responsible-disclosure.html +294 -0
  17. package/public/robots.txt +17 -0
  18. package/public/security.html +157 -0
  19. package/public/threat-model.html +153 -0
  20. package/public/viral-coefficient.html +533 -0
  21. package/public/wab-dataset.html +501 -0
  22. package/public/wab-email.html +78 -0
  23. package/public/wab-lens.html +61 -0
  24. package/public/wab-p2p.html +96 -0
  25. package/public/wab-registry.html +481 -0
  26. package/public/wab-today.html +448 -0
  27. package/public/wab-uri.html +88 -0
  28. package/public/webhooks.html +181 -0
  29. package/script/ai-agent-bridge.js +24 -4
  30. package/server/index.js +1193 -827
  31. package/server/models/db.js +2 -1
  32. package/server/routes/admin-shieldlink.js +1 -1
  33. package/server/routes/admin-shieldqr.js +1 -1
  34. package/server/routes/admin-trust-monitor.js +1 -1
  35. package/server/routes/api-keys.js +2 -1
  36. package/server/routes/customer-shieldlink.js +1 -1
  37. package/server/routes/enterprise-mesh.js +2 -1
  38. package/server/routes/genius-bridge.js +256 -0
  39. package/server/routes/genius-gateway.js +137 -0
  40. package/server/routes/governance-saas.js +2 -1
  41. package/server/routes/notary.js +309 -0
  42. package/server/routes/observatory.js +109 -0
  43. package/server/routes/partners.js +2 -1
  44. package/server/routes/registry.js +352 -0
  45. package/server/routes/research.js +83 -0
  46. package/server/routes/ring4.js +2 -1
  47. package/server/routes/runtime.js +98 -25
  48. package/server/routes/security-researchers.js +161 -0
  49. package/server/routes/shieldqr.js +1 -1
  50. package/server/routes/traces.js +247 -0
  51. package/server/services/agent-tasks.js +9 -7
  52. package/server/services/email.js +50 -2
  53. package/server/services/marketplace.js +27 -8
  54. package/server/services/plans.js +1 -1
  55. package/server/services/shieldlink.js +1 -1
  56. package/server/services/ssl-ct-monitor.js +1 -1
  57. package/server/services/ssl-monitor.js +1 -1
  58. package/server/services/stripe.js +29 -4
  59. package/server/services/webhooks.js +61 -1
  60. package/server/utils/migrate.js +1 -1
  61. package/server/utils/safe-compare.js +26 -0
@@ -16,6 +16,7 @@
16
16
 
17
17
  const crypto = require('crypto');
18
18
  const { db } = require('../models/db');
19
+ const { safeFetch } = require('../utils/safe-fetch');
19
20
 
20
21
  // ─── Schema ──────────────────────────────────────────────────────────
21
22
 
@@ -362,21 +363,22 @@ async function executeUrlTask(taskId) {
362
363
  _addMessage(taskId, 'agent', analyzeMsg, { type: 'progress', step: 'analyze' });
363
364
  updates.push({ type: 'progress', step: 'analyze', message: analyzeMsg });
364
365
 
365
- // Try to fetch the original page to get the current price
366
+ // Try to fetch the original page to get the current price.
367
+ // SSRF-hardened: safeFetch blocks private/internal IPs, caps body size,
368
+ // enforces a timeout, and re-validates every redirect hop.
366
369
  let originalPrice = null;
367
370
  try {
368
- const controller = new AbortController();
369
- const timeout = setTimeout(() => controller.abort(), 12000);
370
- const res = await fetch(urlData.url, {
371
+ const res = await safeFetch(urlData.url, {
371
372
  headers: {
372
373
  'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
373
374
  'Accept': 'text/html',
374
375
  'Accept-Language': 'ar,en-US;q=0.9,en;q=0.8',
375
376
  },
376
- signal: controller.signal,
377
- redirect: 'follow',
377
+ }, {
378
+ timeoutMs: 12000,
379
+ maxBytes: 2 * 1024 * 1024,
380
+ allowedContentTypes: ['text/html', 'application/xhtml+xml'],
378
381
  });
379
- clearTimeout(timeout);
380
382
 
381
383
  if (res.ok) {
382
384
  const html = await res.text();
@@ -4,8 +4,48 @@
4
4
  */
5
5
 
6
6
  const nodemailer = require('nodemailer');
7
+ const crypto = require('crypto');
7
8
  const { getSmtpSettings, logNotification } = require('../models/db');
8
9
 
10
+ // WAB email headers (RFC 5322 X-* extension headers) — let receiving agents
11
+ // verify that a transactional email came from a WAB-enabled domain. Spec at
12
+ // https://webagentbridge.com/wab-email. Signing is opt-in via env:
13
+ // WAB_EMAIL_HOST — the domain that owns the wab.json manifest
14
+ // WAB_EMAIL_SIGNING_KEY — base64 Ed25519 private key (32 bytes raw)
15
+ const WAB_EMAIL_HOST = process.env.WAB_EMAIL_HOST || 'webagentbridge.com';
16
+ const WAB_EMAIL_KEY = process.env.WAB_EMAIL_SIGNING_KEY || '';
17
+ let _wabSigningKey = null;
18
+ function _getSigningKey() {
19
+ if (_wabSigningKey !== null) return _wabSigningKey;
20
+ if (!WAB_EMAIL_KEY) { _wabSigningKey = false; return false; }
21
+ try {
22
+ const raw = Buffer.from(WAB_EMAIL_KEY, 'base64');
23
+ if (raw.length !== 32) throw new Error('expected 32-byte ed25519 seed');
24
+ const der = Buffer.concat([Buffer.from('302e020100300506032b657004220420', 'hex'), raw]);
25
+ _wabSigningKey = crypto.createPrivateKey({ key: der, format: 'der', type: 'pkcs8' });
26
+ } catch (e) {
27
+ console.warn('[wab-email] signing disabled:', e.message);
28
+ _wabSigningKey = false;
29
+ }
30
+ return _wabSigningKey;
31
+ }
32
+ function buildWabHeaders({ from, to, subject, template, messageId, receipt }) {
33
+ const headers = {
34
+ 'X-WAB-Manifest': `https://${WAB_EMAIL_HOST}/.well-known/wab.json`,
35
+ 'X-WAB-Action': `email.${template || 'transactional'}`
36
+ };
37
+ if (receipt) headers['X-WAB-Receipt'] = receipt;
38
+ const key = _getSigningKey();
39
+ if (key) {
40
+ const digest = crypto.createHash('sha256')
41
+ .update(`${from}\n${to}\n${subject}\n${messageId}`)
42
+ .digest();
43
+ const sig = crypto.sign(null, digest, key).toString('base64');
44
+ headers['X-WAB-Signature'] = `ed25519=${sig}`;
45
+ }
46
+ return headers;
47
+ }
48
+
9
49
  function escapeHtml(s) {
10
50
  if (s == null) return '';
11
51
  return String(s)
@@ -269,11 +309,19 @@ async function sendEmail({ to, template, data, userId }) {
269
309
  const { subject, html } = tmpl(data);
270
310
 
271
311
  try {
312
+ const from = `"${settings.from_name}" <${settings.from_email}>`;
313
+ const messageId = `<${crypto.randomBytes(12).toString('hex')}@${WAB_EMAIL_HOST}>`;
314
+ const wabHeaders = buildWabHeaders({
315
+ from: settings.from_email, to, subject, template,
316
+ messageId, receipt: data && data.wab_receipt
317
+ });
272
318
  await transport.sendMail({
273
- from: `"${settings.from_name}" <${settings.from_email}>`,
319
+ from,
274
320
  to,
275
321
  subject,
276
- html
322
+ html,
323
+ messageId,
324
+ headers: wabHeaders
277
325
  });
278
326
  logNotification({ userId, emailTo: to, template, subject, status: 'sent' });
279
327
  return { success: true };
@@ -125,29 +125,48 @@ class MarketplaceEngine {
125
125
  };
126
126
 
127
127
  this._purchases.set(purchaseId, purchase);
128
- listing.installs++;
129
128
 
130
- // Track earnings
131
- if (sellerEarning > 0) {
132
- const earnings = this._earnings.get(listing.sellerId) || { total: 0, pending: 0, paid: 0 };
133
- earnings.total += sellerEarning;
134
- earnings.pending += sellerEarning;
135
- this._earnings.set(listing.sellerId, earnings);
129
+ // Only credit installs / revenue / seller earnings once the purchase is
130
+ // actually completed. For paid listings the purchase starts in
131
+ // 'pending_payment' and must be promoted via completePayment(). Crediting
132
+ // here would let any caller who can reach POST /marketplace/:id/purchase
133
+ // inflate a seller's earnings without ever paying.
134
+ if (purchase.status === 'completed') {
135
+ this._creditCompletedPurchase(listing, purchase);
136
136
  }
137
137
 
138
- listing.revenue += listing.price;
139
138
  bus.emit('marketplace.purchased', { purchaseId, listingId, buyerId, price: listing.price });
140
139
  return purchase;
141
140
  }
142
141
 
142
+ /**
143
+ * Credit installs / revenue / seller earnings for a completed purchase.
144
+ * Idempotent guard: stamps purchase._credited so a double-call is a no-op.
145
+ */
146
+ _creditCompletedPurchase(listing, purchase) {
147
+ if (purchase._credited) return;
148
+ listing.installs++;
149
+ listing.revenue += purchase.price;
150
+ if (purchase.sellerEarning > 0) {
151
+ const earnings = this._earnings.get(listing.sellerId) || { total: 0, pending: 0, paid: 0 };
152
+ earnings.total += purchase.sellerEarning;
153
+ earnings.pending += purchase.sellerEarning;
154
+ this._earnings.set(listing.sellerId, earnings);
155
+ }
156
+ purchase._credited = true;
157
+ }
158
+
143
159
  /**
144
160
  * Complete payment for a purchase
145
161
  */
146
162
  completePayment(purchaseId) {
147
163
  const purchase = this._purchases.get(purchaseId);
148
164
  if (!purchase) throw new Error('Purchase not found');
165
+ if (purchase.status === 'completed') return purchase;
149
166
  purchase.status = 'completed';
150
167
  purchase.completedAt = Date.now();
168
+ const listing = this._listings.get(purchase.listingId);
169
+ if (listing) this._creditCompletedPurchase(listing, purchase);
151
170
  return purchase;
152
171
  }
153
172
 
@@ -24,7 +24,7 @@ function db() {
24
24
  const DATA_DIR = process.env.NODE_ENV === 'test'
25
25
  ? path.join(__dirname, '..', '..', 'data-test')
26
26
  : (process.env.DATA_DIR || path.join(__dirname, '..', '..', 'data'));
27
- const dbFile = process.env.NODE_ENV === 'test' ? 'wab-test.db' : 'wab.db';
27
+ const dbFile = process.env.NODE_ENV === 'test' ? `wab-test-${process.env.JEST_WORKER_ID || '1'}.db` : 'wab.db';
28
28
  _db = new Database(path.join(DATA_DIR, dbFile));
29
29
  return _db;
30
30
  }
@@ -40,7 +40,7 @@ const { encryptOptional, decryptOptional } = require('../utils/secureFields');
40
40
  const DATA_DIR = process.env.NODE_ENV === 'test'
41
41
  ? path.join(__dirname, '..', '..', 'data-test')
42
42
  : (process.env.DATA_DIR || path.join(__dirname, '..', '..', 'data'));
43
- const DB_FILE = process.env.NODE_ENV === 'test' ? 'wab-test.db' : 'wab.db';
43
+ const DB_FILE = process.env.NODE_ENV === 'test' ? `wab-test-${process.env.JEST_WORKER_ID || '1'}.db` : 'wab.db';
44
44
  let _db = null;
45
45
  function db() {
46
46
  if (!_db) _db = new Database(path.join(DATA_DIR, DB_FILE));
@@ -21,7 +21,7 @@ const Database = require('better-sqlite3');
21
21
  const DATA_DIR = process.env.NODE_ENV === 'test'
22
22
  ? path.join(__dirname, '..', '..', 'data-test')
23
23
  : (process.env.DATA_DIR || path.join(__dirname, '..', '..', 'data'));
24
- const DB_FILE = process.env.NODE_ENV === 'test' ? 'wab-test.db' : 'wab.db';
24
+ const DB_FILE = process.env.NODE_ENV === 'test' ? `wab-test-${process.env.JEST_WORKER_ID || '1'}.db` : 'wab.db';
25
25
 
26
26
  let _db = null;
27
27
  function db() {
@@ -17,7 +17,7 @@ const ssl = require('./ssl-inspector');
17
17
  const DATA_DIR = process.env.NODE_ENV === 'test'
18
18
  ? path.join(__dirname, '..', '..', 'data-test')
19
19
  : (process.env.DATA_DIR || path.join(__dirname, '..', '..', 'data'));
20
- const DB_FILE = process.env.NODE_ENV === 'test' ? 'wab-test.db' : 'wab.db';
20
+ const DB_FILE = process.env.NODE_ENV === 'test' ? `wab-test-${process.env.JEST_WORKER_ID || '1'}.db` : 'wab.db';
21
21
 
22
22
  let _db = null;
23
23
  function db() {
@@ -26,10 +26,11 @@ function getStripe() {
26
26
 
27
27
  function getStripePrices() {
28
28
  return {
29
- starter: process.env.STRIPE_PRICE_STARTER || getPlatformSetting('stripe_price_starter'),
30
- pro: process.env.STRIPE_PRICE_PRO || getPlatformSetting('stripe_price_pro'),
31
- business: process.env.STRIPE_PRICE_BUSINESS || getPlatformSetting('stripe_price_business'),
32
- enterprise: process.env.STRIPE_PRICE_ENTERPRISE || getPlatformSetting('stripe_price_enterprise')
29
+ starter: process.env.STRIPE_PRICE_STARTER || getPlatformSetting('stripe_price_starter'),
30
+ freelancer: process.env.STRIPE_PRICE_FREELANCER || getPlatformSetting('stripe_price_freelancer'),
31
+ pro: process.env.STRIPE_PRICE_PRO || getPlatformSetting('stripe_price_pro'),
32
+ business: process.env.STRIPE_PRICE_BUSINESS || getPlatformSetting('stripe_price_business'),
33
+ enterprise: process.env.STRIPE_PRICE_ENTERPRISE || getPlatformSetting('stripe_price_enterprise'),
33
34
  };
34
35
  }
35
36
 
@@ -332,6 +333,30 @@ function handleWebhookRequest(req) {
332
333
  if (!s) throw new Error('Stripe not configured');
333
334
  const event = s.webhooks.constructEvent(raw, sig, whSecret);
334
335
  handleWebhookEvent(event);
336
+
337
+ // ── Forward genius-tagged events to genius-platform (same webhook, same secret) ──
338
+ const obj = event.data && event.data.object;
339
+ const geniusOrgId = obj && obj.metadata && obj.metadata.genius_org_id;
340
+ if (geniusOrgId) {
341
+ const callbackUrl = `${process.env.GENIUS_CALLBACK_URL || 'http://localhost:3004'}/api/wab/billing-callback`;
342
+ (async () => {
343
+ try {
344
+ const fetchFn = globalThis.fetch || (await import('node-fetch').then(m => m.default).catch(() => null));
345
+ if (!fetchFn) return;
346
+ await fetchFn(callbackUrl, {
347
+ method: 'POST',
348
+ headers: {
349
+ 'Content-Type': 'application/json',
350
+ 'X-Internal-Secret': process.env.GENIUS_BRIDGE_SECRET || '',
351
+ },
352
+ body: JSON.stringify({ type: event.type, data: event.data }),
353
+ });
354
+ console.log(`[stripe] forwarded ${event.type} → genius-platform (org: ${geniusOrgId})`);
355
+ } catch (e) {
356
+ console.error('[stripe] genius billing-callback failed (non-fatal):', e.message);
357
+ }
358
+ })();
359
+ }
335
360
  }
336
361
 
337
362
  module.exports = {
@@ -20,6 +20,8 @@
20
20
 
21
21
  const crypto = require('crypto');
22
22
  const { db } = require('../models/db');
23
+ const { canonicalize } = require('./canonical-json');
24
+ const signer = require('./operator-signer');
23
25
 
24
26
  const VALID_EVENTS = new Set([
25
27
  'revocation.opened',
@@ -183,6 +185,10 @@ async function _attemptDelivery(deliveryId, subscription, body) {
183
185
  'X-WAB-Webhook-Delivery': deliveryId,
184
186
  'User-Agent': 'web-agent-bridge-webhooks/1.0',
185
187
  };
188
+ if (subscription._operatorSignature) {
189
+ headers['X-WAB-Operator-Signature'] = subscription._operatorSignature;
190
+ headers['X-WAB-Operator-Key-Url'] = '/api/operator-key.json';
191
+ }
186
192
  try {
187
193
  const res = await _httpPost(subscription.url, body, headers);
188
194
  if (res.ok) {
@@ -264,6 +270,20 @@ function emit(eventType, data) {
264
270
  created_at: new Date().toISOString(),
265
271
  data,
266
272
  };
273
+ // Ed25519-sign the canonical form of the payload (RFC 8785). Receivers verify
274
+ // with the public key published at /api/operator-key.json. If no operator key
275
+ // is configured, we still deliver — the HMAC signature alone is sufficient for
276
+ // per-subscription auth.
277
+ const operatorSignature = signer.isConfigured() ? signer.sign(payload) : null;
278
+ if (operatorSignature) {
279
+ payload.signature = {
280
+ alg: signer.ALGORITHM,
281
+ value: operatorSignature,
282
+ key_url: '/api/operator-key.json',
283
+ canonicalization: 'RFC8785',
284
+ signed_fields: ['id', 'type', 'created_at', 'data'],
285
+ };
286
+ }
267
287
  const body = JSON.stringify(payload);
268
288
 
269
289
  for (const sub of subs) {
@@ -273,7 +293,12 @@ function emit(eventType, data) {
273
293
  (id, subscription_id, event_id, event_type, payload, status, attempts)
274
294
  VALUES (?, ?, ?, ?, ?, 'pending', 0)
275
295
  `).run(deliveryId, sub.id, eventId, eventType, body);
276
- const enriched = { ...sub, _eventId: eventId, _eventType: eventType };
296
+ const enriched = {
297
+ ...sub,
298
+ _eventId: eventId,
299
+ _eventType: eventType,
300
+ _operatorSignature: operatorSignature,
301
+ };
277
302
  setImmediate(async () => {
278
303
  const ok = await _attemptDelivery(deliveryId, enriched, body);
279
304
  if (!ok) _scheduleRetry(deliveryId, enriched, body, 1);
@@ -298,6 +323,40 @@ function verifySignature({ secret, header, body, toleranceSec = 300 }) {
298
323
  } catch (_) { return false; }
299
324
  }
300
325
 
326
+ /**
327
+ * Verify the operator (Ed25519) signature embedded inside an event body.
328
+ *
329
+ * The receiver passes the parsed JSON envelope (or the raw body string + we
330
+ * parse). We strip the `signature` field, RFC8785-canonicalise the remainder,
331
+ * and verify with the operator public key (32-byte raw, base64).
332
+ *
333
+ * verifyOperatorSignature({ body, publicKeyB64 }) -> true | false
334
+ */
335
+ function verifyOperatorSignature({ body, publicKeyB64 }) {
336
+ if (!body || !publicKeyB64) return false;
337
+ let envelope;
338
+ try {
339
+ envelope = typeof body === 'string' ? JSON.parse(body) : body;
340
+ } catch (_) { return false; }
341
+ const sigObj = envelope.signature;
342
+ if (!sigObj || sigObj.alg !== 'ed25519' || !sigObj.value) return false;
343
+
344
+ const { signature: _omit, ...signed } = envelope;
345
+ const canon = canonicalize(signed);
346
+
347
+ try {
348
+ const raw = Buffer.from(publicKeyB64, 'base64');
349
+ if (raw.length !== 32) return false;
350
+ // Reconstruct SPKI DER for Ed25519: 12-byte prefix + 32-byte raw.
351
+ const spki = Buffer.concat([
352
+ Buffer.from('302a300506032b6570032100', 'hex'),
353
+ raw,
354
+ ]);
355
+ const pub = crypto.createPublicKey({ key: spki, format: 'der', type: 'spki' });
356
+ return crypto.verify(null, Buffer.from(canon, 'utf8'), pub, Buffer.from(sigObj.value, 'base64'));
357
+ } catch (_) { return false; }
358
+ }
359
+
301
360
  module.exports = {
302
361
  createSubscription,
303
362
  listSubscriptions,
@@ -307,6 +366,7 @@ module.exports = {
307
366
  listDeliveries,
308
367
  emit,
309
368
  verifySignature,
369
+ verifyOperatorSignature,
310
370
  VALID_EVENTS,
311
371
  // exposed for tests
312
372
  _attemptDelivery,
@@ -12,7 +12,7 @@ const DATA_DIR = process.env.NODE_ENV === 'test'
12
12
  : (process.env.DATA_DIR || path.join(__dirname, '..', '..', 'data'));
13
13
  if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
14
14
 
15
- const dbFile = process.env.NODE_ENV === 'test' ? 'wab-test.db' : 'wab.db';
15
+ const dbFile = process.env.NODE_ENV === 'test' ? `wab-test-${process.env.JEST_WORKER_ID || '1'}.db` : 'wab.db';
16
16
  const db = new Database(path.join(DATA_DIR, dbFile));
17
17
 
18
18
  // Ensure migrations tracking table exists
@@ -0,0 +1,26 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+
5
+ /**
6
+ * Constant-time comparison for secrets / bearer tokens.
7
+ *
8
+ * Both operands are hashed to a fixed-length SHA-256 digest before the
9
+ * timing-safe compare, so this never throws on length mismatch and does not
10
+ * leak the secret length through early termination. Non-string inputs (missing
11
+ * headers, undefined env vars) return false instead of throwing.
12
+ *
13
+ * Use this for ALL static-token / shared-secret equality checks instead of
14
+ * `a === b` / `a !== b`, which short-circuit on the first differing byte and
15
+ * are therefore vulnerable to remote timing analysis.
16
+ */
17
+ function safeEqual(a, b) {
18
+ if (typeof a !== 'string' || typeof b !== 'string' || a.length === 0 || b.length === 0) {
19
+ return false;
20
+ }
21
+ const ha = crypto.createHash('sha256').update(a, 'utf8').digest();
22
+ const hb = crypto.createHash('sha256').update(b, 'utf8').digest();
23
+ return crypto.timingSafeEqual(ha, hb);
24
+ }
25
+
26
+ module.exports = { safeEqual };