Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions internal/serviceoffercontroller/assets/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Agent chat widget assets

`chat.html` — the self-contained agent chat page served at `/chat` on every
agent-type offer's dedicated origin. Hand-maintained; no build step. It
derives the agent name from the hostname and price/model/network/asset from
the live 402 challenge, so the same file works for every agent offer on any
stack and network (Base mainnet `eip155:8453` and Base Sepolia
`eip155:84532` are supported).

## Known limitations

- **Session key is derived from a wallet signature** (`keccak256(personal_sign(<EIP-4361 message>))`),
so the wallet must produce a *deterministic* signature for the same message.
Standard RFC-6979 EOAs (MetaMask, Rabby, Ledger, Trezor) do; the `getCode`
guard rejects contract wallets (non-reproducible ERC-1271) while allowing
EIP-7702-delegated EOAs. **Residual gap:** MPC / threshold-ECDSA wallets are
code-less EOAs that pass the guard but sign non-deterministically — funding a
session from one strands the balance on the next visit. There is no on-chain
signal to detect this without a second signature popup, which this
one-signature flow deliberately avoids. Use a standard EOA.
- **Per-turn spend is capped at the price shown when the page loaded** (and the
session balance). A turn whose 402 amount exceeds the displayed price is
refused; a legitimate price change is picked up on reload. Max loss per
session is bounded by what you fund into the session wallet.
- **The signature itself is key material.** Anything that can read it (a
malicious extension, a hooked `window.ethereum`) controls the session funds —
keep session balances small.

`chat-vendor.js` — generated single-file ESM bundle of the widget's
dependencies. Do not edit by hand. Rebuild:

```sh
npm init -y && npm i viem@2.21.25 @x402/fetch@2.18.0 @x402/evm@2.18.0
cat > vendor-entry.mjs <<'EOF'
export { createWalletClient, createPublicClient, custom, http, erc20Abi,
formatUnits, parseUnits, keccak256 } from "viem";
export { privateKeyToAccount } from "viem/accounts";
export { base, baseSepolia } from "viem/chains";
export { wrapFetchWithPayment, x402Client } from "@x402/fetch";
export { ExactEvmScheme, toClientEvmSigner } from "@x402/evm";
EOF
npx esbuild vendor-entry.mjs --bundle --format=esm --minify --target=es2022 \
--outfile=chat-vendor.js
```

When the bundle is rebuilt, bump the `?v=` cache-buster on the
`chat-vendor.js` import in `chat.html` to the new sha256's first 8 hex
chars — intermediaries (e.g. Cloudflare) cache `.js` aggressively.

sha256 of the committed bundle:
`895fd923aa84d7cf80e2b1df299068aa38dba7307a9a380526c0b5426489724d`

The pinned versions are the exact pair validated end-to-end against the
x402-verifier with real on-chain settlements (X-PAYMENT v1 and
PAYMENT-SIGNATURE v2 flows both accepted since #690).
57 changes: 57 additions & 0 deletions internal/serviceoffercontroller/assets/chat-vendor.js

Large diffs are not rendered by default.

458 changes: 458 additions & 0 deletions internal/serviceoffercontroller/assets/chat.html

Large diffs are not rendered by default.

9 changes: 7 additions & 2 deletions internal/serviceoffercontroller/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ func staticSiteContentMatches(cm *unstructured.Unstructured, content, servicesJS
if data["skill.md"] != content ||
data["services.json"] != servicesJSON ||
data["openapi.json"] != openAPIJSON ||
data["api.html"] != apiDocsHTML {
data["api.html"] != apiDocsHTML ||
data["chat-vendor.js"] != chatWidgetVendorJS {
return false
}
// Per-offer bundles: every expected file present + identical, and no
Expand Down Expand Up @@ -87,7 +88,11 @@ func (c *Controller) staticSiteContentUnchanged(ctx context.Context, content, se
}

func computeStaticSiteContentHash(content, servicesJSON, openAPIJSON, apiDocsHTML string, bundles []offerBundleFile) string {
return fmt.Sprintf("%x", md5Sum(content+servicesJSON+openAPIJSON+apiDocsHTML+bundleDigestInput(bundles)))[:8]
// The embedded vendor bundle is part of the served content: fold it in
// so a controller upgrade that changes it re-applies the ConfigMap and
// rolls the httpd (otherwise the skip-when-unchanged fast path pins the
// old asset forever). The per-offer chat pages flow through bundles.
return fmt.Sprintf("%x", md5Sum(content+servicesJSON+openAPIJSON+apiDocsHTML+chatWidgetVendorJS+bundleDigestInput(bundles)))[:8]
}

func staticSiteDeployedContentHash(deployment *unstructured.Unstructured) string {
Expand Down
18 changes: 18 additions & 0 deletions internal/serviceoffercontroller/catalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,21 @@ func TestStaticSiteDeployedContentHash(t *testing.T) {
t.Fatalf("missing annotation hash = %q, want empty", got)
}
}

// TestStaticSiteStaleChatWidgetTriggersUpdate pins the upgrade path: a
// deployed ConfigMap whose chat widget differs from the binary's embedded
// copy must NOT match, otherwise the skip-when-unchanged fast path pins the
// old asset across controller upgrades forever. (Per-offer chat pages flow
// through the offer bundles, which the match already covers.)
func TestStaticSiteStaleChatWidgetTriggersUpdate(t *testing.T) {
cm := buildStaticSiteConfigMap("# cat", `{}`, `{}`, "<html></html>", nil)
if !staticSiteContentMatches(cm, "# cat", `{}`, `{}`, "<html></html>", nil) {
t.Fatalf("fresh ConfigMap should match its own inputs")
}
if err := unstructured.SetNestedField(cm.Object, "stale vendor", "data", "chat-vendor.js"); err != nil {
t.Fatal(err)
}
if staticSiteContentMatches(cm, "# cat", `{}`, `{}`, "<html></html>", nil) {
t.Fatalf("stale chat-vendor.js must trigger a ConfigMap update")
}
}
63 changes: 63 additions & 0 deletions internal/serviceoffercontroller/chatwidget.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package serviceoffercontroller

import (
_ "embed"
"html/template"
"strings"

"github.com/ObolNetwork/obol-stack/internal/monetizeapi"
"github.com/ObolNetwork/obol-stack/internal/schemas"
"github.com/ObolNetwork/obol-stack/internal/storefront"
)

// The agent chat widget: a self-contained browser chat client served free on
// every agent-type offer's dedicated origin at /chat (and embedded on the
// offer's landing page). The page discovers pricing at runtime from its own
// origin — price, model, payment network and asset come from the 402
// challenge on POST /v1/chat/completions — while identity and theme are
// rendered per offer: the template receives the offer's display name and the
// same resolved storefront theme tokens as its landing page, so default and
// branded designs flow through identically.
//
// Payment is fully client-side: the visitor connects an injected wallet,
// signs one fixed message ("sign in with Ethereum") whose keccak256 becomes
// a deterministic local session key, funds that session address with a small
// USDC transfer, and every chat turn is then paid silently via x402
// (EIP-3009 transferWithAuthorization signed by the session key — gasless
// for the payer). The session key never leaves the page and is re-derived by
// re-signing the same message, so nothing is persisted.
//
//go:embed assets/chat.html
var chatWidgetTmplSrc string

var chatWidgetTmpl = template.Must(template.New("chat_widget").Parse(chatWidgetTmplSrc))

// chatWidgetVendorJS is the widget's only dependency: viem 2.21.25 +
// @x402/fetch 2.18.0 + @x402/evm 2.18.0 bundled into one ESM file so the
// page loads with zero external requests (no CDN, works on air-gapped
// stacks). Served once at the catalog httpd root — per-offer /chat pages
// import it behind a content-hash ?v= cache-buster. Rebuild: see
// assets/README.md.
//
//go:embed assets/chat-vendor.js
var chatWidgetVendorJS string

// buildOfferChatHTML renders the offer's /chat page with the same title and
// resolved theme as its landing page.
func buildOfferChatHTML(offer *monetizeapi.ServiceOffer, profile schemas.StorefrontProfile) string {
title := strings.TrimSpace(offer.Spec.Registration.Name)
if title == "" {
title = offer.Name
}
theme := storefront.ResolveTheme(profile.Theme, profile.AccentColor)
var out strings.Builder
err := chatWidgetTmpl.Execute(&out, map[string]any{
"Title": title,
"OfferName": offer.Name,
"ThemeCSS": template.CSS(theme.CSSVars()),
})
if err != nil {
return "<!doctype html><title>" + template.HTMLEscapeString(title) + "</title>"
}
return out.String()
}
168 changes: 164 additions & 4 deletions internal/serviceoffercontroller/hostoffer_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package serviceoffercontroller

import (
"crypto/sha256"
"encoding/json"
"fmt"
"strings"
"testing"

Expand Down Expand Up @@ -46,9 +48,9 @@ func TestBuildHostHTTPRoute(t *testing.T) {
// Rule shapes: first three Exact → catalog httpd with full-path
// rewrite; last PathPrefix / → verifier with prefix rewrite + headers.
wantExact := map[string]string{
"/": "/offers/sec/audit/index.html",
"/openapi.json": "/offers/sec/audit/openapi.json",
"/.well-known/x402": "/offers/sec/audit/x402.json",
"/": "/offers/sec/audit/index.html",
"/openapi.json": "/offers/sec/audit/openapi.json",
"/.well-known/x402": "/offers/sec/audit/x402.json",
"/.well-known/agent-registration.json": "/offers/sec/audit/agent-registration.json",
}
for i := 0; i < 4; i++ {
Expand Down Expand Up @@ -339,6 +341,164 @@ func TestCatalogAdvertisesDedicatedOrigin(t *testing.T) {
}
}

// TestBuildHostHTTPRoute_AgentChatWidget pins the agent-only chat rules:
// Exact /chat and /chat-vendor.js rewrite to the SHARED widget files at the
// catalog httpd root (not the offer bundle dir), sit between the discovery
// rules and the catch-all so they win over the payment gate, and do not
// exist at all for non-agent offers (covered by TestBuildHostHTTPRoute's
// rule-count pin).
func TestBuildHostHTTPRoute_AgentChatWidget(t *testing.T) {
offer := hostnameOffer()
offer.Spec.Type = "agent"
route := buildHostHTTPRoute(offer)

rules, _, _ := unstructured.NestedSlice(route.Object, "spec", "rules")
if len(rules) != 7 {
t.Fatalf("rules = %d, want 7 (4 discovery + /chat + /chat-vendor.js + catch-all)", len(rules))
}

wantShared := map[string]string{
"/chat": "/offers/sec/audit/chat.html",
"/chat-vendor.js": "/chat-vendor.js",
}
for i := 4; i <= 5; i++ {
rule := rules[i].(map[string]any)
match := rule["matches"].([]any)[0].(map[string]any)["path"].(map[string]any)
if match["type"] != "Exact" {
t.Errorf("rule %d match type = %v, want Exact", i, match["type"])
}
public := match["value"].(string)
want, ok := wantShared[public]
if !ok {
t.Fatalf("rule %d matches unexpected path %q", i, public)
}
filter := rule["filters"].([]any)[0].(map[string]any)
rewrite := filter["urlRewrite"].(map[string]any)["path"].(map[string]any)
if got := rewrite["replaceFullPath"]; got != want {
t.Errorf("rule %d (%s) rewrites to %v, want %s", i, public, got, want)
}
backend := rule["backendRefs"].([]any)[0].(map[string]any)
if backend["name"] != staticSiteConfigMapName || backend["namespace"] != staticSiteNamespace {
t.Errorf("rule %d backend = %v", i, backend)
}
}

// /chat holds a hot session key and signs USDC transfers — it must carry
// frame-ancestors 'self' so it can't be clickjacked into a cross-origin
// iframe (the offer's own landing page still embeds it same-origin).
chatRule := rules[4].(map[string]any)
var sawCSP bool
for _, rawFilter := range chatRule["filters"].([]any) {
filter := rawFilter.(map[string]any)
if filter["type"] != "ResponseHeaderModifier" {
continue
}
for _, s := range filter["responseHeaderModifier"].(map[string]any)["set"].([]any) {
h := s.(map[string]any)
if h["name"] == "Content-Security-Policy" && h["value"] == "frame-ancestors 'self'" {
sawCSP = true
}
}
}
if !sawCSP {
t.Errorf("/chat rule missing Content-Security-Policy: frame-ancestors 'self'")
}

// Catch-all must still be last.
last := rules[6].(map[string]any)
match := last["matches"].([]any)[0].(map[string]any)["path"].(map[string]any)
if match["type"] != "PathPrefix" {
t.Fatalf("last rule = %v, want the PathPrefix catch-all", match)
}
}

// TestOfferLandingChatEmbed pins the landing-page widget embed: agent offers
// get the chat card iframing /chat; everything else keeps the plain landing.
func TestOfferLandingChatEmbed(t *testing.T) {
profile := schemas.StorefrontProfile{DisplayName: "Acme", ContactEmail: "ops@acme.example"}

plain := buildOfferLandingHTML(hostnameOffer(), profile)
if strings.Contains(plain, `data-obol="chat"`) {
t.Fatalf("non-agent landing embeds the chat widget")
}

agent := hostnameOffer()
agent.Spec.Type = "agent"
withChat := buildOfferLandingHTML(agent, profile)
if !strings.Contains(withChat, `data-obol="chat"`) || !strings.Contains(withChat, `src="/chat"`) {
t.Fatalf("agent landing missing chat embed:\n%s", withChat)
}
}

// TestStaticSiteServesChatWidget pins the widget delivery: the shared
// vendor bundle sits in the catalog ConfigMap (served with a JavaScript
// MIME type — module imports hard-fail otherwise), while the chat page is
// rendered per agent offer into its bundle with the offer's title and the
// same resolved theme tokens as its landing page.
func TestStaticSiteServesChatWidget(t *testing.T) {
cm := buildStaticSiteConfigMap("# cat", `{"services":[]}`, `{}`, "<html></html>", nil)
data, _, _ := unstructured.NestedStringMap(cm.Object, "data")
if data["chat-vendor.js"] == "" {
t.Fatalf("catalog ConfigMap missing the shared chat vendor bundle")
}
if !strings.Contains(data["httpd.conf"], ".js:text/javascript") {
t.Fatalf("httpd.conf missing .js MIME mapping: %q", data["httpd.conf"])
}
var sawJS bool
for _, raw := range staticSiteVolumeItems(nil) {
item := raw.(map[string]any)
if item["key"] == "chat-vendor.js" && item["path"] == "chat-vendor.js" {
sawJS = true
}
}
if !sawJS {
t.Fatalf("volume items missing the chat vendor projection")
}

// Per-offer page: agent offers gain a chat.html bundle file carrying
// the landing page's theme tokens and title; non-agent offers do not.
profile := schemas.StorefrontProfile{DisplayName: "Acme"}
plain := buildOfferBundles([]*monetizeapi.ServiceOffer{hostnameOffer()}, profile, noUpstreamOpenAPI)
for _, f := range plain {
if strings.HasSuffix(f.Path, "chat.html") {
t.Fatalf("non-agent offer rendered a chat page: %s", f.Path)
}
}
agent := hostnameOffer()
agent.Spec.Type = "agent"
bundles := buildOfferBundles([]*monetizeapi.ServiceOffer{agent}, profile, noUpstreamOpenAPI)
var chat string
for _, f := range bundles {
if f.Path == "offers/sec/audit/chat.html" {
chat = f.Content
}
}
if chat == "" {
t.Fatalf("agent offer bundle missing chat.html (got %d files)", len(bundles))
}
theme := storefront.ResolveTheme(profile.Theme, profile.AccentColor)
if !strings.Contains(chat, "--bg01:"+theme.Vars["bg01"]) {
t.Errorf("chat page missing resolved theme tokens")
}
if !strings.Contains(chat, "chat-vendor.js?v=") {
t.Errorf("chat page missing cache-busted vendor import")
}
}

// TestChatVendorVersionMatchesBundle guards the ?v= cache-buster on
// chat.html's chat-vendor.js import against a forgotten bump: the bundle is
// served 1-year immutable (buildHostHTTPRoute's exactToShared), so a rebuild
// that forgets to bump ?v= would silently serve returning visitors the OLD
// payment-signing bundle for up to a year. This must fail CI whenever
// assets/chat-vendor.js and assets/chat.html's ?v= go out of sync.
func TestChatVendorVersionMatchesBundle(t *testing.T) {
sum := sha256.Sum256([]byte(chatWidgetVendorJS))
want := "chat-vendor.js?v=" + fmt.Sprintf("%x", sum)[:8]
if !strings.Contains(chatWidgetTmplSrc, want) {
t.Fatalf("chat.html's chat-vendor.js ?v= does not match sha256(assets/chat-vendor.js); want %q (see assets/README.md rebuild steps)", want)
}
}

// TestHostRouteDiscoveryRulesAreGETOnly pins the method scoping: a
// root-priced offer advertises POST <origin>/ as its paid resource, so the
// Exact "/" discovery rule must only capture GETs — POSTs fall through to
Expand Down Expand Up @@ -374,7 +534,7 @@ func TestBuildOfferBundles_UpstreamOpenAPI(t *testing.T) {
"get": map[string]any{
"summary": "Leaderboard", "security": []any{map[string]any{"x402": []any{}}},
"x-payment-info": map[string]any{"price": map[string]any{"amount": "0.001"}},
"responses": map[string]any{"200": map[string]any{}, "402": map[string]any{}},
"responses": map[string]any{"200": map[string]any{}, "402": map[string]any{}},
},
},
"/v1/markets/overview": map[string]any{
Expand Down
15 changes: 14 additions & 1 deletion internal/serviceoffercontroller/offerbundle.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import (
// resources per origin. An offer with spec.hostname therefore gets its own
// discovery documents — an openapi.json scoped to just that offer with
// paths rooted at "/", a /.well-known/x402 resource list, and a minimal
// landing page — served by the same static-site httpd via per-offer ConfigMap
// landing page — served by the same catalog httpd via per-offer ConfigMap
// keys and Exact-match rewrite routes on the offer's hostname.

// offerBundleFile is one generated file: Key is the ConfigMap data key,
Expand Down Expand Up @@ -95,6 +95,16 @@ func buildOfferBundles(offers []*monetizeapi.ServiceOffer, profile schemas.Store
Content: buildOfferLandingHTML(offer, originProfile),
},
)
if offer.IsAgent() {
// The chat widget page is themed and titled per offer (same
// resolved profile as the landing page); the heavy vendor
// bundle stays shared at the httpd root.
bundles = append(bundles, offerBundleFile{
Key: offerBundleKey(offer, "chat.html"),
Path: offerBundleDir(offer) + "/chat.html",
Content: buildOfferChatHTML(offer, originProfile),
})
}
}
sort.Slice(bundles, func(i, j int) bool { return bundles[i].Key < bundles[j].Key })
return bundles
Expand Down Expand Up @@ -345,6 +355,9 @@ func buildOfferLandingHTML(offer *monetizeapi.ServiceOffer, profile schemas.Stor
var out strings.Builder
err := offerLandingTmpl.Execute(&out, map[string]any{
"Title": title,
// Agent-type offers get the embedded chat widget: the /chat and
// /chat-vendor.js Exact routes exist on the hostname iff IsAgent.
"ChatEnabled": offer.IsAgent(),
// Meta/OG tags keep the plain text; the body renders the markdown.
"Description": desc,
"DescriptionHTML": storefront.RenderRichText(desc),
Expand Down
Loading
Loading