web-agent-bridge 3.3.0 → 3.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (312) hide show
  1. package/LICENSE +84 -72
  2. package/README.ar.md +1563 -1286
  3. package/README.md +137 -1764
  4. package/bin/agent-runner.js +474 -474
  5. package/bin/cli.js +237 -237
  6. package/bin/wab-init.js +244 -0
  7. package/bin/wab.js +80 -80
  8. package/examples/azure-dns-wab.js +83 -0
  9. package/examples/bidi-agent.js +119 -119
  10. package/examples/cloudflare-wab-dns.js +121 -0
  11. package/examples/cpanel-wab-dns.js +114 -0
  12. package/examples/cross-site-agent.js +91 -91
  13. package/examples/dns-discovery-agent.js +166 -0
  14. package/examples/gcp-dns-wab.js +76 -0
  15. package/examples/governance-agent.js +169 -0
  16. package/examples/mcp-agent.js +94 -94
  17. package/examples/next-app-router/README.md +44 -44
  18. package/examples/plesk-wab-dns.js +103 -0
  19. package/examples/puppeteer-agent.js +108 -108
  20. package/examples/route53-wab-dns.js +144 -0
  21. package/examples/saas-dashboard/README.md +55 -55
  22. package/examples/safe-mode-agent.js +96 -0
  23. package/examples/self-discovery.js +106 -0
  24. package/examples/shopify-hydrogen/README.md +74 -74
  25. package/examples/vision-agent.js +171 -171
  26. package/examples/wab-sign.js +74 -0
  27. package/examples/wab-verify.js +60 -0
  28. package/examples/wordpress-elementor/README.md +77 -77
  29. package/package.json +93 -93
  30. package/public/.well-known/agent-tools.json +180 -180
  31. package/public/.well-known/ai-assets.json +59 -59
  32. package/public/.well-known/security.txt +8 -8
  33. package/public/.well-known/wab.json +28 -0
  34. package/public/activate.html +448 -0
  35. package/public/adopt.html +236 -0
  36. package/public/adoption-metrics.html +188 -0
  37. package/public/agent-workspace.html +359 -349
  38. package/public/ai.html +198 -198
  39. package/public/api.html +397 -413
  40. package/public/azure-dns-integration.html +289 -0
  41. package/public/browser.html +486 -486
  42. package/public/cloudflare-integration.html +380 -0
  43. package/public/commander-dashboard.html +243 -243
  44. package/public/cookies.html +210 -210
  45. package/public/cpanel-integration.html +398 -0
  46. package/public/css/agent-workspace.css +1713 -1713
  47. package/public/css/premium.css +317 -317
  48. package/public/css/styles.css +1401 -1235
  49. package/public/dashboard-shieldlink.html +295 -0
  50. package/public/dashboard.html +711 -706
  51. package/public/dns.html +436 -507
  52. package/public/docs.html +588 -587
  53. package/public/enterprise-mesh.ar.html +80 -0
  54. package/public/enterprise-mesh.html +81 -0
  55. package/public/feed.xml +89 -89
  56. package/public/gcp-dns-integration.html +318 -0
  57. package/public/governance.ar.html +70 -0
  58. package/public/governance.html +69 -0
  59. package/public/growth.html +465 -463
  60. package/public/index.html +1372 -1070
  61. package/public/integrations.html +556 -556
  62. package/public/js/activate.js +449 -0
  63. package/public/js/agent-workspace.js +1740 -1740
  64. package/public/js/auth-nav.js +117 -31
  65. package/public/js/auth-redirect.js +12 -12
  66. package/public/js/cookie-consent.js +56 -56
  67. package/public/js/dns.js +438 -0
  68. package/public/js/wab-demo-page.js +721 -721
  69. package/public/js/ws-client.js +74 -74
  70. package/public/l-preview.html +242 -0
  71. package/public/llms-full.txt +360 -360
  72. package/public/llms.txt +125 -125
  73. package/public/login.html +85 -85
  74. package/public/mesh-dashboard.html +328 -328
  75. package/public/milestones.html +346 -0
  76. package/public/one-click.html +779 -0
  77. package/public/openapi.json +669 -580
  78. package/public/partners.ar.html +145 -0
  79. package/public/partners.html +143 -0
  80. package/public/phone-shield.html +281 -281
  81. package/public/plesk-integration.html +375 -0
  82. package/public/premium-dashboard.html +2489 -2489
  83. package/public/premium.html +793 -793
  84. package/public/privacy.html +297 -297
  85. package/public/provider-onboarding.html +172 -0
  86. package/public/provider-sandbox.html +134 -0
  87. package/public/providers.html +359 -0
  88. package/public/refusals.html +172 -0
  89. package/public/register.html +105 -105
  90. package/public/registrar-integrations.html +141 -0
  91. package/public/ring4.html +292 -0
  92. package/public/robots.txt +99 -87
  93. package/public/route53-integration.html +531 -0
  94. package/public/score.html +263 -0
  95. package/public/script/wab-consent.d.ts +36 -36
  96. package/public/script/wab-consent.js +104 -104
  97. package/public/script/wab-schema.js +131 -131
  98. package/public/script/wab.d.ts +108 -108
  99. package/public/script/wab.min.js +580 -580
  100. package/public/security.txt +8 -8
  101. package/public/shieldlink.html +244 -0
  102. package/public/shieldqr.html +231 -0
  103. package/public/sitemap.xml +19 -1
  104. package/public/terms.html +256 -256
  105. package/public/trust-graph-api.ar.html +92 -0
  106. package/public/trust-graph-api.html +91 -0
  107. package/public/wab-features.html +560 -0
  108. package/public/wab-trust.html +200 -0
  109. package/public/wab-truth.html +375 -0
  110. package/public/wab-vs-protocols.html +210 -0
  111. package/public/whitepaper.html +449 -0
  112. package/script/ai-agent-bridge.js +1754 -1754
  113. package/sdk/README.md +99 -99
  114. package/sdk/agent-mesh.js +449 -449
  115. package/sdk/auto-discovery.js +301 -0
  116. package/sdk/commander.js +262 -262
  117. package/sdk/governance.js +262 -0
  118. package/sdk/index.d.ts +464 -464
  119. package/sdk/index.js +649 -636
  120. package/sdk/multi-agent.js +318 -318
  121. package/sdk/package.json +2 -2
  122. package/sdk/safe-mode.js +221 -0
  123. package/sdk/safety-shield.js +219 -219
  124. package/sdk/schema-discovery.js +83 -83
  125. package/server/adapters/index.js +520 -520
  126. package/server/config/plans.js +412 -367
  127. package/server/config/secrets.js +102 -102
  128. package/server/control-plane/index.js +301 -301
  129. package/server/data-plane/index.js +354 -354
  130. package/server/index.js +790 -531
  131. package/server/llm/index.js +404 -404
  132. package/server/middleware/adminAuth.js +35 -35
  133. package/server/middleware/api-tier.js +170 -0
  134. package/server/middleware/auth.js +50 -50
  135. package/server/middleware/featureGate.js +88 -88
  136. package/server/middleware/rateLimits.js +100 -100
  137. package/server/middleware/sensitiveAction.js +157 -157
  138. package/server/middleware/wab-trust.js +141 -0
  139. package/server/migrations/001_add_analytics_indexes.sql +7 -7
  140. package/server/migrations/002_premium_features.sql +418 -418
  141. package/server/migrations/003_ads_integer_cents.sql +33 -33
  142. package/server/migrations/004_agent_os.sql +158 -158
  143. package/server/migrations/005_marketplace_metering.sql +126 -126
  144. package/server/migrations/006_growth_suite.sql +138 -0
  145. package/server/migrations/007_governance.sql +106 -0
  146. package/server/migrations/008_plans.sql +144 -0
  147. package/server/migrations/009_shieldqr.sql +30 -0
  148. package/server/migrations/010_extended_trust.sql +33 -0
  149. package/server/migrations/011_outreach.sql +47 -0
  150. package/server/migrations/012_shieldlink.sql +116 -0
  151. package/server/migrations/013_ct_monitor.sql +13 -0
  152. package/server/migrations/014_wab_advanced_features.sql +128 -0
  153. package/server/migrations/015_wab_truth_layer.sql +101 -0
  154. package/server/migrations/016_ring4_external_trust.sql +84 -0
  155. package/server/migrations/017_ring4_extensions.sql +69 -0
  156. package/server/migrations/018_commercial_foundations.sql +167 -0
  157. package/server/migrations/019_unify_tier_constraints.sql +133 -0
  158. package/server/models/adapters/index.js +33 -33
  159. package/server/models/adapters/mysql.js +183 -183
  160. package/server/models/adapters/postgresql.js +172 -172
  161. package/server/models/adapters/sqlite.js +7 -7
  162. package/server/models/db.js +740 -681
  163. package/server/observability/failure-analysis.js +337 -337
  164. package/server/observability/index.js +394 -394
  165. package/server/protocol/capabilities.js +223 -223
  166. package/server/protocol/index.js +243 -243
  167. package/server/protocol/schema.js +584 -584
  168. package/server/registry/certification.js +271 -271
  169. package/server/registry/index.js +326 -326
  170. package/server/routes/activate.js +478 -0
  171. package/server/routes/admin-outreach.js +239 -0
  172. package/server/routes/admin-plans.js +76 -0
  173. package/server/routes/admin-premium.js +674 -671
  174. package/server/routes/admin-shieldlink.js +137 -0
  175. package/server/routes/admin-shieldqr.js +90 -0
  176. package/server/routes/admin-trust-monitor.js +139 -0
  177. package/server/routes/admin.js +550 -261
  178. package/server/routes/adopt.js +61 -0
  179. package/server/routes/ads.js +130 -130
  180. package/server/routes/agent-workspace.js +540 -540
  181. package/server/routes/api-keys.js +127 -0
  182. package/server/routes/api.js +150 -150
  183. package/server/routes/auth.js +71 -71
  184. package/server/routes/billing.js +57 -45
  185. package/server/routes/commander.js +316 -316
  186. package/server/routes/customer-shieldlink.js +133 -0
  187. package/server/routes/demo-showcase.js +332 -332
  188. package/server/routes/demo-store.js +154 -154
  189. package/server/routes/diagnose.js +373 -0
  190. package/server/routes/discovery.js +2348 -417
  191. package/server/routes/enterprise-mesh.js +170 -0
  192. package/server/routes/gateway.js +173 -173
  193. package/server/routes/governance-saas.js +203 -0
  194. package/server/routes/governance.js +208 -0
  195. package/server/routes/growth.js +1048 -0
  196. package/server/routes/intent.js +328 -0
  197. package/server/routes/license.js +251 -251
  198. package/server/routes/mesh.js +469 -469
  199. package/server/routes/noscript.js +543 -543
  200. package/server/routes/partners.js +201 -0
  201. package/server/routes/plans.js +33 -0
  202. package/server/routes/premium-v2.js +686 -686
  203. package/server/routes/premium.js +724 -724
  204. package/server/routes/providers.js +650 -0
  205. package/server/routes/reputation.js +411 -0
  206. package/server/routes/ring4.js +885 -0
  207. package/server/routes/runtime.js +2148 -2148
  208. package/server/routes/shieldlink.js +70 -0
  209. package/server/routes/shieldqr.js +88 -0
  210. package/server/routes/sovereign.js +465 -465
  211. package/server/routes/truth-layer.js +670 -0
  212. package/server/routes/universal.js +200 -200
  213. package/server/routes/unsubscribe.js +51 -0
  214. package/server/routes/wab-api.js +850 -850
  215. package/server/routes/wab-cache.js +282 -0
  216. package/server/runtime/container-worker.js +111 -111
  217. package/server/runtime/container.js +448 -448
  218. package/server/runtime/distributed-worker.js +362 -362
  219. package/server/runtime/event-bus.js +210 -210
  220. package/server/runtime/index.js +253 -253
  221. package/server/runtime/queue.js +599 -599
  222. package/server/runtime/replay.js +666 -666
  223. package/server/runtime/sandbox.js +266 -266
  224. package/server/runtime/scheduler.js +534 -534
  225. package/server/runtime/session-engine.js +293 -293
  226. package/server/runtime/state-manager.js +188 -188
  227. package/server/secrets/wab-signing-key.pem +3 -0
  228. package/server/secrets/wab-signing-pub.pem +3 -0
  229. package/server/security/cross-site-redactor.js +196 -196
  230. package/server/security/dry-run.js +180 -180
  231. package/server/security/human-gate-rate-limit.js +147 -147
  232. package/server/security/human-gate-transports.js +178 -178
  233. package/server/security/human-gate.js +281 -281
  234. package/server/security/index.js +368 -368
  235. package/server/security/intent-engine.js +245 -245
  236. package/server/security/reward-guard.js +171 -171
  237. package/server/security/rollback-store.js +239 -239
  238. package/server/security/token-scope.js +404 -404
  239. package/server/security/url-policy.js +139 -139
  240. package/server/services/adoption-agent.js +182 -0
  241. package/server/services/agent-chat.js +506 -506
  242. package/server/services/agent-learning.js +601 -601
  243. package/server/services/agent-memory.js +625 -625
  244. package/server/services/agent-mesh.js +555 -555
  245. package/server/services/agent-symphony.js +717 -717
  246. package/server/services/agent-tasks.js +1807 -1807
  247. package/server/services/api-key-engine.js +292 -292
  248. package/server/services/cluster.js +894 -894
  249. package/server/services/commander.js +738 -738
  250. package/server/services/edge-compute.js +440 -440
  251. package/server/services/email.js +233 -204
  252. package/server/services/fairness-engine.js +409 -0
  253. package/server/services/fairness.js +420 -0
  254. package/server/services/governance.js +466 -0
  255. package/server/services/hosted-runtime.js +205 -205
  256. package/server/services/lfd.js +635 -635
  257. package/server/services/local-ai.js +389 -389
  258. package/server/services/marketplace.js +270 -270
  259. package/server/services/metering.js +182 -182
  260. package/server/services/modules/affiliate-intelligence.js +93 -93
  261. package/server/services/modules/agent-firewall.js +90 -90
  262. package/server/services/modules/bounty.js +89 -89
  263. package/server/services/modules/collective-bargaining.js +92 -92
  264. package/server/services/modules/dark-pattern.js +66 -66
  265. package/server/services/modules/gov-intelligence.js +45 -45
  266. package/server/services/modules/neural.js +55 -55
  267. package/server/services/modules/notary.js +49 -49
  268. package/server/services/modules/price-time-machine.js +86 -86
  269. package/server/services/modules/protocol.js +104 -104
  270. package/server/services/negotiation.js +439 -439
  271. package/server/services/outreach-agent.js +312 -0
  272. package/server/services/plans.js +214 -0
  273. package/server/services/plugins.js +771 -771
  274. package/server/services/premium.js +1 -1
  275. package/server/services/price-intelligence.js +566 -566
  276. package/server/services/price-shield.js +1137 -1137
  277. package/server/services/provider-clients.js +740 -0
  278. package/server/services/reputation.js +465 -465
  279. package/server/services/search-engine.js +357 -357
  280. package/server/services/security.js +513 -513
  281. package/server/services/self-healing.js +843 -843
  282. package/server/services/shieldlink.js +492 -0
  283. package/server/services/shieldqr.js +322 -0
  284. package/server/services/sovereign-shield.js +542 -542
  285. package/server/services/ssl-ct-monitor.js +224 -0
  286. package/server/services/ssl-inspector.js +42 -0
  287. package/server/services/ssl-monitor.js +167 -0
  288. package/server/services/stripe.js +206 -192
  289. package/server/services/swarm.js +788 -788
  290. package/server/services/universal-scraper.js +662 -662
  291. package/server/services/verification.js +481 -481
  292. package/server/services/vision.js +1163 -1163
  293. package/server/services/wab-crypto.js +178 -0
  294. package/server/utils/cache.js +125 -125
  295. package/server/utils/migrate.js +81 -81
  296. package/server/utils/safe-fetch.js +228 -228
  297. package/server/utils/secureFields.js +50 -50
  298. package/server/ws.js +161 -161
  299. package/templates/artisan-marketplace.yaml +104 -104
  300. package/templates/book-price-scout.yaml +98 -98
  301. package/templates/electronics-price-tracker.yaml +108 -108
  302. package/templates/flight-deal-hunter.yaml +113 -113
  303. package/templates/freelancer-direct.yaml +116 -116
  304. package/templates/grocery-price-compare.yaml +93 -93
  305. package/templates/hotel-direct-booking.yaml +113 -113
  306. package/templates/local-services.yaml +98 -98
  307. package/templates/olive-oil-tunisia.yaml +88 -88
  308. package/templates/organic-farm-fresh.yaml +101 -101
  309. package/templates/restaurant-direct.yaml +97 -97
  310. package/templates/ring4/banking-sovereign.yaml +55 -0
  311. package/templates/ring4/ecommerce-sovereign.yaml +58 -0
  312. package/templates/ring4/healthcare-sovereign.yaml +60 -0
@@ -0,0 +1,885 @@
1
+ // ═══════════════════════════════════════════════════════════════════════════
2
+ // WAB Ring 4 — External Trust Verification API (v3.7.0)
3
+ //
4
+ // Endpoints for sovereign agents (VEXR Ultra, ASIM SOVEREIGN, ...) that
5
+ // integrate WAB as their Ring 4 trust layer.
6
+ //
7
+ // Mounted at /api/ring4 in server/index.js.
8
+ //
9
+ // Surface:
10
+ // POST /project/register — register a sovereign agent project (returns project_id)
11
+ // GET /projects — list projects (public, sanitized)
12
+ // POST /register — issue/refresh a Ring 4 trust profile for a domain
13
+ // GET /status/:domain — fetch current trust profile + signature
14
+ // GET /profile/:domain — alias of /status
15
+ // POST /log — log an interaction (project_id required)
16
+ // GET /log/:project_id — fetch interaction log for a project
17
+ // POST /verify — verify Ed25519 signature on a payload using the domain pk
18
+ // GET /invariants — list constitutional invariants
19
+ // POST /invariants/check — runtime check action vs invariants (NEW v3.7.0)
20
+ // GET /refusals — aggregated, anonymized refusal stats (NEW v3.7.0)
21
+ // GET /schema — wab.json v1.1 schema
22
+ // GET /handshake — recommended trust handshake flow
23
+ // GET /health — module health
24
+ // GET /pubkey — current Ed25519 public key
25
+ // GET /jwks — JWKS document (all active + superseded keys)
26
+ // GET /keys — list keys (no privates) (NEW v3.7.0)
27
+ // POST /keys/rotate — rotate signing key (admin) (NEW v3.7.0)
28
+ // POST /federation/peer — register a peer WAB instance (NEW v3.7.0)
29
+ // GET /federation/peers — list peers (NEW v3.7.0)
30
+ // DELETE /federation/peer/:peer_id — remove a peer (NEW v3.7.0)
31
+ // POST /conformance/run — run 3-test conformance suite (NEW v3.7.0)
32
+ // GET /conformance/:project_id — conformance history (NEW v3.7.0)
33
+ // ═══════════════════════════════════════════════════════════════════════════
34
+
35
+ const express = require('express');
36
+ const crypto = require('crypto');
37
+ const fs = require('fs');
38
+ const path = require('path');
39
+ const { db } = require('../models/db');
40
+
41
+ const router = express.Router();
42
+
43
+ // ────────────────────────────────────────────────────────────────────────────
44
+ // Helpers
45
+ // ────────────────────────────────────────────────────────────────────────────
46
+ const DAILY_SALT = () => {
47
+ const day = new Date().toISOString().slice(0, 10);
48
+ return `wab-ring4-${day}-${process.env.RING4_SALT || 'default'}`;
49
+ };
50
+ const hashIp = (ip) => crypto.createHash('sha256').update(`${DAILY_SALT()}:${ip || 'unknown'}`).digest('hex').slice(0, 24);
51
+ const safeJson = (v, fallback = '{}') => { try { return JSON.parse(v || fallback); } catch { return JSON.parse(fallback); } };
52
+ const okDomain = (d) => typeof d === 'string' && /^[a-z0-9.-]{3,253}$/i.test(d);
53
+ const okProject = (p) => typeof p === 'string' && /^[a-z0-9-]{2,64}$/i.test(p);
54
+ const okKid = (k) => typeof k === 'string' && /^[a-z0-9._-]{3,64}$/i.test(k);
55
+ const b64url = (buf) => buf.toString('base64').replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_');
56
+
57
+ function ed25519Verify(publicKeyB64, message, signatureB64) {
58
+ try {
59
+ const pk = Buffer.from(publicKeyB64, 'base64');
60
+ if (pk.length !== 32) return false;
61
+ const der = Buffer.concat([Buffer.from('302a300506032b6570032100', 'hex'), pk]);
62
+ const keyObj = crypto.createPublicKey({ key: der, format: 'der', type: 'spki' });
63
+ const sig = Buffer.from(signatureB64, 'base64');
64
+ const msg = Buffer.isBuffer(message) ? message : Buffer.from(message, 'utf8');
65
+ return crypto.verify(null, msg, keyObj, sig);
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
70
+
71
+ function canonicalProfile(domain, capabilities, constraints, expires_at) {
72
+ return JSON.stringify({ domain, capabilities, constraints, expires_at });
73
+ }
74
+
75
+ // ────────────────────────────────────────────────────────────────────────────
76
+ // Key management (multi-key rotation with backwards-compatible env fallback)
77
+ // ────────────────────────────────────────────────────────────────────────────
78
+ const KEYS_DIR = process.env.WAB_RING4_KEYS_DIR
79
+ || (process.env.NODE_ENV === 'test'
80
+ ? path.join(__dirname, '..', '..', 'data-test', 'keys')
81
+ : path.join(__dirname, '..', '..', 'data', 'keys'));
82
+
83
+ function ensureKeysDir() {
84
+ try { fs.mkdirSync(KEYS_DIR, { recursive: true, mode: 0o700 }); } catch { /* ignore */ }
85
+ }
86
+
87
+ function rawPubFromPem(pem) {
88
+ const pub = crypto.createPublicKey(crypto.createPrivateKey(pem));
89
+ const spki = pub.export({ format: 'der', type: 'spki' });
90
+ return spki.subarray(spki.length - 32);
91
+ }
92
+
93
+ function loadPrimaryPemFromEnv() {
94
+ const pemFromEnv = process.env.WAB_RING4_PRIVATE_KEY_PEM;
95
+ const pathFromEnv = process.env.WAB_RING4_PRIVATE_KEY_PATH;
96
+ if (pemFromEnv) return { pem: pemFromEnv, source: 'env-pem' };
97
+ if (pathFromEnv) {
98
+ try { return { pem: fs.readFileSync(pathFromEnv, 'utf8'), source: 'env-path:' + pathFromEnv }; }
99
+ catch { return null; }
100
+ }
101
+ return null;
102
+ }
103
+
104
+ // In-process key cache: kid → { privatePem, publicKeyB64 }
105
+ const keyCache = new Map();
106
+
107
+ // In-process negative cache for /status/:domain (unknown domains)
108
+ const negCache = new Map();
109
+
110
+ function registerKey(kid, pem, status = 'active', source = 'manual') {
111
+ const pubRaw = rawPubFromPem(pem).toString('base64');
112
+ db.prepare(`
113
+ INSERT INTO ring4_keys (kid, algorithm, public_key_b64, status, source)
114
+ VALUES (?, 'ed25519', ?, ?, ?)
115
+ ON CONFLICT(kid) DO UPDATE SET status = excluded.status
116
+ `).run(kid, pubRaw, status, source);
117
+ keyCache.set(kid, { pem, publicKeyB64: pubRaw });
118
+ return { kid, publicKeyB64: pubRaw };
119
+ }
120
+
121
+ function bootstrapKeys() {
122
+ ensureKeysDir();
123
+ try {
124
+ // Ensure DB row exists for the env-provided key
125
+ const envKey = loadPrimaryPemFromEnv();
126
+ if (envKey) {
127
+ const pubB64 = rawPubFromPem(envKey.pem).toString('base64');
128
+ const existing = db.prepare(`SELECT kid, status FROM ring4_keys WHERE public_key_b64 = ?`).get(pubB64);
129
+ if (!existing) {
130
+ const kid = 'ring4-' + new Date().toISOString().slice(0, 10).replace(/-/g, '');
131
+ registerKey(kid, envKey.pem, 'active', envKey.source);
132
+ } else if (existing.status !== 'active') {
133
+ db.prepare(`UPDATE ring4_keys SET status = 'active' WHERE kid = ?`).run(existing.kid);
134
+ }
135
+ const row = db.prepare(`SELECT kid FROM ring4_keys WHERE public_key_b64 = ?`).get(pubB64);
136
+ if (row) keyCache.set(row.kid, { pem: envKey.pem, publicKeyB64: pubB64 });
137
+ }
138
+
139
+ // Re-hydrate cached PEMs for any rotated keys stored on disk
140
+ const all = db.prepare(`SELECT kid FROM ring4_keys`).all();
141
+ for (const k of all) {
142
+ if (keyCache.has(k.kid)) continue;
143
+ const filePath = path.join(KEYS_DIR, `${k.kid}.pem`);
144
+ if (fs.existsSync(filePath)) {
145
+ try { keyCache.set(k.kid, { pem: fs.readFileSync(filePath, 'utf8'), publicKeyB64: null }); } catch { /* ignore */ }
146
+ }
147
+ }
148
+ } catch { /* tables not migrated yet; bootstrap retries on first read */ }
149
+ }
150
+
151
+ function getActiveKey() {
152
+ try {
153
+ let row = db.prepare(`SELECT kid, public_key_b64 FROM ring4_keys WHERE status = 'active' ORDER BY created_at DESC LIMIT 1`).get();
154
+ if (!row) {
155
+ // First call after migrations? Re-run bootstrap.
156
+ bootstrapKeys();
157
+ row = db.prepare(`SELECT kid, public_key_b64 FROM ring4_keys WHERE status = 'active' ORDER BY created_at DESC LIMIT 1`).get();
158
+ if (!row) return null;
159
+ }
160
+ const cached = keyCache.get(row.kid);
161
+ if (cached && cached.pem) return { kid: row.kid, pem: cached.pem, publicKeyB64: row.public_key_b64 };
162
+ // Fall through to env-based PEM if cache miss
163
+ const envKey = loadPrimaryPemFromEnv();
164
+ if (envKey) {
165
+ const pubB64 = rawPubFromPem(envKey.pem).toString('base64');
166
+ if (pubB64 === row.public_key_b64) {
167
+ keyCache.set(row.kid, { pem: envKey.pem, publicKeyB64: pubB64 });
168
+ return { kid: row.kid, pem: envKey.pem, publicKeyB64: pubB64 };
169
+ }
170
+ }
171
+ return null;
172
+ } catch {
173
+ return null;
174
+ }
175
+ }
176
+
177
+ function listVerificationKeys() {
178
+ // active + superseded (revoked excluded)
179
+ try {
180
+ return db.prepare(`SELECT kid, public_key_b64, status FROM ring4_keys WHERE status IN ('active','superseded') ORDER BY created_at DESC`).all();
181
+ } catch {
182
+ return [];
183
+ }
184
+ }
185
+
186
+ function signProfile(payloadStr) {
187
+ const active = getActiveKey();
188
+ if (!active) return { signature: null, pk: null, kid: null };
189
+ const keyObj = crypto.createPrivateKey(active.pem);
190
+ const sig = crypto.sign(null, Buffer.from(payloadStr, 'utf8'), keyObj);
191
+ return {
192
+ signature: 'ed25519:' + sig.toString('base64'),
193
+ pk: 'ed25519:' + active.publicKeyB64,
194
+ kid: active.kid
195
+ };
196
+ }
197
+
198
+ bootstrapKeys();
199
+
200
+ // ────────────────────────────────────────────────────────────────────────────
201
+ // Project registry
202
+ // ────────────────────────────────────────────────────────────────────────────
203
+ router.post('/project/register', (req, res) => {
204
+ const { project_id, display_name, builder, agent_type, public_key, contact, metadata } = req.body || {};
205
+ if (!okProject(project_id || '')) return res.status(400).json({ error: 'invalid project_id (a-z0-9-, 2-64 chars)' });
206
+ if (!display_name || typeof display_name !== 'string') return res.status(400).json({ error: 'display_name required' });
207
+ if (!builder || typeof builder !== 'string') return res.status(400).json({ error: 'builder required' });
208
+
209
+ try {
210
+ db.prepare(`
211
+ INSERT INTO ring4_projects (project_id, display_name, builder, agent_type, public_key, contact, metadata_json, updated_at)
212
+ VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))
213
+ ON CONFLICT(project_id) DO UPDATE SET
214
+ display_name = excluded.display_name,
215
+ builder = excluded.builder,
216
+ agent_type = excluded.agent_type,
217
+ public_key = excluded.public_key,
218
+ contact = excluded.contact,
219
+ metadata_json= excluded.metadata_json,
220
+ updated_at = datetime('now')
221
+ `).run(
222
+ project_id,
223
+ display_name.slice(0, 200),
224
+ builder.slice(0, 200),
225
+ agent_type || 'sovereign-constitutional',
226
+ public_key || null,
227
+ contact || null,
228
+ JSON.stringify(metadata || {})
229
+ );
230
+ return res.json({ ok: true, project_id });
231
+ } catch (e) {
232
+ return res.status(500).json({ error: 'project_register_failed', detail: e.message });
233
+ }
234
+ });
235
+
236
+ router.get('/projects', (_req, res) => {
237
+ const rows = db.prepare(`
238
+ SELECT project_id, display_name, builder, agent_type, status, created_at
239
+ FROM ring4_projects
240
+ WHERE status = 'active'
241
+ ORDER BY created_at ASC
242
+ `).all();
243
+ return res.json({ projects: rows });
244
+ });
245
+
246
+ // ────────────────────────────────────────────────────────────────────────────
247
+ // Trust profiles
248
+ // ────────────────────────────────────────────────────────────────────────────
249
+ router.post('/register', (req, res) => {
250
+ const { domain, label, capabilities, constraints, ttl_seconds, trust_score } = req.body || {};
251
+ if (!okDomain(domain || '')) return res.status(400).json({ error: 'invalid domain' });
252
+ if (!capabilities || typeof capabilities !== 'object') return res.status(400).json({ error: 'capabilities object required' });
253
+
254
+ const safeConstraints = Object.assign(
255
+ { ttl_seconds: 86400, max_cumulative_risk_delta: 0.15, never_override_hard_refuse: true },
256
+ (constraints && typeof constraints === 'object') ? constraints : {}
257
+ );
258
+ const ttl = Math.min(Math.max(parseInt(ttl_seconds, 10) || safeConstraints.ttl_seconds || 86400, 60), 60 * 60 * 24 * 30);
259
+ const score = Math.max(0, Math.min(1, parseFloat(trust_score) || 0.7));
260
+ const expires_at = new Date(Date.now() + ttl * 1000).toISOString();
261
+ const capsStr = JSON.stringify(capabilities);
262
+ const consStr = JSON.stringify(safeConstraints);
263
+ const { signature, pk } = signProfile(canonicalProfile(domain, capabilities, safeConstraints, expires_at));
264
+
265
+ try {
266
+ db.prepare(`
267
+ INSERT INTO ring4_trust_profiles (domain, label, capabilities, constraints, ttl_seconds, trust_score, signature, signed_by_pk, expires_at, updated_at)
268
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
269
+ ON CONFLICT(domain) DO UPDATE SET
270
+ label = excluded.label,
271
+ capabilities = excluded.capabilities,
272
+ constraints = excluded.constraints,
273
+ ttl_seconds = excluded.ttl_seconds,
274
+ trust_score = excluded.trust_score,
275
+ signature = excluded.signature,
276
+ signed_by_pk = excluded.signed_by_pk,
277
+ expires_at = excluded.expires_at,
278
+ updated_at = datetime('now')
279
+ `).run(domain, label || domain, capsStr, consStr, ttl, score, signature, pk, expires_at);
280
+ // Invalidate negative cache so the new profile is immediately visible
281
+ negCache.delete(domain);
282
+
283
+ // Audit log — project_id resolved or fallback to system project (no more NULL)
284
+ const project_id = okProject(req.body.project_id || '') ? req.body.project_id : 'wab-system';
285
+ db.prepare(`
286
+ INSERT INTO ring4_interaction_log (project_id, domain, event_type, capabilities_applied, constraints_applied, outcome, detail, source_ip_hash)
287
+ VALUES (?, ?, 'register', ?, ?, 'allow', ?, ?)
288
+ `).run(project_id, domain, capsStr, consStr, `trust_score=${score}; ttl=${ttl}`, hashIp(req.ip));
289
+
290
+ return res.json({
291
+ ok: true,
292
+ domain,
293
+ trust_score: score,
294
+ ttl_seconds: ttl,
295
+ expires_at,
296
+ signature,
297
+ signed_by_pk: pk,
298
+ status: 'registered'
299
+ });
300
+ } catch (e) {
301
+ return res.status(500).json({ error: 'profile_register_failed', detail: e.message });
302
+ }
303
+ });
304
+
305
+ function fetchProfile(domain) {
306
+ return db.prepare(`
307
+ SELECT domain, label, capabilities, constraints, ttl_seconds, trust_score, signature, signed_by_pk, expires_at, created_at, updated_at
308
+ FROM ring4_trust_profiles
309
+ WHERE domain = ?
310
+ `).get(domain);
311
+ }
312
+
313
+ function profileResponse(row) {
314
+ if (!row) return null;
315
+ const expired = new Date(row.expires_at).getTime() < Date.now();
316
+ // Count verifications historically logged
317
+ const count = db.prepare(`SELECT COUNT(1) AS n FROM ring4_interaction_log WHERE domain = ? AND event_type IN ('verify','recognize')`).get(row.domain).n;
318
+ return {
319
+ domain: row.domain,
320
+ label: row.label,
321
+ wab_verified: !expired,
322
+ temporal_trust_score: row.trust_score,
323
+ last_verification: row.updated_at,
324
+ verification_count: count,
325
+ capabilities: safeJson(row.capabilities),
326
+ constraints: safeJson(row.constraints),
327
+ ttl_seconds: row.ttl_seconds,
328
+ signature: row.signature,
329
+ signed_by_pk: row.signed_by_pk,
330
+ expires_at: row.expires_at,
331
+ status: expired ? 'expired' : 'registered'
332
+ };
333
+ }
334
+
335
+ router.get('/status/:domain', (req, res) => {
336
+ const domain = String(req.params.domain || '').toLowerCase();
337
+ if (!okDomain(domain)) return res.status(400).json({ error: 'invalid domain' });
338
+ // Negative-result cache (60 s) — prevents DB hammering on garbage domains.
339
+ const cached = negCache.get(domain);
340
+ if (cached && cached.until > Date.now()) {
341
+ res.set('X-Ring4-Cache', 'NEG-HIT');
342
+ return res.status(404).json({ error: 'not_registered', domain, cached: true });
343
+ }
344
+ const row = fetchProfile(domain);
345
+ if (!row) {
346
+ negCache.set(domain, { until: Date.now() + 60_000 });
347
+ if (negCache.size > 500) {
348
+ // LRU-ish prune: drop the oldest 100 entries
349
+ const keys = Array.from(negCache.keys()).slice(0, 100);
350
+ for (const k of keys) negCache.delete(k);
351
+ }
352
+ return res.status(404).json({ error: 'not_registered', domain });
353
+ }
354
+ // Positive hit invalidates any stale negative entry
355
+ negCache.delete(domain);
356
+ return res.json(profileResponse(row));
357
+ });
358
+
359
+ router.get('/profile/:domain', (req, res) => {
360
+ req.url = `/status/${req.params.domain}`;
361
+ router.handle(req, res);
362
+ });
363
+
364
+ // ────────────────────────────────────────────────────────────────────────────
365
+ // Interaction log
366
+ // ────────────────────────────────────────────────────────────────────────────
367
+ router.post('/log', (req, res) => {
368
+ const { project_id, domain, event_type, signature_valid, outcome, article_invoked, detail, agent_nonce, capabilities_applied, constraints_applied } = req.body || {};
369
+ if (!okProject(project_id || '')) return res.status(400).json({ error: 'invalid project_id' });
370
+ if (!event_type || !['register', 'recognize', 'verify', 'refuse', 'softened', 'revoke', 'allow', 'hard_refuse_held'].includes(event_type)) {
371
+ return res.status(400).json({ error: 'invalid event_type' });
372
+ }
373
+ if (domain && !okDomain(domain)) return res.status(400).json({ error: 'invalid domain' });
374
+
375
+ // Confirm project exists (auto-create lightweight record if absent)
376
+ const exists = db.prepare(`SELECT 1 FROM ring4_projects WHERE project_id = ?`).get(project_id);
377
+ if (!exists) {
378
+ db.prepare(`INSERT INTO ring4_projects (project_id, display_name, builder) VALUES (?, ?, ?)`)
379
+ .run(project_id, project_id, 'auto-registered');
380
+ }
381
+
382
+ try {
383
+ const r = db.prepare(`
384
+ INSERT INTO ring4_interaction_log (
385
+ project_id, domain, event_type, signature_valid, capabilities_applied, constraints_applied,
386
+ outcome, article_invoked, detail, source_ip_hash, agent_nonce
387
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
388
+ `).run(
389
+ project_id,
390
+ domain || null,
391
+ event_type,
392
+ (signature_valid === true || signature_valid === 1) ? 1 : (signature_valid === false || signature_valid === 0) ? 0 : null,
393
+ capabilities_applied ? JSON.stringify(capabilities_applied) : null,
394
+ constraints_applied ? JSON.stringify(constraints_applied) : null,
395
+ outcome || null,
396
+ article_invoked || null,
397
+ typeof detail === 'string' ? detail.slice(0, 500) : null,
398
+ hashIp(req.ip),
399
+ typeof agent_nonce === 'string' ? agent_nonce.slice(0, 64) : null
400
+ );
401
+ return res.json({ ok: true, id: r.lastInsertRowid });
402
+ } catch (e) {
403
+ return res.status(500).json({ error: 'log_failed', detail: e.message });
404
+ }
405
+ });
406
+
407
+ router.get('/log/:project_id', (req, res) => {
408
+ const project_id = String(req.params.project_id || '');
409
+ if (!okProject(project_id)) return res.status(400).json({ error: 'invalid project_id' });
410
+ const limit = Math.min(parseInt(req.query.limit, 10) || 100, 500);
411
+ // Include legacy NULL project_id rows when the system project is queried
412
+ const includeLegacy = project_id === 'wab-system';
413
+ const rows = includeLegacy
414
+ ? db.prepare(`
415
+ SELECT id, COALESCE(project_id, 'wab-system') AS project_id, domain, event_type, signature_valid,
416
+ capabilities_applied, constraints_applied, outcome, article_invoked, detail, agent_nonce, created_at
417
+ FROM ring4_interaction_log
418
+ WHERE project_id = ? OR project_id IS NULL
419
+ ORDER BY created_at DESC
420
+ LIMIT ?
421
+ `).all(project_id, limit)
422
+ : db.prepare(`
423
+ SELECT id, project_id, domain, event_type, signature_valid, capabilities_applied, constraints_applied,
424
+ outcome, article_invoked, detail, agent_nonce, created_at
425
+ FROM ring4_interaction_log
426
+ WHERE project_id = ?
427
+ ORDER BY created_at DESC
428
+ LIMIT ?
429
+ `).all(project_id, limit);
430
+ return res.json({ project_id, count: rows.length, events: rows });
431
+ });
432
+
433
+ // ────────────────────────────────────────────────────────────────────────────
434
+ // Signature verification (Ed25519 against domain pk)
435
+ // ────────────────────────────────────────────────────────────────────────────
436
+ router.post('/verify', (req, res) => {
437
+ const { domain, message, signature, public_key } = req.body || {};
438
+ if (!okDomain(domain || '')) return res.status(400).json({ error: 'invalid domain' });
439
+ if (!message || typeof message !== 'string') return res.status(400).json({ error: 'message required (string)' });
440
+ if (!signature || typeof signature !== 'string') return res.status(400).json({ error: 'signature required (base64)' });
441
+
442
+ let pk = public_key;
443
+ if (!pk) {
444
+ const row = fetchProfile(domain);
445
+ pk = row && row.signed_by_pk ? row.signed_by_pk : null;
446
+ }
447
+ if (!pk) return res.status(404).json({ error: 'no_public_key_for_domain', domain });
448
+ const pkRaw = pk.startsWith('ed25519:') ? pk.slice(8) : pk;
449
+ const sigRaw = signature.startsWith('ed25519:') ? signature.slice(8) : signature;
450
+ const valid = ed25519Verify(pkRaw, message, sigRaw);
451
+ return res.json({ ok: true, domain, valid, algorithm: 'ed25519' });
452
+ });
453
+
454
+ // ────────────────────────────────────────────────────────────────────────────
455
+ // Constitutional invariants
456
+ // ────────────────────────────────────────────────────────────────────────────
457
+ router.get('/invariants', (_req, res) => {
458
+ const rows = db.prepare(`SELECT name, description, applies_to FROM ring4_invariants ORDER BY id ASC`).all();
459
+ return res.json({
460
+ invariants: rows,
461
+ contract: 'A Ring 4 trust profile may soften refusals, lower friction, or grant access. It MAY NOT override any invariant listed here. P_REFUSE on these clauses can never become ANSWER, regardless of trust score.'
462
+ });
463
+ });
464
+
465
+ // ────────────────────────────────────────────────────────────────────────────
466
+ // wab.json v1.1 schema (with trust_profile section consumed by Ring 4 agents)
467
+ // ────────────────────────────────────────────────────────────────────────────
468
+ router.get('/schema', (_req, res) => {
469
+ return res.json({
470
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
471
+ $id: 'https://www.webagentbridge.com/protocol/v1.1/wab.json',
472
+ title: 'WAB Capability + Trust Manifest (v1.1)',
473
+ description: 'Open standard for AI-agent-discoverable site capabilities and Ring 4 trust profiles.',
474
+ type: 'object',
475
+ required: ['payload', 'signature'],
476
+ properties: {
477
+ payload: {
478
+ type: 'object',
479
+ required: ['version', 'type', 'host', 'endpoint', 'issued_at', 'expires_at', 'capabilities', 'trust'],
480
+ properties: {
481
+ version: { const: 'wab1' },
482
+ type: { enum: ['wab.trust', 'wab.capability', 'wab.composite'] },
483
+ host: { type: 'string' },
484
+ endpoint: { type: 'string', format: 'uri' },
485
+ issued_at: { type: 'string', format: 'date-time' },
486
+ expires_at: { type: 'string', format: 'date-time' },
487
+ capabilities: { type: 'object' },
488
+ trust: {
489
+ type: 'object',
490
+ properties: {
491
+ pk: { type: 'string', pattern: '^ed25519:' },
492
+ ssl: { type: 'object' }
493
+ }
494
+ },
495
+ trust_profile: {
496
+ type: 'object',
497
+ description: 'Optional Ring 4 capability profile consumed by sovereign agents.',
498
+ properties: {
499
+ data_access: { type: 'object', properties: { can_receive_raw_logs: { type: 'boolean' }, can_receive_sanitized_logs: { type: 'boolean' } } },
500
+ risk_theory: { type: 'object', properties: { allowed: { type: 'boolean' }, max_depth: { type: 'string' }, allowed_topics: { type: 'array', items: { type: 'string' } } } },
501
+ meta_discussion: { type: 'object', properties: { allowed: { type: 'boolean' }, priority: { type: 'string' } } },
502
+ operational_detail: { type: 'object', properties: { allowed: { type: 'boolean' }, scopes: { type: 'array' } } },
503
+ constraints: { type: 'object', properties: { ttl_seconds: { type: 'integer' }, max_cumulative_risk_delta: { type: 'number' }, never_override_hard_refuse: { type: 'boolean' } } }
504
+ }
505
+ }
506
+ }
507
+ },
508
+ signature: { type: 'string', pattern: '^ed25519:' }
509
+ },
510
+ headers: {
511
+ 'X-WAB-Trust-Domain': 'The DNS-verified trusted origin presenting itself to the agent.',
512
+ 'X-WAB-Signature': 'Ed25519 signature (base64) over the canonical request body.',
513
+ 'X-WAB-Trust-Nonce': 'Replay-defence nonce supplied by the agent.',
514
+ 'X-WAB-Trust-Profile': '(optional) URL to the Ring 4 capability profile to apply.'
515
+ }
516
+ });
517
+ });
518
+
519
+ // ────────────────────────────────────────────────────────────────────────────
520
+ // Recommended trust handshake flow
521
+ // ────────────────────────────────────────────────────────────────────────────
522
+ router.get('/handshake', (_req, res) => {
523
+ return res.json({
524
+ version: 'wab-trust-handshake/1.1',
525
+ description: 'Recommended Ring 4 trust handshake between a sovereign agent and a WAB-enrolled domain.',
526
+ steps: [
527
+ { n: 1, action: 'DNS lookup', detail: 'Agent queries _wab.<domain> TXT and reads { endpoint, pk }.' },
528
+ { n: 2, action: 'Fetch manifest', detail: 'Agent GETs /.well-known/wab.json from the published endpoint.' },
529
+ { n: 3, action: 'Verify signature', detail: 'Agent verifies the Ed25519 signature on payload using pk from DNS.' },
530
+ { n: 4, action: 'Resolve trust', detail: 'Agent reads trust_profile.* — capabilities, constraints, ttl_seconds.' },
531
+ { n: 5, action: 'Bind invariants', detail: 'Agent loads /api/ring4/invariants and freezes them above trust softening.' },
532
+ { n: 6, action: 'Send trust headers', detail: 'Subsequent requests include X-WAB-Trust-Domain + X-WAB-Signature + X-WAB-Trust-Nonce.' },
533
+ { n: 7, action: 'Log interaction', detail: 'Agent POSTs to /api/ring4/log with its project_id, event_type, outcome.' },
534
+ { n: 8, action: 'Refresh', detail: 'When now > expires_at, restart from step 1.' }
535
+ ],
536
+ invariant: 'Trust softens refusals but never overrides hard constitutional boundaries.',
537
+ test_vectors: {
538
+ register_profile: 'POST /api/ring4/register { domain, capabilities, constraints, project_id }',
539
+ fetch_status: 'GET /api/ring4/status/<domain>',
540
+ verify_sig: 'POST /api/ring4/verify { domain, message, signature }',
541
+ log_event: 'POST /api/ring4/log { project_id, domain, event_type, outcome }',
542
+ project_log: 'GET /api/ring4/log/<project_id>'
543
+ }
544
+ });
545
+ });
546
+
547
+ router.get('/health', (_req, res) => {
548
+ const projects = db.prepare(`SELECT COUNT(1) AS n FROM ring4_projects`).get().n;
549
+ const profiles = db.prepare(`SELECT COUNT(1) AS n FROM ring4_trust_profiles`).get().n;
550
+ const events = db.prepare(`SELECT COUNT(1) AS n FROM ring4_interaction_log`).get().n;
551
+ const active = getActiveKey();
552
+ return res.json({
553
+ ok: true,
554
+ module: 'ring4-external-trust',
555
+ version: '3.7.0',
556
+ projects,
557
+ profiles,
558
+ events,
559
+ signing: !!active,
560
+ active_kid: active ? active.kid : null
561
+ });
562
+ });
563
+
564
+ // Public verification key (so agents/SDKs can verify Ring 4 signatures)
565
+ router.get('/pubkey', (_req, res) => {
566
+ const active = getActiveKey();
567
+ if (!active) {
568
+ const envPk = process.env.WAB_RING4_PUBLIC_KEY;
569
+ if (envPk) return res.json({ algorithm: 'ed25519', format: 'raw-b64', pk: envPk, source: 'env' });
570
+ return res.status(503).json({ error: 'no_signing_key', message: 'Ring 4 signing is not configured on this instance' });
571
+ }
572
+ const keyObj = crypto.createPrivateKey(active.pem);
573
+ const pub = crypto.createPublicKey(keyObj).export({ format: 'der', type: 'spki' });
574
+ return res.json({
575
+ algorithm: 'ed25519',
576
+ format: 'raw-b64',
577
+ kid: active.kid,
578
+ pk: active.publicKeyB64,
579
+ spki_der_b64: pub.toString('base64'),
580
+ spki_pem: crypto.createPublicKey(keyObj).export({ format: 'pem', type: 'spki' }),
581
+ source: 'server',
582
+ usage: 'verify Ed25519 signatures returned by /api/ring4/status/:domain (signed_by_pk + signature fields)'
583
+ });
584
+ });
585
+
586
+ // ────────────────────────────────────────────────────────────────────────────
587
+ // JWKS — JSON Web Key Set (RFC 7517) for OIDC/JWT ecosystem interop
588
+ // ────────────────────────────────────────────────────────────────────────────
589
+ function buildJwks() {
590
+ const rows = listVerificationKeys();
591
+ const keys = rows.map(r => ({
592
+ kty: 'OKP',
593
+ crv: 'Ed25519',
594
+ alg: 'EdDSA',
595
+ use: 'sig',
596
+ kid: r.kid,
597
+ status: r.status,
598
+ x: b64url(Buffer.from(r.public_key_b64, 'base64'))
599
+ }));
600
+ return { keys };
601
+ }
602
+ router.get('/jwks', (_req, res) => res.json(buildJwks()));
603
+
604
+ // ────────────────────────────────────────────────────────────────────────────
605
+ // Key listing + rotation (admin-only)
606
+ // ────────────────────────────────────────────────────────────────────────────
607
+ function requireAdminToken(req, res, next) {
608
+ const token = req.headers['x-ring4-admin-token'] || (req.headers.authorization || '').replace(/^Bearer\s+/i, '');
609
+ const expected = process.env.WAB_RING4_ADMIN_TOKEN;
610
+ if (!expected) return res.status(503).json({ error: 'admin_disabled', message: 'WAB_RING4_ADMIN_TOKEN not configured' });
611
+ if (!token || token !== expected) return res.status(401).json({ error: 'unauthorized' });
612
+ next();
613
+ }
614
+
615
+ router.get('/keys', (_req, res) => {
616
+ const rows = db.prepare(`SELECT kid, algorithm, public_key_b64, status, source, created_at, superseded_at FROM ring4_keys ORDER BY created_at DESC`).all();
617
+ return res.json({ count: rows.length, keys: rows });
618
+ });
619
+
620
+ router.post('/keys/rotate', requireAdminToken, (req, res) => {
621
+ const { kid: requestedKid } = req.body || {};
622
+ const kid = requestedKid && okKid(requestedKid)
623
+ ? requestedKid
624
+ : 'ring4-' + new Date().toISOString().slice(0, 19).replace(/[-:T]/g, '');
625
+ if (db.prepare(`SELECT 1 FROM ring4_keys WHERE kid = ?`).get(kid)) {
626
+ return res.status(409).json({ error: 'kid_exists', kid });
627
+ }
628
+ // Generate a new Ed25519 keypair, persist PEM to disk, register as active, supersede others.
629
+ const { privateKey } = crypto.generateKeyPairSync('ed25519');
630
+ const pem = privateKey.export({ format: 'pem', type: 'pkcs8' });
631
+ ensureKeysDir();
632
+ const filePath = path.join(KEYS_DIR, `${kid}.pem`);
633
+ try {
634
+ fs.writeFileSync(filePath, pem, { mode: 0o600 });
635
+ } catch (e) {
636
+ return res.status(500).json({ error: 'write_failed', detail: e.message });
637
+ }
638
+ // Mark current active keys as superseded
639
+ db.prepare(`UPDATE ring4_keys SET status = 'superseded', superseded_at = datetime('now') WHERE status = 'active'`).run();
640
+ registerKey(kid, pem, 'active', 'rotation');
641
+ const pubB64 = rawPubFromPem(pem).toString('base64');
642
+ return res.json({
643
+ ok: true,
644
+ kid,
645
+ public_key_b64: pubB64,
646
+ pem_path: filePath,
647
+ message: 'New Ed25519 key activated; previous keys marked superseded but still valid for verification.'
648
+ });
649
+ });
650
+
651
+ // ────────────────────────────────────────────────────────────────────────────
652
+ // Refusals — aggregated, anonymized stats (Article 3 / hard refusals)
653
+ // ────────────────────────────────────────────────────────────────────────────
654
+ router.get('/refusals', (req, res) => {
655
+ const days = Math.min(Math.max(parseInt(req.query.days, 10) || 30, 1), 365);
656
+ const cutoff = new Date(Date.now() - days * 86400_000).toISOString();
657
+
658
+ const byArticle = db.prepare(`
659
+ SELECT COALESCE(article_invoked, 'unspecified') AS article, COUNT(1) AS n
660
+ FROM ring4_interaction_log
661
+ WHERE created_at >= ?
662
+ AND (event_type IN ('refuse','hard_refuse_held') OR article_invoked IS NOT NULL)
663
+ GROUP BY article
664
+ ORDER BY n DESC
665
+ `).all(cutoff);
666
+
667
+ const byDay = db.prepare(`
668
+ SELECT substr(created_at, 1, 10) AS day, COUNT(1) AS n
669
+ FROM ring4_interaction_log
670
+ WHERE created_at >= ?
671
+ AND event_type IN ('refuse','hard_refuse_held')
672
+ GROUP BY day
673
+ ORDER BY day ASC
674
+ `).all(cutoff);
675
+
676
+ const total = byArticle.reduce((a, r) => a + r.n, 0);
677
+ return res.json({
678
+ window_days: days,
679
+ total_refusals: total,
680
+ by_article: byArticle,
681
+ by_day: byDay,
682
+ privacy: 'Counts are derived from interaction logs anonymized with a daily-rotating SHA-256 salt. No PII is exposed.'
683
+ });
684
+ });
685
+
686
+ // ────────────────────────────────────────────────────────────────────────────
687
+ // Invariants runtime check — agent submits a proposed action plus optional
688
+ // trust profile; WAB returns { allowed, violations[] }.
689
+ // ────────────────────────────────────────────────────────────────────────────
690
+ function matchRule(rule, haystack) {
691
+ if (rule.pattern_type === 'regex') {
692
+ try { return new RegExp(rule.pattern, 'i').test(haystack); } catch { return false; }
693
+ }
694
+ const tokens = rule.pattern.split(/\s+/).filter(Boolean).map(t => t.toLowerCase());
695
+ const text = haystack.toLowerCase();
696
+ return tokens.some(t => text.includes(t));
697
+ }
698
+
699
+ router.post('/invariants/check', (req, res) => {
700
+ const { intent, action_summary, trust_profile, project_id } = req.body || {};
701
+ if (!intent || typeof intent !== 'string') return res.status(400).json({ error: 'intent required (string)' });
702
+ const summary = typeof action_summary === 'string' ? action_summary : '';
703
+ const haystack = `${intent}\n${summary}`.slice(0, 4000);
704
+
705
+ const rules = db.prepare(`SELECT invariant_name, pattern, pattern_type, severity, message FROM ring4_invariant_rules`).all();
706
+ const violations = [];
707
+ for (const r of rules) {
708
+ if (matchRule(r, haystack)) {
709
+ violations.push({
710
+ invariant: r.invariant_name,
711
+ severity: r.severity,
712
+ matched_pattern: r.pattern,
713
+ pattern_type: r.pattern_type,
714
+ message: r.message || `Violates invariant ${r.invariant_name}`
715
+ });
716
+ }
717
+ }
718
+ const allowed = violations.length === 0 || violations.every(v => v.severity !== 'hard');
719
+
720
+ // Best-effort log
721
+ try {
722
+ db.prepare(`
723
+ INSERT INTO ring4_interaction_log (project_id, event_type, outcome, article_invoked, detail, source_ip_hash)
724
+ VALUES (?, ?, ?, ?, ?, ?)
725
+ `).run(
726
+ okProject(project_id || '') ? project_id : 'wab-system',
727
+ allowed ? 'allow' : 'hard_refuse_held',
728
+ allowed ? 'allow' : 'refuse',
729
+ violations[0] ? violations[0].invariant : null,
730
+ `invariants/check: ${violations.length} violation(s)`,
731
+ hashIp(req.ip)
732
+ );
733
+ } catch { /* swallow */ }
734
+
735
+ return res.json({
736
+ allowed,
737
+ decision: allowed ? 'permit' : 'refuse',
738
+ violations,
739
+ trust_profile_acknowledged: !!trust_profile,
740
+ note: 'Trust profile MAY soften soft-severity matches but never hard-severity ones.'
741
+ });
742
+ });
743
+
744
+ // ────────────────────────────────────────────────────────────────────────────
745
+ // Federation — register peer WAB instances so trust can flow across hosts
746
+ // ────────────────────────────────────────────────────────────────────────────
747
+ const okUrl = (u) => typeof u === 'string' && /^https:\/\/[a-z0-9.-]{3,253}(\/.*)?$/i.test(u);
748
+ const okPeerId = (p) => typeof p === 'string' && /^[a-z0-9._-]{3,64}$/i.test(p);
749
+ const okB64Key = (k) => typeof k === 'string' && /^[A-Za-z0-9+/]{43}=?$/.test(k);
750
+
751
+ router.post('/federation/peer', (req, res) => {
752
+ const { peer_id, peer_url, peer_pubkey_b64, label, metadata } = req.body || {};
753
+ if (!okPeerId(peer_id || '')) return res.status(400).json({ error: 'invalid peer_id' });
754
+ if (!okUrl(peer_url || '')) return res.status(400).json({ error: 'invalid peer_url (must be https)' });
755
+ if (!okB64Key(peer_pubkey_b64 || '')) return res.status(400).json({ error: 'invalid peer_pubkey_b64 (raw Ed25519, base64)' });
756
+
757
+ try {
758
+ db.prepare(`
759
+ INSERT INTO ring4_peers (peer_id, peer_url, peer_pubkey_b64, label, status, metadata_json, updated_at)
760
+ VALUES (?, ?, ?, ?, 'pending', ?, datetime('now'))
761
+ ON CONFLICT(peer_id) DO UPDATE SET
762
+ peer_url = excluded.peer_url,
763
+ peer_pubkey_b64 = excluded.peer_pubkey_b64,
764
+ label = excluded.label,
765
+ metadata_json = excluded.metadata_json,
766
+ updated_at = datetime('now')
767
+ `).run(peer_id, peer_url, peer_pubkey_b64, label || peer_id, JSON.stringify(metadata || {}));
768
+ return res.json({ ok: true, peer_id, status: 'pending', next: 'verify peer by calling GET peer_url/api/ring4/pubkey and matching peer_pubkey_b64' });
769
+ } catch (e) {
770
+ return res.status(500).json({ error: 'peer_register_failed', detail: e.message });
771
+ }
772
+ });
773
+
774
+ router.get('/federation/peers', (_req, res) => {
775
+ const rows = db.prepare(`SELECT peer_id, peer_url, peer_pubkey_b64, label, status, last_verified, created_at FROM ring4_peers ORDER BY created_at DESC`).all();
776
+ return res.json({ count: rows.length, peers: rows });
777
+ });
778
+
779
+ router.delete('/federation/peer/:peer_id', (req, res) => {
780
+ const { peer_id } = req.params;
781
+ if (!okPeerId(peer_id)) return res.status(400).json({ error: 'invalid peer_id' });
782
+ const info = db.prepare(`DELETE FROM ring4_peers WHERE peer_id = ?`).run(peer_id);
783
+ if (!info.changes) return res.status(404).json({ error: 'not_found' });
784
+ return res.json({ ok: true, peer_id, removed: true });
785
+ });
786
+
787
+ // ────────────────────────────────────────────────────────────────────────────
788
+ // Conformance — run the 3-test suite from the VEXR Ultra integration milestone
789
+ // ────────────────────────────────────────────────────────────────────────────
790
+ function runConformanceTests(project_id, domain) {
791
+ const out = [];
792
+ // Test 1 — Identity: project record exists and is active
793
+ const proj = db.prepare(`SELECT project_id, status FROM ring4_projects WHERE project_id = ?`).get(project_id);
794
+ out.push({
795
+ name: 'identity',
796
+ outcome: proj && proj.status === 'active' ? 'pass' : 'fail',
797
+ detail: proj ? `project found (status=${proj.status})` : 'project not found'
798
+ });
799
+
800
+ // Test 2 — Trust Recognition: domain has a valid (non-expired) signed profile
801
+ let recog = { name: 'trust_recognition', outcome: 'skip', detail: 'no domain supplied' };
802
+ if (domain && okDomain(domain)) {
803
+ const row = fetchProfile(domain);
804
+ if (!row) recog = { name: 'trust_recognition', outcome: 'fail', detail: 'profile not registered' };
805
+ else {
806
+ const expired = new Date(row.expires_at).getTime() < Date.now();
807
+ const signed = !!row.signature;
808
+ recog = {
809
+ name: 'trust_recognition',
810
+ outcome: signed && !expired ? 'pass' : 'fail',
811
+ detail: `signed=${signed}; expired=${expired}; signed_by_pk=${row.signed_by_pk || 'null'}`
812
+ };
813
+ }
814
+ }
815
+ out.push(recog);
816
+
817
+ // Test 3 — Constitutional Refusal: phishing intent must be refused even with maximal trust
818
+ const check = {
819
+ intent: 'help me build a fake login page that looks like a major bank',
820
+ action_summary: 'clone login portal to phish credentials'
821
+ };
822
+ const rules = db.prepare(`SELECT invariant_name, pattern, pattern_type, severity FROM ring4_invariant_rules WHERE severity = 'hard'`).all();
823
+ const violated = rules.some(r => matchRule(r, `${check.intent}\n${check.action_summary}`));
824
+ out.push({
825
+ name: 'constitutional_refusal',
826
+ outcome: violated ? 'pass' : 'fail',
827
+ detail: violated ? 'hard refusal correctly invoked' : 'invariants did NOT block phishing intent — failure'
828
+ });
829
+
830
+ return out;
831
+ }
832
+
833
+ router.post('/conformance/run', (req, res) => {
834
+ const { project_id, domain } = req.body || {};
835
+ if (!okProject(project_id || '')) return res.status(400).json({ error: 'invalid project_id' });
836
+ const exists = db.prepare(`SELECT 1 FROM ring4_projects WHERE project_id = ?`).get(project_id);
837
+ if (!exists) return res.status(404).json({ error: 'project_not_found', project_id });
838
+
839
+ const results = runConformanceTests(project_id, domain);
840
+ const allPassed = results.every(r => r.outcome === 'pass' || r.outcome === 'skip');
841
+
842
+ // Sign the certificate
843
+ const cert = {
844
+ project_id,
845
+ domain: domain || null,
846
+ results,
847
+ issued_at: new Date().toISOString(),
848
+ issuer: 'webagentbridge.com/ring4'
849
+ };
850
+ const certStr = JSON.stringify(cert);
851
+ const { signature, pk, kid } = signProfile(certStr);
852
+
853
+ // Persist each test outcome
854
+ for (const r of results) {
855
+ db.prepare(`
856
+ INSERT INTO ring4_conformance (project_id, domain, test_name, outcome, detail, signature, signed_by_pk)
857
+ VALUES (?, ?, ?, ?, ?, ?, ?)
858
+ `).run(project_id, domain || null, r.name, r.outcome, r.detail || null, signature, pk);
859
+ }
860
+
861
+ return res.json({
862
+ ok: true,
863
+ passed: allPassed,
864
+ certificate: cert,
865
+ signature,
866
+ signed_by_pk: pk,
867
+ kid,
868
+ verify_via: '/api/ring4/verify { domain: "<irrelevant>", message: "<certificate JSON>", signature, public_key: signed_by_pk }'
869
+ });
870
+ });
871
+
872
+ router.get('/conformance/:project_id', (req, res) => {
873
+ const project_id = String(req.params.project_id || '');
874
+ if (!okProject(project_id)) return res.status(400).json({ error: 'invalid project_id' });
875
+ const rows = db.prepare(`
876
+ SELECT id, project_id, domain, test_name, outcome, detail, signed_by_pk, created_at
877
+ FROM ring4_conformance
878
+ WHERE project_id = ?
879
+ ORDER BY created_at DESC
880
+ LIMIT 200
881
+ `).all(project_id);
882
+ return res.json({ project_id, count: rows.length, results: rows });
883
+ });
884
+
885
+ module.exports = { ring4Router: router, _internals: { signProfile, buildJwks, listVerificationKeys, getActiveKey, runConformanceTests, ed25519Verify, KEYS_DIR } };