web-agent-bridge 3.2.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (256) hide show
  1. package/LICENSE +84 -72
  2. package/README.ar.md +1304 -1152
  3. package/README.md +298 -1635
  4. package/bin/agent-runner.js +474 -474
  5. package/bin/cli.js +237 -138
  6. package/bin/wab-init.js +223 -0
  7. package/bin/wab.js +80 -80
  8. package/examples/azure-dns-wab.js +83 -0
  9. package/examples/bidi-agent.js +119 -119
  10. package/examples/cloudflare-wab-dns.js +121 -0
  11. package/examples/cpanel-wab-dns.js +114 -0
  12. package/examples/cross-site-agent.js +91 -91
  13. package/examples/dns-discovery-agent.js +166 -0
  14. package/examples/gcp-dns-wab.js +76 -0
  15. package/examples/governance-agent.js +169 -0
  16. package/examples/mcp-agent.js +94 -94
  17. package/examples/next-app-router/README.md +44 -44
  18. package/examples/plesk-wab-dns.js +103 -0
  19. package/examples/puppeteer-agent.js +108 -108
  20. package/examples/route53-wab-dns.js +144 -0
  21. package/examples/saas-dashboard/README.md +55 -55
  22. package/examples/safe-mode-agent.js +96 -0
  23. package/examples/shopify-hydrogen/README.md +74 -74
  24. package/examples/vision-agent.js +171 -171
  25. package/examples/wab-sign.js +74 -0
  26. package/examples/wab-verify.js +60 -0
  27. package/examples/wordpress-elementor/README.md +77 -77
  28. package/package.json +19 -6
  29. package/public/.well-known/agent-tools.json +180 -180
  30. package/public/.well-known/ai-assets.json +59 -59
  31. package/public/.well-known/security.txt +8 -0
  32. package/public/.well-known/wab.json +28 -0
  33. package/public/activate.html +368 -0
  34. package/public/adoption-metrics.html +188 -0
  35. package/public/agent-workspace.html +349 -349
  36. package/public/ai.html +198 -198
  37. package/public/api.html +413 -412
  38. package/public/azure-dns-integration.html +289 -0
  39. package/public/browser.html +486 -486
  40. package/public/cloudflare-integration.html +380 -0
  41. package/public/commander-dashboard.html +243 -243
  42. package/public/cookies.html +210 -210
  43. package/public/cpanel-integration.html +398 -0
  44. package/public/css/agent-workspace.css +1713 -1713
  45. package/public/css/premium.css +317 -317
  46. package/public/css/styles.css +1263 -1235
  47. package/public/dashboard.html +707 -706
  48. package/public/dns.html +436 -0
  49. package/public/docs.html +588 -587
  50. package/public/feed.xml +89 -89
  51. package/public/gcp-dns-integration.html +318 -0
  52. package/public/growth.html +465 -463
  53. package/public/index.html +1266 -982
  54. package/public/integrations.html +556 -0
  55. package/public/js/activate.js +145 -0
  56. package/public/js/agent-workspace.js +1740 -1740
  57. package/public/js/auth-nav.js +65 -31
  58. package/public/js/auth-redirect.js +12 -12
  59. package/public/js/cookie-consent.js +56 -56
  60. package/public/js/dns.js +438 -0
  61. package/public/js/wab-demo-page.js +721 -721
  62. package/public/js/ws-client.js +74 -74
  63. package/public/llms-full.txt +360 -360
  64. package/public/llms.txt +125 -125
  65. package/public/login.html +85 -85
  66. package/public/mesh-dashboard.html +328 -328
  67. package/public/openapi.json +669 -580
  68. package/public/phone-shield.html +281 -0
  69. package/public/plesk-integration.html +375 -0
  70. package/public/premium-dashboard.html +2489 -2489
  71. package/public/premium.html +793 -793
  72. package/public/privacy.html +297 -297
  73. package/public/provider-onboarding.html +172 -0
  74. package/public/provider-sandbox.html +134 -0
  75. package/public/providers.html +359 -0
  76. package/public/register.html +105 -105
  77. package/public/registrar-integrations.html +141 -0
  78. package/public/robots.txt +99 -87
  79. package/public/route53-integration.html +531 -0
  80. package/public/script/wab-consent.d.ts +36 -36
  81. package/public/script/wab-consent.js +104 -104
  82. package/public/script/wab-schema.js +131 -131
  83. package/public/script/wab.d.ts +108 -108
  84. package/public/script/wab.min.js +580 -580
  85. package/public/security.txt +8 -0
  86. package/public/shieldqr.html +231 -0
  87. package/public/sitemap.xml +6 -0
  88. package/public/terms.html +256 -256
  89. package/public/wab-trust.html +200 -0
  90. package/public/wab-vs-protocols.html +210 -0
  91. package/public/whitepaper.html +449 -0
  92. package/script/ai-agent-bridge.js +1754 -1754
  93. package/sdk/README.md +99 -99
  94. package/sdk/agent-mesh.js +449 -449
  95. package/sdk/auto-discovery.js +288 -0
  96. package/sdk/commander.js +262 -262
  97. package/sdk/governance.js +262 -0
  98. package/sdk/index.d.ts +464 -464
  99. package/sdk/index.js +25 -1
  100. package/sdk/multi-agent.js +318 -318
  101. package/sdk/package.json +2 -2
  102. package/sdk/safe-mode.js +221 -0
  103. package/sdk/safety-shield.js +219 -0
  104. package/sdk/schema-discovery.js +83 -83
  105. package/server/adapters/index.js +520 -520
  106. package/server/config/plans.js +367 -367
  107. package/server/config/secrets.js +102 -102
  108. package/server/control-plane/index.js +301 -301
  109. package/server/data-plane/index.js +354 -354
  110. package/server/index.js +670 -427
  111. package/server/llm/index.js +404 -404
  112. package/server/middleware/adminAuth.js +35 -35
  113. package/server/middleware/auth.js +50 -50
  114. package/server/middleware/featureGate.js +88 -88
  115. package/server/middleware/rateLimits.js +100 -100
  116. package/server/middleware/sensitiveAction.js +157 -0
  117. package/server/migrations/001_add_analytics_indexes.sql +7 -7
  118. package/server/migrations/002_premium_features.sql +418 -418
  119. package/server/migrations/003_ads_integer_cents.sql +33 -33
  120. package/server/migrations/004_agent_os.sql +158 -158
  121. package/server/migrations/005_marketplace_metering.sql +126 -126
  122. package/server/migrations/007_governance.sql +106 -0
  123. package/server/migrations/008_plans.sql +144 -0
  124. package/server/migrations/009_shieldqr.sql +30 -0
  125. package/server/migrations/010_extended_trust.sql +33 -0
  126. package/server/models/adapters/index.js +33 -33
  127. package/server/models/adapters/mysql.js +183 -183
  128. package/server/models/adapters/postgresql.js +172 -172
  129. package/server/models/adapters/sqlite.js +7 -7
  130. package/server/models/db.js +740 -681
  131. package/server/observability/failure-analysis.js +337 -337
  132. package/server/observability/index.js +394 -394
  133. package/server/protocol/capabilities.js +223 -223
  134. package/server/protocol/index.js +243 -243
  135. package/server/protocol/schema.js +584 -584
  136. package/server/registry/certification.js +271 -271
  137. package/server/registry/index.js +326 -326
  138. package/server/routes/admin-plans.js +76 -0
  139. package/server/routes/admin-premium.js +673 -671
  140. package/server/routes/admin-shieldqr.js +90 -0
  141. package/server/routes/admin-trust-monitor.js +83 -0
  142. package/server/routes/admin.js +549 -261
  143. package/server/routes/ads.js +130 -130
  144. package/server/routes/agent-workspace.js +540 -540
  145. package/server/routes/api.js +150 -150
  146. package/server/routes/auth.js +71 -71
  147. package/server/routes/billing.js +57 -45
  148. package/server/routes/commander.js +316 -316
  149. package/server/routes/demo-showcase.js +332 -332
  150. package/server/routes/demo-store.js +154 -0
  151. package/server/routes/discovery.js +2348 -417
  152. package/server/routes/gateway.js +173 -157
  153. package/server/routes/governance.js +208 -0
  154. package/server/routes/license.js +251 -240
  155. package/server/routes/mesh.js +469 -469
  156. package/server/routes/noscript.js +543 -543
  157. package/server/routes/plans.js +33 -0
  158. package/server/routes/premium-v2.js +686 -686
  159. package/server/routes/premium.js +724 -724
  160. package/server/routes/providers.js +650 -0
  161. package/server/routes/runtime.js +2148 -2147
  162. package/server/routes/shieldqr.js +88 -0
  163. package/server/routes/sovereign.js +465 -385
  164. package/server/routes/universal.js +200 -185
  165. package/server/routes/wab-api.js +850 -501
  166. package/server/runtime/container-worker.js +111 -111
  167. package/server/runtime/container.js +448 -448
  168. package/server/runtime/distributed-worker.js +362 -362
  169. package/server/runtime/event-bus.js +210 -210
  170. package/server/runtime/index.js +253 -253
  171. package/server/runtime/queue.js +599 -599
  172. package/server/runtime/replay.js +666 -666
  173. package/server/runtime/sandbox.js +266 -266
  174. package/server/runtime/scheduler.js +534 -534
  175. package/server/runtime/session-engine.js +293 -293
  176. package/server/runtime/state-manager.js +188 -188
  177. package/server/security/cross-site-redactor.js +196 -0
  178. package/server/security/dry-run.js +180 -0
  179. package/server/security/human-gate-rate-limit.js +147 -0
  180. package/server/security/human-gate-transports.js +178 -0
  181. package/server/security/human-gate.js +281 -0
  182. package/server/security/index.js +368 -368
  183. package/server/security/intent-engine.js +245 -0
  184. package/server/security/reward-guard.js +171 -0
  185. package/server/security/rollback-store.js +239 -0
  186. package/server/security/token-scope.js +404 -0
  187. package/server/security/url-policy.js +139 -0
  188. package/server/services/agent-chat.js +506 -506
  189. package/server/services/agent-learning.js +601 -575
  190. package/server/services/agent-memory.js +625 -625
  191. package/server/services/agent-mesh.js +555 -539
  192. package/server/services/agent-symphony.js +717 -717
  193. package/server/services/agent-tasks.js +1807 -1807
  194. package/server/services/api-key-engine.js +292 -261
  195. package/server/services/cluster.js +894 -894
  196. package/server/services/commander.js +738 -738
  197. package/server/services/edge-compute.js +440 -440
  198. package/server/services/email.js +233 -204
  199. package/server/services/governance.js +466 -0
  200. package/server/services/hosted-runtime.js +205 -205
  201. package/server/services/lfd.js +635 -635
  202. package/server/services/local-ai.js +389 -389
  203. package/server/services/marketplace.js +270 -270
  204. package/server/services/metering.js +182 -182
  205. package/server/services/modules/affiliate-intelligence.js +93 -93
  206. package/server/services/modules/agent-firewall.js +90 -90
  207. package/server/services/modules/bounty.js +89 -89
  208. package/server/services/modules/collective-bargaining.js +92 -92
  209. package/server/services/modules/dark-pattern.js +66 -66
  210. package/server/services/modules/gov-intelligence.js +45 -45
  211. package/server/services/modules/neural.js +55 -55
  212. package/server/services/modules/notary.js +49 -49
  213. package/server/services/modules/price-time-machine.js +86 -86
  214. package/server/services/modules/protocol.js +104 -104
  215. package/server/services/negotiation.js +439 -439
  216. package/server/services/plans.js +214 -0
  217. package/server/services/plugins.js +771 -771
  218. package/server/services/premium.js +1 -1
  219. package/server/services/price-intelligence.js +566 -566
  220. package/server/services/price-shield.js +1137 -1137
  221. package/server/services/provider-clients.js +740 -0
  222. package/server/services/reputation.js +465 -465
  223. package/server/services/search-engine.js +357 -357
  224. package/server/services/security.js +513 -513
  225. package/server/services/self-healing.js +843 -843
  226. package/server/services/shieldqr.js +322 -0
  227. package/server/services/sovereign-shield.js +542 -0
  228. package/server/services/ssl-inspector.js +42 -0
  229. package/server/services/ssl-monitor.js +167 -0
  230. package/server/services/stripe.js +205 -192
  231. package/server/services/swarm.js +788 -788
  232. package/server/services/universal-scraper.js +662 -661
  233. package/server/services/verification.js +481 -481
  234. package/server/services/vision.js +1163 -1163
  235. package/server/services/wab-crypto.js +178 -0
  236. package/server/utils/cache.js +125 -125
  237. package/server/utils/migrate.js +81 -81
  238. package/server/utils/safe-fetch.js +228 -0
  239. package/server/utils/secureFields.js +50 -50
  240. package/server/ws.js +161 -161
  241. package/templates/artisan-marketplace.yaml +104 -104
  242. package/templates/book-price-scout.yaml +98 -98
  243. package/templates/electronics-price-tracker.yaml +108 -108
  244. package/templates/flight-deal-hunter.yaml +113 -113
  245. package/templates/freelancer-direct.yaml +116 -116
  246. package/templates/grocery-price-compare.yaml +93 -93
  247. package/templates/hotel-direct-booking.yaml +113 -113
  248. package/templates/local-services.yaml +98 -98
  249. package/templates/olive-oil-tunisia.yaml +88 -88
  250. package/templates/organic-farm-fresh.yaml +101 -101
  251. package/templates/restaurant-direct.yaml +97 -97
  252. package/public/score.html +0 -263
  253. package/server/migrations/006_growth_suite.sql +0 -138
  254. package/server/routes/growth.js +0 -962
  255. package/server/services/fairness-engine.js +0 -409
  256. package/server/services/fairness.js +0 -420
@@ -1,417 +1,2348 @@
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
-
11
- // Fairness module is proprietary — provide stubs when not available
12
- let calculateNeutralityScore, fairnessWeightedSearch, registerInDirectory, getDirectoryListings, generateFairnessReport;
13
- try {
14
- ({
15
- calculateNeutralityScore,
16
- fairnessWeightedSearch,
17
- registerInDirectory,
18
- getDirectoryListings,
19
- generateFairnessReport
20
- } = require('../services/fairness'));
21
- } catch {
22
- calculateNeutralityScore = () => ({ score: 0, label: 'unrated' });
23
- fairnessWeightedSearch = (_q, candidates) => candidates;
24
- registerInDirectory = () => ({});
25
- getDirectoryListings = () => [];
26
- generateFairnessReport = () => ({ status: 'unavailable' });
27
- }
28
-
29
- const WAB_VERSION = '1.2.0';
30
-
31
- // ─── Helpers ─────────────────────────────────────────────────────────
32
-
33
- function findSiteByDomain(domain) {
34
- if (!domain) return null;
35
- const normalized = domain.toLowerCase().replace(/^www\./, '');
36
- return db.prepare(
37
- "SELECT * FROM sites WHERE LOWER(REPLACE(domain, 'www.', '')) = ? AND active = 1 LIMIT 1"
38
- ).get(normalized);
39
- }
40
-
41
- function getRequestDomain(req) {
42
- const origin = req.get('origin');
43
- if (origin) {
44
- try { return new URL(origin).hostname; } catch (_) {}
45
- }
46
- const host = req.get('host');
47
- if (host) return host.split(':')[0];
48
- return req.hostname;
49
- }
50
-
51
- function parseSiteConfig(site) {
52
- try { return JSON.parse(site.config || '{}'); } catch (_) { return {}; }
53
- }
54
-
55
- function buildDiscoveryDocument(site) {
56
- const config = parseSiteConfig(site);
57
- const perms = config.agentPermissions || {};
58
- const restrictions = config.restrictions || {};
59
- const features = config.features || {};
60
-
61
- const enabledActions = Object.entries(perms)
62
- .filter(([, v]) => v)
63
- .map(([k]) => k);
64
-
65
- const featureList = ['auto_discovery', 'noscript_fallback'];
66
- if (features.advancedAnalytics) featureList.push('advanced_analytics');
67
- if (features.realTimeUpdates) featureList.push('real_time_updates');
68
- if (perms.apiAccess) featureList.push('api_access');
69
-
70
- const dirEntry = db.prepare('SELECT * FROM wab_directory WHERE site_id = ?').get(site.id);
71
- const neutralityScore = calculateNeutralityScore(site);
72
-
73
- return {
74
- wab_version: WAB_VERSION,
75
- generated_at: new Date().toISOString(),
76
- provider: {
77
- name: site.name,
78
- domain: site.domain,
79
- category: (dirEntry && dirEntry.category) || config.category || 'general',
80
- description: site.description || ''
81
- },
82
- capabilities: {
83
- commands: enabledActions,
84
- permissions: perms,
85
- tier: site.tier,
86
- transport: ['js_global', 'http', 'websocket'],
87
- features: featureList
88
- },
89
- agent_access: {
90
- bridge_script: '/script/ai-agent-bridge.js',
91
- api_base: '/api/wab',
92
- websocket: '/ws/analytics',
93
- noscript: `/api/noscript/bridge/${site.id}`,
94
- discovery: `/api/discovery/${site.id}`
95
- },
96
- fairness: {
97
- is_independent: dirEntry ? !!dirEntry.is_independent : false,
98
- commission_rate: dirEntry ? dirEntry.commission_rate : 0,
99
- direct_benefit: dirEntry ? (dirEntry.direct_benefit || '') : '',
100
- neutrality_score: neutralityScore
101
- },
102
- security: {
103
- session_required: true,
104
- origin_validation: true,
105
- rate_limit: (restrictions.rateLimit && restrictions.rateLimit.maxCallsPerMinute) || 60,
106
- sandbox: true
107
- },
108
- endpoints: {
109
- authenticate: '/api/wab/authenticate',
110
- discover: `/api/wab/discover?siteId=${site.id}`,
111
- actions: `/api/wab/actions?siteId=${site.id}`,
112
- execute: '/api/wab/actions/{actionName}',
113
- read: '/api/wab/read',
114
- page_info: `/api/wab/page-info?siteId=${site.id}`,
115
- search: '/api/wab/search',
116
- ping: '/api/wab/ping',
117
- token_exchange: '/api/license/token',
118
- bridge_page: `/api/noscript/bridge/${site.id}`
119
- }
120
- };
121
- }
122
-
123
- // ═════════════════════════════════════════════════════════════════════
124
- // 1. GET /.well-known/wab.json — Standard discovery location
125
- // ═════════════════════════════════════════════════════════════════════
126
-
127
- function buildSelfDiscovery() {
128
- return {
129
- wab_version: WAB_VERSION,
130
- protocol: '1.0',
131
- generated_at: new Date().toISOString(),
132
- provider: {
133
- name: 'Web Agent Bridge',
134
- domain: 'webagentbridge.com',
135
- category: 'developer-tools',
136
- description: 'Open protocol and runtime for AI agent ↔ website interaction. The OpenAPI for human-facing web pages.'
137
- },
138
- capabilities: {
139
- commands: ['read', 'navigate', 'search', 'discover'],
140
- permissions: { readContent: true, navigate: true, apiAccess: true },
141
- tier: 'platform',
142
- transport: ['http', 'javascript', 'websocket'],
143
- features: [
144
- 'discovery_protocol', 'fairness_engine', 'mcp_adapter',
145
- 'noscript_fallback', 'agent_sdk', 'wordpress_plugin',
146
- 'openapi_spec', 'llms_txt', 'atom_feed'
147
- ]
148
- },
149
- agent_access: {
150
- bridge_script: '/script/ai-agent-bridge.js',
151
- api_base: '/api/wab',
152
- websocket: '/ws/analytics',
153
- discovery: '/agent-bridge.json',
154
- llms_txt: '/llms.txt',
155
- llms_full_txt: '/llms-full.txt',
156
- openapi: '/openapi.json',
157
- ai_assets: '/.well-known/ai-assets.json',
158
- atom_feed: '/feed.xml',
159
- sitemap: '/sitemap.xml'
160
- },
161
- fairness: {
162
- is_independent: true,
163
- commission_rate: 0,
164
- direct_benefit: 'Open-source protocol maintainer',
165
- neutrality_score: 100
166
- },
167
- security: {
168
- session_required: false,
169
- origin_validation: true,
170
- rate_limit: 60,
171
- sandbox: true
172
- },
173
- endpoints: {
174
- discover: '/api/wab/discover',
175
- actions: '/api/wab/actions',
176
- execute: '/api/wab/execute',
177
- ping: '/api/wab/ping',
178
- search: '/api/wab/search',
179
- registry: '/api/discovery/registry',
180
- plans: '/api/plans',
181
- page_info: '/api/wab/page-info'
182
- },
183
- ecosystem: {
184
- npm: 'https://www.npmjs.com/package/web-agent-bridge',
185
- github: 'https://github.com/abokenan444/web-agent-bridge',
186
- security: 'https://socket.dev/npm/package/web-agent-bridge',
187
- mcp_adapter: 'https://www.npmjs.com/package/wab-mcp-adapter',
188
- wordpress: 'https://github.com/abokenan444/web-agent-bridge/tree/master/web-agent-bridge-wordpress',
189
- specification: 'https://github.com/abokenan444/web-agent-bridge/blob/master/docs/SPEC.md'
190
- }
191
- };
192
- }
193
-
194
- router.get('/.well-known/wab.json', (req, res) => {
195
- try {
196
- const domain = getRequestDomain(req);
197
- const site = findSiteByDomain(domain);
198
-
199
- if (!site) {
200
- if (domain === 'webagentbridge.com' || domain === 'www.webagentbridge.com' || domain === 'localhost') {
201
- res.set('Cache-Control', 'public, max-age=300');
202
- res.set('X-WAB-Version', WAB_VERSION);
203
- return res.json(buildSelfDiscovery());
204
- }
205
- return res.status(404).json({
206
- error: 'No WAB-enabled site found for this domain',
207
- domain,
208
- hint: 'Register your site at /dashboard to enable WAB discovery'
209
- });
210
- }
211
-
212
- const doc = buildDiscoveryDocument(site);
213
- res.set('Cache-Control', 'public, max-age=300');
214
- res.set('X-WAB-Version', WAB_VERSION);
215
- res.json(doc);
216
- } catch (err) {
217
- res.status(500).json({ error: 'Failed to generate discovery document' });
218
- }
219
- });
220
-
221
- // ═════════════════════════════════════════════════════════════════════
222
- // 2. GET /agent-bridge.json — Alternative discovery location
223
- // ═════════════════════════════════════════════════════════════════════
224
-
225
- router.get('/agent-bridge.json', (req, res) => {
226
- try {
227
- const domain = getRequestDomain(req);
228
- const site = findSiteByDomain(domain);
229
-
230
- if (!site) {
231
- if (domain === 'webagentbridge.com' || domain === 'www.webagentbridge.com' || domain === 'localhost') {
232
- res.set('Cache-Control', 'public, max-age=300');
233
- res.set('X-WAB-Version', WAB_VERSION);
234
- return res.json(buildSelfDiscovery());
235
- }
236
- return res.status(404).json({
237
- error: 'No WAB-enabled site found for this domain',
238
- domain,
239
- hint: 'Register your site at /dashboard to enable WAB discovery'
240
- });
241
- }
242
-
243
- const doc = buildDiscoveryDocument(site);
244
- res.set('Cache-Control', 'public, max-age=300');
245
- res.set('X-WAB-Version', WAB_VERSION);
246
- res.json(doc);
247
- } catch (err) {
248
- res.status(500).json({ error: 'Failed to generate discovery document' });
249
- }
250
- });
251
-
252
- // ═════════════════════════════════════════════════════════════════════
253
- // 3. GET /api/discovery/registry — Public registry with fairness scoring
254
- // (defined BEFORE :siteId to avoid route shadowing)
255
- // ═════════════════════════════════════════════════════════════════════
256
-
257
- router.get('/api/discovery/registry', (req, res) => {
258
- try {
259
- const category = req.query.category || 'all';
260
- const limit = Math.min(parseInt(req.query.limit) || 50, 200);
261
- const offset = parseInt(req.query.offset) || 0;
262
-
263
- const listings = getDirectoryListings(category, { limit, offset });
264
-
265
- const registry = listings.map(entry => ({
266
- siteId: entry.id,
267
- name: entry.name,
268
- domain: entry.domain,
269
- description: entry.description || '',
270
- category: entry.category || 'general',
271
- tier: entry.tier,
272
- neutrality_score: entry.neutrality_score || 0,
273
- is_independent: !!entry.is_independent,
274
- tags: safeParseTags(entry.tags),
275
- discovery_url: `/api/discovery/${entry.id}`
276
- }));
277
-
278
- res.json({
279
- wab_version: WAB_VERSION,
280
- total: registry.length,
281
- category,
282
- listings: registry
283
- });
284
- } catch (err) {
285
- res.status(500).json({ error: 'Failed to fetch registry' });
286
- }
287
- });
288
-
289
- // ═════════════════════════════════════════════════════════════════════
290
- // 4. POST /api/discovery/register — Register site in WAB directory
291
- // ═════════════════════════════════════════════════════════════════════
292
-
293
- router.post('/api/discovery/register', authenticateToken, (req, res) => {
294
- try {
295
- const { siteId, category, tags, is_independent, commission_rate, direct_benefit, trust_signature } = req.body;
296
-
297
- if (!siteId) {
298
- return res.status(400).json({ error: 'siteId is required' });
299
- }
300
-
301
- const site = findSiteById.get(siteId);
302
- if (!site) {
303
- return res.status(404).json({ error: 'Site not found' });
304
- }
305
- if (site.user_id !== req.user.id) {
306
- return res.status(403).json({ error: 'You do not own this site' });
307
- }
308
-
309
- const result = registerInDirectory(siteId, {
310
- category,
311
- tags,
312
- is_independent,
313
- commission_rate,
314
- direct_benefit,
315
- trust_signature
316
- });
317
-
318
- if (!result.success) {
319
- return res.status(400).json({ error: result.error });
320
- }
321
-
322
- const report = generateFairnessReport(siteId);
323
-
324
- res.status(201).json({
325
- success: true,
326
- registration: result,
327
- fairness_report: report
328
- });
329
- } catch (err) {
330
- res.status(500).json({ error: 'Failed to register site' });
331
- }
332
- });
333
-
334
- // ═════════════════════════════════════════════════════════════════════
335
- // 5. GET /api/discovery/search — Search WAB sites (fairness-weighted)
336
- // ═════════════════════════════════════════════════════════════════════
337
-
338
- router.get('/api/discovery/search', (req, res) => {
339
- try {
340
- const query = req.query.q || '';
341
- const category = req.query.category || null;
342
- const limit = Math.min(parseInt(req.query.limit) || 20, 100);
343
-
344
- let sql = `
345
- SELECT s.*, d.category, d.tags, d.is_independent, d.commission_rate,
346
- d.direct_benefit, d.neutrality_score, d.trust_signature
347
- FROM wab_directory d
348
- JOIN sites s ON d.site_id = s.id AND s.active = 1
349
- WHERE d.listed = 1
350
- `;
351
- const params = [];
352
-
353
- if (category) {
354
- sql += ' AND d.category = ?';
355
- params.push(category);
356
- }
357
-
358
- sql += ' ORDER BY d.neutrality_score DESC LIMIT ?';
359
- params.push(limit * 3);
360
-
361
- const candidates = db.prepare(sql).all(...params);
362
- const results = fairnessWeightedSearch(query, candidates).slice(0, limit);
363
-
364
- res.json({
365
- wab_version: WAB_VERSION,
366
- query,
367
- total: results.length,
368
- results: results.map(r => ({
369
- siteId: r.id,
370
- name: r.name,
371
- domain: r.domain,
372
- description: r.description || '',
373
- category: r.category || 'general',
374
- tier: r.tier,
375
- neutrality_score: r._neutralityScore,
376
- is_independent: r._isIndependent,
377
- relevance_score: r._relevance,
378
- fairness_boost: r._fairnessBoost,
379
- final_score: r._finalScore,
380
- tags: safeParseTags(r.tags),
381
- discovery_url: `/api/discovery/${r.id}`
382
- }))
383
- });
384
- } catch (err) {
385
- res.status(500).json({ error: 'Search failed' });
386
- }
387
- });
388
-
389
- // ═════════════════════════════════════════════════════════════════════
390
- // 6. GET /api/discovery/:siteId — Discovery doc for a specific site
391
- // (defined AFTER named routes to prevent shadowing)
392
- // ═════════════════════════════════════════════════════════════════════
393
-
394
- router.get('/api/discovery/:siteId', (req, res) => {
395
- try {
396
- const site = findSiteById.get(req.params.siteId);
397
- if (!site || !site.active) {
398
- return res.status(404).json({ error: 'Site not found' });
399
- }
400
-
401
- const doc = buildDiscoveryDocument(site);
402
- res.set('Cache-Control', 'public, max-age=300');
403
- res.set('X-WAB-Version', WAB_VERSION);
404
- res.json(doc);
405
- } catch (err) {
406
- res.status(500).json({ error: 'Failed to generate discovery document' });
407
- }
408
- });
409
-
410
- // ─── Utility ─────────────────────────────────────────────────────────
411
-
412
- function safeParseTags(tags) {
413
- if (Array.isArray(tags)) return tags;
414
- try { return JSON.parse(tags || '[]'); } catch (_) { return []; }
415
- }
416
-
417
- module.exports = router;
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 crypto = require('crypto');
9
+ const { findSiteById, db } = require('../models/db');
10
+ const { authenticateToken } = require('../middleware/auth');
11
+ const { safeFetch } = require('../utils/safe-fetch');
12
+ const { verify } = require('../../packages/dns-verify/src/index');
13
+ const wabCrypto = require('../services/wab-crypto');
14
+
15
+ // Fairness module is proprietary — provide stubs when not available
16
+ let calculateNeutralityScore, fairnessWeightedSearch, registerInDirectory, getDirectoryListings, generateFairnessReport;
17
+ try {
18
+ ({
19
+ calculateNeutralityScore,
20
+ fairnessWeightedSearch,
21
+ registerInDirectory,
22
+ getDirectoryListings,
23
+ generateFairnessReport
24
+ } = require('../services/fairness'));
25
+ } catch {
26
+ calculateNeutralityScore = () => ({ score: 0, label: 'unrated' });
27
+ fairnessWeightedSearch = (_q, candidates) => candidates;
28
+ registerInDirectory = () => ({});
29
+ getDirectoryListings = () => [];
30
+ generateFairnessReport = () => ({ status: 'unavailable' });
31
+ }
32
+
33
+ const WAB_VERSION = '1.3.0';
34
+
35
+ db.exec(`
36
+ CREATE TABLE IF NOT EXISTS discovery_usage_runs (
37
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
38
+ domain TEXT NOT NULL,
39
+ mode TEXT NOT NULL,
40
+ preferred_use_case TEXT,
41
+ selected_action TEXT,
42
+ readiness_ok INTEGER DEFAULT 0,
43
+ execution_attempted INTEGER DEFAULT 0,
44
+ execution_succeeded INTEGER DEFAULT 0,
45
+ value_score REAL DEFAULT 0,
46
+ end_to_end_ms INTEGER,
47
+ detail TEXT,
48
+ created_at TEXT DEFAULT (datetime('now'))
49
+ );
50
+
51
+ CREATE INDEX IF NOT EXISTS idx_discovery_usage_runs_domain_time
52
+ ON discovery_usage_runs(domain, created_at DESC);
53
+ `);
54
+
55
+ // ─── Helpers ─────────────────────────────────────────────────────────
56
+
57
+ function findSiteByDomain(domain) {
58
+ if (!domain) return null;
59
+ const normalized = domain.toLowerCase().replace(/^www\./, '');
60
+ return db.prepare(
61
+ "SELECT * FROM sites WHERE LOWER(REPLACE(domain, 'www.', '')) = ? AND active = 1 LIMIT 1"
62
+ ).get(normalized);
63
+ }
64
+
65
+ function getRequestDomain(req) {
66
+ const origin = req.get('origin');
67
+ if (origin) {
68
+ try { return new URL(origin).hostname; } catch (_) {}
69
+ }
70
+ const host = req.get('host');
71
+ if (host) return host.split(':')[0];
72
+ return req.hostname;
73
+ }
74
+
75
+ function parseSiteConfig(site) {
76
+ try { return JSON.parse(site.config || '{}'); } catch (_) { return {}; }
77
+ }
78
+
79
+ function sanitizeDomain(input) {
80
+ if (!input || typeof input !== 'string') return '';
81
+ return input
82
+ .trim()
83
+ .toLowerCase()
84
+ .replace(/^https?:\/\//, '')
85
+ .replace(/\/.*$/, '')
86
+ .replace(/:\d+$/, '')
87
+ .replace(/^www\./, '');
88
+ }
89
+
90
+ function deriveEndpointFromRecord(rawRecords, parsedRecord) {
91
+ if (parsedRecord && parsedRecord.endpoint) return parsedRecord.endpoint;
92
+ const first = (rawRecords || [])[0] || '';
93
+ const match = /endpoint=([^;\s]+)/i.exec(first);
94
+ return match ? match[1] : null;
95
+ }
96
+
97
+ function summarizeUseCase(wabDoc) {
98
+ const raw = (wabDoc && wabDoc.use_case) ||
99
+ (wabDoc && wabDoc.provider && wabDoc.provider.use_case) ||
100
+ (wabDoc && wabDoc.provider && wabDoc.provider.category) ||
101
+ '';
102
+ if (raw) return String(raw);
103
+
104
+ const commands = new Set((wabDoc && wabDoc.capabilities && wabDoc.capabilities.commands) || []);
105
+ if (commands.has('checkout')) return 'checkout';
106
+ if (commands.has('booking')) return 'booking';
107
+ if (commands.has('message') || commands.has('messaging')) return 'messaging';
108
+ if (commands.has('search')) return 'search';
109
+ if (commands.has('read') || commands.has('readContent')) return 'content-reading';
110
+ return 'general-automation';
111
+ }
112
+
113
+ function hostAllowList(domain, endpointHost) {
114
+ const list = [domain, '*.' + domain];
115
+ if (endpointHost && endpointHost !== domain) {
116
+ list.push(endpointHost);
117
+ list.push('*.' + endpointHost);
118
+ }
119
+ return Array.from(new Set(list));
120
+ }
121
+
122
+ function toBooleanState(v) {
123
+ return v ? 'yes' : 'no';
124
+ }
125
+
126
+ function buildProviderRecordTemplate(domain, endpointOverride) {
127
+ const hostFqdn = `_wab.${domain}`;
128
+ let endpoint = endpointOverride;
129
+ if (!endpoint) {
130
+ endpoint = `https://${domain}/.well-known/wab.json`;
131
+ }
132
+ return {
133
+ domain,
134
+ record: {
135
+ host: '_wab',
136
+ host_fqdn: hostFqdn,
137
+ type: 'TXT',
138
+ ttl_recommended: 3600,
139
+ value: `v=wab1; endpoint=${endpoint}`,
140
+ },
141
+ endpoint,
142
+ };
143
+ }
144
+
145
+ function buildProviderEnablePlan(domain, options = {}) {
146
+ const action = options.action === 'disable' ? 'disable' : 'enable';
147
+ const endpointOverride = options.endpointOverride || null;
148
+ const template = buildProviderRecordTemplate(domain, endpointOverride);
149
+
150
+ const enableSteps = [
151
+ {
152
+ step: 1,
153
+ title: 'Write DNS TXT record',
154
+ operation: 'dns.write_record',
155
+ payload: template.record,
156
+ },
157
+ {
158
+ step: 2,
159
+ title: 'Verify propagation (poll)',
160
+ operation: 'http.poll',
161
+ endpoint: `/api/discovery/provider/status?domain=${encodeURIComponent(domain)}`,
162
+ until: "status == 'enabled'",
163
+ interval_seconds: 20,
164
+ timeout_seconds: 1200,
165
+ },
166
+ {
167
+ step: 3,
168
+ title: 'Optional deep check',
169
+ operation: 'http.get',
170
+ endpoint: `/api/discovery/test-agent?domain=${encodeURIComponent(domain)}`,
171
+ optional: true,
172
+ }
173
+ ];
174
+
175
+ const disableSteps = [
176
+ {
177
+ step: 1,
178
+ title: 'Delete DNS TXT record',
179
+ operation: 'dns.delete_record',
180
+ payload: {
181
+ host: '_wab',
182
+ host_fqdn: `_wab.${domain}`,
183
+ type: 'TXT',
184
+ },
185
+ },
186
+ {
187
+ step: 2,
188
+ title: 'Verify disabled state (poll)',
189
+ operation: 'http.poll',
190
+ endpoint: `/api/discovery/provider/status?domain=${encodeURIComponent(domain)}`,
191
+ until: "status == 'disabled'",
192
+ interval_seconds: 20,
193
+ timeout_seconds: 1200,
194
+ }
195
+ ];
196
+
197
+ return {
198
+ domain,
199
+ action,
200
+ protocol: 'wab-dns-discovery-v1',
201
+ objective: action === 'enable'
202
+ ? 'Enable WAB DNS Discovery with one click.'
203
+ : 'Disable WAB DNS Discovery with one click.',
204
+ template,
205
+ verification: {
206
+ status: `/api/discovery/provider/status?domain=${encodeURIComponent(domain)}`,
207
+ verify_live: `/api/discovery/verify-live?domain=${encodeURIComponent(domain)}`,
208
+ test_agent: `/api/discovery/test-agent?domain=${encodeURIComponent(domain)}`,
209
+ },
210
+ rollback: {
211
+ on_enable_failure: 'Delete _wab TXT and mark state as disabled.',
212
+ on_disable_failure: 'Re-check provider DNS write propagation and retry delete.',
213
+ },
214
+ steps: action === 'enable' ? enableSteps : disableSteps,
215
+ };
216
+ }
217
+
218
+ function buildCallbackSignature(payloadText, secret) {
219
+ if (!secret) return null;
220
+ const sig = crypto.createHmac('sha256', secret).update(payloadText).digest('hex');
221
+ return `sha256=${sig}`;
222
+ }
223
+
224
+ async function deliverBatchCallback(callbackUrl, callbackSecret, payload) {
225
+ const body = JSON.stringify(payload);
226
+ const signature = buildCallbackSignature(body, callbackSecret);
227
+ const headers = {
228
+ 'content-type': 'application/json',
229
+ accept: 'application/json',
230
+ 'x-wab-event': 'provider.verify-batch.completed',
231
+ 'x-wab-request-id': payload.request_id,
232
+ };
233
+ if (signature) headers['x-wab-signature'] = signature;
234
+
235
+ const res = await safeFetch(callbackUrl, {
236
+ method: 'POST',
237
+ headers,
238
+ body,
239
+ }, {
240
+ requireHttps: true,
241
+ timeoutMs: 10000,
242
+ maxBytes: 1024 * 1024,
243
+ allowedContentTypes: ['application/json', 'text/plain', 'text/html'],
244
+ });
245
+
246
+ return {
247
+ ok: !!res.ok,
248
+ http_status: res.status,
249
+ };
250
+ }
251
+
252
+ function resolveAbsoluteUrl(origin, pathOrUrl) {
253
+ if (!pathOrUrl) return null;
254
+ try {
255
+ return new URL(pathOrUrl, origin).toString();
256
+ } catch {
257
+ return null;
258
+ }
259
+ }
260
+
261
+ function pickUsageAction(actions, preferredUseCase) {
262
+ const list = Array.isArray(actions) ? actions : [];
263
+ if (!list.length) return null;
264
+
265
+ const byUseCase = {
266
+ booking: ['booking', 'reserve', 'book', 'createBooking', 'schedule'],
267
+ messaging: ['message', 'messaging', 'sendMessage', 'contact'],
268
+ payment: ['payment', 'checkout', 'purchase', 'pay'],
269
+ checkout: ['checkout', 'purchase', 'pay', 'order'],
270
+ search: ['search', 'find', 'lookup'],
271
+ 'content-reading': ['read', 'readContent', 'extract', 'extractData'],
272
+ 'general-automation': ['click', 'navigate', 'scroll', 'readContent']
273
+ };
274
+
275
+ const preferred = byUseCase[preferredUseCase] || [];
276
+ for (const keyword of preferred) {
277
+ const hit = list.find((a) => String(a.name || '').toLowerCase().includes(keyword.toLowerCase()));
278
+ if (hit) return hit;
279
+ }
280
+
281
+ const safeFallbackOrder = ['search', 'readContent', 'read', 'click', 'scroll', 'navigate', 'fillForms'];
282
+ for (const name of safeFallbackOrder) {
283
+ const hit = list.find((a) => String(a.name || '') === name);
284
+ if (hit) return hit;
285
+ }
286
+
287
+ return list[0] || null;
288
+ }
289
+
290
+ function buildActionParams(actionName, useCase) {
291
+ const n = String(actionName || '').toLowerCase();
292
+ const uc = String(useCase || '').toLowerCase();
293
+
294
+ if (uc === 'booking' || n.includes('book') || n.includes('reserve')) {
295
+ return {
296
+ check_in: '2026-06-20',
297
+ check_out: '2026-06-22',
298
+ guests: 2,
299
+ city: 'Riyadh'
300
+ };
301
+ }
302
+ if (uc === 'messaging' || n.includes('message') || n.includes('contact')) {
303
+ return {
304
+ channel: 'support',
305
+ message: 'Hello from WAB Usage Proof test.',
306
+ subject: 'Usage proof check'
307
+ };
308
+ }
309
+ if (uc === 'payment' || uc === 'checkout' || n.includes('checkout') || n.includes('pay') || n.includes('purchase')) {
310
+ return {
311
+ amount: 10,
312
+ currency: 'USD',
313
+ reference: 'usage-proof-demo'
314
+ };
315
+ }
316
+
317
+ if (n === 'search') return { q: 'sample query' };
318
+ if (n === 'readcontent' || n === 'read') return { selector: 'body' };
319
+ if (n === 'navigate') return { url: '/' };
320
+ if (n === 'scroll') return { amount: 1 };
321
+ if (n === 'fillforms') return { fields: { email: 'usage-proof@wab.test' } };
322
+ if (n === 'click') return { selector: 'button, a' };
323
+ return { sample: true };
324
+ }
325
+
326
+ function storeUsageProofRun(domain, proof) {
327
+ try {
328
+ db.prepare(`
329
+ INSERT INTO discovery_usage_runs (
330
+ domain, mode, preferred_use_case, selected_action,
331
+ readiness_ok, execution_attempted, execution_succeeded,
332
+ value_score, end_to_end_ms, detail
333
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
334
+ `).run(
335
+ domain,
336
+ (proof.intent && proof.intent.mode) || 'readiness',
337
+ (proof.intent && proof.intent.preferred_use_case) || null,
338
+ (proof.usage_proof && proof.usage_proof.selected_action) || null,
339
+ proof.usage_proof && proof.usage_proof.readiness_ok ? 1 : 0,
340
+ proof.usage_proof && proof.usage_proof.execution_attempted ? 1 : 0,
341
+ proof.usage_proof && proof.usage_proof.execution_succeeded ? 1 : 0,
342
+ (proof.kpi && Number(proof.kpi.value_score)) || 0,
343
+ (proof.kpi && Number(proof.kpi.end_to_end_ms)) || null,
344
+ (proof.usage_proof && proof.usage_proof.detail) || null
345
+ );
346
+ } catch (_) {
347
+ // History storage is non-blocking for proof flow.
348
+ }
349
+ }
350
+
351
+ async function parseJsonSafe(res) {
352
+ return res.json().catch(() => ({}));
353
+ }
354
+
355
+ async function buildUsageProof(domain, opts = {}) {
356
+ const apiKey = (opts.apiKey || '').trim();
357
+ const preferredUseCase = (opts.preferredUseCase || '').trim().toLowerCase();
358
+ const startedAt = Date.now();
359
+
360
+ const out = {
361
+ wab_version: WAB_VERSION,
362
+ checked_at: new Date().toISOString(),
363
+ domain,
364
+ intent: {
365
+ mode: apiKey ? 'execute' : 'readiness',
366
+ preferred_use_case: preferredUseCase || null,
367
+ },
368
+ kpi: {
369
+ end_to_end_ms: null,
370
+ discovery_ms: null,
371
+ auth_ms: null,
372
+ execution_ms: null,
373
+ discovered_actions_count: 0,
374
+ business_commands_count: 0,
375
+ value_score: 0,
376
+ },
377
+ usage_proof: {
378
+ ok: false,
379
+ readiness_ok: false,
380
+ execution_attempted: false,
381
+ execution_succeeded: false,
382
+ selected_action: null,
383
+ use_case: null,
384
+ detail: null,
385
+ steps: [
386
+ { key: 'verify_core', ok: false, detail: null },
387
+ { key: 'discover_actions', ok: false, detail: null },
388
+ { key: 'authenticate_agent', ok: false, detail: null },
389
+ { key: 'execute_real_action', ok: false, detail: null },
390
+ ],
391
+ },
392
+ baseline: null,
393
+ };
394
+
395
+ const discoveryStart = Date.now();
396
+ const baseline = await buildProof(domain, { includeAgentRun: true });
397
+ out.baseline = baseline;
398
+ out.kpi.discovery_ms = Date.now() - discoveryStart;
399
+
400
+ const coreOk = !!(baseline && baseline.dns && baseline.dns.ok && baseline.wab_json && baseline.wab_json.ok);
401
+ out.usage_proof.steps[0].ok = coreOk;
402
+ out.usage_proof.steps[0].detail = coreOk ? 'DNS + wab.json verified' : 'core verification failed';
403
+ out.usage_proof.use_case = baseline && baseline.wab_json ? baseline.wab_json.use_case : null;
404
+
405
+ if (!coreOk) {
406
+ out.usage_proof.detail = 'usage proof blocked: core verification failed';
407
+ out.kpi.end_to_end_ms = Date.now() - startedAt;
408
+ return out;
409
+ }
410
+
411
+ let wabUrl;
412
+ try {
413
+ wabUrl = new URL(baseline.wab_json.url);
414
+ } catch {
415
+ out.usage_proof.detail = 'usage proof blocked: invalid wab.json URL';
416
+ out.kpi.end_to_end_ms = Date.now() - startedAt;
417
+ return out;
418
+ }
419
+ const origin = wabUrl.origin;
420
+
421
+ const discoverUrl = origin + '/api/wab/discover';
422
+ const fallbackDiscoverUrl = origin + '/agent-bridge.json';
423
+ let discoverDoc = null;
424
+ try {
425
+ const discoverRes = await safeFetch(discoverUrl, {
426
+ method: 'GET',
427
+ headers: { accept: 'application/json' },
428
+ }, {
429
+ requireHttps: true,
430
+ allowList: hostAllowList(domain, wabUrl.hostname),
431
+ timeoutMs: 8000,
432
+ maxBytes: 1024 * 1024,
433
+ allowedContentTypes: ['application/json'],
434
+ });
435
+ const discoverBody = await parseJsonSafe(discoverRes);
436
+ if (discoverRes.ok) {
437
+ discoverDoc = discoverBody && (discoverBody.result || discoverBody);
438
+ } else {
439
+ const fallbackRes = await safeFetch(fallbackDiscoverUrl, {
440
+ method: 'GET',
441
+ headers: { accept: 'application/json' },
442
+ }, {
443
+ requireHttps: true,
444
+ allowList: hostAllowList(domain, wabUrl.hostname),
445
+ timeoutMs: 8000,
446
+ maxBytes: 1024 * 1024,
447
+ allowedContentTypes: ['application/json'],
448
+ });
449
+ const fallbackBody = await parseJsonSafe(fallbackRes);
450
+ if (fallbackRes.ok) discoverDoc = fallbackBody && (fallbackBody.result || fallbackBody);
451
+ }
452
+ } catch (_) {
453
+ discoverDoc = null;
454
+ }
455
+
456
+ const actionsEndpoint = resolveAbsoluteUrl(origin,
457
+ discoverDoc && discoverDoc.endpoints && discoverDoc.endpoints.actions
458
+ ? discoverDoc.endpoints.actions
459
+ : '/api/wab/actions'
460
+ );
461
+
462
+ let actions = [];
463
+ if (actionsEndpoint) {
464
+ try {
465
+ const actionsRes = await safeFetch(actionsEndpoint, {
466
+ method: 'GET',
467
+ headers: { accept: 'application/json' },
468
+ }, {
469
+ requireHttps: true,
470
+ allowList: hostAllowList(domain, wabUrl.hostname),
471
+ timeoutMs: 8000,
472
+ maxBytes: 1024 * 1024,
473
+ allowedContentTypes: ['application/json'],
474
+ });
475
+ const actionsBody = await parseJsonSafe(actionsRes);
476
+ if (actionsRes.ok) {
477
+ const payload = actionsBody && (actionsBody.result || actionsBody);
478
+ actions = Array.isArray(payload && payload.actions) ? payload.actions : [];
479
+ }
480
+ } catch (_) {
481
+ actions = [];
482
+ }
483
+ }
484
+
485
+ out.kpi.discovered_actions_count = actions.length;
486
+ const commandSet = new Set((baseline.wab_json && baseline.wab_json.commands) || []);
487
+ const discoveredCommandCount = commandSet.size;
488
+ const businessHints = ['booking', 'checkout', 'payment', 'message', 'messaging', 'purchase'];
489
+ out.kpi.business_commands_count = businessHints.filter((k) => commandSet.has(k)).length;
490
+
491
+ out.usage_proof.steps[1].ok = actions.length > 0 || discoveredCommandCount > 0;
492
+ out.usage_proof.steps[1].detail = actions.length > 0
493
+ ? `discovered ${actions.length} executable actions`
494
+ : (discoveredCommandCount > 0
495
+ ? `discovered ${discoveredCommandCount} commands in wab.json (actions endpoint not publicly listable)`
496
+ : 'no commands or executable actions discovered');
497
+ out.usage_proof.readiness_ok = out.usage_proof.steps[1].ok;
498
+
499
+ const effectiveUseCase = preferredUseCase || out.usage_proof.use_case || 'general-automation';
500
+ const picked = pickUsageAction(actions, effectiveUseCase);
501
+ out.usage_proof.selected_action = picked ? picked.name : null;
502
+
503
+ if (!apiKey) {
504
+ out.usage_proof.detail = out.usage_proof.readiness_ok
505
+ ? 'readiness proof complete; provide api_key to run real execution proof'
506
+ : 'readiness is incomplete; provide api_key and verify commands/actions availability';
507
+ out.kpi.value_score = Math.max(0,
508
+ Math.min(100,
509
+ (out.usage_proof.readiness_ok ? 45 : 0) +
510
+ Math.min(out.kpi.discovered_actions_count * 5, 30) +
511
+ Math.min(discoveredCommandCount * 3, 20) +
512
+ Math.min(out.kpi.business_commands_count * 10, 25)
513
+ )
514
+ );
515
+ out.kpi.end_to_end_ms = Date.now() - startedAt;
516
+ return out;
517
+ }
518
+
519
+ if (!picked) {
520
+ out.usage_proof.detail = 'execution proof blocked: no action candidate found';
521
+ out.kpi.value_score = 25;
522
+ out.kpi.end_to_end_ms = Date.now() - startedAt;
523
+ return out;
524
+ }
525
+
526
+ out.usage_proof.execution_attempted = true;
527
+
528
+ const authUrl = origin + '/api/wab/authenticate';
529
+ const authStart = Date.now();
530
+ let token = null;
531
+ try {
532
+ const authRes = await safeFetch(authUrl, {
533
+ method: 'POST',
534
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
535
+ body: JSON.stringify({ apiKey, meta: { name: 'usage-proof-lab' } }),
536
+ }, {
537
+ requireHttps: true,
538
+ allowList: hostAllowList(domain, wabUrl.hostname),
539
+ timeoutMs: 8000,
540
+ maxBytes: 1024 * 1024,
541
+ allowedContentTypes: ['application/json'],
542
+ });
543
+ const authBody = await parseJsonSafe(authRes);
544
+ const payload = authBody && (authBody.result || authBody);
545
+ if (authRes.ok && payload && payload.token) {
546
+ token = payload.token;
547
+ out.usage_proof.steps[2].ok = true;
548
+ out.usage_proof.steps[2].detail = 'agent authentication succeeded';
549
+ } else {
550
+ out.usage_proof.steps[2].ok = false;
551
+ out.usage_proof.steps[2].detail = `agent authentication failed (HTTP ${authRes.status})`;
552
+ }
553
+ } catch (err) {
554
+ out.usage_proof.steps[2].ok = false;
555
+ out.usage_proof.steps[2].detail = err && err.message ? err.message : 'auth_request_failed';
556
+ }
557
+ out.kpi.auth_ms = Date.now() - authStart;
558
+
559
+ if (!token) {
560
+ out.usage_proof.detail = 'execution proof failed at auth step';
561
+ out.kpi.value_score = Math.max(10, out.usage_proof.readiness_ok ? 40 : 10);
562
+ out.kpi.end_to_end_ms = Date.now() - startedAt;
563
+ return out;
564
+ }
565
+
566
+ const execUrl = origin + '/api/wab/actions/' + encodeURIComponent(picked.name);
567
+ const execStart = Date.now();
568
+ try {
569
+ const execRes = await safeFetch(execUrl, {
570
+ method: 'POST',
571
+ headers: {
572
+ authorization: 'Bearer ' + token,
573
+ 'content-type': 'application/json',
574
+ accept: 'application/json',
575
+ },
576
+ body: JSON.stringify({
577
+ id: 'usage-proof',
578
+ params: buildActionParams(picked.name, effectiveUseCase),
579
+ }),
580
+ }, {
581
+ requireHttps: true,
582
+ allowList: hostAllowList(domain, wabUrl.hostname),
583
+ timeoutMs: 10000,
584
+ maxBytes: 1024 * 1024,
585
+ allowedContentTypes: ['application/json'],
586
+ });
587
+ const execBody = await parseJsonSafe(execRes);
588
+ if (execRes.ok) {
589
+ out.usage_proof.steps[3].ok = true;
590
+ out.usage_proof.steps[3].detail = 'real action executed successfully';
591
+ out.usage_proof.execution_succeeded = true;
592
+ out.usage_proof.detail = 'usage proof complete: real action execution succeeded';
593
+ out.usage_proof.execution_result = execBody && (execBody.result || execBody);
594
+ } else {
595
+ const errCode = execBody && execBody.error && execBody.error.code;
596
+ if (errCode === 'HUMAN_GATE_REQUIRED' || errCode === 'HUMAN_GATE_PENDING' || errCode === 'INTENT_BLOCKED') {
597
+ out.usage_proof.steps[3].ok = true;
598
+ out.usage_proof.steps[3].detail = `execution reached policy gate (${errCode})`;
599
+ out.usage_proof.execution_succeeded = false;
600
+ out.usage_proof.detail = 'execution reached a real policy gate; operational flow is active';
601
+ } else {
602
+ out.usage_proof.steps[3].ok = false;
603
+ out.usage_proof.steps[3].detail = `execution failed (HTTP ${execRes.status})`;
604
+ out.usage_proof.execution_succeeded = false;
605
+ out.usage_proof.detail = 'execution proof failed';
606
+ }
607
+ out.usage_proof.execution_result = execBody;
608
+ }
609
+ } catch (err) {
610
+ out.usage_proof.steps[3].ok = false;
611
+ out.usage_proof.steps[3].detail = err && err.message ? err.message : 'execution_request_failed';
612
+ out.usage_proof.detail = 'execution request failed';
613
+ }
614
+ out.kpi.execution_ms = Date.now() - execStart;
615
+
616
+ out.usage_proof.ok = out.usage_proof.steps[0].ok && out.usage_proof.steps[1].ok && out.usage_proof.steps[2].ok && out.usage_proof.steps[3].ok;
617
+ out.kpi.value_score = Math.max(0,
618
+ Math.min(100,
619
+ (out.usage_proof.steps[0].ok ? 20 : 0) +
620
+ (out.usage_proof.steps[1].ok ? 20 : 0) +
621
+ (out.usage_proof.steps[2].ok ? 20 : 0) +
622
+ (out.usage_proof.steps[3].ok ? 30 : 0) +
623
+ Math.min(out.kpi.business_commands_count * 5, 10)
624
+ )
625
+ );
626
+ out.kpi.end_to_end_ms = Date.now() - startedAt;
627
+ return out;
628
+ }
629
+
630
+ async function buildProof(domain, opts = {}) {
631
+ const includeAgentRun = opts.includeAgentRun === true;
632
+ const out = {
633
+ wab_version: WAB_VERSION,
634
+ checked_at: new Date().toISOString(),
635
+ domain,
636
+ three_steps: [
637
+ 'Add TXT record at _wab.<domain>',
638
+ 'Serve /.well-known/wab.json',
639
+ 'Agent discovers and runs a test call',
640
+ ],
641
+ dns: {
642
+ fqdn: `_wab.${domain}`,
643
+ ok: false,
644
+ ad: false,
645
+ records: [],
646
+ parsed: null,
647
+ error: null,
648
+ },
649
+ wab_json: {
650
+ url: null,
651
+ ok: false,
652
+ http_status: null,
653
+ provider: null,
654
+ commands: [],
655
+ use_case: null,
656
+ error: null,
657
+ },
658
+ execution_proof: {
659
+ attempted: includeAgentRun,
660
+ ok: false,
661
+ steps: [
662
+ { key: 'discover_dns', ok: false, detail: null },
663
+ { key: 'fetch_wab_json', ok: false, detail: null },
664
+ { key: 'agent_discover_call', ok: false, detail: null },
665
+ { key: 'agent_ping_call', ok: false, detail: null },
666
+ ],
667
+ result: null,
668
+ error: null,
669
+ },
670
+ statuses: {
671
+ registered: 'no',
672
+ dns_verified: 'no',
673
+ agent_ready: 'no',
674
+ production: 'no',
675
+ },
676
+ };
677
+
678
+ // Internal registration is informative only. DNS + wab.json remain sufficient.
679
+ const internalSite = findSiteByDomain(domain);
680
+ if (internalSite) {
681
+ const cfg = parseSiteConfig(internalSite);
682
+ out.statuses.registered = 'yes';
683
+ out.statuses.production = toBooleanState((cfg.environment || 'production') === 'production');
684
+ }
685
+
686
+ const proof = await verify(domain, { timeoutMs: 6000 }).catch((err) => ({
687
+ ok: false,
688
+ records: [{
689
+ type: '_wab',
690
+ ad: false,
691
+ raw: [],
692
+ parsed: null,
693
+ error: err && err.message ? err.message : 'verify_failed',
694
+ code: err && err.code,
695
+ }],
696
+ }));
697
+
698
+ const wabRecord = (proof.records || []).find((r) => r.type === '_wab') || {};
699
+ out.dns.ok = !!wabRecord.ok;
700
+ out.dns.ad = !!wabRecord.ad;
701
+ out.dns.records = wabRecord.raw || [];
702
+ out.dns.parsed = wabRecord.parsed || null;
703
+ out.dns.error = wabRecord.error || null;
704
+ out.execution_proof.steps[0].ok = out.dns.ok;
705
+ out.execution_proof.steps[0].detail = out.dns.ok ? 'valid _wab TXT record' : (out.dns.error || 'missing _wab record');
706
+ out.statuses.dns_verified = toBooleanState(out.dns.ok);
707
+
708
+ const endpoint = deriveEndpointFromRecord(out.dns.records, out.dns.parsed);
709
+ out.wab_json.url = endpoint;
710
+
711
+ if (!endpoint) {
712
+ out.wab_json.error = 'endpoint missing in _wab record';
713
+ out.execution_proof.error = out.wab_json.error;
714
+ return out;
715
+ }
716
+
717
+ let endpointUrl;
718
+ try {
719
+ endpointUrl = new URL(endpoint);
720
+ } catch {
721
+ out.wab_json.error = 'invalid endpoint URL';
722
+ out.execution_proof.error = out.wab_json.error;
723
+ return out;
724
+ }
725
+
726
+ try {
727
+ const wabRes = await safeFetch(endpointUrl.toString(), {
728
+ method: 'GET',
729
+ headers: { accept: 'application/json' },
730
+ }, {
731
+ requireHttps: true,
732
+ allowList: hostAllowList(domain, endpointUrl.hostname),
733
+ timeoutMs: 8000,
734
+ maxBytes: 1024 * 1024,
735
+ allowedContentTypes: ['application/json', 'application/ld+json', 'text/plain'],
736
+ });
737
+ out.wab_json.http_status = wabRes.status;
738
+ const doc = await wabRes.json();
739
+ out.wab_json.ok = wabRes.ok && doc && typeof doc === 'object';
740
+ out.wab_json.provider = doc && doc.provider ? {
741
+ name: doc.provider.name || null,
742
+ domain: doc.provider.domain || null,
743
+ category: doc.provider.category || null,
744
+ } : null;
745
+ out.wab_json.commands = (doc && doc.capabilities && doc.capabilities.commands) || [];
746
+ out.wab_json.use_case = summarizeUseCase(doc);
747
+ out.execution_proof.steps[1].ok = out.wab_json.ok;
748
+ out.execution_proof.steps[1].detail = out.wab_json.ok ? 'wab.json fetched and parsed' : 'wab.json invalid';
749
+ out.statuses.agent_ready = toBooleanState(out.wab_json.ok && out.wab_json.commands.length > 0);
750
+
751
+ if (!includeAgentRun) return out;
752
+
753
+ const endpointOrigin = endpointUrl.origin;
754
+ const discoverUrl = endpointOrigin + '/api/wab/discover';
755
+ const fallbackDiscoverUrl = endpointOrigin + '/agent-bridge.json';
756
+ const pingUrl = endpointOrigin + '/api/wab/ping';
757
+
758
+ try {
759
+ const discoverRes = await safeFetch(discoverUrl, {
760
+ method: 'GET',
761
+ headers: { accept: 'application/json' },
762
+ }, {
763
+ requireHttps: true,
764
+ allowList: hostAllowList(domain, endpointUrl.hostname),
765
+ timeoutMs: 8000,
766
+ maxBytes: 1024 * 1024,
767
+ allowedContentTypes: ['application/json'],
768
+ });
769
+ let discoverBody = await discoverRes.json().catch(() => ({}));
770
+ if (discoverRes.ok) {
771
+ out.execution_proof.steps[2].ok = true;
772
+ out.execution_proof.steps[2].detail = 'GET /api/wab/discover succeeded';
773
+ } else {
774
+ // Fallback: some sites expose discovery via agent-bridge.json only.
775
+ const fallbackRes = await safeFetch(fallbackDiscoverUrl, {
776
+ method: 'GET',
777
+ headers: { accept: 'application/json' },
778
+ }, {
779
+ requireHttps: true,
780
+ allowList: hostAllowList(domain, endpointUrl.hostname),
781
+ timeoutMs: 8000,
782
+ maxBytes: 1024 * 1024,
783
+ allowedContentTypes: ['application/json'],
784
+ });
785
+ const fallbackBody = await fallbackRes.json().catch(() => ({}));
786
+ if (fallbackRes.ok) {
787
+ out.execution_proof.steps[2].ok = true;
788
+ out.execution_proof.steps[2].detail =
789
+ `GET /api/wab/discover returned HTTP ${discoverRes.status}; fallback /agent-bridge.json succeeded`;
790
+ discoverBody = fallbackBody;
791
+ } else {
792
+ out.execution_proof.steps[2].ok = false;
793
+ out.execution_proof.steps[2].detail =
794
+ `discover HTTP ${discoverRes.status}; fallback /agent-bridge.json HTTP ${fallbackRes.status}`;
795
+ }
796
+ }
797
+
798
+ const pingRes = await safeFetch(pingUrl, {
799
+ method: 'GET',
800
+ headers: { accept: 'application/json' },
801
+ }, {
802
+ requireHttps: true,
803
+ allowList: hostAllowList(domain, endpointUrl.hostname),
804
+ timeoutMs: 8000,
805
+ maxBytes: 512 * 1024,
806
+ allowedContentTypes: ['application/json'],
807
+ });
808
+ const pingBody = await pingRes.json().catch(() => ({}));
809
+ out.execution_proof.steps[3].ok = !!pingRes.ok;
810
+ out.execution_proof.steps[3].detail = pingRes.ok ? 'GET /api/wab/ping succeeded' : ('HTTP ' + pingRes.status);
811
+ // `agent_discover_call` can fail on sites that expose discovery only via
812
+ // wab.json but not /api/wab/discover. Treat it as best-effort so the
813
+ // core proof remains: DNS -> wab.json -> agent call result.
814
+ out.execution_proof.ok =
815
+ out.execution_proof.steps[0].ok &&
816
+ out.execution_proof.steps[1].ok &&
817
+ out.execution_proof.steps[3].ok;
818
+ out.execution_proof.result = {
819
+ discovered: discoverBody && (discoverBody.result || discoverBody),
820
+ ping: pingBody && (pingBody.result || pingBody),
821
+ };
822
+ } catch (err) {
823
+ out.execution_proof.error = err && err.message ? err.message : 'agent_test_failed';
824
+ }
825
+ } catch (err) {
826
+ out.wab_json.error = err && err.message ? err.message : 'wab_fetch_failed';
827
+ out.execution_proof.error = out.wab_json.error;
828
+ }
829
+
830
+ return out;
831
+ }
832
+
833
+ function buildDiscoveryDocument(site) {
834
+ const config = parseSiteConfig(site);
835
+ const perms = config.agentPermissions || {};
836
+ const restrictions = config.restrictions || {};
837
+ const features = config.features || {};
838
+
839
+ const enabledActions = Object.entries(perms)
840
+ .filter(([, v]) => v)
841
+ .map(([k]) => k);
842
+
843
+ const featureList = ['auto_discovery', 'noscript_fallback'];
844
+ if (features.advancedAnalytics) featureList.push('advanced_analytics');
845
+ if (features.realTimeUpdates) featureList.push('real_time_updates');
846
+ if (perms.apiAccess) featureList.push('api_access');
847
+
848
+ const dirEntry = db.prepare('SELECT * FROM wab_directory WHERE site_id = ?').get(site.id);
849
+ const neutralityScore = calculateNeutralityScore(site);
850
+
851
+ return {
852
+ wab_version: WAB_VERSION,
853
+ generated_at: new Date().toISOString(),
854
+ provider: {
855
+ name: site.name,
856
+ domain: site.domain,
857
+ category: (dirEntry && dirEntry.category) || config.category || 'general',
858
+ description: site.description || ''
859
+ },
860
+ capabilities: {
861
+ commands: enabledActions,
862
+ permissions: perms,
863
+ tier: site.tier,
864
+ transport: ['js_global', 'http', 'websocket'],
865
+ features: featureList
866
+ },
867
+ agent_access: {
868
+ bridge_script: '/script/ai-agent-bridge.js',
869
+ api_base: '/api/wab',
870
+ websocket: '/ws/analytics',
871
+ noscript: `/api/noscript/bridge/${site.id}`,
872
+ discovery: `/api/discovery/${site.id}`
873
+ },
874
+ fairness: {
875
+ is_independent: dirEntry ? !!dirEntry.is_independent : false,
876
+ commission_rate: dirEntry ? dirEntry.commission_rate : 0,
877
+ direct_benefit: dirEntry ? (dirEntry.direct_benefit || '') : '',
878
+ neutrality_score: neutralityScore
879
+ },
880
+ security: {
881
+ session_required: true,
882
+ origin_validation: true,
883
+ rate_limit: (restrictions.rateLimit && restrictions.rateLimit.maxCallsPerMinute) || 60,
884
+ sandbox: true
885
+ },
886
+ endpoints: {
887
+ authenticate: '/api/wab/authenticate',
888
+ discover: `/api/wab/discover?siteId=${site.id}`,
889
+ actions: `/api/wab/actions?siteId=${site.id}`,
890
+ execute: '/api/wab/actions/{actionName}',
891
+ read: '/api/wab/read',
892
+ page_info: `/api/wab/page-info?siteId=${site.id}`,
893
+ search: '/api/wab/search',
894
+ ping: '/api/wab/ping',
895
+ token_exchange: '/api/license/token',
896
+ bridge_page: `/api/noscript/bridge/${site.id}`
897
+ }
898
+ };
899
+ }
900
+
901
+ // ═════════════════════════════════════════════════════════════════════
902
+ // 1. GET /.well-known/wab.json — Standard discovery location
903
+ // ═════════════════════════════════════════════════════════════════════
904
+
905
+ function buildSelfDiscovery() {
906
+ return {
907
+ wab_version: WAB_VERSION,
908
+ protocol: '1.0',
909
+ generated_at: new Date().toISOString(),
910
+ provider: {
911
+ name: 'Web Agent Bridge',
912
+ domain: 'webagentbridge.com',
913
+ category: 'developer-tools',
914
+ description: 'Open protocol and runtime for AI agent ↔ website interaction. The OpenAPI for human-facing web pages.'
915
+ },
916
+ capabilities: {
917
+ commands: ['read', 'navigate', 'search', 'discover'],
918
+ permissions: { readContent: true, navigate: true, apiAccess: true },
919
+ tier: 'platform',
920
+ transport: ['http', 'javascript', 'websocket'],
921
+ features: [
922
+ 'discovery_protocol', 'fairness_engine', 'mcp_adapter',
923
+ 'noscript_fallback', 'agent_sdk', 'wordpress_plugin',
924
+ 'openapi_spec', 'llms_txt', 'atom_feed'
925
+ ]
926
+ },
927
+ agent_access: {
928
+ bridge_script: '/script/ai-agent-bridge.js',
929
+ api_base: '/api/wab',
930
+ websocket: '/ws/analytics',
931
+ discovery: '/agent-bridge.json',
932
+ llms_txt: '/llms.txt',
933
+ llms_full_txt: '/llms-full.txt',
934
+ openapi: '/openapi.json',
935
+ ai_assets: '/.well-known/ai-assets.json',
936
+ atom_feed: '/feed.xml',
937
+ sitemap: '/sitemap.xml'
938
+ },
939
+ fairness: {
940
+ is_independent: true,
941
+ commission_rate: 0,
942
+ direct_benefit: 'Open-source protocol maintainer',
943
+ neutrality_score: 100
944
+ },
945
+ security: {
946
+ session_required: false,
947
+ origin_validation: true,
948
+ rate_limit: 60,
949
+ sandbox: true
950
+ },
951
+ endpoints: {
952
+ discover: '/api/wab/discover',
953
+ actions: '/api/wab/actions',
954
+ execute: '/api/wab/execute',
955
+ ping: '/api/wab/ping',
956
+ search: '/api/wab/search',
957
+ registry: '/api/discovery/registry',
958
+ plans: '/api/plans',
959
+ page_info: '/api/wab/page-info'
960
+ },
961
+ ecosystem: {
962
+ npm: 'https://www.npmjs.com/package/web-agent-bridge',
963
+ github: 'https://github.com/abokenan444/web-agent-bridge',
964
+ security: 'https://socket.dev/npm/package/web-agent-bridge',
965
+ mcp_adapter: 'https://www.npmjs.com/package/wab-mcp-adapter',
966
+ wordpress: 'https://github.com/abokenan444/web-agent-bridge/tree/master/web-agent-bridge-wordpress',
967
+ specification: 'https://github.com/abokenan444/web-agent-bridge/blob/master/docs/SPEC.md'
968
+ }
969
+ };
970
+ }
971
+
972
+ router.get('/.well-known/wab.json', (req, res) => {
973
+ try {
974
+ const domain = getRequestDomain(req);
975
+ const site = findSiteByDomain(domain);
976
+
977
+ if (!site) {
978
+ if (domain === 'webagentbridge.com' || domain === 'www.webagentbridge.com' || domain === 'localhost') {
979
+ res.set('Cache-Control', 'public, max-age=300');
980
+ res.set('X-WAB-Version', WAB_VERSION);
981
+ return res.json(buildSelfDiscovery());
982
+ }
983
+ return res.status(404).json({
984
+ error: 'No WAB-enabled site found for this domain',
985
+ domain,
986
+ hint: 'Register your site at /dashboard to enable WAB discovery'
987
+ });
988
+ }
989
+
990
+ const doc = buildDiscoveryDocument(site);
991
+ res.set('Cache-Control', 'public, max-age=300');
992
+ res.set('X-WAB-Version', WAB_VERSION);
993
+ res.json(doc);
994
+ } catch (err) {
995
+ res.status(500).json({ error: 'Failed to generate discovery document' });
996
+ }
997
+ });
998
+
999
+ // ═════════════════════════════════════════════════════════════════════
1000
+ // 2. GET /agent-bridge.json — Alternative discovery location
1001
+ // ═════════════════════════════════════════════════════════════════════
1002
+
1003
+ router.get('/agent-bridge.json', (req, res) => {
1004
+ try {
1005
+ const domain = getRequestDomain(req);
1006
+ const site = findSiteByDomain(domain);
1007
+
1008
+ if (!site) {
1009
+ if (domain === 'webagentbridge.com' || domain === 'www.webagentbridge.com' || domain === 'localhost') {
1010
+ res.set('Cache-Control', 'public, max-age=300');
1011
+ res.set('X-WAB-Version', WAB_VERSION);
1012
+ return res.json(buildSelfDiscovery());
1013
+ }
1014
+ return res.status(404).json({
1015
+ error: 'No WAB-enabled site found for this domain',
1016
+ domain,
1017
+ hint: 'Register your site at /dashboard to enable WAB discovery'
1018
+ });
1019
+ }
1020
+
1021
+ const doc = buildDiscoveryDocument(site);
1022
+ res.set('Cache-Control', 'public, max-age=300');
1023
+ res.set('X-WAB-Version', WAB_VERSION);
1024
+ res.json(doc);
1025
+ } catch (err) {
1026
+ res.status(500).json({ error: 'Failed to generate discovery document' });
1027
+ }
1028
+ });
1029
+
1030
+ // ═════════════════════════════════════════════════════════════════════
1031
+ // 3. GET /api/discovery/registry — Public registry with fairness scoring
1032
+ // (defined BEFORE :siteId to avoid route shadowing)
1033
+ // ═════════════════════════════════════════════════════════════════════
1034
+
1035
+ router.get('/api/discovery/registry', (req, res) => {
1036
+ try {
1037
+ const category = req.query.category || 'all';
1038
+ const limit = Math.min(parseInt(req.query.limit) || 50, 200);
1039
+ const offset = parseInt(req.query.offset) || 0;
1040
+
1041
+ const listings = getDirectoryListings(category, { limit, offset });
1042
+
1043
+ const registry = listings.map(entry => ({
1044
+ siteId: entry.id,
1045
+ name: entry.name,
1046
+ domain: entry.domain,
1047
+ description: entry.description || '',
1048
+ category: entry.category || 'general',
1049
+ tier: entry.tier,
1050
+ neutrality_score: entry.neutrality_score || 0,
1051
+ is_independent: !!entry.is_independent,
1052
+ tags: safeParseTags(entry.tags),
1053
+ discovery_url: `/api/discovery/${entry.id}`
1054
+ }));
1055
+
1056
+ res.json({
1057
+ wab_version: WAB_VERSION,
1058
+ total: registry.length,
1059
+ category,
1060
+ listings: registry
1061
+ });
1062
+ } catch (err) {
1063
+ res.status(500).json({ error: 'Failed to fetch registry' });
1064
+ }
1065
+ });
1066
+
1067
+ // ═════════════════════════════════════════════════════════════════════
1068
+ // 4. POST /api/discovery/register — Register site in WAB directory
1069
+ // ═════════════════════════════════════════════════════════════════════
1070
+
1071
+ router.post('/api/discovery/register', authenticateToken, (req, res) => {
1072
+ try {
1073
+ const { siteId, category, tags, is_independent, commission_rate, direct_benefit, trust_signature } = req.body;
1074
+
1075
+ if (!siteId) {
1076
+ return res.status(400).json({ error: 'siteId is required' });
1077
+ }
1078
+
1079
+ const site = findSiteById.get(siteId);
1080
+ if (!site) {
1081
+ return res.status(404).json({ error: 'Site not found' });
1082
+ }
1083
+ if (site.user_id !== req.user.id) {
1084
+ return res.status(403).json({ error: 'You do not own this site' });
1085
+ }
1086
+
1087
+ const result = registerInDirectory(siteId, {
1088
+ category,
1089
+ tags,
1090
+ is_independent,
1091
+ commission_rate,
1092
+ direct_benefit,
1093
+ trust_signature
1094
+ });
1095
+
1096
+ if (!result.success) {
1097
+ return res.status(400).json({ error: result.error });
1098
+ }
1099
+
1100
+ const report = generateFairnessReport(siteId);
1101
+
1102
+ res.status(201).json({
1103
+ success: true,
1104
+ registration: result,
1105
+ fairness_report: report
1106
+ });
1107
+ } catch (err) {
1108
+ res.status(500).json({ error: 'Failed to register site' });
1109
+ }
1110
+ });
1111
+
1112
+ // ═════════════════════════════════════════════════════════════════════
1113
+ // 5. GET /api/discovery/search — Search WAB sites (fairness-weighted)
1114
+ // ═════════════════════════════════════════════════════════════════════
1115
+
1116
+ router.get('/api/discovery/search', (req, res) => {
1117
+ try {
1118
+ const query = req.query.q || '';
1119
+ const category = req.query.category || null;
1120
+ const limit = Math.min(parseInt(req.query.limit) || 20, 100);
1121
+
1122
+ let sql = `
1123
+ SELECT s.*, d.category, d.tags, d.is_independent, d.commission_rate,
1124
+ d.direct_benefit, d.neutrality_score, d.trust_signature
1125
+ FROM wab_directory d
1126
+ JOIN sites s ON d.site_id = s.id AND s.active = 1
1127
+ WHERE d.listed = 1
1128
+ `;
1129
+ const params = [];
1130
+
1131
+ if (category) {
1132
+ sql += ' AND d.category = ?';
1133
+ params.push(category);
1134
+ }
1135
+
1136
+ sql += ' ORDER BY d.neutrality_score DESC LIMIT ?';
1137
+ params.push(limit * 3);
1138
+
1139
+ const candidates = db.prepare(sql).all(...params);
1140
+ const results = fairnessWeightedSearch(query, candidates).slice(0, limit);
1141
+
1142
+ res.json({
1143
+ wab_version: WAB_VERSION,
1144
+ query,
1145
+ total: results.length,
1146
+ results: results.map(r => ({
1147
+ siteId: r.id,
1148
+ name: r.name,
1149
+ domain: r.domain,
1150
+ description: r.description || '',
1151
+ category: r.category || 'general',
1152
+ tier: r.tier,
1153
+ neutrality_score: r._neutralityScore,
1154
+ is_independent: r._isIndependent,
1155
+ relevance_score: r._relevance,
1156
+ fairness_boost: r._fairnessBoost,
1157
+ final_score: r._finalScore,
1158
+ tags: safeParseTags(r.tags),
1159
+ discovery_url: `/api/discovery/${r.id}`
1160
+ }))
1161
+ });
1162
+ } catch (err) {
1163
+ res.status(500).json({ error: 'Search failed' });
1164
+ }
1165
+ });
1166
+
1167
+ // ═════════════════════════════════════════════════════════════════════
1168
+ // 6. GET /api/discovery/verify-live?domain=example.com
1169
+ // Verifiable proof: DNS TXT + wab.json + explicit status model.
1170
+ // ═════════════════════════════════════════════════════════════════════
1171
+
1172
+ router.get('/api/discovery/verify-live', async (req, res) => {
1173
+ const domain = sanitizeDomain(req.query.domain || '');
1174
+ if (!domain) {
1175
+ return res.status(400).json({
1176
+ error: 'domain is required',
1177
+ hint: 'Use /api/discovery/verify-live?domain=example.com',
1178
+ });
1179
+ }
1180
+ try {
1181
+ const proof = await buildProof(domain, { includeAgentRun: false });
1182
+ return res.json(proof);
1183
+ } catch (err) {
1184
+ return res.status(500).json({ error: 'verify_live_failed', details: err.message });
1185
+ }
1186
+ });
1187
+
1188
+ // ═════════════════════════════════════════════════════════════════════
1189
+ // 7. GET /api/discovery/test-agent?domain=example.com
1190
+ // Execution proof: discover → ping (official consumer path).
1191
+ // ═════════════════════════════════════════════════════════════════════
1192
+
1193
+ router.get('/api/discovery/test-agent', async (req, res) => {
1194
+ const domain = sanitizeDomain(req.query.domain || '');
1195
+ if (!domain) {
1196
+ return res.status(400).json({
1197
+ error: 'domain is required',
1198
+ hint: 'Use /api/discovery/test-agent?domain=example.com',
1199
+ });
1200
+ }
1201
+ try {
1202
+ const proof = await buildProof(domain, { includeAgentRun: true });
1203
+ if (!proof.execution_proof.ok) {
1204
+ return res.status(200).json({
1205
+ ...proof,
1206
+ warning: 'agent flow did not fully pass; inspect execution_proof.steps',
1207
+ });
1208
+ }
1209
+ return res.json(proof);
1210
+ } catch (err) {
1211
+ return res.status(500).json({ error: 'agent_test_failed', details: err.message });
1212
+ }
1213
+ });
1214
+
1215
+ // ═════════════════════════════════════════════════════════════════════
1216
+ // 8. GET /api/discovery/provider/manifest
1217
+ // Provider-facing protocol contract for one-click DNS toggles.
1218
+ // ═════════════════════════════════════════════════════════════════════
1219
+
1220
+ router.get('/api/discovery/provider/manifest', (_req, res) => {
1221
+ return res.json({
1222
+ wab_version: WAB_VERSION,
1223
+ protocol: 'wab-dns-discovery-v1',
1224
+ txt_record: {
1225
+ host: '_wab',
1226
+ type: 'TXT',
1227
+ format: 'v=wab1; endpoint=https://<domain>/.well-known/wab.json',
1228
+ required_keys: ['v', 'endpoint'],
1229
+ constraints: {
1230
+ endpoint_scheme: 'https',
1231
+ endpoint_path_recommended: '/.well-known/wab.json',
1232
+ }
1233
+ },
1234
+ toggle_flow: {
1235
+ enable: [
1236
+ 'Create TXT _wab.<domain>',
1237
+ 'Verify DNS + endpoint',
1238
+ 'Show DNS verified / Agent-ready status'
1239
+ ],
1240
+ disable: [
1241
+ 'Delete TXT _wab.<domain>',
1242
+ 'Verify disabled state',
1243
+ 'Show disabled status'
1244
+ ]
1245
+ },
1246
+ verification_endpoints: {
1247
+ verify_live: '/api/discovery/verify-live?domain=<domain>',
1248
+ test_agent: '/api/discovery/test-agent?domain=<domain>',
1249
+ provider_status: '/api/discovery/provider/status?domain=<domain>',
1250
+ provider_verify_batch: '/api/discovery/provider/verify-batch',
1251
+ provider_record_template: '/api/discovery/provider/record-template?domain=<domain>'
1252
+ },
1253
+ callback_contract: {
1254
+ event: 'provider.verify-batch.completed',
1255
+ header_request_id: 'x-wab-request-id',
1256
+ header_signature: 'x-wab-signature (optional, sha256 HMAC when callback_secret is provided)',
1257
+ },
1258
+ examples: {
1259
+ txt_value: 'v=wab1; endpoint=https://example.com/.well-known/wab.json',
1260
+ status_call: '/api/discovery/provider/status?domain=example.com',
1261
+ template_call: '/api/discovery/provider/record-template?domain=example.com',
1262
+ batch_body: {
1263
+ domains: ['example.com', 'shop.example.com'],
1264
+ include_agent_run: false,
1265
+ callback_url: 'https://provider.example/webhooks/wab-discovery',
1266
+ callback_secret: 'optional-shared-secret'
1267
+ }
1268
+ }
1269
+ });
1270
+ });
1271
+
1272
+ router.get('/api/discovery/provider/record-template', (req, res) => {
1273
+ const domain = sanitizeDomain(req.query.domain || '');
1274
+ const endpointOverride = String(req.query.endpoint || '').trim();
1275
+ if (!domain) {
1276
+ return res.status(400).json({
1277
+ error: 'domain is required',
1278
+ hint: 'Use /api/discovery/provider/record-template?domain=example.com',
1279
+ });
1280
+ }
1281
+
1282
+ if (endpointOverride) {
1283
+ let parsed;
1284
+ try {
1285
+ parsed = new URL(endpointOverride);
1286
+ } catch {
1287
+ return res.status(400).json({ error: 'invalid endpoint URL' });
1288
+ }
1289
+ if (parsed.protocol !== 'https:') {
1290
+ return res.status(400).json({ error: 'endpoint must use https' });
1291
+ }
1292
+ }
1293
+
1294
+ const template = buildProviderRecordTemplate(domain, endpointOverride || null);
1295
+ return res.json({
1296
+ wab_version: WAB_VERSION,
1297
+ protocol: 'wab-dns-discovery-v1',
1298
+ ...template,
1299
+ verify_urls: {
1300
+ provider_status: `/api/discovery/provider/status?domain=${encodeURIComponent(domain)}`,
1301
+ verify_live: `/api/discovery/verify-live?domain=${encodeURIComponent(domain)}`,
1302
+ test_agent: `/api/discovery/test-agent?domain=${encodeURIComponent(domain)}`,
1303
+ }
1304
+ });
1305
+ });
1306
+
1307
+ router.get('/api/discovery/provider/enable-plan', (req, res) => {
1308
+ const domain = sanitizeDomain(req.query.domain || '');
1309
+ const action = String(req.query.action || 'enable').toLowerCase();
1310
+ const endpointOverride = String(req.query.endpoint || '').trim();
1311
+
1312
+ if (!domain) {
1313
+ return res.status(400).json({
1314
+ error: 'domain is required',
1315
+ hint: 'Use /api/discovery/provider/enable-plan?domain=example.com&action=enable',
1316
+ });
1317
+ }
1318
+ if (action !== 'enable' && action !== 'disable') {
1319
+ return res.status(400).json({ error: 'action must be enable or disable' });
1320
+ }
1321
+ if (endpointOverride) {
1322
+ let parsed;
1323
+ try {
1324
+ parsed = new URL(endpointOverride);
1325
+ } catch {
1326
+ return res.status(400).json({ error: 'invalid endpoint URL' });
1327
+ }
1328
+ if (parsed.protocol !== 'https:') {
1329
+ return res.status(400).json({ error: 'endpoint must use https' });
1330
+ }
1331
+ }
1332
+
1333
+ const plan = buildProviderEnablePlan(domain, {
1334
+ action,
1335
+ endpointOverride: endpointOverride || null,
1336
+ });
1337
+
1338
+ return res.json({
1339
+ wab_version: WAB_VERSION,
1340
+ request: {
1341
+ domain,
1342
+ action,
1343
+ },
1344
+ plan,
1345
+ });
1346
+ });
1347
+
1348
+ // ═════════════════════════════════════════════════════════════════════
1349
+ // 9. GET /api/discovery/provider/status?domain=example.com
1350
+ // Machine-friendly status for registrar/provider toggles.
1351
+ // ═════════════════════════════════════════════════════════════════════
1352
+
1353
+ router.get('/api/discovery/provider/status', async (req, res) => {
1354
+ const domain = sanitizeDomain(req.query.domain || '');
1355
+ if (!domain) {
1356
+ return res.status(400).json({
1357
+ error: 'domain is required',
1358
+ hint: 'Use /api/discovery/provider/status?domain=example.com'
1359
+ });
1360
+ }
1361
+
1362
+ try {
1363
+ const proof = await buildProof(domain, { includeAgentRun: false });
1364
+ const dnsVerified = !!(proof.dns && proof.dns.ok);
1365
+ const endpointReady = !!(proof.wab_json && proof.wab_json.ok);
1366
+ const status = dnsVerified && endpointReady ? 'enabled' : (dnsVerified ? 'partial' : 'disabled');
1367
+
1368
+ return res.json({
1369
+ wab_version: WAB_VERSION,
1370
+ domain,
1371
+ status,
1372
+ flags: {
1373
+ dns_verified: dnsVerified,
1374
+ endpoint_ready: endpointReady,
1375
+ agent_ready: proof.statuses && proof.statuses.agent_ready === 'yes'
1376
+ },
1377
+ diagnostics: {
1378
+ dns_error: proof.dns && proof.dns.error,
1379
+ endpoint_error: proof.wab_json && proof.wab_json.error
1380
+ },
1381
+ checked_at: proof.checked_at
1382
+ });
1383
+ } catch (err) {
1384
+ return res.status(500).json({ error: 'provider_status_failed', details: err.message });
1385
+ }
1386
+ });
1387
+
1388
+ // ═════════════════════════════════════════════════════════════════════
1389
+ // 10. POST /api/discovery/provider/verify-batch
1390
+ // Batch verification for DNS providers and registrars.
1391
+ // ═════════════════════════════════════════════════════════════════════
1392
+
1393
+ router.post('/api/discovery/provider/verify-batch', async (req, res) => {
1394
+ const domainsRaw = Array.isArray(req.body && req.body.domains) ? req.body.domains : [];
1395
+ const includeAgentRun = !!(req.body && req.body.include_agent_run);
1396
+ const callbackUrl = String((req.body && req.body.callback_url) || '').trim();
1397
+ const callbackSecret = String((req.body && req.body.callback_secret) || '').trim();
1398
+ const domains = domainsRaw.map((d) => sanitizeDomain(d)).filter(Boolean);
1399
+ const requestId = `pvb_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
1400
+
1401
+ if (!domains.length) {
1402
+ return res.status(400).json({
1403
+ error: 'domains[] is required',
1404
+ hint: 'Use {"domains":["example.com"],"include_agent_run":false}'
1405
+ });
1406
+ }
1407
+ if (domains.length > 50) {
1408
+ return res.status(400).json({ error: 'max 50 domains per request' });
1409
+ }
1410
+
1411
+ try {
1412
+ const results = [];
1413
+ for (const domain of domains) {
1414
+ try {
1415
+ const proof = await buildProof(domain, { includeAgentRun });
1416
+ const dnsVerified = !!(proof.dns && proof.dns.ok);
1417
+ const endpointReady = !!(proof.wab_json && proof.wab_json.ok);
1418
+ const status = dnsVerified && endpointReady ? 'enabled' : (dnsVerified ? 'partial' : 'disabled');
1419
+ results.push({
1420
+ domain,
1421
+ status,
1422
+ dns_verified: dnsVerified,
1423
+ endpoint_ready: endpointReady,
1424
+ agent_ready: proof.statuses && proof.statuses.agent_ready === 'yes',
1425
+ checked_at: proof.checked_at,
1426
+ dns_error: proof.dns && proof.dns.error,
1427
+ endpoint_error: proof.wab_json && proof.wab_json.error,
1428
+ });
1429
+ } catch (err) {
1430
+ results.push({
1431
+ domain,
1432
+ status: 'error',
1433
+ dns_verified: false,
1434
+ endpoint_ready: false,
1435
+ agent_ready: false,
1436
+ error: err && err.message ? err.message : 'verify_failed',
1437
+ });
1438
+ }
1439
+ }
1440
+
1441
+ const summary = {
1442
+ total: results.length,
1443
+ enabled: results.filter((r) => r.status === 'enabled').length,
1444
+ partial: results.filter((r) => r.status === 'partial').length,
1445
+ disabled: results.filter((r) => r.status === 'disabled').length,
1446
+ error: results.filter((r) => r.status === 'error').length,
1447
+ };
1448
+
1449
+ const payload = {
1450
+ wab_version: WAB_VERSION,
1451
+ request_id: requestId,
1452
+ include_agent_run: includeAgentRun,
1453
+ summary,
1454
+ results,
1455
+ };
1456
+
1457
+ let callback = null;
1458
+ if (callbackUrl) {
1459
+ try {
1460
+ const delivered = await deliverBatchCallback(callbackUrl, callbackSecret || null, payload);
1461
+ callback = {
1462
+ attempted: true,
1463
+ delivered: delivered.ok,
1464
+ http_status: delivered.http_status,
1465
+ url: callbackUrl,
1466
+ };
1467
+ } catch (err) {
1468
+ callback = {
1469
+ attempted: true,
1470
+ delivered: false,
1471
+ error: err && err.message ? err.message : 'callback_failed',
1472
+ url: callbackUrl,
1473
+ };
1474
+ }
1475
+ }
1476
+
1477
+ return res.json({
1478
+ ...payload,
1479
+ callback,
1480
+ });
1481
+ } catch (err) {
1482
+ return res.status(500).json({ error: 'provider_verify_batch_failed', details: err.message });
1483
+ }
1484
+ });
1485
+
1486
+ // ═════════════════════════════════════════════════════════════════════
1487
+ // 8. POST /api/discovery/usage-proof
1488
+ // Real execution proof + KPIs (readiness if no api_key is supplied).
1489
+ // ═════════════════════════════════════════════════════════════════════
1490
+
1491
+ router.post('/api/discovery/usage-proof', async (req, res) => {
1492
+ const domain = sanitizeDomain((req.body && req.body.domain) || req.query.domain || '');
1493
+ if (!domain) {
1494
+ return res.status(400).json({
1495
+ error: 'domain is required',
1496
+ hint: 'Use POST /api/discovery/usage-proof with {"domain":"example.com"}',
1497
+ });
1498
+ }
1499
+
1500
+ const apiKey = (req.body && req.body.api_key) || '';
1501
+ const preferredUseCase = (req.body && req.body.preferred_use_case) || '';
1502
+
1503
+ try {
1504
+ const proof = await buildUsageProof(domain, { apiKey, preferredUseCase });
1505
+ storeUsageProofRun(domain, proof);
1506
+ return res.json(proof);
1507
+ } catch (err) {
1508
+ return res.status(500).json({ error: 'usage_proof_failed', details: err.message });
1509
+ }
1510
+ });
1511
+
1512
+ router.get('/api/discovery/usage-proof-runs', (req, res) => {
1513
+ const domain = sanitizeDomain(req.query.domain || '');
1514
+ const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 20, 1), 100);
1515
+
1516
+ try {
1517
+ const rows = domain
1518
+ ? db.prepare(`
1519
+ SELECT domain, mode, preferred_use_case, selected_action, readiness_ok,
1520
+ execution_attempted, execution_succeeded, value_score, end_to_end_ms,
1521
+ detail, created_at
1522
+ FROM discovery_usage_runs
1523
+ WHERE domain = ?
1524
+ ORDER BY id DESC
1525
+ LIMIT ?
1526
+ `).all(domain, limit)
1527
+ : db.prepare(`
1528
+ SELECT domain, mode, preferred_use_case, selected_action, readiness_ok,
1529
+ execution_attempted, execution_succeeded, value_score, end_to_end_ms,
1530
+ detail, created_at
1531
+ FROM discovery_usage_runs
1532
+ ORDER BY id DESC
1533
+ LIMIT ?
1534
+ `).all(limit);
1535
+
1536
+ return res.json({
1537
+ wab_version: WAB_VERSION,
1538
+ domain: domain || null,
1539
+ total: rows.length,
1540
+ runs: rows.map((r) => ({
1541
+ domain: r.domain,
1542
+ mode: r.mode,
1543
+ preferred_use_case: r.preferred_use_case,
1544
+ selected_action: r.selected_action,
1545
+ readiness_ok: !!r.readiness_ok,
1546
+ execution_attempted: !!r.execution_attempted,
1547
+ execution_succeeded: !!r.execution_succeeded,
1548
+ value_score: Number(r.value_score || 0),
1549
+ end_to_end_ms: r.end_to_end_ms,
1550
+ detail: r.detail,
1551
+ created_at: r.created_at,
1552
+ }))
1553
+ });
1554
+ } catch (err) {
1555
+ return res.status(500).json({ error: 'usage_proof_runs_failed', details: err.message });
1556
+ }
1557
+ });
1558
+
1559
+ // ───────────────────────────────────────────────────────────────────
1560
+ // GET /api/discovery/adoption-metrics
1561
+ // Aggregate analytics across all WAB Discovery usage runs.
1562
+ // Powers the public adoption dashboard at /adoption-metrics
1563
+ // ───────────────────────────────────────────────────────────────────
1564
+ router.get('/api/discovery/adoption-metrics', (_req, res) => {
1565
+ try {
1566
+ const totals = db.prepare(`
1567
+ SELECT
1568
+ COUNT(*) AS total_runs,
1569
+ COUNT(DISTINCT domain) AS unique_domains,
1570
+ SUM(CASE WHEN readiness_ok = 1 THEN 1 ELSE 0 END) AS ready_runs,
1571
+ SUM(CASE WHEN execution_attempted = 1 THEN 1 ELSE 0 END) AS exec_attempted,
1572
+ SUM(CASE WHEN execution_succeeded = 1 THEN 1 ELSE 0 END) AS exec_succeeded,
1573
+ AVG(value_score) AS avg_value_score,
1574
+ AVG(end_to_end_ms) AS avg_end_to_end_ms
1575
+ FROM discovery_usage_runs
1576
+ `).get();
1577
+
1578
+ const byUseCase = db.prepare(`
1579
+ SELECT preferred_use_case AS use_case, COUNT(*) AS runs,
1580
+ SUM(CASE WHEN readiness_ok = 1 THEN 1 ELSE 0 END) AS ready,
1581
+ AVG(value_score) AS avg_value_score
1582
+ FROM discovery_usage_runs
1583
+ WHERE preferred_use_case IS NOT NULL AND preferred_use_case != ''
1584
+ GROUP BY preferred_use_case
1585
+ ORDER BY runs DESC
1586
+ LIMIT 10
1587
+ `).all();
1588
+
1589
+ const daily = db.prepare(`
1590
+ SELECT date(created_at) AS day,
1591
+ COUNT(*) AS runs,
1592
+ COUNT(DISTINCT domain) AS domains,
1593
+ SUM(CASE WHEN readiness_ok = 1 THEN 1 ELSE 0 END) AS ready
1594
+ FROM discovery_usage_runs
1595
+ WHERE created_at >= datetime('now', '-30 days')
1596
+ GROUP BY day
1597
+ ORDER BY day ASC
1598
+ `).all();
1599
+
1600
+ const topDomains = db.prepare(`
1601
+ SELECT domain, COUNT(*) AS runs,
1602
+ SUM(CASE WHEN readiness_ok = 1 THEN 1 ELSE 0 END) AS ready,
1603
+ AVG(value_score) AS avg_value_score,
1604
+ MAX(created_at) AS last_seen
1605
+ FROM discovery_usage_runs
1606
+ GROUP BY domain
1607
+ ORDER BY runs DESC
1608
+ LIMIT 20
1609
+ `).all();
1610
+
1611
+ const recent = db.prepare(`
1612
+ SELECT domain, preferred_use_case, readiness_ok, value_score, created_at
1613
+ FROM discovery_usage_runs
1614
+ ORDER BY id DESC
1615
+ LIMIT 20
1616
+ `).all();
1617
+
1618
+ const t = totals || {};
1619
+ const safeDiv = (a, b) => (b > 0 ? Number(a || 0) / Number(b) : 0);
1620
+
1621
+ res.json({
1622
+ wab_version: WAB_VERSION,
1623
+ generated_at: new Date().toISOString(),
1624
+ totals: {
1625
+ total_runs: Number(t.total_runs || 0),
1626
+ unique_domains: Number(t.unique_domains || 0),
1627
+ ready_runs: Number(t.ready_runs || 0),
1628
+ readiness_rate: safeDiv(t.ready_runs, t.total_runs),
1629
+ exec_attempted: Number(t.exec_attempted || 0),
1630
+ exec_succeeded: Number(t.exec_succeeded || 0),
1631
+ success_rate: safeDiv(t.exec_succeeded, t.exec_attempted),
1632
+ avg_value_score: Number(t.avg_value_score || 0),
1633
+ avg_end_to_end_ms: Number(t.avg_end_to_end_ms || 0),
1634
+ },
1635
+ by_use_case: byUseCase.map(r => ({
1636
+ use_case: r.use_case,
1637
+ runs: r.runs,
1638
+ ready: r.ready,
1639
+ avg_value_score: Number(r.avg_value_score || 0),
1640
+ })),
1641
+ daily_last_30: daily.map(r => ({
1642
+ day: r.day, runs: r.runs, domains: r.domains, ready: r.ready,
1643
+ })),
1644
+ top_domains: topDomains.map(r => ({
1645
+ domain: r.domain, runs: r.runs, ready: r.ready,
1646
+ avg_value_score: Number(r.avg_value_score || 0),
1647
+ last_seen: r.last_seen,
1648
+ })),
1649
+ recent_runs: recent.map(r => ({
1650
+ domain: r.domain,
1651
+ preferred_use_case: r.preferred_use_case,
1652
+ readiness_ok: !!r.readiness_ok,
1653
+ value_score: Number(r.value_score || 0),
1654
+ created_at: r.created_at,
1655
+ })),
1656
+ });
1657
+ } catch (err) {
1658
+ res.status(500).json({ error: 'adoption_metrics_failed', details: err.message });
1659
+ }
1660
+ });
1661
+
1662
+ // ═════════════════════════════════════════════════════════════════════
1663
+ // WAB Trust Layer v1.3 — Ed25519 + DNSSEC + signed manifests
1664
+ // Anchored in DNS ownership: no CA, no central registry.
1665
+ // ═════════════════════════════════════════════════════════════════════
1666
+
1667
+ db.exec(`
1668
+ CREATE TABLE IF NOT EXISTS discovery_trust_runs (
1669
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1670
+ domain TEXT NOT NULL,
1671
+ score INTEGER NOT NULL,
1672
+ dnssec TEXT,
1673
+ has_pk INTEGER DEFAULT 0,
1674
+ signed_manifest INTEGER DEFAULT 0,
1675
+ sig_valid INTEGER DEFAULT 0,
1676
+ https_ok INTEGER DEFAULT 0,
1677
+ findings TEXT,
1678
+ created_at TEXT DEFAULT (datetime('now'))
1679
+ );
1680
+ CREATE INDEX IF NOT EXISTS idx_discovery_trust_runs_domain_time
1681
+ ON discovery_trust_runs(domain, created_at DESC);
1682
+ `);
1683
+
1684
+ // POST /api/discovery/keys/generate
1685
+ // Generates a fresh Ed25519 keypair. Stateless — server does not store
1686
+ // the private key. The caller saves it; the public key goes into DNS.
1687
+ router.post('/api/discovery/keys/generate', (_req, res) => {
1688
+ try {
1689
+ const kp = wabCrypto.generateKeyPair();
1690
+ res.json({
1691
+ wab_version: WAB_VERSION,
1692
+ ...kp,
1693
+ txt_record_snippet: `v=wab1; endpoint=https://your-domain/.well-known/wab.json; pk=ed25519:${kp.public_key}`,
1694
+ warnings: [
1695
+ 'Save private_key offline. The server does not retain it.',
1696
+ 'Publish public_key in your _wab TXT record under pk=ed25519:<value>.',
1697
+ 'Sign your wab.json manifest with the private key before publishing.',
1698
+ ],
1699
+ });
1700
+ } catch (err) {
1701
+ res.status(500).json({ error: 'key_generation_failed', details: err.message });
1702
+ }
1703
+ });
1704
+
1705
+ // POST /api/discovery/sign-manifest
1706
+ // Body: { manifest: object, private_key: base64, embed_public_key?: boolean }
1707
+ // Returns the manifest with a `signature` block appended.
1708
+ // Stateless — the private key is used in-memory only.
1709
+ router.post('/api/discovery/sign-manifest', (req, res) => {
1710
+ const { manifest, private_key, embed_public_key } = req.body || {};
1711
+ if (!manifest || typeof manifest !== 'object') {
1712
+ return res.status(400).json({ error: 'manifest object required' });
1713
+ }
1714
+ if (!private_key || typeof private_key !== 'string') {
1715
+ return res.status(400).json({ error: 'private_key (base64) required' });
1716
+ }
1717
+ try {
1718
+ const signed = wabCrypto.signManifest(manifest, private_key, { embed_public_key: !!embed_public_key });
1719
+ res.json({ wab_version: WAB_VERSION, signed_manifest: signed });
1720
+ } catch (err) {
1721
+ res.status(400).json({ error: 'signing_failed', details: err.message });
1722
+ }
1723
+ });
1724
+
1725
+ // POST /api/discovery/verify-manifest
1726
+ // Body: { manifest: object (signed), public_key?: base64 }
1727
+ // If public_key is omitted, the embedded `signature.public_key` is used.
1728
+ router.post('/api/discovery/verify-manifest', (req, res) => {
1729
+ const { manifest, public_key, max_age_seconds } = req.body || {};
1730
+ if (!manifest || typeof manifest !== 'object') {
1731
+ return res.status(400).json({ error: 'manifest object required' });
1732
+ }
1733
+ try {
1734
+ const result = wabCrypto.verifyManifest(manifest, public_key || null, {
1735
+ max_age_seconds: max_age_seconds || undefined,
1736
+ });
1737
+ res.json({ wab_version: WAB_VERSION, ...result });
1738
+ } catch (err) {
1739
+ res.status(500).json({ error: 'verification_failed', details: err.message });
1740
+ }
1741
+ });
1742
+
1743
+ // GET /api/discovery/trust/:domain
1744
+ // Comprehensive trust report combining:
1745
+ // 1. DNSSEC AD flag on _wab record
1746
+ // 2. pk=ed25519:... presence in TXT
1747
+ // 3. HTTPS reachability of the manifest endpoint
1748
+ // 4. Signed manifest + Ed25519 verification against DNS-published key
1749
+ // Returns a 0–100 score and persists to discovery_trust_runs.
1750
+ router.get('/api/discovery/trust/:domain', async (req, res) => {
1751
+ const domain = sanitizeDomain(req.params.domain || '');
1752
+ if (!domain) return res.status(400).json({ error: 'invalid domain' });
1753
+
1754
+ const findings = [];
1755
+ const checks = {
1756
+ dns_resolved: false,
1757
+ dnssec_verified: false,
1758
+ has_public_key: false,
1759
+ pk_algorithm: null,
1760
+ https_endpoint: false,
1761
+ manifest_fetched: false,
1762
+ manifest_signed: false,
1763
+ signature_valid: false,
1764
+ };
1765
+ let endpoint = null;
1766
+ let pk = null;
1767
+
1768
+ // 1. DNS lookup with DNSSEC
1769
+ let dnsResult;
1770
+ try {
1771
+ dnsResult = await verify(domain, { strict: false });
1772
+ checks.dns_resolved = !!(dnsResult.ok && dnsResult.records[0] && dnsResult.records[0].present);
1773
+ checks.dnssec_verified = dnsResult.dnssec === 'verified';
1774
+ if (!checks.dns_resolved) findings.push('No _wab TXT record found');
1775
+ if (checks.dns_resolved && !checks.dnssec_verified) findings.push('DNSSEC AD flag missing — domain not signed');
1776
+ } catch (err) {
1777
+ findings.push('DNS lookup failed: ' + err.message);
1778
+ }
1779
+
1780
+ if (checks.dns_resolved && dnsResult.records[0].parsed) {
1781
+ const parsed = dnsResult.records[0].parsed;
1782
+ endpoint = parsed.endpoint || null;
1783
+ if (parsed.pk) {
1784
+ const parsedPk = wabCrypto.parsePkField(parsed.pk);
1785
+ if (parsedPk && parsedPk.algorithm === 'ed25519') {
1786
+ checks.has_public_key = true;
1787
+ checks.pk_algorithm = 'ed25519';
1788
+ pk = parsedPk.public_key;
1789
+ } else if (parsedPk) {
1790
+ findings.push(`Unsupported pk algorithm: ${parsedPk.algorithm}`);
1791
+ } else {
1792
+ findings.push('Malformed pk= field');
1793
+ }
1794
+ } else {
1795
+ findings.push('No pk= field in _wab TXT record (cryptographic identity disabled)');
1796
+ }
1797
+ }
1798
+
1799
+ // 2. Fetch signed manifest from endpoint
1800
+ let manifest = null;
1801
+ if (endpoint && /^https:\/\//i.test(endpoint)) {
1802
+ checks.https_endpoint = true;
1803
+ try {
1804
+ const r = await safeFetch(endpoint, {}, { timeoutMs: 6000 });
1805
+ if (r && r.ok) {
1806
+ const text = await r.text();
1807
+ try {
1808
+ manifest = JSON.parse(text);
1809
+ checks.manifest_fetched = true;
1810
+ if (manifest && manifest.signature && manifest.signature.algorithm === 'ed25519') {
1811
+ checks.manifest_signed = true;
1812
+ }
1813
+ } catch {
1814
+ findings.push('Manifest is not valid JSON');
1815
+ }
1816
+ } else {
1817
+ findings.push(`Manifest fetch failed: HTTP ${r ? r.status : 'no response'}`);
1818
+ }
1819
+ } catch (err) {
1820
+ findings.push('Manifest fetch error: ' + err.message);
1821
+ }
1822
+ } else if (endpoint) {
1823
+ findings.push('Endpoint is not HTTPS');
1824
+ } else {
1825
+ findings.push('No endpoint= field to fetch manifest from');
1826
+ }
1827
+
1828
+ // 3. Verify signature
1829
+ let verifyResult = null;
1830
+ if (manifest && checks.manifest_signed && pk) {
1831
+ verifyResult = wabCrypto.verifyManifest(manifest, pk);
1832
+ checks.signature_valid = !!verifyResult.ok;
1833
+ if (!verifyResult.ok) findings.push('Signature verification failed: ' + verifyResult.reason);
1834
+ } else if (manifest && checks.manifest_signed && !pk) {
1835
+ findings.push('Manifest is signed but no DNS pk= to verify against (untrusted)');
1836
+ } else if (manifest && !checks.manifest_signed) {
1837
+ findings.push('Manifest is unsigned (cryptographic protection disabled)');
1838
+ }
1839
+
1840
+ // 4. Score: 5 boolean checks × 20 = 0–100
1841
+ const score = [
1842
+ checks.dns_resolved,
1843
+ checks.dnssec_verified,
1844
+ checks.has_public_key,
1845
+ checks.https_endpoint && checks.manifest_fetched,
1846
+ checks.signature_valid,
1847
+ ].filter(Boolean).length * 20;
1848
+
1849
+ let label;
1850
+ if (score >= 100) label = 'platinum';
1851
+ else if (score >= 80) label = 'gold';
1852
+ else if (score >= 60) label = 'silver';
1853
+ else if (score >= 40) label = 'bronze';
1854
+ else if (score >= 20) label = 'basic';
1855
+ else label = 'unverified';
1856
+
1857
+ // Persist
1858
+ try {
1859
+ db.prepare(`
1860
+ INSERT INTO discovery_trust_runs
1861
+ (domain, score, dnssec, has_pk, signed_manifest, sig_valid, https_ok, findings)
1862
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1863
+ `).run(
1864
+ domain, score,
1865
+ dnsResult ? dnsResult.dnssec : 'error',
1866
+ checks.has_public_key ? 1 : 0,
1867
+ checks.manifest_signed ? 1 : 0,
1868
+ checks.signature_valid ? 1 : 0,
1869
+ checks.https_endpoint ? 1 : 0,
1870
+ JSON.stringify(findings),
1871
+ );
1872
+ } catch { /* analytics best-effort */ }
1873
+
1874
+ res.json({
1875
+ wab_version: WAB_VERSION,
1876
+ domain,
1877
+ trust_score: score,
1878
+ trust_label: label,
1879
+ checks,
1880
+ endpoint,
1881
+ public_key: pk ? { algorithm: 'ed25519', value: pk, fingerprint: wabCrypto.fingerprint(pk) } : null,
1882
+ signature: verifyResult,
1883
+ findings,
1884
+ generated_at: new Date().toISOString(),
1885
+ });
1886
+ });
1887
+
1888
+ // GET /api/discovery/trust-leaderboard
1889
+ // Top domains by latest trust score — feeds the comparison & metrics pages.
1890
+ router.get('/api/discovery/trust-leaderboard', (_req, res) => {
1891
+ try {
1892
+ const rows = db.prepare(`
1893
+ SELECT t.domain, t.score, t.dnssec, t.has_pk, t.signed_manifest, t.sig_valid, t.created_at
1894
+ FROM discovery_trust_runs t
1895
+ INNER JOIN (
1896
+ SELECT domain, MAX(id) AS max_id
1897
+ FROM discovery_trust_runs
1898
+ GROUP BY domain
1899
+ ) latest ON latest.max_id = t.id
1900
+ ORDER BY t.score DESC, t.created_at DESC
1901
+ LIMIT 50
1902
+ `).all();
1903
+ res.json({
1904
+ wab_version: WAB_VERSION,
1905
+ total: rows.length,
1906
+ domains: rows.map(r => ({
1907
+ domain: r.domain,
1908
+ score: r.score,
1909
+ dnssec: r.dnssec,
1910
+ has_pk: !!r.has_pk,
1911
+ signed_manifest: !!r.signed_manifest,
1912
+ signature_valid: !!r.sig_valid,
1913
+ last_checked: r.created_at,
1914
+ })),
1915
+ });
1916
+ } catch (err) {
1917
+ res.status(500).json({ error: 'leaderboard_failed', details: err.message });
1918
+ }
1919
+ });
1920
+
1921
+ // ═════════════════════════════════════════════════════════════════════
1922
+ // WAB Score / Compliance / Badge — Phase 18
1923
+ // Strategic adoption layer: make WAB the cheaper, safer default.
1924
+ // - Score: observable, machine-comparable signal an agent can rank by.
1925
+ // - Compliance: allow/restrict/deny verdict with signed reasons,
1926
+ // so enterprise agents can run in "WAB-only" mode.
1927
+ // - Badge: viral SVG that any WAB site can embed.
1928
+ // ═════════════════════════════════════════════════════════════════════
1929
+
1930
+ // Helper: compute the latest composite WAB Score for a domain.
1931
+ // Score is a weighted blend of:
1932
+ // - signature/trust integrity (30%) ← discovery_trust_runs
1933
+ // - execution success rate (20%) ← discovery_usage_runs
1934
+ // - latency (lower is better) (10%)
1935
+ // - readiness rate ( 8%)
1936
+ // - sample volume bonus ( 7%)
1937
+ // - configured fairness (15%) ← sites.config (neutrality)
1938
+ // - configured security signals (10%) ← sites.config (perms/restrictions/logging)
1939
+ // Components with no data shrink the max so cold-start sites aren't
1940
+ // double-penalised purely for lack of usage history.
1941
+ function computeWabScore(domain) {
1942
+ const out = {
1943
+ domain,
1944
+ score: 0,
1945
+ label: 'unrated',
1946
+ components: {
1947
+ trust: { score: 0, weight: 30, available: false },
1948
+ success: { score: 0, weight: 20, available: false },
1949
+ latency: { score: 0, weight: 10, available: false, ms: null },
1950
+ readiness: { score: 0, weight: 8, available: false },
1951
+ volume: { score: 0, weight: 7, runs: 0 },
1952
+ fairness: { score: 0, weight: 15, available: false },
1953
+ security: { score: 0, weight: 10, available: false, signals: [] },
1954
+ },
1955
+ error_classes: {},
1956
+ signature_valid_rate: 0,
1957
+ success_rate: 0,
1958
+ avg_latency_ms: null,
1959
+ sample_size: 0,
1960
+ cold_start: false,
1961
+ last_seen: null,
1962
+ last_trust_check: null,
1963
+ site_registered: false,
1964
+ };
1965
+
1966
+ // --- Trust component (latest run) ---
1967
+ let trustRow = null;
1968
+ try {
1969
+ trustRow = db.prepare(`
1970
+ SELECT score, sig_valid, has_pk, signed_manifest, https_ok, dnssec, created_at
1971
+ FROM discovery_trust_runs WHERE domain = ?
1972
+ ORDER BY id DESC LIMIT 1
1973
+ `).get(domain);
1974
+ } catch { /* table may not exist yet */ }
1975
+ if (trustRow) {
1976
+ out.components.trust.score = Math.max(0, Math.min(100, Number(trustRow.score || 0)));
1977
+ out.components.trust.available = true;
1978
+ out.last_trust_check = trustRow.created_at;
1979
+ }
1980
+
1981
+ // --- Trust history → signature_valid_rate (last 50 runs) ---
1982
+ try {
1983
+ const hist = db.prepare(`
1984
+ SELECT sig_valid FROM discovery_trust_runs
1985
+ WHERE domain = ? ORDER BY id DESC LIMIT 50
1986
+ `).all(domain);
1987
+ if (hist.length) {
1988
+ const valid = hist.filter(r => r.sig_valid).length;
1989
+ out.signature_valid_rate = valid / hist.length;
1990
+ }
1991
+ } catch { /* best-effort */ }
1992
+
1993
+ // --- Usage runs aggregation (last 200 runs in last 30d) ---
1994
+ let usage = null;
1995
+ try {
1996
+ usage = db.prepare(`
1997
+ SELECT
1998
+ COUNT(*) AS runs,
1999
+ SUM(CASE WHEN readiness_ok = 1 THEN 1 ELSE 0 END) AS ready,
2000
+ SUM(CASE WHEN execution_attempted = 1 THEN 1 ELSE 0 END) AS attempted,
2001
+ SUM(CASE WHEN execution_succeeded = 1 THEN 1 ELSE 0 END) AS succeeded,
2002
+ AVG(end_to_end_ms) AS avg_ms,
2003
+ MAX(created_at) AS last_seen
2004
+ FROM discovery_usage_runs
2005
+ WHERE domain = ? AND created_at >= datetime('now', '-30 days')
2006
+ `).get(domain);
2007
+ } catch { /* table may not exist */ }
2008
+
2009
+ if (usage && usage.runs > 0) {
2010
+ out.sample_size = Number(usage.runs);
2011
+ out.last_seen = usage.last_seen;
2012
+ out.components.volume.runs = Number(usage.runs);
2013
+ out.components.volume.score = Math.min(100, Math.log2(usage.runs + 1) * 20);
2014
+
2015
+ if (usage.attempted > 0) {
2016
+ out.success_rate = Number(usage.succeeded || 0) / Number(usage.attempted);
2017
+ out.components.success.score = Math.round(out.success_rate * 100);
2018
+ out.components.success.available = true;
2019
+ }
2020
+ if (usage.runs > 0) {
2021
+ const readiness = Number(usage.ready || 0) / Number(usage.runs);
2022
+ out.components.readiness.score = Math.round(readiness * 100);
2023
+ out.components.readiness.available = true;
2024
+ }
2025
+ if (usage.avg_ms != null) {
2026
+ const ms = Number(usage.avg_ms);
2027
+ out.avg_latency_ms = Math.round(ms);
2028
+ out.components.latency.ms = Math.round(ms);
2029
+ // 200ms or less → 100; 5000ms+ → 0; linear in between.
2030
+ const lat = Math.max(0, Math.min(100, 100 - ((ms - 200) / 4800) * 100));
2031
+ out.components.latency.score = Math.round(lat);
2032
+ out.components.latency.available = true;
2033
+ }
2034
+
2035
+ // Error classes
2036
+ try {
2037
+ const errs = db.prepare(`
2038
+ SELECT detail FROM discovery_usage_runs
2039
+ WHERE domain = ? AND execution_attempted = 1 AND execution_succeeded = 0
2040
+ AND created_at >= datetime('now', '-30 days')
2041
+ LIMIT 200
2042
+ `).all(domain);
2043
+ for (const r of errs) {
2044
+ let cls = 'unknown';
2045
+ try {
2046
+ const d = JSON.parse(r.detail || '{}');
2047
+ cls = String(d.error_class || d.error_code || d.error || 'unknown').slice(0, 40);
2048
+ } catch { cls = 'unknown'; }
2049
+ out.error_classes[cls] = (out.error_classes[cls] || 0) + 1;
2050
+ }
2051
+ } catch { /* best-effort */ }
2052
+ } else {
2053
+ out.cold_start = true;
2054
+ }
2055
+
2056
+ // --- Configuration-based components: fairness + security ---
2057
+ // Pulled from sites.config (set by the site owner). Independent of
2058
+ // behavioral telemetry; available even for brand-new registrations.
2059
+ let siteRow = null;
2060
+ try {
2061
+ siteRow = db.prepare(
2062
+ `SELECT * FROM sites WHERE LOWER(REPLACE(domain, 'www.', '')) = ? AND active = 1`
2063
+ ).get(domain);
2064
+ } catch { /* sites table may not exist in some test setups */ }
2065
+
2066
+ if (siteRow) {
2067
+ out.site_registered = true;
2068
+
2069
+ // Fairness / neutrality (calculateNeutralityScore returns 0–100 or {score})
2070
+ try {
2071
+ const fr = calculateNeutralityScore(siteRow);
2072
+ const fScore = typeof fr === 'number' ? fr : Number((fr && fr.score) || 0);
2073
+ if (Number.isFinite(fScore) && fScore > 0) {
2074
+ out.components.fairness.score = Math.max(0, Math.min(100, fScore));
2075
+ out.components.fairness.available = true;
2076
+ }
2077
+ } catch { /* fairness module optional */ }
2078
+
2079
+ // Security configuration signals from sites.config
2080
+ try {
2081
+ let cfg = {};
2082
+ try { cfg = siteRow.config ? JSON.parse(siteRow.config) : {}; } catch { cfg = {}; }
2083
+ let sec = 60; // baseline for a registered site
2084
+ const sigs = [];
2085
+ if (cfg.agentPermissions) { sec += 15; sigs.push('agent_permissions_configured'); }
2086
+ if (cfg.restrictions && Object.keys(cfg.restrictions).length) { sec += 10; sigs.push('restrictions_defined'); }
2087
+ if (cfg.logging) { sec += 8; sigs.push('logging_enabled'); }
2088
+ if (cfg.rateLimit) { sec += 5; sigs.push('rate_limit_set'); }
2089
+ if (cfg.requireAuth) { sec += 5; sigs.push('auth_required'); }
2090
+ out.components.security.score = Math.max(0, Math.min(100, sec));
2091
+ out.components.security.signals = sigs;
2092
+ out.components.security.available = true;
2093
+ } catch { /* best-effort */ }
2094
+ }
2095
+
2096
+ // --- Composite weighted score ---
2097
+ // Components with data contribute their weight; missing components
2098
+ // shrink the maximum so a brand-new but well-signed domain isn't
2099
+ // penalised purely for lack of usage history.
2100
+ const c = out.components;
2101
+ let weighted = 0, maxWeighted = 0;
2102
+ for (const k of Object.keys(c)) {
2103
+ const comp = c[k];
2104
+ if (comp.available || (k === 'volume' && comp.runs > 0)) {
2105
+ weighted += (comp.score / 100) * comp.weight;
2106
+ maxWeighted += comp.weight;
2107
+ }
2108
+ }
2109
+ out.score = maxWeighted > 0 ? Math.round((weighted / maxWeighted) * 100) : 0;
2110
+
2111
+ if (out.score >= 90) out.label = 'platinum';
2112
+ else if (out.score >= 75) out.label = 'gold';
2113
+ else if (out.score >= 60) out.label = 'silver';
2114
+ else if (out.score >= 40) out.label = 'bronze';
2115
+ else if (out.score > 0) out.label = 'basic';
2116
+ else out.label = 'unrated';
2117
+
2118
+ return out;
2119
+ }
2120
+
2121
+ // GET /api/discovery/score/:domain
2122
+ // Public, observable signal for agents. Cacheable for 60s.
2123
+ router.get('/api/discovery/score/:domain', (req, res) => {
2124
+ const domain = sanitizeDomain(req.params.domain || '');
2125
+ if (!domain) return res.status(400).json({ error: 'invalid_domain' });
2126
+ try {
2127
+ const result = computeWabScore(domain);
2128
+ res.set('Cache-Control', 'public, max-age=60');
2129
+ res.set('X-WAB-Version', WAB_VERSION);
2130
+ res.json({
2131
+ wab_version: WAB_VERSION,
2132
+ generated_at: new Date().toISOString(),
2133
+ ...result,
2134
+ });
2135
+ } catch (err) {
2136
+ res.status(500).json({ error: 'score_failed', details: err.message });
2137
+ }
2138
+ });
2139
+
2140
+ // GET /api/discovery/compliance/:domain?policy=strict|standard|permissive
2141
+ // Returns a verdict an agent can use as a policy gate:
2142
+ // - allow: safe to run write/exec actions
2143
+ // - restrict: read-only / no payments / no PII writes
2144
+ // - deny: do not interact (no DNS, fake signature, etc.)
2145
+ //
2146
+ // Policies (defaults; can be overridden via query):
2147
+ // strict — DNSSEC + signed manifest + score ≥ 75
2148
+ // standard — DNS + signed manifest + score ≥ 60 (default)
2149
+ // permissive — DNS + score ≥ 40
2150
+ const COMPLIANCE_POLICIES = {
2151
+ strict: { require_dnssec: true, require_signature: true, min_score: 75 },
2152
+ standard: { require_dnssec: false, require_signature: true, min_score: 60 },
2153
+ permissive: { require_dnssec: false, require_signature: false, min_score: 40 },
2154
+ };
2155
+
2156
+ router.get('/api/discovery/compliance/:domain', (req, res) => {
2157
+ const domain = sanitizeDomain(req.params.domain || '');
2158
+ if (!domain) return res.status(400).json({ error: 'invalid_domain' });
2159
+
2160
+ const policyName = String(req.query.policy || 'standard').toLowerCase();
2161
+ const policy = COMPLIANCE_POLICIES[policyName] || COMPLIANCE_POLICIES.standard;
2162
+
2163
+ const reasons = [];
2164
+ let verdict = 'allow';
2165
+
2166
+ // Pull the latest trust check (no live network call — agents that need a
2167
+ // live check should hit /api/discovery/trust/:domain first).
2168
+ let trustRow = null;
2169
+ try {
2170
+ trustRow = db.prepare(`
2171
+ SELECT score, sig_valid, has_pk, signed_manifest, https_ok, dnssec, created_at
2172
+ FROM discovery_trust_runs WHERE domain = ?
2173
+ ORDER BY id DESC LIMIT 1
2174
+ `).get(domain);
2175
+ } catch { /* table may not exist */ }
2176
+
2177
+ const score = computeWabScore(domain);
2178
+
2179
+ // Hard fails → deny
2180
+ if (!trustRow) {
2181
+ reasons.push({ code: 'no_trust_record', severity: 'deny', message: 'Run /api/discovery/trust/:domain first' });
2182
+ verdict = 'deny';
2183
+ } else {
2184
+ if (!trustRow.has_pk) {
2185
+ reasons.push({ code: 'no_public_key', severity: 'deny', message: 'No pk= in DNS — cannot verify identity' });
2186
+ verdict = 'deny';
2187
+ }
2188
+ if (!trustRow.https_ok) {
2189
+ reasons.push({ code: 'no_https_endpoint', severity: 'deny', message: 'Endpoint is not HTTPS' });
2190
+ verdict = 'deny';
2191
+ }
2192
+
2193
+ // Soft fails → restrict
2194
+ if (verdict !== 'deny') {
2195
+ if (policy.require_dnssec && trustRow.dnssec !== 'verified') {
2196
+ reasons.push({ code: 'dnssec_required', severity: 'restrict', message: 'Policy requires DNSSEC AD flag' });
2197
+ verdict = 'restrict';
2198
+ }
2199
+ if (policy.require_signature && !trustRow.sig_valid) {
2200
+ reasons.push({ code: 'signature_required', severity: 'restrict', message: 'Policy requires a valid Ed25519 manifest signature' });
2201
+ verdict = 'restrict';
2202
+ }
2203
+ if (score.score < policy.min_score) {
2204
+ reasons.push({
2205
+ code: 'score_below_threshold',
2206
+ severity: 'restrict',
2207
+ message: `WAB Score ${score.score} below policy minimum ${policy.min_score}`,
2208
+ });
2209
+ verdict = 'restrict';
2210
+ }
2211
+ }
2212
+ }
2213
+
2214
+ // Sign the verdict so downstream agents can prove what was returned.
2215
+ const verdictPayload = {
2216
+ wab_version: WAB_VERSION,
2217
+ domain,
2218
+ policy: policyName,
2219
+ verdict,
2220
+ score: score.score,
2221
+ score_label: score.label,
2222
+ reasons,
2223
+ signature_valid_rate: score.signature_valid_rate,
2224
+ success_rate: score.success_rate,
2225
+ last_trust_check: trustRow ? trustRow.created_at : null,
2226
+ generated_at: new Date().toISOString(),
2227
+ };
2228
+ let serverSig = null;
2229
+ try {
2230
+ if (typeof wabCrypto.signServerPayload === 'function') {
2231
+ serverSig = wabCrypto.signServerPayload(verdictPayload);
2232
+ }
2233
+ } catch { /* optional */ }
2234
+
2235
+ res.set('Cache-Control', 'public, max-age=60');
2236
+ res.set('X-WAB-Version', WAB_VERSION);
2237
+ res.set('X-WAB-Compliance', verdict);
2238
+ res.json({ ...verdictPayload, server_signature: serverSig });
2239
+ });
2240
+
2241
+ // GET /badge/:domain.svg
2242
+ // Embeddable SVG badge — like shields.io. Two pills: "WAB" + score/label.
2243
+ // Agents see it; users see a trust signal; sites get a viral hook.
2244
+ function _wabBadgeColor(score) {
2245
+ if (score >= 90) return '#16a34a'; // platinum / green
2246
+ if (score >= 75) return '#22c55e'; // gold
2247
+ if (score >= 60) return '#84cc16'; // silver
2248
+ if (score >= 40) return '#eab308'; // bronze
2249
+ if (score > 0) return '#f97316'; // basic / orange
2250
+ return '#6b7280'; // unrated / gray
2251
+ }
2252
+
2253
+ router.get('/badge/:domainfile', (req, res) => {
2254
+ const file = String(req.params.domainfile || '');
2255
+ const m = file.match(/^([a-z0-9.-]+)\.svg$/i);
2256
+ if (!m) return res.status(404).type('text/plain').send('not_found');
2257
+ const domain = sanitizeDomain(m[1]);
2258
+ if (!domain) return res.status(400).type('text/plain').send('invalid_domain');
2259
+
2260
+ let score = 0, label = 'unrated';
2261
+ try {
2262
+ const r = computeWabScore(domain);
2263
+ score = r.score;
2264
+ label = r.label;
2265
+ } catch { /* fall through with defaults */ }
2266
+
2267
+ const style = String(req.query.style || 'flat').toLowerCase();
2268
+ const right = score > 0 ? `${label} ${score}` : 'unrated';
2269
+ const color = _wabBadgeColor(score);
2270
+
2271
+ // Approximate widths for Verdana 11px (works fine without web fonts).
2272
+ const charW = 6.5;
2273
+ const padX = 8;
2274
+ const leftLabel = 'WAB';
2275
+ const leftW = Math.round(leftLabel.length * charW + padX * 2);
2276
+ const rightW = Math.round(right.length * charW + padX * 2);
2277
+ const totalW = leftW + rightW;
2278
+ const radius = style === 'flat-square' ? 0 : 3;
2279
+
2280
+ const escape = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
2281
+
2282
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${totalW}" height="20" role="img" aria-label="WAB: ${escape(right)}">
2283
+ <title>WAB: ${escape(right)}</title>
2284
+ <linearGradient id="s" x2="0" y2="100%">
2285
+ <stop offset="0" stop-color="#fff" stop-opacity=".7"/>
2286
+ <stop offset=".1" stop-color="#aaa" stop-opacity=".1"/>
2287
+ <stop offset=".9" stop-color="#000" stop-opacity=".3"/>
2288
+ <stop offset="1" stop-color="#000" stop-opacity=".5"/>
2289
+ </linearGradient>
2290
+ <clipPath id="r"><rect width="${totalW}" height="20" rx="${radius}" fill="#fff"/></clipPath>
2291
+ <g clip-path="url(#r)">
2292
+ <rect width="${leftW}" height="20" fill="#374151"/>
2293
+ <rect x="${leftW}" width="${rightW}" height="20" fill="${color}"/>
2294
+ <rect width="${totalW}" height="20" fill="url(#s)"/>
2295
+ </g>
2296
+ <g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
2297
+ <text x="${leftW / 2}" y="15" fill="#010101" fill-opacity=".3">${leftLabel}</text>
2298
+ <text x="${leftW / 2}" y="14">${leftLabel}</text>
2299
+ <text x="${leftW + rightW / 2}" y="15" fill="#010101" fill-opacity=".3">${escape(right)}</text>
2300
+ <text x="${leftW + rightW / 2}" y="14">${escape(right)}</text>
2301
+ </g>
2302
+ </svg>`;
2303
+
2304
+ res.set('Content-Type', 'image/svg+xml; charset=utf-8');
2305
+ res.set('Cache-Control', 'public, max-age=300, s-maxage=300');
2306
+ res.set('X-WAB-Version', WAB_VERSION);
2307
+ res.send(svg);
2308
+ });
2309
+
2310
+ // ═════════════════════════════════════════════════════════════════════
2311
+ // 11. GET /api/discovery/:siteId — Discovery doc for a specific site
2312
+ // (defined AFTER named routes to prevent shadowing)
2313
+ // ═════════════════════════════════════════════════════════════════════
2314
+
2315
+ router.get('/api/discovery/:siteId', (req, res) => {
2316
+ try {
2317
+ const site = findSiteById.get(req.params.siteId);
2318
+ if (!site || !site.active) {
2319
+ return res.status(404).json({ error: 'Site not found' });
2320
+ }
2321
+
2322
+ const doc = buildDiscoveryDocument(site);
2323
+ res.set('Cache-Control', 'public, max-age=300');
2324
+ res.set('X-WAB-Version', WAB_VERSION);
2325
+ res.json(doc);
2326
+ } catch (err) {
2327
+ res.status(500).json({ error: 'Failed to generate discovery document' });
2328
+ }
2329
+ });
2330
+
2331
+ // ─── Utility ─────────────────────────────────────────────────────────
2332
+
2333
+ function safeParseTags(tags) {
2334
+ if (Array.isArray(tags)) return tags;
2335
+ try { return JSON.parse(tags || '[]'); } catch (_) { return []; }
2336
+ }
2337
+
2338
+ module.exports = router;
2339
+ module.exports._internals = {
2340
+ sanitizeDomain,
2341
+ deriveEndpointFromRecord,
2342
+ summarizeUseCase,
2343
+ hostAllowList,
2344
+ pickUsageAction,
2345
+ resolveAbsoluteUrl,
2346
+ buildActionParams,
2347
+ computeWabScore,
2348
+ };