facilitator: implement GET /supported - #143
Conversation
Lens is currently only an x402 resource server (a seller). This adds the first piece of the facilitator side of the interface: GET /supported, the metadata route every x402 client hits first to decide whether Lens can serve them. The response matches SupportedResponse from @x402/core (kinds, extensions, signers) rather than a hand-rolled shape. It advertises the exact scheme for both stellar:pubnet and stellar:testnet, driven by the existing dual-network convention used in middleware/x402.ts, and reports areFeesSponsored in each kind's extra to mirror what ExactStellarScheme.getExtra() reports from @x402/stellar. Signer addresses are optional and read from FACILITATOR_SIGNER_ADDRESSES since this route is pure capability discovery and must not require live signing keys to answer. The route is registered outside the x402 gating plugin's matched prefixes and marked config.public, so it is neither payment-gated nor API-key gated — a facilitator cannot charge for its own capability discovery. closes Miracle656#124
|
@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.
The reasoning in the doc comments here is the best of any PR in this batch — particularly the point that capability discovery must answer without live signing keys, and must not itself be gated behind payment. A client has to be able to call /supported before it has any payment method configured. That's exactly right and easy to get wrong.
Two things.
1. Direct collision with #142
@Anambraboi-1's #142 also implements GET /supported, in src/api/facilitator.ts, and both files export a function named registerFacilitatorRoutes. Same route path, same exported symbol, two different files, both registered from src/index.ts. Whichever merges second either fails to compile or silently shadows the other depending on import order — this won't resolve itself in a rebase.
Not your fault; two people picked up overlapping issues. But it needs deciding before either lands, so please sync with @Anambraboi-1.
2. This advertises networks that may not be configured — and that's the substantive difference
const kinds: SupportedKind[] = (['mainnet', 'testnet'] as const).map(network => ({
x402Version: 2, scheme: 'exact', network: STELLAR_NETWORK_IDS[network], extra,
}))Both networks are advertised unconditionally. #142 registers a scheme only when that network has a FACILITATOR_SECRET_KEY, and derives /supported from what is actually registered.
That difference matters: with no mainnet key configured, this route tells a client stellar:pubnet is supported, the client selects mainnet, and /verify then fails. The client did nothing wrong — we advertised a capability we don't have. For an RFP judged on spec compliance, a /supported that overstates is worse than one that returns fewer kinds.
Suggested resolution — take the best half of each:
- Keep #142's derivation from registered schemes, so the answer is always truthful.
- Keep your framing that the route must answer without live keys and must stay ungated — and your tests, which #142 is thinner on.
Concretely, that probably means this PR narrows to its tests plus the doc comments, applied against #142's implementation. Which is a slightly unsatisfying outcome for the work you did, so I want to be clear the analysis here is good — it's the duplication that forces a choice, not the quality.
Minor
FACILITATOR_SIGNER_ADDRESSES as a separate env var means the advertised signers can drift from the keys actually loaded. If it derives from the registered schemes instead, the two can't disagree.
Same @ts-ignore note as #142: prefer @ts-expect-error so it removes itself once the types resolve, and it's worth checking whether moduleResolution in tsconfig.json is the real fix — your comment says the @x402 packages ship ESM-only types, which is usually a resolution-mode problem rather than a genuinely missing type.
|
Following up on my review — I've made the call on the Decision: That's not a knock on the work. Your reasoning is the better-argued of the two — particularly that discovery must answer without live signing keys and must not itself be gated behind payment. A client has to call What I'd like this PR to become: your tests and doc comments, applied against #142's implementation. Concretely:
That leaves a smaller diff than you wrote, which I know is an unsatisfying outcome. To be clear about the reason: it's duplication forcing a choice, not a quality judgement. Two people picked up overlapping issues and that's on how they were scoped, not on you. If you'd rather not do the rework, say so and I'll port the tests and comments across myself with attribution to you — either is fine, I'd just rather ask than assume. One note if you do rework: |
…orted-endpoint-124 # Conflicts: # src/routes/facilitator.ts
Miracle656#142 landed GET /supported in src/api/facilitator.ts, derived from the schemes actually registered. This PR implemented the same route in src/routes/facilitator.ts from a hard-coded network list, and both files exported registerFacilitatorRoutes. Miracle656#149 has since taken that filename for POST /settle, so the collision was threefold. Kept from this PR the half that was better than what merged: the reasoning that capability discovery must answer without live signing keys and must be gated by neither x402 nor API-key auth, now a doc comment on the shipped route; and the tests, which Miracle656#142 was thin on. The 'advertises both networks' assertions are replaced by their inverse: /supported must report ONLY registered schemes. Advertising stellar:pubnet on a deployment with no mainnet key makes a client select mainnet, call /verify and fail through no fault of its own. Under-reporting is recoverable; overstating is not. Dropped FACILITATOR_SIGNER_ADDRESSES / FACILITATOR_FEES_SPONSORED from .env.example — nothing reads them now, and documenting unreachable variables is worse than documenting none. tsc clean; full suite 377 passed / 1 skipped.
Miracle656
left a comment
There was a problem hiding this comment.
Approved and merging — I did the port myself (b2d491e), since the wave has closed and the half of this PR that was best deserves to ship.
Your framing was right and it is now the comment on the route that shipped: capability discovery must answer without live signing keys, and must be gated by neither x402 nor API-key auth. A facilitator cannot charge for its own discovery endpoint, and a client has to be able to call it before it has any payment method configured. That is easy to get wrong — the natural instinct is to put every facilitator route behind the same gate — and you got it right and said why.
What changed is the source of the answer, and it is the substantive point from my earlier review. Your version built the list from a hard-coded ; the version that merged derives it from the schemes actually registered. The difference only shows up in production: on a deployment with no mainnet key, a hard-coded list tells a client is supported, the client selects mainnet, calls , and fails — having done nothing wrong. We advertised a capability we did not have. A that under-reports is recoverable; one that overstates sends clients down a path that cannot work.
So I inverted your two network assertions into their opposite, which I think is the more valuable test either way:
- — asserts is absent when only testnet is registered.
- — honest emptiness over a plausible lie.
And I kept the rest of yours as-is, because they test things #142 did not: that the route answers with no header and returns neither 401 nor 402, that the body matches the shape, and that every kind carries .
Two other things I had to resolve: had since been taken by #149 for , so the filename collision was threefold rather than twofold; and I dropped / from , because nothing reads them now and documenting an unreachable variable is worse than documenting none — someone sets it, sees no effect, and has no way to find out why.
Verified: clean, full suite 377 passed / 1 skipped.
Three people picked up overlapping facilitator issues in this wave (#142, #143, #149) and all three did good work. That is a scoping failure on my side, not a coordination failure on yours.
|
(Reposting — my review above had its code spans stripped by a shell quoting mistake on my end. Full text below.) What changed is the source of the answer, and it is the substantive point from my earlier review. Your version built the list from a hard-coded The difference only shows up in production. On a deployment with no mainnet key, a hard-coded list tells a client So I inverted your two network assertions, which I think makes them the more valuable tests either way:
I kept the rest of yours as-is, because they test things #142 did not: that the route answers with no Two other things I resolved during the port:
Verified: Three people picked up overlapping facilitator issues in this wave (#142, #143, #149) and all three did good work. That is a scoping failure on my side, not a coordination failure on yours. |
Summary
Lens is currently only an x402 resource server (a seller) — src/middleware/x402.ts delegates verify and settle to https://facilitator.stellar.org. This PR implements the first piece of the other side of that interface: GET /supported, the capability-discovery route every x402 client hits first to decide whether a facilitator can serve them.
This is metadata only — it moves no money and holds no keys.
What changed
Comparison against facilitator.stellar.org/supported
I could not reach facilitator.stellar.org from my sandbox (outbound DNS is blocked in this environment), so I was not able to diff a live response byte-for-byte. Instead I matched the shape by reading the shipped types: SupportedResponse and SupportedKind in node_modules/@x402/core/dist/cjs/mechanisms-*.d.ts, and ExactStellarScheme.getExtra()/getSigners() in node_modules/@x402/stellar/dist/cjs/exact/facilitator/index.d.ts, which is what facilitator.stellar.org itself is built on. The response this route returns is structurally identical to what x402Facilitator.getSupported() would produce for a facilitator that has ExactStellarScheme registered on both stellar:pubnet and stellar:testnet. I'd appreciate a maintainer diff against the live endpoint if one is available, and I'm happy to adjust if anything differs (e.g. exact x402Version value, key ordering, or additional extra fields the live facilitator includes).
Tests
src/tests/facilitator.test.ts covers:
Test plan
closes #124