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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand Down
1 change: 1 addition & 0 deletions api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export default function handler(req: VercelRequest, res: VercelResponse) {
'GET /api/search?q=<query>': '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',
},
})
}
37 changes: 37 additions & 0 deletions api/well-known/x402.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
15 changes: 15 additions & 0 deletions api/well-known/x402.ts
Original file line number Diff line number Diff line change
@@ -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) }))
}
7 changes: 7 additions & 0 deletions server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
50 changes: 50 additions & 0 deletions src/lib/x402Discovery.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
101 changes: 101 additions & 0 deletions src/lib/x402Discovery.ts
Original file line number Diff line number Diff line change
@@ -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, string | string[] | undefined> }): 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<X402DiscoveryMetadata>
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'
}
8 changes: 8 additions & 0 deletions vercel.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"rewrites": [
{
"source": "/.well-known/x402",
"destination": "/api/well-known/x402"
}
]
}