diff --git a/README.md b/README.md index 1dab512..e1e791f 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,14 @@ The typed schema checks required core variables separately from optional feature --- +## x402 service discovery + +Autonomous clients can fetch [`/.well-known/x402`](http://localhost:3001/.well-known/x402) without payment to discover the paid resources before making a request. Express and Vercel serve the same runtime-generated document; Vercel rewrites the stable root path to its serverless handler. + +The document includes `resourceTemplates` for `/search`, `/images`, and `/news`, plus the active `networks`, Soroban USDC `assets`, supported payment `schemes`, and a `priceDiscoveryUrl`. Each template carries the exact x402 payment option, including the configured receiving address, network, asset, and `10000` stroop (`0.001 USDC`) price. The metadata is intentionally public and does not bypass payment, approval, signature verification, replay protection, or settlement on paid routes. + +The response is cacheable for five minutes. Set `PUBLIC_BASE_URL` to keep `priceDiscoveryUrl` canonical behind a reverse proxy. + ## How the x402 payment flow works ``` diff --git a/api/index.ts b/api/index.ts index 5165b3b..d87cd58 100644 --- a/api/index.ts +++ b/api/index.ts @@ -9,6 +9,7 @@ export default function handler(req: VercelRequest, res: VercelResponse) { 'GET /api/search?q=': '0.001 USDC via x402', 'POST /api/ai/chat': 'Groq AI — free', 'GET /api/health': 'Live server stats', + 'GET /.well-known/x402': 'Machine-readable x402 resource and pricing discovery metadata', }, }) } \ No newline at end of file diff --git a/api/well-known/x402.test.ts b/api/well-known/x402.test.ts new file mode 100644 index 0000000..607f0bf --- /dev/null +++ b/api/well-known/x402.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from 'vitest' +import handler from '../well-known/x402' + +function mockResponse() { + const res: any = { + setHeader: vi.fn(), + status: vi.fn().mockImplementation((code: number) => { + res._status = code + return res + }), + json: vi.fn().mockImplementation((body: unknown) => { + res._json = body + return res + }), + } + return res +} + +describe('Vercel x402 discovery handler', () => { + it('returns a cacheable machine-readable document', async () => { + const res = mockResponse() + await handler({ method: 'GET', headers: { host: 'example.com', 'x-forwarded-proto': 'https' } } as any, res) + + expect(res._status).toBe(200) + expect(res.setHeader).toHaveBeenCalledWith('Cache-Control', 'public, max-age=300') + expect(res._json.protocol).toBe('x402') + expect(res._json.resourceTemplates).toHaveLength(3) + expect(res._json.priceDiscoveryUrl).toBe('https://example.com/.well-known/x402') + }) + + it('only permits GET', async () => { + const res = mockResponse() + await handler({ method: 'POST', headers: {} } as any, res) + expect(res._status).toBe(405) + expect(res.setHeader).toHaveBeenCalledWith('Allow', 'GET') + }) +}) diff --git a/api/well-known/x402.ts b/api/well-known/x402.ts new file mode 100644 index 0000000..1cc918c --- /dev/null +++ b/api/well-known/x402.ts @@ -0,0 +1,15 @@ +import type { VercelRequest, VercelResponse } from '@vercel/node' +import { + getX402DiscoveryMetadata, + requestOrigin, +} from '../../src/lib/x402Discovery' + +export default function handler(req: VercelRequest, res: VercelResponse) { + if (req.method !== 'GET') { + res.setHeader('Allow', 'GET') + return res.status(405).json({ error: 'Method not allowed' }) + } + + res.setHeader('Cache-Control', 'public, max-age=300') + return res.status(200).json(getX402DiscoveryMetadata({ origin: requestOrigin(req) })) +} diff --git a/server/index.ts b/server/index.ts index 1725765..5b8f6ce 100644 --- a/server/index.ts +++ b/server/index.ts @@ -111,6 +111,13 @@ app.use(cors(buildCorsOptions())) app.use(express.json()) app.use(limiter) +// Machine-readable x402 service discovery. Keep this before payment middleware: +// discovery is public, while the resource templates it advertises are paid. +app.get('/.well-known/x402', (req: Request, res: Response) => { + res.setHeader('Cache-Control', 'public, max-age=300') + return res.json(getX402DiscoveryMetadata({ origin: requestOrigin(req) })) +}) + // ─── In-memory stats ────────────────────────────────────────────────────── const stats = { totalQueries: 0, diff --git a/src/lib/x402Discovery.test.ts b/src/lib/x402Discovery.test.ts new file mode 100644 index 0000000..9319202 --- /dev/null +++ b/src/lib/x402Discovery.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from 'vitest' +import { + getX402DiscoveryMetadata, + isX402DiscoveryMetadata, + requestOrigin, + X402_DISCOVERY_PATH, +} from './x402Discovery' + +describe('x402 discovery metadata', () => { + it('enumerates all paid resource templates and shared payment capabilities', () => { + const metadata = getX402DiscoveryMetadata({ + origin: 'https://search.example.com', + payTo: 'GAAZI4TCR3TY5OJHCTJC2A4AFL5MNSF3GAKGOWG5W2LBBGCS2TDPZOM3', + }) + + expect(metadata.version).toBe(1) + expect(metadata.protocol).toBe('x402') + expect(metadata.resourceTemplates.map((template) => template.resource)).toEqual([ + '/search?q={q}', + '/images?q={q}', + '/news?q={q}', + ]) + expect(metadata.networks).toEqual(['stellar:testnet']) + expect(metadata.assets).toEqual(['CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA']) + expect(metadata.schemes).toEqual(['exact']) + expect(metadata.priceDiscoveryUrl).toBe(`https://search.example.com${X402_DISCOVERY_PATH}`) + expect(metadata.resourceTemplates.every((template) => template.accepts[0].amount === '10000')).toBe(true) + expect(metadata.resourceTemplates.every((template) => template.accepts[0].payTo !== null)).toBe(true) + expect(isX402DiscoveryMetadata(metadata)).toBe(true) + }) + + it('takes the receiving address from runtime configuration when not supplied', () => { + vi.stubEnv('STELLAR_RECEIVING_ADDRESS', 'G_RUNTIME_ADDRESS') + const metadata = getX402DiscoveryMetadata({ origin: 'http://localhost:3001' }) + expect(metadata.resourceTemplates[0].accepts[0].payTo).toBe('G_RUNTIME_ADDRESS') + vi.unstubAllEnvs() + }) + + it('derives origin from forwarded deployment headers and honors PUBLIC_BASE_URL', () => { + expect(requestOrigin({ headers: { host: 'example.test', 'x-forwarded-proto': 'https' } })).toBe('https://example.test') + vi.stubEnv('PUBLIC_BASE_URL', 'https://canonical.example') + expect(requestOrigin({ headers: { host: 'internal:3000' } })).toBe('https://canonical.example') + vi.unstubAllEnvs() + }) + + it('rejects malformed metadata', () => { + expect(isX402DiscoveryMetadata(null)).toBe(false) + expect(isX402DiscoveryMetadata({ protocol: 'x402' })).toBe(false) + }) +}) diff --git a/src/lib/x402Discovery.ts b/src/lib/x402Discovery.ts new file mode 100644 index 0000000..211b445 --- /dev/null +++ b/src/lib/x402Discovery.ts @@ -0,0 +1,101 @@ +import { + AMOUNT_STROOPS, + AMOUNT_USDC, + STELLAR_NETWORK, + USDC_CONTRACT, +} from './constants' + +export const X402_DISCOVERY_PATH = '/.well-known/x402' + +export interface X402ResourceTemplate { + id: string + resource: string + method: 'GET' + description: string + accepts: X402PaymentOption[] +} + +export interface X402PaymentOption { + scheme: 'exact' + network: string + asset: string + amount: string + price: string + currency: 'USDC' + payTo: string | null +} + +export interface X402DiscoveryMetadata { + version: 1 + protocol: 'x402' + resourceTemplates: X402ResourceTemplate[] + networks: string[] + assets: string[] + schemes: string[] + priceDiscoveryUrl: string +} + +function paymentOption(payTo: string | null): X402PaymentOption { + return { + scheme: 'exact', + network: STELLAR_NETWORK, + asset: USDC_CONTRACT, + amount: AMOUNT_STROOPS, + price: AMOUNT_USDC, + currency: 'USDC', + payTo, + } +} + +export function getX402DiscoveryMetadata({ + origin, + payTo = process.env.STELLAR_RECEIVING_ADDRESS || null, +}: { + origin: string + payTo?: string | null +}): X402DiscoveryMetadata { + const accepts = paymentOption(payTo) + const resourceTemplates = [ + ['search', '/search?q={q}', 'Pay-per-query web search'], + ['images', '/images?q={q}', 'Pay-per-query image search'], + ['news', '/news?q={q}', 'Pay-per-query news search'], + ].map(([id, resource, description]) => ({ + id, + resource, + method: 'GET' as const, + description, + accepts: [accepts], + })) + + return { + version: 1, + protocol: 'x402', + resourceTemplates, + networks: [STELLAR_NETWORK], + assets: [USDC_CONTRACT], + schemes: ['exact'], + priceDiscoveryUrl: new URL(X402_DISCOVERY_PATH, origin).toString(), + } +} + +export function requestOrigin(req: { headers?: Record }): string { + const headers = req.headers || {} + const forwardedProto = headers['x-forwarded-proto'] + const forwardedHost = headers['x-forwarded-host'] || headers.host + const protocol = Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto + const host = Array.isArray(forwardedHost) ? forwardedHost[0] : forwardedHost + return process.env.PUBLIC_BASE_URL || `${protocol || 'http'}://${host || 'localhost:3001'}` +} + +export function isX402DiscoveryMetadata(value: unknown): value is X402DiscoveryMetadata { + if (!value || typeof value !== 'object') return false + const metadata = value as Partial + return metadata.version === 1 + && metadata.protocol === 'x402' + && Array.isArray(metadata.resourceTemplates) + && metadata.resourceTemplates.length > 0 + && Array.isArray(metadata.networks) + && Array.isArray(metadata.assets) + && Array.isArray(metadata.schemes) + && typeof metadata.priceDiscoveryUrl === 'string' +} diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..159d83d --- /dev/null +++ b/vercel.json @@ -0,0 +1,8 @@ +{ + "rewrites": [ + { + "source": "/.well-known/x402", + "destination": "/api/well-known/x402" + } + ] +}