web-agent-bridge 3.4.0 → 3.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (315) hide show
  1. package/LICENSE +84 -84
  2. package/README.ar.md +1565 -1304
  3. package/README.md +171 -298
  4. package/bin/agent-runner.js +474 -474
  5. package/bin/cli.js +237 -237
  6. package/bin/wab-init.js +244 -223
  7. package/bin/wab.js +80 -80
  8. package/examples/azure-dns-wab.js +83 -83
  9. package/examples/bidi-agent.js +119 -119
  10. package/examples/cloudflare-wab-dns.js +121 -121
  11. package/examples/cpanel-wab-dns.js +114 -114
  12. package/examples/cross-site-agent.js +91 -91
  13. package/examples/dns-discovery-agent.js +166 -166
  14. package/examples/gcp-dns-wab.js +76 -76
  15. package/examples/governance-agent.js +169 -169
  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 -103
  19. package/examples/puppeteer-agent.js +108 -108
  20. package/examples/route53-wab-dns.js +144 -144
  21. package/examples/saas-dashboard/README.md +55 -55
  22. package/examples/safe-mode-agent.js +96 -96
  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 -74
  27. package/examples/wab-verify.js +60 -60
  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 -28
  34. package/public/activate.html +448 -368
  35. package/public/adopt.html +236 -0
  36. package/public/adoption-metrics.html +188 -188
  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/atp.html +171 -0
  41. package/public/azure-dns-integration.html +289 -289
  42. package/public/browser.html +486 -486
  43. package/public/cloudflare-integration.html +380 -380
  44. package/public/commander-dashboard.html +243 -243
  45. package/public/cookies.html +210 -210
  46. package/public/cpanel-integration.html +398 -398
  47. package/public/css/agent-workspace.css +1713 -1713
  48. package/public/css/premium.css +317 -317
  49. package/public/css/styles.css +1401 -1263
  50. package/public/dashboard-shieldlink.html +295 -0
  51. package/public/dashboard.html +711 -707
  52. package/public/dns.html +436 -436
  53. package/public/docs.html +588 -588
  54. package/public/enterprise-mesh.ar.html +80 -0
  55. package/public/enterprise-mesh.html +81 -0
  56. package/public/feed.xml +89 -89
  57. package/public/gcp-dns-integration.html +318 -318
  58. package/public/governance.ar.html +70 -0
  59. package/public/governance.html +69 -0
  60. package/public/growth.html +465 -465
  61. package/public/index.html +1372 -1266
  62. package/public/integrations.html +556 -556
  63. package/public/js/activate.js +449 -145
  64. package/public/js/agent-workspace.js +1740 -1740
  65. package/public/js/auth-nav.js +117 -65
  66. package/public/js/auth-redirect.js +12 -12
  67. package/public/js/cookie-consent.js +56 -56
  68. package/public/js/dns.js +438 -438
  69. package/public/js/wab-demo-page.js +721 -721
  70. package/public/js/ws-client.js +74 -74
  71. package/public/l-preview.html +242 -0
  72. package/public/llms-full.txt +360 -360
  73. package/public/llms.txt +125 -125
  74. package/public/login.html +85 -85
  75. package/public/mesh-dashboard.html +328 -328
  76. package/public/milestones.html +346 -0
  77. package/public/one-click.html +779 -0
  78. package/public/openapi.json +669 -669
  79. package/public/partners.ar.html +145 -0
  80. package/public/partners.html +143 -0
  81. package/public/phone-shield.html +281 -281
  82. package/public/plesk-integration.html +375 -375
  83. package/public/premium-dashboard.html +2489 -2489
  84. package/public/premium.html +793 -793
  85. package/public/privacy.html +297 -297
  86. package/public/provider-onboarding.html +172 -172
  87. package/public/provider-sandbox.html +134 -134
  88. package/public/providers.html +359 -359
  89. package/public/refusals.html +172 -0
  90. package/public/register.html +105 -105
  91. package/public/registrar-integrations.html +141 -141
  92. package/public/ring4.html +292 -0
  93. package/public/robots.txt +99 -99
  94. package/public/route53-integration.html +531 -531
  95. package/public/score.html +263 -0
  96. package/public/script/wab-consent.d.ts +36 -36
  97. package/public/script/wab-consent.js +104 -104
  98. package/public/script/wab-schema.js +131 -131
  99. package/public/script/wab.d.ts +108 -108
  100. package/public/script/wab.min.js +580 -580
  101. package/public/security.txt +8 -8
  102. package/public/shieldlink.html +244 -0
  103. package/public/shieldqr.html +231 -231
  104. package/public/sitemap.xml +13 -1
  105. package/public/terms.html +256 -256
  106. package/public/trust-graph-api.ar.html +92 -0
  107. package/public/trust-graph-api.html +91 -0
  108. package/public/wab-features.html +560 -0
  109. package/public/wab-trust.html +200 -200
  110. package/public/wab-truth.html +375 -0
  111. package/public/wab-vs-protocols.html +210 -210
  112. package/public/whitepaper.html +449 -449
  113. package/script/ai-agent-bridge.js +1754 -1754
  114. package/sdk/README.md +99 -99
  115. package/sdk/agent-mesh.js +449 -449
  116. package/sdk/atp.js +103 -0
  117. package/sdk/auto-discovery.js +301 -288
  118. package/sdk/commander.js +262 -262
  119. package/sdk/governance.js +262 -262
  120. package/sdk/index.d.ts +464 -464
  121. package/sdk/index.js +653 -649
  122. package/sdk/multi-agent.js +318 -318
  123. package/sdk/safe-mode.js +221 -221
  124. package/sdk/safety-shield.js +219 -219
  125. package/sdk/schema-discovery.js +83 -83
  126. package/server/adapters/index.js +520 -520
  127. package/server/config/plans.js +412 -367
  128. package/server/config/secrets.js +102 -102
  129. package/server/control-plane/index.js +301 -301
  130. package/server/data-plane/index.js +354 -354
  131. package/server/index.js +793 -670
  132. package/server/llm/index.js +404 -404
  133. package/server/middleware/adminAuth.js +35 -35
  134. package/server/middleware/api-tier.js +170 -0
  135. package/server/middleware/auth.js +50 -50
  136. package/server/middleware/featureGate.js +88 -88
  137. package/server/middleware/rateLimits.js +100 -100
  138. package/server/middleware/sensitiveAction.js +157 -157
  139. package/server/middleware/wab-trust.js +141 -0
  140. package/server/migrations/001_add_analytics_indexes.sql +7 -7
  141. package/server/migrations/002_premium_features.sql +418 -418
  142. package/server/migrations/003_ads_integer_cents.sql +33 -33
  143. package/server/migrations/004_agent_os.sql +158 -158
  144. package/server/migrations/005_marketplace_metering.sql +126 -126
  145. package/server/migrations/006_growth_suite.sql +138 -0
  146. package/server/migrations/007_governance.sql +106 -106
  147. package/server/migrations/008_plans.sql +144 -144
  148. package/server/migrations/009_shieldqr.sql +30 -30
  149. package/server/migrations/010_extended_trust.sql +33 -33
  150. package/server/migrations/011_outreach.sql +47 -0
  151. package/server/migrations/012_shieldlink.sql +116 -0
  152. package/server/migrations/013_ct_monitor.sql +13 -0
  153. package/server/migrations/014_wab_advanced_features.sql +128 -0
  154. package/server/migrations/015_wab_truth_layer.sql +101 -0
  155. package/server/migrations/016_ring4_external_trust.sql +84 -0
  156. package/server/migrations/017_ring4_extensions.sql +69 -0
  157. package/server/migrations/018_commercial_foundations.sql +167 -0
  158. package/server/migrations/019_unify_tier_constraints.sql +133 -0
  159. package/server/migrations/020_agent_transaction_primitive.sql +119 -0
  160. package/server/models/adapters/index.js +33 -33
  161. package/server/models/adapters/mysql.js +183 -183
  162. package/server/models/adapters/postgresql.js +172 -172
  163. package/server/models/adapters/sqlite.js +7 -7
  164. package/server/models/db.js +740 -740
  165. package/server/observability/failure-analysis.js +337 -337
  166. package/server/observability/index.js +394 -394
  167. package/server/protocol/capabilities.js +223 -223
  168. package/server/protocol/index.js +243 -243
  169. package/server/protocol/schema.js +584 -584
  170. package/server/registry/certification.js +271 -271
  171. package/server/registry/index.js +326 -326
  172. package/server/routes/activate.js +478 -0
  173. package/server/routes/admin-outreach.js +239 -0
  174. package/server/routes/admin-plans.js +76 -76
  175. package/server/routes/admin-premium.js +674 -673
  176. package/server/routes/admin-shieldlink.js +137 -0
  177. package/server/routes/admin-shieldqr.js +90 -90
  178. package/server/routes/admin-trust-monitor.js +139 -83
  179. package/server/routes/admin.js +550 -549
  180. package/server/routes/adopt.js +61 -0
  181. package/server/routes/ads.js +130 -130
  182. package/server/routes/agent-workspace.js +540 -540
  183. package/server/routes/api-keys.js +127 -0
  184. package/server/routes/api.js +150 -150
  185. package/server/routes/auth.js +71 -71
  186. package/server/routes/billing.js +57 -57
  187. package/server/routes/commander.js +316 -316
  188. package/server/routes/customer-shieldlink.js +133 -0
  189. package/server/routes/demo-showcase.js +332 -332
  190. package/server/routes/demo-store.js +154 -154
  191. package/server/routes/diagnose.js +373 -0
  192. package/server/routes/discovery.js +2348 -2348
  193. package/server/routes/enterprise-mesh.js +170 -0
  194. package/server/routes/gateway.js +173 -173
  195. package/server/routes/governance-saas.js +203 -0
  196. package/server/routes/governance.js +208 -208
  197. package/server/routes/growth.js +1048 -0
  198. package/server/routes/intent.js +328 -0
  199. package/server/routes/license.js +251 -251
  200. package/server/routes/mesh.js +469 -469
  201. package/server/routes/noscript.js +543 -543
  202. package/server/routes/partners.js +201 -0
  203. package/server/routes/plans.js +33 -33
  204. package/server/routes/premium-v2.js +686 -686
  205. package/server/routes/premium.js +724 -724
  206. package/server/routes/providers.js +650 -650
  207. package/server/routes/reputation.js +411 -0
  208. package/server/routes/ring4.js +885 -0
  209. package/server/routes/runtime.js +2148 -2148
  210. package/server/routes/shieldlink.js +70 -0
  211. package/server/routes/shieldqr.js +88 -88
  212. package/server/routes/sovereign.js +465 -465
  213. package/server/routes/transactions.js +233 -0
  214. package/server/routes/truth-layer.js +670 -0
  215. package/server/routes/universal.js +200 -200
  216. package/server/routes/unsubscribe.js +51 -0
  217. package/server/routes/wab-api.js +850 -850
  218. package/server/routes/wab-cache.js +282 -0
  219. package/server/runtime/container-worker.js +111 -111
  220. package/server/runtime/container.js +448 -448
  221. package/server/runtime/distributed-worker.js +362 -362
  222. package/server/runtime/event-bus.js +210 -210
  223. package/server/runtime/index.js +253 -253
  224. package/server/runtime/queue.js +599 -599
  225. package/server/runtime/replay.js +666 -666
  226. package/server/runtime/sandbox.js +266 -266
  227. package/server/runtime/scheduler.js +534 -534
  228. package/server/runtime/session-engine.js +293 -293
  229. package/server/runtime/state-manager.js +188 -188
  230. package/server/secrets/wab-signing-key.pem +3 -0
  231. package/server/secrets/wab-signing-pub.pem +3 -0
  232. package/server/security/cross-site-redactor.js +196 -196
  233. package/server/security/dry-run.js +180 -180
  234. package/server/security/human-gate-rate-limit.js +147 -147
  235. package/server/security/human-gate-transports.js +178 -178
  236. package/server/security/human-gate.js +281 -281
  237. package/server/security/index.js +368 -368
  238. package/server/security/intent-engine.js +245 -245
  239. package/server/security/reward-guard.js +171 -171
  240. package/server/security/rollback-store.js +239 -239
  241. package/server/security/token-scope.js +404 -404
  242. package/server/security/url-policy.js +139 -139
  243. package/server/services/adoption-agent.js +182 -0
  244. package/server/services/agent-chat.js +506 -506
  245. package/server/services/agent-learning.js +601 -601
  246. package/server/services/agent-memory.js +625 -625
  247. package/server/services/agent-mesh.js +555 -555
  248. package/server/services/agent-symphony.js +717 -717
  249. package/server/services/agent-tasks.js +1807 -1807
  250. package/server/services/api-key-engine.js +292 -292
  251. package/server/services/cluster.js +894 -894
  252. package/server/services/commander.js +738 -738
  253. package/server/services/edge-compute.js +440 -440
  254. package/server/services/email.js +233 -233
  255. package/server/services/fairness-engine.js +409 -0
  256. package/server/services/fairness.js +420 -0
  257. package/server/services/governance.js +466 -466
  258. package/server/services/hosted-runtime.js +205 -205
  259. package/server/services/lfd.js +635 -635
  260. package/server/services/local-ai.js +389 -389
  261. package/server/services/marketplace.js +270 -270
  262. package/server/services/metering.js +182 -182
  263. package/server/services/modules/affiliate-intelligence.js +93 -93
  264. package/server/services/modules/agent-firewall.js +90 -90
  265. package/server/services/modules/bounty.js +89 -89
  266. package/server/services/modules/collective-bargaining.js +92 -92
  267. package/server/services/modules/dark-pattern.js +66 -66
  268. package/server/services/modules/gov-intelligence.js +45 -45
  269. package/server/services/modules/neural.js +55 -55
  270. package/server/services/modules/notary.js +49 -49
  271. package/server/services/modules/price-time-machine.js +86 -86
  272. package/server/services/modules/protocol.js +104 -104
  273. package/server/services/negotiation.js +439 -439
  274. package/server/services/outreach-agent.js +312 -0
  275. package/server/services/plans.js +214 -214
  276. package/server/services/plugins.js +771 -771
  277. package/server/services/price-intelligence.js +566 -566
  278. package/server/services/price-shield.js +1137 -1137
  279. package/server/services/provider-clients.js +740 -740
  280. package/server/services/reputation.js +465 -465
  281. package/server/services/search-engine.js +357 -357
  282. package/server/services/security.js +513 -513
  283. package/server/services/self-healing.js +843 -843
  284. package/server/services/shieldlink.js +492 -0
  285. package/server/services/shieldqr.js +322 -322
  286. package/server/services/sovereign-shield.js +542 -542
  287. package/server/services/ssl-ct-monitor.js +224 -0
  288. package/server/services/ssl-inspector.js +42 -42
  289. package/server/services/ssl-monitor.js +167 -167
  290. package/server/services/stripe.js +206 -205
  291. package/server/services/swarm.js +788 -788
  292. package/server/services/transactions.js +525 -0
  293. package/server/services/universal-scraper.js +662 -662
  294. package/server/services/verification.js +481 -481
  295. package/server/services/vision.js +1163 -1163
  296. package/server/services/wab-crypto.js +178 -178
  297. package/server/utils/cache.js +125 -125
  298. package/server/utils/migrate.js +81 -81
  299. package/server/utils/safe-fetch.js +228 -228
  300. package/server/utils/secureFields.js +50 -50
  301. package/server/ws.js +161 -161
  302. package/templates/artisan-marketplace.yaml +104 -104
  303. package/templates/book-price-scout.yaml +98 -98
  304. package/templates/electronics-price-tracker.yaml +108 -108
  305. package/templates/flight-deal-hunter.yaml +113 -113
  306. package/templates/freelancer-direct.yaml +116 -116
  307. package/templates/grocery-price-compare.yaml +93 -93
  308. package/templates/hotel-direct-booking.yaml +113 -113
  309. package/templates/local-services.yaml +98 -98
  310. package/templates/olive-oil-tunisia.yaml +88 -88
  311. package/templates/organic-farm-fresh.yaml +101 -101
  312. package/templates/restaurant-direct.yaml +97 -97
  313. package/templates/ring4/banking-sovereign.yaml +55 -0
  314. package/templates/ring4/ecommerce-sovereign.yaml +58 -0
  315. 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 } };