triage-ows 1.0.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 (91) hide show
  1. package/README.md +786 -0
  2. package/bin/triage-ows.js +218 -0
  3. package/bin/triage-policy.js +66 -0
  4. package/bin/triage-server.js +4 -0
  5. package/dashboard-dist/assets/index-Dnhi_dJQ.css +2 -0
  6. package/dashboard-dist/assets/index-g_2MwC-o.js +9 -0
  7. package/dashboard-dist/favicon.svg +7 -0
  8. package/dashboard-dist/index.html +16 -0
  9. package/dist/config.d.ts +32 -0
  10. package/dist/config.d.ts.map +1 -0
  11. package/dist/config.js +61 -0
  12. package/dist/config.js.map +1 -0
  13. package/dist/emitter.d.ts +7 -0
  14. package/dist/emitter.d.ts.map +1 -0
  15. package/dist/emitter.js +41 -0
  16. package/dist/emitter.js.map +1 -0
  17. package/dist/index.d.ts +17 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +55 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/scoring/behavior.d.ts +3 -0
  22. package/dist/scoring/behavior.d.ts.map +1 -0
  23. package/dist/scoring/behavior.js +13 -0
  24. package/dist/scoring/behavior.js.map +1 -0
  25. package/dist/scoring/compliance.d.ts +3 -0
  26. package/dist/scoring/compliance.d.ts.map +1 -0
  27. package/dist/scoring/compliance.js +11 -0
  28. package/dist/scoring/compliance.js.map +1 -0
  29. package/dist/scoring/identity.d.ts +3 -0
  30. package/dist/scoring/identity.d.ts.map +1 -0
  31. package/dist/scoring/identity.js +16 -0
  32. package/dist/scoring/identity.js.map +1 -0
  33. package/dist/scoring/index.d.ts +10 -0
  34. package/dist/scoring/index.d.ts.map +1 -0
  35. package/dist/scoring/index.js +35 -0
  36. package/dist/scoring/index.js.map +1 -0
  37. package/dist/scoring/limits.d.ts +7 -0
  38. package/dist/scoring/limits.d.ts.map +1 -0
  39. package/dist/scoring/limits.js +9 -0
  40. package/dist/scoring/limits.js.map +1 -0
  41. package/dist/scoring/network.d.ts +3 -0
  42. package/dist/scoring/network.d.ts.map +1 -0
  43. package/dist/scoring/network.js +16 -0
  44. package/dist/scoring/network.js.map +1 -0
  45. package/dist/scoring/onchain.d.ts +4 -0
  46. package/dist/scoring/onchain.d.ts.map +1 -0
  47. package/dist/scoring/onchain.js +35 -0
  48. package/dist/scoring/onchain.js.map +1 -0
  49. package/dist/scoring/risk.d.ts +3 -0
  50. package/dist/scoring/risk.d.ts.map +1 -0
  51. package/dist/scoring/risk.js +22 -0
  52. package/dist/scoring/risk.js.map +1 -0
  53. package/dist/server.d.ts +6 -0
  54. package/dist/server.d.ts.map +1 -0
  55. package/dist/server.js +405 -0
  56. package/dist/server.js.map +1 -0
  57. package/dist/store.d.ts +13 -0
  58. package/dist/store.d.ts.map +1 -0
  59. package/dist/store.js +177 -0
  60. package/dist/store.js.map +1 -0
  61. package/dist/types.d.ts +107 -0
  62. package/dist/types.d.ts.map +1 -0
  63. package/dist/types.js +26 -0
  64. package/dist/types.js.map +1 -0
  65. package/dist/webbotauth.d.ts +38 -0
  66. package/dist/webbotauth.d.ts.map +1 -0
  67. package/dist/webbotauth.js +120 -0
  68. package/dist/webbotauth.js.map +1 -0
  69. package/dist/xmtp.d.ts +6 -0
  70. package/dist/xmtp.d.ts.map +1 -0
  71. package/dist/xmtp.js +161 -0
  72. package/dist/xmtp.js.map +1 -0
  73. package/package.json +58 -0
  74. package/policy-template.json +14 -0
  75. package/src/config.ts +86 -0
  76. package/src/emitter.ts +40 -0
  77. package/src/index.ts +18 -0
  78. package/src/scoring/behavior.ts +15 -0
  79. package/src/scoring/compliance.ts +12 -0
  80. package/src/scoring/identity.ts +12 -0
  81. package/src/scoring/index.ts +31 -0
  82. package/src/scoring/limits.ts +10 -0
  83. package/src/scoring/network.ts +18 -0
  84. package/src/scoring/onchain.ts +44 -0
  85. package/src/scoring/risk.ts +25 -0
  86. package/src/server.ts +410 -0
  87. package/src/store.ts +197 -0
  88. package/src/types.ts +137 -0
  89. package/src/webbotauth.ts +128 -0
  90. package/src/xmtp.ts +188 -0
  91. package/triage.config.example.json +22 -0
package/src/emitter.ts ADDED
@@ -0,0 +1,40 @@
1
+ import { WebSocketServer, WebSocket } from 'ws'
2
+ import type { IncomingMessage } from 'http'
3
+ import type { Duplex } from 'stream'
4
+ import type { TriageEvent } from './types'
5
+
6
+ let wss: WebSocketServer | null = null
7
+ const clients = new Set<WebSocket>()
8
+
9
+ export function startWebSocketServer(port: number) {
10
+ wss = new WebSocketServer({ port })
11
+ wireConnections()
12
+ }
13
+
14
+ export function attachWebSocketToServer(server: { on: (event: string, handler: (...args: any[]) => void) => void }) {
15
+ wss = new WebSocketServer({ noServer: true })
16
+ wireConnections()
17
+ server.on('upgrade', (request: IncomingMessage, socket: Duplex, head: Buffer) => {
18
+ const url = new URL(request.url || '/', `http://${request.headers.host}`)
19
+ if (url.pathname === '/ws') {
20
+ wss!.handleUpgrade(request, socket, head, (ws) => wss!.emit('connection', ws, request))
21
+ } else {
22
+ socket.destroy()
23
+ }
24
+ })
25
+ }
26
+
27
+ function wireConnections() {
28
+ if (!wss) return
29
+ wss.on('connection', (ws) => {
30
+ clients.add(ws)
31
+ ws.on('close', () => clients.delete(ws))
32
+ })
33
+ }
34
+
35
+ export function emitEvent(event: TriageEvent) {
36
+ const data = JSON.stringify(event)
37
+ for (const client of clients) {
38
+ if (client.readyState === WebSocket.OPEN) client.send(data)
39
+ }
40
+ }
package/src/index.ts ADDED
@@ -0,0 +1,18 @@
1
+ export { app, startServer, BASE_SEPOLIA, USDC_BASE_SEPOLIA } from './server'
2
+ export { computeTrustScore } from './scoring/index'
3
+ export { getSpendingLimits } from './scoring/limits'
4
+ export { identityScore } from './scoring/identity'
5
+ export { onChainScore, refreshOnChainData } from './scoring/onchain'
6
+ export { behaviorScore } from './scoring/behavior'
7
+ export { complianceScore } from './scoring/compliance'
8
+ export { networkScore } from './scoring/network'
9
+ export { riskPenalty } from './scoring/risk'
10
+ export { getOrCreateAgent, getAgent, getAllAgents, getTopAgents, recordApproval, recordDenial, recordOverride, addVerifiedHuman, isVerifiedHuman, setOWSWallet, setWebBotAuthVerified } from './store'
11
+ export { verifyWebBotAuth, registerAgentPublicKey, parseSignatureInput } from './webbotauth'
12
+ export { emitEvent, startWebSocketServer, attachWebSocketToServer } from './emitter'
13
+ // XMTP exports are intentionally omitted — @xmtp/node-sdk requires native bindings.
14
+ // Import lazily: const xmtp = await import('triage-ows/dist/xmtp')
15
+ export { loadConfig, getConfig } from './config'
16
+ export type { TriageConfig } from './config'
17
+ export { getTierForScore, SPENDING_TIERS, updateSpendingTiers } from './types'
18
+ export type { PolicyContext, PolicyResult, TrustBreakdown, SpendingTier, AgentProfile, PolicyDecisionEvent, TrustChangeEvent, BudgetWarningEvent, TriageEvent } from './types'
@@ -0,0 +1,15 @@
1
+ import type { AgentProfile } from '../types'
2
+
3
+ export function behaviorScore(agent: AgentProfile): number {
4
+ const successRate = Math.min(5, (agent.successfulRequests / Math.max(1, agent.totalRequests)) * 5)
5
+
6
+ const now = Date.now()
7
+ const last60s = agent.requestTimestamps.filter(t => now - t < 60_000).length
8
+ const pacing = last60s > 15 ? 0 : last60s > 5 ? 2 : 5
9
+
10
+ const cleanDays = Math.min(5, agent.consecutiveCleanDays * 0.5)
11
+
12
+ const concentration = agent.counterparties.length >= 5 ? 5 : agent.counterparties.length >= 2 ? 2 : 0
13
+
14
+ return Math.min(20, successRate + pacing + cleanDays + concentration)
15
+ }
@@ -0,0 +1,12 @@
1
+ import type { AgentProfile } from '../types'
2
+
3
+ export function complianceScore(agent: AgentProfile): number {
4
+ const total = agent.totalApproved + agent.totalDenied
5
+ const approvalRate = Math.min(5, (agent.totalApproved / Math.max(1, total)) * 5)
6
+
7
+ const streak = Math.min(5, agent.consecutiveApprovals * 0.25)
8
+
9
+ const overrideFreq = 5 - Math.min(5, agent.humanOverrides * 1.67)
10
+
11
+ return Math.min(15, approvalRate + streak + overrideFreq)
12
+ }
@@ -0,0 +1,12 @@
1
+ import type { AgentProfile } from '../types'
2
+
3
+ export function identityScore(agent: AgentProfile): number {
4
+ let score = 4 // base: fresh wallet
5
+
6
+ if (agent.totalRequests > 0) score = 12 // funded with tx history
7
+ if (agent.isOWSWallet && agent.totalApproved > 0) score = 20 // OWS wallet with policy trail
8
+ if (agent.webBotAuthVerified) score += 4 // +4 request-level crypto identity
9
+ if (agent.worldIdVerified) score += 11 // +11 human verified
10
+
11
+ return Math.min(35, score)
12
+ }
@@ -0,0 +1,31 @@
1
+ import type { AgentProfile, TrustBreakdown } from '../types'
2
+ import { identityScore } from './identity'
3
+ import { onChainScore } from './onchain'
4
+ import { behaviorScore } from './behavior'
5
+ import { complianceScore } from './compliance'
6
+ import { networkScore } from './network'
7
+ import { riskPenalty } from './risk'
8
+
9
+ export function computeTrustScore(
10
+ agent: AgentProfile,
11
+ getAgent: (addr: string) => AgentProfile | undefined
12
+ ): TrustBreakdown {
13
+ const identity = identityScore(agent)
14
+ const onChain = onChainScore(agent)
15
+ const behavior = behaviorScore(agent)
16
+ const compliance = complianceScore(agent)
17
+ const network = networkScore(agent, getAgent)
18
+ const risk = riskPenalty(agent)
19
+
20
+ const total = Math.max(0, Math.min(100, identity + onChain + behavior + compliance + network - risk))
21
+
22
+ return { identity, onChain, behavior, compliance, network, risk, total }
23
+ }
24
+
25
+ export { identityScore } from './identity'
26
+ export { onChainScore } from './onchain'
27
+ export { behaviorScore } from './behavior'
28
+ export { complianceScore } from './compliance'
29
+ export { networkScore } from './network'
30
+ export { riskPenalty } from './risk'
31
+ export { getSpendingLimits } from './limits'
@@ -0,0 +1,10 @@
1
+ import { getTierForScore, type SpendingTier } from '../types'
2
+
3
+ export function getSpendingLimits(score: number): {
4
+ tier: SpendingTier
5
+ dailyLimit: number
6
+ perTxLimit: number
7
+ } {
8
+ const tier = getTierForScore(score)
9
+ return { tier, dailyLimit: tier.dailyLimit, perTxLimit: tier.perTxLimit }
10
+ }
@@ -0,0 +1,18 @@
1
+ import type { AgentProfile } from '../types'
2
+ import { getConfig } from '../config'
3
+
4
+ export function networkScore(
5
+ agent: AgentProfile,
6
+ getAgent: (addr: string) => AgentProfile | undefined
7
+ ): number {
8
+ if (!getConfig().networkScore?.enabled) return 0
9
+
10
+ const known = agent.counterparties
11
+ .map(addr => getAgent(addr))
12
+ .filter((a): a is AgentProfile => a !== undefined)
13
+
14
+ if (known.length === 0) return 0
15
+
16
+ const avg = known.reduce((sum, a) => sum + a.trustScore, 0) / known.length
17
+ return Math.min(5, avg / 20)
18
+ }
@@ -0,0 +1,44 @@
1
+ import type { AgentProfile } from '../types'
2
+ import { createPublicClient, http } from 'viem'
3
+ import { baseSepolia } from 'viem/chains'
4
+
5
+ const CACHE_TTL = 10 * 60 * 1000 // 10 minutes
6
+
7
+ interface CachedBalance {
8
+ balance: number // ETH balance in wei as a number (>0 means funded)
9
+ fetchedAt: number
10
+ }
11
+
12
+ const balanceCache = new Map<string, CachedBalance>()
13
+
14
+ function getRpcClient() {
15
+ return createPublicClient({
16
+ chain: baseSepolia,
17
+ transport: http(process.env.BASE_SEPOLIA_RPC_URL || 'https://sepolia.base.org'),
18
+ })
19
+ }
20
+
21
+ export async function refreshOnChainData(address: string): Promise<void> {
22
+ try {
23
+ const client = getRpcClient()
24
+ const raw = await client.getBalance({ address: address as `0x${string}` })
25
+ balanceCache.set(address, { balance: Number(raw), fetchedAt: Date.now() })
26
+ } catch {
27
+ // On failure, keep old cached value (or leave absent — score will default to 0)
28
+ }
29
+ }
30
+
31
+ export function onChainScore(agent: AgentProfile): number {
32
+ const monthsOld = (Date.now() - agent.createdAt) / (30 * 24 * 60 * 60 * 1000)
33
+ const age = Math.min(5, monthsOld * 0.5)
34
+
35
+ const txCount = Math.min(5, Math.log10(Math.max(1, agent.totalRequests)) * 2.5)
36
+
37
+ const diversity = Math.min(5, (agent.counterparties.length / 10) * 5)
38
+
39
+ const cached = balanceCache.get(agent.address)
40
+ const fresh = cached && (Date.now() - cached.fetchedAt) < CACHE_TTL
41
+ const balance = fresh ? (cached.balance > 0 ? 5 : 0) : 0
42
+
43
+ return Math.min(20, age + txCount + diversity + balance)
44
+ }
@@ -0,0 +1,25 @@
1
+ import type { AgentProfile } from '../types'
2
+ import { getTierForScore } from '../types'
3
+ import { getConfig } from '../config'
4
+
5
+ export function riskPenalty(agent: AgentProfile): number {
6
+ const cfg = getConfig().scoring
7
+ const now = Date.now()
8
+ const last60s = agent.requestTimestamps.filter(t => now - t < 60_000).length
9
+ const maxFreqPenalty = cfg?.maxFrequencyPenalty ?? 10
10
+ const freqSpike = last60s > 15 ? maxFreqPenalty : last60s > 10 ? Math.min(7, maxFreqPenalty) : last60s > 5 ? Math.min(3, maxFreqPenalty) : 0
11
+
12
+ const failedTx = Math.min(5, agent.failedRequests * 2)
13
+
14
+ const hoursInactive = (now - agent.lastActive) / (60 * 60 * 1000)
15
+ const decayRate = cfg?.inactivityDecayRate ?? 0.5
16
+ const inactivity = Math.min(5, hoursInactive * decayRate)
17
+
18
+ const dailyLimit = getTierForScore(agent.trustScore).dailyLimit
19
+ const spendRatio = dailyLimit > 0 ? agent.dailySpent / dailyLimit : 0
20
+ const spendPressure = spendRatio > 0.85 && agent.consecutiveApprovals > 0 ? 5 : 0
21
+
22
+ const denialStreak = Math.min(5, agent.consecutiveDenials * 2.5)
23
+
24
+ return Math.min(30, freqSpike + failedTx + inactivity + spendPressure + denialStreak)
25
+ }
package/src/server.ts ADDED
@@ -0,0 +1,410 @@
1
+ import { Hono } from 'hono'
2
+ import { serve } from '@hono/node-server'
3
+ import { join } from 'path'
4
+ import { readFile } from 'fs/promises'
5
+
6
+ import type { PolicyContext } from './types'
7
+ import {
8
+ getOrCreateAgent, getAgent, getAllAgents, getTopAgents,
9
+ recordApproval, recordDenial, recordOverride,
10
+ addVerifiedHuman, setOWSWallet, setWebBotAuthVerified,
11
+ } from './store'
12
+ import { getSpendingLimits } from './scoring/limits'
13
+ import { emitEvent, attachWebSocketToServer } from './emitter'
14
+ import { loadConfig, getConfig } from './config'
15
+ // XMTP is loaded lazily — native bindings may not be available on all systems
16
+ type XmtpModule = typeof import('./xmtp')
17
+ let xmtp: XmtpModule | null = null
18
+
19
+ export const BASE_SEPOLIA = 'eip155:84532'
20
+ export const USDC_BASE_SEPOLIA = '0x036CbD53842c5426634e7929541eC2318f3dCF7e'
21
+ const X402_FACILITATOR = 'https://x402.org/facilitator'
22
+
23
+ let ethUsdPrice = 2500 // fallback
24
+ let priceLastFetched = 0
25
+ const PRICE_CACHE_TTL = 5 * 60 * 1000 // 5 minutes
26
+
27
+ async function getEthUsdPrice(): Promise<number> {
28
+ if (Date.now() - priceLastFetched < PRICE_CACHE_TTL) return ethUsdPrice
29
+ try {
30
+ const res = await fetch('https://api.coingecko.com/api/v3/simple/price?ids=ethereum&vs_currencies=usd')
31
+ if (res.ok) {
32
+ const data = await res.json() as { ethereum?: { usd?: number } }
33
+ if (data.ethereum?.usd) {
34
+ ethUsdPrice = data.ethereum.usd
35
+ priceLastFetched = Date.now()
36
+ }
37
+ }
38
+ } catch {
39
+ // keep last known price on failure
40
+ }
41
+ return ethUsdPrice
42
+ }
43
+
44
+ export const app = new Hono()
45
+
46
+ // ----------------------------------------------------------------
47
+ // POST /api/policy/evaluate — Core OWS integration point
48
+ // ----------------------------------------------------------------
49
+ app.post('/api/policy/evaluate', async (c) => {
50
+ const ctx = await c.req.json().catch(() => null) as PolicyContext | null
51
+ if (!ctx || !ctx.transaction || !ctx.api_key_id) {
52
+ return c.json({ allow: false, reason: 'Invalid PolicyContext' }, 400)
53
+ }
54
+
55
+ // Optional shared secret — if POLICY_SECRET is set, reject unauthenticated requests
56
+ const policySecret = process.env.POLICY_SECRET
57
+ if (policySecret) {
58
+ const provided = c.req.header('x-policy-secret') || (ctx.policy_config?.secret as string)
59
+ if (provided !== policySecret) {
60
+ return c.json({ allow: false, reason: 'Unauthorized: invalid policy secret' }, 403)
61
+ }
62
+ }
63
+
64
+ const agentKey = ctx.api_key_id
65
+
66
+ // Ensure the agent is marked as an OWS wallet
67
+ setOWSWallet(agentKey, ctx.wallet_id, ctx.api_key_id)
68
+ const agent = getOrCreateAgent(agentKey)
69
+
70
+ // Reset daily spend if new day (before limit checks)
71
+ const today = new Date().toISOString().split('T')[0]
72
+ if (agent.dailyDate !== today) {
73
+ agent.dailySpent = 0
74
+ agent.dailyDate = today
75
+ }
76
+
77
+ // Check Web Bot Auth (RFC 9421) signature
78
+ const reqHeaders: Record<string, string> = {}
79
+ c.req.raw.headers.forEach((v, k) => { reqHeaders[k] = v })
80
+ if (reqHeaders['signature-input'] && reqHeaders['signature']) {
81
+ const { verifyWebBotAuth } = await import('./webbotauth')
82
+ const verified = verifyWebBotAuth(reqHeaders, {
83
+ method: c.req.method,
84
+ url: c.req.url,
85
+ headers: reqHeaders
86
+ }, agentKey)
87
+ if (verified && !agent.webBotAuthVerified) {
88
+ setWebBotAuthVerified(agentKey)
89
+ }
90
+ }
91
+
92
+ // Parse transaction amount
93
+ let weiValue: bigint
94
+ try {
95
+ weiValue = BigInt(ctx.transaction.value || '0')
96
+ } catch {
97
+ weiValue = 0n
98
+ }
99
+ const ethAmount = Number(weiValue) / 1e18
100
+ const ethUsd = await getEthUsdPrice()
101
+ const amountUSD = ethAmount * ethUsd
102
+
103
+ const { tier, dailyLimit, perTxLimit } = getSpendingLimits(agent.trustScore)
104
+
105
+ // Check 1: frozen
106
+ if (tier.name === 'Frozen') {
107
+ const denied = recordDenial(agentKey, ctx.transaction, amountUSD)
108
+ const reason = 'Agent is frozen'
109
+ emitEvent({
110
+ type: 'POLICY_DECISION',
111
+ agent: agentKey, amount: amountUSD,
112
+ trustScore: denied.trustScore, tier: denied.tier,
113
+ decision: 'DENY', reason,
114
+ dailyLimit, dailySpent: denied.dailySpent, timestamp: Date.now(),
115
+ })
116
+ void xmtp?.notifyDenial(agentKey, amountUSD, denied.trustScore, denied.tier, dailyLimit, denied.dailySpent, reason, ctx.transaction.to)
117
+ console.log(`[Policy] agent=${agentKey} amount=$${amountUSD.toFixed(2)} → DENY reason=${reason}`)
118
+ return c.json({ allow: false, reason, trustScore: denied.trustScore, tier: denied.tier, dailyLimit, dailySpent: denied.dailySpent, perTxLimit }, 200)
119
+ }
120
+
121
+ // Check 2: per-transaction limit
122
+ if (amountUSD > perTxLimit) {
123
+ const denied = recordDenial(agentKey, ctx.transaction, amountUSD)
124
+ const reason = `Exceeds per-transaction limit ($${perTxLimit})`
125
+ emitEvent({
126
+ type: 'POLICY_DECISION',
127
+ agent: agentKey, amount: amountUSD,
128
+ trustScore: denied.trustScore, tier: denied.tier,
129
+ decision: 'DENY', reason,
130
+ dailyLimit, dailySpent: denied.dailySpent, timestamp: Date.now(),
131
+ })
132
+ void xmtp?.notifyDenial(agentKey, amountUSD, denied.trustScore, denied.tier, dailyLimit, denied.dailySpent, reason, ctx.transaction.to)
133
+ console.log(`[Policy] agent=${agentKey} amount=$${amountUSD.toFixed(2)} → DENY reason=${reason}`)
134
+ return c.json({ allow: false, reason, trustScore: denied.trustScore, tier: denied.tier, dailyLimit, dailySpent: denied.dailySpent, perTxLimit }, 200)
135
+ }
136
+
137
+ // Check 3: daily limit
138
+ if (agent.dailySpent + amountUSD > dailyLimit) {
139
+ const denied = recordDenial(agentKey, ctx.transaction, amountUSD)
140
+ const reason = `Exceeds daily spending limit ($${dailyLimit})`
141
+ emitEvent({
142
+ type: 'POLICY_DECISION',
143
+ agent: agentKey, amount: amountUSD,
144
+ trustScore: denied.trustScore, tier: denied.tier,
145
+ decision: 'DENY', reason,
146
+ dailyLimit, dailySpent: denied.dailySpent, timestamp: Date.now(),
147
+ })
148
+ void xmtp?.notifyDenial(agentKey, amountUSD, denied.trustScore, denied.tier, dailyLimit, denied.dailySpent, reason, ctx.transaction.to)
149
+ console.log(`[Policy] agent=${agentKey} amount=$${amountUSD.toFixed(2)} → DENY reason=${reason}`)
150
+ return c.json({ allow: false, reason, trustScore: denied.trustScore, tier: denied.tier, dailyLimit, dailySpent: denied.dailySpent, perTxLimit }, 200)
151
+ }
152
+
153
+ // All checks pass — approve
154
+ const approved = recordApproval(agentKey, ctx.transaction.to, amountUSD)
155
+ emitEvent({
156
+ type: 'POLICY_DECISION',
157
+ agent: agentKey, amount: amountUSD,
158
+ trustScore: approved.trustScore, tier: approved.tier,
159
+ decision: 'APPROVE', reason: 'OK',
160
+ dailyLimit, dailySpent: approved.dailySpent, timestamp: Date.now(),
161
+ })
162
+
163
+ // Budget warning at 80%
164
+ if (dailyLimit > 0 && approved.dailySpent / dailyLimit > getConfig().warningThreshold) {
165
+ emitEvent({
166
+ type: 'BUDGET_WARNING',
167
+ agent: agentKey,
168
+ spent: approved.dailySpent,
169
+ limit: dailyLimit,
170
+ percentage: Math.round((approved.dailySpent / dailyLimit) * 100),
171
+ timestamp: Date.now(),
172
+ })
173
+ void xmtp?.notifyBudgetWarning(agentKey, approved.dailySpent, dailyLimit)
174
+ }
175
+
176
+ console.log(`[Policy] agent=${agentKey} amount=$${amountUSD.toFixed(2)} → APPROVE reason=OK`)
177
+ return c.json({ allow: true, trustScore: approved.trustScore, tier: approved.tier, dailyLimit, dailySpent: approved.dailySpent, perTxLimit }, 200)
178
+ })
179
+
180
+ // ----------------------------------------------------------------
181
+ // POST /api/override/:address — Human override
182
+ // ----------------------------------------------------------------
183
+ app.post('/api/override/:address', (c) => {
184
+ const address = c.req.param('address')
185
+ const before = getAgent(address)
186
+ if (!before) return c.json({ error: 'Agent not found' }, 404)
187
+
188
+ // Save pending data before recordOverride clears it
189
+ const pendingAmount = before.pendingOverride?.amount ?? 0
190
+ const oldScore = before.trustScore
191
+ const oldTier = before.tier
192
+
193
+ const agent = recordOverride(address)
194
+ if (!agent) return c.json({ error: 'No pending override for this agent' }, 404)
195
+
196
+ const { dailyLimit } = getSpendingLimits(agent.trustScore)
197
+
198
+ emitEvent({
199
+ type: 'TRUST_CHANGE',
200
+ agent: address,
201
+ oldScore, newScore: agent.trustScore,
202
+ oldTier, newTier: agent.tier,
203
+ reason: 'Human override approved',
204
+ timestamp: Date.now(),
205
+ })
206
+ if (oldTier !== agent.tier) console.log(`[Trust] agent=${address} score ${oldScore}→${agent.trustScore} (${oldTier}→${agent.tier})`)
207
+ void xmtp?.notifyTrustChange(address, oldScore, agent.trustScore, oldTier, agent.tier, 'Human override approved')
208
+ emitEvent({
209
+ type: 'POLICY_DECISION',
210
+ agent: address, amount: pendingAmount,
211
+ trustScore: agent.trustScore, tier: agent.tier,
212
+ decision: 'OVERRIDE', reason: 'Human override',
213
+ dailyLimit, dailySpent: agent.dailySpent, timestamp: Date.now(),
214
+ })
215
+
216
+ return c.json(agent)
217
+ })
218
+
219
+ // ----------------------------------------------------------------
220
+ // GET /api/agents
221
+ // ----------------------------------------------------------------
222
+ app.get('/api/agents', (c) => {
223
+ return c.json(getTopAgents(20))
224
+ })
225
+
226
+ // ----------------------------------------------------------------
227
+ // GET /api/agents/:address
228
+ // ----------------------------------------------------------------
229
+ app.get('/api/agents/:address', (c) => {
230
+ const agent = getAgent(c.req.param('address'))
231
+ if (!agent) return c.json({ error: 'Agent not found' }, 404)
232
+ return c.json(agent)
233
+ })
234
+
235
+ // ----------------------------------------------------------------
236
+ // POST /api/agents/:address/pubkey — Register agent's Ed25519 public key for Web Bot Auth
237
+ // ----------------------------------------------------------------
238
+ app.post('/api/agents/:address/pubkey', async (c) => {
239
+ const { address } = c.req.param()
240
+ const { publicKey } = await c.req.json().catch(() => ({ publicKey: '' }))
241
+ if (!publicKey) return c.json({ error: 'Missing publicKey (base64)' }, 400)
242
+ const { registerAgentPublicKey } = await import('./webbotauth')
243
+ registerAgentPublicKey(address, publicKey)
244
+ return c.json({ registered: true, agent: address })
245
+ })
246
+
247
+ // ----------------------------------------------------------------
248
+ // GET /api/stats
249
+ // ----------------------------------------------------------------
250
+ app.get('/api/stats', (c) => {
251
+ const all = getAllAgents()
252
+ return c.json({
253
+ totalAgents: all.length,
254
+ totalDecisions: all.reduce((s, a) => s + a.totalApproved + a.totalDenied, 0),
255
+ totalApproved: all.reduce((s, a) => s + a.totalApproved, 0),
256
+ totalDenied: all.reduce((s, a) => s + a.totalDenied, 0),
257
+ })
258
+ })
259
+
260
+ // ----------------------------------------------------------------
261
+ // POST /api/verify-context — World ID sign request
262
+ // ----------------------------------------------------------------
263
+ app.post('/api/verify-context', async (c) => {
264
+ const signingKey = process.env.WORLD_ID_SIGNING_KEY
265
+ const rpId = process.env.WORLD_ID_RP_ID
266
+ if (!signingKey || !rpId) return c.json({ error: 'World ID not configured' }, 501)
267
+
268
+ try {
269
+ const { signRequest } = await import('@worldcoin/idkit-server')
270
+ const rpSig = signRequest({ signingKeyHex: signingKey, action: 'triage-verify' })
271
+ return c.json({ rp_id: rpId, nonce: rpSig.nonce, created_at: rpSig.createdAt, expires_at: rpSig.expiresAt, signature: rpSig.sig })
272
+ } catch {
273
+ return c.json({ error: 'World ID sign request failed' }, 500)
274
+ }
275
+ })
276
+
277
+ // ----------------------------------------------------------------
278
+ // POST /api/verify-human — World ID proof verification
279
+ // ----------------------------------------------------------------
280
+ app.post('/api/verify-human', async (c) => {
281
+ const rpId = process.env.WORLD_ID_RP_ID
282
+ if (!rpId) return c.json({ error: 'World ID not configured' }, 501)
283
+
284
+ try {
285
+ const body = await c.req.json<{ address?: string }>()
286
+ const response = await fetch(
287
+ `https://developer.world.org/api/v4/verify/${rpId}`,
288
+ { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }
289
+ )
290
+ if (response.ok) {
291
+ const result = await response.json() as { nullifier_hash?: string }
292
+ const nullifierHash = result.nullifier_hash || 'world-id-verified'
293
+ const address = body.address || nullifierHash
294
+ addVerifiedHuman(address, nullifierHash)
295
+ return c.json({ success: true, verified: true, nullifierHash })
296
+ }
297
+ const error = await response.json()
298
+ return c.json({ success: false, error }, 400)
299
+ } catch {
300
+ return c.json({ success: false, error: 'Verification failed' }, 500)
301
+ }
302
+ })
303
+
304
+ // ----------------------------------------------------------------
305
+ // POST /api/x402/verify — Verify an x402 payment signature
306
+ // ----------------------------------------------------------------
307
+ app.post('/api/x402/verify', async (c) => {
308
+ try {
309
+ const body = await c.req.json()
310
+ const { paymentHeader, payTo, maxAmountRequired } = body
311
+
312
+ if (!paymentHeader || !payTo) {
313
+ return c.json({ error: 'Missing paymentHeader or payTo' }, 400)
314
+ }
315
+
316
+ const res = await fetch(`${X402_FACILITATOR}/verify`, {
317
+ method: 'POST',
318
+ headers: { 'Content-Type': 'application/json' },
319
+ body: JSON.stringify({
320
+ paymentHeader,
321
+ payTo,
322
+ maxAmountRequired: maxAmountRequired || '0',
323
+ network: BASE_SEPOLIA,
324
+ resource: '',
325
+ }),
326
+ })
327
+
328
+ if (!res.ok) {
329
+ return c.json({ valid: false, error: 'Facilitator rejected payment' }, 402)
330
+ }
331
+
332
+ const result = await res.json()
333
+ return c.json({ valid: true, ...result })
334
+ } catch {
335
+ return c.json({ valid: false, error: 'Payment verification failed' }, 500)
336
+ }
337
+ })
338
+
339
+ // ----------------------------------------------------------------
340
+ // GET /api/x402/payment-requirements — Payment info for 402 responses
341
+ // ----------------------------------------------------------------
342
+ app.get('/api/x402/payment-requirements', (c) => {
343
+ const payTo = process.env.PAY_TO_ADDRESS || ''
344
+ if (!payTo) {
345
+ return c.json({ error: 'PAY_TO_ADDRESS not configured' }, 501)
346
+ }
347
+ return c.json({
348
+ network: BASE_SEPOLIA,
349
+ token: USDC_BASE_SEPOLIA,
350
+ payTo,
351
+ maxAmountRequired: '1000000', // $1 USDC (6 decimals)
352
+ resource: '',
353
+ facilitator: X402_FACILITATOR,
354
+ })
355
+ })
356
+
357
+ // ----------------------------------------------------------------
358
+ // Dashboard — serve dashboard-dist/ static files
359
+ // ----------------------------------------------------------------
360
+ const dashboardDir = join(__dirname, '../dashboard-dist')
361
+
362
+ const MIME: Record<string, string> = {
363
+ html: 'text/html', js: 'application/javascript', mjs: 'application/javascript',
364
+ css: 'text/css', svg: 'image/svg+xml', png: 'image/png',
365
+ json: 'application/json', wasm: 'application/wasm', ico: 'image/x-icon',
366
+ }
367
+
368
+ app.get('/*', async (c) => {
369
+ const reqPath = c.req.path === '/' ? '/index.html' : c.req.path
370
+ try {
371
+ const file = await readFile(join(dashboardDir, reqPath))
372
+ const ext = reqPath.split('.').pop() || 'html'
373
+ return new Response(file, {
374
+ headers: { 'Content-Type': MIME[ext] || 'application/octet-stream' },
375
+ })
376
+ } catch {
377
+ const index = await readFile(join(dashboardDir, 'index.html'))
378
+ return new Response(index, { headers: { 'Content-Type': 'text/html' } })
379
+ }
380
+ })
381
+
382
+ // ----------------------------------------------------------------
383
+ // Start server
384
+ // ----------------------------------------------------------------
385
+ export function startServer(port?: number) {
386
+ loadConfig()
387
+ const listenPort = port ?? getConfig().port
388
+ const server = serve({ fetch: app.fetch, port: listenPort }, (info) => {
389
+ console.log(`Triage OWS scoring server running on http://localhost:${info.port}`)
390
+ console.log(`Dashboard: http://localhost:${info.port}/`)
391
+ })
392
+
393
+ attachWebSocketToServer(server)
394
+
395
+ // Initialize XMTP (await the lazy import)
396
+ import('./xmtp').then(m => {
397
+ xmtp = m
398
+ return m.initXMTP()
399
+ }).catch(err => {
400
+ console.warn('[XMTP] Unavailable:', (err as Error).message?.split('\n')[0])
401
+ })
402
+
403
+ return server
404
+ }
405
+
406
+ // Only auto-start when run directly (not imported as library)
407
+ const isDirectRun = process.argv[1]?.includes('server')
408
+ if (isDirectRun) {
409
+ startServer(Number(process.env.PORT) || 4021)
410
+ }