web-agent-bridge 1.2.0 → 2.1.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 (111) hide show
  1. package/LICENSE +21 -21
  2. package/README.ar.md +572 -446
  3. package/README.md +968 -933
  4. package/bin/agent-runner.js +465 -0
  5. package/bin/cli.js +138 -80
  6. package/bin/wab.js +80 -80
  7. package/examples/bidi-agent.js +119 -119
  8. package/examples/mcp-agent.js +94 -94
  9. package/examples/next-app-router/README.md +44 -0
  10. package/examples/puppeteer-agent.js +108 -108
  11. package/examples/saas-dashboard/README.md +55 -0
  12. package/examples/shopify-hydrogen/README.md +74 -0
  13. package/examples/vision-agent.js +171 -171
  14. package/examples/wordpress-elementor/README.md +77 -0
  15. package/package.json +71 -78
  16. package/public/.well-known/ai-assets.json +59 -0
  17. package/public/admin/login.html +84 -84
  18. package/public/ai.html +196 -0
  19. package/public/cookies.html +208 -208
  20. package/public/css/premium.css +317 -0
  21. package/public/css/styles.css +1235 -1235
  22. package/public/dashboard.html +704 -704
  23. package/public/demo.html +259 -0
  24. package/public/docs.html +585 -585
  25. package/public/feed.xml +89 -0
  26. package/public/index.html +581 -332
  27. package/public/js/auth-nav.js +31 -31
  28. package/public/js/auth-redirect.js +12 -12
  29. package/public/js/cookie-consent.js +56 -56
  30. package/public/js/wab-demo-page.js +721 -0
  31. package/public/js/ws-client.js +74 -74
  32. package/public/llms-full.txt +309 -0
  33. package/public/llms.txt +85 -0
  34. package/public/login.html +83 -83
  35. package/public/openapi.json +580 -0
  36. package/public/premium-dashboard.html +2487 -0
  37. package/public/premium.html +791 -0
  38. package/public/privacy.html +295 -295
  39. package/public/register.html +103 -103
  40. package/public/robots.txt +87 -0
  41. package/public/script/wab-consent.d.ts +36 -0
  42. package/public/script/wab-consent.js +104 -0
  43. package/public/script/wab-schema.js +131 -0
  44. package/public/script/wab.d.ts +108 -0
  45. package/public/script/wab.min.js +405 -0
  46. package/public/sitemap.xml +93 -0
  47. package/public/sovereign.html +660 -0
  48. package/public/terms.html +254 -254
  49. package/public/video/tutorial.mp4 +0 -0
  50. package/script/ai-agent-bridge.js +1558 -1513
  51. package/sdk/README.md +55 -55
  52. package/sdk/index.d.ts +118 -0
  53. package/sdk/index.js +257 -203
  54. package/sdk/package.json +14 -14
  55. package/sdk/schema-discovery.js +83 -0
  56. package/server/config/secrets.js +94 -92
  57. package/server/index.js +2 -9
  58. package/server/middleware/adminAuth.js +30 -30
  59. package/server/middleware/auth.js +41 -41
  60. package/server/middleware/rateLimits.js +24 -24
  61. package/server/migrations/001_add_analytics_indexes.sql +7 -7
  62. package/server/migrations/002_premium_features.sql +418 -0
  63. package/server/models/adapters/index.js +33 -33
  64. package/server/models/adapters/mysql.js +183 -183
  65. package/server/models/adapters/postgresql.js +172 -172
  66. package/server/models/adapters/sqlite.js +7 -7
  67. package/server/models/db.js +561 -561
  68. package/server/routes/admin-premium.js +671 -0
  69. package/server/routes/admin.js +247 -247
  70. package/server/routes/api.js +131 -138
  71. package/server/routes/auth.js +51 -51
  72. package/server/routes/billing.js +45 -45
  73. package/server/routes/discovery.js +406 -329
  74. package/server/routes/license.js +240 -240
  75. package/server/routes/noscript.js +543 -543
  76. package/server/routes/premium-v2.js +686 -0
  77. package/server/routes/premium.js +724 -0
  78. package/server/routes/sovereign.js +307 -0
  79. package/server/routes/wab-api.js +476 -476
  80. package/server/services/agent-memory.js +625 -0
  81. package/server/services/email.js +204 -204
  82. package/server/services/fairness.js +420 -420
  83. package/server/services/negotiation.js +439 -0
  84. package/server/services/plugins.js +747 -0
  85. package/server/services/premium.js +1883 -0
  86. package/server/services/reputation.js +465 -0
  87. package/server/services/self-healing.js +843 -0
  88. package/server/services/stripe.js +192 -192
  89. package/server/services/swarm.js +788 -0
  90. package/server/services/verification.js +481 -0
  91. package/server/services/vision.js +871 -0
  92. package/server/utils/cache.js +125 -125
  93. package/server/utils/migrate.js +81 -81
  94. package/server/utils/secureFields.js +50 -50
  95. package/server/ws.js +101 -101
  96. package/templates/artisan-marketplace.yaml +104 -0
  97. package/templates/book-price-scout.yaml +98 -0
  98. package/templates/electronics-price-tracker.yaml +108 -0
  99. package/templates/flight-deal-hunter.yaml +113 -0
  100. package/templates/freelancer-direct.yaml +116 -0
  101. package/templates/grocery-price-compare.yaml +93 -0
  102. package/templates/hotel-direct-booking.yaml +113 -0
  103. package/templates/local-services.yaml +98 -0
  104. package/templates/olive-oil-tunisia.yaml +88 -0
  105. package/templates/organic-farm-fresh.yaml +101 -0
  106. package/templates/restaurant-direct.yaml +97 -0
  107. package/docs/DEPLOY.md +0 -118
  108. package/docs/SPEC.md +0 -1540
  109. package/wab-mcp-adapter/README.md +0 -136
  110. package/wab-mcp-adapter/index.js +0 -555
  111. package/wab-mcp-adapter/package.json +0 -17
@@ -1,543 +1,543 @@
1
- const express = require('express');
2
- const crypto = require('crypto');
3
- const router = express.Router();
4
- const { findSiteById, findSiteByLicense, recordAnalytic, verifyLicense, db } = require('../models/db');
5
- const { broadcastAnalytic } = require('../ws');
6
- let premium;
7
- try { premium = require('../services/premium'); } catch (_) { premium = null; }
8
-
9
- const rateLimit = require('express-rate-limit');
10
-
11
- const TRANSPARENT_GIF = Buffer.from(
12
- 'R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
13
- 'base64'
14
- );
15
-
16
- const WAB_VERSION = '1.2.0';
17
-
18
- // ─── Rate limiter for pixel endpoint (300 req/min per IP) ────────────
19
- const pixelLimiter = rateLimit({
20
- windowMs: 60 * 1000,
21
- max: 300,
22
- standardHeaders: true,
23
- legacyHeaders: false,
24
- handler(_req, res) {
25
- res.set('Content-Type', 'image/gif');
26
- res.set('Cache-Control', 'no-store, no-cache, must-revalidate');
27
- res.set('Pragma', 'no-cache');
28
- res.set('Expires', '0');
29
- res.end(TRANSPARENT_GIF);
30
- }
31
- });
32
-
33
- // ─── Helpers ─────────────────────────────────────────────────────────
34
-
35
- function sendGif(res) {
36
- res.set('Content-Type', 'image/gif');
37
- res.set('Cache-Control', 'no-store, no-cache, must-revalidate');
38
- res.set('Pragma', 'no-cache');
39
- res.set('Expires', '0');
40
- res.end(TRANSPARENT_GIF);
41
- }
42
-
43
- function getSiteConfig(site) {
44
- let config = {};
45
- try { config = JSON.parse(site.config || '{}'); } catch (_) {}
46
- return config;
47
- }
48
-
49
- function getPermissionsList(config) {
50
- const perms = config.agentPermissions || {};
51
- return Object.entries(perms)
52
- .filter(([, v]) => v)
53
- .map(([k]) => k);
54
- }
55
-
56
- function premiumIntegrate(siteId, actionName, req) {
57
- if (!premium) return;
58
- try {
59
- const ua = req.headers['user-agent'] || '';
60
- const ip = req.ip || req.connection.remoteAddress || '';
61
- premium.recordAgentVisit(siteId, { userAgent: ua, ip });
62
- premium.triggerWebhooks(siteId, 'noscript.track', {
63
- actionName,
64
- source: 'noscript'
65
- }).catch(() => {});
66
- premium.logAudit(siteId, {
67
- action: actionName,
68
- resourceType: 'noscript',
69
- details: { source: 'noscript' },
70
- ipAddress: ip,
71
- userAgent: ua
72
- });
73
- } catch (_) {}
74
- }
75
-
76
- // ═════════════════════════════════════════════════════════════════════
77
- // 1. Tracking Pixel: GET /pixel/:siteId
78
- // ═════════════════════════════════════════════════════════════════════
79
-
80
- router.get('/pixel/:siteId', pixelLimiter, (req, res) => {
81
- try {
82
- const site = findSiteById.get(req.params.siteId);
83
- if (!site) return sendGif(res);
84
-
85
- const action = req.query.action || 'pageview';
86
- const ref = req.query.ref || null;
87
- const agent = req.query.agent || null;
88
- const t = req.query.t || null;
89
-
90
- recordAnalytic({
91
- siteId: site.id,
92
- actionName: action,
93
- agentId: agent,
94
- triggerType: t || 'noscript_pixel',
95
- success: true,
96
- metadata: { ref, source: 'noscript_pixel' }
97
- });
98
-
99
- broadcastAnalytic(site.id, {
100
- actionName: action,
101
- agentId: agent,
102
- triggerType: t || 'noscript_pixel',
103
- success: true
104
- });
105
-
106
- premiumIntegrate(site.id, action, req);
107
- } catch (_) {
108
- // Always return the GIF regardless of errors
109
- }
110
-
111
- sendGif(res);
112
- });
113
-
114
- // ═════════════════════════════════════════════════════════════════════
115
- // 2. CSS Tracker: GET /css/:siteId
116
- // ═════════════════════════════════════════════════════════════════════
117
-
118
- router.get('/css/:siteId', (req, res) => {
119
- try {
120
- const siteId = req.params.siteId;
121
- const site = findSiteById.get(siteId);
122
- if (!site) {
123
- res.status(404).set('Content-Type', 'text/css').end('/* site not found */');
124
- return;
125
- }
126
-
127
- const p = `/api/noscript/pixel/${siteId}`;
128
-
129
- const css = `/* WAB NoScript CSS Tracker — ${siteId} */
130
-
131
- /* Page load tracking */
132
- body::after {
133
- content: '';
134
- display: block;
135
- width: 0;
136
- height: 0;
137
- overflow: hidden;
138
- background-image: url('${p}?action=css_pageview&t=css');
139
- }
140
-
141
- /* Form submission tracking */
142
- form:focus-within::after {
143
- content: '';
144
- display: block;
145
- width: 0;
146
- height: 0;
147
- overflow: hidden;
148
- background-image: url('${p}?action=form_interaction&t=css');
149
- }
150
-
151
- /* Link hover tracking (captures intent) */
152
- a:hover::after {
153
- content: '';
154
- display: block;
155
- width: 0;
156
- height: 0;
157
- overflow: hidden;
158
- background-image: url('${p}?action=link_hover&t=css');
159
- }
160
-
161
- /* Scroll tracking via anchor detection */
162
- :target::before {
163
- content: '';
164
- display: block;
165
- width: 0;
166
- height: 0;
167
- overflow: hidden;
168
- background-image: url('${p}?action=anchor_navigate&t=css');
169
- }
170
-
171
- /* Input focus tracking */
172
- input:focus ~ .wab-track, textarea:focus ~ .wab-track, select:focus ~ .wab-track {
173
- background-image: url('${p}?action=input_focus&t=css');
174
- }
175
-
176
- /* Checkbox/radio change tracking */
177
- input[type="checkbox"]:checked ~ .wab-track {
178
- background-image: url('${p}?action=checkbox_check&t=css');
179
- }
180
-
181
- /* Print detection */
182
- @media print {
183
- body::before {
184
- content: '';
185
- display: block;
186
- width: 0;
187
- height: 0;
188
- overflow: hidden;
189
- background-image: url('${p}?action=print&t=css');
190
- }
191
- }
192
-
193
- /* Custom data attribute tracking: [data-wab-track] */
194
- [data-wab-track]:hover::after {
195
- content: '';
196
- display: block;
197
- width: 0;
198
- height: 0;
199
- overflow: hidden;
200
- background-image: url('${p}?action=custom_hover&t=css');
201
- }
202
- `;
203
-
204
- res.set('Content-Type', 'text/css');
205
- res.set('Cache-Control', 'no-store');
206
- res.set('X-Content-Type-Options', 'nosniff');
207
- res.end(css);
208
- } catch (err) {
209
- res.status(500).set('Content-Type', 'text/css').end('/* internal error */');
210
- }
211
- });
212
-
213
- // ═════════════════════════════════════════════════════════════════════
214
- // 3. SSR Bridge Page: GET /bridge/:siteId
215
- // ═════════════════════════════════════════════════════════════════════
216
-
217
- router.get('/bridge/:siteId', (req, res) => {
218
- try {
219
- const site = findSiteById.get(req.params.siteId);
220
- if (!site) return res.status(404).send('<!DOCTYPE html><html><body><h1>Site not found</h1></body></html>');
221
-
222
- const siteId = site.id;
223
- const config = getSiteConfig(site);
224
- const permissions = getPermissionsList(config);
225
- const permissionsStr = permissions.join(',') || 'none';
226
- const siteName = site.name || site.domain;
227
-
228
- const permissionsObj = config.agentPermissions || {};
229
- const ldJson = JSON.stringify({
230
- '@context': 'https://schema.org',
231
- '@type': 'WebApplication',
232
- name: 'Web Agent Bridge',
233
- applicationCategory: 'AI Agent Middleware',
234
- operatingSystem: 'Any',
235
- offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' },
236
- 'wab:siteId': siteId,
237
- 'wab:tier': site.tier,
238
- 'wab:permissions': permissionsObj,
239
- 'wab:domain': site.domain,
240
- 'wab:noscriptEndpoints': {
241
- pixel: `/api/noscript/pixel/${siteId}`,
242
- css: `/api/noscript/css/${siteId}`,
243
- form: '/api/noscript/action',
244
- serverTrack: '/api/noscript/server-track'
245
- }
246
- }, null, 2);
247
-
248
- const permissionsListHtml = permissions.length > 0
249
- ? permissions.map(p => `<li>${escapeHtml(p)}</li>`).join('\n ')
250
- : '<li>No permissions enabled</li>';
251
-
252
- const html = `<!DOCTYPE html>
253
- <html lang="en">
254
- <head>
255
- <meta charset="UTF-8">
256
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
257
- <meta name="wab:site-id" content="${escapeAttr(siteId)}">
258
- <meta name="wab:tier" content="${escapeAttr(site.tier)}">
259
- <meta name="wab:version" content="${WAB_VERSION}">
260
- <meta name="wab:permissions" content="${escapeAttr(permissionsStr)}">
261
- <meta name="wab:noscript" content="true">
262
- <link rel="stylesheet" href="/api/noscript/css/${escapeAttr(siteId)}">
263
- <title>WAB Bridge &mdash; ${escapeHtml(siteName)}</title>
264
- <script type="application/ld+json">
265
- ${ldJson}
266
- </script>
267
- </head>
268
- <body itemscope itemtype="https://schema.org/WebApplication">
269
- <meta itemprop="name" content="Web Agent Bridge">
270
- <meta itemprop="applicationCategory" content="AI Agent Middleware">
271
- <meta itemprop="operatingSystem" content="Any">
272
-
273
- <h1>Web Agent Bridge &mdash; ${escapeHtml(siteName)}</h1>
274
- <p>This page provides a JavaScript-free interface for AI agents.</p>
275
-
276
- <section id="config">
277
- <h2>Site Configuration</h2>
278
- <dl>
279
- <dt>Site ID</dt><dd>${escapeHtml(siteId)}</dd>
280
- <dt>Domain</dt><dd>${escapeHtml(site.domain)}</dd>
281
- <dt>Tier</dt><dd>${escapeHtml(site.tier)}</dd>
282
- <dt>Permissions</dt>
283
- <dd><ul>
284
- ${permissionsListHtml}
285
- </ul></dd>
286
- </dl>
287
- </section>
288
-
289
- <section id="actions">
290
- <h2>Available Actions</h2>
291
- <form method="POST" action="/api/noscript/action">
292
- <input type="hidden" name="siteId" value="${escapeAttr(siteId)}">
293
- <p><label>Action Name: <input type="text" name="actionName" required></label></p>
294
- <p><label>Agent ID: <input type="text" name="agentId"></label></p>
295
- <p><label>Data (JSON): <textarea name="data" rows="4" cols="50"></textarea></label></p>
296
- <p><button type="submit">Execute Action</button></p>
297
- </form>
298
- </section>
299
-
300
- <section id="agent-instructions">
301
- <h2>AI Agent Integration (No-JS Mode)</h2>
302
- <h3>Tracking Pixel</h3>
303
- <pre>&lt;img src="/api/noscript/pixel/${escapeHtml(siteId)}?action=pageview" width="1" height="1" alt=""&gt;</pre>
304
- <h3>CSS Tracker</h3>
305
- <pre>&lt;link rel="stylesheet" href="/api/noscript/css/${escapeHtml(siteId)}"&gt;</pre>
306
- <h3>Server-to-Server API</h3>
307
- <pre>POST /api/noscript/server-track
308
- Content-Type: application/json
309
- X-WAB-API-Key: YOUR_API_KEY
310
- { "siteId": "${escapeHtml(siteId)}", "actionName": "...", "agentId": "...", "metadata": {} }</pre>
311
- </section>
312
-
313
- <noscript>
314
- <img src="/api/noscript/pixel/${escapeAttr(siteId)}?action=bridge_view" width="1" height="1" alt="">
315
- </noscript>
316
- </body>
317
- </html>`;
318
-
319
- res.set('Content-Type', 'text/html; charset=utf-8');
320
- res.set('X-WAB-SiteId', siteId);
321
- res.set('X-WAB-Tier', site.tier);
322
- res.set('X-WAB-Version', WAB_VERSION);
323
- res.send(html);
324
- } catch (err) {
325
- res.status(500).send('<!DOCTYPE html><html><body><h1>Internal Server Error</h1></body></html>');
326
- }
327
- });
328
-
329
- // ═════════════════════════════════════════════════════════════════════
330
- // 4. Form Action Handler: POST /action
331
- // ═════════════════════════════════════════════════════════════════════
332
-
333
- router.post('/action', express.urlencoded({ extended: false }), (req, res) => {
334
- try {
335
- const { siteId, actionName, agentId, data, redirect } = req.body;
336
-
337
- if (!siteId || !actionName) {
338
- return res.status(400).send(buildSimplePage('Bad Request', '<p>siteId and actionName are required.</p>'));
339
- }
340
-
341
- const referer = req.get('referer') || req.get('origin') || '';
342
- if (!referer) {
343
- return res.status(403).send(buildSimplePage('Forbidden', '<p>Missing Referer header.</p>'));
344
- }
345
-
346
- const site = findSiteById.get(siteId);
347
- if (!site) {
348
- return res.status(404).send(buildSimplePage('Not Found', '<p>Site not found.</p>'));
349
- }
350
-
351
- let metadata = {};
352
- if (data) {
353
- try { metadata = JSON.parse(data); } catch (_) { metadata = { raw: data }; }
354
- }
355
-
356
- recordAnalytic({
357
- siteId: site.id,
358
- actionName,
359
- agentId: agentId || null,
360
- triggerType: 'noscript_form',
361
- success: true,
362
- metadata
363
- });
364
-
365
- broadcastAnalytic(site.id, {
366
- actionName,
367
- agentId: agentId || null,
368
- triggerType: 'noscript_form',
369
- success: true
370
- });
371
-
372
- premiumIntegrate(site.id, actionName, req);
373
-
374
- if (redirect && isSafeRedirect(redirect, site.domain, referer)) {
375
- return res.redirect(303, redirect);
376
- }
377
-
378
- res.send(buildSimplePage('Action Recorded', `
379
- <p>Your action <strong>${escapeHtml(actionName)}</strong> has been recorded successfully.</p>
380
- <p><a href="/api/noscript/bridge/${escapeAttr(siteId)}">Back to Bridge</a></p>
381
- `));
382
- } catch (err) {
383
- res.status(500).send(buildSimplePage('Error', '<p>An internal error occurred.</p>'));
384
- }
385
- });
386
-
387
- // ═════════════════════════════════════════════════════════════════════
388
- // 5. Server-to-Server Track: POST /server-track
389
- // ═════════════════════════════════════════════════════════════════════
390
-
391
- router.post('/server-track', express.json(), (req, res) => {
392
- try {
393
- const apiKey = req.get('X-WAB-API-Key');
394
- if (!apiKey) {
395
- return res.status(401).json({ error: 'X-WAB-API-Key header is required' });
396
- }
397
-
398
- const { siteId, actionName, agentId, triggerType, success, metadata } = req.body;
399
- if (!siteId || !actionName) {
400
- return res.status(400).json({ error: 'siteId and actionName are required' });
401
- }
402
-
403
- const site = findSiteById.get(siteId);
404
- if (!site) {
405
- return res.status(404).json({ error: 'Site not found' });
406
- }
407
-
408
- if (site.api_key !== apiKey) {
409
- return res.status(403).json({ error: 'Invalid API key' });
410
- }
411
-
412
- recordAnalytic({
413
- siteId: site.id,
414
- actionName,
415
- agentId: agentId || null,
416
- triggerType: triggerType || 'server',
417
- success: success !== false,
418
- metadata: metadata || {}
419
- });
420
-
421
- broadcastAnalytic(site.id, {
422
- actionName,
423
- agentId: agentId || null,
424
- triggerType: triggerType || 'server',
425
- success: success !== false
426
- });
427
-
428
- premiumIntegrate(site.id, actionName, req);
429
-
430
- res.json({ recorded: true, timestamp: new Date().toISOString() });
431
- } catch (err) {
432
- res.status(500).json({ error: 'Failed to record analytics' });
433
- }
434
- });
435
-
436
- // ═════════════════════════════════════════════════════════════════════
437
- // 6. Embed Snippet: GET /embed/:siteId
438
- // ═════════════════════════════════════════════════════════════════════
439
-
440
- router.get('/embed/:siteId', (req, res) => {
441
- try {
442
- const siteId = req.params.siteId;
443
- const site = findSiteById.get(siteId);
444
- if (!site) {
445
- return res.status(404).set('Content-Type', 'text/html').end('<!-- site not found -->');
446
- }
447
-
448
- const html = `<div id="wab-noscript-embed" style="position:absolute;width:0;height:0;overflow:hidden;">
449
- <img src="/api/noscript/pixel/${escapeAttr(siteId)}?action=embed_load" width="1" height="1" alt="">
450
- <link rel="stylesheet" href="/api/noscript/css/${escapeAttr(siteId)}">
451
- </div>`;
452
-
453
- res.set('Content-Type', 'text/html; charset=utf-8');
454
- res.set('Cache-Control', 'no-store');
455
- res.send(html);
456
- } catch (err) {
457
- res.status(500).set('Content-Type', 'text/html').end('<!-- internal error -->');
458
- }
459
- });
460
-
461
- // ═════════════════════════════════════════════════════════════════════
462
- // 7. Health/Status: GET /status/:siteId
463
- // ═════════════════════════════════════════════════════════════════════
464
-
465
- router.get('/status/:siteId', (req, res) => {
466
- try {
467
- const siteId = req.params.siteId;
468
- const site = findSiteById.get(siteId);
469
- if (!site) {
470
- return res.status(404).json({ error: 'Site not found' });
471
- }
472
-
473
- res.json({
474
- siteId: site.id,
475
- active: !!site.active,
476
- tier: site.tier,
477
- noscriptEnabled: true,
478
- endpoints: {
479
- pixel: `/api/noscript/pixel/${siteId}`,
480
- css: `/api/noscript/css/${siteId}`,
481
- bridge: `/api/noscript/bridge/${siteId}`,
482
- form: '/api/noscript/action',
483
- serverTrack: '/api/noscript/server-track',
484
- embed: `/api/noscript/embed/${siteId}`
485
- }
486
- });
487
- } catch (err) {
488
- res.status(500).json({ error: 'Internal server error' });
489
- }
490
- });
491
-
492
- // ─── Utility functions ───────────────────────────────────────────────
493
-
494
- function escapeHtml(str) {
495
- if (!str) return '';
496
- return String(str)
497
- .replace(/&/g, '&amp;')
498
- .replace(/</g, '&lt;')
499
- .replace(/>/g, '&gt;')
500
- .replace(/"/g, '&quot;')
501
- .replace(/'/g, '&#39;');
502
- }
503
-
504
- function escapeAttr(str) {
505
- return escapeHtml(str);
506
- }
507
-
508
- function buildSimplePage(title, bodyContent) {
509
- return `<!DOCTYPE html>
510
- <html lang="en">
511
- <head><meta charset="UTF-8"><title>${escapeHtml(title)}</title></head>
512
- <body>
513
- <h1>${escapeHtml(title)}</h1>
514
- ${bodyContent}
515
- </body>
516
- </html>`;
517
- }
518
-
519
- function isSafeRedirect(url, siteDomain, referer) {
520
- try {
521
- const parsed = new URL(url, 'http://placeholder');
522
-
523
- if (parsed.hostname === 'placeholder' || !parsed.hostname) return true;
524
-
525
- const normTarget = parsed.hostname.toLowerCase().replace(/^www\./, '');
526
- const normSite = (siteDomain || '').toLowerCase().replace(/^www\./, '');
527
-
528
- if (normTarget === normSite) return true;
529
-
530
- if (referer) {
531
- try {
532
- const refHost = new URL(referer).hostname.toLowerCase().replace(/^www\./, '');
533
- if (normTarget === refHost) return true;
534
- } catch (_) {}
535
- }
536
-
537
- return false;
538
- } catch (_) {
539
- return false;
540
- }
541
- }
542
-
543
- module.exports = router;
1
+ const express = require('express');
2
+ const crypto = require('crypto');
3
+ const router = express.Router();
4
+ const { findSiteById, findSiteByLicense, recordAnalytic, verifyLicense, db } = require('../models/db');
5
+ const { broadcastAnalytic } = require('../ws');
6
+ let premium;
7
+ try { premium = require('../services/premium'); } catch (_) { premium = null; }
8
+
9
+ const rateLimit = require('express-rate-limit');
10
+
11
+ const TRANSPARENT_GIF = Buffer.from(
12
+ 'R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
13
+ 'base64'
14
+ );
15
+
16
+ const WAB_VERSION = '1.2.0';
17
+
18
+ // ─── Rate limiter for pixel endpoint (300 req/min per IP) ────────────
19
+ const pixelLimiter = rateLimit({
20
+ windowMs: 60 * 1000,
21
+ max: 300,
22
+ standardHeaders: true,
23
+ legacyHeaders: false,
24
+ handler(_req, res) {
25
+ res.set('Content-Type', 'image/gif');
26
+ res.set('Cache-Control', 'no-store, no-cache, must-revalidate');
27
+ res.set('Pragma', 'no-cache');
28
+ res.set('Expires', '0');
29
+ res.end(TRANSPARENT_GIF);
30
+ }
31
+ });
32
+
33
+ // ─── Helpers ─────────────────────────────────────────────────────────
34
+
35
+ function sendGif(res) {
36
+ res.set('Content-Type', 'image/gif');
37
+ res.set('Cache-Control', 'no-store, no-cache, must-revalidate');
38
+ res.set('Pragma', 'no-cache');
39
+ res.set('Expires', '0');
40
+ res.end(TRANSPARENT_GIF);
41
+ }
42
+
43
+ function getSiteConfig(site) {
44
+ let config = {};
45
+ try { config = JSON.parse(site.config || '{}'); } catch (_) {}
46
+ return config;
47
+ }
48
+
49
+ function getPermissionsList(config) {
50
+ const perms = config.agentPermissions || {};
51
+ return Object.entries(perms)
52
+ .filter(([, v]) => v)
53
+ .map(([k]) => k);
54
+ }
55
+
56
+ function premiumIntegrate(siteId, actionName, req) {
57
+ if (!premium) return;
58
+ try {
59
+ const ua = req.headers['user-agent'] || '';
60
+ const ip = req.ip || req.connection.remoteAddress || '';
61
+ premium.recordAgentVisit(siteId, { userAgent: ua, ip });
62
+ premium.triggerWebhooks(siteId, 'noscript.track', {
63
+ actionName,
64
+ source: 'noscript'
65
+ }).catch(() => {});
66
+ premium.logAudit(siteId, {
67
+ action: actionName,
68
+ resourceType: 'noscript',
69
+ details: { source: 'noscript' },
70
+ ipAddress: ip,
71
+ userAgent: ua
72
+ });
73
+ } catch (_) {}
74
+ }
75
+
76
+ // ═════════════════════════════════════════════════════════════════════
77
+ // 1. Tracking Pixel: GET /pixel/:siteId
78
+ // ═════════════════════════════════════════════════════════════════════
79
+
80
+ router.get('/pixel/:siteId', pixelLimiter, (req, res) => {
81
+ try {
82
+ const site = findSiteById.get(req.params.siteId);
83
+ if (!site) return sendGif(res);
84
+
85
+ const action = req.query.action || 'pageview';
86
+ const ref = req.query.ref || null;
87
+ const agent = req.query.agent || null;
88
+ const t = req.query.t || null;
89
+
90
+ recordAnalytic({
91
+ siteId: site.id,
92
+ actionName: action,
93
+ agentId: agent,
94
+ triggerType: t || 'noscript_pixel',
95
+ success: true,
96
+ metadata: { ref, source: 'noscript_pixel' }
97
+ });
98
+
99
+ broadcastAnalytic(site.id, {
100
+ actionName: action,
101
+ agentId: agent,
102
+ triggerType: t || 'noscript_pixel',
103
+ success: true
104
+ });
105
+
106
+ premiumIntegrate(site.id, action, req);
107
+ } catch (_) {
108
+ // Always return the GIF regardless of errors
109
+ }
110
+
111
+ sendGif(res);
112
+ });
113
+
114
+ // ═════════════════════════════════════════════════════════════════════
115
+ // 2. CSS Tracker: GET /css/:siteId
116
+ // ═════════════════════════════════════════════════════════════════════
117
+
118
+ router.get('/css/:siteId', (req, res) => {
119
+ try {
120
+ const siteId = req.params.siteId;
121
+ const site = findSiteById.get(siteId);
122
+ if (!site) {
123
+ res.status(404).set('Content-Type', 'text/css').end('/* site not found */');
124
+ return;
125
+ }
126
+
127
+ const p = `/api/noscript/pixel/${siteId}`;
128
+
129
+ const css = `/* WAB NoScript CSS Tracker — ${siteId} */
130
+
131
+ /* Page load tracking */
132
+ body::after {
133
+ content: '';
134
+ display: block;
135
+ width: 0;
136
+ height: 0;
137
+ overflow: hidden;
138
+ background-image: url('${p}?action=css_pageview&t=css');
139
+ }
140
+
141
+ /* Form submission tracking */
142
+ form:focus-within::after {
143
+ content: '';
144
+ display: block;
145
+ width: 0;
146
+ height: 0;
147
+ overflow: hidden;
148
+ background-image: url('${p}?action=form_interaction&t=css');
149
+ }
150
+
151
+ /* Link hover tracking (captures intent) */
152
+ a:hover::after {
153
+ content: '';
154
+ display: block;
155
+ width: 0;
156
+ height: 0;
157
+ overflow: hidden;
158
+ background-image: url('${p}?action=link_hover&t=css');
159
+ }
160
+
161
+ /* Scroll tracking via anchor detection */
162
+ :target::before {
163
+ content: '';
164
+ display: block;
165
+ width: 0;
166
+ height: 0;
167
+ overflow: hidden;
168
+ background-image: url('${p}?action=anchor_navigate&t=css');
169
+ }
170
+
171
+ /* Input focus tracking */
172
+ input:focus ~ .wab-track, textarea:focus ~ .wab-track, select:focus ~ .wab-track {
173
+ background-image: url('${p}?action=input_focus&t=css');
174
+ }
175
+
176
+ /* Checkbox/radio change tracking */
177
+ input[type="checkbox"]:checked ~ .wab-track {
178
+ background-image: url('${p}?action=checkbox_check&t=css');
179
+ }
180
+
181
+ /* Print detection */
182
+ @media print {
183
+ body::before {
184
+ content: '';
185
+ display: block;
186
+ width: 0;
187
+ height: 0;
188
+ overflow: hidden;
189
+ background-image: url('${p}?action=print&t=css');
190
+ }
191
+ }
192
+
193
+ /* Custom data attribute tracking: [data-wab-track] */
194
+ [data-wab-track]:hover::after {
195
+ content: '';
196
+ display: block;
197
+ width: 0;
198
+ height: 0;
199
+ overflow: hidden;
200
+ background-image: url('${p}?action=custom_hover&t=css');
201
+ }
202
+ `;
203
+
204
+ res.set('Content-Type', 'text/css');
205
+ res.set('Cache-Control', 'no-store');
206
+ res.set('X-Content-Type-Options', 'nosniff');
207
+ res.end(css);
208
+ } catch (err) {
209
+ res.status(500).set('Content-Type', 'text/css').end('/* internal error */');
210
+ }
211
+ });
212
+
213
+ // ═════════════════════════════════════════════════════════════════════
214
+ // 3. SSR Bridge Page: GET /bridge/:siteId
215
+ // ═════════════════════════════════════════════════════════════════════
216
+
217
+ router.get('/bridge/:siteId', (req, res) => {
218
+ try {
219
+ const site = findSiteById.get(req.params.siteId);
220
+ if (!site) return res.status(404).send('<!DOCTYPE html><html><body><h1>Site not found</h1></body></html>');
221
+
222
+ const siteId = site.id;
223
+ const config = getSiteConfig(site);
224
+ const permissions = getPermissionsList(config);
225
+ const permissionsStr = permissions.join(',') || 'none';
226
+ const siteName = site.name || site.domain;
227
+
228
+ const permissionsObj = config.agentPermissions || {};
229
+ const ldJson = JSON.stringify({
230
+ '@context': 'https://schema.org',
231
+ '@type': 'WebApplication',
232
+ name: 'Web Agent Bridge',
233
+ applicationCategory: 'AI Agent Middleware',
234
+ operatingSystem: 'Any',
235
+ offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' },
236
+ 'wab:siteId': siteId,
237
+ 'wab:tier': site.tier,
238
+ 'wab:permissions': permissionsObj,
239
+ 'wab:domain': site.domain,
240
+ 'wab:noscriptEndpoints': {
241
+ pixel: `/api/noscript/pixel/${siteId}`,
242
+ css: `/api/noscript/css/${siteId}`,
243
+ form: '/api/noscript/action',
244
+ serverTrack: '/api/noscript/server-track'
245
+ }
246
+ }, null, 2);
247
+
248
+ const permissionsListHtml = permissions.length > 0
249
+ ? permissions.map(p => `<li>${escapeHtml(p)}</li>`).join('\n ')
250
+ : '<li>No permissions enabled</li>';
251
+
252
+ const html = `<!DOCTYPE html>
253
+ <html lang="en">
254
+ <head>
255
+ <meta charset="UTF-8">
256
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
257
+ <meta name="wab:site-id" content="${escapeAttr(siteId)}">
258
+ <meta name="wab:tier" content="${escapeAttr(site.tier)}">
259
+ <meta name="wab:version" content="${WAB_VERSION}">
260
+ <meta name="wab:permissions" content="${escapeAttr(permissionsStr)}">
261
+ <meta name="wab:noscript" content="true">
262
+ <link rel="stylesheet" href="/api/noscript/css/${escapeAttr(siteId)}">
263
+ <title>WAB Bridge &mdash; ${escapeHtml(siteName)}</title>
264
+ <script type="application/ld+json">
265
+ ${ldJson}
266
+ </script>
267
+ </head>
268
+ <body itemscope itemtype="https://schema.org/WebApplication">
269
+ <meta itemprop="name" content="Web Agent Bridge">
270
+ <meta itemprop="applicationCategory" content="AI Agent Middleware">
271
+ <meta itemprop="operatingSystem" content="Any">
272
+
273
+ <h1>Web Agent Bridge &mdash; ${escapeHtml(siteName)}</h1>
274
+ <p>This page provides a JavaScript-free interface for AI agents.</p>
275
+
276
+ <section id="config">
277
+ <h2>Site Configuration</h2>
278
+ <dl>
279
+ <dt>Site ID</dt><dd>${escapeHtml(siteId)}</dd>
280
+ <dt>Domain</dt><dd>${escapeHtml(site.domain)}</dd>
281
+ <dt>Tier</dt><dd>${escapeHtml(site.tier)}</dd>
282
+ <dt>Permissions</dt>
283
+ <dd><ul>
284
+ ${permissionsListHtml}
285
+ </ul></dd>
286
+ </dl>
287
+ </section>
288
+
289
+ <section id="actions">
290
+ <h2>Available Actions</h2>
291
+ <form method="POST" action="/api/noscript/action">
292
+ <input type="hidden" name="siteId" value="${escapeAttr(siteId)}">
293
+ <p><label>Action Name: <input type="text" name="actionName" required></label></p>
294
+ <p><label>Agent ID: <input type="text" name="agentId"></label></p>
295
+ <p><label>Data (JSON): <textarea name="data" rows="4" cols="50"></textarea></label></p>
296
+ <p><button type="submit">Execute Action</button></p>
297
+ </form>
298
+ </section>
299
+
300
+ <section id="agent-instructions">
301
+ <h2>AI Agent Integration (No-JS Mode)</h2>
302
+ <h3>Tracking Pixel</h3>
303
+ <pre>&lt;img src="/api/noscript/pixel/${escapeHtml(siteId)}?action=pageview" width="1" height="1" alt=""&gt;</pre>
304
+ <h3>CSS Tracker</h3>
305
+ <pre>&lt;link rel="stylesheet" href="/api/noscript/css/${escapeHtml(siteId)}"&gt;</pre>
306
+ <h3>Server-to-Server API</h3>
307
+ <pre>POST /api/noscript/server-track
308
+ Content-Type: application/json
309
+ X-WAB-API-Key: YOUR_API_KEY
310
+ { "siteId": "${escapeHtml(siteId)}", "actionName": "...", "agentId": "...", "metadata": {} }</pre>
311
+ </section>
312
+
313
+ <noscript>
314
+ <img src="/api/noscript/pixel/${escapeAttr(siteId)}?action=bridge_view" width="1" height="1" alt="">
315
+ </noscript>
316
+ </body>
317
+ </html>`;
318
+
319
+ res.set('Content-Type', 'text/html; charset=utf-8');
320
+ res.set('X-WAB-SiteId', siteId);
321
+ res.set('X-WAB-Tier', site.tier);
322
+ res.set('X-WAB-Version', WAB_VERSION);
323
+ res.send(html);
324
+ } catch (err) {
325
+ res.status(500).send('<!DOCTYPE html><html><body><h1>Internal Server Error</h1></body></html>');
326
+ }
327
+ });
328
+
329
+ // ═════════════════════════════════════════════════════════════════════
330
+ // 4. Form Action Handler: POST /action
331
+ // ═════════════════════════════════════════════════════════════════════
332
+
333
+ router.post('/action', express.urlencoded({ extended: false }), (req, res) => {
334
+ try {
335
+ const { siteId, actionName, agentId, data, redirect } = req.body;
336
+
337
+ if (!siteId || !actionName) {
338
+ return res.status(400).send(buildSimplePage('Bad Request', '<p>siteId and actionName are required.</p>'));
339
+ }
340
+
341
+ const referer = req.get('referer') || req.get('origin') || '';
342
+ if (!referer) {
343
+ return res.status(403).send(buildSimplePage('Forbidden', '<p>Missing Referer header.</p>'));
344
+ }
345
+
346
+ const site = findSiteById.get(siteId);
347
+ if (!site) {
348
+ return res.status(404).send(buildSimplePage('Not Found', '<p>Site not found.</p>'));
349
+ }
350
+
351
+ let metadata = {};
352
+ if (data) {
353
+ try { metadata = JSON.parse(data); } catch (_) { metadata = { raw: data }; }
354
+ }
355
+
356
+ recordAnalytic({
357
+ siteId: site.id,
358
+ actionName,
359
+ agentId: agentId || null,
360
+ triggerType: 'noscript_form',
361
+ success: true,
362
+ metadata
363
+ });
364
+
365
+ broadcastAnalytic(site.id, {
366
+ actionName,
367
+ agentId: agentId || null,
368
+ triggerType: 'noscript_form',
369
+ success: true
370
+ });
371
+
372
+ premiumIntegrate(site.id, actionName, req);
373
+
374
+ if (redirect && isSafeRedirect(redirect, site.domain, referer)) {
375
+ return res.redirect(303, redirect);
376
+ }
377
+
378
+ res.send(buildSimplePage('Action Recorded', `
379
+ <p>Your action <strong>${escapeHtml(actionName)}</strong> has been recorded successfully.</p>
380
+ <p><a href="/api/noscript/bridge/${escapeAttr(siteId)}">Back to Bridge</a></p>
381
+ `));
382
+ } catch (err) {
383
+ res.status(500).send(buildSimplePage('Error', '<p>An internal error occurred.</p>'));
384
+ }
385
+ });
386
+
387
+ // ═════════════════════════════════════════════════════════════════════
388
+ // 5. Server-to-Server Track: POST /server-track
389
+ // ═════════════════════════════════════════════════════════════════════
390
+
391
+ router.post('/server-track', express.json(), (req, res) => {
392
+ try {
393
+ const apiKey = req.get('X-WAB-API-Key');
394
+ if (!apiKey) {
395
+ return res.status(401).json({ error: 'X-WAB-API-Key header is required' });
396
+ }
397
+
398
+ const { siteId, actionName, agentId, triggerType, success, metadata } = req.body;
399
+ if (!siteId || !actionName) {
400
+ return res.status(400).json({ error: 'siteId and actionName are required' });
401
+ }
402
+
403
+ const site = findSiteById.get(siteId);
404
+ if (!site) {
405
+ return res.status(404).json({ error: 'Site not found' });
406
+ }
407
+
408
+ if (site.api_key !== apiKey) {
409
+ return res.status(403).json({ error: 'Invalid API key' });
410
+ }
411
+
412
+ recordAnalytic({
413
+ siteId: site.id,
414
+ actionName,
415
+ agentId: agentId || null,
416
+ triggerType: triggerType || 'server',
417
+ success: success !== false,
418
+ metadata: metadata || {}
419
+ });
420
+
421
+ broadcastAnalytic(site.id, {
422
+ actionName,
423
+ agentId: agentId || null,
424
+ triggerType: triggerType || 'server',
425
+ success: success !== false
426
+ });
427
+
428
+ premiumIntegrate(site.id, actionName, req);
429
+
430
+ res.json({ recorded: true, timestamp: new Date().toISOString() });
431
+ } catch (err) {
432
+ res.status(500).json({ error: 'Failed to record analytics' });
433
+ }
434
+ });
435
+
436
+ // ═════════════════════════════════════════════════════════════════════
437
+ // 6. Embed Snippet: GET /embed/:siteId
438
+ // ═════════════════════════════════════════════════════════════════════
439
+
440
+ router.get('/embed/:siteId', (req, res) => {
441
+ try {
442
+ const siteId = req.params.siteId;
443
+ const site = findSiteById.get(siteId);
444
+ if (!site) {
445
+ return res.status(404).set('Content-Type', 'text/html').end('<!-- site not found -->');
446
+ }
447
+
448
+ const html = `<div id="wab-noscript-embed" style="position:absolute;width:0;height:0;overflow:hidden;">
449
+ <img src="/api/noscript/pixel/${escapeAttr(siteId)}?action=embed_load" width="1" height="1" alt="">
450
+ <link rel="stylesheet" href="/api/noscript/css/${escapeAttr(siteId)}">
451
+ </div>`;
452
+
453
+ res.set('Content-Type', 'text/html; charset=utf-8');
454
+ res.set('Cache-Control', 'no-store');
455
+ res.send(html);
456
+ } catch (err) {
457
+ res.status(500).set('Content-Type', 'text/html').end('<!-- internal error -->');
458
+ }
459
+ });
460
+
461
+ // ═════════════════════════════════════════════════════════════════════
462
+ // 7. Health/Status: GET /status/:siteId
463
+ // ═════════════════════════════════════════════════════════════════════
464
+
465
+ router.get('/status/:siteId', (req, res) => {
466
+ try {
467
+ const siteId = req.params.siteId;
468
+ const site = findSiteById.get(siteId);
469
+ if (!site) {
470
+ return res.status(404).json({ error: 'Site not found' });
471
+ }
472
+
473
+ res.json({
474
+ siteId: site.id,
475
+ active: !!site.active,
476
+ tier: site.tier,
477
+ noscriptEnabled: true,
478
+ endpoints: {
479
+ pixel: `/api/noscript/pixel/${siteId}`,
480
+ css: `/api/noscript/css/${siteId}`,
481
+ bridge: `/api/noscript/bridge/${siteId}`,
482
+ form: '/api/noscript/action',
483
+ serverTrack: '/api/noscript/server-track',
484
+ embed: `/api/noscript/embed/${siteId}`
485
+ }
486
+ });
487
+ } catch (err) {
488
+ res.status(500).json({ error: 'Internal server error' });
489
+ }
490
+ });
491
+
492
+ // ─── Utility functions ───────────────────────────────────────────────
493
+
494
+ function escapeHtml(str) {
495
+ if (!str) return '';
496
+ return String(str)
497
+ .replace(/&/g, '&amp;')
498
+ .replace(/</g, '&lt;')
499
+ .replace(/>/g, '&gt;')
500
+ .replace(/"/g, '&quot;')
501
+ .replace(/'/g, '&#39;');
502
+ }
503
+
504
+ function escapeAttr(str) {
505
+ return escapeHtml(str);
506
+ }
507
+
508
+ function buildSimplePage(title, bodyContent) {
509
+ return `<!DOCTYPE html>
510
+ <html lang="en">
511
+ <head><meta charset="UTF-8"><title>${escapeHtml(title)}</title></head>
512
+ <body>
513
+ <h1>${escapeHtml(title)}</h1>
514
+ ${bodyContent}
515
+ </body>
516
+ </html>`;
517
+ }
518
+
519
+ function isSafeRedirect(url, siteDomain, referer) {
520
+ try {
521
+ const parsed = new URL(url, 'http://placeholder');
522
+
523
+ if (parsed.hostname === 'placeholder' || !parsed.hostname) return true;
524
+
525
+ const normTarget = parsed.hostname.toLowerCase().replace(/^www\./, '');
526
+ const normSite = (siteDomain || '').toLowerCase().replace(/^www\./, '');
527
+
528
+ if (normTarget === normSite) return true;
529
+
530
+ if (referer) {
531
+ try {
532
+ const refHost = new URL(referer).hostname.toLowerCase().replace(/^www\./, '');
533
+ if (normTarget === refHost) return true;
534
+ } catch (_) {}
535
+ }
536
+
537
+ return false;
538
+ } catch (_) {
539
+ return false;
540
+ }
541
+ }
542
+
543
+ module.exports = router;