From 3afbe9f9308cab67877c861c7303c6534008dd57 Mon Sep 17 00:00:00 2001 From: Bohan Date: Thu, 28 May 2026 12:29:06 +0800 Subject: [PATCH 1/3] feat(core): support custom brands via extraBrands in config.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add brandRegistry map and RegisterBrand/MergeEndpointOverrides to types.go. LoadMultiAppConfig registers extraBrands entries on load. ResolveEndpoints signature unchanged — all callers zero-modified. --- internal/core/config.go | 12 ++++--- internal/core/types.go | 69 +++++++++++++++++++++++++++++-------- internal/core/types_test.go | 62 +++++++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 19 deletions(-) diff --git a/internal/core/config.go b/internal/core/config.go index 9c566ce50..ff5bfd97d 100644 --- a/internal/core/config.go +++ b/internal/core/config.go @@ -58,10 +58,11 @@ func (a *AppConfig) ProfileName() string { // MultiAppConfig is the multi-app config file format. type MultiAppConfig struct { - StrictMode StrictMode `json:"strictMode,omitempty"` - CurrentApp string `json:"currentApp,omitempty"` - PreviousApp string `json:"previousApp,omitempty"` - Apps []AppConfig `json:"apps"` + StrictMode StrictMode `json:"strictMode,omitempty"` + CurrentApp string `json:"currentApp,omitempty"` + PreviousApp string `json:"previousApp,omitempty"` + ExtraBrands map[string]*Endpoints `json:"extraBrands,omitempty"` + Apps []AppConfig `json:"apps"` } // CurrentAppConfig returns the currently active app config. @@ -199,6 +200,9 @@ func LoadMultiAppConfig() (*MultiAppConfig, error) { if len(multi.Apps) == 0 { return nil, fmt.Errorf("invalid config format: no apps") } + for name, ep := range multi.ExtraBrands { + RegisterBrand(name, *ep) + } return &multi, nil } diff --git a/internal/core/types.go b/internal/core/types.go index cf842e6a4..1a44f7ed4 100644 --- a/internal/core/types.go +++ b/internal/core/types.go @@ -30,24 +30,63 @@ type Endpoints struct { AppLink string // e.g. "https://applink.feishu.cn" } +// brandRegistry maps brand names to their endpoint defaults. +// Built-in brands (feishu, lark) are pre-registered via init. +// Custom brands can be added via RegisterBrand. +var brandRegistry = map[string]Endpoints{} + +func init() { + brandRegistry[string(BrandFeishu)] = Endpoints{ + Open: "https://open.feishu.cn", + Accounts: "https://accounts.feishu.cn", + MCP: "https://mcp.feishu.cn", + AppLink: "https://applink.feishu.cn", + } + brandRegistry[string(BrandLark)] = Endpoints{ + Open: "https://open.larksuite.com", + Accounts: "https://accounts.larksuite.com", + MCP: "https://mcp.larksuite.com", + AppLink: "https://applink.larksuite.com", + } +} + +// RegisterBrand adds or updates a brand's endpoint defaults. +// Partial overrides are merged on top of the "feishu" defaults. +func RegisterBrand(name string, ep Endpoints) { + base := brandRegistry[string(BrandFeishu)] + if name == string(BrandLark) { + base = brandRegistry[string(BrandLark)] + } + brandRegistry[name] = MergeEndpointOverrides(base, &ep) +} + +// MergeEndpointOverrides returns a copy of base with non-empty overrides applied. +func MergeEndpointOverrides(base Endpoints, overrides *Endpoints) Endpoints { + if overrides == nil { + return base + } + result := base + if overrides.Open != "" { + result.Open = overrides.Open + } + if overrides.Accounts != "" { + result.Accounts = overrides.Accounts + } + if overrides.MCP != "" { + result.MCP = overrides.MCP + } + if overrides.AppLink != "" { + result.AppLink = overrides.AppLink + } + return result +} + // ResolveEndpoints resolves endpoint URLs based on brand. func ResolveEndpoints(brand LarkBrand) Endpoints { - switch brand { - case BrandLark: - return Endpoints{ - Open: "https://open.larksuite.com", - Accounts: "https://accounts.larksuite.com", - MCP: "https://mcp.larksuite.com", - AppLink: "https://applink.larksuite.com", - } - default: - return Endpoints{ - Open: "https://open.feishu.cn", - Accounts: "https://accounts.feishu.cn", - MCP: "https://mcp.feishu.cn", - AppLink: "https://applink.feishu.cn", - } + if ep, ok := brandRegistry[string(brand)]; ok { + return ep } + return brandRegistry[string(BrandFeishu)] } // ResolveOpenBaseURL returns the Open API base URL for the given brand. diff --git a/internal/core/types_test.go b/internal/core/types_test.go index 72f331700..7ee55ff57 100644 --- a/internal/core/types_test.go +++ b/internal/core/types_test.go @@ -52,3 +52,65 @@ func TestResolveOpenBaseURL(t *testing.T) { t.Errorf("ResolveOpenBaseURL(lark) = %q", got) } } + +func TestMergeEndpointOverrides(t *testing.T) { + base := Endpoints{ + Open: "https://open.feishu.cn", + Accounts: "https://accounts.feishu.cn", + MCP: "https://mcp.feishu.cn", + AppLink: "https://applink.feishu.cn", + } + + t.Run("nil overrides returns base", func(t *testing.T) { + got := MergeEndpointOverrides(base, nil) + if got != base { + t.Errorf("got %v, want %v", got, base) + } + }) + + t.Run("partial override", func(t *testing.T) { + got := MergeEndpointOverrides(base, &Endpoints{Open: "https://proxy.example.com"}) + if got.Open != "https://proxy.example.com" { + t.Errorf("Open = %q", got.Open) + } + if got.Accounts != "https://accounts.feishu.cn" { + t.Errorf("Accounts = %q, want unchanged", got.Accounts) + } + }) + + t.Run("full override", func(t *testing.T) { + overrides := Endpoints{ + Open: "https://a.example.com", Accounts: "https://b.example.com", + MCP: "https://c.example.com", AppLink: "https://d.example.com", + } + got := MergeEndpointOverrides(base, &overrides) + if got != overrides { + t.Errorf("got %v, want %v", got, overrides) + } + }) +} + +func TestRegisterBrand(t *testing.T) { + RegisterBrand("staging", Endpoints{Open: "https://open-staging.feishu.cn"}) + ep := ResolveEndpoints("staging") + if ep.Open != "https://open-staging.feishu.cn" { + t.Errorf("Open = %q, want staging URL", ep.Open) + } + if ep.Accounts != "https://accounts.feishu.cn" { + t.Errorf("Accounts = %q, want feishu default", ep.Accounts) + } +} + +func TestRegisterBrand_Full(t *testing.T) { + RegisterBrand("proxy", Endpoints{ + Open: "https://api-proxy.example.com", Accounts: "https://acct-proxy.example.com", + MCP: "https://mcp-proxy.example.com", AppLink: "https://applink-proxy.example.com", + }) + ep := ResolveEndpoints("proxy") + if ep.Open != "https://api-proxy.example.com" { + t.Errorf("Open = %q", ep.Open) + } + if ep.Accounts != "https://acct-proxy.example.com" { + t.Errorf("Accounts = %q", ep.Accounts) + } +} From 214d3652624dca09a90b0f1e596f639abe82f779 Mon Sep 17 00:00:00 2001 From: Bohan Date: Thu, 28 May 2026 12:29:06 +0800 Subject: [PATCH 2/3] feat(core): support custom brands via extraBrands in config.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add brandRegistry map and RegisterBrand/MergeEndpointOverrides to types.go. LoadMultiAppConfig registers extraBrands entries on load. ResolveEndpoints signature unchanged — all callers zero-modified. --- internal/core/config.go | 12 ++++-- internal/core/types.go | 77 ++++++++++++++++++++++++++++--------- internal/core/types_test.go | 62 +++++++++++++++++++++++++++++ 3 files changed, 128 insertions(+), 23 deletions(-) diff --git a/internal/core/config.go b/internal/core/config.go index 9c566ce50..ff5bfd97d 100644 --- a/internal/core/config.go +++ b/internal/core/config.go @@ -58,10 +58,11 @@ func (a *AppConfig) ProfileName() string { // MultiAppConfig is the multi-app config file format. type MultiAppConfig struct { - StrictMode StrictMode `json:"strictMode,omitempty"` - CurrentApp string `json:"currentApp,omitempty"` - PreviousApp string `json:"previousApp,omitempty"` - Apps []AppConfig `json:"apps"` + StrictMode StrictMode `json:"strictMode,omitempty"` + CurrentApp string `json:"currentApp,omitempty"` + PreviousApp string `json:"previousApp,omitempty"` + ExtraBrands map[string]*Endpoints `json:"extraBrands,omitempty"` + Apps []AppConfig `json:"apps"` } // CurrentAppConfig returns the currently active app config. @@ -199,6 +200,9 @@ func LoadMultiAppConfig() (*MultiAppConfig, error) { if len(multi.Apps) == 0 { return nil, fmt.Errorf("invalid config format: no apps") } + for name, ep := range multi.ExtraBrands { + RegisterBrand(name, *ep) + } return &multi, nil } diff --git a/internal/core/types.go b/internal/core/types.go index cf842e6a4..b420045b1 100644 --- a/internal/core/types.go +++ b/internal/core/types.go @@ -24,30 +24,69 @@ func ParseBrand(value string) LarkBrand { // Endpoints holds resolved endpoint URLs for different Lark services. type Endpoints struct { - Open string // e.g. "https://open.feishu.cn" - Accounts string // e.g. "https://accounts.feishu.cn" - MCP string // e.g. "https://mcp.feishu.cn" - AppLink string // e.g. "https://applink.feishu.cn" + Open string `json:"open"` // e.g. "https://open.feishu.cn" + Accounts string `json:"accounts"` // e.g. "https://accounts.feishu.cn" + MCP string `json:"mcp"` // e.g. "https://mcp.feishu.cn" + AppLink string `json:"applink"` // e.g. "https://applink.feishu.cn" +} + +// brandRegistry maps brand names to their endpoint defaults. +// Built-in brands (feishu, lark) are pre-registered via init. +// Custom brands can be added via RegisterBrand. +var brandRegistry = map[string]Endpoints{} + +func init() { + brandRegistry[string(BrandFeishu)] = Endpoints{ + Open: "https://open.feishu.cn", + Accounts: "https://accounts.feishu.cn", + MCP: "https://mcp.feishu.cn", + AppLink: "https://applink.feishu.cn", + } + brandRegistry[string(BrandLark)] = Endpoints{ + Open: "https://open.larksuite.com", + Accounts: "https://accounts.larksuite.com", + MCP: "https://mcp.larksuite.com", + AppLink: "https://applink.larksuite.com", + } +} + +// RegisterBrand adds or updates a brand's endpoint defaults. +// Partial overrides are merged on top of the "feishu" defaults. +func RegisterBrand(name string, ep Endpoints) { + base := brandRegistry[string(BrandFeishu)] + if name == string(BrandLark) { + base = brandRegistry[string(BrandLark)] + } + brandRegistry[name] = MergeEndpointOverrides(base, &ep) +} + +// MergeEndpointOverrides returns a copy of base with non-empty overrides applied. +func MergeEndpointOverrides(base Endpoints, overrides *Endpoints) Endpoints { + if overrides == nil { + return base + } + result := base + if overrides.Open != "" { + result.Open = overrides.Open + } + if overrides.Accounts != "" { + result.Accounts = overrides.Accounts + } + if overrides.MCP != "" { + result.MCP = overrides.MCP + } + if overrides.AppLink != "" { + result.AppLink = overrides.AppLink + } + return result } // ResolveEndpoints resolves endpoint URLs based on brand. func ResolveEndpoints(brand LarkBrand) Endpoints { - switch brand { - case BrandLark: - return Endpoints{ - Open: "https://open.larksuite.com", - Accounts: "https://accounts.larksuite.com", - MCP: "https://mcp.larksuite.com", - AppLink: "https://applink.larksuite.com", - } - default: - return Endpoints{ - Open: "https://open.feishu.cn", - Accounts: "https://accounts.feishu.cn", - MCP: "https://mcp.feishu.cn", - AppLink: "https://applink.feishu.cn", - } + if ep, ok := brandRegistry[string(brand)]; ok { + return ep } + return brandRegistry[string(BrandFeishu)] } // ResolveOpenBaseURL returns the Open API base URL for the given brand. diff --git a/internal/core/types_test.go b/internal/core/types_test.go index 72f331700..7ee55ff57 100644 --- a/internal/core/types_test.go +++ b/internal/core/types_test.go @@ -52,3 +52,65 @@ func TestResolveOpenBaseURL(t *testing.T) { t.Errorf("ResolveOpenBaseURL(lark) = %q", got) } } + +func TestMergeEndpointOverrides(t *testing.T) { + base := Endpoints{ + Open: "https://open.feishu.cn", + Accounts: "https://accounts.feishu.cn", + MCP: "https://mcp.feishu.cn", + AppLink: "https://applink.feishu.cn", + } + + t.Run("nil overrides returns base", func(t *testing.T) { + got := MergeEndpointOverrides(base, nil) + if got != base { + t.Errorf("got %v, want %v", got, base) + } + }) + + t.Run("partial override", func(t *testing.T) { + got := MergeEndpointOverrides(base, &Endpoints{Open: "https://proxy.example.com"}) + if got.Open != "https://proxy.example.com" { + t.Errorf("Open = %q", got.Open) + } + if got.Accounts != "https://accounts.feishu.cn" { + t.Errorf("Accounts = %q, want unchanged", got.Accounts) + } + }) + + t.Run("full override", func(t *testing.T) { + overrides := Endpoints{ + Open: "https://a.example.com", Accounts: "https://b.example.com", + MCP: "https://c.example.com", AppLink: "https://d.example.com", + } + got := MergeEndpointOverrides(base, &overrides) + if got != overrides { + t.Errorf("got %v, want %v", got, overrides) + } + }) +} + +func TestRegisterBrand(t *testing.T) { + RegisterBrand("staging", Endpoints{Open: "https://open-staging.feishu.cn"}) + ep := ResolveEndpoints("staging") + if ep.Open != "https://open-staging.feishu.cn" { + t.Errorf("Open = %q, want staging URL", ep.Open) + } + if ep.Accounts != "https://accounts.feishu.cn" { + t.Errorf("Accounts = %q, want feishu default", ep.Accounts) + } +} + +func TestRegisterBrand_Full(t *testing.T) { + RegisterBrand("proxy", Endpoints{ + Open: "https://api-proxy.example.com", Accounts: "https://acct-proxy.example.com", + MCP: "https://mcp-proxy.example.com", AppLink: "https://applink-proxy.example.com", + }) + ep := ResolveEndpoints("proxy") + if ep.Open != "https://api-proxy.example.com" { + t.Errorf("Open = %q", ep.Open) + } + if ep.Accounts != "https://acct-proxy.example.com" { + t.Errorf("Accounts = %q", ep.Accounts) + } +} From 7ed692b7d5e88a90201d866e4b007f9be9f07bf3 Mon Sep 17 00:00:00 2001 From: Bohan Date: Thu, 28 May 2026 14:46:42 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(core):=20harden=20extraBrands=20?= =?UTF-8?q?=E2=80=94=20nil=20guard,=20built-in=20brand=20protection,=20ded?= =?UTF-8?q?up?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/core/config.go | 4 ++- internal/core/types.go | 58 +++---------------------------------- internal/core/types_test.go | 18 ++++++++++++ 3 files changed, 25 insertions(+), 55 deletions(-) diff --git a/internal/core/config.go b/internal/core/config.go index ff5bfd97d..58be84b35 100644 --- a/internal/core/config.go +++ b/internal/core/config.go @@ -201,7 +201,9 @@ func LoadMultiAppConfig() (*MultiAppConfig, error) { return nil, fmt.Errorf("invalid config format: no apps") } for name, ep := range multi.ExtraBrands { - RegisterBrand(name, *ep) + if ep != nil { + RegisterBrand(name, *ep) + } } return &multi, nil } diff --git a/internal/core/types.go b/internal/core/types.go index 7e87aa715..6a7c83977 100644 --- a/internal/core/types.go +++ b/internal/core/types.go @@ -50,64 +50,14 @@ func init() { } } -// RegisterBrand adds or updates a brand's endpoint defaults. +// RegisterBrand adds or updates a custom brand's endpoint defaults. // Partial overrides are merged on top of the "feishu" defaults. +// Built-in brands (feishu, lark) and empty names are silently ignored. func RegisterBrand(name string, ep Endpoints) { - base := brandRegistry[string(BrandFeishu)] - if name == string(BrandLark) { - base = brandRegistry[string(BrandLark)] - } - brandRegistry[name] = MergeEndpointOverrides(base, &ep) -} - -// MergeEndpointOverrides returns a copy of base with non-empty overrides applied. -func MergeEndpointOverrides(base Endpoints, overrides *Endpoints) Endpoints { - if overrides == nil { - return base - } - result := base - if overrides.Open != "" { - result.Open = overrides.Open - } - if overrides.Accounts != "" { - result.Accounts = overrides.Accounts - } - if overrides.MCP != "" { - result.MCP = overrides.MCP - } - if overrides.AppLink != "" { - result.AppLink = overrides.AppLink - } - return result -} - -// brandRegistry maps brand names to their endpoint defaults. -// Built-in brands (feishu, lark) are pre-registered via init. -// Custom brands can be added via RegisterBrand. -var brandRegistry = map[string]Endpoints{} - -func init() { - brandRegistry[string(BrandFeishu)] = Endpoints{ - Open: "https://open.feishu.cn", - Accounts: "https://accounts.feishu.cn", - MCP: "https://mcp.feishu.cn", - AppLink: "https://applink.feishu.cn", - } - brandRegistry[string(BrandLark)] = Endpoints{ - Open: "https://open.larksuite.com", - Accounts: "https://accounts.larksuite.com", - MCP: "https://mcp.larksuite.com", - AppLink: "https://applink.larksuite.com", + if name == "" || name == string(BrandFeishu) || name == string(BrandLark) { + return } -} - -// RegisterBrand adds or updates a brand's endpoint defaults. -// Partial overrides are merged on top of the "feishu" defaults. -func RegisterBrand(name string, ep Endpoints) { base := brandRegistry[string(BrandFeishu)] - if name == string(BrandLark) { - base = brandRegistry[string(BrandLark)] - } brandRegistry[name] = MergeEndpointOverrides(base, &ep) } diff --git a/internal/core/types_test.go b/internal/core/types_test.go index 7ee55ff57..633711a81 100644 --- a/internal/core/types_test.go +++ b/internal/core/types_test.go @@ -92,6 +92,8 @@ func TestMergeEndpointOverrides(t *testing.T) { func TestRegisterBrand(t *testing.T) { RegisterBrand("staging", Endpoints{Open: "https://open-staging.feishu.cn"}) + defer delete(brandRegistry, "staging") + ep := ResolveEndpoints("staging") if ep.Open != "https://open-staging.feishu.cn" { t.Errorf("Open = %q, want staging URL", ep.Open) @@ -106,6 +108,8 @@ func TestRegisterBrand_Full(t *testing.T) { Open: "https://api-proxy.example.com", Accounts: "https://acct-proxy.example.com", MCP: "https://mcp-proxy.example.com", AppLink: "https://applink-proxy.example.com", }) + defer delete(brandRegistry, "proxy") + ep := ResolveEndpoints("proxy") if ep.Open != "https://api-proxy.example.com" { t.Errorf("Open = %q", ep.Open) @@ -114,3 +118,17 @@ func TestRegisterBrand_Full(t *testing.T) { t.Errorf("Accounts = %q", ep.Accounts) } } + +func TestRegisterBrand_IgnoresBuiltIn(t *testing.T) { + original := brandRegistry[string(BrandFeishu)] + RegisterBrand("feishu", Endpoints{Open: "https://malicious.example.com"}) + RegisterBrand("lark", Endpoints{Open: "https://malicious.example.com"}) + RegisterBrand("", Endpoints{Open: "https://malicious.example.com"}) + + if brandRegistry[string(BrandFeishu)] != original { + t.Error("RegisterBrand should not overwrite built-in feishu brand") + } + if brandRegistry[string(BrandLark)].Open == "https://malicious.example.com" { + t.Error("RegisterBrand should not overwrite built-in lark brand") + } +}