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.
- package/README.md +786 -0
- package/bin/triage-ows.js +218 -0
- package/bin/triage-policy.js +66 -0
- package/bin/triage-server.js +4 -0
- package/dashboard-dist/assets/index-Dnhi_dJQ.css +2 -0
- package/dashboard-dist/assets/index-g_2MwC-o.js +9 -0
- package/dashboard-dist/favicon.svg +7 -0
- package/dashboard-dist/index.html +16 -0
- package/dist/config.d.ts +32 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +61 -0
- package/dist/config.js.map +1 -0
- package/dist/emitter.d.ts +7 -0
- package/dist/emitter.d.ts.map +1 -0
- package/dist/emitter.js +41 -0
- package/dist/emitter.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +55 -0
- package/dist/index.js.map +1 -0
- package/dist/scoring/behavior.d.ts +3 -0
- package/dist/scoring/behavior.d.ts.map +1 -0
- package/dist/scoring/behavior.js +13 -0
- package/dist/scoring/behavior.js.map +1 -0
- package/dist/scoring/compliance.d.ts +3 -0
- package/dist/scoring/compliance.d.ts.map +1 -0
- package/dist/scoring/compliance.js +11 -0
- package/dist/scoring/compliance.js.map +1 -0
- package/dist/scoring/identity.d.ts +3 -0
- package/dist/scoring/identity.d.ts.map +1 -0
- package/dist/scoring/identity.js +16 -0
- package/dist/scoring/identity.js.map +1 -0
- package/dist/scoring/index.d.ts +10 -0
- package/dist/scoring/index.d.ts.map +1 -0
- package/dist/scoring/index.js +35 -0
- package/dist/scoring/index.js.map +1 -0
- package/dist/scoring/limits.d.ts +7 -0
- package/dist/scoring/limits.d.ts.map +1 -0
- package/dist/scoring/limits.js +9 -0
- package/dist/scoring/limits.js.map +1 -0
- package/dist/scoring/network.d.ts +3 -0
- package/dist/scoring/network.d.ts.map +1 -0
- package/dist/scoring/network.js +16 -0
- package/dist/scoring/network.js.map +1 -0
- package/dist/scoring/onchain.d.ts +4 -0
- package/dist/scoring/onchain.d.ts.map +1 -0
- package/dist/scoring/onchain.js +35 -0
- package/dist/scoring/onchain.js.map +1 -0
- package/dist/scoring/risk.d.ts +3 -0
- package/dist/scoring/risk.d.ts.map +1 -0
- package/dist/scoring/risk.js +22 -0
- package/dist/scoring/risk.js.map +1 -0
- package/dist/server.d.ts +6 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +405 -0
- package/dist/server.js.map +1 -0
- package/dist/store.d.ts +13 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +177 -0
- package/dist/store.js.map +1 -0
- package/dist/types.d.ts +107 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +26 -0
- package/dist/types.js.map +1 -0
- package/dist/webbotauth.d.ts +38 -0
- package/dist/webbotauth.d.ts.map +1 -0
- package/dist/webbotauth.js +120 -0
- package/dist/webbotauth.js.map +1 -0
- package/dist/xmtp.d.ts +6 -0
- package/dist/xmtp.d.ts.map +1 -0
- package/dist/xmtp.js +161 -0
- package/dist/xmtp.js.map +1 -0
- package/package.json +58 -0
- package/policy-template.json +14 -0
- package/src/config.ts +86 -0
- package/src/emitter.ts +40 -0
- package/src/index.ts +18 -0
- package/src/scoring/behavior.ts +15 -0
- package/src/scoring/compliance.ts +12 -0
- package/src/scoring/identity.ts +12 -0
- package/src/scoring/index.ts +31 -0
- package/src/scoring/limits.ts +10 -0
- package/src/scoring/network.ts +18 -0
- package/src/scoring/onchain.ts +44 -0
- package/src/scoring/risk.ts +25 -0
- package/src/server.ts +410 -0
- package/src/store.ts +197 -0
- package/src/types.ts +137 -0
- package/src/webbotauth.ts +128 -0
- package/src/xmtp.ts +188 -0
- 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
|
+
}
|