bridge: wire tenant brand to the SPA at runtime (white-label)

The app/bridge SPA baked @luxfi/brand at build time and the Go server served
brand at /envs.js while the SPA loads /__ENV.js — so a white-label tenant
(e.g. zooai/bridge-shim) rendered the Lux brand. Complete the stated
'jurisdiction-neutral, tenant supplies brand' design:

- bridge.config.ts: brand name/favicon/primaryColor/supportEmail/docsUrl are now
  window.__ENV-overridable (logoUrl already was); @luxfi/brand stays the
  fallback so the Lux tenant is byte-unchanged when no override is set.
- frontend.go: serve /__ENV.js (the endpoint the SPA actually loads) projecting
  the tenant brand+IAM into the BRIDGE_* keys; applyBrandMetadata() then themes
  title/favicon/logo/color at mount. Empty when no tenant → Lux fallback.
- main.go: pass the tenant config into NewFrontend.
This commit is contained in:
2026-07-08 06:53:45 -07:00
parent e75c9a9a3c
commit f4e17d69cf
3 changed files with 59 additions and 11 deletions
+9 -4
View File
@@ -170,17 +170,22 @@ export const bridgeConfig: BridgeConfig = {
clientId: env('BRIDGE_CLIENT_ID'),
iamOrg: env('BRIDGE_IAM_ORG', 'lux'),
brand: {
name: `${shortName ?? 'Lux'} Bridge`,
// Every brand field is runtime-overridable via window.__ENV so a single
// build serves any tenant (the serving container — or the Go single-binary
// image's /__ENV.js — templates these from its tenant config). @luxfi/brand
// stays the fallback, so the Lux tenant is unchanged when no override is set.
name: env('BRIDGE_BRAND_NAME') || `${shortName ?? 'Lux'} Bridge`,
logoUrl: env('BRIDGE_LOGO_URL') || luxLogoDataUrl,
primaryColor: primaryColor ?? '#000000',
faviconUrl: env('BRIDGE_FAVICON_URL'),
primaryColor: env('BRIDGE_PRIMARY_COLOR') || primaryColor || '#000000',
// secondaryColor intentionally omitted. brand.json's theme.{light,dark}.accent1
// is a per-theme foreground (white on dark / black on light), NOT a brand
// accent — wiring it to --brand-secondary breaks hover-color on the
// opposite-theme surface. Fall back to the SDK's compiled default
// (`var(--brand-secondary, #7a9ff5)` in pkg/bridge/src/app/styles/theme.css)
// until @luxfi/brand exposes a real brand-accent field.
supportEmail: supportEmail ?? '[email protected]',
docsUrl: fallbackDocsUrl,
supportEmail: env('BRIDGE_SUPPORT_EMAIL') || supportEmail || '[email protected]',
docsUrl: env('BRIDGE_DOCS_URL') || fallbackDocsUrl,
},
wallet,
mpc,
+49 -6
View File
@@ -11,6 +11,8 @@ import (
"path/filepath"
"strings"
"time"
"github.com/luxfi/bridge/pkg/tenant"
)
// staticFS is the embedded SPA. The Dockerfile populates static/ from a
@@ -24,13 +26,14 @@ var staticFS embed.FS
// brand assets (/icon.svg, /logo.svg). Brand assets read from disk on every
// request so a deploy can swap them without rebuilding the binary.
type Frontend struct {
cfg Config
root fs.FS
index []byte
overlay string // optional disk dir for SPA + brand override
cfg Config
root fs.FS
index []byte
overlay string // optional disk dir for SPA + brand override
runtimeEnv map[string]string // window.__ENV served at /__ENV.js (tenant brand)
}
func NewFrontend(cfg Config, overlay string) (*Frontend, error) {
func NewFrontend(cfg Config, overlay string, tcfg *tenant.Config) (*Frontend, error) {
root, err := fs.Sub(staticFS, "static")
if err != nil {
return nil, fmt.Errorf("sub-fs: %w", err)
@@ -39,11 +42,40 @@ func NewFrontend(cfg Config, overlay string) (*Frontend, error) {
if err != nil {
return nil, fmt.Errorf("read index.html: %w", err)
}
return &Frontend{cfg: cfg, root: root, index: idx, overlay: overlay}, nil
return &Frontend{cfg: cfg, root: root, index: idx, overlay: overlay, runtimeEnv: buildRuntimeEnv(tcfg)}, nil
}
// buildRuntimeEnv projects the tenant brand + IAM config into the window.__ENV
// keys the SPA's bridge.config.ts reads (BRIDGE_*). When there is no tenant
// (the canonical Lux deployment), it returns an empty map so the SPA falls
// back to its build-time @luxfi/brand defaults — Lux is unchanged. White-label
// shims ship a tenant.yaml and get their brand applied at runtime, one image.
func buildRuntimeEnv(tcfg *tenant.Config) map[string]string {
e := map[string]string{}
if tcfg == nil {
return e
}
set := func(k, v string) {
if v != "" {
e[k] = v
}
}
b := tcfg.Brand
set("BRIDGE_BRAND_NAME", b.Name)
set("BRIDGE_LOGO_URL", b.LogoURL)
set("BRIDGE_FAVICON_URL", b.FaviconURL)
set("BRIDGE_PRIMARY_COLOR", b.PrimaryColor)
set("BRIDGE_DOCS_URL", b.DocsURL)
set("BRIDGE_IAM_ORG", tcfg.IAM.Organization)
set("BRIDGE_CLIENT_ID", tcfg.IAM.ClientID)
return e
}
func (f *Frontend) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/__ENV.js":
f.serveRuntimeEnv(w, r)
return
case "/envs.js":
f.serveEnvs(w, r)
return
@@ -78,6 +110,17 @@ func (f *Frontend) ServeHTTP(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(f.index)
}
// serveRuntimeEnv emits window.__ENV — the runtime brand/config the embedded
// SPA loads via <script src="/__ENV.js"> before its main bundle. This is the
// tenant→frontend brand bridge: bridge.config.ts reads these BRIDGE_* keys and
// applyBrandMetadata() applies name/favicon/logo/color to the document.
func (f *Frontend) serveRuntimeEnv(w http.ResponseWriter, r *http.Request) {
body, _ := json.Marshal(f.runtimeEnv)
w.Header().Set("Content-Type", "application/javascript")
w.Header().Set("Cache-Control", "no-store")
fmt.Fprintf(w, "window.__ENV = %s;", body)
}
func (f *Frontend) serveEnvs(w http.ResponseWriter, r *http.Request) {
envs := map[string]any{
"brand": f.cfg.Brand,
+1 -1
View File
@@ -257,7 +257,7 @@ func main() {
os.Exit(1)
}
frontend, err := NewFrontend(cfg, *staticDir)
frontend, err := NewFrontend(cfg, *staticDir, tcfg)
if err != nil {
logger.Error("frontend init", "err", err)
os.Exit(1)