feat(storefront): built-in browser chat widget for agent-type offers#752
Open
bussyjd wants to merge 15 commits into
Open
feat(storefront): built-in browser chat widget for agent-type offers#752bussyjd wants to merge 15 commits into
bussyjd wants to merge 15 commits into
Conversation
Contributor
Author
|
Two fixes pushed after live browser testing with a real wallet:
Both verified live: paid request with an |
skillCatalog* named a busybox httpd after the first file it ever held.
It now serves skill.md, services.json (the storefront's own backend via
SERVICES_URL), openapi.json, the API docs, and one bundle per
hostname-bound offer -- including every offer landing page. "Skill
catalog" described one of those.
Two concepts were sharing the name, so this splits them:
skillCatalog* -> staticSite* (the httpd + its ConfigMap)
buildSkillCatalogMarkdown -> buildSkillMarkdown (builds skill.md)
skillCatalog{HowToPay,TryIt,RouteLines} -> skillMarkdown* (its sections)
catalogMu -> staticSiteMu (guards the static-site reconcile)
Identifiers only. Every k8s wire name is untouched -- obol-skill-md,
obol-skill-md-route, obol-catalog-headers and namespace x402 are all
byte-identical, verified by diffing the string literals removed against
those re-added (net zero). Renaming the objects would be a live-cluster
migration and they are referenced from internal/tunnel, cmd/obol,
internal/stackbackup, the embedded x402.yaml and next.config.ts; a
comment at the const block now explains the deliberate mismatch.
ServiceCatalog* (the /api/services.json wire types) keeps its name -- that
one is genuinely a catalog.
Claude-Session: https://claude.ai/code/session_014YjPMViNrZ7zBVgUQzwEKk
…emplate The landing page was a backtick literal buried mid-offerbundle.go while all three x402 pages (payment_required, siwx_challenge, error_page) follow one convention: templates/<name>.html + //go:embed + <name>HTMLSrc + <name>Tmpl. It is the surface users actually hit and it was the only one you could not open as HTML. Adopts the existing x402 convention rather than inventing a second one: internal/serviceoffercontroller/templates/offer_landing.html //go:embed templates/offer_landing.html var offerLandingHTMLSrc string Content is byte-identical to the previous literal (verified by extracting the literal from origin/main and diffing against the new file), so the rendered page is unchanged. Claude-Session: https://claude.ai/code/session_014YjPMViNrZ7zBVgUQzwEKk
… gone
DESIGN.md told the next engineer to hand-mirror the token palette across
three files and gave a drift check to enforce it. Both were stale:
payment_required.html contains zero hex values -- theming moved to
storefront.ResolveTheme(...).CSSVars() via {{.Branding.ThemeCSS}}. The
documented check diffed hex out of that template against globals.css, so
its left side was empty and it compared an empty set forever. Following
the doc would have re-introduced exactly the drift it existed to prevent.
- SS 2: describes the real source (theme.go is the single owner) and says
not to paste hex back in.
- SS 5: points at the one hand-mirror that does survive -- theme.ts's
LIGHT_THEME_VARS, a fallback that rots silently because a healthy page
takes tokens from the feed. The new check compares theme.go's ThemeLight
against it; verified it passes today (13 pairs each side) and verified it
FAILS on injected drift, which the old one could not do.
- SS 0: adds the five public surfaces and their data-obol="page-*" markers.
Nothing else in the repo lists them. Records the two things that are easy
to get backwards: page-402 renders only on a paid path with Accept:
text/html (so a root-priced offer's browser visitors get page-landing,
never page-402), and data-obol="checkout" is a mount div on two pages
rather than a name for the 402 page.
- Corrects "four surfaces" -> five.
Claude-Session: https://claude.ai/code/session_014YjPMViNrZ7zBVgUQzwEKk
Every hostname-bound offer with spec.type=agent now serves a free, self-contained chat client at /chat on its dedicated origin, embedded on the offer landing page via the reserved checkout mount. One shared static copy (catalog httpd root) serves every agent offer on any stack: the page derives the agent name from the hostname and price/model/payment network/asset from the live 402 challenge on POST /v1/chat/completions (eip155:8453 and eip155:84532 supported). Sign-in with Ethereum, without a SIWE server: the visitor connects an injected wallet, signs one fixed domain-bound message whose keccak256 deterministically derives a local session key, funds that address with a small USDC transfer, and every chat turn is then paid silently via x402 (EIP-3009, gasless for the payer, wrapFetchWithPayment). The key never leaves the page and re-signing recovers it; the page refuses challenges above a $0.05 safety cap. Delivery: chat.html + a single esbuild vendor bundle (viem 2.21.25 + @x402/fetch + @x402/evm 2.18.0 — the pair validated live against the verifier with real settlements) are embedded in the controller binary, projected into the catalog ConfigMap once, and exposed per-agent-offer via two Exact rules on the so-<name>-host route so they beat the PathPrefix payment gate exactly like the discovery paths. httpd.conf gains the .js MIME mapping ES module loading requires. Non-agent offers are unchanged (route topology pinned by tests). Refs #671. Claude-Session: https://claude.ai/code/session_014YjPMViNrZ7zBVgUQzwEKk
Intermediaries cache .js aggressively (Cloudflare's default cacheable extensions include .js), so a rebuilt vendor bundle behind the same URL can strand browsers on a stale copy whose exports no longer match chat.html. Pin the import with a content-derived ?v= query and document the bump step in the assets README. Claude-Session: https://claude.ai/code/session_014YjPMViNrZ7zBVgUQzwEKk
… chat The widget only re-read the session wallet's balance at sign-in, after its own Fund button, and after each message — funds sent from any other wallet (or arriving after sign-in) left the composer disabled on a stale zero. Poll every 8s once signed in, make the balance readout click-to-refresh, and stop refreshBalance from throwing on transient RPC errors (keep the last rendered state instead). Claude-Session: https://claude.ai/code/session_014YjPMViNrZ7zBVgUQzwEKk
Hermes's API server CORS middleware returns 403 for any request carrying an Origin header whose origin is not allowlisted — and its default allowlist is empty. Browsers attach Origin to every POST, including same-origin ones, so the /chat widget's paid requests were rejected by the upstream after payment verification (settlement correctly skipped — buyers were not charged, but chat was unusable from any browser). Set API_SERVER_CORS_ORIGINS="*" on the rendered hermes deployment: the API server is only reachable through the x402 verifier, payment is the gate, and no ambient credentials exist, so the wildcard is safe. Claude-Session: https://claude.ai/code/session_014YjPMViNrZ7zBVgUQzwEKk
The skip-when-unchanged fast path compared only skill.md, the aggregate docs, and the per-offer bundles — a controller upgrade that changed the embedded chat widget computed the same hash, skipped the ConfigMap apply, and pinned the old widget assets forever. Fold chat.html + chat-vendor.js into both the content hash and the deployed-ConfigMap match, with a regression test. Claude-Session: https://claude.ai/code/session_014YjPMViNrZ7zBVgUQzwEKk
…er origin-strip Unsigned WIP checkpoint — matches the images deployed on silvernuc3 (serviceoffer-controller:chatwidget-compact5, obol-x402-verifier:originstrip1). To be re-committed signed onto feat/agent-chat-widget for PR #752. Claude-Session: https://claude.ai/code/session_014YjPMViNrZ7zBVgUQzwEKk
Connect button chains straight into the session-key signature (still user-triggered — same click gesture), so the two-stage onboarding is one click; the separate sign-in stage remains only as the rejection-retry path. Removes the reveal-key affordance: the session key is deterministic (re-derivable by signing in again with the same wallet), so surfacing a raw private key bought nothing but risk. Slimmer empty-state copy. Claude-Session: https://claude.ai/code/session_014YjPMViNrZ7zBVgUQzwEKk
The iframe'd widget duplicated the landing page around it: agent title, price, network. A head inline script adds .embed when framed (before first paint, so no header flash) and CSS hides the header; the price still shows in the composer placeholder. The landing chat card loses its heading and padding — it is just the widget now — with a small full-page link below the card. Full-page /chat is unchanged. Claude-Session: https://claude.ai/code/session_014YjPMViNrZ7zBVgUQzwEKk
The textarea placeholder injected the full registration name, wrapped to two lines and clipped in the one-row box. Short placeholder (the page already names the agent), a touch more padding and gap in the strip and composer, muted placeholder color. Claude-Session: https://claude.ai/code/session_014YjPMViNrZ7zBVgUQzwEKk
The label span was inline while every sibling row (Endpoint, Model, Network) breaks after its label, so the card rendered "API docsAPI docs ↗ · openapi.json ↗". block on the label matches the siblings. Claude-Session: https://claude.ai/code/session_014YjPMViNrZ7zBVgUQzwEKk
stream:true with a line-buffered SSE reader appending deltas via textContent (JSON fallback kept for non-streaming upstreams) — first tokens at ~3.5s instead of the full ~15s wait, verified end-to-end with a settled paid probe before the widget change. Payment policy: the fixed $0.05 MAX_PRICE cap is gone (it also blocked legitimately pricier agents); instead an x402Client registerPolicy filter refuses any single payment above the CURRENT session balance. The session balance is the budget the user explicitly parked, so no authorization can ever exceed it, and a mid-session price hike beyond it is refused with a clear message instead of silently paid. The startup probe price check was advisory only; this enforces on every payment. Streaming implemented by grok-4.5 lane from spec; balance-ceiling follow-up from the forked side-review. Diff reviewed, tests re-run. Claude-Session: https://claude.ai/code/session_014YjPMViNrZ7zBVgUQzwEKk
11c0924 to
a585aeb
Compare
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.
Stacked on #753 (page-surface naming) — review the 12 commits above its 3.
Every agent-type offer automatically gets a built-in browser chat client: a
/chatpage and an embedded chat card on its landing page, themed per offer through the same resolved storefront profile as the rest of its origin. No signup, no API key: connect an injected wallet, one signature derives a deterministic local session wallet (domain-bound message, key never stored or displayed), fund it once with USDC, and every message pays per-turn via x402 (EIP-3009, gasless) — payment is the authentication.Highlights beyond the original widget:
stream:true+ line-buffered SSE rendering (JSON fallback kept). Settlement fires at stream-open via the existing header-time interceptor; verified end-to-end with settled paid probes.x402Clientpolicy refuses any single payment above the current session balance — a mid-session price change can never silently drain more than the user parked./chatkeeps full context.Origin/Sec-Fetch-*before re-issuing upstream, so origin-allowlisting upstreams (hermes API server default-deny) work for paid browser traffic without CORS wildcards.Cache-Control(no-cache HTML/JSON, immutable hashed vendor), GET-scoped discovery routes so root-priced offers keep their paidPOST /.The vendor bundle (viem + @x402/fetch + @x402/evm, esbuild, pinned + hash-cache-busted) is embedded and served same-origin — no CDN at runtime. Rebuild recipe in
assets/README.md.Tests green (
go test ./...); commits signed; running live-verified on a production stack.https://claude.ai/code/session_014YjPMViNrZ7zBVgUQzwEKk