From 5fa4b8d83bcbcaab607245e8b5efa443b1a0ad20 Mon Sep 17 00:00:00 2001 From: HananINouman Date: Tue, 23 Jun 2026 21:53:30 +0300 Subject: [PATCH 1/5] feat(sell): add storefront branding profile and /api/storefront.json Let operators set display name, tagline, and logo via `obol sell storefront` independent of ServiceOffers. Controller publishes /api/storefront.json; the public storefront consumes it for header, metadata, OG image, and favicon. Co-authored-by: Cursor --- .envrc.local.example | 11 +- cmd/obol/sell.go | 1 + cmd/obol/sell_storefront.go | 235 ++++++++++++++++++ internal/schemas/service_catalog.go | 8 + internal/serviceoffercontroller/controller.go | 71 +++++- .../serviceoffercontroller/identity_render.go | 9 + .../identity_render_test.go | 39 +++ internal/serviceoffercontroller/render.go | 59 ++++- .../render_builders_test.go | 12 +- internal/storefront/profile.go | 128 ++++++++++ internal/storefront/profile_test.go | 62 +++++ web/public-storefront/next.config.ts | 17 ++ web/public-storefront/src/app/layout.tsx | 91 ++++--- web/public-storefront/src/app/manifest.ts | 23 +- .../src/app/opengraph-image.tsx | 53 +++- web/public-storefront/src/app/page.tsx | 33 +-- .../src/components/Header.tsx | 37 ++- web/public-storefront/src/lib/catalog.ts | 53 ++++ web/public-storefront/src/types.ts | 6 + 19 files changed, 833 insertions(+), 115 deletions(-) create mode 100644 cmd/obol/sell_storefront.go create mode 100644 internal/storefront/profile.go create mode 100644 internal/storefront/profile_test.go create mode 100644 web/public-storefront/src/lib/catalog.ts diff --git a/.envrc.local.example b/.envrc.local.example index a6a08b54..2cbf9221 100644 --- a/.envrc.local.example +++ b/.envrc.local.example @@ -1,12 +1,17 @@ # Local environment overrides (copy to .envrc.local) # This file is for local development customizations and is gitignored +# Recommended when your stack lives in ~/.config/obol but you build obol from this repo: +# export OBOL_CONFIG_DIR="${HOME}/.config/obol" +# export OBOL_BIN_DIR="${PWD}/.workspace/bin" +# PATH_add .workspace/bin +# +# Without OBOL_CONFIG_DIR, OBOL_DEVELOPMENT=true uses .workspace/config and may +# create a second k3d cluster (separate stack ID from your real install). + # Example: Override development mode # export OBOL_DEVELOPMENT=false -# Example: Add workspace bin to PATH -# PATH_add .workspace/bin - # Example: Override config directories # export OBOL_CONFIG_DIR=/custom/config/path # export OBOL_BIN_DIR=/custom/bin/path diff --git a/cmd/obol/sell.go b/cmd/obol/sell.go index 2ea01bba..c9e186d7 100644 --- a/cmd/obol/sell.go +++ b/cmd/obol/sell.go @@ -63,6 +63,7 @@ func sellCommand(cfg *config.Config) *cli.Command { sellPricingCommand(cfg), sellRegisterCommand(cfg), sellIdentityCommand(cfg), + sellStorefrontCommand(cfg), sellInfoCommand(cfg), sellResumeCommand(cfg), }, diff --git a/cmd/obol/sell_storefront.go b/cmd/obol/sell_storefront.go new file mode 100644 index 00000000..0202bbb1 --- /dev/null +++ b/cmd/obol/sell_storefront.go @@ -0,0 +1,235 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/ObolNetwork/obol-stack/internal/config" + "github.com/ObolNetwork/obol-stack/internal/kubectl" + "github.com/ObolNetwork/obol-stack/internal/schemas" + "github.com/ObolNetwork/obol-stack/internal/storefront" + "github.com/ObolNetwork/obol-stack/internal/tunnel" + "github.com/ObolNetwork/obol-stack/internal/ui" + "github.com/urfave/cli/v3" +) + +func sellStorefrontCommand(cfg *config.Config) *cli.Command { + return &cli.Command{ + Name: "storefront", + Usage: "Configure public storefront branding (name, tagline, logo)", + Description: `Sets the seller-wide storefront profile served at /api/storefront.json. +This is independent of individual ServiceOffers and ERC-8004 identity. + +Examples: + obol sell storefront set --display-name "Acme Labs" --tagline "Paid APIs." --logo-url "https://acme.example/logo.png" + obol sell storefront show + obol sell storefront reset`, + Commands: []*cli.Command{ + sellStorefrontSetCommand(cfg), + sellStorefrontShowCommand(cfg), + sellStorefrontResetCommand(cfg), + }, + } +} + +func sellStorefrontSetCommand(cfg *config.Config) *cli.Command { + return &cli.Command{ + Name: "set", + Usage: "Set storefront display name, tagline, and/or logo URL", + Flags: []cli.Flag{ + &cli.StringFlag{Name: "display-name", Usage: "Storefront title shown in the header and page hero"}, + &cli.StringFlag{Name: "tagline", Usage: "Short subtitle under the storefront title"}, + &cli.StringFlag{Name: "logo-url", Usage: "Logo image URL (https://... or /path on this host)"}, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + u := getUI(cmd) + if err := kubectl.EnsureCluster(cfg); err != nil { + return err + } + + patch := schemas.StorefrontProfile{ + DisplayName: strings.TrimSpace(cmd.String("display-name")), + Tagline: strings.TrimSpace(cmd.String("tagline")), + LogoURL: strings.TrimSpace(cmd.String("logo-url")), + } + if patch.DisplayName == "" && patch.Tagline == "" && patch.LogoURL == "" { + return errors.New("pass at least one of --display-name, --tagline, --logo-url") + } + if err := storefront.ValidateLogoURL(patch.LogoURL); err != nil { + return err + } + + current, err := loadStorefrontProfile(cfg) + if err != nil { + return err + } + merged := storefront.MergeProfile(current, patch) + if err := applyStorefrontProfile(cfg, merged); err != nil { + return err + } + + published, err := waitForPublishedStorefront(cfg, &merged, 45*time.Second) + if err != nil { + return err + } + + u.Success("Storefront profile updated") + printStorefrontProfile(u, published) + u.Blank() + u.Dim("Verify: curl -s http://obol.stack:8080/api/storefront.json | jq .") + return nil + }, + } +} + +func sellStorefrontShowCommand(cfg *config.Config) *cli.Command { + return &cli.Command{ + Name: "show", + Usage: "Show the current storefront profile", + Flags: []cli.Flag{ + &cli.BoolFlag{Name: "json", Aliases: []string{"j"}, Usage: "Output as JSON"}, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + u := getUI(cmd) + if err := kubectl.EnsureCluster(cfg); err != nil { + return err + } + profile, err := loadStorefrontProfile(cfg) + if err != nil { + return err + } + baseURL, _ := storefrontBaseURL(cfg) + published := storefront.ResolvePublished(&profile, baseURL) + if u.IsJSON() || cmd.Bool("json") { + return u.JSON(published) + } + printStorefrontProfile(u, published) + return nil + }, + } +} + +func sellStorefrontResetCommand(cfg *config.Config) *cli.Command { + return &cli.Command{ + Name: "reset", + Usage: "Remove custom storefront branding and restore defaults", + Action: func(ctx context.Context, cmd *cli.Command) error { + u := getUI(cmd) + if err := kubectl.EnsureCluster(cfg); err != nil { + return err + } + bin, kc := kubectl.Paths(cfg) + if err := kubectl.RunSilent(bin, kc, "delete", "configmap", storefront.ProfileConfigMap, + "-n", storefront.ProfileNamespace, "--ignore-not-found"); err != nil { + return fmt.Errorf("delete storefront profile: %w", err) + } + _ = os.Remove(storefront.ProfileLocalPath(cfg)) + + published, err := waitForPublishedStorefront(cfg, nil, 45*time.Second) + if err != nil { + return err + } + + u.Success("Storefront profile reset to defaults") + printStorefrontProfile(u, published) + return nil + }, + } +} + +func loadStorefrontProfile(cfg *config.Config) (schemas.StorefrontProfile, error) { + if raw, err := kubectlOutput(cfg, "get", "configmap", storefront.ProfileConfigMap, + "-n", storefront.ProfileNamespace, "-o", "jsonpath={.data."+storefront.ProfileDataKey+"}"); err == nil { + if p, err := storefront.ParseProfile(raw); err != nil { + return schemas.StorefrontProfile{}, err + } else if p != nil { + return *p, nil + } + } + if data, err := os.ReadFile(storefront.ProfileLocalPath(cfg)); err == nil { + if p, err := storefront.ParseProfile(string(data)); err != nil { + return schemas.StorefrontProfile{}, err + } else if p != nil { + return *p, nil + } + } + return schemas.StorefrontProfile{}, nil +} + +func applyStorefrontProfile(cfg *config.Config, profile schemas.StorefrontProfile) error { + if err := os.MkdirAll(filepath.Dir(storefront.ProfileLocalPath(cfg)), 0o700); err != nil { + return err + } + payload, err := storefront.MarshalProfile(profile) + if err != nil { + return err + } + if err := os.WriteFile(storefront.ProfileLocalPath(cfg), []byte(payload), 0o600); err != nil { + return err + } + manifest, err := storefront.ConfigMapManifest(profile) + if err != nil { + return err + } + if err := kubectlApply(cfg, manifest); err != nil { + return fmt.Errorf("apply storefront profile: %w", err) + } + return nil +} + +func waitForPublishedStorefront(cfg *config.Config, explicit *schemas.StorefrontProfile, timeout time.Duration) (schemas.StorefrontProfile, error) { + want := storefront.ResolvePublished(explicit, mustStorefrontBaseURL(cfg)) + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + raw, err := kubectlOutput(cfg, "get", "configmap", "obol-skill-md", + "-n", storefront.ProfileNamespace, "-o", "jsonpath={.data.storefront\\.json}") + if err == nil && strings.TrimSpace(raw) != "" { + var got schemas.StorefrontProfile + if err := json.Unmarshal([]byte(raw), &got); err == nil && storefrontProfilesEqual(got, want) { + return got, nil + } + } + time.Sleep(2 * time.Second) + } + return want, fmt.Errorf("timed out waiting for controller to publish /api/storefront.json") +} + +func storefrontProfilesEqual(a, b schemas.StorefrontProfile) bool { + return strings.TrimSpace(a.DisplayName) == strings.TrimSpace(b.DisplayName) && + strings.TrimSpace(a.Tagline) == strings.TrimSpace(b.Tagline) && + strings.TrimSpace(a.LogoURL) == strings.TrimSpace(b.LogoURL) +} + +func storefrontBaseURL(cfg *config.Config) (string, error) { + st, err := tunnel.LoadTunnelState(cfg) + if err != nil { + return "", err + } + if st != nil && strings.TrimSpace(st.Hostname) != "" { + return "https://" + strings.TrimSpace(st.Hostname), nil + } + if url, err := tunnel.GetTunnelURL(cfg); err == nil && strings.TrimSpace(url) != "" { + return strings.TrimRight(strings.TrimSpace(url), "/"), nil + } + return "http://obol.stack:8080", nil +} + +func mustStorefrontBaseURL(cfg *config.Config) string { + baseURL, err := storefrontBaseURL(cfg) + if err != nil || baseURL == "" { + return "http://obol.stack:8080" + } + return baseURL +} + +func printStorefrontProfile(u *ui.UI, profile schemas.StorefrontProfile) { + u.Printf(" Display name: %s", profile.DisplayName) + u.Printf(" Tagline: %s", profile.Tagline) + u.Printf(" Logo URL: %s", profile.LogoURL) +} diff --git a/internal/schemas/service_catalog.go b/internal/schemas/service_catalog.go index e0ebb3de..d2282267 100644 --- a/internal/schemas/service_catalog.go +++ b/internal/schemas/service_catalog.go @@ -102,3 +102,11 @@ type ServiceCatalogEIP712Domain struct { Name string `json:"name"` Version string `json:"version"` } + +// StorefrontProfile is the minimal branding payload served at +// /api/storefront.json for the public storefront. +type StorefrontProfile struct { + DisplayName string `json:"displayName"` + Tagline string `json:"tagline"` + LogoURL string `json:"logoUrl"` +} diff --git a/internal/serviceoffercontroller/controller.go b/internal/serviceoffercontroller/controller.go index 3c0c26e5..14a2d7c6 100644 --- a/internal/serviceoffercontroller/controller.go +++ b/internal/serviceoffercontroller/controller.go @@ -16,6 +16,8 @@ import ( "github.com/ObolNetwork/obol-stack/internal/erc8004" "github.com/ObolNetwork/obol-stack/internal/monetizeapi" + "github.com/ObolNetwork/obol-stack/internal/schemas" + "github.com/ObolNetwork/obol-stack/internal/storefront" "github.com/ethereum/go-ethereum/common" "k8s.io/apimachinery/pkg/api/equality" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -308,18 +310,41 @@ func (c *Controller) enqueueDiscoveryRefresh(obj any) { if u == nil { return } - if u.GetNamespace() != "obol-frontend" || u.GetName() != "obol-stack-config" { + ns, name := u.GetNamespace(), u.GetName() + if ns == "obol-frontend" && name == "obol-stack-config" { + log.Printf("serviceoffer-controller: base URL change detected, refreshing offers and registration requests") + for _, item := range c.offerInformer.GetStore().List() { + c.enqueueOffer(item) + } + for _, item := range c.registrationInformer.GetStore().List() { + c.enqueueRegistration(item) + } + for _, item := range c.identityInformer.GetStore().List() { + c.enqueueIdentity(item) + } return } - log.Printf("serviceoffer-controller: base URL change detected, refreshing offers and registration requests") - for _, item := range c.offerInformer.GetStore().List() { - c.enqueueOffer(item) + if ns == storefront.ProfileNamespace && name == storefront.ProfileConfigMap { + log.Printf("serviceoffer-controller: storefront profile change detected, refreshing skill catalog") + c.enqueueSkillCatalogRefresh() } - for _, item := range c.registrationInformer.GetStore().List() { - c.enqueueRegistration(item) +} + +func (c *Controller) enqueueSkillCatalogRefresh() { + items := c.offerInformer.GetStore().List() + if len(items) > 0 { + // Any single offer reconcile rebuilds the full catalog (including storefront.json). + c.enqueueOffer(items[0]) + return } - for _, item := range c.identityInformer.GetStore().List() { - c.enqueueIdentity(item) + go c.refreshSkillCatalogAsync() +} + +func (c *Controller) refreshSkillCatalogAsync() { + 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) } } @@ -1116,6 +1141,24 @@ func (c *Controller) reconcileRegistrationTombstone(ctx context.Context, raw *un return c.updateRegistrationStatus(ctx, raw, status) } +func (c *Controller) loadStorefrontProfile(ctx context.Context) (*schemas.StorefrontProfile, error) { + cm, err := c.configMaps.Namespace(skillCatalogNamespace).Get(ctx, storefront.ProfileConfigMap, metav1.GetOptions{}) + if err != nil { + if apierrors.IsNotFound(err) { + return nil, nil + } + return nil, err + } + raw, found, err := unstructured.NestedString(cm.Object, "data", storefront.ProfileDataKey) + if err != nil { + return nil, err + } + if !found { + return nil, nil + } + return storefront.ParseProfile(raw) +} + // reconcileSkillCatalog rebuilds the /skill.md ConfigMap/Deployment/Service/ // HTTPRoute from the current set of Ready ServiceOffers. If `override` is // non-nil, that offer replaces (or is appended to) the informer-cached copy @@ -1158,6 +1201,11 @@ func (c *Controller) reconcileSkillCatalog(ctx context.Context, override *moneti content := buildSkillCatalogMarkdown(offers, baseURL) servicesJSON := buildServiceCatalogJSON(offers, baseURL) + storefrontProfile, err := c.loadStorefrontProfile(ctx) + if err != nil { + return err + } + storefrontJSON := buildStorefrontJSON(baseURL, storefrontProfile) // buildOpenAPIDocument prefers the tunnel URL for the public `servers[0]` // entry; baseURL is sourced from obol-stack-config.tunnelURL via // registrationBaseURL, which is also what /skill.md and services.json @@ -1166,9 +1214,9 @@ func (c *Controller) reconcileSkillCatalog(ctx context.Context, override *moneti // when tunnelURL changes — see enqueueDiscoveryRefresh). openAPIJSON := buildOpenAPIDocument(offers, baseURL) apiDocsHTML := scalarHTML() - contentHash := fmt.Sprintf("%x", md5Sum(content+servicesJSON+openAPIJSON+apiDocsHTML))[:8] + contentHash := fmt.Sprintf("%x", md5Sum(content+servicesJSON+storefrontJSON+openAPIJSON+apiDocsHTML))[:8] - if err := c.applyObject(ctx, c.configMaps.Namespace(skillCatalogNamespace), buildSkillCatalogConfigMap(content, servicesJSON, openAPIJSON, apiDocsHTML)); err != nil { + if err := c.applyObject(ctx, c.configMaps.Namespace(skillCatalogNamespace), buildSkillCatalogConfigMap(content, servicesJSON, storefrontJSON, openAPIJSON, apiDocsHTML)); err != nil { return err } if err := c.applyObject(ctx, c.deployments.Namespace(skillCatalogNamespace), buildSkillCatalogDeployment(contentHash)); err != nil { @@ -1183,6 +1231,9 @@ func (c *Controller) reconcileSkillCatalog(ctx context.Context, override *moneti if err := c.applyObject(ctx, c.httpRoutes.Namespace(skillCatalogNamespace), buildServicesJSONHTTPRoute()); err != nil { return err } + if err := c.applyObject(ctx, c.httpRoutes.Namespace(skillCatalogNamespace), buildStorefrontJSONHTTPRoute()); err != nil { + return err + } if err := c.applyObject(ctx, c.httpRoutes.Namespace(skillCatalogNamespace), buildOpenAPIHTTPRoute()); err != nil { return err } diff --git a/internal/serviceoffercontroller/identity_render.go b/internal/serviceoffercontroller/identity_render.go index 89623d36..364d50ec 100644 --- a/internal/serviceoffercontroller/identity_render.go +++ b/internal/serviceoffercontroller/identity_render.go @@ -1,12 +1,15 @@ package serviceoffercontroller import ( + "encoding/json" "fmt" "sort" "strings" "github.com/ObolNetwork/obol-stack/internal/erc8004" "github.com/ObolNetwork/obol-stack/internal/monetizeapi" + "github.com/ObolNetwork/obol-stack/internal/schemas" + "github.com/ObolNetwork/obol-stack/internal/storefront" ) // IdentityRegistrationView is the identity-driven inputs needed to render an @@ -101,6 +104,12 @@ func identityDocumentMetadata(identity *monetizeapi.AgentIdentity, offers []*mon return name, description, image } +func buildStorefrontJSON(baseURL string, explicit *schemas.StorefrontProfile) string { + profile := storefront.ResolvePublished(explicit, baseURL) + out, _ := json.MarshalIndent(profile, "", " ") + return string(out) +} + func buildIdentityOnChainRegistrations(identity *monetizeapi.AgentIdentity) []erc8004.OnChainReg { if identity == nil { return nil diff --git a/internal/serviceoffercontroller/identity_render_test.go b/internal/serviceoffercontroller/identity_render_test.go index 1e8fd2b9..bcad6462 100644 --- a/internal/serviceoffercontroller/identity_render_test.go +++ b/internal/serviceoffercontroller/identity_render_test.go @@ -1,11 +1,13 @@ package serviceoffercontroller import ( + "encoding/json" "testing" "time" "github.com/ObolNetwork/obol-stack/internal/erc8004" "github.com/ObolNetwork/obol-stack/internal/monetizeapi" + "github.com/ObolNetwork/obol-stack/internal/schemas" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -248,3 +250,40 @@ func TestBuildIdentityRegistrationDocument_DescriptionPrecedence(t *testing.T) { }) } } + +func TestBuildStorefrontJSON_UsesExplicitProfile(t *testing.T) { + explicit := &schemas.StorefrontProfile{ + DisplayName: "Acme Inference", + Tagline: "Custom seller tagline", + LogoURL: "https://cdn.example/logo.png", + } + + jsonStr := buildStorefrontJSON("https://seller.example", explicit) + var profile schemas.StorefrontProfile + if err := json.Unmarshal([]byte(jsonStr), &profile); err != nil { + t.Fatalf("unmarshal storefront profile: %v", err) + } + if profile.DisplayName != "Acme Inference" { + t.Fatalf("DisplayName = %q", profile.DisplayName) + } + if profile.Tagline != "Custom seller tagline" { + t.Fatalf("Tagline = %q", profile.Tagline) + } + if profile.LogoURL != "https://cdn.example/logo.png" { + t.Fatalf("LogoURL = %q", profile.LogoURL) + } +} + +func TestBuildStorefrontJSON_Defaults(t *testing.T) { + jsonStr := buildStorefrontJSON("https://seller.example", nil) + var profile schemas.StorefrontProfile + if err := json.Unmarshal([]byte(jsonStr), &profile); err != nil { + t.Fatalf("unmarshal storefront profile: %v", err) + } + if profile.DisplayName != "Obol Stack" { + t.Fatalf("DisplayName = %q", profile.DisplayName) + } + if profile.Tagline == "" { + t.Fatal("Tagline empty") + } +} diff --git a/internal/serviceoffercontroller/render.go b/internal/serviceoffercontroller/render.go index 8481b077..fb4c21ab 100644 --- a/internal/serviceoffercontroller/render.go +++ b/internal/serviceoffercontroller/render.go @@ -24,6 +24,7 @@ const ( skillCatalogConfigMapName = "obol-skill-md" skillCatalogRouteName = "obol-skill-md-route" servicesJSONRouteName = "obol-services-json-route" + storefrontJSONRouteName = "obol-storefront-json-route" openAPIRouteName = "obol-openapi-route" apiDocsRouteName = "obol-api-docs-route" ) @@ -247,7 +248,7 @@ func agentIdentityLabels(identity *monetizeapi.AgentIdentity, appName string) ma } } -func buildSkillCatalogConfigMap(content, servicesJSON, openAPIJSON, apiDocsHTML string) *unstructured.Unstructured { +func buildSkillCatalogConfigMap(content, servicesJSON, storefrontJSON, openAPIJSON, apiDocsHTML string) *unstructured.Unstructured { return &unstructured.Unstructured{ Object: map[string]any{ "apiVersion": "v1", @@ -261,11 +262,12 @@ func buildSkillCatalogConfigMap(content, servicesJSON, openAPIJSON, apiDocsHTML }, }, "data": map[string]any{ - "skill.md": content, - "services.json": servicesJSON, - "openapi.json": openAPIJSON, - "api.html": apiDocsHTML, - "httpd.conf": ".md:text/markdown\n.json:application/json\n.html:text/html\n", + "skill.md": content, + "services.json": servicesJSON, + "storefront.json": storefrontJSON, + "openapi.json": openAPIJSON, + "api.html": apiDocsHTML, + "httpd.conf": ".md:text/markdown\n.json:application/json\n.html:text/html\n", }, }, } @@ -326,6 +328,7 @@ func buildSkillCatalogDeployment(contentHash string) *unstructured.Unstructured "items": []any{ map[string]any{"key": "skill.md", "path": "skill.md"}, map[string]any{"key": "services.json", "path": "api/services.json"}, + map[string]any{"key": "storefront.json", "path": "api/storefront.json"}, map[string]any{"key": "openapi.json", "path": "openapi.json"}, // busybox httpd resolves /api/ → /api/index.html, so the // Scalar shell sits at api/index.html. The /api Exact @@ -576,6 +579,50 @@ func buildServicesJSONHTTPRoute() *unstructured.Unstructured { } } +func buildStorefrontJSONHTTPRoute() *unstructured.Unstructured { + return &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "gateway.networking.k8s.io/v1", + "kind": "HTTPRoute", + "metadata": map[string]any{ + "name": storefrontJSONRouteName, + "namespace": skillCatalogNamespace, + "labels": map[string]any{ + "obol.org/managed-by": "serviceoffer-controller", + }, + }, + "spec": map[string]any{ + "parentRefs": []any{ + map[string]any{ + "name": "traefik-gateway", + "namespace": "traefik", + "sectionName": "web", + }, + }, + "rules": []any{ + map[string]any{ + "matches": []any{ + map[string]any{ + "path": map[string]any{ + "type": "Exact", + "value": "/api/storefront.json", + }, + }, + }, + "backendRefs": []any{ + map[string]any{ + "name": skillCatalogConfigMapName, + "namespace": skillCatalogNamespace, + "port": int64(8080), + }, + }, + }, + }, + }, + }, + } +} + func buildHTTPRoute(offer *monetizeapi.ServiceOffer) *unstructured.Unstructured { obj := &unstructured.Unstructured{ Object: map[string]any{ diff --git a/internal/serviceoffercontroller/render_builders_test.go b/internal/serviceoffercontroller/render_builders_test.go index 5c3bf105..c08ea936 100644 --- a/internal/serviceoffercontroller/render_builders_test.go +++ b/internal/serviceoffercontroller/render_builders_test.go @@ -103,7 +103,7 @@ func TestBuildAgentIdentityRegistrationDeployment_RestrictedPSS(t *testing.T) { // TestBuildSkillCatalogConfigMap: exposes skill.md + services.json + openapi.json // + api docs HTML + httpd conf. func TestBuildSkillCatalogConfigMap(t *testing.T) { - cm := buildSkillCatalogConfigMap("# Catalog", `[{"name":"a"}]`, `{"openapi":"3.1.0"}`, "shell") + cm := buildSkillCatalogConfigMap("# Catalog", `[{"name":"a"}]`, `{"displayName":"Acme"}`, `{"openapi":"3.1.0"}`, "shell") if cm.GetName() != skillCatalogConfigMapName { t.Errorf("name = %q, want %q", cm.GetName(), skillCatalogConfigMapName) @@ -118,6 +118,9 @@ func TestBuildSkillCatalogConfigMap(t *testing.T) { if data["services.json"] != `[{"name":"a"}]` { t.Errorf("services.json payload mismatch, got %v", data["services.json"]) } + if data["storefront.json"] != `{"displayName":"Acme"}` { + t.Errorf("storefront.json payload mismatch, got %v", data["storefront.json"]) + } if data["openapi.json"] != `{"openapi":"3.1.0"}` { t.Errorf("openapi.json payload mismatch, got %v", data["openapi.json"]) } @@ -162,9 +165,10 @@ func TestBuildSkillCatalogDeployment(t *testing.T) { podSpec, _ := template1["spec"].(map[string]any) volumes, _ := podSpec["volumes"].([]any) expectedPaths := map[string]string{ - "services.json": "api/services.json", - "openapi.json": "openapi.json", - "api.html": "api/index.html", + "services.json": "api/services.json", + "storefront.json": "api/storefront.json", + "openapi.json": "openapi.json", + "api.html": "api/index.html", } foundPaths := map[string]string{} for _, v := range volumes { diff --git a/internal/storefront/profile.go b/internal/storefront/profile.go new file mode 100644 index 00000000..c85396c9 --- /dev/null +++ b/internal/storefront/profile.go @@ -0,0 +1,128 @@ +package storefront + +import ( + "encoding/json" + "fmt" + "path/filepath" + "strings" + + "github.com/ObolNetwork/obol-stack/internal/config" + "github.com/ObolNetwork/obol-stack/internal/schemas" +) + +const ( + ProfileNamespace = "x402" + ProfileConfigMap = "obol-storefront-profile" + ProfileDataKey = "profile.json" + DefaultLogoPath = "/obol-stack-logo.png" + profileLocalRelPath = "storefront/profile.json" +) + +// ResolvePublished merges an operator-set profile over stack defaults. +func ResolvePublished(explicit *schemas.StorefrontProfile, baseURL string) schemas.StorefrontProfile { + baseURL = strings.TrimRight(baseURL, "/") + profile := schemas.StorefrontProfile{ + DisplayName: "Obol Stack", + Tagline: "Unlock Agent and API services with digital payments.", + LogoURL: baseURL + DefaultLogoPath, + } + if explicit == nil { + return profile + } + if v := strings.TrimSpace(explicit.DisplayName); v != "" { + profile.DisplayName = v + } + if v := strings.TrimSpace(explicit.Tagline); v != "" { + profile.Tagline = v + } + if v := strings.TrimSpace(explicit.LogoURL); v != "" { + profile.LogoURL = v + } + return profile +} + +// MarshalProfile serialises a profile for ConfigMap storage. +func MarshalProfile(p schemas.StorefrontProfile) (string, error) { + data, err := json.MarshalIndent(p, "", " ") + if err != nil { + return "", err + } + return string(data), nil +} + +// ParseProfile decodes profile JSON from a ConfigMap data key. +func ParseProfile(raw string) (*schemas.StorefrontProfile, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + var p schemas.StorefrontProfile + if err := json.Unmarshal([]byte(raw), &p); err != nil { + return nil, err + } + return &p, nil +} + +// MergeProfile overlays non-empty patch fields onto base. +func MergeProfile(base, patch schemas.StorefrontProfile) schemas.StorefrontProfile { + out := base + if v := strings.TrimSpace(patch.DisplayName); v != "" { + out.DisplayName = v + } + if v := strings.TrimSpace(patch.Tagline); v != "" { + out.Tagline = v + } + if v := strings.TrimSpace(patch.LogoURL); v != "" { + out.LogoURL = v + } + return out +} + +// ProfileLocalPath is the host-side record of the operator profile. +func ProfileLocalPath(cfg *config.Config) string { + return filepath.Join(cfg.ConfigDir, profileLocalRelPath) +} + +// ValidateLogoURL accepts absolute http(s) URLs or site-relative paths. +func ValidateLogoURL(raw string) error { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + if strings.HasPrefix(raw, "/") { + return nil + } + if strings.HasPrefix(raw, "https://") || strings.HasPrefix(raw, "http://") { + return nil + } + return fmt.Errorf("logo URL must be https://..., http://..., or a path starting with /") +} + +// IsDefaultLogoURL reports whether url is the stack default wordmark (relative or absolute). +func IsDefaultLogoURL(raw string) bool { + raw = strings.TrimSpace(raw) + return raw == DefaultLogoPath || strings.HasSuffix(raw, DefaultLogoPath) +} + +// ConfigMapManifest returns a kubectl-applyable ConfigMap for the profile. +func ConfigMapManifest(p schemas.StorefrontProfile) (map[string]any, error) { + payload, err := MarshalProfile(p) + if err != nil { + return nil, err + } + return map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{ + "name": ProfileConfigMap, + "namespace": ProfileNamespace, + "labels": map[string]any{ + "app": ProfileConfigMap, + "obol.org/managed-by": "obol-cli", + }, + }, + "data": map[string]any{ + ProfileDataKey: payload, + }, + }, nil +} diff --git a/internal/storefront/profile_test.go b/internal/storefront/profile_test.go new file mode 100644 index 00000000..4117b168 --- /dev/null +++ b/internal/storefront/profile_test.go @@ -0,0 +1,62 @@ +package storefront_test + +import ( + "testing" + + "github.com/ObolNetwork/obol-stack/internal/schemas" + "github.com/ObolNetwork/obol-stack/internal/storefront" +) + +func TestResolvePublished_ExplicitOverridesDefaults(t *testing.T) { + explicit := &schemas.StorefrontProfile{ + DisplayName: "Acme", + Tagline: "Paid APIs", + LogoURL: "https://acme.example/logo.png", + } + got := storefront.ResolvePublished(explicit, "https://seller.example") + if got.DisplayName != "Acme" || got.Tagline != "Paid APIs" || got.LogoURL != "https://acme.example/logo.png" { + t.Fatalf("unexpected profile: %+v", got) + } +} + +func TestMergeProfile_PartialUpdate(t *testing.T) { + base := schemas.StorefrontProfile{DisplayName: "Acme", Tagline: "Old", LogoURL: "https://a/logo.png"} + got := storefront.MergeProfile(base, schemas.StorefrontProfile{Tagline: "New"}) + if got.DisplayName != "Acme" || got.Tagline != "New" || got.LogoURL != "https://a/logo.png" { + t.Fatalf("unexpected merge: %+v", got) + } +} + +func TestValidateLogoURL(t *testing.T) { + for _, tc := range []struct { + raw string + ok bool + }{ + {"https://cdn.example/logo.png", true}, + {"/obol-stack-logo.png", true}, + {"logo.png", false}, + } { + err := storefront.ValidateLogoURL(tc.raw) + if tc.ok && err != nil { + t.Fatalf("%q: %v", tc.raw, err) + } + if !tc.ok && err == nil { + t.Fatalf("%q: expected error", tc.raw) + } + } +} + +func TestIsDefaultLogoURL(t *testing.T) { + for _, tc := range []struct { + raw string + deflt bool + }{ + {"/obol-stack-logo.png", true}, + {"https://seller.example/obol-stack-logo.png", true}, + {"https://cdn.example/logo.png", false}, + } { + if got := storefront.IsDefaultLogoURL(tc.raw); got != tc.deflt { + t.Fatalf("IsDefaultLogoURL(%q) = %v, want %v", tc.raw, got, tc.deflt) + } + } +} diff --git a/web/public-storefront/next.config.ts b/web/public-storefront/next.config.ts index 68a6c64d..987291af 100644 --- a/web/public-storefront/next.config.ts +++ b/web/public-storefront/next.config.ts @@ -1,7 +1,24 @@ import type { NextConfig } from "next"; +const servicesURL = + process.env.SERVICES_URL ?? "http://obol-skill-md.x402.svc:8080"; + const nextConfig: NextConfig = { output: "standalone", + // Local dev has no Traefik in front — proxy catalog JSON through Next so the + // client-side refresh in ServicesList hits the cluster via SERVICES_URL. + async rewrites() { + return [ + { + source: "/api/services.json", + destination: `${servicesURL}/api/services.json`, + }, + { + source: "/api/storefront.json", + destination: `${servicesURL}/api/storefront.json`, + }, + ]; + }, }; export default nextConfig; diff --git a/web/public-storefront/src/app/layout.tsx b/web/public-storefront/src/app/layout.tsx index 94dca4fd..243e13f9 100644 --- a/web/public-storefront/src/app/layout.tsx +++ b/web/public-storefront/src/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata, Viewport } from "next"; import { headers } from "next/headers"; import { DM_Sans } from "next/font/google"; import type { Service } from "@/types"; +import { fetchServices, fetchStorefront, isDefaultStorefrontLogo } from "@/lib/catalog"; import "./globals.css"; const dmSans = DM_Sans({ @@ -11,12 +12,8 @@ const dmSans = DM_Sans({ variable: "--font-dm-sans", }); -const SERVICES_URL = - process.env.SERVICES_URL ?? "http://obol-skill-md.x402.svc:8080"; - -const DEFAULT_TITLE = "Obol Stack — Buy agent services"; -const DEFAULT_DESCRIPTION = - "Purchase from specialised Agents and APIs with digital payments. On demand inference, agents, and HTTP services with OBOL and USDC support."; +const DEFAULT_TITLE = "Buy agent services"; +const DEFAULT_DESCRIPTION = "Unlock Agent and API services with digital payments."; // Derive the public site URL from the incoming request so OG/Twitter scrapers // see the tunnel hostname they hit (rather than the in-cluster default). Falls @@ -37,60 +34,66 @@ async function resolveSiteUrl(): Promise { return process.env.NEXT_PUBLIC_SITE_URL ?? "http://obol.stack:8080"; } -async function fetchServices(): Promise { - try { - const res = await fetch(`${SERVICES_URL}/api/services.json`, { - cache: "no-store", - }); - if (!res.ok) return []; - return res.json(); - } catch { - return []; - } -} - -function buildDynamicCopy(services: Service[]) { +function buildDynamicCopy(storefrontName: string, tagline: string, services: Service[]) { if (services.length === 0) { - return { title: DEFAULT_TITLE, description: DEFAULT_DESCRIPTION }; + return { + title: `${storefrontName} — ${DEFAULT_TITLE}`, + description: tagline || DEFAULT_DESCRIPTION, + }; } const total = services.length; const summary = `${total} service${total === 1 ? "" : "s"}`; - const title = `Obol Stack — ${summary} for sale`; + const title = `${storefrontName} — ${summary} for sale`; const sample = services .slice(0, 3) .map((s) => s.model ?? s.name) .filter(Boolean) .join(", "); const description = sample - ? `Buy agent services from this Obol Agent: ${sample}. Pay per call in USDC or OBOL on Base.` - : DEFAULT_DESCRIPTION; + ? `${tagline} ${sample}.` + : tagline || DEFAULT_DESCRIPTION; return { title, description }; } +function buildIcons(storefront: Awaited>) { + if (isDefaultStorefrontLogo(storefront.logoUrl)) { + return { + icon: [ + { url: "/icon-32.png", type: "image/png", sizes: "32x32" }, + { url: "/icon-16.png", type: "image/png", sizes: "16x16" }, + { url: "/favicon.png", type: "image/png", sizes: "256x256" }, + ], + apple: [{ url: "/apple-icon.png", sizes: "180x180" }], + }; + } + return { + icon: [{ url: storefront.logoUrl }], + apple: [{ url: storefront.logoUrl, sizes: "180x180" }], + }; +} + export async function generateMetadata(): Promise { - const [services, siteUrl] = await Promise.all([ + const [services, storefront, siteUrl] = await Promise.all([ fetchServices(), + fetchStorefront(), resolveSiteUrl(), ]); - const { title, description } = buildDynamicCopy(services); + const { title, description } = buildDynamicCopy( + storefront.displayName, + storefront.tagline, + services, + ); return { metadataBase: new URL(siteUrl), title, description, - applicationName: "Obol Stack", - icons: { - icon: [ - { url: "/icon-32.png", type: "image/png", sizes: "32x32" }, - { url: "/icon-16.png", type: "image/png", sizes: "16x16" }, - { url: "/favicon.png", type: "image/png", sizes: "256x256" }, - ], - apple: [{ url: "/apple-icon.png", sizes: "180x180" }], - }, + applicationName: storefront.displayName, + icons: buildIcons(storefront), manifest: "/manifest.webmanifest", openGraph: { type: "website", - siteName: "Obol Stack", + siteName: storefront.displayName, title, description, url: siteUrl, @@ -115,15 +118,19 @@ export const viewport: Viewport = { function JsonLd({ services, siteUrl, + storefrontName, + storefrontTagline, }: { services: Service[]; siteUrl: string; + storefrontName: string; + storefrontTagline: string; }) { const data = { "@context": "https://schema.org", "@type": "WebSite", - name: "Obol Stack", - description: DEFAULT_DESCRIPTION, + name: storefrontName, + description: storefrontTagline || DEFAULT_DESCRIPTION, url: siteUrl, publisher: { "@type": "Organization", @@ -157,15 +164,21 @@ export default async function RootLayout({ }: { children: React.ReactNode; }) { - const [services, siteUrl] = await Promise.all([ + const [services, storefront, siteUrl] = await Promise.all([ fetchServices(), + fetchStorefront(), resolveSiteUrl(), ]); return ( {children} - + ); diff --git a/web/public-storefront/src/app/manifest.ts b/web/public-storefront/src/app/manifest.ts index fe09d8c0..4be5f5be 100644 --- a/web/public-storefront/src/app/manifest.ts +++ b/web/public-storefront/src/app/manifest.ts @@ -1,20 +1,27 @@ import type { MetadataRoute } from "next"; +import { fetchStorefront, isDefaultStorefrontLogo } from "@/lib/catalog"; -export default function manifest(): MetadataRoute.Manifest { +export default async function manifest(): Promise { + const storefront = await fetchStorefront(); + const iconSrc = isDefaultStorefrontLogo(storefront.logoUrl) + ? "/icon-192.png" + : storefront.logoUrl; + const largeIcon = isDefaultStorefrontLogo(storefront.logoUrl) + ? "/icon-512.png" + : storefront.logoUrl; return { - name: "Obol Stack", - short_name: "Obol", - description: - "Buy agent services. Unlock Agent and API services with digital payments.", + name: storefront.displayName, + short_name: storefront.displayName, + description: storefront.tagline, start_url: "/", display: "standalone", background_color: "#091011", theme_color: "#091011", icons: [ - { src: "/icon-192.png", sizes: "192x192", type: "image/png" }, - { src: "/icon-512.png", sizes: "512x512", type: "image/png" }, + { src: iconSrc, sizes: "192x192", type: "image/png" }, + { src: largeIcon, sizes: "512x512", type: "image/png" }, { - src: "/icon-512.png", + src: largeIcon, sizes: "512x512", type: "image/png", purpose: "maskable", diff --git a/web/public-storefront/src/app/opengraph-image.tsx b/web/public-storefront/src/app/opengraph-image.tsx index 85240692..aa8ba51d 100644 --- a/web/public-storefront/src/app/opengraph-image.tsx +++ b/web/public-storefront/src/app/opengraph-image.tsx @@ -1,6 +1,7 @@ import { ImageResponse } from "next/og"; import { readFileSync } from "node:fs"; import { join } from "node:path"; +import { fetchStorefront, isDefaultStorefrontLogo } from "@/lib/catalog"; export const runtime = "nodejs"; export const alt = "Obol Stack — buy agent services"; @@ -15,6 +16,7 @@ const BG_PANEL = "#111F22"; const STROKE_GREEN = "#1D5249"; export default async function OpengraphImage() { + const storefront = await fetchStorefront(); const wordmark = readFileSync( join(process.cwd(), "public", "obol-stack-logo.png"), ); @@ -53,15 +55,44 @@ export default async function OpengraphImage() { background: BG01, }} > - {/* Wordmark, top-left */} - {/* eslint-disable-next-line @next/next/no-img-element */} - Obol Stack + {/* Brand, top-left */} + {isDefaultStorefrontLogo(storefront.logoUrl) ? ( + // eslint-disable-next-line @next/next/no-img-element + {storefront.displayName} + ) : ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {storefront.displayName} +
+ {storefront.displayName} +
+
+ )} {/* Headline */}
- Buy agent services + {storefront.displayName}
{/* Subtext */} @@ -89,7 +120,7 @@ export default async function OpengraphImage() { lineHeight: 1.3, }} > - Unlock Agent and API services with digital payments. + {storefront.tagline} {/* Chips, bottom-left */} diff --git a/web/public-storefront/src/app/page.tsx b/web/public-storefront/src/app/page.tsx index 2155525b..7e4071a6 100644 --- a/web/public-storefront/src/app/page.tsx +++ b/web/public-storefront/src/app/page.tsx @@ -1,42 +1,25 @@ -import type { Service } from "@/types"; +import { fetchServices, fetchStorefront } from "@/lib/catalog"; import { Header } from "@/components/Header"; import { ServicesList } from "@/components/ServicesList"; import { PaymentFlow } from "@/components/PaymentFlow"; - -// Always render fresh — newly-deployed demos must appear immediately. The -// underlying services.json is built from a Kubernetes ConfigMap that the -// controller updates on every ServiceOffer reconcile, and the client list -// then polls every 10s to surface further changes without a page reload. export const dynamic = "force-dynamic"; export const revalidate = 0; -async function getServices(): Promise { - try { - const res = await fetch( - `${process.env.SERVICES_URL ?? "http://obol-skill-md.x402.svc:8080"}/api/services.json`, - { cache: "no-store" }, - ); - if (!res.ok) return []; - return res.json(); - } catch { - return []; - } -} - export default async function Home() { - const services = await getServices(); + const [services, storefront] = await Promise.all([ + fetchServices(), + fetchStorefront(), + ]); return ( <> -
+

- Agent services + {storefront.displayName}

-

- This Obol Agent offers the following services for digital payment: -

+

{storefront.tagline}

diff --git a/web/public-storefront/src/components/Header.tsx b/web/public-storefront/src/components/Header.tsx index 33074e43..0d4d0ffb 100644 --- a/web/public-storefront/src/components/Header.tsx +++ b/web/public-storefront/src/components/Header.tsx @@ -1,17 +1,36 @@ import Image from "next/image"; +import type { StorefrontProfile } from "@/types"; +import { isDefaultStorefrontLogo } from "@/lib/catalog"; -export function Header() { +export function Header({ storefront }: { storefront: StorefrontProfile }) { + const isDefaultLogo = isDefaultStorefrontLogo(storefront.logoUrl); return (
- Obol Stack + {isDefaultLogo ? ( + {storefront.displayName} + ) : ( + <> + {/* eslint-disable-next-line @next/next/no-img-element */} + {storefront.displayName} +
+
+ {storefront.displayName} +
+
+ + )}
); diff --git a/web/public-storefront/src/lib/catalog.ts b/web/public-storefront/src/lib/catalog.ts new file mode 100644 index 00000000..3450a95c --- /dev/null +++ b/web/public-storefront/src/lib/catalog.ts @@ -0,0 +1,53 @@ +import type { Service, StorefrontProfile } from "@/types"; + +const SERVICES_URL = + process.env.SERVICES_URL ?? "http://obol-skill-md.x402.svc:8080"; + +const CATALOG_FETCH_TIMEOUT_MS = 5_000; + +async function fetchCatalog(path: string): Promise { + return fetch(`${SERVICES_URL}${path}`, { + cache: "no-store", + signal: AbortSignal.timeout(CATALOG_FETCH_TIMEOUT_MS), + }); +} + +export const DEFAULT_LOGO_PATH = "/obol-stack-logo.png"; + +export const DEFAULT_STOREFRONT: StorefrontProfile = { + displayName: "Obol Stack", + tagline: "Unlock Agent and API services with digital payments.", + logoUrl: DEFAULT_LOGO_PATH, +}; + +/** True when the logo is the stack default (relative or tunnel-absolute URL). */ +export function isDefaultStorefrontLogo(logoUrl: string): boolean { + return ( + logoUrl === DEFAULT_LOGO_PATH || logoUrl.endsWith(DEFAULT_LOGO_PATH) + ); +} + +export async function fetchServices(): Promise { + try { + const res = await fetchCatalog("/api/services.json"); + if (!res.ok) return []; + return res.json(); + } catch { + return []; + } +} + +export async function fetchStorefront(): Promise { + try { + const res = await fetchCatalog("/api/storefront.json"); + if (!res.ok) return DEFAULT_STOREFRONT; + const data = (await res.json()) as Partial; + return { + displayName: data.displayName || DEFAULT_STOREFRONT.displayName, + tagline: data.tagline || DEFAULT_STOREFRONT.tagline, + logoUrl: data.logoUrl || DEFAULT_STOREFRONT.logoUrl, + }; + } catch { + return DEFAULT_STOREFRONT; + } +} diff --git a/web/public-storefront/src/types.ts b/web/public-storefront/src/types.ts index 452529a8..2ce09882 100644 --- a/web/public-storefront/src/types.ts +++ b/web/public-storefront/src/types.ts @@ -51,3 +51,9 @@ export interface Service { // weight orders services within a category; higher sorts earlier. weight?: number; } + +export interface StorefrontProfile { + displayName: string; + tagline: string; + logoUrl: string; +} From 123970c28ebed4a1eec414d98f8dfed4ceda0dde Mon Sep 17 00:00:00 2001 From: HananINouman Date: Tue, 23 Jun 2026 22:23:12 +0300 Subject: [PATCH 2/5] fix(storefront): watch profile CM and dedupe catalog fetches Add a dedicated x402/obol-storefront-profile informer so controller republishes storefront.json on branding changes. Wrap fetchStorefront/ fetchServices in React.cache(), use ProfileNamespace for profile reads, and log marshal failures. Co-authored-by: Cursor --- CLAUDE.md | 2 ++ internal/serviceoffercontroller/controller.go | 32 +++++++++++++++---- .../serviceoffercontroller/identity_render.go | 11 ++++++- web/public-storefront/src/lib/catalog.ts | 9 +++--- 4 files changed, 43 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ac428a33..125bc7cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,6 +62,7 @@ Integration tests use `//go:build integration`; skip when prerequisites missing. | Public (tunnel) | none | `/.well-known/agent-registration.json` | ERC-8004 httpd | | Public (tunnel) | none | `/skill.md` | service catalog | | Public (tunnel) | none | `/api/services.json` | service catalog JSON feed | +| Public (tunnel) | none | `/api/storefront.json` | storefront branding JSON feed | | Public (tunnel) | tunnel hostname only | `/` | storefront landing page (Next.js) | **NEVER remove hostname restrictions from frontend or eRPC HTTPRoutes** — exposing the frontend/RPC to the public internet is a critical security flaw. @@ -426,6 +427,7 @@ The Cloudflare tunnel exposes the cluster to the public internet. Only x402-gate - `/services/*` — x402 payment-gated, safe by design - `/.well-known/agent-registration.json` — ERC-8004 discovery - `/skill.md` — machine-readable service catalog +- `/api/storefront.json` — storefront branding profile (display name, tagline, logo) - `/` on tunnel hostname — static storefront landing page (busybox httpd) ## Dependencies diff --git a/internal/serviceoffercontroller/controller.go b/internal/serviceoffercontroller/controller.go index 14a2d7c6..1fa9e33f 100644 --- a/internal/serviceoffercontroller/controller.go +++ b/internal/serviceoffercontroller/controller.go @@ -70,6 +70,7 @@ type Controller struct { purchaseInformer cache.SharedIndexInformer agentInformer cache.SharedIndexInformer configMapInformer cache.SharedIndexInformer + storefrontProfileInformer cache.SharedIndexInformer offerQueue workqueue.TypedRateLimitingInterface[string] registrationQueue workqueue.TypedRateLimitingInterface[string] identityQueue workqueue.TypedRateLimitingInterface[string] @@ -113,6 +114,10 @@ func New(cfg *rest.Config) (*Controller, error) { options.FieldSelector = fields.OneTermEqualSelector("metadata.name", "obol-stack-config").String() }) configMapInformer := configMapFactory.ForResource(monetizeapi.ConfigMapGVR).Informer() + storefrontProfileFactory := dynamicinformer.NewFilteredDynamicSharedInformerFactory(client, 0, storefront.ProfileNamespace, func(options *metav1.ListOptions) { + options.FieldSelector = fields.OneTermEqualSelector("metadata.name", storefront.ProfileConfigMap).String() + }) + storefrontProfileInformer := storefrontProfileFactory.ForResource(monetizeapi.ConfigMapGVR).Informer() controller := &Controller{ kubeClient: kubeClient, @@ -133,8 +138,9 @@ func New(cfg *rest.Config) (*Controller, error) { identityInformer: identityInformer, purchaseInformer: purchaseInformer, agentInformer: agentInformer, - configMapInformer: configMapInformer, - offerQueue: workqueue.NewTypedRateLimitingQueue(workqueue.DefaultTypedControllerRateLimiter[string]()), + configMapInformer: configMapInformer, + storefrontProfileInformer: storefrontProfileInformer, + offerQueue: workqueue.NewTypedRateLimitingQueue(workqueue.DefaultTypedControllerRateLimiter[string]()), registrationQueue: workqueue.NewTypedRateLimitingQueue(workqueue.DefaultTypedControllerRateLimiter[string]()), identityQueue: workqueue.NewTypedRateLimitingQueue(workqueue.DefaultTypedControllerRateLimiter[string]()), purchaseQueue: workqueue.NewTypedRateLimitingQueue(workqueue.DefaultTypedControllerRateLimiter[string]()), @@ -203,6 +209,11 @@ func New(cfg *rest.Config) (*Controller, error) { UpdateFunc: func(_, newObj any) { controller.enqueueDiscoveryRefresh(newObj) }, DeleteFunc: controller.enqueueDiscoveryRefresh, }) + storefrontProfileInformer.AddEventHandler(cache.ResourceEventHandlerFuncs{ + AddFunc: controller.enqueueStorefrontProfileRefresh, + UpdateFunc: func(_, newObj any) { controller.enqueueStorefrontProfileRefresh(newObj) }, + DeleteFunc: controller.enqueueStorefrontProfileRefresh, + }) return controller, nil } @@ -220,6 +231,7 @@ func (c *Controller) Run(ctx context.Context, workers int) error { go c.purchaseInformer.Run(ctx.Done()) go c.agentInformer.Run(ctx.Done()) go c.configMapInformer.Run(ctx.Done()) + go c.storefrontProfileInformer.Run(ctx.Done()) if !cache.WaitForCacheSync(ctx.Done(), c.offerInformer.HasSynced, c.registrationInformer.HasSynced, @@ -227,6 +239,7 @@ func (c *Controller) Run(ctx context.Context, workers int) error { c.purchaseInformer.HasSynced, c.agentInformer.HasSynced, c.configMapInformer.HasSynced, + c.storefrontProfileInformer.HasSynced, ) { return fmt.Errorf("wait for informer sync") } @@ -322,12 +335,19 @@ func (c *Controller) enqueueDiscoveryRefresh(obj any) { for _, item := range c.identityInformer.GetStore().List() { c.enqueueIdentity(item) } + } +} + +func (c *Controller) enqueueStorefrontProfileRefresh(obj any) { + u := asUnstructured(obj) + if u == nil { return } - if ns == storefront.ProfileNamespace && name == storefront.ProfileConfigMap { - log.Printf("serviceoffer-controller: storefront profile change detected, refreshing skill catalog") - c.enqueueSkillCatalogRefresh() + if u.GetNamespace() != storefront.ProfileNamespace || u.GetName() != storefront.ProfileConfigMap { + return } + log.Printf("serviceoffer-controller: storefront profile change detected, refreshing skill catalog") + c.enqueueSkillCatalogRefresh() } func (c *Controller) enqueueSkillCatalogRefresh() { @@ -1142,7 +1162,7 @@ func (c *Controller) reconcileRegistrationTombstone(ctx context.Context, raw *un } func (c *Controller) loadStorefrontProfile(ctx context.Context) (*schemas.StorefrontProfile, error) { - cm, err := c.configMaps.Namespace(skillCatalogNamespace).Get(ctx, storefront.ProfileConfigMap, metav1.GetOptions{}) + cm, err := c.configMaps.Namespace(storefront.ProfileNamespace).Get(ctx, storefront.ProfileConfigMap, metav1.GetOptions{}) if err != nil { if apierrors.IsNotFound(err) { return nil, nil diff --git a/internal/serviceoffercontroller/identity_render.go b/internal/serviceoffercontroller/identity_render.go index 364d50ec..39b8a0b1 100644 --- a/internal/serviceoffercontroller/identity_render.go +++ b/internal/serviceoffercontroller/identity_render.go @@ -3,6 +3,7 @@ package serviceoffercontroller import ( "encoding/json" "fmt" + "log" "sort" "strings" @@ -106,7 +107,15 @@ func identityDocumentMetadata(identity *monetizeapi.AgentIdentity, offers []*mon func buildStorefrontJSON(baseURL string, explicit *schemas.StorefrontProfile) string { profile := storefront.ResolvePublished(explicit, baseURL) - out, _ := json.MarshalIndent(profile, "", " ") + out, err := json.MarshalIndent(profile, "", " ") + if err != nil { + log.Printf("serviceoffer-controller: marshal storefront profile: %v", err) + fallback, ferr := json.MarshalIndent(storefront.ResolvePublished(nil, baseURL), "", " ") + if ferr != nil { + return "{}" + } + return string(fallback) + } return string(out) } diff --git a/web/public-storefront/src/lib/catalog.ts b/web/public-storefront/src/lib/catalog.ts index 3450a95c..fdf130b4 100644 --- a/web/public-storefront/src/lib/catalog.ts +++ b/web/public-storefront/src/lib/catalog.ts @@ -1,3 +1,4 @@ +import { cache } from "react"; import type { Service, StorefrontProfile } from "@/types"; const SERVICES_URL = @@ -27,7 +28,7 @@ export function isDefaultStorefrontLogo(logoUrl: string): boolean { ); } -export async function fetchServices(): Promise { +export const fetchServices = cache(async (): Promise => { try { const res = await fetchCatalog("/api/services.json"); if (!res.ok) return []; @@ -35,9 +36,9 @@ export async function fetchServices(): Promise { } catch { return []; } -} +}); -export async function fetchStorefront(): Promise { +export const fetchStorefront = cache(async (): Promise => { try { const res = await fetchCatalog("/api/storefront.json"); if (!res.ok) return DEFAULT_STOREFRONT; @@ -50,4 +51,4 @@ export async function fetchStorefront(): Promise { } catch { return DEFAULT_STOREFRONT; } -} +}); From e3510bd23dbaba1744f5db2e862563a892621224 Mon Sep 17 00:00:00 2001 From: HananINouman Date: Wed, 24 Jun 2026 09:36:17 +0300 Subject: [PATCH 3/5] fix(storefront): keep Agent services hero, profile name in header only Always show "Agent services" as the page title; use profile tagline in the hero and displayName in the custom header so seller name is not duplicated. Co-authored-by: Cursor --- web/public-storefront/src/app/layout.tsx | 27 ++++++++++++++---------- web/public-storefront/src/app/page.tsx | 4 ++-- web/public-storefront/src/lib/catalog.ts | 2 ++ 3 files changed, 20 insertions(+), 13 deletions(-) diff --git a/web/public-storefront/src/app/layout.tsx b/web/public-storefront/src/app/layout.tsx index 243e13f9..f3a0bb31 100644 --- a/web/public-storefront/src/app/layout.tsx +++ b/web/public-storefront/src/app/layout.tsx @@ -2,7 +2,11 @@ import type { Metadata, Viewport } from "next"; import { headers } from "next/headers"; import { DM_Sans } from "next/font/google"; import type { Service } from "@/types"; -import { fetchServices, fetchStorefront, isDefaultStorefrontLogo } from "@/lib/catalog"; +import { + fetchServices, + fetchStorefront, + isDefaultStorefrontLogo, +} from "@/lib/catalog"; import "./globals.css"; const dmSans = DM_Sans({ @@ -12,9 +16,6 @@ const dmSans = DM_Sans({ variable: "--font-dm-sans", }); -const DEFAULT_TITLE = "Buy agent services"; -const DEFAULT_DESCRIPTION = "Unlock Agent and API services with digital payments."; - // Derive the public site URL from the incoming request so OG/Twitter scrapers // see the tunnel hostname they hit (rather than the in-cluster default). Falls // back to NEXT_PUBLIC_SITE_URL, then the local-dev default. @@ -34,11 +35,17 @@ async function resolveSiteUrl(): Promise { return process.env.NEXT_PUBLIC_SITE_URL ?? "http://obol.stack:8080"; } -function buildDynamicCopy(storefrontName: string, tagline: string, services: Service[]) { +const DEFAULT_TITLE_SUFFIX = "Buy agent services"; + +function buildDynamicCopy( + storefrontName: string, + tagline: string, + services: Service[], +) { if (services.length === 0) { return { - title: `${storefrontName} — ${DEFAULT_TITLE}`, - description: tagline || DEFAULT_DESCRIPTION, + title: `${storefrontName} — ${DEFAULT_TITLE_SUFFIX}`, + description: tagline, }; } const total = services.length; @@ -49,9 +56,7 @@ function buildDynamicCopy(storefrontName: string, tagline: string, services: Ser .map((s) => s.model ?? s.name) .filter(Boolean) .join(", "); - const description = sample - ? `${tagline} ${sample}.` - : tagline || DEFAULT_DESCRIPTION; + const description = sample ? `${tagline} ${sample}.` : tagline; return { title, description }; } @@ -130,7 +135,7 @@ function JsonLd({ "@context": "https://schema.org", "@type": "WebSite", name: storefrontName, - description: storefrontTagline || DEFAULT_DESCRIPTION, + description: storefrontTagline, url: siteUrl, publisher: { "@type": "Organization", diff --git a/web/public-storefront/src/app/page.tsx b/web/public-storefront/src/app/page.tsx index 7e4071a6..a4c000cd 100644 --- a/web/public-storefront/src/app/page.tsx +++ b/web/public-storefront/src/app/page.tsx @@ -1,4 +1,4 @@ -import { fetchServices, fetchStorefront } from "@/lib/catalog"; +import { DEFAULT_HERO_TITLE, fetchServices, fetchStorefront } from "@/lib/catalog"; import { Header } from "@/components/Header"; import { ServicesList } from "@/components/ServicesList"; import { PaymentFlow } from "@/components/PaymentFlow"; @@ -17,7 +17,7 @@ export default async function Home() {

- {storefront.displayName} + {DEFAULT_HERO_TITLE}

{storefront.tagline}

diff --git a/web/public-storefront/src/lib/catalog.ts b/web/public-storefront/src/lib/catalog.ts index fdf130b4..10db92af 100644 --- a/web/public-storefront/src/lib/catalog.ts +++ b/web/public-storefront/src/lib/catalog.ts @@ -28,6 +28,8 @@ export function isDefaultStorefrontLogo(logoUrl: string): boolean { ); } +export const DEFAULT_HERO_TITLE = "Agent services"; + export const fetchServices = cache(async (): Promise => { try { const res = await fetchCatalog("/api/services.json"); From 5c10db84270d2ab6bf18aef83f651fae9f4aed50 Mon Sep 17 00:00:00 2001 From: HananINouman Date: Wed, 24 Jun 2026 18:44:11 +0300 Subject: [PATCH 4/5] feat(sell): embed seller branding in /api/services.json envelope Move displayName, tagline, and logoUrl into the public catalog, remove the separate storefront.json route, and configure branding via obol sell info. Co-authored-by: Cursor --- CLAUDE.md | 10 +- cmd/obol/sell.go | 88 ------------ cmd/obol/{sell_storefront.go => sell_info.go} | 102 +++++++------- internal/buy/catalog_test.go | 11 +- internal/buy/discover.go | 6 +- internal/schemas/service-catalog.schema.json | 30 +++- internal/schemas/service_catalog.go | 12 +- internal/serviceoffercontroller/controller.go | 12 +- .../serviceoffercontroller/identity_render.go | 18 --- .../identity_render_test.go | 40 +++--- internal/serviceoffercontroller/render.go | 96 +++++-------- .../render_builders_test.go | 14 +- .../serviceoffercontroller/render_test.go | 133 ++++++++---------- web/public-storefront/next.config.ts | 4 - web/public-storefront/src/app/page.tsx | 13 +- .../src/components/ServicesList.tsx | 5 +- web/public-storefront/src/lib/catalog.ts | 56 +++++--- 17 files changed, 276 insertions(+), 374 deletions(-) rename cmd/obol/{sell_storefront.go => sell_info.go} (61%) diff --git a/CLAUDE.md b/CLAUDE.md index 125bc7cd..612f6ec3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,8 +61,7 @@ Integration tests use `//go:build integration`; skip when prerequisites missing. | Public (tunnel) | none | `/services//*` | x402 ForwardAuth -> upstream | | Public (tunnel) | none | `/.well-known/agent-registration.json` | ERC-8004 httpd | | Public (tunnel) | none | `/skill.md` | service catalog | -| Public (tunnel) | none | `/api/services.json` | service catalog JSON feed | -| Public (tunnel) | none | `/api/storefront.json` | storefront branding JSON feed | +| Public (tunnel) | none | `/api/services.json` | service catalog JSON feed (`displayName`, `tagline`, `logoUrl`, `services[]`) | | Public (tunnel) | tunnel hostname only | `/` | storefront landing page (Next.js) | **NEVER remove hostname restrictions from frontend or eRPC HTTPRoutes** — exposing the frontend/RPC to the public internet is a critical security flaw. @@ -100,7 +99,7 @@ obol - `agent auth` (alias `token`): `--runtime [hermes|openclaw|all]`, `--regenerate`; positional `[instance-name]` defaults to stack-managed agent. Replaces legacy `hermes token`. - `agent new` (alias `onboard`): CRD-declared sub-agent via `--model`, `--skills`, `--objective`, `--create-wallet`. Without positional name, falls back to legacy host-rendered Hermes/OpenClaw onboard. - `network install` has dynamic subcommands (one per supported chain; `--help` to list). `network sync [/]` with `--all`. -- `sell info ` prints purchase instructions (URL, model, buy.py command). +- `sell info set|show|reset` configures seller branding in `/api/services.json` (`displayName`, `tagline`, `logoUrl`). Writes `x402/obol-storefront-profile`; controller merges into the catalog envelope. Example: `obol sell info set --display-name "Acme Labs" --tagline "Paid APIs." --logo-url "https://…"`. **Dev note**: branding changes need both a dev-built `obol` CLI (`go build -o .workspace/bin/obol ./cmd/obol`) and a current `serviceoffer-controller` image — if `sell info set` times out with `timed out waiting for controller to publish /api/services.json`, the profile ConfigMap was likely applied but the running controller is still publishing the legacy bare-array catalog. Rebuild + roll the controller before retrying: `OBOL_FORCE_REBUILD_LOCAL_DEV_IMAGES=serviceoffer-controller OBOL_DEVELOPMENT=true obol stack up`, or `docker build -f Dockerfile.serviceoffer-controller -t ghcr.io/obolnetwork/serviceoffer-controller:latest . && k3d image import … -c && kubectl rollout restart deploy/serviceoffer-controller -n x402`. - `sell mcp [name]` runs a foreground x402-paid MCP server: forwards buyer JSON args to a backend HTTP service, injecting the seller's own API key (buyer never sees it). Payment rides MCP `_meta` (`internal/x402mcp`). - `sell resume` replays every persisted sell offer (inference incl. detached host-gateway relaunch; http/agent/demo-agent via the manifest ledger at `$OBOL_CONFIG_DIR/sell-http/`) — run after a host reboot; `obol stack up` runs the same path. `--install-boot-unit` adds a systemd user unit (Linux). `sell mcp` is foreground-only, no offer, not resumed. - `tunnel setup []`: the one permanent-URL command. Connector-token based (dashboard-managed) — no host binary, no account-wide API key. Accepts the bare connector token, the `--token` flag, a positional arg, or the whole `cloudflared tunnel run --token …` line (prefix stripped via `extractConnectorToken`). Reuses the remote runtime (`ProvisionWithToken` → `TUNNEL_TOKEN` secret, chart `management_mode=remote`); DNS/ingress are configured by the user in the Cloudflare dashboard (route Public Hostname → `http://traefik.traefik.svc.cluster.local:80`), not via API. The API-token provisioning path was removed (no more `tunnel provision`, no setup `--api-token/--account-id/--zone-id/--register-domain`). `--management local` (alias hidden `tunnel login`) is the browser fallback (needs `cloudflared`). `tunnel status` reads connector health from cloudflared's in-cluster `/ready`+`/metrics` (port 2000, no token) plus a public HTTP probe; concise by default, `--verbose` for replicas/pods, `--no-probe` to stay offline. Domain management lives under `obol domain` (`list`, `search`, `check`, `register`) — an optional CLI wrapper around Cloudflare Registrar; still uses a scoped Cloudflare **API token** (Account → Domain perm, via `--api-token`/`CLOUDFLARE_API_TOKEN`; on a TTY it walks you through token creation and prompts). `--api-token` deliberately has NO `-t` alias to avoid colliding with `tunnel setup -t` (connector token — a different credential). `register` is billable (needs a payment method on the CF account); on success it prints the `obol tunnel setup --hostname …` handoff. @@ -408,6 +407,7 @@ A registry digest pin instead of `:latest` on the verifier means your dev rewrit 16. **Clusters created on <= v0.10.0-rc12 keep hostPath-typed PVs** — kubelet ignores `fsGroup` there, and v0.10.0's non-root pods (UID 1000, no chown inits) cannot read the legacy 10000-owned data. Symptom after ANY chart re-render (`agent sync`, model sync, tests that sync): `Init:CrashLoopBackOff` with `mkdir /data/.hermes: Permission denied`. Supported path: recreate the cluster (`obol stack export` -> recreate -> `import`); full steps in the v0.10.0 release notes (Breaking changes). Non-destructive workaround: `docker exec chown -R 1000:1000 /data//hermes-data` then delete the pod. 17. **EIP-7702-contaminated test accounts on a Base Sepolia fork** — standard anvil/hardhat accounts #1–#9 (the `test test ... junk` mnemonic) carry EIP-7702 delegation code (`0xef0100…`) from real-chain 7702 experiments on Base Sepolia. Base-Sepolia USDC is FiatTokenV2_2, which verifies EIP-3009 via `SignatureChecker.isValidSignatureNow` — any `from` with code routes to EIP-1271 `isValidSignature` and ignores a perfectly valid ECDSA signature, reverting `FiatTokenV2: invalid signature` (surfacing as facilitator 503 / `unexpected_error`). The buyer/`from` MUST be a freshly generated EOA (account #0 happens to be clean; payTo with code is fine — only the signer is checked). This is why flow-08 funds the agent's generated wallet, and why `flow-17-sell-mcp.sh` generates fresh buyer + seller keys and preflights `cast code "$BUYER_ADDR" == 0x`. 18. **x402 SDK signs `validAfter = now` with no past buffer** — the `x402-foundation/x402/go` client sets EIP-3009 `validAfter` to wall-clock now. An anvil fork's `block.timestamp` is pinned to the forked block and lags real time the longer the fork has been up, so verify/settle revert `FiatTokenV2: authorization is not yet valid`. In a normal release-smoke run flow-17 follows flow-10 immediately so the gap is tiny; flow-17 still defends with `cast rpc evm_setNextBlockTimestamp $((now+30)) && evm_mine` right before the paid call. (obol's own buy.py uses a past buffer and isn't affected.) +19. **`obol sell info set` times out but profile CM updated** — the dev CLI wrote `x402/obol-storefront-profile` but the in-cluster `serviceoffer-controller` is still on an image that publishes `/api/services.json` as a bare `services[]` array (no `displayName` envelope). Symptom: `configmap/obol-storefront-profile configured` then `timed out waiting for controller to publish /api/services.json`; `kubectl get cm -n x402 obol-skill-md -o jsonpath='{.data.services\.json}'` starts with `[` not `{`. Fix: rebuild + import + restart `serviceoffer-controller` (see `sell info` bullet above). `obol stack up` with a warm cache (`built == 0`) does not pick up controller source changes unless `OBOL_FORCE_REBUILD_LOCAL_DEV_IMAGES=serviceoffer-controller`. For a fuller debug catalog with symptom->fix mapping, see `.agents/skills/obol-stack-dev/references/release-smoke-debugging.md`. @@ -427,8 +427,8 @@ The Cloudflare tunnel exposes the cluster to the public internet. Only x402-gate - `/services/*` — x402 payment-gated, safe by design - `/.well-known/agent-registration.json` — ERC-8004 discovery - `/skill.md` — machine-readable service catalog -- `/api/storefront.json` — storefront branding profile (display name, tagline, logo) -- `/` on tunnel hostname — static storefront landing page (busybox httpd) +- `/api/services.json` — service catalog envelope (`displayName`, `tagline`, `logoUrl`, `services[]`) +- `/` on tunnel hostname — public storefront landing page (Next.js) ## Dependencies diff --git a/cmd/obol/sell.go b/cmd/obol/sell.go index c9e186d7..4e3cd469 100644 --- a/cmd/obol/sell.go +++ b/cmd/obol/sell.go @@ -4,7 +4,6 @@ import ( "bufio" "bytes" "context" - "encoding/hex" "encoding/json" "errors" "fmt" @@ -24,7 +23,6 @@ import ( "github.com/ObolNetwork/obol-stack/internal/agentcrd" "github.com/ObolNetwork/obol-stack/internal/config" - "github.com/ObolNetwork/obol-stack/internal/enclave" "github.com/ObolNetwork/obol-stack/internal/erc8004" "github.com/ObolNetwork/obol-stack/internal/hermes" "github.com/ObolNetwork/obol-stack/internal/images" @@ -63,7 +61,6 @@ func sellCommand(cfg *config.Config) *cli.Command { sellPricingCommand(cfg), sellRegisterCommand(cfg), sellIdentityCommand(cfg), - sellStorefrontCommand(cfg), sellInfoCommand(cfg), sellResumeCommand(cfg), }, @@ -3560,91 +3557,6 @@ func (pf *signerPortForwarder) Stop() { } } -// sellInfoCommand returns info about a local inference gateway deployment. -// Kept for the enclave pubkey functionality. -func sellInfoCommand(cfg *config.Config) *cli.Command { - return &cli.Command{ - Name: "info", - Usage: "Show inference gateway deployment details and encryption key", - ArgsUsage: "", - Flags: []cli.Flag{ - &cli.BoolFlag{ - Name: "json", - Aliases: []string{"j"}, - Usage: "Output as JSON", - }, - }, - Action: func(ctx context.Context, cmd *cli.Command) error { - u := getUI(cmd) - name := cmd.Args().First() - if name == "" { - return fmt.Errorf("usage: obol sell info ") - } - - store := inference.NewStore(cfg.ConfigDir) - d, err := store.Get(name) - if err != nil { - return err - } - - var k enclave.Key - var keyErr error - if d.TEEType != "" { - k, keyErr = tee.NewKey(d.EnclaveTag, d.ModelHash) - } else { - k, keyErr = enclave.NewKey(d.EnclaveTag) - } - - if u.IsJSON() || cmd.Bool("json") { - out := map[string]any{ - "name": d.Name, - "enclave_tag": d.EnclaveTag, - "listen_addr": d.ListenAddr, - "upstream_url": d.UpstreamURL, - "wallet_address": d.WalletAddress, - "price_per_request": d.PricePerRequest, - "price_per_mtok": d.PricePerMTok, - "approx_tokens_per_request": d.ApproxTokensPerRequest, - "chain": d.Chain, - "facilitator_url": d.FacilitatorURL, - "created_at": d.CreatedAt, - "updated_at": d.UpdatedAt, - "algorithm": "ECIES-P256-HKDF-SHA256-AES256GCM", - } - if keyErr == nil { - out["pubkey"] = hex.EncodeToString(k.PublicKeyBytes()) - out["persistent"] = k.Persistent() - } else { - out["pubkey_error"] = keyErr.Error() - } - return u.JSON(out) - } - - u.Printf("Name: %s", d.Name) - u.Printf("Enclave tag: %s", d.EnclaveTag) - u.Printf("Algorithm: ECIES-P256-HKDF-SHA256-AES256GCM") - if keyErr == nil { - u.Printf("Pubkey: %s", hex.EncodeToString(k.PublicKeyBytes())) - u.Printf("Persistent: %v", k.Persistent()) - } else { - u.Printf("Pubkey: (unavailable: %v)", keyErr) - } - u.Blank() - u.Printf("Listen: %s", d.ListenAddr) - u.Printf("Upstream: %s", d.UpstreamURL) - u.Printf("Wallet: %s", d.WalletAddress) - u.Printf("Price: %s", formatInferencePriceSummary(d, "")) - u.Printf("Chain: %s", d.Chain) - u.Printf("Facilitator: %s", d.FacilitatorURL) - u.Printf("Created: %s", d.CreatedAt) - if d.UpdatedAt != "" { - u.Printf("Updated: %s", d.UpdatedAt) - } - return nil - }, - } -} - // --------------------------------------------------------------------------- // kubectl helpers // --------------------------------------------------------------------------- diff --git a/cmd/obol/sell_storefront.go b/cmd/obol/sell_info.go similarity index 61% rename from cmd/obol/sell_storefront.go rename to cmd/obol/sell_info.go index 0202bbb1..0064d9e1 100644 --- a/cmd/obol/sell_storefront.go +++ b/cmd/obol/sell_info.go @@ -19,32 +19,32 @@ import ( "github.com/urfave/cli/v3" ) -func sellStorefrontCommand(cfg *config.Config) *cli.Command { +func sellInfoCommand(cfg *config.Config) *cli.Command { return &cli.Command{ - Name: "storefront", - Usage: "Configure public storefront branding (name, tagline, logo)", - Description: `Sets the seller-wide storefront profile served at /api/storefront.json. + Name: "info", + Usage: "Configure public seller branding in /api/services.json", + Description: `Sets seller-wide display name, tagline, and logo in the public catalog. This is independent of individual ServiceOffers and ERC-8004 identity. Examples: - obol sell storefront set --display-name "Acme Labs" --tagline "Paid APIs." --logo-url "https://acme.example/logo.png" - obol sell storefront show - obol sell storefront reset`, + obol sell info set --display-name "Acme Labs" --tagline "Paid APIs." --logo-url "https://acme.example/logo.png" + obol sell info show + obol sell info reset`, Commands: []*cli.Command{ - sellStorefrontSetCommand(cfg), - sellStorefrontShowCommand(cfg), - sellStorefrontResetCommand(cfg), + sellInfoSetCommand(cfg), + sellInfoShowCommand(cfg), + sellInfoResetCommand(cfg), }, } } -func sellStorefrontSetCommand(cfg *config.Config) *cli.Command { +func sellInfoSetCommand(cfg *config.Config) *cli.Command { return &cli.Command{ Name: "set", - Usage: "Set storefront display name, tagline, and/or logo URL", + Usage: "Set seller display name, tagline, and/or logo URL", Flags: []cli.Flag{ - &cli.StringFlag{Name: "display-name", Usage: "Storefront title shown in the header and page hero"}, - &cli.StringFlag{Name: "tagline", Usage: "Short subtitle under the storefront title"}, + &cli.StringFlag{Name: "display-name", Usage: "Seller title shown in the storefront header"}, + &cli.StringFlag{Name: "tagline", Usage: "Short subtitle under the storefront hero"}, &cli.StringFlag{Name: "logo-url", Usage: "Logo image URL (https://... or /path on this host)"}, }, Action: func(ctx context.Context, cmd *cli.Command) error { @@ -65,33 +65,33 @@ func sellStorefrontSetCommand(cfg *config.Config) *cli.Command { return err } - current, err := loadStorefrontProfile(cfg) + current, err := loadSellerProfile(cfg) if err != nil { return err } merged := storefront.MergeProfile(current, patch) - if err := applyStorefrontProfile(cfg, merged); err != nil { + if err := applySellerProfile(cfg, merged); err != nil { return err } - published, err := waitForPublishedStorefront(cfg, &merged, 45*time.Second) + published, err := waitForPublishedCatalog(cfg, &merged, 45*time.Second) if err != nil { return err } - u.Success("Storefront profile updated") - printStorefrontProfile(u, published) + u.Success("Seller profile updated") + printSellerProfile(u, published) u.Blank() - u.Dim("Verify: curl -s http://obol.stack:8080/api/storefront.json | jq .") + u.Dim("Verify: curl -s http://obol.stack:8080/api/services.json | jq '{displayName,tagline,logoUrl}'") return nil }, } } -func sellStorefrontShowCommand(cfg *config.Config) *cli.Command { +func sellInfoShowCommand(cfg *config.Config) *cli.Command { return &cli.Command{ Name: "show", - Usage: "Show the current storefront profile", + Usage: "Show the current seller profile", Flags: []cli.Flag{ &cli.BoolFlag{Name: "json", Aliases: []string{"j"}, Usage: "Output as JSON"}, }, @@ -100,25 +100,25 @@ func sellStorefrontShowCommand(cfg *config.Config) *cli.Command { if err := kubectl.EnsureCluster(cfg); err != nil { return err } - profile, err := loadStorefrontProfile(cfg) + profile, err := loadSellerProfile(cfg) if err != nil { return err } - baseURL, _ := storefrontBaseURL(cfg) + baseURL, _ := sellerBaseURL(cfg) published := storefront.ResolvePublished(&profile, baseURL) if u.IsJSON() || cmd.Bool("json") { return u.JSON(published) } - printStorefrontProfile(u, published) + printSellerProfile(u, published) return nil }, } } -func sellStorefrontResetCommand(cfg *config.Config) *cli.Command { +func sellInfoResetCommand(cfg *config.Config) *cli.Command { return &cli.Command{ Name: "reset", - Usage: "Remove custom storefront branding and restore defaults", + Usage: "Remove custom seller branding and restore defaults", Action: func(ctx context.Context, cmd *cli.Command) error { u := getUI(cmd) if err := kubectl.EnsureCluster(cfg); err != nil { @@ -127,23 +127,23 @@ func sellStorefrontResetCommand(cfg *config.Config) *cli.Command { bin, kc := kubectl.Paths(cfg) if err := kubectl.RunSilent(bin, kc, "delete", "configmap", storefront.ProfileConfigMap, "-n", storefront.ProfileNamespace, "--ignore-not-found"); err != nil { - return fmt.Errorf("delete storefront profile: %w", err) + return fmt.Errorf("delete seller profile: %w", err) } _ = os.Remove(storefront.ProfileLocalPath(cfg)) - published, err := waitForPublishedStorefront(cfg, nil, 45*time.Second) + published, err := waitForPublishedCatalog(cfg, nil, 45*time.Second) if err != nil { return err } - u.Success("Storefront profile reset to defaults") - printStorefrontProfile(u, published) + u.Success("Seller profile reset to defaults") + printSellerProfile(u, published) return nil }, } } -func loadStorefrontProfile(cfg *config.Config) (schemas.StorefrontProfile, error) { +func loadSellerProfile(cfg *config.Config) (schemas.StorefrontProfile, error) { if raw, err := kubectlOutput(cfg, "get", "configmap", storefront.ProfileConfigMap, "-n", storefront.ProfileNamespace, "-o", "jsonpath={.data."+storefront.ProfileDataKey+"}"); err == nil { if p, err := storefront.ParseProfile(raw); err != nil { @@ -162,7 +162,7 @@ func loadStorefrontProfile(cfg *config.Config) (schemas.StorefrontProfile, error return schemas.StorefrontProfile{}, nil } -func applyStorefrontProfile(cfg *config.Config, profile schemas.StorefrontProfile) error { +func applySellerProfile(cfg *config.Config, profile schemas.StorefrontProfile) error { if err := os.MkdirAll(filepath.Dir(storefront.ProfileLocalPath(cfg)), 0o700); err != nil { return err } @@ -178,35 +178,39 @@ func applyStorefrontProfile(cfg *config.Config, profile schemas.StorefrontProfil return err } if err := kubectlApply(cfg, manifest); err != nil { - return fmt.Errorf("apply storefront profile: %w", err) + return fmt.Errorf("apply seller profile: %w", err) } return nil } -func waitForPublishedStorefront(cfg *config.Config, explicit *schemas.StorefrontProfile, timeout time.Duration) (schemas.StorefrontProfile, error) { - want := storefront.ResolvePublished(explicit, mustStorefrontBaseURL(cfg)) +func waitForPublishedCatalog(cfg *config.Config, explicit *schemas.StorefrontProfile, timeout time.Duration) (schemas.StorefrontProfile, error) { + want := storefront.ResolvePublished(explicit, mustSellerBaseURL(cfg)) deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { raw, err := kubectlOutput(cfg, "get", "configmap", "obol-skill-md", - "-n", storefront.ProfileNamespace, "-o", "jsonpath={.data.storefront\\.json}") + "-n", storefront.ProfileNamespace, "-o", "jsonpath={.data.services\\.json}") if err == nil && strings.TrimSpace(raw) != "" { - var got schemas.StorefrontProfile - if err := json.Unmarshal([]byte(raw), &got); err == nil && storefrontProfilesEqual(got, want) { - return got, nil + var got schemas.ServiceCatalog + if err := json.Unmarshal([]byte(raw), &got); err == nil && sellerProfilesEqual(got, want) { + return schemas.StorefrontProfile{ + DisplayName: got.DisplayName, + Tagline: got.Tagline, + LogoURL: got.LogoURL, + }, nil } } time.Sleep(2 * time.Second) } - return want, fmt.Errorf("timed out waiting for controller to publish /api/storefront.json") + return want, fmt.Errorf("timed out waiting for controller to publish /api/services.json") } -func storefrontProfilesEqual(a, b schemas.StorefrontProfile) bool { - return strings.TrimSpace(a.DisplayName) == strings.TrimSpace(b.DisplayName) && - strings.TrimSpace(a.Tagline) == strings.TrimSpace(b.Tagline) && - strings.TrimSpace(a.LogoURL) == strings.TrimSpace(b.LogoURL) +func sellerProfilesEqual(catalog schemas.ServiceCatalog, want schemas.StorefrontProfile) bool { + return strings.TrimSpace(catalog.DisplayName) == strings.TrimSpace(want.DisplayName) && + strings.TrimSpace(catalog.Tagline) == strings.TrimSpace(want.Tagline) && + strings.TrimSpace(catalog.LogoURL) == strings.TrimSpace(want.LogoURL) } -func storefrontBaseURL(cfg *config.Config) (string, error) { +func sellerBaseURL(cfg *config.Config) (string, error) { st, err := tunnel.LoadTunnelState(cfg) if err != nil { return "", err @@ -220,15 +224,15 @@ func storefrontBaseURL(cfg *config.Config) (string, error) { return "http://obol.stack:8080", nil } -func mustStorefrontBaseURL(cfg *config.Config) string { - baseURL, err := storefrontBaseURL(cfg) +func mustSellerBaseURL(cfg *config.Config) string { + baseURL, err := sellerBaseURL(cfg) if err != nil || baseURL == "" { return "http://obol.stack:8080" } return baseURL } -func printStorefrontProfile(u *ui.UI, profile schemas.StorefrontProfile) { +func printSellerProfile(u *ui.UI, profile schemas.StorefrontProfile) { u.Printf(" Display name: %s", profile.DisplayName) u.Printf(" Tagline: %s", profile.Tagline) u.Printf(" Logo URL: %s", profile.LogoURL) diff --git a/internal/buy/catalog_test.go b/internal/buy/catalog_test.go index 64055165..d77f62f2 100644 --- a/internal/buy/catalog_test.go +++ b/internal/buy/catalog_test.go @@ -102,9 +102,14 @@ func TestFetchServiceCatalog(t *testing.T) { return } w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode([]CatalogEntry{ - {Name: "aeon", Type: "inference", Model: "aeon7", Endpoint: "/services/aeon/v1/chat/completions"}, - {Name: "http-thing", Type: "http", Endpoint: "/services/http-thing"}, + _ = json.NewEncoder(w).Encode(map[string]any{ + "displayName": "Seller", + "tagline": "Paid APIs", + "logoUrl": "https://example/logo.png", + "services": []CatalogEntry{ + {Name: "aeon", Type: "inference", Model: "aeon7", Endpoint: "/services/aeon/v1/chat/completions"}, + {Name: "http-thing", Type: "http", Endpoint: "/services/http-thing"}, + }, }) })) defer srv.Close() diff --git a/internal/buy/discover.go b/internal/buy/discover.go index a3e311de..49b7e39d 100644 --- a/internal/buy/discover.go +++ b/internal/buy/discover.go @@ -205,11 +205,11 @@ func FetchServiceCatalog(ctx context.Context, sellerURL string) ([]CatalogEntry, return nil, fmt.Errorf("read catalog body: %w", err) } - var entries []CatalogEntry - if err := json.Unmarshal(body, &entries); err != nil { + var catalog schemas.ServiceCatalog + if err := json.Unmarshal(body, &catalog); err != nil { return nil, fmt.Errorf("parse catalog JSON: %w", err) } - return entries, nil + return catalog.Services, nil } // PickCatalogEntry picks the entry whose endpoint matches sellerURL. diff --git a/internal/schemas/service-catalog.schema.json b/internal/schemas/service-catalog.schema.json index 9da9fa17..366a9b85 100644 --- a/internal/schemas/service-catalog.schema.json +++ b/internal/schemas/service-catalog.schema.json @@ -3,9 +3,33 @@ "$id": "https://obol.org/schemas/service-catalog.schema.json", "title": "Obol Service Catalog", "description": "Public /api/services.json catalog served by x402 sellers.", - "type": "array", - "items": { - "$ref": "#/$defs/service" + "type": "object", + "additionalProperties": false, + "required": [ + "displayName", + "tagline", + "logoUrl", + "services" + ], + "properties": { + "displayName": { + "type": "string", + "minLength": 1 + }, + "tagline": { + "type": "string", + "minLength": 1 + }, + "logoUrl": { + "type": "string", + "minLength": 1 + }, + "services": { + "type": "array", + "items": { + "$ref": "#/$defs/service" + } + } }, "$defs": { "evmAddress": { diff --git a/internal/schemas/service_catalog.go b/internal/schemas/service_catalog.go index d2282267..5b052827 100644 --- a/internal/schemas/service_catalog.go +++ b/internal/schemas/service_catalog.go @@ -103,10 +103,18 @@ type ServiceCatalogEIP712Domain struct { Version string `json:"version"` } -// StorefrontProfile is the minimal branding payload served at -// /api/storefront.json for the public storefront. +// StorefrontProfile is operator-set seller branding merged into +// /api/services.json by the controller. type StorefrontProfile struct { DisplayName string `json:"displayName"` Tagline string `json:"tagline"` LogoURL string `json:"logoUrl"` } + +// ServiceCatalog is the public /api/services.json envelope. +type ServiceCatalog struct { + DisplayName string `json:"displayName"` + Tagline string `json:"tagline"` + LogoURL string `json:"logoUrl"` + Services []ServiceCatalogEntry `json:"services"` +} diff --git a/internal/serviceoffercontroller/controller.go b/internal/serviceoffercontroller/controller.go index 1fa9e33f..7be4f200 100644 --- a/internal/serviceoffercontroller/controller.go +++ b/internal/serviceoffercontroller/controller.go @@ -353,7 +353,7 @@ func (c *Controller) enqueueStorefrontProfileRefresh(obj any) { func (c *Controller) enqueueSkillCatalogRefresh() { items := c.offerInformer.GetStore().List() if len(items) > 0 { - // Any single offer reconcile rebuilds the full catalog (including storefront.json). + // Any single offer reconcile rebuilds the full catalog. c.enqueueOffer(items[0]) return } @@ -1220,12 +1220,11 @@ func (c *Controller) reconcileSkillCatalog(ctx context.Context, override *moneti } content := buildSkillCatalogMarkdown(offers, baseURL) - servicesJSON := buildServiceCatalogJSON(offers, baseURL) storefrontProfile, err := c.loadStorefrontProfile(ctx) if err != nil { return err } - storefrontJSON := buildStorefrontJSON(baseURL, storefrontProfile) + servicesJSON := buildServiceCatalogJSON(offers, baseURL, storefrontProfile) // buildOpenAPIDocument prefers the tunnel URL for the public `servers[0]` // entry; baseURL is sourced from obol-stack-config.tunnelURL via // registrationBaseURL, which is also what /skill.md and services.json @@ -1234,9 +1233,9 @@ func (c *Controller) reconcileSkillCatalog(ctx context.Context, override *moneti // when tunnelURL changes — see enqueueDiscoveryRefresh). openAPIJSON := buildOpenAPIDocument(offers, baseURL) apiDocsHTML := scalarHTML() - contentHash := fmt.Sprintf("%x", md5Sum(content+servicesJSON+storefrontJSON+openAPIJSON+apiDocsHTML))[:8] + contentHash := fmt.Sprintf("%x", md5Sum(content+servicesJSON+openAPIJSON+apiDocsHTML))[:8] - if err := c.applyObject(ctx, c.configMaps.Namespace(skillCatalogNamespace), buildSkillCatalogConfigMap(content, servicesJSON, storefrontJSON, openAPIJSON, apiDocsHTML)); err != nil { + if err := c.applyObject(ctx, c.configMaps.Namespace(skillCatalogNamespace), buildSkillCatalogConfigMap(content, servicesJSON, openAPIJSON, apiDocsHTML)); err != nil { return err } if err := c.applyObject(ctx, c.deployments.Namespace(skillCatalogNamespace), buildSkillCatalogDeployment(contentHash)); err != nil { @@ -1251,9 +1250,6 @@ func (c *Controller) reconcileSkillCatalog(ctx context.Context, override *moneti if err := c.applyObject(ctx, c.httpRoutes.Namespace(skillCatalogNamespace), buildServicesJSONHTTPRoute()); err != nil { return err } - if err := c.applyObject(ctx, c.httpRoutes.Namespace(skillCatalogNamespace), buildStorefrontJSONHTTPRoute()); err != nil { - return err - } if err := c.applyObject(ctx, c.httpRoutes.Namespace(skillCatalogNamespace), buildOpenAPIHTTPRoute()); err != nil { return err } diff --git a/internal/serviceoffercontroller/identity_render.go b/internal/serviceoffercontroller/identity_render.go index 39b8a0b1..89623d36 100644 --- a/internal/serviceoffercontroller/identity_render.go +++ b/internal/serviceoffercontroller/identity_render.go @@ -1,16 +1,12 @@ package serviceoffercontroller import ( - "encoding/json" "fmt" - "log" "sort" "strings" "github.com/ObolNetwork/obol-stack/internal/erc8004" "github.com/ObolNetwork/obol-stack/internal/monetizeapi" - "github.com/ObolNetwork/obol-stack/internal/schemas" - "github.com/ObolNetwork/obol-stack/internal/storefront" ) // IdentityRegistrationView is the identity-driven inputs needed to render an @@ -105,20 +101,6 @@ func identityDocumentMetadata(identity *monetizeapi.AgentIdentity, offers []*mon return name, description, image } -func buildStorefrontJSON(baseURL string, explicit *schemas.StorefrontProfile) string { - profile := storefront.ResolvePublished(explicit, baseURL) - out, err := json.MarshalIndent(profile, "", " ") - if err != nil { - log.Printf("serviceoffer-controller: marshal storefront profile: %v", err) - fallback, ferr := json.MarshalIndent(storefront.ResolvePublished(nil, baseURL), "", " ") - if ferr != nil { - return "{}" - } - return string(fallback) - } - return string(out) -} - func buildIdentityOnChainRegistrations(identity *monetizeapi.AgentIdentity) []erc8004.OnChainReg { if identity == nil { return nil diff --git a/internal/serviceoffercontroller/identity_render_test.go b/internal/serviceoffercontroller/identity_render_test.go index bcad6462..12ed8162 100644 --- a/internal/serviceoffercontroller/identity_render_test.go +++ b/internal/serviceoffercontroller/identity_render_test.go @@ -251,39 +251,39 @@ func TestBuildIdentityRegistrationDocument_DescriptionPrecedence(t *testing.T) { } } -func TestBuildStorefrontJSON_UsesExplicitProfile(t *testing.T) { +func TestBuildServiceCatalogJSON_UsesExplicitProfile(t *testing.T) { explicit := &schemas.StorefrontProfile{ DisplayName: "Acme Inference", Tagline: "Custom seller tagline", LogoURL: "https://cdn.example/logo.png", } - jsonStr := buildStorefrontJSON("https://seller.example", explicit) - var profile schemas.StorefrontProfile - if err := json.Unmarshal([]byte(jsonStr), &profile); err != nil { - t.Fatalf("unmarshal storefront profile: %v", err) + jsonStr := buildServiceCatalogJSON(nil, "https://seller.example", explicit) + catalog := decodeServiceCatalog(t, jsonStr) + if catalog.DisplayName != "Acme Inference" { + t.Fatalf("DisplayName = %q", catalog.DisplayName) } - if profile.DisplayName != "Acme Inference" { - t.Fatalf("DisplayName = %q", profile.DisplayName) + if catalog.Tagline != "Custom seller tagline" { + t.Fatalf("Tagline = %q", catalog.Tagline) } - if profile.Tagline != "Custom seller tagline" { - t.Fatalf("Tagline = %q", profile.Tagline) - } - if profile.LogoURL != "https://cdn.example/logo.png" { - t.Fatalf("LogoURL = %q", profile.LogoURL) + if catalog.LogoURL != "https://cdn.example/logo.png" { + t.Fatalf("LogoURL = %q", catalog.LogoURL) } } -func TestBuildStorefrontJSON_Defaults(t *testing.T) { - jsonStr := buildStorefrontJSON("https://seller.example", nil) - var profile schemas.StorefrontProfile - if err := json.Unmarshal([]byte(jsonStr), &profile); err != nil { - t.Fatalf("unmarshal storefront profile: %v", err) +func TestBuildServiceCatalogJSON_DefaultBranding(t *testing.T) { + jsonStr := buildServiceCatalogJSON(nil, "https://seller.example", nil) + var catalog schemas.ServiceCatalog + if err := json.Unmarshal([]byte(jsonStr), &catalog); err != nil { + t.Fatalf("unmarshal catalog: %v", err) } - if profile.DisplayName != "Obol Stack" { - t.Fatalf("DisplayName = %q", profile.DisplayName) + if catalog.DisplayName != "Obol Stack" { + t.Fatalf("DisplayName = %q", catalog.DisplayName) } - if profile.Tagline == "" { + if catalog.Tagline == "" { t.Fatal("Tagline empty") } + if catalog.LogoURL != "https://seller.example/obol-stack-logo.png" { + t.Fatalf("LogoURL = %q", catalog.LogoURL) + } } diff --git a/internal/serviceoffercontroller/render.go b/internal/serviceoffercontroller/render.go index fb4c21ab..98a7c0c4 100644 --- a/internal/serviceoffercontroller/render.go +++ b/internal/serviceoffercontroller/render.go @@ -14,6 +14,7 @@ import ( "github.com/ObolNetwork/obol-stack/internal/erc8004" "github.com/ObolNetwork/obol-stack/internal/monetizeapi" "github.com/ObolNetwork/obol-stack/internal/schemas" + "github.com/ObolNetwork/obol-stack/internal/storefront" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/types" @@ -24,7 +25,6 @@ const ( skillCatalogConfigMapName = "obol-skill-md" skillCatalogRouteName = "obol-skill-md-route" servicesJSONRouteName = "obol-services-json-route" - storefrontJSONRouteName = "obol-storefront-json-route" openAPIRouteName = "obol-openapi-route" apiDocsRouteName = "obol-api-docs-route" ) @@ -248,7 +248,7 @@ func agentIdentityLabels(identity *monetizeapi.AgentIdentity, appName string) ma } } -func buildSkillCatalogConfigMap(content, servicesJSON, storefrontJSON, openAPIJSON, apiDocsHTML string) *unstructured.Unstructured { +func buildSkillCatalogConfigMap(content, servicesJSON, openAPIJSON, apiDocsHTML string) *unstructured.Unstructured { return &unstructured.Unstructured{ Object: map[string]any{ "apiVersion": "v1", @@ -262,12 +262,11 @@ func buildSkillCatalogConfigMap(content, servicesJSON, storefrontJSON, openAPIJS }, }, "data": map[string]any{ - "skill.md": content, - "services.json": servicesJSON, - "storefront.json": storefrontJSON, - "openapi.json": openAPIJSON, - "api.html": apiDocsHTML, - "httpd.conf": ".md:text/markdown\n.json:application/json\n.html:text/html\n", + "skill.md": content, + "services.json": servicesJSON, + "openapi.json": openAPIJSON, + "api.html": apiDocsHTML, + "httpd.conf": ".md:text/markdown\n.json:application/json\n.html:text/html\n", }, }, } @@ -328,7 +327,6 @@ func buildSkillCatalogDeployment(contentHash string) *unstructured.Unstructured "items": []any{ map[string]any{"key": "skill.md", "path": "skill.md"}, map[string]any{"key": "services.json", "path": "api/services.json"}, - map[string]any{"key": "storefront.json", "path": "api/storefront.json"}, map[string]any{"key": "openapi.json", "path": "openapi.json"}, // busybox httpd resolves /api/ → /api/index.html, so the // Scalar shell sits at api/index.html. The /api Exact @@ -579,50 +577,6 @@ func buildServicesJSONHTTPRoute() *unstructured.Unstructured { } } -func buildStorefrontJSONHTTPRoute() *unstructured.Unstructured { - return &unstructured.Unstructured{ - Object: map[string]any{ - "apiVersion": "gateway.networking.k8s.io/v1", - "kind": "HTTPRoute", - "metadata": map[string]any{ - "name": storefrontJSONRouteName, - "namespace": skillCatalogNamespace, - "labels": map[string]any{ - "obol.org/managed-by": "serviceoffer-controller", - }, - }, - "spec": map[string]any{ - "parentRefs": []any{ - map[string]any{ - "name": "traefik-gateway", - "namespace": "traefik", - "sectionName": "web", - }, - }, - "rules": []any{ - map[string]any{ - "matches": []any{ - map[string]any{ - "path": map[string]any{ - "type": "Exact", - "value": "/api/storefront.json", - }, - }, - }, - "backendRefs": []any{ - map[string]any{ - "name": skillCatalogConfigMapName, - "namespace": skillCatalogNamespace, - "port": int64(8080), - }, - }, - }, - }, - }, - }, - } -} - func buildHTTPRoute(offer *monetizeapi.ServiceOffer) *unstructured.Unstructured { obj := &unstructured.Unstructured{ Object: map[string]any{ @@ -1164,8 +1118,8 @@ func offerAwaitingRegistration(offer *monetizeapi.ServiceOffer) bool { return false } -// buildServiceCatalogJSON returns a JSON array of operationally-ready -// ServiceOffers for the public storefront feed (/api/services.json). +// buildServiceCatalogJSON returns the public /api/services.json envelope: +// seller branding plus operationally-ready ServiceOffers. // // The filter is operationally-ready (route published, payment gate // active, upstream healthy) rather than the stricter controller @@ -1175,8 +1129,9 @@ func offerAwaitingRegistration(offer *monetizeapi.ServiceOffer) bool { // up` until they funded the agent wallet and ran `obol sell register`. // That UX failed the "all paid services come back automatically" promise // of the stack-up resume feature. -func buildServiceCatalogJSON(offers []*monetizeapi.ServiceOffer, baseURL string) string { +func buildServiceCatalogJSON(offers []*monetizeapi.ServiceOffer, baseURL string, explicit *schemas.StorefrontProfile) string { baseURL = strings.TrimRight(baseURL, "/") + profile := storefront.ResolvePublished(explicit, baseURL) now := time.Now() var ready []*monetizeapi.ServiceOffer @@ -1275,9 +1230,34 @@ func buildServiceCatalogJSON(offers []*monetizeapi.ServiceOffer, baseURL string) services = append(services, svc) } - out, err := json.MarshalIndent(services, "", " ") + catalog := schemas.ServiceCatalog{ + DisplayName: profile.DisplayName, + Tagline: profile.Tagline, + LogoURL: profile.LogoURL, + Services: services, + } + if catalog.Services == nil { + catalog.Services = []schemas.ServiceCatalogEntry{} + } + + out, err := json.MarshalIndent(catalog, "", " ") + if err != nil { + return fallbackServiceCatalogJSON(baseURL) + } + return string(out) +} + +func fallbackServiceCatalogJSON(baseURL string) string { + profile := storefront.ResolvePublished(nil, baseURL) + catalog := schemas.ServiceCatalog{ + DisplayName: profile.DisplayName, + Tagline: profile.Tagline, + LogoURL: profile.LogoURL, + Services: []schemas.ServiceCatalogEntry{}, + } + out, err := json.MarshalIndent(catalog, "", " ") if err != nil { - return "[]" + return `{"displayName":"Obol Stack","tagline":"Unlock Agent and API services with digital payments.","logoUrl":"/obol-stack-logo.png","services":[]}` } return string(out) } diff --git a/internal/serviceoffercontroller/render_builders_test.go b/internal/serviceoffercontroller/render_builders_test.go index c08ea936..f9979fbd 100644 --- a/internal/serviceoffercontroller/render_builders_test.go +++ b/internal/serviceoffercontroller/render_builders_test.go @@ -103,7 +103,7 @@ func TestBuildAgentIdentityRegistrationDeployment_RestrictedPSS(t *testing.T) { // TestBuildSkillCatalogConfigMap: exposes skill.md + services.json + openapi.json // + api docs HTML + httpd conf. func TestBuildSkillCatalogConfigMap(t *testing.T) { - cm := buildSkillCatalogConfigMap("# Catalog", `[{"name":"a"}]`, `{"displayName":"Acme"}`, `{"openapi":"3.1.0"}`, "shell") + cm := buildSkillCatalogConfigMap("# Catalog", `{"displayName":"Acme","tagline":"t","logoUrl":"https://x/logo.png","services":[{"name":"a"}]}`, `{"openapi":"3.1.0"}`, "shell") if cm.GetName() != skillCatalogConfigMapName { t.Errorf("name = %q, want %q", cm.GetName(), skillCatalogConfigMapName) @@ -115,12 +115,9 @@ func TestBuildSkillCatalogConfigMap(t *testing.T) { if data["skill.md"] != "# Catalog" { t.Errorf("skill.md payload mismatch, got %v", data["skill.md"]) } - if data["services.json"] != `[{"name":"a"}]` { + if !strings.Contains(data["services.json"].(string), `"displayName":"Acme"`) { t.Errorf("services.json payload mismatch, got %v", data["services.json"]) } - if data["storefront.json"] != `{"displayName":"Acme"}` { - t.Errorf("storefront.json payload mismatch, got %v", data["storefront.json"]) - } if data["openapi.json"] != `{"openapi":"3.1.0"}` { t.Errorf("openapi.json payload mismatch, got %v", data["openapi.json"]) } @@ -165,10 +162,9 @@ func TestBuildSkillCatalogDeployment(t *testing.T) { podSpec, _ := template1["spec"].(map[string]any) volumes, _ := podSpec["volumes"].([]any) expectedPaths := map[string]string{ - "services.json": "api/services.json", - "storefront.json": "api/storefront.json", - "openapi.json": "openapi.json", - "api.html": "api/index.html", + "services.json": "api/services.json", + "openapi.json": "openapi.json", + "api.html": "api/index.html", } foundPaths := map[string]string{} for _, v := range volumes { diff --git a/internal/serviceoffercontroller/render_test.go b/internal/serviceoffercontroller/render_test.go index c903a338..9291a50a 100644 --- a/internal/serviceoffercontroller/render_test.go +++ b/internal/serviceoffercontroller/render_test.go @@ -38,6 +38,15 @@ func assertServiceCatalogSchema(t *testing.T, jsonStr string) { } } +func decodeServiceCatalog(t *testing.T, jsonStr string) schemas.ServiceCatalog { + t.Helper() + var catalog schemas.ServiceCatalog + if err := json.Unmarshal([]byte(jsonStr), &catalog); err != nil { + t.Fatalf("unmarshal catalog: %v\n%s", err, jsonStr) + } + return catalog +} + func TestBuildHTTPRoute(t *testing.T) { offer := &monetizeapi.ServiceOffer{ ObjectMeta: metav1.ObjectMeta{Name: "demo", Namespace: "llm", UID: types.UID("demo-uid")}, @@ -757,13 +766,10 @@ func TestBuildServiceCatalogJSON(t *testing.T) { }, } - jsonStr := buildServiceCatalogJSON([]*monetizeapi.ServiceOffer{readyOffer, notReadyOffer}, "https://example.com") + jsonStr := buildServiceCatalogJSON([]*monetizeapi.ServiceOffer{readyOffer, notReadyOffer}, "https://example.com", nil) assertServiceCatalogSchema(t, jsonStr) - var services []schemas.ServiceCatalogEntry - if err := json.Unmarshal([]byte(jsonStr), &services); err != nil { - t.Fatalf("invalid JSON: %v\n%s", err, jsonStr) - } + services := decodeServiceCatalog(t, jsonStr).Services if len(services) != 1 { t.Fatalf("expected 1 ready service, got %d", len(services)) @@ -809,13 +815,10 @@ func TestBuildServiceCatalogJSON_MultiPayment(t *testing.T) { Status: monetizeapi.ServiceOfferStatus{Conditions: []monetizeapi.Condition{{Type: "Ready", Status: "True"}}}, } - jsonStr := buildServiceCatalogJSON([]*monetizeapi.ServiceOffer{offer}, "https://example.com") + jsonStr := buildServiceCatalogJSON([]*monetizeapi.ServiceOffer{offer}, "https://example.com", nil) assertServiceCatalogSchema(t, jsonStr) - var services []schemas.ServiceCatalogEntry - if err := json.Unmarshal([]byte(jsonStr), &services); err != nil { - t.Fatalf("invalid JSON: %v", err) - } + services := decodeServiceCatalog(t, jsonStr).Services if len(services) != 1 { t.Fatalf("want 1 service, got %d", len(services)) } @@ -860,13 +863,10 @@ func TestBuildServiceCatalogJSON_ZeroDecimalAssetAtomicPrice(t *testing.T) { Status: monetizeapi.ServiceOfferStatus{Conditions: []monetizeapi.Condition{{Type: "Ready", Status: "True"}}}, } - jsonStr := buildServiceCatalogJSON([]*monetizeapi.ServiceOffer{offer}, "https://example.com") + jsonStr := buildServiceCatalogJSON([]*monetizeapi.ServiceOffer{offer}, "https://example.com", nil) assertServiceCatalogSchema(t, jsonStr) - var services []schemas.ServiceCatalogEntry - if err := json.Unmarshal([]byte(jsonStr), &services); err != nil { - t.Fatalf("invalid JSON: %v", err) - } + services := decodeServiceCatalog(t, jsonStr).Services if len(services) != 1 || len(services[0].Payments) != 1 { t.Fatalf("services/payments = %+v, want one payment", services) } @@ -876,10 +876,14 @@ func TestBuildServiceCatalogJSON_ZeroDecimalAssetAtomicPrice(t *testing.T) { } func TestBuildServiceCatalogJSON_Empty(t *testing.T) { - jsonStr := buildServiceCatalogJSON(nil, "https://example.com") + jsonStr := buildServiceCatalogJSON(nil, "https://example.com", nil) assertServiceCatalogSchema(t, jsonStr) - if jsonStr != "[]" { - t.Errorf("expected empty array, got %q", jsonStr) + catalog := decodeServiceCatalog(t, jsonStr) + if len(catalog.Services) != 0 { + t.Errorf("expected empty services, got %d", len(catalog.Services)) + } + if catalog.DisplayName == "" || catalog.Tagline == "" || catalog.LogoURL == "" { + t.Errorf("expected default seller branding, got %+v", catalog) } } @@ -914,13 +918,10 @@ func TestBuildServiceCatalogJSON_AgentOfferUsesResolvedModel(t *testing.T) { }, } - jsonStr := buildServiceCatalogJSON([]*monetizeapi.ServiceOffer{offer}, "https://seller.example") + jsonStr := buildServiceCatalogJSON([]*monetizeapi.ServiceOffer{offer}, "https://seller.example", nil) assertServiceCatalogSchema(t, jsonStr) - var services []schemas.ServiceCatalogEntry - if err := json.Unmarshal([]byte(jsonStr), &services); err != nil { - t.Fatalf("invalid JSON: %v\n%s", err, jsonStr) - } + services := decodeServiceCatalog(t, jsonStr).Services if len(services) != 1 { t.Fatalf("expected 1 service, got %d: %s", len(services), jsonStr) } @@ -991,12 +992,9 @@ func TestBuildServiceCatalogJSON_ExcludesNonReady(t *testing.T) { }, } - jsonStr := buildServiceCatalogJSON(offers, "https://example.com") + jsonStr := buildServiceCatalogJSON(offers, "https://example.com", nil) - var services []schemas.ServiceCatalogEntry - if err := json.Unmarshal([]byte(jsonStr), &services); err != nil { - t.Fatalf("invalid JSON: %v\n%s", err, jsonStr) - } + services := decodeServiceCatalog(t, jsonStr).Services if len(services) != 1 { t.Fatalf("expected exactly 1 service (ready-svc), got %d: %+v", len(services), services) } @@ -1007,14 +1005,19 @@ func TestBuildServiceCatalogJSON_ExcludesNonReady(t *testing.T) { // Pure-additive wire schema: active offers must serialize without // `available` (no field at all). Consumers detect drain via the // presence of `drainEndsAt`, not via a legacy `available` boolean. - var raw []map[string]any + var raw map[string]any if err := json.Unmarshal([]byte(jsonStr), &raw); err != nil { t.Fatalf("invalid raw JSON: %v\n%s", err, jsonStr) } - if _, ok := raw[0]["available"]; ok { + servicesRaw, _ := raw["services"].([]any) + if len(servicesRaw) != 1 { + t.Fatalf("expected 1 raw service entry, got %d", len(servicesRaw)) + } + svc0, _ := servicesRaw[0].(map[string]any) + if _, ok := svc0["available"]; ok { t.Errorf("ready-svc JSON contains `available` key; drain wire schema must be additive (drainEndsAt only)") } - if _, ok := raw[0]["drainEndsAt"]; ok { + if _, ok := svc0["drainEndsAt"]; ok { t.Errorf("ready-svc JSON contains `drainEndsAt`; should only appear on draining offers") } } @@ -1059,17 +1062,19 @@ func TestBuildServiceCatalogJSON_DrainLifecycle(t *testing.T) { exp.Spec.DrainAt = &expDrainAt exp.Spec.DrainGracePeriod = &expGrace - jsonStr := buildServiceCatalogJSON([]*monetizeapi.ServiceOffer{&pre, &mid, &exp}, "https://example.com") - var raw []map[string]any + jsonStr := buildServiceCatalogJSON([]*monetizeapi.ServiceOffer{&pre, &mid, &exp}, "https://example.com", nil) + var raw map[string]any if err := json.Unmarshal([]byte(jsonStr), &raw); err != nil { t.Fatalf("invalid JSON: %v\n%s", err, jsonStr) } - if len(raw) != 2 { - t.Fatalf("expected 2 services (pre + mid; expired filtered out), got %d: %+v", len(raw), raw) + servicesRaw, _ := raw["services"].([]any) + if len(servicesRaw) != 2 { + t.Fatalf("expected 2 services (pre + mid; expired filtered out), got %d: %+v", len(servicesRaw), servicesRaw) } byName := map[string]map[string]any{} - for _, s := range raw { + for _, item := range servicesRaw { + s, _ := item.(map[string]any) name, _ := s["name"].(string) byName[name] = s } @@ -1125,12 +1130,9 @@ func TestBuildServiceCatalogJSON_SortOrder(t *testing.T) { makeOffer("bravo"), } - jsonStr := buildServiceCatalogJSON(offers, "https://example.com") + jsonStr := buildServiceCatalogJSON(offers, "https://example.com", nil) - var services []schemas.ServiceCatalogEntry - if err := json.Unmarshal([]byte(jsonStr), &services); err != nil { - t.Fatalf("invalid JSON: %v", err) - } + services := decodeServiceCatalog(t, jsonStr).Services names := []string{services[0].Name, services[1].Name, services[2].Name} want := []string{"alpha", "bravo", "charlie"} for i := range want { @@ -1161,13 +1163,10 @@ func TestBuildServiceCatalogJSON_PerMTokPricing(t *testing.T) { }, } - jsonStr := buildServiceCatalogJSON([]*monetizeapi.ServiceOffer{offer}, "https://example.com") + jsonStr := buildServiceCatalogJSON([]*monetizeapi.ServiceOffer{offer}, "https://example.com", nil) assertServiceCatalogSchema(t, jsonStr) - var services []schemas.ServiceCatalogEntry - if err := json.Unmarshal([]byte(jsonStr), &services); err != nil { - t.Fatalf("invalid JSON: %v", err) - } + services := decodeServiceCatalog(t, jsonStr).Services if len(services) != 1 { t.Fatalf("expected 1 service, got %d", len(services)) } @@ -1209,13 +1208,10 @@ func TestBuildServiceCatalogJSON_FallbackDescription(t *testing.T) { }, } - jsonStr := buildServiceCatalogJSON([]*monetizeapi.ServiceOffer{offer}, "https://example.com") + jsonStr := buildServiceCatalogJSON([]*monetizeapi.ServiceOffer{offer}, "https://example.com", nil) assertServiceCatalogSchema(t, jsonStr) - var services []schemas.ServiceCatalogEntry - if err := json.Unmarshal([]byte(jsonStr), &services); err != nil { - t.Fatalf("invalid JSON: %v", err) - } + services := decodeServiceCatalog(t, jsonStr).Services if len(services) != 1 { t.Fatalf("expected 1 service, got %d", len(services)) } @@ -1239,12 +1235,9 @@ func TestBuildServiceCatalogJSON_BaseURLTrailingSlash(t *testing.T) { }, } - jsonStr := buildServiceCatalogJSON([]*monetizeapi.ServiceOffer{offer}, "https://example.com/") + jsonStr := buildServiceCatalogJSON([]*monetizeapi.ServiceOffer{offer}, "https://example.com/", nil) - var services []schemas.ServiceCatalogEntry - if err := json.Unmarshal([]byte(jsonStr), &services); err != nil { - t.Fatalf("invalid JSON: %v", err) - } + services := decodeServiceCatalog(t, jsonStr).Services if len(services) != 1 { t.Fatalf("expected 1 service, got %d", len(services)) } @@ -1278,13 +1271,10 @@ func TestBuildServiceCatalogJSON_AssetAndCAIP2Defaults(t *testing.T) { }, } - jsonStr := buildServiceCatalogJSON([]*monetizeapi.ServiceOffer{offer}, "https://example.com") + jsonStr := buildServiceCatalogJSON([]*monetizeapi.ServiceOffer{offer}, "https://example.com", nil) assertServiceCatalogSchema(t, jsonStr) - var services []schemas.ServiceCatalogEntry - if err := json.Unmarshal([]byte(jsonStr), &services); err != nil { - t.Fatalf("invalid JSON: %v\n%s", err, jsonStr) - } + services := decodeServiceCatalog(t, jsonStr).Services if len(services) != 1 { t.Fatalf("expected 1 service, got %d", len(services)) } @@ -1365,13 +1355,10 @@ func TestBuildServiceCatalogJSON_ExplicitOBOLToken(t *testing.T) { }, } - jsonStr := buildServiceCatalogJSON([]*monetizeapi.ServiceOffer{offer}, "https://example.com") + jsonStr := buildServiceCatalogJSON([]*monetizeapi.ServiceOffer{offer}, "https://example.com", nil) assertServiceCatalogSchema(t, jsonStr) - var services []schemas.ServiceCatalogEntry - if err := json.Unmarshal([]byte(jsonStr), &services); err != nil { - t.Fatalf("invalid JSON: %v\n%s", err, jsonStr) - } + services := decodeServiceCatalog(t, jsonStr).Services if len(services) != 1 { t.Fatalf("expected 1 service, got %d", len(services)) } @@ -1533,11 +1520,8 @@ func TestBuildServiceCatalogJSON_IncludesPendingRegistrationOffers(t *testing.T) }, }, } - jsonStr := buildServiceCatalogJSON(offers, "https://inference.example.com") - var services []schemas.ServiceCatalogEntry - if err := json.Unmarshal([]byte(jsonStr), &services); err != nil { - t.Fatalf("invalid JSON: %v\n%s", err, jsonStr) - } + jsonStr := buildServiceCatalogJSON(offers, "https://inference.example.com", nil) + services := decodeServiceCatalog(t, jsonStr).Services if len(services) != 1 { t.Fatalf("expected 1 service in catalog, got %d: %+v", len(services), services) } @@ -1575,11 +1559,8 @@ func TestBuildServiceCatalogJSON_RegistrationPendingFalseForFullyReady(t *testin }, }, } - jsonStr := buildServiceCatalogJSON(offers, "https://example.com") - var services []schemas.ServiceCatalogEntry - if err := json.Unmarshal([]byte(jsonStr), &services); err != nil { - t.Fatalf("invalid JSON: %v\n%s", err, jsonStr) - } + jsonStr := buildServiceCatalogJSON(offers, "https://example.com", nil) + services := decodeServiceCatalog(t, jsonStr).Services if len(services) != 1 { t.Fatalf("expected 1 service, got %d", len(services)) } diff --git a/web/public-storefront/next.config.ts b/web/public-storefront/next.config.ts index 987291af..42291dc4 100644 --- a/web/public-storefront/next.config.ts +++ b/web/public-storefront/next.config.ts @@ -13,10 +13,6 @@ const nextConfig: NextConfig = { source: "/api/services.json", destination: `${servicesURL}/api/services.json`, }, - { - source: "/api/storefront.json", - destination: `${servicesURL}/api/storefront.json`, - }, ]; }, }; diff --git a/web/public-storefront/src/app/page.tsx b/web/public-storefront/src/app/page.tsx index a4c000cd..158cdada 100644 --- a/web/public-storefront/src/app/page.tsx +++ b/web/public-storefront/src/app/page.tsx @@ -1,4 +1,4 @@ -import { DEFAULT_HERO_TITLE, fetchServices, fetchStorefront } from "@/lib/catalog"; +import { DEFAULT_HERO_TITLE, fetchCatalogDocument } from "@/lib/catalog"; import { Header } from "@/components/Header"; import { ServicesList } from "@/components/ServicesList"; import { PaymentFlow } from "@/components/PaymentFlow"; @@ -6,23 +6,20 @@ export const dynamic = "force-dynamic"; export const revalidate = 0; export default async function Home() { - const [services, storefront] = await Promise.all([ - fetchServices(), - fetchStorefront(), - ]); + const catalog = await fetchCatalogDocument(); return ( <> -
+

{DEFAULT_HERO_TITLE}

-

{storefront.tagline}

+

{catalog.tagline}

- +
diff --git a/web/public-storefront/src/components/ServicesList.tsx b/web/public-storefront/src/components/ServicesList.tsx index 81679370..889f8801 100644 --- a/web/public-storefront/src/components/ServicesList.tsx +++ b/web/public-storefront/src/components/ServicesList.tsx @@ -15,7 +15,10 @@ export function ServicesList({ initial }: { initial: Service[] }) { try { const res = await fetch("/api/services.json", { cache: "no-store" }); if (!res.ok) return; - const fresh: Service[] = await res.json(); + const data = await res.json(); + const fresh: Service[] = Array.isArray(data?.services) + ? data.services + : []; if (!cancelled) setServices(fresh); } catch { // Network blip — keep existing list, retry next tick. diff --git a/web/public-storefront/src/lib/catalog.ts b/web/public-storefront/src/lib/catalog.ts index 10db92af..e1ad5eee 100644 --- a/web/public-storefront/src/lib/catalog.ts +++ b/web/public-storefront/src/lib/catalog.ts @@ -30,27 +30,45 @@ export function isDefaultStorefrontLogo(logoUrl: string): boolean { export const DEFAULT_HERO_TITLE = "Agent services"; -export const fetchServices = cache(async (): Promise => { - try { - const res = await fetchCatalog("/api/services.json"); - if (!res.ok) return []; - return res.json(); - } catch { - return []; +export interface ServiceCatalogDocument extends StorefrontProfile { + services: Service[]; +} + +function parseCatalogDocument(data: unknown): ServiceCatalogDocument { + if (!data || typeof data !== "object" || Array.isArray(data)) { + return { ...DEFAULT_STOREFRONT, services: [] }; } + const doc = data as Partial; + return { + displayName: doc.displayName || DEFAULT_STOREFRONT.displayName, + tagline: doc.tagline || DEFAULT_STOREFRONT.tagline, + logoUrl: doc.logoUrl || DEFAULT_STOREFRONT.logoUrl, + services: Array.isArray(doc.services) ? doc.services : [], + }; +} + +export const fetchCatalogDocument = cache( + async (): Promise => { + try { + const res = await fetchCatalog("/api/services.json"); + if (!res.ok) return { ...DEFAULT_STOREFRONT, services: [] }; + return parseCatalogDocument(await res.json()); + } catch { + return { ...DEFAULT_STOREFRONT, services: [] }; + } + }, +); + +export const fetchServices = cache(async (): Promise => { + const catalog = await fetchCatalogDocument(); + return catalog.services; }); export const fetchStorefront = cache(async (): Promise => { - try { - const res = await fetchCatalog("/api/storefront.json"); - if (!res.ok) return DEFAULT_STOREFRONT; - const data = (await res.json()) as Partial; - return { - displayName: data.displayName || DEFAULT_STOREFRONT.displayName, - tagline: data.tagline || DEFAULT_STOREFRONT.tagline, - logoUrl: data.logoUrl || DEFAULT_STOREFRONT.logoUrl, - }; - } catch { - return DEFAULT_STOREFRONT; - } + const catalog = await fetchCatalogDocument(); + return { + displayName: catalog.displayName, + tagline: catalog.tagline, + logoUrl: catalog.logoUrl, + }; }); From b734ca0aae5c0b2e75124956df6b3cf3bb7a5328 Mon Sep 17 00:00:00 2001 From: bussyjd Date: Thu, 25 Jun 2026 16:59:54 +0400 Subject: [PATCH 5/5] fix(storefront): resolve relative logo URLs in OG image --- internal/serviceoffercontroller/controller.go | 76 +++++++++---------- web/public-storefront/src/app/layout.tsx | 21 +---- .../src/app/opengraph-image.tsx | 20 ++++- web/public-storefront/src/lib/site-url.ts | 34 +++++++++ 4 files changed, 89 insertions(+), 62 deletions(-) create mode 100644 web/public-storefront/src/lib/site-url.ts diff --git a/internal/serviceoffercontroller/controller.go b/internal/serviceoffercontroller/controller.go index 7be4f200..ffbe14fa 100644 --- a/internal/serviceoffercontroller/controller.go +++ b/internal/serviceoffercontroller/controller.go @@ -64,19 +64,19 @@ type Controller struct { httpRoutes dynamic.NamespaceableResourceInterface referenceGrants dynamic.NamespaceableResourceInterface - offerInformer cache.SharedIndexInformer - registrationInformer cache.SharedIndexInformer - identityInformer cache.SharedIndexInformer - purchaseInformer cache.SharedIndexInformer - agentInformer cache.SharedIndexInformer - configMapInformer cache.SharedIndexInformer + offerInformer cache.SharedIndexInformer + registrationInformer cache.SharedIndexInformer + identityInformer cache.SharedIndexInformer + purchaseInformer cache.SharedIndexInformer + agentInformer cache.SharedIndexInformer + configMapInformer cache.SharedIndexInformer storefrontProfileInformer cache.SharedIndexInformer - offerQueue workqueue.TypedRateLimitingInterface[string] - registrationQueue workqueue.TypedRateLimitingInterface[string] - identityQueue workqueue.TypedRateLimitingInterface[string] - purchaseQueue workqueue.TypedRateLimitingInterface[string] - agentQueue workqueue.TypedRateLimitingInterface[string] - catalogMu sync.Mutex + offerQueue workqueue.TypedRateLimitingInterface[string] + registrationQueue workqueue.TypedRateLimitingInterface[string] + identityQueue workqueue.TypedRateLimitingInterface[string] + purchaseQueue workqueue.TypedRateLimitingInterface[string] + agentQueue workqueue.TypedRateLimitingInterface[string] + catalogMu sync.Mutex pendingAuths sync.Map // key: "ns/name" → []map[string]string @@ -120,35 +120,35 @@ func New(cfg *rest.Config) (*Controller, error) { storefrontProfileInformer := storefrontProfileFactory.ForResource(monetizeapi.ConfigMapGVR).Informer() controller := &Controller{ - kubeClient: kubeClient, - dynClient: client, - client: client, - offers: client.Resource(monetizeapi.ServiceOfferGVR), - registrationRequests: client.Resource(monetizeapi.RegistrationRequestGVR), - agentIdentities: client.Resource(monetizeapi.AgentIdentityGVR), - agents: client.Resource(monetizeapi.AgentGVR), - services: client.Resource(monetizeapi.ServiceGVR), - configMaps: client.Resource(monetizeapi.ConfigMapGVR), - deployments: client.Resource(monetizeapi.DeploymentGVR), - middlewares: client.Resource(monetizeapi.MiddlewareGVR), - httpRoutes: client.Resource(monetizeapi.HTTPRouteGVR), - referenceGrants: client.Resource(monetizeapi.ReferenceGrantGVR), - offerInformer: offerInformer, - registrationInformer: registrationInformer, - identityInformer: identityInformer, - purchaseInformer: purchaseInformer, - agentInformer: agentInformer, + kubeClient: kubeClient, + dynClient: client, + client: client, + offers: client.Resource(monetizeapi.ServiceOfferGVR), + registrationRequests: client.Resource(monetizeapi.RegistrationRequestGVR), + agentIdentities: client.Resource(monetizeapi.AgentIdentityGVR), + agents: client.Resource(monetizeapi.AgentGVR), + services: client.Resource(monetizeapi.ServiceGVR), + configMaps: client.Resource(monetizeapi.ConfigMapGVR), + deployments: client.Resource(monetizeapi.DeploymentGVR), + middlewares: client.Resource(monetizeapi.MiddlewareGVR), + httpRoutes: client.Resource(monetizeapi.HTTPRouteGVR), + referenceGrants: client.Resource(monetizeapi.ReferenceGrantGVR), + offerInformer: offerInformer, + registrationInformer: registrationInformer, + identityInformer: identityInformer, + purchaseInformer: purchaseInformer, + agentInformer: agentInformer, configMapInformer: configMapInformer, storefrontProfileInformer: storefrontProfileInformer, offerQueue: workqueue.NewTypedRateLimitingQueue(workqueue.DefaultTypedControllerRateLimiter[string]()), - registrationQueue: workqueue.NewTypedRateLimitingQueue(workqueue.DefaultTypedControllerRateLimiter[string]()), - identityQueue: workqueue.NewTypedRateLimitingQueue(workqueue.DefaultTypedControllerRateLimiter[string]()), - purchaseQueue: workqueue.NewTypedRateLimitingQueue(workqueue.DefaultTypedControllerRateLimiter[string]()), - agentQueue: workqueue.NewTypedRateLimitingQueue(workqueue.DefaultTypedControllerRateLimiter[string]()), - httpClient: &http.Client{Timeout: 3 * time.Second}, - registrationRPCBase: getenvDefault("ERC8004_RPC_BASE", erc8004.DefaultRPCBase), - baseURLOverride: strings.TrimRight(os.Getenv("AGENT_BASE_URL"), "/"), - defaultBaseURL: "http://obol.stack:8080", + registrationQueue: workqueue.NewTypedRateLimitingQueue(workqueue.DefaultTypedControllerRateLimiter[string]()), + identityQueue: workqueue.NewTypedRateLimitingQueue(workqueue.DefaultTypedControllerRateLimiter[string]()), + purchaseQueue: workqueue.NewTypedRateLimitingQueue(workqueue.DefaultTypedControllerRateLimiter[string]()), + agentQueue: workqueue.NewTypedRateLimitingQueue(workqueue.DefaultTypedControllerRateLimiter[string]()), + httpClient: &http.Client{Timeout: 3 * time.Second}, + registrationRPCBase: getenvDefault("ERC8004_RPC_BASE", erc8004.DefaultRPCBase), + baseURLOverride: strings.TrimRight(os.Getenv("AGENT_BASE_URL"), "/"), + defaultBaseURL: "http://obol.stack:8080", } offerInformer.AddEventHandler(cache.ResourceEventHandlerFuncs{ diff --git a/web/public-storefront/src/app/layout.tsx b/web/public-storefront/src/app/layout.tsx index f3a0bb31..3648d952 100644 --- a/web/public-storefront/src/app/layout.tsx +++ b/web/public-storefront/src/app/layout.tsx @@ -1,5 +1,4 @@ import type { Metadata, Viewport } from "next"; -import { headers } from "next/headers"; import { DM_Sans } from "next/font/google"; import type { Service } from "@/types"; import { @@ -7,6 +6,7 @@ import { fetchStorefront, isDefaultStorefrontLogo, } from "@/lib/catalog"; +import { resolveSiteUrl } from "@/lib/site-url"; import "./globals.css"; const dmSans = DM_Sans({ @@ -16,25 +16,6 @@ const dmSans = DM_Sans({ variable: "--font-dm-sans", }); -// Derive the public site URL from the incoming request so OG/Twitter scrapers -// see the tunnel hostname they hit (rather than the in-cluster default). Falls -// back to NEXT_PUBLIC_SITE_URL, then the local-dev default. -async function resolveSiteUrl(): Promise { - try { - const h = await headers(); - const forwardedHost = h.get("x-forwarded-host") ?? h.get("host"); - const forwardedProto = - h.get("x-forwarded-proto") ?? - (forwardedHost?.includes("localhost") || forwardedHost === "obol.stack:8080" - ? "http" - : "https"); - if (forwardedHost) return `${forwardedProto}://${forwardedHost}`; - } catch { - // headers() unavailable (e.g. build-time prerender) — fall through. - } - return process.env.NEXT_PUBLIC_SITE_URL ?? "http://obol.stack:8080"; -} - const DEFAULT_TITLE_SUFFIX = "Buy agent services"; function buildDynamicCopy( diff --git a/web/public-storefront/src/app/opengraph-image.tsx b/web/public-storefront/src/app/opengraph-image.tsx index aa8ba51d..4fe7d515 100644 --- a/web/public-storefront/src/app/opengraph-image.tsx +++ b/web/public-storefront/src/app/opengraph-image.tsx @@ -2,6 +2,7 @@ import { ImageResponse } from "next/og"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { fetchStorefront, isDefaultStorefrontLogo } from "@/lib/catalog"; +import { resolvePublicUrl, resolveSiteUrl } from "@/lib/site-url"; export const runtime = "nodejs"; export const alt = "Obol Stack — buy agent services"; @@ -16,11 +17,17 @@ const BG_PANEL = "#111F22"; const STROKE_GREEN = "#1D5249"; export default async function OpengraphImage() { - const storefront = await fetchStorefront(); + const [storefront, siteUrl] = await Promise.all([ + fetchStorefront(), + resolveSiteUrl(), + ]); const wordmark = readFileSync( join(process.cwd(), "public", "obol-stack-logo.png"), ); const wordmarkDataUrl = `data:image/png;base64,${wordmark.toString("base64")}`; + const customLogoSrc = isDefaultStorefrontLogo(storefront.logoUrl) + ? "" + : resolvePublicUrl(storefront.logoUrl, siteUrl); const Chip = ({ label }: { label: string }) => (
{/* Brand, top-left */} - {isDefaultStorefrontLogo(storefront.logoUrl) ? ( + {customLogoSrc === "" ? ( // eslint-disable-next-line @next/next/no-img-element {/* eslint-disable-next-line @next/next/no-img-element */} {storefront.displayName}
{ + try { + const h = await headers(); + const forwardedHost = h.get("x-forwarded-host") ?? h.get("host"); + const forwardedProto = + h.get("x-forwarded-proto") ?? + (usesPlainHTTP(forwardedHost) ? "http" : "https"); + if (forwardedHost) return `${forwardedProto}://${forwardedHost}`; + } catch { + // headers() unavailable (e.g. build-time prerender) — fall through. + } + return process.env.NEXT_PUBLIC_SITE_URL ?? "http://obol.stack:8080"; +} + +export function resolvePublicUrl(rawUrl: string, siteUrl: string): string { + const value = rawUrl.trim(); + if (!value) return ""; + try { + return new URL(value, siteUrl).toString(); + } catch { + return ""; + } +}