From 262cf3eddbe0f81c64966fab84b2125f694e4392 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ois=C3=ADn=20Kyne?= Date: Fri, 3 Jul 2026 14:03:54 +0100 Subject: [PATCH 1/4] feat(sell): pre-flight check logo URLs for pitfalls --- cmd/obol/sell_info.go | 57 +++++++++- internal/storefront/logocheck.go | 111 ++++++++++++++++++++ internal/storefront/logocheck_test.go | 145 ++++++++++++++++++++++++++ internal/storefront/profile.go | 36 ++++++- internal/storefront/profile_test.go | 9 ++ 5 files changed, 354 insertions(+), 4 deletions(-) create mode 100644 internal/storefront/logocheck.go create mode 100644 internal/storefront/logocheck_test.go diff --git a/cmd/obol/sell_info.go b/cmd/obol/sell_info.go index 0c66e38b..1440d6e8 100644 --- a/cmd/obol/sell_info.go +++ b/cmd/obol/sell_info.go @@ -101,11 +101,19 @@ func sellInfoSetCommand(cfg *config.Config) *cli.Command { 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.`, +the fields you pass change; everything else is left untouched. + +Logo URLs are preflight-checked before publishing: reachability, an image +content-type, https (mixed content), and permissive CORS headers (needed by +sites that embed your catalog). On a TTY a failing check asks before +proceeding; non-interactive runs warn and continue. To sidestep hosting +brittleness entirely, pass an inline data URI (self-contained, no CORS): + + obol sell info set --logo-url "data:image/png;base64,$(base64 -i logo.png)"`, 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)"}, + &cli.StringFlag{Name: "logo-url", Usage: "Logo image URL (https://..., /path on this host, or inline data:image/...;base64)"}, &cli.StringFlag{Name: "contact-email", Usage: "Operator contact email published in /openapi.json (x402scan)"}, }, Action: func(ctx context.Context, cmd *cli.Command) error { @@ -164,6 +172,9 @@ the fields you pass change; everything else is left untouched.`, if err := storefront.ValidateContactEmail(patch.ContactEmail); err != nil { return err } + if err := confirmLogoURL(ctx, u, cfg, patch.LogoURL); err != nil { + return err + } merged := storefront.MergeProfile(current, patch) if err := applySellerProfile(cfg, merged); err != nil { @@ -240,6 +251,48 @@ more field flags to reset only those fields, leaving the rest untouched.`, } } +// confirmLogoURL probes a newly-set logo URL the way browsers will load it +// (reachability, image content-type, permissive CORS, https, size) before it +// is published to every catalog consumer. On a TTY, problems become a +// proceed-anyway confirmation; non-interactive runs warn and continue so +// scripts never block. Default, inline (data:), and empty logos skip the +// probe — there is nothing remote to check. +func confirmLogoURL(ctx context.Context, u *ui.UI, cfg *config.Config, logoURL string) error { + logoURL = strings.TrimSpace(logoURL) + if logoURL == "" || strings.HasPrefix(logoURL, "data:") || storefront.IsDefaultLogoURL(logoURL) { + return nil + } + if strings.HasPrefix(logoURL, "/") { + // Site-relative paths are resolved against the seller origin by + // consumers; probe the same absolute URL they will fetch. + logoURL = strings.TrimRight(mustSellerBaseURL(cfg), "/") + logoURL + } + + var result storefront.LogoPreflight + _ = u.RunWithSpinner("Checking logo URL", func() error { + result = storefront.PreflightLogoURL(ctx, logoURL) + return nil + }) + if result.OK() { + return nil + } + for _, w := range result.Warnings { + u.Warn(w) + } + if !u.IsTTY() || u.IsJSON() { + u.Warn("continuing anyway (non-interactive); the logo may not load on all sites") + return nil + } + msg, defaultYes := "The logo may not display on every site. Set it anyway?", true + if result.LoadFailure { + msg, defaultYes = "Unable to load the logo image. Set it anyway?", false + } + if !u.Confirm(msg, defaultYes) { + return errors.New("cancelled: storefront branding not updated") + } + 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. diff --git a/internal/storefront/logocheck.go b/internal/storefront/logocheck.go new file mode 100644 index 00000000..8f429b00 --- /dev/null +++ b/internal/storefront/logocheck.go @@ -0,0 +1,111 @@ +package storefront + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// probeOrigin is a representative third-party origin sent as the Origin +// header when probing a logo host. Browsers attach Origin to cross-origin +// image fetches; sending one elicits the host's CORS response headers so we +// can tell whether fetch()/canvas consumers will be able to load the image. +const probeOrigin = "https://storefront.example" + +// largeLogoBytes is the size above which we warn that the logo will be slow +// to load on catalog and storefront pages. +const largeLogoBytes = 2 << 20 // 2 MiB + +// logoProbeClient performs preflight requests; overridable in tests so TLS +// httptest servers can be trusted. +var logoProbeClient = &http.Client{Timeout: 10 * time.Second} + +// LogoPreflight is the result of probing a logo URL the way a browser would +// load it. +type LogoPreflight struct { + // LoadFailure is true when the image could not be fetched at all + // (network error, HTTP error status, or a non-image response) — the + // logo will be broken everywhere, not just for strict consumers. + LoadFailure bool + // Warnings are human-readable problems found by the probe. + Warnings []string +} + +// OK reports whether the probe found no problems. +func (p LogoPreflight) OK() bool { return !p.LoadFailure && len(p.Warnings) == 0 } + +// PreflightLogoURL fetches an absolute logo URL and checks the properties +// browsers and catalog consumers depend on: reachability, an image +// content-type, permissive CORS (for fetch()/canvas-based consumers), https +// (mixed content on https storefront pages), and size. Problems come back as +// Warnings rather than an error so the caller can decide whether to proceed. +// Empty and data: URIs are self-contained and probe as OK. +func PreflightLogoURL(ctx context.Context, rawURL string) LogoPreflight { + var out LogoPreflight + rawURL = strings.TrimSpace(rawURL) + if rawURL == "" || strings.HasPrefix(rawURL, "data:") { + return out + } + + if strings.HasPrefix(rawURL, "http://") { + out.Warnings = append(out.Warnings, + "logo is served over http:// — browsers block mixed content, so it will not render on https pages (including your tunnel storefront); use an https URL") + } + + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + out.LoadFailure = true + out.Warnings = append(out.Warnings, fmt.Sprintf("invalid logo URL: %v", err)) + return out + } + req.Header.Set("Origin", probeOrigin) + req.Header.Set("Accept", "image/*,*/*") + + resp, err := logoProbeClient.Do(req) + if err != nil { + out.LoadFailure = true + out.Warnings = append(out.Warnings, fmt.Sprintf("could not fetch logo: %v", err)) + return out + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + out.LoadFailure = true + out.Warnings = append(out.Warnings, fmt.Sprintf("logo URL returned HTTP %d", resp.StatusCode)) + return out + } + + // Content-type: trust a specific header, sniff when absent or generic. + sniff, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + ctype := strings.ToLower(strings.TrimSpace(resp.Header.Get("Content-Type"))) + if i := strings.Index(ctype, ";"); i >= 0 { + ctype = strings.TrimSpace(ctype[:i]) + } + if ctype == "" || ctype == "application/octet-stream" { + ctype = http.DetectContentType(sniff) + } + if !strings.HasPrefix(ctype, "image/") { + out.LoadFailure = true + out.Warnings = append(out.Warnings, + fmt.Sprintf("logo URL does not serve an image (content-type %s)", ctype)) + } + + // Plain tags load without CORS, but fetch()/canvas-based consumers + // (marketplace previews, catalog aggregators) need a permissive + // Access-Control-Allow-Origin from the logo host. + if acao := strings.TrimSpace(resp.Header.Get("Access-Control-Allow-Origin")); acao != "*" && acao != probeOrigin { + out.Warnings = append(out.Warnings, + "logo host does not send permissive CORS headers (Access-Control-Allow-Origin) — the image may not load on every site that embeds your catalog; host it on a CDN with CORS enabled or inline it as a data:image/... URI") + } + + if resp.ContentLength > largeLogoBytes { + out.Warnings = append(out.Warnings, + fmt.Sprintf("logo is large (%.1f MiB) — it will load slowly; consider an image under 2 MiB", float64(resp.ContentLength)/(1<<20))) + } + return out +} diff --git a/internal/storefront/logocheck_test.go b/internal/storefront/logocheck_test.go new file mode 100644 index 00000000..745e2554 --- /dev/null +++ b/internal/storefront/logocheck_test.go @@ -0,0 +1,145 @@ +package storefront + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// pngBytes is a minimal PNG magic header — enough for http.DetectContentType +// to sniff image/png. +var pngBytes = []byte("\x89PNG\r\n\x1a\n0000000000000000") + +func withProbeClient(t *testing.T, c *http.Client) { + t.Helper() + prev := logoProbeClient + logoProbeClient = c + t.Cleanup(func() { logoProbeClient = prev }) +} + +func newLogoServer(t *testing.T, handler http.HandlerFunc) *httptest.Server { + t.Helper() + srv := httptest.NewTLSServer(handler) + t.Cleanup(srv.Close) + withProbeClient(t, srv.Client()) + return srv +} + +func hasWarning(p LogoPreflight, substr string) bool { + for _, w := range p.Warnings { + if strings.Contains(w, substr) { + return true + } + } + return false +} + +func TestPreflightLogoURL_HappyPath(t *testing.T) { + srv := newLogoServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/png") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Write(pngBytes) + }) + got := PreflightLogoURL(context.Background(), srv.URL+"/logo.png") + if !got.OK() { + t.Fatalf("expected OK, got %+v", got) + } +} + +func TestPreflightLogoURL_MissingCORSWarns(t *testing.T) { + srv := newLogoServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/png") + w.Write(pngBytes) + }) + got := PreflightLogoURL(context.Background(), srv.URL+"/logo.png") + if got.LoadFailure { + t.Fatalf("missing CORS is a soft warning, not a load failure: %+v", got) + } + if !hasWarning(got, "CORS") { + t.Fatalf("expected CORS warning, got %+v", got) + } +} + +func TestPreflightLogoURL_EchoedOriginIsPermissive(t *testing.T) { + srv := newLogoServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/png") + w.Header().Set("Access-Control-Allow-Origin", r.Header.Get("Origin")) + w.Write(pngBytes) + }) + got := PreflightLogoURL(context.Background(), srv.URL+"/logo.png") + if !got.OK() { + t.Fatalf("echoed Origin should pass the CORS check: %+v", got) + } +} + +func TestPreflightLogoURL_HTTPErrorIsLoadFailure(t *testing.T) { + srv := newLogoServer(t, func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + }) + got := PreflightLogoURL(context.Background(), srv.URL+"/logo.png") + if !got.LoadFailure || !hasWarning(got, "HTTP 404") { + t.Fatalf("expected load failure with HTTP 404, got %+v", got) + } +} + +func TestPreflightLogoURL_NonImageIsLoadFailure(t *testing.T) { + srv := newLogoServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Write([]byte("login required")) + }) + got := PreflightLogoURL(context.Background(), srv.URL+"/logo.png") + if !got.LoadFailure || !hasWarning(got, "does not serve an image") { + t.Fatalf("expected non-image load failure, got %+v", got) + } +} + +func TestPreflightLogoURL_SniffsGenericContentType(t *testing.T) { + srv := newLogoServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Write(pngBytes) + }) + got := PreflightLogoURL(context.Background(), srv.URL+"/logo.png") + if !got.OK() { + t.Fatalf("PNG bytes behind a generic content-type should sniff as image: %+v", got) + } +} + +func TestPreflightLogoURL_UnreachableIsLoadFailure(t *testing.T) { + srv := httptest.NewServer(http.NotFoundHandler()) + url := srv.URL + srv.Close() + withProbeClient(t, http.DefaultClient) + got := PreflightLogoURL(context.Background(), url+"/logo.png") + if !got.LoadFailure || !hasWarning(got, "could not fetch") { + t.Fatalf("expected fetch failure, got %+v", got) + } +} + +func TestPreflightLogoURL_PlainHTTPWarnsMixedContent(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/png") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Write(pngBytes) + })) + t.Cleanup(srv.Close) + withProbeClient(t, srv.Client()) + got := PreflightLogoURL(context.Background(), srv.URL+"/logo.png") + if got.LoadFailure { + t.Fatalf("http scheme is a soft warning, not a load failure: %+v", got) + } + if !hasWarning(got, "mixed content") { + t.Fatalf("expected mixed-content warning for http:// URL, got %+v", got) + } +} + +func TestPreflightLogoURL_SkipsEmptyAndDataURIs(t *testing.T) { + for _, raw := range []string{"", "data:image/png;base64,aGk="} { + if got := PreflightLogoURL(context.Background(), raw); !got.OK() { + t.Fatalf("%q: expected OK without probing, got %+v", raw, got) + } + } +} diff --git a/internal/storefront/profile.go b/internal/storefront/profile.go index 02434091..d205f488 100644 --- a/internal/storefront/profile.go +++ b/internal/storefront/profile.go @@ -1,6 +1,7 @@ package storefront import ( + "encoding/base64" "encoding/json" "fmt" "net/mail" @@ -90,7 +91,14 @@ func ProfileLocalPath(cfg *config.Config) string { return filepath.Join(cfg.ConfigDir, profileLocalRelPath) } -// ValidateLogoURL accepts absolute http(s) URLs or site-relative paths. +// maxInlineLogoBytes caps the decoded size of an inline data: logo. The +// profile (and the published catalog that embeds it) live in ConfigMaps with +// a hard 1 MiB object limit, so the logo must stay well under that. +const maxInlineLogoBytes = 256 << 10 // 256 KiB + +// ValidateLogoURL accepts absolute http(s) URLs, site-relative paths, or +// inline data:image/...;base64 URIs (self-contained — immune to CORS, +// hotlink protection, and dead hosts). func ValidateLogoURL(raw string) error { raw = strings.TrimSpace(raw) if raw == "" { @@ -102,7 +110,31 @@ func ValidateLogoURL(raw string) error { 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 /") + if strings.HasPrefix(raw, "data:") { + return validateInlineLogo(raw) + } + return fmt.Errorf("logo URL must be https://..., http://..., a path starting with /, or an inline data:image/...;base64 URI") +} + +func validateInlineLogo(raw string) error { + meta, payload, ok := strings.Cut(strings.TrimPrefix(raw, "data:"), ",") + if !ok { + return fmt.Errorf("inline logo: malformed data: URI (missing comma separator)") + } + if !strings.HasPrefix(meta, "image/") { + return fmt.Errorf("inline logo must be a data:image/... URI, got data:%s", meta) + } + if !strings.HasSuffix(meta, ";base64") { + return fmt.Errorf("inline logo must be base64-encoded (data:image/...;base64,...)") + } + decoded, err := base64.StdEncoding.DecodeString(payload) + if err != nil { + return fmt.Errorf("inline logo: invalid base64 payload: %w", err) + } + if len(decoded) > maxInlineLogoBytes { + return fmt.Errorf("inline logo is %d KiB decoded; max %d KiB (it is embedded in the catalog ConfigMap)", len(decoded)>>10, maxInlineLogoBytes>>10) + } + return nil } // ValidateContactEmail accepts a bare operator contact address for OpenAPI diff --git a/internal/storefront/profile_test.go b/internal/storefront/profile_test.go index 7a1709a9..92393985 100644 --- a/internal/storefront/profile_test.go +++ b/internal/storefront/profile_test.go @@ -1,6 +1,7 @@ package storefront_test import ( + "encoding/base64" "testing" "github.com/ObolNetwork/obol-stack/internal/schemas" @@ -35,6 +36,14 @@ func TestValidateLogoURL(t *testing.T) { {"https://cdn.example/logo.png", true}, {"/obol-stack-logo.png", true}, {"logo.png", false}, + // Inline data URIs: image mime + base64 only, size-capped. + {"data:image/png;base64," + base64.StdEncoding.EncodeToString([]byte("\x89PNG\r\n\x1a\n")), true}, + {"data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte("")), true}, + {"data:text/html;base64," + base64.StdEncoding.EncodeToString([]byte("")), false}, + {"data:image/png;base64,not-valid-base64!!!", false}, + {"data:image/png;base64", false}, // no comma separator + {"data:image/svg+xml,", false}, // not base64-encoded + {"data:image/png;base64," + base64.StdEncoding.EncodeToString(make([]byte, 300<<10)), false}, // over 256 KiB cap } { err := storefront.ValidateLogoURL(tc.raw) if tc.ok && err != nil { From bfa56e28b6fbae84affd73bd99595f3feb1b98c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ois=C3=ADn=20Kyne?= Date: Fri, 3 Jul 2026 14:19:30 +0100 Subject: [PATCH 2/4] allow for a local file too --- cmd/obol/sell_info.go | 56 ++++++++++++++++++--- internal/storefront/profile.go | 62 +++++++++++++++++++++++ internal/storefront/profile_test.go | 78 +++++++++++++++++++++++++++++ 3 files changed, 188 insertions(+), 8 deletions(-) diff --git a/cmd/obol/sell_info.go b/cmd/obol/sell_info.go index 1440d6e8..b8db5124 100644 --- a/cmd/obol/sell_info.go +++ b/cmd/obol/sell_info.go @@ -107,13 +107,15 @@ Logo URLs are preflight-checked before publishing: reachability, an image content-type, https (mixed content), and permissive CORS headers (needed by sites that embed your catalog). On a TTY a failing check asks before proceeding; non-interactive runs warn and continue. To sidestep hosting -brittleness entirely, pass an inline data URI (self-contained, no CORS): +brittleness entirely (CORS, hotlinking, dead hosts), inline a local image — +it is embedded in the catalog as a self-contained data: URI: - obol sell info set --logo-url "data:image/png;base64,$(base64 -i logo.png)"`, + obol sell info set --logo-file ./logo.png`, 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://..., /path on this host, or inline data:image/...;base64)"}, + &cli.StringFlag{Name: "logo-file", Usage: "Local image file to inline as the logo (≤256 KiB, converted to a data: URI — no hosting needed)"}, &cli.StringFlag{Name: "contact-email", Usage: "Operator contact email published in /openapi.json (x402scan)"}, }, Action: func(ctx context.Context, cmd *cli.Command) error { @@ -128,9 +130,12 @@ brittleness entirely, pass an inline data URI (self-contained, no CORS): } patch := schemas.StorefrontProfile{} - anyFlag := cmd.IsSet("display-name") || cmd.IsSet("tagline") || cmd.IsSet("logo-url") || cmd.IsSet("contact-email") + anyFlag := cmd.IsSet("display-name") || cmd.IsSet("tagline") || cmd.IsSet("logo-url") || cmd.IsSet("logo-file") || cmd.IsSet("contact-email") if anyFlag { // Flag mode: patch only the fields the operator passed. + if cmd.IsSet("logo-url") && cmd.IsSet("logo-file") { + return errors.New("--logo-url and --logo-file are mutually exclusive") + } if cmd.IsSet("display-name") { patch.DisplayName = strings.TrimSpace(cmd.String("display-name")) } @@ -140,13 +145,20 @@ brittleness entirely, pass an inline data URI (self-contained, no CORS): if cmd.IsSet("logo-url") { patch.LogoURL = strings.TrimSpace(cmd.String("logo-url")) } + if cmd.IsSet("logo-file") { + uri, err := storefront.InlineLogoFromFile(strings.TrimSpace(cmd.String("logo-file"))) + if err != nil { + return err + } + patch.LogoURL = uri + } if cmd.IsSet("contact-email") { patch.ContactEmail = strings.TrimSpace(cmd.String("contact-email")) } } 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, --logo-url, and/or --contact-email") + return errors.New("no flags given and not a TTY: pass --display-name, --tagline, --logo-url, --logo-file, and/or --contact-email") } effective := storefront.ResolvePublished(¤t, mustSellerBaseURL(cfg)) if v, err := u.Input("Display name", effective.DisplayName); err == nil { @@ -155,8 +167,15 @@ brittleness entirely, pass an inline data URI (self-contained, no CORS): 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) + // An inline data: URI is too long to pre-fill; keep it unless + // the operator types a replacement. + logoDefault := effective.LogoURL + if strings.HasPrefix(logoDefault, "data:") { + u.Dim("Current logo: " + storefront.DescribeLogoURL(logoDefault) + " (leave blank to keep)") + logoDefault = "" + } + if v, err := u.Input("Logo URL or local image file", logoDefault); err == nil { + patch.LogoURL = resolveLogoInput(u, v) } if v, err := u.Input("Contact email (OpenAPI)", effective.ContactEmail); err == nil { patch.ContactEmail = strings.TrimSpace(v) @@ -251,6 +270,27 @@ more field flags to reset only those fields, leaving the rest untouched.`, } } +// resolveLogoInput turns an interactive "Logo URL" answer into a profile +// value: a path to an existing local image file is inlined as a data: URI; +// anything else passes through unchanged for URL validation. +func resolveLogoInput(u *ui.UI, v string) string { + v = strings.TrimSpace(v) + if v == "" || strings.HasPrefix(v, "data:") || + strings.HasPrefix(v, "http://") || strings.HasPrefix(v, "https://") { + return v + } + if st, err := os.Stat(v); err != nil || !st.Mode().IsRegular() { + return v + } + uri, err := storefront.InlineLogoFromFile(v) + if err != nil { + u.Warn(err.Error()) + return v + } + u.Infof("Inlined %s as %s", v, storefront.DescribeLogoURL(uri)) + return uri +} + // confirmLogoURL probes a newly-set logo URL the way browsers will load it // (reachability, image content-type, permissive CORS, https, size) before it // is published to every catalog consumer. On a TTY, problems become a @@ -362,7 +402,7 @@ 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.Printf(" Logo: %s", valueOrNone(storefront.DescribeLogoURL(catalog.LogoURL))) u.Blank() } @@ -543,7 +583,7 @@ func mustSellerBaseURL(cfg *config.Config) string { 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) + u.Printf(" Logo URL: %s", storefront.DescribeLogoURL(profile.LogoURL)) if email := strings.TrimSpace(profile.ContactEmail); email != "" { u.Printf(" Contact email: %s", email) } else { diff --git a/internal/storefront/profile.go b/internal/storefront/profile.go index d205f488..cc6b53fe 100644 --- a/internal/storefront/profile.go +++ b/internal/storefront/profile.go @@ -4,7 +4,9 @@ import ( "encoding/base64" "encoding/json" "fmt" + "net/http" "net/mail" + "os" "path/filepath" "strings" @@ -116,6 +118,44 @@ func ValidateLogoURL(raw string) error { return fmt.Errorf("logo URL must be https://..., http://..., a path starting with /, or an inline data:image/...;base64 URI") } +// InlineLogoFromFile reads a local image file and returns it as a +// data:image/...;base64 URI suitable for StorefrontProfile.LogoURL. Inline +// logos are self-contained — immune to CORS, hotlink protection, and dead +// hosts — but must fit the ConfigMap budget (maxInlineLogoBytes). +func InlineLogoFromFile(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("read logo file: %w", err) + } + if len(data) == 0 { + return "", fmt.Errorf("logo file %s is empty", path) + } + if len(data) > maxInlineLogoBytes { + return "", fmt.Errorf("logo file is %d KiB; max %d KiB for an inline logo (it is embedded in the catalog ConfigMap) — host larger images at an https URL instead", len(data)>>10, maxInlineLogoBytes>>10) + } + mime := detectImageMIME(path, data) + if mime == "" { + return "", fmt.Errorf("logo file %s does not look like an image (png, jpeg, gif, webp, svg, ico)", path) + } + return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data), nil +} + +// detectImageMIME sniffs an image content-type from file bytes, falling back +// to the extension for SVG (which sniffs as XML/plain text). +func detectImageMIME(path string, data []byte) string { + sniffed := http.DetectContentType(data) + if i := strings.Index(sniffed, ";"); i >= 0 { + sniffed = sniffed[:i] + } + if strings.HasPrefix(sniffed, "image/") { + return sniffed + } + if strings.EqualFold(filepath.Ext(path), ".svg") { + return "image/svg+xml" + } + return "" +} + func validateInlineLogo(raw string) error { meta, payload, ok := strings.Cut(strings.TrimPrefix(raw, "data:"), ",") if !ok { @@ -154,6 +194,28 @@ func ValidateContactEmail(raw string) error { return nil } +// DescribeLogoURL returns a terminal-friendly rendering of a logo URL: +// inline data: URIs are summarised (mime + decoded size) instead of dumping +// the base64 payload; everything else passes through unchanged. +func DescribeLogoURL(raw string) string { + raw = strings.TrimSpace(raw) + if !strings.HasPrefix(raw, "data:") { + return raw + } + meta, payload, ok := strings.Cut(strings.TrimPrefix(raw, "data:"), ",") + if !ok { + return raw + } + mime := strings.TrimSuffix(meta, ";base64") + // DecodedLen ignores '=' padding; subtract it for an exact size. + pad := 0 + for i := len(payload) - 1; i >= 0 && payload[i] == '='; i-- { + pad++ + } + size := len(payload)/4*3 - pad + return fmt.Sprintf("inline %s (%d KiB)", mime, (size+1023)>>10) +} + // IsDefaultLogoURL reports whether url is the stack default wordmark (relative or absolute). func IsDefaultLogoURL(raw string) bool { raw = strings.TrimSpace(raw) diff --git a/internal/storefront/profile_test.go b/internal/storefront/profile_test.go index 92393985..973c5f29 100644 --- a/internal/storefront/profile_test.go +++ b/internal/storefront/profile_test.go @@ -2,6 +2,9 @@ package storefront_test import ( "encoding/base64" + "os" + "path/filepath" + "strings" "testing" "github.com/ObolNetwork/obol-stack/internal/schemas" @@ -74,6 +77,81 @@ func TestValidateContactEmail(t *testing.T) { } } +func TestInlineLogoFromFile(t *testing.T) { + dir := t.TempDir() + write := func(name string, data []byte) string { + t.Helper() + path := filepath.Join(dir, name) + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + return path + } + pngBytes := []byte("\x89PNG\r\n\x1a\n0000000000000000") + + t.Run("png sniffed from bytes", func(t *testing.T) { + uri, err := storefront.InlineLogoFromFile(write("logo.bin", pngBytes)) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(uri, "data:image/png;base64,") { + t.Fatalf("unexpected URI prefix: %.50s", uri) + } + if err := storefront.ValidateLogoURL(uri); err != nil { + t.Fatalf("inlined logo should validate: %v", err) + } + }) + + t.Run("svg by extension", func(t *testing.T) { + uri, err := storefront.InlineLogoFromFile(write("logo.svg", []byte(``))) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(uri, "data:image/svg+xml;base64,") { + t.Fatalf("unexpected URI prefix: %.50s", uri) + } + }) + + t.Run("rejects non-image", func(t *testing.T) { + if _, err := storefront.InlineLogoFromFile(write("notes.txt", []byte("hello"))); err == nil { + t.Fatal("expected error for non-image file") + } + }) + + t.Run("rejects oversized", func(t *testing.T) { + big := append(append([]byte{}, pngBytes...), make([]byte, 300<<10)...) + if _, err := storefront.InlineLogoFromFile(write("big.png", big)); err == nil { + t.Fatal("expected error for oversized file") + } + }) + + t.Run("rejects empty and missing", func(t *testing.T) { + if _, err := storefront.InlineLogoFromFile(write("empty.png", nil)); err == nil { + t.Fatal("expected error for empty file") + } + if _, err := storefront.InlineLogoFromFile(filepath.Join(dir, "nope.png")); err == nil { + t.Fatal("expected error for missing file") + } + }) +} + +func TestDescribeLogoURL(t *testing.T) { + payload := base64.StdEncoding.EncodeToString(make([]byte, 4<<10)) + for _, tc := range []struct { + raw, want string + }{ + {"https://cdn.example/logo.png", "https://cdn.example/logo.png"}, + {"/obol-stack-logo.png", "/obol-stack-logo.png"}, + {"", ""}, + {"data:image/png;base64," + payload, "inline image/png (4 KiB)"}, + {"data:garbage-no-comma", "data:garbage-no-comma"}, + } { + if got := storefront.DescribeLogoURL(tc.raw); got != tc.want { + t.Fatalf("DescribeLogoURL(%.40q) = %q, want %q", tc.raw, got, tc.want) + } + } +} + func TestIsDefaultLogoURL(t *testing.T) { for _, tc := range []struct { raw string From 8d3fe2561d705c43b5bef8330200b85d5762846c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ois=C3=ADn=20Kyne?= Date: Fri, 3 Jul 2026 16:09:58 +0100 Subject: [PATCH 3/4] Register to x402scan via cli --- CLAUDE.md | 4 +- cmd/obol/sell.go | 17 +- cmd/obol/sell_register_x402scan.go | 208 +++++++++++++++++++++ cmd/obol/sell_register_x402scan_test.go | 36 ++++ internal/erc8004/signer.go | 40 ++++ internal/x402scan/client.go | 235 +++++++++++++++++++++++ internal/x402scan/client_test.go | 236 ++++++++++++++++++++++++ internal/x402scan/siwe.go | 80 ++++++++ 8 files changed, 849 insertions(+), 7 deletions(-) create mode 100644 cmd/obol/sell_register_x402scan.go create mode 100644 cmd/obol/sell_register_x402scan_test.go create mode 100644 internal/x402scan/client.go create mode 100644 internal/x402scan/client_test.go create mode 100644 internal/x402scan/siwe.go diff --git a/CLAUDE.md b/CLAUDE.md index 3eaf1db6..4d0a6c25 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -157,7 +157,9 @@ Port-forward to `x402-verifier` and calling `/verify` directly: MUST set `X-Forw **PurchaseRequest status caveat**: `PurchaseRequest.status` (`conditions[].message`, `remaining`, `spent`) is controller's last reconciled snapshot, NOT live per-request counter. For real-time auth pool + refill decisions, always query `x402-buyer` `GET /status` in litellm pod. -**CLI**: `obol sell pricing --pay-to --chain`, `obol sell inference --model --pay-to --price|--per-mtok [--token USDC|OBOL]`, `obol sell http --pay-to --chain --price|--per-request|--per-mtok --upstream --port --namespace --health-path [--token USDC|OBOL]`, `obol sell list|status|stop|delete`, `obol sell register --chain [--name]`. +**CLI**: `obol sell pricing --pay-to --chain`, `obol sell inference --model --pay-to --price|--per-mtok [--token USDC|OBOL]`, `obol sell http --pay-to --chain --price|--per-request|--per-mtok --upstream --port --namespace --health-path [--token USDC|OBOL]`, `obol sell list|status|stop|delete`, `obol sell register --chain [--name]`, `obol sell register x402scan [--origin]`. + +**x402scan registration** (`obol sell register x402scan`, `internal/x402scan/`): submits the storefront origin to the x402scan.com discovery index via `POST /api/x402/registry/register-origin`. Auth is SIWX (EIP-4361/SIWE challenge in the 402 body, signed EIP-191 by the agent's remote-signer via `POST /api/v1/sign/{addr}/message`, retried with the payload base64-encoded in the `SIGN-IN-WITH-X` header). x402scan crawls the origin's `/openapi.json` (already published with `x-payment-info` per paid op) and live-probes each endpoint for a real 402. Rejects `obol.stack`/localhost and `*.trycloudflare.com` quick-tunnels locally — needs a permanent tunnel hostname. Idempotent; stale offers are deprecated server-side. **`obol sell http` flag reference** (common mistakes: `--model`, `--network` do NOT exist; `--wallet` is a deprecated alias for `--pay-to`): ``` diff --git a/cmd/obol/sell.go b/cmd/obol/sell.go index 036c8e78..5a7f4cb3 100644 --- a/cmd/obol/sell.go +++ b/cmd/obol/sell.go @@ -53,7 +53,8 @@ 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) + register Publish the seller to a distribution channel (ERC-8004 on-chain, + 'register x402scan' for the x402scan discovery index) identity Manage the durable on-chain identity record list List every offer (raw) resume Re-publish all offers (e.g. after a restart) @@ -3080,11 +3081,11 @@ Reloads the payment verifier when configuration is changed.`, func sellRegisterCommand(cfg *config.Config) *cli.Command { return &cli.Command{ Name: "register", - Usage: "Publish the seller's identity to a distribution channel (ERC-8004)", + Usage: "Publish the seller's identity to a distribution channel (ERC-8004, x402scan)", 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. +discover it. The default (no subcommand) registers on the ERC-8004 Agent +Registry (on-chain); 'register x402scan' submits the storefront origin to the +x402scan.com discovery index instead. 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 @@ -3094,7 +3095,11 @@ the target chain (~$0.20–$0.50 of native gas typically suffices). Examples: obol sell register # defaults to mainnet obol sell register --network base # register on base - obol sell register --network base-sepolia # add a Base Sepolia registration`, + obol sell register --network base-sepolia # add a Base Sepolia registration + obol sell register x402scan # list in the x402scan index (no gas)`, + Commands: []*cli.Command{ + sellRegisterX402scanCommand(cfg), + }, Flags: []cli.Flag{ &cli.StringFlag{ Name: "network", diff --git a/cmd/obol/sell_register_x402scan.go b/cmd/obol/sell_register_x402scan.go new file mode 100644 index 00000000..f9845610 --- /dev/null +++ b/cmd/obol/sell_register_x402scan.go @@ -0,0 +1,208 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/ObolNetwork/obol-stack/internal/config" + "github.com/ObolNetwork/obol-stack/internal/erc8004" + "github.com/ObolNetwork/obol-stack/internal/hermes" + "github.com/ObolNetwork/obol-stack/internal/kubectl" + "github.com/ObolNetwork/obol-stack/internal/ui" + "github.com/ObolNetwork/obol-stack/internal/x402scan" + "github.com/urfave/cli/v3" +) + +// sellRegisterX402scanCommand registers the storefront's public origin in the +// x402scan.com discovery index. x402scan crawls the origin's /openapi.json +// (which the serviceoffer-controller already publishes with x-payment-info +// per paid operation), live-probes each advertised endpoint for a real 402 +// challenge, and lists the ones that pass. Authentication is SIWX: the +// registry's challenge is signed EIP-191 by the agent's remote-signer — the +// CLI never touches key material. +func sellRegisterX402scanCommand(cfg *config.Config) *cli.Command { + return &cli.Command{ + Name: "x402scan", + Usage: "Register this storefront's origin in the x402scan discovery index", + Description: `Submits the storefront's public origin to x402scan.com so agents can +discover its paid services. x402scan reads /openapi.json from the origin and +probes every advertised endpoint for a live x402 402 challenge, so: + + - a public tunnel hostname must be configured (obol tunnel setup); + x402scan rejects ephemeral quick-tunnel and private/localhost origins + - at least one offer must be Ready (obol sell status) + +The request is authenticated by signing the registry's Sign-In-With-X +challenge with the agent's wallet via the remote-signer. Re-running is safe: +registration is idempotent per origin, and offers that disappeared from the +catalog are deprecated on the x402scan side. + +Examples: + obol sell register x402scan + obol sell register x402scan --origin https://store.example.com`, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "origin", + Usage: "Public origin to register (auto-detected from the tunnel hostname if not set)", + }, + &cli.StringFlag{ + Name: "registry-url", + Usage: "x402scan-compatible registry base URL", + Value: x402scan.DefaultBaseURL, + Hidden: true, + }, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + u := getUI(cmd) + if err := kubectl.EnsureCluster(cfg); err != nil { + return err + } + + origin, err := resolveX402scanOrigin(cfg, cmd.String("origin")) + if err != nil { + return err + } + + // Non-fatal preflight: x402scan discovers services from the + // origin's /openapi.json, so a missing/empty doc means the + // registration will come back "no discovery" or "no valid + // resources". Warn early with the local fix. + preflightOpenAPI(ctx, u, origin) + + // Same signer posture as ERC-8004 registration: all signing via + // the agent's remote-signer, no key material in the CLI. + if _, err := hermes.ResolveWalletAddress(cfg); err != nil { + return fmt.Errorf("no Hermes remote-signer wallet found: %w\n\n Run 'obol agent init' first, or 'obol wallet import --private-key-file ' to seed a specific key", err) + } + signerNS, err := hermes.ResolveInstanceNamespace(cfg) + if err != nil { + return fmt.Errorf("resolve Hermes instance namespace: %w", err) + } + pf, err := startSignerPortForward(cfg, signerNS) + if err != nil { + return fmt.Errorf("port-forward to remote-signer: %w", err) + } + defer pf.Stop() + + signer := erc8004.NewRemoteSigner(fmt.Sprintf("http://localhost:%d", pf.localPort)) + addr, err := signer.GetAddress(ctx) + if err != nil { + return err + } + + u.Infof("Registering %s with x402scan...", origin) + u.Dim(fmt.Sprintf(" Signing as agent wallet %s", addr.Hex())) + + client := x402scan.NewClient(cmd.String("registry-url")) + var result *x402scan.RegisterResult + err = u.RunWithSpinner("x402scan is crawling and probing the origin", func() error { + var regErr error + result, regErr = client.RegisterOrigin(ctx, origin, addr, signer) + return regErr + }) + if err != nil { + switch { + case errors.Is(err, x402scan.ErrNoDiscovery): + u.Error(err.Error()) + u.Dim(" x402scan could not read a discovery document from the origin.") + u.Dim(fmt.Sprintf(" Check that %s/openapi.json is publicly reachable and the tunnel is up (obol tunnel status).", origin)) + case errors.Is(err, x402scan.ErrNoValidResources): + u.Error(err.Error()) + u.Dim(" A discovery document was found, but no endpoint answered with a live x402 402 challenge.") + u.Dim(" Check offers are Ready (obol sell status) and publicly reachable through the tunnel.") + } + return err + } + + if u.IsJSON() { + return u.JSON(result) + } + + u.Successf("Registered %d/%d resources on x402scan (source: %s)", result.Registered, result.Total, result.Source) + if result.SIWX > 0 { + u.Printf(" Identity-only (SIWX) resources: %d", result.SIWX) + } + if result.Deprecated > 0 { + u.Printf(" Deprecated stale resources: %d", result.Deprecated) + } + for _, f := range result.FailedList { + u.Warnf("failed probe: %s — %s", f.URL, f.Error) + } + if result.Warning != "" { + u.Warn(result.Warning) + } + if result.ContactEmail != "" { + u.Dim(" Contact email on record: " + result.ContactEmail) + } + u.Blank() + u.Dim("Browse the index: https://x402scan.com/resources") + return nil + }, + } +} + +// resolveX402scanOrigin picks the public origin to register and rejects +// origins x402scan is known to refuse (local hostnames, ephemeral +// quick-tunnel domains) with actionable errors instead of a remote 4xx. +func resolveX402scanOrigin(cfg *config.Config, explicit string) (string, error) { + origin := strings.TrimSpace(explicit) + if origin == "" { + base, err := sellerBaseURL(cfg) + if err != nil { + return "", fmt.Errorf("resolve public origin: %w (pass --origin explicitly)", err) + } + origin = base + } + + parsed, err := url.Parse(origin) + if err != nil || parsed.Host == "" { + return "", fmt.Errorf("invalid origin %q: expected https://", origin) + } + host := parsed.Hostname() + switch { + case host == "obol.stack" || host == "localhost" || host == "127.0.0.1": + return "", errors.New("no public hostname configured — x402scan must be able to reach the storefront from the internet.\n\n Set up a permanent tunnel hostname first: obol tunnel setup\n Then re-run, or pass --origin https://") + case strings.HasSuffix(host, ".trycloudflare.com"): + return "", errors.New("the current tunnel is an ephemeral quick-tunnel (*.trycloudflare.com), which x402scan rejects.\n\n Register a permanent hostname on your own domain (obol tunnel setup, see also obol domain), then re-run") + } + if parsed.Scheme != "https" { + return "", fmt.Errorf("origin %s must be https:// — x402scan only indexes TLS origins", origin) + } + // Registration is per-origin; strip any path so what we submit matches + // what the registry crawls. + return parsed.Scheme + "://" + parsed.Host, nil +} + +// preflightOpenAPI warns (never fails) when the origin's /openapi.json is +// unreachable or advertises no operations — the two states that make the +// remote registration a guaranteed no-op. +func preflightOpenAPI(ctx context.Context, u *ui.UI, origin string) { + ctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, origin+"/openapi.json", nil) + if err != nil { + return + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + u.Warnf("could not fetch %s/openapi.json (%v) — x402scan discovers services from it; check the tunnel is up", origin, err) + return + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + u.Warnf("%s/openapi.json returned HTTP %d — x402scan discovers services from it; check the tunnel is up", origin, resp.StatusCode) + return + } + var doc struct { + Paths map[string]json.RawMessage `json:"paths"` + } + if err := json.NewDecoder(resp.Body).Decode(&doc); err == nil && len(doc.Paths) == 0 { + u.Warn("the published /openapi.json advertises no operations — put at least one offer on sale (obol sell inference|http|agent) before registering") + } +} diff --git a/cmd/obol/sell_register_x402scan_test.go b/cmd/obol/sell_register_x402scan_test.go new file mode 100644 index 00000000..a810346f --- /dev/null +++ b/cmd/obol/sell_register_x402scan_test.go @@ -0,0 +1,36 @@ +package main + +import ( + "strings" + "testing" +) + +func TestResolveX402scanOrigin_Explicit(t *testing.T) { + for _, tc := range []struct { + in string + want string + errPart string + }{ + {in: "https://store.example.com", want: "https://store.example.com"}, + {in: "https://store.example.com/some/path", want: "https://store.example.com"}, + {in: "http://store.example.com", errPart: "must be https"}, + {in: "https://obol.stack:8080", errPart: "no public hostname"}, + {in: "https://localhost:8080", errPart: "no public hostname"}, + {in: "https://abc-def.trycloudflare.com", errPart: "quick-tunnel"}, + {in: "not a url", errPart: "invalid origin"}, + } { + got, err := resolveX402scanOrigin(nil, tc.in) + if tc.errPart != "" { + if err == nil || !strings.Contains(err.Error(), tc.errPart) { + t.Fatalf("%q: expected error containing %q, got %v", tc.in, tc.errPart, err) + } + continue + } + if err != nil { + t.Fatalf("%q: %v", tc.in, err) + } + if got != tc.want { + t.Fatalf("%q: got %q, want %q", tc.in, got, tc.want) + } + } +} diff --git a/internal/erc8004/signer.go b/internal/erc8004/signer.go index 1a553bc6..6201733a 100644 --- a/internal/erc8004/signer.go +++ b/internal/erc8004/signer.go @@ -127,6 +127,46 @@ func (s *RemoteSigner) SignTransaction(ctx context.Context, addr common.Address, return sr.SignedTransaction, nil } +// SignMessage signs a plain-text message with the EIP-191 personal-message +// prefix via the remote-signer (POST /api/v1/sign/{address}/message). Used +// for SIWE / Sign-In-With-X authentication. Returns the 65-byte signature +// as a 0x-prefixed hex string. +func (s *RemoteSigner) SignMessage(ctx context.Context, addr common.Address, message string) (string, error) { + url := fmt.Sprintf("%s/api/v1/sign/%s/message", s.baseURL, addr.Hex()) + + body, err := json.Marshal(map[string]string{"message": message}) + if err != nil { + return "", fmt.Errorf("remote-signer: marshal message: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return "", fmt.Errorf("remote-signer: build request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := s.client.Do(req) + if err != nil { + return "", fmt.Errorf("remote-signer: sign message: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("remote-signer: sign message: HTTP %d: %s", resp.StatusCode, body) + } + + var sr signResponse + if err := json.NewDecoder(resp.Body).Decode(&sr); err != nil { + return "", fmt.Errorf("remote-signer: decode response: %w", err) + } + if sr.Error != "" { + return "", fmt.Errorf("remote-signer: %s", sr.Error) + } + + return sr.Signature, nil +} + // EIP712TypedData represents a full EIP-712 typed data structure for signing. type EIP712TypedData struct { Types map[string][]EIP712Field `json:"types"` diff --git a/internal/x402scan/client.go b/internal/x402scan/client.go new file mode 100644 index 00000000..49c22245 --- /dev/null +++ b/internal/x402scan/client.go @@ -0,0 +1,235 @@ +package x402scan + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/ethereum/go-ethereum/common" +) + +// DefaultBaseURL is the public x402scan registry. +const DefaultBaseURL = "https://x402scan.com" + +const registerOriginPath = "/api/x402/registry/register-origin" + +// siwxHeader carries the base64-encoded signed SIWX payload on the retry. +const siwxHeader = "SIGN-IN-WITH-X" + +// Sentinel errors for the registry's two well-known rejection modes so the +// CLI can print targeted fix-it guidance. +var ( + // ErrNoDiscovery: the registry could not find a discovery document + // (openapi.json / .well-known/x402) at the origin. HTTP 404. + ErrNoDiscovery = errors.New("x402scan found no discovery document at the origin") + // ErrNoValidResources: a discovery document was found but no advertised + // endpoint passed the live x402 402 probe. HTTP 422. + ErrNoValidResources = errors.New("x402scan found no valid paid resources at the origin") +) + +// MessageSigner signs a plain-text message EIP-191 style with the key held +// for addr. *erc8004.RemoteSigner satisfies this. +type MessageSigner interface { + SignMessage(ctx context.Context, addr common.Address, message string) (string, error) +} + +// Client talks to an x402scan-compatible registry. +type Client struct { + baseURL string + http *http.Client +} + +// NewClient returns a registry client. An empty baseURL selects the public +// x402scan.com instance. +func NewClient(baseURL string) *Client { + if strings.TrimSpace(baseURL) == "" { + baseURL = DefaultBaseURL + } + return &Client{ + baseURL: strings.TrimRight(baseURL, "/"), + // Registration crawls the origin and live-probes every advertised + // endpoint before answering, so allow well over the registry's own + // 10s-per-fetch discovery budget. + http: &http.Client{Timeout: 120 * time.Second}, + } +} + +// RegisterResult is the registry's summary of a registration run. +type RegisterResult struct { + Success bool `json:"success"` + Registered int `json:"registered"` + SIWX int `json:"siwx"` + Failed int `json:"failed"` + Skipped int `json:"skipped"` + Deprecated int `json:"deprecated"` + Total int `json:"total"` + Source string `json:"source"` + FailedList []FailedResource `json:"failedDetails,omitempty"` + SIWXList []SIWXResource `json:"siwxDetails,omitempty"` + ContactEmail string `json:"contactEmail,omitempty"` + Warning string `json:"warning,omitempty"` +} + +// FailedResource describes one advertised endpoint that failed the +// registry's live probe. +type FailedResource struct { + URL string `json:"url"` + Error string `json:"error"` + Status int `json:"status,omitempty"` +} + +// SIWXResource is an identity-only (wallet-gated, unpaid) endpoint. +type SIWXResource struct { + URL string `json:"url"` +} + +// registryError is the {"success":false,"error":{...}} failure envelope. +type registryError struct { + Success bool `json:"success"` + Error struct { + Type string `json:"type"` + Message string `json:"message"` + } `json:"error"` +} + +// challengeBody is the subset of the 402 response we need: the SIWX +// challenge under extensions["sign-in-with-x"]. +type challengeBody struct { + Extensions struct { + SignInWithX struct { + Info SIWXInfo `json:"info"` + SupportedChains []struct { + ChainID string `json:"chainId"` + Type string `json:"type"` + } `json:"supportedChains"` + } `json:"sign-in-with-x"` + } `json:"extensions"` +} + +// siwxPayload is the signed proof sent back in the SIGN-IN-WITH-X header: +// the challenge fields verbatim plus the signer address and signature. +type siwxPayload struct { + SIWXInfo + Address string `json:"address"` + Signature string `json:"signature"` +} + +// RegisterOrigin registers origin in the discovery index, authenticating as +// addr via signer. It performs the full SIWX handshake: unauthenticated POST +// -> 402 challenge -> EIP-191 sign -> authenticated retry. +func (c *Client) RegisterOrigin(ctx context.Context, origin string, addr common.Address, signer MessageSigner) (*RegisterResult, error) { + // First POST without auth to obtain the SIWX challenge (nonce, domain, + // uri, timestamps) minted by the registry. + status, body, err := c.postRegister(ctx, origin, "") + if err != nil { + return nil, err + } + if status != http.StatusPaymentRequired { + // Auth requirements may be relaxed in future; accept a direct answer. + return parseRegisterResponse(status, body) + } + + var challenge challengeBody + if err := json.Unmarshal(body, &challenge); err != nil { + return nil, fmt.Errorf("x402scan: parse SIWX challenge: %w (body: %.300s)", err, body) + } + info := challenge.Extensions.SignInWithX.Info + if info.Nonce == "" || info.Domain == "" { + return nil, fmt.Errorf("x402scan: 402 response carried no sign-in-with-x challenge (body: %.300s)", body) + } + if info.Type != "" && info.Type != "eip191" { + return nil, fmt.Errorf("x402scan: registry requires unsupported signature scheme %q (only eip191 is supported)", info.Type) + } + + message := FormatSIWEMessage(info, addr) + signature, err := signer.SignMessage(ctx, addr, message) + if err != nil { + return nil, fmt.Errorf("sign SIWX challenge: %w", err) + } + + payload := siwxPayload{SIWXInfo: info, Address: addr.Hex(), Signature: signature} + if payload.Type == "" { + payload.Type = "eip191" + } + encoded, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("x402scan: marshal SIWX payload: %w", err) + } + + status, body, err = c.postRegister(ctx, origin, base64.StdEncoding.EncodeToString(encoded)) + if err != nil { + return nil, err + } + if status == http.StatusPaymentRequired { + // Auth was rejected on the retry — surface the registry's reason + // (siwx_expired, siwx_invalid_signature, ...) rather than a bare 402. + var authErr struct { + Error string `json:"error"` + Message string `json:"message"` + } + _ = json.Unmarshal(body, &authErr) + if authErr.Error != "" { + return nil, fmt.Errorf("x402scan rejected SIWX authentication: %s: %s", authErr.Error, authErr.Message) + } + return nil, fmt.Errorf("x402scan rejected SIWX authentication (body: %.300s)", body) + } + return parseRegisterResponse(status, body) +} + +func (c *Client) postRegister(ctx context.Context, origin, siwx string) (int, []byte, error) { + payload, err := json.Marshal(map[string]string{"origin": origin}) + if err != nil { + return 0, nil, fmt.Errorf("x402scan: marshal body: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+registerOriginPath, bytes.NewReader(payload)) + if err != nil { + return 0, nil, fmt.Errorf("x402scan: build request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + if siwx != "" { + req.Header.Set(siwxHeader, siwx) + } + + resp, err := c.http.Do(req) + if err != nil { + return 0, nil, fmt.Errorf("x402scan: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return 0, nil, fmt.Errorf("x402scan: read response: %w", err) + } + return resp.StatusCode, body, nil +} + +func parseRegisterResponse(status int, body []byte) (*RegisterResult, error) { + switch status { + case http.StatusOK: + var result RegisterResult + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("x402scan: parse registration result: %w (body: %.300s)", err, body) + } + return &result, nil + case http.StatusNotFound: + return nil, fmt.Errorf("%w: %s", ErrNoDiscovery, registryErrorMessage(body)) + case http.StatusUnprocessableEntity: + return nil, fmt.Errorf("%w: %s", ErrNoValidResources, registryErrorMessage(body)) + default: + return nil, fmt.Errorf("x402scan: registration failed: HTTP %d: %.300s", status, body) + } +} + +func registryErrorMessage(body []byte) string { + var e registryError + if err := json.Unmarshal(body, &e); err == nil && e.Error.Message != "" { + return e.Error.Message + } + return strings.TrimSpace(fmt.Sprintf("%.300s", body)) +} diff --git a/internal/x402scan/client_test.go b/internal/x402scan/client_test.go new file mode 100644 index 00000000..ab8d2163 --- /dev/null +++ b/internal/x402scan/client_test.go @@ -0,0 +1,236 @@ +package x402scan + +import ( + "context" + "crypto/ecdsa" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/accounts" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" +) + +// localSigner implements MessageSigner with an in-memory key, mirroring the +// remote-signer's EIP-191 semantics (prefix + keccak + 27-offset v). +type localSigner struct{ key *ecdsa.PrivateKey } + +func (s localSigner) SignMessage(_ context.Context, _ common.Address, message string) (string, error) { + sig, err := crypto.Sign(accounts.TextHash([]byte(message)), s.key) + if err != nil { + return "", err + } + sig[64] += 27 + return "0x" + hex.EncodeToString(sig), nil +} + +// recoverSIWX re-renders the SIWE message from a decoded payload and +// recovers the signer address — the same verification x402scan performs. +func recoverSIWX(t *testing.T, payload siwxPayload) common.Address { + t.Helper() + msg := FormatSIWEMessage(payload.SIWXInfo, common.HexToAddress(payload.Address)) + sig, err := hex.DecodeString(strings.TrimPrefix(payload.Signature, "0x")) + if err != nil || len(sig) != 65 { + t.Fatalf("bad signature encoding: %v (len %d)", err, len(sig)) + } + sig[64] -= 27 + pub, err := crypto.SigToPub(accounts.TextHash([]byte(msg)), sig) + if err != nil { + t.Fatalf("recover signer: %v", err) + } + return crypto.PubkeyToAddress(*pub) +} + +// newRegistry stands up a fake x402scan: 402 + SIWX challenge without the +// header, full verification + canned result with it. +func newRegistry(t *testing.T, result string) (*httptest.Server, *SIWXInfo) { + t.Helper() + issued := &SIWXInfo{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != registerOriginPath { + http.NotFound(w, r) + return + } + var body struct { + Origin string `json:"origin"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Origin == "" { + http.Error(w, "bad body", http.StatusBadRequest) + return + } + + header := r.Header.Get(siwxHeader) + if header == "" { + *issued = SIWXInfo{ + Domain: "x402scan.test", + URI: "http://" + r.Host + registerOriginPath, + Version: "1", + ChainID: "eip155:8453", + Type: "eip191", + Nonce: "a3f1c2d4e5b6978812345678deadbeef", + IssuedAt: "2026-07-03T12:00:00.000Z", + ExpirationTime: "2026-07-03T12:05:00.000Z", + Statement: "Sign in to verify your wallet identity", + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusPaymentRequired) + challenge := map[string]any{ + "x402Version": 2, + "error": "SIWX authentication required", + "accepts": []any{}, + "extensions": map[string]any{ + "sign-in-with-x": map[string]any{ + "info": issued, + "supportedChains": []map[string]string{ + {"chainId": "eip155:8453", "type": "eip191"}, + }, + }, + }, + } + _ = json.NewEncoder(w).Encode(challenge) + return + } + + raw, err := base64.StdEncoding.DecodeString(header) + if err != nil { + http.Error(w, `{"error":"siwx_malformed"}`, http.StatusPaymentRequired) + return + } + var payload siwxPayload + if err := json.Unmarshal(raw, &payload); err != nil { + http.Error(w, `{"error":"siwx_malformed"}`, http.StatusPaymentRequired) + return + } + if payload.Nonce != issued.Nonce || payload.Domain != issued.Domain || payload.URI != issued.URI { + http.Error(w, `{"error":"siwx_challenge_mismatch"}`, http.StatusPaymentRequired) + return + } + if got := recoverSIWX(t, payload); got != common.HexToAddress(payload.Address) { + http.Error(w, `{"error":"siwx_invalid_signature"}`, http.StatusPaymentRequired) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(result)) + })) + t.Cleanup(srv.Close) + return srv, issued +} + +func TestRegisterOrigin_FullSIWXHandshake(t *testing.T) { + srv, _ := newRegistry(t, `{"success":true,"registered":2,"siwx":0,"failed":1,"skipped":0,"deprecated":1,"total":3,"source":"openapi","failedDetails":[{"url":"https://s.example/services/x","error":"no 402 challenge","status":200}],"warning":"no contact email"}`) + + key, _ := crypto.GenerateKey() + addr := crypto.PubkeyToAddress(key.PublicKey) + + result, err := NewClient(srv.URL).RegisterOrigin(context.Background(), "https://seller.example", addr, localSigner{key}) + if err != nil { + t.Fatal(err) + } + if !result.Success || result.Registered != 2 || result.Total != 3 || result.Source != "openapi" { + t.Fatalf("unexpected result: %+v", result) + } + if len(result.FailedList) != 1 || result.FailedList[0].Error != "no 402 challenge" { + t.Fatalf("unexpected failedDetails: %+v", result.FailedList) + } + if result.Warning != "no contact email" { + t.Fatalf("unexpected warning: %q", result.Warning) + } +} + +func TestRegisterOrigin_WrongKeyIsRejected(t *testing.T) { + srv, _ := newRegistry(t, `{"success":true}`) + + key, _ := crypto.GenerateKey() + imposter, _ := crypto.GenerateKey() + addr := crypto.PubkeyToAddress(key.PublicKey) // claims key's address... + + _, err := NewClient(srv.URL).RegisterOrigin(context.Background(), "https://seller.example", addr, localSigner{imposter}) + if err == nil || !strings.Contains(err.Error(), "siwx_invalid_signature") { + t.Fatalf("expected siwx_invalid_signature rejection, got %v", err) + } +} + +func TestRegisterOrigin_NoDiscovery(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"success":false,"error":{"type":"no_discovery","message":"no discovery document found"}}`)) + })) + t.Cleanup(srv.Close) + + key, _ := crypto.GenerateKey() + _, err := NewClient(srv.URL).RegisterOrigin(context.Background(), "https://seller.example", crypto.PubkeyToAddress(key.PublicKey), localSigner{key}) + if !errors.Is(err, ErrNoDiscovery) { + t.Fatalf("expected ErrNoDiscovery, got %v", err) + } + if !strings.Contains(err.Error(), "no discovery document found") { + t.Fatalf("expected registry message in error, got %v", err) + } +} + +func TestRegisterOrigin_NoValidResources(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"success":false,"error":{"type":"no_valid_resources","message":"0 of 2 endpoints answered a 402"}}`)) + })) + t.Cleanup(srv.Close) + + key, _ := crypto.GenerateKey() + _, err := NewClient(srv.URL).RegisterOrigin(context.Background(), "https://seller.example", crypto.PubkeyToAddress(key.PublicKey), localSigner{key}) + if !errors.Is(err, ErrNoValidResources) { + t.Fatalf("expected ErrNoValidResources, got %v", err) + } +} + +func TestFormatSIWEMessage_Golden(t *testing.T) { + info := SIWXInfo{ + Domain: "x402scan.com", + URI: "https://x402scan.com/api/x402/registry/register-origin", + Version: "1", + ChainID: "eip155:8453", + Type: "eip191", + Nonce: "deadbeefdeadbeefdeadbeefdeadbeef", + IssuedAt: "2026-07-03T12:00:00.000Z", + ExpirationTime: "2026-07-03T12:05:00.000Z", + Statement: "Sign in to verify your wallet identity", + } + addr := common.HexToAddress("0x8ba1f109551bd432803012645ac136ddd64dba72") + + want := "x402scan.com wants you to sign in with your Ethereum account:\n" + + "0x8ba1f109551bD432803012645Ac136ddd64DBA72\n" + + "\n" + + "Sign in to verify your wallet identity\n" + + "\n" + + "URI: https://x402scan.com/api/x402/registry/register-origin\n" + + "Version: 1\n" + + "Chain ID: 8453\n" + + "Nonce: deadbeefdeadbeefdeadbeefdeadbeef\n" + + "Issued At: 2026-07-03T12:00:00.000Z\n" + + "Expiration Time: 2026-07-03T12:05:00.000Z" + + if got := FormatSIWEMessage(info, addr); got != want { + t.Fatalf("SIWE message mismatch:\n--- got ---\n%s\n--- want ---\n%s", got, want) + } +} + +func TestFormatSIWEMessage_NoStatementFollowsABNF(t *testing.T) { + info := SIWXInfo{ + Domain: "x402scan.com", + URI: "https://x402scan.com/api", + Version: "1", + ChainID: "eip155:8453", + Nonce: "n0nce", + IssuedAt: "2026-07-03T12:00:00.000Z", + } + addr := common.HexToAddress("0x8ba1f109551bd432803012645ac136ddd64dba72") + got := FormatSIWEMessage(info, addr) + // EIP-4361 ABNF without a statement: address LF, blank line, blank line, fields. + if !strings.Contains(got, addr.Hex()+"\n\n\nURI: ") { + t.Fatalf("no-statement layout wrong:\n%s", got) + } +} diff --git a/internal/x402scan/siwe.go b/internal/x402scan/siwe.go new file mode 100644 index 00000000..78147d4d --- /dev/null +++ b/internal/x402scan/siwe.go @@ -0,0 +1,80 @@ +// Package x402scan registers this stack's public origin in the x402scan.com +// discovery index (https://x402scan.com/discovery/spec). Registration is a +// SIWX-authenticated POST: the registry answers an unauthenticated request +// with a 402 carrying a Sign-In-With-X (EIP-4361 / SIWE) challenge, the +// client signs it EIP-191 with the agent wallet, and retries with the signed +// payload base64-encoded in the SIGN-IN-WITH-X header. x402scan then crawls +// the origin's /openapi.json, probes each advertised endpoint for a real +// x402 402 challenge, and indexes the ones that pass. +package x402scan + +import ( + "fmt" + "strings" + + "github.com/ethereum/go-ethereum/common" +) + +// SIWXInfo is the challenge issued by the registry under +// extensions["sign-in-with-x"].info in the 402 body. The signed payload +// echoes these fields verbatim (plus address and signature) — the server +// re-renders the SIWE message from them to verify the signature, so they +// must round-trip unmodified. +type SIWXInfo struct { + Domain string `json:"domain"` + URI string `json:"uri"` + Version string `json:"version"` + ChainID string `json:"chainId"` // CAIP-2, e.g. "eip155:8453" + Type string `json:"type"` // signature scheme, "eip191" + Nonce string `json:"nonce"` + IssuedAt string `json:"issuedAt"` + ExpirationTime string `json:"expirationTime,omitempty"` + NotBefore string `json:"notBefore,omitempty"` + RequestID string `json:"requestId,omitempty"` + Statement string `json:"statement,omitempty"` + Resources []string `json:"resources,omitempty"` +} + +// FormatSIWEMessage renders the EIP-4361 message for a challenge and signer +// address. Layout follows the spec ABNF exactly — the server re-renders the +// same message from the payload fields, so any deviation (a missing blank +// line, a lowercase address) makes signature verification fail. +func FormatSIWEMessage(info SIWXInfo, addr common.Address) string { + var b strings.Builder + // addr.Hex() is EIP-55 checksummed, which SIWE requires. + fmt.Fprintf(&b, "%s wants you to sign in with your Ethereum account:\n%s\n\n", info.Domain, addr.Hex()) + if info.Statement != "" { + b.WriteString(info.Statement + "\n") + } + b.WriteString("\n") + fmt.Fprintf(&b, "URI: %s\n", info.URI) + fmt.Fprintf(&b, "Version: %s\n", info.Version) + fmt.Fprintf(&b, "Chain ID: %s\n", numericChainID(info.ChainID)) + fmt.Fprintf(&b, "Nonce: %s\n", info.Nonce) + fmt.Fprintf(&b, "Issued At: %s", info.IssuedAt) + if info.ExpirationTime != "" { + fmt.Fprintf(&b, "\nExpiration Time: %s", info.ExpirationTime) + } + if info.NotBefore != "" { + fmt.Fprintf(&b, "\nNot Before: %s", info.NotBefore) + } + if info.RequestID != "" { + fmt.Fprintf(&b, "\nRequest ID: %s", info.RequestID) + } + if len(info.Resources) > 0 { + b.WriteString("\nResources:") + for _, r := range info.Resources { + fmt.Fprintf(&b, "\n- %s", r) + } + } + return b.String() +} + +// numericChainID strips the CAIP-2 namespace ("eip155:8453" -> "8453") for +// the SIWE "Chain ID" line, which is numeric per EIP-4361. +func numericChainID(caip2 string) string { + if _, id, ok := strings.Cut(caip2, ":"); ok { + return id + } + return caip2 +} From 8db45e0776b0d2f4c947c42542891e103989f50f Mon Sep 17 00:00:00 2001 From: bussyjd Date: Wed, 8 Jul 2026 14:19:40 +0400 Subject: [PATCH 4/4] feat(x402scan): warn when registering a shared origin that serves many offers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit x402scan indexes per ORIGIN — it crawls the one /openapi.json we submit and lists everything under that origin. On the shared /services/ model that collapses every offer into one mixed listing (a data API beside a chat agent beside a dataset), which reads as a single blurry product to discovery crawlers. The preflight now counts distinct offers in the advertised doc and warns, pointing at the clean shape: one subdomain per offer. Non-fatal, like the rest of the preflight. Pure stdlib, ~8 lines + a table test. --- cmd/obol/sell_register_x402scan.go | 37 ++++++++++++++++++++++++- cmd/obol/sell_register_x402scan_test.go | 29 +++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/cmd/obol/sell_register_x402scan.go b/cmd/obol/sell_register_x402scan.go index f9845610..7a823056 100644 --- a/cmd/obol/sell_register_x402scan.go +++ b/cmd/obol/sell_register_x402scan.go @@ -202,7 +202,42 @@ func preflightOpenAPI(ctx context.Context, u *ui.UI, origin string) { var doc struct { Paths map[string]json.RawMessage `json:"paths"` } - if err := json.NewDecoder(resp.Body).Decode(&doc); err == nil && len(doc.Paths) == 0 { + if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { + return + } + if len(doc.Paths) == 0 { u.Warn("the published /openapi.json advertises no operations — put at least one offer on sale (obol sell inference|http|agent) before registering") + return + } + // x402scan indexes per ORIGIN — it crawls this one /openapi.json and + // lists everything it finds under the origin we submit. On the shared + // /services/ origin model, that means every offer collapses into + // one mixed listing (a data API next to a chat agent next to a dataset), + // which reads as a single blurry product to discovery crawlers. Count + // the distinct offers and warn: the clean shape is one origin per offer. + offers := map[string]struct{}{} + for p := range doc.Paths { + if name := servicesOfferName(p); name != "" { + offers[name] = struct{}{} + } + } + if len(offers) > 1 { + u.Warnf("this origin advertises %d offers in one /openapi.json — x402scan indexes per origin, so all of them will be listed together under %s rather than as distinct products. For clean discovery, give each offer its own subdomain (obol tunnel hostname add ) and register that origin.", len(offers), origin) + } +} + +// servicesOfferName extracts the offer name from a shared-origin OpenAPI path +// key like "/services/foo/v1/chat/completions" → "foo". Returns "" for paths +// that aren't under /services/ — a per-offer subdomain roots its paths +// elsewhere, so it correctly never trips the multi-offer warning. +func servicesOfferName(path string) string { + const prefix = "/services/" + if !strings.HasPrefix(path, prefix) { + return "" + } + rest := path[len(prefix):] + if i := strings.IndexByte(rest, '/'); i >= 0 { + return rest[:i] } + return rest } diff --git a/cmd/obol/sell_register_x402scan_test.go b/cmd/obol/sell_register_x402scan_test.go index a810346f..725e5734 100644 --- a/cmd/obol/sell_register_x402scan_test.go +++ b/cmd/obol/sell_register_x402scan_test.go @@ -34,3 +34,32 @@ func TestResolveX402scanOrigin_Explicit(t *testing.T) { } } } + +func TestServicesOfferName(t *testing.T) { + for _, tc := range []struct{ in, want string }{ + {"/services/foo/v1/chat/completions", "foo"}, + {"/services/bar", "bar"}, + {"/services/baz/", "baz"}, + {"/openapi.json", ""}, // per-offer subdomain root — no /services/ prefix + {"/audits/{id}", ""}, // app path on a dedicated origin + {"/", ""}, + {"/servicesfoo", ""}, // not the /services/ prefix + } { + if got := servicesOfferName(tc.in); got != tc.want { + t.Errorf("servicesOfferName(%q) = %q, want %q", tc.in, got, tc.want) + } + } + + // The leak signal: two distinct /services/ prefixes = a shared + // origin serving multiple offers (what the preflight warns about). + paths := []string{"/services/foo/v1", "/services/foo/v1/models", "/services/bar", "/openapi.json"} + offers := map[string]struct{}{} + for _, p := range paths { + if n := servicesOfferName(p); n != "" { + offers[n] = struct{}{} + } + } + if len(offers) != 2 { + t.Fatalf("distinct offers = %d, want 2 (foo, bar)", len(offers)) + } +}