Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ba148db2f | ||
|
|
197be5fed7 | ||
|
|
f75a9e954d | ||
|
|
818e4c20ee | ||
|
|
8e112b8a09 | ||
|
|
c473b3c851 | ||
|
|
2efb68dc30 | ||
|
|
25878f9d7a | ||
|
|
da10820a18 | ||
|
|
569b1139c5 | ||
|
|
839c5d2c65 | ||
|
|
93b64ae2d2 | ||
|
|
ffda55a2dc | ||
|
|
21be2fecd7 | ||
|
|
573d3cdde2 | ||
|
|
ccca48b5c2 | ||
|
|
5917e56a1e | ||
|
|
beb79dddca | ||
|
|
e7864887b8 | ||
|
|
313b27d537 | ||
|
|
6813e8a304 | ||
|
|
87ca9544a2 | ||
|
|
05237b0470 | ||
|
|
ac70eb41d1 | ||
|
|
18756a8ce2 | ||
|
|
73346553ce | ||
|
|
23950b9ea4 | ||
|
|
331675c945 | ||
|
|
5ab47aeb5d | ||
|
|
fea1660363 | ||
|
|
5f01fb90f4 | ||
|
|
0da7d5b33a | ||
|
|
787352b0eb | ||
|
|
7c6f148435 | ||
|
|
f2fc1e4539 | ||
|
|
c15300493c | ||
|
|
a39ebf4b4e | ||
|
|
36fb502b13 | ||
|
|
75ea9b6bae | ||
|
|
7183bb69c8 | ||
|
|
2fe32a731e | ||
|
|
1ac8c599b4 | ||
|
|
13413d41a6 | ||
|
|
470aa48408 | ||
|
|
bd04657638 | ||
|
|
793b9b37bc | ||
|
|
255c9622fb | ||
|
|
5d411ff26a | ||
|
|
c78eddd735 | ||
|
|
1e919446b6 | ||
|
|
66c6ebee63 | ||
|
|
ce9de2e9cb | ||
|
|
dddb1bb637 | ||
|
|
06a360e71e | ||
|
|
23c5b47eea | ||
|
|
c2db6cc171 | ||
|
|
9c17d55ebb | ||
|
|
b4b87be598 | ||
|
|
c89793f14d | ||
|
|
446a46a465 | ||
|
|
c9943fb883 | ||
|
|
b774772a92 | ||
|
|
9477777e6b | ||
|
|
e9cca37728 | ||
|
|
e22fef1f2a | ||
|
|
7a72225f58 | ||
|
|
0184f80346 | ||
|
|
3b067475d1 | ||
|
|
cdd9590e2a | ||
|
|
48acbaaf92 | ||
|
|
c8a14ce212 | ||
|
|
6b12efdde5 | ||
|
|
483b5e177c | ||
|
|
818a7d9079 | ||
|
|
d378b6d14b | ||
|
|
ad3e6daf48 | ||
|
|
c6d53f678c | ||
|
|
1c1cd9a803 | ||
|
|
8a556d0094 | ||
|
|
55c7d907de | ||
|
|
41d2409117 | ||
|
|
d05a53d3cb | ||
|
|
93b3524a42 | ||
|
|
8aee66d029 | ||
|
|
2d4e759dfb | ||
|
|
a7e917f348 | ||
|
|
6907c88383 | ||
|
|
bc890fd0d0 | ||
|
|
876be989fd | ||
|
|
8d2ad1891a | ||
|
|
1acb94ac65 | ||
|
|
2cd1979cb0 | ||
|
|
08a19b8dcd | ||
|
|
7bbfb77cd7 | ||
|
|
ffe5d2b9b2 | ||
|
|
4226ef57a7 | ||
|
|
e74c3ffdae | ||
|
|
403ff90b10 | ||
|
|
09ae6190b6 | ||
|
|
33cc55e837 | ||
|
|
dfe7eff66d | ||
|
|
cc7885c680 | ||
|
|
ec17532fb1 | ||
|
|
f2596c4788 | ||
|
|
aa10455c43 | ||
|
|
2b3f9330e7 | ||
|
|
45ffd21a85 | ||
|
|
9bfbb8add2 | ||
|
|
6fb61a4817 | ||
|
|
da19c5f208 | ||
|
|
7f609193ff | ||
|
|
a87b7500e5 | ||
|
|
bf734cf3cf | ||
|
|
23d0c0e7a8 | ||
|
|
5ace3df75c | ||
|
|
436931bb53 | ||
|
|
aee84762b9 | ||
|
|
2ceecf49a5 | ||
|
|
e9d3ae9d07 | ||
|
|
f5d2f13499 | ||
|
|
09239ed16c | ||
|
|
f8640af7bc | ||
|
|
b914f74144 | ||
|
|
f4e7c0d145 | ||
|
|
55d7d3f411 | ||
|
|
0a97192506 | ||
|
|
9cf009a8ac | ||
|
|
d928ecdfe3 | ||
|
|
47e477d599 | ||
|
|
87d2da9c74 | ||
|
|
9517f73345 | ||
|
|
218528569e | ||
|
|
4cc44563b8 | ||
|
|
6df3f160b8 | ||
|
|
373e00516e | ||
|
|
0c190b08e3 | ||
|
|
cdb83c111d | ||
|
|
33b46504b3 | ||
|
|
f807b6a11c | ||
|
|
7ce73662b2 | ||
|
|
583e406fa6 | ||
|
|
87ecfef2a2 | ||
|
|
e9ec7689a8 | ||
|
|
43f7d74249 | ||
|
|
6c1f661f17 | ||
|
|
1ea5fe9cdb | ||
|
|
154d0b3b7d | ||
|
|
8745f62bfd | ||
|
|
d89222ad27 | ||
|
|
db7acceca8 | ||
|
|
fe13917088 | ||
|
|
fbb5da8c70 | ||
|
|
23cac42399 | ||
|
|
0e10986cc6 | ||
|
|
0cf50f1f1b | ||
|
|
ddd5f4c409 | ||
|
|
e303575006 | ||
|
|
106818b9a1 | ||
|
|
5d29cf4a55 | ||
|
|
b879084af0 | ||
|
|
b425ce9979 | ||
|
|
be4ea25d2f | ||
|
|
29e85db41d | ||
|
|
d6af481d72 | ||
|
|
a22f3c9648 | ||
|
|
6c7e083100 | ||
|
|
f6b7b9327c | ||
|
|
dc3f888355 | ||
|
|
6e33166f13 | ||
|
|
7d91d22904 | ||
|
|
670f69f604 | ||
|
|
647bb88353 | ||
|
|
2365f83855 | ||
|
|
3262d9e47e | ||
|
|
2132c13e0c | ||
|
|
818ae385e5 | ||
|
|
5b9555ada6 | ||
|
|
704b6989a4 | ||
|
|
b0b0c1addf | ||
|
|
172b4ddf6d | ||
|
|
c0f11dcf28 | ||
|
|
f04eb29f28 | ||
|
|
70b1330df2 | ||
|
|
583e3454dc | ||
|
|
7a9ac97526 | ||
|
|
cff508e2f0 | ||
|
|
ef6263d666 | ||
|
|
98c78c7ad1 | ||
|
|
4c89defe95 | ||
|
|
fb06c01189 | ||
|
|
5dfbbad5c1 | ||
|
|
d43e700f41 | ||
|
|
be087c496a | ||
|
|
b7628eee35 | ||
|
|
7753b0ce78 | ||
|
|
8d7031cf3d | ||
|
|
30c7d0a8c4 | ||
|
|
9b54655329 | ||
|
|
17b98ae11c | ||
|
|
feca00917a | ||
|
|
43371d76f4 | ||
|
|
6678b9b9fd | ||
|
|
6748c62e22 | ||
|
|
7e359f279c | ||
|
|
981cce26e3 | ||
|
|
86c961bbaf | ||
|
|
fce00d8549 | ||
|
|
919d261d0a | ||
|
|
8b85781327 | ||
|
|
3d2001dab7 | ||
|
|
3bdccc0095 | ||
|
|
a8a6ad353d | ||
|
|
dddac31dc5 | ||
|
|
4578efbfc5 | ||
|
|
d4eaeabedb | ||
|
|
64de2b7441 | ||
|
|
b6748bf8f5 | ||
|
|
bc2106db08 | ||
|
|
0e2f42256f | ||
|
|
4d0f06ee34 | ||
|
|
3f2ceaf839 | ||
|
|
c525074995 | ||
|
|
ffdf3eab1f | ||
|
|
1d3b81bd92 | ||
|
|
8462c5a4c8 | ||
|
|
cc64e11712 | ||
|
|
d1810354f6 | ||
|
|
8f0ee95754 | ||
|
|
9655226fa6 | ||
|
|
9b3b5c8238 | ||
|
|
fbc3b1bf42 | ||
|
|
77f8d6e17d | ||
|
|
fff4b8f38f | ||
|
|
22f1eb3dd1 | ||
|
|
171ba8cb18 | ||
|
|
1cd915a4e9 | ||
|
|
7e8c9c4084 | ||
|
|
94d53d7f17 | ||
|
|
5d44b6a246 | ||
|
|
0d506cc23b | ||
|
|
fafbf9fe9d | ||
|
|
cdb960ffa9 | ||
|
|
6f1868e3cf | ||
|
|
b31552bb49 | ||
|
|
b29d16334c | ||
|
|
8a9485b7ba | ||
|
|
5fc7cbe72d | ||
|
|
ed2445da6d | ||
|
|
fa14c7e0ca | ||
|
|
66a21facf8 | ||
|
|
f6fa601c9d |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="640" viewBox="0 0 1280 640" role="img" aria-label="id">
|
||||
<rect width="1280" height="640" fill="#0A0A0A"/>
|
||||
<svg x="96" y="215" width="210" height="210" viewBox="0 0 67 67"><path d="M22.21 67V44.6369H0V67H22.21Z" fill="#fff"/><path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="#fff"/><path d="M22.21 0H0V22.3184H22.21V0Z" fill="#fff"/><path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="#fff"/><path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="#fff"/></svg>
|
||||
<text x="378" y="276" font-family="Inter,system-ui,-apple-system,sans-serif" font-size="78" font-weight="800" letter-spacing="-2" fill="#ffffff">id</text>
|
||||
<text x="378" y="322" font-family="Inter,system-ui,sans-serif" font-size="30" fill="#ffffff" opacity=".66">Hosted login pages for Hanzo IAM - configurable per organization</text>
|
||||
<rect x="378" y="338" width="806" height="3" rx="1.5" fill="#ffffff" opacity=".9"/>
|
||||
<text x="378" y="390" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">github.com/hanzoai</text>
|
||||
<text x="1184" y="390" text-anchor="end" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">hanzo.ai</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -1,59 +0,0 @@
|
||||
name: Docker
|
||||
# Self-contained build on Hanzo self-hosted runners. The shared reusable
|
||||
# workflow (hanzoai/.github docker-build.yml@main) is currently failing graph
|
||||
# validation for every caller (org-wide startup_failure), so this repo builds
|
||||
# its own image directly. Cluster nodes are linux/amd64 → build that arch.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main, dev, test]
|
||||
tags: ['v*']
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
jobs:
|
||||
docker:
|
||||
# Target the ARC scale set by NAME (the org convention, cf. hanzoai/iam).
|
||||
# A label array like [self-hosted, linux, amd64] matches the OFFLINE classic
|
||||
# `evo-*` runners instead of the live ephemeral scale set, so every run
|
||||
# queued forever. ARC scale-set jobs route on the exact scaleSetName.
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Compute tags
|
||||
id: tags
|
||||
run: |
|
||||
# The ARC runner image ships git + docker but NO node, so read the
|
||||
# version from package.json with portable shell (sed), not `node -p`.
|
||||
SHA="sha-$(git rev-parse --short HEAD)"
|
||||
VER="$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' package.json | head -1)"
|
||||
if [ -z "$VER" ]; then echo "could not parse version from package.json" >&2; exit 1; fi
|
||||
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "ver=$VER" >> "$GITHUB_OUTPUT"
|
||||
echo "Tags: $SHA, $VER"
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push (linux/amd64)
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/hanzoai/id:${{ steps.tags.outputs.sha }}
|
||||
ghcr.io/hanzoai/id:${{ steps.tags.outputs.ver }}
|
||||
cache-from: type=gha,scope=hanzoai-id
|
||||
# mode=min (not max): the ARC runner builds via DinD on a 32G node;
|
||||
# exporting every intermediate layer (mode=max) ballooned the DinD
|
||||
# store past the kubelet eviction threshold and killed builds with
|
||||
# "no space left". min caches only the final image layers.
|
||||
cache-to: type=gha,scope=hanzoai-id,mode=min
|
||||
@@ -1,7 +0,0 @@
|
||||
name: Workflow Sanity
|
||||
on:
|
||||
pull_request:
|
||||
paths: ['.github/workflows/**']
|
||||
jobs:
|
||||
sanity:
|
||||
uses: hanzoai/.github/.github/workflows/workflow-sanity.yml@main
|
||||
@@ -0,0 +1,50 @@
|
||||
name: CI/CD
|
||||
|
||||
# The caller, and deliberately nothing else: triggers plus the import. All real
|
||||
# config is the repo-root hanzo.yml, which platform.hanzo.ai reads too.
|
||||
#
|
||||
# `.hanzo/workflows`, NOT `.github/workflows`, and the difference is whether
|
||||
# anything runs at all:
|
||||
#
|
||||
# * github.com has ZERO self-hosted runners for this org
|
||||
# (/orgs/hanzoai/actions/runners -> total_count 0), so a job asking for
|
||||
# `hanzo-build-linux-amd64` there is never claimed. An unclaimable job does
|
||||
# not fail — it waits out the 24h timeout while the next push queues behind
|
||||
# it. Silence, not an error.
|
||||
# * The `git-runner` StatefulSet registers against the FORGE only
|
||||
# (GIT_INSTANCE_URL=http://hanzo-git.hanzo.svc), and that is the pool which
|
||||
# actually advertises that label.
|
||||
# * Gitea collects workflows from the FIRST of WORKFLOW_DIRS present in the
|
||||
# commit (modules/actions/workflows.go, listWorkflowsInDirs breaks on the
|
||||
# first hit). `.hanzo/workflows` already existed here, so on this forge the
|
||||
# whole of `.github/workflows` was already dark — including the Docker lane
|
||||
# that used to live there.
|
||||
#
|
||||
# The `uses:` path points at .hanzo/workflows for the same reason: reusables
|
||||
# resolve through services/actions.ResolveUses, which enforces the WORKFLOW_DIRS
|
||||
# allowlist on the referenced path too. hanzoai/ci publishes build.yml at both
|
||||
# paths from the same tag, byte-identical apart from the path each names for
|
||||
# itself, so this is the same pipeline at the same @v1.
|
||||
#
|
||||
# NO `paths-ignore`. deploy.yml carried one so that a docs commit would not trip
|
||||
# its "this version already exists" refusal — a guard this lane does not need,
|
||||
# because it derives the next patch instead of going red. Dropping the filter
|
||||
# means the GATES run on every commit to main, including the commits that only
|
||||
# touch a workflow. A change to CI that breaks CI should be caught by CI.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# A `v*` tag is a RELEASE and publishes an image named after it, verbatim.
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: cicd-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
cicd:
|
||||
uses: hanzoai/ci/.hanzo/workflows/build.yml@v1
|
||||
secrets: inherit
|
||||
@@ -0,0 +1,97 @@
|
||||
name: Sync from GitHub
|
||||
# git.hanzo.ai is CANONICAL and builds natively; development also lands on
|
||||
# github.com/hanzo-inc/id. That repo MOVED — it was hanzoai/id, and it is now
|
||||
# PRIVATE, which is why this job had been failing: GitHub answers 404 (not 403)
|
||||
# for a private repo a token cannot see, so the old URL produced
|
||||
# `remote: Repository not found` and the forge silently fell behind main.
|
||||
# A member's SSH key reads it fine, which is exactly how that stayed invisible
|
||||
# from a laptop. Together with the push-mirror going the other way
|
||||
# (native -> GitHub, sync_on_commit) this is the full bidirectional loop.
|
||||
#
|
||||
# The two compose rather than fight: a native commit reaches GitHub via the
|
||||
# push-mirror, so this job then sees LOCAL == REMOTE and exits "in sync". A
|
||||
# GitHub commit fast-forwards native here, and the resulting push-mirror is a
|
||||
# no-op because GitHub already has it. No echo, no loop.
|
||||
#
|
||||
# ONE deterministic direction per job: an in-cluster PULL. The runner reaches
|
||||
# both ends (GitHub outbound, this forge via the instance URL actions/checkout
|
||||
# already uses), so the sync has no ingress dependency.
|
||||
#
|
||||
# Fast-forward ONLY. A divergence fails LOUDLY here rather than force-pushing
|
||||
# either side and destroying whichever history lost the race.
|
||||
on:
|
||||
schedule:
|
||||
- cron: '*/10 * * * *'
|
||||
workflow_dispatch: {}
|
||||
concurrency:
|
||||
group: sync-from-github
|
||||
cancel-in-progress: false
|
||||
jobs:
|
||||
ff-main:
|
||||
runs-on: [hanzo-build-linux-amd64]
|
||||
steps:
|
||||
- name: Checkout main (full history for the ancestry check)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: true
|
||||
- name: Fast-forward main from github.com/hanzo-inc/id
|
||||
env:
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --quiet "https://x-access-token:${GH_PAT}@github.com/hanzo-inc/id.git" main
|
||||
LOCAL="$(git rev-parse HEAD)"
|
||||
REMOTE="$(git rev-parse FETCH_HEAD)"
|
||||
if [ "$LOCAL" = "$REMOTE" ]; then
|
||||
echo "in sync at $LOCAL"
|
||||
exit 0
|
||||
fi
|
||||
if git merge-base --is-ancestor "$LOCAL" "$REMOTE"; then
|
||||
echo "fast-forwarding $LOCAL -> $REMOTE"
|
||||
git push origin "$REMOTE:refs/heads/main"
|
||||
# A push made with the workflow token does NOT trigger other workflows
|
||||
# (loop prevention), so synced commits would never build. Dispatch it
|
||||
# explicitly — a real fast-forward means real commits arrived.
|
||||
#
|
||||
# This names the workflow BY FILENAME, so it is a hard reference to a
|
||||
# file in this repo and moves when that file does. It pointed at
|
||||
# deploy.yml, which no longer exists; the `|| echo` below makes that a
|
||||
# non-fatal 404, so the symptom would not have been a red sync — it
|
||||
# would have been commits arriving on the forge and NOTHING building,
|
||||
# silently, which is the exact failure this repo already survived once.
|
||||
curl -fsS --max-time 20 -X POST \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${{ github.server_url }}/v1/repos/${{ github.repository }}/actions/workflows/cicd.yml/dispatches" \
|
||||
-d '{"ref":"main"}' || echo "build dispatch failed (non-fatal)"
|
||||
elif git merge-base --is-ancestor "$REMOTE" "$LOCAL"; then
|
||||
# Native is AHEAD of GitHub. This is the NORMAL state for the whole
|
||||
# window between a native push and the push-mirror carrying it over,
|
||||
# and that window is long: the mirror is sync_on_commit with an 8h
|
||||
# floor, so it can legitimately last hours.
|
||||
#
|
||||
# It used to land in the `else` below, because that branch asked only
|
||||
# "is LOCAL an ancestor of REMOTE" and called every other answer a
|
||||
# divergence. So every native commit produced up to 48 consecutive
|
||||
# DIVERGED failures, ten minutes apart, describing a repository that
|
||||
# was in exactly the state this design intends. 11 of them were red on
|
||||
# the hour I read them, none of them a real divergence.
|
||||
#
|
||||
# The cost is not the noise, it is what the noise hides: a job that is
|
||||
# always red cannot report the one thing it exists to report. The loud
|
||||
# failure below is worth keeping ONLY if it is rare.
|
||||
#
|
||||
# There is nothing to pull here and nothing to fix. Do NOT push native
|
||||
# to GitHub from this job — that is the push-mirror's single direction,
|
||||
# and duplicating it is how two writers start racing one destination.
|
||||
echo "native is ahead at $LOCAL; GitHub at $REMOTE will follow via the push-mirror"
|
||||
exit 0
|
||||
else
|
||||
# Genuine divergence: neither ref contains the other, so a commit
|
||||
# exists on each side that the other has never seen. Nothing can be
|
||||
# fast-forwarded and either direction would destroy history.
|
||||
echo "DIVERGED: native $LOCAL and GitHub $REMOTE share no ancestry line." >&2
|
||||
echo "Resolve by hand; this job will not force-push either side." >&2
|
||||
exit 1
|
||||
fi
|
||||
+72
-4
@@ -5,18 +5,86 @@ WORKDIR /build
|
||||
ENV PNPM_HOME=/pnpm PATH=$PNPM_HOME:$PATH
|
||||
RUN corepack enable && corepack prepare pnpm@10.15.0 --activate
|
||||
|
||||
COPY pnpm-workspace.yaml package.json tsconfig.base.json ./
|
||||
# THE LOCKFILE SHIPS, and the install is frozen to it.
|
||||
#
|
||||
# This used to omit pnpm-lock.yaml and run `--frozen-lockfile=false`, so the
|
||||
# image resolved the whole tree FRESH on every build while `pnpm test` on the
|
||||
# runner resolved it from the lockfile. Two different dependency graphs from one
|
||||
# commit: the tested one, and the shipped one. It went green for as long as free
|
||||
# resolution happened to agree, and stopped the moment it did not — adding one
|
||||
# dependency (@hanzo/event) moved vite from the lockfile's
|
||||
# 7.3.5_@types+node@25.9.3_… to 7.3.6_@types+node@22.20.1 and the build died on
|
||||
# `Cannot find module '/build/apps/web/node_modules/vite/bin/vite.js'`. Nothing
|
||||
# was wrong with the source: the same commit builds cleanly when installed from
|
||||
# the lockfile.
|
||||
#
|
||||
# A resolver free to drift ships a bundle no one has run. Frozen, the image gets
|
||||
# the exact tree the tests passed against, and a lockfile that has gone stale
|
||||
# fails HERE — loudly, naming the mismatch — instead of silently building
|
||||
# something else.
|
||||
#
|
||||
# EVERY workspace member's package.json must be present before a frozen install:
|
||||
# pnpm validates the lockfile against all of them and refuses if one is missing.
|
||||
# apps/account is not built into this image, but it IS in the workspace, so its
|
||||
# manifest is required for the check to pass.
|
||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json ./
|
||||
COPY apps/web/package.json apps/web/
|
||||
COPY apps/account/package.json apps/account/
|
||||
COPY pkgs/shared/package.json pkgs/shared/
|
||||
COPY pkgs/auth/package.json pkgs/auth/
|
||||
COPY pkgs/connect/package.json pkgs/connect/
|
||||
COPY pkgs/idv/package.json pkgs/idv/
|
||||
COPY pkgs/onboarding/package.json pkgs/onboarding/
|
||||
RUN pnpm install --frozen-lockfile=false
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY apps apps
|
||||
COPY pkgs pkgs
|
||||
RUN pnpm --filter @hanzo/id-web build
|
||||
|
||||
# Publishable event-ingest key (pk-live-…), inlined by Vite into the bundle.
|
||||
#
|
||||
# PUBLISHABLE_KEY is the name in KMS (org `hanzo`, path `deploy`, env `prod`)
|
||||
# and on the --build-arg; the VITE_ prefix is what makes Vite inline it, and it
|
||||
# is a property of THIS build, so it is applied here and the secret store keeps
|
||||
# the ONE plain name.
|
||||
#
|
||||
# Publishable and write-only by design — it authorizes a write into one org and
|
||||
# can read nothing — so shipping it in a bundle is the documented use. It is
|
||||
# still a credential: it comes from KMS via CI. Never commit a value here.
|
||||
#
|
||||
# Deliberately NO default. An absent key is not a degraded mode: the door refuses
|
||||
# the beacon outright. Measured against the live endpoint with and without a
|
||||
# browser Origin, for pageviews AND exceptions alike: a hard 401
|
||||
# `ingest_key_required`. Nothing arrives.
|
||||
#
|
||||
# This comment used to say the unkeyed beacon took an "anonymous lane" that filed
|
||||
# rows under a `$public` tenant and answered 200. That lane is not implemented in
|
||||
# the deployed cloud, and the belief was expensive: it is why "we still get
|
||||
# errors, just not events" was accepted across four repos while the true answer
|
||||
# was that a keyless surface reports nothing at all. hanzo.id ran exactly that
|
||||
# way, which is the failure this gate exists to make loud.
|
||||
ARG PUBLISHABLE_KEY
|
||||
ENV VITE_PUBLISHABLE_KEY=$PUBLISHABLE_KEY
|
||||
# Fail closed, and gate HERE because this is the one path every builder passes
|
||||
# through — a guard in a workflow protects that lane only.
|
||||
RUN case "$PUBLISHABLE_KEY" in \
|
||||
pk-*) : ;; \
|
||||
'') echo "PUBLISHABLE_KEY is empty - pass --build-arg PUBLISHABLE_KEY=<pk-...> (KMS deploy/PUBLISHABLE_KEY, env prod)" >&2; exit 1 ;; \
|
||||
*) echo "PUBLISHABLE_KEY is not a publishable key (expected a pk- prefix)" >&2; exit 1 ;; \
|
||||
esac
|
||||
|
||||
# Do NOT re-declare ARG VITE_PUBLISHABLE_KEY below this line. A later ARG of the
|
||||
# same name shadows the ENV set above with an empty default, so the key resolves,
|
||||
# passes the gate, and is then blanked before Vite inlines it — every step green,
|
||||
# the bundle unattributed. That is exactly how hanzo.chat 1.0.58 shipped.
|
||||
#
|
||||
# `&&`, not `;`: with `;` the RUN exits with the status of the LAST command and a
|
||||
# failed build would be masked. Assert on the bytes that actually ship — a key
|
||||
# present in the environment and absent from the bundle is indistinguishable from
|
||||
# success everywhere except the warehouse, where the traffic simply stops being
|
||||
# attributable.
|
||||
RUN pnpm --filter @hanzo/id-web build && \
|
||||
{ grep -rqF "$VITE_PUBLISHABLE_KEY" apps/web/dist || \
|
||||
{ echo "ERROR: the ingest key is not in apps/web/dist - hanzo.id would ship unattributed" >&2; exit 1; }; }
|
||||
|
||||
# SPA server stage — hanzoai/spa is the correct base for a Vite SPA:
|
||||
# history-API fallthrough for client-side routes AND a SPA-safe CSP.
|
||||
@@ -24,6 +92,6 @@ RUN pnpm --filter @hanzo/id-web build
|
||||
# (built for static assets, not an app that loads its own bundle), which
|
||||
# blocks the SPA's own scripts and leaves a blank page. hanzoai/spa serves
|
||||
# index.html for all routes with a sane CSP. Defaults: PORT=3000, ROOT=/public.
|
||||
FROM ghcr.io/hanzoai/spa:1.2.0
|
||||
FROM ghcr.io/hanzoai/spa:1.4.11
|
||||
COPY --from=build /build/apps/web/dist /public
|
||||
EXPOSE 3000
|
||||
|
||||
@@ -1,14 +1,516 @@
|
||||
# LLM.md — Hanzo ID
|
||||
|
||||
## Mobile floors and a hover that stopped painting a wireframe (0.2.23)
|
||||
|
||||
A styling audit of iam.hanzo.ai. **The headline finding is a negative, and it is
|
||||
the useful part: this surface does NOT have the "classes without rules" defect**
|
||||
that broke hanzo.app, and structurally cannot. It uses neither Tamagui atomic nor
|
||||
Tailwind — it is hand-written semantic `.hanzo-id-*` CSS. Measured in Chromium
|
||||
against the served bundle, 7 routes x 3 viewports:
|
||||
|
||||
gui atomic (_bg- _dsp- _pos- _fs- _col- t_dark …) used 0 | rules 0
|
||||
tailwind (flex items-center rounded-lg text-sm …) used 0 | rules 0
|
||||
semantic .hanzo-id-* every rendered class resolves
|
||||
|
||||
The only classes with no rule are five page-variant markers
|
||||
(`hanzo-id-login/-signup/-forgot/-onboarding-page/-callback`), each always paired
|
||||
with `.hanzo-id-page`, which carries the layout. They are BEM-style hooks with
|
||||
nothing to paint yet, not a defect — `.hanzo-id-device` and `.hanzo-id-portal`
|
||||
are the same pattern with rules attached. Left alone deliberately.
|
||||
|
||||
**An audit claim that was wrong, corrected here so nobody re-fixes it.** It was
|
||||
reported that a build with `@hanzo/design` absent "emits a bare unresolved
|
||||
@import and every token vanishes with no error" — i.e. the hanzo.app failure
|
||||
class, one `pnpm install` away. It does not. Removing the package and building
|
||||
was tried: Vite's postcss-import **hard-fails**,
|
||||
`[vite:css] [postcss] ENOENT: no such file or directory, open
|
||||
'@hanzo/design/styles.css'`, exit 1. The token layer cannot silently disappear
|
||||
from this build. What was actually true is narrower: the dev tree had not been
|
||||
installed, so `tokens.test.ts` could not run. It passes once deps are present.
|
||||
|
||||
Four real defects fixed, all in `apps/web/src/app.css`, all verified by
|
||||
before/after measurement of the BUILT bundle in a real browser (not by a green
|
||||
build — a green build is what let the original defect ship):
|
||||
|
||||
- **Ghost-button hover painted a near-white wireframe.** It set
|
||||
`border-color: var(--foreground)` — #ededed, **17.9:1** on `--background` — on
|
||||
the two most-hovered controls on the page (Continue with GitHub / Google).
|
||||
Measured rest→hover: `rgb(115,115,115)` → `rgb(237,237,237)`. Now
|
||||
`rgb(115,115,115)` → `rgb(115,115,115)`: the edge no longer moves and the
|
||||
SURFACE carries the state (`--white-05` → `--white-10`), which is what the
|
||||
`transition` on `.hanzo-id-btn` was already animating.
|
||||
**Why not simply a dimmer border:** every rung brighter than `--neutral-500` is
|
||||
worse on white. `--neutral-400` measures 8.3:1 on black but **2.52:1 on white**
|
||||
and would fail the same WCAG 1.4.11 floor the resting border is documented to
|
||||
hold in BOTH themes. No token gets brighter on dark and darker on light, so the
|
||||
edge must not encode state at all. The resting `--border-strong` (4.43:1, both
|
||||
themes) is untouched — that deviation from a low-alpha hairline is deliberate
|
||||
and documented, and a control boundary still needs 3:1.
|
||||
- **`viewport-fit=cover` was declared and never consumed.** Zero
|
||||
`env(safe-area-inset-*)` rules in the whole stylesheet; `.hanzo-id-page` padded
|
||||
a flat 24px, so content ran under the notch and home indicator. Now
|
||||
`max(24px, env(...))` per side — 0 insets compute to exactly 24px, so nothing
|
||||
moves on hardware without a notch (verified: page padding still `24px`).
|
||||
- **The iOS-zoom comment described a protection the code did not implement.**
|
||||
`font-size: var(--text-base) /* <16px makes iOS Safari zoom on focus */` —
|
||||
`--text-base` is 0.875rem = **14px**, so both credential fields zoomed on focus
|
||||
and iOS never zooms back out. The scale has no 16px rung and should not grow
|
||||
one (16 is Safari's threshold, not a design value), so it is a literal scoped to
|
||||
`@media (pointer: coarse)`; the desktop ramp is untouched. Verified under iPhone
|
||||
emulation: input 14px→16px, `iosWouldZoom` true→false.
|
||||
- **Three tap targets under the 44px floor**, including a logo link whose target
|
||||
was SMALLER than the logo inside it: an inline `<a>` takes its box from its own
|
||||
line box, not from a replaced child, so it measured **32x18** around a 32px
|
||||
mark. Fixed with `inline-flex` + a padding/negative-margin pair; the footer
|
||||
links ("Forgot password?", "Create account", "Sign in", "Back to sign in") got
|
||||
the same treatment — vertical padding on an inline box is hit-tested but does
|
||||
not enter line-box height, so all of it is **zero layout shift**. Screenshots
|
||||
before/after are pixel-identical at rest; only the targets and the hover moved.
|
||||
|
||||
Measured, BEFORE → AFTER, built bundle served like prod, 7 routes x 390/768/1280:
|
||||
|
||||
tap targets < 44px 3 → 0 (iPhone-emulated, pointer:coarse)
|
||||
ghost hover border #ededed → #737373 (17.9:1 → 4.43:1)
|
||||
safe-area rules in CSSOM 0 → 1
|
||||
@media rules 1 → 2 (added pointer:coarse)
|
||||
input font-size (touch) 14px → 16px
|
||||
horizontal overflow 0 → 0 (unchanged, all routes)
|
||||
console errors / pageerrors 0 → 0
|
||||
atomic + tailwind used|rules 0|0 → 0|0 (no regression)
|
||||
CSS transferred 16,733 → 17,070 bytes (+337, +2.0%)
|
||||
JS transferred 884,469 → 884,469 (unchanged)
|
||||
|
||||
**Still open, deliberately not taken here** (each is a finding against a layer
|
||||
below this repo, and one is already recorded above under 0.2.15):
|
||||
|
||||
- `BrandHeader` loads the mark from `cdn.jsdelivr.net/npm/@<brand>/brand@latest/
|
||||
assets/logo/logo.svg` — a third-party request pinned to a floating tag on the
|
||||
credential-entry path. The `logoUrl` comes from `@hanzo/brand`'s own
|
||||
`brand.json`, so it cannot be fixed here; the asset already exists locally at
|
||||
`apps/web/public/brand/@hanzo/brand/assets/logo/logo.svg`. Note the catalog's
|
||||
`brandUrl` is NOT fetched — `brandPackageFromUrl` only parses it for the npm
|
||||
scope, and the brand JSON itself is same-origin `/brand/<slug>.json`.
|
||||
- 939 KB of decoded JS to paint a two-field form; the eager bundle still carries
|
||||
WalletConnect/viem that the email+GitHub+Google path never uses. Code splitting
|
||||
works (ccip/login/secp256k1 chunks are correctly not fetched on `/`).
|
||||
- The 70 KB Geist woff2 is discovered only after CSS parse, with no
|
||||
`<link rel=preload>`; the filename is content-hashed, so a correct preload
|
||||
needs `transformIndexHtml` to read the real name out of the bundle.
|
||||
- `pkgs/shared/src/org.test.ts` "oauthCallbackOrigin is the org's hosted ID host"
|
||||
FAILS on origin/main (`hanzo.app` vs `hanzo.chat`), pre-existing and unrelated
|
||||
to this change — verified by running it on a pristine checkout. It belongs to
|
||||
the in-flight callback work; not touched.
|
||||
|
||||
## The whole token layer, the ONE account control, and a gate on resolution (0.2.15)
|
||||
|
||||
0.2.14 adopted @hanzo/design and, in three places, worked around it. Those
|
||||
findings have been fixed IN @hanzo/design 0.3.0, so the workarounds are gone and
|
||||
this surface takes the system's answer.
|
||||
|
||||
- **ONE import: `@hanzo/design/styles.css`.** `app.css` cherry-picked four of the
|
||||
nine token groups, so `--z-*`, `--shadow-*`, `--space-*`, `--font-*` and the
|
||||
element defaults did not exist here at all. Nothing broke visibly, because an
|
||||
unresolved `var()` paints nothing and reports no error — that silence is the
|
||||
whole defect. @hanzo/iam's account menu alone reaches for `--z-popover`,
|
||||
`--shadow-floating` and `--space-1..3`.
|
||||
- **Geist is SELF-HOSTED in @hanzo/design 0.3.0**, so the reason for
|
||||
cherry-picking is gone: `tokens/fonts.css` no longer requests
|
||||
fonts.googleapis.com and the sign-in path can take the typeface with the
|
||||
colours. Measured on the built bundle: rendered face is Geist, served from
|
||||
`/assets/Geist-Variable-*.woff2`, zero third-party font requests.
|
||||
- **The focus rule is DELETED from this file**, and it stays deleted.
|
||||
`tokens/base.css` ships `:focus-visible{outline:2px solid var(--ring)}` to
|
||||
every consumer; `--ring` is `var(--white-40)` in the 0.4.x line, measured
|
||||
`2px solid rgba(255,255,255,.4)` = **3.77:1** on `--background` (WCAG 2.4.11
|
||||
wants 3:1). Do not restore a local override — the last one existed only
|
||||
because `--ring` was 1.66:1, which is fixed upstream.
|
||||
|
||||
Until **@hanzo/design 0.4.9** that layer carried a SECOND rule, a
|
||||
field-specific `:where(input,select,textarea):focus-visible` that suppressed
|
||||
the outline and drew a brightened edge + halo instead. Both rules computed to
|
||||
(0,1,0) — `:where()` zeroes what it wraps — so the cascade fell through to
|
||||
source order, the generic ring was written later, and it overrode the
|
||||
`outline:none` the field rule stated to prevent it: every focused input here
|
||||
drew BOTH indicators. Worse, the treatment it was trying to apply could never
|
||||
have worked on this surface — its indicator rode on `border-color`, and
|
||||
`.hanzo-id-input` states `border` unlayered, which beats a layer whatever its
|
||||
specificity, so focused fields sat at the resting `.15` (1.47:1) while both
|
||||
stylesheets read as correct. 0.4.9 deleted the field rule; one ring now covers
|
||||
every focusable thing, and an `outline` is immune to the `border` this file
|
||||
declares. `check-tokens.mjs` fails the build if a second focus rule, an
|
||||
`outline:none` or a focus `box-shadow` ever returns.
|
||||
- **Control boundaries are `--border-control`** (0.4.2 onward; this bullet read
|
||||
`--border-strong` when 0.3.0 briefly cut the control rungs from the neutral
|
||||
ladder). 0.4.2 put them back on alpha — `--border-control` .15, `--border-focus`
|
||||
.22, `--border-selected` .30 — because an edge that clears 3:1 on a near-black
|
||||
page is a mid-grey box, and a form of them reads as a wireframe rather than a
|
||||
surface. The contrast budget moved to `--ring`, the focus indicator: the one
|
||||
boundary a keyboard user actually navigates by, and the only one still pinned
|
||||
at 3:1. `--border-strong` survives, but its duty is hover and emphasis — it is
|
||||
decoration, and a control must not reach for it.
|
||||
- **The signed-in portal mounts `<UserMenu>` from @hanzo/iam** (bumped
|
||||
0.13.1 → 0.21.1 in `apps/web`, `pkgs/auth`, `pkgs/onboarding`) in place of a
|
||||
hand-rolled "Billing / Sign out" link row. Identity comes from
|
||||
`resolveIdentity`, the same resolution every Hanzo surface shows, so the portal
|
||||
cannot disagree with the console about who you are. No `brand` prop is passed:
|
||||
omitting `markSvg` would put the HANZO mark on lux.id and zoo.id.
|
||||
- **Portal.tsx's inline `style={{…}}` is gone.** It carried
|
||||
`rgba(255,255,255,0.14)`, `borderRadius 12`, `fontSize 13`, `fontWeight 600`
|
||||
and two bare opacities — six invented values for facts the token layer already
|
||||
states. `.hanzo-id-apps` / `.hanzo-id-applink` now, class-keyed like the rest.
|
||||
- **`apps/web/src/tokens.test.ts` is the gate, and it tests RESOLUTION.**
|
||||
"It is declared" was never evidence, and neither was "it type-checks": the
|
||||
reference is built at runtime from a string, so it is invisible to the compiler
|
||||
AND to grep. The test walks `app.css`'s `@import` graph into the installed
|
||||
@hanzo/design, then asserts (1) every token group is served, (2) every
|
||||
`var(--x)` under `src/` resolves, (3) every token @hanzo/iam's bundle paints
|
||||
the menu with resolves. Verified by reintroducing both original defects: it
|
||||
names the 5 dropped groups and the 8 unresolved iam tokens. `vitest.config.ts`
|
||||
now includes `apps/**` so it actually runs.
|
||||
|
||||
Measured in Chromium, fresh context, empty storage, 1440 AND 390, against the
|
||||
built bundle served as hanzo.id / lux.id / zoolabs.id: **0 unresolved tokens of
|
||||
76 referenced** on every host; input edge and focus ring both 4.43:1; the account
|
||||
menu paints `#0a0a0a` fill / 12px radius / `--shadow-floating` / `z-index 700`
|
||||
(it was transparent before the iam fix). Brand switches with zero per-brand code:
|
||||
hanzo.id → "Hanzo ID" + Hanzo apps, lux.id → "Lux ID" + Lux apps, and no Hanzo
|
||||
mark reaches the Lux menu.
|
||||
|
||||
STILL OPEN, and they are findings against layers below this surface, not against
|
||||
this repo: the menu's own panel edge is `--border` at 1.27:1 on `--popover` —
|
||||
0.4.2 recut the control and focus rungs but left `--border` on the .10 rung, so a
|
||||
floating panel still has no perceivable edge. And `BrandHeader` loads the brand
|
||||
mark from `cdn.jsdelivr.net/npm/@<brand>/brand@latest/...` — a third-party
|
||||
request pinned to `@latest` on the sign-in path, which is exactly what this file
|
||||
refuses for fonts.
|
||||
|
||||
## One token layer, and no control painted by its ancestor (0.2.14)
|
||||
|
||||
The portal is now styled from **@hanzo/design tokens** — the same token layer
|
||||
hanzoai/pay renders from, so the two halves of one flow (sign in → pay) agree on
|
||||
the page black, the type ramp, the radii and the greys. `apps/web/src/app.css`
|
||||
invents no colour, radius or size; `@hanzo/gui` was declared as a dependency but
|
||||
never imported once, and is removed rather than left as decoration.
|
||||
|
||||
- **Surface is keyed to a CLASS the component carries, never to where it is
|
||||
mounted.** The stylesheet used to paint controls with the descendant selectors
|
||||
`form input {…}` and `.hanzo-id-btn, form button {…}`. `DeviceApproval.tsx` —
|
||||
the screen a human hits to authorise the CLI — has 2 inputs and **0** `<form>`
|
||||
ancestors, so its device-code field fell out of the stylesheet and rendered as
|
||||
raw UA chrome: 31px tall, `#3b3b3b`, `2px inset` bevel, square corners, beside
|
||||
correctly-styled 44px siblings. Every control now carries `.hanzo-id-input`,
|
||||
`.hanzo-id-btn`, `.hanzo-id-field` or `.hanzo-id-form`, and there is not one
|
||||
element-descendant selector for surface left in the file. Same class of defect
|
||||
as a component library shipping utility class names with no CSS behind them —
|
||||
the mirror image, bare elements instead of bare class names.
|
||||
- **`.hanzo-id-spinner` had no rule at all.** The loading state measured 0px and
|
||||
was invisible on hanzo.id, lux.id and pars.id at once. It is a real 28px ring
|
||||
now, verified rendering and animating.
|
||||
- **ONE focus indicator.** The file had exactly one focus rule (`form
|
||||
input:focus`), so every button, link and social entry fell back to Chrome's
|
||||
`outline: auto` — a blue ring on a monochrome surface. `:focus-visible` is now
|
||||
global, 2px `--primary`. Note `--ring` (#333333) measures **1.66:1** on
|
||||
`--background` and cannot carry a focus indicator; that is a finding against
|
||||
@hanzo/design, not a licence to invent a value.
|
||||
- **ONE button.** `.hanzo-id-social-btn` and the `.primary` modifier are gone:
|
||||
`.hanzo-id-btn` IS primary, `.ghost` is the secondary surface (social sign-in,
|
||||
Skip/Back). 13 treatments → 1 primitive with 1 modifier.
|
||||
- **`font: inherit` on every control.** Buttons and inputs rendered in Arial
|
||||
while headings rendered in the platform face — two typefaces in one 432px card,
|
||||
including on the "Sign in" CTA.
|
||||
- **Control borders are `--white-40`, deliberately.** On `--background` the
|
||||
semantic `--border` (#1f1f1f) measures 1.27:1 and `--border-strong` (#404040)
|
||||
2.03:1 — neither clears the 3:1 a control boundary needs (WCAG 1.4.11).
|
||||
`--white-40` measures 3.66:1 and is on the ladder.
|
||||
- **Fonts are NOT imported from the design package.** `tokens/fonts.css` pulls
|
||||
Geist from fonts.googleapis.com; the sign-in path loads no third-party font.
|
||||
`--font-sans` resolves to the platform stack, the identical value hanzoai/pay
|
||||
sets. Self-hosting Geist would let both import `fonts.css` unchanged.
|
||||
|
||||
Measured in a real browser against the built bundle (fresh context, empty
|
||||
storage): page `#000000`, controls 44px, one 6px radius, h1 21px, white focus
|
||||
ring, no control under the touch floor at 390px, no horizontal overflow.
|
||||
|
||||
**This release also reunites `main` with production.** The running image (0.2.13,
|
||||
built from `9477777`) was NOT on `origin/main`: the two had diverged at
|
||||
`7a72225f`, with the self-service-signup fix (`/v1/iam/onboard` instead of the
|
||||
admin-only `add-organization` verb) and the App/Chat/Cloud launcher live but
|
||||
unmerged. A release cut from `main` would have silently regressed both. 0.2.14 is
|
||||
the merge, so `main` is once again what runs.
|
||||
|
||||
## One chain-agnostic "Connect Wallet" button — merge EVM + Solana entries (0.2.9)
|
||||
|
||||
The login page rendered ONE wallet button PER enabled chain (`SocialButtons`
|
||||
mapped `ENABLED_WALLET_CHAINS` → "Continue with Ethereum / EVM" +
|
||||
"Continue with Solana"), so a two-chain build showed two near-identical buttons
|
||||
above the divider. Merged into a SINGLE chain-agnostic "Connect Wallet" entry —
|
||||
the ENTRY is merged, both underlying flows are kept intact. (Rides on 0.2.8:
|
||||
GitLab provider + vitest-unified test runner.)
|
||||
|
||||
- **Detection is a pure function, in the ONE web3 module.** `detectWalletChains()`
|
||||
(`pkgs/auth/src/web3.ts`) is a pure `window` sniff — EVM = `window.ethereum`,
|
||||
Solana = `window.solana`/`solflare`/`backpack` — that returns the
|
||||
`ENABLED_WALLET_CHAINS` with an injected provider. Derived from the enabled
|
||||
set (one source of truth); testable via an injectable window (no DOM, no
|
||||
connect, no I/O). Exported alongside `loginWithWalletChain`.
|
||||
- **`SocialButtons` renders one entry.** The web3 provider now renders a single
|
||||
"Connect Wallet" button (`data-wallet-connect`). On click, `onConnectWallet`
|
||||
calls `detectWalletChains()`: exactly one injected chain → connect straight
|
||||
(`startWallet(chain)`, no chooser); zero or many → reveal an inline chooser
|
||||
(`.hanzo-id-wallet-chains`) of one button per enabled chain
|
||||
(`data-chain=evm|solana`) so EITHER chain stays reachable. Same monochrome
|
||||
`hanzo-id-social-btn` style as GitHub/GitLab/Google; the chooser is indented
|
||||
under the entry. The per-chain path (`startWallet` → `loginWithWalletChain`)
|
||||
is UNCHANGED — only the button that reaches it is merged.
|
||||
- **Verified.** Auth unit tests green (3 new `detectWalletChains` cases:
|
||||
single→[chain], both→[evm,solana], none→[]). Playwright against the dev SPA
|
||||
(get-app-login intercepted so web3 resolves): one "Connect Wallet" renders;
|
||||
click with no injected wallet → chooser shows Ethereum/EVM + Solana; click
|
||||
with an injected `window.ethereum` → NO chooser, straight into the EVM flow.
|
||||
- **Backend untouched.** Pure `id` SPA change (frontend), no IAM edit.
|
||||
Ships as `ghcr.io/hanzoai/id:0.2.9`; deploy = bump the operator CR image
|
||||
(`universe/infra/k8s/operator/crs/id.yaml`) by hand (id not in the
|
||||
gitops-reconcile allowlist). NEVER restart ingress (TLS-outage hazard).
|
||||
|
||||
## The tenant catalog was never read — one key name, every host (0.2.21)
|
||||
|
||||
`/config.json` is served by `hanzoai/spa`, which derives its JSON key
|
||||
**mechanically from the env var we supply**: `SPA_IAM_TENANT_CONFIG_JSON`
|
||||
(ConfigMap `id-tenant-catalog`) becomes `iamTenantConfigJson`. The
|
||||
`TenantConfig`→`OrgConfig` rename swept through this codebase and renamed the
|
||||
READ too — `cfg.iamOrgConfigJson`, a key nothing emits. The fetch returned 200,
|
||||
the field was `undefined`, `resolveOrg` fell back, and nothing logged. Verified
|
||||
live: `https://hanzo.id/config.json` returns `iamTenantConfigJson`, and the SPA
|
||||
was reading straight past it.
|
||||
|
||||
The env var is the interface and it is NOT ours to rename unilaterally — the
|
||||
ConfigMap, the Deployment's `envFrom` and the spa image all speak it. Code
|
||||
follows the interface, so the read moved to `catalogOf` in `pkgs/shared/src/
|
||||
org.ts`, next to the resolver it feeds, pinned by tests that fail on the old
|
||||
spelling.
|
||||
|
||||
Two live consequences, and the first is why this shipped with 0.2.20:
|
||||
|
||||
- **hanzo.id authenticated as the wrong app.** The catalog maps it to
|
||||
`hanzo-console` (`enableSignUp: true`); with no catalog it fell to the built-in
|
||||
`hanzo-id` (**false**). Federation PROVISIONS a local user for a new identity,
|
||||
so a first-time GitHub user was refused "the application does not allow to sign
|
||||
up new account" — the 0.2.20 fix working perfectly and still failing the exact
|
||||
people it was for. `enableSignUp` itself needed no change; reading the catalog
|
||||
did.
|
||||
- **Every catalog-ONLY host resolved nothing.** osage.id, zoolabs.id,
|
||||
id.zoo.network, id.lux.network, iam.lux.network, id.pars.network, id.bootno.de
|
||||
and iam.hanzo.ai have no built-in entry, so they got `hostSkeleton` — empty
|
||||
`clientId`, no application. That the resolver fails CLOSED there is why it leaked
|
||||
no brand (the osage.id regression stayed fixed); it is also why it was silent.
|
||||
|
||||
Declared state was corrected to match: `hanzo-console` now lists
|
||||
`https://hanzo.id/callback` in `init_data.json`. The live row already had it, and
|
||||
`seed.go` deliberately keeps `redirectUris` off `appPolicyKeys` ("the registration
|
||||
surface, which drifts legitimately and is owned elsewhere") so nothing reverts —
|
||||
but redirectUris ARE applied on CREATE, and from 0.2.21 hanzo.id authenticates as
|
||||
`hanzo-console` against that exact callback. Without the line, a REBUILT cluster
|
||||
would come up unable to sign anyone in at hanzo.id.
|
||||
|
||||
The shape to remember: a rename is not finished at the language boundary. This
|
||||
one crossed into an env var, a ConfigMap key and a base image's templating
|
||||
convention, none of which the compiler or the type system can see — and the
|
||||
failure mode was a successful fetch of a field that wasn't there.
|
||||
|
||||
**Follow-up, deliberately not taken here: `oauthCallbackOrigin` is now dead.**
|
||||
Nothing reads it. It existed so the browser-built hop could target the shared
|
||||
`iam.hanzo.ai/callback` OAuth client; with federation, IAM pins its own callback
|
||||
from the TRUSTED request host (`federationBaseURL` → `resolveIssuer(c.Host())`),
|
||||
so the browser never chooses a callback origin. What survives is the field in
|
||||
`pkgs/shared/src/types.ts`, its passthrough in `org.ts`, two test fixtures, and
|
||||
an entry on every catalog row in `universe/infra/k8s/id/configmap.yaml`. Its doc
|
||||
comment still states the DELETED rule — "the provider hop MUST send
|
||||
`redirect_uri=<oauthCallbackOrigin>/callback`" — which is exactly the sentence
|
||||
that would talk the next reader into rebuilding the hop. Delete the field with
|
||||
the next code change to this package; it was left alone today only because
|
||||
touching it cuts another release of a live auth surface for zero behaviour
|
||||
change, and the build refuses a commit that does not bump the version.
|
||||
|
||||
## Social sign-in goes through IAM, because it always did (0.2.20)
|
||||
|
||||
GitHub sign-in reached GitHub, succeeded there, and then dead-ended: the user
|
||||
landed back on hanzo.id and was told to sign in first. That message is IAM's
|
||||
`please sign in first` — a 401 from `/v1/iam/onboard`, reached with no session
|
||||
and no bearer. The SPA had never obtained a token.
|
||||
|
||||
**The SPA was the relying party, and it cannot be one.** `social.ts` built the
|
||||
IdP URL in the browser (`github.com/login/oauth/authorize`, `redirect_uri=
|
||||
${origin}/callback`) against a contract copied from an IAM fork whose front end
|
||||
no longer exists. GitHub then returned a GitHub code to the SPA's own
|
||||
`/callback` — and **nothing can spend that code**: exchanging it needs the client
|
||||
SECRET, which a browser must never hold, and IAM has no endpoint that takes a raw
|
||||
provider code. `Callback.tsx` POSTed it to `/v1/iam/login`, which does password,
|
||||
device approval and code minting, and knows nothing about providers. So every
|
||||
social sign-in ended authenticated at GitHub and anonymous here.
|
||||
|
||||
**IAM already implements the whole flow and was never called.**
|
||||
`internal/oidc/federation.go` is a complete OAuth2/OIDC relying party. Naming a
|
||||
`provider` on the authorize endpoint IS the entry point: `authorizeHandler`
|
||||
validates client_id, the EXACT redirect_uri and the PKCE policy, then
|
||||
`beginFederation` resolves the provider, mints a single-use transaction, sets a
|
||||
browser-binding cookie and sends the browser to the IdP. The IdP returns to
|
||||
**IAM's** fixed callback, `/v1/iam/oauth/callback`, where IAM — holding the
|
||||
secret — exchanges the code, links or provisions the user, and mints an IAM
|
||||
authorization code bound to the original PKCE challenge, redirect_uri and nonce.
|
||||
`OAuthAuthorizeRequest.provider` had been declared in the SPA's own types the
|
||||
whole time; `authorize()` simply never emitted it. The fix is that one parameter,
|
||||
plus deleting everything that existed to work around its absence.
|
||||
|
||||
**`provider` is the RECORD name — `provider-github`, never `github`.**
|
||||
`federationProvider` matches `ProviderItem.Name` exactly, and `EnrichProviders`
|
||||
resolves that same name to the record, so the two are one string by construction.
|
||||
Verified live: `?provider=github` → `invalid_request: unknown or unavailable
|
||||
provider`; `?provider=provider-github` → 302 to GitHub. A comment on
|
||||
`providerKey` used to assert the opposite; it is now corrected in place. The bare
|
||||
key is a DISPLAY key (icon, label, `provider_hint` matching) and nothing else.
|
||||
|
||||
**Two arms, and they are the two the password path already had.** The question is
|
||||
only who owns the PKCE verifier (`SocialButtons.hop`):
|
||||
|
||||
- **An app sent the user here** (`redirect_uri` on the query) → re-enter authorize
|
||||
with THAT app's request via `social.ts::authorizeRequest`, so IAM mints the code
|
||||
against its client_id, redirect_uri and challenge and returns the browser
|
||||
straight to it. The app holds the verifier; this portal is never in the return
|
||||
path and never touches a token. Same branch as `Login.completeAfterAuth`.
|
||||
- **A bare portal sign-in** → `createIam(...).signinRedirect({additionalParams:
|
||||
{provider}})`. The SDK generates and persists the verifier in **localStorage**
|
||||
(`txStorage`, keyed `hanzo_iam_code_verifier:<state>` — localStorage, not
|
||||
session, deliberately, so it survives the full-page redirect), and
|
||||
`handleCallback` reads back that exact slot. IAM returns the APP's state
|
||||
(`federationMint` sets `state` from `AppState`, not its IdP-leg state), so the
|
||||
SDK's state check matches.
|
||||
|
||||
Deleted, all of it dead once the browser stops being the relying party: the IdP
|
||||
endpoint/scope table, `buildProviderAuthUrl`, `startProviderLogin`,
|
||||
`isHoppableProvider`, `encodeState`/`decodeState`, `client.providerLogin`,
|
||||
`ProviderExchangeRequest`, and `Callback.tsx`'s provider branch. `/callback` now
|
||||
has ONE case — an ordinary IAM code — because a federated return is
|
||||
indistinguishable from any other.
|
||||
|
||||
Verified in a real browser against live IAM (local bundle, `/config.json`
|
||||
pointing `localhost` at `https://hanzo.id`; port 5173 because
|
||||
`http://localhost:5173/callback` is registered on `hanzo-id`):
|
||||
|
||||
- bare-portal click → `hanzo.id/v1/iam/oauth/authorize?…&provider=provider-github`
|
||||
→ 302 → GitHub, with `redirect_uri=https://hanzo.id/v1/iam/oauth/callback` and
|
||||
the `hanzo_fed` cookie (`SameSite=Lax`, `path=/v1/iam/oauth/callback`, 600s);
|
||||
a verifier slot appears keyed by state.
|
||||
- app-initiated click → same, and NO verifier slot is created — proof the app's
|
||||
own request was forwarded rather than the portal starting its own flow.
|
||||
- return leg → `/callback?code=…&state=<stored>` consumes the slot and POSTs
|
||||
`/v1/iam/oauth/token`, which answers `invalid_grant: invalid authorization
|
||||
code` for a deliberately fake code. The exchange is wired; only the code was
|
||||
false.
|
||||
|
||||
Not exercisable here: the GitHub login itself. See the registration note under
|
||||
"Social providers" below — GitHub defers redirect_uri validation until after
|
||||
sign-in, so an unauthenticated probe CANNOT tell a registered callback from an
|
||||
unregistered one (a deliberately bogus URL returns the identical 302).
|
||||
|
||||
## Provider-hint auto-federation — click GitHub/Google downstream, land straight in the provider (0.2.6)
|
||||
|
||||
Clicking "Continue with GitHub/Google" on a downstream app (console.hanzo.ai)
|
||||
used to bounce the user to the hanzo.id login FORM — the portal ignored the
|
||||
provider the user already chose. Now it launches that provider immediately.
|
||||
Three fixes. Points 1 and 2 still hold; point 3 and every mention of the "hop"
|
||||
below are **superseded by 0.2.20** — the hop is gone, the auto-launch now starts
|
||||
IAM's federation like the button does, and `method` is not a parameter of it
|
||||
(IAM's federation callback links-or-provisions on its own).
|
||||
|
||||
1. **`Login.tsx` honors `provider_hint`.** The console SDK already appends
|
||||
`&provider_hint=provider-github` (the IAM record `name`) to the authorize
|
||||
redirect. A new `federate` phase: after a silent-SSO miss, if the hint is
|
||||
present it renders a HEADLESS `<SocialButtons autoStart={hint}>` that
|
||||
auto-runs the same hop the button runs — no form flash — and drops to the
|
||||
form only if the hint matches no configured provider. Honor ONLY
|
||||
`provider_hint`, never bare `provider=` (that carries the SSO SDK's
|
||||
`<org>-iam` IDP hint — see the single-provider-state note below).
|
||||
`matchProviderHint` (`social.ts`, pure + tested) maps the hint to a provider
|
||||
by record name / key (`provider-github` or `github`, case-insensitive).
|
||||
|
||||
2. **`getAppLogin` validates against the DOWNSTREAM app's own redirect_uri.**
|
||||
`getAppLogin(clientId, redirectUri?)` now sends the incoming OIDC
|
||||
`redirect_uri` (read from the query), not the hardcoded
|
||||
`${publicOrigin}/callback`. IAM validates it against the app's registered
|
||||
list: `hanzo-cloud` (the console's client_id) registers
|
||||
`console.hanzo.ai/auth/callback` but NOT `hanzo.id/callback`, so the old
|
||||
hardcode made `CheckOAuthLogin` answer `status:error` ("Redirect URI …
|
||||
doesn't exist in the allowed list") and the SPA dropped the whole response
|
||||
(`status!=='ok'`→null) → NO providers resolved. This blocked the social
|
||||
buttons for EVERY cross-app SSO read, not just auto-federation. `SocialButtons`
|
||||
reads `redirect_uri` from `window.location.search` (same pattern as the wallet
|
||||
path); absent (bare portal / device flow) it defaults to the portal callback.
|
||||
|
||||
3. **The interactive hop uses `method=signup` (find-or-create-login).** IAM runs
|
||||
find-or-create only under `signup` (`controllers/auth.go:1041`: existing
|
||||
3rd-party identity → sign in, else create). `signin` is the account-LINK
|
||||
branch (`auth.go:1257`: `GetSessionUsername()==''` → `ResponseError("user
|
||||
doesn't exist")`) — it needs a live session and errors on a fresh "Continue
|
||||
with GitHub". The old `intent==='signup'?'signup':'signin'` sent `signin` from
|
||||
the sign-in page; both intents now use `signup` (intent only changes button
|
||||
copy). Latent bug — never exercised end-to-end before this.
|
||||
|
||||
Contracts locked in `pkgs/auth/src/{social,client}.test.ts`. Deploy: image
|
||||
`ghcr.io/hanzoai/id:0.2.6`, operator CR `universe/infra/k8s/operator/crs/id.yaml`
|
||||
(the `id` CR did not exist in-cluster before — id ran off the raw
|
||||
`deployment.yaml`; applying the CR handed the Deployment to the operator, safe
|
||||
because id carries no env, only `envFrom: id-tenant-catalog`). `id` is NOT in the
|
||||
gitops-reconcile allowlist → apply the CR by hand. Rides on 0.2.5 forced-MFA.
|
||||
|
||||
## Silent SSO must be org-scoped — admin-guard god-mode fix (fixed 0.2.2 → 0.2.3)
|
||||
|
||||
`admin.hanzo.ai` (global-admin console) sits behind admin-guard, a Traefik
|
||||
ForwardAuth that allows ONLY `owner == admin` tokens. Login rides
|
||||
`client_id=hanzo-admin-guard` (org=admin). The 0.2.2 credential-form fix
|
||||
(`LoginForm` posts `app.organization` from `get-app-login` → org=admin →
|
||||
IAM resolves `admin/z`, owner=admin) is CORRECT and verified: posting
|
||||
`organization=admin` to `/v1/iam/login` resolves the admin/* row and
|
||||
`get-account` returns owner=admin.
|
||||
|
||||
But it "didn't take effect end-to-end" because `Login.tsx` (0.2.2) added a
|
||||
`silentLogin` SSO fast-path (`canSilent = client_id && redirect_uri`) that,
|
||||
on mount, minted an auth code from the AMBIENT `iam_session_id` session
|
||||
REGARDLESS of that session's org. Real operators carry a hanzo/* session
|
||||
(from hanzo.chat/console), so silentLogin minted an owner=hanzo code and
|
||||
the guard bounced them to console.hanzo.ai — the org-scoped form was never
|
||||
shown. Silent SSO shadowed the form fix.
|
||||
|
||||
Fix (`pkgs/auth/src/client.ts`, `silentLogin`): silent SSO may reuse the
|
||||
ambient session ONLY when its user org == the app's org. `silentLogin` now
|
||||
resolves the app org (`get-app-login`) + session owner (`get-account`,
|
||||
new internal `sessionOwner()` helper) before minting; on no session or an
|
||||
org mismatch it returns `{}` so `Login.tsx` falls back to the interactive
|
||||
form (which authenticates in the app's own org). Same-org SSO (the common
|
||||
case: hanzo session → hanzo app) still mints silently — no UX change.
|
||||
Cross-org (hanzo session → admin-guard) → form → org=admin → owner=admin →
|
||||
god-mode. Non-admins (e.g. Dave) never reach god-mode: the guard validates
|
||||
`owner==admin` server-side, so this is availability (admins get IN), not a
|
||||
privilege boundary — the fix is client-side and cannot admit a non-admin.
|
||||
IAM (`iam:v1.31.14`) is unchanged; the SSO-ATO exact-match redirect fix
|
||||
(d7648965) is untouched.
|
||||
|
||||
## Social login (GitHub/Google) — single-provider state + matched redirect_uri (fixed 0.1.24 → 0.1.25)
|
||||
|
||||
> **Superseded by 0.2.20.** Bug A (read the provider identity from the NESTED
|
||||
> record) still holds and still ships. Everything below about the base64 `state`,
|
||||
> `buildProviderAuthUrl` and `providerLogin` describes the browser-side IdP hop,
|
||||
> which is gone — the browser no longer builds an IdP URL or handles a provider
|
||||
> code, so neither bug can recur. Kept because it records how the flow was
|
||||
> misdiagnosed twice: each fix made the SPA a slightly better relying party, when
|
||||
> the SPA could never be one at all.
|
||||
|
||||
The social hop used to fail at the IAM `/callback` exchange — GitHub with
|
||||
**"The provider: hanzo-iam does not exist"**, Google with "password or code is
|
||||
incorrect". TWO independent bugs, both in the SPA's provider-name handling; the
|
||||
IAM backend, the OAuth creds, and the registered redirect_uri were all fine.
|
||||
|
||||
**Bug A — wrong provider IDENTITY (fixed 0.1.24).** `parseAppLogin`
|
||||
(`pkgs/auth/src/client.ts`) read the **outer** Casdoor app-provider LINK `name`
|
||||
(`pkgs/auth/src/client.ts`) read the **outer** IAM app-provider LINK `name`
|
||||
as the provider identity. `get-app-login` returns each provider as a link object
|
||||
`{name, canSignIn, …, provider:{name, type, clientId, …}}`; the REAL identity is
|
||||
the nested `provider.name` (e.g. `provider-github`), the name the backend
|
||||
@@ -115,7 +617,38 @@ with `defaultProviders: [provider-github, provider-google, provider-web3,
|
||||
provider-apple]` (or any subset) and leave each app's `providers: []`. All apps
|
||||
inherit automatically — no per-app reconfiguration.
|
||||
|
||||
## Org-agnostic password login (fixed 0.1.23)
|
||||
## Org-agnostic password login (0.1.23) — SUPERSEDED, do not restore
|
||||
|
||||
**This section describes behaviour that no longer works and must not be
|
||||
reinstated.** It is kept because the reasoning below is what makes the current
|
||||
design legible, and because someone reading only this section will otherwise
|
||||
"fix" the apex login straight back into an outage.
|
||||
|
||||
iam2 removed cross-org resolution deliberately. It scopes every credential
|
||||
lookup to one org and treats the collision this design leaned on as a defect —
|
||||
`internal/registry/registry.go` names it "the F-2 bug where z@hanzo.ai collided
|
||||
across admin and hanzo", because resolving across orgs coupled lockout counters
|
||||
between rows and handed out a brute-force oracle on the SuperAdmin. An org-less
|
||||
`POST /v1/iam/login` is now refused outright:
|
||||
|
||||
HTTP 200 {"status":"error","msg":"organization, username and password are required"}
|
||||
|
||||
Note the **200**. The form renders that as though the user's own password were
|
||||
wrong, and every status-code monitor reads it as healthy. Left unfixed it killed
|
||||
the bare sign-in on hanzo.id, lux.id, iam.hanzo.ai and pars.id at once while
|
||||
looking green.
|
||||
|
||||
So `LoginForm` now resolves the app's own org via `get-app-login` and posts it on
|
||||
BOTH entry points — the same thing the 0.2.2 fix below already established for
|
||||
the downstream-app path. A global admin is no longer reached by omission; they
|
||||
reach admin/* by signing into an admin-org app (e.g. `hanzo-admin-guard`), which
|
||||
is the explicit path 0.2.2/0.2.3 describe. `client.login()` stays a pure
|
||||
passthrough: it never invents an org, it only forwards one.
|
||||
|
||||
The original 0.1.23 note follows, for context only.
|
||||
|
||||
### (superseded) original text
|
||||
|
||||
|
||||
The portal login is now **org-agnostic**: it no longer pins
|
||||
`organization=<brand>` on `POST /v1/iam/login`. `LoginForm` passes
|
||||
@@ -204,7 +737,7 @@ pars.id ──┘ │
|
||||
▼
|
||||
per-brand OIDC issuer host: hanzo.id / lux.id /
|
||||
zoo.id / pars.id (serves /.well-known + /v1/iam/*;
|
||||
same Casdoor-fork backend, tenant-scoped by org)
|
||||
same Hanzo IAM backend, tenant-scoped by org)
|
||||
│
|
||||
▼
|
||||
iam-* postgres in hanzo namespace
|
||||
@@ -223,35 +756,54 @@ mirrors each `-id` app's provider config in
|
||||
REST `login` and returns an auth code directly. Both honor a downstream
|
||||
`redirect_uri`.
|
||||
|
||||
### Social providers — render only when configured; redirect via the "hop"
|
||||
### Social providers — render only when configured; federate through IAM
|
||||
|
||||
`SocialButtons` renders ONLY providers IAM holds a REAL credential for
|
||||
(`AppProvider.configured` = non-placeholder clientId). With the seed's
|
||||
placeholders every social button is hidden, so a user never hits a dead-end;
|
||||
they reappear automatically once real creds land. Clicking a configured OAuth
|
||||
provider runs the **hop** (`social.ts::startProviderLogin`), which redirects
|
||||
straight to the provider with a base64 `state` that round-trips the original
|
||||
authorize request — matching the IAM (Casdoor) `getAuthUrl` contract. The
|
||||
provider returns to `/callback`; `Callback.tsx` detects the provider state and
|
||||
calls `client.providerLogin` to exchange the code at the IAM backend, then
|
||||
follows the continue-URL (which re-enters `/callback` as the normal OIDC code).
|
||||
(NOT `@hanzo/iam` `signinRedirect` — that loops back to the login page.)
|
||||
(`AppProvider.configured` = non-placeholder clientId), so a placeholder-seeded
|
||||
provider is hidden rather than dead-ending; it reappears once real creds land.
|
||||
IAM applies the same rule server-side (`isConfigured`), so a hidden provider is
|
||||
also refused at authorize — the two agree without sharing a table. Clicking a
|
||||
configured provider names it on IAM's authorize endpoint and IAM runs the entire
|
||||
IdP leg; see "Social sign-in goes through IAM" above. This browser never builds
|
||||
an IdP URL, never sees a provider code, and holds no secret.
|
||||
|
||||
**To ENABLE real social login (the only remaining work):**
|
||||
1. Register an OAuth app per provider (GitHub/Google) with callback
|
||||
**`https://<brand>/v1/iam/callback`** AND the app authorize redirect
|
||||
`https://<brand>/callback` (per brand host: hanzo.id, lux.id, pars.id …).
|
||||
2. Put the client id/secret in KMS at **project `hanzo-iam`, env `prod`**, keys
|
||||
`IAM_GITHUB_CLIENT_ID` / `IAM_GITHUB_CLIENT_SECRET` (and `IAM_GOOGLE_*`). The
|
||||
`iam-kms-sync` KMSSecret (`universe/infra/k8s/iam/secret.yaml`) syncs that
|
||||
path into `iam-secrets`; init_data.json substitutes `${IAM_GITHUB_CLIENT_ID}`
|
||||
at deploy. The whole sync + env-ref chain already exists — today those keys
|
||||
just hold placeholder values, so providers read as unconfigured (buttons
|
||||
hidden). Replace the values; nothing else to wire.
|
||||
3. The buttons appear automatically (no portal change). **Live-verify** the
|
||||
round-trip reaches the provider and completes — the hop + exchange are wired
|
||||
and unit-tested (`pkgs/auth/src/social.test.ts`) but can only be exercised
|
||||
end-to-end once real creds exist.
|
||||
**The callback the IdP must have registered is IAM's, not the SPA's.** One fixed
|
||||
path, every provider, per brand host:
|
||||
|
||||
https://<brand>/v1/iam/oauth/callback ← register THIS at GitHub/Google
|
||||
|
||||
(`PathFederationCallback`; the origin is the brand's pinned issuer, resolved from
|
||||
the trusted request host — `hanzo.id` federates to `hanzo.id/v1/iam/oauth/
|
||||
callback`, lux.id to its own, and so on.) It is NOT `/v1/iam/callback` (that path
|
||||
does not exist — an earlier revision of this file said so and was wrong) and NOT
|
||||
`https://<brand>/callback`, which is the APP's authorize redirect and belongs to
|
||||
the app's registered `redirectUris` in `init_data.json`, a different list for a
|
||||
different leg.
|
||||
|
||||
Beware verifying this from outside: **GitHub defers redirect_uri validation until
|
||||
after the user signs in**, so an unauthenticated request to
|
||||
`github.com/login/oauth/authorize` 302s to the login page whether the callback is
|
||||
registered or not — a deliberately unregistered URL behaves identically. The only
|
||||
sound check is the GitHub App's own settings page.
|
||||
|
||||
Credentials live in KMS at project `hanzo-iam`, env `prod`, keys
|
||||
`IAM_GITHUB_CLIENT_ID` / `IAM_GITHUB_CLIENT_SECRET` (and `IAM_GOOGLE_*`). The
|
||||
`iam-kms-sync` KMSSecret (`universe/infra/k8s/iam/secret.yaml`) syncs that path
|
||||
into `iam-secrets`; `init_data.json` substitutes `${IAM_GITHUB_CLIENT_ID}` at
|
||||
deploy. That chain already works — GitHub and Google both carry real values
|
||||
today; `provider-web3` and `provider-apple` are still placeholders and stay
|
||||
hidden.
|
||||
|
||||
**`enableSignUp` gates a FIRST-TIME federated user.** Federation provisions a
|
||||
local user when the identity is new, so an app with `enableSignUp:false` refuses
|
||||
a first-time GitHub user with "the application does not allow to sign up new
|
||||
account" — a sign-in failure that has nothing to do with the flow being wired.
|
||||
Live: `hanzo-console` and `hanzo-app` are true; `hanzo-id` and `hanzo-cloud` are
|
||||
false. `hanzo.id` sends `clientId: hanzo-console` (`universe/infra/k8s/id/
|
||||
configmap.yaml`), so the portal is on a signup-permitting app. It is governed
|
||||
declaratively in `universe/infra/k8s/iam/init_data.json` and reconciled every
|
||||
boot (`iam/internal/seed/seed.go`, `appPolicyKeys`) — change it THERE, never by
|
||||
an admin call, which the next boot would revert.
|
||||
|
||||
## Workspace
|
||||
|
||||
@@ -352,7 +904,7 @@ Custom providers: implement the `IDVProvider` interface in
|
||||
|
||||
## Backend
|
||||
|
||||
The Go IAM backend lives at `~/work/hanzo/iam` (Casdoor fork, module
|
||||
The Go IAM backend lives at `~/work/hanzo/iam` (Hanzo IAM, module
|
||||
`github.com/hanzoai/iam`, image `ghcr.io/hanzoai/iam`). All paths are under
|
||||
the `/v1/iam` prefix — no legacy `/oauth/*`, no `/api/`. This portal talks
|
||||
to it via:
|
||||
@@ -372,3 +924,226 @@ to it via:
|
||||
All hostnames talk to the same IAM backend — the org is carried in the
|
||||
request body (`organization: <orgId>`), and the IAM backend tenant-scopes
|
||||
on that.
|
||||
|
||||
## Truth flows git.hanzo.ai -> GitHub (2026-07-26)
|
||||
|
||||
`hanzoai/id` is CANONICAL on git.hanzo.ai (`mirror:false`, default `main`).
|
||||
GitHub is a **push-mirror** of it, `sync_on_commit: true` with an 8h floor —
|
||||
push here and GitHub follows on its own.
|
||||
|
||||
It used to be the reverse, and that was the bug: as a pull-mirror this repo
|
||||
could run no CI at all, so `.hanzo/workflows` never fired and four commits
|
||||
shipped zero images without a single red signal. Do not re-point the sync.
|
||||
|
||||
Builds publish to BOTH `oci.hanzo.ai/id` (ours, the destination) and
|
||||
`ghcr.io/hanzoai/id` (kept so a rollback target always resolves), tagged from
|
||||
`package.json` — a release IS a version bump. Deploying is a `spec.image.tag`
|
||||
edit in `universe/infra/k8s/operator/crs/id.yaml`; CI must never patch that CR
|
||||
itself, because Hanzo CD reverts it within ~90s.
|
||||
|
||||
---
|
||||
|
||||
## HANDOFF — 2026-08-03: social login, SSO, and the build path
|
||||
|
||||
State at handoff. Everything below was measured against production, not inferred.
|
||||
Where a claim is unverified it says so.
|
||||
|
||||
### THE ONE-LINE ROOT CAUSE OF "DAYS OF LOGIN BUGS"
|
||||
|
||||
`pkgs/shared/src/org.ts` — `oauthCallbackOrigin` defaulted to `publicOrigin`, the
|
||||
BRAND'S OWN host, and no catalog entry overrode it. So every property sent a
|
||||
different `redirect_uri` to Google/GitHub:
|
||||
|
||||
hanzo.app -> hanzo.app/callback hanzo.chat -> hanzo.chat/callback
|
||||
console -> console.hanzo.ai/... cloud -> cloud.hanzo.ai/...
|
||||
|
||||
Each provider holds ONE OAuth client with a FIXED list of authorized URIs, so
|
||||
social login could work on at most ONE property. Google's own error payload,
|
||||
base64-decoded, reads `redirect_uri_mismatch`. GitHub's reads "redirect_uri is
|
||||
not associated with this application".
|
||||
|
||||
IT WAS NEVER KMS OR SECRETS. The Google client_id
|
||||
(113591532635-s8pvqrebprkbndluhftddmdap4htvu1p.apps.googleusercontent.com)
|
||||
reached Google intact every time. Do not go looking at KMS again.
|
||||
|
||||
FIXED in c153004: the default is now the org's hosted ID host, read out of
|
||||
DEFAULT_TENANTS via `idOriginFor()` so the `.id` hosts stay declared once.
|
||||
Verified by executing resolveOrg under tsx with a production-shaped catalog:
|
||||
|
||||
hanzo.app / hanzo.chat / console.hanzo.ai / cloud.hanzo.ai -> hanzo.id/callback
|
||||
id.lux.network -> lux.id/callback
|
||||
|
||||
FIRST ATTEMPT WAS WRONG — recorded so nobody repeats it: defaulting to
|
||||
`iamIssuer` does NOT work. `hostSkeleton()` derives the issuer from the REQUEST
|
||||
HOST too, so it is per-brand for the same reason. It must be a per-ORG constant.
|
||||
|
||||
### LEFT TO DO, IN ORDER
|
||||
|
||||
1. CONFIRM `ghcr.io/hanzoai/id:0.2.22` EXISTS, then bump the chart.
|
||||
`crane digest ghcr.io/hanzoai/id:0.2.22` — a digest is the ONLY proof; the
|
||||
build job's exit code lied twice this session. Chart:
|
||||
universe/charts/app/values/hanzo/id.yaml (currently 0.2.21). Pin tag AND
|
||||
digest together — a stale digest wins over the tag and serves the old build.
|
||||
Build was fired by pushing to forge (see §BUILD PATH) as pf-runner-yuylsijvdnpb.
|
||||
|
||||
2. REGISTER THE CALLBACK URI with both providers. One entry each, whole fleet:
|
||||
- GitHub OAuth App -> Authorization callback URL
|
||||
- Google Cloud project -> Authorized redirect URIs
|
||||
GET THE EXACT STRING EMPIRICALLY: click "Continue with GitHub" at hanzo.id and
|
||||
read `redirect_uri=` out of the address bar BEFORE the error page. Do not
|
||||
trust a predicted string.
|
||||
Until BOTH (1) and (2) are done, social login stays broken and the error text
|
||||
is identical either way.
|
||||
|
||||
3. WALLET LOGIN IS A DATA FIX, NOT CODE. `provider-web3` has canSignUp=true and
|
||||
canSignIn=false on 6 of 11 apps (hanzo-app, hanzo-base, hanzo-docs,
|
||||
hanzo-insights, hanzo-o11y, hanzo-world); symmetric and correct on
|
||||
hanzo-chat, hanzo-cloud, hanzo-console, hanzo-platform. So it is inconsistent
|
||||
seeding, not policy. Anyone who signed up with a wallet cannot sign back in.
|
||||
SocialButtons.tsx:162 renders the flags faithfully — do not "fix" the code.
|
||||
Needs an admin bearer token: the portal session cookie gets 401 on
|
||||
/v1/iam/applications.
|
||||
|
||||
4. MFA / AUTHENTICATORS / PASSKEYS — COMPLETELY UNVERIFIED. Surfaces exist
|
||||
(MfaEnrollForm, OTPForm, SmsConsentNotice, django_otp in insights) but NO
|
||||
flow was driven end to end. Email OTP, SMS OTP, TOTP and WebAuthn each need
|
||||
a real browser pass. Do not report any of them as working without driving it.
|
||||
|
||||
5. PLATFORM BUILD ENQUEUE IS BROKEN (separate from the above).
|
||||
POST platform.hanzo.ai/v1/runner -> 400 "organizationId is required (no
|
||||
DEFAULT_BUILD_ORG_ID configured)"; supplying `hanzo`, `admin/hanzo`, or the
|
||||
real UUID dfb7a19b-108f-5150-8131-7d207488bf48 all -> 500 "enqueue failed:
|
||||
FOREIGN KEY constraint failed". Cause: platform's /data/data.db was LAST
|
||||
WRITTEN Jul 28 (auxiliary.db is live today) — the org row the build_job FK
|
||||
references does not exist. No sqlite3 in the pod.
|
||||
CTO ruling: DELETE `DEFAULT_BUILD_ORG_ID` entirely. A fallback org is a second
|
||||
way to do things and it let this fail silently for a week; every build belongs
|
||||
to a real prepaid org. Make the enqueue say "org X has no build account"
|
||||
instead of leaking a raw SQL constraint. Fix lives in ~/work/hanzo/platform.
|
||||
api.hanzo.ai/v1/runner also returned 503 during this window.
|
||||
|
||||
6. o11y-mcp — DELETE IT, do not debrand it. Branch debrand/no-signoz (016535f)
|
||||
removes SigNoz+ClickHouse from go.mod and builds clean, but it is the wrong
|
||||
fix: o11y already declares 353 typed zip ops and zip projects each into an MCP
|
||||
tool from the same declaration. Cloud's door at POST api.hanzo.ai/v1/mcp
|
||||
serves 932 tools across 116 apps. A hand-rolled Go MCP server is a second way
|
||||
to do MCP, and its staleness proved it — it was publishing DDL for tables
|
||||
HIP-0132 dropped. Archive the repo; regenerate plugin/o11y/mcp.json from the
|
||||
typed ops (it holds 12 entries where o11y declares 353).
|
||||
|
||||
7. LEDGER CONSOLIDATION -> hanzoai/ledger. Two live forks of one Formance root.
|
||||
ledger-fi is the live lineage (8 real PRs, already zip, 6 commits behind
|
||||
upstream). Registry rule is Hanzo->hanzoai, so hanzo-fi is a fourth org that
|
||||
exists for one repo. RENAME THE LIVE ONE FIRST (hanzo-fi/ledger ->
|
||||
hanzoai/ledger) so GitHub's redirect protects `go get`, THEN delete the dead
|
||||
twin — never the reverse or the redirect is stranded. Its clickhouse-go is
|
||||
Formance's own dep and leaves with the consolidation. NOTE: 0 of 1,444 changed
|
||||
lines in internal/api are ours; do not rewrite vendored paths beyond go.mod.
|
||||
|
||||
8. ALSO DEPRECATED: archive `hanzoai/datastore-go` — imported by NOBODY while 20
|
||||
repos use `github.com/hanzo-ds/go`. Confirmed deprecated by the CTO.
|
||||
|
||||
### SHIPPED AND LIVE (verified by response body, not status code)
|
||||
|
||||
- iam@3f86f1f5e — ONE SSO seam. Two independent fixes reconciled:
|
||||
* session is CREATED on every grant shape. The bug: `if f.Type != "code"`
|
||||
guarded sessions.Set, while client.ts:170 sends type=code for every OAuth
|
||||
login — so the IdP forgot the human the instant they signed in and the
|
||||
fully-built silent-SSO branch had nothing to read.
|
||||
* session is USABLE without UI: prompt=none now returns the code with no UI,
|
||||
or error=login_required TO THE REDIRECT_URI (never a rendered page).
|
||||
VERIFIED LIVE on hanzo.id. Also landed: __Host- cookie prefix, max_age
|
||||
enforcement, and id_token_hint signature verification (without it a silent
|
||||
renewal could return a code for a DIFFERENT human through a callback the RP
|
||||
already trusts — an identity swap with nothing on screen).
|
||||
- id@f2fc1e4 — the callback fix + semver: id-shared 0.1.2, id-auth 0.1.7,
|
||||
id-onboarding 0.1.2, id-idv 0.1.1, root 0.2.22. id-connect NOT bumped (does
|
||||
not depend on id-shared).
|
||||
- id@2fe32a7 — deleted ProviderButtons.tsx, a dead 2-provider list that was in
|
||||
no barrel and imported by nobody, sitting next to the 4-provider SocialButtons.
|
||||
- console v8.5.36 — hero h1 now 61.6px line box for 56px glyphs. It was
|
||||
line-height 1.12px: a ONE-PIXEL box under 30px glyphs, so the heading
|
||||
overflowed onto its own subtitle. Cause: react-native-web appends `px` to
|
||||
numeric style values absent from its unitless list, and lineHeight is absent,
|
||||
so {lineHeight:1.12} compiled to `1.12px`.
|
||||
- console 4656c316f4 — react-native-svg 15.15.5. console main had been
|
||||
UNBUILDABLE since the gui-8 bump: @hanzogui/lucide-icons-2@8.0.0 imports
|
||||
react-native-svg while declaring it in NEITHER dependencies. That is why
|
||||
v8.5.33/34/35 never existed in GHCR and two weeks of fixes never shipped.
|
||||
- cloud.hanzo.ai login — client_id=hanzo-cloud (was hanzo-app, whose client
|
||||
carries only hanzo.app/auth/callback).
|
||||
- datastore 8Gi -> 12Gi — ClickHouse derives max_server_memory_usage from the
|
||||
cgroup at 0.9, so 8Gi WAS the 7.20 GiB ceiling and the server was refusing
|
||||
reads with MEMORY_LIMIT_EXCEEDED at 7.88 GiB RSS.
|
||||
|
||||
### BUILD PATH THAT WORKS
|
||||
|
||||
platform.hanzo.ai/v1/runner is broken (§5). USE THE NATIVE FORGE PUSH:
|
||||
|
||||
git push forge origin/main:main # git.hanzo.ai fires .hanzo/workflows/deploy.yml
|
||||
|
||||
Verified working this session: git-runner fleet 4/4 Running, and
|
||||
build-console / build-docs / build-openapi all Completed within 30 min.
|
||||
NOTE forge/main was 8 commits BEHIND origin/main for `id` — a working builder
|
||||
would still have built the wrong tree. Check both remotes agree.
|
||||
|
||||
### METHOD NOTES — these caught real errors, four times
|
||||
|
||||
- A CHART BUMP IS NOT A DEPLOY; A MERGED COMMIT IS NOT PRODUCTION. Verify what
|
||||
RUNS. I pinned console to v8.5.35, an image that NEVER EXISTED (GHCR 403'd
|
||||
anonymously and I shipped anyway on "RollingUpdate fails safe" — it did fail
|
||||
safe, and it also shipped nothing while reading as done).
|
||||
- A GREEN TEST COMMAND IS NOT A GREEN TEST. `pnpm --filter @hanzo/id-shared test`
|
||||
exits 0 having run NOTHING — that package declares no `test` script. Root has
|
||||
`test: vitest run`, but vitest is not installed in the checkout. The org.ts
|
||||
behaviour was verified by EXECUTING resolveOrg under tsx.
|
||||
- `$?` AFTER A PIPE IS THE PIPE'S STATUS. Printed "build: 0" for a failed build.
|
||||
Put echo $? on its own line.
|
||||
- MEASURE origin/main, NEVER A LOCAL CHECKOUT, and run `git status -sb` first.
|
||||
Stale checkouts produced five false findings, worst: hanzoai/iam called a beego
|
||||
carrier while the local tree sat 388 commits behind on a dead branch that ships
|
||||
its own DEPRECATED.md. node is 7,565 behind. Filter `// indirect` too.
|
||||
- ROUTE/SYMBOL COUNTS IN A REPO PROVE NOTHING ABOUT WHAT SHIPS. bootnode was
|
||||
ranked a top conversion target on 206 chi routes that ship in ZERO binaries
|
||||
(its live API is Python/uvicorn). Use `go list -deps` against the binary the
|
||||
Dockerfile builds, plus what runs in the cluster.
|
||||
|
||||
### SECURITY, OPEN
|
||||
|
||||
- NO FIRST-USE CONSENT on prompt=none: a signed-in victim top-level navigated to
|
||||
authorize?client_id=<attacker>&prompt=none yields a code to that client's
|
||||
registered redirect_uri. SameSite=Lax sends the cookie on a top-level GET and
|
||||
Sec-Fetch cannot help — it is a genuine navigation. Bounded by MintFor's
|
||||
tenancy rule, so blast radius depends on who may set IsShared. This is the
|
||||
standard reason IdPs gate first use of a client behind consent.
|
||||
- CORS EDGE, RE-CHECK: login.go's own comment records a proxy on the
|
||||
hanzo.ai/hanzo.id zones once reflecting *.hanzo.ai with
|
||||
Access-Control-Allow-Credentials:true, which would make the credential-less
|
||||
mint reachable from any subdomain. ACAO measures as exactly https://hanzo.id
|
||||
today; a hostile Origin was NOT tested. The SSO fix puts live sessions in far
|
||||
more browsers, so this matters more now.
|
||||
- hanzo_iam_access_token on domain hanzo.app is a full RS256 JWT in a
|
||||
NON-HttpOnly, JS-readable cookie.
|
||||
- Application.EnableSigninSession — declared at pkg/schema/application.go:146,
|
||||
set TRUE on every app, READ BY NO CODE. Revive it as a real gate or delete it.
|
||||
- The __Host- cookie rename INVALIDATES EVERY LIVE SESSION on deploy. One
|
||||
re-login per human. Decide accept-vs-dual-read before rolling.
|
||||
- CLEANUP OWED: qa-signup-probe-0803@hanzo.ai is a real account created to prove
|
||||
signup works end to end; delete it. A DigitalOcean PAT (dop_v1_ff09e128…) was
|
||||
pasted into the session transcript and is on disk — ROTATE IT.
|
||||
|
||||
### DEAD ENDS — do not re-derive
|
||||
|
||||
- insights.hanzo.ai is NOT broken. It is SSO-gated and the chain works:
|
||||
/login -> /login/oidc/ -> hanzo.id/…authorize?client_id=hanzo-insights -> 200.
|
||||
The "532 MIME errors, empty #root" report was an unauthenticated browser
|
||||
following those 302s.
|
||||
- THERE IS NO SHADCN TO KILL. Neither console main nor blue3/ui-shadcn-explicit
|
||||
contains shadcn, radix or tailwind; main's only two matches are comments saying
|
||||
the console deliberately is not the shadcn build. That branch is 906 behind /
|
||||
10 ahead and REGRESSES deps (@hanzo/gui 7.3.0 vs ^8.0.0, @hanzo/iam ^0.13.6 vs
|
||||
^0.21.2). ABANDON IT, do not merge. Tamagui-native is already true on main.
|
||||
- arc is dead and arcd was removed. It was a systemd --user service on the spark
|
||||
workstation, NOT in k8s; the k8s ARC removal happened 2026-07-29. Its 65 jobs
|
||||
in 30 days were 65/65 FAILURES from one cron. hanzoai/ci's runner default was
|
||||
NEVER arc — it is hanzo-build-linux-amd64, served by git-runner.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
<p align="center"><img src=".github/hero.svg" alt="id" width="880"></p>
|
||||
|
||||
# @hanzo/id
|
||||
|
||||
White-label login + identity verification portal. One Vite SPA, four hosts
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# @hanzo/id-account
|
||||
|
||||
The account portal: branded account-management pages at `account.hanzo.id`,
|
||||
`account.lux.id`, … served by a Cloudflare Worker, authenticated with IAM access
|
||||
tokens over the OAuth code exchange.
|
||||
|
||||
It lives HERE, in `hanzoai/id`, because it is identity UI — the same brands, the
|
||||
same IAM, the same login redirect as `apps/web`. It used to live in
|
||||
`hanzoai/account`, which is a Go module: one repo held two unrelated codebases
|
||||
under one name (`main` was this Worker, the Go module survived only on a `go`
|
||||
branch and its tags). That collision is what made
|
||||
`github.com/hanzoai/account` unresolvable as a Go module from a clean checkout.
|
||||
|
||||
`hanzoai/account` is now the Go module and nothing else — see its README for the
|
||||
billing-account rule it owns.
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "@hanzo/id-account",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"deploy": "wrangler deploy"
|
||||
},
|
||||
"devDependencies": {
|
||||
"wrangler": "^4.65.0"
|
||||
},
|
||||
"description": "Branded account-management pages for account.<brand>.id \u2014 a Cloudflare Worker."
|
||||
}
|
||||
@@ -0,0 +1,803 @@
|
||||
/**
|
||||
* Account Portal — Cloudflare Worker
|
||||
*
|
||||
* Serves branded account management pages for:
|
||||
* - account.hanzo.id (Hanzo brand)
|
||||
* - account.lux.id (Lux brand)
|
||||
*
|
||||
* Authentication: Uses IAM access tokens via OAuth code exchange.
|
||||
* Users are redirected to their brand's login page if unauthenticated.
|
||||
*/
|
||||
|
||||
const IAM_ORIGIN = 'https://iam.hanzo.ai';
|
||||
|
||||
// Brand configuration keyed by hostname
|
||||
const BRANDS = {
|
||||
'account.hanzo.id': {
|
||||
name: 'Hanzo',
|
||||
domain: 'hanzo.id',
|
||||
loginUrl: 'https://hanzo.id/login',
|
||||
bg: '#0a0a0a',
|
||||
surface: '#111111',
|
||||
border: '#222222',
|
||||
accent: '#fd4444',
|
||||
clientId: 'hanzo-app-client-id',
|
||||
logo: `<svg viewBox="0 0 100 100" width="40" height="40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M50 5L90 27.5V72.5L50 95L10 72.5V27.5L50 5Z" stroke="#fd4444" stroke-width="4"/>
|
||||
<path d="M30 35V65M70 35V65M30 50H70" stroke="#fd4444" stroke-width="4" stroke-linecap="round"/>
|
||||
</svg>`,
|
||||
},
|
||||
'account.lux.id': {
|
||||
name: 'Lux',
|
||||
domain: 'lux.id',
|
||||
loginUrl: 'https://lux.id/login',
|
||||
bg: '#050508',
|
||||
surface: '#0c0c10',
|
||||
border: '#222222',
|
||||
accent: '#ffffff',
|
||||
clientId: 'lux-app-client-id',
|
||||
logo: `<svg viewBox="0 0 100 100" width="40" height="40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<polygon points="50,10 90,75 10,75" stroke="white" stroke-width="3" fill="none"/>
|
||||
<polygon points="50,30 72,68 28,68" stroke="white" stroke-width="2" fill="none"/>
|
||||
</svg>`,
|
||||
},
|
||||
};
|
||||
|
||||
function getBrand(hostname) {
|
||||
return BRANDS[hostname] || BRANDS['account.hanzo.id'];
|
||||
}
|
||||
|
||||
function htmlResponse(html) {
|
||||
return new Response(html, {
|
||||
headers: {
|
||||
'content-type': 'text/html;charset=UTF-8',
|
||||
'cache-control': 'no-store',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'X-Frame-Options': 'DENY',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Parse access token from cookie
|
||||
function getToken(request) {
|
||||
const cookie = request.headers.get('Cookie') || '';
|
||||
const match = cookie.match(/account_token=([^;]+)/);
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
}
|
||||
|
||||
// Fetch user info from IAM using token
|
||||
async function getUserInfo(token) {
|
||||
const res = await fetch(`${IAM_ORIGIN}/v1/iam/userinfo`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
return data;
|
||||
}
|
||||
|
||||
// Fetch full user object for editing
|
||||
async function getUser(token, owner, name) {
|
||||
const res = await fetch(`${IAM_ORIGIN}/v1/iam/get-user?id=${encodeURIComponent(owner)}/${encodeURIComponent(name)}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
return data.data || null;
|
||||
}
|
||||
|
||||
// Build the OAuth login URL for the brand. Points at the brand's branded
|
||||
// /login page (front-door worker) with the OAuth params; that page renders
|
||||
// the two-pane login and emits the canonical /v1/iam/* calls itself — no
|
||||
// bare /oauth/authorize, no host leak to iam.hanzo.ai.
|
||||
function buildLoginUrl(brand, callbackUrl) {
|
||||
const params = new URLSearchParams({
|
||||
client_id: brand.clientId,
|
||||
redirect_uri: callbackUrl,
|
||||
response_type: 'code',
|
||||
scope: 'openid profile email',
|
||||
state: 'account',
|
||||
});
|
||||
return `${brand.loginUrl}?${params.toString()}`;
|
||||
}
|
||||
|
||||
// Exchange authorization code for access token
|
||||
async function exchangeCode(code, callbackUrl, brand) {
|
||||
const res = await fetch(`${IAM_ORIGIN}/v1/iam/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: callbackUrl,
|
||||
client_id: brand.clientId,
|
||||
}).toString(),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
return data.access_token || null;
|
||||
}
|
||||
|
||||
function renderAccountPage(brand, user, fullUser) {
|
||||
const providers = [];
|
||||
// Extract linked providers from user object
|
||||
const providerFields = ['github', 'google', 'facebook', 'twitter', 'linkedin', 'discord', 'wechat', 'dingtalk'];
|
||||
if (fullUser) {
|
||||
for (const p of providerFields) {
|
||||
if (fullUser[p] && fullUser[p] !== '') {
|
||||
providers.push({ name: p, id: fullUser[p] });
|
||||
}
|
||||
}
|
||||
// Check for MetaMask/Web3 wallet
|
||||
if (fullUser.metamask && fullUser.metamask !== '') {
|
||||
providers.push({ name: 'web3', id: fullUser.metamask });
|
||||
} else if (fullUser.web3onboard && fullUser.web3onboard !== '') {
|
||||
providers.push({ name: 'web3', id: fullUser.web3onboard });
|
||||
}
|
||||
}
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Account - ${brand.name}</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: ${brand.bg};
|
||||
color: #fff;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.topbar {
|
||||
border-bottom: 1px solid ${brand.border};
|
||||
padding: 0.75rem 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: ${brand.surface};
|
||||
}
|
||||
.topbar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.topbar-actions { display: flex; gap: 0.75rem; align-items: center; }
|
||||
.topbar-actions a {
|
||||
color: #999;
|
||||
text-decoration: none;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid ${brand.border};
|
||||
}
|
||||
.topbar-actions a:hover { color: #fff; border-color: #555; }
|
||||
.topbar-actions .logout { color: #ff6b6b; border-color: #ff6b6b33; }
|
||||
.topbar-actions .logout:hover { background: #ff6b6b11; }
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
.section {
|
||||
background: ${brand.surface};
|
||||
border: 1px solid ${brand.border};
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.section h2 {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid ${brand.border};
|
||||
}
|
||||
.field {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.6rem 0;
|
||||
}
|
||||
.field + .field { border-top: 1px solid ${brand.border}22; }
|
||||
.field-label { color: #888; font-size: 0.85rem; min-width: 120px; }
|
||||
.field-value { font-size: 0.92rem; }
|
||||
.field-action {
|
||||
color: ${brand.accent};
|
||||
text-decoration: none;
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: 1px solid ${brand.accent}44;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.field-action:hover { background: ${brand.accent}11; }
|
||||
.provider-list { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.provider-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.6rem 0.8rem;
|
||||
border: 1px solid ${brand.border};
|
||||
border-radius: 8px;
|
||||
background: ${brand.bg};
|
||||
}
|
||||
.provider-item .name {
|
||||
text-transform: capitalize;
|
||||
font-weight: 500;
|
||||
}
|
||||
.provider-item .id {
|
||||
color: #888;
|
||||
font-size: 0.8rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
.provider-item .unlink {
|
||||
color: #ff6b6b;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: 1px solid #ff6b6b33;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.add-provider {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.add-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.4rem 0.8rem;
|
||||
border: 1px dashed ${brand.border};
|
||||
border-radius: 8px;
|
||||
color: #999;
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
text-decoration: none;
|
||||
}
|
||||
.add-btn:hover { border-color: #555; color: #fff; }
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
background: #1a472a;
|
||||
color: #6ee7b7;
|
||||
margin-left: 0.4rem;
|
||||
}
|
||||
.badge.unverified { background: #472a1a; color: #e7b76e; }
|
||||
.danger-zone {
|
||||
border-color: #ff6b6b33;
|
||||
}
|
||||
.danger-zone h2 { color: #ff6b6b; }
|
||||
.msg {
|
||||
display: none;
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.msg.success { background: #1a472a; color: #6ee7b7; display: block; }
|
||||
.msg.error { background: #472a1a; color: #e7b76e; display: block; }
|
||||
.avatar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.6rem 0;
|
||||
}
|
||||
.avatar {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background: ${brand.border};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
.avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.7);
|
||||
z-index: 100;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.modal-overlay.active { display: flex; }
|
||||
.modal {
|
||||
background: ${brand.surface};
|
||||
border: 1px solid ${brand.border};
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
}
|
||||
.modal h3 { margin-bottom: 1rem; }
|
||||
.modal input {
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
border: 1px solid ${brand.border};
|
||||
background: ${brand.bg};
|
||||
color: #fff;
|
||||
padding: 0.6rem 0.8rem;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.modal-actions button {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid ${brand.border};
|
||||
background: none;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.modal-actions .primary {
|
||||
background: ${brand.accent};
|
||||
color: ${brand.accent === '#ffffff' ? '#111' : '#fff'};
|
||||
border-color: ${brand.accent};
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.container { padding: 0 1rem; margin: 1rem auto; }
|
||||
.section { padding: 1rem; }
|
||||
.field { flex-direction: column; align-items: flex-start; gap: 0.3rem; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="topbar">
|
||||
<div class="topbar-brand">
|
||||
${brand.logo}
|
||||
<span>${brand.name} Account</span>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
<a href="https://${brand.domain}">Back to ${brand.name}</a>
|
||||
<a href="https://${brand.domain}/logout" class="logout">Sign Out</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container">
|
||||
<div id="msg" class="msg"></div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Profile</h2>
|
||||
<div class="avatar-row">
|
||||
<div class="avatar">
|
||||
${user.picture ? `<img src="${user.picture}" alt="Avatar">` : (user.name || 'U').charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-weight:600;font-size:1.1rem;">${user.preferred_username || user.name || 'User'}</div>
|
||||
<div style="color:#888;font-size:0.85rem;">${user.email || ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field-label">Display Name</span>
|
||||
<span class="field-value">${user.name || '—'}</span>
|
||||
<button class="field-action" onclick="editField('name','${(user.name || '').replace(/'/g, "\\'")}')">Edit</button>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field-label">Email</span>
|
||||
<span class="field-value">
|
||||
${user.email || '—'}
|
||||
${user.email_verified ? '<span class="badge">Verified</span>' : '<span class="badge unverified">Unverified</span>'}
|
||||
</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field-label">Phone</span>
|
||||
<span class="field-value">${user.phone || '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Login Methods</h2>
|
||||
<div class="provider-list">
|
||||
<div class="provider-item">
|
||||
<div>
|
||||
<span class="name">Email & Password</span>
|
||||
<span class="id">${user.email || 'Not set'}</span>
|
||||
</div>
|
||||
<button class="field-action" onclick="openPasswordModal()">Change Password</button>
|
||||
</div>
|
||||
${providers.map(p => `
|
||||
<div class="provider-item">
|
||||
<div>
|
||||
<span class="name">${p.name === 'web3' ? 'Web3 Wallet' : p.name}</span>
|
||||
<span class="id">${p.name === 'web3' ? p.id.slice(0, 6) + '...' + p.id.slice(-4) : p.id}</span>
|
||||
</div>
|
||||
<button class="unlink" onclick="unlinkProvider('${p.name}')">Unlink</button>
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
<div class="add-provider">
|
||||
<a class="add-btn" href="https://${brand.domain}/v1/iam/oauth/authorize?client_id=${brand.clientId}&redirect_uri=${encodeURIComponent(`https://${brand.domain}/callback`)}&response_type=code&scope=openid+profile+email&provider=provider-google">
|
||||
+ Google
|
||||
</a>
|
||||
<a class="add-btn" href="https://${brand.domain}/v1/iam/oauth/authorize?client_id=${brand.clientId}&redirect_uri=${encodeURIComponent(`https://${brand.domain}/callback`)}&response_type=code&scope=openid+profile+email&provider=provider-github">
|
||||
+ GitHub
|
||||
</a>
|
||||
<button class="add-btn" onclick="linkWallet()">+ Web3 Wallet</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Security</h2>
|
||||
<div class="field">
|
||||
<span class="field-label">Two-Factor Auth</span>
|
||||
<span class="field-value">${fullUser && fullUser.totpSecret ? '<span class="badge">Enabled</span>' : 'Not enabled'}</span>
|
||||
<button class="field-action" onclick="window.location.href='https://iam.hanzo.ai/account#mfa'">${fullUser && fullUser.totpSecret ? 'Manage' : 'Enable'}</button>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field-label">Last Sign-in</span>
|
||||
<span class="field-value">${fullUser && fullUser.lastSigninTime ? new Date(fullUser.lastSigninTime).toLocaleString() : '—'}</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field-label">Last Sign-in IP</span>
|
||||
<span class="field-value">${fullUser && fullUser.lastSigninIp ? fullUser.lastSigninIp : '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section danger-zone">
|
||||
<h2>Danger Zone</h2>
|
||||
<div class="field">
|
||||
<span class="field-label">Delete Account</span>
|
||||
<span class="field-value" style="color:#888;font-size:0.82rem;">Permanently delete your account and all data</span>
|
||||
<button class="field-action" style="color:#ff6b6b;border-color:#ff6b6b44;" onclick="confirmDelete()">Delete Account</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Password Change Modal -->
|
||||
<div class="modal-overlay" id="password-modal">
|
||||
<div class="modal">
|
||||
<h3>Change Password</h3>
|
||||
<input type="password" id="old-password" placeholder="Current password" autocomplete="current-password">
|
||||
<input type="password" id="new-password" placeholder="New password (min 8 characters)" autocomplete="new-password">
|
||||
<input type="password" id="confirm-password" placeholder="Confirm new password" autocomplete="new-password">
|
||||
<div id="pw-error" style="color:#ff8f8f;font-size:0.82rem;display:none;margin-bottom:0.5rem;"></div>
|
||||
<div class="modal-actions">
|
||||
<button onclick="closePasswordModal()">Cancel</button>
|
||||
<button class="primary" onclick="changePassword()">Update Password</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Field Modal -->
|
||||
<div class="modal-overlay" id="edit-modal">
|
||||
<div class="modal">
|
||||
<h3 id="edit-title">Edit Field</h3>
|
||||
<input type="text" id="edit-value">
|
||||
<div class="modal-actions">
|
||||
<button onclick="closeEditModal()">Cancel</button>
|
||||
<button class="primary" onclick="saveField()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const TOKEN = document.cookie.match(/account_token=([^;]+)/)?.[1] ? decodeURIComponent(document.cookie.match(/account_token=([^;]+)/)[1]) : null;
|
||||
const IAM = '${IAM_ORIGIN}';
|
||||
const BRAND_DOMAIN = '${brand.domain}';
|
||||
let editingField = null;
|
||||
|
||||
function showMsg(text, type) {
|
||||
const el = document.getElementById('msg');
|
||||
el.textContent = text;
|
||||
el.className = 'msg ' + type;
|
||||
setTimeout(() => { el.className = 'msg'; }, 5000);
|
||||
}
|
||||
|
||||
function openPasswordModal() {
|
||||
document.getElementById('password-modal').classList.add('active');
|
||||
}
|
||||
function closePasswordModal() {
|
||||
document.getElementById('password-modal').classList.remove('active');
|
||||
document.getElementById('old-password').value = '';
|
||||
document.getElementById('new-password').value = '';
|
||||
document.getElementById('confirm-password').value = '';
|
||||
document.getElementById('pw-error').style.display = 'none';
|
||||
}
|
||||
|
||||
async function changePassword() {
|
||||
const oldPw = document.getElementById('old-password').value;
|
||||
const newPw = document.getElementById('new-password').value;
|
||||
const confirm = document.getElementById('confirm-password').value;
|
||||
const errEl = document.getElementById('pw-error');
|
||||
|
||||
if (!oldPw || !newPw) { errEl.textContent = 'All fields required'; errEl.style.display = 'block'; return; }
|
||||
if (newPw.length < 8) { errEl.textContent = 'Password must be at least 8 characters'; errEl.style.display = 'block'; return; }
|
||||
if (newPw !== confirm) { errEl.textContent = 'Passwords do not match'; errEl.style.display = 'block'; return; }
|
||||
|
||||
try {
|
||||
const res = await fetch('/v1/iam/set-password', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Authorization: 'Bearer ' + TOKEN,
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
userOwner: '${fullUser ? fullUser.owner : 'hanzo'}',
|
||||
userName: '${fullUser ? fullUser.name : ''}',
|
||||
oldPassword: oldPw,
|
||||
newPassword: newPw,
|
||||
}).toString(),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.status === 'ok') {
|
||||
closePasswordModal();
|
||||
showMsg('Password updated successfully', 'success');
|
||||
} else {
|
||||
errEl.textContent = data.msg || 'Failed to update password';
|
||||
errEl.style.display = 'block';
|
||||
}
|
||||
} catch (e) {
|
||||
errEl.textContent = 'Network error';
|
||||
errEl.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
function editField(field, currentValue) {
|
||||
editingField = field;
|
||||
document.getElementById('edit-title').textContent = 'Edit ' + field.charAt(0).toUpperCase() + field.slice(1);
|
||||
document.getElementById('edit-value').value = currentValue;
|
||||
document.getElementById('edit-modal').classList.add('active');
|
||||
}
|
||||
function closeEditModal() {
|
||||
document.getElementById('edit-modal').classList.remove('active');
|
||||
editingField = null;
|
||||
}
|
||||
async function saveField() {
|
||||
if (!editingField) return;
|
||||
const value = document.getElementById('edit-value').value;
|
||||
try {
|
||||
const userObj = { owner: '${fullUser ? fullUser.owner : 'hanzo'}', name: '${fullUser ? fullUser.name : ''}' };
|
||||
if (editingField === 'name') userObj.displayName = value;
|
||||
const res = await fetch('/v1/iam/update-user?id=${fullUser ? fullUser.owner + '/' + fullUser.name : ''}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer ' + TOKEN,
|
||||
},
|
||||
body: JSON.stringify(userObj),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.status === 'ok') {
|
||||
closeEditModal();
|
||||
showMsg('Updated successfully. Refreshing...', 'success');
|
||||
setTimeout(() => location.reload(), 1000);
|
||||
} else {
|
||||
showMsg(data.msg || 'Update failed', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showMsg('Network error', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function unlinkProvider(provider) {
|
||||
if (!confirm('Unlink ' + provider + ' from your account?')) return;
|
||||
try {
|
||||
var field = provider === 'web3' ? 'metamask' : provider;
|
||||
var userObj = { owner: '${fullUser ? fullUser.owner : 'hanzo'}', name: '${fullUser ? fullUser.name : ''}' };
|
||||
userObj[field] = '';
|
||||
var res = await fetch('/v1/iam/update-user?id=${fullUser ? fullUser.owner + '/' + fullUser.name : ''}', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(userObj),
|
||||
});
|
||||
var data = await res.json();
|
||||
if (data.status === 'ok') {
|
||||
showMsg(provider + ' unlinked successfully', 'success');
|
||||
setTimeout(function() { location.reload(); }, 1000);
|
||||
} else {
|
||||
showMsg(data.msg || 'Failed to unlink ' + provider, 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showMsg('Network error: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function linkWallet() {
|
||||
if (typeof window.ethereum === 'undefined') {
|
||||
showMsg('Please install MetaMask or another Web3 wallet', 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
var accounts = await window.ethereum.request({ method: 'eth_requestAccounts' });
|
||||
var address = accounts[0];
|
||||
if (!address) { showMsg('No wallet account found', 'error'); return; }
|
||||
|
||||
// Sign a message to prove wallet ownership
|
||||
var message = 'Link wallet ' + address + ' to ' + BRAND_DOMAIN + ' account for ${fullUser ? fullUser.name : 'user'}';
|
||||
await window.ethereum.request({ method: 'personal_sign', params: [message, address] });
|
||||
|
||||
// Update user with wallet address
|
||||
var userObj = { owner: '${fullUser ? fullUser.owner : 'hanzo'}', name: '${fullUser ? fullUser.name : ''}', metamask: address };
|
||||
var res = await fetch('/v1/iam/update-user?id=${fullUser ? fullUser.owner + '/' + fullUser.name : ''}', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(userObj),
|
||||
});
|
||||
var data = await res.json();
|
||||
if (data.status === 'ok') {
|
||||
showMsg('Wallet linked: ' + address.slice(0, 6) + '...' + address.slice(-4), 'success');
|
||||
setTimeout(function() { location.reload(); }, 1500);
|
||||
} else {
|
||||
showMsg(data.msg || 'Failed to link wallet', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.code === 4001) return; // User rejected
|
||||
showMsg(err.message || 'Failed to connect wallet', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
if (!confirm('Are you sure? This action is permanent and cannot be undone.')) return;
|
||||
if (!confirm('This will permanently delete your account and all associated data. Type OK to confirm.')) return;
|
||||
showMsg('Account deletion requires verification. Redirecting to IAM...', 'error');
|
||||
window.location.href = 'https://iam.hanzo.ai/account';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function renderLoginRedirectPage(brand, loginUrl) {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Sign In Required - ${brand.name}</title>
|
||||
<meta http-equiv="refresh" content="2;url=${loginUrl}">
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: ${brand.bg};
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
}
|
||||
.card {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
border: 1px solid ${brand.border};
|
||||
border-radius: 12px;
|
||||
background: ${brand.surface};
|
||||
max-width: 400px;
|
||||
}
|
||||
a { color: ${brand.accent}; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
${brand.logo}
|
||||
<h2 style="margin-top:1rem;">Sign in to continue</h2>
|
||||
<p style="color:#888;margin-top:0.5rem;">Redirecting to ${brand.name} login...</p>
|
||||
<p style="margin-top:1rem;"><a href="${loginUrl}">Click here if not redirected</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request, env) {
|
||||
const url = new URL(request.url);
|
||||
const hostname = url.hostname;
|
||||
const pathname = url.pathname;
|
||||
const brand = getBrand(hostname);
|
||||
const callbackUrl = `https://${hostname}/callback`;
|
||||
|
||||
// Handle OAuth callback — exchange code for token
|
||||
if (pathname === '/callback') {
|
||||
const code = url.searchParams.get('code');
|
||||
if (!code) {
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { Location: buildLoginUrl(brand, callbackUrl) },
|
||||
});
|
||||
}
|
||||
|
||||
const token = await exchangeCode(code, callbackUrl, brand);
|
||||
if (!token) {
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { Location: buildLoginUrl(brand, callbackUrl) },
|
||||
});
|
||||
}
|
||||
|
||||
// Set token cookie and redirect to account page
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: '/',
|
||||
'Set-Cookie': `account_token=${encodeURIComponent(token)}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=86400`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Handle logout
|
||||
if (pathname === '/logout') {
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: `https://${brand.domain}`,
|
||||
'Set-Cookie': 'account_token=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Proxy IAM API calls (for client-side JS). Same-origin /v1/iam/* from
|
||||
// the account page is forwarded to IAM; the access token is attached
|
||||
// server-side from the HttpOnly cookie so it never rides in page JS.
|
||||
if (pathname.startsWith('/v1/iam/')) {
|
||||
const token = getToken(request);
|
||||
const iamUrl = new URL(pathname + url.search, IAM_ORIGIN);
|
||||
const headers = new Headers(request.headers);
|
||||
headers.set('Host', new URL(IAM_ORIGIN).hostname);
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
const iamRes = await fetch(iamUrl.toString(), {
|
||||
method: request.method,
|
||||
headers,
|
||||
body: request.method !== 'GET' ? request.body : undefined,
|
||||
});
|
||||
|
||||
return new Response(iamRes.body, {
|
||||
status: iamRes.status,
|
||||
headers: {
|
||||
'content-type': iamRes.headers.get('content-type') || 'application/json',
|
||||
'cache-control': 'no-store',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Check authentication for all other routes
|
||||
const token = getToken(request);
|
||||
if (!token) {
|
||||
const loginUrl = buildLoginUrl(brand, callbackUrl);
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { Location: loginUrl },
|
||||
});
|
||||
}
|
||||
|
||||
// Get user info
|
||||
const user = await getUserInfo(token);
|
||||
if (!user || !user.name) {
|
||||
// Token expired or invalid — re-authenticate
|
||||
const loginUrl = buildLoginUrl(brand, callbackUrl);
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: loginUrl,
|
||||
'Set-Cookie': 'account_token=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Get full user object for detailed info
|
||||
const fullUser = await getUser(token, user.owner || 'hanzo', user.preferred_username || user.name);
|
||||
|
||||
// Serve account page
|
||||
return htmlResponse(renderAccountPage(brand, user, fullUser));
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
name = "account-portal"
|
||||
main = "src/worker.js"
|
||||
compatibility_date = "2024-01-01"
|
||||
workers_dev = false
|
||||
|
||||
# Custom domains are managed via CF Workers Custom Domains API:
|
||||
# account.hanzo.id -> zone hanzo.id
|
||||
# account.lux.id -> zone lux.id
|
||||
# DNS records and SSL certs are automatically provisioned.
|
||||
|
||||
[vars]
|
||||
IAM_ORIGIN = "https://iam.hanzo.ai"
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "@hanzo/id-web",
|
||||
"private": true,
|
||||
"version": "0.1.26",
|
||||
"description": "Hanzo ID \u2014 white-label login / signup / IDV portal. Vite + React 19 + @hanzo/gui. Same image serves hanzo.id / lux.id / zoo.id / pars.id.",
|
||||
"version": "0.1.34",
|
||||
"description": "Hanzo ID \u2014 white-label login / signup / IDV portal. Vite + React 19, styled from @hanzo/design tokens. Same image serves hanzo.id / lux.id / zoo.id / pars.id.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -13,8 +13,9 @@
|
||||
"dependencies": {
|
||||
"@crossmarkio/sdk": "^0.4.0",
|
||||
"@hanzo/brand": "^1.3.0",
|
||||
"@hanzo/gui": "^7.2.4",
|
||||
"@hanzo/iam": "^0.13.1",
|
||||
"@hanzo/design": "^0.4.9",
|
||||
"@hanzo/event": "^0.3.11",
|
||||
"@hanzo/iam": "^0.21.1",
|
||||
"@hanzo/id-auth": "workspace:*",
|
||||
"@hanzo/id-connect": "workspace:*",
|
||||
"@hanzo/id-idv": "workspace:*",
|
||||
|
||||
+21
-21
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { loadBrand, parseCatalog, resolveTenant, type BrandContract, type TenantConfig } from '@hanzo/id-shared'
|
||||
import { loadBrand, catalogOf, parseCatalog, resolveOrg, idBrandLabel, type BrandContract, type OrgConfig } from '@hanzo/id-shared'
|
||||
import { createAuthClient } from '@hanzo/id-auth'
|
||||
import { Portal } from './pages/Portal'
|
||||
import { Login } from './pages/Login'
|
||||
@@ -10,45 +10,45 @@ import { Onboarding } from './pages/Onboarding'
|
||||
import { DeviceApproval } from './pages/DeviceApproval'
|
||||
|
||||
/**
|
||||
* Top-level wiring. Resolves tenant + brand once on mount, then routes via
|
||||
* Top-level wiring. Resolves org + brand once on mount, then routes via
|
||||
* `window.location.pathname`. No router lib needed — this app is 5 pages,
|
||||
* `<a href>` is enough. Adding paths is a switch case.
|
||||
*/
|
||||
export function App() {
|
||||
const [tenant, setTenant] = useState<TenantConfig | null>(null)
|
||||
const [org, setOrg] = useState<OrgConfig | null>(null)
|
||||
const [brand, setBrand] = useState<BrandContract | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
async function boot() {
|
||||
// The runtime serves the per-host tenant catalog at /config.json
|
||||
// (templated from SPA_IAM_TENANT_CONFIG_JSON by the static server). Read
|
||||
// it from there — NOT a `window.__ID_CATALOG__` global, which the runtime
|
||||
// never injects (relying on it silently dropped every catalog-only host,
|
||||
// e.g. osage.id, to the bundled Hanzo default). Fall back to the inlined
|
||||
// global, then empty, so a host always resolves to something.
|
||||
// The runtime serves the per-host org catalog at /config.json — NOT a
|
||||
// `window.__ID_CATALOG__` global, which the runtime never injects
|
||||
// (relying on it silently dropped every catalog-only host, e.g. osage.id,
|
||||
// to the bundled Hanzo default). Fall back to the global, then empty, so a
|
||||
// host always resolves to something.
|
||||
//
|
||||
// `catalogOf` owns which key that payload uses — the name is the server's,
|
||||
// not ours (see its doc). Reading the wrong one is a silent total-catalog
|
||||
// outage, so it is pinned by a test next to the resolver it feeds.
|
||||
let catalogRaw: string | undefined
|
||||
try {
|
||||
const res = await fetch('/config.json', { cache: 'no-store' })
|
||||
if (res.ok) {
|
||||
const cfg = (await res.json()) as { iamTenantConfigJson?: string }
|
||||
catalogRaw = cfg.iamTenantConfigJson
|
||||
}
|
||||
if (res.ok) catalogRaw = catalogOf(await res.json())
|
||||
} catch {
|
||||
// network/parse error → fall back below
|
||||
}
|
||||
if (!catalogRaw) {
|
||||
catalogRaw = (window as unknown as { __ID_CATALOG__?: string }).__ID_CATALOG__
|
||||
}
|
||||
const t = resolveTenant(window.location.hostname, { catalog: parseCatalog(catalogRaw) })
|
||||
const t = resolveOrg(window.location.hostname, { catalog: parseCatalog(catalogRaw) })
|
||||
if (cancelled) return
|
||||
setTenant(t)
|
||||
setOrg(t)
|
||||
try {
|
||||
const b = await loadBrand(t.brandPackage)
|
||||
if (cancelled) return
|
||||
setBrand(b)
|
||||
document.title = `Sign in — ${b.name}`
|
||||
document.title = idBrandLabel(b, t.orgId)
|
||||
const fav = document.getElementById('favicon') as HTMLLinkElement | null
|
||||
if (fav && b.faviconUrl) fav.href = b.faviconUrl
|
||||
} catch (e) {
|
||||
@@ -61,10 +61,10 @@ export function App() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const client = useMemo(() => (tenant ? createAuthClient({ tenant }) : null), [tenant])
|
||||
const client = useMemo(() => (org ? createAuthClient({ org }) : null), [org])
|
||||
|
||||
if (error) return <div className="hanzo-id-error">{error}</div>
|
||||
if (!tenant || !brand || !client) return <div>Loading…</div>
|
||||
if (!org || !brand || !client) return <div>Loading…</div>
|
||||
|
||||
const path = window.location.pathname
|
||||
// Device-authorization approval (RFC 8628). Must precede the `/login` catch
|
||||
@@ -74,7 +74,7 @@ export function App() {
|
||||
if (path === '/login' || path.startsWith('/login/')) return <Login client={client} brand={brand} />
|
||||
if (path === '/signup' || path.startsWith('/signup/')) return <Signup client={client} brand={brand} />
|
||||
if (path === '/forget' || path === '/forgot' || path.startsWith('/forg')) return <Forgot client={client} brand={brand} />
|
||||
if (path === '/callback' || path.startsWith('/callback/')) return <Callback tenant={tenant} brand={brand} />
|
||||
if (path === '/onboarding' || path.startsWith('/onboarding/')) return <Onboarding tenant={tenant} brand={brand} />
|
||||
return <Portal client={client} brand={brand} tenant={tenant} />
|
||||
if (path === '/callback' || path.startsWith('/callback/')) return <Callback org={org} brand={brand} />
|
||||
if (path === '/onboarding' || path.startsWith('/onboarding/')) return <Onboarding org={org} brand={brand} />
|
||||
return <Portal client={client} brand={brand} org={org} />
|
||||
}
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* The telemetry gate, tested where it is actually true or false: on the bytes
|
||||
* the client puts on the wire.
|
||||
*
|
||||
* The defect this guards is not hypothetical and is not visible in review. The
|
||||
* obvious way to keep an OAuth code out of telemetry — "send the pathname, never
|
||||
* the href" — DOES NOT WORK against @hanzo/event, because `build()` stamps
|
||||
* `url: window.location.href` onto every event it assembles regardless of the
|
||||
* `path` the caller passed. A pageview from `/callback?code=…&state=…` therefore
|
||||
* ships the authorization code while `path` reads a clean `/callback`, and the
|
||||
* client's scrubber does not catch it: that scrubber redacts secret SHAPES
|
||||
* (JWT, sk-/pk-/hk-, bearer, cloud keys, PAN) and an opaque authorization code
|
||||
* is not one.
|
||||
*
|
||||
* So the gate is "do not emit from a route whose URL carries a credential", and
|
||||
* the test below asserts BOTH halves: that gated routes emit nothing, and that
|
||||
* the same setup ungated really does leak. The second half is what keeps this
|
||||
* from decaying into a decorative assertion — if @hanzo/event ever stops putting
|
||||
* the href on the wire, that case fails and this whole file can be revisited.
|
||||
*/
|
||||
import { test } from 'vitest'
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { createAnalytics } from '@hanzo/event'
|
||||
import { telemetryAllowed, consented } from './analytics'
|
||||
|
||||
// ── the route gate ──────────────────────────────────────────────────────────
|
||||
|
||||
test('auth-artifact routes are refused, funnel routes are not', () => {
|
||||
// Carry a credential in the query string -> must never emit.
|
||||
for (const p of [
|
||||
'/callback',
|
||||
'/callback/',
|
||||
'/callback/anything',
|
||||
'/login/oauth/device',
|
||||
'/login/oauth/device/',
|
||||
'/login/oauth/device/WDJB-MJHT',
|
||||
]) {
|
||||
assert.equal(telemetryAllowed(p), false, `${p} must not emit`)
|
||||
}
|
||||
|
||||
// The funnel this exists to measure: arrival -> sign-in -> session.
|
||||
for (const p of [
|
||||
'/',
|
||||
'/login',
|
||||
'/login/',
|
||||
'/signup',
|
||||
'/forgot',
|
||||
'/forget',
|
||||
'/onboarding',
|
||||
'/callbacks', // near-miss: a real route that merely starts the same way
|
||||
'/login/oauth', // the device path is the specific one, not all of oauth
|
||||
]) {
|
||||
assert.equal(telemetryAllowed(p), true, `${p} must emit`)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* The gate is written against literal paths, and App.tsx dispatches against its
|
||||
* own. If someone adds a route that lands on the callback or device page, this
|
||||
* fails rather than silently starting to ship codes — the same reason the token
|
||||
* suite computes from what the bundle serves instead of trusting a list.
|
||||
*/
|
||||
test('every auth-artifact route App.tsx dispatches is covered by the gate', () => {
|
||||
const app = fs.readFileSync(path.join(import.meta.dirname, 'App.tsx'), 'utf8')
|
||||
|
||||
// Route literals compared in App.tsx: path === '…' / path.startsWith('…').
|
||||
const routes = [...app.matchAll(/path\s*(?:===\s*|\.startsWith\(\s*)'([^']+)'/g)].map((m) => m[1]!)
|
||||
assert.ok(routes.length >= 10, `expected App.tsx route literals, found ${routes.length}`)
|
||||
|
||||
for (const r of routes) {
|
||||
const isAuthArtifact = r.startsWith('/callback') || r.startsWith('/login/oauth/device')
|
||||
if (isAuthArtifact) {
|
||||
assert.equal(telemetryAllowed(r), false, `App.tsx routes ${r} to an auth-artifact page; gate it`)
|
||||
}
|
||||
}
|
||||
|
||||
// Both pages are actually reachable — the gate is not guarding dead routes.
|
||||
assert.ok(routes.some((r) => r.startsWith('/callback')), 'App.tsx must route /callback')
|
||||
assert.ok(
|
||||
routes.some((r) => r.startsWith('/login/oauth/device')),
|
||||
'App.tsx must route /login/oauth/device',
|
||||
)
|
||||
})
|
||||
|
||||
// ── consent ─────────────────────────────────────────────────────────────────
|
||||
|
||||
test('an explicit browser opt-out turns everything off', () => {
|
||||
assert.equal(consented({ globalPrivacyControl: true }), false)
|
||||
assert.equal(consented({ doNotTrack: '1' }), false)
|
||||
assert.equal(consented({ doNotTrack: 'yes' }), false)
|
||||
|
||||
assert.equal(consented(), true)
|
||||
assert.equal(consented({}), true)
|
||||
assert.equal(consented({ globalPrivacyControl: false, doNotTrack: '0' }), true)
|
||||
assert.equal(consented({ doNotTrack: null }), true)
|
||||
})
|
||||
|
||||
// ── the wire ────────────────────────────────────────────────────────────────
|
||||
|
||||
const CODE = 'AUTHCODE_abc123XYZ'
|
||||
const STATE = 'STATE_deadbeef'
|
||||
const USER_CODE = 'WDJB-MJHT'
|
||||
|
||||
/** Installs the browser globals @hanzo/event reads, at a given location. */
|
||||
function atLocation(href: string, pathname: string, search: string) {
|
||||
const store: Record<string, string> = {}
|
||||
const localStorage = {
|
||||
getItem: (k: string) => store[k] ?? null,
|
||||
setItem: (k: string, v: string) => void (store[k] = String(v)),
|
||||
removeItem: (k: string) => void delete store[k],
|
||||
}
|
||||
const g = globalThis as Record<string, unknown>
|
||||
g.window = {
|
||||
location: { href, pathname, search, hostname: 'hanzo.id', origin: 'https://hanzo.id' },
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
localStorage,
|
||||
screen: { width: 1440, height: 900 },
|
||||
}
|
||||
g.document = {
|
||||
referrer: '',
|
||||
title: 'Sign in',
|
||||
visibilityState: 'visible',
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
}
|
||||
g.localStorage = localStorage
|
||||
g.screen = { width: 1440, height: 900 }
|
||||
g.location = (g.window as { location: unknown }).location
|
||||
}
|
||||
|
||||
function clearLocation() {
|
||||
const g = globalThis as Record<string, unknown>
|
||||
delete g.window
|
||||
delete g.document
|
||||
delete g.localStorage
|
||||
delete g.screen
|
||||
delete g.location
|
||||
}
|
||||
|
||||
/** Runs the client exactly as mounted and returns everything it tried to send. */
|
||||
function wireFrom(href: string, pathname: string, search: string, enabled: boolean): string {
|
||||
atLocation(href, pathname, search)
|
||||
try {
|
||||
const sent: string[] = []
|
||||
const client = createAnalytics({
|
||||
product: 'id',
|
||||
host: 'https://api.hanzo.ai',
|
||||
ingestKey: 'pk-live-TESTKEY',
|
||||
enabled,
|
||||
transport: { send: (_url: string, body: string) => void sent.push(body) },
|
||||
})
|
||||
client.init()
|
||||
client.pageview(pathname) // pathname only — the mitigation that is NOT enough
|
||||
client.captureError(new Error('boom'))
|
||||
client.flush()
|
||||
return sent.join('')
|
||||
} finally {
|
||||
clearLocation()
|
||||
}
|
||||
}
|
||||
|
||||
test('a gated auth-artifact route puts nothing on the wire', () => {
|
||||
const cb = wireFrom(
|
||||
`https://hanzo.id/callback?code=${CODE}&state=${STATE}`,
|
||||
'/callback',
|
||||
`?code=${CODE}&state=${STATE}`,
|
||||
telemetryAllowed('/callback'),
|
||||
)
|
||||
assert.equal(cb, '', 'the callback route must emit nothing at all')
|
||||
assert.ok(!cb.includes(CODE), 'authorization code must never reach the wire')
|
||||
assert.ok(!cb.includes(STATE), 'state must never reach the wire')
|
||||
|
||||
const dev = wireFrom(
|
||||
`https://hanzo.id/login/oauth/device?user_code=${USER_CODE}`,
|
||||
'/login/oauth/device',
|
||||
`?user_code=${USER_CODE}`,
|
||||
telemetryAllowed('/login/oauth/device'),
|
||||
)
|
||||
assert.equal(dev, '', 'the device route must emit nothing at all')
|
||||
assert.ok(!dev.includes(USER_CODE), 'device user_code must never reach the wire')
|
||||
})
|
||||
|
||||
/**
|
||||
* The reason the gate exists. Passing a clean pathname is NOT what protects the
|
||||
* code — if this ever stops leaking, @hanzo/event changed and the gate's
|
||||
* justification should be re-read.
|
||||
*/
|
||||
test('without the gate, a clean pathname still leaks the code (why the gate exists)', () => {
|
||||
const leaked = wireFrom(
|
||||
`https://hanzo.id/callback?code=${CODE}&state=${STATE}`,
|
||||
'/callback',
|
||||
`?code=${CODE}&state=${STATE}`,
|
||||
true, // ungated
|
||||
)
|
||||
assert.ok(leaked.includes(CODE), 'expected the ungated client to leak the code via `url`')
|
||||
assert.ok(leaked.includes(STATE), 'expected the ungated client to leak the state via `url`')
|
||||
assert.ok(leaked.includes('"path":"/callback"'), 'and to report a clean path while doing it')
|
||||
})
|
||||
|
||||
test('funnel routes do report, and carry no credential', () => {
|
||||
for (const p of ['/', '/login', '/signup', '/onboarding']) {
|
||||
const wire = wireFrom(`https://hanzo.id${p}`, p, '', telemetryAllowed(p))
|
||||
assert.ok(wire.includes('"$pageview"'), `${p} must report a pageview`)
|
||||
assert.ok(wire.includes('"product":"id"'), `${p} must attribute to the id product`)
|
||||
for (const secret of [CODE, STATE, USER_CODE]) {
|
||||
assert.ok(!wire.includes(secret), `${p} must not carry ${secret}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('an opted-out visitor emits nothing even on a funnel route', () => {
|
||||
const wire = wireFrom(
|
||||
'https://hanzo.id/login',
|
||||
'/login',
|
||||
'',
|
||||
consented({ globalPrivacyControl: true }) && telemetryAllowed('/login'),
|
||||
)
|
||||
assert.equal(wire, '', 'GPC must suppress the whole client')
|
||||
})
|
||||
@@ -0,0 +1,138 @@
|
||||
// Telemetry for the sign-in portal — pageviews and errors, anonymous, via the
|
||||
// ONE @hanzo/event client (POST /v1/event, the front door cloud fans out into
|
||||
// the web / product / error lenses). No page tag, no second SDK.
|
||||
//
|
||||
// This surface reported NOTHING before this file existed, which is why the
|
||||
// arrival->session funnel had no denominator: hanzo.id is where every property's
|
||||
// visitor lands, and none of it was attributable.
|
||||
//
|
||||
// It is also an AUTH surface, so what is NOT here is deliberate:
|
||||
//
|
||||
// - no identify(). Attribution here is anonymous; @hanzo/event stamps a
|
||||
// per-browser `anonymousId` that survives sign-up, so the visitor's
|
||||
// pre-signup pageviews still join to whoever they become once a
|
||||
// post-auth surface (chat/console) identifies them. Reading the IAM
|
||||
// subject would mean wiring this into the auth context for a join that
|
||||
// already happens downstream.
|
||||
// - no interaction autocapture (@hanzo/observe). Heat maps answer "where do
|
||||
// they click"; the question this funnel exists to answer is "did they get a
|
||||
// session", which pageviews answer completely. Autocapture on the login and
|
||||
// signup forms is capture surface bought for no funnel signal.
|
||||
// - no session replay, no input capture, no email/name.
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { AnalyticsProvider as EventProvider, usePageview } from '@hanzo/event/react'
|
||||
|
||||
const HOST = 'https://api.hanzo.ai'
|
||||
|
||||
/**
|
||||
* Publishable ingest key (pk-…), inlined by Vite from the build env. Write-only:
|
||||
* it attributes a write to ONE org and mints no reading principal, which is what
|
||||
* makes it safe in a bundle — and it is the ONLY thing that attributes a
|
||||
* LOGGED-OUT visitor, which on a sign-in portal is nearly all of them.
|
||||
*
|
||||
* Absent is not a degraded mode: cloud takes an unkeyed beacon down the anonymous
|
||||
* lane and files it under `$public`, a tenant this org cannot read, and answers
|
||||
* 200 either way. The loss is silent on both ends, so the Dockerfile fails the
|
||||
* build rather than letting an empty value ship. Never hardcode a value here.
|
||||
*/
|
||||
const INGEST_KEY = import.meta.env.VITE_PUBLISHABLE_KEY?.trim() || undefined
|
||||
|
||||
/**
|
||||
* Routes whose URL carries an authentication artifact.
|
||||
*
|
||||
* `/callback` holds the OAuth authorization `code` and `state`; the device
|
||||
* verification URI holds a `user_code`. Both sit in the QUERY STRING, and
|
||||
* @hanzo/event stamps `url: window.location.href` onto every event it builds —
|
||||
* independently of the `path` a caller passes. So passing a clean pathname does
|
||||
* NOT keep the code out of the payload; only not emitting does. Measured against
|
||||
* the real client, a pageview from `/callback?code=…&state=…` put both values on
|
||||
* the wire in cleartext while `path` read a tidy `/callback`.
|
||||
*
|
||||
* The client's scrubber does not save this either — it redacts secret SHAPES
|
||||
* (JWTs, sk-/pk-/hk-, bearer, cloud keys, PANs) and an opaque authorization code
|
||||
* matches none of them.
|
||||
*
|
||||
* Neither route is a funnel step: both are transient machine hops that redirect
|
||||
* onward within a tick. The funnel is `/` -> `/login` -> `/onboarding`, and every
|
||||
* one of those still reports. Dropping these two costs no signal and removes the
|
||||
* entire class of credential leak. See analytics.test.ts.
|
||||
*/
|
||||
const AUTH_ARTIFACT = /^\/(callback|login\/oauth\/device)(\/|$)/
|
||||
|
||||
/** telemetryAllowed reports whether a path may emit at all. Pure. */
|
||||
export function telemetryAllowed(pathname: string): boolean {
|
||||
return !AUTH_ARTIFACT.test(pathname)
|
||||
}
|
||||
|
||||
/**
|
||||
* consented honours an explicit browser opt-out — Global Privacy Control, then
|
||||
* legacy Do-Not-Track. This is the whole consent surface, and it suppresses
|
||||
* pageviews AND errors together: a visitor who opted out is not "mostly" off.
|
||||
* Pure with respect to its argument so the policy is testable without a DOM.
|
||||
*/
|
||||
export function consented(nav?: {
|
||||
globalPrivacyControl?: boolean
|
||||
doNotTrack?: string | null
|
||||
}): boolean {
|
||||
if (!nav) return true
|
||||
if (nav.globalPrivacyControl === true) return false
|
||||
const dnt = nav.doNotTrack
|
||||
return dnt !== '1' && dnt !== 'yes'
|
||||
}
|
||||
|
||||
/** Reads the live opt-out signals off `navigator`, or none outside a browser. */
|
||||
function browserConsent(): boolean {
|
||||
if (typeof navigator === 'undefined') return true
|
||||
return consented(navigator as Navigator & { globalPrivacyControl?: boolean })
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires a pageview on SPA route changes.
|
||||
*
|
||||
* Inert today, and deliberately kept: this app navigates with
|
||||
* `window.location.assign/replace`, so every route change is a fresh document
|
||||
* and the provider's own initial pageview counts each page exactly once
|
||||
* (`usePageview` skips its first mount for precisely that reason — it would
|
||||
* otherwise double-count). `@tanstack/react-router` is a declared dependency
|
||||
* that nothing imports; the day someone mounts it, navigation stops reloading
|
||||
* the document and this is what keeps pageviews from silently going to zero.
|
||||
*
|
||||
* It is fed the PATHNAME, never `location.href`, and only when the path is
|
||||
* allowed to emit — `usePageview` no-ops on a null path.
|
||||
*/
|
||||
function RouteViews() {
|
||||
const [pathname, setPathname] = useState(() =>
|
||||
typeof window === 'undefined' ? '/' : window.location.pathname,
|
||||
)
|
||||
useEffect(() => {
|
||||
const sync = () => setPathname(window.location.pathname)
|
||||
window.addEventListener('popstate', sync)
|
||||
return () => window.removeEventListener('popstate', sync)
|
||||
}, [])
|
||||
usePageview(telemetryAllowed(pathname) ? pathname : null)
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounts the client. `enabled` is the single gate every plane reads — it stops
|
||||
* init, enqueue, flush and the error handlers alike, so an off state emits
|
||||
* nothing at all rather than emitting less.
|
||||
*
|
||||
* The gate is evaluated once per document, which is exact here BECAUSE
|
||||
* navigation is full-page: the path a document is loaded at is the path it dies
|
||||
* at, so there is no window in which a `/callback` load is measured under an
|
||||
* earlier route's decision.
|
||||
*/
|
||||
export function Analytics({ children }: { children: ReactNode }) {
|
||||
const pathname = typeof window === 'undefined' ? '/' : window.location.pathname
|
||||
const enabled = browserConsent() && telemetryAllowed(pathname)
|
||||
|
||||
return (
|
||||
<EventProvider config={{ product: 'id', host: HOST, ingestKey: INGEST_KEY, enabled }}>
|
||||
<RouteViews />
|
||||
{children}
|
||||
</EventProvider>
|
||||
)
|
||||
}
|
||||
+512
-125
@@ -1,118 +1,415 @@
|
||||
/* Hanzo ID — the styling layer for hanzo.id / lux.id / zoo.id / pars.id.
|
||||
*
|
||||
* TOKENS COME FROM @hanzo/design. Not one colour, radius or type size is
|
||||
* invented here; every value below resolves to a design token, so a token change
|
||||
* lands on all four brand portals AND on pay.hanzo.ai — the other half of the
|
||||
* same sign-in-then-pay flow — at once.
|
||||
*
|
||||
* ARCHITECTURE RULE, learned the hard way. A component's surface must never
|
||||
* depend on WHERE it is mounted. This file used to paint controls with the
|
||||
* descendant selectors `form input {…}` and `.hanzo-id-btn, form button {…}`,
|
||||
* so any control that escaped a <form> ancestor fell out of the stylesheet and
|
||||
* rendered as raw UA chrome: the device-approval screen (2 inputs, 0 forms)
|
||||
* showed a 31px beveled browser input next to correctly-styled 44px siblings.
|
||||
* That is the same class of defect as a distributed component shipping utility
|
||||
* class names with no CSS behind them. Every rule below is keyed to a CLASS the
|
||||
* component itself carries — `.hanzo-id-input`, `.hanzo-id-btn`,
|
||||
* `.hanzo-id-field`, `.hanzo-id-form` — and there are no element-descendant
|
||||
* selectors for surface anywhere in this file.
|
||||
*/
|
||||
|
||||
/* ONE import, the whole token layer. This file used to cherry-pick four of the
|
||||
* nine token groups, which meant z, elevation, spacing, fonts and the element
|
||||
* defaults simply did not exist here — and an absent group is invisible: an
|
||||
* unresolved var() paints nothing and reports no error. @hanzo/iam's account
|
||||
* menu alone reaches for --z-popover, --shadow-floating and --space-1..3, none
|
||||
* of which the four-group subset carried.
|
||||
*
|
||||
* The reason for cherry-picking is gone: as of @hanzo/design 0.3.0 Geist is
|
||||
* SELF-HOSTED inside the package (two variable woff2, SIL OFL-1.1), so
|
||||
* tokens/fonts.css no longer makes a request to fonts.googleapis.com and the
|
||||
* sign-in path can take the typeface along with the colours. */
|
||||
@import '@hanzo/design/styles.css';
|
||||
|
||||
:root {
|
||||
--brand: #ffffff;
|
||||
--bg: #0a0a0a;
|
||||
--fg: #fafafa;
|
||||
--muted: #a3a3a3;
|
||||
--border: #262626;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #root { height: 100%; margin: 0; }
|
||||
/* 100dvh, with 100vh left underneath it as the fallback for anything that does
|
||||
not know the unit. On a phone `vh` resolves against the LARGEST viewport —
|
||||
the one with the URL bar retracted — so a bar that is actually on screen
|
||||
makes a "full height" page taller than the space it has, and a login page
|
||||
with one card on it acquires a scrollbar and sits low in the window. `dvh`
|
||||
is the height that is really there. */
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
/* NO focus rule here any more — tokens/base.css ships
|
||||
`:focus-visible{outline:2px solid var(--ring);outline-offset:2px}` to every
|
||||
consumer, and as of 0.3.0 --ring is var(--neutral-500), which measures 4.43:1
|
||||
on --background (WCAG 2.4.13 wants 3:1). The local override that painted the
|
||||
ring --primary existed only because --ring was #333333 at 1.66:1; that was a
|
||||
finding against @hanzo/design, it has been fixed there, so the workaround
|
||||
goes. One focus indicator, defined once, in the design system. */
|
||||
|
||||
/* ── Page shell ────────────────────────────────────────────────────── */
|
||||
|
||||
.hanzo-id-page {
|
||||
flex: 1;
|
||||
/* #root is not a flex container, so `flex: 1` alone never stretches this to
|
||||
the viewport — pin a min height so `main`'s justify-content:center has room
|
||||
to vertically center the auth card (box-sizing:border-box folds in padding).
|
||||
dvh for the reason given on `body`; the vh line is the fallback. */
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
/* index.html declares `viewport-fit=cover`, which puts the page UNDER the
|
||||
notch, the rounded corners and the home indicator. Declaring cover without
|
||||
consuming the insets is strictly worse than not declaring it, and nothing
|
||||
in this stylesheet consumed them: `max()` keeps the 24px gutter everywhere
|
||||
it is already enough and only grows it where the hardware intrudes, so
|
||||
nothing moves on a device with no insets (env() is 0px there). */
|
||||
padding: max(24px, env(safe-area-inset-top)) max(24px, env(safe-area-inset-right))
|
||||
max(24px, env(safe-area-inset-bottom)) max(24px, env(safe-area-inset-left));
|
||||
}
|
||||
|
||||
.hanzo-id-brand-header {
|
||||
padding: 16px 0 32px;
|
||||
.hanzo-id-brand-header { padding: 16px 0 32px; }
|
||||
|
||||
/* The home link wraps a 32px mark but measured 32x18 — an inline <a> takes its
|
||||
box from the LINE BOX of its own font, not from a replaced child, so the tap
|
||||
target was SMALLER than the logo inside it. inline-flex gives the anchor the
|
||||
mark's real box; the padding/negative-margin pair then grows the hit area to
|
||||
the 44px floor while leaving the mark exactly where it was — the padding
|
||||
offsets the margin, so the logo's painted position and the header's height
|
||||
are both unchanged, and only the invisible target bleeds into the gutter. */
|
||||
.hanzo-id-brand-header a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
box-sizing: content-box;
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
padding: 6px;
|
||||
margin: -6px;
|
||||
}
|
||||
|
||||
/* The lockup is 32px tall for EVERY brand, and it has to be said here.
|
||||
BrandHeader writes `height={32}`, but that is a presentational attribute and
|
||||
@hanzo/design's `:where(img,video){height:auto}` overrides it — so the logo
|
||||
was sized by whatever the brand package happened to ship, not by this page.
|
||||
The two assets differ in exactly the way that hides it: @hanzo/brand's SVG
|
||||
carries a viewBox and no width/height, so it has no intrinsic size and landed
|
||||
near the intended 32; @luxfi/brand's declares 1024x1024, so it took the full
|
||||
column and rendered a 342px mark over lux.id's sign-in form. Same markup,
|
||||
same CSS, opposite results, and only the brand nobody was looking at broke.
|
||||
Pinning the height and letting width follow makes the header's own
|
||||
declaration true again whatever a brand ships. */
|
||||
.hanzo-id-brand-header img { height: 32px; width: auto; }
|
||||
|
||||
/* Text fallback when a brand ships no logo asset (see BrandHeader). */
|
||||
.hanzo-id-wordmark {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--fg);
|
||||
font-size: var(--text-xl);
|
||||
font-weight: var(--weight-bold);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.hanzo-id-page main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.hanzo-id-page h1 { margin: 0; font-size: 28px; }
|
||||
.hanzo-id-page .lede { color: var(--muted); margin: 0; }
|
||||
|
||||
form { display: flex; flex-direction: column; gap: 16px; }
|
||||
form label { display: flex; flex-direction: column; gap: 6px; font-size: 14px; color: var(--muted); }
|
||||
form input {
|
||||
background: #111;
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
font-size: 16px;
|
||||
/* The type scale is the design scale, five rungs: xs / sm / base / xl / 2xl.
|
||||
`h1` at --text-2xl is the SAME 24px as pay.hanzo.ai's `text-2xl` headings, so
|
||||
a heading does not change size when the flow crosses between the two. */
|
||||
.hanzo-id-page h1 {
|
||||
margin: 0;
|
||||
font-size: var(--text-2xl);
|
||||
line-height: var(--leading-2xl);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
}
|
||||
form input:focus { outline: 2px solid var(--brand); outline-offset: -1px; }
|
||||
.hanzo-id-page h2 { margin: 0; font-size: var(--text-xl); line-height: var(--leading-xl); }
|
||||
.hanzo-id-page .lede { color: var(--muted-foreground); margin: 0; font-size: var(--text-base); }
|
||||
|
||||
.hanzo-id-btn, form button {
|
||||
background: var(--fg);
|
||||
color: var(--bg);
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
/* ── Field ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.hanzo-id-form { display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
.hanzo-id-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.hanzo-id-input {
|
||||
/* `font: inherit` first: a bare <input>/<button> otherwise renders in the UA
|
||||
face (Arial), which put two typefaces inside one 432px card — including on
|
||||
the primary CTA. */
|
||||
font: inherit;
|
||||
font-size: var(--text-base);
|
||||
background: var(--white-05);
|
||||
color: var(--foreground);
|
||||
/* A control's resting edge is --border-control. 0.4.2 cut it back to the alpha
|
||||
ladder (.15) because a boundary that clears 3:1 on a near-black page IS a
|
||||
mid-grey box, and a form of them reads as a wireframe. The contrast budget
|
||||
went to --ring instead — the focus indicator is what a keyboard user
|
||||
navigates by, it is the only boundary still held at 3:1, and unlike a
|
||||
resting edge it is worth the loudness because it is transient. */
|
||||
border: 1px solid var(--border-control);
|
||||
/* 8px. 0.4.2 moved every control in the system to --radius-md, and tokens/
|
||||
base.css draws a bare field at it; --radius-sm (6px) keeps its job on
|
||||
genuinely small parts — badges, chips, menu rows — where 8px looks bubbly. */
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0 14px;
|
||||
min-height: 44px; /* the touch-target floor */
|
||||
width: 100%;
|
||||
}
|
||||
.hanzo-id-input::placeholder { color: var(--text-disabled); }
|
||||
.hanzo-id-input:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* Password reveal. The button sits INSIDE the field's box rather than beside it,
|
||||
so adding it costs no layout: the row is still one 44px control on one line,
|
||||
at every width, and nothing below it moves. */
|
||||
.hanzo-id-reveal { position: relative; display: flex; }
|
||||
/* Reserve the button's width so a long password never runs under the eye. 14px
|
||||
resting pad + 44px target = 58px, which is what the input gives back. */
|
||||
.hanzo-id-reveal .hanzo-id-input { padding-right: 58px; }
|
||||
.hanzo-id-revealbtn {
|
||||
font: inherit;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
/* 44x44 — the same touch floor every other control here holds, and the reason
|
||||
the button is sized rather than just padded: an icon button that is only as
|
||||
big as its 20px glyph is a miss on a phone, which is the device this whole
|
||||
feature is for. */
|
||||
width: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/* No box of its own: a second bordered surface inside the field would read as
|
||||
a nested control. The glyph IS the affordance. */
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
.hanzo-id-revealbtn:hover { color: var(--foreground); }
|
||||
/* Pressed = the password is currently visible. Worth a permanent, non-hover
|
||||
signal: the strongest cue that a credential is on screen should not depend on
|
||||
where the pointer is, and on touch there is no hover at all. */
|
||||
.hanzo-id-revealbtn[aria-pressed='true'] { color: var(--foreground); }
|
||||
|
||||
/* iOS Safari ZOOMS the viewport when a focused control's text is under 16px,
|
||||
and it does not zoom back out — the user is left on a magnified, sideways-
|
||||
scrolling sign-in page mid-credential. This rule used to be a COMMENT on
|
||||
font-size above claiming `--text-base` prevented that; --text-base is
|
||||
0.875rem = 14px, so the protection was never in effect on either credential
|
||||
field. The type scale has no 16px rung and should not grow one — 16 is not a
|
||||
design value here, it is the threshold in Safari's own zoom heuristic — so it
|
||||
is written as the literal it is, and scoped to touch-primary pointers so the
|
||||
desktop type ramp is untouched. */
|
||||
@media (pointer: coarse) {
|
||||
.hanzo-id-input { font-size: 16px; }
|
||||
}
|
||||
|
||||
/* Checkbox: the UA control, sized up from its 13px intrinsic box and tinted with
|
||||
the brand. Its touch target is the whole <label> row, which is why it is the
|
||||
one control here that is not 44px itself. */
|
||||
.hanzo-id-check {
|
||||
font: inherit;
|
||||
accent-color: var(--primary);
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
min-height: 0;
|
||||
margin-top: 2px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* ── Button — ONE primitive, two modifiers ─────────────────────────── */
|
||||
/* `.hanzo-id-btn` is filled (the primary action). `.ghost` is the secondary
|
||||
surface — social sign-in, org rows, Skip/Back. `.row` spreads content for a
|
||||
list row. There is no `.primary`: the base IS primary, so there is one and
|
||||
only one way to write the default button. */
|
||||
|
||||
.hanzo-id-btn {
|
||||
font: inherit;
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--weight-semibold);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
border: 1px solid transparent;
|
||||
/* Same 8px as the field it sits under. Two stacked controls in one 432px card
|
||||
cannot round differently, and the system draws buttons at 8-10px too. */
|
||||
border-radius: var(--radius-md);
|
||||
padding: 10px 16px;
|
||||
min-height: 44px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
display: inline-block;
|
||||
transition: background var(--duration-fast) var(--ease-out),
|
||||
border-color var(--duration-fast) var(--ease-out);
|
||||
}
|
||||
.hanzo-id-btn[aria-disabled='true'], form button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.hanzo-id-btn.primary { background: var(--brand); }
|
||||
.hanzo-id-btn:disabled,
|
||||
.hanzo-id-btn[aria-disabled='true'] { opacity: 0.5; cursor: not-allowed; }
|
||||
.hanzo-id-btn svg { flex: none; }
|
||||
|
||||
/* The filled button had no hover at all. It has declared `transition:
|
||||
background` since it was written and the system has shipped --primary-hover
|
||||
the whole time, so the most-clicked control on the portal — Sign in, Continue,
|
||||
Create account — was animating a property nothing ever changed. The ghost
|
||||
variant below got its hover and this one was simply missed.
|
||||
|
||||
`:not(.ghost)` rather than relying on order: this selector and the ghost
|
||||
hover both compute to (0,4,0), so with a bare `.hanzo-id-btn:hover` the two
|
||||
would tie and source order would decide which surface a ghost button lifts
|
||||
to. Saying which buttons are meant makes them unable to collide. */
|
||||
.hanzo-id-btn:not(.ghost):hover:not(:disabled):not([aria-disabled='true']) {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.hanzo-id-btn.ghost {
|
||||
background: var(--white-05);
|
||||
color: var(--foreground);
|
||||
border-color: var(--border-strong);
|
||||
font-weight: var(--weight-medium);
|
||||
}
|
||||
/* Hover is a SURFACE lift, not a brighter edge. This used to also set
|
||||
`border-color: var(--foreground)` — #ededed, 17.9:1 on --background — which
|
||||
painted a near-solid-white 1px wireframe around the two most-hovered controls
|
||||
on the page (Continue with GitHub / Continue with Google). Two problems with
|
||||
moving the edge on hover, and the second is the one that matters:
|
||||
|
||||
1. It read as a wireframe, not as a refined surface. The house hairline is a
|
||||
low-alpha step (--white-10/-15), ~4x quieter than #ededed.
|
||||
2. Brightening an edge is THEME-HOSTILE. Every rung brighter than
|
||||
--neutral-500 is worse on white: --neutral-400 measures 8.3:1 on black but
|
||||
only 2.52:1 on white, which would fail the same WCAG 1.4.11 floor the
|
||||
resting border is documented to hold in BOTH themes. There is no token
|
||||
that gets brighter on dark and darker on light, so the edge must not
|
||||
encode state at all.
|
||||
|
||||
So the border stays the constant control boundary (--border-strong, 4.43:1,
|
||||
both themes) and the background carries the state — the lift the `transition`
|
||||
on .hanzo-id-btn was already animating. */
|
||||
.hanzo-id-btn.ghost:hover:not(:disabled) { background: var(--white-10); }
|
||||
|
||||
.hanzo-id-btn.row { justify-content: space-between; text-align: left; width: 100%; }
|
||||
|
||||
.hanzo-id-cta-row { display: flex; gap: 12px; }
|
||||
.hanzo-id-footer-links { color: var(--muted); font-size: 14px; }
|
||||
.hanzo-id-footer-links a { color: var(--fg); }
|
||||
.hanzo-id-cta-row .hanzo-id-btn { flex: 1; }
|
||||
|
||||
.hanzo-id-linkbtn {
|
||||
font: inherit;
|
||||
background: none;
|
||||
border: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
padding: 4px 0;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 4px;
|
||||
}
|
||||
|
||||
.hanzo-id-footer-links { color: var(--muted-foreground); font-size: var(--text-sm); }
|
||||
/* "Forgot password?" / "Create account" / "Sign in" / "Back to sign in" are the
|
||||
only route changes on these pages that are not buttons, and they measured 17px
|
||||
tall — the line box of their own text. Vertical padding on an INLINE box is
|
||||
hit-tested but does not enter line-box height, so this buys the 44px target
|
||||
with zero layout movement and no change to the sentences they sit inside.
|
||||
(17 + 14 + 14 = 45.) The 14px bleed stays inside `main`'s 24px gap, so no two
|
||||
targets overlap. Horizontal size already clears 44px on every one of them. */
|
||||
.hanzo-id-footer-links a {
|
||||
color: var(--text-primary);
|
||||
padding: 14px 0;
|
||||
margin: -14px 0;
|
||||
}
|
||||
|
||||
/* A2P SMS consent disclosure (shown on phone/SMS surfaces). */
|
||||
.hanzo-id-sms-consent { color: var(--muted); font-size: 12px; line-height: 1.5; }
|
||||
.hanzo-id-sms-consent { color: var(--muted-foreground); font-size: var(--text-xs); line-height: var(--leading-relaxed); }
|
||||
.hanzo-id-sms-consent p { margin: 0 0 6px; }
|
||||
.hanzo-id-sms-consent-links { margin: 0; }
|
||||
.hanzo-id-sms-consent a { color: var(--fg); }
|
||||
.hanzo-id-sms-consent a { color: var(--text-primary); }
|
||||
|
||||
.hanzo-id-error {
|
||||
background: #2d0a0a;
|
||||
color: #ff7878;
|
||||
border: 1px solid #5a1414;
|
||||
/* ── Inline message ────────────────────────────────────────────────── */
|
||||
/* Red is one of the two hues @hanzo/design permits, and these are the system's
|
||||
own state tokens — the same #fca5a5 pay.hanzo.ai renders errors in. The
|
||||
informational variant used to be a blue (#78b8ff) that exists nowhere in the
|
||||
system; on a monochrome surface "information" is a neutral card. */
|
||||
|
||||
.hanzo-id-error,
|
||||
.hanzo-id-info {
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--leading-sm);
|
||||
margin: 0;
|
||||
}
|
||||
.hanzo-id-error {
|
||||
background: var(--state-error-bg);
|
||||
color: var(--state-error-text);
|
||||
border: 1px solid var(--state-error);
|
||||
}
|
||||
.hanzo-id-info {
|
||||
background: #0a1f2d;
|
||||
color: #78b8ff;
|
||||
border: 1px solid #14385a;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--card);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--white-10);
|
||||
}
|
||||
|
||||
/* ── Device-authorization approval ─────────────────────────────── */
|
||||
/* ── Loading ───────────────────────────────────────────────────────── */
|
||||
/* `.hanzo-id-spinner` used to be a class with NO rule behind it: the loading
|
||||
state measured 0px tall and was invisible on every portal. */
|
||||
|
||||
.hanzo-id-spinner {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin: 0 auto;
|
||||
border: 2px solid var(--white-15);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: var(--radius-full);
|
||||
animation: hanzo-id-spin 700ms linear infinite;
|
||||
}
|
||||
@keyframes hanzo-id-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ── Device-authorization approval ─────────────────────────────────── */
|
||||
|
||||
.hanzo-id-device main { gap: 18px; }
|
||||
.hanzo-id-device-prompt { color: var(--muted); font-size: 14px; line-height: 1.5; margin: 0; }
|
||||
.hanzo-id-device-prompt strong { color: var(--fg); }
|
||||
.hanzo-id-device-code-field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.hanzo-id-device-code-field span { color: var(--muted); font-size: 13px; }
|
||||
.hanzo-id-device-prompt {
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--leading-relaxed);
|
||||
margin: 0;
|
||||
}
|
||||
.hanzo-id-device-prompt strong { color: var(--text-primary); }
|
||||
.hanzo-id-device-code {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 22px;
|
||||
letter-spacing: 0.25em;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xl);
|
||||
letter-spacing: var(--tracking-widest);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.hanzo-id-device-confirm {
|
||||
@@ -120,104 +417,194 @@ form input:focus { outline: 2px solid var(--brand); outline-offset: -1px; }
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.hanzo-id-device-confirm input {
|
||||
width: auto;
|
||||
margin-top: 2px;
|
||||
flex: none;
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--leading-relaxed);
|
||||
}
|
||||
|
||||
/* ── Social / Web3 sign-in buttons ─────────────────────────────── */
|
||||
/* ── Forced TOTP enrollment ────────────────────────────────────────── */
|
||||
|
||||
.hanzo-id-mfa-enroll { display: flex; flex-direction: column; gap: 16px; }
|
||||
.hanzo-id-mfa-enroll h2 { margin: 0; font-size: var(--text-xl); }
|
||||
.hanzo-id-mfa-qr {
|
||||
align-self: center;
|
||||
background: var(--pure-white);
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-lg);
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
.hanzo-id-mfa-qr svg { width: 100%; height: 100%; display: block; }
|
||||
.hanzo-id-mfa-manual { font-size: var(--text-sm); color: var(--muted-foreground); }
|
||||
.hanzo-id-mfa-manual summary { cursor: pointer; }
|
||||
.hanzo-id-mfa-secret,
|
||||
.hanzo-id-mfa-recovery code {
|
||||
display: inline-block;
|
||||
margin-top: 8px;
|
||||
padding: 6px 10px;
|
||||
background: var(--white-05);
|
||||
border: 1px solid var(--white-10);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-mono);
|
||||
letter-spacing: var(--tracking-widest);
|
||||
word-break: break-all;
|
||||
}
|
||||
.hanzo-id-mfa-recovery { font-size: var(--text-sm); color: var(--muted-foreground); line-height: var(--leading-relaxed); }
|
||||
.hanzo-id-mfa-recovery code { letter-spacing: var(--tracking-normal); }
|
||||
|
||||
/* ── Social / Web3 sign-in ─────────────────────────────────────────── */
|
||||
/* These are `.hanzo-id-btn.ghost` — there is no separate social-button surface. */
|
||||
|
||||
.hanzo-id-social { display: flex; flex-direction: column; gap: 10px; }
|
||||
.hanzo-id-social-btn {
|
||||
|
||||
/* Wallet chain chooser — revealed under the single "Connect Wallet" button when
|
||||
the injected chain is ambiguous. Indented so the EVM/Solana options read as
|
||||
children of the wallet entry. */
|
||||
.hanzo-id-wallet-chains {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
background: #111;
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 11px 14px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
margin-left: 4px;
|
||||
padding-left: 12px;
|
||||
border-left: 1px solid var(--white-10);
|
||||
}
|
||||
.hanzo-id-social-btn:hover { border-color: #3a3a3a; background: #161616; }
|
||||
.hanzo-id-social-btn svg { flex: none; }
|
||||
|
||||
/* Labeled divider between social row and email form. */
|
||||
.hanzo-id-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.hanzo-id-divider::before,
|
||||
.hanzo-id-divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
background: var(--white-10);
|
||||
}
|
||||
.hanzo-id-divider span { padding: 0 12px; }
|
||||
|
||||
/* ── Onboarding flow ───────────────────────────────────────────── */
|
||||
/* ── Signed-in portal: the apps launcher ───────────────────────────── */
|
||||
/* These rules used to be an inline `style={{…}}` object on Portal.tsx carrying
|
||||
rgba(255,255,255,0.14), borderRadius 12, fontSize 13, fontWeight 600 and two
|
||||
bare opacities — six invented values for facts the token layer already
|
||||
states. They are classes now for the same reason as everything else in this
|
||||
file: a surface must not depend on where it is mounted. */
|
||||
|
||||
.hanzo-id-portal main { width: 100%; max-width: 760px; }
|
||||
|
||||
.hanzo-id-apps {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-6);
|
||||
}
|
||||
|
||||
.hanzo-id-applink {
|
||||
display: block;
|
||||
padding: var(--space-4);
|
||||
/* A tile whose affordance is carried by its label, not its edge — so this is
|
||||
the decorative hairline rung, not the 3:1 control boundary. --white-15 is
|
||||
the ladder rung the old rgba(255,255,255,0.14) was approximating. */
|
||||
border: 1px solid var(--white-15);
|
||||
border-radius: var(--radius-lg);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: border-color var(--duration-fast) var(--ease-out);
|
||||
}
|
||||
.hanzo-id-applink:hover { border-color: var(--border-strong); text-decoration: none; }
|
||||
.hanzo-id-applink-name { display: flex; justify-content: space-between; font-weight: var(--weight-semibold); }
|
||||
.hanzo-id-applink-name span[aria-hidden] { color: var(--text-tertiary); }
|
||||
.hanzo-id-applink-desc { color: var(--muted-foreground); font-size: var(--text-sm); margin-top: var(--space-1); }
|
||||
|
||||
/* The account control is a control, not a bar: cap it so the trigger (and the
|
||||
menu, which matches the trigger's width) reads at the size it does on every
|
||||
other Hanzo surface instead of spanning the whole 760px column. */
|
||||
.hanzo-id-portal-account { margin-top: var(--space-6); max-width: 260px; }
|
||||
|
||||
/* ── Onboarding flow ───────────────────────────────────────────────── */
|
||||
|
||||
.hanzo-id-onboarding { display: flex; flex-direction: column; gap: 24px; }
|
||||
.hanzo-id-onboarding-head { display: flex; flex-direction: column; gap: 6px; }
|
||||
.hanzo-id-onboarding-head h1 { margin: 0; font-size: 26px; }
|
||||
.hanzo-id-onboarding-head h1,
|
||||
.hanzo-id-onboarding-done h1 { margin: 0; font-size: var(--text-2xl); letter-spacing: var(--tracking-tight); }
|
||||
.hanzo-id-onboarding-body { display: flex; flex-direction: column; gap: 16px; }
|
||||
.hanzo-id-onboarding-done { display: flex; flex-direction: column; gap: 16px; }
|
||||
.hanzo-id-onboarding-done h1 { margin: 0; font-size: 26px; }
|
||||
|
||||
.hanzo-id-stepdots { display: flex; gap: 8px; }
|
||||
.hanzo-id-stepdots span {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--border);
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--white-10);
|
||||
}
|
||||
.hanzo-id-stepdots span.on { background: var(--brand); }
|
||||
.hanzo-id-stepdots span.on { background: var(--primary); }
|
||||
|
||||
.hanzo-id-org-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||
.hanzo-id-org-row {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #111;
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 14px 16px;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
.hanzo-id-slug-preview {
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--text-sm);
|
||||
margin: -8px 0 0;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.hanzo-id-org-row:hover { border-color: #3a3a3a; background: #161616; }
|
||||
.hanzo-id-org-slug { color: var(--muted); font-size: 13px; font-family: ui-monospace, monospace; }
|
||||
|
||||
.hanzo-id-linkbtn {
|
||||
background: none;
|
||||
border: 0;
|
||||
color: var(--fg);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
padding: 4px 0;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.hanzo-id-slug-preview { color: var(--muted); font-size: 13px; margin: -8px 0 0; font-family: ui-monospace, monospace; }
|
||||
|
||||
.hanzo-id-onboarding-actions { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
.hanzo-id-onboarding-actions .hanzo-id-btn { flex: 1; min-width: 120px; }
|
||||
.hanzo-id-btn.ghost { background: transparent; color: var(--fg); border: 1px solid var(--border); }
|
||||
.hanzo-id-btn.ghost:hover { border-color: #3a3a3a; }
|
||||
|
||||
.hanzo-id-summary { display: grid; grid-template-columns: auto 1fr; gap: 8px 16px; margin: 0; }
|
||||
.hanzo-id-summary dt { color: var(--muted); font-size: 14px; }
|
||||
.hanzo-id-summary dd { margin: 0; font-size: 14px; font-family: ui-monospace, monospace; }
|
||||
.hanzo-id-summary dt { color: var(--muted-foreground); font-size: var(--text-sm); }
|
||||
.hanzo-id-summary dd { margin: 0; font-size: var(--text-sm); font-family: var(--font-mono); }
|
||||
|
||||
/* Consent step */
|
||||
.hanzo-id-consent { display: flex; flex-direction: column; gap: 16px; }
|
||||
.hanzo-id-consent p { margin: 0; color: var(--muted-foreground); font-size: var(--text-sm); line-height: var(--leading-normal); }
|
||||
.hanzo-id-consent-check {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border-control);
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.hanzo-id-consent-check input { margin-top: 2px; accent-color: var(--primary); }
|
||||
|
||||
/* Plan step — one card per catalog plan + the pay-as-you-go card */
|
||||
.hanzo-id-plans { display: flex; flex-direction: column; gap: 10px; }
|
||||
.hanzo-id-plan {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
min-height: 44px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--border-control);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--white-05);
|
||||
color: var(--foreground);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
.hanzo-id-plan:hover { background: var(--white-10); }
|
||||
.hanzo-id-plan:disabled { opacity: 0.6; cursor: default; }
|
||||
.hanzo-id-plan.popular { border-color: var(--border-selected); }
|
||||
.hanzo-id-plan-badge {
|
||||
align-self: flex-end;
|
||||
margin: -4px 0 -18px;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
}
|
||||
.hanzo-id-plans-empty { margin: 0; color: var(--muted-foreground); font-size: var(--text-sm); }
|
||||
.hanzo-id-plan-name { font-weight: 600; }
|
||||
.hanzo-id-plan-price { font-size: var(--text-sm); }
|
||||
.hanzo-id-plan-price em { font-style: normal; color: var(--muted-foreground); }
|
||||
.hanzo-id-plan-desc { color: var(--muted-foreground); font-size: var(--text-sm); }
|
||||
|
||||
+10
-1
@@ -1,6 +1,7 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { App } from './App'
|
||||
import { Analytics } from './analytics'
|
||||
import { registerProvider } from '@hanzo/id-idv'
|
||||
import { createStubProvider } from '@hanzo/id-idv/providers/stub'
|
||||
import './app.css'
|
||||
@@ -12,6 +13,14 @@ const root = document.getElementById('root')
|
||||
if (!root) throw new Error('#root missing')
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
{/*
|
||||
Telemetry wraps App rather than living inside it: the pageview must be
|
||||
recorded for the arrival itself, including the loads where `/config.json`
|
||||
or the brand package fails and App renders nothing but an error. Those are
|
||||
exactly the visits worth counting.
|
||||
*/}
|
||||
<Analytics>
|
||||
<App />
|
||||
</Analytics>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* visual essentials (name, logo, accent). The split-view login's marketing
|
||||
* panel and the post-login apps launcher need richer, org-specific copy —
|
||||
* ported verbatim from the frozen `legacy-nextjs` design (`staticBranding`
|
||||
* content + `orgApps`). Keyed by `tenant.orgId` so it stays decoupled from
|
||||
* content + `orgApps`). Keyed by `org.orgId` so it stays decoupled from
|
||||
* hostname switches; unknown orgs fall back to `hanzo`.
|
||||
*/
|
||||
|
||||
@@ -67,14 +67,23 @@ const MARKETING: Record<string, Marketing> = {
|
||||
},
|
||||
}
|
||||
|
||||
// The launcher lists PRODUCTS a person opens, not every host we run.
|
||||
//
|
||||
// Hanzo is three: App (build), Chat (talk), Cloud (the platform + its API).
|
||||
// "Console" is NOT a fourth — it is Cloud's former name, and console.hanzo.ai
|
||||
// now redirects to cloud.hanzo.ai, so listing both showed one product twice
|
||||
// under two names and sent half the traffic through a redirect. It is gone;
|
||||
// nothing here links to console.hanzo.ai.
|
||||
//
|
||||
// Analytics, Platform and Storage came out with it: s3.hanzo.ai answers a bare
|
||||
// XML AccessDenied to a browser (it is an S3 API endpoint, not a page), and the
|
||||
// other two are surfaces inside Cloud rather than products of their own. A
|
||||
// launcher that lands you on an error page teaches people the tiles are broken.
|
||||
const APPS: Record<string, readonly AppLink[]> = {
|
||||
hanzo: [
|
||||
{ name: 'Console', href: 'https://console.hanzo.ai', description: 'Observability & traces' },
|
||||
{ name: 'App', href: 'https://hanzo.app', description: 'Build with AI' },
|
||||
{ name: 'Chat', href: 'https://hanzo.chat', description: 'AI chat interface' },
|
||||
{ name: 'Cloud', href: 'https://cloud.hanzo.ai', description: 'AI model API' },
|
||||
{ name: 'Analytics', href: 'https://analytics.hanzo.ai', description: 'Web analytics' },
|
||||
{ name: 'Platform', href: 'https://platform.hanzo.ai', description: 'PaaS deployments' },
|
||||
{ name: 'Storage', href: 'https://s3.hanzo.ai', description: 'S3-compatible storage' },
|
||||
{ name: 'Cloud', href: 'https://cloud.hanzo.ai', description: 'Models, compute & API' },
|
||||
],
|
||||
lux: [
|
||||
{ name: 'Bridge', href: 'https://bridge.lux.network', description: 'Cross-chain bridge' },
|
||||
|
||||
@@ -1,77 +1,42 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { BrandContract, TenantConfig } from '@hanzo/id-shared'
|
||||
import { createIam, createAuthClient, decodeState } from '@hanzo/id-auth'
|
||||
import type { BrandContract, OrgConfig } from '@hanzo/id-shared'
|
||||
import { createIam } from '@hanzo/id-auth'
|
||||
import { BrandHeader } from '../components/BrandHeader'
|
||||
|
||||
/**
|
||||
* OAuth/OIDC callback.
|
||||
* OAuth/OIDC callback — the portal's OWN PKCE return, and only that.
|
||||
*
|
||||
* Two kinds of return land here:
|
||||
* 1. The portal's own OIDC PKCE return (password / SDK `signinRedirect`) —
|
||||
* completed by the `@hanzo/iam` SDK's `handleCallback`, which reads back
|
||||
* the exact PKCE verifier/state it stored.
|
||||
* 2. A SOCIAL provider return (GitHub/Google), where `social.ts` sent the
|
||||
* user out with a base64 `state` that encodes the original authorize
|
||||
* request. We detect that, exchange the provider `code` at the IAM backend
|
||||
* (`client.providerLogin`), and follow the continue-URL it returns — which
|
||||
* re-enters this callback as case (1). (Pending live verification; only
|
||||
* reachable once real provider creds are seeded.)
|
||||
* One kind of return lands here: an IAM authorization code for a flow this
|
||||
* portal started (password, wallet, or a federated provider begun through
|
||||
* `signinRedirect`). The `@hanzo/iam` SDK's `handleCallback` completes it,
|
||||
* reading back the exact PKCE verifier and state it stored.
|
||||
*
|
||||
* Routing after the OIDC exchange:
|
||||
* - A downstream app left its target in `post_login_redirect` → forward tokens.
|
||||
* There is no second, social-specific case. A federated sign-in returns from the
|
||||
* IdP to IAM's OWN callback (`/v1/iam/oauth/callback`), which does the code
|
||||
* exchange server-side and sends the browser back here with an ordinary IAM code
|
||||
* — indistinguishable from any other. The page used to carry a branch that
|
||||
* decoded a base64 provider `state` and posted the raw IdP code back to IAM; no
|
||||
* endpoint ever accepted that, and nothing can produce that state any more.
|
||||
*
|
||||
* Routing after the exchange:
|
||||
* - A non-OIDC "come back here" target left in `post_login_redirect` (device
|
||||
* approval) → forward tokens there.
|
||||
* - A bare portal sign-in → `/onboarding`.
|
||||
*
|
||||
* An app that sent the user here for a code never reaches this page at all: that
|
||||
* flow re-enters IAM's authorize endpoint and IAM redirects straight to the app.
|
||||
*/
|
||||
|
||||
/** Decode a social-provider `state` (URL-safe base64 of the authorize query). */
|
||||
function decodeProviderState(state: string | null): URLSearchParams | null {
|
||||
if (!state) return null
|
||||
try {
|
||||
const decoded = decodeState(state)
|
||||
const params = new URLSearchParams(decoded.replace(/^\?/, ''))
|
||||
// A provider-login state always carries application + provider markers.
|
||||
if (params.get('provider') && params.get('application')) return params
|
||||
} catch {
|
||||
// not base64 → an SDK/OIDC state, not a provider return
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function Callback({ tenant, brand }: { tenant: TenantConfig; brand: BrandContract }) {
|
||||
export function Callback({ org, brand }: { org: OrgConfig; brand: BrandContract }) {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
useEffect(() => {
|
||||
const search = new URLSearchParams(window.location.search)
|
||||
const providerState = decodeProviderState(search.get('state'))
|
||||
|
||||
// Case (2): social provider return → exchange the provider code, then follow
|
||||
// the continue-URL back into case (1).
|
||||
if (providerState && search.get('code')) {
|
||||
const client = createAuthClient({ tenant })
|
||||
const oidcQuery = decodeState(search.get('state')!)
|
||||
client
|
||||
.providerLogin({
|
||||
application: providerState.get('application') ?? '',
|
||||
provider: providerState.get('provider') ?? '',
|
||||
code: search.get('code') ?? '',
|
||||
oidcQuery,
|
||||
method: providerState.get('method') ?? 'signin',
|
||||
})
|
||||
.then((r) => {
|
||||
if (r.redirectUrl) window.location.replace(r.redirectUrl)
|
||||
else setError(r.error ?? 'Sign-in failed')
|
||||
})
|
||||
.catch((e) => setError(String(e)))
|
||||
return
|
||||
}
|
||||
|
||||
// Case (1): the portal's own OIDC PKCE return.
|
||||
const iam = createIam(tenant)
|
||||
const iam = createIam(org)
|
||||
iam
|
||||
.handleCallback(window.location.href)
|
||||
.then((tok) => {
|
||||
const target = sessionStorage.getItem('post_login_redirect')
|
||||
sessionStorage.removeItem('post_login_redirect')
|
||||
if (target) {
|
||||
// Forward tokens to whichever app initiated this flow.
|
||||
// Forward tokens to the page that sent the user to sign in.
|
||||
const url = new URL(target, window.location.origin)
|
||||
url.searchParams.set('access_token', tok.accessToken)
|
||||
if (tok.refreshToken) url.searchParams.set('refresh_token', tok.refreshToken)
|
||||
@@ -83,7 +48,7 @@ export function Callback({ tenant, brand }: { tenant: TenantConfig; brand: Brand
|
||||
window.location.replace('/onboarding')
|
||||
})
|
||||
.catch((e) => setError(String(e)))
|
||||
}, [tenant])
|
||||
}, [org])
|
||||
|
||||
return (
|
||||
<div className="hanzo-id-page hanzo-id-callback">
|
||||
|
||||
@@ -1,23 +1,32 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import type { BrandContract } from '@hanzo/id-shared'
|
||||
import { LoginForm, SocialButtons, type AuthClient } from '@hanzo/id-auth'
|
||||
import { LoginForm, SocialButtons, type AuthClient, type DeviceInfoResult } from '@hanzo/id-auth'
|
||||
import { BrandHeader } from '../components/BrandHeader'
|
||||
|
||||
/**
|
||||
* RFC 8628 device-authorization approval (`/login/oauth/device`).
|
||||
*
|
||||
* The terminal leg of `dev login --device-auth`: the CLI shows a short
|
||||
* `user_code` and sends the human here (the IAM `verification_uri`;
|
||||
* `verification_uri_complete` adds `?user_code=<code>`). The human signs in to
|
||||
* the SAME issuer, confirms the code matches what their device shows, and
|
||||
* approves — which flips the device code's `UserSignIn=true` so the CLI's token
|
||||
* poll completes.
|
||||
* The terminal leg of `hanzo login`: the CLI shows a short `user_code` and sends
|
||||
* the human here (the IAM `verification_uri`; `verification_uri_complete`
|
||||
* appends the code as a PATH segment, `/login/oauth/device/<code>` — IAM builds
|
||||
* it that way because that is the route this page is registered on, and
|
||||
* `readUserCode` accepts the `?user_code=` query form too). The human signs in
|
||||
* to the SAME issuer, confirms the code matches what their device shows, and
|
||||
* approves — which binds their identity onto the pending row (`Token.User`,
|
||||
* owner/name) so the CLI's token poll stops answering `authorization_pending`
|
||||
* and mints. There is no `UserSignIn` flag; an empty `User` IS "not yet
|
||||
* approved".
|
||||
*
|
||||
* Auth is reused, never reimplemented: not-signed-in renders the normal
|
||||
* `<LoginForm>` + `<SocialButtons>`; once the issuer session cookie is set the
|
||||
* page reads it back from `/v1/iam/get-account` and shows the confirm step.
|
||||
* Approval rides that session cookie (`client.approveDevice`), so no token ever
|
||||
* touches the URL or logs.
|
||||
*
|
||||
* The screen exists to answer ONE question — which application am I authorizing?
|
||||
* — so the application it names is read from the code (`client.deviceInfo`) and
|
||||
* from nowhere else. Until IAM has named one there is no name on screen and no
|
||||
* button to press.
|
||||
*/
|
||||
|
||||
type Phase =
|
||||
@@ -55,14 +64,33 @@ function scrubUrl() {
|
||||
export function DeviceApproval({ client, brand }: { client: AuthClient; brand: BrandContract }) {
|
||||
const [phase, setPhase] = useState<Phase>({ s: 'checking' })
|
||||
const [userCode, setUserCode] = useState(() => readUserCode())
|
||||
// Anti-phishing gate: the `?user_code=` prefill is DISPLAY-ONLY. A signed-in
|
||||
// victim who lands here from a crafted `verification_uri_complete` link must
|
||||
// NOT be able to approve an attacker's device with one click — they have to
|
||||
// explicitly affirm the code matches the one their OWN device shows. The
|
||||
// prefill cannot tick this box, so it can never auto-approve.
|
||||
const [confirmed, setConfirmed] = useState(false)
|
||||
// The code is prefilled from `?user_code=` and stays EDITABLE, which is the
|
||||
// anti-phishing property that matters: approving is an explicit click on a
|
||||
// code the human can read and correct against what their own device shows.
|
||||
// There was also a "I started this sign-in" checkbox in front of that click.
|
||||
// No device page anyone actually uses has one — Google, GitHub and AWS all
|
||||
// show the code and an Approve button — and a tickbox is not evidence: a
|
||||
// victim being walked through a crafted link ticks it as readily as they
|
||||
// click Approve. It bought nothing and cost every honest user a step.
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const appLabel = client.tenant.appName
|
||||
// WHICH application is asking — the whole point of this screen, and the one
|
||||
// thing the page cannot know on its own. It used to render `org.appName`: this
|
||||
// PORTAL's own branding, a static per-org string, so a sign-in started by
|
||||
// hanzo-cli was approved under a screen reading "hanzo-console". The client is
|
||||
// a property of the CODE (it lives on the pending row and is what the backend
|
||||
// actually approves), so it is read from the code — `client.deviceInfo`,
|
||||
// IAM `POST /v1/iam/oauth/device/info`.
|
||||
//
|
||||
// That read is session-gated and answers with one opaque refusal for unknown /
|
||||
// expired / already-approved, so it is no oracle for hunting live codes: it
|
||||
// tells a caller strictly less than the approval that same caller could already
|
||||
// attempt.
|
||||
//
|
||||
// null = not resolved yet. NOTHING is rendered in its place — no fallback name,
|
||||
// no portal name, no guess. Naming the wrong party is the defect being fixed
|
||||
// here, and a screen that names none is strictly better than one that lies.
|
||||
const [app, setApp] = useState<DeviceInfoResult | null>(null)
|
||||
const named = app?.ok ? app : null
|
||||
|
||||
// Resolve the issuer session: signed in → confirm, else → sign-in form. Reads
|
||||
// same-origin from `/v1/iam/get-account` (cookie session; the brand `*.id`
|
||||
@@ -70,7 +98,7 @@ export function DeviceApproval({ client, brand }: { client: AuthClient; brand: B
|
||||
useEffect(() => {
|
||||
scrubUrl()
|
||||
let alive = true
|
||||
fetch(new URL('/v1/iam/get-account', client.tenant.iamUrl).toString(), {
|
||||
fetch(new URL('/v1/iam/get-account', client.org.iamUrl).toString(), {
|
||||
credentials: 'include',
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
@@ -90,7 +118,36 @@ export function DeviceApproval({ client, brand }: { client: AuthClient; brand: B
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [client.tenant.iamUrl])
|
||||
}, [client.org.iamUrl])
|
||||
|
||||
// Ask WHICH application the code belongs to. Needs both halves of what the
|
||||
// endpoint is gated on: the issuer session (every phase past the check has one
|
||||
// except `signin`, and the boolean keeps consent/approving from re-asking) and
|
||||
// a code to ask about.
|
||||
//
|
||||
// The debounce is what makes a hand-typed code work: each keystroke is a
|
||||
// different code, and a partial one is not a real code — without it the human
|
||||
// watches IAM's refusal flash at them while they are still typing.
|
||||
const signedIn = phase.s !== 'checking' && phase.s !== 'signin'
|
||||
const blank = userCode.trim().length === 0
|
||||
useEffect(() => {
|
||||
if (!signedIn || blank) return
|
||||
let alive = true
|
||||
const t = setTimeout(() => {
|
||||
client.deviceInfo(userCode).then((r) => {
|
||||
if (!alive) return
|
||||
// The session lapsed between the get-account check and this read. The
|
||||
// signin phase preserves the code in `returnTo`, so the human lands back
|
||||
// here with it intact.
|
||||
if (!r.ok && r.loginRequired) setPhase({ s: 'signin' })
|
||||
else setApp(r)
|
||||
})
|
||||
}, 250)
|
||||
return () => {
|
||||
alive = false
|
||||
clearTimeout(t)
|
||||
}
|
||||
}, [client, userCode, signedIn, blank])
|
||||
|
||||
async function approve() {
|
||||
setError(null)
|
||||
@@ -145,18 +202,33 @@ export function DeviceApproval({ client, brand }: { client: AuthClient; brand: B
|
||||
const busy = phase.s === 'approving'
|
||||
const consent = phase.s === 'consent'
|
||||
const email = phase.s === 'confirm' || phase.s === 'consent' ? phase.email : undefined
|
||||
// ONE place shows a failure, whichever leg produced it: the approval itself, or
|
||||
// the lookup that has to name an application before an approval is offered.
|
||||
const failure = error ?? (app && !app.ok ? app.error : null)
|
||||
|
||||
return (
|
||||
<Shell brand={brand}>
|
||||
<h1>Approve this device</h1>
|
||||
{email ? <p className="lede">Signed in as {email}</p> : null}
|
||||
|
||||
{/* The application is named ONLY once IAM has confirmed it — the clientId
|
||||
alongside the display name, so a technical human can check it reads
|
||||
`hanzo-cli` exactly and not something that merely looks like it. Until
|
||||
then the sentence says a device, because that is all the page knows. */}
|
||||
<p className="hanzo-id-device-prompt">
|
||||
You are about to authorize <strong>{appLabel}</strong> to sign in on a device. Approve
|
||||
ONLY if this code matches the one shown on that device.
|
||||
{named ? (
|
||||
<>
|
||||
<strong>{named.displayName}</strong> (<code>{named.clientId}</code>) is asking to
|
||||
sign in as you.
|
||||
</>
|
||||
) : (
|
||||
'A device is asking to sign in as you.'
|
||||
)}{' '}
|
||||
Approve ONLY if the code below matches the one shown on that device, and only if you
|
||||
started this sign-in yourself.
|
||||
</p>
|
||||
|
||||
<label className="hanzo-id-device-code-field">
|
||||
<label className="hanzo-id-field">
|
||||
<span>Device code</span>
|
||||
<input
|
||||
type="text"
|
||||
@@ -166,40 +238,38 @@ export function DeviceApproval({ client, brand }: { client: AuthClient; brand: B
|
||||
spellCheck={false}
|
||||
autoComplete="one-time-code"
|
||||
aria-label="Device code"
|
||||
className="hanzo-id-device-code"
|
||||
className="hanzo-id-input hanzo-id-device-code"
|
||||
value={userCode}
|
||||
onChange={(e) => setUserCode(e.target.value)}
|
||||
// A name — and a failure — belongs to a CODE. Edit the code and both are
|
||||
// dropped in the same commit, so no name is ever left on screen for a
|
||||
// frame beside a code it was not confirmed for.
|
||||
onChange={(e) => {
|
||||
setUserCode(e.target.value)
|
||||
setApp(null)
|
||||
setError(null)
|
||||
}}
|
||||
placeholder="e.g. K7M4P2QH"
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{consent ? (
|
||||
{consent && named ? (
|
||||
<p className="hanzo-id-info">
|
||||
{brand.name} needs your consent to continue. By approving you grant {appLabel} access to
|
||||
your profile.
|
||||
<strong>{named.displayName}</strong> needs your consent to continue. By approving
|
||||
you grant the device showing this code access to your profile.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<label className="hanzo-id-device-confirm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={confirmed}
|
||||
onChange={(e) => setConfirmed(e.target.checked)}
|
||||
disabled={busy}
|
||||
/>
|
||||
<span>
|
||||
I started this sign-in on my own device, and this code matches the one it shows.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
|
||||
{failure ? <p role="alert" className="hanzo-id-error">{failure}</p> : null}
|
||||
|
||||
<div className="hanzo-id-cta-row">
|
||||
<button
|
||||
type="button"
|
||||
className="hanzo-id-btn primary"
|
||||
disabled={busy || userCode.trim().length === 0 || !confirmed}
|
||||
// Nothing is approved until IAM has named what is being approved. An
|
||||
// unresolved or refused lookup leaves no button to press, rather than a
|
||||
// button that authorizes an unnamed party.
|
||||
className="hanzo-id-btn"
|
||||
disabled={busy || !named}
|
||||
onClick={approve}
|
||||
>
|
||||
{busy ? 'Approving…' : consent ? 'Approve & grant access' : 'Approve'}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
/**
|
||||
* The signup funnel has to reach signup.
|
||||
*
|
||||
* hanzo.app's "Get started" forwards `signup=true` on the authorize request and
|
||||
* this page ignored it, so a net-new customer landed on "Sign in to Hanzo ID"
|
||||
* with empty credentials and had to spot the small "Create account" link. The
|
||||
* app was already doing its part; the IdP dropped the hint.
|
||||
*
|
||||
* Source text rather than a mount: the assertions are about which BRANCH exists
|
||||
* and its ordering against silent SSO, and a render test would need the whole
|
||||
* auth client stubbed to say much less.
|
||||
*/
|
||||
const src = readFileSync(join(__dirname, 'Login.tsx'), 'utf8')
|
||||
|
||||
describe('Login honors a registration hint', () => {
|
||||
it('reads both spellings of the hint', () => {
|
||||
expect(src).toMatch(/sp\.get\('signup'\)\s*===\s*'true'/)
|
||||
// The OIDC-standard spelling, so a compliant client works without knowing ours.
|
||||
expect(src).toMatch(/sp\.get\('screen_hint'\)\s*===\s*'signup'/)
|
||||
})
|
||||
|
||||
it('sends the whole OIDC request across, or registration cannot return the user', () => {
|
||||
// client_id, redirect_uri, state and the PKCE challenge live in the search
|
||||
// string; a bare '/signup' strands the new account with nowhere to go back to.
|
||||
expect(src).toMatch(/replace\(`\/signup\$\{window\.location\.search\}`\)/)
|
||||
})
|
||||
|
||||
it('loses to silent SSO instead of short-circuiting it', () => {
|
||||
// Someone whose browser already holds an issuer session HAS an account.
|
||||
// The hint must sit in the fallback, so `canSilent` still wins the initial
|
||||
// phase — sending a returning customer to registration is worse than
|
||||
// ignoring the hint entirely.
|
||||
expect(src).toMatch(/const fallback = providerHint \? 'federate' : wantsSignup \? 'register' : 'form'/)
|
||||
expect(src).toMatch(/useState<[^>]*>\(\s*canSilent \? 'silent' : fallback,?\s*\)/)
|
||||
})
|
||||
})
|
||||
+158
-12
@@ -1,26 +1,66 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { BrandContract } from '@hanzo/id-shared'
|
||||
import { LoginForm, SocialButtons, type AuthClient } from '@hanzo/id-auth'
|
||||
import { idBrandLabel, type BrandContract } from '@hanzo/id-shared'
|
||||
import {
|
||||
LoginForm,
|
||||
MfaEnrollForm,
|
||||
OTPForm,
|
||||
SocialButtons,
|
||||
mfaChannelOf,
|
||||
type AuthClient,
|
||||
type LoginResponse,
|
||||
} from '@hanzo/id-auth'
|
||||
import { BrandHeader } from '../components/BrandHeader'
|
||||
import { clientIdFrom } from '../route'
|
||||
|
||||
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
|
||||
const clientIdOverride = clientIdFrom(window.location.search, window.location.pathname)
|
||||
const codeChallenge = sp.get('code_challenge') ?? undefined
|
||||
const codeChallengeMethod = (sp.get('code_challenge_method') as 'S256' | 'plain' | null) ?? undefined
|
||||
const nonce = sp.get('nonce') ?? undefined
|
||||
// A provider the user already chose upstream (the console sends
|
||||
// `?provider_hint=provider-github` when they click "Continue with GitHub"
|
||||
// over there). With no live session we launch that provider straight away
|
||||
// instead of showing this form — so the click lands directly in the social
|
||||
// flow, never bouncing the user to a second login page. We honor ONLY
|
||||
// `provider_hint`, never a bare `provider=` (the SSO SDK uses that for its
|
||||
// `<org>-iam` IDP hint — a different meaning).
|
||||
const providerHint = sp.get('provider_hint') ?? undefined
|
||||
|
||||
// TRUE single sign-on. When an app sent the user here for an authorization
|
||||
// code (client_id + redirect_uri present) AND the browser already holds an
|
||||
// issuer session from an earlier sign-in (the `iam_session_id` cookie), mint
|
||||
// the code from that session and redirect straight back — no form, no
|
||||
// credential re-entry. Only fall back to the interactive form when there is
|
||||
// no live session. A bare portal visit (no client_id/redirect_uri) has
|
||||
// nowhere to redirect, so it shows the form immediately as before.
|
||||
// credential re-entry. With no live session we fall back to auto-launching the
|
||||
// hinted provider if one was named, else the interactive form. A bare portal
|
||||
// visit (no client_id/redirect_uri) has nowhere to redirect, so it shows the
|
||||
// form immediately as before.
|
||||
// A caller that sent the user here to REGISTER should get registration.
|
||||
// hanzo.app's "Get started" forwards `signup=true` and this page ignored it,
|
||||
// so every net-new customer met a sign-in form with empty credentials and had
|
||||
// to notice the small "Create account" link to get past it — the signup funnel
|
||||
// never reached signup. `screen_hint=signup` is the OIDC-standard spelling of
|
||||
// the same request, so both are honored.
|
||||
//
|
||||
// It LOSES to silent SSO, deliberately. A browser already holding an issuer
|
||||
// session belongs to someone who has an account, and sending them to
|
||||
// registration would be worse than ignoring the hint. So it is the fallback
|
||||
// when there is no session, never a short-circuit ahead of one.
|
||||
const wantsSignup = sp.get('signup') === 'true' || sp.get('screen_hint') === 'signup'
|
||||
const canSilent = !!clientIdOverride && !!redirectUri
|
||||
const [phase, setPhase] = useState<'silent' | 'form'>(canSilent ? 'silent' : 'form')
|
||||
const fallback = providerHint ? 'federate' : wantsSignup ? 'register' : 'form'
|
||||
const [phase, setPhase] = useState<'silent' | 'federate' | 'form' | 'register'>(
|
||||
canSilent ? 'silent' : fallback,
|
||||
)
|
||||
|
||||
// null = show the credential form; otherwise IAM returned an MFA signal and
|
||||
// we render the matching step instead of navigating on.
|
||||
const [mfa, setMfa] = useState<LoginResponse | null>(null)
|
||||
const [challengeError, setChallengeError] = useState<string | null>(null)
|
||||
|
||||
const clientId = clientIdOverride ?? client.org.clientId
|
||||
|
||||
useEffect(() => {
|
||||
if (!canSilent) return
|
||||
@@ -40,11 +80,11 @@ export function Login({ client, brand }: { client: AuthClient; brand: BrandContr
|
||||
if (r.redirectUrl) {
|
||||
window.location.assign(r.redirectUrl)
|
||||
} else {
|
||||
setPhase('form')
|
||||
setPhase(fallback)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setPhase('form')
|
||||
if (!cancelled) setPhase(fallback)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
@@ -53,7 +93,36 @@ export function Login({ client, brand }: { client: AuthClient; brand: BrandContr
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
if (phase === 'silent') {
|
||||
// The credential check succeeded (or MFA was satisfied). For a downstream
|
||||
// OIDC request, re-enter authorize with the now-established IAM session so it
|
||||
// mints the code; for a bare portal sign-in, land on onboarding.
|
||||
function completeAfterAuth() {
|
||||
if (redirectUri) {
|
||||
window.location.href = client.authorize({
|
||||
clientId,
|
||||
redirectUri,
|
||||
state: state ?? '',
|
||||
codeChallenge,
|
||||
codeChallengeMethod,
|
||||
})
|
||||
} else {
|
||||
window.location.href = '/onboarding'
|
||||
}
|
||||
}
|
||||
|
||||
// Registration lives on its own page, so this is a real navigation rather than
|
||||
// a branch in the render. `replace`, not `assign`: Back from the signup form
|
||||
// must return to whatever sent the user here, not to a login page that would
|
||||
// immediately bounce forward again. The whole search string travels — the
|
||||
// client_id, redirect_uri, state and PKCE challenge are what let registration
|
||||
// return the new user to the app that asked for them.
|
||||
useEffect(() => {
|
||||
if (phase === 'register') {
|
||||
window.location.replace(`/signup${window.location.search}`)
|
||||
}
|
||||
}, [phase])
|
||||
|
||||
if (phase === 'silent' || phase === 'register') {
|
||||
return (
|
||||
<div className="hanzo-id-page hanzo-id-login">
|
||||
<BrandHeader brand={brand} />
|
||||
@@ -64,11 +133,82 @@ export function Login({ client, brand }: { client: AuthClient; brand: BrandContr
|
||||
)
|
||||
}
|
||||
|
||||
// Auto-launch the hinted provider. `SocialButtons` is headless here — it
|
||||
// resolves the app config and runs the hop; we show a busy state meanwhile,
|
||||
// and drop to the form only if the hint matched no configured provider.
|
||||
if (phase === 'federate') {
|
||||
return (
|
||||
<div className="hanzo-id-page hanzo-id-login">
|
||||
<BrandHeader brand={brand} />
|
||||
<main aria-busy="true">
|
||||
<p>Signing you in…</p>
|
||||
<SocialButtons
|
||||
client={client}
|
||||
clientIdOverride={clientIdOverride}
|
||||
intent="signin"
|
||||
postLoginRedirect={redirectUri}
|
||||
autoStart={providerHint}
|
||||
onAutoStartResolved={(started) => {
|
||||
if (!started) setPhase('form')
|
||||
}}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (mfa?.mfaStage === 'enroll') {
|
||||
return (
|
||||
<div className="hanzo-id-page hanzo-id-login">
|
||||
<BrandHeader brand={brand} />
|
||||
<main>
|
||||
<MfaEnrollForm client={client} onComplete={completeAfterAuth} />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (mfa?.mfaStage === 'challenge') {
|
||||
const iamType = mfa.mfaTypes?.[0] ?? 'app'
|
||||
async function onChallenge(code: string) {
|
||||
setChallengeError(null)
|
||||
const res = await client.mfaChallenge({
|
||||
mfaType: iamType,
|
||||
passcode: code,
|
||||
clientId,
|
||||
application: client.org.appName,
|
||||
organization: client.org.orgId,
|
||||
redirectUri,
|
||||
state,
|
||||
codeChallenge,
|
||||
codeChallengeMethod,
|
||||
})
|
||||
if (res.error) {
|
||||
setChallengeError(res.error)
|
||||
} else if (res.redirectUrl) {
|
||||
window.location.href = res.redirectUrl
|
||||
} else {
|
||||
completeAfterAuth()
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="hanzo-id-page hanzo-id-login">
|
||||
<BrandHeader brand={brand} />
|
||||
<main>
|
||||
<h1>Two-factor authentication</h1>
|
||||
<p className="lede">Enter the code from your authenticator app to finish signing in.</p>
|
||||
{challengeError ? <p role="alert" className="hanzo-id-error">{challengeError}</p> : null}
|
||||
<OTPForm channel={mfaChannelOf(iamType)} onSubmit={onChallenge} />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="hanzo-id-page hanzo-id-login">
|
||||
<BrandHeader brand={brand} />
|
||||
<main>
|
||||
<h1>Sign in to {brand.name}</h1>
|
||||
<h1>Sign in to {idBrandLabel(brand, client.org.orgId)}</h1>
|
||||
<SocialButtons
|
||||
client={client}
|
||||
clientIdOverride={clientIdOverride}
|
||||
@@ -83,9 +223,15 @@ export function Login({ client, brand }: { client: AuthClient; brand: BrandContr
|
||||
codeChallenge={codeChallenge}
|
||||
codeChallengeMethod={codeChallengeMethod}
|
||||
nonce={nonce}
|
||||
onMfaRequired={setMfa}
|
||||
/>
|
||||
<p className="hanzo-id-footer-links">
|
||||
<a href="/forget">Forgot password?</a> · <a href="/signup">Create account</a>
|
||||
{/* Carry the OIDC request across. These are full page loads, so a bare
|
||||
href drops the client_id, redirect_uri, state and PKCE challenge the
|
||||
app sent — and registration then has nothing to return the new user
|
||||
to. `Signup` reads exactly these params. */}
|
||||
<a href={`/forget${window.location.search}`}>Forgot password?</a> ·{' '}
|
||||
<a href={`/signup${window.location.search}`}>Create account</a>
|
||||
</p>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { BrandContract, TenantConfig } from '@hanzo/id-shared'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import type { BrandContract, OrgConfig } from '@hanzo/id-shared'
|
||||
import { createIam } from '@hanzo/id-auth'
|
||||
import { OnboardingFlow, createOnboardingService, type OnboardingState } from '@hanzo/id-onboarding'
|
||||
import { getConnector } from '@hanzo/id-connect/connectors'
|
||||
import { BrandHeader } from '../components/BrandHeader'
|
||||
|
||||
/** Fleet default pay origin; a white-label brand overrides via catalog `payUrl`. */
|
||||
const DEFAULT_PAY_URL = 'https://pay.hanzo.ai'
|
||||
|
||||
/**
|
||||
* Post-login onboarding page.
|
||||
*
|
||||
* Reached after a bare portal sign-in (no downstream `redirect_uri`). Mounts
|
||||
* the `@hanzo/id-onboarding` flow (org → project → wallet) wired to:
|
||||
* the `@hanzo/id-onboarding` flow (org → project → wallet → consent → plan)
|
||||
* wired to:
|
||||
*
|
||||
* - the IAM session token: read from the same `@hanzo/iam` PKCE client the
|
||||
* Callback stored it on, so the onboarding writes ride the logged-in
|
||||
@@ -18,39 +22,110 @@ import { BrandHeader } from '../components/BrandHeader'
|
||||
* the onboarding pkg stays wallet-agnostic. Absent injected provider →
|
||||
* the wallet step is skip-only.
|
||||
*
|
||||
* On completion it lands on the portal home (`/`); a downstream app that
|
||||
* wanted a token would have carried `redirect_uri` and never reached here.
|
||||
* NEVER REPEATS: completion is recorded on the USER (Properties, via
|
||||
* saveOnboarding) — so before mounting the flow this page reads it back and,
|
||||
* if the user already finished onboarding on ANY browser, goes straight to
|
||||
* the portal. The read failing open (network blip → run the flow again) is
|
||||
* deliberate: repeating is annoying, silently skipping a required step is
|
||||
* worse.
|
||||
*
|
||||
* On completion it routes by the plan choice — the platform is prepay-only,
|
||||
* so a plan goes to the pay cart and pay-as-you-go goes to the top-up flow.
|
||||
* A downstream app that wanted a token would have carried `redirect_uri` and
|
||||
* never reached here.
|
||||
*/
|
||||
export function Onboarding({ tenant, brand }: { tenant: TenantConfig; brand: BrandContract }) {
|
||||
const iam = useMemo(() => createIam(tenant), [tenant])
|
||||
export function Onboarding({ org, brand }: { org: OrgConfig; brand: BrandContract }) {
|
||||
const iam = useMemo(() => createIam(org), [org])
|
||||
const payUrl = org.payUrl || DEFAULT_PAY_URL
|
||||
|
||||
const service = useMemo(
|
||||
() =>
|
||||
createOnboardingService({
|
||||
iamUrl: tenant.iamUrl,
|
||||
orgId: tenant.orgId,
|
||||
iamUrl: org.iamUrl,
|
||||
orgId: org.orgId,
|
||||
getAccessToken: () => iam.getValidAccessToken(),
|
||||
}),
|
||||
[tenant, iam],
|
||||
[org, iam],
|
||||
)
|
||||
|
||||
function onComplete(_state: OnboardingState) {
|
||||
// Land on the authenticated portal (apps launcher), NOT the bare hero.
|
||||
// The marker makes the portal treat the just-established session as authed
|
||||
// even before the cross-request get-account read settles.
|
||||
window.location.replace('/?signed_in=1')
|
||||
// null = still checking; false = run the flow; true = already done, leaving.
|
||||
const [alreadyDone, setAlreadyDone] = useState<boolean | null>(null)
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
;(async () => {
|
||||
// TOKEN BOOTSTRAP. A password/form sign-in mints a SESSION COOKIE and
|
||||
// lands here directly — the PKCE SDK holds no token, so every onboarding
|
||||
// WRITE (update-user needs a bearer; the cookie alone is refused, and
|
||||
// rightly — a cookie-authed write is a CSRF surface) answered 401 and
|
||||
// the funnel dead-ended at consent. With a live session, authorize is
|
||||
// the silent-SSO branch: signinRedirect bounces through IAM with no UI,
|
||||
// /callback stores the token and returns to /onboarding. One bounce per
|
||||
// session, guarded, so a broken mint degrades to the read-only 401
|
||||
// instead of a redirect loop.
|
||||
const token = await iam.getValidAccessToken().catch(() => null)
|
||||
if (!alive) return
|
||||
if (!token) {
|
||||
const guard = 'onboarding.token_bounce'
|
||||
if (!sessionStorage.getItem(guard)) {
|
||||
sessionStorage.setItem(guard, '1')
|
||||
void iam.signinRedirect()
|
||||
return
|
||||
}
|
||||
} else {
|
||||
sessionStorage.removeItem('onboarding.token_bounce')
|
||||
}
|
||||
try {
|
||||
const { completedAt } = await service.readOnboarding()
|
||||
if (!alive) return
|
||||
if (completedAt) {
|
||||
setAlreadyDone(true)
|
||||
window.location.replace('/?signed_in=1')
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Read failing open (network blip → run the flow) is deliberate.
|
||||
}
|
||||
if (alive) setAlreadyDone(false)
|
||||
})()
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [service, iam])
|
||||
|
||||
function onComplete(state: OnboardingState) {
|
||||
// Prepay-only funnel: the last step recorded a choice, now act on it.
|
||||
// - a plan slug → the pay cart, seats + payment there (price is the
|
||||
// catalog's — commerce recomputes server-side, the slug is enough)
|
||||
// - pay as you go → the top-up flow ($5 minimum, all methods)
|
||||
// The plan choice is already persisted on the user, so bouncing off the
|
||||
// payment page never re-enters onboarding.
|
||||
const choice = state.planChoice
|
||||
if (choice === 'payg') {
|
||||
window.location.replace(`${payUrl}/onboard`)
|
||||
} else if (choice) {
|
||||
window.location.replace(`${payUrl}/cart?plan=${encodeURIComponent(choice)}`)
|
||||
} else {
|
||||
// No recorded choice (should not happen — the plan step requires one):
|
||||
// land on the authenticated portal rather than a dead end.
|
||||
window.location.replace('/?signed_in=1')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="hanzo-id-page hanzo-id-onboarding-page">
|
||||
<BrandHeader brand={brand} />
|
||||
<main>
|
||||
<OnboardingFlow
|
||||
service={service}
|
||||
brandName={brand.name}
|
||||
connectWallet={connectInjectedWallet}
|
||||
onComplete={onComplete}
|
||||
/>
|
||||
{alreadyDone === false ? (
|
||||
<OnboardingFlow
|
||||
service={service}
|
||||
brandName={brand.name}
|
||||
connectWallet={connectInjectedWallet}
|
||||
onComplete={onComplete}
|
||||
payUrl={payUrl}
|
||||
/>
|
||||
) : (
|
||||
<div className="hanzo-id-spinner" aria-label="Loading" />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
@@ -60,8 +135,8 @@ export function Onboarding({ tenant, brand }: { tenant: TenantConfig; brand: Bra
|
||||
* EVM wallet connector backed by @hanzo/id-connect (EIP-6963 multi-injection,
|
||||
* viem under the hood). Returns the checksummed 0x address, or null when the
|
||||
* user cancels or no injected EVM wallet is present. The onboarding wallet step
|
||||
* only needs the address (it stores it via update-user?columns=web3onboard), so
|
||||
* we connect and return account.address — no signature round-trip here.
|
||||
* only needs the address (it stores it via a full-row read-merge-write in the
|
||||
* service), so we connect and return account.address — no signature round-trip.
|
||||
*/
|
||||
async function connectInjectedWallet(): Promise<string | null> {
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { BrandContract, TenantConfig } from '@hanzo/id-shared'
|
||||
import type { IamIdentity } from '@hanzo/iam/react'
|
||||
import { UserMenu, resolveIdentity } from '@hanzo/iam/react'
|
||||
import type { BrandContract, OrgConfig } from '@hanzo/id-shared'
|
||||
import type { AuthClient } from '@hanzo/id-auth'
|
||||
import { Login } from './Login'
|
||||
import { BrandHeader } from '../components/BrandHeader'
|
||||
@@ -8,7 +10,7 @@ import { appsFor, billingFor } from '../marketing'
|
||||
type Auth =
|
||||
| { s: 'loading' }
|
||||
| { s: 'anon' }
|
||||
| { s: 'authed'; name?: string; email?: string }
|
||||
| { s: 'authed'; identity: IamIdentity | null }
|
||||
|
||||
/**
|
||||
* Root portal (`/`). The portal IS the login surface, not a marketing hero:
|
||||
@@ -19,7 +21,7 @@ type Auth =
|
||||
* - signed in → the apps launcher (the org's apps) + billing / sign-out.
|
||||
*
|
||||
* Auth is read same-origin from `/v1/iam/get-account` (cookie session;
|
||||
* `tenant.iamUrl` is the brand's own `*.id` host, so this is first-party and
|
||||
* `org.iamUrl` is the brand's own `*.id` host, so this is first-party and
|
||||
* the session cookie rides along). The `?signed_in=1` marker set by the
|
||||
* bare-login / onboarding-complete redirect is the authoritative "just
|
||||
* authenticated" signal when the cookie read hasn't propagated yet.
|
||||
@@ -27,18 +29,18 @@ type Auth =
|
||||
export function Portal({
|
||||
client,
|
||||
brand,
|
||||
tenant,
|
||||
org,
|
||||
}: {
|
||||
client: AuthClient
|
||||
brand: BrandContract
|
||||
tenant: TenantConfig
|
||||
org: OrgConfig
|
||||
}) {
|
||||
const [auth, setAuth] = useState<Auth>({ s: 'loading' })
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
const justSignedIn = new URLSearchParams(window.location.search).get('signed_in') === '1'
|
||||
fetch(new URL('/v1/iam/get-account', tenant.iamUrl).toString(), {
|
||||
fetch(new URL('/v1/iam/get-account', org.iamUrl).toString(), {
|
||||
credentials: 'include',
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
@@ -47,23 +49,26 @@ export function Portal({
|
||||
if (!alive) return
|
||||
const d = b.data as Record<string, unknown> | undefined
|
||||
if (b.status === 'ok' && d && typeof d === 'object') {
|
||||
setAuth({ s: 'authed', name: str(d.displayName) ?? str(d.name), email: str(d.email) })
|
||||
// `resolveIdentity` is the SAME name/avatar/initials resolution every
|
||||
// Hanzo surface shows, so the portal cannot disagree with the console
|
||||
// about who you are — and it never falls back to a raw uuid.
|
||||
setAuth({ s: 'authed', identity: resolveIdentity(d, {}) })
|
||||
} else {
|
||||
setAuth(justSignedIn ? { s: 'authed' } : { s: 'anon' })
|
||||
setAuth(justSignedIn ? { s: 'authed', identity: null } : { s: 'anon' })
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (alive) setAuth(justSignedIn ? { s: 'authed' } : { s: 'anon' })
|
||||
if (alive) setAuth(justSignedIn ? { s: 'authed', identity: null } : { s: 'anon' })
|
||||
})
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [tenant.iamUrl])
|
||||
}, [org.iamUrl])
|
||||
|
||||
if (auth.s === 'loading') {
|
||||
return (
|
||||
<div className="hanzo-id-page" style={{ minHeight: '40vh' }}>
|
||||
<div className="hanzo-id-spinner" style={{ borderTopColor: brand.accentColor ?? '#fff' }} />
|
||||
<div className="hanzo-id-spinner" style={{ borderTopColor: brand.accentColor ?? 'var(--primary)' }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -72,54 +77,46 @@ export function Portal({
|
||||
if (auth.s === 'anon') return <Login client={client} brand={brand} />
|
||||
|
||||
// Signed in: the apps launcher.
|
||||
const apps = appsFor(tenant.orgId)
|
||||
const billingUrl = billingFor(tenant.orgId)
|
||||
const logoutUrl = client.logout(undefined, `${tenant.publicOrigin}/login`)
|
||||
const apps = appsFor(org.orgId)
|
||||
const billingUrl = billingFor(org.orgId)
|
||||
// signOut, not logout: the latter only builds the IdP URL and leaves this
|
||||
// browser's `hanzo_iam_*` keys in place, so the token string outlived the
|
||||
// session it named. Called on click, not at render — it clears storage.
|
||||
|
||||
return (
|
||||
<div className="hanzo-id-page hanzo-id-portal">
|
||||
<BrandHeader brand={brand} />
|
||||
<main style={{ width: '100%', maxWidth: 760 }}>
|
||||
<main>
|
||||
<h1>Your {brand.name} apps</h1>
|
||||
{auth.email ? <p className="lede">{auth.email}</p> : null}
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(210px, 1fr))',
|
||||
gap: 12,
|
||||
marginTop: 24,
|
||||
}}
|
||||
>
|
||||
<div className="hanzo-id-apps">
|
||||
{apps.map((a) => (
|
||||
<a
|
||||
key={a.name}
|
||||
href={a.href}
|
||||
style={{
|
||||
display: 'block',
|
||||
padding: '16px 18px',
|
||||
border: '1px solid rgba(255,255,255,0.14)',
|
||||
borderRadius: 12,
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, display: 'flex', justifyContent: 'space-between' }}>
|
||||
<a key={a.name} className="hanzo-id-applink" href={a.href}>
|
||||
<div className="hanzo-id-applink-name">
|
||||
<span>{a.name}</span>
|
||||
<span aria-hidden style={{ opacity: 0.5 }}>↗</span>
|
||||
<span aria-hidden>↗</span>
|
||||
</div>
|
||||
<div style={{ opacity: 0.6, fontSize: 13, marginTop: 4 }}>{a.description}</div>
|
||||
<div className="hanzo-id-applink-desc">{a.description}</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginTop: 28, display: 'flex', gap: 18 }}>
|
||||
<a className="hanzo-id-linkbtn" href={billingUrl}>Billing</a>
|
||||
<a className="hanzo-id-linkbtn" href={logoutUrl}>Sign out</a>
|
||||
{/* The ONE account control. This was a hand-rolled "Billing / Sign out"
|
||||
link row; every Hanzo surface mounts @hanzo/iam's UserMenu instead,
|
||||
so identity, billing and sign-out read and behave identically here,
|
||||
on hanzo.chat and in the console. The portal's session is its own
|
||||
cookie read rather than an IamProvider, which is exactly what the
|
||||
`identity` / `isAuthenticated` / `onSignOut` overrides are for.
|
||||
No `brand` prop: omitting `markSvg` would put the HANZO mark on
|
||||
lux.id and zoo.id, and this one image serves all four portals. */}
|
||||
<div className="hanzo-id-portal-account">
|
||||
<UserMenu
|
||||
identity={auth.identity}
|
||||
isAuthenticated
|
||||
usageUrl={billingUrl}
|
||||
usageLabel="Billing"
|
||||
onSignOut={() => { window.location.href = client.signOut(`${org.publicOrigin}/login`) }}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function str(v: unknown): string | undefined {
|
||||
return typeof v === 'string' && v.length > 0 ? v : undefined
|
||||
}
|
||||
|
||||
@@ -1,12 +1,69 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { BrandContract } from '@hanzo/id-shared'
|
||||
import { SignupForm, SocialButtons, type AuthClient } from '@hanzo/id-auth'
|
||||
import { BrandHeader } from '../components/BrandHeader'
|
||||
import { clientIdFrom } from '../route'
|
||||
|
||||
export function Signup({ client, brand }: { client: AuthClient; brand: BrandContract }) {
|
||||
const sp = new URLSearchParams(window.location.search)
|
||||
const inviteCode = sp.get('invite') ?? undefined
|
||||
const clientIdOverride = sp.get('client_id') ?? undefined
|
||||
const clientIdOverride = clientIdFrom(window.location.search, window.location.pathname)
|
||||
const redirectUri = sp.get('redirect_uri') ?? undefined
|
||||
// The same downstream OIDC request `Login` reads. Registration ends in a
|
||||
// sign-in, so it needs the whole request — not just the client and its
|
||||
// callback — or the minted code carries no PKCE binding and no state.
|
||||
const state = sp.get('state') ?? undefined
|
||||
const codeChallenge = sp.get('code_challenge') ?? undefined
|
||||
const codeChallengeMethod = (sp.get('code_challenge_method') as 'S256' | 'plain' | null) ?? undefined
|
||||
const nonce = sp.get('nonce') ?? undefined
|
||||
|
||||
// Only ask for credentials an account can actually be made with. IAM refuses
|
||||
// signup on an app with `enableSignUp:false` — 48 of 51 hanzo applications
|
||||
// today — and it refuses at SUBMIT, so this page used to take an email and a
|
||||
// password and only then answer "the application does not allow to sign up
|
||||
// new account". The provider buttons dead-end the same way: federation
|
||||
// PROVISIONS a local user for a new identity, so a first-time GitHub sign-up
|
||||
// hits the same gate after a whole round trip through GitHub.
|
||||
//
|
||||
// `enableSignUp` is already on `AppLogin` and already fetched — SignupForm
|
||||
// reads the same row inside onSubmit. Reading it here instead is what turns
|
||||
// the refusal from a surprise into a state.
|
||||
//
|
||||
// It FAILS OPEN, deliberately: an unreadable app config renders the form, and
|
||||
// the server still refuses. This is honesty about a known answer, not a gate —
|
||||
// the gate is `internal/oidc/signup.go` and must stay the only one.
|
||||
const [open, setOpen] = useState(true)
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
client
|
||||
.getAppLogin(clientIdOverride, redirectUri)
|
||||
.then((app) => {
|
||||
if (!cancelled && app) setOpen(app.enableSignUp)
|
||||
})
|
||||
.catch(() => {})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [client, clientIdOverride, redirectUri])
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<div className="hanzo-id-page hanzo-id-signup">
|
||||
<BrandHeader brand={brand} />
|
||||
<main>
|
||||
<h1>Create your {brand.name} account</h1>
|
||||
<p className="hanzo-id-info">
|
||||
This application does not accept new accounts. If you already have
|
||||
one, sign in below.
|
||||
</p>
|
||||
<p className="hanzo-id-footer-links">
|
||||
<a href={`/login${window.location.search}`}>Sign in</a>
|
||||
</p>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="hanzo-id-page hanzo-id-signup">
|
||||
<BrandHeader brand={brand} />
|
||||
@@ -18,9 +75,18 @@ export function Signup({ client, brand }: { client: AuthClient; brand: BrandCont
|
||||
intent="signup"
|
||||
postLoginRedirect={redirectUri}
|
||||
/>
|
||||
<SignupForm client={client} inviteCode={inviteCode} />
|
||||
<SignupForm
|
||||
client={client}
|
||||
inviteCode={inviteCode}
|
||||
clientIdOverride={clientIdOverride}
|
||||
redirectUri={redirectUri}
|
||||
state={state}
|
||||
codeChallenge={codeChallenge}
|
||||
codeChallengeMethod={codeChallengeMethod}
|
||||
nonce={nonce}
|
||||
/>
|
||||
<p className="hanzo-id-footer-links">
|
||||
Already have an account? <a href="/login">Sign in</a>
|
||||
Already have an account? <a href={`/login${window.location.search}`}>Sign in</a>
|
||||
</p>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { test } from 'vitest'
|
||||
import assert from 'node:assert/strict'
|
||||
import { clientIdFrom } from './route'
|
||||
|
||||
test('the OAuth query shape wins — it is the one carrying a redirect_uri', () => {
|
||||
assert.equal(clientIdFrom('?client_id=hanzo-chat', '/signup'), 'hanzo-chat')
|
||||
// Both present: the query is the real request; the segment is decoration.
|
||||
assert.equal(clientIdFrom('?client_id=hanzo-app', '/signup/hanzo-chat'), 'hanzo-app')
|
||||
})
|
||||
|
||||
test('the plain-link path shape is read — this is the bug hanzo.chat hit', () => {
|
||||
// Three live components in hanzoai/chat link exactly here. App.tsx routes
|
||||
// `/signup/` and `/login/`, so the shape was accepted and then dropped: the
|
||||
// page fell back to the host default and created the account under a
|
||||
// DIFFERENT application than the button that asked for it.
|
||||
assert.equal(clientIdFrom('', '/signup/hanzo-chat'), 'hanzo-chat')
|
||||
assert.equal(clientIdFrom('', '/login/hanzo-chat'), 'hanzo-chat')
|
||||
assert.equal(clientIdFrom('', '/signup/hanzo-cloud'), 'hanzo-cloud')
|
||||
})
|
||||
|
||||
test('anything that is not one <org>-<app> segment falls back to the host default', () => {
|
||||
// These must stay undefined, or the page would authenticate as whatever junk
|
||||
// was in the URL — the fallback is the host's declared app, which is correct.
|
||||
for (const p of [
|
||||
'/signup', // bare page, the ordinary case
|
||||
'/login',
|
||||
'/signup/', // trailing slash, no segment
|
||||
'/signup/nodash', // not <org>-<app>
|
||||
'/signup/a/b', // deeper path
|
||||
'/signup/Hanzo-Chat', // ids are lower-case
|
||||
'/signup/../admin',
|
||||
'/',
|
||||
]) {
|
||||
assert.equal(clientIdFrom('', p), undefined, p)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Which application a visitor arrived FOR.
|
||||
*
|
||||
* Two shapes reach these pages and both are deliberate:
|
||||
*
|
||||
* /signup?client_id=hanzo-chat the OAuth shape, carrying the whole
|
||||
* request (redirect_uri, state, PKCE)
|
||||
* /signup/hanzo-chat the plain-link shape, for a marketing
|
||||
* "Sign up" that starts no OAuth request
|
||||
*
|
||||
* `App.tsx` has always routed the second one — `path.startsWith('/signup/')`
|
||||
* and `'/login/'` are in its switch — so the shape is accepted by design. The
|
||||
* PAGES then read only `?client_id`, so the segment was matched and thrown
|
||||
* away: hanzo.chat links to `hanzo.id/signup/hanzo-chat` from three components,
|
||||
* and every one of them landed on a page that had silently fallen back to the
|
||||
* host's default app. An account created there belongs to a different
|
||||
* application than the button that asked for it.
|
||||
*
|
||||
* Query wins when both are present: it is the OAuth request, and it is the one
|
||||
* that carries a redirect_uri to return through.
|
||||
*/
|
||||
export function clientIdFrom(search: string, pathname: string): string | undefined {
|
||||
const fromQuery = new URLSearchParams(search).get('client_id')
|
||||
if (fromQuery) return fromQuery
|
||||
|
||||
// Exactly one segment after the page, and it must look like an IAM client id
|
||||
// (`<org>-<app>`, the estate's one naming rule). Anything else — a deeper
|
||||
// path, an encoded slash, junk — resolves to undefined and the caller falls
|
||||
// back to the host default, which is the behaviour that was there before.
|
||||
const seg = pathname.split('/').filter(Boolean)
|
||||
if (seg.length !== 2) return undefined
|
||||
return /^[a-z0-9]+(-[a-z0-9]+)+$/.test(seg[1]!) ? seg[1] : undefined
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Every token this surface references must RESOLVE.
|
||||
*
|
||||
* An undefined CSS custom property paints nothing and reports no error, so this
|
||||
* whole class of defect survives review: `var(--surface-1)` and
|
||||
* `var(--shadow-lg)` shipped in @hanzo/iam's account menu against a token layer
|
||||
* that defines neither, and the menu rendered transparent. "It is declared" was
|
||||
* never evidence — nor was "it type-checks", because the reference is built at
|
||||
* runtime from a string and is invisible to both the compiler and grep.
|
||||
*
|
||||
* So the gate is resolution, and it is computed from what the bundle ACTUALLY
|
||||
* serves: it walks app.css's @import graph into the installed @hanzo/design,
|
||||
* collects the tokens those files declare, then asserts that every var(--x)
|
||||
* anywhere under src/ — plus every token @hanzo/iam paints its menu with — is
|
||||
* in that set.
|
||||
*
|
||||
* This fails if someone cherry-picks token groups again (the four-of-nine
|
||||
* subset this file used to import left --z-*, --shadow-* and --space-* out),
|
||||
* if @hanzo/design renames or drops a token, or if a component starts asking
|
||||
* for a token in @hanzo/brand's vocabulary instead of @hanzo/design's.
|
||||
*/
|
||||
import { test } from 'vitest'
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const SRC = path.join(import.meta.dirname, '.')
|
||||
|
||||
/**
|
||||
* Both packages restrict `exports`, so resolve them by walking up for the
|
||||
* node_modules directory rather than by asking Node for a subpath it refuses.
|
||||
*/
|
||||
function pkgRoot(name: string): string {
|
||||
for (let d = SRC; d !== path.dirname(d); d = path.dirname(d)) {
|
||||
const p = path.join(d, 'node_modules', name)
|
||||
if (fs.existsSync(path.join(p, 'package.json'))) return fs.realpathSync(p)
|
||||
}
|
||||
throw new Error(`${name} is not installed`)
|
||||
}
|
||||
const DESIGN = pkgRoot('@hanzo/design')
|
||||
|
||||
const read = (p: string) => fs.readFileSync(p, 'utf8')
|
||||
const declaredIn = (css: string) => [...css.matchAll(/(--[a-zA-Z0-9-]+)\s*:/g)].map((m) => m[1])
|
||||
const referencedIn = (css: string) => [...css.matchAll(/var\(\s*(--[a-zA-Z0-9-]+)/g)].map((m) => m[1])
|
||||
|
||||
/** Follow @import from an entry stylesheet into the @hanzo/design package. */
|
||||
function tokenFiles(entry: string, seen = new Set<string>()): string[] {
|
||||
for (const m of read(entry).matchAll(/@import\s+(?:url\()?['"]([^'"]+)['"]/g)) {
|
||||
const spec = m[1]
|
||||
const abs = spec.startsWith('@hanzo/design/')
|
||||
? path.join(DESIGN, spec.slice('@hanzo/design/'.length))
|
||||
: path.resolve(path.dirname(entry), spec)
|
||||
if (seen.has(abs) || !fs.existsSync(abs)) continue
|
||||
seen.add(abs)
|
||||
tokenFiles(abs, seen)
|
||||
}
|
||||
return [...seen]
|
||||
}
|
||||
|
||||
/** Every .css/.ts/.tsx under src/, so inline `var(--x)` in a component counts. */
|
||||
function sources(dir: string, out: string[] = []): string[] {
|
||||
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = path.join(dir, e.name)
|
||||
if (e.isDirectory()) sources(p, out)
|
||||
// Skip this file: its own doc comment quotes `var(--x)`.
|
||||
else if (/\.(css|tsx?)$/.test(e.name) && p !== import.meta.filename) out.push(p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const served = tokenFiles(path.join(SRC, 'app.css'))
|
||||
const available = new Set(served.flatMap((f) => declaredIn(read(f))))
|
||||
const files = sources(SRC)
|
||||
const local = new Set(files.flatMap((f) => declaredIn(read(f))))
|
||||
|
||||
/**
|
||||
* Coverage is judged by TOKENS, not by files. This used to assert that each
|
||||
* `tokens/<group>.css` was itself reached through @import, which stopped being
|
||||
* true at @hanzo/design 0.4.x: gen-tokens.mjs now FLATTENS all ten groups into
|
||||
* styles.css so a bundler never has to resolve those subpaths. Nothing was
|
||||
* dropped — the files still ship, they are just inlined — so asking "is every
|
||||
* token this group declares actually served?" catches the cherry-picking this
|
||||
* gate exists for, and survives however the package chooses to assemble itself.
|
||||
*/
|
||||
test('app.css serves the whole @hanzo/design token layer, not a subset', () => {
|
||||
const groups = fs.readdirSync(path.join(DESIGN, 'tokens')).filter((f) => f.endsWith('.css'))
|
||||
const missing = groups.filter((g) => {
|
||||
const declared = [...new Set(declaredIn(read(path.join(DESIGN, 'tokens', g))))]
|
||||
return declared.length > 0 && !declared.every((t) => available.has(t))
|
||||
})
|
||||
assert.deepEqual(missing, [], `token groups authored by @hanzo/design but never served here: ${missing.join(', ')}`)
|
||||
/* base.css is the odd group and token coverage cannot see it: it ships the
|
||||
ELEMENT DEFAULTS (the control, the focused control, the scrollbar) as 23
|
||||
:where() rules, and the single token it declares — --border — is declared by
|
||||
colors.css too. It is also the only group that opens `@layer base`, so that
|
||||
is the exact marker for "the defaults are actually being served". */
|
||||
assert.ok(
|
||||
served.some((f) => /@layer\s+base/.test(read(f))),
|
||||
'the element defaults from tokens/base.css are not served'
|
||||
)
|
||||
})
|
||||
|
||||
test('every token this surface references is defined', () => {
|
||||
const unresolved = new Map<string, string[]>()
|
||||
for (const f of files) {
|
||||
for (const name of referencedIn(read(f))) {
|
||||
if (available.has(name) || local.has(name)) continue
|
||||
const at = unresolved.get(name) ?? []
|
||||
at.push(path.relative(SRC, f))
|
||||
unresolved.set(name, at)
|
||||
}
|
||||
}
|
||||
assert.deepEqual(
|
||||
[...unresolved].map(([n, at]) => `${n} (${[...new Set(at)].join(', ')})`),
|
||||
[],
|
||||
)
|
||||
})
|
||||
|
||||
test('every token @hanzo/iam paints the account menu with is defined', () => {
|
||||
// The menu is a distributed component: it emits its own stylesheet at
|
||||
// runtime, so its token references never appear in this repo's source and no
|
||||
// amount of grepping here would find them. Read them out of the shipped
|
||||
// bundle instead — literal `var(--x)` plus the names its tok() helper builds.
|
||||
const iam = read(path.join(pkgRoot('@hanzo/iam'), 'dist/react.js'))
|
||||
const names = new Set([
|
||||
...referencedIn(iam),
|
||||
...[...iam.matchAll(/\btok\(\s*["']([a-zA-Z0-9-]+)["']/g)].map((m) => `--${m[1]}`),
|
||||
])
|
||||
const unresolved = [...names].filter((n) => !available.has(n)).sort()
|
||||
assert.deepEqual(unresolved, [])
|
||||
})
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
/**
|
||||
* Publishable event-ingest key (pk-…), inlined at build time from the
|
||||
* PUBLISHABLE_KEY build-arg (KMS `deploy/PUBLISHABLE_KEY`, env `prod`).
|
||||
* Declared so a typo reads as a type error rather than as `any` — Vite's
|
||||
* ImportMetaEnv carries a string index signature, so an undeclared
|
||||
* `import.meta.env.VITE_EVENT_INGEST_KEZ` would type-check and ship empty.
|
||||
*/
|
||||
readonly VITE_PUBLISHABLE_KEY: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
@@ -2,9 +2,10 @@
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
},
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
# Hanzo ID — the ONE file that says how this repo builds, gates and ships.
|
||||
#
|
||||
# The only workflow is `.hanzo/workflows/cicd.yml`, ~10 lines that import
|
||||
# hanzoai/ci; everything real is here, and platform.hanzo.ai reads this same
|
||||
# file. It replaces TWO image lanes that had drifted apart:
|
||||
#
|
||||
# .hanzo/workflows/deploy.yml the one that worked — buildx, two registry
|
||||
# logins, a version read out of package.json and
|
||||
# a post-push manifest check. Every line of it is
|
||||
# something the reusable already does.
|
||||
# .github/workflows/docker.yml a second lane on a plane with no runners for
|
||||
# our labels. Already reduced to an echo, but it
|
||||
# kept the shape of a build lane alive in a
|
||||
# directory this forge cannot even read.
|
||||
#
|
||||
# Neither gated anything. This repo has 13 test files and 160 assertions and CI
|
||||
# ran none of them, which is how `pkgs/shared/src/org.test.ts` sat RED on main
|
||||
# while the code it tested was correct.
|
||||
|
||||
# Gates run DIRECTLY on the runner, not inside the image build. hanzoai/ci
|
||||
# provisions Node 22 and enables corepack; corepack then reads `packageManager`
|
||||
# from package.json, so the pnpm this uses is the SAME pnpm the Dockerfile
|
||||
# activates — stated once, in package.json, rather than pinned again here.
|
||||
#
|
||||
# Three gates, because there are three different ways this repo breaks and one
|
||||
# combined gate would report all of them as the same failure.
|
||||
test:
|
||||
- name: install
|
||||
# --frozen-lockfile, deliberately stricter than the Dockerfile's
|
||||
# `--frozen-lockfile=false`. The image build must not be blocked by a
|
||||
# lockfile that drifted; CI is exactly where that drift should be caught.
|
||||
run: |
|
||||
set -e
|
||||
corepack enable
|
||||
pnpm install --frozen-lockfile
|
||||
- name: typecheck
|
||||
# Vite does NOT typecheck — `vite build` transpiles and strips types, so a
|
||||
# type error ships a green image. This is the only thing in the pipeline
|
||||
# that reads the types across all 7 workspace packages.
|
||||
run: |
|
||||
set -e
|
||||
pnpm -r tc
|
||||
- name: unit
|
||||
# vitest over pkgs/**/src and apps/**/src (vitest.config.ts). 160 tests,
|
||||
# including the org resolver that decides which brand a host authenticates
|
||||
# as and which redirect_uri every social hop sends — the surface that
|
||||
# produced days of "login is broken" and cannot be verified by looking at it.
|
||||
run: |
|
||||
set -e
|
||||
pnpm test
|
||||
|
||||
images:
|
||||
- name: id
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
repo: ghcr.io/hanzoai/id
|
||||
platforms: [linux/amd64]
|
||||
# Fetched from KMS (`deploy/PUBLISHABLE_KEY`, env `prod`) and passed as
|
||||
# --build-arg PUBLISHABLE_KEY. The reusable fails closed on an empty value,
|
||||
# and the Dockerfile gates the shape and then asserts the key actually landed
|
||||
# in the bundle — because a build that is green with an unattributed bundle
|
||||
# is the failure mode here, not a build that goes red.
|
||||
#
|
||||
# This is the ONE spelling the fleet carries: KMS holds the plain name, each
|
||||
# Dockerfile re-exports it with the prefix its bundler inlines.
|
||||
build_secrets: [PUBLISHABLE_KEY]
|
||||
# The version comes from package.json — hanzoai/ci's bin/imgver reads it as
|
||||
# the declared floor, exactly as deploy.yml's `node -p require('./package.
|
||||
# json').version` did, so a release is still a version bump and nothing about
|
||||
# how this image is named changes.
|
||||
#
|
||||
# One difference worth stating: imgver takes max(declared, published)+1 patch
|
||||
# when the declared version is ALREADY at the registry, where deploy.yml
|
||||
# failed the build instead ("bump the version to cut a release"). Both refuse
|
||||
# to put a second digest under a tag someone may already be pinning; this one
|
||||
# publishes the next patch rather than going red on a docs commit.
|
||||
#
|
||||
# oci.hanzo.ai is still written to — the reusable crane-copies the exact tag
|
||||
# set after the ghcr push. The PATH changes: deploy.yml pushed
|
||||
# `oci.hanzo.ai/id`, the reusable writes the org-qualified
|
||||
# `oci.hanzo.ai/hanzoai/id`, which is the fleet convention. Nothing live
|
||||
# pulls either one; charts/app/values/hanzo/id.yaml pins
|
||||
# `ghcr.io/hanzoai/id` by tag AND digest.
|
||||
|
||||
# No `deploy:` ON PURPOSE, and this is deploy.yml's own rule, not a new one.
|
||||
#
|
||||
# id is governed by Hanzo CD. The tag it runs is declared in
|
||||
# hanzoai/universe (charts/app/values/hanzo/id.yaml, tag + digest together), and
|
||||
# the in-cluster reconcile restores that within ~60-90s. A CI-side `kubectl
|
||||
# patch` therefore CANNOT stick — and the reason to refuse it is not that it
|
||||
# fails, it is that it LOOKS like it works: the patch applies, the pod rolls, and
|
||||
# CD quietly puts the old tag back a minute later.
|
||||
#
|
||||
# It would also be actively wrong here. The reusable's deploy step guards a
|
||||
# semver pin against a transient branch build by reading the current tag out of
|
||||
# `infra/k8s/operator/crs/<svc>.yaml` — a path this service does not use — so the
|
||||
# guard would not fire and a `sha-<short>-amd64` tag would be written over a
|
||||
# reviewed semver+digest pin on the live sign-in surface for every property in
|
||||
# the fleet.
|
||||
#
|
||||
# Build and deploy are separate concerns: this emits an immutable image, git
|
||||
# declares desired state, CD applies it. To ship a build, set image.tag AND
|
||||
# image.digest in universe and push.
|
||||
+7
-3
@@ -1,15 +1,19 @@
|
||||
{
|
||||
"name": "@hanzo/id",
|
||||
"private": true,
|
||||
"version": "0.1.25",
|
||||
"description": "Hanzo ID \u2014 white-label login + identity verification portal (Vite + @hanzo/gui)",
|
||||
"version": "0.2.28",
|
||||
"description": "Hanzo ID — white-label login + identity verification portal (Vite + @hanzo/gui)",
|
||||
"packageManager": "pnpm@10.15.0",
|
||||
"scripts": {
|
||||
"build": "pnpm -r build",
|
||||
"dev": "pnpm --filter @hanzo/id-web dev",
|
||||
"tc": "pnpm -r tc",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"clean": "bash scripts/clean.sh"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3"
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hanzo/id-auth",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.8",
|
||||
"description": "Composable login / signup / OTP / OAuth-PKCE flows on top of @hanzo/iam. UI primitives in @hanzo/gui.",
|
||||
"license": "BSD-3-Clause",
|
||||
"type": "module",
|
||||
@@ -16,13 +16,13 @@
|
||||
"src"
|
||||
],
|
||||
"scripts": {
|
||||
"tc": "tsc --noEmit",
|
||||
"test": "node --test --experimental-strip-types 'src/**/*.test.ts'"
|
||||
"tc": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hanzo/id-connect": "workspace:*",
|
||||
"@hanzo/id-shared": "workspace:*",
|
||||
"@hanzo/iam": "^0.13.1"
|
||||
"@hanzo/iam": "^0.21.1",
|
||||
"@paulmillr/qr": "^0.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=19",
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* MFA wiring tests — pure, no network (fetch is mocked via `fetchImpl`).
|
||||
* Run with: pnpm --filter @hanzo/id-auth test
|
||||
*
|
||||
* Locks the wire contract verified live against iam.hanzo.ai:
|
||||
* - login answers a forced-MFA org with `data:"RequiredMfa"` (enroll) or
|
||||
* `data:"NextMfa"` + the challenge list — named `mfa` first, legacy
|
||||
* `data2`; both decode until the legacy slot is deleted. STRINGS, never a
|
||||
* boolean.
|
||||
* - the `/v1/iam/mfa/setup/*` calls carry EVERY param on the query string with
|
||||
* an EMPTY body (the one shape IAM's authz self-match + controller accept).
|
||||
* - the challenge re-POSTs `/v1/iam/login` with `{mfaType,passcode}` and NO
|
||||
* username, riding the MFA session cookie.
|
||||
*/
|
||||
import { test } from 'vitest'
|
||||
import assert from 'node:assert/strict'
|
||||
import type { OrgConfig } from '@hanzo/id-shared'
|
||||
import { createAuthClient, mfaChannelOf, MFA_TOTP } from './client.ts'
|
||||
|
||||
const TENANT: OrgConfig = {
|
||||
orgId: 'hanzo',
|
||||
iamUrl: 'https://hanzo.id',
|
||||
iamIssuer: 'https://hanzo.id',
|
||||
clientId: 'hanzo-id',
|
||||
appName: 'hanzo-id',
|
||||
publicOrigin: 'https://hanzo.id',
|
||||
brandPackage: '@hanzo/brand',
|
||||
}
|
||||
|
||||
type Call = { url: string; init: RequestInit }
|
||||
|
||||
function mockFetch(body: unknown, calls: Call[]): typeof fetch {
|
||||
return (async (input: string | URL, init?: RequestInit) => {
|
||||
calls.push({ url: String(input), init: init ?? {} })
|
||||
return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } })
|
||||
}) as unknown as typeof fetch
|
||||
}
|
||||
|
||||
test('login → RequiredMfa maps to an enroll signal (not a redirect)', async () => {
|
||||
const calls: Call[] = []
|
||||
const client = createAuthClient({ org: TENANT, fetchImpl: mockFetch({ status: 'ok', data: 'RequiredMfa' }, calls) })
|
||||
const res = await client.login({
|
||||
identifier: 'davelorenzini@gmail.com',
|
||||
password: 'x',
|
||||
clientId: 'hanzo-id',
|
||||
application: 'hanzo-id',
|
||||
organization: 'hanzo',
|
||||
})
|
||||
assert.equal(res.mfaRequired, true)
|
||||
assert.equal(res.mfaStage, 'enroll')
|
||||
assert.equal(res.redirectUrl, undefined, 'must NOT short-circuit to /onboarding')
|
||||
})
|
||||
|
||||
const CHALLENGE = [{ mfaType: 'app', enabled: true }, { mfaType: 'sms', enabled: true }]
|
||||
|
||||
// Both spellings decode until IAM's envelope rename lands everywhere and the
|
||||
// legacy slot is deleted: named `mfa` (new), untyped `data2` (legacy), and
|
||||
// named-first precedence when a transitional server sends both.
|
||||
test.each([
|
||||
['named mfa', { status: 'ok', data: 'NextMfa', mfa: CHALLENGE }],
|
||||
['legacy data2', { status: 'ok', data: 'NextMfa', data2: CHALLENGE }],
|
||||
['mfa wins over data2', { status: 'ok', data: 'NextMfa', mfa: CHALLENGE, data2: [{ mfaType: 'email', enabled: true }] }],
|
||||
])('login → NextMfa maps to a challenge signal and carries the allowed types (%s)', async (_spelling, body) => {
|
||||
const calls: Call[] = []
|
||||
const client = createAuthClient({ org: TENANT, fetchImpl: mockFetch(body, calls) })
|
||||
const res = await client.login({
|
||||
identifier: 'davelorenzini@gmail.com',
|
||||
password: 'x',
|
||||
clientId: 'hanzo-id',
|
||||
application: 'hanzo-id',
|
||||
organization: 'hanzo',
|
||||
})
|
||||
assert.equal(res.mfaStage, 'challenge')
|
||||
assert.deepEqual(res.mfaTypes, ['app', 'sms'])
|
||||
})
|
||||
|
||||
test('mfaInitiate puts owner/name/mfaType on the query string with an empty body', async () => {
|
||||
const calls: Call[] = []
|
||||
const data = { secret: 'BOUYRUSHJCEDDB33', url: 'otpauth://totp/Hanzo:x?secret=BOUYRUSHJCEDDB33', recoveryCodes: ['rc-1'] }
|
||||
const client = createAuthClient({ org: TENANT, fetchImpl: mockFetch({ status: 'ok', data }, calls) })
|
||||
const setup = await client.mfaInitiate({ owner: 'hanzo', name: 'davelorenzini@gmail.com' })
|
||||
|
||||
assert.equal(setup.secret, 'BOUYRUSHJCEDDB33')
|
||||
assert.equal(setup.mfaType, MFA_TOTP)
|
||||
assert.deepEqual(setup.recoveryCodes, ['rc-1'])
|
||||
|
||||
const u = new URL(calls[0].url)
|
||||
assert.equal(u.pathname, '/v1/iam/mfa/setup/initiate')
|
||||
assert.equal(u.searchParams.get('owner'), 'hanzo')
|
||||
assert.equal(u.searchParams.get('name'), 'davelorenzini@gmail.com')
|
||||
assert.equal(u.searchParams.get('mfaType'), 'app')
|
||||
assert.equal(calls[0].init.method, 'POST')
|
||||
assert.equal(calls[0].init.body, undefined, 'body must be empty for authz self-match')
|
||||
assert.equal(calls[0].init.credentials, 'include')
|
||||
})
|
||||
|
||||
test('mfaVerify carries owner/name (for authz) + secret + passcode on the query', async () => {
|
||||
const calls: Call[] = []
|
||||
const client = createAuthClient({ org: TENANT, fetchImpl: mockFetch({ status: 'ok', data: 'OK' }, calls) })
|
||||
const r = await client.mfaVerify({ owner: 'hanzo', name: 'dave@x', secret: 'SEC', passcode: '123456' })
|
||||
assert.equal(r.ok, true)
|
||||
const u = new URL(calls[0].url)
|
||||
assert.equal(u.pathname, '/v1/iam/mfa/setup/verify')
|
||||
assert.equal(u.searchParams.get('owner'), 'hanzo')
|
||||
assert.equal(u.searchParams.get('secret'), 'SEC')
|
||||
assert.equal(u.searchParams.get('passcode'), '123456')
|
||||
assert.equal(u.searchParams.get('mfaType'), 'app')
|
||||
})
|
||||
|
||||
test('mfaVerify surfaces an IAM error instead of throwing', async () => {
|
||||
const calls: Call[] = []
|
||||
const client = createAuthClient({ org: TENANT, fetchImpl: mockFetch({ status: 'error', msg: 'wrong passcode' }, calls) })
|
||||
const r = await client.mfaVerify({ owner: 'hanzo', name: 'dave@x', secret: 'SEC', passcode: '000000' })
|
||||
assert.equal(r.ok, false)
|
||||
assert.equal(r.error, 'wrong passcode')
|
||||
})
|
||||
|
||||
test('mfaEnable echoes the recovery code back on the query', async () => {
|
||||
const calls: Call[] = []
|
||||
const client = createAuthClient({ org: TENANT, fetchImpl: mockFetch({ status: 'ok', data: 'OK' }, calls) })
|
||||
const r = await client.mfaEnable({ owner: 'hanzo', name: 'dave@x', secret: 'SEC', recoveryCode: 'rc-1' })
|
||||
assert.equal(r.ok, true)
|
||||
const u = new URL(calls[0].url)
|
||||
assert.equal(u.pathname, '/v1/iam/mfa/setup/enable')
|
||||
assert.equal(u.searchParams.get('recoveryCodes'), 'rc-1')
|
||||
assert.equal(u.searchParams.get('secret'), 'SEC')
|
||||
})
|
||||
|
||||
test('mfaChallenge re-POSTs /v1/iam/login with mfaType/passcode and NO username', async () => {
|
||||
const calls: Call[] = []
|
||||
// code flow: data is the freshly minted auth code
|
||||
const client = createAuthClient({ org: TENANT, fetchImpl: mockFetch({ status: 'ok', data: 'AUTHCODE' }, calls) })
|
||||
const res = await client.mfaChallenge({
|
||||
mfaType: 'app',
|
||||
passcode: '654321',
|
||||
clientId: 'hanzo-id',
|
||||
application: 'hanzo-id',
|
||||
organization: 'hanzo',
|
||||
redirectUri: 'https://app.example/cb',
|
||||
state: 'st',
|
||||
})
|
||||
const sent = JSON.parse(String(calls[0].init.body)) as Record<string, unknown>
|
||||
assert.equal(new URL(calls[0].url).pathname, '/v1/iam/login')
|
||||
assert.equal(sent.mfaType, 'app')
|
||||
assert.equal(sent.passcode, '654321')
|
||||
assert.equal(sent.username, undefined, 'challenge must not send a username')
|
||||
assert.equal(calls[0].init.credentials, 'include')
|
||||
assert.equal(res.redirectUrl, 'https://app.example/cb?code=AUTHCODE&state=st')
|
||||
})
|
||||
|
||||
test('mfaChannelOf maps IAM types to UI channels', () => {
|
||||
assert.equal(mfaChannelOf('app'), 'totp')
|
||||
assert.equal(mfaChannelOf('sms'), 'sms')
|
||||
assert.equal(mfaChannelOf('email'), 'email')
|
||||
assert.equal(mfaChannelOf('anything-else'), 'totp')
|
||||
})
|
||||
+483
-80
@@ -1,7 +1,7 @@
|
||||
import { test } from 'node:test'
|
||||
import { test } from 'vitest'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createAuthClient } from './client.ts'
|
||||
import type { TenantConfig } from '@hanzo/id-shared'
|
||||
import type { OrgConfig } from '@hanzo/id-shared'
|
||||
|
||||
// A capturing fetch double: records the URL + parsed JSON body of the last call
|
||||
// and returns a canned IAM "ok" response. No network.
|
||||
@@ -20,7 +20,34 @@ function capturingFetch() {
|
||||
return { calls, fetchImpl }
|
||||
}
|
||||
|
||||
function tenant(overrides: Partial<TenantConfig> = {}): TenantConfig {
|
||||
// A routing fetch double for the silent-SSO org gate: silentLogin resolves the
|
||||
// app's org (`/v1/iam/get-app-login`) and the ambient session's owner
|
||||
// (`/v1/iam/get-account`) BEFORE minting a code (`/v1/iam/login`). This lets a
|
||||
// test set the app org + session owner independently and assert whether the mint
|
||||
// leg ran. `sessionOwner: null` models "no live session" (get-account errors).
|
||||
function routingFetch(opts: { appOrg: string; sessionOwner: string | null; code?: string }) {
|
||||
const calls: { url: string; body: Record<string, unknown> }[] = []
|
||||
const json = (payload: unknown) =>
|
||||
new Response(JSON.stringify(payload), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
||||
const fetchImpl: typeof fetch = async (input, init) => {
|
||||
const url = typeof input === 'string' ? input : input.toString()
|
||||
let body: Record<string, unknown> = {}
|
||||
if (init?.body && typeof init.body === 'string') body = JSON.parse(init.body)
|
||||
calls.push({ url, body })
|
||||
if (url.includes('/get-app-login')) {
|
||||
return json({ status: 'ok', data: { name: 'app', organization: opts.appOrg, providers: [] } })
|
||||
}
|
||||
if (url.includes('/get-account')) {
|
||||
return opts.sessionOwner
|
||||
? json({ status: 'ok', data: { owner: opts.sessionOwner, name: 'z' } })
|
||||
: json({ status: 'error', msg: 'please sign in first' })
|
||||
}
|
||||
return json({ status: 'ok', data: opts.code ?? 'AUTHCODE' })
|
||||
}
|
||||
return { calls, fetchImpl }
|
||||
}
|
||||
|
||||
function org(overrides: Partial<OrgConfig> = {}): OrgConfig {
|
||||
return {
|
||||
orgId: 'hanzo',
|
||||
iamUrl: 'https://hanzo.id',
|
||||
@@ -34,27 +61,37 @@ function tenant(overrides: Partial<TenantConfig> = {}): TenantConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// THE FIX: with loginOrg unset, the portal must NOT pin the brand org — it omits
|
||||
// `organization` so IAM resolves the user cross-org (a global admin → the admin
|
||||
// org / full session; a brand user → their own org). Pinning `hanzo` here is the
|
||||
// live bug that truncates a global admin to one org.
|
||||
test('login OMITS organization when loginOrg is unset (org-agnostic resolution)', async () => {
|
||||
// `client.login` is a PURE PASSTHROUGH for `organization`: it sends what the
|
||||
// caller gave it and omits the key when there is nothing to send. That is the
|
||||
// contract these two tests pin, and it is unchanged.
|
||||
//
|
||||
// What DID change is whose job it is to supply one. This used to be deliberate
|
||||
// omission — IAM resolved the user cross-org so a colliding identity
|
||||
// (z@hanzo.ai exists in both `admin` and `hanzo`) landed on admin/* with a full
|
||||
// multi-org session. iam2 removed that on purpose, treating the collision as a
|
||||
// defect ("the F-2 bug where z@hanzo.ai collided across admin and hanzo": it
|
||||
// coupled lockout counters across rows and gave a brute-force oracle on the
|
||||
// superadmin), and now REFUSES an org-less login. So LoginForm resolves the
|
||||
// app's own org via get-app-login and always passes one. Do not re-add an
|
||||
// omit-the-org path here expecting the server to figure it out — it will not,
|
||||
// and it fails with an HTTP 200 that reads like a wrong password.
|
||||
test('login omits organization when the caller supplies none', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
|
||||
await client.login({
|
||||
identifier: 'z@hanzo.ai',
|
||||
password: 'pw',
|
||||
clientId: 'hanzo-console',
|
||||
application: 'hanzo-console',
|
||||
// organization intentionally not provided (LoginForm passes tenant.loginOrg)
|
||||
// organization intentionally not provided — LoginForm now always resolves one
|
||||
})
|
||||
|
||||
assert.equal(calls.length, 1)
|
||||
assert.equal(
|
||||
'organization' in calls[0]!.body,
|
||||
false,
|
||||
'organization must be absent from the body so IAM runs cross-org resolution',
|
||||
'organization must be absent when the caller supplies none — the client never invents one',
|
||||
)
|
||||
// The identity + app still ride the request.
|
||||
assert.equal(calls[0]!.body.username, 'z@hanzo.ai')
|
||||
@@ -63,9 +100,9 @@ test('login OMITS organization when loginOrg is unset (org-agnostic resolution)'
|
||||
|
||||
// An empty-string org is treated the same as unset (defensive: a catalog might
|
||||
// emit "").
|
||||
test('login OMITS organization when it is an empty string', async () => {
|
||||
test('login omits organization when it is an empty string', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
await client.login({
|
||||
identifier: 'z@hanzo.ai',
|
||||
password: 'pw',
|
||||
@@ -79,7 +116,7 @@ test('login OMITS organization when it is an empty string', async () => {
|
||||
// A brand that DELIBERATELY scopes its portal to one org can still force it.
|
||||
test('login INCLUDES organization when one is explicitly provided', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
await client.login({
|
||||
identifier: 'someone',
|
||||
password: 'pw',
|
||||
@@ -95,7 +132,7 @@ test('login INCLUDES organization when one is explicitly provided', async () =>
|
||||
// for the SSO path too).
|
||||
test('app SSO (redirectUri present) uses type=code and still omits organization', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
await client.login({
|
||||
identifier: 'z@hanzo.ai',
|
||||
password: 'pw',
|
||||
@@ -112,7 +149,7 @@ test('app SSO (redirectUri present) uses type=code and still omits organization'
|
||||
// Signup MUST still carry a concrete org — you cannot create a user in "no org".
|
||||
test('signup STILL sends organization (unchanged — create needs a concrete org)', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
await client.signup({
|
||||
email: 'new@hanzo.ai',
|
||||
password: 'pw',
|
||||
@@ -123,7 +160,83 @@ test('signup STILL sends organization (unchanged — create needs a concrete org
|
||||
assert.equal(calls[0]!.body.organization, 'hanzo')
|
||||
})
|
||||
|
||||
// REGRESSION (the `hanzo-iam does not exist` social-login bug): a Casdoor
|
||||
// REGRESSION (every new customer was stranded on the portal): IAM's signup is
|
||||
// CREATE-ONLY — it sets no session and mints no code. Signup must therefore end
|
||||
// in a real sign-in, or the app that sent the user waits forever for a code.
|
||||
test('signup COMPLETES the OIDC request — create, then sign in, then redirect back with the code', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const res = await client.signup({
|
||||
email: 'new@hanzo.ai',
|
||||
password: 'pw',
|
||||
clientId: 'hanzo-app',
|
||||
application: 'hanzo-app',
|
||||
organization: 'hanzo',
|
||||
redirectUri: 'https://hanzo.app/auth/callback',
|
||||
state: 'xyz',
|
||||
codeChallenge: 'CHALLENGE',
|
||||
codeChallengeMethod: 'S256',
|
||||
})
|
||||
|
||||
// Two legs, in order: create the row, then authenticate it.
|
||||
assert.equal(calls.length, 2)
|
||||
assert.match(calls[0]!.url, /\/v1\/iam\/signup/)
|
||||
assert.match(calls[1]!.url, /\/v1\/iam\/login/)
|
||||
|
||||
// The sign-in leg carries the downstream request, so the code is PKCE-bound.
|
||||
assert.match(calls[1]!.url, /code_challenge=CHALLENGE/)
|
||||
assert.match(calls[1]!.url, /code_challenge_method=S256/)
|
||||
assert.match(calls[1]!.url, /type=code/)
|
||||
assert.equal(calls[1]!.body.username, 'new@hanzo.ai')
|
||||
|
||||
// And the caller is handed a destination BACK AT THE APP — never the portal's
|
||||
// own /onboarding, which is where the create-only response used to land.
|
||||
assert.equal(res.redirectUrl, 'https://hanzo.app/auth/callback?code=AUTHCODE&state=xyz')
|
||||
})
|
||||
|
||||
// `autoSignin` was posted for its name and dropped on the floor: the Go
|
||||
// signupForm has no such field, so it never signed anyone in. Do not post a flag
|
||||
// the server does not read — it is what made this look like it worked.
|
||||
test('signup does NOT post autoSignin (IAM has no such field)', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
await client.signup({
|
||||
email: 'new@hanzo.ai',
|
||||
password: 'pw',
|
||||
clientId: 'hanzo-app',
|
||||
application: 'hanzo-app',
|
||||
organization: 'hanzo',
|
||||
})
|
||||
assert.equal('autoSignin' in calls[0]!.body, false)
|
||||
})
|
||||
|
||||
// IAM refuses with HTTP 200 + status:"error", so the status code proves nothing.
|
||||
// A refused create must surface the reason and must NOT go on to try a login.
|
||||
test('a refused signup surfaces the reason and never attempts a sign-in', async () => {
|
||||
const seen: string[] = []
|
||||
const fetchImpl: typeof fetch = async (input) => {
|
||||
seen.push(typeof input === 'string' ? input : input.toString())
|
||||
return new Response(JSON.stringify({ status: 'error', msg: 'email already exists', data: null }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const res = await client.signup({
|
||||
email: 'taken@hanzo.ai',
|
||||
password: 'pw',
|
||||
clientId: 'hanzo-app',
|
||||
application: 'hanzo-app',
|
||||
organization: 'hanzo',
|
||||
redirectUri: 'https://hanzo.app/auth/callback',
|
||||
})
|
||||
assert.equal(res.error, 'email already exists')
|
||||
assert.equal(res.redirectUrl, undefined)
|
||||
assert.equal(seen.length, 1)
|
||||
assert.match(seen[0]!, /\/v1\/iam\/signup/)
|
||||
})
|
||||
|
||||
// REGRESSION (the `hanzo-iam does not exist` social-login bug): an IAM
|
||||
// app-provider LINK can carry an outer `name` that is NOT the provider record's
|
||||
// name (some seeds label it `<org>-iam`). The provider's real identity is the
|
||||
// nested `provider.name` the backend resolves on the social hop. getAppLogin
|
||||
@@ -152,7 +265,7 @@ test('getAppLogin uses the nested provider record name, not the outer link label
|
||||
},
|
||||
],
|
||||
})
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const app = await client.getAppLogin('hanzo-console')
|
||||
assert.ok(app, 'app login resolved')
|
||||
assert.equal(app!.providers.length, 1)
|
||||
@@ -163,37 +276,38 @@ test('getAppLogin uses the nested provider record name, not the outer link label
|
||||
assert.equal(gh.configured, true, 'a real (non-placeholder) clientId is configured')
|
||||
})
|
||||
|
||||
// The social code exchange must reuse the provider's REGISTERED callback host
|
||||
// (oauthCallbackOrigin), not the brand host (publicOrigin). IAM forwards this
|
||||
// redirect_uri verbatim to the provider's token endpoint, which requires it to
|
||||
// match the authorize hop or the exchange fails `invalid_grant`. When a brand
|
||||
// portal (hanzo.id) shares the iam.hanzo.ai OAuth client these two differ.
|
||||
test('providerLogin posts redirectUri from oauthCallbackOrigin (matches the hop), with the single provider + code', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
const client = createAuthClient({
|
||||
tenant: tenant({ publicOrigin: 'https://hanzo.id', oauthCallbackOrigin: 'https://iam.hanzo.ai' }),
|
||||
fetchImpl,
|
||||
})
|
||||
const r = await client.providerLogin({
|
||||
application: 'hanzo-console',
|
||||
provider: 'provider-google',
|
||||
code: 'goog_code_xyz',
|
||||
oidcQuery:
|
||||
'?client_id=hanzo-console&redirect_uri=https%3A%2F%2Fconsole.hanzo.ai%2Fauth%2Fiam%2Fcallback&response_type=code&scope=openid&state=rp1',
|
||||
method: 'signin',
|
||||
})
|
||||
assert.equal(calls.length, 1)
|
||||
assert.equal(
|
||||
calls[0]!.body.redirectUri,
|
||||
'https://iam.hanzo.ai/callback',
|
||||
'redirect_uri derives from oauthCallbackOrigin (the hop), never publicOrigin',
|
||||
// Federation is entered by NAMING the provider on IAM's own authorize endpoint.
|
||||
// The `provider` field has always been on OAuthAuthorizeRequest; `authorize`
|
||||
// never emitted it, which is why social sign-in had no server side at all.
|
||||
test('authorize emits the provider record name, so IAM federates instead of showing its login', () => {
|
||||
const client = createAuthClient({ org: org({ iamUrl: 'https://hanzo.id' }) })
|
||||
const url = new URL(
|
||||
client.authorize({
|
||||
clientId: 'hanzo-console',
|
||||
redirectUri: 'https://hanzo.id/callback',
|
||||
state: 'rp1',
|
||||
codeChallenge: 'C1',
|
||||
codeChallengeMethod: 'S256',
|
||||
provider: 'provider-github',
|
||||
}),
|
||||
)
|
||||
assert.equal(calls[0]!.body.provider, 'provider-google')
|
||||
assert.equal(calls[0]!.body.code, 'goog_code_xyz')
|
||||
// The upstream OIDC params ride the query so IAM continues the original authorize.
|
||||
assert.match(calls[0]!.url, /client_id=hanzo-console/)
|
||||
assert.match(calls[0]!.url, /state=rp1/)
|
||||
assert.equal(r.redirectUrl, 'AUTHCODE')
|
||||
assert.equal(url.pathname, '/v1/iam/oauth/authorize')
|
||||
// The RECORD name, never the bare key: federationProvider matches
|
||||
// ProviderItem.Name exactly (live, `provider=github` is refused).
|
||||
assert.equal(url.searchParams.get('provider'), 'provider-github')
|
||||
// The app's own request is what IAM binds the minted code to.
|
||||
assert.equal(url.searchParams.get('client_id'), 'hanzo-console')
|
||||
assert.equal(url.searchParams.get('redirect_uri'), 'https://hanzo.id/callback')
|
||||
assert.equal(url.searchParams.get('code_challenge'), 'C1')
|
||||
assert.equal(url.searchParams.get('code_challenge_method'), 'S256')
|
||||
})
|
||||
|
||||
test('authorize without a provider stays the ordinary hosted-login request', () => {
|
||||
const client = createAuthClient({ org: org({ iamUrl: 'https://hanzo.id' }) })
|
||||
const url = new URL(
|
||||
client.authorize({ clientId: 'hanzo-console', redirectUri: 'https://hanzo.id/callback', state: 'rp1' }),
|
||||
)
|
||||
assert.equal(url.searchParams.get('provider'), null)
|
||||
})
|
||||
|
||||
// When there is NO nested record (degenerate seed), fall back to the outer label
|
||||
@@ -204,7 +318,7 @@ test('getAppLogin falls back to the outer name when no nested provider record',
|
||||
organization: 'hanzo',
|
||||
providers: [{ name: 'provider-google', canSignIn: true, canSignUp: true, provider: null }],
|
||||
})
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const app = await client.getAppLogin('hanzo-console')
|
||||
assert.ok(app)
|
||||
assert.equal(app!.providers[0]!.name, 'provider-google')
|
||||
@@ -212,10 +326,12 @@ test('getAppLogin falls back to the outer name when no nested provider record',
|
||||
|
||||
// TRUE SSO — the silent leg. silentLogin carries NO credentials: IAM mints the
|
||||
// code from the existing issuer session (cookie sent via credentials:include).
|
||||
// It builds the redirect back to the app from the minted code + state.
|
||||
test('silentLogin posts NO credentials and redirects with the minted code', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
// It builds the redirect back to the app from the minted code + state. The mint
|
||||
// runs ONLY when the ambient session's org matches the app's org (same-org SSO,
|
||||
// the common case: a hanzo session signing into a hanzo app).
|
||||
test('silentLogin (same-org session) mints the code and redirects, carrying NO credentials', async () => {
|
||||
const { calls, fetchImpl } = routingFetch({ appOrg: 'hanzo', sessionOwner: 'hanzo' })
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
|
||||
const r = await client.silentLogin({
|
||||
clientId: 'hanzo-console',
|
||||
@@ -225,50 +341,83 @@ test('silentLogin posts NO credentials and redirects with the minted code', asyn
|
||||
codeChallenge: 'chal',
|
||||
})
|
||||
|
||||
assert.equal(calls.length, 1)
|
||||
// The mint leg (POST /v1/iam/login, type=code) ran after the org gate passed.
|
||||
const mint = calls.find((c) => c.body.type === 'code')
|
||||
assert.ok(mint, 'mint leg ran for a same-org session')
|
||||
// No credentials of any kind — this is session-only.
|
||||
assert.equal('username' in calls[0]!.body, false, 'no username in silent login')
|
||||
assert.equal('password' in calls[0]!.body, false, 'no password in silent login')
|
||||
assert.equal('provider' in calls[0]!.body, false, 'no provider hop in silent login')
|
||||
// The body carries the code intent + the target application only.
|
||||
assert.equal(calls[0]!.body.type, 'code')
|
||||
assert.equal(calls[0]!.body.application, 'hanzo-console')
|
||||
assert.equal('username' in mint!.body, false, 'no username in silent login')
|
||||
assert.equal('password' in mint!.body, false, 'no password in silent login')
|
||||
assert.equal('provider' in mint!.body, false, 'no provider hop in silent login')
|
||||
assert.equal(mint!.body.application, 'hanzo-console')
|
||||
// OAuth params ride the query so IAM mints a code for the right client + PKCE.
|
||||
assert.match(calls[0]!.url, /clientId=hanzo-console/)
|
||||
assert.match(calls[0]!.url, /code_challenge=chal/)
|
||||
// The capturing fetch returns data:'AUTHCODE' -> a fully-formed app redirect.
|
||||
assert.match(mint!.url, /clientId=hanzo-console/)
|
||||
assert.match(mint!.url, /code_challenge=chal/)
|
||||
// The mint returns data:'AUTHCODE' -> a fully-formed app redirect.
|
||||
assert.equal(
|
||||
r.redirectUrl,
|
||||
'https://console.hanzo.ai/auth/iam/callback?code=AUTHCODE&state=st1',
|
||||
)
|
||||
})
|
||||
|
||||
// No live session: IAM answers status:error -> silentLogin surfaces { error }
|
||||
// so Login.tsx falls back to the interactive form (never a dead end).
|
||||
test('silentLogin returns { error } when there is no session (form fallback)', async () => {
|
||||
const fetchImpl: typeof fetch = async () =>
|
||||
new Response(JSON.stringify({ status: 'error', msg: 'please sign in first' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
// THE ADMIN-GUARD FIX: silent SSO must NOT reuse a session that belongs to a
|
||||
// DIFFERENT org than the app being signed into. An operator with an ambient
|
||||
// hanzo/* session hitting the admin-guard (org=admin) must fall through to the
|
||||
// interactive form (which authenticates in the admin org and resolves the
|
||||
// admin/* identity) — NOT silently mint a code from the hanzo session (which
|
||||
// would confer owner=hanzo and shadow the fix). No mint leg runs; no redirect.
|
||||
test('silentLogin (cross-org session) does NOT mint — falls back to the form', async () => {
|
||||
const { calls, fetchImpl } = routingFetch({ appOrg: 'admin', sessionOwner: 'hanzo' })
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
|
||||
const r = await client.silentLogin({
|
||||
clientId: 'hanzo-admin-guard',
|
||||
application: 'hanzo-admin-guard',
|
||||
redirectUri: 'https://admin.hanzo.ai/__guard/callback',
|
||||
state: 'st1',
|
||||
codeChallenge: 'chal',
|
||||
})
|
||||
|
||||
assert.equal(r.redirectUrl, undefined, 'no silent redirect for a cross-org session')
|
||||
assert.equal(calls.some((c) => c.body.type === 'code'), false, 'the mint leg must NOT run')
|
||||
})
|
||||
|
||||
// Same-org SSO still holds when BOTH are the admin org: an operator already
|
||||
// signed in as admin/* silently re-enters the admin console.
|
||||
test('silentLogin (same admin-org session) mints for the admin-guard', async () => {
|
||||
const { calls, fetchImpl } = routingFetch({ appOrg: 'admin', sessionOwner: 'admin' })
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const r = await client.silentLogin({
|
||||
clientId: 'hanzo-admin-guard',
|
||||
application: 'hanzo-admin-guard',
|
||||
redirectUri: 'https://admin.hanzo.ai/__guard/callback',
|
||||
state: 'st1',
|
||||
})
|
||||
assert.ok(calls.find((c) => c.body.type === 'code'), 'mint leg ran for a same-org admin session')
|
||||
assert.equal(r.redirectUrl, 'https://admin.hanzo.ai/__guard/callback?code=AUTHCODE&state=st1')
|
||||
})
|
||||
|
||||
// No live session: silentLogin returns an empty response (no mint) so Login.tsx
|
||||
// falls back to the interactive form (never a dead end).
|
||||
test('silentLogin returns empty (no mint) when there is no session (form fallback)', async () => {
|
||||
const { calls, fetchImpl } = routingFetch({ appOrg: 'hanzo', sessionOwner: null })
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const r = await client.silentLogin({
|
||||
clientId: 'hanzo-console',
|
||||
application: 'hanzo-console',
|
||||
redirectUri: 'https://console.hanzo.ai/auth/iam/callback',
|
||||
})
|
||||
assert.equal(r.redirectUrl, undefined)
|
||||
assert.equal(r.error, 'please sign in first')
|
||||
assert.equal(calls.some((c) => c.body.type === 'code'), false, 'no mint without a session')
|
||||
})
|
||||
|
||||
// ── Device-authorization approval (RFC 8628) ─────────────────────────────────
|
||||
// approveDevice rides the issuer SESSION (like silentLogin): NO credentials in
|
||||
// the body, `type:device` + the userCode IAM keys its DeviceAuthMap on, plus the
|
||||
// tenant application/organization for the app lookup. On {status:ok} the device
|
||||
// org application/organization for the app lookup. On {status:ok} the device
|
||||
// code is approved (UserSignIn=true) and the CLI's token poll succeeds.
|
||||
test('approveDevice posts type=device + normalized userCode + tenant app/org, NO credentials', async () => {
|
||||
test('approveDevice posts type=device + normalized userCode + org app/org, NO credentials', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
|
||||
const r = await client.approveDevice('K7M4P2QH')
|
||||
|
||||
@@ -290,7 +439,7 @@ test('approveDevice posts type=device + normalized userCode + tenant app/org, NO
|
||||
// TO uppercase so the lookup matches — case-insensitive entry, exact-match send.
|
||||
test('approveDevice uppercases and strips spaces/dashes before sending', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
await client.approveDevice(' k7m4-p2qh ')
|
||||
assert.equal(calls[0]!.body.userCode, 'K7M4P2QH')
|
||||
})
|
||||
@@ -298,7 +447,7 @@ test('approveDevice uppercases and strips spaces/dashes before sending', async (
|
||||
// An empty/blank code never hits the network — fail fast with a clear message.
|
||||
test('approveDevice rejects an empty code without calling fetch', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const r = await client.approveDevice(' ')
|
||||
assert.equal(calls.length, 0)
|
||||
assert.equal(r.ok, false)
|
||||
@@ -312,7 +461,7 @@ test('approveDevice surfaces the IAM error message', async () => {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const r = await client.approveDevice('K7M4P2QH')
|
||||
assert.equal(r.ok, false)
|
||||
assert.equal(r.error, 'UserCode Expired')
|
||||
@@ -326,9 +475,263 @@ test('approveDevice maps the consent-required branch to { required: true }', asy
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const r = await client.approveDevice('K7M4P2QH')
|
||||
assert.equal(r.ok, false)
|
||||
assert.equal(r.required, true)
|
||||
assert.equal(r.error, undefined)
|
||||
})
|
||||
|
||||
// ── Which application is this code for? (deviceInfo) ─────────────────────────
|
||||
// A one-call double for `POST /v1/iam/oauth/device/info`: records what the
|
||||
// request actually was (URL, method, credentials, body) and answers with
|
||||
// `payload`.
|
||||
function deviceInfoFetch(payload: unknown) {
|
||||
const calls: {
|
||||
url: string
|
||||
method?: string
|
||||
credentials?: RequestCredentials
|
||||
body?: string
|
||||
}[] = []
|
||||
const fetchImpl: typeof fetch = async (input, init) => {
|
||||
calls.push({
|
||||
url: typeof input === 'string' ? input : input.toString(),
|
||||
method: init?.method,
|
||||
credentials: init?.credentials,
|
||||
body: typeof init?.body === 'string' ? init.body : undefined,
|
||||
})
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
return { calls, fetchImpl }
|
||||
}
|
||||
|
||||
// THE REGRESSION THIS FILE EXISTS FOR. The approval page used to render
|
||||
// `org.appName` — the PORTAL's own branding, the static `hanzo-console` this
|
||||
// test's org() is configured with — so a device sign-in started by `hanzo-cli`
|
||||
// was approved under a screen naming a different application. The name must come
|
||||
// off the RESPONSE, which is the code's own application, and never off the org
|
||||
// config; asserting both is what keeps the two from being confused again.
|
||||
test('deviceInfo names the RESPONSE client, never the portal org appName', async () => {
|
||||
const { calls, fetchImpl } = deviceInfoFetch({
|
||||
status: 'ok',
|
||||
data: { clientId: 'hanzo-cli', displayName: 'Hanzo CLI' },
|
||||
})
|
||||
const cfg = org()
|
||||
const client = createAuthClient({ org: cfg, fetchImpl })
|
||||
|
||||
const r = await client.deviceInfo('K7M4P2QH')
|
||||
|
||||
assert.equal(r.ok, true)
|
||||
assert.equal(r.ok && r.clientId, 'hanzo-cli')
|
||||
assert.equal(r.ok && r.displayName, 'Hanzo CLI')
|
||||
// The portal is hanzo-console. Nothing about it may reach the result.
|
||||
assert.equal(cfg.appName, 'hanzo-console')
|
||||
assert.notEqual(r.ok && r.clientId, cfg.appName)
|
||||
assert.notEqual(r.ok && r.displayName, cfg.appName)
|
||||
|
||||
// A session-cookie POST at the /v1/ device-info path. The user_code is the one
|
||||
// secret in this flow, so it rides the BODY: a request line is copied into
|
||||
// ingress and proxy access logs where a body is not, and this page ships
|
||||
// scrubUrl() precisely to keep the code out of URLs.
|
||||
assert.equal(calls.length, 1)
|
||||
assert.equal(calls[0]!.url, 'https://hanzo.id/v1/iam/oauth/device/info')
|
||||
assert.equal(calls[0]!.method, 'POST')
|
||||
assert.equal(calls[0]!.credentials, 'include')
|
||||
assert.equal(calls[0]!.body, JSON.stringify({ userCode: 'K7M4P2QH' }))
|
||||
assert.equal(calls[0]!.url.includes('K7M4P2QH'), false)
|
||||
})
|
||||
|
||||
// Same normalization as the approval: a code transcribed lower-cased or with
|
||||
// dashes must resolve to the same row IAM minted, or the page would refuse to
|
||||
// name an application that is perfectly live.
|
||||
test('deviceInfo uppercases and strips spaces/dashes into the body', async () => {
|
||||
const { calls, fetchImpl } = deviceInfoFetch({
|
||||
status: 'ok',
|
||||
data: { clientId: 'hanzo-cli', displayName: 'Hanzo CLI' },
|
||||
})
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
await client.deviceInfo(' k7m4-p2qh ')
|
||||
assert.equal(calls[0]!.url, 'https://hanzo.id/v1/iam/oauth/device/info')
|
||||
assert.equal(calls[0]!.body, JSON.stringify({ userCode: 'K7M4P2QH' }))
|
||||
})
|
||||
|
||||
// An empty code names nothing and never hits the network.
|
||||
test('deviceInfo rejects an empty code without calling fetch', async () => {
|
||||
const { calls, fetchImpl } = deviceInfoFetch({ status: 'ok', data: {} })
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const r = await client.deviceInfo(' ')
|
||||
assert.equal(calls.length, 0)
|
||||
assert.equal(r.ok, false)
|
||||
assert.equal(r.ok === false && r.loginRequired, undefined)
|
||||
})
|
||||
|
||||
// IAM `CodeLoginRequired`: the session lapsed. Flagged separately from a refusal
|
||||
// because the page's answer is to sign the human in and come back, not to give up.
|
||||
test('deviceInfo flags login_required distinctly from a refusal', async () => {
|
||||
const { fetchImpl } = deviceInfoFetch({
|
||||
status: 'error',
|
||||
msg: 'please sign in first',
|
||||
code: 'login_required',
|
||||
})
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const r = await client.deviceInfo('K7M4P2QH')
|
||||
assert.equal(r.ok, false)
|
||||
assert.equal(r.ok === false && r.loginRequired, true)
|
||||
assert.equal(r.ok === false && r.error, 'please sign in first')
|
||||
})
|
||||
|
||||
// The ONE opaque refusal IAM answers for unknown / expired / already-approved —
|
||||
// surfaced verbatim, carrying no loginRequired, so the page shows it and offers
|
||||
// no approval. Distinguishing those three would be an oracle for hunting the
|
||||
// 40-bit user_code; the client must not invent a distinction either.
|
||||
test('deviceInfo surfaces the opaque refusal verbatim and does not name an app', async () => {
|
||||
const { fetchImpl } = deviceInfoFetch({
|
||||
status: 'error',
|
||||
msg: 'the user code is invalid or expired',
|
||||
})
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const r = await client.deviceInfo('K7M4P2QH')
|
||||
assert.equal(r.ok, false)
|
||||
assert.equal(r.ok === false && r.error, 'the user code is invalid or expired')
|
||||
assert.equal(r.ok === false && r.loginRequired, undefined)
|
||||
})
|
||||
|
||||
// The org-boundary refusal is a plain refusal too: surfaced, not special-cased.
|
||||
test('deviceInfo surfaces the wrong-org refusal', async () => {
|
||||
const { fetchImpl } = deviceInfoFetch({
|
||||
status: 'error',
|
||||
msg: 'your organization may not approve this device sign-in',
|
||||
})
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const r = await client.deviceInfo('K7M4P2QH')
|
||||
assert.equal(r.ok, false)
|
||||
assert.equal(r.ok === false && r.error, 'your organization may not approve this device sign-in')
|
||||
})
|
||||
|
||||
// An HTML error page from a proxy is not an application name. It must fail,
|
||||
// never resolve to a blank or guessed one.
|
||||
test('deviceInfo fails on a non-JSON response', async () => {
|
||||
const fetchImpl: typeof fetch = async () =>
|
||||
new Response('<html>502 Bad Gateway</html>', {
|
||||
status: 502,
|
||||
headers: { 'Content-Type': 'text/html' },
|
||||
})
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const r = await client.deviceInfo('K7M4P2QH')
|
||||
assert.equal(r.ok, false)
|
||||
assert.match(String(r.ok === false && r.error), /non-JSON/)
|
||||
})
|
||||
|
||||
// A network failure resolves — never rejects — so the page renders the failure
|
||||
// instead of tearing down on an unhandled rejection.
|
||||
test('deviceInfo resolves an error when fetch throws', async () => {
|
||||
const fetchImpl: typeof fetch = async () => {
|
||||
throw new Error('offline')
|
||||
}
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const r = await client.deviceInfo('K7M4P2QH')
|
||||
assert.equal(r.ok, false)
|
||||
assert.match(String(r.ok === false && r.error), /offline/)
|
||||
})
|
||||
|
||||
// A 200 that names no client is not a name. Falling back to ANY local string here
|
||||
// is what produced the original defect, so an absent clientId is a failure.
|
||||
test('deviceInfo refuses an ok response with no clientId', async () => {
|
||||
const { fetchImpl } = deviceInfoFetch({ status: 'ok', data: { displayName: 'Hanzo CLI' } })
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const r = await client.deviceInfo('K7M4P2QH')
|
||||
assert.equal(r.ok, false)
|
||||
})
|
||||
|
||||
// IAM already falls back to the app's name when DisplayName is empty; if one ever
|
||||
// arrives blank anyway, the label is the server-confirmed clientId — never the portal's.
|
||||
test('deviceInfo falls back to the confirmed clientId when displayName is empty', async () => {
|
||||
const { fetchImpl } = deviceInfoFetch({ status: 'ok', data: { clientId: 'hanzo-cli', displayName: '' } })
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const r = await client.deviceInfo('K7M4P2QH')
|
||||
assert.equal(r.ok, true)
|
||||
assert.equal(r.ok && r.displayName, 'hanzo-cli')
|
||||
})
|
||||
|
||||
// getAppLogin's redirectUri is validated by IAM against the app's REGISTERED
|
||||
// list. A cross-app SSO read (the console's `hanzo-cloud` viewed from hanzo.id)
|
||||
// MUST send the downstream app's OWN redirect_uri — the portal's `/callback` is
|
||||
// not in that app's list, so hardcoding it makes IAM drop the response and no
|
||||
// social buttons resolve. Absent, it defaults to the portal's own callback.
|
||||
test('getAppLogin sends the passed redirect_uri, and defaults to the portal callback when omitted', async () => {
|
||||
const urls: string[] = []
|
||||
const fetchImpl: typeof fetch = async (input) => {
|
||||
urls.push(typeof input === 'string' ? input : input.toString())
|
||||
return new Response(
|
||||
JSON.stringify({ status: 'ok', data: { name: 'hanzo-cloud', organization: 'hanzo', providers: [] } }),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
)
|
||||
}
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
|
||||
// Cross-app read: the console's registered redirect_uri rides through verbatim.
|
||||
await client.getAppLogin('hanzo-cloud', 'https://console.hanzo.ai/auth/callback')
|
||||
const u1 = new URL(urls[0]!)
|
||||
assert.equal(u1.searchParams.get('clientId'), 'hanzo-cloud')
|
||||
assert.equal(u1.searchParams.get('redirectUri'), 'https://console.hanzo.ai/auth/callback')
|
||||
|
||||
// Bare/own read: no redirect_uri → default to the portal's own /callback.
|
||||
await client.getAppLogin('hanzo-id')
|
||||
const u2 = new URL(urls[1]!)
|
||||
assert.equal(u2.searchParams.get('redirectUri'), 'https://hanzo.id/callback')
|
||||
})
|
||||
|
||||
// Sign-out has to clear THIS BROWSER, not just end the session at the IdP.
|
||||
//
|
||||
// The bug: Portal navigated straight to client.logout(), which builds the
|
||||
// RP-initiated logout URL and nothing else. The server really did revoke the
|
||||
// token (measured against prod: the leftover token 401s "invalid or revoked"),
|
||||
// but every `hanzo_iam_*` key survived — so the token STRING outlived the
|
||||
// session it named, and anything treating that key's presence as "signed in"
|
||||
// still believed you were.
|
||||
test('signOut clears every hanzo_iam_* key, in BOTH storages, and returns the IdP URL', () => {
|
||||
const store = () => {
|
||||
const m = new Map<string, string>()
|
||||
return {
|
||||
get length() { return m.size },
|
||||
key: (i: number) => [...m.keys()][i] ?? null,
|
||||
getItem: (k: string) => m.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void m.set(k, v),
|
||||
removeItem: (k: string) => void m.delete(k),
|
||||
clear: () => m.clear(),
|
||||
_keys: () => [...m.keys()],
|
||||
}
|
||||
}
|
||||
const ss = store(), ls = store()
|
||||
const g = globalThis as Record<string, unknown>
|
||||
const [ps, pl] = [g.sessionStorage, g.localStorage]
|
||||
g.sessionStorage = ss
|
||||
g.localStorage = ls
|
||||
try {
|
||||
// Three of the SDK's keys, plus a neighbour that must SURVIVE — otherwise
|
||||
// "it cleared everything" would pass this test just as well.
|
||||
for (const s of [ss, ls]) {
|
||||
s.setItem('hanzo_iam_access_token', 'x')
|
||||
s.setItem('hanzo_iam_expires_at', 'x')
|
||||
s.setItem('hanzo_iam_code_verifier:abc', 'x')
|
||||
s.setItem('theme', 'dark')
|
||||
}
|
||||
const client = createAuthClient({
|
||||
org: { orgId: 'hanzo', iamUrl: 'https://hanzo.id', iamIssuer: 'https://hanzo.id',
|
||||
clientId: 'hanzo-console', appName: 'hanzo-console',
|
||||
publicOrigin: 'https://hanzo.id', brandPackage: '@hanzo/brand' } as never,
|
||||
})
|
||||
const url = client.signOut('https://hanzo.id/login')
|
||||
|
||||
assert.deepEqual(ss._keys(), ['theme'], 'sessionStorage')
|
||||
assert.deepEqual(ls._keys(), ['theme'], 'localStorage')
|
||||
assert.match(url, /\/v1\/iam\/oauth\/logout\?/)
|
||||
assert.match(url, /post_logout_redirect_uri=https%3A%2F%2Fhanzo\.id%2Flogin/)
|
||||
} finally {
|
||||
g.sessionStorage = ps
|
||||
g.localStorage = pl
|
||||
}
|
||||
})
|
||||
|
||||
+421
-136
@@ -1,23 +1,36 @@
|
||||
import type { TenantConfig } from '@hanzo/id-shared'
|
||||
import type { OrgConfig } from '@hanzo/id-shared'
|
||||
import type {
|
||||
AppLogin,
|
||||
AppProvider,
|
||||
DeviceApprovalResult,
|
||||
DeviceInfoResult,
|
||||
ForgotRequest,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
MfaChallengeRequest,
|
||||
MfaChannel,
|
||||
MfaIdentity,
|
||||
MfaSetup,
|
||||
OAuthAuthorizeRequest,
|
||||
SignupRequest,
|
||||
SilentLoginRequest,
|
||||
TokenResponse,
|
||||
} from './types'
|
||||
|
||||
/** IAM's TOTP MFA type constant (`object.TotpType`). */
|
||||
export const MFA_TOTP = 'app'
|
||||
|
||||
/** Map an IAM MFA type to the {@link MfaChannel} the OTP UI renders a label for. */
|
||||
export function mfaChannelOf(iamType: string): MfaChannel {
|
||||
return iamType === 'sms' ? 'sms' : iamType === 'email' ? 'email' : 'totp'
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable IAM client.
|
||||
*
|
||||
* Stateless wrapper around the canonical IAM REST surface (Casdoor-compat
|
||||
* paths under `/v1/iam/*` and the OIDC paths under `/v1/iam/oauth/*`). One
|
||||
* client instance per tenant. The portal creates one in `createRoot()`;
|
||||
* Stateless wrapper around the canonical IAM REST surface (paths under
|
||||
* `/v1/iam/*` and the OIDC paths under `/v1/iam/oauth/*`). One
|
||||
* client instance per org. The portal creates one in `createRoot()`;
|
||||
* downstream pages call `.login()`, `.signup()`, `.forgot()`, `.authorize()`
|
||||
* directly.
|
||||
*
|
||||
@@ -34,7 +47,7 @@ import type {
|
||||
* past the redirect.
|
||||
*/
|
||||
export interface AuthClient {
|
||||
readonly tenant: TenantConfig
|
||||
readonly org: OrgConfig
|
||||
login(req: LoginRequest): Promise<LoginResponse>
|
||||
/**
|
||||
* Silent single-sign-on: mint an authorization code from the EXISTING issuer
|
||||
@@ -51,64 +64,106 @@ export interface AuthClient {
|
||||
* issuer — this rides the SAME `iam_session_id` cookie as silent SSO
|
||||
* (`credentials:'include'`, no credentials in the body). It POSTs
|
||||
* `/v1/iam/login` with `type:'device'` + the `userCode` the device shows,
|
||||
* plus the tenant's `application`/`organization`; IAM resolves the user from
|
||||
* plus the org's `application`/`organization`; IAM resolves the user from
|
||||
* the session, flips the device code's `UserSignIn=true`, and the CLI's token
|
||||
* poll then succeeds. Returns `{required:true}` when the app needs consent
|
||||
* first (rare for first-party apps), or `{error}` with the IAM message.
|
||||
*/
|
||||
approveDevice(userCode: string): Promise<DeviceApprovalResult>
|
||||
/**
|
||||
* Name the application a pending device code belongs to, so the approval page
|
||||
* can say WHICH app it is authorizing — `GET
|
||||
* /v1/iam/oauth/device/<user_code>`, riding the same `iam_session_id` cookie
|
||||
* as {@link approveDevice}.
|
||||
*
|
||||
* Read this and render it; never `org.appName`, which is this portal's own
|
||||
* branding and names a different application than the one that minted the
|
||||
* code. IAM answers from the code's own application row.
|
||||
*
|
||||
* Session-gated and deliberately terse: an expired session comes back as
|
||||
* `loginRequired`, and unknown / expired / already-approved all come back as
|
||||
* ONE indistinguishable refusal, because a user_code is 40 bits and an
|
||||
* endpoint that told them apart would be an oracle for hunting live codes.
|
||||
*/
|
||||
deviceInfo(userCode: string): Promise<DeviceInfoResult>
|
||||
signup(req: SignupRequest): Promise<LoginResponse>
|
||||
forgot(req: ForgotRequest): Promise<{ ok: boolean; error?: string }>
|
||||
authorize(req: OAuthAuthorizeRequest): string
|
||||
exchange(code: string, codeVerifier?: string): Promise<TokenResponse>
|
||||
logout(idTokenHint?: string, postLogoutRedirectUri?: string): string
|
||||
/**
|
||||
* Sign out COMPLETELY: drop this browser's own tokens, then hand back the
|
||||
* IdP's RP-initiated logout URL for the caller to navigate to.
|
||||
*
|
||||
* `logout()` alone is only half of it, and the missing half is the half a
|
||||
* person notices. It builds the IdP URL, which ends the session at the
|
||||
* server — measured, the access token really is revoked there — but it
|
||||
* touches nothing this browser stored, so `hanzo_iam_access_token` and its
|
||||
* siblings survive a sign-out. Anything that treats the presence of that key
|
||||
* as "signed in" then still believes you are (hanzoai/playground's AuthGuard
|
||||
* reads exactly that key), and a token string that outlives its session is a
|
||||
* thing to delete on principle even where nothing reads it.
|
||||
*
|
||||
* So sign-out is ONE call, not a URL plus a cleanup every caller has to
|
||||
* remember. Local first, then the redirect: a navigation ends this
|
||||
* document, and anything left after `location.href` is a coin flip.
|
||||
*/
|
||||
signOut(postLogoutRedirectUri?: string): string
|
||||
/**
|
||||
* Read the live enabled-auth-methods view for an application from
|
||||
* `/v1/iam/get-app-login` — the canonical source of truth for which
|
||||
* sign-in buttons (password / GitHub / Google / Web3) to render.
|
||||
* Resolves to null when the endpoint is unreachable so callers can fall
|
||||
* back to the tenant's declared default method set.
|
||||
* back to the org's declared default method set.
|
||||
*
|
||||
* `redirectUri` is validated by IAM against the app's registered list. For a
|
||||
* cross-app SSO read (e.g. console → hanzo.id, `clientId=hanzo-cloud`) pass the
|
||||
* DOWNSTREAM app's own OIDC `redirect_uri` — the portal's `/callback` is NOT in
|
||||
* that app's list, so hardcoding it makes IAM answer `status:error`
|
||||
* ("Redirect URI … doesn't exist in the allowed list") and drops the whole
|
||||
* response. Omit it for a bare/own-app read (defaults to the portal callback).
|
||||
*/
|
||||
getAppLogin(clientId?: string): Promise<AppLogin | null>
|
||||
getAppLogin(clientId?: string, redirectUri?: string): Promise<AppLogin | null>
|
||||
/**
|
||||
* Complete a social provider login when the provider redirects back to
|
||||
* `/callback` with a `code` + base64 `state` (see `social.ts`). Exchanges the
|
||||
* provider code at the IAM backend (the Casdoor `AuthBackend.login` contract)
|
||||
* and resolves the URL to redirect to — the original OIDC `redirect_uri` with
|
||||
* an authorization code, which the portal's normal PKCE callback then
|
||||
* completes. NOTE: pending live verification — runs only once real OAuth
|
||||
* provider creds are seeded (the buttons are hidden until then).
|
||||
* Resolve the signed-in user's `{owner, name}` from the IAM session
|
||||
* (`/v1/iam/get-account`). After a `RequiredMfa` login the IAM session cookie
|
||||
* already authenticates the user (IAM calls `SetSessionUsername` before
|
||||
* answering `RequiredMfa`), so this is how the portal learns the identity to
|
||||
* key the forced-enrollment calls on. Resolves null when unauthenticated.
|
||||
*/
|
||||
providerLogin(req: ProviderExchangeRequest): Promise<{ redirectUrl?: string; error?: string }>
|
||||
}
|
||||
|
||||
/** Inputs to {@link AuthClient.providerLogin}, recovered from the /callback return. */
|
||||
export interface ProviderExchangeRequest {
|
||||
/** IAM application name (from the decoded state). */
|
||||
readonly application: string
|
||||
/** IAM provider record name, e.g. `provider-github`. */
|
||||
readonly provider: string
|
||||
/** The provider's authorization code (the `?code=` on the /callback return). */
|
||||
readonly code: string
|
||||
/** The ORIGINAL OIDC authorize query string (decoded from the base64 state). */
|
||||
readonly oidcQuery: string
|
||||
/** "signin" | "signup". */
|
||||
readonly method: string
|
||||
getAccount(): Promise<MfaIdentity | null>
|
||||
/**
|
||||
* Begin TOTP enrollment: `POST /v1/iam/mfa/setup/initiate`. Returns the secret
|
||||
* + `otpauth://` URI + recovery codes. Does NOT persist anything — only
|
||||
* {@link mfaEnable} does.
|
||||
*/
|
||||
mfaInitiate(id: MfaIdentity): Promise<MfaSetup>
|
||||
/** Verify a TOTP code against a pending secret: `POST /v1/iam/mfa/setup/verify`. */
|
||||
mfaVerify(req: MfaIdentity & { secret: string; passcode: string }): Promise<{ ok: boolean; error?: string }>
|
||||
/** Persist a verified TOTP enrollment: `POST /v1/iam/mfa/setup/enable`. */
|
||||
mfaEnable(req: MfaIdentity & { secret: string; recoveryCode: string }): Promise<{ ok: boolean; error?: string }>
|
||||
/**
|
||||
* Answer a `NextMfa` challenge: `POST /v1/iam/login` with `{mfaType, passcode}`
|
||||
* and NO username, riding the MFA session cookie IAM set with `NextMfa`.
|
||||
* Returns the same shape as {@link login} (a redirect with an auth code for the
|
||||
* code flow, or a bare-session signal for portal sign-in).
|
||||
*/
|
||||
mfaChallenge(req: MfaChallengeRequest): Promise<LoginResponse>
|
||||
}
|
||||
|
||||
export interface AuthClientOptions {
|
||||
readonly tenant: TenantConfig
|
||||
readonly org: OrgConfig
|
||||
/** Override fetch impl (testing). Defaults to global fetch. */
|
||||
readonly fetchImpl?: typeof fetch
|
||||
}
|
||||
|
||||
export function createAuthClient(opts: AuthClientOptions): AuthClient {
|
||||
const tenant = opts.tenant
|
||||
const org = opts.org
|
||||
const f = opts.fetchImpl ?? fetch
|
||||
|
||||
async function login(req: LoginRequest): Promise<LoginResponse> {
|
||||
const type = req.redirectUri ? 'code' : 'login'
|
||||
const url = new URL('/v1/iam/login', tenant.iamUrl)
|
||||
const url = new URL('/v1/iam/login', org.iamUrl)
|
||||
url.searchParams.set('clientId', req.clientId)
|
||||
url.searchParams.set('responseType', 'code')
|
||||
if (req.redirectUri) url.searchParams.set('redirectUri', req.redirectUri)
|
||||
@@ -146,8 +201,45 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
|
||||
return parseLoginResponse(res, req)
|
||||
}
|
||||
|
||||
// Resolve the org of the user in the ambient IAM session (the `iam_session_id`
|
||||
// cookie), or null when there is no live session. Reads `/v1/iam/get-account`;
|
||||
// the org is the `owner` field (IAM returns the User at the top level or
|
||||
// under `data`). Used to keep silent SSO from reusing a session that belongs
|
||||
// to a DIFFERENT org than the app being signed into.
|
||||
async function sessionOwner(): Promise<string | null> {
|
||||
try {
|
||||
const res = await f(new URL('/v1/iam/get-account', org.iamUrl).toString(), {
|
||||
credentials: 'include',
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
if (!res.ok) return null
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
if (body.status === 'error') return null
|
||||
const nested = (typeof body.data === 'object' && body.data ? body.data : {}) as Record<string, unknown>
|
||||
const owner = typeof body.owner === 'string' ? body.owner : nested.owner
|
||||
return typeof owner === 'string' && owner ? owner : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function silentLogin(req: SilentLoginRequest): Promise<LoginResponse> {
|
||||
const url = new URL('/v1/iam/login', tenant.iamUrl)
|
||||
// Silent SSO may reuse the ambient IAM session ONLY when that session's user
|
||||
// belongs to the SAME org as the app being signed into. A cross-org app —
|
||||
// e.g. the admin-guard (client_id=hanzo-admin-guard, org=admin) reached from
|
||||
// a browser that already holds a hanzo/* session — must NOT mint a code from
|
||||
// the wrong-org session: that confers owner=hanzo and silently shadows the
|
||||
// org-scoped credential form (which resolves the admin/* identity). Resolve
|
||||
// the app's org and the session owner; on no session or an org mismatch,
|
||||
// return an empty response so Login.tsx falls back to the interactive form,
|
||||
// which authenticates in the app's own org. Same-org SSO (the common case)
|
||||
// still mints silently, so seamless sign-in is preserved.
|
||||
const [app, owner] = await Promise.all([getAppLogin(req.clientId), sessionOwner()])
|
||||
if (!owner) return {}
|
||||
const appOrg = app?.organization
|
||||
if (appOrg && owner !== appOrg) return {}
|
||||
|
||||
const url = new URL('/v1/iam/login', org.iamUrl)
|
||||
url.searchParams.set('clientId', req.clientId)
|
||||
url.searchParams.set('responseType', 'code')
|
||||
url.searchParams.set('redirectUri', req.redirectUri)
|
||||
@@ -177,7 +269,7 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
|
||||
async function approveDevice(userCode: string): Promise<DeviceApprovalResult> {
|
||||
const code = normalizeUserCode(userCode)
|
||||
if (!code) return { ok: false, error: 'Enter the code shown on your device.' }
|
||||
const url = new URL('/v1/iam/login', tenant.iamUrl)
|
||||
const url = new URL('/v1/iam/login', org.iamUrl)
|
||||
// IAM's device branch keys the cache off the `userCode` in the BODY; the
|
||||
// `type` echo on the query mirrors the other login legs. NO credentials —
|
||||
// the user is already signed in, so this rides the session cookie
|
||||
@@ -186,12 +278,12 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
|
||||
const body: Record<string, unknown> = {
|
||||
type: 'device',
|
||||
userCode: code,
|
||||
application: tenant.appName,
|
||||
application: org.appName,
|
||||
}
|
||||
// `organization` scopes the application lookup (FindApplicationByName); it
|
||||
// does NOT resolve the user (that comes from the session), so pinning the
|
||||
// tenant org here is safe — unlike password login, which omits it.
|
||||
if (tenant.orgId) body.organization = tenant.orgId
|
||||
// org org here is safe — unlike password login, which omits it.
|
||||
if (org.orgId) body.organization = org.orgId
|
||||
let res: Response
|
||||
try {
|
||||
res = await f(url.toString(), {
|
||||
@@ -221,8 +313,54 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
async function deviceInfo(userCode: string): Promise<DeviceInfoResult> {
|
||||
const code = normalizeUserCode(userCode)
|
||||
if (!code) return { ok: false, error: 'Enter the code shown on your device.' }
|
||||
// POST, and the code rides the BODY — like `approveDevice` beside it, and for
|
||||
// the reason IAM's own introspection endpoint is POST: the user_code is the one
|
||||
// secret in this flow, and a request line is copied into ingress and proxy
|
||||
// access logs where a body is not. This page ships `scrubUrl()` to keep the
|
||||
// code out of the address bar; putting it into every request line would undo
|
||||
// that server-side. Same session cookie as the approval: whatever you may look
|
||||
// at is exactly what you may approve.
|
||||
const url = new URL('/v1/iam/oauth/device/info', org.iamUrl)
|
||||
let res: Response
|
||||
try {
|
||||
res = await f(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ userCode: code }),
|
||||
})
|
||||
} catch (e) {
|
||||
return { ok: false, error: String(e) }
|
||||
}
|
||||
let parsed: Record<string, unknown> = {}
|
||||
try {
|
||||
parsed = (await res.json()) as Record<string, unknown>
|
||||
} catch {
|
||||
return { ok: false, error: `HTTP ${res.status} non-JSON response` }
|
||||
}
|
||||
if (!res.ok || parsed.status === 'error') {
|
||||
const error = typeof parsed.msg === 'string' && parsed.msg ? parsed.msg : `HTTP ${res.status}`
|
||||
// IAM `CodeLoginRequired` (internal/oidc/oidc.go): the session lapsed between
|
||||
// the page's get-account check and this read. Not a dead end — sign in again.
|
||||
if (parsed.code === 'login_required') return { ok: false, error, loginRequired: true }
|
||||
return { ok: false, error }
|
||||
}
|
||||
// A name is only worth rendering if the server sent it. An answer with no
|
||||
// clientId names nothing, so it fails rather than letting the page fall back
|
||||
// to a guess — showing the WRONG application is the defect this endpoint exists
|
||||
// to fix. `displayName` falls back to the clientId, which IAM did confirm.
|
||||
const data = parsed.data as Record<string, unknown> | undefined
|
||||
const clientId = typeof data?.clientId === 'string' ? data.clientId : ''
|
||||
const displayName = typeof data?.displayName === 'string' ? data.displayName : ''
|
||||
if (!clientId) return { ok: false, error: 'IAM did not name the application for this code.' }
|
||||
return { ok: true, clientId, displayName: displayName || clientId }
|
||||
}
|
||||
|
||||
async function signup(req: SignupRequest): Promise<LoginResponse> {
|
||||
const url = new URL('/v1/iam/signup', tenant.iamUrl)
|
||||
const url = new URL('/v1/iam/signup', org.iamUrl)
|
||||
url.searchParams.set('clientId', req.clientId)
|
||||
const username = req.email.split('@')[0]
|
||||
const res = await f(url.toString(), {
|
||||
@@ -237,22 +375,48 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
|
||||
email: req.email,
|
||||
password: req.password,
|
||||
confirm: req.password,
|
||||
autoSignin: true,
|
||||
...(req.inviteCode ? { invitationCode: req.inviteCode } : {}),
|
||||
}),
|
||||
})
|
||||
return parseLoginResponse(res)
|
||||
|
||||
// Registration is CREATE-ONLY at IAM. `/v1/iam/signup` persists the user and
|
||||
// answers with the created row — it sets no session cookie and mints no
|
||||
// authorization code, and its form (`internal/oidc/signup.go`) has no
|
||||
// `autoSignin`, `redirectUri` or `code_challenge` field to make it do so.
|
||||
// The `autoSignin: true` this used to post was silently dropped by the Go
|
||||
// decoder, so "signed up" and "signed in" were never the same event.
|
||||
//
|
||||
// Left there, the response fell through `parseLoginResponse`'s no-redirect
|
||||
// arm to `{ redirectUrl: '/onboarding' }` — every new customer was sent to
|
||||
// the portal's own onboarding, unauthenticated, while the app that sent them
|
||||
// waited on a code that was never minted. So finish the job here: a signup
|
||||
// that leaves you logged out is not a signup.
|
||||
const created = await parseCreated(res)
|
||||
if (created.error) return created
|
||||
|
||||
return login({
|
||||
identifier: req.email,
|
||||
password: req.password,
|
||||
clientId: req.clientId,
|
||||
application: req.application,
|
||||
organization: req.organization,
|
||||
redirectUri: req.redirectUri,
|
||||
state: req.state,
|
||||
codeChallenge: req.codeChallenge,
|
||||
codeChallengeMethod: req.codeChallengeMethod,
|
||||
nonce: req.nonce,
|
||||
})
|
||||
}
|
||||
|
||||
async function forgot(req: ForgotRequest): Promise<{ ok: boolean; error?: string }> {
|
||||
const url = new URL('/v1/iam/send-verification-code', tenant.iamUrl)
|
||||
const url = new URL('/v1/iam/send-verification-code', org.iamUrl)
|
||||
url.searchParams.set('clientId', req.clientId)
|
||||
url.searchParams.set('organization', req.organization)
|
||||
const res = await f(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
applicationId: `admin/${tenant.appName}`,
|
||||
applicationId: `admin/${org.appName}`,
|
||||
organization: req.organization,
|
||||
dest: req.identifier,
|
||||
type: req.identifier.includes('@') ? 'email' : 'phone',
|
||||
@@ -267,7 +431,7 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
|
||||
}
|
||||
|
||||
function authorize(req: OAuthAuthorizeRequest): string {
|
||||
const url = new URL('/v1/iam/oauth/authorize', tenant.iamUrl)
|
||||
const url = new URL('/v1/iam/oauth/authorize', org.iamUrl)
|
||||
url.searchParams.set('client_id', req.clientId)
|
||||
url.searchParams.set('redirect_uri', req.redirectUri)
|
||||
url.searchParams.set('response_type', req.responseType ?? 'code')
|
||||
@@ -278,16 +442,20 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
|
||||
url.searchParams.set('code_challenge', req.codeChallenge)
|
||||
url.searchParams.set('code_challenge_method', req.codeChallengeMethod ?? 'S256')
|
||||
}
|
||||
// Naming a provider federates the request to that external IdP instead of
|
||||
// the hosted credential login. The type has always declared this field;
|
||||
// never emitting it is why social sign-in had no server side at all.
|
||||
if (req.provider) url.searchParams.set('provider', req.provider)
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
async function exchange(code: string, codeVerifier?: string): Promise<TokenResponse> {
|
||||
const url = new URL('/v1/iam/oauth/token', tenant.iamUrl)
|
||||
const url = new URL('/v1/iam/oauth/token', org.iamUrl)
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
client_id: tenant.clientId,
|
||||
redirect_uri: `${tenant.publicOrigin}/callback`,
|
||||
client_id: org.clientId,
|
||||
redirect_uri: `${org.publicOrigin}/callback`,
|
||||
})
|
||||
if (codeVerifier) body.set('code_verifier', codeVerifier)
|
||||
const res = await f(url.toString(), {
|
||||
@@ -308,21 +476,52 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
|
||||
}
|
||||
|
||||
function logout(idTokenHint?: string, postLogoutRedirectUri?: string): string {
|
||||
const url = new URL('/v1/iam/oauth/logout', tenant.iamUrl)
|
||||
const url = new URL('/v1/iam/oauth/logout', org.iamUrl)
|
||||
if (idTokenHint) url.searchParams.set('id_token_hint', idTokenHint)
|
||||
url.searchParams.set(
|
||||
'post_logout_redirect_uri',
|
||||
postLogoutRedirectUri ?? `${tenant.publicOrigin}/login`,
|
||||
postLogoutRedirectUri ?? `${org.publicOrigin}/login`,
|
||||
)
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
async function getAppLogin(clientId?: string): Promise<AppLogin | null> {
|
||||
const id = clientId ?? tenant.clientId
|
||||
const url = new URL('/v1/iam/get-app-login', tenant.iamUrl)
|
||||
function signOut(postLogoutRedirectUri?: string): string {
|
||||
// Every key the SDK owns is namespaced `hanzo_iam_*` — access_token,
|
||||
// expires_at, state, code_verifier, current_org, current_project,
|
||||
// post_login_redirect. Sweep the PREFIX rather than naming them: a list of
|
||||
// literals here is a second copy of the SDK's key set, and the copy is what
|
||||
// goes stale when the SDK adds one. The prefix is the contract.
|
||||
//
|
||||
// Both storages. The token lives in sessionStorage, but the PKCE verifier
|
||||
// is deliberately in localStorage (it has to survive the full-page redirect
|
||||
// to the IdP), and apps cache the token there too.
|
||||
for (const store of [
|
||||
typeof sessionStorage !== 'undefined' ? sessionStorage : null,
|
||||
typeof localStorage !== 'undefined' ? localStorage : null,
|
||||
]) {
|
||||
if (!store) continue
|
||||
// Collect first, then delete: removing while iterating by index
|
||||
// re-indexes the store and skips every other key.
|
||||
const doomed: string[] = []
|
||||
for (let i = 0; i < store.length; i++) {
|
||||
const k = store.key(i)
|
||||
if (k && k.startsWith('hanzo_iam_')) doomed.push(k)
|
||||
}
|
||||
for (const k of doomed) store.removeItem(k)
|
||||
}
|
||||
return logout(undefined, postLogoutRedirectUri)
|
||||
}
|
||||
|
||||
async function getAppLogin(clientId?: string, redirectUri?: string): Promise<AppLogin | null> {
|
||||
const id = clientId ?? org.clientId
|
||||
const url = new URL('/v1/iam/get-app-login', org.iamUrl)
|
||||
url.searchParams.set('clientId', id)
|
||||
url.searchParams.set('responseType', 'code')
|
||||
url.searchParams.set('redirectUri', `${tenant.publicOrigin}/callback`)
|
||||
// Validate against the downstream app's OWN redirect_uri when the caller has
|
||||
// one (the SSO authorize flow carries it); the portal's own /callback is not
|
||||
// registered for another app, so IAM would reject the read and we'd surface
|
||||
// no social buttons. Fall back to the portal callback for a bare/own read.
|
||||
url.searchParams.set('redirectUri', redirectUri || `${org.publicOrigin}/callback`)
|
||||
url.searchParams.set('scope', 'openid profile email')
|
||||
url.searchParams.set('state', 'app-login')
|
||||
let body: Record<string, unknown>
|
||||
@@ -334,110 +533,157 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
|
||||
return null
|
||||
}
|
||||
if (body.status !== 'ok' || typeof body.data !== 'object' || body.data === null) return null
|
||||
return parseAppLogin(body.data as Record<string, unknown>, tenant.appName, tenant.orgId)
|
||||
return parseAppLogin(body.data as Record<string, unknown>, org.appName, org.orgId)
|
||||
}
|
||||
|
||||
async function providerLogin(
|
||||
req: ProviderExchangeRequest,
|
||||
): Promise<{ redirectUrl?: string; error?: string }> {
|
||||
// POST the provider code to the IAM backend together with the app's ORIGINAL
|
||||
// OIDC authorize params (recovered from the round-tripped `state`). IAM
|
||||
// exchanges the provider code, signs the user in, and mints an authorization
|
||||
// code BOUND TO THE APP'S request — the app's client_id and, crucially, its
|
||||
// PKCE `code_challenge` (C1) — which we then hand back to the originating app
|
||||
// so its OWN callback exchanges the code with ITS verifier (V1). The minted
|
||||
// code is bound to C1, so that exchange matches; this is the keystone of the
|
||||
// social-login PKCE round-trip.
|
||||
const oidc = new URLSearchParams(req.oidcQuery.replace(/^\?/, ''))
|
||||
const appRedirectUri = oidc.get('redirect_uri') ?? ''
|
||||
const appState = oidc.get('state') ?? ''
|
||||
|
||||
const url = new URL('/v1/iam/login', tenant.iamUrl)
|
||||
// OIDC params ride the QUERY — IAM's HandleLoggedIn reads them there first
|
||||
// when minting the code. Forward exactly the app's request so the code
|
||||
// carries its client_id, scope, nonce and — load-bearing — its
|
||||
// `code_challenge`. (`redirect_uri` snake-case is NOT forwarded: IAM reads
|
||||
// the code's redirect binding from camelCase `redirectUri`, set below.)
|
||||
for (const [k, v] of oidc) {
|
||||
if (['client_id', 'response_type', 'scope', 'state', 'nonce', 'code_challenge', 'code_challenge_method'].includes(k)) {
|
||||
url.searchParams.set(k, v)
|
||||
}
|
||||
async function getAccount(): Promise<MfaIdentity | null> {
|
||||
const url = new URL('/v1/iam/get-account', org.iamUrl)
|
||||
let body: Record<string, unknown>
|
||||
try {
|
||||
const res = await f(url.toString(), { headers: { Accept: 'application/json' }, credentials: 'include' })
|
||||
if (!res.ok) return null
|
||||
body = (await res.json()) as Record<string, unknown>
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
// Bind the minted code to the APP's redirect_uri (camelCase `redirectUri` —
|
||||
// the param HandleLoggedIn reads), so the code targets the app, NOT the
|
||||
// provider-callback host carried in the body below.
|
||||
if (appRedirectUri) url.searchParams.set('redirectUri', appRedirectUri)
|
||||
const d = (typeof body.data === 'object' && body.data ? body.data : {}) as Record<string, unknown>
|
||||
if (typeof d.owner !== 'string' || typeof d.name !== 'string' || !d.owner || !d.name) return null
|
||||
return { owner: d.owner, name: d.name }
|
||||
}
|
||||
|
||||
// The redirect_uri IAM forwards to the PROVIDER's token endpoint MUST be
|
||||
// byte-identical to the hop's redirect_uri — the provider's REGISTERED
|
||||
// callback host (`oauthCallbackOrigin`, e.g. `iam.hanzo.ai`, shared across
|
||||
// brand portals) — or the provider rejects the exchange `invalid_grant`.
|
||||
// This is a DIFFERENT redirect_uri from the app's above: one drives the
|
||||
// provider exchange (body), one binds the minted app code (query).
|
||||
const callbackOrigin = tenant.oauthCallbackOrigin ?? tenant.publicOrigin
|
||||
/**
|
||||
* Build a `/v1/iam/mfa/setup/*` POST URL with EVERY param on the query string
|
||||
* and send an EMPTY body. This is the one wire shape IAM's authz filter and
|
||||
* the MFA controller both accept: the controller reads `owner`/`name`/… from
|
||||
* the merged form (query + body), while the authz filter only extracts the
|
||||
* `{owner,name}` object from the query when the body is empty (a non-empty
|
||||
* body is JSON-unmarshalled, and a urlencoded body fails that parse → empty
|
||||
* object → the self-access match `sub==obj` fails → "Unauthorized operation").
|
||||
* `owner`/`name` ride the query on EVERY call — including `verify`, which
|
||||
* otherwise carries no identity — purely so that self-access check passes.
|
||||
*/
|
||||
async function mfaSetupPost(path: string, params: Record<string, string>): Promise<Record<string, unknown>> {
|
||||
const url = new URL(`/v1/iam/mfa/setup/${path}`, org.iamUrl)
|
||||
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v)
|
||||
const res = await f(url.toString(), { method: 'POST', credentials: 'include' })
|
||||
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>
|
||||
if (typeof body.status === 'string' && body.status === 'error') {
|
||||
throw new Error(typeof body.msg === 'string' && body.msg ? body.msg : `HTTP ${res.status}`)
|
||||
}
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return body
|
||||
}
|
||||
|
||||
async function mfaInitiate(id: MfaIdentity): Promise<MfaSetup> {
|
||||
const body = await mfaSetupPost('initiate', { owner: id.owner, name: id.name, mfaType: MFA_TOTP })
|
||||
const d = (typeof body.data === 'object' && body.data ? body.data : {}) as Record<string, unknown>
|
||||
const secret = typeof d.secret === 'string' ? d.secret : ''
|
||||
const url = typeof d.url === 'string' ? d.url : ''
|
||||
if (!secret || !url) throw new Error('IAM returned no TOTP secret')
|
||||
return {
|
||||
mfaType: MFA_TOTP,
|
||||
secret,
|
||||
url,
|
||||
recoveryCodes: Array.isArray(d.recoveryCodes) ? d.recoveryCodes.filter((c): c is string => typeof c === 'string') : [],
|
||||
}
|
||||
}
|
||||
|
||||
async function mfaVerify(req: MfaIdentity & { secret: string; passcode: string }): Promise<{ ok: boolean; error?: string }> {
|
||||
try {
|
||||
await mfaSetupPost('verify', { owner: req.owner, name: req.name, mfaType: MFA_TOTP, secret: req.secret, passcode: req.passcode })
|
||||
return { ok: true }
|
||||
} catch (e) {
|
||||
return { ok: false, error: e instanceof Error ? e.message : String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async function mfaEnable(req: MfaIdentity & { secret: string; recoveryCode: string }): Promise<{ ok: boolean; error?: string }> {
|
||||
try {
|
||||
await mfaSetupPost('enable', {
|
||||
owner: req.owner,
|
||||
name: req.name,
|
||||
mfaType: MFA_TOTP,
|
||||
secret: req.secret,
|
||||
recoveryCodes: req.recoveryCode,
|
||||
})
|
||||
return { ok: true }
|
||||
} catch (e) {
|
||||
return { ok: false, error: e instanceof Error ? e.message : String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async function mfaChallenge(req: MfaChallengeRequest): Promise<LoginResponse> {
|
||||
const type = req.redirectUri ? 'code' : 'login'
|
||||
const url = new URL('/v1/iam/login', org.iamUrl)
|
||||
url.searchParams.set('clientId', req.clientId)
|
||||
url.searchParams.set('responseType', 'code')
|
||||
if (req.redirectUri) url.searchParams.set('redirectUri', req.redirectUri)
|
||||
url.searchParams.set('scope', 'openid profile email')
|
||||
if (req.state) url.searchParams.set('state', req.state)
|
||||
if (req.codeChallenge) {
|
||||
url.searchParams.set('code_challenge', req.codeChallenge)
|
||||
url.searchParams.set('code_challenge_method', req.codeChallengeMethod ?? 'S256')
|
||||
}
|
||||
url.searchParams.set('type', type)
|
||||
const res = await f(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
type: 'code',
|
||||
type,
|
||||
// No username: IAM resolves the user from the MFA session cookie it set
|
||||
// when it answered NextMfa.
|
||||
mfaType: req.mfaType,
|
||||
passcode: req.passcode,
|
||||
application: req.application,
|
||||
provider: req.provider,
|
||||
code: req.code,
|
||||
// IAM's social state guard accepts state == application name.
|
||||
state: req.application,
|
||||
redirectUri: `${callbackOrigin}/callback`,
|
||||
// "signup" = find-or-create-LOGIN (see social.ts); never the link branch.
|
||||
method: req.method,
|
||||
organization: req.organization,
|
||||
enableMfaRemember: req.rememberDevice ?? false,
|
||||
}),
|
||||
})
|
||||
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}` }
|
||||
}
|
||||
// IAM returns the freshly-minted authorization CODE in `data` (codeToResponse
|
||||
// → the bare code string, NOT a URL). Build the redirect back to the app's
|
||||
// own redirect_uri + original state so the app runs the standard OIDC code
|
||||
// exchange. (Treating the bare code AS the redirect URL — the prior bug —
|
||||
// dead-ended the flow on the issuer host and never returned to the app.)
|
||||
const code = typeof body.data === 'string' ? body.data : ''
|
||||
if (!code) return { error: 'provider login returned no authorization code' }
|
||||
if (!appRedirectUri) return { error: 'provider login: missing redirect_uri in social state' }
|
||||
const sep = appRedirectUri.includes('?') ? '&' : '?'
|
||||
return {
|
||||
redirectUrl: `${appRedirectUri}${sep}code=${encodeURIComponent(code)}&state=${encodeURIComponent(appState)}`,
|
||||
}
|
||||
return parseLoginResponse(res, req)
|
||||
}
|
||||
|
||||
return { tenant, login, silentLogin, approveDevice, signup, forgot, authorize, exchange, logout, getAppLogin, providerLogin }
|
||||
return {
|
||||
org,
|
||||
login,
|
||||
silentLogin,
|
||||
approveDevice,
|
||||
deviceInfo,
|
||||
signup,
|
||||
forgot,
|
||||
authorize,
|
||||
exchange,
|
||||
logout,
|
||||
signOut,
|
||||
getAppLogin,
|
||||
getAccount,
|
||||
mfaInitiate,
|
||||
mfaVerify,
|
||||
mfaEnable,
|
||||
mfaChallenge,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonicalize a user-entered device code to the form IAM generated. IAM mints
|
||||
* codes from `[0-9a-z]{6}` (`util.GetRandomName`), so we lowercase (making entry
|
||||
* case-insensitive), trim surrounding whitespace, and drop spaces/dashes a user
|
||||
* might add while transcribing. The DeviceAuthMap key is the exact string, so we
|
||||
* normalize TO that lowercase alphabet — never uppercase.
|
||||
* user_codes from an UPPERCASE unambiguous alphabet ([A-HJ-NP-Z2-9], no
|
||||
* I/L/O/0/1) and keys its DeviceAuthMap on the exact string. A human may
|
||||
* transcribe it lower-cased or with stray spaces/dashes, so normalize TO
|
||||
* uppercase and strip separators — case-insensitive entry, an exact-match send.
|
||||
*/
|
||||
function normalizeUserCode(raw: string): string {
|
||||
// IAM mints user_codes from an UPPERCASE unambiguous alphabet
|
||||
// ([A-HJ-NP-Z2-9], no I/L/O/0/1) and keys its DeviceAuthMap on the exact
|
||||
// string. A human may transcribe it lower-cased or with stray spaces/dashes,
|
||||
// so normalize TO uppercase and strip separators — case-insensitive entry,
|
||||
// an exact-match send.
|
||||
return raw.trim().toUpperCase().replace(/[\s-]+/g, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an IAM provider record to its canonical authorize-endpoint `provider`
|
||||
* key. IAM names providers `provider-<key>` (e.g. `provider-github`); the
|
||||
* `/v1/iam/oauth/authorize?provider=<key>` param wants the bare key. The
|
||||
* Web3Onboard wallet provider maps to `web3`.
|
||||
* The provider's DISPLAY key — `provider-github` → `github` — used to pick an
|
||||
* icon and a label (`PROVIDER_META`) and to match a `provider_hint`.
|
||||
*
|
||||
* It is NOT what the authorize endpoint wants. `federationProvider` matches the
|
||||
* record name exactly, so `?provider=` must carry the full `provider-github`;
|
||||
* live, `?provider=github` is refused "unknown or unavailable provider". This
|
||||
* comment used to assert the opposite — a bare key — which was never true of the
|
||||
* federation broker.
|
||||
*/
|
||||
function providerKey(name: string): string {
|
||||
return name.replace(/^provider-/, '')
|
||||
@@ -469,7 +715,7 @@ function parseAppLogin(
|
||||
// (`rec.provider.name`, e.g. `provider-github`) — that is what the IAM
|
||||
// backend's social-login lookup (`GetProvider(admin/<name>)`) resolves.
|
||||
// The OUTER link object's `name` is the app's provider-LINK label, which
|
||||
// some Casdoor seeds set to a per-app default (e.g. `<org>-iam`); reading
|
||||
// some IAM seeds set to a per-app default (e.g. `<org>-iam`); reading
|
||||
// it as the provider name made the hop POST `provider=<org>-iam`, which
|
||||
// the backend rejects ("The provider: <org>-iam does not exist"). Prefer
|
||||
// the inner record name; fall back to the outer label only when there is
|
||||
@@ -505,6 +751,28 @@ function parseAppLogin(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a create-only IAM response: `{status, msg, data}` where `data` is the
|
||||
* created row. Success carries nothing the caller can navigate to, so this
|
||||
* reports only whether it worked — never a redirect. Kept separate from
|
||||
* `parseLoginResponse` precisely because that one INVENTS a destination when no
|
||||
* `redirectUri` was requested, which is wrong for a row that is not a session.
|
||||
*/
|
||||
async function parseCreated(res: Response): Promise<{ error?: string }> {
|
||||
let body: Record<string, unknown> = {}
|
||||
try {
|
||||
body = (await res.json()) as Record<string, unknown>
|
||||
} catch {
|
||||
return { error: `HTTP ${res.status} non-JSON response` }
|
||||
}
|
||||
// IAM answers a REFUSAL with HTTP 200 + status:"error" (see the org-less login
|
||||
// note in this repo's LLM.md), so the status code alone proves nothing.
|
||||
if (!res.ok || body.status === 'error') {
|
||||
return { error: typeof body.msg === 'string' ? body.msg : `HTTP ${res.status}` }
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
async function parseLoginResponse(
|
||||
res: Response,
|
||||
req?: { redirectUri?: string; state?: string },
|
||||
@@ -520,6 +788,25 @@ async function parseLoginResponse(
|
||||
}
|
||||
const data = body.data
|
||||
|
||||
// Multi-factor signal — IAM answers a successful credential check with a
|
||||
// STRING in `data` (NOT a `mfa_required` boolean): `"RequiredMfa"` when org
|
||||
// policy forces MFA the user has not enrolled, `"NextMfa"` when the user has
|
||||
// MFA and must answer a challenge. Branch BEFORE any session/redirect return:
|
||||
// the password session is not yet usable, so the portal must render the
|
||||
// enrollment/challenge step rather than navigate on.
|
||||
if (data === 'RequiredMfa') {
|
||||
return { mfaRequired: true, mfaStage: 'enroll' }
|
||||
}
|
||||
if (data === 'NextMfa') {
|
||||
// Challenge allow-list: IAM's named `mfa` field first, falling back to
|
||||
// the legacy untyped `data2` slot until IAM stops emitting it.
|
||||
const allow = Array.isArray(body.mfa) ? body.mfa : Array.isArray(body.data2) ? body.data2 : []
|
||||
const mfaTypes = allow
|
||||
.map((p) => (typeof p === 'object' && p !== null ? (p as Record<string, unknown>).mfaType : undefined))
|
||||
.filter((t): t is string => typeof t === 'string' && t.length > 0)
|
||||
return { mfaRequired: true, mfaStage: 'challenge', mfaTypes }
|
||||
}
|
||||
|
||||
// Authorization-code flow: a client redirectUri is present and `data` is the
|
||||
// freshly minted code — hand the SPA a fully-formed redirect back to the app.
|
||||
if (req?.redirectUri && typeof data === 'string' && data.length > 0) {
|
||||
@@ -544,7 +831,5 @@ async function parseLoginResponse(
|
||||
refreshToken: typeof d.refresh_token === 'string' ? d.refresh_token : undefined,
|
||||
idToken: typeof d.id_token === 'string' ? d.id_token : undefined,
|
||||
expiresAt: typeof d.expires_at === 'number' ? d.expires_at : undefined,
|
||||
mfaRequired: d.mfa_required === true,
|
||||
mfaChannel: typeof d.mfa_channel === 'string' ? (d.mfa_channel as LoginResponse['mfaChannel']) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { TenantConfig } from '@hanzo/id-shared'
|
||||
import type { OrgConfig } from '@hanzo/id-shared'
|
||||
import { IAM } from '@hanzo/iam/browser'
|
||||
|
||||
/**
|
||||
* One IAM browser-SDK instance per tenant, wired to the portal's own
|
||||
* One IAM browser-SDK instance per org, wired to the portal's own
|
||||
* `/callback` route. This is the single place that constructs the PKCE
|
||||
* client — social/web3 sign-in (here) and the callback handler
|
||||
* (`Callback.tsx`) share it so the PKCE verifier/state the SDK stores on
|
||||
@@ -11,11 +11,11 @@ import { IAM } from '@hanzo/iam/browser'
|
||||
* The portal is its own OIDC client (`clientId` = the brand `-id` app), so
|
||||
* every flow it initiates lands back at `${publicOrigin}/callback`.
|
||||
*/
|
||||
export function createIam(tenant: TenantConfig, clientId?: string): IAM {
|
||||
export function createIam(org: OrgConfig): IAM {
|
||||
return new IAM({
|
||||
serverUrl: tenant.iamUrl,
|
||||
clientId: clientId ?? tenant.clientId,
|
||||
redirectUri: `${tenant.publicOrigin}/callback`,
|
||||
serverUrl: org.iamUrl,
|
||||
clientId: org.clientId,
|
||||
redirectUri: `${org.publicOrigin}/callback`,
|
||||
scope: 'openid profile email',
|
||||
})
|
||||
}
|
||||
|
||||
+15
-9
@@ -1,22 +1,27 @@
|
||||
export { createAuthClient, type AuthClient, type AuthClientOptions } from './client'
|
||||
export { createIam } from './iam'
|
||||
export {
|
||||
startProviderLogin,
|
||||
buildProviderAuthUrl,
|
||||
isHoppableProvider,
|
||||
encodeState,
|
||||
decodeState,
|
||||
type ProviderLoginParams,
|
||||
} from './social'
|
||||
createAuthClient,
|
||||
mfaChannelOf,
|
||||
MFA_TOTP,
|
||||
type AuthClient,
|
||||
type AuthClientOptions,
|
||||
} from './client'
|
||||
export { createIam } from './iam'
|
||||
export { authorizeRequest, matchProviderHint } from './social'
|
||||
export {
|
||||
loginWithWalletChain,
|
||||
detectWalletChains,
|
||||
ENABLED_WALLET_CHAINS,
|
||||
WALLET_CHAIN_LABELS,
|
||||
type WalletLoginContext,
|
||||
type WalletWindow,
|
||||
} from './web3'
|
||||
export type {
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
MfaChannel,
|
||||
MfaChallengeRequest,
|
||||
MfaIdentity,
|
||||
MfaSetup,
|
||||
SignupRequest,
|
||||
ForgotRequest,
|
||||
OAuthAuthorizeRequest,
|
||||
@@ -24,5 +29,6 @@ export type {
|
||||
AppLogin,
|
||||
AppProvider,
|
||||
DeviceApprovalResult,
|
||||
DeviceInfoResult,
|
||||
} from './types'
|
||||
export * from './ui'
|
||||
|
||||
@@ -1,115 +1,106 @@
|
||||
/**
|
||||
* Provider-hop URL builder tests — pure, no network. Run with:
|
||||
* Federated sign-in — pure unit tests, no network. Run with:
|
||||
* pnpm --filter @hanzo/id-auth test
|
||||
*
|
||||
* Verifies the URL + base64 state match the Hanzo-IAM (Casdoor) `getAuthUrl`
|
||||
* contract so the backend `/callback` exchange accepts the return. The
|
||||
* end-to-end OAuth round-trip still needs live verification once real provider
|
||||
* creds are seeded — but the URL/state construction is locked down here.
|
||||
* The browser's whole job in a federated sign-in is to name the provider on
|
||||
* IAM's authorize endpoint and, when an app sent the user here, to hand that
|
||||
* app's own request back unchanged so IAM mints the code against it. Those two
|
||||
* are what these tests pin; the IdP leg belongs to IAM and is not modelled here.
|
||||
*/
|
||||
import { test } from 'node:test'
|
||||
import { test } from 'vitest'
|
||||
import assert from 'node:assert/strict'
|
||||
import { buildProviderAuthUrl, isHoppableProvider } from './social.ts'
|
||||
import { authorizeRequest, matchProviderHint, PROVIDER_ORDER } from './social.ts'
|
||||
|
||||
const ORIGIN = 'https://hanzo.id'
|
||||
// The original OIDC authorize query the portal was bounced here with.
|
||||
const SEARCH = '?client_id=hanzo-id&redirect_uri=https%3A%2F%2Fhanzo.id%2Fcallback&response_type=code&scope=openid&state=rp123'
|
||||
const PORTAL = 'hanzo-console'
|
||||
|
||||
test('GitHub hop builds the correct endpoint, client_id, redirect_uri, and scope', () => {
|
||||
const url = buildProviderAuthUrl(
|
||||
{ application: 'hanzo-id', providerName: 'provider-github', type: 'GitHub', clientId: 'gh_real_123' },
|
||||
ORIGIN,
|
||||
SEARCH,
|
||||
test('Google is offered above GitHub, and the wallet last', () => {
|
||||
// Order is a deliberate product decision, not an accident of how the buttons
|
||||
// were typed out. It lived as an unexported constant inside the component and
|
||||
// had already drifted once with nothing to catch it, which is the whole reason
|
||||
// it is a value in this module now.
|
||||
const at = (k: string) => PROVIDER_ORDER.indexOf(k as (typeof PROVIDER_ORDER)[number])
|
||||
assert.ok(at('google') < at('github'), 'Google leads')
|
||||
assert.ok(at('github') < at('gitlab'))
|
||||
assert.equal(PROVIDER_ORDER[PROVIDER_ORDER.length - 1], 'web3', 'the wallet trails')
|
||||
})
|
||||
|
||||
test('every ordered provider is one the hint matcher can also resolve', () => {
|
||||
// The two provider policies in this module must agree: a key the strip renders
|
||||
// must be a key a `provider_hint` can name, or the console's one-click hand-off
|
||||
// silently falls back to the form for a provider that is plainly on screen.
|
||||
for (const key of PROVIDER_ORDER) {
|
||||
const found = matchProviderHint([{ name: `provider-${key}`, key }], `provider-${key}`)
|
||||
assert.equal(found?.key, key)
|
||||
}
|
||||
})
|
||||
|
||||
test('an app-initiated request is recovered whole, so IAM binds the code to that app', () => {
|
||||
// What IAM forwards to the hosted login (authorizeForwardQuery) when an app
|
||||
// sends a user here for a code.
|
||||
const req = authorizeRequest(
|
||||
'?client_id=hanzo-app&redirect_uri=https%3A%2F%2Fhanzo.app%2Fcallback&response_type=code' +
|
||||
'&scope=openid+profile&state=rp123&nonce=n1&code_challenge=C1&code_challenge_method=S256',
|
||||
PORTAL,
|
||||
)!
|
||||
assert.ok(url.startsWith('https://github.com/login/oauth/authorize?'))
|
||||
assert.ok(url.includes('client_id=gh_real_123'))
|
||||
// No callbackOrigin → defaults to the browser origin.
|
||||
assert.ok(url.includes('redirect_uri=https://hanzo.id/callback'))
|
||||
assert.ok(url.includes('scope=user:email+read:user')) // GitHub default
|
||||
assert.ok(url.includes('response_type=code'))
|
||||
assert.equal(req.clientId, 'hanzo-app')
|
||||
assert.equal(req.redirectUri, 'https://hanzo.app/callback')
|
||||
assert.equal(req.state, 'rp123')
|
||||
assert.equal(req.scope, 'openid profile')
|
||||
assert.equal(req.nonce, 'n1')
|
||||
// Load-bearing: the code IAM mints is bound to the APP's challenge, so the
|
||||
// app's own callback completes the exchange with the verifier it kept.
|
||||
assert.equal(req.codeChallenge, 'C1')
|
||||
assert.equal(req.codeChallengeMethod, 'S256')
|
||||
})
|
||||
|
||||
test('the registered callback origin overrides the browser origin in redirect_uri', () => {
|
||||
// The shared OAuth client is registered against iam.hanzo.ai/callback, so the
|
||||
// hop must return there even though the SPA runs on hanzo.id — otherwise the
|
||||
// provider rejects the redirect_uri (verified live: Google accepts ONLY
|
||||
// https://iam.hanzo.ai/callback for this client).
|
||||
const url = buildProviderAuthUrl(
|
||||
{ application: 'hanzo-id', providerName: 'provider-google', type: 'Google', clientId: 'goog_1' },
|
||||
ORIGIN,
|
||||
SEARCH,
|
||||
'https://iam.hanzo.ai',
|
||||
)!
|
||||
assert.ok(url.includes('redirect_uri=https://iam.hanzo.ai/callback'))
|
||||
assert.ok(!url.includes('redirect_uri=https://hanzo.id/callback'))
|
||||
test('a bare portal sign-in has no app to return to', () => {
|
||||
// No redirect_uri → nothing to return a code to, so the portal starts its own
|
||||
// PKCE flow instead (the SDK owns the verifier; Callback reads it back).
|
||||
assert.equal(authorizeRequest('', PORTAL), null)
|
||||
assert.equal(authorizeRequest('?provider_hint=provider-github', PORTAL), null)
|
||||
})
|
||||
|
||||
test('state base64-encodes the original OIDC query + application/provider/method (round-trips)', () => {
|
||||
const url = buildProviderAuthUrl(
|
||||
{ application: 'hanzo-id', providerName: 'provider-github', type: 'GitHub', clientId: 'gh_real_123', method: 'signup' },
|
||||
ORIGIN,
|
||||
SEARCH,
|
||||
)!
|
||||
const state = new URL(url).searchParams.get('state')!
|
||||
const decoded = Buffer.from(state, 'base64').toString('utf8')
|
||||
// The RP's original request survives so the backend can complete it.
|
||||
assert.ok(decoded.includes('client_id=hanzo-id'))
|
||||
assert.ok(decoded.includes('state=rp123'))
|
||||
assert.ok(decoded.includes('application=hanzo-id'))
|
||||
assert.ok(decoded.includes('provider=provider-github'))
|
||||
assert.ok(decoded.includes('method=signup'))
|
||||
test('the portal client id is the fallback, never an override', () => {
|
||||
const own = authorizeRequest('?redirect_uri=https%3A%2F%2Fhanzo.id%2Fcallback', PORTAL)!
|
||||
assert.equal(own.clientId, PORTAL, 'no client_id on the query → the portal is the client')
|
||||
|
||||
const app = authorizeRequest('?client_id=hanzo-app&redirect_uri=https%3A%2F%2Fhanzo.app%2Fcallback', PORTAL)!
|
||||
assert.equal(app.clientId, 'hanzo-app', "the app's own client_id wins — the code is minted for IT")
|
||||
})
|
||||
|
||||
test('a pre-existing provider= in the upstream query is stripped — state carries exactly ONE provider', () => {
|
||||
// The console→hanzo.id SSO SDK appends `provider=hanzo-iam` (its per-org IDP
|
||||
// hint) to the upstream authorize query. The hop appends the REAL social
|
||||
// provider; the upstream one MUST be stripped, because `Callback` recovers the
|
||||
// provider with `URLSearchParams.get` (the FIRST match) — two `provider=`
|
||||
// params would make it post `hanzo-iam`, which the IAM backend rejects.
|
||||
const searchWithHint =
|
||||
'?client_id=hanzo-console&redirect_uri=https%3A%2F%2Fiam.hanzo.ai%2Fcallback&response_type=code&scope=openid&state=rp123&provider=hanzo-iam'
|
||||
const url = buildProviderAuthUrl(
|
||||
{ application: 'hanzo-console', providerName: 'provider-google', type: 'Google', clientId: 'goog_1' },
|
||||
ORIGIN,
|
||||
searchWithHint,
|
||||
'https://iam.hanzo.ai',
|
||||
)!
|
||||
const state = new URL(url).searchParams.get('state')!
|
||||
const decoded = Buffer.from(state, 'base64').toString('utf8')
|
||||
const params = new URLSearchParams(decoded.replace(/^\?/, ''))
|
||||
// Exactly one provider, and it is the real social one (not the upstream hint).
|
||||
assert.deepEqual(params.getAll('provider'), ['provider-google'])
|
||||
assert.equal(params.get('provider'), 'provider-google') // FIRST match = the social provider
|
||||
assert.ok(!decoded.includes('hanzo-iam')) // the upstream hint is gone entirely
|
||||
// The rest of the upstream OIDC request is preserved so the backend completes it.
|
||||
assert.ok(decoded.includes('client_id=hanzo-console'))
|
||||
assert.ok(decoded.includes('state=rp123'))
|
||||
test('a leading ? is optional and absent params stay absent', () => {
|
||||
const withMark = authorizeRequest('?redirect_uri=https%3A%2F%2Fhanzo.id%2Fcallback', PORTAL)!
|
||||
const without = authorizeRequest('redirect_uri=https%3A%2F%2Fhanzo.id%2Fcallback', PORTAL)!
|
||||
assert.deepEqual(withMark, without)
|
||||
// Undefined, not '' — client.authorize omits a param it was not given, and an
|
||||
// empty code_challenge is not the same request as no code_challenge.
|
||||
assert.equal(withMark.codeChallenge, undefined)
|
||||
assert.equal(withMark.nonce, undefined)
|
||||
assert.equal(withMark.scope, undefined)
|
||||
assert.equal(withMark.state, '', 'state is always sent, empty when the app sent none')
|
||||
})
|
||||
|
||||
test('Google uses its own endpoint + scope; a custom provider scope overrides', () => {
|
||||
const g = buildProviderAuthUrl(
|
||||
{ application: 'hanzo-id', providerName: 'provider-google', type: 'Google', clientId: 'goog_1' },
|
||||
ORIGIN,
|
||||
SEARCH,
|
||||
)!
|
||||
assert.ok(g.startsWith('https://accounts.google.com/o/oauth2/v2/auth?'))
|
||||
assert.ok(g.includes('scope=profile+email'))
|
||||
|
||||
const custom = buildProviderAuthUrl(
|
||||
{ application: 'hanzo-id', providerName: 'provider-github', type: 'GitHub', clientId: 'gh_1', scopes: 'repo+user' },
|
||||
ORIGIN,
|
||||
SEARCH,
|
||||
)!
|
||||
assert.ok(custom.includes('scope=repo+user'))
|
||||
test('only the two PKCE methods RFC 7636 defines are carried through', () => {
|
||||
const base = 'redirect_uri=https%3A%2F%2Fhanzo.id%2Fcallback&code_challenge=C1&code_challenge_method='
|
||||
assert.equal(authorizeRequest(base + 'S256', PORTAL)!.codeChallengeMethod, 'S256')
|
||||
assert.equal(authorizeRequest(base + 'plain', PORTAL)!.codeChallengeMethod, 'plain')
|
||||
// Anything else is dropped rather than forwarded, so client.authorize applies
|
||||
// its S256 default instead of asking IAM to honor a method it does not define.
|
||||
assert.equal(authorizeRequest(base + 'md5', PORTAL)!.codeChallengeMethod, undefined)
|
||||
})
|
||||
|
||||
test('an unconfigured (empty clientId) or unknown provider type yields no URL', () => {
|
||||
assert.equal(buildProviderAuthUrl({ application: 'a', providerName: 'p', type: 'GitHub', clientId: '' }, ORIGIN, SEARCH), null)
|
||||
assert.equal(buildProviderAuthUrl({ application: 'a', providerName: 'p', type: 'Mystery', clientId: 'x' }, ORIGIN, SEARCH), null)
|
||||
})
|
||||
|
||||
test('isHoppableProvider knows the OAuth set, not wallet', () => {
|
||||
assert.equal(isHoppableProvider('GitHub'), true)
|
||||
assert.equal(isHoppableProvider('Google'), true)
|
||||
assert.equal(isHoppableProvider('Web3Onboard'), false)
|
||||
test('matchProviderHint resolves the console hint, the bare key, and case, else undefined', () => {
|
||||
const providers = [
|
||||
{ name: 'provider-github', key: 'github' },
|
||||
{ name: 'provider-google', key: 'google' },
|
||||
]
|
||||
// The console sends the IAM record name verbatim (`provider-github`).
|
||||
assert.equal(matchProviderHint(providers, 'provider-github')?.key, 'github')
|
||||
assert.equal(matchProviderHint(providers, 'provider-google')?.key, 'google')
|
||||
// The bare key and any case also resolve, so the two sides need no shared constant.
|
||||
assert.equal(matchProviderHint(providers, 'github')?.key, 'github')
|
||||
assert.equal(matchProviderHint(providers, 'GitHub')?.key, 'github')
|
||||
// A hint for a provider this app doesn't offer, or an empty hint, matches nothing.
|
||||
assert.equal(matchProviderHint(providers, 'provider-apple'), undefined)
|
||||
assert.equal(matchProviderHint(providers, ''), undefined)
|
||||
})
|
||||
|
||||
+90
-129
@@ -1,144 +1,105 @@
|
||||
import type { OAuthAuthorizeRequest } from './types'
|
||||
|
||||
/**
|
||||
* Social provider redirect — the "hop" that sends the browser to GitHub /
|
||||
* Google / … to start an OAuth login, replicating the Hanzo-IAM (Casdoor)
|
||||
* front-end `Provider.getAuthUrl` contract so the IAM backend's `/callback`
|
||||
* exchange accepts the return.
|
||||
* Federated sign-in — the portal's half of IAM identity federation.
|
||||
*
|
||||
* Why this exists: the IAM backend's OIDC authorize endpoint, for an
|
||||
* unauthenticated request, 302s to its OWN login-page route (`/login/oauth/
|
||||
* authorize`) and expects the FRONT END to read `?provider=` and bounce to the
|
||||
* provider. We replaced that front end with this portal, so the portal must do
|
||||
* the bounce. `iam.signinRedirect({provider})` does NOT — it just re-enters the
|
||||
* authorize endpoint and loops.
|
||||
* IAM is the relying party; this SPA is not. `/v1/iam/oauth/authorize?provider=…`
|
||||
* IS the entry point: having already validated the client_id, the EXACT
|
||||
* redirect_uri and the PKCE policy, IAM stashes the app-leg request server-side,
|
||||
* sets a single-use browser-binding cookie and sends the browser to the IdP
|
||||
* (`internal/oidc/federation.go::beginFederation`). The IdP returns to IAM's own
|
||||
* fixed callback — `/v1/iam/oauth/callback`, never a route in this SPA — where
|
||||
* IAM, which holds the client SECRET a browser cannot, exchanges the code, links
|
||||
* or provisions the user, and mints an IAM authorization code bound to the
|
||||
* original PKCE challenge, redirect_uri and nonce. The ordinary code→token
|
||||
* exchange then completes unchanged.
|
||||
*
|
||||
* Contract (from `web/src/auth/Provider.tsx::getAuthUrl` + `Util.tsx::
|
||||
* getStateFromQueryParams` in the IAM fork):
|
||||
* url = `${endpoint}?client_id=${clientId}&redirect_uri=${origin}/callback`
|
||||
* `&scope=${scope}&response_type=code&state=${state}`
|
||||
* state = encodeState(`${window.location.search}&application=${app}&provider=`
|
||||
* `${providerName}&method=${method}`) // URL-SAFE base64 of the
|
||||
* // ORIGINAL OIDC query + app/provider/method, so the backend recovers
|
||||
* // the original request when the provider returns to /callback.
|
||||
* This file used to build the IdP URL here in the browser, replicating a contract
|
||||
* from an IAM fork whose front end no longer exists. Nothing could ever finish it:
|
||||
* the SPA has no client secret and IAM has no endpoint that exchanges a raw
|
||||
* provider code, so GitHub returned to `/callback` with a code nobody could spend
|
||||
* and the flow died there. The browser's only job is to NAME the provider.
|
||||
*
|
||||
* Only the standard OAuth2 set is wired here (the providers a `-id` app
|
||||
* actually enables: github, google, +web3 handled elsewhere). Apple uses the
|
||||
* backend callback and is added when needed.
|
||||
*
|
||||
* NOTE: live-verify this end-to-end once real OAuth credentials are seeded —
|
||||
* it cannot be exercised while every provider carries placeholder creds (the
|
||||
* buttons are hidden until then; see SocialButtons + AppProvider.configured).
|
||||
* The name is the IAM provider RECORD name (`provider-github`), never the bare
|
||||
* key: `federationProvider` matches `ProviderItem.Name` exactly, and
|
||||
* `EnrichProviders` resolves that same name to the record, so the two are one
|
||||
* string by construction. Verified live — `provider=github` is refused with
|
||||
* "unknown or unavailable provider"; `provider=provider-github` redirects to
|
||||
* GitHub.
|
||||
*/
|
||||
|
||||
/** Provider `type` → OAuth2 authorize endpoint + default scope (IAM `authInfo`). */
|
||||
const AUTH_INFO: Record<string, { endpoint: string; scope: string }> = {
|
||||
GitHub: { endpoint: 'https://github.com/login/oauth/authorize', scope: 'user:email+read:user' },
|
||||
// Canonical Google OAuth2 authorize endpoint. (Google aliases the legacy
|
||||
// `/signin/oauth` path, but `/o/oauth2/v2/auth` is the documented, stable one.)
|
||||
Google: { endpoint: 'https://accounts.google.com/o/oauth2/v2/auth', scope: 'profile+email' },
|
||||
}
|
||||
|
||||
export interface ProviderLoginParams {
|
||||
/** IAM application name the portal authenticates as (e.g. `hanzo-id`). */
|
||||
readonly application: string
|
||||
/** IAM provider record name, e.g. `provider-github`. */
|
||||
readonly providerName: string
|
||||
/** IAM provider `type`, e.g. `GitHub` / `Google` (selects the endpoint). */
|
||||
readonly type: string
|
||||
/** The provider's real OAuth client id (from `get-app-login`). */
|
||||
readonly clientId: string
|
||||
/** Override scope; falls back to the type default. */
|
||||
readonly scopes?: string
|
||||
/**
|
||||
* IAM social-auth method. Defaults to "signup" — the find-or-create-LOGIN
|
||||
* branch (Casdoor canonical). "signin" is the account-LINK branch and needs
|
||||
* an existing session, so it is NOT used for interactive provider sign-in.
|
||||
*/
|
||||
readonly method?: 'signin' | 'signup'
|
||||
/**
|
||||
* The authorize request this portal is standing in for, read from its own URL.
|
||||
*
|
||||
* When an app sends a user here for a code, IAM forwards that app's validated
|
||||
* request on the query (`authorizeForwardQuery`). Re-entering authorize with it —
|
||||
* plus `provider` — is what makes IAM mint the code against THAT app: its
|
||||
* client_id, its redirect_uri, its PKCE challenge. The browser is returned
|
||||
* straight to the app, so this portal's own `/callback` never runs and no token
|
||||
* is ever handed across on a URL.
|
||||
*
|
||||
* Null when there is no app to return to (a bare portal sign-in), which is the
|
||||
* signal to start the portal's own PKCE flow instead. Keyed on `redirect_uri`
|
||||
* because that is the one parameter that makes a request returnable — the same
|
||||
* condition the password path branches on (`Login.completeAfterAuth`).
|
||||
*
|
||||
* The result is an {@link OAuthAuthorizeRequest} because that is exactly what
|
||||
* `client.authorize` consumes: one type, read and written in one shape.
|
||||
*/
|
||||
export function authorizeRequest(search: string, clientId: string): OAuthAuthorizeRequest | null {
|
||||
const q = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search)
|
||||
const redirectUri = q.get('redirect_uri')
|
||||
if (!redirectUri) return null
|
||||
const method = q.get('code_challenge_method')
|
||||
return {
|
||||
clientId: q.get('client_id') || clientId,
|
||||
redirectUri,
|
||||
state: q.get('state') ?? '',
|
||||
scope: q.get('scope') ?? undefined,
|
||||
nonce: q.get('nonce') ?? undefined,
|
||||
codeChallenge: q.get('code_challenge') ?? undefined,
|
||||
codeChallengeMethod: method === 'plain' || method === 'S256' ? method : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* URL-safe base64 (RFC 4648 §5) for the round-tripped `state`.
|
||||
* The order federated providers are offered in, most-used first.
|
||||
*
|
||||
* The provider reflects `state` back on a URL query string. Standard base64
|
||||
* (`btoa`) emits `+` `/` `=`, and on the return `URLSearchParams` turns `+`
|
||||
* into a space (then `atob` strips it) — silently CORRUPTING the encoded OIDC
|
||||
* request, including the `code_challenge`. A corrupted, non-empty challenge is
|
||||
* exactly what makes the app's later token exchange fail with
|
||||
* `invalid_grant: code_verifier does not match code_challenge`. Encoding the
|
||||
* state with the URL-safe alphabet (and decoding it the same way in `Callback`)
|
||||
* makes the round-trip byte-exact. The payload is ASCII (an OAuth query), so a
|
||||
* plain `btoa`/`atob` core is sufficient.
|
||||
* Order is PROVIDER POLICY, so it lives here beside the other provider policy
|
||||
* rather than in the component that happens to paint the buttons — and being a
|
||||
* value in a pure module, it is the only part of the button strip a test can
|
||||
* actually hold still. It had drifted before: an unexported constant inside the
|
||||
* component, covered by nothing.
|
||||
*
|
||||
* Google leads because it is the account most people arrive already signed into,
|
||||
* and the wallet trails because it is the specialist entry. A provider absent
|
||||
* from this list does not render at all, which is deliberate: a name here is the
|
||||
* statement that the portal knows how to draw and finish that provider's flow.
|
||||
*/
|
||||
export function encodeState(raw: string): string {
|
||||
return btoa(raw).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
/** Inverse of {@link encodeState}; tolerant of standard or URL-safe input. */
|
||||
export function decodeState(state: string): string {
|
||||
let b64 = state.replace(/-/g, '+').replace(/_/g, '/')
|
||||
while (b64.length % 4) b64 += '='
|
||||
return atob(b64)
|
||||
}
|
||||
export const PROVIDER_ORDER = ['google', 'github', 'gitlab', 'web3'] as const
|
||||
|
||||
/**
|
||||
* Build the provider authorize URL (pure; testable without navigating).
|
||||
*
|
||||
* `callbackOrigin` is the origin of the `/callback` that MUST be registered as
|
||||
* the provider's authorized redirect URI. The OAuth client (one per provider)
|
||||
* is registered against a SINGLE callback host — the IAM backend host
|
||||
* (`iam.hanzo.ai`) — shared across every brand portal, so the provider only
|
||||
* accepts that exact `redirect_uri`. Sending the browser's own origin (e.g.
|
||||
* `hanzo.id`) yields `redirect_uri_mismatch`. Callers pass the registered
|
||||
* origin; it defaults to `origin` for the single-host / local-dev case.
|
||||
*
|
||||
* `iam.hanzo.ai/callback` serves the SAME `@hanzo/id` SPA (the headless
|
||||
* `Callback` page — no login UI), which decodes the base64 `state` to recover
|
||||
* the original app's `redirect_uri`, exchanges the provider `code` at the IAM
|
||||
* backend, and forwards the browser back to the originating app.
|
||||
* Resolve a `provider_hint` from the authorize query to one of the app's
|
||||
* configured providers. A client that already knows which provider the user
|
||||
* chose (the console passes `?provider_hint=provider-github` when a user clicks
|
||||
* "Continue with GitHub" over there) sends the hint so this portal launches that
|
||||
* provider straight away — no second button press, no bounce through a login
|
||||
* page. Accepts the IAM record name (`provider-github`), the normalized key
|
||||
* (`github`), or the record name with the `provider-` prefix stripped, so the
|
||||
* two sides agree without a shared constant. Returns undefined when nothing
|
||||
* matches (the caller falls back to the interactive form).
|
||||
*/
|
||||
export function buildProviderAuthUrl(
|
||||
p: ProviderLoginParams,
|
||||
origin: string,
|
||||
search: string,
|
||||
callbackOrigin: string = origin,
|
||||
): string | null {
|
||||
const info = AUTH_INFO[p.type]
|
||||
if (!info || !p.clientId) return null
|
||||
const scope = p.scopes && p.scopes.trim() !== '' ? p.scopes : info.scope
|
||||
const redirectUri = `${callbackOrigin}/callback`
|
||||
// IAM's social branch does FIND-OR-CREATE-LOGIN only under method `signup`
|
||||
// (the canonical Casdoor default — web `Util.tsx` getEvent → getAuthUrl(...,
|
||||
// "signup")). Any other value (incl. `signin`) takes the account-LINK branch,
|
||||
// which requires an EXISTING session and 400s a fresh "Continue with Google".
|
||||
const method = p.method ?? 'signup'
|
||||
// The console SSO SDK appends a unified `provider=<org>-iam` hint to the
|
||||
// upstream authorize query (`search`). We append the REAL social provider
|
||||
// below, so strip any pre-existing `provider=` first — otherwise the state
|
||||
// carries TWO `provider=` params and the /callback exchange resolves the
|
||||
// wrong one (`<org>-iam`, which IAM rejects). One provider, one source of
|
||||
// truth — don't rely on "backend reads the last param".
|
||||
const baseQ = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search)
|
||||
baseQ.delete('provider')
|
||||
const baseSearch = `?${baseQ.toString()}`
|
||||
// Base64 of the original OIDC query + routing — the backend decodes this on
|
||||
// the /callback return to complete the original authorize request.
|
||||
const state = encodeState(`${baseSearch}&application=${encodeURIComponent(p.application)}&provider=${encodeURIComponent(p.providerName)}&method=${method}`)
|
||||
return `${info.endpoint}?client_id=${p.clientId}&redirect_uri=${redirectUri}&scope=${scope}&response_type=code&state=${state}`
|
||||
}
|
||||
|
||||
/** True when this portal knows how to start an OAuth hop for the given type. */
|
||||
export function isHoppableProvider(type: string): boolean {
|
||||
return type in AUTH_INFO
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect the browser to the provider to begin login. No-op return on bad input.
|
||||
*
|
||||
* `callbackOrigin` (the provider's registered redirect host, e.g.
|
||||
* `https://iam.hanzo.ai`) defaults to the current origin when omitted.
|
||||
*/
|
||||
export function startProviderLogin(p: ProviderLoginParams, callbackOrigin?: string): void {
|
||||
if (typeof window === 'undefined') return
|
||||
const url = buildProviderAuthUrl(p, window.location.origin, window.location.search, callbackOrigin ?? window.location.origin)
|
||||
if (url) window.location.assign(url)
|
||||
export function matchProviderHint<P extends { name: string; key: string }>(
|
||||
providers: Iterable<P>,
|
||||
hint: string,
|
||||
): P | undefined {
|
||||
const h = hint.trim().toLowerCase()
|
||||
if (h === '') return undefined
|
||||
const bare = h.replace(/^provider-/, '')
|
||||
for (const p of providers) {
|
||||
const name = p.name.toLowerCase()
|
||||
const key = p.key.toLowerCase()
|
||||
if (name === h || key === h || key === bare) return p
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
+98
-4
@@ -17,7 +17,7 @@ export interface LoginRequest {
|
||||
* session) while a brand-only identity resolves to its own brand org. This is
|
||||
* why the portal does NOT pin the brand org here — pinning `hanzo` would
|
||||
* resolve a colliding `hanzo/<name>` row and truncate a global admin to one
|
||||
* org. Set it only to FORCE a specific tenant (e.g. a brand that deliberately
|
||||
* org. Set it only to FORCE a specific org (e.g. a brand that deliberately
|
||||
* scopes its portal to a single org). Signup, by contrast, MUST carry a
|
||||
* concrete org (you cannot create a user in "no org").
|
||||
*/
|
||||
@@ -62,14 +62,33 @@ export interface SilentLoginRequest {
|
||||
readonly nonce?: string
|
||||
}
|
||||
|
||||
/** A multi-factor channel the portal can render a code entry for. */
|
||||
export type MfaChannel = 'totp' | 'sms' | 'email'
|
||||
|
||||
export interface LoginResponse {
|
||||
readonly accessToken?: string
|
||||
readonly refreshToken?: string
|
||||
readonly idToken?: string
|
||||
readonly expiresAt?: number
|
||||
readonly redirectUrl?: string
|
||||
/**
|
||||
* Set when IAM answered the login with a multi-factor signal instead of a
|
||||
* session/code. `mfaStage` discriminates the two IAM states:
|
||||
* - `'enroll'` — IAM returned `data:"RequiredMfa"`: org policy forces MFA
|
||||
* and the user has none yet → render forced TOTP enrollment.
|
||||
* - `'challenge'` — IAM returned `data:"NextMfa"`: the user has MFA enabled
|
||||
* → render a code challenge for one of `mfaTypes`.
|
||||
* The password session is NOT established until the enrollment/challenge
|
||||
* completes, so the portal must not navigate past this signal.
|
||||
*/
|
||||
readonly mfaRequired?: boolean
|
||||
readonly mfaChannel?: 'totp' | 'sms' | 'email'
|
||||
readonly mfaStage?: 'enroll' | 'challenge'
|
||||
/**
|
||||
* The IAM MFA types available for a `'challenge'` (from the login response's
|
||||
* named `mfa` field, legacy `data2`), in IAM's own vocabulary: `app` (TOTP),
|
||||
* `sms`, `email`. Empty for enrollment.
|
||||
*/
|
||||
readonly mfaTypes?: readonly string[]
|
||||
readonly error?: string
|
||||
}
|
||||
|
||||
@@ -89,13 +108,88 @@ export interface DeviceApprovalResult {
|
||||
readonly error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* WHICH application a pending device code belongs to
|
||||
* ({@link AuthClient.deviceInfo}) — the one thing the approval page exists to
|
||||
* tell a human, and the one thing it cannot know on its own.
|
||||
*
|
||||
* Both fields come off the device code's own application row, so a page that
|
||||
* renders them names the party it is actually authorizing. They are the ONLY
|
||||
* honest source: `org.appName` is this portal's static branding and names the
|
||||
* wrong app for every code minted by anything else.
|
||||
*
|
||||
* Discriminated on `ok` so a caller cannot read `displayName` without having
|
||||
* proved the server confirmed one. `loginRequired` singles out the expired
|
||||
* session (IAM `code:"login_required"`) — the page's cue to sign the human in
|
||||
* and come back, not an error to show. Every other failure is IAM's single
|
||||
* opaque refusal, surfaced verbatim.
|
||||
*/
|
||||
export type DeviceInfoResult =
|
||||
| { readonly ok: true; readonly clientId: string; readonly displayName: string }
|
||||
| { readonly ok: false; readonly error: string; readonly loginRequired?: boolean }
|
||||
|
||||
/**
|
||||
* The TOTP enrollment material minted by `/v1/iam/mfa/setup/initiate`. The
|
||||
* secret + `url` (an `otpauth://` URI) are rendered locally as a QR code — the
|
||||
* secret never leaves the browser to a third party. `recoveryCodes[0]` must be
|
||||
* echoed back to `/v1/iam/mfa/setup/enable`.
|
||||
*/
|
||||
export interface MfaSetup {
|
||||
/** IAM MFA type — `app` for TOTP. */
|
||||
readonly mfaType: string
|
||||
/** Base32 TOTP secret. */
|
||||
readonly secret: string
|
||||
/** `otpauth://totp/...` provisioning URI for the authenticator app. */
|
||||
readonly url: string
|
||||
/** One-time recovery codes issued alongside the secret. */
|
||||
readonly recoveryCodes: readonly string[]
|
||||
}
|
||||
|
||||
/** The signed-in user's identity, resolved from the IAM session for MFA setup. */
|
||||
export interface MfaIdentity {
|
||||
readonly owner: string
|
||||
readonly name: string
|
||||
}
|
||||
|
||||
/** A TOTP challenge submission for a user who already enrolled (`NextMfa`). */
|
||||
export interface MfaChallengeRequest {
|
||||
/** IAM MFA type, e.g. `app` (TOTP), `sms`, `email`. */
|
||||
readonly mfaType: string
|
||||
readonly passcode: string
|
||||
readonly clientId: string
|
||||
readonly application: string
|
||||
readonly organization: string
|
||||
readonly redirectUri?: string
|
||||
readonly state?: string
|
||||
readonly codeChallenge?: string
|
||||
readonly codeChallengeMethod?: 'S256' | 'plain'
|
||||
/** Honor the org's "remember this device" window after a successful code. */
|
||||
readonly rememberDevice?: boolean
|
||||
}
|
||||
|
||||
export interface SignupRequest {
|
||||
readonly email: string
|
||||
readonly password: string
|
||||
readonly clientId: string
|
||||
readonly application: string
|
||||
/**
|
||||
* The org to create the user in. REQUIRED — unlike login's optional
|
||||
* lookup hint, you cannot create a user in "no org", and IAM gates this
|
||||
* against the application's own org.
|
||||
*/
|
||||
readonly organization: string
|
||||
readonly inviteCode?: string
|
||||
/**
|
||||
* The downstream OIDC request, when an app sent the user here to register.
|
||||
* Registration completes by signing the new user in, so these are forwarded
|
||||
* to that sign-in: without them the minted code carries no PKCE binding and
|
||||
* there is nowhere to return the user to.
|
||||
*/
|
||||
readonly redirectUri?: string
|
||||
readonly state?: string
|
||||
readonly codeChallenge?: string
|
||||
readonly codeChallengeMethod?: 'S256' | 'plain'
|
||||
readonly nonce?: string
|
||||
}
|
||||
|
||||
export interface ForgotRequest {
|
||||
@@ -121,9 +215,9 @@ export interface OAuthAuthorizeRequest {
|
||||
export interface ProviderInfo {
|
||||
readonly name: string
|
||||
readonly displayName?: string
|
||||
/** Casdoor provider type, e.g. GitHub, Google, Apple, Web3Onboard. */
|
||||
/** IAM provider type, e.g. GitHub, Google, Apple, Web3Onboard. */
|
||||
readonly type?: string
|
||||
/** Casdoor category, e.g. OAuth, Web3, SAML. */
|
||||
/** IAM category, e.g. OAuth, Web3, SAML. */
|
||||
readonly category?: string
|
||||
readonly canSignIn?: boolean
|
||||
readonly canSignUp?: boolean
|
||||
|
||||
@@ -20,8 +20,8 @@ export function ForgotForm(props: ForgotFormProps) {
|
||||
try {
|
||||
const res = await client.forgot({
|
||||
identifier,
|
||||
clientId: client.tenant.clientId,
|
||||
organization: client.tenant.orgId,
|
||||
clientId: client.org.clientId,
|
||||
organization: client.org.orgId,
|
||||
})
|
||||
if (!res.ok) setError(res.error ?? 'send failed')
|
||||
else {
|
||||
@@ -40,13 +40,13 @@ export function ForgotForm(props: ForgotFormProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="hanzo-id-forgot-form" aria-busy={busy}>
|
||||
<label>
|
||||
<form onSubmit={onSubmit} className="hanzo-id-form" aria-busy={busy}>
|
||||
<label className="hanzo-id-field">
|
||||
<span>Email</span>
|
||||
<input type="email" autoComplete="email" value={identifier} onChange={(e) => setIdentifier(e.target.value)} required />
|
||||
<input className="hanzo-id-input" type="email" autoComplete="email" value={identifier} onChange={(e) => setIdentifier(e.target.value)} required />
|
||||
</label>
|
||||
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
|
||||
<button type="submit" disabled={busy}>{busy ? 'Sending…' : 'Send reset link'}</button>
|
||||
<button type="submit" className="hanzo-id-btn" disabled={busy}>{busy ? 'Sending…' : 'Send reset link'}</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import type { AuthClient } from '../client'
|
||||
import type { LoginResponse } from '../types'
|
||||
import { PasswordField } from './PasswordField'
|
||||
|
||||
export interface LoginFormProps {
|
||||
readonly client: AuthClient
|
||||
@@ -33,17 +34,45 @@ export function LoginForm(props: LoginFormProps) {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
// Authenticate against the ORG OF THE APP being logged into, not the
|
||||
// portal's own brand. When a downstream app initiates the login it passes
|
||||
// its own `client_id` (props.clientIdOverride); that app may live in a
|
||||
// different org than this brand portal — e.g. the admin-guard
|
||||
// (client_id=hanzo-admin-guard) is in the `admin` org, so its operators
|
||||
// must resolve to the admin/* identity (owner=admin), NOT this brand's
|
||||
// hanzo/* row. get-app-login is the canonical clientId -> {application,
|
||||
// organization} map; resolve through it and post BOTH so IAM scopes the
|
||||
// credential check to the app's org.
|
||||
//
|
||||
// BOTH entry points resolve the same way — the downstream-app login
|
||||
// (clientIdOverride) and the brand portal's own bare sign-in. They used to
|
||||
// differ: the bare portal deliberately posted NO `organization` so IAM's
|
||||
// cross-org fallback landed a colliding identity (z@hanzo.ai exists in both
|
||||
// `admin` and `hanzo`) on admin/* and returned the full multi-org session.
|
||||
//
|
||||
// That is gone, on purpose, at the server. iam2 scopes every credential
|
||||
// lookup to one org and treats the collision it relied on as a defect —
|
||||
// "the F-2 bug where z@hanzo.ai collided across admin and hanzo" — because
|
||||
// cross-org resolution coupled lockout counters across rows and gave a
|
||||
// brute-force oracle on the superadmin. So it now REFUSES an org-less login
|
||||
// with "organization, username and password are required". It answers HTTP
|
||||
// **200**, which the form then renders as if the user's own password were
|
||||
// wrong, and which every status-code monitor reads as green — the apex form
|
||||
// was dead on hanzo.id, lux.id, iam.hanzo.ai and pars.id simultaneously.
|
||||
//
|
||||
// Posting the app's own org is the established answer (it is what the
|
||||
// override path already does, and what reaches admin/* for admin-org apps).
|
||||
// A global admin is no longer resolved by omission; they reach the admin
|
||||
// identity by signing into an admin-org app, which is the explicit path.
|
||||
const app = await client.getAppLogin(props.clientIdOverride ?? client.org.clientId)
|
||||
const application = app?.application ?? client.org.appName
|
||||
const organization = app?.organization ?? client.org.loginOrg
|
||||
const res = await client.login({
|
||||
identifier,
|
||||
password,
|
||||
clientId: props.clientIdOverride ?? client.tenant.clientId,
|
||||
application: client.tenant.appName,
|
||||
// Org-agnostic by default: `loginOrg` is unset, so no `organization` is
|
||||
// posted and IAM resolves the user cross-org by credentials. A global
|
||||
// admin (identity in the `admin` org) lands in the global multi-org
|
||||
// session; a brand-only user lands in their own org. Pinning the brand
|
||||
// org here would truncate a global admin to one org (the live bug).
|
||||
organization: client.tenant.loginOrg,
|
||||
clientId: props.clientIdOverride ?? client.org.clientId,
|
||||
application,
|
||||
organization,
|
||||
redirectUri: props.redirectUri,
|
||||
state: props.state,
|
||||
codeChallenge: props.codeChallenge,
|
||||
@@ -71,10 +100,11 @@ export function LoginForm(props: LoginFormProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="hanzo-id-login-form" aria-busy={busy}>
|
||||
<label>
|
||||
<form onSubmit={onSubmit} className="hanzo-id-form" aria-busy={busy}>
|
||||
<label className="hanzo-id-field">
|
||||
<span>Email or username</span>
|
||||
<input
|
||||
className="hanzo-id-input"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
value={identifier}
|
||||
@@ -82,18 +112,14 @@ export function LoginForm(props: LoginFormProps) {
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Password</span>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<PasswordField
|
||||
label="Password"
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
|
||||
<button type="submit" disabled={busy}>{busy ? 'Signing in…' : 'Sign in'}</button>
|
||||
<button type="submit" className="hanzo-id-btn" disabled={busy}>{busy ? 'Signing in…' : 'Sign in'}</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import encodeQR from '@paulmillr/qr'
|
||||
import type { AuthClient } from '../client'
|
||||
import type { MfaIdentity, MfaSetup } from '../types'
|
||||
import { OTPForm } from './OTPForm'
|
||||
|
||||
export interface MfaEnrollFormProps {
|
||||
readonly client: AuthClient
|
||||
/**
|
||||
* Called once the user has verified a TOTP code AND the enrollment is
|
||||
* persisted. The caller continues the session (onboarding or the OIDC
|
||||
* code redirect).
|
||||
*/
|
||||
readonly onComplete: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Forced TOTP enrollment, shown when IAM answers a login with `RequiredMfa`
|
||||
* (org policy requires MFA and the user has none). There is intentionally NO
|
||||
* skip / dismiss control — the only way past this screen is to enroll an
|
||||
* authenticator. The QR is rendered locally from the `otpauth://` URI, so the
|
||||
* TOTP secret never leaves the browser.
|
||||
*
|
||||
* Flow: `getAccount` (resolve identity from the session IAM set with
|
||||
* `RequiredMfa`) → `mfaInitiate` (secret + QR) → user scans → `mfaVerify`
|
||||
* (prove the code) → `mfaEnable` (persist) → `onComplete`.
|
||||
*/
|
||||
export function MfaEnrollForm({ client, onComplete }: MfaEnrollFormProps) {
|
||||
const [identity, setIdentity] = useState<MfaIdentity | null>(null)
|
||||
const [setup, setSetup] = useState<MfaSetup | null>(null)
|
||||
const [fatal, setFatal] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
async function begin() {
|
||||
try {
|
||||
const id = await client.getAccount()
|
||||
if (!id) throw new Error('Your session could not be resolved. Please sign in again.')
|
||||
const s = await client.mfaInitiate(id)
|
||||
if (cancelled) return
|
||||
setIdentity(id)
|
||||
setSetup(s)
|
||||
} catch (e) {
|
||||
if (!cancelled) setFatal(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}
|
||||
void begin()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [client])
|
||||
|
||||
const qrSvg = useMemo(() => (setup ? encodeQR(setup.url, 'svg') : ''), [setup])
|
||||
|
||||
async function onCode(code: string) {
|
||||
if (!identity || !setup || busy) return
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
const verified = await client.mfaVerify({ owner: identity.owner, name: identity.name, secret: setup.secret, passcode: code })
|
||||
if (!verified.ok) {
|
||||
setError(verified.error ?? 'That code did not match. Try the current code from your app.')
|
||||
return
|
||||
}
|
||||
const enabled = await client.mfaEnable({
|
||||
owner: identity.owner,
|
||||
name: identity.name,
|
||||
secret: setup.secret,
|
||||
recoveryCode: setup.recoveryCodes[0] ?? '',
|
||||
})
|
||||
if (!enabled.ok) {
|
||||
setError(enabled.error ?? 'Could not enable two-factor authentication.')
|
||||
return
|
||||
}
|
||||
onComplete()
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (fatal) {
|
||||
return (
|
||||
<div className="hanzo-id-mfa-enroll">
|
||||
<h2>Two-factor setup</h2>
|
||||
<p role="alert" className="hanzo-id-error">{fatal}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!setup) {
|
||||
return (
|
||||
<div className="hanzo-id-mfa-enroll">
|
||||
<h2>Two-factor setup</h2>
|
||||
<p className="lede">Preparing your authenticator…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const recoveryCode = setup.recoveryCodes[0]
|
||||
return (
|
||||
<div className="hanzo-id-mfa-enroll">
|
||||
<h2>Set up two-factor authentication</h2>
|
||||
<p className="lede">
|
||||
Your organization requires two-factor authentication. Scan this QR code with an
|
||||
authenticator app (Google Authenticator, 1Password, Authy), then enter the 6-digit code it
|
||||
shows.
|
||||
</p>
|
||||
<div
|
||||
className="hanzo-id-mfa-qr"
|
||||
role="img"
|
||||
aria-label="TOTP enrollment QR code"
|
||||
// Local SVG from @paulmillr/qr — the otpauth secret never leaves the browser.
|
||||
dangerouslySetInnerHTML={{ __html: qrSvg }}
|
||||
/>
|
||||
<details className="hanzo-id-mfa-manual">
|
||||
<summary>Can't scan? Enter this key manually</summary>
|
||||
<code className="hanzo-id-mfa-secret">{setup.secret}</code>
|
||||
</details>
|
||||
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
|
||||
<OTPForm channel="totp" onSubmit={onCode} />
|
||||
{recoveryCode ? (
|
||||
<p className="hanzo-id-mfa-recovery">
|
||||
Save this recovery code somewhere safe — it lets you sign in if you lose your device:
|
||||
<br />
|
||||
<code>{recoveryCode}</code>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -26,10 +26,11 @@ export function OTPForm(props: OTPFormProps) {
|
||||
const label = channel === 'sms' ? 'SMS code' : channel === 'email' ? 'Email code' : 'Authenticator code'
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="hanzo-id-otp-form" aria-busy={busy}>
|
||||
<label>
|
||||
<form onSubmit={onSubmit} className="hanzo-id-form" aria-busy={busy}>
|
||||
<label className="hanzo-id-field">
|
||||
<span>{label}</span>
|
||||
<input
|
||||
className="hanzo-id-input"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern={`\\d{${length}}`}
|
||||
@@ -41,7 +42,7 @@ export function OTPForm(props: OTPFormProps) {
|
||||
/>
|
||||
</label>
|
||||
{channel === 'sms' ? <SmsConsentNotice /> : null}
|
||||
<button type="submit" disabled={busy || code.length !== length}>{busy ? 'Verifying…' : 'Verify'}</button>
|
||||
<button type="submit" className="hanzo-id-btn" disabled={busy || code.length !== length}>{busy ? 'Verifying…' : 'Verify'}</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useId, useState } from 'react'
|
||||
import { EyeIcon, EyeOffIcon } from './icons'
|
||||
|
||||
/**
|
||||
* The ONE password field. Every credential this portal collects is typed into
|
||||
* this component, so "can I see what I typed" is answered in one place rather
|
||||
* than per form.
|
||||
*
|
||||
* A masked field with no way to unmask is the single most common cause of a
|
||||
* failed sign-in that looks like a wrong password, and it is worst exactly where
|
||||
* typing is least reliable: a phone keyboard, one glyph at a time, with
|
||||
* autocorrect and a shifted layout in the way. On signup it is worse still — the
|
||||
* account is CREATED with whatever was actually typed, so a typo behind the dots
|
||||
* locks the user out of an account they believe they just made.
|
||||
*
|
||||
* The reveal is the user's call, not ours. It defaults to masked, so nothing
|
||||
* about the resting page changes; unmasking is a deliberate act with the eye
|
||||
* open as its own state indicator.
|
||||
*/
|
||||
export interface PasswordFieldProps {
|
||||
readonly label: string
|
||||
readonly value: string
|
||||
readonly onChange: (value: string) => void
|
||||
/**
|
||||
* `current-password` on sign-in, `new-password` on registration. It is what
|
||||
* lets a password manager fill the right thing, and getting it wrong is how a
|
||||
* manager offers to "update" a saved credential during a fresh signup.
|
||||
*/
|
||||
readonly autoComplete: 'current-password' | 'new-password'
|
||||
readonly minLength?: number
|
||||
}
|
||||
|
||||
export function PasswordField(props: PasswordFieldProps) {
|
||||
const [shown, setShown] = useState(false)
|
||||
const id = useId()
|
||||
return (
|
||||
<div className="hanzo-id-field">
|
||||
{/* A <label> wrapping the control is the idiom everywhere else here, but a
|
||||
<button> inside a <label> is activated twice — once as the button, once
|
||||
as the label forwarding to its control — so the toggle would fight
|
||||
itself. `htmlFor` keeps the same click-the-text-to-focus behaviour with
|
||||
the button safely outside. */}
|
||||
<label htmlFor={id}>{props.label}</label>
|
||||
<div className="hanzo-id-reveal">
|
||||
<input
|
||||
id={id}
|
||||
className="hanzo-id-input"
|
||||
type={shown ? 'text' : 'password'}
|
||||
autoComplete={props.autoComplete}
|
||||
value={props.value}
|
||||
onChange={(e) => props.onChange(e.target.value)}
|
||||
minLength={props.minLength}
|
||||
required
|
||||
/>
|
||||
<button
|
||||
// NOT a submit. A bare <button> in a <form> defaults to type=submit,
|
||||
// so without this, revealing the password would submit the form.
|
||||
type="button"
|
||||
className="hanzo-id-revealbtn"
|
||||
// The control is a toggle, so it says what it DOES and reports its
|
||||
// state separately; a label that flips between "Show"/"Hide" makes
|
||||
// screen readers announce a change of control rather than of state.
|
||||
aria-label="Show password"
|
||||
aria-pressed={shown}
|
||||
aria-controls={id}
|
||||
onClick={() => setShown((s) => !s)}
|
||||
>
|
||||
{shown ? <EyeOffIcon width={20} height={20} /> : <EyeIcon width={20} height={20} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import type { AuthClient } from '../client'
|
||||
import type { ProviderInfo } from '../types'
|
||||
|
||||
interface Meta {
|
||||
readonly label: string
|
||||
/** stable brand token used for the CSS class + data attribute (for icon styling) */
|
||||
readonly brand: string
|
||||
}
|
||||
|
||||
// Keyed by a normalized provider token (type or name, lowercased, alnum-only,
|
||||
// "provider" prefix stripped). Falls back to a generic label for anything new.
|
||||
const META: Record<string, Meta> = {
|
||||
google: { label: 'Continue with Google', brand: 'google' },
|
||||
github: { label: 'Continue with GitHub', brand: 'github' },
|
||||
apple: { label: 'Continue with Apple', brand: 'apple' },
|
||||
facebook: { label: 'Continue with Facebook', brand: 'facebook' },
|
||||
web3: { label: 'Connect wallet', brand: 'web3' },
|
||||
web3onboard: { label: 'Connect wallet', brand: 'web3' },
|
||||
metamask: { label: 'Connect wallet', brand: 'web3' },
|
||||
}
|
||||
|
||||
function metaFor(p: ProviderInfo): Meta {
|
||||
const key = (p.type || p.name || '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, '')
|
||||
.replace(/^provider/, '')
|
||||
return META[key] ?? { label: `Continue with ${p.displayName || p.name}`, brand: 'generic' }
|
||||
}
|
||||
|
||||
export interface ProviderButtonsProps {
|
||||
readonly client: AuthClient
|
||||
readonly providers: ProviderInfo[]
|
||||
readonly mode: 'login' | 'signup'
|
||||
readonly redirectUri?: string
|
||||
readonly state?: string
|
||||
readonly clientIdOverride?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders one button per social/wallet provider attached to the application.
|
||||
* Each links to the IAM authorize endpoint with `provider=<name>`, which
|
||||
* initiates that provider's OAuth and returns to `${publicOrigin}/callback`.
|
||||
* The set is driven by the live app config (AuthClient.appLogin) — no
|
||||
* hardcoded provider list — so enabling a provider in IAM surfaces it here.
|
||||
*/
|
||||
export function ProviderButtons(props: ProviderButtonsProps) {
|
||||
const { client, providers, mode } = props
|
||||
const usable = providers.filter((p) =>
|
||||
mode === 'signup' ? p.canSignUp !== false : p.canSignIn !== false,
|
||||
)
|
||||
if (usable.length === 0) return null
|
||||
|
||||
const redirectUri = props.redirectUri ?? `${client.tenant.publicOrigin}/callback`
|
||||
return (
|
||||
<div className="hanzo-id-providers">
|
||||
{usable.map((p) => {
|
||||
const m = metaFor(p)
|
||||
const href = client.authorize({
|
||||
clientId: props.clientIdOverride ?? client.tenant.clientId,
|
||||
redirectUri,
|
||||
state: props.state ?? mode,
|
||||
provider: p.name,
|
||||
})
|
||||
return (
|
||||
<a
|
||||
key={p.name}
|
||||
className={`hanzo-id-provider-btn hanzo-id-provider-${m.brand}`}
|
||||
href={href}
|
||||
data-provider={m.brand}
|
||||
>
|
||||
{m.label}
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
<div className="hanzo-id-or">
|
||||
<span>or</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,21 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import type { AuthClient } from '../client'
|
||||
import { PasswordField } from './PasswordField'
|
||||
|
||||
export interface SignupFormProps {
|
||||
readonly client: AuthClient
|
||||
readonly inviteCode?: string
|
||||
readonly onSuccess?: () => void
|
||||
/**
|
||||
* The downstream OIDC request the user arrived with, when an app sent them
|
||||
* here to register. Forwarded to the sign-in that follows account creation so
|
||||
* the flow ends where it started — back at the app, holding a code.
|
||||
*/
|
||||
readonly redirectUri?: string
|
||||
readonly state?: string
|
||||
readonly clientIdOverride?: string
|
||||
readonly codeChallenge?: string
|
||||
readonly codeChallengeMethod?: 'S256' | 'plain'
|
||||
readonly nonce?: string
|
||||
}
|
||||
|
||||
export function SignupForm(props: SignupFormProps) {
|
||||
@@ -19,17 +30,45 @@ export function SignupForm(props: SignupFormProps) {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await client.signup({
|
||||
// Register against the app the user CAME FROM, not this portal. IAM's
|
||||
// signup resolves the application by clientId and then gates the org
|
||||
// against that app's own org, so a downstream `client_id` must reach
|
||||
// it or the account is created under the portal's app instead.
|
||||
const clientId = props.clientIdOverride ?? client.org.clientId
|
||||
const app = await client.getAppLogin(clientId, props.redirectUri)
|
||||
const application = app?.application ?? client.org.appName
|
||||
const organization = app?.organization ?? client.org.orgId
|
||||
|
||||
const session = await client.signup({
|
||||
email,
|
||||
password,
|
||||
clientId: client.tenant.clientId,
|
||||
application: client.tenant.appName,
|
||||
organization: client.tenant.orgId,
|
||||
clientId,
|
||||
application,
|
||||
organization,
|
||||
inviteCode: props.inviteCode,
|
||||
redirectUri: props.redirectUri,
|
||||
state: props.state,
|
||||
codeChallenge: props.codeChallenge,
|
||||
codeChallengeMethod: props.codeChallengeMethod,
|
||||
nonce: props.nonce,
|
||||
})
|
||||
if (res.error) setError(res.error)
|
||||
else if (res.redirectUrl) window.location.href = res.redirectUrl
|
||||
else props.onSuccess?.()
|
||||
if (session.error) {
|
||||
setError(session.error)
|
||||
return
|
||||
}
|
||||
if (session.redirectUrl) {
|
||||
window.location.href = session.redirectUrl
|
||||
return
|
||||
}
|
||||
// The account exists but the session did not complete here — an org that
|
||||
// forces MFA answers the login with an enrollment step. Hand the user to
|
||||
// the sign-in page, carrying the same OIDC request, rather than leaving
|
||||
// them on a form that has nothing left to do.
|
||||
if (session.mfaRequired) {
|
||||
window.location.href = `/login${window.location.search}`
|
||||
return
|
||||
}
|
||||
setError('Your account was created, but sign-in did not complete. Please sign in.')
|
||||
} catch (err) {
|
||||
setError(String(err))
|
||||
} finally {
|
||||
@@ -38,24 +77,20 @@ export function SignupForm(props: SignupFormProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="hanzo-id-signup-form" aria-busy={busy}>
|
||||
<label>
|
||||
<form onSubmit={onSubmit} className="hanzo-id-form" aria-busy={busy}>
|
||||
<label className="hanzo-id-field">
|
||||
<span>Email</span>
|
||||
<input type="email" autoComplete="email" value={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
|
||||
/>
|
||||
<input className="hanzo-id-input" type="email" autoComplete="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
|
||||
</label>
|
||||
<PasswordField
|
||||
label="Password"
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
autoComplete="new-password"
|
||||
minLength={12}
|
||||
/>
|
||||
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
|
||||
<button type="submit" disabled={busy}>{busy ? 'Creating account…' : 'Create account'}</button>
|
||||
<button type="submit" className="hanzo-id-btn" disabled={busy}>{busy ? 'Creating account…' : 'Create account'}</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Fragment, useEffect, useRef, useState } from 'react'
|
||||
import type { ComponentType, SVGProps } from 'react'
|
||||
import type { Chain } from '@hanzo/id-connect'
|
||||
import type { AuthClient } from '../client'
|
||||
import type { AppProvider } from '../types'
|
||||
import { startProviderLogin, isHoppableProvider } from '../social'
|
||||
import { loginWithWalletChain, ENABLED_WALLET_CHAINS, WALLET_CHAIN_LABELS } from '../web3'
|
||||
import { GitHubIcon, GoogleIcon, WalletIcon } from './icons'
|
||||
import { authorizeRequest, matchProviderHint, PROVIDER_ORDER } from '../social'
|
||||
import { createIam } from '../iam'
|
||||
import {
|
||||
loginWithWalletChain,
|
||||
detectWalletChains,
|
||||
ENABLED_WALLET_CHAINS,
|
||||
WALLET_CHAIN_LABELS,
|
||||
} from '../web3'
|
||||
import { GitHubIcon, GitLabIcon, GoogleIcon, WalletIcon } from './icons'
|
||||
import { Divider } from './Divider'
|
||||
|
||||
/**
|
||||
@@ -19,13 +25,17 @@ import { Divider } from './Divider'
|
||||
* real creds land. When the config is unreadable we render none.
|
||||
*
|
||||
* Two sign-in shapes, decomplected:
|
||||
* - OAuth (github/google) → the provider "hop" (`startProviderLogin`): redirect
|
||||
* straight to the provider; the IAM backend `/callback` exchange completes it.
|
||||
* - OAuth (github/google/gitlab) → FEDERATION: name the provider on IAM's own
|
||||
* authorize endpoint (`?provider=provider-github`) and let IAM run the entire
|
||||
* IdP leg server-side, where the client secret lives. See `social.ts`. This
|
||||
* browser never builds an IdP URL and never sees a provider code.
|
||||
* - Web3/wallet → native Sign-In-With-X (`loginWithWalletChain`): connect a
|
||||
* wallet with `@hanzo/id-connect` (no WalletConnect, no projectId), sign the
|
||||
* IAM-minted challenge, POST `/v1/iam/web3/verify`, then follow the SAME
|
||||
* redirect the password flow returns. The wallet provider expands into one
|
||||
* button per ENABLED chain.
|
||||
* redirect the password flow returns. The wallet provider renders ONE
|
||||
* chain-agnostic "Connect Wallet" button: it auto-detects the injected
|
||||
* chain (`detectWalletChains`) and connects straight when exactly one is
|
||||
* present, else reveals a chooser so either EVM or Solana stays reachable.
|
||||
*/
|
||||
export interface SocialButtonsProps {
|
||||
readonly client: AuthClient
|
||||
@@ -41,6 +51,22 @@ export interface SocialButtonsProps {
|
||||
* a bare portal sign-in that lands on onboarding.
|
||||
*/
|
||||
readonly postLoginRedirect?: string
|
||||
/**
|
||||
* A `provider_hint` from the authorize query — the console passes
|
||||
* `?provider_hint=provider-github` when a user clicks "Continue with GitHub"
|
||||
* over there. When set, once the app config resolves this component launches
|
||||
* the matching provider's hop straight away (the SAME hop the button runs) and
|
||||
* renders NOTHING: it is headless, a pure side-effect, so the caller shows its
|
||||
* own "signing you in" state. If the hint matches no configured provider,
|
||||
* `onAutoStartResolved(false)` fires so the caller can fall back to the form.
|
||||
*/
|
||||
readonly autoStart?: string
|
||||
/**
|
||||
* Called once, in `autoStart` mode, after the app config resolves: `true` when
|
||||
* the hinted provider launched, `false` when the hint matched nothing (so the
|
||||
* caller can drop to the interactive form instead of a blank redirect state).
|
||||
*/
|
||||
readonly onAutoStartResolved?: (started: boolean) => void
|
||||
}
|
||||
|
||||
interface ProviderMeta {
|
||||
@@ -52,16 +78,12 @@ interface ProviderMeta {
|
||||
/** Display metadata for the providers the portal knows how to render. */
|
||||
const PROVIDER_META: Record<string, ProviderMeta> = {
|
||||
github: { key: 'github', label: 'GitHub', Icon: GitHubIcon },
|
||||
gitlab: { key: 'gitlab', label: 'GitLab', Icon: GitLabIcon },
|
||||
google: { key: 'google', label: 'Google', Icon: GoogleIcon },
|
||||
web3: { key: 'web3', label: 'Wallet', Icon: WalletIcon },
|
||||
}
|
||||
|
||||
/** Canonical render order. */
|
||||
const ORDER = ['github', 'google', 'web3']
|
||||
|
||||
interface Resolved {
|
||||
/** IAM application name (for the provider-hop state). */
|
||||
readonly application: string
|
||||
/** Configured + renderable providers, keyed by their normalized key. */
|
||||
readonly providers: Record<string, AppProvider>
|
||||
}
|
||||
@@ -71,21 +93,76 @@ export function SocialButtons({
|
||||
clientIdOverride,
|
||||
intent = 'signin',
|
||||
postLoginRedirect,
|
||||
autoStart,
|
||||
onAutoStartResolved,
|
||||
}: SocialButtonsProps) {
|
||||
const [resolved, setResolved] = useState<Resolved | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busyChain, setBusyChain] = useState<Chain | null>(null)
|
||||
// The chain-agnostic wallet entry reveals a chooser only when it can't decide
|
||||
// for the user (zero or multiple injected wallets); a single injected wallet
|
||||
// connects straight without ever showing it.
|
||||
const [walletMenu, setWalletMenu] = useState(false)
|
||||
const autoStarted = useRef(false)
|
||||
|
||||
// Start federation: hand the provider's NAME to IAM's authorize endpoint and
|
||||
// let IAM run the whole IdP leg. Shared by the button click and the `autoStart`
|
||||
// auto-launch so both take the identical path.
|
||||
//
|
||||
// Two arms, and they are the same two the password path already branches on
|
||||
// (`Login.completeAfterAuth`) — the question is only who owns the PKCE verifier:
|
||||
//
|
||||
// an app sent the user here → re-enter authorize with THAT app's request, so
|
||||
// IAM mints the code against its client_id, redirect_uri and challenge and
|
||||
// returns the browser straight to it. The app holds the verifier; this portal
|
||||
// is never in the return path and never touches a token.
|
||||
//
|
||||
// a bare portal sign-in → the portal is its own client, so the IAM SDK mints
|
||||
// and stores the verifier that `Callback` reads back. `post_login_redirect`
|
||||
// carries a non-OIDC "come back here" target (device approval), which is why
|
||||
// it belongs to this arm alone: it is only ever read by the portal's own
|
||||
// callback, and only this arm runs it.
|
||||
function hop(provider: AppProvider) {
|
||||
const app = authorizeRequest(window.location.search, clientIdOverride ?? client.org.clientId)
|
||||
if (app) {
|
||||
sessionStorage.removeItem('post_login_redirect')
|
||||
window.location.assign(client.authorize({ ...app, provider: provider.name }))
|
||||
return
|
||||
}
|
||||
if (postLoginRedirect) sessionStorage.setItem('post_login_redirect', postLoginRedirect)
|
||||
else sessionStorage.removeItem('post_login_redirect')
|
||||
// Bare arm = the portal signs in as ITSELF, always. Threading a downstream
|
||||
// app's client_id in here paired it with the portal's own `/callback` — a
|
||||
// hybrid no app registers (that pairing was the older model), so IAM
|
||||
// answered "invalid redirect_uri", and `Callback` (portal client) could
|
||||
// never have redeemed the code anyway. An app that wants a code arrives as
|
||||
// a full authorize request and takes the arm above.
|
||||
createIam(client.org)
|
||||
.signinRedirect({ additionalParams: { provider: provider.name } })
|
||||
.catch((e) => setError(String(e)))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
// Read the app config against the DOWNSTREAM app's own redirect_uri (carried
|
||||
// on the authorize query in the SSO flow), not the portal's /callback — IAM
|
||||
// validates it against the app's registered list, and a cross-app clientId
|
||||
// (e.g. console's `hanzo-cloud` viewed from hanzo.id) does NOT register the
|
||||
// portal callback, so hardcoding it drops the whole response and no social
|
||||
// resolves. Absent (bare portal / device flow) → getAppLogin defaults it.
|
||||
const oidcRedirectUri =
|
||||
typeof window !== 'undefined'
|
||||
? new URLSearchParams(window.location.search).get('redirect_uri') ?? undefined
|
||||
: undefined
|
||||
client
|
||||
.getAppLogin(clientIdOverride)
|
||||
.getAppLogin(clientIdOverride, oidcRedirectUri)
|
||||
.then((app) => {
|
||||
if (cancelled) return
|
||||
if (!app) {
|
||||
// Can't read the app config → render no social rather than risk a
|
||||
// dead-end button. Password / email-code still render.
|
||||
setResolved({ application: '', providers: {} })
|
||||
setResolved({ providers: {} })
|
||||
onAutoStartResolved?.(false)
|
||||
return
|
||||
}
|
||||
const want = intent === 'signup' ? (p: AppProvider) => p.canSignUp : (p: AppProvider) => p.canSignIn
|
||||
@@ -99,43 +176,49 @@ export function SocialButtons({
|
||||
const enabled = p.key === 'web3' ? want(p) : want(p) && p.configured
|
||||
if (enabled && p.key in PROVIDER_META) providers[p.key] = p
|
||||
}
|
||||
setResolved({ application: app.application, providers })
|
||||
setResolved({ providers })
|
||||
// A client that already knows the provider (console `?provider_hint=…`)
|
||||
// launches it straight away — the SAME hop the button runs, so a click
|
||||
// over there lands directly in the provider flow, no second press and no
|
||||
// bounce through this login page.
|
||||
if (autoStart && !autoStarted.current) {
|
||||
autoStarted.current = true
|
||||
const target = matchProviderHint(Object.values(providers), autoStart)
|
||||
if (target) {
|
||||
onAutoStartResolved?.(true)
|
||||
hop(target)
|
||||
} else {
|
||||
// Hint names a provider this app doesn't offer → let the caller show
|
||||
// the form rather than dead-end on a blank "signing you in".
|
||||
onAutoStartResolved?.(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setResolved({ application: '', providers: {} })
|
||||
if (cancelled) return
|
||||
setResolved({ providers: {} })
|
||||
onAutoStartResolved?.(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
// Run once on mount: getAppLogin is a one-shot and autoStart is fixed for
|
||||
// the life of the page; the ref guards the hop against a double-fire.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [client, clientIdOverride, intent])
|
||||
|
||||
// In autoStart mode the component is headless — it exists only to run the hop
|
||||
// above; the caller renders its own "signing you in" state. Render nothing.
|
||||
if (autoStart) return null
|
||||
if (resolved === null) return null // resolving — render nothing rather than flicker
|
||||
const ordered = ORDER.filter((k) => k in resolved.providers)
|
||||
const ordered = PROVIDER_ORDER.filter((k) => k in resolved.providers)
|
||||
if (ordered.length === 0) return null
|
||||
|
||||
const verb = intent === 'signup' ? 'Sign up' : 'Continue'
|
||||
|
||||
function startOAuth(provider: AppProvider) {
|
||||
setError(null)
|
||||
// Persist the downstream target across the IAM round-trip; `Callback`
|
||||
// reads it back and forwards tokens there (else lands on onboarding).
|
||||
if (postLoginRedirect) sessionStorage.setItem('post_login_redirect', postLoginRedirect)
|
||||
else sessionStorage.removeItem('post_login_redirect')
|
||||
const method = intent === 'signup' ? 'signup' : 'signin'
|
||||
startProviderLogin(
|
||||
{
|
||||
application: resolved!.application,
|
||||
providerName: provider.name,
|
||||
type: provider.type,
|
||||
clientId: provider.clientId,
|
||||
scopes: provider.scopes,
|
||||
method,
|
||||
},
|
||||
// The shared OAuth client is registered against the IAM backend's
|
||||
// /callback (not this brand host), so the hop must return there or the
|
||||
// provider rejects the redirect_uri. Catalog-driven; defaults to host.
|
||||
client.tenant.oauthCallbackOrigin,
|
||||
)
|
||||
hop(provider)
|
||||
}
|
||||
|
||||
async function startWallet(chain: Chain) {
|
||||
@@ -164,6 +247,16 @@ export function SocialButtons({
|
||||
}
|
||||
}
|
||||
|
||||
// The chain-agnostic entry: auto-detect the injected wallet and connect
|
||||
// straight when exactly one chain is available; otherwise reveal the chooser
|
||||
// so the user picks EVM or Solana. Both underlying flows stay reachable.
|
||||
function onConnectWallet() {
|
||||
setError(null)
|
||||
const detected = detectWalletChains()
|
||||
if (detected.length === 1) startWallet(detected[0]!)
|
||||
else setWalletMenu(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="hanzo-id-social">
|
||||
@@ -172,31 +265,56 @@ export function SocialButtons({
|
||||
// Web3 expands into one connect button per ENABLED chain; OAuth
|
||||
// providers render a single hop button.
|
||||
if (k === 'web3') {
|
||||
return ENABLED_WALLET_CHAINS.map((chain) => (
|
||||
<button
|
||||
key={`web3-${chain}`}
|
||||
type="button"
|
||||
className="hanzo-id-social-btn"
|
||||
data-provider="web3"
|
||||
data-chain={chain}
|
||||
disabled={busyChain !== null}
|
||||
onClick={() => startWallet(chain)}
|
||||
>
|
||||
<WalletIcon />
|
||||
<span>
|
||||
{busyChain === chain ? 'Connecting…' : `${verb} with ${WALLET_CHAIN_LABELS[chain]}`}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
// ONE chain-agnostic entry. It connects straight when a single
|
||||
// wallet is detected, else expands into the chooser below — so the
|
||||
// page always shows exactly one "Connect Wallet" button, with both
|
||||
// EVM and Solana reachable from it.
|
||||
return (
|
||||
<Fragment key="web3">
|
||||
<button
|
||||
type="button"
|
||||
className="hanzo-id-btn ghost"
|
||||
data-provider="web3"
|
||||
data-wallet-connect="true"
|
||||
aria-expanded={walletMenu}
|
||||
disabled={busyChain !== null}
|
||||
onClick={onConnectWallet}
|
||||
>
|
||||
<WalletIcon />
|
||||
<span>{busyChain !== null && !walletMenu ? 'Connecting…' : 'Connect Wallet'}</span>
|
||||
</button>
|
||||
{walletMenu ? (
|
||||
<div
|
||||
className="hanzo-id-wallet-chains"
|
||||
role="group"
|
||||
aria-label="Choose a wallet network"
|
||||
>
|
||||
{ENABLED_WALLET_CHAINS.map((chain) => (
|
||||
<button
|
||||
key={`web3-${chain}`}
|
||||
type="button"
|
||||
className="hanzo-id-btn ghost"
|
||||
data-provider="web3"
|
||||
data-chain={chain}
|
||||
disabled={busyChain !== null}
|
||||
onClick={() => startWallet(chain)}
|
||||
>
|
||||
<WalletIcon />
|
||||
<span>{busyChain === chain ? 'Connecting…' : WALLET_CHAIN_LABELS[chain]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
if (!isHoppableProvider(provider.type)) return null
|
||||
const meta = PROVIDER_META[k]!
|
||||
const { Icon } = meta
|
||||
return (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
className="hanzo-id-social-btn"
|
||||
className="hanzo-id-btn ghost"
|
||||
data-provider={k}
|
||||
onClick={() => startOAuth(provider)}
|
||||
>
|
||||
|
||||
@@ -32,6 +32,14 @@ export function GoogleIcon(props: SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function GitLabIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...base(props)} fill="currentColor">
|
||||
<path d="M23.955 13.587l-1.342-4.135-2.664-8.189a.455.455 0 0 0-.867 0L16.418 9.45H7.582L4.919 1.263a.455.455 0 0 0-.867 0L1.388 9.452-.001 13.587a.924.924 0 0 0 .331 1.023L12 23.054l11.625-8.443a.92.92 0 0 0 .33-1.024" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function WalletIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...base(props)} fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
@@ -41,3 +49,29 @@ export function WalletIcon(props: SVGProps<SVGSVGElement>) {
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Password reveal, the only pair here that is a STATE rather than a brand: eye
|
||||
* = currently masked (tap to reveal), struck-through eye = currently visible
|
||||
* (tap to hide). Stroked rather than filled, because at 20px a filled eye reads
|
||||
* as a blob; `base` supplies the 18px default, and PasswordField overrides to 20
|
||||
* so the glyph holds its own inside a 44px target.
|
||||
*/
|
||||
export function EyeIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...base(props)} fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M2 12s3.5-6.5 10-6.5S22 12 22 12s-3.5 6.5-10 6.5S2 12 2 12Z" />
|
||||
<circle cx="12" cy="12" r="2.75" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function EyeOffIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...base(props)} fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M10.6 6.1A9.9 9.9 0 0 1 12 5.5c6.5 0 10 6.5 10 6.5a17 17 0 0 1-3.1 3.9M6.4 7.9A17 17 0 0 0 2 12s3.5 6.5 10 6.5a9.9 9.9 0 0 0 3.6-.65" />
|
||||
<path d="M10.1 10.1a2.75 2.75 0 0 0 3.8 3.8" />
|
||||
<path d="m3 3 18 18" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ export { LoginForm } from './LoginForm'
|
||||
export { SignupForm } from './SignupForm'
|
||||
export { ForgotForm } from './ForgotForm'
|
||||
export { OTPForm } from './OTPForm'
|
||||
export { MfaEnrollForm, type MfaEnrollFormProps } from './MfaEnrollForm'
|
||||
export { SmsConsentNotice, SMS_CONSENT_TEXT } from './SmsConsent'
|
||||
export { SocialButtons, type SocialButtonsProps } from './SocialButtons'
|
||||
export { Divider } from './Divider'
|
||||
export { PasswordField, type PasswordFieldProps } from './PasswordField'
|
||||
|
||||
@@ -7,19 +7,20 @@
|
||||
* fake signer (the injectable `WalletSigner` seam — the real one lazy-loads the
|
||||
* wallet libs, which this test never touches).
|
||||
*/
|
||||
import { test } from 'node:test'
|
||||
import { test } from 'vitest'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createAuthClient } from './client.ts'
|
||||
import {
|
||||
loginWithWalletChain,
|
||||
detectWalletChains,
|
||||
ENABLED_WALLET_CHAINS,
|
||||
WALLET_CHAIN_LABELS,
|
||||
type WalletSigner,
|
||||
} from './web3.ts'
|
||||
import type { Chain, LoginChallenge, SignedProof } from '@hanzo/id-connect'
|
||||
import type { TenantConfig } from '@hanzo/id-shared'
|
||||
import type { OrgConfig } from '@hanzo/id-shared'
|
||||
|
||||
function tenant(overrides: Partial<TenantConfig> = {}): TenantConfig {
|
||||
function org(overrides: Partial<OrgConfig> = {}): OrgConfig {
|
||||
return {
|
||||
orgId: 'hanzo',
|
||||
iamUrl: 'https://hanzo.id',
|
||||
@@ -90,7 +91,7 @@ function fakeSigner() {
|
||||
test('fetches the nonce for the chosen chain, signs the returned challenge, POSTs the proof', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
const { seen, sign } = fakeSigner()
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
|
||||
const res = await loginWithWalletChain(client, 'evm', {}, fetchImpl, sign)
|
||||
|
||||
@@ -132,7 +133,7 @@ test('fetches the nonce for the chosen chain, signs the returned challenge, POST
|
||||
test('SSO flow (downstream redirectUri) sends type=code and returns the app redirect with the minted code', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch('CODE_XYZ')
|
||||
const { sign } = fakeSigner()
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
|
||||
const res = await loginWithWalletChain(
|
||||
client,
|
||||
@@ -170,7 +171,7 @@ test('disabled chains are not offered and fail closed without any network or sig
|
||||
signed = true
|
||||
throw new Error('signer must not run for a disabled chain')
|
||||
}
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const res = await loginWithWalletChain(client, 'ton', {}, fetchImpl, sign)
|
||||
assert.match(res.error ?? '', /not enabled/)
|
||||
assert.equal(calls.length, 0)
|
||||
@@ -182,7 +183,7 @@ test('a wallet rejection surfaces as { error } (not a throw), and verify is neve
|
||||
const sign: WalletSigner = async () => {
|
||||
throw new Error('User rejected the request')
|
||||
}
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const res = await loginWithWalletChain(client, 'evm', {}, fetchImpl, sign)
|
||||
assert.equal(res.error, 'User rejected the request')
|
||||
// nonce was fetched (1 call) but verify was NOT (no 2nd call).
|
||||
@@ -190,6 +191,31 @@ test('a wallet rejection surfaces as { error } (not a throw), and verify is neve
|
||||
assert.match(calls[0]!.url, /\/v1\/iam\/web3\/nonce/)
|
||||
})
|
||||
|
||||
test('detectWalletChains: a single injected wallet resolves to exactly its chain', () => {
|
||||
// EVM only → [evm]; the UI connects straight, no chooser.
|
||||
assert.deepEqual(detectWalletChains({ ethereum: {} }), ['evm'])
|
||||
// Solana only, via any of the recognized injected providers → [solana].
|
||||
assert.deepEqual(detectWalletChains({ solana: {} }), ['solana'])
|
||||
assert.deepEqual(detectWalletChains({ solflare: {} }), ['solana'])
|
||||
assert.deepEqual(detectWalletChains({ backpack: {} }), ['solana'])
|
||||
})
|
||||
|
||||
test('detectWalletChains: both injected → both, in enabled order (chooser)', () => {
|
||||
assert.deepEqual(detectWalletChains({ ethereum: {}, solana: {} }), ['evm', 'solana'])
|
||||
})
|
||||
|
||||
test('detectWalletChains: nothing injected → [] (chooser, both still reachable)', () => {
|
||||
// No window (server / node) and an empty window both resolve to none — the UI
|
||||
// then reveals the chooser so EVM and Solana stay selectable regardless.
|
||||
assert.deepEqual(detectWalletChains({}), [])
|
||||
assert.deepEqual(detectWalletChains(undefined), [])
|
||||
assert.deepEqual(detectWalletChains(), []) // node has no global window
|
||||
// Every detectable chain is one the wallet flow actually enables.
|
||||
for (const c of detectWalletChains({ ethereum: {}, solana: {} })) {
|
||||
assert.ok(ENABLED_WALLET_CHAINS.includes(c))
|
||||
}
|
||||
})
|
||||
|
||||
test('an IAM verify error is returned as { error }', async () => {
|
||||
const calls: string[] = []
|
||||
const fetchImpl: typeof fetch = async (input) => {
|
||||
@@ -201,7 +227,7 @@ test('an IAM verify error is returned as { error }', async () => {
|
||||
return new Response(JSON.stringify({ status: 'error', msg: 'web3: bad signature' }), { status: 200 })
|
||||
}
|
||||
const { sign } = fakeSigner()
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const res = await loginWithWalletChain(client, 'evm', {}, fetchImpl, sign)
|
||||
assert.equal(res.error, 'web3: bad signature')
|
||||
assert.equal(res.redirectUrl, undefined)
|
||||
|
||||
+50
-11
@@ -17,7 +17,7 @@
|
||||
* POST {iamUrl}/v1/iam/web3/verify body = SignedProof + routing fields
|
||||
* → same success shape as /v1/iam/login (auth code | session cookie).
|
||||
*/
|
||||
import type { TenantConfig } from '@hanzo/id-shared'
|
||||
import type { OrgConfig } from '@hanzo/id-shared'
|
||||
import type { Chain, LoginChallenge, SignedProof } from '@hanzo/id-connect'
|
||||
import type { AuthClient } from './client'
|
||||
import type { LoginResponse } from './types'
|
||||
@@ -55,9 +55,48 @@ export const WALLET_CHAIN_LABELS: Record<Chain, string> = {
|
||||
xrp: 'XRP',
|
||||
}
|
||||
|
||||
/** The `window` fields the injected-wallet sniff reads — kept local so the
|
||||
* wallet libs stay out of this module (detection is a pure property read). */
|
||||
export interface WalletWindow {
|
||||
readonly ethereum?: unknown
|
||||
readonly solana?: unknown
|
||||
readonly solflare?: unknown
|
||||
readonly backpack?: unknown
|
||||
}
|
||||
|
||||
/** Is an injected wallet for `chain` present on `w`? Mirrors the connectors'
|
||||
* own discovery: EVM = `window.ethereum` (EIP-1193 / EIP-6963 legacy handle),
|
||||
* Solana = Phantom/Solflare/Backpack injected providers. */
|
||||
function chainInjected(chain: Chain, w: WalletWindow): boolean {
|
||||
switch (chain) {
|
||||
case 'evm':
|
||||
return Boolean(w.ethereum)
|
||||
case 'solana':
|
||||
return Boolean(w.solana || w.solflare || w.backpack)
|
||||
default:
|
||||
// A chain with no sniff is never auto-detected; the chooser still offers
|
||||
// it. Only the ENABLED set is ever consulted, so this stays unreachable.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The ENABLED wallet chains that currently have an injected provider. A pure
|
||||
* `window` sniff — no connect, no I/O — that powers the chain-agnostic "Connect
|
||||
* Wallet" entry: exactly one match → connect straight; zero or many → let the
|
||||
* user pick. Derived from {@link ENABLED_WALLET_CHAINS} so there is ONE source
|
||||
* of truth for what wallet login offers.
|
||||
*/
|
||||
export function detectWalletChains(
|
||||
w: WalletWindow | undefined = typeof window === 'undefined' ? undefined : (window as WalletWindow),
|
||||
): Chain[] {
|
||||
if (!w) return []
|
||||
return ENABLED_WALLET_CHAINS.filter((c) => chainInjected(c, w))
|
||||
}
|
||||
|
||||
/** Routing context for the verify POST — exactly what the password flow carries. */
|
||||
export interface WalletLoginContext {
|
||||
/** Override the OAuth client_id (downstream app); defaults to tenant.clientId. */
|
||||
/** Override the OAuth client_id (downstream app); defaults to org.clientId. */
|
||||
readonly clientId?: string
|
||||
/** Downstream app `redirect_uri`; presence flips the flow to the auth-code (SSO) path. */
|
||||
readonly redirectUri?: string
|
||||
@@ -75,7 +114,7 @@ export interface WalletLoginContext {
|
||||
* the UI can't act on; expected failures (user rejects, bad signature) come back
|
||||
* as `{ error }`.
|
||||
*
|
||||
* `client.tenant.iamUrl` is the fetch base (HIP-0111 host-relative — the brand's
|
||||
* `client.org.iamUrl` is the fetch base (HIP-0111 host-relative — the brand's
|
||||
* own `*.id` host), matching every other AuthClient call.
|
||||
*/
|
||||
export async function loginWithWalletChain(
|
||||
@@ -85,7 +124,7 @@ export async function loginWithWalletChain(
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
sign: WalletSigner = defaultSigner,
|
||||
): Promise<LoginResponse> {
|
||||
const tenant = client.tenant
|
||||
const org = client.org
|
||||
if (!ENABLED_WALLET_CHAINS.includes(chain)) {
|
||||
return { error: `wallet login not enabled for ${chain}` }
|
||||
}
|
||||
@@ -96,7 +135,7 @@ export async function loginWithWalletChain(
|
||||
// from the SIGNED message, so there is no second round-trip to scope it.
|
||||
let proof: SignedProof
|
||||
try {
|
||||
const challenge = await fetchNonce(tenant, chain, fetchImpl)
|
||||
const challenge = await fetchNonce(org, chain, fetchImpl)
|
||||
proof = await sign(chain, challenge)
|
||||
} catch (err) {
|
||||
return { error: errMessage(err) }
|
||||
@@ -104,17 +143,17 @@ export async function loginWithWalletChain(
|
||||
|
||||
// 2. Verify the proof + routing at IAM. Type defaults to "login" (session
|
||||
// cookie) server-side; a downstream redirectUri makes it the code flow.
|
||||
const url = new URL('/v1/iam/web3/verify', tenant.iamUrl)
|
||||
const url = new URL('/v1/iam/web3/verify', org.iamUrl)
|
||||
const res = await fetchImpl(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
// routing
|
||||
organization: tenant.loginOrg ?? '',
|
||||
application: tenant.appName,
|
||||
organization: org.loginOrg ?? '',
|
||||
application: org.appName,
|
||||
method: 'login',
|
||||
clientId: ctx.clientId ?? tenant.clientId,
|
||||
clientId: ctx.clientId ?? org.clientId,
|
||||
redirectUri: ctx.redirectUri ?? '',
|
||||
state: ctx.state ?? '',
|
||||
scope: 'openid profile email',
|
||||
@@ -138,11 +177,11 @@ export async function loginWithWalletChain(
|
||||
|
||||
/** GET the CAIP-122 challenge for (chain) from IAM; throws on a non-ok payload. */
|
||||
async function fetchNonce(
|
||||
tenant: TenantConfig,
|
||||
org: OrgConfig,
|
||||
chain: Chain,
|
||||
fetchImpl: typeof fetch,
|
||||
): Promise<LoginChallenge> {
|
||||
const url = new URL('/v1/iam/web3/nonce', tenant.iamUrl)
|
||||
const url = new URL('/v1/iam/web3/nonce', org.iamUrl)
|
||||
url.searchParams.set('chain', chain)
|
||||
const res = await fetchImpl(url.toString(), { headers: { Accept: 'application/json' } })
|
||||
let body: Record<string, unknown> = {}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hanzo/id-idv",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"description": "Pluggable identity verification (KYC / KYB / liveness). Provider-agnostic — wires Persona, Onfido, Veriff, Sumsub, or any custom backend behind a single React surface.",
|
||||
"license": "BSD-3-Clause",
|
||||
"type": "module",
|
||||
|
||||
@@ -30,7 +30,7 @@ export type IDVStatus =
|
||||
export interface IDVSubject {
|
||||
/** Stable subject identifier (typically the IAM user id). */
|
||||
readonly subjectId: string
|
||||
/** Tenant org slug (for multi-tenant providers). */
|
||||
/** Org org slug (for multi-org providers). */
|
||||
readonly orgId: string
|
||||
/** Email + display name carried through for vendor pre-fill. */
|
||||
readonly email?: string
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hanzo/id-onboarding",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.5",
|
||||
"description": "Post-login onboarding for the Hanzo ID portal: choose/create org → optional project → optional wallet link. White-labeled by host. Domain / service / UI split.",
|
||||
"license": "BSD-3-Clause",
|
||||
"type": "module",
|
||||
@@ -15,12 +15,11 @@
|
||||
"files": ["src", "!src/**/*.test.ts"],
|
||||
"scripts": {
|
||||
"build": "tsc --noEmit",
|
||||
"tc": "tsc --noEmit",
|
||||
"test": "node --test --experimental-strip-types 'src/**/*.test.ts'"
|
||||
"tc": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hanzo/id-shared": "workspace:*",
|
||||
"@hanzo/iam": "^0.13.1"
|
||||
"@hanzo/iam": "^0.21.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=19",
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
*/
|
||||
|
||||
/** Identifier for each step in the onboarding flow. */
|
||||
export type StepId = 'org' | 'project' | 'wallet' | 'done'
|
||||
export type StepId = 'org' | 'project' | 'wallet' | 'consent' | 'plan' | 'done'
|
||||
|
||||
/** A step's place in the linear flow. */
|
||||
export interface StepDesc {
|
||||
@@ -53,11 +53,29 @@ export const STEPS: readonly StepDesc[] = [
|
||||
byline: 'Connect a Web3 wallet to sign and pay onchain. Optional.',
|
||||
skippable: true,
|
||||
},
|
||||
{
|
||||
id: 'consent',
|
||||
title: 'Data sharing',
|
||||
byline: 'Choose whether to share usage data to improve the products.',
|
||||
// Not skippable: the agreement needs an explicit ANSWER (yes or no, both
|
||||
// valid), recorded once on the user so it is never re-asked. Skipping is
|
||||
// how this page kept going missing.
|
||||
skippable: false,
|
||||
},
|
||||
{
|
||||
id: 'plan',
|
||||
title: 'Choose how you pay',
|
||||
byline: 'Pick a plan, or pay as you go with a prepaid balance.',
|
||||
// The LAST page, and a required choice: the platform is prepay-only, so an
|
||||
// account is not usable until a plan or a balance exists. "Pay as you go"
|
||||
// IS a choice — there is nothing to skip to.
|
||||
skippable: false,
|
||||
},
|
||||
] as const
|
||||
|
||||
/** A minimal org reference the UI lists in the "choose org" step. */
|
||||
export interface OrgRef {
|
||||
/** Casdoor org slug (the `<org>` in `<org>-<app>`). */
|
||||
/** IAM org slug (the `<org>` in `<org>-<app>`). */
|
||||
readonly name: string
|
||||
/** Human-facing name; falls back to `name` when unset. */
|
||||
readonly displayName: string
|
||||
@@ -85,8 +103,43 @@ export interface OnboardingState {
|
||||
readonly projectName?: string
|
||||
/** Wallet address linked in step 3, if any. */
|
||||
readonly walletAddress?: string
|
||||
/** The data-sharing answer given in step 4 (true = opted in). */
|
||||
readonly dataSharingConsent?: boolean
|
||||
/**
|
||||
* The payment choice made on the final step: a plan slug from the billing
|
||||
* catalog, or the literal 'payg' for a prepaid pay-as-you-go balance.
|
||||
*/
|
||||
readonly planChoice?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* One purchasable plan as the billing catalog serves it (GET /v1/billing/plans
|
||||
* on the pay origin). Prices are the CATALOG's — this pkg never states one.
|
||||
*/
|
||||
export interface PlanInfo {
|
||||
readonly slug: string
|
||||
readonly name: string
|
||||
readonly description?: string
|
||||
/** Monthly price in CENTS (the catalog's `price` — 900 = $9/mo). */
|
||||
readonly priceCents: number
|
||||
/**
|
||||
* Monthly-equivalent price in CENTS when billed annually (the catalog's
|
||||
* `priceAnnual` — 825 = $8.25/mo ≈ $99/yr). Absent when the plan has no
|
||||
* annual rate.
|
||||
*/
|
||||
readonly priceAnnualCents?: number
|
||||
readonly popular?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* User-record keys the onboarding persists under `Properties`. ONE writer
|
||||
* (saveOnboarding) and one reader (readOnboarding); the names are part of the
|
||||
* user record's public shape, so change them never.
|
||||
*/
|
||||
export const PROP_COMPLETED = 'onboarding.completedAt'
|
||||
export const PROP_CONSENT = 'onboarding.dataSharingConsent'
|
||||
export const PROP_PLAN = 'onboarding.plan'
|
||||
|
||||
/** Resolve a step descriptor by id. */
|
||||
export function stepById(id: StepId): StepDesc | undefined {
|
||||
return STEPS.find((s) => s.id === id)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// @hanzo/id-onboarding — post-login onboarding for the Hanzo ID portal.
|
||||
//
|
||||
// Three-step flow: choose/create org → optional project → optional wallet
|
||||
// link. White-labeled by the host's brand name. Domain (serializable types +
|
||||
// step machine) / service (IAM-backed writes) / UI (self-contained flow)
|
||||
// split. Auth lives in @hanzo/id-auth — import login/signup from there.
|
||||
// Five-step flow: choose/create org → optional project → optional wallet
|
||||
// link → data-sharing consent → plan or pay-as-you-go. White-labeled by the
|
||||
// host's brand name. Domain (serializable types + step machine) / service
|
||||
// (IAM-backed writes) / UI (self-contained flow) split. Auth lives in
|
||||
// @hanzo/id-auth — import login/signup from there.
|
||||
|
||||
// ── Domain ──────────────────────────────────────────────────────
|
||||
export {
|
||||
@@ -16,6 +17,7 @@ export {
|
||||
type OrgRef,
|
||||
type ProjectRef,
|
||||
type OnboardingState,
|
||||
type PlanInfo,
|
||||
} from './domain/types'
|
||||
|
||||
// ── Service ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -7,18 +7,20 @@
|
||||
* Covers the React-free surface: the domain step machine and the service's
|
||||
* request shaping + IAM response translation (with an injected fake fetch).
|
||||
*/
|
||||
import { test } from 'node:test'
|
||||
import { test } from 'vitest'
|
||||
import assert from 'node:assert/strict'
|
||||
import { STEPS, stepById, nextStep, prevStep } from './domain/types.ts'
|
||||
import { createOnboardingService } from './service/onboarding.ts'
|
||||
|
||||
// ── Domain: step machine ────────────────────────────────────────────
|
||||
|
||||
test('step machine walks org → project → wallet → done', () => {
|
||||
test('step machine walks org → project → wallet → consent → plan → done', () => {
|
||||
assert.equal(STEPS[0]!.id, 'org')
|
||||
assert.equal(nextStep('org'), 'project')
|
||||
assert.equal(nextStep('project'), 'wallet')
|
||||
assert.equal(nextStep('wallet'), 'done')
|
||||
assert.equal(nextStep('wallet'), 'consent')
|
||||
assert.equal(nextStep('consent'), 'plan')
|
||||
assert.equal(nextStep('plan'), 'done')
|
||||
assert.equal(nextStep('done'), 'done') // terminal is a fixpoint
|
||||
})
|
||||
|
||||
@@ -26,12 +28,20 @@ test('prevStep is the inverse within the flow, undefined at the head', () => {
|
||||
assert.equal(prevStep('org'), undefined)
|
||||
assert.equal(prevStep('project'), 'org')
|
||||
assert.equal(prevStep('wallet'), 'project')
|
||||
assert.equal(prevStep('consent'), 'wallet')
|
||||
assert.equal(prevStep('plan'), 'consent')
|
||||
})
|
||||
|
||||
test('only org is required; project and wallet are skippable', () => {
|
||||
test('project and wallet are skippable; org, consent and plan are not', () => {
|
||||
assert.equal(stepById('org')!.skippable, false)
|
||||
assert.equal(stepById('project')!.skippable, true)
|
||||
assert.equal(stepById('wallet')!.skippable, true)
|
||||
// Consent needs an ANSWER (either answer) and plan is the prepay gate —
|
||||
// neither may be walked past. plan is LAST so the choice hands straight
|
||||
// off to the pay surface.
|
||||
assert.equal(stepById('consent')!.skippable, false)
|
||||
assert.equal(stepById('plan')!.skippable, false)
|
||||
assert.equal(STEPS[STEPS.length - 1]!.id, 'plan')
|
||||
})
|
||||
|
||||
// ── Service: fake-fetch harness ─────────────────────────────────────
|
||||
@@ -86,24 +96,51 @@ test('listOrgs hits get-organizations with the bearer token and maps rows', asyn
|
||||
])
|
||||
})
|
||||
|
||||
test('listOrgs decodes rows from the legacy data2 slot until IAM stops emitting it', async () => {
|
||||
const { service } = harness(() => ({ json: { status: 'ok', data2: [{ name: 'acme' }] } }))
|
||||
assert.deepEqual(await service.listOrgs(), [{ name: 'acme', displayName: 'acme' }])
|
||||
})
|
||||
|
||||
test('listOrgs returns [] (not throw) on a server error', async () => {
|
||||
const { service } = harness(() => ({ status: 500, json: { status: 'error', msg: 'boom' } }))
|
||||
assert.deepEqual(await service.listOrgs(), [])
|
||||
})
|
||||
|
||||
test('createOrg posts the org and reports IAM error messages', async () => {
|
||||
const ok = harness(() => ({ json: { status: 'ok' } }))
|
||||
// Founding an org goes through the SELF-SERVICE front door, never the
|
||||
// add-organization admin verb — that one is bearer-only entity CRUD filed under
|
||||
// owner "admin", so a person founding their first org gets 401/403 there. This is
|
||||
// the regression guard for the hanzo.id/onboarding "HTTP 401".
|
||||
test('createOrg founds the org through /v1/iam/onboard, never the admin verb', async () => {
|
||||
const ok = harness(() => ({ json: { org: 'acme', accessKey: 'pk-live-x' } }))
|
||||
const res = await ok.service.createOrg({ name: 'acme', displayName: 'Acme Inc' })
|
||||
assert.equal(ok.calls[0]!.url, 'https://hanzo.id/v1/iam/add-organization')
|
||||
assert.equal(ok.calls[0]!.url, 'https://hanzo.id/v1/iam/onboard')
|
||||
assert.equal(ok.calls[0]!.method, 'POST')
|
||||
const sent = JSON.parse(ok.calls[0]!.body!)
|
||||
assert.equal(sent.name, 'acme')
|
||||
assert.equal(sent.displayName, 'Acme Inc')
|
||||
assert.ok(!ok.calls.some((c) => c.url.includes('add-organization')))
|
||||
// The DISPLAY name is what travels: the server owns the slug policy.
|
||||
assert.deepEqual(JSON.parse(ok.calls[0]!.body!), { name: 'Acme Inc' })
|
||||
// …and the slug it answers with is authoritative, not the client's guess.
|
||||
assert.deepEqual(res, { ok: true, value: { name: 'acme', displayName: 'Acme Inc' } })
|
||||
})
|
||||
|
||||
const denied = harness(() => ({ status: 403, json: { status: 'error', msg: 'permission denied' } }))
|
||||
const fail = await denied.service.createOrg({ name: 'x', displayName: 'X' })
|
||||
assert.deepEqual(fail, { ok: false, error: 'HTTP 403' })
|
||||
test('createOrg carries BOTH credentials — the portal session mints no bearer', async () => {
|
||||
const { service, calls } = harness(() => ({ json: { org: 'acme' } }))
|
||||
await service.createOrg({ name: 'acme', displayName: 'Acme Inc' })
|
||||
assert.equal(calls[0]!.headers.Authorization, 'Bearer tok-123')
|
||||
assert.equal(calls[0]!.headers['Content-Type'], 'application/json')
|
||||
})
|
||||
|
||||
test('createOrg surfaces the front door’s own error text, not a bare HTTP code', async () => {
|
||||
const taken = harness(() => ({ status: 409, json: { error: 'the organization "acme" already exists' } }))
|
||||
assert.deepEqual(await taken.service.createOrg({ name: 'acme', displayName: 'Acme' }), {
|
||||
ok: false,
|
||||
error: 'the organization "acme" already exists',
|
||||
})
|
||||
|
||||
const anon = harness(() => ({ status: 401, json: { error: 'please sign in first' } }))
|
||||
assert.deepEqual(await anon.service.createOrg({ name: 'x', displayName: 'X' }), {
|
||||
ok: false,
|
||||
error: 'please sign in first',
|
||||
})
|
||||
})
|
||||
|
||||
test('linkWallet rejects a malformed address before any network call', async () => {
|
||||
@@ -113,24 +150,96 @@ test('linkWallet rejects a malformed address before any network call', async ()
|
||||
assert.equal(calls.length, 0)
|
||||
})
|
||||
|
||||
test('linkWallet resolves the user via get-account then writes web3onboard', async () => {
|
||||
// IAM's update-user is a FULL-ROW write that ignores `columns=` — a minimal
|
||||
// body silently blanks every field it omits. So the contract under test is
|
||||
// read-merge-write: the row that comes back from get-account goes back OUT
|
||||
// with only the mutation applied. This is the regression guard for the wallet
|
||||
// step wiping displayName/email on every link.
|
||||
test('linkWallet reads the full row and writes it back with web3onboard merged in', async () => {
|
||||
const addr = '0x' + 'a'.repeat(40)
|
||||
const { service, calls } = harness((rec) => {
|
||||
if (rec.url.includes('get-account')) return { json: { status: 'ok', data: { owner: 'hanzo', name: 'alice' } } }
|
||||
if (rec.url.includes('get-account'))
|
||||
return {
|
||||
json: {
|
||||
status: 'ok',
|
||||
data: { owner: 'hanzo', name: 'alice', displayName: 'Alice', email: 'alice@hanzo.ai' },
|
||||
},
|
||||
}
|
||||
return { json: { status: 'ok' } }
|
||||
})
|
||||
const res = await service.linkWallet(addr)
|
||||
assert.deepEqual(res, { ok: true, value: addr })
|
||||
// 1) get-account, 2) update-user keyed by owner/name, column-scoped
|
||||
// 1) get-account, 2) update-user keyed by owner/name with the WHOLE row
|
||||
assert.match(calls[0]!.url, /get-account$/)
|
||||
const upd = calls[1]!
|
||||
assert.ok(upd.url.includes('/v1/iam/update-user'))
|
||||
assert.ok(upd.url.includes('id=hanzo%2Falice') || upd.url.includes('id=hanzo/alice'))
|
||||
assert.ok(upd.url.includes('columns=web3onboard'))
|
||||
const sent = JSON.parse(upd.body!)
|
||||
assert.equal(sent.web3onboard, addr)
|
||||
assert.equal(sent.owner, 'hanzo')
|
||||
assert.equal(sent.name, 'alice')
|
||||
// The fields the mutation did not touch MUST survive the round trip.
|
||||
assert.equal(sent.displayName, 'Alice')
|
||||
assert.equal(sent.email, 'alice@hanzo.ai')
|
||||
})
|
||||
|
||||
test('saveOnboarding merges properties without dropping existing ones; readOnboarding decodes them', async () => {
|
||||
const { service, calls } = harness((rec) => {
|
||||
if (rec.url.includes('get-account'))
|
||||
return {
|
||||
json: {
|
||||
status: 'ok',
|
||||
data: {
|
||||
owner: 'hanzo',
|
||||
name: 'alice',
|
||||
properties: { 'onboarding.dataSharingConsent': 'true', unrelated: 'kept' },
|
||||
},
|
||||
},
|
||||
}
|
||||
return { json: { status: 'ok' } }
|
||||
})
|
||||
const res = await service.saveOnboarding({ plan: 'pro', completedAt: '2026-08-04T00:00:00Z' })
|
||||
assert.deepEqual(res, { ok: true, value: true })
|
||||
const sent = JSON.parse(calls[1]!.body!)
|
||||
assert.deepEqual(sent.properties, {
|
||||
'onboarding.dataSharingConsent': 'true',
|
||||
unrelated: 'kept',
|
||||
'onboarding.plan': 'pro',
|
||||
'onboarding.completedAt': '2026-08-04T00:00:00Z',
|
||||
})
|
||||
})
|
||||
|
||||
test('readOnboarding reports null completedAt/consent/plan for a fresh user', async () => {
|
||||
const { service } = harness(() => ({ json: { status: 'ok', data: { owner: 'hanzo', name: 'bob' } } }))
|
||||
assert.deepEqual(await service.readOnboarding(), { completedAt: null, consent: null, plan: null })
|
||||
})
|
||||
|
||||
// The live catalog prices in CENTS (go=900 means $9/mo, priceAnnual=825 means
|
||||
// $8.25/mo billed annually) and carries other product lines (dns-*) in the
|
||||
// same list. This test pins both facts with production-shaped rows.
|
||||
test('listPlans keeps cents unscaled, keeps personal+team only; [] on failure', async () => {
|
||||
const { service, calls } = harness(() => ({
|
||||
json: [
|
||||
{ slug: 'pro', name: 'Pro', category: 'personal', price: 4900, priceAnnual: 4150, popular: true },
|
||||
{ slug: 'go', name: 'Go', category: 'personal', price: 900, priceAnnual: 825 },
|
||||
{ slug: 'team', name: 'Team', category: 'team', price: 2500, priceAnnual: 2000 },
|
||||
{ slug: 'dns-pro', name: 'DNS Pro', category: 'dns', price: 500 }, // other product line → dropped
|
||||
{ slug: 'enterprise', name: 'Enterprise', category: 'enterprise', price: 0 }, // not self-serve → dropped
|
||||
{ slug: '', name: 'broken', category: 'personal', price: 500 }, // no slug → dropped
|
||||
],
|
||||
}))
|
||||
const plans = await service.listPlans('https://pay.hanzo.ai/')
|
||||
assert.equal(calls[0]!.url, 'https://pay.hanzo.ai/v1/billing/plans')
|
||||
assert.deepEqual(
|
||||
plans.map((p) => p.slug),
|
||||
['pro', 'go', 'team'],
|
||||
)
|
||||
assert.equal(plans[0]!.priceCents, 4900)
|
||||
assert.equal(plans[0]!.priceAnnualCents, 4150)
|
||||
assert.equal(plans[0]!.popular, true)
|
||||
|
||||
const down = harness(() => ({ status: 503, json: { error: 'nope' } }))
|
||||
assert.deepEqual(await down.service.listPlans('https://pay.hanzo.ai'), [])
|
||||
})
|
||||
|
||||
test('linkWallet fails closed when there is no signed-in user', async () => {
|
||||
|
||||
@@ -3,20 +3,39 @@
|
||||
* wallet flow.
|
||||
*
|
||||
* One way: every write goes through the canonical IAM REST surface under
|
||||
* `/v1/iam/*` (the same Casdoor-compat paths the auth client uses), carrying
|
||||
* the user's bearer token. There is no separate onboarding backend — the org
|
||||
* and project records live in IAM, which is the identity registry.
|
||||
* `/v1/iam/*` (the same IAM paths the auth client uses). There is no separate
|
||||
* onboarding backend — the org and project records live in IAM, which is the
|
||||
* identity registry.
|
||||
*
|
||||
* listOrgs() GET /v1/iam/get-organizations (user-scoped server-side)
|
||||
* createOrg() POST /v1/iam/add-organization
|
||||
* createOrg() POST /v1/iam/onboard (the self-service front door)
|
||||
* createProject POST /v1/iam/add-project
|
||||
* linkWallet() client-side wallet connect → IAM update-user (host-driven)
|
||||
*
|
||||
* Token is supplied by the host through `getAccessToken` (the portal already
|
||||
* holds the session after login). The service never stores it.
|
||||
* Founding an org goes through `onboard`, NOT the `add-organization` admin verb.
|
||||
* They are different doors: add-organization is entity CRUD behind IAM's
|
||||
* authenticated Guard, filed under owner "admin", and a human may only write an
|
||||
* org row named after the org they are already in — so a person founding their
|
||||
* FIRST org is refused there by construction (403), and with no bearer at all the
|
||||
* Guard refuses before that (401). `onboard` is the door built for this: it
|
||||
* resolves the caller from their own session or bearer and provisions the whole
|
||||
* org — org stamped with them as Founder, them moved in as its owner, one
|
||||
* metered API key — under their own authority as its founder.
|
||||
*
|
||||
* Both credentials are offered on every call: `credentials: 'include'` for the
|
||||
* portal session cookie (a bare portal sign-in mints NO bearer, which is why the
|
||||
* bearer-only door 401'd), and `Authorization` when the host does hold a token.
|
||||
* IAM resolves session first, then bearer.
|
||||
*/
|
||||
import type { Organization, Project } from '@hanzo/iam'
|
||||
import type { OrgRef, ProjectRef } from '../domain/types'
|
||||
import type { Project } from '@hanzo/iam'
|
||||
import {
|
||||
PROP_COMPLETED,
|
||||
PROP_CONSENT,
|
||||
PROP_PLAN,
|
||||
type OrgRef,
|
||||
type PlanInfo,
|
||||
type ProjectRef,
|
||||
} from '../domain/types'
|
||||
|
||||
/** Result of a write that can fail gracefully (no throw on expected errors). */
|
||||
export type Result<T> = { readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: string }
|
||||
@@ -39,10 +58,30 @@ export interface OnboardingService {
|
||||
* resulting address.
|
||||
*/
|
||||
linkWallet(address: string): Promise<Result<string>>
|
||||
/**
|
||||
* Read the persisted onboarding record from the signed-in user's
|
||||
* `properties`. All-null when the user has never completed onboarding —
|
||||
* which is the ONLY case the host should mount the flow for.
|
||||
*/
|
||||
readOnboarding(): Promise<{ completedAt: string | null; consent: boolean | null; plan: string | null }>
|
||||
/**
|
||||
* Persist onboarding fields onto the user record, read-merge-write. THIS is
|
||||
* what stops the flow repeating: completion lives on the USER, not in any
|
||||
* browser storage, so a new device, a cleared cache and a re-login all see
|
||||
* it done.
|
||||
*/
|
||||
saveOnboarding(patch: { completedAt?: string; consent?: boolean; plan?: string }): Promise<Result<true>>
|
||||
/**
|
||||
* List purchasable plans from the billing catalog on the PAY origin. The
|
||||
* catalog is the only price authority — this pkg renders what it serves and
|
||||
* states no price of its own. Returns [] on any failure; the plan step then
|
||||
* offers the two choices without a price grid.
|
||||
*/
|
||||
listPlans(payUrl: string): Promise<PlanInfo[]>
|
||||
}
|
||||
|
||||
export interface OnboardingServiceOptions {
|
||||
/** IAM origin, no trailing slash (the tenant's `iamUrl`, i.e. hanzo.id). */
|
||||
/** IAM origin, no trailing slash (the org's `iamUrl`, i.e. hanzo.id). */
|
||||
readonly iamUrl: string
|
||||
/** Owning org slug used as the default `owner` for new records. */
|
||||
readonly orgId: string
|
||||
@@ -80,16 +119,36 @@ export function createOnboardingService(opts: OnboardingServiceOptions): Onboard
|
||||
return rows.map(toOrgRef).filter((o): o is OrgRef => o !== null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Found the caller's own organization through the self-service front door.
|
||||
*
|
||||
* The server owns the slug: it derives it from the display name under the ONE
|
||||
* policy every surface shares, so the returned `org` is authoritative and the
|
||||
* client's slug preview is only a preview. It answers `{org}` on success and
|
||||
* `{error}` with a 4xx/5xx on failure — not the casibase `{status,msg}`
|
||||
* envelope the entity CRUD returns — so read it directly.
|
||||
*/
|
||||
async function createOrg(input: { name: string; displayName: string }): Promise<Result<OrgRef>> {
|
||||
const url = new URL('/v1/iam/add-organization', base)
|
||||
const org: Partial<Organization> = {
|
||||
owner: 'admin',
|
||||
name: input.name,
|
||||
displayName: input.displayName,
|
||||
isPersonal: false,
|
||||
balanceCurrency: 'USD',
|
||||
const url = new URL('/v1/iam/onboard', base)
|
||||
const displayName = input.displayName || input.name
|
||||
try {
|
||||
const res = await f(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: await authHeaders(),
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ name: displayName }),
|
||||
})
|
||||
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>
|
||||
if (!res.ok) {
|
||||
const msg = typeof body.error === 'string' && body.error ? body.error : `HTTP ${res.status}`
|
||||
return { ok: false, error: msg }
|
||||
}
|
||||
const org = typeof body.org === 'string' ? body.org : ''
|
||||
if (!org) return { ok: false, error: 'request failed' }
|
||||
return { ok: true, value: { name: org, displayName } }
|
||||
} catch (e) {
|
||||
return { ok: false, error: String(e) }
|
||||
}
|
||||
return writeRecord(url, org, () => ({ name: input.name, displayName: input.displayName }))
|
||||
}
|
||||
|
||||
async function createProject(input: {
|
||||
@@ -116,45 +175,114 @@ export function createOnboardingService(opts: OnboardingServiceOptions): Onboard
|
||||
async function linkWallet(address: string): Promise<Result<string>> {
|
||||
const trimmed = address.trim()
|
||||
if (!isHexAddress(trimmed)) return { ok: false, error: 'invalid wallet address' }
|
||||
// Resolve the signed-in user (owner/name) from the session — IAM's
|
||||
// update-user is keyed by `id=<owner>/<name>`, not a "self" alias.
|
||||
const account = await getAccount()
|
||||
if (!account) return { ok: false, error: 'not signed in' }
|
||||
const res = await updateSelf((row) => {
|
||||
row.web3onboard = trimmed
|
||||
})
|
||||
return res.ok ? { ok: true, value: trimmed } : res
|
||||
}
|
||||
|
||||
async function readOnboarding(): Promise<{
|
||||
completedAt: string | null
|
||||
consent: boolean | null
|
||||
plan: string | null
|
||||
}> {
|
||||
const row = await getAccount()
|
||||
const props = (row?.properties ?? {}) as Record<string, unknown>
|
||||
const str = (k: string): string | null => (typeof props[k] === 'string' && props[k] ? (props[k] as string) : null)
|
||||
const consentRaw = str(PROP_CONSENT)
|
||||
return {
|
||||
completedAt: str(PROP_COMPLETED),
|
||||
consent: consentRaw === null ? null : consentRaw === 'true',
|
||||
plan: str(PROP_PLAN),
|
||||
}
|
||||
}
|
||||
|
||||
async function saveOnboarding(patch: {
|
||||
completedAt?: string
|
||||
consent?: boolean
|
||||
plan?: string
|
||||
}): Promise<Result<true>> {
|
||||
const res = await updateSelf((row) => {
|
||||
const props = { ...((row.properties as Record<string, string> | undefined) ?? {}) }
|
||||
if (patch.completedAt !== undefined) props[PROP_COMPLETED] = patch.completedAt
|
||||
if (patch.consent !== undefined) props[PROP_CONSENT] = String(patch.consent)
|
||||
if (patch.plan !== undefined) props[PROP_PLAN] = patch.plan
|
||||
row.properties = props
|
||||
})
|
||||
return res.ok ? { ok: true, value: true } : res
|
||||
}
|
||||
|
||||
async function listPlans(payUrl: string): Promise<PlanInfo[]> {
|
||||
try {
|
||||
const res = await f(trimSlash(payUrl) + '/v1/billing/plans', { headers: { Accept: 'application/json' } })
|
||||
if (!res.ok) return []
|
||||
const body = (await res.json()) as unknown
|
||||
const rows = Array.isArray(body) ? body : []
|
||||
return rows
|
||||
.filter((r): r is Record<string, unknown> => typeof r === 'object' && r !== null)
|
||||
// Onboarding offers the account plans; other product lines in the same
|
||||
// catalog (dns-*, enterprise) have their own surfaces.
|
||||
.filter((r) => r.category === 'personal' || r.category === 'team')
|
||||
.map((r) => ({
|
||||
slug: typeof r.slug === 'string' ? r.slug : '',
|
||||
name: typeof r.name === 'string' ? r.name : '',
|
||||
description: typeof r.description === 'string' ? r.description : undefined,
|
||||
// Catalog prices are CENTS (900 = $9/mo); passed through unscaled.
|
||||
priceCents: typeof r.price === 'number' ? r.price : NaN,
|
||||
priceAnnualCents: typeof r.priceAnnual === 'number' && r.priceAnnual > 0 ? r.priceAnnual : undefined,
|
||||
popular: r.popular === true,
|
||||
}))
|
||||
.filter((p) => p.slug && p.name && Number.isFinite(p.priceCents) && p.priceCents > 0)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-merge-write the signed-in user's FULL row. IAM's update-user is a
|
||||
* FULL-ROW write (internal/users Update: "this is a full-row write") and it
|
||||
* ignores the v1 `columns=` scoping param — so a minimal body silently
|
||||
* blanks every field it omits. The wallet step used to do exactly that,
|
||||
* wiping displayName/email on every link. Every self-write goes through
|
||||
* here now: fetch the row, mutate, post the whole thing back.
|
||||
*/
|
||||
async function updateSelf(mutate: (row: Record<string, unknown>) => void): Promise<Result<true>> {
|
||||
const row = await getAccount()
|
||||
if (!row) return { ok: false, error: 'not signed in' }
|
||||
const owner = typeof row.owner === 'string' ? row.owner : ''
|
||||
const name = typeof row.name === 'string' ? row.name : ''
|
||||
if (!owner || !name) return { ok: false, error: 'not signed in' }
|
||||
mutate(row)
|
||||
row.owner = owner
|
||||
row.name = name
|
||||
const url = new URL('/v1/iam/update-user', base)
|
||||
url.searchParams.set('id', `${account.owner}/${account.name}`)
|
||||
// Scope the write to the single `web3onboard` column so the rest of the
|
||||
// user row is untouched (Casdoor replaces unscoped writes wholesale).
|
||||
url.searchParams.set('columns', 'web3onboard')
|
||||
url.searchParams.set('id', `${owner}/${name}`)
|
||||
try {
|
||||
const res = await f(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: await authHeaders(),
|
||||
credentials: 'include',
|
||||
// Casdoor's User JSON tag is lowercase `web3onboard`; send the full
|
||||
// owner/name so the row identity is unambiguous on the server.
|
||||
body: JSON.stringify({ owner: account.owner, name: account.name, web3onboard: trimmed }),
|
||||
body: JSON.stringify(row),
|
||||
})
|
||||
if (!res.ok) return { ok: false, error: `HTTP ${res.status}` }
|
||||
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>
|
||||
if (body.status === 'error') return { ok: false, error: msgOf(body) }
|
||||
return { ok: true, value: trimmed }
|
||||
return { ok: true, value: true }
|
||||
} catch (e) {
|
||||
return { ok: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the signed-in user's `{owner, name}` from `/v1/iam/get-account`. */
|
||||
async function getAccount(): Promise<{ owner: string; name: string } | null> {
|
||||
/** Read the signed-in user's FULL row from `/v1/iam/get-account`. */
|
||||
async function getAccount(): Promise<Record<string, unknown> | null> {
|
||||
const url = new URL('/v1/iam/get-account', base)
|
||||
try {
|
||||
const res = await f(url.toString(), { headers: await authHeaders(false), credentials: 'include' })
|
||||
if (!res.ok) return null
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
const data = (body.data ?? body) as Record<string, unknown>
|
||||
const owner = typeof data.owner === 'string' ? data.owner : ''
|
||||
const name = typeof data.name === 'string' ? data.name : ''
|
||||
if (!owner || !name) return null
|
||||
return { owner, name }
|
||||
if (typeof data !== 'object' || data === null) return null
|
||||
return data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
@@ -181,10 +309,10 @@ export function createOnboardingService(opts: OnboardingServiceOptions): Onboard
|
||||
}
|
||||
}
|
||||
|
||||
return { listOrgs, createOrg, createProject, linkWallet }
|
||||
return { listOrgs, createOrg, createProject, linkWallet, readOnboarding, saveOnboarding, listPlans }
|
||||
}
|
||||
|
||||
/** Pull the array payload out of an IAM list response (`data` or `data2`). */
|
||||
/** Rows of an IAM list response: the named `data` slot, falling back to the legacy `data2` slot until IAM stops emitting it. */
|
||||
function extractRows(body: Record<string, unknown>): Record<string, unknown>[] {
|
||||
const candidate = Array.isArray(body.data) ? body.data : Array.isArray(body.data2) ? body.data2 : []
|
||||
return candidate.filter((r): r is Record<string, unknown> => typeof r === 'object' && r !== null)
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
prevStep,
|
||||
stepById,
|
||||
type OnboardingState,
|
||||
type PlanInfo,
|
||||
type StepId,
|
||||
} from '../domain/types'
|
||||
import type { OnboardingService } from '../service/onboarding'
|
||||
@@ -24,7 +25,7 @@ import type { OnboardingService } from '../service/onboarding'
|
||||
*/
|
||||
export interface OnboardingFlowProps {
|
||||
readonly service: OnboardingService
|
||||
/** Brand display name for headings (e.g. the resolved tenant brand). */
|
||||
/** Brand display name for headings (e.g. the resolved org brand). */
|
||||
readonly brandName: string
|
||||
/**
|
||||
* Host-supplied wallet connector. Returns the connected address (0x…) or
|
||||
@@ -36,6 +37,11 @@ export interface OnboardingFlowProps {
|
||||
readonly connectWallet?: () => Promise<string | null>
|
||||
/** Called once the flow reaches `done`, with the final accumulated state. */
|
||||
readonly onComplete: (state: OnboardingState) => void
|
||||
/**
|
||||
* Pay origin serving the billing catalog (GET /v1/billing/plans). The plan
|
||||
* step renders the catalog's own prices — no price is stated here.
|
||||
*/
|
||||
readonly payUrl: string
|
||||
}
|
||||
|
||||
interface FlowState {
|
||||
@@ -58,7 +64,7 @@ function reducer(state: FlowState, action: FlowAction): FlowState {
|
||||
}
|
||||
}
|
||||
|
||||
export function OnboardingFlow({ service, brandName, connectWallet, onComplete }: OnboardingFlowProps) {
|
||||
export function OnboardingFlow({ service, brandName, connectWallet, onComplete, payUrl }: OnboardingFlowProps) {
|
||||
const [state, dispatch] = useReducer(reducer, { step: 'org', data: {} })
|
||||
|
||||
// Terminal step: hand the accumulated state back to the host exactly once.
|
||||
@@ -106,6 +112,12 @@ export function OnboardingFlow({ service, brandName, connectWallet, onComplete }
|
||||
onNext={advance}
|
||||
/>
|
||||
) : null}
|
||||
{state.step === 'consent' ? (
|
||||
<ConsentStep service={service} showBack={showBack} onBack={back} onNext={advance} />
|
||||
) : null}
|
||||
{state.step === 'plan' ? (
|
||||
<PlanStep service={service} payUrl={payUrl} showBack={showBack} onBack={back} onNext={advance} />
|
||||
) : null}
|
||||
{state.step === 'done' ? <DoneStep brandName={brandName} data={state.data} /> : null}
|
||||
</div>
|
||||
)
|
||||
@@ -153,16 +165,17 @@ function OrgStep({
|
||||
onNext({ orgName: res.value.name, orgCreated: true })
|
||||
}
|
||||
|
||||
// Onboarding never lists other tenants' organizations — a brand-new user only
|
||||
// Onboarding never lists other orgs' organizations — a brand-new user only
|
||||
// ever creates their own org or skips. Listing the org directory would leak
|
||||
// every tenant's name to anyone who signs up. Joining an existing org happens
|
||||
// every org's name to anyone who signs up. Joining an existing org happens
|
||||
// by invitation, handled outside this flow.
|
||||
return (
|
||||
<div className="hanzo-id-onboarding-body">
|
||||
<form onSubmit={create} aria-busy={busy}>
|
||||
<label>
|
||||
<form onSubmit={create} className="hanzo-id-form" aria-busy={busy}>
|
||||
<label className="hanzo-id-field">
|
||||
<span>Organization name</span>
|
||||
<input
|
||||
className="hanzo-id-input"
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
@@ -177,7 +190,7 @@ function OrgStep({
|
||||
<button type="button" className="hanzo-id-btn ghost" onClick={() => onNext({})} disabled={busy}>
|
||||
Skip for now
|
||||
</button>
|
||||
<button type="submit" className="hanzo-id-btn primary" disabled={busy}>
|
||||
<button type="submit" className="hanzo-id-btn" disabled={busy}>
|
||||
{busy ? 'Creating…' : 'Create organization'}
|
||||
</button>
|
||||
</div>
|
||||
@@ -236,7 +249,7 @@ function ProjectStep({
|
||||
Back
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" className="hanzo-id-btn primary" onClick={() => onNext({})}>
|
||||
<button type="button" className="hanzo-id-btn" onClick={() => onNext({})}>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
@@ -246,10 +259,11 @@ function ProjectStep({
|
||||
|
||||
return (
|
||||
<div className="hanzo-id-onboarding-body">
|
||||
<form onSubmit={create} aria-busy={busy}>
|
||||
<label>
|
||||
<form onSubmit={create} className="hanzo-id-form" aria-busy={busy}>
|
||||
<label className="hanzo-id-field">
|
||||
<span>Project name</span>
|
||||
<input
|
||||
className="hanzo-id-input"
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
@@ -268,7 +282,7 @@ function ProjectStep({
|
||||
<button type="button" className="hanzo-id-btn ghost" onClick={() => onNext({})} disabled={busy}>
|
||||
Skip
|
||||
</button>
|
||||
<button type="submit" className="hanzo-id-btn primary" disabled={busy}>
|
||||
<button type="submit" className="hanzo-id-btn" disabled={busy}>
|
||||
{busy ? 'Creating…' : 'Create project'}
|
||||
</button>
|
||||
</div>
|
||||
@@ -334,7 +348,7 @@ function WalletStep({
|
||||
Skip
|
||||
</button>
|
||||
{connectWallet ? (
|
||||
<button type="button" className="hanzo-id-btn primary" onClick={link} disabled={busy}>
|
||||
<button type="button" className="hanzo-id-btn" onClick={link} disabled={busy}>
|
||||
{busy ? 'Connecting…' : 'Connect wallet'}
|
||||
</button>
|
||||
) : null}
|
||||
@@ -343,6 +357,189 @@ function WalletStep({
|
||||
)
|
||||
}
|
||||
|
||||
// ── Step 4: data-sharing consent ────────────────────────────────────
|
||||
|
||||
function ConsentStep({
|
||||
service,
|
||||
showBack,
|
||||
onBack,
|
||||
onNext,
|
||||
}: {
|
||||
service: OnboardingService
|
||||
showBack: boolean
|
||||
onBack: () => void
|
||||
onNext: (patch: Partial<OnboardingState>) => void
|
||||
}) {
|
||||
const [agreed, setAgreed] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Either answer continues; the answer itself is what must exist. It is
|
||||
// persisted on the USER (not browser storage) before the flow advances, so
|
||||
// this page is asked exactly once per account, ever.
|
||||
async function answer() {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
const res = await service.saveOnboarding({ consent: agreed })
|
||||
setBusy(false)
|
||||
if (!res.ok) {
|
||||
setError(res.error)
|
||||
return
|
||||
}
|
||||
onNext({ dataSharingConsent: agreed })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="hanzo-id-onboarding-body">
|
||||
<div className="hanzo-id-consent">
|
||||
<p>
|
||||
Sharing usage data helps improve the models and products you use. It
|
||||
covers product usage patterns and diagnostics — never the content of
|
||||
your conversations, code, or files. You can change this any time in
|
||||
account settings.
|
||||
</p>
|
||||
<label className="hanzo-id-consent-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={agreed}
|
||||
onChange={(e) => setAgreed(e.target.checked)}
|
||||
/>
|
||||
<span>I agree to share usage data to improve products and models.</span>
|
||||
</label>
|
||||
</div>
|
||||
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
|
||||
<div className="hanzo-id-onboarding-actions">
|
||||
{showBack ? (
|
||||
<button type="button" className="hanzo-id-btn ghost" onClick={onBack} disabled={busy}>
|
||||
Back
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" className="hanzo-id-btn" onClick={answer} disabled={busy}>
|
||||
{busy ? 'Saving…' : 'Continue'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Step 5 (last): plan or pay-as-you-go ────────────────────────────
|
||||
|
||||
/** Format catalog CENTS as dollars — "$9" or "$8.25", never "$9.00". */
|
||||
function usd(cents: number): string {
|
||||
const dollars = cents / 100
|
||||
return Number.isInteger(dollars) ? `$${dollars}` : `$${dollars.toFixed(2)}`
|
||||
}
|
||||
|
||||
function PlanStep({
|
||||
service,
|
||||
payUrl,
|
||||
showBack,
|
||||
onBack,
|
||||
onNext,
|
||||
}: {
|
||||
service: OnboardingService
|
||||
payUrl: string
|
||||
showBack: boolean
|
||||
onBack: () => void
|
||||
onNext: (patch: Partial<OnboardingState>) => void
|
||||
}) {
|
||||
const [plans, setPlans] = useState<PlanInfo[] | null>(null)
|
||||
const [busy, setBusy] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
service.listPlans(payUrl).then((p) => {
|
||||
if (alive) setPlans(p)
|
||||
})
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
// payUrl is fixed for the page's life.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// The choice is persisted (with completion) BEFORE the flow advances, so a
|
||||
// user who bounces off the payment page still never re-enters onboarding —
|
||||
// they land on the portal, where the top-up surface remains one click away.
|
||||
async function choose(choice: string) {
|
||||
setBusy(choice)
|
||||
setError(null)
|
||||
const res = await service.saveOnboarding({
|
||||
plan: choice,
|
||||
completedAt: new Date().toISOString(),
|
||||
})
|
||||
setBusy(null)
|
||||
if (!res.ok) {
|
||||
setError(res.error)
|
||||
return
|
||||
}
|
||||
onNext({ planChoice: choice })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="hanzo-id-onboarding-body">
|
||||
{plans === null ? (
|
||||
<p className="lede">Loading plans…</p>
|
||||
) : (
|
||||
<div className="hanzo-id-plans" role="list">
|
||||
{plans.length === 0 ? (
|
||||
// The catalog fetch failed or came back empty. Say so — a plan
|
||||
// picker showing ONLY pay-as-you-go with no explanation reads as
|
||||
// "there are no plans", which is false. Pay as you go still works,
|
||||
// and plans remain choosable later from billing.
|
||||
<p role="alert" className="hanzo-id-plans-empty">
|
||||
Plans are unavailable right now — you can start with pay as you
|
||||
go and pick a plan later from Billing.
|
||||
</p>
|
||||
) : null}
|
||||
{plans.map((p) => (
|
||||
<button
|
||||
key={p.slug}
|
||||
type="button"
|
||||
role="listitem"
|
||||
className={p.popular ? 'hanzo-id-plan popular' : 'hanzo-id-plan'}
|
||||
onClick={() => choose(p.slug)}
|
||||
disabled={busy !== null}
|
||||
aria-busy={busy === p.slug}
|
||||
>
|
||||
{p.popular ? <span className="hanzo-id-plan-badge">Popular</span> : null}
|
||||
<span className="hanzo-id-plan-name">{p.name}</span>
|
||||
<span className="hanzo-id-plan-price">
|
||||
{usd(p.priceCents)}/mo
|
||||
{p.priceAnnualCents ? <em> · {usd(p.priceAnnualCents * 12)}/yr billed annually</em> : null}
|
||||
</span>
|
||||
{p.description ? <span className="hanzo-id-plan-desc">{p.description}</span> : null}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
role="listitem"
|
||||
className="hanzo-id-plan payg"
|
||||
onClick={() => choose('payg')}
|
||||
disabled={busy !== null}
|
||||
aria-busy={busy === 'payg'}
|
||||
>
|
||||
<span className="hanzo-id-plan-name">Pay as you go</span>
|
||||
<span className="hanzo-id-plan-price">Prepaid balance · $5 minimum</span>
|
||||
<span className="hanzo-id-plan-desc">
|
||||
No subscription. Top up a balance and pay only for what you use.
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
|
||||
{showBack ? (
|
||||
<div className="hanzo-id-onboarding-actions">
|
||||
<button type="button" className="hanzo-id-btn ghost" onClick={onBack} disabled={busy !== null}>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Terminal: success ───────────────────────────────────────────────
|
||||
|
||||
function DoneStep({ brandName, data }: { brandName: string; data: OnboardingState }) {
|
||||
|
||||
@@ -1,24 +1,28 @@
|
||||
{
|
||||
"name": "@hanzo/id-shared",
|
||||
"version": "0.1.1",
|
||||
"description": "Shared types + tenant resolver for the Hanzo ID portal. No UI deps.",
|
||||
"version": "0.1.4",
|
||||
"description": "Shared types + org resolver for the Hanzo ID portal. No UI deps.",
|
||||
"license": "BSD-3-Clause",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./tenant": "./src/tenant.ts",
|
||||
"./org": "./src/org.ts",
|
||||
"./brand": "./src/brand.ts",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": ["src"],
|
||||
"files": [
|
||||
"src"
|
||||
],
|
||||
"scripts": {
|
||||
"tc": "tsc --noEmit",
|
||||
"build": "tsc --noEmit",
|
||||
"test": "node --test --experimental-strip-types 'src/**/*.test.ts'"
|
||||
"build": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hanzo/brand": "^1.4.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { BrandContract } from './types'
|
||||
|
||||
/**
|
||||
* Resolve a BrandContract from a tenant's brand package.
|
||||
* Resolve a BrandContract from a org's brand package.
|
||||
*
|
||||
* Each per-org brand pkg (`@hanzo/brand`, `@luxfi/brand`, `@zooai/brand`,
|
||||
* `@parsdao/brand`) ships a `brand.json` at the package root. This loader
|
||||
@@ -50,7 +50,7 @@ export async function loadBrand(brandPackage: string): Promise<BrandContract> {
|
||||
* Last-resort brand when the asset is unreachable after retries. Keeps the
|
||||
* login form usable (a generic heading) instead of blanking the page. The
|
||||
* display name is derived from the pkg scope (`@hanzo/brand` -> "Hanzo"); the
|
||||
* few tenants whose scope differs from their display name are mapped.
|
||||
* few orgs whose scope differs from their display name are mapped.
|
||||
*/
|
||||
function fallbackBrand(brandPackage: string): BrandContract {
|
||||
const scope = brandPackage.replace(/^@/, '').split('/')[0] ?? 'hanzo'
|
||||
@@ -76,6 +76,24 @@ export interface BrandRuntime {
|
||||
readonly accentColor?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Neutral identity-portal label — always "<Brand> ID". `BrandContract.name`
|
||||
* is meant to be the bare org display ("Lux"), but some brand packages ship
|
||||
* the product name ("Lux Exchange" / "Zoo Exchange"), which leaks a sibling
|
||||
* surface into the IAM portal heading + tab title. Prefer the org orgId
|
||||
* ("lux" → "Lux"); otherwise strip a trailing product word from the brand
|
||||
* name. So id.lux.network reads "Lux ID", never "Lux Exchange".
|
||||
*/
|
||||
export function idBrandLabel(brand: { name: string }, orgId?: string): string {
|
||||
const cap = (s: string) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : '')
|
||||
const short =
|
||||
cap((orgId ?? '').trim()) ||
|
||||
(brand.name ?? '').replace(/\s+(Exchange|Network|Labs|Foundation|DAO|Wallet)\b.*$/i, '').trim() ||
|
||||
brand.name ||
|
||||
'Account'
|
||||
return `${short} ID`
|
||||
}
|
||||
|
||||
export function toBrandRuntime(b: BrandContract): BrandRuntime {
|
||||
return {
|
||||
name: b.name,
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export * from './tenant'
|
||||
export * from './org'
|
||||
export * from './brand'
|
||||
export * from './types'
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* Org-resolver tests — run with the Node built-in runner + native TS strip:
|
||||
*
|
||||
* pnpm --filter @hanzo/id-shared test
|
||||
*
|
||||
* Focus: a host that exists ONLY in the runtime catalog (no built-in entry)
|
||||
* must resolve to ITS OWN brand and issuer — never inherit Hanzo's. This is the
|
||||
* osage.id brand-leak regression: the catalog carries `brandUrl`, and the
|
||||
* resolver must map it to `brandPackage` and derive issuer/origin from the host.
|
||||
*/
|
||||
import { test } from 'vitest'
|
||||
import assert from 'node:assert/strict'
|
||||
import { resolveOrg, parseCatalog, catalogOf } from './org.ts'
|
||||
|
||||
// Mirrors the K8s ConfigMap shape: entries carry `brandUrl`, not `brandPackage`.
|
||||
const CATALOG = {
|
||||
'lux.id': {
|
||||
orgId: 'lux',
|
||||
clientId: 'lux-cloud',
|
||||
appName: 'lux-cloud',
|
||||
brandUrl: 'https://cdn.jsdelivr.net/npm/@luxfi/brand@latest/brand.json',
|
||||
},
|
||||
'osage.id': {
|
||||
orgId: 'osage',
|
||||
clientId: 'osage-id-portal',
|
||||
appName: 'osage-id',
|
||||
brandUrl: 'https://cdn.jsdelivr.net/npm/@osage/brand@latest/brand.json',
|
||||
},
|
||||
// No brandUrl on purpose — no bootnode brand package is published. This is
|
||||
// the shape that must NOT fall back to Hanzo.
|
||||
'id.bootno.de': {
|
||||
orgId: 'bootnode',
|
||||
clientId: 'bootnode-platform',
|
||||
appName: 'bootnode-platform',
|
||||
},
|
||||
}
|
||||
|
||||
test('a built-in host resolves to its own brand with no catalog', () => {
|
||||
const t = resolveOrg('hanzo.id')
|
||||
assert.equal(t.orgId, 'hanzo')
|
||||
assert.equal(t.brandPackage, '@hanzo/brand')
|
||||
assert.equal(t.iamUrl, 'https://hanzo.id')
|
||||
})
|
||||
|
||||
test('a catalog entry overrides clientId/appName but keeps a consistent brand', () => {
|
||||
const t = resolveOrg('lux.id', { catalog: CATALOG })
|
||||
assert.equal(t.orgId, 'lux')
|
||||
assert.equal(t.clientId, 'lux-cloud')
|
||||
assert.equal(t.brandPackage, '@luxfi/brand')
|
||||
assert.equal(t.iamUrl, 'https://lux.id')
|
||||
assert.equal(t.publicOrigin, 'https://lux.id')
|
||||
})
|
||||
|
||||
test('a catalog-ONLY host does NOT leak the Hanzo brand (osage.id regression)', () => {
|
||||
const t = resolveOrg('osage.id', { catalog: CATALOG })
|
||||
assert.equal(t.orgId, 'osage')
|
||||
assert.equal(t.clientId, 'osage-id-portal')
|
||||
// brandUrl is mapped onto brandPackage, and it is NOT Hanzo's.
|
||||
assert.equal(t.brandPackage, '@osage/brand')
|
||||
assert.notEqual(t.brandPackage, '@hanzo/brand')
|
||||
// issuer + origin are the host itself, never hanzo.id.
|
||||
assert.equal(t.iamUrl, 'https://osage.id')
|
||||
assert.equal(t.iamIssuer, 'https://osage.id')
|
||||
assert.equal(t.publicOrigin, 'https://osage.id')
|
||||
})
|
||||
|
||||
test('a catalog host with NO brandUrl still does not leak Hanzo (id.bootno.de)', () => {
|
||||
const t = resolveOrg('id.bootno.de', { catalog: CATALOG })
|
||||
assert.equal(t.orgId, 'bootnode')
|
||||
assert.equal(t.clientId, 'bootnode-platform')
|
||||
// The interesting case: no brandUrl at all. It must resolve EMPTY so the
|
||||
// loader shows a neutral wordmark — never another brand's mark. Before this
|
||||
// host was in the catalog it fell through to the `hanzo` default and the page
|
||||
// read "Sign in to Hanzo ID" on a Bootnode surface.
|
||||
assert.equal(t.brandPackage, '')
|
||||
assert.notEqual(t.brandPackage, '@hanzo/brand')
|
||||
// issuer + origin derive from the host itself, never hanzo.id.
|
||||
assert.equal(t.iamUrl, 'https://id.bootno.de')
|
||||
assert.equal(t.iamIssuer, 'https://id.bootno.de')
|
||||
assert.equal(t.publicOrigin, 'https://id.bootno.de')
|
||||
})
|
||||
|
||||
test('an unknown host FAILS CLOSED — it never inherits another brand', () => {
|
||||
const t = resolveOrg('totally-unregistered.example', { catalog: CATALOG })
|
||||
// Was: DEFAULT_TENANTS['hanzo.id'] — orgId hanzo, @hanzo/brand, and
|
||||
// iamUrl https://hanzo.id. Empty clientId means the portal refuses rather
|
||||
// than authenticating as some other brand's IAM application.
|
||||
assert.equal(t.orgId, '')
|
||||
assert.equal(t.clientId, '')
|
||||
assert.equal(t.brandPackage, '')
|
||||
assert.equal(t.iamUrl, 'https://totally-unregistered.example')
|
||||
assert.notEqual(t.iamUrl, 'https://hanzo.id')
|
||||
})
|
||||
|
||||
test('a catalog host with a FAILED catalog fetch does not leak Hanzo', () => {
|
||||
// The live failure mode: App.tsx tolerates a failed /config.json, so these
|
||||
// real hosts resolve with NO catalog at all. Every one of them used to come
|
||||
// back as Hanzo — same brand, same mark, and credentials posted at hanzo.id.
|
||||
for (const host of [
|
||||
'zoolabs.id',
|
||||
'www.zoolabs.id',
|
||||
'id.zoo.network',
|
||||
'id.lux.network',
|
||||
'iam.lux.network',
|
||||
'id.pars.network',
|
||||
'id.bootno.de',
|
||||
]) {
|
||||
const t = resolveOrg(host) // no catalog — the fetch failed
|
||||
assert.notEqual(t.orgId, 'hanzo', `${host} leaked orgId hanzo`)
|
||||
assert.notEqual(t.brandPackage, '@hanzo/brand', `${host} leaked the Hanzo mark`)
|
||||
assert.notEqual(t.iamUrl, 'https://hanzo.id', `${host} would post credentials at hanzo.id`)
|
||||
assert.equal(t.iamUrl, `https://${host}`)
|
||||
assert.equal(t.iamIssuer, `https://${host}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('zoo.id is gone — it is NXDOMAIN and must not be a built-in', () => {
|
||||
const t = resolveOrg('zoo.id')
|
||||
assert.equal(t.orgId, '')
|
||||
assert.equal(t.clientId, '')
|
||||
})
|
||||
|
||||
test('pars built-in uses the working pars-console portal app (not the missing pars-id)', () => {
|
||||
const t = resolveOrg('pars.id')
|
||||
assert.equal(t.clientId, 'pars-console')
|
||||
assert.equal(t.brandPackage, '@parsdao/brand')
|
||||
})
|
||||
|
||||
test('osage built-in resolves to Osage even with NO catalog (fallback safety)', () => {
|
||||
const t = resolveOrg('osage.id')
|
||||
assert.equal(t.orgId, 'osage')
|
||||
assert.equal(t.brandPackage, '@osage/brand')
|
||||
assert.notEqual(t.brandPackage, '@hanzo/brand')
|
||||
assert.equal(t.iamUrl, 'https://osage.id')
|
||||
})
|
||||
|
||||
test('an unknown host keeps its own origin AND does not inherit an org', () => {
|
||||
const t = resolveOrg('preview.example.com')
|
||||
// This assertion used to be `orgId === 'hanzo'` — it pinned the cross-brand
|
||||
// fallback as intended behaviour. Keeping its own origin is right; being
|
||||
// handed Hanzo's org, mark and issuer is the defect that shipped behind it.
|
||||
assert.equal(t.orgId, '')
|
||||
assert.equal(t.publicOrigin, 'https://preview.example.com')
|
||||
})
|
||||
|
||||
test('parseCatalog tolerates junk', () => {
|
||||
assert.deepEqual(parseCatalog(undefined), {})
|
||||
assert.deepEqual(parseCatalog(null), {})
|
||||
assert.deepEqual(parseCatalog('not json'), {})
|
||||
assert.deepEqual(parseCatalog('{"osage.id":{"orgId":"osage"}}'), {
|
||||
'osage.id': { orgId: 'osage' },
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* THE SOCIAL-LOGIN REGRESSION. Google and GitHub each accept a fixed list of
|
||||
* redirect URIs and we hold ONE shared OAuth client per provider, so every
|
||||
* brand must send the same `redirect_uri` or the provider answers
|
||||
* `redirect_uri_mismatch`.
|
||||
*
|
||||
* `oauthCallbackOrigin` used to default to `publicOrigin` — the brand's own
|
||||
* host — and no catalog entry overrode it, so each property sent a different
|
||||
* URI and social login could work on at most one of them. The failure surfaced
|
||||
* at Google, not here, which is why it read as a credentials problem for days.
|
||||
*
|
||||
* The default is the ORG'S HOSTED ID HOST — hanzo.id for hanzo, lux.id for lux.
|
||||
*
|
||||
* This test used to assert the default was `iamIssuer`, which was the FIRST
|
||||
* attempt at the fix and was abandoned in the same change that shipped the real
|
||||
* one: `hostSkeleton` derives the issuer from the REQUEST HOST, so on an app
|
||||
* host the issuer IS the brand host and the bug is unchanged. `org.ts` says so
|
||||
* in place. The assertion was left behind and had been failing on `main` ever
|
||||
* since, against code that is correct.
|
||||
*
|
||||
* It was also asking the question on hosts that carry no org: `resolveOrg` was
|
||||
* called with NO catalog, so hanzo.app and hanzo.chat fell to the deliberate
|
||||
* unknown-host skeleton (empty orgId, fail closed, never another brand's
|
||||
* config). No design can make two ORG-LESS hosts agree on one org's callback —
|
||||
* the old assertion could not have passed under either default.
|
||||
*
|
||||
* So ask it the way production does: the catalog (`/config.json`) is what
|
||||
* supplies the orgId, and the invariant that matters is one registered URI PER
|
||||
* ORG.
|
||||
*/
|
||||
test('the provider callback is the org hosted-ID host, one per org', () => {
|
||||
const catalog = parseCatalog(
|
||||
JSON.stringify({
|
||||
'hanzo.app': { orgId: 'hanzo', clientId: 'hanzo-app' },
|
||||
'hanzo.chat': { orgId: 'hanzo', clientId: 'hanzo-chat' },
|
||||
'console.hanzo.ai': { orgId: 'hanzo', clientId: 'hanzo-cloud' },
|
||||
'id.lux.network': { orgId: 'lux', clientId: 'lux-id' },
|
||||
}),
|
||||
)
|
||||
|
||||
const app = resolveOrg('hanzo.app', { catalog })
|
||||
const chat = resolveOrg('hanzo.chat', { catalog })
|
||||
const consoleHost = resolveOrg('console.hanzo.ai', { catalog })
|
||||
const portal = resolveOrg('hanzo.id', { catalog }) // built-in; needs no row
|
||||
|
||||
// Whatever the property, ONE registered redirect_uri serves them all.
|
||||
for (const t of [app, chat, consoleHost, portal]) {
|
||||
assert.equal(t.oauthCallbackOrigin, 'https://hanzo.id')
|
||||
}
|
||||
|
||||
// And it is NOT the brand host — the precise shape of the bug.
|
||||
assert.notEqual(app.oauthCallbackOrigin, app.publicOrigin)
|
||||
assert.notEqual(chat.oauthCallbackOrigin, chat.publicOrigin)
|
||||
assert.notEqual(consoleHost.oauthCallbackOrigin, consoleHost.publicOrigin)
|
||||
|
||||
// Per-ORG, not global, and NOT the issuer: lux gets lux.id. This is the
|
||||
// assertion that pins the abandoned first attempt out of the codebase —
|
||||
// under `iamIssuer` this host would send its own origin and break again.
|
||||
const lux = resolveOrg('id.lux.network', { catalog })
|
||||
assert.equal(lux.oauthCallbackOrigin, 'https://lux.id')
|
||||
assert.notEqual(lux.oauthCallbackOrigin, lux.iamIssuer)
|
||||
})
|
||||
|
||||
test('an explicit catalog oauthCallbackOrigin still wins over the issuer', () => {
|
||||
const catalog = parseCatalog(
|
||||
JSON.stringify({ 'per-host.example': { oauthCallbackOrigin: 'https://its-own-client.example' } }),
|
||||
)
|
||||
const org = resolveOrg('per-host.example', { catalog })
|
||||
assert.equal(org.oauthCallbackOrigin, 'https://its-own-client.example')
|
||||
})
|
||||
|
||||
// The catalog key is the SERVER'S name. Reading the wrong one returns undefined,
|
||||
// the app falls back to a global the runtime never injects, and every
|
||||
// catalog-only host silently drops to the bundled defaults — a total catalog
|
||||
// outage that looks like nothing at all. Pinned here, next to the resolver it
|
||||
// feeds, because the last time these tests were deleted the function went with
|
||||
// them and the image stopped building.
|
||||
test('catalogOf reads the key the runtime actually serves', () => {
|
||||
const served = { iamTenantConfigJson: '{"hanzo.id":{"clientId":"hanzo-console"}}', v: 1 }
|
||||
assert.equal(catalogOf(served), '{"hanzo.id":{"clientId":"hanzo-console"}}')
|
||||
assert.equal(parseCatalog(catalogOf(served))['hanzo.id']!.clientId, 'hanzo-console')
|
||||
|
||||
// Anything else is not the catalog: no guessing, no second accepted key.
|
||||
assert.equal(catalogOf({ iamOrgConfigJson: '{"hanzo.id":{}}' }), undefined)
|
||||
assert.equal(catalogOf({}), undefined)
|
||||
assert.equal(catalogOf(null), undefined)
|
||||
assert.equal(catalogOf(undefined), undefined)
|
||||
assert.equal(catalogOf({ iamTenantConfigJson: 42 }), undefined)
|
||||
})
|
||||
|
||||
// The regression that broke the build: App.tsx imports catalogOf from the
|
||||
// package barrel, so exporting it from org.ts alone is not enough.
|
||||
test('catalogOf is reachable from the package barrel', async () => {
|
||||
const barrel = await import('./index.ts')
|
||||
assert.equal(typeof barrel.catalogOf, 'function')
|
||||
assert.equal(barrel.catalogOf({ iamTenantConfigJson: '{}' }), '{}')
|
||||
})
|
||||
|
||||
// The fallback and the catalog must name the SAME application per host.
|
||||
//
|
||||
// resolveOrg spreads the catalog OVER the built-in table, so a host where the
|
||||
// two disagree authenticates as one app or the other depending on whether
|
||||
// /config.json won a race. hanzo.id said `hanzo-id` in the table and
|
||||
// `hanzo-console` in the catalog, and those two differ in enableSignUp — false
|
||||
// vs true. On a load where the fetch did not arrive, the signup form rendered
|
||||
// and the POST came back "the application does not allow to sign up new
|
||||
// account". Intermittent, device-dependent, and reported from a phone.
|
||||
//
|
||||
// This asserts the property that makes the race harmless: for a host present in
|
||||
// BOTH, resolving with and without the catalog yields the same clientId. It is
|
||||
// checked against the catalog fixture above, which mirrors the ConfigMap in
|
||||
// universe/infra/k8s/id/configmap.yaml — so a change to one side that is not
|
||||
// mirrored in the other fails here rather than in a customer's browser.
|
||||
test('the built-in fallback names the SAME app as the catalog, per host', () => {
|
||||
const CANONICAL: Record<string, string> = {
|
||||
'hanzo.id': 'hanzo-console',
|
||||
'lux.id': 'lux-cloud',
|
||||
'pars.id': 'pars-console',
|
||||
}
|
||||
for (const [host, clientId] of Object.entries(CANONICAL)) {
|
||||
// No catalog — the failed-/config.json path a real visitor can hit.
|
||||
assert.equal(resolveOrg(host).clientId, clientId, `${host} fallback`)
|
||||
// With the catalog — the ordinary path.
|
||||
assert.equal(
|
||||
resolveOrg(host, { catalog: { [host]: { clientId, appName: clientId } } }).clientId,
|
||||
clientId,
|
||||
`${host} with catalog`,
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,286 @@
|
||||
import type { OrgConfig } from './types'
|
||||
|
||||
/**
|
||||
* Resolve a OrgConfig by hostname.
|
||||
*
|
||||
* Resolution order (first hit wins):
|
||||
* 1. `IAM_TENANT_CONFIG_JSON` runtime catalog (set in K8s ConfigMap, served
|
||||
* to the browser via `/config.json` at pod startup).
|
||||
* 2. Built-in defaults, for the identity hosts that have one.
|
||||
* 3. A skeleton derived from the REQUESTED HOST — never another brand.
|
||||
*
|
||||
* There is deliberately no cross-brand default. An unknown host resolves to
|
||||
* itself with an empty clientId and fails closed, because the alternative is a
|
||||
* visitor on one brand's host being shown another brand's login and posting
|
||||
* credentials there.
|
||||
*
|
||||
* No hardcoded hostname switches anywhere downstream. Adding a org
|
||||
* means editing the runtime catalog, never editing source.
|
||||
*/
|
||||
|
||||
const TRIM_TRAILING_SLASH = (s: string): string => s.replace(/\/+$/, '')
|
||||
|
||||
/**
|
||||
* Built-in orgs for the four canonical identity hosts.
|
||||
*
|
||||
* `iamUrl` is the per-brand OIDC ISSUER — the host that serves
|
||||
* `/.well-known/openid-configuration` and the `/v1/iam/*` surface. Per
|
||||
* HIP-0111 this is the brand's own `*.id` host (hanzo.id / lux.id / …),
|
||||
* NOT `iam.hanzo.ai`: discovery must be host-relative so the SDK never
|
||||
* resolves to the wrong origin (or the IAM SPA HTML catch-all). The IAM
|
||||
* backend org-scopes on the `organization` body param; one backend
|
||||
* serves every brand behind its own issuer host.
|
||||
*
|
||||
* `clientId` MUST name the SAME application the runtime catalog names for that
|
||||
* host. This table is a fallback for a failed `/config.json`, not a second
|
||||
* opinion — `resolveOrg` spreads the catalog OVER it, so any key where the two
|
||||
* disagree makes the host authenticate as one app or the other depending on
|
||||
* whether a network fetch won a race.
|
||||
*
|
||||
* That is not hypothetical. `hanzo.id` said `hanzo-id` here while the catalog
|
||||
* says `hanzo-console`, and the two differ in `enableSignUp` — false vs true.
|
||||
* So on a load where `/config.json` did not arrive, hanzo.id authenticated as
|
||||
* `hanzo-id`, the signup form rendered, and the POST came back "the application
|
||||
* does not allow to sign up new account". Intermittent, device-dependent, and
|
||||
* indistinguishable from a bad password to the person hitting it. Reported from
|
||||
* a phone, where a dropped request is ordinary.
|
||||
*
|
||||
* Both now match the catalog (`universe/infra/k8s/id/configmap.yaml`,
|
||||
* SPA_IAM_TENANT_CONFIG_JSON). `org.test.ts` pins them, so a future edit to one
|
||||
* side has to confront the other. Fixing the fallback to be SURVIVABLE was the
|
||||
* wrong shape — the fix is that there is only one answer per host.
|
||||
*/
|
||||
const DEFAULT_TENANTS: Record<string, OrgConfig> = {
|
||||
'hanzo.id': {
|
||||
orgId: 'hanzo',
|
||||
iamUrl: 'https://hanzo.id',
|
||||
iamIssuer: 'https://hanzo.id',
|
||||
clientId: 'hanzo-console',
|
||||
appName: 'hanzo-console',
|
||||
publicOrigin: 'https://hanzo.id',
|
||||
brandPackage: '@hanzo/brand',
|
||||
},
|
||||
'lux.id': {
|
||||
orgId: 'lux',
|
||||
iamUrl: 'https://lux.id',
|
||||
iamIssuer: 'https://lux.id',
|
||||
clientId: 'lux-cloud',
|
||||
appName: 'lux-cloud',
|
||||
publicOrigin: 'https://lux.id',
|
||||
brandPackage: '@luxfi/brand',
|
||||
},
|
||||
'pars.id': {
|
||||
orgId: 'pars',
|
||||
iamUrl: 'https://pars.id',
|
||||
iamIssuer: 'https://pars.id',
|
||||
// The portal app is `pars-console` (it carries the https://pars.id/callback
|
||||
// redirect); a bare `pars-id` app does not exist in IAM.
|
||||
clientId: 'pars-console',
|
||||
appName: 'pars-console',
|
||||
publicOrigin: 'https://pars.id',
|
||||
brandPackage: '@parsdao/brand',
|
||||
},
|
||||
// Osage is served by this portal too; without a built-in it would fall back
|
||||
// to the Hanzo default and leak the wrong brand if the runtime catalog ever
|
||||
// fails to load. (osage-id-portal is pre-launch — no IAM app yet — but the
|
||||
// brand must read as Osage, never Hanzo.)
|
||||
'osage.id': {
|
||||
orgId: 'osage',
|
||||
iamUrl: 'https://osage.id',
|
||||
iamIssuer: 'https://osage.id',
|
||||
clientId: 'osage-id-portal',
|
||||
appName: 'osage-id',
|
||||
publicOrigin: 'https://osage.id',
|
||||
brandPackage: '@osage/brand',
|
||||
},
|
||||
'www.osage.id': {
|
||||
orgId: 'osage',
|
||||
iamUrl: 'https://www.osage.id',
|
||||
iamIssuer: 'https://www.osage.id',
|
||||
clientId: 'osage-id-portal',
|
||||
appName: 'osage-id',
|
||||
publicOrigin: 'https://www.osage.id',
|
||||
brandPackage: '@osage/brand',
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* A runtime catalog entry as it appears in the K8s ConfigMap / `/config.json`.
|
||||
* It carries the human-authored shape — notably `brandUrl` (a CDN URL), which
|
||||
* this module maps onto the code-facing `brandPackage`. All fields optional;
|
||||
* whatever is present overrides the host-derived base.
|
||||
*/
|
||||
export type CatalogEntry = Partial<OrgConfig> & {
|
||||
/** CDN URL of the brand package, e.g. `…/npm/@osage/brand@latest/brand.json`. */
|
||||
readonly brandUrl?: string
|
||||
}
|
||||
|
||||
export interface ResolveOptions {
|
||||
/** Optional runtime catalog (parsed from IAM_TENANT_CONFIG_JSON or /config.json). */
|
||||
readonly catalog?: Record<string, CatalogEntry>
|
||||
}
|
||||
|
||||
export function resolveOrg(hostname: string, opts: ResolveOptions = {}): OrgConfig {
|
||||
const host = stripPort(hostname).toLowerCase()
|
||||
const catalogEntry = opts.catalog?.[host]
|
||||
const builtIn = DEFAULT_TENANTS[host]
|
||||
if (catalogEntry || builtIn) {
|
||||
// Base = the built-in org if one exists, else a skeleton derived from
|
||||
// THIS host. Never another brand's config: a catalog-only host (osage.id,
|
||||
// zoolabs.id) must not inherit Hanzo's issuer or brand package.
|
||||
const base = builtIn ?? hostSkeleton(host)
|
||||
const merged: OrgConfig = { ...base, ...fromCatalog(catalogEntry) } as OrgConfig
|
||||
return normalize(merged)
|
||||
}
|
||||
// Unknown host → derive from the host ITSELF. Never another brand's org.
|
||||
//
|
||||
// This used to return DEFAULT_TENANTS[`${defaultOrg}.id`], i.e. Hanzo's. Eight
|
||||
// real hosts have no built-in entry and live only in the runtime catalog —
|
||||
// zoolabs.id, www.zoolabs.id, id.zoo.network, id.lux.network, iam.lux.network,
|
||||
// id.pars.network, id.bootno.de, iam.hanzo.ai — and App.tsx deliberately
|
||||
// tolerates a failed /config.json fetch. So whenever that fetch failed, a Zoo,
|
||||
// Lux, Pars or Bootnode visitor was handed orgId `hanzo`, `@hanzo/brand` and
|
||||
// iamUrl `https://hanzo.id`: shown "Sign in to Hanzo ID" under the Hanzo mark
|
||||
// and POSTING THEIR CREDENTIALS AT hanzo.id. The comment ten lines up already
|
||||
// promised this could not happen ("Never another brand's config") — it held
|
||||
// only while the catalog loaded.
|
||||
//
|
||||
// The skeleton carries an empty clientId, so the portal fails closed rather
|
||||
// than silently authenticating as some other brand's IAM application. A login
|
||||
// page that cannot resolve its org must refuse, not guess.
|
||||
return normalize(hostSkeleton(host))
|
||||
}
|
||||
|
||||
/**
|
||||
* A host-derived org skeleton for a catalog-only host (no built-in entry).
|
||||
* URLs point at the host itself so nothing leaks from another brand; the
|
||||
* catalog entry spread over this supplies orgId / clientId / appName /
|
||||
* brandPackage. brandPackage defaults empty → the brand loader falls back to a
|
||||
* neutral wordmark rather than showing the wrong brand.
|
||||
*/
|
||||
function hostSkeleton(host: string): OrgConfig {
|
||||
return {
|
||||
orgId: '',
|
||||
iamUrl: `https://${host}`,
|
||||
iamIssuer: `https://${host}`,
|
||||
clientId: '',
|
||||
appName: '',
|
||||
publicOrigin: `https://${host}`,
|
||||
brandPackage: '',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a catalog entry onto a OrgConfig patch, mapping `brandUrl` →
|
||||
* `brandPackage` (the code-facing field) when an explicit `brandPackage` isn't
|
||||
* given. Only defined string fields are emitted, so the host-derived base shows
|
||||
* through for anything the entry omits.
|
||||
*/
|
||||
function fromCatalog(entry: CatalogEntry | undefined): Partial<OrgConfig> {
|
||||
if (!entry) return {}
|
||||
const out: Record<string, string> = {}
|
||||
for (const k of ['orgId', 'loginOrg', 'iamUrl', 'iamIssuer', 'clientId', 'appName', 'publicOrigin', 'oauthCallbackOrigin', 'brandPackage', 'payUrl'] as const) {
|
||||
const v = entry[k]
|
||||
if (typeof v === 'string' && v.length > 0) out[k] = v
|
||||
}
|
||||
if (!out.brandPackage && typeof entry.brandUrl === 'string') {
|
||||
const pkg = brandPackageFromUrl(entry.brandUrl)
|
||||
if (pkg) out.brandPackage = pkg
|
||||
}
|
||||
return out as Partial<OrgConfig>
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the npm package name from a CDN brand URL, e.g.
|
||||
* `https://cdn.jsdelivr.net/npm/@osage/brand@latest/brand.json` → `@osage/brand`.
|
||||
*/
|
||||
function brandPackageFromUrl(url: string): string {
|
||||
const m = /\/npm\/(@[^/]+\/[^@/]+|[^@/]+)(?:@|\/)/.exec(url)
|
||||
return m ? m[1]! : ''
|
||||
}
|
||||
|
||||
function stripPort(h: string): string {
|
||||
return h.replace(/:\d+$/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* The org's hosted-ID origin — hanzo.id for hanzo, lux.id for lux, and so on —
|
||||
* derived from DEFAULT_TENANTS so the `.id` hosts are declared exactly once.
|
||||
* Returns '' for an org with no hosted ID host (local dev, per-host clients).
|
||||
*/
|
||||
function idOriginFor(orgId: string): string {
|
||||
if (!orgId) return ''
|
||||
for (const [host, t] of Object.entries(DEFAULT_TENANTS)) {
|
||||
if (t.orgId === orgId && host.endsWith('.id')) return `https://${host}`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function normalize(t: OrgConfig): OrgConfig {
|
||||
const publicOrigin = TRIM_TRAILING_SLASH(t.publicOrigin)
|
||||
const iamIssuer = TRIM_TRAILING_SLASH(t.iamIssuer || t.iamUrl)
|
||||
return {
|
||||
...t,
|
||||
iamUrl: TRIM_TRAILING_SLASH(t.iamUrl),
|
||||
iamIssuer,
|
||||
publicOrigin,
|
||||
// ONE provider callback for the whole org, and it is the hosted ID host —
|
||||
// hanzo.id for hanzo, lux.id for lux. A social provider must never learn
|
||||
// about individual apps: it holds ONE OAuth client with ONE registered
|
||||
// redirect_uri, so every app's hop has to arrive from the same origin or
|
||||
// the provider answers `redirect_uri_mismatch`.
|
||||
//
|
||||
// This defaulted to `publicOrigin` — the BRAND'S OWN host — and no catalog
|
||||
// entry overrode it, so hanzo.app sent hanzo.app/callback, hanzo.chat sent
|
||||
// hanzo.chat/callback, console sent console.hanzo.ai/callback, and social
|
||||
// login could work on at most ONE property. Defaulting to `iamIssuer` does
|
||||
// NOT fix it: hostSkeleton derives the issuer from the REQUEST HOST too, so
|
||||
// it is per-brand for exactly the same reason. It has to be a per-ORG
|
||||
// constant, which is what idOriginFor reads out of DEFAULT_TENANTS.
|
||||
//
|
||||
// The failure surfaced at Google, not here, which is why it read as a
|
||||
// credentials or KMS problem for days. It never was: the client_id reached
|
||||
// Google intact every time and Google's own error decoded to
|
||||
// `redirect_uri_mismatch`.
|
||||
//
|
||||
// hanzo.id then completes the exchange and forwards the browser back to the
|
||||
// originating app, so the app hosts stay entirely invisible to the provider.
|
||||
oauthCallbackOrigin: TRIM_TRAILING_SLASH(
|
||||
t.oauthCallbackOrigin || idOriginFor(t.orgId) || iamIssuer || publicOrigin,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the catalog JSON string out of the `/config.json` payload.
|
||||
*
|
||||
* The key is the SERVER'S name, not ours: the runtime serves
|
||||
* `{"iamTenantConfigJson": "<json>", "v": 1}`. Reading any other key yields
|
||||
* `undefined`, App.tsx falls back to a global the runtime never injects, and
|
||||
* EVERY catalog-only host silently drops to the bundled defaults — a total
|
||||
* catalog outage that looks like nothing at all. That is why this is one named
|
||||
* function pinned by tests next to the resolver it feeds, rather than an inline
|
||||
* property read at the call site.
|
||||
*
|
||||
* Restored after c153004 deleted it together with its tests while App.tsx still
|
||||
* imported it: the id image failed to build from that commit onward
|
||||
* (`"catalogOf" is not exported by "../../pkgs/shared/src/index.ts"`), which is
|
||||
* why 0.2.22 never existed. Deleting a function and its tests in one move
|
||||
* removes the thing that would have reported the deletion.
|
||||
*/
|
||||
export function catalogOf(payload: unknown): string | undefined {
|
||||
if (!payload || typeof payload !== 'object') return undefined
|
||||
const raw = (payload as { iamTenantConfigJson?: unknown }).iamTenantConfigJson
|
||||
return typeof raw === 'string' ? raw : undefined
|
||||
}
|
||||
|
||||
/** Parse the runtime catalog JSON safely; returns {} on any error. */
|
||||
export function parseCatalog(raw: string | undefined | null): Record<string, Partial<OrgConfig>> {
|
||||
if (!raw) return {}
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
return parsed && typeof parsed === 'object' ? parsed : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
/**
|
||||
* Tenant-resolver tests — run with the Node built-in runner + native TS strip:
|
||||
*
|
||||
* pnpm --filter @hanzo/id-shared test
|
||||
*
|
||||
* Focus: a host that exists ONLY in the runtime catalog (no built-in entry)
|
||||
* must resolve to ITS OWN brand and issuer — never inherit Hanzo's. This is the
|
||||
* osage.id brand-leak regression: the catalog carries `brandUrl`, and the
|
||||
* resolver must map it to `brandPackage` and derive issuer/origin from the host.
|
||||
*/
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { resolveTenant, parseCatalog } from './tenant.ts'
|
||||
|
||||
// Mirrors the K8s ConfigMap shape: entries carry `brandUrl`, not `brandPackage`.
|
||||
const CATALOG = {
|
||||
'lux.id': {
|
||||
orgId: 'lux',
|
||||
clientId: 'lux-cloud',
|
||||
appName: 'lux-cloud',
|
||||
brandUrl: 'https://cdn.jsdelivr.net/npm/@luxfi/brand@latest/brand.json',
|
||||
},
|
||||
'osage.id': {
|
||||
orgId: 'osage',
|
||||
clientId: 'osage-id-portal',
|
||||
appName: 'osage-id',
|
||||
brandUrl: 'https://cdn.jsdelivr.net/npm/@osage/brand@latest/brand.json',
|
||||
},
|
||||
}
|
||||
|
||||
test('a built-in host resolves to its own brand with no catalog', () => {
|
||||
const t = resolveTenant('hanzo.id')
|
||||
assert.equal(t.orgId, 'hanzo')
|
||||
assert.equal(t.brandPackage, '@hanzo/brand')
|
||||
assert.equal(t.iamUrl, 'https://hanzo.id')
|
||||
})
|
||||
|
||||
test('a catalog entry overrides clientId/appName but keeps a consistent brand', () => {
|
||||
const t = resolveTenant('lux.id', { catalog: CATALOG })
|
||||
assert.equal(t.orgId, 'lux')
|
||||
assert.equal(t.clientId, 'lux-cloud')
|
||||
assert.equal(t.brandPackage, '@luxfi/brand')
|
||||
assert.equal(t.iamUrl, 'https://lux.id')
|
||||
assert.equal(t.publicOrigin, 'https://lux.id')
|
||||
})
|
||||
|
||||
test('a catalog-ONLY host does NOT leak the Hanzo brand (osage.id regression)', () => {
|
||||
const t = resolveTenant('osage.id', { catalog: CATALOG })
|
||||
assert.equal(t.orgId, 'osage')
|
||||
assert.equal(t.clientId, 'osage-id-portal')
|
||||
// brandUrl is mapped onto brandPackage, and it is NOT Hanzo's.
|
||||
assert.equal(t.brandPackage, '@osage/brand')
|
||||
assert.notEqual(t.brandPackage, '@hanzo/brand')
|
||||
// issuer + origin are the host itself, never hanzo.id.
|
||||
assert.equal(t.iamUrl, 'https://osage.id')
|
||||
assert.equal(t.iamIssuer, 'https://osage.id')
|
||||
assert.equal(t.publicOrigin, 'https://osage.id')
|
||||
})
|
||||
|
||||
test('pars built-in uses the working pars-console portal app (not the missing pars-id)', () => {
|
||||
const t = resolveTenant('pars.id')
|
||||
assert.equal(t.clientId, 'pars-console')
|
||||
assert.equal(t.brandPackage, '@parsdao/brand')
|
||||
})
|
||||
|
||||
test('osage built-in resolves to Osage even with NO catalog (fallback safety)', () => {
|
||||
const t = resolveTenant('osage.id')
|
||||
assert.equal(t.orgId, 'osage')
|
||||
assert.equal(t.brandPackage, '@osage/brand')
|
||||
assert.notEqual(t.brandPackage, '@hanzo/brand')
|
||||
assert.equal(t.iamUrl, 'https://osage.id')
|
||||
})
|
||||
|
||||
test('an unknown host falls back to the default org but keeps its own origin', () => {
|
||||
const t = resolveTenant('preview.example.com')
|
||||
assert.equal(t.orgId, 'hanzo')
|
||||
assert.equal(t.publicOrigin, 'https://preview.example.com')
|
||||
})
|
||||
|
||||
test('parseCatalog tolerates junk', () => {
|
||||
assert.deepEqual(parseCatalog(undefined), {})
|
||||
assert.deepEqual(parseCatalog(null), {})
|
||||
assert.deepEqual(parseCatalog('not json'), {})
|
||||
assert.deepEqual(parseCatalog('{"osage.id":{"orgId":"osage"}}'), {
|
||||
'osage.id': { orgId: 'osage' },
|
||||
})
|
||||
})
|
||||
@@ -1,208 +0,0 @@
|
||||
import type { TenantConfig } from './types'
|
||||
|
||||
/**
|
||||
* Resolve a TenantConfig by hostname.
|
||||
*
|
||||
* Resolution order (first hit wins):
|
||||
* 1. `IAM_TENANT_CONFIG_JSON` runtime catalog (set in K8s ConfigMap, served
|
||||
* to the browser via `/config.json` at pod startup).
|
||||
* 2. Built-in defaults for the four canonical Hanzo identity hosts.
|
||||
* 3. `IAM_DEFAULT_ORG` (or "hanzo") fallback — used for unknown hosts
|
||||
* (preview deploys, local dev, custom domains pre-launch).
|
||||
*
|
||||
* No hardcoded hostname switches anywhere downstream. Adding a tenant
|
||||
* means editing the runtime catalog, never editing source.
|
||||
*/
|
||||
|
||||
const TRIM_TRAILING_SLASH = (s: string): string => s.replace(/\/+$/, '')
|
||||
|
||||
/**
|
||||
* Built-in tenants for the four canonical identity hosts.
|
||||
*
|
||||
* `iamUrl` is the per-brand OIDC ISSUER — the host that serves
|
||||
* `/.well-known/openid-configuration` and the `/v1/iam/*` surface. Per
|
||||
* HIP-0111 this is the brand's own `*.id` host (hanzo.id / lux.id / …),
|
||||
* NOT `iam.hanzo.ai`: discovery must be host-relative so the SDK never
|
||||
* resolves to the wrong origin (or the IAM SPA HTML catch-all). The IAM
|
||||
* backend tenant-scopes on the `organization` body param; one backend
|
||||
* serves every brand behind its own issuer host.
|
||||
*
|
||||
* `clientId` is the brand `-id` app registered in `init_data.json`
|
||||
* (`hanzo-id`, `lux-id`, …) so the portal authenticates as that app — the
|
||||
* same app whose enabled providers (password + GitHub + Google + Web3)
|
||||
* `get-app-login` reports.
|
||||
*/
|
||||
const DEFAULT_TENANTS: Record<string, TenantConfig> = {
|
||||
'hanzo.id': {
|
||||
orgId: 'hanzo',
|
||||
iamUrl: 'https://hanzo.id',
|
||||
iamIssuer: 'https://hanzo.id',
|
||||
clientId: 'hanzo-id',
|
||||
appName: 'hanzo-id',
|
||||
publicOrigin: 'https://hanzo.id',
|
||||
brandPackage: '@hanzo/brand',
|
||||
},
|
||||
'lux.id': {
|
||||
orgId: 'lux',
|
||||
iamUrl: 'https://lux.id',
|
||||
iamIssuer: 'https://lux.id',
|
||||
clientId: 'lux-id',
|
||||
appName: 'lux-id',
|
||||
publicOrigin: 'https://lux.id',
|
||||
brandPackage: '@luxfi/brand',
|
||||
},
|
||||
'zoo.id': {
|
||||
orgId: 'zoo',
|
||||
iamUrl: 'https://zoo.id',
|
||||
iamIssuer: 'https://zoo.id',
|
||||
clientId: 'zoo-id',
|
||||
appName: 'zoo-id',
|
||||
publicOrigin: 'https://zoo.id',
|
||||
brandPackage: '@zooai/brand',
|
||||
},
|
||||
'pars.id': {
|
||||
orgId: 'pars',
|
||||
iamUrl: 'https://pars.id',
|
||||
iamIssuer: 'https://pars.id',
|
||||
// The portal app is `pars-console` (it carries the https://pars.id/callback
|
||||
// redirect); a bare `pars-id` app does not exist in IAM.
|
||||
clientId: 'pars-console',
|
||||
appName: 'pars-console',
|
||||
publicOrigin: 'https://pars.id',
|
||||
brandPackage: '@parsdao/brand',
|
||||
},
|
||||
// Osage is served by this portal too; without a built-in it would fall back
|
||||
// to the Hanzo default and leak the wrong brand if the runtime catalog ever
|
||||
// fails to load. (osage-id-portal is pre-launch — no IAM app yet — but the
|
||||
// brand must read as Osage, never Hanzo.)
|
||||
'osage.id': {
|
||||
orgId: 'osage',
|
||||
iamUrl: 'https://osage.id',
|
||||
iamIssuer: 'https://osage.id',
|
||||
clientId: 'osage-id-portal',
|
||||
appName: 'osage-id',
|
||||
publicOrigin: 'https://osage.id',
|
||||
brandPackage: '@osage/brand',
|
||||
},
|
||||
'www.osage.id': {
|
||||
orgId: 'osage',
|
||||
iamUrl: 'https://www.osage.id',
|
||||
iamIssuer: 'https://www.osage.id',
|
||||
clientId: 'osage-id-portal',
|
||||
appName: 'osage-id',
|
||||
publicOrigin: 'https://www.osage.id',
|
||||
brandPackage: '@osage/brand',
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* A runtime catalog entry as it appears in the K8s ConfigMap / `/config.json`.
|
||||
* It carries the human-authored shape — notably `brandUrl` (a CDN URL), which
|
||||
* this module maps onto the code-facing `brandPackage`. All fields optional;
|
||||
* whatever is present overrides the host-derived base.
|
||||
*/
|
||||
export type CatalogEntry = Partial<TenantConfig> & {
|
||||
/** CDN URL of the brand package, e.g. `…/npm/@osage/brand@latest/brand.json`. */
|
||||
readonly brandUrl?: string
|
||||
}
|
||||
|
||||
export interface ResolveOptions {
|
||||
/** Optional runtime catalog (parsed from IAM_TENANT_CONFIG_JSON or /config.json). */
|
||||
readonly catalog?: Record<string, CatalogEntry>
|
||||
/** Default org slug when host has no entry. */
|
||||
readonly defaultOrg?: string
|
||||
}
|
||||
|
||||
export function resolveTenant(hostname: string, opts: ResolveOptions = {}): TenantConfig {
|
||||
const host = stripPort(hostname).toLowerCase()
|
||||
const catalogEntry = opts.catalog?.[host]
|
||||
const builtIn = DEFAULT_TENANTS[host]
|
||||
if (catalogEntry || builtIn) {
|
||||
// Base = the built-in tenant if one exists, else a skeleton derived from
|
||||
// THIS host. Never another brand's config: a catalog-only host (osage.id,
|
||||
// zoolabs.id) must not inherit Hanzo's issuer or brand package.
|
||||
const base = builtIn ?? hostSkeleton(host)
|
||||
const merged: TenantConfig = { ...base, ...fromCatalog(catalogEntry) } as TenantConfig
|
||||
return normalize(merged)
|
||||
}
|
||||
const defaultOrg = opts.defaultOrg ?? 'hanzo'
|
||||
const fallback = DEFAULT_TENANTS[`${defaultOrg}.id`] ?? DEFAULT_TENANTS['hanzo.id']
|
||||
return normalize({ ...fallback, publicOrigin: `https://${host}` })
|
||||
}
|
||||
|
||||
/**
|
||||
* A host-derived tenant skeleton for a catalog-only host (no built-in entry).
|
||||
* URLs point at the host itself so nothing leaks from another brand; the
|
||||
* catalog entry spread over this supplies orgId / clientId / appName /
|
||||
* brandPackage. brandPackage defaults empty → the brand loader falls back to a
|
||||
* neutral wordmark rather than showing the wrong brand.
|
||||
*/
|
||||
function hostSkeleton(host: string): TenantConfig {
|
||||
return {
|
||||
orgId: '',
|
||||
iamUrl: `https://${host}`,
|
||||
iamIssuer: `https://${host}`,
|
||||
clientId: '',
|
||||
appName: '',
|
||||
publicOrigin: `https://${host}`,
|
||||
brandPackage: '',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a catalog entry onto a TenantConfig patch, mapping `brandUrl` →
|
||||
* `brandPackage` (the code-facing field) when an explicit `brandPackage` isn't
|
||||
* given. Only defined string fields are emitted, so the host-derived base shows
|
||||
* through for anything the entry omits.
|
||||
*/
|
||||
function fromCatalog(entry: CatalogEntry | undefined): Partial<TenantConfig> {
|
||||
if (!entry) return {}
|
||||
const out: Record<string, string> = {}
|
||||
for (const k of ['orgId', 'loginOrg', 'iamUrl', 'iamIssuer', 'clientId', 'appName', 'publicOrigin', 'oauthCallbackOrigin', 'brandPackage'] as const) {
|
||||
const v = entry[k]
|
||||
if (typeof v === 'string' && v.length > 0) out[k] = v
|
||||
}
|
||||
if (!out.brandPackage && typeof entry.brandUrl === 'string') {
|
||||
const pkg = brandPackageFromUrl(entry.brandUrl)
|
||||
if (pkg) out.brandPackage = pkg
|
||||
}
|
||||
return out as Partial<TenantConfig>
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the npm package name from a CDN brand URL, e.g.
|
||||
* `https://cdn.jsdelivr.net/npm/@osage/brand@latest/brand.json` → `@osage/brand`.
|
||||
*/
|
||||
function brandPackageFromUrl(url: string): string {
|
||||
const m = /\/npm\/(@[^/]+\/[^@/]+|[^@/]+)(?:@|\/)/.exec(url)
|
||||
return m ? m[1]! : ''
|
||||
}
|
||||
|
||||
function stripPort(h: string): string {
|
||||
return h.replace(/:\d+$/, '')
|
||||
}
|
||||
|
||||
function normalize(t: TenantConfig): TenantConfig {
|
||||
const publicOrigin = TRIM_TRAILING_SLASH(t.publicOrigin)
|
||||
return {
|
||||
...t,
|
||||
iamUrl: TRIM_TRAILING_SLASH(t.iamUrl),
|
||||
iamIssuer: TRIM_TRAILING_SLASH(t.iamIssuer || t.iamUrl),
|
||||
publicOrigin,
|
||||
// The social OAuth hop's redirect_uri must hit the provider's registered
|
||||
// callback host. Default to this host; brands sharing a single OAuth client
|
||||
// override it (via the catalog) to that client's registered origin.
|
||||
oauthCallbackOrigin: TRIM_TRAILING_SLASH(t.oauthCallbackOrigin || publicOrigin),
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 {}
|
||||
}
|
||||
}
|
||||
+20
-27
@@ -1,12 +1,12 @@
|
||||
/**
|
||||
* Per-tenant configuration resolved at runtime.
|
||||
* Per-org configuration resolved at runtime.
|
||||
*
|
||||
* One image, many hosts. The portal resolves a TenantConfig for each
|
||||
* One image, many hosts. The portal resolves a OrgConfig for each
|
||||
* incoming request by hostname; the IAM backend, OAuth client id, and
|
||||
* brand package are all wired from this single object.
|
||||
*/
|
||||
export interface TenantConfig {
|
||||
/** Tenant org slug (matches the JWT `owner` claim and the IAM `<org>-<app>` namespace). */
|
||||
export interface OrgConfig {
|
||||
/** Org org slug (matches the JWT `owner` claim and the IAM `<org>-<app>` namespace). */
|
||||
readonly orgId: string
|
||||
/**
|
||||
* OPTIONAL org-resolution anchor for PASSWORD LOGIN only. Unset (the default)
|
||||
@@ -16,7 +16,7 @@ export interface TenantConfig {
|
||||
* their own org). Pinning `orgId` here would resolve a colliding brand-org row
|
||||
* and truncate a global admin to a single org — so the portal leaves this
|
||||
* unset. Set it ONLY for a brand that deliberately scopes its portal login to
|
||||
* one tenant. Does NOT affect signup (which always targets `orgId`) or the
|
||||
* one org. Does NOT affect signup (which always targets `orgId`) or the
|
||||
* apps launcher (which is brand-scoped by `orgId`).
|
||||
*/
|
||||
readonly loginOrg?: string
|
||||
@@ -45,29 +45,22 @@ export interface TenantConfig {
|
||||
/** Optional absolute URL to brand.json (e.g. a jsDelivr-hosted copy from
|
||||
* config.json). Preferred over the app-local /brand/<pkg>/brand.json. */
|
||||
readonly brandUrl?: string
|
||||
/**
|
||||
* Pay origin for this brand — the top-up / plan-purchase surface and its
|
||||
* billing catalog (`GET <payUrl>/v1/billing/plans`). Onboarding's plan step
|
||||
* reads the catalog from here and hands off to it after the choice. Defaults
|
||||
* to `https://pay.hanzo.ai`; a white-label brand sets its own in the runtime
|
||||
* catalog. NO trailing slash. */
|
||||
readonly payUrl?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Brand contract that all per-org brand packages MUST satisfy.
|
||||
* Matches the consumer contract in `@hanzo/brand` / `@luxfi/brand` /
|
||||
* `@zooai/brand` / `@parsdao/brand`. Read from each pkg's `brand.json`.
|
||||
* Brand contract that all per-org brand packages MUST satisfy — the shape the
|
||||
* portal reads from each pkg's `brand.json` (`@hanzo/brand` / `@luxfi/brand` /
|
||||
* `@zooai/brand` / `@parsdao/brand`).
|
||||
*
|
||||
* DRY: this type is NO LONGER defined here. `@hanzo/brand` is the canonical
|
||||
* home (`toBrandContract` projects the registry onto exactly this shape); we
|
||||
* re-export it so there is one contract, not two that can drift.
|
||||
*/
|
||||
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 (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
|
||||
}
|
||||
export type { BrandContract } from '@hanzo/brand/registry'
|
||||
|
||||
Generated
+820
-6165
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
// Single test runner for the whole monorepo. Every `*.test.ts` under any
|
||||
// package's `src/` runs here — the connect crypto/connector suites (vitest
|
||||
// describe/it/expect) and the auth / shared / onboarding suites (bare
|
||||
// `test()` + node:assert). One config, one `pnpm test`, one way.
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['pkgs/**/src/**/*.test.ts', 'apps/**/src/**/*.test.ts'],
|
||||
environment: 'node',
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user