Repository navigation
feat(web): the demo runs in the visitor's browser - #21
Merged
Merged
Conversation
x402 carries everything that matters in headers, and a browser hides those from script unless the server names them. So a page could fetch this seller, receive the 402, and still not read what it wanted paying: the response was visible and the terms were not. That is why the interface asks through a route of its own instead. Three parts, and the third is the one that gets forgotten. An origin, the methods, and `Access-Control-Expose-Headers` naming `PAYMENT-REQUIRED` and the two response names. Plus an `OPTIONS` handler, because a paid request carries `PAYMENT-SIGNATURE` and `X-PAYMENT` and a custom header makes the request preflighted rather than simple. The preflight echoes what was asked for rather than listing names. Those names belong to x402 and move with it, and a preflight that hardcodes them is one that goes stale quietly. Any origin. The terms are public, anyone can already read them with curl, and a seller that speaks x402 exists to be found and paid. Checked against a running copy: the 402 carries the headers, OPTIONS answers 204 with the requested set echoed, and a page on localhost:3000 fetched localhost:3002 and decoded payTo and the amount out of the header it could not previously see. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It ran in a serverless function behind `/api/demo/run`, guarded by a module-level `let running`. On one instance that turned the second visitor away; across instances it capped nothing, because each instance gets its own copy of the module and none of them can see the others. Vercel answers a queue by opening more doors, each with its own sign that has never heard of the others. What actually broke under load was friendbot. Every run funds a fresh owner through it, it rate limits by source address, and every function shared one egress. A hundred browsers are a hundred addresses, so the thing that made concurrency dangerous is the thing that moving it removes. The 60 second ceiling on this plan goes with it, and so does the flag, the route and the EventSource. Three things had to change for a browser to do the work. The seller now says a page may read its headers, in the commit before this one. `ensureWasm` fetched the release asset to hash it, which a browser cannot read and which the network does not need: the hash is a constant the dashboard already deploys from, so both now import it from one place. And `Buffer.from(agent.rawPublicKey())` became the raw key, which is what the dashboard has always passed. `bake` was the other caller of that route. It drives the generator directly now, through the same dependencies the page uses, so there is one implementation rather than a server one and a browser one. The import is dynamic. Most of the page's weight is stellar-sdk and a reader who never presses the button should not carry it. Verified as a run rather than as code. `npm run bake` completed eight rows in Node with both refusals. In the browser, the first click landed before hydration and rendered the baked run back at me, which is worth recording because the numbers looked right: it was the identical swap figure and transaction hash that gave it away. The real run produced contract `CBRRQC6A…XQFT` and transaction `3982e1d0…6385`, and Horizon says that transaction succeeded with source `GCS6COEV…`, the facilitator, moving USDC out of `CBRRQC6A…` and not out of any account the agent holds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The live demo ran in a serverless function behind
/api/demo/run. It runs in the visitor's browser now, and the function, its concurrency flag and the EventSource are gone.What the guard was actually doing
On an always-on server there is one process, one
running, and that line means what it says. On Vercel the route is a serverless function: each instance loads the module fresh and gets its own copy, and Vercel answers a queue by starting more instances. So it failed in both directions at once. Within one instance it turned the second visitor away from a run that is mostly idle on the network. Across instances it capped nothing, which is the only place a cap would have mattered.What broke under load was friendbot. Every run funds a fresh owner through it, it rate limits by source address, and every function shared one Vercel egress. A hundred browsers are a hundred addresses, so moving the run removes the pressure rather than managing it. The 60 second function ceiling on this plan goes with it.
What had to be true first
node:cryptoensureWasmfetched the release asset only to hash it. GitHub sends no cross-origin headers on release assets, and the hash is what a deployment needs anyway, soWASM_HASHmoved intoparams.tsandwrite.tsimports it from there instead of declaring its own.Buffer.from(agent.rawPublicKey())became the raw key, which is whatwrite.tshas always passed.npm run bakewas the route's other caller. It drives the generator directly now, through the sameliveDepsthe page uses, so there is one implementation instead of a server one and a browser one.The import is dynamic. Most of the page's weight is stellar-sdk, and a reader who never presses the button should not carry it.
Verification
npm run bakecompleted eight rows in Node, both refusals, no server.In the browser, the first attempt looked like a success and was not. The click landed before hydration, so the page simply rendered the baked run back, and the rows were right because they were a real run - the identical swap figure and transaction hash to
baked.jsonare what gave it away. Worth recording, because the screenshot would have passed.The real run produced a new contract and new hashes, and Horizon was asked directly rather than the page believed:
Both refusals appeared, the button returned to an enabled state, and no request touched an origin of ours.
32 unit tests, 27 Playwright tests,
tsc --noEmitclean.Deploying this
The seller has to ship before or with the web. Until it sends the headers, a browser can fetch it and still not read the terms, and the demo stops at the row that asks what the seller wants. They are separate Vercel projects and separate manual deploys.
🤖 Generated with Claude Code