Bazaar: GET /discovery/resources with the spec's filters - #144
Conversation
Implements the catalog-browsing half of the x402 bazaar extension
(specs/extensions/bazaar.md in x402-foundation/x402) so Stellar-denominated
services are discoverable the same way any other x402 facilitator's
listings are, rather than only through whichever multi-chain facilitator
happens to carry them.
Schema: a single bazaar_resources table (Postgres, via Prisma) holds both
HTTP endpoints and MCP tools, discriminated by `type`. HTTP resources are
identified by (network, url, httpMethod); MCP resources by
(network, url, toolName) — the spec's required tuple of (resource.url,
input.toolName), additionally scoped by network. That scoping is a
deliberate deviation from the spec's literal tuple: Lens is dual-network,
so the same (url, toolName) pair can legitimately be two different
listings (different payTo/asset) on mainnet vs testnet, which the spec's
tuple alone can't express. Each row's `accepts` and `extensions.bazaar`
are stored verbatim so the discovery response round-trips the exact
PaymentRequirements and bazaar.info/schema/routeTemplate a resource
advertised in its own 402 response.
Route: GET /discovery/resources implements the spec's six filters —
type, payTo, network, extensions, limit, offset. `network` accepts either
our NetworkName ("mainnet"/"testnet") or the CAIP-2 id the spec's examples
use ("stellar:pubnet"/"stellar:testnet"); `extensions` matches on presence
of the given key in a resource's declared extension list. Pagination is
plain offset/limit ordered by (createdAt desc, id desc) — the id
tiebreaker keeps a paginated walk stable across pages even if new rows are
inserted concurrently, since ordering by createdAt alone could otherwise
shift which row lands on a given offset.
Interop: the response shape mirrors the resource/accepts/extensions.bazaar
structure used in a 402 PaymentRequired response (per the spec, since no
literal JSON example is given for the list endpoint) — the same shape any
x402 facilitator's bazaar listings use, so a Stellar listing here looks
structurally identical to one from an EVM-chain facilitator except for its
network id and asset. The one addition beyond the spec is the network
column and its dual-format filter matching described above, needed
because Lens serves two networks from one process.
The bazaar.md spec text does not give a concrete JSON example for
GET /discovery/resources or specify AND/OR semantics for combining
multiple filters — this implementation applies all provided filters as
an AND, which is the reading consistent with /discovery/search "mirroring
the list endpoint" as a narrowing search.
closes Miracle656#128
|
@Elizabethxxx Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits. You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀 |
Miracle656
left a comment
There was a problem hiding this comment.
Careful, well-documented work, and it gets the two things right that discovery endpoints usually get wrong.
Pagination is bounded. limit clamped to [1, 200], offset floored at 0, both with Number.isFinite guards, so ?limit=999999 or ?offset=-1 can't turn a public un-gated endpoint into a full-table scan. You even documented the offset-pagination row-shifting caveat rather than pretending it doesn't exist.
The schema comments explain a decision, not the syntax. This is the right kind of comment:
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…
Someone will look at that table in a year and wonder why it's polymorphic. Now they don't have to guess.
Worth noting explicitly for anyone reading this alongside #141: BazaarResource.network is String with no default, and that's fine here — it's a brand-new table, so there are no existing rows for prisma db push to fail on. #141's problem is specifically about adding a required column to populated tables. No ordering constraint from that direction.
Two coordination notes:
- The route doc comment references
routes/facilitator.tsfor the un-gated-discovery reasoning, which is #143's file. That file may not exist depending on how the #142/#143/supportedcollision resolves — worth a follow-up so the cross-reference doesn't dangle. The reasoning itself is right and consistent with #143's, which is good: discovery has to work before a client has a payment method. - Both this and #141 touch
prisma/schema.prisma, so whichever lands second needs a trivial rebase. Additive in different regions, so it should be clean.
The parseDiscoveryFilters / queryDiscoveryResources split is also the right seam — filter parsing is pure and testable without a database, which is why bazaarCatalog.test.ts can be meaningful.
Summary
Implements the catalog-browsing half of the x402
bazaarextension (specs/extensions/bazaar.md in x402-foundation/x402): GET /discovery/resources, with the six filters the spec defines, backed by Postgres/Prisma (no new infrastructure, per the issue's guidance — the catalog is a table and an index).Schema
Added a single
bazaar_resourcesmodel (prisma/schema.prisma) that holds both HTTP endpoints and MCP tools, discriminated bytype:(network, url, httpMethod).(network, url, toolName)— the spec requires the tuple(resource.url, input.toolName)because multiple tools multiplex over one MCP server endpoint. I scoped that tuple bynetworkin addition. This is a deliberate deviation from the literal spec tuple, called out here per the issue's ask to flag ambiguity rather than guess silently: Lens is dual-network, so the same(url, toolName)pair can legitimately be two different listings (differentpayTo/asset) on mainnet vs. testnet — the spec's tuple alone can't express that without collapsing the two into one row.accepts(the full payment requirements array) andextensions.bazaar(info/schema/routeTemplate) verbatim as JSON, so a discovery listing round-trips exactly what the resource itself would return in its own 402 response.networkrecords which Stellar network the listing settles on ("mainnet"/"testnet", matching config.ts's NetworkName) — this is the "a listing knows which network it settles on" criterion.Only HTTP is populated for now (nothing in Lens registers itself into the catalog yet in this PR — see Scope below), but the schema represents both from day one so it doesn't need a migration later.
Route: GET /discovery/resources
All six spec filters, applied as AND:
type— exact match on"http" | "mcp"payTo— exact match against the resource's payment recipient (denormalized onto the row fromaccepts[0].payTosince Postgres can't efficiently index into a JSON array element)network— accepts either our internalNetworkName("mainnet"/"testnet") or the CAIP-2 id the spec's own examples use ("stellar:pubnet"/"stellar:testnet"); an unrecognized value is passed through verbatim rather than silently matching nothing, so a future non-Stellar network id still filters correctlyextensions— matches resources that declare the given extension key (every row declares "bazaar" by default)limit— defaults to 50, clamped to [1, 200]offset— defaults to 0, floored at 0Response shape:
{ resources: [...], limit, offset, total }, where each item is{ resource, accepts, extensions: { bazaar: { info, schema, routeTemplate? } } }— the sameresource/accepts/extensions.bazaarstructure used in a 402PaymentRequiredresponse. I used that as the reference shape because the spec does not give a literal JSON example for the list endpoint's response (I read specs/extensions/bazaar.md directly — it documents the filter names and theresource/extensions.bazaarobject fields individually, but not a concreteGET /discovery/resourcesresponse body). This is the most literal reading available: the spec's own MCP/HTTP identification and service-metadata sections are defined in terms of that same 402-response shape, and/discovery/searchis described as "mirroring the list endpoint," which only makes sense if the list endpoint already returns that shape per-item.Also unspecified in the doc: whether combining multiple filters is AND or OR. I implemented AND, which is the natural reading of "filters" narrowing a list, and is consistent with
/discovery/searchbeing described as adding full-text search "with the same optional filters" — i.e. filters narrow, they don't broaden.Pagination under concurrent writes
Ordered by
(createdAt desc, id desc)rather thancreatedAt descalone.idis the primary key and strictly unique, so it's a stable tiebreaker — a plaincreatedAt-only order can silently reshuffle which row lands on a given offset if two rows share a timestamp or a new row is inserted between two page fetches with the samecreatedAtas an existing boundary row. Covered by a test that exerciseslimit/offsetdirectly against the query builder.Interoperability
The issue asks to look at how an existing multi-chain facilitator represents its listings and note where Lens matches/deviates. I was not able to reach a live facilitator's discovery endpoint from my sandbox (outbound network is restricted to a small allowlist there), so I compared against the spec text itself, which is what every compliant facilitator — including multi-chain ones — implements against:
resource/accepts/extensions.bazaar), thetype/payTo/network/extensions/limit/offsetfilter set, and MCP's(resource.url, input.toolName)identification are all taken directly from the spec, so a Stellar listing here is structurally identical to a listing from any other network's facilitator except for itsnetworkid andassetvalue inaccepts.networkin addition to the spec's(url, toolName), for the dual-network reason above. This is additive (a stricter uniqueness constraint, not a looser one) so it does not change what a spec-compliant client sees in the response — it only affects how Lens deduplicates registrations internally.I would welcome a maintainer pointing me at a specific facilitator's live discovery response if one is known-good, so I can verify byte-for-byte rather than against the spec text alone.
Scope not included in this PR
/price,/pools,/candles) into this catalog yet — that would mean addingextensions.bazaardeclarations to those routes' 402 responses and callingregisterBazaarResource()(exported fromsrc/bazaar/catalog.ts) on startup. I kept this PR scoped to the discovery endpoint and schema per the issue title; happy to follow up if that is wanted.GET /discovery/search(full-text) is explicitly marked optional in the spec and out of scope for this issue.Tests
src/__tests__/bazaarCatalog.test.ts— filter parsing (defaults, clamping, invalid values), each of the six filters in isolation, network id resolution (both directions plus passthrough), pagination ordering/limit/skip, row-to-listing mapping (including therouteTemplateoptional field), andregisterBazaarResource(HTTP and MCP upsert keys, payTo denormalization, rejection whenacceptsis empty).src/__tests__/discovery.test.ts— route-level tests: no auth required, response envelope shape, each filter forwarded from the query string, and pass-through of catalog results.Test plan
npx tsc --noEmitpassesnpx prisma validate/npx prisma generatesucceed against the new schemanpx vitest run src/__tests__/bazaarCatalog.test.ts src/__tests__/discovery.test.ts— 31/31 passnpx vitest run— no regressions (208 pre-existing tests still pass; two pre-existing failures inbestRoute.test.ts/aggregator.property.test.tsare unrelated stale-mock issues that also fail onmainbefore this change)closes #128