Compare commits

..
Author SHA1 Message Date
Antje Worring aea7d7d622 fix(docker): static needs -root/-spa flags, not ROOT/PORT env
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.
2026-06-18 15:33:31 -07:00
47 changed files with 580 additions and 3494 deletions
+8 -42
View File
@@ -1,8 +1,4 @@
name: Docker
# Self-contained build on Hanzo self-hosted runners. The shared reusable
# workflow (hanzoai/.github docker-build.yml@main) is currently failing graph
# validation for every caller (org-wide startup_failure), so this repo builds
# its own image directly. Cluster nodes are linux/amd64 → build that arch.
on:
workflow_dispatch:
push:
@@ -13,41 +9,11 @@ permissions:
packages: write
jobs:
docker:
# Route to the org-level ARC scale set (always-on, autoscales 0→100).
# The bare [self-hosted, linux, amd64] labels target the native dbc/evo/
# spark runners, which queue indefinitely when offline. ARC v0.14 routes
# by scale-set NAME, so name the pool directly (matches universe + the
# shared hanzoai/.github docker-build.yml default).
runs-on: hanzo-build-linux-amd64
steps:
- uses: actions/checkout@v4
- name: Compute tags
id: tags
run: |
SHA="sha-${GITHUB_SHA:0:7}"
VER="$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' package.json | head -1)"
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
echo "ver=$VER" >> "$GITHUB_OUTPUT"
echo "Tags: $SHA, $VER"
- uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push (linux/amd64)
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64
push: true
tags: |
ghcr.io/hanzoai/id:${{ steps.tags.outputs.sha }}
ghcr.io/hanzoai/id:${{ steps.tags.outputs.ver }}
cache-from: type=gha,scope=hanzoai-id
cache-to: type=gha,scope=hanzoai-id,mode=max
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 -10
View File
@@ -1,5 +1,5 @@
# syntax=docker/dockerfile:1.7
# Hanzo ID — Vite SPA built once, served by hanzoai/spa.
# Hanzo ID — Vite SPA built once, served by hanzoai/static.
FROM node:24-alpine AS build
WORKDIR /build
ENV PNPM_HOME=/pnpm PATH=$PNPM_HOME:$PATH
@@ -10,19 +10,16 @@ COPY apps/web/package.json apps/web/
COPY pkgs/shared/package.json pkgs/shared/
COPY pkgs/auth/package.json pkgs/auth/
COPY pkgs/idv/package.json pkgs/idv/
COPY pkgs/onboarding/package.json pkgs/onboarding/
RUN pnpm install --frozen-lockfile=false
COPY apps apps
COPY pkgs pkgs
RUN pnpm --filter @hanzo/id-web build
# 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.
# hanzoai/static defaults to `Content-Security-Policy: default-src 'none'`
# (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
COPY --from=build /build/apps/web/dist /public
# Static server stage — hanzoai/static reads /spa for assets
FROM ghcr.io/hanzoai/static:0.4.1
COPY --from=build /build/apps/web/dist /spa
EXPOSE 3000
# hanzoai/static reads -root/-spa as FLAGS (ROOT/PORT env are ignored);
# default port 3000 matches the id deploy probe.
ENTRYPOINT ["/static", "-root", "/spa", "-spa"]
+7 -92
View File
@@ -1,29 +1,5 @@
# LLM.md — Hanzo ID
## PKCE on password login (fixed 0.1.13)
`client.login()` (POST `/v1/iam/login`) must forward `code_challenge`
/`code_challenge_method` on the QUERY string, exactly like `authorize()`.
IAM's Login handler (`hanzoai/iam` `controllers/account.go`) reads
`code_challenge` from the query first, body fallback, then threads it into
`GetOAuthCode` so the minted code stores the challenge. Omitting it makes
IAM store an EMPTY challenge; the downstream public SPA client (e.g.
`hanzo-platform`) then fails token exchange with
`token.CodeChallenge: empty``invalid_client` (it falls back to a
client_secret check the public client can't satisfy — see
`object/token_oauth.go:809-844`: with a non-empty stored challenge AND no
client_secret sent, the secret check is bypassed). Social login was never
affected (it rides `authorize()`). Plumb the URL's `code_challenge`
through Login page → LoginForm → `client.login()`; do NOT default a
challenge — only forward what the downstream OAuth request put on the URL.
Known SEPARATE blocker (NOT this repo): after a 200 token exchange,
platform's `/v1/iam/session` (`hanzo/platform` `pkg/platform/src/lib/iam.ts`
`@hanzo/iam/server` `getServerSession`) returns 401 "Invalid IAM token"
for a valid `aud:[hanzo-platform]` RS256 JWT signed by `cert-hanzo` (key IS
in hanzo.id JWKS; the duplicate `cert-hanzo` entry is identical, harmless).
That is a platform/SDK-side verification bug, tracked separately.
## What this is
White-label login + identity verification portal. Vite SPA, served from
@@ -51,57 +27,12 @@ pars.id ──┘ │
createAuthClient(tenant)
per-brand OIDC issuer host: hanzo.id / lux.id /
zoo.id / pars.id (serves /.well-known + /v1/iam/*;
same Casdoor-fork backend, tenant-scoped by org)
https://iam.hanzo.ai (Casdoor fork, Go)
iam-* postgres in hanzo namespace
```
`iamUrl` is the brand's OWN `*.id` host, NOT `iam.hanzo.ai` (HIP-0111:
discovery must be host-relative or the SDK resolves to the IAM SPA HTML
catch-all). One backend serves every brand behind its issuer host.
## Auth methods — the full set
Email/password + email/SMS code + GitHub + Google + Web3 (wallet). The enabled
set is read LIVE from `/v1/iam/get-app-login` (`AuthClient.getAppLogin`), which
mirrors each `-id` app's provider config in
`universe/infra/k8s/iam/init_data.json`. Password sign-in goes through the IAM
REST `login` and returns an auth code directly. Both honor a downstream
`redirect_uri`.
### Social providers — render only when configured; redirect via the "hop"
`SocialButtons` renders ONLY providers IAM holds a REAL credential for
(`AppProvider.configured` = non-placeholder clientId). With the seed's
placeholders every social button is hidden, so a user never hits a dead-end;
they reappear automatically once real creds land. Clicking a configured OAuth
provider runs the **hop** (`social.ts::startProviderLogin`), which redirects
straight to the provider with a base64 `state` that round-trips the original
authorize request — matching the IAM (Casdoor) `getAuthUrl` contract. The
provider returns to `/callback`; `Callback.tsx` detects the provider state and
calls `client.providerLogin` to exchange the code at the IAM backend, then
follows the continue-URL (which re-enters `/callback` as the normal OIDC code).
(NOT `@hanzo/iam` `signinRedirect` — that loops back to the login page.)
**To ENABLE real social login (the only remaining work):**
1. Register an OAuth app per provider (GitHub/Google) with callback
**`https://<brand>/v1/iam/callback`** AND the app authorize redirect
`https://<brand>/callback` (per brand host: hanzo.id, lux.id, pars.id …).
2. Put the client id/secret in KMS at **project `hanzo-iam`, env `prod`**, keys
`IAM_GITHUB_CLIENT_ID` / `IAM_GITHUB_CLIENT_SECRET` (and `IAM_GOOGLE_*`). The
`iam-kms-sync` KMSSecret (`universe/infra/k8s/iam/secret.yaml`) syncs that
path into `iam-secrets`; init_data.json substitutes `${IAM_GITHUB_CLIENT_ID}`
at deploy. The whole sync + env-ref chain already exists — today those keys
just hold placeholder values, so providers read as unconfigured (buttons
hidden). Replace the values; nothing else to wire.
3. The buttons appear automatically (no portal change). **Live-verify** the
round-trip reaches the provider and completes — the hop + exchange are wired
and unit-tested (`pkgs/auth/src/social.test.ts`) but can only be exercised
end-to-end once real creds exist.
## Workspace
```
@@ -109,14 +40,8 @@ apps/
web/ @hanzo/id-web — Vite + React 19 + @hanzo/gui SPA
pkgs/
shared/ @hanzo/id-shared — TenantConfig, resolveTenant, loadBrand
auth/ @hanzo/id-auth — composable login/signup/OTP/forgot forms +
SocialButtons (GitHub/Google/Web3) +
AuthClient (wraps @hanzo/iam REST + SDK PKCE)
onboarding/ @hanzo/id-onboarding — post-login org → project → wallet flow.
domain (serializable step machine) / service
(IAM-backed writes) / ui (self-contained flow).
Tests: `pnpm --filter @hanzo/id-onboarding test`
(Node built-in runner, no test-framework dep).
auth/ @hanzo/id-auth — composable login/signup/OTP forms +
AuthClient (wraps @hanzo/iam REST)
idv/ @hanzo/id-idv — pluggable identity verification
(Persona, Onfido, Veriff, stub)
legacy-nextjs/ Frozen predecessor. Delete after v0.1.0 ships.
@@ -202,21 +127,11 @@ Custom providers: implement the `IDVProvider` interface in
## Backend
The Go IAM backend lives at `~/work/hanzo/iam` (Casdoor fork, module
`github.com/hanzoai/iam`, image `ghcr.io/hanzoai/iam`). All paths are under
the `/v1/iam` prefix — no legacy `/oauth/*`, no `/api/`. This portal talks
to it via:
`github.com/hanzoai/iam`, image `ghcr.io/hanzoai/iam`). This portal talks
to it via the routes in `pkgs/auth/src/client.ts`:
- auth (`pkgs/auth/src/client.ts`): `/v1/iam/login` `/v1/iam/signup`
`/v1/iam/send-verification-code` `/v1/iam/get-app-login`, and the OIDC
PKCE endpoints `/v1/iam/oauth/{authorize,token,userinfo,logout}` (via the
`@hanzo/iam` SDK).
- onboarding (`pkgs/onboarding/src/service/onboarding.ts`):
`/v1/iam/get-organizations` (allowed for any signed-in user, scoped to
their memberships server-side), `/v1/iam/add-organization` +
`/v1/iam/add-project` (admin-gated in IAM authz — the create path surfaces
a permission message for non-admins and stays skippable),
`/v1/iam/get-account` + `/v1/iam/update-user?columns=web3onboard` (wallet
link).
- `/v1/iam/login` `/v1/iam/signup` `/v1/iam/send-verification-code`
- `/oauth/authorize` `/oauth/token` `/oauth/logout`
All hostnames talk to the same IAM backend — the org is carried in the
request body (`organization: <orgId>`), and the IAM backend tenant-scopes
+2 -3
View File
@@ -1,7 +1,7 @@
{
"name": "@hanzo/id-web",
"private": true,
"version": "0.1.22",
"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.",
"type": "module",
"scripts": {
@@ -13,10 +13,9 @@
"dependencies": {
"@hanzo/brand": "^1.3.0",
"@hanzo/gui": "^7.2.4",
"@hanzo/iam": "^0.11.0",
"@hanzo/iam": "^0.9.4",
"@hanzo/id-auth": "workspace:*",
"@hanzo/id-idv": "workspace:*",
"@hanzo/id-onboarding": "workspace:*",
"@hanzo/id-shared": "workspace:*",
"@luxfi/brand": "^1.0.0",
"@parsdao/brand": "^1.0.0",
+9 -39
View File
@@ -6,7 +6,6 @@ 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'
/**
* Top-level wiring. Resolves tenant + brand once on mount, then routes via
@@ -19,45 +18,17 @@ export function App() {
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
async function boot() {
// The runtime serves the per-host tenant catalog at /config.json
// (templated from SPA_IAM_TENANT_CONFIG_JSON by the static server). Read
// it from there — 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 inlined
// global, then empty, so a host always resolves to something.
let catalogRaw: string | undefined
try {
const res = await fetch('/config.json', { cache: 'no-store' })
if (res.ok) {
const cfg = (await res.json()) as { iamTenantConfigJson?: string }
catalogRaw = cfg.iamTenantConfigJson
}
} catch {
// network/parse error → fall back below
}
if (!catalogRaw) {
catalogRaw = (window as unknown as { __ID_CATALOG__?: string }).__ID_CATALOG__
}
const t = resolveTenant(window.location.hostname, { catalog: parseCatalog(catalogRaw) })
if (cancelled) return
setTenant(t)
try {
const b = await loadBrand(t.brandPackage)
if (cancelled) return
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)
.then((b) => {
setBrand(b)
document.title = `Sign in — ${b.name}`
const fav = document.getElementById('favicon') as HTMLLinkElement | null
if (fav && b.faviconUrl) fav.href = b.faviconUrl
} catch (e) {
if (!cancelled) setError(String(e))
}
}
void boot()
return () => {
cancelled = true
}
})
.catch((e) => setError(String(e)))
}, [])
const client = useMemo(() => (tenant ? createAuthClient({ tenant }) : null), [tenant])
@@ -69,7 +40,6 @@ export function App() {
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 tenant={tenant} brand={brand} />
if (path === '/onboarding' || path.startsWith('/onboarding/')) return <Onboarding tenant={tenant} brand={brand} />
return <Portal client={client} brand={brand} tenant={tenant} />
if (path === '/callback' || path.startsWith('/callback/')) return <Callback client={client} brand={brand} />
return <Portal brand={brand} />
}
+49 -106
View File
@@ -31,14 +31,6 @@ body {
padding: 16px 0 32px;
}
/* Text fallback when a brand ships no logo asset (see BrandHeader). */
.hanzo-id-wordmark {
font-size: 22px;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--fg);
}
.hanzo-id-page main {
flex: 1;
display: flex;
@@ -81,12 +73,6 @@ form input:focus { outline: 2px solid var(--brand); outline-offset: -1px; }
.hanzo-id-footer-links { color: var(--muted); font-size: 14px; }
.hanzo-id-footer-links a { color: var(--fg); }
/* A2P SMS consent disclosure (shown on phone/SMS surfaces). */
.hanzo-id-sms-consent { color: var(--muted); font-size: 12px; line-height: 1.5; }
.hanzo-id-sms-consent p { margin: 0 0 6px; }
.hanzo-id-sms-consent-links { margin: 0; }
.hanzo-id-sms-consent a { color: var(--fg); }
.hanzo-id-error {
background: #2d0a0a;
color: #ff7878;
@@ -103,39 +89,20 @@ form input:focus { outline: 2px solid var(--brand); outline-offset: -1px; }
border-radius: 8px;
}
/* ── Forced TOTP enrollment ────────────────────────────────────── */
.hanzo-id-mfa-enroll { display: flex; flex-direction: column; gap: 16px; }
.hanzo-id-mfa-enroll h2 { margin: 0; font-size: 22px; }
.hanzo-id-mfa-qr {
align-self: center;
background: #fff;
padding: 12px;
border-radius: 12px;
width: 220px;
height: 220px;
box-sizing: content-box;
/* --- Social providers + passwordless (email/SMS) login + signup --- */
.hanzo-id-login,
.hanzo-id-signup,
.hanzo-id-code-login {
display: flex;
flex-direction: column;
gap: 16px;
}
.hanzo-id-mfa-qr svg { width: 100%; height: 100%; display: block; }
.hanzo-id-mfa-manual { font-size: 14px; color: var(--muted); }
.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: #111;
border: 1px solid var(--border);
border-radius: 6px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing: 2px;
word-break: break-all;
.hanzo-id-providers {
display: flex;
flex-direction: column;
gap: 10px;
}
.hanzo-id-mfa-recovery { font-size: 13px; color: var(--muted); line-height: 1.6; }
.hanzo-id-mfa-recovery code { letter-spacing: normal; }
/* ── Social / Web3 sign-in buttons ─────────────────────────────── */
.hanzo-id-social { display: flex; flex-direction: column; gap: 10px; }
.hanzo-id-social-btn {
.hanzo-id-provider-btn {
display: flex;
align-items: center;
justify-content: center;
@@ -144,83 +111,59 @@ form input:focus { outline: 2px solid var(--brand); outline-offset: -1px; }
color: var(--fg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 11px 14px;
padding: 12px 16px;
font-size: 15px;
font-weight: 500;
font-weight: 600;
cursor: pointer;
text-decoration: none;
text-align: center;
}
.hanzo-id-social-btn:hover { border-color: #3a3a3a; background: #161616; }
.hanzo-id-social-btn svg { flex: none; }
/* Labeled divider between social row and email form. */
.hanzo-id-divider {
.hanzo-id-provider-btn:hover {
border-color: var(--muted);
background: #161616;
}
.hanzo-id-provider-web3 {
border-color: #3a3357;
}
.hanzo-id-or {
display: flex;
align-items: center;
text-align: center;
gap: 12px;
color: var(--muted);
font-size: 13px;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.hanzo-id-divider::before,
.hanzo-id-divider::after {
.hanzo-id-or::before,
.hanzo-id-or::after {
content: '';
flex: 1;
height: 1px;
background: var(--border);
}
.hanzo-id-divider span { padding: 0 12px; }
/* ── 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 { margin: 0; font-size: 26px; }
.hanzo-id-onboarding-body { display: flex; flex-direction: column; gap: 16px; }
.hanzo-id-onboarding-done { display: flex; flex-direction: column; gap: 16px; }
.hanzo-id-onboarding-done h1 { margin: 0; font-size: 26px; }
.hanzo-id-stepdots { display: flex; gap: 8px; }
.hanzo-id-stepdots span {
flex: 1;
height: 4px;
border-radius: 2px;
background: var(--border);
}
.hanzo-id-stepdots span.on { background: var(--brand); }
.hanzo-id-org-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.hanzo-id-org-row {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
background: #111;
color: var(--fg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px 16px;
font-size: 15px;
cursor: pointer;
text-align: left;
}
.hanzo-id-org-row:hover { border-color: #3a3a3a; background: #161616; }
.hanzo-id-org-slug { color: var(--muted); font-size: 13px; font-family: ui-monospace, monospace; }
.hanzo-id-linkbtn {
background: none;
border: 0;
color: var(--fg);
color: var(--muted);
font-size: 14px;
font-weight: 500;
width: auto;
padding: 4px;
cursor: pointer;
text-align: left;
padding: 4px 0;
text-align: center;
}
.hanzo-id-linkbtn:hover {
color: var(--fg);
text-decoration: underline;
}
.hanzo-id-slug-preview { color: var(--muted); font-size: 13px; margin: -8px 0 0; font-family: ui-monospace, monospace; }
.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-btn.ghost { background: transparent; color: var(--fg); border: 1px solid var(--border); }
.hanzo-id-btn.ghost:hover { border-color: #3a3a3a; }
.hanzo-id-summary { display: grid; grid-template-columns: auto 1fr; gap: 8px 16px; margin: 0; }
.hanzo-id-summary dt { color: var(--muted); font-size: 14px; }
.hanzo-id-summary dd { margin: 0; font-size: 14px; font-family: ui-monospace, monospace; }
.hanzo-id-toggle-mode {
margin-top: 4px;
}
.hanzo-id-notice {
background: #0a1f2d;
color: #78b8ff;
border: 1px solid #14385a;
padding: 12px;
border-radius: 8px;
font-size: 14px;
}
+1 -15
View File
@@ -1,24 +1,10 @@
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}>
{showImg ? (
<img src={brand.logoUrl} alt={brand.name} height={32} onError={() => setImgOk(false)} />
) : (
<span className="hanzo-id-wordmark">{brand.name}</span>
)}
<img src={brand.logoUrl} alt={brand.name} height={32} />
</a>
</header>
)
-113
View File
@@ -1,113 +0,0 @@
/**
* 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 `tenant.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' },
],
},
}
const APPS: Record<string, readonly AppLink[]> = {
hanzo: [
{ name: 'Console', href: 'https://console.hanzo.ai', description: 'Observability & traces' },
{ name: 'Chat', href: 'https://hanzo.chat', description: 'AI chat interface' },
{ name: 'Cloud', href: 'https://cloud.hanzo.ai', description: 'AI model API' },
{ name: 'Analytics', href: 'https://analytics.hanzo.ai', description: 'Web analytics' },
{ name: 'Platform', href: 'https://platform.hanzo.ai', description: 'PaaS deployments' },
{ name: 'Storage', href: 'https://s3.hanzo.ai', description: 'S3-compatible storage' },
],
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
}
+19 -75
View File
@@ -1,89 +1,33 @@
import { useEffect, useState } from 'react'
import type { BrandContract, TenantConfig } from '@hanzo/id-shared'
import { createIam, createAuthClient } from '@hanzo/id-auth'
import type { BrandContract } from '@hanzo/id-shared'
import type { AuthClient } from '@hanzo/id-auth'
import { BrandHeader } from '../components/BrandHeader'
/**
* OAuth/OIDC callback.
*
* Two kinds of return land here:
* 1. The portal's own OIDC PKCE return (password / SDK `signinRedirect`) —
* completed by the `@hanzo/iam` SDK's `handleCallback`, which reads back
* the exact PKCE verifier/state it stored.
* 2. A SOCIAL provider return (GitHub/Google), where `social.ts` sent the
* user out with a base64 `state` that encodes the original authorize
* request. We detect that, exchange the provider `code` at the IAM backend
* (`client.providerLogin`), and follow the continue-URL it returns — which
* re-enters this callback as case (1). (Pending live verification; only
* reachable once real provider creds are seeded.)
*
* Routing after the OIDC exchange:
* - A downstream app left its target in `post_login_redirect` → forward tokens.
* - A bare portal sign-in → `/onboarding`.
*/
/** Decode a social-provider `state` (base64 of the original authorize query). */
function decodeProviderState(state: string | null): URLSearchParams | null {
if (!state) return null
try {
const decoded = atob(state)
const params = new URLSearchParams(decoded.replace(/^\?/, ''))
// A provider-login state always carries application + provider markers.
if (params.get('provider') && params.get('application')) return params
} catch {
// not base64 → an SDK/OIDC state, not a provider return
}
return null
}
export function Callback({ tenant, brand }: { tenant: TenantConfig; brand: BrandContract }) {
export function Callback({ client, brand }: { client: AuthClient; brand: BrandContract }) {
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const search = new URLSearchParams(window.location.search)
const providerState = decodeProviderState(search.get('state'))
// Case (2): social provider return → exchange the provider code, then follow
// the continue-URL back into case (1).
if (providerState && search.get('code')) {
const client = createAuthClient({ tenant })
const oidcQuery = atob(search.get('state')!)
client
.providerLogin({
application: providerState.get('application') ?? '',
provider: providerState.get('provider') ?? '',
code: search.get('code') ?? '',
oidcQuery,
method: providerState.get('method') ?? 'signin',
})
.then((r) => {
if (r.redirectUrl) window.location.replace(r.redirectUrl)
else setError(r.error ?? 'Sign-in failed')
})
.catch((e) => setError(String(e)))
const sp = new URLSearchParams(window.location.search)
const code = sp.get('code')
if (!code) {
setError('Missing authorization code.')
return
}
// Case (1): the portal's own OIDC PKCE return.
const iam = createIam(tenant)
iam
.handleCallback(window.location.href)
const codeVerifier = sessionStorage.getItem('pkce_verifier') ?? undefined
client
.exchange(code, codeVerifier)
.then((tok) => {
const target = sessionStorage.getItem('post_login_redirect')
// 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')
sessionStorage.removeItem('post_login_redirect')
if (target) {
// Forward tokens to whichever app initiated this flow.
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')
window.location.replace(url.toString())
})
.catch((e) => setError(String(e)))
}, [tenant])
}, [client])
return (
<div className="hanzo-id-page hanzo-id-callback">
+1 -93
View File
@@ -1,14 +1,5 @@
import { useState } from 'react'
import type { BrandContract } from '@hanzo/id-shared'
import {
LoginForm,
MfaEnrollForm,
OTPForm,
SocialButtons,
mfaChannelOf,
type AuthClient,
type LoginResponse,
} from '@hanzo/id-auth'
import { LoginForm, type AuthClient } from '@hanzo/id-auth'
import { BrandHeader } from '../components/BrandHeader'
export function Login({ client, brand }: { client: AuthClient; brand: BrandContract }) {
@@ -16,99 +7,16 @@ 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
// 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.tenant.clientId
// 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 (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.tenant.appName,
organization: client.tenant.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>
<SocialButtons
client={client}
clientIdOverride={clientIdOverride}
intent="signin"
postLoginRedirect={redirectUri}
/>
<LoginForm
client={client}
redirectUri={redirectUri}
state={state}
clientIdOverride={clientIdOverride ?? undefined}
codeChallenge={codeChallenge}
codeChallengeMethod={codeChallengeMethod}
onMfaRequired={setMfa}
/>
<p className="hanzo-id-footer-links">
<a href="/forget">Forgot password?</a> · <a href="/signup">Create account</a>
-72
View File
@@ -1,72 +0,0 @@
import { useMemo } from 'react'
import type { BrandContract, TenantConfig } from '@hanzo/id-shared'
import { createIam } from '@hanzo/id-auth'
import { OnboardingFlow, createOnboardingService, type OnboardingState } from '@hanzo/id-onboarding'
import { BrandHeader } from '../components/BrandHeader'
/**
* Post-login onboarding page.
*
* Reached after a bare portal sign-in (no downstream `redirect_uri`). Mounts
* the `@hanzo/id-onboarding` flow (org → project → wallet) 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.
*
* On completion it lands on the portal home (`/`); a downstream app that
* wanted a token would have carried `redirect_uri` and never reached here.
*/
export function Onboarding({ tenant, brand }: { tenant: TenantConfig; brand: BrandContract }) {
const iam = useMemo(() => createIam(tenant), [tenant])
const service = useMemo(
() =>
createOnboardingService({
iamUrl: tenant.iamUrl,
orgId: tenant.orgId,
getAccessToken: () => iam.getValidAccessToken(),
}),
[tenant, iam],
)
function onComplete(_state: OnboardingState) {
// Land on the authenticated portal (apps launcher), NOT the bare hero.
// The marker makes the portal treat the just-established session as authed
// even before the cross-request get-account read settles.
window.location.replace('/?signed_in=1')
}
return (
<div className="hanzo-id-page hanzo-id-onboarding-page">
<BrandHeader brand={brand} />
<main>
<OnboardingFlow
service={service}
brandName={brand.name}
connectWallet={connectInjectedWallet}
onComplete={onComplete}
/>
</main>
</div>
)
}
/** Minimal EIP-1193 `eth_requestAccounts` connector. Null on cancel/no wallet. */
async function connectInjectedWallet(): Promise<string | null> {
const eth = (window as unknown as { ethereum?: Eip1193 }).ethereum
if (!eth) return null
try {
const accounts = (await eth.request({ method: 'eth_requestAccounts' })) as string[]
return accounts?.[0] ?? null
} catch {
return null // user rejected the connection prompt
}
}
interface Eip1193 {
request(args: { method: string; params?: unknown[] }): Promise<unknown>
}
+8 -115
View File
@@ -1,125 +1,18 @@
import { useEffect, useState } from 'react'
import type { BrandContract, TenantConfig } from '@hanzo/id-shared'
import type { AuthClient } from '@hanzo/id-auth'
import { Login } from './Login'
import type { BrandContract } from '@hanzo/id-shared'
import { BrandHeader } from '../components/BrandHeader'
import { appsFor, billingFor } from '../marketing'
type Auth =
| { s: 'loading' }
| { s: 'anon' }
| { s: 'authed'; name?: string; email?: string }
/**
* 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;
* `tenant.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,
tenant,
}: {
client: AuthClient
brand: BrandContract
tenant: TenantConfig
}) {
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', tenant.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') {
setAuth({ s: 'authed', name: str(d.displayName) ?? str(d.name), email: str(d.email) })
} else {
setAuth(justSignedIn ? { s: 'authed' } : { s: 'anon' })
}
})
.catch(() => {
if (alive) setAuth(justSignedIn ? { s: 'authed' } : { s: 'anon' })
})
return () => {
alive = false
}
}, [tenant.iamUrl])
if (auth.s === 'loading') {
return (
<div className="hanzo-id-page" style={{ minHeight: '40vh' }}>
<div className="hanzo-id-spinner" style={{ borderTopColor: brand.accentColor ?? '#fff' }} />
</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(tenant.orgId)
const billingUrl = billingFor(tenant.orgId)
const logoutUrl = client.logout(undefined, `${tenant.publicOrigin}/login`)
export function Portal({ brand }: { brand: BrandContract }) {
return (
<div className="hanzo-id-page hanzo-id-portal">
<BrandHeader brand={brand} />
<main style={{ width: '100%', maxWidth: 760 }}>
<h1>Your {brand.name} apps</h1>
{auth.email ? <p className="lede">{auth.email}</p> : null}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(210px, 1fr))',
gap: 12,
marginTop: 24,
}}
>
{apps.map((a) => (
<a
key={a.name}
href={a.href}
style={{
display: 'block',
padding: '16px 18px',
border: '1px solid rgba(255,255,255,0.14)',
borderRadius: 12,
textDecoration: 'none',
color: 'inherit',
}}
>
<div style={{ fontWeight: 600, display: 'flex', justifyContent: 'space-between' }}>
<span>{a.name}</span>
<span aria-hidden style={{ opacity: 0.5 }}></span>
</div>
<div style={{ opacity: 0.6, fontSize: 13, marginTop: 4 }}>{a.description}</div>
</a>
))}
</div>
<div style={{ marginTop: 28, display: 'flex', gap: 18 }}>
<a className="hanzo-id-linkbtn" href={billingUrl}>Billing</a>
<a className="hanzo-id-linkbtn" href={logoutUrl}>Sign out</a>
<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>
</div>
</main>
</div>
)
}
function str(v: unknown): string | undefined {
return typeof v === 'string' && v.length > 0 ? v : undefined
}
+1 -9
View File
@@ -1,23 +1,15 @@
import type { BrandContract } from '@hanzo/id-shared'
import { SignupForm, SocialButtons, type AuthClient } from '@hanzo/id-auth'
import { SignupForm, 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
return (
<div className="hanzo-id-page hanzo-id-signup">
<BrandHeader brand={brand} />
<main>
<h1>Create your {brand.name} account</h1>
<SocialButtons
client={client}
clientIdOverride={clientIdOverride}
intent="signup"
postLoginRedirect={redirectUri}
/>
<SignupForm client={client} inviteCode={inviteCode} />
<p className="hanzo-id-footer-links">
Already have an account? <a href="/login">Sign in</a>
+10 -24
View File
@@ -2,48 +2,34 @@ 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 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`.
* `@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.
*
* 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((req2: any, res: any, next: any) => {
const m = /^\/brand\/([^/]+)\.json$/.exec(req2.url ?? '')
server.middlewares.use((req: any, res: any, next: any) => {
const m = /^\/brand\/(.+)\/brand\.json$/.exec(req.url ?? '')
if (!m) return next()
const slug = m[1]!
const pkg = BRAND_PACKAGES.find((p) => brandSlug(p) === slug)
if (!pkg) {
const pkg = decodeURIComponent(m[1]!)
if (!BRAND_PACKAGES.includes(pkg)) {
res.statusCode = 404
return res.end()
}
try {
const path = req.resolve(`${pkg}/brand.json`)
const path = require.resolve(`${pkg}/brand.json`)
res.setHeader('Content-Type', 'application/json')
res.setHeader('Cache-Control', 'no-store')
return res.end(readFileSync(path, 'utf8'))
@@ -56,11 +42,11 @@ function brandJsonPlugin() {
generateBundle(this: any) {
for (const pkg of BRAND_PACKAGES) {
try {
const path = req.resolve(`${pkg}/brand.json`)
const path = require.resolve(`${pkg}/brand.json`)
if (!existsSync(path)) continue
this.emitFile({
type: 'asset',
fileName: `brand/${brandSlug(pkg)}.json`,
fileName: `brand/${pkg}/brand.json`,
source: readFileSync(path, 'utf8'),
})
} catch {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@hanzo/id",
"private": true,
"version": "0.1.29",
"version": "0.1.1",
"description": "Hanzo ID — white-label login + identity verification portal (Vite + @hanzo/gui)",
"scripts": {
"build": "pnpm -r build",
+3 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@hanzo/id-auth",
"version": "0.1.1",
"version": "0.1.0",
"description": "Composable login / signup / OTP / OAuth-PKCE flows on top of @hanzo/iam. UI primitives in @hanzo/gui.",
"license": "BSD-3-Clause",
"type": "module",
@@ -14,13 +14,11 @@
},
"files": ["src"],
"scripts": {
"tc": "tsc --noEmit",
"test": "node --test --experimental-strip-types 'src/**/*.test.ts'"
"tc": "tsc --noEmit"
},
"dependencies": {
"@hanzo/id-shared": "workspace:*",
"@hanzo/iam": "^0.11.0",
"@paulmillr/qr": "^0.3.0"
"@hanzo/iam": "^0.9.4"
},
"peerDependencies": {
"react": ">=19",
-150
View File
@@ -1,150 +0,0 @@
/**
* 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"` + `data2` (challenge) — 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 'node:test'
import assert from 'node:assert/strict'
import type { TenantConfig } from '@hanzo/id-shared'
import { createAuthClient, mfaChannelOf, MFA_TOTP } from './client.ts'
const TENANT: TenantConfig = {
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({ tenant: 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')
})
test('login → NextMfa maps to a challenge signal and carries the allowed types', async () => {
const calls: Call[] = []
const body = {
status: 'ok',
data: 'NextMfa',
data2: [{ mfaType: 'app', enabled: true }, { mfaType: 'sms', enabled: true }],
}
const client = createAuthClient({ tenant: 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({ tenant: 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({ tenant: 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({ tenant: 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({ tenant: 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({ tenant: 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')
})
+72 -299
View File
@@ -1,27 +1,15 @@
import type { TenantConfig } from '@hanzo/id-shared'
import type {
AppLogin,
AppProvider,
AppLoginInfo,
CodeLoginRequest,
ForgotRequest,
LoginRequest,
LoginResponse,
MfaChallengeRequest,
MfaChannel,
MfaIdentity,
MfaSetup,
OAuthAuthorizeRequest,
SignupRequest,
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.
*
@@ -51,63 +39,12 @@ export interface AuthClient {
authorize(req: OAuthAuthorizeRequest): string
exchange(code: string, codeVerifier?: string): Promise<TokenResponse>
logout(idTokenHint?: string, postLogoutRedirectUri?: string): string
/**
* 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 tenant's declared default method set.
*/
getAppLogin(clientId?: string): Promise<AppLogin | null>
/**
* Complete a social provider login when the provider redirects back to
* `/callback` with a `code` + base64 `state` (see `social.ts`). Exchanges the
* provider code at the IAM backend (the Casdoor `AuthBackend.login` contract)
* and resolves the URL to redirect to — the original OIDC `redirect_uri` with
* an authorization code, which the portal's normal PKCE callback then
* completes. NOTE: pending live verification — runs only once real OAuth
* provider creds are seeded (the buttons are hidden until then).
*/
providerLogin(req: ProviderExchangeRequest): Promise<{ redirectUrl?: string; error?: string }>
/**
* 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>
}
/** Inputs to {@link AuthClient.providerLogin}, recovered from the /callback return. */
export interface ProviderExchangeRequest {
/** IAM application name (from the decoded state). */
readonly application: string
/** IAM provider record name, e.g. `provider-github`. */
readonly provider: string
/** The provider's authorization code (the `?code=` on the /callback return). */
readonly code: string
/** The ORIGINAL OIDC authorize query string (decoded from the base64 state). */
readonly oidcQuery: string
/** "signin" | "signup". */
readonly method: 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>
}
export interface AuthClientOptions {
@@ -128,10 +65,6 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
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 res = await f(url.toString(), {
method: 'POST',
@@ -202,6 +135,7 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
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)
@@ -246,145 +180,72 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
return url.toString()
}
async function getAppLogin(clientId?: string): Promise<AppLogin | null> {
const id = clientId ?? tenant.clientId
// 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', id)
url.searchParams.set('clientId', tenant.clientId)
url.searchParams.set('responseType', 'code')
url.searchParams.set('redirectUri', `${tenant.publicOrigin}/callback`)
url.searchParams.set('scope', 'openid profile email')
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
url.searchParams.set('state', 'login')
const res = await f(url.toString(), { 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,
}))
: []
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,
}
if (body.status !== 'ok' || typeof body.data !== 'object' || body.data === null) return null
return parseAppLogin(body.data as Record<string, unknown>, tenant.appName, tenant.orgId)
}
async function providerLogin(
req: ProviderExchangeRequest,
): Promise<{ redirectUrl?: string; error?: string }> {
// POST the provider code to the IAM backend with the original OIDC params
// as the query string (Casdoor `AuthBackend.login(body, oAuthParams)`). The
// backend exchanges the code, signs the user in, and returns the URL to
// continue the original authorize request.
const url = new URL('/v1/iam/login', tenant.iamUrl)
const oidc = new URLSearchParams(req.oidcQuery.replace(/^\?/, ''))
for (const [k, v] of oidc) {
if (['client_id', 'redirect_uri', 'response_type', 'scope', 'state', 'nonce', 'code_challenge', 'code_challenge_method'].includes(k)) {
url.searchParams.set(k, v)
}
}
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' },
credentials: 'include',
body: JSON.stringify({
type: 'code',
application: req.application,
provider: req.provider,
code: req.code,
state: req.application,
redirectUri: `${tenant.publicOrigin}/callback`,
method: req.method,
applicationId: `admin/${tenant.appName}`,
organization: tenant.orgId,
dest,
type: isEmail ? 'email' : 'phone',
method: 'login',
checkUser: dest,
}),
})
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}` }
}
// On success the backend returns the continue-URL in `data`.
const data = typeof body.data === 'string' ? body.data : ''
return data ? { redirectUrl: data } : { error: 'provider login returned no redirect' }
}
async function getAccount(): Promise<MfaIdentity | null> {
const url = new URL('/v1/iam/get-account', tenant.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}`, tenant.iamUrl)
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v)
const res = await f(url.toString(), { method: 'POST', credentials: 'include' })
if (!res.ok) return { ok: false, error: `HTTP ${res.status}` }
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>
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
if (body.status === 'error') return { ok: false, error: typeof body.msg === 'string' ? body.msg : 'failed' }
return { ok: true }
}
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 {
mfaType: MFA_TOTP,
secret,
url,
recoveryCodes: Array.isArray(d.recoveryCodes) ? d.recoveryCodes.filter((c): c is string => typeof c === 'string') : [],
}
}
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 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> {
async function loginWithCode(req: CodeLoginRequest): Promise<LoginResponse> {
const type = req.redirectUri ? 'code' : 'login'
const url = new URL('/v1/iam/login', tenant.iamUrl)
url.searchParams.set('clientId', req.clientId)
@@ -392,24 +253,21 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
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,
// No username: IAM resolves the user from the MFA session cookie it set
// when it answered NextMfa.
mfaType: req.mfaType,
passcode: req.passcode,
username: req.dest,
code: req.code,
application: req.application,
organization: req.organization,
enableMfaRemember: req.rememberDevice ?? false,
signinMethod: 'Verification code',
...(isEmail ? { email: req.dest } : { phone: req.dest }),
autoSignin: true,
}),
})
return parseLoginResponse(res, req)
@@ -423,76 +281,9 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
authorize,
exchange,
logout,
getAppLogin,
providerLogin,
getAccount,
mfaInitiate,
mfaVerify,
mfaEnable,
mfaChallenge,
}
}
/**
* Map an IAM provider record to its canonical authorize-endpoint `provider`
* key. IAM names providers `provider-<key>` (e.g. `provider-github`); the
* `/v1/iam/oauth/authorize?provider=<key>` param wants the bare key. The
* Web3Onboard wallet provider maps to `web3`.
*/
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>
const name = typeof rec.name === 'string' ? rec.name : ''
if (!name) return null
// The clientId lives on the nested provider record (`rec.provider`), not
// the outer link object.
const inner =
typeof rec.provider === 'object' && rec.provider !== null
? (rec.provider as Record<string, unknown>)
: {}
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,
appLogin,
sendLoginCode,
loginWithCode,
}
}
@@ -511,23 +302,6 @@ 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') {
const allow = 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) {
@@ -537,12 +311,9 @@ async function parseLoginResponse(
}
}
// 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.
// Bare portal sign-in: the IAM session cookie is now set; land on the portal.
if (!req?.redirectUri) {
return { redirectUrl: '/onboarding' }
return { redirectUrl: '/' }
}
// Fallback: a nested token payload (future direct-token IAM responses).
@@ -552,5 +323,7 @@ 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
@@ -1,21 +0,0 @@
import type { TenantConfig } from '@hanzo/id-shared'
import { IAM } from '@hanzo/iam/browser'
/**
* One IAM browser-SDK instance per tenant, 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(tenant: TenantConfig, clientId?: string): IAM {
return new IAM({
serverUrl: tenant.iamUrl,
clientId: clientId ?? tenant.clientId,
redirectUri: `${tenant.publicOrigin}/callback`,
scope: 'openid profile email',
})
}
+5 -20
View File
@@ -1,29 +1,14 @@
export {
createAuthClient,
mfaChannelOf,
MFA_TOTP,
type AuthClient,
type AuthClientOptions,
} from './client'
export { createIam } from './iam'
export {
startProviderLogin,
buildProviderAuthUrl,
isHoppableProvider,
type ProviderLoginParams,
} from './social'
export { createAuthClient, type AuthClient, type AuthClientOptions } from './client'
export type {
LoginRequest,
LoginResponse,
MfaChannel,
MfaChallengeRequest,
MfaIdentity,
MfaSetup,
SignupRequest,
ForgotRequest,
OAuthAuthorizeRequest,
TokenResponse,
AppLogin,
AppProvider,
ProviderInfo,
SigninMethod,
AppLoginInfo,
CodeLoginRequest,
} from './types'
export * from './ui'
-89
View File
@@ -1,89 +0,0 @@
/**
* Provider-hop URL builder tests — pure, no network. Run with:
* pnpm --filter @hanzo/id-auth test
*
* Verifies the URL + base64 state match the Hanzo-IAM (Casdoor) `getAuthUrl`
* contract so the backend `/callback` exchange accepts the return. The
* end-to-end OAuth round-trip still needs live verification once real provider
* creds are seeded — but the URL/state construction is locked down here.
*/
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { buildProviderAuthUrl, isHoppableProvider } from './social.ts'
const ORIGIN = 'https://hanzo.id'
// The original OIDC authorize query the portal was bounced here with.
const SEARCH = '?client_id=hanzo-id&redirect_uri=https%3A%2F%2Fhanzo.id%2Fcallback&response_type=code&scope=openid&state=rp123'
test('GitHub hop builds the correct endpoint, client_id, redirect_uri, and scope', () => {
const url = buildProviderAuthUrl(
{ application: 'hanzo-id', providerName: 'provider-github', type: 'GitHub', clientId: 'gh_real_123' },
ORIGIN,
SEARCH,
)!
assert.ok(url.startsWith('https://github.com/login/oauth/authorize?'))
assert.ok(url.includes('client_id=gh_real_123'))
// No callbackOrigin → defaults to the browser origin.
assert.ok(url.includes('redirect_uri=https://hanzo.id/callback'))
assert.ok(url.includes('scope=user:email+read:user')) // GitHub default
assert.ok(url.includes('response_type=code'))
})
test('the registered callback origin overrides the browser origin in redirect_uri', () => {
// The shared OAuth client is registered against iam.hanzo.ai/callback, so the
// hop must return there even though the SPA runs on hanzo.id — otherwise the
// provider rejects the redirect_uri (verified live: Google accepts ONLY
// https://iam.hanzo.ai/callback for this client).
const url = buildProviderAuthUrl(
{ application: 'hanzo-id', providerName: 'provider-google', type: 'Google', clientId: 'goog_1' },
ORIGIN,
SEARCH,
'https://iam.hanzo.ai',
)!
assert.ok(url.includes('redirect_uri=https://iam.hanzo.ai/callback'))
assert.ok(!url.includes('redirect_uri=https://hanzo.id/callback'))
})
test('state base64-encodes the original OIDC query + application/provider/method (round-trips)', () => {
const url = buildProviderAuthUrl(
{ application: 'hanzo-id', providerName: 'provider-github', type: 'GitHub', clientId: 'gh_real_123', method: 'signup' },
ORIGIN,
SEARCH,
)!
const state = new URL(url).searchParams.get('state')!
const decoded = Buffer.from(state, 'base64').toString('utf8')
// The RP's original request survives so the backend can complete it.
assert.ok(decoded.includes('client_id=hanzo-id'))
assert.ok(decoded.includes('state=rp123'))
assert.ok(decoded.includes('application=hanzo-id'))
assert.ok(decoded.includes('provider=provider-github'))
assert.ok(decoded.includes('method=signup'))
})
test('Google uses its own endpoint + scope; a custom provider scope overrides', () => {
const g = buildProviderAuthUrl(
{ application: 'hanzo-id', providerName: 'provider-google', type: 'Google', clientId: 'goog_1' },
ORIGIN,
SEARCH,
)!
assert.ok(g.startsWith('https://accounts.google.com/o/oauth2/v2/auth?'))
assert.ok(g.includes('scope=profile+email'))
const custom = buildProviderAuthUrl(
{ application: 'hanzo-id', providerName: 'provider-github', type: 'GitHub', clientId: 'gh_1', scopes: 'repo+user' },
ORIGIN,
SEARCH,
)!
assert.ok(custom.includes('scope=repo+user'))
})
test('an unconfigured (empty clientId) or unknown provider type yields no URL', () => {
assert.equal(buildProviderAuthUrl({ application: 'a', providerName: 'p', type: 'GitHub', clientId: '' }, ORIGIN, SEARCH), null)
assert.equal(buildProviderAuthUrl({ application: 'a', providerName: 'p', type: 'Mystery', clientId: 'x' }, ORIGIN, SEARCH), null)
})
test('isHoppableProvider knows the OAuth set, not wallet', () => {
assert.equal(isHoppableProvider('GitHub'), true)
assert.equal(isHoppableProvider('Google'), true)
assert.equal(isHoppableProvider('Web3Onboard'), false)
})
-103
View File
@@ -1,103 +0,0 @@
/**
* Social provider redirect — the "hop" that sends the browser to GitHub /
* Google / … to start an OAuth login, replicating the Hanzo-IAM (Casdoor)
* front-end `Provider.getAuthUrl` contract so the IAM backend's `/callback`
* exchange accepts the return.
*
* Why this exists: the IAM backend's OIDC authorize endpoint, for an
* unauthenticated request, 302s to its OWN login-page route (`/login/oauth/
* authorize`) and expects the FRONT END to read `?provider=` and bounce to the
* provider. We replaced that front end with this portal, so the portal must do
* the bounce. `iam.signinRedirect({provider})` does NOT — it just re-enters the
* authorize endpoint and loops.
*
* Contract (from `web/src/auth/Provider.tsx::getAuthUrl` + `Util.tsx::
* getStateFromQueryParams` in the IAM fork):
* url = `${endpoint}?client_id=${clientId}&redirect_uri=${origin}/callback`
* `&scope=${scope}&response_type=code&state=${state}`
* state = btoa(`${window.location.search}&application=${app}&provider=`
* `${providerName}&method=${method}`) // base64 of the ORIGINAL
* // OIDC query + app/provider/method, so the backend recovers the
* // original request when the provider returns to /callback.
*
* Only the standard OAuth2 set is wired here (the providers a `-id` app
* actually enables: github, google, +web3 handled elsewhere). Apple uses the
* backend callback and is added when needed.
*
* NOTE: live-verify this end-to-end once real OAuth credentials are seeded —
* it cannot be exercised while every provider carries placeholder creds (the
* buttons are hidden until then; see SocialButtons + AppProvider.configured).
*/
/** Provider `type` → OAuth2 authorize endpoint + default scope (IAM `authInfo`). */
const AUTH_INFO: Record<string, { endpoint: string; scope: string }> = {
GitHub: { endpoint: 'https://github.com/login/oauth/authorize', scope: 'user:email+read:user' },
// Canonical Google OAuth2 authorize endpoint. (Google aliases the legacy
// `/signin/oauth` path, but `/o/oauth2/v2/auth` is the documented, stable one.)
Google: { endpoint: 'https://accounts.google.com/o/oauth2/v2/auth', scope: 'profile+email' },
}
export interface ProviderLoginParams {
/** IAM application name the portal authenticates as (e.g. `hanzo-id`). */
readonly application: string
/** IAM provider record name, e.g. `provider-github`. */
readonly providerName: string
/** IAM provider `type`, e.g. `GitHub` / `Google` (selects the endpoint). */
readonly type: string
/** The provider's real OAuth client id (from `get-app-login`). */
readonly clientId: string
/** Override scope; falls back to the type default. */
readonly scopes?: string
/** "signin" (default) or "signup" — passed through to the backend. */
readonly method?: 'signin' | 'signup'
}
/**
* Build the provider authorize URL (pure; testable without navigating).
*
* `callbackOrigin` is the origin of the `/callback` that MUST be registered as
* the provider's authorized redirect URI. The OAuth client (one per provider)
* is registered against a SINGLE callback host — the IAM backend host
* (`iam.hanzo.ai`) — shared across every brand portal, so the provider only
* accepts that exact `redirect_uri`. Sending the browser's own origin (e.g.
* `hanzo.id`) yields `redirect_uri_mismatch`. Callers pass the registered
* origin; it defaults to `origin` for the single-host / local-dev case.
*
* `iam.hanzo.ai/callback` serves the SAME `@hanzo/id` SPA (the headless
* `Callback` page — no login UI), which decodes the base64 `state` to recover
* the original app's `redirect_uri`, exchanges the provider `code` at the IAM
* backend, and forwards the browser back to the originating app.
*/
export function buildProviderAuthUrl(
p: ProviderLoginParams,
origin: string,
search: string,
callbackOrigin: string = origin,
): string | null {
const info = AUTH_INFO[p.type]
if (!info || !p.clientId) return null
const scope = p.scopes && p.scopes.trim() !== '' ? p.scopes : info.scope
const redirectUri = `${callbackOrigin}/callback`
const method = p.method ?? 'signin'
// Base64 of the original OIDC query + routing — the backend decodes this on
// the /callback return to complete the original authorize request.
const state = btoa(`${search}&application=${encodeURIComponent(p.application)}&provider=${encodeURIComponent(p.providerName)}&method=${method}`)
return `${info.endpoint}?client_id=${p.clientId}&redirect_uri=${redirectUri}&scope=${scope}&response_type=code&state=${state}`
}
/** True when this portal knows how to start an OAuth hop for the given type. */
export function isHoppableProvider(type: string): boolean {
return type in AUTH_INFO
}
/**
* Redirect the browser to the provider to begin login. No-op return on bad input.
*
* `callbackOrigin` (the provider's registered redirect host, e.g.
* `https://iam.hanzo.ai`) defaults to the current origin when omitted.
*/
export function startProviderLogin(p: ProviderLoginParams, callbackOrigin?: string): void {
if (typeof window === 'undefined') return
const url = buildProviderAuthUrl(p, window.location.origin, window.location.search, callbackOrigin ?? window.location.origin)
if (url) window.location.assign(url)
}
+1 -110
View File
@@ -6,79 +6,19 @@ export interface LoginRequest {
readonly organization: string
readonly redirectUri?: string
readonly state?: string
readonly codeChallenge?: string
readonly codeChallengeMethod?: 'S256' | 'plain'
}
/** 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 mfaStage?: 'enroll' | 'challenge'
/**
* The IAM MFA types available for a `'challenge'` (from the login response's
* `data2`), in IAM's own vocabulary: `app` (TOTP), `sms`, `email`. Empty for
* enrollment.
*/
readonly mfaTypes?: readonly string[]
readonly mfaChannel?: 'totp' | 'sms' | 'email'
readonly error?: string
}
/**
* 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
@@ -156,52 +96,3 @@ 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
@@ -1,8 +0,0 @@
/** 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>
)
}
+166 -37
View File
@@ -1,26 +1,58 @@
import { useState, type FormEvent } from 'react'
import { useEffect, useState, type FormEvent } from 'react'
import type { AuthClient } from '../client'
import type { LoginResponse } from '../types'
import type { AppLoginInfo, LoginResponse } from '../types'
import { ProviderButtons } from './ProviderButtons'
import { OTPForm } from './OTPForm'
export interface LoginFormProps {
readonly client: AuthClient
readonly redirectUri?: string
readonly state?: string
readonly clientIdOverride?: string
readonly codeChallenge?: string
readonly codeChallengeMethod?: 'S256' | 'plain'
readonly onSuccess?: (res: LoginResponse) => void
readonly onMfaRequired?: (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)
async function onSubmit(e: FormEvent) {
// 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) {
e.preventDefault()
setBusy(true)
setError(null)
@@ -33,17 +65,27 @@ export function LoginForm(props: LoginFormProps) {
organization: client.tenant.orgId,
redirectUri: props.redirectUri,
state: props.state,
codeChallenge: props.codeChallenge,
codeChallengeMethod: props.codeChallengeMethod,
})
if (res.error) {
setError(res.error)
} else if (res.mfaRequired) {
props.onMfaRequired?.(res)
} else if (res.redirectUrl) {
window.location.href = res.redirectUrl
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}`)
} else {
props.onSuccess?.(res)
setError(r.error ?? 'Could not send code')
}
} catch (err) {
setError(String(err))
@@ -52,30 +94,117 @@ 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 (
<form onSubmit={onSubmit} 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
<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}
/>
</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>
) : 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>
)
}
-132
View File
@@ -1,132 +0,0 @@
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>
)
}
-2
View File
@@ -1,5 +1,4 @@
import { useState, type FormEvent } from 'react'
import { SmsConsentNotice } from './SmsConsent'
export interface OTPFormProps {
readonly onSubmit: (code: string) => void | Promise<void>
@@ -40,7 +39,6 @@ export function OTPForm(props: OTPFormProps) {
required
/>
</label>
{channel === 'sms' ? <SmsConsentNotice /> : null}
<button type="submit" disabled={busy || code.length !== length}>{busy ? 'Verifying…' : 'Verify'}</button>
</form>
)
+41 -20
View File
@@ -1,5 +1,7 @@
import { useState, type FormEvent } from 'react'
import { useEffect, 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
@@ -9,11 +11,25 @@ export interface SignupFormProps {
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)
@@ -38,24 +54,29 @@ export function SignupForm(props: SignupFormProps) {
}
return (
<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 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>
)
}
-38
View File
@@ -1,38 +0,0 @@
// 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>
)
}
-169
View File
@@ -1,169 +0,0 @@
import { useEffect, useState } from 'react'
import type { ComponentType, SVGProps } from 'react'
import type { AuthClient } from '../client'
import type { AppProvider } from '../types'
import { createIam } from '../iam'
import { startProviderLogin, isHoppableProvider } from '../social'
import { GitHubIcon, GoogleIcon, WalletIcon } from './icons'
import { Divider } from './Divider'
/**
* Social + Web3 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.
*
* Each OAuth button drives the provider "hop" (`startProviderLogin`) — it
* redirects straight to GitHub/Google with a Casdoor-compatible state that
* round-trips the original authorize request, so the IAM backend's `/callback`
* exchange completes it. (Web3/wallet falls back to the `@hanzo/iam` redirect.)
*/
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. Social/Web3 sign-in always returns to the portal's own `/callback`
* (the SDK's fixed redirectUri), so we stash this target before the
* redirect; `Callback` reads it back and forwards the tokens there. Absent
* → a bare portal sign-in that lands on onboarding.
*/
readonly postLoginRedirect?: string
}
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 },
google: { key: 'google', label: 'Google', Icon: GoogleIcon },
web3: { key: 'web3', label: 'Wallet', Icon: WalletIcon },
}
/** Canonical render order. */
const ORDER = ['github', 'google', 'web3']
interface Resolved {
/** IAM application name (for the provider-hop state). */
readonly application: string
/** Configured + renderable providers, keyed by their normalized key. */
readonly providers: Record<string, AppProvider>
}
export function SocialButtons({
client,
clientIdOverride,
intent = 'signin',
postLoginRedirect,
}: SocialButtonsProps) {
const [resolved, setResolved] = useState<Resolved | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
client
.getAppLogin(clientIdOverride)
.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({ application: '', providers: {} })
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.
const providers: Record<string, AppProvider> = {}
for (const p of app.providers) {
if (want(p) && p.configured && p.key in PROVIDER_META) providers[p.key] = p
}
setResolved({ application: app.application, providers })
})
.catch(() => {
if (!cancelled) setResolved({ application: '', providers: {} })
})
return () => {
cancelled = true
}
}, [client, clientIdOverride, intent])
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 start(provider: AppProvider) {
setError(null)
// Persist the downstream target across the IAM round-trip; `Callback`
// reads it back and forwards tokens there (else lands on onboarding).
if (postLoginRedirect) sessionStorage.setItem('post_login_redirect', postLoginRedirect)
else sessionStorage.removeItem('post_login_redirect')
const method = intent === 'signup' ? 'signup' : 'signin'
// OAuth providers (github/google) hop straight to the provider; wallet/web3
// falls back to the @hanzo/iam redirect.
if (isHoppableProvider(provider.type)) {
startProviderLogin(
{
application: resolved!.application,
providerName: provider.name,
type: provider.type,
clientId: provider.clientId,
scopes: provider.scopes,
method,
},
// The shared OAuth client is registered against the IAM backend's
// /callback (not this brand host), so the hop must return there or the
// provider rejects the redirect_uri. Catalog-driven; defaults to host.
client.tenant.oauthCallbackOrigin,
)
return
}
const iam = createIam(client.tenant, clientIdOverride)
iam.signinRedirect({ additionalParams: { provider: provider.key } }).catch((e) => {
setError(String(e))
})
}
return (
<>
<div className="hanzo-id-social">
{ordered.map((k) => {
const meta = PROVIDER_META[k]
const { Icon } = meta
const provider = resolved.providers[k]!
return (
<button
key={k}
type="button"
className="hanzo-id-social-btn"
data-provider={k}
onClick={() => start(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 />
</>
)
}
-43
View File
@@ -1,43 +0,0 @@
/**
* 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 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>
)
}
+1 -4
View File
@@ -2,7 +2,4 @@ export { LoginForm } from './LoginForm'
export { SignupForm } from './SignupForm'
export { ForgotForm } from './ForgotForm'
export { OTPForm } from './OTPForm'
export { MfaEnrollForm, type MfaEnrollFormProps } from './MfaEnrollForm'
export { SmsConsentNotice, SMS_CONSENT_TEXT } from './SmsConsent'
export { SocialButtons, type SocialButtonsProps } from './SocialButtons'
export { Divider } from './Divider'
export { ProviderButtons } from './ProviderButtons'
+1 -2
View File
@@ -4,6 +4,5 @@
"outDir": "dist",
"noEmit": true
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
"include": ["src"]
}
-36
View File
@@ -1,36 +0,0 @@
{
"name": "@hanzo/id-onboarding",
"version": "0.1.0",
"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",
"test": "node --test --experimental-strip-types 'src/**/*.test.ts'"
},
"dependencies": {
"@hanzo/id-shared": "workspace:*",
"@hanzo/iam": "^0.11.0"
},
"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"
}
}
-108
View File
@@ -1,108 +0,0 @@
/**
* 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' | '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,
},
] as const
/** A minimal org reference the UI lists in the "choose org" step. */
export interface OrgRef {
/** Casdoor 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
}
/** 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
}
-30
View File
@@ -1,30 +0,0 @@
// @hanzo/id-onboarding — post-login onboarding for the Hanzo ID portal.
//
// Three-step flow: choose/create org → optional project → optional wallet
// link. 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,
} from './domain/types'
// ── Service ─────────────────────────────────────────────────────
export {
createOnboardingService,
type OnboardingService,
type OnboardingServiceOptions,
type Result,
} from './service/onboarding'
// ── UI ──────────────────────────────────────────────────────────
export { OnboardingFlow, type OnboardingFlowProps } from './ui/OnboardingFlow'
-143
View File
@@ -1,143 +0,0 @@
/**
* 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 'node:test'
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 → done', () => {
assert.equal(STEPS[0]!.id, 'org')
assert.equal(nextStep('org'), 'project')
assert.equal(nextStep('project'), 'wallet')
assert.equal(nextStep('wallet'), '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')
})
test('only org is required; project and wallet are skippable', () => {
assert.equal(stepById('org')!.skippable, false)
assert.equal(stepById('project')!.skippable, true)
assert.equal(stepById('wallet')!.skippable, true)
})
// ── 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 returns [] (not throw) on a server error', async () => {
const { service } = harness(() => ({ status: 500, json: { status: 'error', msg: 'boom' } }))
assert.deepEqual(await service.listOrgs(), [])
})
test('createOrg posts the org and reports IAM error messages', async () => {
const ok = harness(() => ({ json: { status: 'ok' } }))
const res = await ok.service.createOrg({ name: 'acme', displayName: 'Acme Inc' })
assert.equal(ok.calls[0]!.url, 'https://hanzo.id/v1/iam/add-organization')
assert.equal(ok.calls[0]!.method, 'POST')
const sent = JSON.parse(ok.calls[0]!.body!)
assert.equal(sent.name, 'acme')
assert.equal(sent.displayName, 'Acme Inc')
assert.deepEqual(res, { ok: true, value: { name: 'acme', displayName: 'Acme Inc' } })
const denied = harness(() => ({ status: 403, json: { status: 'error', msg: 'permission denied' } }))
const fail = await denied.service.createOrg({ name: 'x', displayName: 'X' })
assert.deepEqual(fail, { ok: false, error: 'HTTP 403' })
})
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)
})
test('linkWallet resolves the user via get-account then writes web3onboard', 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' } } }
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, column-scoped
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'))
assert.ok(upd.url.includes('columns=web3onboard'))
const sent = JSON.parse(upd.body!)
assert.equal(sent.web3onboard, addr)
assert.equal(sent.owner, 'hanzo')
assert.equal(sent.name, 'alice')
})
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' })
})
-207
View File
@@ -1,207 +0,0 @@
/**
* 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 Casdoor-compat paths the auth client uses), carrying
* the user's bearer token. 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/add-organization
* createProject POST /v1/iam/add-project
* linkWallet() client-side wallet connect → IAM update-user (host-driven)
*
* Token is supplied by the host through `getAccessToken` (the portal already
* holds the session after login). The service never stores it.
*/
import type { Organization, Project } from '@hanzo/iam'
import type { OrgRef, 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>>
}
export interface OnboardingServiceOptions {
/** IAM origin, no trailing slash (the tenant'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)
}
async function createOrg(input: { name: string; displayName: string }): Promise<Result<OrgRef>> {
const url = new URL('/v1/iam/add-organization', base)
const org: Partial<Organization> = {
owner: 'admin',
name: input.name,
displayName: input.displayName,
isPersonal: false,
balanceCurrency: 'USD',
}
return writeRecord(url, org, () => ({ name: input.name, displayName: input.displayName }))
}
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' }
// Resolve the signed-in user (owner/name) from the session — IAM's
// update-user is keyed by `id=<owner>/<name>`, not a "self" alias.
const account = await getAccount()
if (!account) return { ok: false, error: 'not signed in' }
const url = new URL('/v1/iam/update-user', base)
url.searchParams.set('id', `${account.owner}/${account.name}`)
// Scope the write to the single `web3onboard` column so the rest of the
// user row is untouched (Casdoor replaces unscoped writes wholesale).
url.searchParams.set('columns', 'web3onboard')
try {
const res = await f(url.toString(), {
method: 'POST',
headers: await authHeaders(),
credentials: 'include',
// Casdoor's User JSON tag is lowercase `web3onboard`; send the full
// owner/name so the row identity is unambiguous on the server.
body: JSON.stringify({ owner: account.owner, name: account.name, web3onboard: trimmed }),
})
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: trimmed }
} catch (e) {
return { ok: false, error: String(e) }
}
}
/** Read the signed-in user's `{owner, name}` from `/v1/iam/get-account`. */
async function getAccount(): Promise<{ owner: string; name: string } | 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>
const owner = typeof data.owner === 'string' ? data.owner : ''
const name = typeof data.name === 'string' ? data.name : ''
if (!owner || !name) return null
return { owner, name }
} 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 }
}
/** Pull the array payload out of an IAM list response (`data` or `data2`). */
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)
}
-452
View File
@@ -1,452 +0,0 @@
import { useCallback, useEffect, useReducer, useState, type FormEvent } from 'react'
import {
STEPS,
nextStep,
prevStep,
stepById,
type OnboardingState,
type OrgRef,
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 tenant 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
}
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 }: 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 === '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 [orgs, setOrgs] = useState<OrgRef[] | null>(null)
const [mode, setMode] = useState<'pick' | 'create'>('pick')
const [displayName, setDisplayName] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
service.listOrgs().then((list) => {
if (cancelled) return
setOrgs(list)
// No existing memberships → drop straight into create mode.
if (list.length === 0) setMode('create')
})
return () => {
cancelled = true
}
}, [service])
async function pick(org: OrgRef) {
onNext({ orgName: org.name, orgCreated: false })
}
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 })
}
if (orgs === null) return <p className="hanzo-id-info">Loading your organizations</p>
return (
<div className="hanzo-id-onboarding-body">
{mode === 'pick' && orgs.length > 0 ? (
<>
<ul className="hanzo-id-org-list">
{orgs.map((o) => (
<li key={o.name}>
<button type="button" className="hanzo-id-org-row" onClick={() => pick(o)}>
<span>{o.displayName}</span>
<span className="hanzo-id-org-slug">{o.name}</span>
</button>
</li>
))}
</ul>
<button type="button" className="hanzo-id-linkbtn" onClick={() => setMode('create')}>
+ Create a new organization
</button>
</>
) : (
<form onSubmit={create} aria-busy={busy}>
<label>
<span>Organization name</span>
<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">
{orgs.length > 0 ? (
<button type="button" className="hanzo-id-btn ghost" onClick={() => setMode('pick')}>
Back
</button>
) : (
// No org to fall back to and creation may be denied — let the
// user proceed rather than dead-end. They land org-less; an
// admin can add them to an org later.
<button type="button" className="hanzo-id-btn ghost" onClick={() => onNext({})} disabled={busy}>
Skip for now
</button>
)}
<button type="submit" className="hanzo-id-btn primary" 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 primary" onClick={() => onNext({})}>
Continue
</button>
</div>
</div>
)
}
return (
<div className="hanzo-id-onboarding-body">
<form onSubmit={create} aria-busy={busy}>
<label>
<span>Project name</span>
<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 primary" 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 primary" onClick={link} disabled={busy}>
{busy ? 'Connecting…' : 'Connect wallet'}
</button>
) : null}
</div>
</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
@@ -1,9 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"noEmit": true
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}
+2 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@hanzo/id-shared",
"version": "0.1.1",
"version": "0.1.0",
"description": "Shared types + tenant resolver for the Hanzo ID portal. No UI deps.",
"license": "BSD-3-Clause",
"type": "module",
@@ -15,8 +15,7 @@
"files": ["src"],
"scripts": {
"tc": "tsc --noEmit",
"build": "tsc --noEmit",
"test": "node --test --experimental-strip-types 'src/**/*.test.ts'"
"build": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^5.9.3"
+6 -43
View File
@@ -14,30 +14,13 @@ import type { BrandContract } from './types'
* `assets/` directory at this path).
*/
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.
// Browser: served by the app from /brand/<pkg>/brand.json
if (typeof window !== 'undefined') {
// 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) {
const raw = await res.json()
return raw.brand as BrandContract
}
} catch {
// network error — fall through to retry
}
if (attempt < 2) await new Promise((r) => setTimeout(r, 150 * (attempt + 1)))
}
return fallbackBrand(brandPackage)
const url = `/brand/${encodeURIComponent(brandPackage)}/brand.json`
const res = await fetch(url, { cache: 'no-store' })
if (!res.ok) throw new Error(`brand.json fetch failed: ${res.status} for ${brandPackage}`)
const raw = await res.json()
return raw.brand as BrandContract
}
// Node: dynamic import (build step + SSR fallback)
const mod = (await import(/* @vite-ignore */ `${brandPackage}/brand.json`, {
@@ -46,26 +29,6 @@ export async function loadBrand(brandPackage: string): Promise<BrandContract> {
return mod.default.brand
}
/**
* 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 tenants whose scope differs from their display name are mapped.
*/
function fallbackBrand(brandPackage: string): BrandContract {
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: name,
description: '',
appDomain: '',
logoUrl: '',
faviconUrl: '',
}
}
/** Subset of the brand contract safe to expose to the browser as window.__BRAND__. */
export interface BrandRuntime {
readonly name: string
-87
View File
@@ -1,87 +0,0 @@
/**
* Tenant-resolver tests — run with the Node built-in runner + native TS strip:
*
* pnpm --filter @hanzo/id-shared test
*
* Focus: a host that exists ONLY in the runtime catalog (no built-in entry)
* must resolve to ITS OWN brand and issuer — never inherit Hanzo's. This is the
* osage.id brand-leak regression: the catalog carries `brandUrl`, and the
* resolver must map it to `brandPackage` and derive issuer/origin from the host.
*/
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { resolveTenant, parseCatalog } from './tenant.ts'
// Mirrors the K8s ConfigMap shape: entries carry `brandUrl`, not `brandPackage`.
const CATALOG = {
'lux.id': {
orgId: 'lux',
clientId: 'lux-cloud',
appName: 'lux-cloud',
brandUrl: 'https://cdn.jsdelivr.net/npm/@luxfi/brand@latest/brand.json',
},
'osage.id': {
orgId: 'osage',
clientId: 'osage-id-portal',
appName: 'osage-id',
brandUrl: 'https://cdn.jsdelivr.net/npm/@osage/brand@latest/brand.json',
},
}
test('a built-in host resolves to its own brand with no catalog', () => {
const t = resolveTenant('hanzo.id')
assert.equal(t.orgId, 'hanzo')
assert.equal(t.brandPackage, '@hanzo/brand')
assert.equal(t.iamUrl, 'https://hanzo.id')
})
test('a catalog entry overrides clientId/appName but keeps a consistent brand', () => {
const t = resolveTenant('lux.id', { catalog: CATALOG })
assert.equal(t.orgId, 'lux')
assert.equal(t.clientId, 'lux-cloud')
assert.equal(t.brandPackage, '@luxfi/brand')
assert.equal(t.iamUrl, 'https://lux.id')
assert.equal(t.publicOrigin, 'https://lux.id')
})
test('a catalog-ONLY host does NOT leak the Hanzo brand (osage.id regression)', () => {
const t = resolveTenant('osage.id', { catalog: CATALOG })
assert.equal(t.orgId, 'osage')
assert.equal(t.clientId, 'osage-id-portal')
// brandUrl is mapped onto brandPackage, and it is NOT Hanzo's.
assert.equal(t.brandPackage, '@osage/brand')
assert.notEqual(t.brandPackage, '@hanzo/brand')
// issuer + origin are the host itself, never hanzo.id.
assert.equal(t.iamUrl, 'https://osage.id')
assert.equal(t.iamIssuer, 'https://osage.id')
assert.equal(t.publicOrigin, 'https://osage.id')
})
test('pars built-in uses the working pars-console portal app (not the missing pars-id)', () => {
const t = resolveTenant('pars.id')
assert.equal(t.clientId, 'pars-console')
assert.equal(t.brandPackage, '@parsdao/brand')
})
test('osage built-in resolves to Osage even with NO catalog (fallback safety)', () => {
const t = resolveTenant('osage.id')
assert.equal(t.orgId, 'osage')
assert.equal(t.brandPackage, '@osage/brand')
assert.notEqual(t.brandPackage, '@hanzo/brand')
assert.equal(t.iamUrl, 'https://osage.id')
})
test('an unknown host falls back to the default org but keeps its own origin', () => {
const t = resolveTenant('preview.example.com')
assert.equal(t.orgId, 'hanzo')
assert.equal(t.publicOrigin, 'https://preview.example.com')
})
test('parseCatalog tolerates junk', () => {
assert.deepEqual(parseCatalog(undefined), {})
assert.deepEqual(parseCatalog(null), {})
assert.deepEqual(parseCatalog('not json'), {})
assert.deepEqual(parseCatalog('{"osage.id":{"orgId":"osage"}}'), {
'osage.id': { orgId: 'osage' },
})
})
+12 -117
View File
@@ -16,26 +16,10 @@ import type { TenantConfig } from './types'
const TRIM_TRAILING_SLASH = (s: string): string => s.replace(/\/+$/, '')
/**
* Built-in tenants for the four canonical identity hosts.
*
* `iamUrl` is the per-brand OIDC ISSUER — the host that serves
* `/.well-known/openid-configuration` and the `/v1/iam/*` surface. Per
* HIP-0111 this is the brand's own `*.id` host (hanzo.id / lux.id / …),
* NOT `iam.hanzo.ai`: discovery must be host-relative so the SDK never
* resolves to the wrong origin (or the IAM SPA HTML catch-all). The IAM
* backend tenant-scopes on the `organization` body param; one backend
* serves every brand behind its own issuer host.
*
* `clientId` is the brand `-id` app registered in `init_data.json`
* (`hanzo-id`, `lux-id`, …) so the portal authenticates as that app — the
* same app whose enabled providers (password + GitHub + Google + Web3)
* `get-app-login` reports.
*/
const DEFAULT_TENANTS: Record<string, TenantConfig> = {
'hanzo.id': {
orgId: 'hanzo',
iamUrl: 'https://hanzo.id',
iamUrl: 'https://iam.hanzo.ai',
iamIssuer: 'https://hanzo.id',
clientId: 'hanzo-id',
appName: 'hanzo-id',
@@ -44,7 +28,7 @@ const DEFAULT_TENANTS: Record<string, TenantConfig> = {
},
'lux.id': {
orgId: 'lux',
iamUrl: 'https://lux.id',
iamUrl: 'https://iam.hanzo.ai',
iamIssuer: 'https://lux.id',
clientId: 'lux-id',
appName: 'lux-id',
@@ -53,7 +37,7 @@ const DEFAULT_TENANTS: Record<string, TenantConfig> = {
},
'zoo.id': {
orgId: 'zoo',
iamUrl: 'https://zoo.id',
iamUrl: 'https://iam.hanzo.ai',
iamIssuer: 'https://zoo.id',
clientId: 'zoo-id',
appName: 'zoo-id',
@@ -62,53 +46,18 @@ const DEFAULT_TENANTS: Record<string, TenantConfig> = {
},
'pars.id': {
orgId: 'pars',
iamUrl: 'https://pars.id',
iamUrl: 'https://iam.hanzo.ai',
iamIssuer: 'https://pars.id',
// The portal app is `pars-console` (it carries the https://pars.id/callback
// redirect); a bare `pars-id` app does not exist in IAM.
clientId: 'pars-console',
appName: 'pars-console',
clientId: 'pars-id',
appName: 'pars-id',
publicOrigin: 'https://pars.id',
brandPackage: '@parsdao/brand',
},
// Osage is served by this portal too; without a built-in it would fall back
// to the Hanzo default and leak the wrong brand if the runtime catalog ever
// fails to load. (osage-id-portal is pre-launch — no IAM app yet — but the
// brand must read as Osage, never Hanzo.)
'osage.id': {
orgId: 'osage',
iamUrl: 'https://osage.id',
iamIssuer: 'https://osage.id',
clientId: 'osage-id-portal',
appName: 'osage-id',
publicOrigin: 'https://osage.id',
brandPackage: '@osage/brand',
},
'www.osage.id': {
orgId: 'osage',
iamUrl: 'https://www.osage.id',
iamIssuer: 'https://www.osage.id',
clientId: 'osage-id-portal',
appName: 'osage-id',
publicOrigin: 'https://www.osage.id',
brandPackage: '@osage/brand',
},
}
/**
* A runtime catalog entry as it appears in the K8s ConfigMap / `/config.json`.
* It carries the human-authored shape — notably `brandUrl` (a CDN URL), which
* this module maps onto the code-facing `brandPackage`. All fields optional;
* whatever is present overrides the host-derived base.
*/
export type CatalogEntry = Partial<TenantConfig> & {
/** CDN URL of the brand package, e.g. `…/npm/@osage/brand@latest/brand.json`. */
readonly brandUrl?: string
}
export interface ResolveOptions {
/** Optional runtime catalog (parsed from IAM_TENANT_CONFIG_JSON or /config.json). */
readonly catalog?: Record<string, CatalogEntry>
readonly catalog?: Record<string, Partial<TenantConfig>>
/** Default org slug when host has no entry. */
readonly defaultOrg?: string
}
@@ -118,11 +67,10 @@ export function resolveTenant(hostname: string, opts: ResolveOptions = {}): Tena
const catalogEntry = opts.catalog?.[host]
const builtIn = DEFAULT_TENANTS[host]
if (catalogEntry || builtIn) {
// Base = the built-in tenant if one exists, else a skeleton derived from
// THIS host. Never another brand's config: a catalog-only host (osage.id,
// zoolabs.id) must not inherit Hanzo's issuer or brand package.
const base = builtIn ?? hostSkeleton(host)
const merged: TenantConfig = { ...base, ...fromCatalog(catalogEntry) } as TenantConfig
const merged: TenantConfig = {
...(builtIn ?? DEFAULT_TENANTS['hanzo.id']),
...(catalogEntry ?? {}),
} as TenantConfig
return normalize(merged)
}
const defaultOrg = opts.defaultOrg ?? 'hanzo'
@@ -130,69 +78,16 @@ export function resolveTenant(hostname: string, opts: ResolveOptions = {}): Tena
return normalize({ ...fallback, publicOrigin: `https://${host}` })
}
/**
* A host-derived tenant skeleton for a catalog-only host (no built-in entry).
* URLs point at the host itself so nothing leaks from another brand; the
* catalog entry spread over this supplies orgId / clientId / appName /
* brandPackage. brandPackage defaults empty → the brand loader falls back to a
* neutral wordmark rather than showing the wrong brand.
*/
function hostSkeleton(host: string): TenantConfig {
return {
orgId: '',
iamUrl: `https://${host}`,
iamIssuer: `https://${host}`,
clientId: '',
appName: '',
publicOrigin: `https://${host}`,
brandPackage: '',
}
}
/**
* Project a catalog entry onto a TenantConfig patch, mapping `brandUrl` →
* `brandPackage` (the code-facing field) when an explicit `brandPackage` isn't
* given. Only defined string fields are emitted, so the host-derived base shows
* through for anything the entry omits.
*/
function fromCatalog(entry: CatalogEntry | undefined): Partial<TenantConfig> {
if (!entry) return {}
const out: Record<string, string> = {}
for (const k of ['orgId', 'iamUrl', 'iamIssuer', 'clientId', 'appName', 'publicOrigin', 'oauthCallbackOrigin', 'brandPackage'] as const) {
const v = entry[k]
if (typeof v === 'string' && v.length > 0) out[k] = v
}
if (!out.brandPackage && typeof entry.brandUrl === 'string') {
const pkg = brandPackageFromUrl(entry.brandUrl)
if (pkg) out.brandPackage = pkg
}
return out as Partial<TenantConfig>
}
/**
* Extract the npm package name from a CDN brand URL, e.g.
* `https://cdn.jsdelivr.net/npm/@osage/brand@latest/brand.json` → `@osage/brand`.
*/
function brandPackageFromUrl(url: string): string {
const m = /\/npm\/(@[^/]+\/[^@/]+|[^@/]+)(?:@|\/)/.exec(url)
return m ? m[1]! : ''
}
function stripPort(h: string): string {
return h.replace(/:\d+$/, '')
}
function normalize(t: TenantConfig): TenantConfig {
const publicOrigin = TRIM_TRAILING_SLASH(t.publicOrigin)
return {
...t,
iamUrl: TRIM_TRAILING_SLASH(t.iamUrl),
iamIssuer: TRIM_TRAILING_SLASH(t.iamIssuer || t.iamUrl),
publicOrigin,
// The social OAuth hop's redirect_uri must hit the provider's registered
// callback host. Default to this host; brands sharing a single OAuth client
// override it (via the catalog) to that client's registered origin.
oauthCallbackOrigin: TRIM_TRAILING_SLASH(t.oauthCallbackOrigin || publicOrigin),
publicOrigin: TRIM_TRAILING_SLASH(t.publicOrigin),
}
}
-13
View File
@@ -18,21 +18,8 @@ export interface TenantConfig {
readonly appName: string
/** Canonical public origin for the host (used for OIDC discovery rewrites). */
readonly publicOrigin: string
/**
* Origin whose `/callback` is registered as the social OAuth providers'
* authorized redirect URI. The shared GitHub/Google OAuth clients are
* registered against ONE callback host — the IAM backend (`iam.hanzo.ai`) —
* so the provider hop MUST send `redirect_uri=<oauthCallbackOrigin>/callback`
* or the provider rejects it with `redirect_uri_mismatch`. That host serves
* the same headless `Callback` SPA, which completes the exchange and forwards
* back to the originating app. Defaults to `publicOrigin` (per-host clients /
* local dev). NO trailing slash. */
readonly oauthCallbackOrigin?: string
/** npm package name of the brand pkg to load (e.g. `@hanzo/brand`). */
readonly brandPackage: string
/** Optional absolute URL to brand.json (e.g. a jsDelivr-hosted copy from
* config.json). Preferred over the app-local /brand/<pkg>/brand.json. */
readonly brandUrl?: string
}
/**
+1 -2
View File
@@ -4,6 +4,5 @@
"outDir": "dist",
"noEmit": true
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
"include": ["src"]
}
+146 -183
View File
@@ -4,6 +4,10 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
overrides:
esbuild: ^0.28.1
uuid: ^11.1.1
importers:
.:
@@ -21,17 +25,14 @@ importers:
specifier: ^7.2.4
version: 7.3.0(expo@56.0.12)(react-dom@19.2.7(react@19.2.7))(react-native@0.86.0(@babel/core@7.29.7)(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)(sf-symbols-typescript@2.2.0)
'@hanzo/iam':
specifier: ^0.11.0
version: 0.11.0(react@19.2.7)
specifier: ^0.9.4
version: 0.9.4(react@19.2.7)
'@hanzo/id-auth':
specifier: workspace:*
version: link:../../pkgs/auth
'@hanzo/id-idv':
specifier: workspace:*
version: link:../../pkgs/idv
'@hanzo/id-onboarding':
specifier: workspace:*
version: link:../../pkgs/onboarding
'@hanzo/id-shared':
specifier: workspace:*
version: link:../../pkgs/shared
@@ -73,14 +74,11 @@ importers:
pkgs/auth:
dependencies:
'@hanzo/iam':
specifier: ^0.11.0
version: 0.11.0(react@19.2.7)
specifier: ^0.9.4
version: 0.9.4(react@19.2.7)
'@hanzo/id-shared':
specifier: workspace:*
version: link:../shared
'@paulmillr/qr':
specifier: ^0.3.0
version: 0.3.0
devDependencies:
'@types/react':
specifier: ^19.0.0
@@ -114,31 +112,6 @@ importers:
specifier: ^5.9.3
version: 5.9.3
pkgs/onboarding:
dependencies:
'@hanzo/iam':
specifier: ^0.11.0
version: 0.11.0(react@19.2.7)
'@hanzo/id-shared':
specifier: workspace:*
version: link:../shared
devDependencies:
'@types/node':
specifier: ^22.0.0
version: 22.19.21
'@types/react':
specifier: ^19.0.0
version: 19.2.17
react:
specifier: ^19.2.0
version: 19.2.7
react-dom:
specifier: ^19.2.0
version: 19.2.7(react@19.2.7)
typescript:
specifier: ^5.9.3
version: 5.9.3
pkgs/shared:
devDependencies:
typescript:
@@ -508,158 +481,158 @@ packages:
resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
engines: {node: '>=6.9.0'}
'@esbuild/aix-ppc64@0.27.7':
resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==}
'@esbuild/aix-ppc64@0.28.1':
resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.27.7':
resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==}
'@esbuild/android-arm64@0.28.1':
resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.27.7':
resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==}
'@esbuild/android-arm@0.28.1':
resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.27.7':
resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==}
'@esbuild/android-x64@0.28.1':
resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.27.7':
resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==}
'@esbuild/darwin-arm64@0.28.1':
resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.27.7':
resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==}
'@esbuild/darwin-x64@0.28.1':
resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.27.7':
resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==}
'@esbuild/freebsd-arm64@0.28.1':
resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.27.7':
resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==}
'@esbuild/freebsd-x64@0.28.1':
resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.27.7':
resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==}
'@esbuild/linux-arm64@0.28.1':
resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.27.7':
resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==}
'@esbuild/linux-arm@0.28.1':
resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.27.7':
resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==}
'@esbuild/linux-ia32@0.28.1':
resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.27.7':
resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==}
'@esbuild/linux-loong64@0.28.1':
resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.27.7':
resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==}
'@esbuild/linux-mips64el@0.28.1':
resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.27.7':
resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==}
'@esbuild/linux-ppc64@0.28.1':
resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.27.7':
resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==}
'@esbuild/linux-riscv64@0.28.1':
resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.27.7':
resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==}
'@esbuild/linux-s390x@0.28.1':
resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.27.7':
resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==}
'@esbuild/linux-x64@0.28.1':
resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.27.7':
resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==}
'@esbuild/netbsd-arm64@0.28.1':
resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.27.7':
resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==}
'@esbuild/netbsd-x64@0.28.1':
resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.27.7':
resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==}
'@esbuild/openbsd-arm64@0.28.1':
resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.27.7':
resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==}
'@esbuild/openbsd-x64@0.28.1':
resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.27.7':
resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==}
'@esbuild/openharmony-arm64@0.28.1':
resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.27.7':
resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==}
'@esbuild/sunos-x64@0.28.1':
resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.27.7':
resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==}
'@esbuild/win32-arm64@0.28.1':
resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.27.7':
resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==}
'@esbuild/win32-ia32@0.28.1':
resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.27.7':
resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==}
'@esbuild/win32-x64@0.28.1':
resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
@@ -850,8 +823,8 @@ packages:
react: '>=19'
react-native: '*'
'@hanzo/iam@0.11.0':
resolution: {integrity: sha512-AGTYSl+En0Ci3UTBJs/kJ1MjxIAKOW9pMwLD6QCFLsBZIZCp1JljESepTEs4aBLifK0L3h/j3ya53ZyU3dSXjA==}
'@hanzo/iam@0.9.4':
resolution: {integrity: sha512-IJzjvwZct2TB1PpMaWJvytud/DYLTek14qZ8UBpTjVtFWm/bkgcny46CfmhE7IA8EGKCkV8Fvr4Nu82K6eNqpA==}
engines: {node: '>=18'}
peerDependencies:
react: '>=17'
@@ -1463,10 +1436,6 @@ packages:
peerDependencies:
react: '>=18.0.0'
'@paulmillr/qr@0.3.0':
resolution: {integrity: sha512-3s/cagXuoXTA2gWSfSfJNanNgm2ifmqgoX8WLOs5//3qrIJ3WWHFjqFqCxvYGf46Afwv6PctT9eAOXLDGwp96Q==}
deprecated: 'Switch to "qr" (new package name) for security updates: npm install qr'
'@react-native/assets-registry@0.86.0':
resolution: {integrity: sha512-nIaXbm2jX1OTYp0qbviJ3O6KZivoE8z3BnhUQ2LsqfZSWRoOK/n1qsiAr6oALiNKWnXY3j2KPwtYORnZzp8xew==}
engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0}
@@ -1588,66 +1557,79 @@ packages:
resolution: {integrity: sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.62.0':
resolution: {integrity: sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.62.0':
resolution: {integrity: sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.62.0':
resolution: {integrity: sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.62.0':
resolution: {integrity: sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.62.0':
resolution: {integrity: sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.62.0':
resolution: {integrity: sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.62.0':
resolution: {integrity: sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.62.0':
resolution: {integrity: sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.62.0':
resolution: {integrity: sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.62.0':
resolution: {integrity: sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.62.0':
resolution: {integrity: sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.62.0':
resolution: {integrity: sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.62.0':
resolution: {integrity: sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg==}
@@ -1730,9 +1712,6 @@ packages:
'@types/istanbul-reports@3.0.4':
resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==}
'@types/node@22.19.21':
resolution: {integrity: sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==}
'@types/node@25.9.3':
resolution: {integrity: sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==}
@@ -2095,8 +2074,8 @@ packages:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'}
esbuild@0.27.7:
resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==}
esbuild@0.28.1:
resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==}
engines: {node: '>=18'}
hasBin: true
@@ -2429,9 +2408,6 @@ packages:
resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==}
engines: {node: '>=6'}
libphonenumber-js@1.13.7:
resolution: {integrity: sha512-rvr3HIMdOgzhz1RFGjftji+wjoAFlzhqCNqJOU/MKTZQ8d9NZxAR/tI+0weDicyoucqVR0U1GCniqHJ0f8aM2A==}
lighthouse-logger@1.4.2:
resolution: {integrity: sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==}
@@ -2470,24 +2446,28 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
@@ -3069,9 +3049,6 @@ packages:
uid2@0.0.4:
resolution: {integrity: sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==}
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
undici-types@7.24.6:
resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
@@ -3110,9 +3087,8 @@ packages:
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
engines: {node: '>= 0.4.0'}
uuid@7.0.3:
resolution: {integrity: sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==}
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
uuid@11.1.1:
resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==}
hasBin: true
validate-npm-package-name@5.0.1:
@@ -3716,82 +3692,82 @@ snapshots:
'@babel/helper-string-parser': 7.29.7
'@babel/helper-validator-identifier': 7.29.7
'@esbuild/aix-ppc64@0.27.7':
'@esbuild/aix-ppc64@0.28.1':
optional: true
'@esbuild/android-arm64@0.27.7':
'@esbuild/android-arm64@0.28.1':
optional: true
'@esbuild/android-arm@0.27.7':
'@esbuild/android-arm@0.28.1':
optional: true
'@esbuild/android-x64@0.27.7':
'@esbuild/android-x64@0.28.1':
optional: true
'@esbuild/darwin-arm64@0.27.7':
'@esbuild/darwin-arm64@0.28.1':
optional: true
'@esbuild/darwin-x64@0.27.7':
'@esbuild/darwin-x64@0.28.1':
optional: true
'@esbuild/freebsd-arm64@0.27.7':
'@esbuild/freebsd-arm64@0.28.1':
optional: true
'@esbuild/freebsd-x64@0.27.7':
'@esbuild/freebsd-x64@0.28.1':
optional: true
'@esbuild/linux-arm64@0.27.7':
'@esbuild/linux-arm64@0.28.1':
optional: true
'@esbuild/linux-arm@0.27.7':
'@esbuild/linux-arm@0.28.1':
optional: true
'@esbuild/linux-ia32@0.27.7':
'@esbuild/linux-ia32@0.28.1':
optional: true
'@esbuild/linux-loong64@0.27.7':
'@esbuild/linux-loong64@0.28.1':
optional: true
'@esbuild/linux-mips64el@0.27.7':
'@esbuild/linux-mips64el@0.28.1':
optional: true
'@esbuild/linux-ppc64@0.27.7':
'@esbuild/linux-ppc64@0.28.1':
optional: true
'@esbuild/linux-riscv64@0.27.7':
'@esbuild/linux-riscv64@0.28.1':
optional: true
'@esbuild/linux-s390x@0.27.7':
'@esbuild/linux-s390x@0.28.1':
optional: true
'@esbuild/linux-x64@0.27.7':
'@esbuild/linux-x64@0.28.1':
optional: true
'@esbuild/netbsd-arm64@0.27.7':
'@esbuild/netbsd-arm64@0.28.1':
optional: true
'@esbuild/netbsd-x64@0.27.7':
'@esbuild/netbsd-x64@0.28.1':
optional: true
'@esbuild/openbsd-arm64@0.27.7':
'@esbuild/openbsd-arm64@0.28.1':
optional: true
'@esbuild/openbsd-x64@0.27.7':
'@esbuild/openbsd-x64@0.28.1':
optional: true
'@esbuild/openharmony-arm64@0.27.7':
'@esbuild/openharmony-arm64@0.28.1':
optional: true
'@esbuild/sunos-x64@0.27.7':
'@esbuild/sunos-x64@0.28.1':
optional: true
'@esbuild/win32-arm64@0.27.7':
'@esbuild/win32-arm64@0.28.1':
optional: true
'@esbuild/win32-ia32@0.27.7':
'@esbuild/win32-ia32@0.28.1':
optional: true
'@esbuild/win32-x64@0.27.7':
'@esbuild/win32-x64@0.28.1':
optional: true
'@expo/cli@56.1.16(@expo/dom-webview@56.0.5)(expo-constants@56.0.18(expo@56.0.12)(react-native@0.86.0(@babel/core@7.29.7)(@types/react@19.2.17)(react@19.2.7)))(expo-font@56.0.7(expo@56.0.12)(react-native@0.86.0(@babel/core@7.29.7)(@types/react@19.2.17)(react@19.2.7))(react@19.2.7))(expo@56.0.12)(react-dom@19.2.7(react@19.2.7))(react-native@0.86.0(@babel/core@7.29.7)(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)(typescript@5.9.3)':
@@ -4249,10 +4225,9 @@ snapshots:
- sf-symbols-typescript
- zeego
'@hanzo/iam@0.11.0(react@19.2.7)':
'@hanzo/iam@0.9.4(react@19.2.7)':
dependencies:
jose: 6.2.3
libphonenumber-js: 1.13.7
passport-oauth2: 1.8.0
optionalDependencies:
react: 19.2.7
@@ -6132,7 +6107,7 @@ snapshots:
'@jest/schemas': 29.6.3
'@types/istanbul-lib-coverage': 2.0.6
'@types/istanbul-reports': 3.0.4
'@types/node': 22.19.21
'@types/node': 25.9.3
'@types/yargs': 17.0.35
chalk: 4.1.2
@@ -6166,8 +6141,6 @@ snapshots:
dependencies:
react: 19.2.7
'@paulmillr/qr@0.3.0': {}
'@react-native/assets-registry@0.86.0': {}
'@react-native/babel-plugin-codegen@0.85.3(@babel/core@7.29.7)':
@@ -6428,14 +6401,9 @@ snapshots:
dependencies:
'@types/istanbul-lib-report': 3.0.3
'@types/node@22.19.21':
dependencies:
undici-types: 6.21.0
'@types/node@25.9.3':
dependencies:
undici-types: 7.24.6
optional: true
'@types/react-dom@19.2.3(@types/react@19.2.17)':
dependencies:
@@ -6674,7 +6642,7 @@ snapshots:
chrome-launcher@0.15.2:
dependencies:
'@types/node': 22.19.21
'@types/node': 25.9.3
escape-string-regexp: 4.0.0
is-wsl: 2.2.0
lighthouse-logger: 1.4.2
@@ -6683,7 +6651,7 @@ snapshots:
chromium-edge-launcher@0.3.0:
dependencies:
'@types/node': 22.19.21
'@types/node': 25.9.3
escape-string-regexp: 4.0.0
is-wsl: 2.2.0
lighthouse-logger: 1.4.2
@@ -6810,34 +6778,34 @@ snapshots:
es-errors@1.3.0: {}
esbuild@0.27.7:
esbuild@0.28.1:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.7
'@esbuild/android-arm': 0.27.7
'@esbuild/android-arm64': 0.27.7
'@esbuild/android-x64': 0.27.7
'@esbuild/darwin-arm64': 0.27.7
'@esbuild/darwin-x64': 0.27.7
'@esbuild/freebsd-arm64': 0.27.7
'@esbuild/freebsd-x64': 0.27.7
'@esbuild/linux-arm': 0.27.7
'@esbuild/linux-arm64': 0.27.7
'@esbuild/linux-ia32': 0.27.7
'@esbuild/linux-loong64': 0.27.7
'@esbuild/linux-mips64el': 0.27.7
'@esbuild/linux-ppc64': 0.27.7
'@esbuild/linux-riscv64': 0.27.7
'@esbuild/linux-s390x': 0.27.7
'@esbuild/linux-x64': 0.27.7
'@esbuild/netbsd-arm64': 0.27.7
'@esbuild/netbsd-x64': 0.27.7
'@esbuild/openbsd-arm64': 0.27.7
'@esbuild/openbsd-x64': 0.27.7
'@esbuild/openharmony-arm64': 0.27.7
'@esbuild/sunos-x64': 0.27.7
'@esbuild/win32-arm64': 0.27.7
'@esbuild/win32-ia32': 0.27.7
'@esbuild/win32-x64': 0.27.7
'@esbuild/aix-ppc64': 0.28.1
'@esbuild/android-arm': 0.28.1
'@esbuild/android-arm64': 0.28.1
'@esbuild/android-x64': 0.28.1
'@esbuild/darwin-arm64': 0.28.1
'@esbuild/darwin-x64': 0.28.1
'@esbuild/freebsd-arm64': 0.28.1
'@esbuild/freebsd-x64': 0.28.1
'@esbuild/linux-arm': 0.28.1
'@esbuild/linux-arm64': 0.28.1
'@esbuild/linux-ia32': 0.28.1
'@esbuild/linux-loong64': 0.28.1
'@esbuild/linux-mips64el': 0.28.1
'@esbuild/linux-ppc64': 0.28.1
'@esbuild/linux-riscv64': 0.28.1
'@esbuild/linux-s390x': 0.28.1
'@esbuild/linux-x64': 0.28.1
'@esbuild/netbsd-arm64': 0.28.1
'@esbuild/netbsd-x64': 0.28.1
'@esbuild/openbsd-arm64': 0.28.1
'@esbuild/openbsd-x64': 0.28.1
'@esbuild/openharmony-arm64': 0.28.1
'@esbuild/sunos-x64': 0.28.1
'@esbuild/win32-arm64': 0.28.1
'@esbuild/win32-ia32': 0.28.1
'@esbuild/win32-x64': 0.28.1
escalade@3.2.0: {}
@@ -7101,7 +7069,7 @@ snapshots:
jest-util@29.7.0:
dependencies:
'@jest/types': 29.6.3
'@types/node': 22.19.21
'@types/node': 25.9.3
chalk: 4.1.2
ci-info: 3.9.0
graceful-fs: 4.2.11
@@ -7118,7 +7086,7 @@ snapshots:
jest-worker@29.7.0:
dependencies:
'@types/node': 22.19.21
'@types/node': 25.9.3
jest-util: 29.7.0
merge-stream: 2.0.0
supports-color: 8.1.1
@@ -7145,8 +7113,6 @@ snapshots:
leven@3.1.0: {}
libphonenumber-js@1.13.7: {}
lighthouse-logger@1.4.2:
dependencies:
debug: 2.6.9
@@ -7875,10 +7841,7 @@ snapshots:
uid2@0.0.4: {}
undici-types@6.21.0: {}
undici-types@7.24.6:
optional: true
undici-types@7.24.6: {}
unicode-canonical-property-names-ecmascript@2.0.1: {}
@@ -7905,7 +7868,7 @@ snapshots:
utils-merge@1.0.1: {}
uuid@7.0.3: {}
uuid@11.1.1: {}
validate-npm-package-name@5.0.1: {}
@@ -7913,7 +7876,7 @@ snapshots:
vite@7.3.5(@types/node@25.9.3)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0):
dependencies:
esbuild: 0.27.7
esbuild: 0.28.1
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
postcss: 8.5.15
@@ -7957,7 +7920,7 @@ snapshots:
xcode@3.0.1:
dependencies:
simple-plist: 1.3.1
uuid: 7.0.3
uuid: 11.1.1
xml2js@0.6.0:
dependencies: