web-agent-bridge 3.13.0 → 3.15.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "web-agent-bridge",
3
- "version": "3.13.0",
3
+ "version": "3.15.0",
4
4
  "description": "Agent Transaction Bridge — the trust + transaction layer for agentic commerce. Signed intent contracts, idempotent transactions, Ed25519-verifiable receipts, explicit compensation. Plus the original WAB stack: sovereign browser, ShieldQR, SSL health, DNS discovery, agent mesh, and unified gateway for safe AI–website interaction.",
5
5
  "author": "Web Agent Bridge <dev@webagentbridge.com>",
6
6
  "main": "server/index.js",
package/server/index.js CHANGED
@@ -323,6 +323,10 @@ app.use('/api/revocations', apiLimiter, require('./routes/revocations'));
323
323
  // ── Agent-Driven Adoption v3.12.0 — canonical LLM agent system prompt ──
324
324
  app.use('/api/agent', apiLimiter, require('./routes/agent-prompt'));
325
325
 
326
+ // ── Network Effect v3.14.0 — trusted-domains snapshot + revocations feeds ──
327
+ // (apiLimiter already applies via /api mount above; do not stack it here.)
328
+ app.use('/api', require('./routes/network'));
329
+
326
330
  // ── WAB Commercial Foundations v3.8.0 (Partners · Trust Graph API · Governance SaaS · Enterprise Mesh) ──
327
331
  app.use('/api/partners', apiLimiter, require('./routes/partners'));
328
332
  app.use('/api/keys', apiLimiter, require('./routes/api-keys'));
@@ -0,0 +1,306 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Network-effect public endpoints (v3.14.0 + signed snapshots v3.15.0).
5
+ *
6
+ * GET /api/trusted-domains.json — signed snapshot of currently-attested,
7
+ * non-revoked WAB sites. Cached 1 hour. Designed for agent bootstrap
8
+ * and third-party crawlers building "verified web" indexes.
9
+ * GET /api/trusted-domains.txt — same data, newline-separated domains.
10
+ * GET /api/trusted-domains/archive.json — manifest of available daily snapshots.
11
+ * GET /api/trusted-domains/:date.json — historical signed snapshot.
12
+ * GET /api/transparency/feed.json — JSON Feed 1.1 of the transparency log.
13
+ * GET /api/transparency/feed.xml — Atom 1.0 of the transparency log.
14
+ * GET /api/operator-key.json — operator Ed25519 public key (b64 + JWK).
15
+ *
16
+ * Mounted at /api in server/index.js.
17
+ */
18
+
19
+ const express = require('express');
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+ const crypto = require('crypto');
23
+ const router = express.Router();
24
+ const { db } = require('../models/db');
25
+ const { canonicalize } = require('../services/canonical-json');
26
+ const signer = require('../services/operator-signer');
27
+
28
+ const SNAPSHOT_TTL_MS = 60 * 60 * 1000; // 1h
29
+ const ARCHIVE_DIR = process.env.WAB_SNAPSHOT_DIR ||
30
+ path.join(__dirname, '..', '..',
31
+ (process.env.NODE_ENV === 'test' ? 'data-test' : 'data'),
32
+ 'snapshots');
33
+
34
+ let _snapshotCache = { ts: 0, data: null };
35
+
36
+ function _ensureArchiveDir() {
37
+ try { fs.mkdirSync(ARCHIVE_DIR, { recursive: true }); } catch (_) { /* ignore */ }
38
+ }
39
+
40
+ function _todayUtc() {
41
+ return new Date().toISOString().slice(0, 10); // YYYY-MM-DD
42
+ }
43
+
44
+ function buildSnapshot() {
45
+ // Active sites that have no active blocking revocation.
46
+ let rows = [];
47
+ try {
48
+ rows = db.prepare(`
49
+ SELECT s.id, s.domain, s.name, s.description, s.tier, s.created_at
50
+ FROM sites s
51
+ WHERE s.active = 1
52
+ AND NOT EXISTS (
53
+ SELECT 1 FROM site_revocations r
54
+ WHERE r.site_id = s.id
55
+ AND r.status IN ('pending_appeal', 'appealed', 'final')
56
+ AND r.type IN ('suspended', 'revoked')
57
+ )
58
+ ORDER BY s.created_at ASC
59
+ `).all();
60
+ } catch (_) {
61
+ rows = db.prepare(`
62
+ SELECT id, domain, name, description, tier, created_at
63
+ FROM sites WHERE active = 1 ORDER BY created_at ASC
64
+ `).all();
65
+ }
66
+
67
+ const generated_at = new Date().toISOString();
68
+ const date = generated_at.slice(0, 10);
69
+ const payload = {
70
+ schema: 'wab-trusted-domains/v1',
71
+ generated_at,
72
+ date,
73
+ total: rows.length,
74
+ domains: rows.map(r => ({
75
+ domain: r.domain,
76
+ name: r.name,
77
+ tier: r.tier || 'free',
78
+ registered_at: r.created_at,
79
+ discovery_url: 'https://' + r.domain + '/.well-known/wab.json',
80
+ badge_url: 'https://webagentbridge.com/api/discovery/badge/' + r.domain + '.svg'
81
+ }))
82
+ };
83
+
84
+ // Hash + sign over the canonical bytes of `payload` (without signature fields).
85
+ const canonical = canonicalize(payload);
86
+ const content_hash = 'sha256:' + crypto.createHash('sha256').update(canonical, 'utf8').digest('hex');
87
+ const signature = signer.sign(payload);
88
+
89
+ const out = Object.assign({}, payload, {
90
+ content_hash,
91
+ signature: signature
92
+ ? { alg: signer.ALGORITHM, value: signature, key_url: '/api/operator-key.json' }
93
+ : null,
94
+ });
95
+
96
+ // Persist today's snapshot to disk for time-machine queries (idempotent overwrite).
97
+ try {
98
+ _ensureArchiveDir();
99
+ const file = path.join(ARCHIVE_DIR, date + '.json');
100
+ fs.writeFileSync(file, JSON.stringify(out, null, 2) + '\n', 'utf8');
101
+ } catch (e) {
102
+ console.warn('[network] snapshot archive write failed (non-fatal):', e.message);
103
+ }
104
+
105
+ return out;
106
+ }
107
+
108
+ function getSnapshot() {
109
+ const now = Date.now();
110
+ if (_snapshotCache.data && (now - _snapshotCache.ts) < SNAPSHOT_TTL_MS) {
111
+ return _snapshotCache.data;
112
+ }
113
+ const snap = buildSnapshot();
114
+ _snapshotCache = { ts: now, data: snap };
115
+ return snap;
116
+ }
117
+
118
+ router.get('/trusted-domains.json', (req, res) => {
119
+ const snap = getSnapshot();
120
+ res.set('Cache-Control', 'public, max-age=3600, s-maxage=3600');
121
+ res.set('X-WAB-Snapshot-Schema', snap.schema);
122
+ res.set('X-WAB-Snapshot-Hash', snap.content_hash);
123
+ if (snap.signature) res.set('X-WAB-Snapshot-Signature', snap.signature.value);
124
+ res.json(snap);
125
+ });
126
+
127
+ router.get('/trusted-domains.txt', (req, res) => {
128
+ const snap = getSnapshot();
129
+ res.set('Cache-Control', 'public, max-age=3600, s-maxage=3600');
130
+ res.type('text/plain; charset=utf-8');
131
+ res.send(snap.domains.map(d => d.domain).join('\n') + '\n');
132
+ });
133
+
134
+ // ── Daily archive ────────────────────────────────────────────────────
135
+
136
+ function _listArchiveDates() {
137
+ try {
138
+ _ensureArchiveDir();
139
+ return fs.readdirSync(ARCHIVE_DIR)
140
+ .filter(f => /^\d{4}-\d{2}-\d{2}\.json$/.test(f))
141
+ .map(f => f.replace(/\.json$/, ''))
142
+ .sort();
143
+ } catch (_) { return []; }
144
+ }
145
+
146
+ router.get('/trusted-domains/archive.json', (req, res) => {
147
+ // Touch today's snapshot to ensure at least today is archived.
148
+ getSnapshot();
149
+ const dates = _listArchiveDates();
150
+ const manifest = {
151
+ schema: 'wab-trusted-domains-archive/v1',
152
+ generated_at: new Date().toISOString(),
153
+ total: dates.length,
154
+ snapshots: dates.map(d => ({
155
+ date: d,
156
+ url: '/api/trusted-domains/' + d + '.json',
157
+ })),
158
+ };
159
+ const sig = signer.sign(manifest);
160
+ if (sig) manifest.signature = { alg: signer.ALGORITHM, value: sig, key_url: '/api/operator-key.json' };
161
+ res.set('Cache-Control', 'public, max-age=3600, s-maxage=3600');
162
+ res.json(manifest);
163
+ });
164
+
165
+ router.get('/trusted-domains/:date.json', (req, res) => {
166
+ const date = req.params.date;
167
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
168
+ return res.status(400).json({ error: 'invalid_date', hint: 'Use YYYY-MM-DD.' });
169
+ }
170
+ // Serve today live (so a fresh boot doesn't return 404 before the first snapshot).
171
+ if (date === _todayUtc()) {
172
+ return res.json(getSnapshot());
173
+ }
174
+ _ensureArchiveDir();
175
+ const file = path.join(ARCHIVE_DIR, date + '.json');
176
+ if (!fs.existsSync(file)) {
177
+ return res.status(404).json({ error: 'snapshot_not_found', date });
178
+ }
179
+ res.set('Cache-Control', 'public, max-age=86400, s-maxage=86400, immutable');
180
+ res.type('application/json; charset=utf-8');
181
+ res.send(fs.readFileSync(file, 'utf8'));
182
+ });
183
+
184
+ // ── Operator public key ──────────────────────────────────────────────
185
+
186
+ router.get('/operator-key.json', (req, res) => {
187
+ const pub = signer.publicKey();
188
+ if (!pub) {
189
+ return res.status(503).json({ error: 'signing_not_configured' });
190
+ }
191
+ res.set('Cache-Control', 'public, max-age=86400, s-maxage=86400');
192
+ res.json({
193
+ schema: 'wab-operator-key/v1',
194
+ alg: signer.ALGORITHM,
195
+ public_key_b64: pub.b64,
196
+ jwk: pub.jwk,
197
+ issued_at: new Date().toISOString(),
198
+ notice: 'Use this key to verify signatures on /api/trusted-domains.json and /api/trusted-domains/*.json.',
199
+ });
200
+ });
201
+
202
+
203
+ // ── Revocation feeds ─────────────────────────────────────────────────
204
+
205
+ function listRecentRevocations(limit) {
206
+ try {
207
+ return db.prepare(`
208
+ SELECT id, domain, type, reason_code, reason_text,
209
+ decided_at, appeal_deadline, status, updated_at
210
+ FROM site_revocations
211
+ WHERE type IN ('suspended', 'revoked')
212
+ ORDER BY decided_at DESC
213
+ LIMIT ?
214
+ `).all(limit);
215
+ } catch (_) { return []; }
216
+ }
217
+
218
+ function escapeXml(s) {
219
+ return String(s == null ? '' : s)
220
+ .replace(/&/g, '&amp;')
221
+ .replace(/</g, '&lt;')
222
+ .replace(/>/g, '&gt;')
223
+ .replace(/"/g, '&quot;')
224
+ .replace(/'/g, '&apos;');
225
+ }
226
+
227
+ router.get('/transparency/feed.json', (req, res) => {
228
+ const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
229
+ const items = listRecentRevocations(limit);
230
+ res.set('Cache-Control', 'public, max-age=300, s-maxage=300');
231
+ res.json({
232
+ version: 'https://jsonfeed.org/version/1.1',
233
+ title: 'Web Agent Bridge — Revocations Transparency Log',
234
+ home_page_url: 'https://webagentbridge.com/revocations.html',
235
+ feed_url: 'https://webagentbridge.com/api/transparency/feed.json',
236
+ description: 'Live feed of WAB site revocations and suspensions.',
237
+ language: 'en',
238
+ items: items.map(r => ({
239
+ id: r.id,
240
+ url: 'https://webagentbridge.com/revocations.html#' + r.id,
241
+ title: '[' + r.type.toUpperCase() + '] ' + r.domain + ' — ' + (r.reason_code || 'unknown'),
242
+ content_text: (r.reason_text || '') +
243
+ (r.appeal_deadline ? '\nAppeal deadline: ' + r.appeal_deadline : '') +
244
+ '\nStatus: ' + r.status,
245
+ date_published: r.decided_at,
246
+ date_modified: r.updated_at || r.decided_at,
247
+ tags: [r.type, r.reason_code, r.status].filter(Boolean),
248
+ _wab: {
249
+ domain: r.domain,
250
+ type: r.type,
251
+ reason_code: r.reason_code,
252
+ status: r.status,
253
+ appeal_deadline: r.appeal_deadline
254
+ }
255
+ }))
256
+ });
257
+ });
258
+
259
+ router.get('/transparency/feed.xml', (req, res) => {
260
+ const limit = Math.min(parseInt(req.query.limit, 10) || 50, 200);
261
+ const items = listRecentRevocations(limit);
262
+ const updated = items[0] && items[0].decided_at
263
+ ? new Date(items[0].decided_at).toISOString()
264
+ : new Date().toISOString();
265
+
266
+ const entries = items.map(r => {
267
+ const url = 'https://webagentbridge.com/revocations.html#' + r.id;
268
+ const published = new Date(r.decided_at).toISOString();
269
+ const mod = new Date(r.updated_at || r.decided_at).toISOString();
270
+ const title = '[' + r.type.toUpperCase() + '] ' + r.domain + ' — ' + (r.reason_code || 'unknown');
271
+ const summary = (r.reason_text || '') +
272
+ (r.appeal_deadline ? ' Appeal deadline: ' + r.appeal_deadline + '.' : '') +
273
+ ' Status: ' + r.status + '.';
274
+ return [
275
+ ' <entry>',
276
+ ' <id>tag:webagentbridge.com,2026:' + r.id + '</id>',
277
+ ' <title>' + escapeXml(title) + '</title>',
278
+ ' <link rel="alternate" href="' + url + '"/>',
279
+ ' <published>' + published + '</published>',
280
+ ' <updated>' + mod + '</updated>',
281
+ ' <category term="' + escapeXml(r.type) + '"/>',
282
+ ' <category term="' + escapeXml(r.reason_code || '') + '"/>',
283
+ ' <summary>' + escapeXml(summary) + '</summary>',
284
+ ' </entry>'
285
+ ].join('\n');
286
+ }).join('\n');
287
+
288
+ const xml = '<?xml version="1.0" encoding="utf-8"?>\n' +
289
+ '<feed xmlns="http://www.w3.org/2005/Atom">\n' +
290
+ ' <id>https://webagentbridge.com/api/transparency/feed.xml</id>\n' +
291
+ ' <title>Web Agent Bridge — Revocations Transparency Log</title>\n' +
292
+ ' <updated>' + updated + '</updated>\n' +
293
+ ' <link rel="self" href="https://webagentbridge.com/api/transparency/feed.xml"/>\n' +
294
+ ' <link rel="alternate" href="https://webagentbridge.com/revocations.html"/>\n' +
295
+ (entries ? entries + '\n' : '') +
296
+ '</feed>\n';
297
+
298
+ res.set('Cache-Control', 'public, max-age=300, s-maxage=300');
299
+ res.type('application/atom+xml; charset=utf-8');
300
+ res.send(xml);
301
+ });
302
+
303
+ module.exports = router;
304
+ module.exports._buildSnapshot = buildSnapshot;
305
+ module.exports._listRecentRevocations = listRecentRevocations;
306
+ module.exports.__resetCache = function () { _snapshotCache = { ts: 0, data: null }; };
@@ -0,0 +1,98 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Operator signing service — Ed25519 + RFC 8785.
5
+ *
6
+ * Loads WAB_OPERATOR_ED25519_PRIV (PKCS8 base64-DER) once and exposes:
7
+ * sign(payload) — returns base64 Ed25519 signature over canonicalize(payload).
8
+ * publicKey() — returns the operator's public key as {b64, jwk} (raw 32-byte b64 + JWK).
9
+ * isConfigured() — whether a signing key is available.
10
+ * ALGORITHM — 'ed25519' constant.
11
+ *
12
+ * The same private key is already used by services/revocations.js to sign revocation
13
+ * decisions; this module unifies usage so other surfaces (snapshots, manifests) can
14
+ * sign with the same identity.
15
+ */
16
+
17
+ const crypto = require('crypto');
18
+ const { canonicalize } = require('./canonical-json');
19
+
20
+ const ALGORITHM = 'ed25519';
21
+ const PRIV_B64 = process.env.WAB_OPERATOR_ED25519_PRIV || '';
22
+
23
+ let _priv = null;
24
+ let _pub = null;
25
+
26
+ function _load() {
27
+ if (_priv || !PRIV_B64) return;
28
+ try {
29
+ const der = Buffer.from(PRIV_B64, 'base64');
30
+ _priv = crypto.createPrivateKey({ key: der, format: 'der', type: 'pkcs8' });
31
+ const pubKey = crypto.createPublicKey(_priv);
32
+ const rawDer = pubKey.export({ format: 'der', type: 'spki' });
33
+ // SPKI for Ed25519 is 44 bytes; raw key is the last 32.
34
+ const raw = rawDer.slice(-32);
35
+ _pub = {
36
+ b64: raw.toString('base64'),
37
+ jwk: {
38
+ kty: 'OKP',
39
+ crv: 'Ed25519',
40
+ x: raw.toString('base64url'),
41
+ alg: 'EdDSA',
42
+ use: 'sig',
43
+ },
44
+ };
45
+ } catch (e) {
46
+ console.warn('[operator-signer] key load failed (non-fatal):', e.message);
47
+ }
48
+ }
49
+
50
+ function isConfigured() {
51
+ _load();
52
+ return !!_priv;
53
+ }
54
+
55
+ function sign(payload) {
56
+ _load();
57
+ if (!_priv) return null;
58
+ try {
59
+ const sig = crypto.sign(null, Buffer.from(canonicalize(payload), 'utf8'), _priv);
60
+ return sig.toString('base64');
61
+ } catch (e) {
62
+ console.warn('[operator-signer] sign failed:', e.message);
63
+ return null;
64
+ }
65
+ }
66
+
67
+ function publicKey() {
68
+ _load();
69
+ return _pub;
70
+ }
71
+
72
+ /**
73
+ * Verify a signature against a payload using the operator's public key.
74
+ * Returns true/false. Convenience helper for tests and self-verification.
75
+ */
76
+ function verify(payload, signatureB64) {
77
+ _load();
78
+ if (!_pub) return false;
79
+ try {
80
+ const der = Buffer.from(PRIV_B64, 'base64');
81
+ const priv = crypto.createPrivateKey({ key: der, format: 'der', type: 'pkcs8' });
82
+ const pubKey = crypto.createPublicKey(priv);
83
+ return crypto.verify(
84
+ null,
85
+ Buffer.from(canonicalize(payload), 'utf8'),
86
+ pubKey,
87
+ Buffer.from(signatureB64, 'base64')
88
+ );
89
+ } catch (_) { return false; }
90
+ }
91
+
92
+ module.exports = {
93
+ ALGORITHM,
94
+ sign,
95
+ verify,
96
+ publicKey,
97
+ isConfigured,
98
+ };