From 5c10db84270d2ab6bf18aef83f651fae9f4aed50 Mon Sep 17 00:00:00 2001 From: HananINouman Date: Wed, 24 Jun 2026 18:44:11 +0300 Subject: [PATCH] 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, + }; });