web-agent-bridge 3.3.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 (83) hide show
  1. package/LICENSE +12 -0
  2. package/README.ar.md +18 -0
  3. package/README.md +198 -1664
  4. package/bin/wab-init.js +223 -0
  5. package/examples/azure-dns-wab.js +83 -0
  6. package/examples/cloudflare-wab-dns.js +121 -0
  7. package/examples/cpanel-wab-dns.js +114 -0
  8. package/examples/dns-discovery-agent.js +166 -0
  9. package/examples/gcp-dns-wab.js +76 -0
  10. package/examples/governance-agent.js +169 -0
  11. package/examples/plesk-wab-dns.js +103 -0
  12. package/examples/route53-wab-dns.js +144 -0
  13. package/examples/safe-mode-agent.js +96 -0
  14. package/examples/wab-sign.js +74 -0
  15. package/examples/wab-verify.js +60 -0
  16. package/package.json +5 -5
  17. package/public/.well-known/wab.json +28 -0
  18. package/public/activate.html +368 -0
  19. package/public/adoption-metrics.html +188 -0
  20. package/public/api.html +1 -1
  21. package/public/azure-dns-integration.html +289 -0
  22. package/public/cloudflare-integration.html +380 -0
  23. package/public/cpanel-integration.html +398 -0
  24. package/public/css/styles.css +28 -0
  25. package/public/dashboard.html +1 -0
  26. package/public/dns.html +101 -172
  27. package/public/docs.html +1 -0
  28. package/public/gcp-dns-integration.html +318 -0
  29. package/public/growth.html +4 -2
  30. package/public/index.html +227 -31
  31. package/public/integrations.html +1 -1
  32. package/public/js/activate.js +145 -0
  33. package/public/js/auth-nav.js +34 -0
  34. package/public/js/dns.js +438 -0
  35. package/public/openapi.json +89 -0
  36. package/public/plesk-integration.html +375 -0
  37. package/public/premium.html +1 -1
  38. package/public/provider-onboarding.html +172 -0
  39. package/public/provider-sandbox.html +134 -0
  40. package/public/providers.html +359 -0
  41. package/public/registrar-integrations.html +141 -0
  42. package/public/robots.txt +12 -0
  43. package/public/route53-integration.html +531 -0
  44. package/public/shieldqr.html +231 -0
  45. package/public/sitemap.xml +6 -0
  46. package/public/wab-trust.html +200 -0
  47. package/public/wab-vs-protocols.html +210 -0
  48. package/public/whitepaper.html +449 -0
  49. package/sdk/auto-discovery.js +288 -0
  50. package/sdk/governance.js +262 -0
  51. package/sdk/index.js +13 -0
  52. package/sdk/package.json +2 -2
  53. package/sdk/safe-mode.js +221 -0
  54. package/server/index.js +144 -5
  55. package/server/migrations/007_governance.sql +106 -0
  56. package/server/migrations/008_plans.sql +144 -0
  57. package/server/migrations/009_shieldqr.sql +30 -0
  58. package/server/migrations/010_extended_trust.sql +33 -0
  59. package/server/models/adapters/mysql.js +1 -1
  60. package/server/models/adapters/postgresql.js +1 -1
  61. package/server/models/db.js +60 -1
  62. package/server/routes/admin-plans.js +76 -0
  63. package/server/routes/admin-premium.js +4 -2
  64. package/server/routes/admin-shieldqr.js +90 -0
  65. package/server/routes/admin-trust-monitor.js +83 -0
  66. package/server/routes/admin.js +289 -1
  67. package/server/routes/billing.js +16 -4
  68. package/server/routes/discovery.js +1933 -2
  69. package/server/routes/governance.js +208 -0
  70. package/server/routes/plans.js +33 -0
  71. package/server/routes/providers.js +650 -0
  72. package/server/routes/shieldqr.js +88 -0
  73. package/server/services/email.js +29 -0
  74. package/server/services/governance.js +466 -0
  75. package/server/services/plans.js +214 -0
  76. package/server/services/premium.js +1 -1
  77. package/server/services/provider-clients.js +740 -0
  78. package/server/services/shieldqr.js +322 -0
  79. package/server/services/ssl-inspector.js +42 -0
  80. package/server/services/ssl-monitor.js +167 -0
  81. package/server/services/stripe.js +18 -5
  82. package/server/services/vision.js +1 -1
  83. package/server/services/wab-crypto.js +178 -0
@@ -5,8 +5,12 @@
5
5
 
6
6
  const express = require('express');
7
7
  const router = express.Router();
8
+ const crypto = require('crypto');
8
9
  const { findSiteById, db } = require('../models/db');
9
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');
10
14
 
11
15
  // Fairness module is proprietary — provide stubs when not available
12
16
  let calculateNeutralityScore, fairnessWeightedSearch, registerInDirectory, getDirectoryListings, generateFairnessReport;
@@ -26,7 +30,27 @@ try {
26
30
  generateFairnessReport = () => ({ status: 'unavailable' });
27
31
  }
28
32
 
29
- const WAB_VERSION = '1.2.0';
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
+ `);
30
54
 
31
55
  // ─── Helpers ─────────────────────────────────────────────────────────
32
56
 
@@ -52,6 +76,760 @@ function parseSiteConfig(site) {
52
76
  try { return JSON.parse(site.config || '{}'); } catch (_) { return {}; }
53
77
  }
54
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
+
55
833
  function buildDiscoveryDocument(site) {
56
834
  const config = parseSiteConfig(site);
57
835
  const perms = config.agentPermissions || {};
@@ -387,7 +1165,1150 @@ router.get('/api/discovery/search', (req, res) => {
387
1165
  });
388
1166
 
389
1167
  // ═════════════════════════════════════════════════════════════════════
390
- // 6. GET /api/discovery/:siteId — Discovery doc for a specific site
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
391
2312
  // (defined AFTER named routes to prevent shadowing)
392
2313
  // ═════════════════════════════════════════════════════════════════════
393
2314
 
@@ -415,3 +2336,13 @@ function safeParseTags(tags) {
415
2336
  }
416
2337
 
417
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
+ };