Compare commits

...
271 Commits
Author SHA1 Message Date
zooqueenandhanzo-dev 6813e8a304 docs: the focus law, and the two-rule collision that hid under it
Hanzo CI/CD / cicd (push) Successful in 1m4s
CI/CD / cicd (push) Successful in 1m4s
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 <dev@hanzo.ai>
2026-08-05 06:46:47 -07:00
zooqueenandhanzo-dev 87ca9544a2 id: one focus ring, a CTA that answers the pointer, a lockup that stays 32px
Hanzo CI/CD / cicd (push) Successful in 1m31s
CI/CD / cicd (push) Successful in 1m33s
@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 <dev@hanzo.ai>
2026-08-05 06:42:46 -07:00
hanzo-dev 05237b0470 the image installs what the tests ran
Hanzo CI/CD / cicd (push) Successful in 1m21s
CI/CD / cicd (push) Successful in 1m21s
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
vite@7.3.5_@types+node@25.9.3_..., free resolution chose
vite@7.3.6_@types+node@22.20.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 <dev@hanzo.ai>
2026-08-05 01:05:20 -07:00
zeekayandhanzo-dev ac70eb41d1 telemetry: report pageviews and errors, refused on auth-artifact routes
Hanzo CI/CD / cicd (push) Failing after 49s
CI/CD / cicd (push) Failing after 50s
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 <dev@hanzo.ai>
2026-08-04 23:48:20 -07:00
zeekayandhanzo-dev 18756a8ce2 ci: retire deploy.yml and the dead .github lane — one build path
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 <dev@hanzo.ai>
2026-08-04 17:28:44 -07:00
zeekayandhanzo-dev 73346553ce ci: the canonical lane — root hanzo.yml + .hanzo/workflows/cicd.yml (0.2.27)
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: pnpm@10.15.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 <dev@hanzo.ai>
2026-08-04 17:08:39 -07:00
zeekayandhanzo-dev 23950b9ea4 onboarding: mint the bearer the writes need (0.2.26)
build / build (push) Successful in 39s
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 <dev@hanzo.ai>
2026-08-04 15:29:01 -07:00
zeekayandhanzo-dev 331675c945 onboarding: catalog prices are CENTS — pin the real contract (0.2.25)
build / build (push) Successful in 36s
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 <dev@hanzo.ai>
2026-08-04 14:43:48 -07:00
zeekayandhanzo-dev 5ab47aeb5d onboarding: consent + plan-or-payg steps, saved ONCE on the user (0.2.24)
build / build (push) Successful in 40s
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 <dev@hanzo.ai>
2026-08-04 14:41:56 -07:00
zeekay fea1660363 merge origin/main
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 11:22:27 -07:00
zeekayandhanzo-dev 5f01fb90f4 web: the sign-in control takes the system's edge and radius
@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 <dev@hanzo.ai>
2026-08-04 11:20:24 -07:00
zeekayandhanzo-dev 0da7d5b33a fix(web): mobile floors + a hover that painted a wireframe (0.2.23)
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 <dev@hanzo.ai>
2026-08-04 10:19:14 -07:00
hanzo-dev 787352b0eb fix(shared): restore catalogOf — c153004 deleted it with its own tests
The id image has failed to build since c153004 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.

c153004 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 <dev@hanzo.ai>
2026-08-03 18:11:09 -07:00
hanzo-dev 7c6f148435 docs(llm): handoff — social login root cause, what shipped, what is left
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 <dev@hanzo.ai>
2026-08-03 17:52:51 -07:00
hanzo-dev f2fc1e4539 release: id-shared 0.1.2 and the three packages that carry it
c153004 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 <dev@hanzo.ai>
2026-08-03 17:33:42 -07:00
hanzo-dev c15300493c auth: one provider callback per org, and it is the hosted ID host
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 <dev@hanzo.ai>
2026-08-03 17:32:09 -07:00
zooqueenandhanzo-dev a39ebf4b4e docs: oauthCallbackOrigin is dead config, and its comment argues for the bug
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 14:00:54 -07:00
zooqueenandhanzo-dev 36fb502b13 docs: the catalog key, and why a rename did not stop at the language boundary
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 13:59:13 -07:00
zooqueenandhanzo-dev 75ea9b6bae config: read the catalog key the runtime actually serves
build / build (push) Successful in 46s
/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 <dev@hanzo.ai>
2026-08-03 13:56:11 -07:00
zooqueenandhanzo-dev 7183bb69c8 social: the browser was the relying party, and it cannot be one
build / build (push) Successful in 54s
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 <dev@hanzo.ai>
2026-08-03 13:49:22 -07:00
hanzo-dev 2fe32a731e auth: one provider list, not two — delete the one nothing could render
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 <dev@hanzo.ai>
2026-08-03 07:51:00 -07:00
zooqueenandhanzo-dev 1ac8c599b4 device approval: the header described a flow that does not exist
build / build (push) Successful in 36s
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 <dev@hanzo.ai>
2026-08-01 20:52:51 -07:00
zooqueenandhanzo-dev 13413d41a6 0.2.19 — the device page names the right client, and the checkbox ships
build / build (push) Failing after 12s
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 5d411ff (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 (5d411ff), and the approval page
naming the client that actually minted the code (470aa48).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 20:43:40 -07:00
zooqueenandhanzo-dev 470aa48408 device approval: name the client from the code, not the portal
The page said "You are about to authorize hanzo-console" for a sign-in started
by hanzo-cli. appLabel was org.appName — this PORTAL's own branding, a static
per-brand string — so every device code, whichever client minted it, was
approved under the same wrong name. The one job of the screen is to say WHICH
application you are authorizing, and a screen that names the wrong one does not
merely fail to help: it teaches people the name means nothing.

bd04657 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 <dev@hanzo.ai>
2026-08-01 20:43:26 -07:00
zooqueenandhanzo-dev bd04657638 fix: the consent screen named the wrong client, and we have orgs not tenants
APPROVING THE WRONG NAME. The device page read "You are about to authorize
hanzo-console" for a sign-in started by hanzo-cli, because appLabel was
tenant.appName — this PORTAL's own branding, a static string, not the client that
asked. The backend was never confused: approveDevice binds the pending row's own
application, and says so ("the portal app the browser happens to be on is
irrelevant to WHAT is being approved").

The page cannot honestly name the client, and must not learn it: the user_code is
40 bits and the one secret in this flow, so an endpoint answering "which app is
this code for" would be the oracle device.go refuses to be — it returns one opaque
refusal for unknown / expired / already-approved precisely so live codes cannot be
hunted. So the page stops naming a party it cannot vouch for and asks about the
thing it can: the code the human transcribed off their own device. A consent screen
that names the wrong party is worse than one that names none — it teaches people
the name means nothing.

ORGS, NOT TENANTS. There is no tenant concept here; the identifier has always been
the org (the JWT `owner` claim, the IAM `<org>-<app>` namespace). TenantConfig →
OrgConfig, resolveTenant → resolveOrg, tenant → org, and the module renamed to
match. 28 files, no residual `tenant`, typecheck clean, 145 tests pass.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 20:32:41 -07:00
zooqueenandhanzo-dev 793b9b37bc device approval: stop naming the wrong app, and say what the page can vouch for
It read 'You are about to authorize hanzo-console' for a sign-in started by
hanzo-cli: appLabel was tenant.appName, this PORTAL's own branding, never the
client being approved. A consent screen that names the wrong party is worse than
one that names none — it teaches people the name means nothing.

It cannot honestly learn the right name either, and must not: the user_code is
40 bits and the only secret in this flow, so an endpoint that revealed which app
a code belonged to would be an oracle for hunting live codes — exactly what
device.go refuses to be, answering unknown, expired and already-approved with
one opaque refusal.

So the prompt asks about the thing this page CAN vouch for: the code in front of
the human, and whether they started the sign-in themselves. That is also where
the removed checkbox's intent belongs — in the sentence someone reads before
clicking, not a tickbox in front of it.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 20:30:38 -07:00
zooqueenandhanzo-dev 255c9622fb 0.2.18 — device approval drops the confirmation checkbox
A release is a version bump; 0.2.17 already shipped the signup fix and its image
is published, so the checkbox change needs its own.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 20:22:40 -07:00
zooqueenandhanzo-dev 5d411ff26a device approval: drop the confirmation checkbox
No device page anyone actually uses has one — Google, GitHub and AWS all show
the code and an Approve button. It sat in front of the only action on the screen
and disabled it until ticked.

It was there as an anti-phishing gate, on the theory that a victim arriving from
a crafted verification_uri_complete link must not approve with one click. A
tickbox is not evidence of that: someone being walked through a crafted link
ticks it exactly as readily as they click Approve. The property that does the
work is the one that stays — the code is displayed and remains EDITABLE, so
approving is an explicit act on a value the human can read and correct against
what their own device shows.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 18:47:36 -07:00
Antje Worringandhanzo-dev c78eddd735 signup must end in a session — new customers were stranded on the portal
build / build (push) Successful in 3m7s
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 <dev@hanzo.ai>
2026-07-30 20:05:05 -07:00
zeekayandhanzo-dev 1e919446b6 0.2.16 — read IAM's named envelope fields before legacy data2
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 <dev@hanzo.ai>
2026-07-30 10:59:28 -07:00
zooqueenandhanzo-dev 66c6ebee63 docs: name the identity server IAM in these comments
hanzoai/iam is Hanzo IAM — original, clean-room work; the vendor-derived server
was hanzoai/iam-v1, retired and in nothing we ship. The comments here named that
vendor for behavior that is IAM's own: get-account really does return the User
at the top level or under `data`, and add-organization really is entity CRUD
behind the authenticated Guard. Kept the facts, dropped the wrong name.

Comments and markdown only.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-28 17:13:43 -07:00
zandhanzo-dev ce9de2e9cb ci: log in to ghcr with GHCR_USER/GHCR_TOKEN, the pair that works
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 <dev@hanzo.ai>
2026-07-28 09:54:56 -07:00
zandhanzo-dev dddb1bb637 ci: the oci.hanzo.ai pull credential exists now — correct the note that says it does not
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 <dev@hanzo.ai>
2026-07-28 09:41:37 -07:00
zandhanzo-dev 06a360e71e merge github/hanzoai/id — reconcile five months of parallel history
build / build (push) Successful in 35s
The ff-main sync has been failing every 10 minutes. It was not broken: the forge
and GitHub genuinely diverged at 6fd3a63 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 3b06747 "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 <dev@hanzo.ai>
2026-07-28 05:37:03 -07:00
zooqueenandhanzo-dev 23c5b47eea 0.2.15 — take the whole token layer, mount the ONE account control, gate on resolution
0.2.14 adopted @hanzo/design and worked around it in three places. Those
findings are fixed IN @hanzo/design 0.3.0, so the workarounds go and this
surface takes the system's answer.

- ONE import: `@hanzo/design/styles.css`. app.css cherry-picked four of nine
  token groups, so --z-*, --shadow-*, --space-*, --font-* and the element
  defaults did not exist here. Nothing broke visibly, because an unresolved
  var() paints nothing and reports no error. @hanzo/iam's account menu alone
  reaches for --z-popover, --shadow-floating and --space-1..3.

- Geist is self-hosted in 0.3.0, so the reason for cherry-picking is gone:
  fonts.css no longer requests fonts.googleapis.com. Measured on the built
  bundle: rendered face is Geist, zero third-party font requests.

- The :focus-visible rule is deleted from this file. tokens/base.css ships it
  to every consumer and --ring is now var(--neutral-500): measured 2px solid
  rgb(115,115,115), 4.43:1 on --background (WCAG 2.4.11 wants 3:1). The local
  --primary override existed only because --ring was 1.66:1.

- Control boundaries are --border-strong, not --white-40. 0.3.0 moved it to
  var(--neutral-500) — 4.43:1 in BOTH themes. --white-40 cleared 3:1 on black
  and measures 1.00:1 on white.

- @hanzo/iam 0.13.1 -> 0.21.1 in apps/web, pkgs/auth, pkgs/onboarding, and the
  signed-in portal mounts <UserMenu> in place of a hand-rolled Billing/Sign out
  link row. Identity comes from resolveIdentity, so the portal cannot disagree
  with the console about who you are. No `brand` prop: omitting markSvg would
  put the Hanzo mark on lux.id and zoo.id.

- Portal.tsx's inline style object is gone — rgba(255,255,255,0.14),
  borderRadius 12, fontSize 13, fontWeight 600 and two bare opacities, six
  invented values for facts the token layer already states.

- apps/web/src/tokens.test.ts gates RESOLUTION, not declaration. The reference
  is built at runtime from a string, so it is invisible to the compiler and to
  grep. Verified by reintroducing both original defects: it names the 5 dropped
  groups and the 8 unresolved iam tokens.

Measured in Chromium, fresh context, 1440 and 390, on the built bundle served
as hanzo.id / lux.id / zoolabs.id: 0 unresolved of 76 tokens referenced on every
host; input edge and focus ring both 4.43:1; the account menu paints #0a0a0a /
12px / --shadow-floating / z-index 700. Brand switches with zero per-brand code
and no Hanzo mark reaches the Lux menu.

Open, against layers below this surface: the menu's own panel edge is --border
at 1.27:1 on --popover (0.3.0 raised --ring and --border-strong, not --border),
and BrandHeader loads the mark from cdn.jsdelivr.net/@latest — a third-party
request on the sign-in path, which is what this file refuses for fonts.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 17:49:37 -07:00
zeekayandhanzo-dev c2db6cc171 fix: complete the TypeScript 7 tsconfig migration
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 <dev@hanzo.ai>
2026-07-27 17:25:55 -07:00
zeekayandhanzo-dev 9c17d55ebb build: migrate tsconfig to TypeScript 7 (native compiler)
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 <dev@hanzo.ai>
2026-07-27 14:18:19 -07:00
zeekayandhanzo-dev b4b87be598 account portal: move it here, where the identity UI lives
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 <dev@hanzo.ai>
2026-07-27 13:05:47 -07:00
hanzo-dev c89793f14d ci: drop the Gitea mirror-sync nudge
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.
2026-07-27 10:34:44 -07:00
zooqueenandhanzo-dev 446a46a465 0.2.14 — reunite main with production, on top of the design-token pass
The running image (0.2.13, built from 9477777) was NOT on origin/main. The two
had diverged at 7a72225f 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 <dev@hanzo.ai>
2026-07-27 09:28:10 -07:00
zooqueenandhanzo-dev c9943fb883 id: style from @hanzo/design tokens, and stop painting controls by ancestor
A component's surface must never depend on WHERE it is mounted. app.css painted
controls with the descendant selectors `form input {…}` and `.hanzo-id-btn, form
button {…}`. DeviceApproval — the screen a human hits to authorise the CLI — has
2 inputs and 0 <form> ancestors, so its device-code field fell out of the
stylesheet and rendered as raw UA chrome: 31px tall, #3b3b3b, a 2px inset bevel,
square corners, sitting beside correctly-styled 44px siblings. That is the mirror
image of a component library shipping utility class names with no CSS behind
them — bare elements instead of bare class names — and it was live on hanzo.id,
lux.id and pars.id at once. Every control now carries its own class
(.hanzo-id-input / .hanzo-id-btn / .hanzo-id-field / .hanzo-id-form) and no
element-descendant selector for surface is left in the file.

.hanzo-id-spinner had NO rule at all: the loading state measured 0px and was
invisible on every portal. It is a real 28px ring now.

Tokens come from @hanzo/design — the same token layer hanzoai/pay renders from,
so the two halves of one flow (sign in -> pay) agree on the page black (#000000,
--background; the page was painted with the --card value), the type ramp (h1 21px
on both), the radii and the greys. Nothing in this file invents a colour, radius
or size. @hanzo/gui was declared as a dependency and imported exactly zero times;
it is removed rather than left as decoration.

ONE button: .hanzo-id-social-btn and the .primary modifier are gone. .hanzo-id-btn
IS primary and .ghost is the secondary surface, so 13 treatments become 1
primitive with 1 modifier. ONE focus indicator: the file had a single focus rule
(`form input:focus`), so every button, link and social entry fell back to
Chrome's blue outline: auto on a monochrome surface. `font: inherit` on every
control: buttons and inputs rendered in Arial while headings rendered in the
platform face — two typefaces in one 432px card, including on the Sign in CTA.

Control borders are --white-40 on purpose. On --background the semantic --border
(#1f1f1f) measures 1.27:1 and --border-strong (#404040) 2.03:1; neither clears
the 3:1 a control boundary needs (WCAG 1.4.11), and --white-40 measures 3.66:1
and is on the ladder. Same reasoning for the focus ring: --ring (#333333)
measures 1.66:1 and cannot carry one, so the ring is --primary.

Fonts are deliberately NOT imported from the design package: tokens/fonts.css
pulls Geist from fonts.googleapis.com and the sign-in path loads no third-party
font. --font-sans resolves to the platform stack, the identical value hanzoai/pay
sets.

Measured in a real browser against the built bundle, fresh context and empty
storage: page #000000, every control 44px, one 6px radius, h1 21px, a white
2px focus ring, nothing under the touch floor at 390px, no horizontal overflow.
134 unit tests and every workspace typecheck green.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 09:25:33 -07:00
hanzo-dev b774772a92 ci: nudge git.hanzo.ai to pull on push
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.
2026-07-27 08:20:36 -07:00
zeekayandhanzo-dev 9477777e6b chore(release): 0.2.13 — self-service org creation + App/Chat/Cloud launcher
0.2.12 is the live image (built from 3b067475); this is the next free release
above it.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-26 17:26:12 -07:00
zeekayandhanzo-dev e9cca37728 fix(launcher): Hanzo is App, Chat, Cloud — Console is Cloud's old name
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 <dev@hanzo.ai>
2026-07-26 17:24:14 -07:00
zeekayandhanzo-dev e22fef1f2a fix(onboarding): found an org through the front door, not the admin verb
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 <dev@hanzo.ai>
2026-07-26 17:24:03 -07:00
zeekay 7a72225f58 fix: point forge API calls at /v1 — /api/v1 is gone
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.
2026-07-26 15:51:46 -07:00
zeekay 0184f80346 ci: drop the last two GitHub workflows — id is native like the rest
Neither did any work. `docker.yml` was reduced to a `notice` job whose only step
echoes "native pipeline is .hanzo/workflows/deploy.yml; GitHub is a mirror" —
it burned a GitHub runner to print one sentence, and had run four times doing
exactly that. `workflow-sanity.yml` triggers only on
`pull_request: paths: ['.github/workflows/**']`, so with that directory empty it
can never fire on anything meaningful; it validated the thing being removed.

The real pipeline is unchanged and already here:
  .hanzo/workflows/deploy.yml           builds on hanzo-build-linux-amd64
  .hanzo/workflows/sync-from-github.yml fast-forward-only mirror pull

That matches console, cloud, universe and bootnode, all of which carry zero
GitHub workflows. git.hanzo.ai is the forge; GitHub is a mirror and needs no
CI of its own.
2026-07-26 14:28:05 -07:00
zeekay 3b067475d1 fix(tenant): a host with no entry must fail closed, not become Hanzo
resolveTenant's terminal branch returned DEFAULT_TENANTS[`${defaultOrg}.id`] —
Hanzo's tenant — for any host it could not resolve. Eight real identity hosts
have no built-in entry and exist only in the runtime catalog:

  zoolabs.id  www.zoolabs.id  id.zoo.network  id.lux.network
  iam.lux.network  id.pars.network  id.bootno.de  iam.hanzo.ai

and App.tsx deliberately tolerates a failed /config.json fetch. So whenever
that fetch failed, a Zoo, Lux, Pars or Bootnode visitor was handed orgId
`hanzo`, brandPackage `@hanzo/brand` and iamUrl `https://hanzo.id` — shown
"Sign in to Hanzo ID" under the Hanzo mark, and posting their credentials at
hanzo.id. Not a cosmetic brand leak: the wrong origin receives the password.

The function's own comment eleven lines above already promised this could not
happen ("Never another brand's config: a catalog-only host … must not inherit
Hanzo's issuer or brand package"). The guarantee held only while the catalog
loaded; the fallback broke it exactly when the catalog did not.

Unknown hosts now resolve to hostSkeleton(host) — the host's own origin, empty
orgId/clientId/brandPackage. The portal fails closed rather than silently
authenticating as another brand's IAM application. A login page that cannot
resolve its tenant must refuse, not guess. The `defaultOrg` option is deleted
with the branch that used it; the docblock no longer advertises a cross-brand
default.

Also removes the `zoo.id` built-in: crs/iam.yaml records the host as retired to
NXDOMAIN, and `dig zoo.id` returns nothing. It named a tenant that cannot be
reached.

One existing test asserted the defect as intended behaviour
(`orgId === 'hanzo'` for an unknown host) and is corrected. Added coverage
walks all seven brand hosts with NO catalog and asserts none of them comes back
as Hanzo. 134 tests pass across 12 files.
2026-07-26 12:33:30 -07:00
zeekay cdd9590e2a fix(tenant): a host with no entry must fail closed, not become Hanzo
resolveTenant's terminal branch returned DEFAULT_TENANTS[`${defaultOrg}.id`] —
Hanzo's tenant — for any host it could not resolve. Eight real identity hosts
have no built-in entry and exist only in the runtime catalog:

  zoolabs.id  www.zoolabs.id  id.zoo.network  id.lux.network
  iam.lux.network  id.pars.network  id.bootno.de  iam.hanzo.ai

and App.tsx deliberately tolerates a failed /config.json fetch. So whenever
that fetch failed, a Zoo, Lux, Pars or Bootnode visitor was handed orgId
`hanzo`, brandPackage `@hanzo/brand` and iamUrl `https://hanzo.id` — shown
"Sign in to Hanzo ID" under the Hanzo mark, and posting their credentials at
hanzo.id. Not a cosmetic brand leak: the wrong origin receives the password.

The function's own comment eleven lines above already promised this could not
happen ("Never another brand's config: a catalog-only host … must not inherit
Hanzo's issuer or brand package"). The guarantee held only while the catalog
loaded; the fallback broke it exactly when the catalog did not.

Unknown hosts now resolve to hostSkeleton(host) — the host's own origin, empty
orgId/clientId/brandPackage. The portal fails closed rather than silently
authenticating as another brand's IAM application. A login page that cannot
resolve its tenant must refuse, not guess. The `defaultOrg` option is deleted
with the branch that used it; the docblock no longer advertises a cross-brand
default.

Also removes the `zoo.id` built-in: crs/iam.yaml records the host as retired to
NXDOMAIN, and `dig zoo.id` returns nothing. It named a tenant that cannot be
reached.

One existing test asserted the defect as intended behaviour
(`orgId === 'hanzo'` for an unknown host) and is corrected. Added coverage
walks all seven brand hosts with NO catalog and asserts none of them comes back
as Hanzo. 134 tests pass across 12 files.
2026-07-26 12:33:30 -07:00
zeekay 48acbaaf92 test(tenant): a catalog host with NO brandUrl must not fall back to Hanzo
id.bootno.de had no catalog entry at all, so resolveTenant took the
`defaultOrg` path and a Bootnode customer was asked to "Sign in to Hanzo ID"
under the Hanzo mark — the white-label invariant broken at the highest-intent
moment in the product.

The existing osage.id case covers a catalog-only host that HAS a brandUrl.
id.bootno.de is the other shape: no bootnode brand package is published
(@bootnode/brand, @bootno/brand — all 404 on npm), so the entry carries no
brandUrl at all. That must resolve to an EMPTY brandPackage — the loader then
shows a neutral wordmark rather than another brand's mark — while idBrandLabel
still reads `orgId` first and renders "Sign in to Bootnode ID".

Also pins the intended default explicitly: a genuinely unknown host still
resolves to hanzo. That is the behaviour we want to keep; the bug was that a
KNOWN brand host was reaching it.

9 tests pass.
2026-07-26 11:02:37 -07:00
zeekay c8a14ce212 test(tenant): a catalog host with NO brandUrl must not fall back to Hanzo
id.bootno.de had no catalog entry at all, so resolveTenant took the
`defaultOrg` path and a Bootnode customer was asked to "Sign in to Hanzo ID"
under the Hanzo mark — the white-label invariant broken at the highest-intent
moment in the product.

The existing osage.id case covers a catalog-only host that HAS a brandUrl.
id.bootno.de is the other shape: no bootnode brand package is published
(@bootnode/brand, @bootno/brand — all 404 on npm), so the entry carries no
brandUrl at all. That must resolve to an EMPTY brandPackage — the loader then
shows a neutral wordmark rather than another brand's mark — while idBrandLabel
still reads `orgId` first and renders "Sign in to Bootnode ID".

Also pins the intended default explicitly: a genuinely unknown host still
resolves to hanzo. That is the behaviour we want to keep; the bug was that a
KNOWN brand host was reaching it.

9 tests pass.
2026-07-26 11:02:37 -07:00
zeekayandClaude Opus 5 6b12efdde5 ci: pull from GitHub fast-forward-only — the other half of the loop
The push-mirror carries native -> GitHub. This is the return leg, so the two
forges converge from either side.

They compose rather than fight: a native commit reaches GitHub via the mirror,
so this job then sees LOCAL == REMOTE and exits "in sync"; a GitHub commit
fast-forwards native here and the resulting mirror push is a no-op. No echo,
no loop.

Fast-forward ONLY -- a divergence fails loudly instead of force-pushing either
side and destroying whichever history lost the race. Same shape hanzoai/app has
run green 299 times.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:15:45 -07:00
zeekayandhanzo-dev 483b5e177c ci: pull from GitHub fast-forward-only — the other half of the loop
The push-mirror carries native -> GitHub. This is the return leg, so the two
forges converge from either side.

They compose rather than fight: a native commit reaches GitHub via the mirror,
so this job then sees LOCAL == REMOTE and exits "in sync"; a GitHub commit
fast-forwards native here and the resulting mirror push is a no-op. No echo,
no loop.

Fast-forward ONLY -- a divergence fails loudly instead of force-pushing either
side and destroying whichever history lost the race. Same shape hanzoai/app has
run green 299 times.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-26 09:15:45 -07:00
zeekayandClaude Opus 5 818a7d9079 build: do not build on docs-only commits
The previous commit touched only LLM.md and went RED: the version guard
correctly refused to rebuild an existing 0.2.11, but a docs edit cannot produce
a release, so it should never have started a build. A red run for a change that
could not possibly ship is how people learn to ignore red.

paths-ignore for markdown, workflow files and docs -- same shape hanzoai/app
uses. Real source changes still build; workflow_dispatch forces one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 08:14:25 -07:00
zeekayandhanzo-dev d378b6d14b build: do not build on docs-only commits
The previous commit touched only LLM.md and went RED: the version guard
correctly refused to rebuild an existing 0.2.11, but a docs edit cannot produce
a release, so it should never have started a build. A red run for a change that
could not possibly ship is how people learn to ignore red.

paths-ignore for markdown, workflow files and docs -- same shape hanzoai/app
uses. Real source changes still build; workflow_dispatch forces one.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-26 08:14:25 -07:00
zeekayandClaude Opus 5 ad3e6daf48 docs: record that truth flows git.hanzo.ai -> GitHub
build / build (push) Failing after 23s
This repo is canonical on git.hanzo.ai and GitHub is now a push-mirror of it
(sync_on_commit). It used to be the reverse, and as a pull-mirror it could run
no CI at all -- four commits shipped zero images with nothing reporting a fault.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 08:11:55 -07:00
zeekayandhanzo-dev c6d53f678c docs: record that truth flows git.hanzo.ai -> GitHub
This repo is canonical on git.hanzo.ai and GitHub is now a push-mirror of it
(sync_on_commit). It used to be the reverse, and as a pull-mirror it could run
no CI at all -- four commits shipped zero images with nothing reporting a fault.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-26 08:11:55 -07:00
zeekayandClaude Opus 5 1c1cd9a803 build: publish to oci.hanzo.ai as well as ghcr, and 0.2.11
build / build (push) Successful in 56s
oci.hanzo.ai is our own registry and where images belong. It is the same
registry.hanzo.ai backend under a second hostname, gated by IAM token auth
(realm https://iam.hanzo.ai/v1/iam/registry/token). Verified hanzo-registry can
obtain a repository:id:pull,push token before wiring this.

Publishing to BOTH rather than cutting over, deliberately: Kubernetes matches
imagePullSecrets by HOSTNAME, and the KMS-synced registry-credentials secret
carries an auths entry only for registry.hanzo.ai. A CR pointed at
oci.hanzo.ai today would ImagePullBackOff every replica. Adding that entry
changes a shared credential every registry client holds, so it is not a change
to make blind. With both published, the CR flip is one line the moment it lands.

Creds are ORG-level Actions secrets (OCI_USER / OCI_TOKEN) sourced from the
KMS-synced credential already in-cluster -- KMS stays the source of truth.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 17:58:02 -07:00
zeekayandhanzo-dev 8a556d0094 build: publish to oci.hanzo.ai as well as ghcr, and 0.2.11
oci.hanzo.ai is our own registry and where images belong. It is the same
registry.hanzo.ai backend under a second hostname, gated by IAM token auth
(realm https://iam.hanzo.ai/v1/iam/registry/token). Verified hanzo-registry can
obtain a repository:id:pull,push token before wiring this.

Publishing to BOTH rather than cutting over, deliberately: Kubernetes matches
imagePullSecrets by HOSTNAME, and the KMS-synced registry-credentials secret
carries an auths entry only for registry.hanzo.ai. A CR pointed at
oci.hanzo.ai today would ImagePullBackOff every replica. Adding that entry
changes a shared credential every registry client holds, so it is not a change
to make blind. With both published, the CR flip is one line the moment it lands.

Creds are ORG-level Actions secrets (OCI_USER / OCI_TOKEN) sourced from the
KMS-synced credential already in-cluster -- KMS stays the source of truth.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-25 17:58:02 -07:00
zeekayandClaude Opus 5 55c7d907de build: tag images semver from package.json, never a sha
build / build (push) Successful in 47s
Deployments pin semver. package.json is the single source of the version, so a
release IS a version bump: edit the version, push, and the image that appears is
named after it -- no separate tagging step to forget and no sha in a CR.

Refuses to rebuild a version that already exists, so a tag other environments
may be pinning is never silently overwritten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 17:42:55 -07:00
zeekayandhanzo-dev 41d2409117 build: tag images semver from package.json, never a sha
Deployments pin semver. package.json is the single source of the version, so a
release IS a version bump: edit the version, push, and the image that appears is
named after it -- no separate tagging step to forget and no sha in a CR.

Refuses to rebuild a version that already exists, so a tag other environments
may be pinning is never silently overwritten.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-25 17:42:55 -07:00
zeekayandClaude Opus 5 d05a53d3cb build: log in to ghcr before pushing
build / build (push) Successful in 52s
Every run died on 'https://ghcr.io/v2/: denied: denied'. The raw buildctl
invocation pushed with no registry login at all -- there was no auth step in the
job. Replaced with the same trio hanzoai/app and hanzoai/cloud use:
setup-buildx -> login-action (GH_PAT, write:packages, user hanzo-dev, an
ORG-level secret already inherited here) -> build-push-action.

Adds a manifest-inspect gate: build-push-action can exit 0 while the manifest is
not yet resolvable, and a CR bumped to a phantom tag becomes ImagePullBackOff.
Prove it pulls here instead.

Tag is sha-<full sha> so it is immutable and never floats.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 17:37:14 -07:00
zeekayandhanzo-dev 93b3524a42 build: log in to ghcr before pushing
Every run died on 'https://ghcr.io/v2/: denied: denied'. The raw buildctl
invocation pushed with no registry login at all -- there was no auth step in the
job. Replaced with the same trio hanzoai/app and hanzoai/cloud use:
setup-buildx -> login-action (GH_PAT, write:packages, user hanzo-dev, an
ORG-level secret already inherited here) -> build-push-action.

Adds a manifest-inspect gate: build-push-action can exit 0 while the manifest is
not yet resolvable, and a CR bumped to a phantom tag becomes ImagePullBackOff.
Prove it pulls here instead.

Tag is sha-<full sha> so it is immutable and never floats.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-25 17:37:14 -07:00
zeekayandClaude Opus 5 8aee66d029 build: target the runner pool that exists
build / build (push) Failing after 4s
runs-on was hanzo-linux-amd64. The fleet advertises hanzo-build-linux-amd64
(plus ubuntu-* aliases) and nothing else, so no runner could match and Gitea
created NO RUN AT ALL -- not a queued one, not a failed one. Four commits landed
on main between 2026-07-23 and today and produced zero images, with nothing
anywhere reporting a problem.

Same label hanzoai/app uses, where it has 299 green runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 17:35:29 -07:00
zeekayandhanzo-dev 2d4e759dfb build: target the runner pool that exists
runs-on was hanzo-linux-amd64. The fleet advertises hanzo-build-linux-amd64
(plus ubuntu-* aliases) and nothing else, so no runner could match and Gitea
created NO RUN AT ALL -- not a queued one, not a failed one. Four commits landed
on main between 2026-07-23 and today and produced zero images, with nothing
anywhere reporting a problem.

Same label hanzoai/app uses, where it has 299 green runs.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-25 17:35:29 -07:00
zeekayandClaude Opus 5 a7e917f348 build: separate build from deploy, and 0.2.10
build / build (push) Canceled after 0s
The pipeline ended with 'kubectl patch app id', but App/id is CD-governed --
id.yaml is listed in universe crs/kustomization.yaml and the live object carries
apps.hanzo.ai/tracking-id -- so that patch is reverted on the next sync (~90s)
to whatever git says. It could never stick, and it FAILED SILENTLY: the patch
applies, the pod rolls, and CD quietly restores the old tag a minute later.

Build and deploy are separate concerns. This emits an immutable image; git
declares desired state; CD applies it. Shipping a build is a tag bump in
universe crs/id.yaml -- the one way a tag reaches the cluster.

0.2.9 -> 0.2.10 carries the apex-login fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 17:32:52 -07:00
zeekayandhanzo-dev 6907c88383 build: separate build from deploy, and 0.2.10
The pipeline ended with 'kubectl patch app id', but App/id is CD-governed --
id.yaml is listed in universe crs/kustomization.yaml and the live object carries
apps.hanzo.ai/tracking-id -- so that patch is reverted on the next sync (~90s)
to whatever git says. It could never stick, and it FAILED SILENTLY: the patch
applies, the pod rolls, and CD quietly restores the old tag a minute later.

Build and deploy are separate concerns. This emits an immutable image; git
declares desired state; CD applies it. Shipping a build is a tag bump in
universe crs/id.yaml -- the one way a tag reaches the cluster.

0.2.9 -> 0.2.10 carries the apex-login fix.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-25 17:32:52 -07:00
zeekayandClaude Opus 5 bc890fd0d0 login: the apex form posts an organization — it was dead on every brand
deploy / deploy (push) Canceled after 0s
Typing a password at hanzo.id, lux.id, iam.hanzo.ai or pars.id did nothing. The
bare portal deliberately posted NO organization, and iam2 refuses that:

    HTTP 200  {"status":"error","msg":"organization, username and password are required"}

The 200 is why nobody caught it. The form renders that message as though the
USER's credentials were wrong, and every status-code monitor reads the response
as healthy. Four brand front doors, dead, green on the dashboard.

The omission was correct once. It let IAM resolve cross-org so a colliding
identity (z@hanzo.ai exists in both admin and hanzo) landed on admin/* with a
full multi-org session. iam2 removed that ON PURPOSE and calls the collision a
defect -- internal/registry/registry.go: "the F-2 bug where z@hanzo.ai collided
across admin and hanzo" -- because cross-org resolution coupled lockout counters
across rows and gave a brute-force oracle on the SuperAdmin. The server is not
regressed; this client was.

LoginForm now resolves the app's own org through get-app-login and posts it on
BOTH entry points, which is exactly what 0.2.2 already established for the
downstream-app path -- the apex path simply never got it. A global admin is no
longer reached by omission: they sign into an admin-org app (hanzo-admin-guard),
the explicit path 0.2.2/0.2.3 describe.

client.login() stays a pure passthrough -- it never invents an org. The two
tests pinning that are unchanged and still pass; only their now-dead rationale
is corrected, because a stale WHY is how this gets reverted. CLAUDE.md's 0.1.23
section is marked SUPERSEDED for the same reason: read alone, it argues for
putting the outage back.

130/130 green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 17:17:01 -07:00
zeekayandhanzo-dev 876be989fd login: the apex form posts an organization — it was dead on every brand
Typing a password at hanzo.id, lux.id, iam.hanzo.ai or pars.id did nothing. The
bare portal deliberately posted NO organization, and iam2 refuses that:

    HTTP 200  {"status":"error","msg":"organization, username and password are required"}

The 200 is why nobody caught it. The form renders that message as though the
USER's credentials were wrong, and every status-code monitor reads the response
as healthy. Four brand front doors, dead, green on the dashboard.

The omission was correct once. It let IAM resolve cross-org so a colliding
identity (z@hanzo.ai exists in both admin and hanzo) landed on admin/* with a
full multi-org session. iam2 removed that ON PURPOSE and calls the collision a
defect -- internal/registry/registry.go: "the F-2 bug where z@hanzo.ai collided
across admin and hanzo" -- because cross-org resolution coupled lockout counters
across rows and gave a brute-force oracle on the SuperAdmin. The server is not
regressed; this client was.

LoginForm now resolves the app's own org through get-app-login and posts it on
BOTH entry points, which is exactly what 0.2.2 already established for the
downstream-app path -- the apex path simply never got it. A global admin is no
longer reached by omission: they sign into an admin-org app (hanzo-admin-guard),
the explicit path 0.2.2/0.2.3 describe.

client.login() stays a pure passthrough -- it never invents an org. The two
tests pinning that are unchanged and still pass; only their now-dead rationale
is corrected, because a stale WHY is how this gets reverted. CLAUDE.md's 0.1.23
section is marked SUPERSEDED for the same reason: read alone, it argues for
putting the outage back.

130/130 green.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-25 17:17:01 -07:00
z 8d2ad1891a chore(spa): pin the ONE canonical hanzoai/spa 1.4.8
hanzoai/spa is the SPA-friendly server (index.html/.html fallthrough + a CSP
that does not blank a client-routed app); hanzoai/static is the strict one.
Consumers had drifted to two untagged pins (implicit :latest — banned, and
non-reproducible) and three at 1.2.0, thirteen patches behind. One image,
one version. Published spelling is 1.4.8 (v1.4.8 does not exist).
2026-07-25 10:46:58 -07:00
z 1acb94ac65 chore(spa): pin the ONE canonical hanzoai/spa 1.4.8
hanzoai/spa is the SPA-friendly server (index.html/.html fallthrough + a CSP
that does not blank a client-routed app); hanzoai/static is the strict one.
Consumers had drifted to two untagged pins (implicit :latest — banned, and
non-reproducible) and three at 1.2.0, thirteen patches behind. One image,
one version. Published spelling is 1.4.8 (v1.4.8 does not exist).
2026-07-25 10:46:58 -07:00
hanzo-dev 2cd1979cb0 ci(docker): neutralize to sync-notice — native build is .hanzo/workflows/deploy.yml 2026-07-24 15:15:38 -07:00
hanzo-dev 08a19b8dcd ci(docker): neutralize to sync-notice — native build is .hanzo/workflows/deploy.yml 2026-07-24 15:15:38 -07:00
hanzo-dev 7bbfb77cd7 ci(deploy): native Hanzo pipeline — BuildKit → ghcr.io/hanzoai/id → operator reconcile 2026-07-24 15:15:17 -07:00
hanzo-dev ffe5d2b9b2 ci(deploy): native Hanzo pipeline — BuildKit → ghcr.io/hanzoai/id → operator reconcile 2026-07-24 15:15:17 -07:00
Hanzo AI 4226ef57a7 chore(release): 0.2.9 — one chain-agnostic Connect Wallet button 2026-07-23 01:38:36 -07:00
Hanzo AI e74c3ffdae chore(release): 0.2.9 — one chain-agnostic Connect Wallet button 2026-07-23 01:38:36 -07:00
Hanzo AI 403ff90b10 feat(id/auth): merge EVM + Solana wallet buttons into one chain-agnostic Connect Wallet
SocialButtons rendered one wallet button per ENABLED chain (Continue with Ethereum / EVM + Continue with Solana). Merge the ENTRY into a single monochrome 'Connect Wallet' button that keeps BOTH flows.

- web3.ts: add detectWalletChains() — a pure window sniff (EVM=window.ethereum, Solana=window.solana/solflare/backpack) derived from ENABLED_WALLET_CHAINS; injectable window, no DOM/connect/I-O.
- SocialButtons.tsx: one Connect Wallet button (data-wallet-connect). onConnectWallet -> detect: exactly one injected chain connects straight; zero or many reveals an inline chooser (.hanzo-id-wallet-chains, data-chain=evm|solana) so either chain stays reachable. Per-chain startWallet -> loginWithWalletChain unchanged. Sits beside the GitLab provider (0.2.8).
- app.css: indented monochrome chooser matching the GitHub/GitLab/Google buttons.
- 3 new detectWalletChains unit tests (130 vitest pass). Frontend-only; IAM/casdoor untouched.
2026-07-23 01:38:36 -07:00
Hanzo AI 09ae6190b6 feat(id/auth): merge EVM + Solana wallet buttons into one chain-agnostic Connect Wallet
SocialButtons rendered one wallet button per ENABLED chain (Continue with Ethereum / EVM + Continue with Solana). Merge the ENTRY into a single monochrome 'Connect Wallet' button that keeps BOTH flows.

- web3.ts: add detectWalletChains() — a pure window sniff (EVM=window.ethereum, Solana=window.solana/solflare/backpack) derived from ENABLED_WALLET_CHAINS; injectable window, no DOM/connect/I-O.
- SocialButtons.tsx: one Connect Wallet button (data-wallet-connect). onConnectWallet -> detect: exactly one injected chain connects straight; zero or many reveals an inline chooser (.hanzo-id-wallet-chains, data-chain=evm|solana) so either chain stays reachable. Per-chain startWallet -> loginWithWalletChain unchanged. Sits beside the GitLab provider (0.2.8).
- app.css: indented monochrome chooser matching the GitHub/GitLab/Google buttons.
- 3 new detectWalletChains unit tests (130 vitest pass). Frontend-only; IAM/casdoor untouched.
2026-07-23 01:38:36 -07:00
hanzo-dev 33cc55e837 merge(feat/brandcontract-from-hanzo-brand): consolidate onto main 2026-07-21 17:18:35 -07:00
hanzo-dev dfe7eff66d merge(feat/brandcontract-from-hanzo-brand): consolidate onto main 2026-07-21 17:18:35 -07:00
hanzo-dev cc7885c680 refactor(brand): re-export BrandContract from @hanzo/brand
id-shared's BrandContract already documented that it 'matches the consumer
contract in @hanzo/brand' — now it IS that contract, re-exported, not a second
copy that can drift. @hanzo/brand is the canonical home (its toBrandContract
projects the registry onto exactly this shape). Byte-identical fields, so the
8 auth-page consumers (Login/Signup/Callback/DeviceApproval/Portal/Onboarding/
Forgot/BrandHeader) see the same type — zero behavior change.

Verified: id-shared tc (tsc --noEmit) + 7 tenant tests green with @hanzo/brand
linked. App-level build gates on the id workspace install + @hanzo/brand
publish (deploy-gate).
2026-07-21 09:41:03 -07:00
hanzo-dev ec17532fb1 refactor(brand): re-export BrandContract from @hanzo/brand
id-shared's BrandContract already documented that it 'matches the consumer
contract in @hanzo/brand' — now it IS that contract, re-exported, not a second
copy that can drift. @hanzo/brand is the canonical home (its toBrandContract
projects the registry onto exactly this shape). Byte-identical fields, so the
8 auth-page consumers (Login/Signup/Callback/DeviceApproval/Portal/Onboarding/
Forgot/BrandHeader) see the same type — zero behavior change.

Verified: id-shared tc (tsc --noEmit) + 7 tenant tests green with @hanzo/brand
linked. App-level build gates on the id workspace install + @hanzo/brand
publish (deploy-gate).
2026-07-21 09:41:03 -07:00
zeekayandClaude Opus 4.8 f2596c4788 test(id): unify on vitest — one runner, 12/12 files, 127 tests green (0.2.8)
The connect crypto/connector suites (bitcoin, ton, xrp, caip122, verify,
connectors — 6 files, 71 tests) imported `vitest` but vitest was never
installed and connect had no `test` script: they had never run. The auth /
shared / onboarding suites ran green under three separate per-package
`node --test --experimental-strip-types` scripts — a divergent second runner.

Decomplect to ONE runner:
- add vitest 3.2.7 (built for the repo's vite 7) as the sole test dep + root
  `vitest.config.ts` (include pkgs/**/src/**/*.test.ts, node env)
- root `test` = `vitest run`; drop the three per-package node:test scripts
- swap `import { test } from 'node:test'` -> `'vitest'` in the 6 node:test
  files; node:assert/strict assertions kept verbatim (real assertions, no
  rewrites)
- add pkgs/connect/tsconfig.json (matching siblings) so `pnpm tc` is green —
  connect had no tsconfig, so its typecheck errored out entirely

Result: `pnpm test` 12 files / 127 tests pass; `pnpm tc` green across all 6
projects.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 19:53:40 -07:00
zeekayandhanzo-dev aa10455c43 test(id): unify on vitest — one runner, 12/12 files, 127 tests green (0.2.8)
The connect crypto/connector suites (bitcoin, ton, xrp, caip122, verify,
connectors — 6 files, 71 tests) imported `vitest` but vitest was never
installed and connect had no `test` script: they had never run. The auth /
shared / onboarding suites ran green under three separate per-package
`node --test --experimental-strip-types` scripts — a divergent second runner.

Decomplect to ONE runner:
- add vitest 3.2.7 (built for the repo's vite 7) as the sole test dep + root
  `vitest.config.ts` (include pkgs/**/src/**/*.test.ts, node env)
- root `test` = `vitest run`; drop the three per-package node:test scripts
- swap `import { test } from 'node:test'` -> `'vitest'` in the 6 node:test
  files; node:assert/strict assertions kept verbatim (real assertions, no
  rewrites)
- add pkgs/connect/tsconfig.json (matching siblings) so `pnpm tc` is green —
  connect had no tsconfig, so its typecheck errored out entirely

Result: `pnpm test` 12 files / 127 tests pass; `pnpm tc` green across all 6
projects.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-19 19:53:40 -07:00
zeekayandClaude 2b3f9330e7 fix(id): center auth card — pin .hanzo-id-page min-height:100vh so main can vertically center
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-19 19:23:47 -07:00
zeekayandhanzo-dev 45ffd21a85 fix(id): center auth card — pin .hanzo-id-page min-height:100vh so main can vertically center
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-19 19:23:47 -07:00
zeekayandClaude 9bfbb8add2 fix(id): neutral "<Brand> ID" heading + title, WCAG-3:1 input border, centered auth card
- Login <h1> + document.title now use idBrandLabel(brand, orgId) → "Lux ID" /
  "Zoo ID" / "Hanzo ID" instead of the brand package's product name
  ("Lux Exchange" / "Zoo Exchange") leaking into the IAM portal heading + tab
  title. New helper in @hanzo/id-shared strips a trailing product word and
  appends " ID", preferring the tenant orgId.
- --border #262626 → #71717a: 1.25:1 → ~3.9:1 against the #111 input fill,
  clearing the WCAG 3:1 non-text-contrast threshold (inputs, social buttons,
  dividers, org rows).
- .hanzo-id-page main: justify-content center → the auth card is vertically
  centered instead of top-anchored (~15% → centered).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-19 19:10:55 -07:00
zeekayandhanzo-dev 6fb61a4817 fix(id): neutral "<Brand> ID" heading + title, WCAG-3:1 input border, centered auth card
- Login <h1> + document.title now use idBrandLabel(brand, orgId) → "Lux ID" /
  "Zoo ID" / "Hanzo ID" instead of the brand package's product name
  ("Lux Exchange" / "Zoo Exchange") leaking into the IAM portal heading + tab
  title. New helper in @hanzo/id-shared strips a trailing product word and
  appends " ID", preferring the tenant orgId.
- --border #262626 → #71717a: 1.25:1 → ~3.9:1 against the #111 input fill,
  clearing the WCAG 3:1 non-text-contrast threshold (inputs, social buttons,
  dividers, org rows).
- .hanzo-id-page main: justify-content center → the auth card is vertically
  centered instead of top-anchored (~15% → centered).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-19 19:10:55 -07:00
hanzo-dev da19c5f208 feat(id/social): render GitLab as a first-class sign-in provider (0.2.7)
hanzo.id showed only GitHub/Google/wallet because the portal gated the
dynamic get-app-login set on a hardcoded renderable-provider allowlist that
omitted GitLab, and the social hop had no GitLab authorize endpoint — so the
configured provider-gitlab (real clientId in IAM) never appeared.

- social.ts AUTH_INFO: GitLab -> https://gitlab.com/oauth/authorize (read_user).
- SocialButtons: gitlab in PROVIDER_META + ORDER (+ GitLabIcon). It still
  renders ONLY when get-app-login says the app has it configured + canSignIn —
  dynamic, one source of truth; the allowlist is just per-type presentation.
- Tests: GitLab hop URL + isHoppableProvider(GitLab) locked in social.test.ts.

Verified: 9/9 auth tests pass; built bundle contains GitLab. Same provider-gitlab
backs the cloud /v1/integrations/gitlab connector (one app, KMS hanzo/prod/gitlab-oauth).
2026-07-15 10:17:41 -07:00
hanzo-dev 7f609193ff feat(id/social): render GitLab as a first-class sign-in provider (0.2.7)
hanzo.id showed only GitHub/Google/wallet because the portal gated the
dynamic get-app-login set on a hardcoded renderable-provider allowlist that
omitted GitLab, and the social hop had no GitLab authorize endpoint — so the
configured provider-gitlab (real clientId in IAM) never appeared.

- social.ts AUTH_INFO: GitLab -> https://gitlab.com/oauth/authorize (read_user).
- SocialButtons: gitlab in PROVIDER_META + ORDER (+ GitLabIcon). It still
  renders ONLY when get-app-login says the app has it configured + canSignIn —
  dynamic, one source of truth; the allowlist is just per-type presentation.
- Tests: GitLab hop URL + isHoppableProvider(GitLab) locked in social.test.ts.

Verified: 9/9 auth tests pass; built bundle contains GitLab. Same provider-gitlab
backs the cloud /v1/integrations/gitlab connector (one app, KMS hanzo/prod/gitlab-oauth).
2026-07-15 10:17:41 -07:00
Hanzo AI a87b7500e5 docs(id): document provider_hint auto-federation (0.2.6) [skip ci] 2026-07-14 06:30:45 -07:00
Hanzo AI bf734cf3cf docs(id): document provider_hint auto-federation (0.2.6) [skip ci] 2026-07-14 06:30:45 -07:00
Hanzo AI 23d0c0e7a8 chore(release): 0.2.6 — provider_hint social login auto-federation 2026-07-14 06:18:41 -07:00
Hanzo AI 5ace3df75c chore(release): 0.2.6 — provider_hint social login auto-federation 2026-07-14 06:18:41 -07:00
Hanzo AI 436931bb53 feat(id/social): honor provider_hint so console social login lands straight in the provider
Clicking "Continue with GitHub/Google" on console.hanzo.ai now goes directly
into the provider OAuth flow instead of bouncing the user to the hanzo.id login
form. Three fixes, all reusing the EXISTING hop (no duplicated IdP config):

- Login.tsx: a `federate` phase honors `?provider_hint=provider-github` (the
  value the console already sends). After a silent-SSO miss it auto-launches the
  hinted provider via a headless SocialButtons instead of showing the form,
  falling back to the form only when the hint matches no configured provider.
- SocialButtons: `autoStart` runs the SAME startProviderLogin hop the button
  runs, matched by a new pure `matchProviderHint` (accepts provider-github /
  github, case-insensitive). getAppLogin now validates against the DOWNSTREAM
  app's own redirect_uri (read from the query), not the portal /callback — the
  latter is not registered for a cross-app clientId (hanzo-cloud), so IAM
  rejected the read (status:error) and no providers resolved.
- hop uses method=signup (IAM find-or-create-login) for interactive social
  login; method=signin is the account-LINK branch and errors "user doesn't
  exist" on a fresh sign-in with no session.

Contracts locked: matchProviderHint + getAppLogin redirect_uri. 39 tests green,
tsc + vite build clean.
2026-07-14 06:18:41 -07:00
Hanzo AI aee84762b9 feat(id/social): honor provider_hint so console social login lands straight in the provider
Clicking "Continue with GitHub/Google" on console.hanzo.ai now goes directly
into the provider OAuth flow instead of bouncing the user to the hanzo.id login
form. Three fixes, all reusing the EXISTING hop (no duplicated IdP config):

- Login.tsx: a `federate` phase honors `?provider_hint=provider-github` (the
  value the console already sends). After a silent-SSO miss it auto-launches the
  hinted provider via a headless SocialButtons instead of showing the form,
  falling back to the form only when the hint matches no configured provider.
- SocialButtons: `autoStart` runs the SAME startProviderLogin hop the button
  runs, matched by a new pure `matchProviderHint` (accepts provider-github /
  github, case-insensitive). getAppLogin now validates against the DOWNSTREAM
  app's own redirect_uri (read from the query), not the portal /callback — the
  latter is not registered for a cross-app clientId (hanzo-cloud), so IAM
  rejected the read (status:error) and no providers resolved.
- hop uses method=signup (IAM find-or-create-login) for interactive social
  login; method=signin is the account-LINK branch and errors "user doesn't
  exist" on a fresh sign-in with no session.

Contracts locked: matchProviderHint + getAppLogin redirect_uri. 39 tests green,
tsc + vite build clean.
2026-07-14 06:18:41 -07:00
hanzo-dev 2ceecf49a5 chore(release): 0.2.5 — forced TOTP MFA enforcement (RequiredMfa/NextMfa)
Docker / docker (push) Failing after 11s
Ships the portal-side forced-MFA fix: parseLoginResponse now branches on
IAM's string data signal (RequiredMfa/NextMfa) instead of the never-set
mfa_required boolean, so org-forced 2FA can no longer be silently bypassed
into /onboarding.

Claude-Session: https://claude.ai/code/session_01XsNmNwzHUSKXfN7gYruUsM
2026-07-08 07:02:33 -07:00
hanzo-dev e9d3ae9d07 chore(release): 0.2.5 — forced TOTP MFA enforcement (RequiredMfa/NextMfa)
Ships the portal-side forced-MFA fix: parseLoginResponse now branches on
IAM's string data signal (RequiredMfa/NextMfa) instead of the never-set
mfa_required boolean, so org-forced 2FA can no longer be silently bypassed
into /onboarding.
2026-07-08 07:02:33 -07:00
zeekayandhanzo-dev f5d2f13499 feat(auth): enforce forced TOTP MFA in the portal (RequiredMfa / NextMfa)
IAM signals MFA with a STRING in the login response `data`
("RequiredMfa" = org forces MFA, user not enrolled; "NextMfa" = user has
MFA, needs a challenge) — never the `mfa_required` boolean the portal was
checking. So every first login fell through to /onboarding, silently
bypassing 2FA.

parseLoginResponse now branches on `data` BEFORE the /onboarding return:
  - RequiredMfa -> { mfaRequired, mfaStage: 'enroll' }
  - NextMfa     -> { mfaRequired, mfaStage: 'challenge', mfaTypes }

New AuthClient methods drive the flow against the canonical /v1/iam surface:
  getAccount, mfaInitiate, mfaVerify, mfaEnable, mfaChallenge.

Wire contract (verified live vs iam.hanzo.ai): the /v1/iam/mfa/setup/*
calls put EVERY param on the query string with an EMPTY body — the only
shape that satisfies both IAM's authz self-match (objOwner/objName are
read from the query only when the body is empty; a urlencoded body fails
the JSON-unmarshal and yields "Unauthorized operation") and the MFA
controller. owner/name ride the query on every call, incl. verify.

UI: MfaEnrollForm renders forced TOTP enrollment — QR from the otpauth://
URI via @paulmillr/qr (secret never leaves the browser), manual-key
fallback, recovery code, and the existing OTPForm for code entry. No skip
control. Login.tsx routes onMfaRequired by stage; the existing OTPForm is
reused for the NextMfa challenge.

Tests: 8 new MFA wire-contract tests (pure, mocked fetch). 14/14 green.
(cherry picked from commit 3df982b9fc)
2026-07-08 07:01:57 -07:00
zeekayandhanzo-dev 09239ed16c feat(auth): enforce forced TOTP MFA in the portal (RequiredMfa / NextMfa)
IAM signals MFA with a STRING in the login response `data`
("RequiredMfa" = org forces MFA, user not enrolled; "NextMfa" = user has
MFA, needs a challenge) — never the `mfa_required` boolean the portal was
checking. So every first login fell through to /onboarding, silently
bypassing 2FA.

parseLoginResponse now branches on `data` BEFORE the /onboarding return:
  - RequiredMfa -> { mfaRequired, mfaStage: 'enroll' }
  - NextMfa     -> { mfaRequired, mfaStage: 'challenge', mfaTypes }

New AuthClient methods drive the flow against the canonical /v1/iam surface:
  getAccount, mfaInitiate, mfaVerify, mfaEnable, mfaChallenge.

Wire contract (verified live vs iam.hanzo.ai): the /v1/iam/mfa/setup/*
calls put EVERY param on the query string with an EMPTY body — the only
shape that satisfies both IAM's authz self-match (objOwner/objName are
read from the query only when the body is empty; a urlencoded body fails
the JSON-unmarshal and yields "Unauthorized operation") and the MFA
controller. owner/name ride the query on every call, incl. verify.

UI: MfaEnrollForm renders forced TOTP enrollment — QR from the otpauth://
URI via @paulmillr/qr (secret never leaves the browser), manual-key
fallback, recovery code, and the existing OTPForm for code entry. No skip
control. Login.tsx routes onMfaRequired by stage; the existing OTPForm is
reused for the NextMfa challenge.

Tests: 8 new MFA wire-contract tests (pure, mocked fetch). 14/14 green.
(cherry picked from commit 115408a9d9)
2026-07-08 07:01:57 -07:00
zeekayandClaude Opus 4.8 f8640af7bc fix(id/login): >=44px touch targets on sign-in + social buttons (cosmetic)
Sign-in button and the social/Web3 buttons rendered at 42px on mobile,
2px under the 44px minimum touch target. Bump sign-in vertical padding to
13px (44px) and add min-height:44px to the flex-centered social buttons.
CSS-only; no auth/OAuth logic touched. Branched off the deployed 0.2.3
(origin/main) so nothing but this cosmetic change ships. Version -> 0.2.4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 07:29:01 -07:00
zeekayandhanzo-dev b914f74144 fix(id/login): >=44px touch targets on sign-in + social buttons (cosmetic)
Sign-in button and the social/Web3 buttons rendered at 42px on mobile,
2px under the 44px minimum touch target. Bump sign-in vertical padding to
13px (44px) and add min-height:44px to the flex-centered social buttons.
CSS-only; no auth/OAuth logic touched. Branched off the deployed 0.2.3
(origin/main) so nothing but this cosmetic change ships. Version -> 0.2.4.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-05 07:29:01 -07:00
zandClaude Opus 4.8 f4e7c0d145 fix(auth): org-scope silent SSO so admin operators reach god-mode
admin.hanzo.ai is gated by admin-guard (ForwardAuth: owner==admin only).
Login rides client_id=hanzo-admin-guard (org=admin) and the 0.2.2 form fix
correctly posts organization=admin -> IAM resolves admin/z (owner=admin).
But Login.tsx's silentLogin SSO fast-path minted a code from the ambient
iam_session_id session regardless of its org; operators carry a hanzo/*
session, so it minted owner=hanzo and the guard bounced them to console —
the org-scoped form was never shown. Silent SSO shadowed the fix.

silentLogin now resolves the app org (get-app-login) + session owner
(get-account) and mints ONLY when they match; on no session or an org
mismatch it returns {} so Login.tsx falls back to the interactive form,
which authenticates in the app's own org. Same-org SSO (hanzo->hanzo)
still mints silently — no UX change. Cross-org (hanzo->admin-guard) now
falls to the form -> org=admin -> owner=admin -> god-mode.

Guard still validates owner==admin server-side, so this is availability
(admins get IN), not a privilege boundary — a non-admin can never reach
god-mode. IAM (v1.31.14) unchanged; SSO-ATO exact-match fix untouched.

Tests: 4 silentLogin cases (same-org mint, cross-org no-mint form-fallback,
same admin-org mint, no-session no-mint) + fix a stale providerLogin
redirect-url expectation. tc + 29 tests green; web build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 14:30:17 -07:00
zandhanzo-dev 55d7d3f411 fix(auth): org-scope silent SSO so admin operators reach god-mode
admin.hanzo.ai is gated by admin-guard (ForwardAuth: owner==admin only).
Login rides client_id=hanzo-admin-guard (org=admin) and the 0.2.2 form fix
correctly posts organization=admin -> IAM resolves admin/z (owner=admin).
But Login.tsx's silentLogin SSO fast-path minted a code from the ambient
iam_session_id session regardless of its org; operators carry a hanzo/*
session, so it minted owner=hanzo and the guard bounced them to console —
the org-scoped form was never shown. Silent SSO shadowed the fix.

silentLogin now resolves the app org (get-app-login) + session owner
(get-account) and mints ONLY when they match; on no session or an org
mismatch it returns {} so Login.tsx falls back to the interactive form,
which authenticates in the app's own org. Same-org SSO (hanzo->hanzo)
still mints silently — no UX change. Cross-org (hanzo->admin-guard) now
falls to the form -> org=admin -> owner=admin -> god-mode.

Guard still validates owner==admin server-side, so this is availability
(admins get IN), not a privilege boundary — a non-admin can never reach
god-mode. IAM (v1.31.14) unchanged; SSO-ATO exact-match fix untouched.

Tests: 4 silentLogin cases (same-org mint, cross-org no-mint form-fallback,
same admin-org mint, no-session no-mint) + fix a stale providerLogin
redirect-url expectation. tc + 29 tests green; web build green.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 14:30:17 -07:00
zeekayandClaude Opus 4.8 0a97192506 chore(release): 0.2.2 — admin-org login resolution fix
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 12:49:47 -07:00
zeekayandhanzo-dev 9cf009a8ac chore(release): 0.2.2 — admin-org login resolution fix
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 12:49:47 -07:00
zeekayandClaude Opus 4.8 d928ecdfe3 fix(auth): resolve login org from the app being signed into, not the brand
A downstream app that initiates login passes its own client_id
(props.clientIdOverride). LoginForm ignored it for org/app resolution and
always pinned application=tenant.appName + organization=tenant.loginOrg, so
EVERY login authenticated inside the brand portal's own org (hanzo). The
admin-guard (client_id=hanzo-admin-guard) lives in the `admin` org: its
operators resolved to hanzo/* (owner=hanzo) instead of admin/* (owner=admin),
so the admin.hanzo.ai forward-auth gate (predicate owner==admin) could never
be satisfied — god-mode was unreachable.

Fix: when a client_id override is present, resolve {application, organization}
from that app's get-app-login (the canonical clientId -> app/org map) and post
BOTH, so IAM scopes the credential check to the app's org. The bare brand-portal
sign-in (no override) stays org-agnostic (loginOrg unset) so a global admin
still resolves cross-org into the full multi-org session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 12:42:40 -07:00
zeekayandhanzo-dev 47e477d599 fix(auth): resolve login org from the app being signed into, not the brand
A downstream app that initiates login passes its own client_id
(props.clientIdOverride). LoginForm ignored it for org/app resolution and
always pinned application=tenant.appName + organization=tenant.loginOrg, so
EVERY login authenticated inside the brand portal's own org (hanzo). The
admin-guard (client_id=hanzo-admin-guard) lives in the `admin` org: its
operators resolved to hanzo/* (owner=hanzo) instead of admin/* (owner=admin),
so the admin.hanzo.ai forward-auth gate (predicate owner==admin) could never
be satisfied — god-mode was unreachable.

Fix: when a client_id override is present, resolve {application, organization}
from that app's get-app-login (the canonical clientId -> app/org map) and post
BOTH, so IAM scopes the credential check to the app's org. The bare brand-portal
sign-in (no override) stays org-agnostic (loginOrg unset) so a global admin
still resolves cross-org into the full multi-org session.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 12:42:40 -07:00
87d2da9c74 Debrand: replace Casdoor name with Hanzo IAM in comments/docs/aliases (#19)
Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-06-30 14:57:54 -07:00
9517f73345 Debrand: replace Casdoor name with Hanzo IAM in comments/docs/aliases (#19)
Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-06-30 14:57:54 -07:00
z 218528569e docs(brand): add hero banner 2026-06-28 20:06:13 -07:00
z 4cc44563b8 docs(brand): add hero banner 2026-06-28 20:06:13 -07:00
z 6df3f160b8 chore(brand): dynamic hero banner 2026-06-28 20:06:11 -07:00
z 373e00516e chore(brand): dynamic hero banner 2026-06-28 20:06:11 -07:00
648cbe8e65 feat: RFC 8628 device-authorization approval page (#16)
Docker / docker (push) Failing after 13s
* feat(web): RFC 8628 device-authorization approval page

Add the /login/oauth/device approval page — the final gap in device login
(dev login --device-auth) on hanzo.id/lux.id/zoolabs.id.

- pkgs/auth: AuthClient.approveDevice(userCode) POSTs /v1/iam/login
  {type:device, userCode, application, organization} over the issuer session
  cookie (no credentials in body), mapping the consent-required and error
  branches. userCode normalized to IAM's [0-9a-z] alphabet.
- LoginForm: optional onAuthenticated to keep the caller on-page after sign-in.
- apps/web: DeviceApproval page — sign-in (reused) then anti-phishing confirm
  of app + code, success/error states. White-label via tenant. URL scrubbed of
  tokens. Route wired in App.tsx before the /login catch.
- Tests: 5 approveDevice cases (posting/normalization/empty/error/consent).

* fix(device): require explicit human affirmation before approval (H2)

H2 (HIGH): the ?user_code= prefill is now DISPLAY-ONLY. A signed-in victim
arriving from a crafted verification_uri_complete link can no longer one-click
approve an attacker's device — the Approve button stays disabled until the user
ticks an explicit checkbox affirming the code matches the one their OWN device
shows. Anti-phishing copy retained.

Aligns the client with the iam M1 change: user_codes are now UPPERCASE over an
unambiguous alphabet, so normalizeUserCode uppercases (was lowercase) for an
exact-match send, the code field renders uppercase, and the placeholder/tests
use a realistic code. Adds the .hanzo-id-device-confirm style.

tc + build green; device client tests green.

---------

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-06-28 08:12:34 -07:00
0c190b08e3 feat: RFC 8628 device-authorization approval page (#16)
* feat(web): RFC 8628 device-authorization approval page

Add the /login/oauth/device approval page — the final gap in device login
(dev login --device-auth) on hanzo.id/lux.id/zoolabs.id.

- pkgs/auth: AuthClient.approveDevice(userCode) POSTs /v1/iam/login
  {type:device, userCode, application, organization} over the issuer session
  cookie (no credentials in body), mapping the consent-required and error
  branches. userCode normalized to IAM's [0-9a-z] alphabet.
- LoginForm: optional onAuthenticated to keep the caller on-page after sign-in.
- apps/web: DeviceApproval page — sign-in (reused) then anti-phishing confirm
  of app + code, success/error states. White-label via tenant. URL scrubbed of
  tokens. Route wired in App.tsx before the /login catch.
- Tests: 5 approveDevice cases (posting/normalization/empty/error/consent).

* fix(device): require explicit human affirmation before approval (H2)

H2 (HIGH): the ?user_code= prefill is now DISPLAY-ONLY. A signed-in victim
arriving from a crafted verification_uri_complete link can no longer one-click
approve an attacker's device — the Approve button stays disabled until the user
ticks an explicit checkbox affirming the code matches the one their OWN device
shows. Anti-phishing copy retained.

Aligns the client with the iam M1 change: user_codes are now UPPERCASE over an
unambiguous alphabet, so normalizeUserCode uppercases (was lowercase) for an
exact-match send, the code field renders uppercase, and the placeholder/tests
use a realistic code. Adds the .hanzo-id-device-confirm style.

tc + build green; device client tests green.

---------

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-06-28 08:12:34 -07:00
Hanzo AI db509e068a fix(social): URL-safe base64 state + method=signup — fixes 'code_verifier does not match code_challenge' on Google login
Two root causes of broken social login across apps (billing, console2, console):
1. state was standard btoa() (emits +,/,=). The provider reflects state on a URL
   query; URLSearchParams turns '+' into space → atob corrupts the encoded OIDC
   request INCLUDING code_challenge → app's token exchange fails invalid_grant
   'code_verifier does not match code_challenge'. Fix: URL-safe base64
   (encodeState/decodeState), byte-exact round-trip.
2. method defaulted to 'signin' (account-LINK branch, needs existing session →
   400 on a fresh 'Continue with Google'). Canonical Casdoor default is 'signup'
   (find-or-create-LOGIN). Fixed.
Completes the lane that was interrupted by the session limit.
2026-06-26 19:22:37 -07:00
Hanzo AI cdb83c111d fix(social): URL-safe base64 state + method=signup — fixes 'code_verifier does not match code_challenge' on Google login
Two root causes of broken social login across apps (billing, console2, console):
1. state was standard btoa() (emits +,/,=). The provider reflects state on a URL
   query; URLSearchParams turns '+' into space → atob corrupts the encoded OIDC
   request INCLUDING code_challenge → app's token exchange fails invalid_grant
   'code_verifier does not match code_challenge'. Fix: URL-safe base64
   (encodeState/decodeState), byte-exact round-trip.
2. method defaulted to 'signin' (account-LINK branch, needs existing session →
   400 on a fresh 'Continue with Google'). Canonical Casdoor default is 'signup'
   (find-or-create-LOGIN). Fixed.
Completes the lane that was interrupted by the session limit.
2026-06-26 19:22:37 -07:00
Hanzo AI b865303473 Standardize favicon to monochrome Hanzo H (transparent bg) 2026-06-26 14:33:05 -07:00
Hanzo AI 33b46504b3 Standardize favicon to monochrome Hanzo H (transparent bg) 2026-06-26 14:33:05 -07:00
Hanzo AI f483ee6791 chore(iam): unify @hanzo/iam to ^0.13.1 (localStorage PKCE login fix) 2026-06-26 12:59:40 -07:00
Hanzo AI f807b6a11c chore(iam): unify @hanzo/iam to ^0.13.1 (localStorage PKCE login fix) 2026-06-26 12:59:40 -07:00
Hanzo AI 7757aa1c74 docs(sso): document the silent single sign-on mechanism (0.1.26) 2026-06-25 18:02:51 -07:00
Hanzo AI 7ce73662b2 docs(sso): document the silent single sign-on mechanism (0.1.26) 2026-06-25 18:02:51 -07:00
Hanzo AI f0b9750325 feat(sso): silent single sign-on — auto-continue authorize from existing issuer session
Login.tsx now attempts a credential-less silentLogin when an app sends the user
to the authorize page (client_id + redirect_uri present). IAM's Login handler
mints an auth code from the existing iam_session_id cookie (its already-signed-in
branch), so the 2nd/3rd app logs in seamlessly with no form. Falls back to the
interactive form when there is no live session. Backend already supports this;
this wires the SPA leg. Contract locked in client.test.ts (no creds posted,
redirect built from minted code; { error } -> form fallback).
2026-06-25 17:55:40 -07:00
Hanzo AI 583e406fa6 feat(sso): silent single sign-on — auto-continue authorize from existing issuer session
Login.tsx now attempts a credential-less silentLogin when an app sends the user
to the authorize page (client_id + redirect_uri present). IAM's Login handler
mints an auth code from the existing iam_session_id cookie (its already-signed-in
branch), so the 2nd/3rd app logs in seamlessly with no form. Falls back to the
interactive form when there is no live session. Backend already supports this;
this wires the SPA leg. Contract locked in client.test.ts (no creds posted,
redirect built from minted code; { error } -> form fallback).
2026-06-25 17:55:40 -07:00
Hanzo AI 064ea5666b social login: dedup provider= in state (RC#1), match exchange redirect_uri to hop (RC#2 hardening)
- buildProviderAuthUrl strips any pre-existing provider= before appending the
  real social provider, so the base64 state carries exactly ONE provider=.
  Callback.tsx reads URLSearchParams.get (FIRST match) — two providers made it
  post the upstream hint (hanzo-iam) instead of provider-google.
- providerLogin redirect_uri now derives from tenant.oauthCallbackOrigin (the
  same source the hop uses), never publicOrigin — IAM forwards it verbatim to
  the provider token endpoint; a mismatch is invalid_grant.
- tests: social.test.ts dedup lock + client.test.ts redirect_uri lock (20 pass).
- RC#2 backend proven live: junk-code probe returns invalid_grant not
  invalid_client → Google clientId/secret + iam.hanzo.ai/callback all valid.
- bump id 0.1.25, @hanzo/id-auth 0.1.2.
2026-06-25 16:04:26 -07:00
Hanzo AI 87ecfef2a2 social login: dedup provider= in state (RC#1), match exchange redirect_uri to hop (RC#2 hardening)
- buildProviderAuthUrl strips any pre-existing provider= before appending the
  real social provider, so the base64 state carries exactly ONE provider=.
  Callback.tsx reads URLSearchParams.get (FIRST match) — two providers made it
  post the upstream hint (hanzo-iam) instead of provider-google.
- providerLogin redirect_uri now derives from tenant.oauthCallbackOrigin (the
  same source the hop uses), never publicOrigin — IAM forwards it verbatim to
  the provider token endpoint; a mismatch is invalid_grant.
- tests: social.test.ts dedup lock + client.test.ts redirect_uri lock (20 pass).
- RC#2 backend proven live: junk-code probe returns invalid_grant not
  invalid_client → Google clientId/secret + iam.hanzo.ai/callback all valid.
- bump id 0.1.25, @hanzo/id-auth 0.1.2.
2026-06-25 16:04:26 -07:00
Hanzo AI 9d906eda2a docs(LLM): provider-name resolution fix + org unified-login mechanism 2026-06-24 17:02:24 -07:00
Hanzo AI e9ec7689a8 docs(LLM): provider-name resolution fix + org unified-login mechanism 2026-06-24 17:02:24 -07:00
Hanzo AI 140a000794 fix(auth): resolve social provider name from nested record, not outer link label
parseAppLogin read the outer Casdoor app-provider LINK name as the
provider identity. Some seeds label that link <org>-iam, so the social
hop POSTed provider=<org>-iam and the IAM backend rejected it ('The
provider: hanzo-iam does not exist'). The provider's real identity is the
nested provider record name (provider-github). Prefer the inner record
name; fall back to the outer label only when no nested record exists.

Regression: getAppLogin uses the nested name + falls back. 18/18 pass.
2026-06-24 16:50:57 -07:00
Hanzo AI 43f7d74249 fix(auth): resolve social provider name from nested record, not outer link label
parseAppLogin read the outer Casdoor app-provider LINK name as the
provider identity. Some seeds label that link <org>-iam, so the social
hop POSTed provider=<org>-iam and the IAM backend rejected it ('The
provider: hanzo-iam does not exist'). The provider's real identity is the
nested provider record name (provider-github). Prefer the inner record
name; fall back to the outer label only when no nested record exists.

Regression: getAppLogin uses the nested name + falls back. 18/18 pass.
2026-06-24 16:50:57 -07:00
Hanzo 1c1d6873af fix(docker): COPY pkgs/connect/package.json before pnpm install
pkgs/auth now depends on @hanzo/id-connect (workspace:*) for the
multi-chain wallet login. The build stage COPYs each workspace
package.json before `pnpm install` so pnpm can resolve the graph, but
pkgs/connect/package.json was missing -> install failed:
'@hanzo/id-connect' unresolved workspace dependency. Add the COPY line.
2026-06-23 21:55:53 -07:00
Hanzo 6c1f661f17 fix(docker): COPY pkgs/connect/package.json before pnpm install
pkgs/auth now depends on @hanzo/id-connect (workspace:*) for the
multi-chain wallet login. The build stage COPYs each workspace
package.json before `pnpm install` so pnpm can resolve the graph, but
pkgs/connect/package.json was missing -> install failed:
'@hanzo/id-connect' unresolved workspace dependency. Add the COPY line.
2026-06-23 21:55:53 -07:00
Hanzo AI 5de04a2b46 feat(web): native multi-chain wallet SIWX login (EVM+Solana), drop OAuth-redirect web3 fallback
Replace the web3 button's @hanzo/iam OAuth-redirect fallback in SocialButtons
with native Sign-In-With-X via the vendored @hanzo/id-connect connectors. The
connect->nonce->sign->verify orchestration is ONE function (loginWithWalletChain
in pkgs/auth/src/web3.ts): GET /v1/iam/web3/nonce -> connector.signLogin(challenge)
-> POST /v1/iam/web3/verify, returning the SAME LoginResponse/redirect the
password flow uses. Decomplected: connect+sign = browser (lazy-loaded wallet
libs, code-split), verify = server.

Enabled chains gated on the verifier-readiness matrix via one exported constant
ENABLED_WALLET_CHAINS=['evm','solana'] (TON/XRP/Bitcoin disabled: Go verifiers
are stubs, would fail closed). The wallet provider expands into one connect
button per enabled chain. NO WalletConnect, NO projectId, NO web3-onboard.

Tests: pkgs/auth/src/web3.test.ts (mock fetch + fake signer) asserts nonce fetch,
signLogin gets the challenge, proof POSTed, SSO code redirect, disabled chains
fail closed without network/signer, wallet-rejection -> {error}.
2026-06-23 21:47:38 -07:00
Hanzo AI 1ea5fe9cdb feat(web): native multi-chain wallet SIWX login (EVM+Solana), drop OAuth-redirect web3 fallback
Replace the web3 button's @hanzo/iam OAuth-redirect fallback in SocialButtons
with native Sign-In-With-X via the vendored @hanzo/id-connect connectors. The
connect->nonce->sign->verify orchestration is ONE function (loginWithWalletChain
in pkgs/auth/src/web3.ts): GET /v1/iam/web3/nonce -> connector.signLogin(challenge)
-> POST /v1/iam/web3/verify, returning the SAME LoginResponse/redirect the
password flow uses. Decomplected: connect+sign = browser (lazy-loaded wallet
libs, code-split), verify = server.

Enabled chains gated on the verifier-readiness matrix via one exported constant
ENABLED_WALLET_CHAINS=['evm','solana'] (TON/XRP/Bitcoin disabled: Go verifiers
are stubs, would fail closed). The wallet provider expands into one connect
button per enabled chain. NO WalletConnect, NO projectId, NO web3-onboard.

Tests: pkgs/auth/src/web3.test.ts (mock fetch + fake signer) asserts nonce fetch,
signLogin gets the challenge, proof POSTed, SSO code redirect, disabled chains
fail closed without network/signer, wallet-rejection -> {error}.
2026-06-23 21:47:38 -07:00
Hanzo AI 7360334996 feat(id): wire @luxwallet/connect multi-wallet web3 (EVM/SOL/BTC/TON/XRP)
- vendor luxwallet/connect (MIT, not on npm) as workspace pkg
  @hanzo/id-connect (pkgs/connect) — exports map points at ./src/*.ts,
  Vite compiles it directly like the other id pkgs.
- replace the placeholder window.ethereum eth_requestAccounts connector
  in Onboarding.tsx with getConnector('evm').connect() (EIP-6963
  multi-injection via viem). connectWallet keeps its string|null contract
  so @hanzo/id-onboarding stays wallet-lib-agnostic.
- add wallet peerDeps to @hanzo/id-web: viem, @tonconnect/sdk,
  sats-connect, @crossmarkio/sdk (connectors.ts eagerly imports all 5
  chains). No WalletConnect projectId needed — connect uses pure injected
  discovery, not the WC bridge protocol.

IAM provider-web3 already exists and is attached to the hanzo-id app.
vite build green (607 modules); secp256k1 chunk bundled.
2026-06-23 21:39:53 -07:00
Hanzo AI 154d0b3b7d feat(id): wire @luxwallet/connect multi-wallet web3 (EVM/SOL/BTC/TON/XRP)
- vendor luxwallet/connect (MIT, not on npm) as workspace pkg
  @hanzo/id-connect (pkgs/connect) — exports map points at ./src/*.ts,
  Vite compiles it directly like the other id pkgs.
- replace the placeholder window.ethereum eth_requestAccounts connector
  in Onboarding.tsx with getConnector('evm').connect() (EIP-6963
  multi-injection via viem). connectWallet keeps its string|null contract
  so @hanzo/id-onboarding stays wallet-lib-agnostic.
- add wallet peerDeps to @hanzo/id-web: viem, @tonconnect/sdk,
  sats-connect, @crossmarkio/sdk (connectors.ts eagerly imports all 5
  chains). No WalletConnect projectId needed — connect uses pure injected
  discovery, not the WC bridge protocol.

IAM provider-web3 already exists and is attached to the hanzo-id app.
vite build green (607 modules); secp256k1 chunk bundled.
2026-06-23 21:39:53 -07:00
Antje Worring b116ea118d fix(auth): thread OIDC nonce through the password-login path
Confidential OIDC clients that validate strictly (LibreChat openid-client with
OPENID_REUSE_TOKENS) send a nonce on authorize and require the id_token to echo
it. The portal's password-login path read code_challenge from the authorize URL
but DROPPED nonce, so the IAM minted a code (and id_token) with no nonce ->
openid-client 'unexpected JWT claim value encountered' -> hanzo.chat callback
HTTP 500. Thread nonce: Login.tsx reads ?nonce, LoginForm forwards it, and
client.login() puts it on the /v1/iam/login query (next to code_challenge).
Additive + forward-only (never defaulted); social login already rode authorize()
which carried nonce. Verified: id SPA builds green.
2026-06-23 18:17:30 -07:00
Antje Worring 8745f62bfd fix(auth): thread OIDC nonce through the password-login path
Confidential OIDC clients that validate strictly (LibreChat openid-client with
OPENID_REUSE_TOKENS) send a nonce on authorize and require the id_token to echo
it. The portal's password-login path read code_challenge from the authorize URL
but DROPPED nonce, so the IAM minted a code (and id_token) with no nonce ->
openid-client 'unexpected JWT claim value encountered' -> hanzo.chat callback
HTTP 500. Thread nonce: Login.tsx reads ?nonce, LoginForm forwards it, and
client.login() puts it on the /v1/iam/login query (next to code_challenge).
Additive + forward-only (never defaulted); social login already rode authorize()
which carried nonce. Verified: id SPA builds green.
2026-06-23 18:17:30 -07:00
Antje Worring 80ced70133 ci(docker): compute version without node (ARC runner has no node)
The ARC runner image ships git+docker but not node, so the Compute-tags step's
`node -p "require('./package.json').version"` died with exit 127 (node: command
not found), skipping the build. Parse the version from package.json with
portable sed instead. Verified locally -> 0.1.23.
2026-06-23 15:42:29 -07:00
Antje Worring d89222ad27 ci(docker): compute version without node (ARC runner has no node)
The ARC runner image ships git+docker but not node, so the Compute-tags step's
`node -p "require('./package.json').version"` died with exit 127 (node: command
not found), skipping the build. Parse the version from package.json with
portable sed instead. Verified locally -> 0.1.23.
2026-06-23 15:42:29 -07:00
Antje Worring 1c060e6886 ci(docker): target ARC scale set by name + mode=min cache
runs-on was [self-hosted, linux, amd64], which GitHub matched to the OFFLINE
classic evo-* org runners instead of the live ephemeral ARC scale set, so every
Docker run since the self-contained-build switch queued forever (never built).
Use runs-on: hanzo-build-linux-amd64 (the exact scaleSetName), matching the
hanzoai/iam convention, so jobs route to scale set 31.

Also switch buildx gha cache to mode=min: the runner builds via DinD on a 32G
node and mode=max exported every layer, ballooning the DinD store past the
kubelet eviction threshold (NodeHasDiskPressure -> build killed 'no space').
2026-06-23 15:40:29 -07:00
Antje Worring db7acceca8 ci(docker): target ARC scale set by name + mode=min cache
runs-on was [self-hosted, linux, amd64], which GitHub matched to the OFFLINE
classic evo-* org runners instead of the live ephemeral ARC scale set, so every
Docker run since the self-contained-build switch queued forever (never built).
Use runs-on: hanzo-build-linux-amd64 (the exact scaleSetName), matching the
hanzoai/iam convention, so jobs route to scale set 31.

Also switch buildx gha cache to mode=min: the runner builds via DinD on a 32G
node and mode=max exported every layer, ballooning the DinD store past the
kubelet eviction threshold (NodeHasDiskPressure -> build killed 'no space').
2026-06-23 15:40:29 -07:00
Antje Worring 0b144a53e5 fix(auth): org-agnostic password login — resolve real owner-org, not pinned brand
LoginForm no longer pins organization=<brand> on POST /v1/iam/login. It passes
the new (normally-unset) TenantConfig.loginOrg, and client.login() OMITS the
organization field when empty so IAM runs cross-org resolution and the session
encodes the user's REAL owner-org (GetOrganizationByUser), never the hint.

Fixes: global admins (z@/a@hanzo.ai, woo@lux.network — owner=admin) were
truncated to a 1-org 'hanzo' session via the UI because the pinned org made
GetUserByFields hit the colliding hanzo/<name> row first, so the cross-org
fallback to admin/<name> never ran. IAM IsGlobalAdmin()==(Owner=='admin') and
get-organizations returns all orgs only for a global admin. Verified live on
hanzo.id: z@hanzo.ai -> owner=admin, 45 orgs; brand-only user -> 1 org.

Boundaries preserved: signup still sends a concrete org; per-app SSO
(client_id+redirect_uri, type=code) still mints a code bound to the resolved
user; a brand can force single-org login via loginOrg in the catalog ConfigMap.
Contract locked in pkgs/auth/src/client.test.ts.

Bump 0.1.22 -> 0.1.23.
2026-06-23 15:24:32 -07:00
Antje Worring fe13917088 fix(auth): org-agnostic password login — resolve real owner-org, not pinned brand
LoginForm no longer pins organization=<brand> on POST /v1/iam/login. It passes
the new (normally-unset) TenantConfig.loginOrg, and client.login() OMITS the
organization field when empty so IAM runs cross-org resolution and the session
encodes the user's REAL owner-org (GetOrganizationByUser), never the hint.

Fixes: global admins (z@/a@hanzo.ai, woo@lux.network — owner=admin) were
truncated to a 1-org 'hanzo' session via the UI because the pinned org made
GetUserByFields hit the colliding hanzo/<name> row first, so the cross-org
fallback to admin/<name> never ran. IAM IsGlobalAdmin()==(Owner=='admin') and
get-organizations returns all orgs only for a global admin. Verified live on
hanzo.id: z@hanzo.ai -> owner=admin, 45 orgs; brand-only user -> 1 org.

Boundaries preserved: signup still sends a concrete org; per-app SSO
(client_id+redirect_uri, type=code) still mints a code bound to the resolved
user; a brand can force single-org login via loginOrg in the catalog ConfigMap.
Contract locked in pkgs/auth/src/client.test.ts.

Bump 0.1.22 -> 0.1.23.
2026-06-23 15:24:32 -07:00
616a1131a6 fix(onboarding): never list other tenants' orgs; create-or-skip only (#15)
The org step fetched service.listOrgs() and rendered a pick-list of every
existing organization to a brand-new user — leaking the tenant directory to
anyone who signs up, and not the intended UX. A new user should only create
their own organization or skip; joining an existing org is invitation-based
and handled outside onboarding.

Drop the listOrgs() fetch and the pick-list entirely; the org step now always
shows the create form with a 'Skip for now' action.

Co-authored-by: Darkhorse7stars <z@hanzo.ai>
2026-06-23 12:56:31 -07:00
fbb5da8c70 fix(onboarding): never list other tenants' orgs; create-or-skip only (#15)
The org step fetched service.listOrgs() and rendered a pick-list of every
existing organization to a brand-new user — leaking the tenant directory to
anyone who signs up, and not the intended UX. A new user should only create
their own organization or skip; joining an existing org is invitation-based
and handled outside onboarding.

Drop the listOrgs() fetch and the pick-list entirely; the org step now always
shows the create form with a 'Skip for now' action.

Co-authored-by: Darkhorse7stars <z@hanzo.ai>
2026-06-23 12:56:31 -07:00
zeekay a6b37b02d7 Merge branch 'feat/sms-consent-disclosure' 2026-06-23 11:32:36 -07:00
zeekay 23cac42399 Merge branch 'feat/sms-consent-disclosure' 2026-06-23 11:32:36 -07:00
zeekay 4a8d73b0fb feat(auth): A2P SMS consent disclosure on the SMS verification surface
Twilio A2P 10DLC requires the SMS consent disclosure wherever a user enters
or uses a phone number to receive messages. Add one shared, verbatim consent
component (matches hanzo.ai/sms-opt-in SMS_CONSENT_TEXT and the IAM phone-login
UI) and render it on the portal's SMS surface, gated to the SMS channel only.

- New pkgs/auth/src/ui/SmsConsent.tsx: single source of SMS_CONSENT_TEXT plus
  <SmsConsentNotice/> (disclosure + Terms/Privacy links). Exported from the ui
  barrel for reuse by any future phone-collection surface.
- OTPForm: render SmsConsentNotice beneath the code input only when
  channel === 'sms' (not for totp/email).
- app.css: muted styling for .hanzo-id-sms-consent, matching footer-links.

Note: this portal does not render its own phone-number field — phone-number
COLLECTION happens in the IAM-hosted UI (covered separately), where signup uses
a required, submit-gating consent checkbox. The portal's only SMS-facing step
today is the verification-code entry, so the disclosure-only notice applies
here. Typecheck + tests green (pnpm -r tc, @hanzo/id-auth test).
2026-06-23 09:38:27 -07:00
zeekay 0e10986cc6 feat(auth): A2P SMS consent disclosure on the SMS verification surface
Twilio A2P 10DLC requires the SMS consent disclosure wherever a user enters
or uses a phone number to receive messages. Add one shared, verbatim consent
component (matches hanzo.ai/sms-opt-in SMS_CONSENT_TEXT and the IAM phone-login
UI) and render it on the portal's SMS surface, gated to the SMS channel only.

- New pkgs/auth/src/ui/SmsConsent.tsx: single source of SMS_CONSENT_TEXT plus
  <SmsConsentNotice/> (disclosure + Terms/Privacy links). Exported from the ui
  barrel for reuse by any future phone-collection surface.
- OTPForm: render SmsConsentNotice beneath the code input only when
  channel === 'sms' (not for totp/email).
- app.css: muted styling for .hanzo-id-sms-consent, matching footer-links.

Note: this portal does not render its own phone-number field — phone-number
COLLECTION happens in the IAM-hosted UI (covered separately), where signup uses
a required, submit-gating consent checkbox. The portal's only SMS-facing step
today is the verification-code entry, so the disclosure-only notice applies
here. Typecheck + tests green (pnpm -r tc, @hanzo/id-auth test).
2026-06-23 09:38:27 -07:00
Hanzo 369b0702b9 ci(docker): self-contained build on self-hosted runners
The shared reusable workflow (hanzoai/.github docker-build.yml@main) fails graph
validation for EVERY caller right now (org-wide startup_failure, 0 jobs created),
so id never built via CI (0.1.x images were pushed manually). Build the amd64
image directly on our self-hosted runners instead — no shared-infra dependency.
Tags: sha-<short> + package.json version.
2026-06-22 23:30:19 -07:00
Hanzo 0cf50f1f1b ci(docker): self-contained build on self-hosted runners
The shared reusable workflow (hanzoai/.github docker-build.yml@main) fails graph
validation for EVERY caller right now (org-wide startup_failure, 0 jobs created),
so id never built via CI (0.1.x images were pushed manually). Build the amd64
image directly on our self-hosted runners instead — no shared-infra dependency.
Tags: sha-<short> + package.json version.
2026-06-22 23:30:19 -07:00
Hanzo 52bb873f87 fix(social): real Google endpoint + registered redirect_uri (iam.hanzo.ai/callback)
The provider hop sent redirect_uri=${origin}/callback (= hanzo.id/callback),
which the shared Google/GitHub OAuth client does NOT accept — verified live the
Google client accepts ONLY https://iam.hanzo.ai/callback (every other URI →
redirect_uri_mismatch). It also used the invalid authorize endpoint
accounts.google.com/signin/oauth.

- social.ts: Google endpoint → https://accounts.google.com/o/oauth2/v2/auth
  (canonical/stable); add callbackOrigin param to buildProviderAuthUrl/
  startProviderLogin (defaults to origin — local/single-host unchanged).
- TenantConfig.oauthCallbackOrigin (catalog-driven; defaults to publicOrigin);
  SocialButtons passes it so the hop returns to the provider's REGISTERED
  /callback. iam.hanzo.ai serves the same @hanzo/id SPA: the headless Callback
  completes the exchange and forwards to the originating app.
- tests: lock the registered-callback override + canonical Google endpoint.

bump id 0.1.1→0.1.22, id-auth/id-shared 0.1.0→0.1.1
2026-06-22 23:26:35 -07:00
Hanzo ddd5f4c409 fix(social): real Google endpoint + registered redirect_uri (iam.hanzo.ai/callback)
The provider hop sent redirect_uri=${origin}/callback (= hanzo.id/callback),
which the shared Google/GitHub OAuth client does NOT accept — verified live the
Google client accepts ONLY https://iam.hanzo.ai/callback (every other URI →
redirect_uri_mismatch). It also used the invalid authorize endpoint
accounts.google.com/signin/oauth.

- social.ts: Google endpoint → https://accounts.google.com/o/oauth2/v2/auth
  (canonical/stable); add callbackOrigin param to buildProviderAuthUrl/
  startProviderLogin (defaults to origin — local/single-host unchanged).
- TenantConfig.oauthCallbackOrigin (catalog-driven; defaults to publicOrigin);
  SocialButtons passes it so the hop returns to the provider's REGISTERED
  /callback. iam.hanzo.ai serves the same @hanzo/id SPA: the headless Callback
  completes the exchange and forwards to the originating app.
- tests: lock the registered-callback override + canonical Google endpoint.

bump id 0.1.1→0.1.22, id-auth/id-shared 0.1.0→0.1.1
2026-06-22 23:26:35 -07:00
Antje Worring af8819ba60 fix(portal): root IS the login form (logged-out) / apps launcher (logged-in)
- '/' rendered a static 'Welcome' marketing hero; for an identity portal the
  root should BE the login form when signed out, and the org's apps launcher
  when signed in. Portal now reads /v1/iam/get-account (same-origin, first-party
  cookie) and renders <Login> (anon) or the apps grid (authed).
- Onboarding completion redirected to '/' → the hero, which read as 'looped back
  to the beginning' after the last (wallet) step's Skip. It now lands on
  '/?signed_in=1' → the authed apps launcher. (The step machine was already
  correct: nextStep('wallet')==='done'.)
- Port marketing.ts (appsFor/billingFor) from the restore-design line.
2026-06-22 22:05:14 -07:00
Antje Worring e303575006 fix(portal): root IS the login form (logged-out) / apps launcher (logged-in)
- '/' rendered a static 'Welcome' marketing hero; for an identity portal the
  root should BE the login form when signed out, and the org's apps launcher
  when signed in. Portal now reads /v1/iam/get-account (same-origin, first-party
  cookie) and renders <Login> (anon) or the apps grid (authed).
- Onboarding completion redirected to '/' → the hero, which read as 'looped back
  to the beginning' after the last (wallet) step's Skip. It now lands on
  '/?signed_in=1' → the authed apps launcher. (The step machine was already
  correct: nextStep('wallet')==='done'.)
- Port marketing.ts (appsFor/billingFor) from the restore-design line.
2026-06-22 22:05:14 -07:00
zeekay 04b2a0324d fix(auth): use feat's clean LoginForm/SignupForm (getAppLogin via SocialButtons; password+PKCE) — auto-merge had mixed main's appLogin/sendLoginCode API with feat's client 2026-06-21 19:43:15 -07:00
zeekay 106818b9a1 fix(auth): use feat's clean LoginForm/SignupForm (getAppLogin via SocialButtons; password+PKCE) — auto-merge had mixed main's appLogin/sendLoginCode API with feat's client 2026-06-21 19:43:15 -07:00
zeekay 7c67359f6f fix(docker): COPY pkgs/onboarding/package.json — merged workspace needs it for pnpm install 2026-06-21 19:37:00 -07:00
zeekay 5d29cf4a55 fix(docker): COPY pkgs/onboarding/package.json — merged workspace needs it for pnpm install 2026-06-21 19:37:00 -07:00
zeekay 03217a97be merge(id): integrate feat/full-auth-onboarding — PKCE password-login fix + composable auth UI + social hop into main
# Conflicts:
#	Dockerfile
#	apps/web/src/App.tsx
#	apps/web/src/app.css
#	pkgs/auth/src/client.ts
#	pkgs/auth/src/index.ts
#	pkgs/auth/src/ui/index.ts
#	pkgs/shared/src/brand.ts
#	pkgs/shared/src/tenant.ts
2026-06-21 19:35:05 -07:00
zeekay b879084af0 merge(id): integrate feat/full-auth-onboarding — PKCE password-login fix + composable auth UI + social hop into main
# Conflicts:
#	Dockerfile
#	apps/web/src/App.tsx
#	apps/web/src/app.css
#	pkgs/auth/src/client.ts
#	pkgs/auth/src/index.ts
#	pkgs/auth/src/ui/index.ts
#	pkgs/shared/src/brand.ts
#	pkgs/shared/src/tenant.ts
2026-06-21 19:35:05 -07:00
zeekay 324c698dd5 feat(social): wire the provider hop + return exchange (gated until creds seeded)
Prep social login end-to-end so only OAuth-app registration + KMS creds remain.

- The hop (social.ts/startProviderLogin): redirects straight to GitHub/Google with a base64 state that round-trips the original authorize request — the Casdoor getAuthUrl contract. Replaces the @hanzo/iam signinRedirect that looped back to the login page. Pure URL builder is unit-tested (social.test.ts, 5 cases).

- SocialButtons now keeps the full provider records (type/clientId/scopes surfaced from get-app-login) and routes OAuth providers through the hop, wallet through the SDK.

- Callback gained a GATED provider-return branch: a provider state (base64 with provider+application) is exchanged via client.providerLogin at the IAM backend, then follows the continue-URL back into the normal OIDC path. The existing OIDC/password path is the untouched else-branch — and the social branch is unreachable until a configured provider exists, so zero impact today.

- Cred-sync already exists (init_data.json ${IAM_GITHUB_CLIENT_ID} <- iam-kms-sync KMSSecret, project hanzo-iam/prod). Runbook in LLM.md. End-to-end needs live verification once real creds are seeded.
2026-06-20 10:46:26 -07:00
zeekay b425ce9979 feat(social): wire the provider hop + return exchange (gated until creds seeded)
Prep social login end-to-end so only OAuth-app registration + KMS creds remain.

- The hop (social.ts/startProviderLogin): redirects straight to GitHub/Google with a base64 state that round-trips the original authorize request — the Casdoor getAuthUrl contract. Replaces the @hanzo/iam signinRedirect that looped back to the login page. Pure URL builder is unit-tested (social.test.ts, 5 cases).

- SocialButtons now keeps the full provider records (type/clientId/scopes surfaced from get-app-login) and routes OAuth providers through the hop, wallet through the SDK.

- Callback gained a GATED provider-return branch: a provider state (base64 with provider+application) is exchanged via client.providerLogin at the IAM backend, then follows the continue-URL back into the normal OIDC path. The existing OIDC/password path is the untouched else-branch — and the social branch is unreachable until a configured provider exists, so zero impact today.

- Cred-sync already exists (init_data.json ${IAM_GITHUB_CLIENT_ID} <- iam-kms-sync KMSSecret, project hanzo-iam/prod). Runbook in LLM.md. End-to-end needs live verification once real creds are seeded.
2026-06-20 10:46:26 -07:00
zeekay ba9363cf7a fix(login): deliver tenant catalog via /config.json; correct pars/osage/lux client wiring
Root cause osage.id still showed Hanzo: the SPA read the catalog from window.__ID_CATALOG__, which the runtime never injects — so every catalog-only host silently fell back to the bundled Hanzo default. Read the catalog from /config.json (what the static server actually templates from SPA_IAM_TENANT_CONFIG_JSON), with the global as a fallback.

Also corrected verified-broken client wiring: built-in pars-id does not exist (use pars-console, which carries the /callback redirect); added an osage built-in so a catalog-load failure can never leak the Hanzo brand. lux uses lux-id (lux-cloud lacks the portal redirect) — fixed in the ConfigMap. Tests: 7/7.
2026-06-20 02:51:08 -07:00
zeekay be4ea25d2f fix(login): deliver tenant catalog via /config.json; correct pars/osage/lux client wiring
Root cause osage.id still showed Hanzo: the SPA read the catalog from window.__ID_CATALOG__, which the runtime never injects — so every catalog-only host silently fell back to the bundled Hanzo default. Read the catalog from /config.json (what the static server actually templates from SPA_IAM_TENANT_CONFIG_JSON), with the global as a fallback.

Also corrected verified-broken client wiring: built-in pars-id does not exist (use pars-console, which carries the /callback redirect); added an osage built-in so a catalog-load failure can never leak the Hanzo brand. lux uses lux-id (lux-cloud lacks the portal redirect) — fixed in the ConfigMap. Tests: 7/7.
2026-06-20 02:51:08 -07:00
zeekay 584e6cd679 fix(tenant): catalog-only hosts resolve their OWN brand; drop orphan divider
osage.id (and zoolabs.id) rendered as Hanzo — a white-label leak. Root cause: the runtime catalog entries carry brandUrl (+orgId/clientId), but a host with NO built-in DEFAULT_TENANTS entry used DEFAULT_TENANTS['hanzo.id'] as its merge base, so brandPackage/iamUrl silently inherited Hanzo. Now a catalog-only host derives iamUrl/iamIssuer/publicOrigin from the host itself and maps brandUrl -> brandPackage; it can never inherit another brand. Unknown brand asset -> neutral wordmark, not the wrong brand. Regression test added (pkgs/shared now has a test runner).

Drop the orphan 'or' divider: it lived between the social block and the password form, but with unconfigured social hidden it dangled above the form on every brand. Moved the divider INTO SocialButtons so it renders only alongside actual buttons; removed the standalone one from Login + Signup.
2026-06-20 02:38:31 -07:00
zeekay 29e85db41d fix(tenant): catalog-only hosts resolve their OWN brand; drop orphan divider
osage.id (and zoolabs.id) rendered as Hanzo — a white-label leak. Root cause: the runtime catalog entries carry brandUrl (+orgId/clientId), but a host with NO built-in DEFAULT_TENANTS entry used DEFAULT_TENANTS['hanzo.id'] as its merge base, so brandPackage/iamUrl silently inherited Hanzo. Now a catalog-only host derives iamUrl/iamIssuer/publicOrigin from the host itself and maps brandUrl -> brandPackage; it can never inherit another brand. Unknown brand asset -> neutral wordmark, not the wrong brand. Regression test added (pkgs/shared now has a test runner).

Drop the orphan 'or' divider: it lived between the social block and the password form, but with unconfigured social hidden it dangled above the form on every brand. Moved the divider INTO SocialButtons so it renders only alongside actual buttons; removed the standalone one from Login + Signup.
2026-06-20 02:38:31 -07:00
zeekay fe30228bdc fix(login): only show configured social providers; never show a broken brand logo
Two things made login feel broken across every brand on the portal (hanzo.id/lux.id/pars.id/osage.id):

1) Social SSO dead-ended. The GitHub/Google/Apple/Web3 providers are seeded with PLACEHOLDER credentials (clientId=GITHUB_CLIENT_ID_PLACEHOLDER, etc.), so the OAuth redirect can never complete — clicking 'Continue with GitHub' just looped back to the login form. Render ONLY providers IAM actually holds a real clientId for (AppProvider.configured, computed from the nested provider record). With placeholders that hides all social buttons, leaving the working methods (email/password + email/SMS code) — and the buttons reappear automatically once real OAuth creds are seeded, no code change. When get-app-login is unreadable, render none rather than risk a dead-end.

2) Broken logo. brand.json points logo/favicon at jsdelivr @hanzo/brand assets, but the published brand packages ship no assets/ dir (404). BrandHeader now falls back to the brand NAME as a text wordmark on image error/absence, so a missing logo never renders a broken-image icon.

Enabling real social login later needs: (a) register OAuth apps (GitHub/Google/Apple) with callback https://<brand>/v1/iam/callback, (b) put their client_id/secret in KMS → IAM provider records, (c) the SPA provider-redirect hop. (a)+(b) are external provisioning; until then social is correctly hidden.
2026-06-20 02:21:35 -07:00
zeekay d6af481d72 fix(login): only show configured social providers; never show a broken brand logo
Two things made login feel broken across every brand on the portal (hanzo.id/lux.id/pars.id/osage.id):

1) Social SSO dead-ended. The GitHub/Google/Apple/Web3 providers are seeded with PLACEHOLDER credentials (clientId=GITHUB_CLIENT_ID_PLACEHOLDER, etc.), so the OAuth redirect can never complete — clicking 'Continue with GitHub' just looped back to the login form. Render ONLY providers IAM actually holds a real clientId for (AppProvider.configured, computed from the nested provider record). With placeholders that hides all social buttons, leaving the working methods (email/password + email/SMS code) — and the buttons reappear automatically once real OAuth creds are seeded, no code change. When get-app-login is unreadable, render none rather than risk a dead-end.

2) Broken logo. brand.json points logo/favicon at jsdelivr @hanzo/brand assets, but the published brand packages ship no assets/ dir (404). BrandHeader now falls back to the brand NAME as a text wordmark on image error/absence, so a missing logo never renders a broken-image icon.

Enabling real social login later needs: (a) register OAuth apps (GitHub/Google/Apple) with callback https://<brand>/v1/iam/callback, (b) put their client_id/secret in KMS → IAM provider records, (c) the SPA provider-redirect hop. (a)+(b) are external provisioning; until then social is correctly hidden.
2026-06-20 02:21:35 -07:00
zeekay d0863532d4 fix(auth): forward PKCE code_challenge on password login
login() built /v1/iam/login with clientId/responseType/redirectUri/state
but dropped the PKCE code_challenge/code_challenge_method that authorize()
already forwards. A downstream public SPA client (e.g. hanzo-platform)
sending a user to password sign-in got an auth code minted with an empty
stored challenge; the downstream /auth/callback token exchange then failed
PKCE validation and fell back to a client_secret check the public client
can't satisfy (token.CodeChallenge: empty -> invalid_client).

IAM's Login handler (controllers/account.go) reads code_challenge from the
query first, body fallback, so forward it on the query mirroring authorize().
Plumb it from the OAuth params already on the /login URL through Login page
-> LoginForm -> client.login(). Social login was unaffected (rides authorize).
2026-06-20 01:36:59 -07:00
zeekay a22f3c9648 fix(auth): forward PKCE code_challenge on password login
login() built /v1/iam/login with clientId/responseType/redirectUri/state
but dropped the PKCE code_challenge/code_challenge_method that authorize()
already forwards. A downstream public SPA client (e.g. hanzo-platform)
sending a user to password sign-in got an auth code minted with an empty
stored challenge; the downstream /auth/callback token exchange then failed
PKCE validation and fell back to a client_secret check the public client
can't satisfy (token.CodeChallenge: empty -> invalid_client).

IAM's Login handler (controllers/account.go) reads code_challenge from the
query first, body fallback, so forward it on the query mirroring authorize().
Plumb it from the OAuth params already on the /login URL through Login page
-> LoginForm -> client.login(). Social login was unaffected (rides authorize).
2026-06-20 01:36:59 -07:00
zeekay 68c33527b9 fix(docker): bake SPA-appropriate CSP into the image (HANZO_STATIC_CSP)
hanzoai/static defaults CSP to `default-src 'none'` which blocks the
SPA's own bundle (no script-src) -> blank page. Bake a widened-but-locked
CSP that permits: self scripts + CF beacon, same-origin + jsDelivr brand
fetches, https/data images (brand logos), inline styles (@hanzo/gui).
Baking it (vs a deploy-time env) makes the image render correctly
standalone and survives universe reconcile.
2026-06-20 00:17:30 -07:00
zeekay 6c7e083100 fix(docker): bake SPA-appropriate CSP into the image (HANZO_STATIC_CSP)
hanzoai/static defaults CSP to `default-src 'none'` which blocks the
SPA's own bundle (no script-src) -> blank page. Bake a widened-but-locked
CSP that permits: self scripts + CF beacon, same-origin + jsDelivr brand
fetches, https/data images (brand logos), inline styles (@hanzo/gui).
Baking it (vs a deploy-time env) makes the image render correctly
standalone and survives universe reconcile.
2026-06-20 00:17:30 -07:00
zeekay 6af816ac9c fix(brand): emit + fetch brand.json at flat encoding-safe /brand/<scope>.json
Two coupled bugs broke runtime brand loading on the hanzoai/static
(scratch) image:

1. vite.config.ts is ESM ("type":"module") so the brandJsonPlugin's
   require.resolve('@scope/brand/brand.json') threw ReferenceError
   (no global require), was swallowed by the silent catch, and NO
   brand.json was ever emitted into dist/. Fixed with
   createRequire(import.meta.url).

2. The brand fetch path /brand/@hanzo/brand/brand.json carries a literal
   @ and an encoded %2F that the production static server cannot map to
   the on-disk file, so it falls through to the SPA catch-all and returns
   index.html — the SPA then parses HTML as JSON. Emit and fetch at a
   FLAT slug path instead: @hanzo/brand -> /brand/hanzo.json. The plugin
   and loadBrand derive the slug identically (npm scope).

With defensive loadBrand (prev commit) a miss degrades to a neutral
brand; this restores real per-brand branding.
2026-06-20 00:07:06 -07:00
zeekay f6b7b9327c fix(brand): emit + fetch brand.json at flat encoding-safe /brand/<scope>.json
Two coupled bugs broke runtime brand loading on the hanzoai/static
(scratch) image:

1. vite.config.ts is ESM ("type":"module") so the brandJsonPlugin's
   require.resolve('@scope/brand/brand.json') threw ReferenceError
   (no global require), was swallowed by the silent catch, and NO
   brand.json was ever emitted into dist/. Fixed with
   createRequire(import.meta.url).

2. The brand fetch path /brand/@hanzo/brand/brand.json carries a literal
   @ and an encoded %2F that the production static server cannot map to
   the on-disk file, so it falls through to the SPA catch-all and returns
   index.html — the SPA then parses HTML as JSON. Emit and fetch at a
   FLAT slug path instead: @hanzo/brand -> /brand/hanzo.json. The plugin
   and loadBrand derive the slug identically (npm scope).

With defensive loadBrand (prev commit) a miss degrades to a neutral
brand; this restores real per-brand branding.
2026-06-20 00:07:06 -07:00
zeekay 84a529cd0a fix(brand): never blank the login form on a transient brand.json failure
The browser brand loader threw on any non-ok /brand/<pkg>/brand.json fetch, so a single transient 502 on the cosmetic brand asset (intermittent behind Cloudflare) left the IAM login SPA blank — no form, just 'brand.json fetch failed: 502'. Retry the flaky asset up to 3x with backoff, then fall back to a neutral per-tenant brand so the form always renders. brand.json stays the source of truth on the happy path.
2026-06-19 23:54:11 -07:00
zeekay dc3f888355 fix(brand): never blank the login form on a transient brand.json failure
The browser brand loader threw on any non-ok /brand/<pkg>/brand.json fetch, so a single transient 502 on the cosmetic brand asset (intermittent behind Cloudflare) left the IAM login SPA blank — no form, just 'brand.json fetch failed: 502'. Retry the flaky asset up to 3x with backoff, then fall back to a neutral per-tenant brand so the form always renders. brand.json stays the source of truth on the happy path.
2026-06-19 23:54:11 -07:00
zeekay bfbca725f2 fix(docker): serve SPA from /public with --spa for hanzoai/static contract
hanzoai/static:0.4.1 is FROM scratch + ENTRYPOINT ["/static"]; the
binary defaults to -root /public -port 3000 and writes
/public/config.json from SPA_* env at boot. The previous final stage
copied the bundle to /spa and set dead ENV ROOT/PORT (the binary reads
flags, not env), so /public never existed and the runtime-config write
crashed (open /public/config.json.tmp: no such file or directory).

Copy the dist to /public (the default root + config.json target) and
pass --spa so client-routed paths (/auth/*, /callback) fall back to
index.html. Port 3000 matches the id Service and probes.
2026-06-19 23:48:00 -07:00
zeekay 6e33166f13 fix(docker): serve SPA from /public with --spa for hanzoai/static contract
hanzoai/static:0.4.1 is FROM scratch + ENTRYPOINT ["/static"]; the
binary defaults to -root /public -port 3000 and writes
/public/config.json from SPA_* env at boot. The previous final stage
copied the bundle to /spa and set dead ENV ROOT/PORT (the binary reads
flags, not env), so /public never existed and the runtime-config write
crashed (open /public/config.json.tmp: no such file or directory).

Copy the dist to /public (the default root + config.json target) and
pass --spa so client-routed paths (/auth/*, /callback) fall back to
index.html. Port 3000 matches the id Service and probes.
2026-06-19 23:48:00 -07:00
zeekay 6265944602 fix(docker): COPY pkgs/onboarding/package.json into install stage
apps/web depends on @hanzo/id-onboarding (workspace:*) since the
onboarding flow landed, but the Dockerfile's per-package manifest
COPY list (the layer feeding the dependency-resolution
`pnpm install` before the full `COPY pkgs pkgs`) was never
updated. In-cluster builds therefore failed at install with
ERR_PNPM_WORKSPACE_PKG_NOT_FOUND for @hanzo/id-onboarding.

Local builds masked this because node_modules was already
populated from a prior full install.
2026-06-19 23:40:16 -07:00
zeekay 7d91d22904 fix(docker): COPY pkgs/onboarding/package.json into install stage
apps/web depends on @hanzo/id-onboarding (workspace:*) since the
onboarding flow landed, but the Dockerfile's per-package manifest
COPY list (the layer feeding the dependency-resolution
`pnpm install` before the full `COPY pkgs pkgs`) was never
updated. In-cluster builds therefore failed at install with
ERR_PNPM_WORKSPACE_PKG_NOT_FOUND for @hanzo/id-onboarding.

Local builds masked this because node_modules was already
populated from a prior full install.
2026-06-19 23:40:16 -07:00
zeekay 3e31fe7373 test(onboarding): unit tests for step machine + IAM wire contracts; docs
9 tests via the Node built-in runner (--experimental-strip-types, no
test-framework dependency): step machine (org→project→wallet→done),
listOrgs mapping + error-resilience, createOrg request shape + error
surfacing, linkWallet address validation + get-account→update-user
(web3onboard, column-scoped) + fail-closed. tc excludes *.test.ts so the
build gate stays type-only.

LLM.md: corrected issuer to per-brand *.id host (not iam.hanzo.ai),
documented the full method set + get-app-login source-of-truth, the
onboarding pkg + its IAM routes + admin-gating reality, and the /v1/iam
OIDC paths.
2026-06-19 15:34:37 -07:00
zeekay 670f69f604 test(onboarding): unit tests for step machine + IAM wire contracts; docs
9 tests via the Node built-in runner (--experimental-strip-types, no
test-framework dependency): step machine (org→project→wallet→done),
listOrgs mapping + error-resilience, createOrg request shape + error
surfacing, linkWallet address validation + get-account→update-user
(web3onboard, column-scoped) + fail-closed. tc excludes *.test.ts so the
build gate stays type-only.

LLM.md: corrected issuer to per-brand *.id host (not iam.hanzo.ai),
documented the full method set + get-app-login source-of-truth, the
onboarding pkg + its IAM routes + admin-gating reality, and the /v1/iam
OIDC paths.
2026-06-19 15:34:37 -07:00
zeekay 8163c3c2a8 fix(auth): social/Web3 sign-in honors downstream redirect_uri
Social + Web3 always return to the portal's own /callback (the SDK's fixed
redirectUri), so a downstream app's redirect_uri would be lost. SocialButtons
now stashes it in post_login_redirect before signinRedirect; Callback reads it
back and forwards the tokens there, else lands on /onboarding. Login + Signup
pass redirect_uri through. Password path already forwards directly via the
auth-code response — both methods now reach the same downstream target.
2026-06-19 15:31:21 -07:00
zeekay 647bb88353 fix(auth): social/Web3 sign-in honors downstream redirect_uri
Social + Web3 always return to the portal's own /callback (the SDK's fixed
redirectUri), so a downstream app's redirect_uri would be lost. SocialButtons
now stashes it in post_login_redirect before signinRedirect; Callback reads it
back and forwards the tokens there, else lands on /onboarding. Login + Signup
pass redirect_uri through. Password path already forwards directly via the
auth-code response — both methods now reach the same downstream target.
2026-06-19 15:31:21 -07:00
zeekay 8cc5428e97 fix(onboarding): correct IAM contracts — get-account+web3onboard wallet, authz-aware org/project
linkWallet now resolves the signed-in user via /v1/iam/get-account (owner/name)
and writes the lowercase `web3onboard` column scoped via ?columns= so the rest
of the user row is untouched — IAM's update-user is keyed by owner/name, not a
self alias.

Org/project creation is admin-gated in IAM authz (add-organization needs the
admin role; add-project default-denies non-admins). The common path — pick the
org you signed up into, listed via the *-allowed get-organizations — works for
everyone; create-new surfaces a plain permission message and the step stays
skippable so onboarding never hard-blocks. ProjectStep tolerates an org-less
flow (continue-only).
2026-06-19 15:29:42 -07:00
zeekay 2365f83855 fix(onboarding): correct IAM contracts — get-account+web3onboard wallet, authz-aware org/project
linkWallet now resolves the signed-in user via /v1/iam/get-account (owner/name)
and writes the lowercase `web3onboard` column scoped via ?columns= so the rest
of the user row is untouched — IAM's update-user is keyed by owner/name, not a
self alias.

Org/project creation is admin-gated in IAM authz (add-organization needs the
admin role; add-project default-denies non-admins). The common path — pick the
org you signed up into, listed via the *-allowed get-organizations — works for
everyone; create-new surfaces a plain permission message and the step stays
skippable so onboarding never hard-blocks. ProjectStep tolerates an org-less
flow (continue-only).
2026-06-19 15:29:42 -07:00
zeekay d402732ebf feat(onboarding): post-login org → project → wallet flow + serverUrl=hanzo.id
@hanzo/id-onboarding: domain (serializable step machine + types) / service
(IAM-backed writes via /v1/iam/{get-organizations,add-organization,add-project,
update-user}, cookie+bearer) / UI (self-contained 3-step OnboardingFlow, no
router lib). Web app mounts it at /onboarding; both password (cookie) and
social/Web3 (SDK token) sign-in land there for a bare portal login.

Callback now completes via the @hanzo/iam SDK handleCallback (matches
SocialButtons' signinRedirect) and forwards tokens to a downstream app only
when one initiated the flow.

tenant.ts: iamUrl is the per-brand OIDC issuer host (hanzo.id / lux.id /
zoo.id / pars.id), never iam.hanzo.ai — HIP-0111 host-relative discovery.
clientId is the brand -id app (hanzo-id), matching init_data.json.
2026-06-19 15:25:04 -07:00
zeekay 3262d9e47e feat(onboarding): post-login org → project → wallet flow + serverUrl=hanzo.id
@hanzo/id-onboarding: domain (serializable step machine + types) / service
(IAM-backed writes via /v1/iam/{get-organizations,add-organization,add-project,
update-user}, cookie+bearer) / UI (self-contained 3-step OnboardingFlow, no
router lib). Web app mounts it at /onboarding; both password (cookie) and
social/Web3 (SDK token) sign-in land there for a bare portal login.

Callback now completes via the @hanzo/iam SDK handleCallback (matches
SocialButtons' signinRedirect) and forwards tokens to a downstream app only
when one initiated the flow.

tenant.ts: iamUrl is the per-brand OIDC issuer host (hanzo.id / lux.id /
zoo.id / pars.id), never iam.hanzo.ai — HIP-0111 host-relative discovery.
clientId is the brand -id app (hanzo-id), matching init_data.json.
2026-06-19 15:25:04 -07:00
zeekay 9590dc319d feat(auth): full method set — email/password + GitHub + Google + Web3
SocialButtons reads live enabled providers from /v1/iam/get-app-login and
drives the @hanzo/iam PKCE redirect (provider param) per method. Login/Signup
render the social row + divider above the email form. AppLogin/AppProvider
types model the get-app-login view; createIam() centralizes the one PKCE
client both SocialButtons and Callback share.
2026-06-19 15:19:50 -07:00
zeekay 2132c13e0c feat(auth): full method set — email/password + GitHub + Google + Web3
SocialButtons reads live enabled providers from /v1/iam/get-app-login and
drives the @hanzo/iam PKCE redirect (provider param) per method. Login/Signup
render the social row + divider above the email form. AppLogin/AppProvider
types model the get-app-login view; createIam() centralizes the one PKCE
client both SocialButtons and Callback share.
2026-06-19 15:19:50 -07:00
818ae385e5 fix(brand): resilient brand loader — never blank the login page (#14)
loadBrand did res.json() on the SPA-fallback HTML (the spa server answers
unknown paths with index.html + HTTP 200 for client routing), so a missing
brand.json threw 'Unexpected token <' and crashed App boot -> blank login
page. brand.json is not bundled and the path also wrongly encoded the '/'
in '@hanzo/brand' (%2F), so it never resolved.

Fix:
- Prefer the tenant's brandUrl (the working jsDelivr brand.json from
  config.json), then the app-local path; reject non-JSON (SPA-fallback HTML)
  responses by content-type; on total failure return a minimal fallback brand
  derived from the package scope. Branding is cosmetic and must never block login.
- Add TenantConfig.brandUrl (already supplied by config.json) and pass it from App.
- Drop encodeURIComponent on the whole package (it mangled the scope slash).

Co-authored-by: Antje Worring <worringantje@gmail.com>
2026-06-18 17:56:31 -07:00
5b9555ada6 fix(docker): pin hanzoai/spa:1.2.0 (1.3.0 image not published) (#13)
The v1.3.0 git tag exists but no 1.3.0 image was published to ghcr
(build pipeline blocked). 1.2.0 is the latest published spa image and the
version the canonical RECIPE.md uses.

Co-authored-by: Antje Worring <worringantje@gmail.com>
2026-06-18 16:18:13 -07:00
704b6989a4 fix(docker): serve SPA via hanzoai/spa, not hanzoai/static (#12)
hanzoai/static defaults to Content-Security-Policy: default-src 'none'
(no script-src) — built for static assets, not a SPA that loads its own
JS bundle. That CSP blocks index-*.js, so React never mounts and the
login page renders blank. hanzoai/spa is the purpose-built base: history-
API fallthrough for client-side routes + a SPA-safe CSP. Defaults
PORT=3000 / ROOT=/public, matching the id deploy probe.

Immediate prod was unblocked by setting HANZO_STATIC_CSP on the live
deployment; this is the durable, one-way fix so the override hack isn't
needed and every rebuild serves correctly.

Co-authored-by: Antje Worring <worringantje@gmail.com>
2026-06-18 16:14:27 -07:00
b0b0c1addf fix(docker): static needs -root/-spa flags, not ROOT/PORT env (#11)
hanzoai/static:0.4.1 reads the serve root from the -root flag (default
/public) and port from -port (default 3000) — the ENV ROOT=/spa PORT=8080
were no-ops, so the built image served /public (crash) on :8080 while the
deploy probes :3000. Set ENTRYPOINT [/static -root /spa -spa] (default
port 3000). Matches the hand-built id:0.1.5 now live.

Co-authored-by: Antje Worring <worringantje@gmail.com>
2026-06-18 15:33:36 -07:00
172b4ddf6d fix(id): appLogin uses /v1/iam/get-app-login (main dropped /api/*) (#10)
Merged IAM main serves the canonical /v1/iam/* only; /api/get-app-login
now returns the SPA shell. Point the providers/methods fetch at the
canonical path so social buttons + sign-in methods load.

Co-authored-by: Antje Worring <worringantje@gmail.com>
2026-06-18 14:08:32 -07:00
c0f11dcf28 feat(id): render social logins + email + SMS code on login/signup (#9)
The Vite portal rendered only email/password. Add — driven by the live
IAM app config (new AuthClient.appLogin -> get-app-login), no hardcoded
lists:
- social provider buttons (GitHub/Google/Apple/Web3) via authorize?provider=
- passwordless email / SMS code sign-in (send-verification-code + OTPForm)
- new ProviderButtons component; LoginForm + SignupForm render providers
Email/password sign-in unchanged. tsc --noEmit clean; vite build clean.

Functional prerequisites (display works now; these make it WORK):
- real OAuth client IDs for github/google/apple (IAM currently has
  GITHUB_CLIENT_ID_PLACEHOLDER etc.)
- an SMS gateway provider (Twilio/etc.) in IAM for SMS codes
- redirectUris backfill so the /callback OAuth return is allowed (iam#51)

Co-authored-by: Antje Worring <worringantje@gmail.com>
2026-06-18 13:42:41 -07:00
f04eb29f28 chore(deps): clear Dependabot alerts — drop legacy-nextjs, patch esbuild/uuid (#8)
Resolves all 35 open Dependabot alerts on hanzoai/id:

- Delete legacy-nextjs/ (frozen Next.js predecessor, not wired into any
  build/Dockerfile/workspace glob; docs slated it for deletion after
  v0.1.0 — v0.1.1 has shipped). Removes 31 alerts (next, undici, ws,
  picomatch, postcss, js-yaml, cookie, defu, ...).

- pnpm overrides for the 3 active-workspace transitives:
    esbuild ^0.28.1  (was 0.27.7) — GHSA-gv7w-rqvm-qjhr (high), GHSA-g7r4-m6w7-qqqr (low)
    uuid    ^11.1.1  (was 7.0.3/10/x via xcode@3.0.1) — GHSA-w5hq-g745-h8pq (medium)

Verified: pnpm install + typecheck (4/4) + build green (vite 7.3.5,
esbuild 0.28.1, 52 modules); 'pnpm audit' → no known vulnerabilities.

Co-authored-by: Antje Worring <worringantje@gmail.com>
2026-06-18 12:23:57 -07:00
70b1330df2 fix(tenant): use real IAM clientId (<org>-id, not <org>-id-portal) (#7)
The IAM apps are registered with clientId '<org>-id' (hanzo-id, lux-id,
zoo-id, pars-id) — there is no '-portal' variant. clientId 'hanzo-id-portal'
returns 'Invalid client_id' from IAM, breaking the login flow. Align all
four tenant clientIds with their appName and the IAM seed convention.

Co-authored-by: Antje Worring <worringantje@gmail.com>
2026-06-18 11:34:50 -07:00
583e3454dc fix(id): pin @hanzo/iam to published ^0.9.4 (0.10.0 unpublished, breaks pnpm install) (#6)
Co-authored-by: hanzo-dev <dev@hanzo.ai>
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-06-17 04:03:28 -07:00
7a9ac97526 chore(decomplect): deploy + per-host catalog live in universe, not the app repo (#5)
The id repo is brand-neutral (zero brand data bundled), but apps/web/k8s/
carried the per-host brand catalog + Deployment/Ingress — duplicated in
hanzoai/universe infra/k8s/id/ (the single deploy source-of-truth). Removed
the overlay so app (brand-neutral image) and deploy (per-host catalog +
manifests, in universe) are separated. One way, one place.

Co-authored-by: hanzo-dev <dev@hanzo.ai>
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-06-16 18:05:31 -07:00
cff508e2f0 fix(auth): IAM wire contract (body params) + brand-app catalog (v0.1.1) (#4)
* fix(auth): IAM wire contract + brand-app catalog

- client.ts: send type/application/organization in the request BODY (IAM reads
  auth fields from body, OAuth params from query) so the branded login form
  authenticates; build the code-redirect from the response.
- canonical /v1/iam/oauth/{authorize,token,logout} paths.
- tenant-catalog: point each host at its brand's real IAM app
  (hanzo-console/lux-cloud/zoo-console/pars-console).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>

* chore(release): 0.1.1

---------

Co-authored-by: hanzo-dev <dev@hanzo.ai>
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-06-16 18:00:41 -07:00
hanzo-dev ef6263d666 chore: update 2026-06-10 14:12:32 -07:00
hanzo-dev 98c78c7ad1 merge: id-vite-monorepo (-X theirs) 2026-06-01 18:09:45 -07:00
hanzo-dev 4c89defe95 merge: ci/canonical-docker-build-1776995843 (-X theirs) 2026-06-01 18:09:44 -07:00
hanzo-dev fb06c01189 refactor: brand-neutral identity portal (zero brand-specific code)
Image carries no brand identity. Every per-tenant value comes from the
runtime catalog (K8s ConfigMap → /config.json → window.__ID_CATALOG__)
or is derived from the hostname.

Source changes:
- TenantConfig.brandPackage → brandUrl (absolute URL to brand.json).
  Brands self-host (npm + jsDelivr is convention; any URL works).
- DEFAULT_TENANTS = {} (deleted). Hostname-derivation is the new fallback:
    foo.id / www.foo.id / id.foo.net / iam.foo.net → orgId=foo,
    clientId=foo-id-portal, appName=foo-id,
    brandUrl=https://cdn.jsdelivr.net/npm/@foo/brand@latest/brand.json
  Works out-of-the-box when the npm scope matches the org. The catalog
  handles mismatches.
- brand.ts loadBrand(brandUrl) fetches the URL directly. localizeAssets()
  removed — brand.json's URLs are absolute, used as-is.
- vite.config.ts: removed brandJsonPlugin + BRAND_PACKAGES list. Vite
  bundles no brand assets. dist drops from ~250kB to 205kB (64kB gzip).
- apps/web/package.json: dropped @hanzo/brand, @luxfi/brand, @zooai/brand,
  @parsdao/brand deps. Zero brand packages in the image.
- main.tsx: fetches /config.json before mount; sets
  window.__ID_CATALOG__ from cfg.iamTenantConfigJson (templated by
  hanzoai/spa runtime from SPA_IAM_TENANT_CONFIG_JSON env var).
- App.tsx: loadBrand(t.brandUrl).
- LLM.md: documents the brand-neutral architecture and how to add a
  brand (publish brand.json + add to deploy's catalog ConfigMap; image
  never changes).

Deploy changes:
- apps/web/k8s/tenant-catalog.yaml: NEW ConfigMap carrying the Hanzo
  deployment's full host→tenant map for 11 hosts (hanzo / lux / zoo /
  pars / osage and their id.*.network / iam.*.network / www.* variants).
  Brand-specific knowledge lives ONLY here.
- apps/web/k8s/deployment.yaml: envFrom: configMapRef: id-tenant-catalog.
- apps/web/k8s/kustomization.yaml: includes tenant-catalog.yaml first.

Adding a brand from here on is:
  1. publish @<scope>/brand to npm with brand.json
  2. add the host(s) to the deployment's ConfigMap
  3. add the host to ingress.yaml (cert-manager auto-provisions TLS)
  4. DNS → cluster ingress IP

No source change. No image rebuild.
2026-05-29 11:37:05 -07:00
hanzo-dev 5dfbbad5c1 feat(k8s): add www.zoolabs.id + osage.id + www.osage.id to id ingress
cert-manager.io/cluster-issuer=letsencrypt-prod-cf provisions per-host
TLS via DNS-01 (the Traefik file-route certResolver path is non-functional
in this cluster — CF env vars on the ingress deployment are empty, ACME
in /data/acme.json is empty). cert-manager owns TLS for identity hosts.

After apply + cert issuance (~90s): all 7 identity hosts return their
own OIDC issuer with valid LE certs:
  lux.id, hanzo.id, pars.id, zoolabs.id, www.zoolabs.id, osage.id, www.osage.id
2026-05-29 11:07:17 -07:00
hanzo-dev d43e700f41 feat(tenant): add osage.id + www.osage.id + www.zoolabs.id tenants
DEFAULT_TENANTS now resolves all 4 active identity hosts that didn't
have built-in entries:
- www.zoolabs.id → orgId=zoo, brand=@zooai/brand
- osage.id      → orgId=osage, brand=@osage/brand
- www.osage.id  → orgId=osage, brand=@osage/brand

zoolabs.id was already present. orgId, iamIssuer, clientId, appName,
publicOrigin, brandPackage all follow the existing per-host shape.

@osage/brand is unpublished (~/work/osage/brand v0.1.0 needs build +
publish). Until then, /brand/@osage/brand/brand.json 404s and the
SPA's loadBrand falls back to the @hanzo/brand default — adequate
until osage.id DNS flips off Cloudflare Pages.
2026-05-28 18:27:27 -07:00
hanzo-dev be087c496a chore: update 2026-05-25 15:14:35 -07:00
hanzo-dev b7628eee35 feat: add zoolabs.id (canonical Zoo identity host; was zoo.id)
Per user clarification, the Zoo identity domain is zoolabs.id (zoo.id is
not owned). Add tenant entry + Ingress host + TLS secret. DNS A record
already repointed in CF to 129.212.164.5.
2026-05-24 14:10:33 -07:00
hanzo-dev 7753b0ce78 fix(tenant): hanzo.id clientId was mangled by previous sed (id-portal-portal → hanzo-id-portal) 2026-05-24 10:32:18 -07:00
hanzo-dev 8d7031cf3d style: rename CSS classes hanzo-id-* → id-portal-*
Brand-neutral CSS class names match the public-facing identity of the
portal (id-portal). Cosmetic only; no rendered text changes. Per
playwright agent's source-view note.
2026-05-24 10:31:03 -07:00
hanzo-dev 30c7d0a8c4 feat: extend id portal to .network identity hosts
Add iam.lux.network + id.lux.network (Lux brand) + id.zoo.network
(Zoo brand) to the id Ingress + tenant catalog. Same image, same code
path — only DEFAULT_TENANTS + Ingress hosts/tls grow.

Cutover plan (post-merge):
  1. kubectl apply -k infra/k8s/id  (cert-manager issues 3 new TLS certs via DNS-01)
  2. Repoint CF DNS A records → 129.212.164.5
  3. Delete the 3 hanzo-id Worker routes
  4. Archive hanzoai/hanzo.id-worker repo (no routes remaining)
2026-05-24 10:12:23 -07:00
hanzo-dev 9b54655329 feat(brand): bundle brand logo SVGs as static assets
The published @hanzo/brand / @luxfi/brand / @zooai/brand / @parsdao/brand
npm tarballs don't ship the assets/ directory (only dist/ + brand.json
per the pkg's 'files' field). The Vite plugin can only emit what's
present on disk; result: 404 on /brand/<pkg>/assets/logo/logo.svg.

Workaround until the brand pkgs republish with assets included: commit
the SVGs into apps/web/public/brand/<pkg>/assets/logo/ so they ship in
the SPA bundle directly. Same path the localizeAssets rewrite produces,
so loadBrand() output is unchanged.

To update: re-copy from ~/work/<org>/brand/assets/logo/ and bump
@hanzo/id patch.
2026-05-24 09:39:59 -07:00
hanzo-dev 17b98ae11c fix(brand): regex now matches @scoped/name pkg URLs
The old regex [^@/]+ excluded the leading @ in @scope/name pkg paths
on jsdelivr, so the rewrite silently passed through to the upstream
CDN URL and the browser rendered a broken-image glyph. Add @? prefix.
2026-05-24 09:32:47 -07:00
hanzo-dev feca00917a fix(brand): serve logo + assets from /brand/<pkg>/, switch to DNS-01 issuer
playwright agent flagged: brand.json works but logo SVG 404s on
jsdelivr because the brand pkgs' npm tarballs don't ship assets/logo/.

Fix at this side instead of waiting on a brand-pkg republish:
  1. vite plugin now serves ALL files under <pkg>/assets/ (not just
     brand.json), at /brand/<pkg>/assets/...
  2. loadBrand() in pkgs/shared rewrites the brand.json logoUrl and
     faviconUrl from /npm/<pkg>@latest/<rest> → /brand/<pkg>/<rest>.
     Only the brand's own pkg URLs rewrite; third-party CDN refs pass
     through unchanged.

Also flip the cert-manager cluster-issuer for the id Ingress from
letsencrypt-prod (HTTP-01) to letsencrypt-prod-cf (DNS-01 via
Cloudflare). The Ingress unconditionally 308-redirects all HTTP→HTTPS
including /.well-known/acme-challenge/*, so HTTP-01 never solves.
DNS-01 via CF API works regardless of HTTP path routing.
2026-05-24 09:17:36 -07:00
hanzo-dev 43371d76f4 fix(vite): brand-json plugin actually emits files at build time
The previous plugin used require.resolve in ESM context which failed
silently — generateBundle had no warning and no assets emitted. Result:
production image had no /brand/<pkg>/brand.json files, hanzoai/spa
fell back to index.html for those paths, and the browser tried to JSON-parse
HTML → 'Unexpected token <' on every host.

Fix: use createRequire(import.meta.url) + paths fallback walk through
node_modules. emitFile now writes dist/brand/<pkg>/brand.json.

Verified by: pnpm build outputs dist/brand/@hanzo/brand/brand.json etc.
playwright report flagged this as the root cause of the white-label
blank page across hanzo.id / lux.id / pars.id.
2026-05-24 08:43:02 -07:00
hanzo-dev 6678b9b9fd fix(docker): switch to hanzoai/spa (zero-config SPA server)
hanzoai/static is for traditional static-file serving (no SPA fallback,
needs --spa flag for client-side routing, /healthz only). hanzoai/spa is
purpose-built for SPAs: SPA mode always on, runtime config via SPA_* env
vars, /health endpoint. K8s probes flip /healthz → /health to match.
2026-05-24 07:46:08 -07:00
hanzo-dev 6748c62e22 fix(docker): static image serves /public on :3000 (not /spa:8080)
hanzoai/static:0.4.1 is hardcoded to read /public and listen on :3000.
PORT/ROOT env vars from the Dockerfile are ignored. Move dist there.

K8s deployment containerPort + probes flipped from 8080 to 3000 to match.
Service targetPort already :3000. Service port 80 stays the same.
2026-05-24 07:12:28 -07:00
hanzo-dev 7e359f279c deps: pin @hanzo/iam to ^0.9.4 (latest published); drop @hanzo/gui (not published yet)
The 0.10.0 / 7.2.4 versions in source are unpublished WIP. Use the latest
public-npm versions so the Docker build can resolve. @hanzo/gui re-adds
once 7.2.x publishes to npm (per hanzoai/gui PR #2 dist/ emit fix).
2026-05-24 06:57:56 -07:00
hanzo-dev 981cce26e3 ci: amd64-only (arm64 ARC pool paused on DOKS) 2026-05-24 06:50:23 -07:00
hanzo-dev 86c961bbaf ci: drop pre-build-command — Dockerfile self-contains pnpm build 2026-05-24 06:38:53 -07:00
hanzo-dev fce00d8549 ci: drop CF Pages deploy (was Next.js-only; Vite SPA ships via Docker) 2026-05-24 06:25:55 -07:00
hanzo-dev 919d261d0a ci: add id-token permission + allow pnpm to generate lockfile
Tag-push of v0.1.0 hit startup_failure because the caller workflow lacked
id-token: write. The hanzoai/.github reusable docker-build.yml requires it
at the caller's top-level (see universe LLM.md, 2026-05-05 sprint notes).

Also drop --frozen-lockfile since this is a fresh rewrite without a
checked-in lockfile yet — let pnpm generate one.
2026-05-24 06:09:28 -07:00
hanzo-devandGitHub 8b85781327 rewrite: Vite + @hanzo/gui monorepo (drops CF Worker + Next.js) (#2)
Replaces the Cloudflare Worker (hanzo.id-worker) and the Next.js portal
with a pnpm monorepo following the a-monorepo/id pattern.

Layout:
  apps/web/        Vite + React 19 SPA, embeds @hanzo/gui shell
    k8s/           Deployment(2) + Service + Ingress (4 hosts, 4 TLS)
  pkgs/shared/     @hanzo/id-shared — TenantConfig, resolveTenant,
                                       loadBrand (browser + node)
  pkgs/auth/       @hanzo/id-auth   — AuthClient (wraps @hanzo/iam REST)
                                       + LoginForm/SignupForm/ForgotForm/OTPForm
  pkgs/idv/        @hanzo/id-idv    — pluggable IDV (stub, persona,
                                       onfido, veriff) behind one
                                       IDVProvider interface
  legacy-nextjs/   Frozen — Next.js predecessor. Delete after v0.1.0 ships.
  Dockerfile       Two-stage: pnpm build → hanzoai/static:0.4.1 serves /spa
  README.md
  LLM.md           Architecture, dev, deploy, cutover plan

Tenant resolution: hostname → TenantConfig (orgId, iamUrl, clientId,
appName, publicOrigin, brandPackage). Built-in defaults for
hanzo.id/lux.id/zoo.id/pars.id; runtime override via
IAM_TENANT_CONFIG_JSON env (served as /config.json at pod startup).

Brand resolution: each per-org brand pkg (@hanzo/brand, @luxfi/brand,
@zooai/brand, @parsdao/brand) ships brand.json. The Vite plugin
brandJsonPlugin emits /brand/<pkg>/brand.json verbatim; the browser
fetches the right one based on the resolved tenant. No bundle bloat.

IDV: stub (default for dev), persona, onfido, veriff. Each adapter
implements `IDVProvider` from pkgs/idv/src/provider.ts. Swap providers
with one registration call at boot — portal code unchanged.

Cutover (separate ops PR — not in this commit):
  1. Tag + push image ghcr.io/hanzoai/id:0.1.0
  2. Apply k8s manifests, cert-manager issues TLS
  3. Remove CF Worker routes for hanzo.id/lux.id/zoo.id/pars.id
  4. CF A records → 129.212.164.5 (hanzo ingress LB)
  5. Archive hanzo.id-worker repo
2026-05-24 06:00:20 -07:00
hanzo-dev 3d2001dab7 rewrite: Vite + @hanzo/gui monorepo (drops CF Worker + Next.js)
Replaces the Cloudflare Worker (hanzo.id-worker) and the Next.js portal
with a pnpm monorepo following the a-monorepo/id pattern.

Layout:
  apps/web/        Vite + React 19 SPA, embeds @hanzo/gui shell
    k8s/           Deployment(2) + Service + Ingress (4 hosts, 4 TLS)
  pkgs/shared/     @hanzo/id-shared — TenantConfig, resolveTenant,
                                       loadBrand (browser + node)
  pkgs/auth/       @hanzo/id-auth   — AuthClient (wraps @hanzo/iam REST)
                                       + LoginForm/SignupForm/ForgotForm/OTPForm
  pkgs/idv/        @hanzo/id-idv    — pluggable IDV (stub, persona,
                                       onfido, veriff) behind one
                                       IDVProvider interface
  legacy-nextjs/   Frozen — Next.js predecessor. Delete after v0.1.0 ships.
  Dockerfile       Two-stage: pnpm build → hanzoai/static:0.4.1 serves /spa
  README.md
  LLM.md           Architecture, dev, deploy, cutover plan

Tenant resolution: hostname → TenantConfig (orgId, iamUrl, clientId,
appName, publicOrigin, brandPackage). Built-in defaults for
hanzo.id/lux.id/zoo.id/pars.id; runtime override via
IAM_TENANT_CONFIG_JSON env (served as /config.json at pod startup).

Brand resolution: each per-org brand pkg (@hanzo/brand, @luxfi/brand,
@zooai/brand, @parsdao/brand) ships brand.json. The Vite plugin
brandJsonPlugin emits /brand/<pkg>/brand.json verbatim; the browser
fetches the right one based on the resolved tenant. No bundle bloat.

IDV: stub (default for dev), persona, onfido, veriff. Each adapter
implements `IDVProvider` from pkgs/idv/src/provider.ts. Swap providers
with one registration call at boot — portal code unchanged.

Cutover (separate ops PR — not in this commit):
  1. Tag + push image ghcr.io/hanzoai/id:0.1.0
  2. Apply k8s manifests, cert-manager issues TLS
  3. Remove CF Worker routes for hanzo.id/lux.id/zoo.id/pars.id
  4. CF A records → 129.212.164.5 (hanzo ingress LB)
  5. Archive hanzo.id-worker repo
2026-05-24 01:50:39 -07:00
hanzo-dev 3bdccc0095 fix(middleware): proxy /v1/iam/* canonical IAM surface
Bug: hanzo.id returned 405 on POST /v1/iam/login because:

1. The matcher had no /v1/iam/:path* rule — the middleware never fired,
   the static SPA had no POST handler at that path, CF Pages returned
   405 directly.
2. IAM_PATH_PREFIXES carried '/api/' but not '/v1/iam/' — even if the
   matcher did fire, shouldProxyToIAM() returned false.
3. PATH_REWRITES mapped RFC OAuth paths (/oauth/token, /oauth/userinfo,
   …) onto legacy /api/* targets — wrong direction; IAM serves /v1/iam/*
   natively.

Fix: drop the entire /api/* hop. RFC aliases now collapse onto canonical
/v1/iam/* targets (one-way mapping, no legacy detour). The matcher lists
/v1/iam/:path*. SPA components, lib/oauth.ts, and the server-side logout
handler all call /v1/iam/* directly. The discovery rewriter strips the
canonical /v1/iam/* form back to RFC-public /oauth/* for OIDC clients.

Net: one canonical surface (/v1/iam/*), three RFC-spec public aliases
(/oauth/*, /login/oauth/*, /.well-known/*). No /api/* anywhere in the
caller path.
2026-05-15 14:35:21 -07:00
hanzo-devandGitHub a8a6ad353d ci: migrate to canonical hanzoai/.github/docker-build.yml reusable (#1) 2026-04-23 18:58:37 -07:00
hanzo-dev dddac31dc5 ci: migrate to canonical hanzoai/.github/docker-build.yml reusable 2026-04-23 18:57:29 -07:00
hanzo-dev 4578efbfc5 feat: add id.lux.cloud as Lux tenant (was defaulting to Hanzo) 2026-04-20 21:37:05 -07:00
hanzo-dev d4eaeabedb refactor: flatten id-{dev,test}.hanzo.ai so *.hanzo.ai Universal SSL covers them 2026-04-20 21:31:45 -07:00
hanzo-dev 64de2b7441 feat: zoolabs.id as canonical Zoo tenant (replaces zoo.id)
zoolabs.id was just acquired to replace zoo.id which we no longer own.
Same Zoo branding + content + socialProviders as id.zoo.network.
2026-04-20 19:45:15 -07:00
hanzo-dev b6748bf8f5 feat(branding): TENANT_BRANDING_JSON env var for runtime white-labeling
Allows any deployment of hanzo-login image to add/override tenants without
white-label deployment to inject their own domain branding + auth providers.

Example:
  env:
    - name: TENANT_BRANDING_JSON
      value: |
        {
            "orgId": "liquidity",
            "orgName": "",
            "content": { "title": "Trade digital securities" },
            "auth": { "socialProviders": ["google", "apple"] }
          }
        }
2026-04-20 19:35:09 -07:00
hanzo-dev bc2106db08 remove: zoo.id tenant — domain no longer owned; use id.zoo.network instead
Zoo Labs ecosystem logins go through id.zoo.network / id.zoo-dev.network / id.zoo-test.network.
zoo.id was removed from TENANTS map, staticBranding, and the hanzo-login ingress.
2026-04-20 19:34:02 -07:00
hanzo-dev 0e2f42256f feat(branding): add id.{hanzo,hanzo-dev,hanzo-test,zoo-dev,zoo-test}.network tenants
Match the {brand}-{env}.network naming scheme. Each env gets its own
subtitle + title so login flow reflects which network the user is on.
2026-04-20 19:26:19 -07:00
hanzo-dev 4d0f06ee34 ci: use ghcr.io/hanzoai/hanzo-login (new package) 2026-04-20 19:15:03 -07:00
hanzo-dev 3f2ceaf839 ci: push to ghcr.io/hanzoai/login (avoid conflict with existing ghcr.io/hanzoai/id) 2026-04-20 19:12:15 -07:00
hanzo-dev c525074995 feat(branding): add dev/test tenants for lux-dev/test.network + dev/test.hanzo.ai 2026-04-20 19:11:37 -07:00
hanzo-dev ffdf3eab1f ci: add docker workflow for ghcr.io/hanzoai/id image 2026-04-20 19:07:51 -07:00
hanzo-dev 1d3b81bd92 feat(login): full marketing panel for lux/zoo + apple social provider
- Add Apple OAuth button (rendered when branding.auth.socialProviders contains 'apple')
- Lux tenant: custom tagline 'Lux-powered infrastructure', title 'Start deploying in seconds'
- Zoo tenant: custom tagline 'Open AI research network', title 'Build the future of DeAI'
- Pars tenant: gets its own content structure (ready for extension)
- All tenants explicitly declare auth feature flags (password/code/webauthn/faceid/social)

Tenants that want apple instead of github: set socialProviders: ['metamask','google','apple']
via IAM /api/branding response — hanzo/id never hardcodes /liquidity.
2026-04-20 19:06:31 -07:00
hanzo-dev 8462c5a4c8 chore: symlink AGENTS.md and CLAUDE.md to LLM.md
Canonical project context lives in LLM.md. Symlinks ensure
agentic coding tools (agent, Cursor, etc.) find context
automatically regardless of which filename they look for.
2026-04-01 14:37:38 -07:00
Darkhorse7starsandhanzo-dev cc64e11712 fix(signup): add username field to IAM signup request
Casdoor's /api/signup expects a `username` field. Without it, the username
defaults to empty string which fails the "at least 2 characters" validation.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-04-01 12:06:37 -05:00
Darkhorse7starsandhanzo-dev d1810354f6 fix: use hanzo-id as IAM application name
IAM enforces that app names start with the org prefix (hanzo-).
The old name 'app-hanzo' fails validation. Changed to 'hanzo-id'
which matches the IAM application entry and passes the prefix check.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-04-01 11:51:09 -05:00
hanzo-dev 8f0ee95754 fix: pass PKCE code_challenge in POST body (Casdoor reads from body, not query) 2026-03-25 21:47:20 -07:00
hanzo-dev 9655226fa6 feat: referral code bridging — capture ?ref= on signup/login, claim on callback 2026-03-22 19:09:56 -07:00
hanzo-dev 9b3b5c8238 security: gate localhost OAuth origins behind NODE_ENV !== production 2026-03-21 01:19:23 -07:00
hanzo-dev fbc3b1bf42 rebrand: 'Hanzo AI' → 'Hanzo' — Hanzo ID is universal, not AI-specific 2026-03-20 17:41:29 -07:00
hanzo-dev 77f8d6e17d fix: add permanentAvatar to UserInfo type — unbreaks CF Pages build
The TypeScript error on permanentAvatar caused the build to fail
silently (swallowed by || true), deploying zero functions and 404'ing
the entire site including /login and /account.

Also adds post-build verification to catch future silent build failures.
2026-03-11 18:38:22 -07:00
hanzo-dev fff4b8f38f security: fix critical OAuth redirect and cross-tenant vulnerabilities
- Add redirect URI allowlist validation to social-callback handler
  (was unvalidated open redirect — CRITICAL)
- Enforce org resolution from client map over untrusted state/cookie
  sources to prevent cross-tenant bypass (CRITICAL)
- Reject malformed/oversized state parameters
- Remove sensitive debug info from error redirects
- Add PKCE code_verifier forwarding in bridge handler
- Expand ALLOWED_ORIGINS to include billing, hanzo.id, lux/zoo/pars.id
2026-03-11 18:29:48 -07:00
hanzo-dev 22f1eb3dd1 fix(ci): use apiToken instead of CLOUDFLARE_API_KEY for wrangler
The wrangler-action needs apiToken input for API Token auth.
CLOUDFLARE_API_KEY env var is for Global API Key auth which
requires CLOUDFLARE_EMAIL — not what KMS provides.
2026-03-11 18:01:44 -07:00
hanzo-dev 171ba8cb18 fix: resolve user profile display on account page
- Use same-origin /oauth/userinfo proxy to avoid CORS
- Prefer id_token claims for displayName, email, avatar
- Store id_token in localStorage from both PKCE and passthrough flows
- Fetch /oauth/userinfo after password login for full profile
2026-03-11 17:40:13 -07:00
hanzo-dev 1cd915a4e9 fix(ci): use v3 KMS API with workspaceId and Universal Auth
- environment=production → environment=prod (matches KMS config)
- v1/secrets/raw → v3/secrets/raw with workspaceId
- Added KMS_CLIENT_ID/SECRET Universal Auth with HANZO_API_KEY fallback
2026-03-11 16:00:58 -07:00
hanzo-dev 7e8c9c4084 ci: migrate CF Pages deploy to HANZO_API_KEY + KMS 2026-03-11 14:50:51 -07:00
hanzo-dev 94d53d7f17 docs: add LLM.md project guide 2026-03-11 10:31:35 -07:00
hanzo-dev 5d44b6a246 fix(middleware): fall through to login UI when IAM doesn't auto-authorize
When proxying /oauth/authorize with a session cookie, IAM may return
200 (its built-in login page) instead of a 3xx redirect. This happens
when the session isn't valid for the requested application. In this
case, fall through to our own login UI instead of proxying IAM's page.
2026-03-10 20:18:46 -07:00
hanzo-dev 0d506cc23b fix(oauth): revert to direct code grant flow for PKCE login
The two-step approach (authenticate then redirect to /oauth/authorize)
doesn't work because IAM doesn't auto-authorize from session cookie.
Revert to the proven direct approach: POST to /api/login with
code_challenge as query param, get authorization code directly,
redirect to client callback with code+state.
2026-03-10 19:21:49 -07:00
hanzo-dev fafbf9fe9d fix(oauth): two-step login for proper PKCE support
Step 1: POST /api/login with type=login to authenticate (set session)
Step 2: Redirect to /oauth/authorize which middleware now proxies to IAM
when session cookie exists, producing a properly PKCE-bound auth code.

Previously, LoginForm tried to get the code directly from /api/login,
which didn't bind the code_challenge, causing PKCE verification failures
at the client's token exchange.
2026-03-10 19:11:09 -07:00
hanzo-dev cdb960ffa9 feat(i18n): expand language dropdown to 71 languages
Cover all major world languages including South/Southeast Asian,
African, Central Asian, Baltic, Nordic, and Caucasian languages.
2026-03-10 18:58:26 -07:00
hanzo-dev 6f1868e3cf fix(login): use middleware proxy instead of cross-origin IAM fetch
Root cause: LoginForm.tsx constructed absolute URLs to iam.hanzo.ai for
the /api/login POST, which fails due to CORS in browser. Now uses
relative URLs (/api/login) so requests go through the Next.js middleware
proxy.

Also:
- Add CLIENT_APP_MAP fallback for appName resolution (prevents sending
  raw clientId like "hanzo-console-client-id" when /api/get-app-login
  fails silently)
- Add LanguageDropdown component (wired globe icon to actual lang picker)
2026-03-10 17:36:01 -07:00
hanzo-dev b31552bb49 fix(branding): monochrome Hanzo theme + H logo mark
Replace red (#ef4444) accent with monochrome zinc-200 (#e4e4e7) across
all Hanzo domain branding configs. Update logo SVG from text-only
wordmark to geometric H mark + wordmark. Fix hardcoded red box-shadow
in input focus state.
2026-03-10 17:23:17 -07:00
Darkhorse7starsandhanzo-dev b29d16334c fix: extract app name from IAM response regardless of status
IAM's get-app-login API returns the app data (including name) even
when the redirect URI validation fails (status: "error"). Previously
we only extracted appName when status was "ok", causing the login to
fall back to using the raw clientId as the application name — which
IAM rejects with "does not exist".

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-03-10 17:18:31 -05:00
Darkhorse7starsandhanzo-dev 8a9485b7ba fix(ci): use Global API Key auth for Cloudflare Pages deploy
Switch from apiToken to CLOUDFLARE_API_KEY + CLOUDFLARE_EMAIL env vars
for wrangler-action, since we're using a Global API Key instead of a
scoped API Token.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-03-10 16:05:07 -05:00
Darkhorse7starsandhanzo-dev 5fc7cbe72d fix(ci): use npm packageManager for wrangler-action
The wrangler-action auto-detects pnpm from pnpm-lock.yaml and fails
when trying to install wrangler via pnpm. Force npm to avoid this.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-03-10 09:18:07 -05:00
hanzo-dev ed2445da6d feat: add multi-tenant login with org logos and IAM integration 2026-03-09 23:27:37 -07:00
hanzo-dev fa14c7e0ca fix: update Lux brand color from orange to zinc-200
Change lux.id primary color from #f97316 (orange) to #e4e4e7 (zinc-200)
with dark text (#09090b) for a clean, professional blockchain brand look.
2026-03-09 23:27:37 -07:00
Darkhorse7starsandhanzo-dev 66a21facf8 ci: add GitHub Actions workflow for Cloudflare Pages auto-deploy
Triggers on push to main and manual dispatch. Requires
CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID secrets.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-03-10 00:11:25 -05:00
Darkhorse7starsandhanzo-dev f6fa601c9d fix: resolve IAM app name from clientId and use camelCase query params
The login and signup forms were sending the OAuth client_id as the
`application` field to IAM's /api/login, but IAM expects the
application NAME (e.g. "app-hanzobot" not "hanzobot-client-id").
Additionally, IAM reads query params using camelCase keys (clientId,
responseType, redirectUri) but the forms were sending snake_case.

Changes:
- LoginForm/SignUpForm: resolve app name via get-app-login on mount
- Use camelCase query param keys matching IAM convention
- Include clientId/redirectUri in POST body as fallback
- Forward PKCE code_challenge params through login endpoint

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-03-09 23:59:21 -05:00
107 changed files with 15428 additions and 6770 deletions
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="640" viewBox="0 0 1280 640" role="img" aria-label="id">
<rect width="1280" height="640" fill="#0A0A0A"/>
<svg x="96" y="215" width="210" height="210" viewBox="0 0 67 67"><path d="M22.21 67V44.6369H0V67H22.21Z" fill="#fff"/><path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="#fff"/><path d="M22.21 0H0V22.3184H22.21V0Z" fill="#fff"/><path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="#fff"/><path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="#fff"/></svg>
<text x="378" y="276" font-family="Inter,system-ui,-apple-system,sans-serif" font-size="78" font-weight="800" letter-spacing="-2" fill="#ffffff">id</text>
<text x="378" y="322" font-family="Inter,system-ui,sans-serif" font-size="30" fill="#ffffff" opacity=".66">Hosted login pages for Hanzo IAM - configurable per organization</text>
<rect x="378" y="338" width="806" height="3" rx="1.5" fill="#ffffff" opacity=".9"/>
<text x="378" y="390" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">github.com/hanzoai</text>
<text x="1184" y="390" text-anchor="end" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">hanzo.ai</text>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

-19
View File
@@ -1,19 +0,0 @@
name: Docker
on:
workflow_dispatch:
push:
branches: [main, dev, test]
tags: ['v*']
permissions:
contents: read
packages: write
jobs:
docker:
uses: hanzoai/.github/.github/workflows/docker-build.yml@main
with:
image: ghcr.io/hanzoai/id
pre-build-command: |
corepack enable
pnpm install --frozen-lockfile
pnpm build
secrets: inherit
-7
View File
@@ -1,7 +0,0 @@
name: Workflow Sanity
on:
pull_request:
paths: ['.github/workflows/**']
jobs:
sanity:
uses: hanzoai/.github/.github/workflows/workflow-sanity.yml@main
+50
View File
@@ -0,0 +1,50 @@
name: CI/CD
# The caller, and deliberately nothing else: triggers plus the import. All real
# config is the repo-root hanzo.yml, which platform.hanzo.ai reads too.
#
# `.hanzo/workflows`, NOT `.github/workflows`, and the difference is whether
# anything runs at all:
#
# * github.com has ZERO self-hosted runners for this org
# (/orgs/hanzoai/actions/runners -> total_count 0), so a job asking for
# `hanzo-build-linux-amd64` there is never claimed. An unclaimable job does
# not fail — it waits out the 24h timeout while the next push queues behind
# it. Silence, not an error.
# * The `git-runner` StatefulSet registers against the FORGE only
# (GIT_INSTANCE_URL=http://hanzo-git.hanzo.svc), and that is the pool which
# actually advertises that label.
# * Gitea collects workflows from the FIRST of WORKFLOW_DIRS present in the
# commit (modules/actions/workflows.go, listWorkflowsInDirs breaks on the
# first hit). `.hanzo/workflows` already existed here, so on this forge the
# whole of `.github/workflows` was already dark — including the Docker lane
# that used to live there.
#
# The `uses:` path points at .hanzo/workflows for the same reason: reusables
# resolve through services/actions.ResolveUses, which enforces the WORKFLOW_DIRS
# allowlist on the referenced path too. hanzoai/ci publishes build.yml at both
# paths from the same tag, byte-identical apart from the path each names for
# itself, so this is the same pipeline at the same @v1.
#
# NO `paths-ignore`. deploy.yml carried one so that a docs commit would not trip
# its "this version already exists" refusal — a guard this lane does not need,
# because it derives the next patch instead of going red. Dropping the filter
# means the GATES run on every commit to main, including the commits that only
# touch a workflow. A change to CI that breaks CI should be caught by CI.
on:
push:
branches: [main]
# A `v*` tag is a RELEASE and publishes an image named after it, verbatim.
tags: ['v*']
pull_request:
workflow_dispatch:
concurrency:
group: cicd-${{ github.ref }}
cancel-in-progress: true
jobs:
cicd:
uses: hanzoai/ci/.hanzo/workflows/build.yml@v1
secrets: inherit
+67
View File
@@ -0,0 +1,67 @@
name: Sync from GitHub
# git.hanzo.ai is CANONICAL and builds natively; development also lands on
# github.com/hanzoai/id. Together with the push-mirror going the other way
# (native -> GitHub, sync_on_commit) this is the full bidirectional loop.
#
# The two compose rather than fight: a native commit reaches GitHub via the
# push-mirror, so this job then sees LOCAL == REMOTE and exits "in sync". A
# GitHub commit fast-forwards native here, and the resulting push-mirror is a
# no-op because GitHub already has it. No echo, no loop.
#
# ONE deterministic direction per job: an in-cluster PULL. The runner reaches
# both ends (GitHub outbound, this forge via the instance URL actions/checkout
# already uses), so the sync has no ingress dependency.
#
# Fast-forward ONLY. A divergence fails LOUDLY here rather than force-pushing
# either side and destroying whichever history lost the race.
on:
schedule:
- cron: '*/10 * * * *'
workflow_dispatch: {}
concurrency:
group: sync-from-github
cancel-in-progress: false
jobs:
ff-main:
runs-on: [hanzo-build-linux-amd64]
steps:
- name: Checkout main (full history for the ancestry check)
uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: true
- name: Fast-forward main from github.com/hanzoai/id
env:
GH_PAT: ${{ secrets.GH_PAT }}
run: |
set -euo pipefail
git fetch --quiet "https://x-access-token:${GH_PAT}@github.com/hanzoai/id.git" main
LOCAL="$(git rev-parse HEAD)"
REMOTE="$(git rev-parse FETCH_HEAD)"
if [ "$LOCAL" = "$REMOTE" ]; then
echo "in sync at $LOCAL"
exit 0
fi
if git merge-base --is-ancestor "$LOCAL" "$REMOTE"; then
echo "fast-forwarding $LOCAL -> $REMOTE"
git push origin "$REMOTE:refs/heads/main"
# A push made with the workflow token does NOT trigger other workflows
# (loop prevention), so synced commits would never build. Dispatch it
# explicitly — a real fast-forward means real commits arrived.
#
# This names the workflow BY FILENAME, so it is a hard reference to a
# file in this repo and moves when that file does. It pointed at
# deploy.yml, which no longer exists; the `|| echo` below makes that a
# non-fatal 404, 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.
curl -fsS --max-time 20 -X POST \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Content-Type: application/json" \
"${{ github.server_url }}/v1/repos/${{ github.repository }}/actions/workflows/cicd.yml/dispatches" \
-d '{"ref":"main"}' || echo "build dispatch failed (non-fatal)"
else
echo "DIVERGED: native $LOCAL is not an ancestor of GitHub $REMOTE." >&2
echo "Resolve by hand; this job will not force-push either side." >&2
exit 1
fi
+69 -4
View File
@@ -5,16 +5,81 @@ WORKDIR /build
ENV PNPM_HOME=/pnpm PATH=$PNPM_HOME:$PATH
RUN corepack enable && corepack prepare pnpm@10.15.0 --activate
COPY pnpm-workspace.yaml package.json tsconfig.base.json ./
# THE LOCKFILE SHIPS, and the install is frozen to it.
#
# This used to omit pnpm-lock.yaml and run `--frozen-lockfile=false`, so the
# image resolved the whole tree FRESH on every build while `pnpm test` on the
# runner resolved it from the lockfile. Two different dependency graphs from one
# commit: the tested one, and the shipped one. It went green for as long as free
# resolution happened to agree, and stopped the moment it did not — adding one
# dependency (@hanzo/event) moved vite from the lockfile's
# 7.3.5_@types+node@25.9.3_… to 7.3.6_@types+node@22.20.1 and the build died on
# `Cannot find module '/build/apps/web/node_modules/vite/bin/vite.js'`. Nothing
# was wrong with the source: the same commit builds cleanly when installed from
# the lockfile.
#
# A resolver free to drift ships a bundle no one has run. Frozen, the image gets
# the exact tree the tests passed against, and a lockfile that has gone stale
# fails HERE — loudly, naming the mismatch — instead of silently building
# something else.
#
# EVERY workspace member's package.json must be present before a frozen install:
# pnpm validates the lockfile against all of them and refuses if one is missing.
# apps/account is not built into this image, but it IS in the workspace, so its
# manifest is required for the check to pass.
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json ./
COPY apps/web/package.json apps/web/
COPY apps/account/package.json apps/account/
COPY pkgs/shared/package.json pkgs/shared/
COPY pkgs/auth/package.json pkgs/auth/
COPY pkgs/connect/package.json pkgs/connect/
COPY pkgs/idv/package.json pkgs/idv/
RUN pnpm install --frozen-lockfile=false
COPY pkgs/onboarding/package.json pkgs/onboarding/
RUN pnpm install --frozen-lockfile
COPY apps apps
COPY pkgs pkgs
RUN pnpm --filter @hanzo/id-web build
# Publishable event-ingest key (pk-live-…), inlined by Vite into the bundle.
#
# EVENT_INGEST_KEY is the name in KMS (org `hanzo`, path `deploy`, env `prod`)
# and on the --build-arg; the VITE_ prefix is what makes Vite inline it, and it
# is a property of THIS build, so it is applied here and the secret store keeps
# the ONE plain name.
#
# Publishable and write-only by design — it authorizes a write into one org and
# can read nothing — so shipping it in a bundle is the documented use. It is
# still a credential: it comes from KMS via CI. Never commit a value here.
#
# Deliberately NO default. An absent key is not a degraded mode: cloud takes the
# unkeyed beacon down the anonymous lane, files every row under the `$public`
# tenant this org cannot read, and answers 200 — so a keyless build looks
# healthy from the page and reports nothing to the warehouse. hanzo.id ran that
# way with no telemetry at all, which is the failure this build gate exists to
# make loud.
ARG EVENT_INGEST_KEY
ENV VITE_EVENT_INGEST_KEY=$EVENT_INGEST_KEY
# Fail closed, and gate HERE because this is the one path every builder passes
# through — a guard in a workflow protects that lane only.
RUN case "$EVENT_INGEST_KEY" in \
pk-*) : ;; \
'') echo "EVENT_INGEST_KEY is empty - pass --build-arg EVENT_INGEST_KEY=<pk-...> (KMS deploy/EVENT_INGEST_KEY, env prod)" >&2; exit 1 ;; \
*) echo "EVENT_INGEST_KEY is not a publishable key (expected a pk- prefix)" >&2; exit 1 ;; \
esac
# Do NOT re-declare ARG VITE_EVENT_INGEST_KEY below this line. A later ARG of the
# same name shadows the ENV set above with an empty default, so the key resolves,
# passes the gate, and is then blanked before Vite inlines it — every step green,
# the bundle unattributed. That is exactly how hanzo.chat 1.0.58 shipped.
#
# `&&`, not `;`: with `;` the RUN exits with the status of the LAST command and a
# failed build would be masked. Assert on the bytes that actually ship — a key
# present in the environment and absent from the bundle is indistinguishable from
# success everywhere except the warehouse, where the traffic simply stops being
# attributable.
RUN pnpm --filter @hanzo/id-web build && \
{ grep -rqF "$VITE_EVENT_INGEST_KEY" apps/web/dist || \
{ echo "ERROR: the ingest key is not in apps/web/dist - hanzo.id would ship unattributed" >&2; exit 1; }; }
# SPA server stage — hanzoai/spa is the correct base for a Vite SPA:
# history-API fallthrough for client-side routes AND a SPA-safe CSP.
@@ -22,6 +87,6 @@ RUN pnpm --filter @hanzo/id-web build
# (built for static assets, not an app that loads its own bundle), which
# blocks the SPA's own scripts and leaves a blank page. hanzoai/spa serves
# index.html for all routes with a sane CSP. Defaults: PORT=3000, ROOT=/public.
FROM ghcr.io/hanzoai/spa:1.2.0
FROM ghcr.io/hanzoai/spa:1.4.8
COPY --from=build /build/apps/web/dist /public
EXPOSE 3000
+1019 -8
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -1,3 +1,5 @@
<p align="center"><img src=".github/hero.svg" alt="id" width="880"></p>
# @hanzo/id
White-label login + identity verification portal. One Vite SPA, four hosts
+15
View File
@@ -0,0 +1,15 @@
# @hanzo/id-account
The account portal: branded account-management pages at `account.hanzo.id`,
`account.lux.id`, … served by a Cloudflare Worker, authenticated with IAM access
tokens over the OAuth code exchange.
It lives HERE, in `hanzoai/id`, because it is identity UI — the same brands, the
same IAM, the same login redirect as `apps/web`. It used to live in
`hanzoai/account`, which is a Go module: one repo held two unrelated codebases
under one name (`main` was this Worker, the Go module survived only on a `go`
branch and its tags). That collision is what made
`github.com/hanzoai/account` unresolvable as a Go module from a clean checkout.
`hanzoai/account` is now the Go module and nothing else — see its README for the
billing-account rule it owns.
+13
View File
@@ -0,0 +1,13 @@
{
"name": "@hanzo/id-account",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy"
},
"devDependencies": {
"wrangler": "^4.65.0"
},
"description": "Branded account-management pages for account.<brand>.id \u2014 a Cloudflare Worker."
}
+803
View File
@@ -0,0 +1,803 @@
/**
* Account Portal — Cloudflare Worker
*
* Serves branded account management pages for:
* - account.hanzo.id (Hanzo brand)
* - account.lux.id (Lux brand)
*
* Authentication: Uses IAM access tokens via OAuth code exchange.
* Users are redirected to their brand's login page if unauthenticated.
*/
const IAM_ORIGIN = 'https://iam.hanzo.ai';
// Brand configuration keyed by hostname
const BRANDS = {
'account.hanzo.id': {
name: 'Hanzo',
domain: 'hanzo.id',
loginUrl: 'https://hanzo.id/login',
bg: '#0a0a0a',
surface: '#111111',
border: '#222222',
accent: '#fd4444',
clientId: 'hanzo-app-client-id',
logo: `<svg viewBox="0 0 100 100" width="40" height="40" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M50 5L90 27.5V72.5L50 95L10 72.5V27.5L50 5Z" stroke="#fd4444" stroke-width="4"/>
<path d="M30 35V65M70 35V65M30 50H70" stroke="#fd4444" stroke-width="4" stroke-linecap="round"/>
</svg>`,
},
'account.lux.id': {
name: 'Lux',
domain: 'lux.id',
loginUrl: 'https://lux.id/login',
bg: '#050508',
surface: '#0c0c10',
border: '#222222',
accent: '#ffffff',
clientId: 'lux-app-client-id',
logo: `<svg viewBox="0 0 100 100" width="40" height="40" fill="none" xmlns="http://www.w3.org/2000/svg">
<polygon points="50,10 90,75 10,75" stroke="white" stroke-width="3" fill="none"/>
<polygon points="50,30 72,68 28,68" stroke="white" stroke-width="2" fill="none"/>
</svg>`,
},
};
function getBrand(hostname) {
return BRANDS[hostname] || BRANDS['account.hanzo.id'];
}
function htmlResponse(html) {
return new Response(html, {
headers: {
'content-type': 'text/html;charset=UTF-8',
'cache-control': 'no-store',
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
},
});
}
// Parse access token from cookie
function getToken(request) {
const cookie = request.headers.get('Cookie') || '';
const match = cookie.match(/account_token=([^;]+)/);
return match ? decodeURIComponent(match[1]) : null;
}
// Fetch user info from IAM using token
async function getUserInfo(token) {
const res = await fetch(`${IAM_ORIGIN}/v1/iam/userinfo`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) return null;
const data = await res.json();
return data;
}
// Fetch full user object for editing
async function getUser(token, owner, name) {
const res = await fetch(`${IAM_ORIGIN}/v1/iam/get-user?id=${encodeURIComponent(owner)}/${encodeURIComponent(name)}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) return null;
const data = await res.json();
return data.data || null;
}
// Build the OAuth login URL for the brand. Points at the brand's branded
// /login page (front-door worker) with the OAuth params; that page renders
// the two-pane login and emits the canonical /v1/iam/* calls itself — no
// bare /oauth/authorize, no host leak to iam.hanzo.ai.
function buildLoginUrl(brand, callbackUrl) {
const params = new URLSearchParams({
client_id: brand.clientId,
redirect_uri: callbackUrl,
response_type: 'code',
scope: 'openid profile email',
state: 'account',
});
return `${brand.loginUrl}?${params.toString()}`;
}
// Exchange authorization code for access token
async function exchangeCode(code, callbackUrl, brand) {
const res = await fetch(`${IAM_ORIGIN}/v1/iam/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: callbackUrl,
client_id: brand.clientId,
}).toString(),
});
if (!res.ok) return null;
const data = await res.json();
return data.access_token || null;
}
function renderAccountPage(brand, user, fullUser) {
const providers = [];
// Extract linked providers from user object
const providerFields = ['github', 'google', 'facebook', 'twitter', 'linkedin', 'discord', 'wechat', 'dingtalk'];
if (fullUser) {
for (const p of providerFields) {
if (fullUser[p] && fullUser[p] !== '') {
providers.push({ name: p, id: fullUser[p] });
}
}
// Check for MetaMask/Web3 wallet
if (fullUser.metamask && fullUser.metamask !== '') {
providers.push({ name: 'web3', id: fullUser.metamask });
} else if (fullUser.web3onboard && fullUser.web3onboard !== '') {
providers.push({ name: 'web3', id: fullUser.web3onboard });
}
}
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Account - ${brand.name}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: ${brand.bg};
color: #fff;
min-height: 100vh;
}
.topbar {
border-bottom: 1px solid ${brand.border};
padding: 0.75rem 1.5rem;
display: flex;
align-items: center;
justify-content: space-between;
background: ${brand.surface};
}
.topbar-brand {
display: flex;
align-items: center;
gap: 0.75rem;
font-weight: 600;
font-size: 1.1rem;
}
.topbar-actions { display: flex; gap: 0.75rem; align-items: center; }
.topbar-actions a {
color: #999;
text-decoration: none;
font-size: 0.85rem;
padding: 0.4rem 0.8rem;
border-radius: 6px;
border: 1px solid ${brand.border};
}
.topbar-actions a:hover { color: #fff; border-color: #555; }
.topbar-actions .logout { color: #ff6b6b; border-color: #ff6b6b33; }
.topbar-actions .logout:hover { background: #ff6b6b11; }
.container {
max-width: 800px;
margin: 2rem auto;
padding: 0 1.5rem;
}
.section {
background: ${brand.surface};
border: 1px solid ${brand.border};
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.section h2 {
font-size: 1.1rem;
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid ${brand.border};
}
.field {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.6rem 0;
}
.field + .field { border-top: 1px solid ${brand.border}22; }
.field-label { color: #888; font-size: 0.85rem; min-width: 120px; }
.field-value { font-size: 0.92rem; }
.field-action {
color: ${brand.accent};
text-decoration: none;
font-size: 0.82rem;
cursor: pointer;
background: none;
border: 1px solid ${brand.accent}44;
padding: 0.3rem 0.6rem;
border-radius: 6px;
}
.field-action:hover { background: ${brand.accent}11; }
.provider-list { display: flex; flex-direction: column; gap: 0.5rem; }
.provider-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.6rem 0.8rem;
border: 1px solid ${brand.border};
border-radius: 8px;
background: ${brand.bg};
}
.provider-item .name {
text-transform: capitalize;
font-weight: 500;
}
.provider-item .id {
color: #888;
font-size: 0.8rem;
margin-left: 0.5rem;
}
.provider-item .unlink {
color: #ff6b6b;
font-size: 0.78rem;
cursor: pointer;
background: none;
border: 1px solid #ff6b6b33;
padding: 0.2rem 0.5rem;
border-radius: 4px;
}
.add-provider {
display: flex;
gap: 0.5rem;
margin-top: 0.75rem;
flex-wrap: wrap;
}
.add-btn {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.8rem;
border: 1px dashed ${brand.border};
border-radius: 8px;
color: #999;
font-size: 0.82rem;
cursor: pointer;
background: none;
text-decoration: none;
}
.add-btn:hover { border-color: #555; color: #fff; }
.badge {
display: inline-block;
padding: 0.15rem 0.45rem;
border-radius: 4px;
font-size: 0.72rem;
font-weight: 500;
background: #1a472a;
color: #6ee7b7;
margin-left: 0.4rem;
}
.badge.unverified { background: #472a1a; color: #e7b76e; }
.danger-zone {
border-color: #ff6b6b33;
}
.danger-zone h2 { color: #ff6b6b; }
.msg {
display: none;
padding: 0.75rem;
border-radius: 8px;
margin-bottom: 1rem;
font-size: 0.85rem;
}
.msg.success { background: #1a472a; color: #6ee7b7; display: block; }
.msg.error { background: #472a1a; color: #e7b76e; display: block; }
.avatar-row {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.6rem 0;
}
.avatar {
width: 64px;
height: 64px;
border-radius: 50%;
background: ${brand.border};
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
font-weight: 700;
color: #fff;
overflow: hidden;
}
.avatar img { width: 100%; height: 100%; object-fit: cover; }
.modal-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.7);
z-index: 100;
align-items: center;
justify-content: center;
}
.modal-overlay.active { display: flex; }
.modal {
background: ${brand.surface};
border: 1px solid ${brand.border};
border-radius: 12px;
padding: 1.5rem;
width: 100%;
max-width: 420px;
}
.modal h3 { margin-bottom: 1rem; }
.modal input {
width: 100%;
border-radius: 8px;
border: 1px solid ${brand.border};
background: ${brand.bg};
color: #fff;
padding: 0.6rem 0.8rem;
font-size: 0.9rem;
margin-bottom: 0.75rem;
}
.modal-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-top: 0.75rem;
}
.modal-actions button {
padding: 0.5rem 1rem;
border-radius: 6px;
border: 1px solid ${brand.border};
background: none;
color: #fff;
cursor: pointer;
font-size: 0.85rem;
}
.modal-actions .primary {
background: ${brand.accent};
color: ${brand.accent === '#ffffff' ? '#111' : '#fff'};
border-color: ${brand.accent};
}
@media (max-width: 640px) {
.container { padding: 0 1rem; margin: 1rem auto; }
.section { padding: 1rem; }
.field { flex-direction: column; align-items: flex-start; gap: 0.3rem; }
}
</style>
</head>
<body>
<nav class="topbar">
<div class="topbar-brand">
${brand.logo}
<span>${brand.name} Account</span>
</div>
<div class="topbar-actions">
<a href="https://${brand.domain}">Back to ${brand.name}</a>
<a href="https://${brand.domain}/logout" class="logout">Sign Out</a>
</div>
</nav>
<div class="container">
<div id="msg" class="msg"></div>
<div class="section">
<h2>Profile</h2>
<div class="avatar-row">
<div class="avatar">
${user.picture ? `<img src="${user.picture}" alt="Avatar">` : (user.name || 'U').charAt(0).toUpperCase()}
</div>
<div>
<div style="font-weight:600;font-size:1.1rem;">${user.preferred_username || user.name || 'User'}</div>
<div style="color:#888;font-size:0.85rem;">${user.email || ''}</div>
</div>
</div>
<div class="field">
<span class="field-label">Display Name</span>
<span class="field-value">${user.name || '—'}</span>
<button class="field-action" onclick="editField('name','${(user.name || '').replace(/'/g, "\\'")}')">Edit</button>
</div>
<div class="field">
<span class="field-label">Email</span>
<span class="field-value">
${user.email || '—'}
${user.email_verified ? '<span class="badge">Verified</span>' : '<span class="badge unverified">Unverified</span>'}
</span>
</div>
<div class="field">
<span class="field-label">Phone</span>
<span class="field-value">${user.phone || '—'}</span>
</div>
</div>
<div class="section">
<h2>Login Methods</h2>
<div class="provider-list">
<div class="provider-item">
<div>
<span class="name">Email & Password</span>
<span class="id">${user.email || 'Not set'}</span>
</div>
<button class="field-action" onclick="openPasswordModal()">Change Password</button>
</div>
${providers.map(p => `
<div class="provider-item">
<div>
<span class="name">${p.name === 'web3' ? 'Web3 Wallet' : p.name}</span>
<span class="id">${p.name === 'web3' ? p.id.slice(0, 6) + '...' + p.id.slice(-4) : p.id}</span>
</div>
<button class="unlink" onclick="unlinkProvider('${p.name}')">Unlink</button>
</div>`).join('')}
</div>
<div class="add-provider">
<a class="add-btn" href="https://${brand.domain}/v1/iam/oauth/authorize?client_id=${brand.clientId}&redirect_uri=${encodeURIComponent(`https://${brand.domain}/callback`)}&response_type=code&scope=openid+profile+email&provider=provider-google">
+ Google
</a>
<a class="add-btn" href="https://${brand.domain}/v1/iam/oauth/authorize?client_id=${brand.clientId}&redirect_uri=${encodeURIComponent(`https://${brand.domain}/callback`)}&response_type=code&scope=openid+profile+email&provider=provider-github">
+ GitHub
</a>
<button class="add-btn" onclick="linkWallet()">+ Web3 Wallet</button>
</div>
</div>
<div class="section">
<h2>Security</h2>
<div class="field">
<span class="field-label">Two-Factor Auth</span>
<span class="field-value">${fullUser && fullUser.totpSecret ? '<span class="badge">Enabled</span>' : 'Not enabled'}</span>
<button class="field-action" onclick="window.location.href='https://iam.hanzo.ai/account#mfa'">${fullUser && fullUser.totpSecret ? 'Manage' : 'Enable'}</button>
</div>
<div class="field">
<span class="field-label">Last Sign-in</span>
<span class="field-value">${fullUser && fullUser.lastSigninTime ? new Date(fullUser.lastSigninTime).toLocaleString() : '—'}</span>
</div>
<div class="field">
<span class="field-label">Last Sign-in IP</span>
<span class="field-value">${fullUser && fullUser.lastSigninIp ? fullUser.lastSigninIp : '—'}</span>
</div>
</div>
<div class="section danger-zone">
<h2>Danger Zone</h2>
<div class="field">
<span class="field-label">Delete Account</span>
<span class="field-value" style="color:#888;font-size:0.82rem;">Permanently delete your account and all data</span>
<button class="field-action" style="color:#ff6b6b;border-color:#ff6b6b44;" onclick="confirmDelete()">Delete Account</button>
</div>
</div>
</div>
<!-- Password Change Modal -->
<div class="modal-overlay" id="password-modal">
<div class="modal">
<h3>Change Password</h3>
<input type="password" id="old-password" placeholder="Current password" autocomplete="current-password">
<input type="password" id="new-password" placeholder="New password (min 8 characters)" autocomplete="new-password">
<input type="password" id="confirm-password" placeholder="Confirm new password" autocomplete="new-password">
<div id="pw-error" style="color:#ff8f8f;font-size:0.82rem;display:none;margin-bottom:0.5rem;"></div>
<div class="modal-actions">
<button onclick="closePasswordModal()">Cancel</button>
<button class="primary" onclick="changePassword()">Update Password</button>
</div>
</div>
</div>
<!-- Edit Field Modal -->
<div class="modal-overlay" id="edit-modal">
<div class="modal">
<h3 id="edit-title">Edit Field</h3>
<input type="text" id="edit-value">
<div class="modal-actions">
<button onclick="closeEditModal()">Cancel</button>
<button class="primary" onclick="saveField()">Save</button>
</div>
</div>
</div>
<script>
const TOKEN = document.cookie.match(/account_token=([^;]+)/)?.[1] ? decodeURIComponent(document.cookie.match(/account_token=([^;]+)/)[1]) : null;
const IAM = '${IAM_ORIGIN}';
const BRAND_DOMAIN = '${brand.domain}';
let editingField = null;
function showMsg(text, type) {
const el = document.getElementById('msg');
el.textContent = text;
el.className = 'msg ' + type;
setTimeout(() => { el.className = 'msg'; }, 5000);
}
function openPasswordModal() {
document.getElementById('password-modal').classList.add('active');
}
function closePasswordModal() {
document.getElementById('password-modal').classList.remove('active');
document.getElementById('old-password').value = '';
document.getElementById('new-password').value = '';
document.getElementById('confirm-password').value = '';
document.getElementById('pw-error').style.display = 'none';
}
async function changePassword() {
const oldPw = document.getElementById('old-password').value;
const newPw = document.getElementById('new-password').value;
const confirm = document.getElementById('confirm-password').value;
const errEl = document.getElementById('pw-error');
if (!oldPw || !newPw) { errEl.textContent = 'All fields required'; errEl.style.display = 'block'; return; }
if (newPw.length < 8) { errEl.textContent = 'Password must be at least 8 characters'; errEl.style.display = 'block'; return; }
if (newPw !== confirm) { errEl.textContent = 'Passwords do not match'; errEl.style.display = 'block'; return; }
try {
const res = await fetch('/v1/iam/set-password', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: 'Bearer ' + TOKEN,
},
body: new URLSearchParams({
userOwner: '${fullUser ? fullUser.owner : 'hanzo'}',
userName: '${fullUser ? fullUser.name : ''}',
oldPassword: oldPw,
newPassword: newPw,
}).toString(),
});
const data = await res.json();
if (data.status === 'ok') {
closePasswordModal();
showMsg('Password updated successfully', 'success');
} else {
errEl.textContent = data.msg || 'Failed to update password';
errEl.style.display = 'block';
}
} catch (e) {
errEl.textContent = 'Network error';
errEl.style.display = 'block';
}
}
function editField(field, currentValue) {
editingField = field;
document.getElementById('edit-title').textContent = 'Edit ' + field.charAt(0).toUpperCase() + field.slice(1);
document.getElementById('edit-value').value = currentValue;
document.getElementById('edit-modal').classList.add('active');
}
function closeEditModal() {
document.getElementById('edit-modal').classList.remove('active');
editingField = null;
}
async function saveField() {
if (!editingField) return;
const value = document.getElementById('edit-value').value;
try {
const userObj = { owner: '${fullUser ? fullUser.owner : 'hanzo'}', name: '${fullUser ? fullUser.name : ''}' };
if (editingField === 'name') userObj.displayName = value;
const res = await fetch('/v1/iam/update-user?id=${fullUser ? fullUser.owner + '/' + fullUser.name : ''}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer ' + TOKEN,
},
body: JSON.stringify(userObj),
});
const data = await res.json();
if (data.status === 'ok') {
closeEditModal();
showMsg('Updated successfully. Refreshing...', 'success');
setTimeout(() => location.reload(), 1000);
} else {
showMsg(data.msg || 'Update failed', 'error');
}
} catch (e) {
showMsg('Network error', 'error');
}
}
async function unlinkProvider(provider) {
if (!confirm('Unlink ' + provider + ' from your account?')) return;
try {
var field = provider === 'web3' ? 'metamask' : provider;
var userObj = { owner: '${fullUser ? fullUser.owner : 'hanzo'}', name: '${fullUser ? fullUser.name : ''}' };
userObj[field] = '';
var res = await fetch('/v1/iam/update-user?id=${fullUser ? fullUser.owner + '/' + fullUser.name : ''}', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userObj),
});
var data = await res.json();
if (data.status === 'ok') {
showMsg(provider + ' unlinked successfully', 'success');
setTimeout(function() { location.reload(); }, 1000);
} else {
showMsg(data.msg || 'Failed to unlink ' + provider, 'error');
}
} catch (e) {
showMsg('Network error: ' + e.message, 'error');
}
}
async function linkWallet() {
if (typeof window.ethereum === 'undefined') {
showMsg('Please install MetaMask or another Web3 wallet', 'error');
return;
}
try {
var accounts = await window.ethereum.request({ method: 'eth_requestAccounts' });
var address = accounts[0];
if (!address) { showMsg('No wallet account found', 'error'); return; }
// Sign a message to prove wallet ownership
var message = 'Link wallet ' + address + ' to ' + BRAND_DOMAIN + ' account for ${fullUser ? fullUser.name : 'user'}';
await window.ethereum.request({ method: 'personal_sign', params: [message, address] });
// Update user with wallet address
var userObj = { owner: '${fullUser ? fullUser.owner : 'hanzo'}', name: '${fullUser ? fullUser.name : ''}', metamask: address };
var res = await fetch('/v1/iam/update-user?id=${fullUser ? fullUser.owner + '/' + fullUser.name : ''}', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userObj),
});
var data = await res.json();
if (data.status === 'ok') {
showMsg('Wallet linked: ' + address.slice(0, 6) + '...' + address.slice(-4), 'success');
setTimeout(function() { location.reload(); }, 1500);
} else {
showMsg(data.msg || 'Failed to link wallet', 'error');
}
} catch (err) {
if (err.code === 4001) return; // User rejected
showMsg(err.message || 'Failed to connect wallet', 'error');
}
}
function confirmDelete() {
if (!confirm('Are you sure? This action is permanent and cannot be undone.')) return;
if (!confirm('This will permanently delete your account and all associated data. Type OK to confirm.')) return;
showMsg('Account deletion requires verification. Redirecting to IAM...', 'error');
window.location.href = 'https://iam.hanzo.ai/account';
}
</script>
</body>
</html>`;
}
function renderLoginRedirectPage(brand, loginUrl) {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sign In Required - ${brand.name}</title>
<meta http-equiv="refresh" content="2;url=${loginUrl}">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: ${brand.bg};
color: #fff;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
}
.card {
text-align: center;
padding: 2rem;
border: 1px solid ${brand.border};
border-radius: 12px;
background: ${brand.surface};
max-width: 400px;
}
a { color: ${brand.accent}; }
</style>
</head>
<body>
<div class="card">
${brand.logo}
<h2 style="margin-top:1rem;">Sign in to continue</h2>
<p style="color:#888;margin-top:0.5rem;">Redirecting to ${brand.name} login...</p>
<p style="margin-top:1rem;"><a href="${loginUrl}">Click here if not redirected</a></p>
</div>
</body>
</html>`;
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
const hostname = url.hostname;
const pathname = url.pathname;
const brand = getBrand(hostname);
const callbackUrl = `https://${hostname}/callback`;
// Handle OAuth callback — exchange code for token
if (pathname === '/callback') {
const code = url.searchParams.get('code');
if (!code) {
return new Response(null, {
status: 302,
headers: { Location: buildLoginUrl(brand, callbackUrl) },
});
}
const token = await exchangeCode(code, callbackUrl, brand);
if (!token) {
return new Response(null, {
status: 302,
headers: { Location: buildLoginUrl(brand, callbackUrl) },
});
}
// Set token cookie and redirect to account page
return new Response(null, {
status: 302,
headers: {
Location: '/',
'Set-Cookie': `account_token=${encodeURIComponent(token)}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=86400`,
},
});
}
// Handle logout
if (pathname === '/logout') {
return new Response(null, {
status: 302,
headers: {
Location: `https://${brand.domain}`,
'Set-Cookie': 'account_token=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT',
},
});
}
// Proxy IAM API calls (for client-side JS). Same-origin /v1/iam/* from
// the account page is forwarded to IAM; the access token is attached
// server-side from the HttpOnly cookie so it never rides in page JS.
if (pathname.startsWith('/v1/iam/')) {
const token = getToken(request);
const iamUrl = new URL(pathname + url.search, IAM_ORIGIN);
const headers = new Headers(request.headers);
headers.set('Host', new URL(IAM_ORIGIN).hostname);
if (token) headers.set('Authorization', `Bearer ${token}`);
const iamRes = await fetch(iamUrl.toString(), {
method: request.method,
headers,
body: request.method !== 'GET' ? request.body : undefined,
});
return new Response(iamRes.body, {
status: iamRes.status,
headers: {
'content-type': iamRes.headers.get('content-type') || 'application/json',
'cache-control': 'no-store',
},
});
}
// Check authentication for all other routes
const token = getToken(request);
if (!token) {
const loginUrl = buildLoginUrl(brand, callbackUrl);
return new Response(null, {
status: 302,
headers: { Location: loginUrl },
});
}
// Get user info
const user = await getUserInfo(token);
if (!user || !user.name) {
// Token expired or invalid — re-authenticate
const loginUrl = buildLoginUrl(brand, callbackUrl);
return new Response(null, {
status: 302,
headers: {
Location: loginUrl,
'Set-Cookie': 'account_token=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT',
},
});
}
// Get full user object for detailed info
const fullUser = await getUser(token, user.owner || 'hanzo', user.preferred_username || user.name);
// Serve account page
return htmlResponse(renderAccountPage(brand, user, fullUser));
},
};
+12
View File
@@ -0,0 +1,12 @@
name = "account-portal"
main = "src/worker.js"
compatibility_date = "2024-01-01"
workers_dev = false
# Custom domains are managed via CF Workers Custom Domains API:
# account.hanzo.id -> zone hanzo.id
# account.lux.id -> zone lux.id
# DNS records and SSL certs are automatically provisioned.
[vars]
IAM_ORIGIN = "https://iam.hanzo.ai"
+3 -1
View File
@@ -4,7 +4,9 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="robots" content="noindex" />
<link id="favicon" rel="icon" type="image/png" href="data:," />
<link id="favicon" rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<title>Sign in</title>
</head>
<body>
+12 -5
View File
@@ -1,8 +1,8 @@
{
"name": "@hanzo/id-web",
"private": true,
"version": "0.1.1",
"description": "Hanzo ID white-label login / signup / IDV portal. Vite + React 19 + @hanzo/gui. Same image serves hanzo.id / lux.id / zoo.id / pars.id.",
"version": "0.1.34",
"description": "Hanzo ID \u2014 white-label login / signup / IDV portal. Vite + React 19, styled from @hanzo/design tokens. Same image serves hanzo.id / lux.id / zoo.id / pars.id.",
"type": "module",
"scripts": {
"dev": "vite",
@@ -11,18 +11,25 @@
"tc": "tsc --noEmit"
},
"dependencies": {
"@crossmarkio/sdk": "^0.4.0",
"@hanzo/brand": "^1.3.0",
"@hanzo/gui": "^7.2.4",
"@hanzo/iam": "^0.9.4",
"@hanzo/design": "^0.4.9",
"@hanzo/event": "^0.3.11",
"@hanzo/iam": "^0.21.1",
"@hanzo/id-auth": "workspace:*",
"@hanzo/id-connect": "workspace:*",
"@hanzo/id-idv": "workspace:*",
"@hanzo/id-onboarding": "workspace:*",
"@hanzo/id-shared": "workspace:*",
"@luxfi/brand": "^1.0.0",
"@parsdao/brand": "^1.0.0",
"@tanstack/react-router": "^1.168.0",
"@tonconnect/sdk": "^4.0.0",
"@zooai/brand": "^1.3.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
"react-dom": "^19.2.0",
"sats-connect": "^4.2.1",
"viem": "^2.53.1"
},
"devDependencies": {
"@types/react": "^19.0.0",
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 67 67" role="img" aria-label="Hanzo">
<style>path{fill:#000}@media (prefers-color-scheme:dark){path{fill:#fff}}</style>
<path d="M22.21 67V44.6369H0V67H22.21Z"/>
<path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z"/>
<path d="M22.21 0H0V22.3184H22.21V0Z"/>
<path d="M66.7198 0H44.5098V22.3184H66.7198V0Z"/>
<path d="M66.7198 67V44.6369H44.5098V67H66.7198Z"/>
</svg>

After

Width:  |  Height:  |  Size: 443 B

+50 -15
View File
@@ -1,45 +1,80 @@
import { useEffect, useMemo, useState } from 'react'
import { loadBrand, parseCatalog, resolveTenant, type BrandContract, type TenantConfig } from '@hanzo/id-shared'
import { loadBrand, catalogOf, parseCatalog, resolveOrg, idBrandLabel, type BrandContract, type OrgConfig } from '@hanzo/id-shared'
import { createAuthClient } from '@hanzo/id-auth'
import { Portal } from './pages/Portal'
import { Login } from './pages/Login'
import { Signup } from './pages/Signup'
import { Forgot } from './pages/Forgot'
import { Callback } from './pages/Callback'
import { Onboarding } from './pages/Onboarding'
import { DeviceApproval } from './pages/DeviceApproval'
/**
* Top-level wiring. Resolves tenant + brand once on mount, then routes via
* Top-level wiring. Resolves org + brand once on mount, then routes via
* `window.location.pathname`. No router lib needed — this app is 5 pages,
* `<a href>` is enough. Adding paths is a switch case.
*/
export function App() {
const [tenant, setTenant] = useState<TenantConfig | null>(null)
const [org, setOrg] = useState<OrgConfig | null>(null)
const [brand, setBrand] = useState<BrandContract | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const runtimeCatalog = (window as unknown as { __ID_CATALOG__?: string }).__ID_CATALOG__
const t = resolveTenant(window.location.hostname, { catalog: parseCatalog(runtimeCatalog) })
setTenant(t)
loadBrand(t.brandPackage, t.brandUrl)
.then((b) => {
let cancelled = false
async function boot() {
// The runtime serves the per-host org catalog at /config.json — NOT a
// `window.__ID_CATALOG__` global, which the runtime never injects
// (relying on it silently dropped every catalog-only host, e.g. osage.id,
// to the bundled Hanzo default). Fall back to the global, then empty, so a
// host always resolves to something.
//
// `catalogOf` owns which key that payload uses — the name is the server's,
// not ours (see its doc). Reading the wrong one is a silent total-catalog
// outage, so it is pinned by a test next to the resolver it feeds.
let catalogRaw: string | undefined
try {
const res = await fetch('/config.json', { cache: 'no-store' })
if (res.ok) catalogRaw = catalogOf(await res.json())
} catch {
// network/parse error → fall back below
}
if (!catalogRaw) {
catalogRaw = (window as unknown as { __ID_CATALOG__?: string }).__ID_CATALOG__
}
const t = resolveOrg(window.location.hostname, { catalog: parseCatalog(catalogRaw) })
if (cancelled) return
setOrg(t)
try {
const b = await loadBrand(t.brandPackage)
if (cancelled) return
setBrand(b)
document.title = `Sign in — ${b.name}`
document.title = idBrandLabel(b, t.orgId)
const fav = document.getElementById('favicon') as HTMLLinkElement | null
if (fav && b.faviconUrl) fav.href = b.faviconUrl
})
.catch((e) => setError(String(e)))
} catch (e) {
if (!cancelled) setError(String(e))
}
}
void boot()
return () => {
cancelled = true
}
}, [])
const client = useMemo(() => (tenant ? createAuthClient({ tenant }) : null), [tenant])
const client = useMemo(() => (org ? createAuthClient({ org }) : null), [org])
if (error) return <div className="hanzo-id-error">{error}</div>
if (!tenant || !brand || !client) return <div>Loading</div>
if (!org || !brand || !client) return <div>Loading</div>
const path = window.location.pathname
// Device-authorization approval (RFC 8628). Must precede the `/login` catch
// since it lives under `/login/oauth/device`.
if (path === '/login/oauth/device' || path.startsWith('/login/oauth/device/'))
return <DeviceApproval client={client} brand={brand} />
if (path === '/login' || path.startsWith('/login/')) return <Login client={client} brand={brand} />
if (path === '/signup' || path.startsWith('/signup/')) return <Signup client={client} brand={brand} />
if (path === '/forget' || path === '/forgot' || path.startsWith('/forg')) return <Forgot client={client} brand={brand} />
if (path === '/callback' || path.startsWith('/callback/')) return <Callback client={client} brand={brand} />
return <Portal brand={brand} />
if (path === '/callback' || path.startsWith('/callback/')) return <Callback org={org} brand={brand} />
if (path === '/onboarding' || path.startsWith('/onboarding/')) return <Onboarding org={org} brand={brand} />
return <Portal client={client} brand={brand} org={org} />
}
+222
View File
@@ -0,0 +1,222 @@
/**
* The telemetry gate, tested where it is actually true or false: on the bytes
* the client puts on the wire.
*
* The defect this guards is not hypothetical and is not visible in review. The
* obvious way to keep an OAuth code out of telemetry — "send the pathname, never
* the href" — DOES NOT WORK against @hanzo/event, because `build()` stamps
* `url: window.location.href` onto every event it assembles regardless of the
* `path` the caller passed. A pageview from `/callback?code=…&state=…` therefore
* ships the authorization code while `path` reads a clean `/callback`, and the
* client's scrubber does not catch it: that scrubber redacts secret SHAPES
* (JWT, sk-/pk-/hk-, bearer, cloud keys, PAN) and an opaque authorization code
* is not one.
*
* So the gate is "do not emit from a route whose URL carries a credential", and
* the test below asserts BOTH halves: that gated routes emit nothing, and that
* the same setup ungated really does leak. The second half is what keeps this
* from decaying into a decorative assertion — if @hanzo/event ever stops putting
* the href on the wire, that case fails and this whole file can be revisited.
*/
import { test } from 'vitest'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import { createAnalytics } from '@hanzo/event'
import { telemetryAllowed, consented } from './analytics'
// ── the route gate ──────────────────────────────────────────────────────────
test('auth-artifact routes are refused, funnel routes are not', () => {
// Carry a credential in the query string -> must never emit.
for (const p of [
'/callback',
'/callback/',
'/callback/anything',
'/login/oauth/device',
'/login/oauth/device/',
'/login/oauth/device/WDJB-MJHT',
]) {
assert.equal(telemetryAllowed(p), false, `${p} must not emit`)
}
// The funnel this exists to measure: arrival -> sign-in -> session.
for (const p of [
'/',
'/login',
'/login/',
'/signup',
'/forgot',
'/forget',
'/onboarding',
'/callbacks', // near-miss: a real route that merely starts the same way
'/login/oauth', // the device path is the specific one, not all of oauth
]) {
assert.equal(telemetryAllowed(p), true, `${p} must emit`)
}
})
/**
* The gate is written against literal paths, and App.tsx dispatches against its
* own. If someone adds a route that lands on the callback or device page, this
* fails rather than silently starting to ship codes — the same reason the token
* suite computes from what the bundle serves instead of trusting a list.
*/
test('every auth-artifact route App.tsx dispatches is covered by the gate', () => {
const app = fs.readFileSync(path.join(import.meta.dirname, 'App.tsx'), 'utf8')
// Route literals compared in App.tsx: path === '…' / path.startsWith('…').
const routes = [...app.matchAll(/path\s*(?:===\s*|\.startsWith\(\s*)'([^']+)'/g)].map((m) => m[1]!)
assert.ok(routes.length >= 10, `expected App.tsx route literals, found ${routes.length}`)
for (const r of routes) {
const isAuthArtifact = r.startsWith('/callback') || r.startsWith('/login/oauth/device')
if (isAuthArtifact) {
assert.equal(telemetryAllowed(r), false, `App.tsx routes ${r} to an auth-artifact page; gate it`)
}
}
// Both pages are actually reachable — the gate is not guarding dead routes.
assert.ok(routes.some((r) => r.startsWith('/callback')), 'App.tsx must route /callback')
assert.ok(
routes.some((r) => r.startsWith('/login/oauth/device')),
'App.tsx must route /login/oauth/device',
)
})
// ── consent ─────────────────────────────────────────────────────────────────
test('an explicit browser opt-out turns everything off', () => {
assert.equal(consented({ globalPrivacyControl: true }), false)
assert.equal(consented({ doNotTrack: '1' }), false)
assert.equal(consented({ doNotTrack: 'yes' }), false)
assert.equal(consented(), true)
assert.equal(consented({}), true)
assert.equal(consented({ globalPrivacyControl: false, doNotTrack: '0' }), true)
assert.equal(consented({ doNotTrack: null }), true)
})
// ── the wire ────────────────────────────────────────────────────────────────
const CODE = 'AUTHCODE_abc123XYZ'
const STATE = 'STATE_deadbeef'
const USER_CODE = 'WDJB-MJHT'
/** Installs the browser globals @hanzo/event reads, at a given location. */
function atLocation(href: string, pathname: string, search: string) {
const store: Record<string, string> = {}
const localStorage = {
getItem: (k: string) => store[k] ?? null,
setItem: (k: string, v: string) => void (store[k] = String(v)),
removeItem: (k: string) => void delete store[k],
}
const g = globalThis as Record<string, unknown>
g.window = {
location: { href, pathname, search, hostname: 'hanzo.id', origin: 'https://hanzo.id' },
addEventListener() {},
removeEventListener() {},
localStorage,
screen: { width: 1440, height: 900 },
}
g.document = {
referrer: '',
title: 'Sign in',
visibilityState: 'visible',
addEventListener() {},
removeEventListener() {},
}
g.localStorage = localStorage
g.screen = { width: 1440, height: 900 }
g.location = (g.window as { location: unknown }).location
}
function clearLocation() {
const g = globalThis as Record<string, unknown>
delete g.window
delete g.document
delete g.localStorage
delete g.screen
delete g.location
}
/** Runs the client exactly as mounted and returns everything it tried to send. */
function wireFrom(href: string, pathname: string, search: string, enabled: boolean): string {
atLocation(href, pathname, search)
try {
const sent: string[] = []
const client = createAnalytics({
product: 'id',
host: 'https://api.hanzo.ai',
ingestKey: 'pk-live-TESTKEY',
enabled,
transport: { send: (_url: string, body: string) => void sent.push(body) },
})
client.init()
client.pageview(pathname) // pathname only — the mitigation that is NOT enough
client.captureError(new Error('boom'))
client.flush()
return sent.join('')
} finally {
clearLocation()
}
}
test('a gated auth-artifact route puts nothing on the wire', () => {
const cb = wireFrom(
`https://hanzo.id/callback?code=${CODE}&state=${STATE}`,
'/callback',
`?code=${CODE}&state=${STATE}`,
telemetryAllowed('/callback'),
)
assert.equal(cb, '', 'the callback route must emit nothing at all')
assert.ok(!cb.includes(CODE), 'authorization code must never reach the wire')
assert.ok(!cb.includes(STATE), 'state must never reach the wire')
const dev = wireFrom(
`https://hanzo.id/login/oauth/device?user_code=${USER_CODE}`,
'/login/oauth/device',
`?user_code=${USER_CODE}`,
telemetryAllowed('/login/oauth/device'),
)
assert.equal(dev, '', 'the device route must emit nothing at all')
assert.ok(!dev.includes(USER_CODE), 'device user_code must never reach the wire')
})
/**
* The reason the gate exists. Passing a clean pathname is NOT what protects the
* code — if this ever stops leaking, @hanzo/event changed and the gate's
* justification should be re-read.
*/
test('without the gate, a clean pathname still leaks the code (why the gate exists)', () => {
const leaked = wireFrom(
`https://hanzo.id/callback?code=${CODE}&state=${STATE}`,
'/callback',
`?code=${CODE}&state=${STATE}`,
true, // ungated
)
assert.ok(leaked.includes(CODE), 'expected the ungated client to leak the code via `url`')
assert.ok(leaked.includes(STATE), 'expected the ungated client to leak the state via `url`')
assert.ok(leaked.includes('"path":"/callback"'), 'and to report a clean path while doing it')
})
test('funnel routes do report, and carry no credential', () => {
for (const p of ['/', '/login', '/signup', '/onboarding']) {
const wire = wireFrom(`https://hanzo.id${p}`, p, '', telemetryAllowed(p))
assert.ok(wire.includes('"$pageview"'), `${p} must report a pageview`)
assert.ok(wire.includes('"product":"id"'), `${p} must attribute to the id product`)
for (const secret of [CODE, STATE, USER_CODE]) {
assert.ok(!wire.includes(secret), `${p} must not carry ${secret}`)
}
}
})
test('an opted-out visitor emits nothing even on a funnel route', () => {
const wire = wireFrom(
'https://hanzo.id/login',
'/login',
'',
consented({ globalPrivacyControl: true }) && telemetryAllowed('/login'),
)
assert.equal(wire, '', 'GPC must suppress the whole client')
})
+138
View File
@@ -0,0 +1,138 @@
// Telemetry for the sign-in portal — pageviews and errors, anonymous, via the
// ONE @hanzo/event client (POST /v1/event, the front door cloud fans out into
// the web / product / error lenses). No page tag, no second SDK.
//
// This surface reported NOTHING before this file existed, which is why the
// arrival->session funnel had no denominator: hanzo.id is where every property's
// visitor lands, and none of it was attributable.
//
// It is also an AUTH surface, so what is NOT here is deliberate:
//
// - no identify(). Attribution here is anonymous; @hanzo/event stamps a
// per-browser `anonymousId` that survives sign-up, so the visitor's
// pre-signup pageviews still join to whoever they become once a
// post-auth surface (chat/console) identifies them. Reading the IAM
// subject would mean wiring this into the auth context for a join that
// already happens downstream.
// - no interaction autocapture (@hanzo/observe). Heat maps answer "where do
// they click"; the question this funnel exists to answer is "did they get a
// session", which pageviews answer completely. Autocapture on the login and
// signup forms is capture surface bought for no funnel signal.
// - no session replay, no input capture, no email/name.
import { useEffect, useState } from 'react'
import type { ReactNode } from 'react'
import { AnalyticsProvider as EventProvider, usePageview } from '@hanzo/event/react'
const HOST = 'https://api.hanzo.ai'
/**
* Publishable ingest key (pk-…), inlined by Vite from the build env. Write-only:
* it attributes a write to ONE org and mints no reading principal, which is what
* makes it safe in a bundle — and it is the ONLY thing that attributes a
* LOGGED-OUT visitor, which on a sign-in portal is nearly all of them.
*
* Absent is not a degraded mode: cloud takes an unkeyed beacon down the anonymous
* lane and files it under `$public`, a tenant this org cannot read, and answers
* 200 either way. The loss is silent on both ends, so the Dockerfile fails the
* build rather than letting an empty value ship. Never hardcode a value here.
*/
const INGEST_KEY = import.meta.env.VITE_EVENT_INGEST_KEY?.trim() || undefined
/**
* Routes whose URL carries an authentication artifact.
*
* `/callback` holds the OAuth authorization `code` and `state`; the device
* verification URI holds a `user_code`. Both sit in the QUERY STRING, and
* @hanzo/event stamps `url: window.location.href` onto every event it builds —
* independently of the `path` a caller passes. So passing a clean pathname does
* NOT keep the code out of the payload; only not emitting does. Measured against
* the real client, a pageview from `/callback?code=…&state=…` put both values on
* the wire in cleartext while `path` read a tidy `/callback`.
*
* The client's scrubber does not save this either — it redacts secret SHAPES
* (JWTs, sk-/pk-/hk-, bearer, cloud keys, PANs) and an opaque authorization code
* matches none of them.
*
* Neither route is a funnel step: both are transient machine hops that redirect
* onward within a tick. The funnel is `/` -> `/login` -> `/onboarding`, and every
* one of those still reports. Dropping these two costs no signal and removes the
* entire class of credential leak. See analytics.test.ts.
*/
const AUTH_ARTIFACT = /^\/(callback|login\/oauth\/device)(\/|$)/
/** telemetryAllowed reports whether a path may emit at all. Pure. */
export function telemetryAllowed(pathname: string): boolean {
return !AUTH_ARTIFACT.test(pathname)
}
/**
* consented honours an explicit browser opt-out — Global Privacy Control, then
* legacy Do-Not-Track. This is the whole consent surface, and it suppresses
* pageviews AND errors together: a visitor who opted out is not "mostly" off.
* Pure with respect to its argument so the policy is testable without a DOM.
*/
export function consented(nav?: {
globalPrivacyControl?: boolean
doNotTrack?: string | null
}): boolean {
if (!nav) return true
if (nav.globalPrivacyControl === true) return false
const dnt = nav.doNotTrack
return dnt !== '1' && dnt !== 'yes'
}
/** Reads the live opt-out signals off `navigator`, or none outside a browser. */
function browserConsent(): boolean {
if (typeof navigator === 'undefined') return true
return consented(navigator as Navigator & { globalPrivacyControl?: boolean })
}
/**
* Fires a pageview on SPA route changes.
*
* Inert today, and deliberately kept: this app navigates with
* `window.location.assign/replace`, so every route change is a fresh document
* and the provider's own initial pageview counts each page exactly once
* (`usePageview` skips its first mount for precisely that reason — it would
* otherwise double-count). `@tanstack/react-router` is a declared dependency
* that nothing imports; the day someone mounts it, navigation stops reloading
* the document and this is what keeps pageviews from silently going to zero.
*
* It is fed the PATHNAME, never `location.href`, and only when the path is
* allowed to emit — `usePageview` no-ops on a null path.
*/
function RouteViews() {
const [pathname, setPathname] = useState(() =>
typeof window === 'undefined' ? '/' : window.location.pathname,
)
useEffect(() => {
const sync = () => setPathname(window.location.pathname)
window.addEventListener('popstate', sync)
return () => window.removeEventListener('popstate', sync)
}, [])
usePageview(telemetryAllowed(pathname) ? pathname : null)
return null
}
/**
* Mounts the client. `enabled` is the single gate every plane reads — it stops
* init, enqueue, flush and the error handlers alike, so an off state emits
* nothing at all rather than emitting less.
*
* The gate is evaluated once per document, which is exact here BECAUSE
* navigation is full-page: the path a document is loaded at is the path it dies
* at, so there is no window in which a `/callback` load is measured under an
* earlier route's decision.
*/
export function Analytics({ children }: { children: ReactNode }) {
const pathname = typeof window === 'undefined' ? '/' : window.location.pathname
const enabled = browserConsent() && telemetryAllowed(pathname)
return (
<EventProvider config={{ product: 'id', host: HOST, ingestKey: INGEST_KEY, enabled }}>
<RouteViews />
{children}
</EventProvider>
)
}
+515 -111
View File
@@ -1,169 +1,573 @@
/* Hanzo ID — the styling layer for hanzo.id / lux.id / zoo.id / pars.id.
*
* TOKENS COME FROM @hanzo/design. Not one colour, radius or type size is
* invented here; every value below resolves to a design token, so a token change
* lands on all four brand portals AND on pay.hanzo.ai — the other half of the
* same sign-in-then-pay flow — at once.
*
* ARCHITECTURE RULE, learned the hard way. A component's surface must never
* depend on WHERE it is mounted. This file used to paint controls with the
* descendant selectors `form input {…}` and `.hanzo-id-btn, form button {…}`,
* so any control that escaped a <form> ancestor fell out of the stylesheet and
* rendered as raw UA chrome: the device-approval screen (2 inputs, 0 forms)
* showed a 31px beveled browser input next to correctly-styled 44px siblings.
* That is the same class of defect as a distributed component shipping utility
* class names with no CSS behind them. Every rule below is keyed to a CLASS the
* component itself carries — `.hanzo-id-input`, `.hanzo-id-btn`,
* `.hanzo-id-field`, `.hanzo-id-form` — and there are no element-descendant
* selectors for surface anywhere in this file.
*/
/* ONE import, the whole token layer. This file used to cherry-pick four of the
* nine token groups, which meant z, elevation, spacing, fonts and the element
* defaults simply did not exist here — and an absent group is invisible: 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, none
* of which the four-group subset carried.
*
* The reason for cherry-picking is gone: as of @hanzo/design 0.3.0 Geist is
* SELF-HOSTED inside the package (two variable woff2, SIL OFL-1.1), so
* tokens/fonts.css no longer makes a request to fonts.googleapis.com and the
* sign-in path can take the typeface along with the colours. */
@import '@hanzo/design/styles.css';
:root {
--brand: #ffffff;
--bg: #0a0a0a;
--fg: #fafafa;
--muted: #a3a3a3;
--border: #262626;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
color-scheme: dark;
}
* { box-sizing: border-box; }
html, body, #root { height: 100%; margin: 0; }
/* 100dvh, with 100vh left underneath it as the fallback for anything that does
not know the unit. On a phone `vh` resolves against the LARGEST viewport —
the one with the URL bar retracted — so a bar that is actually on screen
makes a "full height" page taller than the space it has, and a login page
with one card on it acquires a scrollbar and sits low in the window. `dvh`
is the height that is really there. */
body {
background: var(--bg);
color: var(--fg);
background: var(--background);
color: var(--foreground);
min-height: 100vh;
min-height: 100dvh;
display: flex;
flex-direction: column;
font-size: var(--text-base);
}
/* NO focus rule here any more — tokens/base.css ships
`:focus-visible{outline:2px solid var(--ring);outline-offset:2px}` to every
consumer, and as of 0.3.0 --ring is var(--neutral-500), which measures 4.43:1
on --background (WCAG 2.4.13 wants 3:1). The local override that painted the
ring --primary existed only because --ring was #333333 at 1.66:1; that was a
finding against @hanzo/design, it has been fixed there, so the workaround
goes. One focus indicator, defined once, in the design system. */
/* ── Page shell ────────────────────────────────────────────────────── */
.hanzo-id-page {
flex: 1;
/* #root is not a flex container, so `flex: 1` alone never stretches this to
the viewport — pin a min height so `main`'s justify-content:center has room
to vertically center the auth card (box-sizing:border-box folds in padding).
dvh for the reason given on `body`; the vh line is the fallback. */
min-height: 100vh;
min-height: 100dvh;
display: flex;
flex-direction: column;
max-width: 480px;
margin: 0 auto;
padding: 24px;
/* index.html declares `viewport-fit=cover`, which puts the page UNDER the
notch, the rounded corners and the home indicator. Declaring cover without
consuming the insets is strictly worse than not declaring it, and nothing
in this stylesheet consumed them: `max()` keeps the 24px gutter everywhere
it is already enough and only grows it where the hardware intrudes, so
nothing moves on a device with no insets (env() is 0px there). */
padding: max(24px, env(safe-area-inset-top)) max(24px, env(safe-area-inset-right))
max(24px, env(safe-area-inset-bottom)) max(24px, env(safe-area-inset-left));
}
.hanzo-id-brand-header {
padding: 16px 0 32px;
.hanzo-id-brand-header { padding: 16px 0 32px; }
/* The home link wraps a 32px mark but measured 32x18 — an inline <a> takes its
box from the LINE BOX of its own font, not from a replaced child, so the tap
target was SMALLER than the logo inside it. inline-flex gives the anchor the
mark's real box; the padding/negative-margin pair then grows the hit area to
the 44px floor while leaving the mark exactly where it was — the padding
offsets the margin, so the logo's painted position and the header's height
are both unchanged, and only the invisible target bleeds into the gutter. */
.hanzo-id-brand-header a {
display: inline-flex;
align-items: center;
box-sizing: content-box;
min-width: 32px;
min-height: 32px;
padding: 6px;
margin: -6px;
}
/* The lockup is 32px tall for EVERY brand, and it has to be said here.
BrandHeader writes `height={32}`, but that is a presentational attribute and
@hanzo/design's `:where(img,video){height:auto}` overrides it — so the logo
was sized by whatever the brand package happened to ship, not by this page.
The two assets differ in exactly the way that hides it: @hanzo/brand's SVG
carries a viewBox and no width/height, so it has no intrinsic size and landed
near the intended 32; @luxfi/brand's declares 1024x1024, so it took the full
column and rendered a 342px mark over lux.id's sign-in form. Same markup,
same CSS, opposite results, and only the brand nobody was looking at broke.
Pinning the height and letting width follow makes the header's own
declaration true again whatever a brand ships. */
.hanzo-id-brand-header img { height: 32px; width: auto; }
/* Text fallback when a brand ships no logo asset (see BrandHeader). */
.hanzo-id-wordmark {
font-size: var(--text-xl);
font-weight: var(--weight-bold);
letter-spacing: var(--tracking-tight);
color: var(--text-primary);
}
.hanzo-id-page main {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
gap: 24px;
}
.hanzo-id-page h1 { margin: 0; font-size: 28px; }
.hanzo-id-page .lede { color: var(--muted); margin: 0; }
form { display: flex; flex-direction: column; gap: 16px; }
form label { display: flex; flex-direction: column; gap: 6px; font-size: 14px; color: var(--muted); }
form input {
background: #111;
color: var(--fg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 14px;
font-size: 16px;
/* The type scale is the design scale, five rungs: xs / sm / base / xl / 2xl.
`h1` at --text-2xl is the SAME 24px as pay.hanzo.ai's `text-2xl` headings, so
a heading does not change size when the flow crosses between the two. */
.hanzo-id-page h1 {
margin: 0;
font-size: var(--text-2xl);
line-height: var(--leading-2xl);
letter-spacing: var(--tracking-tight);
}
form input:focus { outline: 2px solid var(--brand); outline-offset: -1px; }
.hanzo-id-page h2 { margin: 0; font-size: var(--text-xl); line-height: var(--leading-xl); }
.hanzo-id-page .lede { color: var(--muted-foreground); margin: 0; font-size: var(--text-base); }
.hanzo-id-btn, form button {
background: var(--fg);
color: var(--bg);
border: 0;
border-radius: 8px;
padding: 12px 16px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
text-decoration: none;
text-align: center;
display: inline-block;
}
.hanzo-id-btn[aria-disabled='true'], form button:disabled { opacity: 0.5; cursor: not-allowed; }
.hanzo-id-btn.primary { background: var(--brand); }
/* ── Field ─────────────────────────────────────────────────────────── */
.hanzo-id-cta-row { display: flex; gap: 12px; }
.hanzo-id-footer-links { color: var(--muted); font-size: 14px; }
.hanzo-id-footer-links a { color: var(--fg); }
.hanzo-id-form { display: flex; flex-direction: column; gap: 16px; }
.hanzo-id-error {
background: #2d0a0a;
color: #ff7878;
border: 1px solid #5a1414;
padding: 12px;
border-radius: 8px;
font-size: 14px;
}
.hanzo-id-info {
background: #0a1f2d;
color: #78b8ff;
border: 1px solid #14385a;
padding: 12px;
border-radius: 8px;
}
/* --- Social providers + passwordless (email/SMS) login + signup --- */
.hanzo-id-login,
.hanzo-id-signup,
.hanzo-id-code-login {
.hanzo-id-field {
display: flex;
flex-direction: column;
gap: 16px;
gap: 6px;
font-size: var(--text-sm);
color: var(--muted-foreground);
}
.hanzo-id-providers {
display: flex;
flex-direction: column;
gap: 10px;
.hanzo-id-input {
/* `font: inherit` first: a bare <input>/<button> otherwise renders in the UA
face (Arial), which put two typefaces inside one 432px card — including on
the primary CTA. */
font: inherit;
font-size: var(--text-base);
background: var(--white-05);
color: var(--foreground);
/* A control's resting edge is --border-control. 0.4.2 cut it back to the alpha
ladder (.15) because a boundary that clears 3:1 on a near-black page IS a
mid-grey box, and a form of them reads as a wireframe. The contrast budget
went to --ring instead — the focus indicator is what a keyboard user
navigates by, it is the only boundary still held at 3:1, and unlike a
resting edge it is worth the loudness because it is transient. */
border: 1px solid var(--border-control);
/* 8px. 0.4.2 moved every control in the system to --radius-md, and tokens/
base.css draws a bare field at it; --radius-sm (6px) keeps its job on
genuinely small parts — badges, chips, menu rows — where 8px looks bubbly. */
border-radius: var(--radius-md);
padding: 0 14px;
min-height: 44px; /* the touch-target floor */
width: 100%;
}
.hanzo-id-provider-btn {
display: flex;
.hanzo-id-input::placeholder { color: var(--text-disabled); }
.hanzo-id-input:disabled { opacity: 0.5; cursor: not-allowed; }
/* iOS Safari ZOOMS the viewport when a focused control's text is under 16px,
and it does not zoom back out — the user is left on a magnified, sideways-
scrolling sign-in page mid-credential. This rule used to be a COMMENT on
font-size above claiming `--text-base` prevented that; --text-base is
0.875rem = 14px, so the protection was never in effect on either credential
field. The type scale has no 16px rung and should not grow one — 16 is not a
design value here, it is the threshold in Safari's own zoom heuristic — so it
is written as the literal it is, and scoped to touch-primary pointers so the
desktop type ramp is untouched. */
@media (pointer: coarse) {
.hanzo-id-input { font-size: 16px; }
}
/* Checkbox: the UA control, sized up from its 13px intrinsic box and tinted with
the brand. Its touch target is the whole <label> row, which is why it is the
one control here that is not 44px itself. */
.hanzo-id-check {
font: inherit;
accent-color: var(--primary);
width: 18px;
height: 18px;
min-height: 0;
margin-top: 2px;
flex: none;
}
/* ── Button — ONE primitive, two modifiers ─────────────────────────── */
/* `.hanzo-id-btn` is filled (the primary action). `.ghost` is the secondary
surface — social sign-in, org rows, Skip/Back. `.row` spreads content for a
list row. There is no `.primary`: the base IS primary, so there is one and
only one way to write the default button. */
.hanzo-id-btn {
font: inherit;
font-size: var(--text-base);
font-weight: var(--weight-semibold);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
background: #111;
color: var(--fg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 16px;
font-size: 15px;
font-weight: 600;
background: var(--primary);
color: var(--primary-foreground);
border: 1px solid transparent;
/* Same 8px as the field it sits under. Two stacked controls in one 432px card
cannot round differently, and the system draws buttons at 8-10px too. */
border-radius: var(--radius-md);
padding: 10px 16px;
min-height: 44px;
cursor: pointer;
text-decoration: none;
text-align: center;
transition: background var(--duration-fast) var(--ease-out),
border-color var(--duration-fast) var(--ease-out);
}
.hanzo-id-provider-btn:hover {
border-color: var(--muted);
background: #161616;
.hanzo-id-btn:disabled,
.hanzo-id-btn[aria-disabled='true'] { opacity: 0.5; cursor: not-allowed; }
.hanzo-id-btn svg { flex: none; }
/* The filled button had no hover at all. It has declared `transition:
background` since it was written and the system has shipped --primary-hover
the whole time, so the most-clicked control on the portal — Sign in, Continue,
Create account — was animating a property nothing ever changed. The ghost
variant below got its hover and this one was simply missed.
`:not(.ghost)` rather than relying on order: this selector and the ghost
hover both compute to (0,4,0), so with a bare `.hanzo-id-btn:hover` the two
would tie and source order would decide which surface a ghost button lifts
to. Saying which buttons are meant makes them unable to collide. */
.hanzo-id-btn:not(.ghost):hover:not(:disabled):not([aria-disabled='true']) {
background: var(--primary-hover);
}
.hanzo-id-provider-web3 {
border-color: #3a3357;
.hanzo-id-btn.ghost {
background: var(--white-05);
color: var(--foreground);
border-color: var(--border-strong);
font-weight: var(--weight-medium);
}
.hanzo-id-or {
/* Hover is a SURFACE lift, not a brighter edge. This used to also set
`border-color: var(--foreground)` — #ededed, 17.9:1 on --background — which
painted a near-solid-white 1px wireframe around the two most-hovered controls
on the page (Continue with GitHub / Continue with Google). Two problems with
moving the edge on hover, and the second is the one that matters:
1. It read as a wireframe, not as a refined surface. The house hairline is a
low-alpha step (--white-10/-15), ~4x quieter than #ededed.
2. Brightening an edge is THEME-HOSTILE. Every rung brighter than
--neutral-500 is worse on white: --neutral-400 measures 8.3:1 on black but
only 2.52:1 on white, which would fail the same WCAG 1.4.11 floor the
resting border is documented to hold in BOTH themes. There is no token
that gets brighter on dark and darker on light, so the edge must not
encode state at all.
So the border stays the constant control boundary (--border-strong, 4.43:1,
both themes) and the background carries the state — the lift the `transition`
on .hanzo-id-btn was already animating. */
.hanzo-id-btn.ghost:hover:not(:disabled) { background: var(--white-10); }
.hanzo-id-btn.row { justify-content: space-between; text-align: left; width: 100%; }
.hanzo-id-cta-row { display: flex; gap: 12px; }
.hanzo-id-cta-row .hanzo-id-btn { flex: 1; }
.hanzo-id-linkbtn {
font: inherit;
background: none;
border: 0;
color: var(--text-primary);
font-size: var(--text-sm);
cursor: pointer;
text-align: left;
padding: 4px 0;
text-decoration: underline;
text-underline-offset: 4px;
}
.hanzo-id-footer-links { color: var(--muted-foreground); font-size: var(--text-sm); }
/* "Forgot password?" / "Create account" / "Sign in" / "Back to sign in" are the
only route changes on these pages that are not buttons, and they measured 17px
tall — the line box of their own text. Vertical padding on an INLINE box is
hit-tested but does not enter line-box height, so this buys the 44px target
with zero layout movement and no change to the sentences they sit inside.
(17 + 14 + 14 = 45.) The 14px bleed stays inside `main`'s 24px gap, so no two
targets overlap. Horizontal size already clears 44px on every one of them. */
.hanzo-id-footer-links a {
color: var(--text-primary);
padding: 14px 0;
margin: -14px 0;
}
/* A2P SMS consent disclosure (shown on phone/SMS surfaces). */
.hanzo-id-sms-consent { color: var(--muted-foreground); font-size: var(--text-xs); line-height: var(--leading-relaxed); }
.hanzo-id-sms-consent p { margin: 0 0 6px; }
.hanzo-id-sms-consent-links { margin: 0; }
.hanzo-id-sms-consent a { color: var(--text-primary); }
/* ── Inline message ────────────────────────────────────────────────── */
/* Red is one of the two hues @hanzo/design permits, and these are the system's
own state tokens — the same #fca5a5 pay.hanzo.ai renders errors in. The
informational variant used to be a blue (#78b8ff) that exists nowhere in the
system; on a monochrome surface "information" is a neutral card. */
.hanzo-id-error,
.hanzo-id-info {
padding: 12px;
border-radius: var(--radius-md);
font-size: var(--text-sm);
line-height: var(--leading-sm);
margin: 0;
}
.hanzo-id-error {
background: var(--state-error-bg);
color: var(--state-error-text);
border: 1px solid var(--state-error);
}
.hanzo-id-info {
background: var(--card);
color: var(--text-secondary);
border: 1px solid var(--white-10);
}
/* ── Loading ───────────────────────────────────────────────────────── */
/* `.hanzo-id-spinner` used to be a class with NO rule behind it: the loading
state measured 0px tall and was invisible on every portal. */
.hanzo-id-spinner {
width: 28px;
height: 28px;
margin: 0 auto;
border: 2px solid var(--white-15);
border-top-color: var(--primary);
border-radius: var(--radius-full);
animation: hanzo-id-spin 700ms linear infinite;
}
@keyframes hanzo-id-spin { to { transform: rotate(360deg); } }
/* ── Device-authorization approval ─────────────────────────────────── */
.hanzo-id-device main { gap: 18px; }
.hanzo-id-device-prompt {
color: var(--muted-foreground);
font-size: var(--text-sm);
line-height: var(--leading-relaxed);
margin: 0;
}
.hanzo-id-device-prompt strong { color: var(--text-primary); }
.hanzo-id-device-code {
font-family: var(--font-mono);
font-size: var(--text-xl);
letter-spacing: var(--tracking-widest);
text-transform: uppercase;
}
.hanzo-id-device-confirm {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 10px;
color: var(--muted-foreground);
font-size: var(--text-sm);
line-height: var(--leading-relaxed);
}
/* ── Forced TOTP enrollment ────────────────────────────────────────── */
.hanzo-id-mfa-enroll { display: flex; flex-direction: column; gap: 16px; }
.hanzo-id-mfa-enroll h2 { margin: 0; font-size: var(--text-xl); }
.hanzo-id-mfa-qr {
align-self: center;
background: var(--pure-white);
padding: 12px;
border-radius: var(--radius-lg);
width: 220px;
height: 220px;
box-sizing: content-box;
}
.hanzo-id-mfa-qr svg { width: 100%; height: 100%; display: block; }
.hanzo-id-mfa-manual { font-size: var(--text-sm); color: var(--muted-foreground); }
.hanzo-id-mfa-manual summary { cursor: pointer; }
.hanzo-id-mfa-secret,
.hanzo-id-mfa-recovery code {
display: inline-block;
margin-top: 8px;
padding: 6px 10px;
background: var(--white-05);
border: 1px solid var(--white-10);
border-radius: var(--radius-sm);
font-family: var(--font-mono);
letter-spacing: var(--tracking-widest);
word-break: break-all;
}
.hanzo-id-mfa-recovery { font-size: var(--text-sm); color: var(--muted-foreground); line-height: var(--leading-relaxed); }
.hanzo-id-mfa-recovery code { letter-spacing: var(--tracking-normal); }
/* ── Social / Web3 sign-in ─────────────────────────────────────────── */
/* These are `.hanzo-id-btn.ghost` — there is no separate social-button surface. */
.hanzo-id-social { display: flex; flex-direction: column; gap: 10px; }
/* Wallet chain chooser — revealed under the single "Connect Wallet" button when
the injected chain is ambiguous. Indented so the EVM/Solana options read as
children of the wallet entry. */
.hanzo-id-wallet-chains {
display: flex;
flex-direction: column;
gap: 10px;
margin-left: 4px;
padding-left: 12px;
border-left: 1px solid var(--white-10);
}
/* Labeled divider between social row and email form. */
.hanzo-id-divider {
display: flex;
align-items: center;
gap: 12px;
color: var(--muted);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.08em;
text-align: center;
color: var(--muted-foreground);
font-size: var(--text-sm);
}
.hanzo-id-or::before,
.hanzo-id-or::after {
.hanzo-id-divider::before,
.hanzo-id-divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--border);
background: var(--white-10);
}
.hanzo-id-linkbtn {
background: none;
border: 0;
color: var(--muted);
font-size: 14px;
font-weight: 500;
width: auto;
padding: 4px;
cursor: pointer;
text-align: center;
.hanzo-id-divider span { padding: 0 12px; }
/* ── Signed-in portal: the apps launcher ───────────────────────────── */
/* These rules used to be an inline `style={{…}}` object on Portal.tsx carrying
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. They are classes now for the same reason as everything else in this
file: a surface must not depend on where it is mounted. */
.hanzo-id-portal main { width: 100%; max-width: 760px; }
.hanzo-id-apps {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
gap: var(--space-3);
margin-top: var(--space-6);
}
.hanzo-id-linkbtn:hover {
color: var(--fg);
text-decoration: underline;
.hanzo-id-applink {
display: block;
padding: var(--space-4);
/* A tile whose affordance is carried by its label, not its edge — so this is
the decorative hairline rung, not the 3:1 control boundary. --white-15 is
the ladder rung the old rgba(255,255,255,0.14) was approximating. */
border: 1px solid var(--white-15);
border-radius: var(--radius-lg);
text-decoration: none;
color: inherit;
transition: border-color var(--duration-fast) var(--ease-out);
}
.hanzo-id-toggle-mode {
margin-top: 4px;
.hanzo-id-applink:hover { border-color: var(--border-strong); text-decoration: none; }
.hanzo-id-applink-name { display: flex; justify-content: space-between; font-weight: var(--weight-semibold); }
.hanzo-id-applink-name span[aria-hidden] { color: var(--text-tertiary); }
.hanzo-id-applink-desc { color: var(--muted-foreground); font-size: var(--text-sm); margin-top: var(--space-1); }
/* The account control is a control, not a bar: cap it so the trigger (and the
menu, which matches the trigger's width) reads at the size it does on every
other Hanzo surface instead of spanning the whole 760px column. */
.hanzo-id-portal-account { margin-top: var(--space-6); max-width: 260px; }
/* ── Onboarding flow ───────────────────────────────────────────────── */
.hanzo-id-onboarding { display: flex; flex-direction: column; gap: 24px; }
.hanzo-id-onboarding-head { display: flex; flex-direction: column; gap: 6px; }
.hanzo-id-onboarding-head h1,
.hanzo-id-onboarding-done h1 { margin: 0; font-size: var(--text-2xl); letter-spacing: var(--tracking-tight); }
.hanzo-id-onboarding-body { display: flex; flex-direction: column; gap: 16px; }
.hanzo-id-onboarding-done { display: flex; flex-direction: column; gap: 16px; }
.hanzo-id-stepdots { display: flex; gap: 8px; }
.hanzo-id-stepdots span {
flex: 1;
height: 4px;
border-radius: var(--radius-full);
background: var(--white-10);
}
.hanzo-id-notice {
background: #0a1f2d;
color: #78b8ff;
border: 1px solid #14385a;
.hanzo-id-stepdots span.on { background: var(--primary); }
.hanzo-id-slug-preview {
color: var(--muted-foreground);
font-size: var(--text-sm);
margin: -8px 0 0;
font-family: var(--font-mono);
}
.hanzo-id-onboarding-actions { display: flex; gap: 10px; flex-wrap: wrap; }
.hanzo-id-onboarding-actions .hanzo-id-btn { flex: 1; min-width: 120px; }
.hanzo-id-summary { display: grid; grid-template-columns: auto 1fr; gap: 8px 16px; margin: 0; }
.hanzo-id-summary dt { color: var(--muted-foreground); font-size: var(--text-sm); }
.hanzo-id-summary dd { margin: 0; font-size: var(--text-sm); font-family: var(--font-mono); }
/* Consent step */
.hanzo-id-consent { display: flex; flex-direction: column; gap: 16px; }
.hanzo-id-consent p { margin: 0; color: var(--muted-foreground); font-size: var(--text-sm); line-height: var(--leading-normal); }
.hanzo-id-consent-check {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px;
border-radius: 8px;
font-size: 14px;
border: 1px solid var(--border-control);
border-radius: var(--radius-lg);
cursor: pointer;
font-size: var(--text-sm);
}
.hanzo-id-consent-check input { margin-top: 2px; accent-color: var(--primary); }
/* Plan step — one card per catalog plan + the pay-as-you-go card */
.hanzo-id-plans { display: flex; flex-direction: column; gap: 10px; }
.hanzo-id-plan {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
min-height: 44px;
padding: 14px 16px;
border: 1px solid var(--border-control);
border-radius: var(--radius-lg);
background: var(--white-05);
color: var(--foreground);
font: inherit;
text-align: left;
cursor: pointer;
transition: background 0.15s ease;
}
.hanzo-id-plan:hover { background: var(--white-10); }
.hanzo-id-plan:disabled { opacity: 0.6; cursor: default; }
.hanzo-id-plan.popular { border-color: var(--border-selected); }
.hanzo-id-plan-badge {
align-self: flex-end;
margin: -4px 0 -18px;
padding: 2px 8px;
border-radius: var(--radius-full);
background: var(--primary);
color: var(--primary-foreground);
font-size: var(--text-xs);
font-weight: 600;
}
.hanzo-id-plans-empty { margin: 0; color: var(--muted-foreground); font-size: var(--text-sm); }
.hanzo-id-plan-name { font-weight: 600; }
.hanzo-id-plan-price { font-size: var(--text-sm); }
.hanzo-id-plan-price em { font-style: normal; color: var(--muted-foreground); }
.hanzo-id-plan-desc { color: var(--muted-foreground); font-size: var(--text-sm); }
+15 -1
View File
@@ -1,10 +1,24 @@
import { useState } from 'react'
import type { BrandContract } from '@hanzo/id-shared'
/**
* Brand lockup for the auth pages. Renders the brand logo when it loads, and
* falls back to the brand NAME as a text wordmark when the logo is absent or
* fails to load — so a 404 logo (e.g. a brand package that doesn't ship its
* assets) never shows a broken-image icon. The name always exists on the
* brand contract, so the header is always presentable.
*/
export function BrandHeader({ brand }: { brand: BrandContract }) {
const [imgOk, setImgOk] = useState(true)
const showImg = Boolean(brand.logoUrl) && imgOk
return (
<header className="hanzo-id-brand-header">
<a href="/" aria-label={brand.name}>
<img src={brand.logoUrl} alt={brand.name} height={32} />
{showImg ? (
<img src={brand.logoUrl} alt={brand.name} height={32} onError={() => setImgOk(false)} />
) : (
<span className="hanzo-id-wordmark">{brand.name}</span>
)}
</a>
</header>
)
+10 -1
View File
@@ -1,6 +1,7 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { App } from './App'
import { Analytics } from './analytics'
import { registerProvider } from '@hanzo/id-idv'
import { createStubProvider } from '@hanzo/id-idv/providers/stub'
import './app.css'
@@ -12,6 +13,14 @@ const root = document.getElementById('root')
if (!root) throw new Error('#root missing')
createRoot(root).render(
<StrictMode>
<App />
{/*
Telemetry wraps App rather than living inside it: the pageview must be
recorded for the arrival itself, including the loads where `/config.json`
or the brand package fails and App renders nothing but an error. Those are
exactly the visits worth counting.
*/}
<Analytics>
<App />
</Analytics>
</StrictMode>,
)
+122
View File
@@ -0,0 +1,122 @@
/**
* Per-org marketing content + app launcher links.
*
* The brand-neutral `BrandContract` (from `loadBrand`) carries only the
* visual essentials (name, logo, accent). The split-view login's marketing
* panel and the post-login apps launcher need richer, org-specific copy —
* ported verbatim from the frozen `legacy-nextjs` design (`staticBranding`
* content + `orgApps`). Keyed by `org.orgId` so it stays decoupled from
* hostname switches; unknown orgs fall back to `hanzo`.
*/
export interface Quote {
readonly text: string
readonly author: string
readonly role?: string
}
export interface Marketing {
/** Pill above the hero ("✦ <tagline>"). */
readonly tagline?: string
/** Hero heading. */
readonly title: string
/** Hero subheading. */
readonly subtitle: string
/** Rotating testimonials. */
readonly quotes: readonly Quote[]
}
export interface AppLink {
readonly name: string
readonly href: string
readonly description: string
}
const MARKETING: Record<string, Marketing> = {
hanzo: {
tagline: 'AI-powered development',
title: 'Start building in seconds',
subtitle: 'Describe your idea and watch AI bring it to life instantly.',
quotes: [
{ text: 'Hanzo is amazing. It is revolutionizing how we build and deploy applications.', author: 'Developer', role: 'Software Engineer' },
],
},
lux: {
tagline: 'Lux-powered infrastructure',
title: 'Start deploying in seconds',
subtitle: 'High-performance blockchain infrastructure for the Lux ecosystem.',
quotes: [
{ text: 'Lux is fast. We deploy chains in minutes, not weeks.', author: 'Validator', role: 'Node Operator' },
],
},
zoo: {
tagline: 'Open AI research network',
title: 'Build the future of DeAI',
subtitle: 'Open AI research and decentralized science for everyone.',
quotes: [
{ text: 'Zoo is where bleeding-edge DeAI experiments actually ship.', author: 'Researcher', role: 'ML Engineer' },
],
},
pars: {
tagline: 'Sovereign digital identity',
title: 'Welcome to Pars',
subtitle: 'The decentralized network for the next generation.',
quotes: [
{ text: 'Pars gives our community a sovereign, verifiable identity layer.', author: 'Member', role: 'Community Lead' },
],
},
}
// The launcher lists PRODUCTS a person opens, not every host we run.
//
// Hanzo is three: App (build), Chat (talk), Cloud (the platform + its API).
// "Console" is NOT a fourth — it is Cloud's former name, and console.hanzo.ai
// now redirects to cloud.hanzo.ai, so listing both showed one product twice
// under two names and sent half the traffic through a redirect. It is gone;
// nothing here links to console.hanzo.ai.
//
// Analytics, Platform and Storage came out with it: s3.hanzo.ai answers a bare
// XML AccessDenied to a browser (it is an S3 API endpoint, not a page), and the
// other two are surfaces inside Cloud rather than products of their own. A
// launcher that lands you on an error page teaches people the tiles are broken.
const APPS: Record<string, readonly AppLink[]> = {
hanzo: [
{ name: 'App', href: 'https://hanzo.app', description: 'Build with AI' },
{ name: 'Chat', href: 'https://hanzo.chat', description: 'AI chat interface' },
{ name: 'Cloud', href: 'https://cloud.hanzo.ai', description: 'Models, compute & API' },
],
lux: [
{ name: 'Bridge', href: 'https://bridge.lux.network', description: 'Cross-chain bridge' },
{ name: 'Exchange', href: 'https://lux.exchange', description: 'DEX trading' },
{ name: 'Cloud', href: 'https://lux.cloud', description: 'Lux Cloud' },
{ name: 'Explorer', href: 'https://explore.lux.network', description: 'Block explorer' },
],
zoo: [
{ name: 'Network', href: 'https://zoo.ngo', description: 'Zoo Labs Foundation' },
{ name: 'ZIPs', href: 'https://zips.zoo.ngo', description: 'Improvement proposals' },
{ name: 'Chat', href: 'https://chat.zoo.ngo', description: 'DeAI chat interface' },
],
pars: [
{ name: 'Network', href: 'https://pars.network', description: 'Pars Network' },
{ name: 'Vote', href: 'https://pars.vote', description: 'Governance & proposals' },
],
}
const BILLING: Record<string, string> = {
hanzo: 'https://billing.hanzo.ai',
lux: 'https://billing.lux.network',
zoo: 'https://billing.zoo.network',
pars: 'https://billing.pars.network',
}
export function marketingFor(orgId: string): Marketing {
return MARKETING[orgId] ?? MARKETING.hanzo
}
export function appsFor(orgId: string): readonly AppLink[] {
return APPS[orgId] ?? APPS.hanzo
}
export function billingFor(orgId: string): string {
return BILLING[orgId] ?? BILLING.hanzo
}
+42 -21
View File
@@ -1,33 +1,54 @@
import { useEffect, useState } from 'react'
import type { BrandContract } from '@hanzo/id-shared'
import type { AuthClient } from '@hanzo/id-auth'
import type { BrandContract, OrgConfig } from '@hanzo/id-shared'
import { createIam } from '@hanzo/id-auth'
import { BrandHeader } from '../components/BrandHeader'
export function Callback({ client, brand }: { client: AuthClient; brand: BrandContract }) {
/**
* OAuth/OIDC callback — the portal's OWN PKCE return, and only that.
*
* One kind of return lands here: an IAM authorization code for a flow this
* portal started (password, wallet, or a federated provider begun through
* `signinRedirect`). The `@hanzo/iam` SDK's `handleCallback` completes it,
* reading back the exact PKCE verifier and state it stored.
*
* There is no second, social-specific case. A federated sign-in returns from the
* IdP to IAM's OWN callback (`/v1/iam/oauth/callback`), which does the code
* exchange server-side and sends the browser back here with an ordinary IAM code
* — indistinguishable from any other. The page used to carry a branch that
* decoded a base64 provider `state` and posted the raw IdP code back to IAM; no
* endpoint ever accepted that, and nothing can produce that state any more.
*
* Routing after the exchange:
* - A non-OIDC "come back here" target left in `post_login_redirect` (device
* approval) → forward tokens there.
* - A bare portal sign-in → `/onboarding`.
*
* An app that sent the user here for a code never reaches this page at all: that
* flow re-enters IAM's authorize endpoint and IAM redirects straight to the app.
*/
export function Callback({ org, brand }: { org: OrgConfig; brand: BrandContract }) {
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const sp = new URLSearchParams(window.location.search)
const code = sp.get('code')
if (!code) {
setError('Missing authorization code.')
return
}
const codeVerifier = sessionStorage.getItem('pkce_verifier') ?? undefined
client
.exchange(code, codeVerifier)
const iam = createIam(org)
iam
.handleCallback(window.location.href)
.then((tok) => {
// Forward the tokens to whichever app initiated this flow.
const target = sessionStorage.getItem('post_login_redirect') ?? '/'
const url = new URL(target, window.location.origin)
url.searchParams.set('access_token', tok.accessToken)
if (tok.refreshToken) url.searchParams.set('refresh_token', tok.refreshToken)
if (tok.idToken) url.searchParams.set('id_token', tok.idToken)
sessionStorage.removeItem('pkce_verifier')
const target = sessionStorage.getItem('post_login_redirect')
sessionStorage.removeItem('post_login_redirect')
window.location.replace(url.toString())
if (target) {
// Forward tokens to the page that sent the user to sign in.
const url = new URL(target, window.location.origin)
url.searchParams.set('access_token', tok.accessToken)
if (tok.refreshToken) url.searchParams.set('refresh_token', tok.refreshToken)
if (tok.idToken) url.searchParams.set('id_token', tok.idToken)
window.location.replace(url.toString())
return
}
// Bare portal sign-in → onboarding.
window.location.replace('/onboarding')
})
.catch((e) => setError(String(e)))
}, [client])
}, [org])
return (
<div className="hanzo-id-page hanzo-id-callback">
+293
View File
@@ -0,0 +1,293 @@
import { useEffect, useState, type ReactNode } from 'react'
import type { BrandContract } from '@hanzo/id-shared'
import { LoginForm, SocialButtons, type AuthClient, type DeviceInfoResult } from '@hanzo/id-auth'
import { BrandHeader } from '../components/BrandHeader'
/**
* RFC 8628 device-authorization approval (`/login/oauth/device`).
*
* The terminal leg of `hanzo login`: the CLI shows a short `user_code` and sends
* the human here (the IAM `verification_uri`; `verification_uri_complete`
* appends the code as a PATH segment, `/login/oauth/device/<code>` — IAM builds
* it that way because that is the route this page is registered on, and
* `readUserCode` accepts the `?user_code=` query form too). The human signs in
* to the SAME issuer, confirms the code matches what their device shows, and
* approves — which binds their identity onto the pending row (`Token.User`,
* owner/name) so the CLI's token poll stops answering `authorization_pending`
* and mints. There is no `UserSignIn` flag; an empty `User` IS "not yet
* approved".
*
* Auth is reused, never reimplemented: not-signed-in renders the normal
* `<LoginForm>` + `<SocialButtons>`; once the issuer session cookie is set the
* page reads it back from `/v1/iam/get-account` and shows the confirm step.
* Approval rides that session cookie (`client.approveDevice`), so no token ever
* touches the URL or logs.
*
* The screen exists to answer ONE question — which application am I authorizing?
* — so the application it names is read from the code (`client.deviceInfo`) and
* from nowhere else. Until IAM has named one there is no name on screen and no
* button to press.
*/
type Phase =
| { s: 'checking' }
| { s: 'signin' }
| { s: 'confirm'; email?: string }
| { s: 'consent'; email?: string }
| { s: 'approving' }
| { s: 'approved' }
/** Read the user_code from `?user_code=` first, then a trailing path segment
* (`/login/oauth/device/<code>`) so both the complete and bare verification
* URIs work; absent → the user types it. */
function readUserCode(): string {
const fromQuery = new URLSearchParams(window.location.search).get('user_code')
if (fromQuery) return fromQuery
const m = window.location.pathname.match(/\/login\/oauth\/device\/([^/?#]+)/)
return m ? decodeURIComponent(m[1]!) : ''
}
/** A device-flow return must never leave tokens/codes sitting in the address
* bar (history, referrer, shoulder-surf). Strip everything but `user_code`. */
function scrubUrl() {
const url = new URL(window.location.href)
let changed = false
for (const k of ['access_token', 'refresh_token', 'id_token', 'code', 'state']) {
if (url.searchParams.has(k)) {
url.searchParams.delete(k)
changed = true
}
}
if (changed) window.history.replaceState({}, '', url.toString())
}
export function DeviceApproval({ client, brand }: { client: AuthClient; brand: BrandContract }) {
const [phase, setPhase] = useState<Phase>({ s: 'checking' })
const [userCode, setUserCode] = useState(() => readUserCode())
// The code is prefilled from `?user_code=` and stays EDITABLE, which is the
// anti-phishing property that matters: approving is an explicit click on a
// code the human can read and correct against what their own device shows.
// There was also a "I started this sign-in" checkbox in front of that click.
// No device page anyone actually uses has one — Google, GitHub and AWS all
// show the code and an Approve button — and a tickbox is not evidence: a
// victim being walked through a crafted link ticks it as readily as they
// click Approve. It bought nothing and cost every honest user a step.
const [error, setError] = useState<string | null>(null)
// WHICH application is asking — the whole point of this screen, and the one
// thing the page cannot know on its own. It used to render `org.appName`: this
// PORTAL's own branding, a static per-org string, so a sign-in started by
// hanzo-cli was approved under a screen reading "hanzo-console". The client is
// a property of the CODE (it lives on the pending row and is what the backend
// actually approves), so it is read from the code — `client.deviceInfo`,
// IAM `POST /v1/iam/oauth/device/info`.
//
// That read is session-gated and answers with one opaque refusal for unknown /
// expired / already-approved, so it is no oracle for hunting live codes: it
// tells a caller strictly less than the approval that same caller could already
// attempt.
//
// null = not resolved yet. NOTHING is rendered in its place — no fallback name,
// no portal name, no guess. Naming the wrong party is the defect being fixed
// here, and a screen that names none is strictly better than one that lies.
const [app, setApp] = useState<DeviceInfoResult | null>(null)
const named = app?.ok ? app : null
// Resolve the issuer session: signed in → confirm, else → sign-in form. Reads
// same-origin from `/v1/iam/get-account` (cookie session; the brand `*.id`
// host IS `iamUrl`, so the cookie rides along) — identical to the Portal.
useEffect(() => {
scrubUrl()
let alive = true
fetch(new URL('/v1/iam/get-account', client.org.iamUrl).toString(), {
credentials: 'include',
headers: { Accept: 'application/json' },
})
.then((r) => r.json())
.then((b: Record<string, unknown>) => {
if (!alive) return
const d = b.data as Record<string, unknown> | undefined
if (b.status === 'ok' && d && typeof d === 'object') {
setPhase({ s: 'confirm', email: str(d.email) ?? str(d.name) })
} else {
setPhase({ s: 'signin' })
}
})
.catch(() => {
if (alive) setPhase({ s: 'signin' })
})
return () => {
alive = false
}
}, [client.org.iamUrl])
// Ask WHICH application the code belongs to. Needs both halves of what the
// endpoint is gated on: the issuer session (every phase past the check has one
// except `signin`, and the boolean keeps consent/approving from re-asking) and
// a code to ask about.
//
// The debounce is what makes a hand-typed code work: each keystroke is a
// different code, and a partial one is not a real code — without it the human
// watches IAM's refusal flash at them while they are still typing.
const signedIn = phase.s !== 'checking' && phase.s !== 'signin'
const blank = userCode.trim().length === 0
useEffect(() => {
if (!signedIn || blank) return
let alive = true
const t = setTimeout(() => {
client.deviceInfo(userCode).then((r) => {
if (!alive) return
// The session lapsed between the get-account check and this read. The
// signin phase preserves the code in `returnTo`, so the human lands back
// here with it intact.
if (!r.ok && r.loginRequired) setPhase({ s: 'signin' })
else setApp(r)
})
}, 250)
return () => {
alive = false
clearTimeout(t)
}
}, [client, userCode, signedIn, blank])
async function approve() {
setError(null)
setPhase({ s: 'approving' })
const res = await client.approveDevice(userCode)
if (res.ok) {
setPhase({ s: 'approved' })
} else if (res.required) {
setPhase({ s: 'consent' })
} else {
setError(res.error ?? 'Approval failed. Restart sign-in on your device.')
setPhase({ s: 'confirm' })
}
}
if (phase.s === 'checking') {
return (
<Shell brand={brand}>
<div className="hanzo-id-spinner" style={{ borderTopColor: brand.accentColor ?? '#fff' }} />
</Shell>
)
}
if (phase.s === 'signin') {
// Sign in first (reuse the normal flow). The password leg stays on-page via
// `onAuthenticated` and re-checks the session; the social leg round-trips and
// returns to THIS page (postLoginRedirect), where the session check resumes.
const returnTo = userCode
? `${window.location.pathname}?user_code=${encodeURIComponent(userCode)}`
: window.location.pathname
return (
<Shell brand={brand}>
<h1>Sign in to approve your device</h1>
<SocialButtons client={client} intent="signin" postLoginRedirect={returnTo} />
<LoginForm client={client} onAuthenticated={() => setPhase({ s: 'confirm' })} />
<p className="hanzo-id-footer-links">
<a href="/forget">Forgot password?</a>
</p>
</Shell>
)
}
if (phase.s === 'approved') {
return (
<Shell brand={brand}>
<h1>You're signed in on your device</h1>
<p className="lede">Approval complete — you can close this window and return to your device.</p>
</Shell>
)
}
const busy = phase.s === 'approving'
const consent = phase.s === 'consent'
const email = phase.s === 'confirm' || phase.s === 'consent' ? phase.email : undefined
// ONE place shows a failure, whichever leg produced it: the approval itself, or
// the lookup that has to name an application before an approval is offered.
const failure = error ?? (app && !app.ok ? app.error : null)
return (
<Shell brand={brand}>
<h1>Approve this device</h1>
{email ? <p className="lede">Signed in as {email}</p> : null}
{/* The application is named ONLY once IAM has confirmed it — the clientId
alongside the display name, so a technical human can check it reads
`hanzo-cli` exactly and not something that merely looks like it. Until
then the sentence says a device, because that is all the page knows. */}
<p className="hanzo-id-device-prompt">
{named ? (
<>
<strong>{named.displayName}</strong> (<code>{named.clientId}</code>) is asking to
sign in as you.
</>
) : (
'A device is asking to sign in as you.'
)}{' '}
Approve ONLY if the code below matches the one shown on that device, and only if you
started this sign-in yourself.
</p>
<label className="hanzo-id-field">
<span>Device code</span>
<input
type="text"
inputMode="text"
autoCapitalize="characters"
autoCorrect="off"
spellCheck={false}
autoComplete="one-time-code"
aria-label="Device code"
className="hanzo-id-input hanzo-id-device-code"
value={userCode}
// A name — and a failure — belongs to a CODE. Edit the code and both are
// dropped in the same commit, so no name is ever left on screen for a
// frame beside a code it was not confirmed for.
onChange={(e) => {
setUserCode(e.target.value)
setApp(null)
setError(null)
}}
placeholder="e.g. K7M4P2QH"
disabled={busy}
/>
</label>
{consent && named ? (
<p className="hanzo-id-info">
<strong>{named.displayName}</strong> needs your consent to continue. By approving
you grant the device showing this code access to your profile.
</p>
) : null}
{failure ? <p role="alert" className="hanzo-id-error">{failure}</p> : null}
<div className="hanzo-id-cta-row">
<button
type="button"
// Nothing is approved until IAM has named what is being approved. An
// unresolved or refused lookup leaves no button to press, rather than a
// button that authorizes an unnamed party.
className="hanzo-id-btn"
disabled={busy || !named}
onClick={approve}
>
{busy ? 'Approving' : consent ? 'Approve & grant access' : 'Approve'}
</button>
</div>
</Shell>
)
}
function Shell({ brand, children }: { brand: BrandContract; children: ReactNode }) {
return (
<div className="hanzo-id-page hanzo-id-device">
<BrandHeader brand={brand} />
<main>{children}</main>
</div>
)
}
function str(v: unknown): string | undefined {
return typeof v === 'string' && v.length > 0 ? v : undefined
}
+189 -4
View File
@@ -1,5 +1,14 @@
import type { BrandContract } from '@hanzo/id-shared'
import { LoginForm, type AuthClient } from '@hanzo/id-auth'
import { useEffect, useState } from 'react'
import { idBrandLabel, type BrandContract } from '@hanzo/id-shared'
import {
LoginForm,
MfaEnrollForm,
OTPForm,
SocialButtons,
mfaChannelOf,
type AuthClient,
type LoginResponse,
} from '@hanzo/id-auth'
import { BrandHeader } from '../components/BrandHeader'
export function Login({ client, brand }: { client: AuthClient; brand: BrandContract }) {
@@ -7,19 +16,195 @@ export function Login({ client, brand }: { client: AuthClient; brand: BrandContr
const redirectUri = sp.get('redirect_uri') ?? undefined
const state = sp.get('state') ?? undefined
const clientIdOverride = sp.get('client_id') ?? undefined
const codeChallenge = sp.get('code_challenge') ?? undefined
const codeChallengeMethod = (sp.get('code_challenge_method') as 'S256' | 'plain' | null) ?? undefined
const nonce = sp.get('nonce') ?? undefined
// A provider the user already chose upstream (the console sends
// `?provider_hint=provider-github` when they click "Continue with GitHub"
// over there). With no live session we launch that provider straight away
// instead of showing this form — so the click lands directly in the social
// flow, never bouncing the user to a second login page. We honor ONLY
// `provider_hint`, never a bare `provider=` (the SSO SDK uses that for its
// `<org>-iam` IDP hint — a different meaning).
const providerHint = sp.get('provider_hint') ?? undefined
// TRUE single sign-on. When an app sent the user here for an authorization
// code (client_id + redirect_uri present) AND the browser already holds an
// issuer session from an earlier sign-in (the `iam_session_id` cookie), mint
// the code from that session and redirect straight back — no form, no
// credential re-entry. With no live session we fall back to auto-launching the
// hinted provider if one was named, else the interactive form. A bare portal
// visit (no client_id/redirect_uri) has nowhere to redirect, so it shows the
// form immediately as before.
const canSilent = !!clientIdOverride && !!redirectUri
const fallback = providerHint ? 'federate' : 'form'
const [phase, setPhase] = useState<'silent' | 'federate' | 'form'>(canSilent ? 'silent' : fallback)
// null = show the credential form; otherwise IAM returned an MFA signal and
// we render the matching step instead of navigating on.
const [mfa, setMfa] = useState<LoginResponse | null>(null)
const [challengeError, setChallengeError] = useState<string | null>(null)
const clientId = clientIdOverride ?? client.org.clientId
useEffect(() => {
if (!canSilent) return
let cancelled = false
client
.silentLogin({
clientId: clientIdOverride!,
application: clientIdOverride!,
redirectUri: redirectUri!,
state,
codeChallenge,
codeChallengeMethod,
nonce,
})
.then((r) => {
if (cancelled) return
if (r.redirectUrl) {
window.location.assign(r.redirectUrl)
} else {
setPhase(fallback)
}
})
.catch(() => {
if (!cancelled) setPhase(fallback)
})
return () => {
cancelled = true
}
// Run once on mount; the OAuth params are fixed for the life of the page.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// The credential check succeeded (or MFA was satisfied). For a downstream
// OIDC request, re-enter authorize with the now-established IAM session so it
// mints the code; for a bare portal sign-in, land on onboarding.
function completeAfterAuth() {
if (redirectUri) {
window.location.href = client.authorize({
clientId,
redirectUri,
state: state ?? '',
codeChallenge,
codeChallengeMethod,
})
} else {
window.location.href = '/onboarding'
}
}
if (phase === 'silent') {
return (
<div className="hanzo-id-page hanzo-id-login">
<BrandHeader brand={brand} />
<main aria-busy="true">
<p>Signing you in</p>
</main>
</div>
)
}
// Auto-launch the hinted provider. `SocialButtons` is headless here — it
// resolves the app config and runs the hop; we show a busy state meanwhile,
// and drop to the form only if the hint matched no configured provider.
if (phase === 'federate') {
return (
<div className="hanzo-id-page hanzo-id-login">
<BrandHeader brand={brand} />
<main aria-busy="true">
<p>Signing you in</p>
<SocialButtons
client={client}
clientIdOverride={clientIdOverride}
intent="signin"
postLoginRedirect={redirectUri}
autoStart={providerHint}
onAutoStartResolved={(started) => {
if (!started) setPhase('form')
}}
/>
</main>
</div>
)
}
if (mfa?.mfaStage === 'enroll') {
return (
<div className="hanzo-id-page hanzo-id-login">
<BrandHeader brand={brand} />
<main>
<MfaEnrollForm client={client} onComplete={completeAfterAuth} />
</main>
</div>
)
}
if (mfa?.mfaStage === 'challenge') {
const iamType = mfa.mfaTypes?.[0] ?? 'app'
async function onChallenge(code: string) {
setChallengeError(null)
const res = await client.mfaChallenge({
mfaType: iamType,
passcode: code,
clientId,
application: client.org.appName,
organization: client.org.orgId,
redirectUri,
state,
codeChallenge,
codeChallengeMethod,
})
if (res.error) {
setChallengeError(res.error)
} else if (res.redirectUrl) {
window.location.href = res.redirectUrl
} else {
completeAfterAuth()
}
}
return (
<div className="hanzo-id-page hanzo-id-login">
<BrandHeader brand={brand} />
<main>
<h1>Two-factor authentication</h1>
<p className="lede">Enter the code from your authenticator app to finish signing in.</p>
{challengeError ? <p role="alert" className="hanzo-id-error">{challengeError}</p> : null}
<OTPForm channel={mfaChannelOf(iamType)} onSubmit={onChallenge} />
</main>
</div>
)
}
return (
<div className="hanzo-id-page hanzo-id-login">
<BrandHeader brand={brand} />
<main>
<h1>Sign in to {brand.name}</h1>
<h1>Sign in to {idBrandLabel(brand, client.org.orgId)}</h1>
<SocialButtons
client={client}
clientIdOverride={clientIdOverride}
intent="signin"
postLoginRedirect={redirectUri}
/>
<LoginForm
client={client}
redirectUri={redirectUri}
state={state}
clientIdOverride={clientIdOverride ?? undefined}
codeChallenge={codeChallenge}
codeChallengeMethod={codeChallengeMethod}
nonce={nonce}
onMfaRequired={setMfa}
/>
<p className="hanzo-id-footer-links">
<a href="/forget">Forgot password?</a> · <a href="/signup">Create account</a>
{/* Carry the OIDC request across. These are full page loads, so a bare
href drops the client_id, redirect_uri, state and PKCE challenge the
app sent — and registration then has nothing to return the new user
to. `Signup` reads exactly these params. */}
<a href={`/forget${window.location.search}`}>Forgot password?</a> ·{' '}
<a href={`/signup${window.location.search}`}>Create account</a>
</p>
</main>
</div>
+148
View File
@@ -0,0 +1,148 @@
import { useEffect, useMemo, useState } from 'react'
import type { BrandContract, OrgConfig } from '@hanzo/id-shared'
import { createIam } from '@hanzo/id-auth'
import { OnboardingFlow, createOnboardingService, type OnboardingState } from '@hanzo/id-onboarding'
import { getConnector } from '@hanzo/id-connect/connectors'
import { BrandHeader } from '../components/BrandHeader'
/** Fleet default pay origin; a white-label brand overrides via catalog `payUrl`. */
const DEFAULT_PAY_URL = 'https://pay.hanzo.ai'
/**
* Post-login onboarding page.
*
* Reached after a bare portal sign-in (no downstream `redirect_uri`). Mounts
* the `@hanzo/id-onboarding` flow (org → project → wallet → consent → plan)
* wired to:
*
* - the IAM session token: read from the same `@hanzo/iam` PKCE client the
* Callback stored it on, so the onboarding writes ride the logged-in
* user's bearer token. One client, one way.
* - a `window.ethereum` wallet connector: the host owns the wallet lib so
* the onboarding pkg stays wallet-agnostic. Absent injected provider →
* the wallet step is skip-only.
*
* NEVER REPEATS: completion is recorded on the USER (Properties, via
* saveOnboarding) — so before mounting the flow this page reads it back and,
* if the user already finished onboarding on ANY browser, goes straight to
* the portal. The read failing open (network blip → run the flow again) is
* deliberate: repeating is annoying, silently skipping a required step is
* worse.
*
* On completion it routes by the plan choice — the platform is prepay-only,
* so a plan goes to the pay cart and pay-as-you-go goes to the top-up flow.
* A downstream app that wanted a token would have carried `redirect_uri` and
* never reached here.
*/
export function Onboarding({ org, brand }: { org: OrgConfig; brand: BrandContract }) {
const iam = useMemo(() => createIam(org), [org])
const payUrl = org.payUrl || DEFAULT_PAY_URL
const service = useMemo(
() =>
createOnboardingService({
iamUrl: org.iamUrl,
orgId: org.orgId,
getAccessToken: () => iam.getValidAccessToken(),
}),
[org, iam],
)
// null = still checking; false = run the flow; true = already done, leaving.
const [alreadyDone, setAlreadyDone] = useState<boolean | null>(null)
useEffect(() => {
let alive = true
;(async () => {
// TOKEN BOOTSTRAP. A password/form sign-in mints a SESSION COOKIE and
// lands here directly — the PKCE SDK holds no token, so every onboarding
// WRITE (update-user needs a bearer; the cookie alone is refused, and
// rightly — a cookie-authed write is a CSRF surface) answered 401 and
// the funnel dead-ended at consent. With a live session, authorize is
// the silent-SSO branch: signinRedirect bounces through IAM with no UI,
// /callback stores the token and returns to /onboarding. One bounce per
// session, guarded, so a broken mint degrades to the read-only 401
// instead of a redirect loop.
const token = await iam.getValidAccessToken().catch(() => null)
if (!alive) return
if (!token) {
const guard = 'onboarding.token_bounce'
if (!sessionStorage.getItem(guard)) {
sessionStorage.setItem(guard, '1')
void iam.signinRedirect()
return
}
} else {
sessionStorage.removeItem('onboarding.token_bounce')
}
try {
const { completedAt } = await service.readOnboarding()
if (!alive) return
if (completedAt) {
setAlreadyDone(true)
window.location.replace('/?signed_in=1')
return
}
} catch {
// Read failing open (network blip → run the flow) is deliberate.
}
if (alive) setAlreadyDone(false)
})()
return () => {
alive = false
}
}, [service, iam])
function onComplete(state: OnboardingState) {
// Prepay-only funnel: the last step recorded a choice, now act on it.
// - a plan slug → the pay cart, seats + payment there (price is the
// catalog's — commerce recomputes server-side, the slug is enough)
// - pay as you go → the top-up flow ($5 minimum, all methods)
// The plan choice is already persisted on the user, so bouncing off the
// payment page never re-enters onboarding.
const choice = state.planChoice
if (choice === 'payg') {
window.location.replace(`${payUrl}/onboard`)
} else if (choice) {
window.location.replace(`${payUrl}/cart?plan=${encodeURIComponent(choice)}`)
} else {
// No recorded choice (should not happen — the plan step requires one):
// land on the authenticated portal rather than a dead end.
window.location.replace('/?signed_in=1')
}
}
return (
<div className="hanzo-id-page hanzo-id-onboarding-page">
<BrandHeader brand={brand} />
<main>
{alreadyDone === false ? (
<OnboardingFlow
service={service}
brandName={brand.name}
connectWallet={connectInjectedWallet}
onComplete={onComplete}
payUrl={payUrl}
/>
) : (
<div className="hanzo-id-spinner" aria-label="Loading" />
)}
</main>
</div>
)
}
/**
* EVM wallet connector backed by @hanzo/id-connect (EIP-6963 multi-injection,
* viem under the hood). Returns the checksummed 0x address, or null when the
* user cancels or no injected EVM wallet is present. The onboarding wallet step
* only needs the address (it stores it via a full-row read-merge-write in the
* service), so we connect and return account.address — no signature round-trip.
*/
async function connectInjectedWallet(): Promise<string | null> {
try {
const account = await getConnector('evm').connect()
return account.address ?? null
} catch {
return null // user rejected, or no injected EVM wallet available
}
}
+109 -7
View File
@@ -1,16 +1,118 @@
import type { BrandContract } from '@hanzo/id-shared'
import { useEffect, useState } from 'react'
import type { IamIdentity } from '@hanzo/iam/react'
import { UserMenu, resolveIdentity } from '@hanzo/iam/react'
import type { BrandContract, OrgConfig } from '@hanzo/id-shared'
import type { AuthClient } from '@hanzo/id-auth'
import { Login } from './Login'
import { BrandHeader } from '../components/BrandHeader'
import { appsFor, billingFor } from '../marketing'
type Auth =
| { s: 'loading' }
| { s: 'anon' }
| { s: 'authed'; identity: IamIdentity | null }
/**
* Root portal (`/`). The portal IS the login surface, not a marketing hero:
*
* - signed out → the actual `<Login>` form (GitHub/Google/email+password),
* identical to `/login`. A bare sign-in here lands on
* onboarding, then back on `/` authenticated.
* - signed in → the apps launcher (the org's apps) + billing / sign-out.
*
* Auth is read same-origin from `/v1/iam/get-account` (cookie session;
* `org.iamUrl` is the brand's own `*.id` host, so this is first-party and
* the session cookie rides along). The `?signed_in=1` marker set by the
* bare-login / onboarding-complete redirect is the authoritative "just
* authenticated" signal when the cookie read hasn't propagated yet.
*/
export function Portal({
client,
brand,
org,
}: {
client: AuthClient
brand: BrandContract
org: OrgConfig
}) {
const [auth, setAuth] = useState<Auth>({ s: 'loading' })
useEffect(() => {
let alive = true
const justSignedIn = new URLSearchParams(window.location.search).get('signed_in') === '1'
fetch(new URL('/v1/iam/get-account', org.iamUrl).toString(), {
credentials: 'include',
headers: { Accept: 'application/json' },
})
.then((r) => r.json())
.then((b: Record<string, unknown>) => {
if (!alive) return
const d = b.data as Record<string, unknown> | undefined
if (b.status === 'ok' && d && typeof d === 'object') {
// `resolveIdentity` is the SAME name/avatar/initials resolution every
// Hanzo surface shows, so the portal cannot disagree with the console
// about who you are — and it never falls back to a raw uuid.
setAuth({ s: 'authed', identity: resolveIdentity(d, {}) })
} else {
setAuth(justSignedIn ? { s: 'authed', identity: null } : { s: 'anon' })
}
})
.catch(() => {
if (alive) setAuth(justSignedIn ? { s: 'authed', identity: null } : { s: 'anon' })
})
return () => {
alive = false
}
}, [org.iamUrl])
if (auth.s === 'loading') {
return (
<div className="hanzo-id-page" style={{ minHeight: '40vh' }}>
<div className="hanzo-id-spinner" style={{ borderTopColor: brand.accentColor ?? 'var(--primary)' }} />
</div>
)
}
// Signed out: the root IS the login form (no marketing hero).
if (auth.s === 'anon') return <Login client={client} brand={brand} />
// Signed in: the apps launcher.
const apps = appsFor(org.orgId)
const billingUrl = billingFor(org.orgId)
const logoutUrl = client.logout(undefined, `${org.publicOrigin}/login`)
export function Portal({ brand }: { brand: BrandContract }) {
return (
<div className="hanzo-id-page hanzo-id-portal">
<BrandHeader brand={brand} />
<main>
<h1>Welcome to {brand.name}</h1>
<p className="lede">{brand.description}</p>
<div className="hanzo-id-cta-row">
<a className="hanzo-id-btn primary" href="/login">Sign in</a>
<a className="hanzo-id-btn" href="/signup">Create account</a>
<h1>Your {brand.name} apps</h1>
<div className="hanzo-id-apps">
{apps.map((a) => (
<a key={a.name} className="hanzo-id-applink" href={a.href}>
<div className="hanzo-id-applink-name">
<span>{a.name}</span>
<span aria-hidden></span>
</div>
<div className="hanzo-id-applink-desc">{a.description}</div>
</a>
))}
</div>
{/* The ONE account control. This was a hand-rolled "Billing / Sign out"
link row; every Hanzo surface mounts @hanzo/iam's UserMenu instead,
so identity, billing and sign-out read and behave identically here,
on hanzo.chat and in the console. The portal's session is its own
cookie read rather than an IamProvider, which is exactly what the
`identity` / `isAuthenticated` / `onSignOut` overrides are for.
No `brand` prop: omitting `markSvg` would put the HANZO mark on
lux.id and zoo.id, and this one image serves all four portals. */}
<div className="hanzo-id-portal-account">
<UserMenu
identity={auth.identity}
isAuthenticated
usageUrl={billingUrl}
usageLabel="Billing"
onSignOut={() => { window.location.href = logoutUrl }}
/>
</div>
</main>
</div>
+27 -3
View File
@@ -1,18 +1,42 @@
import type { BrandContract } from '@hanzo/id-shared'
import { SignupForm, type AuthClient } from '@hanzo/id-auth'
import { SignupForm, SocialButtons, type AuthClient } from '@hanzo/id-auth'
import { BrandHeader } from '../components/BrandHeader'
export function Signup({ client, brand }: { client: AuthClient; brand: BrandContract }) {
const sp = new URLSearchParams(window.location.search)
const inviteCode = sp.get('invite') ?? undefined
const clientIdOverride = sp.get('client_id') ?? undefined
const redirectUri = sp.get('redirect_uri') ?? undefined
// The same downstream OIDC request `Login` reads. Registration ends in a
// sign-in, so it needs the whole request — not just the client and its
// callback — or the minted code carries no PKCE binding and no state.
const state = sp.get('state') ?? undefined
const codeChallenge = sp.get('code_challenge') ?? undefined
const codeChallengeMethod = (sp.get('code_challenge_method') as 'S256' | 'plain' | null) ?? undefined
const nonce = sp.get('nonce') ?? undefined
return (
<div className="hanzo-id-page hanzo-id-signup">
<BrandHeader brand={brand} />
<main>
<h1>Create your {brand.name} account</h1>
<SignupForm client={client} inviteCode={inviteCode} />
<SocialButtons
client={client}
clientIdOverride={clientIdOverride}
intent="signup"
postLoginRedirect={redirectUri}
/>
<SignupForm
client={client}
inviteCode={inviteCode}
clientIdOverride={clientIdOverride}
redirectUri={redirectUri}
state={state}
codeChallenge={codeChallenge}
codeChallengeMethod={codeChallengeMethod}
nonce={nonce}
/>
<p className="hanzo-id-footer-links">
Already have an account? <a href="/login">Sign in</a>
Already have an account? <a href={`/login${window.location.search}`}>Sign in</a>
</p>
</main>
</div>
+131
View File
@@ -0,0 +1,131 @@
/**
* Every token this surface references must RESOLVE.
*
* An undefined CSS custom property paints nothing and reports no error, so this
* whole class of defect survives review: `var(--surface-1)` and
* `var(--shadow-lg)` shipped in @hanzo/iam's account menu against a token layer
* that defines neither, and the menu rendered transparent. "It is declared" was
* never evidence — nor was "it type-checks", because the reference is built at
* runtime from a string and is invisible to both the compiler and grep.
*
* So the gate is resolution, and it is computed from what the bundle ACTUALLY
* serves: it walks app.css's @import graph into the installed @hanzo/design,
* collects the tokens those files declare, then asserts that every var(--x)
* anywhere under src/ — plus every token @hanzo/iam paints its menu with — is
* in that set.
*
* This fails if someone cherry-picks token groups again (the four-of-nine
* subset this file used to import left --z-*, --shadow-* and --space-* out),
* if @hanzo/design renames or drops a token, or if a component starts asking
* for a token in @hanzo/brand's vocabulary instead of @hanzo/design's.
*/
import { test } from 'vitest'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
const SRC = path.join(import.meta.dirname, '.')
/**
* Both packages restrict `exports`, so resolve them by walking up for the
* node_modules directory rather than by asking Node for a subpath it refuses.
*/
function pkgRoot(name: string): string {
for (let d = SRC; d !== path.dirname(d); d = path.dirname(d)) {
const p = path.join(d, 'node_modules', name)
if (fs.existsSync(path.join(p, 'package.json'))) return fs.realpathSync(p)
}
throw new Error(`${name} is not installed`)
}
const DESIGN = pkgRoot('@hanzo/design')
const read = (p: string) => fs.readFileSync(p, 'utf8')
const declaredIn = (css: string) => [...css.matchAll(/(--[a-zA-Z0-9-]+)\s*:/g)].map((m) => m[1])
const referencedIn = (css: string) => [...css.matchAll(/var\(\s*(--[a-zA-Z0-9-]+)/g)].map((m) => m[1])
/** Follow @import from an entry stylesheet into the @hanzo/design package. */
function tokenFiles(entry: string, seen = new Set<string>()): string[] {
for (const m of read(entry).matchAll(/@import\s+(?:url\()?['"]([^'"]+)['"]/g)) {
const spec = m[1]
const abs = spec.startsWith('@hanzo/design/')
? path.join(DESIGN, spec.slice('@hanzo/design/'.length))
: path.resolve(path.dirname(entry), spec)
if (seen.has(abs) || !fs.existsSync(abs)) continue
seen.add(abs)
tokenFiles(abs, seen)
}
return [...seen]
}
/** Every .css/.ts/.tsx under src/, so inline `var(--x)` in a component counts. */
function sources(dir: string, out: string[] = []): string[] {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name)
if (e.isDirectory()) sources(p, out)
// Skip this file: its own doc comment quotes `var(--x)`.
else if (/\.(css|tsx?)$/.test(e.name) && p !== import.meta.filename) out.push(p)
}
return out
}
const served = tokenFiles(path.join(SRC, 'app.css'))
const available = new Set(served.flatMap((f) => declaredIn(read(f))))
const files = sources(SRC)
const local = new Set(files.flatMap((f) => declaredIn(read(f))))
/**
* Coverage is judged by TOKENS, not by files. This used to assert that each
* `tokens/<group>.css` was itself reached through @import, which stopped being
* true at @hanzo/design 0.4.x: gen-tokens.mjs now FLATTENS all ten groups into
* styles.css so a bundler never has to resolve those subpaths. Nothing was
* dropped — the files still ship, they are just inlined — so asking "is every
* token this group declares actually served?" catches the cherry-picking this
* gate exists for, and survives however the package chooses to assemble itself.
*/
test('app.css serves the whole @hanzo/design token layer, not a subset', () => {
const groups = fs.readdirSync(path.join(DESIGN, 'tokens')).filter((f) => f.endsWith('.css'))
const missing = groups.filter((g) => {
const declared = [...new Set(declaredIn(read(path.join(DESIGN, 'tokens', g))))]
return declared.length > 0 && !declared.every((t) => available.has(t))
})
assert.deepEqual(missing, [], `token groups authored by @hanzo/design but never served here: ${missing.join(', ')}`)
/* base.css is the odd group and token coverage cannot see it: it ships the
ELEMENT DEFAULTS (the control, the focused control, the scrollbar) as 23
:where() rules, and the single token it declares — --border — is declared by
colors.css too. It is also the only group that opens `@layer base`, so that
is the exact marker for "the defaults are actually being served". */
assert.ok(
served.some((f) => /@layer\s+base/.test(read(f))),
'the element defaults from tokens/base.css are not served'
)
})
test('every token this surface references is defined', () => {
const unresolved = new Map<string, string[]>()
for (const f of files) {
for (const name of referencedIn(read(f))) {
if (available.has(name) || local.has(name)) continue
const at = unresolved.get(name) ?? []
at.push(path.relative(SRC, f))
unresolved.set(name, at)
}
}
assert.deepEqual(
[...unresolved].map(([n, at]) => `${n} (${[...new Set(at)].join(', ')})`),
[],
)
})
test('every token @hanzo/iam paints the account menu with is defined', () => {
// The menu is a distributed component: it emits its own stylesheet at
// runtime, so its token references never appear in this repo's source and no
// amount of grepping here would find them. Read them out of the shipped
// bundle instead — literal `var(--x)` plus the names its tok() helper builds.
const iam = read(path.join(pkgRoot('@hanzo/iam'), 'dist/react.js'))
const names = new Set([
...referencedIn(iam),
...[...iam.matchAll(/\btok\(\s*["']([a-zA-Z0-9-]+)["']/g)].map((m) => `--${m[1]}`),
])
const unresolved = [...names].filter((n) => !available.has(n)).sort()
assert.deepEqual(unresolved, [])
})
+16
View File
@@ -0,0 +1,16 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
/**
* Publishable event-ingest key (pk-…), inlined at build time from the
* EVENT_INGEST_KEY build-arg (KMS `deploy/EVENT_INGEST_KEY`, env `prod`).
* Declared so a typo reads as a type error rather than as `any` — Vite's
* ImportMetaEnv carries a string index signature, so an undeclared
* `import.meta.env.VITE_EVENT_INGEST_KEZ` would type-check and ship empty.
*/
readonly VITE_EVENT_INGEST_KEY: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
+3 -2
View File
@@ -2,9 +2,10 @@
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
"@/*": [
"./src/*"
]
},
"types": ["vite/client"]
},
+24 -10
View File
@@ -2,34 +2,48 @@ import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { resolve } from 'path'
import { readFileSync, existsSync } from 'fs'
import { createRequire } from 'module'
// ESM Vite config has no global `require`; build one bound to this file so
// `require.resolve('@scope/brand/brand.json')` works at config-eval time.
const req = createRequire(import.meta.url)
/**
* Per-brand brand.json copy plugin.
*
* Each per-org brand package (`@hanzo/brand`, `@luxfi/brand`, `@zooai/brand`,
* `@parsdao/brand`) ships a `brand.json` at the package root. We serve them
* verbatim at `/brand/<pkg>/brand.json` so the runtime tenant resolver can
* fetch the right one based on hostname.
* `@parsdao/brand`) ships a `brand.json` at the package root. We serve each at
* a FLAT, encoding-safe path `/brand/<scope>.json` (scope = the npm scope:
* `@hanzo/brand` -> `hanzo`). A nested `/brand/@hanzo/brand/brand.json` URL
* carries a literal `@` and an encoded `%2F` that the production static server
* (hanzoai/static) cannot map to the on-disk file — it falls through to the
* SPA catch-all and returns index.html, so the runtime brand fetch would parse
* HTML as JSON. The flat slug avoids that entirely. `loadBrand` fetches the
* same `/brand/<scope>.json`.
*
* Assets (logos, favicons) are imported by URL inside the per-brand `brand.json`
* (CDN URLs in production), so no further asset copying is needed.
*/
const BRAND_PACKAGES = ['@hanzo/brand', '@luxfi/brand', '@zooai/brand', '@parsdao/brand']
/** npm scope -> flat brand slug: `@hanzo/brand` -> `hanzo`. */
const brandSlug = (pkg: string): string => pkg.replace(/^@/, '').split('/')[0]!
function brandJsonPlugin() {
return {
name: 'hanzo-id-brand-json',
configureServer(server: any) {
server.middlewares.use((req: any, res: any, next: any) => {
const m = /^\/brand\/(.+)\/brand\.json$/.exec(req.url ?? '')
server.middlewares.use((req2: any, res: any, next: any) => {
const m = /^\/brand\/([^/]+)\.json$/.exec(req2.url ?? '')
if (!m) return next()
const pkg = decodeURIComponent(m[1]!)
if (!BRAND_PACKAGES.includes(pkg)) {
const slug = m[1]!
const pkg = BRAND_PACKAGES.find((p) => brandSlug(p) === slug)
if (!pkg) {
res.statusCode = 404
return res.end()
}
try {
const path = require.resolve(`${pkg}/brand.json`)
const path = req.resolve(`${pkg}/brand.json`)
res.setHeader('Content-Type', 'application/json')
res.setHeader('Cache-Control', 'no-store')
return res.end(readFileSync(path, 'utf8'))
@@ -42,11 +56,11 @@ function brandJsonPlugin() {
generateBundle(this: any) {
for (const pkg of BRAND_PACKAGES) {
try {
const path = require.resolve(`${pkg}/brand.json`)
const path = req.resolve(`${pkg}/brand.json`)
if (!existsSync(path)) continue
this.emitFile({
type: 'asset',
fileName: `brand/${pkg}/brand.json`,
fileName: `brand/${brandSlug(pkg)}.json`,
source: readFileSync(path, 'utf8'),
})
} catch {
+103
View File
@@ -0,0 +1,103 @@
# Hanzo ID — the ONE file that says how this repo builds, gates and ships.
#
# The only workflow is `.hanzo/workflows/cicd.yml`, ~10 lines that import
# hanzoai/ci; everything real is here, and platform.hanzo.ai reads this same
# file. It replaces TWO image lanes that had drifted apart:
#
# .hanzo/workflows/deploy.yml the one that worked — buildx, two registry
# logins, a version read out of package.json and
# a post-push manifest check. Every line of it is
# something the reusable already does.
# .github/workflows/docker.yml a second lane on a plane with no runners for
# our labels. Already reduced to an echo, but it
# kept the shape of a build lane alive in a
# directory this forge cannot even read.
#
# Neither gated anything. This repo has 13 test files and 160 assertions and CI
# ran none of them, which is how `pkgs/shared/src/org.test.ts` sat RED on main
# while the code it tested was correct.
# Gates run DIRECTLY on the runner, not inside the image build. hanzoai/ci
# provisions Node 22 and enables corepack; corepack then reads `packageManager`
# from package.json, so the pnpm this uses is the SAME pnpm the Dockerfile
# activates — stated once, in package.json, rather than pinned again here.
#
# Three gates, because there are three different ways this repo breaks and one
# combined gate would report all of them as the same failure.
test:
- name: install
# --frozen-lockfile, deliberately stricter than the Dockerfile's
# `--frozen-lockfile=false`. The image build must not be blocked by a
# lockfile that drifted; CI is exactly where that drift should be caught.
run: |
set -e
corepack enable
pnpm install --frozen-lockfile
- name: typecheck
# Vite does NOT typecheck — `vite build` transpiles and strips types, so a
# type error ships a green image. This is the only thing in the pipeline
# that reads the types across all 7 workspace packages.
run: |
set -e
pnpm -r tc
- name: unit
# vitest over pkgs/**/src and apps/**/src (vitest.config.ts). 160 tests,
# including the org resolver that decides which brand a host authenticates
# as and which redirect_uri every social hop sends — the surface that
# produced days of "login is broken" and cannot be verified by looking at it.
run: |
set -e
pnpm test
images:
- name: id
context: .
dockerfile: Dockerfile
repo: ghcr.io/hanzoai/id
platforms: [linux/amd64]
# Fetched from KMS (`deploy/EVENT_INGEST_KEY`, env `prod`) and passed as
# --build-arg EVENT_INGEST_KEY. The reusable fails closed on an empty value,
# and the Dockerfile gates the shape and then asserts the key actually landed
# in the bundle — because a build that is green with an unattributed bundle
# is the failure mode here, not a build that goes red.
#
# This is the ONE spelling the fleet carries: KMS holds the plain name, each
# Dockerfile re-exports it with the prefix its bundler inlines.
build_secrets: [EVENT_INGEST_KEY]
# The version comes from package.json — hanzoai/ci's bin/imgver reads it as
# the declared floor, exactly as deploy.yml's `node -p require('./package.
# json').version` did, so a release is still a version bump and nothing about
# how this image is named changes.
#
# One difference worth stating: imgver takes max(declared, published)+1 patch
# when the declared version is ALREADY at the registry, where deploy.yml
# failed the build instead ("bump the version to cut a release"). Both refuse
# to put a second digest under a tag someone may already be pinning; this one
# publishes the next patch rather than going red on a docs commit.
#
# oci.hanzo.ai is still written to — the reusable crane-copies the exact tag
# set after the ghcr push. The PATH changes: deploy.yml pushed
# `oci.hanzo.ai/id`, the reusable writes the org-qualified
# `oci.hanzo.ai/hanzoai/id`, which is the fleet convention. Nothing live
# pulls either one; charts/app/values/hanzo/id.yaml pins
# `ghcr.io/hanzoai/id` by tag AND digest.
# No `deploy:` ON PURPOSE, and this is deploy.yml's own rule, not a new one.
#
# id is governed by Hanzo CD. The tag it runs is declared in
# hanzoai/universe (charts/app/values/hanzo/id.yaml, tag + digest together), and
# the in-cluster reconcile restores that within ~60-90s. A CI-side `kubectl
# patch` therefore CANNOT stick — and the reason to refuse it is not that it
# fails, it is that it LOOKS like it works: the patch applies, the pod rolls, and
# CD quietly puts the old tag back a minute later.
#
# It would also be actively wrong here. The reusable's deploy step guards a
# semver pin against a transient branch build by reading the current tag out of
# `infra/k8s/operator/crs/<svc>.yaml` — a path this service does not use — so the
# guard would not fire and a `sha-<short>-amd64` tag would be written over a
# reviewed semver+digest pin on the live sign-in surface for every property in
# the fleet.
#
# Build and deploy are separate concerns: this emits an immutable image, git
# declares desired state, CD applies it. To ship a build, set image.tag AND
# image.digest in universe and push.
+6 -2
View File
@@ -1,15 +1,19 @@
{
"name": "@hanzo/id",
"private": true,
"version": "0.1.1",
"version": "0.2.28",
"description": "Hanzo ID — white-label login + identity verification portal (Vite + @hanzo/gui)",
"packageManager": "pnpm@10.15.0",
"scripts": {
"build": "pnpm -r build",
"dev": "pnpm --filter @hanzo/id-web dev",
"tc": "pnpm -r tc",
"test": "vitest run",
"test:watch": "vitest",
"clean": "bash scripts/clean.sh"
},
"devDependencies": {
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"vitest": "^3.2.4"
}
}
+7 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@hanzo/id-auth",
"version": "0.1.0",
"version": "0.1.7",
"description": "Composable login / signup / OTP / OAuth-PKCE flows on top of @hanzo/iam. UI primitives in @hanzo/gui.",
"license": "BSD-3-Clause",
"type": "module",
@@ -12,13 +12,17 @@
"./forms": "./src/ui/index.ts",
"./package.json": "./package.json"
},
"files": ["src"],
"files": [
"src"
],
"scripts": {
"tc": "tsc --noEmit"
},
"dependencies": {
"@hanzo/id-connect": "workspace:*",
"@hanzo/id-shared": "workspace:*",
"@hanzo/iam": "^0.9.4"
"@hanzo/iam": "^0.21.1",
"@paulmillr/qr": "^0.3.0"
},
"peerDependencies": {
"react": ">=19",
+156
View File
@@ -0,0 +1,156 @@
/**
* MFA wiring tests — pure, no network (fetch is mocked via `fetchImpl`).
* Run with: pnpm --filter @hanzo/id-auth test
*
* Locks the wire contract verified live against iam.hanzo.ai:
* - login answers a forced-MFA org with `data:"RequiredMfa"` (enroll) or
* `data:"NextMfa"` + the challenge list — named `mfa` first, legacy
* `data2`; both decode until the legacy slot is deleted. STRINGS, never a
* boolean.
* - the `/v1/iam/mfa/setup/*` calls carry EVERY param on the query string with
* an EMPTY body (the one shape IAM's authz self-match + controller accept).
* - the challenge re-POSTs `/v1/iam/login` with `{mfaType,passcode}` and NO
* username, riding the MFA session cookie.
*/
import { test } from 'vitest'
import assert from 'node:assert/strict'
import type { OrgConfig } from '@hanzo/id-shared'
import { createAuthClient, mfaChannelOf, MFA_TOTP } from './client.ts'
const TENANT: OrgConfig = {
orgId: 'hanzo',
iamUrl: 'https://hanzo.id',
iamIssuer: 'https://hanzo.id',
clientId: 'hanzo-id',
appName: 'hanzo-id',
publicOrigin: 'https://hanzo.id',
brandPackage: '@hanzo/brand',
}
type Call = { url: string; init: RequestInit }
function mockFetch(body: unknown, calls: Call[]): typeof fetch {
return (async (input: string | URL, init?: RequestInit) => {
calls.push({ url: String(input), init: init ?? {} })
return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } })
}) as unknown as typeof fetch
}
test('login → RequiredMfa maps to an enroll signal (not a redirect)', async () => {
const calls: Call[] = []
const client = createAuthClient({ org: TENANT, fetchImpl: mockFetch({ status: 'ok', data: 'RequiredMfa' }, calls) })
const res = await client.login({
identifier: 'davelorenzini@gmail.com',
password: 'x',
clientId: 'hanzo-id',
application: 'hanzo-id',
organization: 'hanzo',
})
assert.equal(res.mfaRequired, true)
assert.equal(res.mfaStage, 'enroll')
assert.equal(res.redirectUrl, undefined, 'must NOT short-circuit to /onboarding')
})
const CHALLENGE = [{ mfaType: 'app', enabled: true }, { mfaType: 'sms', enabled: true }]
// Both spellings decode until IAM's envelope rename lands everywhere and the
// legacy slot is deleted: named `mfa` (new), untyped `data2` (legacy), and
// named-first precedence when a transitional server sends both.
test.each([
['named mfa', { status: 'ok', data: 'NextMfa', mfa: CHALLENGE }],
['legacy data2', { status: 'ok', data: 'NextMfa', data2: CHALLENGE }],
['mfa wins over data2', { status: 'ok', data: 'NextMfa', mfa: CHALLENGE, data2: [{ mfaType: 'email', enabled: true }] }],
])('login → NextMfa maps to a challenge signal and carries the allowed types (%s)', async (_spelling, body) => {
const calls: Call[] = []
const client = createAuthClient({ org: TENANT, fetchImpl: mockFetch(body, calls) })
const res = await client.login({
identifier: 'davelorenzini@gmail.com',
password: 'x',
clientId: 'hanzo-id',
application: 'hanzo-id',
organization: 'hanzo',
})
assert.equal(res.mfaStage, 'challenge')
assert.deepEqual(res.mfaTypes, ['app', 'sms'])
})
test('mfaInitiate puts owner/name/mfaType on the query string with an empty body', async () => {
const calls: Call[] = []
const data = { secret: 'BOUYRUSHJCEDDB33', url: 'otpauth://totp/Hanzo:x?secret=BOUYRUSHJCEDDB33', recoveryCodes: ['rc-1'] }
const client = createAuthClient({ org: TENANT, fetchImpl: mockFetch({ status: 'ok', data }, calls) })
const setup = await client.mfaInitiate({ owner: 'hanzo', name: 'davelorenzini@gmail.com' })
assert.equal(setup.secret, 'BOUYRUSHJCEDDB33')
assert.equal(setup.mfaType, MFA_TOTP)
assert.deepEqual(setup.recoveryCodes, ['rc-1'])
const u = new URL(calls[0].url)
assert.equal(u.pathname, '/v1/iam/mfa/setup/initiate')
assert.equal(u.searchParams.get('owner'), 'hanzo')
assert.equal(u.searchParams.get('name'), 'davelorenzini@gmail.com')
assert.equal(u.searchParams.get('mfaType'), 'app')
assert.equal(calls[0].init.method, 'POST')
assert.equal(calls[0].init.body, undefined, 'body must be empty for authz self-match')
assert.equal(calls[0].init.credentials, 'include')
})
test('mfaVerify carries owner/name (for authz) + secret + passcode on the query', async () => {
const calls: Call[] = []
const client = createAuthClient({ org: TENANT, fetchImpl: mockFetch({ status: 'ok', data: 'OK' }, calls) })
const r = await client.mfaVerify({ owner: 'hanzo', name: 'dave@x', secret: 'SEC', passcode: '123456' })
assert.equal(r.ok, true)
const u = new URL(calls[0].url)
assert.equal(u.pathname, '/v1/iam/mfa/setup/verify')
assert.equal(u.searchParams.get('owner'), 'hanzo')
assert.equal(u.searchParams.get('secret'), 'SEC')
assert.equal(u.searchParams.get('passcode'), '123456')
assert.equal(u.searchParams.get('mfaType'), 'app')
})
test('mfaVerify surfaces an IAM error instead of throwing', async () => {
const calls: Call[] = []
const client = createAuthClient({ org: TENANT, fetchImpl: mockFetch({ status: 'error', msg: 'wrong passcode' }, calls) })
const r = await client.mfaVerify({ owner: 'hanzo', name: 'dave@x', secret: 'SEC', passcode: '000000' })
assert.equal(r.ok, false)
assert.equal(r.error, 'wrong passcode')
})
test('mfaEnable echoes the recovery code back on the query', async () => {
const calls: Call[] = []
const client = createAuthClient({ org: TENANT, fetchImpl: mockFetch({ status: 'ok', data: 'OK' }, calls) })
const r = await client.mfaEnable({ owner: 'hanzo', name: 'dave@x', secret: 'SEC', recoveryCode: 'rc-1' })
assert.equal(r.ok, true)
const u = new URL(calls[0].url)
assert.equal(u.pathname, '/v1/iam/mfa/setup/enable')
assert.equal(u.searchParams.get('recoveryCodes'), 'rc-1')
assert.equal(u.searchParams.get('secret'), 'SEC')
})
test('mfaChallenge re-POSTs /v1/iam/login with mfaType/passcode and NO username', async () => {
const calls: Call[] = []
// code flow: data is the freshly minted auth code
const client = createAuthClient({ org: TENANT, fetchImpl: mockFetch({ status: 'ok', data: 'AUTHCODE' }, calls) })
const res = await client.mfaChallenge({
mfaType: 'app',
passcode: '654321',
clientId: 'hanzo-id',
application: 'hanzo-id',
organization: 'hanzo',
redirectUri: 'https://app.example/cb',
state: 'st',
})
const sent = JSON.parse(String(calls[0].init.body)) as Record<string, unknown>
assert.equal(new URL(calls[0].url).pathname, '/v1/iam/login')
assert.equal(sent.mfaType, 'app')
assert.equal(sent.passcode, '654321')
assert.equal(sent.username, undefined, 'challenge must not send a username')
assert.equal(calls[0].init.credentials, 'include')
assert.equal(res.redirectUrl, 'https://app.example/cb?code=AUTHCODE&state=st')
})
test('mfaChannelOf maps IAM types to UI channels', () => {
assert.equal(mfaChannelOf('app'), 'totp')
assert.equal(mfaChannelOf('sms'), 'sms')
assert.equal(mfaChannelOf('email'), 'email')
assert.equal(mfaChannelOf('anything-else'), 'totp')
})
+685
View File
@@ -0,0 +1,685 @@
import { test } from 'vitest'
import assert from 'node:assert/strict'
import { createAuthClient } from './client.ts'
import type { OrgConfig } from '@hanzo/id-shared'
// A capturing fetch double: records the URL + parsed JSON body of the last call
// and returns a canned IAM "ok" response. No network.
function capturingFetch() {
const calls: { url: string; body: Record<string, unknown> }[] = []
const fetchImpl: typeof fetch = async (input, init) => {
const url = typeof input === 'string' ? input : input.toString()
let body: Record<string, unknown> = {}
if (init?.body && typeof init.body === 'string') body = JSON.parse(init.body)
calls.push({ url, body })
return new Response(JSON.stringify({ status: 'ok', data: 'AUTHCODE' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}
return { calls, fetchImpl }
}
// A routing fetch double for the silent-SSO org gate: silentLogin resolves the
// app's org (`/v1/iam/get-app-login`) and the ambient session's owner
// (`/v1/iam/get-account`) BEFORE minting a code (`/v1/iam/login`). This lets a
// test set the app org + session owner independently and assert whether the mint
// leg ran. `sessionOwner: null` models "no live session" (get-account errors).
function routingFetch(opts: { appOrg: string; sessionOwner: string | null; code?: string }) {
const calls: { url: string; body: Record<string, unknown> }[] = []
const json = (payload: unknown) =>
new Response(JSON.stringify(payload), { status: 200, headers: { 'Content-Type': 'application/json' } })
const fetchImpl: typeof fetch = async (input, init) => {
const url = typeof input === 'string' ? input : input.toString()
let body: Record<string, unknown> = {}
if (init?.body && typeof init.body === 'string') body = JSON.parse(init.body)
calls.push({ url, body })
if (url.includes('/get-app-login')) {
return json({ status: 'ok', data: { name: 'app', organization: opts.appOrg, providers: [] } })
}
if (url.includes('/get-account')) {
return opts.sessionOwner
? json({ status: 'ok', data: { owner: opts.sessionOwner, name: 'z' } })
: json({ status: 'error', msg: 'please sign in first' })
}
return json({ status: 'ok', data: opts.code ?? 'AUTHCODE' })
}
return { calls, fetchImpl }
}
function org(overrides: Partial<OrgConfig> = {}): OrgConfig {
return {
orgId: 'hanzo',
iamUrl: 'https://hanzo.id',
iamIssuer: 'https://hanzo.id',
clientId: 'hanzo-console',
appName: 'hanzo-console',
publicOrigin: 'https://hanzo.id',
oauthCallbackOrigin: 'https://hanzo.id',
brandPackage: '@hanzo/brand',
...overrides,
}
}
// `client.login` is a PURE PASSTHROUGH for `organization`: it sends what the
// caller gave it and omits the key when there is nothing to send. That is the
// contract these two tests pin, and it is unchanged.
//
// What DID change is whose job it is to supply one. This used to be deliberate
// omission — IAM resolved the user cross-org so a colliding identity
// (z@hanzo.ai exists in both `admin` and `hanzo`) landed on admin/* with a full
// multi-org session. iam2 removed that on purpose, treating the collision as a
// defect ("the F-2 bug where z@hanzo.ai collided across admin and hanzo": it
// coupled lockout counters across rows and gave a brute-force oracle on the
// superadmin), and now REFUSES an org-less login. So LoginForm resolves the
// app's own org via get-app-login and always passes one. Do not re-add an
// omit-the-org path here expecting the server to figure it out — it will not,
// and it fails with an HTTP 200 that reads like a wrong password.
test('login omits organization when the caller supplies none', async () => {
const { calls, fetchImpl } = capturingFetch()
const client = createAuthClient({ org: org(), fetchImpl })
await client.login({
identifier: 'z@hanzo.ai',
password: 'pw',
clientId: 'hanzo-console',
application: 'hanzo-console',
// organization intentionally not provided — LoginForm now always resolves one
})
assert.equal(calls.length, 1)
assert.equal(
'organization' in calls[0]!.body,
false,
'organization must be absent when the caller supplies none — the client never invents one',
)
// The identity + app still ride the request.
assert.equal(calls[0]!.body.username, 'z@hanzo.ai')
assert.equal(calls[0]!.body.application, 'hanzo-console')
})
// An empty-string org is treated the same as unset (defensive: a catalog might
// emit "").
test('login omits organization when it is an empty string', async () => {
const { calls, fetchImpl } = capturingFetch()
const client = createAuthClient({ org: org(), fetchImpl })
await client.login({
identifier: 'z@hanzo.ai',
password: 'pw',
clientId: 'hanzo-console',
application: 'hanzo-console',
organization: '',
})
assert.equal('organization' in calls[0]!.body, false)
})
// A brand that DELIBERATELY scopes its portal to one org can still force it.
test('login INCLUDES organization when one is explicitly provided', async () => {
const { calls, fetchImpl } = capturingFetch()
const client = createAuthClient({ org: org(), fetchImpl })
await client.login({
identifier: 'someone',
password: 'pw',
clientId: 'hanzo-console',
application: 'hanzo-console',
organization: 'hanzo',
})
assert.equal(calls[0]!.body.organization, 'hanzo')
})
// Per-app SSO: the downstream app's client_id + redirect_uri still flow through;
// `type` flips to `code` and the org is STILL omitted (resolution stays correct
// for the SSO path too).
test('app SSO (redirectUri present) uses type=code and still omits organization', async () => {
const { calls, fetchImpl } = capturingFetch()
const client = createAuthClient({ org: org(), fetchImpl })
await client.login({
identifier: 'z@hanzo.ai',
password: 'pw',
clientId: 'hanzo-console',
application: 'hanzo-console',
redirectUri: 'https://console.hanzo.ai/auth/iam/callback',
state: 'xyz',
})
assert.equal(calls[0]!.body.type, 'code')
assert.match(calls[0]!.url, /type=code/)
assert.equal('organization' in calls[0]!.body, false)
})
// Signup MUST still carry a concrete org — you cannot create a user in "no org".
test('signup STILL sends organization (unchanged — create needs a concrete org)', async () => {
const { calls, fetchImpl } = capturingFetch()
const client = createAuthClient({ org: org(), fetchImpl })
await client.signup({
email: 'new@hanzo.ai',
password: 'pw',
clientId: 'hanzo-console',
application: 'hanzo-console',
organization: 'hanzo',
})
assert.equal(calls[0]!.body.organization, 'hanzo')
})
// REGRESSION (every new customer was stranded on the portal): IAM's signup is
// CREATE-ONLY — it sets no session and mints no code. Signup must therefore end
// in a real sign-in, or the app that sent the user waits forever for a code.
test('signup COMPLETES the OIDC request — create, then sign in, then redirect back with the code', async () => {
const { calls, fetchImpl } = capturingFetch()
const client = createAuthClient({ org: org(), fetchImpl })
const res = await client.signup({
email: 'new@hanzo.ai',
password: 'pw',
clientId: 'hanzo-app',
application: 'hanzo-app',
organization: 'hanzo',
redirectUri: 'https://hanzo.app/auth/callback',
state: 'xyz',
codeChallenge: 'CHALLENGE',
codeChallengeMethod: 'S256',
})
// Two legs, in order: create the row, then authenticate it.
assert.equal(calls.length, 2)
assert.match(calls[0]!.url, /\/v1\/iam\/signup/)
assert.match(calls[1]!.url, /\/v1\/iam\/login/)
// The sign-in leg carries the downstream request, so the code is PKCE-bound.
assert.match(calls[1]!.url, /code_challenge=CHALLENGE/)
assert.match(calls[1]!.url, /code_challenge_method=S256/)
assert.match(calls[1]!.url, /type=code/)
assert.equal(calls[1]!.body.username, 'new@hanzo.ai')
// And the caller is handed a destination BACK AT THE APP — never the portal's
// own /onboarding, which is where the create-only response used to land.
assert.equal(res.redirectUrl, 'https://hanzo.app/auth/callback?code=AUTHCODE&state=xyz')
})
// `autoSignin` was posted for its name and dropped on the floor: the Go
// signupForm has no such field, so it never signed anyone in. Do not post a flag
// the server does not read — it is what made this look like it worked.
test('signup does NOT post autoSignin (IAM has no such field)', async () => {
const { calls, fetchImpl } = capturingFetch()
const client = createAuthClient({ org: org(), fetchImpl })
await client.signup({
email: 'new@hanzo.ai',
password: 'pw',
clientId: 'hanzo-app',
application: 'hanzo-app',
organization: 'hanzo',
})
assert.equal('autoSignin' in calls[0]!.body, false)
})
// IAM refuses with HTTP 200 + status:"error", so the status code proves nothing.
// A refused create must surface the reason and must NOT go on to try a login.
test('a refused signup surfaces the reason and never attempts a sign-in', async () => {
const seen: string[] = []
const fetchImpl: typeof fetch = async (input) => {
seen.push(typeof input === 'string' ? input : input.toString())
return new Response(JSON.stringify({ status: 'error', msg: 'email already exists', data: null }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}
const client = createAuthClient({ org: org(), fetchImpl })
const res = await client.signup({
email: 'taken@hanzo.ai',
password: 'pw',
clientId: 'hanzo-app',
application: 'hanzo-app',
organization: 'hanzo',
redirectUri: 'https://hanzo.app/auth/callback',
})
assert.equal(res.error, 'email already exists')
assert.equal(res.redirectUrl, undefined)
assert.equal(seen.length, 1)
assert.match(seen[0]!, /\/v1\/iam\/signup/)
})
// REGRESSION (the `hanzo-iam does not exist` social-login bug): an IAM
// app-provider LINK can carry an outer `name` that is NOT the provider record's
// name (some seeds label it `<org>-iam`). The provider's real identity is the
// nested `provider.name` the backend resolves on the social hop. getAppLogin
// MUST surface the inner record name (`provider-github`), never the outer label,
// or SocialButtons posts `provider=<org>-iam` and the backend 400s.
function appLoginFetch(payload: unknown): typeof fetch {
return async () =>
new Response(JSON.stringify({ status: 'ok', data: payload }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}
test('getAppLogin uses the nested provider record name, not the outer link label', async () => {
const fetchImpl = appLoginFetch({
name: 'hanzo-console',
organization: 'hanzo',
providers: [
{
// Outer link label — a real-world seed set this to the per-app default.
name: 'hanzo-iam',
canSignIn: true,
canSignUp: true,
// Nested provider RECORD — the true identity + creds.
provider: { name: 'provider-github', type: 'GitHub', clientId: 'Iv23li_real', scopes: '' },
},
],
})
const client = createAuthClient({ org: org(), fetchImpl })
const app = await client.getAppLogin('hanzo-console')
assert.ok(app, 'app login resolved')
assert.equal(app!.providers.length, 1)
const gh = app!.providers[0]!
assert.equal(gh.name, 'provider-github', 'provider name comes from the nested record')
assert.equal(gh.key, 'github', 'key strips the provider- prefix')
assert.equal(gh.type, 'GitHub')
assert.equal(gh.configured, true, 'a real (non-placeholder) clientId is configured')
})
// Federation is entered by NAMING the provider on IAM's own authorize endpoint.
// The `provider` field has always been on OAuthAuthorizeRequest; `authorize`
// never emitted it, which is why social sign-in had no server side at all.
test('authorize emits the provider record name, so IAM federates instead of showing its login', () => {
const client = createAuthClient({ org: org({ iamUrl: 'https://hanzo.id' }) })
const url = new URL(
client.authorize({
clientId: 'hanzo-console',
redirectUri: 'https://hanzo.id/callback',
state: 'rp1',
codeChallenge: 'C1',
codeChallengeMethod: 'S256',
provider: 'provider-github',
}),
)
assert.equal(url.pathname, '/v1/iam/oauth/authorize')
// The RECORD name, never the bare key: federationProvider matches
// ProviderItem.Name exactly (live, `provider=github` is refused).
assert.equal(url.searchParams.get('provider'), 'provider-github')
// The app's own request is what IAM binds the minted code to.
assert.equal(url.searchParams.get('client_id'), 'hanzo-console')
assert.equal(url.searchParams.get('redirect_uri'), 'https://hanzo.id/callback')
assert.equal(url.searchParams.get('code_challenge'), 'C1')
assert.equal(url.searchParams.get('code_challenge_method'), 'S256')
})
test('authorize without a provider stays the ordinary hosted-login request', () => {
const client = createAuthClient({ org: org({ iamUrl: 'https://hanzo.id' }) })
const url = new URL(
client.authorize({ clientId: 'hanzo-console', redirectUri: 'https://hanzo.id/callback', state: 'rp1' }),
)
assert.equal(url.searchParams.get('provider'), null)
})
// When there is NO nested record (degenerate seed), fall back to the outer label
// so the provider is still surfaced rather than dropped.
test('getAppLogin falls back to the outer name when no nested provider record', async () => {
const fetchImpl = appLoginFetch({
name: 'hanzo-console',
organization: 'hanzo',
providers: [{ name: 'provider-google', canSignIn: true, canSignUp: true, provider: null }],
})
const client = createAuthClient({ org: org(), fetchImpl })
const app = await client.getAppLogin('hanzo-console')
assert.ok(app)
assert.equal(app!.providers[0]!.name, 'provider-google')
})
// TRUE SSO — the silent leg. silentLogin carries NO credentials: IAM mints the
// code from the existing issuer session (cookie sent via credentials:include).
// It builds the redirect back to the app from the minted code + state. The mint
// runs ONLY when the ambient session's org matches the app's org (same-org SSO,
// the common case: a hanzo session signing into a hanzo app).
test('silentLogin (same-org session) mints the code and redirects, carrying NO credentials', async () => {
const { calls, fetchImpl } = routingFetch({ appOrg: 'hanzo', sessionOwner: 'hanzo' })
const client = createAuthClient({ org: org(), fetchImpl })
const r = await client.silentLogin({
clientId: 'hanzo-console',
application: 'hanzo-console',
redirectUri: 'https://console.hanzo.ai/auth/iam/callback',
state: 'st1',
codeChallenge: 'chal',
})
// The mint leg (POST /v1/iam/login, type=code) ran after the org gate passed.
const mint = calls.find((c) => c.body.type === 'code')
assert.ok(mint, 'mint leg ran for a same-org session')
// No credentials of any kind — this is session-only.
assert.equal('username' in mint!.body, false, 'no username in silent login')
assert.equal('password' in mint!.body, false, 'no password in silent login')
assert.equal('provider' in mint!.body, false, 'no provider hop in silent login')
assert.equal(mint!.body.application, 'hanzo-console')
// OAuth params ride the query so IAM mints a code for the right client + PKCE.
assert.match(mint!.url, /clientId=hanzo-console/)
assert.match(mint!.url, /code_challenge=chal/)
// The mint returns data:'AUTHCODE' -> a fully-formed app redirect.
assert.equal(
r.redirectUrl,
'https://console.hanzo.ai/auth/iam/callback?code=AUTHCODE&state=st1',
)
})
// THE ADMIN-GUARD FIX: silent SSO must NOT reuse a session that belongs to a
// DIFFERENT org than the app being signed into. An operator with an ambient
// hanzo/* session hitting the admin-guard (org=admin) must fall through to the
// interactive form (which authenticates in the admin org and resolves the
// admin/* identity) — NOT silently mint a code from the hanzo session (which
// would confer owner=hanzo and shadow the fix). No mint leg runs; no redirect.
test('silentLogin (cross-org session) does NOT mint — falls back to the form', async () => {
const { calls, fetchImpl } = routingFetch({ appOrg: 'admin', sessionOwner: 'hanzo' })
const client = createAuthClient({ org: org(), fetchImpl })
const r = await client.silentLogin({
clientId: 'hanzo-admin-guard',
application: 'hanzo-admin-guard',
redirectUri: 'https://admin.hanzo.ai/__guard/callback',
state: 'st1',
codeChallenge: 'chal',
})
assert.equal(r.redirectUrl, undefined, 'no silent redirect for a cross-org session')
assert.equal(calls.some((c) => c.body.type === 'code'), false, 'the mint leg must NOT run')
})
// Same-org SSO still holds when BOTH are the admin org: an operator already
// signed in as admin/* silently re-enters the admin console.
test('silentLogin (same admin-org session) mints for the admin-guard', async () => {
const { calls, fetchImpl } = routingFetch({ appOrg: 'admin', sessionOwner: 'admin' })
const client = createAuthClient({ org: org(), fetchImpl })
const r = await client.silentLogin({
clientId: 'hanzo-admin-guard',
application: 'hanzo-admin-guard',
redirectUri: 'https://admin.hanzo.ai/__guard/callback',
state: 'st1',
})
assert.ok(calls.find((c) => c.body.type === 'code'), 'mint leg ran for a same-org admin session')
assert.equal(r.redirectUrl, 'https://admin.hanzo.ai/__guard/callback?code=AUTHCODE&state=st1')
})
// No live session: silentLogin returns an empty response (no mint) so Login.tsx
// falls back to the interactive form (never a dead end).
test('silentLogin returns empty (no mint) when there is no session (form fallback)', async () => {
const { calls, fetchImpl } = routingFetch({ appOrg: 'hanzo', sessionOwner: null })
const client = createAuthClient({ org: org(), fetchImpl })
const r = await client.silentLogin({
clientId: 'hanzo-console',
application: 'hanzo-console',
redirectUri: 'https://console.hanzo.ai/auth/iam/callback',
})
assert.equal(r.redirectUrl, undefined)
assert.equal(calls.some((c) => c.body.type === 'code'), false, 'no mint without a session')
})
// ── Device-authorization approval (RFC 8628) ─────────────────────────────────
// approveDevice rides the issuer SESSION (like silentLogin): NO credentials in
// the body, `type:device` + the userCode IAM keys its DeviceAuthMap on, plus the
// org application/organization for the app lookup. On {status:ok} the device
// code is approved (UserSignIn=true) and the CLI's token poll succeeds.
test('approveDevice posts type=device + normalized userCode + org app/org, NO credentials', async () => {
const { calls, fetchImpl } = capturingFetch()
const client = createAuthClient({ org: org(), fetchImpl })
const r = await client.approveDevice('K7M4P2QH')
assert.equal(calls.length, 1)
assert.equal(calls[0]!.body.type, 'device')
assert.equal(calls[0]!.body.userCode, 'K7M4P2QH')
assert.equal(calls[0]!.body.application, 'hanzo-console')
assert.equal(calls[0]!.body.organization, 'hanzo')
// Session-only: never any credentials in a device approval.
assert.equal('username' in calls[0]!.body, false)
assert.equal('password' in calls[0]!.body, false)
assert.equal('provider' in calls[0]!.body, false)
assert.match(calls[0]!.url, /type=device/)
assert.equal(r.ok, true)
})
// IAM mints codes from an UPPERCASE unambiguous alphabet ([A-HJ-NP-Z2-9]); a
// human may transcribe them lower-cased or with stray spaces/dashes. Normalize
// TO uppercase so the lookup matches — case-insensitive entry, exact-match send.
test('approveDevice uppercases and strips spaces/dashes before sending', async () => {
const { calls, fetchImpl } = capturingFetch()
const client = createAuthClient({ org: org(), fetchImpl })
await client.approveDevice(' k7m4-p2qh ')
assert.equal(calls[0]!.body.userCode, 'K7M4P2QH')
})
// An empty/blank code never hits the network — fail fast with a clear message.
test('approveDevice rejects an empty code without calling fetch', async () => {
const { calls, fetchImpl } = capturingFetch()
const client = createAuthClient({ org: org(), fetchImpl })
const r = await client.approveDevice(' ')
assert.equal(calls.length, 0)
assert.equal(r.ok, false)
assert.ok(r.error)
})
// The IAM error message (e.g. "UserCode Expired") is surfaced verbatim.
test('approveDevice surfaces the IAM error message', async () => {
const fetchImpl: typeof fetch = async () =>
new Response(JSON.stringify({ status: 'error', msg: 'UserCode Expired' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
const client = createAuthClient({ org: org(), fetchImpl })
const r = await client.approveDevice('K7M4P2QH')
assert.equal(r.ok, false)
assert.equal(r.error, 'UserCode Expired')
})
// Consent branch: {status:ok, data:{required:true}} → {ok:false, required:true}
// so the page can render consent instead of treating it as success or a dead end.
test('approveDevice maps the consent-required branch to { required: true }', async () => {
const fetchImpl: typeof fetch = async () =>
new Response(JSON.stringify({ status: 'ok', data: { required: true } }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
const client = createAuthClient({ org: org(), fetchImpl })
const r = await client.approveDevice('K7M4P2QH')
assert.equal(r.ok, false)
assert.equal(r.required, true)
assert.equal(r.error, undefined)
})
// ── Which application is this code for? (deviceInfo) ─────────────────────────
// A one-call double for `POST /v1/iam/oauth/device/info`: records what the
// request actually was (URL, method, credentials, body) and answers with
// `payload`.
function deviceInfoFetch(payload: unknown) {
const calls: {
url: string
method?: string
credentials?: RequestCredentials
body?: string
}[] = []
const fetchImpl: typeof fetch = async (input, init) => {
calls.push({
url: typeof input === 'string' ? input : input.toString(),
method: init?.method,
credentials: init?.credentials,
body: typeof init?.body === 'string' ? init.body : undefined,
})
return new Response(JSON.stringify(payload), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}
return { calls, fetchImpl }
}
// THE REGRESSION THIS FILE EXISTS FOR. The approval page used to render
// `org.appName` — the PORTAL's own branding, the static `hanzo-console` this
// test's org() is configured with — so a device sign-in started by `hanzo-cli`
// was approved under a screen naming a different application. The name must come
// off the RESPONSE, which is the code's own application, and never off the org
// config; asserting both is what keeps the two from being confused again.
test('deviceInfo names the RESPONSE client, never the portal org appName', async () => {
const { calls, fetchImpl } = deviceInfoFetch({
status: 'ok',
data: { clientId: 'hanzo-cli', displayName: 'Hanzo CLI' },
})
const cfg = org()
const client = createAuthClient({ org: cfg, fetchImpl })
const r = await client.deviceInfo('K7M4P2QH')
assert.equal(r.ok, true)
assert.equal(r.ok && r.clientId, 'hanzo-cli')
assert.equal(r.ok && r.displayName, 'Hanzo CLI')
// The portal is hanzo-console. Nothing about it may reach the result.
assert.equal(cfg.appName, 'hanzo-console')
assert.notEqual(r.ok && r.clientId, cfg.appName)
assert.notEqual(r.ok && r.displayName, cfg.appName)
// A session-cookie POST at the /v1/ device-info path. The user_code is the one
// secret in this flow, so it rides the BODY: a request line is copied into
// ingress and proxy access logs where a body is not, and this page ships
// scrubUrl() precisely to keep the code out of URLs.
assert.equal(calls.length, 1)
assert.equal(calls[0]!.url, 'https://hanzo.id/v1/iam/oauth/device/info')
assert.equal(calls[0]!.method, 'POST')
assert.equal(calls[0]!.credentials, 'include')
assert.equal(calls[0]!.body, JSON.stringify({ userCode: 'K7M4P2QH' }))
assert.equal(calls[0]!.url.includes('K7M4P2QH'), false)
})
// Same normalization as the approval: a code transcribed lower-cased or with
// dashes must resolve to the same row IAM minted, or the page would refuse to
// name an application that is perfectly live.
test('deviceInfo uppercases and strips spaces/dashes into the body', async () => {
const { calls, fetchImpl } = deviceInfoFetch({
status: 'ok',
data: { clientId: 'hanzo-cli', displayName: 'Hanzo CLI' },
})
const client = createAuthClient({ org: org(), fetchImpl })
await client.deviceInfo(' k7m4-p2qh ')
assert.equal(calls[0]!.url, 'https://hanzo.id/v1/iam/oauth/device/info')
assert.equal(calls[0]!.body, JSON.stringify({ userCode: 'K7M4P2QH' }))
})
// An empty code names nothing and never hits the network.
test('deviceInfo rejects an empty code without calling fetch', async () => {
const { calls, fetchImpl } = deviceInfoFetch({ status: 'ok', data: {} })
const client = createAuthClient({ org: org(), fetchImpl })
const r = await client.deviceInfo(' ')
assert.equal(calls.length, 0)
assert.equal(r.ok, false)
assert.equal(r.ok === false && r.loginRequired, undefined)
})
// IAM `CodeLoginRequired`: the session lapsed. Flagged separately from a refusal
// because the page's answer is to sign the human in and come back, not to give up.
test('deviceInfo flags login_required distinctly from a refusal', async () => {
const { fetchImpl } = deviceInfoFetch({
status: 'error',
msg: 'please sign in first',
code: 'login_required',
})
const client = createAuthClient({ org: org(), fetchImpl })
const r = await client.deviceInfo('K7M4P2QH')
assert.equal(r.ok, false)
assert.equal(r.ok === false && r.loginRequired, true)
assert.equal(r.ok === false && r.error, 'please sign in first')
})
// The ONE opaque refusal IAM answers for unknown / expired / already-approved —
// surfaced verbatim, carrying no loginRequired, so the page shows it and offers
// no approval. Distinguishing those three would be an oracle for hunting the
// 40-bit user_code; the client must not invent a distinction either.
test('deviceInfo surfaces the opaque refusal verbatim and does not name an app', async () => {
const { fetchImpl } = deviceInfoFetch({
status: 'error',
msg: 'the user code is invalid or expired',
})
const client = createAuthClient({ org: org(), fetchImpl })
const r = await client.deviceInfo('K7M4P2QH')
assert.equal(r.ok, false)
assert.equal(r.ok === false && r.error, 'the user code is invalid or expired')
assert.equal(r.ok === false && r.loginRequired, undefined)
})
// The org-boundary refusal is a plain refusal too: surfaced, not special-cased.
test('deviceInfo surfaces the wrong-org refusal', async () => {
const { fetchImpl } = deviceInfoFetch({
status: 'error',
msg: 'your organization may not approve this device sign-in',
})
const client = createAuthClient({ org: org(), fetchImpl })
const r = await client.deviceInfo('K7M4P2QH')
assert.equal(r.ok, false)
assert.equal(r.ok === false && r.error, 'your organization may not approve this device sign-in')
})
// An HTML error page from a proxy is not an application name. It must fail,
// never resolve to a blank or guessed one.
test('deviceInfo fails on a non-JSON response', async () => {
const fetchImpl: typeof fetch = async () =>
new Response('<html>502 Bad Gateway</html>', {
status: 502,
headers: { 'Content-Type': 'text/html' },
})
const client = createAuthClient({ org: org(), fetchImpl })
const r = await client.deviceInfo('K7M4P2QH')
assert.equal(r.ok, false)
assert.match(String(r.ok === false && r.error), /non-JSON/)
})
// A network failure resolves — never rejects — so the page renders the failure
// instead of tearing down on an unhandled rejection.
test('deviceInfo resolves an error when fetch throws', async () => {
const fetchImpl: typeof fetch = async () => {
throw new Error('offline')
}
const client = createAuthClient({ org: org(), fetchImpl })
const r = await client.deviceInfo('K7M4P2QH')
assert.equal(r.ok, false)
assert.match(String(r.ok === false && r.error), /offline/)
})
// A 200 that names no client is not a name. Falling back to ANY local string here
// is what produced the original defect, so an absent clientId is a failure.
test('deviceInfo refuses an ok response with no clientId', async () => {
const { fetchImpl } = deviceInfoFetch({ status: 'ok', data: { displayName: 'Hanzo CLI' } })
const client = createAuthClient({ org: org(), fetchImpl })
const r = await client.deviceInfo('K7M4P2QH')
assert.equal(r.ok, false)
})
// IAM already falls back to the app's name when DisplayName is empty; if one ever
// arrives blank anyway, the label is the server-confirmed clientId — never the portal's.
test('deviceInfo falls back to the confirmed clientId when displayName is empty', async () => {
const { fetchImpl } = deviceInfoFetch({ status: 'ok', data: { clientId: 'hanzo-cli', displayName: '' } })
const client = createAuthClient({ org: org(), fetchImpl })
const r = await client.deviceInfo('K7M4P2QH')
assert.equal(r.ok, true)
assert.equal(r.ok && r.displayName, 'hanzo-cli')
})
// getAppLogin's redirectUri is validated by IAM against the app's REGISTERED
// list. A cross-app SSO read (the console's `hanzo-cloud` viewed from hanzo.id)
// MUST send the downstream app's OWN redirect_uri — the portal's `/callback` is
// not in that app's list, so hardcoding it makes IAM drop the response and no
// social buttons resolve. Absent, it defaults to the portal's own callback.
test('getAppLogin sends the passed redirect_uri, and defaults to the portal callback when omitted', async () => {
const urls: string[] = []
const fetchImpl: typeof fetch = async (input) => {
urls.push(typeof input === 'string' ? input : input.toString())
return new Response(
JSON.stringify({ status: 'ok', data: { name: 'hanzo-cloud', organization: 'hanzo', providers: [] } }),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
)
}
const client = createAuthClient({ org: org(), fetchImpl })
// Cross-app read: the console's registered redirect_uri rides through verbatim.
await client.getAppLogin('hanzo-cloud', 'https://console.hanzo.ai/auth/callback')
const u1 = new URL(urls[0]!)
assert.equal(u1.searchParams.get('clientId'), 'hanzo-cloud')
assert.equal(u1.searchParams.get('redirectUri'), 'https://console.hanzo.ai/auth/callback')
// Bare/own read: no redirect_uri → default to the portal's own /callback.
await client.getAppLogin('hanzo-id')
const u2 = new URL(urls[1]!)
assert.equal(u2.searchParams.get('redirectUri'), 'https://hanzo.id/callback')
})
+569 -109
View File
@@ -1,21 +1,36 @@
import type { TenantConfig } from '@hanzo/id-shared'
import type { OrgConfig } from '@hanzo/id-shared'
import type {
AppLoginInfo,
CodeLoginRequest,
AppLogin,
AppProvider,
DeviceApprovalResult,
DeviceInfoResult,
ForgotRequest,
LoginRequest,
LoginResponse,
MfaChallengeRequest,
MfaChannel,
MfaIdentity,
MfaSetup,
OAuthAuthorizeRequest,
SignupRequest,
SilentLoginRequest,
TokenResponse,
} from './types'
/** IAM's TOTP MFA type constant (`object.TotpType`). */
export const MFA_TOTP = 'app'
/** Map an IAM MFA type to the {@link MfaChannel} the OTP UI renders a label for. */
export function mfaChannelOf(iamType: string): MfaChannel {
return iamType === 'sms' ? 'sms' : iamType === 'email' ? 'email' : 'totp'
}
/**
* Composable IAM client.
*
* Stateless wrapper around the canonical IAM REST surface (Casdoor-compat
* paths under `/v1/iam/*` and the OIDC paths under `/v1/iam/oauth/*`). One
* client instance per tenant. The portal creates one in `createRoot()`;
* Stateless wrapper around the canonical IAM REST surface (paths under
* `/v1/iam/*` and the OIDC paths under `/v1/iam/oauth/*`). One
* client instance per org. The portal creates one in `createRoot()`;
* downstream pages call `.login()`, `.signup()`, `.forgot()`, `.authorize()`
* directly.
*
@@ -32,59 +47,302 @@ import type {
* past the redirect.
*/
export interface AuthClient {
readonly tenant: TenantConfig
readonly org: OrgConfig
login(req: LoginRequest): Promise<LoginResponse>
/**
* Silent single-sign-on: mint an authorization code from the EXISTING issuer
* session (the `iam_session_id` cookie set when the user signed in once for
* another app) — no credentials, no provider hop. Returns `{ redirectUrl }`
* (the app's `redirect_uri` + `?code=&state=`) when a live session exists, or
* `{ error }` when it does not so the caller renders the interactive form.
* This is the seamless 2nd/3rd-app login leg.
*/
silentLogin(req: SilentLoginRequest): Promise<LoginResponse>
/**
* Approve an RFC 8628 device-authorization request from the device-approval
* page (`/login/oauth/device`). The user MUST already be signed in to the
* issuer — this rides the SAME `iam_session_id` cookie as silent SSO
* (`credentials:'include'`, no credentials in the body). It POSTs
* `/v1/iam/login` with `type:'device'` + the `userCode` the device shows,
* plus the org's `application`/`organization`; IAM resolves the user from
* the session, flips the device code's `UserSignIn=true`, and the CLI's token
* poll then succeeds. Returns `{required:true}` when the app needs consent
* first (rare for first-party apps), or `{error}` with the IAM message.
*/
approveDevice(userCode: string): Promise<DeviceApprovalResult>
/**
* Name the application a pending device code belongs to, so the approval page
* can say WHICH app it is authorizing — `GET
* /v1/iam/oauth/device/<user_code>`, riding the same `iam_session_id` cookie
* as {@link approveDevice}.
*
* Read this and render it; never `org.appName`, which is this portal's own
* branding and names a different application than the one that minted the
* code. IAM answers from the code's own application row.
*
* Session-gated and deliberately terse: an expired session comes back as
* `loginRequired`, and unknown / expired / already-approved all come back as
* ONE indistinguishable refusal, because a user_code is 40 bits and an
* endpoint that told them apart would be an oracle for hunting live codes.
*/
deviceInfo(userCode: string): Promise<DeviceInfoResult>
signup(req: SignupRequest): Promise<LoginResponse>
forgot(req: ForgotRequest): Promise<{ ok: boolean; error?: string }>
authorize(req: OAuthAuthorizeRequest): string
exchange(code: string, codeVerifier?: string): Promise<TokenResponse>
logout(idTokenHint?: string, postLogoutRedirectUri?: string): string
/** Fetch the application's enabled providers + sign-in methods (drives which buttons render). */
appLogin(): Promise<AppLoginInfo>
/** Send an email/SMS verification code for passwordless login. dest = email or E.164 phone. */
sendLoginCode(dest: string): Promise<{ ok: boolean; error?: string }>
/** Complete a passwordless login with the code sent to dest. */
loginWithCode(req: CodeLoginRequest): Promise<LoginResponse>
/**
* Read the live enabled-auth-methods view for an application from
* `/v1/iam/get-app-login` — the canonical source of truth for which
* sign-in buttons (password / GitHub / Google / Web3) to render.
* Resolves to null when the endpoint is unreachable so callers can fall
* back to the org's declared default method set.
*
* `redirectUri` is validated by IAM against the app's registered list. For a
* cross-app SSO read (e.g. console → hanzo.id, `clientId=hanzo-cloud`) pass the
* DOWNSTREAM app's own OIDC `redirect_uri` — the portal's `/callback` is NOT in
* that app's list, so hardcoding it makes IAM answer `status:error`
* ("Redirect URI … doesn't exist in the allowed list") and drops the whole
* response. Omit it for a bare/own-app read (defaults to the portal callback).
*/
getAppLogin(clientId?: string, redirectUri?: string): Promise<AppLogin | null>
/**
* Resolve the signed-in user's `{owner, name}` from the IAM session
* (`/v1/iam/get-account`). After a `RequiredMfa` login the IAM session cookie
* already authenticates the user (IAM calls `SetSessionUsername` before
* answering `RequiredMfa`), so this is how the portal learns the identity to
* key the forced-enrollment calls on. Resolves null when unauthenticated.
*/
getAccount(): Promise<MfaIdentity | null>
/**
* Begin TOTP enrollment: `POST /v1/iam/mfa/setup/initiate`. Returns the secret
* + `otpauth://` URI + recovery codes. Does NOT persist anything — only
* {@link mfaEnable} does.
*/
mfaInitiate(id: MfaIdentity): Promise<MfaSetup>
/** Verify a TOTP code against a pending secret: `POST /v1/iam/mfa/setup/verify`. */
mfaVerify(req: MfaIdentity & { secret: string; passcode: string }): Promise<{ ok: boolean; error?: string }>
/** Persist a verified TOTP enrollment: `POST /v1/iam/mfa/setup/enable`. */
mfaEnable(req: MfaIdentity & { secret: string; recoveryCode: string }): Promise<{ ok: boolean; error?: string }>
/**
* Answer a `NextMfa` challenge: `POST /v1/iam/login` with `{mfaType, passcode}`
* and NO username, riding the MFA session cookie IAM set with `NextMfa`.
* Returns the same shape as {@link login} (a redirect with an auth code for the
* code flow, or a bare-session signal for portal sign-in).
*/
mfaChallenge(req: MfaChallengeRequest): Promise<LoginResponse>
}
export interface AuthClientOptions {
readonly tenant: TenantConfig
readonly org: OrgConfig
/** Override fetch impl (testing). Defaults to global fetch. */
readonly fetchImpl?: typeof fetch
}
export function createAuthClient(opts: AuthClientOptions): AuthClient {
const tenant = opts.tenant
const org = opts.org
const f = opts.fetchImpl ?? fetch
async function login(req: LoginRequest): Promise<LoginResponse> {
const type = req.redirectUri ? 'code' : 'login'
const url = new URL('/v1/iam/login', tenant.iamUrl)
const url = new URL('/v1/iam/login', org.iamUrl)
url.searchParams.set('clientId', req.clientId)
url.searchParams.set('responseType', 'code')
if (req.redirectUri) url.searchParams.set('redirectUri', req.redirectUri)
url.searchParams.set('scope', 'openid profile email')
if (req.state) url.searchParams.set('state', req.state)
// Echo the downstream OIDC nonce so the minted code -> id_token carries it.
// Strict openid-client consumers (LibreChat OPENID_REUSE_TOKENS) reject an
// id_token whose nonce != the one they sent ("unexpected JWT claim value").
if (req.nonce) url.searchParams.set('nonce', req.nonce)
if (req.codeChallenge) {
url.searchParams.set('code_challenge', req.codeChallenge)
url.searchParams.set('code_challenge_method', req.codeChallengeMethod ?? 'S256')
}
url.searchParams.set('type', type)
// `organization` is an OPTIONAL lookup hint (see LoginRequest). Omit it when
// empty so IAM runs its cross-org resolution: a global-admin identity then
// resolves to the `admin` org (full multi-org session) instead of being
// pinned to — and truncated by — a colliding brand-org row. The session's
// org is always the resolved user's real owner, never this hint.
const body: Record<string, unknown> = {
type,
username: req.identifier,
password: req.password,
application: req.application,
signinMethod: 'Password',
autoSignin: true,
}
if (req.organization) body.organization = req.organization
const res = await f(url.toString(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
type,
username: req.identifier,
password: req.password,
application: req.application,
organization: req.organization,
signinMethod: 'Password',
autoSignin: true,
}),
body: JSON.stringify(body),
})
return parseLoginResponse(res, req)
}
// Resolve the org of the user in the ambient IAM session (the `iam_session_id`
// cookie), or null when there is no live session. Reads `/v1/iam/get-account`;
// the org is the `owner` field (IAM returns the User at the top level or
// under `data`). Used to keep silent SSO from reusing a session that belongs
// to a DIFFERENT org than the app being signed into.
async function sessionOwner(): Promise<string | null> {
try {
const res = await f(new URL('/v1/iam/get-account', org.iamUrl).toString(), {
credentials: 'include',
headers: { Accept: 'application/json' },
})
if (!res.ok) return null
const body = (await res.json()) as Record<string, unknown>
if (body.status === 'error') return null
const nested = (typeof body.data === 'object' && body.data ? body.data : {}) as Record<string, unknown>
const owner = typeof body.owner === 'string' ? body.owner : nested.owner
return typeof owner === 'string' && owner ? owner : null
} catch {
return null
}
}
async function silentLogin(req: SilentLoginRequest): Promise<LoginResponse> {
// Silent SSO may reuse the ambient IAM session ONLY when that session's user
// belongs to the SAME org as the app being signed into. A cross-org app —
// e.g. the admin-guard (client_id=hanzo-admin-guard, org=admin) reached from
// a browser that already holds a hanzo/* session — must NOT mint a code from
// the wrong-org session: that confers owner=hanzo and silently shadows the
// org-scoped credential form (which resolves the admin/* identity). Resolve
// the app's org and the session owner; on no session or an org mismatch,
// return an empty response so Login.tsx falls back to the interactive form,
// which authenticates in the app's own org. Same-org SSO (the common case)
// still mints silently, so seamless sign-in is preserved.
const [app, owner] = await Promise.all([getAppLogin(req.clientId), sessionOwner()])
if (!owner) return {}
const appOrg = app?.organization
if (appOrg && owner !== appOrg) return {}
const url = new URL('/v1/iam/login', org.iamUrl)
url.searchParams.set('clientId', req.clientId)
url.searchParams.set('responseType', 'code')
url.searchParams.set('redirectUri', req.redirectUri)
url.searchParams.set('scope', req.scope ?? 'openid profile email')
if (req.state) url.searchParams.set('state', req.state)
if (req.nonce) url.searchParams.set('nonce', req.nonce)
if (req.codeChallenge) {
url.searchParams.set('code_challenge', req.codeChallenge)
url.searchParams.set('code_challenge_method', req.codeChallengeMethod ?? 'S256')
}
url.searchParams.set('type', 'code')
// NO username/password and NO provider: IAM's Login handler falls through to
// its "already signed in to IAM" branch (`GetSessionUsername() != ""`) and
// mints an authorization code for `application` from the existing
// `iam_session_id` cookie. `credentials: 'include'` sends that cookie. When
// there is no live session IAM responds `status:error` -> parseLoginResponse
// returns `{ error }`, and Login.tsx renders the interactive form instead.
const res = await f(url.toString(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ type: 'code', application: req.application, autoSignin: true }),
})
return parseLoginResponse(res, { redirectUri: req.redirectUri, state: req.state })
}
async function approveDevice(userCode: string): Promise<DeviceApprovalResult> {
const code = normalizeUserCode(userCode)
if (!code) return { ok: false, error: 'Enter the code shown on your device.' }
const url = new URL('/v1/iam/login', org.iamUrl)
// IAM's device branch keys the cache off the `userCode` in the BODY; the
// `type` echo on the query mirrors the other login legs. NO credentials —
// the user is already signed in, so this rides the session cookie
// (`credentials:'include'`) and IAM resolves the user from the session.
url.searchParams.set('type', 'device')
const body: Record<string, unknown> = {
type: 'device',
userCode: code,
application: org.appName,
}
// `organization` scopes the application lookup (FindApplicationByName); it
// does NOT resolve the user (that comes from the session), so pinning the
// org org here is safe — unlike password login, which omits it.
if (org.orgId) body.organization = org.orgId
let res: Response
try {
res = await f(url.toString(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(body),
})
} catch (e) {
return { ok: false, error: String(e) }
}
let parsed: Record<string, unknown> = {}
try {
parsed = (await res.json()) as Record<string, unknown>
} catch {
return { ok: false, error: `HTTP ${res.status} non-JSON response` }
}
if (!res.ok || parsed.status === 'error') {
return { ok: false, error: typeof parsed.msg === 'string' && parsed.msg ? parsed.msg : `HTTP ${res.status}` }
}
// Consent branch: {status:ok, data:{required:true}}. First-party apps skip
// this; surface it so the caller can render consent rather than dead-ending.
const data = parsed.data
if (data !== null && typeof data === 'object' && (data as Record<string, unknown>).required === true) {
return { ok: false, required: true }
}
return { ok: true }
}
async function deviceInfo(userCode: string): Promise<DeviceInfoResult> {
const code = normalizeUserCode(userCode)
if (!code) return { ok: false, error: 'Enter the code shown on your device.' }
// POST, and the code rides the BODY — like `approveDevice` beside it, and for
// the reason IAM's own introspection endpoint is POST: the 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 ships `scrubUrl()` to keep the
// code out of the address bar; putting it into every request line would undo
// that server-side. Same session cookie as the approval: whatever you may look
// at is exactly what you may approve.
const url = new URL('/v1/iam/oauth/device/info', org.iamUrl)
let res: Response
try {
res = await f(url.toString(), {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
credentials: 'include',
body: JSON.stringify({ userCode: code }),
})
} catch (e) {
return { ok: false, error: String(e) }
}
let parsed: Record<string, unknown> = {}
try {
parsed = (await res.json()) as Record<string, unknown>
} catch {
return { ok: false, error: `HTTP ${res.status} non-JSON response` }
}
if (!res.ok || parsed.status === 'error') {
const error = typeof parsed.msg === 'string' && parsed.msg ? parsed.msg : `HTTP ${res.status}`
// IAM `CodeLoginRequired` (internal/oidc/oidc.go): the session lapsed between
// the page's get-account check and this read. Not a dead end — sign in again.
if (parsed.code === 'login_required') return { ok: false, error, loginRequired: true }
return { ok: false, error }
}
// A name is only worth rendering if the server sent it. An answer with no
// clientId names nothing, so it fails rather than letting the page fall back
// to a guess — showing the WRONG application is the defect this endpoint exists
// to fix. `displayName` falls back to the clientId, which IAM did confirm.
const data = parsed.data as Record<string, unknown> | undefined
const clientId = typeof data?.clientId === 'string' ? data.clientId : ''
const displayName = typeof data?.displayName === 'string' ? data.displayName : ''
if (!clientId) return { ok: false, error: 'IAM did not name the application for this code.' }
return { ok: true, clientId, displayName: displayName || clientId }
}
async function signup(req: SignupRequest): Promise<LoginResponse> {
const url = new URL('/v1/iam/signup', tenant.iamUrl)
const url = new URL('/v1/iam/signup', org.iamUrl)
url.searchParams.set('clientId', req.clientId)
const username = req.email.split('@')[0]
const res = await f(url.toString(), {
@@ -99,22 +357,48 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
email: req.email,
password: req.password,
confirm: req.password,
autoSignin: true,
...(req.inviteCode ? { invitationCode: req.inviteCode } : {}),
}),
})
return parseLoginResponse(res)
// Registration is CREATE-ONLY at IAM. `/v1/iam/signup` persists the user and
// answers with the created row — it sets no session cookie and mints no
// authorization code, and its form (`internal/oidc/signup.go`) has no
// `autoSignin`, `redirectUri` or `code_challenge` field to make it do so.
// The `autoSignin: true` this used to post was silently dropped by the Go
// decoder, so "signed up" and "signed in" were never the same event.
//
// Left there, the response fell through `parseLoginResponse`'s no-redirect
// arm to `{ redirectUrl: '/onboarding' }` — every new customer was sent to
// the portal's own onboarding, unauthenticated, while the app that sent them
// waited on a code that was never minted. So finish the job here: a signup
// that leaves you logged out is not a signup.
const created = await parseCreated(res)
if (created.error) return created
return login({
identifier: req.email,
password: req.password,
clientId: req.clientId,
application: req.application,
organization: req.organization,
redirectUri: req.redirectUri,
state: req.state,
codeChallenge: req.codeChallenge,
codeChallengeMethod: req.codeChallengeMethod,
nonce: req.nonce,
})
}
async function forgot(req: ForgotRequest): Promise<{ ok: boolean; error?: string }> {
const url = new URL('/v1/iam/send-verification-code', tenant.iamUrl)
const url = new URL('/v1/iam/send-verification-code', org.iamUrl)
url.searchParams.set('clientId', req.clientId)
url.searchParams.set('organization', req.organization)
const res = await f(url.toString(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
applicationId: `admin/${tenant.appName}`,
applicationId: `admin/${org.appName}`,
organization: req.organization,
dest: req.identifier,
type: req.identifier.includes('@') ? 'email' : 'phone',
@@ -129,28 +413,31 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
}
function authorize(req: OAuthAuthorizeRequest): string {
const url = new URL('/v1/iam/oauth/authorize', tenant.iamUrl)
const url = new URL('/v1/iam/oauth/authorize', org.iamUrl)
url.searchParams.set('client_id', req.clientId)
url.searchParams.set('redirect_uri', req.redirectUri)
url.searchParams.set('response_type', req.responseType ?? 'code')
url.searchParams.set('scope', req.scope ?? 'openid profile email')
url.searchParams.set('state', req.state)
if (req.provider) url.searchParams.set('provider', req.provider)
if (req.nonce) url.searchParams.set('nonce', req.nonce)
if (req.codeChallenge) {
url.searchParams.set('code_challenge', req.codeChallenge)
url.searchParams.set('code_challenge_method', req.codeChallengeMethod ?? 'S256')
}
// Naming a provider federates the request to that external IdP instead of
// the hosted credential login. The type has always declared this field;
// never emitting it is why social sign-in had no server side at all.
if (req.provider) url.searchParams.set('provider', req.provider)
return url.toString()
}
async function exchange(code: string, codeVerifier?: string): Promise<TokenResponse> {
const url = new URL('/v1/iam/oauth/token', tenant.iamUrl)
const url = new URL('/v1/iam/oauth/token', org.iamUrl)
const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
client_id: tenant.clientId,
redirect_uri: `${tenant.publicOrigin}/callback`,
client_id: org.clientId,
redirect_uri: `${org.publicOrigin}/callback`,
})
if (codeVerifier) body.set('code_verifier', codeVerifier)
const res = await f(url.toString(), {
@@ -171,122 +458,275 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
}
function logout(idTokenHint?: string, postLogoutRedirectUri?: string): string {
const url = new URL('/v1/iam/oauth/logout', tenant.iamUrl)
const url = new URL('/v1/iam/oauth/logout', org.iamUrl)
if (idTokenHint) url.searchParams.set('id_token_hint', idTokenHint)
url.searchParams.set(
'post_logout_redirect_uri',
postLogoutRedirectUri ?? `${tenant.publicOrigin}/login`,
postLogoutRedirectUri ?? `${org.publicOrigin}/login`,
)
return url.toString()
}
// appLogin fetches the application's enabled providers + sign-in methods so
// the UI renders exactly what the IAM app offers (social buttons, code login).
async function appLogin(): Promise<AppLoginInfo> {
const url = new URL('/v1/iam/get-app-login', tenant.iamUrl)
url.searchParams.set('clientId', tenant.clientId)
async function getAppLogin(clientId?: string, redirectUri?: string): Promise<AppLogin | null> {
const id = clientId ?? org.clientId
const url = new URL('/v1/iam/get-app-login', org.iamUrl)
url.searchParams.set('clientId', id)
url.searchParams.set('responseType', 'code')
url.searchParams.set('redirectUri', `${tenant.publicOrigin}/callback`)
// Validate against the downstream app's OWN redirect_uri when the caller has
// one (the SSO authorize flow carries it); the portal's own /callback is not
// registered for another app, so IAM would reject the read and we'd surface
// no social buttons. Fall back to the portal callback for a bare/own read.
url.searchParams.set('redirectUri', redirectUri || `${org.publicOrigin}/callback`)
url.searchParams.set('scope', 'openid profile email')
url.searchParams.set('state', 'login')
const res = await f(url.toString(), { credentials: 'include' })
url.searchParams.set('state', 'app-login')
let body: Record<string, unknown>
try {
const res = await f(url.toString(), { headers: { Accept: 'application/json' } })
if (!res.ok) return null
body = (await res.json()) as Record<string, unknown>
} catch {
return null
}
if (body.status !== 'ok' || typeof body.data !== 'object' || body.data === null) return null
return parseAppLogin(body.data as Record<string, unknown>, org.appName, org.orgId)
}
async function getAccount(): Promise<MfaIdentity | null> {
const url = new URL('/v1/iam/get-account', org.iamUrl)
let body: Record<string, unknown>
try {
const res = await f(url.toString(), { headers: { Accept: 'application/json' }, credentials: 'include' })
if (!res.ok) return null
body = (await res.json()) as Record<string, unknown>
} catch {
return null
}
const d = (typeof body.data === 'object' && body.data ? body.data : {}) as Record<string, unknown>
if (typeof d.owner !== 'string' || typeof d.name !== 'string' || !d.owner || !d.name) return null
return { owner: d.owner, name: d.name }
}
/**
* Build a `/v1/iam/mfa/setup/*` POST URL with EVERY param on the query string
* and send an EMPTY body. This is the one wire shape IAM's authz filter and
* the MFA controller both accept: the controller reads `owner`/`name`/… from
* the merged form (query + body), while the authz filter only extracts the
* `{owner,name}` object from the query when the body is empty (a non-empty
* body is JSON-unmarshalled, and a urlencoded body fails that parse → empty
* object → the self-access match `sub==obj` fails → "Unauthorized operation").
* `owner`/`name` ride the query on EVERY call — including `verify`, which
* otherwise carries no identity — purely so that self-access check passes.
*/
async function mfaSetupPost(path: string, params: Record<string, string>): Promise<Record<string, unknown>> {
const url = new URL(`/v1/iam/mfa/setup/${path}`, org.iamUrl)
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v)
const res = await f(url.toString(), { method: 'POST', credentials: 'include' })
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>
const d = (body.data ?? {}) as Record<string, unknown>
const providers = Array.isArray(d.providers)
? (d.providers as Array<Record<string, unknown>>).map((p) => {
const prov = (p.provider ?? {}) as Record<string, unknown>
return {
name: String(p.name ?? prov.name ?? ''),
displayName: typeof prov.displayName === 'string' ? prov.displayName : undefined,
type: typeof prov.type === 'string' ? prov.type : undefined,
category: typeof prov.category === 'string' ? prov.category : undefined,
canSignIn: p.canSignIn !== false,
canSignUp: p.canSignUp !== false,
}
})
: []
const signinMethods = Array.isArray(d.signinMethods)
? (d.signinMethods as Array<Record<string, unknown>>).map((m) => ({
name: String(m.name ?? ''),
rule: typeof m.rule === 'string' ? m.rule : undefined,
}))
: []
if (typeof body.status === 'string' && body.status === 'error') {
throw new Error(typeof body.msg === 'string' && body.msg ? body.msg : `HTTP ${res.status}`)
}
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return body
}
async function mfaInitiate(id: MfaIdentity): Promise<MfaSetup> {
const body = await mfaSetupPost('initiate', { owner: id.owner, name: id.name, mfaType: MFA_TOTP })
const d = (typeof body.data === 'object' && body.data ? body.data : {}) as Record<string, unknown>
const secret = typeof d.secret === 'string' ? d.secret : ''
const url = typeof d.url === 'string' ? d.url : ''
if (!secret || !url) throw new Error('IAM returned no TOTP secret')
return {
name: String(d.name ?? tenant.appName),
displayName: typeof d.displayName === 'string' ? d.displayName : undefined,
providers,
signinMethods,
enablePassword: d.enablePassword !== false,
enableCodeSignin: d.enableCodeSignin === true,
enableSignUp: d.enableSignUp !== false,
mfaType: MFA_TOTP,
secret,
url,
recoveryCodes: Array.isArray(d.recoveryCodes) ? d.recoveryCodes.filter((c): c is string => typeof c === 'string') : [],
}
}
async function sendLoginCode(dest: string): Promise<{ ok: boolean; error?: string }> {
const url = new URL('/v1/iam/send-verification-code', tenant.iamUrl)
url.searchParams.set('clientId', tenant.clientId)
url.searchParams.set('organization', tenant.orgId)
const isEmail = dest.includes('@')
const res = await f(url.toString(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
applicationId: `admin/${tenant.appName}`,
organization: tenant.orgId,
dest,
type: isEmail ? 'email' : 'phone',
method: 'login',
checkUser: dest,
}),
})
if (!res.ok) return { ok: false, error: `HTTP ${res.status}` }
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>
if (body.status === 'error') return { ok: false, error: typeof body.msg === 'string' ? body.msg : 'failed' }
return { ok: true }
async function mfaVerify(req: MfaIdentity & { secret: string; passcode: string }): Promise<{ ok: boolean; error?: string }> {
try {
await mfaSetupPost('verify', { owner: req.owner, name: req.name, mfaType: MFA_TOTP, secret: req.secret, passcode: req.passcode })
return { ok: true }
} catch (e) {
return { ok: false, error: e instanceof Error ? e.message : String(e) }
}
}
async function loginWithCode(req: CodeLoginRequest): Promise<LoginResponse> {
async function mfaEnable(req: MfaIdentity & { secret: string; recoveryCode: string }): Promise<{ ok: boolean; error?: string }> {
try {
await mfaSetupPost('enable', {
owner: req.owner,
name: req.name,
mfaType: MFA_TOTP,
secret: req.secret,
recoveryCodes: req.recoveryCode,
})
return { ok: true }
} catch (e) {
return { ok: false, error: e instanceof Error ? e.message : String(e) }
}
}
async function mfaChallenge(req: MfaChallengeRequest): Promise<LoginResponse> {
const type = req.redirectUri ? 'code' : 'login'
const url = new URL('/v1/iam/login', tenant.iamUrl)
const url = new URL('/v1/iam/login', org.iamUrl)
url.searchParams.set('clientId', req.clientId)
url.searchParams.set('responseType', 'code')
if (req.redirectUri) url.searchParams.set('redirectUri', req.redirectUri)
url.searchParams.set('scope', 'openid profile email')
if (req.state) url.searchParams.set('state', req.state)
if (req.codeChallenge) {
url.searchParams.set('code_challenge', req.codeChallenge)
url.searchParams.set('code_challenge_method', req.codeChallengeMethod ?? 'S256')
}
url.searchParams.set('type', type)
const isEmail = req.dest.includes('@')
const res = await f(url.toString(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
type,
username: req.dest,
code: req.code,
// No username: IAM resolves the user from the MFA session cookie it set
// when it answered NextMfa.
mfaType: req.mfaType,
passcode: req.passcode,
application: req.application,
organization: req.organization,
signinMethod: 'Verification code',
...(isEmail ? { email: req.dest } : { phone: req.dest }),
autoSignin: true,
enableMfaRemember: req.rememberDevice ?? false,
}),
})
return parseLoginResponse(res, req)
}
return {
tenant,
org,
login,
silentLogin,
approveDevice,
deviceInfo,
signup,
forgot,
authorize,
exchange,
logout,
appLogin,
sendLoginCode,
loginWithCode,
getAppLogin,
getAccount,
mfaInitiate,
mfaVerify,
mfaEnable,
mfaChallenge,
}
}
/**
* Canonicalize a user-entered device code to the form IAM generated. IAM mints
* user_codes from an UPPERCASE unambiguous alphabet ([A-HJ-NP-Z2-9], no
* I/L/O/0/1) and keys its DeviceAuthMap on the exact string. A human may
* transcribe it lower-cased or with stray spaces/dashes, so normalize TO
* uppercase and strip separators — case-insensitive entry, an exact-match send.
*/
function normalizeUserCode(raw: string): string {
return raw.trim().toUpperCase().replace(/[\s-]+/g, '')
}
/**
* The provider's DISPLAY key — `provider-github` → `github` — used to pick an
* icon and a label (`PROVIDER_META`) and to match a `provider_hint`.
*
* It is NOT what the authorize endpoint wants. `federationProvider` matches the
* record name exactly, so `?provider=` must carry the full `provider-github`;
* live, `?provider=github` is refused "unknown or unavailable provider". This
* comment used to assert the opposite — a bare key — which was never true of the
* federation broker.
*/
function providerKey(name: string): string {
return name.replace(/^provider-/, '')
}
/**
* A provider is renderable only when IAM holds a real OAuth clientId for it.
* The seed ships obvious placeholders (`GITHUB_CLIENT_ID_PLACEHOLDER`,
* `placeholder`); an empty or placeholder id means the provider isn't
* provisioned, so its button is hidden rather than dead-ending the user. Real
* OAuth client ids never contain "placeholder".
*/
function isConfiguredClientId(clientId: string): boolean {
return clientId.length > 0 && !/placeholder/i.test(clientId)
}
/** Shape the `/v1/iam/get-app-login` `data` payload into the {@link AppLogin} view. */
function parseAppLogin(
data: Record<string, unknown>,
fallbackApp: string,
fallbackOrg: string,
): AppLogin {
const rawProviders = Array.isArray(data.providers) ? data.providers : []
const providers: AppProvider[] = rawProviders
.map((p): AppProvider | null => {
if (typeof p !== 'object' || p === null) return null
const rec = p as Record<string, unknown>
// The provider's IDENTITY is the nested provider record's name
// (`rec.provider.name`, e.g. `provider-github`) — that is what the IAM
// backend's social-login lookup (`GetProvider(admin/<name>)`) resolves.
// The OUTER link object's `name` is the app's provider-LINK label, which
// some IAM seeds set to a per-app default (e.g. `<org>-iam`); reading
// it as the provider name made the hop POST `provider=<org>-iam`, which
// the backend rejects ("The provider: <org>-iam does not exist"). Prefer
// the inner record name; fall back to the outer label only when there is
// no nested provider record. One source of truth: the provider record.
const inner =
typeof rec.provider === 'object' && rec.provider !== null
? (rec.provider as Record<string, unknown>)
: {}
const innerName = typeof inner.name === 'string' ? inner.name : ''
const outerName = typeof rec.name === 'string' ? rec.name : ''
const name = innerName || outerName
if (!name) return null
const clientId = typeof inner.clientId === 'string' ? inner.clientId : ''
return {
name,
key: providerKey(name),
canSignIn: rec.canSignIn !== false,
canSignUp: rec.canSignUp !== false,
configured: isConfiguredClientId(clientId),
type: typeof inner.type === 'string' ? inner.type : '',
clientId,
scopes: typeof inner.scopes === 'string' ? inner.scopes : '',
}
})
.filter((p): p is AppProvider => p !== null)
return {
application: typeof data.name === 'string' ? data.name : fallbackApp,
organization: typeof data.organization === 'string' ? data.organization : fallbackOrg,
enablePassword: data.enablePassword !== false,
enableSignUp: data.enableSignUp !== false,
enableCodeSignin: data.enableCodeSignin === true,
providers,
}
}
/**
* Read a create-only IAM response: `{status, msg, data}` where `data` is the
* created row. Success carries nothing the caller can navigate to, so this
* reports only whether it worked — never a redirect. Kept separate from
* `parseLoginResponse` precisely because that one INVENTS a destination when no
* `redirectUri` was requested, which is wrong for a row that is not a session.
*/
async function parseCreated(res: Response): Promise<{ error?: string }> {
let body: Record<string, unknown> = {}
try {
body = (await res.json()) as Record<string, unknown>
} catch {
return { error: `HTTP ${res.status} non-JSON response` }
}
// IAM answers a REFUSAL with HTTP 200 + status:"error" (see the org-less login
// note in this repo's LLM.md), so the status code alone proves nothing.
if (!res.ok || body.status === 'error') {
return { error: typeof body.msg === 'string' ? body.msg : `HTTP ${res.status}` }
}
return {}
}
async function parseLoginResponse(
res: Response,
req?: { redirectUri?: string; state?: string },
@@ -302,6 +742,25 @@ async function parseLoginResponse(
}
const data = body.data
// Multi-factor signal — IAM answers a successful credential check with a
// STRING in `data` (NOT a `mfa_required` boolean): `"RequiredMfa"` when org
// policy forces MFA the user has not enrolled, `"NextMfa"` when the user has
// MFA and must answer a challenge. Branch BEFORE any session/redirect return:
// the password session is not yet usable, so the portal must render the
// enrollment/challenge step rather than navigate on.
if (data === 'RequiredMfa') {
return { mfaRequired: true, mfaStage: 'enroll' }
}
if (data === 'NextMfa') {
// Challenge allow-list: IAM's named `mfa` field first, falling back to
// the legacy untyped `data2` slot until IAM stops emitting it.
const allow = Array.isArray(body.mfa) ? body.mfa : Array.isArray(body.data2) ? body.data2 : []
const mfaTypes = allow
.map((p) => (typeof p === 'object' && p !== null ? (p as Record<string, unknown>).mfaType : undefined))
.filter((t): t is string => typeof t === 'string' && t.length > 0)
return { mfaRequired: true, mfaStage: 'challenge', mfaTypes }
}
// Authorization-code flow: a client redirectUri is present and `data` is the
// freshly minted code — hand the SPA a fully-formed redirect back to the app.
if (req?.redirectUri && typeof data === 'string' && data.length > 0) {
@@ -311,9 +770,12 @@ async function parseLoginResponse(
}
}
// Bare portal sign-in: the IAM session cookie is now set; land on the portal.
// Bare portal sign-in: the IAM session cookie is now set; land on the
// post-login onboarding flow. Onboarding's IAM writes ride the same
// session cookie (`credentials: include`), so no bearer token is needed
// for the password path.
if (!req?.redirectUri) {
return { redirectUrl: '/' }
return { redirectUrl: '/onboarding' }
}
// Fallback: a nested token payload (future direct-token IAM responses).
@@ -323,7 +785,5 @@ async function parseLoginResponse(
refreshToken: typeof d.refresh_token === 'string' ? d.refresh_token : undefined,
idToken: typeof d.id_token === 'string' ? d.id_token : undefined,
expiresAt: typeof d.expires_at === 'number' ? d.expires_at : undefined,
mfaRequired: d.mfa_required === true,
mfaChannel: typeof d.mfa_channel === 'string' ? (d.mfa_channel as LoginResponse['mfaChannel']) : undefined,
}
}
+21
View File
@@ -0,0 +1,21 @@
import type { OrgConfig } from '@hanzo/id-shared'
import { IAM } from '@hanzo/iam/browser'
/**
* One IAM browser-SDK instance per org, wired to the portal's own
* `/callback` route. This is the single place that constructs the PKCE
* client — social/web3 sign-in (here) and the callback handler
* (`Callback.tsx`) share it so the PKCE verifier/state the SDK stores on
* `signinRedirect` is the same one it reads on `handleCallback`. One way.
*
* The portal is its own OIDC client (`clientId` = the brand `-id` app), so
* every flow it initiates lands back at `${publicOrigin}/callback`.
*/
export function createIam(org: OrgConfig, clientId?: string): IAM {
return new IAM({
serverUrl: org.iamUrl,
clientId: clientId ?? org.clientId,
redirectUri: `${org.publicOrigin}/callback`,
scope: 'openid profile email',
})
}
+25 -5
View File
@@ -1,14 +1,34 @@
export { createAuthClient, type AuthClient, type AuthClientOptions } from './client'
export {
createAuthClient,
mfaChannelOf,
MFA_TOTP,
type AuthClient,
type AuthClientOptions,
} from './client'
export { createIam } from './iam'
export { authorizeRequest, matchProviderHint } from './social'
export {
loginWithWalletChain,
detectWalletChains,
ENABLED_WALLET_CHAINS,
WALLET_CHAIN_LABELS,
type WalletLoginContext,
type WalletWindow,
} from './web3'
export type {
LoginRequest,
LoginResponse,
MfaChannel,
MfaChallengeRequest,
MfaIdentity,
MfaSetup,
SignupRequest,
ForgotRequest,
OAuthAuthorizeRequest,
TokenResponse,
ProviderInfo,
SigninMethod,
AppLoginInfo,
CodeLoginRequest,
AppLogin,
AppProvider,
DeviceApprovalResult,
DeviceInfoResult,
} from './types'
export * from './ui'
+85
View File
@@ -0,0 +1,85 @@
/**
* Federated sign-in — pure unit tests, no network. Run with:
* pnpm --filter @hanzo/id-auth test
*
* The browser's whole job in a federated sign-in is to name the provider on
* IAM's authorize endpoint and, when an app sent the user here, to hand that
* app's own request back unchanged so IAM mints the code against it. Those two
* are what these tests pin; the IdP leg belongs to IAM and is not modelled here.
*/
import { test } from 'vitest'
import assert from 'node:assert/strict'
import { authorizeRequest, matchProviderHint } from './social.ts'
const PORTAL = 'hanzo-console'
test('an app-initiated request is recovered whole, so IAM binds the code to that app', () => {
// What IAM forwards to the hosted login (authorizeForwardQuery) when an app
// sends a user here for a code.
const req = authorizeRequest(
'?client_id=hanzo-app&redirect_uri=https%3A%2F%2Fhanzo.app%2Fcallback&response_type=code' +
'&scope=openid+profile&state=rp123&nonce=n1&code_challenge=C1&code_challenge_method=S256',
PORTAL,
)!
assert.equal(req.clientId, 'hanzo-app')
assert.equal(req.redirectUri, 'https://hanzo.app/callback')
assert.equal(req.state, 'rp123')
assert.equal(req.scope, 'openid profile')
assert.equal(req.nonce, 'n1')
// Load-bearing: the code IAM mints is bound to the APP's challenge, so the
// app's own callback completes the exchange with the verifier it kept.
assert.equal(req.codeChallenge, 'C1')
assert.equal(req.codeChallengeMethod, 'S256')
})
test('a bare portal sign-in has no app to return to', () => {
// No redirect_uri → nothing to return a code to, so the portal starts its own
// PKCE flow instead (the SDK owns the verifier; Callback reads it back).
assert.equal(authorizeRequest('', PORTAL), null)
assert.equal(authorizeRequest('?provider_hint=provider-github', PORTAL), null)
})
test('the portal client id is the fallback, never an override', () => {
const own = authorizeRequest('?redirect_uri=https%3A%2F%2Fhanzo.id%2Fcallback', PORTAL)!
assert.equal(own.clientId, PORTAL, 'no client_id on the query → the portal is the client')
const app = authorizeRequest('?client_id=hanzo-app&redirect_uri=https%3A%2F%2Fhanzo.app%2Fcallback', PORTAL)!
assert.equal(app.clientId, 'hanzo-app', "the app's own client_id wins — the code is minted for IT")
})
test('a leading ? is optional and absent params stay absent', () => {
const withMark = authorizeRequest('?redirect_uri=https%3A%2F%2Fhanzo.id%2Fcallback', PORTAL)!
const without = authorizeRequest('redirect_uri=https%3A%2F%2Fhanzo.id%2Fcallback', PORTAL)!
assert.deepEqual(withMark, without)
// Undefined, not '' — client.authorize omits a param it was not given, and an
// empty code_challenge is not the same request as no code_challenge.
assert.equal(withMark.codeChallenge, undefined)
assert.equal(withMark.nonce, undefined)
assert.equal(withMark.scope, undefined)
assert.equal(withMark.state, '', 'state is always sent, empty when the app sent none')
})
test('only the two PKCE methods RFC 7636 defines are carried through', () => {
const base = 'redirect_uri=https%3A%2F%2Fhanzo.id%2Fcallback&code_challenge=C1&code_challenge_method='
assert.equal(authorizeRequest(base + 'S256', PORTAL)!.codeChallengeMethod, 'S256')
assert.equal(authorizeRequest(base + 'plain', PORTAL)!.codeChallengeMethod, 'plain')
// Anything else is dropped rather than forwarded, so client.authorize applies
// its S256 default instead of asking IAM to honor a method it does not define.
assert.equal(authorizeRequest(base + 'md5', PORTAL)!.codeChallengeMethod, undefined)
})
test('matchProviderHint resolves the console hint, the bare key, and case, else undefined', () => {
const providers = [
{ name: 'provider-github', key: 'github' },
{ name: 'provider-google', key: 'google' },
]
// The console sends the IAM record name verbatim (`provider-github`).
assert.equal(matchProviderHint(providers, 'provider-github')?.key, 'github')
assert.equal(matchProviderHint(providers, 'provider-google')?.key, 'google')
// The bare key and any case also resolve, so the two sides need no shared constant.
assert.equal(matchProviderHint(providers, 'github')?.key, 'github')
assert.equal(matchProviderHint(providers, 'GitHub')?.key, 'github')
// A hint for a provider this app doesn't offer, or an empty hint, matches nothing.
assert.equal(matchProviderHint(providers, 'provider-apple'), undefined)
assert.equal(matchProviderHint(providers, ''), undefined)
})
+89
View File
@@ -0,0 +1,89 @@
import type { OAuthAuthorizeRequest } from './types'
/**
* Federated sign-in — the portal's half of IAM identity federation.
*
* IAM is the relying party; this SPA is not. `/v1/iam/oauth/authorize?provider=…`
* IS the entry point: having already validated the client_id, the EXACT
* redirect_uri and the PKCE policy, IAM stashes the app-leg request server-side,
* sets a single-use browser-binding cookie and sends the browser to the IdP
* (`internal/oidc/federation.go::beginFederation`). The IdP returns to IAM's own
* fixed callback — `/v1/iam/oauth/callback`, never a route in this SPA — where
* IAM, which holds the client SECRET a browser cannot, exchanges the code, links
* or provisions the user, and mints an IAM authorization code bound to the
* original PKCE challenge, redirect_uri and nonce. The ordinary code→token
* exchange then completes unchanged.
*
* This file used to build the IdP URL here in the browser, replicating a contract
* from an IAM fork whose front end no longer exists. Nothing could ever finish it:
* the SPA has no client secret and IAM has no endpoint that exchanges a raw
* provider code, so GitHub returned to `/callback` with a code nobody could spend
* and the flow died there. The browser's only job is to NAME the provider.
*
* The name is the IAM provider RECORD name (`provider-github`), never the bare
* key: `federationProvider` matches `ProviderItem.Name` exactly, and
* `EnrichProviders` resolves that same name to the record, so the two are one
* string by construction. Verified live — `provider=github` is refused with
* "unknown or unavailable provider"; `provider=provider-github` redirects to
* GitHub.
*/
/**
* The authorize request this portal is standing in for, read from its own URL.
*
* When an app sends a user here for a code, IAM forwards that app's validated
* request on the query (`authorizeForwardQuery`). Re-entering authorize with it —
* plus `provider` — is what makes IAM mint the code against THAT app: its
* client_id, its redirect_uri, its PKCE challenge. The browser is returned
* straight to the app, so this portal's own `/callback` never runs and no token
* is ever handed across on a URL.
*
* Null when there is no app to return to (a bare portal sign-in), which is the
* signal to start the portal's own PKCE flow instead. Keyed on `redirect_uri`
* because that is the one parameter that makes a request returnable — the same
* condition the password path branches on (`Login.completeAfterAuth`).
*
* The result is an {@link OAuthAuthorizeRequest} because that is exactly what
* `client.authorize` consumes: one type, read and written in one shape.
*/
export function authorizeRequest(search: string, clientId: string): OAuthAuthorizeRequest | null {
const q = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search)
const redirectUri = q.get('redirect_uri')
if (!redirectUri) return null
const method = q.get('code_challenge_method')
return {
clientId: q.get('client_id') || clientId,
redirectUri,
state: q.get('state') ?? '',
scope: q.get('scope') ?? undefined,
nonce: q.get('nonce') ?? undefined,
codeChallenge: q.get('code_challenge') ?? undefined,
codeChallengeMethod: method === 'plain' || method === 'S256' ? method : undefined,
}
}
/**
* Resolve a `provider_hint` from the authorize query to one of the app's
* configured providers. A client that already knows which provider the user
* chose (the console passes `?provider_hint=provider-github` when a user clicks
* "Continue with GitHub" over there) sends the hint so this portal launches that
* provider straight away — no second button press, no bounce through a login
* page. Accepts the IAM record name (`provider-github`), the normalized key
* (`github`), or the record name with the `provider-` prefix stripped, so the
* two sides agree without a shared constant. Returns undefined when nothing
* matches (the caller falls back to the interactive form).
*/
export function matchProviderHint<P extends { name: string; key: string }>(
providers: Iterable<P>,
hint: string,
): P | undefined {
const h = hint.trim().toLowerCase()
if (h === '') return undefined
const bare = h.replace(/^provider-/, '')
for (const p of providers) {
const name = p.name.toLowerCase()
const key = p.key.toLowerCase()
if (name === h || key === h || key === bare) return p
}
return undefined
}
+217 -4
View File
@@ -3,29 +3,193 @@ export interface LoginRequest {
readonly password: string
readonly clientId: string
readonly application: string
readonly organization: string
/**
* Org-resolution anchor for the credential lookup. OPTIONAL by design.
*
* IAM resolves the user by (org, identifier); if the in-org lookup misses it
* falls back to a CROSS-ORG lookup by email/username and the session always
* encodes the user's REAL owner-org (`GetOrganizationByUser`), never this
* value. So this field is a lookup HINT, not the session's org.
*
* Leaving it empty/undefined makes login ORG-AGNOSTIC: every in-org lookup
* misses, the cross-org fallback runs, and an identity that lives in the
* global `admin` org (a global admin) resolves to `admin` (→ full multi-org
* session) while a brand-only identity resolves to its own brand org. This is
* why the portal does NOT pin the brand org here — pinning `hanzo` would
* resolve a colliding `hanzo/<name>` row and truncate a global admin to one
* org. Set it only to FORCE a specific org (e.g. a brand that deliberately
* scopes its portal to a single org). Signup, by contrast, MUST carry a
* concrete org (you cannot create a user in "no org").
*/
readonly organization?: string
readonly redirectUri?: string
readonly state?: string
readonly codeChallenge?: string
readonly codeChallengeMethod?: 'S256' | 'plain'
/**
* OIDC nonce from the downstream authorize request. MUST be threaded through
* the password-login path so the minted code (and resulting id_token) echo it.
* Confidential OIDC clients that validate strictly (e.g. LibreChat /
* openid-client with OPENID_REUSE_TOKENS) reject an id_token whose nonce
* doesn't match the one they sent -> "unexpected JWT claim value" -> callback
* 500. Forward, never default — only echo what the authorize URL carried.
*/
readonly nonce?: string
}
/**
* Inputs to {@link AuthClient.silentLogin} — the silent-SSO leg.
*
* Carries NO credentials. When the browser already holds an `iam_session_id`
* cookie on the issuer host (the user signed in once for another app), IAM's
* Login handler takes its "already signed in" branch and mints an authorization
* code for `application` without a password or a provider hop. This is what
* makes the 2nd/3rd app log in seamlessly. With no live session IAM returns an
* error and the caller falls back to the interactive login form.
*/
export interface SilentLoginRequest {
/** OAuth client id of the requesting app (== application name in Hanzo IAM). */
readonly clientId: string
/** IAM application name the code is minted for. */
readonly application: string
/** The requesting app's OAuth redirect_uri — the code is appended to it. */
readonly redirectUri: string
readonly state?: string
readonly scope?: string
readonly codeChallenge?: string
readonly codeChallengeMethod?: 'S256' | 'plain'
/** OIDC nonce, echoed into the minted code -> id_token (strict consumers). */
readonly nonce?: string
}
/** A multi-factor channel the portal can render a code entry for. */
export type MfaChannel = 'totp' | 'sms' | 'email'
export interface LoginResponse {
readonly accessToken?: string
readonly refreshToken?: string
readonly idToken?: string
readonly expiresAt?: number
readonly redirectUrl?: string
/**
* Set when IAM answered the login with a multi-factor signal instead of a
* session/code. `mfaStage` discriminates the two IAM states:
* - `'enroll'` — IAM returned `data:"RequiredMfa"`: org policy forces MFA
* and the user has none yet → render forced TOTP enrollment.
* - `'challenge'` — IAM returned `data:"NextMfa"`: the user has MFA enabled
* → render a code challenge for one of `mfaTypes`.
* The password session is NOT established until the enrollment/challenge
* completes, so the portal must not navigate past this signal.
*/
readonly mfaRequired?: boolean
readonly mfaChannel?: 'totp' | 'sms' | 'email'
readonly mfaStage?: 'enroll' | 'challenge'
/**
* The IAM MFA types available for a `'challenge'` (from the login response's
* named `mfa` field, legacy `data2`), in IAM's own vocabulary: `app` (TOTP),
* `sms`, `email`. Empty for enrollment.
*/
readonly mfaTypes?: readonly string[]
readonly error?: string
}
/**
* Result of approving an RFC 8628 device-authorization request
* ({@link AuthClient.approveDevice}).
*
* `ok` — the device code was marked signed-in (the CLI's token poll now
* succeeds). `required` — the application needs the user to grant consent
* before approval can complete (`{status:ok, data:{required:true}}`); rare for
* first-party apps. `error` — the IAM-surfaced failure message (e.g.
* "UserCode Expired", "DeviceCode Invalid").
*/
export interface DeviceApprovalResult {
readonly ok: boolean
readonly required?: boolean
readonly error?: string
}
/**
* WHICH application a pending device code belongs to
* ({@link AuthClient.deviceInfo}) — the one thing the approval page exists to
* tell a human, and the one thing it cannot know on its own.
*
* Both fields come off the device code's own application row, so a page that
* renders them names the party it is actually authorizing. They are the ONLY
* honest source: `org.appName` is this portal's static branding and names the
* wrong app for every code minted by anything else.
*
* Discriminated on `ok` so a caller cannot read `displayName` without having
* proved the server confirmed one. `loginRequired` singles out the expired
* session (IAM `code:"login_required"`) — the page's cue to sign the human in
* and come back, not an error to show. Every other failure is IAM's single
* opaque refusal, surfaced verbatim.
*/
export type DeviceInfoResult =
| { readonly ok: true; readonly clientId: string; readonly displayName: string }
| { readonly ok: false; readonly error: string; readonly loginRequired?: boolean }
/**
* The TOTP enrollment material minted by `/v1/iam/mfa/setup/initiate`. The
* secret + `url` (an `otpauth://` URI) are rendered locally as a QR code — the
* secret never leaves the browser to a third party. `recoveryCodes[0]` must be
* echoed back to `/v1/iam/mfa/setup/enable`.
*/
export interface MfaSetup {
/** IAM MFA type — `app` for TOTP. */
readonly mfaType: string
/** Base32 TOTP secret. */
readonly secret: string
/** `otpauth://totp/...` provisioning URI for the authenticator app. */
readonly url: string
/** One-time recovery codes issued alongside the secret. */
readonly recoveryCodes: readonly string[]
}
/** The signed-in user's identity, resolved from the IAM session for MFA setup. */
export interface MfaIdentity {
readonly owner: string
readonly name: string
}
/** A TOTP challenge submission for a user who already enrolled (`NextMfa`). */
export interface MfaChallengeRequest {
/** IAM MFA type, e.g. `app` (TOTP), `sms`, `email`. */
readonly mfaType: string
readonly passcode: string
readonly clientId: string
readonly application: string
readonly organization: string
readonly redirectUri?: string
readonly state?: string
readonly codeChallenge?: string
readonly codeChallengeMethod?: 'S256' | 'plain'
/** Honor the org's "remember this device" window after a successful code. */
readonly rememberDevice?: boolean
}
export interface SignupRequest {
readonly email: string
readonly password: string
readonly clientId: string
readonly application: string
/**
* The org to create the user in. REQUIRED — unlike login's optional
* lookup hint, you cannot create a user in "no org", and IAM gates this
* against the application's own org.
*/
readonly organization: string
readonly inviteCode?: string
/**
* The downstream OIDC request, when an app sent the user here to register.
* Registration completes by signing the new user in, so these are forwarded
* to that sign-in: without them the minted code carries no PKCE binding and
* there is nowhere to return the user to.
*/
readonly redirectUri?: string
readonly state?: string
readonly codeChallenge?: string
readonly codeChallengeMethod?: 'S256' | 'plain'
readonly nonce?: string
}
export interface ForgotRequest {
@@ -51,9 +215,9 @@ export interface OAuthAuthorizeRequest {
export interface ProviderInfo {
readonly name: string
readonly displayName?: string
/** Casdoor provider type, e.g. GitHub, Google, Apple, Web3Onboard. */
/** IAM provider type, e.g. GitHub, Google, Apple, Web3Onboard. */
readonly type?: string
/** Casdoor category, e.g. OAuth, Web3, SAML. */
/** IAM category, e.g. OAuth, Web3, SAML. */
readonly category?: string
readonly canSignIn?: boolean
readonly canSignUp?: boolean
@@ -96,3 +260,52 @@ export interface TokenResponse {
readonly expiresIn?: number
readonly scope?: string
}
/** A social/web3 provider enabled on an IAM application. */
export interface AppProvider {
/** IAM provider record name, e.g. `provider-github`. */
readonly name: string
/** Normalized provider key passed to the authorize endpoint, e.g. `github`, `google`, `web3`. */
readonly key: string
/** Whether the provider may be used to sign in. */
readonly canSignIn: boolean
/** Whether the provider may be used to sign up. */
readonly canSignUp: boolean
/**
* Whether IAM holds a real OAuth credential for this provider (a non-empty,
* non-placeholder clientId). The login UI renders ONLY configured providers,
* so an unprovisioned button never dead-ends the user — it appears
* automatically once real credentials are seeded into IAM. The seed ships
* obvious placeholders (`GITHUB_CLIENT_ID_PLACEHOLDER`, `placeholder`), which
* read as not-configured.
*/
readonly configured: boolean
/** IAM provider `type`, e.g. `GitHub` / `Google` / `Web3Onboard` (selects the OAuth endpoint). */
readonly type: string
/** The provider's OAuth client id (used to build the provider redirect; empty when unconfigured). */
readonly clientId: string
/** Override OAuth scopes, if the provider record sets them. */
readonly scopes: string
}
/**
* The enabled-auth-methods view of an IAM application, read live from
* `/v1/iam/get-app-login`. This is the canonical source of truth for which
* buttons to render — it reflects the per-app config in `init_data.json`
* (password + GitHub + Google + Web3). The portal renders exactly what IAM
* reports enabled, so there is no client/server method drift.
*/
export interface AppLogin {
/** IAM application name (e.g. `hanzo-id`). */
readonly application: string
/** Owning organization slug. */
readonly organization: string
/** Email/username + password sign-in is enabled. */
readonly enablePassword: boolean
/** Self-service signup is enabled. */
readonly enableSignUp: boolean
/** Email/SMS verification-code sign-in is enabled. */
readonly enableCodeSignin: boolean
/** Social + Web3 providers enabled on the app, in display order. */
readonly providers: readonly AppProvider[]
}
+8
View File
@@ -0,0 +1,8 @@
/** Labeled horizontal rule, e.g. "or", separating social from email sign-in. */
export function Divider({ label = 'or' }: { label?: string }) {
return (
<div className="hanzo-id-divider" role="separator" aria-label={label}>
<span>{label}</span>
</div>
)
}
+6 -6
View File
@@ -20,8 +20,8 @@ export function ForgotForm(props: ForgotFormProps) {
try {
const res = await client.forgot({
identifier,
clientId: client.tenant.clientId,
organization: client.tenant.orgId,
clientId: client.org.clientId,
organization: client.org.orgId,
})
if (!res.ok) setError(res.error ?? 'send failed')
else {
@@ -40,13 +40,13 @@ export function ForgotForm(props: ForgotFormProps) {
}
return (
<form onSubmit={onSubmit} className="hanzo-id-forgot-form" aria-busy={busy}>
<label>
<form onSubmit={onSubmit} className="hanzo-id-form" aria-busy={busy}>
<label className="hanzo-id-field">
<span>Email</span>
<input type="email" autoComplete="email" value={identifier} onChange={(e) => setIdentifier(e.target.value)} required />
<input className="hanzo-id-input" type="email" autoComplete="email" value={identifier} onChange={(e) => setIdentifier(e.target.value)} required />
</label>
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
<button type="submit" disabled={busy}>{busy ? 'Sending…' : 'Send reset link'}</button>
<button type="submit" className="hanzo-id-btn" disabled={busy}>{busy ? 'Sending…' : 'Send reset link'}</button>
</form>
)
}
+88 -169
View File
@@ -1,91 +1,95 @@
import { useEffect, useState, type FormEvent } from 'react'
import { useState, type FormEvent } from 'react'
import type { AuthClient } from '../client'
import type { AppLoginInfo, LoginResponse } from '../types'
import { ProviderButtons } from './ProviderButtons'
import { OTPForm } from './OTPForm'
import type { LoginResponse } from '../types'
export interface LoginFormProps {
readonly client: AuthClient
readonly redirectUri?: string
readonly state?: string
readonly clientIdOverride?: string
readonly codeChallenge?: string
readonly codeChallengeMethod?: 'S256' | 'plain'
readonly nonce?: string
readonly onSuccess?: (res: LoginResponse) => void
readonly onMfaRequired?: (res: LoginResponse) => void
/**
* Called after a successful sign-in INSTEAD of the form's default post-login
* navigation. When provided, the form does not redirect (neither to a
* downstream app nor to `/onboarding`) — the caller owns what happens next.
* Used by the device-approval page to stay on-page and show the confirm step.
*/
readonly onAuthenticated?: (res: LoginResponse) => void
}
export function LoginForm(props: LoginFormProps) {
const { client } = props
const [app, setApp] = useState<AppLoginInfo | null>(null)
const [mode, setMode] = useState<'password' | 'code'>('password')
const [identifier, setIdentifier] = useState('')
const [password, setPassword] = useState('')
const [codeSent, setCodeSent] = useState(false)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [notice, setNotice] = useState<string | null>(null)
// Load the application's providers + sign-in methods so we render exactly
// what IAM offers (social buttons, email/SMS code). Best-effort: on failure
// we still show password login.
useEffect(() => {
let alive = true
client
.appLogin()
.then((a) => {
if (alive) setApp(a)
})
.catch(() => {})
return () => {
alive = false
}
}, [client])
const codeEnabled =
!!app &&
(app.enableCodeSignin ||
app.signinMethods.some((m) => m.name === 'Verification code' && m.rule !== 'None'))
function handleResult(res: LoginResponse) {
if (res.error) setError(res.error)
else if (res.mfaRequired) props.onMfaRequired?.(res)
else if (res.redirectUrl) window.location.href = res.redirectUrl
else props.onSuccess?.(res)
}
async function onPasswordSubmit(e: FormEvent) {
async function onSubmit(e: FormEvent) {
e.preventDefault()
setBusy(true)
setError(null)
try {
// Authenticate against the ORG OF THE APP being logged into, not the
// portal's own brand. When a downstream app initiates the login it passes
// its own `client_id` (props.clientIdOverride); that app may live in a
// different org than this brand portal — e.g. the admin-guard
// (client_id=hanzo-admin-guard) is in the `admin` org, so its operators
// must resolve to the admin/* identity (owner=admin), NOT this brand's
// hanzo/* row. get-app-login is the canonical clientId -> {application,
// organization} map; resolve through it and post BOTH so IAM scopes the
// credential check to the app's org.
//
// BOTH entry points resolve the same way — the downstream-app login
// (clientIdOverride) and the brand portal's own bare sign-in. They used to
// differ: the bare portal deliberately posted NO `organization` so IAM's
// cross-org fallback landed a colliding identity (z@hanzo.ai exists in both
// `admin` and `hanzo`) on admin/* and returned the full multi-org session.
//
// That is gone, on purpose, at the server. iam2 scopes every credential
// lookup to one org and treats the collision it relied on as a defect —
// "the F-2 bug where z@hanzo.ai collided across admin and hanzo" — because
// cross-org resolution coupled lockout counters across rows and gave a
// brute-force oracle on the superadmin. So it now REFUSES an org-less login
// with "organization, username and password are required". It answers HTTP
// **200**, which the form then renders as if the user's own password were
// wrong, and which every status-code monitor reads as green — the apex form
// was dead on hanzo.id, lux.id, iam.hanzo.ai and pars.id simultaneously.
//
// Posting the app's own org is the established answer (it is what the
// override path already does, and what reaches admin/* for admin-org apps).
// A global admin is no longer resolved by omission; they reach the admin
// identity by signing into an admin-org app, which is the explicit path.
const app = await client.getAppLogin(props.clientIdOverride ?? client.org.clientId)
const application = app?.application ?? client.org.appName
const organization = app?.organization ?? client.org.loginOrg
const res = await client.login({
identifier,
password,
clientId: props.clientIdOverride ?? client.tenant.clientId,
application: client.tenant.appName,
organization: client.tenant.orgId,
clientId: props.clientIdOverride ?? client.org.clientId,
application,
organization,
redirectUri: props.redirectUri,
state: props.state,
codeChallenge: props.codeChallenge,
codeChallengeMethod: props.codeChallengeMethod,
nonce: props.nonce,
})
handleResult(res)
} catch (err) {
setError(String(err))
} finally {
setBusy(false)
}
}
async function onSendCode(e: FormEvent) {
e.preventDefault()
setBusy(true)
setError(null)
setNotice(null)
try {
const r = await client.sendLoginCode(identifier)
if (r.ok) {
setCodeSent(true)
setNotice(`Code sent to ${identifier}`)
if (res.error) {
setError(res.error)
} else if (res.mfaRequired) {
props.onMfaRequired?.(res)
} else if (props.onAuthenticated) {
// Caller owns the next step (e.g. device approval) — suppress the
// default navigation so we stay on-page.
props.onAuthenticated(res)
} else if (res.redirectUrl) {
window.location.href = res.redirectUrl
} else {
setError(r.error ?? 'Could not send code')
props.onSuccess?.(res)
}
} catch (err) {
setError(String(err))
@@ -94,117 +98,32 @@ export function LoginForm(props: LoginFormProps) {
}
}
async function onVerifyCode(code: string) {
setBusy(true)
setError(null)
try {
const res = await client.loginWithCode({
dest: identifier,
code,
clientId: props.clientIdOverride ?? client.tenant.clientId,
application: client.tenant.appName,
organization: client.tenant.orgId,
redirectUri: props.redirectUri,
state: props.state,
})
handleResult(res)
} catch (err) {
setError(String(err))
} finally {
setBusy(false)
}
}
return (
<div className="hanzo-id-login">
{app && app.providers.length > 0 ? (
<ProviderButtons
client={client}
providers={app.providers}
mode="login"
redirectUri={props.redirectUri}
state={props.state}
clientIdOverride={props.clientIdOverride}
<form onSubmit={onSubmit} className="hanzo-id-form" aria-busy={busy}>
<label className="hanzo-id-field">
<span>Email or username</span>
<input
className="hanzo-id-input"
type="text"
autoComplete="username"
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
required
/>
) : null}
{mode === 'password' ? (
<form onSubmit={onPasswordSubmit} className="hanzo-id-login-form" aria-busy={busy}>
<label>
<span>Email or username</span>
<input
type="text"
autoComplete="username"
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
required
/>
</label>
<label>
<span>Password</span>
<input
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</label>
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
<button type="submit" disabled={busy}>{busy ? 'Signing in…' : 'Sign in'}</button>
</form>
) : (
<div className="hanzo-id-code-login">
{!codeSent ? (
<form onSubmit={onSendCode} className="hanzo-id-login-form" aria-busy={busy}>
<label>
<span>Email or phone</span>
<input
type="text"
autoComplete="username"
placeholder="you@example.com or +1 555 555 5555"
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
required
/>
</label>
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
<button type="submit" disabled={busy}>{busy ? 'Sending…' : 'Send code'}</button>
</form>
) : (
<>
{notice ? <p className="hanzo-id-notice">{notice}</p> : null}
<OTPForm channel={identifier.includes('@') ? 'email' : 'sms'} onSubmit={onVerifyCode} />
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
<button
type="button"
className="hanzo-id-linkbtn"
onClick={() => {
setCodeSent(false)
setNotice(null)
}}
>
Use a different address
</button>
</>
)}
</div>
)}
{codeEnabled ? (
<button
type="button"
className="hanzo-id-linkbtn hanzo-id-toggle-mode"
onClick={() => {
setMode(mode === 'password' ? 'code' : 'password')
setError(null)
setNotice(null)
setCodeSent(false)
}}
>
{mode === 'password' ? 'Sign in with email or SMS code' : 'Sign in with password'}
</button>
) : null}
</div>
</label>
<label className="hanzo-id-field">
<span>Password</span>
<input
className="hanzo-id-input"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</label>
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
<button type="submit" className="hanzo-id-btn" disabled={busy}>{busy ? 'Signing in…' : 'Sign in'}</button>
</form>
)
}
+132
View File
@@ -0,0 +1,132 @@
import { useEffect, useMemo, useState } from 'react'
import encodeQR from '@paulmillr/qr'
import type { AuthClient } from '../client'
import type { MfaIdentity, MfaSetup } from '../types'
import { OTPForm } from './OTPForm'
export interface MfaEnrollFormProps {
readonly client: AuthClient
/**
* Called once the user has verified a TOTP code AND the enrollment is
* persisted. The caller continues the session (onboarding or the OIDC
* code redirect).
*/
readonly onComplete: () => void
}
/**
* Forced TOTP enrollment, shown when IAM answers a login with `RequiredMfa`
* (org policy requires MFA and the user has none). There is intentionally NO
* skip / dismiss control — the only way past this screen is to enroll an
* authenticator. The QR is rendered locally from the `otpauth://` URI, so the
* TOTP secret never leaves the browser.
*
* Flow: `getAccount` (resolve identity from the session IAM set with
* `RequiredMfa`) → `mfaInitiate` (secret + QR) → user scans → `mfaVerify`
* (prove the code) → `mfaEnable` (persist) → `onComplete`.
*/
export function MfaEnrollForm({ client, onComplete }: MfaEnrollFormProps) {
const [identity, setIdentity] = useState<MfaIdentity | null>(null)
const [setup, setSetup] = useState<MfaSetup | null>(null)
const [fatal, setFatal] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
useEffect(() => {
let cancelled = false
async function begin() {
try {
const id = await client.getAccount()
if (!id) throw new Error('Your session could not be resolved. Please sign in again.')
const s = await client.mfaInitiate(id)
if (cancelled) return
setIdentity(id)
setSetup(s)
} catch (e) {
if (!cancelled) setFatal(e instanceof Error ? e.message : String(e))
}
}
void begin()
return () => {
cancelled = true
}
}, [client])
const qrSvg = useMemo(() => (setup ? encodeQR(setup.url, 'svg') : ''), [setup])
async function onCode(code: string) {
if (!identity || !setup || busy) return
setBusy(true)
setError(null)
try {
const verified = await client.mfaVerify({ owner: identity.owner, name: identity.name, secret: setup.secret, passcode: code })
if (!verified.ok) {
setError(verified.error ?? 'That code did not match. Try the current code from your app.')
return
}
const enabled = await client.mfaEnable({
owner: identity.owner,
name: identity.name,
secret: setup.secret,
recoveryCode: setup.recoveryCodes[0] ?? '',
})
if (!enabled.ok) {
setError(enabled.error ?? 'Could not enable two-factor authentication.')
return
}
onComplete()
} finally {
setBusy(false)
}
}
if (fatal) {
return (
<div className="hanzo-id-mfa-enroll">
<h2>Two-factor setup</h2>
<p role="alert" className="hanzo-id-error">{fatal}</p>
</div>
)
}
if (!setup) {
return (
<div className="hanzo-id-mfa-enroll">
<h2>Two-factor setup</h2>
<p className="lede">Preparing your authenticator</p>
</div>
)
}
const recoveryCode = setup.recoveryCodes[0]
return (
<div className="hanzo-id-mfa-enroll">
<h2>Set up two-factor authentication</h2>
<p className="lede">
Your organization requires two-factor authentication. Scan this QR code with an
authenticator app (Google Authenticator, 1Password, Authy), then enter the 6-digit code it
shows.
</p>
<div
className="hanzo-id-mfa-qr"
role="img"
aria-label="TOTP enrollment QR code"
// Local SVG from @paulmillr/qr — the otpauth secret never leaves the browser.
dangerouslySetInnerHTML={{ __html: qrSvg }}
/>
<details className="hanzo-id-mfa-manual">
<summary>Can't scan? Enter this key manually</summary>
<code className="hanzo-id-mfa-secret">{setup.secret}</code>
</details>
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
<OTPForm channel="totp" onSubmit={onCode} />
{recoveryCode ? (
<p className="hanzo-id-mfa-recovery">
Save this recovery code somewhere safe it lets you sign in if you lose your device:
<br />
<code>{recoveryCode}</code>
</p>
) : null}
</div>
)
}
+6 -3
View File
@@ -1,4 +1,5 @@
import { useState, type FormEvent } from 'react'
import { SmsConsentNotice } from './SmsConsent'
export interface OTPFormProps {
readonly onSubmit: (code: string) => void | Promise<void>
@@ -25,10 +26,11 @@ export function OTPForm(props: OTPFormProps) {
const label = channel === 'sms' ? 'SMS code' : channel === 'email' ? 'Email code' : 'Authenticator code'
return (
<form onSubmit={onSubmit} className="hanzo-id-otp-form" aria-busy={busy}>
<label>
<form onSubmit={onSubmit} className="hanzo-id-form" aria-busy={busy}>
<label className="hanzo-id-field">
<span>{label}</span>
<input
className="hanzo-id-input"
type="text"
inputMode="numeric"
pattern={`\\d{${length}}`}
@@ -39,7 +41,8 @@ export function OTPForm(props: OTPFormProps) {
required
/>
</label>
<button type="submit" disabled={busy || code.length !== length}>{busy ? 'Verifying…' : 'Verify'}</button>
{channel === 'sms' ? <SmsConsentNotice /> : null}
<button type="submit" className="hanzo-id-btn" disabled={busy || code.length !== length}>{busy ? 'Verifying…' : 'Verify'}</button>
</form>
)
}
-80
View File
@@ -1,80 +0,0 @@
import type { AuthClient } from '../client'
import type { ProviderInfo } from '../types'
interface Meta {
readonly label: string
/** stable brand token used for the CSS class + data attribute (for icon styling) */
readonly brand: string
}
// Keyed by a normalized provider token (type or name, lowercased, alnum-only,
// "provider" prefix stripped). Falls back to a generic label for anything new.
const META: Record<string, Meta> = {
google: { label: 'Continue with Google', brand: 'google' },
github: { label: 'Continue with GitHub', brand: 'github' },
apple: { label: 'Continue with Apple', brand: 'apple' },
facebook: { label: 'Continue with Facebook', brand: 'facebook' },
web3: { label: 'Connect wallet', brand: 'web3' },
web3onboard: { label: 'Connect wallet', brand: 'web3' },
metamask: { label: 'Connect wallet', brand: 'web3' },
}
function metaFor(p: ProviderInfo): Meta {
const key = (p.type || p.name || '')
.toLowerCase()
.replace(/[^a-z0-9]/g, '')
.replace(/^provider/, '')
return META[key] ?? { label: `Continue with ${p.displayName || p.name}`, brand: 'generic' }
}
export interface ProviderButtonsProps {
readonly client: AuthClient
readonly providers: ProviderInfo[]
readonly mode: 'login' | 'signup'
readonly redirectUri?: string
readonly state?: string
readonly clientIdOverride?: string
}
/**
* Renders one button per social/wallet provider attached to the application.
* Each links to the IAM authorize endpoint with `provider=<name>`, which
* initiates that provider's OAuth and returns to `${publicOrigin}/callback`.
* The set is driven by the live app config (AuthClient.appLogin) — no
* hardcoded provider list — so enabling a provider in IAM surfaces it here.
*/
export function ProviderButtons(props: ProviderButtonsProps) {
const { client, providers, mode } = props
const usable = providers.filter((p) =>
mode === 'signup' ? p.canSignUp !== false : p.canSignIn !== false,
)
if (usable.length === 0) return null
const redirectUri = props.redirectUri ?? `${client.tenant.publicOrigin}/callback`
return (
<div className="hanzo-id-providers">
{usable.map((p) => {
const m = metaFor(p)
const href = client.authorize({
clientId: props.clientIdOverride ?? client.tenant.clientId,
redirectUri,
state: props.state ?? mode,
provider: p.name,
})
return (
<a
key={p.name}
className={`hanzo-id-provider-btn hanzo-id-provider-${m.brand}`}
href={href}
data-provider={m.brand}
>
{m.label}
</a>
)
})}
<div className="hanzo-id-or">
<span>or</span>
</div>
</div>
)
}
+67 -49
View File
@@ -1,51 +1,73 @@
import { useEffect, useState, type FormEvent } from 'react'
import { useState, type FormEvent } from 'react'
import type { AuthClient } from '../client'
import type { AppLoginInfo } from '../types'
import { ProviderButtons } from './ProviderButtons'
export interface SignupFormProps {
readonly client: AuthClient
readonly inviteCode?: string
readonly onSuccess?: () => void
/**
* The downstream OIDC request the user arrived with, when an app sent them
* here to register. Forwarded to the sign-in that follows account creation so
* the flow ends where it started — back at the app, holding a code.
*/
readonly redirectUri?: string
readonly state?: string
readonly clientIdOverride?: string
readonly codeChallenge?: string
readonly codeChallengeMethod?: 'S256' | 'plain'
readonly nonce?: string
}
export function SignupForm(props: SignupFormProps) {
const { client } = props
const [app, setApp] = useState<AppLoginInfo | null>(null)
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let alive = true
client
.appLogin()
.then((a) => {
if (alive) setApp(a)
})
.catch(() => {})
return () => {
alive = false
}
}, [client])
async function onSubmit(e: FormEvent) {
e.preventDefault()
setBusy(true)
setError(null)
try {
const res = await client.signup({
// Register against the app the user CAME FROM, not this portal. IAM's
// signup resolves the application by clientId and then gates the org
// against that app's own org, so a downstream `client_id` must reach
// it or the account is created under the portal's app instead.
const clientId = props.clientIdOverride ?? client.org.clientId
const app = await client.getAppLogin(clientId, props.redirectUri)
const application = app?.application ?? client.org.appName
const organization = app?.organization ?? client.org.orgId
const session = await client.signup({
email,
password,
clientId: client.tenant.clientId,
application: client.tenant.appName,
organization: client.tenant.orgId,
clientId,
application,
organization,
inviteCode: props.inviteCode,
redirectUri: props.redirectUri,
state: props.state,
codeChallenge: props.codeChallenge,
codeChallengeMethod: props.codeChallengeMethod,
nonce: props.nonce,
})
if (res.error) setError(res.error)
else if (res.redirectUrl) window.location.href = res.redirectUrl
else props.onSuccess?.()
if (session.error) {
setError(session.error)
return
}
if (session.redirectUrl) {
window.location.href = session.redirectUrl
return
}
// The account exists but the session did not complete here — an org that
// forces MFA answers the login with an enrollment step. Hand the user to
// the sign-in page, carrying the same OIDC request, rather than leaving
// them on a form that has nothing left to do.
if (session.mfaRequired) {
window.location.href = `/login${window.location.search}`
return
}
setError('Your account was created, but sign-in did not complete. Please sign in.')
} catch (err) {
setError(String(err))
} finally {
@@ -54,29 +76,25 @@ export function SignupForm(props: SignupFormProps) {
}
return (
<div className="hanzo-id-signup">
{app && app.providers.length > 0 ? (
<ProviderButtons client={client} providers={app.providers} mode="signup" />
) : null}
<form onSubmit={onSubmit} className="hanzo-id-signup-form" aria-busy={busy}>
<label>
<span>Email</span>
<input type="email" autoComplete="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
</label>
<label>
<span>Password</span>
<input
type="password"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
minLength={12}
required
/>
</label>
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
<button type="submit" disabled={busy}>{busy ? 'Creating account…' : 'Create account'}</button>
</form>
</div>
<form onSubmit={onSubmit} className="hanzo-id-form" aria-busy={busy}>
<label className="hanzo-id-field">
<span>Email</span>
<input className="hanzo-id-input" type="email" autoComplete="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
</label>
<label className="hanzo-id-field">
<span>Password</span>
<input
className="hanzo-id-input"
type="password"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
minLength={12}
required
/>
</label>
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
<button type="submit" className="hanzo-id-btn" disabled={busy}>{busy ? 'Creating account…' : 'Create account'}</button>
</form>
)
}
+38
View File
@@ -0,0 +1,38 @@
// Canonical A2P 10DLC consent copy. This EXACT disclosure is reused at every
// point where Hanzo collects or uses a phone number for messaging. It MUST stay
// verbatim-identical to the public opt-in page (hanzo.ai/sms-opt-in,
// `SMS_CONSENT_TEXT`) and to the IAM phone-login UI — Twilio / carrier campaign
// review compares the wording across surfaces. One string, reused everywhere.
export const SMS_CONSENT_TEXT =
'I agree to receive text messages (SMS) from Hanzo AI at the number provided, ' +
'including one-time passcodes and two-factor authentication, account and security ' +
'alerts, and transactional notifications. Message frequency varies. Message and data ' +
'rates may apply. Reply STOP to opt out at any time, or HELP for help. Consent is not ' +
'a condition of any purchase.'
const TERMS_URL = 'https://hanzo.ai/terms'
const PRIVACY_URL = 'https://hanzo.ai/privacy'
/**
* SMS consent disclosure shown beneath any phone/SMS surface (disclosure-only,
* no checkbox — the portal's SMS step is reached only after the user already
* provided/opted-in their number in IAM, and after a code was sent).
*
* For a phone-number COLLECTION surface that requires affirmative opt-in (A2P),
* gate the submit on a checkbox and reuse {@link SMS_CONSENT_TEXT} — see the IAM
* SignupPage `SmsConsentCheckbox`. The portal does not yet render its own phone
* field (collection happens in the IAM-hosted UI), so only the notice is used
* here today.
*/
export function SmsConsentNotice() {
return (
<div className="hanzo-id-sms-consent" role="note">
<p>{SMS_CONSENT_TEXT}</p>
<p className="hanzo-id-sms-consent-links">
By continuing, you agree to our{' '}
<a href={TERMS_URL} target="_blank" rel="noreferrer">Terms of Service</a> and{' '}
<a href={PRIVACY_URL} target="_blank" rel="noreferrer">Privacy Policy</a>.
</p>
</div>
)
}
+331
View File
@@ -0,0 +1,331 @@
import { Fragment, useEffect, useRef, useState } from 'react'
import type { ComponentType, SVGProps } from 'react'
import type { Chain } from '@hanzo/id-connect'
import type { AuthClient } from '../client'
import type { AppProvider } from '../types'
import { authorizeRequest, matchProviderHint } from '../social'
import { createIam } from '../iam'
import {
loginWithWalletChain,
detectWalletChains,
ENABLED_WALLET_CHAINS,
WALLET_CHAIN_LABELS,
} from '../web3'
import { GitHubIcon, GitLabIcon, GoogleIcon, WalletIcon } from './icons'
import { Divider } from './Divider'
/**
* Social + multi-chain wallet sign-in buttons.
*
* The enabled set is read live from `/v1/iam/get-app-login` (via
* `client.getAppLogin()`) — the canonical source of truth that mirrors the
* per-app provider config in `init_data.json`. We render ONLY providers IAM
* holds real credentials for (`AppProvider.configured`); a provider seeded with
* placeholder creds is hidden so its button never dead-ends, and reappears once
* real creds land. When the config is unreadable we render none.
*
* Two sign-in shapes, decomplected:
* - OAuth (github/google/gitlab) → FEDERATION: name the provider on IAM's own
* authorize endpoint (`?provider=provider-github`) and let IAM run the entire
* IdP leg server-side, where the client secret lives. See `social.ts`. This
* browser never builds an IdP URL and never sees a provider code.
* - Web3/wallet → native Sign-In-With-X (`loginWithWalletChain`): connect a
* wallet with `@hanzo/id-connect` (no WalletConnect, no projectId), sign the
* IAM-minted challenge, POST `/v1/iam/web3/verify`, then follow the SAME
* redirect the password flow returns. The wallet provider renders ONE
* chain-agnostic "Connect Wallet" button: it auto-detects the injected
* chain (`detectWalletChains`) and connects straight when exactly one is
* present, else reveals a chooser so either EVM or Solana stays reachable.
*/
export interface SocialButtonsProps {
readonly client: AuthClient
/** Override the OAuth client_id (e.g. a downstream app's id). */
readonly clientIdOverride?: string
/** "signin" (default) or "signup" — only changes button copy. */
readonly intent?: 'signin' | 'signup'
/**
* Downstream app's `redirect_uri`, if this portal is mid-flow for another
* app. OAuth sign-in returns to the portal's own `/callback` (stashed here
* and forwarded by `Callback`); wallet sign-in threads it straight into the
* verify POST so IAM mints the auth-code redirect back to the app. Absent →
* a bare portal sign-in that lands on onboarding.
*/
readonly postLoginRedirect?: string
/**
* A `provider_hint` from the authorize query — the console passes
* `?provider_hint=provider-github` when a user clicks "Continue with GitHub"
* over there. When set, once the app config resolves this component launches
* the matching provider's hop straight away (the SAME hop the button runs) and
* renders NOTHING: it is headless, a pure side-effect, so the caller shows its
* own "signing you in" state. If the hint matches no configured provider,
* `onAutoStartResolved(false)` fires so the caller can fall back to the form.
*/
readonly autoStart?: string
/**
* Called once, in `autoStart` mode, after the app config resolves: `true` when
* the hinted provider launched, `false` when the hint matched nothing (so the
* caller can drop to the interactive form instead of a blank redirect state).
*/
readonly onAutoStartResolved?: (started: boolean) => void
}
interface ProviderMeta {
readonly key: string
readonly label: string
readonly Icon: ComponentType<SVGProps<SVGSVGElement>>
}
/** Display metadata for the providers the portal knows how to render. */
const PROVIDER_META: Record<string, ProviderMeta> = {
github: { key: 'github', label: 'GitHub', Icon: GitHubIcon },
gitlab: { key: 'gitlab', label: 'GitLab', Icon: GitLabIcon },
google: { key: 'google', label: 'Google', Icon: GoogleIcon },
web3: { key: 'web3', label: 'Wallet', Icon: WalletIcon },
}
/** Canonical render order. */
const ORDER = ['github', 'gitlab', 'google', 'web3']
interface Resolved {
/** Configured + renderable providers, keyed by their normalized key. */
readonly providers: Record<string, AppProvider>
}
export function SocialButtons({
client,
clientIdOverride,
intent = 'signin',
postLoginRedirect,
autoStart,
onAutoStartResolved,
}: SocialButtonsProps) {
const [resolved, setResolved] = useState<Resolved | null>(null)
const [error, setError] = useState<string | null>(null)
const [busyChain, setBusyChain] = useState<Chain | null>(null)
// The chain-agnostic wallet entry reveals a chooser only when it can't decide
// for the user (zero or multiple injected wallets); a single injected wallet
// connects straight without ever showing it.
const [walletMenu, setWalletMenu] = useState(false)
const autoStarted = useRef(false)
// Start federation: hand the provider's NAME to IAM's authorize endpoint and
// let IAM run the whole IdP leg. Shared by the button click and the `autoStart`
// auto-launch so both take the identical path.
//
// Two arms, and they are the same two the password path already branches on
// (`Login.completeAfterAuth`) — the question is only who owns the PKCE verifier:
//
// an app sent the user here → re-enter authorize with THAT app's request, so
// IAM mints the code against its client_id, redirect_uri and challenge and
// returns the browser straight to it. The app holds the verifier; this portal
// is never in the return path and never touches a token.
//
// a bare portal sign-in → the portal is its own client, so the IAM SDK mints
// and stores the verifier that `Callback` reads back. `post_login_redirect`
// carries a non-OIDC "come back here" target (device approval), which is why
// it belongs to this arm alone: it is only ever read by the portal's own
// callback, and only this arm runs it.
function hop(provider: AppProvider) {
const app = authorizeRequest(window.location.search, clientIdOverride ?? client.org.clientId)
if (app) {
sessionStorage.removeItem('post_login_redirect')
window.location.assign(client.authorize({ ...app, provider: provider.name }))
return
}
if (postLoginRedirect) sessionStorage.setItem('post_login_redirect', postLoginRedirect)
else sessionStorage.removeItem('post_login_redirect')
createIam(client.org, clientIdOverride)
.signinRedirect({ additionalParams: { provider: provider.name } })
.catch((e) => setError(String(e)))
}
useEffect(() => {
let cancelled = false
// Read the app config against the DOWNSTREAM app's own redirect_uri (carried
// on the authorize query in the SSO flow), not the portal's /callback — IAM
// validates it against the app's registered list, and a cross-app clientId
// (e.g. console's `hanzo-cloud` viewed from hanzo.id) does NOT register the
// portal callback, so hardcoding it drops the whole response and no social
// resolves. Absent (bare portal / device flow) → getAppLogin defaults it.
const oidcRedirectUri =
typeof window !== 'undefined'
? new URLSearchParams(window.location.search).get('redirect_uri') ?? undefined
: undefined
client
.getAppLogin(clientIdOverride, oidcRedirectUri)
.then((app) => {
if (cancelled) return
if (!app) {
// Can't read the app config → render no social rather than risk a
// dead-end button. Password / email-code still render.
setResolved({ providers: {} })
onAutoStartResolved?.(false)
return
}
const want = intent === 'signup' ? (p: AppProvider) => p.canSignUp : (p: AppProvider) => p.canSignIn
// Render ONLY providers IAM actually holds credentials for. A provider
// with placeholder/empty creds would dead-end the OAuth redirect, so we
// hide it; it reappears automatically once real creds are seeded. Web3
// needs no IAM-side OAuth credential (the wallet IS the credential), so
// it renders whenever the app enables it.
const providers: Record<string, AppProvider> = {}
for (const p of app.providers) {
const enabled = p.key === 'web3' ? want(p) : want(p) && p.configured
if (enabled && p.key in PROVIDER_META) providers[p.key] = p
}
setResolved({ providers })
// A client that already knows the provider (console `?provider_hint=…`)
// launches it straight away — the SAME hop the button runs, so a click
// over there lands directly in the provider flow, no second press and no
// bounce through this login page.
if (autoStart && !autoStarted.current) {
autoStarted.current = true
const target = matchProviderHint(Object.values(providers), autoStart)
if (target) {
onAutoStartResolved?.(true)
hop(target)
} else {
// Hint names a provider this app doesn't offer → let the caller show
// the form rather than dead-end on a blank "signing you in".
onAutoStartResolved?.(false)
}
}
})
.catch(() => {
if (cancelled) return
setResolved({ providers: {} })
onAutoStartResolved?.(false)
})
return () => {
cancelled = true
}
// Run once on mount: getAppLogin is a one-shot and autoStart is fixed for
// the life of the page; the ref guards the hop against a double-fire.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [client, clientIdOverride, intent])
// In autoStart mode the component is headless — it exists only to run the hop
// above; the caller renders its own "signing you in" state. Render nothing.
if (autoStart) return null
if (resolved === null) return null // resolving — render nothing rather than flicker
const ordered = ORDER.filter((k) => k in resolved.providers)
if (ordered.length === 0) return null
const verb = intent === 'signup' ? 'Sign up' : 'Continue'
function startOAuth(provider: AppProvider) {
setError(null)
hop(provider)
}
async function startWallet(chain: Chain) {
setError(null)
setBusyChain(chain)
try {
const sp = new URLSearchParams(typeof window !== 'undefined' ? window.location.search : '')
const res = await loginWithWalletChain(client, chain, {
clientId: clientIdOverride,
redirectUri: postLoginRedirect,
state: sp.get('state') ?? undefined,
nonce: sp.get('nonce') ?? undefined,
codeChallenge: sp.get('code_challenge') ?? undefined,
codeChallengeMethod: (sp.get('code_challenge_method') as 'S256' | 'plain' | null) ?? undefined,
})
if (res.error) {
setError(res.error)
} else if (res.redirectUrl) {
// Same post-login redirect the password flow performs.
window.location.href = res.redirectUrl
}
} catch (e) {
setError(String(e))
} finally {
setBusyChain(null)
}
}
// The chain-agnostic entry: auto-detect the injected wallet and connect
// straight when exactly one chain is available; otherwise reveal the chooser
// so the user picks EVM or Solana. Both underlying flows stay reachable.
function onConnectWallet() {
setError(null)
const detected = detectWalletChains()
if (detected.length === 1) startWallet(detected[0]!)
else setWalletMenu(true)
}
return (
<>
<div className="hanzo-id-social">
{ordered.map((k) => {
const provider = resolved.providers[k]!
// Web3 expands into one connect button per ENABLED chain; OAuth
// providers render a single hop button.
if (k === 'web3') {
// ONE chain-agnostic entry. It connects straight when a single
// wallet is detected, else expands into the chooser below — so the
// page always shows exactly one "Connect Wallet" button, with both
// EVM and Solana reachable from it.
return (
<Fragment key="web3">
<button
type="button"
className="hanzo-id-btn ghost"
data-provider="web3"
data-wallet-connect="true"
aria-expanded={walletMenu}
disabled={busyChain !== null}
onClick={onConnectWallet}
>
<WalletIcon />
<span>{busyChain !== null && !walletMenu ? 'Connecting…' : 'Connect Wallet'}</span>
</button>
{walletMenu ? (
<div
className="hanzo-id-wallet-chains"
role="group"
aria-label="Choose a wallet network"
>
{ENABLED_WALLET_CHAINS.map((chain) => (
<button
key={`web3-${chain}`}
type="button"
className="hanzo-id-btn ghost"
data-provider="web3"
data-chain={chain}
disabled={busyChain !== null}
onClick={() => startWallet(chain)}
>
<WalletIcon />
<span>{busyChain === chain ? 'Connecting…' : WALLET_CHAIN_LABELS[chain]}</span>
</button>
))}
</div>
) : null}
</Fragment>
)
}
const meta = PROVIDER_META[k]!
const { Icon } = meta
return (
<button
key={k}
type="button"
className="hanzo-id-btn ghost"
data-provider={k}
onClick={() => startOAuth(provider)}
>
<Icon />
<span>{verb} with {meta.label}</span>
</button>
)
})}
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
</div>
{/* The "or" separator belongs WITH the social block — render it only when
there are buttons, so it never dangles above the password form when
no providers are configured. */}
<Divider />
</>
)
}
+51
View File
@@ -0,0 +1,51 @@
/**
* Minimal inline provider marks. Brand-neutral, currentColor-driven, no
* external icon dependency. One 18px glyph per supported sign-in provider.
*/
import type { SVGProps } from 'react'
const base = (props: SVGProps<SVGSVGElement>) => ({
width: 18,
height: 18,
viewBox: '0 0 24 24',
'aria-hidden': true,
focusable: false as const,
...props,
})
export function GitHubIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...base(props)} fill="currentColor">
<path d="M12 .5C5.73.5.5 5.73.5 12a11.5 11.5 0 0 0 7.86 10.92c.58.1.79-.25.79-.56v-2c-3.2.7-3.88-1.37-3.88-1.37-.53-1.34-1.3-1.7-1.3-1.7-1.05-.72.08-.7.08-.7 1.17.08 1.78 1.2 1.78 1.2 1.04 1.78 2.73 1.27 3.4.97.1-.75.4-1.27.73-1.56-2.56-.29-5.26-1.28-5.26-5.7 0-1.26.45-2.29 1.2-3.1-.12-.3-.52-1.48.11-3.08 0 0 .97-.31 3.18 1.18a11 11 0 0 1 5.8 0c2.2-1.5 3.17-1.18 3.17-1.18.63 1.6.23 2.78.11 3.08.75.81 1.2 1.84 1.2 3.1 0 4.43-2.7 5.4-5.28 5.69.42.36.79 1.07.79 2.16v3.2c0 .31.21.67.8.56A11.5 11.5 0 0 0 23.5 12C23.5 5.73 18.27.5 12 .5Z" />
</svg>
)
}
export function GoogleIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...base(props)}>
<path fill="#4285F4" d="M23.52 12.27c0-.82-.07-1.6-.21-2.36H12v4.46h6.46a5.52 5.52 0 0 1-2.4 3.62v3h3.88c2.27-2.09 3.58-5.17 3.58-8.72Z" />
<path fill="#34A853" d="M12 24c3.24 0 5.96-1.08 7.94-2.91l-3.88-3c-1.08.72-2.45 1.15-4.06 1.15-3.12 0-5.77-2.11-6.71-4.95H1.28v3.1A12 12 0 0 0 12 24Z" />
<path fill="#FBBC05" d="M5.29 14.29A7.2 7.2 0 0 1 4.91 12c0-.8.14-1.57.38-2.29v-3.1H1.28A12 12 0 0 0 0 12c0 1.94.46 3.77 1.28 5.39l4.01-3.1Z" />
<path fill="#EA4335" d="M12 4.76c1.76 0 3.34.61 4.58 1.8l3.43-3.43A11.99 11.99 0 0 0 12 0 12 12 0 0 0 1.28 6.61l4.01 3.1C6.23 6.87 8.88 4.76 12 4.76Z" />
</svg>
)
}
export function GitLabIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...base(props)} fill="currentColor">
<path d="M23.955 13.587l-1.342-4.135-2.664-8.189a.455.455 0 0 0-.867 0L16.418 9.45H7.582L4.919 1.263a.455.455 0 0 0-.867 0L1.388 9.452-.001 13.587a.924.924 0 0 0 .331 1.023L12 23.054l11.625-8.443a.92.92 0 0 0 .33-1.024" />
</svg>
)
}
export function WalletIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...base(props)} fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M3 7a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v1H5a2 2 0 0 0-2 2V7Z" />
<path d="M3 9a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V9Z" />
<circle cx="16.5" cy="13" r="1.25" fill="currentColor" stroke="none" />
</svg>
)
}
+4 -1
View File
@@ -2,4 +2,7 @@ export { LoginForm } from './LoginForm'
export { SignupForm } from './SignupForm'
export { ForgotForm } from './ForgotForm'
export { OTPForm } from './OTPForm'
export { ProviderButtons } from './ProviderButtons'
export { MfaEnrollForm, type MfaEnrollFormProps } from './MfaEnrollForm'
export { SmsConsentNotice, SMS_CONSENT_TEXT } from './SmsConsent'
export { SocialButtons, type SocialButtonsProps } from './SocialButtons'
export { Divider } from './Divider'
+234
View File
@@ -0,0 +1,234 @@
/**
* Multi-chain wallet login orchestration tests — pure, no network, no wallet
* libs. Run with: pnpm --filter @hanzo/id-auth test
*
* Locks the connect→nonce→sign→verify→redirect contract against
* `hanzoai/iam` controllers/web3_auth.go using a capturing fetch double and a
* fake signer (the injectable `WalletSigner` seam — the real one lazy-loads the
* wallet libs, which this test never touches).
*/
import { test } from 'vitest'
import assert from 'node:assert/strict'
import { createAuthClient } from './client.ts'
import {
loginWithWalletChain,
detectWalletChains,
ENABLED_WALLET_CHAINS,
WALLET_CHAIN_LABELS,
type WalletSigner,
} from './web3.ts'
import type { Chain, LoginChallenge, SignedProof } from '@hanzo/id-connect'
import type { OrgConfig } from '@hanzo/id-shared'
function org(overrides: Partial<OrgConfig> = {}): OrgConfig {
return {
orgId: 'hanzo',
iamUrl: 'https://hanzo.id',
iamIssuer: 'https://hanzo.id',
clientId: 'hanzo-id',
appName: 'hanzo-id',
publicOrigin: 'https://hanzo.id',
oauthCallbackOrigin: 'https://hanzo.id',
brandPackage: '@hanzo/brand',
...overrides,
}
}
const CHALLENGE: LoginChallenge = {
domain: 'hanzo.id',
uri: 'https://hanzo.id',
nonce: 'NONCE1234567890A',
issuedAt: '2026-01-01T00:00:00.000Z',
expirationTime: '2026-01-01T00:10:00.000Z',
version: '1',
}
/**
* Capturing fetch: returns the minted CHALLENGE for the nonce GET, and a canned
* IAM "ok" body for the verify POST (an auth code in `data`, like /v1/iam/login).
* Records every call so the test can assert the exact wire shape.
*/
function capturingFetch(verifyData: unknown = 'AUTHCODE') {
const calls: { url: string; method: string; body: Record<string, unknown> }[] = []
const fetchImpl: typeof fetch = async (input, init) => {
const url = typeof input === 'string' ? input : input.toString()
const method = init?.method ?? 'GET'
let body: Record<string, unknown> = {}
if (init?.body && typeof init.body === 'string') body = JSON.parse(init.body)
calls.push({ url, method, body })
if (url.includes('/v1/iam/web3/nonce')) {
return new Response(JSON.stringify({ status: 'ok', data: CHALLENGE }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}
// verify
return new Response(JSON.stringify({ status: 'ok', data: verifyData }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}
return { calls, fetchImpl }
}
/** Fake signer: records the (chain, challenge) it was handed, returns a proof. */
function fakeSigner() {
const seen: { chain: Chain; challenge: LoginChallenge }[] = []
const proof: SignedProof = {
chain: 'evm',
scheme: 'secp256k1-eip191',
address: '0xabc0000000000000000000000000000000000def',
message: 'rendered CAIP-122 message',
signature: '0xdeadbeef',
}
const sign: WalletSigner = async (chain, challenge) => {
seen.push({ chain, challenge })
return { ...proof, chain }
}
return { seen, sign, proof }
}
test('fetches the nonce for the chosen chain, signs the returned challenge, POSTs the proof', async () => {
const { calls, fetchImpl } = capturingFetch()
const { seen, sign } = fakeSigner()
const client = createAuthClient({ org: org(), fetchImpl })
const res = await loginWithWalletChain(client, 'evm', {}, fetchImpl, sign)
// 1) nonce GET first, scoped to the chain, on the brand's own iamUrl.
assert.equal(calls[0]!.method, 'GET')
assert.match(calls[0]!.url, /^https:\/\/hanzo\.id\/v1\/iam\/web3\/nonce\?chain=evm$/)
// 2) the server challenge was handed to the signer (nonce/domain/uri/times
// intact — the server-minted, single-use values the proof must bind).
assert.equal(seen.length, 1)
assert.equal(seen[0]!.chain, 'evm')
assert.equal(seen[0]!.challenge.nonce, CHALLENGE.nonce)
assert.equal(seen[0]!.challenge.domain, CHALLENGE.domain)
assert.equal(seen[0]!.challenge.uri, CHALLENGE.uri)
assert.equal(seen[0]!.challenge.issuedAt, CHALLENGE.issuedAt)
assert.equal(seen[0]!.challenge.expirationTime, CHALLENGE.expirationTime)
// 3) the proof + routing was POSTed to verify.
const verify = calls[1]!
assert.equal(verify.method, 'POST')
assert.match(verify.url, /\/v1\/iam\/web3\/verify$/)
assert.equal(verify.body.chain, 'evm')
assert.equal(verify.body.scheme, 'secp256k1-eip191')
assert.equal(verify.body.address, '0xabc0000000000000000000000000000000000def')
assert.equal(verify.body.message, 'rendered CAIP-122 message')
assert.equal(verify.body.signature, '0xdeadbeef')
// routing fields the controller needs.
assert.equal(verify.body.application, 'hanzo-id')
assert.equal(verify.body.method, 'login')
assert.equal(verify.body.clientId, 'hanzo-id')
// bare sign-in (no downstream redirectUri) → type=login.
assert.equal(verify.body.type, 'login')
// 4) bare sign-in lands on onboarding — same destination as the password flow.
assert.equal(res.redirectUrl, '/onboarding')
assert.equal(res.error, undefined)
})
test('SSO flow (downstream redirectUri) sends type=code and returns the app redirect with the minted code', async () => {
const { calls, fetchImpl } = capturingFetch('CODE_XYZ')
const { sign } = fakeSigner()
const client = createAuthClient({ org: org(), fetchImpl })
const res = await loginWithWalletChain(
client,
'evm',
{ redirectUri: 'https://console.hanzo.ai/auth/iam/callback', state: 'rp123', clientId: 'hanzo-console' },
fetchImpl,
sign,
)
const verify = calls[1]!
assert.equal(verify.body.type, 'code')
assert.equal(verify.body.redirectUri, 'https://console.hanzo.ai/auth/iam/callback')
assert.equal(verify.body.state, 'rp123')
assert.equal(verify.body.clientId, 'hanzo-console')
assert.equal(
res.redirectUrl,
'https://console.hanzo.ai/auth/iam/callback?code=CODE_XYZ&state=rp123',
)
})
test('disabled chains are not offered and fail closed without any network or signer call', async () => {
// The stub-verifier chains must NOT be in the enabled set...
for (const stub of ['ton', 'xrp', 'bitcoin'] as const) {
assert.equal(ENABLED_WALLET_CHAINS.includes(stub), false, `${stub} must be disabled`)
}
// ...and only the production-verifier chains are.
assert.deepEqual([...ENABLED_WALLET_CHAINS], ['evm', 'solana'])
// every enabled chain has a render label.
for (const c of ENABLED_WALLET_CHAINS) assert.ok(WALLET_CHAIN_LABELS[c])
// Calling a disabled chain returns an error and touches neither fetch nor signer.
const { calls, fetchImpl } = capturingFetch()
let signed = false
const sign: WalletSigner = async () => {
signed = true
throw new Error('signer must not run for a disabled chain')
}
const client = createAuthClient({ org: org(), fetchImpl })
const res = await loginWithWalletChain(client, 'ton', {}, fetchImpl, sign)
assert.match(res.error ?? '', /not enabled/)
assert.equal(calls.length, 0)
assert.equal(signed, false)
})
test('a wallet rejection surfaces as { error } (not a throw), and verify is never called', async () => {
const { calls, fetchImpl } = capturingFetch()
const sign: WalletSigner = async () => {
throw new Error('User rejected the request')
}
const client = createAuthClient({ org: org(), fetchImpl })
const res = await loginWithWalletChain(client, 'evm', {}, fetchImpl, sign)
assert.equal(res.error, 'User rejected the request')
// nonce was fetched (1 call) but verify was NOT (no 2nd call).
assert.equal(calls.length, 1)
assert.match(calls[0]!.url, /\/v1\/iam\/web3\/nonce/)
})
test('detectWalletChains: a single injected wallet resolves to exactly its chain', () => {
// EVM only → [evm]; the UI connects straight, no chooser.
assert.deepEqual(detectWalletChains({ ethereum: {} }), ['evm'])
// Solana only, via any of the recognized injected providers → [solana].
assert.deepEqual(detectWalletChains({ solana: {} }), ['solana'])
assert.deepEqual(detectWalletChains({ solflare: {} }), ['solana'])
assert.deepEqual(detectWalletChains({ backpack: {} }), ['solana'])
})
test('detectWalletChains: both injected → both, in enabled order (chooser)', () => {
assert.deepEqual(detectWalletChains({ ethereum: {}, solana: {} }), ['evm', 'solana'])
})
test('detectWalletChains: nothing injected → [] (chooser, both still reachable)', () => {
// No window (server / node) and an empty window both resolve to none — the UI
// then reveals the chooser so EVM and Solana stay selectable regardless.
assert.deepEqual(detectWalletChains({}), [])
assert.deepEqual(detectWalletChains(undefined), [])
assert.deepEqual(detectWalletChains(), []) // node has no global window
// Every detectable chain is one the wallet flow actually enables.
for (const c of detectWalletChains({ ethereum: {}, solana: {} })) {
assert.ok(ENABLED_WALLET_CHAINS.includes(c))
}
})
test('an IAM verify error is returned as { error }', async () => {
const calls: string[] = []
const fetchImpl: typeof fetch = async (input) => {
const url = typeof input === 'string' ? input : input.toString()
calls.push(url)
if (url.includes('/web3/nonce')) {
return new Response(JSON.stringify({ status: 'ok', data: CHALLENGE }), { status: 200 })
}
return new Response(JSON.stringify({ status: 'error', msg: 'web3: bad signature' }), { status: 200 })
}
const { sign } = fakeSigner()
const client = createAuthClient({ org: org(), fetchImpl })
const res = await loginWithWalletChain(client, 'evm', {}, fetchImpl, sign)
assert.equal(res.error, 'web3: bad signature')
assert.equal(res.redirectUrl, undefined)
})
+245
View File
@@ -0,0 +1,245 @@
/**
* Multi-chain wallet Sign-In-With-X — the ONE client-side orchestration.
*
* Decomplected: connect+sign is the BROWSER's job (native `@hanzo/id-connect`
* connectors — viem / @solana injected wallets, no WalletConnect, no projectId),
* verify is the SERVER's job (IAM `POST /v1/iam/web3/verify`, which runs the same
* `walletconnect.VerifyProof` the connectors target). This module ties the two
* into a single call so the UI only picks a chain and follows the redirect.
*
* client: nonce ─► connect ─► signLogin(challenge) ─► SignedProof
* ─► POST verify ─► IAM signs in ─► same redirect the password flow uses
*
* Wire contract (verified against `hanzoai/iam` controllers/web3_auth.go):
* GET {iamUrl}/v1/iam/web3/nonce?chain=<c>&address=<a>
* → {status:'ok', data:{domain,uri,statement,nonce,issuedAt,
* expirationTime,version}} (a LoginChallenge)
* POST {iamUrl}/v1/iam/web3/verify body = SignedProof + routing fields
* → same success shape as /v1/iam/login (auth code | session cookie).
*/
import type { OrgConfig } from '@hanzo/id-shared'
import type { Chain, LoginChallenge, SignedProof } from '@hanzo/id-connect'
import type { AuthClient } from './client'
import type { LoginResponse } from './types'
/**
* Connect a wallet on `chain` and sign `challenge`, returning the proof. The
* single seam between this orchestrator and the browser wallet libs: the default
* lazy-loads `@hanzo/id-connect/login` (so importing this module never pulls
* viem/sats-connect, and the wallet bundle is code-split until first use); tests
* inject a fake. One signature, one way.
*/
export type WalletSigner = (chain: Chain, challenge: LoginChallenge) => Promise<SignedProof>
const defaultSigner: WalletSigner = async (chain, challenge) => {
const { loginWithWallet } = await import('@hanzo/id-connect/login')
const { proof } = await loginWithWallet({ chain, challenge })
return proof
}
/**
* Chains whose wallet login is ENABLED. The server-side verifiers for EVM and
* Solana are production-grade; TON / XRP / Bitcoin verifiers are still stubs and
* would fail closed, so they are NOT offered. Adding a chain later is one line
* here (once its Go verifier is real). Single source of truth — the UI renders
* exactly this set.
*/
export const ENABLED_WALLET_CHAINS: readonly Chain[] = ['evm', 'solana']
/** Display label per chain, shown on each connect button. */
export const WALLET_CHAIN_LABELS: Record<Chain, string> = {
evm: 'Ethereum / EVM',
solana: 'Solana',
bitcoin: 'Bitcoin',
ton: 'TON',
xrp: 'XRP',
}
/** The `window` fields the injected-wallet sniff reads — kept local so the
* wallet libs stay out of this module (detection is a pure property read). */
export interface WalletWindow {
readonly ethereum?: unknown
readonly solana?: unknown
readonly solflare?: unknown
readonly backpack?: unknown
}
/** Is an injected wallet for `chain` present on `w`? Mirrors the connectors'
* own discovery: EVM = `window.ethereum` (EIP-1193 / EIP-6963 legacy handle),
* Solana = Phantom/Solflare/Backpack injected providers. */
function chainInjected(chain: Chain, w: WalletWindow): boolean {
switch (chain) {
case 'evm':
return Boolean(w.ethereum)
case 'solana':
return Boolean(w.solana || w.solflare || w.backpack)
default:
// A chain with no sniff is never auto-detected; the chooser still offers
// it. Only the ENABLED set is ever consulted, so this stays unreachable.
return false
}
}
/**
* The ENABLED wallet chains that currently have an injected provider. A pure
* `window` sniff — no connect, no I/O — that powers the chain-agnostic "Connect
* Wallet" entry: exactly one match → connect straight; zero or many → let the
* user pick. Derived from {@link ENABLED_WALLET_CHAINS} so there is ONE source
* of truth for what wallet login offers.
*/
export function detectWalletChains(
w: WalletWindow | undefined = typeof window === 'undefined' ? undefined : (window as WalletWindow),
): Chain[] {
if (!w) return []
return ENABLED_WALLET_CHAINS.filter((c) => chainInjected(c, w))
}
/** Routing context for the verify POST — exactly what the password flow carries. */
export interface WalletLoginContext {
/** Override the OAuth client_id (downstream app); defaults to org.clientId. */
readonly clientId?: string
/** Downstream app `redirect_uri`; presence flips the flow to the auth-code (SSO) path. */
readonly redirectUri?: string
readonly state?: string
/** OIDC nonce from the downstream authorize request, echoed into the minted code. */
readonly nonce?: string
readonly codeChallenge?: string
readonly codeChallengeMethod?: 'S256' | 'plain'
}
/**
* Connect a wallet on `chain`, sign the IAM-minted challenge, and verify it —
* resolving to the SAME {@link LoginResponse} the password login returns (so the
* caller reuses one redirect path). Throws only on a programming/transport error
* the UI can't act on; expected failures (user rejects, bad signature) come back
* as `{ error }`.
*
* `client.org.iamUrl` is the fetch base (HIP-0111 host-relative — the brand's
* own `*.id` host), matching every other AuthClient call.
*/
export async function loginWithWalletChain(
client: AuthClient,
chain: Chain,
ctx: WalletLoginContext = {},
fetchImpl: typeof fetch = fetch,
sign: WalletSigner = defaultSigner,
): Promise<LoginResponse> {
const org = client.org
if (!ENABLED_WALLET_CHAINS.includes(chain)) {
return { error: `wallet login not enabled for ${chain}` }
}
// 1. Mint the challenge, then connect+sign atomically (the connector
// disconnects on failure). The nonce is fetched without an address — the
// controller treats (chain,address) as advisory and binds the real address
// from the SIGNED message, so there is no second round-trip to scope it.
let proof: SignedProof
try {
const challenge = await fetchNonce(org, chain, fetchImpl)
proof = await sign(chain, challenge)
} catch (err) {
return { error: errMessage(err) }
}
// 2. Verify the proof + routing at IAM. Type defaults to "login" (session
// cookie) server-side; a downstream redirectUri makes it the code flow.
const url = new URL('/v1/iam/web3/verify', org.iamUrl)
const res = await fetchImpl(url.toString(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
// routing
organization: org.loginOrg ?? '',
application: org.appName,
method: 'login',
clientId: ctx.clientId ?? org.clientId,
redirectUri: ctx.redirectUri ?? '',
state: ctx.state ?? '',
scope: 'openid profile email',
type: ctx.redirectUri ? 'code' : 'login',
nonce: ctx.nonce ?? '',
codeChallenge: ctx.codeChallenge ?? '',
codeChallengeMethod: ctx.codeChallengeMethod ?? '',
// proof
chain: proof.chain,
scheme: proof.scheme,
address: proof.address,
publicKey: proof.publicKey ?? '',
message: proof.message,
signature: proof.signature,
extra: proof.extra ?? {},
}),
})
return parseVerifyResponse(res, ctx)
}
/** GET the CAIP-122 challenge for (chain) from IAM; throws on a non-ok payload. */
async function fetchNonce(
org: OrgConfig,
chain: Chain,
fetchImpl: typeof fetch,
): Promise<LoginChallenge> {
const url = new URL('/v1/iam/web3/nonce', org.iamUrl)
url.searchParams.set('chain', chain)
const res = await fetchImpl(url.toString(), { headers: { Accept: 'application/json' } })
let body: Record<string, unknown> = {}
try {
body = (await res.json()) as Record<string, unknown>
} catch {
throw new Error(`web3 nonce: HTTP ${res.status} non-JSON response`)
}
if (!res.ok || body.status !== 'ok' || typeof body.data !== 'object' || body.data === null) {
throw new Error(typeof body.msg === 'string' ? body.msg : `web3 nonce: HTTP ${res.status}`)
}
const d = body.data as Record<string, unknown>
return {
domain: String(d.domain ?? ''),
uri: String(d.uri ?? ''),
statement: typeof d.statement === 'string' ? d.statement : undefined,
nonce: String(d.nonce ?? ''),
issuedAt: String(d.issuedAt ?? ''),
expirationTime: typeof d.expirationTime === 'string' ? d.expirationTime : undefined,
version: typeof d.version === 'string' ? d.version : '1',
}
}
/**
* Shape the `/v1/iam/web3/verify` response into a {@link LoginResponse}, mirroring
* the password flow's `parseLoginResponse`: auth-code flow → a redirect back to
* the downstream app; bare sign-in → land on onboarding.
*/
async function parseVerifyResponse(
res: Response,
ctx: WalletLoginContext,
): Promise<LoginResponse> {
let body: Record<string, unknown> = {}
try {
body = (await res.json()) as Record<string, unknown>
} catch {
return { error: `HTTP ${res.status} non-JSON response` }
}
if (!res.ok || body.status === 'error') {
return { error: typeof body.msg === 'string' ? body.msg : `HTTP ${res.status}` }
}
const data = body.data
// Auth-code (SSO) flow: a downstream redirectUri is present and `data` is the
// minted code — hand back a fully-formed redirect to the app.
if (ctx.redirectUri && typeof data === 'string' && data.length > 0) {
const sep = ctx.redirectUri.includes('?') ? '&' : '?'
return {
redirectUrl: `${ctx.redirectUri}${sep}code=${encodeURIComponent(data)}&state=${encodeURIComponent(ctx.state ?? '')}`,
}
}
// Bare portal sign-in: the IAM session cookie is set; land on onboarding —
// identical to the password path so there is one post-login destination.
return { redirectUrl: '/onboarding' }
}
function errMessage(err: unknown): string {
if (err instanceof Error) return err.message
return String(err)
}
+2 -1
View File
@@ -4,5 +4,6 @@
"outDir": "dist",
"noEmit": true
},
"include": ["src"]
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}
+99
View File
@@ -0,0 +1,99 @@
# @luxwallet/connect
Multi-chain wallet connect + **Sign-In-With-X** for **EVM, Solana, Bitcoin, TON, XRP**.
One vocabulary, one canonical login message ([CAIP-122](https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-122.md)),
one verifier. **MIT licensed — zero GPL.** This is the clean wallet stack; the
Uniswap-derived GPL bones stay quarantined in `luxfi/exchange`.
## Why
`@luxfi/wallet` (Uniswap "Universe" fork) is GPL-3.0 and EVM-only. This package
is a from-scratch, permissively-licensed connector that any Hanzo/Lux/Zoo/Pars
surface — `hanzo.id` login, the browser extension, web and mobile apps — can use
to authenticate a wallet on **any** supported chain.
## Architecture
```
connect(chain) ─► Account ─► signLogin(challenge) ─► SignedProof ─► verifyProof()
(browser, per-chain connector) (server, one pure fn)
```
- **`caip122.ts`** — render/parse the canonical login message. `build ∘ parse` round-trips.
- **`verify.ts`** — `verifyProof(proof, expected)`: parse → enforce domain/nonce/time → dispatch to the per-chain crypto verifier. Pure, fails closed, never throws.
- **`<chain>/`** — per-chain connector (browser) + verifier (pure crypto).
- **`go/walletconnect`** — Go port of `verifyProof`, imported by Hanzo IAM so the server verifies identically.
## Chain support
| Chain | Connect lib (license) | Login proof | Connector | Verifier |
|-------|-----------------------|-------------|-----------|----------|
| EVM | `viem` (MIT) — EIP-6963 / `window.ethereum` | EIP-191 `personal_sign` | ✅ `evm/connect.ts` | ✅ secp256k1 recover |
| Solana | injected provider (Phantom/Solflare/Backpack) | ed25519 `signMessage` | ✅ `solana/connect.ts` | ✅ ed25519 |
| Bitcoin | `sats-connect` (MIT) — Xverse/Leather/Unisat | BIP-322 | ✅ `bitcoin/connect.ts` | ✅ legacy + BIP-322 |
| TON | `@tonconnect/sdk` (Apache-2.0) | `ton_proof` | ✅ `ton/connect.ts` | ✅ ed25519 envelope |
| XRP | `@crossmarkio/sdk` (MIT) — Crossmark | `signInAndWait` | ✅ `xrp/connect.ts` | ✅ secp256k1 + ed25519 |
All connect libs are MIT/Apache/ISC — **no GPL anywhere** in the dependency tree.
GemWallet is intentionally not wired: its only client, `@gemwallet/api`, ships
under a custom dual license requiring GemWallet's permission for public/commercial
use — incompatible with the MIT/Apache/ISC-only rule. Crossmark covers both XRPL
key types, so the XRP path is complete without it.
### Architecture: server verify never pulls a wallet lib
The wallet libraries are **optional peer dependencies**. The server-side
`verifyProof` path imports only `@noble/*` + `bs58`:
```ts
import { verifyProof } from '@luxwallet/connect/verify'; // zero wallet libs
import { buildSiwxMessage } from '@luxwallet/connect/caip122'; // zero deps
```
Connectors live behind separate entrypoints, so a server bundle stays clean:
```ts
import { loginWithWallet, getConnector } from '@luxwallet/connect/connectors';
import { EvmConnector } from '@luxwallet/connect/evm/connect';
```
## Use
```ts
// Server: mint a challenge
import { newChallenge, verifyProof } from '@luxwallet/connect';
const challenge = newChallenge({ domain: 'hanzo.id', uri: 'https://hanzo.id/login' });
// → store challenge.nonce, send challenge to the client
// Server: verify what comes back
const res = verifyProof(proof, { domain: 'hanzo.id', nonce: challenge.nonce });
if (res.ok) { /* res.address is authenticated on res.chain */ }
```
```ts
// Client (browser): connect a wallet and sign the challenge in one call.
import { loginWithWallet } from '@luxwallet/connect/connectors';
const { account, proof } = await loginWithWallet({ chain: 'evm', challenge });
// → POST `proof` to the server, which calls verifyProof(proof, { domain, nonce }).
// Or drive a connector directly:
import { getConnector } from '@luxwallet/connect/connectors';
const c = getConnector('solana');
const acct = await c.connect(); // provider.connect()
const p = await c.signLogin(acct, challenge); // ed25519 signMessage → SignedProof
```
## Develop
```bash
pnpm install
pnpm test # vitest — crypto verifiers run against generated keypairs
pnpm typecheck
pnpm build
```
## License
MIT © Lux Industries Inc.
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@hanzo/id-connect",
"version": "0.1.0",
"description": "Multi-chain wallet connect + Sign-In-With-X (EVM, Solana, Bitcoin, TON, XRP). Vendored from luxwallet/connect (MIT).",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./verify": "./src/verify.ts",
"./caip122": "./src/caip122.ts",
"./connectors": "./src/connectors.ts",
"./login": "./src/login.ts",
"./evm/connect": "./src/evm/connect.ts",
"./solana/connect": "./src/solana/connect.ts",
"./bitcoin/connect": "./src/bitcoin/connect.ts",
"./ton/connect": "./src/ton/connect.ts",
"./xrp/connect": "./src/xrp/connect.ts",
"./package.json": "./package.json"
},
"scripts": {
"tc": "tsc --noEmit"
},
"dependencies": {
"@noble/curves": "^1.6.0",
"@noble/hashes": "^1.5.0",
"bs58": "^6.0.0"
},
"peerDependencies": {
"@crossmarkio/sdk": "^0.4.0",
"@tonconnect/sdk": "^4.0.0",
"sats-connect": "^4.2.1",
"viem": "^2.53.1"
}
}
+434
View File
@@ -0,0 +1,434 @@
/**
* Bitcoin verifier tests.
*
* Coverage:
* • Legacy "Bitcoin Signed Message" (recoverable ECDSA) over a real CAIP-122
* login message — for P2PKH, P2WPKH and P2TR (BIP-86) addresses.
* • BIP-322 "simple" for P2WPKH (ECDSA / BIP-143) and P2TR key-path
* (Schnorr / BIP-341).
* • Tamper / wrong-address / wrong-type negatives (fail closed).
* • Anchors: the BIP-322 message hash + to_spend txid + P2WPKH derivation
* are pinned to the official Bitcoin Core BIP-322 test vectors, so the
* sighash construction is verified against a known-answer source rather
* than only self-consistently.
*/
import { describe, it, expect } from 'vitest';
import { secp256k1, schnorr } from '@noble/curves/secp256k1';
import { sha256 } from '@noble/hashes/sha2';
import { ripemd160 } from '@noble/hashes/legacy';
import { verifyBitcoin } from '../bitcoin/verify.js';
import { encodeSegwitAddress } from '../bitcoin/bech32.js';
import { base58checkEncode } from '../bitcoin/base58check.js';
import { buildSiwxMessage } from '../caip122.js';
import { newChallenge } from '../nonce.js';
import { utf8ToBytes, concatBytes, bytesToHex } from '../bytes.js';
import type { SignedProof } from '../types.js';
// ── local crypto helpers (independent of the verifier internals) ─────────────
const enc = (s: string) => utf8ToBytes(s);
const sha256d = (b: Uint8Array) => sha256(sha256(b));
const hash160 = (b: Uint8Array) => ripemd160(sha256(b));
function taggedHash(tag: string, ...m: Uint8Array[]): Uint8Array {
const t = sha256(enc(tag));
return sha256(concatBytes(t, t, ...m));
}
function compactSize(n: number): Uint8Array {
if (n < 0xfd) return new Uint8Array([n]);
if (n <= 0xffff) return new Uint8Array([0xfd, n & 0xff, (n >>> 8) & 0xff]);
return new Uint8Array([0xfe, n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, (n >>> 24) & 0xff]);
}
const u32le = (n: number) =>
new Uint8Array([n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, (n >>> 24) & 0xff]);
const u64le = (n: bigint) => {
const o = new Uint8Array(8);
let v = n;
for (let i = 0; i < 8; i++) {
o[i] = Number(v & 0xffn);
v >>= 8n;
}
return o;
};
const varBytes = (b: Uint8Array) => concatBytes(compactSize(b.length), b);
function bytesToBig(b: Uint8Array): bigint {
let v = 0n;
for (const x of b) v = (v << 8n) | BigInt(x);
return v;
}
function bigToXonly(x: bigint): Uint8Array {
const o = new Uint8Array(32);
let v = x;
for (let i = 31; i >= 0; i--) {
o[i] = Number(v & 0xffn);
v >>= 8n;
}
return o;
}
function toBase64(b: Uint8Array): string {
return Buffer.from(b).toString('base64');
}
// Address derivations (must match the verifier's, independently written).
function p2pkh(pubkey: Uint8Array): string {
return base58checkEncode(0x00, hash160(pubkey));
}
function p2wpkh(pubCompressed: Uint8Array): string {
return encodeSegwitAddress('bc', 0, hash160(pubCompressed))!;
}
function taprootTweak(internalXonly: Uint8Array): Uint8Array {
const n = secp256k1.CURVE.n;
const P = schnorr.utils.lift_x(bytesToBig(internalXonly));
const t = bytesToBig(taggedHash('TapTweak', internalXonly)) % n;
const Q = P.add(secp256k1.Point.BASE.multiply(t));
return bigToXonly(Q.toAffine().x);
}
function p2tr(internalXonly: Uint8Array): { address: string; program: Uint8Array } {
const program = taprootTweak(internalXonly);
return { address: encodeSegwitAddress('bc', 1, program)!, program };
}
// ── legacy "Bitcoin Signed Message" signer ───────────────────────────────────
function legacyDigest(message: string): Uint8Array {
const msg = enc(message);
const magic = enc('\x18Bitcoin Signed Message:\n');
return sha256d(concatBytes(magic, compactSize(msg.length), msg));
}
/** Produce a 65-byte [header || r || s] legacy signature. */
function signLegacy(priv: Uint8Array, message: string, compressed: boolean): Uint8Array {
const digest = legacyDigest(message);
const sig = secp256k1.sign(digest, priv);
const recid = sig.recovery!;
const header = 27 + recid + (compressed ? 4 : 0);
return concatBytes(new Uint8Array([header]), sig.toBytes('compact'));
}
// ── BIP-322 simple signers (sign exactly the verifier's sighash) ─────────────
function toSpendTxid(message: string, scriptPubKey: Uint8Array): Uint8Array {
const msgHash = taggedHash('BIP0322-signed-message', enc(message));
const scriptSig = concatBytes(new Uint8Array([0x00, 0x20]), msgHash);
const ser = concatBytes(
u32le(0),
compactSize(1),
new Uint8Array(32),
u32le(0xffffffff),
varBytes(scriptSig),
u32le(0),
compactSize(1),
u64le(0n),
varBytes(scriptPubKey),
u32le(0),
);
return sha256d(ser);
}
function bip143SighashP2WPKH(txid: Uint8Array, h160: Uint8Array): Uint8Array {
const outpoint = concatBytes(txid, u32le(0));
const nSequence = u32le(0);
const hashPrevouts = sha256d(outpoint);
const hashSequence = sha256d(nSequence);
const scriptCode = concatBytes(
new Uint8Array([0x19, 0x76, 0xa9, 0x14]),
h160,
new Uint8Array([0x88, 0xac]),
);
const output = concatBytes(u64le(0n), varBytes(new Uint8Array([0x6a])));
const hashOutputs = sha256d(output);
const preimage = concatBytes(
u32le(0),
hashPrevouts,
hashSequence,
outpoint,
scriptCode,
u64le(0n),
nSequence,
hashOutputs,
u32le(0),
u32le(1),
);
return sha256d(preimage);
}
function bip341SighashP2TR(txid: Uint8Array, scriptPubKey: Uint8Array): Uint8Array {
const outpoint = concatBytes(txid, u32le(0));
const nSequence = u32le(0);
const shaPrevouts = sha256(outpoint);
const shaAmounts = sha256(u64le(0n));
const shaScriptPubkeys = sha256(varBytes(scriptPubKey));
const shaSequences = sha256(nSequence);
const output = concatBytes(u64le(0n), varBytes(new Uint8Array([0x6a])));
const shaOutputs = sha256(output);
const sigMsg = concatBytes(
new Uint8Array([0x00]), // hash_type SIGHASH_DEFAULT
u32le(0),
u32le(0),
shaPrevouts,
shaAmounts,
shaScriptPubkeys,
shaSequences,
shaOutputs,
new Uint8Array([0x00]), // spend_type
u32le(0), // input index
);
return taggedHash('TapSighash', concatBytes(new Uint8Array([0x00]), sigMsg));
}
function serializeWitness(items: Uint8Array[]): Uint8Array {
let out = compactSize(items.length);
for (const it of items) out = concatBytes(out, varBytes(it));
return out;
}
/** BIP-322 simple P2WPKH signature (serialized witness [sig||SIGHASH_ALL, pubkey]). */
function signBip322P2WPKH(priv: Uint8Array, message: string): Uint8Array {
const pub = secp256k1.getPublicKey(priv, true);
const h160 = hash160(pub);
const spk = concatBytes(new Uint8Array([0x00, 0x14]), h160);
const txid = toSpendTxid(message, spk);
const sighash = bip143SighashP2WPKH(txid, h160);
const sig = secp256k1.sign(sighash, priv, { lowS: true });
const der = concatBytes(sig.toBytes('der'), new Uint8Array([0x01])); // SIGHASH_ALL
return serializeWitness([der, pub]);
}
/** BIP-322 simple P2TR key-path signature (serialized witness [schnorr_sig]). */
function signBip322P2TR(priv: Uint8Array, message: string): { sig: Uint8Array; address: string } {
const internalXonly = secp256k1.getPublicKey(priv, true).slice(1);
const { address, program } = p2tr(internalXonly);
const spk = concatBytes(new Uint8Array([0x51, 0x20]), program);
const txid = toSpendTxid(message, spk);
const sighash = bip341SighashP2TR(txid, spk);
// Taproot key-path must sign with the *tweaked* private key.
const n = secp256k1.CURVE.n;
let d = bytesToBig(priv) % n;
// BIP-340: if the internal pubkey has odd Y, negate d.
const Pfull = secp256k1.Point.BASE.multiply(d);
if (Pfull.toAffine().y % 2n === 1n) d = n - d;
const t = bytesToBig(taggedHash('TapTweak', internalXonly)) % n;
const tweaked = (d + t) % n;
const tweakedBytes = bigToXonly(tweaked);
const sig = schnorr.sign(sighash, tweakedBytes);
return { sig: serializeWitness([sig]), address };
}
// ── fixtures ─────────────────────────────────────────────────────────────────
function makeMessage(address: string): string {
const challenge = newChallenge({
domain: 'hanzo.id',
uri: 'https://hanzo.id/login',
statement: 'Sign in to Hanzo',
nonce: 'abc123XYZ789',
now: Date.UTC(2026, 0, 1),
});
return buildSiwxMessage({ challenge, address, chain: 'bitcoin' });
}
// A fixed key so failures are reproducible.
const PRIV = new Uint8Array(32).fill(0);
PRIV[31] = 0x2a; // d = 42
describe('verifyBitcoin — anchors against Bitcoin Core BIP-322 vectors', () => {
it('message_hash + to_spend txid match the official vectors', () => {
const addr = 'bc1q9vza2e8x573nczrlzms0wvx3gsqjx7vavgkx0l';
// Reconstruct that address's witness program from the known WIF private key.
// WIF L3VFe…: 0x80 || priv(32) || 0x01 || checksum(4) → priv extracted below.
const wifPriv = hexToBytesLocal(
'bb051cd0dda0246f33c5a9e133ebd8e7bc02a92af6c41adc131ccd7826c5b004',
);
const pub = secp256k1.getPublicKey(wifPriv, true);
expect(p2wpkh(pub)).toBe(addr); // P2WPKH derivation anchor
expect(bytesToHex(taggedHash('BIP0322-signed-message', enc('')))).toBe(
'c90c269c4f8fcbe6880f72a721ddfbf1914268a794cbb21cfafee13770ae19f1',
);
expect(bytesToHex(taggedHash('BIP0322-signed-message', enc('Hello World')))).toBe(
'f0eb03b1a75ac6d9847f55c624a99169b5dccba2a31f5b23bea77ba270de0a7a',
);
const spk = concatBytes(new Uint8Array([0x00, 0x14]), hash160(pub));
const display = (b: Uint8Array) => bytesToHex(Uint8Array.from([...b].reverse()));
expect(display(toSpendTxid('', spk))).toBe(
'c5680aa69bb8d860bf82d4e9cd3504b55dde018de765a91bb566283c545a99a7',
);
expect(display(toSpendTxid('Hello World', spk))).toBe(
'b79d196740ad5217771c1098fc4a4b51e0535c32236c71f1ea4d61a2d603352b',
);
});
});
function hexToBytesLocal(hex: string): Uint8Array {
const out = new Uint8Array(hex.length / 2);
for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
return out;
}
describe('verifyBitcoin — legacy Bitcoin Signed Message', () => {
const pubC = secp256k1.getPublicKey(PRIV, true);
const pubU = secp256k1.getPublicKey(PRIV, false);
it('P2PKH (compressed) verifies, tamper + wrong address fail', () => {
const address = p2pkh(pubC);
const message = makeMessage(address);
const sig = signLegacy(PRIV, message, true);
const proof: SignedProof = {
chain: 'bitcoin',
scheme: 'bip322',
address,
message,
signature: toBase64(sig),
};
expect(verifyBitcoin(proof)).toBe(true);
// Tamper the signature (flip a byte in r).
const bad = sig.slice();
bad[5] = bad[5]! ^ 0xff;
expect(verifyBitcoin({ ...proof, signature: toBase64(bad) })).toBe(false);
// Tamper the message.
expect(verifyBitcoin({ ...proof, message: message + ' ' })).toBe(false);
// Wrong address (different key's P2PKH).
const other = secp256k1.getPublicKey(hexToBytesLocal('11'.repeat(32)), true);
expect(verifyBitcoin({ ...proof, address: p2pkh(other) })).toBe(false);
});
it('P2PKH (uncompressed) verifies and is key-encoding-bound', () => {
const address = p2pkh(pubU);
const message = makeMessage(address);
const sig = signLegacy(PRIV, message, false);
expect(
verifyBitcoin({ chain: 'bitcoin', scheme: 'bip322', address, message, signature: toBase64(sig) }),
).toBe(true);
// The compressed-key address must NOT verify against an uncompressed-header sig.
const cAddr = p2pkh(pubC);
const cMsg = makeMessage(cAddr);
const uncompSig = signLegacy(PRIV, cMsg, false);
expect(
verifyBitcoin({ chain: 'bitcoin', scheme: 'bip322', address: cAddr, message: cMsg, signature: toBase64(uncompSig) }),
).toBe(false);
});
it('P2WPKH verifies via legacy recoverable sig, rejects uncompressed header', () => {
const address = p2wpkh(pubC);
const message = makeMessage(address);
const sig = signLegacy(PRIV, message, true);
expect(
verifyBitcoin({ chain: 'bitcoin', scheme: 'bip322', address, message, signature: toBase64(sig) }),
).toBe(true);
// Uncompressed header can't back a segwit address → reject.
const uncompSig = signLegacy(PRIV, message, false);
expect(
verifyBitcoin({ chain: 'bitcoin', scheme: 'bip322', address, message, signature: toBase64(uncompSig) }),
).toBe(false);
// A P2WPKH address from a DIFFERENT key must not verify against this sig.
const otherP2wpkh = p2wpkh(secp256k1.getPublicKey(hexToBytesLocal('05'.repeat(32)), true));
expect(
verifyBitcoin({
chain: 'bitcoin',
scheme: 'bip322',
address: otherP2wpkh,
message,
signature: toBase64(sig),
}),
).toBe(false);
});
it('P2TR (BIP-86) verifies via legacy recoverable sig', () => {
const internalXonly = pubC.slice(1);
const { address } = p2tr(internalXonly);
const message = makeMessage(address);
const sig = signLegacy(PRIV, message, true);
expect(
verifyBitcoin({ chain: 'bitcoin', scheme: 'bip322', address, message, signature: toBase64(sig) }),
).toBe(true);
// Tamper → false.
const bad = sig.slice();
bad[40] = bad[40]! ^ 0x01;
expect(
verifyBitcoin({ chain: 'bitcoin', scheme: 'bip322', address, message, signature: toBase64(bad) }),
).toBe(false);
});
});
describe('verifyBitcoin — BIP-322 simple', () => {
it('P2WPKH (BIP-143 / ECDSA) verifies, tamper + wrong address fail', () => {
const pub = secp256k1.getPublicKey(PRIV, true);
const address = p2wpkh(pub);
const message = makeMessage(address);
const sig = signBip322P2WPKH(PRIV, message);
const proof: SignedProof = {
chain: 'bitcoin',
scheme: 'bip322',
address,
message,
signature: toBase64(sig),
extra: { addressType: 'p2wpkh' },
};
expect(verifyBitcoin(proof)).toBe(true);
// Tamper the message → sighash changes → false.
expect(verifyBitcoin({ ...proof, message: message + 'x' })).toBe(false);
// Wrong address → witness pubkey no longer hashes to it → false.
const other = p2wpkh(secp256k1.getPublicKey(hexToBytesLocal('07'.repeat(32)), true));
expect(verifyBitcoin({ ...proof, address: other })).toBe(false);
// Truncated witness → false.
expect(verifyBitcoin({ ...proof, signature: toBase64(sig.slice(0, sig.length - 3)) })).toBe(false);
});
it('P2TR key-path (BIP-341 / Schnorr) verifies, tamper fails', () => {
const message0 = 'placeholder';
const { address } = signBip322P2TR(PRIV, message0); // get the address first
const message = makeMessage(address);
const { sig } = signBip322P2TR(PRIV, message);
const proof: SignedProof = {
chain: 'bitcoin',
scheme: 'bip322',
address,
message,
signature: toBase64(sig),
extra: { addressType: 'p2tr' },
};
expect(verifyBitcoin(proof)).toBe(true);
// Tamper the message → false.
expect(verifyBitcoin({ ...proof, message: message + 'z' })).toBe(false);
// Flip a byte in the schnorr sig → false.
const bad = sig.slice();
bad[bad.length - 1] = bad[bad.length - 1]! ^ 0x01;
expect(verifyBitcoin({ ...proof, signature: toBase64(bad) })).toBe(false);
});
});
describe('verifyBitcoin — malformed input fails closed', () => {
const address = p2wpkh(secp256k1.getPublicKey(PRIV, true));
const message = makeMessage(address);
const base: SignedProof = { chain: 'bitcoin', scheme: 'bip322', address, message, signature: '' };
it('empty signature → false', () => {
expect(verifyBitcoin(base)).toBe(false);
});
it('garbage base64 → false', () => {
expect(verifyBitcoin({ ...base, signature: '!!!notbase64!!!' })).toBe(false);
});
it('unknown address prefix → false', () => {
expect(verifyBitcoin({ ...base, address: '3unsupportedP2SHaddress', signature: 'AQID' })).toBe(false);
});
it('legacy sig with out-of-range header → false', () => {
const sig = new Uint8Array(65);
sig[0] = 99; // invalid header
expect(verifyBitcoin({ ...base, signature: toBase64(sig) })).toBe(false);
});
});
@@ -0,0 +1,71 @@
import { describe, it, expect } from 'vitest';
import { buildSiwxMessage, parseSiwxMessage } from '../caip122.js';
import { newChallenge } from '../nonce.js';
describe('CAIP-122 message', () => {
const now = 1_700_000_000_000; // fixed epoch for determinism
it('build → parse round-trips all fields', () => {
const challenge = newChallenge({
domain: 'hanzo.id',
uri: 'https://hanzo.id/login',
statement: 'Sign in to Hanzo.',
nonce: 'abc12345',
now,
ttlSeconds: 600,
requestId: 'req-1',
resources: ['https://hanzo.ai/api', 'https://hanzo.chat'],
});
const msg = buildSiwxMessage({
challenge,
address: '0x1111111111111111111111111111111111111111',
chain: 'evm',
chainId: 'eip155:1',
});
const p = parseSiwxMessage(msg);
expect(p.domain).toBe('hanzo.id');
expect(p.address).toBe('0x1111111111111111111111111111111111111111');
expect(p.statement).toBe('Sign in to Hanzo.');
expect(p.uri).toBe('https://hanzo.id/login');
expect(p.version).toBe('1');
expect(p.chainId).toBe('eip155:1');
expect(p.nonce).toBe('abc12345');
expect(p.issuedAt).toBe(new Date(now).toISOString());
expect(p.expirationTime).toBe(new Date(now + 600_000).toISOString());
expect(p.requestId).toBe('req-1');
expect(p.resources).toEqual(['https://hanzo.ai/api', 'https://hanzo.chat']);
});
it('renders the chain label on the header line', () => {
const c = newChallenge({ domain: 'hanzo.id', uri: 'https://hanzo.id', nonce: 'nonce123', now });
expect(buildSiwxMessage({ challenge: c, address: 'So1aNa', chain: 'solana' })).toContain(
'wants you to sign in with your Solana account:',
);
expect(buildSiwxMessage({ challenge: c, address: 'bc1q', chain: 'bitcoin' })).toContain(
'with your Bitcoin account:',
);
expect(buildSiwxMessage({ challenge: c, address: 'EQxx', chain: 'ton' })).toContain(
'with your TON account:',
);
expect(buildSiwxMessage({ challenge: c, address: 'rXYZ', chain: 'xrp' })).toContain(
'with your XRP Ledger account:',
);
});
it('omits optional fields when absent', () => {
const c = newChallenge({ domain: 'd', uri: 'https://d', nonce: 'nonce123', now });
const msg = buildSiwxMessage({ challenge: { ...c, expirationTime: undefined }, address: 'a', chain: 'evm' });
expect(msg).not.toContain('Chain ID:');
expect(msg).not.toContain('Request ID:');
expect(msg).not.toContain('Resources:');
});
it('rejects a multi-line statement', () => {
const c = newChallenge({ domain: 'd', uri: 'https://d', nonce: 'nonce123', now, statement: 'a\nb' });
expect(() => buildSiwxMessage({ challenge: c, address: 'a', chain: 'evm' })).toThrow();
});
it('throws on malformed message', () => {
expect(() => parseSiwxMessage('not a siwx message')).toThrow();
});
});
@@ -0,0 +1,265 @@
/**
* Connector tests.
*
* Two things are exercised without a real wallet or browser:
* 1. getConnector(chain) returns a connector whose `.chain` matches.
* 2. EVM + Solana round-trip: a MOCKED injected provider (backed by a real
* keypair) signs the CAIP-122 message via the connector's signLogin, and
* the resulting SignedProof passes the server-side verifyProof.
*
* The other connectors (Bitcoin/TON/XRP) drive third-party SDKs whose wallet
* handshakes cannot be faithfully mocked headlessly; they are covered by their
* verifiers' round-trip tests and need a real wallet to exercise end-to-end.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { secp256k1 } from '@noble/curves/secp256k1';
import { ed25519 } from '@noble/curves/ed25519';
import bs58 from 'bs58';
import { getConnector, allConnectors } from '../connectors.js';
import { EvmConnector } from '../evm/connect.js';
import { SolanaConnector } from '../solana/connect.js';
import { verifyProof } from '../verify.js';
import { newChallenge } from '../nonce.js';
import { CHAINS, type Chain } from '../types.js';
import {
eip191Digest,
addressFromPublicKey,
recoverEvmAddress,
} from '../evm/verify.js';
import { bytesToHex, utf8ToBytes, hexToBytes } from '../bytes.js';
// ── factory ──────────────────────────────────────────────────────────────────
describe('getConnector', () => {
it('returns a connector whose chain matches, for every chain', () => {
for (const chain of CHAINS) {
const c = getConnector(chain);
expect(c.chain).toBe(chain);
}
});
it('binds the right class per chain', () => {
expect(getConnector('evm')).toBeInstanceOf(EvmConnector);
expect(getConnector('solana')).toBeInstanceOf(SolanaConnector);
});
it('allConnectors() yields one connector per chain, in canonical order', () => {
const all = allConnectors();
expect(all.map((c) => c.chain)).toEqual(CHAINS as Chain[]);
});
});
// ── shared window shim ───────────────────────────────────────────────────────
// A bare window without addEventListener/dispatchEvent so the EVM connector's
// EIP-6963 discovery short-circuits to the legacy window.ethereum path (fast,
// deterministic — no 300ms announce wait).
const realWindow = (globalThis as Record<string, unknown>).window;
function setWindow(props: Record<string, unknown>): void {
(globalThis as Record<string, unknown>).window = props;
}
afterEach(() => {
if (realWindow === undefined) {
delete (globalThis as Record<string, unknown>).window;
} else {
(globalThis as Record<string, unknown>).window = realWindow;
}
});
// ── EVM mock provider (EIP-1193, real secp256k1 key) ─────────────────────────
function makeEvmProvider() {
const priv = secp256k1.utils.randomPrivateKey();
const pub = secp256k1.getPublicKey(priv, false);
const address = addressFromPublicKey(pub); // lowercased 0x…
const provider = {
async request({ method, params }: { method: string; params?: unknown[] }): Promise<unknown> {
switch (method) {
case 'eth_requestAccounts':
case 'eth_accounts':
return [address];
case 'eth_chainId':
return '0x1';
case 'personal_sign': {
// viem sends [data, account]; data is the 0x-hex of the UTF-8 message.
const dataHex = params?.[0] as string;
const msgBytes = hexToBytes(dataHex);
const message = new TextDecoder().decode(msgBytes);
const sig = secp256k1.sign(eip191Digest(message), priv);
const full = new Uint8Array(65);
full.set(sig.toCompactRawBytes(), 0);
full[64] = (sig.recovery ?? 0) + 27;
return '0x' + bytesToHex(full);
}
default:
throw new Error(`unexpected method ${method}`);
}
},
};
return { provider, address };
}
describe('EvmConnector round-trip (mocked injected wallet)', () => {
beforeEach(() => {
const { provider } = makeEvmProvider();
setWindow({ ethereum: provider });
});
it('connects, signs the CAIP-122 message, and verifyProof accepts it', async () => {
const c = new EvmConnector();
const account = await c.connect();
expect(account.chain).toBe('evm');
expect(account.address).toMatch(/^0x[0-9a-fA-F]{40}$/);
const now = 1_700_000_000_000;
const challenge = newChallenge({
domain: 'hanzo.id',
uri: 'https://hanzo.id/login',
nonce: 'evmNonce123',
now,
});
const proof = await c.signLogin(account, challenge);
expect(proof.scheme).toBe('secp256k1-eip191');
expect(proof.chain).toBe('evm');
// The signature recovers the connected address (the verifier's core check).
expect(recoverEvmAddress(proof.message, proof.signature)?.toLowerCase()).toBe(
account.address.toLowerCase(),
);
const res = verifyProof(proof, { domain: 'hanzo.id', nonce: 'evmNonce123', now });
expect(res.ok).toBe(true);
expect(res.address?.toLowerCase()).toBe(account.address.toLowerCase());
expect(res.chain).toBe('evm');
});
it('available() discovers the injected wallet via the legacy path', async () => {
const c = new EvmConnector();
const wallets = await c.available();
expect(wallets.length).toBe(1);
expect(wallets[0]?.chain).toBe('evm');
expect(wallets[0]?.installed).toBe(true);
});
});
// ── Solana mock provider (real ed25519 key) ──────────────────────────────────
function makeSolanaProvider() {
const priv = ed25519.utils.randomPrivateKey();
const pub = ed25519.getPublicKey(priv);
const address = bs58.encode(pub);
const publicKey = {
toBytes: () => pub,
toString: () => address,
};
const provider = {
isPhantom: true,
publicKey,
async connect() {
return { publicKey };
},
async signMessage(message: Uint8Array, _encoding?: string) {
return { signature: ed25519.sign(message, priv) };
},
async disconnect() {},
};
return { provider, address };
}
describe('SolanaConnector round-trip (mocked injected wallet)', () => {
let expectedAddress: string;
beforeEach(() => {
const { provider, address } = makeSolanaProvider();
expectedAddress = address;
setWindow({ solana: provider });
});
it('connects, signs the CAIP-122 message, and verifyProof accepts it', async () => {
const c = new SolanaConnector();
const account = await c.connect();
expect(account.chain).toBe('solana');
expect(account.address).toBe(expectedAddress);
const now = 1_700_000_000_000;
const challenge = newChallenge({
domain: 'hanzo.id',
uri: 'https://hanzo.id/login',
nonce: 'solNonce4567',
now,
});
const proof = await c.signLogin(account, challenge);
expect(proof.scheme).toBe('ed25519');
expect(proof.chain).toBe('solana');
expect(proof.address).toBe(expectedAddress);
const res = verifyProof(proof, { domain: 'hanzo.id', nonce: 'solNonce4567', now });
expect(res.ok).toBe(true);
expect(res.address).toBe(expectedAddress);
expect(res.chain).toBe('solana');
});
it('handles the bare-Uint8Array signMessage return shape', async () => {
// Some wallets return the raw signature bytes instead of {signature}.
const priv = ed25519.utils.randomPrivateKey();
const pub = ed25519.getPublicKey(priv);
const address = bs58.encode(pub);
const publicKey = { toBytes: () => pub, toString: () => address };
setWindow({
solana: {
isPhantom: true,
publicKey,
connect: async () => ({ publicKey }),
signMessage: async (m: Uint8Array) => ed25519.sign(m, priv),
},
});
const c = new SolanaConnector();
const account = await c.connect();
const now = 1_700_000_000_000;
const challenge = newChallenge({
domain: 'hanzo.id',
uri: 'https://hanzo.id/login',
nonce: 'solBareSig99',
now,
});
const proof = await c.signLogin(account, challenge);
expect(verifyProof(proof, { domain: 'hanzo.id', nonce: 'solBareSig99', now }).ok).toBe(true);
});
it('rejects a proof whose nonce was tampered after signing', async () => {
const c = new SolanaConnector();
const account = await c.connect();
const now = 1_700_000_000_000;
const challenge = newChallenge({
domain: 'hanzo.id',
uri: 'https://hanzo.id/login',
nonce: 'solGood0001',
now,
});
const proof = await c.signLogin(account, challenge);
// Server expects a different nonce → rejected before crypto.
expect(verifyProof(proof, { domain: 'hanzo.id', nonce: 'solOther0002', now }).reason).toBe(
'nonce-mismatch',
);
});
});
// ── browser-only guard ───────────────────────────────────────────────────────
describe('connectors are browser-only', () => {
it('EVM connect throws without a window', async () => {
delete (globalThis as Record<string, unknown>).window;
await expect(new EvmConnector().connect()).rejects.toThrow(/browser-only/);
});
it('Solana connect throws without a window', async () => {
delete (globalThis as Record<string, unknown>).window;
await expect(new SolanaConnector().connect()).rejects.toThrow(/browser-only/);
});
});
+222
View File
@@ -0,0 +1,222 @@
import { describe, it, expect } from 'vitest';
import { ed25519 } from '@noble/curves/ed25519';
import { sha256 } from '@noble/hashes/sha256';
import { verifyTon } from '../ton/verify.js';
import { buildSiwxMessage } from '../caip122.js';
import { newChallenge } from '../nonce.js';
import { bytesToHex, bytesToBase64, utf8ToBytes, concatBytes } from '../bytes.js';
import type { SignedProof } from '../types.js';
// --- Reproduce the TON Connect ton_proof signing algorithm (the wallet side). ---
// This MUST mirror src/ton/verify.ts byte-for-byte; if they ever drift, the
// round-trip "accepts a valid proof" test fails — which is the whole point.
interface ProofEnvelope {
timestamp: number;
domain: string;
payload: string;
workchain: number;
addressHashHex: string;
}
function tonProofDigest(env: ProofEnvelope): Uint8Array {
const addressHash = hexFix(env.addressHashHex);
const wc = new Uint8Array(4);
new DataView(wc.buffer).setInt32(0, env.workchain, false); // big-endian, signed
const domainBytes = utf8ToBytes(env.domain);
const dlen = new Uint8Array(4);
new DataView(dlen.buffer).setUint32(0, domainBytes.length, true); // little-endian
const ts = new Uint8Array(8);
new DataView(ts.buffer).setBigUint64(0, BigInt(env.timestamp), true); // little-endian
const message = concatBytes(
utf8ToBytes('ton-proof-item-v2/'),
wc,
addressHash,
dlen,
domainBytes,
ts,
utf8ToBytes(env.payload),
);
const fullMsg = concatBytes(Uint8Array.of(0xff, 0xff), utf8ToBytes('ton-connect'), sha256(message));
return sha256(fullMsg);
}
/** Local hex→bytes (no 0x) so the test does not depend on verifier internals. */
function hexFix(hex: string): Uint8Array {
const h = hex.startsWith('0x') ? hex.slice(2) : hex;
const out = new Uint8Array(h.length / 2);
for (let i = 0; i < out.length; i++) out[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16);
return out;
}
/**
* Mint a fresh, self-consistent TON proof: random ed25519 key, a CAIP-122
* message whose Nonce equals the ton_proof payload, and a real signature over
* the reconstructed digest.
*/
function mintProof(overrides?: {
workchain?: number;
domain?: string;
now?: number;
}): { proof: SignedProof; env: ProofEnvelope; priv: Uint8Array; pub: Uint8Array } {
const priv = ed25519.utils.randomPrivateKey();
const pub = ed25519.getPublicKey(priv);
// A TON address-hash (account state-init hash). For the verifier it is just
// 32 opaque bytes; use a deterministic-but-arbitrary value here.
const addressHash = sha256(pub); // 32 bytes
const addressHashHex = bytesToHex(addressHash);
const workchain = overrides?.workchain ?? 0;
const domain = overrides?.domain ?? 'hanzo.id';
const now = overrides?.now ?? 1_700_000_000_000;
const timestamp = Math.floor(now / 1000);
// Server mints the nonce; the connector reuses it as the ton_proof payload.
const challenge = newChallenge({ domain, uri: `https://${domain}/login`, now });
const payload = challenge.nonce;
// The on-chain "address" we use for binding: friendly form would be base64url,
// but for verifier purposes the address must simply equal the SIWx address
// line. Use "<workchain>:<addressHashHex>" (raw TON address form).
const address = `${workchain}:${addressHashHex}`;
const message = buildSiwxMessage({ challenge, address, chain: 'ton' });
const env: ProofEnvelope = { timestamp, domain, payload, workchain, addressHashHex };
const digest = tonProofDigest(env);
const signature = bytesToBase64(ed25519.sign(digest, priv));
const proof: SignedProof = {
chain: 'ton',
scheme: 'ton-proof',
address,
publicKey: bytesToHex(pub),
message,
signature,
extra: { ...env },
};
return { proof, env, priv, pub };
}
describe('TON ton_proof verify', () => {
it('accepts a valid proof (full round-trip)', () => {
const { proof } = mintProof();
expect(verifyTon(proof)).toBe(true);
});
it('accepts a valid proof on the masterchain (workchain = -1)', () => {
// Exercises signed int32BE encoding: -1 must serialize as 0xFFFFFFFF on
// both the signing and verifying sides.
const { proof } = mintProof({ workchain: -1 });
expect(verifyTon(proof)).toBe(true);
});
it('rejects a tampered timestamp', () => {
const { proof } = mintProof();
const bad: SignedProof = {
...proof,
extra: { ...(proof.extra as object), timestamp: (proof.extra as any).timestamp + 1 },
};
expect(verifyTon(bad)).toBe(false);
});
it('rejects a tampered domain', () => {
const { proof } = mintProof();
const bad: SignedProof = {
...proof,
extra: { ...(proof.extra as object), domain: 'evil.com' },
};
expect(verifyTon(bad)).toBe(false);
});
it('rejects a tampered payload (signature no longer matches)', () => {
const { proof } = mintProof();
// Mutate BOTH the SIWx nonce and the envelope payload so the binding check
// passes and we isolate the cryptographic rejection.
const tamperedPayload = (proof.extra as any).payload + 'X';
const bad: SignedProof = {
...proof,
message: proof.message.replace(/Nonce: .*/, `Nonce: ${tamperedPayload}`),
address: proof.address,
extra: { ...(proof.extra as object), payload: tamperedPayload },
};
expect(verifyTon(bad)).toBe(false);
});
it('rejects a wrong public key', () => {
const { proof } = mintProof();
const otherPub = ed25519.getPublicKey(ed25519.utils.randomPrivateKey());
const bad: SignedProof = { ...proof, publicKey: bytesToHex(otherPub) };
expect(verifyTon(bad)).toBe(false);
});
it('rejects a nonce/payload mismatch (binding failure, before crypto)', () => {
const { proof } = mintProof();
// Envelope payload no longer equals the SIWx Nonce → binding rejects it
// even though the (still-valid-for-old-payload) signature is untouched.
const bad: SignedProof = {
...proof,
extra: { ...(proof.extra as object), payload: 'a-different-nonce' },
};
expect(verifyTon(bad)).toBe(false);
});
it('rejects an address that does not match the SIWx message', () => {
const { proof } = mintProof();
const bad: SignedProof = { ...proof, address: '0:deadbeef' };
expect(verifyTon(bad)).toBe(false);
});
it('rejects a malformed signature (wrong length)', () => {
const { proof } = mintProof();
const bad: SignedProof = { ...proof, signature: bytesToBase64(new Uint8Array(63)) };
expect(verifyTon(bad)).toBe(false);
});
it('rejects a malformed public key (wrong length)', () => {
const { proof } = mintProof();
const bad: SignedProof = { ...proof, publicKey: bytesToHex(new Uint8Array(31)) };
expect(verifyTon(bad)).toBe(false);
});
it('rejects a malformed address hash (wrong length)', () => {
const { proof } = mintProof();
const bad: SignedProof = {
...proof,
extra: { ...(proof.extra as object), addressHashHex: 'dead' },
};
expect(verifyTon(bad)).toBe(false);
});
it('fails closed on a missing envelope', () => {
const { proof } = mintProof();
const bad: SignedProof = { ...proof, extra: undefined };
expect(verifyTon(bad)).toBe(false);
});
it('fails closed on a missing public key', () => {
const { proof } = mintProof();
const bad = { ...proof, publicKey: undefined } as SignedProof;
expect(verifyTon(bad)).toBe(false);
});
it('does not throw on garbage input', () => {
const garbage = {
chain: 'ton',
scheme: 'ton-proof',
address: 'x',
publicKey: 'nothex',
message: 'not a siwx message',
signature: '!!!!',
extra: { timestamp: 'soon', domain: 1, payload: null, workchain: 0.5, addressHashHex: 7 },
} as unknown as SignedProof;
expect(() => verifyTon(garbage)).not.toThrow();
expect(verifyTon(garbage)).toBe(false);
});
});
+140
View File
@@ -0,0 +1,140 @@
import { describe, it, expect } from 'vitest';
import { secp256k1 } from '@noble/curves/secp256k1';
import { ed25519 } from '@noble/curves/ed25519';
import bs58 from 'bs58';
import { verifyEvm, eip191Digest, addressFromPublicKey } from '../evm/verify.js';
import { verifySolana } from '../solana/verify.js';
import { verifyProof } from '../verify.js';
import { buildSiwxMessage } from '../caip122.js';
import { newChallenge } from '../nonce.js';
import { bytesToHex, bytesToBase64, utf8ToBytes } from '../bytes.js';
import type { SignedProof } from '../types.js';
// --- test signers (produce real signatures the verifier must accept) ---
function evmSign(message: string) {
const priv = secp256k1.utils.randomPrivateKey();
const pub = secp256k1.getPublicKey(priv, false);
const address = addressFromPublicKey(pub);
const sig = secp256k1.sign(eip191Digest(message), priv);
const full = new Uint8Array(65);
full.set(sig.toCompactRawBytes(), 0);
full[64] = (sig.recovery ?? 0) + 27;
return { address, signature: '0x' + bytesToHex(full) };
}
function solanaSign(message: string) {
const priv = ed25519.utils.randomPrivateKey();
const pub = ed25519.getPublicKey(priv);
const address = bs58.encode(pub);
const signature = bytesToBase64(ed25519.sign(utf8ToBytes(message), priv));
return { address, signature };
}
describe('EVM EIP-191 verify', () => {
it('accepts a valid signature', () => {
const message = 'hello hanzo';
const { address, signature } = evmSign(message);
expect(verifyEvm(message, signature, address)).toBe(true);
});
it('is case-insensitive on the address', () => {
const message = 'hello';
const { address, signature } = evmSign(message);
expect(verifyEvm(message, signature, address.toUpperCase().replace('0X', '0x'))).toBe(true);
});
it('rejects a tampered message', () => {
const { address, signature } = evmSign('original');
expect(verifyEvm('tampered', signature, address)).toBe(false);
});
it('rejects a wrong address', () => {
const { signature } = evmSign('m');
expect(verifyEvm('m', signature, '0x0000000000000000000000000000000000000000')).toBe(false);
});
});
describe('Solana ed25519 verify', () => {
it('accepts a valid signature', () => {
const message = 'hello solana';
const { address, signature } = solanaSign(message);
expect(verifySolana(message, signature, address)).toBe(true);
});
it('rejects a tampered message', () => {
const { address, signature } = solanaSign('original');
expect(verifySolana('tampered', signature, address)).toBe(false);
});
it('rejects a wrong address', () => {
const { signature } = solanaSign('m');
const other = bs58.encode(ed25519.getPublicKey(ed25519.utils.randomPrivateKey()));
expect(verifySolana('m', signature, other)).toBe(false);
});
});
describe('verifyProof end-to-end', () => {
const now = 1_700_000_000_000;
const base = { domain: 'hanzo.id', uri: 'https://hanzo.id/login', nonce: 'abc12345', now };
it('accepts a fresh EVM proof', () => {
const challenge = newChallenge(base);
// EVM: derive the address from the key, embed it in the message, then sign.
const priv = secp256k1.utils.randomPrivateKey();
const address = addressFromPublicKey(secp256k1.getPublicKey(priv, false));
const message = buildSiwxMessage({ challenge, address, chain: 'evm' });
const sig = secp256k1.sign(eip191Digest(message), priv);
const full = new Uint8Array(65);
full.set(sig.toCompactRawBytes(), 0);
full[64] = (sig.recovery ?? 0) + 27;
const proof: SignedProof = {
chain: 'evm', scheme: 'secp256k1-eip191', address, message,
signature: '0x' + bytesToHex(full),
};
const res = verifyProof(proof, { domain: 'hanzo.id', nonce: 'abc12345', now });
expect(res.ok).toBe(true);
expect(res.address).toBe(address);
});
it('accepts a fresh Solana proof', () => {
const challenge = newChallenge(base);
// address is the pubkey; sign the message that embeds that address
const priv = ed25519.utils.randomPrivateKey();
const address = bs58.encode(ed25519.getPublicKey(priv));
const message = buildSiwxMessage({ challenge, address, chain: 'solana' });
const signature = bytesToBase64(ed25519.sign(utf8ToBytes(message), priv));
const proof: SignedProof = { chain: 'solana', scheme: 'ed25519', address, message, signature };
expect(verifyProof(proof, { domain: 'hanzo.id', nonce: 'abc12345', now }).ok).toBe(true);
});
it('rejects wrong nonce / domain', () => {
const challenge = newChallenge(base);
const address = bs58.encode(ed25519.getPublicKey(ed25519.utils.randomPrivateKey()));
const message = buildSiwxMessage({ challenge, address, chain: 'solana' });
const proof: SignedProof = { chain: 'solana', scheme: 'ed25519', address, message, signature: 'AAAA' };
expect(verifyProof(proof, { domain: 'hanzo.id', nonce: 'WRONG', now }).reason).toBe('nonce-mismatch');
expect(verifyProof(proof, { domain: 'evil.com', nonce: 'abc12345', now }).reason).toBe('domain-mismatch');
});
it('rejects an expired proof', () => {
const challenge = newChallenge({ ...base, ttlSeconds: 60 });
const priv = ed25519.utils.randomPrivateKey();
const address = bs58.encode(ed25519.getPublicKey(priv));
const message = buildSiwxMessage({ challenge, address, chain: 'solana' });
const signature = bytesToBase64(ed25519.sign(utf8ToBytes(message), priv));
const proof: SignedProof = { chain: 'solana', scheme: 'ed25519', address, message, signature };
// now is 1h after issuance, well past 60s ttl + skew
const res = verifyProof(proof, { domain: 'hanzo.id', nonce: 'abc12345', now: now + 3_600_000 });
expect(res.reason).toBe('expired');
});
it('fails closed on an unknown scheme', () => {
const challenge = newChallenge(base);
const message = buildSiwxMessage({ challenge, address: 'rXYZ', chain: 'xrp' });
const proof = { chain: 'xrp', scheme: 'totally-unknown', address: 'rXYZ', message, signature: '00' } as unknown as SignedProof;
expect(verifyProof(proof, { domain: 'hanzo.id', nonce: 'abc12345', now }).reason).toBe('unsupported-scheme');
});
it('fails closed (bad-signature) on a wired-but-unverifiable proof', () => {
const challenge = newChallenge(base);
const message = buildSiwxMessage({ challenge, address: 'rXYZ', chain: 'xrp' });
const proof: SignedProof = { chain: 'xrp', scheme: 'secp256k1-xrpl', address: 'rXYZ', message, signature: '00' };
expect(verifyProof(proof, { domain: 'hanzo.id', nonce: 'abc12345', now }).ok).toBe(false);
});
});
+231
View File
@@ -0,0 +1,231 @@
import { describe, it, expect } from 'vitest';
import { secp256k1 } from '@noble/curves/secp256k1';
import { ed25519 } from '@noble/curves/ed25519';
import { sha256 } from '@noble/hashes/sha256';
import { sha512 } from '@noble/hashes/sha512';
import { ripemd160 } from '@noble/hashes/ripemd160';
import { verifyXrp } from '../xrp/verify.js';
import { buildSiwxMessage } from '../caip122.js';
import { newChallenge } from '../nonce.js';
import { bytesToHex, concatBytes, utf8ToBytes } from '../bytes.js';
import type { SignedProof } from '../types.js';
// --- Reproduce the XRPL signing + r-address derivation (the wallet side). ---
// This is an INDEPENDENT implementation of the same spec the verifier uses; if
// the two ever drift, the round-trip "accepts a valid proof" test fails. That
// is the whole point of mirroring rather than importing verifier internals.
const XRPL_ALPHABET = 'rpshnaf39wBUDNEGHJKLM4PQRST7VWXYZ2bcdeCg65jkm8oFqi1tuvAxyz';
/** Independent XRPL base58check (account/version-prefixed payload in). */
function base58CheckXrpl(payload: Uint8Array): string {
const checksum = sha256(sha256(payload)).slice(0, 4);
const full = concatBytes(payload, checksum);
let acc = 0n;
for (const b of full) acc = (acc << 8n) | BigInt(b);
let out = '';
while (acc > 0n) {
out = XRPL_ALPHABET[Number(acc % 58n)] + out;
acc /= 58n;
}
for (let i = 0; i < full.length && full[i] === 0; i++) out = XRPL_ALPHABET[0] + out;
return out;
}
/** r-address from a full 33-byte XRPL public key. */
function rAddressFromPubkey(pubkey33: Uint8Array): string {
const accountId = ripemd160(sha256(pubkey33));
return base58CheckXrpl(concatBytes(Uint8Array.of(0x00), accountId));
}
function sha512Half(d: Uint8Array): Uint8Array {
return sha512(d).slice(0, 32);
}
interface Minted {
proof: SignedProof;
address: string;
}
/** Mint a self-consistent ed25519-xrpl proof: key → r-address → SIWx → raw sig. */
function mintEd25519(now = 1_700_000_000_000): Minted {
const seed = ed25519.utils.randomPrivateKey();
const raw32 = ed25519.getPublicKey(seed);
// XRPL ed25519 public key = 0xED || 32-byte Edwards key.
const pubkey33 = concatBytes(Uint8Array.of(0xed), raw32);
const address = rAddressFromPubkey(pubkey33);
const challenge = newChallenge({ domain: 'hanzo.id', uri: 'https://hanzo.id/login', now });
const message = buildSiwxMessage({ challenge, address, chain: 'xrp' });
// ed25519-xrpl signs the raw UTF-8 message bytes.
const sig = ed25519.sign(utf8ToBytes(message), seed);
return {
address,
proof: {
chain: 'xrp',
scheme: 'ed25519-xrpl',
address,
publicKey: bytesToHex(pubkey33),
message,
signature: bytesToHex(sig),
},
};
}
/** Mint a self-consistent secp256k1-xrpl proof: key → r-address → SIWx → DER sig. */
function mintSecp256k1(now = 1_700_000_000_000): Minted {
// Reject keys whose compressed form is not the usual length (defensive).
const priv = secp256k1.utils.randomPrivateKey();
const pubkey33 = secp256k1.getPublicKey(priv, true); // compressed: 0x02/0x03 || 32
const address = rAddressFromPubkey(pubkey33);
const challenge = newChallenge({ domain: 'hanzo.id', uri: 'https://hanzo.id/login', now });
const message = buildSiwxMessage({ challenge, address, chain: 'xrp' });
// secp256k1-xrpl signs the sha512half of the message, DER-encoded.
const digest = sha512Half(utf8ToBytes(message));
const sig = secp256k1.sign(digest, priv, { prehash: false, lowS: true });
const der = bytesToHex(sig.toBytes('der'));
return {
address,
proof: {
chain: 'xrp',
scheme: 'secp256k1-xrpl',
address,
publicKey: bytesToHex(pubkey33),
message,
signature: der,
},
};
}
describe('XRPL base58check (known-answer vector)', () => {
it('encodes the canonical xrpl.org AccountID example', () => {
// From https://xrpl.org/addresses.html (Address Encoding worked example):
// AccountID = BA8E78626EE42C41B46D46C3048DF3A1C3C87072
// r-address = rJrRMgiRgrU6hDF4pgu5DXQdWyPbY35ErN
const accountId = Uint8Array.from(
'BA8E78626EE42C41B46D46C3048DF3A1C3C87072'.match(/../g)!.map((h) => parseInt(h, 16)),
);
const addr = base58CheckXrpl(concatBytes(Uint8Array.of(0x00), accountId));
expect(addr).toBe('rJrRMgiRgrU6hDF4pgu5DXQdWyPbY35ErN');
});
});
describe('verifyXrp — ed25519-xrpl', () => {
it('accepts a valid proof (full round-trip)', () => {
const { proof } = mintEd25519();
expect(verifyXrp(proof)).toBe(true);
});
it('rejects a tampered message (signature no longer matches)', () => {
const { proof } = mintEd25519();
const bad: SignedProof = { ...proof, message: proof.message + ' ' };
expect(verifyXrp(bad)).toBe(false);
});
it('rejects a flipped signature bit', () => {
const { proof } = mintEd25519();
const sig = Uint8Array.from(proof.signature.match(/../g)!.map((h) => parseInt(h, 16)));
sig[0] = (sig[0]! ^ 0x01) & 0xff;
expect(verifyXrp({ ...proof, signature: bytesToHex(sig) })).toBe(false);
});
it('rejects a wrong address (binding failure, key/sig still valid)', () => {
const { proof } = mintEd25519();
const other = mintEd25519();
expect(verifyXrp({ ...proof, address: other.address })).toBe(false);
});
it('rejects a mismatched public key', () => {
const { proof } = mintEd25519();
const other = mintEd25519();
// Valid 0xED-prefixed key but not the signer → sig verify fails.
expect(verifyXrp({ ...proof, publicKey: other.proof.publicKey })).toBe(false);
});
it('rejects a missing 0xED prefix on the public key', () => {
const { proof } = mintEd25519();
const bytes = Uint8Array.from(proof.publicKey!.match(/../g)!.map((h) => parseInt(h, 16)));
bytes[0] = 0xee; // wrong family tag
expect(verifyXrp({ ...proof, publicKey: bytesToHex(bytes) })).toBe(false);
});
it('fails closed on a missing public key', () => {
const { proof } = mintEd25519();
expect(verifyXrp({ ...proof, publicKey: undefined })).toBe(false);
});
});
describe('verifyXrp — secp256k1-xrpl', () => {
it('accepts a valid proof (full round-trip)', () => {
const { proof } = mintSecp256k1();
expect(verifyXrp(proof)).toBe(true);
});
it('rejects a tampered message (signature no longer matches)', () => {
const { proof } = mintSecp256k1();
const bad: SignedProof = { ...proof, message: proof.message + ' ' };
expect(verifyXrp(bad)).toBe(false);
});
it('rejects a corrupted DER signature', () => {
const { proof } = mintSecp256k1();
const der = Uint8Array.from(proof.signature.match(/../g)!.map((h) => parseInt(h, 16)));
const last = der.length - 1;
der[last] = (der[last]! ^ 0x01) & 0xff; // mangle last byte of s
expect(verifyXrp({ ...proof, signature: bytesToHex(der) })).toBe(false);
});
it('rejects a wrong address (binding failure, key/sig still valid)', () => {
const { proof } = mintSecp256k1();
const other = mintSecp256k1();
expect(verifyXrp({ ...proof, address: other.address })).toBe(false);
});
it('rejects a mismatched public key', () => {
const { proof } = mintSecp256k1();
const other = mintSecp256k1();
expect(verifyXrp({ ...proof, publicKey: other.proof.publicKey })).toBe(false);
});
it('rejects an ed25519-tagged key under the secp256k1 scheme', () => {
const { proof } = mintSecp256k1();
const bytes = Uint8Array.from(proof.publicKey!.match(/../g)!.map((h) => parseInt(h, 16)));
bytes[0] = 0xed; // not a valid compressed-point tag
expect(verifyXrp({ ...proof, publicKey: bytesToHex(bytes) })).toBe(false);
});
it('fails closed on a missing public key', () => {
const { proof } = mintSecp256k1();
expect(verifyXrp({ ...proof, publicKey: undefined })).toBe(false);
});
});
describe('verifyXrp — fail-closed hardening', () => {
it('rejects an unknown scheme via this verifier', () => {
const { proof } = mintEd25519();
expect(verifyXrp({ ...proof, scheme: 'ed25519' as SignedProof['scheme'] })).toBe(false);
});
it('does not throw on garbage input', () => {
const garbage = {
chain: 'xrp',
scheme: 'ed25519-xrpl',
address: 'x',
publicKey: 'nothex',
message: 'not a siwx message',
signature: '!!!!',
} as unknown as SignedProof;
expect(() => verifyXrp(garbage)).not.toThrow();
expect(verifyXrp(garbage)).toBe(false);
});
it('rejects a wrong-length public key', () => {
const { proof } = mintEd25519();
expect(verifyXrp({ ...proof, publicKey: bytesToHex(new Uint8Array(31)) })).toBe(false);
});
});
+43
View File
@@ -0,0 +1,43 @@
/**
* Base58Check (Bitcoin alphabet) — inline, no deps. Encode-only: we build a
* P2PKH address from a recovered pubkey-hash and compare it byte-for-byte
* against `proof.address`. Checksum = first 4 bytes of sha256d(payload).
*/
import { sha256 } from '@noble/hashes/sha2';
import { concatBytes } from '../bytes.js';
const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
/** Plain base58 (big-endian) encode. */
function base58encode(bytes: Uint8Array): string {
// Count leading zero bytes → leading '1's.
let zeros = 0;
while (zeros < bytes.length && bytes[zeros] === 0) zeros++;
// Convert base-256 → base-58 via repeated division on a digit buffer.
const digits: number[] = [0];
for (let i = zeros; i < bytes.length; i++) {
let carry = bytes[i]!;
for (let j = 0; j < digits.length; j++) {
carry += digits[j]! << 8;
digits[j] = carry % 58;
carry = (carry / 58) | 0;
}
while (carry > 0) {
digits.push(carry % 58);
carry = (carry / 58) | 0;
}
}
let out = '';
for (let i = 0; i < zeros; i++) out += '1';
for (let i = digits.length - 1; i >= 0; i--) out += ALPHABET[digits[i]!];
return out;
}
/** version-byte || payload, append sha256d checksum, base58-encode. */
export function base58checkEncode(version: number, payload: Uint8Array): string {
const data = concatBytes(new Uint8Array([version & 0xff]), payload);
const checksum = sha256(sha256(data)).slice(0, 4);
return base58encode(concatBytes(data, checksum));
}
+91
View File
@@ -0,0 +1,91 @@
/**
* bech32 (BIP-173) and bech32m (BIP-350) segwit address encoding — inline, no
* deps. Encode-only: we derive an address from a recovered pubkey and compare
* it against the claimed `proof.address` string, so we never need to decode.
*
* A SegWit v0 address (P2WPKH) uses the bech32 constant; v1+ (P2TR) uses
* bech32m. The two differ only in the final XOR constant of the checksum —
* the source of the 2017-era "bech32 is malleable for v1" fix.
*/
const CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
const BECH32_CONST = 1;
const BECH32M_CONST = 0x2bc830a3;
function polymod(values: number[]): number {
const GEN = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3];
let chk = 1;
for (const v of values) {
const top = chk >>> 25;
chk = ((chk & 0x1ffffff) << 5) ^ v;
for (let i = 0; i < 5; i++) {
if ((top >>> i) & 1) chk ^= GEN[i]!;
}
}
return chk >>> 0;
}
function hrpExpand(hrp: string): number[] {
const out: number[] = [];
for (let i = 0; i < hrp.length; i++) out.push(hrp.charCodeAt(i) >>> 5);
out.push(0);
for (let i = 0; i < hrp.length; i++) out.push(hrp.charCodeAt(i) & 31);
return out;
}
/** Convert a byte array (8-bit) to 5-bit groups (frombits=8, tobits=5, pad=true). */
function convert8to5(data: Uint8Array): number[] | null {
let acc = 0;
let bits = 0;
const out: number[] = [];
const maxv = 31;
for (const value of data) {
if (value < 0 || value >> 8 !== 0) return null;
acc = ((acc << 8) | value) & 0xffffffff;
bits += 8;
while (bits >= 5) {
bits -= 5;
out.push((acc >>> bits) & maxv);
}
}
if (bits > 0) out.push((acc << (5 - bits)) & maxv);
return out;
}
function createChecksum(hrp: string, data5: number[], constant: number): number[] {
const values = hrpExpand(hrp).concat(data5);
const mod = polymod(values.concat([0, 0, 0, 0, 0, 0])) ^ constant;
const out: number[] = [];
for (let i = 0; i < 6; i++) out.push((mod >>> (5 * (5 - i))) & 31);
return out;
}
/**
* Encode a SegWit address. witver 0 → bech32 (P2WPKH); witver 1 → bech32m
* (P2TR). Returns null on any invalid input (fail closed; never throws).
*/
export function encodeSegwitAddress(
hrp: string,
witver: number,
program: Uint8Array,
): string | null {
if (witver < 0 || witver > 16) return null;
// BIP-141 program length bounds: 2..40 bytes; v0 must be 20 or 32.
if (program.length < 2 || program.length > 40) return null;
if (witver === 0 && program.length !== 20 && program.length !== 32) return null;
const data5 = convert8to5(program);
if (data5 === null) return null;
const payload = [witver, ...data5];
const constant = witver === 0 ? BECH32_CONST : BECH32M_CONST;
const checksum = createChecksum(hrp, payload, constant);
const combined = payload.concat(checksum);
let out = hrp + '1';
for (const d of combined) {
if (d < 0 || d > 31) return null;
out += CHARSET[d];
}
return out;
}
+152
View File
@@ -0,0 +1,152 @@
/**
* Bitcoin wallet connector — message signing via `sats-connect` (Xverse,
* Leather, Unisat, and any wallet implementing the sats-connect provider RPC).
*
* Connect requests the wallet's addresses and prefers a P2WPKH ('bc1q…')
* payment address (the broadest-compatibility key-path login form). Signing
* uses `signMessage` with the BIP-322 protocol, which returns a base64
* signature — a serialized witness stack for segwit/taproot addresses, or a
* recoverable ECDSA sig for legacy. {@link verifyBitcoin} dispatches on that
* shape, so the proof here carries the address *type* in `extra.addressType`
* and scheme `bip322`.
*/
import Wallet, {
AddressPurpose,
MessageSigningProtocols,
type Address,
} from 'sats-connect';
import type {
Account,
LoginChallenge,
SignedProof,
WalletConnector,
WalletInfo,
} from '../types.js';
import { buildSiwxMessage } from '../caip122.js';
/** sats-connect addressType → the hint string {@link verifyBitcoin} expects. */
type BtcAddressTypeHint = 'p2pkh' | 'p2wpkh' | 'p2tr';
function toAddressTypeHint(addressType: string): BtcAddressTypeHint | null {
if (addressType === 'p2pkh' || addressType === 'p2wpkh' || addressType === 'p2tr') {
return addressType;
}
return null;
}
/**
* Choose the address to sign with. Order of preference:
* 1. P2WPKH payment ('bc1q…') — widest wallet + verifier support
* 2. P2TR payment/ordinals — taproot key-path
* 3. P2PKH — legacy
* Returns the chosen entry plus its verifier address-type hint.
*/
function chooseAddress(addresses: readonly Address[]): { addr: Address; hint: BtcAddressTypeHint } | null {
const ranked: BtcAddressTypeHint[] = ['p2wpkh', 'p2tr', 'p2pkh'];
for (const want of ranked) {
const found = addresses.find((a) => toAddressTypeHint(a.addressType) === want);
if (found) return { addr: found, hint: want };
}
return null;
}
export class BitcoinConnector implements WalletConnector {
readonly chain = 'bitcoin' as const;
#address: string | null = null;
#addressType: BtcAddressTypeHint | null = null;
#walletId = 'sats-connect';
/**
* sats-connect resolves the concrete wallet at request time (it shows its own
* provider picker), so discovery here advertises the aggregate provider.
*/
async available(): Promise<WalletInfo[]> {
if (typeof window === 'undefined') return [];
return [
{
id: 'sats-connect',
name: 'Bitcoin Wallet (Xverse / Leather / Unisat)',
chain: this.chain,
installed: true,
},
];
}
/**
* Connect and pick a signing address. `walletId` is forwarded to sats-connect
* as the provider id when given; otherwise its built-in picker is used.
*/
async connect(walletId?: string): Promise<Account> {
if (typeof window === 'undefined') {
throw new Error('bitcoin: no window — connectors are browser-only');
}
if (walletId != null) this.#walletId = walletId;
const res = await Wallet.request('getAddresses', {
purposes: [AddressPurpose.Payment, AddressPurpose.Ordinals],
message: 'Connect to sign in',
});
if (res.status !== 'success') {
throw new Error(`bitcoin: getAddresses failed (${res.error?.message ?? 'rejected'})`);
}
const chosen = chooseAddress(res.result.addresses);
if (!chosen) throw new Error('bitcoin: wallet returned no usable address');
this.#address = chosen.addr.address;
this.#addressType = chosen.hint;
return {
chain: this.chain,
address: chosen.addr.address,
publicKey: chosen.addr.publicKey,
walletId: this.#walletId,
};
}
/**
* Render the CAIP-122 message and have the wallet sign it (BIP-322).
* Produces a `bip322` proof carrying `extra.addressType` so the verifier
* derives the correct address form.
*/
async signLogin(account: Account, challenge: LoginChallenge): Promise<SignedProof> {
if (!this.#address || !this.#addressType) {
throw new Error('bitcoin: not connected — call connect() first');
}
const message = buildSiwxMessage({
challenge,
address: account.address,
chain: this.chain,
});
const res = await Wallet.request('signMessage', {
address: account.address,
message,
protocol: MessageSigningProtocols.BIP322,
});
if (res.status !== 'success') {
throw new Error(`bitcoin: signMessage failed (${res.error?.message ?? 'rejected'})`);
}
return {
chain: this.chain,
scheme: 'bip322',
address: account.address,
message,
signature: res.result.signature, // base64
extra: { addressType: this.#addressType },
};
}
async disconnect(): Promise<void> {
try {
await Wallet.disconnect();
} catch {
// sats-connect throws if no session; ignore on teardown.
} finally {
this.#address = null;
this.#addressType = null;
}
}
}
+555
View File
@@ -0,0 +1,555 @@
/**
* Bitcoin login-signature verifier.
*
* Two signing conventions are supported, both verifying a CAIP-122 message
* against a BTC address (P2PKH '1…', P2WPKH 'bc1q…', P2TR 'bc1p…'):
*
* 1. Legacy "Bitcoin Signed Message" — recoverable ECDSA over the
* double-SHA256 of the magic-prefixed message. Primary path; works for
* every address type (the wallet picks the key form via the header byte).
*
* 2. BIP-322 "simple" — a virtual to_spend/to_sign transaction pair whose
* witness is verified with BIP-143 (P2WPKH, ECDSA) or BIP-341 (P2TR,
* Schnorr) sighash. Used when the signature is a serialized witness stack
* rather than a 65-byte recoverable sig.
*
* Security posture: fail closed. Every parse/branch returns `false` on the
* slightest irregularity and the whole function is wrapped so it never throws.
* The recovered/derived address must match the *type* claimed by the proof and
* be byte-for-byte equal to `proof.address`.
*/
import { secp256k1, schnorr } from '@noble/curves/secp256k1';
import { sha256 } from '@noble/hashes/sha2';
import { ripemd160 } from '@noble/hashes/legacy';
import type { SignedProof } from '../types.js';
import { base64ToBytes, utf8ToBytes, concatBytes } from '../bytes.js';
import { encodeSegwitAddress } from './bech32.js';
import { base58checkEncode } from './base58check.js';
// ── address type ──────────────────────────────────────────────────────────
type BtcAddressType = 'p2pkh' | 'p2wpkh' | 'p2tr';
/** Bitcoin mainnet bech32 human-readable part. */
const HRP = 'bc';
/** Determine the address type from an explicit hint, else from the prefix. */
function addressType(proof: SignedProof): BtcAddressType | null {
const hint = (proof.extra?.addressType as string | undefined)?.toLowerCase();
if (hint === 'p2pkh' || hint === 'p2wpkh' || hint === 'p2tr') return hint;
const a = proof.address;
if (a.startsWith('bc1p')) return 'p2tr';
if (a.startsWith('bc1q')) return 'p2wpkh';
if (a.startsWith('1')) return 'p2pkh';
return null;
}
// ── hashing helpers ─────────────────────────────────────────────────────────
const sha256d = (b: Uint8Array): Uint8Array => sha256(sha256(b));
const hash160 = (b: Uint8Array): Uint8Array => ripemd160(sha256(b));
/** BIP-340 tagged hash: sha256(sha256(tag) || sha256(tag) || msg…). */
function taggedHash(tag: string, ...messages: Uint8Array[]): Uint8Array {
const tagHash = sha256(utf8ToBytes(tag));
return sha256(concatBytes(tagHash, tagHash, ...messages));
}
// ── address derivation (pubkey → address string) ────────────────────────────
function deriveP2PKH(pubkey: Uint8Array): string {
// base58check( 0x00 || hash160(pubkey) )
return base58checkEncode(0x00, hash160(pubkey));
}
function deriveP2WPKH(pubkeyCompressed: Uint8Array): string | null {
// bech32(hrp='bc', witver=0, program=hash160(compressed pubkey))
return encodeSegwitAddress(HRP, 0, hash160(pubkeyCompressed));
}
/** x-only (32-byte) coordinate of a point given as its affine x bigint. */
function xonlyFromBigInt(x: bigint): Uint8Array {
const out = new Uint8Array(32);
let v = x;
for (let i = 31; i >= 0; i--) {
out[i] = Number(v & 0xffn);
v >>= 8n;
}
return out;
}
/**
* BIP-86 key-path taproot output for an internal pubkey.
* t = taggedHash('TapTweak', xonly(P))
* Q = lift_x(xonly(P)) + t*G // internal key is the even-Y lift
* program = xonly(Q)
* Returns the 32-byte tweaked x-only program, or null on any failure.
*/
function taprootTweak(internalXonly: Uint8Array): Uint8Array | null {
try {
const Point = secp256k1.Point;
const n = secp256k1.CURVE.n;
const x = bytesToBigInt(internalXonly);
const P = schnorr.utils.lift_x(x); // even-Y point with this x
const t = bytesToBigInt(taggedHash('TapTweak', internalXonly)) % n;
if (t === 0n) return null;
const Q = P.add(Point.BASE.multiply(t));
const qx = Q.toAffine().x;
return xonlyFromBigInt(qx);
} catch {
return null;
}
}
function deriveP2TR(internalXonly: Uint8Array): string | null {
const program = taprootTweak(internalXonly);
if (program === null) return null;
return encodeSegwitAddress(HRP, 1, program);
}
function bytesToBigInt(b: Uint8Array): bigint {
let v = 0n;
for (const byte of b) v = (v << 8n) | BigInt(byte);
return v;
}
// ── Bitcoin var-int / serialization (CompactSize) ───────────────────────────
function compactSize(n: number): Uint8Array {
if (n < 0) throw new Error('negative compactSize');
if (n < 0xfd) return new Uint8Array([n]);
if (n <= 0xffff) return new Uint8Array([0xfd, n & 0xff, (n >>> 8) & 0xff]);
if (n <= 0xffffffff) {
return new Uint8Array([0xfe, n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, (n >>> 24) & 0xff]);
}
// 64-bit lengths are never needed for login messages.
const out = new Uint8Array(9);
out[0] = 0xff;
let v = BigInt(n);
for (let i = 1; i <= 8; i++) {
out[i] = Number(v & 0xffn);
v >>= 8n;
}
return out;
}
function u32le(n: number): Uint8Array {
return new Uint8Array([n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, (n >>> 24) & 0xff]);
}
function u64le(n: bigint): Uint8Array {
const out = new Uint8Array(8);
let v = n;
for (let i = 0; i < 8; i++) {
out[i] = Number(v & 0xffn);
v >>= 8n;
}
return out;
}
/** length-prefixed (CompactSize) byte string. */
function varBytes(b: Uint8Array): Uint8Array {
return concatBytes(compactSize(b.length), b);
}
// ════════════════════════════════════════════════════════════════════════════
// 1. LEGACY "Bitcoin Signed Message" (recoverable ECDSA)
// ════════════════════════════════════════════════════════════════════════════
const MSG_MAGIC = utf8ToBytes('\x18Bitcoin Signed Message:\n');
/** digest = sha256d( magic || varint(len) || message ). */
function legacyMessageDigest(message: string): Uint8Array {
const msg = utf8ToBytes(message);
return sha256d(concatBytes(MSG_MAGIC, compactSize(msg.length), msg));
}
/**
* Verify a 65-byte recoverable signature [header || r || s] over the legacy
* message digest, deriving the address of `type` and comparing to `address`.
*/
function verifyLegacy(
sig: Uint8Array,
message: string,
address: string,
type: BtcAddressType,
): boolean {
if (sig.length !== 65) return false;
const header = sig[0]!;
// 27-30: uncompressed key; 31-34: compressed key. (BIP-137 also defines
// 35-42 for segwit, but the recovered key form is what matters here, so we
// accept the canonical 27-34 range and infer compression from it.)
if (header < 27 || header > 34) return false;
const recid = (header - 27) & 3;
const compressed = header >= 31;
const r = sig.slice(1, 33);
const s = sig.slice(33, 65);
const digest = legacyMessageDigest(message);
let point;
try {
point = secp256k1.Signature.fromCompact(concatBytes(r, s))
.addRecoveryBit(recid)
.recoverPublicKey(digest);
} catch {
return false;
}
// For segwit address types the key MUST be compressed — an uncompressed key
// cannot back a P2WPKH/P2TR script, so reject rather than silently coercing.
if ((type === 'p2wpkh' || type === 'p2tr') && !compressed) return false;
let derived: string | null;
switch (type) {
case 'p2pkh': {
// P2PKH commits to the exact key encoding chosen by the header byte.
const pub = point.toBytes(compressed);
derived = deriveP2PKH(pub);
break;
}
case 'p2wpkh': {
derived = deriveP2WPKH(point.toBytes(true));
break;
}
case 'p2tr': {
// Internal key = x-only of the recovered (compressed) key.
const xonly = point.toBytes(true).slice(1);
derived = deriveP2TR(xonly);
break;
}
}
return derived !== null && constTimeStrEq(derived, address);
}
/** Length-checked, content-comparing string equality (addresses are public). */
function constTimeStrEq(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
return diff === 0;
}
// ════════════════════════════════════════════════════════════════════════════
// 2. BIP-322 "simple"
// ════════════════════════════════════════════════════════════════════════════
/** Parse a serialized witness stack: count, then length-prefixed elements. */
function parseWitness(buf: Uint8Array): Uint8Array[] | null {
let off = 0;
const readCompact = (): number | null => {
if (off >= buf.length) return null;
const first = buf[off++]!;
if (first < 0xfd) return first;
if (first === 0xfd) {
if (off + 2 > buf.length) return null;
const v = buf[off]! | (buf[off + 1]! << 8);
off += 2;
return v;
}
if (first === 0xfe) {
if (off + 4 > buf.length) return null;
const v = buf[off]! | (buf[off + 1]! << 8) | (buf[off + 2]! << 16) | (buf[off + 3]! * 0x1000000);
off += 4;
return v;
}
return null; // 64-bit lengths never appear in witness logins
};
const count = readCompact();
if (count === null || count < 1 || count > 4) return null;
const items: Uint8Array[] = [];
for (let i = 0; i < count; i++) {
const len = readCompact();
if (len === null || len < 0 || off + len > buf.length) return null;
items.push(buf.slice(off, off + len));
off += len;
}
if (off !== buf.length) return null; // no trailing garbage
return items;
}
/** scriptPubKey bytes for each supported address type given its program. */
function scriptPubKeyP2WPKH(hash160Pub: Uint8Array): Uint8Array {
// OP_0 PUSH20 <hash160>
return concatBytes(new Uint8Array([0x00, 0x14]), hash160Pub);
}
function scriptPubKeyP2TR(programXonly: Uint8Array): Uint8Array {
// OP_1 PUSH32 <tweaked xonly>
return concatBytes(new Uint8Array([0x51, 0x20]), programXonly);
}
/**
* Build the BIP-322 to_spend txid.
* to_spend: nVersion=0, vin=[ {prevout=0..00:0xFFFFFFFF,
* scriptSig=OP_0 PUSH32 <msgHash>, nSequence=0} ],
* vout=[ {value=0, scriptPubKey} ], nLockTime=0
* txid = sha256d(serialization without witness).
*/
function toSpendTxid(message: string, scriptPubKey: Uint8Array): Uint8Array {
const msgHash = taggedHash('BIP0322-signed-message', utf8ToBytes(message));
const scriptSig = concatBytes(new Uint8Array([0x00, 0x20]), msgHash); // OP_0 PUSH32
const ser = concatBytes(
u32le(0), // nVersion = 0
compactSize(1), // vin count
new Uint8Array(32), // prevout hash = 0
u32le(0xffffffff), // prevout index = 0xFFFFFFFF
varBytes(scriptSig),
u32le(0), // nSequence = 0
compactSize(1), // vout count
u64le(0n), // value = 0
varBytes(scriptPubKey),
u32le(0), // nLockTime = 0
);
return sha256d(ser);
}
/**
* BIP-143 sighash for the single input of the BIP-322 to_sign tx (P2WPKH).
* SIGHASH_ALL. scriptCode for P2WPKH = OP_DUP OP_HASH160 PUSH20 <h160>
* OP_EQUALVERIFY OP_CHECKSIG.
*/
function bip143SighashP2WPKH(toSpendTxid: Uint8Array, hash160Pub: Uint8Array): Uint8Array {
const outpoint = concatBytes(toSpendTxid, u32le(0)); // to_spend:0
const nSequence = u32le(0);
const hashPrevouts = sha256d(outpoint);
const hashSequence = sha256d(nSequence);
const scriptCode = concatBytes(
new Uint8Array([0x19, 0x76, 0xa9, 0x14]), // len(25) OP_DUP OP_HASH160 PUSH20
hash160Pub,
new Uint8Array([0x88, 0xac]), // OP_EQUALVERIFY OP_CHECKSIG
);
// to_sign single output: value=0, scriptPubKey = OP_RETURN (0x6a).
const output = concatBytes(u64le(0n), varBytes(new Uint8Array([0x6a])));
const hashOutputs = sha256d(output);
const preimage = concatBytes(
u32le(0), // nVersion = 0
hashPrevouts,
hashSequence,
outpoint,
scriptCode,
u64le(0n), // amount of the spent output = 0
nSequence,
hashOutputs,
u32le(0), // nLockTime = 0
u32le(1), // SIGHASH_ALL
);
return sha256d(preimage);
}
/**
* BIP-341 (taproot key-path) sighash for the single input of the to_sign tx,
* SIGHASH_DEFAULT (0x00). Single P2TR input, single OP_RETURN output.
*/
function bip341SighashP2TR(toSpendTxid: Uint8Array, scriptPubKey: Uint8Array): Uint8Array {
const outpoint = concatBytes(toSpendTxid, u32le(0));
const nSequence = u32le(0);
const shaPrevouts = sha256(outpoint);
const shaAmounts = sha256(u64le(0n)); // single spent amount = 0
const shaScriptPubkeys = sha256(varBytes(scriptPubKey));
const shaSequences = sha256(nSequence);
const output = concatBytes(u64le(0n), varBytes(new Uint8Array([0x6a]))); // value=0, OP_RETURN
const shaOutputs = sha256(output);
const epoch = new Uint8Array([0x00]);
const hashType = new Uint8Array([0x00]); // SIGHASH_DEFAULT
const spendType = new Uint8Array([0x00]); // no annex, key-path
const inputIndex = u32le(0);
const sigMsg = concatBytes(
hashType,
u32le(0), // nVersion = 0
u32le(0), // nLockTime = 0
shaPrevouts,
shaAmounts,
shaScriptPubkeys,
shaSequences,
shaOutputs,
spendType,
inputIndex,
);
// BIP-341: tagged hash "TapSighash" over (epoch || sigMsg).
return taggedHash('TapSighash', concatBytes(epoch, sigMsg));
}
/** Strip a trailing SIGHASH byte from a DER ECDSA signature, returning (der, sighash). */
function splitDerSighash(witnessSig: Uint8Array): { der: Uint8Array; sighash: number } | null {
if (witnessSig.length < 1) return null;
const sighash = witnessSig[witnessSig.length - 1]!;
return { der: witnessSig.slice(0, witnessSig.length - 1), sighash };
}
/** Parse a 64- or 65-byte BIP-340 schnorr sig (optional trailing sighash). */
function splitSchnorrSighash(witnessSig: Uint8Array): { sig: Uint8Array; sighash: number } | null {
if (witnessSig.length === 64) return { sig: witnessSig, sighash: 0x00 };
if (witnessSig.length === 65) {
const sighash = witnessSig[64]!;
return { sig: witnessSig.slice(0, 64), sighash };
}
return null;
}
function verifyBip322P2WPKH(
witness: Uint8Array[],
message: string,
address: string,
): boolean {
// Witness stack for P2WPKH is exactly [signature, pubkey].
if (witness.length !== 2) return false;
const [sigBytes, pubkey] = witness as [Uint8Array, Uint8Array];
if (pubkey.length !== 33 || (pubkey[0] !== 0x02 && pubkey[0] !== 0x03)) return false;
// Address binding: the witness pubkey must hash to the claimed P2WPKH address.
const h160 = hash160(pubkey);
const derived = deriveP2WPKH(pubkey);
if (derived === null || !constTimeStrEq(derived, address)) return false;
const parsed = splitDerSighash(sigBytes);
if (parsed === null) return false;
// BIP-322 simple for single-key uses SIGHASH_ALL.
if (parsed.sighash !== 0x01) return false;
const txid = toSpendTxid(message, scriptPubKeyP2WPKH(h160));
const sighash = bip143SighashP2WPKH(txid, h160);
try {
const sig = secp256k1.Signature.fromDER(parsed.der);
// Reject high-S (BIP-146 / consensus-standardness, anti-malleability).
if (sig.hasHighS()) return false;
return secp256k1.verify(sig.toCompactRawBytes(), sighash, pubkey, { lowS: true });
} catch {
return false;
}
}
function verifyBip322P2TR(
witness: Uint8Array[],
message: string,
address: string,
): boolean {
// Key-path spend: witness is exactly [schnorr_sig].
if (witness.length !== 1) return false;
const parsed = splitSchnorrSighash(witness[0]!);
if (parsed === null) return false;
if (parsed.sighash !== 0x00) return false; // SIGHASH_DEFAULT only
// Recover the tweaked output key from the claimed address by re-deriving the
// scriptPubKey from… the address itself: we must decode the program. Since we
// only have the address string, derive the program by trusting the bech32m
// body is the output key. We re-encode and compare, then verify schnorr
// against that x-only output key.
const program = decodeP2TRProgram(address);
if (program === null) return false;
const txid = toSpendTxid(message, scriptPubKeyP2TR(program));
const sighash = bip341SighashP2TR(txid, scriptPubKeyP2TR(program));
try {
return schnorr.verify(parsed.sig, sighash, program);
} catch {
return false;
}
}
/**
* Decode a bech32m P2TR ('bc1p…') address to its 32-byte witness program.
* Minimal decoder used only to recover the output key for schnorr verify; it
* re-validates the checksum by re-encoding and comparing (fail closed).
*/
function decodeP2TRProgram(address: string): Uint8Array | null {
const CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
const lower = address.toLowerCase();
if (lower !== address && address.toUpperCase() !== address) return null; // mixed case
const pos = lower.lastIndexOf('1');
if (pos < 1) return null;
const hrp = lower.slice(0, pos);
if (hrp !== HRP) return null;
const dataPart = lower.slice(pos + 1);
if (dataPart.length < 7) return null; // 1 (witver) + program + 6 checksum
const values: number[] = [];
for (const ch of dataPart) {
const v = CHARSET.indexOf(ch);
if (v === -1) return null;
values.push(v);
}
const witver = values[0]!;
if (witver !== 1) return null; // only taproot here
// Convert 5-bit data (excluding witver and 6-byte checksum) → 8-bit program.
const data5 = values.slice(1, values.length - 6);
const program = convert5to8(data5);
if (program === null || program.length !== 32) return null;
// Re-encode with bech32m and compare to validate the checksum.
const reencoded = encodeSegwitAddress(HRP, 1, program);
if (reencoded === null || reencoded !== lower) return null;
return program;
}
/** 5-bit groups → 8-bit bytes (frombits=5, tobits=8, pad=false). */
function convert5to8(data: number[]): Uint8Array | null {
let acc = 0;
let bits = 0;
const out: number[] = [];
for (const value of data) {
if (value < 0 || value >> 5 !== 0) return null;
acc = ((acc << 5) | value) & 0xffffffff;
bits += 5;
while (bits >= 8) {
bits -= 8;
out.push((acc >>> bits) & 0xff);
}
}
// Reject if leftover bits form a non-zero pad (strict, per BIP-173).
if (bits >= 5) return null;
if ((acc << (8 - bits)) & 0xff) return null;
return Uint8Array.from(out);
}
// ════════════════════════════════════════════════════════════════════════════
// dispatch
// ════════════════════════════════════════════════════════════════════════════
export function verifyBitcoin(proof: SignedProof): boolean {
try {
const type = addressType(proof);
if (type === null) return false;
let sig: Uint8Array;
try {
sig = base64ToBytes(proof.signature);
} catch {
return false;
}
if (sig.length === 0) return false;
// Shape-based dispatch (exactly one path per shape):
// • 65 bytes with a valid header → legacy recoverable ECDSA.
// • otherwise → BIP-322 simple (serialized witness stack).
const header = sig[0]!;
const looksLegacy = sig.length === 65 && header >= 27 && header <= 34;
if (looksLegacy) {
return verifyLegacy(sig, proof.message, proof.address, type);
}
// BIP-322 simple — only P2WPKH and P2TR are defined for key-path here.
const witness = parseWitness(sig);
if (witness === null) return false;
if (type === 'p2wpkh') return verifyBip322P2WPKH(witness, proof.message, proof.address);
if (type === 'p2tr') return verifyBip322P2TR(witness, proof.message, proof.address);
// BIP-322 for P2PKH is not standardized for "simple"; legacy covers it.
return false;
} catch {
// Absolute backstop: never throw out of a verifier.
return false;
}
}
+39
View File
@@ -0,0 +1,39 @@
/** Byte helpers shared by every verifier. Cross-runtime (Node + browser). */
import { hexToBytes as nobleHexToBytes, bytesToHex, utf8ToBytes, concatBytes } from '@noble/hashes/utils';
export { bytesToHex, utf8ToBytes, concatBytes };
/** Hex → bytes, tolerant of a leading 0x. */
export function hexToBytes(hex: string): Uint8Array {
return nobleHexToBytes(hex.startsWith('0x') || hex.startsWith('0X') ? hex.slice(2) : hex);
}
/** base64 (standard, with padding) → bytes. */
export function base64ToBytes(b64: string): Uint8Array {
if (typeof atob === 'function') {
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
// Node
return new Uint8Array(Buffer.from(b64, 'base64'));
}
export function bytesToBase64(bytes: Uint8Array): string {
if (typeof btoa === 'function') {
let bin = '';
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]!);
return btoa(bin);
}
return Buffer.from(bytes).toString('base64');
}
/** Decode a signature that may be hex (0x…) or base64 into raw bytes. */
export function decodeSignature(sig: string): Uint8Array {
const s = sig.trim();
if (s.startsWith('0x') || s.startsWith('0X')) return hexToBytes(s);
// Heuristic: pure hex (even length, [0-9a-f]) → hex, else base64.
if (/^[0-9a-fA-F]+$/.test(s) && s.length % 2 === 0) return hexToBytes(s);
return base64ToBytes(s);
}
+162
View File
@@ -0,0 +1,162 @@
/**
* CAIP-122 "Sign-In-With-X" message — the one canonical login string for every
* chain. Generalizes EIP-4361 (SIWE) so a Solana / Bitcoin / TON / XRP wallet
* signs the exact same human-readable assertion an Ethereum wallet does.
*
* Spec: https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-122.md
* (which itself generalizes https://eips.ethereum.org/EIPS/eip-4361)
*
* build ∘ parse round-trips. Both are pure — no I/O, no clock — so the Go port
* for IAM can mirror them byte-for-byte.
*/
import type { Chain, LoginChallenge } from './types.js';
/** Human chain label used on the first line of the message. */
const CHAIN_LABEL: Record<Chain, string> = {
evm: 'Ethereum',
solana: 'Solana',
bitcoin: 'Bitcoin',
ton: 'TON',
xrp: 'XRP Ledger',
};
export interface ParsedSiwx {
domain: string;
address: string;
statement?: string;
uri: string;
version?: string;
chainId?: string;
nonce: string;
issuedAt: string;
expirationTime?: string;
notBefore?: string;
requestId?: string;
resources?: string[];
}
export interface BuildParams {
challenge: LoginChallenge;
address: string;
chain: Chain;
/** CAIP-2 network id, e.g. 'eip155:1', 'solana:5eykt...'. Optional. */
chainId?: string;
}
/** Render a {@link LoginChallenge} to the canonical CAIP-122 message string. */
export function buildSiwxMessage(params: BuildParams): string {
const { challenge: c, address, chain, chainId } = params;
const label = CHAIN_LABEL[chain];
const lines: string[] = [];
lines.push(`${c.domain} wants you to sign in with your ${label} account:`);
lines.push(address);
lines.push('');
// Statement block is optional. When present it sits on its own line between
// two blank lines (per EIP-4361 ABNF).
if (c.statement != null && c.statement.length > 0) {
if (c.statement.includes('\n')) {
throw new Error('caip122: statement must be a single line');
}
lines.push(c.statement);
lines.push('');
}
lines.push(`URI: ${c.uri}`);
lines.push(`Version: ${c.version ?? '1'}`);
if (chainId != null) {
lines.push(`Chain ID: ${chainId}`);
}
lines.push(`Nonce: ${c.nonce}`);
lines.push(`Issued At: ${c.issuedAt}`);
if (c.expirationTime != null) {
lines.push(`Expiration Time: ${c.expirationTime}`);
}
if (c.notBefore != null) {
lines.push(`Not Before: ${c.notBefore}`);
}
if (c.requestId != null) {
lines.push(`Request ID: ${c.requestId}`);
}
if (c.resources != null && c.resources.length > 0) {
lines.push('Resources:');
for (const r of c.resources) {
lines.push(`- ${r}`);
}
}
return lines.join('\n');
}
const HEADER_RE = /^(?<domain>[^\n]+?) wants you to sign in with your .+ account:$/;
const FIELD_RE = /^(?<key>URI|Version|Chain ID|Nonce|Issued At|Expiration Time|Not Before|Request ID): (?<val>.*)$/;
/** Parse a CAIP-122 message back into its fields. Throws on malformed input. */
export function parseSiwxMessage(message: string): ParsedSiwx {
const raw = message.split('\n');
if (raw.length < 2) {
throw new Error('caip122: message too short');
}
const header = HEADER_RE.exec(raw[0] ?? '');
if (!header?.groups) {
throw new Error('caip122: malformed header line');
}
const domain = header.groups.domain;
const address = (raw[1] ?? '').trim();
if (address.length === 0) {
throw new Error('caip122: missing address line');
}
// Everything from line 2 onward: an optional statement block, then fields.
const out: Partial<ParsedSiwx> = { domain, address };
const resources: string[] = [];
let inResources = false;
let statementParts: string[] = [];
let sawField = false;
for (let i = 2; i < raw.length; i++) {
const line = raw[i] ?? '';
if (inResources) {
if (line.startsWith('- ')) {
resources.push(line.slice(2));
continue;
}
inResources = false;
}
if (line === 'Resources:') {
inResources = true;
sawField = true;
continue;
}
const f = FIELD_RE.exec(line);
if (f?.groups) {
sawField = true;
const v = f.groups.val;
switch (f.groups.key) {
case 'URI': out.uri = v; break;
case 'Version': out.version = v; break;
case 'Chain ID': out.chainId = v; break;
case 'Nonce': out.nonce = v; break;
case 'Issued At': out.issuedAt = v; break;
case 'Expiration Time': out.expirationTime = v; break;
case 'Not Before': out.notBefore = v; break;
case 'Request ID': out.requestId = v; break;
}
continue;
}
// Pre-field, non-empty, non-field lines are the statement.
if (!sawField && line.length > 0) {
statementParts.push(line);
}
}
if (statementParts.length > 0) {
out.statement = statementParts.join('\n');
}
if (resources.length > 0) {
out.resources = resources;
}
if (out.uri == null || out.nonce == null || out.issuedAt == null) {
throw new Error('caip122: missing required field (URI / Nonce / Issued At)');
}
return out as ParsedSiwx;
}
+60
View File
@@ -0,0 +1,60 @@
/**
* Browser wallet connectors — the client side of @luxwallet/connect.
*
* One factory, one vocabulary: {@link getConnector} returns the
* {@link WalletConnector} for a {@link Chain}; every connector produces a
* {@link SignedProof} the matching server-side verifier accepts.
*
* IMPORTANT: this module (and everything it imports) pulls the wallet libraries
* (viem, sats-connect, @tonconnect/sdk, @crossmarkio/sdk). The server verify
* path (`@luxwallet/connect/verify`) imports NONE of this — keep it that way.
*/
import type { Chain, WalletConnector } from './types.js';
import { EvmConnector } from './evm/connect.js';
import { SolanaConnector } from './solana/connect.js';
import { BitcoinConnector } from './bitcoin/connect.js';
import { TonConnector, type TonConnectorOptions } from './ton/connect.js';
import { XrpConnector } from './xrp/connect.js';
export { EvmConnector } from './evm/connect.js';
export { SolanaConnector } from './solana/connect.js';
export { BitcoinConnector } from './bitcoin/connect.js';
export { TonConnector, type TonConnectorOptions } from './ton/connect.js';
export { XrpConnector } from './xrp/connect.js';
/** Per-chain construction options. Only TON needs one (its dApp manifest URL). */
export interface ConnectorOptions {
ton?: TonConnectorOptions;
}
/** Build the connector for a chain. Pure construction — no I/O, no window touch. */
export function getConnector(chain: Chain, options: ConnectorOptions = {}): WalletConnector {
switch (chain) {
case 'evm':
return new EvmConnector();
case 'solana':
return new SolanaConnector();
case 'bitcoin':
return new BitcoinConnector();
case 'ton':
return new TonConnector(options.ton);
case 'xrp':
return new XrpConnector();
default: {
// Exhaustiveness: a new Chain must be handled here.
const _never: never = chain;
throw new Error(`no connector for chain '${String(_never)}'`);
}
}
}
/** One connector per supported chain, in canonical order. */
export function allConnectors(options: ConnectorOptions = {}): WalletConnector[] {
return [
getConnector('evm', options),
getConnector('solana', options),
getConnector('bitcoin', options),
getConnector('ton', options),
getConnector('xrp', options),
];
}
+211
View File
@@ -0,0 +1,211 @@
/**
* EVM wallet connector — EIP-191 `personal_sign` over the CAIP-122 message.
*
* Discovery: EIP-6963 multi-injection (`window.dispatchEvent` /
* `eip6963:requestProvider`) when wallets announce themselves, with a fallback
* to the legacy single `window.ethereum`. Connection uses viem's `custom`
* transport over the chosen EIP-1193 provider; signing uses `personal_sign`.
*
* The produced {@link SignedProof} is exactly what {@link verifyEvm} accepts:
* a 65-byte hex signature, address recoverable from it, scheme
* `secp256k1-eip191`. viem stays out of the verify core (see ../verify.ts) —
* it lives here, on the browser side only.
*/
import {
createWalletClient,
custom,
getAddress as toChecksum,
type WalletClient,
type EIP1193Provider,
} from 'viem';
import type {
Account,
LoginChallenge,
SignedProof,
WalletConnector,
WalletInfo,
} from '../types.js';
import { buildSiwxMessage } from '../caip122.js';
/** EIP-6963 provider announcement detail. */
interface Eip6963ProviderInfo {
uuid: string;
name: string;
icon: string;
rdns: string;
}
interface Eip6963ProviderDetail {
info: Eip6963ProviderInfo;
provider: EIP1193Provider;
}
interface Eip6963AnnounceEvent extends Event {
detail: Eip6963ProviderDetail;
}
/** A discovered injected provider, keyed by a stable id. */
interface DiscoveredProvider {
id: string;
name: string;
icon?: string;
provider: EIP1193Provider;
}
/** window shape we touch — kept local so the browser libs stay optional. */
interface EvmWindow {
ethereum?: EIP1193Provider & { providers?: EIP1193Provider[] };
addEventListener?: typeof addEventListener;
removeEventListener?: typeof removeEventListener;
dispatchEvent?: typeof dispatchEvent;
}
function getWindow(): EvmWindow | undefined {
return typeof window === 'undefined' ? undefined : (window as unknown as EvmWindow);
}
/**
* Collect EIP-6963 providers. Wallets respond to `eip6963:requestProvider`
* synchronously by dispatching `eip6963:announceProvider`; we listen for a
* short window and dedupe by rdns.
*/
function discoverEip6963(win: EvmWindow, waitMs = 300): Promise<DiscoveredProvider[]> {
if (typeof win.addEventListener !== 'function' || typeof win.dispatchEvent !== 'function') {
return Promise.resolve([]);
}
return new Promise((resolve) => {
const byRdns = new Map<string, DiscoveredProvider>();
const onAnnounce = (ev: Event): void => {
const e = ev as Eip6963AnnounceEvent;
const d = e.detail;
if (d?.info?.rdns && d.provider && !byRdns.has(d.info.rdns)) {
byRdns.set(d.info.rdns, {
id: d.info.rdns,
name: d.info.name,
icon: d.info.icon,
provider: d.provider,
});
}
};
win.addEventListener!('eip6963:announceProvider', onAnnounce as EventListener);
win.dispatchEvent!(new Event('eip6963:requestProvider'));
setTimeout(() => {
win.removeEventListener?.('eip6963:announceProvider', onAnnounce as EventListener);
resolve([...byRdns.values()]);
}, waitMs);
});
}
/** Legacy fallback: window.ethereum (and any window.ethereum.providers fan-out). */
function discoverLegacy(win: EvmWindow): DiscoveredProvider[] {
const eth = win.ethereum;
if (!eth) return [];
const list = Array.isArray(eth.providers) && eth.providers.length > 0 ? eth.providers : [eth];
return list.map((provider, i) => ({
id: i === 0 ? 'injected' : `injected-${i}`,
name: 'Injected Wallet',
provider,
}));
}
export class EvmConnector implements WalletConnector {
readonly chain = 'evm' as const;
#provider: EIP1193Provider | null = null;
#client: WalletClient | null = null;
/** Discover injected EVM wallets via EIP-6963, falling back to window.ethereum. */
async available(): Promise<WalletInfo[]> {
const win = getWindow();
if (!win) return [];
const discovered = await this.#discover(win);
return discovered.map((d) => ({
id: d.id,
name: d.name,
chain: this.chain,
icon: d.icon,
installed: true,
}));
}
async #discover(win: EvmWindow): Promise<DiscoveredProvider[]> {
const announced = await discoverEip6963(win);
if (announced.length > 0) return announced;
return discoverLegacy(win);
}
/**
* Connect to an injected wallet. `walletId` selects an EIP-6963 provider by
* its rdns (or the legacy `injected[-n]` id); omit it to use the first.
*/
async connect(walletId?: string): Promise<Account> {
const win = getWindow();
if (!win) throw new Error('evm: no window — connectors are browser-only');
const discovered = await this.#discover(win);
if (discovered.length === 0) {
throw new Error('evm: no injected EVM wallet found');
}
const chosen = walletId != null ? discovered.find((d) => d.id === walletId) : discovered[0];
if (!chosen) {
throw new Error(`evm: wallet '${walletId}' not found`);
}
const provider = chosen.provider;
const accounts = (await provider.request({ method: 'eth_requestAccounts' })) as string[];
if (!Array.isArray(accounts) || accounts.length === 0) {
throw new Error('evm: wallet returned no accounts');
}
const address = toChecksum(accounts[0]!);
let caip2: string | undefined;
try {
const chainIdHex = (await provider.request({ method: 'eth_chainId' })) as string;
const chainId = Number.parseInt(chainIdHex, 16);
if (Number.isFinite(chainId)) caip2 = `eip155:${chainId}`;
} catch {
// chainId is best-effort; signing does not require it.
}
this.#provider = provider;
this.#client = createWalletClient({ account: address, transport: custom(provider) });
return { chain: this.chain, address, walletId: chosen.id, caip2 };
}
/**
* Render the CAIP-122 message and have the wallet `personal_sign` it.
* Produces a `secp256k1-eip191` proof: 65-byte hex signature over the EIP-191
* digest, with the signer recoverable from the signature.
*/
async signLogin(account: Account, challenge: LoginChallenge): Promise<SignedProof> {
if (!this.#client || !this.#provider) {
throw new Error('evm: not connected — call connect() first');
}
const chainId = account.caip2 ?? undefined;
const message = buildSiwxMessage({
challenge,
address: account.address,
chain: this.chain,
chainId,
});
// personal_sign returns a 0x-prefixed 65-byte signature (r‖s‖v).
const signature = await this.#client.signMessage({
account: account.address as `0x${string}`,
message,
});
return {
chain: this.chain,
scheme: 'secp256k1-eip191',
address: account.address,
message,
signature,
};
}
async disconnect(): Promise<void> {
this.#provider = null;
this.#client = null;
}
}
+56
View File
@@ -0,0 +1,56 @@
/**
* EVM verifier — EIP-191 `personal_sign` over the CAIP-122 message.
*
* Recovers the secp256k1 public key from the signature, derives the Ethereum
* address (keccak256 of the uncompressed pubkey, last 20 bytes), and compares
* it case-insensitively to the claimed address. No viem dependency — pure
* @noble so this mirrors 1:1 in the Go port.
*/
import { secp256k1 } from '@noble/curves/secp256k1';
import { keccak_256 } from '@noble/hashes/sha3';
import { utf8ToBytes, concatBytes, hexToBytes, bytesToHex } from '../bytes.js';
/** keccak256(\x19Ethereum Signed Message:\n<len><msg>). */
export function eip191Digest(message: string): Uint8Array {
const msg = utf8ToBytes(message);
const prefix = utf8ToBytes(`\x19Ethereum Signed Message:\n${msg.length}`);
return keccak_256(concatBytes(prefix, msg));
}
/** Lowercased 0x-address derived from an uncompressed (65-byte) public key. */
export function addressFromPublicKey(pubUncompressed: Uint8Array): string {
// Drop the 0x04 prefix → 64 bytes, hash, take last 20.
const body = pubUncompressed.length === 65 ? pubUncompressed.slice(1) : pubUncompressed;
const hash = keccak_256(body);
return '0x' + bytesToHex(hash.slice(-20));
}
/**
* Verify an EIP-191 signature. Returns the recovered lowercased address, or
* null if the signature is malformed / unrecoverable.
*/
export function recoverEvmAddress(message: string, signature: string): string | null {
try {
const sig = hexToBytes(signature);
if (sig.length !== 65) return null;
const compact = sig.slice(0, 64);
let v = sig[64]!;
// Accept 27/28 (Ethereum) and raw 0/1 recovery ids.
if (v >= 27) v -= 27;
if (v !== 0 && v !== 1) return null;
const digest = eip191Digest(message);
const recovered = secp256k1.Signature.fromCompact(compact)
.addRecoveryBit(v)
.recoverPublicKey(digest);
return addressFromPublicKey(recovered.toRawBytes(false));
} catch {
return null;
}
}
/** True iff `signature` over `message` was produced by `address`. */
export function verifyEvm(message: string, signature: string, address: string): boolean {
const recovered = recoverEvmAddress(message, signature);
if (recovered == null) return false;
return recovered.toLowerCase() === address.trim().toLowerCase();
}
+51
View File
@@ -0,0 +1,51 @@
/**
* @luxwallet/connect — multi-chain wallet connect + Sign-In-With-X.
*
* Public surface. One vocabulary ({@link Chain}, {@link SignedProof}), one
* canonical login message (CAIP-122), one verifier ({@link verifyProof}).
*
* MIT licensed, zero GPL — clean of the Uniswap-derived bones that stay
* quarantined in luxfi/exchange.
*/
export type {
Chain,
SignatureScheme,
Account,
LoginChallenge,
SignedProof,
VerifyExpectation,
VerifyResult,
WalletConnector,
WalletInfo,
} from './types.js';
export { CHAINS } from './types.js';
export { buildSiwxMessage, parseSiwxMessage } from './caip122.js';
export type { ParsedSiwx, BuildParams } from './caip122.js';
export { generateNonce, newChallenge } from './nonce.js';
export { verifyProof } from './verify.js';
// Per-chain primitives (useful standalone; the connectors build on them).
export { verifyEvm, recoverEvmAddress, eip191Digest } from './evm/verify.js';
export { verifySolana } from './solana/verify.js';
export { verifyTon } from './ton/verify.js';
export { verifyBitcoin } from './bitcoin/verify.js';
export { verifyXrp } from './xrp/verify.js';
// Browser wallet connectors + the high-level login flow. These import the
// wallet libraries (viem, sats-connect, @tonconnect/sdk, @crossmarkio/sdk);
// the server verify path above pulls NONE of them.
export {
getConnector,
allConnectors,
EvmConnector,
SolanaConnector,
BitcoinConnector,
TonConnector,
XrpConnector,
} from './connectors.js';
export type { ConnectorOptions, TonConnectorOptions } from './connectors.js';
export { loginWithWallet } from './login.js';
export type { LoginWithWalletParams, LoginResult } from './login.js';
+50
View File
@@ -0,0 +1,50 @@
/**
* loginWithWallet — the one client-side login flow.
*
* Ties a connector's connect → signLogin into a single call: pick the chain,
* connect the wallet, sign the server-issued {@link LoginChallenge}, and return
* the {@link SignedProof}. The caller (or its server) mints the challenge and
* later verifies the proof with {@link import('./verify.js').verifyProof}.
*
* server: newChallenge() ─► client: loginWithWallet({chain, challenge})
* ─► SignedProof ─► server: verifyProof(proof, {domain, nonce})
*
* This module imports connectors, so it carries the wallet libs. Keep it out of
* the server verify path.
*/
import type { Account, Chain, LoginChallenge, SignedProof } from './types.js';
import { getConnector, type ConnectorOptions } from './connectors.js';
export interface LoginWithWalletParams {
/** Which chain's wallet to authenticate. */
chain: Chain;
/** Server-minted challenge to sign (domain, nonce, uri, times). */
challenge: LoginChallenge;
/** Specific wallet id to target (else the connector's default). */
walletId?: string;
/** Per-chain connector options (e.g. TON manifest URL). */
options?: ConnectorOptions;
}
export interface LoginResult {
account: Account;
proof: SignedProof;
}
/**
* Connect a wallet on `chain` and sign `challenge`, returning the connected
* account and the {@link SignedProof}. Throws if no wallet is available or the
* user rejects; the connector is disconnected on failure to avoid a dangling
* session.
*/
export async function loginWithWallet(params: LoginWithWalletParams): Promise<LoginResult> {
const connector = getConnector(params.chain, params.options);
try {
const account = await connector.connect(params.walletId);
const proof = await connector.signLogin(account, params.challenge);
return { account, proof };
} catch (err) {
await connector.disconnect().catch(() => {});
throw err;
}
}
+49
View File
@@ -0,0 +1,49 @@
/**
* Single-use login nonces. The server mints one per challenge, stores it, and
* burns it on verify so a captured proof cannot be replayed.
*/
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
/** Cryptographically-random alphanumeric nonce (default 16 chars, ~95 bits). */
export function generateNonce(length = 16): string {
if (length < 8) {
throw new Error('nonce: length must be >= 8 (CAIP-122 minimum)');
}
const bytes = new Uint8Array(length);
globalThis.crypto.getRandomValues(bytes);
let out = '';
for (let i = 0; i < length; i++) {
out += ALPHABET[bytes[i]! % ALPHABET.length];
}
return out;
}
/** Build a {@link import('./types.js').LoginChallenge} with sane defaults. */
export function newChallenge(opts: {
domain: string;
uri: string;
statement?: string;
nonce?: string;
/** TTL in seconds for the expirationTime field. Default 600 (10 min). */
ttlSeconds?: number;
/** Epoch ms for "now"; injectable for tests. */
now?: number;
requestId?: string;
resources?: string[];
}) {
const nowMs = opts.now ?? Date.now();
const issuedAt = new Date(nowMs).toISOString();
const ttl = opts.ttlSeconds ?? 600;
const expirationTime = new Date(nowMs + ttl * 1000).toISOString();
return {
domain: opts.domain,
uri: opts.uri,
statement: opts.statement,
nonce: opts.nonce ?? generateNonce(),
issuedAt,
expirationTime,
version: '1',
requestId: opts.requestId,
resources: opts.resources,
};
}
+140
View File
@@ -0,0 +1,140 @@
/**
* Solana wallet connector — ed25519 `signMessage` over the CAIP-122 message.
*
* Uses the injected provider directly (Phantom `window.solana`, Solflare
* `window.solflare`) — no adapter library needed; the Wallet Standard surface
* these expose is a thin `connect()` / `signMessage()` pair. The account
* address IS the base58 ed25519 public key, which is exactly what
* {@link verifySolana} needs (it decodes the address as the verifying key).
*
* Produced {@link SignedProof}: scheme `ed25519`, base64 signature over the
* raw UTF-8 message bytes, address = base58 public key.
*/
import bs58 from 'bs58';
import type {
Account,
LoginChallenge,
SignedProof,
WalletConnector,
WalletInfo,
} from '../types.js';
import { buildSiwxMessage } from '../caip122.js';
import { utf8ToBytes, bytesToBase64 } from '../bytes.js';
/** Minimal shape of an injected Solana provider (Phantom / Solflare / Backpack). */
interface SolanaProvider {
isPhantom?: boolean;
isSolflare?: boolean;
isBackpack?: boolean;
publicKey?: { toBytes(): Uint8Array; toString(): string } | null;
connect(opts?: { onlyIfTrusted?: boolean }): Promise<{ publicKey: { toBytes(): Uint8Array; toString(): string } }>;
disconnect?(): Promise<void>;
signMessage(message: Uint8Array, encoding?: 'utf8' | 'hex'): Promise<{ signature: Uint8Array } | Uint8Array>;
}
interface SolanaWindow {
solana?: SolanaProvider;
solflare?: SolanaProvider;
backpack?: SolanaProvider;
}
function getWindow(): SolanaWindow | undefined {
return typeof window === 'undefined' ? undefined : (window as unknown as SolanaWindow);
}
interface ProviderEntry {
id: string;
name: string;
provider: SolanaProvider;
}
/** Enumerate the injected providers we know how to drive. */
function discover(win: SolanaWindow): ProviderEntry[] {
const out: ProviderEntry[] = [];
if (win.solana) {
out.push({ id: win.solana.isPhantom ? 'phantom' : 'solana', name: win.solana.isPhantom ? 'Phantom' : 'Solana', provider: win.solana });
}
if (win.solflare && win.solflare !== win.solana) {
out.push({ id: 'solflare', name: 'Solflare', provider: win.solflare });
}
if (win.backpack && win.backpack !== win.solana) {
out.push({ id: 'backpack', name: 'Backpack', provider: win.backpack });
}
return out;
}
/** Normalize the two shapes signMessage can return into raw signature bytes. */
function extractSignature(res: { signature: Uint8Array } | Uint8Array): Uint8Array {
if (res instanceof Uint8Array) return res;
if (res && res.signature instanceof Uint8Array) return res.signature;
throw new Error('solana: wallet returned an unrecognized signMessage result');
}
export class SolanaConnector implements WalletConnector {
readonly chain = 'solana' as const;
#provider: SolanaProvider | null = null;
async available(): Promise<WalletInfo[]> {
const win = getWindow();
if (!win) return [];
return discover(win).map((e) => ({
id: e.id,
name: e.name,
chain: this.chain,
installed: true,
}));
}
/** Connect to an injected wallet (Phantom by default) and return the account. */
async connect(walletId?: string): Promise<Account> {
const win = getWindow();
if (!win) throw new Error('solana: no window — connectors are browser-only');
const entries = discover(win);
if (entries.length === 0) throw new Error('solana: no injected Solana wallet found');
const chosen = walletId != null ? entries.find((e) => e.id === walletId) : entries[0];
if (!chosen) throw new Error(`solana: wallet '${walletId}' not found`);
const { publicKey } = await chosen.provider.connect();
const address = bs58.encode(publicKey.toBytes());
this.#provider = chosen.provider;
// For Solana the address IS the base58 ed25519 public key — one value.
return { chain: this.chain, address, publicKey: address, walletId: chosen.id };
}
/**
* Render the CAIP-122 message and have the wallet sign its UTF-8 bytes.
* Produces an `ed25519` proof whose signature {@link verifySolana} checks
* against the base58 address (the public key).
*/
async signLogin(account: Account, challenge: LoginChallenge): Promise<SignedProof> {
if (!this.#provider) throw new Error('solana: not connected — call connect() first');
const message = buildSiwxMessage({
challenge,
address: account.address,
chain: this.chain,
});
const res = await this.#provider.signMessage(utf8ToBytes(message), 'utf8');
const signature = bytesToBase64(extractSignature(res));
return {
chain: this.chain,
scheme: 'ed25519',
address: account.address,
message,
signature,
};
}
async disconnect(): Promise<void> {
try {
await this.#provider?.disconnect?.();
} finally {
this.#provider = null;
}
}
}
+22
View File
@@ -0,0 +1,22 @@
/**
* Solana verifier — ed25519 over the raw UTF-8 CAIP-122 message
* (the bytes a wallet's `signMessage` returns). The account address IS the
* base58-encoded ed25519 public key, so the key needed to verify is the
* address itself.
*/
import { ed25519 } from '@noble/curves/ed25519';
import bs58 from 'bs58';
import { utf8ToBytes, decodeSignature } from '../bytes.js';
/** True iff `signature` over `message` was produced by the key behind `address`. */
export function verifySolana(message: string, signature: string, address: string): boolean {
try {
const pub = bs58.decode(address.trim());
if (pub.length !== 32) return false;
const sig = decodeSignature(signature);
if (sig.length !== 64) return false;
return ed25519.verify(sig, utf8ToBytes(message), pub);
} catch {
return false;
}
}
+210
View File
@@ -0,0 +1,210 @@
/**
* TON wallet connector — TON Connect `ton_proof` (ed25519).
*
* TON differs from text-signing chains: the wallet does not sign the CAIP-122
* string. It signs a structured `ton_proof` envelope whose `payload` we pin to
* the server nonce, and returns the ed25519 signature plus the domain /
* timestamp it bound in. {@link verifyTon} reconstructs that envelope and
* checks the signature, then binds it back to the CAIP-122 message
* (nonce === payload, address === signer).
*
* Because TON Connect binds the proof payload at connect time, `signLogin`
* re-runs the connect handshake with `tonProof: challenge.nonce` and waits for
* the wallet's `ton_proof` reply. The produced {@link SignedProof} carries:
* - scheme `ton-proof`
* - publicKey: ed25519 key, hex
* - signature: base64
* - extra: { timestamp, domain, payload, workchain, addressHashHex }
*/
import TonConnect, {
isWalletInfoCurrentlyInjected,
type Wallet,
type WalletInfo as TonWalletInfo,
type TonProofItemReply,
} from '@tonconnect/sdk';
import type {
Account,
LoginChallenge,
SignedProof,
WalletConnector,
WalletInfo,
} from '../types.js';
import { buildSiwxMessage } from '../caip122.js';
/** Default manifest URL used when the host page does not supply one. */
const DEFAULT_MANIFEST = 'https://hanzo.id/tonconnect-manifest.json';
export interface TonConnectorOptions {
/** TON Connect dApp manifest URL (defaults to hanzo.id's). */
manifestUrl?: string;
}
/** A successful ton_proof reply (the only variant we accept). */
function readProof(reply: TonProofItemReply | undefined): {
timestamp: number;
domain: string;
payload: string;
signature: string;
} | null {
if (!reply || reply.name !== 'ton_proof' || !('proof' in reply)) return null;
const p = reply.proof;
return { timestamp: p.timestamp, domain: p.domain.value, payload: p.payload, signature: p.signature };
}
/** Split a raw TON address `"<workchain>:<hex>"` into its parts. */
function parseRawAddress(address: string): { workchain: number; addressHashHex: string } | null {
const i = address.indexOf(':');
if (i < 0) return null;
const workchain = Number.parseInt(address.slice(0, i), 10);
const addressHashHex = address.slice(i + 1);
if (!Number.isInteger(workchain)) return null;
if (!/^[0-9a-fA-F]{64}$/.test(addressHashHex)) return null;
return { workchain, addressHashHex };
}
export class TonConnector implements WalletConnector {
readonly chain = 'ton' as const;
readonly #manifestUrl: string;
#connector: TonConnect | null = null;
constructor(options: TonConnectorOptions = {}) {
// Pure construction: TonConnect's constructor touches localStorage, so it is
// created lazily on first use (in a browser) rather than here.
this.#manifestUrl = options.manifestUrl ?? DEFAULT_MANIFEST;
}
/** Lazily build the underlying TonConnect (requires a browser w/ localStorage). */
#sdk(): TonConnect {
if (typeof window === 'undefined') {
throw new Error('ton: no window — connectors are browser-only');
}
if (!this.#connector) {
this.#connector = new TonConnect({ manifestUrl: this.#manifestUrl });
}
return this.#connector;
}
/** List injected TON wallets (Tonkeeper, MyTonWallet, …) detected on the page. */
async available(): Promise<WalletInfo[]> {
if (typeof window === 'undefined') return [];
const wallets = await this.#sdk().getWallets();
return wallets.filter(isWalletInfoCurrentlyInjected).map((w: TonWalletInfo) => ({
id: (w as { jsBridgeKey: string }).jsBridgeKey,
name: w.name,
chain: this.chain,
icon: w.imageUrl,
installed: true,
}));
}
/**
* Establish a session (no proof yet) and return the account. `walletId` is the
* wallet's `jsBridgeKey`; omit it to use the first injected wallet.
*/
async connect(walletId?: string): Promise<Account> {
const wallet = await this.#handshake(walletId);
return this.#toAccount(wallet, walletId);
}
/**
* Re-run the handshake with `tonProof: nonce`, wait for the wallet's signed
* envelope, and assemble the {@link SignedProof} {@link verifyTon} accepts.
*/
async signLogin(account: Account, challenge: LoginChallenge): Promise<SignedProof> {
const parsed = parseRawAddress(account.address);
if (!parsed) throw new Error(`ton: address '${account.address}' is not raw '<wc>:<hex>' form`);
// Bind the ton_proof payload to the server nonce.
const wallet = await this.#handshake(account.walletId, challenge.nonce);
const publicKey = wallet.account.publicKey;
if (!publicKey) throw new Error('ton: wallet did not return a public key');
const proof = readProof(wallet.connectItems?.tonProof);
if (!proof) throw new Error('ton: wallet did not return a ton_proof');
if (proof.payload !== challenge.nonce) {
throw new Error('ton: wallet signed a different payload than the requested nonce');
}
// CAIP-122 message: address line is the raw TON address; its Nonce equals
// the ton_proof payload (the verifier enforces both bindings).
const message = buildSiwxMessage({
challenge,
address: account.address,
chain: this.chain,
});
return {
chain: this.chain,
scheme: 'ton-proof',
address: account.address,
publicKey,
message,
signature: proof.signature,
extra: {
timestamp: proof.timestamp,
domain: proof.domain,
payload: proof.payload,
workchain: parsed.workchain,
addressHashHex: parsed.addressHashHex,
},
};
}
async disconnect(): Promise<void> {
// Only act if the SDK was ever created (avoids touching localStorage in SSR).
if (this.#connector?.connected) await this.#connector.disconnect();
}
/** Resolve the chosen injected wallet's jsBridgeKey. */
async #resolveBridgeKey(walletId?: string): Promise<string> {
if (walletId != null) return walletId;
const wallets = (await this.#sdk().getWallets()).filter(isWalletInfoCurrentlyInjected);
const first = wallets[0] as { jsBridgeKey?: string } | undefined;
if (!first?.jsBridgeKey) throw new Error('ton: no injected TON wallet found');
return first.jsBridgeKey;
}
/**
* Drive one connect handshake and resolve with the resulting Wallet. When
* `proofPayload` is given, the wallet returns a ton_proof bound to it.
*/
async #handshake(walletId?: string, proofPayload?: string): Promise<Wallet> {
const sdk = this.#sdk(); // throws outside a browser
const jsBridgeKey = await this.#resolveBridgeKey(walletId);
return new Promise<Wallet>((resolve, reject) => {
let unsubscribe: (() => void) | undefined;
const done = (fn: () => void): void => {
unsubscribe?.();
fn();
};
unsubscribe = sdk.onStatusChange(
(wallet) => {
if (wallet) done(() => resolve(wallet));
},
(err) => done(() => reject(err)),
);
try {
// Injected connect returns void; the reply arrives via onStatusChange.
sdk.connect(
{ jsBridgeKey },
proofPayload != null ? { tonProof: proofPayload } : undefined,
);
} catch (err) {
done(() => reject(err instanceof Error ? err : new Error(String(err))));
}
});
}
#toAccount(wallet: Wallet, walletId?: string): Account {
return {
chain: this.chain,
address: wallet.account.address,
publicKey: wallet.account.publicKey,
walletId: walletId ?? wallet.device.appName,
caip2: `ton:${wallet.account.chain}`,
};
}
}
+158
View File
@@ -0,0 +1,158 @@
/**
* TON verifier — TON Connect `ton_proof` (ed25519).
*
* Unlike text-signing chains, TON signs a structured `ton_proof` envelope, not
* the CAIP-122 string. The connector carries the envelope in `proof.extra` and
* the wallet public key in `proof.publicKey`; this verifier reconstructs the
* ton_proof signing message exactly as the TON Connect spec defines it, checks
* the ed25519 signature over the double-SHA-256 digest, and binds the envelope
* to the CAIP-122 login message (nonce == payload, address == signer).
*
* Reference (TON Connect ton_proof):
* message = "ton-proof-item-v2/"
* ‖ int32BE(workchain)
* ‖ addressHash(32)
* ‖ uint32LE(len(domain))
* ‖ domain
* ‖ uint64LE(timestamp)
* ‖ payload
* signed = sha256( 0xffff ‖ "ton-connect" ‖ sha256(message) )
* ok = ed25519.verify(signature, signed, publicKey)
*
* Pure: no I/O, no network, no clock. Fails closed — any malformed or missing
* field returns false; never throws.
*/
import { ed25519 } from '@noble/curves/ed25519';
import { sha256 } from '@noble/hashes/sha256';
import type { SignedProof } from '../types.js';
import { parseSiwxMessage } from '../caip122.js';
import { hexToBytes, base64ToBytes, utf8ToBytes, concatBytes } from '../bytes.js';
/** ton_proof static prefixes (TON Connect v2). */
const PROOF_PREFIX = utf8ToBytes('ton-proof-item-v2/');
const CONNECT_PREFIX = utf8ToBytes('ton-connect');
/** ed25519 public keys are 32 bytes; signatures are 64 bytes; addr hash 32. */
const ED25519_PUBKEY_LEN = 32;
const ED25519_SIG_LEN = 64;
const ADDR_HASH_LEN = 32;
/** The `extra` envelope a TON connector attaches to a ton_proof. */
interface TonProofExtra {
timestamp: number;
domain: string;
payload: string;
workchain: number;
addressHashHex: string;
}
/** Narrow `proof.extra` to the ton_proof envelope, validating field shapes. */
function readExtra(extra: unknown): TonProofExtra | null {
if (extra == null || typeof extra !== 'object') return null;
const e = extra as Record<string, unknown>;
const { timestamp, domain, payload, workchain, addressHashHex } = e;
// timestamp: a finite, non-negative integer number of unix seconds.
if (typeof timestamp !== 'number' || !Number.isInteger(timestamp) || timestamp < 0) return null;
// workchain: a finite integer (0 = basechain, -1 = masterchain typically).
if (typeof workchain !== 'number' || !Number.isInteger(workchain)) return null;
if (typeof domain !== 'string') return null;
if (typeof payload !== 'string') return null;
if (typeof addressHashHex !== 'string') return null;
return { timestamp, domain, payload, workchain, addressHashHex };
}
/**
* Reconstruct the ton_proof message body that the wallet hashed:
* "ton-proof-item-v2/" ‖ int32BE(wc) ‖ addrHash ‖ uint32LE(|domain|) ‖ domain
* ‖ uint64LE(ts) ‖ payload
*
* Integer widths/endianness are spec-exact:
* - workchain: 4 bytes, big-endian, SIGNED (so -1 → 0xFFFFFFFF).
* - domain length: 4 bytes, little-endian, the UTF-8 BYTE length.
* - timestamp: 8 bytes, little-endian (BigInt to span > 2^53 safely).
*/
function buildProofMessage(
workchain: number,
addressHash: Uint8Array,
domainBytes: Uint8Array,
timestamp: number,
payloadBytes: Uint8Array,
): Uint8Array {
// workchain — int32 big-endian (signed two's-complement via setInt32).
const wc = new Uint8Array(4);
new DataView(wc.buffer).setInt32(0, workchain, /* littleEndian */ false);
// domain length — uint32 little-endian over the UTF-8 byte length.
const dlen = new Uint8Array(4);
new DataView(dlen.buffer).setUint32(0, domainBytes.length, /* littleEndian */ true);
// timestamp — uint64 little-endian.
const ts = new Uint8Array(8);
new DataView(ts.buffer).setBigUint64(0, BigInt(timestamp), /* littleEndian */ true);
return concatBytes(PROOF_PREFIX, wc, addressHash, dlen, domainBytes, ts, payloadBytes);
}
/** TON Connect's full pre-image and the double hash that ed25519 actually signs. */
function proofDigest(message: Uint8Array): Uint8Array {
// fullMsg = 0xff 0xff ‖ "ton-connect" ‖ sha256(message)
const fullMsg = concatBytes(Uint8Array.of(0xff, 0xff), CONNECT_PREFIX, sha256(message));
return sha256(fullMsg);
}
/**
* Verify a TON Connect `ton_proof` login proof.
*
* @returns true iff the ed25519 signature is valid over the reconstructed
* ton_proof digest AND the envelope is bound to the CAIP-122 message
* (nonce == payload, address == signer). Any other condition → false.
*/
export function verifyTon(proof: SignedProof): boolean {
try {
// --- 0. Structural presence: scheme, public key, signature, envelope. ---
if (proof.scheme !== 'ton-proof') return false;
if (typeof proof.publicKey !== 'string' || proof.publicKey.length === 0) return false;
if (typeof proof.signature !== 'string' || proof.signature.length === 0) return false;
if (typeof proof.message !== 'string' || proof.message.length === 0) return false;
if (typeof proof.address !== 'string' || proof.address.length === 0) return false;
const extra = readExtra(proof.extra);
if (extra === null) return false;
// --- 1. Binding to the CAIP-122 login message (anti-replay, anti-phishing).
// parseSiwxMessage throws on malformed input; the try/catch fails closed.
const parsed = parseSiwxMessage(proof.message);
// The signed payload MUST be the server-minted nonce carried in the SIWx msg.
if (parsed.nonce !== extra.payload) return false;
// The signer MUST be the address embedded in the message.
if (parsed.address !== proof.address) return false;
// --- 2. Decode + length-check the fixed-width cryptographic material. ---
const publicKey = hexToBytes(proof.publicKey);
if (publicKey.length !== ED25519_PUBKEY_LEN) return false;
const signature = base64ToBytes(proof.signature);
if (signature.length !== ED25519_SIG_LEN) return false;
const addressHash = hexToBytes(extra.addressHashHex);
if (addressHash.length !== ADDR_HASH_LEN) return false;
// --- 3. Reconstruct the ton_proof message and the digest the wallet signed.
const domainBytes = utf8ToBytes(extra.domain);
const payloadBytes = utf8ToBytes(extra.payload);
const message = buildProofMessage(
extra.workchain,
addressHash,
domainBytes,
extra.timestamp,
payloadBytes,
);
const digest = proofDigest(message);
// --- 4. ed25519 signature check over the 32-byte digest. ---
return ed25519.verify(signature, digest, publicKey);
} catch {
// Bad hex/base64, malformed SIWx, etc. — fail closed.
return false;
}
}
+140
View File
@@ -0,0 +1,140 @@
/**
* @luxwallet/connect — core types.
*
* One vocabulary across every chain. A wallet on any chain produces a
* {@link SignedProof}; the server verifies it with one {@link verifyProof}
* call. The login message itself is chain-agnostic (CAIP-122 "Sign-In-With-X").
*/
/** Supported chain families. Values, not places — namespaced by this union. */
export type Chain = 'evm' | 'solana' | 'bitcoin' | 'ton' | 'xrp';
export const CHAINS: readonly Chain[] = ['evm', 'solana', 'bitcoin', 'ton', 'xrp'];
/**
* Signature scheme used to produce a proof. The verifier dispatches on this,
* not on {@link Chain}, so a chain could in principle offer more than one.
*/
export type SignatureScheme =
| 'secp256k1-eip191' // EVM personal_sign (EIP-191)
| 'ed25519' // Solana, TON
| 'bip322' // Bitcoin message signing (BIP-322)
| 'ton-proof' // TON Connect ton_proof envelope (ed25519 inside)
| 'secp256k1-xrpl' // XRPL signMessage
| 'ed25519-xrpl'; // XRPL ed25519 keypair
/** A connected wallet account. `publicKey` is required where the address is not recoverable from the signature (Solana, TON, XRP). */
export interface Account {
chain: Chain;
/** Canonical address string for the chain (checksum EVM, base58 Solana, etc.). */
address: string;
/** Raw public key, hex (no 0x) or base64 — needed by ed25519/XRPL verifiers. */
publicKey?: string;
/** Identifier of the wallet that produced it (e.g. 'metamask', 'phantom'). */
walletId: string;
/** CAIP-2 chain id of the specific network, when known (e.g. 'eip155:1'). */
caip2?: string;
}
/**
* The login challenge a server asks a wallet to sign. Mirrors EIP-4361 /
* CAIP-122 fields. The server mints `nonce` and stores it until verification.
*/
export interface LoginChallenge {
/** RFC 4501 dnsauthority that is requesting the signing (e.g. 'hanzo.id'). */
domain: string;
/** RFC 3986 URI referring to the resource that is the subject of the signing. */
uri: string;
/** Human-readable assertion the user signs (one line, no newlines). */
statement?: string;
/** Server-minted single-use nonce (>= 8 alphanumerics). */
nonce: string;
/** ISO-8601 issuance time. */
issuedAt: string;
/** ISO-8601 expiry; after this the proof is rejected. */
expirationTime?: string;
/** ISO-8601 not-before; before this the proof is rejected. */
notBefore?: string;
/** Opaque request correlation id. */
requestId?: string;
/** Version of the message spec; '1' for CAIP-122/EIP-4361. */
version?: string;
/** Resource URIs the sign-in grants access to. */
resources?: string[];
}
/** What a wallet hands back after signing — everything a server needs to verify. */
export interface SignedProof {
chain: Chain;
scheme: SignatureScheme;
/** Address that signed (must match the address embedded in `message`). */
address: string;
/** Public key (hex/base64) when required by the scheme. */
publicKey?: string;
/** The exact UTF-8 string that was signed (the rendered CAIP-122 message). */
message: string;
/** Signature bytes, hex (0x-prefixed allowed) or base64 per scheme. */
signature: string;
/** Scheme-specific extra fields (e.g. TON proof envelope, BTC address type). */
extra?: Record<string, unknown>;
}
/** Server-side expectations checked against the parsed message during verify. */
export interface VerifyExpectation {
/** Must equal the message `domain`. */
domain: string;
/** Must equal the message `nonce` (single-use; server also burns it). */
nonce: string;
/** Optional: require an exact address (case-insensitive for EVM). */
address?: string;
/** Override "now" for deterministic tests (epoch ms). */
now?: number;
/** Max clock skew tolerated on issuedAt/notBefore, ms. Default 5 min. */
clockSkewMs?: number;
}
export interface VerifyResult {
ok: boolean;
/** Present when ok=false: machine-readable reason. */
reason?:
| 'bad-signature'
| 'address-mismatch'
| 'domain-mismatch'
| 'nonce-mismatch'
| 'expired'
| 'not-yet-valid'
| 'malformed-message'
| 'unsupported-scheme'
| 'missing-public-key';
/** The verified address (canonicalized) when ok=true. */
address?: string;
chain?: Chain;
}
/**
* A per-chain connector. Browser/runtime side only — the verifier never needs
* it. Implementations live under src/<chain>/.
*/
export interface WalletConnector {
readonly chain: Chain;
/** Wallets this connector can discover/offer in the current runtime. */
available(): Promise<WalletInfo[]>;
/** Connect (optionally to a specific wallet) and return the active account. */
connect(walletId?: string): Promise<Account>;
/** Render the challenge to the canonical message and have the wallet sign it. */
signLogin(account: Account, challenge: LoginChallenge): Promise<SignedProof>;
/** Disconnect / forget the session. */
disconnect(): Promise<void>;
}
export interface WalletInfo {
id: string;
name: string;
chain: Chain;
/** Data URI or URL to the wallet icon. */
icon?: string;
/** True if detected/installed in the current runtime. */
installed: boolean;
/** Where to get it if not installed. */
downloadUrl?: string;
}
+107
View File
@@ -0,0 +1,107 @@
/**
* verifyProof — the one server-side entry point. Chain-agnostic: parse the
* CAIP-122 message, enforce domain/nonce/time, then dispatch to the per-chain
* cryptographic verifier. Fails closed: any unknown scheme or malformed input
* returns `{ ok: false, reason }`, never throws.
*
* This pure function is mirrored by the Go port in go/walletconnect so IAM
* verifies identically.
*/
import type { SignedProof, VerifyExpectation, VerifyResult, Chain } from './types.js';
import { parseSiwxMessage } from './caip122.js';
import { verifyEvm } from './evm/verify.js';
import { verifySolana } from './solana/verify.js';
import { verifyTon } from './ton/verify.js';
import { verifyBitcoin } from './bitcoin/verify.js';
import { verifyXrp } from './xrp/verify.js';
const DEFAULT_SKEW_MS = 5 * 60 * 1000;
function fail(reason: NonNullable<VerifyResult['reason']>): VerifyResult {
return { ok: false, reason };
}
/** Case-insensitive only for EVM (checksummed hex); all others are exact. */
function addressesEqual(chain: Chain, a: string, b: string): boolean {
const x = a.trim();
const y = b.trim();
return chain === 'evm' ? x.toLowerCase() === y.toLowerCase() : x === y;
}
function parseTime(s: string | undefined): number | null {
if (s == null) return null;
const t = Date.parse(s);
return Number.isNaN(t) ? null : t;
}
/** Cryptographic dispatch. Returns null for not-yet-supported schemes. */
function verifyCrypto(proof: SignedProof): boolean | null {
switch (proof.scheme) {
case 'secp256k1-eip191':
return verifyEvm(proof.message, proof.signature, proof.address);
case 'ed25519':
// ed25519-over-message is Solana today; TON uses 'ton-proof'.
return verifySolana(proof.message, proof.signature, proof.address);
case 'ton-proof':
return verifyTon(proof);
case 'bip322':
return verifyBitcoin(proof);
case 'secp256k1-xrpl':
case 'ed25519-xrpl':
return verifyXrp(proof);
default:
return null;
}
}
export function verifyProof(proof: SignedProof, expected: VerifyExpectation): VerifyResult {
let parsed;
try {
parsed = parseSiwxMessage(proof.message);
} catch {
return fail('malformed-message');
}
// 1. Binding: the signer in the message must match the proof's address.
if (!addressesEqual(proof.chain, parsed.address, proof.address)) {
return fail('address-mismatch');
}
if (expected.address != null && !addressesEqual(proof.chain, proof.address, expected.address)) {
return fail('address-mismatch');
}
// 2. Domain + nonce binding (anti-phishing, anti-replay).
if (parsed.domain !== expected.domain) {
return fail('domain-mismatch');
}
if (parsed.nonce !== expected.nonce) {
return fail('nonce-mismatch');
}
// 3. Time window.
const now = expected.now ?? Date.now();
const skew = expected.clockSkewMs ?? DEFAULT_SKEW_MS;
const exp = parseTime(parsed.expirationTime);
if (exp != null && now > exp + skew) {
return fail('expired');
}
const nbf = parseTime(parsed.notBefore);
if (nbf != null && now + skew < nbf) {
return fail('not-yet-valid');
}
const iat = parseTime(parsed.issuedAt);
if (iat != null && iat - skew > now) {
return fail('not-yet-valid');
}
// 4. Cryptographic signature.
const crypto = verifyCrypto(proof);
if (crypto == null) {
return fail('unsupported-scheme');
}
if (!crypto) {
return fail('bad-signature');
}
return { ok: true, address: proof.address, chain: proof.chain };
}
+123
View File
@@ -0,0 +1,123 @@
/**
* XRP (XRP Ledger) wallet connector — Crossmark (`@crossmarkio/sdk`, MIT).
*
* Crossmark's `signInAndWait(hex)` performs a sign-in that also signs the hex
* bytes we pass, returning the account's r-address, its 33-byte public key
* (hex, with the XRPL family tag — `0xED` for ed25519, `0x02/0x03` for
* secp256k1), and the signature. {@link verifyXrp} digests the CAIP-122 message
* the same way XRPL does and checks the signature under that key, then binds
* the key to the r-address.
*
* The signing input must be the CAIP-122 message bytes: we pass
* `hex(utf8(message))` to the wallet and set `proof.message` to the same
* string, so the verifier's `utf8ToBytes(proof.message)` digest matches what
* the wallet signed. The scheme is chosen from the public key's family tag.
*
* GemWallet is intentionally NOT wired: its only client (`@gemwallet/api`) ships
* under a custom dual license that requires GemWallet's permission for
* public/commercial use — incompatible with this package's MIT/Apache/ISC-only
* rule. Crossmark covers both XRPL key types, so the XRP path stays complete.
*/
import sdk from '@crossmarkio/sdk';
import type {
Account,
LoginChallenge,
SignedProof,
SignatureScheme,
WalletConnector,
WalletInfo,
} from '../types.js';
import { buildSiwxMessage } from '../caip122.js';
import { utf8ToBytes, bytesToHex } from '../bytes.js';
/** XRPL public-key family tag → signature scheme. */
function schemeForPublicKey(publicKeyHex: string): SignatureScheme {
const tag = publicKeyHex.slice(0, 2).toLowerCase();
return tag === 'ed' ? 'ed25519-xrpl' : 'secp256k1-xrpl';
}
export class XrpConnector implements WalletConnector {
readonly chain = 'xrp' as const;
#publicKey: string | null = null;
/** Crossmark is the supported XRP wallet; report it when installed. */
async available(): Promise<WalletInfo[]> {
if (typeof window === 'undefined') return [];
const installed = sdk.sync.isInstalled() === true;
return [
{
id: 'crossmark',
name: 'Crossmark',
chain: this.chain,
installed,
downloadUrl: installed ? undefined : 'https://crossmark.io',
},
];
}
/**
* Connect via Crossmark sign-in. We do a bare sign-in here to capture the
* address + public key; the actual login signature is produced in signLogin.
*/
async connect(walletId?: string): Promise<Account> {
if (typeof window === 'undefined') {
throw new Error('xrp: no window — connectors are browser-only');
}
if (walletId != null && walletId !== 'crossmark') {
throw new Error(`xrp: unsupported wallet '${walletId}' (only 'crossmark')`);
}
const res = await sdk.async.signInAndWait();
const data = res?.response?.data;
if (!data?.address || !data?.publicKey) {
throw new Error('xrp: Crossmark sign-in returned no address/public key');
}
this.#publicKey = data.publicKey;
return {
chain: this.chain,
address: data.address,
publicKey: data.publicKey,
walletId: 'crossmark',
};
}
/**
* Render the CAIP-122 message, have Crossmark sign its UTF-8 bytes (passed as
* hex), and assemble a proof under the key's scheme. The signature lands in
* the sign-in response's `signature` field when a hex challenge is supplied.
*/
async signLogin(account: Account, challenge: LoginChallenge): Promise<SignedProof> {
if (!this.#publicKey) throw new Error('xrp: not connected — call connect() first');
const message = buildSiwxMessage({
challenge,
address: account.address,
chain: this.chain,
});
const hex = bytesToHex(utf8ToBytes(message));
const res = await sdk.async.signInAndWait(hex);
const data = res?.response?.data;
if (!data?.signature) {
throw new Error('xrp: Crossmark did not return a signature');
}
// The public key may refine on the signing response; prefer it if present.
const publicKey = data.publicKey ?? this.#publicKey;
return {
chain: this.chain,
scheme: schemeForPublicKey(publicKey),
address: account.address,
publicKey,
message,
signature: data.signature,
};
}
async disconnect(): Promise<void> {
this.#publicKey = null;
}
}
+130
View File
@@ -0,0 +1,130 @@
/**
* XRP (XRP Ledger) verifier — wallet login-message signatures.
*
* XRPL accounts use either a secp256k1 or an ed25519 keypair. The connector
* carries the public key in `proof.publicKey` (33 bytes in XRPL's canonical
* form). This verifier does two independent checks, both of which must hold:
*
* 1. Signature: the signature is valid over the CAIP-122 message under the
* declared key, using XRPL's signing convention for the scheme.
* - ed25519-xrpl : raw EdDSA over the UTF-8 message bytes.
* - secp256k1-xrpl : ECDSA over the "sha512half" digest
* (first 32 bytes of SHA-512 of the message), DER-encoded.
* 2. Address binding: the public key derives the claimed r-address via the
* standard XRPL AccountID derivation (RIPEMD160(SHA256(pubkey)) under the
* 0x00 account prefix, base58check with the XRPL alphabet).
*
* Decomplected: signature verification and address binding are separate,
* each complete on its own. Fails closed — every error path returns false,
* nothing throws. Pure: no I/O, no clock. Mirrors 1:1 in the Go port.
*
* Refs:
* - https://xrpl.org/cryptographic-keys.html (key prefixes, AccountID)
* - https://xrpl.org/base58-encodings.html (XRPL base58 alphabet, type prefix)
* - https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-122.md
*/
import { secp256k1 } from '@noble/curves/secp256k1';
import { ed25519 } from '@noble/curves/ed25519';
import { sha256 } from '@noble/hashes/sha256';
import { sha512 } from '@noble/hashes/sha512';
import { ripemd160 } from '@noble/hashes/ripemd160';
import type { SignedProof } from '../types.js';
import { hexToBytes, decodeSignature, utf8ToBytes, concatBytes } from '../bytes.js';
/** XRPL's base58 alphabet (NOT the Bitcoin/IPFS alphabet — different order). */
const XRPL_ALPHABET = 'rpshnaf39wBUDNEGHJKLM4PQRST7VWXYZ2bcdeCg65jkm8oFqi1tuvAxyz';
/** Account address type prefix byte (the leading 'r' once base58-encoded). */
const ACCOUNT_ID_PREFIX = 0x00;
/** XRPL public keys are always 33 bytes: a 1-byte family tag + 32-byte key. */
const PUBKEY_LEN = 33;
const ED25519_PREFIX = 0xed;
const ED25519_SIG_LEN = 64;
/** XRPL "sha512half": the first half (32 bytes) of SHA-512 over the input. */
function sha512Half(data: Uint8Array): Uint8Array {
return sha512(data).slice(0, 32);
}
/**
* Base58Check encode using the XRPL alphabet. `payload` is the version-prefixed
* data; a 4-byte double-SHA256 checksum is appended before encoding. Pure
* big-integer base conversion so it matches the Go port byte-for-byte.
*/
function base58CheckXrpl(payload: Uint8Array): string {
const checksum = sha256(sha256(payload)).slice(0, 4);
const full = concatBytes(payload, checksum);
// Big-endian base-256 → base-58 via repeated division.
let acc = 0n;
for (const b of full) acc = (acc << 8n) | BigInt(b);
let out = '';
while (acc > 0n) {
const rem = Number(acc % 58n);
acc = acc / 58n;
out = XRPL_ALPHABET[rem] + out;
}
// Each leading zero byte encodes as the alphabet's zeroth character.
for (let i = 0; i < full.length && full[i] === 0; i++) {
out = XRPL_ALPHABET[0] + out;
}
return out;
}
/**
* Derive the canonical r-address from a 33-byte XRPL public key:
* accountID = ripemd160(sha256(pubkey))
* address = base58check( 0x00 || accountID )
* The FULL 33-byte key (with its 0xED / 0x02 / 0x03 family tag) is hashed —
* this matches rippled's AccountID derivation for both key types.
*/
function deriveAddress(publicKey33: Uint8Array): string {
const accountId = ripemd160(sha256(publicKey33));
const versioned = concatBytes(Uint8Array.of(ACCOUNT_ID_PREFIX), accountId);
return base58CheckXrpl(versioned);
}
export function verifyXrp(proof: SignedProof): boolean {
try {
if (proof.publicKey == null || proof.publicKey.length === 0) return false;
const publicKey = hexToBytes(proof.publicKey);
if (publicKey.length !== PUBKEY_LEN) return false;
const messageBytes = utf8ToBytes(proof.message);
const sigBytes = decodeSignature(proof.signature);
// 1. Cryptographic signature check, per scheme.
let sigOk: boolean;
if (proof.scheme === 'ed25519-xrpl') {
// Family tag must be 0xED; verify over the bare 32-byte Edwards key.
if (publicKey[0] !== ED25519_PREFIX) return false;
if (sigBytes.length !== ED25519_SIG_LEN) return false;
const pub32 = publicKey.slice(1);
sigOk = ed25519.verify(sigBytes, messageBytes, pub32);
} else if (proof.scheme === 'secp256k1-xrpl') {
// Compressed point: family tag is 0x02 or 0x03.
if (publicKey[0] !== 0x02 && publicKey[0] !== 0x03) return false;
const digest = sha512Half(messageBytes);
// DER signature over the prehashed digest. lowS:false — rippled does not
// require low-S of wallet signatures, and malleability is irrelevant for
// a login proof already bound to a server nonce.
sigOk = secp256k1.verify(sigBytes, digest, publicKey, {
prehash: false,
lowS: false,
format: 'der',
});
} else {
return false;
}
if (!sigOk) return false;
// 2. Address binding: the key must derive exactly the claimed r-address.
const derived = deriveAddress(publicKey);
return derived === proof.address.trim();
} catch {
return false;
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"noEmit": true
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hanzo/id-idv",
"version": "0.1.0",
"version": "0.1.1",
"description": "Pluggable identity verification (KYC / KYB / liveness). Provider-agnostic — wires Persona, Onfido, Veriff, Sumsub, or any custom backend behind a single React surface.",
"license": "BSD-3-Clause",
"type": "module",
+1 -1
View File
@@ -30,7 +30,7 @@ export type IDVStatus =
export interface IDVSubject {
/** Stable subject identifier (typically the IAM user id). */
readonly subjectId: string
/** Tenant org slug (for multi-tenant providers). */
/** Org org slug (for multi-org providers). */
readonly orgId: string
/** Email + display name carried through for vendor pre-fill. */
readonly email?: string
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@hanzo/id-onboarding",
"version": "0.1.5",
"description": "Post-login onboarding for the Hanzo ID portal: choose/create org → optional project → optional wallet link. White-labeled by host. Domain / service / UI split.",
"license": "BSD-3-Clause",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./service": "./src/service/onboarding.ts",
"./flow": "./src/ui/OnboardingFlow.tsx",
"./package.json": "./package.json"
},
"files": ["src", "!src/**/*.test.ts"],
"scripts": {
"build": "tsc --noEmit",
"tc": "tsc --noEmit"
},
"dependencies": {
"@hanzo/id-shared": "workspace:*",
"@hanzo/iam": "^0.21.1"
},
"peerDependencies": {
"react": ">=19",
"react-dom": ">=19"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"typescript": "^5.9.3"
}
}
+161
View File
@@ -0,0 +1,161 @@
/**
* Onboarding domain types — React-free, serializable.
*
* The post-login onboarding is a three-step linear flow:
*
* 1. org — choose an existing org the user already belongs to, or
* create a new one. Required (every account needs a home org).
* 2. project — create a first project inside the chosen org. Optional
* (skippable; the org ships with a default project).
* 3. wallet — link a Web3 wallet to the account. Optional (skippable).
*
* The flow is declared as data here so the UI layer can render it without
* the domain importing React. `OnboardingService` (the service layer) does
* the actual IAM writes; this module only describes the shape of the flow
* and its accumulated state.
*/
/** Identifier for each step in the onboarding flow. */
export type StepId = 'org' | 'project' | 'wallet' | 'consent' | 'plan' | 'done'
/** A step's place in the linear flow. */
export interface StepDesc {
readonly id: StepId
/** Heading shown at the top of the step. */
readonly title: string
/** One-line subhead under the title. */
readonly byline: string
/** Whether the user may skip this step (Continue without acting). */
readonly skippable: boolean
}
/**
* The canonical step sequence. `done` is a terminal pseudo-step the flow
* lands on after `wallet`; it renders the success state and hands control
* back to the host via `onComplete`.
*/
export const STEPS: readonly StepDesc[] = [
{
id: 'org',
title: 'Choose your organization',
byline: 'Pick an organization you belong to, or create a new one.',
skippable: false,
},
{
id: 'project',
title: 'Create your first project',
byline: 'Projects group your apps, keys, and usage. You can add more later.',
skippable: true,
},
{
id: 'wallet',
title: 'Link a wallet',
byline: 'Connect a Web3 wallet to sign and pay onchain. Optional.',
skippable: true,
},
{
id: 'consent',
title: 'Data sharing',
byline: 'Choose whether to share usage data to improve the products.',
// Not skippable: the agreement needs an explicit ANSWER (yes or no, both
// valid), recorded once on the user so it is never re-asked. Skipping is
// how this page kept going missing.
skippable: false,
},
{
id: 'plan',
title: 'Choose how you pay',
byline: 'Pick a plan, or pay as you go with a prepaid balance.',
// The LAST page, and a required choice: the platform is prepay-only, so an
// account is not usable until a plan or a balance exists. "Pay as you go"
// IS a choice — there is nothing to skip to.
skippable: false,
},
] as const
/** A minimal org reference the UI lists in the "choose org" step. */
export interface OrgRef {
/** IAM org slug (the `<org>` in `<org>-<app>`). */
readonly name: string
/** Human-facing name; falls back to `name` when unset. */
readonly displayName: string
}
/** A minimal project reference returned after creation. */
export interface ProjectRef {
readonly owner: string
readonly name: string
readonly displayName: string
readonly organization: string
}
/**
* Accumulated flow state. Each step writes its result here; the success
* screen and `onComplete` read it. Serializable so the host can persist a
* resume point if it wants (this pkg does not persist on its own).
*/
export interface OnboardingState {
/** Slug of the org the user landed in (chosen or created). */
readonly orgName?: string
/** Whether the org was freshly created in this flow (vs. pre-existing). */
readonly orgCreated?: boolean
/** Name of the project created in step 2, if any. */
readonly projectName?: string
/** Wallet address linked in step 3, if any. */
readonly walletAddress?: string
/** The data-sharing answer given in step 4 (true = opted in). */
readonly dataSharingConsent?: boolean
/**
* The payment choice made on the final step: a plan slug from the billing
* catalog, or the literal 'payg' for a prepaid pay-as-you-go balance.
*/
readonly planChoice?: string
}
/**
* One purchasable plan as the billing catalog serves it (GET /v1/billing/plans
* on the pay origin). Prices are the CATALOG's — this pkg never states one.
*/
export interface PlanInfo {
readonly slug: string
readonly name: string
readonly description?: string
/** Monthly price in CENTS (the catalog's `price` — 900 = $9/mo). */
readonly priceCents: number
/**
* Monthly-equivalent price in CENTS when billed annually (the catalog's
* `priceAnnual` — 825 = $8.25/mo ≈ $99/yr). Absent when the plan has no
* annual rate.
*/
readonly priceAnnualCents?: number
readonly popular?: boolean
}
/**
* User-record keys the onboarding persists under `Properties`. ONE writer
* (saveOnboarding) and one reader (readOnboarding); the names are part of the
* user record's public shape, so change them never.
*/
export const PROP_COMPLETED = 'onboarding.completedAt'
export const PROP_CONSENT = 'onboarding.dataSharingConsent'
export const PROP_PLAN = 'onboarding.plan'
/** Resolve a step descriptor by id. */
export function stepById(id: StepId): StepDesc | undefined {
return STEPS.find((s) => s.id === id)
}
/** The step that follows `id` in the linear flow (`done` is terminal). */
export function nextStep(id: StepId): StepId {
if (id === 'done') return 'done'
const i = STEPS.findIndex((s) => s.id === id)
if (i < 0 || i + 1 >= STEPS.length) return 'done'
return STEPS[i + 1]!.id
}
/** The step that precedes `id`, or undefined at the first step. */
export function prevStep(id: StepId): StepId | undefined {
const i = STEPS.findIndex((s) => s.id === id)
if (i <= 0) return undefined
return STEPS[i - 1]!.id
}
+32
View File
@@ -0,0 +1,32 @@
// @hanzo/id-onboarding — post-login onboarding for the Hanzo ID portal.
//
// Five-step flow: choose/create org → optional project → optional wallet
// link → data-sharing consent → plan or pay-as-you-go. White-labeled by the
// host's brand name. Domain (serializable types + step machine) / service
// (IAM-backed writes) / UI (self-contained flow) split. Auth lives in
// @hanzo/id-auth — import login/signup from there.
// ── Domain ──────────────────────────────────────────────────────
export {
STEPS,
stepById,
nextStep,
prevStep,
type StepId,
type StepDesc,
type OrgRef,
type ProjectRef,
type OnboardingState,
type PlanInfo,
} from './domain/types'
// ── Service ─────────────────────────────────────────────────────
export {
createOnboardingService,
type OnboardingService,
type OnboardingServiceOptions,
type Result,
} from './service/onboarding'
// ── UI ──────────────────────────────────────────────────────────
export { OnboardingFlow, type OnboardingFlowProps } from './ui/OnboardingFlow'
+252
View File
@@ -0,0 +1,252 @@
/**
* Onboarding unit tests — run with the Node built-in test runner and native
* TypeScript stripping (no test-framework dependency):
*
* node --test --experimental-strip-types src/onboarding.test.ts
*
* Covers the React-free surface: the domain step machine and the service's
* request shaping + IAM response translation (with an injected fake fetch).
*/
import { test } from 'vitest'
import assert from 'node:assert/strict'
import { STEPS, stepById, nextStep, prevStep } from './domain/types.ts'
import { createOnboardingService } from './service/onboarding.ts'
// ── Domain: step machine ────────────────────────────────────────────
test('step machine walks org → project → wallet → consent → plan → done', () => {
assert.equal(STEPS[0]!.id, 'org')
assert.equal(nextStep('org'), 'project')
assert.equal(nextStep('project'), 'wallet')
assert.equal(nextStep('wallet'), 'consent')
assert.equal(nextStep('consent'), 'plan')
assert.equal(nextStep('plan'), 'done')
assert.equal(nextStep('done'), 'done') // terminal is a fixpoint
})
test('prevStep is the inverse within the flow, undefined at the head', () => {
assert.equal(prevStep('org'), undefined)
assert.equal(prevStep('project'), 'org')
assert.equal(prevStep('wallet'), 'project')
assert.equal(prevStep('consent'), 'wallet')
assert.equal(prevStep('plan'), 'consent')
})
test('project and wallet are skippable; org, consent and plan are not', () => {
assert.equal(stepById('org')!.skippable, false)
assert.equal(stepById('project')!.skippable, true)
assert.equal(stepById('wallet')!.skippable, true)
// Consent needs an ANSWER (either answer) and plan is the prepay gate —
// neither may be walked past. plan is LAST so the choice hands straight
// off to the pay surface.
assert.equal(stepById('consent')!.skippable, false)
assert.equal(stepById('plan')!.skippable, false)
assert.equal(STEPS[STEPS.length - 1]!.id, 'plan')
})
// ── Service: fake-fetch harness ─────────────────────────────────────
interface Recorded {
url: string
method: string
body?: string
headers: Record<string, string>
}
/** Build a service whose fetch records calls and returns scripted JSON. */
function harness(script: (rec: Recorded) => { status?: number; json: unknown }) {
const calls: Recorded[] = []
const fetchImpl = (async (input: string | URL, init?: RequestInit) => {
const headers: Record<string, string> = {}
const h = init?.headers as Record<string, string> | undefined
if (h) for (const k of Object.keys(h)) headers[k] = h[k]!
const rec: Recorded = {
url: String(input),
method: init?.method ?? 'GET',
body: typeof init?.body === 'string' ? init.body : undefined,
headers,
}
calls.push(rec)
const { status = 200, json } = script(rec)
return new Response(JSON.stringify(json), {
status,
headers: { 'Content-Type': 'application/json' },
})
}) as unknown as typeof fetch
const service = createOnboardingService({
iamUrl: 'https://hanzo.id',
orgId: 'hanzo',
getAccessToken: () => 'tok-123',
fetchImpl,
})
return { service, calls }
}
test('listOrgs hits get-organizations with the bearer token and maps rows', async () => {
const { service, calls } = harness(() => ({
json: { status: 'ok', data: [{ name: 'hanzo', displayName: 'Hanzo' }, { name: 'acme' }] },
}))
const orgs = await service.listOrgs()
assert.equal(calls[0]!.url, 'https://hanzo.id/v1/iam/get-organizations')
assert.equal(calls[0]!.headers.Authorization, 'Bearer tok-123')
assert.deepEqual(orgs, [
{ name: 'hanzo', displayName: 'Hanzo' },
{ name: 'acme', displayName: 'acme' }, // displayName falls back to name
])
})
test('listOrgs decodes rows from the legacy data2 slot until IAM stops emitting it', async () => {
const { service } = harness(() => ({ json: { status: 'ok', data2: [{ name: 'acme' }] } }))
assert.deepEqual(await service.listOrgs(), [{ name: 'acme', displayName: 'acme' }])
})
test('listOrgs returns [] (not throw) on a server error', async () => {
const { service } = harness(() => ({ status: 500, json: { status: 'error', msg: 'boom' } }))
assert.deepEqual(await service.listOrgs(), [])
})
// Founding an org goes through the SELF-SERVICE front door, never the
// add-organization admin verb — that one is bearer-only entity CRUD filed under
// owner "admin", so a person founding their first org gets 401/403 there. This is
// the regression guard for the hanzo.id/onboarding "HTTP 401".
test('createOrg founds the org through /v1/iam/onboard, never the admin verb', async () => {
const ok = harness(() => ({ json: { org: 'acme', accessKey: 'pk-live-x' } }))
const res = await ok.service.createOrg({ name: 'acme', displayName: 'Acme Inc' })
assert.equal(ok.calls[0]!.url, 'https://hanzo.id/v1/iam/onboard')
assert.equal(ok.calls[0]!.method, 'POST')
assert.ok(!ok.calls.some((c) => c.url.includes('add-organization')))
// The DISPLAY name is what travels: the server owns the slug policy.
assert.deepEqual(JSON.parse(ok.calls[0]!.body!), { name: 'Acme Inc' })
// …and the slug it answers with is authoritative, not the client's guess.
assert.deepEqual(res, { ok: true, value: { name: 'acme', displayName: 'Acme Inc' } })
})
test('createOrg carries BOTH credentials — the portal session mints no bearer', async () => {
const { service, calls } = harness(() => ({ json: { org: 'acme' } }))
await service.createOrg({ name: 'acme', displayName: 'Acme Inc' })
assert.equal(calls[0]!.headers.Authorization, 'Bearer tok-123')
assert.equal(calls[0]!.headers['Content-Type'], 'application/json')
})
test('createOrg surfaces the front doors own error text, not a bare HTTP code', async () => {
const taken = harness(() => ({ status: 409, json: { error: 'the organization "acme" already exists' } }))
assert.deepEqual(await taken.service.createOrg({ name: 'acme', displayName: 'Acme' }), {
ok: false,
error: 'the organization "acme" already exists',
})
const anon = harness(() => ({ status: 401, json: { error: 'please sign in first' } }))
assert.deepEqual(await anon.service.createOrg({ name: 'x', displayName: 'X' }), {
ok: false,
error: 'please sign in first',
})
})
test('linkWallet rejects a malformed address before any network call', async () => {
const { service, calls } = harness(() => ({ json: { status: 'ok' } }))
const res = await service.linkWallet('not-an-address')
assert.deepEqual(res, { ok: false, error: 'invalid wallet address' })
assert.equal(calls.length, 0)
})
// IAM's update-user is a FULL-ROW write that ignores `columns=` — a minimal
// body silently blanks every field it omits. So the contract under test is
// read-merge-write: the row that comes back from get-account goes back OUT
// with only the mutation applied. This is the regression guard for the wallet
// step wiping displayName/email on every link.
test('linkWallet reads the full row and writes it back with web3onboard merged in', async () => {
const addr = '0x' + 'a'.repeat(40)
const { service, calls } = harness((rec) => {
if (rec.url.includes('get-account'))
return {
json: {
status: 'ok',
data: { owner: 'hanzo', name: 'alice', displayName: 'Alice', email: 'alice@hanzo.ai' },
},
}
return { json: { status: 'ok' } }
})
const res = await service.linkWallet(addr)
assert.deepEqual(res, { ok: true, value: addr })
// 1) get-account, 2) update-user keyed by owner/name with the WHOLE row
assert.match(calls[0]!.url, /get-account$/)
const upd = calls[1]!
assert.ok(upd.url.includes('/v1/iam/update-user'))
assert.ok(upd.url.includes('id=hanzo%2Falice') || upd.url.includes('id=hanzo/alice'))
const sent = JSON.parse(upd.body!)
assert.equal(sent.web3onboard, addr)
assert.equal(sent.owner, 'hanzo')
assert.equal(sent.name, 'alice')
// The fields the mutation did not touch MUST survive the round trip.
assert.equal(sent.displayName, 'Alice')
assert.equal(sent.email, 'alice@hanzo.ai')
})
test('saveOnboarding merges properties without dropping existing ones; readOnboarding decodes them', async () => {
const { service, calls } = harness((rec) => {
if (rec.url.includes('get-account'))
return {
json: {
status: 'ok',
data: {
owner: 'hanzo',
name: 'alice',
properties: { 'onboarding.dataSharingConsent': 'true', unrelated: 'kept' },
},
},
}
return { json: { status: 'ok' } }
})
const res = await service.saveOnboarding({ plan: 'pro', completedAt: '2026-08-04T00:00:00Z' })
assert.deepEqual(res, { ok: true, value: true })
const sent = JSON.parse(calls[1]!.body!)
assert.deepEqual(sent.properties, {
'onboarding.dataSharingConsent': 'true',
unrelated: 'kept',
'onboarding.plan': 'pro',
'onboarding.completedAt': '2026-08-04T00:00:00Z',
})
})
test('readOnboarding reports null completedAt/consent/plan for a fresh user', async () => {
const { service } = harness(() => ({ json: { status: 'ok', data: { owner: 'hanzo', name: 'bob' } } }))
assert.deepEqual(await service.readOnboarding(), { completedAt: null, consent: null, plan: null })
})
// The live catalog prices in CENTS (go=900 means $9/mo, priceAnnual=825 means
// $8.25/mo billed annually) and carries other product lines (dns-*) in the
// same list. This test pins both facts with production-shaped rows.
test('listPlans keeps cents unscaled, keeps personal+team only; [] on failure', async () => {
const { service, calls } = harness(() => ({
json: [
{ slug: 'pro', name: 'Pro', category: 'personal', price: 4900, priceAnnual: 4150, popular: true },
{ slug: 'go', name: 'Go', category: 'personal', price: 900, priceAnnual: 825 },
{ slug: 'team', name: 'Team', category: 'team', price: 2500, priceAnnual: 2000 },
{ slug: 'dns-pro', name: 'DNS Pro', category: 'dns', price: 500 }, // other product line → dropped
{ slug: 'enterprise', name: 'Enterprise', category: 'enterprise', price: 0 }, // not self-serve → dropped
{ slug: '', name: 'broken', category: 'personal', price: 500 }, // no slug → dropped
],
}))
const plans = await service.listPlans('https://pay.hanzo.ai/')
assert.equal(calls[0]!.url, 'https://pay.hanzo.ai/v1/billing/plans')
assert.deepEqual(
plans.map((p) => p.slug),
['pro', 'go', 'team'],
)
assert.equal(plans[0]!.priceCents, 4900)
assert.equal(plans[0]!.priceAnnualCents, 4150)
assert.equal(plans[0]!.popular, true)
const down = harness(() => ({ status: 503, json: { error: 'nope' } }))
assert.deepEqual(await down.service.listPlans('https://pay.hanzo.ai'), [])
})
test('linkWallet fails closed when there is no signed-in user', async () => {
const addr = '0x' + 'b'.repeat(40)
const { service } = harness((rec) => {
if (rec.url.includes('get-account')) return { status: 401, json: { status: 'error', msg: 'not signed in' } }
return { json: { status: 'ok' } }
})
assert.deepEqual(await service.linkWallet(addr), { ok: false, error: 'not signed in' })
})
+335
View File
@@ -0,0 +1,335 @@
/**
* Onboarding service — the IAM-backed implementation of the org/project/
* wallet flow.
*
* One way: every write goes through the canonical IAM REST surface under
* `/v1/iam/*` (the same IAM paths the auth client uses). There is no separate
* onboarding backend — the org and project records live in IAM, which is the
* identity registry.
*
* listOrgs() GET /v1/iam/get-organizations (user-scoped server-side)
* createOrg() POST /v1/iam/onboard (the self-service front door)
* createProject POST /v1/iam/add-project
* linkWallet() client-side wallet connect → IAM update-user (host-driven)
*
* Founding an org goes through `onboard`, NOT the `add-organization` admin verb.
* They are different doors: add-organization is entity CRUD behind IAM's
* authenticated Guard, filed under owner "admin", and a human may only write an
* org row named after the org they are already in — so a person founding their
* FIRST org is refused there by construction (403), and with no bearer at all the
* Guard refuses before that (401). `onboard` is the door built for this: it
* resolves the caller from their own session or bearer and provisions the whole
* org — org stamped with them as Founder, them moved in as its owner, one
* metered API key — under their own authority as its founder.
*
* Both credentials are offered on every call: `credentials: 'include'` for the
* portal session cookie (a bare portal sign-in mints NO bearer, which is why the
* bearer-only door 401'd), and `Authorization` when the host does hold a token.
* IAM resolves session first, then bearer.
*/
import type { Project } from '@hanzo/iam'
import {
PROP_COMPLETED,
PROP_CONSENT,
PROP_PLAN,
type OrgRef,
type PlanInfo,
type ProjectRef,
} from '../domain/types'
/** Result of a write that can fail gracefully (no throw on expected errors). */
export type Result<T> = { readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: string }
export interface OnboardingService {
/**
* List organizations the signed-in user can land in. IAM scopes
* `get-organizations` to the caller's memberships server-side from the
* bearer token. Returns [] (not an error) when the user belongs to none.
*/
listOrgs(): Promise<OrgRef[]>
/** Create a new organization owned by the user. */
createOrg(input: { name: string; displayName: string }): Promise<Result<OrgRef>>
/** Create a project inside `organization`. */
createProject(input: { organization: string; name: string; displayName: string }): Promise<Result<ProjectRef>>
/**
* Attach a wallet address to the signed-in user (IAM `update-user`,
* `web3Onboard` address field). The actual wallet connect happens in the
* browser via the host-supplied `connectWallet`; this only persists the
* resulting address.
*/
linkWallet(address: string): Promise<Result<string>>
/**
* Read the persisted onboarding record from the signed-in user's
* `properties`. All-null when the user has never completed onboarding —
* which is the ONLY case the host should mount the flow for.
*/
readOnboarding(): Promise<{ completedAt: string | null; consent: boolean | null; plan: string | null }>
/**
* Persist onboarding fields onto the user record, read-merge-write. THIS is
* what stops the flow repeating: completion lives on the USER, not in any
* browser storage, so a new device, a cleared cache and a re-login all see
* it done.
*/
saveOnboarding(patch: { completedAt?: string; consent?: boolean; plan?: string }): Promise<Result<true>>
/**
* List purchasable plans from the billing catalog on the PAY origin. The
* catalog is the only price authority — this pkg renders what it serves and
* states no price of its own. Returns [] on any failure; the plan step then
* offers the two choices without a price grid.
*/
listPlans(payUrl: string): Promise<PlanInfo[]>
}
export interface OnboardingServiceOptions {
/** IAM origin, no trailing slash (the org's `iamUrl`, i.e. hanzo.id). */
readonly iamUrl: string
/** Owning org slug used as the default `owner` for new records. */
readonly orgId: string
/** Bearer-token provider; resolves null when no session is present. */
readonly getAccessToken: () => Promise<string | null> | string | null
/** Override fetch (testing). Defaults to global fetch. */
readonly fetchImpl?: typeof fetch
}
const trimSlash = (s: string): string => s.replace(/\/+$/, '')
export function createOnboardingService(opts: OnboardingServiceOptions): OnboardingService {
const base = trimSlash(opts.iamUrl)
const f = opts.fetchImpl ?? fetch
async function authHeaders(json = true): Promise<HeadersInit> {
const token = await opts.getAccessToken()
const h: Record<string, string> = { Accept: 'application/json' }
if (json) h['Content-Type'] = 'application/json'
if (token) h.Authorization = `Bearer ${token}`
return h
}
async function listOrgs(): Promise<OrgRef[]> {
const url = new URL('/v1/iam/get-organizations', base)
let body: Record<string, unknown>
try {
const res = await f(url.toString(), { headers: await authHeaders(false), credentials: 'include' })
if (!res.ok) return []
body = (await res.json()) as Record<string, unknown>
} catch {
return []
}
const rows = extractRows(body)
return rows.map(toOrgRef).filter((o): o is OrgRef => o !== null)
}
/**
* Found the caller's own organization through the self-service front door.
*
* The server owns the slug: it derives it from the display name under the ONE
* policy every surface shares, so the returned `org` is authoritative and the
* client's slug preview is only a preview. It answers `{org}` on success and
* `{error}` with a 4xx/5xx on failure — not the casibase `{status,msg}`
* envelope the entity CRUD returns — so read it directly.
*/
async function createOrg(input: { name: string; displayName: string }): Promise<Result<OrgRef>> {
const url = new URL('/v1/iam/onboard', base)
const displayName = input.displayName || input.name
try {
const res = await f(url.toString(), {
method: 'POST',
headers: await authHeaders(),
credentials: 'include',
body: JSON.stringify({ name: displayName }),
})
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>
if (!res.ok) {
const msg = typeof body.error === 'string' && body.error ? body.error : `HTTP ${res.status}`
return { ok: false, error: msg }
}
const org = typeof body.org === 'string' ? body.org : ''
if (!org) return { ok: false, error: 'request failed' }
return { ok: true, value: { name: org, displayName } }
} catch (e) {
return { ok: false, error: String(e) }
}
}
async function createProject(input: {
organization: string
name: string
displayName: string
}): Promise<Result<ProjectRef>> {
const url = new URL('/v1/iam/add-project', base)
const project: Partial<Project> = {
owner: input.organization,
name: input.name,
displayName: input.displayName,
organization: input.organization,
isDefault: false,
}
return writeRecord(url, project, () => ({
owner: input.organization,
name: input.name,
displayName: input.displayName,
organization: input.organization,
}))
}
async function linkWallet(address: string): Promise<Result<string>> {
const trimmed = address.trim()
if (!isHexAddress(trimmed)) return { ok: false, error: 'invalid wallet address' }
const res = await updateSelf((row) => {
row.web3onboard = trimmed
})
return res.ok ? { ok: true, value: trimmed } : res
}
async function readOnboarding(): Promise<{
completedAt: string | null
consent: boolean | null
plan: string | null
}> {
const row = await getAccount()
const props = (row?.properties ?? {}) as Record<string, unknown>
const str = (k: string): string | null => (typeof props[k] === 'string' && props[k] ? (props[k] as string) : null)
const consentRaw = str(PROP_CONSENT)
return {
completedAt: str(PROP_COMPLETED),
consent: consentRaw === null ? null : consentRaw === 'true',
plan: str(PROP_PLAN),
}
}
async function saveOnboarding(patch: {
completedAt?: string
consent?: boolean
plan?: string
}): Promise<Result<true>> {
const res = await updateSelf((row) => {
const props = { ...((row.properties as Record<string, string> | undefined) ?? {}) }
if (patch.completedAt !== undefined) props[PROP_COMPLETED] = patch.completedAt
if (patch.consent !== undefined) props[PROP_CONSENT] = String(patch.consent)
if (patch.plan !== undefined) props[PROP_PLAN] = patch.plan
row.properties = props
})
return res.ok ? { ok: true, value: true } : res
}
async function listPlans(payUrl: string): Promise<PlanInfo[]> {
try {
const res = await f(trimSlash(payUrl) + '/v1/billing/plans', { headers: { Accept: 'application/json' } })
if (!res.ok) return []
const body = (await res.json()) as unknown
const rows = Array.isArray(body) ? body : []
return rows
.filter((r): r is Record<string, unknown> => typeof r === 'object' && r !== null)
// Onboarding offers the account plans; other product lines in the same
// catalog (dns-*, enterprise) have their own surfaces.
.filter((r) => r.category === 'personal' || r.category === 'team')
.map((r) => ({
slug: typeof r.slug === 'string' ? r.slug : '',
name: typeof r.name === 'string' ? r.name : '',
description: typeof r.description === 'string' ? r.description : undefined,
// Catalog prices are CENTS (900 = $9/mo); passed through unscaled.
priceCents: typeof r.price === 'number' ? r.price : NaN,
priceAnnualCents: typeof r.priceAnnual === 'number' && r.priceAnnual > 0 ? r.priceAnnual : undefined,
popular: r.popular === true,
}))
.filter((p) => p.slug && p.name && Number.isFinite(p.priceCents) && p.priceCents > 0)
} catch {
return []
}
}
/**
* Read-merge-write the signed-in user's FULL row. IAM's update-user is a
* FULL-ROW write (internal/users Update: "this is a full-row write") and it
* ignores the v1 `columns=` scoping param — so a minimal body silently
* blanks every field it omits. The wallet step used to do exactly that,
* wiping displayName/email on every link. Every self-write goes through
* here now: fetch the row, mutate, post the whole thing back.
*/
async function updateSelf(mutate: (row: Record<string, unknown>) => void): Promise<Result<true>> {
const row = await getAccount()
if (!row) return { ok: false, error: 'not signed in' }
const owner = typeof row.owner === 'string' ? row.owner : ''
const name = typeof row.name === 'string' ? row.name : ''
if (!owner || !name) return { ok: false, error: 'not signed in' }
mutate(row)
row.owner = owner
row.name = name
const url = new URL('/v1/iam/update-user', base)
url.searchParams.set('id', `${owner}/${name}`)
try {
const res = await f(url.toString(), {
method: 'POST',
headers: await authHeaders(),
credentials: 'include',
body: JSON.stringify(row),
})
if (!res.ok) return { ok: false, error: `HTTP ${res.status}` }
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>
if (body.status === 'error') return { ok: false, error: msgOf(body) }
return { ok: true, value: true }
} catch (e) {
return { ok: false, error: String(e) }
}
}
/** Read the signed-in user's FULL row from `/v1/iam/get-account`. */
async function getAccount(): Promise<Record<string, unknown> | null> {
const url = new URL('/v1/iam/get-account', base)
try {
const res = await f(url.toString(), { headers: await authHeaders(false), credentials: 'include' })
if (!res.ok) return null
const body = (await res.json()) as Record<string, unknown>
const data = (body.data ?? body) as Record<string, unknown>
if (typeof data !== 'object' || data === null) return null
return data
} catch {
return null
}
}
async function writeRecord<T>(
url: URL,
payload: unknown,
onOk: () => T,
): Promise<Result<T>> {
try {
const res = await f(url.toString(), {
method: 'POST',
headers: await authHeaders(),
credentials: 'include',
body: JSON.stringify(payload),
})
if (!res.ok) return { ok: false, error: `HTTP ${res.status}` }
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>
if (body.status === 'error') return { ok: false, error: msgOf(body) }
return { ok: true, value: onOk() }
} catch (e) {
return { ok: false, error: String(e) }
}
}
return { listOrgs, createOrg, createProject, linkWallet, readOnboarding, saveOnboarding, listPlans }
}
/** Rows of an IAM list response: the named `data` slot, falling back to the legacy `data2` slot until IAM stops emitting it. */
function extractRows(body: Record<string, unknown>): Record<string, unknown>[] {
const candidate = Array.isArray(body.data) ? body.data : Array.isArray(body.data2) ? body.data2 : []
return candidate.filter((r): r is Record<string, unknown> => typeof r === 'object' && r !== null)
}
function toOrgRef(row: Record<string, unknown>): OrgRef | null {
const name = typeof row.name === 'string' ? row.name : ''
if (!name) return null
const displayName = typeof row.displayName === 'string' && row.displayName ? row.displayName : name
return { name, displayName }
}
function msgOf(body: Record<string, unknown>): string {
return typeof body.msg === 'string' && body.msg ? body.msg : 'request failed'
}
/** EIP-55-agnostic 0x-prefixed 20-byte address check. */
function isHexAddress(s: string): boolean {
return /^0x[0-9a-fA-F]{40}$/.test(s)
}
+604
View File
@@ -0,0 +1,604 @@
import { useCallback, useEffect, useReducer, useState, type FormEvent } from 'react'
import {
STEPS,
nextStep,
prevStep,
stepById,
type OnboardingState,
type PlanInfo,
type StepId,
} from '../domain/types'
import type { OnboardingService } from '../service/onboarding'
/**
* Post-login onboarding flow.
*
* A self-contained three-step wizard (org → project → wallet) driven by an
* internal step machine — no router lib, consistent with the rest of the
* portal which routes on `window.location` and keeps page-local state in
* React. The host renders this once after login and gets the accumulated
* {@link OnboardingState} back via `onComplete`.
*
* White-label: all copy comes from the domain `STEPS` table + the `brandName`
* prop. No brand-specific strings live in this component. Styling reuses the
* portal's `hanzo-id-*` classes (defined in the web app's app.css).
*/
export interface OnboardingFlowProps {
readonly service: OnboardingService
/** Brand display name for headings (e.g. the resolved org brand). */
readonly brandName: string
/**
* Host-supplied wallet connector. Returns the connected address (0x…) or
* null if the user cancels. Kept as a prop so this pkg stays free of any
* specific wallet library — the host wires Web3Onboard / wagmi / window
* .ethereum. When omitted, the wallet step shows a "not available" note
* and can only be skipped.
*/
readonly connectWallet?: () => Promise<string | null>
/** Called once the flow reaches `done`, with the final accumulated state. */
readonly onComplete: (state: OnboardingState) => void
/**
* Pay origin serving the billing catalog (GET /v1/billing/plans). The plan
* step renders the catalog's own prices — no price is stated here.
*/
readonly payUrl: string
}
interface FlowState {
readonly step: StepId
readonly data: OnboardingState
}
type FlowAction =
| { type: 'advance'; patch: Partial<OnboardingState> }
| { type: 'back' }
function reducer(state: FlowState, action: FlowAction): FlowState {
switch (action.type) {
case 'advance':
return { step: nextStep(state.step), data: { ...state.data, ...action.patch } }
case 'back': {
const prev = prevStep(state.step)
return prev ? { ...state, step: prev } : state
}
}
}
export function OnboardingFlow({ service, brandName, connectWallet, onComplete, payUrl }: OnboardingFlowProps) {
const [state, dispatch] = useReducer(reducer, { step: 'org', data: {} })
// Terminal step: hand the accumulated state back to the host exactly once.
useEffect(() => {
if (state.step === 'done') onComplete(state.data)
}, [state.step, state.data, onComplete])
const advance = useCallback((patch: Partial<OnboardingState>) => dispatch({ type: 'advance', patch }), [])
const back = useCallback(() => dispatch({ type: 'back' }), [])
const desc = stepById(state.step)
const stepIndex = STEPS.findIndex((s) => s.id === state.step)
const showBack = stepIndex > 0
return (
<div className="hanzo-id-onboarding">
{state.step !== 'done' && desc ? (
<>
<StepDots active={stepIndex} total={STEPS.length} />
<header className="hanzo-id-onboarding-head">
<h1>{desc.title}</h1>
<p className="lede">{desc.byline}</p>
</header>
</>
) : null}
{state.step === 'org' ? (
<OrgStep service={service} onNext={advance} />
) : null}
{state.step === 'project' ? (
<ProjectStep
service={service}
orgName={state.data.orgName}
showBack={showBack}
onBack={back}
onNext={advance}
/>
) : null}
{state.step === 'wallet' ? (
<WalletStep
service={service}
connectWallet={connectWallet}
showBack={showBack}
onBack={back}
onNext={advance}
/>
) : null}
{state.step === 'consent' ? (
<ConsentStep service={service} showBack={showBack} onBack={back} onNext={advance} />
) : null}
{state.step === 'plan' ? (
<PlanStep service={service} payUrl={payUrl} showBack={showBack} onBack={back} onNext={advance} />
) : null}
{state.step === 'done' ? <DoneStep brandName={brandName} data={state.data} /> : null}
</div>
)
}
/** Linear progress dots. */
function StepDots({ active, total }: { active: number; total: number }) {
return (
<div className="hanzo-id-stepdots" role="progressbar" aria-valuenow={active + 1} aria-valuemax={total}>
{Array.from({ length: total }, (_, i) => (
<span key={i} className={i <= active ? 'on' : ''} aria-hidden />
))}
</div>
)
}
// ── Step 1: organization ────────────────────────────────────────────
function OrgStep({
service,
onNext,
}: {
service: OnboardingService
onNext: (patch: Partial<OnboardingState>) => void
}) {
const [displayName, setDisplayName] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
async function create(e: FormEvent) {
e.preventDefault()
const name = slugify(displayName)
if (!name) {
setError('Enter an organization name.')
return
}
setBusy(true)
setError(null)
const res = await service.createOrg({ name, displayName: displayName.trim() })
setBusy(false)
if (!res.ok) {
setError(humanizeError(res.error))
return
}
onNext({ orgName: res.value.name, orgCreated: true })
}
// Onboarding never lists other orgs' organizations — a brand-new user only
// ever creates their own org or skips. Listing the org directory would leak
// every org's name to anyone who signs up. Joining an existing org happens
// by invitation, handled outside this flow.
return (
<div className="hanzo-id-onboarding-body">
<form onSubmit={create} className="hanzo-id-form" aria-busy={busy}>
<label className="hanzo-id-field">
<span>Organization name</span>
<input
className="hanzo-id-input"
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Acme Inc"
autoFocus
required
/>
</label>
{displayName ? <p className="hanzo-id-slug-preview">slug: {slugify(displayName) || '—'}</p> : null}
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
<div className="hanzo-id-onboarding-actions">
<button type="button" className="hanzo-id-btn ghost" onClick={() => onNext({})} disabled={busy}>
Skip for now
</button>
<button type="submit" className="hanzo-id-btn" disabled={busy}>
{busy ? 'Creating…' : 'Create organization'}
</button>
</div>
</form>
</div>
)
}
// ── Step 2: project (optional) ──────────────────────────────────────
function ProjectStep({
service,
orgName,
showBack,
onBack,
onNext,
}: {
service: OnboardingService
orgName?: string
showBack: boolean
onBack: () => void
onNext: (patch: Partial<OnboardingState>) => void
}) {
const [displayName, setDisplayName] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
async function create(e: FormEvent) {
e.preventDefault()
if (!orgName) return // can't create a project without a home org
const name = slugify(displayName)
if (!name) {
setError('Enter a project name.')
return
}
setBusy(true)
setError(null)
const res = await service.createProject({ organization: orgName, name, displayName: displayName.trim() })
setBusy(false)
if (!res.ok) {
setError(humanizeError(res.error))
return
}
onNext({ projectName: res.value.name })
}
// No org was chosen (org step skipped) — a project needs a home org, so
// offer only to continue.
if (!orgName) {
return (
<div className="hanzo-id-onboarding-body">
<p className="hanzo-id-info">Choose an organization first to create a project. You can do this later.</p>
<div className="hanzo-id-onboarding-actions">
{showBack ? (
<button type="button" className="hanzo-id-btn ghost" onClick={onBack}>
Back
</button>
) : null}
<button type="button" className="hanzo-id-btn" onClick={() => onNext({})}>
Continue
</button>
</div>
</div>
)
}
return (
<div className="hanzo-id-onboarding-body">
<form onSubmit={create} className="hanzo-id-form" aria-busy={busy}>
<label className="hanzo-id-field">
<span>Project name</span>
<input
className="hanzo-id-input"
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Production"
autoFocus
/>
</label>
{displayName ? <p className="hanzo-id-slug-preview">slug: {slugify(displayName) || '—'}</p> : null}
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
<div className="hanzo-id-onboarding-actions">
{showBack ? (
<button type="button" className="hanzo-id-btn ghost" onClick={onBack}>
Back
</button>
) : null}
<button type="button" className="hanzo-id-btn ghost" onClick={() => onNext({})} disabled={busy}>
Skip
</button>
<button type="submit" className="hanzo-id-btn" disabled={busy}>
{busy ? 'Creating…' : 'Create project'}
</button>
</div>
</form>
</div>
)
}
// ── Step 3: wallet (optional) ───────────────────────────────────────
function WalletStep({
service,
connectWallet,
showBack,
onBack,
onNext,
}: {
service: OnboardingService
connectWallet?: () => Promise<string | null>
showBack: boolean
onBack: () => void
onNext: (patch: Partial<OnboardingState>) => void
}) {
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
async function link() {
if (!connectWallet) return
setBusy(true)
setError(null)
try {
const address = await connectWallet()
if (!address) {
setBusy(false)
return // user cancelled the wallet prompt
}
const res = await service.linkWallet(address)
setBusy(false)
if (!res.ok) {
setError(res.error)
return
}
onNext({ walletAddress: res.value })
} catch (e) {
setBusy(false)
setError(String(e))
}
}
return (
<div className="hanzo-id-onboarding-body">
{connectWallet ? null : (
<p className="hanzo-id-info">Wallet linking isnt available here. You can add one later in settings.</p>
)}
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
<div className="hanzo-id-onboarding-actions">
{showBack ? (
<button type="button" className="hanzo-id-btn ghost" onClick={onBack}>
Back
</button>
) : null}
<button type="button" className="hanzo-id-btn ghost" onClick={() => onNext({})} disabled={busy}>
Skip
</button>
{connectWallet ? (
<button type="button" className="hanzo-id-btn" onClick={link} disabled={busy}>
{busy ? 'Connecting…' : 'Connect wallet'}
</button>
) : null}
</div>
</div>
)
}
// ── Step 4: data-sharing consent ────────────────────────────────────
function ConsentStep({
service,
showBack,
onBack,
onNext,
}: {
service: OnboardingService
showBack: boolean
onBack: () => void
onNext: (patch: Partial<OnboardingState>) => void
}) {
const [agreed, setAgreed] = useState(false)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
// Either answer continues; the answer itself is what must exist. It is
// persisted on the USER (not browser storage) before the flow advances, so
// this page is asked exactly once per account, ever.
async function answer() {
setBusy(true)
setError(null)
const res = await service.saveOnboarding({ consent: agreed })
setBusy(false)
if (!res.ok) {
setError(res.error)
return
}
onNext({ dataSharingConsent: agreed })
}
return (
<div className="hanzo-id-onboarding-body">
<div className="hanzo-id-consent">
<p>
Sharing usage data helps improve the models and products you use. It
covers product usage patterns and diagnostics never the content of
your conversations, code, or files. You can change this any time in
account settings.
</p>
<label className="hanzo-id-consent-check">
<input
type="checkbox"
checked={agreed}
onChange={(e) => setAgreed(e.target.checked)}
/>
<span>I agree to share usage data to improve products and models.</span>
</label>
</div>
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
<div className="hanzo-id-onboarding-actions">
{showBack ? (
<button type="button" className="hanzo-id-btn ghost" onClick={onBack} disabled={busy}>
Back
</button>
) : null}
<button type="button" className="hanzo-id-btn" onClick={answer} disabled={busy}>
{busy ? 'Saving…' : 'Continue'}
</button>
</div>
</div>
)
}
// ── Step 5 (last): plan or pay-as-you-go ────────────────────────────
/** Format catalog CENTS as dollars — "$9" or "$8.25", never "$9.00". */
function usd(cents: number): string {
const dollars = cents / 100
return Number.isInteger(dollars) ? `$${dollars}` : `$${dollars.toFixed(2)}`
}
function PlanStep({
service,
payUrl,
showBack,
onBack,
onNext,
}: {
service: OnboardingService
payUrl: string
showBack: boolean
onBack: () => void
onNext: (patch: Partial<OnboardingState>) => void
}) {
const [plans, setPlans] = useState<PlanInfo[] | null>(null)
const [busy, setBusy] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let alive = true
service.listPlans(payUrl).then((p) => {
if (alive) setPlans(p)
})
return () => {
alive = false
}
// payUrl is fixed for the page's life.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// The choice is persisted (with completion) BEFORE the flow advances, so a
// user who bounces off the payment page still never re-enters onboarding —
// they land on the portal, where the top-up surface remains one click away.
async function choose(choice: string) {
setBusy(choice)
setError(null)
const res = await service.saveOnboarding({
plan: choice,
completedAt: new Date().toISOString(),
})
setBusy(null)
if (!res.ok) {
setError(res.error)
return
}
onNext({ planChoice: choice })
}
return (
<div className="hanzo-id-onboarding-body">
{plans === null ? (
<p className="lede">Loading plans</p>
) : (
<div className="hanzo-id-plans" role="list">
{plans.length === 0 ? (
// The catalog fetch failed or came back empty. Say so — a plan
// picker showing ONLY pay-as-you-go with no explanation reads as
// "there are no plans", which is false. Pay as you go still works,
// and plans remain choosable later from billing.
<p role="alert" className="hanzo-id-plans-empty">
Plans are unavailable right now you can start with pay as you
go and pick a plan later from Billing.
</p>
) : null}
{plans.map((p) => (
<button
key={p.slug}
type="button"
role="listitem"
className={p.popular ? 'hanzo-id-plan popular' : 'hanzo-id-plan'}
onClick={() => choose(p.slug)}
disabled={busy !== null}
aria-busy={busy === p.slug}
>
{p.popular ? <span className="hanzo-id-plan-badge">Popular</span> : null}
<span className="hanzo-id-plan-name">{p.name}</span>
<span className="hanzo-id-plan-price">
{usd(p.priceCents)}/mo
{p.priceAnnualCents ? <em> · {usd(p.priceAnnualCents * 12)}/yr billed annually</em> : null}
</span>
{p.description ? <span className="hanzo-id-plan-desc">{p.description}</span> : null}
</button>
))}
<button
type="button"
role="listitem"
className="hanzo-id-plan payg"
onClick={() => choose('payg')}
disabled={busy !== null}
aria-busy={busy === 'payg'}
>
<span className="hanzo-id-plan-name">Pay as you go</span>
<span className="hanzo-id-plan-price">Prepaid balance · $5 minimum</span>
<span className="hanzo-id-plan-desc">
No subscription. Top up a balance and pay only for what you use.
</span>
</button>
</div>
)}
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
{showBack ? (
<div className="hanzo-id-onboarding-actions">
<button type="button" className="hanzo-id-btn ghost" onClick={onBack} disabled={busy !== null}>
Back
</button>
</div>
) : null}
</div>
)
}
// ── Terminal: success ───────────────────────────────────────────────
function DoneStep({ brandName, data }: { brandName: string; data: OnboardingState }) {
return (
<div className="hanzo-id-onboarding-done">
<h1>Youre all set</h1>
<p className="lede">Welcome to {brandName}.</p>
<dl className="hanzo-id-summary">
{data.orgName ? (
<>
<dt>Organization</dt>
<dd>{data.orgName}</dd>
</>
) : null}
{data.projectName ? (
<>
<dt>Project</dt>
<dd>{data.projectName}</dd>
</>
) : null}
{data.walletAddress ? (
<>
<dt>Wallet</dt>
<dd>{shortAddr(data.walletAddress)}</dd>
</>
) : null}
</dl>
</div>
)
}
/**
* Map raw IAM errors to a human sentence. Org/project creation is admin-gated
* in IAM authz (`add-organization` requires the `admin` role; `add-project`
* default-denies for non-admins), so a normal member hits a permission error
* — say so plainly instead of leaking an HTTP code, and the step stays
* skippable so onboarding never hard-blocks.
*/
function humanizeError(raw: string): string {
const lower = raw.toLowerCase()
if (lower.includes('403') || lower.includes('permission') || lower.includes('not allowed') || lower.includes('unauthorized')) {
return 'You dont have permission to create this here. Pick an existing organization, or ask an admin to invite you.'
}
if (lower.includes('already') || lower.includes('exist') || lower.includes('conflict') || lower.includes('409')) {
return 'That name is taken. Try a different one.'
}
return raw
}
/** Lower-kebab a display name into an org/project slug. */
function slugify(s: string): string {
return s
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 40)
}
function shortAddr(a: string): string {
return a.length > 12 ? `${a.slice(0, 6)}${a.slice(-4)}` : a
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"noEmit": true
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}
+9 -4
View File
@@ -1,23 +1,28 @@
{
"name": "@hanzo/id-shared",
"version": "0.1.0",
"description": "Shared types + tenant resolver for the Hanzo ID portal. No UI deps.",
"version": "0.1.4",
"description": "Shared types + org resolver for the Hanzo ID portal. No UI deps.",
"license": "BSD-3-Clause",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./tenant": "./src/tenant.ts",
"./org": "./src/org.ts",
"./brand": "./src/brand.ts",
"./package.json": "./package.json"
},
"files": ["src"],
"files": [
"src"
],
"scripts": {
"tc": "tsc --noEmit",
"build": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^5.9.3"
},
"dependencies": {
"@hanzo/brand": "^1.4.0"
}
}
+46 -28
View File
@@ -1,7 +1,7 @@
import type { BrandContract } from './types'
/**
* Resolve a BrandContract from a tenant's brand package.
* Resolve a BrandContract from a org's brand package.
*
* Each per-org brand pkg (`@hanzo/brand`, `@luxfi/brand`, `@zooai/brand`,
* `@parsdao/brand`) ships a `brand.json` at the package root. This loader
@@ -13,31 +13,29 @@ import type { BrandContract } from './types'
* Vite plugin or the Express static serves the assets from each pkg's
* `assets/` directory at this path).
*/
export async function loadBrand(brandPackage: string, brandUrl?: string): Promise<BrandContract> {
// Browser: try the tenant's brandUrl (e.g. the jsDelivr-hosted brand.json
// from config.json) first, then the app-local /brand/<pkg>/brand.json.
//
// Branding is cosmetic — it must NEVER block the login form. The SPA server
// answers unknown paths with index.html (HTTP 200, text/html) for client-
// side routing, so a missing brand.json comes back as HTML, not a 404. We
// therefore reject any non-JSON response and, if every candidate fails,
// return a minimal fallback brand so the app still renders. (Previously this
// did `res.json()` on the HTML fallback → "Unexpected token '<'" → blank page.)
export async function loadBrand(brandPackage: string): Promise<BrandContract> {
// Browser: served by the app from /brand/<pkg>/brand.json. The brand is
// purely cosmetic, so a transient fetch failure must NEVER blank the login
// form. Retry the (occasionally 502-flaky) asset a few times, then fall back
// to a neutral brand so the form always renders.
if (typeof window !== 'undefined') {
const candidates = [brandUrl, `/brand/${brandPackage}/brand.json`].filter(
(u): u is string => typeof u === 'string' && u.length > 0,
)
for (const url of candidates) {
// Flat, encoding-safe path emitted by the Vite brandJsonPlugin:
// `@hanzo/brand` -> `/brand/hanzo.json`. A nested `@scope/brand/brand.json`
// URL cannot be served by the production static server (literal `@` +
// encoded `%2F` miss the on-disk file -> SPA catch-all returns index.html).
const slug = brandPackage.replace(/^@/, '').split('/')[0] ?? 'hanzo'
const url = `/brand/${slug}.json`
for (let attempt = 0; attempt < 3; attempt++) {
try {
const res = await fetch(url, { cache: 'no-store' })
if (!res.ok) continue
const ct = res.headers.get('content-type') ?? ''
if (!ct.includes('json')) continue // SPA fallback HTML — not our brand.json
const raw = (await res.json()) as { brand?: BrandContract }
if (raw?.brand) return raw.brand
if (res.ok) {
const raw = await res.json()
return raw.brand as BrandContract
}
} catch {
// try the next candidate
// network error — fall through to retry
}
if (attempt < 2) await new Promise((r) => setTimeout(r, 150 * (attempt + 1)))
}
return fallbackBrand(brandPackage)
}
@@ -49,20 +47,22 @@ export async function loadBrand(brandPackage: string, brandUrl?: string): Promis
}
/**
* Minimal brand used only when no brand.json could be loaded — keeps the login
* form rendering. Derives a display name from the package scope
* (`@hanzo/brand` "Hanzo").
* Last-resort brand when the asset is unreachable after retries. Keeps the
* login form usable (a generic heading) instead of blanking the page. The
* display name is derived from the pkg scope (`@hanzo/brand` -> "Hanzo"); the
* few orgs whose scope differs from their display name are mapped.
*/
function fallbackBrand(brandPackage: string): BrandContract {
const scope = brandPackage.match(/@([^/]+)\//)?.[1] ?? 'hanzo'
const name = scope.charAt(0).toUpperCase() + scope.slice(1)
const scope = brandPackage.replace(/^@/, '').split('/')[0] ?? 'hanzo'
const overrides: Record<string, string> = { luxfi: 'Lux', zooai: 'Zoo', parsdao: 'Pars' }
const name = overrides[scope] ?? scope.charAt(0).toUpperCase() + scope.slice(1)
return {
name,
title: 'Sign in',
title: name,
description: '',
appDomain: '',
logoUrl: '',
faviconUrl: 'data:,',
faviconUrl: '',
}
}
@@ -76,6 +76,24 @@ export interface BrandRuntime {
readonly accentColor?: string
}
/**
* Neutral identity-portal label — always "<Brand> ID". `BrandContract.name`
* is meant to be the bare org display ("Lux"), but some brand packages ship
* the product name ("Lux Exchange" / "Zoo Exchange"), which leaks a sibling
* surface into the IAM portal heading + tab title. Prefer the org orgId
* ("lux" → "Lux"); otherwise strip a trailing product word from the brand
* name. So id.lux.network reads "Lux ID", never "Lux Exchange".
*/
export function idBrandLabel(brand: { name: string }, orgId?: string): string {
const cap = (s: string) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : '')
const short =
cap((orgId ?? '').trim()) ||
(brand.name ?? '').replace(/\s+(Exchange|Network|Labs|Foundation|DAO|Wallet)\b.*$/i, '').trim() ||
brand.name ||
'Account'
return `${short} ID`
}
export function toBrandRuntime(b: BrandContract): BrandRuntime {
return {
name: b.name,
+1 -1
View File
@@ -1,3 +1,3 @@
export * from './tenant'
export * from './org'
export * from './brand'
export * from './types'

Some files were not shown because too many files have changed in this diff Show More