From b9888c131c123122a418cc4dcb966f4b79a94d03 Mon Sep 17 00:00:00 2001 From: bussyjd Date: Wed, 15 Jul 2026 14:29:01 +0400 Subject: [PATCH 1/3] chore(storefront): name the static site for what it actually serves 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 --- .../agent_confighash_test.go | 20 +-- internal/serviceoffercontroller/catalog.go | 12 +- .../serviceoffercontroller/catalog_test.go | 28 ++--- internal/serviceoffercontroller/controller.go | 56 ++++----- .../serviceoffercontroller/hostoffer_test.go | 4 +- .../serviceoffercontroller/offerbundle.go | 2 +- internal/serviceoffercontroller/render.go | 115 ++++++++++-------- .../render_builders_test.go | 40 +++--- .../serviceoffercontroller/render_test.go | 34 +++--- .../routesurface_golden_test.go | 2 +- 10 files changed, 161 insertions(+), 152 deletions(-) diff --git a/internal/serviceoffercontroller/agent_confighash_test.go b/internal/serviceoffercontroller/agent_confighash_test.go index 62029243..3966173f 100644 --- a/internal/serviceoffercontroller/agent_confighash_test.go +++ b/internal/serviceoffercontroller/agent_confighash_test.go @@ -30,32 +30,32 @@ func TestHermesConfigDecision(t *testing.T) { wantDrift: false, }, { - name: "annotation matches and data hashes to desired -> skip, no drift", - live: hermesConfigCM(t, "ns", desiredYAML, desiredHash, "1"), + name: "annotation matches and data hashes to desired -> skip, no drift", + live: hermesConfigCM(t, "ns", desiredYAML, desiredHash, "1"), wantSkip: true, wantDrift: false, }, { - name: "annotation matches but data hand-edited -> skip with drift", - live: hermesConfigCM(t, "ns", otherYAML, desiredHash, "1"), + name: "annotation matches but data hand-edited -> skip with drift", + live: hermesConfigCM(t, "ns", otherYAML, desiredHash, "1"), wantSkip: true, wantDrift: true, }, { - name: "annotation does not match desired -> apply", - live: hermesConfigCM(t, "ns", desiredYAML, otherHash, "1"), + name: "annotation does not match desired -> apply", + live: hermesConfigCM(t, "ns", desiredYAML, otherHash, "1"), wantSkip: false, wantDrift: false, }, { - name: "annotation absent (pre-feature migration) -> apply", - live: hermesConfigCM(t, "ns", desiredYAML, "", "1"), + name: "annotation absent (pre-feature migration) -> apply", + live: hermesConfigCM(t, "ns", desiredYAML, "", "1"), wantSkip: false, wantDrift: false, }, { - name: "annotation matches but data key missing -> skip with drift", - live: hermesConfigCMNoData(t, "ns", desiredHash, "1"), + name: "annotation matches but data key missing -> skip with drift", + live: hermesConfigCMNoData(t, "ns", desiredHash, "1"), wantSkip: true, wantDrift: true, }, diff --git a/internal/serviceoffercontroller/catalog.go b/internal/serviceoffercontroller/catalog.go index a33cfad6..97327377 100644 --- a/internal/serviceoffercontroller/catalog.go +++ b/internal/serviceoffercontroller/catalog.go @@ -40,7 +40,7 @@ func (c *Controller) listServiceOffersForCatalog(ctx context.Context, override * return offers, nil } -func skillCatalogContentMatches(cm *unstructured.Unstructured, content, servicesJSON, openAPIJSON, apiDocsHTML string, bundles []offerBundleFile) bool { +func staticSiteContentMatches(cm *unstructured.Unstructured, content, servicesJSON, openAPIJSON, apiDocsHTML string, bundles []offerBundleFile) bool { if cm == nil { return false } @@ -75,22 +75,22 @@ func skillCatalogContentMatches(cm *unstructured.Unstructured, content, services return true } -func (c *Controller) skillCatalogContentUnchanged(ctx context.Context, content, servicesJSON, openAPIJSON, apiDocsHTML string, bundles []offerBundleFile) (bool, error) { - cm, err := c.configMaps.Namespace(skillCatalogNamespace).Get(ctx, skillCatalogConfigMapName, metav1.GetOptions{}) +func (c *Controller) staticSiteContentUnchanged(ctx context.Context, content, servicesJSON, openAPIJSON, apiDocsHTML string, bundles []offerBundleFile) (bool, error) { + cm, err := c.configMaps.Namespace(staticSiteNamespace).Get(ctx, staticSiteConfigMapName, metav1.GetOptions{}) if apierrors.IsNotFound(err) { return false, nil } if err != nil { return false, err } - return skillCatalogContentMatches(cm, content, servicesJSON, openAPIJSON, apiDocsHTML, bundles), nil + return staticSiteContentMatches(cm, content, servicesJSON, openAPIJSON, apiDocsHTML, bundles), nil } -func computeSkillCatalogContentHash(content, servicesJSON, openAPIJSON, apiDocsHTML string, bundles []offerBundleFile) string { +func computeStaticSiteContentHash(content, servicesJSON, openAPIJSON, apiDocsHTML string, bundles []offerBundleFile) string { return fmt.Sprintf("%x", md5Sum(content+servicesJSON+openAPIJSON+apiDocsHTML+bundleDigestInput(bundles)))[:8] } -func skillCatalogDeployedContentHash(deployment *unstructured.Unstructured) string { +func staticSiteDeployedContentHash(deployment *unstructured.Unstructured) string { if deployment == nil { return "" } diff --git a/internal/serviceoffercontroller/catalog_test.go b/internal/serviceoffercontroller/catalog_test.go index e596ae6c..184a5939 100644 --- a/internal/serviceoffercontroller/catalog_test.go +++ b/internal/serviceoffercontroller/catalog_test.go @@ -6,9 +6,9 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" ) -func TestComputeSkillCatalogContentHashDeterministic(t *testing.T) { - a := computeSkillCatalogContentHash("# cat", `{"services":[]}`, `{"openapi":"3.1.0"}`, "", nil) - b := computeSkillCatalogContentHash("# cat", `{"services":[]}`, `{"openapi":"3.1.0"}`, "", nil) +func TestComputeStaticSiteContentHashDeterministic(t *testing.T) { + a := computeStaticSiteContentHash("# cat", `{"services":[]}`, `{"openapi":"3.1.0"}`, "", nil) + b := computeStaticSiteContentHash("# cat", `{"services":[]}`, `{"openapi":"3.1.0"}`, "", nil) if a != b { t.Fatalf("hash not deterministic: %q vs %q", a, b) } @@ -16,36 +16,36 @@ func TestComputeSkillCatalogContentHashDeterministic(t *testing.T) { t.Fatalf("hash length = %d, want 8", len(a)) } - changed := computeSkillCatalogContentHash("# cat", `{"services":[{"name":"a"}]}`, `{"openapi":"3.1.0"}`, "", nil) + changed := computeStaticSiteContentHash("# cat", `{"services":[{"name":"a"}]}`, `{"openapi":"3.1.0"}`, "", nil) if changed == a { t.Fatal("expected different hash when catalog content changes") } } -func TestSkillCatalogContentMatches(t *testing.T) { - cm := buildSkillCatalogConfigMap("# cat", `{"services":[]}`, `{"openapi":"3.1.0"}`, "", nil) - if !skillCatalogContentMatches(cm, "# cat", `{"services":[]}`, `{"openapi":"3.1.0"}`, "", nil) { +func TestStaticSiteContentMatches(t *testing.T) { + cm := buildStaticSiteConfigMap("# cat", `{"services":[]}`, `{"openapi":"3.1.0"}`, "", nil) + if !staticSiteContentMatches(cm, "# cat", `{"services":[]}`, `{"openapi":"3.1.0"}`, "", nil) { t.Fatal("expected matching catalog content") } - if skillCatalogContentMatches(cm, "# changed", `{"services":[]}`, `{"openapi":"3.1.0"}`, "", nil) { + if staticSiteContentMatches(cm, "# changed", `{"services":[]}`, `{"openapi":"3.1.0"}`, "", nil) { t.Fatal("expected different skill.md to not match") } - if skillCatalogContentMatches(nil, "# cat", `{}`, `{}`, "", nil) { + if staticSiteContentMatches(nil, "# cat", `{}`, `{}`, "", nil) { t.Fatal("nil configmap must not match") } } -func TestSkillCatalogDeployedContentHash(t *testing.T) { - deployment := buildSkillCatalogDeployment("abc12345", nil) - if got := skillCatalogDeployedContentHash(deployment); got != "abc12345" { +func TestStaticSiteDeployedContentHash(t *testing.T) { + deployment := buildStaticSiteDeployment("abc12345", nil) + if got := staticSiteDeployedContentHash(deployment); got != "abc12345" { t.Fatalf("hash = %q, want abc12345", got) } - if got := skillCatalogDeployedContentHash(nil); got != "" { + if got := staticSiteDeployedContentHash(nil); got != "" { t.Fatalf("nil deployment hash = %q, want empty", got) } empty := &unstructured.Unstructured{Object: map[string]any{"spec": map[string]any{}}} - if got := skillCatalogDeployedContentHash(empty); got != "" { + if got := staticSiteDeployedContentHash(empty); got != "" { t.Fatalf("missing annotation hash = %q, want empty", got) } } diff --git a/internal/serviceoffercontroller/controller.go b/internal/serviceoffercontroller/controller.go index 0d45c95b..20a5714f 100644 --- a/internal/serviceoffercontroller/controller.go +++ b/internal/serviceoffercontroller/controller.go @@ -76,7 +76,7 @@ type Controller struct { identityQueue workqueue.TypedRateLimitingInterface[string] purchaseQueue workqueue.TypedRateLimitingInterface[string] agentQueue workqueue.TypedRateLimitingInterface[string] - catalogMu sync.Mutex + staticSiteMu sync.Mutex pendingAuths sync.Map // key: "ns/name" → []map[string]string @@ -351,25 +351,25 @@ func (c *Controller) enqueueStorefrontProfileRefresh(obj any) { if u.GetNamespace() != storefront.ProfileNamespace || u.GetName() != storefront.ProfileConfigMap { return } - log.Printf("serviceoffer-controller: storefront profile change detected, refreshing skill catalog") - c.enqueueSkillCatalogRefresh() + log.Printf("serviceoffer-controller: storefront profile change detected, refreshing static site") + c.enqueueStaticSiteRefresh() } -func (c *Controller) enqueueSkillCatalogRefresh() { +func (c *Controller) enqueueStaticSiteRefresh() { items := c.offerInformer.GetStore().List() if len(items) > 0 { // Any single offer reconcile rebuilds the full catalog. c.enqueueOffer(items[0]) return } - go c.refreshSkillCatalogAsync() + go c.refreshStaticSiteAsync() } -func (c *Controller) refreshSkillCatalogAsync() { +func (c *Controller) refreshStaticSiteAsync() { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() - if err := c.reconcileSkillCatalog(ctx, nil); err != nil { - log.Printf("serviceoffer-controller: refresh skill catalog: %v", err) + if err := c.reconcileStaticSite(ctx, nil); err != nil { + log.Printf("serviceoffer-controller: refresh static site: %v", err) } } @@ -467,7 +467,7 @@ func (c *Controller) reconcileOffer(ctx context.Context, key string) error { now := metav1.Now() tombstone.DeletionTimestamp = &now } - if err := c.reconcileSkillCatalog(ctx, &tombstone); err != nil { + if err := c.reconcileStaticSite(ctx, &tombstone); err != nil { return err } return c.removeFinalizer(ctx, raw, serviceOfferFinalizer) @@ -661,10 +661,10 @@ func (c *Controller) reconcileOffer(ctx context.Context, key string) error { // requiring a spec mutation or unrelated ConfigMap update. c.offerQueue.AddAfter(offer.Namespace+"/"+offer.Name, 5*time.Second) } - // Rebuild the skill catalog on every reconcile so tunnel URL changes and - // offer status updates propagate immediately. reconcileSkillCatalog skips + // Rebuild the static site on every reconcile so tunnel URL changes and + // offer status updates propagate immediately. reconcileStaticSite skips // ConfigMap/Deployment writes when the rendered hash is unchanged. - return c.reconcileSkillCatalog(ctx, nil) + return c.reconcileStaticSite(ctx, nil) } func (c *Controller) reconcileDeletingOffer(ctx context.Context, offer *monetizeapi.ServiceOffer) error { @@ -1246,16 +1246,16 @@ func (c *Controller) loadStorefrontProfile(ctx context.Context) (*schemas.Storef return storefront.ParseProfile(raw) } -// reconcileSkillCatalog rebuilds the /skill.md ConfigMap/Deployment/Service/ +// reconcileStaticSite rebuilds the /skill.md ConfigMap/Deployment/Service/ // HTTPRoute from the current set of operationally-ready ServiceOffers. Offers // are listed from the API server so every reconcile sees a consistent snapshot; // override replaces or appends a just-created offer the list has not observed yet. // ConfigMap and Deployment are only applied when the rendered catalog hash differs // from the live obol-skill-md Deployment annotation, so idle reconciles do not -// roll the skill-catalog pod. -func (c *Controller) reconcileSkillCatalog(ctx context.Context, override *monetizeapi.ServiceOffer) error { - c.catalogMu.Lock() - defer c.catalogMu.Unlock() +// roll the static-site pod. +func (c *Controller) reconcileStaticSite(ctx context.Context, override *monetizeapi.ServiceOffer) error { + c.staticSiteMu.Lock() + defer c.staticSiteMu.Unlock() baseURL, err := c.registrationBaseURL(ctx) if err != nil { @@ -1271,7 +1271,7 @@ func (c *Controller) reconcileSkillCatalog(ctx context.Context, override *moneti if err != nil { return err } - content := buildSkillCatalogMarkdown(offers, baseURL, storefrontProfile) + content := buildSkillMarkdown(offers, baseURL, storefrontProfile) servicesJSON := buildServiceCatalogJSON(offers, baseURL, storefrontProfile) resolvedProfile := storefront.ResolvePublished(storefrontProfile, baseURL) // buildOpenAPIDocument prefers the tunnel URL for the public `servers[0]` @@ -1283,9 +1283,9 @@ func (c *Controller) reconcileSkillCatalog(ctx context.Context, override *moneti openAPIJSON := buildOpenAPIDocument(offers, baseURL, resolvedProfile) apiDocsHTML := scalarHTML(resolvedProfile) bundles := buildOfferBundles(offers, resolvedProfile) - contentHash := computeSkillCatalogContentHash(content, servicesJSON, openAPIJSON, apiDocsHTML, bundles) + contentHash := computeStaticSiteContentHash(content, servicesJSON, openAPIJSON, apiDocsHTML, bundles) - unchanged, err := c.skillCatalogContentUnchanged(ctx, content, servicesJSON, openAPIJSON, apiDocsHTML, bundles) + unchanged, err := c.staticSiteContentUnchanged(ctx, content, servicesJSON, openAPIJSON, apiDocsHTML, bundles) if err != nil { return err } @@ -1295,30 +1295,30 @@ func (c *Controller) reconcileSkillCatalog(ctx context.Context, override *moneti return nil } - if err := c.applyObject(ctx, c.configMaps.Namespace(skillCatalogNamespace), buildSkillCatalogConfigMap(content, servicesJSON, openAPIJSON, apiDocsHTML, bundles)); err != nil { + if err := c.applyObject(ctx, c.configMaps.Namespace(staticSiteNamespace), buildStaticSiteConfigMap(content, servicesJSON, openAPIJSON, apiDocsHTML, bundles)); err != nil { return err } - if err := c.applyObject(ctx, c.deployments.Namespace(skillCatalogNamespace), buildSkillCatalogDeployment(contentHash, bundles)); err != nil { + if err := c.applyObject(ctx, c.deployments.Namespace(staticSiteNamespace), buildStaticSiteDeployment(contentHash, bundles)); err != nil { return err } - if err := c.applyObject(ctx, c.services.Namespace(skillCatalogNamespace), buildSkillCatalogService()); err != nil { + if err := c.applyObject(ctx, c.services.Namespace(staticSiteNamespace), buildStaticSiteService()); err != nil { return err } // Headers Middleware must exist before the routes that reference it, or // Traefik drops the routes for a dangling ExtensionRef. - if err := c.applyObject(ctx, c.middlewares.Namespace(skillCatalogNamespace), buildCatalogHeadersMiddleware()); err != nil { + if err := c.applyObject(ctx, c.middlewares.Namespace(staticSiteNamespace), buildCatalogHeadersMiddleware()); err != nil { return err } - if err := c.applyObject(ctx, c.httpRoutes.Namespace(skillCatalogNamespace), buildSkillCatalogHTTPRoute()); err != nil { + if err := c.applyObject(ctx, c.httpRoutes.Namespace(staticSiteNamespace), buildStaticSiteHTTPRoute()); err != nil { return err } - if err := c.applyObject(ctx, c.httpRoutes.Namespace(skillCatalogNamespace), buildServicesJSONHTTPRoute()); err != nil { + if err := c.applyObject(ctx, c.httpRoutes.Namespace(staticSiteNamespace), buildServicesJSONHTTPRoute()); err != nil { return err } - if err := c.applyObject(ctx, c.httpRoutes.Namespace(skillCatalogNamespace), buildOpenAPIHTTPRoute()); err != nil { + if err := c.applyObject(ctx, c.httpRoutes.Namespace(staticSiteNamespace), buildOpenAPIHTTPRoute()); err != nil { return err } - if err := c.applyObject(ctx, c.httpRoutes.Namespace(skillCatalogNamespace), buildAPIDocsHTTPRoute()); err != nil { + if err := c.applyObject(ctx, c.httpRoutes.Namespace(staticSiteNamespace), buildAPIDocsHTTPRoute()); err != nil { return err } readyOffers := countReadyServiceOffers(offers) diff --git a/internal/serviceoffercontroller/hostoffer_test.go b/internal/serviceoffercontroller/hostoffer_test.go index 43795bb8..5036df96 100644 --- a/internal/serviceoffercontroller/hostoffer_test.go +++ b/internal/serviceoffercontroller/hostoffer_test.go @@ -59,7 +59,7 @@ func TestBuildHostHTTPRoute(t *testing.T) { t.Errorf("rule %d (%s) rewrites to %v, want %s", i, public, got, wantExact[public]) } backend := rule["backendRefs"].([]any)[0].(map[string]any) - if backend["name"] != skillCatalogConfigMapName || backend["namespace"] != skillCatalogNamespace { + if backend["name"] != staticSiteConfigMapName || backend["namespace"] != staticSiteNamespace { t.Errorf("rule %d backend = %v", i, backend) } } @@ -262,7 +262,7 @@ func TestCatalogAdvertisesDedicatedOrigin(t *testing.T) { t.Fatalf("catalog endpoint = %+v, want dedicated origin", catalog.Services) } - skill := buildSkillCatalogMarkdown([]*monetizeapi.ServiceOffer{offer}, "https://shared.example", nil) + skill := buildSkillMarkdown([]*monetizeapi.ServiceOffer{offer}, "https://shared.example", nil) if !strings.Contains(skill, "`POST https://audit.v1337.example/submit`") { t.Errorf("skill.md routes not rooted at dedicated origin:\n%.400s", skill) } diff --git a/internal/serviceoffercontroller/offerbundle.go b/internal/serviceoffercontroller/offerbundle.go index 51db1778..621eb053 100644 --- a/internal/serviceoffercontroller/offerbundle.go +++ b/internal/serviceoffercontroller/offerbundle.go @@ -19,7 +19,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 catalog httpd via per-offer ConfigMap +// landing page — served by the same static-site 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, diff --git a/internal/serviceoffercontroller/render.go b/internal/serviceoffercontroller/render.go index c7340b09..3e8e3b4d 100644 --- a/internal/serviceoffercontroller/render.go +++ b/internal/serviceoffercontroller/render.go @@ -22,12 +22,21 @@ import ( ) const ( - skillCatalogNamespace = "x402" - skillCatalogConfigMapName = "obol-skill-md" - skillCatalogRouteName = "obol-skill-md-route" - servicesJSONRouteName = "obol-services-json-route" - openAPIRouteName = "obol-openapi-route" - apiDocsRouteName = "obol-api-docs-route" + // The static site is the busybox httpd that serves every public artifact: + // skill.md, services.json (the storefront's own backend), openapi.json, + // the API docs, and one bundle per hostname-bound offer (landing page, + // scoped openapi.json, .well-known/x402). The "obol-skill-md" object names + // predate all but the first of those and are load-bearing — they are + // referenced by internal/tunnel (SERVICES_URL), cmd/obol/sell_info.go, + // internal/stackbackup, the embedded x402.yaml, and the storefront's + // next.config.ts. Renaming them is a live-cluster migration, so the Go + // identifiers say what this is and the wire names stay put. + staticSiteNamespace = "x402" + staticSiteConfigMapName = "obol-skill-md" + staticSiteRouteName = "obol-skill-md-route" + servicesJSONRouteName = "obol-services-json-route" + openAPIRouteName = "obol-openapi-route" + apiDocsRouteName = "obol-api-docs-route" // catalogHeadersMiddlewareName is the Traefik headers Middleware attached // to the public catalog HTTPRoutes (/skill.md, /openapi.json, /api, @@ -255,7 +264,7 @@ func agentIdentityLabels(identity *monetizeapi.AgentIdentity, appName string) ma } } -func buildSkillCatalogConfigMap(content, servicesJSON, openAPIJSON, apiDocsHTML string, bundles []offerBundleFile) *unstructured.Unstructured { +func buildStaticSiteConfigMap(content, servicesJSON, openAPIJSON, apiDocsHTML string, bundles []offerBundleFile) *unstructured.Unstructured { data := map[string]any{ "skill.md": content, "services.json": servicesJSON, @@ -271,10 +280,10 @@ func buildSkillCatalogConfigMap(content, servicesJSON, openAPIJSON, apiDocsHTML "apiVersion": "v1", "kind": "ConfigMap", "metadata": map[string]any{ - "name": skillCatalogConfigMapName, - "namespace": skillCatalogNamespace, + "name": staticSiteConfigMapName, + "namespace": staticSiteNamespace, "labels": map[string]any{ - "app": skillCatalogConfigMapName, + "app": staticSiteConfigMapName, "obol.org/managed-by": "serviceoffer-controller", }, }, @@ -283,10 +292,10 @@ func buildSkillCatalogConfigMap(content, servicesJSON, openAPIJSON, apiDocsHTML } } -// skillCatalogVolumeItems projects the ConfigMap keys into the httpd's /www +// staticSiteVolumeItems projects the ConfigMap keys into the httpd's /www // tree: the four aggregate documents plus one file per hostname-offer // bundle entry (offers///…). -func skillCatalogVolumeItems(bundles []offerBundleFile) []any { +func staticSiteVolumeItems(bundles []offerBundleFile) []any { items := []any{ map[string]any{"key": "skill.md", "path": "skill.md"}, map[string]any{"key": "services.json", "path": "api/services.json"}, @@ -303,9 +312,9 @@ func skillCatalogVolumeItems(bundles []offerBundleFile) []any { return items } -func buildSkillCatalogDeployment(contentHash string, bundles []offerBundleFile) *unstructured.Unstructured { +func buildStaticSiteDeployment(contentHash string, bundles []offerBundleFile) *unstructured.Unstructured { labels := map[string]any{ - "app": skillCatalogConfigMapName, + "app": staticSiteConfigMapName, "obol.org/managed-by": "serviceoffer-controller", } return &unstructured.Unstructured{ @@ -313,8 +322,8 @@ func buildSkillCatalogDeployment(contentHash string, bundles []offerBundleFile) "apiVersion": "apps/v1", "kind": "Deployment", "metadata": map[string]any{ - "name": skillCatalogConfigMapName, - "namespace": skillCatalogNamespace, + "name": staticSiteConfigMapName, + "namespace": staticSiteNamespace, "labels": labels, }, "spec": map[string]any{ @@ -354,14 +363,14 @@ func buildSkillCatalogDeployment(contentHash string, bundles []offerBundleFile) map[string]any{ "name": "content", "configMap": map[string]any{ - "name": skillCatalogConfigMapName, - "items": skillCatalogVolumeItems(bundles), + "name": staticSiteConfigMapName, + "items": staticSiteVolumeItems(bundles), }, }, map[string]any{ "name": "httpdconf", "configMap": map[string]any{ - "name": skillCatalogConfigMapName, + "name": staticSiteConfigMapName, "items": []any{map[string]any{"key": "httpd.conf", "path": "httpd.conf"}}, }, }, @@ -373,9 +382,9 @@ func buildSkillCatalogDeployment(contentHash string, bundles []offerBundleFile) } } -func buildSkillCatalogService() *unstructured.Unstructured { +func buildStaticSiteService() *unstructured.Unstructured { labels := map[string]any{ - "app": skillCatalogConfigMapName, + "app": staticSiteConfigMapName, "obol.org/managed-by": "serviceoffer-controller", } return &unstructured.Unstructured{ @@ -383,8 +392,8 @@ func buildSkillCatalogService() *unstructured.Unstructured { "apiVersion": "v1", "kind": "Service", "metadata": map[string]any{ - "name": skillCatalogConfigMapName, - "namespace": skillCatalogNamespace, + "name": staticSiteConfigMapName, + "namespace": staticSiteNamespace, "labels": labels, }, "spec": map[string]any{ @@ -417,7 +426,7 @@ func buildCatalogHeadersMiddleware() *unstructured.Unstructured { "kind": "Middleware", "metadata": map[string]any{ "name": catalogHeadersMiddlewareName, - "namespace": skillCatalogNamespace, + "namespace": staticSiteNamespace, "labels": map[string]any{ "obol.org/managed-by": "serviceoffer-controller", }, @@ -451,14 +460,14 @@ func catalogHeadersFilters() []any { } } -func buildSkillCatalogHTTPRoute() *unstructured.Unstructured { +func buildStaticSiteHTTPRoute() *unstructured.Unstructured { return &unstructured.Unstructured{ Object: map[string]any{ "apiVersion": "gateway.networking.k8s.io/v1", "kind": "HTTPRoute", "metadata": map[string]any{ - "name": skillCatalogRouteName, - "namespace": skillCatalogNamespace, + "name": staticSiteRouteName, + "namespace": staticSiteNamespace, "labels": map[string]any{ "obol.org/managed-by": "serviceoffer-controller", }, @@ -484,8 +493,8 @@ func buildSkillCatalogHTTPRoute() *unstructured.Unstructured { "filters": catalogHeadersFilters(), "backendRefs": []any{ map[string]any{ - "name": skillCatalogConfigMapName, - "namespace": skillCatalogNamespace, + "name": staticSiteConfigMapName, + "namespace": staticSiteNamespace, "port": int64(8080), }, }, @@ -511,7 +520,7 @@ func buildOpenAPIHTTPRoute() *unstructured.Unstructured { "kind": "HTTPRoute", "metadata": map[string]any{ "name": openAPIRouteName, - "namespace": skillCatalogNamespace, + "namespace": staticSiteNamespace, "labels": map[string]any{ "obol.org/managed-by": "serviceoffer-controller", }, @@ -537,8 +546,8 @@ func buildOpenAPIHTTPRoute() *unstructured.Unstructured { "filters": catalogHeadersFilters(), "backendRefs": []any{ map[string]any{ - "name": skillCatalogConfigMapName, - "namespace": skillCatalogNamespace, + "name": staticSiteConfigMapName, + "namespace": staticSiteNamespace, "port": int64(8080), }, }, @@ -567,7 +576,7 @@ func buildAPIDocsHTTPRoute() *unstructured.Unstructured { "kind": "HTTPRoute", "metadata": map[string]any{ "name": apiDocsRouteName, - "namespace": skillCatalogNamespace, + "namespace": staticSiteNamespace, "labels": map[string]any{ "obol.org/managed-by": "serviceoffer-controller", }, @@ -599,8 +608,8 @@ func buildAPIDocsHTTPRoute() *unstructured.Unstructured { "filters": catalogHeadersFilters(), "backendRefs": []any{ map[string]any{ - "name": skillCatalogConfigMapName, - "namespace": skillCatalogNamespace, + "name": staticSiteConfigMapName, + "namespace": staticSiteNamespace, "port": int64(8080), }, }, @@ -618,7 +627,7 @@ func buildServicesJSONHTTPRoute() *unstructured.Unstructured { "kind": "HTTPRoute", "metadata": map[string]any{ "name": servicesJSONRouteName, - "namespace": skillCatalogNamespace, + "namespace": staticSiteNamespace, "labels": map[string]any{ "obol.org/managed-by": "serviceoffer-controller", }, @@ -644,8 +653,8 @@ func buildServicesJSONHTTPRoute() *unstructured.Unstructured { "filters": catalogHeadersFilters(), "backendRefs": []any{ map[string]any{ - "name": skillCatalogConfigMapName, - "namespace": skillCatalogNamespace, + "name": staticSiteConfigMapName, + "namespace": staticSiteNamespace, "port": int64(8080), }, }, @@ -738,7 +747,7 @@ func buildHTTPRoute(offer *monetizeapi.ServiceOffer) *unstructured.Unstructured // offer. Topology (proven live before being generalized here — see // docs/proposals/multistore-storefront-routing.md appendix): // -// - Exact /, /openapi.json, /.well-known/x402 → the catalog httpd, with +// - Exact /, /openapi.json, /.well-known/x402 → the static-site httpd, with // full-path rewrites into the offer's generated bundle files. These are // structurally free — they never touch the payment gate. // - PathPrefix / → x402-verifier, with the public path rewritten into the @@ -764,7 +773,7 @@ func buildHostHTTPRoute(offer *monetizeapi.ServiceOffer) *unstructured.Unstructu }, }, "backendRefs": []any{ - map[string]any{"name": skillCatalogConfigMapName, "namespace": skillCatalogNamespace, "port": int64(8080)}, + map[string]any{"name": staticSiteConfigMapName, "namespace": staticSiteNamespace, "port": int64(8080)}, }, } } @@ -890,11 +899,11 @@ func buildReferenceGrant(offer *monetizeapi.ServiceOffer) *unstructured.Unstruct }, // Hostname-bound offers backend their discovery bundle // (Exact / + /openapi.json + /.well-known/x402 rules) - // to the catalog httpd in this namespace. + // to the static-site httpd in this namespace. map[string]any{ "group": "", "kind": "Service", - "name": skillCatalogConfigMapName, + "name": staticSiteConfigMapName, }, }, }, @@ -1140,7 +1149,7 @@ func offerPublishedForRegistration(offer *monetizeapi.ServiceOffer) bool { isConditionTrue(offer.Status, "RoutePublished") } -func buildSkillCatalogMarkdown(offers []*monetizeapi.ServiceOffer, baseURL string, explicit *schemas.StorefrontProfile) string { +func buildSkillMarkdown(offers []*monetizeapi.ServiceOffer, baseURL string, explicit *schemas.StorefrontProfile) string { baseURL = strings.TrimRight(baseURL, "/") profile := storefront.ResolvePublished(explicit, baseURL) @@ -1186,7 +1195,7 @@ func buildSkillCatalogMarkdown(offers []*monetizeapi.ServiceOffer, baseURL strin "", } - lines = append(lines, skillCatalogHowToPay(baseURL)...) + lines = append(lines, skillMarkdownHowToPay(baseURL)...) if len(ready) == 0 { lines = append(lines, "## Services", "", "**No services currently available.**", "") @@ -1250,7 +1259,7 @@ func buildSkillCatalogMarkdown(offers []*monetizeapi.ServiceOffer, baseURL strin lines = append(lines, fmt.Sprintf(" %d. %s", i+1, describePaymentDetail(payments[i]))) } } - lines = append(lines, skillCatalogRouteLines(offer, endpoint)...) + lines = append(lines, skillMarkdownRouteLines(offer, endpoint)...) if offer.Spec.Async.Enabled { access := "results are gated to the paying wallet (SIWX sign-in) or the `jobToken` from the 202 body" if offer.Spec.Async.EffectiveResultVisibility() == monetizeapi.ResultVisibilityPublic { @@ -1268,17 +1277,17 @@ func buildSkillCatalogMarkdown(offers []*monetizeapi.ServiceOffer, baseURL strin description = fmt.Sprintf("x402 payment-gated %s service", fallbackOfferType(offer)) } lines = append(lines, fmt.Sprintf("- **Description**: %s", description), "") - lines = append(lines, skillCatalogTryIt(offer, endpoint)...) + lines = append(lines, skillMarkdownTryIt(offer, endpoint)...) } return strings.Join(lines, "\n") } -// skillCatalogHowToPay returns the self-contained "How to pay" section. It +// skillMarkdownHowToPay returns the self-contained "How to pay" section. It // is written so any LLM agent — not just one running on Obol Stack — can // pay these endpoints by following the x402 v2 loop, without first reading // any external doc. baseURL points the reader at the machine-readable specs. -func skillCatalogHowToPay(baseURL string) []string { +func skillMarkdownHowToPay(baseURL string) []string { return []string{ "## How to pay (x402)", "", @@ -1330,12 +1339,12 @@ func catalogModelName(offer *monetizeapi.ServiceOffer) string { return "" } -// skillCatalogTryIt renders the per-offer "Try it" subsection: one curl that +// skillMarkdownTryIt renders the per-offer "Try it" subsection: one curl that // probes the 402 pricing, and one worked paid request. The paid example for // chat-shaped offers is buyprompts.Build's Example — the exact same bytes // /api/services.json publishes in the entry's buy.example — so the two // surfaces cannot drift. Agent buyers convert off copy-paste, not prose. -func skillCatalogTryIt(offer *monetizeapi.ServiceOffer, endpoint string) []string { +func skillMarkdownTryIt(offer *monetizeapi.ServiceOffer, endpoint string) []string { // Route-table offers: probe + pay against the primary paid route, not // the offer root (which may not be served at all when the table has no // catch-all). @@ -1391,7 +1400,7 @@ func skillCatalogTryIt(offer *monetizeapi.ServiceOffer, endpoint string) []strin // active, and upstream healthy serves buyers correctly regardless of // whether the on-chain identity has been minted yet. // -// Used by the storefront catalog (and the skill catalog) so an offer that +// Used by the services.json catalog (and skill.md) so an offer that // is functionally usable doesn't disappear from the operator's own // dashboard just because the agent wallet hasn't been funded with gas // yet. Callers should set ServiceCatalogEntry.RegistrationPending = true @@ -1877,12 +1886,12 @@ func describePaymentDetail(p monetizeapi.ServiceOfferPayment) string { return b.String() } -// skillCatalogRouteLines renders the per-route list for offers with a +// skillMarkdownRouteLines renders the per-route list for offers with a // declared route table (spec.routes). One line per route: methods, full // URL, gate/price, and the route summary. Offers without a route table // contribute nothing — their single implicit catch-all is already fully // described by the Endpoint/Payment lines above. -func skillCatalogRouteLines(offer *monetizeapi.ServiceOffer, endpoint string) []string { +func skillMarkdownRouteLines(offer *monetizeapi.ServiceOffer, endpoint string) []string { if len(offer.Spec.Routes) == 0 { return nil } diff --git a/internal/serviceoffercontroller/render_builders_test.go b/internal/serviceoffercontroller/render_builders_test.go index bac5fa10..a650ffc5 100644 --- a/internal/serviceoffercontroller/render_builders_test.go +++ b/internal/serviceoffercontroller/render_builders_test.go @@ -74,14 +74,14 @@ func assertRestrictedPSS(t *testing.T, deploymentName string, spec map[string]an } } -// TestBuildSkillCatalogDeployment_RestrictedPSS verifies the skill-md +// TestBuildStaticSiteDeployment_RestrictedPSS verifies the skill-md // httpd Deployment ships a Restricted-PSS-compliant securityContext. // Regression test for the cross-PR interaction with #521 surfaced by // the 14-PR integration test (Bug #3). -func TestBuildSkillCatalogDeployment_RestrictedPSS(t *testing.T) { - d := buildSkillCatalogDeployment("hash-x", nil) +func TestBuildStaticSiteDeployment_RestrictedPSS(t *testing.T) { + d := buildStaticSiteDeployment("hash-x", nil) spec, _ := d.Object["spec"].(map[string]any) - assertRestrictedPSS(t, skillCatalogConfigMapName, spec) + assertRestrictedPSS(t, staticSiteConfigMapName, spec) } // TestBuildAgentIdentityRegistrationDeployment_RestrictedPSS verifies the @@ -100,16 +100,16 @@ func TestBuildAgentIdentityRegistrationDeployment_RestrictedPSS(t *testing.T) { assertRestrictedPSS(t, agentIdentityRegistrationName(identity), spec) } -// TestBuildSkillCatalogConfigMap: exposes skill.md + services.json + openapi.json +// TestBuildStaticSiteConfigMap: exposes skill.md + services.json + openapi.json // + api docs HTML + httpd conf. -func TestBuildSkillCatalogConfigMap(t *testing.T) { - cm := buildSkillCatalogConfigMap("# Catalog", `{"displayName":"Acme","tagline":"t","logoUrl":"https://x/logo.png","services":[{"name":"a"}]}`, `{"openapi":"3.1.0"}`, "shell", nil) +func TestBuildStaticSiteConfigMap(t *testing.T) { + cm := buildStaticSiteConfigMap("# Catalog", `{"displayName":"Acme","tagline":"t","logoUrl":"https://x/logo.png","services":[{"name":"a"}]}`, `{"openapi":"3.1.0"}`, "shell", nil) - if cm.GetName() != skillCatalogConfigMapName { - t.Errorf("name = %q, want %q", cm.GetName(), skillCatalogConfigMapName) + if cm.GetName() != staticSiteConfigMapName { + t.Errorf("name = %q, want %q", cm.GetName(), staticSiteConfigMapName) } - if cm.GetNamespace() != skillCatalogNamespace { - t.Errorf("namespace = %q, want %q", cm.GetNamespace(), skillCatalogNamespace) + if cm.GetNamespace() != staticSiteNamespace { + t.Errorf("namespace = %q, want %q", cm.GetNamespace(), staticSiteNamespace) } data, _ := cm.Object["data"].(map[string]any) if data["skill.md"] != "# Catalog" { @@ -134,11 +134,11 @@ func TestBuildSkillCatalogConfigMap(t *testing.T) { } } -// TestBuildSkillCatalogDeployment: content-hash annotation + correct volume wiring +// TestBuildStaticSiteDeployment: content-hash annotation + correct volume wiring // (skill.md and api/services.json paths). -func TestBuildSkillCatalogDeployment(t *testing.T) { - d1 := buildSkillCatalogDeployment("hash-1", nil) - d2 := buildSkillCatalogDeployment("hash-2", nil) +func TestBuildStaticSiteDeployment(t *testing.T) { + d1 := buildStaticSiteDeployment("hash-1", nil) + d2 := buildStaticSiteDeployment("hash-2", nil) spec1, _ := d1.Object["spec"].(map[string]any) template1, _ := spec1["template"].(map[string]any) @@ -187,13 +187,13 @@ func TestBuildSkillCatalogDeployment(t *testing.T) { } } -// TestBuildSkillCatalogService: ClusterIP service on port 8080 with the +// TestBuildStaticSiteService: ClusterIP service on port 8080 with the // managed-by selector. -func TestBuildSkillCatalogService(t *testing.T) { - svc := buildSkillCatalogService() +func TestBuildStaticSiteService(t *testing.T) { + svc := buildStaticSiteService() - if svc.GetName() != skillCatalogConfigMapName { - t.Errorf("name = %q, want %q", svc.GetName(), skillCatalogConfigMapName) + if svc.GetName() != staticSiteConfigMapName { + t.Errorf("name = %q, want %q", svc.GetName(), staticSiteConfigMapName) } spec, _ := svc.Object["spec"].(map[string]any) if spec["type"] != "ClusterIP" { diff --git a/internal/serviceoffercontroller/render_test.go b/internal/serviceoffercontroller/render_test.go index 036ac28c..677c980d 100644 --- a/internal/serviceoffercontroller/render_test.go +++ b/internal/serviceoffercontroller/render_test.go @@ -611,7 +611,7 @@ func TestRegistrationDataURL(t *testing.T) { } } -func TestBuildSkillCatalogMarkdown(t *testing.T) { +func TestBuildSkillMarkdown(t *testing.T) { readyOffer := &monetizeapi.ServiceOffer{ ObjectMeta: metav1.ObjectMeta{Name: "flow-qwen", Namespace: "llm"}, Spec: monetizeapi.ServiceOfferSpec{ @@ -639,7 +639,7 @@ func TestBuildSkillCatalogMarkdown(t *testing.T) { }, } - content := buildSkillCatalogMarkdown([]*monetizeapi.ServiceOffer{readyOffer, notReadyOffer}, "https://example.com", nil) + content := buildSkillMarkdown([]*monetizeapi.ServiceOffer{readyOffer, notReadyOffer}, "https://example.com", nil) if !strings.Contains(content, "# Obol Stack Service Catalog") { t.Fatalf("catalog missing title:\n%s", content) @@ -655,13 +655,13 @@ func TestBuildSkillCatalogMarkdown(t *testing.T) { } } -// TestBuildSkillCatalogMarkdown_DrainAdditiveDetail locks in the +// TestBuildSkillMarkdown_DrainAdditiveDetail locks in the // pure-additive markdown surface: active offers must NOT emit a // `- **Available**:` detail bullet (that wire was removed when drain // landed). Draining offers may have a `- **Drain ends at**:` bullet // but never a separate Available bullet, because consumers detect // drain solely via the timestamp's presence. -func TestBuildSkillCatalogMarkdown_DrainAdditiveDetail(t *testing.T) { +func TestBuildSkillMarkdown_DrainAdditiveDetail(t *testing.T) { readyCond := []monetizeapi.Condition{{Type: "Ready", Status: "True"}} activeOffer := &monetizeapi.ServiceOffer{ ObjectMeta: metav1.ObjectMeta{Name: "alpha", Namespace: "llm"}, @@ -693,7 +693,7 @@ func TestBuildSkillCatalogMarkdown_DrainAdditiveDetail(t *testing.T) { Status: monetizeapi.ServiceOfferStatus{Conditions: readyCond}, } - content := buildSkillCatalogMarkdown( + content := buildSkillMarkdown( []*monetizeapi.ServiceOffer{activeOffer, drainingOffer}, "https://example.com", nil, @@ -717,10 +717,10 @@ func TestBuildSkillCatalogMarkdown_DrainAdditiveDetail(t *testing.T) { } } -func TestBuildSkillCatalogHTTPRoute(t *testing.T) { - route := buildSkillCatalogHTTPRoute() - if route.GetName() != skillCatalogRouteName { - t.Fatalf("route name = %q, want %q", route.GetName(), skillCatalogRouteName) +func TestBuildStaticSiteHTTPRoute(t *testing.T) { + route := buildStaticSiteHTTPRoute() + if route.GetName() != staticSiteRouteName { + t.Fatalf("route name = %q, want %q", route.GetName(), staticSiteRouteName) } spec := route.Object["spec"].(map[string]any) rules := spec["rules"].([]any) @@ -732,8 +732,8 @@ func TestBuildSkillCatalogHTTPRoute(t *testing.T) { } backends := firstRule["backendRefs"].([]any) backend := backends[0].(map[string]any) - if backend["name"] != skillCatalogConfigMapName { - t.Fatalf("backend name = %v, want %s", backend["name"], skillCatalogConfigMapName) + if backend["name"] != staticSiteConfigMapName { + t.Fatalf("backend name = %v, want %s", backend["name"], staticSiteConfigMapName) } } @@ -1622,8 +1622,8 @@ func TestBuildCatalogHeadersMiddleware(t *testing.T) { if mw.GetName() != catalogHeadersMiddlewareName { t.Fatalf("name = %q, want %q", mw.GetName(), catalogHeadersMiddlewareName) } - if mw.GetNamespace() != skillCatalogNamespace { - t.Fatalf("namespace = %q, want %q", mw.GetNamespace(), skillCatalogNamespace) + if mw.GetNamespace() != staticSiteNamespace { + t.Fatalf("namespace = %q, want %q", mw.GetNamespace(), staticSiteNamespace) } headers := mw.Object["spec"].(map[string]any)["headers"].(map[string]any) @@ -1648,7 +1648,7 @@ func TestBuildCatalogHeadersMiddleware(t *testing.T) { // (locked separately by TestBuildHTTPRoute's no-filters assertion). func TestCatalogRoutesCarryHeadersFilter(t *testing.T) { routes := map[string]*unstructured.Unstructured{ - "/skill.md": buildSkillCatalogHTTPRoute(), + "/skill.md": buildStaticSiteHTTPRoute(), "/api/services.json": buildServicesJSONHTTPRoute(), "/openapi.json": buildOpenAPIHTTPRoute(), "/api": buildAPIDocsHTTPRoute(), @@ -1699,12 +1699,12 @@ func TestBuildServiceCatalogJSON_SchemaVersion(t *testing.T) { } } -// TestBuildSkillCatalogMarkdown_TryIt asserts every offer detail block ends +// TestBuildSkillMarkdown_TryIt asserts every offer detail block ends // with a copy-paste "Try it" section: a 402 probe curl plus a worked paid // request. Chat-shaped offers must show the REAL model id (including the // AgentResolution fallback for type=agent) — the same id services.json // publishes — and http offers a curl of the gated path. -func TestBuildSkillCatalogMarkdown_TryIt(t *testing.T) { +func TestBuildSkillMarkdown_TryIt(t *testing.T) { readyCond := []monetizeapi.Condition{{Type: "Ready", Status: "True"}} inferenceOffer := &monetizeapi.ServiceOffer{ ObjectMeta: metav1.ObjectMeta{Name: "flow-qwen", Namespace: "llm"}, @@ -1747,7 +1747,7 @@ func TestBuildSkillCatalogMarkdown_TryIt(t *testing.T) { Status: monetizeapi.ServiceOfferStatus{Conditions: readyCond}, } - content := buildSkillCatalogMarkdown( + content := buildSkillMarkdown( []*monetizeapi.ServiceOffer{inferenceOffer, agentOffer, httpOffer}, "https://seller.example", nil, ) diff --git a/internal/serviceoffercontroller/routesurface_golden_test.go b/internal/serviceoffercontroller/routesurface_golden_test.go index 60a7d85e..601cabe1 100644 --- a/internal/serviceoffercontroller/routesurface_golden_test.go +++ b/internal/serviceoffercontroller/routesurface_golden_test.go @@ -137,7 +137,7 @@ func TestRouteSurface_Golden_VerifierAndOpenAPIAgree(t *testing.T) { // appears in skill.md with its method, full URL, and effective price. func TestRouteSurface_Golden_SkillMDListsEveryRoute(t *testing.T) { offer := routeTableOffer() - content := buildSkillCatalogMarkdown([]*monetizeapi.ServiceOffer{offer}, "https://example.com", nil) + content := buildSkillMarkdown([]*monetizeapi.ServiceOffer{offer}, "https://example.com", nil) for _, want := range []string{ "`POST https://example.com/services/audit/submit` — 0.5 USDC/request — Submit source for audit", From ac99ef50c606b7c056ca61a511747bf7f5422f86 Mon Sep 17 00:00:00 2001 From: bussyjd Date: Wed, 15 Jul 2026 14:29:17 +0400 Subject: [PATCH 2/3] refactor(storefront): embed the offer landing page like every other template 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/.html + //go:embed + HTMLSrc + 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 --- .../serviceoffercontroller/offerbundle.go | 72 ++----------------- .../templates/offer_landing.html | 64 +++++++++++++++++ 2 files changed, 71 insertions(+), 65 deletions(-) create mode 100644 internal/serviceoffercontroller/templates/offer_landing.html diff --git a/internal/serviceoffercontroller/offerbundle.go b/internal/serviceoffercontroller/offerbundle.go index 621eb053..63fc4854 100644 --- a/internal/serviceoffercontroller/offerbundle.go +++ b/internal/serviceoffercontroller/offerbundle.go @@ -1,6 +1,7 @@ package serviceoffercontroller import ( + _ "embed" "encoding/json" "fmt" "html/template" @@ -265,71 +266,12 @@ func wellKnownAccept(p monetizeapi.ServiceOfferPayment) map[string]any { return entry } -var offerLandingTmpl = template.Must(template.New("offer_landing").Parse(` - - - - - {{.Title}} - - - - - - - {{if .OGImageURL}} - - {{end}} - {{if .FaviconURL}}{{end}} - - {{if .CustomCSS}}{{end}} - - -
- {{if .LogoURL}}
{{.Operator}}{{if .ShowName}}{{.Operator}}{{end}}
{{end}} - {{.Price}} -

{{.Title}}

-
{{.DescriptionHTML}}
- -
-
-

For agents & developers

-

/openapi.json — request shapes + per-route pricing

-

/.well-known/x402 — signable x402 payment requirements

-

Payment is per-request via x402 micropayments: call an endpoint with no payment to receive the 402 challenge, sign one accepts[] entry, retry with the X-PAYMENT header.

-
- {{if .AboutHTML}} -
-

About {{.Operator}}

-
{{.AboutHTML}}
-
- {{end}} -

Sold by {{.Operator}} · Powered by Obol

-
- - -`)) +//go:embed templates/offer_landing.html +var offerLandingHTMLSrc string + +var offerLandingTmpl = template.Must( + template.New("offer_landing").Parse(offerLandingHTMLSrc), +) // buildOfferLandingHTML renders the hostname's "/" landing page: enough for // a human to understand the product and for previews/scrapers to get sane diff --git a/internal/serviceoffercontroller/templates/offer_landing.html b/internal/serviceoffercontroller/templates/offer_landing.html new file mode 100644 index 00000000..25d5968e --- /dev/null +++ b/internal/serviceoffercontroller/templates/offer_landing.html @@ -0,0 +1,64 @@ + + + + + + {{.Title}} + + + + + + + {{if .OGImageURL}} + + {{end}} + {{if .FaviconURL}}{{end}} + + {{if .CustomCSS}}{{end}} + + +
+ {{if .LogoURL}}
{{.Operator}}{{if .ShowName}}{{.Operator}}{{end}}
{{end}} + {{.Price}} +

{{.Title}}

+
{{.DescriptionHTML}}
+ +
+
+

For agents & developers

+

/openapi.json — request shapes + per-route pricing

+

/.well-known/x402 — signable x402 payment requirements

+

Payment is per-request via x402 micropayments: call an endpoint with no payment to receive the 402 challenge, sign one accepts[] entry, retry with the X-PAYMENT header.

+
+ {{if .AboutHTML}} +
+

About {{.Operator}}

+
{{.AboutHTML}}
+
+ {{end}} +

Sold by {{.Operator}} · Powered by Obol

+
+ + From 06d13b4486d25598ac20b22f5718a217a3d123d9 Mon Sep 17 00:00:00 2001 From: bussyjd Date: Wed, 15 Jul 2026 14:29:17 +0400 Subject: [PATCH 3/3] docs(x402): fix a token-sync contract that described a mirror that is 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 --- internal/x402/templates/DESIGN.md | 93 ++++++++++++++++++++++--------- 1 file changed, 68 insertions(+), 25 deletions(-) diff --git a/internal/x402/templates/DESIGN.md b/internal/x402/templates/DESIGN.md index 6b000df0..6b4362ed 100644 --- a/internal/x402/templates/DESIGN.md +++ b/internal/x402/templates/DESIGN.md @@ -9,8 +9,32 @@ Canonical design system: [`@obolnetwork/obol-ui`](../../../../obol-packages/packages/obol-ui/DESIGN.md). Sister surface that this page is closest to: [`obol-stack/web/public-storefront`](../../../web/public-storefront/DESIGN.md). -This page is deliberately the **smallest, most constrained** of the four -surfaces. + +## 0. The five public surfaces + +Each is identified by a `data-obol="page-*"` marker on its root element — +the stable hook custom CSS targets. All but the storefront are Go-rendered. + +| Marker | Surface | Rendered by | +| --- | --- | --- | +| `page-storefront` | Catalog storefront — lists every offer | `web/public-storefront` (Next.js) | +| `page-landing` | Per-offer landing, on the offer's own hostname | `internal/serviceoffercontroller/offerbundle.go` | +| `page-402` | **This page** — the payment gate | `internal/x402/paymentrequired.go` | +| `page-signin` | SIWX challenge | `internal/x402/authgate.go` | +| `page-error` | Auth/verifier error | `internal/x402/authgate.go` | + +Two distinctions worth holding onto, because both are easy to get backwards: + +- **`page-402` is not the storefront and not the landing page.** It is served + on a *paid resource path*, and only when `Accept` advertises `text/html` + (`prefersHTML`, `paymentrequired.go`); everything else gets the JSON + challenge. For a root-priced offer the paid resource is `POST /`, so a + browser visiting that origin's `/` gets `page-landing`, never this page. +- **`data-obol="checkout"` is not this page.** It is a reserved mount div that + appears on *both* `page-402` and `page-landing`. Custom CSS targeting it + hits two surfaces at once. + +This page is deliberately the **smallest, most constrained** of the five. --- @@ -37,24 +61,30 @@ buttons. --- -## 2. Brand contract (mirrored) +## 2. Brand contract (server-resolved) Same palette, type, and shape language as [`obol-ui/DESIGN.md § 1`](../../../../obol-packages/packages/obol-ui/DESIGN.md#1-brand-contract). -Token values are duplicated inline at the top of the `