diff --git a/CLAUDE.md b/CLAUDE.md index 0e8c2f57..3eaf1db6 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` is the buyer's-eye read of the storefront: `obol sell info` prints branding + every on-sale service (rendered from the published `/api/services.json` envelope, i.e. only operationally-ready offers); `obol sell info ` focuses one service + how-to-buy; `--verbose` adds health/detail. Operator health/conditions (incl. not-ready/draining offers) stay under `sell status`. Branding writes live under `sell info set` (no flags on a TTY → interactive; with flags → patch only those fields) and `sell info reset` (no flags → all defaults; with field flags → reset only those). 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. @@ -428,6 +428,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`. @@ -447,7 +448,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/main.go b/cmd/obol/main.go index 158f9dae..abb988a7 100644 --- a/cmd/obol/main.go +++ b/cmd/obol/main.go @@ -16,6 +16,7 @@ import ( "github.com/ObolNetwork/obol-stack/internal/kubectl" "github.com/ObolNetwork/obol-stack/internal/network" "github.com/ObolNetwork/obol-stack/internal/stack" + "github.com/ObolNetwork/obol-stack/internal/storefront" "github.com/ObolNetwork/obol-stack/internal/ui" "github.com/ObolNetwork/obol-stack/internal/version" "github.com/urfave/cli/v3" @@ -215,6 +216,13 @@ GLOBAL OPTIONS:{{template "visibleFlagTemplate" .}}{{end}} // agent-backed ServiceOffers resolve agent.ref and // would dangle without their Agent. Best-effort. agentcrd.ResumeAll(cfg, u) + // Replay recorded storefront branding into the + // (fresh) x402/obol-storefront-profile ConfigMap + // BEFORE offers republish, so the controller's first + // catalog rebuild carries the operator's branding. + // Best-effort. No-op when `obol sell info set` was + // never used. + storefront.ReconcileRecorded(cfg, u) // Re-apply cluster-side state for locally-persisted // `obol sell *` offers. ServiceOffer CRs and the // Service/Endpoints that route to the host gateway diff --git a/cmd/obol/sell.go b/cmd/obol/sell.go index 2ea01bba..96b861fd 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" @@ -47,7 +45,25 @@ import ( func sellCommand(cfg *config.Config) *cli.Command { return &cli.Command{ Name: "sell", - Usage: "Sell access to services via x402 micropayments", + Usage: "Sell access to agents and services using digital payments", + Description: `Sell access to agents and services using digital payments (x402). + +Commands split into two scopes: + + The storefront (the whole shop) + info Preview the storefront and everything on sale, as buyers see it + info set Set the storefront's branding (name, tagline, logo) + register Publish the seller's identity to a distribution channel (ERC-8004) + identity Manage the durable on-chain identity record + list List every offer (raw) + resume Re-publish all offers (e.g. after a restart) + + A single service (one item on the shelf) + inference | http | agent | mcp | demo Put a service up for sale + status Operator health and conditions for an offer + update Change an offer's price or payout wallet in place + stop Stop selling an offer without deleting it + delete Stop selling an offer and remove it`, Commands: []*cli.Command{ sellInferenceCommand(cfg), sellHTTPCommand(cfg), @@ -2628,7 +2644,7 @@ Examples: func sellStopCommand(cfg *config.Config) *cli.Command { return &cli.Command{ Name: "stop", - Usage: "Drain a ServiceOffer gracefully (advertises wind-down via discovery, then tears down the route)", + Usage: "Stop selling a service without deleting it", ArgsUsage: "", Description: `Marks a ServiceOffer as draining. While draining: - The offer stays in /skill.md and /.well-known/agent-registration.json @@ -2873,7 +2889,7 @@ Examples: func sellDeleteCommand(cfg *config.Config) *cli.Command { return &cli.Command{ Name: "delete", - Usage: "Delete the sale of a service entirely and deactivate its ERC-8004 agent registration", + Usage: "Stop selling a service and remove it (also deactivates any registrations)", ArgsUsage: "", Flags: []cli.Flag{ &cli.StringFlag{ @@ -3053,8 +3069,13 @@ Reloads the payment verifier when configuration is changed.`, func sellRegisterCommand(cfg *config.Config) *cli.Command { return &cli.Command{ Name: "register", - Usage: "Register a service on the ERC-8004 Agent Registry", - Description: `Registers an AgentIdentity on the ERC-8004 Agent Registry for one chain. + Usage: "Publish the seller's identity to a distribution channel (ERC-8004)", + Description: `Publishes the seller's agent identity to a distribution channel so buyers can +discover it. Today the only channel is the ERC-8004 Agent Registry (on-chain); +more channels may follow, so this command is framed around "where do I +advertise" rather than a single registry. + +Registers an AgentIdentity on the ERC-8004 Agent Registry for one chain. The on-chain register tx is signed and broadcast by the Hermes remote-signer and pays gas from the agent's wallet — make sure it has a small balance on the target chain (~$0.20–$0.50 of native gas typically suffices). @@ -3559,91 +3580,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..8270d76e --- /dev/null +++ b/cmd/obol/sell_info.go @@ -0,0 +1,490 @@ +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" +) + +// sellInfoCommand is the read/preview surface for the storefront. +// +// obol sell info → storefront branding + the services on sale +// obol sell info → one service, buyer-facing (what it is, how to buy) +// obol sell info set → change storefront branding (interactive or --flags) +// obol sell info reset → clear branding back to defaults +// +// `info` (and `info `) render the *published* /api/services.json envelope, +// so what you see is exactly what a buyer sees: your branding plus only the +// offers that are operationally ready. For operator-side health and conditions +// (including offers that are not yet ready or are draining), use `sell status`. +func sellInfoCommand(cfg *config.Config) *cli.Command { + return &cli.Command{ + Name: "info", + Usage: "Show the storefront and its services as buyers see them", + ArgsUsage: "[service-name]", + Description: `Renders the published storefront catalog (/api/services.json): + + obol sell info Storefront branding + every service on sale + obol sell info One service in focus, with how-to-buy + obol sell info --verbose Add health and richer per-service detail + +Manage the storefront's own branding (independent of individual services): + + obol sell info set Interactive when no flags are passed; + obol sell info set --tagline … updates only the fields you pass, leaving + the rest untouched. + obol sell info reset Clears all branding back to defaults; + obol sell info reset --tagline resets only the fields you pass. + +This is the buyer's-eye view. For operator health, conditions, and offers that +are not yet ready or are draining, use 'obol sell status'.`, + Flags: []cli.Flag{ + &cli.BoolFlag{Name: "verbose", Aliases: []string{"v"}, Usage: "Show health and richer per-service detail"}, + }, + Commands: []*cli.Command{ + sellInfoSetCommand(cfg), + sellInfoResetCommand(cfg), + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + u := getUI(cmd) + if err := kubectl.EnsureCluster(cfg); err != nil { + return err + } + catalog, err := loadPublishedCatalog(cfg) + if err != nil { + return err + } + verbose := cmd.Bool("verbose") || u.IsVerbose() + + // Focused single-service view: obol sell info . + if cmd.NArg() > 0 { + name := cmd.Args().First() + entry := findCatalogEntry(catalog, name) + if entry == nil { + return fmt.Errorf("no service %q is on sale in the storefront (run 'obol sell info' to list what is)", name) + } + if u.IsJSON() { + return u.JSON(entry) + } + printServiceDetail(u, *entry, catalog, verbose) + return nil + } + + if u.IsJSON() { + return u.JSON(catalog) + } + printStorefrontHeader(u, catalog) + printServiceList(u, catalog.Services, verbose) + return nil + }, + } +} + +func sellInfoSetCommand(cfg *config.Config) *cli.Command { + return &cli.Command{ + Name: "set", + Usage: "Set storefront display name, tagline, and/or logo URL", + Description: `Updates seller-wide storefront branding. With no flags on a TTY this walks +you through each field (pre-filled with the current value). With flags, only +the fields you pass change; everything else is left untouched.`, + 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 + } + + current, err := loadSellerProfile(cfg) + if err != nil { + return err + } + + patch := schemas.StorefrontProfile{} + anyFlag := cmd.IsSet("display-name") || cmd.IsSet("tagline") || cmd.IsSet("logo-url") + if anyFlag { + // Flag mode: patch only the fields the operator passed. + if cmd.IsSet("display-name") { + patch.DisplayName = strings.TrimSpace(cmd.String("display-name")) + } + if cmd.IsSet("tagline") { + patch.Tagline = strings.TrimSpace(cmd.String("tagline")) + } + if cmd.IsSet("logo-url") { + patch.LogoURL = strings.TrimSpace(cmd.String("logo-url")) + } + } else { + // No flags: prompt interactively (pre-filled with effective values). + if !u.IsTTY() { + return errors.New("no flags given and not a TTY: pass --display-name, --tagline, and/or --logo-url") + } + effective := storefront.ResolvePublished(¤t, mustSellerBaseURL(cfg)) + if v, err := u.Input("Display name", effective.DisplayName); err == nil { + patch.DisplayName = strings.TrimSpace(v) + } + if v, err := u.Input("Tagline", effective.Tagline); err == nil { + patch.Tagline = strings.TrimSpace(v) + } + if v, err := u.Input("Logo URL", effective.LogoURL); err == nil { + patch.LogoURL = strings.TrimSpace(v) + } + } + + if patch.DisplayName == "" && patch.Tagline == "" && patch.LogoURL == "" { + return errors.New("nothing to set") + } + if err := storefront.ValidateLogoURL(patch.LogoURL); 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("Storefront branding updated") + printSellerProfile(u, published) + u.Blank() + u.Dim("Preview: obol sell info") + return nil + }, + } +} + +func sellInfoResetCommand(cfg *config.Config) *cli.Command { + return &cli.Command{ + Name: "reset", + Usage: "Clear storefront branding back to defaults", + Description: `With no flags, resets all storefront branding to stack defaults. Pass one or +more field flags to reset only those fields, leaving the rest untouched.`, + Flags: []cli.Flag{ + &cli.BoolFlag{Name: "display-name", Usage: "Reset only the display name"}, + &cli.BoolFlag{Name: "tagline", Usage: "Reset only the tagline"}, + &cli.BoolFlag{Name: "logo-url", Usage: "Reset only the logo URL"}, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + u := getUI(cmd) + if err := kubectl.EnsureCluster(cfg); err != nil { + return err + } + + partial := cmd.Bool("display-name") || cmd.Bool("tagline") || cmd.Bool("logo-url") + + var explicit *schemas.StorefrontProfile + if partial { + current, err := loadSellerProfile(cfg) + if err != nil { + return err + } + cleared := clearProfileFields(current, cmd.Bool("display-name"), cmd.Bool("tagline"), cmd.Bool("logo-url")) + if cleared == (schemas.StorefrontProfile{}) { + // Everything is back to default — remove the override entirely. + if err := deleteSellerProfile(cfg); err != nil { + return err + } + } else { + if err := applySellerProfile(cfg, cleared); err != nil { + return err + } + explicit = &cleared + } + } else { + if err := deleteSellerProfile(cfg); err != nil { + return err + } + } + + published, err := waitForPublishedCatalog(cfg, explicit, 45*time.Second) + if err != nil { + return err + } + + u.Success("Storefront branding reset") + printSellerProfile(u, published) + return nil + }, + } +} + +// clearProfileFields returns a copy of p with the flagged fields emptied, so +// they fall back to stack defaults while the rest of the operator override is +// preserved. +func clearProfileFields(p schemas.StorefrontProfile, displayName, tagline, logoURL bool) schemas.StorefrontProfile { + if displayName { + p.DisplayName = "" + } + if tagline { + p.Tagline = "" + } + if logoURL { + p.LogoURL = "" + } + return p +} + +// findCatalogEntry returns the catalog entry whose name matches, or nil. +func findCatalogEntry(catalog schemas.ServiceCatalog, name string) *schemas.ServiceCatalogEntry { + name = strings.TrimSpace(name) + for i := range catalog.Services { + if catalog.Services[i].Name == name { + return &catalog.Services[i] + } + } + return nil +} + +// serviceHealth summarises an entry's buyer-visible state. Only operationally +// ready offers are published, so the baseline is "ready"; draining and +// registration-pending are the two states a live catalog can still carry. +func serviceHealth(e schemas.ServiceCatalogEntry) string { + switch { + case strings.TrimSpace(e.DrainEndsAt) != "": + return "draining (until " + e.DrainEndsAt + ")" + case e.RegistrationPending: + return "ready (registration pending)" + default: + return "ready" + } +} + +// howToBuy renders a concise, type-appropriate purchase hint for a service. +func howToBuy(e schemas.ServiceCatalogEntry) []string { + switch e.Type { + case "inference": + base := endpointBase(e.Endpoint) + return []string{fmt.Sprintf("obol buy inference %s", base)} + case "agent": + return []string{fmt.Sprintf("buy.py pay-agent %s --model %s --message ''", e.Endpoint, valueOrPlaceholder(e.Model, ""))} + default: // http and everything else + return []string{fmt.Sprintf("buy.py pay %s", e.Endpoint)} + } +} + +func printStorefrontHeader(u *ui.UI, catalog schemas.ServiceCatalog) { + u.Bold("Storefront") + u.Printf(" Name: %s", valueOrNone(catalog.DisplayName)) + u.Printf(" Tagline: %s", valueOrNone(catalog.Tagline)) + u.Printf(" Logo: %s", valueOrNone(catalog.LogoURL)) + u.Blank() +} + +func printServiceList(u *ui.UI, services []schemas.ServiceCatalogEntry, verbose bool) { + if len(services) == 0 { + u.Dim("No services are on sale yet. Publish one with 'obol sell inference', 'obol sell http', or 'obol sell agent'.") + return + } + u.Bold(fmt.Sprintf("Services on sale (%d)", len(services))) + for _, s := range services { + u.Printf(" %s [%s] %s", s.Name, valueOrPlaceholder(s.Type, "service"), valueOrNone(s.Price)) + u.Dim(" " + s.Endpoint) + if verbose { + u.Dim(" health: " + serviceHealth(s)) + if desc := strings.TrimSpace(s.Description); desc != "" { + u.Dim(" " + desc) + } + if len(s.Skills) > 0 { + u.Dim(" skills: " + strings.Join(s.Skills, ", ")) + } + } + } +} + +func printServiceDetail(u *ui.UI, e schemas.ServiceCatalogEntry, catalog schemas.ServiceCatalog, verbose bool) { + u.Bold(fmt.Sprintf("%s [%s]", e.Name, valueOrPlaceholder(e.Type, "service"))) + if desc := strings.TrimSpace(e.Description); desc != "" { + u.Printf(" %s", desc) + u.Blank() + } + u.Printf(" Price: %s", valueOrNone(e.Price)) + u.Printf(" Endpoint: %s", e.Endpoint) + if e.Model != "" { + u.Printf(" Model: %s", e.Model) + } + if e.PayTo != "" { + u.Printf(" Pay to: %s", e.PayTo) + } + if e.Network != "" { + u.Printf(" Network: %s", e.Network) + } + if verbose { + u.Printf(" Health: %s", serviceHealth(e)) + if len(e.Skills) > 0 { + u.Printf(" Skills: %s", strings.Join(e.Skills, ", ")) + } + } + u.Blank() + u.Bold("How to buy") + for _, line := range howToBuy(e) { + u.Printf(" %s", line) + } +} + +// loadPublishedCatalog reads the controller-published /api/services.json feed +// (the obol-skill-md ConfigMap) as the storefront envelope. +func loadPublishedCatalog(cfg *config.Config) (schemas.ServiceCatalog, error) { + raw, err := kubectlOutput(cfg, "get", "configmap", "obol-skill-md", + "-n", storefront.ProfileNamespace, "-o", "jsonpath={.data.services\\.json}") + if err != nil { + return schemas.ServiceCatalog{}, fmt.Errorf("read published catalog: %w", err) + } + raw = strings.TrimSpace(raw) + if raw == "" { + // Nothing published yet — show the resolved profile with no services. + profile, _ := loadSellerProfile(cfg) + resolved := storefront.ResolvePublished(&profile, mustSellerBaseURL(cfg)) + return schemas.ServiceCatalog{ + DisplayName: resolved.DisplayName, + Tagline: resolved.Tagline, + LogoURL: resolved.LogoURL, + Services: nil, + }, nil + } + + var catalog schemas.ServiceCatalog + if err := json.Unmarshal([]byte(raw), &catalog); err != nil { + return schemas.ServiceCatalog{}, fmt.Errorf("parse published catalog: %w", err) + } + return catalog, 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 storefront profile: %w", err) + } + return nil +} + +func deleteSellerProfile(cfg *config.Config) error { + bin, kc := kubectl.Paths(cfg) + if err := kubectl.RunSilent(bin, kc, "delete", "configmap", storefront.ProfileConfigMap, + "-n", storefront.ProfileNamespace, "--ignore-not-found"); err != nil { + return fmt.Errorf("delete storefront profile: %w", err) + } + _ = os.Remove(storefront.ProfileLocalPath(cfg)) + 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) +} + +// endpointBase returns the origin (scheme://host[:port]) of a service endpoint, +// or the endpoint unchanged if it can't be parsed. +func endpointBase(endpoint string) string { + if i := strings.Index(endpoint, "/services/"); i > 0 { + return endpoint[:i] + } + return strings.TrimRight(endpoint, "/") +} + +func valueOrPlaceholder(v, placeholder string) string { + if strings.TrimSpace(v) == "" { + return placeholder + } + return v +} diff --git a/cmd/obol/sell_info_test.go b/cmd/obol/sell_info_test.go new file mode 100644 index 00000000..732eca64 --- /dev/null +++ b/cmd/obol/sell_info_test.go @@ -0,0 +1,110 @@ +package main + +import ( + "strings" + "testing" + + "github.com/ObolNetwork/obol-stack/internal/schemas" +) + +func TestClearProfileFields(t *testing.T) { + base := schemas.StorefrontProfile{DisplayName: "Acme", Tagline: "Paid APIs.", LogoURL: "https://acme/logo.png"} + + tests := []struct { + name string + dn, tl, lu bool + want schemas.StorefrontProfile + }{ + {"none", false, false, false, base}, + {"tagline only", false, true, false, schemas.StorefrontProfile{DisplayName: "Acme", LogoURL: "https://acme/logo.png"}}, + {"display+logo", true, false, true, schemas.StorefrontProfile{Tagline: "Paid APIs."}}, + {"all", true, true, true, schemas.StorefrontProfile{}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := clearProfileFields(base, tc.dn, tc.tl, tc.lu) + if got != tc.want { + t.Fatalf("clearProfileFields = %+v, want %+v", got, tc.want) + } + }) + } +} + +func TestFindCatalogEntry(t *testing.T) { + catalog := schemas.ServiceCatalog{Services: []schemas.ServiceCatalogEntry{ + {Name: "alpha", Type: "http"}, + {Name: "beta", Type: "inference"}, + }} + + if e := findCatalogEntry(catalog, "beta"); e == nil || e.Type != "inference" { + t.Fatalf("expected to find beta/inference, got %+v", e) + } + if e := findCatalogEntry(catalog, " alpha "); e == nil || e.Name != "alpha" { + t.Fatalf("expected trimmed lookup to find alpha, got %+v", e) + } + if e := findCatalogEntry(catalog, "missing"); e != nil { + t.Fatalf("expected nil for missing service, got %+v", e) + } +} + +func TestServiceHealth(t *testing.T) { + tests := []struct { + name string + entry schemas.ServiceCatalogEntry + want string + }{ + {"ready", schemas.ServiceCatalogEntry{}, "ready"}, + {"registration pending", schemas.ServiceCatalogEntry{RegistrationPending: true}, "ready (registration pending)"}, + {"draining wins", schemas.ServiceCatalogEntry{RegistrationPending: true, DrainEndsAt: "2026-07-01T00:00:00Z"}, "draining (until 2026-07-01T00:00:00Z)"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := serviceHealth(tc.entry); got != tc.want { + t.Fatalf("serviceHealth = %q, want %q", got, tc.want) + } + }) + } +} + +func TestEndpointBase(t *testing.T) { + tests := []struct{ in, want string }{ + {"https://acme.example/services/foo/v1/chat", "https://acme.example"}, + {"https://acme.example/services/foo", "https://acme.example"}, + {"https://acme.example/", "https://acme.example"}, + {"https://acme.example", "https://acme.example"}, + } + for _, tc := range tests { + if got := endpointBase(tc.in); got != tc.want { + t.Fatalf("endpointBase(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestHowToBuy(t *testing.T) { + tests := []struct { + name string + entry schemas.ServiceCatalogEntry + contains string + }{ + {"inference uses obol buy inference with origin", schemas.ServiceCatalogEntry{Type: "inference", Endpoint: "https://acme.example/services/x/v1"}, "obol buy inference https://acme.example"}, + {"agent uses pay-agent with model", schemas.ServiceCatalogEntry{Type: "agent", Endpoint: "https://acme.example/services/x", Model: "qwen"}, "pay-agent https://acme.example/services/x --model qwen"}, + {"http uses pay", schemas.ServiceCatalogEntry{Type: "http", Endpoint: "https://acme.example/services/x"}, "buy.py pay https://acme.example/services/x"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + lines := howToBuy(tc.entry) + if len(lines) == 0 { + t.Fatal("expected at least one how-to-buy line") + } + found := false + for _, l := range lines { + if strings.Contains(l, tc.contains) { + found = true + } + } + if !found { + t.Fatalf("howToBuy = %v, want a line %q", lines, tc.contains) + } + }) + } +} diff --git a/internal/buy/catalog_test.go b/internal/buy/catalog_test.go index 64055165..be7a5215 100644 --- a/internal/buy/catalog_test.go +++ b/internal/buy/catalog_test.go @@ -55,9 +55,9 @@ func TestEndpointPath(t *testing.T) { "/services/aeon/chat/completions": "/services/aeon", "https://x.example/services/aeon": "/services/aeon", "https://x.example/services/aeon/v1/chat": "/services/aeon", // trailing chat is not stripped fully but path still picks aeon - "": "", - "/": "", - "/something-else": "", + "": "", + "/": "", + "/something-else": "", } for in, want := range cases { got := endpointPath(in) @@ -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/embed/infrastructure/values/obol-frontend.yaml.gotmpl b/internal/embed/infrastructure/values/obol-frontend.yaml.gotmpl index 11f20daa..2ed4e221 100644 --- a/internal/embed/infrastructure/values/obol-frontend.yaml.gotmpl +++ b/internal/embed/infrastructure/values/obol-frontend.yaml.gotmpl @@ -46,8 +46,8 @@ image: pullPolicy: IfNotPresent # Digest-pinned: tag is informational, sha256 is authoritative. Eliminates # the mutable-tag attack surface called out by the v0.10.0-rc2 supply-chain - # review. Multi-arch index digest for v0.1.27 (linux/amd64 + linux/arm64). - tag: "v0.1.27@sha256:2a4857cda2fe07822846eee734cfa4ac982321b940efa296c4fd0807c022f829" + # review. Multi-arch index digest for v0.1.28-rc0 (linux/amd64 + linux/arm64). + tag: "v0.1.28-rc0@sha256:2a3f5cedebaee042bac666b211d2e051f0c612923f0a86ed9a36806f5fe6a293" service: type: ClusterIP 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..8b940043 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 @@ -1156,8 +1219,12 @@ func (c *Controller) reconcileSkillCatalog(ctx context.Context, override *moneti offers = append(offers, override) } - content := buildSkillCatalogMarkdown(offers, baseURL) - servicesJSON := buildServiceCatalogJSON(offers, baseURL) + storefrontProfile, err := c.loadStorefrontProfile(ctx) + if err != nil { + return err + } + content := buildSkillCatalogMarkdown(offers, 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 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..a5b50637 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" @@ -899,8 +900,9 @@ func offerPublishedForRegistration(offer *monetizeapi.ServiceOffer) bool { isConditionTrue(offer.Status, "RoutePublished") } -func buildSkillCatalogMarkdown(offers []*monetizeapi.ServiceOffer, baseURL string) string { +func buildSkillCatalogMarkdown(offers []*monetizeapi.ServiceOffer, baseURL string, explicit *schemas.StorefrontProfile) string { baseURL = strings.TrimRight(baseURL, "/") + profile := storefront.ResolvePublished(explicit, baseURL) // Same operationally-ready filter as buildServiceCatalogJSON — keep the // two surfaces consistent. An offer that's usable for x402 payments @@ -933,7 +935,7 @@ func buildSkillCatalogMarkdown(offers []*monetizeapi.ServiceOffer, baseURL strin }) lines := []string{ - "# Obol Stack Service Catalog", + fmt.Sprintf("# %s Service Catalog", profile.DisplayName), "", fmt.Sprintf("> Generated from %d ready ServiceOffer(s). Every service below is gated by [x402](https://www.x402.org) micropayments — no API key, no signup, no subscription.", len(ready)), "", @@ -1117,8 +1119,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 +1130,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 +1231,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..c1b023f1 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")}, @@ -629,7 +638,7 @@ func TestBuildSkillCatalogMarkdown(t *testing.T) { }, } - content := buildSkillCatalogMarkdown([]*monetizeapi.ServiceOffer{readyOffer, notReadyOffer}, "https://example.com") + content := buildSkillCatalogMarkdown([]*monetizeapi.ServiceOffer{readyOffer, notReadyOffer}, "https://example.com", nil) if !strings.Contains(content, "# Obol Stack Service Catalog") { t.Fatalf("catalog missing title:\n%s", content) @@ -686,6 +695,7 @@ func TestBuildSkillCatalogMarkdown_DrainAdditiveDetail(t *testing.T) { content := buildSkillCatalogMarkdown( []*monetizeapi.ServiceOffer{activeOffer, drainingOffer}, "https://example.com", + nil, ) if strings.Contains(content, "- **Available**:") { @@ -757,13 +767,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 +816,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 +864,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 +877,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 +919,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 +993,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 +1006,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 +1063,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 +1131,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 +1164,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 +1209,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 +1236,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 +1272,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 +1356,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 +1521,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 +1560,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/stackbackup/cluster.go b/internal/stackbackup/cluster.go index c94d11cb..24097f69 100644 --- a/internal/stackbackup/cluster.go +++ b/internal/stackbackup/cluster.go @@ -30,6 +30,7 @@ var clusterDumps = []clusterDump{ {"litellm-secrets.json", []string{"get", "secret", "litellm-secrets", "-n", "llm", "-o", "json"}}, {"erpc-config.json", []string{"get", "configmap", "erpc-config", "-n", "erpc", "-o", "json"}}, {"x402-pricing.json", []string{"get", "configmap", "x402-pricing", "-n", "x402", "-o", "json"}}, + {"storefront-profile.json", []string{"get", "configmap", "obol-storefront-profile", "-n", "x402", "-o", "json"}}, } // applyOrder is the import-time apply sequence: Agent CRs first (agent-backed @@ -42,6 +43,7 @@ var applyOrder = []string{ "litellm-secrets.json", "erpc-config.json", "x402-pricing.json", + "storefront-profile.json", "serviceoffers.json", "registrationrequests.json", } 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/internal/storefront/record.go b/internal/storefront/record.go new file mode 100644 index 00000000..26d4d7ce --- /dev/null +++ b/internal/storefront/record.go @@ -0,0 +1,57 @@ +package storefront + +import ( + "encoding/json" + "os" + + "github.com/ObolNetwork/obol-stack/internal/config" + "github.com/ObolNetwork/obol-stack/internal/kubectl" + "github.com/ObolNetwork/obol-stack/internal/ui" +) + +// ReconcileRecorded re-applies the operator's recorded storefront branding into +// the cluster during `obol stack up`. +// +// `obol sell info set` writes the profile to both the x402/obol-storefront-profile +// ConfigMap (etcd, destroyed by `obol stack down`) and a host-side record at +// $CONFIG_DIR/storefront/profile.json (survives cluster recreation). Without this +// replay a fresh `stack up` would come back with the branding on disk but no +// matching ConfigMap, so the controller would publish the default catalog envelope. +// +// The host file is the source of truth. No record on disk => nothing to do. +// Best-effort: a failure warns but never blocks stack-up. +func ReconcileRecorded(cfg *config.Config, u *ui.UI) { + data, err := os.ReadFile(ProfileLocalPath(cfg)) + if err != nil { + return // no recorded storefront profile — nothing to replay + } + profile, err := ParseProfile(string(data)) + if err != nil { + u.Warnf("Could not read recorded storefront profile: %v", err) + return + } + if profile == nil { + return + } + if err := kubectl.EnsureCluster(cfg); err != nil { + return + } + + manifest, err := ConfigMapManifest(*profile) + if err != nil { + u.Warnf("Could not render recorded storefront profile: %v", err) + return + } + payload, err := json.Marshal(manifest) + if err != nil { + u.Warnf("Could not render recorded storefront profile: %v", err) + return + } + + bin, kubeconfig := kubectl.Paths(cfg) + if err := kubectl.Apply(bin, kubeconfig, payload); err != nil { + u.Warnf("Could not reconcile recorded storefront profile: %v", err) + return + } + u.Detail("Restored", "storefront branding") +} 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; +}