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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Aggregates price data from Stellar's Classic Order Book (SDEX) and AMM Liquidity
| GET | `/pools` | Active AMM pools being watched |
| GET | `/pairs` | Watched trading pairs |
| GET | `/status` | Indexer health |
| GET | `/discovery/resources?type=&payTo=&network=&extensions=&limit=&offset=` | Bazaar catalog of x402-discoverable resources (spec: [`bazaar`](https://github.com/x402-foundation/x402/blob/main/specs/extensions/bazaar.md)) |

### GraphQL
Available at `/graphql` with GraphiQL IDE at `/graphiql`.
Expand Down
92 changes: 92 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,98 @@ model PairConfig {
@@map("pair_configs")
}

/// A single x402-discoverable resource in the Bazaar catalog — either an
/// HTTP endpoint or an MCP tool, per the x402 `bazaar` extension
/// (specs/extensions/bazaar.md in x402-foundation/x402).
///
/// HTTP and MCP resources share one table (discriminated by `type`) rather
/// than two, because a discovery listing is fundamentally "a resource with
/// payment requirements and a bazaar.info blob" regardless of transport —
/// splitting them would require the discovery query to UNION two tables on
/// every filter combination for no benefit, since the two types are never
/// looked up via different access patterns.
model BazaarResource {
id String @id @default(uuid())

/// "http" | "mcp" — discriminates which of the two input shapes below applies.
type String

/// Which Stellar network this listing settles on ("mainnet" | "testnet").
/// Mirrors config.ts's NetworkName so a listing is never ambiguous about
/// which network's payTo/asset it refers to.
network String

/// The protected resource URL (`resource.url` in the spec). For MCP this is
/// the MCP server endpoint, not the tool itself — the tool is disambiguated
/// by `mcpToolName` below.
url String

/// `resource.description` — human-readable description of the resource.
description String?

/// `resource.mimeType`.
mimeType String? @map("mime_type")

/// Optional service metadata the spec allows on `resource`.
serviceName String? @map("service_name")
tags String[] @default([])
iconUrl String? @map("icon_url")

/// MCP tool identifier (`input.toolName`). Null for HTTP resources.
/// Per the spec, MCP resources are keyed on the TUPLE of (resource.url,
/// input.toolName) since multiple tools multiplex over one server endpoint.
/// We additionally scope that tuple by `network` (see @@unique below) —
/// a deliberate deviation, called out in the PR: since Lens is
/// dual-network, the same (url, toolName) pair can legitimately exist
/// once per network with a different payTo/asset in `accepts`, and the
/// spec's tuple alone can't express that without collapsing them.
mcpToolName String? @map("mcp_tool_name")

/// HTTP method for HTTP resources (GET/POST/...). Null for MCP resources.
httpMethod String? @map("http_method")

/// Full `accepts[]` payment requirements array (scheme/network/amount/asset/
/// payTo/maxTimeoutSeconds/extra), stored verbatim so the discovery response
/// can round-trip the exact PaymentRequirements the resource advertised.
accepts Json

/// The `payTo` address extracted from accepts[0] for indexed filtering.
/// Denormalized on write because Postgres cannot efficiently index into a
/// JSON array element without a functional/GIN index per accepted scheme,
/// and payTo is the one field the spec calls out as a top-level filter.
payTo String @map("pay_to")

/// `extensions.bazaar.info` — discovery metadata (input type, params, output).
bazaarInfo Json @map("bazaar_info")

/// `extensions.bazaar.schema` — JSON Schema validating `bazaarInfo`.
bazaarSchema Json @map("bazaar_schema")

/// `extensions.bazaar.routeTemplate` — canonical `:param` pattern for
/// dynamic HTTP routes, used by the facilitator to consolidate listings.
routeTemplate String? @map("route_template")

/// Any other declared extension keys beyond "bazaar" (spec's `extensions`
/// filter matches on presence of a key here, "bazaar" always included).
extensionKeys String[] @default(["bazaar"]) @map("extension_keys")

createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")

// HTTP resources are keyed on (network, url, httpMethod); MCP resources are
// keyed on (network, url, mcpToolName) per the spec's tuple. Postgres
// treats NULLs as distinct in a unique index, so these two constraints
// don't collide with each other for a row that only populates one side.
@@unique([network, url, httpMethod], name: "bazaarHttpIdentity", map: "bazaar_http_identity")
@@unique([network, url, mcpToolName], name: "bazaarMcpIdentity", map: "bazaar_mcp_identity")
// Covers the six spec filters (type, payTo, network, extensions via
// extensionKeys, plus limit/offset) and keeps pagination stable — see
// routes/discovery.ts, which always orders by (createdAt, id).
@@index([network, type, payTo, createdAt(sort: Desc), id])
@@index([extensionKeys], type: Gin)
@@map("bazaar_resources")
}

model Webhook {
id String @id @default(uuid())
url String
Expand Down
248 changes: 248 additions & 0 deletions src/__tests__/bazaarCatalog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'

const { mockFindMany, mockCount, mockUpsert, mockDeleteMany } = vi.hoisted(() => ({
mockFindMany: vi.fn(),
mockCount: vi.fn(),
mockUpsert: vi.fn(),
mockDeleteMany: vi.fn(),
}))

vi.mock('../db', () => ({
prisma: {
bazaarResource: {
findMany: mockFindMany,
count: mockCount,
upsert: mockUpsert,
deleteMany: mockDeleteMany,
},
},
}))

import {
parseDiscoveryFilters,
queryDiscoveryResources,
registerBazaarResource,
} from '../bazaar/catalog'
import type { RegisterBazaarResourceInput } from '../bazaar/types'

beforeEach(() => {
mockFindMany.mockReset().mockResolvedValue([])
mockCount.mockReset().mockResolvedValue(0)
mockUpsert.mockReset().mockResolvedValue({})
mockDeleteMany.mockReset().mockResolvedValue({ count: 0 })
})

describe('parseDiscoveryFilters', () => {
it('defaults limit to 50 and offset to 0', () => {
const filters = parseDiscoveryFilters({})
expect(filters.limit).toBe(50)
expect(filters.offset).toBe(0)
})

it('clamps limit to a maximum of 200', () => {
const filters = parseDiscoveryFilters({ limit: '10000' })
expect(filters.limit).toBe(200)
})

it('rejects a negative or zero limit, falling back to the default', () => {
expect(parseDiscoveryFilters({ limit: '-5' }).limit).toBe(50)
expect(parseDiscoveryFilters({ limit: '0' }).limit).toBe(50)
})

it('rejects a negative offset, falling back to 0', () => {
expect(parseDiscoveryFilters({ offset: '-10' }).offset).toBe(0)
})

it('passes through a valid offset', () => {
expect(parseDiscoveryFilters({ offset: '25' }).offset).toBe(25)
})

it('only accepts "http" or "mcp" for type, dropping anything else', () => {
expect(parseDiscoveryFilters({ type: 'http' }).type).toBe('http')
expect(parseDiscoveryFilters({ type: 'mcp' }).type).toBe('mcp')
expect(parseDiscoveryFilters({ type: 'websocket' }).type).toBeUndefined()
})

it('passes through payTo, network, and extensions filters', () => {
const filters = parseDiscoveryFilters({
payTo: 'GABC',
network: 'stellar:pubnet',
extensions: 'bazaar',
})
expect(filters.payTo).toBe('GABC')
expect(filters.network).toBe('stellar:pubnet')
expect(filters.extensions).toBe('bazaar')
})
})

describe('queryDiscoveryResources', () => {
it('filters by type', async () => {
await queryDiscoveryResources({ type: 'mcp', limit: 50, offset: 0 })
expect(mockFindMany).toHaveBeenCalledWith(
expect.objectContaining({ where: expect.objectContaining({ type: 'mcp' }) })
)
})

it('filters by payTo', async () => {
await queryDiscoveryResources({ payTo: 'GPAY', limit: 50, offset: 0 })
expect(mockFindMany).toHaveBeenCalledWith(
expect.objectContaining({ where: expect.objectContaining({ payTo: 'GPAY' }) })
)
})

it('resolves a CAIP-2 network filter to the internal NetworkName', async () => {
await queryDiscoveryResources({ network: 'stellar:pubnet', limit: 50, offset: 0 })
expect(mockFindMany).toHaveBeenCalledWith(
expect.objectContaining({ where: expect.objectContaining({ network: 'mainnet' }) })
)
})

it('resolves stellar:testnet to testnet', async () => {
await queryDiscoveryResources({ network: 'stellar:testnet', limit: 50, offset: 0 })
expect(mockFindMany).toHaveBeenCalledWith(
expect.objectContaining({ where: expect.objectContaining({ network: 'testnet' }) })
)
})

it('passes through an unrecognized network filter verbatim', async () => {
await queryDiscoveryResources({ network: 'eip155:8453', limit: 50, offset: 0 })
expect(mockFindMany).toHaveBeenCalledWith(
expect.objectContaining({ where: expect.objectContaining({ network: 'eip155:8453' }) })
)
})

it('filters by extension key presence', async () => {
await queryDiscoveryResources({ extensions: 'bazaar', limit: 50, offset: 0 })
expect(mockFindMany).toHaveBeenCalledWith(
expect.objectContaining({ where: expect.objectContaining({ extensionKeys: { has: 'bazaar' } }) })
)
})

it('applies limit and offset for pagination', async () => {
await queryDiscoveryResources({ limit: 10, offset: 20 })
expect(mockFindMany).toHaveBeenCalledWith(
expect.objectContaining({ take: 10, skip: 20 })
)
})

it('orders by createdAt desc with id as a stable tiebreaker', async () => {
await queryDiscoveryResources({ limit: 50, offset: 0 })
expect(mockFindMany).toHaveBeenCalledWith(
expect.objectContaining({ orderBy: [{ createdAt: 'desc' }, { id: 'desc' }] })
)
})

it('returns total count alongside the page of resources', async () => {
mockCount.mockResolvedValue(137)
const result = await queryDiscoveryResources({ limit: 50, offset: 0 })
expect(result.total).toBe(137)
})

it('maps a stored row back into the spec resource/accepts/extensions shape', async () => {
mockFindMany.mockResolvedValue([
{
url: 'https://lens.example/price',
description: 'Unified price feed',
mimeType: 'application/json',
serviceName: 'Lens',
tags: ['price', 'stellar'],
iconUrl: 'https://lens.example/icon.png',
accepts: [{ scheme: 'exact', network: 'stellar:pubnet', amount: '100000', asset: 'USDC', payTo: 'GPAY', maxTimeoutSeconds: 60 }],
bazaarInfo: { input: { type: 'http', method: 'GET' } },
bazaarSchema: { type: 'object' },
routeTemplate: null,
extensionKeys: ['bazaar'],
},
])

const result = await queryDiscoveryResources({ limit: 50, offset: 0 })
expect(result.resources).toHaveLength(1)
const listing = result.resources[0]
expect(listing.resource.url).toBe('https://lens.example/price')
expect(listing.resource.serviceName).toBe('Lens')
expect(listing.accepts[0].payTo).toBe('GPAY')
expect(listing.extensions.bazaar.info).toEqual({ input: { type: 'http', method: 'GET' } })
expect(listing.extensions.bazaar).not.toHaveProperty('routeTemplate')
})

it('includes routeTemplate when present', async () => {
mockFindMany.mockResolvedValue([
{
url: 'https://lens.example/users/123',
description: null,
mimeType: null,
serviceName: null,
tags: [],
iconUrl: null,
accepts: [],
bazaarInfo: { input: { type: 'http', method: 'GET' } },
bazaarSchema: {},
routeTemplate: '/users/:userId',
extensionKeys: ['bazaar'],
},
])

const result = await queryDiscoveryResources({ limit: 50, offset: 0 })
expect(result.resources[0].extensions.bazaar.routeTemplate).toBe('/users/:userId')
})
})

describe('registerBazaarResource', () => {
const httpInput: RegisterBazaarResourceInput = {
type: 'http',
network: 'mainnet',
resource: { url: 'https://lens.example/price' },
accepts: [{ scheme: 'exact', network: 'stellar:pubnet', amount: '100000', asset: 'USDC', payTo: 'GPAY', maxTimeoutSeconds: 60 }],
bazaar: { info: { input: { type: 'http', method: 'GET' } }, schema: { type: 'object' } },
}

const mcpInput: RegisterBazaarResourceInput = {
type: 'mcp',
network: 'testnet',
resource: { url: 'https://lens.example/mcp' },
accepts: [{ scheme: 'exact', network: 'stellar:testnet', amount: '100000', asset: 'USDC', payTo: 'GPAY2', maxTimeoutSeconds: 60 }],
bazaar: {
info: { input: { type: 'mcp', toolName: 'financial_analysis', inputSchema: { type: 'object' } } },
schema: { type: 'object' },
},
}

it('upserts an HTTP resource keyed on (network, url, httpMethod)', async () => {
await registerBazaarResource(httpInput)
expect(mockUpsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { bazaarHttpIdentity: { network: 'mainnet', url: 'https://lens.example/price', httpMethod: 'GET' } },
})
)
})

it('upserts an MCP resource keyed on (network, resource.url, input.toolName)', async () => {
await registerBazaarResource(mcpInput)
expect(mockUpsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { bazaarMcpIdentity: { network: 'testnet', url: 'https://lens.example/mcp', mcpToolName: 'financial_analysis' } },
})
)
})

it('rejects registration when accepts[] is empty', async () => {
await expect(
registerBazaarResource({ ...httpInput, accepts: [] })
).rejects.toThrow(/payTo/)
expect(mockUpsert).not.toHaveBeenCalled()
})

it('denormalizes payTo from accepts[0] onto the row', async () => {
await registerBazaarResource(httpInput)
expect(mockUpsert).toHaveBeenCalledWith(
expect.objectContaining({ create: expect.objectContaining({ payTo: 'GPAY' }) })
)
})

it('always includes "bazaar" in extensionKeys by default', async () => {
await registerBazaarResource(httpInput)
expect(mockUpsert).toHaveBeenCalledWith(
expect.objectContaining({ create: expect.objectContaining({ extensionKeys: ['bazaar'] }) })
)
})
})
Loading