Compare commits

...
26 Commits
Author SHA1 Message Date
hanzo-dev 007a8fc262 refactor: brand-neutral identity portal (zero brand-specific code)
Image carries no brand identity. Every per-tenant value comes from the
runtime catalog (K8s ConfigMap → /config.json → window.__ID_CATALOG__)
or is derived from the hostname.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Example:
  env:
    - name: TENANT_BRANDING_JSON
      value: |
        {
            "orgId": "liquidity",
            "orgName": "",
            "content": { "title": "Trade digital securities" },
            "auth": { "socialProviders": ["google", "apple"] }
          }
        }
2026-04-20 19:35:09 -07:00
99 changed files with 6008 additions and 2488 deletions
-78
View File
@@ -1,78 +0,0 @@
name: Deploy to Cloudflare Pages
on:
push:
branches: [main]
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
deployments: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install pnpm
run: npm i -g pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build for Cloudflare Pages
run: |
# First pass: builds Next.js via Vercel, may fail on _not-found Node.js function
npx @cloudflare/next-on-pages 2>&1 || true
# Patch out _not-found (Next.js 15 generates it as Node.js even with edge runtime)
node scripts/patch-not-found.mjs
# Second pass: convert pre-built output (skip build step)
npx @cloudflare/next-on-pages --skip-build
# Verify functions were actually generated (catches silent build failures)
if ! ls .vercel/output/static/_worker.js/__next-on-pages-dist__/functions/*.func.js 1>/dev/null 2>&1; then
echo "::error::Build failed — no edge functions generated. Check for TypeScript errors above."
exit 1
fi
echo "✓ Functions verified:"
ls .vercel/output/static/_worker.js/__next-on-pages-dist__/functions/
- name: Fetch CF credentials from KMS
id: kms
env:
KMS_CLIENT_ID: ${{ secrets.KMS_CLIENT_ID }}
KMS_CLIENT_SECRET: ${{ secrets.KMS_CLIENT_SECRET }}
HANZO_API_KEY: ${{ secrets.HANZO_API_KEY }}
KMS_ENDPOINT: ${{ vars.KMS_ENDPOINT || 'https://kms.hanzo.ai' }}
run: |
# Auth: Universal Auth (preferred) or legacy API key
if [ -n "${KMS_CLIENT_ID}" ] && [ -n "${KMS_CLIENT_SECRET}" ]; then
HANZO_API_KEY=$(curl -sf "${KMS_ENDPOINT}/api/v1/auth/universal-auth/login" \
-H "Content-Type: application/json" \
-d "{\"clientId\":\"${KMS_CLIENT_ID}\",\"clientSecret\":\"${KMS_CLIENT_SECRET}\"}" | jq -r '.accessToken')
fi
response=$(curl -sf "${KMS_ENDPOINT}/api/v3/secrets/raw?workspaceId=e1359bf4-31b4-4dfa-bb90-323e2c298ad8&secretPath=/deploy&environment=prod" \
-H "Authorization: Bearer ${HANZO_API_KEY}" 2>/dev/null || echo "")
if [ -n "$response" ]; then
cf_token=$(echo "$response" | jq -r '.secrets[] | select(.secretKey=="CLOUDFLARE_API_TOKEN") | .secretValue // empty')
cf_account=$(echo "$response" | jq -r '.secrets[] | select(.secretKey=="CLOUDFLARE_ACCOUNT_ID") | .secretValue // empty')
fi
if [ -z "${cf_token:-}" ]; then
echo "::error::CF credentials not found in KMS."
exit 1
fi
echo "::add-mask::${cf_token}"
echo "::add-mask::${cf_account}"
echo "cf_token=${cf_token}" >> "$GITHUB_OUTPUT"
echo "cf_account=${cf_account}" >> "$GITHUB_OUTPUT"
- name: Deploy to Cloudflare Pages
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ steps.kms.outputs.cf_token }}
accountId: ${{ steps.kms.outputs.cf_account }}
packageManager: npm
command: pages deploy .vercel/output/static --project-name hanzo-id --commit-dirty=true
+14 -34
View File
@@ -1,38 +1,18 @@
name: Docker
on:
push:
branches: [main]
tags: ['v*']
workflow_dispatch:
push:
branches: [main, dev, test]
tags: ['v*']
permissions:
contents: read
packages: write
id-token: write
jobs:
build-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/metadata-action@v5
id: meta
with:
images: ghcr.io/hanzoai/hanzo-login
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=sha,prefix=,suffix=,format=short
type=semver,pattern={{version}}
- uses: docker/build-push-action@v6
with:
context: .
push: true
platforms: linux/amd64
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
docker:
uses: hanzoai/.github/.github/workflows/docker-build.yml@main
with:
image: ghcr.io/hanzoai/id
# arm64 paused on DOKS (universe LLM.md 2026-04-27) — amd64-only.
platforms: linux/amd64
secrets: inherit
+7
View File
@@ -0,0 +1,7 @@
name: Workflow Sanity
on:
pull_request:
paths: ['.github/workflows/**']
jobs:
sanity:
uses: hanzoai/.github/.github/workflows/workflow-sanity.yml@main
+5
View File
@@ -7,3 +7,8 @@ out/
.env
.env.local
.env.production.local
# Vite + pnpm monorepo
apps/*/dist
pkgs/*/dist
**/tsconfig.tsbuildinfo
**/node_modules
+19 -50
View File
@@ -1,54 +1,23 @@
FROM node:22-alpine AS base
# syntax=docker/dockerfile:1.7
# 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
RUN corepack enable && corepack prepare pnpm@10.15.0 --activate
# Install pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
COPY pnpm-workspace.yaml package.json tsconfig.base.json ./
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/
RUN pnpm install --frozen-lockfile=false
# --- Dependencies ---
FROM base AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml* ./
RUN pnpm install --frozen-lockfile 2>/dev/null || pnpm install
# --- Build ---
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Build args become env vars at build time (for white-label forks)
ARG NEXT_PUBLIC_IAM_URL
ARG NEXT_PUBLIC_ORG
ARG NEXT_PUBLIC_CLIENT_ID
ARG NEXT_PUBLIC_APP_NAME
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm build
# --- Production ---
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
COPY apps apps
COPY pkgs pkgs
RUN pnpm --filter @hanzo/id-web build
# SPA runtime — hanzoai/spa is SPA-mode-always-on, smart caching,
# templates /public/config.json from SPA_* env vars at startup.
FROM ghcr.io/hanzoai/spa:1.2.0
COPY --from=build /build/apps/web/dist /public
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
# Runtime env vars for white-label configuration:
# IAM_ORIGIN — IAM backend URL (default: https://iam.hanzo.ai)
# NEXT_PUBLIC_IAM_URL — Same, for client-side
# NEXT_PUBLIC_ORG — Organization name (default: hanzo)
# NEXT_PUBLIC_CLIENT_ID — Default app client ID
CMD ["node", "server.js"]
+123 -30
View File
@@ -1,38 +1,131 @@
# LLM.md - Hanzo Id
# LLM.md Hanzo ID
## Overview
White-label login portal for Hanzo IAM - forkable, multi-tenant, RFC-compliant OAuth2/OIDC
## What this is
## Tech Stack
- **Language**: TypeScript/JavaScript
**Brand-neutral** white-label identity portal. Vite SPA, ships ZERO
brand-specific data. Every per-tenant value — orgId, brandUrl, clientId,
appName — comes from the runtime catalog (K8s ConfigMap) or is derived
from the hostname at request time.
One image serves any identity host. Adding a brand never touches this repo.
Replaces:
- `~/work/hanzo/hanzo.id-worker` (Cloudflare Worker) — decommissioned
- `~/work/hanzo/id/legacy-nextjs/` (Next.js 15) — frozen, kept for diff
## Architecture
```
DNS → hanzo cluster ingress (129.212.164.5)
any-host.id ──┐
id.any-host.net ──┤ ─ TLS ─→ ingress ─ K8s ─→ Service id ─→ Deployment id
www.any-host.id ──┘ (Traefik) ghcr.io/hanzoai/id:vX.Y.Z
│ on boot:
│ GET /config.json (templated from SPA_IAM_TENANT_CONFIG_JSON)
│ ↓
│ window.__ID_CATALOG__
resolveTenant(hostname)
catalog ?? derive-from-hostname
loadBrand(tenant.brandUrl)
fetch absolute URL (jsdelivr, etc.)
createAuthClient(tenant)
IAM backend (same-origin /v1/iam/*)
```
## Workspace
```
apps/
web/ @hanzo/id-web — Vite + React 19 + @hanzo/gui SPA. No brand deps.
pkgs/
shared/ @hanzo/id-shared — TenantConfig (brandUrl, not brandPackage)
+ resolveTenant + parseCatalog + loadBrand
auth/ @hanzo/id-auth — composable login/signup/OTP forms
idv/ @hanzo/id-idv — pluggable identity verification (stub/persona/onfido/veriff)
legacy-nextjs/ Frozen predecessor.
```
## Brand resolution (3 layers, first non-empty wins)
1. **Runtime catalog**`window.__ID_CATALOG__` populated from `/config.json`
(templated by hanzoai/spa at pod startup from `SPA_IAM_TENANT_CONFIG_JSON`).
This is the standard production path. Each deploy ships a `ConfigMap` with
its full host → tenant map.
2. **Hostname-derived defaults**`foo.id` / `id.foo.net` / `iam.foo.net` /
`www.foo.id` all derive `orgId=foo`, `clientId=foo-id-portal`,
`appName=foo-id`, `brandUrl=https://cdn.jsdelivr.net/npm/@foo/brand@latest/brand.json`.
Works out of the box when the npm scope matches the org. The catalog
handles mismatches (`lux → @luxfi/brand`, `pars → @parsdao/brand`,
`zoo → @zooai/brand`, `zoolabs.id → org=zoo`).
3. The Hanzo deployment's catalog lives at
`apps/web/k8s/tenant-catalog.yaml`. Other consumers (Liquidity,
ad.nexus, bootno.de, ...) ship their own ConfigMap.
## Local dev
## Build & Run
```bash
pnpm install && pnpm build
pnpm test
pnpm install
pnpm dev
```
## Structure
```
id/
Dockerfile
LICENSE
README.md
app/
components/
config/
lib/
middleware.ts
next-env.d.ts
next.config.ts
package.json
pnpm-lock.yaml
postcss.config.mjs
public/
scripts/
For multi-host preview without a deployed catalog, the hostname-derived
defaults render the matching brand pkg from jsDelivr.
For an in-cluster preview with the full catalog, just hit the production
hostnames (hostname → 129.212.164.5).
## Build + deploy
CI publishes images on tag push. Universe auto-bumps the manifest
(`infra/k8s/operator/crs/hanzo-platform.yaml` `images:` override) on
green CI.
Manual:
```bash
docker build -t ghcr.io/hanzoai/id:X.Y.Z .
docker push ghcr.io/hanzoai/id:X.Y.Z
kubectl apply -k apps/web/k8s
```
## Key Files
- `README.md` -- Project documentation
- `package.json` -- Dependencies and scripts
- `Dockerfile` -- Container build
## Adding a brand
Brand maintainer:
1. Publish a brand package to npm exposing `brand.json`. Convention:
`@<org>/brand` containing
`{ "brand": { name, title, description, appDomain, logoUrl, faviconUrl, ... } }`.
`logoUrl` and `faviconUrl` are absolute (CDN — jsDelivr works).
Deploy maintainer:
2. Add the host(s) to `apps/web/k8s/tenant-catalog.yaml` with
`orgId` / `clientId` / `appName` / `brandUrl`. No source change.
3. Add the host to `apps/web/k8s/ingress.yaml` for cert-manager TLS.
4. DNS → cluster ingress IP.
The image never changes. There is no `pnpm add @brand/foo`, no
`vite.config.ts` edit, no `tenant.ts` edit.
## Plugging an IDV provider
```ts
import { registerProvider, createPersonaProvider } from '@hanzo/id-idv'
registerProvider(createPersonaProvider({ templateId, apiKey, environment: 'production' }))
```
| id | source | docs |
|---|---|---|
| `stub` | `@hanzo/id-idv/providers/stub` | in-memory, dev-only |
| `persona` | `@hanzo/id-idv/providers/persona` | https://docs.withpersona.com |
| `onfido` | `@hanzo/id-idv/providers/onfido` | https://documentation.onfido.com |
| `veriff` | `@hanzo/id-idv/providers/veriff` | https://developers.veriff.com |
## Backend
Go IAM backend at `~/work/hanzo/iam` (image `ghcr.io/hanzoai/iam`).
This portal talks to it same-origin via Traefik file routes per-host
(`/v1/iam/*` and `/oauth/*` paths on every identity hostname are
proxied to the IAM service; the rest goes to this SPA).
+54 -127
View File
@@ -1,146 +1,73 @@
# Hanzo ID - Hosted Login Pages
# @hanzo/id
Configurable, white-label login pages for Hanzo IAM. Each organization can customize their login experience based on their domain (CNAME).
White-label login + identity verification portal. One Vite SPA, four hosts
(`hanzo.id`, `lux.id`, `zoo.id`, `pars.id`), per-tenant brand resolved from
the request hostname at runtime.
## Architecture
## Layout
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ hanzo.id │ │ pars.id │ │ lux.id │
│ (CNAME) │ │ (CNAME) │ │ (CNAME) │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
└───────────────────┴───────────────────┘
┌──────▼──────┐
Hanzo ID ← This repo (frontend)
│ (Next.js) │
└──────┬──────┘
┌──────▼──────┐
│ Hanzo IAM │ ← Backend auth services
│ (Go API) │
└─────────────┘
apps/
web/ Vite + React 19 + @hanzo/gui — the actual SPA
k8s/ Deployment + Service + Ingress (4 hosts, 4 TLS secrets)
pkgs/
shared/ @hanzo/id-shared — TenantConfig + brand resolver
auth/ @hanzo/id-auth — composable login/signup/OTP flows
on top of @hanzo/iam SDK
idv/ @hanzo/id-idv — pluggable identity verification
(Persona, Onfido, Veriff, stub)
legacy-nextjs/ Frozen — predecessor Next.js implementation. Kept
for reference until v0.1.0 ships to production.
```
## Features
- **Domain-based branding**: Logo, colors, content based on CNAME
- **Configurable auth methods**: Password, code, WebAuthn, Face ID
- **Social providers**: Google, GitHub, and more
- **Customizable content**: Quotes, testimonials, feature highlights
- **Dark mode by default**: Clean, modern design
- **Easy to fork**: Simple structure for white-labeling
## Configuration
Branding can be configured in two ways:
### 1. Static Configuration (for known domains)
Edit `lib/branding.ts` to add your domain:
```typescript
export const staticBranding: Record<string, Partial<BrandingConfig>> = {
'your-domain.com': {
orgId: 'your-org',
orgName: 'Your Organization',
logo: '/logos/your-logo.svg',
colors: {
primary: '#3b82f6',
primaryText: '#ffffff',
background: '#000000',
surface: '#0a0a0a',
text: '#ffffff',
textMuted: '#a1a1aa',
border: '#27272a',
error: '#dc2626',
},
content: {
title: 'Welcome to Your App',
subtitle: 'Sign in to continue',
},
},
}
```
### 2. Dynamic Configuration (from IAM backend)
The login page fetches branding from IAM API:
```
GET https://api.hanzo.id/api/branding?domain=your-domain.com
```
Response:
```json
{
"orgId": "your-org",
"orgName": "Your Organization",
"logo": "https://...",
"colors": { ... },
"content": { ... },
"links": { ... },
"auth": { ... }
}
```
## Development
## Local dev
```bash
# Install dependencies
npm install
# Start development server
npm run dev
# Build for production
npm run build
# Start production server
npm start
pnpm install
pnpm dev # http://localhost:5173 (defaults to hanzo brand)
```
## Environment Variables
To preview a different brand locally, edit `/etc/hosts`:
```
127.0.0.1 lux.id zoo.id pars.id
```
then visit `http://lux.id:5173`.
## Build
```bash
# IAM backend URL
HANZO_IAM_URL=https://api.hanzo.id
# Public IAM URL (for client-side redirects)
NEXT_PUBLIC_IAM_URL=https://api.hanzo.id
pnpm build # builds apps/web -> dist/
docker build -t ghcr.io/hanzoai/id:0.1.0 .
```
## Forking for White-Label
## Adding a brand
1. Fork this repository
2. Update `lib/branding.ts` with your default branding
3. Add your logo to `public/logos/`
4. Update `app/globals.css` for custom styling
5. Deploy to your infrastructure
1. Publish or workspace-link the new per-org brand pkg (must ship
`brand.json` at the package root and match the `BrandContract` shape
in `pkgs/shared/src/types.ts`).
2. Add a `DEFAULT_TENANTS` entry in `pkgs/shared/src/tenant.ts` OR put
the override in the runtime catalog (`IAM_TENANT_CONFIG_JSON` env)
so no rebuild is needed.
3. Add the hostname to `apps/web/vite.config.ts::BRAND_PACKAGES`
(lets dev + build serve `/brand/<pkg>/brand.json`).
4. Add the hostname + TLS secret to `apps/web/k8s/ingress.yaml`.
5. DNS: CNAME or A record → cluster ingress IP.
## Directory Structure
That's it — no per-brand Worker, no per-brand image, no per-brand
deployment. One binary, four brands.
```
hanzo-id/
├── app/
│ ├── layout.tsx # Root layout with metadata
│ ├── page.tsx # Redirects to /login
│ ├── login/
│ │ └── page.tsx # Main login page
│ ├── signup/ # Sign up page
│ ├── forgot-password # Password reset
│ └── callback/ # OAuth callback handler
├── components/
│ ├── LoginForm.tsx # Login form component
│ └── MarketingPanel.tsx # Right side marketing content
├── lib/
│ └── branding.ts # Branding configuration
├── public/
│ └── logos/ # Organization logos
└── config/ # Additional configuration
## Plugging an IDV provider
```ts
import { registerProvider, createPersonaProvider } from '@hanzo/id-idv'
registerProvider(createPersonaProvider({
templateId: import.meta.env.VITE_PERSONA_TEMPLATE_ID,
apiKey: import.meta.env.VITE_PERSONA_API_KEY,
environment: 'production',
}))
```
## License
MIT - Fork and customize freely!
The portal stays unchanged — switching providers is a single registration
call at boot.
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="robots" content="noindex" />
<link id="favicon" rel="icon" type="image/png" href="data:," />
<title>Sign in</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+80
View File
@@ -0,0 +1,80 @@
# Hanzo ID — brand-neutral identity portal. Single Deployment serves
# any identity host. Tenant resolution is per-request by hostname; the
# runtime tenant catalog lives in the `id-tenant-catalog` ConfigMap and
# is templated into /config.json by the spa runtime at boot.
apiVersion: apps/v1
kind: Deployment
metadata:
name: id
namespace: hanzo
labels:
app: id
app.kubernetes.io/name: id
app.kubernetes.io/part-of: platform
spec:
replicas: 2
selector:
matchLabels:
app: id
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
metadata:
labels:
app: id
spec:
imagePullSecrets:
- name: ghcr-secret
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchExpressions:
- key: app
operator: In
values: [id]
topologyKey: kubernetes.io/hostname
containers:
- name: id
image: ghcr.io/hanzoai/id:0.1.0
imagePullPolicy: IfNotPresent
envFrom:
- configMapRef:
name: id-tenant-catalog
ports:
- name: http
containerPort: 3000
protocol: TCP
resources:
requests: { cpu: 25m, memory: 64Mi }
limits: { cpu: 500m, memory: 256Mi }
readinessProbe:
httpGet: { path: /health, port: 3000 }
initialDelaySeconds: 2
periodSeconds: 5
livenessProbe:
httpGet: { path: /health, port: 3000 }
initialDelaySeconds: 5
periodSeconds: 10
---
apiVersion: v1
kind: Service
metadata:
name: id
namespace: hanzo
labels:
app: id
app.kubernetes.io/name: id
app.kubernetes.io/part-of: platform
spec:
type: ClusterIP
selector:
app: id
ports:
- name: http
port: 80
targetPort: 3000
protocol: TCP
+141
View File
@@ -0,0 +1,141 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod-cf
kubernetes.io/ingress.class: ingress
name: id
namespace: hanzo
spec:
rules:
- host: hanzo.id
http:
paths:
- backend:
service:
name: id
port:
number: 80
path: /
pathType: Prefix
- host: lux.id
http:
paths:
- backend:
service:
name: id
port:
number: 80
path: /
pathType: Prefix
- host: id.lux.network
http:
paths:
- backend:
service:
name: id
port:
number: 80
path: /
pathType: Prefix
- host: iam.lux.network
http:
paths:
- backend:
service:
name: id
port:
number: 80
path: /
pathType: Prefix
- host: pars.id
http:
paths:
- backend:
service:
name: id
port:
number: 80
path: /
pathType: Prefix
- host: zoolabs.id
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: id
port:
number: 80
- host: www.zoolabs.id
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: id
port:
number: 80
- host: osage.id
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: id
port:
number: 80
- host: www.osage.id
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: id
port:
number: 80
- host: id.zoo.network
http:
paths:
- backend:
service:
name: id
port:
number: 80
path: /
pathType: Prefix
tls:
- hosts:
- hanzo.id
secretName: hanzo-id-tls
- hosts:
- lux.id
secretName: lux-id-tls
- hosts:
- id.lux.network
secretName: id-lux-network-tls
- hosts:
- iam.lux.network
secretName: iam-lux-network-tls
- hosts:
- pars.id
secretName: pars-id-tls
- hosts:
- zoolabs.id
secretName: zoolabs-id-tls
- hosts:
- www.zoolabs.id
secretName: www-zoolabs-id-tls
- hosts:
- osage.id
secretName: osage-id-tls
- hosts:
- www.osage.id
secretName: www-osage-id-tls
- hosts:
- id.zoo.network
secretName: id-zoo-network-tls
+12
View File
@@ -0,0 +1,12 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: hanzo
resources:
- tenant-catalog.yaml
- deployment.yaml
- ingress.yaml
labels:
- pairs:
app.kubernetes.io/managed-by: universe
app.kubernetes.io/part-of: platform
includeSelectors: false
+35
View File
@@ -0,0 +1,35 @@
# Hanzo ID — runtime tenant catalog.
#
# Brand-neutral image: zero brand-specific data is bundled. This ConfigMap
# carries the per-host overrides — `orgId`, `brandUrl`, `clientId`. The
# image's hostname-derived defaults handle everything else (and would
# handle the simple cases here too — `hanzo.id` → org=`hanzo` — but the
# org-vs-npm-scope mismatches (`lux` → `@luxfi/brand`, `pars` → `@parsdao/brand`,
# `zoolabs.id` → org=`zoo` (not `zoolabs`), etc.) require explicit overrides).
#
# Hanzo-ID-specific deploys (Liquidity, ad.nexus, bootno.de, etc.) ship
# their own ConfigMap with their own host map. The image never changes.
apiVersion: v1
kind: ConfigMap
metadata:
name: id-tenant-catalog
namespace: hanzo
labels:
app: id
app.kubernetes.io/name: id
app.kubernetes.io/part-of: platform
data:
SPA_IAM_TENANT_CONFIG_JSON: |
{
"hanzo.id": {"orgId":"hanzo","clientId":"hanzo-id-portal","appName":"hanzo-id","brandUrl":"https://cdn.jsdelivr.net/npm/@hanzo/brand@latest/brand.json"},
"lux.id": {"orgId":"lux", "clientId":"lux-id-portal", "appName":"lux-id", "brandUrl":"https://cdn.jsdelivr.net/npm/@luxfi/brand@latest/brand.json"},
"id.lux.network": {"orgId":"lux", "clientId":"lux-id-portal", "appName":"lux-id", "brandUrl":"https://cdn.jsdelivr.net/npm/@luxfi/brand@latest/brand.json"},
"iam.lux.network": {"orgId":"lux", "clientId":"lux-id-portal", "appName":"lux-id", "brandUrl":"https://cdn.jsdelivr.net/npm/@luxfi/brand@latest/brand.json"},
"zoolabs.id": {"orgId":"zoo", "clientId":"zoo-id-portal", "appName":"zoo-id", "brandUrl":"https://cdn.jsdelivr.net/npm/@zooai/brand@latest/brand.json"},
"www.zoolabs.id": {"orgId":"zoo", "clientId":"zoo-id-portal", "appName":"zoo-id", "brandUrl":"https://cdn.jsdelivr.net/npm/@zooai/brand@latest/brand.json"},
"id.zoo.network": {"orgId":"zoo", "clientId":"zoo-id-portal", "appName":"zoo-id", "brandUrl":"https://cdn.jsdelivr.net/npm/@zooai/brand@latest/brand.json"},
"pars.id": {"orgId":"pars", "clientId":"pars-id-portal", "appName":"pars-id", "brandUrl":"https://cdn.jsdelivr.net/npm/@parsdao/brand@latest/brand.json"},
"id.pars.network": {"orgId":"pars", "clientId":"pars-id-portal", "appName":"pars-id", "brandUrl":"https://cdn.jsdelivr.net/npm/@parsdao/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"},
"www.osage.id": {"orgId":"osage","clientId":"osage-id-portal","appName":"osage-id","brandUrl":"https://cdn.jsdelivr.net/npm/@osage/brand@latest/brand.json"}
}
+28
View File
@@ -0,0 +1,28 @@
{
"name": "@hanzo/id-web",
"private": true,
"version": "0.1.0",
"description": "Hanzo ID — brand-neutral white-label login / signup / IDV portal. Vite + React 19 + @hanzo/gui. One image serves any identity host; brand is fetched at runtime from the URL specified by the tenant catalog.",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview --port 5174",
"tc": "tsc --noEmit"
},
"dependencies": {
"@hanzo/iam": "^0.9.4",
"@hanzo/id-auth": "workspace:*",
"@hanzo/id-idv": "workspace:*",
"@hanzo/id-shared": "workspace:*",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.9.3",
"vite": "^7.0.0"
}
}
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<rect width="1024" height="1024" fill="#000000"/>
<g transform="translate(128, 128) scale(11.46)">
<path d="M22.21 67V44.6369H0V67H22.21Z" fill="#ffffff"/>
<path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="#ffffff"/>
<path d="M22.21 0H0V22.3184H22.21V0Z" fill="#ffffff"/>
<path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="#ffffff"/>
<path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="#ffffff"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 541 B

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<!-- Hanzo brand mark: black square + canonical block-H. Red is the brand accent (links/CTAs); the mark itself is always black + white. -->
<rect width="1024" height="1024" fill="#000000"/>
<g transform="translate(128, 128) scale(11.46)">
<path d="M22.21 67V44.6369H0V67H22.21Z" fill="#ffffff"/>
<path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="#ffffff"/>
<path d="M22.21 0H0V22.3184H22.21V0Z" fill="#ffffff"/>
<path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="#ffffff"/>
<path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="#ffffff"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 683 B

@@ -0,0 +1,4 @@
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<rect width="1024" height="1024" fill="#000000"/>
<text x="512" y="720" font-family="ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif" font-weight="700" font-size="800" fill="#FFFFFF" text-anchor="middle" letter-spacing="-40">L</text>
</svg>

After

Width:  |  Height:  |  Size: 361 B

@@ -0,0 +1,5 @@
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<!-- Lux brand mark: solid black square -->
<rect width="1024" height="1024" fill="#000000"/>
<text x="512" y="640" font-family="ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif" font-weight="700" font-size="640" fill="#FFFFFF" text-anchor="middle" letter-spacing="-32">L</text>
</svg>

After

Width:  |  Height:  |  Size: 407 B

@@ -0,0 +1,69 @@
<svg viewBox="-120 -120 240 240" fill="none" xmlns="http://www.w3.org/2000/svg">
<!--
Pars Network Logo - Persian 8-Pointed Star (Khatam/Shamseh)
The traditional Persian geometric motif - recursive fractal star
Used in mosques, palaces, and tilework across Persia for millennia.
-->
<defs>
<linearGradient id="pars-gold" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#f5d06f"/>
<stop offset="50%" stop-color="#caa24a"/>
<stop offset="100%" stop-color="#f3dc8f"/>
</linearGradient>
<linearGradient id="pars-blue" x1="0" y1="1" x2="1" y2="0">
<stop offset="0%" stop-color="#003355"/>
<stop offset="50%" stop-color="#00abff"/>
<stop offset="100%" stop-color="#66d0ff"/>
</linearGradient>
<filter id="pars-glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="2" result="blur"/>
<feComposite in="SourceGraphic" in2="blur" operator="over"/>
</filter>
</defs>
<!-- Outer 8-pointed star -->
<g filter="url(#pars-glow)">
<path
d="M0,-100 L30,-60 L100,-40 L60,0 L100,40 L30,60 L0,100 L-30,60 L-100,40 L-60,0 L-100,-40 L-30,-60 Z"
fill="none"
stroke="url(#pars-gold)"
stroke-width="4"
stroke-linejoin="round"
/>
</g>
<!-- Inner star with blue fill -->
<path
d="M0,-70 L22,-42 L70,-28 L42,0 L70,28 L22,42 L0,70 L-22,42 L-70,28 L-42,0 L-70,-28 L-22,-42 Z"
fill="url(#pars-blue)"
stroke="url(#pars-gold)"
stroke-width="3"
stroke-linejoin="round"
/>
<!-- Recursive inner star -->
<path
d="M0,-45 L14,-27 L45,-18 L27,0 L45,18 L14,27 L0,45 L-14,27 L-45,18 L-27,0 L-45,-18 L-14,-27 Z"
fill="none"
stroke="url(#pars-gold)"
stroke-width="2"
stroke-linejoin="round"
opacity="0.8"
/>
<!-- Interlaced circles (Persian geometric pattern) -->
<g fill="none" stroke="#eaf7ff" stroke-width="1.5" opacity="0.6">
<circle r="55"/>
<circle r="35"/>
</g>
<!-- Center rosette -->
<circle r="8" fill="url(#pars-gold)"/>
<path
d="M0,-20 L6,-6 L20,0 L6,6 L0,20 L-6,6 L-20,0 L-6,-6 Z"
fill="#002a47"
stroke="url(#pars-gold)"
stroke-width="1.5"
stroke-linejoin="round"
/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

@@ -0,0 +1,38 @@
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<!-- Zoo brand mark: three-circle CMYK Venn diagram, full color.
Filled to the viewport so it scales cleanly into 32x32 nav slots
and 256x256 OG cards without tiny dead margins. -->
<defs>
<clipPath id="logoClip">
<circle cx="512" cy="511" r="500"/>
</clipPath>
<clipPath id="logoYellow">
<circle cx="512" cy="250" r="430"/>
</clipPath>
<clipPath id="logoMagenta">
<circle cx="240" cy="670" r="430"/>
</clipPath>
<clipPath id="logoCyan">
<circle cx="784" cy="670" r="430"/>
</clipPath>
</defs>
<g clip-path="url(#logoClip)">
<circle cx="512" cy="250" r="430" fill="#FCF006"/>
<circle cx="240" cy="670" r="430" fill="#EA018E"/>
<circle cx="784" cy="670" r="430" fill="#01ACF1"/>
<g clip-path="url(#logoYellow)">
<circle cx="240" cy="670" r="430" fill="#ED1C24"/>
</g>
<g clip-path="url(#logoYellow)">
<circle cx="784" cy="670" r="430" fill="#00A652"/>
</g>
<g clip-path="url(#logoMagenta)">
<circle cx="784" cy="670" r="430" fill="#2E3192"/>
</g>
<g clip-path="url(#logoYellow)">
<g clip-path="url(#logoMagenta)">
<circle cx="784" cy="670" r="430" fill="#000000"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+45
View File
@@ -0,0 +1,45 @@
import { useEffect, useMemo, useState } from 'react'
import { loadBrand, parseCatalog, resolveTenant, type BrandContract, type TenantConfig } from '@hanzo/id-shared'
import { createAuthClient } from '@hanzo/id-auth'
import { Portal } from './pages/Portal'
import { Login } from './pages/Login'
import { Signup } from './pages/Signup'
import { Forgot } from './pages/Forgot'
import { Callback } from './pages/Callback'
/**
* Top-level wiring. Resolves tenant + brand once on mount, then routes via
* `window.location.pathname`. No router lib needed — this app is 5 pages,
* `<a href>` is enough. Adding paths is a switch case.
*/
export function App() {
const [tenant, setTenant] = useState<TenantConfig | null>(null)
const [brand, setBrand] = useState<BrandContract | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const runtimeCatalog = (window as unknown as { __ID_CATALOG__?: string }).__ID_CATALOG__
const t = resolveTenant(window.location.hostname, { catalog: parseCatalog(runtimeCatalog) })
setTenant(t)
loadBrand(t.brandUrl)
.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) => setError(String(e)))
}, [])
const client = useMemo(() => (tenant ? createAuthClient({ tenant }) : null), [tenant])
if (error) return <div className="id-portal-error">{error}</div>
if (!tenant || !brand || !client) return <div>Loading</div>
const path = window.location.pathname
if (path === '/login' || path.startsWith('/login/')) return <Login client={client} brand={brand} />
if (path === '/signup' || path.startsWith('/signup/')) return <Signup client={client} brand={brand} />
if (path === '/forget' || path === '/forgot' || path.startsWith('/forg')) return <Forgot client={client} brand={brand} />
if (path === '/callback' || path.startsWith('/callback/')) return <Callback client={client} brand={brand} />
return <Portal brand={brand} />
}
+90
View File
@@ -0,0 +1,90 @@
:root {
--brand: #ffffff;
--bg: #0a0a0a;
--fg: #fafafa;
--muted: #a3a3a3;
--border: #262626;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
color-scheme: dark;
}
* { box-sizing: border-box; }
html, body, #root { height: 100%; margin: 0; }
body {
background: var(--bg);
color: var(--fg);
min-height: 100vh;
display: flex;
flex-direction: column;
}
.id-portal-page {
flex: 1;
display: flex;
flex-direction: column;
max-width: 480px;
margin: 0 auto;
padding: 24px;
}
.id-portal-brand-header {
padding: 16px 0 32px;
}
.id-portal-page main {
flex: 1;
display: flex;
flex-direction: column;
gap: 24px;
}
.id-portal-page h1 { margin: 0; font-size: 28px; }
.id-portal-page .lede { color: var(--muted); margin: 0; }
form { display: flex; flex-direction: column; gap: 16px; }
form label { display: flex; flex-direction: column; gap: 6px; font-size: 14px; color: var(--muted); }
form input {
background: #111;
color: var(--fg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 14px;
font-size: 16px;
}
form input:focus { outline: 2px solid var(--brand); outline-offset: -1px; }
.id-portal-btn, form button {
background: var(--fg);
color: var(--bg);
border: 0;
border-radius: 8px;
padding: 12px 16px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
text-decoration: none;
text-align: center;
display: inline-block;
}
.id-portal-btn[aria-disabled='true'], form button:disabled { opacity: 0.5; cursor: not-allowed; }
.id-portal-btn.primary { background: var(--brand); }
.id-portal-cta-row { display: flex; gap: 12px; }
.id-portal-footer-links { color: var(--muted); font-size: 14px; }
.id-portal-footer-links a { color: var(--fg); }
.id-portal-error {
background: #2d0a0a;
color: #ff7878;
border: 1px solid #5a1414;
padding: 12px;
border-radius: 8px;
font-size: 14px;
}
.id-portal-info {
background: #0a1f2d;
color: #78b8ff;
border: 1px solid #14385a;
padding: 12px;
border-radius: 8px;
}
+11
View File
@@ -0,0 +1,11 @@
import type { BrandContract } from '@hanzo/id-shared'
export function BrandHeader({ brand }: { brand: BrandContract }) {
return (
<header className="id-portal-brand-header">
<a href="/" aria-label={brand.name}>
<img src={brand.logoUrl} alt={brand.name} height={32} />
</a>
</header>
)
}
+40
View File
@@ -0,0 +1,40 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { App } from './App'
import { registerProvider } from '@hanzo/id-idv'
import { createStubProvider } from '@hanzo/id-idv/providers/stub'
import './app.css'
// Default IDV provider — replace at boot via env-driven config.
registerProvider(createStubProvider())
/**
* Load the runtime tenant catalog before mounting.
* `/config.json` is templated by hanzoai/spa at pod startup from
* `SPA_IAM_TENANT_CONFIG_JSON` (and other SPA_* env vars). Absence is fine —
* the SPA falls back to hostname-derived defaults.
*/
async function loadCatalog(): Promise<void> {
try {
const res = await fetch('/config.json', { cache: 'no-store' })
if (!res.ok) return
const cfg = await res.json()
const raw = cfg?.iamTenantConfigJson
if (typeof raw === 'string' && raw.length > 0) {
;(window as unknown as { __ID_CATALOG__?: string }).__ID_CATALOG__ = raw
}
} catch {
// Catalog is optional. Hostname-derived defaults will be used.
}
}
const root = document.getElementById('root')
if (!root) throw new Error('#root missing')
loadCatalog().then(() => {
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
)
})
+40
View File
@@ -0,0 +1,40 @@
import { useEffect, useState } from 'react'
import type { BrandContract } from '@hanzo/id-shared'
import type { AuthClient } from '@hanzo/id-auth'
import { BrandHeader } from '../components/BrandHeader'
export function Callback({ client, brand }: { client: AuthClient; brand: BrandContract }) {
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const sp = new URLSearchParams(window.location.search)
const code = sp.get('code')
if (!code) {
setError('Missing authorization code.')
return
}
const codeVerifier = sessionStorage.getItem('pkce_verifier') ?? undefined
client
.exchange(code, codeVerifier)
.then((tok) => {
// Forward the tokens to whichever app initiated this flow.
const target = sessionStorage.getItem('post_login_redirect') ?? '/'
const url = new URL(target, window.location.origin)
url.searchParams.set('access_token', tok.accessToken)
if (tok.refreshToken) url.searchParams.set('refresh_token', tok.refreshToken)
if (tok.idToken) url.searchParams.set('id_token', tok.idToken)
sessionStorage.removeItem('pkce_verifier')
sessionStorage.removeItem('post_login_redirect')
window.location.replace(url.toString())
})
.catch((e) => setError(String(e)))
}, [client])
return (
<div className="id-portal-page id-portal-callback">
<BrandHeader brand={brand} />
<main>
{error ? <p role="alert" className="id-portal-error">{error}</p> : <p>Completing sign-in</p>}
</main>
</div>
)
}
+18
View File
@@ -0,0 +1,18 @@
import type { BrandContract } from '@hanzo/id-shared'
import { ForgotForm, type AuthClient } from '@hanzo/id-auth'
import { BrandHeader } from '../components/BrandHeader'
export function Forgot({ client, brand }: { client: AuthClient; brand: BrandContract }) {
return (
<div className="id-portal-page id-portal-forgot">
<BrandHeader brand={brand} />
<main>
<h1>Reset your {brand.name} password</h1>
<ForgotForm client={client} />
<p className="id-portal-footer-links">
<a href="/login">Back to sign in</a>
</p>
</main>
</div>
)
}
+27
View File
@@ -0,0 +1,27 @@
import type { BrandContract } from '@hanzo/id-shared'
import { LoginForm, type AuthClient } from '@hanzo/id-auth'
import { BrandHeader } from '../components/BrandHeader'
export function Login({ client, brand }: { client: AuthClient; brand: BrandContract }) {
const sp = new URLSearchParams(window.location.search)
const redirectUri = sp.get('redirect_uri') ?? undefined
const state = sp.get('state') ?? undefined
const clientIdOverride = sp.get('client_id') ?? undefined
return (
<div className="id-portal-page id-portal-login">
<BrandHeader brand={brand} />
<main>
<h1>Sign in to {brand.name}</h1>
<LoginForm
client={client}
redirectUri={redirectUri}
state={state}
clientIdOverride={clientIdOverride ?? undefined}
/>
<p className="id-portal-footer-links">
<a href="/forget">Forgot password?</a> · <a href="/signup">Create account</a>
</p>
</main>
</div>
)
}
+18
View File
@@ -0,0 +1,18 @@
import type { BrandContract } from '@hanzo/id-shared'
import { BrandHeader } from '../components/BrandHeader'
export function Portal({ brand }: { brand: BrandContract }) {
return (
<div className="id-portal-page id-portal-portal">
<BrandHeader brand={brand} />
<main>
<h1>Welcome to {brand.name}</h1>
<p className="lede">{brand.description}</p>
<div className="id-portal-cta-row">
<a className="id-portal-btn primary" href="/login">Sign in</a>
<a className="id-portal-btn" href="/signup">Create account</a>
</div>
</main>
</div>
)
}
+20
View File
@@ -0,0 +1,20 @@
import type { BrandContract } from '@hanzo/id-shared'
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
return (
<div className="id-portal-page id-portal-signup">
<BrandHeader brand={brand} />
<main>
<h1>Create your {brand.name} account</h1>
<SignupForm client={client} inviteCode={inviteCode} />
<p className="id-portal-footer-links">
Already have an account? <a href="/login">Sign in</a>
</p>
</main>
</div>
)
}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
}
+25
View File
@@ -0,0 +1,25 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { resolve, dirname } from 'path'
import { fileURLToPath } from 'url'
const __dirname = dirname(fileURLToPath(import.meta.url))
/**
* Brand-neutral identity portal.
*
* NO brand packages are bundled. The runtime fetches `brand.json` from
* the absolute URL supplied by the tenant catalog (typically the brand's
* npm package served via jsDelivr, but any URL works). Adding a brand
* means publishing a brand package and pointing the catalog at it — this
* repo never changes.
*/
export default defineConfig({
plugins: [react()],
resolve: {
alias: { '@': resolve(__dirname, 'src') },
},
server: { port: 5173, host: '0.0.0.0' },
preview: { port: 5174, host: '0.0.0.0' },
build: { target: 'es2022', sourcemap: true },
})
+54
View File
@@ -0,0 +1,54 @@
FROM node:22-alpine AS base
# Install pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
# --- Dependencies ---
FROM base AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml* ./
RUN pnpm install --frozen-lockfile 2>/dev/null || pnpm install
# --- Build ---
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Build args become env vars at build time (for white-label forks)
ARG NEXT_PUBLIC_IAM_URL
ARG NEXT_PUBLIC_ORG
ARG NEXT_PUBLIC_CLIENT_ID
ARG NEXT_PUBLIC_APP_NAME
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm build
# --- Production ---
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
# Runtime env vars for white-label configuration:
# IAM_ORIGIN — IAM backend URL (default: https://iam.hanzo.ai)
# NEXT_PUBLIC_IAM_URL — Same, for client-side
# NEXT_PUBLIC_ORG — Organization name (default: hanzo)
# NEXT_PUBLIC_CLIENT_ID — Default app client ID
CMD ["node", "server.js"]
+146
View File
@@ -0,0 +1,146 @@
# Hanzo ID - Hosted Login Pages
Configurable, white-label login pages for Hanzo IAM. Each organization can customize their login experience based on their domain (CNAME).
## Architecture
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ hanzo.id │ │ pars.id │ │ lux.id │
│ (CNAME) │ │ (CNAME) │ │ (CNAME) │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
└───────────────────┴───────────────────┘
┌──────▼──────┐
│ Hanzo ID │ ← This repo (frontend)
│ (Next.js) │
└──────┬──────┘
┌──────▼──────┐
│ Hanzo IAM │ ← Backend auth services
│ (Go API) │
└─────────────┘
```
## Features
- **Domain-based branding**: Logo, colors, content based on CNAME
- **Configurable auth methods**: Password, code, WebAuthn, Face ID
- **Social providers**: Google, GitHub, and more
- **Customizable content**: Quotes, testimonials, feature highlights
- **Dark mode by default**: Clean, modern design
- **Easy to fork**: Simple structure for white-labeling
## Configuration
Branding can be configured in two ways:
### 1. Static Configuration (for known domains)
Edit `lib/branding.ts` to add your domain:
```typescript
export const staticBranding: Record<string, Partial<BrandingConfig>> = {
'your-domain.com': {
orgId: 'your-org',
orgName: 'Your Organization',
logo: '/logos/your-logo.svg',
colors: {
primary: '#3b82f6',
primaryText: '#ffffff',
background: '#000000',
surface: '#0a0a0a',
text: '#ffffff',
textMuted: '#a1a1aa',
border: '#27272a',
error: '#dc2626',
},
content: {
title: 'Welcome to Your App',
subtitle: 'Sign in to continue',
},
},
}
```
### 2. Dynamic Configuration (from IAM backend)
The login page fetches branding from IAM API:
```
GET https://api.hanzo.id/api/branding?domain=your-domain.com
```
Response:
```json
{
"orgId": "your-org",
"orgName": "Your Organization",
"logo": "https://...",
"colors": { ... },
"content": { ... },
"links": { ... },
"auth": { ... }
}
```
## Development
```bash
# Install dependencies
npm install
# Start development server
npm run dev
# Build for production
npm run build
# Start production server
npm start
```
## Environment Variables
```bash
# IAM backend URL
HANZO_IAM_URL=https://api.hanzo.id
# Public IAM URL (for client-side redirects)
NEXT_PUBLIC_IAM_URL=https://api.hanzo.id
```
## Forking for White-Label
1. Fork this repository
2. Update `lib/branding.ts` with your default branding
3. Add your logo to `public/logos/`
4. Update `app/globals.css` for custom styling
5. Deploy to your infrastructure
## Directory Structure
```
hanzo-id/
├── app/
│ ├── layout.tsx # Root layout with metadata
│ ├── page.tsx # Redirects to /login
│ ├── login/
│ │ └── page.tsx # Main login page
│ ├── signup/ # Sign up page
│ ├── forgot-password # Password reset
│ └── callback/ # OAuth callback handler
├── components/
│ ├── LoginForm.tsx # Login form component
│ └── MarketingPanel.tsx # Right side marketing content
├── lib/
│ └── branding.ts # Branding configuration
├── public/
│ └── logos/ # Organization logos
└── config/ # Additional configuration
```
## License
MIT - Fork and customize freely!
@@ -21,7 +21,7 @@ export async function GET(request: NextRequest) {
const state = url.searchParams.get('state') || ''
// Call IAM logout
const logoutUrl = new URL('/api/logout', iamOrigin)
const logoutUrl = new URL('/v1/iam/logout', iamOrigin)
logoutUrl.searchParams.set('id_token_hint', idTokenHint)
logoutUrl.searchParams.set('post_logout_redirect_uri', postLogoutRedirectUri)
logoutUrl.searchParams.set('state', state)
@@ -65,7 +65,7 @@ export default function LoginForm({ branding }: LoginFormProps) {
state: state || '',
})
fetch(`/api/get-app-login?${params}`)
fetch(`/v1/iam/get-app-login?${params}`)
.then(r => r.json())
.then(data => {
// IAM returns the app data even when status is "error"
@@ -99,7 +99,7 @@ export default function LoginForm({ branding }: LoginFormProps) {
const resolvedApp = appName || CLIENT_APP_MAP[clientId]?.application || clientId
if (isOAuthFlow) {
// OAuth flow: direct code grant via /api/login with PKCE
// OAuth flow: direct code grant via /v1/iam/login with PKCE.
// Pass OAuth params (including code_challenge) as query params so IAM
// binds the authorization code to the PKCE challenge.
const loginParams = new URLSearchParams({
@@ -112,7 +112,7 @@ export default function LoginForm({ branding }: LoginFormProps) {
...(codeChallengeMethod ? { code_challenge_method: codeChallengeMethod } : {}),
})
const res = await fetch(`/api/login?${loginParams}`, {
const res = await fetch(`/v1/iam/login?${loginParams}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -271,6 +271,47 @@ export const staticBranding: Record<string, Partial<BrandingConfig>> = {
home: 'https://lux.network',
},
},
'id.lux.cloud': {
orgId: 'lux',
orgName: 'Lux Network',
logo: '/logos/lux.svg',
colors: {
primary: '#e4e4e7',
primaryText: '#09090b',
background: '#000000',
surface: '#0a0a0a',
text: '#ffffff',
textMuted: '#a1a1aa',
border: '#27272a',
error: '#dc2626',
},
content: {
title: 'Start deploying in seconds',
subtitle: 'High-performance blockchain infrastructure for the Lux ecosystem',
tagline: 'Lux-powered infrastructure',
quotes: [
{
text: "Lux is fast. We deploy chains in minutes, not weeks.",
author: 'Validator',
role: 'Node Operator',
}
],
},
auth: {
passwordEnabled: true,
codeEnabled: true,
webauthnEnabled: true,
faceIdEnabled: true,
socialProviders: ['metamask', 'google', 'github'],
},
links: {
terms: 'https://lux.network/terms',
privacy: 'https://lux.network/privacy',
support: 'https://lux.network/support',
docs: 'https://docs.lux.network',
home: 'https://lux.network',
},
},
'id.lux.network': {
orgId: 'lux',
orgName: 'Lux Network',
@@ -394,7 +435,7 @@ export const staticBranding: Record<string, Partial<BrandingConfig>> = {
home: 'https://lux-test.network',
},
},
'id.dev.hanzo.ai': {
'id-dev.hanzo.ai': {
orgId: 'hanzo',
orgName: 'Hanzo (Dev)',
logo: '/logos/hanzo.svg',
@@ -421,7 +462,7 @@ export const staticBranding: Record<string, Partial<BrandingConfig>> = {
socialProviders: ['metamask', 'google', 'github'],
},
},
'id.test.hanzo.ai': {
'id-test.hanzo.ai': {
orgId: 'hanzo',
orgName: 'Hanzo (Test)',
logo: '/logos/hanzo.svg',
@@ -447,7 +488,49 @@ export const staticBranding: Record<string, Partial<BrandingConfig>> = {
faceIdEnabled: true,
socialProviders: ['metamask', 'google', 'github'],
},
}, 'id.zoo.network': {
},
'zoolabs.id': {
orgId: 'zoo',
orgName: 'Zoo Labs',
logo: '/logos/zoo.svg',
colors: {
primary: '#e4e4e7',
primaryText: '#09090b',
background: '#000000',
surface: '#0a0a0a',
text: '#ffffff',
textMuted: '#a1a1aa',
border: '#27272a',
error: '#dc2626',
},
content: {
title: 'Build the future of DeAI',
subtitle: 'Open AI research + decentralized science for everyone',
tagline: 'Open AI research network',
quotes: [
{
text: "Zoo is where bleeding-edge DeAI experiments actually ship.",
author: 'Researcher',
role: 'ML Engineer',
}
],
},
auth: {
passwordEnabled: true,
codeEnabled: true,
webauthnEnabled: true,
faceIdEnabled: true,
socialProviders: ['metamask', 'google', 'github'],
},
links: {
terms: 'https://zoo.ngo/terms',
privacy: 'https://zoo.ngo/privacy',
support: 'https://zoo.ngo/support',
docs: 'https://zoo.ngo/docs',
home: 'https://zoo.ngo',
},
},
'id.zoo.network': {
orgId: 'zoo',
orgName: 'Zoo Labs',
logo: '/logos/zoo.svg',
@@ -720,6 +803,25 @@ export const staticBranding: Record<string, Partial<BrandingConfig>> = {
// Resolve domain to branding key
// Handles: exact match, id.{domain} → {domain}, {sub}.{domain} patterns
// Runtime-extensible tenants: deployments can ship additional tenant branding via
// TENANT_BRANDING_JSON env var (a JSON object: { domain: BrandingConfig, ... }).
// Downstream tenants and other white-label deployments can override/add tenants
// without modifying this source.
const ENV_TENANTS: Record<string, Partial<BrandingConfig>> = (() => {
try {
const raw = process.env.TENANT_BRANDING_JSON
if (!raw) return {}
const parsed = JSON.parse(raw)
return typeof parsed === 'object' && parsed !== null ? parsed : {}
} catch {
return {}
}
})()
// Merge static (compile-time) + env (runtime) tenants. Env wins.
Object.assign(staticBranding, ENV_TENANTS)
export function resolveBrandingDomain(host: string): string {
const domain = host.split(':')[0]
+1 -1
View File
@@ -83,7 +83,7 @@ export async function passwordLogin(params: {
clientId?: string
redirectUri?: string
}): Promise<{ token: string; code?: string }> {
const url = new URL('/api/login', params.iamUrl)
const url = new URL('/v1/iam/login', params.iamUrl)
// If OAuth params provided, pass as query params (camelCase — IAM convention)
if (params.clientId && params.redirectUri) {
+51 -22
View File
@@ -44,6 +44,11 @@ const TENANTS: Record<string, TenantConfig> = {
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://iam.lux.network',
},
'id.lux.cloud': {
org: 'lux',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://id.lux.cloud',
},
'id.lux.network': {
org: 'lux',
iamOrigin: 'https://iam.hanzo.ai',
@@ -59,15 +64,20 @@ const TENANTS: Record<string, TenantConfig> = {
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://id.lux-test.network',
},
'id.dev.hanzo.ai': {
'id-dev.hanzo.ai': {
org: 'hanzo',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://id.dev.hanzo.ai',
publicOrigin: 'https://id-dev.hanzo.ai',
},
'id.test.hanzo.ai': {
'id-test.hanzo.ai': {
org: 'hanzo',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://id.test.hanzo.ai',
publicOrigin: 'https://id-test.hanzo.ai',
},
'zoolabs.id': {
org: 'zoo',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://zoolabs.id',
},
'id.zoo.network': {
org: 'zoo',
@@ -142,29 +152,35 @@ function getTenant(hostname: string): TenantConfig {
// --- RFC path normalization ---
// PATH_REWRITES collapse RFC-standard OAuth/OIDC paths onto IAM's canonical
// surface. IAM exposes `/v1/iam/*` natively — `/api/*` is legacy and not
// part of the canonical surface. Every standard RFC alias funnels into a
// `/v1/iam/*` target, exactly one way.
const PATH_REWRITES: Record<string, string> = {
// NOTE: /oauth/authorize is handled explicitly in middleware() — NOT here.
// RFC 6749 — Token (exchange, refresh, client_credentials all use this)
'/oauth/token': '/api/login/oauth/access_token',
'/oauth/token': '/v1/iam/login/oauth/access_token',
// RFC 7662 — Token Introspection
'/oauth/introspect': '/api/login/oauth/introspect',
'/oauth/introspect': '/v1/iam/login/oauth/introspect',
// RFC 7009 — Token Revocation
'/oauth/revoke': '/api/login/oauth/revoke',
'/oauth/revoke': '/v1/iam/login/oauth/revoke',
// OIDC Core — UserInfo
'/oauth/userinfo': '/api/userinfo',
'/oauth/userinfo': '/v1/iam/userinfo',
// OIDC — Logout
'/oauth/logout': '/login/oauth/logout',
'/oauth/logout': '/v1/iam/logout',
// RFC 8628 — Device Authorization
'/oauth/device': '/api/login/oauth/device',
'/oauth/device': '/v1/iam/login/oauth/device',
// JWKS — standard /.well-known/jwks.json → IAM's /.well-known/jwks
'/.well-known/jwks.json': '/.well-known/jwks',
// RFC 8414 — OAuth metadata
'/.well-known/oauth-authorization-server': '/.well-known/openid-configuration',
}
// Paths to proxy to IAM backend (prefix match)
// Paths to proxy to IAM backend (prefix match). `/v1/iam/` is the canonical
// IAM surface — everything else here is RFC-spec aliasing that lands at IAM
// after PATH_REWRITES normalization.
const IAM_PATH_PREFIXES = [
'/api/',
'/v1/iam/',
'/oauth/',
'/login/oauth/',
'/.well-known/',
@@ -236,7 +252,7 @@ async function handleSocialProviderRedirect(
scope: url.searchParams.get('scope') || 'openid profile email',
state: url.searchParams.get('state') || '',
})
const appLoginRes = await fetch(`${tenant.iamOrigin}/api/get-app-login?${loginParams}`)
const appLoginRes = await fetch(`${tenant.iamOrigin}/v1/iam/get-app-login?${loginParams}`)
const appLoginData = await appLoginRes.json()
if (appLoginData?.status === 'ok' && appLoginData.data) {
appName = appLoginData.data.name || ''
@@ -405,7 +421,14 @@ export async function middleware(request: NextRequest) {
redirect: 'manual',
})
// Rewrite OIDC discovery documents
// Rewrite OIDC discovery documents.
//
// IAM advertises a mix of canonical (`/v1/iam/login/oauth/*`, `/v1/iam/userinfo`)
// and OAuth2-spec (`/login/oauth/*`, `/oauth/*`) endpoints. The public RFC
// shape on this domain is `/oauth/*` — collapse both legacy `/api/*` and
// canonical `/v1/iam/*` rewrites onto `/oauth/*` so OIDC clients see the
// standard surface. PATH_REWRITES handles the inbound direction
// (RFC → canonical `/v1/iam/*` for proxying).
const isDiscovery = pathname === '/.well-known/openid-configuration'
|| pathname === '/.well-known/oauth-authorization-server'
if (isDiscovery && iamResponse.ok) {
@@ -415,15 +438,18 @@ export async function middleware(request: NextRequest) {
let body = await iamResponse.text()
// Rewrite IAM backend origin to public tenant origin
body = body.replaceAll(tenant.iamOrigin, tenant.publicOrigin)
// Normalize legacy IAM backend paths to RFC standard paths
// Normalize IAM backend paths (both canonical /v1/iam/* and
// legacy /api/*) to RFC standard /oauth/* surface.
body = body.replaceAll('/v1/iam/login/oauth/authorize', '/oauth/authorize')
body = body.replaceAll('/v1/iam/login/oauth/access_token', '/oauth/token')
body = body.replaceAll('/v1/iam/login/oauth/refresh_token', '/oauth/token')
body = body.replaceAll('/v1/iam/login/oauth/introspect', '/oauth/introspect')
body = body.replaceAll('/v1/iam/login/oauth/revoke', '/oauth/revoke')
body = body.replaceAll('/v1/iam/login/oauth/device', '/oauth/device')
body = body.replaceAll('/v1/iam/userinfo', '/oauth/userinfo')
body = body.replaceAll('/v1/iam/logout', '/oauth/logout')
body = body.replaceAll('/login/oauth/authorize', '/oauth/authorize')
body = body.replaceAll('/api/login/oauth/access_token', '/oauth/token')
body = body.replaceAll('/api/login/oauth/refresh_token', '/oauth/token')
body = body.replaceAll('/api/login/oauth/introspect', '/oauth/introspect')
body = body.replaceAll('/api/login/oauth/revoke', '/oauth/revoke')
body = body.replaceAll('/login/oauth/logout', '/oauth/logout')
body = body.replaceAll('/api/login/oauth/device', '/oauth/device')
body = body.replaceAll('/api/userinfo', '/oauth/userinfo')
return new NextResponse(body, {
status: iamResponse.status,
headers: iamResponse.headers,
@@ -455,7 +481,10 @@ export async function middleware(request: NextRequest) {
export const config = {
matcher: [
'/api/:path*',
// /v1/iam/* is the canonical IAM surface — this must match so the
// middleware proxies it to IAM_ORIGIN. Without this, CF Pages returns
// 405 for POST /v1/iam/login because the static SPA has no POST handler.
'/v1/iam/:path*',
'/oauth/:path*',
'/login/oauth/:path*',
'/.well-known/:path*',
View File
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@hanzo/id",
"version": "0.1.0",
"private": true,
"description": "White-label login portal for Hanzo IAM - forkable, multi-tenant, RFC-compliant OAuth2/OIDC",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"pages:build": "npx @cloudflare/next-on-pages 2>&1 || true && node scripts/patch-not-found.mjs && npx @cloudflare/next-on-pages --skip-build",
"deploy": "pnpm pages:build && wrangler pages deploy .vercel/output/static --project-name hanzo-id --commit-dirty=true",
"deploy:docker": "docker build -t hanzo-id . && docker push ghcr.io/hanzoai/id:latest"
},
"dependencies": {
"next": "^15.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwindcss": "^3.4.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0"
},
"devDependencies": {
"@cloudflare/next-on-pages": "^1.13.0",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"typescript": "^5.0.0",
"wrangler": "^3.0.0"
}
}
+2453
View File
File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 226 B

After

Width:  |  Height:  |  Size: 226 B

Before

Width:  |  Height:  |  Size: 829 B

After

Width:  |  Height:  |  Size: 829 B

Before

Width:  |  Height:  |  Size: 221 B

After

Width:  |  Height:  |  Size: 221 B

Before

Width:  |  Height:  |  Size: 222 B

After

Width:  |  Height:  |  Size: 222 B

Before

Width:  |  Height:  |  Size: 221 B

After

Width:  |  Height:  |  Size: 221 B

Before

Width:  |  Height:  |  Size: 221 B

After

Width:  |  Height:  |  Size: 221 B

+7 -22
View File
@@ -1,30 +1,15 @@
{
"name": "@hanzo/id",
"version": "0.1.0",
"private": true,
"description": "White-label login portal for Hanzo IAM - forkable, multi-tenant, RFC-compliant OAuth2/OIDC",
"version": "0.1.0",
"description": "Hanzo ID — white-label login + identity verification portal (Vite + @hanzo/gui)",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"pages:build": "npx @cloudflare/next-on-pages 2>&1 || true && node scripts/patch-not-found.mjs && npx @cloudflare/next-on-pages --skip-build",
"deploy": "pnpm pages:build && wrangler pages deploy .vercel/output/static --project-name hanzo-id --commit-dirty=true",
"deploy:docker": "docker build -t hanzo-id . && docker push ghcr.io/hanzoai/id:latest"
},
"dependencies": {
"next": "^15.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwindcss": "^3.4.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0"
"build": "pnpm -r build",
"dev": "pnpm --filter @hanzo/id-web dev",
"tc": "pnpm -r tc",
"clean": "bash scripts/clean.sh"
},
"devDependencies": {
"@cloudflare/next-on-pages": "^1.13.0",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"typescript": "^5.0.0",
"wrangler": "^3.0.0"
"typescript": "^5.9.3"
}
}
+33
View File
@@ -0,0 +1,33 @@
{
"name": "@hanzo/id-auth",
"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",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./client": "./src/client.ts",
"./forms": "./src/ui/index.ts",
"./package.json": "./package.json"
},
"files": ["src"],
"scripts": {
"tc": "tsc --noEmit"
},
"dependencies": {
"@hanzo/id-shared": "workspace:*",
"@hanzo/iam": "^0.9.4"
},
"peerDependencies": {
"react": ">=19",
"react-dom": ">=19"
},
"devDependencies": {
"@types/react": "^19.0.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"typescript": "^5.9.3"
}
}
+171
View File
@@ -0,0 +1,171 @@
import type { TenantConfig } from '@hanzo/id-shared'
import type {
ForgotRequest,
LoginRequest,
LoginResponse,
OAuthAuthorizeRequest,
SignupRequest,
TokenResponse,
} from './types'
/**
* Composable IAM client.
*
* Stateless wrapper around the canonical IAM REST surface (Casdoor-compat
* paths under `/v1/iam/*` and the OIDC paths under `/oauth/*`). One client
* instance per tenant. The portal creates one in `createRoot()`; downstream
* pages call `.login()`, `.signup()`, `.forgot()`, `.authorize()` directly.
*
* Token storage is intentionally NOT part of this client — the portal is a
* white-label OIDC provider, so tokens are minted then immediately redirected
* back to the requesting app via `redirectUri`. The browser never holds them
* past the redirect.
*/
export interface AuthClient {
readonly tenant: TenantConfig
login(req: LoginRequest): Promise<LoginResponse>
signup(req: SignupRequest): Promise<LoginResponse>
forgot(req: ForgotRequest): Promise<{ ok: boolean; error?: string }>
authorize(req: OAuthAuthorizeRequest): string
exchange(code: string, codeVerifier?: string): Promise<TokenResponse>
logout(idTokenHint?: string, postLogoutRedirectUri?: string): string
}
export interface AuthClientOptions {
readonly tenant: TenantConfig
/** Override fetch impl (testing). Defaults to global fetch. */
readonly fetchImpl?: typeof fetch
}
export function createAuthClient(opts: AuthClientOptions): AuthClient {
const tenant = opts.tenant
const f = opts.fetchImpl ?? fetch
async function login(req: LoginRequest): Promise<LoginResponse> {
const url = new URL('/v1/iam/login', tenant.iamUrl)
url.searchParams.set('clientId', req.clientId)
url.searchParams.set('application', req.application)
url.searchParams.set('organization', req.organization)
if (req.redirectUri) url.searchParams.set('redirect_uri', req.redirectUri)
if (req.state) url.searchParams.set('state', req.state)
const res = await f(url.toString(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
username: req.identifier,
password: req.password,
signinMethod: 'Password',
language: 'en',
autoSignin: true,
}),
})
return parseLoginResponse(res)
}
async function signup(req: SignupRequest): Promise<LoginResponse> {
const url = new URL('/v1/iam/signup', tenant.iamUrl)
url.searchParams.set('clientId', req.clientId)
url.searchParams.set('application', req.application)
url.searchParams.set('organization', req.organization)
const res = await f(url.toString(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
email: req.email,
password: req.password,
inviteCode: req.inviteCode,
}),
})
return parseLoginResponse(res)
}
async function forgot(req: ForgotRequest): Promise<{ ok: boolean; error?: string }> {
const url = new URL('/v1/iam/send-verification-code', tenant.iamUrl)
url.searchParams.set('clientId', req.clientId)
url.searchParams.set('organization', req.organization)
const res = await f(url.toString(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dest: req.identifier, type: 'login' }),
})
if (!res.ok) return { ok: false, error: `HTTP ${res.status}` }
return { ok: true }
}
function authorize(req: OAuthAuthorizeRequest): string {
const url = new URL('/oauth/authorize', tenant.iamUrl)
url.searchParams.set('client_id', req.clientId)
url.searchParams.set('redirect_uri', req.redirectUri)
url.searchParams.set('response_type', req.responseType ?? 'code')
url.searchParams.set('scope', req.scope ?? 'openid profile email')
url.searchParams.set('state', req.state)
if (req.nonce) url.searchParams.set('nonce', req.nonce)
if (req.codeChallenge) {
url.searchParams.set('code_challenge', req.codeChallenge)
url.searchParams.set('code_challenge_method', req.codeChallengeMethod ?? 'S256')
}
return url.toString()
}
async function exchange(code: string, codeVerifier?: string): Promise<TokenResponse> {
const url = new URL('/oauth/token', tenant.iamUrl)
const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
client_id: tenant.clientId,
redirect_uri: `${tenant.publicOrigin}/callback`,
})
if (codeVerifier) body.set('code_verifier', codeVerifier)
const res = await f(url.toString(), {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
})
if (!res.ok) throw new Error(`token exchange failed: ${res.status}`)
const data = (await res.json()) as Record<string, unknown>
return {
accessToken: String(data.access_token ?? ''),
refreshToken: typeof data.refresh_token === 'string' ? data.refresh_token : undefined,
idToken: typeof data.id_token === 'string' ? data.id_token : undefined,
tokenType: String(data.token_type ?? 'Bearer'),
expiresIn: typeof data.expires_in === 'number' ? data.expires_in : undefined,
scope: typeof data.scope === 'string' ? data.scope : undefined,
}
}
function logout(idTokenHint?: string, postLogoutRedirectUri?: string): string {
const url = new URL('/oauth/logout', tenant.iamUrl)
if (idTokenHint) url.searchParams.set('id_token_hint', idTokenHint)
url.searchParams.set(
'post_logout_redirect_uri',
postLogoutRedirectUri ?? `${tenant.publicOrigin}/login`,
)
return url.toString()
}
return { tenant, login, signup, forgot, authorize, exchange, logout }
}
async function parseLoginResponse(res: Response): Promise<LoginResponse> {
let body: Record<string, unknown> = {}
try {
body = (await res.json()) as Record<string, unknown>
} catch {
return { error: `HTTP ${res.status} non-JSON response` }
}
if (!res.ok || body.status === 'error') {
return { error: typeof body.msg === 'string' ? body.msg : `HTTP ${res.status}` }
}
const data = (body.data ?? body) as Record<string, unknown>
return {
accessToken: typeof data.access_token === 'string' ? data.access_token : undefined,
refreshToken: typeof data.refresh_token === 'string' ? data.refresh_token : undefined,
idToken: typeof data.id_token === 'string' ? data.id_token : undefined,
expiresAt: typeof data.expires_at === 'number' ? data.expires_at : undefined,
redirectUrl: typeof data.redirect_url === 'string' ? data.redirect_url : undefined,
mfaRequired: data.mfa_required === true,
mfaChannel: typeof data.mfa_channel === 'string' ? (data.mfa_channel as LoginResponse['mfaChannel']) : undefined,
}
}
+10
View File
@@ -0,0 +1,10 @@
export { createAuthClient, type AuthClient, type AuthClientOptions } from './client'
export type {
LoginRequest,
LoginResponse,
SignupRequest,
ForgotRequest,
OAuthAuthorizeRequest,
TokenResponse,
} from './types'
export * from './ui'
+55
View File
@@ -0,0 +1,55 @@
export interface LoginRequest {
readonly identifier: string
readonly password: string
readonly clientId: string
readonly application: string
readonly organization: string
readonly redirectUri?: string
readonly state?: string
}
export interface LoginResponse {
readonly accessToken?: string
readonly refreshToken?: string
readonly idToken?: string
readonly expiresAt?: number
readonly redirectUrl?: string
readonly mfaRequired?: boolean
readonly mfaChannel?: 'totp' | 'sms' | 'email'
readonly error?: string
}
export interface SignupRequest {
readonly email: string
readonly password: string
readonly clientId: string
readonly application: string
readonly organization: string
readonly inviteCode?: string
}
export interface ForgotRequest {
readonly identifier: string
readonly clientId: string
readonly organization: string
}
export interface OAuthAuthorizeRequest {
readonly clientId: string
readonly redirectUri: string
readonly state: string
readonly scope?: string
readonly nonce?: string
readonly responseType?: 'code' | 'token'
readonly codeChallenge?: string
readonly codeChallengeMethod?: 'S256' | 'plain'
}
export interface TokenResponse {
readonly accessToken: string
readonly refreshToken?: string
readonly idToken?: string
readonly tokenType: string
readonly expiresIn?: number
readonly scope?: string
}
+52
View File
@@ -0,0 +1,52 @@
import { useState, type FormEvent } from 'react'
import type { AuthClient } from '../client'
export interface ForgotFormProps {
readonly client: AuthClient
readonly onSent?: () => void
}
export function ForgotForm(props: ForgotFormProps) {
const { client } = props
const [identifier, setIdentifier] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [sent, setSent] = useState(false)
async function onSubmit(e: FormEvent) {
e.preventDefault()
setBusy(true)
setError(null)
try {
const res = await client.forgot({
identifier,
clientId: client.tenant.clientId,
organization: client.tenant.orgId,
})
if (!res.ok) setError(res.error ?? 'send failed')
else {
setSent(true)
props.onSent?.()
}
} catch (err) {
setError(String(err))
} finally {
setBusy(false)
}
}
if (sent) {
return <p className="id-portal-info">Check your inbox for a reset link.</p>
}
return (
<form onSubmit={onSubmit} className="id-portal-forgot-form" aria-busy={busy}>
<label>
<span>Email</span>
<input type="email" autoComplete="email" value={identifier} onChange={(e) => setIdentifier(e.target.value)} required />
</label>
{error ? <p role="alert" className="id-portal-error">{error}</p> : null}
<button type="submit" disabled={busy}>{busy ? 'Sending…' : 'Send reset link'}</button>
</form>
)
}
+77
View File
@@ -0,0 +1,77 @@
import { useState, type FormEvent } from 'react'
import type { AuthClient } from '../client'
import type { LoginResponse } from '../types'
export interface LoginFormProps {
readonly client: AuthClient
readonly redirectUri?: string
readonly state?: string
readonly clientIdOverride?: string
readonly onSuccess?: (res: LoginResponse) => void
readonly onMfaRequired?: (res: LoginResponse) => void
}
export function LoginForm(props: LoginFormProps) {
const { client } = props
const [identifier, setIdentifier] = useState('')
const [password, setPassword] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
async function onSubmit(e: FormEvent) {
e.preventDefault()
setBusy(true)
setError(null)
try {
const res = await client.login({
identifier,
password,
clientId: props.clientIdOverride ?? client.tenant.clientId,
application: client.tenant.appName,
organization: client.tenant.orgId,
redirectUri: props.redirectUri,
state: props.state,
})
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)
}
} catch (err) {
setError(String(err))
} finally {
setBusy(false)
}
}
return (
<form onSubmit={onSubmit} className="id-portal-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="id-portal-error">{error}</p> : null}
<button type="submit" disabled={busy}>{busy ? 'Signing in…' : 'Sign in'}</button>
</form>
)
}
+45
View File
@@ -0,0 +1,45 @@
import { useState, type FormEvent } from 'react'
export interface OTPFormProps {
readonly onSubmit: (code: string) => void | Promise<void>
readonly length?: number
readonly channel?: 'totp' | 'sms' | 'email'
}
export function OTPForm(props: OTPFormProps) {
const { length = 6, channel = 'totp' } = props
const [code, setCode] = useState('')
const [busy, setBusy] = useState(false)
async function onSubmit(e: FormEvent) {
e.preventDefault()
if (code.length !== length) return
setBusy(true)
try {
await props.onSubmit(code)
} finally {
setBusy(false)
}
}
const label = channel === 'sms' ? 'SMS code' : channel === 'email' ? 'Email code' : 'Authenticator code'
return (
<form onSubmit={onSubmit} className="id-portal-otp-form" aria-busy={busy}>
<label>
<span>{label}</span>
<input
type="text"
inputMode="numeric"
pattern={`\\d{${length}}`}
maxLength={length}
autoComplete="one-time-code"
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, length))}
required
/>
</label>
<button type="submit" disabled={busy || code.length !== length}>{busy ? 'Verifying…' : 'Verify'}</button>
</form>
)
}
+61
View File
@@ -0,0 +1,61 @@
import { useState, type FormEvent } from 'react'
import type { AuthClient } from '../client'
export interface SignupFormProps {
readonly client: AuthClient
readonly inviteCode?: string
readonly onSuccess?: () => void
}
export function SignupForm(props: SignupFormProps) {
const { client } = props
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
async function onSubmit(e: FormEvent) {
e.preventDefault()
setBusy(true)
setError(null)
try {
const res = await client.signup({
email,
password,
clientId: client.tenant.clientId,
application: client.tenant.appName,
organization: client.tenant.orgId,
inviteCode: props.inviteCode,
})
if (res.error) setError(res.error)
else if (res.redirectUrl) window.location.href = res.redirectUrl
else props.onSuccess?.()
} catch (err) {
setError(String(err))
} finally {
setBusy(false)
}
}
return (
<form onSubmit={onSubmit} className="id-portal-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="id-portal-error">{error}</p> : null}
<button type="submit" disabled={busy}>{busy ? 'Creating account…' : 'Create account'}</button>
</form>
)
}
+4
View File
@@ -0,0 +1,4 @@
export { LoginForm } from './LoginForm'
export { SignupForm } from './SignupForm'
export { ForgotForm } from './ForgotForm'
export { OTPForm } from './OTPForm'
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"noEmit": true
},
"include": ["src"]
}
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@hanzo/id-idv",
"version": "0.1.0",
"description": "Pluggable identity verification (KYC / KYB / liveness). Provider-agnostic — wires Persona, Onfido, Veriff, Sumsub, or any custom backend behind a single React surface.",
"license": "BSD-3-Clause",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./providers/persona": "./src/providers/persona.ts",
"./providers/onfido": "./src/providers/onfido.ts",
"./providers/veriff": "./src/providers/veriff.ts",
"./providers/stub": "./src/providers/stub.ts",
"./flow": "./src/ui/IDVFlow.tsx",
"./package.json": "./package.json"
},
"files": ["src"],
"scripts": {
"tc": "tsc --noEmit"
},
"dependencies": {
"@hanzo/id-shared": "workspace:*"
},
"peerDependencies": {
"react": ">=19",
"react-dom": ">=19"
},
"devDependencies": {
"@types/react": "^19.0.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"typescript": "^5.9.3"
}
}
+3
View File
@@ -0,0 +1,3 @@
export * from './provider'
export * from './session'
export { IDVFlow } from './ui/IDVFlow'
+78
View File
@@ -0,0 +1,78 @@
/**
* IDV (identity verification) provider contract.
*
* A provider knows how to:
* 1. mint a verification session for a given subject + flow,
* 2. resume an existing session by id,
* 3. report the terminal status (passed / failed / needs_review / expired).
*
* The portal stays agnostic of the actual vendor (Persona, Onfido, Veriff,
* Sumsub, Stripe Identity, a custom backend, or the in-process stub for
* dev). Wire whichever in `apps/web/src/main.tsx` via `setProvider(...)`.
*/
export type IDVFlowKind =
| 'kyc-basic' // ID doc + selfie
| 'kyc-enhanced' // + proof of address
| 'kyb' // business onboarding
| 'liveness' // selfie-only liveness check
| 'address-proof'
export type IDVStatus =
| 'pending'
| 'in_progress'
| 'awaiting_review'
| 'passed'
| 'failed'
| 'expired'
| 'cancelled'
export interface IDVSubject {
/** Stable subject identifier (typically the IAM user id). */
readonly subjectId: string
/** Tenant org slug (for multi-tenant providers). */
readonly orgId: string
/** Email + display name carried through for vendor pre-fill. */
readonly email?: string
readonly displayName?: string
}
export interface IDVSessionInit {
readonly subject: IDVSubject
readonly flow: IDVFlowKind
/** Where to send the user after the IDV widget completes. */
readonly redirectUri: string
/** Optional vendor-specific metadata pass-through. */
readonly metadata?: Readonly<Record<string, string>>
}
export interface IDVSessionHandle {
readonly id: string
readonly provider: string
/** URL the browser should redirect the user to (hosted vendor flow). */
readonly hostedUrl?: string
/**
* Inline embed config (when the vendor supports an in-app web SDK). The
* IDV UI mounts an iframe / web component using this token + config.
*/
readonly embed?: {
readonly sdkUrl: string
readonly token: string
readonly env?: 'sandbox' | 'production'
}
}
export interface IDVStatusReport {
readonly id: string
readonly status: IDVStatus
readonly reason?: string
/** Provider-specific result blob (audit trail). */
readonly raw?: Readonly<Record<string, unknown>>
}
export interface IDVProvider {
readonly id: string
start(init: IDVSessionInit): Promise<IDVSessionHandle>
status(sessionId: string): Promise<IDVStatusReport>
cancel?(sessionId: string): Promise<void>
}
+69
View File
@@ -0,0 +1,69 @@
import type { IDVProvider, IDVSessionHandle, IDVSessionInit, IDVStatusReport } from '../provider'
export interface OnfidoOptions {
readonly apiToken: string
readonly region?: 'eu' | 'us' | 'ca'
readonly workflowId?: string
readonly fetchImpl?: typeof fetch
}
export function createOnfidoProvider(opts: OnfidoOptions): IDVProvider {
const region = opts.region ?? 'eu'
const base = `https://api.${region}.onfido.com/v3.6`
const f = opts.fetchImpl ?? fetch
return {
id: 'onfido',
async start(init: IDVSessionInit): Promise<IDVSessionHandle> {
// Create an applicant
const applicantRes = await f(`${base}/applicants`, {
method: 'POST',
headers: { Authorization: `Token token=${opts.apiToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
first_name: init.subject.displayName?.split(' ')[0] ?? 'Applicant',
last_name: init.subject.displayName?.split(' ').slice(1).join(' ') || init.subject.subjectId,
email: init.subject.email,
external_id: init.subject.subjectId,
}),
})
if (!applicantRes.ok) throw new Error(`onfido applicant failed: ${applicantRes.status}`)
const applicant = (await applicantRes.json()) as { id: string }
// Generate SDK token for the web SDK
const tokenRes = await f(`${base}/sdk_token`, {
method: 'POST',
headers: { Authorization: `Token token=${opts.apiToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
applicant_id: applicant.id,
referrer: '*://*.hanzo.id/*',
}),
})
if (!tokenRes.ok) throw new Error(`onfido sdk_token failed: ${tokenRes.status}`)
const { token } = (await tokenRes.json()) as { token: string }
return {
id: applicant.id,
provider: 'onfido',
embed: {
sdkUrl: 'https://assets.onfido.com/web-sdk-releases/14.0.0/onfido.min.js',
token,
},
}
},
async status(sessionId: string): Promise<IDVStatusReport> {
const res = await f(`${base}/checks?applicant_id=${sessionId}`, {
headers: { Authorization: `Token token=${opts.apiToken}` },
})
if (!res.ok) throw new Error(`onfido check status failed: ${res.status}`)
const body = (await res.json()) as { checks?: Array<{ status: string; result?: string }> }
const latest = body.checks?.[0]
if (!latest) return { id: sessionId, status: 'pending' }
const status: IDVStatusReport['status'] =
latest.status === 'complete'
? latest.result === 'clear'
? 'passed'
: 'failed'
: 'in_progress'
return { id: sessionId, status, raw: latest as unknown as Readonly<Record<string, unknown>> }
},
}
}
+90
View File
@@ -0,0 +1,90 @@
import type { IDVProvider, IDVSessionHandle, IDVSessionInit, IDVStatusReport } from '../provider'
/**
* Persona (https://withpersona.com) IDV adapter.
*
* Pre-creates an Inquiry via the Persona REST API, returns the hosted
* URL for redirect. Status is read by polling the Inquiry resource.
*/
export interface PersonaOptions {
readonly templateId: string
readonly apiKey: string
/** Sandbox or production environment. */
readonly environment?: 'sandbox' | 'production'
/** Override fetch impl (testing). */
readonly fetchImpl?: typeof fetch
}
export function createPersonaProvider(opts: PersonaOptions): IDVProvider {
const base = 'https://withpersona.com/api/v1'
const f = opts.fetchImpl ?? fetch
return {
id: 'persona',
async start(init: IDVSessionInit): Promise<IDVSessionHandle> {
const res = await f(`${base}/inquiries`, {
method: 'POST',
headers: {
Authorization: `Bearer ${opts.apiKey}`,
'Content-Type': 'application/json',
'Persona-Version': '2023-01-05',
},
body: JSON.stringify({
data: {
attributes: {
'inquiry-template-id': opts.templateId,
'reference-id': init.subject.subjectId,
fields: { email: init.subject.email },
},
},
}),
})
if (!res.ok) throw new Error(`persona start failed: ${res.status}`)
const body = (await res.json()) as { data: { id: string; attributes: { 'session-token'?: string } } }
const id = body.data.id
const token = body.data.attributes['session-token']
return {
id,
provider: 'persona',
embed: token
? {
sdkUrl: 'https://cdn.withpersona.com/dist/persona-v5.1.0.js',
token,
env: opts.environment ?? 'sandbox',
}
: undefined,
hostedUrl: `https://withpersona.com/verify?inquiry-id=${id}&redirect-uri=${encodeURIComponent(init.redirectUri)}`,
}
},
async status(sessionId: string): Promise<IDVStatusReport> {
const res = await f(`${base}/inquiries/${sessionId}`, {
headers: {
Authorization: `Bearer ${opts.apiKey}`,
'Persona-Version': '2023-01-05',
},
})
if (!res.ok) throw new Error(`persona status failed: ${res.status}`)
const body = (await res.json()) as { data: { attributes: { status: string } } }
return { id: sessionId, status: mapStatus(body.data.attributes.status), raw: body.data as unknown as Readonly<Record<string, unknown>> }
},
}
}
function mapStatus(s: string): IDVStatusReport['status'] {
switch (s) {
case 'completed':
case 'approved':
return 'passed'
case 'failed':
case 'declined':
return 'failed'
case 'needs_review':
return 'awaiting_review'
case 'expired':
return 'expired'
case 'created':
case 'pending':
return 'pending'
default:
return 'in_progress'
}
}
+31
View File
@@ -0,0 +1,31 @@
import type { IDVProvider, IDVSessionHandle, IDVSessionInit, IDVStatusReport } from '../provider'
/**
* In-memory IDV stub. Always passes after a short delay. For local dev only
* — never use in any environment that resolves user identity for real.
*/
export function createStubProvider(): IDVProvider {
const sessions = new Map<string, { startedAt: number; init: IDVSessionInit }>()
return {
id: 'stub',
async start(init: IDVSessionInit): Promise<IDVSessionHandle> {
const id = `stub-${crypto.randomUUID()}`
sessions.set(id, { startedAt: Date.now(), init })
return {
id,
provider: 'stub',
hostedUrl: `${init.redirectUri}?session=${id}&status=passed`,
}
},
async status(sessionId: string): Promise<IDVStatusReport> {
const s = sessions.get(sessionId)
if (!s) return { id: sessionId, status: 'expired' }
const elapsed = Date.now() - s.startedAt
return {
id: sessionId,
status: elapsed > 2000 ? 'passed' : 'in_progress',
}
},
}
}
+54
View File
@@ -0,0 +1,54 @@
import type { IDVProvider, IDVSessionHandle, IDVSessionInit, IDVStatusReport } from '../provider'
export interface VeriffOptions {
readonly apiKey: string
readonly secret: string
readonly baseUrl?: string
readonly fetchImpl?: typeof fetch
}
export function createVeriffProvider(opts: VeriffOptions): IDVProvider {
const base = opts.baseUrl ?? 'https://stationapi.veriff.com/v1'
const f = opts.fetchImpl ?? fetch
return {
id: 'veriff',
async start(init: IDVSessionInit): Promise<IDVSessionHandle> {
const res = await f(`${base}/sessions`, {
method: 'POST',
headers: { 'X-AUTH-CLIENT': opts.apiKey, 'Content-Type': 'application/json' },
body: JSON.stringify({
verification: {
callback: init.redirectUri,
person: { firstName: init.subject.displayName ?? '', lastName: init.subject.subjectId },
vendorData: init.subject.subjectId,
},
}),
})
if (!res.ok) throw new Error(`veriff session failed: ${res.status}`)
const body = (await res.json()) as { verification: { id: string; url: string } }
return {
id: body.verification.id,
provider: 'veriff',
hostedUrl: body.verification.url,
}
},
async status(sessionId: string): Promise<IDVStatusReport> {
const res = await f(`${base}/sessions/${sessionId}/decision`, {
headers: { 'X-AUTH-CLIENT': opts.apiKey },
})
if (!res.ok) throw new Error(`veriff decision failed: ${res.status}`)
const body = (await res.json()) as { verification?: { status?: string; code?: number } }
const status: IDVStatusReport['status'] =
body.verification?.status === 'approved'
? 'passed'
: body.verification?.status === 'declined'
? 'failed'
: body.verification?.status === 'resubmission_requested'
? 'awaiting_review'
: body.verification?.status === 'expired'
? 'expired'
: 'in_progress'
return { id: sessionId, status, raw: body as unknown as Readonly<Record<string, unknown>> }
},
}
}
+29
View File
@@ -0,0 +1,29 @@
import type { IDVProvider, IDVSessionHandle, IDVSessionInit, IDVStatusReport } from './provider'
/** Registry of installed IDV providers, keyed by provider id. */
const registry = new Map<string, IDVProvider>()
let active: IDVProvider | null = null
export function registerProvider(p: IDVProvider): void {
registry.set(p.id, p)
if (!active) active = p
}
export function setActiveProvider(id: string): void {
const p = registry.get(id)
if (!p) throw new Error(`IDV provider not registered: ${id}`)
active = p
}
export function getActiveProvider(): IDVProvider {
if (!active) throw new Error('No IDV provider registered. Call registerProvider() at startup.')
return active
}
export async function startSession(init: IDVSessionInit): Promise<IDVSessionHandle> {
return getActiveProvider().start(init)
}
export async function getStatus(sessionId: string): Promise<IDVStatusReport> {
return getActiveProvider().status(sessionId)
}
+81
View File
@@ -0,0 +1,81 @@
import { useEffect, useState } from 'react'
import { getActiveProvider } from '../session'
import type { IDVFlowKind, IDVSessionHandle, IDVStatusReport, IDVSubject } from '../provider'
export interface IDVFlowProps {
readonly subject: IDVSubject
readonly flow: IDVFlowKind
/** Where to send the user after the flow completes. */
readonly redirectUri: string
/** Called as the status changes. */
readonly onChange?: (s: IDVStatusReport) => void
/** Called once the status is terminal. */
readonly onTerminal?: (s: IDVStatusReport) => void
}
const TERMINAL = new Set<IDVStatusReport['status']>(['passed', 'failed', 'expired', 'cancelled'])
export function IDVFlow(props: IDVFlowProps) {
const [handle, setHandle] = useState<IDVSessionHandle | null>(null)
const [status, setStatus] = useState<IDVStatusReport | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let stopped = false
let poll: ReturnType<typeof setInterval> | null = null
const provider = getActiveProvider()
provider
.start({ subject: props.subject, flow: props.flow, redirectUri: props.redirectUri })
.then((h) => {
if (stopped) return
setHandle(h)
// If hosted, redirect immediately
if (h.hostedUrl && !h.embed) {
window.location.href = h.hostedUrl
return
}
// Otherwise poll for status while the embed is rendered
poll = setInterval(async () => {
try {
const s = await provider.status(h.id)
if (stopped) return
setStatus(s)
props.onChange?.(s)
if (TERMINAL.has(s.status)) {
if (poll) clearInterval(poll)
props.onTerminal?.(s)
}
} catch (e) {
setError(String(e))
}
}, 4000)
})
.catch((e) => setError(String(e)))
return () => {
stopped = true
if (poll) clearInterval(poll)
}
// props.flow / props.subject changes trigger a new session; the deps list
// intentionally tracks the meaningful identity bits.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.subject.subjectId, props.flow, props.redirectUri])
if (error) return <p role="alert" className="id-portal-error">{error}</p>
if (!handle) return <p>Starting verification</p>
if (handle.embed) {
return (
<div className="id-portal-idv-embed" data-provider={handle.provider}>
<iframe
title="Identity verification"
src={handle.embed.sdkUrl}
// The web SDKs use postMessage to receive the token — apps that
// need full SDK init should override IDVFlow with their own
// provider-specific mount. This iframe is the safe default.
style={{ width: '100%', minHeight: 600, border: 0 }}
/>
{status ? <p className="id-portal-idv-status">Status: {status.status}</p> : null}
</div>
)
}
return <p>Redirecting</p>
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"noEmit": true
},
"include": ["src"]
}
+23
View File
@@ -0,0 +1,23 @@
{
"name": "@hanzo/id-shared",
"version": "0.1.0",
"description": "Shared types + tenant resolver for the Hanzo ID portal. No UI deps.",
"license": "BSD-3-Clause",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./tenant": "./src/tenant.ts",
"./brand": "./src/brand.ts",
"./package.json": "./package.json"
},
"files": ["src"],
"scripts": {
"tc": "tsc --noEmit",
"build": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^5.9.3"
}
}
+43
View File
@@ -0,0 +1,43 @@
import type { BrandContract } from './types'
/**
* Resolve a BrandContract from an absolute URL.
*
* No coupling to specific brand packages. The TenantConfig's `brandUrl`
* is a full URL — npm CDN (`https://cdn.jsdelivr.net/npm/@foo/brand@latest/brand.json`),
* a brand-owned host, or anywhere else. Whatever the URL is, it must
* serve a JSON document `{ "brand": BrandContract }`.
*
* brand.json's `logoUrl` and `faviconUrl` are absolute URLs and used as-is.
* Brands are responsible for hosting their own assets.
*/
export async function loadBrand(brandUrl: string): Promise<BrandContract> {
const res = await fetch(brandUrl, { cache: 'no-store' })
if (!res.ok) throw new Error(`brand.json fetch failed: ${res.status} for ${brandUrl}`)
const raw = await res.json()
if (!raw || typeof raw !== 'object' || !raw.brand) {
throw new Error(`brand.json malformed (missing .brand): ${brandUrl}`)
}
return raw.brand as BrandContract
}
/** Subset of the brand contract safe to expose to the browser as window.__BRAND__. */
export interface BrandRuntime {
readonly name: string
readonly title: string
readonly description: string
readonly logoUrl: string
readonly faviconUrl: string
readonly accentColor?: string
}
export function toBrandRuntime(b: BrandContract): BrandRuntime {
return {
name: b.name,
title: b.title,
description: b.description,
logoUrl: b.logoUrl,
faviconUrl: b.faviconUrl,
accentColor: b.accentColor,
}
}
+3
View File
@@ -0,0 +1,3 @@
export * from './tenant'
export * from './brand'
export * from './types'
+95
View File
@@ -0,0 +1,95 @@
import type { TenantConfig } from './types'
/**
* Resolve a TenantConfig by hostname.
*
* Resolution order:
* 1. Runtime catalog (parsed from `window.__ID_CATALOG__`, served by the
* pod's `/config.json` from `SPA_IAM_TENANT_CONFIG_JSON` at deploy time).
* 2. Hostname-derived defaults (no brand-specific entries in source).
*
* The catalog supplies all brand-specific knowledge (npm-scope-vs-org
* mismatch like `lux` → `@luxfi/brand`, custom clientId, etc.). The image
* carries zero brand-specific data — adding a new brand never touches
* this repo.
*
* Hostname derivation rules (covers `<org>.id`, `id.<org>.<tld>`,
* `iam.<org>.<tld>`, and `www.` variants) just give a sensible default.
* Anything more nuanced belongs in the catalog.
*/
const TRIM_TRAILING_SLASH = (s: string): string => s.replace(/\/+$/, '')
export interface ResolveOptions {
/** Runtime catalog, host → partial TenantConfig overrides. */
readonly catalog?: Record<string, Partial<TenantConfig>>
}
export function resolveTenant(hostname: string, opts: ResolveOptions = {}): TenantConfig {
const host = stripPort(hostname).toLowerCase()
const derived = deriveTenant(host)
const override = opts.catalog?.[host] ?? {}
return normalize({ ...derived, ...override })
}
/**
* Brand-agnostic hostname → TenantConfig derivation.
* No hardcoded org names, no hardcoded brand packages.
*/
function deriveTenant(host: string): TenantConfig {
const org = deriveOrg(host)
return {
orgId: org,
iamUrl: `https://${host}`,
iamIssuer: `https://${host}`,
clientId: `${org}-id-portal`,
appName: `${org}-id`,
publicOrigin: `https://${host}`,
brandUrl: `https://cdn.jsdelivr.net/npm/@${org}/brand@latest/brand.json`,
}
}
/**
* Extract an org slug from a hostname.
*
* foo.id → foo
* www.foo.id → foo
* id.foo.network → foo
* iam.foo.network → foo
* anything else → first label
*/
function deriveOrg(host: string): string {
const h = host.startsWith('www.') ? host.slice(4) : host
if (h.endsWith('.id')) {
const labels = h.slice(0, -3).split('.')
return labels[labels.length - 1] || 'hanzo'
}
const m = /^(?:id|iam)\.([^.]+)\.[^.]+$/.exec(h)
if (m && m[1]) return m[1]
return h.split('.')[0] || 'hanzo'
}
function stripPort(h: string): string {
return h.replace(/:\d+$/, '')
}
function normalize(t: TenantConfig): TenantConfig {
return {
...t,
iamUrl: TRIM_TRAILING_SLASH(t.iamUrl),
iamIssuer: TRIM_TRAILING_SLASH(t.iamIssuer || t.iamUrl),
publicOrigin: TRIM_TRAILING_SLASH(t.publicOrigin),
brandUrl: TRIM_TRAILING_SLASH(t.brandUrl),
}
}
/** Parse the runtime catalog JSON safely; returns {} on any error. */
export function parseCatalog(raw: string | undefined | null): Record<string, Partial<TenantConfig>> {
if (!raw) return {}
try {
const parsed = JSON.parse(raw)
return parsed && typeof parsed === 'object' ? parsed : {}
} catch {
return {}
}
}
+52
View File
@@ -0,0 +1,52 @@
/**
* Per-tenant configuration resolved at runtime.
*
* One image, many hosts. The portal resolves a TenantConfig for each
* incoming request by hostname; the IAM backend, OAuth client id, and
* brand contract URL are all wired from this single object.
*
* NOTHING in this repo is brand-specific. Built-in defaults are derived
* from the hostname (e.g. `foo.id` → orgId=`foo`). Any non-derivable
* value (npm scope mismatch like `lux` → `@luxfi/brand`, custom clientId,
* etc.) is supplied at deploy time via the runtime catalog.
*/
export interface TenantConfig {
/** Tenant org slug (matches the JWT `owner` claim and the IAM `<org>-<app>` namespace). */
readonly orgId: string
/** IAM (OIDC) backend origin, no trailing slash. Defaults to same-origin. */
readonly iamUrl: string
/** Pinned OIDC issuer claim. Defaults to `https://<hostname>`. */
readonly iamIssuer: string
/** Default OAuth client_id (used when the request has no `?client_id=` param). */
readonly clientId: string
/** Underlying IAM application slug. */
readonly appName: string
/** Canonical public origin for the host. */
readonly publicOrigin: string
/** Absolute URL to the brand contract's `brand.json` (npm CDN, brand-owned host, anywhere). */
readonly brandUrl: string
}
/**
* Brand contract that any brand pkg MUST satisfy.
* Read at runtime from the URL specified by `TenantConfig.brandUrl`.
*/
export interface BrandContract {
/** Org display name shown in headings ("Hanzo", "Lux", "Zoo", "Pars", ...). */
readonly name: string
/** Browser tab title prefix. */
readonly title: string
/** Short tagline rendered on the portal hero. */
readonly description: string
/** Marketing site (footer link target). */
readonly appDomain: string
/** Logo + favicon URLs (absolute — CDN or data URI). */
readonly logoUrl: string
readonly faviconUrl: string
/** Primary accent (CSS color string, e.g. "#ff6b35" or "var(--brand)"). */
readonly accentColor?: string
/** Optional social links rendered in the footer. */
readonly twitter?: string
readonly github?: string
readonly discord?: string
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"noEmit": true
},
"include": ["src"]
}
+893 -2099
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
packages:
- "apps/*"
- "pkgs/*"
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
find . -type d \( -name node_modules -o -name dist -o -name .turbo -o -name .next \) -prune -exec rm -rf {} +
find . -type f -name 'tsconfig.tsbuildinfo' -delete
echo "clean done"
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"strict": true,
"noImplicitAny": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"useDefineForClassFields": true
}
}
-18
View File
@@ -1,18 +0,0 @@
#:schema node_modules/wrangler/config-schema.json
name = "hanzo-id"
compatibility_date = "2024-12-01"
compatibility_flags = ["nodejs_compat"]
pages_build_output_dir = ".vercel/output/static"
# All IAM login domains are configured as custom domains on this CF Pages project.
# The middleware resolves org-specific branding from the request hostname.
# DNS must be proxied through Cloudflare (orange cloud) for each domain.
#
# Custom domains (add via CF dashboard or `wrangler pages project ...`):
# hanzo.id, auth.hanzo.ai, lux.id, pars.id, zoo.id
# id.lux.network, id.zoo.network, iam.lux.network, id.ad.nexus
[vars]
# Default IAM backend — override per fork
IAM_ORIGIN = "https://iam.hanzo.ai"