main
hanzo.id has not published an image since the telemetry commit. The build fails at `vite build` with Cannot find module '/build/apps/web/node_modules/vite/bin/vite.js' which reads as a broken tree but is not: the same commit builds cleanly when its dependencies come from pnpm-lock.yaml. The image never used the lockfile. It was not copied, and the install ran `--frozen-lockfile=false`, so the container re-resolved the entire graph from the registry on every build while `pnpm test` on the runner installed from the lockfile -- two different dependency graphs out of one commit, the tested one and the shipped one. That agreed for as long as free resolution happened to land on the same versions, which is not a property anyone was maintaining. Adding @hanzo/event was enough to break the tie: the lockfile pins vite@7.3.5_@types+node@25.9.3_..., free resolution chose vite@7.3.6_@types+node@22.20.1, and the peer-suffixed store path the workspace link pointed at no longer existed. The dependency did nothing wrong; it disturbed a resolution that was never pinned. So the lockfile ships and the install is frozen to it. The image now gets the exact tree the tests passed against, and a stale lockfile fails at the install naming the mismatch rather than quietly building a different application. apps/account/package.json joins the pre-install copies: a frozen install validates the lockfile against EVERY workspace member, and apps/account is in the workspace even though this image does not build it. Verified from a cold store on this exact commit: frozen install resolves the pinned vite, `pnpm --filter @hanzo/id-web build` succeeds, and the ingest key is present in apps/web/dist -- the assertion the next step makes. Co-authored-by: Hanzo Dev <dev@hanzo.ai>
@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
- Publish or workspace-link the new per-org brand pkg (must ship
brand.jsonat the package root and match theBrandContractshape inpkgs/shared/src/types.ts). - Add a
DEFAULT_TENANTSentry inpkgs/shared/src/tenant.tsOR put the override in the runtime catalog (IAM_TENANT_CONFIG_JSONenv) so no rebuild is needed. - Add the hostname to
apps/web/vite.config.ts::BRAND_PACKAGES(lets dev + build serve/brand/<pkg>/brand.json). - Add the hostname + TLS secret to
apps/web/k8s/ingress.yaml. - 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.
Languages
TypeScript
89.3%
JavaScript
5.3%
CSS
4.3%
Dockerfile
0.9%
HTML
0.1%