import 'dotenv/config' import { Hono } from 'hono' import { serve } from '@hono/node-server' import { join } from 'path' import { readFile } from 'fs/promises' import type { PolicyContext } from './types' import { getOrCreateAgent, getAgent, getAllAgents, getTopAgents, recordApproval, recordDenial, recordOverride, addVerifiedHuman, setOWSWallet, setWebBotAuthVerified, } from './store' import { getSpendingLimits } from './scoring/limits' import { emitEvent, attachWebSocketToServer } from './emitter' import { loadConfig, getConfig } from './config' // XMTP is loaded lazily — native bindings may not be available on all systems type XmtpModule = typeof import('./xmtp') let xmtp: XmtpModule | null = null export const BASE_SEPOLIA = 'eip155:84532' export const USDC_BASE_SEPOLIA = '0x036CbD53842c5426634e7929541eC2318f3dCF7e' const X402_FACILITATOR = 'https://x402.org/facilitator' let ethUsdPrice = 2500 // fallback let priceLastFetched = 0 const PRICE_CACHE_TTL = 5 * 60 * 1000 // 5 minutes async function getEthUsdPrice(): Promise { if (Date.now() - priceLastFetched < PRICE_CACHE_TTL) return ethUsdPrice try { const res = await fetch('https://api.coingecko.com/api/v3/simple/price?ids=ethereum&vs_currencies=usd') if (res.ok) { const data = await res.json() as { ethereum?: { usd?: number } } if (data.ethereum?.usd) { ethUsdPrice = data.ethereum.usd priceLastFetched = Date.now() } } } catch { // keep last known price on failure } return ethUsdPrice } export const app = new Hono() // ---------------------------------------------------------------- // POST /api/policy/evaluate — Core OWS integration point // ---------------------------------------------------------------- app.post('/api/policy/evaluate', async (c) => { const ctx = await c.req.json().catch(() => null) as PolicyContext | null if (!ctx || !ctx.transaction || !ctx.api_key_id) { return c.json({ allow: false, reason: 'Invalid PolicyContext' }, 400) } // Optional shared secret — if POLICY_SECRET is set, reject unauthenticated requests const policySecret = process.env.POLICY_SECRET if (policySecret) { const provided = c.req.header('x-policy-secret') || (ctx.policy_config?.secret as string) if (provided !== policySecret) { return c.json({ allow: false, reason: 'Unauthorized: invalid policy secret' }, 403) } } const agentKey = ctx.api_key_id // Ensure the agent is marked as an OWS wallet setOWSWallet(agentKey, ctx.wallet_id, ctx.api_key_id) const agent = getOrCreateAgent(agentKey) // Reset daily spend if new day (before limit checks) const today = new Date().toISOString().split('T')[0] if (agent.dailyDate !== today) { agent.dailySpent = 0 agent.dailyDate = today } // Check Web Bot Auth (RFC 9421) signature const reqHeaders: Record = {} c.req.raw.headers.forEach((v, k) => { reqHeaders[k] = v }) if (reqHeaders['signature-input'] && reqHeaders['signature']) { const { verifyWebBotAuth } = await import('./webbotauth') const verified = verifyWebBotAuth(reqHeaders, { method: c.req.method, url: c.req.url, headers: reqHeaders }, agentKey) if (verified && !agent.webBotAuthVerified) { setWebBotAuthVerified(agentKey) } } // Parse transaction amount let weiValue: bigint try { weiValue = BigInt(ctx.transaction.value || '0') } catch { weiValue = 0n } const ethAmount = Number(weiValue) / 1e18 const ethUsd = await getEthUsdPrice() const amountUSD = ethAmount * ethUsd const { tier, dailyLimit, perTxLimit } = getSpendingLimits(agent.trustScore) // Check 1: frozen if (tier.name === 'Frozen') { const denied = recordDenial(agentKey, ctx.transaction, amountUSD) const reason = 'Agent is frozen' emitEvent({ type: 'POLICY_DECISION', agent: agentKey, amount: amountUSD, trustScore: denied.trustScore, tier: denied.tier, decision: 'DENY', reason, dailyLimit, dailySpent: denied.dailySpent, timestamp: Date.now(), }) void xmtp?.notifyDenial(agentKey, amountUSD, denied.trustScore, denied.tier, dailyLimit, denied.dailySpent, reason, ctx.transaction.to) console.log(`[Policy] agent=${agentKey} amount=$${amountUSD.toFixed(2)} → DENY reason=${reason}`) return c.json({ allow: false, reason, trustScore: denied.trustScore, tier: denied.tier, dailyLimit, dailySpent: denied.dailySpent, perTxLimit }, 200) } // Check 2: per-transaction limit if (amountUSD > perTxLimit) { const denied = recordDenial(agentKey, ctx.transaction, amountUSD) const reason = `Exceeds per-transaction limit ($${perTxLimit})` emitEvent({ type: 'POLICY_DECISION', agent: agentKey, amount: amountUSD, trustScore: denied.trustScore, tier: denied.tier, decision: 'DENY', reason, dailyLimit, dailySpent: denied.dailySpent, timestamp: Date.now(), }) void xmtp?.notifyDenial(agentKey, amountUSD, denied.trustScore, denied.tier, dailyLimit, denied.dailySpent, reason, ctx.transaction.to) console.log(`[Policy] agent=${agentKey} amount=$${amountUSD.toFixed(2)} → DENY reason=${reason}`) return c.json({ allow: false, reason, trustScore: denied.trustScore, tier: denied.tier, dailyLimit, dailySpent: denied.dailySpent, perTxLimit }, 200) } // Check 3: daily limit if (agent.dailySpent + amountUSD > dailyLimit) { const denied = recordDenial(agentKey, ctx.transaction, amountUSD) const reason = `Exceeds daily spending limit ($${dailyLimit})` emitEvent({ type: 'POLICY_DECISION', agent: agentKey, amount: amountUSD, trustScore: denied.trustScore, tier: denied.tier, decision: 'DENY', reason, dailyLimit, dailySpent: denied.dailySpent, timestamp: Date.now(), }) void xmtp?.notifyDenial(agentKey, amountUSD, denied.trustScore, denied.tier, dailyLimit, denied.dailySpent, reason, ctx.transaction.to) console.log(`[Policy] agent=${agentKey} amount=$${amountUSD.toFixed(2)} → DENY reason=${reason}`) return c.json({ allow: false, reason, trustScore: denied.trustScore, tier: denied.tier, dailyLimit, dailySpent: denied.dailySpent, perTxLimit }, 200) } // All checks pass — approve const approved = recordApproval(agentKey, ctx.transaction.to, amountUSD) emitEvent({ type: 'POLICY_DECISION', agent: agentKey, amount: amountUSD, trustScore: approved.trustScore, tier: approved.tier, decision: 'APPROVE', reason: 'OK', dailyLimit, dailySpent: approved.dailySpent, timestamp: Date.now(), }) // Budget warning at 80% if (dailyLimit > 0 && approved.dailySpent / dailyLimit > getConfig().warningThreshold) { emitEvent({ type: 'BUDGET_WARNING', agent: agentKey, spent: approved.dailySpent, limit: dailyLimit, percentage: Math.round((approved.dailySpent / dailyLimit) * 100), timestamp: Date.now(), }) void xmtp?.notifyBudgetWarning(agentKey, approved.dailySpent, dailyLimit) } console.log(`[Policy] agent=${agentKey} amount=$${amountUSD.toFixed(2)} → APPROVE reason=OK`) return c.json({ allow: true, trustScore: approved.trustScore, tier: approved.tier, dailyLimit, dailySpent: approved.dailySpent, perTxLimit }, 200) }) // ---------------------------------------------------------------- // POST /api/override/:address — Human override // ---------------------------------------------------------------- app.post('/api/override/:address', (c) => { const address = c.req.param('address') const before = getAgent(address) if (!before) return c.json({ error: 'Agent not found' }, 404) // Save pending data before recordOverride clears it const pendingAmount = before.pendingOverride?.amount ?? 0 const oldScore = before.trustScore const oldTier = before.tier const agent = recordOverride(address) if (!agent) return c.json({ error: 'No pending override for this agent' }, 404) const { dailyLimit } = getSpendingLimits(agent.trustScore) emitEvent({ type: 'TRUST_CHANGE', agent: address, oldScore, newScore: agent.trustScore, oldTier, newTier: agent.tier, reason: 'Human override approved', timestamp: Date.now(), }) if (oldTier !== agent.tier) console.log(`[Trust] agent=${address} score ${oldScore}→${agent.trustScore} (${oldTier}→${agent.tier})`) void xmtp?.notifyTrustChange(address, oldScore, agent.trustScore, oldTier, agent.tier, 'Human override approved') emitEvent({ type: 'POLICY_DECISION', agent: address, amount: pendingAmount, trustScore: agent.trustScore, tier: agent.tier, decision: 'OVERRIDE', reason: 'Human override', dailyLimit, dailySpent: agent.dailySpent, timestamp: Date.now(), }) return c.json(agent) }) // ---------------------------------------------------------------- // GET /api/agents // ---------------------------------------------------------------- app.get('/api/agents', (c) => { const agents = getTopAgents(50).filter(a => a.totalRequests > 0 || a.isOWSWallet || a.worldIdVerified) return c.json(agents.slice(0, 20)) }) // ---------------------------------------------------------------- // GET /api/agents/:address // ---------------------------------------------------------------- app.get('/api/agents/:address', (c) => { const agent = getAgent(c.req.param('address')) if (!agent) return c.json({ error: 'Agent not found' }, 404) return c.json(agent) }) // ---------------------------------------------------------------- // POST /api/agents/:address/pubkey — Register agent's Ed25519 public key for Web Bot Auth // ---------------------------------------------------------------- app.post('/api/agents/:address/pubkey', async (c) => { const { address } = c.req.param() const { publicKey } = await c.req.json().catch(() => ({ publicKey: '' })) if (!publicKey) return c.json({ error: 'Missing publicKey (base64)' }, 400) const { registerAgentPublicKey } = await import('./webbotauth') registerAgentPublicKey(address, publicKey) return c.json({ registered: true, agent: address }) }) // ---------------------------------------------------------------- // GET /api/stats // ---------------------------------------------------------------- app.get('/api/stats', (c) => { const all = getAllAgents().filter(a => a.totalRequests > 0 || a.isOWSWallet || a.worldIdVerified) return c.json({ totalAgents: all.length, totalDecisions: all.reduce((s, a) => s + a.totalApproved + a.totalDenied, 0), totalApproved: all.reduce((s, a) => s + a.totalApproved, 0), totalDenied: all.reduce((s, a) => s + a.totalDenied, 0), }) }) // ---------------------------------------------------------------- // POST /api/verify-context — World ID sign request // ---------------------------------------------------------------- app.post('/api/verify-context', async (c) => { const signingKey = process.env.WORLD_ID_SIGNING_KEY const rpId = process.env.WORLD_ID_RP_ID if (!signingKey || !rpId) return c.json({ error: 'World ID not configured' }, 501) try { const { signRequest } = await import('@worldcoin/idkit-server') const rpSig = signRequest({ signingKeyHex: signingKey, action: 'triage-verify' }) return c.json({ rp_id: rpId, nonce: rpSig.nonce, created_at: rpSig.createdAt, expires_at: rpSig.expiresAt, signature: rpSig.sig }) } catch { return c.json({ error: 'World ID sign request failed' }, 500) } }) // ---------------------------------------------------------------- // POST /api/verify-human — World ID proof verification // ---------------------------------------------------------------- app.post('/api/verify-human', async (c) => { const rpId = process.env.WORLD_ID_RP_ID if (!rpId) return c.json({ error: 'World ID not configured' }, 501) try { const body = await c.req.json<{ address?: string }>() const response = await fetch( `https://developer.world.org/api/v4/verify/${rpId}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } ) if (response.ok) { const result = await response.json() as { nullifier_hash?: string } const nullifierHash = result.nullifier_hash || 'world-id-verified' const address = body.address || nullifierHash addVerifiedHuman(address, nullifierHash) return c.json({ success: true, verified: true, nullifierHash }) } const error = await response.json() return c.json({ success: false, error }, 400) } catch { return c.json({ success: false, error: 'Verification failed' }, 500) } }) // ---------------------------------------------------------------- // POST /api/x402/verify — Verify an x402 payment signature // ---------------------------------------------------------------- app.post('/api/x402/verify', async (c) => { try { const body = await c.req.json() const { paymentHeader, payTo, maxAmountRequired } = body if (!paymentHeader || !payTo) { return c.json({ error: 'Missing paymentHeader or payTo' }, 400) } const res = await fetch(`${X402_FACILITATOR}/verify`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ paymentHeader, payTo, maxAmountRequired: maxAmountRequired || '0', network: BASE_SEPOLIA, resource: '', }), }) if (!res.ok) { return c.json({ valid: false, error: 'Facilitator rejected payment' }, 402) } const result = await res.json() return c.json({ valid: true, ...result }) } catch { return c.json({ valid: false, error: 'Payment verification failed' }, 500) } }) // ---------------------------------------------------------------- // GET /api/x402/payment-requirements — Payment info for 402 responses // ---------------------------------------------------------------- app.get('/api/x402/payment-requirements', (c) => { const payTo = process.env.PAY_TO_ADDRESS || '' if (!payTo) { return c.json({ error: 'PAY_TO_ADDRESS not configured' }, 501) } return c.json({ network: BASE_SEPOLIA, token: USDC_BASE_SEPOLIA, payTo, maxAmountRequired: '1000000', // $1 USDC (6 decimals) resource: '', facilitator: X402_FACILITATOR, }) }) // ---------------------------------------------------------------- // Dashboard — serve dashboard-dist/ static files // ---------------------------------------------------------------- const dashboardDir = join(__dirname, '../dashboard-dist') const MIME: Record = { html: 'text/html', js: 'application/javascript', mjs: 'application/javascript', css: 'text/css', svg: 'image/svg+xml', png: 'image/png', json: 'application/json', wasm: 'application/wasm', ico: 'image/x-icon', } app.get('/*', async (c) => { const reqPath = c.req.path === '/' ? '/index.html' : c.req.path try { const file = await readFile(join(dashboardDir, reqPath)) const ext = reqPath.split('.').pop() || 'html' return new Response(file, { headers: { 'Content-Type': MIME[ext] || 'application/octet-stream' }, }) } catch { const index = await readFile(join(dashboardDir, 'index.html')) return new Response(index, { headers: { 'Content-Type': 'text/html' } }) } }) // ---------------------------------------------------------------- // Start server // ---------------------------------------------------------------- export function startServer(port?: number) { loadConfig() const listenPort = port ?? getConfig().port const server = serve({ fetch: app.fetch, port: listenPort }, (info) => { console.log(`Triage OWS scoring server running on http://localhost:${info.port}`) console.log(`Dashboard: http://localhost:${info.port}/`) }) attachWebSocketToServer(server) // Initialize XMTP (await the lazy import) import('./xmtp').then(m => { xmtp = m return m.initXMTP() }).catch(err => { console.warn('[XMTP] Unavailable:', (err as Error).message?.split('\n')[0]) }) return server } // Only auto-start when run directly (not imported as library) const isDirectRun = process.argv[1]?.endsWith('src/server.ts') || process.argv[1]?.endsWith('dist/server.js') if (isDirectRun) { startServer(Number(process.env.PORT) || 4021) }