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.
- package/LICENSE +12 -0
- package/README.ar.md +18 -0
- package/README.md +198 -1664
- package/bin/wab-init.js +223 -0
- package/examples/azure-dns-wab.js +83 -0
- package/examples/cloudflare-wab-dns.js +121 -0
- package/examples/cpanel-wab-dns.js +114 -0
- package/examples/dns-discovery-agent.js +166 -0
- package/examples/gcp-dns-wab.js +76 -0
- package/examples/governance-agent.js +169 -0
- package/examples/plesk-wab-dns.js +103 -0
- package/examples/route53-wab-dns.js +144 -0
- package/examples/safe-mode-agent.js +96 -0
- package/examples/wab-sign.js +74 -0
- package/examples/wab-verify.js +60 -0
- package/package.json +5 -5
- package/public/.well-known/wab.json +28 -0
- package/public/activate.html +368 -0
- package/public/adoption-metrics.html +188 -0
- package/public/api.html +1 -1
- package/public/azure-dns-integration.html +289 -0
- package/public/cloudflare-integration.html +380 -0
- package/public/cpanel-integration.html +398 -0
- package/public/css/styles.css +28 -0
- package/public/dashboard.html +1 -0
- package/public/dns.html +101 -172
- package/public/docs.html +1 -0
- package/public/gcp-dns-integration.html +318 -0
- package/public/growth.html +4 -2
- package/public/index.html +227 -31
- package/public/integrations.html +1 -1
- package/public/js/activate.js +145 -0
- package/public/js/auth-nav.js +34 -0
- package/public/js/dns.js +438 -0
- package/public/openapi.json +89 -0
- package/public/plesk-integration.html +375 -0
- package/public/premium.html +1 -1
- package/public/provider-onboarding.html +172 -0
- package/public/provider-sandbox.html +134 -0
- package/public/providers.html +359 -0
- package/public/registrar-integrations.html +141 -0
- package/public/robots.txt +12 -0
- package/public/route53-integration.html +531 -0
- package/public/shieldqr.html +231 -0
- package/public/sitemap.xml +6 -0
- package/public/wab-trust.html +200 -0
- package/public/wab-vs-protocols.html +210 -0
- package/public/whitepaper.html +449 -0
- package/sdk/auto-discovery.js +288 -0
- package/sdk/governance.js +262 -0
- package/sdk/index.js +13 -0
- package/sdk/package.json +2 -2
- package/sdk/safe-mode.js +221 -0
- package/server/index.js +144 -5
- package/server/migrations/007_governance.sql +106 -0
- package/server/migrations/008_plans.sql +144 -0
- package/server/migrations/009_shieldqr.sql +30 -0
- package/server/migrations/010_extended_trust.sql +33 -0
- package/server/models/adapters/mysql.js +1 -1
- package/server/models/adapters/postgresql.js +1 -1
- package/server/models/db.js +60 -1
- package/server/routes/admin-plans.js +76 -0
- package/server/routes/admin-premium.js +4 -2
- package/server/routes/admin-shieldqr.js +90 -0
- package/server/routes/admin-trust-monitor.js +83 -0
- package/server/routes/admin.js +289 -1
- package/server/routes/billing.js +16 -4
- package/server/routes/discovery.js +1933 -2
- package/server/routes/governance.js +208 -0
- package/server/routes/plans.js +33 -0
- package/server/routes/providers.js +650 -0
- package/server/routes/shieldqr.js +88 -0
- package/server/services/email.js +29 -0
- package/server/services/governance.js +466 -0
- package/server/services/plans.js +214 -0
- package/server/services/premium.js +1 -1
- package/server/services/provider-clients.js +740 -0
- package/server/services/shieldqr.js +322 -0
- package/server/services/ssl-inspector.js +42 -0
- package/server/services/ssl-monitor.js +167 -0
- package/server/services/stripe.js +18 -5
- package/server/services/vision.js +1 -1
- 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.
|
|
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
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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
|
+
};
|