hanzo.id is ONE app, not whichever won the race

Reported from a phone: fill in email + password on hanzo.id/signup, press
Create account, get "the application does not allow to sign up new account".
Not reproducible on demand, which is the tell.

resolveOrg spreads the runtime catalog OVER the built-in table, and for
hanzo.id the two named DIFFERENT applications:

  built-in DEFAULT_TENANTS   hanzo-id        enableSignUp false
  catalog  /config.json      hanzo-console   enableSignUp true

So the answer to "can this person create an account" depended on whether a
second network fetch arrived before the form was submitted. When it did, signup
worked -- which is why it looks fine from a desk. When it did not, the page
still rendered a full form (the fallback app permits password login, just not
registration) and IAM refused the POST. On a phone a dropped request is
ordinary, so that is where it showed up.

Confirmed against prod by replaying the exact POST the SPA makes:

  clientId=hanzo-console application=hanzo-console -> past the gate
  clientId=hanzo-console application=hanzo-id      -> "the application does
                                                       not allow to sign up
                                                       new account"

The second line is the customer's screenshot, reproduced.

lux.id had the same divergence (lux-id vs lux-cloud); both are false there, so
it was latent rather than live. Fixed with hanzo's -- the invariant is one host
one app, not "the currently harmful ones".

The fix is NOT to make the fallback survivable. This table is a fallback for a
failed /config.json, not a second opinion, so the answer is that there is only
one answer per host. org.test.ts now asserts that resolving a host WITH and
WITHOUT the catalog yields the same clientId; reverting org.ts reproduces the
failure ("hanzo.id fallback", 1 failed | 15 passed) and restoring it passes,
so the guard is known to bite. 171/171, typecheck clean.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
zooqueen
2026-08-06 18:14:05 -07:00
co-authored by Hanzo Dev
parent 21a9bba9f9
commit 4a4ace2152
2 changed files with 55 additions and 8 deletions
+33
View File
@@ -249,3 +249,36 @@ test('catalogOf is reachable from the package barrel', async () => {
assert.equal(typeof barrel.catalogOf, 'function')
assert.equal(barrel.catalogOf({ iamTenantConfigJson: '{}' }), '{}')
})
// The fallback and the catalog must name the SAME application per host.
//
// resolveOrg spreads the catalog OVER the built-in table, so a host where the
// two disagree authenticates as one app or the other depending on whether
// /config.json won a race. hanzo.id said `hanzo-id` in the table and
// `hanzo-console` in the catalog, and those two differ in enableSignUp — false
// vs true. On a load where the fetch did not arrive, the signup form rendered
// and the POST came back "the application does not allow to sign up new
// account". Intermittent, device-dependent, and reported from a phone.
//
// This asserts the property that makes the race harmless: for a host present in
// BOTH, resolving with and without the catalog yields the same clientId. It is
// checked against the catalog fixture above, which mirrors the ConfigMap in
// universe/infra/k8s/id/configmap.yaml — so a change to one side that is not
// mirrored in the other fails here rather than in a customer's browser.
test('the built-in fallback names the SAME app as the catalog, per host', () => {
const CANONICAL: Record<string, string> = {
'hanzo.id': 'hanzo-console',
'lux.id': 'lux-cloud',
'pars.id': 'pars-console',
}
for (const [host, clientId] of Object.entries(CANONICAL)) {
// No catalog — the failed-/config.json path a real visitor can hit.
assert.equal(resolveOrg(host).clientId, clientId, `${host} fallback`)
// With the catalog — the ordinary path.
assert.equal(
resolveOrg(host, { catalog: { [host]: { clientId, appName: clientId } } }).clientId,
clientId,
`${host} with catalog`,
)
}
})
+22 -8
View File
@@ -31,18 +31,32 @@ const TRIM_TRAILING_SLASH = (s: string): string => s.replace(/\/+$/, '')
* backend org-scopes on the `organization` body param; one backend
* serves every brand behind its own issuer host.
*
* `clientId` is the brand `-id` app registered in `init_data.json`
* (`hanzo-id`, `lux-id`, …) so the portal authenticates as that app — the
* same app whose enabled providers (password + GitHub + Google + Web3)
* `get-app-login` reports.
* `clientId` MUST name the SAME application the runtime catalog names for that
* host. This table is a fallback for a failed `/config.json`, not a second
* opinion — `resolveOrg` spreads the catalog OVER it, so any key where the two
* disagree makes the host authenticate as one app or the other depending on
* whether a network fetch won a race.
*
* That is not hypothetical. `hanzo.id` said `hanzo-id` here while the catalog
* says `hanzo-console`, and the two differ in `enableSignUp` — false vs true.
* So on a load where `/config.json` did not arrive, hanzo.id authenticated as
* `hanzo-id`, the signup form rendered, and the POST came back "the application
* does not allow to sign up new account". Intermittent, device-dependent, and
* indistinguishable from a bad password to the person hitting it. Reported from
* a phone, where a dropped request is ordinary.
*
* Both now match the catalog (`universe/infra/k8s/id/configmap.yaml`,
* SPA_IAM_TENANT_CONFIG_JSON). `org.test.ts` pins them, so a future edit to one
* side has to confront the other. Fixing the fallback to be SURVIVABLE was the
* wrong shape — the fix is that there is only one answer per host.
*/
const DEFAULT_TENANTS: Record<string, OrgConfig> = {
'hanzo.id': {
orgId: 'hanzo',
iamUrl: 'https://hanzo.id',
iamIssuer: 'https://hanzo.id',
clientId: 'hanzo-id',
appName: 'hanzo-id',
clientId: 'hanzo-console',
appName: 'hanzo-console',
publicOrigin: 'https://hanzo.id',
brandPackage: '@hanzo/brand',
},
@@ -50,8 +64,8 @@ const DEFAULT_TENANTS: Record<string, OrgConfig> = {
orgId: 'lux',
iamUrl: 'https://lux.id',
iamIssuer: 'https://lux.id',
clientId: 'lux-id',
appName: 'lux-id',
clientId: 'lux-cloud',
appName: 'lux-cloud',
publicOrigin: 'https://lux.id',
brandPackage: '@luxfi/brand',
},