diff --git a/api/search.ts b/api/search.ts index 1647b6e..acdd8bf 100644 --- a/api/search.ts +++ b/api/search.ts @@ -1,25 +1,21 @@ import type { VercelRequest, VercelResponse } from '@vercel/node' -import { - USDC_CONTRACT_MAINNET, - USDC_CONTRACT_TESTNET, +import { + STELLAR_NETWORK, + AMOUNT_USDC, } from '../src/lib/constants' +import { + getNetwork, + buildPaymentRequiredPayload, + getPayTo, +} from '../src/lib/x402Config' import { consumePaymentPayload } from '../src/lib/paymentIntegrity' -import { formatConfigurationError, readServerConfig } from '../src/lib/config' +import { normalizeOrganicResults } from '../src/lib/serperNormalizer' +import type { SearchResponse, ApiErrorResponse } from '../src/types/index.js' // ─── Config ─────────────────────────────────────────────────────────────── -let config -try { - config = readServerConfig() -} catch (error) { - console.error(formatConfigurationError(error)) - throw error -} -const RECEIVING_ADDRESS = config.receivingAddress -const NETWORK = config.stellarNetwork -const SERPER_API_KEY = config.serperApiKey -const AMOUNT_STROOPS = config.amountStroops -const AMOUNT_USDC = config.amountUsdc -const USDC_CONTRACT = NETWORK === 'stellar:mainnet' ? USDC_CONTRACT_MAINNET : USDC_CONTRACT_TESTNET +const NETWORK = getNetwork() as 'stellar:testnet' | 'stellar:mainnet' +const RECEIVING_ADDRESS = getPayTo() +const SERPER_API_KEY = process.env.SERPER_API_KEY! export default async function handler(req: VercelRequest, res: VercelResponse) { @@ -59,28 +55,9 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { req.headers['X-PAYMENT'] if (!paymentHeader) { - // Return x402 v2 payment requirements - // The key fix: asset must be a Soroban C... contract address, NOT "USDC:ISSUER" - 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 web search — 0.001 USDC on Stellar', - mimeType: 'application/json', - }, - accepts: [ - { - scheme: 'exact', - network: NETWORK, // "stellar:testnet" - amount: AMOUNT_STROOPS, // "10000" (stroops, not dollars) - asset: USDC_CONTRACT, // "CBIELTK6..." (Soroban contract) - payTo: RECEIVING_ADDRESS, // your G... address - maxTimeoutSeconds: 300, - extra: { areFeesSponsored: true }, - }, - ], - } + // Return x402 v2 payment requirements from shared config (Issue #108) + const requestUrl = `${req.headers['x-forwarded-proto'] || 'http'}://${req.headers['host']}${req.url}` + const paymentRequired = buildPaymentRequiredPayload(requestUrl) res.setHeader( 'PAYMENT-REQUIRED', @@ -147,19 +124,14 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { const latencyMs = Date.now() - t0 const results = normalizeOrganicResults(data) - const queryMeta = normalizeQueryMetadata(data, q.trim()) const responseBody: SearchResponse = { - query: queryMeta.executedQuery, - originalQuery: queryMeta.originalQuery, - executedQuery: queryMeta.executedQuery, - suggestedQuery: queryMeta.suggestedQuery, - isCorrected: queryMeta.isCorrected, + query: q.trim(), results, - count: results.length, - network: NETWORK, - paidAmount: AMOUNT_USDC, - currency: 'USDC', + count: results.length, + network: NETWORK, + paidAmount: AMOUNT_USDC, + currency: 'USDC', txHash, latencyMs, } @@ -171,4 +143,4 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { const errorBody: ApiErrorResponse = { error: 'Search failed.' } return res.status(500).json(errorBody) } -} +} \ No newline at end of file diff --git a/server/index.ts b/server/index.ts index 1725765..145becf 100644 --- a/server/index.ts +++ b/server/index.ts @@ -23,52 +23,36 @@ import Groq from 'groq-sdk' import { paymentMiddlewareFromConfig } from '@x402/express' import { ExactStellarScheme } from '@x402/stellar/exact/server' import { HTTPFacilitatorClient } from '@x402/core/server' +import type { RoutesConfig } from '@x402/core/server' import logger from './logger' -import crypto, { randomUUID } from 'crypto' import { STELLAR_NETWORK, AMOUNT_USDC, - AMOUNT_STROOPS, - USDC_CONTRACT } from '../src/lib/constants' -import { consumePaymentPayload, extractPaymentIdentifier } from '../src/lib/paymentIntegrity' -import { formatConfigurationError, readServerConfig } from '../src/lib/config' +import { + getNetwork, + buildExpressRoutes, + getPayTo, + getFacilitatorUrl, +} from '../src/lib/x402Config' +import { consumePaymentPayload } from '../src/lib/paymentIntegrity' import { normalizeOrganicResults, normalizeImageResults, normalizeNewsResults, - normalizeQueryMetadata, } from '../src/lib/serperNormalizer.js' import type { SearchResponse, ImageSearchResponse, NewsSearchResponse, ApiErrorResponse, - BatchJsonlEvent, - BatchJsonlQuoteEvent, - BatchJsonlSettlementEvent, - BatchJsonlResultEvent, - BatchJsonlErrorEvent, - BatchJsonlDoneEvent, - SearchJob, - JobStatus, } from '../src/types/index.js' -import { buildReconciliationRecord, type ReconciliationRoute } from '../src/lib/reconciliation.js' -import { appendReconciliationRecord } from './reconciliationStore.js' dotenv.config() -let config -try { - config = readServerConfig() -} catch (error) { - console.error(formatConfigurationError(error)) - throw error -} - const app = express() -const PORT = config.port -const RATE_LIMIT_PER_MINUTE = config.rateLimitPerMinute +const PORT = process.env.PORT || 3001 +const RATE_LIMIT_PER_MINUTE = parseInt(process.env.RATE_LIMIT_PER_MINUTE || '30', 10) const limiter = rateLimit({ windowMs: 60 * 1000, @@ -119,197 +103,23 @@ const stats = { startTime: Date.now(), } -// ─── Batch idempotency & async job stores (issues #324, #325) ──────────── -export const MAX_BATCH_SIZE = 10 -export const MAX_BATCH_TOTAL_USDC = 0.01 -export const MAX_JOB_WEBHOOK_ATTEMPTS = 5 -export const WEBHOOK_RETRY_BASE_MS = 1000 - -// Batch idempotency cache: key -> { expiresAt, resultSummary } -export const batchIdempotencyStore = new Map() -// Job store: jobId -> SearchJob -export const jobStore = new Map() -// Job idempotency: key -> jobId -export const jobIdempotencyStore = new Map() -// Recent receipts for MCP resources (opted-in, in-memory capped at 50) -export const recentReceipts: Array<{ id: string; query: string; txHash: string | null; amount: string; currency: string; network: string; timestamp: string; latencyMs: number; count: number }> = [] - -export function resetBatchJobStores(): void { - batchIdempotencyStore.clear() - jobStore.clear() - jobIdempotencyStore.clear() - recentReceipts.length = 0 -} - -export function addRecentReceipt(receipt: typeof recentReceipts[number]): void { - recentReceipts.unshift(receipt) - if (recentReceipts.length > 50) recentReceipts.pop() -} - -function cleanupBatchIdempotency(now = Date.now()): void { - for (const [k, v] of batchIdempotencyStore.entries()) if (v.expiresAt <= now) batchIdempotencyStore.delete(k) - for (const [k, v] of jobIdempotencyStore.entries()) if (v.expiresAt <= now) jobIdempotencyStore.delete(k) -} - -// ─── Webhook SSRF protection & signing (issue #324) ───────────────────── -const BLOCKED_HOSTNAMES = new Set(['localhost', '127.0.0.1', '0.0.0.0', '::1', '[::1]']) - -export function isPrivateIp(hostname: string): boolean { - if (BLOCKED_HOSTNAMES.has(hostname.toLowerCase())) return true - // 10.0.0.0/8 - if (/^10\.\d+\.\d+\.\d+$/.test(hostname)) return true - // 192.168.0.0/16 - if (/^192\.168\.\d+\.\d+$/.test(hostname)) return true - // 172.16.0.0/12 - if (/^172\.(1[6-9]|2\d|3[0-1])\.\d+\.\d+$/.test(hostname)) return true - // 169.254.0.0/16 link-local - if (/^169\.254\.\d+\.\d+$/.test(hostname)) return true - // fc00::/7 private, fe80::/10 link-local - if (hostname.includes(':') && (/^fc/i.test(hostname) || /^fd/i.test(hostname) || /^fe80/i.test(hostname))) return true - return false -} - -export function validateWebhookUrl(urlStr: string): { ok: true } | { ok: false; error: string } { - let parsed: URL - try { - parsed = new URL(urlStr) - } catch { - return { ok: false, error: 'Invalid webhook URL' } - } - if (parsed.protocol !== 'https:') { - return { ok: false, error: 'Webhook URL must be https' } - } - if (isPrivateIp(parsed.hostname)) { - return { ok: false, error: 'Webhook URL points to private or blocked host (SSRF protection)' } - } - if (parsed.username || parsed.password) return { ok: false, error: 'Webhook URL must not contain credentials' } - return { ok: true } -} - -export function signWebhookPayload(payload: string, secret: string): string { - return crypto.createHmac('sha256', secret).update(payload).digest('hex') -} - -export function verifyWebhookSignature(payload: string, signature: string, secret: string, maxAgeMs = 5 * 60 * 1000, timestampHeader?: string): boolean { - const expected = signWebhookPayload(payload, secret) - // timing-safe compare - if (expected.length !== signature.length) return false - try { - if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) return false - } catch { return false } - if (timestampHeader) { - const ts = parseInt(timestampHeader, 10) - if (!Number.isFinite(ts)) return false - const age = Date.now() - ts - if (age < 0 || age > maxAgeMs) return false - } - return true -} - -async function deliverWebhookWithRetry(job: SearchJob, maxAttempts = MAX_JOB_WEBHOOK_ATTEMPTS): Promise { - if (!job.webhookUrl || !job.webhookSecret) return - const payloadObj = { - event: 'job.completed', - jobId: job.id, - status: job.status, - query: job.query, - result: job.result ?? null, - error: job.error ?? null, - txHash: job.txHash, - paymentVerified: job.verified, - timestamp: new Date().toISOString(), - nonce: crypto.randomUUID(), - } - const payload = JSON.stringify(payloadObj) - const timestamp = String(Date.now()) - const signature = signWebhookPayload(`${timestamp}.${payload}`, job.webhookSecret) - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(), 5000) - const res = await fetch(job.webhookUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Webhook-Signature': signature, - 'X-Webhook-Timestamp': timestamp, - 'X-Webhook-Attempt': String(attempt), - 'X-Job-Id': job.id, - 'User-Agent': 'StellarSearch-Webhook/1.0', - }, - body: payload, - signal: controller.signal, - }) - clearTimeout(timeout) - if (res.ok) return - // 4xx except 429 should not retry - if (res.status >= 400 && res.status < 500 && res.status !== 429) { - console.warn(`[webhook] non-retryable ${res.status} for job ${job.id}`) - return - } - } catch (err: any) { - console.warn(`[webhook] attempt ${attempt} failed for job ${job.id}: ${err.message}`) - } - if (attempt < maxAttempts) { - const backoff = WEBHOOK_RETRY_BASE_MS * Math.pow(2, attempt - 1) + Math.floor(Math.random() * 200) - await new Promise((r) => setTimeout(r, backoff)) - } - } - console.error(`[webhook] exhausted retries for job ${job.id}`) -} - // ─── Config ─────────────────────────────────────────────────────────────── -const RECEIVING_ADDRESS = config.receivingAddress -const FACILITATOR_URL = config.facilitatorUrl -const NETWORK = config.stellarNetwork -const SERPER_API_KEY = config.serperApiKey -const GROQ_API_KEY = config.groqApiKey -const AMOUNT_USDC = config.amountUsdc -const AMOUNT_STROOPS = config.amountStroops +const NETWORK = getNetwork() as 'stellar:testnet' | 'stellar:mainnet' +const RECEIVING_ADDRESS = getPayTo() +const FACILITATOR_URL = getFacilitatorUrl() +const SERPER_API_KEY = process.env.SERPER_API_KEY! +const GROQ_API_KEY = process.env.GROQ_API_KEY! + +if (!SERPER_API_KEY) console.warn('⚠ SERPER_API_KEY not set') +if (!GROQ_API_KEY) console.warn('⚠ GROQ_API_KEY not set') // ─── Groq ───────────────────────────────────────────────────────────────── -const groq = GROQ_API_KEY ? new Groq({ apiKey: GROQ_API_KEY }) : undefined +const groq = new Groq({ apiKey: GROQ_API_KEY }) // ─── x402 payment guard on /search ─────────────────────────────────────── // paymentMiddlewareFromConfig is the recommended API per official Stellar docs. -// It uses the Coinbase public facilitator (no API key needed for testnet). -const x402Accepts = [{ - scheme: 'exact', - price: parseFloat(AMOUNT_USDC), - amount: AMOUNT_STROOPS, - network: NETWORK, - payTo: RECEIVING_ADDRESS, -}] - -const x402Routes = { - 'GET /search': { - accepts: x402Accepts, - description: `StellarSearch: pay-per-query web search — ${AMOUNT_USDC} USDC on Stellar`, - }, - 'GET /images': { - accepts: x402Accepts, - description: `StellarSearch: pay-per-query image search — ${AMOUNT_USDC} USDC on Stellar`, - }, - 'GET /news': { - accepts: x402Accepts, - description: `StellarSearch: pay-per-query news search — ${AMOUNT_USDC} USDC on Stellar`, - }, - 'POST /search/batch': { - accepts: [{ - scheme: 'exact', - price: parseFloat(AMOUNT_USDC) * MAX_BATCH_SIZE, - amount: String(parseInt(AMOUNT_STROOPS) * MAX_BATCH_SIZE), - network: NETWORK, - payTo: RECEIVING_ADDRESS, - }], - description: `StellarSearch: batch web search (up to ${MAX_BATCH_SIZE}) — ${AMOUNT_USDC} USDC per query on Stellar, JSONL streaming`, - }, - 'POST /jobs': { - accepts: x402Accepts, - description: `StellarSearch: async paid search job — ${AMOUNT_USDC} USDC on Stellar, webhook callback`, - }, -} +// Payment requirements come from the shared x402Config module (Issue #108). +const x402Routes: RoutesConfig = buildExpressRoutes() as RoutesConfig const facilitatorClient = new HTTPFacilitatorClient({ url: FACILITATOR_URL }) const schemes = [{ network: NETWORK, server: new ExactStellarScheme() }] @@ -356,44 +166,11 @@ app.use((req, res, next) => { if (!consumption.ok) { return res.status(402).json({ error: consumption.error }) } - // Captured for reconciliation — links this request to the settled - // payment identifier without ever touching query content. - ;(req as any).paymentId = consumption.paymentId } } next() }) -// Builds and persists a ReconciliationRecord for a paid route. Never throws — -// a logging failure must not affect the response already sent to the client. -function recordReconciliation(params: { - req: Request - route: ReconciliationRoute - requestId: string - providerDelivered: boolean - resultCount: number - txHash: string | null -}): void { - try { - const idempotencyKey = (params.req as any).paymentId ?? null - // Nothing to reconcile: no payment was captured and nothing was - // delivered (e.g. a bad `q` rejected before any payment attempt). - if (idempotencyKey === null && !params.providerDelivered) return - - const record = buildReconciliationRecord({ - requestId: params.requestId, - idempotencyKey, - route: params.route, - receiptTxHash: params.txHash, - providerDelivered: params.providerDelivered, - resultCount: params.resultCount, - }) - appendReconciliationRecord(record) - } catch (err: any) { - console.error('[reconciliation] failed to record:', err.message) - } -} - export const MAX_QUERY_LENGTH = 256 // Validate and sanitize the user-supplied `q` parameter. Returns either the @@ -419,23 +196,18 @@ export function validateQuery( // ─── GET /search ────────────────────────────────────────────────────────── app.get('/search', async (req: Request, res: Response) => { - const requestId = randomUUID() - let providerDelivered = false - let resultCount = 0 - let txHash: string | null = null + const { q, count = '5', freshness } = req.query as Record - try { - const { q, count = '5', freshness } = req.query as Record - - const v = validateQuery(q) - if (!v.ok) { - const errorBody: ApiErrorResponse = { error: v.error } - return res.status(400).json(errorBody) - } - const cleanQ = v.cleanQ + const v = validateQuery(q) + if (!v.ok) { + const errorBody: ApiErrorResponse = { error: v.error } + return res.status(400).json(errorBody) + } + const cleanQ = v.cleanQ - const t0 = Date.now() + const t0 = Date.now() + try { const requestBody: Record = { q: cleanQ, num: Math.min(parseInt(count) || 5, 20), @@ -478,10 +250,9 @@ app.get('/search', async (req: Request, res: Response) => { if (stats.latencies.length > 200) stats.latencies.shift() const results = normalizeOrganicResults(data) - const queryMeta = normalizeQueryMetadata(data, cleanQ) // The real tx hash comes from the X-PAYMENT-RESPONSE header set by the facilitator - txHash = (req.headers['x-payment-response'] as string) || null + const txHash = (req.headers['x-payment-response'] as string) || null // ── Optional AI suggestions via Groq ────────────────────────────────── let suggestions: string[] = [] @@ -497,7 +268,7 @@ app.get('/search', async (req: Request, res: Response) => { }, { role: 'user', - content: `Query: "${queryMeta.executedQuery}"\nTop results: ${topSnippets}`, + content: `Query: "${cleanQ}"\nTop results: ${topSnippets}`, }, ], max_tokens: 120, @@ -520,11 +291,7 @@ app.get('/search', async (req: Request, res: Response) => { } const responseBody: SearchResponse = { - query: queryMeta.executedQuery, - originalQuery: queryMeta.originalQuery, - executedQuery: queryMeta.executedQuery, - suggestedQuery: queryMeta.suggestedQuery, - isCorrected: queryMeta.isCorrected, + query: cleanQ, results, count: results.length, network: NETWORK, @@ -535,44 +302,28 @@ app.get('/search', async (req: Request, res: Response) => { suggestions, } - // Record opted-in receipt (cap 50, in-memory) - try { - addRecentReceipt({ id: txHash || `local-${Date.now()}-${Math.random().toString(36).slice(2,6)}`, query: queryMeta.originalQuery, txHash, amount: AMOUNT_USDC, currency: 'USDC', network: NETWORK, timestamp: new Date().toISOString(), latencyMs, count: results.length }) - } catch { - // ignore receipt recording failure - } - - providerDelivered = true - resultCount = results.length return res.json(responseBody) } catch (err: any) { console.error('[search error]', err.message) const errorBody: ApiErrorResponse = { error: 'Search failed. Check server logs.' } return res.status(500).json(errorBody) - } finally { - recordReconciliation({ req, route: '/search', requestId, providerDelivered, resultCount, txHash }) } }) // ─── GET /images ────────────────────────────────────────────────────────── app.get('/images', async (req: Request, res: Response) => { - const requestId = randomUUID() - let providerDelivered = false - let resultCount = 0 - let txHash: string | null = null - - try { - const { q, count = '10' } = req.query as Record + const { q, count = '10' } = req.query as Record - const v = validateQuery(q) - if (!v.ok) { - const errorBody: ApiErrorResponse = { error: v.error } - return res.status(400).json(errorBody) - } - const cleanQ = v.cleanQ + const v = validateQuery(q) + if (!v.ok) { + const errorBody: ApiErrorResponse = { error: v.error } + return res.status(400).json(errorBody) + } + const cleanQ = v.cleanQ - const t0 = Date.now() + const t0 = Date.now() + try { const serperRes = await fetch('https://google.serper.dev/images', { method: 'POST', headers: { @@ -602,7 +353,7 @@ app.get('/images', async (req: Request, res: Response) => { const results = normalizeImageResults(data) - txHash = (req.headers['x-payment-response'] as string) || null + const txHash = (req.headers['x-payment-response'] as string) || null const responseBody: ImageSearchResponse = { query: cleanQ, @@ -615,37 +366,28 @@ app.get('/images', async (req: Request, res: Response) => { latencyMs, } - providerDelivered = true - resultCount = results.length return res.json(responseBody) } catch (err: any) { console.error('[images error]', err.message) const errorBody: ApiErrorResponse = { error: 'Image search failed. Check server logs.' } return res.status(500).json(errorBody) - } finally { - recordReconciliation({ req, route: '/images', requestId, providerDelivered, resultCount, txHash }) } }) // ─── GET /news ──────────────────────────────────────────────────────────── app.get('/news', async (req: Request, res: Response) => { - const requestId = randomUUID() - let providerDelivered = false - let resultCount = 0 - let txHash: string | null = null + const { q, count = '10', freshness } = req.query as Record - try { - const { q, count = '10', freshness } = req.query as Record - - const v = validateQuery(q) - if (!v.ok) { - const errorBody: ApiErrorResponse = { error: v.error } - return res.status(400).json(errorBody) - } - const cleanQ = v.cleanQ + const v = validateQuery(q) + if (!v.ok) { + const errorBody: ApiErrorResponse = { error: v.error } + return res.status(400).json(errorBody) + } + const cleanQ = v.cleanQ - const t0 = Date.now() + const t0 = Date.now() + try { const requestBody: Record = { q: cleanQ, num: Math.min(parseInt(count) || 10, 20), @@ -688,7 +430,7 @@ app.get('/news', async (req: Request, res: Response) => { const results = normalizeNewsResults(data) - txHash = (req.headers['x-payment-response'] as string) || null + const txHash = (req.headers['x-payment-response'] as string) || null const responseBody: NewsSearchResponse = { query: cleanQ, @@ -701,360 +443,12 @@ app.get('/news', async (req: Request, res: Response) => { latencyMs, } - providerDelivered = true - resultCount = results.length return res.json(responseBody) } catch (err: any) { console.error('[news error]', err.message) const errorBody: ApiErrorResponse = { error: 'News search failed. Check server logs.' } return res.status(500).json(errorBody) - } finally { - recordReconciliation({ req, route: '/news', requestId, providerDelivered, resultCount, txHash }) - } -}) - -// ─── POST /search/batch — JSON Lines streaming (issue #325) ─────────────── -// Bounded batch endpoint: versioned JSONL events (quote, settlement, result, error, done) -// Handles idempotency, aggregate spending limits, disconnect abort, partial completion. -app.post('/search/batch', async (req: Request, res: Response) => { - const requestId = crypto.randomUUID() - const tBatchStart = Date.now() - - // Idempotency: header or body key, valid for 24h - const idempotencyKey = (req.headers['idempotency-key'] as string) || (req.body as any)?.idempotencyKey - if (idempotencyKey) { - cleanupBatchIdempotency() - const existing = batchIdempotencyStore.get(idempotencyKey) - if (existing && existing.expiresAt > Date.now()) { - return res.status(409).json({ error: 'Idempotent batch already processed', requestId: existing.requestId, idempotencyKey }) - } - } - - const { queries, count: rawCount, freshness } = (req.body || {}) as { queries?: unknown; count?: unknown; freshness?: string } - - if (!Array.isArray(queries) || queries.length === 0) { - return res.status(400).json({ error: 'queries array required (1..10)' }) - } - if (queries.length > MAX_BATCH_SIZE) { - return res.status(400).json({ error: `Batch too large: max ${MAX_BATCH_SIZE} queries, got ${queries.length}` }) - } - const totalAmount = (parseFloat(AMOUNT_USDC) * queries.length).toFixed(3) - if (parseFloat(totalAmount) > MAX_BATCH_TOTAL_USDC) { - return res.status(400).json({ error: `Aggregate spending limit exceeded: ${totalAmount} USDC > ${MAX_BATCH_TOTAL_USDC} USDC` }) - } - const cleanQueries: string[] = [] - for (const q of queries) { - const v = validateQuery(q) - if (!v.ok) return res.status(400).json({ error: `Invalid query "${String(q).slice(0, 30)}": ${v.error}`, index: queries.indexOf(q) }) - cleanQueries.push(v.cleanQ) - } - const parsedCount = Math.min(Math.max(parseInt(String(rawCount ?? '5')) || 5, 1), 20) - - const paymentHeader = (req.headers['payment-signature'] || req.headers['x-payment'] || req.headers['X-PAYMENT'] || req.headers['x-payment-response'] || req.headers['authorization']) as string | undefined - if (!paymentHeader) { - res.setHeader('PAYMENT-REQUIRED', Buffer.from(JSON.stringify({ - x402Version: 2, - error: 'Payment required for batch', - resource: { url: `${req.protocol}://${req.get('host')}${req.originalUrl}`, description: `Batch search ${cleanQueries.length} x ${AMOUNT_USDC} USDC`, mimeType: 'application/x-ndjson' }, - accepts: [{ scheme: 'exact', network: NETWORK, amount: String(parseInt(AMOUNT_STROOPS) * cleanQueries.length), asset: USDC_CONTRACT, payTo: RECEIVING_ADDRESS, maxTimeoutSeconds: 300, extra: { areFeesSponsored: true } }], - })).toString('base64')) - return res.status(402).json({ error: 'Payment required' }) - } - - const consumption = consumePaymentPayload(paymentHeader) - if (!consumption.ok) { - return res.status(402).json({ error: consumption.error }) } - const paymentId = consumption.paymentId - const verified = true - let txHash: string | null = (req.headers['x-payment-response'] as string) || null - try { - const decoded = Buffer.from(paymentHeader, 'base64').toString('utf8') - const parsed = JSON.parse(decoded) - txHash = parsed.transactionHash || parsed.txHash || txHash - } catch { - // ignore header parse error - } - - if (idempotencyKey) { - batchIdempotencyStore.set(idempotencyKey, { requestId, expiresAt: Date.now() + 24 * 3600 * 1000 }) - } - - // Prepare JSONL streaming response - res.setHeader('Content-Type', 'application/x-ndjson; charset=utf-8') - res.setHeader('Cache-Control', 'no-cache, no-transform') - res.setHeader('Connection', 'keep-alive') - res.setHeader('X-Accel-Buffering', 'no') - res.setHeader('X-Request-Id', requestId) - res.flushHeaders?.() - - let clientAborted = false - const abortController = new AbortController() - req.on('close', () => { - if (!res.writableEnded) { - clientAborted = true - abortController.abort() - } - }) - - const writeEvent = (evt: BatchJsonlEvent) => { - if (clientAborted || res.writableEnded) return false - try { - res.write(JSON.stringify(evt) + '\n') - return true - } catch { return false } - } - - // Emit settlement event immediately after payment verification - const settlementEvent: BatchJsonlSettlementEvent = { v: 1, type: 'settlement', requestId, paymentId, txHash, verified, settledAt: new Date().toISOString() } - writeEvent(settlementEvent) - - let succeeded = 0 - let failed = 0 - - for (let i = 0; i < cleanQueries.length; i++) { - if (clientAborted || abortController.signal.aborted) { - const errEvt: BatchJsonlErrorEvent = { v: 1, type: 'error', requestId, index: i, query: cleanQueries[i], error: 'Client disconnected', code: 'CLIENT_DISCONNECT' } - writeEvent(errEvt) - failed++ - // remaining items marked skipped - for (let j = i + 1; j < cleanQueries.length; j++) { - const skipEvt: BatchJsonlErrorEvent = { v: 1, type: 'error', requestId, index: j, query: cleanQueries[j], error: 'Skipped due to client disconnect', code: 'SKIPPED' } - writeEvent(skipEvt) - failed++ - } - break - } - const q = cleanQueries[i] - const t0 = Date.now() - try { - const requestBody: Record = { q, num: parsedCount } - if (freshness) { - const dateFilters: Record = { 'pd': 'qdr:d', 'pw': 'qdr:w', 'pm': 'qdr:m' } - if (dateFilters[freshness]) requestBody.tbs = dateFilters[freshness] - } - const serperRes = await fetch('https://google.serper.dev/search', { - method: 'POST', - headers: { 'X-API-KEY': SERPER_API_KEY, 'Content-Type': 'application/json' }, - body: JSON.stringify(requestBody), - signal: abortController.signal as any, - }) - if (!serperRes.ok) { - const errText = await serperRes.text().catch(() => '') - console.error('[serper batch]', serperRes.status, errText) - const evt: BatchJsonlErrorEvent = { v: 1, type: 'error', requestId, index: i, query: q, error: `Serper.dev API error: ${serperRes.status}`, code: 'UPSTREAM_ERROR' } - writeEvent(evt) - failed++ - continue - } - const data: unknown = await serperRes.json() - const latencyMs = Date.now() - t0 - stats.totalQueries++ - stats.totalUsdcSettled += parseFloat(AMOUNT_USDC) - stats.latencies.push(latencyMs) - if (stats.latencies.length > 200) stats.latencies.shift() - const results = normalizeOrganicResults(data) - const queryMeta = normalizeQueryMetadata(data, q) - addRecentReceipt({ id: txHash || `${requestId}-${i}`, query: queryMeta.originalQuery, txHash, amount: AMOUNT_USDC, currency: 'USDC', network: NETWORK, timestamp: new Date().toISOString(), latencyMs, count: results.length }) - const evt: BatchJsonlResultEvent = { - v: 1, - type: 'result', - requestId, - index: i, - query: queryMeta.executedQuery, - originalQuery: queryMeta.originalQuery, - executedQuery: queryMeta.executedQuery, - suggestedQuery: queryMeta.suggestedQuery, - isCorrected: queryMeta.isCorrected, - results, - count: results.length, - latencyMs, - paidAmount: AMOUNT_USDC, - currency: 'USDC', - network: NETWORK, - txHash, - } - writeEvent(evt) - succeeded++ - } catch (err: any) { - if (err?.name === 'AbortError' || abortController.signal.aborted) { - const evt: BatchJsonlErrorEvent = { v: 1, type: 'error', requestId, index: i, query: q, error: 'Aborted due to client disconnect', code: 'ABORTED' } - writeEvent(evt) - failed++ - // mark remaining as skipped - for (let j = i + 1; j < cleanQueries.length; j++) { - const skip: BatchJsonlErrorEvent = { v: 1, type: 'error', requestId, index: j, query: cleanQueries[j], error: 'Skipped due to abort', code: 'SKIPPED' } - writeEvent(skip); failed++ - } - break - } - const evt: BatchJsonlErrorEvent = { v: 1, type: 'error', requestId, index: i, query: q, error: err.message || 'Search failed', code: 'SEARCH_FAILED' } - writeEvent(evt) - failed++ - } - } - - const doneEvent: BatchJsonlDoneEvent = { - v: 1, type: 'done', requestId, succeeded, failed, - totalUsdcSpent: (succeeded * parseFloat(AMOUNT_USDC)).toFixed(3), - aggregateLatencyMs: Date.now() - tBatchStart, - completedAt: new Date().toISOString(), - } - if (!clientAborted) writeEvent(doneEvent) - res.end() -}) - -// ─── Async paid search jobs with webhooks (issue #324) ───────────────── -app.post('/jobs', async (req: Request, res: Response) => { - cleanupBatchIdempotency() - const idempotencyKey = (req.headers['idempotency-key'] as string) || (req.body as any)?.idempotencyKey - if (idempotencyKey) { - const existing = jobIdempotencyStore.get(idempotencyKey) - if (existing && existing.expiresAt > Date.now()) { - const existingJob = jobStore.get(existing.jobId) - if (existingJob) { - return res.status(200).json({ jobId: existingJob.id, statusUrl: existingJob.statusUrl, paymentVerified: existingJob.verified, job: existingJob }) - } - } - } - - const { query, count = '5', freshness, webhookUrl, webhookSecret } = (req.body || {}) as { query?: unknown; count?: unknown; freshness?: string; webhookUrl?: string; webhookSecret?: string } - - const v = validateQuery(query) - if (!v.ok) return res.status(400).json({ error: v.error }) - const cleanQ = v.cleanQ - const safeCount = Math.min(Math.max(parseInt(String(count)) || 5, 1), 20) - - // Webhook validation (SSRF + https) - if (webhookUrl) { - const chk = validateWebhookUrl(webhookUrl) - if (!chk.ok) return res.status(400).json({ error: chk.error }) - if (!webhookSecret || webhookSecret.length < 16) return res.status(400).json({ error: 'webhookSecret required (min 16 chars) when webhookUrl is set' }) - } - - // Payment verification via x402 header - const paymentHeader = (req.headers['payment-signature'] || req.headers['x-payment'] || req.headers['X-PAYMENT'] || req.headers['x-payment-response'] || req.headers['authorization']) as string | undefined - if (!paymentHeader) { - // Return 402 with payment requirements and statusUrl hint - const paymentRequired = { - x402Version: 2, - error: 'Payment required for async job', - resource: { url: `${req.protocol}://${req.get('host')}${req.originalUrl}`, description: `Async search job: ${AMOUNT_USDC} USDC on Stellar`, mimeType: 'application/json' }, - accepts: [{ scheme: 'exact', network: NETWORK, amount: AMOUNT_STROOPS, asset: USDC_CONTRACT, payTo: RECEIVING_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', hint: 'Retry with X-Payment header containing signed Soroban auth' }) - } - const consumption = consumePaymentPayload(paymentHeader) - if (!consumption.ok) return res.status(402).json({ error: consumption.error }) - const paymentId = consumption.paymentId - const verified = true - let txHash: string | null = (req.headers['x-payment-response'] as string) || null - try { - const decoded = Buffer.from(paymentHeader, 'base64').toString('utf8') - const parsed = JSON.parse(decoded) - txHash = parsed.transactionHash || parsed.txHash || txHash - } catch { - // ignore parse error - } - - const jobId = crypto.randomUUID() - const now = new Date().toISOString() - const statusUrl = `${req.protocol}://${req.get('host')}/jobs/${jobId}` - - const job: SearchJob = { - id: jobId, - query: cleanQ, - count: safeCount, - freshness, - status: 'running' as JobStatus, - createdAt: now, - updatedAt: now, - paymentId, - txHash, - verified, - paidAmount: AMOUNT_USDC, - currency: 'USDC', - network: NETWORK, - webhookUrl, - webhookSecret, - idempotencyKey, - attempts: 0, - statusUrl, - } - jobStore.set(jobId, job) - if (idempotencyKey) jobIdempotencyStore.set(idempotencyKey, { jobId, expiresAt: Date.now() + 24 * 3600 * 1000 }) - - // Immediate 202 response with statusUrl + verified payment state - res.status(202).json({ jobId, statusUrl, paymentVerified: verified, paymentId, txHash, status: job.status }) - - // Fire-and-forget execution (preserves verified x402 settlement, does not block 202) - ;(async () => { - const t0 = Date.now() - try { - const requestBody: Record = { q: cleanQ, num: safeCount } - if (freshness) { - const dateFilters: Record = { 'pd': 'qdr:d', 'pw': 'qdr:w', 'pm': 'qdr:m' } - if (dateFilters[freshness]) requestBody.tbs = dateFilters[freshness] - } - const serperRes = await fetch('https://google.serper.dev/search', { - 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().catch(() => '') - throw new Error(`Serper.dev API error: ${serperRes.status} ${errText}`) - } - const data: unknown = await serperRes.json() - const latencyMs = Date.now() - t0 - stats.totalQueries++ - stats.totalUsdcSettled += parseFloat(AMOUNT_USDC) - stats.latencies.push(latencyMs) - if (stats.latencies.length > 200) stats.latencies.shift() - const results = normalizeOrganicResults(data) - const queryMeta = normalizeQueryMetadata(data, cleanQ) - addRecentReceipt({ id: txHash || jobId, query: queryMeta.originalQuery, txHash, amount: AMOUNT_USDC, currency: 'USDC', network: NETWORK, timestamp: new Date().toISOString(), latencyMs, count: results.length }) - const responseBody: SearchResponse = { - query: queryMeta.executedQuery, - originalQuery: queryMeta.originalQuery, - executedQuery: queryMeta.executedQuery, - suggestedQuery: queryMeta.suggestedQuery, - isCorrected: queryMeta.isCorrected, - results, - count: results.length, - network: NETWORK, - paidAmount: AMOUNT_USDC, - currency: 'USDC', - txHash, - latencyMs, - } - job.result = responseBody - job.status = 'completed' - job.updatedAt = new Date().toISOString() - jobStore.set(jobId, job) - } catch (err: any) { - job.error = err.message || 'Search failed' - job.status = 'failed' - job.updatedAt = new Date().toISOString() - jobStore.set(jobId, job) - } - // Webhook delivery with retries, signed, replay-protected - if (job.webhookUrl && job.webhookSecret) { - await deliverWebhookWithRetry(job) - } - })() -}) - -app.get('/jobs/:id', (req: Request, res: Response) => { - const job = jobStore.get(req.params.id) - if (!job) return res.status(404).json({ error: 'Job not found' }) - return res.json({ job, paymentVerified: job.verified, statusUrl: job.statusUrl }) -}) - -app.get('/jobs', (_req: Request, res: Response) => { - const jobs = Array.from(jobStore.values()).sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()).slice(0, 50) - return res.json({ jobs, count: jobs.length }) }) // ─── GET /health ────────────────────────────────────────────────────────── @@ -1087,9 +481,6 @@ app.get('/health', (_req: Request, res: Response) => { // `Accept: text/event-stream`; otherwise returns the full completion as JSON // (back-compat fallback for callers that don't support SSE). app.post('/ai/chat', async (req: Request, res: Response) => { - if (!groq) { - return res.status(503).json({ error: 'AI assistant is not configured.' }) - } const { messages, model: requestedModel } = req.body as { messages: { role: 'system' | 'user' | 'assistant'; content: string }[] model?: string @@ -1198,18 +589,9 @@ app.get('/', (_req: Request, res: Response) => { 'GET /search?q=': '0.001 USDC via x402', 'GET /images?q=': '0.001 USDC via x402 — image results', 'GET /news?q=': '0.001 USDC via x402 — news articles', - 'POST /search/batch': '0.001 USDC per query (max 10), JSONL streaming — versioned quote/settlement/result/error/done events, idempotency & aggregate limits', - 'POST /jobs': '0.001 USDC via x402 — async job, returns 202 + statusUrl + verified payment state', - 'GET /jobs/:id': 'Job status + verified payment state (webhook signed, replay/SSRF protected)', - 'GET /jobs': 'List recent jobs (capped at 50)', 'POST /ai/chat': 'Groq AI — free', 'GET /health': 'Live server stats', }, - mcp: { - resources: ['stellar-search://capabilities', 'stellar-search://schema/search', 'stellar-search://receipts/recent (opted-in)'], - prompts: ['research_brief (no silent payment)', 'summarize_results', 'compare_sources'], - progress: 'notifications/progress bounded to 4 phases (challenge→signing→settlement→search), cancellation/error terminates cleanly without false completion', - }, }) }) diff --git a/server/validateQuery.test.ts b/server/validateQuery.test.ts index aca238b..dd1ffb8 100644 --- a/server/validateQuery.test.ts +++ b/server/validateQuery.test.ts @@ -1,5 +1,12 @@ import { describe, it, expect, vi } from 'vitest' +// Use vi.hoisted to ensure env is set before vi.mock hoisting triggers module loads +vi.hoisted(() => { + process.env.STELLAR_RECEIVING_ADDRESS = 'GAAZI4TCR3TY5OJHCTJC2A4AFL5MNSF3GAKGOWG5W2LBBGCS2TDPZOM3' + process.env.SERPER_API_KEY = 'test-serper-key' + process.env.GROQ_API_KEY = 'gsk_test' +}) + vi.mock('@x402/express', () => ({ paymentMiddlewareFromConfig: () => (_req: any, _res: any, next: any) => next(), })) @@ -16,10 +23,6 @@ vi.mock('./logger', () => ({ default: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, })) -process.env.STELLAR_RECEIVING_ADDRESS = 'GAAZI4TCR3TY5OJHCTJC2A4AFL5MNSF3GAKGOWG5W2LBBGCS2TDPZOM3' -process.env.SERPER_API_KEY = 'test-serper-key' -process.env.GROQ_API_KEY = 'gsk_test' - import { validateQuery, MAX_QUERY_LENGTH } from './index' describe('validateQuery — x402 paid route input validation', () => { diff --git a/src/lib/x402Config.test.ts b/src/lib/x402Config.test.ts new file mode 100644 index 0000000..0899093 --- /dev/null +++ b/src/lib/x402Config.test.ts @@ -0,0 +1,248 @@ +/** + * x402Config.test.ts + * + * Validates that Express and Vercel runtimes emit identical payment requirements + * built from the single shared x402Config module. + * + * Covers: + * - Network, asset, amount, payTo, timeout, fee sponsorship + * - Snapshot comparison of Express routes vs Vercel payment-required payload + * - Validation guards for malformed env values + * - x402 settlement semantics preservation + */ + +import { describe, it, expect, beforeEach } from 'vitest' + +// Ensure clean env before each test +const ORIGINAL_ENV = { ...process.env } + +beforeEach(() => { + process.env = { ...ORIGINAL_ENV } + process.env.STELLAR_RECEIVING_ADDRESS = 'GAAZI4TCR3TY5OJHCTJC2A4AFL5MNSF3GAKGOWG5W2LBBGCS2TDPZOM3' + process.env.STELLAR_NETWORK = 'stellar:testnet' +}) + +// ─── Shared config helpers ─────────────────────────────────────────────── + +import { + getNetwork, + getAsset, + getAmount, + getAmountUsdc, + getPrice, + getPayTo, + buildPaymentRequirement, + buildExpressRoutes, + buildPaymentRequiredPayload, + getFacilitatorUrl, + getFullConfig, +} from './x402Config' + +// ─── Individual field validation ───────────────────────────────────────── + +describe('x402Config — individual field getters', () => { + it('getNetwork returns stellar:testnet in test env', () => { + expect(getNetwork()).toBe('stellar:testnet') + }) + + it('getAsset returns a valid Soroban contract address', () => { + const asset = getAsset() + expect(asset).toMatch(/^C[A-Z2-7]{55}$/) + }) + + it('getAmount returns "10000" (0.001 USDC in stroops)', () => { + expect(getAmount()).toBe('10000') + }) + + it('getAmountUsdc returns "0.001"', () => { + expect(getAmountUsdc()).toBe('0.001') + }) + + it('getPrice returns 0.001', () => { + expect(getPrice()).toBe(0.001) + }) + + it('getPayTo returns the configured address', () => { + const payTo = getPayTo() + expect(payTo).toBe('GAAZI4TCR3TY5OJHCTJC2A4AFL5MNSF3GAKGOWG5W2LBBGCS2TDPZOM3') + }) + + it('getPayTo uses override when provided', () => { + const custom = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5' + expect(getPayTo(custom)).toBe(custom) + }) + + it('getPayTo throws when address is invalid and no env', () => { + delete process.env.STELLAR_RECEIVING_ADDRESS + expect(() => getPayTo('INVALID')).toThrow(/STELLAR_RECEIVING_ADDRESS/) + }) + + it('getFacilitatorUrl returns default when env not set', () => { + delete process.env.FACILITATOR_URL + expect(getFacilitatorUrl()).toBe('https://www.x402.org/facilitator') + }) +}) + +// ─── buildPaymentRequirement ───────────────────────────────────────────── + +describe('x402Config — buildPaymentRequirement', () => { + it('returns a complete x402 v2 payment requirement', () => { + const req = buildPaymentRequirement() + expect(req).toEqual({ + scheme: 'exact', + network: 'stellar:testnet', + asset: expect.stringMatching(/^C[A-Z2-7]{55}$/), + amount: '10000', + payTo: 'GAAZI4TCR3TY5OJHCTJC2A4AFL5MNSF3GAKGOWG5W2LBBGCS2TDPZOM3', + maxTimeoutSeconds: 300, + extra: { areFeesSponsored: true }, + }) + }) + + it('uses override payTo when provided', () => { + const custom = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5' + const req = buildPaymentRequirement(custom) + expect(req.payTo).toBe(custom) + }) +}) + +// ─── Express vs Vercel snapshot comparison ──────────────────────────────── + +describe('x402Config — Express and Vercel payment requirements are identical', () => { + it('Express routes accept array has correct structure', () => { + const routes = buildExpressRoutes() + + // Every Express route must contain the exact same payment option + for (const route of Object.values(routes)) { + expect(route.accepts).toHaveLength(1) + const opt = route.accepts[0] + expect(opt.scheme).toBe('exact') + expect(opt.payTo).toBe('GAAZI4TCR3TY5OJHCTJC2A4AFL5MNSF3GAKGOWG5W2LBBGCS2TDPZOM3') + expect(opt.price).toBe(0.001) + expect(opt.network).toBe('stellar:testnet') + } + }) + + it('Vercel payment-required payload uses the same requirement', () => { + const url = 'https://example.com/api/search?q=stellar' + const payload = buildPaymentRequiredPayload(url) + const requirement = buildPaymentRequirement() + + expect(payload.x402Version).toBe(2) + expect(payload.accepts).toHaveLength(1) + expect(payload.accepts[0]).toEqual(requirement) + }) + + it('snapshot: Express and Vercel share core fields (network, payTo, scheme, price/amount)', () => { + const routes = buildExpressRoutes() + const url = 'https://example.com/api/search?q=stellar' + const payload = buildPaymentRequiredPayload(url) + + const expressOpt = routes['GET /search'].accepts[0] + const vercelReq = payload.accepts[0] + + // Core fields must match exactly between runtimes + expect(expressOpt.scheme).toBe(vercelReq.scheme) + expect(expressOpt.payTo).toBe(vercelReq.payTo) + expect(expressOpt.network).toBe(vercelReq.network) + // Express uses price (number), Vercel uses amount (stroops string) — derived from same source + expect(expressOpt.price).toBe(parseFloat(vercelReq.amount) / 10_000_000) + }) + + it('snapshot: all Express routes share identical payment option', () => { + const routes = buildExpressRoutes() + expect(routes['GET /search'].accepts[0]).toEqual(routes['GET /images'].accepts[0]) + expect(routes['GET /search'].accepts[0]).toEqual(routes['GET /news'].accepts[0]) + }) + + it('snapshot: full config is stable across calls', () => { + const config1 = getFullConfig() + const config2 = getFullConfig() + expect(config1).toEqual(config2) + }) + + it('snapshot: getFullConfig returns all required fields', () => { + const config = getFullConfig() + expect(config).toMatchObject({ + x402Version: 2, + network: 'stellar:testnet', + asset: expect.stringMatching(/^C[A-Z2-7]{55}$/), + amount: '10000', + price: 0.001, + payTo: 'GAAZI4TCR3TY5OJHCTJC2A4AFL5MNSF3GAKGOWG5W2LBBGCS2TDPZOM3', + maxTimeoutSeconds: 300, + extra: { areFeesSponsored: true }, + facilitatorUrl: 'https://www.x402.org/facilitator', + }) + // Verify expressRoutes structure + expect(Object.keys(config.expressRoutes)).toEqual(['GET /search', 'GET /images', 'GET /news']) + for (const route of Object.values(config.expressRoutes)) { + expect(route.accepts).toHaveLength(1) + expect(route.accepts[0].scheme).toBe('exact') + expect(route.accepts[0].price).toBe(0.001) + } + }) +}) + +// ─── Settlement semantics ──────────────────────────────────────────────── + +describe('x402Config — settlement semantics preserved', () => { + it('amount in stroops is 10^7 × USDC amount (Stellar 7 decimals)', () => { + expect(parseInt(getAmount())).toBe(Math.round(getPrice() * 10_000_000)) + }) + + it('asset is a Soroban contract (C prefix, 56 chars)', () => { + expect(getAsset()).toMatch(/^C[A-Z2-7]{55}$/) + }) + + it('network is a valid x402 network identifier', () => { + expect(getNetwork()).toMatch(/^stellar:(testnet|mainnet)$/) + }) + + it('maxTimeoutSeconds is 300 (5 minutes, aligned with paymentIntegrity)', () => { + const req = buildPaymentRequirement() + expect(req.maxTimeoutSeconds).toBe(300) + }) + + it('areFeesSponsored is true', () => { + const req = buildPaymentRequirement() + expect(req.extra.areFeesSponsored).toBe(true) + }) +}) + +// ─── Express routes structure ──────────────────────────────────────────── + +describe('x402Config — Express routes structure', () => { + it('defines GET /search, GET /images, GET /news', () => { + const routes = buildExpressRoutes() + expect(Object.keys(routes)).toEqual([ + 'GET /search', + 'GET /images', + 'GET /news', + ]) + }) + + it('each route has description and accepts', () => { + const routes = buildExpressRoutes() + for (const route of Object.values(routes)) { + expect(typeof route.description).toBe('string') + expect(route.description.length).toBeGreaterThan(0) + expect(Array.isArray(route.accepts)).toBe(true) + expect(route.accepts.length).toBe(1) + } + }) +}) + +// ─── Vercel payment-required payload structure ──────────────────────────── + +describe('x402Config — Vercel payment-required payload', () => { + it('has x402Version 2, error, resource, and accepts', () => { + const url = 'https://example.com/api/search?q=test' + const payload = buildPaymentRequiredPayload(url) + expect(payload.x402Version).toBe(2) + expect(payload.error).toBe('Payment required') + expect(payload.resource.url).toBe(url) + expect(payload.resource.mimeType).toBe('application/json') + expect(Array.isArray(payload.accepts)).toBe(true) + }) +}) diff --git a/src/lib/x402Config.ts b/src/lib/x402Config.ts new file mode 100644 index 0000000..8a042fe --- /dev/null +++ b/src/lib/x402Config.ts @@ -0,0 +1,250 @@ +/** + * x402Config.ts + * + * Single source of truth for x402 payment route and asset configuration. + * Both Express (server/index.ts) and Vercel (api/search.ts) import this + * module to build payment requirements, eliminating protocol drift. + * + * Fields covered: + * - network (stellar:testnet | stellar:mainnet) + * - asset (Soroban USDC contract address) + * - amount (stroops — 0.001 USDC = 10000) + * - payTo ( Stellar receiving address from env) + * - timeout (maxTimeoutSeconds) + * - fee sponsorship (areFeesSponsored) + */ + +import { + STELLAR_NETWORK, + USDC_CONTRACT, + AMOUNT_STROOPS, + AMOUNT_USDC, +} from './constants' + +// ─── Constants ─────────────────────────────────────────────────────────── +const MAX_TIMEOUT_SECONDS = 300 +const X402_VERSION = 2 + +// ─── Types ─────────────────────────────────────────────────────────────── + +export interface X402PaymentRequirement { + scheme: string + network: string + asset: string + amount: string + payTo: string + maxTimeoutSeconds: number + extra: Record +} + +export interface X402ExpressPaymentOption { + scheme: string + payTo: string + price: number + network: string +} + +export interface X402RouteConfig { + accepts: X402ExpressPaymentOption[] + description: string +} + +export interface X402FullConfig { + x402Version: number + network: string + asset: string + amount: string + price: number + payTo: string + maxTimeoutSeconds: number + extra: Record + facilitatorUrl: string + expressRoutes: Record +} + +// ─── Validation ────────────────────────────────────────────────────────── + +function validateNetwork(network: string): network is 'stellar:testnet' | 'stellar:mainnet' { + return network === 'stellar:testnet' || network === 'stellar:mainnet' +} + +function validateAddress(addr: string): boolean { + return typeof addr === 'string' && /^[A-Z2-7]{56}$/.test(addr) +} + +function validateStroops(amount: string): boolean { + const n = parseInt(amount, 10) + return Number.isFinite(n) && n > 0 +} + +function validatePayTo(payTo: string | undefined, _network: string): string { + if (payTo && validateAddress(payTo)) return payTo + // Fallback: read from env at call time + const envPayTo = process.env.STELLAR_RECEIVING_ADDRESS + if (envPayTo && validateAddress(envPayTo)) return envPayTo + throw new Error( + `[x402Config] STELLAR_RECEIVING_ADDRESS is missing or invalid`, + ) +} + +// ─── Public API ────────────────────────────────────────────────────────── + +/** + * Returns the validated network string for x402. + */ +export function getNetwork(): string { + const network = STELLAR_NETWORK + if (!validateNetwork(network)) { + throw new Error(`[x402Config] Invalid STELLAR_NETWORK: ${network}`) + } + return network +} + +/** + * Returns the Soroban USDC contract address for the current network. + */ +export function getAsset(): string { + const contract = USDC_CONTRACT + if (!validateAddress(contract)) { + throw new Error(`[x402Config] Invalid USDC_CONTRACT: ${contract}`) + } + return contract +} + +/** + * Returns the payment amount in stroops. + */ +export function getAmount(): string { + if (!validateStroops(AMOUNT_STROOPS)) { + throw new Error(`[x402Config] Invalid AMOUNT_STROOPS: ${AMOUNT_STROOPS}`) + } + return AMOUNT_STROOPS +} + +/** + * Returns the payment amount as a decimal USDC string. + */ +export function getAmountUsdc(): string { + return AMOUNT_USDC +} + +/** + * Returns the payment amount as a parsed number (for Express middleware price field). + */ +export function getPrice(): number { + const price = parseFloat(AMOUNT_USDC) + if (!Number.isFinite(price) || price <= 0) { + throw new Error(`[x402Config] Invalid AMOUNT_USDC: ${AMOUNT_USDC}`) + } + return price +} + +/** + * Returns the payTo address. Validates or throws if not configured. + */ +export function getPayTo(override?: string): string { + const network = getNetwork() + return validatePayTo(override, network) +} + +/** + * Returns the default payment requirement object shared by all runtimes. + */ +export function buildPaymentRequirement( + payToOverride?: string, +): X402PaymentRequirement { + return { + scheme: 'exact', + network: getNetwork(), + asset: getAsset(), + amount: getAmount(), + payTo: getPayTo(payToOverride), + maxTimeoutSeconds: MAX_TIMEOUT_SECONDS, + extra: { areFeesSponsored: true }, + } +} + +/** + * Builds the x402 route configuration for Express middleware. + * The Express middleware (paymentMiddlewareFromConfig) expects PaymentOption format: + * { scheme, payTo, price, network } — the facilitator resolves asset and amount from price. + * Each paid route (GET /search, /images, /news) shares the same payment option. + */ +export function buildExpressRoutes( + payToOverride?: string, +): Record { + const payTo = getPayTo(payToOverride) + const network = getNetwork() + const price = getPrice() + const amountUsdc = getAmountUsdc() + + const paymentOption: X402ExpressPaymentOption = { + scheme: 'exact', + payTo, + price, + network: network as 'stellar:testnet' | 'stellar:mainnet', + } + + const routeDescription = (label: string) => + `StellarSearch: pay-per-query ${label} — ${amountUsdc} USDC on ${network}` + + return { + 'GET /search': { + accepts: [paymentOption], + description: routeDescription('web search'), + }, + 'GET /images': { + accepts: [paymentOption], + description: routeDescription('image search'), + }, + 'GET /news': { + accepts: [paymentOption], + description: routeDescription('news search'), + }, + } +} + +/** + * Builds the x402 Payment-Required response body for Vercel's manual 402. + */ +export function buildPaymentRequiredPayload( + requestUrl: string, + payToOverride?: string, +) { + const requirement = buildPaymentRequirement(payToOverride) + return { + x402Version: X402_VERSION, + error: 'Payment required', + resource: { + url: requestUrl, + description: `StellarSearch: pay-per-query web search — ${getAmountUsdc()} USDC on ${getNetwork()}`, + mimeType: 'application/json', + }, + accepts: [requirement], + } +} + +/** + * Returns the facilitator URL from env or default. + */ +export function getFacilitatorUrl(): string { + return process.env.FACILITATOR_URL || 'https://www.x402.org/facilitator' +} + +/** + * Full configuration object for snapshot testing and validation. + */ +export function getFullConfig(payToOverride?: string): X402FullConfig { + return { + x402Version: X402_VERSION, + network: getNetwork(), + asset: getAsset(), + amount: getAmount(), + price: getPrice(), + payTo: getPayTo(payToOverride), + maxTimeoutSeconds: MAX_TIMEOUT_SECONDS, + extra: { areFeesSponsored: true }, + facilitatorUrl: getFacilitatorUrl(), + expressRoutes: buildExpressRoutes(payToOverride), + } +}