One family. Font stacks name Zen and fall back to the native faces, tokens name the role rather than a face, and the packages that used to supply a face now point at @hanzo/font. Self-hosted @font-face blocks are removed rather than renamed: rewriting the family in one would leave a src pointing at a file this repo no longer ships. @hanzo/font carries the binaries and declares the faces, so the local copies go with the declaration that used them. Comments naming a face we no longer use are deleted, not reworded. Co-authored-by: Hanzo Dev <[email protected]>
99 KiB
LLM.md — Hanzo ID
The account surface, and the two doors a cookie cannot open (0.2.79)
hanzo.id could sign you in and then had nothing for you to do. /account is now
a real settings surface — profile, security, organizations, applications — built
from what IAM actually implements, and honest in the places where it does not.
SIGNED OUT IT HAS EXACTLY ONE CONTROL. This page is a relying party of the
issuer that serves it, so the way in is signinRedirect() and nothing else:
measured on the built bundle, one button reading "Continue with Hanzo ID" and
zero password fields. The credential form stays at /login, which is the
ISSUER's screen and the one place a credential is ever typed. Two doors to one
session is how a phishing surface gets built by accident.
What a person can actually change, and it round-trips. The consent answers
are self-scoped by construction — PUT /v1/iam/consent takes the subject from
the caller and never from a body field, because an answer somebody else can give
on your behalf is not consent. Driven against production on a live account:
training false → true, persisted across a reload, then restored. The write
is read BACK rather than assumed, so a save that silently did nothing cannot
leave the switch looking moved.
The identity fields are READ, and the page says so. IAM has no self-service
profile write: update-user is admin CRUD and refuses a regular user on their
OWN row deliberately (internal/authz/authz.go, the self clause is gated to
GET) so a self-write cannot carry isAdmin or organization with it. Eight
disabled inputs would dress that up as a form that is merely busy. One sentence
naming where the change is made is the honest shape until IAM opens a bounded
profile door. See the gaps below.
THE GUARD READS A BEARER AND NOTHING ELSE. This is the finding worth keeping. IAM's doors split in two and the split is invisible from the call site:
PUBLIC group, self-authenticating (`callerOf`: cookie, then bearer)
/v1/iam/account /linked-accounts /auth/methods /consent /preferences
/webauthn/signup/{begin,finish} <- a passkey can be REGISTERED
AUTHED group, behind authz.Guard -> principal() -> errNoBearer
/v1/iam/memberships /webauthn-credentials (+/delete)
principal() has no cookie path, so a portal sign-in — which mints only the
session cookie — gets 401 on exactly two reads: the org list and the passkey
list. Measured: 200 /account, 200 /linked-accounts, 200 /auth/methods,
401 /memberships, 401 /webauthn-credentials. The client attaches a bearer
when the SDK holds one and says so plainly when it does not, rather than showing
the word "unauthorized" to somebody who is plainly signed in. A failed read is
not an empty list: keys stays null on failure so "No passkeys yet." cannot
appear beside the reason it could not be read.
An automatic bounce through signinRedirect() was built to close this and then
REMOVED. It worked, but it put a redirect on the fleet's most sensitive surface
to satisfy two reads, and it carried the tokens back on the query string. A
loop here costs more than a missing list. The gap is IAM's to close.
One switcher, and it is the shell's. @hanzogui/[email protected] OrgHeader
carries the chrome — H-mark, breadcrumb, app launcher, org switcher, account
menu — so this page draws no header of its own. It is mounted for hanzo ONLY:
the bar draws the Hanzo H-mark and exposes no brand slot, and this image serves
lux.id, zoolabs.id and pars.id byte-identically. A shared header is worth having,
but not at the price of putting one company's mark on another company's account
page — the same breach class as the favicon in 0.2.68. Other brands keep Mark.
The org list is IAM's, not the token's. Both exist, and the orgs claim is what
the edge authorizes X-Org-Id against, but a claim is a snapshot taken at mint —
a membership added five minutes ago is invisible until the next refresh. One read
serves both the header's switcher and the Organizations section. Switching writes
hanzo_iam_current_org, the key the SDK defines and every surface reads.
Applications are in the CATALOGUE's order, and the page says that too.
Ordering by recent sign-in needs a per-user, per-application timestamp and IAM
keeps none: lastSigninTime is a column with zero writers, no login writes an
audit row (schema.PlatformWritten reserves five actions, none of them a
sign-in), and the session row is per-application and admin-only to read. A list
sorted by a value that is always empty is arbitrary order wearing a promise.
Two measurement traps, both of which produced a confident wrong answer.
- A fullPage screenshot destroys mobile emulation. Taking one on an emulated
touch context resizes the viewport to capture the document, and what it
restores has lost
mobile— sopointer: coarsestops matching and every rule written for a finger drops out of the cascade FOR THE REST OF THE RUN. The 44px floor read 36px while the built CSS was correct andmatchMediasaidcoarse=false; only the first tab measured was honest. Measure, THEN photograph. - A cookie-authed read cannot be checked from localhost. ACAO is exactly
https://hanzo.id, so the harness servesdist/from INSIDE that origin and lets/v1/*and/config.jsonthrough to the real server.
Two of this surface's own controls were genuinely under the floor and are fixed
(.hanzo-id-linkbtn 30px, the section-head button 36px, both scoped to
pointer: coarse like the input's 16px rule). The consent switch was 18x18
because the stylesheet's note says its target is "the whole row" — true
wherever a label wraps it, false where the control sits alone in a settings row.
Toggle IS that label, so the note is true by construction now.
A connected account was offered for connection. The two sides name one
provider differently — a link is the connector COLUMN (google), the catalogue
answers with the record and its type (provider-google, Google) — so "Connect
Google" rendered directly under the connected Google account. One spelling, both
sides, and either name counts as a match.
Verified against production as [email protected], built bundle, 1440/834/390:
9 profile rows of real data, 5 security cards, 3 password fields, the org row
hanzo / admin / Active, App+Chat+Cloud links and billing.hanzo.ai answering
200. No horizontal overflow at any width; coarse=true on every pass; the
only controls under 44px are the shared footer's Terms/Privacy at 41px, which
predate this and render on eight other pages.
pnpm -r tc 7/7, pnpm test 32 files / 295 tests.
Shipped as ghcr.io/hanzoai/id:0.2.79, digest
sha256:71c5d3aa6d81e2c1d11401e5b8d11179c93967e9d406d25d3b70e82c71cee649, which
sha-03038ef-amd64 resolves to as well — the same bytes under both names, so the
image is provably this commit. 0.2.79, not the 0.2.78 in package.json: imgver
publishes max(declared, published) + a patch, and 0.2.78 was taken by the commit
this one sits on. Pin the tag AND the digest together in
universe/charts/app/values/hanzo/id.yaml.
What IAM is missing for this surface
- A self-service profile write. No door sets
displayName,avatar,bioorphonefor the caller.update-userrefuses self by design and should keep refusing; the shape that fits besideconsent/preferences/passwordis a bounded self-scopedPUT /v1/iam/accountover a fixed field set, withavatartaking theschema.AvatarRefit already validates (https or a data URI, 96 KiB). IAM stores no blobs and there is no user avatar endpoint at all —POST /v1/iam/organizations/avatarexists for orgs only. - A cookie path, or self-scoped twins, for
/membershipsand/webauthn-credentials. Both are unreachable from an issuer-hosted page holding only the session cookie. The passkey ceremonies are already on the public group; the CRUD that lists what they created is not, and it is ORG-scoped besides — a non-admin's scope returns the whole org's passkeys, which this client filters to the caller rather than render a directory. - Per-app sign-in history, if "apps by recent sign-in" is wanted: a writer
for
lastSigninTime, or asigninaudit action.
Silent SSO is the issuer's, so the login page stopped asking (0.2.73)
The hosted login page opened every sign-in with POST /v1/iam/login
{type:'code', application, autoSignin:true} — a bid to mint an authorization
code from an ambient issuer session — and IAM refused it 400 login_required.
That line sat in the console of every signed-out visitor, permanently, wearing
the same shape as a real credential failure. An alarm that is always on costs
somebody an hour during an actual incident.
It could not have succeeded. Single sign-on runs one hop upstream: since
iam 3a7bf2290 (2026-08-03, "a session that exists is an answer, so the second
app stops asking") /v1/iam/oauth/authorize calls silentGrant and, when a live
session answers, 302s straight to the app's redirect_uri with a code — this page
never loads. Measured against production: with a session, authorize redirects to
the callback and /login/oauth/authorize is never fetched. Landing here already
means silent SSO declined, so the mount POST re-asked a question the server had
just answered no to. The SPA leg (0.1.26) was written when authorize did not yet
answer it; it has been redundant since 2026-08-03.
Where it COULD still succeed it should not have. prompt=login and max_age
are how a relying party demands fresh proof before something sensitive; IAM
honours them by SKIPPING its silent branch (!p.interactive()) and forwarding
the prompt here for a screen. The mount POST then minted from the same ambient
session anyway. Both measured against production against a live session:
prompt=login and max_age=0 each returned a code with no screen shown and
zero password fields rendered — the re-authentication silently not performed.
So silentLogin and SilentLoginRequest are gone, and with them the client's
second implementation of single sign-on. One home: authorizeHandler →
silentGrant. The page renders the credential form immediately, which also
retires the "Signing you in…" holding screen that used to cover a doomed round
trip. apps/web/src/pages/Login.test.tsx mounts the page at the exact authorize
URL production redirects to and pins it: no write of any kind before a person
acts, and prompt=login gets a form. Both go red against the previous source.
That test file also replaces Login.signup-hint.test.ts, which read Login.tsx
as TEXT and regex-matched its branch ordering — green with the component broken,
red on a rename, which is what vitest.config.ts says the DOM environment was
added to retire. Its third assertion ("the signup hint must lose to silent SSO",
so a returning customer is not sent to registration) needed no replacement: a
browser holding a session is answered at the issuer and never reaches this page.
The three sections below that build the client silent leg — 0.1.26, 0.2.2/0.2.3, 0.2.44 — are SUPERSEDED. Do not restore a mint on this page.
The wordmark leads, the mark closes, and lux.id stopped drawing Hanzo's favicon (0.2.68)
A WHITE-LABEL BREACH, and the fix for it was already running. index.html
hardcoded three same-origin icons — favicon.svg, favicon.ico,
apple-touch-icon.png — and all three were Hanzo's. That file is served
byte-identical to hanzo.id, lux.id, zoolabs.id and pars.id, so every host drew the
Hanzo mark in its tab. App.tsx DID swap the SVG from the resolved brand, and it
was changing a link nothing read: sizes="any" on the .ico makes it the
browser's preferred candidate over a typed SVG, so the .ico won the pick and the
swap was invisible. That is why this looked unfixed while the code that fixes it
was live. One icon link now, href="data:," until the brand resolves — a blank tab
beats another brand's mark — and the three Hanzo assets are deleted from public/.
The corner is TYPE, the foot takes the MARK. Mark draws the product name
("Hanzo ID", "Lux ID") from the type scale and reads no brand asset at all; the
footer reads logoUrl and closes the page with the brand's symbol.
Setting the corner in type removes the axis that made it unruly. Brand SVGs are
authored at different intrinsic sizes — Hanzo's logoUrl is a 1:1 square, Lux's is
63x17 — so ONE declared height rendered nearly four times the ink on one host as on
another, and no single number was right for both. A word has no intrinsic size, so
one rule now draws every host, with nothing to fetch and nothing to fall back from.
BrandContract.wordmarkUrl (added in @hanzo/brand 1.4.6, separated from logoUrl
in @luxfi/brand 1.1.2) therefore has no reader here. The field stays in the
contract — it is the brand registry's, not this repo's — and the distinction it
draws is still real for any surface that wants a logotype.
The mark moved out of the footer into the shell. It is chrome and all nine
pages carry it, so it was nine imports and nine elements kept in step by hand.
App.tsx renders it once, top left, position: absolute against body — not in
flow, because .hanzo-id-page is already min-height: 100dvh and anything above it
adds a scrollbar to every page.
The favicon is the mark keyed in black, with no box (@luxfi/logo 1.0.8). A
favicon does not choose what is behind it: browsers paint the active tab white in
light mode, so the plain white triangle was invisible on half of them. It was solved
with a black rounded rect, which gives the mark its own ground at the cost of
putting a second shape in a 16px square — at that size the box is most of what you
see. A thick keyline does the same job with nothing added. Thick because at 16px a
hairline lands under one device pixel and the triangle closes into a grey lozenge.
getFaviconSVG() also carried its own copy of the triangle on a 64-box, so the one
place the geometry lived had a rival; it draws TRIANGLE now, and svg/ is
generated from the same functions rather than hand-maintained beside them.
Measured live on all four hosts after deploy, 0 page errors, 1 icon link each:
lux.id favicon lux-favicon.svg corner lux-wordmark-white.svg foot lux-icon-white.svg
hanzo.id favicon favicon.png corner logo.svg foot logo.svg
zoolabs.id favicon favicon.svg corner wordmark.svg foot logo.svg
pars.id favicon favicon.png corner text:"Pars" foot (none)
pars.id renders a text wordmark because @parsdao/brand ships no assets —
assets/logo/logo.svg and assets/logo/favicon.png are both 404 on the CDN. The
text fallback and the absent footer symbol are the degradation working, not a bug
here; the gap is in that brand package.
One door, and a footer that names a company nobody had declared (0.2.63)
The page said "Sign in to Hanzo ID" and was already both doors. Every entry
in the strip finishes a first-time identity as readily as a returning one — IAM's
federation callback links or provisions — so the heading named half of what the
page does and sent new people hunting for a second page. It reads Login or
Signup now, and the credential CTA is Continue, matching the verb every
sibling in the strip already carried (intent==='signup' ? 'Sign up' : 'Continue'). The /signup link stays: password and code both go through
resolveLoginUser, which resolves an existing identity or fails, so those two
arms genuinely cannot provision and the link is the way through for them.
termsUrl and privacyUrl were typed, plumbed and declared by nobody. Both
sit in OrgConfig, both pass through org.ts's catalog list, both have tests —
and no row in universe/infra/k8s/id/configmap.yaml sets either, so the legal
links rendered on ZERO hosts. A field that is fully wired and never supplied
reads as working from every direction except the screen.
A brand is not a company, and the brand packages carry no company at all.
brand.json has name/title/description/logoUrl/faviconUrl and nothing legal, so
printing brand.name in a footer would put a trademark where a legal entity
belongs — and "Zoo" + " Inc" is not Zoo Labs Foundation Inc. company()
(pkgs/shared/src/brand.ts) is the one table that knows, keyed off the same short
brand idBrandLabel resolves, so lux.id and id.lux.network reach it identically.
Per ORG, not per host, for the reason idOriginFor exists: an org answers on
several hostnames and a fact copied onto each row drifts — that IS the
oauthCallbackOrigin bug recorded below. A catalog entry still overrides the
links per host.
Every URL in that table was FETCHED, and one of the three orgs publishes neither page. This is the part worth keeping:
hanzo.ai/terms 200, <title>Terms of Service — Hanzo AI</title> real
hanzo.ai/privacy 200, <title>Privacy Policy — Hanzo AI</title> real
zoo.ngo/terms 200, 27,642 bytes, "Terms of Use" + "Last Updated" real
zoo.ngo/privacy 404 — and the terms page's own Privacy link is /terms
lux.network/terms 200, 112,948 bytes — IDENTICAL to /zzz-control-404
lux.network/privacy 200, 112,948 bytes — IDENTICAL to /zzz-control-404
A 200 from an SPA host proves nothing. lux.network answers 200 for every
path, so both legal URLs "existed" until a nonsense control was fetched beside
them. Two methods failed before content-length did: status alone (200/200/200),
and a sha of the body — lux.network varies per request, so the control's own hash
changed between two fetches and every path looked "distinct". Compare the BYTES
and grep for the words the page would have to contain. So lux declares a name
and no links, zoo declares one link, and each renders only what it has.
An org absent from the table gets null and the line does not render: pars.id,
id.bootno.de and osage.id are live hosts with no company, and a footer naming the
wrong company is worse than one naming none. brand.test.ts walks every built-in
host AND the catalog-only ones (zoolabs.id is Zoo's only host and has no built-in
row) to the company it lands on, asserting the nulls too so an omission stays a
choice.
Phone is last, and now the DOM says so. PROVIDER_ORDER ended in phone and
a unit test pinned the constant — but the constant is not the render, and the
JSX that maps it is what an edit would break. SocialButtons.test.tsx mounts the
strip with a credential form present and asserts
['google','github','web3','phone'] in document order.
Verified in Chromium against the BUILT bundle, /config.json intercepted with a
production-shaped catalog so resolveOrg runs for real, one pass per brand:
hanzo h1='Sign in' 'Hanzo AI Inc, 2026' 'Terms | Privacy'
lux h1='Sign in' 'Lux Industries Inc, 2026' (no links line)
zoo h1='Sign in' 'Zoo Labs Foundation Inc, 2026' 'Terms'
pars h1='Sign in' (no legal line) (no links line)
with the footer in order legal → links → mark, flex-direction: column,
align-items: center, text-align: center, and the CTA reading "Continue".
pnpm tc 7/7, 25 files / 246 tests.
Shipped as ghcr.io/hanzoai/id:0.2.63 = sha-a73dd48-amd64, one build under two
names (same digest 0719868310), pinned in
universe/charts/app/values/hanzo/id.yaml by the sha tag — the semver lane runs
ahead of that pin, so the sha tag is what names the commit.
Harness note: python3 -m http.server has no SPA fallback, so /login 404s and
the page renders the server's own error document — h1 came back "Error
response", which reads exactly like a broken bundle. Serve dist/ with a
fallback to index.html for any path with no extension.
The door IAM knocks on, and a key that could not be a provider (0.2.52)
The second factor for a social sign-in had no page. IAM owns /login/mfa
(internal/oidc/federation.go::PathMfaVerify) and redirects there when a Google
or GitHub sign-in resolves someone who owes a factor: it mints nothing, parks the
whole authorize request in a single-use subject-pinned challenge, and sets the
challenge id as an httpOnly cookie. App.tsx had no such route, so the address
fell through path.startsWith('/login/') to the credential form — measured on
prod, https://hanzo.id/login/mfa renders "Sign in to Hanzo ID" with a username
and a password and no code field — and grep -c federation/mfa was 0 across the
repo and the shipped bundle. Nothing redeemed the challenge, the pinned resume
expired unused, the relying party got nothing, and retrying looped. Anyone with
2FA enrolled could not sign in with a social provider at all.
apps/web/src/pages/Mfa.tsx + client.federationMfa answer it, and the route
sits ahead of the /login catch-all where DeviceApproval's already does. The body
carries the FACTOR ALONE — IAM pinned the account, the client and the
redirect_uri, so there is no field here that could swap them — and the answer is
the finished redirect_uri?code&state IAM built from what it pinned, which is why
the page navigates rather than choosing a destination of its own. That is also why
it is a separate call from mfaChallenge: the password resume re-sends the
authorize request because the browser owns it, and this browser has never seen it.
What that endpoint verifies is TOTP and recovery codes; the page offers TOTP, matching the password challenge step. An account whose only factor is SMS or email still cannot finish a federated sign-in — IAM's federation resume accepts neither, and the page has no way to ask for a code to be sent. Recorded, not fixed here.
web3 is no longer a key in PROVIDER_META. The wallet's slot is filled from
the capability (walletChains), and that table is what the provider loop admits
by — so while the key sat there, a provider-web3 row that was switched on with a
real-looking credential could enter the FEDERATED map, and the only thing that can
then happen to it is provider_hint=web3 being named on the authorize endpoint,
which refuses it ("provider is not a supported federation type") and
error-redirects the app: an OAuth failure where the person expected their wallet.
Leaving the key out makes that unreachable instead of merely unlikely. Nothing
renders from it — the wallet entry draws its own icon and label.
Verified by mounting both components (Mfa.test.tsx, and two cases added to
SocialButtons.test.tsx), and in a real browser against the built bundle:
/login/mfa renders "Two-factor authentication" with one
autocomplete=one-time-code field and no password field, and submitting posts the
factor and lands on redirect_uri?code&state.
Harness note, because it cost an hour: frontDoor() hardcodes https:// and
strips the port, so any catalog entry marked canonical sends a plain-http local
harness to https://<host> and the page dies as a navigation error that reads as a
broken bundle. Omit canonical when serving dist/ locally.
The sign-in surface offers what IAM can complete (0.2.48)
Two descriptors, two questions. Do not conflate them again — conflating them is what made wallet sign-in unreachable.
GET /v1/iam/get-app-login what this APPLICATION offers
enablePassword · enableCodeSignin · enableSignUp
· its social providers · owner/name
GET /v1/iam/auth/methods what this BINARY can do
web3 + web3Chains (schema.WalletChains)
IAM masks each application switch with the capability behind it before answering
(loginView, internal/oidc/frontdoor.go): a code it cannot deliver comes back
false. So the client reads the descriptor and draws exactly that — no local
switch, no second predicate. AppLogin.enableCodeSignin / .enablePassword were
parsed, typed and then DROPPED before this, which is how a fully-built server-side
code arm stayed invisible.
Wallet is the exception that proves the rule: it is not in the provider list and
never will be. The seeded provider-web3 row is category "OAuth" with the
unexpanded clientId ${IAM_WEB3_CLIENT_ID}, IAM's offerable strips it, and all
80 apps that link it set canSignIn:false — while /v1/iam/web3/nonce answers a
real CAIP-122 challenge on seven families. client.walletChains() asks
auth/methods and intersects with @hanzo/id-connect's CHAINS (what this bundle
can actually sign): server-accepted ∩ browser-signable, so the screen can neither
offer a chain the verifier refuses nor hide one it accepts. ENABLED_WALLET_CHAINS
is gone — it was a copy of a server policy living in the browser, and it had
already drifted to two of five.
PROVIDER_ORDER stays the ONE order for the whole strip, wallet slot included.
One identifier, three kinds. IAM resolves name → email → phone
(resolveLoginUser), so the field posts the identifier plainly as username and
the email/phone toggle changes only the LABEL, inputMode and autoComplete. Do
NOT normalize the number in the browser: GetUserByPhone already does, and a
second normalizer is how two spellings of one number stop agreeing.
One credential per request, and which one IS the arm. IAM reads a code where
a password goes and never reaches the password check when one is present, so the
code arm inherits the MFA gate, device approval and the PKCE tail by construction.
signinMethod matched no field on IAM's loginForm and is deleted.
The OTP send is the one call that is NOT JSON. send-verification-code reads
its fields with fiber's FormValue (HIP-0111 §4), so sendCode posts
application/x-www-form-urlencoded dest/type/applicationId and reads the
envelope's msg BEFORE branching on the status code. Posting JSON made IAM answer
"missing parameter: type" and the recovery page render the literal string
"HTTP 400". One send serves recovery AND code sign-in — same endpoint, same
record, keyed on the identifier string both legs must carry identically.
Recovery has no completion step and is not this repo's to add. IAM mints a 6-digit code and no link, and has no endpoint that sets a password from one. So /forget names what arrives and points at the code arm, which IS the way back in; it refuses to mint when the app has no code arm to spend it.
Two primitives, because five forms had the same two bugs. Alert is one
region that RESTS in the DOM with a stable id (empty → display:none), pointed at
by aria-describedby, with aria-invalid on the fields — there were 17 hand-rolled
role="alert" copies and ZERO uses of either attribute. Submit never disables
itself: a disabled element cannot hold focus, so disabled={busy} threw focus to
<body> on every failed sign-in (measured live, twice). It reports with
aria-disabled (already styled) and the handler refuses re-entry.
The runner now has a DOM. vitest.config.ts collects *.test.tsx under
happy-dom; before this it collected only *.test.ts in node, so WHICH method
renders WHEN was unverifiable and a whole sign-in method went missing with both
gates green. Login.signup-hint.test.ts still reads Login.tsx as TEXT and matches
regexes — it would pass with the component broken; convert it when you touch that
page.
Verified: pnpm tc 7/7, pnpm test 18 files / 213 tests, and the BUILT bundle
driven in Chromium against a mocked IAM — Connect Wallet renders with no web3 row,
the chooser lists exactly evm/solana/bitcoin/ton/xrp, a refusal keeps focus on the
submit and resolves aria-describedby to its own text, the phone toggle posts
+1 (415) 555-0134 unchanged, and the code arm posts code with no password.
Mobile floors and a hover that stopped painting a wireframe (0.2.23)
A styling audit of iam.hanzo.ai. The headline finding is a negative, and it is
the useful part: this surface does NOT have the "classes without rules" defect
that broke hanzo.app, and structurally cannot. It uses neither Tamagui atomic nor
Tailwind — it is hand-written semantic .hanzo-id-* CSS. Measured in Chromium
against the served bundle, 7 routes x 3 viewports:
gui atomic (_bg- _dsp- _pos- _fs- _col- t_dark …) used 0 | rules 0
tailwind (flex items-center rounded-lg text-sm …) used 0 | rules 0
semantic .hanzo-id-* every rendered class resolves
The only classes with no rule are five page-variant markers
(hanzo-id-login/-signup/-forgot/-onboarding-page/-callback), each always paired
with .hanzo-id-page, which carries the layout. They are BEM-style hooks with
nothing to paint yet, not a defect — .hanzo-id-device and .hanzo-id-portal
are the same pattern with rules attached. Left alone deliberately.
An audit claim that was wrong, corrected here so nobody re-fixes it. It was
reported that a build with @hanzo/design absent "emits a bare unresolved
@import and every token vanishes with no error" — i.e. the hanzo.app failure
class, one pnpm install away. It does not. Removing the package and building
was tried: Vite's postcss-import hard-fails,
[vite:css] [postcss] ENOENT: no such file or directory, open '@hanzo/font/css', exit 1. The token layer cannot silently disappear
from this build. What was actually true is narrower: the dev tree had not been
installed, so tokens.test.ts could not run. It passes once deps are present.
Four real defects fixed, all in apps/web/src/app.css, all verified by
before/after measurement of the BUILT bundle in a real browser (not by a green
build — a green build is what let the original defect ship):
- Ghost-button hover painted a near-white wireframe. It set
border-color: var(--foreground)— #ededed, 17.9:1 on--background— on the two most-hovered controls on the page (Continue with GitHub / Google). Measured rest→hover:rgb(115,115,115)→rgb(237,237,237). Nowrgb(115,115,115)→rgb(115,115,115): the edge no longer moves and the SURFACE carries the state (--white-05→--white-10), which is what thetransitionon.hanzo-id-btnwas already animating. Why not simply a dimmer border: every rung brighter than--neutral-500is worse on white.--neutral-400measures 8.3:1 on black but 2.52:1 on white and would fail the same WCAG 1.4.11 floor the resting border is documented to hold in BOTH themes. No token gets brighter on dark and darker on light, so the edge must not encode state at all. The resting--border-strong(4.43:1, both themes) is untouched — that deviation from a low-alpha hairline is deliberate and documented, and a control boundary still needs 3:1. viewport-fit=coverwas declared and never consumed. Zeroenv(safe-area-inset-*)rules in the whole stylesheet;.hanzo-id-pagepadded a flat 24px, so content ran under the notch and home indicator. Nowmax(24px, env(...))per side — 0 insets compute to exactly 24px, so nothing moves on hardware without a notch (verified: page padding still24px).- The iOS-zoom comment described a protection the code did not implement.
font-size: var(--text-base) /* <16px makes iOS Safari zoom on focus */—--text-baseis 0.875rem = 14px, so both credential fields zoomed on focus and iOS never zooms back out. The scale has no 16px rung and should not grow one (16 is Safari's threshold, not a design value), so it is a literal scoped to@media (pointer: coarse); the desktop ramp is untouched. Verified under iPhone emulation: input 14px→16px,iosWouldZoomtrue→false. - Three tap targets under the 44px floor, including a logo link whose target
was SMALLER than the logo inside it: an inline
<a>takes its box from its own line box, not from a replaced child, so it measured 32x18 around a 32px mark. Fixed withinline-flex+ a padding/negative-margin pair; the footer links ("Forgot password?", "Create account", "Sign in", "Back to sign in") got the same treatment — vertical padding on an inline box is hit-tested but does not enter line-box height, so all of it is zero layout shift. Screenshots before/after are pixel-identical at rest; only the targets and the hover moved.
Measured, BEFORE → AFTER, built bundle served like prod, 7 routes x 390/768/1280:
tap targets < 44px 3 → 0 (iPhone-emulated, pointer:coarse)
ghost hover border #ededed → #737373 (17.9:1 → 4.43:1)
safe-area rules in CSSOM 0 → 1
@media rules 1 → 2 (added pointer:coarse)
input font-size (touch) 14px → 16px
horizontal overflow 0 → 0 (unchanged, all routes)
console errors / pageerrors 0 → 0
atomic + tailwind used|rules 0|0 → 0|0 (no regression)
CSS transferred 16,733 → 17,070 bytes (+337, +2.0%)
JS transferred 884,469 → 884,469 (unchanged)
Still open, deliberately not taken here (each is a finding against a layer below this repo, and one is already recorded above under 0.2.15):
BrandHeaderloads the mark fromcdn.jsdelivr.net/npm/@<brand>/brand@latest/ assets/logo/logo.svg— a third-party request pinned to a floating tag on the credential-entry path. ThelogoUrlcomes from@hanzo/brand's ownbrand.json, so it cannot be fixed here; the asset already exists locally atapps/web/public/brand/@hanzo/brand/assets/logo/logo.svg. Note the catalog'sbrandUrlis NOT fetched —brandPackageFromUrlonly parses it for the npm scope, and the brand JSON itself is same-origin/brand/<slug>.json.- 939 KB of decoded JS to paint a two-field form; the eager bundle still carries
WalletConnect/viem that the email+GitHub+Google path never uses. Code splitting
works (ccip/login/secp256k1 chunks are correctly not fetched on
/). - The 70 KB Geist woff2 is discovered only after CSS parse, with no
<link rel=preload>; the filename is content-hashed, so a correct preload needstransformIndexHtmlto read the real name out of the bundle. pkgs/shared/src/org.test.ts"the front door is read per org" FAILS on origin/main (hanzo.appvshanzo.chat), pre-existing and unrelated to this change — verified by running it on a pristine checkout. It belongs to the in-flight callback work; not touched.
The whole token layer, the ONE account control, and a gate on resolution (0.2.15)
0.2.14 adopted @hanzo/design and, in three places, worked around it. Those findings have been fixed IN @hanzo/design 0.3.0, so the workarounds are gone and this surface takes the system's answer.
-
ONE import:
@hanzo/font/css.app.csscherry-picked four of the nine token groups, so--z-*,--shadow-*,--space-*,--font-*and the element defaults did not exist here at all. Nothing broke visibly, because an unresolvedvar()paints nothing and reports no error — that silence is the whole defect. @hanzo/iam's account menu alone reaches for--z-popover,--shadow-floatingand--space-1..3. -
Geist is SELF-HOSTED in @hanzo/design 0.3.0, so the reason for cherry-picking is gone:
tokens/fonts.cssno longer requests fonts.googleapis.com and the sign-in path can take the typeface with the colours. Measured on the built bundle: rendered face is Geist, served from/assets/Geist-Variable-*.woff2, zero third-party font requests. -
The focus rule is DELETED from this file, and it stays deleted.
tokens/base.cssships:focus-visible{outline:2px solid var(--ring)}to every consumer;--ringisvar(--white-40)in the 0.4.x line, measured2px solid rgba(255,255,255,.4)= 3.77:1 on--background(WCAG 2.4.11 wants 3:1). Do not restore a local override — the last one existed only because--ringwas 1.66:1, which is fixed upstream.Until @hanzo/design 0.4.9 that layer carried a SECOND rule, a field-specific
:where(input,select,textarea):focus-visiblethat suppressed the outline and drew a brightened edge + halo instead. Both rules computed to (0,1,0) —:where()zeroes what it wraps — so the cascade fell through to source order, the generic ring was written later, and it overrode theoutline:nonethe field rule stated to prevent it: every focused input here drew BOTH indicators. Worse, the treatment it was trying to apply could never have worked on this surface — its indicator rode onborder-color, and.hanzo-id-inputstatesborderunlayered, which beats a layer whatever its specificity, so focused fields sat at the resting.15(1.47:1) while both stylesheets read as correct. 0.4.9 deleted the field rule; one ring now covers every focusable thing, and anoutlineis immune to theborderthis file declares.check-tokens.mjsfails the build if a second focus rule, anoutline:noneor a focusbox-shadowever returns. -
Control boundaries are
--border-control(0.4.2 onward; this bullet read--border-strongwhen 0.3.0 briefly cut the control rungs from the neutral ladder). 0.4.2 put them back on alpha —--border-control.15,--border-focus.22,--border-selected.30 — because an edge that clears 3:1 on a near-black page is a mid-grey box, and a form of them reads as a wireframe rather than a surface. The contrast budget moved to--ring, the focus indicator: the one boundary a keyboard user actually navigates by, and the only one still pinned at 3:1.--border-strongsurvives, but its duty is hover and emphasis — it is decoration, and a control must not reach for it. -
The signed-in portal mounts
<UserMenu>from @hanzo/iam (bumped 0.13.1 → 0.21.1 inapps/web,pkgs/auth,pkgs/onboarding) in place of a hand-rolled "Billing / Sign out" link row. Identity comes fromresolveIdentity, the same resolution every Hanzo surface shows, so the portal cannot disagree with the console about who you are. Nobrandprop is passed: omittingmarkSvgwould put the HANZO mark on lux.id and zoo.id. -
Portal.tsx's inline
style={{…}}is gone. It carriedrgba(255,255,255,0.14),borderRadius 12,fontSize 13,fontWeight 600and two bare opacities — six invented values for facts the token layer already states..hanzo-id-apps/.hanzo-id-applinknow, class-keyed like the rest. -
apps/web/src/tokens.test.tsis the gate, and it tests RESOLUTION. "It is declared" was never evidence, and neither was "it type-checks": the reference is built at runtime from a string, so it is invisible to the compiler AND to grep. The test walksapp.css's@importgraph into the installed @hanzo/design, then asserts (1) every token group is served, (2) everyvar(--x)undersrc/resolves, (3) every token @hanzo/iam's bundle paints the menu with resolves. Verified by reintroducing both original defects: it names the 5 dropped groups and the 8 unresolved iam tokens.vitest.config.tsnow includesapps/**so it actually runs.
Measured in Chromium, fresh context, empty storage, 1440 AND 390, against the
built bundle served as hanzo.id / lux.id / zoolabs.id: 0 unresolved tokens of
76 referenced on every host; input edge and focus ring both 4.43:1; the account
menu paints #0a0a0a fill / 12px radius / --shadow-floating / z-index 700
(it was transparent before the iam fix). Brand switches with zero per-brand code:
hanzo.id → "Hanzo ID" + Hanzo apps, lux.id → "Lux ID" + Lux apps, and no Hanzo
mark reaches the Lux menu.
STILL OPEN, and they are findings against layers below this surface, not against
this repo: the menu's own panel edge is --border at 1.27:1 on --popover —
0.4.2 recut the control and focus rungs but left --border on the .10 rung, so a
floating panel still has no perceivable edge. And BrandHeader loads the brand
mark from cdn.jsdelivr.net/npm/@<brand>/brand@latest/... — a third-party
request pinned to @latest on the sign-in path, which is exactly what this file
refuses for fonts.
One token layer, and no control painted by its ancestor (0.2.14)
The portal is now styled from @hanzo/design tokens — the same token layer
hanzoai/pay renders from, so the two halves of one flow (sign in → pay) agree on
the page black, the type ramp, the radii and the greys. apps/web/src/app.css
invents no colour, radius or size; @hanzo/gui was declared as a dependency but
never imported once, and is removed rather than left as decoration.
- Surface is keyed to a CLASS the component carries, never to where it is
mounted. The stylesheet used to paint controls with the descendant selectors
form input {…}and.hanzo-id-btn, form button {…}.DeviceApproval.tsx— the screen a human hits to authorise the CLI — has 2 inputs and 0<form>ancestors, so its device-code field fell out of the stylesheet and rendered as raw UA chrome: 31px tall,#3b3b3b,2px insetbevel, square corners, beside correctly-styled 44px siblings. Every control now carries.hanzo-id-input,.hanzo-id-btn,.hanzo-id-fieldor.hanzo-id-form, and there is not one element-descendant selector for surface left in the file. Same class of defect as a component library shipping utility class names with no CSS behind them — the mirror image, bare elements instead of bare class names. .hanzo-id-spinnerhad no rule at all. The loading state measured 0px and was invisible on hanzo.id, lux.id and pars.id at once. It is a real 28px ring now, verified rendering and animating.- ONE focus indicator. The file had exactly one focus rule (
form input:focus), so every button, link and social entry fell back to Chrome'soutline: auto— a blue ring on a monochrome surface.:focus-visibleis now global, 2px--primary. Note--ring(#333333) measures 1.66:1 on--backgroundand cannot carry a focus indicator; that is a finding against @hanzo/design, not a licence to invent a value. - ONE button.
.hanzo-id-social-btnand the.primarymodifier are gone:.hanzo-id-btnIS primary,.ghostis the secondary surface (social sign-in, Skip/Back). 13 treatments → 1 primitive with 1 modifier. font: inheriton every control. Buttons and inputs rendered in Arial while headings rendered in the platform face — two typefaces in one 432px card, including on the "Sign in" CTA.- Control borders are
--white-40, deliberately. On--backgroundthe semantic--border(#1f1f1f) measures 1.27:1 and--border-strong(#404040) 2.03:1 — neither clears the 3:1 a control boundary needs (WCAG 1.4.11).--white-40measures 3.66:1 and is on the ladder. - Fonts are NOT imported from the design package.
tokens/fonts.csspulls Geist from fonts.googleapis.com; the sign-in path loads no third-party font.--font-sansresolves to the platform stack, the identical value hanzoai/pay sets. Self-hosting Geist would let both importfonts.cssunchanged.
Measured in a real browser against the built bundle (fresh context, empty
storage): page #000000, controls 44px, one 6px radius, h1 21px, white focus
ring, no control under the touch floor at 390px, no horizontal overflow.
This release also reunites main with production. The running image (0.2.13,
built from 9477777) was NOT on origin/main: the two had diverged at
7a72225f, with the self-service-signup fix (/v1/iam/onboard instead of the
admin-only add-organization verb) and the App/Chat/Cloud launcher live but
unmerged. A release cut from main would have silently regressed both. 0.2.14 is
the merge, so main is once again what runs.
One chain-agnostic "Connect Wallet" button — merge EVM + Solana entries (0.2.9)
The login page rendered ONE wallet button PER enabled chain (SocialButtons
mapped ENABLED_WALLET_CHAINS → "Continue with Ethereum / EVM" +
"Continue with Solana"), so a two-chain build showed two near-identical buttons
above the divider. Merged into a SINGLE chain-agnostic "Connect Wallet" entry —
the ENTRY is merged, both underlying flows are kept intact. (Rides on 0.2.8:
GitLab provider + vitest-unified test runner.)
- Detection is a pure function, in the ONE web3 module.
detectWalletChains()(pkgs/auth/src/web3.ts) is a purewindowsniff — EVM =window.ethereum, Solana =window.solana/solflare/backpack— that returns theENABLED_WALLET_CHAINSwith an injected provider. Derived from the enabled set (one source of truth); testable via an injectable window (no DOM, no connect, no I/O). Exported alongsideloginWithWalletChain. SocialButtonsrenders one entry. The web3 provider now renders a single "Connect Wallet" button (data-wallet-connect). On click,onConnectWalletcallsdetectWalletChains(): exactly one injected chain → connect straight (startWallet(chain), no chooser); zero or many → reveal an inline chooser (.hanzo-id-wallet-chains) of one button per enabled chain (data-chain=evm|solana) so EITHER chain stays reachable. Same monochromehanzo-id-social-btnstyle as GitHub/GitLab/Google; the chooser is indented under the entry. The per-chain path (startWallet→loginWithWalletChain) is UNCHANGED — only the button that reaches it is merged.- Verified. Auth unit tests green (3 new
detectWalletChainscases: single→[chain], both→[evm,solana], none→[]). Playwright against the dev SPA (get-app-login intercepted so web3 resolves): one "Connect Wallet" renders; click with no injected wallet → chooser shows Ethereum/EVM + Solana; click with an injectedwindow.ethereum→ NO chooser, straight into the EVM flow. - Backend untouched. Pure
idSPA change (frontend), no IAM edit. Ships asghcr.io/hanzoai/id:0.2.9; deploy = bump the operator CR image (universe/infra/k8s/operator/crs/id.yaml) by hand (id not in the gitops-reconcile allowlist). NEVER restart ingress (TLS-outage hazard).
The tenant catalog was never read — one key name, every host (0.2.21)
/config.json is served by hanzoai/spa, which derives its JSON key
mechanically from the env var we supply: SPA_IAM_TENANT_CONFIG_JSON
(ConfigMap id-tenant-catalog) becomes iamTenantConfigJson. The
TenantConfig→OrgConfig rename swept through this codebase and renamed the
READ too — cfg.iamOrgConfigJson, a key nothing emits. The fetch returned 200,
the field was undefined, resolveOrg fell back, and nothing logged. Verified
live: https://hanzo.id/config.json returns iamTenantConfigJson, and the SPA
was reading straight past it.
The env var is the interface and it is NOT ours to rename unilaterally — the
ConfigMap, the Deployment's envFrom and the spa image all speak it. Code
follows the interface, so the read moved to catalogOf in pkgs/shared/src/ org.ts, next to the resolver it feeds, pinned by tests that fail on the old
spelling.
Two live consequences, and the first is why this shipped with 0.2.20:
- hanzo.id authenticated as the wrong app. The catalog maps it to
hanzo-console(enableSignUp: true); with no catalog it fell to the built-inhanzo-id(false). Federation PROVISIONS a local user for a new identity, so a first-time GitHub user was refused "the application does not allow to sign up new account" — the 0.2.20 fix working perfectly and still failing the exact people it was for.enableSignUpitself needed no change; reading the catalog did. - Every catalog-ONLY host resolved nothing. osage.id, zoolabs.id,
id.zoo.network, id.lux.network, iam.lux.network, id.pars.network, id.bootno.de
and iam.hanzo.ai have no built-in entry, so they got
hostSkeleton— emptyclientId, no application. That the resolver fails CLOSED there is why it leaked no brand (the osage.id regression stayed fixed); it is also why it was silent.
Declared state was corrected to match: hanzo-console now lists
https://hanzo.id/callback in init_data.json. The live row already had it, and
seed.go deliberately keeps redirectUris off appPolicyKeys ("the registration
surface, which drifts legitimately and is owned elsewhere") so nothing reverts —
but redirectUris ARE applied on CREATE, and from 0.2.21 hanzo.id authenticates as
hanzo-console against that exact callback. Without the line, a REBUILT cluster
would come up unable to sign anyone in at hanzo.id.
The shape to remember: a rename is not finished at the language boundary. This one crossed into an env var, a ConfigMap key and a base image's templating convention, none of which the compiler or the type system can see — and the failure mode was a successful fetch of a field that wasn't there.
oauthCallbackOrigin was dead and is now DELETED (0.2.45). It existed so the
browser-built hop could target the shared iam.hanzo.ai/callback OAuth client;
with federation, IAM pins its own callback from the TRUSTED request host
(federationBaseURL → resolveIssuer(c.Host())), so the browser never chooses
a callback origin. Confirmed against production before deleting — the authorize
hop sends https://lux.id/v1/iam/oauth/callback on lux.id and
https://zoolabs.id/... on zoolabs.id, already correct and already per-brand.
The field survived in types.ts, org.ts, two fixtures, and on EVERY row of
universe/infra/k8s/id/configmap.yaml — thirteen rows, all reading
https://iam.hanzo.ai, i.e. one brand's hostname pinned onto every white-label
host, doing nothing. That is the cost of leaving dead config in place: it is not
inert to a reader, and its doc comment still stated the deleted rule, which is
the sentence that would talk the next person into rebuilding the hop.
The front door (0.2.45)
An org answers on several hostnames — lux.id, id.lux.network,
iam.lux.network — and each one rendered a complete login page. An alias was
never broken; it was a SECOND front door, and a second front door is a second
thing to brand, register with a provider, and remember.
One entry per org now carries "canonical": true and aliasRedirect sends every
other host there before render, path and query intact so a sign-in keeps the
OAuth request that sent it. Two exemptions, both load-bearing: /callback never
moves (a provider returns to the exact URI it was given; redirecting discards the
code mid-exchange), and the front door does not redirect to itself (that is what
a loop is made of). Both are pinned by tests.
It replaced a guess — a scan of DEFAULT_TENANTS for a host ending in .id,
which answered for hanzo/lux/pars and returned NOTHING for zoo, whose front door
is zoolabs.id and which has no DEFAULT_TENANTS row at all. Which host is the
front door is a fact about the ORG; the catalog is keyed by HOST; so the catalog
states it the one way a host-keyed table can, rather than a second table to keep
in step.
Social sign-in goes through IAM, because it always did (0.2.20)
GitHub sign-in reached GitHub, succeeded there, and then dead-ended: the user
landed back on hanzo.id and was told to sign in first. That message is IAM's
please sign in first — a 401 from /v1/iam/onboard, reached with no session
and no bearer. The SPA had never obtained a token.
The SPA was the relying party, and it cannot be one. social.ts built the
IdP URL in the browser (github.com/login/oauth/authorize, redirect_uri= ${origin}/callback) against a contract copied from an IAM fork whose front end
no longer exists. GitHub then returned a GitHub code to the SPA's own
/callback — and nothing can spend that code: exchanging it needs the client
SECRET, which a browser must never hold, and IAM has no endpoint that takes a raw
provider code. Callback.tsx POSTed it to /v1/iam/login, which does password,
device approval and code minting, and knows nothing about providers. So every
social sign-in ended authenticated at GitHub and anonymous here.
IAM already implements the whole flow and was never called.
internal/oidc/federation.go is a complete OAuth2/OIDC relying party. Naming a
provider on the authorize endpoint IS the entry point: authorizeHandler
validates client_id, the EXACT redirect_uri and the PKCE policy, then
beginFederation resolves the provider, mints a single-use transaction, sets a
browser-binding cookie and sends the browser to the IdP. The IdP returns to
IAM's fixed callback, /v1/iam/oauth/callback, where IAM — holding the
secret — exchanges the code, links or provisions the user, and mints an IAM
authorization code bound to the original PKCE challenge, redirect_uri and nonce.
OAuthAuthorizeRequest.provider had been declared in the SPA's own types the
whole time; authorize() simply never emitted it. The fix is that one parameter,
plus deleting everything that existed to work around its absence.
provider is the RECORD name — provider-github, never github.
federationProvider matches ProviderItem.Name exactly, and EnrichProviders
resolves that same name to the record, so the two are one string by construction.
Verified live: ?provider=github → invalid_request: unknown or unavailable provider; ?provider=provider-github → 302 to GitHub. A comment on
providerKey used to assert the opposite; it is now corrected in place. The bare
key is a DISPLAY key (icon, label, provider_hint matching) and nothing else.
Two arms, and they are the two the password path already had. The question is
only who owns the PKCE verifier (SocialButtons.hop):
- An app sent the user here (
redirect_urion the query) → re-enter authorize with THAT app's request viasocial.ts::authorizeRequest, so IAM mints the code against its client_id, redirect_uri and challenge and returns the browser straight to it. The app holds the verifier; this portal is never in the return path and never touches a token. Same branch asLogin.completeAfterAuth. - A bare portal sign-in →
createIam(...).signinRedirect({additionalParams: {provider}}). The SDK generates and persists the verifier in localStorage (txStorage, keyedhanzo_iam_code_verifier:<state>— localStorage, not session, deliberately, so it survives the full-page redirect), andhandleCallbackreads back that exact slot. IAM returns the APP's state (federationMintsetsstatefromAppState, not its IdP-leg state), so the SDK's state check matches.
Deleted, all of it dead once the browser stops being the relying party: the IdP
endpoint/scope table, buildProviderAuthUrl, startProviderLogin,
isHoppableProvider, encodeState/decodeState, client.providerLogin,
ProviderExchangeRequest, and Callback.tsx's provider branch. /callback now
has ONE case — an ordinary IAM code — because a federated return is
indistinguishable from any other.
Verified in a real browser against live IAM (local bundle, /config.json
pointing localhost at https://hanzo.id; port 5173 because
http://localhost:5173/callback is registered on hanzo-id):
- bare-portal click →
hanzo.id/v1/iam/oauth/authorize?…&provider=provider-github→ 302 → GitHub, withredirect_uri=https://hanzo.id/v1/iam/oauth/callbackand thehanzo_fedcookie (SameSite=Lax,path=/v1/iam/oauth/callback, 600s); a verifier slot appears keyed by state. - app-initiated click → same, and NO verifier slot is created — proof the app's own request was forwarded rather than the portal starting its own flow.
- return leg →
/callback?code=…&state=<stored>consumes the slot and POSTs/v1/iam/oauth/token, which answersinvalid_grant: invalid authorization codefor a deliberately fake code. The exchange is wired; only the code was false.
Not exercisable here: the GitHub login itself. See the registration note under "Social providers" below — GitHub defers redirect_uri validation until after sign-in, so an unauthenticated probe CANNOT tell a registered callback from an unregistered one (a deliberately bogus URL returns the identical 302).
Provider-hint auto-federation — click GitHub/Google downstream, land straight in the provider (0.2.6)
Clicking "Continue with GitHub/Google" on a downstream app (console.hanzo.ai)
used to bounce the user to the hanzo.id login FORM — the portal ignored the
provider the user already chose. Now it launches that provider immediately.
Three fixes. Points 1 and 2 still hold; point 3 and every mention of the "hop"
below are superseded by 0.2.20 — the hop is gone, the auto-launch now starts
IAM's federation like the button does, and method is not a parameter of it
(IAM's federation callback links-or-provisions on its own).
-
Login.tsxhonorsprovider_hint. The console SDK already appends&provider_hint=provider-github(the IAM recordname) to the authorize redirect. A newfederatephase: after a silent-SSO miss, if the hint is present it renders a HEADLESS<SocialButtons autoStart={hint}>that auto-runs the same hop the button runs — no form flash — and drops to the form only if the hint matches no configured provider. Honor ONLYprovider_hint, never bareprovider=(that carries the SSO SDK's<org>-iamIDP hint — see the single-provider-state note below).matchProviderHint(social.ts, pure + tested) maps the hint to a provider by record name / key (provider-githuborgithub, case-insensitive). -
getAppLoginvalidates against the DOWNSTREAM app's own redirect_uri.getAppLogin(clientId, redirectUri?)now sends the incoming OIDCredirect_uri(read from the query), not the hardcoded${publicOrigin}/callback. IAM validates it against the app's registered list:hanzo-cloud(the console's client_id) registersconsole.hanzo.ai/auth/callbackbut NOThanzo.id/callback, so the old hardcode madeCheckOAuthLoginanswerstatus:error("Redirect URI … doesn't exist in the allowed list") and the SPA dropped the whole response (status!=='ok'→null) → NO providers resolved. This blocked the social buttons for EVERY cross-app SSO read, not just auto-federation.SocialButtonsreadsredirect_urifromwindow.location.search(same pattern as the wallet path); absent (bare portal / device flow) it defaults to the portal callback. -
The interactive hop uses
method=signup(find-or-create-login). IAM runs find-or-create only undersignup(controllers/auth.go:1041: existing 3rd-party identity → sign in, else create).signinis the account-LINK branch (auth.go:1257:GetSessionUsername()==''→ResponseError("user doesn't exist")) — it needs a live session and errors on a fresh "Continue with GitHub". The oldintent==='signup'?'signup':'signin'sentsigninfrom the sign-in page; both intents now usesignup(intent only changes button copy). Latent bug — never exercised end-to-end before this.
Contracts locked in pkgs/auth/src/{social,client}.test.ts. Deploy: image
ghcr.io/hanzoai/id:0.2.6, operator CR universe/infra/k8s/operator/crs/id.yaml
(the id CR did not exist in-cluster before — id ran off the raw
deployment.yaml; applying the CR handed the Deployment to the operator, safe
because id carries no env, only envFrom: id-tenant-catalog). id is NOT in the
gitops-reconcile allowlist → apply the CR by hand. Rides on 0.2.5 forced-MFA.
Silent SSO must be org-scoped — admin-guard god-mode fix (fixed 0.2.2 → 0.2.3) — SUPERSEDED by 0.2.73, do not restore
admin.hanzo.ai (global-admin console) sits behind admin-guard, a Traefik
ForwardAuth that allows ONLY owner == admin tokens. Login rides
client_id=hanzo-admin-guard (org=admin). The 0.2.2 credential-form fix
(LoginForm posts app.organization from get-app-login → org=admin →
IAM resolves admin/z, owner=admin) is CORRECT and verified: posting
organization=admin to /v1/iam/login resolves the admin/* row and
get-account returns owner=admin.
But it "didn't take effect end-to-end" because Login.tsx (0.2.2) added a
silentLogin SSO fast-path (canSilent = client_id && redirect_uri) that,
on mount, minted an auth code from the AMBIENT iam_session_id session
REGARDLESS of that session's org. Real operators carry a hanzo/* session
(from hanzo.chat/console), so silentLogin minted an owner=hanzo code and
the guard bounced them to console.hanzo.ai — the org-scoped form was never
shown. Silent SSO shadowed the form fix.
Fix (pkgs/auth/src/client.ts, silentLogin): silent SSO may reuse the
ambient session ONLY when its user org == the app's org. silentLogin
resolved the app org (get-app-login) + session owner (get-account, an
internal sessionOwner() helper) before minting; on no session or an
org mismatch it returned {} so Login.tsx fell back to the interactive
form (which authenticates in the app's own org). Cross-org (hanzo session →
admin-guard) → form → org=admin → owner=admin → god-mode.
Superseded by 0.2.44 — the org gate moved back to the server. The invariant above still holds; the client no longer implements it. See "Silent SSO refused every customer" below.
Silent SSO refused every customer after onboarding (fixed 0.2.44) — SUPERSEDED by 0.2.73, do not restore
Reported as: signing in at hanzo.id, finishing onboarding, then arriving at
pay.hanzo.ai — and being asked to sign in again. /login/oauth/authorize
rendered the credential form for a browser that already held a live session.
The client-side org gate above was a copy of IAM's tenant rule with both of
its exemptions dropped. The server rule is the ONE mint path,
internal/oidc/mint.go:
if org != app.Organization && !app.IsShared && app.OrgChoiceMode == "" {
return "", errors.New("the user is not permitted to sign in to this application")
}
hanzo-app carries orgChoiceMode: "create" precisely because its users
found their own orgs — and onboarding then MOVES the caller into the org it
just created (internal/oidc/provision.go: "Onboarding MOVES the caller into
the new org", user.Owner = cl.slug). So from the first completed onboarding
onward every real customer had owner != "hanzo", the client refused a mint
the server would have signed, and silent SSO fell through to a form. It was
invisible to staff, whose owner stays hanzo.
Fix: delete the client-side gate (and the getAppLogin + sessionOwner
pre-flight it needed). silentLogin is now ONE request — attempt the mint,
let IAM answer. Refusals arrive as {error} and Login.tsx renders the form
exactly as before. Measured against production before the change: org=admin,
org=lux and org=zoo apps all answer "the user is not permitted to sign in to
this application", and no session answers login_required — so the
admin-guard invariant is unchanged and is now enforced where it is defined.
A client cannot hold a tenant rule that moves with app config it does not own.
Social login (GitHub/Google) — single-provider state + matched redirect_uri (fixed 0.1.24 → 0.1.25)
Superseded by 0.2.20. Bug A (read the provider identity from the NESTED record) still holds and still ships. Everything below about the base64
state,buildProviderAuthUrlandproviderLogindescribes the browser-side IdP hop, which is gone — the browser no longer builds an IdP URL or handles a provider code, so neither bug can recur. Kept because it records how the flow was misdiagnosed twice: each fix made the SPA a slightly better relying party, when the SPA could never be one at all.
The social hop used to fail at the IAM /callback exchange — GitHub with
"The provider: hanzo-iam does not exist", Google with "password or code is
incorrect". TWO independent bugs, both in the SPA's provider-name handling; the
IAM backend, the OAuth creds, and the registered redirect_uri were all fine.
Bug A — wrong provider IDENTITY (fixed 0.1.24). parseAppLogin
(pkgs/auth/src/client.ts) read the outer IAM app-provider LINK name
as the provider identity. get-app-login returns each provider as a link object
{name, canSignIn, …, provider:{name, type, clientId, …}}; the REAL identity is
the nested provider.name (e.g. provider-github), the name the backend
resolves with GetProvider(admin/<name>). The outer link name can be a
per-app label. Fix: derive from the nested rec.provider.name, falling back
to the outer rec.name only when there is no nested record.
Bug B — TWO provider= in the state (fixed 0.1.25). The console→hanzo.id
SSO SDK appends provider=hanzo-iam (its per-org IDP hint) to the upstream
/login/oauth/authorize query. social.ts::buildProviderAuthUrl then appends
the REAL social provider=provider-google, so the base64 state carried BOTH.
Callback.tsx recovers the provider with URLSearchParams.get('provider') —
which returns the FIRST match (hanzo-iam), NOT the last — so the exchange
POSTed provider=hanzo-iam and the backend rejected it. (The earlier "backend
reads the LAST param" note was WRONG: the SPA reads the first.) Fix:
buildProviderAuthUrl strips any pre-existing provider= from the upstream
query (baseQ.delete('provider')) before appending the real one, so the state
carries exactly ONE provider=. One provider, one source of truth — no
reliance on parameter ordering. Locked in pkgs/auth/src/social.test.ts
("a pre-existing provider= … is stripped — state carries exactly ONE provider").
redirect_uri consistency (hardened 0.1.25). The hop builds the provider
redirect_uri from tenant.oauthCallbackOrigin (the provider's REGISTERED
callback host, https://iam.hanzo.ai, shared across brand portals).
client.providerLogin POSTs that redirect_uri to IAM, which forwards it
verbatim to the provider's token endpoint (auth.go →
GetIdProvider(idpInfo, authForm.RedirectUri) → Config.Exchange); a mismatch
→ invalid_grant. It now derives from the SAME oauthCallbackOrigin (was
publicOrigin, the brand host — equal on the callback host today, but they
diverge whenever a brand shares the iam.hanzo.ai client). Locked in
client.test.ts ("providerLogin posts redirectUri from oauthCallbackOrigin").
RC#2 verified live (no Google account needed). A junk-code probe of the live
social path — POST iam.hanzo.ai/v1/iam/login with provider=provider-google +
a fake code — returns oauth2: "invalid_grant", NOT invalid_client. That
proves Google accepted the client_id + client_secret and the
https://iam.hanzo.ai/callback redirect_uri (client auth passed; only the fake
code was rejected). So the Google clientId/secret in the running IAM are real
and valid, the provider resolves + is enabled for hanzo-console, and the
redirect_uri matches — the exchange will complete with a real Google code. No
OAuth developer-console change is needed. The only thing not exercisable without
a real Google/zoo.ngo account is the final code→user round-trip itself.
Silent single sign-on — auto-continue authorize from the issuer session (0.1.26) — SUPERSEDED by 0.2.73, do not restore
Sign in ONCE at the portal; every other app that authenticates through the same
issuer host then logs in with NO form and NO credential re-entry. The IAM
backend already supported this — its Login handler
(hanzoai/iam controllers/auth.go) has an "already signed in to IAM" branch:
when the request carries the iam_session_id cookie but NO username/password
and NO provider, GetSessionUsername() is non-empty so it mints an
authorization code via HandleLoggedIn straight from the session. The missing
leg was the SPA: Login.tsx always rendered the login form, even with a live
session.
The fix wires the SPA leg. Login.tsx now, when an app sends the user to the
authorize page (client_id + redirect_uri on the query), first calls
client.silentLogin(...) — a credential-less POST to /v1/iam/login
(type:code, application, NO username/password, credentials:'include'). If a
live issuer session exists IAM returns the code and the SPA redirects straight
back to the app (redirect_uri?code=…&state=…); if there is no session IAM
answers status:error and the SPA falls back to the interactive form — never a
dead end. A bare portal visit (no client_id/redirect_uri) has nowhere to
redirect, so it shows the form immediately as before.
In Hanzo IAM the OAuth client_id IS the application name (hanzo-console,
hanzo-chat, …), so the SPA passes application = client_id with no extra
lookup. The silent leg rides the SAME HandleLoggedIn as password/social, so it
inherits the social-login client_id-resolution fix (iam ≥ v1.25.3) — without
it the silently-minted code would carry an empty client_id and fail the
downstream token exchange with invalid_client.
Proven end-to-end against live IAM (no UI needed): a session-cookie-only POST to
/v1/iam/login returns status:ok + a code, and that code exchanges at
/v1/iam/oauth/access_token (with the PKCE verifier) for a real
access_token+id_token+refresh_token — i.e. an existing session silently
yields a usable token for another app. Contract locked in
pkgs/auth/src/client.test.ts ("silentLogin posts NO credentials and redirects
with the minted code"; "returns { error } when there is no session").
Issuer-host note: SSO shares the iam_session_id cookie across apps only when
they funnel auth through the SAME login host. Today both console
(IAM_SERVER_URL=iam.hanzo.ai, which 302s to hanzo.id) and chat
(OPENID_ISSUER=hanzo.id) land on the hanzo.id SPA, where the cookie lives — so
the session is shared. chat has OPENID_AUTO_REDIRECT=false, so the user
clicks "Log in with Hanzo" once (provider selection, not a credential prompt);
set it true for fully zero-click entry into chat.
Org-wide unified login — declare the provider set once per org (iam)
Enabling the same social/SSO set across an org's many apps is now ONE place:
the org-level Organization.DefaultProviders (hanzoai/iam
object/organization.go). Any application whose own Providers list is EMPTY
inherits the org's DefaultProviders — resolved in the single app-read path
extendApplicationWithProviders (object/application.go), so every app shares
one provider set without repeating it. An app may still pin its own Providers
to override. init_data adopts DefaultProviders onto an existing org
additively (initDefinedOrganization, initDataNewOnly — like languages),
never overwriting. The seed (universe/infra/k8s/iam/init_data.json) declares
defaultProviders once per org and leaves per-app providers: [].
Enable unified login for a NEW org/tenant: add the org to init_data.json
with defaultProviders: [provider-github, provider-google, provider-web3, provider-apple] (or any subset) and leave each app's providers: []. All apps
inherit automatically — no per-app reconfiguration.
Org-agnostic password login (0.1.23) — SUPERSEDED, do not restore
This section describes behaviour that no longer works and must not be reinstated. It is kept because the reasoning below is what makes the current design legible, and because someone reading only this section will otherwise "fix" the apex login straight back into an outage.
iam2 removed cross-org resolution deliberately. It scopes every credential
lookup to one org and treats the collision this design leaned on as a defect —
internal/registry/registry.go names it "the F-2 bug where [email protected] collided
across admin and hanzo", because resolving across orgs coupled lockout counters
between rows and handed out a brute-force oracle on the SuperAdmin. An org-less
POST /v1/iam/login is now refused outright:
HTTP 200 {"status":"error","msg":"organization, username and password are required"}
Note the 200. The form renders that as though the user's own password were wrong, and every status-code monitor reads it as healthy. Left unfixed it killed the bare sign-in on hanzo.id, lux.id, iam.hanzo.ai and pars.id at once while looking green.
So LoginForm now resolves the app's own org via get-app-login and posts it on
BOTH entry points — the same thing the 0.2.2 fix below already established for
the downstream-app path. A global admin is no longer reached by omission; they
reach admin/* by signing into an admin-org app (e.g. hanzo-admin-guard), which
is the explicit path 0.2.2/0.2.3 describe. client.login() stays a pure
passthrough: it never invents an org, it only forwards one.
The original 0.1.23 note follows, for context only.
(superseded) original text
The portal login is now org-agnostic: it no longer pins
organization=<brand> on POST /v1/iam/login. LoginForm passes
tenant.loginOrg (a NEW, normally-UNSET TenantConfig field) as the
organization, and client.login() OMITS the field entirely when it is
empty/undefined. With no org posted, IAM runs its cross-org resolution
(object.GetUserByFields → GetUserByFieldCrossOrg) and the session encodes the
user's REAL owner-org (GetOrganizationByUser), never the posted hint.
Why this matters (the bug it fixes): IAM's IsGlobalAdmin() is
user.Owner == "admin" (it ignores the stored isGlobalAdmin column), and
get-organizations returns ALL orgs only for a global admin, else just the
caller's own org. The seeded superusers ([email protected], [email protected],
[email protected]) exist in BOTH the admin org (the global identity) AND their
brand org (hanzo/lux). Pinning organization=hanzo made GetUserByFields
hit the colliding hanzo/z row FIRST (in-org lookup succeeds → cross-org
fallback never runs), so an admin got a 1-org hanzo session via the UI even
though the API could reach the 45-org global session. Omitting the org makes the
in-org lookups miss → cross-org fallback → admin/z (global) for the colliding
hanzo-domain emails, while a brand-only identity ([email protected],
[email protected], …) still resolves to its own org. Verified live on
hanzo.id: [email protected] → owner=admin, 45 orgs; a brand-only user → 1 org.
Boundaries (do NOT regress):
- Signup still sends a concrete
organization(tenant.orgId) — you cannot create a user in "no org". Only LOGIN omits it. - Per-app SSO (console/chat/team pass their own
client_id+redirect_uri) is unaffected:type=code+ the app's client_id still flow; the auth code is bound to the cross-org-resolved user. Proven against live IAM withapplication=hanzo-console. - A brand that deliberately wants single-org portal login can set
loginOrgin its runtime catalog entry (id-tenant-catalogConfigMap) — no rebuild. - Contract locked in
pkgs/auth/src/client.test.ts(omit-when-unset, omit-when-empty, include-when-set, SSO still omits, signup still sends).
PKCE on password login (fixed 0.1.13)
client.login() (POST /v1/iam/login) must forward code_challenge
/code_challenge_method on the QUERY string, exactly like authorize().
IAM's Login handler (hanzoai/iam controllers/account.go) reads
code_challenge from the query first, body fallback, then threads it into
GetOAuthCode so the minted code stores the challenge. Omitting it makes
IAM store an EMPTY challenge; the downstream public SPA client (e.g.
hanzo-platform) then fails token exchange with
token.CodeChallenge: empty → invalid_client (it falls back to a
client_secret check the public client can't satisfy — see
object/token_oauth.go:809-844: with a non-empty stored challenge AND no
client_secret sent, the secret check is bypassed). Social login was never
affected (it rides authorize()). Plumb the URL's code_challenge
through Login page → LoginForm → client.login(); do NOT default a
challenge — only forward what the downstream OAuth request put on the URL.
Known SEPARATE blocker (NOT this repo): after a 200 token exchange,
platform's /v1/iam/session (hanzo/platform pkg/platform/src/lib/iam.ts
→ @hanzo/iam/server getServerSession) returns 401 "Invalid IAM token"
for a valid aud:[hanzo-platform] RS256 JWT signed by cert-hanzo (key IS
in hanzo.id JWKS; the duplicate cert-hanzo entry is identical, harmless).
That is a platform/SDK-side verification bug, tracked separately.
What this is
White-label login + identity verification portal. Vite SPA, served from the Hanzo K8s cluster, white-labels per hostname.
Replaces:
~/work/hanzo/hanzo.id-worker(Cloudflare Worker) — being decommissioned~/work/hanzo/id/legacy-nextjs/(Next.js 15) — frozen, kept for diff
Architecture
DNS → hanzo cluster ingress (129.212.164.5)
hanzo.id ──┐
lux.id ──┤ ─ TLS → ingress ─ K8s ─→ Service id ─→ Deployment id (2 replicas)
zoo.id ──┤ (Traefik) ghcr.io/hanzoai/id:vX.Y.Z
pars.id ──┘ │
│ on boot:
│ resolveTenant(hostname)
│ loadBrand(tenant.brandPackage)
▼
@hanzo|luxfi|zooai|parsdao /brand
│
▼
createAuthClient(tenant)
│
▼
per-brand OIDC issuer host: hanzo.id / lux.id /
zoo.id / pars.id (serves /.well-known + /v1/iam/*;
same Hanzo IAM backend, tenant-scoped by org)
│
▼
iam-* postgres in hanzo namespace
iamUrl is the brand's OWN *.id host, NOT iam.hanzo.ai (HIP-0111:
discovery must be host-relative or the SDK resolves to the IAM SPA HTML
catch-all). One backend serves every brand behind its issuer host.
Auth methods — the full set
Email/password + email/SMS code + GitHub + Google + Web3 (wallet). The enabled
set is read LIVE from /v1/iam/get-app-login (AuthClient.getAppLogin), which
mirrors each -id app's provider config in
universe/infra/k8s/iam/init_data.json. Password sign-in goes through the IAM
REST login and returns an auth code directly. Both honor a downstream
redirect_uri.
Social providers — render only when configured; federate through IAM
SocialButtons renders ONLY providers IAM holds a REAL credential for
(AppProvider.configured = non-placeholder clientId), so a placeholder-seeded
provider is hidden rather than dead-ending; it reappears once real creds land.
IAM applies the same rule server-side (isConfigured), so a hidden provider is
also refused at authorize — the two agree without sharing a table. Clicking a
configured provider names it on IAM's authorize endpoint and IAM runs the entire
IdP leg; see "Social sign-in goes through IAM" above. This browser never builds
an IdP URL, never sees a provider code, and holds no secret.
The callback the IdP must have registered is IAM's, not the SPA's. One fixed path, every provider, per brand host:
https://<brand>/v1/iam/oauth/callback ← register THIS at GitHub/Google
(PathFederationCallback; the origin is the brand's pinned issuer, resolved from
the trusted request host — hanzo.id federates to hanzo.id/v1/iam/oauth/ callback, lux.id to its own, and so on.) It is NOT /v1/iam/callback (that path
does not exist — an earlier revision of this file said so and was wrong) and NOT
https://<brand>/callback, which is the APP's authorize redirect and belongs to
the app's registered redirectUris in init_data.json, a different list for a
different leg.
Beware verifying this from outside: GitHub defers redirect_uri validation until
after the user signs in, so an unauthenticated request to
github.com/login/oauth/authorize 302s to the login page whether the callback is
registered or not — a deliberately unregistered URL behaves identically. The only
sound check is the GitHub App's own settings page.
Credentials live in KMS at project hanzo-iam, env prod, keys
IAM_GITHUB_CLIENT_ID / IAM_GITHUB_CLIENT_SECRET (and IAM_GOOGLE_*). The
iam-kms-sync KMSSecret (universe/infra/k8s/iam/secret.yaml) syncs that path
into iam-secrets; init_data.json substitutes ${IAM_GITHUB_CLIENT_ID} at
deploy. That chain already works — GitHub and Google both carry real values
today; provider-web3 and provider-apple are still placeholders and stay
hidden.
enableSignUp gates a FIRST-TIME federated user. Federation provisions a
local user when the identity is new, so an app with enableSignUp:false refuses
a first-time GitHub user with "the application does not allow to sign up new
account" — a sign-in failure that has nothing to do with the flow being wired.
Live: hanzo-console and hanzo-app are true; hanzo-id and hanzo-cloud are
false. hanzo.id sends clientId: hanzo-console (universe/infra/k8s/id/ configmap.yaml), so the portal is on a signup-permitting app. It is governed
declaratively in universe/infra/k8s/iam/init_data.json and reconciled every
boot (iam/internal/seed/seed.go, appPolicyKeys) — change it THERE, never by
an admin call, which the next boot would revert.
Workspace
apps/
web/ @hanzo/id-web — Vite + React 19 + @hanzo/gui SPA
pkgs/
shared/ @hanzo/id-shared — TenantConfig, resolveTenant, loadBrand
auth/ @hanzo/id-auth — composable login/signup/OTP/forgot forms +
SocialButtons (GitHub/Google/Web3) +
AuthClient (wraps @hanzo/iam REST + SDK PKCE)
onboarding/ @hanzo/id-onboarding — post-login org → project → wallet flow.
domain (serializable step machine) / service
(IAM-backed writes) / ui (self-contained flow).
Tests: `pnpm --filter @hanzo/id-onboarding test`
(Node built-in runner, no test-framework dep).
idv/ @hanzo/id-idv — pluggable identity verification
(Persona, Onfido, Veriff, stub)
legacy-nextjs/ Frozen predecessor. Delete after v0.1.0 ships.
Why this layout (and not just Next.js)
- Vite > Next.js for an SPA. No SSR needed; auth is post-load only. ~200kb gzip vs ~600kb. Build is 3s vs 90s.
- @hanzo/gui v7 is the canonical UI. Same shell as every other Hanzo admin surface. No bespoke components.
- Per-org brand packages.
@hanzo/brand,@luxfi/brand,@zooai/brand,@parsdao/brandalready shipbrand.jsonfiles; we fetch them at runtime. Adding a brand =pnpm add @newco/brand- one line in
pkgs/shared/src/tenant.ts(or a runtime catalog entry — no rebuild).
- one line in
- Provider-pluggable IDV. Same surface for Persona, Onfido, Veriff, custom backends. No vendor lock-in at the portal layer.
- Mirrors downstream tenant id-app forks. Same
apps/+pkgs/pattern, same@hanzo/guishell, same per-tenant brand resolution.
Local dev
pnpm install
pnpm dev # http://localhost:5173 → defaults to hanzo brand
For multi-tenant preview:
echo "127.0.0.1 lux.id zoo.id pars.id" | sudo tee -a /etc/hosts
then visit http://lux.id:5173 etc.
Build + deploy
pnpm build
# Deploy is managed in hanzoai/universe (infra/k8s/id/): operator/kustomize
# applies it; CI image bump flows via universe. App repo ships the
# brand-neutral image + Dockerfile only — no deploy/brand config here.
Adding a new brand
pnpm add -F @hanzo/id-web @newco/brand- Add
TenantConfigfor the hostname inpkgs/shared/src/tenant.ts(or put it inIAM_TENANT_CONFIG_JSONat runtime — no rebuild). - Add the hostname to
BRAND_PACKAGESinapps/web/vite.config.ts. - Add host + TLS secret to
apps/web/k8s/ingress.yaml. - DNS → cluster ingress IP.
Plugging an IDV provider
import { registerProvider, createPersonaProvider } from '@hanzo/id-idv'
registerProvider(createPersonaProvider({ templateId, apiKey, environment: 'production' }))
Pre-built providers
| id | source | docs |
|---|---|---|
stub |
@hanzo/id-idv/providers/stub |
in-memory, dev-only |
persona |
@hanzo/id-idv/providers/persona |
https://docs.withpersona.com |
onfido |
@hanzo/id-idv/providers/onfido |
https://documentation.onfido.com |
veriff |
@hanzo/id-idv/providers/veriff |
https://developers.veriff.com |
Custom providers: implement the IDVProvider interface in
pkgs/idv/src/provider.ts and register it.
Cutover from the CF Worker
- Build + push image (PR scaffolds; image bump comes after CI green).
kubectl apply -k apps/web/k8s— Deployment + Service + Ingress live.- cert-manager issues TLS for all 4 hosts.
- Remove the Cloudflare Worker routes for the 4 identity hosts.
- Repoint CF A records:
hanzo.id,lux.id,zoo.id,pars.id→129.212.164.5(hanzo cluster ingress LB), CF-proxied. - Verify each host loads its own brand.
- Archive
hanzo.id-workerrepo.
Backend
The Go IAM backend lives at ~/work/hanzo/iam (Hanzo IAM, module
github.com/hanzoai/iam, image ghcr.io/hanzoai/iam). All paths are under
the /v1/iam prefix — no legacy /oauth/*, no /api/. This portal talks
to it via:
- auth (
pkgs/auth/src/client.ts):/v1/iam/login/v1/iam/signup/v1/iam/send-verification-code/v1/iam/get-app-login, and the OIDC PKCE endpoints/v1/iam/oauth/{authorize,token,userinfo,logout}(via the@hanzo/iamSDK). - onboarding (
pkgs/onboarding/src/service/onboarding.ts):/v1/iam/get-organizations(allowed for any signed-in user, scoped to their memberships server-side),/v1/iam/add-organization+/v1/iam/add-project(admin-gated in IAM authz — the create path surfaces a permission message for non-admins and stays skippable),/v1/iam/get-account+/v1/iam/update-user?columns=web3onboard(wallet link).
All hostnames talk to the same IAM backend — the org is carried in the
request body (organization: <orgId>), and the IAM backend tenant-scopes
on that.
Truth flows git.hanzo.ai -> GitHub (2026-07-26)
hanzoai/id is CANONICAL on git.hanzo.ai (mirror:false, default main).
GitHub is a push-mirror of it, sync_on_commit: true with an 8h floor —
push here and GitHub follows on its own.
It used to be the reverse, and that was the bug: as a pull-mirror this repo
could run no CI at all, so .hanzo/workflows never fired and four commits
shipped zero images without a single red signal. Do not re-point the sync.
Builds publish to BOTH oci.hanzo.ai/id (ours, the destination) and
ghcr.io/hanzoai/id (kept so a rollback target always resolves).
The version is DERIVED, not read off package.json — that claim used to stand
here and is wrong. hanzoai/ci's bin/imgver publishes
max(declared, published) + a patch, where declared is this repo's manifest and
published is the highest semver already at the registry for this image. So the
manifest is a FLOOR (bump the minor there and the series jumps), never the answer:
0.2.56 in package.json shipped as 0.2.63, because the registry floor was
0.2.62. Deriving from the manifest alone would republish one number over two
digests, which is invisible to a node running imagePullPolicy: IfNotPresent.
Read the tag off the registry after a push; do not predict it. Deploying is a spec.image.tag
edit in universe/infra/k8s/operator/crs/id.yaml; CI must never patch that CR
itself, because Hanzo CD reverts it within ~90s.
HANDOFF — 2026-08-03: social login, SSO, and the build path
State at handoff. Everything below was measured against production, not inferred. Where a claim is unverified it says so.
THE ONE-LINE ROOT CAUSE OF "DAYS OF LOGIN BUGS"
pkgs/shared/src/org.ts — oauthCallbackOrigin defaulted to publicOrigin, the
BRAND'S OWN host, and no catalog entry overrode it. So every property sent a
different redirect_uri to Google/GitHub:
hanzo.app -> hanzo.app/callback hanzo.chat -> hanzo.chat/callback
console -> console.hanzo.ai/... cloud -> cloud.hanzo.ai/...
Each provider holds ONE OAuth client with a FIXED list of authorized URIs, so
social login could work on at most ONE property. Google's own error payload,
base64-decoded, reads redirect_uri_mismatch. GitHub's reads "redirect_uri is
not associated with this application".
IT WAS NEVER KMS OR SECRETS. The Google client_id (113591532635-s8pvqrebprkbndluhftddmdap4htvu1p.apps.googleusercontent.com) reached Google intact every time. Do not go looking at KMS again.
FIXED in c153004: the default is now the org's hosted ID host, read out of
DEFAULT_TENANTS via idOriginFor() so the .id hosts stay declared once.
Verified by executing resolveOrg under tsx with a production-shaped catalog:
hanzo.app / hanzo.chat / console.hanzo.ai / cloud.hanzo.ai -> hanzo.id/callback
id.lux.network -> lux.id/callback
FIRST ATTEMPT WAS WRONG — recorded so nobody repeats it: defaulting to
iamIssuer does NOT work. hostSkeleton() derives the issuer from the REQUEST
HOST too, so it is per-brand for the same reason. It must be a per-ORG constant.
LEFT TO DO, IN ORDER
-
CONFIRM
ghcr.io/hanzoai/id:0.2.22EXISTS, then bump the chart.crane digest ghcr.io/hanzoai/id:0.2.22— a digest is the ONLY proof; the build job's exit code lied twice this session. Chart: universe/charts/app/values/hanzo/id.yaml (currently 0.2.21). Pin tag AND digest together — a stale digest wins over the tag and serves the old build. Build was fired by pushing to forge (see §BUILD PATH) as pf-runner-yuylsijvdnpb. -
REGISTER THE CALLBACK URI with both providers. One entry each, whole fleet:
- GitHub OAuth App -> Authorization callback URL
- Google Cloud project -> Authorized redirect URIs
GET THE EXACT STRING EMPIRICALLY: click "Continue with GitHub" at hanzo.id and
read
redirect_uri=out of the address bar BEFORE the error page. Do not trust a predicted string. Until BOTH (1) and (2) are done, social login stays broken and the error text is identical either way.
-
WALLET LOGIN IS A DATA FIX, NOT CODE. SUPERSEDED — do not act on this. The flags were never the question: the seeded row is OAuth-shaped (
category: "OAuth",type: "Web3Onboard", clientId the unexpanded literal${IAM_WEB3_CLIENT_ID}), so IAM's ownofferable()drops it from get-app-login before any flag is read — flipping canSignIn would have changed nothing. Wallet sign-in is a capability of the IAM BUILD, the chain families it verifies, so IAM answers it on/v1/iam/auth/methodsand the strip reads it there (draw the wallet from the capability, not from a provider row). Theprovider-web3rows in universe's init_data.json govern nothing and are read by nothing. Original text follows.provider-web3has canSignUp=true and canSignIn=false on 6 of 11 apps (hanzo-app, hanzo-base, hanzo-docs, hanzo-insights, hanzo-o11y, hanzo-world); symmetric and correct on hanzo-chat, hanzo-cloud, hanzo-console, hanzo-platform. So it is inconsistent seeding, not policy. Anyone who signed up with a wallet cannot sign back in. SocialButtons.tsx:162 renders the flags faithfully — do not "fix" the code. Needs an admin bearer token: the portal session cookie gets 401 on /v1/iam/applications. -
MFA / AUTHENTICATORS / PASSKEYS — COMPLETELY UNVERIFIED. Surfaces exist (MfaEnrollForm, OTPForm, SmsConsentNotice, django_otp in insights) but NO flow was driven end to end. Email OTP, SMS OTP, TOTP and WebAuthn each need a real browser pass. Do not report any of them as working without driving it.
-
PLATFORM BUILD ENQUEUE IS BROKEN (separate from the above). POST platform.hanzo.ai/v1/runner -> 400 "organizationId is required (no DEFAULT_BUILD_ORG_ID configured)"; supplying
hanzo,admin/hanzo, or the real UUID dfb7a19b-108f-5150-8131-7d207488bf48 all -> 500 "enqueue failed: FOREIGN KEY constraint failed". Cause: platform's /data/data.db was LAST WRITTEN Jul 28 (auxiliary.db is live today) — the org row the build_job FK references does not exist. No sqlite3 in the pod. CTO ruling: DELETEDEFAULT_BUILD_ORG_IDentirely. A fallback org is a second way to do things and it let this fail silently for a week; every build belongs to a real prepaid org. Make the enqueue say "org X has no build account" instead of leaking a raw SQL constraint. Fix lives in ~/work/hanzo/platform. api.hanzo.ai/v1/runner also returned 503 during this window. -
o11y-mcp — DELETE IT, do not debrand it. Branch debrand/no-signoz (016535f) removes SigNoz+ClickHouse from go.mod and builds clean, but it is the wrong fix: o11y already declares 353 typed zip ops and zip projects each into an MCP tool from the same declaration. Cloud's door at POST api.hanzo.ai/v1/mcp serves 932 tools across 116 apps. A hand-rolled Go MCP server is a second way to do MCP, and its staleness proved it — it was publishing DDL for tables HIP-0132 dropped. Archive the repo; regenerate plugin/o11y/mcp.json from the typed ops (it holds 12 entries where o11y declares 353).
-
LEDGER CONSOLIDATION -> hanzoai/ledger. Two live forks of one Formance root. ledger-fi is the live lineage (8 real PRs, already zip, 6 commits behind upstream). Registry rule is Hanzo->hanzoai, so hanzo-fi is a fourth org that exists for one repo. RENAME THE LIVE ONE FIRST (hanzo-fi/ledger -> hanzoai/ledger) so GitHub's redirect protects
go get, THEN delete the dead twin — never the reverse or the redirect is stranded. Its clickhouse-go is Formance's own dep and leaves with the consolidation. NOTE: 0 of 1,444 changed lines in internal/api are ours; do not rewrite vendored paths beyond go.mod. -
ALSO DEPRECATED: archive
hanzoai/datastore-go— imported by NOBODY while 20 repos usegithub.com/hanzo-ds/go. Confirmed deprecated by the CTO.
SHIPPED AND LIVE (verified by response body, not status code)
- iam@3f86f1f5e — ONE SSO seam. Two independent fixes reconciled:
- session is CREATED on every grant shape. The bug:
if f.Type != "code"guarded sessions.Set, while client.ts:170 sends type=code for every OAuth login — so the IdP forgot the human the instant they signed in and the fully-built silent-SSO branch had nothing to read. - session is USABLE without UI: prompt=none now returns the code with no UI, or error=login_required TO THE REDIRECT_URI (never a rendered page). VERIFIED LIVE on hanzo.id. Also landed: __Host- cookie prefix, max_age enforcement, and id_token_hint signature verification (without it a silent renewal could return a code for a DIFFERENT human through a callback the RP already trusts — an identity swap with nothing on screen).
- session is CREATED on every grant shape. The bug:
- id@f2fc1e4 — the callback fix + semver: id-shared 0.1.2, id-auth 0.1.7, id-onboarding 0.1.2, id-idv 0.1.1, root 0.2.22. id-connect NOT bumped (does not depend on id-shared).
- id@2fe32a7 — deleted ProviderButtons.tsx, a dead 2-provider list that was in no barrel and imported by nobody, sitting next to the 4-provider SocialButtons.
- console v8.5.36 — hero h1 now 61.6px line box for 56px glyphs. It was
line-height 1.12px: a ONE-PIXEL box under 30px glyphs, so the heading
overflowed onto its own subtitle. Cause: react-native-web appends
pxto numeric style values absent from its unitless list, and lineHeight is absent, so {lineHeight:1.12} compiled to1.12px. - console 4656c316f4 — react-native-svg 15.15.5. console main had been UNBUILDABLE since the gui-8 bump: @hanzogui/[email protected] imports react-native-svg while declaring it in NEITHER dependencies. That is why v8.5.33/34/35 never existed in GHCR and two weeks of fixes never shipped.
- cloud.hanzo.ai login — client_id=hanzo-cloud (was hanzo-app, whose client carries only hanzo.app/auth/callback).
- datastore 8Gi -> 12Gi — ClickHouse derives max_server_memory_usage from the cgroup at 0.9, so 8Gi WAS the 7.20 GiB ceiling and the server was refusing reads with MEMORY_LIMIT_EXCEEDED at 7.88 GiB RSS.
BUILD PATH THAT WORKS
platform.hanzo.ai/v1/runner is broken (§5). USE THE NATIVE FORGE PUSH:
git push forge origin/main:main # git.hanzo.ai fires .hanzo/workflows/deploy.yml
Verified working this session: git-runner fleet 4/4 Running, and
build-console / build-docs / build-openapi all Completed within 30 min.
NOTE forge/main was 8 commits BEHIND origin/main for id — a working builder
would still have built the wrong tree. Check both remotes agree.
METHOD NOTES — these caught real errors, four times
- A CHART BUMP IS NOT A DEPLOY; A MERGED COMMIT IS NOT PRODUCTION. Verify what RUNS. I pinned console to v8.5.35, an image that NEVER EXISTED (GHCR 403'd anonymously and I shipped anyway on "RollingUpdate fails safe" — it did fail safe, and it also shipped nothing while reading as done).
- A GREEN TEST COMMAND IS NOT A GREEN TEST.
pnpm --filter @hanzo/id-shared testexits 0 having run NOTHING — that package declares notestscript. Root hastest: vitest run, but vitest is not installed in the checkout. The org.ts behaviour was verified by EXECUTING resolveOrg under tsx. $?AFTER A PIPE IS THE PIPE'S STATUS. Printed "build: 0" for a failed build. Put echo $? on its own line.- MEASURE origin/main, NEVER A LOCAL CHECKOUT, and run
git status -sbfirst. Stale checkouts produced five false findings, worst: hanzoai/iam called a beego carrier while the local tree sat 388 commits behind on a dead branch that ships its own DEPRECATED.md. node is 7,565 behind. Filter// indirecttoo. - ROUTE/SYMBOL COUNTS IN A REPO PROVE NOTHING ABOUT WHAT SHIPS. bootnode was
ranked a top conversion target on 206 chi routes that ship in ZERO binaries
(its live API is Python/uvicorn). Use
go list -depsagainst the binary the Dockerfile builds, plus what runs in the cluster.
SECURITY, OPEN
- NO FIRST-USE CONSENT on prompt=none: a signed-in victim top-level navigated to authorize?client_id=&prompt=none yields a code to that client's registered redirect_uri. SameSite=Lax sends the cookie on a top-level GET and Sec-Fetch cannot help — it is a genuine navigation. Bounded by MintFor's tenancy rule, so blast radius depends on who may set IsShared. This is the standard reason IdPs gate first use of a client behind consent.
- CORS EDGE, RE-CHECK: login.go's own comment records a proxy on the hanzo.ai/hanzo.id zones once reflecting *.hanzo.ai with Access-Control-Allow-Credentials:true, which would make the credential-less mint reachable from any subdomain. ACAO measures as exactly https://hanzo.id today; a hostile Origin was NOT tested. The SSO fix puts live sessions in far more browsers, so this matters more now.
- hanzo_iam_access_token on domain hanzo.app is a full RS256 JWT in a NON-HttpOnly, JS-readable cookie.
- Application.EnableSigninSession — declared at pkg/schema/application.go:146, set TRUE on every app, READ BY NO CODE. Revive it as a real gate or delete it.
- The __Host- cookie rename INVALIDATES EVERY LIVE SESSION on deploy. One re-login per human. Decide accept-vs-dual-read before rolling.
- CLEANUP OWED: [email protected] is a real account created to prove signup works end to end; delete it. A DigitalOcean PAT (dop_v1_ff09e128…) was pasted into the session transcript and is on disk — ROTATE IT.
DEAD ENDS — do not re-derive
- insights.hanzo.ai is NOT broken. It is SSO-gated and the chain works: /login -> /login/oidc/ -> hanzo.id/…authorize?client_id=hanzo-insights -> 200. The "532 MIME errors, empty #root" report was an unauthenticated browser following those 302s.
- THERE IS NO SHADCN TO KILL. Neither console main nor blue3/ui-shadcn-explicit contains shadcn, radix or tailwind; main's only two matches are comments saying the console deliberately is not the shadcn build. That branch is 906 behind / 10 ahead and REGRESSES deps (@hanzo/gui 7.3.0 vs ^8.0.0, @hanzo/iam ^0.13.6 vs ^0.21.2). ABANDON IT, do not merge. Tamagui-native is already true on main.