Skip to content

Commit d7f191b

Browse files
committed
Merge feat/chat-widget-reland into integration/v0.14.0-rc1
Fold the agent chat widget into rc1 for the v0.14.0-rc1 pre-release. Widget files (chat.html, chat-vendor.js, chatwidget.go, offerbundle.go) are identical to the deploy/rc1-fixes tree validated live on silvernuc3 (serviceoffer-controller:rc1fix-c2dd5df). render.go merges the widget's /chat route + CSP with rc1's UTF-8 + agent model-strip changes. Claude-Session: https://claude.ai/code/session_01PnhCQLz7CHuDBUhWd5xF8v
2 parents ab28470 + f534f60 commit d7f191b

11 files changed

Lines changed: 893 additions & 13 deletions

File tree

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Agent chat widget assets
2+
3+
`chat.html` — the self-contained agent chat page served at `/chat` on every
4+
agent-type offer's dedicated origin. Hand-maintained; no build step. It
5+
derives the agent name from the hostname and price/model/network/asset from
6+
the live 402 challenge, so the same file works for every agent offer on any
7+
stack and network (Base mainnet `eip155:8453` and Base Sepolia
8+
`eip155:84532` are supported).
9+
10+
## Known limitations
11+
12+
- **Session key is derived from a wallet signature** (`keccak256(personal_sign(<EIP-4361 message>))`),
13+
so the wallet must produce a *deterministic* signature for the same message.
14+
Standard RFC-6979 EOAs (MetaMask, Rabby, Ledger, Trezor) do; the `getCode`
15+
guard rejects contract wallets (non-reproducible ERC-1271) while allowing
16+
EIP-7702-delegated EOAs. **Residual gap:** MPC / threshold-ECDSA wallets are
17+
code-less EOAs that pass the guard but sign non-deterministically — funding a
18+
session from one strands the balance on the next visit. There is no on-chain
19+
signal to detect this without a second signature popup, which this
20+
one-signature flow deliberately avoids. Use a standard EOA.
21+
- **Per-turn spend is capped at the price shown when the page loaded** (and the
22+
session balance). A turn whose 402 amount exceeds the displayed price is
23+
refused; a legitimate price change is picked up on reload. Max loss per
24+
session is bounded by what you fund into the session wallet.
25+
- **The signature itself is key material.** Anything that can read it (a
26+
malicious extension, a hooked `window.ethereum`) controls the session funds —
27+
keep session balances small.
28+
29+
`chat-vendor.js` — generated single-file ESM bundle of the widget's
30+
dependencies. Do not edit by hand. Rebuild:
31+
32+
```sh
33+
npm init -y && npm i viem@2.21.25 @x402/fetch@2.18.0 @x402/evm@2.18.0
34+
cat > vendor-entry.mjs <<'EOF'
35+
export { createWalletClient, createPublicClient, custom, http, erc20Abi,
36+
formatUnits, parseUnits, keccak256 } from "viem";
37+
export { privateKeyToAccount } from "viem/accounts";
38+
export { base, baseSepolia } from "viem/chains";
39+
export { wrapFetchWithPayment, x402Client } from "@x402/fetch";
40+
export { ExactEvmScheme, toClientEvmSigner } from "@x402/evm";
41+
EOF
42+
npx esbuild vendor-entry.mjs --bundle --format=esm --minify --target=es2022 \
43+
--outfile=chat-vendor.js
44+
```
45+
46+
When the bundle is rebuilt, bump the `?v=` cache-buster on the
47+
`chat-vendor.js` import in `chat.html` to the new sha256's first 8 hex
48+
chars — intermediaries (e.g. Cloudflare) cache `.js` aggressively.
49+
50+
sha256 of the committed bundle:
51+
`895fd923aa84d7cf80e2b1df299068aa38dba7307a9a380526c0b5426489724d`
52+
53+
The pinned versions are the exact pair validated end-to-end against the
54+
x402-verifier with real on-chain settlements (X-PAYMENT v1 and
55+
PAYMENT-SIGNATURE v2 flows both accepted since #690).

internal/serviceoffercontroller/assets/chat-vendor.js

Lines changed: 57 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/serviceoffercontroller/assets/chat.html

Lines changed: 458 additions & 0 deletions
Large diffs are not rendered by default.

internal/serviceoffercontroller/catalog.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@ func staticSiteContentMatches(cm *unstructured.Unstructured, content, servicesJS
5151
if data["skill.md"] != content ||
5252
data["services.json"] != servicesJSON ||
5353
data["openapi.json"] != openAPIJSON ||
54-
data["api.html"] != apiDocsHTML {
54+
data["api.html"] != apiDocsHTML ||
55+
data["chat-vendor.js"] != chatWidgetVendorJS {
5556
return false
5657
}
5758
// Per-offer bundles: every expected file present + identical, and no
@@ -87,7 +88,11 @@ func (c *Controller) staticSiteContentUnchanged(ctx context.Context, content, se
8788
}
8889

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

9398
func staticSiteDeployedContentHash(deployment *unstructured.Unstructured) string {

internal/serviceoffercontroller/catalog_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,21 @@ func TestStaticSiteDeployedContentHash(t *testing.T) {
4949
t.Fatalf("missing annotation hash = %q, want empty", got)
5050
}
5151
}
52+
53+
// TestStaticSiteStaleChatWidgetTriggersUpdate pins the upgrade path: a
54+
// deployed ConfigMap whose chat widget differs from the binary's embedded
55+
// copy must NOT match, otherwise the skip-when-unchanged fast path pins the
56+
// old asset across controller upgrades forever. (Per-offer chat pages flow
57+
// through the offer bundles, which the match already covers.)
58+
func TestStaticSiteStaleChatWidgetTriggersUpdate(t *testing.T) {
59+
cm := buildStaticSiteConfigMap("# cat", `{}`, `{}`, "<html></html>", nil)
60+
if !staticSiteContentMatches(cm, "# cat", `{}`, `{}`, "<html></html>", nil) {
61+
t.Fatalf("fresh ConfigMap should match its own inputs")
62+
}
63+
if err := unstructured.SetNestedField(cm.Object, "stale vendor", "data", "chat-vendor.js"); err != nil {
64+
t.Fatal(err)
65+
}
66+
if staticSiteContentMatches(cm, "# cat", `{}`, `{}`, "<html></html>", nil) {
67+
t.Fatalf("stale chat-vendor.js must trigger a ConfigMap update")
68+
}
69+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package serviceoffercontroller
2+
3+
import (
4+
_ "embed"
5+
"html/template"
6+
"strings"
7+
8+
"github.com/ObolNetwork/obol-stack/internal/monetizeapi"
9+
"github.com/ObolNetwork/obol-stack/internal/schemas"
10+
"github.com/ObolNetwork/obol-stack/internal/storefront"
11+
)
12+
13+
// The agent chat widget: a self-contained browser chat client served free on
14+
// every agent-type offer's dedicated origin at /chat (and embedded on the
15+
// offer's landing page). The page discovers pricing at runtime from its own
16+
// origin — price, model, payment network and asset come from the 402
17+
// challenge on POST /v1/chat/completions — while identity and theme are
18+
// rendered per offer: the template receives the offer's display name and the
19+
// same resolved storefront theme tokens as its landing page, so default and
20+
// branded designs flow through identically.
21+
//
22+
// Payment is fully client-side: the visitor connects an injected wallet,
23+
// signs one fixed message ("sign in with Ethereum") whose keccak256 becomes
24+
// a deterministic local session key, funds that session address with a small
25+
// USDC transfer, and every chat turn is then paid silently via x402
26+
// (EIP-3009 transferWithAuthorization signed by the session key — gasless
27+
// for the payer). The session key never leaves the page and is re-derived by
28+
// re-signing the same message, so nothing is persisted.
29+
//
30+
//go:embed assets/chat.html
31+
var chatWidgetTmplSrc string
32+
33+
var chatWidgetTmpl = template.Must(template.New("chat_widget").Parse(chatWidgetTmplSrc))
34+
35+
// chatWidgetVendorJS is the widget's only dependency: viem 2.21.25 +
36+
// @x402/fetch 2.18.0 + @x402/evm 2.18.0 bundled into one ESM file so the
37+
// page loads with zero external requests (no CDN, works on air-gapped
38+
// stacks). Served once at the catalog httpd root — per-offer /chat pages
39+
// import it behind a content-hash ?v= cache-buster. Rebuild: see
40+
// assets/README.md.
41+
//
42+
//go:embed assets/chat-vendor.js
43+
var chatWidgetVendorJS string
44+
45+
// buildOfferChatHTML renders the offer's /chat page with the same title and
46+
// resolved theme as its landing page.
47+
func buildOfferChatHTML(offer *monetizeapi.ServiceOffer, profile schemas.StorefrontProfile) string {
48+
title := strings.TrimSpace(offer.Spec.Registration.Name)
49+
if title == "" {
50+
title = offer.Name
51+
}
52+
theme := storefront.ResolveTheme(profile.Theme, profile.AccentColor)
53+
var out strings.Builder
54+
err := chatWidgetTmpl.Execute(&out, map[string]any{
55+
"Title": title,
56+
"OfferName": offer.Name,
57+
"ThemeCSS": template.CSS(theme.CSSVars()),
58+
})
59+
if err != nil {
60+
return "<!doctype html><title>" + template.HTMLEscapeString(title) + "</title>"
61+
}
62+
return out.String()
63+
}

internal/serviceoffercontroller/hostoffer_test.go

Lines changed: 164 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package serviceoffercontroller
22

33
import (
4+
"crypto/sha256"
45
"encoding/json"
6+
"fmt"
57
"strings"
68
"testing"
79

@@ -46,9 +48,9 @@ func TestBuildHostHTTPRoute(t *testing.T) {
4648
// Rule shapes: first three Exact → catalog httpd with full-path
4749
// rewrite; last PathPrefix / → verifier with prefix rewrite + headers.
4850
wantExact := map[string]string{
49-
"/": "/offers/sec/audit/index.html",
50-
"/openapi.json": "/offers/sec/audit/openapi.json",
51-
"/.well-known/x402": "/offers/sec/audit/x402.json",
51+
"/": "/offers/sec/audit/index.html",
52+
"/openapi.json": "/offers/sec/audit/openapi.json",
53+
"/.well-known/x402": "/offers/sec/audit/x402.json",
5254
"/.well-known/agent-registration.json": "/offers/sec/audit/agent-registration.json",
5355
}
5456
for i := 0; i < 4; i++ {
@@ -339,6 +341,164 @@ func TestCatalogAdvertisesDedicatedOrigin(t *testing.T) {
339341
}
340342
}
341343

344+
// TestBuildHostHTTPRoute_AgentChatWidget pins the agent-only chat rules:
345+
// Exact /chat and /chat-vendor.js rewrite to the SHARED widget files at the
346+
// catalog httpd root (not the offer bundle dir), sit between the discovery
347+
// rules and the catch-all so they win over the payment gate, and do not
348+
// exist at all for non-agent offers (covered by TestBuildHostHTTPRoute's
349+
// rule-count pin).
350+
func TestBuildHostHTTPRoute_AgentChatWidget(t *testing.T) {
351+
offer := hostnameOffer()
352+
offer.Spec.Type = "agent"
353+
route := buildHostHTTPRoute(offer)
354+
355+
rules, _, _ := unstructured.NestedSlice(route.Object, "spec", "rules")
356+
if len(rules) != 7 {
357+
t.Fatalf("rules = %d, want 7 (4 discovery + /chat + /chat-vendor.js + catch-all)", len(rules))
358+
}
359+
360+
wantShared := map[string]string{
361+
"/chat": "/offers/sec/audit/chat.html",
362+
"/chat-vendor.js": "/chat-vendor.js",
363+
}
364+
for i := 4; i <= 5; i++ {
365+
rule := rules[i].(map[string]any)
366+
match := rule["matches"].([]any)[0].(map[string]any)["path"].(map[string]any)
367+
if match["type"] != "Exact" {
368+
t.Errorf("rule %d match type = %v, want Exact", i, match["type"])
369+
}
370+
public := match["value"].(string)
371+
want, ok := wantShared[public]
372+
if !ok {
373+
t.Fatalf("rule %d matches unexpected path %q", i, public)
374+
}
375+
filter := rule["filters"].([]any)[0].(map[string]any)
376+
rewrite := filter["urlRewrite"].(map[string]any)["path"].(map[string]any)
377+
if got := rewrite["replaceFullPath"]; got != want {
378+
t.Errorf("rule %d (%s) rewrites to %v, want %s", i, public, got, want)
379+
}
380+
backend := rule["backendRefs"].([]any)[0].(map[string]any)
381+
if backend["name"] != staticSiteConfigMapName || backend["namespace"] != staticSiteNamespace {
382+
t.Errorf("rule %d backend = %v", i, backend)
383+
}
384+
}
385+
386+
// /chat holds a hot session key and signs USDC transfers — it must carry
387+
// frame-ancestors 'self' so it can't be clickjacked into a cross-origin
388+
// iframe (the offer's own landing page still embeds it same-origin).
389+
chatRule := rules[4].(map[string]any)
390+
var sawCSP bool
391+
for _, rawFilter := range chatRule["filters"].([]any) {
392+
filter := rawFilter.(map[string]any)
393+
if filter["type"] != "ResponseHeaderModifier" {
394+
continue
395+
}
396+
for _, s := range filter["responseHeaderModifier"].(map[string]any)["set"].([]any) {
397+
h := s.(map[string]any)
398+
if h["name"] == "Content-Security-Policy" && h["value"] == "frame-ancestors 'self'" {
399+
sawCSP = true
400+
}
401+
}
402+
}
403+
if !sawCSP {
404+
t.Errorf("/chat rule missing Content-Security-Policy: frame-ancestors 'self'")
405+
}
406+
407+
// Catch-all must still be last.
408+
last := rules[6].(map[string]any)
409+
match := last["matches"].([]any)[0].(map[string]any)["path"].(map[string]any)
410+
if match["type"] != "PathPrefix" {
411+
t.Fatalf("last rule = %v, want the PathPrefix catch-all", match)
412+
}
413+
}
414+
415+
// TestOfferLandingChatEmbed pins the landing-page widget embed: agent offers
416+
// get the chat card iframing /chat; everything else keeps the plain landing.
417+
func TestOfferLandingChatEmbed(t *testing.T) {
418+
profile := schemas.StorefrontProfile{DisplayName: "Acme", ContactEmail: "ops@acme.example"}
419+
420+
plain := buildOfferLandingHTML(hostnameOffer(), profile)
421+
if strings.Contains(plain, `data-obol="chat"`) {
422+
t.Fatalf("non-agent landing embeds the chat widget")
423+
}
424+
425+
agent := hostnameOffer()
426+
agent.Spec.Type = "agent"
427+
withChat := buildOfferLandingHTML(agent, profile)
428+
if !strings.Contains(withChat, `data-obol="chat"`) || !strings.Contains(withChat, `src="/chat"`) {
429+
t.Fatalf("agent landing missing chat embed:\n%s", withChat)
430+
}
431+
}
432+
433+
// TestStaticSiteServesChatWidget pins the widget delivery: the shared
434+
// vendor bundle sits in the catalog ConfigMap (served with a JavaScript
435+
// MIME type — module imports hard-fail otherwise), while the chat page is
436+
// rendered per agent offer into its bundle with the offer's title and the
437+
// same resolved theme tokens as its landing page.
438+
func TestStaticSiteServesChatWidget(t *testing.T) {
439+
cm := buildStaticSiteConfigMap("# cat", `{"services":[]}`, `{}`, "<html></html>", nil)
440+
data, _, _ := unstructured.NestedStringMap(cm.Object, "data")
441+
if data["chat-vendor.js"] == "" {
442+
t.Fatalf("catalog ConfigMap missing the shared chat vendor bundle")
443+
}
444+
if !strings.Contains(data["httpd.conf"], ".js:text/javascript") {
445+
t.Fatalf("httpd.conf missing .js MIME mapping: %q", data["httpd.conf"])
446+
}
447+
var sawJS bool
448+
for _, raw := range staticSiteVolumeItems(nil) {
449+
item := raw.(map[string]any)
450+
if item["key"] == "chat-vendor.js" && item["path"] == "chat-vendor.js" {
451+
sawJS = true
452+
}
453+
}
454+
if !sawJS {
455+
t.Fatalf("volume items missing the chat vendor projection")
456+
}
457+
458+
// Per-offer page: agent offers gain a chat.html bundle file carrying
459+
// the landing page's theme tokens and title; non-agent offers do not.
460+
profile := schemas.StorefrontProfile{DisplayName: "Acme"}
461+
plain := buildOfferBundles([]*monetizeapi.ServiceOffer{hostnameOffer()}, profile, noUpstreamOpenAPI)
462+
for _, f := range plain {
463+
if strings.HasSuffix(f.Path, "chat.html") {
464+
t.Fatalf("non-agent offer rendered a chat page: %s", f.Path)
465+
}
466+
}
467+
agent := hostnameOffer()
468+
agent.Spec.Type = "agent"
469+
bundles := buildOfferBundles([]*monetizeapi.ServiceOffer{agent}, profile, noUpstreamOpenAPI)
470+
var chat string
471+
for _, f := range bundles {
472+
if f.Path == "offers/sec/audit/chat.html" {
473+
chat = f.Content
474+
}
475+
}
476+
if chat == "" {
477+
t.Fatalf("agent offer bundle missing chat.html (got %d files)", len(bundles))
478+
}
479+
theme := storefront.ResolveTheme(profile.Theme, profile.AccentColor)
480+
if !strings.Contains(chat, "--bg01:"+theme.Vars["bg01"]) {
481+
t.Errorf("chat page missing resolved theme tokens")
482+
}
483+
if !strings.Contains(chat, "chat-vendor.js?v=") {
484+
t.Errorf("chat page missing cache-busted vendor import")
485+
}
486+
}
487+
488+
// TestChatVendorVersionMatchesBundle guards the ?v= cache-buster on
489+
// chat.html's chat-vendor.js import against a forgotten bump: the bundle is
490+
// served 1-year immutable (buildHostHTTPRoute's exactToShared), so a rebuild
491+
// that forgets to bump ?v= would silently serve returning visitors the OLD
492+
// payment-signing bundle for up to a year. This must fail CI whenever
493+
// assets/chat-vendor.js and assets/chat.html's ?v= go out of sync.
494+
func TestChatVendorVersionMatchesBundle(t *testing.T) {
495+
sum := sha256.Sum256([]byte(chatWidgetVendorJS))
496+
want := "chat-vendor.js?v=" + fmt.Sprintf("%x", sum)[:8]
497+
if !strings.Contains(chatWidgetTmplSrc, want) {
498+
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)
499+
}
500+
}
501+
342502
// TestHostRouteDiscoveryRulesAreGETOnly pins the method scoping: a
343503
// root-priced offer advertises POST <origin>/ as its paid resource, so the
344504
// Exact "/" discovery rule must only capture GETs — POSTs fall through to
@@ -374,7 +534,7 @@ func TestBuildOfferBundles_UpstreamOpenAPI(t *testing.T) {
374534
"get": map[string]any{
375535
"summary": "Leaderboard", "security": []any{map[string]any{"x402": []any{}}},
376536
"x-payment-info": map[string]any{"price": map[string]any{"amount": "0.001"}},
377-
"responses": map[string]any{"200": map[string]any{}, "402": map[string]any{}},
537+
"responses": map[string]any{"200": map[string]any{}, "402": map[string]any{}},
378538
},
379539
},
380540
"/v1/markets/overview": map[string]any{

internal/serviceoffercontroller/offerbundle.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import (
2020
// resources per origin. An offer with spec.hostname therefore gets its own
2121
// discovery documents — an openapi.json scoped to just that offer with
2222
// paths rooted at "/", a /.well-known/x402 resource list, and a minimal
23-
// landing page — served by the same static-site httpd via per-offer ConfigMap
23+
// landing page — served by the same catalog httpd via per-offer ConfigMap
2424
// keys and Exact-match rewrite routes on the offer's hostname.
2525

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

0 commit comments

Comments
 (0)