mirror of
https://github.com/hanzoai/id.git
synced 2026-10-16 18:38:15 +00:00
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:
+11
-11
@@ -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
@@ -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",
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
Reference in New Issue
Block a user