fix(tenant): a host with no entry must fail closed, not become Hanzo

resolveTenant's terminal branch returned DEFAULT_TENANTS[`${defaultOrg}.id`] —
Hanzo's tenant — for any host it could not resolve. Eight real identity hosts
have no built-in entry and exist only in the runtime catalog:

  zoolabs.id  www.zoolabs.id  id.zoo.network  id.lux.network
  iam.lux.network  id.pars.network  id.bootno.de  iam.hanzo.ai

and App.tsx deliberately tolerates a failed /config.json fetch. So whenever
that fetch failed, a Zoo, Lux, Pars or Bootnode visitor was handed orgId
`hanzo`, brandPackage `@hanzo/brand` and iamUrl `https://hanzo.id` — shown
"Sign in to Hanzo ID" under the Hanzo mark, and posting their credentials at
hanzo.id. Not a cosmetic brand leak: the wrong origin receives the password.

The function's own comment eleven lines above already promised this could not
happen ("Never another brand's config: a catalog-only host … must not inherit
Hanzo's issuer or brand package"). The guarantee held only while the catalog
loaded; the fallback broke it exactly when the catalog did not.

Unknown hosts now resolve to hostSkeleton(host) — the host's own origin, empty
orgId/clientId/brandPackage. The portal fails closed rather than silently
authenticating as another brand's IAM application. A login page that cannot
resolve its tenant must refuse, not guess. The `defaultOrg` option is deleted
with the branch that used it; the docblock no longer advertises a cross-brand
default.

Also removes the `zoo.id` built-in: crs/iam.yaml records the host as retired to
NXDOMAIN, and `dig zoo.id` returns nothing. It named a tenant that cannot be
reached.

One existing test asserted the defect as intended behaviour
(`orgId === 'hanzo'` for an unknown host) and is corrected. Added coverage
walks all seven brand hosts with NO catalog and asserts none of them comes back
as Hanzo. 134 tests pass across 12 files.
This commit is contained in:
zeekay
2026-07-26 12:33:30 -07:00
parent 48acbaaf92
commit 3b067475d1
2 changed files with 66 additions and 21 deletions
+42 -4
View File
@@ -80,9 +80,44 @@ test('a catalog host with NO brandUrl still does not leak Hanzo (id.bootno.de)',
assert.equal(t.publicOrigin, 'https://id.bootno.de')
})
test('an unknown host still falls back to hanzo (the intended default)', () => {
test('an unknown host FAILS CLOSED — it never inherits another brand', () => {
const t = resolveTenant('totally-unregistered.example', { catalog: CATALOG })
assert.equal(t.orgId, 'hanzo')
// Was: DEFAULT_TENANTS['hanzo.id'] — orgId hanzo, @hanzo/brand, and
// iamUrl https://hanzo.id. Empty clientId means the portal refuses rather
// than authenticating as some other brand's IAM application.
assert.equal(t.orgId, '')
assert.equal(t.clientId, '')
assert.equal(t.brandPackage, '')
assert.equal(t.iamUrl, 'https://totally-unregistered.example')
assert.notEqual(t.iamUrl, 'https://hanzo.id')
})
test('a catalog host with a FAILED catalog fetch does not leak Hanzo', () => {
// The live failure mode: App.tsx tolerates a failed /config.json, so these
// real hosts resolve with NO catalog at all. Every one of them used to come
// back as Hanzo — same brand, same mark, and credentials posted at hanzo.id.
for (const host of [
'zoolabs.id',
'www.zoolabs.id',
'id.zoo.network',
'id.lux.network',
'iam.lux.network',
'id.pars.network',
'id.bootno.de',
]) {
const t = resolveTenant(host) // no catalog — the fetch failed
assert.notEqual(t.orgId, 'hanzo', `${host} leaked orgId hanzo`)
assert.notEqual(t.brandPackage, '@hanzo/brand', `${host} leaked the Hanzo mark`)
assert.notEqual(t.iamUrl, 'https://hanzo.id', `${host} would post credentials at hanzo.id`)
assert.equal(t.iamUrl, `https://${host}`)
assert.equal(t.iamIssuer, `https://${host}`)
}
})
test('zoo.id is gone — it is NXDOMAIN and must not be a built-in', () => {
const t = resolveTenant('zoo.id')
assert.equal(t.orgId, '')
assert.equal(t.clientId, '')
})
test('pars built-in uses the working pars-console portal app (not the missing pars-id)', () => {
@@ -99,9 +134,12 @@ test('osage built-in resolves to Osage even with NO catalog (fallback safety)',
assert.equal(t.iamUrl, 'https://osage.id')
})
test('an unknown host falls back to the default org but keeps its own origin', () => {
test('an unknown host keeps its own origin AND does not inherit an org', () => {
const t = resolveTenant('preview.example.com')
assert.equal(t.orgId, 'hanzo')
// This assertion used to be `orgId === 'hanzo'` — it pinned the cross-brand
// fallback as intended behaviour. Keeping its own origin is right; being
// handed Hanzo's org, mark and issuer is the defect that shipped behind it.
assert.equal(t.orgId, '')
assert.equal(t.publicOrigin, 'https://preview.example.com')
})
+24 -17
View File
@@ -6,9 +6,13 @@ import type { TenantConfig } from './types'
* Resolution order (first hit wins):
* 1. `IAM_TENANT_CONFIG_JSON` runtime catalog (set in K8s ConfigMap, served
* to the browser via `/config.json` at pod startup).
* 2. Built-in defaults for the four canonical Hanzo identity hosts.
* 3. `IAM_DEFAULT_ORG` (or "hanzo") fallback — used for unknown hosts
* (preview deploys, local dev, custom domains pre-launch).
* 2. Built-in defaults, for the identity hosts that have one.
* 3. A skeleton derived from the REQUESTED HOST — never another brand.
*
* There is deliberately no cross-brand default. An unknown host resolves to
* itself with an empty clientId and fails closed, because the alternative is a
* visitor on one brand's host being shown another brand's login and posting
* credentials there.
*
* No hardcoded hostname switches anywhere downstream. Adding a tenant
* means editing the runtime catalog, never editing source.
@@ -51,15 +55,6 @@ const DEFAULT_TENANTS: Record<string, TenantConfig> = {
publicOrigin: 'https://lux.id',
brandPackage: '@luxfi/brand',
},
'zoo.id': {
orgId: 'zoo',
iamUrl: 'https://zoo.id',
iamIssuer: 'https://zoo.id',
clientId: 'zoo-id',
appName: 'zoo-id',
publicOrigin: 'https://zoo.id',
brandPackage: '@zooai/brand',
},
'pars.id': {
orgId: 'pars',
iamUrl: 'https://pars.id',
@@ -109,8 +104,6 @@ export type CatalogEntry = Partial<TenantConfig> & {
export interface ResolveOptions {
/** Optional runtime catalog (parsed from IAM_TENANT_CONFIG_JSON or /config.json). */
readonly catalog?: Record<string, CatalogEntry>
/** Default org slug when host has no entry. */
readonly defaultOrg?: string
}
export function resolveTenant(hostname: string, opts: ResolveOptions = {}): TenantConfig {
@@ -125,9 +118,23 @@ export function resolveTenant(hostname: string, opts: ResolveOptions = {}): Tena
const merged: TenantConfig = { ...base, ...fromCatalog(catalogEntry) } as TenantConfig
return normalize(merged)
}
const defaultOrg = opts.defaultOrg ?? 'hanzo'
const fallback = DEFAULT_TENANTS[`${defaultOrg}.id`] ?? DEFAULT_TENANTS['hanzo.id']
return normalize({ ...fallback, publicOrigin: `https://${host}` })
// Unknown host → derive from the host ITSELF. Never another brand's tenant.
//
// This used to return DEFAULT_TENANTS[`${defaultOrg}.id`], i.e. Hanzo's. Eight
// real hosts have no built-in entry and live only in the runtime catalog —
// zoolabs.id, www.zoolabs.id, id.zoo.network, id.lux.network, iam.lux.network,
// id.pars.network, id.bootno.de, iam.hanzo.ai — and App.tsx deliberately
// tolerates a failed /config.json fetch. So whenever that fetch failed, a Zoo,
// Lux, Pars or Bootnode visitor was handed orgId `hanzo`, `@hanzo/brand` and
// iamUrl `https://hanzo.id`: shown "Sign in to Hanzo ID" under the Hanzo mark
// and POSTING THEIR CREDENTIALS AT hanzo.id. The comment ten lines up already
// promised this could not happen ("Never another brand's config") — it held
// only while the catalog loaded.
//
// The skeleton carries an empty clientId, so the portal fails closed rather
// than silently authenticating as some other brand's IAM application. A login
// page that cannot resolve its tenant must refuse, not guess.
return normalize(hostSkeleton(host))
}
/**