zooqueenandHanzo Dev 4a4ace2152 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]>
2026-08-06 18:14:05 -07:00
2026-02-14 05:26:48 -08:00
2026-06-28 20:06:13 -07:00

id

@hanzo/id

White-label login + identity verification portal. One Vite SPA, four hosts (hanzo.id, lux.id, zoo.id, pars.id), per-tenant brand resolved from the request hostname at runtime.

Layout

apps/
  web/                Vite + React 19 + @hanzo/gui — the actual SPA
    k8s/              Deployment + Service + Ingress (4 hosts, 4 TLS secrets)
pkgs/
  shared/             @hanzo/id-shared  — TenantConfig + brand resolver
  auth/               @hanzo/id-auth    — composable login/signup/OTP flows
                                          on top of @hanzo/iam SDK
  idv/                @hanzo/id-idv     — pluggable identity verification
                                          (Persona, Onfido, Veriff, stub)
legacy-nextjs/        Frozen — predecessor Next.js implementation. Kept
                     for reference until v0.1.0 ships to production.

Local dev

pnpm install
pnpm dev                 # http://localhost:5173 (defaults to hanzo brand)

To preview a different brand locally, edit /etc/hosts:

127.0.0.1  lux.id zoo.id pars.id

then visit http://lux.id:5173.

Build

pnpm build               # builds apps/web -> dist/
docker build -t ghcr.io/hanzoai/id:0.1.0 .

Adding a brand

  1. Publish or workspace-link the new per-org brand pkg (must ship brand.json at the package root and match the BrandContract shape in pkgs/shared/src/types.ts).
  2. Add a DEFAULT_TENANTS entry in pkgs/shared/src/tenant.ts OR put the override in the runtime catalog (IAM_TENANT_CONFIG_JSON env) so no rebuild is needed.
  3. Add the hostname to apps/web/vite.config.ts::BRAND_PACKAGES (lets dev + build serve /brand/<pkg>/brand.json).
  4. Add the hostname + TLS secret to apps/web/k8s/ingress.yaml.
  5. DNS: CNAME or A record → cluster ingress IP.

That's it — no per-brand Worker, no per-brand image, no per-brand deployment. One binary, four brands.

Plugging an IDV provider

import { registerProvider, createPersonaProvider } from '@hanzo/id-idv'
registerProvider(createPersonaProvider({
  templateId: import.meta.env.VITE_PERSONA_TEMPLATE_ID,
  apiKey: import.meta.env.VITE_PERSONA_API_KEY,
  environment: 'production',
}))

The portal stays unchanged — switching providers is a single registration call at boot.

S
Description
No description provided
Readme MIT
3.7 MiB
Languages
TypeScript 92.1%
CSS 4.5%
JavaScript 2.6%
Dockerfile 0.6%
HTML 0.2%