config: read the catalog key the runtime actually serves

/config.json is emitted by hanzoai/spa, which derives its key mechanically
from the env var universe supplies: SPA_IAM_TENANT_CONFIG_JSON becomes
iamTenantConfigJson. Renaming TenantConfig -> OrgConfig in this codebase
also renamed the READ to iamOrgConfigJson, a key nothing emits.

So the catalog has been undefined on every host for as long as that
shipped, with no error anywhere — the fetch succeeds, the field is
absent, and resolveOrg falls back. Verified against the live endpoint:
https://hanzo.id/config.json returns iamTenantConfigJson, and the SPA
was reading past it.

Two consequences, both live:

hanzo.id resolved to the BUILT-IN hanzo-id (enableSignUp:false) instead
of the catalog's hanzo-console (true). A first-time federated user is
refused "the application does not allow to sign up new account" — so
this gates the GitHub fix in 0.2.20 for exactly the users it was for.

Every catalog-ONLY host — osage.id, zoolabs.id, id.zoo.network,
id.lux.network, iam.lux.network, id.pars.network, id.bootno.de,
iam.hanzo.ai — got the empty-clientId skeleton and could resolve no
application at all. The resolver's fail-closed behaviour is correct and
is why this leaked no brand; it is also why it was silent.

The key now lives in one place, catalogOf, next to the resolver it feeds,
with the reason it is not ours to rename. Guarded by two tests that fail
on the old spelling and pass on the new.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
zooqueen
2026-08-03 13:56:11 -07:00
co-authored by Hanzo Dev
parent b17a144b8e
commit af29508847
4 changed files with 66 additions and 13 deletions
+11 -11
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react'
import { loadBrand, parseCatalog, resolveOrg, idBrandLabel, type BrandContract, type OrgConfig } from '@hanzo/id-shared'
import { loadBrand, catalogOf, parseCatalog, resolveOrg, idBrandLabel, type BrandContract, type OrgConfig } from '@hanzo/id-shared'
import { createAuthClient } from '@hanzo/id-auth'
import { Portal } from './pages/Portal'
import { Login } from './pages/Login'
@@ -22,19 +22,19 @@ export function App() {
useEffect(() => {
let cancelled = false
async function boot() {
// The runtime serves the per-host org catalog at /config.json
// (templated from SPA_IAM_TENANT_CONFIG_JSON by the static server). Read
// it from there — NOT a `window.__ID_CATALOG__` global, which the runtime
// never injects (relying on it silently dropped every catalog-only host,
// e.g. osage.id, to the bundled Hanzo default). Fall back to the inlined
// global, then empty, so a host always resolves to something.
// The runtime serves the per-host org catalog at /config.json — NOT a
// `window.__ID_CATALOG__` global, which the runtime never injects
// (relying on it silently dropped every catalog-only host, e.g. osage.id,
// to the bundled Hanzo default). Fall back to the global, then empty, so a
// host always resolves to something.
//
// `catalogOf` owns which key that payload uses — the name is the server's,
// not ours (see its doc). Reading the wrong one is a silent total-catalog
// outage, so it is pinned by a test next to the resolver it feeds.
let catalogRaw: string | undefined
try {
const res = await fetch('/config.json', { cache: 'no-store' })
if (res.ok) {
const cfg = (await res.json()) as { iamOrgConfigJson?: string }
catalogRaw = cfg.iamOrgConfigJson
}
if (res.ok) catalogRaw = catalogOf(await res.json())
} catch {
// network/parse error → fall back below
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@hanzo/id",
"private": true,
"version": "0.2.20",
"version": "0.2.21",
"description": "Hanzo ID \u2014 white-label login + identity verification portal (Vite + @hanzo/gui)",
"scripts": {
"build": "pnpm -r build",
+35 -1
View File
@@ -10,7 +10,7 @@
*/
import { test } from 'vitest'
import assert from 'node:assert/strict'
import { resolveOrg, parseCatalog } from './org.ts'
import { resolveOrg, parseCatalog, catalogOf } from './org.ts'
// Mirrors the K8s ConfigMap shape: entries carry `brandUrl`, not `brandPackage`.
const CATALOG = {
@@ -151,3 +151,37 @@ test('parseCatalog tolerates junk', () => {
'osage.id': { orgId: 'osage' },
})
})
// The runtime's /config.json key is the server's name, not ours. hanzoai/spa
// derives it from universe's env var SPA_IAM_TENANT_CONFIG_JSON, so it is
// `iamTenantConfigJson`. Reading anything else returns undefined, the catalog
// parses to {}, and EVERY host quietly falls back — hanzo.id to the built-in
// `hanzo-id` (enableSignUp:false, so first-time social sign-up is refused) and
// each catalog-only host to an empty-clientId skeleton that resolves no
// application at all. It shipped as `iamOrgConfigJson` after the
// TenantConfig→OrgConfig rename and broke exactly that way, with no error.
test('the catalog is read from the key the runtime actually serves', () => {
// Verbatim shape of https://hanzo.id/config.json.
const served = { iamTenantConfigJson: '{"hanzo.id":{"clientId":"hanzo-console"}}', v: '1' }
assert.equal(catalogOf(served), '{"hanzo.id":{"clientId":"hanzo-console"}}')
assert.equal(parseCatalog(catalogOf(served))['hanzo.id']!.clientId, 'hanzo-console')
// The renamed key is not a second spelling to tolerate — it is simply absent.
assert.equal(catalogOf({ iamOrgConfigJson: '{"hanzo.id":{}}' }), undefined)
assert.equal(catalogOf({}), undefined)
assert.equal(catalogOf(null), undefined)
assert.equal(catalogOf({ iamTenantConfigJson: 42 }), undefined)
})
// The consequence, stated as behaviour: with the catalog present hanzo.id is the
// signup-permitting console app; without it, the built-in that refuses new
// federated users. Same host, one dropped key.
test('hanzo.id takes its clientId from the catalog, not the built-in', () => {
const withCatalog = resolveOrg('hanzo.id', {
catalog: parseCatalog(catalogOf({ iamTenantConfigJson: '{"hanzo.id":{"clientId":"hanzo-console","appName":"hanzo-console"}}' })),
})
assert.equal(withCatalog.clientId, 'hanzo-console')
const dropped = resolveOrg('hanzo.id', { catalog: parseCatalog(catalogOf({ iamOrgConfigJson: 'ignored' })) })
assert.equal(dropped.clientId, 'hanzo-id')
})
+19
View File
@@ -203,6 +203,25 @@ function normalize(t: OrgConfig): OrgConfig {
}
}
/**
* The catalog string inside the runtime's `/config.json` payload.
*
* `iamTenantConfigJson` is the server's name, not ours: hanzoai/spa derives the
* key mechanically from the env var universe supplies —
* `SPA_IAM_TENANT_CONFIG_JSON` (ConfigMap `id-tenant-catalog`). Reading any
* other key yields undefined, `resolveOrg` sees an empty catalog, and every host
* silently falls back to its built-in (or, for a catalog-only host, to an
* empty-clientId skeleton that cannot resolve an application at all). That is a
* total-catalog outage with no error anywhere, so the key is pinned by a test
* rather than left inline at the fetch. Rename the env var and this together or
* not at all.
*/
export function catalogOf(payload: unknown): string | undefined {
if (!payload || typeof payload !== 'object') return undefined
const raw = (payload as { iamTenantConfigJson?: unknown }).iamTenantConfigJson
return typeof raw === 'string' ? raw : undefined
}
/** Parse the runtime catalog JSON safely; returns {} on any error. */
export function parseCatalog(raw: string | undefined | null): Record<string, Partial<OrgConfig>> {
if (!raw) return {}