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/CLAUDE.md b/CLAUDE.md index ac428a33..612f6ec3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,7 +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/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. @@ -99,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. @@ -407,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`. @@ -426,7 +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 -- `/` 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 2ea01bba..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" @@ -3559,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_info.go b/cmd/obol/sell_info.go new file mode 100644 index 00000000..0064d9e1 --- /dev/null +++ b/cmd/obol/sell_info.go @@ -0,0 +1,239 @@ +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 sellInfoCommand(cfg *config.Config) *cli.Command { + return &cli.Command{ + 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 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{ + sellInfoSetCommand(cfg), + sellInfoShowCommand(cfg), + sellInfoResetCommand(cfg), + }, + } +} + +func sellInfoSetCommand(cfg *config.Config) *cli.Command { + return &cli.Command{ + Name: "set", + Usage: "Set seller display name, tagline, and/or logo URL", + Flags: []cli.Flag{ + &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 { + 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 := loadSellerProfile(cfg) + if err != nil { + return err + } + merged := storefront.MergeProfile(current, patch) + if err := applySellerProfile(cfg, merged); err != nil { + return err + } + + published, err := waitForPublishedCatalog(cfg, &merged, 45*time.Second) + if err != nil { + return err + } + + u.Success("Seller profile updated") + printSellerProfile(u, published) + u.Blank() + u.Dim("Verify: curl -s http://obol.stack:8080/api/services.json | jq '{displayName,tagline,logoUrl}'") + return nil + }, + } +} + +func sellInfoShowCommand(cfg *config.Config) *cli.Command { + return &cli.Command{ + Name: "show", + Usage: "Show the current seller 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 := loadSellerProfile(cfg) + if err != nil { + return err + } + baseURL, _ := sellerBaseURL(cfg) + published := storefront.ResolvePublished(&profile, baseURL) + if u.IsJSON() || cmd.Bool("json") { + return u.JSON(published) + } + printSellerProfile(u, published) + return nil + }, + } +} + +func sellInfoResetCommand(cfg *config.Config) *cli.Command { + return &cli.Command{ + Name: "reset", + 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 { + 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 seller profile: %w", err) + } + _ = os.Remove(storefront.ProfileLocalPath(cfg)) + + published, err := waitForPublishedCatalog(cfg, nil, 45*time.Second) + if err != nil { + return err + } + + u.Success("Seller profile reset to defaults") + printSellerProfile(u, published) + return nil + }, + } +} + +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 { + 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 applySellerProfile(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 seller profile: %w", err) + } + return nil +} + +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.services\\.json}") + if err == nil && strings.TrimSpace(raw) != "" { + 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/services.json") +} + +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 sellerBaseURL(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 mustSellerBaseURL(cfg *config.Config) string { + baseURL, err := sellerBaseURL(cfg) + if err != nil || baseURL == "" { + return "http://obol.stack:8080" + } + return baseURL +} + +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 e0ebb3de..5b052827 100644 --- a/internal/schemas/service_catalog.go +++ b/internal/schemas/service_catalog.go @@ -102,3 +102,19 @@ type ServiceCatalogEIP712Domain struct { Name string `json:"name"` Version string `json:"version"` } + +// 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 3c0c26e5..ffbe14fa 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" @@ -62,18 +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 - offerQueue workqueue.TypedRateLimitingInterface[string] - registrationQueue workqueue.TypedRateLimitingInterface[string] - identityQueue workqueue.TypedRateLimitingInterface[string] - purchaseQueue workqueue.TypedRateLimitingInterface[string] - agentQueue workqueue.TypedRateLimitingInterface[string] - catalogMu sync.Mutex + 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 pendingAuths sync.Map // key: "ns/name" → []map[string]string @@ -111,36 +114,41 @@ 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, - 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, - 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", + 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", } offerInformer.AddEventHandler(cache.ResourceEventHandlerFuncs{ @@ -201,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 } @@ -218,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, @@ -225,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") } @@ -308,18 +323,48 @@ 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) + } + } +} + +func (c *Controller) enqueueStorefrontProfileRefresh(obj any) { + u := asUnstructured(obj) + if u == nil { 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 u.GetNamespace() != storefront.ProfileNamespace || u.GetName() != storefront.ProfileConfigMap { + return } - for _, item := range c.registrationInformer.GetStore().List() { - c.enqueueRegistration(item) + log.Printf("serviceoffer-controller: storefront profile change detected, refreshing skill catalog") + c.enqueueSkillCatalogRefresh() +} + +func (c *Controller) enqueueSkillCatalogRefresh() { + items := c.offerInformer.GetStore().List() + if len(items) > 0 { + // Any single offer reconcile rebuilds the full catalog. + 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 +1161,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(storefront.ProfileNamespace).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 @@ -1157,7 +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 + } + 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 diff --git a/internal/serviceoffercontroller/identity_render_test.go b/internal/serviceoffercontroller/identity_render_test.go index 1e8fd2b9..12ed8162 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 TestBuildServiceCatalogJSON_UsesExplicitProfile(t *testing.T) { + explicit := &schemas.StorefrontProfile{ + DisplayName: "Acme Inference", + Tagline: "Custom seller tagline", + LogoURL: "https://cdn.example/logo.png", + } + + jsonStr := buildServiceCatalogJSON(nil, "https://seller.example", explicit) + catalog := decodeServiceCatalog(t, jsonStr) + if catalog.DisplayName != "Acme Inference" { + t.Fatalf("DisplayName = %q", catalog.DisplayName) + } + if catalog.Tagline != "Custom seller tagline" { + t.Fatalf("Tagline = %q", catalog.Tagline) + } + if catalog.LogoURL != "https://cdn.example/logo.png" { + t.Fatalf("LogoURL = %q", catalog.LogoURL) + } +} + +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 catalog.DisplayName != "Obol Stack" { + t.Fatalf("DisplayName = %q", catalog.DisplayName) + } + 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 8481b077..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" @@ -1117,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 @@ -1128,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 @@ -1228,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 5c3bf105..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"}]`, `{"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,7 +115,7 @@ 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["openapi.json"] != `{"openapi":"3.1.0"}` { 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/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..42291dc4 100644 --- a/web/public-storefront/next.config.ts +++ b/web/public-storefront/next.config.ts @@ -1,7 +1,20 @@ 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`, + }, + ]; + }, }; export default nextConfig; diff --git a/web/public-storefront/src/app/layout.tsx b/web/public-storefront/src/app/layout.tsx index 94dca4fd..3648d952 100644 --- a/web/public-storefront/src/app/layout.tsx +++ b/web/public-storefront/src/app/layout.tsx @@ -1,7 +1,12 @@ 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 { resolveSiteUrl } from "@/lib/site-url"; import "./globals.css"; const dmSans = DM_Sans({ @@ -11,86 +16,70 @@ 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_SUFFIX = "Buy agent services"; -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."; - -// 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"; -} - -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_SUFFIX}`, + description: tagline, + }; } 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; + const description = sample ? `${tagline} ${sample}.` : tagline; 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 +104,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, url: siteUrl, publisher: { "@type": "Organization", @@ -157,15 +150,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..4fe7d515 100644 --- a/web/public-storefront/src/app/opengraph-image.tsx +++ b/web/public-storefront/src/app/opengraph-image.tsx @@ -1,6 +1,8 @@ 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"; @@ -15,10 +17,17 @@ const BG_PANEL = "#111F22"; const STROKE_GREEN = "#1D5249"; export default async function OpengraphImage() { + 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 }) => (
- {/* Wordmark, top-left */} - {/* eslint-disable-next-line @next/next/no-img-element */} - Obol Stack + {/* Brand, top-left */} + {customLogoSrc === "" ? ( + // 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 +132,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..158cdada 100644 --- a/web/public-storefront/src/app/page.tsx +++ b/web/public-storefront/src/app/page.tsx @@ -1,45 +1,25 @@ -import type { Service } from "@/types"; +import { DEFAULT_HERO_TITLE, fetchCatalogDocument } 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 catalog = await fetchCatalogDocument(); return ( <> -
+

- Agent services + {DEFAULT_HERO_TITLE}

-

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

+

{catalog.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/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 new file mode 100644 index 00000000..e1ad5eee --- /dev/null +++ b/web/public-storefront/src/lib/catalog.ts @@ -0,0 +1,74 @@ +import { cache } from "react"; +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 const DEFAULT_HERO_TITLE = "Agent services"; + +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 => { + const catalog = await fetchCatalogDocument(); + return { + displayName: catalog.displayName, + tagline: catalog.tagline, + logoUrl: catalog.logoUrl, + }; +}); diff --git a/web/public-storefront/src/lib/site-url.ts b/web/public-storefront/src/lib/site-url.ts new file mode 100644 index 00000000..b5385d76 --- /dev/null +++ b/web/public-storefront/src/lib/site-url.ts @@ -0,0 +1,34 @@ +import { headers } from "next/headers"; + +function usesPlainHTTP(host: string | null): boolean { + if (!host) return false; + return /^(localhost|127\.|0\.0\.0\.0|\[::1\]|::1|obol\.stack)(:|$)/.test( + host, + ); +} + +// Derive the public site URL from the incoming request so metadata and +// generated images use the hostname that scrapers hit. +export 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") ?? + (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 ""; + } +} 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; +}