Every telemetry step on the sign-up path runs inside one guard that logs and
passes over a throw, so a full or refused browser store no longer stops the
account from being created or the browser from leaving for the app. Each step
reads the client when it happens rather than when the form rendered, so a
visitor who accepts and submits at once still has the join and the completion
counted once the live client arrives.
Co-authored-by: Hanzo Dev <[email protected]>
The source this image ships — the app and workspace packages, tests and specs
aside, public/ beside the keyring, index.html, vite.config.ts and any .env —
may name no pk- key at all, a placeholder of x aside, and the app and packages
may not name ORG_KEY, SITE_KEY, keyFor, keyForPage or siteKey. The account
page's own storage-key constant is CURRENT_ORG, so the name stays the library's.
Co-authored-by: Hanzo Dev <[email protected]>
The portal no longer starts @hanzo/event's tag manager. Once a stored or new
choice allows Analytics, it asks cloud for the brand's consent policy with the
brand's key and no credential, hands only the policy to @hanzo/event, and
leaves the tags cloud lists beside it unloaded. The event client starts after
the policy arrives and only if the choice still counts under it, so an Accept
stored under an older policy, a refusal cloud saw, or no answer at all starts
nothing and stores nothing, and the banner asks again.
The page reloads only when the visitor withdraws Analytics after the client
started, on the choice they store, never on a policy's arrival. The completed
sign-up goes to the stream with the visitor's consent stamped on it, for cloud
to forward server-side.
The spec lists Google's and Meta's tags for every key and holds every identity
host to loading none, and covers a newer policy, a refusal cloud saw and GPC
over a stored Accept: one load, the form, nothing sent or kept.
Co-authored-by: Hanzo Dev <[email protected]>
The catalog gains id-dev.hanzo.ai: org hanzo on network devnet, client
hanzo-console, its issuer and /v1 its own origin (the dev cloud's IAM),
its app https://app-dev.hanzo.ai, its pay surface
https://app-dev.hanzo.ai/pay and its telemetry api-dev.hanzo.ai.
Three things in the portal sent an org-hanzo host to mainnet, and each now
reads the host's own entry:
- the alias redirect read one front door per org, so id-dev was sent to
hanzo.id with its OAuth request; front doors are now per org per
network, and a network with none stays put;
- a signed-in visit went to the brand's first app (hanzo.ai), the
launcher, billing and the organization page linked mainnet; a host
whose entry names `appUrl` links only its network's app and payUrl;
- telemetry went to api.hanzo.ai; an entry's `apiUrl` names its gateway.
Every mainnet entry names none of the new fields, so every mainnet host
resolves, redirects and links exactly as before.
Co-authored-by: Hanzo Dev <[email protected]>
The banner's chunk is optional: one that does not load, or throws as it
renders, draws nothing and the sign-in form stays. A withdrawal of Analytics
after the event client started reloads the page once, so no client is left to
send. Every identity page sends only its origin as a referrer, so an address
holding a code, an invite or an OIDC request reaches no other page.
The consent spec answers tag config as production does: each identity host's
key names a project with no tags, so after Accept no Google or Meta request
leaves the page and the sign-up reaches the stream with its consent. It
records every request that leaves the host and holds it to what the page may
send at that moment, throws errors before and after a choice, and covers a
failed chunk, Reject after Accept, and the referrer.
Co-authored-by: Hanzo Dev <[email protected]>
The account exists before its completion is stated, so track() runs inside a
try: a throwing pixel is logged, and the browser still leaves for the app that
asked.
Co-authored-by: Hanzo Dev <[email protected]>
The dist check allows @hanzo/event's host-keyed table, so a key copied from
that table into the app's own code would pass it while naming one brand on
every host. The shipped source (the app and workspace packages, tests aside,
public/, index.html and vite.config.ts) may now name only keys the runtime
keyring in public/config.json names; any other fails the image build.
Co-authored-by: Hanzo Dev <[email protected]>
The account's creation is one track(stream, 'signup_completed') under one
event id: our stream hears it, and so does each of the brand's tags the
visitor's choice allows, as GA4 sign_up and Meta CompleteRegistration. The
page address track() stamps into the moment's properties leaves as its path,
like the event's own location fields.
Co-authored-by: Hanzo Dev <[email protected]>
index.html drops its hard-coded GA4, Meta Pixel, Umami and Insights tags. The
shared @hanzo/ui banner asks and @hanzo/event keeps the choice: until it allows
Analytics no event client exists, and until it allows Analytics or Marketing
the tag manager is not started, so nothing is fetched, sent or stored. Global
Privacy Control and Do Not Track refuse without being asked.
The tag manager asks cloud for the tag set of the brand's runtime key, the same
key its event stream files under, so each brand loads only its own GA4 and
pixels and a host with no key starts none. A host whose brand publishes no
privacy policy is not asked, so nothing optional runs there. Routes whose
address carries an authorization code or device code start no tags either.
@hanzo/event 0.3.58 and @hanzo/ui 8.27.90 are pinned, with one @hanzo/event in
the graph. The banner renders on @hanzo/gui through react-native-web and loads
on its own chunk. The dist key check reads its allowance from the runtime
keyring and from @hanzo/event's host-keyed table.
Co-authored-by: Hanzo Dev <[email protected]>
IAM answers a sign-in of an account whose address was never proven with
RequiredEmail and the address it sent a 6-digit code to ("" when none is on
file). The client reads it as a third held state beside NextMfa and
RequiredMfa, carrying the sign-in's own OAuth parameters and never its
credential; emailConfirm and emailSend post them again to /v1/iam/login with
{passcode} or {email}, riding the challenge cookie.
EmailConfirmForm is the step: the code, a new code, a different address, and
the address field first when none is on file. The login, device-approval and
signup pages render it for password, code and wallet sign-ins. A confirmed
address continues as the sign-in would have, second factor included, and an
expired hold returns to the sign-in form with IAM's sentence.
Signup shows IAM's refusals for an address it cannot verify, or for no address,
like any other refusal; the note that such an account is made unproven is gone.
Co-authored-by: Hanzo Dev <[email protected]>
- Display plan selector (Pro, Team, Pay as you go) directly on sign-in when accessed without an external redirect_uri
- Route bare sign-ins straight to checkout (pay.hanzo.ai) to complete plan payment
- Move plan step to Step 1 in onboarding flow (STEPS = ['plan', 'org', 'project', 'wallet', 'consent'])
- Redirect uncompleted/unpaid accounts on Portal.tsx immediately to /onboarding
- Enforce reachability so no subsequent steps can be visited before plan is selected
Co-authored-by: Hanzo Dev <[email protected]>
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]>