Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
214 changes: 214 additions & 0 deletions api/images.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'

vi.hoisted(() => {
process.env.STELLAR_RECEIVING_ADDRESS = 'GAAZI4TCR3TY5OJHCTJC2A4AFL5MNSF3GAKGOWG5W2LBBGCS2TDPZOM3'
process.env.SERPER_API_KEY = 'test-serper'
})

vi.mock('../src/lib/constants', async () => {
const actual: any = await vi.importActual('../src/lib/constants')
return {
...actual,
STELLAR_NETWORK: 'stellar:testnet',
USDC_CONTRACT: 'CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA',
AMOUNT_STROOPS: '10000',
AMOUNT_USDC: '0.001',
}
})

import handler from './images'
import { resetConsumedPayments } from '../src/lib/paymentIntegrity'

function mockReqRes(overrides: any = {}) {
const req: any = {
method: 'GET',
query: {},
url: '/api/images?q=stellar',
...overrides,
headers: { ...(overrides.headers || {}) },
}
const res: any = {}
res.setHeader = vi.fn()
res.status = vi.fn().mockImplementation((code: number) => {
res._status = code
return res
})
res.json = vi.fn().mockImplementation((data: any) => {
res._json = data
return res
})
res.end = vi.fn()
return { req, res }
}

describe('api/images — Vercel x402 image search endpoint', () => {
const originalFetch = global.fetch

beforeEach(() => {
vi.clearAllMocks()
resetConsumedPayments()
global.fetch = originalFetch
})

it('handles OPTIONS preflight', async () => {
const { req, res } = mockReqRes({ method: 'OPTIONS' })
await handler(req, res)
expect(res.status).toHaveBeenCalledWith(200)
expect(res.end).toHaveBeenCalled()
})

it('rejects non-GET methods', async () => {
const { req, res } = mockReqRes({ method: 'POST' })
await handler(req, res)
expect(res._status).toBe(405)
expect(res._json.error).toMatch(/Method not allowed/)
})

it('rejects missing q', async () => {
const { req, res } = mockReqRes({ method: 'GET', query: {} })
await handler(req, res)
expect(res._status).toBe(400)
expect(res._json.error).toMatch(/Missing required parameter/)
})

it('rejects whitespace q', async () => {
const { req, res } = mockReqRes({ method: 'GET', query: { q: ' ' } })
await handler(req, res)
expect(res._status).toBe(400)
})

it('returns 402 Payment Required with x402 v2 payload when no payment header', async () => {
const { req, res } = mockReqRes({
method: 'GET',
query: { q: 'stellar lumens' },
headers: { host: 'example.com', 'x-forwarded-proto': 'https' },
url: '/api/images?q=stellar+lumens',
})
await handler(req, res)
expect(res._status).toBe(402)
expect(res.setHeader).toHaveBeenCalledWith('PAYMENT-REQUIRED', expect.any(String))
const headerCall = res.setHeader.mock.calls.find((c: any) => c[0] === 'PAYMENT-REQUIRED')
const decoded = JSON.parse(Buffer.from(headerCall[1], 'base64').toString('utf8'))
expect(decoded.x402Version).toBe(2)
expect(decoded.resource.description).toContain('image search')
expect(decoded.accepts[0].scheme).toBe('exact')
expect(decoded.accepts[0].network).toBe('stellar:testnet')
expect(decoded.accepts[0].amount).toBe('10000')
expect(decoded.accepts[0].asset).toBe('CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA')
expect(decoded.accepts[0].payTo).toBe(process.env.STELLAR_RECEIVING_ADDRESS)
expect(decoded.accepts[0].maxTimeoutSeconds).toBe(300)
expect(res._json.error).toBe('Payment required')
})

it('sets CORS headers', async () => {
const { req, res } = mockReqRes({ method: 'GET', query: {} })
await handler(req, res)
expect(res.setHeader).toHaveBeenCalledWith('Access-Control-Allow-Origin', '*')
expect(res.setHeader).toHaveBeenCalledWith('Access-Control-Expose-Headers', expect.stringContaining('PAYMENT-REQUIRED'))
})

it('proceeds to image search when payment header present (Serper mock)', async () => {
const fakeTx = Buffer.from(JSON.stringify({ transactionHash: 'tx_img_123' })).toString('base64')
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
images: [
{
title: 'Stellar Logo',
imageUrl: 'https://stellar.org/logo.png',
thumbnailUrl: 'https://stellar.org/thumb.png',
link: 'https://stellar.org',
imageWidth: 800,
imageHeight: 600,
},
],
}),
} as any)

const { req, res } = mockReqRes({
method: 'GET',
query: { q: 'stellar logo', count: '5' },
headers: { 'x-payment': fakeTx },
})
await handler(req, res)
expect(res._json.results).toHaveLength(1)
expect(res._json.results[0].title).toBe('Stellar Logo')
expect(res._json.results[0].imageUrl).toBe('https://stellar.org/logo.png')
expect(res._json.results[0].source).toBe('stellar.org')
expect(res._json.paidAmount).toBe('0.001')
expect(res._json.currency).toBe('USDC')
expect(res._json.txHash).toBe('tx_img_123')
expect(res._json.network).toBe('stellar:testnet')
expect(global.fetch).toHaveBeenCalledWith('https://google.serper.dev/images', expect.any(Object))
})

it('returns 502 when Serper image search fails', async () => {
const fakeTx = Buffer.from(JSON.stringify({ txHash: 'x' })).toString('base64')
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
text: async () => 'serper error',
} as any)
const { req, res } = mockReqRes({
method: 'GET',
query: { q: 'stellar' },
headers: { 'x-payment': fakeTx },
})
await handler(req, res)
expect(res._status).toBe(502)
})

it('rejects replayed payment headers for image search', async () => {
const fakeTx = Buffer.from(JSON.stringify({ transactionHash: 'tx_img_replay_123' })).toString('base64')
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ images: [] }),
} as any)

const first = mockReqRes({
method: 'GET',
query: { q: 'stellar' },
headers: { 'x-payment': fakeTx },
})
await handler(first.req, first.res)
expect(first.res._json.query).toBe('stellar')

const second = mockReqRes({
method: 'GET',
query: { q: 'stellar' },
headers: { 'x-payment': fakeTx },
})
await handler(second.req, second.res)
expect(second.res._status).toBe(402)
expect(second.res._json.error).toBe('Payment payload already consumed')
})

it('concurrency test: ensures only one image search proceeds for duplicate payload', async () => {
const fakeTx = Buffer.from(JSON.stringify({ transactionHash: 'tx_img_concurrent_456' })).toString('base64')
global.fetch = vi.fn().mockImplementation(async () => {
await new Promise((resolve) => setTimeout(resolve, 20))
return {
ok: true,
json: async () => ({ images: [{ title: 'Stellar Image', imageUrl: 'https://stellar.org/img.png' }] }),
} as any
})

const numConcurrent = 4
const pairs = Array.from({ length: numConcurrent }, () =>
mockReqRes({
method: 'GET',
query: { q: 'stellar' },
headers: { 'x-payment': fakeTx },
})
)

await Promise.all(pairs.map((p) => handler(p.req, p.res)))
expect(global.fetch).toHaveBeenCalledTimes(1)

const successes = pairs.filter((p) => p.res._json?.results)
const rejections = pairs.filter((p) => p.res._status === 402 && p.res._json?.error === 'Payment payload already consumed')

expect(successes).toHaveLength(1)
expect(rejections).toHaveLength(numConcurrent - 1)
})
})
147 changes: 147 additions & 0 deletions api/images.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import type { VercelRequest, VercelResponse } from '@vercel/node'
import {
STELLAR_NETWORK,
USDC_CONTRACT,
AMOUNT_STROOPS,
AMOUNT_USDC
} from '../src/lib/constants'
import { consumePaymentPayload } from '../src/lib/paymentIntegrity'

// ─── Config ───────────────────────────────────────────────────────────────
const RECEIVING_ADDRESS = process.env.STELLAR_RECEIVING_ADDRESS!
const NETWORK = STELLAR_NETWORK as 'stellar:testnet' | 'stellar:mainnet'
const SERPER_API_KEY = process.env.SERPER_API_KEY!

export default async function handler(req: VercelRequest, res: VercelResponse) {

// ─── CORS ─────────────────────────────────────────────────────────────────
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
res.setHeader('Access-Control-Allow-Headers', [
'Content-Type',
'Authorization',
'X-Payment',
'payment-signature',
'x-payment',
'X-PAYMENT',
].join(', '))
res.setHeader('Access-Control-Expose-Headers', [
'PAYMENT-REQUIRED',
'X-Payment-Response',
].join(', '))

if (req.method === 'OPTIONS') return res.status(200).end()
if (req.method !== 'GET') return res.status(405).json({ error: 'Method not allowed' })

const { q, count = '10' } = req.query as Record<string, string>

if (!q?.trim()) return res.status(400).json({ error: 'Missing required parameter: q' })

// ─── Payment check ────────────────────────────────────────────────────────
const paymentHeader =
req.headers['payment-signature'] ||
req.headers['x-payment'] ||
req.headers['X-PAYMENT']

if (!paymentHeader) {
// Return x402 v2 payment requirements
const paymentRequired = {
x402Version: 2,
error: 'Payment required',
resource: {
url: `${req.headers['x-forwarded-proto'] || 'http'}://${req.headers['host']}${req.url}`,
description: `StellarSearch: pay-per-query image search — ${AMOUNT_USDC} USDC on Stellar`,
mimeType: 'application/json',
},
accepts: [
{
scheme: 'exact',
network: NETWORK, // "stellar:testnet"
amount: AMOUNT_STROOPS, // "10000" (stroops = 0.001 USDC)
asset: USDC_CONTRACT, // Soroban contract address
payTo: RECEIVING_ADDRESS, // receiving G... address
maxTimeoutSeconds: 300,
extra: { areFeesSponsored: true },
},
],
}

res.setHeader(
'PAYMENT-REQUIRED',
Buffer.from(JSON.stringify(paymentRequired)).toString('base64')
)
return res.status(402).json({ error: 'Payment required' })
}

// ─── Payment Replay Protection ───────────────────────────────────────────
const consumption = consumePaymentPayload(paymentHeader)
if (!consumption.ok) {
return res.status(402).json({ error: consumption.error })
}

// ─── Payment present — proceed with image search ──────────────────────────
console.log('✅ Payment header received')

let txHash: string | null = null
try {
const decoded = Buffer.from(paymentHeader as string, 'base64').toString('utf8')
const parsed = JSON.parse(decoded)
txHash = parsed.transactionHash || parsed.txHash || null
} catch {
// payment header not base64 JSON
}

const t0 = Date.now()

try {
const serperRes = await fetch('https://google.serper.dev/images', {
method: 'POST',
headers: {
'X-API-KEY': SERPER_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
q: q.trim(),
num: Math.min(parseInt(count) || 10, 10),
}),
})

if (!serperRes.ok) {
const errText = await serperRes.text()
console.error('[serper images]', serperRes.status, errText)
return res.status(502).json({ error: `Serper.dev API error: ${serperRes.status}` })
}

const data = await serperRes.json()
const latencyMs = Date.now() - t0

const results = (data.images || []).map((r: any, i: number) => ({
id: String(i + 1),
title: r.title || 'No title',
imageUrl: r.imageUrl,
thumbnailUrl: r.thumbnailUrl || r.imageUrl,
sourceUrl: r.link,
source: (() => {
try { return new URL(r.link).hostname.replace('www.', '') }
catch { return r.link }
})(),
width: r.imageWidth,
height: r.imageHeight,
}))

return res.json({
query: q.trim(),
results,
count: results.length,
network: NETWORK,
paidAmount: AMOUNT_USDC,
currency: 'USDC',
txHash,
latencyMs,
})

} catch (err: any) {
console.error('[images error]', err.message)
return res.status(500).json({ error: 'Image search failed.' })
}
}
2 changes: 2 additions & 0 deletions api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export default function handler(req: VercelRequest, res: VercelResponse) {
description: 'Pay-per-query web search for AI agents via x402 on Stellar',
endpoints: {
'GET /api/search?q=<query>': '0.001 USDC via x402',
'GET /api/images?q=<query>': '0.001 USDC via x402',
'GET /api/news?q=<query>': '0.001 USDC via x402',
'POST /api/ai/chat': 'Groq AI — free',
'GET /api/health': 'Live server stats',
},
Expand Down
Loading