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.
- package/LICENSE +21 -21
- package/README.ar.md +572 -446
- package/README.md +968 -933
- package/bin/agent-runner.js +465 -0
- package/bin/cli.js +138 -80
- package/bin/wab.js +80 -80
- package/examples/bidi-agent.js +119 -119
- package/examples/mcp-agent.js +94 -94
- package/examples/next-app-router/README.md +44 -0
- package/examples/puppeteer-agent.js +108 -108
- package/examples/saas-dashboard/README.md +55 -0
- package/examples/shopify-hydrogen/README.md +74 -0
- package/examples/vision-agent.js +171 -171
- package/examples/wordpress-elementor/README.md +77 -0
- package/package.json +71 -78
- package/public/.well-known/ai-assets.json +59 -0
- package/public/admin/login.html +84 -84
- package/public/ai.html +196 -0
- package/public/cookies.html +208 -208
- package/public/css/premium.css +317 -0
- package/public/css/styles.css +1235 -1235
- package/public/dashboard.html +704 -704
- package/public/demo.html +259 -0
- package/public/docs.html +585 -585
- package/public/feed.xml +89 -0
- package/public/index.html +581 -332
- package/public/js/auth-nav.js +31 -31
- package/public/js/auth-redirect.js +12 -12
- package/public/js/cookie-consent.js +56 -56
- package/public/js/wab-demo-page.js +721 -0
- package/public/js/ws-client.js +74 -74
- package/public/llms-full.txt +309 -0
- package/public/llms.txt +85 -0
- package/public/login.html +83 -83
- package/public/openapi.json +580 -0
- package/public/premium-dashboard.html +2487 -0
- package/public/premium.html +791 -0
- package/public/privacy.html +295 -295
- package/public/register.html +103 -103
- package/public/robots.txt +87 -0
- package/public/script/wab-consent.d.ts +36 -0
- package/public/script/wab-consent.js +104 -0
- package/public/script/wab-schema.js +131 -0
- package/public/script/wab.d.ts +108 -0
- package/public/script/wab.min.js +405 -0
- package/public/sitemap.xml +93 -0
- package/public/sovereign.html +660 -0
- package/public/terms.html +254 -254
- package/public/video/tutorial.mp4 +0 -0
- package/script/ai-agent-bridge.js +1558 -1513
- package/sdk/README.md +55 -55
- package/sdk/index.d.ts +118 -0
- package/sdk/index.js +257 -203
- package/sdk/package.json +14 -14
- package/sdk/schema-discovery.js +83 -0
- package/server/config/secrets.js +94 -92
- package/server/index.js +2 -9
- package/server/middleware/adminAuth.js +30 -30
- package/server/middleware/auth.js +41 -41
- package/server/middleware/rateLimits.js +24 -24
- package/server/migrations/001_add_analytics_indexes.sql +7 -7
- package/server/migrations/002_premium_features.sql +418 -0
- package/server/models/adapters/index.js +33 -33
- package/server/models/adapters/mysql.js +183 -183
- package/server/models/adapters/postgresql.js +172 -172
- package/server/models/adapters/sqlite.js +7 -7
- package/server/models/db.js +561 -561
- package/server/routes/admin-premium.js +671 -0
- package/server/routes/admin.js +247 -247
- package/server/routes/api.js +131 -138
- package/server/routes/auth.js +51 -51
- package/server/routes/billing.js +45 -45
- package/server/routes/discovery.js +406 -329
- package/server/routes/license.js +240 -240
- package/server/routes/noscript.js +543 -543
- package/server/routes/premium-v2.js +686 -0
- package/server/routes/premium.js +724 -0
- package/server/routes/sovereign.js +307 -0
- package/server/routes/wab-api.js +476 -476
- package/server/services/agent-memory.js +625 -0
- package/server/services/email.js +204 -204
- package/server/services/fairness.js +420 -420
- package/server/services/negotiation.js +439 -0
- package/server/services/plugins.js +747 -0
- package/server/services/premium.js +1883 -0
- package/server/services/reputation.js +465 -0
- package/server/services/self-healing.js +843 -0
- package/server/services/stripe.js +192 -192
- package/server/services/swarm.js +788 -0
- package/server/services/verification.js +481 -0
- package/server/services/vision.js +871 -0
- package/server/utils/cache.js +125 -125
- package/server/utils/migrate.js +81 -81
- package/server/utils/secureFields.js +50 -50
- package/server/ws.js +101 -101
- package/templates/artisan-marketplace.yaml +104 -0
- package/templates/book-price-scout.yaml +98 -0
- package/templates/electronics-price-tracker.yaml +108 -0
- package/templates/flight-deal-hunter.yaml +113 -0
- package/templates/freelancer-direct.yaml +116 -0
- package/templates/grocery-price-compare.yaml +93 -0
- package/templates/hotel-direct-booking.yaml +113 -0
- package/templates/local-services.yaml +98 -0
- package/templates/olive-oil-tunisia.yaml +88 -0
- package/templates/organic-farm-fresh.yaml +101 -0
- package/templates/restaurant-direct.yaml +97 -0
- package/docs/DEPLOY.md +0 -118
- package/docs/SPEC.md +0 -1540
- package/wab-mcp-adapter/README.md +0 -136
- package/wab-mcp-adapter/index.js +0 -555
- 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 — ${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 — ${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><img src="/api/noscript/pixel/${escapeHtml(siteId)}?action=pageview" width="1" height="1" alt=""></pre>
|
|
304
|
-
<h3>CSS Tracker</h3>
|
|
305
|
-
<pre><link rel="stylesheet" href="/api/noscript/css/${escapeHtml(siteId)}"></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, '&')
|
|
498
|
-
.replace(/</g, '<')
|
|
499
|
-
.replace(/>/g, '>')
|
|
500
|
-
.replace(/"/g, '"')
|
|
501
|
-
.replace(/'/g, ''');
|
|
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 — ${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 — ${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><img src="/api/noscript/pixel/${escapeHtml(siteId)}?action=pageview" width="1" height="1" alt=""></pre>
|
|
304
|
+
<h3>CSS Tracker</h3>
|
|
305
|
+
<pre><link rel="stylesheet" href="/api/noscript/css/${escapeHtml(siteId)}"></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, '&')
|
|
498
|
+
.replace(/</g, '<')
|
|
499
|
+
.replace(/>/g, '>')
|
|
500
|
+
.replace(/"/g, '"')
|
|
501
|
+
.replace(/'/g, ''');
|
|
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;
|