Skip to content

Commit 0dca2cb

Browse files
authored
Merge pull request #144 from Elizabethxxx/feat/bazaar-discovery-resources-128
Bazaar: GET /discovery/resources with the spec's filters
2 parents 5a31014 + 7222f3a commit 0dca2cb

8 files changed

Lines changed: 744 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Aggregates price data from Stellar's Classic Order Book (SDEX) and AMM Liquidity
2121
| GET | `/pools` | Active AMM pools being watched |
2222
| GET | `/pairs` | Watched trading pairs |
2323
| GET | `/status` | Indexer health |
24+
| 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)) |
2425

2526
Every route accepts an optional `?network=testnet\|mainnet` query param (or
2627
`x-network` header) to pick the Stellar network — default is `testnet`. An

prisma/schema.prisma

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,98 @@ model PairConfig {
9999
@@map("pair_configs")
100100
}
101101

102+
/// A single x402-discoverable resource in the Bazaar catalog — either an
103+
/// HTTP endpoint or an MCP tool, per the x402 `bazaar` extension
104+
/// (specs/extensions/bazaar.md in x402-foundation/x402).
105+
///
106+
/// HTTP and MCP resources share one table (discriminated by `type`) rather
107+
/// than two, because a discovery listing is fundamentally "a resource with
108+
/// payment requirements and a bazaar.info blob" regardless of transport —
109+
/// splitting them would require the discovery query to UNION two tables on
110+
/// every filter combination for no benefit, since the two types are never
111+
/// looked up via different access patterns.
112+
model BazaarResource {
113+
id String @id @default(uuid())
114+
115+
/// "http" | "mcp" — discriminates which of the two input shapes below applies.
116+
type String
117+
118+
/// Which Stellar network this listing settles on ("mainnet" | "testnet").
119+
/// Mirrors config.ts's NetworkName so a listing is never ambiguous about
120+
/// which network's payTo/asset it refers to.
121+
network String
122+
123+
/// The protected resource URL (`resource.url` in the spec). For MCP this is
124+
/// the MCP server endpoint, not the tool itself — the tool is disambiguated
125+
/// by `mcpToolName` below.
126+
url String
127+
128+
/// `resource.description` — human-readable description of the resource.
129+
description String?
130+
131+
/// `resource.mimeType`.
132+
mimeType String? @map("mime_type")
133+
134+
/// Optional service metadata the spec allows on `resource`.
135+
serviceName String? @map("service_name")
136+
tags String[] @default([])
137+
iconUrl String? @map("icon_url")
138+
139+
/// MCP tool identifier (`input.toolName`). Null for HTTP resources.
140+
/// Per the spec, MCP resources are keyed on the TUPLE of (resource.url,
141+
/// input.toolName) since multiple tools multiplex over one server endpoint.
142+
/// We additionally scope that tuple by `network` (see @@unique below) —
143+
/// a deliberate deviation, called out in the PR: since Lens is
144+
/// dual-network, the same (url, toolName) pair can legitimately exist
145+
/// once per network with a different payTo/asset in `accepts`, and the
146+
/// spec's tuple alone can't express that without collapsing them.
147+
mcpToolName String? @map("mcp_tool_name")
148+
149+
/// HTTP method for HTTP resources (GET/POST/...). Null for MCP resources.
150+
httpMethod String? @map("http_method")
151+
152+
/// Full `accepts[]` payment requirements array (scheme/network/amount/asset/
153+
/// payTo/maxTimeoutSeconds/extra), stored verbatim so the discovery response
154+
/// can round-trip the exact PaymentRequirements the resource advertised.
155+
accepts Json
156+
157+
/// The `payTo` address extracted from accepts[0] for indexed filtering.
158+
/// Denormalized on write because Postgres cannot efficiently index into a
159+
/// JSON array element without a functional/GIN index per accepted scheme,
160+
/// and payTo is the one field the spec calls out as a top-level filter.
161+
payTo String @map("pay_to")
162+
163+
/// `extensions.bazaar.info` — discovery metadata (input type, params, output).
164+
bazaarInfo Json @map("bazaar_info")
165+
166+
/// `extensions.bazaar.schema` — JSON Schema validating `bazaarInfo`.
167+
bazaarSchema Json @map("bazaar_schema")
168+
169+
/// `extensions.bazaar.routeTemplate` — canonical `:param` pattern for
170+
/// dynamic HTTP routes, used by the facilitator to consolidate listings.
171+
routeTemplate String? @map("route_template")
172+
173+
/// Any other declared extension keys beyond "bazaar" (spec's `extensions`
174+
/// filter matches on presence of a key here, "bazaar" always included).
175+
extensionKeys String[] @default(["bazaar"]) @map("extension_keys")
176+
177+
createdAt DateTime @default(now()) @map("created_at")
178+
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
179+
180+
// HTTP resources are keyed on (network, url, httpMethod); MCP resources are
181+
// keyed on (network, url, mcpToolName) per the spec's tuple. Postgres
182+
// treats NULLs as distinct in a unique index, so these two constraints
183+
// don't collide with each other for a row that only populates one side.
184+
@@unique([network, url, httpMethod], name: "bazaarHttpIdentity", map: "bazaar_http_identity")
185+
@@unique([network, url, mcpToolName], name: "bazaarMcpIdentity", map: "bazaar_mcp_identity")
186+
// Covers the six spec filters (type, payTo, network, extensions via
187+
// extensionKeys, plus limit/offset) and keeps pagination stable — see
188+
// routes/discovery.ts, which always orders by (createdAt, id).
189+
@@index([network, type, payTo, createdAt(sort: Desc), id])
190+
@@index([extensionKeys], type: Gin)
191+
@@map("bazaar_resources")
192+
}
193+
102194
model Webhook {
103195
id String @id @default(uuid())
104196
url String
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest'
2+
3+
const { mockFindMany, mockCount, mockUpsert, mockDeleteMany } = vi.hoisted(() => ({
4+
mockFindMany: vi.fn(),
5+
mockCount: vi.fn(),
6+
mockUpsert: vi.fn(),
7+
mockDeleteMany: vi.fn(),
8+
}))
9+
10+
vi.mock('../db', () => ({
11+
prisma: {
12+
bazaarResource: {
13+
findMany: mockFindMany,
14+
count: mockCount,
15+
upsert: mockUpsert,
16+
deleteMany: mockDeleteMany,
17+
},
18+
},
19+
}))
20+
21+
import {
22+
parseDiscoveryFilters,
23+
queryDiscoveryResources,
24+
registerBazaarResource,
25+
} from '../bazaar/catalog'
26+
import type { RegisterBazaarResourceInput } from '../bazaar/types'
27+
28+
beforeEach(() => {
29+
mockFindMany.mockReset().mockResolvedValue([])
30+
mockCount.mockReset().mockResolvedValue(0)
31+
mockUpsert.mockReset().mockResolvedValue({})
32+
mockDeleteMany.mockReset().mockResolvedValue({ count: 0 })
33+
})
34+
35+
describe('parseDiscoveryFilters', () => {
36+
it('defaults limit to 50 and offset to 0', () => {
37+
const filters = parseDiscoveryFilters({})
38+
expect(filters.limit).toBe(50)
39+
expect(filters.offset).toBe(0)
40+
})
41+
42+
it('clamps limit to a maximum of 200', () => {
43+
const filters = parseDiscoveryFilters({ limit: '10000' })
44+
expect(filters.limit).toBe(200)
45+
})
46+
47+
it('rejects a negative or zero limit, falling back to the default', () => {
48+
expect(parseDiscoveryFilters({ limit: '-5' }).limit).toBe(50)
49+
expect(parseDiscoveryFilters({ limit: '0' }).limit).toBe(50)
50+
})
51+
52+
it('rejects a negative offset, falling back to 0', () => {
53+
expect(parseDiscoveryFilters({ offset: '-10' }).offset).toBe(0)
54+
})
55+
56+
it('passes through a valid offset', () => {
57+
expect(parseDiscoveryFilters({ offset: '25' }).offset).toBe(25)
58+
})
59+
60+
it('only accepts "http" or "mcp" for type, dropping anything else', () => {
61+
expect(parseDiscoveryFilters({ type: 'http' }).type).toBe('http')
62+
expect(parseDiscoveryFilters({ type: 'mcp' }).type).toBe('mcp')
63+
expect(parseDiscoveryFilters({ type: 'websocket' }).type).toBeUndefined()
64+
})
65+
66+
it('passes through payTo, network, and extensions filters', () => {
67+
const filters = parseDiscoveryFilters({
68+
payTo: 'GABC',
69+
network: 'stellar:pubnet',
70+
extensions: 'bazaar',
71+
})
72+
expect(filters.payTo).toBe('GABC')
73+
expect(filters.network).toBe('stellar:pubnet')
74+
expect(filters.extensions).toBe('bazaar')
75+
})
76+
})
77+
78+
describe('queryDiscoveryResources', () => {
79+
it('filters by type', async () => {
80+
await queryDiscoveryResources({ type: 'mcp', limit: 50, offset: 0 })
81+
expect(mockFindMany).toHaveBeenCalledWith(
82+
expect.objectContaining({ where: expect.objectContaining({ type: 'mcp' }) })
83+
)
84+
})
85+
86+
it('filters by payTo', async () => {
87+
await queryDiscoveryResources({ payTo: 'GPAY', limit: 50, offset: 0 })
88+
expect(mockFindMany).toHaveBeenCalledWith(
89+
expect.objectContaining({ where: expect.objectContaining({ payTo: 'GPAY' }) })
90+
)
91+
})
92+
93+
it('resolves a CAIP-2 network filter to the internal NetworkName', async () => {
94+
await queryDiscoveryResources({ network: 'stellar:pubnet', limit: 50, offset: 0 })
95+
expect(mockFindMany).toHaveBeenCalledWith(
96+
expect.objectContaining({ where: expect.objectContaining({ network: 'mainnet' }) })
97+
)
98+
})
99+
100+
it('resolves stellar:testnet to testnet', async () => {
101+
await queryDiscoveryResources({ network: 'stellar:testnet', limit: 50, offset: 0 })
102+
expect(mockFindMany).toHaveBeenCalledWith(
103+
expect.objectContaining({ where: expect.objectContaining({ network: 'testnet' }) })
104+
)
105+
})
106+
107+
it('passes through an unrecognized network filter verbatim', async () => {
108+
await queryDiscoveryResources({ network: 'eip155:8453', limit: 50, offset: 0 })
109+
expect(mockFindMany).toHaveBeenCalledWith(
110+
expect.objectContaining({ where: expect.objectContaining({ network: 'eip155:8453' }) })
111+
)
112+
})
113+
114+
it('filters by extension key presence', async () => {
115+
await queryDiscoveryResources({ extensions: 'bazaar', limit: 50, offset: 0 })
116+
expect(mockFindMany).toHaveBeenCalledWith(
117+
expect.objectContaining({ where: expect.objectContaining({ extensionKeys: { has: 'bazaar' } }) })
118+
)
119+
})
120+
121+
it('applies limit and offset for pagination', async () => {
122+
await queryDiscoveryResources({ limit: 10, offset: 20 })
123+
expect(mockFindMany).toHaveBeenCalledWith(
124+
expect.objectContaining({ take: 10, skip: 20 })
125+
)
126+
})
127+
128+
it('orders by createdAt desc with id as a stable tiebreaker', async () => {
129+
await queryDiscoveryResources({ limit: 50, offset: 0 })
130+
expect(mockFindMany).toHaveBeenCalledWith(
131+
expect.objectContaining({ orderBy: [{ createdAt: 'desc' }, { id: 'desc' }] })
132+
)
133+
})
134+
135+
it('returns total count alongside the page of resources', async () => {
136+
mockCount.mockResolvedValue(137)
137+
const result = await queryDiscoveryResources({ limit: 50, offset: 0 })
138+
expect(result.total).toBe(137)
139+
})
140+
141+
it('maps a stored row back into the spec resource/accepts/extensions shape', async () => {
142+
mockFindMany.mockResolvedValue([
143+
{
144+
url: 'https://lens.example/price',
145+
description: 'Unified price feed',
146+
mimeType: 'application/json',
147+
serviceName: 'Lens',
148+
tags: ['price', 'stellar'],
149+
iconUrl: 'https://lens.example/icon.png',
150+
accepts: [{ scheme: 'exact', network: 'stellar:pubnet', amount: '100000', asset: 'USDC', payTo: 'GPAY', maxTimeoutSeconds: 60 }],
151+
bazaarInfo: { input: { type: 'http', method: 'GET' } },
152+
bazaarSchema: { type: 'object' },
153+
routeTemplate: null,
154+
extensionKeys: ['bazaar'],
155+
},
156+
])
157+
158+
const result = await queryDiscoveryResources({ limit: 50, offset: 0 })
159+
expect(result.resources).toHaveLength(1)
160+
const listing = result.resources[0]
161+
expect(listing.resource.url).toBe('https://lens.example/price')
162+
expect(listing.resource.serviceName).toBe('Lens')
163+
expect(listing.accepts[0].payTo).toBe('GPAY')
164+
expect(listing.extensions.bazaar.info).toEqual({ input: { type: 'http', method: 'GET' } })
165+
expect(listing.extensions.bazaar).not.toHaveProperty('routeTemplate')
166+
})
167+
168+
it('includes routeTemplate when present', async () => {
169+
mockFindMany.mockResolvedValue([
170+
{
171+
url: 'https://lens.example/users/123',
172+
description: null,
173+
mimeType: null,
174+
serviceName: null,
175+
tags: [],
176+
iconUrl: null,
177+
accepts: [],
178+
bazaarInfo: { input: { type: 'http', method: 'GET' } },
179+
bazaarSchema: {},
180+
routeTemplate: '/users/:userId',
181+
extensionKeys: ['bazaar'],
182+
},
183+
])
184+
185+
const result = await queryDiscoveryResources({ limit: 50, offset: 0 })
186+
expect(result.resources[0].extensions.bazaar.routeTemplate).toBe('/users/:userId')
187+
})
188+
})
189+
190+
describe('registerBazaarResource', () => {
191+
const httpInput: RegisterBazaarResourceInput = {
192+
type: 'http',
193+
network: 'mainnet',
194+
resource: { url: 'https://lens.example/price' },
195+
accepts: [{ scheme: 'exact', network: 'stellar:pubnet', amount: '100000', asset: 'USDC', payTo: 'GPAY', maxTimeoutSeconds: 60 }],
196+
bazaar: { info: { input: { type: 'http', method: 'GET' } }, schema: { type: 'object' } },
197+
}
198+
199+
const mcpInput: RegisterBazaarResourceInput = {
200+
type: 'mcp',
201+
network: 'testnet',
202+
resource: { url: 'https://lens.example/mcp' },
203+
accepts: [{ scheme: 'exact', network: 'stellar:testnet', amount: '100000', asset: 'USDC', payTo: 'GPAY2', maxTimeoutSeconds: 60 }],
204+
bazaar: {
205+
info: { input: { type: 'mcp', toolName: 'financial_analysis', inputSchema: { type: 'object' } } },
206+
schema: { type: 'object' },
207+
},
208+
}
209+
210+
it('upserts an HTTP resource keyed on (network, url, httpMethod)', async () => {
211+
await registerBazaarResource(httpInput)
212+
expect(mockUpsert).toHaveBeenCalledWith(
213+
expect.objectContaining({
214+
where: { bazaarHttpIdentity: { network: 'mainnet', url: 'https://lens.example/price', httpMethod: 'GET' } },
215+
})
216+
)
217+
})
218+
219+
it('upserts an MCP resource keyed on (network, resource.url, input.toolName)', async () => {
220+
await registerBazaarResource(mcpInput)
221+
expect(mockUpsert).toHaveBeenCalledWith(
222+
expect.objectContaining({
223+
where: { bazaarMcpIdentity: { network: 'testnet', url: 'https://lens.example/mcp', mcpToolName: 'financial_analysis' } },
224+
})
225+
)
226+
})
227+
228+
it('rejects registration when accepts[] is empty', async () => {
229+
await expect(
230+
registerBazaarResource({ ...httpInput, accepts: [] })
231+
).rejects.toThrow(/payTo/)
232+
expect(mockUpsert).not.toHaveBeenCalled()
233+
})
234+
235+
it('denormalizes payTo from accepts[0] onto the row', async () => {
236+
await registerBazaarResource(httpInput)
237+
expect(mockUpsert).toHaveBeenCalledWith(
238+
expect.objectContaining({ create: expect.objectContaining({ payTo: 'GPAY' }) })
239+
)
240+
})
241+
242+
it('always includes "bazaar" in extensionKeys by default', async () => {
243+
await registerBazaarResource(httpInput)
244+
expect(mockUpsert).toHaveBeenCalledWith(
245+
expect.objectContaining({ create: expect.objectContaining({ extensionKeys: ['bazaar'] }) })
246+
)
247+
})
248+
})

0 commit comments

Comments
 (0)