Confidential OIDC clients that validate strictly (LibreChat openid-client with
OPENID_REUSE_TOKENS) send a nonce on authorize and require the id_token to echo
it. The portal's password-login path read code_challenge from the authorize URL
but DROPPED nonce, so the IAM minted a code (and id_token) with no nonce ->
openid-client 'unexpected JWT claim value encountered' -> hanzo.chat callback
HTTP 500. Thread nonce: Login.tsx reads ?nonce, LoginForm forwards it, and
client.login() puts it on the /v1/iam/login query (next to code_challenge).
Additive + forward-only (never defaulted); social login already rode authorize()
which carried nonce. Verified: id SPA builds green.
The ARC runner image ships git+docker but not node, so the Compute-tags step's
`node -p "require('./package.json').version"` died with exit 127 (node: command
not found), skipping the build. Parse the version from package.json with
portable sed instead. Verified locally -> 0.1.23.
runs-on was [self-hosted, linux, amd64], which GitHub matched to the OFFLINE
classic evo-* org runners instead of the live ephemeral ARC scale set, so every
Docker run since the self-contained-build switch queued forever (never built).
Use runs-on: hanzo-build-linux-amd64 (the exact scaleSetName), matching the
hanzoai/iam convention, so jobs route to scale set 31.
Also switch buildx gha cache to mode=min: the runner builds via DinD on a 32G
node and mode=max exported every layer, ballooning the DinD store past the
kubelet eviction threshold (NodeHasDiskPressure -> build killed 'no space').
LoginForm no longer pins organization=<brand> on POST /v1/iam/login. It passes
the new (normally-unset) TenantConfig.loginOrg, and client.login() OMITS the
organization field when empty so IAM runs cross-org resolution and the session
encodes the user's REAL owner-org (GetOrganizationByUser), never the hint.
Fixes: global admins (z@/[email protected], [email protected] — owner=admin) were
truncated to a 1-org 'hanzo' session via the UI because the pinned org made
GetUserByFields hit the colliding hanzo/<name> row first, so the cross-org
fallback to admin/<name> never ran. IAM IsGlobalAdmin()==(Owner=='admin') and
get-organizations returns all orgs only for a global admin. Verified live on
hanzo.id: [email protected] -> owner=admin, 45 orgs; brand-only user -> 1 org.
Boundaries preserved: signup still sends a concrete org; per-app SSO
(client_id+redirect_uri, type=code) still mints a code bound to the resolved
user; a brand can force single-org login via loginOrg in the catalog ConfigMap.
Contract locked in pkgs/auth/src/client.test.ts.
Bump 0.1.22 -> 0.1.23.
The org step fetched service.listOrgs() and rendered a pick-list of every
existing organization to a brand-new user — leaking the tenant directory to
anyone who signs up, and not the intended UX. A new user should only create
their own organization or skip; joining an existing org is invitation-based
and handled outside onboarding.
Drop the listOrgs() fetch and the pick-list entirely; the org step now always
shows the create form with a 'Skip for now' action.
Co-authored-by: Darkhorse7stars <[email protected]>
Twilio A2P 10DLC requires the SMS consent disclosure wherever a user enters
or uses a phone number to receive messages. Add one shared, verbatim consent
component (matches hanzo.ai/sms-opt-in SMS_CONSENT_TEXT and the IAM phone-login
UI) and render it on the portal's SMS surface, gated to the SMS channel only.
- New pkgs/auth/src/ui/SmsConsent.tsx: single source of SMS_CONSENT_TEXT plus
<SmsConsentNotice/> (disclosure + Terms/Privacy links). Exported from the ui
barrel for reuse by any future phone-collection surface.
- OTPForm: render SmsConsentNotice beneath the code input only when
channel === 'sms' (not for totp/email).
- app.css: muted styling for .hanzo-id-sms-consent, matching footer-links.
Note: this portal does not render its own phone-number field — phone-number
COLLECTION happens in the IAM-hosted UI (covered separately), where signup uses
a required, submit-gating consent checkbox. The portal's only SMS-facing step
today is the verification-code entry, so the disclosure-only notice applies
here. Typecheck + tests green (pnpm -r tc, @hanzo/id-auth test).
The shared reusable workflow (hanzoai/.github docker-build.yml@main) fails graph
validation for EVERY caller right now (org-wide startup_failure, 0 jobs created),
so id never built via CI (0.1.x images were pushed manually). Build the amd64
image directly on our self-hosted runners instead — no shared-infra dependency.
Tags: sha-<short> + package.json version.
The provider hop sent redirect_uri=${origin}/callback (= hanzo.id/callback),
which the shared Google/GitHub OAuth client does NOT accept — verified live the
Google client accepts ONLY https://iam.hanzo.ai/callback (every other URI →
redirect_uri_mismatch). It also used the invalid authorize endpoint
accounts.google.com/signin/oauth.
- social.ts: Google endpoint → https://accounts.google.com/o/oauth2/v2/auth
(canonical/stable); add callbackOrigin param to buildProviderAuthUrl/
startProviderLogin (defaults to origin — local/single-host unchanged).
- TenantConfig.oauthCallbackOrigin (catalog-driven; defaults to publicOrigin);
SocialButtons passes it so the hop returns to the provider's REGISTERED
/callback. iam.hanzo.ai serves the same @hanzo/id SPA: the headless Callback
completes the exchange and forwards to the originating app.
- tests: lock the registered-callback override + canonical Google endpoint.
bump id 0.1.1→0.1.22, id-auth/id-shared 0.1.0→0.1.1
- '/' rendered a static 'Welcome' marketing hero; for an identity portal the
root should BE the login form when signed out, and the org's apps launcher
when signed in. Portal now reads /v1/iam/get-account (same-origin, first-party
cookie) and renders <Login> (anon) or the apps grid (authed).
- Onboarding completion redirected to '/' → the hero, which read as 'looped back
to the beginning' after the last (wallet) step's Skip. It now lands on
'/?signed_in=1' → the authed apps launcher. (The step machine was already
correct: nextStep('wallet')==='done'.)
- Port marketing.ts (appsFor/billingFor) from the restore-design line.
Prep social login end-to-end so only OAuth-app registration + KMS creds remain.
- The hop (social.ts/startProviderLogin): redirects straight to GitHub/Google with a base64 state that round-trips the original authorize request — the Casdoor getAuthUrl contract. Replaces the @hanzo/iam signinRedirect that looped back to the login page. Pure URL builder is unit-tested (social.test.ts, 5 cases).
- SocialButtons now keeps the full provider records (type/clientId/scopes surfaced from get-app-login) and routes OAuth providers through the hop, wallet through the SDK.
- Callback gained a GATED provider-return branch: a provider state (base64 with provider+application) is exchanged via client.providerLogin at the IAM backend, then follows the continue-URL back into the normal OIDC path. The existing OIDC/password path is the untouched else-branch — and the social branch is unreachable until a configured provider exists, so zero impact today.
- Cred-sync already exists (init_data.json ${IAM_GITHUB_CLIENT_ID} <- iam-kms-sync KMSSecret, project hanzo-iam/prod). Runbook in LLM.md. End-to-end needs live verification once real creds are seeded.
Root cause osage.id still showed Hanzo: the SPA read the catalog from window.__ID_CATALOG__, which the runtime never injects — so every catalog-only host silently fell back to the bundled Hanzo default. Read the catalog from /config.json (what the static server actually templates from SPA_IAM_TENANT_CONFIG_JSON), with the global as a fallback.
Also corrected verified-broken client wiring: built-in pars-id does not exist (use pars-console, which carries the /callback redirect); added an osage built-in so a catalog-load failure can never leak the Hanzo brand. lux uses lux-id (lux-cloud lacks the portal redirect) — fixed in the ConfigMap. Tests: 7/7.
osage.id (and zoolabs.id) rendered as Hanzo — a white-label leak. Root cause: the runtime catalog entries carry brandUrl (+orgId/clientId), but a host with NO built-in DEFAULT_TENANTS entry used DEFAULT_TENANTS['hanzo.id'] as its merge base, so brandPackage/iamUrl silently inherited Hanzo. Now a catalog-only host derives iamUrl/iamIssuer/publicOrigin from the host itself and maps brandUrl -> brandPackage; it can never inherit another brand. Unknown brand asset -> neutral wordmark, not the wrong brand. Regression test added (pkgs/shared now has a test runner).
Drop the orphan 'or' divider: it lived between the social block and the password form, but with unconfigured social hidden it dangled above the form on every brand. Moved the divider INTO SocialButtons so it renders only alongside actual buttons; removed the standalone one from Login + Signup.
Two things made login feel broken across every brand on the portal (hanzo.id/lux.id/pars.id/osage.id):
1) Social SSO dead-ended. The GitHub/Google/Apple/Web3 providers are seeded with PLACEHOLDER credentials (clientId=GITHUB_CLIENT_ID_PLACEHOLDER, etc.), so the OAuth redirect can never complete — clicking 'Continue with GitHub' just looped back to the login form. Render ONLY providers IAM actually holds a real clientId for (AppProvider.configured, computed from the nested provider record). With placeholders that hides all social buttons, leaving the working methods (email/password + email/SMS code) — and the buttons reappear automatically once real OAuth creds are seeded, no code change. When get-app-login is unreadable, render none rather than risk a dead-end.
2) Broken logo. brand.json points logo/favicon at jsdelivr @hanzo/brand assets, but the published brand packages ship no assets/ dir (404). BrandHeader now falls back to the brand NAME as a text wordmark on image error/absence, so a missing logo never renders a broken-image icon.
Enabling real social login later needs: (a) register OAuth apps (GitHub/Google/Apple) with callback https://<brand>/v1/iam/callback, (b) put their client_id/secret in KMS → IAM provider records, (c) the SPA provider-redirect hop. (a)+(b) are external provisioning; until then social is correctly hidden.
login() built /v1/iam/login with clientId/responseType/redirectUri/state
but dropped the PKCE code_challenge/code_challenge_method that authorize()
already forwards. A downstream public SPA client (e.g. hanzo-platform)
sending a user to password sign-in got an auth code minted with an empty
stored challenge; the downstream /auth/callback token exchange then failed
PKCE validation and fell back to a client_secret check the public client
can't satisfy (token.CodeChallenge: empty -> invalid_client).
IAM's Login handler (controllers/account.go) reads code_challenge from the
query first, body fallback, so forward it on the query mirroring authorize().
Plumb it from the OAuth params already on the /login URL through Login page
-> LoginForm -> client.login(). Social login was unaffected (rides authorize).
hanzoai/static defaults CSP to `default-src 'none'` which blocks the
SPA's own bundle (no script-src) -> blank page. Bake a widened-but-locked
CSP that permits: self scripts + CF beacon, same-origin + jsDelivr brand
fetches, https/data images (brand logos), inline styles (@hanzo/gui).
Baking it (vs a deploy-time env) makes the image render correctly
standalone and survives universe reconcile.
Two coupled bugs broke runtime brand loading on the hanzoai/static
(scratch) image:
1. vite.config.ts is ESM ("type":"module") so the brandJsonPlugin's
require.resolve('@scope/brand/brand.json') threw ReferenceError
(no global require), was swallowed by the silent catch, and NO
brand.json was ever emitted into dist/. Fixed with
createRequire(import.meta.url).
2. The brand fetch path /brand/@hanzo/brand/brand.json carries a literal
@ and an encoded %2F that the production static server cannot map to
the on-disk file, so it falls through to the SPA catch-all and returns
index.html — the SPA then parses HTML as JSON. Emit and fetch at a
FLAT slug path instead: @hanzo/brand -> /brand/hanzo.json. The plugin
and loadBrand derive the slug identically (npm scope).
With defensive loadBrand (prev commit) a miss degrades to a neutral
brand; this restores real per-brand branding.
The browser brand loader threw on any non-ok /brand/<pkg>/brand.json fetch, so a single transient 502 on the cosmetic brand asset (intermittent behind Cloudflare) left the IAM login SPA blank — no form, just 'brand.json fetch failed: 502'. Retry the flaky asset up to 3x with backoff, then fall back to a neutral per-tenant brand so the form always renders. brand.json stays the source of truth on the happy path.
hanzoai/static:0.4.1 is FROM scratch + ENTRYPOINT ["/static"]; the
binary defaults to -root /public -port 3000 and writes
/public/config.json from SPA_* env at boot. The previous final stage
copied the bundle to /spa and set dead ENV ROOT/PORT (the binary reads
flags, not env), so /public never existed and the runtime-config write
crashed (open /public/config.json.tmp: no such file or directory).
Copy the dist to /public (the default root + config.json target) and
pass --spa so client-routed paths (/auth/*, /callback) fall back to
index.html. Port 3000 matches the id Service and probes.
apps/web depends on @hanzo/id-onboarding (workspace:*) since the
onboarding flow landed, but the Dockerfile's per-package manifest
COPY list (the layer feeding the dependency-resolution
`pnpm install` before the full `COPY pkgs pkgs`) was never
updated. In-cluster builds therefore failed at install with
ERR_PNPM_WORKSPACE_PKG_NOT_FOUND for @hanzo/id-onboarding.
Local builds masked this because node_modules was already
populated from a prior full install.
Social + Web3 always return to the portal's own /callback (the SDK's fixed
redirectUri), so a downstream app's redirect_uri would be lost. SocialButtons
now stashes it in post_login_redirect before signinRedirect; Callback reads it
back and forwards the tokens there, else lands on /onboarding. Login + Signup
pass redirect_uri through. Password path already forwards directly via the
auth-code response — both methods now reach the same downstream target.
linkWallet now resolves the signed-in user via /v1/iam/get-account (owner/name)
and writes the lowercase `web3onboard` column scoped via ?columns= so the rest
of the user row is untouched — IAM's update-user is keyed by owner/name, not a
self alias.
Org/project creation is admin-gated in IAM authz (add-organization needs the
admin role; add-project default-denies non-admins). The common path — pick the
org you signed up into, listed via the *-allowed get-organizations — works for
everyone; create-new surfaces a plain permission message and the step stays
skippable so onboarding never hard-blocks. ProjectStep tolerates an org-less
flow (continue-only).
@hanzo/id-onboarding: domain (serializable step machine + types) / service
(IAM-backed writes via /v1/iam/{get-organizations,add-organization,add-project,
update-user}, cookie+bearer) / UI (self-contained 3-step OnboardingFlow, no
router lib). Web app mounts it at /onboarding; both password (cookie) and
social/Web3 (SDK token) sign-in land there for a bare portal login.
Callback now completes via the @hanzo/iam SDK handleCallback (matches
SocialButtons' signinRedirect) and forwards tokens to a downstream app only
when one initiated the flow.
tenant.ts: iamUrl is the per-brand OIDC issuer host (hanzo.id / lux.id /
zoo.id / pars.id), never iam.hanzo.ai — HIP-0111 host-relative discovery.
clientId is the brand -id app (hanzo-id), matching init_data.json.
SocialButtons reads live enabled providers from /v1/iam/get-app-login and
drives the @hanzo/iam PKCE redirect (provider param) per method. Login/Signup
render the social row + divider above the email form. AppLogin/AppProvider
types model the get-app-login view; createIam() centralizes the one PKCE
client both SocialButtons and Callback share.
loadBrand did res.json() on the SPA-fallback HTML (the spa server answers
unknown paths with index.html + HTTP 200 for client routing), so a missing
brand.json threw 'Unexpected token <' and crashed App boot -> blank login
page. brand.json is not bundled and the path also wrongly encoded the '/'
in '@hanzo/brand' (%2F), so it never resolved.
Fix:
- Prefer the tenant's brandUrl (the working jsDelivr brand.json from
config.json), then the app-local path; reject non-JSON (SPA-fallback HTML)
responses by content-type; on total failure return a minimal fallback brand
derived from the package scope. Branding is cosmetic and must never block login.
- Add TenantConfig.brandUrl (already supplied by config.json) and pass it from App.
- Drop encodeURIComponent on the whole package (it mangled the scope slash).
Co-authored-by: Antje Worring <[email protected]>
The v1.3.0 git tag exists but no 1.3.0 image was published to ghcr
(build pipeline blocked). 1.2.0 is the latest published spa image and the
version the canonical RECIPE.md uses.
Co-authored-by: Antje Worring <[email protected]>
hanzoai/static defaults to Content-Security-Policy: default-src 'none'
(no script-src) — built for static assets, not a SPA that loads its own
JS bundle. That CSP blocks index-*.js, so React never mounts and the
login page renders blank. hanzoai/spa is the purpose-built base: history-
API fallthrough for client-side routes + a SPA-safe CSP. Defaults
PORT=3000 / ROOT=/public, matching the id deploy probe.
Immediate prod was unblocked by setting HANZO_STATIC_CSP on the live
deployment; this is the durable, one-way fix so the override hack isn't
needed and every rebuild serves correctly.
Co-authored-by: Antje Worring <[email protected]>
hanzoai/static:0.4.1 reads the serve root from the -root flag (default
/public) and port from -port (default 3000) — the ENV ROOT=/spa PORT=8080
were no-ops, so the built image served /public (crash) on :8080 while the
deploy probes :3000. Set ENTRYPOINT [/static -root /spa -spa] (default
port 3000). Matches the hand-built id:0.1.5 now live.
Co-authored-by: Antje Worring <[email protected]>
Merged IAM main serves the canonical /v1/iam/* only; /api/get-app-login
now returns the SPA shell. Point the providers/methods fetch at the
canonical path so social buttons + sign-in methods load.
Co-authored-by: Antje Worring <[email protected]>
The Vite portal rendered only email/password. Add — driven by the live
IAM app config (new AuthClient.appLogin -> get-app-login), no hardcoded
lists:
- social provider buttons (GitHub/Google/Apple/Web3) via authorize?provider=
- passwordless email / SMS code sign-in (send-verification-code + OTPForm)
- new ProviderButtons component; LoginForm + SignupForm render providers
Email/password sign-in unchanged. tsc --noEmit clean; vite build clean.
Functional prerequisites (display works now; these make it WORK):
- real OAuth client IDs for github/google/apple (IAM currently has
GITHUB_CLIENT_ID_PLACEHOLDER etc.)
- an SMS gateway provider (Twilio/etc.) in IAM for SMS codes
- redirectUris backfill so the /callback OAuth return is allowed (iam#51)
Co-authored-by: Antje Worring <[email protected]>
The IAM apps are registered with clientId '<org>-id' (hanzo-id, lux-id,
zoo-id, pars-id) — there is no '-portal' variant. clientId 'hanzo-id-portal'
returns 'Invalid client_id' from IAM, breaking the login flow. Align all
four tenant clientIds with their appName and the IAM seed convention.
Co-authored-by: Antje Worring <[email protected]>
The id repo is brand-neutral (zero brand data bundled), but apps/web/k8s/
carried the per-host brand catalog + Deployment/Ingress — duplicated in
hanzoai/universe infra/k8s/id/ (the single deploy source-of-truth). Removed
the overlay so app (brand-neutral image) and deploy (per-host catalog +
manifests, in universe) are separated. One way, one place.
Co-authored-by: hanzo-dev <[email protected]>
Co-authored-by: Hanzo Dev <[email protected]>
* fix(auth): IAM wire contract + brand-app catalog
- client.ts: send type/application/organization in the request BODY (IAM reads
auth fields from body, OAuth params from query) so the branded login form
authenticates; build the code-redirect from the response.
- canonical /v1/iam/oauth/{authorize,token,logout} paths.
- tenant-catalog: point each host at its brand's real IAM app
(hanzo-console/lux-cloud/zoo-console/pars-console).
* chore(release): 0.1.1
---------
Co-authored-by: hanzo-dev <[email protected]>
Co-authored-by: Hanzo Dev <[email protected]>
Image carries no brand identity. Every per-tenant value comes from the
runtime catalog (K8s ConfigMap → /config.json → window.__ID_CATALOG__)
or is derived from the hostname.
Source changes:
- TenantConfig.brandPackage → brandUrl (absolute URL to brand.json).
Brands self-host (npm + jsDelivr is convention; any URL works).
- DEFAULT_TENANTS = {} (deleted). Hostname-derivation is the new fallback:
foo.id / www.foo.id / id.foo.net / iam.foo.net → orgId=foo,
clientId=foo-id-portal, appName=foo-id,
brandUrl=https://cdn.jsdelivr.net/npm/@foo/brand@latest/brand.json
Works out-of-the-box when the npm scope matches the org. The catalog
handles mismatches.
- brand.ts loadBrand(brandUrl) fetches the URL directly. localizeAssets()
removed — brand.json's URLs are absolute, used as-is.
- vite.config.ts: removed brandJsonPlugin + BRAND_PACKAGES list. Vite
bundles no brand assets. dist drops from ~250kB to 205kB (64kB gzip).
- apps/web/package.json: dropped @hanzo/brand, @luxfi/brand, @zooai/brand,
@parsdao/brand deps. Zero brand packages in the image.
- main.tsx: fetches /config.json before mount; sets
window.__ID_CATALOG__ from cfg.iamTenantConfigJson (templated by
hanzoai/spa runtime from SPA_IAM_TENANT_CONFIG_JSON env var).
- App.tsx: loadBrand(t.brandUrl).
- LLM.md: documents the brand-neutral architecture and how to add a
brand (publish brand.json + add to deploy's catalog ConfigMap; image
never changes).
Deploy changes:
- apps/web/k8s/tenant-catalog.yaml: NEW ConfigMap carrying the Hanzo
deployment's full host→tenant map for 11 hosts (hanzo / lux / zoo /
pars / osage and their id.*.network / iam.*.network / www.* variants).
Brand-specific knowledge lives ONLY here.
- apps/web/k8s/deployment.yaml: envFrom: configMapRef: id-tenant-catalog.
- apps/web/k8s/kustomization.yaml: includes tenant-catalog.yaml first.
Adding a brand from here on is:
1. publish @<scope>/brand to npm with brand.json
2. add the host(s) to the deployment's ConfigMap
3. add the host to ingress.yaml (cert-manager auto-provisions TLS)
4. DNS → cluster ingress IP
No source change. No image rebuild.
cert-manager.io/cluster-issuer=letsencrypt-prod-cf provisions per-host
TLS via DNS-01 (the Traefik file-route certResolver path is non-functional
in this cluster — CF env vars on the ingress deployment are empty, ACME
in /data/acme.json is empty). cert-manager owns TLS for identity hosts.
After apply + cert issuance (~90s): all 7 identity hosts return their
own OIDC issuer with valid LE certs:
lux.id, hanzo.id, pars.id, zoolabs.id, www.zoolabs.id, osage.id, www.osage.id
DEFAULT_TENANTS now resolves all 4 active identity hosts that didn't
have built-in entries:
- www.zoolabs.id → orgId=zoo, brand=@zooai/brand
- osage.id → orgId=osage, brand=@osage/brand
- www.osage.id → orgId=osage, brand=@osage/brand
zoolabs.id was already present. orgId, iamIssuer, clientId, appName,
publicOrigin, brandPackage all follow the existing per-host shape.
@osage/brand is unpublished (~/work/osage/brand v0.1.0 needs build +
publish). Until then, /brand/@osage/brand/brand.json 404s and the
SPA's loadBrand falls back to the @hanzo/brand default — adequate
until osage.id DNS flips off Cloudflare Pages.
Per user clarification, the Zoo identity domain is zoolabs.id (zoo.id is
not owned). Add tenant entry + Ingress host + TLS secret. DNS A record
already repointed in CF to 129.212.164.5.
Brand-neutral CSS class names match the public-facing identity of the
portal (id-portal). Cosmetic only; no rendered text changes. Per
playwright agent's source-view note.
Add iam.lux.network + id.lux.network (Lux brand) + id.zoo.network
(Zoo brand) to the id Ingress + tenant catalog. Same image, same code
path — only DEFAULT_TENANTS + Ingress hosts/tls grow.
Cutover plan (post-merge):
1. kubectl apply -k infra/k8s/id (cert-manager issues 3 new TLS certs via DNS-01)
2. Repoint CF DNS A records → 129.212.164.5
3. Delete the 3 hanzo-id Worker routes
4. Archive hanzoai/hanzo.id-worker repo (no routes remaining)