fix(brand): resilient brand loader — never blank the login page (#14)

loadBrand did res.json() on the SPA-fallback HTML (the spa server answers
unknown paths with index.html + HTTP 200 for client routing), so a missing
brand.json threw 'Unexpected token <' and crashed App boot -> blank login
page. brand.json is not bundled and the path also wrongly encoded the '/'
in '@hanzo/brand' (%2F), so it never resolved.

Fix:
- Prefer the tenant's brandUrl (the working jsDelivr brand.json from
  config.json), then the app-local path; reject non-JSON (SPA-fallback HTML)
  responses by content-type; on total failure return a minimal fallback brand
  derived from the package scope. Branding is cosmetic and must never block login.
- Add TenantConfig.brandUrl (already supplied by config.json) and pass it from App.
- Drop encodeURIComponent on the whole package (it mangled the scope slash).

Co-authored-by: Antje Worring <[email protected]>
This commit is contained in:
zooqueen
2026-06-18 17:56:31 -07:00
committed by GitHub
co-authored by Antje Worring
parent 628822260a
commit 71efb1cbe8
3 changed files with 48 additions and 8 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ export function App() {
const runtimeCatalog = (window as unknown as { __ID_CATALOG__?: string }).__ID_CATALOG__
const t = resolveTenant(window.location.hostname, { catalog: parseCatalog(runtimeCatalog) })
setTenant(t)
loadBrand(t.brandPackage)
loadBrand(t.brandPackage, t.brandUrl)
.then((b) => {
setBrand(b)
document.title = `Sign in — ${b.name}`
+44 -7
View File
@@ -13,14 +13,33 @@ import type { BrandContract } from './types'
* Vite plugin or the Express static serves the assets from each pkg's
* `assets/` directory at this path).
*/
export async function loadBrand(brandPackage: string): Promise<BrandContract> {
// Browser: served by the app from /brand/<pkg>/brand.json
export async function loadBrand(brandPackage: string, brandUrl?: string): Promise<BrandContract> {
// Browser: try the tenant's brandUrl (e.g. the jsDelivr-hosted brand.json
// from config.json) first, then the app-local /brand/<pkg>/brand.json.
//
// Branding is cosmetic — it must NEVER block the login form. The SPA server
// answers unknown paths with index.html (HTTP 200, text/html) for client-
// side routing, so a missing brand.json comes back as HTML, not a 404. We
// therefore reject any non-JSON response and, if every candidate fails,
// return a minimal fallback brand so the app still renders. (Previously this
// did `res.json()` on the HTML fallback → "Unexpected token '<'" → blank page.)
if (typeof window !== 'undefined') {
const url = `/brand/${encodeURIComponent(brandPackage)}/brand.json`
const res = await fetch(url, { cache: 'no-store' })
if (!res.ok) throw new Error(`brand.json fetch failed: ${res.status} for ${brandPackage}`)
const raw = await res.json()
return raw.brand as BrandContract
const candidates = [brandUrl, `/brand/${brandPackage}/brand.json`].filter(
(u): u is string => typeof u === 'string' && u.length > 0,
)
for (const url of candidates) {
try {
const res = await fetch(url, { cache: 'no-store' })
if (!res.ok) continue
const ct = res.headers.get('content-type') ?? ''
if (!ct.includes('json')) continue // SPA fallback HTML — not our brand.json
const raw = (await res.json()) as { brand?: BrandContract }
if (raw?.brand) return raw.brand
} catch {
// try the next candidate
}
}
return fallbackBrand(brandPackage)
}
// Node: dynamic import (build step + SSR fallback)
const mod = (await import(/* @vite-ignore */ `${brandPackage}/brand.json`, {
@@ -29,6 +48,24 @@ export async function loadBrand(brandPackage: string): Promise<BrandContract> {
return mod.default.brand
}
/**
* Minimal brand used only when no brand.json could be loaded — keeps the login
* form rendering. Derives a display name from the package scope
* (`@hanzo/brand` → "Hanzo").
*/
function fallbackBrand(brandPackage: string): BrandContract {
const scope = brandPackage.match(/@([^/]+)\//)?.[1] ?? 'hanzo'
const name = scope.charAt(0).toUpperCase() + scope.slice(1)
return {
name,
title: 'Sign in',
description: '',
appDomain: '',
logoUrl: '',
faviconUrl: 'data:,',
}
}
/** Subset of the brand contract safe to expose to the browser as window.__BRAND__. */
export interface BrandRuntime {
readonly name: string
+3
View File
@@ -20,6 +20,9 @@ export interface TenantConfig {
readonly publicOrigin: string
/** npm package name of the brand pkg to load (e.g. `@hanzo/brand`). */
readonly brandPackage: string
/** Optional absolute URL to brand.json (e.g. a jsDelivr-hosted copy from
* config.json). Preferred over the app-local /brand/<pkg>/brand.json. */
readonly brandUrl?: string
}
/**