ff-main has been red every ten minutes. Eleven consecutive failures in the
hour I read it, and not one of them a divergence: the branch asked only
"is LOCAL an ancestor of REMOTE" and called every other answer DIVERGED,
so the forge simply being AHEAD of GitHub took the loud-failure path.
That is the normal state after every native push, and it is not brief --
the push-mirror is sync_on_commit with an 8h floor, so one commit can
produce up to 48 false alarms before GitHub catches up.
The cost is not the noise. A job that is always red cannot report the one
thing it exists to report, and this job's whole value is refusing to
force-push a real divergence. The loud failure is worth keeping only if it
is rare.
Four sibling repos (app, git, hanzo.ai, iam) already carry exactly this
two-direction check; id had the stale copy. Same logic, same order --
this is convergence onto the estate's answer, not a new one. HIPs,
mirrors and python-sdk are still stale and are being fixed with it.
Exercised against real git ancestry, all four states:
state old new
equal in-sync(0) in-sync(0)
github-ahead fast-forward(0) fast-forward(0)
native-ahead DIVERGED(1) native-ahead(0) <- the fix
diverged DIVERGED(1) DIVERGED(1) <- alarm preserved
One row moves. The genuine-divergence alarm still fires, which is what
keeps this a fix rather than a mute button.
Co-authored-by: Hanzo Dev <[email protected]>
hanzo.id/signup rendered a full credential form for EVERY client_id, and
IAM refuses signup at SUBMIT — so on 48 of 51 hanzo applications a visitor
typed an email and a password and only then got "the application does not
allow to sign up new account". The provider buttons dead-ended the same
way: federation PROVISIONS a local user for a new identity, so a first-time
GitHub sign-up hit the same gate after a full round trip through GitHub.
enableSignUp was already on AppLogin and already fetched -- SignupForm reads
the same row inside onSubmit, too late to change what is on screen. Reading
it at the page turns the refusal from a surprise into a state.
It FAILS OPEN on purpose: an unreadable app config still renders the form and
the server still refuses. This is honesty about an answer we already have, not
a gate; internal/oidc/signup.go stays the only gate.
Measured against LIVE IAM, built bundle, all three directions:
client_id enableSignUp <form> input[type=password]
hanzo-bot true 1 1
hanzo-chat false 0 0
does-not-exist-probe unresolvable 1 1 (fail-open)
The middle row is the fix; the outer rows are what make it evidence rather
than a blanket removal of the form.
No new CSS: .hanzo-id-info already exists for exactly this.
Co-authored-by: Hanzo Dev <[email protected]>
This repo read EVENT_INGEST_KEY; hanzo.chat, hanzo.app, hanzo.ai,
platform and the shared hanzoai/ci `build_secrets` all read
PUBLISHABLE_KEY. Same key, same KMS path, two spellings — and this repo
was the last holdout, which is why the duplicate entry had to exist at
all.
That duplication is a live rotation hazard: two KMS entries holding one
value means rotating either one silently 403s whichever half still reads
the other. Collapsing to a single name is what lets the duplicate be
deleted.
Renamed end to end: the build-arg, the ENV, the Vite-inlined
VITE_PUBLISHABLE_KEY, the ambient type, hanzo.yml's build_secrets, and
every message that names the KMS address in a failure.
Also corrected a claim this file repeats and that turned out to be
false everywhere it appeared: an unkeyed beacon does NOT take an
"anonymous lane" that files rows under a $public tenant and answers 200.
Measured against the live door, with and without a browser Origin, for
pageviews and exceptions alike: a hard 401 ingest_key_required. Nothing
arrives. That myth is why "we still get errors, just not events" was
believed across four repos while keyless surfaces reported nothing at
all.
No behaviour change beyond the name: the fail-closed `case` gate and the
inlining assertion are untouched, and this repo's gate was already
sound — the case rejects an empty value before the grep, so it never had
the `grep -F ""` hole that let hanzoai/chat pass keyless builds.
Co-authored-by: Hanzo Dev <[email protected]>
hanzo.app's "Get started" forwards `signup=true` on the authorize request and
this page ignored it. Measured end to end: the CTA lands on "Sign in to Hanzo ID"
with empty credentials, and appending `&signup=true` to the authorize URL by hand
changes nothing. Every net-new customer had to notice the small "Create account"
link to get past a form they could not fill in. The app was doing its part; the
IdP dropped the hint.
`screen_hint=signup` is honored too — the OIDC-standard spelling of the same
request, so a compliant client works without knowing our name for it.
It LOSES to silent SSO, and that ordering is the point. A browser already
carrying an issuer session belongs to someone who HAS an account, and sending
them to registration is worse than ignoring the hint. So this sits in `fallback`,
where `provider_hint` already sits, and only decides what happens when there is
no session to mint from.
The navigation carries the whole search string. Registration is its own page, and
client_id, redirect_uri, state and the PKCE challenge live in that query — a bare
`/signup` strands the new account with nowhere to return to. `replace` rather
than `assign` so Back reaches whatever sent the user here, not a login page that
would bounce forward again.
Both invariants are mutation-verified: short-circuit silent SSO and the ordering
test fails; drop the search string and the round-trip test fails.
Co-authored-by: Hanzo Dev <[email protected]>
The bullet claimed --ring was var(--neutral-500) at 4.43:1; it has been
var(--white-40) at 3.77:1 for the whole 0.4.x line. It also described base.css
as shipping ONE :focus-visible rule, which only became true at @hanzo/design
0.4.9 — before that a second, field-specific rule tied it at (0,1,0) and lost
to source order, so focused inputs here drew two indicators.
Records the part the next reader cannot derive: why a local focus override is
still the wrong fix, and why the treatment that rule was applying could not have
worked on this surface at all (its indicator rode on border-color, and
.hanzo-id-input states border unlayered).
Co-authored-by: Hanzo Dev <[email protected]>
@hanzo/design 0.4.5 -> 0.4.9 fixes the focus indicator at the root. Two rules in
its base layer both computed to (0,1,0), so source order decided, and the generic
ring overrode the `outline:none` the field rule stated to prevent it: every
focused input on hanzo.id drew BOTH the 2px ring and a halo. Measured on the
shipped bundle, before 2 indicators and after 1. Nothing in this repo could fix
that — app.css deliberately keeps no focus rule of its own — and the same bump
carries it to lux.id, zoo.id and pars.id.
The rest is this surface's own:
The filled button had no hover. It has declared `transition: background` since it
was written and --primary-hover has always existed, so Sign in / Continue /
Create account animated a property nothing changed. `:not(.ghost)` keeps it from
tying with the ghost hover at (0,4,0), where order would otherwise pick the
winner — the same defect class as the ring above, so it is not written that way
here. Measured: #fafafa -> #e5e5e5 on hover, ghost unchanged.
The brand lockup is pinned to 32px. BrandHeader writes `height={32}`, but a
presentational attribute loses to design's `:where(img,video){height:auto}`, so
each portal was sized by whatever its brand package shipped. @hanzo/brand's SVG
has a viewBox and no width/height, so Hanzo landed on 32 and looked correct;
@luxfi/brand's declares 1024x1024, so lux.id has been rendering a 342px mark over
its sign-in form. Now 32x32 on all three, Hanzo unchanged.
min-height moves to 100dvh with 100vh kept underneath as the fallback: on a phone
`vh` is the URL-bar-retracted viewport, so a full-height page overflows the space
it actually has.
Styling and one dependency only — no flow, field, CSRF or handler is touched.
160 tests pass, tsc clean, all four brands verified.
Co-authored-by: Hanzo Dev <[email protected]>
hanzo.id has not published an image since the telemetry commit. The build
fails at `vite build` with
Cannot find module '/build/apps/web/node_modules/vite/bin/vite.js'
which reads as a broken tree but is not: the same commit builds cleanly when
its dependencies come from pnpm-lock.yaml.
The image never used the lockfile. It was not copied, and the install ran
`--frozen-lockfile=false`, so the container re-resolved the entire graph from
the registry on every build while `pnpm test` on the runner installed from the
lockfile -- two different dependency graphs out of one commit, the tested one
and the shipped one. That agreed for as long as free resolution happened to
land on the same versions, which is not a property anyone was maintaining.
Adding @hanzo/event was enough to break the tie: the lockfile pins
[email protected]_@[email protected]_..., free resolution chose
[email protected]_@[email protected].1, and the peer-suffixed store path the workspace
link pointed at no longer existed. The dependency did nothing wrong; it
disturbed a resolution that was never pinned.
So the lockfile ships and the install is frozen to it. The image now gets the
exact tree the tests passed against, and a stale lockfile fails at the install
naming the mismatch rather than quietly building a different application.
apps/account/package.json joins the pre-install copies: a frozen install
validates the lockfile against EVERY workspace member, and apps/account is in
the workspace even though this image does not build it.
Verified from a cold store on this exact commit: frozen install resolves the
pinned vite, `pnpm --filter @hanzo/id-web build` succeeds, and the ingest key
is present in apps/web/dist -- the assertion the next step makes.
Co-authored-by: Hanzo Dev <[email protected]>
hanzo.id is where every property's visitor lands and it reported nothing — the
live bundle carries no @hanzo/event, no /v1/event and no key — so the
arrival->session funnel had no denominator.
Mounts the ONE @hanzo/event client (product `id`, POST /v1/event) for pageviews
and errors, anonymously: no identify(), no interaction autocapture, no session
replay, no input capture, no email/name. A visitor's pre-signup pageviews still
join to whoever they become, because the client's per-browser anonymousId
survives sign-up and a post-auth surface identifies them there.
Telemetry is refused outright on /callback and /login/oauth/device. Passing a
clean pathname is NOT sufficient protection on those routes: @hanzo/event stamps
`url: window.location.href` onto every event it assembles, independently of the
`path` a caller passes, and its scrubber redacts secret SHAPES (JWT, sk-/pk-,
bearer, cloud keys, PAN) — an opaque OAuth authorization code is none of them.
Measured against the real client, a pageview from /callback?code=…&state=… put
the code and state on the wire while `path` read a tidy /callback. Neither route
is a funnel step; both are transient hops that redirect onward, and /, /login
and /onboarding still report. analytics.test.ts asserts BOTH that the gated
routes emit nothing and that the ungated client really does leak, so the gate
cannot decay into a decorative assertion.
The key is a publishable pk- read from KMS (deploy/EVENT_INGEST_KEY, env prod)
and passed as a build-arg. The build fails closed on an empty or
non-publishable value, then asserts the key actually reached dist: a green build
that ships an unattributed bundle is the failure mode here, not a red one.
No auth logic is touched — no OAuth/OIDC/PKCE, token storage, session restore,
or login template. 167 tests pass.
Co-authored-by: Hanzo Dev <[email protected]>
Second half of the migration, and deliberately a separate commit: the canonical
lane was landed first and watched green while deploy.yml was still in place, so
this repo was never left with zero delivery.
Three files out, one line changed.
.hanzo/workflows/deploy.yml — superseded. The canonical lane builds the same
Dockerfile, reads the same package.json version, pushes ghcr.io/hanzoai/id, and
proves the manifest resolves before going green. It also runs the 160 tests and
the typecheck that this file never did.
.github/workflows/docker.yml — a second image lane. It had already been reduced
to an echo, but it kept the SHAPE of a build lane alive in a directory this forge
cannot even read (Gitea takes the first of WORKFLOW_DIRS present, and
.hanzo/workflows has existed here for months). Two lanes for one image is how the
two drifted in the first place.
.github/workflows/workflow-sanity.yml — imports
hanzoai/.github/.github/workflows/workflow-sanity.yml@main, which returns 404:
the reusable it calls does not exist. It could only ever have failed. It also
triggers on `paths: ['.github/workflows/**']`, a directory that is now empty, so
even a live version of it would be guarding nothing.
sync-from-github.yml keeps its cron and its fast-forward-only rule, and its
build dispatch is repointed deploy.yml -> cicd.yml. That reference is BY
FILENAME. Left alone it would have 404'd against the `|| echo` that makes it
non-fatal, so the symptom would not have been a red sync — it would have been
commits arriving on the forge and nothing building, silently, which is the exact
failure this repo already survived once (four commits, zero images, no red).
Co-authored-by: Hanzo Dev <[email protected]>
This repo had TWO image lanes and ZERO gates. 13 test files, 160 assertions, and
CI ran none of them — which is how pkgs/shared/src/org.test.ts sat RED on main
against code that is correct.
Two files. `hanzo.yml` says what this repo is (three gates, one image, no
deploy); `.hanzo/workflows/cicd.yml` is ~10 lines importing
hanzoai/ci/.hanzo/workflows/build.yml@v1. `.hanzo`, not `.github`: github.com has
zero self-hosted runners for this org, so a job asking for
hanzo-build-linux-amd64 there is never claimed — it waits out the 24h timeout
rather than failing. Gitea also reads only the FIRST of WORKFLOW_DIRS present in
the commit, so `.github/workflows` has been dark here all along.
deploy.yml is deliberately LEFT IN PLACE by this commit. It is the current green
lane and it does not come out until the new one has been watched green and shown
to publish the same image from the same source.
THE RED TEST, and why the fix is in the test. `org.test.ts` asserted that
`oauthCallbackOrigin` defaults to `iamIssuer`. That was the FIRST attempt at the
social-login fix and it was abandoned in the same change that shipped the real
one, because `hostSkeleton` derives the issuer from the REQUEST HOST — so on an
app host the issuer IS the brand host and the bug is unchanged. org.ts says
exactly this, in place, directly above the code. The assertion was simply left
behind.
It was also asking the question on hosts that carry no org at all: it called
resolveOrg('hanzo.app') and resolveOrg('hanzo.chat') with NO catalog, so both
fell to the deliberate unknown-host skeleton (empty orgId, fail closed, never
another brand's config). No design can make two ORG-LESS hosts agree on one
org's callback — that assertion could not have passed under either default.
So it now asks the question the way production does. The catalog (/config.json)
supplies the orgId, and the invariant that matters is ONE registered redirect_uri
PER ORG: hanzo.app, hanzo.chat, console.hanzo.ai and hanzo.id all resolve to
https://hanzo.id, none of them to their own host. Plus the assertion that pins
the abandoned attempt OUT: id.lux.network gets https://lux.id, which is NOT its
issuer — under the old default it would send its own origin and break again. A
strictly stronger guard than the one it replaces. 160/160 green.
org.ts also carried BOTH comment blocks — the superseded one ("The issuer is the
right default because...") still sitting above the one that corrects it. That is
one fact with two homes, and the stale half is the exact paragraph that would
talk the next reader into re-breaking social login. Deleted; the correct block is
untouched.
packageManager: [email protected].0 in package.json. corepack already reads it, so the
gate and the Dockerfile now resolve the SAME pnpm from ONE declaration instead of
the gate inheriting whatever corepack defaults to on the runner.
The gates: install (--frozen-lockfile, stricter than the image build's
--frozen-lockfile=false — lockfile drift should be caught in CI, not block a
build), typecheck (vite does NOT typecheck; `vite build` strips types, so a type
error ships a green image and this is the only thing that reads them across all 7
packages), unit (vitest, 160).
No `deploy:`, transcribing deploy.yml's own rule. id is governed by Hanzo CD:
the tag it runs is declared in universe (charts/app/values/hanzo/id.yaml, tag AND
digest) and reconciled back within ~60-90s, so a CI-side patch cannot stick — and
the reason to refuse it is not that it fails, it is that it LOOKS like it works.
It would also be actively wrong: the reusable's semver guard reads
infra/k8s/operator/crs/<svc>.yaml, a path this service does not use, so the guard
would not fire and a sha- tag would land on the live sign-in surface for every
property in the fleet.
0.2.27 / id-shared 0.1.4 — a release IS a version bump, and org.ts changed.
Co-authored-by: Hanzo Dev <[email protected]>
E2E caught the funnel dead-ending at consent: a password/form sign-in mints
a SESSION COOKIE and lands on /onboarding directly — the PKCE SDK holds no
token, update-user (rightly) refuses cookie-only writes, so every
saveOnboarding answered 401 and nothing ever persisted. The page now
bootstraps the token on mount: no SDK token → signinRedirect, which with a
live session is the silent-SSO authorize branch (no UI), /callback stores
the token and returns here. One bounce per session, guarded — a broken
mint degrades to the read-only state, never a redirect loop.
Also from the E2E: the plan step now SAYS when the catalog fetch failed
instead of silently rendering only pay-as-you-go ("there are no plans" is
false), and the popular plan wears a visible badge, not just a class.
Co-authored-by: Hanzo Dev <[email protected]>
The live catalog prices in cents (go=900 → $9/mo; priceAnnual=825 →
$8.25/mo billed annually ≈ $99/yr). 0.2.24's plan step would have rendered
"$900/mo". PlanInfo now carries priceCents/priceAnnualCents unscaled, the
UI formats ("$9/mo · $99/yr billed annually"), and listPlans keeps only the
personal+team product lines — dns-* and enterprise have their own surfaces.
Test pinned with production-shaped rows.
Co-authored-by: Hanzo Dev <[email protected]>
The prepaid funnel's missing half. Two new steps close the flow:
- consent (required answer, either answer): the data-sharing agreement is
asked exactly once and recorded on the USER (Properties, IAM row), so it
never re-appears — on any browser.
- plan (LAST, required): pick a catalog plan or Pay as you go. Prices come
from the pay catalog (GET <payUrl>/v1/billing/plans) — nothing hardcoded.
The choice + completedAt persist BEFORE the redirect, so bouncing off the
payment page never re-enters onboarding.
Completion gate: Onboarding.tsx reads onboarding.completedAt on mount and
skips straight to the portal for a user who already finished — the
DOES-NOT-REPEAT requirement, backed by the user row, not browser storage.
Handoff: plan slug → <payUrl>/cart?plan=<slug>; payg → <payUrl>/onboard
($5-minimum top-up). payUrl is a new optional OrgConfig field (catalog
override for white-label brands; defaults to https://pay.hanzo.ai).
Fixed underneath: every self-write now goes through ONE read-merge-write
(updateSelf). IAM's update-user is a FULL-ROW write that ignores the v1
columns= param, so the wallet step's minimal body was silently blanking
displayName/email on every link. Tests lock the round trip.
id-shared 0.1.3, id-onboarding 0.1.3, id-web 0.1.33, root 0.2.24.
Co-authored-by: Hanzo Dev <[email protected]>
@hanzo/design 0.4.2 put control rungs back on the alpha ladder, so
.hanzo-id-input draws --border-control (.15) instead of --border-strong,
which is now hover and emphasis — decoration a control must not reach
for. Its comment argued the opposite in every clause; rewritten. The 3:1
budget did not vanish, it moved to --ring, where a keyboard user actually
reads position. Lines 183 and 374 keep --border-strong: both are hover.
Radius follows the same recut: the field and the button under it were
6px while the system moved every control to 8px. Changing only the field
would have left two radii on two stacked controls in one 432px card, so
both move.
tokens.test.ts had to change to stay true. It asserted each tokens/*.css
was reachable through @import; 0.4.x flattens all ten groups into
styles.css so a bundler never resolves those subpaths, and the check went
red with nothing wrong. It now asks whether every token a group declares
is actually served, which is the cherry-picking this gate exists to catch
and survives however the package assembles itself. base.css needed its
own assertion — it ships element defaults as rules, and the one token it
declares is also in colors.css, so token coverage cannot see it go
missing; `@layer base` is its exact marker. Both paths proven by
reintroducing the defect and watching the right message fire.
Pins ^0.4.5: 0.4.2 and 0.4.3 shipped a stray */ that PostCSS rejects and
are now deprecated on the registry.
Co-authored-by: Hanzo Dev <[email protected]>
Styling audit of iam.hanzo.ai. The headline finding is a NEGATIVE and it is the
useful part: this surface does not have the "classes without rules" defect that
broke hanzo.app, and structurally cannot — it uses neither Tamagui atomic nor
Tailwind, only hand-written semantic .hanzo-id-*. Measured in Chromium against
the served bundle, 7 routes x 3 viewports: gui atomic used 0 | rules 0, tailwind
used 0 | rules 0, and every rendered .hanzo-id-* class resolves.
An audit claim was WRONG and is corrected in LLM.md rather than "fixed": a build
with @hanzo/design absent does NOT silently emit a bare @import. Tried it —
Vite's postcss-import hard-fails with ENOENT and exit 1. The token layer cannot
vanish from this build. What was true is narrower: the tree was uninstalled, so
tokens.test.ts could not run. It passes once deps are present.
Four real defects, all verified by before/after measurement of the BUILT bundle
in a real browser (a green build is exactly what let the original defect ship):
- Ghost hover set border-color:var(--foreground) — #ededed, 17.9:1 — a
near-white wireframe on the two most-hovered controls. The edge no longer
moves; the surface carries the state, which the existing transition already
animated. NOT simply dimmed: every rung brighter than --neutral-500 is worse
on white (--neutral-400 is 8.3:1 on black but 2.52:1 on white) and would fail
the WCAG 1.4.11 floor the resting border holds in BOTH themes. No token gets
brighter on dark and darker on light, so the edge must not encode state.
- viewport-fit=cover was declared and never consumed — zero env(safe-area-inset)
rules. Now max(24px, env(...)) per side; 0 insets still compute to 24px.
- The iOS-zoom comment described a protection the code did not implement:
--text-base is 14px, so both credential fields zoomed on focus. 16px scoped to
@media (pointer: coarse); the desktop ramp is untouched.
- Three tap targets under 44px, including a logo link whose target was SMALLER
than the logo inside it (inline <a> = 32x18 around a 32px mark). Fixed with
padding/negative-margin pairs — zero layout shift, screenshots pixel-identical
at rest.
Measured BEFORE -> AFTER, built bundle served like prod:
tap targets <44px 3 -> 0 ghost hover #ededed -> #737373
safe-area rules 0 -> 1 @media rules 1 -> 2
input font-size (touch) 14px -> 16px overflow 0 -> 0
console errors 0 -> 0 atomic|tailwind 0|0 -> 0|0
CSS transferred 16,733 -> 17,070 bytes (+337) JS unchanged
The 5 page-variant classes with no rule (hanzo-id-login/-signup/-forgot/
-onboarding-page/-callback) are left alone: BEM-style hooks paired with
.hanzo-id-page, which carries the layout — the same pattern as .hanzo-id-device.
Pre-existing and NOT touched: pkgs/shared/src/org.test.ts fails on origin/main
(hanzo.app vs hanzo.chat), verified on a pristine checkout; it belongs to the
in-flight callback work.
Co-authored-by: Hanzo Dev <[email protected]>
The id image has failed to build since fb6dab3 with
"catalogOf" is not exported by "../../pkgs/shared/src/index.ts"
which is why 0.2.22 never existed and every fix behind it stayed unshipped.
fb6dab3 removed catalogOf from org.ts AND removed its tests from org.test.ts in
the same commit, while apps/web/src/App.tsx kept importing it from the barrel.
Deleting a function together with the tests that pin it removes the very thing
that would have reported the deletion — the failure surfaced two steps later, in
a container build, as a rollup resolution error.
catalogOf owns which key the /config.json payload uses, and the key is the
SERVER'S name (iamTenantConfigJson). Reading any other key returns undefined,
App.tsx falls back to a window global the runtime never injects, and every
catalog-only host silently drops to the bundled defaults — a total catalog
outage that looks like nothing at all. Verified against the live payload:
https://hanzo.id/config.json serves {"iamTenantConfigJson": "...", "v": 1}
with 13 hosts.
Tests restored alongside, plus one the old set lacked: that catalogOf is
reachable from the package BARREL, which is how App.tsx imports it and the exact
edge that broke the build.
Co-authored-by: Hanzo Dev <[email protected]>
Everything measured against production. Records the one-line cause of the
social-login outage (oauthCallbackOrigin defaulted to the brand host, so every
property sent a different redirect_uri), the two IAM fixes reconciled into one
SSO seam, the four method errors that cost real time this session, and the
ordered list of what remains — including the platform build enqueue being down
on a missing org row and the ruling to delete DEFAULT_BUILD_ORG_ID.
Co-authored-by: Hanzo Dev <[email protected]>
fb6dab3 put the one-provider-callback fix in @hanzo/id-shared, but nothing
downstream can consume a fix that was never published. The internal deps are
`workspace:*`, which pnpm rewrites to the concrete version AT PUBLISH TIME — so
id-shared alone going out leaves id-auth, id-onboarding and id-idv still
resolving 0.1.1 for anyone installing from the registry.
@hanzo/id-shared 0.1.1 -> 0.1.2 the fix itself
@hanzo/id-auth 0.1.6 -> 0.1.7 consumes it (SocialButtons, client)
@hanzo/id-onboarding 0.1.1 -> 0.1.2 consumes it
@hanzo/id-idv 0.1.0 -> 0.1.1 consumes it
@hanzo/id-web 0.1.30 -> 0.1.31 private app, kept in step
@hanzo/id 0.2.21 -> 0.2.22 workspace root
Patch throughout: the change is a corrected DEFAULT, and any consumer that had
already set oauthCallbackOrigin explicitly is unaffected — the explicit value
still wins. Nothing here justifies a minor, let alone a major.
@hanzo/id-connect is deliberately NOT bumped: it does not depend on id-shared.
Co-authored-by: Hanzo Dev <[email protected]>
Social login was broken on every Hanzo property for days, and it was never the
credentials. Google's own error payload, decoded, reads `redirect_uri_mismatch`
— our client_id reached Google intact every time.
Google and GitHub each hold ONE OAuth client with a FIXED list of authorized
redirect URIs. `oauthCallbackOrigin` defaulted to `publicOrigin` — the brand's
OWN host — and no catalog entry overrode it. So hanzo.app sent
hanzo.app/callback, hanzo.chat sent hanzo.chat/callback, console sent
console.hanzo.ai/callback, and social login could work on at most ONE property,
whichever happened to be registered. Every new brand arrived broken by
construction, and the failure surfaced at the provider rather than in our logs,
which is why it read as a KMS/secrets problem.
The default is now the org's hosted ID host, read out of DEFAULT_TENANTS so the
`.id` hosts stay declared exactly once:
hanzo.app / hanzo.chat / console.hanzo.ai / cloud.hanzo.ai -> hanzo.id/callback
id.lux.network -> lux.id/callback
A social provider therefore never learns about individual apps. hanzo.id
completes the exchange and forwards the browser back to the originating app.
FIRST ATTEMPT WAS WRONG, recorded so nobody repeats it: defaulting to
`iamIssuer` does NOT fix this. hostSkeleton derives the issuer from the REQUEST
HOST too, so it is per-brand for exactly the same reason. It has to be a
per-ORG constant. Caught by executing resolveOrg rather than reasoning about it.
An unknown host still falls through to the host-derived skeleton with orgId '',
so the fail-closed property that keeps a Zoo visitor off Hanzo's login is
untouched.
Two tests pin the default (there were none, which is how it drifted). They are
NOT run here: this checkout has no vitest installed and @hanzo/id-shared
declares no `test` script — `pnpm --filter @hanzo/id-shared test` exits 0 having
run nothing, which is its own false green. Behaviour verified by executing
resolveOrg directly under tsx against a production-shaped catalog.
Co-authored-by: Hanzo Dev <[email protected]>
/config.json is emitted by hanzoai/spa, which derives its key mechanically
from the env var universe supplies: SPA_IAM_TENANT_CONFIG_JSON becomes
iamTenantConfigJson. Renaming TenantConfig -> OrgConfig in this codebase
also renamed the READ to iamOrgConfigJson, a key nothing emits.
So the catalog has been undefined on every host for as long as that
shipped, with no error anywhere — the fetch succeeds, the field is
absent, and resolveOrg falls back. Verified against the live endpoint:
https://hanzo.id/config.json returns iamTenantConfigJson, and the SPA
was reading past it.
Two consequences, both live:
hanzo.id resolved to the BUILT-IN hanzo-id (enableSignUp:false) instead
of the catalog's hanzo-console (true). A first-time federated user is
refused "the application does not allow to sign up new account" — so
this gates the GitHub fix in 0.2.20 for exactly the users it was for.
Every catalog-ONLY host — osage.id, zoolabs.id, id.zoo.network,
id.lux.network, iam.lux.network, id.pars.network, id.bootno.de,
iam.hanzo.ai — got the empty-clientId skeleton and could resolve no
application at all. The resolver's fail-closed behaviour is correct and
is why this leaked no brand; it is also why it was silent.
The key now lives in one place, catalogOf, next to the resolver it feeds,
with the reason it is not ours to rename. Guarded by two tests that fail
on the old spelling and pass on the new.
Co-authored-by: Hanzo Dev <[email protected]>
GitHub sign-in succeeded at GitHub and dead-ended here — "you need to
login first", which is IAM's 401 from /v1/iam/onboard reached with no
session and no bearer. The SPA never obtained a token.
social.ts built the IdP URL in the browser and pointed it at our own
/callback, so GitHub returned a GitHub code that NOTHING can spend:
exchanging it needs the client secret, which a browser must never hold,
and IAM has no endpoint that takes a raw provider code. Callback.tsx
posted it to /v1/iam/login, which knows only password, device approval
and code minting.
IAM has implemented this correctly the whole time and was never called.
Naming a provider on the authorize endpoint IS the entry point: it
validates client_id, the exact redirect_uri and PKCE, then federates
server-side and returns an IAM code bound to the original challenge.
OAuthAuthorizeRequest.provider was already declared here; authorize()
simply never emitted it. That one parameter is the fix; the rest of this
diff deletes what existed to work around its absence.
The name is the record name — provider-github, never github:
federationProvider matches ProviderItem.Name exactly. A comment claimed
the opposite; corrected in place.
Two arms, the two the password path already branches on, differing only
in who owns the PKCE verifier: an app-initiated request is re-entered
verbatim so IAM mints against that app and returns the browser straight
to it; a bare portal sign-in goes through the SDK, which persists the
verifier in localStorage keyed by state so it survives the redirect.
Gone as dead: the IdP endpoint table, buildProviderAuthUrl,
startProviderLogin, isHoppableProvider, encodeState/decodeState,
client.providerLogin, ProviderExchangeRequest, and Callback's provider
branch. /callback now has one case, because a federated return is
indistinguishable from any other.
Verified in a browser against live IAM: both arms reach GitHub via
/v1/iam/oauth/authorize with redirect_uri=<brand>/v1/iam/oauth/callback
and the hanzo_fed cookie; the app arm creates no verifier slot, proving
the app's own request was forwarded; the return leg consumes the slot
and posts /v1/iam/oauth/token, which answers invalid_grant for a
deliberately fake code. The GitHub login itself is not exercisable here.
Co-authored-by: Hanzo Dev <[email protected]>
pkgs/auth/src/ui/ProviderButtons.tsx declared its own provider map:
google: { label: 'Continue with Google', brand: 'google' },
github: { label: 'Continue with GitHub', brand: 'github' },
Two providers, where SocialButtons renders four (ORDER = github, gitlab,
google, web3). It was imported by nobody, absent from pkgs/auth/src/ui/index.ts
and therefore never part of the package's public API, and unreferenced across
every repo in the estate. It could not render, so it could not be wrong in
production — it could only be COPIED, and the copy would be missing GitLab and
Wallet from the day it was made.
Login.tsx, Signup.tsx and DeviceApproval.tsx all use SocialButtons, which is
the correct design and stays: it resolves {application, providers} from IAM at
runtime and intersects with PROVIDER_META, so the buttons follow the
application's configuration rather than a hardcoded list.
social.ts is NOT a third list and is untouched: its provider map holds OAuth
authorize ENDPOINTS for buildProviderAuthUrl (11 call sites), which is where to
send the user, not what to show them. All six of its exports are live.
NOTE, and this commit does not fix it: the live asymmetry is IAM
per-application config, not this file. Measured against production in one
browser context —
page GitHub GitLab Google Wallet
sign-in y y y n
sign-up via client_id=hanzo-app y y y y
sign-up direct at hanzo.id/signup y n y y
GitLab is enabled on the hanzo-app application and not on the portal default,
and Wallet renders only under the signup intent. Both are configuration, and
both need a decision about what the canonical provider set is per application.
Co-authored-by: Hanzo Dev <[email protected]>
Three errors, each the kind that sent someone down a wrong path today.
`verification_uri_complete` does not add `?user_code=<code>`. IAM appends the
code as a PATH segment — `/login/oauth/device/<code>` — because that is the
route this page is registered on (device.go). readUserCode accepts the query
form too, so both work, but the minted URI is the path one.
Approval does not flip `UserSignIn=true`. No such field exists on the row.
approveDevice binds the approver onto `Token.User` as owner/name, and an EMPTY
User is what "not yet approved" means — that is the whole state machine, and a
reader looking for a boolean will not find one.
The CLI is `hanzo login`, not `dev login --device-auth`.
Also re-runs the 0.2.19 build: the previous one died in the oci.hanzo.ai login
step on a transient 502 (the registry answers 401 again, i.e. healthy), so no
image was ever pushed for this version and nothing is being re-tagged.
Co-authored-by: Hanzo Dev <[email protected]>
0.2.18 was cut, built and published BEFORE the two fixes it is named after
landed. The build workflow refuses to rebuild an existing version — correctly,
since re-pushing a tag yields two digests for one name — so f03c778 (drop the
confirmation checkbox) and everything after it never produced an image. The tag
in universe stayed 0.2.18 and the cluster kept serving the bytes it already had,
which is why the platform owner has asked four times for a checkbox that has
been gone from source the whole time.
A version is the only thing that publishes. Patch-bump, never a re-push.
Ships: the self-attestation checkbox removal (f03c778), and the approval page
naming the client that actually minted the code (5c6bf45).
Co-authored-by: Hanzo Dev <[email protected]>
The page said "You are about to authorize hanzo-console" for a sign-in started
by hanzo-cli. appLabel was org.appName — this PORTAL's own branding, a static
per-brand string — so every device code, whichever client minted it, was
approved under the same wrong name. The one job of the screen is to say WHICH
application you are authorizing, and a screen that names the wrong one does not
merely fail to help: it teaches people the name means nothing.
f682899 removed the false name but concluded the right one could not be learned
without building a code-hunting oracle. That was true of an open lookup and is
not true of the one IAM now serves: POST /v1/iam/oauth/device/info is session
gated, org scoped, and answers unknown / expired / already-approved with a
single opaque refusal, so it reveals strictly less than the approval the same
caller could already attempt. The comment arguing the endpoint must not exist is
replaced by what is actually true of it.
The code rides the POST BODY. A user_code is the one secret in this flow and a
request line is copied into ingress and proxy access logs where a body is not —
this page already ships scrubUrl() to keep the code out of the address bar, and
a GET would have undone that on the server side.
Approve is gated on a resolved name and fails closed: no server-confirmed
application, no button. Nothing local is ever substituted, because rendering a
guess is the defect being fixed. THIS MAKES THE PORTAL DEPEND ON IAM b466bd63 —
ship IAM first or together, or every approval blocks.
Co-authored-by: Hanzo Dev <[email protected]>
APPROVING THE WRONG NAME. The device page read "You are about to authorize
hanzo-console" for a sign-in started by hanzo-cli, because appLabel was
tenant.appName — this PORTAL's own branding, a static string, not the client that
asked. The backend was never confused: approveDevice binds the pending row's own
application, and says so ("the portal app the browser happens to be on is
irrelevant to WHAT is being approved").
The page cannot honestly name the client, and must not learn it: the user_code is
40 bits and the one secret in this flow, so an endpoint answering "which app is
this code for" would be the oracle device.go refuses to be — it returns one opaque
refusal for unknown / expired / already-approved precisely so live codes cannot be
hunted. So the page stops naming a party it cannot vouch for and asks about the
thing it can: the code the human transcribed off their own device. A consent screen
that names the wrong party is worse than one that names none — it teaches people
the name means nothing.
ORGS, NOT TENANTS. There is no tenant concept here; the identifier has always been
the org (the JWT `owner` claim, the IAM `<org>-<app>` namespace). TenantConfig →
OrgConfig, resolveTenant → resolveOrg, tenant → org, and the module renamed to
match. 28 files, no residual `tenant`, typecheck clean, 145 tests pass.
Co-authored-by: Hanzo Dev <[email protected]>
It read 'You are about to authorize hanzo-console' for a sign-in started by
hanzo-cli: appLabel was tenant.appName, this PORTAL's own branding, never the
client being approved. A consent screen that names the wrong party is worse than
one that names none — it teaches people the name means nothing.
It cannot honestly learn the right name either, and must not: the user_code is
40 bits and the only secret in this flow, so an endpoint that revealed which app
a code belonged to would be an oracle for hunting live codes — exactly what
device.go refuses to be, answering unknown, expired and already-approved with
one opaque refusal.
So the prompt asks about the thing this page CAN vouch for: the code in front of
the human, and whether they started the sign-in themselves. That is also where
the removed checkbox's intent belongs — in the sentence someone reads before
clicking, not a tickbox in front of it.
Co-authored-by: Hanzo Dev <[email protected]>
A release is a version bump; 0.2.17 already shipped the signup fix and its image
is published, so the checkbox change needs its own.
Co-authored-by: Hanzo Dev <[email protected]>
No device page anyone actually uses has one — Google, GitHub and AWS all show
the code and an Approve button. It sat in front of the only action on the screen
and disabled it until ticked.
It was there as an anti-phishing gate, on the theory that a victim arriving from
a crafted verification_uri_complete link must not approve with one click. A
tickbox is not evidence of that: someone being walked through a crafted link
ticks it exactly as readily as they click Approve. The property that does the
work is the one that stays — the code is displayed and remains EDITABLE, so
approving is an explicit act on a value the human can read and correct against
what their own device shows.
Co-authored-by: Hanzo Dev <[email protected]>
A customer who clicked "Create account" from an app never came back. Two
defects composed, and every unauthenticated probe read healthy through both.
`Login` linked to a bare `/signup`, so the OIDC request the app sent —
client_id, redirect_uri, state, PKCE challenge — was dropped at a full page
load. `Signup` already parsed exactly those params; nothing ever supplied
them.
Then IAM's signup is CREATE-ONLY. It persists the user and answers with the
row: no session cookie, no authorization code. The `autoSignin: true` the
client posted has no field in `internal/oidc/signup.go`, so the Go decoder
dropped it — "signed up" and "signed in" were never the same event. That
response fell through `parseLoginResponse`'s no-redirect arm to
`{redirectUrl: '/onboarding'}`, so the new user was sent to the portal's own
onboarding, unauthenticated, while the app waited on a code no one minted.
The links now carry the request, and `signup()` finishes the job through the
one path that establishes a session: it logs in, which mints the code bound
to this request's challenge and returns the redirect back to the app. A
signup that leaves you logged out is not a signup.
`parseCreated` reads the create leg, deliberately apart from
`parseLoginResponse` — that one INVENTS a destination when no redirect_uri
was asked for, which is right for a session and wrong for a row.
Registration also resolves the app via `get-app-login` the way `LoginForm`
does, so the account is created under the app the user came FROM rather than
the portal's own tenant.
Contracts locked in client.test.ts; both regressions verified by reverting the
fix and watching them fail. Driven end to end in Chromium against the built
SPA with IAM stubbed at its real response shapes: the params survive the
click and the browser lands on the app's callback holding the code.
Co-authored-by: Hanzo Dev <[email protected]>
The IAM envelope's untyped data2 slot splits into named fields — total
(count) / org (masked org) / mfa (challenge list). id touches two of the
meanings: the login MFA challenge allow-list (client.ts) now reads the
named mfa field first, and onboarding's list rows stay on the named data
slot; both keep the data2 fallback, deleted once IAM stops emitting it.
The MFA test table pins both spellings plus named-first precedence, and
listOrgs pins the legacy list slot.
@hanzo/id-auth 0.1.5, @hanzo/id-onboarding 0.1.1.
Co-authored-by: Hanzo Dev <[email protected]>
hanzoai/iam is Hanzo IAM — original, clean-room work; the vendor-derived server
was hanzoai/iam-v1, retired and in nothing we ship. The comments here named that
vendor for behavior that is IAM's own: get-account really does return the User
at the top level or under `data`, and add-organization really is entity CRUD
behind the authenticated Guard. Kept the facts, dropped the wrong name.
Comments and markdown only.
Co-authored-by: Hanzo Dev <[email protected]>
The build has failed every run on
::error::Error response from daemon: Get "https://ghcr.io/v2/": denied: denied
That is a LOGIN failure, not a push-permission one. It used GH_PAT with a
hardcoded username; every image build in this org that currently succeeds
(auto, base, sign, ui, papers) uses the GHCR_USER/GHCR_TOKEN org-secret pair
instead. The token identity is the only thing separating this job from those.
Checked before changing it, so this is not a guess about the registry side:
ghcr.io issues push,pull tokens for repository:hanzoai/id to the hanzo-dev
credential the cluster holds, and that account carries write:packages. So
neither the registry nor the target package rejects us — the credential this
step presents does.
One consequence worth recording: the hanzoai/id PACKAGE does not exist on ghcr
yet (the org has cloud, visor, vm), so the first successful push CREATES it.
That needs write:packages rather than read, which is exactly the kind of scope
gap a stale PAT produces.
Same registry, same tags, same build — only the credential changes.
Co-authored-by: Hanzo Dev <[email protected]>
This comment described a blocker that has since been cleared, and it is the kind
of stale note that causes harm rather than merely being out of date: read
literally it argues AGAINST pointing an App CR at oci.hanzo.ai, because it says
the cluster cannot pull from there.
It claimed registry-credentials "currently only carries registry.hanzo.ai — so a
CR pointed at oci.hanzo.ai today would ImagePullBackOff". Measured against the
live cluster:
registry-credentials auths -> registry.hanzo.ai (auth), oci.hanzo.ai (auth)
App CR id -> oci.hanzo.ai/id:0.2.13
pods -> 2/2 Running, 39h old, on that image
So the flip already happened and works; only the note lagged.
ghcr is still published to, and that is now stated as the actual reason —
a public mirror and a fallback — rather than as a workaround for a pull
credential that is no longer missing. Whether to drop it is a separate decision
this does not pre-empt.
No behaviour change: same two tags, same build.
Co-authored-by: Hanzo Dev <[email protected]>
The ff-main sync has been failing every 10 minutes. It was not broken: the forge
and GitHub genuinely diverged at 891e246 on 2026-03-06 and never rejoined —
150 commits one side, 163 the other — and the job correctly refuses to force
either history away.
Direction was decided by what production actually runs, not by which remote is
labelled canonical:
live (App CR) oci.hanzo.ai/id:0.2.13
forge/main version line … 0.2.9, 0.2.10, 0.2.11 (stops)
github/main version line … 0.2.11, 0.2.13, 0.2.14, 0.2.15
0.2.13 exists ONLY on the GitHub side. Production was built from that lineage,
so where the two disagree GitHub wins.
The conflict list looked far worse than it was. 19 of the 21 conflicts are
add/add — files absent at the merge base that BOTH sides then created
independently (the whole auth UI: LoginForm, OTPForm, SignupForm, ForgotForm,
SocialButtons, OnboardingFlow, Portal, DeviceApproval). Only package.json and
pnpm-lock.yaml were edited on both sides from a common ancestor. The parallel UI
implementations differ mainly by class naming — `hanzo-id-login-form` vs the
systematic `hanzo-id-form` / `hanzo-id-field` / `hanzo-id-input` from GitHub's
design-token pass — so taking GitHub keeps the version that shipped.
One conflict settles itself on evidence rather than preference:
forge: ${{ github.server_url }}/api/v1/repos/…
github: ${{ github.server_url }}/v1/repos/…
This forge serves /v1 and 404s /api/v1 — measured today against the Service
directly, bypassing ingress. The forge side of its own sync workflow had the
path wrong; GitHub's is correct.
Nothing from either side is lost. 26 files auto-merged, and the forge-only work
is intact — verified by ancestry, including d005dc7 "a host with no entry must
fail closed, not become Hanzo" and its regression test, which is a brand-leak
security fix.
Verified before committing: `pnpm install --frozen-lockfile` succeeds (so the
merged lockfile and the merged package.json files agree), `pnpm -r build`
completes, and `pnpm test` is 139 passed / 139 across 13 files.
This does NOT deploy anything. The App CR still pins 0.2.13; reconciling git and
choosing to ship are separate decisions, and only the first is made here.
Co-authored-by: Hanzo Dev <[email protected]>
0.2.14 adopted @hanzo/design and worked around it in three places. Those
findings are fixed IN @hanzo/design 0.3.0, so the workarounds go and this
surface takes the system's answer.
- ONE import: `@hanzo/design/styles.css`. app.css cherry-picked four of nine
token groups, so --z-*, --shadow-*, --space-*, --font-* and the element
defaults did not exist here. Nothing broke visibly, because an unresolved
var() paints nothing and reports no error. @hanzo/iam's account menu alone
reaches for --z-popover, --shadow-floating and --space-1..3.
- Geist is self-hosted in 0.3.0, so the reason for cherry-picking is gone:
fonts.css no longer requests fonts.googleapis.com. Measured on the built
bundle: rendered face is Geist, zero third-party font requests.
- The :focus-visible rule is deleted from this file. tokens/base.css ships it
to every consumer and --ring is now var(--neutral-500): measured 2px solid
rgb(115,115,115), 4.43:1 on --background (WCAG 2.4.11 wants 3:1). The local
--primary override existed only because --ring was 1.66:1.
- Control boundaries are --border-strong, not --white-40. 0.3.0 moved it to
var(--neutral-500) — 4.43:1 in BOTH themes. --white-40 cleared 3:1 on black
and measures 1.00:1 on white.
- @hanzo/iam 0.13.1 -> 0.21.1 in apps/web, pkgs/auth, pkgs/onboarding, and the
signed-in portal mounts <UserMenu> in place of a hand-rolled Billing/Sign out
link row. Identity comes from resolveIdentity, so the portal cannot disagree
with the console about who you are. No `brand` prop: omitting markSvg would
put the Hanzo mark on lux.id and zoo.id.
- Portal.tsx's inline style object is gone — rgba(255,255,255,0.14),
borderRadius 12, fontSize 13, fontWeight 600 and two bare opacities, six
invented values for facts the token layer already states.
- apps/web/src/tokens.test.ts gates RESOLUTION, not declaration. The reference
is built at runtime from a string, so it is invisible to the compiler and to
grep. Verified by reintroducing both original defects: it names the 5 dropped
groups and the 8 unresolved iam tokens.
Measured in Chromium, fresh context, 1440 and 390, on the built bundle served
as hanzo.id / lux.id / zoolabs.id: 0 unresolved of 76 tokens referenced on every
host; input edge and focus ring both 4.43:1; the account menu paints #0a0a0a /
12px / --shadow-floating / z-index 700. Brand switches with zero per-brand code
and no Hanzo mark reaches the Lux menu.
Open, against layers below this surface: the menu's own panel edge is --border
at 1.27:1 on --popover (0.3.0 raised --ring and --border-strong, not --border),
and BrandHeader loads the mark from cdn.jsdelivr.net/@latest — a third-party
request on the sign-in path, which is what this file refuses for fonts.
Co-authored-by: Hanzo Dev <[email protected]>
The first pass left three classes of config that BOTH tsc 5.9 and tsc 7
reject. Each was proven against both compilers before changing:
TS5090 A `paths` target must be relative once `baseUrl` is gone. The
first pass skipped the "./" prefix wherever baseUrl pointed at
the config's own directory, reasoning it was semantically
equivalent. It is not — without baseUrl a non-relative target
is rejected outright, by 5.9 as well as 7.
TS5110 `moduleResolution: node16` requires `module: node16`. The first
pass mapped commonjs projects to node16 resolution alone, which
BROKE those configs for the current toolchain. Both are now set.
TS5102 `downlevelIteration` is also removed in TS7; it was missing from
the dead-flag list.
Verified: repos that tsc 7 previously refused (base-studio, js-sdk, kv-js)
now report zero config errors on tsc 7 AND tsc 5.9.
Co-authored-by: Hanzo Dev <[email protected]>
TypeScript 7 is the native Go compiler and removes `baseUrl` and
`moduleResolution: node|node10`. Both appear here, so `tsc` from TS7
refuses the config outright (TS5102 / TS5108) and cannot typecheck.
`paths` targets resolve relative to `baseUrl` when it is set and relative
to the tsconfig file otherwise. Every `baseUrl` folded here already
pointed at the config's own directory, so dropping it moves nothing and
the targets are left byte-identical. Where a baseUrl pointed elsewhere,
each affected target was rewritten as join(baseUrl, target).
`moduleResolution` was chosen from the declared `module`: commonjs ->
node16, esnext/preserve -> bundler. Configs whose `module` is unset or
exotic were left alone rather than guessed at.
The result is accepted by BOTH toolchains, so nothing has to upgrade
TypeScript in lockstep. Verified on hanzo/chat packages/api: tsc 5.9
779 -> 778 errors (no regression), and tsc 7.0.2 now runs the project
in 2s where it previously refused the config.
Co-authored-by: Hanzo Dev <[email protected]>
The portal serves account.hanzo.id / account.lux.id — branded account-management
pages, IAM access tokens over the OAuth code exchange, redirecting to each
brand's login. That is identity UI: same brands, same IAM, same login redirect as
apps/web. It had no business being its own repo.
It also had a worse problem. It lived in hanzoai/account, which is ALSO the Go
module github.com/hanzoai/account — the package that owns the billing-account
rule (Account, Payer, Subject) that cloud and commerce both import. One repo,
two unrelated codebases, one name: `main` was this Worker, and the Go module
survived only on a `go` branch plus its tags. A Go module whose default branch
has no go.mod cannot resolve for anything but an explicit tag, which is why it
was private-and-pinned rather than importable.
Moving the Worker here frees that repo to be the Go module and nothing else.
Co-authored-by: Hanzo Dev <[email protected]>
Superseded: the Hanzo GitHub App pushes a webhook, so the forge tracks GitHub
without a per-repo workflow. This file called git.hanzo.ai/api/v1/.../mirror-sync
— a Gitea API for a system we no longer drive — and would sit inert in every repo.
One mechanism, in one place, instead of ~350 copies of a cron.
The running image (0.2.13, built from e620df2) was NOT on origin/main. The two
had diverged at 6d74def6 with the live code unmerged: self-service org creation
founding through /v1/iam/onboard instead of the admin-only add-organization verb
(which is 401 without a bearer and 403 with one, so signup was dead), and the
App/Chat/Cloud launcher. A release cut from main would have silently regressed
both on hanzo.id, lux.id, pars.id and zoo.id at once.
This merges them. main is once again what runs, and 0.2.14 is the first tag that
carries BOTH the live fixes and the @hanzo/design token pass. The only conflict
was the version line.
136 unit tests and every workspace typecheck green; the login, device-approval
and spinner measurements re-taken in a real browser against the merged bundle.
Co-authored-by: Hanzo Dev <[email protected]>
A component's surface must never depend on WHERE it is mounted. app.css painted
controls with the descendant selectors `form input {…}` and `.hanzo-id-btn, form
button {…}`. DeviceApproval — the screen a human hits to authorise the CLI — has
2 inputs and 0 <form> ancestors, so its device-code field fell out of the
stylesheet and rendered as raw UA chrome: 31px tall, #3b3b3b, a 2px inset bevel,
square corners, sitting beside correctly-styled 44px siblings. That is the mirror
image of a component library shipping utility class names with no CSS behind
them — bare elements instead of bare class names — and it was live on hanzo.id,
lux.id and pars.id at once. Every control now carries its own class
(.hanzo-id-input / .hanzo-id-btn / .hanzo-id-field / .hanzo-id-form) and no
element-descendant selector for surface is left in the file.
.hanzo-id-spinner had NO rule at all: the loading state measured 0px and was
invisible on every portal. It is a real 28px ring now.
Tokens come from @hanzo/design — the same token layer hanzoai/pay renders from,
so the two halves of one flow (sign in -> pay) agree on the page black (#000000,
--background; the page was painted with the --card value), the type ramp (h1 21px
on both), the radii and the greys. Nothing in this file invents a colour, radius
or size. @hanzo/gui was declared as a dependency and imported exactly zero times;
it is removed rather than left as decoration.
ONE button: .hanzo-id-social-btn and the .primary modifier are gone. .hanzo-id-btn
IS primary and .ghost is the secondary surface, so 13 treatments become 1
primitive with 1 modifier. ONE focus indicator: the file had a single focus rule
(`form input:focus`), so every button, link and social entry fell back to
Chrome's blue outline: auto on a monochrome surface. `font: inherit` on every
control: buttons and inputs rendered in Arial while headings rendered in the
platform face — two typefaces in one 432px card, including on the Sign in CTA.
Control borders are --white-40 on purpose. On --background the semantic --border
(#1f1f1f) measures 1.27:1 and --border-strong (#404040) 2.03:1; neither clears
the 3:1 a control boundary needs (WCAG 1.4.11), and --white-40 measures 3.66:1
and is on the ladder. Same reasoning for the focus ring: --ring (#333333)
measures 1.66:1 and cannot carry one, so the ring is --primary.
Fonts are deliberately NOT imported from the design package: tokens/fonts.css
pulls Geist from fonts.googleapis.com and the sign-in path loads no third-party
font. --font-sans resolves to the platform stack, the identical value hanzoai/pay
sets.
Measured in a real browser against the built bundle, fresh context and empty
storage: page #000000, every control 44px, one 6px radius, h1 21px, a white
2px focus ring, nothing under the touch floor at 390px, no horizontal overflow.
134 unit tests and every workspace typecheck green.
Co-authored-by: Hanzo Dev <[email protected]>
git.hanzo.ai mirrors this repo by PULL on a ~10-minute interval, and arcd runs
CI/CD there — so every push waited out that interval before anything built.
This asks Gitea to pull HEAD immediately.
Latency only: the repo already mirrors via the App webhook, so a missing
HANZO_GIT_TOKEN or a failed curl is non-fatal and never fails the push.
Idempotent (mirror-sync just pulls HEAD) and concurrency-coalesced.
The signed-in launcher listed six tiles and one product twice. "Console" is not a
separate product: it is what Cloud used to be called, console.hanzo.ai redirects
to cloud.hanzo.ai, and clicking either tile landed on the same page — so the list
taught people a product that does not exist and sent half the traffic through a
redirect. It is gone, and nothing here links to console.hanzo.ai any more.
Analytics, Platform and Storage went with it. Storage pointed at s3.hanzo.ai,
which answers a browser with a bare XML AccessDenied — it is an S3 API endpoint,
not a page — and a launcher that lands you on an error page is how people learn
the tiles are broken. The other two are surfaces inside Cloud, not products of
their own.
The tiles themselves were never the defect: they are plain <a href> anchors, they
hit-test clean, and they navigate — middle-click and copy-link always worked. What
made them feel dead was the destination. Every app bounced a signed-in person to a
credential form, so a click ended back on hanzo.id staring at a password box,
which looks exactly like nothing happened. That is IAM's missing silent-SSO branch
(hanzoai/iam v1.33.15), fixed there rather than papered over here.
Co-authored-by: Hanzo Dev <[email protected]>
A new person typed an org name at hanzo.id/onboarding, clicked "Create
organization", and got HTTP 401. Self-service signup was dead.
createOrg posted to /v1/iam/add-organization — Casdoor entity CRUD behind IAM's
authenticated Guard, with owner:"admin" in the body. That is the wrong door
twice over. The Guard authenticates by BEARER only, and a bare portal sign-in
(type=login) establishes a session cookie and mints no token at all, so the
browser's request arrived anonymous: 401. Hand it a bearer and it is still 403 —
"admin" is a reserved platform org and a human may only write an org row whose
name is the org they are already in, so a person founding their FIRST org can
never pass that gate. Correct behaviour; wrong door. Widening it would mean
handing an app blanket authority over every tenant's orgs.
/v1/iam/onboard is the door built for this: it resolves the caller from their own
session or bearer and provisions the whole tenant under their authority as its
founder — org stamped with them as Founder, them moved in as its owner, one
metered API key — atomically and idempotently.
The display name now travels and the server's slug comes back: slug policy lives
in one place, so the client's preview is only a preview. The front door answers
{org}/{error}, not the casibase envelope, so its own words reach the user ("that
name is reserved", "you already have an organization") instead of a bare code.
Tests: the call goes to onboard and never to add-organization; both credentials
ride along; the front door's error text surfaces verbatim.
Co-authored-by: Hanzo Dev <[email protected]>
The fork moved its API off /api to /v1, so every call built against
${{ github.server_url }}/api/v1/... now 404s. Verified live with a control:
/v1/version 200, /api/v1/version 404, a nonsense path 404.
This is the build-dispatch in sync-from-github, so a fast-forward from GitHub
was landing commits and then silently failing to trigger the build.