web-agent-bridge 2.3.1 → 2.4.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 (38) hide show
  1. package/README.ar.md +506 -31
  2. package/README.md +574 -47
  3. package/bin/agent-runner.js +10 -1
  4. package/package.json +1 -1
  5. package/public/agent-workspace.html +347 -0
  6. package/public/browser.html +484 -0
  7. package/public/css/agent-workspace.css +1713 -0
  8. package/public/index.html +94 -0
  9. package/public/js/agent-workspace.js +1740 -0
  10. package/sdk/index.d.ts +83 -0
  11. package/sdk/index.js +115 -1
  12. package/sdk/package.json +1 -1
  13. package/server/config/secrets.js +13 -5
  14. package/server/index.js +183 -4
  15. package/server/middleware/adminAuth.js +6 -1
  16. package/server/middleware/auth.js +11 -2
  17. package/server/middleware/rateLimits.js +78 -2
  18. package/server/migrations/003_ads_integer_cents.sql +33 -0
  19. package/server/models/db.js +126 -25
  20. package/server/routes/admin.js +16 -2
  21. package/server/routes/ads.js +130 -0
  22. package/server/routes/agent-workspace.js +378 -0
  23. package/server/routes/api.js +21 -2
  24. package/server/routes/auth.js +26 -6
  25. package/server/routes/sovereign.js +78 -0
  26. package/server/routes/universal.js +177 -0
  27. package/server/routes/wab-api.js +20 -5
  28. package/server/services/agent-chat.js +506 -0
  29. package/server/services/agent-symphony.js +6 -0
  30. package/server/services/agent-tasks.js +1807 -0
  31. package/server/services/fairness-engine.js +409 -0
  32. package/server/services/plugins.js +27 -3
  33. package/server/services/price-intelligence.js +565 -0
  34. package/server/services/price-shield.js +1137 -0
  35. package/server/services/search-engine.js +357 -0
  36. package/server/services/security.js +513 -0
  37. package/server/services/universal-scraper.js +661 -0
  38. package/server/ws.js +61 -1
@@ -0,0 +1,177 @@
1
+ /**
2
+ * WAB Universal Agent — Server Routes
3
+ * ═══════════════════════════════════════════════════════════════════
4
+ * API endpoints for the Universal Agent mode.
5
+ * Used by: WAB Browser, Chrome Extension, direct API calls.
6
+ *
7
+ * All endpoints work WITHOUT requiring the target site to install any script.
8
+ */
9
+
10
+ const express = require('express');
11
+ const router = express.Router();
12
+ const scraper = require('../services/universal-scraper');
13
+ const priceIntel = require('../services/price-intelligence');
14
+ const fairness = require('../services/fairness-engine');
15
+
16
+ // ─── POST /api/universal/extract ─────────────────────────────────────
17
+ // Extract prices/products from a URL (server-side fetch)
18
+ router.post('/extract', async (req, res) => {
19
+ const { url } = req.body;
20
+ if (!url) return res.status(400).json({ error: 'URL required' });
21
+
22
+ try {
23
+ const result = await scraper.fetchAndExtract(url);
24
+ res.json(result);
25
+ } catch (err) {
26
+ res.status(500).json({ error: err.message });
27
+ }
28
+ });
29
+
30
+ // ─── POST /api/universal/analyze ─────────────────────────────────────
31
+ // Full analysis: extract + fraud detection + trust score
32
+ // Accepts either a URL (server-side fetch) or pre-extracted data (from browser)
33
+ router.post('/analyze', async (req, res) => {
34
+ const { url, extraction } = req.body;
35
+
36
+ try {
37
+ let result;
38
+
39
+ if (extraction) {
40
+ // Data already extracted by browser/extension
41
+ const processed = scraper.processBrowserExtraction(extraction);
42
+ result = await priceIntel.analyzePrice(extraction.url || url || '', processed);
43
+
44
+ // Add dark pattern detection if text available
45
+ if (extraction.darkPatterns) {
46
+ result.darkPatterns = extraction.darkPatterns;
47
+ }
48
+ } else if (url) {
49
+ // Server-side fetch and analyze
50
+ result = await priceIntel.analyzePrice(url);
51
+ } else {
52
+ return res.status(400).json({ error: 'URL or extraction data required' });
53
+ }
54
+
55
+ // Add fairness score for the domain
56
+ if (result.domain) {
57
+ result.fairness = fairness.calculateFairnessScore(result.domain, {
58
+ fraudAlerts: (result.alerts || []).length,
59
+ });
60
+ }
61
+
62
+ res.json(result);
63
+ } catch (err) {
64
+ res.status(500).json({ error: err.message });
65
+ }
66
+ });
67
+
68
+ // ─── POST /api/universal/compare ─────────────────────────────────────
69
+ // Compare prices across multiple sources
70
+ router.post('/compare', async (req, res) => {
71
+ const { query, category, maxSources } = req.body;
72
+ if (!query) return res.status(400).json({ error: 'Query required' });
73
+
74
+ try {
75
+ const result = await priceIntel.compareAcrossSources(
76
+ query,
77
+ category || 'product',
78
+ { maxSources: maxSources || 8 }
79
+ );
80
+
81
+ // Apply fairness ranking
82
+ if (result.results && result.results.length > 0) {
83
+ result.results = fairness.rankWithFairness(result.results, {
84
+ avgPrice: result.avgPrice,
85
+ });
86
+ }
87
+
88
+ res.json(result);
89
+ } catch (err) {
90
+ res.status(500).json({ error: err.message });
91
+ }
92
+ });
93
+
94
+ // ─── POST /api/universal/deals ───────────────────────────────────────
95
+ // Find best deals with fairness ranking + fraud detection
96
+ router.post('/deals', async (req, res) => {
97
+ const { query, category, lang } = req.body;
98
+ if (!query) return res.status(400).json({ error: 'Query required' });
99
+
100
+ try {
101
+ const result = await priceIntel.findBestDeals(
102
+ query,
103
+ category || 'product',
104
+ { lang: lang || 'en' }
105
+ );
106
+
107
+ // Apply fairness ranking to deals
108
+ if (result.deals && result.deals.length > 0) {
109
+ result.deals = fairness.rankWithFairness(result.deals, {
110
+ avgPrice: result.deals.reduce((s, d) => s + (d.priceUsd || 0), 0) / result.deals.length,
111
+ });
112
+ }
113
+
114
+ res.json(result);
115
+ } catch (err) {
116
+ res.status(500).json({ error: err.message });
117
+ }
118
+ });
119
+
120
+ // ─── POST /api/universal/fairness ────────────────────────────────────
121
+ // Get fairness score for a domain
122
+ router.post('/fairness', (req, res) => {
123
+ const { domain, url } = req.body;
124
+ const d = domain || (url ? (() => { try { return new URL(url).hostname; } catch (_) { return ''; } })() : '');
125
+ if (!d) return res.status(400).json({ error: 'Domain or URL required' });
126
+
127
+ const score = fairness.calculateFairnessScore(d);
128
+ res.json(score);
129
+ });
130
+
131
+ // ─── POST /api/universal/dark-patterns ───────────────────────────────
132
+ // Detect dark patterns in page text
133
+ router.post('/dark-patterns', (req, res) => {
134
+ const { text, lang } = req.body;
135
+ if (!text) return res.status(400).json({ error: 'Text required' });
136
+
137
+ const patterns = fairness.detectDarkPatterns(text, lang || 'en');
138
+ res.json({ patterns, count: patterns.length });
139
+ });
140
+
141
+ // ─── GET /api/universal/history ──────────────────────────────────────
142
+ // Get price history for a URL
143
+ router.get('/history', (req, res) => {
144
+ const { url } = req.query;
145
+ if (!url) return res.status(400).json({ error: 'URL required' });
146
+
147
+ const history = scraper.getPriceHistory(url, 30);
148
+ res.json({ url, history });
149
+ });
150
+
151
+ // ─── GET /api/universal/top-fair ─────────────────────────────────────
152
+ // Get top fairness-ranked sites
153
+ router.get('/top-fair', (req, res) => {
154
+ const limit = parseInt(req.query.limit) || 20;
155
+ const sites = fairness.getTopFairSites(limit);
156
+ res.json({ sites });
157
+ });
158
+
159
+ // ─── GET /api/universal/extraction-script ────────────────────────────
160
+ // Get the browser extraction script (for dynamic injection)
161
+ router.get('/extraction-script', (req, res) => {
162
+ res.set('Content-Type', 'application/javascript');
163
+ res.send(scraper.getBrowserExtractionScript());
164
+ });
165
+
166
+ // ─── GET /api/universal/sources ──────────────────────────────────────
167
+ // List all competing sources by category
168
+ router.get('/sources', (req, res) => {
169
+ const { category } = req.query;
170
+ if (category && priceIntel.COMPETING_SOURCES[category]) {
171
+ res.json({ category, sources: priceIntel.COMPETING_SOURCES[category] });
172
+ } else {
173
+ res.json(priceIntel.COMPETING_SOURCES);
174
+ }
175
+ });
176
+
177
+ module.exports = router;
@@ -9,8 +9,11 @@
9
9
 
10
10
  const express = require('express');
11
11
  const router = express.Router();
12
+ const crypto = require('crypto');
12
13
  const { findSiteById, findSiteByLicense, recordAnalytic, db } = require('../models/db');
13
14
  const { broadcastAnalytic } = require('../ws');
15
+ const { wabAuthenticateLimiter, wabActionLimiter, searchLimiter } = require('../middleware/rateLimits');
16
+ const { auditLog } = require('../services/security');
14
17
  const {
15
18
  calculateNeutralityScore,
16
19
  fairnessWeightedSearch,
@@ -82,7 +85,7 @@ function buildErrorResponse(id, code, message) {
82
85
  // POST /api/wab/authenticate — session token exchange
83
86
  // ═════════════════════════════════════════════════════════════════════
84
87
 
85
- router.post('/authenticate', (req, res) => {
88
+ router.post('/authenticate', wabAuthenticateLimiter, (req, res) => {
86
89
  try {
87
90
  const { siteId, apiKey, meta } = req.body;
88
91
  if (!siteId && !apiKey) {
@@ -91,12 +94,22 @@ router.post('/authenticate', (req, res) => {
91
94
 
92
95
  let site;
93
96
  if (apiKey) {
94
- site = db.prepare('SELECT * FROM sites WHERE api_key = ? AND active = 1').get(apiKey);
97
+ // Timing-safe API key lookup: hash the provided key and compare against stored hashes
98
+ // to prevent timing attacks on the raw key comparison
99
+ const allActive = db.prepare('SELECT * FROM sites WHERE active = 1 AND api_key IS NOT NULL').all();
100
+ site = allActive.find(s => {
101
+ if (!s.api_key) return false;
102
+ const a = Buffer.from(s.api_key);
103
+ const b = Buffer.from(apiKey);
104
+ if (a.length !== b.length) return false;
105
+ return crypto.timingSafeEqual(a, b);
106
+ }) || null;
95
107
  } else {
96
108
  site = findSiteById.get(siteId);
97
109
  }
98
110
 
99
111
  if (!site) {
112
+ auditLog({ actorType: 'agent', action: 'wab_auth_failed', details: { siteId }, ip: req.ip, outcome: 'denied', severity: 'warning' });
100
113
  return res.status(404).json(buildErrorResponse(null, 'not_found', 'Site not found or invalid credentials'));
101
114
  }
102
115
 
@@ -105,7 +118,9 @@ router.post('/authenticate', (req, res) => {
105
118
  try {
106
119
  const reqDomain = new URL(origin).hostname.replace(/^www\./, '');
107
120
  const siteDomain = site.domain.replace(/^www\./, '');
108
- if (reqDomain !== siteDomain && reqDomain !== 'localhost' && reqDomain !== '127.0.0.1') {
121
+ const isProduction = process.env.NODE_ENV === 'production';
122
+ const isLocalhost = reqDomain === 'localhost' || reqDomain === '127.0.0.1';
123
+ if (reqDomain !== siteDomain && !(isLocalhost && !isProduction)) {
109
124
  return res.status(403).json(buildErrorResponse(null, 'origin_mismatch', 'Origin does not match site domain'));
110
125
  }
111
126
  } catch (_) {}
@@ -193,7 +208,7 @@ router.get('/actions', (req, res) => {
193
208
  // POST /api/wab/actions/:name — execute action (with tracking)
194
209
  // ═════════════════════════════════════════════════════════════════════
195
210
 
196
- router.post('/actions/:name', requireSession, (req, res) => {
211
+ router.post('/actions/:name', requireSession, wabActionLimiter, (req, res) => {
197
212
  try {
198
213
  const actionName = req.params.name;
199
214
  const site = findSiteById.get(req.wabSession.siteId);
@@ -333,7 +348,7 @@ router.get('/page-info', (req, res) => {
333
348
  // GET /api/wab/search — fairness-weighted search (MCP adapter uses this)
334
349
  // ═════════════════════════════════════════════════════════════════════
335
350
 
336
- router.get('/search', (req, res) => {
351
+ router.get('/search', searchLimiter, (req, res) => {
337
352
  try {
338
353
  const query = req.query.q || '';
339
354
  const category = req.query.category || null;