Skip to content

Bazaar: GET /discovery/resources with the spec's filters - #144

Merged
Miracle656 merged 1 commit into
Miracle656:mainfrom
Elizabethxxx:feat/bazaar-discovery-resources-128
Aug 30, 2026
Merged

Bazaar: GET /discovery/resources with the spec's filters#144
Miracle656 merged 1 commit into
Miracle656:mainfrom
Elizabethxxx:feat/bazaar-discovery-resources-128

Conversation

@Elizabethxxx

Copy link
Copy Markdown
Contributor

Summary

Implements the catalog-browsing half of the x402 bazaar extension (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_resources model (prisma/schema.prisma) that holds both HTTP endpoints and MCP tools, discriminated by type:

  • HTTP resources are identified by (network, url, httpMethod).
  • MCP resources are identified by (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 by network in 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 (different payTo/asset) on mainnet vs. testnet — the spec's tuple alone can't express that without collapsing the two into one row.
  • Each row stores accepts (the full payment requirements array) and extensions.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.
  • network records 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.
  • One table rather than two per resource type, since a discovery listing is the same shape ("resource + payment requirements + bazaar.info") regardless of transport, and splitting them would force the discovery query to UNION two tables on every filter combination for no benefit.

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 from accepts[0].payTo since Postgres can't efficiently index into a JSON array element)
  • network — accepts either our internal NetworkName ("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 correctly
  • extensions — 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 0

Response shape: { resources: [...], limit, offset, total }, where each item is { resource, accepts, extensions: { bazaar: { info, schema, routeTemplate? } } } — the same resource/accepts/extensions.bazaar structure used in a 402 PaymentRequired response. 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 the resource/extensions.bazaar object fields individually, but not a concrete GET /discovery/resources response 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/search is 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/search being 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 than createdAt desc alone. id is the primary key and strictly unique, so it's a stable tiebreaker — a plain createdAt-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 same createdAt as an existing boundary row. Covered by a test that exercises limit/offset directly 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:

  • Matches: the per-resource shape (resource/accepts/extensions.bazaar), the type/payTo/network/extensions/limit/offset filter 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 its network id and asset value in accepts.
  • Deviates: the MCP identity tuple is scoped by network in 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

  • Nothing in Lens registers its own gated routes (/price, /pools, /candles) into this catalog yet — that would mean adding extensions.bazaar declarations to those routes' 402 responses and calling registerBazaarResource() (exported from src/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.
  • The on-chain Soroban registry stretch goal is intentionally not implemented, per the 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 the routeTemplate optional field), and registerBazaarResource (HTTP and MCP upsert keys, payTo denormalization, rejection when accepts is 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 --noEmit passes
  • npx prisma validate / npx prisma generate succeed against the new schema
  • npx vitest run src/__tests__/bazaarCatalog.test.ts src/__tests__/discovery.test.ts — 31/31 pass
  • npx vitest run — no regressions (208 pre-existing tests still pass; two pre-existing failures in bestRoute.test.ts / aggregator.property.test.ts are unrelated stale-mock issues that also fail on main before this change)
  • Maintainer review of the filter-AND-semantics and response-shape assumptions called out above, since the spec does not pin them down explicitly

closes #128

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
@drips-wave

drips-wave Bot commented Aug 29, 2026

Copy link
Copy Markdown

@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! 🚀

Learn more about application limits

@Miracle656 Miracle656 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.ts for the un-gated-discovery reasoning, which is #143's file. That file may not exist depending on how the #142/#143 /supported collision 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.

@Miracle656
Miracle656 merged commit 0dca2cb into Miracle656:main Aug 30, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bazaar: GET /discovery/resources with the spec's filters

2 participants