feat: white-label multi-tenant bridge UI

- Add tenant config system (lib/tenant.ts) — hostname → branding + MPC mode
- Tenants: bridge.lux.network, bridge.pars.network, bridge.hanzo.ai, bridge.zoo.ngo
- Middleware passes x-tenant-hostname header for SSR + API routes
- /api/tenant endpoint resolves tenant config by hostname
- TenantProvider React context applies CSS vars (--brand-primary, --brand-accent)
- Layout reads tenant config server-side, injects CSS vars + favicon + metadata
- Header supports logoSrc prop for custom logo override per tenant
- next.config.js allows remote logo images from all tenant domains
- k8s ingress: added rules for bridge.pars.network, bridge.hanzo.ai, bridge.zoo.ngo
- MPC backend pluggable: lux-mpc (3-node cluster) or t-chain (ThresholdVM)
- Self-hosted deployments supported via BRIDGE_TENANT_CONFIG env var

Anyone can point a domain at the bridge + add config → instant white-label bridge.
All tenants share the same lux-mpc backend (no per-network SPOF).
This commit is contained in:
2026-03-01 16:35:30 -08:00
parent 605c43d321
commit 11d201da98
10 changed files with 389 additions and 30 deletions
+10 -1
View File
@@ -62,7 +62,16 @@ module.exports = (phase, { defaultConfig }) => {
{
protocol: "http",
hostname: "localhost",
}
},
// White-label tenant logo domains
{ protocol: "https", hostname: "lux.network" },
{ protocol: "https", hostname: "pars.network" },
{ protocol: "https", hostname: "hanzo.ai" },
{ protocol: "https", hostname: "zoo.ngo" },
{ protocol: "https", hostname: "*.lux.network" },
{ protocol: "https", hostname: "*.hanzo.ai" },
{ protocol: "https", hostname: "*.pars.network" },
{ protocol: "https", hostname: "*.zoo.ngo" },
],
},
compiler: {
+19
View File
@@ -0,0 +1,19 @@
import { NextRequest, NextResponse } from 'next/server'
import { resolveTenant } from '@/lib/tenant'
export const runtime = 'edge'
export async function GET(req: NextRequest) {
// Hostname from query param (client-side) or x-tenant header (set by middleware)
const hostname =
req.nextUrl.searchParams.get('hostname') ||
req.headers.get('x-tenant-hostname') ||
req.headers.get('host') ||
'bridge.lux.network'
const tenant = resolveTenant(hostname)
return NextResponse.json(tenant, {
headers: { 'Cache-Control': 'public, max-age=60, stale-while-revalidate=300' },
})
}
+35 -2
View File
@@ -18,6 +18,8 @@ import Main from '@/components/main'
import Header from '@/components/header'
import { ChevronDown } from 'lucide-react'
import getBridgeSettings from '@/util/getBridgeSettings'
import { resolveTenant } from '@/lib/tenant'
import { headers } from 'next/headers'
export const metadata = { ..._metadata }
@@ -29,10 +31,41 @@ const ConnectedWallets = dynamic(
const RootLayout: React.FC<PropsWithChildren> = async ({ children }) => {
const settings = await getBridgeSettings()
// Resolve tenant config server-side from incoming hostname
const headersList = headers()
const hostname =
headersList.get('x-tenant-hostname') ||
headersList.get('host') ||
'bridge.lux.network'
const tenant = resolveTenant(hostname)
// Build per-tenant siteDef override
const tenantSiteDef = {
...siteDef,
currentAs: `https://${tenant.hostname}`,
...(tenant.links?.home ? { home: tenant.links.home } : {}),
}
// Per-tenant metadata
const tenantMetadata = {
..._metadata,
title: tenant.name,
description: `${tenant.name} — cross-chain bridge`,
...(tenant.faviconUrl ? { icons: { icon: tenant.faviconUrl } } : {}),
}
return (
<html lang='en' suppressHydrationWarning className='dark' style={{backgroundColor: '#000'}}>
<head>
<base target='_blank' />
{/* Inject CSS vars for white-label theming */}
<style>{`
:root {
--brand-primary: ${tenant.primaryColor};
--brand-accent: ${tenant.accentColor || tenant.primaryColor};
}
`}</style>
{tenant.faviconUrl && <link rel='icon' href={tenant.faviconUrl} />}
</head>
<body
suppressHydrationWarning
@@ -40,7 +73,7 @@ const RootLayout: React.FC<PropsWithChildren> = async ({ children }) => {
>
{settings ? (
<Contexts settings={settings}>
<Header siteDef={siteDef} logoVariant='logo-only'>
<Header siteDef={tenantSiteDef} logoVariant='logo-only' logoSrc={tenant.logoUrl}>
<ConnectedWallets
connectButtonVariant='outline'
showWalletIcon={false}
@@ -62,7 +95,7 @@ const RootLayout: React.FC<PropsWithChildren> = async ({ children }) => {
</div>
</Main>
)}
<Footer siteDef={siteDef} />
<Footer siteDef={tenantSiteDef} />
</body>
</html>
)
+3
View File
@@ -12,6 +12,7 @@ import { SendErrorMessage } from '@/lib/telegram'
import { THEME_COLORS, type ThemeData } from '../Models/Theme'
import { AuthProvider } from '@/context/authContext'
import { SettingsProvider } from '@/context/settings'
import { TenantProvider } from '@/context/tenantContext'
import { FeeProvider } from '@/context/feeContext'
import { JotaiProvider } from '@/context/jotaiContext'
import { EthersProvider } from '@/context/ethersContext'
@@ -63,6 +64,7 @@ const Contexts: React.FC<
return (
<SWRConfig value={{ revalidateOnFocus: false }}>
<TenantProvider>
<IntercomProvider appId={INTERCOM_APP_ID} initializeDelay={2500}>
{themeData && <ColorSchema themeData={themeData} />}
<SettingsProvider settings={new BridgeAppSettings(_settings)}>
@@ -90,6 +92,7 @@ const Contexts: React.FC<
</AuthProvider>
</SettingsProvider>
</IntercomProvider>
</TenantProvider>
</SWRConfig>
)
}
+17 -10
View File
@@ -1,4 +1,5 @@
import React, { type PropsWithChildren } from 'react'
import Image from 'next/image'
import type { LinkDef } from '@hanzo/ui/types'
@@ -12,6 +13,8 @@ const DesktopHeader: React.FC<
className?: string
noAuth?: boolean
logoVariant?: LogoVariant
/** White-label: override logo with custom image URL */
logoSrc?: string
} & PropsWithChildren
> = ({
links,
@@ -19,11 +22,10 @@ const DesktopHeader: React.FC<
noAuth = false,
children,
logoVariant = 'text-only',
logoSrc,
}) => {
const [isMenuOpened, setIsMenuOpen] = React.useState(false)
// TODO move 13px into a size class and configure twMerge to recognize say, 'text-size-nav'
// (vs be beat out by 'text-color-nav')
return (
<header
id="DESKTOP_HEADER"
@@ -40,14 +42,19 @@ const DesktopHeader: React.FC<
'mx-3 md:mx-6 w-full max-w-screen'
}
>
<Logo
size={logoVariant === 'logo-only' ? 'sm' : 'sm'}
href="/"
outerClx="flex"
key="two"
variant={logoVariant}
/>
{/* md or larger */}
{logoSrc ? (
<a href="/" className="flex items-center">
<Image src={logoSrc} alt="Bridge Logo" height={32} width={120} style={{ objectFit: 'contain' }} />
</a>
) : (
<Logo
size={logoVariant === 'logo-only' ? 'sm' : 'sm'}
href="/"
outerClx="flex"
key="two"
variant={logoVariant}
/>
)}
<div className=""></div>
<div className="flex items-center">{children}</div>
</div>
+5 -5
View File
@@ -14,27 +14,27 @@ const Header: React.FC<{
siteDef: SiteDef
className?: string
logoVariant?: LogoVariant
/** White-label: custom logo image URL (overrides default Lux logo) */
logoSrc?: string
} & PropsWithChildren> = ({
siteDef,
className = '',
children,
logoVariant='text-only'
logoVariant='text-only',
logoSrc,
}) => {
// TODO
//const [open, setOpen] = React.useState<boolean>(false);
const { nav: { common, featured }, currentAs, noAuth } = siteDef
const links = (featured) ? [...common, ...featured] : common
return (<>
<DesktopHeader
// className={cn(className, 'hidden sm:flex')}
className='flex'
links={links}
currentAs={currentAs}
noAuth={noAuth}
logoVariant={logoVariant}
logoSrc={logoSrc}
>
{children}
</DesktopHeader>
+62
View File
@@ -0,0 +1,62 @@
'use client'
import React, { createContext, useContext, useEffect, useState } from 'react'
import type { ResolvedTenant } from '@/lib/tenant'
interface TenantContextValue {
tenant: ResolvedTenant | null
loading: boolean
}
const TenantContext = createContext<TenantContextValue>({
tenant: null,
loading: true,
})
export function TenantProvider({ children }: { children: React.ReactNode }) {
const [tenant, setTenant] = useState<ResolvedTenant | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
const hostname = window.location.hostname
fetch(`/api/tenant?hostname=${encodeURIComponent(hostname)}`)
.then((r) => r.json())
.then((data) => {
setTenant(data)
// Apply CSS custom properties for white-label theming
if (data.primaryColor) {
document.documentElement.style.setProperty('--brand-primary', data.primaryColor)
}
if (data.accentColor) {
document.documentElement.style.setProperty('--brand-accent', data.accentColor)
}
})
.catch(() => { /* use defaults */ })
.finally(() => setLoading(false))
}, [])
return (
<TenantContext.Provider value={{ tenant, loading }}>
{children}
</TenantContext.Provider>
)
}
export function useTenant() {
return useContext(TenantContext)
}
/** Server-side tenant resolution helper for layout.tsx */
export async function fetchTenantServer(hostname: string): Promise<ResolvedTenant | null> {
try {
const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'
const res = await fetch(
`${baseUrl}/api/tenant?hostname=${encodeURIComponent(hostname)}`,
{ next: { revalidate: 60 } }
)
if (!res.ok) return null
return res.json()
} catch {
return null
}
}
+180
View File
@@ -0,0 +1,180 @@
/**
* White-label multi-tenant bridge configuration.
*
* Tenant config is resolved by hostname at request time.
* MPC backend is pluggable: "lux-mpc" (3-node cluster) or "t-chain" (ThresholdVM).
* Anyone can self-host by pointing a domain at the bridge and providing config.
*/
export type MpcMode = 'lux-mpc' | 't-chain' | 'custom'
export interface TenantConfig {
/** Display name shown in UI */
name: string
/** Logo image URL */
logoUrl: string
/** Favicon URL */
faviconUrl?: string
/** Brand primary color (hex) */
primaryColor: string
/** Brand secondary/accent color (hex) */
accentColor?: string
/** Which MPC backend to use */
mpcMode: MpcMode
/** Override MPC endpoint (for "custom" mode or self-hosted) */
mpcEndpoint?: string
/** Network environment */
network: 'mainnet' | 'testnet'
/** Allowed source/destination chain IDs (undefined = all) */
allowedChains?: string[]
/** API server URL override */
apiUrl?: string
/** External links */
links?: {
home?: string
explorer?: string
docs?: string
}
}
export interface ResolvedTenant extends TenantConfig {
hostname: string
}
// Default tenant (lux bridge)
export const DEFAULT_TENANT: TenantConfig = {
name: 'Lux Bridge',
logoUrl: 'https://lux.network/images/logo.png',
primaryColor: '#0055ff',
accentColor: '#00ccff',
mpcMode: 'lux-mpc',
network: 'mainnet',
links: {
home: 'https://lux.network',
explorer: 'https://explore.lux.network',
docs: 'https://docs.lux.network',
},
}
// Static tenant registry — add hostname → config.
// For self-hosted deployments: set BRIDGE_TENANT_CONFIG env var with JSON.
export const TENANT_REGISTRY: Record<string, TenantConfig> = {
'bridge.lux.network': {
name: 'Lux Bridge',
logoUrl: 'https://lux.network/images/logo.png',
faviconUrl: 'https://lux.network/favicon.ico',
primaryColor: '#0055ff',
accentColor: '#00ccff',
mpcMode: 'lux-mpc',
network: 'mainnet',
links: {
home: 'https://lux.network',
explorer: 'https://explore.lux.network',
docs: 'https://docs.lux.network',
},
},
'bridge-test.lux.network': {
name: 'Lux Bridge (Testnet)',
logoUrl: 'https://lux.network/images/logo.png',
primaryColor: '#0055ff',
accentColor: '#00ccff',
mpcMode: 'lux-mpc',
network: 'testnet',
links: {
home: 'https://lux.network',
explorer: 'https://explore.lux-test.network',
},
},
'bridge.pars.network': {
name: 'Pars Bridge',
logoUrl: 'https://pars.network/logo.svg',
faviconUrl: 'https://pars.network/favicon.ico',
primaryColor: '#00cc88',
accentColor: '#00ffaa',
mpcMode: 'lux-mpc',
network: 'mainnet',
allowedChains: ['PARS', 'LUX', 'ETHEREUM', 'BSC'],
links: {
home: 'https://pars.network',
explorer: 'https://explore.pars.network',
},
},
'bridge.hanzo.ai': {
name: 'Hanzo Bridge',
logoUrl: 'https://hanzo.ai/logo.svg',
faviconUrl: 'https://hanzo.ai/favicon.ico',
primaryColor: '#fd4444',
accentColor: '#ff6666',
mpcMode: 'lux-mpc',
network: 'mainnet',
links: {
home: 'https://hanzo.ai',
explorer: 'https://explore.hanzo.ai',
docs: 'https://docs.hanzo.ai',
},
},
'bridge.zoo.ngo': {
name: 'Zoo Bridge',
logoUrl: 'https://zoo.ngo/logo.svg',
faviconUrl: 'https://zoo.ngo/favicon.ico',
primaryColor: '#22c55e',
accentColor: '#4ade80',
mpcMode: 'lux-mpc',
network: 'mainnet',
links: {
home: 'https://zoo.ngo',
explorer: 'https://explore.zoo.ngo',
},
},
}
/**
* Resolve tenant config from hostname.
* Falls back to env-configured tenants, then DEFAULT_TENANT.
*/
export function resolveTenant(hostname: string): ResolvedTenant {
// Strip port if present (e.g. localhost:3000)
const host = hostname.split(':')[0]
// 1. Check static registry
const config = TENANT_REGISTRY[host]
if (config) {
return { ...config, hostname: host }
}
// 2. Check env var override (for self-hosted deployments)
const envConfig = process.env.BRIDGE_TENANT_CONFIG
if (envConfig) {
try {
const parsed = JSON.parse(envConfig) as Record<string, TenantConfig>
if (parsed[host]) {
return { ...parsed[host], hostname: host }
}
} catch {
// ignore
}
}
// 3. Default
return { ...DEFAULT_TENANT, hostname: host }
}
/**
* MPC endpoint resolution.
* lux-mpc → cluster endpoint (set via env or default)
* t-chain → ThresholdVM RPC on T-chain
* custom → tenant-specified endpoint
*/
export function getMpcEndpoint(tenant: TenantConfig): string {
if (tenant.mpcEndpoint) return tenant.mpcEndpoint
switch (tenant.mpcMode) {
case 't-chain':
return (
process.env.TCHAIN_MPC_ENDPOINT || 'https://api.lux.network/ext/bc/T'
)
case 'lux-mpc':
default:
return process.env.MPC_ENDPOINT || 'http://mpc-node-0:6000'
}
}
+10 -7
View File
@@ -1,20 +1,23 @@
import { NextRequest, NextResponse } from "next/server";
// Add cors
export async function middleware(req: NextRequest) {
const origin = req.headers.get("origin") || "";
const hostname = req.headers.get("host") || "bridge.lux.network";
const response = NextResponse.next();
// CORS
response.headers.set("Access-Control-Allow-Origin", origin);
response.headers.set(
"Access-Control-Allow-Methods",
"GET,HEAD,POST,PUT,DELETE"
);
response.headers.set("Access-Control-Allow-Methods", "GET,HEAD,POST,PUT,DELETE");
response.headers.set("Access-Control-Allow-Headers", "Content-Type");
response.headers.set("Access-Control-Allow-Credentials", "true");
// Propagate tenant hostname for SSR + API routes
response.headers.set("x-tenant-hostname", hostname);
return response;
}
// Apply middleware to all API routes
export const config = {
matcher: "/api/:path*",
matcher: ["/api/:path*", "/((?!_next/static|_next/image|favicon.ico).*)"],
};
+48 -5
View File
@@ -155,9 +155,9 @@ data:
backup_dir: /data/mpc/backups
peers.json: |
{
"node0": "0ce02715-0ead-48ef-9772-2583316cc860",
"node1": "c95c340e-5a18-472d-b9b0-5ac68218213a",
"node2": "ac37e85f-caca-4bee-8a3a-49a0fe35abff"
"node0": "lux-mainnet-node0",
"node1": "lux-mainnet-node1",
"node2": "lux-mainnet-node2"
}
node0_identity.json: |
{"node_name":"node0","node_id":"0ce02715-0ead-48ef-9772-2583316cc860","public_key":"96e47112bab4cb83e59a0a222964fad096f02a9dc52e449097f8991470a141ab"}
@@ -522,8 +522,18 @@ spec:
- hosts:
- bridge.lux.network
- bridge-api.lux.network
secretName: bridge-tls
secretName: bridge-lux-tls
- hosts:
- bridge.pars.network
secretName: bridge-pars-tls
- hosts:
- bridge.hanzo.ai
secretName: bridge-hanzo-tls
- hosts:
- bridge.zoo.ngo
secretName: bridge-zoo-tls
rules:
# Lux Network (primary)
- host: bridge.lux.network
http:
paths:
@@ -543,4 +553,37 @@ spec:
service:
name: bridge-server
port:
number: 3000
number: 3000
# Pars Network — white-label, same bridge-ui backend
- host: bridge.pars.network
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: bridge-ui
port:
number: 80
# Hanzo — white-label
- host: bridge.hanzo.ai
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: bridge-ui
port:
number: 80
# Zoo — white-label
- host: bridge.zoo.ngo
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: bridge-ui
port:
number: 80