100 Commits
Author SHA1 Message Date
zooqueenandHanzo Dev 6e6332304d Counting a sign-up never blocks it, and reaches the client that is live when it happens
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]>
2026-10-16 11:37:43 -07:00
zooqueenandHanzo Dev c98ca92754 Merge main: a devnet identity host's policy and stream go to its own gateway
Co-authored-by: Hanzo Dev <[email protected]>
2026-10-16 11:33:11 -07:00
zooqueenandHanzo Dev 0814531906 Refuse any publishable key, and @hanzo/event's key lookups, in shipped source
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]>
2026-10-16 11:09:56 -07:00
zooqueenandHanzo Dev 7eb7b28d40 Load no browser tag on identity hosts, and start the stream only under the policy in force
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]>
2026-10-16 11:09:56 -07:00
zooqueenandHanzo Dev 0a0f7c8f51 id-dev.hanzo.ai is the devnet's identity host, and stays on the devnet
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]>
2026-10-16 07:27:09 -07:00
zooqueenandHanzo Dev 6c4b180d4e Keep the sign-in standing and silent whatever the banner or the visitor does
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]>
2026-10-16 07:09:28 -07:00
zooqueenandHanzo Dev 7505a7b8bc A tag that throws at sign-up never keeps the new account on the page
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]>
2026-10-16 07:09:27 -07:00
zooqueenandHanzo Dev d6d6e203f6 Refuse a publishable key the source names and the runtime keyring does not
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]>
2026-10-16 07:09:27 -07:00
zooqueenandHanzo Dev 9c5268d8a2 Count a completed sign-up once, on the stream and the brand's tags, through track()
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]>
2026-10-16 02:42:30 -07:00
zooqueenandHanzo Dev 09f54bd496 Ask before measuring on every identity host, and load each brand's own tags
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]>
2026-10-16 02:41:50 -07:00
zooqueenandHanzo Dev 9f22a78da6 A sign-in IAM holds for an unproven address stops on Confirm your email, on every way in
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]>
2026-10-14 14:16:31 -07:00
zooqueenandHanzo Dev 0fe3499290 A sign-in bounced from onboarding returns to onboarding; every other signed-in visit goes to the brand's main app.
Co-authored-by: Hanzo Dev <[email protected]>
2026-09-30 15:02:03 -07:00
zooqueenandHanzo Dev 10536d2de8 A signed-in visit to hanzo.id goes straight to the brand's main app instead of the apps launcher.
Co-authored-by: Hanzo Dev <[email protected]>
2026-09-30 14:58:44 -07:00
zooqueenandHanzo Dev b993000391 feat(pricing): update onboarding pro plan price to $20/mo (2000 cents)
Co-authored-by: Hanzo Dev <[email protected]>
2026-09-30 00:35:16 -07:00
zooqueenandHanzo Dev 09717d0cad feat(analytics): add GA4 (G-EKBNQ969GE), Meta Pixel, Hanzo Analytics, and Hanzo Insights
Co-authored-by: Hanzo Dev <[email protected]>
2026-09-30 00:35:16 -07:00
zooqueenandHanzo Dev c667b911fc feat(auth): prompt plan payment immediately after login and credential entry
Co-authored-by: Hanzo Dev <[email protected]>
2026-09-29 21:01:31 -07:00
zooqueenandHanzo Dev fe1c30e84f test(e2e): assert plan prompt cards in login e2e spec
Co-authored-by: Hanzo Dev <[email protected]>
2026-09-29 20:50:34 -07:00
zooqueenandHanzo Dev 4027667d37 feat(plan): prompt plan immediately on hanzo.id and onboarding flow
- 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]>
2026-09-29 20:30:57 -07:00
zooqueenandHanzo Dev 61edd784de White-label sign-in pages name their own brand in the SMS consent and carry no Hanzo class names, comments or brand data.
Co-authored-by: Hanzo Dev <[email protected]>
2026-09-29 16:01:03 -07:00
zooqueenandHanzo Dev aa54c2cc82 A pinned invite's code comes from accept itself; /join shows IAM's sentence and asks again for a new one
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]>
2026-09-27 09:12:43 -07:00
zooqueenandHanzo Dev f47471cdef A pinned invite asks the signed-in account for a code sent to its own address, then joins with it
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]>
2026-09-27 09:04:52 -07:00
zooqueenandHanzo Dev fe93d53eae /join goes through the app its link names, and reads IAM's accept envelope
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]>
2026-09-26 13:59:11 -07:00
zooqueenandHanzo Dev d9a6eed933 An invite link opens /join: an account made on the invitation, or a sign-in that comes back to join
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]>
2026-09-26 13:32:11 -07:00
zooqueenandHanzo Dev f61bdf589d Registration says an address already has an account before any code, and offers sign-in and a reset
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]>
2026-09-26 10:50:58 -07:00
zooqueenandHanzo Dev b82b25fe9f The corner mark keeps a row of its own
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]>
2026-09-24 16:49:58 -07:00
zooqueenandHanzo Dev e2ad0c1529 Sign in reads "No account? Create a new account"
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]>
2026-09-24 16:20:00 -07:00
zooqueenandHanzo Dev f5a86d7351 Sign in offers Create account, and registration links back
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]>
2026-09-24 11:26:25 -07:00
zooqueenandHanzo Dev 1261a5ab88 Onboarding completes when checkout returns paid
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]>
2026-09-23 13:17:34 -07:00
zooqueenandHanzo Dev 80929e5a67 Onboarding ends at checkout: the plan step has no Skip
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]>
2026-09-23 12:48:47 -07:00
zooqueenandHanzo Dev 94590d7acc fix(ci): allow upstream @hanzo/event fallback keys in dist assertion
Co-authored-by: Hanzo Dev <[email protected]>
2026-09-15 14:52:24 -07:00
zooqueenandHanzo Dev e0ee4eb6c6 fix(id-web): declare noble and bs58 workspace dependencies in apps/web
Co-authored-by: Hanzo Dev <[email protected]>
2026-09-15 14:47:34 -07:00
zooqueenandHanzo Dev 7d8451e011 feat(id): update launcher app list with AI, Agency, Team, Bot, App, Cloud, Chat, Pay
Co-authored-by: Hanzo Dev <[email protected]>
2026-09-15 12:24:17 -07:00
zooqueenandHanzo Dev c48c1e4901 Offer the accounts signed in on this browser when asked to choose
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]>
2026-09-15 06:08:46 -07:00
zooqueenandHanzo Dev b353c6f17b Import the design token layer the portal is written against
@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]>
2026-09-15 06:07:28 -07:00
zooqueenandHanzo Dev 5c1b3a4dcb A wallet with no account attaches to the account you sign in with
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]>
2026-09-10 21:37:09 -07:00
zooqueen 79675f0a01 The sign-in page says what it does, and where the other doors are
"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.
2026-09-07 16:05:02 -07:00
zooqueen 7a05fa8ab8 the code boxes take the one focus ring, positioned rather than replaced
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.
2026-08-16 23:20:06 -07:00
zooqueen cfc9dbfb80 registration is a control, not a footnote
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.
2026-08-16 17:37:43 -07:00
zooqueen 674b6bb263 the sign-in order runs through the credential form
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.
2026-08-16 17:37:43 -07:00
zooqueen b6c95724a4 the sign-up steps leave when they happen
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.
2026-08-16 01:22:59 -07:00
zooqueen ba6ac9b5cc the sign-up funnel names who signed up, and its last send needs no preflight
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.
2026-08-16 01:06:54 -07:00
zooqueen d288731dd9 delete the account verbs nothing calls
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.
2026-08-15 13:17:52 -07:00
zooqueen 3643e188f7 record what 0.2.79 shipped, and the digest that proves it 2026-08-15 13:15:56 -07:00
zooqueen 6f4d36a748 the account surface, and the two doors a cookie cannot open
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.
2026-08-15 13:11:43 -07:00
zooqueen b3408f71b4 the corner says which product, and the foot's mark is not dimmed
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.
2026-08-15 12:02:41 -07:00
zooqueen a9d1725004 id 0.2.77 — the sign-up funnel reports, and the request it carries does not
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.
2026-08-15 10:29:02 -07:00
zooqueen b01eafb830 id 0.2.76 — the org a login posts is the descriptor's, or there is no login
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.
2026-08-14 22:21:32 -07:00
zooqueenandHanzo Dev 5834ad423d id: device code entry is a PIN pad — one box per character
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]>
2026-08-09 10:19:26 -07:00
zooqueenandHanzo Dev b302b57731 id: sync the repo that exists — hanzoai/id moved to hanzo-inc and went private
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]>
2026-08-06 20:34:10 -07:00
zooqueenandHanzo Dev ce38f3fe56 read the client id the plain-link shape carries
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]>
2026-08-06 18:14:05 -07:00
zooqueenandHanzo Dev eacbf4affd sign out has to clear this browser, not just the IdP
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]>
2026-08-06 18:14:05 -07:00
zooqueenandHanzo Dev 4a4ace2152 hanzo.id is ONE app, not whichever won the race
Reported from a phone: fill in email + password on hanzo.id/signup, press
Create account, get "the application does not allow to sign up new account".
Not reproducible on demand, which is the tell.

resolveOrg spreads the runtime catalog OVER the built-in table, and for
hanzo.id the two named DIFFERENT applications:

  built-in DEFAULT_TENANTS   hanzo-id        enableSignUp false
  catalog  /config.json      hanzo-console   enableSignUp true

So the answer to "can this person create an account" depended on whether a
second network fetch arrived before the form was submitted. When it did, signup
worked -- which is why it looks fine from a desk. When it did not, the page
still rendered a full form (the fallback app permits password login, just not
registration) and IAM refused the POST. On a phone a dropped request is
ordinary, so that is where it showed up.

Confirmed against prod by replaying the exact POST the SPA makes:

  clientId=hanzo-console application=hanzo-console -> past the gate
  clientId=hanzo-console application=hanzo-id      -> "the application does
                                                       not allow to sign up
                                                       new account"

The second line is the customer's screenshot, reproduced.

lux.id had the same divergence (lux-id vs lux-cloud); both are false there, so
it was latent rather than live. Fixed with hanzo's -- the invariant is one host
one app, not "the currently harmful ones".

The fix is NOT to make the fallback survivable. This table is a fallback for a
failed /config.json, not a second opinion, so the answer is that there is only
one answer per host. org.test.ts now asserts that resolving a host WITH and
WITHOUT the catalog yields the same clientId; reverting org.ts reproduces the
failure ("hanzo.id fallback", 1 failed | 15 passed) and restoring it passes,
so the guard is known to bite. 171/171, typecheck clean.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-06 18:14:05 -07:00
zooqueenandHanzo Dev 21a9bba9f9 password: an eye, so you can see what you typed
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]>
2026-08-06 18:14:05 -07:00
zooqueenandHanzo Dev acda3167f3 sync: the forge being ahead of GitHub is not a divergence
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]>
2026-08-06 18:14:05 -07:00
zooqueenandHanzo Dev bb2575a7cb signup: ask for a password only where an account can be created
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]>
2026-08-06 18:14:04 -07:00
zooqueenandHanzo Dev da0856ef73 read the client id the plain-link shape carries
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]>
2026-08-06 16:44:44 -07:00
zooqueenandHanzo Dev f232b7aa67 sign out has to clear this browser, not just the IdP
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]>
2026-08-06 16:32:46 -07:00
zooqueenandHanzo Dev 9111c896cb base: spa 1.4.11 — asset 404s become no-store
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]>
2026-08-06 16:31:50 -07:00
zooqueenandHanzo Dev bc3e47cb22 hanzo.id is ONE app, not whichever won the race
Reported from a phone: fill in email + password on hanzo.id/signup, press
Create account, get "the application does not allow to sign up new account".
Not reproducible on demand, which is the tell.

resolveOrg spreads the runtime catalog OVER the built-in table, and for
hanzo.id the two named DIFFERENT applications:

  built-in DEFAULT_TENANTS   hanzo-id        enableSignUp false
  catalog  /config.json      hanzo-console   enableSignUp true

So the answer to "can this person create an account" depended on whether a
second network fetch arrived before the form was submitted. When it did, signup
worked -- which is why it looks fine from a desk. When it did not, the page
still rendered a full form (the fallback app permits password login, just not
registration) and IAM refused the POST. On a phone a dropped request is
ordinary, so that is where it showed up.

Confirmed against prod by replaying the exact POST the SPA makes:

  clientId=hanzo-console application=hanzo-console -> past the gate
  clientId=hanzo-console application=hanzo-id      -> "the application does
                                                       not allow to sign up
                                                       new account"

The second line is the customer's screenshot, reproduced.

lux.id had the same divergence (lux-id vs lux-cloud); both are false there, so
it was latent rather than live. Fixed with hanzo's -- the invariant is one host
one app, not "the currently harmful ones".

The fix is NOT to make the fallback survivable. This table is a fallback for a
failed /config.json, not a second opinion, so the answer is that there is only
one answer per host. org.test.ts now asserts that resolving a host WITH and
WITHOUT the catalog yields the same clientId; reverting org.ts reproduces the
failure ("hanzo.id fallback", 1 failed | 15 passed) and restoring it passes,
so the guard is known to bite. 171/171, typecheck clean.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-06 16:20:30 -07:00
zooqueenandHanzo Dev bed41f1865 password: an eye, so you can see what you typed
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]>
2026-08-06 16:08:50 -07:00
zooqueenandHanzo Dev b0d2181f92 sync: the forge being ahead of GitHub is not a divergence
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]>
2026-08-06 15:55:12 -07:00
zooqueenandHanzo Dev 537b09055b signup: ask for a password only where an account can be created
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]>
2026-08-06 15:52:05 -07:00
zooqueenandHanzo Dev 1408e5272d id: one name for the ingest key, and it is the one the estate uses
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]>
2026-08-06 15:37:07 -07:00
zooqueenandHanzo Dev ee85e1fead fix(login): honor a registration hint, so the signup funnel reaches signup
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]>
2026-08-06 02:37:17 -07:00
zooqueenandHanzo Dev 0504174fc9 docs: the focus law, and the two-rule collision that hid under it
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]>
2026-08-05 06:46:47 -07:00
zooqueenandHanzo Dev 7978f58a23 id: one focus ring, a CTA that answers the pointer, a lockup that stays 32px
@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]>
2026-08-05 06:42:46 -07:00
zooqueenandHanzo Dev b95cf1dc17 docs: oauthCallbackOrigin is dead config, and its comment argues for the bug
Co-authored-by: Hanzo Dev <[email protected]>
2026-08-03 14:00:54 -07:00
zooqueenandHanzo Dev 027d56147f docs: the catalog key, and why a rename did not stop at the language boundary
Co-authored-by: Hanzo Dev <[email protected]>
2026-08-03 13:59:13 -07:00
zooqueenandHanzo Dev af29508847 config: read the catalog key the runtime actually serves
/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]>
2026-08-03 13:56:11 -07:00
zooqueenandHanzo Dev b17a144b8e social: the browser was the relying party, and it cannot be one
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]>
2026-08-03 13:49:22 -07:00
zooqueenandHanzo Dev abda09d654 device approval: the header described a flow that does not exist
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]>
2026-08-01 20:52:51 -07:00
zooqueenandHanzo Dev 8d32227a03 0.2.19 — the device page names the right client, and the checkbox ships
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]>
2026-08-01 20:43:40 -07:00
zooqueenandHanzo Dev 5c6bf4599d device approval: name the client from the code, not the portal
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]>
2026-08-01 20:43:26 -07:00
zooqueenandHanzo Dev f6828998a1 fix: the consent screen named the wrong client, and we have orgs not tenants
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]>
2026-08-01 20:32:41 -07:00
zooqueenandHanzo Dev 76c474a205 device approval: stop naming the wrong app, and say what the page can vouch for
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]>
2026-08-01 20:30:38 -07:00
zooqueenandHanzo Dev 1bb749b64b 0.2.18 — device approval drops the confirmation checkbox
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]>
2026-08-01 20:22:40 -07:00
zooqueenandHanzo Dev f03c778fde device approval: drop the confirmation checkbox
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]>
2026-08-01 18:47:36 -07:00
zooqueenandHanzo Dev 66e96951a3 signup must end in a session — new customers were stranded on the portal
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]>
2026-07-30 20:05:05 -07:00
zooqueenandHanzo Dev f154f3a794 docs: name the identity server IAM in these comments
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]>
2026-07-28 17:13:43 -07:00
zooqueenandHanzo Dev 4ca3ff494e 0.2.15 — take the whole token layer, mount the ONE account control, gate on resolution
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]>
2026-07-27 17:49:37 -07:00
zooqueenandHanzo Dev e74f7b912a 0.2.14 — reunite main with production, on top of the design-token pass
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]>
2026-07-27 09:28:10 -07:00
zooqueenandHanzo Dev 7514c5b41a id: style from @hanzo/design tokens, and stop painting controls by ancestor
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]>
2026-07-27 09:25:33 -07:00
zooqueen 4f420977c1 fix(auth): thread OIDC nonce through the password-login path
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.
2026-06-23 18:17:30 -07:00
zooqueen da05c24f51 fix(auth): thread OIDC nonce through the password-login path
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.
2026-06-23 18:17:30 -07:00
zooqueen fbb8e4c13f ci(docker): compute version without node (ARC runner has no node)
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.
2026-06-23 15:42:29 -07:00
zooqueen d3260fec67 ci(docker): compute version without node (ARC runner has no node)
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.
2026-06-23 15:42:29 -07:00
zooqueen 4457fd637e ci(docker): target ARC scale set by name + mode=min cache
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').
2026-06-23 15:40:29 -07:00
zooqueen 3671b0937c ci(docker): target ARC scale set by name + mode=min cache
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').
2026-06-23 15:40:29 -07:00
zooqueen aba085af6b fix(auth): org-agnostic password login — resolve real owner-org, not pinned brand
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.
2026-06-23 15:24:32 -07:00
zooqueen 44140f0f9c fix(auth): org-agnostic password login — resolve real owner-org, not pinned brand
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.
2026-06-23 15:24:32 -07:00
zooqueen 698b4ff4e0 fix(portal): root IS the login form (logged-out) / apps launcher (logged-in)
- '/' 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.
2026-06-22 22:05:14 -07:00
zooqueen 09ec6dade7 fix(portal): root IS the login form (logged-out) / apps launcher (logged-in)
- '/' 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.
2026-06-22 22:05:14 -07:00
275fac6efe fix(brand): resilient brand loader — never blank the login page (#14)
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]>
2026-06-18 17:56:31 -07:00
71efb1cbe8 fix(brand): resilient brand loader — never blank the login page (#14)
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]>
2026-06-18 17:56:31 -07:00
c75b797a8c fix(docker): pin hanzoai/spa:1.2.0 (1.3.0 image not published) (#13)
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]>
2026-06-18 16:18:13 -07:00
628822260a fix(docker): pin hanzoai/spa:1.2.0 (1.3.0 image not published) (#13)
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]>
2026-06-18 16:18:13 -07:00
e8a6e09c70 fix(docker): serve SPA via hanzoai/spa, not hanzoai/static (#12)
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]>
2026-06-18 16:14:27 -07:00
c07370a506 fix(docker): serve SPA via hanzoai/spa, not hanzoai/static (#12)
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]>
2026-06-18 16:14:27 -07:00
0fc4bd57b3 fix(docker): static needs -root/-spa flags, not ROOT/PORT env (#11)
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]>
2026-06-18 15:33:36 -07:00
09a60d471a fix(docker): static needs -root/-spa flags, not ROOT/PORT env (#11)
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]>
2026-06-18 15:33:36 -07:00