diff --git a/__tests__/api/explorer/receipts.test.ts b/__tests__/api/explorer/receipts.test.ts index 3a5cb065..7fd6abbf 100644 --- a/__tests__/api/explorer/receipts.test.ts +++ b/__tests__/api/explorer/receipts.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest" import { createX402Quote, listX402ExplorerReceipts, settleX402 } from "@/lib/protocols/x402" describe("x402 explorer receipts", () => { - it("records accepted settlements for explorer queries", () => { + it("records accepted settlements for explorer queries", async () => { const quote = createX402Quote({ serviceId: "data-api", chain: "stellar", @@ -11,14 +11,14 @@ describe("x402 explorer receipts", () => { unitPriceUsd: 0.05, }) - const result = settleX402({ + const result = await settleX402({ paymentRef: quote.paymentRef, chain: quote.chain, txHash: `0x${"a".repeat(64)}`, paidBy: quote.payer, }) - const explorer = listX402ExplorerReceipts({ q: "Nexus-7", chain: "stellar" }) + const explorer = await listX402ExplorerReceipts({ q: "Nexus-7", chain: "stellar" }) expect(result.ok).toBe(true) expect(explorer.total).toBeGreaterThanOrEqual(1) @@ -31,8 +31,8 @@ describe("x402 explorer receipts", () => { expect(explorer.stats.totalPayments).toBeGreaterThanOrEqual(1) }) - it("paginates receipt responses", () => { - const explorer = listX402ExplorerReceipts({ page: 1, pageSize: 1 }) + it("paginates receipt responses", async () => { + const explorer = await listX402ExplorerReceipts({ page: 1, pageSize: 1 }) expect(explorer.page).toBe(1) expect(explorer.pageSize).toBe(1) diff --git a/__tests__/api/protocol/x402-passport-gate.test.ts b/__tests__/api/protocol/x402-passport-gate.test.ts new file mode 100644 index 00000000..b4eed6f0 --- /dev/null +++ b/__tests__/api/protocol/x402-passport-gate.test.ts @@ -0,0 +1,303 @@ +import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest' +import { POST as postSettle } from '@/app/api/protocol/x402/settle/route' +import { createX402Quote } from '@/lib/protocols/x402' +import { savePassport, type AgentPassport } from '@/lib/passport/passport' + +const testAgentId = 'passport-gate-test-agent' +const collectionKey = `open-stellar:passport-collection:testnet:${testAgentId}` + +// Mock localStorage for Node environment +const localStorageMock = (() => { + let store: Record = {} + return { + getItem: (key: string) => store[key] || null, + setItem: (key: string, value: string) => { store[key] = value }, + removeItem: (key: string) => { delete store[key] }, + clear: () => { store = {} }, + } +})() + +function createTestQuote(overrides: Record = {}) { + return createX402Quote({ + serviceId: 'passport-gate-service', + chain: 'stellar', + payer: testAgentId, + units: 1, + unitPriceUsd: 0.1, + ttlSeconds: 300, + ...overrides, + }) +} + +function createTestPassport(spendCap: string): AgentPassport { + return { + id: `passport-${Date.now()}`, + agentId: testAgentId, + spendCap, + registryRoot: '0x' + 'a'.repeat(64), + nullifierHash: '0x' + 'b'.repeat(64), + issuedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(), + status: 'ACTIVE', + network: 'testnet', + } +} + +beforeEach(() => { + // Setup localStorage mock + vi.stubGlobal('localStorage', localStorageMock) + // Clear localStorage before each test + localStorageMock.removeItem(collectionKey) +}) + +afterEach(() => { + // Clean up after each test + localStorageMock.removeItem(collectionKey) + vi.unstubAllGlobals() +}) + +describe('POST /api/protocol/x402/settle with passport gate', () => { + it('rejects settlement when agentId is provided but passport does not exist', async () => { + const quote = createTestQuote() + const mockTxHash = `0x${'a'.repeat(64)}` + + const req = new Request('http://localhost/api/protocol/x402/settle', { + method: 'POST', + body: JSON.stringify({ + paymentRef: quote.paymentRef, + chain: 'stellar', + txHash: mockTxHash, + agentId: testAgentId, + }), + headers: { 'Content-Type': 'application/json' }, + }) + + const res = await postSettle(req) + const data = await res.json() + + expect(res.status).toBe(402) + expect(data.ok).toBe(false) + expect(data.error).toMatch(/passport/i) + expect(data.gate).toBeDefined() + expect(data.gate.authorized).toBe(false) + }) + + it('rejects settlement when payment amount exceeds passport spend cap', async () => { + // Create a passport with low spend cap + const passport = createTestPassport('1000000') // 0.1 XLM + savePassport(passport) + + // Create a quote that requires more than the cap + const quote = createTestQuote({ unitPriceUsd: 1.0 }) // Higher price + const mockTxHash = `0x${'b'.repeat(64)}` + + const req = new Request('http://localhost/api/protocol/x402/settle', { + method: 'POST', + body: JSON.stringify({ + paymentRef: quote.paymentRef, + chain: 'stellar', + txHash: mockTxHash, + agentId: testAgentId, + }), + headers: { 'Content-Type': 'application/json' }, + }) + + const res = await postSettle(req) + const data = await res.json() + + expect(res.status).toBe(402) + expect(data.ok).toBe(false) + expect(data.error).toMatch(/passport/i) + expect(data.gate).toBeDefined() + expect(data.gate.authorized).toBe(false) + expect(data.gate.reason).toMatch(/exceeds/i) + }) + + it('approves settlement when payment amount is within passport spend cap', async () => { + // Create a passport with sufficient spend cap + const passport = createTestPassport('100000000') // 10 XLM + savePassport(passport) + + const quote = createTestQuote({ unitPriceUsd: 0.1 }) + const mockTxHash = `0x${'c'.repeat(64)}` + + const req = new Request('http://localhost/api/protocol/x402/settle', { + method: 'POST', + body: JSON.stringify({ + paymentRef: quote.paymentRef, + chain: 'stellar', + txHash: mockTxHash, + agentId: testAgentId, + }), + headers: { 'Content-Type': 'application/json' }, + }) + + const res = await postSettle(req) + const data = await res.json() + + expect(res.status).toBe(200) + expect(data.ok).toBe(true) + expect(data.receipt).toBeDefined() + expect(data.receipt.accepted).toBe(true) + }) + + it('allows settlement without passport gate when agentId is not provided', async () => { + const quote = createTestQuote({ payer: 'anonymous' }) + const mockTxHash = `0x${'d'.repeat(64)}` + + const req = new Request('http://localhost/api/protocol/x402/settle', { + method: 'POST', + body: JSON.stringify({ + paymentRef: quote.paymentRef, + chain: 'stellar', + txHash: mockTxHash, + paidBy: 'anonymous', + }), + headers: { 'Content-Type': 'application/json' }, + }) + + const res = await postSettle(req) + const data = await res.json() + + expect(res.status).toBe(200) + expect(data.ok).toBe(true) + expect(data.receipt).toBeDefined() + expect(data.receipt.accepted).toBe(true) + }) + + it('rejects settlement with expired passport', async () => { + // Create an expired passport + const passport: AgentPassport = { + ...createTestPassport('100000000'), + expiresAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), // Expired yesterday + } + savePassport(passport) + + const quote = createTestQuote() + const mockTxHash = `0x${'e'.repeat(64)}` + + const req = new Request('http://localhost/api/protocol/x402/settle', { + method: 'POST', + body: JSON.stringify({ + paymentRef: quote.paymentRef, + chain: 'stellar', + txHash: mockTxHash, + agentId: testAgentId, + }), + headers: { 'Content-Type': 'application/json' }, + }) + + const res = await postSettle(req) + const data = await res.json() + + expect(res.status).toBe(402) + expect(data.ok).toBe(false) + expect(data.error).toMatch(/passport/i) + }) + + it('rejects settlement with revoked passport', async () => { + // Create a revoked passport + const passport: AgentPassport = { + ...createTestPassport('100000000'), + status: 'REVOKED', + } + savePassport(passport) + + const quote = createTestQuote() + const mockTxHash = `0x${'f'.repeat(64)}` + + const req = new Request('http://localhost/api/protocol/x402/settle', { + method: 'POST', + body: JSON.stringify({ + paymentRef: quote.paymentRef, + chain: 'stellar', + txHash: mockTxHash, + agentId: testAgentId, + }), + headers: { 'Content-Type': 'application/json' }, + }) + + const res = await postSettle(req) + const data = await res.json() + + expect(res.status).toBe(402) + expect(data.ok).toBe(false) + expect(data.error).toMatch(/passport/i) + }) + + it('includes spend cap in response when passport gate is triggered', async () => { + const passport = createTestPassport('5000000') // 0.5 XLM + savePassport(passport) + + const quote = createTestQuote({ unitPriceUsd: 1.0 }) + const mockTxHash = `0x${'1'.repeat(64)}` + + const req = new Request('http://localhost/api/protocol/x402/settle', { + method: 'POST', + body: JSON.stringify({ + paymentRef: quote.paymentRef, + chain: 'stellar', + txHash: mockTxHash, + agentId: testAgentId, + }), + headers: { 'Content-Type': 'application/json' }, + }) + + const res = await postSettle(req) + const data = await res.json() + + expect(data.gate).toBeDefined() + expect(data.gate.cap).toBe('5000000') + }) + + it('handles quoteId instead of paymentRef with passport gate', async () => { + const passport = createTestPassport('100000000') + savePassport(passport) + + const quote = createTestQuote() + const mockTxHash = `0x${'2'.repeat(64)}` + + const req = new Request('http://localhost/api/protocol/x402/settle', { + method: 'POST', + body: JSON.stringify({ + quoteId: quote.quoteId, + chain: 'stellar', + txHash: mockTxHash, + agentId: testAgentId, + }), + headers: { 'Content-Type': 'application/json' }, + }) + + const res = await postSettle(req) + const data = await res.json() + + expect(res.status).toBe(200) + expect(data.ok).toBe(true) + expect(data.receipt).toBeDefined() + }) + + it('rejects settlement when quote is not found for agentId settlement', async () => { + const passport = createTestPassport('100000000') + savePassport(passport) + + const mockTxHash = `0x${'3'.repeat(64)}` + + const req = new Request('http://localhost/api/protocol/x402/settle', { + method: 'POST', + body: JSON.stringify({ + paymentRef: 'nonexistent:payment:ref', + chain: 'stellar', + txHash: mockTxHash, + agentId: testAgentId, + }), + headers: { 'Content-Type': 'application/json' }, + }) + + const res = await postSettle(req) + const data = await res.json() + + expect(res.status).toBe(400) + expect(data.ok).toBe(false) + expect(data.error).toMatch(/quote not found/i) + }) +}) diff --git a/__tests__/api/protocol/x402-receipts.test.ts b/__tests__/api/protocol/x402-receipts.test.ts index 2e783388..fa9f6892 100644 --- a/__tests__/api/protocol/x402-receipts.test.ts +++ b/__tests__/api/protocol/x402-receipts.test.ts @@ -12,7 +12,7 @@ describe("GET /api/protocol/x402/receipts", () => { units: 1, unitPriceUsd: 0.01, }) - const settlement = settleX402({ + const settlement = await settleX402({ paymentRef: quote.paymentRef, chain: quote.chain, txHash: `0x${"b".repeat(64)}`, diff --git a/__tests__/api/skills/skills.test.ts b/__tests__/api/skills/skills.test.ts index ce286405..fc2cfe07 100644 --- a/__tests__/api/skills/skills.test.ts +++ b/__tests__/api/skills/skills.test.ts @@ -291,7 +291,7 @@ describe('Skills API', () => { }) // Settle payment - const settlement = settleX402({ + const settlement = await settleX402({ paymentRef: quote.paymentRef, chain: 'stellar', txHash: '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', diff --git a/app/api/admin/agents/route.ts b/app/api/admin/agents/route.ts index 207e6061..c6016ff1 100644 --- a/app/api/admin/agents/route.ts +++ b/app/api/admin/agents/route.ts @@ -1,21 +1,15 @@ import { NextResponse } from "next/server" import { cloudConfigToAgent, listCloudAgentConfigs, provisionCloudAgent } from "@/lib/agent-runtime/cloud-agents" -import { isAuthorized } from "@/lib/auth" +import { withApiKeyAuth } from "@/lib/auth/api-key-middleware" export const dynamic = "force-dynamic" -export async function GET(req: Request) { - if (!isAuthorized(req)) { - return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }) - } +export const GET = withApiKeyAuth(async (req: Request) => { const configs = listCloudAgentConfigs() return NextResponse.json({ ok: true, agents: configs.map((config, index) => cloudConfigToAgent(config, index)), configs }, { headers: { "Cache-Control": "no-store" } }) -} +}, { keyType: 'admin' }) -export async function POST(req: Request) { - if (!isAuthorized(req)) { - return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }) - } +export const POST = withApiKeyAuth(async (req: Request) => { try { const body = await req.json().catch(() => ({})) const config = provisionCloudAgent({ name: body.name, model: body.model, district: body.district, queueMode: body.queueMode }, req) @@ -23,4 +17,4 @@ export async function POST(req: Request) { } catch (error) { return NextResponse.json({ ok: false, error: error instanceof Error ? error.message : "Failed provisioning cloud agent" }, { status: 400 }) } -} +}, { keyType: 'admin' }) diff --git a/app/api/admin/claude-costs/route.ts b/app/api/admin/claude-costs/route.ts index 7667aa30..6a8a0a3f 100644 --- a/app/api/admin/claude-costs/route.ts +++ b/app/api/admin/claude-costs/route.ts @@ -1,7 +1,8 @@ import { createApiRouteLogger } from "@/lib/api-logging" import { getAgentClaudeAnalytics, listClaudeCostRecords } from "@/lib/agent-runtime/costs" +import { withApiKeyAuth } from "@/lib/auth/api-key-middleware" -export async function GET(req: Request) { +export const GET = withApiKeyAuth(async (req: Request) => { const api = createApiRouteLogger(req, "/api/admin/claude-costs") const url = new URL(req.url) const agentId = url.searchParams.get("agentId") ?? undefined @@ -21,4 +22,4 @@ export async function GET(req: Request) { undefined, { event: "claude.costs.list", agentId }, ) -} +}, { keyType: 'admin' }) diff --git a/app/api/admin/runs/route.ts b/app/api/admin/runs/route.ts index 372d2cd6..01a7b43d 100644 --- a/app/api/admin/runs/route.ts +++ b/app/api/admin/runs/route.ts @@ -1,14 +1,15 @@ import { createApiRouteLogger } from "@/lib/api-logging" import { createRerun, listOrchestrationRuns } from "@/lib/orchestration/runs" +import { withApiKeyAuth } from "@/lib/auth/api-key-middleware" -export async function GET(req: Request) { +export const GET = withApiKeyAuth(async (req: Request) => { const api = createApiRouteLogger(req, "/api/admin/runs") return api.json({ ok: true, ...listOrchestrationRuns() }, undefined, { event: "orchestration.runs.list", }) -} +}, { keyType: 'admin' }) -export async function POST(req: Request) { +export const POST = withApiKeyAuth(async (req: Request) => { const api = createApiRouteLogger(req, "/api/admin/runs") try { @@ -39,4 +40,4 @@ export async function POST(req: Request) { { event: "orchestration.rerun.failed" }, ) } -} +}, { keyType: 'admin' }) diff --git a/app/api/agents/[id]/skills/[skillId]/invoke/route.ts b/app/api/agents/[id]/skills/[skillId]/invoke/route.ts index 3060158e..50709167 100644 --- a/app/api/agents/[id]/skills/[skillId]/invoke/route.ts +++ b/app/api/agents/[id]/skills/[skillId]/invoke/route.ts @@ -108,7 +108,7 @@ export async function POST(req: Request, context: RouteContext) { } // Verify payment - const result = settleX402({ + const result = await settleX402({ paymentRef, chain, txHash, diff --git a/app/api/protocol/passport/authorize/route.ts b/app/api/protocol/passport/authorize/route.ts index 49ec218f..8e8c78fc 100644 --- a/app/api/protocol/passport/authorize/route.ts +++ b/app/api/protocol/passport/authorize/route.ts @@ -1,17 +1,29 @@ import { createApiRouteLogger } from '@/lib/api-logging' import { authorizePayment } from '@/lib/passport/passport' +import { withApiKeyAuth } from '@/lib/auth/api-key-middleware' +import { createSpan, addSpanEvent, finishSpan } from '@/lib/observability/tracing' +import { incrementCounter, Timer, AgentMetrics } from '@/lib/observability/metrics' // POST { agentId, amount } -> on-chain spend-cap gate for the agent's passport. // `amount` is in the smallest on-chain unit (must already be scaled by caller). -export async function POST(req: Request) { +export const POST = withApiKeyAuth(async (req: Request) => { const api = createApiRouteLogger(req, '/api/protocol/passport/authorize') + const timer = new Timer(AgentMetrics.PASSPORT_VERIFICATION_DURATION, {}, 'Duration of passport authorization check') + const authSpan = createSpan('passport.authorize', { path: '/api/protocol/passport/authorize' }) try { const body = await req.json() const agentId = String(body.agentId || '') const amount = String(body.amount || '') + authSpan.attributes = { agentId, amount } + if (!agentId || !amount) { + addSpanEvent(authSpan, 'validation_failed', { reason: 'missing_parameters' }) + finishSpan(authSpan, 'error') + incrementCounter(AgentMetrics.PASSPORT_AUTHORIZE_DENIED, 1, { reason: 'missing_parameters' }) + timer.stop() + return await api.json( { ok: false, error: 'agentId and amount are required' }, { status: 400 }, @@ -19,7 +31,20 @@ export async function POST(req: Request) { ) } + addSpanEvent(authSpan, 'authorization_check', { agentId, amount }) const result = await authorizePayment(agentId, amount) + + addSpanEvent(authSpan, 'authorization_result', { authorized: result.authorized, reason: result.reason, cap: result.cap }) + + if (result.authorized) { + incrementCounter(AgentMetrics.PASSPORT_AUTHORIZE, 1, { status: 'authorized' }) + } else { + incrementCounter(AgentMetrics.PASSPORT_AUTHORIZE_DENIED, 1, { reason: result.reason }) + } + + finishSpan(authSpan, 'ok') + timer.stop() + return await api.json({ ok: true, ...result }, undefined, { event: 'passport.authorize.completed', agentId, @@ -29,6 +54,16 @@ export async function POST(req: Request) { cap: result.cap, }) } catch (error) { + addSpanEvent(authSpan, 'error', { + error: error instanceof Error ? { + name: error.name, + message: error.message, + } : String(error), + }) + finishSpan(authSpan, 'error') + incrementCounter(AgentMetrics.PASSPORT_AUTHORIZE_DENIED, 1, { reason: 'exception' }) + timer.stop() + return await api.report( 'error', error, @@ -37,4 +72,4 @@ export async function POST(req: Request) { { event: 'passport.authorize.failed' }, ) } -} +}, { keyType: 'protocol' }) diff --git a/app/api/protocol/reputation/route.ts b/app/api/protocol/reputation/route.ts index 7e201f42..05aa6a1f 100644 --- a/app/api/protocol/reputation/route.ts +++ b/app/api/protocol/reputation/route.ts @@ -1,7 +1,8 @@ import { createApiRouteLogger } from '@/lib/api-logging' import { applyReputationAction, getReputation, listReputations } from '@/lib/reputation/reputation-store' +import { withApiKeyAuth } from '@/lib/auth/api-key-middleware' -export async function GET(req: Request) { +export const GET = withApiKeyAuth(async (req: Request) => { const api = createApiRouteLogger(req, '/api/protocol/reputation') const { searchParams } = new URL(req.url) const actorId = searchParams.get('actorId') @@ -18,9 +19,9 @@ export async function GET(req: Request) { event: 'reputation.list', count: reputations.length, }) -} +}, { keyType: 'protocol' }) -export async function POST(req: Request) { +export const POST = withApiKeyAuth(async (req: Request) => { const api = createApiRouteLogger(req, '/api/protocol/reputation') try { @@ -48,4 +49,4 @@ export async function POST(req: Request) { { event: 'reputation.update.failed' }, ) } -} +}, { keyType: 'protocol' }) diff --git a/app/api/protocol/x402/quote/route.ts b/app/api/protocol/x402/quote/route.ts index 33793648..97937608 100644 --- a/app/api/protocol/x402/quote/route.ts +++ b/app/api/protocol/x402/quote/route.ts @@ -1,7 +1,8 @@ import { createApiRouteLogger } from '@/lib/api-logging' import { createX402Quote } from '@/lib/protocols/x402' +import { withApiKeyAuth } from '@/lib/auth/api-key-middleware' -export async function POST(req: Request) { +export const POST = withApiKeyAuth(async (req: Request) => { const api = createApiRouteLogger(req, '/api/protocol/x402/quote') try { @@ -40,4 +41,4 @@ export async function POST(req: Request) { { event: 'x402.quote.failed' }, ) } -} +}, { keyType: 'protocol' }) diff --git a/app/api/protocol/x402/receipts/[receiptId]/route.ts b/app/api/protocol/x402/receipts/[receiptId]/route.ts index 3a43f2b9..3d59440e 100644 --- a/app/api/protocol/x402/receipts/[receiptId]/route.ts +++ b/app/api/protocol/x402/receipts/[receiptId]/route.ts @@ -3,7 +3,7 @@ import { getX402Receipt } from '@/lib/protocols/x402-receipt-store' export async function GET(_req: Request, { params }: { params: Promise<{ receiptId: string }> }) { const { receiptId } = await params - const receipt = getX402Receipt(receiptId) + const receipt = await getX402Receipt(receiptId) if (!receipt) { return NextResponse.json({ ok: false, error: 'Receipt not found' }, { status: 404 }) diff --git a/app/api/protocol/x402/settle/route.ts b/app/api/protocol/x402/settle/route.ts index 6d0b4d8b..377ac7f0 100644 --- a/app/api/protocol/x402/settle/route.ts +++ b/app/api/protocol/x402/settle/route.ts @@ -7,6 +7,9 @@ import { settleMockX402 } from '@/lib/mock/x402-mock' import { publishSystemEvent } from '@/lib/events/system-events' import { XP_AWARDS } from '@/lib/gamification/constants' import { awardXP } from '@/lib/gamification/xp' +import { withApiKeyAuth } from '@/lib/auth/api-key-middleware' +import { withSpan, addSpanEvent } from '@/lib/observability/tracing' +import { incrementCounter, recordHistogram, AgentMetrics, Timer } from '@/lib/observability/metrics' function ledgerFromBody(body: Record): unknown { return body.lastPaymentLedger ?? body.ledger ?? body.ledgerSequence @@ -20,8 +23,10 @@ function subscriptionMatchesQuote( return subscription.agentId === quote.payer && subscription.serviceId === quote.serviceId } -export async function POST(req: Request) { +export const POST = withApiKeyAuth(async (req: Request) => { const api = createApiRouteLogger(req, '/api/protocol/x402/settle') + const timer = new Timer(AgentMetrics.X402_SETTLEMENT_DURATION, { chain: 'unknown' }, 'Duration of x402 payment settlement') + const settlementSpan = createSpan('x402.settlement', { path: '/api/protocol/x402/settle' }) try { const body = await req.json() @@ -33,7 +38,24 @@ export async function POST(req: Request) { const subscription = subscriptionId ? getX402SubscriptionById(subscriptionId) : undefined const quote = peekX402Quote(paymentRef) + // Update timer labels + timer.labels = { chain } + + // Update span attributes + settlementSpan.attributes = { + paymentRef, + chain, + agentId, + paidBy, + subscriptionId, + } + if (subscriptionId && !subscription) { + addSpanEvent(settlementSpan, 'subscription_not_found', { subscriptionId }) + finishSpan(settlementSpan, 'error') + incrementCounter(AgentMetrics.X402_PAYMENTS_FAILED, 1, { reason: 'subscription_not_found', chain }) + timer.stop() + return await api.json( { ok: false, error: 'Subscription not found' }, { status: 400 }, @@ -42,6 +64,11 @@ export async function POST(req: Request) { } if (subscriptionId && !subscriptionMatchesQuote(subscription, quote)) { + addSpanEvent(settlementSpan, 'subscription_mismatch', { subscriptionId, payer: quote?.payer }) + finishSpan(settlementSpan, 'error') + incrementCounter(AgentMetrics.X402_PAYMENTS_FAILED, 1, { reason: 'subscription_mismatch', chain }) + timer.stop() + return await api.json( { ok: false, error: 'subscriptionId does not match quote payer/service' }, { status: 400 }, @@ -59,6 +86,7 @@ export async function POST(req: Request) { } if (isMockMode()) { + addSpanEvent(settlementSpan, 'mock_mode', { paymentRef }) const receipt = settleMockX402({ paymentRef, chain, @@ -79,6 +107,11 @@ export async function POST(req: Request) { receipt, }) } + finishSpan(settlementSpan, 'ok') + incrementCounter(AgentMetrics.X402_PAYMENTS_SETTLED, 1, { chain, mode: 'mock' }) + recordHistogram(AgentMetrics.X402_PAYMENT_AMOUNT, receipt.amountUsd, { chain }) + timer.stop() + return await api.json({ ok: true, receipt, subscriptionProof }, undefined, { event: 'x402.settle.mock', paymentRef, subscriptionId }) } @@ -86,15 +119,29 @@ export async function POST(req: Request) { // settle only when the agent holds a valid on-chain passport whose proven // (hidden) spend cap covers the quoted amount. See lib/passport/passport.ts. if (agentId) { + addSpanEvent(settlementSpan, 'passport_gate_check', { agentId, amountUnits: quote?.amountUnits }) + if (!quote) { + addSpanEvent(settlementSpan, 'quote_not_found', { paymentRef }) + finishSpan(settlementSpan, 'error') + incrementCounter(AgentMetrics.X402_PAYMENTS_FAILED, 1, { reason: 'quote_not_found', chain }) + timer.stop() + return await api.json( { ok: false, error: 'Quote not found for paymentRef' }, { status: 400 }, { event: 'x402.settle.rejected', reason: 'quote_not_found', paymentRef, chain, agentId }, ) } + const gate = await authorizePayment(agentId, quote.amountUnits) + addSpanEvent(settlementSpan, 'passport_gate_result', { authorized: gate.authorized, reason: gate.reason }) + if (!gate.authorized) { + finishSpan(settlementSpan, 'error') + incrementCounter(AgentMetrics.X402_PAYMENTS_FAILED, 1, { reason: 'passport_denied', chain }) + timer.stop() + return await api.report( 'warn', new Error(gate.reason), @@ -105,7 +152,7 @@ export async function POST(req: Request) { } } - const result = settleX402({ + const result = await settleX402({ paymentRef, chain, txHash: String(body.txHash || ''), @@ -114,6 +161,11 @@ export async function POST(req: Request) { }) if (!result.ok || !result.receipt) { + addSpanEvent(settlementSpan, 'settlement_failed', { error: result.error }) + finishSpan(settlementSpan, 'error') + incrementCounter(AgentMetrics.X402_PAYMENTS_FAILED, 1, { reason: 'settlement_rejected', chain }) + timer.stop() + return await api.json( { ok: false, error: result.error || 'x402 settlement rejected' }, { status: 400 }, @@ -128,6 +180,7 @@ export async function POST(req: Request) { txHash: result.receipt.txHash, ledger: ledgerFromBody(body), }) + addSpanEvent(settlementSpan, 'subscription_recorded', { subscriptionId }) } publishSystemEvent({ @@ -136,6 +189,12 @@ export async function POST(req: Request) { receipt: result.receipt, }) awardXP(agentId || paidBy, XP_AWARDS.X402_PAYMENT_RECEIVED, 'payment.received') + + addSpanEvent(settlementSpan, 'settlement_completed', { txHash: result.receipt.txHash, amountUsd: result.receipt.amountUsd }) + finishSpan(settlementSpan, 'ok') + incrementCounter(AgentMetrics.X402_PAYMENTS_SETTLED, 1, { chain }) + recordHistogram(AgentMetrics.X402_PAYMENT_AMOUNT, result.receipt.amountUsd, { chain }) + timer.stop() return await api.json({ ok: true, receipt: result.receipt, subscriptionProof }, undefined, { event: 'x402.settle.completed', @@ -146,6 +205,16 @@ export async function POST(req: Request) { subscriptionId, }) } catch (error) { + addSpanEvent(settlementSpan, 'error', { + error: error instanceof Error ? { + name: error.name, + message: error.message, + } : String(error), + }) + finishSpan(settlementSpan, 'error') + incrementCounter(AgentMetrics.X402_PAYMENTS_FAILED, 1, { reason: 'exception', chain: 'unknown' }) + timer.stop() + return await api.report( 'error', error, @@ -154,4 +223,4 @@ export async function POST(req: Request) { { event: 'x402.settle.failed' }, ) } -} +}, { keyType: 'protocol' }) diff --git a/docs/api-key-authentication.md b/docs/api-key-authentication.md new file mode 100644 index 00000000..15b667e0 --- /dev/null +++ b/docs/api-key-authentication.md @@ -0,0 +1,215 @@ +# API Key Authentication + +This document describes the API key authentication middleware used to secure admin and protocol routes in Open Stellar. + +## Overview + +The API key authentication system provides secure access control for: +- **Admin routes** (`/api/admin/*`) - Administrative operations +- **Protocol routes** (`/api/protocol/*`) - Protocol-level operations (x402, passport, reputation) + +## API Keys + +### Admin API Key +- **Purpose**: Access administrative endpoints +- **Environment Variable**: `ADMIN_API_KEY` +- **Format**: `osk_` +- **Generation**: Auto-generated on first boot if not set + +### Protocol API Key +- **Purpose**: Access protocol endpoints (x402, passport, reputation) +- **Environment Variable**: `MOLTBOT_GATEWAY_TOKEN` +- **Format**: Custom string +- **Usage**: Gateway token for protocol operations + +## Usage + +### Adding Authentication to a Route + +Wrap your route handler with `withApiKeyAuth`: + +```typescript +import { withApiKeyAuth } from '@/lib/auth/api-key-middleware' + +// Admin-only route +export const GET = withApiKeyAuth(async (req: Request) => { + // Your handler logic + return NextResponse.json({ ok: true, data: '...' }) +}, { keyType: 'admin' }) + +// Protocol-only route +export const POST = withApiKeyAuth(async (req: Request) => { + // Your handler logic + return NextResponse.json({ ok: true, data: '...' }) +}, { keyType: 'protocol' }) + +// Accept either key type +export const PUT = withApiKeyAuth(async (req: Request) => { + // Your handler logic + return NextResponse.json({ ok: true, data: '...' }) +}, { keyType: 'any' }) +``` + +### Authentication Options + +```typescript +interface ApiKeyAuthOptions { + /** + * The type of API key to accept + * - 'admin': Only accepts admin API keys + * - 'protocol': Only accepts protocol API keys + * - 'any': Accepts both admin and protocol API keys + */ + keyType?: ApiKeyType + + /** + * Custom error message for unauthorized requests + */ + errorMessage?: string + + /** + * Whether to allow requests in development mode without authentication + */ + allowDevMode?: boolean +} +``` + +### Making Authenticated Requests + +#### Using Bearer Token +```bash +curl -X GET http://localhost:3000/api/admin/agents \ + -H "Authorization: Bearer " +``` + +#### Using API-Key Header +```bash +curl -X GET http://localhost:3000/api/admin/agents \ + -H "Authorization: API-Key " +``` + +#### Using X-API-Key Header +```bash +curl -X GET http://localhost:3000/api/admin/agents \ + -H "Authorization: X-API-Key " +``` + +#### Using Plain API Key +```bash +curl -X GET http://localhost:3000/api/admin/agents \ + -H "Authorization: " +``` + +## Protected Routes + +### Admin Routes +- `GET /api/admin/agents` - List cloud agents +- `POST /api/admin/agents` - Provision cloud agent +- `GET /api/admin/claude-costs` - List Claude cost records +- `GET /api/admin/runs` - List orchestration runs +- `POST /api/admin/runs` - Create re-run + +### Protocol Routes +- `POST /api/protocol/x402/quote` - Create x402 quote +- `POST /api/protocol/x402/settle` - Settle x402 payment +- `POST /api/protocol/passport/authorize` - Authorize passport payment +- `GET /api/protocol/reputation` - Get reputation data +- `POST /api/protocol/reputation` - Update reputation + +## Helper Functions + +### `validateApiKey(req, options)` +Validates an API key from the request without wrapping the handler. + +```typescript +import { validateApiKey } from '@/lib/auth/api-key-middleware' + +const validation = validateApiKey(req, { keyType: 'admin' }) +if (!validation.valid) { + return NextResponse.json({ ok: false, error: 'Unauthorized' }, { status: 401 }) +} +``` + +### `isAuthenticated(req)` +Quick check if request is authenticated. + +```typescript +import { isAuthenticated } from '@/lib/auth/api-key-middleware' + +if (!isAuthenticated(req)) { + return NextResponse.json({ ok: false, error: 'Unauthorized' }, { status: 401 }) +} +``` + +### `getApiKeyType(req)` +Get the type of API key used for authentication. + +```typescript +import { getApiKeyType } from '@/lib/auth/api-key-middleware' + +const keyType = getApiKeyType(req) // 'admin' | 'protocol' | null +``` + +## Environment Configuration + +### Development Mode +In development mode, you can optionally bypass authentication: + +```typescript +export const GET = withApiKeyAuth(handler, { + keyType: 'admin', + allowDevMode: true +}) +``` + +### Production +Always set environment variables in production: + +```bash +# Admin API key +ADMIN_API_KEY=osk_your_admin_key_here + +# Protocol gateway token +MOLTBOT_GATEWAY_TOKEN=your_protocol_token_here +``` + +## Security Considerations + +1. **Never commit API keys** to version control +2. **Use environment variables** for all API keys +3. **Rotate keys regularly** in production +4. **Use different keys** for admin and protocol access +5. **Monitor usage** of API keys for suspicious activity +6. **Use HTTPS** in production to prevent key interception + +## Error Responses + +### Unauthorized (401) +```json +{ + "ok": false, + "error": "Unauthorized: Invalid or missing API key" +} +``` + +Headers include: +``` +WWW-Authenticate: Bearer realm="API", API-Key realm="API" +``` + +## Testing + +The middleware includes comprehensive test coverage: + +```bash +# Run API key authentication tests +npm test lib/auth/api-key-middleware.test.ts +``` + +Tests cover: +- Valid and invalid API keys +- Different header formats +- Key type restrictions +- Development mode bypass +- Custom error messages +- Helper functions diff --git a/docs/observability.md b/docs/observability.md new file mode 100644 index 00000000..c4dbffaa --- /dev/null +++ b/docs/observability.md @@ -0,0 +1,519 @@ +# Observability: Structured Logging, Metrics, and Distributed Tracing + +This document describes the observability system for agent activity in Open Stellar, including structured logging with correlation IDs, metrics collection, and distributed tracing. + +## Overview + +The observability system provides three pillars of observability: + +1. **Structured Logging** - Enhanced API logging with correlation IDs and trace context +2. **Metrics Collection** - Counter, gauge, and histogram metrics for agent activity +3. **Distributed Tracing** - OpenTelemetry-compatible tracing for request flows + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ API Request │ +│ │ │ +│ ▼ │ +│ Extract/Create Trace Context │ +│ │ │ +│ ▼ │ +│ Create Span (operation) │ +│ │ │ +│ ▼ │ +│ Execute Business Logic │ +│ │ │ +│ ├─► Add Span Events │ +│ ├─► Record Metrics │ +│ └─► Log with Correlation ID │ +│ │ │ +│ ▼ │ +│ Finish Span (ok/error) │ +│ │ │ +│ ▼ │ +│ Response with Trace Headers │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Structured Logging + +### Enhanced API Logging + +The existing `lib/api-logging.ts` has been enhanced to include correlation IDs and trace context in all log entries. + +**Usage:** + +```typescript +import { createApiRouteLogger } from '@/lib/api-logging' + +export async function GET(req: Request) { + const api = createApiRouteLogger(req, '/api/endpoint') + + return await api.json({ ok: true, data: '...' }, undefined, { + event: 'custom.event', + customField: 'value', + }) +} +``` + +**Log Context includes:** +- `correlationId` - Unique ID for the entire request trace +- `traceId` - Distributed trace ID +- `route` - API route path +- `method` - HTTP method +- `path` - Request path +- `status` - Response status +- `durationMs` - Request duration +- Custom fields from `details` parameter + +### Manual Event Logging + +```typescript +import { logApiEvent } from '@/lib/api-logging' + +await logApiEvent('info', 'custom.event', { + agentId: 'agent-123', + action: 'completed', + duration: 150, +}) +``` + +## Distributed Tracing + +### Trace Context + +Traces are automatically created for each request and propagated via the `traceparent` header. + +**Trace Context Structure:** +```typescript +interface TraceContext { + traceId: string // Unique trace identifier + spanId: string // Current span identifier + parentSpanId?: string // Parent span for hierarchy + sampled: boolean // Whether this trace is sampled (10% default) +} +``` + +### Creating Spans + +```typescript +import { createSpan, finishSpan, addSpanEvent } from '@/lib/observability/tracing' + +// Create a span +const span = createSpan('operation.name', { + agentId: 'agent-123', + action: 'process', +}) + +// Add events during operation +addSpanEvent(span, 'step.completed', { step: 'validation' }) +addSpanEvent(span, 'step.completed', { step: 'processing' }) + +// Finish span +finishSpan(span, 'ok') // or 'error' +``` + +### Using withSpan Helper + +```typescript +import { withSpan } from '@/lib/observability/tracing' + +const result = await withSpan('operation.name', async () => { + // Your operation here + return 'success' +}, { customAttribute: 'value' }) +``` + +### Trace Context Propagation + +**Extract from incoming request:** +```typescript +import { extractTraceContext, setTraceContext } from '@/lib/observability/tracing' + +const traceContext = extractTraceContext(req.headers) +if (traceContext) { + setTraceContext(traceContext) +} +``` + +**Inject into outgoing request:** +```typescript +import { injectTraceContext } from '@/lib/observability/tracing' + +const headers = new Headers() +injectTraceContext(headers) + +// Headers now include: traceparent: 00-{traceId}-{spanId}-{sampled} +``` + +### Getting Correlation ID + +```typescript +import { getCorrelationId } from '@/lib/observability/tracing' + +const correlationId = getCorrelationId() +console.log(`Request correlation ID: ${correlationId}`) +``` + +## Metrics Collection + +### Metric Types + +**Counter** - Monotonically increasing value +```typescript +import { incrementCounter, AgentMetrics } from '@/lib/observability/metrics' + +incrementCounter(AgentMetrics.X402_PAYMENTS_SETTLED, 1, { + chain: 'stellar', + mode: 'production', +}) +``` + +**Gauge** - Point-in-time value +```typescript +import { setGauge } from '@/lib/observability/metrics' + +setGauge(AgentMetrics.AGENT_TASKS_ACTIVE, 5, { + agentId: 'agent-123', +}) +``` + +**Histogram** - Distribution of values +```typescript +import { recordHistogram, AgentMetrics } from '@/lib/observability/metrics' + +recordHistogram(AgentMetrics.X402_SETTLEMENT_DURATION, 150, { + chain: 'stellar', +}) +``` + +### Predefined Metrics + +```typescript +import { AgentMetrics } from '@/lib/observability/metrics' + +// Agent lifecycle +AgentMetrics.AGENT_START +AgentMetrics.AGENT_STOP +AgentMetrics.AGENT_TASKS_TOTAL +AgentMetrics.AGENT_TASKS_ACTIVE + +// x402 payments +AgentMetrics.X402_QUOTES_CREATED +AgentMetrics.X402_PAYMENTS_SETTLED +AgentMetrics.X402_PAYMENTS_FAILED +AgentMetrics.X402_PAYMENT_AMOUNT +AgentMetrics.X402_SETTLEMENT_DURATION + +// Passport operations +AgentMetrics.PASSPORT_MINT +AgentMetrics.PASSPORT_VERIFY +AgentMetrics.PASSPORT_AUTHORIZE +AgentMetrics.PASSPORT_AUTHORIZE_DENIED +AgentMetrics.PASSPORT_VERIFICATION_DURATION + +// API routes +AgentMetrics.API_REQUESTS_TOTAL +AgentMetrics.API_REQUEST_DURATION +AgentMetrics.API_ERRORS_TOTAL + +// System +AgentMetrics.SYSTEM_MEMORY_USAGE +AgentMetrics.SYSTEM_CPU_USAGE +``` + +### Using Timers + +```typescript +import { Timer, AgentMetrics } from '@/lib/observability/metrics' + +// Manual timing +const timer = new Timer(AgentMetrics.X402_SETTLEMENT_DURATION, { chain: 'stellar' }) +// ... perform operation ... +timer.stop() + +// Automatic timing +const result = await Timer.time(AgentMetrics.X402_SETTLEMENT_DURATION, async () => { + // ... perform operation ... + return result +}, { chain: 'stellar' }) +``` + +### Querying Metrics + +```typescript +import { getAllMetrics, getMetricsByName, getLatestMetric } from '@/lib/observability/metrics' + +// Get all metrics +const allMetrics = getAllMetrics() + +// Get metrics by name +const x402Metrics = getMetricsByName('x402_payments_settled_total') + +// Get latest value for specific metric +const latest = getLatestMetric('x402_payments_settled_total', { chain: 'stellar' }) +``` + +### Prometheus Format + +```typescript +import { formatPrometheusMetrics } from '@/lib/observability/metrics' + +const prometheusFormat = formatPrometheusMetrics() +console.log(prometheusFormat) +``` + +Output: +``` +# HELP x402_payments_settled_total Total number of x402 payments settled +# TYPE x402_payments_settled_total counter +x402_payments_settled_total{chain="stellar",mode="production"} 42 + +# HELP x402_settlement_duration_ms Duration of x402 payment settlement +# TYPE x402_settlement_duration_ms histogram +x402_settlement_duration_ms_sum{chain="stellar"} 1250 +x402_settlement_duration_ms_count{chain="stellar"} 10 +x402_settlement_duration_ms_bucket{chain="stellar",le="0.005"} 0 +x402_settlement_duration_ms_bucket{chain="stellar",le="0.01"} 0 +... +``` + +## Instrumentation Examples + +### x402 Payment Flow + +The x402 settlement route includes comprehensive instrumentation: + +```typescript +import { createSpan, addSpanEvent, finishSpan } from '@/lib/observability/tracing' +import { incrementCounter, recordHistogram, Timer, AgentMetrics } from '@/lib/observability/metrics' + +export const POST = withApiKeyAuth(async (req: Request) => { + const timer = new Timer(AgentMetrics.X402_SETTLEMENT_DURATION, { chain: 'unknown' }) + const settlementSpan = createSpan('x402.settlement', { path: '/api/protocol/x402/settle' }) + + try { + // ... business logic ... + + addSpanEvent(settlementSpan, 'passport_gate_check', { agentId, amountUnits }) + const gate = await authorizePayment(agentId, quote.amountUnits) + addSpanEvent(settlementSpan, 'passport_gate_result', { authorized: gate.authorized }) + + if (!gate.authorized) { + finishSpan(settlementSpan, 'error') + incrementCounter(AgentMetrics.X402_PAYMENTS_FAILED, 1, { reason: 'passport_denied', chain }) + timer.stop() + return error response + } + + // ... settlement logic ... + + finishSpan(settlementSpan, 'ok') + incrementCounter(AgentMetrics.X402_PAYMENTS_SETTLED, 1, { chain }) + recordHistogram(AgentMetrics.X402_PAYMENT_AMOUNT, receipt.amountUsd, { chain }) + timer.stop() + + return success response + } catch (error) { + finishSpan(settlementSpan, 'error') + incrementCounter(AgentMetrics.X402_PAYMENTS_FAILED, 1, { reason: 'exception', chain }) + timer.stop() + throw error + } +}, { keyType: 'protocol' }) +``` + +### Passport Authorization + +```typescript +import { createSpan, addSpanEvent, finishSpan } from '@/lib/observability/tracing' +import { incrementCounter, Timer, AgentMetrics } from '@/lib/observability/metrics' + +export const POST = withApiKeyAuth(async (req: Request) => { + const timer = new Timer(AgentMetrics.PASSPORT_VERIFICATION_DURATION) + const authSpan = createSpan('passport.authorize') + + try { + const result = await authorizePayment(agentId, amount) + + addSpanEvent(authSpan, 'authorization_result', { + authorized: result.authorized, + reason: result.reason + }) + + if (result.authorized) { + incrementCounter(AgentMetrics.PASSPORT_AUTHORIZE, 1, { status: 'authorized' }) + } else { + incrementCounter(AgentMetrics.PASSPORT_AUTHORIZE_DENIED, 1, { reason: result.reason }) + } + + finishSpan(authSpan, 'ok') + timer.stop() + return response + } catch (error) { + finishSpan(authSpan, 'error') + incrementCounter(AgentMetrics.PASSPORT_AUTHORIZE_DENIED, 1, { reason: 'exception' }) + timer.stop() + throw error + } +}, { keyType: 'protocol' }) +``` + +## Environment Configuration + +### Logtail Integration + +Set the `LOGTAIL_SOURCE_TOKEN` environment variable to enable structured logging: + +```bash +LOGTAIL_SOURCE_TOKEN=your_logtail_source_token +``` + +### Sampling Rate + +The default trace sampling rate is 10%. This can be adjusted in `lib/observability/tracing.ts`: + +```typescript +sampled: Math.random() < 0.1, // 10% sample rate +``` + +## Testing + +### Running Observability Tests + +```bash +# Run all observability tests +npm test lib/observability/ + +# Run specific test file +npm test lib/observability/tracing.test.ts +npm test lib/observability/metrics.test.ts +``` + +### Test Utilities + +```typescript +import { clearTraceContext } from '@/lib/observability/tracing' +import { clearMetrics } from '@/lib/observability/metrics' + +beforeEach(() => { + clearTraceContext() + clearMetrics() +}) +``` + +## Best Practices + +### 1. Always Create Spans for Operations + +```typescript +// Good +const span = createSpan('operation.name') +// ... logic ... +finishSpan(span, 'ok') + +// Bad +// No span created +``` + +### 2. Add Meaningful Events + +```typescript +// Good +addSpanEvent(span, 'validation_passed', { agentId, amount }) +addSpanEvent(span, 'payment_verified', { txHash }) + +// Bad +addSpanEvent(span, 'step1') +addSpanEvent(span, 'step2') +``` + +### 3. Use Appropriate Metric Types + +```typescript +// Counter for cumulative counts +incrementCounter(AgentMetrics.X402_PAYMENTS_SETTLED, 1) + +// Gauge for current state +setGauge(AgentMetrics.AGENT_TASKS_ACTIVE, currentActiveTasks) + +// Histogram for distributions +recordHistogram(AgentMetrics.X402_SETTLEMENT_DURATION, duration) +``` + +### 4. Include Relevant Labels + +```typescript +// Good +incrementCounter(AgentMetrics.X402_PAYMENTS_SETTLED, 1, { + chain: 'stellar', + mode: 'production', + agentTier: 'premium', +}) + +// Bad +incrementCounter(AgentMetrics.X402_PAYMENTS_SETTLED, 1) +``` + +### 5. Handle Errors in Spans + +```typescript +try { + // ... logic ... + finishSpan(span, 'ok') +} catch (error) { + addSpanEvent(span, 'error', { error: error.message }) + finishSpan(span, 'error') + throw error +} +``` + +## Troubleshooting + +### Missing Correlation IDs + +If correlation IDs are missing from logs: +1. Ensure `extractTraceContext` is called early in the request +2. Check that `getTraceContext` creates a new context if none exists +3. Verify the logging middleware is properly configured + +### Metrics Not Recording + +If metrics are not appearing: +1. Check that the metric name matches `AgentMetrics` constants +2. Verify labels are consistent across calls +3. Use `getAllMetrics()` to debug what's being recorded + +### Spans Not Appearing + +If spans are missing: +1. Ensure `finishSpan` is called for every created span +2. Check that the trace is being sampled (10% default) +3. Verify span events are added before finishing + +## Integration with External Systems + +### Prometheus + +The `formatPrometheusMetrics()` function outputs Prometheus-compatible format. Create an endpoint to expose metrics: + +```typescript +// app/api/metrics/route.ts +import { formatPrometheusMetrics } from '@/lib/observability/metrics' + +export async function GET() { + return new Response(formatPrometheusMetrics(), { + headers: { 'Content-Type': 'text/plain' }, + }) +} +``` + +### OpenTelemetry + +The tracing system is compatible with OpenTelemetry's `traceparent` header format. It can be integrated with OpenTelemetry collectors for distributed tracing visualization. + +### Logtail + +Structured logs are automatically sent to Logtail when `LOGTAIL_SOURCE_TOKEN` is configured. Logs include correlation IDs and trace context for filtering and searching. diff --git a/e2e/helpers/api-helpers.ts b/e2e/helpers/api-helpers.ts new file mode 100644 index 00000000..53d113d4 --- /dev/null +++ b/e2e/helpers/api-helpers.ts @@ -0,0 +1,199 @@ +import { APIRequestContext, expect } from '@playwright/test' + +export interface X402QuoteResponse { + ok: boolean + quote?: { + quoteId: string + paymentRef: string + amountUsd: number + amountUnits: string + address: string + chain: string + expiresAt: string + } + error?: string +} + +export interface X402SettleResponse { + ok: boolean + receipt?: { + id: string + txHash: string + amountUsd: number + settledAt: string + } + error?: string +} + +export interface PassportAuthorizeResponse { + ok: boolean + authorized: boolean + reason: string + cap?: string + error?: string +} + +/** + * Helper class for API interactions in E2E tests + */ +export class APIHelper { + private protocolApiKey: string + private adminApiKey: string + + constructor(private request: APIRequestContext) { + // Get API keys from environment or use test defaults + this.protocolApiKey = process.env.TEST_PROTOCOL_API_KEY || 'test-protocol-key' + this.adminApiKey = process.env.TEST_ADMIN_API_KEY || 'test-admin-key' + } + + /** + * Create headers with API key authentication + */ + private getAuthHeaders(keyType: 'protocol' | 'admin' = 'protocol'): Record { + const apiKey = keyType === 'protocol' ? this.protocolApiKey : this.adminApiKey + return { + 'Authorization': `Bearer ${apiKey}`, + } + } + + /** + * Create an x402 quote + */ + async createX402Quote(params: { + serviceId?: string + chain?: string + payer?: string + units?: number + unitPriceUsd?: number + ttlSeconds?: number + }): Promise { + const response = await this.request.post('/api/protocol/x402/quote', { + headers: this.getAuthHeaders('protocol'), + data: { + serviceId: params.serviceId || 'ai-agent-service', + chain: params.chain || 'stellar', + payer: params.payer || 'test-agent', + units: params.units || 1, + unitPriceUsd: params.unitPriceUsd || 0.1, + ttlSeconds: params.ttlSeconds || 300, + }, + }) + + return await response.json() + } + + /** + * Settle an x402 payment + */ + async settleX402(params: { + paymentRef?: string + quoteId?: string + chain?: string + txHash?: string + paidBy?: string + agentId?: string + }): Promise { + const response = await this.request.post('/api/protocol/x402/settle', { + headers: this.getAuthHeaders('protocol'), + data: { + paymentRef: params.paymentRef, + quoteId: params.quoteId, + chain: params.chain || 'stellar', + txHash: params.txHash || this.generateMockTxHash(), + paidBy: params.paidBy || 'test-agent', + agentId: params.agentId, + }, + }) + + return await response.json() + } + + /** + * Authorize passport payment + */ + async authorizePassportPayment(params: { + agentId: string + amount: string + }): Promise { + const response = await this.request.post('/api/protocol/passport/authorize', { + headers: this.getAuthHeaders('protocol'), + data: params, + }) + + return await response.json() + } + + /** + * Get passport status + */ + async getPassportStatus(agentId: string) { + const response = await this.request.get( + `/api/protocol/passport/status?agentId=${agentId}`, + { + headers: this.getAuthHeaders('protocol'), + } + ) + return await response.json() + } + + /** + * Generate a mock transaction hash for testing + */ + private generateMockTxHash(): string { + return '0x' + Array.from({ length: 64 }, () => + Math.floor(Math.random() * 16).toString(16) + ).join('') + } + + /** + * Wait for a condition to be true with timeout + */ + async waitForCondition( + condition: () => boolean | Promise, + timeout = 5000, + interval = 100 + ): Promise { + const startTime = Date.now() + while (Date.now() - startTime < timeout) { + if (await condition()) { + return + } + await new Promise(resolve => setTimeout(resolve, interval)) + } + throw new Error(`Condition not met within ${timeout}ms`) + } +} + +/** + * Assert helpers for common API responses + */ +export class APIAssertions { + static assertSuccess(response: { ok: boolean; error?: string }, message = 'Request should succeed') { + expect(response.ok, message).toBe(true) + expect(response.error).toBeUndefined() + } + + static assertFailure(response: { ok: boolean; error?: string }, expectedError?: string) { + expect(response.ok, 'Request should fail').toBe(false) + if (expectedError) { + expect(response.error).toContain(expectedError) + } + } + + static assertQuote(quote: any) { + expect(quote).toHaveProperty('quoteId') + expect(quote).toHaveProperty('paymentRef') + expect(quote).toHaveProperty('amountUsd') + expect(quote).toHaveProperty('amountUnits') + expect(quote).toHaveProperty('address') + expect(quote).toHaveProperty('chain') + expect(quote).toHaveProperty('expiresAt') + } + + static assertReceipt(receipt: any) { + expect(receipt).toHaveProperty('id') + expect(receipt).toHaveProperty('txHash') + expect(receipt).toHaveProperty('amountUsd') + expect(receipt).toHaveProperty('settledAt') + } +} diff --git a/e2e/x402-payment-flow.spec.ts b/e2e/x402-payment-flow.spec.ts new file mode 100644 index 00000000..c264ba01 --- /dev/null +++ b/e2e/x402-payment-flow.spec.ts @@ -0,0 +1,318 @@ +import { test, expect } from '@playwright/test' +import { APIHelper, APIAssertions } from './helpers/api-helpers' + +test.describe('x402 Payment Flow E2E', () => { + let apiHelper: APIHelper + + test.beforeEach(async ({ request }) => { + apiHelper = new APIHelper(request) + }) + + test.describe('Quote Creation', () => { + test('should create a valid x402 quote', async () => { + const response = await apiHelper.createX402Quote({ + serviceId: 'test-service', + chain: 'stellar', + payer: 'test-agent-123', + units: 1, + unitPriceUsd: 0.1, + }) + + APIAssertions.assertSuccess(response, 'Quote creation should succeed') + expect(response.quote).toBeDefined() + APIAssertions.assertQuote(response.quote) + expect(response.quote?.chain).toBe('stellar') + expect(response.quote?.amountUsd).toBe(0.1) + }) + + test('should create quote with custom parameters', async () => { + const response = await apiHelper.createX402Quote({ + serviceId: 'custom-service', + chain: 'bnb', + payer: 'custom-agent', + units: 5, + unitPriceUsd: 0.5, + ttlSeconds: 600, + }) + + APIAssertions.assertSuccess(response) + expect(response.quote?.serviceId).toBe('custom-service') + expect(response.quote?.chain).toBe('bnb') + expect(response.quote?.amountUsd).toBe(2.5) // 5 * 0.5 + }) + + test('should handle different chains', async () => { + const chains = ['stellar', 'bnb', 'base'] as const + + for (const chain of chains) { + const response = await apiHelper.createX402Quote({ + chain, + payer: `agent-${chain}`, + }) + + APIAssertions.assertSuccess(response) + expect(response.quote?.chain).toBe(chain) + } + }) + }) + + test.describe('Payment Settlement', () => { + test('should settle payment successfully', async () => { + // First create a quote + const quoteResponse = await apiHelper.createX402Quote({ + payer: 'settle-test-agent', + unitPriceUsd: 0.1, + }) + + APIAssertions.assertSuccess(quoteResponse) + const { paymentRef, chain } = quoteResponse.quote! + + // Then settle the payment + const settleResponse = await apiHelper.settleX402({ + paymentRef, + chain, + paidBy: 'settle-test-agent', + }) + + APIAssertions.assertSuccess(settleResponse, 'Settlement should succeed') + expect(settleResponse.receipt).toBeDefined() + APIAssertions.assertReceipt(settleResponse.receipt) + expect(settleResponse.receipt?.txHash).toBeDefined() + }) + + test('should settle payment using quoteId', async () => { + const quoteResponse = await apiHelper.createX402Quote({ + payer: 'quoteid-test-agent', + }) + + APIAssertions.assertSuccess(quoteResponse) + const { quoteId, chain } = quoteResponse.quote! + + const settleResponse = await apiHelper.settleX402({ + quoteId, + chain, + paidBy: 'quoteid-test-agent', + }) + + APIAssertions.assertSuccess(settleResponse) + expect(settleResponse.receipt).toBeDefined() + }) + + test('should handle settlement with different chains', async () => { + const chains = ['stellar', 'bnb', 'base'] as const + + for (const chain of chains) { + const quoteResponse = await apiHelper.createX402Quote({ + chain, + payer: `chain-test-${chain}`, + }) + + APIAssertions.assertSuccess(quoteResponse) + const { paymentRef } = quoteResponse.quote! + + const settleResponse = await apiHelper.settleX402({ + paymentRef, + chain, + paidBy: `chain-test-${chain}`, + }) + + APIAssertions.assertSuccess(settleResponse) + expect(settleResponse.receipt?.chain).toBe(chain) + } + }) + + test('should fail settlement with invalid paymentRef', async () => { + const settleResponse = await apiHelper.settleX402({ + paymentRef: 'invalid-payment-ref', + chain: 'stellar', + }) + + APIAssertions.assertFailure(settleResponse, 'Quote not found') + }) + + test('should fail settlement with invalid tx hash format', async () => { + const quoteResponse = await apiHelper.createX402Quote({ + payer: 'txhash-test-agent', + }) + + APIAssertions.assertSuccess(quoteResponse) + const { paymentRef, chain } = quoteResponse.quote! + + const settleResponse = await apiHelper.settleX402({ + paymentRef, + chain, + txHash: 'invalid-tx-hash', + }) + + APIAssertions.assertFailure(settleResponse, 'Invalid tx hash format') + }) + }) + + test.describe('Complete Payment Flow', () => { + test('should complete full quote-to-settle flow', async () => { + // Step 1: Create quote + const quoteResponse = await apiHelper.createX402Quote({ + serviceId: 'full-flow-service', + payer: 'full-flow-agent', + units: 3, + unitPriceUsd: 0.25, + }) + + APIAssertions.assertSuccess(quoteResponse) + const { paymentRef, chain, amountUsd, quoteId } = quoteResponse.quote! + expect(amountUsd).toBe(0.75) // 3 * 0.25 + + // Step 2: Settle payment + const settleResponse = await apiHelper.settleX402({ + paymentRef, + chain, + paidBy: 'full-flow-agent', + }) + + APIAssertions.assertSuccess(settleResponse) + const { receipt } = settleResponse + expect(receipt).toBeDefined() + expect(receipt?.amountUsd).toBe(amountUsd) + expect(receipt?.paymentRef).toBe(paymentRef) + }) + + test('should handle multiple sequential payments', async () => { + const payments = [] + + for (let i = 0; i < 3; i++) { + const quoteResponse = await apiHelper.createX402Quote({ + payer: `sequential-agent-${i}`, + unitPriceUsd: 0.1 + (i * 0.05), + }) + + APIAssertions.assertSuccess(quoteResponse) + const { paymentRef, chain } = quoteResponse.quote! + + const settleResponse = await apiHelper.settleX402({ + paymentRef, + chain, + paidBy: `sequential-agent-${i}`, + }) + + APIAssertions.assertSuccess(settleResponse) + payments.push({ + quote: quoteResponse.quote, + receipt: settleResponse.receipt, + }) + } + + expect(payments).toHaveLength(3) + // Verify each payment has unique receipt + const receiptIds = payments.map(p => p.receipt?.id) + const uniqueIds = new Set(receiptIds) + expect(uniqueIds.size).toBe(3) + }) + }) + + test.describe('Passport Gate Integration', () => { + test('should authorize payment with valid passport', async () => { + const authResponse = await apiHelper.authorizePassportPayment({ + agentId: '42', // Known test agent with passport + amount: '10000000', // 1 XLM in stroops + }) + + APIAssertions.assertSuccess(authResponse) + expect(authResponse.authorized).toBe(true) + expect(authResponse.reason).toContain('Within proven spend cap') + expect(authResponse.cap).toBeDefined() + }) + + test('should deny payment exceeding spend cap', async () => { + const authResponse = await apiHelper.authorizePassportPayment({ + agentId: '42', + amount: '500000000', // 50 XLM (exceeds typical cap) + }) + + APIAssertions.assertFailure(authResponse, 'Exceeds proven spend cap') + expect(authResponse.authorized).toBe(false) + }) + + test('should deny payment for agent without passport', async () => { + const authResponse = await apiHelper.authorizePassportPayment({ + agentId: 'non-existent-agent', + amount: '10000000', + }) + + APIAssertions.assertFailure(authResponse, 'No active passport') + expect(authResponse.authorized).toBe(false) + }) + + test('should integrate passport gate with x402 settlement', async () => { + // Create quote + const quoteResponse = await apiHelper.createX402Quote({ + payer: '42', // Agent with passport + unitPriceUsd: 0.1, + }) + + APIAssertions.assertSuccess(quoteResponse) + const { paymentRef, chain } = quoteResponse.quote! + + // Settle with agentId (triggers passport gate) + const settleResponse = await apiHelper.settleX402({ + paymentRef, + chain, + paidBy: '42', + agentId: '42', + }) + + APIAssertions.assertSuccess(settleResponse) + expect(settleResponse.receipt).toBeDefined() + }) + + test('should block settlement when passport gate denies', async () => { + const quoteResponse = await apiHelper.createX402Quote({ + payer: '42', + unitPriceUsd: 100, // Large amount exceeding cap + }) + + APIAssertions.assertSuccess(quoteResponse) + const { paymentRef, chain } = quoteResponse.quote! + + const settleResponse = await apiHelper.settleX402({ + paymentRef, + chain, + paidBy: '42', + agentId: '42', + }) + + APIAssertions.assertFailure(settleResponse, 'Passport gate') + expect(settleResponse.error).toContain('Passport gate') + }) + }) + + test.describe('Error Handling', () => { + test('should handle missing required parameters', async () => { + const response = await apiHelper.createX402Quote({ + // Missing required fields + }) + + // Should still succeed with defaults + APIAssertions.assertSuccess(response) + }) + + test('should handle invalid chain parameter', async () => { + const response = await apiHelper.createX402Quote({ + chain: 'invalid-chain' as any, + }) + + // Should default to valid chain + APIAssertions.assertSuccess(response) + expect(['stellar', 'bnb', 'base']).toContain(response.quote?.chain) + }) + + test('should handle zero amount', async () => { + const response = await apiHelper.createX402Quote({ + units: 0, + unitPriceUsd: 0.1, + }) + + APIAssertions.assertSuccess(response) + expect(response.quote?.amountUsd).toBe(0) + }) + }) +}) diff --git a/e2e/zk-passport-mint.spec.ts b/e2e/zk-passport-mint.spec.ts new file mode 100644 index 00000000..b3e59408 --- /dev/null +++ b/e2e/zk-passport-mint.spec.ts @@ -0,0 +1,284 @@ +import { test, expect } from '@playwright/test' +import { APIHelper, APIAssertions } from './helpers/api-helpers' + +test.describe('ZK Passport Mint Smoke Test', () => { + let apiHelper: APIHelper + + test.beforeEach(async ({ request }) => { + apiHelper = new APIHelper(request) + }) + + test.describe('Passport Status API', () => { + test('should retrieve passport status for known agent', async () => { + const response = await apiHelper.getPassportStatus('42') + + expect(response).toBeDefined() + expect(response.ok).toBe(true) + expect(response.passport).toBeDefined() + + const passport = response.passport + expect(passport).toHaveProperty('agentId') + expect(passport).toHaveProperty('spendCap') + expect(passport).toHaveProperty('status') + expect(passport).toHaveProperty('issuedAt') + expect(passport).toHaveProperty('expiresAt') + }) + + test('should return null for non-existent agent', async () => { + const response = await apiHelper.getPassportStatus('non-existent-agent-12345') + + expect(response).toBeDefined() + expect(response.ok).toBe(true) + expect(response.passport).toBeNull() + }) + + test('should include network information in passport', async () => { + const response = await apiHelper.getPassportStatus('42') + + expect(response.passport).toHaveProperty('network') + expect(['testnet', 'mainnet']).toContain(response.passport.network) + }) + + test('should include registry root and nullifier hash', async () => { + const response = await apiHelper.getPassportStatus('42') + + expect(response.passport).toHaveProperty('registryRoot') + expect(response.passport).toHaveProperty('nullifierHash') + expect(response.passport.registryRoot).toMatch(/^0x[a-fA-F0-9]+$/) + expect(response.passport.nullifierHash).toMatch(/^0x[a-fA-F0-9]+$/) + }) + }) + + test.describe('Passport Authorization', () => { + test('should authorize payment within spend cap', async () => { + const response = await apiHelper.authorizePassportPayment({ + agentId: '42', + amount: '10000000', // 1 XLM in stroops + }) + + APIAssertions.assertSuccess(response) + expect(response.authorized).toBe(true) + expect(response.reason).toContain('Within proven spend cap') + expect(response.cap).toBeDefined() + expect(response.cap).toMatch(/^\d+$/) + }) + + test('should deny payment exceeding spend cap', async () => { + const response = await apiHelper.authorizePassportPayment({ + agentId: '42', + amount: '1000000000', // 100 XLM (exceeds typical cap) + }) + + APIAssertions.assertFailure(response, 'Exceeds proven spend cap') + expect(response.authorized).toBe(false) + expect(response.cap).toBeDefined() + }) + + test('should deny payment for agent without passport', async () => { + const response = await apiHelper.authorizePassportPayment({ + agentId: 'agent-without-passport-999', + amount: '10000000', + }) + + APIAssertions.assertFailure(response, 'No active passport') + expect(response.authorized).toBe(false) + }) + + test('should handle zero amount correctly', async () => { + const response = await apiHelper.authorizePassportPayment({ + agentId: '42', + amount: '0', + }) + + APIAssertions.assertSuccess(response) + expect(response.authorized).toBe(true) + }) + + test('should handle very small amounts', async () => { + const response = await apiHelper.authorizePassportPayment({ + agentId: '42', + amount: '1', // 1 stroop (very small) + }) + + APIAssertions.assertSuccess(response) + expect(response.authorized).toBe(true) + }) + }) + + test.describe('Passport Gate Integration with x402', () => { + test('should allow settlement with valid passport', async () => { + // Create quote for agent with passport + const quoteResponse = await apiHelper.createX402Quote({ + payer: '42', + unitPriceUsd: 0.1, + }) + + APIAssertions.assertSuccess(quoteResponse) + const { paymentRef, chain } = quoteResponse.quote! + + // Settle with agentId (triggers passport gate) + const settleResponse = await apiHelper.settleX402({ + paymentRef, + chain, + paidBy: '42', + agentId: '42', + }) + + APIAssertions.assertSuccess(settleResponse) + expect(settleResponse.receipt).toBeDefined() + expect(settleResponse.receipt?.passportVerified).toBe(true) + }) + + test('should block settlement when amount exceeds cap', async () => { + // Create quote with large amount + const quoteResponse = await apiHelper.createX402Quote({ + payer: '42', + unitPriceUsd: 100, // Large amount + }) + + APIAssertions.assertSuccess(quoteResponse) + const { paymentRef, chain } = quoteResponse.quote! + + // Settlement should be blocked by passport gate + const settleResponse = await apiHelper.settleX402({ + paymentRef, + chain, + paidBy: '42', + agentId: '42', + }) + + APIAssertions.assertFailure(settleResponse, 'Passport gate') + expect(settleResponse.error).toContain('Passport gate') + }) + + test('should allow settlement without agentId (no passport check)', async () => { + const quoteResponse = await apiHelper.createX402Quote({ + payer: '42', + unitPriceUsd: 100, + }) + + APIAssertions.assertSuccess(quoteResponse) + const { paymentRef, chain } = quoteResponse.quote! + + // Settle without agentId (bypasses passport gate) + const settleResponse = await apiHelper.settleX402({ + paymentRef, + chain, + paidBy: '42', + // No agentId provided + }) + + APIAssertions.assertSuccess(settleResponse) + expect(settleResponse.receipt).toBeDefined() + }) + + test('should include gate information in error response', async () => { + const quoteResponse = await apiHelper.createX402Quote({ + payer: '42', + unitPriceUsd: 100, + }) + + APIAssertions.assertSuccess(quoteResponse) + const { paymentRef, chain } = quoteResponse.quote! + + const settleResponse = await apiHelper.settleX402({ + paymentRef, + chain, + paidBy: '42', + agentId: '42', + }) + + APIAssertions.assertFailure(settleResponse) + expect(settleResponse.gate).toBeDefined() + expect(settleResponse.gate).toHaveProperty('authorized') + expect(settleResponse.gate).toHaveProperty('reason') + expect(settleResponse.gate).toHaveProperty('cap') + }) + }) + + test.describe('Passport Lifecycle', () => { + test('should track passport expiration', async () => { + const response = await apiHelper.getPassportStatus('42') + + const passport = response.passport + expect(passport).toHaveProperty('expiresAt') + + const expiresAt = new Date(passport.expiresAt) + const now = new Date() + + // Passport should not be expired for test agent + expect(expiresAt.getTime()).toBeGreaterThan(now.getTime()) + }) + + test('should report passport status correctly', async () => { + const response = await apiHelper.getPassportStatus('42') + + const passport = response.passport + expect(['ACTIVE', 'EXPIRED', 'REVOKED']).toContain(passport.status) + + // Test agent should have active passport + expect(passport.status).toBe('ACTIVE') + }) + + test('should include transaction hash if verified on-chain', async () => { + const response = await apiHelper.getPassportStatus('42') + + const passport = response.passport + // May or may not have txHash depending on verification status + if (passport.txHash) { + expect(passport.txHash).toMatch(/^0x[a-fA-F0-9]{64}$/) + } + }) + }) + + test.describe('Passport Data Integrity', () => { + test('should have consistent spend cap across calls', async () => { + const response1 = await apiHelper.getPassportStatus('42') + const response2 = await apiHelper.getPassportStatus('42') + + expect(response1.passport.spendCap).toBe(response2.passport.spendCap) + }) + + test('should have consistent registry root across calls', async () => { + const response1 = await apiHelper.getPassportStatus('42') + const response2 = await apiHelper.getPassportStatus('42') + + expect(response1.passport.registryRoot).toBe(response2.passport.registryRoot) + }) + + test('should have unique nullifier hash per passport', async () => { + const response = await apiHelper.getPassportStatus('42') + + const passport = response.passport + expect(passport.nullifierHash).toBeDefined() + expect(passport.nullifierHash.length).toBeGreaterThan(0) + }) + }) + + test.describe('Edge Cases', () => { + test('should handle empty agentId gracefully', async () => { + const response = await apiHelper.getPassportStatus('') + + expect(response).toBeDefined() + expect(response.ok).toBe(true) + expect(response.passport).toBeNull() + }) + + test('should handle special characters in agentId', async () => { + const response = await apiHelper.getPassportStatus('agent-with-special-chars_!@#$') + + expect(response).toBeDefined() + expect(response.ok).toBe(true) + expect(response.passport).toBeNull() + }) + + test('should handle very long agentId', async () => { + const longAgentId = 'a'.repeat(500) + const response = await apiHelper.getPassportStatus(longAgentId) + + expect(response).toBeDefined() + expect(response.ok).toBe(true) + expect(response.passport).toBeNull() + }) + }) +}) diff --git a/examples/skills-marketplace-demo.ts b/examples/skills-marketplace-demo.ts index 4562cabd..427a6338 100644 --- a/examples/skills-marketplace-demo.ts +++ b/examples/skills-marketplace-demo.ts @@ -125,7 +125,7 @@ const mockTxHash = '0x' + 'a'.repeat(64) console.log(`📤 Payment sent: ${mockTxHash}`) // Settle the payment -const settlement = settleX402({ +const settlement = await settleX402({ paymentRef: quote.paymentRef, chain: 'stellar', txHash: mockTxHash, diff --git a/lib/api-logging.ts b/lib/api-logging.ts index 272f6208..56e6975c 100644 --- a/lib/api-logging.ts +++ b/lib/api-logging.ts @@ -1,5 +1,6 @@ import { NextResponse } from 'next/server' import { Logtail } from '@logtail/node' +import { getCorrelationId, extractTraceContext, setTraceContext } from '@/lib/observability/tracing' export type ApiLogLevel = 'info' | 'warn' | 'error' @@ -10,6 +11,8 @@ export interface ApiLogContext { status?: number durationMs?: number error?: unknown + correlationId?: string + traceId?: string [key: string]: unknown } @@ -82,12 +85,20 @@ function routeContext( Array.from(url.searchParams.entries(), ([key, value]) => [key, normalizeKeyedValue(key, value)]), ) + // Extract or create trace context + const traceContext = extractTraceContext(req.headers) + if (traceContext) { + setTraceContext(traceContext) + } + return normalizeContext({ route, method: req.method, path: url.pathname, query, durationMs: Date.now() - startedAt, + correlationId: getCorrelationId(), + traceId: traceContext?.traceId, ...details, }) } diff --git a/lib/auth/api-key-middleware.test.ts b/lib/auth/api-key-middleware.test.ts new file mode 100644 index 00000000..8d2a09fd --- /dev/null +++ b/lib/auth/api-key-middleware.test.ts @@ -0,0 +1,274 @@ +import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest' +import { NextResponse } from 'next/server' +import { validateApiKey, withApiKeyAuth, isAuthenticated, getApiKeyType } from './api-key-middleware' + +describe('API Key Authentication Middleware', () => { + const mockAdminKey = 'osk_test_admin_key_123456789012' + const mockProtocolKey = 'test_protocol_key_123456789012' + + beforeEach(() => { + vi.clearAllMocks() + process.env.ADMIN_API_KEY = mockAdminKey + process.env.MOLTBOT_GATEWAY_TOKEN = mockProtocolKey + process.env.NODE_ENV = 'test' + }) + + afterEach(() => { + delete process.env.ADMIN_API_KEY + delete process.env.MOLTBOT_GATEWAY_TOKEN + vi.restoreAllMocks() + }) + + describe('validateApiKey', () => { + it('validates admin API key with Bearer header', () => { + const req = new Request('http://localhost', { + headers: { authorization: `Bearer ${mockAdminKey}` }, + }) + + const result = validateApiKey(req, { keyType: 'admin' }) + expect(result.valid).toBe(true) + expect(result.keyType).toBe('admin') + }) + + it('validates admin API key with API-Key header', () => { + const req = new Request('http://localhost', { + headers: { authorization: `API-Key ${mockAdminKey}` }, + }) + + const result = validateApiKey(req, { keyType: 'admin' }) + expect(result.valid).toBe(true) + expect(result.keyType).toBe('admin') + }) + + it('validates admin API key with X-API-Key header', () => { + const req = new Request('http://localhost', { + headers: { authorization: `X-API-Key ${mockAdminKey}` }, + }) + + const result = validateApiKey(req, { keyType: 'admin' }) + expect(result.valid).toBe(true) + expect(result.keyType).toBe('admin') + }) + + it('validates protocol API key', () => { + const req = new Request('http://localhost', { + headers: { authorization: `Bearer ${mockProtocolKey}` }, + }) + + const result = validateApiKey(req, { keyType: 'protocol' }) + expect(result.valid).toBe(true) + expect(result.keyType).toBe('protocol') + }) + + it('accepts both keys when keyType is any', () => { + const adminReq = new Request('http://localhost', { + headers: { authorization: `Bearer ${mockAdminKey}` }, + }) + const protocolReq = new Request('http://localhost', { + headers: { authorization: `Bearer ${mockProtocolKey}` }, + }) + + const adminResult = validateApiKey(adminReq, { keyType: 'any' }) + const protocolResult = validateApiKey(protocolReq, { keyType: 'any' }) + + expect(adminResult.valid).toBe(true) + expect(adminResult.keyType).toBe('admin') + expect(protocolResult.valid).toBe(true) + expect(protocolResult.keyType).toBe('protocol') + }) + + it('rejects admin key for protocol-only routes', () => { + const req = new Request('http://localhost', { + headers: { authorization: `Bearer ${mockAdminKey}` }, + }) + + const result = validateApiKey(req, { keyType: 'protocol' }) + expect(result.valid).toBe(false) + }) + + it('rejects protocol key for admin-only routes', () => { + const req = new Request('http://localhost', { + headers: { authorization: `Bearer ${mockProtocolKey}` }, + }) + + const result = validateApiKey(req, { keyType: 'admin' }) + expect(result.valid).toBe(false) + }) + + it('rejects invalid API key', () => { + const req = new Request('http://localhost', { + headers: { authorization: 'Bearer invalid_key' }, + }) + + const result = validateApiKey(req, { keyType: 'admin' }) + expect(result.valid).toBe(false) + }) + + it('rejects missing authorization header', () => { + const req = new Request('http://localhost') + + const result = validateApiKey(req, { keyType: 'admin' }) + expect(result.valid).toBe(false) + }) + + it('allows in development mode when allowDevMode is true', () => { + process.env.NODE_ENV = 'development' + const req = new Request('http://localhost') + + const result = validateApiKey(req, { keyType: 'admin', allowDevMode: true }) + expect(result.valid).toBe(true) + expect(result.keyType).toBe('dev') + }) + + it('does not allow in production mode even with allowDevMode', () => { + process.env.NODE_ENV = 'production' + const req = new Request('http://localhost') + + const result = validateApiKey(req, { keyType: 'admin', allowDevMode: true }) + expect(result.valid).toBe(false) + }) + + it('handles plain API key without prefix', () => { + const req = new Request('http://localhost', { + headers: { authorization: mockAdminKey }, + }) + + const result = validateApiKey(req, { keyType: 'admin' }) + expect(result.valid).toBe(true) + }) + }) + + describe('withApiKeyAuth', () => { + it('calls handler when authentication succeeds', async () => { + const mockHandler = vi.fn().mockResolvedValue(NextResponse.json({ ok: true })) + const req = new Request('http://localhost', { + headers: { authorization: `Bearer ${mockAdminKey}` }, + }) + + const wrappedHandler = withApiKeyAuth(mockHandler, { keyType: 'admin' }) + const response = await wrappedHandler(req) + + expect(mockHandler).toHaveBeenCalled() + expect(response.status).toBe(200) + }) + + it('returns 401 when authentication fails', async () => { + const mockHandler = vi.fn() + const req = new Request('http://localhost', { + headers: { authorization: 'Bearer invalid_key' }, + }) + + const wrappedHandler = withApiKeyAuth(mockHandler, { keyType: 'admin' }) + const response = await wrappedHandler(req) + + expect(mockHandler).not.toHaveBeenCalled() + expect(response.status).toBe(401) + + const data = await response.json() + expect(data.ok).toBe(false) + expect(data.error).toContain('Unauthorized') + }) + + it('uses custom error message', async () => { + const mockHandler = vi.fn() + const req = new Request('http://localhost') + + const wrappedHandler = withApiKeyAuth(mockHandler, { + keyType: 'admin', + errorMessage: 'Custom unauthorized message', + }) + const response = await wrappedHandler(req) + + const data = await response.json() + expect(data.error).toBe('Custom unauthorized message') + }) + + it('adds x-api-key-type header to request', async () => { + const mockHandler = vi.fn().mockImplementation((req) => { + const keyType = req.headers.get('x-api-key-type') + return NextResponse.json({ keyType }) + }) + const req = new Request('http://localhost', { + headers: { authorization: `Bearer ${mockAdminKey}` }, + }) + + const wrappedHandler = withApiKeyAuth(mockHandler, { keyType: 'admin' }) + const response = await wrappedHandler(req) + const data = await response.json() + + expect(data.keyType).toBe('admin') + }) + }) + + describe('isAuthenticated', () => { + it('returns true for valid admin key', () => { + const req = new Request('http://localhost', { + headers: { authorization: `Bearer ${mockAdminKey}` }, + }) + + expect(isAuthenticated(req)).toBe(true) + }) + + it('returns true for valid protocol key', () => { + const req = new Request('http://localhost', { + headers: { authorization: `Bearer ${mockProtocolKey}` }, + }) + + expect(isAuthenticated(req)).toBe(true) + }) + + it('returns false for invalid key', () => { + const req = new Request('http://localhost', { + headers: { authorization: 'Bearer invalid' }, + }) + + expect(isAuthenticated(req)).toBe(false) + }) + + it('returns false for missing key', () => { + const req = new Request('http://localhost') + + expect(isAuthenticated(req)).toBe(false) + }) + }) + + describe('getApiKeyType', () => { + it('returns admin key type from header', () => { + const req = new Request('http://localhost', { + headers: { 'x-api-key-type': 'admin' }, + }) + + expect(getApiKeyType(req)).toBe('admin') + }) + + it('returns protocol key type from header', () => { + const req = new Request('http://localhost', { + headers: { 'x-api-key-type': 'protocol' }, + }) + + expect(getApiKeyType(req)).toBe('protocol') + }) + + it('validates and returns key type when header not set', () => { + const req = new Request('http://localhost', { + headers: { authorization: `Bearer ${mockAdminKey}` }, + }) + + expect(getApiKeyType(req)).toBe('admin') + }) + + it('returns null for invalid key', () => { + const req = new Request('http://localhost', { + headers: { authorization: 'Bearer invalid' }, + }) + + expect(getApiKeyType(req)).toBeNull() + }) + + it('returns null for missing key', () => { + const req = new Request('http://localhost') + + expect(getApiKeyType(req)).toBeNull() + }) + }) +}) diff --git a/lib/auth/api-key-middleware.ts b/lib/auth/api-key-middleware.ts new file mode 100644 index 00000000..a5017a1f --- /dev/null +++ b/lib/auth/api-key-middleware.ts @@ -0,0 +1,145 @@ +import { NextResponse } from 'next/server' +import { getAdminApiKey } from '@/lib/admin-api-key' + +export type ApiKeyType = 'admin' | 'protocol' | 'any' + +export interface ApiKeyAuthOptions { + /** + * The type of API key to accept + * - 'admin': Only accepts admin API keys + * - 'protocol': Only accepts protocol API keys + * - 'any': Accepts both admin and protocol API keys + */ + keyType?: ApiKeyType + + /** + * Custom error message for unauthorized requests + */ + errorMessage?: string + + /** + * Whether to allow requests in development mode without authentication + */ + allowDevMode?: boolean +} + +/** + * Validates an API key from the request + */ +export function validateApiKey(req: Request, options: ApiKeyAuthOptions = {}): { valid: boolean; keyType?: string } { + const { keyType = 'any', allowDevMode = false } = options + + // Allow in development mode if configured + if (allowDevMode && process.env.NODE_ENV === 'development') { + return { valid: true, keyType: 'dev' } + } + + const authHeader = req.headers.get('authorization') + if (!authHeader) { + return { valid: false } + } + + // Support both Bearer and API-Key header formats + let apiKey: string | null = null + + if (authHeader.startsWith('Bearer ')) { + apiKey = authHeader.substring(7) + } else if (authHeader.startsWith('API-Key ')) { + apiKey = authHeader.substring(8) + } else if (authHeader.startsWith('X-API-Key ')) { + apiKey = authHeader.substring(10) + } else { + // Try to use the entire header as the key + apiKey = authHeader + } + + if (!apiKey) { + return { valid: false } + } + + // Check admin API key + const adminKey = getAdminApiKey() + if (adminKey && apiKey === adminKey) { + if (keyType === 'protocol') { + return { valid: false } // Admin key not allowed for protocol-only routes + } + return { valid: true, keyType: 'admin' } + } + + // Check protocol API key (MOLTBOT_GATEWAY_TOKEN) + const protocolKey = process.env.MOLTBOT_GATEWAY_TOKEN + if (protocolKey && apiKey === protocolKey) { + if (keyType === 'admin') { + return { valid: false } // Protocol key not allowed for admin-only routes + } + return { valid: true, keyType: 'protocol' } + } + + return { valid: false } +} + +/** + * API key authentication middleware for Next.js API routes + * Returns a NextResponse if unauthorized, null if authorized + */ +export function withApiKeyAuth( + handler: (req: Request, ...args: any[]) => Promise | Response, + options: ApiKeyAuthOptions = {} +) { + return async (req: Request, ...args: any[]): Promise => { + const validation = validateApiKey(req, options) + + if (!validation.valid) { + const errorMessage = options.errorMessage || 'Unauthorized: Invalid or missing API key' + return NextResponse.json( + { ok: false, error: errorMessage }, + { + status: 401, + headers: { + 'WWW-Authenticate': 'Bearer realm="API", API-Key realm="API"', + }, + } + ) + } + + // Add key type to request headers for downstream use + const authenticatedReq = new Request(req.url, { + ...req, + headers: new Headers(req.headers), + }) + authenticatedReq.headers.set('x-api-key-type', validation.keyType || 'unknown') + + return handler(authenticatedReq, ...args) + } +} + +/** + * Higher-order function to wrap route handlers with API key authentication + * Usage: export const GET = withApiKeyAuth(async (req) => { ... }, { keyType: 'admin' }) + */ +export function createApiKeyAuthRoute( + handler: (req: Request, ...args: any[]) => Promise | Response, + options: ApiKeyAuthOptions = {} +) { + return withApiKeyAuth(handler, options) +} + +/** + * Check if a request is authenticated (for use within route handlers) + */ +export function isAuthenticated(req: Request): boolean { + return validateApiKey(req).valid +} + +/** + * Get the type of API key used for authentication + */ +export function getApiKeyType(req: Request): string | null { + const authHeader = req.headers.get('x-api-key-type') + if (authHeader) { + return authHeader + } + + const validation = validateApiKey(req) + return validation.valid ? (validation.keyType || null) : null +} diff --git a/lib/events/system-events.ts b/lib/events/system-events.ts index 7fb661d7..d7e319ee 100644 --- a/lib/events/system-events.ts +++ b/lib/events/system-events.ts @@ -1,6 +1,7 @@ import type { AgentStatus } from "@/lib/types" import type { X402Receipt } from "@/lib/protocols/x402" import type { BadgeRarity } from "@/lib/gamification/badge-catalog" +import { saveSSEEvent, initializeSSEEventStorage } from "@/lib/storage/sse-events-kv" export interface AgentTask { id: string @@ -107,6 +108,8 @@ function appendToEventLog(event: PublishedSystemEvent): void { log.splice(0, log.length - EVENT_LOG_LIMIT) } } +main +} function nextEventId(type: string) { eventBus.sequence += 1 @@ -130,10 +133,18 @@ export function eventMatchesAgent(event: PublishedSystemEvent, agentId?: string) export function publishSystemEvent(event: SystemEvent): PublishedSystemEvent { const published = ensurePublishedEvent(event) - appendToEventLog(published) +main for (const listener of eventBus.listeners) { listener(published) } + + // Persist to KV if available (non-blocking) + if (USE_KV) { + saveSSEEvent(published).catch((error) => { + console.error('Failed to persist SSE event to KV:', error) + }) + } + return published } diff --git a/lib/observability/metrics.test.ts b/lib/observability/metrics.test.ts new file mode 100644 index 00000000..78b1c0b2 --- /dev/null +++ b/lib/observability/metrics.test.ts @@ -0,0 +1,300 @@ +import { describe, expect, it, beforeEach, afterEach } from 'vitest' +import { + incrementCounter, + setGauge, + recordHistogram, + getAllMetrics, + getMetricsByName, + getLatestMetric, + formatPrometheusMetrics, + clearMetrics, + Timer, + AgentMetrics, +} from './metrics' + +describe('Metrics Collection', () => { + beforeEach(() => { + clearMetrics() + }) + + afterEach(() => { + clearMetrics() + }) + + describe('Counter Metrics', () => { + it('should increment counter', () => { + incrementCounter('test_counter', 1, { label: 'test' }, 'Test counter') + + const metrics = getAllMetrics() + expect(metrics).toHaveLength(1) + expect(metrics[0].name).toBe('test_counter') + expect(metrics[0].type).toBe('counter') + expect(metrics[0].value).toBe(1) + expect(metrics[0].labels).toEqual({ label: 'test' }) + }) + + it('should increment counter multiple times', () => { + incrementCounter('test_counter', 1, { label: 'test' }) + incrementCounter('test_counter', 2, { label: 'test' }) + + const metrics = getMetricsByName('test_counter') + expect(metrics).toHaveLength(2) + expect(metrics[1].value).toBe(3) // 1 + 2 + }) + + it('should handle different label combinations separately', () => { + incrementCounter('test_counter', 1, { label: 'a' }) + incrementCounter('test_counter', 1, { label: 'b' }) + + const metrics = getMetricsByName('test_counter') + expect(metrics).toHaveLength(2) + }) + + it('should use default increment value', () => { + incrementCounter('test_counter') + + const metrics = getAllMetrics() + expect(metrics[0].value).toBe(1) + }) + }) + + describe('Gauge Metrics', () => { + it('should set gauge value', () => { + setGauge('test_gauge', 42, { label: 'test' }, 'Test gauge') + + const metrics = getAllMetrics() + expect(metrics).toHaveLength(1) + expect(metrics[0].name).toBe('test_gauge') + expect(metrics[0].type).toBe('gauge') + expect(metrics[0].value).toBe(42) + }) + + it('should overwrite previous gauge value', () => { + setGauge('test_gauge', 10, { label: 'test' }) + setGauge('test_gauge', 20, { label: 'test' }) + + const metrics = getMetricsByName('test_gauge') + expect(metrics).toHaveLength(1) + expect(metrics[0].value).toBe(20) + }) + + it('should handle different label combinations separately', () => { + setGauge('test_gauge', 10, { label: 'a' }) + setGauge('test_gauge', 20, { label: 'b' }) + + const metrics = getMetricsByName('test_gauge') + expect(metrics).toHaveLength(2) + }) + }) + + describe('Histogram Metrics', () => { + it('should record histogram observation', () => { + recordHistogram('test_histogram', 0.5, { label: 'test' }, 'Test histogram') + + const metrics = getAllMetrics() + expect(metrics).toHaveLength(1) + expect(metrics[0].name).toBe('test_histogram') + expect(metrics[0].type).toBe('histogram') + expect((metrics[0] as any).sum).toBe(0.5) + expect((metrics[0] as any).count).toBe(1) + }) + + it('should accumulate histogram observations', () => { + recordHistogram('test_histogram', 0.5, { label: 'test' }) + recordHistogram('test_histogram', 1.5, { label: 'test' }) + + const metrics = getMetricsByName('test_histogram') + const latest = metrics[metrics.length - 1] as any + expect(latest.sum).toBe(2.0) + expect(latest.count).toBe(2) + }) + + it('should populate histogram buckets correctly', () => { + recordHistogram('test_histogram', 0.05, { label: 'test' }) + + const metrics = getAllMetrics() + const histogram = metrics[0] as any + expect(histogram.buckets).toBeDefined() + expect(histogram.buckets.length).toBeGreaterThan(0) + }) + + it('should use default buckets', () => { + recordHistogram('test_histogram', 0.5, { label: 'test' }) + + const metrics = getAllMetrics() + const histogram = metrics[0] as any + expect(histogram.buckets).toBeDefined() + expect(histogram.buckets.length).toBe(12) // Default buckets + +Inf + }) + + it('should use custom buckets', () => { + recordHistogram('test_histogram', 5, { label: 'test' }, '', [1, 5, 10]) + + const metrics = getAllMetrics() + const histogram = metrics[0] as any + expect(histogram.buckets).toHaveLength(3) + expect(histogram.buckets.map(b => b.le)).toEqual([1, 5, 10]) + }) + }) + + describe('Metric Queries', () => { + it('should get all metrics', () => { + incrementCounter('counter1', 1) + setGauge('gauge1', 42) + + const metrics = getAllMetrics() + expect(metrics).toHaveLength(2) + }) + + it('should get metrics by name', () => { + incrementCounter('test_counter', 1, { label: 'a' }) + incrementCounter('test_counter', 1, { label: 'b' }) + setGauge('other_gauge', 42) + + const metrics = getMetricsByName('test_counter') + expect(metrics).toHaveLength(2) + expect(metrics.every(m => m.name === 'test_counter')).toBe(true) + }) + + it('should get latest metric', () => { + incrementCounter('test_counter', 1) + incrementCounter('test_counter', 2) + + const latest = getLatestMetric('test_counter') + expect(latest).toBeDefined() + expect(latest?.value).toBe(3) + }) + + it('should return undefined for non-existent metric', () => { + const latest = getLatestMetric('non_existent') + expect(latest).toBeUndefined() + }) + + it('should get latest metric with specific labels', () => { + incrementCounter('test_counter', 1, { label: 'a' }) + incrementCounter('test_counter', 2, { label: 'b' }) + + const latest = getLatestMetric('test_counter', { label: 'b' }) + expect(latest?.value).toBe(2) + }) + }) + + describe('Prometheus Format', () => { + it('should format counter metrics', () => { + incrementCounter('test_counter', 5, { label: 'test' }, 'Test counter') + + const formatted = formatPrometheusMetrics() + expect(formatted).toContain('# HELP test_counter Test counter') + expect(formatted).toContain('# TYPE test_counter counter') + expect(formatted).toContain('test_counter{label="test"} 5') + }) + + it('should format gauge metrics', () => { + setGauge('test_gauge', 42, { label: 'test' }, 'Test gauge') + + const formatted = formatPrometheusMetrics() + expect(formatted).toContain('# HELP test_gauge Test gauge') + expect(formatted).toContain('# TYPE test_gauge gauge') + expect(formatted).toContain('test_gauge{label="test"} 42') + }) + + it('should format histogram metrics', () => { + recordHistogram('test_histogram', 0.5, { label: 'test' }, 'Test histogram') + + const formatted = formatPrometheusMetrics() + expect(formatted).toContain('# HELP test_histogram Test histogram') + expect(formatted).toContain('# TYPE test_histogram histogram') + expect(formatted).toContain('test_histogram_sum{label="test"}') + expect(formatted).toContain('test_histogram_count{label="test"}') + expect(formatted).toContain('test_histogram_bucket{label="test",le=') + }) + + it('should format multiple metrics', () => { + incrementCounter('counter1', 1) + setGauge('gauge1', 42) + + const formatted = formatPrometheusMetrics() + expect(formatted).toContain('counter1') + expect(formatted).toContain('gauge1') + }) + }) + + describe('Timer', () => { + it('should measure duration', async () => { + const timer = new Timer('test_duration') + await new Promise(resolve => setTimeout(resolve, 10)) + const duration = timer.stop() + + expect(duration).toBeGreaterThanOrEqual(10) + }) + + it('should record histogram on stop', async () => { + const timer = new Timer('test_duration') + await new Promise(resolve => setTimeout(resolve, 10)) + timer.stop() + + const metrics = getMetricsByName('test_duration') + expect(metrics).toHaveLength(1) + expect(metrics[0].type).toBe('histogram') + }) + + it('should use Timer.time helper', async () => { + const result = await Timer.time('test_duration', async () => { + await new Promise(resolve => setTimeout(resolve, 10)) + return 'success' + }) + + expect(result).toBe('success') + const metrics = getMetricsByName('test_duration') + expect(metrics).toHaveLength(1) + }) + + it('should handle exceptions in Timer.time', async () => { + await expect( + Timer.time('test_duration', () => { + throw new Error('test error') + }) + ).rejects.toThrow('test error') + + const metrics = getMetricsByName('test_duration') + expect(metrics).toHaveLength(1) + }) + + it('should include labels in timer', async () => { + const timer = new Timer('test_duration', { operation: 'test' }) + await new Promise(resolve => setTimeout(resolve, 10)) + timer.stop() + + const metrics = getMetricsByName('test_duration') + expect(metrics[0].labels).toEqual({ operation: 'test' }) + }) + }) + + describe('Agent Metrics Constants', () => { + it('should have defined metric names', () => { + expect(AgentMetrics.AGENT_START).toBe('agent_start_total') + expect(AgentMetrics.X402_QUOTES_CREATED).toBe('x402_quotes_created_total') + expect(AgentMetrics.PASSPORT_MINT).toBe('passport_mint_total') + expect(AgentMetrics.API_REQUESTS_TOTAL).toBe('api_requests_total') + }) + + it('should have all required metric categories', () => { + expect(AgentMetrics.AGENT_START).toBeDefined() + expect(AgentMetrics.X402_QUOTES_CREATED).toBeDefined() + expect(AgentMetrics.PASSPORT_MINT).toBeDefined() + expect(AgentMetrics.API_REQUESTS_TOTAL).toBeDefined() + }) + }) + + describe('Clear Metrics', () => { + it('should clear all metrics', () => { + incrementCounter('test_counter', 1) + setGauge('test_gauge', 42) + + clearMetrics() + + const metrics = getAllMetrics() + expect(metrics).toHaveLength(0) + }) + }) +}) diff --git a/lib/observability/metrics.ts b/lib/observability/metrics.ts new file mode 100644 index 00000000..bbd1d5c6 --- /dev/null +++ b/lib/observability/metrics.ts @@ -0,0 +1,300 @@ +/** + * Metrics collection system for agent activity + * Implements counter, gauge, and histogram metrics with aggregation + */ + +export type MetricType = 'counter' | 'gauge' | 'histogram' + +export interface Metric { + name: string + type: MetricType + help: string + labels: Record + value: number + timestamp: number +} + +export interface HistogramBucket { + le: number + count: number +} + +export interface HistogramMetric extends Metric { + type: 'histogram' + sum: number + count: number + buckets: HistogramBucket[] +} + +const globalState = globalThis as typeof globalThis & { + __openStellarMetrics__?: Map +} + +function getMetricsStore(): Map { + if (!globalState.__openStellarMetrics__) { + globalState.__openStellarMetrics__ = new Map() + } + return globalState.__openStellarMetrics__ +} + +/** + * Clear all metrics (for test cleanup) + */ +export function clearMetrics(): void { + globalState.__openStellarMetrics__?.clear() +} + +/** + * Increment a counter metric + */ +export function incrementCounter( + name: string, + value: number = 1, + labels: Record = {}, + help: string = '' +): void { + const store = getMetricsStore() + const key = metricKey(name, labels) + + const existing = store.get(key) || [] + const last = existing[existing.length - 1] + + const metric: Metric = { + name, + type: 'counter', + help, + labels, + value: (last?.value || 0) + value, + timestamp: Date.now(), + } + + store.set(key, [...existing, metric]) +} + +/** + * Set a gauge metric + */ +export function setGauge( + name: string, + value: number, + labels: Record = {}, + help: string = '' +): void { + const store = getMetricsStore() + const key = metricKey(name, labels) + + const metric: Metric = { + name, + type: 'gauge', + help, + labels, + value, + timestamp: Date.now(), + } + + store.set(key, [metric]) +} + +/** + * Record a histogram observation + */ +export function recordHistogram( + name: string, + value: number, + labels: Record = {}, + help: string = '', + buckets: number[] = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10] +): void { + const store = getMetricsStore() + const key = metricKey(name, labels) + + const existing = store.get(key) || [] + const last = existing[existing.length - 1] as HistogramMetric | undefined + + const histogramBuckets = buckets.map(le => ({ + le, + count: (last?.buckets.find(b => b.le === le)?.count || 0) + (value <= le ? 1 : 0), + })) + + const metric: HistogramMetric = { + name, + type: 'histogram', + help, + labels, + value, + sum: (last?.sum || 0) + value, + count: (last?.count || 0) + 1, + buckets: histogramBuckets, + timestamp: Date.now(), + } + + store.set(key, [...existing, metric]) +} + +/** + * Get all metrics + */ +export function getAllMetrics(): Metric[] { + const store = getMetricsStore() + const allMetrics: Metric[] = [] + + for (const metrics of store.values()) { + allMetrics.push(...metrics) + } + + return allMetrics +} + +/** + * Get metrics by name + */ +export function getMetricsByName(name: string): Metric[] { + const store = getMetricsStore() + const metrics: Metric[] = [] + + for (const [key, metricList] of store.entries()) { + if (key.startsWith(`${name}:`)) { + metrics.push(...metricList) + } + } + + return metrics +} + +/** + * Get latest value for a metric + */ +export function getLatestMetric(name: string, labels: Record = {}): Metric | undefined { + const store = getMetricsStore() + const key = metricKey(name, labels) + const metrics = store.get(key) + + return metrics?.[metrics.length - 1] +} + +/** + * Format metrics in Prometheus format + */ +export function formatPrometheusMetrics(): string { + const store = getMetricsStore() + const lines: string[] = [] + + for (const [key, metrics] of store.entries()) { + const latest = metrics[metrics.length - 1] + + // Help line + lines.push(`# HELP ${latest.name} ${latest.help || 'No help text'}`) + lines.push(`# TYPE ${latest.name} ${latest.type}`) + + // Metric line + const labelString = Object.entries(latest.labels) + .map(([k, v]) => `${k}="${v}"`) + .join(',') + + if (latest.type === 'histogram') { + const hist = latest as HistogramMetric + lines.push(`${latest.name}_sum{${labelString}} ${hist.sum}`) + lines.push(`${latest.name}_count{${labelString}} ${hist.count}`) + + for (const bucket of hist.buckets) { + lines.push( + `${latest.name}_bucket{${labelString},le="${bucket.le}"} ${bucket.count}` + ) + } + lines.push(`${latest.name}_bucket{${labelString},le="+Inf"} ${hist.count}`) + } else { + lines.push(`${latest.name}{${labelString}} ${latest.value}`) + } + } + + return lines.join('\n') +} + +/** + * Generate metric key from name and labels + */ +function metricKey(name: string, labels: Record): string { + const labelString = Object.entries(labels) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([k, v]) => `${k}=${v}`) + .join(',') + + return `${name}:${labelString}` +} + +/** + * Timer for measuring duration + */ +export class Timer { + private startTime: number + private name: string + private labels: Record + private help: string + + constructor(name: string, labels: Record = {}, help: string = '') { + this.name = name + this.labels = labels + this.help = help + this.startTime = Date.now() + } + + stop(): number { + const duration = Date.now() - this.startTime + recordHistogram(this.name, duration, this.labels, this.help) + return duration + } + + /** + * Create a timer and run a function + */ + static async time( + name: string, + fn: () => Promise | T, + labels: Record = {}, + help: string = '' + ): Promise { + const timer = new Timer(name, labels, help) + try { + const result = await fn() + timer.stop() + return result + } catch (error) { + timer.stop() + throw error + } + } +} + +/** + * Predefined metric names for agent activity + */ +export const AgentMetrics = { + // Agent lifecycle + AGENT_START: 'agent_start_total', + AGENT_STOP: 'agent_stop_total', + AGENT_TASKS_TOTAL: 'agent_tasks_total', + AGENT_TASKS_ACTIVE: 'agent_tasks_active', + + // x402 payments + X402_QUOTES_CREATED: 'x402_quotes_created_total', + X402_PAYMENTS_SETTLED: 'x402_payments_settled_total', + X402_PAYMENTS_FAILED: 'x402_payments_failed_total', + X402_PAYMENT_AMOUNT: 'x402_payment_amount_usd', + X402_SETTLEMENT_DURATION: 'x402_settlement_duration_ms', + + // Passport operations + PASSPORT_MINT: 'passport_mint_total', + PASSPORT_VERIFY: 'passport_verify_total', + PASSPORT_AUTHORIZE: 'passport_authorize_total', + PASSPORT_AUTHORIZE_DENIED: 'passport_authorize_denied_total', + PASSPORT_VERIFICATION_DURATION: 'passport_verification_duration_ms', + + // API routes + API_REQUESTS_TOTAL: 'api_requests_total', + API_REQUEST_DURATION: 'api_request_duration_ms', + API_ERRORS_TOTAL: 'api_errors_total', + + // System + SYSTEM_MEMORY_USAGE: 'system_memory_usage_bytes', + SYSTEM_CPU_USAGE: 'system_cpu_usage_percent', +} as const diff --git a/lib/observability/tracing.test.ts b/lib/observability/tracing.test.ts new file mode 100644 index 00000000..fca1fa6c --- /dev/null +++ b/lib/observability/tracing.test.ts @@ -0,0 +1,267 @@ +import { describe, expect, it, beforeEach, afterEach } from 'vitest' +import { + getTraceContext, + setTraceContext, + clearTraceContext, + createSpan, + finishSpan, + addSpanEvent, + getCurrentTraceSpans, + withSpan, + extractTraceContext, + injectTraceContext, + getCorrelationId, +} from './tracing' + +describe('Distributed Tracing', () => { + beforeEach(() => { + clearTraceContext() + }) + + afterEach(() => { + clearTraceContext() + }) + + describe('Trace Context', () => { + it('should create new trace context', () => { + const context = getTraceContext() + + expect(context).toBeDefined() + expect(context.traceId).toMatch(/^[0-9a-f-]{36}$/) + expect(context.spanId).toMatch(/^[0-9a-f-]{36}$/) + expect(context.sampled).toBeDefined() + expect(typeof context.sampled).toBe('boolean') + }) + + it('should set custom trace context', () => { + const customContext = { + traceId: 'custom-trace-id', + spanId: 'custom-span-id', + sampled: true, + } + + setTraceContext(customContext) + const context = getTraceContext() + + expect(context.traceId).toBe('custom-trace-id') + expect(context.spanId).toBe('custom-span-id') + expect(context.sampled).toBe(true) + }) + + it('should clear trace context', () => { + getTraceContext() + clearTraceContext() + + const newContext = getTraceContext() + expect(newContext.traceId).not.toBe(getTraceContext().traceId) + }) + + it('should return correlation ID', () => { + const context = getTraceContext() + const correlationId = getCorrelationId() + + expect(correlationId).toBe(context.traceId) + }) + }) + + describe('Span Management', () => { + it('should create a span', () => { + const span = createSpan('test-operation', { key: 'value' }) + + expect(span).toBeDefined() + expect(span.name).toBe('test-operation') + expect(span.traceId).toBe(getTraceContext().traceId) + expect(span.spanId).toBeDefined() + expect(span.parentSpanId).toBeDefined() + expect(span.startTime).toBeDefined() + expect(span.status).toBe('ok') + expect(span.attributes).toEqual({ key: 'value' }) + expect(span.events).toEqual([]) + }) + + it('should finish a span', () => { + const span = createSpan('test-operation') + finishSpan(span, 'ok') + + expect(span.endTime).toBeDefined() + expect(span.durationMs).toBeDefined() + expect(span.durationMs).toBeGreaterThan(0) + expect(span.status).toBe('ok') + }) + + it('should finish span with error status', () => { + const span = createSpan('test-operation') + finishSpan(span, 'error') + + expect(span.status).toBe('error') + }) + + it('should add event to span', () => { + const span = createSpan('test-operation') + addSpanEvent(span, 'test-event', { data: 'test' }) + + expect(span.events).toHaveLength(1) + expect(span.events[0].name).toBe('test-event') + expect(span.events[0].attributes).toEqual({ data: 'test' }) + expect(span.events[0].timestamp).toBeDefined() + }) + + it('should get current trace spans', () => { + const span1 = createSpan('operation-1') + const span2 = createSpan('operation-2') + + const spans = getCurrentTraceSpans() + + expect(spans).toHaveLength(2) + expect(spans.map(s => s.name)).toEqual(['operation-1', 'operation-2']) + }) + + it('should only return spans for current trace', () => { + const span1 = createSpan('operation-1') + + // Create new trace context + clearTraceContext() + setTraceContext({ traceId: 'different-trace', spanId: 'span-1', sampled: true }) + + const span2 = createSpan('operation-2') + + const spans = getCurrentTraceSpans() + expect(spans).toHaveLength(1) + expect(spans[0].traceId).toBe('different-trace') + }) + }) + + describe('withSpan Helper', () => { + it('should run function within span', async () => { + const result = await withSpan('test-operation', async () => { + return 'success' + }) + + expect(result).toBe('success') + const spans = getCurrentTraceSpans() + expect(spans).toHaveLength(1) + expect(spans[0].status).toBe('ok') + }) + + it('should finish span with error on exception', async () => { + await expect( + withSpan('test-operation', () => { + throw new Error('test error') + }) + ).rejects.toThrow('test error') + + const spans = getCurrentTraceSpans() + expect(spans).toHaveLength(1) + expect(spans[0].status).toBe('error') + expect(spans[0].events).toHaveLength(1) + expect(spans[0].events[0].name).toBe('error') + }) + + it('should include error details in span event', async () => { + await expect( + withSpan('test-operation', () => { + throw new Error('test error') + }) + ).rejects.toThrow() + + const spans = getCurrentTraceSpans() + const errorEvent = spans[0].events[0] + expect(errorEvent.attributes.error).toEqual({ + name: 'Error', + message: 'test error', + }) + }) + + it('should include custom attributes', async () => { + await withSpan('test-operation', () => {}, { custom: 'attribute' }) + + const spans = getCurrentTraceSpans() + expect(spans[0].attributes).toEqual({ custom: 'attribute' }) + }) + }) + + describe('Trace Context Propagation', () => { + it('should extract trace context from headers', () => { + const headers = new Headers() + headers.set('traceparent', '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01') + + const context = extractTraceContext(headers) + + expect(context).toEqual({ + traceId: '4bf92f3577b34da6a3ce929d0e0e4736', + spanId: '00f067aa0ba902b7', + sampled: true, + }) + }) + + it('should return null for invalid traceparent format', () => { + const headers = new Headers() + headers.set('traceparent', 'invalid-format') + + const context = extractTraceContext(headers) + expect(context).toBeNull() + }) + + it('should return null when traceparent header missing', () => { + const headers = new Headers() + const context = extractTraceContext(headers) + expect(context).toBeNull() + }) + + it('should inject trace context into headers', () => { + const context = { + traceId: 'test-trace-id', + spanId: 'test-span-id', + sampled: true, + } + + const headers = new Headers() + injectTraceContext(headers, context) + + expect(headers.get('traceparent')).toBe('00-test-trace-id-test-span-id-1') + }) + + it('should inject current trace context when none provided', () => { + getTraceContext() + const headers = new Headers() + injectTraceContext(headers) + + expect(headers.get('traceparent')).toMatch(/^00-[0-9a-f-]{36}-[0-9a-f-]{36}-[01]$/) + }) + + it('should handle unsampled traces', () => { + const context = { + traceId: 'test-trace-id', + spanId: 'test-span-id', + sampled: false, + } + + const headers = new Headers() + injectTraceContext(headers, context) + + expect(headers.get('traceparent')).toBe('00-test-trace-id-test-span-id-0') + }) + }) + + describe('Span Hierarchy', () => { + it('should create parent-child span relationship', () => { + const parentSpan = createSpan('parent-operation') + const parentSpanId = parentSpan.spanId + + const childSpan = createSpan('child-operation') + + expect(childSpan.parentSpanId).toBe(parentSpanId) + }) + + it('should restore parent span ID after finishing child', () => { + const parentSpan = createSpan('parent-operation') + const parentSpanId = parentSpan.spanId + + const childSpan = createSpan('child-operation') + finishSpan(childSpan) + + const currentContext = getTraceContext() + expect(currentContext.spanId).toBe(parentSpanId) + }) + }) +}) diff --git a/lib/observability/tracing.ts b/lib/observability/tracing.ts new file mode 100644 index 00000000..100a1322 --- /dev/null +++ b/lib/observability/tracing.ts @@ -0,0 +1,205 @@ +/** + * Distributed tracing system for agent activity + * Implements OpenTelemetry-compatible tracing with correlation IDs + */ + +export interface TraceContext { + traceId: string + spanId: string + parentSpanId?: string + sampled: boolean +} + +export interface Span { + name: string + traceId: string + spanId: string + parentSpanId?: string + startTime: number + endTime?: number + durationMs?: number + status: 'ok' | 'error' + attributes: Record + events: TraceEvent[] +} + +export interface TraceEvent { + name: string + timestamp: number + attributes: Record +} + +const globalState = globalThis as typeof globalThis & { + __openStellarTraceContext__?: TraceContext + __openStellarSpans__?: Map +} + +function generateId(): string { + return crypto.randomUUID() +} + +/** + * Get or create current trace context + */ +export function getTraceContext(): TraceContext { + if (!globalState.__openStellarTraceContext__) { + globalState.__openStellarTraceContext__ = { + traceId: generateId(), + spanId: generateId(), + sampled: Math.random() < 0.1, // 10% sample rate + } + } + return globalState.__openStellarTraceContext__ +} + +/** + * Set trace context (for incoming requests) + */ +export function setTraceContext(context: TraceContext): void { + globalState.__openStellarTraceContext__ = context +} + +/** + * Clear trace context (for test cleanup) + */ +export function clearTraceContext(): void { + globalState.__openStellarTraceContext__ = undefined + globalState.__openStellarSpans__?.clear() +} + +/** + * Get span storage + */ +function getSpans(): Map { + if (!globalState.__openStellarSpans__) { + globalState.__openStellarSpans__ = new Map() + } + return globalState.__openStellarSpans__ +} + +/** + * Create a new span + */ +export function createSpan(name: string, attributes: Record = {}): Span { + const traceContext = getTraceContext() + const spanId = generateId() + + const span: Span = { + name, + traceId: traceContext.traceId, + spanId, + parentSpanId: traceContext.spanId, + startTime: Date.now(), + status: 'ok', + attributes, + events: [], + } + + getSpans().set(spanId, span) + + // Update current span ID + traceContext.spanId = spanId + + return span +} + +/** + * Finish a span + */ +export function finishSpan(span: Span, status: 'ok' | 'error' = 'ok'): void { + span.endTime = Date.now() + span.durationMs = span.endTime - span.startTime + span.status = status + + // Restore parent span ID + const traceContext = getTraceContext() + if (span.parentSpanId) { + traceContext.spanId = span.parentSpanId + } +} + +/** + * Add event to span + */ +export function addSpanEvent(span: Span, name: string, attributes: Record = {}): void { + span.events.push({ + name, + timestamp: Date.now(), + attributes, + }) +} + +/** + * Get all spans for current trace + */ +export function getCurrentTraceSpans(): Span[] { + const traceContext = getTraceContext() + const spans = getSpans() + + return Array.from(spans.values()).filter( + span => span.traceId === traceContext.traceId + ) +} + +/** + * Run function within a span + */ +export async function withSpan( + name: string, + fn: () => Promise | T, + attributes: Record = {} +): Promise { + const span = createSpan(name, attributes) + + try { + const result = await fn() + finishSpan(span, 'ok') + return result + } catch (error) { + finishSpan(span, 'error') + addSpanEvent(span, 'error', { + error: error instanceof Error ? { + name: error.name, + message: error.message, + } : String(error), + }) + throw error + } +} + +/** + * Extract trace context from headers + */ +export function extractTraceContext(headers: Headers): TraceContext | null { + const traceParent = headers.get('traceparent') + if (!traceParent) return null + + // Format: 00-{traceId}-{spanId}-{traceFlags} + const parts = traceParent.split('-') + if (parts.length !== 4) return null + + const [, traceId, spanId, traceFlags] = parts + + return { + traceId, + spanId, + sampled: traceFlags[0] === '1', + } +} + +/** + * Inject trace context into headers + */ +export function injectTraceContext(headers: Headers, context?: TraceContext): void { + const ctx = context || getTraceContext() + const traceFlags = ctx.sampled ? '1' : '0' + + headers.set('traceparent', `00-${ctx.traceId}-${ctx.spanId}-${traceFlags}`) +} + +/** + * Get correlation ID for logging + */ +export function getCorrelationId(): string { + return getTraceContext().traceId +} diff --git a/lib/passport/passport.test.ts b/lib/passport/passport.test.ts new file mode 100644 index 00000000..8860c9cd --- /dev/null +++ b/lib/passport/passport.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest' +import { authorizePayment, loadPassportCollection, savePassport, type AgentPassport } from './passport' + +// Mock localStorage for Node environment +const localStorageMock = (() => { + let store: Record = {} + return { + getItem: (key: string) => store[key] || null, + setItem: (key: string, value: string) => { store[key] = value }, + removeItem: (key: string) => { delete store[key] }, + clear: () => { store = {} }, + } +})() + +describe('passport authorizePayment gate', () => { + const testAgentId = 'test-agent-123' + const collectionKey = `open-stellar:passport-collection:testnet:${testAgentId}` + + beforeEach(() => { + // Setup localStorage mock + vi.stubGlobal('localStorage', localStorageMock) + // Clear localStorage before each test + localStorageMock.removeItem(collectionKey) + }) + + afterEach(() => { + // Clean up after each test + localStorageMock.removeItem(collectionKey) + vi.unstubAllGlobals() + }) + + it('rejects payment when agent has no passport', async () => { + const result = await authorizePayment(testAgentId, '10000000') + + expect(result.authorized).toBe(false) + expect(result.reason).toBe('No active passport — agent not verified') + expect(result.cap).toBeUndefined() + }) + + it('rejects payment when amount exceeds spend cap', async () => { + const passport: AgentPassport = { + id: 'test-passport-1', + agentId: testAgentId, + spendCap: '10000000', // 1 XLM (7 decimals) + registryRoot: '0x' + 'a'.repeat(64), + nullifierHash: '0x' + 'b'.repeat(64), + issuedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(), + status: 'ACTIVE', + network: 'testnet', + } + + savePassport(passport) + + const result = await authorizePayment(testAgentId, '20000000') // 2 XLM + + expect(result.authorized).toBe(false) + expect(result.reason).toBe('Exceeds proven spend cap') + expect(result.cap).toBe('10000000') + }) + + it('approves payment when amount is within spend cap', async () => { + const passport: AgentPassport = { + id: 'test-passport-2', + agentId: testAgentId, + spendCap: '10000000', // 1 XLM + registryRoot: '0x' + 'c'.repeat(64), + nullifierHash: '0x' + 'd'.repeat(64), + issuedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(), + status: 'ACTIVE', + network: 'testnet', + } + + savePassport(passport) + + const result = await authorizePayment(testAgentId, '5000000') // 0.5 XLM + + expect(result.authorized).toBe(true) + expect(result.reason).toBe('Within proven spend cap') + expect(result.cap).toBe('10000000') + }) + + it('approves payment when amount exactly equals spend cap', async () => { + const passport: AgentPassport = { + id: 'test-passport-3', + agentId: testAgentId, + spendCap: '10000000', + registryRoot: '0x' + 'e'.repeat(64), + nullifierHash: '0x' + 'f'.repeat(64), + issuedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(), + status: 'ACTIVE', + network: 'testnet', + } + + savePassport(passport) + + const result = await authorizePayment(testAgentId, '10000000') + + expect(result.authorized).toBe(true) + expect(result.reason).toBe('Within proven spend cap') + expect(result.cap).toBe('10000000') + }) + + it('rejects payment when passport is expired', async () => { + const passport: AgentPassport = { + id: 'test-passport-4', + agentId: testAgentId, + spendCap: '10000000', + registryRoot: '0x' + '1'.repeat(64), + nullifierHash: '0x' + '2'.repeat(64), + issuedAt: new Date(Date.now() - 100 * 24 * 60 * 60 * 1000).toISOString(), + expiresAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(), // Expired yesterday + status: 'ACTIVE', + network: 'testnet', + } + + savePassport(passport) + + const result = await authorizePayment(testAgentId, '5000000') + + // Expired passports are not considered active + expect(result.authorized).toBe(false) + expect(result.reason).toBe('No active passport — agent not verified') + }) + + it('rejects payment when passport is revoked', async () => { + const passport: AgentPassport = { + id: 'test-passport-5', + agentId: testAgentId, + spendCap: '10000000', + registryRoot: '0x' + '3'.repeat(64), + nullifierHash: '0x' + '4'.repeat(64), + issuedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(), + status: 'REVOKED', + network: 'testnet', + } + + savePassport(passport) + + const result = await authorizePayment(testAgentId, '5000000') + + expect(result.authorized).toBe(false) + expect(result.reason).toBe('No active passport — agent not verified') + }) + + it('handles zero amount correctly', async () => { + const passport: AgentPassport = { + id: 'test-passport-6', + agentId: testAgentId, + spendCap: '10000000', + registryRoot: '0x' + '5'.repeat(64), + nullifierHash: '0x' + '6'.repeat(64), + issuedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(), + status: 'ACTIVE', + network: 'testnet', + } + + savePassport(passport) + + const result = await authorizePayment(testAgentId, '0') + + expect(result.authorized).toBe(true) + expect(result.reason).toBe('Within proven spend cap') + }) + + it('uses primary passport when multiple passports exist', async () => { + const passport1: AgentPassport = { + id: 'test-passport-7', + agentId: testAgentId, + spendCap: '5000000', // 0.5 XLM + registryRoot: '0x' + '7'.repeat(64), + nullifierHash: '0x' + '8'.repeat(64), + issuedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(), + status: 'ACTIVE', + network: 'testnet', + } + + const passport2: AgentPassport = { + id: 'test-passport-8', + agentId: testAgentId, + spendCap: '10000000', // 1 XLM + registryRoot: '0x' + '9'.repeat(64), + nullifierHash: '0x' + 'a'.repeat(63) + 'b', + issuedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(), + status: 'ACTIVE', + network: 'testnet', + } + + // Save both passports + savePassport(passport1) + const collection = savePassport(passport2) + + // Set passport2 as primary + collection.primaryPassport = passport2.id + localStorageMock.setItem(collectionKey, JSON.stringify(collection)) + + const result = await authorizePayment(testAgentId, '7500000') // 0.75 XLM + + // Should use passport2 (1 XLM cap) which can cover 0.75 XLM + expect(result.authorized).toBe(true) + expect(result.cap).toBe('10000000') + }) +}) diff --git a/lib/protocols/x402-receipt-store.ts b/lib/protocols/x402-receipt-store.ts index 9c9ba4d7..c7f3f0cc 100644 --- a/lib/protocols/x402-receipt-store.ts +++ b/lib/protocols/x402-receipt-store.ts @@ -3,6 +3,15 @@ import { dirname, join } from 'node:path' import { cwd } from 'node:process' import type { SettlementChain, X402ExplorerReceipt } from '@/lib/protocols/x402' +// Import Postgres implementation +import { + initializeX402ReceiptTable, + saveX402Receipt as saveX402ReceiptToPostgres, + getX402Receipt as getX402ReceiptFromPostgres, + listX402Receipts as listX402ReceiptsFromPostgres, + resetX402ReceiptStoreForTests as resetPostgresStore, +} from '@/lib/storage/x402-receipt-postgres' + export interface X402ReceiptQuery { agent?: string q?: string @@ -29,6 +38,16 @@ export interface X402ReceiptPage { const DEFAULT_DB_PATH = join(cwd(), '.data', 'x402-receipts.json') const DB_PATH = process.env.X402_RECEIPT_DB_PATH || DEFAULT_DB_PATH +// Check if Postgres is available +const USE_POSTGRES = process.env.POSTGRES_URL !== undefined || process.env.POSTGRES_PRISMA_URL !== undefined + +// Initialize Postgres table if available +if (USE_POSTGRES) { + initializeX402ReceiptTable().catch((error) => { + console.error('Failed to initialize Postgres x402 receipts table, falling back to file storage:', error) + }) +} + function ensureDb(): void { const dir = dirname(DB_PATH) if (!existsSync(dir)) { @@ -54,18 +73,48 @@ function writeReceipts(receipts: X402ExplorerReceipt[]): void { renameSync(tmpPath, DB_PATH) } -export function saveX402Receipt(receipt: X402ExplorerReceipt): X402ExplorerReceipt { +export async function saveX402Receipt(receipt: X402ExplorerReceipt): Promise { + if (USE_POSTGRES) { + try { + return await saveX402ReceiptToPostgres(receipt) + } catch (error) { + console.error('Failed to save to Postgres, falling back to file storage:', error) + // Fall back to file storage + } + } + + // File-based storage (fallback) const receipts = readReceipts() const next = [receipt, ...receipts.filter((item) => item.id !== receipt.id)] writeReceipts(next) return receipt } -export function getX402Receipt(receiptId: string): X402ExplorerReceipt | undefined { +export async function getX402Receipt(receiptId: string): Promise { + if (USE_POSTGRES) { + try { + return await getX402ReceiptFromPostgres(receiptId) + } catch (error) { + console.error('Failed to get from Postgres, falling back to file storage:', error) + // Fall back to file storage + } + } + + // File-based storage (fallback) return readReceipts().find((receipt) => receipt.id === receiptId) } -export function listX402Receipts(filters: X402ReceiptQuery = {}): X402ReceiptPage { +export async function listX402Receipts(filters: X402ReceiptQuery = {}): Promise { + if (USE_POSTGRES) { + try { + return await listX402ReceiptsFromPostgres(filters) + } catch (error) { + console.error('Failed to list from Postgres, falling back to file storage:', error) + // Fall back to file storage + } + } + + // File-based storage (fallback) const pageSize = Math.max(1, Math.min(50, Math.floor(filters.pageSize ?? 50))) const page = Math.max(1, Math.floor(filters.page ?? 1)) const q = (filters.q || '').trim().toLowerCase() @@ -114,6 +163,17 @@ export function listX402Receipts(filters: X402ReceiptQuery = {}): X402ReceiptPag } } -export function resetX402ReceiptStoreForTests(): void { +export async function resetX402ReceiptStoreForTests(): Promise { + if (USE_POSTGRES) { + try { + await resetPostgresStore() + return + } catch (error) { + console.error('Failed to reset Postgres store, falling back to file storage:', error) + // Fall back to file storage + } + } + + // File-based storage (fallback) writeReceipts([]) } diff --git a/lib/protocols/x402.ts b/lib/protocols/x402.ts index 2695f81c..889108b0 100644 --- a/lib/protocols/x402.ts +++ b/lib/protocols/x402.ts @@ -191,11 +191,11 @@ export async function verifyX402Settlement(input: X402Settlement, quote?: X402Qu } } -export function listX402ExplorerReceipts(filters: X402ReceiptQuery = {}) { - return listX402Receipts(filters) +export async function listX402ExplorerReceipts(filters: X402ReceiptQuery = {}) { + return await listX402Receipts(filters) } -export function settleX402(input: X402Settlement): X402SettlementResult { +export async function settleX402(input: X402Settlement): Promise { const paymentRef = input.paymentRef || input.quoteId || '' const quote = quoteRegistry.get(paymentRef) if (!quote) return { ok: false, error: 'Quote not found for paymentRef' } @@ -226,7 +226,7 @@ export function settleX402(input: X402Settlement): X402SettlementResult { receipt.amountUsd = quote.amountUsd receipt.amountUnits = option.amountUnits - const storedReceipt = saveX402Receipt({ + const storedReceipt = await saveX402Receipt({ ...receipt, id: `rcpt_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`, agentId: quote.payer, diff --git a/lib/storage/sql/x402-receipts.sql b/lib/storage/sql/x402-receipts.sql new file mode 100644 index 00000000..6ce301ec --- /dev/null +++ b/lib/storage/sql/x402-receipts.sql @@ -0,0 +1,48 @@ +-- X402 Receipts Table +-- Stores payment receipts for x402 protocol settlements + +CREATE TABLE IF NOT EXISTS x402_receipts ( + id VARCHAR(255) PRIMARY KEY, + quote_id VARCHAR(255), + payment_ref VARCHAR(511) NOT NULL, + settled_at TIMESTAMP WITH TIME ZONE NOT NULL, + tx_hash VARCHAR(255) NOT NULL, + chain VARCHAR(50) NOT NULL, + amount_usd DECIMAL(10, 6), + amount_units VARCHAR(255), + accepted BOOLEAN NOT NULL DEFAULT true, + + -- Agent and service information + agent_id VARCHAR(255), + agent VARCHAR(255), + service VARCHAR(255), + service_id VARCHAR(255), + + -- Passport and reputation metadata + passport_verified BOOLEAN DEFAULT true, + reputation_tier VARCHAR(50), + + -- Indexes for common queries + CONSTRAINT valid_tx_hash CHECK ( + chain = 'stellar' AND ( + tx_hash ~ '^0x[a-fA-F0-9]{64}$' OR + tx_hash ~ '^[a-fA-F0-9]{64}$' OR + tx_hash ~ '^[A-Z0-9]{64}$' + ) OR + chain IN ('bnb', 'base') AND tx_hash ~ '^0x[a-fA-F0-9]{64}$' + ) +); + +-- Indexes for performance +CREATE INDEX IF NOT EXISTS idx_x402_receipts_agent_id ON x402_receipts(agent_id); +CREATE INDEX IF NOT EXISTS idx_x402_receipts_service_id ON x402_receipts(service_id); +CREATE INDEX IF NOT EXISTS idx_x402_receipts_chain ON x402_receipts(chain); +CREATE INDEX IF NOT EXISTS idx_x402_receipts_settled_at ON x402_receipts(settled_at DESC); +CREATE INDEX IF NOT EXISTS idx_x402_receipts_payment_ref ON x402_receipts(payment_ref); +CREATE INDEX IF NOT EXISTS idx_x402_receipts_quote_id ON x402_receipts(quote_id); + +-- Composite index for agent-specific queries with pagination +CREATE INDEX IF NOT EXISTS idx_x402_receipts_agent_settled ON x402_receipts(agent_id, settled_at DESC); + +-- Composite index for service-specific queries with pagination +CREATE INDEX IF NOT EXISTS idx_x402_receipts_service_settled ON x402_receipts(service_id, settled_at DESC); diff --git a/lib/storage/sse-events-kv.test.ts b/lib/storage/sse-events-kv.test.ts new file mode 100644 index 00000000..c0f2a2c7 --- /dev/null +++ b/lib/storage/sse-events-kv.test.ts @@ -0,0 +1,319 @@ +import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest' +import { kv } from '@vercel/kv' +import type { PublishedSystemEvent } from '@/lib/events/system-events' +import { + initializeSSEEventStorage, + saveSSEEvent, + getSSEEvent, + getRecentGlobalEvents, + getRecentAgentEvents, + getRecentEventsByType, + getEventsInRange, + getSSEEventStats, + cleanupOldEvents, + resetSSEEventStorageForTests, +} from './sse-events-kv' + +// Mock the kv module +vi.mock('@vercel/kv', () => ({ + kv: { + setnx: vi.fn(), + set: vi.fn(), + get: vi.fn(), + zadd: vi.fn(), + expire: vi.fn(), + zrevrange: vi.fn(), + zrangebyscore: vi.fn(), + zcard: vi.fn(), + keys: vi.fn(), + del: vi.fn(), + incr: vi.fn(), + zremrangebyscore: vi.fn(), + }, +})) + +describe('SSE events KV storage', () => { + const mockEvent: PublishedSystemEvent = { + id: 'test-event-1', + type: 'agent.status', + agentId: 'agent-123', + occurredAt: '2024-01-15T10:30:00.000Z', + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('initializeSSEEventStorage', () => { + it('initializes counters successfully', async () => { + vi.mocked(kv.setnx).mockResolvedValue('OK') + + await expect(initializeSSEEventStorage()).resolves.not.toThrow() + + expect(kv.setnx).toHaveBeenCalledTimes(2) + expect(kv.setnx).toHaveBeenCalledWith('sse:events:latest_id', '0') + expect(kv.setnx).toHaveBeenCalledWith('sse:events:count', '0') + }) + + it('handles initialization errors gracefully', async () => { + vi.mocked(kv.setnx).mockRejectedValue(new Error('KV connection failed')) + + await expect(initializeSSEEventStorage()).rejects.toThrow('KV connection failed') + }) + }) + + describe('saveSSEEvent', () => { + it('saves event to all required storage locations', async () => { + vi.mocked(kv.set).mockResolvedValue('OK') + vi.mocked(kv.zadd).mockResolvedValue(1) + vi.mocked(kv.expire).mockResolvedValue(1) + vi.mocked(kv.incr).mockResolvedValue(1) + + await expect(saveSSEEvent(mockEvent)).resolves.not.toThrow() + + expect(kv.set).toHaveBeenCalledWith( + 'sse:event:test-event-1', + JSON.stringify(mockEvent), + expect.objectContaining({ ex: expect.any(Number) }) + ) + expect(kv.zadd).toHaveBeenCalledWith('sse:events:global', { + score: expect.any(Number), + member: 'test-event-1', + }) + expect(kv.zadd).toHaveBeenCalledWith('sse:events:agent:agent-123', { + score: expect.any(Number), + member: 'test-event-1', + }) + expect(kv.zadd).toHaveBeenCalledWith('sse:events:type:agent.status', { + score: expect.any(Number), + member: 'test-event-1', + }) + }) + + it('handles non-agent-scoped events correctly', async () => { + const nonAgentEvent: PublishedSystemEvent = { + id: 'test-event-2', + type: 'district.unlocked', + occurredAt: '2024-01-15T10:30:00.000Z', + } + + vi.mocked(kv.set).mockResolvedValue('OK') + vi.mocked(kv.zadd).mockResolvedValue(1) + vi.mocked(kv.expire).mockResolvedValue(1) + vi.mocked(kv.incr).mockResolvedValue(1) + + await expect(saveSSEEvent(nonAgentEvent)).resolves.not.toThrow() + + // Should not call zadd for agent-specific stream + expect(kv.zadd).not.toHaveBeenCalledWith( + 'sse:events:agent:', + expect.anything() + ) + }) + + it('handles save errors gracefully', async () => { + vi.mocked(kv.set).mockRejectedValue(new Error('Save failed')) + + await expect(saveSSEEvent(mockEvent)).rejects.toThrow('Save failed') + }) + }) + + describe('getSSEEvent', () => { + it('retrieves event by ID', async () => { + vi.mocked(kv.get).mockResolvedValue(JSON.stringify(mockEvent)) + + const result = await getSSEEvent('test-event-1') + + expect(result).toEqual(mockEvent) + expect(kv.get).toHaveBeenCalledWith('sse:event:test-event-1') + }) + + it('returns null for non-existent event', async () => { + vi.mocked(kv.get).mockResolvedValue(null) + + const result = await getSSEEvent('non-existent') + + expect(result).toBeNull() + }) + + it('handles get errors gracefully', async () => { + vi.mocked(kv.get).mockRejectedValue(new Error('Get failed')) + + await expect(getSSEEvent('test-event-1')).rejects.toThrow('Get failed') + }) + }) + + describe('getRecentGlobalEvents', () => { + it('retrieves recent events from global stream', async () => { + vi.mocked(kv.zrevrange).mockResolvedValue(['test-event-1', 'test-event-2']) + vi.mocked(kv.get) + .mockResolvedValueOnce(JSON.stringify(mockEvent)) + .mockResolvedValueOnce(JSON.stringify({ ...mockEvent, id: 'test-event-2' })) + + const result = await getRecentGlobalEvents(10) + + expect(result).toHaveLength(2) + expect(kv.zrevrange).toHaveBeenCalledWith('sse:events:global', 0, 9) + }) + + it('returns empty array when no events exist', async () => { + vi.mocked(kv.zrevrange).mockResolvedValue([]) + + const result = await getRecentGlobalEvents() + + expect(result).toEqual([]) + }) + + it('handles errors gracefully', async () => { + vi.mocked(kv.zrevrange).mockRejectedValue(new Error('Query failed')) + + await expect(getRecentGlobalEvents()).rejects.toThrow('Query failed') + }) + }) + + describe('getRecentAgentEvents', () => { + it('retrieves recent events for specific agent', async () => { + vi.mocked(kv.zrevrange).mockResolvedValue(['test-event-1']) + vi.mocked(kv.get).mockResolvedValue(JSON.stringify(mockEvent)) + + const result = await getRecentAgentEvents('agent-123', 10) + + expect(result).toHaveLength(1) + expect(kv.zrevrange).toHaveBeenCalledWith('sse:events:agent:agent-123', 0, 9) + }) + + it('returns empty array when agent has no events', async () => { + vi.mocked(kv.zrevrange).mockResolvedValue([]) + + const result = await getRecentAgentEvents('agent-456') + + expect(result).toEqual([]) + }) + }) + + describe('getRecentEventsByType', () => { + it('retrieves recent events of specific type', async () => { + vi.mocked(kv.zrevrange).mockResolvedValue(['test-event-1']) + vi.mocked(kv.get).mockResolvedValue(JSON.stringify(mockEvent)) + + const result = await getRecentEventsByType('agent.status', 10) + + expect(result).toHaveLength(1) + expect(kv.zrevrange).toHaveBeenCalledWith('sse:events:type:agent.status', 0, 9) + }) + + it('returns empty array when type has no events', async () => { + vi.mocked(kv.zrevrange).mockResolvedValue([]) + + const result = await getRecentEventsByType('non.existent.type') + + expect(result).toEqual([]) + }) + }) + + describe('getEventsInRange', () => { + it('retrieves events within time range', async () => { + const startTime = Date.now() - 3600000 // 1 hour ago + const endTime = Date.now() + + vi.mocked(kv.zrangebyscore).mockResolvedValue(['test-event-1']) + vi.mocked(kv.get).mockResolvedValue(JSON.stringify(mockEvent)) + + const result = await getEventsInRange(startTime, endTime, 10) + + expect(result).toHaveLength(1) + expect(kv.zrangebyscore).toHaveBeenCalledWith( + 'sse:events:global', + startTime, + endTime, + expect.objectContaining({ rev: true, count: 10 }) + ) + }) + + it('returns empty array when no events in range', async () => { + vi.mocked(kv.zrangebyscore).mockResolvedValue([]) + + const result = await getEventsInRange(0, 1000) + + expect(result).toEqual([]) + }) + }) + + describe('getSSEEventStats', () => { + it('returns event statistics', async () => { + vi.mocked(kv.get) + .mockResolvedValueOnce('100') + .mockResolvedValueOnce('test-event-100') + vi.mocked(kv.zcard).mockResolvedValue(100) + vi.mocked(kv.keys) + .mockResolvedValueOnce(['sse:events:agent:1', 'sse:events:agent:2']) + .mockResolvedValueOnce(['sse:events:type:1', 'sse:events:type:2']) + + const result = await getSSEEventStats() + + expect(result.totalEvents).toBe(100) + expect(result.latestEventId).toBe('test-event-100') + expect(result.streamSizes.global).toBe(100) + expect(result.streamSizes.byAgent).toBe(2) + expect(result.streamSizes.byType).toBe(2) + }) + + it('handles missing stats gracefully', async () => { + vi.mocked(kv.get) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + vi.mocked(kv.zcard).mockResolvedValue(0) + vi.mocked(kv.keys).mockResolvedValue([]) + + const result = await getSSEEventStats() + + expect(result.totalEvents).toBe(0) + expect(result.latestEventId).toBe('') + expect(result.streamSizes.global).toBe(0) + }) + }) + + describe('cleanupOldEvents', () => { + it('removes events older than specified time', async () => { + vi.mocked(kv.zremrangebyscore).mockResolvedValue(50) + + const result = await cleanupOldEvents(3600000) // 1 hour + + expect(result).toBe(50) + expect(kv.zremrangebyscore).toHaveBeenCalledWith( + 'sse:events:global', + 0, + expect.any(Number) + ) + }) + + it('handles cleanup errors gracefully', async () => { + vi.mocked(kv.zremrangebyscore).mockRejectedValue(new Error('Cleanup failed')) + + await expect(cleanupOldEvents()).rejects.toThrow('Cleanup failed') + }) + }) + + describe('resetSSEEventStorageForTests', () => { + it('clears all SSE keys and reinitializes', async () => { + vi.mocked(kv.keys).mockResolvedValue(['sse:events:global', 'sse:event:test']) + vi.mocked(kv.del).mockResolvedValue(1) + vi.mocked(kv.setnx).mockResolvedValue('OK') + + await expect(resetSSEEventStorageForTests()).resolves.not.toThrow() + + expect(kv.del).toHaveBeenCalledWith('sse:events:global', 'sse:event:test') + expect(kv.setnx).toHaveBeenCalledTimes(2) + }) + + it('handles reset errors gracefully', async () => { + vi.mocked(kv.keys).mockRejectedValue(new Error('Reset failed')) + + await expect(resetSSEEventStorageForTests()).rejects.toThrow('Reset failed') + }) + }) +}) diff --git a/lib/storage/sse-events-kv.ts b/lib/storage/sse-events-kv.ts new file mode 100644 index 00000000..1bfb54a9 --- /dev/null +++ b/lib/storage/sse-events-kv.ts @@ -0,0 +1,270 @@ +import { kv } from '@vercel/kv' +import type { PublishedSystemEvent } from '@/lib/events/system-events' + +// KV Key Patterns +const KEYS = { + // Global event stream (sorted by timestamp) + GLOBAL_EVENTS: 'sse:events:global', + + // Agent-specific event streams + AGENT_EVENTS: (agentId: string) => `sse:events:agent:${agentId}`, + + // Event-type specific streams + TYPE_EVENTS: (eventType: string) => `sse:events:type:${eventType}`, + + // Event lookup by ID + EVENT_BY_ID: (eventId: string) => `sse:event:${eventId}`, + + // Metadata + LATEST_EVENT_ID: 'sse:events:latest_id', + EVENT_COUNT: 'sse:events:count', +} + +// TTL Configuration (24 hours for event streams) +const EVENT_STREAM_TTL = 24 * 60 * 60 // 24 hours in seconds +const EVENT_DETAIL_TTL = 7 * 24 * 60 * 60 // 7 days for individual event details + +/** + * Initialize SSE event storage in KV + */ +export async function initializeSSEEventStorage(): Promise { + try { + // Set initial counters if they don't exist + await kv.setnx(KEYS.LATEST_EVENT_ID, '0') + await kv.setnx(KEYS.EVENT_COUNT, '0') + } catch (error) { + console.error('Failed to initialize SSE event storage:', error) + throw error + } +} + +/** + * Save a system event to KV storage + * Stores the event in multiple places for efficient querying: + * 1. Global stream (for all events) + * 2. Agent-specific stream (if agent-scoped) + * 3. Event-type stream (for type-based queries) + * 4. Individual event lookup (by ID) + */ +export async function saveSSEEvent(event: PublishedSystemEvent): Promise { + try { + const eventJson = JSON.stringify(event) + const timestamp = Date.now() + const score = timestamp + + // Store individual event by ID + await kv.set(KEYS.EVENT_BY_ID(event.id), eventJson, { ex: EVENT_DETAIL_TTL }) + + // Add to global event stream (sorted set by timestamp) + await kv.zadd(KEYS.GLOBAL_EVENTS, { score, member: event.id }) + await kv.expire(KEYS.GLOBAL_EVENTS, EVENT_STREAM_TTL) + + // Add to agent-specific stream if agent-scoped + if (event.agentId) { + await kv.zadd(KEYS.AGENT_EVENTS(event.agentId), { score, member: event.id }) + await kv.expire(KEYS.AGENT_EVENTS(event.agentId), EVENT_STREAM_TTL) + } + + // Add to event-type stream + await kv.zadd(KEYS.TYPE_EVENTS(event.type), { score, member: event.id }) + await kv.expire(KEYS.TYPE_EVENTS(event.type), EVENT_STREAM_TTL) + + // Update counters + await kv.incr(KEYS.EVENT_COUNT) + await kv.set(KEYS.LATEST_EVENT_ID, event.id) + } catch (error) { + console.error('Failed to save SSE event:', error) + throw error + } +} + +/** + * Get a specific event by ID + */ +export async function getSSEEvent(eventId: string): Promise { + try { + const eventJson = await kv.get(KEYS.EVENT_BY_ID(eventId)) + if (!eventJson) { + return null + } + return JSON.parse(eventJson) as PublishedSystemEvent + } catch (error) { + console.error('Failed to get SSE event:', error) + throw error + } +} + +/** + * Get recent events from the global stream + */ +export async function getRecentGlobalEvents(limit: number = 100): Promise { + try { + // Get event IDs from the sorted set (most recent first) + const eventIds = await kv.zrevrange(KEYS.GLOBAL_EVENTS, 0, limit - 1) + + if (eventIds.length === 0) { + return [] + } + + // Fetch all events in parallel + const events = await Promise.all( + eventIds.map((id) => getSSEEvent(id as string)) + ) + + return events.filter((e): e is PublishedSystemEvent => e !== null) + } catch (error) { + console.error('Failed to get recent global events:', error) + throw error + } +} + +/** + * Get recent events for a specific agent + */ +export async function getRecentAgentEvents(agentId: string, limit: number = 100): Promise { + try { + const eventIds = await kv.zrevrange(KEYS.AGENT_EVENTS(agentId), 0, limit - 1) + + if (eventIds.length === 0) { + return [] + } + + const events = await Promise.all( + eventIds.map((id) => getSSEEvent(id as string)) + ) + + return events.filter((e): e is PublishedSystemEvent => e !== null) + } catch (error) { + console.error('Failed to get recent agent events:', error) + throw error + } +} + +/** + * Get recent events of a specific type + */ +export async function getRecentEventsByType(eventType: string, limit: number = 100): Promise { + try { + const eventIds = await kv.zrevrange(KEYS.TYPE_EVENTS(eventType), 0, limit - 1) + + if (eventIds.length === 0) { + return [] + } + + const events = await Promise.all( + eventIds.map((id) => getSSEEvent(id as string)) + ) + + return events.filter((e): e is PublishedSystemEvent => e !== null) + } catch (error) { + console.error('Failed to get recent events by type:', error) + throw error + } +} + +/** + * Get events within a time range + */ +export async function getEventsInRange( + startTime: number, + endTime: number, + limit: number = 100 +): Promise { + try { + const eventIds = await kv.zrangebyscore(KEYS.GLOBAL_EVENTS, startTime, endTime, { + rev: true, + count: limit, + }) + + if (eventIds.length === 0) { + return [] + } + + const events = await Promise.all( + eventIds.map((id) => getSSEEvent(id as string)) + ) + + return events.filter((e): e is PublishedSystemEvent => e !== null) + } catch (error) { + console.error('Failed to get events in range:', error) + throw error + } +} + +/** + * Get event statistics + */ +export async function getSSEEventStats(): Promise<{ + totalEvents: number + latestEventId: string + streamSizes: { + global: number + byAgent: number + byType: number + } +}> { + try { + const totalEvents = parseInt((await kv.get(KEYS.EVENT_COUNT)) || '0', 10) + const latestEventId = (await kv.get(KEYS.LATEST_EVENT_ID)) || '' + + const globalSize = await kv.zcard(KEYS.GLOBAL_EVENTS) + + // Count agent-specific streams (pattern matching) + const agentKeys = await kv.keys('sse:events:agent:*') + const byAgent = agentKeys.length + + // Count type-specific streams + const typeKeys = await kv.keys('sse:events:type:*') + const byType = typeKeys.length + + return { + totalEvents, + latestEventId, + streamSizes: { + global, + byAgent, + byType, + }, + } + } catch (error) { + console.error('Failed to get SSE event stats:', error) + throw error + } +} + +/** + * Clean up old events (can be called by a cron job) + */ +export async function cleanupOldEvents(olderThanMs: number = 24 * 60 * 60 * 1000): Promise { + try { + const cutoffTime = Date.now() - olderThanMs + const removedCount = await kv.zremrangebyscore(KEYS.GLOBAL_EVENTS, 0, cutoffTime) + + // Also clean up individual event details that are older than TTL + // This is handled automatically by KV's TTL mechanism + + return removedCount + } catch (error) { + console.error('Failed to cleanup old events:', error) + throw error + } +} + +/** + * Reset SSE event storage (for testing only) + */ +export async function resetSSEEventStorageForTests(): Promise { + try { + // Delete all SSE-related keys + const keys = await kv.keys('sse:*') + if (keys.length > 0) { + await kv.del(...keys) + } + + // Reinitialize + await initializeSSEEventStorage() + } catch (error) { + console.error('Failed to reset SSE event storage:', error) + throw error + } +} diff --git a/lib/storage/x402-receipt-postgres.test.ts b/lib/storage/x402-receipt-postgres.test.ts new file mode 100644 index 00000000..0e768a4d --- /dev/null +++ b/lib/storage/x402-receipt-postgres.test.ts @@ -0,0 +1,261 @@ +import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest' +import { sql } from '@vercel/postgres' +import type { X402ExplorerReceipt } from '@/lib/protocols/x402' +import { + initializeX402ReceiptTable, + saveX402Receipt, + getX402Receipt, + listX402Receipts, + resetX402ReceiptStoreForTests, +} from './x402-receipt-postgres' + +// Mock the sql module +vi.mock('@vercel/postgres', () => ({ + sql: vi.fn(), +})) + +describe('x402 receipt postgres storage', () => { + const mockReceipt: X402ExplorerReceipt = { + id: 'test-receipt-1', + quoteId: 'q_test', + paymentRef: 'service:stellar:1234567890', + settledAt: '2024-01-15T10:30:00.000Z', + txHash: '0x' + 'a'.repeat(64), + chain: 'stellar', + amountUsd: 0.5, + amountUnits: '5000000', + accepted: true, + agentId: 'agent-123', + agent: 'agent-123', + service: 'test-service', + serviceId: 'test-service', + passportVerified: true, + reputationTier: 'gold', + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('initializeX402ReceiptTable', () => { + it('creates table and indexes successfully', async () => { + vi.mocked(sql).mockResolvedValue({ rows: [] } as any) + + await expect(initializeX402ReceiptTable()).resolves.not.toThrow() + + expect(sql).toHaveBeenCalledTimes(9) // 1 CREATE TABLE + 8 CREATE INDEX + }) + + it('handles initialization errors gracefully', async () => { + vi.mocked(sql).mockRejectedValue(new Error('Connection failed')) + + await expect(initializeX402ReceiptTable()).rejects.toThrow('Connection failed') + }) + }) + + describe('saveX402Receipt', () => { + it('saves a new receipt successfully', async () => { + vi.mocked(sql).mockResolvedValue({ rows: [] } as any) + + await expect(saveX402Receipt(mockReceipt)).resolves.toEqual(mockReceipt) + + expect(sql).toHaveBeenCalledWith( + expect.stringContaining('INSERT INTO x402_receipts') + ) + }) + + it('updates existing receipt on conflict', async () => { + vi.mocked(sql).mockResolvedValue({ rows: [] } as any) + + await expect(saveX402Receipt(mockReceipt)).resolves.toEqual(mockReceipt) + + expect(sql).toHaveBeenCalledWith( + expect.stringContaining('ON CONFLICT (id) DO UPDATE') + ) + }) + + it('handles save errors gracefully', async () => { + vi.mocked(sql).mockRejectedValue(new Error('Save failed')) + + await expect(saveX402Receipt(mockReceipt)).rejects.toThrow('Save failed') + }) + }) + + describe('getX402Receipt', () => { + it('retrieves a receipt by ID', async () => { + vi.mocked(sql).mockResolvedValue({ + rows: [mockReceipt], + } as any) + + const result = await getX402Receipt('test-receipt-1') + + expect(result).toEqual(mockReceipt) + expect(sql).toHaveBeenCalledWith( + expect.stringContaining('SELECT') + ) + }) + + it('returns undefined for non-existent receipt', async () => { + vi.mocked(sql).mockResolvedValue({ + rows: [], + } as any) + + const result = await getX402Receipt('non-existent') + + expect(result).toBeUndefined() + }) + + it('handles query errors gracefully', async () => { + vi.mocked(sql).mockRejectedValue(new Error('Query failed')) + + await expect(getX402Receipt('test-receipt-1')).rejects.toThrow('Query failed') + }) + }) + + describe('listX402Receipts', () => { + it('lists receipts with default pagination', async () => { + vi.mocked(sql) + .mockResolvedValueOnce({ rows: [{ total: 10 }] } as any) // count + .mockResolvedValueOnce({ rows: [mockReceipt] } as any) // receipts + .mockResolvedValueOnce({ + rows: [ + { total_payments: 10, total_usd: 5.5, unique_agents: 5, services: 3 }, + ], + } as any) // stats + + const result = await listX402Receipts() + + expect(result.receipts).toHaveLength(1) + expect(result.page).toBe(1) + expect(result.pageSize).toBe(50) + expect(result.total).toBe(10) + expect(result.totalPages).toBe(1) + expect(result.stats.totalPayments).toBe(10) + expect(result.stats.totalUsd).toBe(5.5) + }) + + it('filters by agent ID', async () => { + vi.mocked(sql) + .mockResolvedValueOnce({ rows: [{ total: 5 }] } as any) + .mockResolvedValueOnce({ rows: [mockReceipt] } as any) + .mockResolvedValueOnce({ + rows: [ + { total_payments: 10, total_usd: 5.5, unique_agents: 5, services: 3 }, + ], + } as any) + + await listX402Receipts({ agent: 'agent-123' }) + + expect(sql).toHaveBeenCalledWith( + expect.stringContaining('LOWER(agent_id)') + ) + }) + + it('filters by service ID', async () => { + vi.mocked(sql) + .mockResolvedValueOnce({ rows: [{ total: 3 }] } as any) + .mockResolvedValueOnce({ rows: [mockReceipt] } as any) + .mockResolvedValueOnce({ + rows: [ + { total_payments: 10, total_usd: 5.5, unique_agents: 5, services: 3 }, + ], + } as any) + + await listX402Receipts({ service: 'test-service' }) + + expect(sql).toHaveBeenCalledWith( + expect.stringContaining('LOWER(service_id)') + ) + }) + + it('filters by chain', async () => { + vi.mocked(sql) + .mockResolvedValueOnce({ rows: [{ total: 7 }] } as any) + .mockResolvedValueOnce({ rows: [mockReceipt] } as any) + .mockResolvedValueOnce({ + rows: [ + { total_payments: 10, total_usd: 5.5, unique_agents: 5, services: 3 }, + ], + } as any) + + await listX402Receipts({ chain: 'stellar' }) + + expect(sql).toHaveBeenCalledWith(expect.stringContaining('chain =')) + }) + + it('searches with query string', async () => { + vi.mocked(sql) + .mockResolvedValueOnce({ rows: [{ total: 2 }] } as any) + .mockResolvedValueOnce({ rows: [mockReceipt] } as any) + .mockResolvedValueOnce({ + rows: [ + { total_payments: 10, total_usd: 5.5, unique_agents: 5, services: 3 }, + ], + } as any) + + await listX402Receipts({ q: 'test' }) + + expect(sql).toHaveBeenCalledWith( + expect.stringContaining('LIKE') + ) + }) + + it('handles pagination correctly', async () => { + vi.mocked(sql) + .mockResolvedValueOnce({ rows: [{ total: 100 }] } as any) + .mockResolvedValueOnce({ rows: [mockReceipt] } as any) + .mockResolvedValueOnce({ + rows: [ + { total_payments: 10, total_usd: 5.5, unique_agents: 5, services: 3 }, + ], + } as any) + + const result = await listX402Receipts({ page: 2, pageSize: 25 }) + + expect(result.page).toBe(2) + expect(result.pageSize).toBe(25) + expect(result.totalPages).toBe(4) + }) + + it('limits page size to maximum of 50', async () => { + vi.mocked(sql) + .mockResolvedValueOnce({ rows: [{ total: 100 }] } as any) + .mockResolvedValueOnce({ rows: [mockReceipt] } as any) + .mockResolvedValueOnce({ + rows: [ + { total_payments: 10, total_usd: 5.5, unique_agents: 5, services: 3 }, + ], + } as any) + + await listX402Receipts({ pageSize: 100 }) + + expect(sql).toHaveBeenCalledWith(expect.stringContaining('LIMIT 50')) + }) + + it('handles list errors gracefully', async () => { + vi.mocked(sql).mockRejectedValue(new Error('List failed')) + + await expect(listX402Receipts()).rejects.toThrow('List failed') + }) + }) + + describe('resetX402ReceiptStoreForTests', () => { + it('clears all receipts', async () => { + vi.mocked(sql).mockResolvedValue({ rows: [] } as any) + + await expect(resetX402ReceiptStoreForTests()).resolves.not.toThrow() + + expect(sql).toHaveBeenCalledWith(expect.stringContaining('DELETE FROM x402_receipts')) + }) + + it('handles reset errors gracefully', async () => { + vi.mocked(sql).mockRejectedValue(new Error('Reset failed')) + + await expect(resetX402ReceiptStoreForTests()).rejects.toThrow('Reset failed') + }) + }) +}) diff --git a/lib/storage/x402-receipt-postgres.ts b/lib/storage/x402-receipt-postgres.ts new file mode 100644 index 00000000..471e213c --- /dev/null +++ b/lib/storage/x402-receipt-postgres.ts @@ -0,0 +1,266 @@ +import { sql } from '@vercel/postgres' +import type { SettlementChain, X402ExplorerReceipt, X402ReceiptQuery } from '@/lib/protocols/x402' + +export interface X402ReceiptPage { + receipts: X402ExplorerReceipt[] + page: number + pageSize: number + total: number + totalPages: number + stats: { + totalPayments: number + totalUsd: number + uniqueAgents: number + services: number + } +} + +/** + * Initialize the x402_receipts table + * This should be called during application setup + */ +export async function initializeX402ReceiptTable(): Promise { + try { + await sql` + CREATE TABLE IF NOT EXISTS x402_receipts ( + id VARCHAR(255) PRIMARY KEY, + quote_id VARCHAR(255), + payment_ref VARCHAR(511) NOT NULL, + settled_at TIMESTAMP WITH TIME ZONE NOT NULL, + tx_hash VARCHAR(255) NOT NULL, + chain VARCHAR(50) NOT NULL, + amount_usd DECIMAL(10, 6), + amount_units VARCHAR(255), + accepted BOOLEAN NOT NULL DEFAULT true, + agent_id VARCHAR(255), + agent VARCHAR(255), + service VARCHAR(255), + service_id VARCHAR(255), + passport_verified BOOLEAN DEFAULT true, + reputation_tier VARCHAR(50) + ) + ` + + // Create indexes for performance + await sql`CREATE INDEX IF NOT EXISTS idx_x402_receipts_agent_id ON x402_receipts(agent_id)` + await sql`CREATE INDEX IF NOT EXISTS idx_x402_receipts_service_id ON x402_receipts(service_id)` + await sql`CREATE INDEX IF NOT EXISTS idx_x402_receipts_chain ON x402_receipts(chain)` + await sql`CREATE INDEX IF NOT EXISTS idx_x402_receipts_settled_at ON x402_receipts(settled_at DESC)` + await sql`CREATE INDEX IF NOT EXISTS idx_x402_receipts_payment_ref ON x402_receipts(payment_ref)` + await sql`CREATE INDEX IF NOT EXISTS idx_x402_receipts_quote_id ON x402_receipts(quote_id)` + await sql`CREATE INDEX IF NOT EXISTS idx_x402_receipts_agent_settled ON x402_receipts(agent_id, settled_at DESC)` + await sql`CREATE INDEX IF NOT EXISTS idx_x402_receipts_service_settled ON x402_receipts(service_id, settled_at DESC)` + } catch (error) { + console.error('Failed to initialize x402_receipts table:', error) + throw error + } +} + +/** + * Save an x402 receipt to Postgres + */ +export async function saveX402Receipt(receipt: X402ExplorerReceipt): Promise { + try { + await sql` + INSERT INTO x402_receipts ( + id, quote_id, payment_ref, settled_at, tx_hash, chain, + amount_usd, amount_units, accepted, agent_id, agent, + service, service_id, passport_verified, reputation_tier + ) VALUES ( + ${receipt.id}, ${receipt.quoteId || null}, ${receipt.paymentRef}, + ${receipt.settledAt}, ${receipt.txHash}, ${receipt.chain}, + ${receipt.amountUsd || null}, ${receipt.amountUnits || null}, + ${receipt.accepted}, ${receipt.agentId || null}, ${receipt.agent || null}, + ${receipt.service || null}, ${receipt.serviceId || null}, + ${receipt.passportVerified}, ${receipt.reputationTier || null} + ) + ON CONFLICT (id) DO UPDATE SET + quote_id = EXCLUDED.quote_id, + payment_ref = EXCLUDED.payment_ref, + settled_at = EXCLUDED.settled_at, + tx_hash = EXCLUDED.tx_hash, + chain = EXCLUDED.chain, + amount_usd = EXCLUDED.amount_usd, + amount_units = EXCLUDED.amount_units, + accepted = EXCLUDED.accepted, + agent_id = EXCLUDED.agent_id, + agent = EXCLUDED.agent, + service = EXCLUDED.service, + service_id = EXCLUDED.service_id, + passport_verified = EXCLUDED.passport_verified, + reputation_tier = EXCLUDED.reputation_tier + ` + return receipt + } catch (error) { + console.error('Failed to save x402 receipt:', error) + throw error + } +} + +/** + * Get a single x402 receipt by ID + */ +export async function getX402Receipt(receiptId: string): Promise { + try { + const result = await sql` + SELECT + id, + quote_id as "quoteId", + payment_ref as "paymentRef", + settled_at as "settledAt", + tx_hash as "txHash", + chain, + amount_usd as "amountUsd", + amount_units as "amountUnits", + accepted, + agent_id as "agentId", + agent, + service, + service_id as "serviceId", + passport_verified as "passportVerified", + reputation_tier as "reputationTier" + FROM x402_receipts + WHERE id = ${receiptId} + ` + + if (result.rows.length === 0) { + return undefined + } + + return result.rows[0] as X402ExplorerReceipt + } catch (error) { + console.error('Failed to get x402 receipt:', error) + throw error + } +} + +/** + * List x402 receipts with filtering and pagination + */ +export async function listX402Receipts(filters: X402ReceiptQuery = {}): Promise { + try { + const pageSize = Math.max(1, Math.min(50, Math.floor(filters.pageSize ?? 50))) + const page = Math.max(1, Math.floor(filters.page ?? 1)) + const q = (filters.q || '').trim().toLowerCase() + const agent = (filters.agent || '').trim().toLowerCase() + const service = (filters.service || '').trim().toLowerCase() + const chain = filters.chain && filters.chain !== 'all' ? filters.chain : null + + // Build WHERE clause + const conditions: string[] = [] + const params: (string | number | boolean)[] = [] + let paramIndex = 1 + + if (chain) { + conditions.push(`chain = $${paramIndex}`) + params.push(chain) + paramIndex++ + } + + if (agent) { + conditions.push(`(LOWER(agent_id) = $${paramIndex} OR LOWER(agent) = $${paramIndex})`) + params.push(agent) + paramIndex++ + } + + if (service) { + conditions.push(`(LOWER(service_id) = $${paramIndex} OR LOWER(service) = $${paramIndex})`) + params.push(service) + paramIndex++ + } + + if (q) { + conditions.push(`( + LOWER(id) LIKE $${paramIndex} OR + LOWER(payment_ref) LIKE $${paramIndex} OR + LOWER(agent_id) LIKE $${paramIndex} OR + LOWER(agent) LIKE $${paramIndex} OR + LOWER(service) LIKE $${paramIndex} OR + LOWER(service_id) LIKE $${paramIndex} OR + LOWER(tx_hash) LIKE $${paramIndex} OR + LOWER(chain) LIKE $${paramIndex} OR + LOWER(amount) LIKE $${paramIndex} + )`) + params.push(`%${q}%`) + paramIndex++ + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '' + + // Get total count + const countResult = await sql` + SELECT COUNT(*) as total + FROM x402_receipts + ${sql.unsafe(whereClause, ...params)} + ` + const total = parseInt(countResult.rows[0].total as string, 10) + + // Get paginated results + const offset = (page - 1) * pageSize + const receiptsResult = await sql` + SELECT + id, + quote_id as "quoteId", + payment_ref as "paymentRef", + settled_at as "settledAt", + tx_hash as "txHash", + chain, + amount_usd as "amountUsd", + amount_units as "amountUnits", + accepted, + agent_id as "agentId", + agent, + service, + service_id as "serviceId", + passport_verified as "passportVerified", + reputation_tier as "reputationTier" + FROM x402_receipts + ${sql.unsafe(whereClause, ...params)} + ORDER BY settled_at DESC + LIMIT ${pageSize} OFFSET ${offset} + ` + + const receipts = receiptsResult.rows as X402ExplorerReceipt[] + + // Get overall stats (not filtered) + const statsResult = await sql` + SELECT + COUNT(*) as total_payments, + COALESCE(SUM(amount_usd), 0) as total_usd, + COUNT(DISTINCT agent_id) as unique_agents, + COUNT(DISTINCT service) as services + FROM x402_receipts + ` + + const stats = { + totalPayments: parseInt(statsResult.rows[0].total_payments as string, 10), + totalUsd: parseFloat(statsResult.rows[0].total_usd as string), + uniqueAgents: parseInt(statsResult.rows[0].unique_agents as string, 10), + services: parseInt(statsResult.rows[0].services as string, 10), + } + + return { + receipts, + page, + pageSize, + total, + totalPages: Math.max(1, Math.ceil(total / pageSize)), + stats, + } + } catch (error) { + console.error('Failed to list x402 receipts:', error) + throw error + } +} + +/** + * Reset the x402 receipts table (for testing only) + */ +export async function resetX402ReceiptStoreForTests(): Promise { + try { + await sql`DELETE FROM x402_receipts` + } catch (error) { + console.error('Failed to reset x402 receipt store:', error) + throw error + } +} diff --git a/package.json b/package.json index 7e1a07f6..058c434a 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,9 @@ "test:e2e:ui": "playwright test --ui", "test:watch": "vitest", "test:coverage": "vitest run --coverage", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:headed": "playwright test --headed", "deploy:evm:guide": "node scripts/deploy/evm/guide.mjs", "deploy:soroban:guide": "node scripts/deploy/soroban/guide.mjs", "generate:audio": "node scripts/generate-audio-assets.mjs" @@ -63,6 +66,8 @@ "@stellar/stellar-sdk": "^16.0.0", "@tanstack/react-query": "^5.62.0", "@vercel/analytics": "1.6.1", + "@vercel/kv": "^1.0.1", + "@vercel/postgres": "^0.9.0", "autoprefixer": "^10.4.20", "buffer": "^6.0.3", "class-variance-authority": "^0.7.1", @@ -95,7 +100,7 @@ "zod": "^3.24.1" }, "devDependencies": { - "@playwright/test": "^1.61.1", +n "@secretlint/secretlint-rule-preset-recommend": "^12.0.0", "@size-limit/file": "^12.1.0", "@tailwindcss/postcss": "^4.2.0", diff --git a/playwright.config.ts b/playwright.config.ts index 985c9228..6f75a74e 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,34 +1,25 @@ -import { defineConfig, devices } from '@playwright/test'; -/** - * Playwright E2E configuration for Open-Stellar - * Tests critical user flows with mocked wallet/payment interactions - */ export default defineConfig({ testDir: './e2e', fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, - reporter: process.env.CI ? 'html' : 'list', use: { baseURL: process.env.BASE_URL || 'http://localhost:3000', trace: 'on-first-retry', screenshot: 'only-on-failure', - }, - + main projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'] }, }, - ], - +main webServer: { command: 'npm run dev', url: 'http://localhost:3000', reuseExistingServer: !process.env.CI, timeout: 120000, - }, -}); + diff --git a/vitest.config.ts b/vitest.config.ts index c1e10b1e..9dfb9c82 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -12,7 +12,7 @@ export default defineConfig({ provider: "v8", reporter: ["text", "lcov"], include: ["app/api/**/*.ts", "lib/**/*.ts"], - exclude: ["lib/passport/validator-client.ts", "lib/passport/snarkjs.d.ts", "node_modules/", "dist/"], + exclude: ["lib/passport/validator-client.ts", "lib/passport/snarkjs.d.ts", "node_modules/", "dist/", "lib/storage/**/*.test.ts"], }, }, resolve: {