- 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]>
An app that takes a work email on its own page — hanzo.team's Get
Team — sends it as login_hint with screen_hint=signup. Sign-in already
read the hint; registration dropped it, so the person typed the address
twice. SignupForm takes an initial email and /signup passes the hint.
Claude-Session: https://claude.ai/code/session_01HuMDfgFssPUptYUzgZ6Cp8
Accept without emailCode sends the code to the signed-in account and answers email_code_sent (or email_code_required inside the resend window); its msg becomes the code step's line. Send a new code is accept again; submit is accept with emailCode. The page no longer requests a code or looks up the application for one.
Co-authored-by: Hanzo Dev <[email protected]>
acceptInvitation sends emailCode and reports IAM's reason code. On email_code_required /join sends a code to the account's email under the link's application and accepts again with it; a wrong code, a different address and too many attempts show IAM's sentence as written.
Co-authored-by: Hanzo Dev <[email protected]>
Invite links carry client_id; registration and the sign-in that comes back both go through that app, and the host's own when the link names none. acceptInvitation reports a join only for {status:ok, data:{joined:true}} and shows IAM's refusal sentence as written.
Co-authored-by: Hanzo Dev <[email protected]>
Signed out, /join?org=&invite= offers registration that carries organization and invitationCode to IAM, or a sign-in whose ?return= (same-origin /join only) brings the person back. Signed in, Join posts {owner, code} to /v1/iam/invitations/accept. An account created on the invitation is shown as already in the org.
Co-authored-by: Hanzo Dev <[email protected]>
The first submit carries no code, so IAM answers a taken address or a refused password there. A taken address shows Sign in and Reset password, both opening on that address for the same request. Recovery prefills from login_hint and returns to the same sign-in.
Co-authored-by: Hanzo Dev <[email protected]>
A page as tall as the viewport pushed its heading up into the mark's
row: "Hanzo ID" at 19px semibold beside "Sign in" at 17px bold, a
pixel or two apart, a header that reads as almost aligned. The page
now starts its column under the mark's 28px row plus 16px, so the
heading sits below the mark at every width. The e2e asserts it.
Co-authored-by: Hanzo Dev <[email protected]>
The owner's wording for the line under the sign-in column. Same link,
same gate on enableSignUp, same request carried to /signup.
Co-authored-by: Hanzo Dev <[email protected]>
The sign-in page closes on "Don't have an account? Create account" when the
application takes new accounts (enableSignUp on its row, read from
/v1/iam/auth/application). An unreadable answer draws nothing; the server
stays the only gate.
route.ts builds both links. signupHref carries the query verbatim and keeps
the plain-link shape (/login/<app> -> /signup/<app>), so client_id,
redirect_uri, state, nonce and the PKCE challenge reach registration and the
new account returns to the app that sent it. signinHref is its inverse and
drops only signup=true / screen_hint=signup, which /login would answer by
forwarding straight back. The signup page's "Sign in" links and the
post-signup MFA hand-off use it.
Unit tests pin every parameter and both shapes; the e2e now asserts the
control, the round trip and its absence when signup is off.
Co-authored-by: Hanzo Dev <[email protected]>
Hanzo CI/CD / test (linux-amd64) (push) Successful in 3m29s
Hanzo CI/CD / test (linux-arm64) (push) Successful in 8m26s
Hanzo CI/CD / cicd (push) Successful in 4m17s
CI/CD / cicd (push) Successful in 27m0s
1.0.1's brand.json named its logo and favicon at @latest, and that release
ships no assets, so pars.id and id.pars.network drew a 404 for both. 1.0.3
carries them and names itself. It is a day old, so it is excluded from pnpm's
release-age hold.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Hanzo CI/CD / test (linux-amd64) (push) Successful in 2m24s
Hanzo CI/CD / test (linux-arm64) (push) Successful in 8m12s
Hanzo CI/CD / cicd (push) Successful in 2m15s
CI/CD / cicd (push) Successful in 10m31s
osage.id is served by this bundle, but osage has no brand package for
the build to resolve, so its brand JSON and mark were placed in the
bucket by hand and every publish of a fresh build dropped them. In
public/ they are part of the build like any other file.
Claude-Session: https://claude.ai/code/session_01HuMDfgFssPUptYUzgZ6Cp8
Hanzo CI/CD / test (linux-amd64) (push) Successful in 2m54s
Hanzo CI/CD / test (linux-arm64) (push) Successful in 9m21s
Hanzo CI/CD / cicd (push) Successful in 2m4s
CI/CD / cicd (push) Successful in 26m52s
`id` is a reserved label (cloud apps/sites/reserved.go), so the site lane's
POST /v1/projects answered 400 "slug is a reserved subdomain" and run 12348
stopped at Publish site with every gate green. hanzo-id is hanzo.id's own
name, as hanzo-team is hanzo.team's; the edge root follows it in universe.
Co-authored-by: Hanzo Dev <[email protected]>
Hanzo CI/CD / test (linux-amd64) (push) Successful in 2m14s
Hanzo CI/CD / test (linux-arm64) (push) Successful in 7m52s
Hanzo CI/CD / cicd (push) Failing after 2m26s
CI/CD / cicd (push) Failing after 10m25s
The forge runner image (hanzoai/ci runner/Dockerfile) already carries
Chromium's shared libraries, and its sandbox refuses sudo, so
`playwright install --with-deps` stopped run 12345 at the apt step before a
single spec ran. The gate now fetches only the browser that matches
@playwright/test into $RUNNER_TEMP, as hanzo.ai's gate does.
Co-authored-by: Hanzo Dev <[email protected]>
The page now reads, on every brand: Sign in, Email or username, Password,
Continue, Send me a code instead, then "or", then Google, GitHub, Wallet and
Phone, each drawn only when the application can complete it, then Forgot
password? and the footer. PROVIDER_ORDER puts `form` first and the rule
follows the form, so login, signup and device approval share the one column.
The "New here?" / "Create account" block is gone, along with the sentences
that pointed at it (the refusal note and the unlinked-wallet message). /signup
stays: hanzo.app's signup=true and hanzo.chat's /signup/<client> still land
there. Server sign-up rules are unchanged.
apps/web/e2e drives the built export in Chromium on hanzo.id and lux.id at
1440 and 390 and asserts that order and the absence of any registration
control; hanzo.yml runs it as a fourth gate.
Co-authored-by: Hanzo Dev <[email protected]>
The plan step records the choice and sends the buyer to checkout with the
org they founded as ?org=, so pay charges that org's ledger rather than the
signup org. Completion records only when pay returns to /onboarding with
checkout=success; leaving checkout unpaid reopens the plan step. Plans show
the monthly price checkout sells, and a per-seat plan states its seat
minimum and first charge from the catalog's perSeat and limits.minSeats.
Co-authored-by: Hanzo Dev <[email protected]>
Choosing a plan or a prepaid balance is the only way out of onboarding; it
records completion and sends the person to checkout. The step machine refuses
an empty answer on a step that is not skippable, so Skip, the arrow keys and
the step dots cannot reach done without a plan. A catalog that fails to load
offers Retry rather than a choice whose checkout cannot answer.
Co-authored-by: Hanzo Dev <[email protected]>
The signup screen created the account from an address nobody had proven, and
nothing a password account can reach proves it later, so IAM kept the address
unproven for good. Cloud refuses the git path to an unproven address, and the
federation broker will not link a social identity onto one.
Where the application can deliver a code, the screen now sends one to the
address first and creates the account with it, and IAM records the address
proven. A refused send is shown and creates nothing. Changing the address sends
the new one its own code. An application that cannot deliver a code registers
as before.
Co-authored-by: Hanzo Dev <[email protected]>
Every id host renders the same SPA, and the only thing the pod adds is
/config.json, which it writes at boot from two SPA_* env values: the per-host
org catalog and the ingest keyring, both public (the browser reads them). That
file now ships in the export, and the site lane publishes it to
s3://hanzo-sites/hanzo/id, so the edge can serve hanzo.id and its brand hosts
with no pod. The image keeps its SPA_* env until the pod is retired, and that
env still wins inside the pod.
Checked in headless Chromium with /v1 and /.well-known left to cloud: the export
renders what the pods render on hanzo.id, iam.hanzo.ai, lux.id, id.lux.network,
zoolabs.id and pars.id.
Co-authored-by: Hanzo Dev <[email protected]>
Two tests exist to prove why the route gate and bare() exist: the same view,
sent by an unmodified client, put the OAuth code, state, challenge and nonce
on the wire. They asserted the leak by those values, and since @hanzo/event
0.3.13 the client redacts a query value by the NAME it is filed under —
code, state, nonce, token and a list more — so those values no longer leave
and both tests failed on their own, in any order. The suites were written
against 0.3.12.
The gate and bare() are still load-bearing, and the tests now measure why.
The client's list is a list. Measured against 0.3.41: an ungated pageview
from /login/oauth/device?user_code=… ships the device code while path reads
clean, and an undoored signup_viewed from /signup?… ships client_id and
code_challenge in both url and referrer while state and nonce leave as
[redacted]. No list names every parameter an identity flow will carry, so
the page's rule stays the route and the whole query, not a name.
The comments in analytics.tsx said the client redacts secret shapes only.
That was 0.3.12. They now say what it does.
Co-authored-by: Hanzo Dev <[email protected]>
hanzo.id appeared to report on Lux's key. The page does not; the suite did.
Since @hanzo/event 0.3.27 createAnalytics returns the page's client for a
(host, product) stream, and a later caller is handed the first caller's key,
transport and identity — adopt() never replaces a key that is present. In a
browser that is the point: a page is one document on one host, so the first
key is the right one. A test file is one realm for all of its tests, and
these visit lux.id and then hanzo.id in it, so every document after the
first was handed the first one's client. The suites were written against
0.3.12, which built a client per call; 4043d05 moved the lockfile to 0.3.37
and the suites did not move with it. Four failures, one cause:
- a Hanzo host reported on Lux's key: the previous test's client;
- a client built with its own transport received nothing: same;
- an arrival was already the person: the previous test's identify();
- the second location in one test reported nothing: the first one's
transport.
Each passes alone and fails after another. newDocument() drops what a page
keeps for its lifetime — the /config.json memo and the clients — through the
reset the package names, and every suite that visits documents begins each
one with it.
Without resetClients() in newDocument() all four fail again; with it the
three suites pass in five shuffled orders. The two tests in these files that
still fail measure something else — next commit.
Co-authored-by: Hanzo Dev <[email protected]>
Since 0.3.27 createAnalytics keeps one client per (host, product) on the
page, under a Symbol.for slot, and hands every later caller that client. A
suite that visits several documents in one file has to drop it between them,
and 0.3.37 gave that no name, so a suite had to delete the slot itself.
0.3.40 exports resetClients() for exactly this; 0.3.41 is the current release.
The source diff from 0.3.37 is confined to that export, `zoolabs.io` in the
package's own domain table (unused here: the live key comes from the runtime
keyring, never from the package) and VERSION read from package.json instead
of restated. The react peer range moved to >=19.2.8, which the catalog pins.
The suites take it up in the next commit.
Co-authored-by: Hanzo Dev <[email protected]>
Arriving with a state this origin holds no transaction for is ordinary, not
exceptional: an old link carrying ?code=&state= is followed twice, the attempt
began in another profile or a private window, or site data was cleared between
the two halves. @hanzo/iam refuses it and is right to — that check is the CSRF
protection, and its own test forbids soft-skipping it.
The answer was wrong, though. This page caught every failure alike and printed
the SDK's sentence, so a recoverable return became a dead end whose only
instruction — restart sign-in — was the one thing it gave no way to do. The
query is what cannot be completed, so it is dropped and the portal's login page
begins a fresh transaction.
Once. A restart that cannot succeed must not become a loop, so the attempt is
marked and a second arrival shows the error and stops; a sign-in that genuinely
failed is reported and never bounced. post_login_redirect is left alone, since
where the person was going is still true.
Co-authored-by: Hanzo Dev <[email protected]>
With prompt=select_account the sign-in page lists the people IAM holds a
session for (GET /v1/iam/accounts) and a "Use another account" row that
opens the credential form. Choosing a person re-enters authorize with the
request the page was handed plus login_hint, and IAM answers from that
person's session. Nobody signed in here goes straight to the form.
login_hint also starts the credential form with that identifier.
Co-authored-by: Hanzo Dev <[email protected]>
@hanzo/font is not a dependency of this workspace, so vite build stops
at `@import '@hanzo/font/css'`. @hanzo/design/styles.css is the file the
comment above the import describes: every token group plus the Zen faces
the package ships. With it the build passes and tokens.test.ts finds
every referenced token defined.
Co-authored-by: Hanzo Dev <[email protected]>
Pressing a wallet on the sign-in page sent IAM a login, and IAM's answer
for a wallet nobody holds — "no account is linked to this wallet" — was
drawn as a dead end beside a Create account button. Somebody who already
had an account by email or a provider made a second one to get in.
IAM attaches a verified wallet to an existing identity only when the
verify arrives with that identity's own same-site session (hanzoai/iam
internal/wallet/verify.go). So the refusal is now named (`unlinked`),
the chain is parked in session storage, the page says to sign in below,
and after any sign-in — the form, a second factor, a provider's return
through /callback — the wallet flow runs once more with the session and
IAM links it. From then on the wallet alone signs them in. A declined
second signature leaves the sign-in as it was, wallet unattached.
Co-authored-by: Hanzo Dev <[email protected]>
It has produced no run on the forge or on GitHub since it was added.
A workflow file that never fires still evaluates on every replayed
ref, so it is a hazard rather than a lane.
Co-authored-by: Hanzo Dev <[email protected]>
ghcr.io/hanzoai/spa served only linux/amd64 through 1.4.12, so an image
built on it has no manifest an arm64 node can pull. 1.4.13 is an index
over both architectures.
Co-authored-by: Hanzo Dev <[email protected]>
The forge reads .github/workflows natively and github.com reads nothing else,
so one directory serves both planes instead of two conventions that disagree
about which one is live. The reusable in hanzoai/ci already lives there, so a
caller naming that path now resolves to a file that exists.
Co-authored-by: Hanzo Dev <[email protected]>
hanzo-build-linux-amd64, lux-build-linux-amd64, zoo-build-linux-amd64 and
every other org-prefixed spelling all meant the same machine. Which org a
job belongs to does not change which host can build it, so the org bought
one pool name per org per arch and each could go stale on its own -- the
evo host alone answered to fourteen names for one label.
One vocabulary now: linux-amd64, linux-arm64, macos-arm64, win-amd64. The
generic labels every plain workflow uses (ubuntu-latest, self-hosted) are
untouched, so naive CI keeps working.
Co-authored-by: Hanzo Dev <[email protected]>
The control keeps its two words and its button surface; "New here?"
stands as a line of its own. The refusal wears the note style, not the
footer's. @noble/curves stays on its 1.x line, which is the API the
connect package is written against.
Co-authored-by: Hanzo Dev <[email protected]>
pnpm 11 stops an install whose build scripts are left as placeholders;
sharp, tiny-secp256k1 and workerd are the ones this tree carries.
Co-authored-by: Hanzo Dev <[email protected]>
One family. Font stacks name Zen and fall back to the native faces, tokens
name the role rather than a face, and the packages that used to supply a
face now point at @hanzo/font.
Self-hosted @font-face blocks are removed rather than renamed: rewriting the
family in one would leave a src pointing at a file this repo no longer ships.
@hanzo/font carries the binaries and declares the faces, so the local copies
go with the declaration that used them.
Comments naming a face we no longer use are deleted, not reworded.
Co-authored-by: Hanzo Dev <[email protected]>
Nothing else on any Hanzo surface underlines — the console footer turns the
anchor default off and says why — and this rule was the one that did, so "Send me
a code instead" read as pasted in from another site next to the pill buttons and
the mark.
It is a quiet control and now says so the way the rest of the estate does: muted
until you point at it.
Co-authored-by: Hanzo Dev <[email protected]>
hanzoai/ci at v1 carries build.yml under .github/workflows; the .hanzo
copy lives only on a branch of the same name, so a run naming it is
refused before it starts.
Co-authored-by: Hanzo Dev <[email protected]>