web-agent-bridge 1.0.0 → 1.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 (52) hide show
  1. package/README.ar.md +1 -1
  2. package/README.md +336 -36
  3. package/docs/DEPLOY.md +118 -0
  4. package/docs/SPEC.md +1540 -0
  5. package/examples/mcp-agent.js +85 -0
  6. package/examples/vision-agent.js +12 -0
  7. package/package.json +14 -3
  8. package/public/admin/dashboard.html +848 -0
  9. package/public/admin/login.html +84 -0
  10. package/public/cookies.html +208 -0
  11. package/public/css/premium.css +317 -0
  12. package/public/dashboard.html +138 -0
  13. package/public/docs.html +5 -2
  14. package/public/index.html +54 -28
  15. package/public/js/auth-nav.js +31 -0
  16. package/public/js/auth-redirect.js +12 -0
  17. package/public/js/cookie-consent.js +56 -0
  18. package/public/js/ws-client.js +74 -0
  19. package/public/login.html +4 -2
  20. package/public/premium-dashboard.html +2075 -0
  21. package/public/premium.html +791 -0
  22. package/public/privacy.html +295 -0
  23. package/public/register.html +11 -2
  24. package/public/terms.html +254 -0
  25. package/script/ai-agent-bridge.js +253 -22
  26. package/sdk/index.js +36 -0
  27. package/server/config/secrets.js +92 -0
  28. package/server/index.js +100 -26
  29. package/server/middleware/adminAuth.js +30 -0
  30. package/server/middleware/auth.js +4 -7
  31. package/server/middleware/rateLimits.js +24 -0
  32. package/server/migrations/001_add_analytics_indexes.sql +7 -0
  33. package/server/migrations/002_premium_features.sql +418 -0
  34. package/server/models/db.js +360 -4
  35. package/server/routes/admin.js +247 -0
  36. package/server/routes/api.js +26 -9
  37. package/server/routes/billing.js +45 -0
  38. package/server/routes/discovery.js +324 -0
  39. package/server/routes/license.js +200 -11
  40. package/server/routes/noscript.js +543 -0
  41. package/server/routes/premium.js +724 -0
  42. package/server/services/email.js +204 -0
  43. package/server/services/fairness.js +420 -0
  44. package/server/services/premium.js +1680 -0
  45. package/server/services/stripe.js +192 -0
  46. package/server/utils/cache.js +125 -0
  47. package/server/utils/migrate.js +81 -0
  48. package/server/utils/secureFields.js +50 -0
  49. package/server/ws.js +33 -13
  50. package/wab-mcp-adapter/README.md +136 -0
  51. package/wab-mcp-adapter/index.js +528 -0
  52. package/wab-mcp-adapter/package.json +17 -0
@@ -47,7 +47,10 @@ router.put('/sites/:id/config', authenticateToken, (req, res) => {
47
47
  if (!config) return res.status(400).json({ error: 'Config is required' });
48
48
 
49
49
  try {
50
- updateSiteConfig.run(JSON.stringify(config), req.params.id, req.user.id);
50
+ const r = updateSiteConfig.run(JSON.stringify(config), req.params.id, req.user.id);
51
+ if (r.changes === 0) {
52
+ return res.status(404).json({ error: 'Site not found' });
53
+ }
51
54
  res.json({ success: true });
52
55
  } catch (err) {
53
56
  res.status(500).json({ error: 'Failed to update config' });
@@ -61,7 +64,10 @@ router.put('/sites/:id/tier', authenticateToken, (req, res) => {
61
64
  }
62
65
 
63
66
  try {
64
- updateSiteTier.run(tier, req.params.id, req.user.id);
67
+ const r = updateSiteTier.run(tier, req.params.id, req.user.id);
68
+ if (r.changes === 0) {
69
+ return res.status(404).json({ error: 'Site not found' });
70
+ }
65
71
  res.json({ success: true, tier });
66
72
  } catch (err) {
67
73
  res.status(500).json({ error: 'Failed to update tier' });
@@ -70,7 +76,10 @@ router.put('/sites/:id/tier', authenticateToken, (req, res) => {
70
76
 
71
77
  router.delete('/sites/:id', authenticateToken, (req, res) => {
72
78
  try {
73
- deleteSite.run(req.params.id, req.user.id);
79
+ const r = deleteSite.run(req.params.id, req.user.id);
80
+ if (r.changes === 0) {
81
+ return res.status(404).json({ error: 'Site not found' });
82
+ }
74
83
  res.json({ success: true });
75
84
  } catch (err) {
76
85
  res.status(500).json({ error: 'Failed to delete site' });
@@ -103,19 +112,27 @@ router.get('/sites/:id/snippet', authenticateToken, (req, res) => {
103
112
  }
104
113
 
105
114
  const config = JSON.parse(site.config || '{}');
106
- const snippet = `<!-- Web Agent Bridge -->
115
+ const snippet = `<!-- Web Agent Bridge (Secure Mode + NoJS Fallback) -->
107
116
  <script>
108
117
  window.AIBridgeConfig = {
109
- licenseKey: "${site.license_key}",
110
- subscriptionTier: "${site.tier}",
118
+ siteId: "${site.id}",
119
+ configEndpoint: "/api/license/token",
111
120
  agentPermissions: ${JSON.stringify(config.agentPermissions || {}, null, 4)},
112
121
  restrictions: ${JSON.stringify(config.restrictions || {}, null, 4)},
113
122
  logging: ${JSON.stringify(config.logging || {}, null, 4)}
114
123
  };
115
124
  </script>
116
- <script src="/script/ai-agent-bridge.js"></script>`;
117
-
118
- res.json({ snippet, licenseKey: site.license_key });
125
+ <script src="/script/ai-agent-bridge.js"></script>
126
+ <noscript>
127
+ <!-- Automatic NoJS Fallback: tracking + CSS analytics + SSR bridge -->
128
+ <link rel="stylesheet" href="/api/noscript/css/${site.id}">
129
+ <img src="/api/noscript/pixel/${site.id}?action=pageview&t=noscript" width="1" height="1" alt="" style="position:absolute;opacity:0;">
130
+ <meta name="wab:site-id" content="${site.id}">
131
+ <meta name="wab:noscript" content="true">
132
+ <meta name="wab:bridge" content="/api/noscript/bridge/${site.id}">
133
+ </noscript>`;
134
+
135
+ res.json({ snippet, siteId: site.id });
119
136
  });
120
137
 
121
138
  module.exports = router;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Billing Routes (Customer-facing Stripe integration)
3
+ */
4
+
5
+ const express = require('express');
6
+ const router = express.Router();
7
+ const { authenticateToken } = require('../middleware/auth');
8
+ const { getPlatformSetting } = require('../models/db');
9
+ const { createCheckoutSession, createPortalSession, isStripeConfigured } = require('../services/stripe');
10
+
11
+ // ─── Create Checkout Session ──────────────────────────────────────────
12
+ router.post('/checkout', authenticateToken, async (req, res) => {
13
+ const { siteId, tier } = req.body;
14
+ if (!siteId || !tier) return res.status(400).json({ error: 'siteId and tier required' });
15
+ if (!['starter', 'pro', 'enterprise'].includes(tier)) return res.status(400).json({ error: 'Invalid tier' });
16
+
17
+ if (!isStripeConfigured()) {
18
+ return res.status(503).json({ error: 'Payment system not configured' });
19
+ }
20
+
21
+ try {
22
+ const session = await createCheckoutSession({ userId: req.user.id, userEmail: req.user.email, siteId, tier });
23
+ res.json(session);
24
+ } catch (err) {
25
+ res.status(500).json({ error: err.message });
26
+ }
27
+ });
28
+
29
+ // ─── Customer Portal ──────────────────────────────────────────────────
30
+ router.post('/portal', authenticateToken, async (req, res) => {
31
+ try {
32
+ const session = await createPortalSession(req.user.id);
33
+ res.json(session);
34
+ } catch (err) {
35
+ res.status(500).json({ error: err.message });
36
+ }
37
+ });
38
+
39
+ // ─── Stripe Config (public key for frontend) ─────────────────────────
40
+ router.get('/config', (req, res) => {
41
+ const publishableKey = getPlatformSetting('stripe_publishable_key');
42
+ res.json({ configured: isStripeConfigured(), publishableKey: publishableKey || null });
43
+ });
44
+
45
+ module.exports = router;
@@ -0,0 +1,324 @@
1
+ /**
2
+ * WAB Discovery Protocol — Auto-generated discovery documents and
3
+ * public registry of WAB-enabled sites with fairness scoring.
4
+ */
5
+
6
+ const express = require('express');
7
+ const router = express.Router();
8
+ const { findSiteById, db } = require('../models/db');
9
+ const { authenticateToken } = require('../middleware/auth');
10
+ const {
11
+ calculateNeutralityScore,
12
+ fairnessWeightedSearch,
13
+ registerInDirectory,
14
+ getDirectoryListings,
15
+ generateFairnessReport
16
+ } = require('../services/fairness');
17
+
18
+ const WAB_VERSION = '1.1.0';
19
+
20
+ // ─── Helpers ─────────────────────────────────────────────────────────
21
+
22
+ function findSiteByDomain(domain) {
23
+ if (!domain) return null;
24
+ const normalized = domain.toLowerCase().replace(/^www\./, '');
25
+ return db.prepare(
26
+ 'SELECT * FROM sites WHERE LOWER(REPLACE(domain, "www.", "")) = ? AND active = 1 LIMIT 1'
27
+ ).get(normalized);
28
+ }
29
+
30
+ function getRequestDomain(req) {
31
+ const origin = req.get('origin');
32
+ if (origin) {
33
+ try { return new URL(origin).hostname; } catch (_) {}
34
+ }
35
+ const host = req.get('host');
36
+ if (host) return host.split(':')[0];
37
+ return req.hostname;
38
+ }
39
+
40
+ function parseSiteConfig(site) {
41
+ try { return JSON.parse(site.config || '{}'); } catch (_) { return {}; }
42
+ }
43
+
44
+ function buildDiscoveryDocument(site) {
45
+ const config = parseSiteConfig(site);
46
+ const perms = config.agentPermissions || {};
47
+ const restrictions = config.restrictions || {};
48
+ const features = config.features || {};
49
+
50
+ const enabledActions = Object.entries(perms)
51
+ .filter(([, v]) => v)
52
+ .map(([k]) => k);
53
+
54
+ const featureList = ['auto_discovery', 'noscript_fallback'];
55
+ if (features.advancedAnalytics) featureList.push('advanced_analytics');
56
+ if (features.realTimeUpdates) featureList.push('real_time_updates');
57
+ if (perms.apiAccess) featureList.push('api_access');
58
+
59
+ const dirEntry = db.prepare('SELECT * FROM wab_directory WHERE site_id = ?').get(site.id);
60
+ const neutralityScore = calculateNeutralityScore(site);
61
+
62
+ return {
63
+ wab_version: WAB_VERSION,
64
+ generated_at: new Date().toISOString(),
65
+ provider: {
66
+ name: site.name,
67
+ domain: site.domain,
68
+ category: (dirEntry && dirEntry.category) || config.category || 'general',
69
+ description: site.description || ''
70
+ },
71
+ capabilities: {
72
+ commands: enabledActions,
73
+ permissions: perms,
74
+ tier: site.tier,
75
+ transport: ['js_global', 'http', 'websocket'],
76
+ features: featureList
77
+ },
78
+ agent_access: {
79
+ bridge_script: '/script/ai-agent-bridge.js',
80
+ api_base: '/api/license',
81
+ websocket: '/ws/analytics',
82
+ noscript: '/api/noscript',
83
+ discovery: '/api/discovery'
84
+ },
85
+ fairness: {
86
+ is_independent: dirEntry ? !!dirEntry.is_independent : false,
87
+ commission_rate: dirEntry ? dirEntry.commission_rate : 0,
88
+ direct_benefit: dirEntry ? (dirEntry.direct_benefit || '') : '',
89
+ neutrality_score: neutralityScore
90
+ },
91
+ security: {
92
+ session_required: true,
93
+ origin_validation: true,
94
+ rate_limit: (restrictions.rateLimit && restrictions.rateLimit.maxCallsPerMinute) || 60,
95
+ sandbox: true
96
+ },
97
+ endpoints: {
98
+ token_exchange: '/api/license/token',
99
+ verify: '/api/license/verify',
100
+ track: '/api/license/track',
101
+ actions: `/api/discovery/${site.id}`,
102
+ bridge_page: `/api/noscript/bridge/${site.id}`
103
+ }
104
+ };
105
+ }
106
+
107
+ // ═════════════════════════════════════════════════════════════════════
108
+ // 1. GET /.well-known/wab.json — Standard discovery location
109
+ // ═════════════════════════════════════════════════════════════════════
110
+
111
+ router.get('/.well-known/wab.json', (req, res) => {
112
+ try {
113
+ const domain = getRequestDomain(req);
114
+ const site = findSiteByDomain(domain);
115
+
116
+ if (!site) {
117
+ return res.status(404).json({
118
+ error: 'No WAB-enabled site found for this domain',
119
+ domain,
120
+ hint: 'Register your site at /dashboard to enable WAB discovery'
121
+ });
122
+ }
123
+
124
+ const doc = buildDiscoveryDocument(site);
125
+ res.set('Cache-Control', 'public, max-age=300');
126
+ res.set('X-WAB-Version', WAB_VERSION);
127
+ res.json(doc);
128
+ } catch (err) {
129
+ res.status(500).json({ error: 'Failed to generate discovery document' });
130
+ }
131
+ });
132
+
133
+ // ═════════════════════════════════════════════════════════════════════
134
+ // 2. GET /agent-bridge.json — Alternative discovery location
135
+ // ═════════════════════════════════════════════════════════════════════
136
+
137
+ router.get('/agent-bridge.json', (req, res) => {
138
+ try {
139
+ const domain = getRequestDomain(req);
140
+ const site = findSiteByDomain(domain);
141
+
142
+ if (!site) {
143
+ return res.status(404).json({
144
+ error: 'No WAB-enabled site found for this domain',
145
+ domain,
146
+ hint: 'Register your site at /dashboard to enable WAB discovery'
147
+ });
148
+ }
149
+
150
+ const doc = buildDiscoveryDocument(site);
151
+ res.set('Cache-Control', 'public, max-age=300');
152
+ res.set('X-WAB-Version', WAB_VERSION);
153
+ res.json(doc);
154
+ } catch (err) {
155
+ res.status(500).json({ error: 'Failed to generate discovery document' });
156
+ }
157
+ });
158
+
159
+ // ═════════════════════════════════════════════════════════════════════
160
+ // 3. GET /api/discovery/registry — Public registry with fairness scoring
161
+ // (defined BEFORE :siteId to avoid route shadowing)
162
+ // ═════════════════════════════════════════════════════════════════════
163
+
164
+ router.get('/api/discovery/registry', (req, res) => {
165
+ try {
166
+ const category = req.query.category || 'all';
167
+ const limit = Math.min(parseInt(req.query.limit) || 50, 200);
168
+ const offset = parseInt(req.query.offset) || 0;
169
+
170
+ const listings = getDirectoryListings(category, { limit, offset });
171
+
172
+ const registry = listings.map(entry => ({
173
+ siteId: entry.id,
174
+ name: entry.name,
175
+ domain: entry.domain,
176
+ description: entry.description || '',
177
+ category: entry.category || 'general',
178
+ tier: entry.tier,
179
+ neutrality_score: entry.neutrality_score || 0,
180
+ is_independent: !!entry.is_independent,
181
+ tags: safeParseTags(entry.tags),
182
+ discovery_url: `/api/discovery/${entry.id}`
183
+ }));
184
+
185
+ res.json({
186
+ wab_version: WAB_VERSION,
187
+ total: registry.length,
188
+ category,
189
+ listings: registry
190
+ });
191
+ } catch (err) {
192
+ res.status(500).json({ error: 'Failed to fetch registry' });
193
+ }
194
+ });
195
+
196
+ // ═════════════════════════════════════════════════════════════════════
197
+ // 4. POST /api/discovery/register — Register site in WAB directory
198
+ // ═════════════════════════════════════════════════════════════════════
199
+
200
+ router.post('/api/discovery/register', authenticateToken, (req, res) => {
201
+ try {
202
+ const { siteId, category, tags, is_independent, commission_rate, direct_benefit, trust_signature } = req.body;
203
+
204
+ if (!siteId) {
205
+ return res.status(400).json({ error: 'siteId is required' });
206
+ }
207
+
208
+ const site = findSiteById.get(siteId);
209
+ if (!site) {
210
+ return res.status(404).json({ error: 'Site not found' });
211
+ }
212
+ if (site.user_id !== req.user.id) {
213
+ return res.status(403).json({ error: 'You do not own this site' });
214
+ }
215
+
216
+ const result = registerInDirectory(siteId, {
217
+ category,
218
+ tags,
219
+ is_independent,
220
+ commission_rate,
221
+ direct_benefit,
222
+ trust_signature
223
+ });
224
+
225
+ if (!result.success) {
226
+ return res.status(400).json({ error: result.error });
227
+ }
228
+
229
+ const report = generateFairnessReport(siteId);
230
+
231
+ res.status(201).json({
232
+ success: true,
233
+ registration: result,
234
+ fairness_report: report
235
+ });
236
+ } catch (err) {
237
+ res.status(500).json({ error: 'Failed to register site' });
238
+ }
239
+ });
240
+
241
+ // ═════════════════════════════════════════════════════════════════════
242
+ // 5. GET /api/discovery/search — Search WAB sites (fairness-weighted)
243
+ // ═════════════════════════════════════════════════════════════════════
244
+
245
+ router.get('/api/discovery/search', (req, res) => {
246
+ try {
247
+ const query = req.query.q || '';
248
+ const category = req.query.category || null;
249
+ const limit = Math.min(parseInt(req.query.limit) || 20, 100);
250
+
251
+ let sql = `
252
+ SELECT s.*, d.category, d.tags, d.is_independent, d.commission_rate,
253
+ d.direct_benefit, d.neutrality_score, d.trust_signature
254
+ FROM wab_directory d
255
+ JOIN sites s ON d.site_id = s.id AND s.active = 1
256
+ WHERE d.listed = 1
257
+ `;
258
+ const params = [];
259
+
260
+ if (category) {
261
+ sql += ' AND d.category = ?';
262
+ params.push(category);
263
+ }
264
+
265
+ sql += ' ORDER BY d.neutrality_score DESC LIMIT ?';
266
+ params.push(limit * 3);
267
+
268
+ const candidates = db.prepare(sql).all(...params);
269
+ const results = fairnessWeightedSearch(query, candidates).slice(0, limit);
270
+
271
+ res.json({
272
+ wab_version: WAB_VERSION,
273
+ query,
274
+ total: results.length,
275
+ results: results.map(r => ({
276
+ siteId: r.id,
277
+ name: r.name,
278
+ domain: r.domain,
279
+ description: r.description || '',
280
+ category: r.category || 'general',
281
+ tier: r.tier,
282
+ neutrality_score: r._neutralityScore,
283
+ is_independent: r._isIndependent,
284
+ relevance_score: r._relevance,
285
+ fairness_boost: r._fairnessBoost,
286
+ final_score: r._finalScore,
287
+ tags: safeParseTags(r.tags),
288
+ discovery_url: `/api/discovery/${r.id}`
289
+ }))
290
+ });
291
+ } catch (err) {
292
+ res.status(500).json({ error: 'Search failed' });
293
+ }
294
+ });
295
+
296
+ // ═════════════════════════════════════════════════════════════════════
297
+ // 6. GET /api/discovery/:siteId — Discovery doc for a specific site
298
+ // (defined AFTER named routes to prevent shadowing)
299
+ // ═════════════════════════════════════════════════════════════════════
300
+
301
+ router.get('/api/discovery/:siteId', (req, res) => {
302
+ try {
303
+ const site = findSiteById.get(req.params.siteId);
304
+ if (!site || !site.active) {
305
+ return res.status(404).json({ error: 'Site not found' });
306
+ }
307
+
308
+ const doc = buildDiscoveryDocument(site);
309
+ res.set('Cache-Control', 'public, max-age=300');
310
+ res.set('X-WAB-Version', WAB_VERSION);
311
+ res.json(doc);
312
+ } catch (err) {
313
+ res.status(500).json({ error: 'Failed to generate discovery document' });
314
+ }
315
+ });
316
+
317
+ // ─── Utility ─────────────────────────────────────────────────────────
318
+
319
+ function safeParseTags(tags) {
320
+ if (Array.isArray(tags)) return tags;
321
+ try { return JSON.parse(tags || '[]'); } catch (_) { return []; }
322
+ }
323
+
324
+ module.exports = router;
@@ -1,31 +1,221 @@
1
1
  const express = require('express');
2
+ const crypto = require('crypto');
2
3
  const router = express.Router();
3
- const { verifyLicense, recordAnalytic, findSiteByLicense } = require('../models/db');
4
+ const {
5
+ verifyLicense,
6
+ recordAnalytic,
7
+ findSiteByLicense,
8
+ findSiteById,
9
+ db
10
+ } = require('../models/db');
4
11
  const { broadcastAnalytic } = require('../ws');
12
+ const { cache, AnalyticsQueue } = require('../utils/cache');
13
+ const { licenseTokenLimiter, licenseTrackLimiter } = require('../middleware/rateLimits');
5
14
 
15
+ const analyticsQueue = new AnalyticsQueue(db, { maxSize: 50, maxBufferTotal: 5000 });
16
+
17
+ // ─── Session Token Store (in-memory, TTL 1 hour) ────────────────────
18
+ const sessionTokens = new Map();
19
+ const SESSION_TTL = 60 * 60 * 1000;
20
+
21
+ setInterval(() => {
22
+ const now = Date.now();
23
+ for (const [token, data] of sessionTokens) {
24
+ if (now > data.expiresAt) sessionTokens.delete(token);
25
+ }
26
+ }, 5 * 60 * 1000);
27
+
28
+ function normalizeHost(host) {
29
+ if (!host) return '';
30
+ let h = String(host).toLowerCase().trim();
31
+ if (h.startsWith('www.')) h = h.slice(4);
32
+ return h;
33
+ }
34
+
35
+ function getRequestHostname(req) {
36
+ const origin = req.get('origin') || req.get('referer');
37
+ try {
38
+ return origin ? new URL(origin).hostname : req.hostname;
39
+ } catch {
40
+ return req.hostname;
41
+ }
42
+ }
43
+
44
+ function allowDevInsecureOrigin(hostname) {
45
+ if (process.env.NODE_ENV === 'production') return false;
46
+ if (process.env.ALLOW_INSECURE_LICENSE_ORIGIN !== 'true') return false;
47
+ const n = normalizeHost(hostname);
48
+ return n === 'localhost' || n === '127.0.0.1' || n === '[::1]';
49
+ }
50
+
51
+ // ─── Verify (domain + license OR session + siteId) ─────────────────
6
52
  router.post('/verify', (req, res) => {
7
- const { domain, licenseKey } = req.body;
53
+ const { domain, licenseKey, siteId, sessionToken } = req.body;
54
+
55
+ if (sessionToken && siteId) {
56
+ const session = sessionTokens.get(sessionToken);
57
+ if (!session || Date.now() > session.expiresAt) {
58
+ sessionTokens.delete(sessionToken);
59
+ return res.json({ valid: false, error: 'Session expired or invalid', tier: 'free' });
60
+ }
61
+ const requestDomain = getRequestHostname(req);
62
+ if (normalizeHost(requestDomain) !== normalizeHost(session.domain)) {
63
+ return res.json({ valid: false, error: 'Domain mismatch', tier: 'free' });
64
+ }
65
+ if (session.siteId !== siteId) {
66
+ return res.json({ valid: false, error: 'Invalid site', tier: 'free' });
67
+ }
68
+ return res.json({
69
+ valid: true,
70
+ tier: session.tier,
71
+ domain: session.domain,
72
+ allowedPermissions: session.permissions
73
+ });
74
+ }
8
75
 
9
76
  if (!domain || !licenseKey) {
10
- return res.status(400).json({ valid: false, error: 'Domain and licenseKey are required', tier: 'free' });
77
+ return res.status(400).json({ valid: false, error: 'Domain and licenseKey are required (or sessionToken + siteId)', tier: 'free' });
11
78
  }
12
79
 
80
+ const cacheKey = `license:${domain}:${licenseKey}`;
81
+ const cached = cache.get(cacheKey);
82
+ if (cached) return res.json(cached);
83
+
13
84
  const result = verifyLicense(domain, licenseKey);
85
+ cache.set(cacheKey, result, 60000);
14
86
  res.json(result);
15
87
  });
16
88
 
17
- router.post('/track', (req, res) => {
18
- const { licenseKey, actionName, agentId, triggerType, success, metadata } = req.body;
89
+ // ─── Token exchange: siteId (preferred) or licenseKey (legacy) ─────
90
+ router.post('/token', licenseTokenLimiter, (req, res) => {
91
+ const { licenseKey, siteId } = req.body;
92
+ const domain = getRequestHostname(req);
93
+ const normReq = normalizeHost(domain);
94
+
95
+ const finishSession = (site, result) => {
96
+ const sessionToken = crypto.randomBytes(32).toString('hex');
97
+ const expiresAt = Date.now() + SESSION_TTL;
98
+ sessionTokens.set(sessionToken, {
99
+ siteId: site.id,
100
+ domain: site.domain,
101
+ tier: result.tier,
102
+ permissions: result.allowedPermissions,
103
+ expiresAt
104
+ });
105
+ res.json({
106
+ sessionToken,
107
+ siteId: site.id,
108
+ tier: result.tier,
109
+ permissions: result.allowedPermissions,
110
+ expiresIn: SESSION_TTL / 1000
111
+ });
112
+ };
113
+
114
+ if (siteId && !licenseKey) {
115
+ const site = findSiteById.get(siteId);
116
+ if (!site || !site.active) {
117
+ return res.status(404).json({ error: 'Site not found' });
118
+ }
119
+ const originOk =
120
+ normReq === normalizeHost(site.domain) ||
121
+ allowDevInsecureOrigin(domain);
122
+ if (!originOk) {
123
+ return res.status(403).json({ error: 'Origin does not match registered site domain' });
124
+ }
125
+ const cacheKey = `license:${site.domain}:${site.license_key}`;
126
+ let result = cache.get(cacheKey);
127
+ if (!result) {
128
+ result = verifyLicense(site.domain, site.license_key);
129
+ cache.set(cacheKey, result, 60000);
130
+ }
131
+ if (!result.valid) {
132
+ return res.status(403).json({ error: result.error || 'Invalid license', tier: 'free' });
133
+ }
134
+ return finishSession(site, result);
135
+ }
19
136
 
20
- if (!licenseKey || !actionName) {
21
- return res.status(400).json({ error: 'licenseKey and actionName are required' });
137
+ if (!licenseKey) {
138
+ return res.status(400).json({ error: 'siteId or licenseKey is required' });
22
139
  }
23
140
 
24
- try {
25
- const site = findSiteByLicense.get(licenseKey);
141
+ const cacheKey = `license:${domain}:${licenseKey}`;
142
+ let result = cache.get(cacheKey);
143
+ if (!result) {
144
+ result = verifyLicense(domain, licenseKey);
145
+ cache.set(cacheKey, result, 60000);
146
+ }
147
+ if (!result.valid) {
148
+ return res.status(403).json({ error: result.error || 'Invalid license', tier: 'free' });
149
+ }
150
+
151
+ const site = findSiteByLicense.get(licenseKey);
152
+ if (!site) {
153
+ return res.status(404).json({ error: 'Site not found' });
154
+ }
155
+
156
+ finishSession(site, result);
157
+ });
158
+
159
+ // ─── Validate Session Token ─────────────────────────────────────────
160
+ router.post('/session', (req, res) => {
161
+ const { sessionToken } = req.body;
162
+ if (!sessionToken) {
163
+ return res.status(400).json({ valid: false, error: 'sessionToken required' });
164
+ }
165
+
166
+ const session = sessionTokens.get(sessionToken);
167
+ if (!session || Date.now() > session.expiresAt) {
168
+ sessionTokens.delete(sessionToken);
169
+ return res.status(401).json({ valid: false, error: 'Session expired or invalid' });
170
+ }
171
+
172
+ const requestDomain = getRequestHostname(req);
173
+ if (normalizeHost(requestDomain) !== normalizeHost(session.domain)) {
174
+ return res.status(403).json({ valid: false, error: 'Domain mismatch' });
175
+ }
176
+
177
+ res.json({
178
+ valid: true,
179
+ siteId: session.siteId,
180
+ tier: session.tier,
181
+ permissions: session.permissions
182
+ });
183
+ });
184
+
185
+ // ─── Analytics track (session-bound; licenseKey deprecated) ──────
186
+ router.post('/track', licenseTrackLimiter, (req, res) => {
187
+ const { sessionToken, actionName, agentId, triggerType, success, metadata, licenseKey } = req.body;
188
+
189
+ if (!actionName) {
190
+ return res.status(400).json({ error: 'actionName is required' });
191
+ }
192
+
193
+ let site;
194
+ if (sessionToken) {
195
+ const session = sessionTokens.get(sessionToken);
196
+ if (!session || Date.now() > session.expiresAt) {
197
+ sessionTokens.delete(sessionToken);
198
+ return res.status(401).json({ error: 'Session expired or invalid' });
199
+ }
200
+ const requestDomain = getRequestHostname(req);
201
+ if (normalizeHost(requestDomain) !== normalizeHost(session.domain)) {
202
+ return res.status(403).json({ error: 'Origin does not match session domain' });
203
+ }
204
+ site = findSiteById.get(session.siteId);
205
+ if (!site || !site.active) {
206
+ return res.status(404).json({ error: 'Site not found' });
207
+ }
208
+ } else if (licenseKey && process.env.ALLOW_LEGACY_LICENSE_TRACK === 'true') {
209
+ site = findSiteByLicense.get(licenseKey);
26
210
  if (!site) return res.status(404).json({ error: 'Site not found' });
211
+ } else {
212
+ return res.status(400).json({
213
+ error: 'sessionToken is required. Obtain via POST /api/license/token (see installation snippet).'
214
+ });
215
+ }
27
216
 
28
- recordAnalytic({
217
+ try {
218
+ analyticsQueue.push({
29
219
  siteId: site.id,
30
220
  actionName,
31
221
  agentId,
@@ -34,7 +224,6 @@ router.post('/track', (req, res) => {
34
224
  metadata
35
225
  });
36
226
 
37
- // Broadcast real-time analytics via WebSocket
38
227
  broadcastAnalytic(site.id, {
39
228
  actionName,
40
229
  agentId,