fix(brand): per-host whitelabel + default-chain — covers *-test.network

- host matcher now accepts explorer.* (alias of explore.*) and
  {prefix}.{slug}-{env}.tld shapes — so explorer.zoo-test.network
  resolves to the Zoo brand (was falling through to Lux default).
- new chainSlugForHost helper drives a default-chain promotion in
  handleEnvs: when the host pins a chain, the SPA opens on that
  chain instead of the config-default cchain. explore.zoo.network
  now defaults to Zoo, not C-Chain.
- factored host matching into pure hostMatchesSlug predicate so
  brand and slug paths share one ground truth.
- tests cover both regressions (explorer.zoo-test.network → Zoo,
  explore.zoo.network → slug=zoo) plus the existing
  cross-contamination guards (zoofoo must not match zoo).
This commit is contained in:
2026-06-10 10:46:27 -07:00
parent c3afae17ba
commit 2cbbb4f333
2 changed files with 140 additions and 18 deletions
+81 -18
View File
@@ -164,7 +164,21 @@ func (f *Frontend) writeIndex(w http.ResponseWriter) {
// different brand + chain list.
func (f *Frontend) handleEnvs(w http.ResponseWriter, r *http.Request) {
hostBrand := f.brandForHost(r.Host)
hostSlug := f.chainSlugForHost(r.Host)
chains := f.chainListJSON()
// When the host pins a specific chain (e.g. explore.zoo.network →
// zoo, explorer.zoo-test.network → zoo), promote that chain to
// default and demote any other config-level default. The SPA's chain
// switcher honours the "default" flag.
if hostSlug != "" {
for i := range chains {
if chains[i]["slug"] == hostSlug {
chains[i]["default"] = true
} else {
chains[i]["default"] = false
}
}
}
env := map[string]any{
"VITE_CHAINS": chains,
@@ -230,37 +244,86 @@ func (f *Frontend) brandForHost(host string) map[string]any {
// brandStruct returns the Brand for a request host: a chain whose name or
// slug matches the hostname wins; otherwise the global default.
//
// Matches any of these hostname shapes, in order of specificity:
// See hostMatchesSlug for the list of supported hostname shapes.
func (f *Frontend) brandStruct(host string) Brand {
if c, ok := f.matchChain(host); ok && c.Brand != nil {
return *c.Brand
}
return f.cfg.BrandDefault
}
// chainSlugForHost returns the slug of the chain pinned by the request host,
// or "" if the host does not name a chain. Used by handleEnvs to promote a
// per-host chain to the SPA's default selection.
func (f *Frontend) chainSlugForHost(host string) string {
if c, ok := f.matchChain(host); ok {
return c.Slug
}
return ""
}
// matchChain finds the first configured chain whose slug appears in the
// request host. Matches these hostname shapes, in order of specificity:
// - "{slug}" — bare slug (curl localhost)
// - "{slug}.tld..." — {slug}.lux.network, {slug}.hanzo.ai
// - "explore-{slug}.tld..." — explore-hanzo.lux.network
// - "explorer-{slug}.tld..." — explorer-hanzo.lux.network
// - "api-explore-{slug}.tld..." — api-explore-hanzo.lux.network
// - "explore.{slug}.tld..." — explore.hanzo.ai, explore.zoo.ngo
// - "explorer.{slug}.tld..." — explorer.hanzo.ai, explorer.zoo.network
// - "api-explore.{slug}.tld..." — api-explore.hanzo.ai
// - "explore-{slug}-{env}.tld..." — explore-hanzo-test.lux.network
// - "explorer-{slug}-{env}.tld..." — explorer-hanzo-test.lux.network
// - "api-explore-{slug}-{env}.tld..." — api-explore-hanzo-dev.lux.network
// - "explore.{slug}-{env}.tld..." — explore.zoo-test.network
// - "explorer.{slug}-{env}.tld..." — explorer.zoo-test.network
// - "api-explore.{slug}-{env}.tld..." — api-explore.zoo-test.network
//
// Per-chain Brand wins over BrandDefault; first match wins on ambiguous
// hosts.
func (f *Frontend) brandStruct(host string) Brand {
// First chain match wins on ambiguous hosts.
func (f *Frontend) matchChain(host string) (ChainConfig, bool) {
host = strings.ToLower(strings.SplitN(host, ":", 2)[0])
for _, c := range f.cfg.Chains {
if c.Brand == nil {
continue
}
slug := c.Slug
if host == slug ||
strings.HasPrefix(host, slug+".") ||
strings.HasPrefix(host, "explore-"+slug+".") ||
strings.HasPrefix(host, "explore-"+slug+"-") ||
strings.HasPrefix(host, "api-explore-"+slug+".") ||
strings.HasPrefix(host, "api-explore-"+slug+"-") ||
strings.HasPrefix(host, "explore."+slug+".") ||
strings.HasPrefix(host, "api-explore."+slug+".") {
return *c.Brand
if hostMatchesSlug(host, c.Slug) {
return c, true
}
}
return f.cfg.BrandDefault
return ChainConfig{}, false
}
// hostMatchesSlug is the pure string predicate for chain↔host matching.
// Kept separate from matchChain so unit tests can cover it directly.
func hostMatchesSlug(host, slug string) bool {
if slug == "" {
return false
}
if host == slug {
return true
}
// Direct slug-as-subdomain: zoo.lux.network, hanzo.ai.
if strings.HasPrefix(host, slug+".") || strings.HasPrefix(host, slug+"-") {
return true
}
// Brand-owned domains: explore.zoo.network, explorer.zoo-test.network,
// api-explore.zoo.ngo, etc. Each "explore"/"explorer" prefix combines
// with "." or "-" as the slug separator.
prefixes := []string{
"explore-", "explorer-",
"api-explore-", "api-explorer-",
"explore.", "explorer.",
"api-explore.", "api-explorer.",
}
for _, p := range prefixes {
// {prefix}{slug}.tld — e.g. explore.zoo.network, explore-zoo.lux.network
if strings.HasPrefix(host, p+slug+".") {
return true
}
// {prefix}{slug}-{env}.tld — e.g. explore-zoo-test.lux.network,
// explorer.zoo-test.network
if strings.HasPrefix(host, p+slug+"-") {
return true
}
}
return false
}
// chainListJSON returns a SPA-friendly chain list from the live registry,
+59
View File
@@ -163,11 +163,23 @@ func TestBrandStructHostMatching(t *testing.T) {
{"explore.zoo.ngo", "Zoo"},
{"api-explore.hanzo.ai", "Hanzo"},
{"api-explore.zoo.ngo", "Zoo"},
// explorer.{slug}.tld shape (alias of explore.* for brand-owned domains).
{"explorer.zoo.network", "Zoo"},
{"explorer.hanzo.ai", "Hanzo"},
// {slug}-{env}.tld shape — brand-owned testnet/devnet domains.
// explorer.zoo-test.network is the primary case this fixes.
{"explorer.zoo-test.network", "Zoo"},
{"explore.zoo-test.network", "Zoo"},
{"explorer.hanzo-test.network", "Hanzo"},
{"api-explore.zoo-test.network", "Zoo"},
// Mismatched hosts fall back to BrandDefault.
{"explore.lux.network", "Lux Explorer"},
{"unknown.example.com", "Lux Explorer"},
// Cross-contamination guard: explore-spcfoo must NOT match spc.
{"explore-spcfoo.lux.network", "Lux Explorer"},
// Cross-contamination guard: zoofoo must NOT match zoo.
{"explore.zoofoo.network", "Lux Explorer"},
{"explorer.zoofoo.network", "Lux Explorer"},
}
for _, tc := range cases {
@@ -179,3 +191,50 @@ func TestBrandStructHostMatching(t *testing.T) {
})
}
}
// TestChainSlugForHost covers the host→default-chain promotion that
// handleEnvs uses to set the SPA's initial chain selection. Hosts that
// pin a chain (explore.zoo.network, explorer.zoo-test.network) must
// return the slug; generic hosts (explore.lux.network) must return "".
func TestChainSlugForHost(t *testing.T) {
zooBrand := Brand{Name: "Zoo"}
hanzoBrand := Brand{Name: "Hanzo"}
cfg := Config{
BrandDefault: Brand{Name: "Lux Explorer"},
Chains: []ChainConfig{
{Slug: "cchain", Brand: &Brand{Name: "Lux C-Chain"}},
{Slug: "hanzo", Brand: &hanzoBrand},
{Slug: "zoo", Brand: &zooBrand},
},
}
f, err := NewFrontend(cfg, NewChainRegistry())
if err != nil {
t.Fatal(err)
}
cases := []struct {
host string
want string
}{
{"explore.zoo.network", "zoo"},
{"explore.zoo.ngo", "zoo"},
{"explorer.zoo.network", "zoo"},
{"explorer.zoo-test.network", "zoo"},
{"explore.zoo-test.network", "zoo"},
{"explore-zoo.lux.network", "zoo"},
{"explore-zoo-test.lux.network", "zoo"},
{"explore.hanzo.ai", "hanzo"},
{"explorer.hanzo-test.network", "hanzo"},
// Generic / mismatched hosts: no slug.
{"explore.lux.network", ""},
{"unknown.example.com", ""},
{"explore.zoofoo.network", ""},
}
for _, tc := range cases {
t.Run(tc.host, func(t *testing.T) {
got := f.chainSlugForHost(tc.host)
if got != tc.want {
t.Fatalf("host=%q: got slug=%q, want %q", tc.host, got, tc.want)
}
})
}
}