From 169ecdc2d7823620086b7d54d29431227731f77f Mon Sep 17 00:00:00 2001 From: Bytebinders Date: Mon, 31 Aug 2026 08:43:53 +0100 Subject: [PATCH] feat: add Vercel serverless functions for image and news search --- CONTRIBUTING.md | 4 + README.md | 2 + api/images.test.ts | 214 +++++++++++++++++++++++++++++++++++++++++ api/images.ts | 147 ++++++++++++++++++++++++++++ api/index.ts | 2 + api/news.test.ts | 233 +++++++++++++++++++++++++++++++++++++++++++++ api/news.ts | 157 ++++++++++++++++++++++++++++++ vite.config.ts | 2 + 8 files changed, 761 insertions(+) create mode 100644 api/images.test.ts create mode 100644 api/images.ts create mode 100644 api/news.test.ts create mode 100644 api/news.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 41207de..acb99b5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -195,6 +195,8 @@ stellar-search/ │ ├── api/ # Vercel serverless function equivalents │ ├── search.ts +│ ├── images.ts +│ ├── news.ts │ ├── health.ts │ └── ai/chat.ts │ @@ -420,6 +422,8 @@ Global thresholds start modest and ratchet upward as payment/wallet/API/MCP/UI b | `src/components/search/SearchBar.tsx` | 80% | 80% | 90% | 80% | | `server/index.ts` | 65% | 60% | 65% | 65% | | `api/search.ts` | 90% | 75% | 80% | 90% | +| `api/images.ts` | 90% | 75% | 80% | 90% | +| `api/news.ts` | 90% | 75% | 80% | 90% | | `api/health.ts` | 80% | 50% | 100% | 80% | | `mcp-server/index.ts` | 30% | 20% | 20% | 30% | | `src/hooks/useFreighterWallet.ts` | 85% | 65% | 90% | 85% | diff --git a/README.md b/README.md index 26d4ddd..b179466 100644 --- a/README.md +++ b/README.md @@ -228,6 +228,8 @@ Global thresholds are deliberately modest initially and ratchet upward as paymen | `src/components/search/SearchBar.tsx` | 80% | 80% | 90% | 80% | | `server/index.ts` | 65% | 60% | 65% | 65% | | `api/search.ts` | 90% | 75% | 80% | 90% | +| `api/images.ts` | 90% | 75% | 80% | 90% | +| `api/news.ts` | 90% | 75% | 80% | 90% | | `api/health.ts` | 80% | 50% | 100% | 80% | | `mcp-server/index.ts` | 30% | 20% | 20% | 30% | | `src/hooks/useFreighterWallet.ts` | 85% | 65% | 90% | 85% | diff --git a/api/images.test.ts b/api/images.test.ts new file mode 100644 index 0000000..6079444 --- /dev/null +++ b/api/images.test.ts @@ -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) + }) +}) diff --git a/api/images.ts b/api/images.ts new file mode 100644 index 0000000..1004b22 --- /dev/null +++ b/api/images.ts @@ -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 + + 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.' }) + } +} diff --git a/api/index.ts b/api/index.ts index 5165b3b..4699148 100644 --- a/api/index.ts +++ b/api/index.ts @@ -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=': '0.001 USDC via x402', + 'GET /api/images?q=': '0.001 USDC via x402', + 'GET /api/news?q=': '0.001 USDC via x402', 'POST /api/ai/chat': 'Groq AI — free', 'GET /api/health': 'Live server stats', }, diff --git a/api/news.test.ts b/api/news.test.ts new file mode 100644 index 0000000..ce7f6bc --- /dev/null +++ b/api/news.test.ts @@ -0,0 +1,233 @@ +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 './news' +import { resetConsumedPayments } from '../src/lib/paymentIntegrity' + +function mockReqRes(overrides: any = {}) { + const req: any = { + method: 'GET', + query: {}, + url: '/api/news?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/news — Vercel x402 news 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 horizon' }, + headers: { host: 'example.com', 'x-forwarded-proto': 'https' }, + url: '/api/news?q=stellar+horizon', + }) + 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('news 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 news search when payment header present (Serper mock)', async () => { + const fakeTx = Buffer.from(JSON.stringify({ transactionHash: 'tx_news_123' })).toString('base64') + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + news: [ + { + title: 'Stellar Protocol Upgrade', + link: 'https://news.stellar.org/upgrade', + snippet: 'Protocol 21 released', + source: 'Stellar Foundation', + date: '2026-01-15', + imageUrl: 'https://news.stellar.org/img.png', + }, + ], + }), + } as any) + + const { req, res } = mockReqRes({ + method: 'GET', + query: { q: 'stellar upgrade', count: '5' }, + headers: { 'x-payment': fakeTx }, + }) + await handler(req, res) + expect(res._json.results).toHaveLength(1) + expect(res._json.results[0].title).toBe('Stellar Protocol Upgrade') + expect(res._json.results[0].snippet).toBe('Protocol 21 released') + expect(res._json.results[0].source).toBe('Stellar Foundation') + expect(res._json.paidAmount).toBe('0.001') + expect(res._json.currency).toBe('USDC') + expect(res._json.txHash).toBe('tx_news_123') + expect(res._json.network).toBe('stellar:testnet') + expect(global.fetch).toHaveBeenCalledWith('https://google.serper.dev/news', expect.any(Object)) + }) + + it('applies freshness filter', async () => { + const fakeTx = Buffer.from(JSON.stringify({})).toString('base64') + let capturedBody: any = null + global.fetch = vi.fn().mockImplementation(async (_url: any, opts: any) => { + capturedBody = JSON.parse(opts.body) + return { + ok: true, + json: async () => ({ news: [] }), + } as any + }) + const { req, res } = mockReqRes({ + method: 'GET', + query: { q: 'stellar news', freshness: 'pd' }, + headers: { 'x-payment': fakeTx }, + }) + await handler(req, res) + expect(capturedBody.tbs).toBe('qdr:d') + }) + + it('returns 502 when Serper news 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 news search', async () => { + const fakeTx = Buffer.from(JSON.stringify({ transactionHash: 'tx_news_replay_123' })).toString('base64') + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ news: [] }), + } 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 news search proceeds for duplicate payload', async () => { + const fakeTx = Buffer.from(JSON.stringify({ transactionHash: 'tx_news_concurrent_456' })).toString('base64') + global.fetch = vi.fn().mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)) + return { + ok: true, + json: async () => ({ news: [{ title: 'Stellar News', link: 'https://news.stellar.org' }] }), + } 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) + }) +}) diff --git a/api/news.ts b/api/news.ts new file mode 100644 index 0000000..a25dab6 --- /dev/null +++ b/api/news.ts @@ -0,0 +1,157 @@ +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', freshness } = req.query as Record + + 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 news 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 news 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 requestBody: Record = { + q: q.trim(), + num: Math.min(parseInt(count) || 10, 20), + } + + if (freshness) { + const dateFilters: Record = { + pd: 'qdr:d', // past day + pw: 'qdr:w', // past week + pm: 'qdr:m', // past month + } + if (dateFilters[freshness]) requestBody.tbs = dateFilters[freshness] + } + + const serperRes = await fetch('https://google.serper.dev/news', { + method: 'POST', + headers: { + 'X-API-KEY': SERPER_API_KEY, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }) + + if (!serperRes.ok) { + const errText = await serperRes.text() + console.error('[serper news]', 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.news || []).map((r: any, i: number) => ({ + id: String(i + 1), + title: r.title || 'No title', + url: r.link, + snippet: r.snippet || '', + source: r.source || (() => { + try { return new URL(r.link).hostname.replace('www.', '') } + catch { return r.link } + })(), + publishedAt: r.date || undefined, + imageUrl: r.imageUrl || undefined, + })) + + return res.json({ + query: q.trim(), + results, + count: results.length, + network: NETWORK, + paidAmount: AMOUNT_USDC, + currency: 'USDC', + txHash, + latencyMs, + }) + + } catch (err: any) { + console.error('[news error]', err.message) + return res.status(500).json({ error: 'News search failed.' }) + } +} diff --git a/vite.config.ts b/vite.config.ts index 5611d64..0451a5a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -37,6 +37,8 @@ export default defineConfig({ 'src/components/search/SearchBar.tsx': { statements: 80, branches: 80, functions: 90, lines: 80 }, 'server/index.ts': { statements: 65, branches: 60, functions: 65, lines: 65 }, 'api/search.ts': { statements: 90, branches: 75, functions: 80, lines: 90 }, + 'api/images.ts': { statements: 90, branches: 75, functions: 80, lines: 90 }, + 'api/news.ts': { statements: 90, branches: 75, functions: 80, lines: 90 }, 'api/health.ts': { statements: 80, branches: 50, functions: 100, lines: 80 }, 'mcp-server/index.ts': { statements: 30, branches: 20, functions: 20, lines: 30 }, 'src/hooks/useFreighterWallet.ts': { statements: 85, branches: 65, functions: 90, lines: 85 },