Every telemetry step on the sign-up path runs inside one guard that logs and
passes over a throw, so a full or refused browser store no longer stops the
account from being created or the browser from leaving for the app. Each step
reads the client when it happens rather than when the form rendered, so a
visitor who accepts and submits at once still has the join and the completion
counted once the live client arrives.
Co-authored-by: Hanzo Dev <[email protected]>
The source this image ships — the app and workspace packages, tests and specs
aside, public/ beside the keyring, index.html, vite.config.ts and any .env —
may name no pk- key at all, a placeholder of x aside, and the app and packages
may not name ORG_KEY, SITE_KEY, keyFor, keyForPage or siteKey. The account
page's own storage-key constant is CURRENT_ORG, so the name stays the library's.
Co-authored-by: Hanzo Dev <[email protected]>
The portal no longer starts @hanzo/event's tag manager. Once a stored or new
choice allows Analytics, it asks cloud for the brand's consent policy with the
brand's key and no credential, hands only the policy to @hanzo/event, and
leaves the tags cloud lists beside it unloaded. The event client starts after
the policy arrives and only if the choice still counts under it, so an Accept
stored under an older policy, a refusal cloud saw, or no answer at all starts
nothing and stores nothing, and the banner asks again.
The page reloads only when the visitor withdraws Analytics after the client
started, on the choice they store, never on a policy's arrival. The completed
sign-up goes to the stream with the visitor's consent stamped on it, for cloud
to forward server-side.
The spec lists Google's and Meta's tags for every key and holds every identity
host to loading none, and covers a newer policy, a refusal cloud saw and GPC
over a stored Accept: one load, the form, nothing sent or kept.
Co-authored-by: Hanzo Dev <[email protected]>
The catalog gains id-dev.hanzo.ai: org hanzo on network devnet, client
hanzo-console, its issuer and /v1 its own origin (the dev cloud's IAM),
its app https://app-dev.hanzo.ai, its pay surface
https://app-dev.hanzo.ai/pay and its telemetry api-dev.hanzo.ai.
Three things in the portal sent an org-hanzo host to mainnet, and each now
reads the host's own entry:
- the alias redirect read one front door per org, so id-dev was sent to
hanzo.id with its OAuth request; front doors are now per org per
network, and a network with none stays put;
- a signed-in visit went to the brand's first app (hanzo.ai), the
launcher, billing and the organization page linked mainnet; a host
whose entry names `appUrl` links only its network's app and payUrl;
- telemetry went to api.hanzo.ai; an entry's `apiUrl` names its gateway.
Every mainnet entry names none of the new fields, so every mainnet host
resolves, redirects and links exactly as before.
Co-authored-by: Hanzo Dev <[email protected]>
The banner's chunk is optional: one that does not load, or throws as it
renders, draws nothing and the sign-in form stays. A withdrawal of Analytics
after the event client started reloads the page once, so no client is left to
send. Every identity page sends only its origin as a referrer, so an address
holding a code, an invite or an OIDC request reaches no other page.
The consent spec answers tag config as production does: each identity host's
key names a project with no tags, so after Accept no Google or Meta request
leaves the page and the sign-up reaches the stream with its consent. It
records every request that leaves the host and holds it to what the page may
send at that moment, throws errors before and after a choice, and covers a
failed chunk, Reject after Accept, and the referrer.
Co-authored-by: Hanzo Dev <[email protected]>
The account exists before its completion is stated, so track() runs inside a
try: a throwing pixel is logged, and the browser still leaves for the app that
asked.
Co-authored-by: Hanzo Dev <[email protected]>
The dist check allows @hanzo/event's host-keyed table, so a key copied from
that table into the app's own code would pass it while naming one brand on
every host. The shipped source (the app and workspace packages, tests aside,
public/, index.html and vite.config.ts) may now name only keys the runtime
keyring in public/config.json names; any other fails the image build.
Co-authored-by: Hanzo Dev <[email protected]>
The account's creation is one track(stream, 'signup_completed') under one
event id: our stream hears it, and so does each of the brand's tags the
visitor's choice allows, as GA4 sign_up and Meta CompleteRegistration. The
page address track() stamps into the moment's properties leaves as its path,
like the event's own location fields.
Co-authored-by: Hanzo Dev <[email protected]>
index.html drops its hard-coded GA4, Meta Pixel, Umami and Insights tags. The
shared @hanzo/ui banner asks and @hanzo/event keeps the choice: until it allows
Analytics no event client exists, and until it allows Analytics or Marketing
the tag manager is not started, so nothing is fetched, sent or stored. Global
Privacy Control and Do Not Track refuse without being asked.
The tag manager asks cloud for the tag set of the brand's runtime key, the same
key its event stream files under, so each brand loads only its own GA4 and
pixels and a host with no key starts none. A host whose brand publishes no
privacy policy is not asked, so nothing optional runs there. Routes whose
address carries an authorization code or device code start no tags either.
@hanzo/event 0.3.58 and @hanzo/ui 8.27.90 are pinned, with one @hanzo/event in
the graph. The banner renders on @hanzo/gui through react-native-web and loads
on its own chunk. The dist key check reads its allowance from the runtime
keyring and from @hanzo/event's host-keyed table.
Co-authored-by: Hanzo Dev <[email protected]>
IAM answers a sign-in of an account whose address was never proven with
RequiredEmail and the address it sent a 6-digit code to ("" when none is on
file). The client reads it as a third held state beside NextMfa and
RequiredMfa, carrying the sign-in's own OAuth parameters and never its
credential; emailConfirm and emailSend post them again to /v1/iam/login with
{passcode} or {email}, riding the challenge cookie.
EmailConfirmForm is the step: the code, a new code, a different address, and
the address field first when none is on file. The login, device-approval and
signup pages render it for password, code and wallet sign-ins. A confirmed
address continues as the sign-in would have, second factor included, and an
expired hold returns to the sign-in form with IAM's sentence.
Signup shows IAM's refusals for an address it cannot verify, or for no address,
like any other refusal; the note that such an account is made unproven is gone.
Co-authored-by: Hanzo Dev <[email protected]>
- Display plan selector (Pro, Team, Pay as you go) directly on sign-in when accessed without an external redirect_uri
- Route bare sign-ins straight to checkout (pay.hanzo.ai) to complete plan payment
- Move plan step to Step 1 in onboarding flow (STEPS = ['plan', 'org', 'project', 'wallet', 'consent'])
- Redirect uncompleted/unpaid accounts on Portal.tsx immediately to /onboarding
- Enforce reachability so no subsequent steps can be visited before plan is selected
Co-authored-by: Hanzo Dev <[email protected]>
Accept without emailCode sends the code to the signed-in account and answers email_code_sent (or email_code_required inside the resend window); its msg becomes the code step's line. Send a new code is accept again; submit is accept with emailCode. The page no longer requests a code or looks up the application for one.
Co-authored-by: Hanzo Dev <[email protected]>
acceptInvitation sends emailCode and reports IAM's reason code. On email_code_required /join sends a code to the account's email under the link's application and accepts again with it; a wrong code, a different address and too many attempts show IAM's sentence as written.
Co-authored-by: Hanzo Dev <[email protected]>
Invite links carry client_id; registration and the sign-in that comes back both go through that app, and the host's own when the link names none. acceptInvitation reports a join only for {status:ok, data:{joined:true}} and shows IAM's refusal sentence as written.
Co-authored-by: Hanzo Dev <[email protected]>
Signed out, /join?org=&invite= offers registration that carries organization and invitationCode to IAM, or a sign-in whose ?return= (same-origin /join only) brings the person back. Signed in, Join posts {owner, code} to /v1/iam/invitations/accept. An account created on the invitation is shown as already in the org.
Co-authored-by: Hanzo Dev <[email protected]>
The first submit carries no code, so IAM answers a taken address or a refused password there. A taken address shows Sign in and Reset password, both opening on that address for the same request. Recovery prefills from login_hint and returns to the same sign-in.
Co-authored-by: Hanzo Dev <[email protected]>
A page as tall as the viewport pushed its heading up into the mark's
row: "Hanzo ID" at 19px semibold beside "Sign in" at 17px bold, a
pixel or two apart, a header that reads as almost aligned. The page
now starts its column under the mark's 28px row plus 16px, so the
heading sits below the mark at every width. The e2e asserts it.
Co-authored-by: Hanzo Dev <[email protected]>
The owner's wording for the line under the sign-in column. Same link,
same gate on enableSignUp, same request carried to /signup.
Co-authored-by: Hanzo Dev <[email protected]>
The sign-in page closes on "Don't have an account? Create account" when the
application takes new accounts (enableSignUp on its row, read from
/v1/iam/auth/application). An unreadable answer draws nothing; the server
stays the only gate.
route.ts builds both links. signupHref carries the query verbatim and keeps
the plain-link shape (/login/<app> -> /signup/<app>), so client_id,
redirect_uri, state, nonce and the PKCE challenge reach registration and the
new account returns to the app that sent it. signinHref is its inverse and
drops only signup=true / screen_hint=signup, which /login would answer by
forwarding straight back. The signup page's "Sign in" links and the
post-signup MFA hand-off use it.
Unit tests pin every parameter and both shapes; the e2e now asserts the
control, the round trip and its absence when signup is off.
Co-authored-by: Hanzo Dev <[email protected]>
The plan step records the choice and sends the buyer to checkout with the
org they founded as ?org=, so pay charges that org's ledger rather than the
signup org. Completion records only when pay returns to /onboarding with
checkout=success; leaving checkout unpaid reopens the plan step. Plans show
the monthly price checkout sells, and a per-seat plan states its seat
minimum and first charge from the catalog's perSeat and limits.minSeats.
Co-authored-by: Hanzo Dev <[email protected]>
Choosing a plan or a prepaid balance is the only way out of onboarding; it
records completion and sends the person to checkout. The step machine refuses
an empty answer on a step that is not skippable, so Skip, the arrow keys and
the step dots cannot reach done without a plan. A catalog that fails to load
offers Retry rather than a choice whose checkout cannot answer.
Co-authored-by: Hanzo Dev <[email protected]>
With prompt=select_account the sign-in page lists the people IAM holds a
session for (GET /v1/iam/accounts) and a "Use another account" row that
opens the credential form. Choosing a person re-enters authorize with the
request the page was handed plus login_hint, and IAM answers from that
person's session. Nobody signed in here goes straight to the form.
login_hint also starts the credential form with that identifier.
Co-authored-by: Hanzo Dev <[email protected]>
@hanzo/font is not a dependency of this workspace, so vite build stops
at `@import '@hanzo/font/css'`. @hanzo/design/styles.css is the file the
comment above the import describes: every token group plus the Zen faces
the package ships. With it the build passes and tokens.test.ts finds
every referenced token defined.
Co-authored-by: Hanzo Dev <[email protected]>
Pressing a wallet on the sign-in page sent IAM a login, and IAM's answer
for a wallet nobody holds — "no account is linked to this wallet" — was
drawn as a dead end beside a Create account button. Somebody who already
had an account by email or a provider made a second one to get in.
IAM attaches a verified wallet to an existing identity only when the
verify arrives with that identity's own same-site session (hanzoai/iam
internal/wallet/verify.go). So the refusal is now named (`unlinked`),
the chain is parked in session storage, the page says to sign in below,
and after any sign-in — the form, a second factor, a provider's return
through /callback — the wallet flow runs once more with the session and
IAM links it. From then on the wallet alone signs them in. A declined
second signature leaves the sign-in as it was, wallet unattached.
Co-authored-by: Hanzo Dev <[email protected]>
"Login or Signup" promised a page that would tell a person which they
were, and the form cannot: IAM answers one sentence whether the password
is wrong or the account does not exist. The heading is "Sign in", the
button under the form reads "New here? Create account", and a refusal
is followed by the two ways out — create an account, or reset the
password. LoginForm hands the refusal to its page through onRefused.
This file states at the top that no focus indicator lives here — tokens/base.css
ships `:focus-visible{outline:2px solid var(--ring);outline-offset:2px}` — and
that the ONE permitted exception is a position, `.hanzo-id-input`'s
`outline-offset: -2px`. `.hanzo-id-codebox:focus` was a second one, and it broke
all three of the properties @hanzo/design's check-tokens holds: it set
`outline: none`, added a focus `box-shadow`, and moved `border-color` as well.
Measured against the built stylesheet by mounting the component's own markup and
tabbing into it:
before outline 3px NONE box-shadow 0 0 0 1.37px rgba(255,255,255,.275)
border .15 -> .32 2 affordances, and the system ring gone
after outline 2px solid rgba(255,255,255,.4) at -2px box-shadow none
border .15 -> .15 1 affordance, 3.77:1
The offset is `-2px` for the input's reason and one more of its own: the boxes
sit 8px apart, so a ring at a +2px offset spends 4px of that gap per side and
neighbouring rings very nearly meet.
It is also `:focus-visible` now rather than `:focus`, so a mouse click no longer
paints a keyboard indicator — the same predicate every other control here uses.
pnpm -r tc 7/7, pnpm test 32 files / 301 tests.
The heading offers two things and only one of them was a control. Creating an
account was 13px of text at the foot of the login page, on the same line as the
link for people who forgot a password, so a first-time visitor — whose only
business here is that door — had to read past every way of signing in to find it.
It is now a button of the same size and surface as the ways in, directly under
the credential it is the alternative to: 52px tall and the full width of the
column, against 17px of inline text before. It carries the whole OIDC request as
it always did, so registration can still return the new account to the app that
asked for it.
What is left at the foot is the one link that belongs there.
The wallet and the phone ranked third and fourth on the login screen, above the
email field, because they were entries in a strip and the strip led the page.
Somebody arriving to sign up for a small business met two specialist ways in
before the one they were going to use. They are still offered, they just stop
outranking the field: Google and GitHub lead, the form follows, the wallet and
the phone trail.
PROVIDER_ORDER carries the whole arrangement now rather than the buttons' half of
it, so the form has a named place in the one list and the column can be read in
one line. That is why the form is a child of the component that draws the column
— entries fall on both sides of it, and a component cannot place entries around a
sibling it does not contain. The alternative was a second list on the page saying
which entries lead and which trail, and two lists of one arrangement drift.
The rule travels with the form and appears only when something sits above it, so
an app with no providers configured no longer gets a rule separating its form
from nothing. It keeps the 24px of air it had when the page's gap owned it.
The form no longer waits on the two descriptor reads. It was safe to return
nothing until IAM answered while the form was a sibling; as a child that would be
a blank screen wherever IAM is slow.
Signup and device approval draw the same column from the same list. Device
approval had the form above the strip under a comment claiming that matched the
login page, which stopped being true when the one-click entries moved to the
front.
signup_viewed and signup_submitted were both still buffered when the account was
created, so the only send that carried them was the one at unload. That send is
the most fragile one there is — a closed tab, a stalled network or a slow
redirect drops it — and it is the only one nothing downstream can read back to
check, so the two steps the funnel is actually measured on were the two least
certain to arrive.
Creating the account is a full round trip and the browser leaves the moment it
answers, which is room enough to hand them over on an ordinary request. The
arrival is still buffered at that point and rides out with the attempt. The
completion keeps the beacon, because nothing follows it.
Registration is the one moment this portal learns a person's name. IAM answers
the create with the row it wrote, whose `id` is the account's stable subject —
the same value that arrives later as the OIDC `sub`. Read it in parseCreated,
carry it out on SignupResponse, hand it to onCompleted, and let the page
identify() with it just before the completion is counted. The steps already
queued keep the anonymous id they happened under, which is what they were;
everything from the completion on is the person. One identify row joins the two
halves, and without it a visit and the account it produced are two records
nothing connects.
The subject and nothing else. The row also carries the address, and the package
stays the flow rather than the thing that watches it.
The unload send is labelled text/plain. A cross-origin POST is preflighted
unless its content type is CORS-simple, and a preflight is a second round trip
that a document being torn down does not get. The bytes are the same JSON; the
ingest reads the body it is handed rather than dispatching on the header, so
nothing server-side is relaxed to accept it.
Two tests pin both halves, and each fails without its own change.
preferences/savePreferences, mfaPreferred and the MfaType alias shipped with no
caller. The preference pair was duplication as well as decoration: onboarding's
own service already writes that document, and two clients for one shallow-merge
store is how two products start disagreeing about who last wrote a key.
mfaPreferred chooses between enrolled factors and this surface enrolls one.
Caught by the unit gate rather than the type gate — pkgs/auth's tsc does not
read its own test files, so a test calling a method that no longer exists is
green until it runs.
hanzo.id could sign you in and then had nothing for you to do. /account is a
settings surface now — profile, security, organizations, applications — built
from what IAM implements and honest where it does not.
Signed out it has ONE control, the issuer's own authorize leg: no credential
form, no provider strip. Those stay at /login, which is the issuer's screen and
the one place a credential is typed.
Consent is what a person can actually change here, and it round-trips: the write
is read back, so a save that did nothing cannot leave the switch looking moved.
The identity fields are read-only because update-user refuses a regular user on
their own row by design; the page says so rather than render a form that cannot
save.
IAM's Guard reads a bearer and has no cookie path, so a portal sign-in gets 401
on exactly two reads — memberships and webauthn-credentials. The client attaches
a token when the SDK holds one and says what is true when it does not. A failed
read is not an empty list: "No passkeys yet." cannot appear beside the reason
they could not be read.
The chrome is @hanzogui/shell's OrgHeader, mounted for hanzo only — that bar
draws the Hanzo mark and exposes no brand slot, and this image serves lux.id,
zoolabs.id and pars.id byte-identically.
Applications are in the catalogue's order and the page says so: lastSigninTime
has no writer and no login writes an audit row, so sorting by it would be
arbitrary order wearing a promise.
Also fixes a pre-existing red on main: LoginForm's phone case waits on a dynamic
import inside waitFor's 1s default, and the chunk arrives at ~2s. The budget was
wrong, not the claim.
THE CORNER NAMED THE COMPANY AND LEFT THE PRODUCT TO BE GUESSED. It drew the
mark alone, and the mark is the same on every surface a person reaches from
this brand -- so the one piece of page identity answered a question nobody was
asking. It carries the label the tab already carries, idBrandLabel: Hanzo ID on
hanzo.id, Lux ID on lux.id, and no per-brand code. The img's alt empties,
because the anchor already carries the name and a mark beside its own text
otherwise announces the brand twice.
THE FOOT'S MARK WAS AT .55, which on this ground reads as a disabled control
rather than as a mark -- the one graphic on the page, dimmer than the legal
line above it. Quiet is the size's job here, so 24px stays and the opacity
goes.
Both new values resolve through @hanzo/design; tokens.test.ts walks the import
graph and would fail on an invented one. 282 tests, tc 7/7.
signup_viewed on arrival (before the closed-application branch, so a refusal
is an outcome and not a hole), signup_submitted past the busy guard, and
signup_completed on every branch that has created the account — flushed on
the beacon, because the browser leaves on the next line. The names come from
the shared taxonomy; there is no canonical one for a refusal, so a refusal is
submitted-and-not-completed and IAM's message stays at IAM.
pkgs/auth hands the moments out as callbacks and stays telemetry-free.
Every location leaves as its path. /signup is reached carrying the whole
downstream OIDC request, the client stamps url and referrer inside build()
for every event and every call site, and its scrubber redacts secret shapes
that an opaque state or PKCE challenge does not match. The transport is where
a surface decides its own bytes, so that is where the query comes off.
The credential form assembled its post as
`organization: app?.organization ?? client.org.loginOrg`. No catalog row sets
loginOrg on any host, so the second half is not a fallback — it is undefined, and
the field drops out of the body. A descriptor read that has not landed therefore
sends IAM a login with no org.
IAM refuses that outright: it scopes every credential lookup to one org and
answers "organization, username and password are required" — with HTTP 200 and
{"status":"error"}, so the form renders it exactly where a wrong password lands
and the person retypes a credential that was never the problem.
The org belongs to the APPLICATION being signed into, not to the host serving the
form: admin-cms lives in `admin`, hanzo-cms in `hanzo`, and both are reached at
hanzo.id. get-app-login is the one clientId -> {application, organization} map, so
it is the only thing that can answer, and there is nothing to fall back TO. The
submit now refuses on an unread descriptor with the sentence the code arm already
used, and both arms say it once.
Cross-org resolution is not the alternative. [email protected] exists in BOTH `admin` and
`hanzo` as two separate accounts — provision, never promote — so a bare email is
undecidable, and resolving across orgs coupled their lockout counters into a
brute-force oracle on the SuperAdmin. loginOrg's doc claimed the opposite and
would have talked the next reader into restoring it; it now states what it is,
the wallet leg's anchor, that leg having no descriptor to read.
Pinned by a test that posts nothing and says so: it fails on the old form by
posting an org-less login.
The device-approval screen showed the 8-char user_code in one wide text field.
Make it eight boxes, the shape a person expects for a login/PIN code: type
advances, backspace steps back, paste/one-time-code autofill distribute across
the boxes and strip a separator, and the whole thing normalizes to the RFC 8628
alphabet IAM mints. Same border/focus tokens as every other control, so it reads
as one control. tsc + vite build clean.
Co-authored-by: Hanzo Dev <[email protected]>
This job fetched https://github.com/hanzoai/id.git and died with
`remote: Repository not found`, exit 128, on every run. The repo moved:
`gh api repos/hanzoai/id` resolves to hanzo-inc/id, and it is private.
GitHub answers 404 rather than 403 for a private repo a token cannot see,
so "not found" here means "no access", not "no such repo" — and a
member's SSH key reads it fine, which is how this stayed invisible from a
laptop while CI was blind.
The consequence was not cosmetic: the forge is what builds, so it fell
behind main and hanzo.id shipped stale source. It had already lost a
login fix ("honor a registration hint, so the signup funnel reaches
signup") before anyone noticed.
This points at the repo that exists. If the CI GH_PAT still cannot read a
private hanzo-inc repo, the job will now fail with a 404 naming the RIGHT
url, which is the honest next signal — the remaining half of the fix is a
token grant, not a code change.
Co-authored-by: Hanzo Dev <[email protected]>
hanzo.chat's homepage "Sign up" links to hanzo.id/signup/hanzo-chat -- the
client id in the PATH, from three live components (Visitor, MobileNav, the
error surface). App.tsx has always routed that shape (`path.startsWith
('/signup/')` and '/login/' are in its switch), so it is accepted by design.
The pages then read only `?client_id`, so the segment was matched and thrown
away.
What that produced: the page fell back to the host's default app, rendered a
form, and created the account under hanzo-console -- a DIFFERENT application
than the button that asked for it -- then left the new customer on hanzo.id
with no redirect_uri to return through. Signup "worked" and the funnel dropped
them.
One resolver, used by both pages. Query wins when both are present: it is the
OAuth request and the only one carrying a redirect_uri.
The segment must look like an IAM client id (<org>-<app>, the estate's one
naming rule) or it resolves to undefined and the host default applies, which is
exactly the previous behaviour. Tested: both shapes, precedence, and eight
rejects including a deeper path, wrong case, and a traversal attempt -- so
"accept anything" cannot pass. 175/175, typecheck clean.
Co-authored-by: Hanzo Dev <[email protected]>
Reported as "sign out is not working", and it half was. Portal navigated
straight to client.logout(), which builds the RP-initiated logout URL and does
nothing else. The server end is genuinely correct -- measured against prod, the
leftover token comes back 401 "the access token is invalid or revoked" -- so
this is not an access leak. What survived was the token STRING and its
siblings: every hanzo_iam_* key stayed in storage after signing out.
That is enough to read as broken, because the presence of that key is how other
surfaces decide you are signed in. hanzoai/playground's AuthGuard branches on
sessionStorage.getItem('hanzo_iam_access_token') before anything else. And a
token that outlives the session it names is worth deleting wherever it sits.
signOut() is now ONE call: clear, then hand back the IdP URL. A URL builder
that also mutates storage would be a trap, so logout() keeps its single job and
the complete operation gets its own name -- the call site asks for the outcome,
not the two halves.
Three details that are the bug in miniature:
- Sweep the hanzo_iam_ PREFIX, never a list of literals. A list here is a
second copy of the SDK's key set, and the copy is what goes stale when the
SDK adds one. There are nine today.
- Collect keys, THEN delete. Removing while iterating by index re-indexes the
store and skips every other key.
- Both storages: the token is in sessionStorage, but the PKCE verifier is in
localStorage by design (it has to survive the redirect to the IdP).
Tested with a neighbour key that must SURVIVE, so "it cleared everything"
cannot pass; and the guard is known to bite -- neutering the prefix check fails
it by name, restoring passes. 172/172, typechecks clean.
Co-authored-by: Hanzo Dev <[email protected]>
Reported from a phone: fill in email + password on hanzo.id/signup, press
Create account, get "the application does not allow to sign up new account".
Not reproducible on demand, which is the tell.
resolveOrg spreads the runtime catalog OVER the built-in table, and for
hanzo.id the two named DIFFERENT applications:
built-in DEFAULT_TENANTS hanzo-id enableSignUp false
catalog /config.json hanzo-console enableSignUp true
So the answer to "can this person create an account" depended on whether a
second network fetch arrived before the form was submitted. When it did, signup
worked -- which is why it looks fine from a desk. When it did not, the page
still rendered a full form (the fallback app permits password login, just not
registration) and IAM refused the POST. On a phone a dropped request is
ordinary, so that is where it showed up.
Confirmed against prod by replaying the exact POST the SPA makes:
clientId=hanzo-console application=hanzo-console -> past the gate
clientId=hanzo-console application=hanzo-id -> "the application does
not allow to sign up
new account"
The second line is the customer's screenshot, reproduced.
lux.id had the same divergence (lux-id vs lux-cloud); both are false there, so
it was latent rather than live. Fixed with hanzo's -- the invariant is one host
one app, not "the currently harmful ones".
The fix is NOT to make the fallback survivable. This table is a fallback for a
failed /config.json, not a second opinion, so the answer is that there is only
one answer per host. org.test.ts now asserts that resolving a host WITH and
WITHOUT the catalog yields the same clientId; reverting org.ts reproduces the
failure ("hanzo.id fallback", 1 failed | 15 passed) and restoring it passes,
so the guard is known to bite. 171/171, typecheck clean.
Co-authored-by: Hanzo Dev <[email protected]>
Every credential this portal takes was masked with no way to unmask. That is
the most common cause of a sign-in failure that looks like a wrong password,
and it is worst where typing is least reliable -- a phone keyboard, one glyph
at a time, with autocorrect and a shifted layout in the way. On SIGNUP it is
worse still: the account is created with whatever was actually typed, so a
typo behind the dots locks you out of an account you believe you just made.
ONE PasswordField, used by both forms. Login and signup had separate
hand-rolled <label>+<input> pairs; adding a toggle to each would have been two
answers to one question. Both now render the same primitive, so the field is
also the one place autoComplete is stated (current-password vs new-password --
getting that wrong is how a manager offers to "update" a saved credential
during a fresh signup).
Details that are the whole feature:
- type="button". A bare <button> in a <form> defaults to submit, so the
reveal would have submitted the form.
- The button is OUTSIDE the <label>. A <button> inside one is activated
twice -- as itself, and again by the label forwarding to its control -- so
the toggle would fight itself. htmlFor keeps click-the-text-to-focus.
- 44x44, the touch floor this stylesheet already holds everywhere else. An
icon button only as big as its 20px glyph is a miss on a phone, which is
the device this is for.
- The input gives back 58px of right padding, so a long password never runs
under the eye.
- aria-pressed carries the state and the colour follows it, not :hover --
on touch there is no hover, and "is my password on screen" must not
depend on where a pointer is.
- Icons live in ui/icons.tsx with the provider marks, inline and
currentColor-driven. No icon dependency on the credential path.
Measured in a real browser on the built bundle, /login and /signup, at 390 /
768 / 1440 with the viewport actually resized between readings: button 44x44,
input 44 high, button inside the input box, no horizontal overflow, in all six.
Toggle across frames: password -> text -> password, aria-pressed false -> true
-> false. Suite 170/170, typecheck clean.
Default is masked. Nothing about the resting page changes.
Co-authored-by: Hanzo Dev <[email protected]>
ff-main has been red every ten minutes. Eleven consecutive failures in the
hour I read it, and not one of them a divergence: the branch asked only
"is LOCAL an ancestor of REMOTE" and called every other answer DIVERGED,
so the forge simply being AHEAD of GitHub took the loud-failure path.
That is the normal state after every native push, and it is not brief --
the push-mirror is sync_on_commit with an 8h floor, so one commit can
produce up to 48 false alarms before GitHub catches up.
The cost is not the noise. A job that is always red cannot report the one
thing it exists to report, and this job's whole value is refusing to
force-push a real divergence. The loud failure is worth keeping only if it
is rare.
Four sibling repos (app, git, hanzo.ai, iam) already carry exactly this
two-direction check; id had the stale copy. Same logic, same order --
this is convergence onto the estate's answer, not a new one. HIPs,
mirrors and python-sdk are still stale and are being fixed with it.
Exercised against real git ancestry, all four states:
state old new
equal in-sync(0) in-sync(0)
github-ahead fast-forward(0) fast-forward(0)
native-ahead DIVERGED(1) native-ahead(0) <- the fix
diverged DIVERGED(1) DIVERGED(1) <- alarm preserved
One row moves. The genuine-divergence alarm still fires, which is what
keeps this a fix rather than a mute button.
Co-authored-by: Hanzo Dev <[email protected]>
hanzo.id/signup rendered a full credential form for EVERY client_id, and
IAM refuses signup at SUBMIT — so on 48 of 51 hanzo applications a visitor
typed an email and a password and only then got "the application does not
allow to sign up new account". The provider buttons dead-ended the same
way: federation PROVISIONS a local user for a new identity, so a first-time
GitHub sign-up hit the same gate after a full round trip through GitHub.
enableSignUp was already on AppLogin and already fetched -- SignupForm reads
the same row inside onSubmit, too late to change what is on screen. Reading
it at the page turns the refusal from a surprise into a state.
It FAILS OPEN on purpose: an unreadable app config still renders the form and
the server still refuses. This is honesty about an answer we already have, not
a gate; internal/oidc/signup.go stays the only gate.
Measured against LIVE IAM, built bundle, all three directions:
client_id enableSignUp <form> input[type=password]
hanzo-bot true 1 1
hanzo-chat false 0 0
does-not-exist-probe unresolvable 1 1 (fail-open)
The middle row is the fix; the outer rows are what make it evidence rather
than a blanket removal of the form.
No new CSS: .hanzo-id-info already exists for exactly this.
Co-authored-by: Hanzo Dev <[email protected]>
hanzo.chat's homepage "Sign up" links to hanzo.id/signup/hanzo-chat -- the
client id in the PATH, from three live components (Visitor, MobileNav, the
error surface). App.tsx has always routed that shape (`path.startsWith
('/signup/')` and '/login/' are in its switch), so it is accepted by design.
The pages then read only `?client_id`, so the segment was matched and thrown
away.
What that produced: the page fell back to the host's default app, rendered a
form, and created the account under hanzo-console -- a DIFFERENT application
than the button that asked for it -- then left the new customer on hanzo.id
with no redirect_uri to return through. Signup "worked" and the funnel dropped
them.
One resolver, used by both pages. Query wins when both are present: it is the
OAuth request and the only one carrying a redirect_uri.
The segment must look like an IAM client id (<org>-<app>, the estate's one
naming rule) or it resolves to undefined and the host default applies, which is
exactly the previous behaviour. Tested: both shapes, precedence, and eight
rejects including a deeper path, wrong case, and a traversal attempt -- so
"accept anything" cannot pass. 175/175, typecheck clean.
Co-authored-by: Hanzo Dev <[email protected]>
Reported as "sign out is not working", and it half was. Portal navigated
straight to client.logout(), which builds the RP-initiated logout URL and does
nothing else. The server end is genuinely correct -- measured against prod, the
leftover token comes back 401 "the access token is invalid or revoked" -- so
this is not an access leak. What survived was the token STRING and its
siblings: every hanzo_iam_* key stayed in storage after signing out.
That is enough to read as broken, because the presence of that key is how other
surfaces decide you are signed in. hanzoai/playground's AuthGuard branches on
sessionStorage.getItem('hanzo_iam_access_token') before anything else. And a
token that outlives the session it names is worth deleting wherever it sits.
signOut() is now ONE call: clear, then hand back the IdP URL. A URL builder
that also mutates storage would be a trap, so logout() keeps its single job and
the complete operation gets its own name -- the call site asks for the outcome,
not the two halves.
Three details that are the bug in miniature:
- Sweep the hanzo_iam_ PREFIX, never a list of literals. A list here is a
second copy of the SDK's key set, and the copy is what goes stale when the
SDK adds one. There are nine today.
- Collect keys, THEN delete. Removing while iterating by index re-indexes the
store and skips every other key.
- Both storages: the token is in sessionStorage, but the PKCE verifier is in
localStorage by design (it has to survive the redirect to the IdP).
Tested with a neighbour key that must SURVIVE, so "it cleared everything"
cannot pass; and the guard is known to bite -- neutering the prefix check fails
it by name, restoring passes. 172/172, typechecks clean.
Co-authored-by: Hanzo Dev <[email protected]>
Today's sign-in outage: during the 0.2.34 rollout a module fetch for the
new bundle hit an old replica, the bare 404 had no cache-control, and
Cloudflare's edge cached it for four hours. hanzo.id served a black page
with a healthy origin behind it. spa 1.4.11 marks asset-miss 404s
no-store (and reunites the spa lineage that shipped as 1.4.3-1.4.8 from
a branch no remote called main).
Co-authored-by: Hanzo Dev <[email protected]>
Reported from a phone: fill in email + password on hanzo.id/signup, press
Create account, get "the application does not allow to sign up new account".
Not reproducible on demand, which is the tell.
resolveOrg spreads the runtime catalog OVER the built-in table, and for
hanzo.id the two named DIFFERENT applications:
built-in DEFAULT_TENANTS hanzo-id enableSignUp false
catalog /config.json hanzo-console enableSignUp true
So the answer to "can this person create an account" depended on whether a
second network fetch arrived before the form was submitted. When it did, signup
worked -- which is why it looks fine from a desk. When it did not, the page
still rendered a full form (the fallback app permits password login, just not
registration) and IAM refused the POST. On a phone a dropped request is
ordinary, so that is where it showed up.
Confirmed against prod by replaying the exact POST the SPA makes:
clientId=hanzo-console application=hanzo-console -> past the gate
clientId=hanzo-console application=hanzo-id -> "the application does
not allow to sign up
new account"
The second line is the customer's screenshot, reproduced.
lux.id had the same divergence (lux-id vs lux-cloud); both are false there, so
it was latent rather than live. Fixed with hanzo's -- the invariant is one host
one app, not "the currently harmful ones".
The fix is NOT to make the fallback survivable. This table is a fallback for a
failed /config.json, not a second opinion, so the answer is that there is only
one answer per host. org.test.ts now asserts that resolving a host WITH and
WITHOUT the catalog yields the same clientId; reverting org.ts reproduces the
failure ("hanzo.id fallback", 1 failed | 15 passed) and restoring it passes,
so the guard is known to bite. 171/171, typecheck clean.
Co-authored-by: Hanzo Dev <[email protected]>
Every credential this portal takes was masked with no way to unmask. That is
the most common cause of a sign-in failure that looks like a wrong password,
and it is worst where typing is least reliable -- a phone keyboard, one glyph
at a time, with autocorrect and a shifted layout in the way. On SIGNUP it is
worse still: the account is created with whatever was actually typed, so a
typo behind the dots locks you out of an account you believe you just made.
ONE PasswordField, used by both forms. Login and signup had separate
hand-rolled <label>+<input> pairs; adding a toggle to each would have been two
answers to one question. Both now render the same primitive, so the field is
also the one place autoComplete is stated (current-password vs new-password --
getting that wrong is how a manager offers to "update" a saved credential
during a fresh signup).
Details that are the whole feature:
- type="button". A bare <button> in a <form> defaults to submit, so the
reveal would have submitted the form.
- The button is OUTSIDE the <label>. A <button> inside one is activated
twice -- as itself, and again by the label forwarding to its control -- so
the toggle would fight itself. htmlFor keeps click-the-text-to-focus.
- 44x44, the touch floor this stylesheet already holds everywhere else. An
icon button only as big as its 20px glyph is a miss on a phone, which is
the device this is for.
- The input gives back 58px of right padding, so a long password never runs
under the eye.
- aria-pressed carries the state and the colour follows it, not :hover --
on touch there is no hover, and "is my password on screen" must not
depend on where a pointer is.
- Icons live in ui/icons.tsx with the provider marks, inline and
currentColor-driven. No icon dependency on the credential path.
Measured in a real browser on the built bundle, /login and /signup, at 390 /
768 / 1440 with the viewport actually resized between readings: button 44x44,
input 44 high, button inside the input box, no horizontal overflow, in all six.
Toggle across frames: password -> text -> password, aria-pressed false -> true
-> false. Suite 170/170, typecheck clean.
Default is masked. Nothing about the resting page changes.
Co-authored-by: Hanzo Dev <[email protected]>
ff-main has been red every ten minutes. Eleven consecutive failures in the
hour I read it, and not one of them a divergence: the branch asked only
"is LOCAL an ancestor of REMOTE" and called every other answer DIVERGED,
so the forge simply being AHEAD of GitHub took the loud-failure path.
That is the normal state after every native push, and it is not brief --
the push-mirror is sync_on_commit with an 8h floor, so one commit can
produce up to 48 false alarms before GitHub catches up.
The cost is not the noise. A job that is always red cannot report the one
thing it exists to report, and this job's whole value is refusing to
force-push a real divergence. The loud failure is worth keeping only if it
is rare.
Four sibling repos (app, git, hanzo.ai, iam) already carry exactly this
two-direction check; id had the stale copy. Same logic, same order --
this is convergence onto the estate's answer, not a new one. HIPs,
mirrors and python-sdk are still stale and are being fixed with it.
Exercised against real git ancestry, all four states:
state old new
equal in-sync(0) in-sync(0)
github-ahead fast-forward(0) fast-forward(0)
native-ahead DIVERGED(1) native-ahead(0) <- the fix
diverged DIVERGED(1) DIVERGED(1) <- alarm preserved
One row moves. The genuine-divergence alarm still fires, which is what
keeps this a fix rather than a mute button.
Co-authored-by: Hanzo Dev <[email protected]>
hanzo.id/signup rendered a full credential form for EVERY client_id, and
IAM refuses signup at SUBMIT — so on 48 of 51 hanzo applications a visitor
typed an email and a password and only then got "the application does not
allow to sign up new account". The provider buttons dead-ended the same
way: federation PROVISIONS a local user for a new identity, so a first-time
GitHub sign-up hit the same gate after a full round trip through GitHub.
enableSignUp was already on AppLogin and already fetched -- SignupForm reads
the same row inside onSubmit, too late to change what is on screen. Reading
it at the page turns the refusal from a surprise into a state.
It FAILS OPEN on purpose: an unreadable app config still renders the form and
the server still refuses. This is honesty about an answer we already have, not
a gate; internal/oidc/signup.go stays the only gate.
Measured against LIVE IAM, built bundle, all three directions:
client_id enableSignUp <form> input[type=password]
hanzo-bot true 1 1
hanzo-chat false 0 0
does-not-exist-probe unresolvable 1 1 (fail-open)
The middle row is the fix; the outer rows are what make it evidence rather
than a blanket removal of the form.
No new CSS: .hanzo-id-info already exists for exactly this.
Co-authored-by: Hanzo Dev <[email protected]>
This repo read EVENT_INGEST_KEY; hanzo.chat, hanzo.app, hanzo.ai,
platform and the shared hanzoai/ci `build_secrets` all read
PUBLISHABLE_KEY. Same key, same KMS path, two spellings — and this repo
was the last holdout, which is why the duplicate entry had to exist at
all.
That duplication is a live rotation hazard: two KMS entries holding one
value means rotating either one silently 403s whichever half still reads
the other. Collapsing to a single name is what lets the duplicate be
deleted.
Renamed end to end: the build-arg, the ENV, the Vite-inlined
VITE_PUBLISHABLE_KEY, the ambient type, hanzo.yml's build_secrets, and
every message that names the KMS address in a failure.
Also corrected a claim this file repeats and that turned out to be
false everywhere it appeared: an unkeyed beacon does NOT take an
"anonymous lane" that files rows under a $public tenant and answers 200.
Measured against the live door, with and without a browser Origin, for
pageviews and exceptions alike: a hard 401 ingest_key_required. Nothing
arrives. That myth is why "we still get errors, just not events" was
believed across four repos while keyless surfaces reported nothing at
all.
No behaviour change beyond the name: the fail-closed `case` gate and the
inlining assertion are untouched, and this repo's gate was already
sound — the case rejects an empty value before the grep, so it never had
the `grep -F ""` hole that let hanzoai/chat pass keyless builds.
Co-authored-by: Hanzo Dev <[email protected]>
hanzo.app's "Get started" forwards `signup=true` on the authorize request and
this page ignored it. Measured end to end: the CTA lands on "Sign in to Hanzo ID"
with empty credentials, and appending `&signup=true` to the authorize URL by hand
changes nothing. Every net-new customer had to notice the small "Create account"
link to get past a form they could not fill in. The app was doing its part; the
IdP dropped the hint.
`screen_hint=signup` is honored too — the OIDC-standard spelling of the same
request, so a compliant client works without knowing our name for it.
It LOSES to silent SSO, and that ordering is the point. A browser already
carrying an issuer session belongs to someone who HAS an account, and sending
them to registration is worse than ignoring the hint. So this sits in `fallback`,
where `provider_hint` already sits, and only decides what happens when there is
no session to mint from.
The navigation carries the whole search string. Registration is its own page, and
client_id, redirect_uri, state and the PKCE challenge live in that query — a bare
`/signup` strands the new account with nowhere to return to. `replace` rather
than `assign` so Back reaches whatever sent the user here, not a login page that
would bounce forward again.
Both invariants are mutation-verified: short-circuit silent SSO and the ordering
test fails; drop the search string and the round-trip test fails.
Co-authored-by: Hanzo Dev <[email protected]>
The bullet claimed --ring was var(--neutral-500) at 4.43:1; it has been
var(--white-40) at 3.77:1 for the whole 0.4.x line. It also described base.css
as shipping ONE :focus-visible rule, which only became true at @hanzo/design
0.4.9 — before that a second, field-specific rule tied it at (0,1,0) and lost
to source order, so focused inputs here drew two indicators.
Records the part the next reader cannot derive: why a local focus override is
still the wrong fix, and why the treatment that rule was applying could not have
worked on this surface at all (its indicator rode on border-color, and
.hanzo-id-input states border unlayered).
Co-authored-by: Hanzo Dev <[email protected]>
@hanzo/design 0.4.5 -> 0.4.9 fixes the focus indicator at the root. Two rules in
its base layer both computed to (0,1,0), so source order decided, and the generic
ring overrode the `outline:none` the field rule stated to prevent it: every
focused input on hanzo.id drew BOTH the 2px ring and a halo. Measured on the
shipped bundle, before 2 indicators and after 1. Nothing in this repo could fix
that — app.css deliberately keeps no focus rule of its own — and the same bump
carries it to lux.id, zoo.id and pars.id.
The rest is this surface's own:
The filled button had no hover. It has declared `transition: background` since it
was written and --primary-hover has always existed, so Sign in / Continue /
Create account animated a property nothing changed. `:not(.ghost)` keeps it from
tying with the ghost hover at (0,4,0), where order would otherwise pick the
winner — the same defect class as the ring above, so it is not written that way
here. Measured: #fafafa -> #e5e5e5 on hover, ghost unchanged.
The brand lockup is pinned to 32px. BrandHeader writes `height={32}`, but a
presentational attribute loses to design's `:where(img,video){height:auto}`, so
each portal was sized by whatever its brand package shipped. @hanzo/brand's SVG
has a viewBox and no width/height, so Hanzo landed on 32 and looked correct;
@luxfi/brand's declares 1024x1024, so lux.id has been rendering a 342px mark over
its sign-in form. Now 32x32 on all three, Hanzo unchanged.
min-height moves to 100dvh with 100vh kept underneath as the fallback: on a phone
`vh` is the URL-bar-retracted viewport, so a full-height page overflows the space
it actually has.
Styling and one dependency only — no flow, field, CSRF or handler is touched.
160 tests pass, tsc clean, all four brands verified.
Co-authored-by: Hanzo Dev <[email protected]>
/config.json is emitted by hanzoai/spa, which derives its key mechanically
from the env var universe supplies: SPA_IAM_TENANT_CONFIG_JSON becomes
iamTenantConfigJson. Renaming TenantConfig -> OrgConfig in this codebase
also renamed the READ to iamOrgConfigJson, a key nothing emits.
So the catalog has been undefined on every host for as long as that
shipped, with no error anywhere — the fetch succeeds, the field is
absent, and resolveOrg falls back. Verified against the live endpoint:
https://hanzo.id/config.json returns iamTenantConfigJson, and the SPA
was reading past it.
Two consequences, both live:
hanzo.id resolved to the BUILT-IN hanzo-id (enableSignUp:false) instead
of the catalog's hanzo-console (true). A first-time federated user is
refused "the application does not allow to sign up new account" — so
this gates the GitHub fix in 0.2.20 for exactly the users it was for.
Every catalog-ONLY host — osage.id, zoolabs.id, id.zoo.network,
id.lux.network, iam.lux.network, id.pars.network, id.bootno.de,
iam.hanzo.ai — got the empty-clientId skeleton and could resolve no
application at all. The resolver's fail-closed behaviour is correct and
is why this leaked no brand; it is also why it was silent.
The key now lives in one place, catalogOf, next to the resolver it feeds,
with the reason it is not ours to rename. Guarded by two tests that fail
on the old spelling and pass on the new.
Co-authored-by: Hanzo Dev <[email protected]>
GitHub sign-in succeeded at GitHub and dead-ended here — "you need to
login first", which is IAM's 401 from /v1/iam/onboard reached with no
session and no bearer. The SPA never obtained a token.
social.ts built the IdP URL in the browser and pointed it at our own
/callback, so GitHub returned a GitHub code that NOTHING can spend:
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 knows only password, device approval
and code minting.
IAM has implemented this correctly the whole time and was never called.
Naming a provider on the authorize endpoint IS the entry point: it
validates client_id, the exact redirect_uri and PKCE, then federates
server-side and returns an IAM code bound to the original challenge.
OAuthAuthorizeRequest.provider was already declared here; authorize()
simply never emitted it. That one parameter is the fix; the rest of this
diff deletes what existed to work around its absence.
The name is the record name — provider-github, never github:
federationProvider matches ProviderItem.Name exactly. A comment claimed
the opposite; corrected in place.
Two arms, the two the password path already branches on, differing only
in who owns the PKCE verifier: an app-initiated request is re-entered
verbatim so IAM mints against that app and returns the browser straight
to it; a bare portal sign-in goes through the SDK, which persists the
verifier in localStorage keyed by state so it survives the redirect.
Gone as dead: the IdP endpoint table, buildProviderAuthUrl,
startProviderLogin, isHoppableProvider, encodeState/decodeState,
client.providerLogin, ProviderExchangeRequest, and Callback's provider
branch. /callback now has one case, because a federated return is
indistinguishable from any other.
Verified in a browser against live IAM: both arms reach GitHub via
/v1/iam/oauth/authorize with redirect_uri=<brand>/v1/iam/oauth/callback
and the hanzo_fed cookie; the app arm creates no verifier slot, proving
the app's own request was forwarded; the return leg consumes the slot
and posts /v1/iam/oauth/token, which answers invalid_grant for a
deliberately fake code. The GitHub login itself is not exercisable here.
Co-authored-by: Hanzo Dev <[email protected]>
Three errors, each the kind that sent someone down a wrong path today.
`verification_uri_complete` does not add `?user_code=<code>`. IAM appends the
code as a PATH segment — `/login/oauth/device/<code>` — because that is the
route this page is registered on (device.go). readUserCode accepts the query
form too, so both work, but the minted URI is the path one.
Approval does not flip `UserSignIn=true`. No such field exists on the row.
approveDevice binds the approver onto `Token.User` as owner/name, and an EMPTY
User is what "not yet approved" means — that is the whole state machine, and a
reader looking for a boolean will not find one.
The CLI is `hanzo login`, not `dev login --device-auth`.
Also re-runs the 0.2.19 build: the previous one died in the oci.hanzo.ai login
step on a transient 502 (the registry answers 401 again, i.e. healthy), so no
image was ever pushed for this version and nothing is being re-tagged.
Co-authored-by: Hanzo Dev <[email protected]>
0.2.18 was cut, built and published BEFORE the two fixes it is named after
landed. The build workflow refuses to rebuild an existing version — correctly,
since re-pushing a tag yields two digests for one name — so f03c778 (drop the
confirmation checkbox) and everything after it never produced an image. The tag
in universe stayed 0.2.18 and the cluster kept serving the bytes it already had,
which is why the platform owner has asked four times for a checkbox that has
been gone from source the whole time.
A version is the only thing that publishes. Patch-bump, never a re-push.
Ships: the self-attestation checkbox removal (f03c778), and the approval page
naming the client that actually minted the code (5c6bf45).
Co-authored-by: Hanzo Dev <[email protected]>
The page said "You are about to authorize hanzo-console" for a sign-in started
by hanzo-cli. appLabel was org.appName — this PORTAL's own branding, a static
per-brand string — so every device code, whichever client minted it, was
approved under the same wrong name. The one job of the screen is to say WHICH
application you are authorizing, and a screen that names the wrong one does not
merely fail to help: it teaches people the name means nothing.
f682899 removed the false name but concluded the right one could not be learned
without building a code-hunting oracle. That was true of an open lookup and is
not true of the one IAM now serves: POST /v1/iam/oauth/device/info is session
gated, org scoped, and answers unknown / expired / already-approved with a
single opaque refusal, so it reveals strictly less than the approval the same
caller could already attempt. The comment arguing the endpoint must not exist is
replaced by what is actually true of it.
The code rides the POST BODY. A user_code is the one secret in this flow and a
request line is copied into ingress and proxy access logs where a body is not —
this page already ships scrubUrl() to keep the code out of the address bar, and
a GET would have undone that on the server side.
Approve is gated on a resolved name and fails closed: no server-confirmed
application, no button. Nothing local is ever substituted, because rendering a
guess is the defect being fixed. THIS MAKES THE PORTAL DEPEND ON IAM b466bd63 —
ship IAM first or together, or every approval blocks.
Co-authored-by: Hanzo Dev <[email protected]>
APPROVING THE WRONG NAME. The device page read "You are about to authorize
hanzo-console" for a sign-in started by hanzo-cli, because appLabel was
tenant.appName — this PORTAL's own branding, a static string, not the client that
asked. The backend was never confused: approveDevice binds the pending row's own
application, and says so ("the portal app the browser happens to be on is
irrelevant to WHAT is being approved").
The page cannot honestly name the client, and must not learn it: the user_code is
40 bits and the one secret in this flow, so an endpoint answering "which app is
this code for" would be the oracle device.go refuses to be — it returns one opaque
refusal for unknown / expired / already-approved precisely so live codes cannot be
hunted. So the page stops naming a party it cannot vouch for and asks about the
thing it can: the code the human transcribed off their own device. A consent screen
that names the wrong party is worse than one that names none — it teaches people
the name means nothing.
ORGS, NOT TENANTS. There is no tenant concept here; the identifier has always been
the org (the JWT `owner` claim, the IAM `<org>-<app>` namespace). TenantConfig →
OrgConfig, resolveTenant → resolveOrg, tenant → org, and the module renamed to
match. 28 files, no residual `tenant`, typecheck clean, 145 tests pass.
Co-authored-by: Hanzo Dev <[email protected]>
It read 'You are about to authorize hanzo-console' for a sign-in started by
hanzo-cli: appLabel was tenant.appName, this PORTAL's own branding, never the
client being approved. A consent screen that names the wrong party is worse than
one that names none — it teaches people the name means nothing.
It cannot honestly learn the right name either, and must not: the user_code is
40 bits and the only secret in this flow, so an endpoint that revealed which app
a code belonged to would be an oracle for hunting live codes — exactly what
device.go refuses to be, answering unknown, expired and already-approved with
one opaque refusal.
So the prompt asks about the thing this page CAN vouch for: the code in front of
the human, and whether they started the sign-in themselves. That is also where
the removed checkbox's intent belongs — in the sentence someone reads before
clicking, not a tickbox in front of it.
Co-authored-by: Hanzo Dev <[email protected]>
A release is a version bump; 0.2.17 already shipped the signup fix and its image
is published, so the checkbox change needs its own.
Co-authored-by: Hanzo Dev <[email protected]>
No device page anyone actually uses has one — Google, GitHub and AWS all show
the code and an Approve button. It sat in front of the only action on the screen
and disabled it until ticked.
It was there as an anti-phishing gate, on the theory that a victim arriving from
a crafted verification_uri_complete link must not approve with one click. A
tickbox is not evidence of that: someone being walked through a crafted link
ticks it exactly as readily as they click Approve. The property that does the
work is the one that stays — the code is displayed and remains EDITABLE, so
approving is an explicit act on a value the human can read and correct against
what their own device shows.
Co-authored-by: Hanzo Dev <[email protected]>
A customer who clicked "Create account" from an app never came back. Two
defects composed, and every unauthenticated probe read healthy through both.
`Login` linked to a bare `/signup`, so the OIDC request the app sent —
client_id, redirect_uri, state, PKCE challenge — was dropped at a full page
load. `Signup` already parsed exactly those params; nothing ever supplied
them.
Then IAM's signup is CREATE-ONLY. It persists the user and answers with the
row: no session cookie, no authorization code. The `autoSignin: true` the
client posted has no field in `internal/oidc/signup.go`, so the Go decoder
dropped it — "signed up" and "signed in" were never the same event. That
response fell through `parseLoginResponse`'s no-redirect arm to
`{redirectUrl: '/onboarding'}`, so the new user was sent to the portal's own
onboarding, unauthenticated, while the app waited on a code no one minted.
The links now carry the request, and `signup()` finishes the job through the
one path that establishes a session: it logs in, which mints the code bound
to this request's challenge and returns the redirect back to the app. A
signup that leaves you logged out is not a signup.
`parseCreated` reads the create leg, deliberately apart from
`parseLoginResponse` — that one INVENTS a destination when no redirect_uri
was asked for, which is right for a session and wrong for a row.
Registration also resolves the app via `get-app-login` the way `LoginForm`
does, so the account is created under the app the user came FROM rather than
the portal's own tenant.
Contracts locked in client.test.ts; both regressions verified by reverting the
fix and watching them fail. Driven end to end in Chromium against the built
SPA with IAM stubbed at its real response shapes: the params survive the
click and the browser lands on the app's callback holding the code.
Co-authored-by: Hanzo Dev <[email protected]>
hanzoai/iam is Hanzo IAM — original, clean-room work; the vendor-derived server
was hanzoai/iam-v1, retired and in nothing we ship. The comments here named that
vendor for behavior that is IAM's own: get-account really does return the User
at the top level or under `data`, and add-organization really is entity CRUD
behind the authenticated Guard. Kept the facts, dropped the wrong name.
Comments and markdown only.
Co-authored-by: Hanzo Dev <[email protected]>
0.2.14 adopted @hanzo/design and worked around it in three places. Those
findings are fixed IN @hanzo/design 0.3.0, so the workarounds go and this
surface takes the system's answer.
- ONE import: `@hanzo/design/styles.css`. app.css cherry-picked four of nine
token groups, so --z-*, --shadow-*, --space-*, --font-* and the element
defaults did not exist here. Nothing broke visibly, because an unresolved
var() paints nothing and reports no error. @hanzo/iam's account menu alone
reaches for --z-popover, --shadow-floating and --space-1..3.
- Geist is self-hosted in 0.3.0, so the reason for cherry-picking is gone:
fonts.css no longer requests fonts.googleapis.com. Measured on the built
bundle: rendered face is Geist, zero third-party font requests.
- The :focus-visible rule is deleted from this file. tokens/base.css ships it
to every consumer and --ring is now var(--neutral-500): measured 2px solid
rgb(115,115,115), 4.43:1 on --background (WCAG 2.4.11 wants 3:1). The local
--primary override existed only because --ring was 1.66:1.
- Control boundaries are --border-strong, not --white-40. 0.3.0 moved it to
var(--neutral-500) — 4.43:1 in BOTH themes. --white-40 cleared 3:1 on black
and measures 1.00:1 on white.
- @hanzo/iam 0.13.1 -> 0.21.1 in apps/web, pkgs/auth, pkgs/onboarding, and the
signed-in portal mounts <UserMenu> in place of a hand-rolled Billing/Sign out
link row. Identity comes from resolveIdentity, so the portal cannot disagree
with the console about who you are. No `brand` prop: omitting markSvg would
put the Hanzo mark on lux.id and zoo.id.
- Portal.tsx's inline style object is gone — rgba(255,255,255,0.14),
borderRadius 12, fontSize 13, fontWeight 600 and two bare opacities, six
invented values for facts the token layer already states.
- apps/web/src/tokens.test.ts gates RESOLUTION, not declaration. The reference
is built at runtime from a string, so it is invisible to the compiler and to
grep. Verified by reintroducing both original defects: it names the 5 dropped
groups and the 8 unresolved iam tokens.
Measured in Chromium, fresh context, 1440 and 390, on the built bundle served
as hanzo.id / lux.id / zoolabs.id: 0 unresolved of 76 tokens referenced on every
host; input edge and focus ring both 4.43:1; the account menu paints #0a0a0a /
12px / --shadow-floating / z-index 700. Brand switches with zero per-brand code
and no Hanzo mark reaches the Lux menu.
Open, against layers below this surface: the menu's own panel edge is --border
at 1.27:1 on --popover (0.3.0 raised --ring and --border-strong, not --border),
and BrandHeader loads the mark from cdn.jsdelivr.net/@latest — a third-party
request on the sign-in path, which is what this file refuses for fonts.
Co-authored-by: Hanzo Dev <[email protected]>
The running image (0.2.13, built from e620df2) was NOT on origin/main. The two
had diverged at 6d74def6 with the live code unmerged: self-service org creation
founding through /v1/iam/onboard instead of the admin-only add-organization verb
(which is 401 without a bearer and 403 with one, so signup was dead), and the
App/Chat/Cloud launcher. A release cut from main would have silently regressed
both on hanzo.id, lux.id, pars.id and zoo.id at once.
This merges them. main is once again what runs, and 0.2.14 is the first tag that
carries BOTH the live fixes and the @hanzo/design token pass. The only conflict
was the version line.
136 unit tests and every workspace typecheck green; the login, device-approval
and spinner measurements re-taken in a real browser against the merged bundle.
Co-authored-by: Hanzo Dev <[email protected]>
A component's surface must never depend on WHERE it is mounted. app.css painted
controls with the descendant selectors `form input {…}` and `.hanzo-id-btn, form
button {…}`. DeviceApproval — 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, a 2px inset bevel,
square corners, sitting beside correctly-styled 44px siblings. That is the mirror
image of a component library shipping utility class names with no CSS behind
them — bare elements instead of bare class names — and it was live on hanzo.id,
lux.id and pars.id at once. Every control now carries its own class
(.hanzo-id-input / .hanzo-id-btn / .hanzo-id-field / .hanzo-id-form) and no
element-descendant selector for surface is left in the file.
.hanzo-id-spinner had NO rule at all: the loading state measured 0px and was
invisible on every portal. It is a real 28px ring now.
Tokens come from @hanzo/design — the same token layer hanzoai/pay renders from,
so the two halves of one flow (sign in -> pay) agree on the page black (#000000,
--background; the page was painted with the --card value), the type ramp (h1 21px
on both), the radii and the greys. Nothing in this file invents a colour, radius
or size. @hanzo/gui was declared as a dependency and imported exactly zero times;
it is removed rather than left as decoration.
ONE button: .hanzo-id-social-btn and the .primary modifier are gone. .hanzo-id-btn
IS primary and .ghost is the secondary surface, so 13 treatments become 1
primitive with 1 modifier. ONE focus indicator: the file had a single focus rule
(`form input:focus`), so every button, link and social entry fell back to
Chrome's blue outline: auto on a monochrome surface. `font: inherit` on 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 on purpose. On --background the 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), and --white-40 measures 3.66:1
and is on the ladder. Same reasoning for the focus ring: --ring (#333333)
measures 1.66:1 and cannot carry one, so the ring is --primary.
Fonts are deliberately NOT imported from the design package: tokens/fonts.css
pulls Geist from fonts.googleapis.com and the sign-in path loads no third-party
font. --font-sans resolves to the platform stack, the identical value hanzoai/pay
sets.
Measured in a real browser against the built bundle, fresh context and empty
storage: page #000000, every control 44px, one 6px radius, h1 21px, a white
2px focus ring, nothing under the touch floor at 390px, no horizontal overflow.
134 unit tests and every workspace typecheck green.
Co-authored-by: Hanzo Dev <[email protected]>
Confidential OIDC clients that validate strictly (LibreChat openid-client with
OPENID_REUSE_TOKENS) send a nonce on authorize and require the id_token to echo
it. The portal's password-login path read code_challenge from the authorize URL
but DROPPED nonce, so the IAM minted a code (and id_token) with no nonce ->
openid-client 'unexpected JWT claim value encountered' -> hanzo.chat callback
HTTP 500. Thread nonce: Login.tsx reads ?nonce, LoginForm forwards it, and
client.login() puts it on the /v1/iam/login query (next to code_challenge).
Additive + forward-only (never defaulted); social login already rode authorize()
which carried nonce. Verified: id SPA builds green.
Confidential OIDC clients that validate strictly (LibreChat openid-client with
OPENID_REUSE_TOKENS) send a nonce on authorize and require the id_token to echo
it. The portal's password-login path read code_challenge from the authorize URL
but DROPPED nonce, so the IAM minted a code (and id_token) with no nonce ->
openid-client 'unexpected JWT claim value encountered' -> hanzo.chat callback
HTTP 500. Thread nonce: Login.tsx reads ?nonce, LoginForm forwards it, and
client.login() puts it on the /v1/iam/login query (next to code_challenge).
Additive + forward-only (never defaulted); social login already rode authorize()
which carried nonce. Verified: id SPA builds green.
The ARC runner image ships git+docker but not node, so the Compute-tags step's
`node -p "require('./package.json').version"` died with exit 127 (node: command
not found), skipping the build. Parse the version from package.json with
portable sed instead. Verified locally -> 0.1.23.
The ARC runner image ships git+docker but not node, so the Compute-tags step's
`node -p "require('./package.json').version"` died with exit 127 (node: command
not found), skipping the build. Parse the version from package.json with
portable sed instead. Verified locally -> 0.1.23.
runs-on was [self-hosted, linux, amd64], which GitHub matched to the OFFLINE
classic evo-* org runners instead of the live ephemeral ARC scale set, so every
Docker run since the self-contained-build switch queued forever (never built).
Use runs-on: hanzo-build-linux-amd64 (the exact scaleSetName), matching the
hanzoai/iam convention, so jobs route to scale set 31.
Also switch buildx gha cache to mode=min: the runner builds via DinD on a 32G
node and mode=max exported every layer, ballooning the DinD store past the
kubelet eviction threshold (NodeHasDiskPressure -> build killed 'no space').
runs-on was [self-hosted, linux, amd64], which GitHub matched to the OFFLINE
classic evo-* org runners instead of the live ephemeral ARC scale set, so every
Docker run since the self-contained-build switch queued forever (never built).
Use runs-on: hanzo-build-linux-amd64 (the exact scaleSetName), matching the
hanzoai/iam convention, so jobs route to scale set 31.
Also switch buildx gha cache to mode=min: the runner builds via DinD on a 32G
node and mode=max exported every layer, ballooning the DinD store past the
kubelet eviction threshold (NodeHasDiskPressure -> build killed 'no space').
LoginForm no longer pins organization=<brand> on POST /v1/iam/login. It passes
the new (normally-unset) TenantConfig.loginOrg, and client.login() OMITS the
organization field when empty so IAM runs cross-org resolution and the session
encodes the user's REAL owner-org (GetOrganizationByUser), never the hint.
Fixes: global admins (z@/[email protected], [email protected] — owner=admin) were
truncated to a 1-org 'hanzo' session via the UI because the pinned org made
GetUserByFields hit the colliding hanzo/<name> row first, so the cross-org
fallback to admin/<name> never ran. IAM IsGlobalAdmin()==(Owner=='admin') and
get-organizations returns all orgs only for a global admin. Verified live on
hanzo.id: [email protected] -> owner=admin, 45 orgs; brand-only user -> 1 org.
Boundaries preserved: signup still sends a concrete org; per-app SSO
(client_id+redirect_uri, type=code) still mints a code bound to the resolved
user; a brand can force single-org login via loginOrg in the catalog ConfigMap.
Contract locked in pkgs/auth/src/client.test.ts.
Bump 0.1.22 -> 0.1.23.
LoginForm no longer pins organization=<brand> on POST /v1/iam/login. It passes
the new (normally-unset) TenantConfig.loginOrg, and client.login() OMITS the
organization field when empty so IAM runs cross-org resolution and the session
encodes the user's REAL owner-org (GetOrganizationByUser), never the hint.
Fixes: global admins (z@/[email protected], [email protected] — owner=admin) were
truncated to a 1-org 'hanzo' session via the UI because the pinned org made
GetUserByFields hit the colliding hanzo/<name> row first, so the cross-org
fallback to admin/<name> never ran. IAM IsGlobalAdmin()==(Owner=='admin') and
get-organizations returns all orgs only for a global admin. Verified live on
hanzo.id: [email protected] -> owner=admin, 45 orgs; brand-only user -> 1 org.
Boundaries preserved: signup still sends a concrete org; per-app SSO
(client_id+redirect_uri, type=code) still mints a code bound to the resolved
user; a brand can force single-org login via loginOrg in the catalog ConfigMap.
Contract locked in pkgs/auth/src/client.test.ts.
Bump 0.1.22 -> 0.1.23.
- '/' rendered a static 'Welcome' marketing hero; for an identity portal the
root should BE the login form when signed out, and the org's apps launcher
when signed in. Portal now reads /v1/iam/get-account (same-origin, first-party
cookie) and renders <Login> (anon) or the apps grid (authed).
- Onboarding completion redirected to '/' → the hero, which read as 'looped back
to the beginning' after the last (wallet) step's Skip. It now lands on
'/?signed_in=1' → the authed apps launcher. (The step machine was already
correct: nextStep('wallet')==='done'.)
- Port marketing.ts (appsFor/billingFor) from the restore-design line.
- '/' rendered a static 'Welcome' marketing hero; for an identity portal the
root should BE the login form when signed out, and the org's apps launcher
when signed in. Portal now reads /v1/iam/get-account (same-origin, first-party
cookie) and renders <Login> (anon) or the apps grid (authed).
- Onboarding completion redirected to '/' → the hero, which read as 'looped back
to the beginning' after the last (wallet) step's Skip. It now lands on
'/?signed_in=1' → the authed apps launcher. (The step machine was already
correct: nextStep('wallet')==='done'.)
- Port marketing.ts (appsFor/billingFor) from the restore-design line.
loadBrand did res.json() on the SPA-fallback HTML (the spa server answers
unknown paths with index.html + HTTP 200 for client routing), so a missing
brand.json threw 'Unexpected token <' and crashed App boot -> blank login
page. brand.json is not bundled and the path also wrongly encoded the '/'
in '@hanzo/brand' (%2F), so it never resolved.
Fix:
- Prefer the tenant's brandUrl (the working jsDelivr brand.json from
config.json), then the app-local path; reject non-JSON (SPA-fallback HTML)
responses by content-type; on total failure return a minimal fallback brand
derived from the package scope. Branding is cosmetic and must never block login.
- Add TenantConfig.brandUrl (already supplied by config.json) and pass it from App.
- Drop encodeURIComponent on the whole package (it mangled the scope slash).
Co-authored-by: Antje Worring <[email protected]>
loadBrand did res.json() on the SPA-fallback HTML (the spa server answers
unknown paths with index.html + HTTP 200 for client routing), so a missing
brand.json threw 'Unexpected token <' and crashed App boot -> blank login
page. brand.json is not bundled and the path also wrongly encoded the '/'
in '@hanzo/brand' (%2F), so it never resolved.
Fix:
- Prefer the tenant's brandUrl (the working jsDelivr brand.json from
config.json), then the app-local path; reject non-JSON (SPA-fallback HTML)
responses by content-type; on total failure return a minimal fallback brand
derived from the package scope. Branding is cosmetic and must never block login.
- Add TenantConfig.brandUrl (already supplied by config.json) and pass it from App.
- Drop encodeURIComponent on the whole package (it mangled the scope slash).
Co-authored-by: Antje Worring <[email protected]>
The v1.3.0 git tag exists but no 1.3.0 image was published to ghcr
(build pipeline blocked). 1.2.0 is the latest published spa image and the
version the canonical RECIPE.md uses.
Co-authored-by: Antje Worring <[email protected]>
The v1.3.0 git tag exists but no 1.3.0 image was published to ghcr
(build pipeline blocked). 1.2.0 is the latest published spa image and the
version the canonical RECIPE.md uses.
Co-authored-by: Antje Worring <[email protected]>
hanzoai/static defaults to Content-Security-Policy: default-src 'none'
(no script-src) — built for static assets, not a SPA that loads its own
JS bundle. That CSP blocks index-*.js, so React never mounts and the
login page renders blank. hanzoai/spa is the purpose-built base: history-
API fallthrough for client-side routes + a SPA-safe CSP. Defaults
PORT=3000 / ROOT=/public, matching the id deploy probe.
Immediate prod was unblocked by setting HANZO_STATIC_CSP on the live
deployment; this is the durable, one-way fix so the override hack isn't
needed and every rebuild serves correctly.
Co-authored-by: Antje Worring <[email protected]>
hanzoai/static defaults to Content-Security-Policy: default-src 'none'
(no script-src) — built for static assets, not a SPA that loads its own
JS bundle. That CSP blocks index-*.js, so React never mounts and the
login page renders blank. hanzoai/spa is the purpose-built base: history-
API fallthrough for client-side routes + a SPA-safe CSP. Defaults
PORT=3000 / ROOT=/public, matching the id deploy probe.
Immediate prod was unblocked by setting HANZO_STATIC_CSP on the live
deployment; this is the durable, one-way fix so the override hack isn't
needed and every rebuild serves correctly.
Co-authored-by: Antje Worring <[email protected]>
hanzoai/static:0.4.1 reads the serve root from the -root flag (default
/public) and port from -port (default 3000) — the ENV ROOT=/spa PORT=8080
were no-ops, so the built image served /public (crash) on :8080 while the
deploy probes :3000. Set ENTRYPOINT [/static -root /spa -spa] (default
port 3000). Matches the hand-built id:0.1.5 now live.
Co-authored-by: Antje Worring <[email protected]>
hanzoai/static:0.4.1 reads the serve root from the -root flag (default
/public) and port from -port (default 3000) — the ENV ROOT=/spa PORT=8080
were no-ops, so the built image served /public (crash) on :8080 while the
deploy probes :3000. Set ENTRYPOINT [/static -root /spa -spa] (default
port 3000). Matches the hand-built id:0.1.5 now live.
Co-authored-by: Antje Worring <[email protected]>