Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad91418abe | ||
|
|
73f431d54e | ||
|
|
22e0cc72e2 | ||
|
|
f85de5d464 | ||
|
|
d423a077c3 | ||
|
|
9b0e7a1ba9 |
@@ -1,9 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,19 @@
|
||||
name: Docker
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main, dev, test]
|
||||
tags: ['v*']
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
jobs:
|
||||
docker:
|
||||
uses: hanzoai/.github/.github/workflows/docker-build.yml@main
|
||||
with:
|
||||
image: ghcr.io/hanzoai/id
|
||||
pre-build-command: |
|
||||
corepack enable
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build
|
||||
secrets: inherit
|
||||
@@ -0,0 +1,7 @@
|
||||
name: Workflow Sanity
|
||||
on:
|
||||
pull_request:
|
||||
paths: ['.github/workflows/**']
|
||||
jobs:
|
||||
sanity:
|
||||
uses: hanzoai/.github/.github/workflows/workflow-sanity.yml@main
|
||||
@@ -1,50 +0,0 @@
|
||||
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
|
||||
@@ -1,67 +0,0 @@
|
||||
name: Sync from GitHub
|
||||
# git.hanzo.ai is CANONICAL and builds natively; development also lands on
|
||||
# github.com/hanzoai/id. Together with the push-mirror going the other way
|
||||
# (native -> GitHub, sync_on_commit) this is the full bidirectional loop.
|
||||
#
|
||||
# The two compose rather than fight: a native commit reaches GitHub via the
|
||||
# push-mirror, so this job then sees LOCAL == REMOTE and exits "in sync". A
|
||||
# GitHub commit fast-forwards native here, and the resulting push-mirror is a
|
||||
# no-op because GitHub already has it. No echo, no loop.
|
||||
#
|
||||
# ONE deterministic direction per job: an in-cluster PULL. The runner reaches
|
||||
# both ends (GitHub outbound, this forge via the instance URL actions/checkout
|
||||
# already uses), so the sync has no ingress dependency.
|
||||
#
|
||||
# Fast-forward ONLY. A divergence fails LOUDLY here rather than force-pushing
|
||||
# either side and destroying whichever history lost the race.
|
||||
on:
|
||||
schedule:
|
||||
- cron: '*/10 * * * *'
|
||||
workflow_dispatch: {}
|
||||
concurrency:
|
||||
group: sync-from-github
|
||||
cancel-in-progress: false
|
||||
jobs:
|
||||
ff-main:
|
||||
runs-on: [hanzo-build-linux-amd64]
|
||||
steps:
|
||||
- name: Checkout main (full history for the ancestry check)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: true
|
||||
- name: Fast-forward main from github.com/hanzoai/id
|
||||
env:
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --quiet "https://x-access-token:${GH_PAT}@github.com/hanzoai/id.git" main
|
||||
LOCAL="$(git rev-parse HEAD)"
|
||||
REMOTE="$(git rev-parse FETCH_HEAD)"
|
||||
if [ "$LOCAL" = "$REMOTE" ]; then
|
||||
echo "in sync at $LOCAL"
|
||||
exit 0
|
||||
fi
|
||||
if git merge-base --is-ancestor "$LOCAL" "$REMOTE"; then
|
||||
echo "fast-forwarding $LOCAL -> $REMOTE"
|
||||
git push origin "$REMOTE:refs/heads/main"
|
||||
# A push made with the workflow token does NOT trigger other workflows
|
||||
# (loop prevention), so synced commits would never build. Dispatch it
|
||||
# explicitly — a real fast-forward means real commits arrived.
|
||||
#
|
||||
# This names the workflow BY FILENAME, so it is a hard reference to a
|
||||
# file in this repo and moves when that file does. It pointed at
|
||||
# deploy.yml, which no longer exists; the `|| echo` below makes that a
|
||||
# non-fatal 404, so the symptom would not have been a red sync — it
|
||||
# would have been commits arriving on the forge and NOTHING building,
|
||||
# silently, which is the exact failure this repo already survived once.
|
||||
curl -fsS --max-time 20 -X POST \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${{ github.server_url }}/v1/repos/${{ github.repository }}/actions/workflows/cicd.yml/dispatches" \
|
||||
-d '{"ref":"main"}' || echo "build dispatch failed (non-fatal)"
|
||||
else
|
||||
echo "DIVERGED: native $LOCAL is not an ancestor of GitHub $REMOTE." >&2
|
||||
echo "Resolve by hand; this job will not force-push either side." >&2
|
||||
exit 1
|
||||
fi
|
||||
+4
-69
@@ -5,81 +5,16 @@ WORKDIR /build
|
||||
ENV PNPM_HOME=/pnpm PATH=$PNPM_HOME:$PATH
|
||||
RUN corepack enable && corepack prepare pnpm@10.15.0 --activate
|
||||
|
||||
# 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 pnpm-workspace.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
|
||||
RUN pnpm install --frozen-lockfile=false
|
||||
|
||||
COPY apps apps
|
||||
COPY pkgs pkgs
|
||||
|
||||
# Publishable event-ingest key (pk-live-…), inlined by Vite into the bundle.
|
||||
#
|
||||
# EVENT_INGEST_KEY is the name in KMS (org `hanzo`, path `deploy`, env `prod`)
|
||||
# and on the --build-arg; the VITE_ prefix is what makes Vite inline it, and it
|
||||
# is a property of THIS build, so it is applied here and the secret store keeps
|
||||
# the ONE plain name.
|
||||
#
|
||||
# Publishable and write-only by design — it authorizes a write into one org and
|
||||
# can read nothing — so shipping it in a bundle is the documented use. It is
|
||||
# still a credential: it comes from KMS via CI. Never commit a value here.
|
||||
#
|
||||
# Deliberately NO default. An absent key is not a degraded mode: cloud takes the
|
||||
# unkeyed beacon down the anonymous lane, files every row under the `$public`
|
||||
# tenant this org cannot read, and answers 200 — so a keyless build looks
|
||||
# healthy from the page and reports nothing to the warehouse. hanzo.id ran that
|
||||
# way with no telemetry at all, which is the failure this build gate exists to
|
||||
# make loud.
|
||||
ARG EVENT_INGEST_KEY
|
||||
ENV VITE_EVENT_INGEST_KEY=$EVENT_INGEST_KEY
|
||||
# Fail closed, and gate HERE because this is the one path every builder passes
|
||||
# through — a guard in a workflow protects that lane only.
|
||||
RUN case "$EVENT_INGEST_KEY" in \
|
||||
pk-*) : ;; \
|
||||
'') echo "EVENT_INGEST_KEY is empty - pass --build-arg EVENT_INGEST_KEY=<pk-...> (KMS deploy/EVENT_INGEST_KEY, env prod)" >&2; exit 1 ;; \
|
||||
*) echo "EVENT_INGEST_KEY is not a publishable key (expected a pk- prefix)" >&2; exit 1 ;; \
|
||||
esac
|
||||
|
||||
# Do NOT re-declare ARG VITE_EVENT_INGEST_KEY below this line. A later ARG of the
|
||||
# same name shadows the ENV set above with an empty default, so the key resolves,
|
||||
# passes the gate, and is then blanked before Vite inlines it — every step green,
|
||||
# the bundle unattributed. That is exactly how hanzo.chat 1.0.58 shipped.
|
||||
#
|
||||
# `&&`, not `;`: with `;` the RUN exits with the status of the LAST command and a
|
||||
# failed build would be masked. Assert on the bytes that actually ship — a key
|
||||
# present in the environment and absent from the bundle is indistinguishable from
|
||||
# success everywhere except the warehouse, where the traffic simply stops being
|
||||
# attributable.
|
||||
RUN pnpm --filter @hanzo/id-web build && \
|
||||
{ grep -rqF "$VITE_EVENT_INGEST_KEY" apps/web/dist || \
|
||||
{ echo "ERROR: the ingest key is not in apps/web/dist - hanzo.id would ship unattributed" >&2; exit 1; }; }
|
||||
RUN pnpm --filter @hanzo/id-web build
|
||||
|
||||
# SPA server stage — hanzoai/spa is the correct base for a Vite SPA:
|
||||
# history-API fallthrough for client-side routes AND a SPA-safe CSP.
|
||||
@@ -87,6 +22,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.4.8
|
||||
FROM ghcr.io/hanzoai/spa:1.2.0
|
||||
COPY --from=build /build/apps/web/dist /public
|
||||
EXPOSE 3000
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
<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
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
# @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.
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"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."
|
||||
}
|
||||
@@ -1,803 +0,0 @@
|
||||
/**
|
||||
* 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));
|
||||
},
|
||||
};
|
||||
@@ -1,12 +0,0 @@
|
||||
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
-3
@@ -4,9 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="robots" content="noindex" />
|
||||
<link id="favicon" rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link id="favicon" rel="icon" type="image/png" href="data:," />
|
||||
<title>Sign in</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
+5
-12
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "@hanzo/id-web",
|
||||
"private": true,
|
||||
"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.",
|
||||
"version": "0.1.10",
|
||||
"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.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -11,25 +11,18 @@
|
||||
"tc": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@crossmarkio/sdk": "^0.4.0",
|
||||
"@hanzo/brand": "^1.3.0",
|
||||
"@hanzo/design": "^0.4.9",
|
||||
"@hanzo/event": "^0.3.11",
|
||||
"@hanzo/iam": "^0.21.1",
|
||||
"@hanzo/gui": "^7.2.4",
|
||||
"@hanzo/iam": "^0.9.4",
|
||||
"@hanzo/id-auth": "workspace:*",
|
||||
"@hanzo/id-connect": "workspace:*",
|
||||
"@hanzo/id-idv": "workspace:*",
|
||||
"@hanzo/id-onboarding": "workspace:*",
|
||||
"@hanzo/id-shared": "workspace:*",
|
||||
"@luxfi/brand": "^1.0.0",
|
||||
"@parsdao/brand": "^1.0.0",
|
||||
"@tanstack/react-router": "^1.168.0",
|
||||
"@tonconnect/sdk": "^4.0.0",
|
||||
"@zooai/brand": "^1.3.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"sats-connect": "^4.2.1",
|
||||
"viem": "^2.53.1"
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.6 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
@@ -1,8 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 67 67" role="img" aria-label="Hanzo">
|
||||
<style>path{fill:#000}@media (prefers-color-scheme:dark){path{fill:#fff}}</style>
|
||||
<path d="M22.21 67V44.6369H0V67H22.21Z"/>
|
||||
<path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z"/>
|
||||
<path d="M22.21 0H0V22.3184H22.21V0Z"/>
|
||||
<path d="M66.7198 0H44.5098V22.3184H66.7198V0Z"/>
|
||||
<path d="M66.7198 67V44.6369H44.5098V67H66.7198Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 443 B |
+20
-53
@@ -1,80 +1,47 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { loadBrand, catalogOf, parseCatalog, resolveOrg, idBrandLabel, type BrandContract, type OrgConfig } from '@hanzo/id-shared'
|
||||
import { loadBrand, parseCatalog, resolveTenant, type BrandContract, type TenantConfig } from '@hanzo/id-shared'
|
||||
import { createAuthClient } from '@hanzo/id-auth'
|
||||
import { Portal } from './pages/Portal'
|
||||
import { Login } from './pages/Login'
|
||||
import { Signup } from './pages/Signup'
|
||||
import { Forgot } from './pages/Forgot'
|
||||
import { Callback } from './pages/Callback'
|
||||
import { Onboarding } from './pages/Onboarding'
|
||||
import { DeviceApproval } from './pages/DeviceApproval'
|
||||
|
||||
/**
|
||||
* Top-level wiring. Resolves org + brand once on mount, then routes via
|
||||
* Top-level wiring. Resolves tenant + brand once on mount, then routes via
|
||||
* `window.location.pathname`. No router lib needed — this app is 5 pages,
|
||||
* `<a href>` is enough. Adding paths is a switch case.
|
||||
*/
|
||||
export function App() {
|
||||
const [org, setOrg] = useState<OrgConfig | null>(null)
|
||||
const [tenant, setTenant] = useState<TenantConfig | 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 org catalog at /config.json — NOT a
|
||||
// `window.__ID_CATALOG__` global, which the runtime never injects
|
||||
// (relying on it silently dropped every catalog-only host, e.g. osage.id,
|
||||
// to the bundled Hanzo default). Fall back to the global, then empty, so a
|
||||
// host always resolves to something.
|
||||
//
|
||||
// `catalogOf` owns which key that payload uses — the name is the server's,
|
||||
// not ours (see its doc). Reading the wrong one is a silent total-catalog
|
||||
// outage, so it is pinned by a test next to the resolver it feeds.
|
||||
let catalogRaw: string | undefined
|
||||
try {
|
||||
const res = await fetch('/config.json', { cache: 'no-store' })
|
||||
if (res.ok) catalogRaw = catalogOf(await res.json())
|
||||
} catch {
|
||||
// network/parse error → fall back below
|
||||
}
|
||||
if (!catalogRaw) {
|
||||
catalogRaw = (window as unknown as { __ID_CATALOG__?: string }).__ID_CATALOG__
|
||||
}
|
||||
const t = resolveOrg(window.location.hostname, { catalog: parseCatalog(catalogRaw) })
|
||||
if (cancelled) return
|
||||
setOrg(t)
|
||||
try {
|
||||
const b = await loadBrand(t.brandPackage)
|
||||
if (cancelled) return
|
||||
const runtimeCatalog = (window as unknown as { __ID_CATALOG__?: string }).__ID_CATALOG__
|
||||
const t = resolveTenant(window.location.hostname, { catalog: parseCatalog(runtimeCatalog) })
|
||||
setTenant(t)
|
||||
loadBrand(t.brandPackage, t.brandUrl)
|
||||
.then((b) => {
|
||||
setBrand(b)
|
||||
document.title = idBrandLabel(b, t.orgId)
|
||||
document.title = `Sign in — ${b.name}`
|
||||
const fav = document.getElementById('favicon') as HTMLLinkElement | null
|
||||
if (fav && b.faviconUrl) fav.href = b.faviconUrl
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(String(e))
|
||||
}
|
||||
}
|
||||
void boot()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
})
|
||||
.catch((e) => setError(String(e)))
|
||||
}, [])
|
||||
|
||||
const client = useMemo(() => (org ? createAuthClient({ org }) : null), [org])
|
||||
const client = useMemo(() => (tenant ? createAuthClient({ tenant }) : null), [tenant])
|
||||
|
||||
if (error) return <div className="hanzo-id-error">{error}</div>
|
||||
if (!org || !brand || !client) return <div>Loading…</div>
|
||||
if (!tenant || !brand || !client) return <div>Loading…</div>
|
||||
|
||||
// undefined = enabled; only an explicit `false` disables self-service signup.
|
||||
const signupEnabled = tenant.signupEnabled !== false
|
||||
const path = window.location.pathname
|
||||
// Device-authorization approval (RFC 8628). Must precede the `/login` catch
|
||||
// since it lives under `/login/oauth/device`.
|
||||
if (path === '/login/oauth/device' || path.startsWith('/login/oauth/device/'))
|
||||
return <DeviceApproval client={client} brand={brand} />
|
||||
if (path === '/login' || path.startsWith('/login/')) return <Login client={client} brand={brand} />
|
||||
if (path === '/signup' || path.startsWith('/signup/')) return <Signup client={client} brand={brand} />
|
||||
if (path === '/forget' || path === '/forgot' || path.startsWith('/forg')) return <Forgot client={client} brand={brand} />
|
||||
if (path === '/callback' || path.startsWith('/callback/')) return <Callback org={org} brand={brand} />
|
||||
if (path === '/onboarding' || path.startsWith('/onboarding/')) return <Onboarding org={org} brand={brand} />
|
||||
return <Portal client={client} brand={brand} org={org} />
|
||||
if (path === '/login' || path.startsWith('/login/')) return <Login client={client} brand={brand} tenant={tenant} signupEnabled={signupEnabled} />
|
||||
if (path === '/signup' || path.startsWith('/signup/')) return signupEnabled ? <Signup client={client} brand={brand} tenant={tenant} /> : <Login client={client} brand={brand} tenant={tenant} signupEnabled={signupEnabled} />
|
||||
if (path === '/forget' || path === '/forgot' || path.startsWith('/forg')) return <Forgot client={client} brand={brand} tenant={tenant} />
|
||||
if (path === '/callback' || path.startsWith('/callback/')) return <Callback client={client} brand={brand} tenant={tenant} />
|
||||
return <Portal client={client} brand={brand} tenant={tenant} signupEnabled={signupEnabled} />
|
||||
}
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
/**
|
||||
* 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')
|
||||
})
|
||||
@@ -1,138 +0,0 @@
|
||||
// Telemetry for the sign-in portal — pageviews and errors, anonymous, via the
|
||||
// ONE @hanzo/event client (POST /v1/event, the front door cloud fans out into
|
||||
// the web / product / error lenses). No page tag, no second SDK.
|
||||
//
|
||||
// This surface reported NOTHING before this file existed, which is why the
|
||||
// arrival->session funnel had no denominator: hanzo.id is where every property's
|
||||
// visitor lands, and none of it was attributable.
|
||||
//
|
||||
// It is also an AUTH surface, so what is NOT here is deliberate:
|
||||
//
|
||||
// - no identify(). Attribution here is anonymous; @hanzo/event stamps a
|
||||
// per-browser `anonymousId` that survives sign-up, so the visitor's
|
||||
// pre-signup pageviews still join to whoever they become once a
|
||||
// post-auth surface (chat/console) identifies them. Reading the IAM
|
||||
// subject would mean wiring this into the auth context for a join that
|
||||
// already happens downstream.
|
||||
// - no interaction autocapture (@hanzo/observe). Heat maps answer "where do
|
||||
// they click"; the question this funnel exists to answer is "did they get a
|
||||
// session", which pageviews answer completely. Autocapture on the login and
|
||||
// signup forms is capture surface bought for no funnel signal.
|
||||
// - no session replay, no input capture, no email/name.
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { AnalyticsProvider as EventProvider, usePageview } from '@hanzo/event/react'
|
||||
|
||||
const HOST = 'https://api.hanzo.ai'
|
||||
|
||||
/**
|
||||
* Publishable ingest key (pk-…), inlined by Vite from the build env. Write-only:
|
||||
* it attributes a write to ONE org and mints no reading principal, which is what
|
||||
* makes it safe in a bundle — and it is the ONLY thing that attributes a
|
||||
* LOGGED-OUT visitor, which on a sign-in portal is nearly all of them.
|
||||
*
|
||||
* Absent is not a degraded mode: cloud takes an unkeyed beacon down the anonymous
|
||||
* lane and files it under `$public`, a tenant this org cannot read, and answers
|
||||
* 200 either way. The loss is silent on both ends, so the Dockerfile fails the
|
||||
* build rather than letting an empty value ship. Never hardcode a value here.
|
||||
*/
|
||||
const INGEST_KEY = import.meta.env.VITE_EVENT_INGEST_KEY?.trim() || undefined
|
||||
|
||||
/**
|
||||
* Routes whose URL carries an authentication artifact.
|
||||
*
|
||||
* `/callback` holds the OAuth authorization `code` and `state`; the device
|
||||
* verification URI holds a `user_code`. Both sit in the QUERY STRING, and
|
||||
* @hanzo/event stamps `url: window.location.href` onto every event it builds —
|
||||
* independently of the `path` a caller passes. So passing a clean pathname does
|
||||
* NOT keep the code out of the payload; only not emitting does. Measured against
|
||||
* the real client, a pageview from `/callback?code=…&state=…` put both values on
|
||||
* the wire in cleartext while `path` read a tidy `/callback`.
|
||||
*
|
||||
* The client's scrubber does not save this either — it redacts secret SHAPES
|
||||
* (JWTs, sk-/pk-/hk-, bearer, cloud keys, PANs) and an opaque authorization code
|
||||
* matches none of them.
|
||||
*
|
||||
* Neither route is a funnel step: both are transient machine hops that redirect
|
||||
* onward within a tick. The funnel is `/` -> `/login` -> `/onboarding`, and every
|
||||
* one of those still reports. Dropping these two costs no signal and removes the
|
||||
* entire class of credential leak. See analytics.test.ts.
|
||||
*/
|
||||
const AUTH_ARTIFACT = /^\/(callback|login\/oauth\/device)(\/|$)/
|
||||
|
||||
/** telemetryAllowed reports whether a path may emit at all. Pure. */
|
||||
export function telemetryAllowed(pathname: string): boolean {
|
||||
return !AUTH_ARTIFACT.test(pathname)
|
||||
}
|
||||
|
||||
/**
|
||||
* consented honours an explicit browser opt-out — Global Privacy Control, then
|
||||
* legacy Do-Not-Track. This is the whole consent surface, and it suppresses
|
||||
* pageviews AND errors together: a visitor who opted out is not "mostly" off.
|
||||
* Pure with respect to its argument so the policy is testable without a DOM.
|
||||
*/
|
||||
export function consented(nav?: {
|
||||
globalPrivacyControl?: boolean
|
||||
doNotTrack?: string | null
|
||||
}): boolean {
|
||||
if (!nav) return true
|
||||
if (nav.globalPrivacyControl === true) return false
|
||||
const dnt = nav.doNotTrack
|
||||
return dnt !== '1' && dnt !== 'yes'
|
||||
}
|
||||
|
||||
/** Reads the live opt-out signals off `navigator`, or none outside a browser. */
|
||||
function browserConsent(): boolean {
|
||||
if (typeof navigator === 'undefined') return true
|
||||
return consented(navigator as Navigator & { globalPrivacyControl?: boolean })
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires a pageview on SPA route changes.
|
||||
*
|
||||
* Inert today, and deliberately kept: this app navigates with
|
||||
* `window.location.assign/replace`, so every route change is a fresh document
|
||||
* and the provider's own initial pageview counts each page exactly once
|
||||
* (`usePageview` skips its first mount for precisely that reason — it would
|
||||
* otherwise double-count). `@tanstack/react-router` is a declared dependency
|
||||
* that nothing imports; the day someone mounts it, navigation stops reloading
|
||||
* the document and this is what keeps pageviews from silently going to zero.
|
||||
*
|
||||
* It is fed the PATHNAME, never `location.href`, and only when the path is
|
||||
* allowed to emit — `usePageview` no-ops on a null path.
|
||||
*/
|
||||
function RouteViews() {
|
||||
const [pathname, setPathname] = useState(() =>
|
||||
typeof window === 'undefined' ? '/' : window.location.pathname,
|
||||
)
|
||||
useEffect(() => {
|
||||
const sync = () => setPathname(window.location.pathname)
|
||||
window.addEventListener('popstate', sync)
|
||||
return () => window.removeEventListener('popstate', sync)
|
||||
}, [])
|
||||
usePageview(telemetryAllowed(pathname) ? pathname : null)
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounts the client. `enabled` is the single gate every plane reads — it stops
|
||||
* init, enqueue, flush and the error handlers alike, so an off state emits
|
||||
* nothing at all rather than emitting less.
|
||||
*
|
||||
* The gate is evaluated once per document, which is exact here BECAUSE
|
||||
* navigation is full-page: the path a document is loaded at is the path it dies
|
||||
* at, so there is no window in which a `/callback` load is measured under an
|
||||
* earlier route's decision.
|
||||
*/
|
||||
export function Analytics({ children }: { children: ReactNode }) {
|
||||
const pathname = typeof window === 'undefined' ? '/' : window.location.pathname
|
||||
const enabled = browserConsent() && telemetryAllowed(pathname)
|
||||
|
||||
return (
|
||||
<EventProvider config={{ product: 'id', host: HOST, ingestKey: INGEST_KEY, enabled }}>
|
||||
<RouteViews />
|
||||
{children}
|
||||
</EventProvider>
|
||||
)
|
||||
}
|
||||
+270
-513
@@ -1,573 +1,330 @@
|
||||
/* 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(--background);
|
||||
color: var(--foreground);
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
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;
|
||||
/* 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));
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.hanzo-id-brand-header { padding: 16px 0 32px; }
|
||||
|
||||
/* The home link wraps a 32px mark but measured 32x18 — an inline <a> takes its
|
||||
box from the LINE BOX of its own font, not from a replaced child, so the tap
|
||||
target was SMALLER than the logo inside it. inline-flex gives the anchor the
|
||||
mark's real box; the padding/negative-margin pair then grows the hit area to
|
||||
the 44px floor while leaving the mark exactly where it was — the padding
|
||||
offsets the margin, so the logo's painted position and the header's height
|
||||
are both unchanged, and only the invisible target bleeds into the gutter. */
|
||||
.hanzo-id-brand-header a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
box-sizing: content-box;
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
padding: 6px;
|
||||
margin: -6px;
|
||||
}
|
||||
|
||||
/* The lockup is 32px tall for EVERY brand, and it has to be said here.
|
||||
BrandHeader writes `height={32}`, but that is a presentational attribute and
|
||||
@hanzo/design's `:where(img,video){height:auto}` overrides it — so the logo
|
||||
was sized by whatever the brand package happened to ship, not by this page.
|
||||
The two assets differ in exactly the way that hides it: @hanzo/brand's SVG
|
||||
carries a viewBox and no width/height, so it has no intrinsic size and landed
|
||||
near the intended 32; @luxfi/brand's declares 1024x1024, so it took the full
|
||||
column and rendered a 342px mark over lux.id's sign-in form. Same markup,
|
||||
same CSS, opposite results, and only the brand nobody was looking at broke.
|
||||
Pinning the height and letting width follow makes the header's own
|
||||
declaration true again whatever a brand ships. */
|
||||
.hanzo-id-brand-header img { height: 32px; width: auto; }
|
||||
|
||||
/* Text fallback when a brand ships no logo asset (see BrandHeader). */
|
||||
.hanzo-id-wordmark {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: var(--weight-bold);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
color: var(--text-primary);
|
||||
.hanzo-id-brand-header {
|
||||
padding: 16px 0 32px;
|
||||
}
|
||||
|
||||
.hanzo-id-page main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
/* 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);
|
||||
.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;
|
||||
}
|
||||
.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); }
|
||||
form input:focus { outline: 2px solid var(--brand); outline-offset: -1px; }
|
||||
|
||||
/* ── Field ─────────────────────────────────────────────────────────── */
|
||||
.hanzo-id-btn, form button {
|
||||
background: var(--fg);
|
||||
color: var(--bg);
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
display: inline-block;
|
||||
}
|
||||
.hanzo-id-btn[aria-disabled='true'], form button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.hanzo-id-btn.primary { background: var(--brand); }
|
||||
|
||||
.hanzo-id-form { display: flex; flex-direction: column; gap: 16px; }
|
||||
.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-field {
|
||||
.hanzo-id-error {
|
||||
background: #2d0a0a;
|
||||
color: #ff7878;
|
||||
border: 1px solid #5a1414;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.hanzo-id-info {
|
||||
background: #0a1f2d;
|
||||
color: #78b8ff;
|
||||
border: 1px solid #14385a;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* --- Social providers + passwordless (email/SMS) login + signup --- */
|
||||
.hanzo-id-login,
|
||||
.hanzo-id-signup,
|
||||
.hanzo-id-code-login {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--muted-foreground);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.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-providers {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.hanzo-id-input::placeholder { color: var(--text-disabled); }
|
||||
.hanzo-id-input:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* iOS Safari ZOOMS the viewport when a focused control's text is under 16px,
|
||||
and it does not zoom back out — the user is left on a magnified, sideways-
|
||||
scrolling sign-in page mid-credential. This rule used to be a COMMENT on
|
||||
font-size above claiming `--text-base` prevented that; --text-base is
|
||||
0.875rem = 14px, so the protection was never in effect on either credential
|
||||
field. The type scale has no 16px rung and should not grow one — 16 is not a
|
||||
design value here, it is the threshold in Safari's own zoom heuristic — so it
|
||||
is written as the literal it is, and scoped to touch-primary pointers so the
|
||||
desktop type ramp is untouched. */
|
||||
@media (pointer: coarse) {
|
||||
.hanzo-id-input { font-size: 16px; }
|
||||
}
|
||||
|
||||
/* Checkbox: the UA control, sized up from its 13px intrinsic box and tinted with
|
||||
the brand. Its touch target is the whole <label> row, which is why it is the
|
||||
one control here that is not 44px itself. */
|
||||
.hanzo-id-check {
|
||||
font: inherit;
|
||||
accent-color: var(--primary);
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
min-height: 0;
|
||||
margin-top: 2px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* ── Button — ONE primitive, two modifiers ─────────────────────────── */
|
||||
/* `.hanzo-id-btn` is filled (the primary action). `.ghost` is the secondary
|
||||
surface — social sign-in, org rows, Skip/Back. `.row` spreads content for a
|
||||
list row. There is no `.primary`: the base IS primary, so there is one and
|
||||
only one way to write the default button. */
|
||||
|
||||
.hanzo-id-btn {
|
||||
font: inherit;
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--weight-semibold);
|
||||
display: inline-flex;
|
||||
.hanzo-id-provider-btn {
|
||||
display: 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;
|
||||
background: #111;
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
transition: background var(--duration-fast) var(--ease-out),
|
||||
border-color var(--duration-fast) var(--ease-out);
|
||||
}
|
||||
.hanzo-id-btn:disabled,
|
||||
.hanzo-id-btn[aria-disabled='true'] { opacity: 0.5; cursor: not-allowed; }
|
||||
.hanzo-id-btn svg { flex: none; }
|
||||
|
||||
/* The filled button had no hover at all. It has declared `transition:
|
||||
background` since it was written and the system has shipped --primary-hover
|
||||
the whole time, so the most-clicked control on the portal — Sign in, Continue,
|
||||
Create account — was animating a property nothing ever changed. The ghost
|
||||
variant below got its hover and this one was simply missed.
|
||||
|
||||
`:not(.ghost)` rather than relying on order: this selector and the ghost
|
||||
hover both compute to (0,4,0), so with a bare `.hanzo-id-btn:hover` the two
|
||||
would tie and source order would decide which surface a ghost button lifts
|
||||
to. Saying which buttons are meant makes them unable to collide. */
|
||||
.hanzo-id-btn:not(.ghost):hover:not(:disabled):not([aria-disabled='true']) {
|
||||
background: var(--primary-hover);
|
||||
.hanzo-id-provider-btn:hover {
|
||||
border-color: var(--muted);
|
||||
background: #161616;
|
||||
}
|
||||
|
||||
.hanzo-id-btn.ghost {
|
||||
background: var(--white-05);
|
||||
color: var(--foreground);
|
||||
border-color: var(--border-strong);
|
||||
font-weight: var(--weight-medium);
|
||||
.hanzo-id-provider-web3 {
|
||||
border-color: #3a3357;
|
||||
}
|
||||
/* Hover is a SURFACE lift, not a brighter edge. This used to also set
|
||||
`border-color: var(--foreground)` — #ededed, 17.9:1 on --background — which
|
||||
painted a near-solid-white 1px wireframe around the two most-hovered controls
|
||||
on the page (Continue with GitHub / Continue with Google). Two problems with
|
||||
moving the edge on hover, and the second is the one that matters:
|
||||
|
||||
1. It read as a wireframe, not as a refined surface. The house hairline is a
|
||||
low-alpha step (--white-10/-15), ~4x quieter than #ededed.
|
||||
2. Brightening an edge is THEME-HOSTILE. Every rung brighter than
|
||||
--neutral-500 is worse on white: --neutral-400 measures 8.3:1 on black but
|
||||
only 2.52:1 on white, which would fail the same WCAG 1.4.11 floor the
|
||||
resting border is documented to hold in BOTH themes. There is no token
|
||||
that gets brighter on dark and darker on light, so the edge must not
|
||||
encode state at all.
|
||||
|
||||
So the border stays the constant control boundary (--border-strong, 4.43:1,
|
||||
both themes) and the background carries the state — the lift the `transition`
|
||||
on .hanzo-id-btn was already animating. */
|
||||
.hanzo-id-btn.ghost:hover:not(:disabled) { background: var(--white-10); }
|
||||
|
||||
.hanzo-id-btn.row { justify-content: space-between; text-align: left; width: 100%; }
|
||||
|
||||
.hanzo-id-cta-row { display: flex; gap: 12px; }
|
||||
.hanzo-id-cta-row .hanzo-id-btn { flex: 1; }
|
||||
|
||||
.hanzo-id-linkbtn {
|
||||
font: inherit;
|
||||
background: none;
|
||||
border: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
padding: 4px 0;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 4px;
|
||||
}
|
||||
|
||||
.hanzo-id-footer-links { color: var(--muted-foreground); font-size: var(--text-sm); }
|
||||
/* "Forgot password?" / "Create account" / "Sign in" / "Back to sign in" are the
|
||||
only route changes on these pages that are not buttons, and they measured 17px
|
||||
tall — the line box of their own text. Vertical padding on an INLINE box is
|
||||
hit-tested but does not enter line-box height, so this buys the 44px target
|
||||
with zero layout movement and no change to the sentences they sit inside.
|
||||
(17 + 14 + 14 = 45.) The 14px bleed stays inside `main`'s 24px gap, so no two
|
||||
targets overlap. Horizontal size already clears 44px on every one of them. */
|
||||
.hanzo-id-footer-links a {
|
||||
color: var(--text-primary);
|
||||
padding: 14px 0;
|
||||
margin: -14px 0;
|
||||
}
|
||||
|
||||
/* A2P SMS consent disclosure (shown on phone/SMS surfaces). */
|
||||
.hanzo-id-sms-consent { color: var(--muted-foreground); font-size: var(--text-xs); line-height: var(--leading-relaxed); }
|
||||
.hanzo-id-sms-consent p { margin: 0 0 6px; }
|
||||
.hanzo-id-sms-consent-links { margin: 0; }
|
||||
.hanzo-id-sms-consent a { color: var(--text-primary); }
|
||||
|
||||
/* ── Inline message ────────────────────────────────────────────────── */
|
||||
/* Red is one of the two hues @hanzo/design permits, and these are the system's
|
||||
own state tokens — the same #fca5a5 pay.hanzo.ai renders errors in. The
|
||||
informational variant used to be a blue (#78b8ff) that exists nowhere in the
|
||||
system; on a monochrome surface "information" is a neutral card. */
|
||||
|
||||
.hanzo-id-error,
|
||||
.hanzo-id-info {
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--leading-sm);
|
||||
margin: 0;
|
||||
}
|
||||
.hanzo-id-error {
|
||||
background: var(--state-error-bg);
|
||||
color: var(--state-error-text);
|
||||
border: 1px solid var(--state-error);
|
||||
}
|
||||
.hanzo-id-info {
|
||||
background: var(--card);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--white-10);
|
||||
}
|
||||
|
||||
/* ── Loading ───────────────────────────────────────────────────────── */
|
||||
/* `.hanzo-id-spinner` used to be a class with NO rule behind it: the loading
|
||||
state measured 0px tall and was invisible on every portal. */
|
||||
|
||||
.hanzo-id-spinner {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin: 0 auto;
|
||||
border: 2px solid var(--white-15);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: var(--radius-full);
|
||||
animation: hanzo-id-spin 700ms linear infinite;
|
||||
}
|
||||
@keyframes hanzo-id-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ── Device-authorization approval ─────────────────────────────────── */
|
||||
|
||||
.hanzo-id-device main { gap: 18px; }
|
||||
.hanzo-id-device-prompt {
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--leading-relaxed);
|
||||
margin: 0;
|
||||
}
|
||||
.hanzo-id-device-prompt strong { color: var(--text-primary); }
|
||||
.hanzo-id-device-code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xl);
|
||||
letter-spacing: var(--tracking-widest);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.hanzo-id-device-confirm {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--leading-relaxed);
|
||||
}
|
||||
|
||||
/* ── Forced TOTP enrollment ────────────────────────────────────────── */
|
||||
|
||||
.hanzo-id-mfa-enroll { display: flex; flex-direction: column; gap: 16px; }
|
||||
.hanzo-id-mfa-enroll h2 { margin: 0; font-size: var(--text-xl); }
|
||||
.hanzo-id-mfa-qr {
|
||||
align-self: center;
|
||||
background: var(--pure-white);
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-lg);
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
.hanzo-id-mfa-qr svg { width: 100%; height: 100%; display: block; }
|
||||
.hanzo-id-mfa-manual { font-size: var(--text-sm); color: var(--muted-foreground); }
|
||||
.hanzo-id-mfa-manual summary { cursor: pointer; }
|
||||
.hanzo-id-mfa-secret,
|
||||
.hanzo-id-mfa-recovery code {
|
||||
display: inline-block;
|
||||
margin-top: 8px;
|
||||
padding: 6px 10px;
|
||||
background: var(--white-05);
|
||||
border: 1px solid var(--white-10);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-mono);
|
||||
letter-spacing: var(--tracking-widest);
|
||||
word-break: break-all;
|
||||
}
|
||||
.hanzo-id-mfa-recovery { font-size: var(--text-sm); color: var(--muted-foreground); line-height: var(--leading-relaxed); }
|
||||
.hanzo-id-mfa-recovery code { letter-spacing: var(--tracking-normal); }
|
||||
|
||||
/* ── Social / Web3 sign-in ─────────────────────────────────────────── */
|
||||
/* These are `.hanzo-id-btn.ghost` — there is no separate social-button surface. */
|
||||
|
||||
.hanzo-id-social { display: flex; flex-direction: column; gap: 10px; }
|
||||
|
||||
/* Wallet chain chooser — revealed under the single "Connect Wallet" button when
|
||||
the injected chain is ambiguous. Indented so the EVM/Solana options read as
|
||||
children of the wallet entry. */
|
||||
.hanzo-id-wallet-chains {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-left: 4px;
|
||||
padding-left: 12px;
|
||||
border-left: 1px solid var(--white-10);
|
||||
}
|
||||
|
||||
/* Labeled divider between social row and email form. */
|
||||
.hanzo-id-divider {
|
||||
.hanzo-id-or {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--text-sm);
|
||||
gap: 12px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.hanzo-id-divider::before,
|
||||
.hanzo-id-divider::after {
|
||||
.hanzo-id-or::before,
|
||||
.hanzo-id-or::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--white-10);
|
||||
background: var(--border);
|
||||
}
|
||||
.hanzo-id-divider span { padding: 0 12px; }
|
||||
|
||||
/* ── Signed-in portal: the apps launcher ───────────────────────────── */
|
||||
/* These rules used to be an inline `style={{…}}` object on Portal.tsx carrying
|
||||
rgba(255,255,255,0.14), borderRadius 12, fontSize 13, fontWeight 600 and two
|
||||
bare opacities — six invented values for facts the token layer already
|
||||
states. They are classes now for the same reason as everything else in this
|
||||
file: a surface must not depend on where it is mounted. */
|
||||
|
||||
.hanzo-id-portal main { width: 100%; max-width: 760px; }
|
||||
|
||||
.hanzo-id-apps {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-6);
|
||||
}
|
||||
|
||||
.hanzo-id-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,
|
||||
.hanzo-id-onboarding-done h1 { margin: 0; font-size: var(--text-2xl); letter-spacing: var(--tracking-tight); }
|
||||
.hanzo-id-onboarding-body { display: flex; flex-direction: column; gap: 16px; }
|
||||
.hanzo-id-onboarding-done { display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
.hanzo-id-stepdots { display: flex; gap: 8px; }
|
||||
.hanzo-id-stepdots span {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--white-10);
|
||||
}
|
||||
.hanzo-id-stepdots span.on { background: var(--primary); }
|
||||
|
||||
.hanzo-id-slug-preview {
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--text-sm);
|
||||
margin: -8px 0 0;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.hanzo-id-onboarding-actions { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
.hanzo-id-onboarding-actions .hanzo-id-btn { flex: 1; min-width: 120px; }
|
||||
|
||||
.hanzo-id-summary { display: grid; grid-template-columns: auto 1fr; gap: 8px 16px; margin: 0; }
|
||||
.hanzo-id-summary dt { color: var(--muted-foreground); font-size: var(--text-sm); }
|
||||
.hanzo-id-summary dd { margin: 0; font-size: var(--text-sm); font-family: var(--font-mono); }
|
||||
|
||||
/* Consent step */
|
||||
.hanzo-id-consent { display: flex; flex-direction: column; gap: 16px; }
|
||||
.hanzo-id-consent p { margin: 0; color: var(--muted-foreground); font-size: var(--text-sm); line-height: var(--leading-normal); }
|
||||
.hanzo-id-consent-check {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border-control);
|
||||
border-radius: var(--radius-lg);
|
||||
.hanzo-id-linkbtn {
|
||||
background: none;
|
||||
border: 0;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
width: auto;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
font-size: var(--text-sm);
|
||||
text-align: center;
|
||||
}
|
||||
.hanzo-id-linkbtn:hover {
|
||||
color: var(--fg);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.hanzo-id-toggle-mode {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.hanzo-id-notice {
|
||||
background: #0a1f2d;
|
||||
color: #78b8ff;
|
||||
border: 1px solid #14385a;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.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 {
|
||||
/* ============================================================
|
||||
Split-view login (form left, marketing panel right)
|
||||
Ported from legacy-nextjs/app/login + components/MarketingPanel
|
||||
============================================================ */
|
||||
.hanzo-id-split {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.hanzo-id-split-form {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px;
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.hanzo-id-split-form { width: 50%; }
|
||||
}
|
||||
.hanzo-id-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
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;
|
||||
gap: 24px;
|
||||
}
|
||||
.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-card-head { display: flex; align-items: center; justify-content: space-between; }
|
||||
.hanzo-id-card-head img { display: block; }
|
||||
.hanzo-id-card-title { margin: 0; font-size: 28px; font-weight: 700; }
|
||||
|
||||
/* Right panel — hidden below lg, gradient backdrop above */
|
||||
.hanzo-id-split-brand {
|
||||
display: none;
|
||||
}
|
||||
.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); }
|
||||
@media (min-width: 1024px) {
|
||||
.hanzo-id-split-brand {
|
||||
width: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px;
|
||||
background: linear-gradient(135deg, #000 0%, #18181b 50%, #000 100%);
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
.hanzo-id-marketing {
|
||||
max-width: 28rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
}
|
||||
.hanzo-id-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
align-self: flex-start;
|
||||
padding: 6px 14px;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid #3f3f46;
|
||||
font-size: 14px;
|
||||
color: #d4d4d8;
|
||||
}
|
||||
.hanzo-id-pill-star { color: #facc15; }
|
||||
.hanzo-id-marketing-title { margin: 0; font-size: 36px; line-height: 1.1; font-weight: 700; color: #fff; }
|
||||
.hanzo-id-marketing-subtitle { margin: 0; font-size: 18px; color: #a1a1aa; }
|
||||
|
||||
.hanzo-id-quote-card {
|
||||
background: rgba(24, 24, 27, 0.5);
|
||||
border: 1px solid #27272a;
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
}
|
||||
.hanzo-id-quote-card blockquote { margin: 0 0 16px; color: #fff; font-size: 16px; line-height: 1.6; }
|
||||
.hanzo-id-quote-mark { color: #52525b; font-size: 22px; }
|
||||
.hanzo-id-quote-author { display: flex; align-items: center; gap: 12px; }
|
||||
.hanzo-id-quote-avatar {
|
||||
width: 40px; height: 40px; border-radius: 9999px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: #09090b; font-weight: 600; font-size: 14px; flex: 0 0 auto;
|
||||
}
|
||||
.hanzo-id-quote-name { font-weight: 600; color: #fff; }
|
||||
.hanzo-id-quote-role { font-size: 14px; color: #71717a; }
|
||||
.hanzo-id-quote-dots { display: flex; justify-content: center; gap: 8px; margin-top: 16px; }
|
||||
.hanzo-id-quote-dot {
|
||||
width: 8px; height: 8px; border-radius: 9999px; border: 0;
|
||||
background: #52525b; cursor: pointer; padding: 0;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Authenticated portal — apps launcher ("all the apps")
|
||||
Ported from legacy-nextjs/app/account/page.tsx
|
||||
============================================================ */
|
||||
.hanzo-id-loading {
|
||||
flex: 1; min-height: 100vh;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: #000;
|
||||
}
|
||||
.hanzo-id-spinner {
|
||||
width: 32px; height: 32px; border-radius: 9999px;
|
||||
border: 2px solid #3f3f46; border-top-color: #fff;
|
||||
animation: hanzo-id-spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes hanzo-id-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.hanzo-id-portal-authed { flex: 1; min-height: 100vh; }
|
||||
.hanzo-id-nav {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 16px 24px; border-bottom: 1px solid rgba(39, 39, 42, 0.5);
|
||||
}
|
||||
.hanzo-id-nav img { display: block; }
|
||||
.hanzo-id-nav-links { display: flex; align-items: center; gap: 20px; }
|
||||
.hanzo-id-nav-links a { color: #a1a1aa; font-size: 14px; text-decoration: none; }
|
||||
.hanzo-id-nav-links a:hover { color: #fff; }
|
||||
|
||||
.hanzo-id-portal-body { max-width: 56rem; margin: 0 auto; padding: 48px 24px; }
|
||||
.hanzo-id-profile { display: flex; align-items: center; gap: 24px; margin-bottom: 48px; }
|
||||
.hanzo-id-profile-avatar { width: 80px; height: 80px; border-radius: 9999px; object-fit: cover; }
|
||||
.hanzo-id-profile-avatar-fallback {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.08); font-size: 30px; font-weight: 700;
|
||||
}
|
||||
.hanzo-id-profile h1 { margin: 0; font-size: 30px; font-weight: 700; color: #fff; }
|
||||
.hanzo-id-profile .lede { margin: 4px 0 0; }
|
||||
|
||||
.hanzo-id-section-title { margin: 0 0 16px; font-size: 18px; font-weight: 600; color: #fff; }
|
||||
.hanzo-id-apps-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
@media (min-width: 640px) { .hanzo-id-apps-grid { grid-template-columns: repeat(2, 1fr); } }
|
||||
@media (min-width: 1024px) { .hanzo-id-apps-grid { grid-template-columns: repeat(3, 1fr); } }
|
||||
.hanzo-id-app-card {
|
||||
display: block;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #27272a;
|
||||
background: rgba(24, 24, 27, 0.3);
|
||||
text-decoration: none;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.hanzo-id-app-card:hover { background: rgba(24, 24, 27, 0.6); border-color: #3f3f46; }
|
||||
.hanzo-id-app-card-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
||||
.hanzo-id-app-name { font-weight: 600; color: #fff; }
|
||||
.hanzo-id-app-arrow { color: #52525b; font-size: 16px; }
|
||||
.hanzo-id-app-card:hover .hanzo-id-app-arrow { color: #a1a1aa; }
|
||||
.hanzo-id-app-desc { margin: 0; font-size: 14px; color: #71717a; }
|
||||
|
||||
.hanzo-id-portal-footer {
|
||||
margin-top: 48px; padding-top: 32px; border-top: 1px solid #27272a;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
.hanzo-id-muted-link { color: #71717a; font-size: 14px; text-decoration: none; }
|
||||
.hanzo-id-muted-link:hover { color: #fff; }
|
||||
.hanzo-id-btn.ghost {
|
||||
background: transparent; color: #a1a1aa;
|
||||
border: 1px solid #3f3f46; font-size: 14px; padding: 8px 16px;
|
||||
}
|
||||
.hanzo-id-btn.ghost:hover { color: #fff; border-color: #71717a; }
|
||||
|
||||
@@ -1,24 +1,11 @@
|
||||
import { useState } from 'react'
|
||||
import type { BrandContract } from '@hanzo/id-shared'
|
||||
import type { BrandContract, TenantConfig } from '@hanzo/id-shared'
|
||||
import { BrandLogo } from './BrandLogo'
|
||||
|
||||
/**
|
||||
* Brand lockup for the auth pages. Renders the brand logo when it loads, and
|
||||
* falls back to the brand NAME as a text wordmark when the logo is absent or
|
||||
* fails to load — so a 404 logo (e.g. a brand package that doesn't ship its
|
||||
* assets) never shows a broken-image icon. The name always exists on the
|
||||
* brand contract, so the header is always presentable.
|
||||
*/
|
||||
export function BrandHeader({ brand }: { brand: BrandContract }) {
|
||||
const [imgOk, setImgOk] = useState(true)
|
||||
const showImg = Boolean(brand.logoUrl) && imgOk
|
||||
export function BrandHeader({ brand, tenant }: { brand: BrandContract; tenant: TenantConfig }) {
|
||||
return (
|
||||
<header className="hanzo-id-brand-header">
|
||||
<a href="/" aria-label={brand.name}>
|
||||
{showImg ? (
|
||||
<img src={brand.logoUrl} alt={brand.name} height={32} onError={() => setImgOk(false)} />
|
||||
) : (
|
||||
<span className="hanzo-id-wordmark">{brand.name}</span>
|
||||
)}
|
||||
<BrandLogo brand={brand} tenant={tenant} height={32} />
|
||||
</a>
|
||||
</header>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useState } from 'react'
|
||||
import type { BrandContract, TenantConfig } from '@hanzo/id-shared'
|
||||
|
||||
/**
|
||||
* Brand logo with a self-hosted fallback.
|
||||
*
|
||||
* `brand.logoUrl` is a CDN URL (jsdelivr). When that 404s or is blocked, we
|
||||
* fall back to the brand package's logo shipped inside this image at
|
||||
* `/brand/<pkg>/assets/logo/logo.svg` — the same package `loadBrand` fetches,
|
||||
* served by the same `hanzoai/spa` server. No external dependency required.
|
||||
*/
|
||||
export function BrandLogo({
|
||||
brand,
|
||||
tenant,
|
||||
height = 32,
|
||||
}: {
|
||||
brand: BrandContract
|
||||
tenant: TenantConfig
|
||||
height?: number
|
||||
}) {
|
||||
const local = `/brand/${encodeURIComponent(tenant.brandPackage)}/assets/logo/logo.svg`
|
||||
const [src, setSrc] = useState(brand.logoUrl || local)
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={brand.name}
|
||||
height={height}
|
||||
style={{ height, width: 'auto', display: 'block' }}
|
||||
onError={() => {
|
||||
if (src !== local) setSrc(local)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { Marketing } from '../marketing'
|
||||
|
||||
/**
|
||||
* Right-hand branding panel for the split-view login. Ported from the frozen
|
||||
* `legacy-nextjs/components/MarketingPanel.tsx`: a tagline pill, hero copy, and
|
||||
* an auto-rotating testimonial card. Accent color comes from the brand
|
||||
* contract; copy comes from the per-org marketing map.
|
||||
*/
|
||||
export function MarketingPanel({ marketing, accent }: { marketing: Marketing; accent: string }) {
|
||||
const quotes = marketing.quotes
|
||||
const [i, setI] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (quotes.length <= 1) return
|
||||
const t = setInterval(() => setI((p) => (p + 1) % quotes.length), 5000)
|
||||
return () => clearInterval(t)
|
||||
}, [quotes.length])
|
||||
|
||||
const q = quotes[i]
|
||||
|
||||
return (
|
||||
<div className="hanzo-id-marketing">
|
||||
{marketing.tagline ? (
|
||||
<div className="hanzo-id-pill">
|
||||
<span className="hanzo-id-pill-star">✦</span>
|
||||
<span>{marketing.tagline}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<h2 className="hanzo-id-marketing-title">{marketing.title}</h2>
|
||||
<p className="hanzo-id-marketing-subtitle">{marketing.subtitle}</p>
|
||||
|
||||
{q ? (
|
||||
<div className="hanzo-id-quote-card">
|
||||
<blockquote>
|
||||
<span className="hanzo-id-quote-mark">“</span>
|
||||
{q.text}
|
||||
<span className="hanzo-id-quote-mark">”</span>
|
||||
</blockquote>
|
||||
<div className="hanzo-id-quote-author">
|
||||
<div className="hanzo-id-quote-avatar" style={{ backgroundColor: accent }}>
|
||||
{q.author
|
||||
.split(' ')
|
||||
.map((n) => n[0])
|
||||
.join('')
|
||||
.slice(0, 2)
|
||||
.toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<div className="hanzo-id-quote-name">{q.author}</div>
|
||||
{q.role ? <div className="hanzo-id-quote-role">{q.role}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
{quotes.length > 1 ? (
|
||||
<div className="hanzo-id-quote-dots">
|
||||
{quotes.map((_, n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
aria-label={`Show testimonial ${n + 1}`}
|
||||
className="hanzo-id-quote-dot"
|
||||
onClick={() => setI(n)}
|
||||
style={{ backgroundColor: n === i ? accent : undefined }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+1
-10
@@ -1,7 +1,6 @@
|
||||
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'
|
||||
@@ -13,14 +12,6 @@ const root = document.getElementById('root')
|
||||
if (!root) throw new Error('#root missing')
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
{/*
|
||||
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>
|
||||
<App />
|
||||
</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 `org.orgId` so it stays decoupled from
|
||||
* content + `orgApps`). Keyed by `tenant.orgId` so it stays decoupled from
|
||||
* hostname switches; unknown orgs fall back to `hanzo`.
|
||||
*/
|
||||
|
||||
@@ -67,23 +67,14 @@ 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: 'App', href: 'https://hanzo.app', description: 'Build with AI' },
|
||||
{ name: 'Console', href: 'https://console.hanzo.ai', description: 'Observability & traces' },
|
||||
{ name: 'Chat', href: 'https://hanzo.chat', description: 'AI chat interface' },
|
||||
{ name: 'Cloud', href: 'https://cloud.hanzo.ai', description: 'Models, compute & API' },
|
||||
{ name: 'Cloud', href: 'https://cloud.hanzo.ai', description: 'AI model API' },
|
||||
{ name: 'Analytics', href: 'https://analytics.hanzo.ai', description: 'Web analytics' },
|
||||
{ name: 'Platform', href: 'https://platform.hanzo.ai', description: 'PaaS deployments' },
|
||||
{ name: 'Storage', href: 'https://s3.hanzo.ai', description: 'S3-compatible storage' },
|
||||
],
|
||||
lux: [
|
||||
{ name: 'Bridge', href: 'https://bridge.lux.network', description: 'Cross-chain bridge' },
|
||||
|
||||
@@ -1,58 +1,37 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { BrandContract, OrgConfig } from '@hanzo/id-shared'
|
||||
import { createIam } from '@hanzo/id-auth'
|
||||
import type { BrandContract, TenantConfig } from '@hanzo/id-shared'
|
||||
import type { AuthClient } from '@hanzo/id-auth'
|
||||
import { BrandHeader } from '../components/BrandHeader'
|
||||
|
||||
/**
|
||||
* OAuth/OIDC callback — the portal's OWN PKCE return, and only that.
|
||||
*
|
||||
* One kind of return lands here: an IAM authorization code for a flow this
|
||||
* portal started (password, wallet, or a federated provider begun through
|
||||
* `signinRedirect`). The `@hanzo/iam` SDK's `handleCallback` completes it,
|
||||
* reading back the exact PKCE verifier and state it stored.
|
||||
*
|
||||
* There is no second, social-specific case. A federated sign-in returns from the
|
||||
* IdP to IAM's OWN callback (`/v1/iam/oauth/callback`), which does the code
|
||||
* exchange server-side and sends the browser back here with an ordinary IAM code
|
||||
* — indistinguishable from any other. The page used to carry a branch that
|
||||
* decoded a base64 provider `state` and posted the raw IdP code back to IAM; no
|
||||
* endpoint ever accepted that, and nothing can produce that state any more.
|
||||
*
|
||||
* Routing after the exchange:
|
||||
* - A non-OIDC "come back here" target left in `post_login_redirect` (device
|
||||
* approval) → forward tokens there.
|
||||
* - A bare portal sign-in → `/onboarding`.
|
||||
*
|
||||
* An app that sent the user here for a code never reaches this page at all: that
|
||||
* flow re-enters IAM's authorize endpoint and IAM redirects straight to the app.
|
||||
*/
|
||||
export function Callback({ org, brand }: { org: OrgConfig; brand: BrandContract }) {
|
||||
export function Callback({ client, brand, tenant }: { client: AuthClient; brand: BrandContract; tenant: TenantConfig }) {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
useEffect(() => {
|
||||
const iam = createIam(org)
|
||||
iam
|
||||
.handleCallback(window.location.href)
|
||||
const sp = new URLSearchParams(window.location.search)
|
||||
const code = sp.get('code')
|
||||
if (!code) {
|
||||
setError('Missing authorization code.')
|
||||
return
|
||||
}
|
||||
const codeVerifier = sessionStorage.getItem('pkce_verifier') ?? undefined
|
||||
client
|
||||
.exchange(code, codeVerifier)
|
||||
.then((tok) => {
|
||||
const target = sessionStorage.getItem('post_login_redirect')
|
||||
// Forward the tokens to whichever app initiated this flow.
|
||||
const target = sessionStorage.getItem('post_login_redirect') ?? '/'
|
||||
const url = new URL(target, window.location.origin)
|
||||
url.searchParams.set('access_token', tok.accessToken)
|
||||
if (tok.refreshToken) url.searchParams.set('refresh_token', tok.refreshToken)
|
||||
if (tok.idToken) url.searchParams.set('id_token', tok.idToken)
|
||||
sessionStorage.removeItem('pkce_verifier')
|
||||
sessionStorage.removeItem('post_login_redirect')
|
||||
if (target) {
|
||||
// Forward tokens to the page that sent the user to sign in.
|
||||
const url = new URL(target, window.location.origin)
|
||||
url.searchParams.set('access_token', tok.accessToken)
|
||||
if (tok.refreshToken) url.searchParams.set('refresh_token', tok.refreshToken)
|
||||
if (tok.idToken) url.searchParams.set('id_token', tok.idToken)
|
||||
window.location.replace(url.toString())
|
||||
return
|
||||
}
|
||||
// Bare portal sign-in → onboarding.
|
||||
window.location.replace('/onboarding')
|
||||
window.location.replace(url.toString())
|
||||
})
|
||||
.catch((e) => setError(String(e)))
|
||||
}, [org])
|
||||
}, [client])
|
||||
|
||||
return (
|
||||
<div className="hanzo-id-page hanzo-id-callback">
|
||||
<BrandHeader brand={brand} />
|
||||
<BrandHeader brand={brand} tenant={tenant} />
|
||||
<main>
|
||||
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : <p>Completing sign-in…</p>}
|
||||
</main>
|
||||
|
||||
@@ -1,293 +0,0 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import type { BrandContract } from '@hanzo/id-shared'
|
||||
import { LoginForm, SocialButtons, type AuthClient, type DeviceInfoResult } from '@hanzo/id-auth'
|
||||
import { BrandHeader } from '../components/BrandHeader'
|
||||
|
||||
/**
|
||||
* RFC 8628 device-authorization approval (`/login/oauth/device`).
|
||||
*
|
||||
* The terminal leg of `hanzo login`: the CLI shows a short `user_code` and sends
|
||||
* the human here (the IAM `verification_uri`; `verification_uri_complete`
|
||||
* appends the code as a PATH segment, `/login/oauth/device/<code>` — IAM builds
|
||||
* it that way because that is the route this page is registered on, and
|
||||
* `readUserCode` accepts the `?user_code=` query form too). The human signs in
|
||||
* to the SAME issuer, confirms the code matches what their device shows, and
|
||||
* approves — which binds their identity onto the pending row (`Token.User`,
|
||||
* owner/name) so the CLI's token poll stops answering `authorization_pending`
|
||||
* and mints. There is no `UserSignIn` flag; an empty `User` IS "not yet
|
||||
* approved".
|
||||
*
|
||||
* Auth is reused, never reimplemented: not-signed-in renders the normal
|
||||
* `<LoginForm>` + `<SocialButtons>`; once the issuer session cookie is set the
|
||||
* page reads it back from `/v1/iam/get-account` and shows the confirm step.
|
||||
* Approval rides that session cookie (`client.approveDevice`), so no token ever
|
||||
* touches the URL or logs.
|
||||
*
|
||||
* The screen exists to answer ONE question — which application am I authorizing?
|
||||
* — so the application it names is read from the code (`client.deviceInfo`) and
|
||||
* from nowhere else. Until IAM has named one there is no name on screen and no
|
||||
* button to press.
|
||||
*/
|
||||
|
||||
type Phase =
|
||||
| { s: 'checking' }
|
||||
| { s: 'signin' }
|
||||
| { s: 'confirm'; email?: string }
|
||||
| { s: 'consent'; email?: string }
|
||||
| { s: 'approving' }
|
||||
| { s: 'approved' }
|
||||
|
||||
/** Read the user_code from `?user_code=` first, then a trailing path segment
|
||||
* (`/login/oauth/device/<code>`) so both the complete and bare verification
|
||||
* URIs work; absent → the user types it. */
|
||||
function readUserCode(): string {
|
||||
const fromQuery = new URLSearchParams(window.location.search).get('user_code')
|
||||
if (fromQuery) return fromQuery
|
||||
const m = window.location.pathname.match(/\/login\/oauth\/device\/([^/?#]+)/)
|
||||
return m ? decodeURIComponent(m[1]!) : ''
|
||||
}
|
||||
|
||||
/** A device-flow return must never leave tokens/codes sitting in the address
|
||||
* bar (history, referrer, shoulder-surf). Strip everything but `user_code`. */
|
||||
function scrubUrl() {
|
||||
const url = new URL(window.location.href)
|
||||
let changed = false
|
||||
for (const k of ['access_token', 'refresh_token', 'id_token', 'code', 'state']) {
|
||||
if (url.searchParams.has(k)) {
|
||||
url.searchParams.delete(k)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) window.history.replaceState({}, '', url.toString())
|
||||
}
|
||||
|
||||
export function DeviceApproval({ client, brand }: { client: AuthClient; brand: BrandContract }) {
|
||||
const [phase, setPhase] = useState<Phase>({ s: 'checking' })
|
||||
const [userCode, setUserCode] = useState(() => readUserCode())
|
||||
// The code is prefilled from `?user_code=` and stays EDITABLE, which is the
|
||||
// anti-phishing property that matters: approving is an explicit click on a
|
||||
// code the human can read and correct against what their own device shows.
|
||||
// There was also a "I started this sign-in" checkbox in front of that click.
|
||||
// No device page anyone actually uses has one — Google, GitHub and AWS all
|
||||
// show the code and an Approve button — and a tickbox is not evidence: a
|
||||
// victim being walked through a crafted link ticks it as readily as they
|
||||
// click Approve. It bought nothing and cost every honest user a step.
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
// WHICH application is asking — the whole point of this screen, and the one
|
||||
// thing the page cannot know on its own. It used to render `org.appName`: this
|
||||
// PORTAL's own branding, a static per-org string, so a sign-in started by
|
||||
// hanzo-cli was approved under a screen reading "hanzo-console". The client is
|
||||
// a property of the CODE (it lives on the pending row and is what the backend
|
||||
// actually approves), so it is read from the code — `client.deviceInfo`,
|
||||
// IAM `POST /v1/iam/oauth/device/info`.
|
||||
//
|
||||
// That read is session-gated and answers with one opaque refusal for unknown /
|
||||
// expired / already-approved, so it is no oracle for hunting live codes: it
|
||||
// tells a caller strictly less than the approval that same caller could already
|
||||
// attempt.
|
||||
//
|
||||
// null = not resolved yet. NOTHING is rendered in its place — no fallback name,
|
||||
// no portal name, no guess. Naming the wrong party is the defect being fixed
|
||||
// here, and a screen that names none is strictly better than one that lies.
|
||||
const [app, setApp] = useState<DeviceInfoResult | null>(null)
|
||||
const named = app?.ok ? app : null
|
||||
|
||||
// Resolve the issuer session: signed in → confirm, else → sign-in form. Reads
|
||||
// same-origin from `/v1/iam/get-account` (cookie session; the brand `*.id`
|
||||
// host IS `iamUrl`, so the cookie rides along) — identical to the Portal.
|
||||
useEffect(() => {
|
||||
scrubUrl()
|
||||
let alive = true
|
||||
fetch(new URL('/v1/iam/get-account', client.org.iamUrl).toString(), {
|
||||
credentials: 'include',
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((b: Record<string, unknown>) => {
|
||||
if (!alive) return
|
||||
const d = b.data as Record<string, unknown> | undefined
|
||||
if (b.status === 'ok' && d && typeof d === 'object') {
|
||||
setPhase({ s: 'confirm', email: str(d.email) ?? str(d.name) })
|
||||
} else {
|
||||
setPhase({ s: 'signin' })
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (alive) setPhase({ s: 'signin' })
|
||||
})
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [client.org.iamUrl])
|
||||
|
||||
// Ask WHICH application the code belongs to. Needs both halves of what the
|
||||
// endpoint is gated on: the issuer session (every phase past the check has one
|
||||
// except `signin`, and the boolean keeps consent/approving from re-asking) and
|
||||
// a code to ask about.
|
||||
//
|
||||
// The debounce is what makes a hand-typed code work: each keystroke is a
|
||||
// different code, and a partial one is not a real code — without it the human
|
||||
// watches IAM's refusal flash at them while they are still typing.
|
||||
const signedIn = phase.s !== 'checking' && phase.s !== 'signin'
|
||||
const blank = userCode.trim().length === 0
|
||||
useEffect(() => {
|
||||
if (!signedIn || blank) return
|
||||
let alive = true
|
||||
const t = setTimeout(() => {
|
||||
client.deviceInfo(userCode).then((r) => {
|
||||
if (!alive) return
|
||||
// The session lapsed between the get-account check and this read. The
|
||||
// signin phase preserves the code in `returnTo`, so the human lands back
|
||||
// here with it intact.
|
||||
if (!r.ok && r.loginRequired) setPhase({ s: 'signin' })
|
||||
else setApp(r)
|
||||
})
|
||||
}, 250)
|
||||
return () => {
|
||||
alive = false
|
||||
clearTimeout(t)
|
||||
}
|
||||
}, [client, userCode, signedIn, blank])
|
||||
|
||||
async function approve() {
|
||||
setError(null)
|
||||
setPhase({ s: 'approving' })
|
||||
const res = await client.approveDevice(userCode)
|
||||
if (res.ok) {
|
||||
setPhase({ s: 'approved' })
|
||||
} else if (res.required) {
|
||||
setPhase({ s: 'consent' })
|
||||
} else {
|
||||
setError(res.error ?? 'Approval failed. Restart sign-in on your device.')
|
||||
setPhase({ s: 'confirm' })
|
||||
}
|
||||
}
|
||||
|
||||
if (phase.s === 'checking') {
|
||||
return (
|
||||
<Shell brand={brand}>
|
||||
<div className="hanzo-id-spinner" style={{ borderTopColor: brand.accentColor ?? '#fff' }} />
|
||||
</Shell>
|
||||
)
|
||||
}
|
||||
|
||||
if (phase.s === 'signin') {
|
||||
// Sign in first (reuse the normal flow). The password leg stays on-page via
|
||||
// `onAuthenticated` and re-checks the session; the social leg round-trips and
|
||||
// returns to THIS page (postLoginRedirect), where the session check resumes.
|
||||
const returnTo = userCode
|
||||
? `${window.location.pathname}?user_code=${encodeURIComponent(userCode)}`
|
||||
: window.location.pathname
|
||||
return (
|
||||
<Shell brand={brand}>
|
||||
<h1>Sign in to approve your device</h1>
|
||||
<SocialButtons client={client} intent="signin" postLoginRedirect={returnTo} />
|
||||
<LoginForm client={client} onAuthenticated={() => setPhase({ s: 'confirm' })} />
|
||||
<p className="hanzo-id-footer-links">
|
||||
<a href="/forget">Forgot password?</a>
|
||||
</p>
|
||||
</Shell>
|
||||
)
|
||||
}
|
||||
|
||||
if (phase.s === 'approved') {
|
||||
return (
|
||||
<Shell brand={brand}>
|
||||
<h1>You're signed in on your device</h1>
|
||||
<p className="lede">Approval complete — you can close this window and return to your device.</p>
|
||||
</Shell>
|
||||
)
|
||||
}
|
||||
|
||||
const busy = phase.s === 'approving'
|
||||
const consent = phase.s === 'consent'
|
||||
const email = phase.s === 'confirm' || phase.s === 'consent' ? phase.email : undefined
|
||||
// ONE place shows a failure, whichever leg produced it: the approval itself, or
|
||||
// the lookup that has to name an application before an approval is offered.
|
||||
const failure = error ?? (app && !app.ok ? app.error : null)
|
||||
|
||||
return (
|
||||
<Shell brand={brand}>
|
||||
<h1>Approve this device</h1>
|
||||
{email ? <p className="lede">Signed in as {email}</p> : null}
|
||||
|
||||
{/* The application is named ONLY once IAM has confirmed it — the clientId
|
||||
alongside the display name, so a technical human can check it reads
|
||||
`hanzo-cli` exactly and not something that merely looks like it. Until
|
||||
then the sentence says a device, because that is all the page knows. */}
|
||||
<p className="hanzo-id-device-prompt">
|
||||
{named ? (
|
||||
<>
|
||||
<strong>{named.displayName}</strong> (<code>{named.clientId}</code>) is asking to
|
||||
sign in as you.
|
||||
</>
|
||||
) : (
|
||||
'A device is asking to sign in as you.'
|
||||
)}{' '}
|
||||
Approve ONLY if the code below matches the one shown on that device, and only if you
|
||||
started this sign-in yourself.
|
||||
</p>
|
||||
|
||||
<label className="hanzo-id-field">
|
||||
<span>Device code</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="text"
|
||||
autoCapitalize="characters"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
autoComplete="one-time-code"
|
||||
aria-label="Device code"
|
||||
className="hanzo-id-input hanzo-id-device-code"
|
||||
value={userCode}
|
||||
// A name — and a failure — belongs to a CODE. Edit the code and both are
|
||||
// dropped in the same commit, so no name is ever left on screen for a
|
||||
// frame beside a code it was not confirmed for.
|
||||
onChange={(e) => {
|
||||
setUserCode(e.target.value)
|
||||
setApp(null)
|
||||
setError(null)
|
||||
}}
|
||||
placeholder="e.g. K7M4P2QH"
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{consent && named ? (
|
||||
<p className="hanzo-id-info">
|
||||
<strong>{named.displayName}</strong> needs your consent to continue. By approving
|
||||
you grant the device showing this code access to your profile.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{failure ? <p role="alert" className="hanzo-id-error">{failure}</p> : null}
|
||||
|
||||
<div className="hanzo-id-cta-row">
|
||||
<button
|
||||
type="button"
|
||||
// Nothing is approved until IAM has named what is being approved. An
|
||||
// unresolved or refused lookup leaves no button to press, rather than a
|
||||
// button that authorizes an unnamed party.
|
||||
className="hanzo-id-btn"
|
||||
disabled={busy || !named}
|
||||
onClick={approve}
|
||||
>
|
||||
{busy ? 'Approving…' : consent ? 'Approve & grant access' : 'Approve'}
|
||||
</button>
|
||||
</div>
|
||||
</Shell>
|
||||
)
|
||||
}
|
||||
|
||||
function Shell({ brand, children }: { brand: BrandContract; children: ReactNode }) {
|
||||
return (
|
||||
<div className="hanzo-id-page hanzo-id-device">
|
||||
<BrandHeader brand={brand} />
|
||||
<main>{children}</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function str(v: unknown): string | undefined {
|
||||
return typeof v === 'string' && v.length > 0 ? v : undefined
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { BrandContract } from '@hanzo/id-shared'
|
||||
import type { BrandContract, TenantConfig } from '@hanzo/id-shared'
|
||||
import { ForgotForm, type AuthClient } from '@hanzo/id-auth'
|
||||
import { BrandHeader } from '../components/BrandHeader'
|
||||
|
||||
export function Forgot({ client, brand }: { client: AuthClient; brand: BrandContract }) {
|
||||
export function Forgot({ client, brand, tenant }: { client: AuthClient; brand: BrandContract; tenant: TenantConfig }) {
|
||||
return (
|
||||
<div className="hanzo-id-page hanzo-id-forgot">
|
||||
<BrandHeader brand={brand} />
|
||||
<BrandHeader brand={brand} tenant={tenant} />
|
||||
<main>
|
||||
<h1>Reset your {brand.name} password</h1>
|
||||
<ForgotForm client={client} />
|
||||
|
||||
+55
-202
@@ -1,212 +1,65 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { idBrandLabel, type BrandContract } from '@hanzo/id-shared'
|
||||
import {
|
||||
LoginForm,
|
||||
MfaEnrollForm,
|
||||
OTPForm,
|
||||
SocialButtons,
|
||||
mfaChannelOf,
|
||||
type AuthClient,
|
||||
type LoginResponse,
|
||||
} from '@hanzo/id-auth'
|
||||
import { BrandHeader } from '../components/BrandHeader'
|
||||
import type { BrandContract, TenantConfig } from '@hanzo/id-shared'
|
||||
import { LoginForm, type AuthClient } from '@hanzo/id-auth'
|
||||
import { BrandLogo } from '../components/BrandLogo'
|
||||
import { MarketingPanel } from '../components/MarketingPanel'
|
||||
import { marketingFor } from '../marketing'
|
||||
|
||||
export function Login({ client, brand }: { client: AuthClient; brand: BrandContract }) {
|
||||
/**
|
||||
* Split-view login (restored from the legacy Next.js design): the login card
|
||||
* on the left, the per-org marketing/branding panel on the right (hidden on
|
||||
* narrow viewports). The auth wiring is untouched — `<LoginForm>` from
|
||||
* `@hanzo/id-auth` still drives the `/v1/iam/login` + code flow.
|
||||
*/
|
||||
export function Login({
|
||||
client,
|
||||
brand,
|
||||
tenant,
|
||||
signupEnabled,
|
||||
}: {
|
||||
client: AuthClient
|
||||
brand: BrandContract
|
||||
tenant: TenantConfig
|
||||
signupEnabled: boolean
|
||||
}) {
|
||||
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 codeChallenge = sp.get('code_challenge') ?? undefined
|
||||
const codeChallengeMethod = (sp.get('code_challenge_method') as 'S256' | 'plain' | null) ?? undefined
|
||||
const nonce = sp.get('nonce') ?? undefined
|
||||
// A provider the user already chose upstream (the console sends
|
||||
// `?provider_hint=provider-github` when they click "Continue with GitHub"
|
||||
// over there). With no live session we launch that provider straight away
|
||||
// instead of showing this form — so the click lands directly in the social
|
||||
// flow, never bouncing the user to a second login page. We honor ONLY
|
||||
// `provider_hint`, never a bare `provider=` (the SSO SDK uses that for its
|
||||
// `<org>-iam` IDP hint — a different meaning).
|
||||
const providerHint = sp.get('provider_hint') ?? undefined
|
||||
|
||||
// TRUE single sign-on. When an app sent the user here for an authorization
|
||||
// code (client_id + redirect_uri present) AND the browser already holds an
|
||||
// issuer session from an earlier sign-in (the `iam_session_id` cookie), mint
|
||||
// the code from that session and redirect straight back — no form, no
|
||||
// credential re-entry. With no live session we fall back to auto-launching the
|
||||
// hinted provider if one was named, else the interactive form. A bare portal
|
||||
// visit (no client_id/redirect_uri) has nowhere to redirect, so it shows the
|
||||
// form immediately as before.
|
||||
const canSilent = !!clientIdOverride && !!redirectUri
|
||||
const fallback = providerHint ? 'federate' : 'form'
|
||||
const [phase, setPhase] = useState<'silent' | 'federate' | 'form'>(canSilent ? 'silent' : fallback)
|
||||
|
||||
// null = show the credential form; otherwise IAM returned an MFA signal and
|
||||
// we render the matching step instead of navigating on.
|
||||
const [mfa, setMfa] = useState<LoginResponse | null>(null)
|
||||
const [challengeError, setChallengeError] = useState<string | null>(null)
|
||||
|
||||
const clientId = clientIdOverride ?? client.org.clientId
|
||||
|
||||
useEffect(() => {
|
||||
if (!canSilent) return
|
||||
let cancelled = false
|
||||
client
|
||||
.silentLogin({
|
||||
clientId: clientIdOverride!,
|
||||
application: clientIdOverride!,
|
||||
redirectUri: redirectUri!,
|
||||
state,
|
||||
codeChallenge,
|
||||
codeChallengeMethod,
|
||||
nonce,
|
||||
})
|
||||
.then((r) => {
|
||||
if (cancelled) return
|
||||
if (r.redirectUrl) {
|
||||
window.location.assign(r.redirectUrl)
|
||||
} else {
|
||||
setPhase(fallback)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setPhase(fallback)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
// Run once on mount; the OAuth params are fixed for the life of the page.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// The credential check succeeded (or MFA was satisfied). For a downstream
|
||||
// OIDC request, re-enter authorize with the now-established IAM session so it
|
||||
// mints the code; for a bare portal sign-in, land on onboarding.
|
||||
function completeAfterAuth() {
|
||||
if (redirectUri) {
|
||||
window.location.href = client.authorize({
|
||||
clientId,
|
||||
redirectUri,
|
||||
state: state ?? '',
|
||||
codeChallenge,
|
||||
codeChallengeMethod,
|
||||
})
|
||||
} else {
|
||||
window.location.href = '/onboarding'
|
||||
}
|
||||
}
|
||||
|
||||
if (phase === 'silent') {
|
||||
return (
|
||||
<div className="hanzo-id-page hanzo-id-login">
|
||||
<BrandHeader brand={brand} />
|
||||
<main aria-busy="true">
|
||||
<p>Signing you in…</p>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Auto-launch the hinted provider. `SocialButtons` is headless here — it
|
||||
// resolves the app config and runs the hop; we show a busy state meanwhile,
|
||||
// and drop to the form only if the hint matched no configured provider.
|
||||
if (phase === 'federate') {
|
||||
return (
|
||||
<div className="hanzo-id-page hanzo-id-login">
|
||||
<BrandHeader brand={brand} />
|
||||
<main aria-busy="true">
|
||||
<p>Signing you in…</p>
|
||||
<SocialButtons
|
||||
client={client}
|
||||
clientIdOverride={clientIdOverride}
|
||||
intent="signin"
|
||||
postLoginRedirect={redirectUri}
|
||||
autoStart={providerHint}
|
||||
onAutoStartResolved={(started) => {
|
||||
if (!started) setPhase('form')
|
||||
}}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (mfa?.mfaStage === 'enroll') {
|
||||
return (
|
||||
<div className="hanzo-id-page hanzo-id-login">
|
||||
<BrandHeader brand={brand} />
|
||||
<main>
|
||||
<MfaEnrollForm client={client} onComplete={completeAfterAuth} />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (mfa?.mfaStage === 'challenge') {
|
||||
const iamType = mfa.mfaTypes?.[0] ?? 'app'
|
||||
async function onChallenge(code: string) {
|
||||
setChallengeError(null)
|
||||
const res = await client.mfaChallenge({
|
||||
mfaType: iamType,
|
||||
passcode: code,
|
||||
clientId,
|
||||
application: client.org.appName,
|
||||
organization: client.org.orgId,
|
||||
redirectUri,
|
||||
state,
|
||||
codeChallenge,
|
||||
codeChallengeMethod,
|
||||
})
|
||||
if (res.error) {
|
||||
setChallengeError(res.error)
|
||||
} else if (res.redirectUrl) {
|
||||
window.location.href = res.redirectUrl
|
||||
} else {
|
||||
completeAfterAuth()
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="hanzo-id-page hanzo-id-login">
|
||||
<BrandHeader brand={brand} />
|
||||
<main>
|
||||
<h1>Two-factor authentication</h1>
|
||||
<p className="lede">Enter the code from your authenticator app to finish signing in.</p>
|
||||
{challengeError ? <p role="alert" className="hanzo-id-error">{challengeError}</p> : null}
|
||||
<OTPForm channel={mfaChannelOf(iamType)} onSubmit={onChallenge} />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const accent = brand.accentColor ?? '#ffffff'
|
||||
const marketing = marketingFor(tenant.orgId)
|
||||
const search = window.location.search
|
||||
|
||||
return (
|
||||
<div className="hanzo-id-page hanzo-id-login">
|
||||
<BrandHeader brand={brand} />
|
||||
<main>
|
||||
<h1>Sign in to {idBrandLabel(brand, client.org.orgId)}</h1>
|
||||
<SocialButtons
|
||||
client={client}
|
||||
clientIdOverride={clientIdOverride}
|
||||
intent="signin"
|
||||
postLoginRedirect={redirectUri}
|
||||
/>
|
||||
<LoginForm
|
||||
client={client}
|
||||
redirectUri={redirectUri}
|
||||
state={state}
|
||||
clientIdOverride={clientIdOverride ?? undefined}
|
||||
codeChallenge={codeChallenge}
|
||||
codeChallengeMethod={codeChallengeMethod}
|
||||
nonce={nonce}
|
||||
onMfaRequired={setMfa}
|
||||
/>
|
||||
<p className="hanzo-id-footer-links">
|
||||
{/* 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 className="hanzo-id-split">
|
||||
<section className="hanzo-id-split-form">
|
||||
<div className="hanzo-id-card">
|
||||
<header className="hanzo-id-card-head">
|
||||
<a href="/" aria-label={brand.name}>
|
||||
<BrandLogo brand={brand} tenant={tenant} height={36} />
|
||||
</a>
|
||||
</header>
|
||||
<h1 className="hanzo-id-card-title">Sign in to {brand.name}</h1>
|
||||
<LoginForm
|
||||
client={client}
|
||||
redirectUri={redirectUri}
|
||||
state={state}
|
||||
clientIdOverride={clientIdOverride ?? undefined}
|
||||
/>
|
||||
<p className="hanzo-id-footer-links">
|
||||
<a href="/forget">Forgot password?</a>
|
||||
{signupEnabled ? (
|
||||
<>
|
||||
{' '}·{' '}
|
||||
<a href={`/signup${search}`}>Create account</a>
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className="hanzo-id-split-brand">
|
||||
<MarketingPanel marketing={marketing} accent={accent} />
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import type { BrandContract, OrgConfig } from '@hanzo/id-shared'
|
||||
import { createIam } from '@hanzo/id-auth'
|
||||
import { OnboardingFlow, createOnboardingService, type OnboardingState } from '@hanzo/id-onboarding'
|
||||
import { getConnector } from '@hanzo/id-connect/connectors'
|
||||
import { BrandHeader } from '../components/BrandHeader'
|
||||
|
||||
/** Fleet default pay origin; a white-label brand overrides via catalog `payUrl`. */
|
||||
const DEFAULT_PAY_URL = 'https://pay.hanzo.ai'
|
||||
|
||||
/**
|
||||
* Post-login onboarding page.
|
||||
*
|
||||
* Reached after a bare portal sign-in (no downstream `redirect_uri`). Mounts
|
||||
* the `@hanzo/id-onboarding` flow (org → project → wallet → consent → plan)
|
||||
* wired to:
|
||||
*
|
||||
* - the IAM session token: read from the same `@hanzo/iam` PKCE client the
|
||||
* Callback stored it on, so the onboarding writes ride the logged-in
|
||||
* user's bearer token. One client, one way.
|
||||
* - a `window.ethereum` wallet connector: the host owns the wallet lib so
|
||||
* the onboarding pkg stays wallet-agnostic. Absent injected provider →
|
||||
* the wallet step is skip-only.
|
||||
*
|
||||
* NEVER REPEATS: completion is recorded on the USER (Properties, via
|
||||
* saveOnboarding) — so before mounting the flow this page reads it back and,
|
||||
* if the user already finished onboarding on ANY browser, goes straight to
|
||||
* the portal. The read failing open (network blip → run the flow again) is
|
||||
* deliberate: repeating is annoying, silently skipping a required step is
|
||||
* worse.
|
||||
*
|
||||
* On completion it routes by the plan choice — the platform is prepay-only,
|
||||
* so a plan goes to the pay cart and pay-as-you-go goes to the top-up flow.
|
||||
* A downstream app that wanted a token would have carried `redirect_uri` and
|
||||
* never reached here.
|
||||
*/
|
||||
export function Onboarding({ org, brand }: { org: OrgConfig; brand: BrandContract }) {
|
||||
const iam = useMemo(() => createIam(org), [org])
|
||||
const payUrl = org.payUrl || DEFAULT_PAY_URL
|
||||
|
||||
const service = useMemo(
|
||||
() =>
|
||||
createOnboardingService({
|
||||
iamUrl: org.iamUrl,
|
||||
orgId: org.orgId,
|
||||
getAccessToken: () => iam.getValidAccessToken(),
|
||||
}),
|
||||
[org, iam],
|
||||
)
|
||||
|
||||
// null = still checking; false = run the flow; true = already done, leaving.
|
||||
const [alreadyDone, setAlreadyDone] = useState<boolean | null>(null)
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
;(async () => {
|
||||
// TOKEN BOOTSTRAP. A password/form sign-in mints a SESSION COOKIE and
|
||||
// lands here directly — the PKCE SDK holds no token, so every onboarding
|
||||
// WRITE (update-user needs a bearer; the cookie alone is refused, and
|
||||
// rightly — a cookie-authed write is a CSRF surface) answered 401 and
|
||||
// the funnel dead-ended at consent. With a live session, authorize is
|
||||
// the silent-SSO branch: signinRedirect bounces through IAM with no UI,
|
||||
// /callback stores the token and returns to /onboarding. One bounce per
|
||||
// session, guarded, so a broken mint degrades to the read-only 401
|
||||
// instead of a redirect loop.
|
||||
const token = await iam.getValidAccessToken().catch(() => null)
|
||||
if (!alive) return
|
||||
if (!token) {
|
||||
const guard = 'onboarding.token_bounce'
|
||||
if (!sessionStorage.getItem(guard)) {
|
||||
sessionStorage.setItem(guard, '1')
|
||||
void iam.signinRedirect()
|
||||
return
|
||||
}
|
||||
} else {
|
||||
sessionStorage.removeItem('onboarding.token_bounce')
|
||||
}
|
||||
try {
|
||||
const { completedAt } = await service.readOnboarding()
|
||||
if (!alive) return
|
||||
if (completedAt) {
|
||||
setAlreadyDone(true)
|
||||
window.location.replace('/?signed_in=1')
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Read failing open (network blip → run the flow) is deliberate.
|
||||
}
|
||||
if (alive) setAlreadyDone(false)
|
||||
})()
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [service, iam])
|
||||
|
||||
function onComplete(state: OnboardingState) {
|
||||
// Prepay-only funnel: the last step recorded a choice, now act on it.
|
||||
// - a plan slug → the pay cart, seats + payment there (price is the
|
||||
// catalog's — commerce recomputes server-side, the slug is enough)
|
||||
// - pay as you go → the top-up flow ($5 minimum, all methods)
|
||||
// The plan choice is already persisted on the user, so bouncing off the
|
||||
// payment page never re-enters onboarding.
|
||||
const choice = state.planChoice
|
||||
if (choice === 'payg') {
|
||||
window.location.replace(`${payUrl}/onboard`)
|
||||
} else if (choice) {
|
||||
window.location.replace(`${payUrl}/cart?plan=${encodeURIComponent(choice)}`)
|
||||
} else {
|
||||
// No recorded choice (should not happen — the plan step requires one):
|
||||
// land on the authenticated portal rather than a dead end.
|
||||
window.location.replace('/?signed_in=1')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="hanzo-id-page hanzo-id-onboarding-page">
|
||||
<BrandHeader brand={brand} />
|
||||
<main>
|
||||
{alreadyDone === false ? (
|
||||
<OnboardingFlow
|
||||
service={service}
|
||||
brandName={brand.name}
|
||||
connectWallet={connectInjectedWallet}
|
||||
onComplete={onComplete}
|
||||
payUrl={payUrl}
|
||||
/>
|
||||
) : (
|
||||
<div className="hanzo-id-spinner" aria-label="Loading" />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* EVM wallet connector backed by @hanzo/id-connect (EIP-6963 multi-injection,
|
||||
* viem under the hood). Returns the checksummed 0x address, or null when the
|
||||
* user cancels or no injected EVM wallet is present. The onboarding wallet step
|
||||
* only needs the address (it stores it via a full-row read-merge-write in the
|
||||
* service), so we connect and return account.address — no signature round-trip.
|
||||
*/
|
||||
async function connectInjectedWallet(): Promise<string | null> {
|
||||
try {
|
||||
const account = await getConnector('evm').connect()
|
||||
return account.address ?? null
|
||||
} catch {
|
||||
return null // user rejected, or no injected EVM wallet available
|
||||
}
|
||||
}
|
||||
+118
-70
@@ -1,120 +1,168 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { IamIdentity } from '@hanzo/iam/react'
|
||||
import { UserMenu, resolveIdentity } from '@hanzo/iam/react'
|
||||
import type { BrandContract, OrgConfig } from '@hanzo/id-shared'
|
||||
import type { BrandContract, TenantConfig } from '@hanzo/id-shared'
|
||||
import type { AuthClient } from '@hanzo/id-auth'
|
||||
import { Login } from './Login'
|
||||
import { BrandHeader } from '../components/BrandHeader'
|
||||
import { BrandLogo } from '../components/BrandLogo'
|
||||
import { appsFor, billingFor } from '../marketing'
|
||||
|
||||
type Auth =
|
||||
| { s: 'loading' }
|
||||
| { s: 'anon' }
|
||||
| { s: 'authed'; identity: IamIdentity | null }
|
||||
interface SessionUser {
|
||||
readonly id: string
|
||||
readonly name?: string
|
||||
readonly displayName?: string
|
||||
readonly email?: string
|
||||
readonly avatar?: string
|
||||
}
|
||||
|
||||
type AuthState = { status: 'loading' } | { status: 'anon' } | { status: 'authed'; user: SessionUser }
|
||||
|
||||
/**
|
||||
* Root portal (`/`). The portal IS the login surface, not a marketing hero:
|
||||
* Post-login portal. Detects the IAM session via the same-origin
|
||||
* `/v1/iam/get-account` proxy (cookie-scoped, `credentials: 'include'`):
|
||||
*
|
||||
* - signed out → the actual `<Login>` form (GitHub/Google/email+password),
|
||||
* identical to `/login`. A bare sign-in here lands on
|
||||
* onboarding, then back on `/` authenticated.
|
||||
* - signed in → the apps launcher (the org's apps) + billing / sign-out.
|
||||
* - signed in → the apps launcher ("all the apps") + account/billing cards,
|
||||
* restored from the legacy `account` page.
|
||||
* - signed out → the brand hero with sign-in / create-account CTAs.
|
||||
*
|
||||
* Auth is read same-origin from `/v1/iam/get-account` (cookie session;
|
||||
* `org.iamUrl` is the brand's own `*.id` host, so this is first-party and
|
||||
* the session cookie rides along). The `?signed_in=1` marker set by the
|
||||
* bare-login / onboarding-complete redirect is the authoritative "just
|
||||
* authenticated" signal when the cookie read hasn't propagated yet.
|
||||
* No token juggling in localStorage — the portal is a cookie-session OIDC
|
||||
* provider, so the session lives in the IAM cookie and we just read it.
|
||||
*/
|
||||
export function Portal({
|
||||
client,
|
||||
brand,
|
||||
org,
|
||||
tenant,
|
||||
signupEnabled,
|
||||
}: {
|
||||
client: AuthClient
|
||||
brand: BrandContract
|
||||
org: OrgConfig
|
||||
tenant: TenantConfig
|
||||
signupEnabled: boolean
|
||||
}) {
|
||||
const [auth, setAuth] = useState<Auth>({ s: 'loading' })
|
||||
const [auth, setAuth] = useState<AuthState>({ status: 'loading' })
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
// `?signed_in=1` is set by the bare-portal login flow the moment the IAM
|
||||
// session cookie is established this tab. It's our authoritative "just
|
||||
// authenticated" signal; `get-account` is the richer-but-best-effort source
|
||||
// for the user's name/email/avatar.
|
||||
const justSignedIn = new URLSearchParams(window.location.search).get('signed_in') === '1'
|
||||
fetch(new URL('/v1/iam/get-account', org.iamUrl).toString(), {
|
||||
fetch(new URL('/v1/iam/get-account', tenant.publicOrigin).toString(), {
|
||||
credentials: 'include',
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((b: Record<string, unknown>) => {
|
||||
.then((body: Record<string, unknown>) => {
|
||||
if (!alive) return
|
||||
const d = b.data as Record<string, unknown> | undefined
|
||||
if (b.status === 'ok' && d && typeof d === 'object') {
|
||||
// `resolveIdentity` is the SAME name/avatar/initials resolution every
|
||||
// Hanzo surface shows, so the portal cannot disagree with the console
|
||||
// about who you are — and it never falls back to a raw uuid.
|
||||
setAuth({ s: 'authed', identity: resolveIdentity(d, {}) })
|
||||
const d = body.data as Record<string, unknown> | undefined
|
||||
if (body.status === 'ok' && d && typeof d === 'object') {
|
||||
setAuth({
|
||||
status: 'authed',
|
||||
user: {
|
||||
id: String(d.id ?? d.name ?? ''),
|
||||
name: typeof d.name === 'string' ? d.name : undefined,
|
||||
displayName: typeof d.displayName === 'string' ? d.displayName : undefined,
|
||||
email: typeof d.email === 'string' ? d.email : undefined,
|
||||
avatar: typeof d.avatar === 'string' ? d.avatar : undefined,
|
||||
},
|
||||
})
|
||||
} else if (justSignedIn) {
|
||||
setAuth({ status: 'authed', user: { id: '' } })
|
||||
} else {
|
||||
setAuth(justSignedIn ? { s: 'authed', identity: null } : { s: 'anon' })
|
||||
setAuth({ status: 'anon' })
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (alive) setAuth(justSignedIn ? { s: 'authed', identity: null } : { s: 'anon' })
|
||||
if (!alive) return
|
||||
setAuth(justSignedIn ? { status: 'authed', user: { id: '' } } : { status: 'anon' })
|
||||
})
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [org.iamUrl])
|
||||
}, [tenant.publicOrigin])
|
||||
|
||||
if (auth.s === 'loading') {
|
||||
if (auth.status === 'loading') {
|
||||
return (
|
||||
<div className="hanzo-id-page" style={{ minHeight: '40vh' }}>
|
||||
<div className="hanzo-id-spinner" style={{ borderTopColor: brand.accentColor ?? 'var(--primary)' }} />
|
||||
<div className="hanzo-id-loading">
|
||||
<div className="hanzo-id-spinner" style={{ borderTopColor: brand.accentColor ?? '#fff' }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Signed out: the root IS the login form (no marketing hero).
|
||||
if (auth.s === 'anon') return <Login client={client} brand={brand} />
|
||||
if (auth.status === 'anon') {
|
||||
return (
|
||||
<div className="hanzo-id-page hanzo-id-portal">
|
||||
<header className="hanzo-id-brand-header">
|
||||
<a href="/" aria-label={brand.name}>
|
||||
<BrandLogo brand={brand} tenant={tenant} height={32} />
|
||||
</a>
|
||||
</header>
|
||||
<main>
|
||||
<h1>Welcome to {brand.name}</h1>
|
||||
<p className="lede">{brand.description}</p>
|
||||
<div className="hanzo-id-cta-row">
|
||||
<a className="hanzo-id-btn primary" href="/login">Sign in</a>
|
||||
{signupEnabled ? <a className="hanzo-id-btn" href="/signup">Create account</a> : null}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Signed in: the apps launcher.
|
||||
const apps = appsFor(org.orgId)
|
||||
const billingUrl = billingFor(org.orgId)
|
||||
const logoutUrl = client.logout(undefined, `${org.publicOrigin}/login`)
|
||||
const { user } = auth
|
||||
const apps = appsFor(tenant.orgId)
|
||||
const billingUrl = billingFor(tenant.orgId)
|
||||
const display = user.displayName || user.name || user.email || 'Account'
|
||||
const logoutUrl = client.logout(undefined, tenant.publicOrigin + '/login')
|
||||
|
||||
return (
|
||||
<div className="hanzo-id-page hanzo-id-portal">
|
||||
<BrandHeader brand={brand} />
|
||||
<main>
|
||||
<h1>Your {brand.name} apps</h1>
|
||||
<div className="hanzo-id-apps">
|
||||
{apps.map((a) => (
|
||||
<a key={a.name} className="hanzo-id-applink" href={a.href}>
|
||||
<div className="hanzo-id-applink-name">
|
||||
<span>{a.name}</span>
|
||||
<span aria-hidden>↗</span>
|
||||
<div className="hanzo-id-portal-authed">
|
||||
<nav className="hanzo-id-nav">
|
||||
<a href="/" aria-label={brand.name}>
|
||||
<BrandLogo brand={brand} tenant={tenant} height={28} />
|
||||
</a>
|
||||
<div className="hanzo-id-nav-links">
|
||||
<a href={billingUrl}>Billing</a>
|
||||
<a href={logoutUrl}>Sign out</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="hanzo-id-portal-body">
|
||||
<div className="hanzo-id-profile">
|
||||
{user.avatar ? (
|
||||
<img className="hanzo-id-profile-avatar" src={user.avatar} alt="" />
|
||||
) : (
|
||||
<div
|
||||
className="hanzo-id-profile-avatar hanzo-id-profile-avatar-fallback"
|
||||
style={{ color: brand.accentColor ?? '#fff' }}
|
||||
>
|
||||
{display[0]?.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h1>{display}</h1>
|
||||
{user.email ? <p className="lede">{user.email}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="hanzo-id-section-title">{brand.name} apps</h2>
|
||||
<div className="hanzo-id-apps-grid">
|
||||
{apps.map((app) => (
|
||||
<a key={app.name} className="hanzo-id-app-card" href={app.href}>
|
||||
<div className="hanzo-id-app-card-head">
|
||||
<span className="hanzo-id-app-name">{app.name}</span>
|
||||
<span className="hanzo-id-app-arrow" aria-hidden="true">↗</span>
|
||||
</div>
|
||||
<div className="hanzo-id-applink-desc">{a.description}</div>
|
||||
<p className="hanzo-id-app-desc">{app.description}</p>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
{/* The ONE account control. This was a hand-rolled "Billing / Sign out"
|
||||
link row; every Hanzo surface mounts @hanzo/iam's UserMenu instead,
|
||||
so identity, billing and sign-out read and behave identically here,
|
||||
on hanzo.chat and in the console. The portal's session is its own
|
||||
cookie read rather than an IamProvider, which is exactly what the
|
||||
`identity` / `isAuthenticated` / `onSignOut` overrides are for.
|
||||
No `brand` prop: omitting `markSvg` would put the HANZO mark on
|
||||
lux.id and zoo.id, and this one image serves all four portals. */}
|
||||
<div className="hanzo-id-portal-account">
|
||||
<UserMenu
|
||||
identity={auth.identity}
|
||||
isAuthenticated
|
||||
usageUrl={billingUrl}
|
||||
usageLabel="Billing"
|
||||
onSignOut={() => { window.location.href = logoutUrl }}
|
||||
/>
|
||||
|
||||
<div className="hanzo-id-portal-footer">
|
||||
<a className="hanzo-id-muted-link" href={brand.appDomain ? `https://${brand.appDomain}` : '/'}>
|
||||
← Back to {brand.name}
|
||||
</a>
|
||||
<a className="hanzo-id-btn ghost" href={logoutUrl}>Sign out</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,44 +1,37 @@
|
||||
import type { BrandContract } from '@hanzo/id-shared'
|
||||
import { SignupForm, SocialButtons, type AuthClient } from '@hanzo/id-auth'
|
||||
import { BrandHeader } from '../components/BrandHeader'
|
||||
import type { BrandContract, TenantConfig } from '@hanzo/id-shared'
|
||||
import { SignupForm, type AuthClient } from '@hanzo/id-auth'
|
||||
import { BrandLogo } from '../components/BrandLogo'
|
||||
import { MarketingPanel } from '../components/MarketingPanel'
|
||||
import { marketingFor } from '../marketing'
|
||||
|
||||
export function Signup({ client, brand }: { client: AuthClient; brand: BrandContract }) {
|
||||
/** Split-view signup, mirroring the restored login layout. */
|
||||
export function Signup({ client, brand, tenant }: { client: AuthClient; brand: BrandContract; tenant: TenantConfig }) {
|
||||
const sp = new URLSearchParams(window.location.search)
|
||||
const inviteCode = sp.get('invite') ?? undefined
|
||||
const clientIdOverride = sp.get('client_id') ?? undefined
|
||||
const redirectUri = sp.get('redirect_uri') ?? undefined
|
||||
// The same downstream OIDC request `Login` reads. Registration ends in a
|
||||
// sign-in, so it needs the whole request — not just the client and its
|
||||
// callback — or the minted code carries no PKCE binding and no state.
|
||||
const state = sp.get('state') ?? undefined
|
||||
const codeChallenge = sp.get('code_challenge') ?? undefined
|
||||
const codeChallengeMethod = (sp.get('code_challenge_method') as 'S256' | 'plain' | null) ?? undefined
|
||||
const nonce = sp.get('nonce') ?? undefined
|
||||
const accent = brand.accentColor ?? '#ffffff'
|
||||
const marketing = marketingFor(tenant.orgId)
|
||||
const search = window.location.search
|
||||
|
||||
return (
|
||||
<div className="hanzo-id-page hanzo-id-signup">
|
||||
<BrandHeader brand={brand} />
|
||||
<main>
|
||||
<h1>Create your {brand.name} account</h1>
|
||||
<SocialButtons
|
||||
client={client}
|
||||
clientIdOverride={clientIdOverride}
|
||||
intent="signup"
|
||||
postLoginRedirect={redirectUri}
|
||||
/>
|
||||
<SignupForm
|
||||
client={client}
|
||||
inviteCode={inviteCode}
|
||||
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${window.location.search}`}>Sign in</a>
|
||||
</p>
|
||||
</main>
|
||||
<div className="hanzo-id-split">
|
||||
<section className="hanzo-id-split-form">
|
||||
<div className="hanzo-id-card">
|
||||
<header className="hanzo-id-card-head">
|
||||
<a href="/" aria-label={brand.name}>
|
||||
<BrandLogo brand={brand} tenant={tenant} height={36} />
|
||||
</a>
|
||||
</header>
|
||||
<h1 className="hanzo-id-card-title">Create your {brand.name} account</h1>
|
||||
<SignupForm client={client} inviteCode={inviteCode} />
|
||||
<p className="hanzo-id-footer-links">
|
||||
Already have an account? <a href={`/login${search}`}>Sign in</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className="hanzo-id-split-brand">
|
||||
<MarketingPanel marketing={marketing} accent={accent} />
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
/**
|
||||
* 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
@@ -1,16 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
/**
|
||||
* Publishable event-ingest key (pk-…), inlined at build time from the
|
||||
* EVENT_INGEST_KEY build-arg (KMS `deploy/EVENT_INGEST_KEY`, env `prod`).
|
||||
* Declared so a typo reads as a type error rather than as `any` — Vite's
|
||||
* ImportMetaEnv carries a string index signature, so an undeclared
|
||||
* `import.meta.env.VITE_EVENT_INGEST_KEZ` would type-check and ship empty.
|
||||
*/
|
||||
readonly VITE_EVENT_INGEST_KEY: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
@@ -2,10 +2,9 @@
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
|
||||
+14
-22
@@ -4,46 +4,38 @@ import { resolve } from 'path'
|
||||
import { readFileSync, existsSync } from 'fs'
|
||||
import { createRequire } from 'module'
|
||||
|
||||
// ESM Vite config has no global `require`; build one bound to this file so
|
||||
// `require.resolve('@scope/brand/brand.json')` works at config-eval time.
|
||||
const req = createRequire(import.meta.url)
|
||||
// Vite loads this config as a native ESM module, where `require` is undefined.
|
||||
// Build a CJS-style resolver bound to this file so `<pkg>/brand.json` subpath
|
||||
// resolution works in both `configureServer` and `generateBundle`.
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
/**
|
||||
* Per-brand brand.json copy plugin.
|
||||
*
|
||||
* Each per-org brand package (`@hanzo/brand`, `@luxfi/brand`, `@zooai/brand`,
|
||||
* `@parsdao/brand`) ships a `brand.json` at the package root. We serve each at
|
||||
* a FLAT, encoding-safe path `/brand/<scope>.json` (scope = the npm scope:
|
||||
* `@hanzo/brand` -> `hanzo`). A nested `/brand/@hanzo/brand/brand.json` URL
|
||||
* carries a literal `@` and an encoded `%2F` that the production static server
|
||||
* (hanzoai/static) cannot map to the on-disk file — it falls through to the
|
||||
* SPA catch-all and returns index.html, so the runtime brand fetch would parse
|
||||
* HTML as JSON. The flat slug avoids that entirely. `loadBrand` fetches the
|
||||
* same `/brand/<scope>.json`.
|
||||
* `@parsdao/brand`) ships a `brand.json` at the package root. We serve them
|
||||
* verbatim at `/brand/<pkg>/brand.json` so the runtime tenant resolver can
|
||||
* fetch the right one based on hostname.
|
||||
*
|
||||
* Assets (logos, favicons) are imported by URL inside the per-brand `brand.json`
|
||||
* (CDN URLs in production), so no further asset copying is needed.
|
||||
*/
|
||||
const BRAND_PACKAGES = ['@hanzo/brand', '@luxfi/brand', '@zooai/brand', '@parsdao/brand']
|
||||
|
||||
/** npm scope -> flat brand slug: `@hanzo/brand` -> `hanzo`. */
|
||||
const brandSlug = (pkg: string): string => pkg.replace(/^@/, '').split('/')[0]!
|
||||
|
||||
function brandJsonPlugin() {
|
||||
return {
|
||||
name: 'hanzo-id-brand-json',
|
||||
configureServer(server: any) {
|
||||
server.middlewares.use((req2: any, res: any, next: any) => {
|
||||
const m = /^\/brand\/([^/]+)\.json$/.exec(req2.url ?? '')
|
||||
server.middlewares.use((req: any, res: any, next: any) => {
|
||||
const m = /^\/brand\/(.+)\/brand\.json$/.exec(req.url ?? '')
|
||||
if (!m) return next()
|
||||
const slug = m[1]!
|
||||
const pkg = BRAND_PACKAGES.find((p) => brandSlug(p) === slug)
|
||||
if (!pkg) {
|
||||
const pkg = decodeURIComponent(m[1]!)
|
||||
if (!BRAND_PACKAGES.includes(pkg)) {
|
||||
res.statusCode = 404
|
||||
return res.end()
|
||||
}
|
||||
try {
|
||||
const path = req.resolve(`${pkg}/brand.json`)
|
||||
const path = require.resolve(`${pkg}/brand.json`)
|
||||
res.setHeader('Content-Type', 'application/json')
|
||||
res.setHeader('Cache-Control', 'no-store')
|
||||
return res.end(readFileSync(path, 'utf8'))
|
||||
@@ -56,11 +48,11 @@ function brandJsonPlugin() {
|
||||
generateBundle(this: any) {
|
||||
for (const pkg of BRAND_PACKAGES) {
|
||||
try {
|
||||
const path = req.resolve(`${pkg}/brand.json`)
|
||||
const path = require.resolve(`${pkg}/brand.json`)
|
||||
if (!existsSync(path)) continue
|
||||
this.emitFile({
|
||||
type: 'asset',
|
||||
fileName: `brand/${brandSlug(pkg)}.json`,
|
||||
fileName: `brand/${pkg}/brand.json`,
|
||||
source: readFileSync(path, 'utf8'),
|
||||
})
|
||||
} catch {
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
# Hanzo ID — the ONE file that says how this repo builds, gates and ships.
|
||||
#
|
||||
# The only workflow is `.hanzo/workflows/cicd.yml`, ~10 lines that import
|
||||
# hanzoai/ci; everything real is here, and platform.hanzo.ai reads this same
|
||||
# file. It replaces TWO image lanes that had drifted apart:
|
||||
#
|
||||
# .hanzo/workflows/deploy.yml the one that worked — buildx, two registry
|
||||
# logins, a version read out of package.json and
|
||||
# a post-push manifest check. Every line of it is
|
||||
# something the reusable already does.
|
||||
# .github/workflows/docker.yml a second lane on a plane with no runners for
|
||||
# our labels. Already reduced to an echo, but it
|
||||
# kept the shape of a build lane alive in a
|
||||
# directory this forge cannot even read.
|
||||
#
|
||||
# Neither gated anything. This repo has 13 test files and 160 assertions and CI
|
||||
# ran none of them, which is how `pkgs/shared/src/org.test.ts` sat RED on main
|
||||
# while the code it tested was correct.
|
||||
|
||||
# Gates run DIRECTLY on the runner, not inside the image build. hanzoai/ci
|
||||
# provisions Node 22 and enables corepack; corepack then reads `packageManager`
|
||||
# from package.json, so the pnpm this uses is the SAME pnpm the Dockerfile
|
||||
# activates — stated once, in package.json, rather than pinned again here.
|
||||
#
|
||||
# Three gates, because there are three different ways this repo breaks and one
|
||||
# combined gate would report all of them as the same failure.
|
||||
test:
|
||||
- name: install
|
||||
# --frozen-lockfile, deliberately stricter than the Dockerfile's
|
||||
# `--frozen-lockfile=false`. The image build must not be blocked by a
|
||||
# lockfile that drifted; CI is exactly where that drift should be caught.
|
||||
run: |
|
||||
set -e
|
||||
corepack enable
|
||||
pnpm install --frozen-lockfile
|
||||
- name: typecheck
|
||||
# Vite does NOT typecheck — `vite build` transpiles and strips types, so a
|
||||
# type error ships a green image. This is the only thing in the pipeline
|
||||
# that reads the types across all 7 workspace packages.
|
||||
run: |
|
||||
set -e
|
||||
pnpm -r tc
|
||||
- name: unit
|
||||
# vitest over pkgs/**/src and apps/**/src (vitest.config.ts). 160 tests,
|
||||
# including the org resolver that decides which brand a host authenticates
|
||||
# as and which redirect_uri every social hop sends — the surface that
|
||||
# produced days of "login is broken" and cannot be verified by looking at it.
|
||||
run: |
|
||||
set -e
|
||||
pnpm test
|
||||
|
||||
images:
|
||||
- name: id
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
repo: ghcr.io/hanzoai/id
|
||||
platforms: [linux/amd64]
|
||||
# Fetched from KMS (`deploy/EVENT_INGEST_KEY`, env `prod`) and passed as
|
||||
# --build-arg EVENT_INGEST_KEY. The reusable fails closed on an empty value,
|
||||
# and the Dockerfile gates the shape and then asserts the key actually landed
|
||||
# in the bundle — because a build that is green with an unattributed bundle
|
||||
# is the failure mode here, not a build that goes red.
|
||||
#
|
||||
# This is the ONE spelling the fleet carries: KMS holds the plain name, each
|
||||
# Dockerfile re-exports it with the prefix its bundler inlines.
|
||||
build_secrets: [EVENT_INGEST_KEY]
|
||||
# The version comes from package.json — hanzoai/ci's bin/imgver reads it as
|
||||
# the declared floor, exactly as deploy.yml's `node -p require('./package.
|
||||
# json').version` did, so a release is still a version bump and nothing about
|
||||
# how this image is named changes.
|
||||
#
|
||||
# One difference worth stating: imgver takes max(declared, published)+1 patch
|
||||
# when the declared version is ALREADY at the registry, where deploy.yml
|
||||
# failed the build instead ("bump the version to cut a release"). Both refuse
|
||||
# to put a second digest under a tag someone may already be pinning; this one
|
||||
# publishes the next patch rather than going red on a docs commit.
|
||||
#
|
||||
# oci.hanzo.ai is still written to — the reusable crane-copies the exact tag
|
||||
# set after the ghcr push. The PATH changes: deploy.yml pushed
|
||||
# `oci.hanzo.ai/id`, the reusable writes the org-qualified
|
||||
# `oci.hanzo.ai/hanzoai/id`, which is the fleet convention. Nothing live
|
||||
# pulls either one; charts/app/values/hanzo/id.yaml pins
|
||||
# `ghcr.io/hanzoai/id` by tag AND digest.
|
||||
|
||||
# No `deploy:` ON PURPOSE, and this is deploy.yml's own rule, not a new one.
|
||||
#
|
||||
# id is governed by Hanzo CD. The tag it runs is declared in
|
||||
# hanzoai/universe (charts/app/values/hanzo/id.yaml, tag + digest together), and
|
||||
# the in-cluster reconcile restores that within ~60-90s. A CI-side `kubectl
|
||||
# patch` therefore CANNOT stick — and the reason to refuse it is not that it
|
||||
# fails, it is that it LOOKS like it works: the patch applies, the pod rolls, and
|
||||
# CD quietly puts the old tag back a minute later.
|
||||
#
|
||||
# It would also be actively wrong here. The reusable's deploy step guards a
|
||||
# semver pin against a transient branch build by reading the current tag out of
|
||||
# `infra/k8s/operator/crs/<svc>.yaml` — a path this service does not use — so the
|
||||
# guard would not fire and a `sha-<short>-amd64` tag would be written over a
|
||||
# reviewed semver+digest pin on the live sign-in surface for every property in
|
||||
# the fleet.
|
||||
#
|
||||
# Build and deploy are separate concerns: this emits an immutable image, git
|
||||
# declares desired state, CD applies it. To ship a build, set image.tag AND
|
||||
# image.digest in universe and push.
|
||||
+2
-6
@@ -1,19 +1,15 @@
|
||||
{
|
||||
"name": "@hanzo/id",
|
||||
"private": true,
|
||||
"version": "0.2.28",
|
||||
"version": "0.1.1",
|
||||
"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",
|
||||
"vitest": "^3.2.4"
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hanzo/id-auth",
|
||||
"version": "0.1.7",
|
||||
"version": "0.1.0",
|
||||
"description": "Composable login / signup / OTP / OAuth-PKCE flows on top of @hanzo/iam. UI primitives in @hanzo/gui.",
|
||||
"license": "BSD-3-Clause",
|
||||
"type": "module",
|
||||
@@ -12,17 +12,13 @@
|
||||
"./forms": "./src/ui/index.ts",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"src"
|
||||
],
|
||||
"files": ["src"],
|
||||
"scripts": {
|
||||
"tc": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hanzo/id-connect": "workspace:*",
|
||||
"@hanzo/id-shared": "workspace:*",
|
||||
"@hanzo/iam": "^0.21.1",
|
||||
"@paulmillr/qr": "^0.3.0"
|
||||
"@hanzo/iam": "^0.9.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=19",
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
/**
|
||||
* MFA wiring tests — pure, no network (fetch is mocked via `fetchImpl`).
|
||||
* Run with: pnpm --filter @hanzo/id-auth test
|
||||
*
|
||||
* Locks the wire contract verified live against iam.hanzo.ai:
|
||||
* - login answers a forced-MFA org with `data:"RequiredMfa"` (enroll) or
|
||||
* `data:"NextMfa"` + 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')
|
||||
})
|
||||
@@ -1,685 +0,0 @@
|
||||
import { test } from 'vitest'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createAuthClient } from './client.ts'
|
||||
import type { OrgConfig } from '@hanzo/id-shared'
|
||||
|
||||
// A capturing fetch double: records the URL + parsed JSON body of the last call
|
||||
// and returns a canned IAM "ok" response. No network.
|
||||
function capturingFetch() {
|
||||
const calls: { url: string; body: Record<string, unknown> }[] = []
|
||||
const fetchImpl: typeof fetch = async (input, init) => {
|
||||
const url = typeof input === 'string' ? input : input.toString()
|
||||
let body: Record<string, unknown> = {}
|
||||
if (init?.body && typeof init.body === 'string') body = JSON.parse(init.body)
|
||||
calls.push({ url, body })
|
||||
return new Response(JSON.stringify({ status: 'ok', data: 'AUTHCODE' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
return { calls, fetchImpl }
|
||||
}
|
||||
|
||||
// A routing fetch double for the silent-SSO org gate: silentLogin resolves the
|
||||
// app's org (`/v1/iam/get-app-login`) and the ambient session's owner
|
||||
// (`/v1/iam/get-account`) BEFORE minting a code (`/v1/iam/login`). This lets a
|
||||
// test set the app org + session owner independently and assert whether the mint
|
||||
// leg ran. `sessionOwner: null` models "no live session" (get-account errors).
|
||||
function routingFetch(opts: { appOrg: string; sessionOwner: string | null; code?: string }) {
|
||||
const calls: { url: string; body: Record<string, unknown> }[] = []
|
||||
const json = (payload: unknown) =>
|
||||
new Response(JSON.stringify(payload), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
||||
const fetchImpl: typeof fetch = async (input, init) => {
|
||||
const url = typeof input === 'string' ? input : input.toString()
|
||||
let body: Record<string, unknown> = {}
|
||||
if (init?.body && typeof init.body === 'string') body = JSON.parse(init.body)
|
||||
calls.push({ url, body })
|
||||
if (url.includes('/get-app-login')) {
|
||||
return json({ status: 'ok', data: { name: 'app', organization: opts.appOrg, providers: [] } })
|
||||
}
|
||||
if (url.includes('/get-account')) {
|
||||
return opts.sessionOwner
|
||||
? json({ status: 'ok', data: { owner: opts.sessionOwner, name: 'z' } })
|
||||
: json({ status: 'error', msg: 'please sign in first' })
|
||||
}
|
||||
return json({ status: 'ok', data: opts.code ?? 'AUTHCODE' })
|
||||
}
|
||||
return { calls, fetchImpl }
|
||||
}
|
||||
|
||||
function org(overrides: Partial<OrgConfig> = {}): OrgConfig {
|
||||
return {
|
||||
orgId: 'hanzo',
|
||||
iamUrl: 'https://hanzo.id',
|
||||
iamIssuer: 'https://hanzo.id',
|
||||
clientId: 'hanzo-console',
|
||||
appName: 'hanzo-console',
|
||||
publicOrigin: 'https://hanzo.id',
|
||||
oauthCallbackOrigin: 'https://hanzo.id',
|
||||
brandPackage: '@hanzo/brand',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// `client.login` is a PURE PASSTHROUGH for `organization`: it sends what the
|
||||
// caller gave it and omits the key when there is nothing to send. That is the
|
||||
// contract these two tests pin, and it is unchanged.
|
||||
//
|
||||
// What DID change is whose job it is to supply one. This used to be deliberate
|
||||
// omission — IAM resolved the user cross-org so a colliding identity
|
||||
// (z@hanzo.ai exists in both `admin` and `hanzo`) landed on admin/* with a full
|
||||
// multi-org session. iam2 removed that on purpose, treating the collision as a
|
||||
// defect ("the F-2 bug where z@hanzo.ai collided across admin and hanzo": it
|
||||
// coupled lockout counters across rows and gave a brute-force oracle on the
|
||||
// superadmin), and now REFUSES an org-less login. So LoginForm resolves the
|
||||
// app's own org via get-app-login and always passes one. Do not re-add an
|
||||
// omit-the-org path here expecting the server to figure it out — it will not,
|
||||
// and it fails with an HTTP 200 that reads like a wrong password.
|
||||
test('login omits organization when the caller supplies none', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
|
||||
await client.login({
|
||||
identifier: 'z@hanzo.ai',
|
||||
password: 'pw',
|
||||
clientId: 'hanzo-console',
|
||||
application: 'hanzo-console',
|
||||
// organization intentionally not provided — LoginForm now always resolves one
|
||||
})
|
||||
|
||||
assert.equal(calls.length, 1)
|
||||
assert.equal(
|
||||
'organization' in calls[0]!.body,
|
||||
false,
|
||||
'organization must be absent when the caller supplies none — the client never invents one',
|
||||
)
|
||||
// The identity + app still ride the request.
|
||||
assert.equal(calls[0]!.body.username, 'z@hanzo.ai')
|
||||
assert.equal(calls[0]!.body.application, 'hanzo-console')
|
||||
})
|
||||
|
||||
// An empty-string org is treated the same as unset (defensive: a catalog might
|
||||
// emit "").
|
||||
test('login omits organization when it is an empty string', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
await client.login({
|
||||
identifier: 'z@hanzo.ai',
|
||||
password: 'pw',
|
||||
clientId: 'hanzo-console',
|
||||
application: 'hanzo-console',
|
||||
organization: '',
|
||||
})
|
||||
assert.equal('organization' in calls[0]!.body, false)
|
||||
})
|
||||
|
||||
// A brand that DELIBERATELY scopes its portal to one org can still force it.
|
||||
test('login INCLUDES organization when one is explicitly provided', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
await client.login({
|
||||
identifier: 'someone',
|
||||
password: 'pw',
|
||||
clientId: 'hanzo-console',
|
||||
application: 'hanzo-console',
|
||||
organization: 'hanzo',
|
||||
})
|
||||
assert.equal(calls[0]!.body.organization, 'hanzo')
|
||||
})
|
||||
|
||||
// Per-app SSO: the downstream app's client_id + redirect_uri still flow through;
|
||||
// `type` flips to `code` and the org is STILL omitted (resolution stays correct
|
||||
// for the SSO path too).
|
||||
test('app SSO (redirectUri present) uses type=code and still omits organization', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
await client.login({
|
||||
identifier: 'z@hanzo.ai',
|
||||
password: 'pw',
|
||||
clientId: 'hanzo-console',
|
||||
application: 'hanzo-console',
|
||||
redirectUri: 'https://console.hanzo.ai/auth/iam/callback',
|
||||
state: 'xyz',
|
||||
})
|
||||
assert.equal(calls[0]!.body.type, 'code')
|
||||
assert.match(calls[0]!.url, /type=code/)
|
||||
assert.equal('organization' in calls[0]!.body, false)
|
||||
})
|
||||
|
||||
// Signup MUST still carry a concrete org — you cannot create a user in "no org".
|
||||
test('signup STILL sends organization (unchanged — create needs a concrete org)', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
await client.signup({
|
||||
email: 'new@hanzo.ai',
|
||||
password: 'pw',
|
||||
clientId: 'hanzo-console',
|
||||
application: 'hanzo-console',
|
||||
organization: 'hanzo',
|
||||
})
|
||||
assert.equal(calls[0]!.body.organization, 'hanzo')
|
||||
})
|
||||
|
||||
// REGRESSION (every new customer was stranded on the portal): IAM's signup is
|
||||
// CREATE-ONLY — it sets no session and mints no code. Signup must therefore end
|
||||
// in a real sign-in, or the app that sent the user waits forever for a code.
|
||||
test('signup COMPLETES the OIDC request — create, then sign in, then redirect back with the code', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const res = await client.signup({
|
||||
email: 'new@hanzo.ai',
|
||||
password: 'pw',
|
||||
clientId: 'hanzo-app',
|
||||
application: 'hanzo-app',
|
||||
organization: 'hanzo',
|
||||
redirectUri: 'https://hanzo.app/auth/callback',
|
||||
state: 'xyz',
|
||||
codeChallenge: 'CHALLENGE',
|
||||
codeChallengeMethod: 'S256',
|
||||
})
|
||||
|
||||
// Two legs, in order: create the row, then authenticate it.
|
||||
assert.equal(calls.length, 2)
|
||||
assert.match(calls[0]!.url, /\/v1\/iam\/signup/)
|
||||
assert.match(calls[1]!.url, /\/v1\/iam\/login/)
|
||||
|
||||
// The sign-in leg carries the downstream request, so the code is PKCE-bound.
|
||||
assert.match(calls[1]!.url, /code_challenge=CHALLENGE/)
|
||||
assert.match(calls[1]!.url, /code_challenge_method=S256/)
|
||||
assert.match(calls[1]!.url, /type=code/)
|
||||
assert.equal(calls[1]!.body.username, 'new@hanzo.ai')
|
||||
|
||||
// And the caller is handed a destination BACK AT THE APP — never the portal's
|
||||
// own /onboarding, which is where the create-only response used to land.
|
||||
assert.equal(res.redirectUrl, 'https://hanzo.app/auth/callback?code=AUTHCODE&state=xyz')
|
||||
})
|
||||
|
||||
// `autoSignin` was posted for its name and dropped on the floor: the Go
|
||||
// signupForm has no such field, so it never signed anyone in. Do not post a flag
|
||||
// the server does not read — it is what made this look like it worked.
|
||||
test('signup does NOT post autoSignin (IAM has no such field)', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
await client.signup({
|
||||
email: 'new@hanzo.ai',
|
||||
password: 'pw',
|
||||
clientId: 'hanzo-app',
|
||||
application: 'hanzo-app',
|
||||
organization: 'hanzo',
|
||||
})
|
||||
assert.equal('autoSignin' in calls[0]!.body, false)
|
||||
})
|
||||
|
||||
// IAM refuses with HTTP 200 + status:"error", so the status code proves nothing.
|
||||
// A refused create must surface the reason and must NOT go on to try a login.
|
||||
test('a refused signup surfaces the reason and never attempts a sign-in', async () => {
|
||||
const seen: string[] = []
|
||||
const fetchImpl: typeof fetch = async (input) => {
|
||||
seen.push(typeof input === 'string' ? input : input.toString())
|
||||
return new Response(JSON.stringify({ status: 'error', msg: 'email already exists', data: null }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const res = await client.signup({
|
||||
email: 'taken@hanzo.ai',
|
||||
password: 'pw',
|
||||
clientId: 'hanzo-app',
|
||||
application: 'hanzo-app',
|
||||
organization: 'hanzo',
|
||||
redirectUri: 'https://hanzo.app/auth/callback',
|
||||
})
|
||||
assert.equal(res.error, 'email already exists')
|
||||
assert.equal(res.redirectUrl, undefined)
|
||||
assert.equal(seen.length, 1)
|
||||
assert.match(seen[0]!, /\/v1\/iam\/signup/)
|
||||
})
|
||||
|
||||
// REGRESSION (the `hanzo-iam does not exist` social-login bug): an IAM
|
||||
// app-provider LINK can carry an outer `name` that is NOT the provider record's
|
||||
// name (some seeds label it `<org>-iam`). The provider's real identity is the
|
||||
// nested `provider.name` the backend resolves on the social hop. getAppLogin
|
||||
// MUST surface the inner record name (`provider-github`), never the outer label,
|
||||
// or SocialButtons posts `provider=<org>-iam` and the backend 400s.
|
||||
function appLoginFetch(payload: unknown): typeof fetch {
|
||||
return async () =>
|
||||
new Response(JSON.stringify({ status: 'ok', data: payload }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
test('getAppLogin uses the nested provider record name, not the outer link label', async () => {
|
||||
const fetchImpl = appLoginFetch({
|
||||
name: 'hanzo-console',
|
||||
organization: 'hanzo',
|
||||
providers: [
|
||||
{
|
||||
// Outer link label — a real-world seed set this to the per-app default.
|
||||
name: 'hanzo-iam',
|
||||
canSignIn: true,
|
||||
canSignUp: true,
|
||||
// Nested provider RECORD — the true identity + creds.
|
||||
provider: { name: 'provider-github', type: 'GitHub', clientId: 'Iv23li_real', scopes: '' },
|
||||
},
|
||||
],
|
||||
})
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const app = await client.getAppLogin('hanzo-console')
|
||||
assert.ok(app, 'app login resolved')
|
||||
assert.equal(app!.providers.length, 1)
|
||||
const gh = app!.providers[0]!
|
||||
assert.equal(gh.name, 'provider-github', 'provider name comes from the nested record')
|
||||
assert.equal(gh.key, 'github', 'key strips the provider- prefix')
|
||||
assert.equal(gh.type, 'GitHub')
|
||||
assert.equal(gh.configured, true, 'a real (non-placeholder) clientId is configured')
|
||||
})
|
||||
|
||||
// Federation is entered by NAMING the provider on IAM's own authorize endpoint.
|
||||
// The `provider` field has always been on OAuthAuthorizeRequest; `authorize`
|
||||
// never emitted it, which is why social sign-in had no server side at all.
|
||||
test('authorize emits the provider record name, so IAM federates instead of showing its login', () => {
|
||||
const client = createAuthClient({ org: org({ iamUrl: 'https://hanzo.id' }) })
|
||||
const url = new URL(
|
||||
client.authorize({
|
||||
clientId: 'hanzo-console',
|
||||
redirectUri: 'https://hanzo.id/callback',
|
||||
state: 'rp1',
|
||||
codeChallenge: 'C1',
|
||||
codeChallengeMethod: 'S256',
|
||||
provider: 'provider-github',
|
||||
}),
|
||||
)
|
||||
assert.equal(url.pathname, '/v1/iam/oauth/authorize')
|
||||
// The RECORD name, never the bare key: federationProvider matches
|
||||
// ProviderItem.Name exactly (live, `provider=github` is refused).
|
||||
assert.equal(url.searchParams.get('provider'), 'provider-github')
|
||||
// The app's own request is what IAM binds the minted code to.
|
||||
assert.equal(url.searchParams.get('client_id'), 'hanzo-console')
|
||||
assert.equal(url.searchParams.get('redirect_uri'), 'https://hanzo.id/callback')
|
||||
assert.equal(url.searchParams.get('code_challenge'), 'C1')
|
||||
assert.equal(url.searchParams.get('code_challenge_method'), 'S256')
|
||||
})
|
||||
|
||||
test('authorize without a provider stays the ordinary hosted-login request', () => {
|
||||
const client = createAuthClient({ org: org({ iamUrl: 'https://hanzo.id' }) })
|
||||
const url = new URL(
|
||||
client.authorize({ clientId: 'hanzo-console', redirectUri: 'https://hanzo.id/callback', state: 'rp1' }),
|
||||
)
|
||||
assert.equal(url.searchParams.get('provider'), null)
|
||||
})
|
||||
|
||||
// When there is NO nested record (degenerate seed), fall back to the outer label
|
||||
// so the provider is still surfaced rather than dropped.
|
||||
test('getAppLogin falls back to the outer name when no nested provider record', async () => {
|
||||
const fetchImpl = appLoginFetch({
|
||||
name: 'hanzo-console',
|
||||
organization: 'hanzo',
|
||||
providers: [{ name: 'provider-google', canSignIn: true, canSignUp: true, provider: null }],
|
||||
})
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const app = await client.getAppLogin('hanzo-console')
|
||||
assert.ok(app)
|
||||
assert.equal(app!.providers[0]!.name, 'provider-google')
|
||||
})
|
||||
|
||||
// TRUE SSO — the silent leg. silentLogin carries NO credentials: IAM mints the
|
||||
// code from the existing issuer session (cookie sent via credentials:include).
|
||||
// It builds the redirect back to the app from the minted code + state. The mint
|
||||
// runs ONLY when the ambient session's org matches the app's org (same-org SSO,
|
||||
// the common case: a hanzo session signing into a hanzo app).
|
||||
test('silentLogin (same-org session) mints the code and redirects, carrying NO credentials', async () => {
|
||||
const { calls, fetchImpl } = routingFetch({ appOrg: 'hanzo', sessionOwner: 'hanzo' })
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
|
||||
const r = await client.silentLogin({
|
||||
clientId: 'hanzo-console',
|
||||
application: 'hanzo-console',
|
||||
redirectUri: 'https://console.hanzo.ai/auth/iam/callback',
|
||||
state: 'st1',
|
||||
codeChallenge: 'chal',
|
||||
})
|
||||
|
||||
// The mint leg (POST /v1/iam/login, type=code) ran after the org gate passed.
|
||||
const mint = calls.find((c) => c.body.type === 'code')
|
||||
assert.ok(mint, 'mint leg ran for a same-org session')
|
||||
// No credentials of any kind — this is session-only.
|
||||
assert.equal('username' in mint!.body, false, 'no username in silent login')
|
||||
assert.equal('password' in mint!.body, false, 'no password in silent login')
|
||||
assert.equal('provider' in mint!.body, false, 'no provider hop in silent login')
|
||||
assert.equal(mint!.body.application, 'hanzo-console')
|
||||
// OAuth params ride the query so IAM mints a code for the right client + PKCE.
|
||||
assert.match(mint!.url, /clientId=hanzo-console/)
|
||||
assert.match(mint!.url, /code_challenge=chal/)
|
||||
// The mint returns data:'AUTHCODE' -> a fully-formed app redirect.
|
||||
assert.equal(
|
||||
r.redirectUrl,
|
||||
'https://console.hanzo.ai/auth/iam/callback?code=AUTHCODE&state=st1',
|
||||
)
|
||||
})
|
||||
|
||||
// THE ADMIN-GUARD FIX: silent SSO must NOT reuse a session that belongs to a
|
||||
// DIFFERENT org than the app being signed into. An operator with an ambient
|
||||
// hanzo/* session hitting the admin-guard (org=admin) must fall through to the
|
||||
// interactive form (which authenticates in the admin org and resolves the
|
||||
// admin/* identity) — NOT silently mint a code from the hanzo session (which
|
||||
// would confer owner=hanzo and shadow the fix). No mint leg runs; no redirect.
|
||||
test('silentLogin (cross-org session) does NOT mint — falls back to the form', async () => {
|
||||
const { calls, fetchImpl } = routingFetch({ appOrg: 'admin', sessionOwner: 'hanzo' })
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
|
||||
const r = await client.silentLogin({
|
||||
clientId: 'hanzo-admin-guard',
|
||||
application: 'hanzo-admin-guard',
|
||||
redirectUri: 'https://admin.hanzo.ai/__guard/callback',
|
||||
state: 'st1',
|
||||
codeChallenge: 'chal',
|
||||
})
|
||||
|
||||
assert.equal(r.redirectUrl, undefined, 'no silent redirect for a cross-org session')
|
||||
assert.equal(calls.some((c) => c.body.type === 'code'), false, 'the mint leg must NOT run')
|
||||
})
|
||||
|
||||
// Same-org SSO still holds when BOTH are the admin org: an operator already
|
||||
// signed in as admin/* silently re-enters the admin console.
|
||||
test('silentLogin (same admin-org session) mints for the admin-guard', async () => {
|
||||
const { calls, fetchImpl } = routingFetch({ appOrg: 'admin', sessionOwner: 'admin' })
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const r = await client.silentLogin({
|
||||
clientId: 'hanzo-admin-guard',
|
||||
application: 'hanzo-admin-guard',
|
||||
redirectUri: 'https://admin.hanzo.ai/__guard/callback',
|
||||
state: 'st1',
|
||||
})
|
||||
assert.ok(calls.find((c) => c.body.type === 'code'), 'mint leg ran for a same-org admin session')
|
||||
assert.equal(r.redirectUrl, 'https://admin.hanzo.ai/__guard/callback?code=AUTHCODE&state=st1')
|
||||
})
|
||||
|
||||
// No live session: silentLogin returns an empty response (no mint) so Login.tsx
|
||||
// falls back to the interactive form (never a dead end).
|
||||
test('silentLogin returns empty (no mint) when there is no session (form fallback)', async () => {
|
||||
const { calls, fetchImpl } = routingFetch({ appOrg: 'hanzo', sessionOwner: null })
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const r = await client.silentLogin({
|
||||
clientId: 'hanzo-console',
|
||||
application: 'hanzo-console',
|
||||
redirectUri: 'https://console.hanzo.ai/auth/iam/callback',
|
||||
})
|
||||
assert.equal(r.redirectUrl, undefined)
|
||||
assert.equal(calls.some((c) => c.body.type === 'code'), false, 'no mint without a session')
|
||||
})
|
||||
|
||||
// ── Device-authorization approval (RFC 8628) ─────────────────────────────────
|
||||
// approveDevice rides the issuer SESSION (like silentLogin): NO credentials in
|
||||
// the body, `type:device` + the userCode IAM keys its DeviceAuthMap on, plus the
|
||||
// org application/organization for the app lookup. On {status:ok} the device
|
||||
// code is approved (UserSignIn=true) and the CLI's token poll succeeds.
|
||||
test('approveDevice posts type=device + normalized userCode + org app/org, NO credentials', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
|
||||
const r = await client.approveDevice('K7M4P2QH')
|
||||
|
||||
assert.equal(calls.length, 1)
|
||||
assert.equal(calls[0]!.body.type, 'device')
|
||||
assert.equal(calls[0]!.body.userCode, 'K7M4P2QH')
|
||||
assert.equal(calls[0]!.body.application, 'hanzo-console')
|
||||
assert.equal(calls[0]!.body.organization, 'hanzo')
|
||||
// Session-only: never any credentials in a device approval.
|
||||
assert.equal('username' in calls[0]!.body, false)
|
||||
assert.equal('password' in calls[0]!.body, false)
|
||||
assert.equal('provider' in calls[0]!.body, false)
|
||||
assert.match(calls[0]!.url, /type=device/)
|
||||
assert.equal(r.ok, true)
|
||||
})
|
||||
|
||||
// IAM mints codes from an UPPERCASE unambiguous alphabet ([A-HJ-NP-Z2-9]); a
|
||||
// human may transcribe them lower-cased or with stray spaces/dashes. Normalize
|
||||
// TO uppercase so the lookup matches — case-insensitive entry, exact-match send.
|
||||
test('approveDevice uppercases and strips spaces/dashes before sending', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
await client.approveDevice(' k7m4-p2qh ')
|
||||
assert.equal(calls[0]!.body.userCode, 'K7M4P2QH')
|
||||
})
|
||||
|
||||
// An empty/blank code never hits the network — fail fast with a clear message.
|
||||
test('approveDevice rejects an empty code without calling fetch', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const r = await client.approveDevice(' ')
|
||||
assert.equal(calls.length, 0)
|
||||
assert.equal(r.ok, false)
|
||||
assert.ok(r.error)
|
||||
})
|
||||
|
||||
// The IAM error message (e.g. "UserCode Expired") is surfaced verbatim.
|
||||
test('approveDevice surfaces the IAM error message', async () => {
|
||||
const fetchImpl: typeof fetch = async () =>
|
||||
new Response(JSON.stringify({ status: 'error', msg: 'UserCode Expired' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const r = await client.approveDevice('K7M4P2QH')
|
||||
assert.equal(r.ok, false)
|
||||
assert.equal(r.error, 'UserCode Expired')
|
||||
})
|
||||
|
||||
// Consent branch: {status:ok, data:{required:true}} → {ok:false, required:true}
|
||||
// so the page can render consent instead of treating it as success or a dead end.
|
||||
test('approveDevice maps the consent-required branch to { required: true }', async () => {
|
||||
const fetchImpl: typeof fetch = async () =>
|
||||
new Response(JSON.stringify({ status: 'ok', data: { required: true } }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const r = await client.approveDevice('K7M4P2QH')
|
||||
assert.equal(r.ok, false)
|
||||
assert.equal(r.required, true)
|
||||
assert.equal(r.error, undefined)
|
||||
})
|
||||
|
||||
// ── Which application is this code for? (deviceInfo) ─────────────────────────
|
||||
// A one-call double for `POST /v1/iam/oauth/device/info`: records what the
|
||||
// request actually was (URL, method, credentials, body) and answers with
|
||||
// `payload`.
|
||||
function deviceInfoFetch(payload: unknown) {
|
||||
const calls: {
|
||||
url: string
|
||||
method?: string
|
||||
credentials?: RequestCredentials
|
||||
body?: string
|
||||
}[] = []
|
||||
const fetchImpl: typeof fetch = async (input, init) => {
|
||||
calls.push({
|
||||
url: typeof input === 'string' ? input : input.toString(),
|
||||
method: init?.method,
|
||||
credentials: init?.credentials,
|
||||
body: typeof init?.body === 'string' ? init.body : undefined,
|
||||
})
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
return { calls, fetchImpl }
|
||||
}
|
||||
|
||||
// THE REGRESSION THIS FILE EXISTS FOR. The approval page used to render
|
||||
// `org.appName` — the PORTAL's own branding, the static `hanzo-console` this
|
||||
// test's org() is configured with — so a device sign-in started by `hanzo-cli`
|
||||
// was approved under a screen naming a different application. The name must come
|
||||
// off the RESPONSE, which is the code's own application, and never off the org
|
||||
// config; asserting both is what keeps the two from being confused again.
|
||||
test('deviceInfo names the RESPONSE client, never the portal org appName', async () => {
|
||||
const { calls, fetchImpl } = deviceInfoFetch({
|
||||
status: 'ok',
|
||||
data: { clientId: 'hanzo-cli', displayName: 'Hanzo CLI' },
|
||||
})
|
||||
const cfg = org()
|
||||
const client = createAuthClient({ org: cfg, fetchImpl })
|
||||
|
||||
const r = await client.deviceInfo('K7M4P2QH')
|
||||
|
||||
assert.equal(r.ok, true)
|
||||
assert.equal(r.ok && r.clientId, 'hanzo-cli')
|
||||
assert.equal(r.ok && r.displayName, 'Hanzo CLI')
|
||||
// The portal is hanzo-console. Nothing about it may reach the result.
|
||||
assert.equal(cfg.appName, 'hanzo-console')
|
||||
assert.notEqual(r.ok && r.clientId, cfg.appName)
|
||||
assert.notEqual(r.ok && r.displayName, cfg.appName)
|
||||
|
||||
// A session-cookie POST at the /v1/ device-info path. The user_code is the one
|
||||
// secret in this flow, so it rides the BODY: a request line is copied into
|
||||
// ingress and proxy access logs where a body is not, and this page ships
|
||||
// scrubUrl() precisely to keep the code out of URLs.
|
||||
assert.equal(calls.length, 1)
|
||||
assert.equal(calls[0]!.url, 'https://hanzo.id/v1/iam/oauth/device/info')
|
||||
assert.equal(calls[0]!.method, 'POST')
|
||||
assert.equal(calls[0]!.credentials, 'include')
|
||||
assert.equal(calls[0]!.body, JSON.stringify({ userCode: 'K7M4P2QH' }))
|
||||
assert.equal(calls[0]!.url.includes('K7M4P2QH'), false)
|
||||
})
|
||||
|
||||
// Same normalization as the approval: a code transcribed lower-cased or with
|
||||
// dashes must resolve to the same row IAM minted, or the page would refuse to
|
||||
// name an application that is perfectly live.
|
||||
test('deviceInfo uppercases and strips spaces/dashes into the body', async () => {
|
||||
const { calls, fetchImpl } = deviceInfoFetch({
|
||||
status: 'ok',
|
||||
data: { clientId: 'hanzo-cli', displayName: 'Hanzo CLI' },
|
||||
})
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
await client.deviceInfo(' k7m4-p2qh ')
|
||||
assert.equal(calls[0]!.url, 'https://hanzo.id/v1/iam/oauth/device/info')
|
||||
assert.equal(calls[0]!.body, JSON.stringify({ userCode: 'K7M4P2QH' }))
|
||||
})
|
||||
|
||||
// An empty code names nothing and never hits the network.
|
||||
test('deviceInfo rejects an empty code without calling fetch', async () => {
|
||||
const { calls, fetchImpl } = deviceInfoFetch({ status: 'ok', data: {} })
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const r = await client.deviceInfo(' ')
|
||||
assert.equal(calls.length, 0)
|
||||
assert.equal(r.ok, false)
|
||||
assert.equal(r.ok === false && r.loginRequired, undefined)
|
||||
})
|
||||
|
||||
// IAM `CodeLoginRequired`: the session lapsed. Flagged separately from a refusal
|
||||
// because the page's answer is to sign the human in and come back, not to give up.
|
||||
test('deviceInfo flags login_required distinctly from a refusal', async () => {
|
||||
const { fetchImpl } = deviceInfoFetch({
|
||||
status: 'error',
|
||||
msg: 'please sign in first',
|
||||
code: 'login_required',
|
||||
})
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const r = await client.deviceInfo('K7M4P2QH')
|
||||
assert.equal(r.ok, false)
|
||||
assert.equal(r.ok === false && r.loginRequired, true)
|
||||
assert.equal(r.ok === false && r.error, 'please sign in first')
|
||||
})
|
||||
|
||||
// The ONE opaque refusal IAM answers for unknown / expired / already-approved —
|
||||
// surfaced verbatim, carrying no loginRequired, so the page shows it and offers
|
||||
// no approval. Distinguishing those three would be an oracle for hunting the
|
||||
// 40-bit user_code; the client must not invent a distinction either.
|
||||
test('deviceInfo surfaces the opaque refusal verbatim and does not name an app', async () => {
|
||||
const { fetchImpl } = deviceInfoFetch({
|
||||
status: 'error',
|
||||
msg: 'the user code is invalid or expired',
|
||||
})
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const r = await client.deviceInfo('K7M4P2QH')
|
||||
assert.equal(r.ok, false)
|
||||
assert.equal(r.ok === false && r.error, 'the user code is invalid or expired')
|
||||
assert.equal(r.ok === false && r.loginRequired, undefined)
|
||||
})
|
||||
|
||||
// The org-boundary refusal is a plain refusal too: surfaced, not special-cased.
|
||||
test('deviceInfo surfaces the wrong-org refusal', async () => {
|
||||
const { fetchImpl } = deviceInfoFetch({
|
||||
status: 'error',
|
||||
msg: 'your organization may not approve this device sign-in',
|
||||
})
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const r = await client.deviceInfo('K7M4P2QH')
|
||||
assert.equal(r.ok, false)
|
||||
assert.equal(r.ok === false && r.error, 'your organization may not approve this device sign-in')
|
||||
})
|
||||
|
||||
// An HTML error page from a proxy is not an application name. It must fail,
|
||||
// never resolve to a blank or guessed one.
|
||||
test('deviceInfo fails on a non-JSON response', async () => {
|
||||
const fetchImpl: typeof fetch = async () =>
|
||||
new Response('<html>502 Bad Gateway</html>', {
|
||||
status: 502,
|
||||
headers: { 'Content-Type': 'text/html' },
|
||||
})
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const r = await client.deviceInfo('K7M4P2QH')
|
||||
assert.equal(r.ok, false)
|
||||
assert.match(String(r.ok === false && r.error), /non-JSON/)
|
||||
})
|
||||
|
||||
// A network failure resolves — never rejects — so the page renders the failure
|
||||
// instead of tearing down on an unhandled rejection.
|
||||
test('deviceInfo resolves an error when fetch throws', async () => {
|
||||
const fetchImpl: typeof fetch = async () => {
|
||||
throw new Error('offline')
|
||||
}
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const r = await client.deviceInfo('K7M4P2QH')
|
||||
assert.equal(r.ok, false)
|
||||
assert.match(String(r.ok === false && r.error), /offline/)
|
||||
})
|
||||
|
||||
// A 200 that names no client is not a name. Falling back to ANY local string here
|
||||
// is what produced the original defect, so an absent clientId is a failure.
|
||||
test('deviceInfo refuses an ok response with no clientId', async () => {
|
||||
const { fetchImpl } = deviceInfoFetch({ status: 'ok', data: { displayName: 'Hanzo CLI' } })
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const r = await client.deviceInfo('K7M4P2QH')
|
||||
assert.equal(r.ok, false)
|
||||
})
|
||||
|
||||
// IAM already falls back to the app's name when DisplayName is empty; if one ever
|
||||
// arrives blank anyway, the label is the server-confirmed clientId — never the portal's.
|
||||
test('deviceInfo falls back to the confirmed clientId when displayName is empty', async () => {
|
||||
const { fetchImpl } = deviceInfoFetch({ status: 'ok', data: { clientId: 'hanzo-cli', displayName: '' } })
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const r = await client.deviceInfo('K7M4P2QH')
|
||||
assert.equal(r.ok, true)
|
||||
assert.equal(r.ok && r.displayName, 'hanzo-cli')
|
||||
})
|
||||
|
||||
// getAppLogin's redirectUri is validated by IAM against the app's REGISTERED
|
||||
// list. A cross-app SSO read (the console's `hanzo-cloud` viewed from hanzo.id)
|
||||
// MUST send the downstream app's OWN redirect_uri — the portal's `/callback` is
|
||||
// not in that app's list, so hardcoding it makes IAM drop the response and no
|
||||
// social buttons resolve. Absent, it defaults to the portal's own callback.
|
||||
test('getAppLogin sends the passed redirect_uri, and defaults to the portal callback when omitted', async () => {
|
||||
const urls: string[] = []
|
||||
const fetchImpl: typeof fetch = async (input) => {
|
||||
urls.push(typeof input === 'string' ? input : input.toString())
|
||||
return new Response(
|
||||
JSON.stringify({ status: 'ok', data: { name: 'hanzo-cloud', organization: 'hanzo', providers: [] } }),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
)
|
||||
}
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
|
||||
// Cross-app read: the console's registered redirect_uri rides through verbatim.
|
||||
await client.getAppLogin('hanzo-cloud', 'https://console.hanzo.ai/auth/callback')
|
||||
const u1 = new URL(urls[0]!)
|
||||
assert.equal(u1.searchParams.get('clientId'), 'hanzo-cloud')
|
||||
assert.equal(u1.searchParams.get('redirectUri'), 'https://console.hanzo.ai/auth/callback')
|
||||
|
||||
// Bare/own read: no redirect_uri → default to the portal's own /callback.
|
||||
await client.getAppLogin('hanzo-id')
|
||||
const u2 = new URL(urls[1]!)
|
||||
assert.equal(u2.searchParams.get('redirectUri'), 'https://hanzo.id/callback')
|
||||
})
|
||||
+112
-569
@@ -1,36 +1,21 @@
|
||||
import type { OrgConfig } from '@hanzo/id-shared'
|
||||
import type { TenantConfig } from '@hanzo/id-shared'
|
||||
import type {
|
||||
AppLogin,
|
||||
AppProvider,
|
||||
DeviceApprovalResult,
|
||||
DeviceInfoResult,
|
||||
AppLoginInfo,
|
||||
CodeLoginRequest,
|
||||
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 (paths under
|
||||
* `/v1/iam/*` and the OIDC paths under `/v1/iam/oauth/*`). One
|
||||
* client instance per org. The portal creates one in `createRoot()`;
|
||||
* 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()`;
|
||||
* downstream pages call `.login()`, `.signup()`, `.forgot()`, `.authorize()`
|
||||
* directly.
|
||||
*
|
||||
@@ -47,302 +32,59 @@ export function mfaChannelOf(iamType: string): MfaChannel {
|
||||
* past the redirect.
|
||||
*/
|
||||
export interface AuthClient {
|
||||
readonly org: OrgConfig
|
||||
readonly tenant: TenantConfig
|
||||
login(req: LoginRequest): Promise<LoginResponse>
|
||||
/**
|
||||
* Silent single-sign-on: mint an authorization code from the EXISTING issuer
|
||||
* session (the `iam_session_id` cookie set when the user signed in once for
|
||||
* another app) — no credentials, no provider hop. Returns `{ redirectUrl }`
|
||||
* (the app's `redirect_uri` + `?code=&state=`) when a live session exists, or
|
||||
* `{ error }` when it does not so the caller renders the interactive form.
|
||||
* This is the seamless 2nd/3rd-app login leg.
|
||||
*/
|
||||
silentLogin(req: SilentLoginRequest): Promise<LoginResponse>
|
||||
/**
|
||||
* Approve an RFC 8628 device-authorization request from the device-approval
|
||||
* page (`/login/oauth/device`). The user MUST already be signed in to the
|
||||
* issuer — this rides the SAME `iam_session_id` cookie as silent SSO
|
||||
* (`credentials:'include'`, no credentials in the body). It POSTs
|
||||
* `/v1/iam/login` with `type:'device'` + the `userCode` the device shows,
|
||||
* plus the org's `application`/`organization`; IAM resolves the user from
|
||||
* the session, flips the device code's `UserSignIn=true`, and the CLI's token
|
||||
* poll then succeeds. Returns `{required:true}` when the app needs consent
|
||||
* first (rare for first-party apps), or `{error}` with the IAM message.
|
||||
*/
|
||||
approveDevice(userCode: string): Promise<DeviceApprovalResult>
|
||||
/**
|
||||
* Name the application a pending device code belongs to, so the approval page
|
||||
* can say WHICH app it is authorizing — `GET
|
||||
* /v1/iam/oauth/device/<user_code>`, riding the same `iam_session_id` cookie
|
||||
* as {@link approveDevice}.
|
||||
*
|
||||
* Read this and render it; never `org.appName`, which is this portal's own
|
||||
* branding and names a different application than the one that minted the
|
||||
* code. IAM answers from the code's own application row.
|
||||
*
|
||||
* Session-gated and deliberately terse: an expired session comes back as
|
||||
* `loginRequired`, and unknown / expired / already-approved all come back as
|
||||
* ONE indistinguishable refusal, because a user_code is 40 bits and an
|
||||
* endpoint that told them apart would be an oracle for hunting live codes.
|
||||
*/
|
||||
deviceInfo(userCode: string): Promise<DeviceInfoResult>
|
||||
signup(req: SignupRequest): Promise<LoginResponse>
|
||||
forgot(req: ForgotRequest): Promise<{ ok: boolean; error?: string }>
|
||||
authorize(req: OAuthAuthorizeRequest): string
|
||||
exchange(code: string, codeVerifier?: string): Promise<TokenResponse>
|
||||
logout(idTokenHint?: string, postLogoutRedirectUri?: string): string
|
||||
/**
|
||||
* Read the live enabled-auth-methods view for an application from
|
||||
* `/v1/iam/get-app-login` — the canonical source of truth for which
|
||||
* sign-in buttons (password / GitHub / Google / Web3) to render.
|
||||
* Resolves to null when the endpoint is unreachable so callers can fall
|
||||
* back to the org's declared default method set.
|
||||
*
|
||||
* `redirectUri` is validated by IAM against the app's registered list. For a
|
||||
* cross-app SSO read (e.g. console → hanzo.id, `clientId=hanzo-cloud`) pass the
|
||||
* DOWNSTREAM app's own OIDC `redirect_uri` — the portal's `/callback` is NOT in
|
||||
* that app's list, so hardcoding it makes IAM answer `status:error`
|
||||
* ("Redirect URI … doesn't exist in the allowed list") and drops the whole
|
||||
* response. Omit it for a bare/own-app read (defaults to the portal callback).
|
||||
*/
|
||||
getAppLogin(clientId?: string, redirectUri?: string): Promise<AppLogin | null>
|
||||
/**
|
||||
* Resolve the signed-in user's `{owner, name}` from the IAM session
|
||||
* (`/v1/iam/get-account`). After a `RequiredMfa` login the IAM session cookie
|
||||
* already authenticates the user (IAM calls `SetSessionUsername` before
|
||||
* answering `RequiredMfa`), so this is how the portal learns the identity to
|
||||
* key the forced-enrollment calls on. Resolves null when unauthenticated.
|
||||
*/
|
||||
getAccount(): Promise<MfaIdentity | null>
|
||||
/**
|
||||
* Begin TOTP enrollment: `POST /v1/iam/mfa/setup/initiate`. Returns the secret
|
||||
* + `otpauth://` URI + recovery codes. Does NOT persist anything — only
|
||||
* {@link mfaEnable} does.
|
||||
*/
|
||||
mfaInitiate(id: MfaIdentity): Promise<MfaSetup>
|
||||
/** Verify a TOTP code against a pending secret: `POST /v1/iam/mfa/setup/verify`. */
|
||||
mfaVerify(req: MfaIdentity & { secret: string; passcode: string }): Promise<{ ok: boolean; error?: string }>
|
||||
/** Persist a verified TOTP enrollment: `POST /v1/iam/mfa/setup/enable`. */
|
||||
mfaEnable(req: MfaIdentity & { secret: string; recoveryCode: string }): Promise<{ ok: boolean; error?: string }>
|
||||
/**
|
||||
* Answer a `NextMfa` challenge: `POST /v1/iam/login` with `{mfaType, passcode}`
|
||||
* and NO username, riding the MFA session cookie IAM set with `NextMfa`.
|
||||
* Returns the same shape as {@link login} (a redirect with an auth code for the
|
||||
* code flow, or a bare-session signal for portal sign-in).
|
||||
*/
|
||||
mfaChallenge(req: MfaChallengeRequest): Promise<LoginResponse>
|
||||
/** Fetch the application's enabled providers + sign-in methods (drives which buttons render). */
|
||||
appLogin(): Promise<AppLoginInfo>
|
||||
/** Send an email/SMS verification code for passwordless login. dest = email or E.164 phone. */
|
||||
sendLoginCode(dest: string): Promise<{ ok: boolean; error?: string }>
|
||||
/** Complete a passwordless login with the code sent to dest. */
|
||||
loginWithCode(req: CodeLoginRequest): Promise<LoginResponse>
|
||||
}
|
||||
|
||||
export interface AuthClientOptions {
|
||||
readonly org: OrgConfig
|
||||
readonly tenant: TenantConfig
|
||||
/** Override fetch impl (testing). Defaults to global fetch. */
|
||||
readonly fetchImpl?: typeof fetch
|
||||
}
|
||||
|
||||
export function createAuthClient(opts: AuthClientOptions): AuthClient {
|
||||
const org = opts.org
|
||||
const tenant = opts.tenant
|
||||
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', org.iamUrl)
|
||||
const url = new URL('/v1/iam/login', tenant.iamUrl)
|
||||
url.searchParams.set('clientId', req.clientId)
|
||||
url.searchParams.set('responseType', 'code')
|
||||
if (req.redirectUri) url.searchParams.set('redirectUri', req.redirectUri)
|
||||
url.searchParams.set('scope', 'openid profile email')
|
||||
if (req.state) url.searchParams.set('state', req.state)
|
||||
// Echo the downstream OIDC nonce so the minted code -> id_token carries it.
|
||||
// Strict openid-client consumers (LibreChat OPENID_REUSE_TOKENS) reject an
|
||||
// id_token whose nonce != the one they sent ("unexpected JWT claim value").
|
||||
if (req.nonce) url.searchParams.set('nonce', req.nonce)
|
||||
if (req.codeChallenge) {
|
||||
url.searchParams.set('code_challenge', req.codeChallenge)
|
||||
url.searchParams.set('code_challenge_method', req.codeChallengeMethod ?? 'S256')
|
||||
}
|
||||
url.searchParams.set('type', type)
|
||||
// `organization` is an OPTIONAL lookup hint (see LoginRequest). Omit it when
|
||||
// empty so IAM runs its cross-org resolution: a global-admin identity then
|
||||
// resolves to the `admin` org (full multi-org session) instead of being
|
||||
// pinned to — and truncated by — a colliding brand-org row. The session's
|
||||
// org is always the resolved user's real owner, never this hint.
|
||||
const body: Record<string, unknown> = {
|
||||
type,
|
||||
username: req.identifier,
|
||||
password: req.password,
|
||||
application: req.application,
|
||||
signinMethod: 'Password',
|
||||
autoSignin: true,
|
||||
}
|
||||
if (req.organization) body.organization = req.organization
|
||||
const res = await f(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(body),
|
||||
body: JSON.stringify({
|
||||
type,
|
||||
username: req.identifier,
|
||||
password: req.password,
|
||||
application: req.application,
|
||||
organization: req.organization,
|
||||
signinMethod: 'Password',
|
||||
autoSignin: true,
|
||||
}),
|
||||
})
|
||||
return parseLoginResponse(res, req)
|
||||
}
|
||||
|
||||
// Resolve the org of the user in the ambient IAM session (the `iam_session_id`
|
||||
// cookie), or null when there is no live session. Reads `/v1/iam/get-account`;
|
||||
// the org is the `owner` field (IAM returns the User at the top level or
|
||||
// under `data`). Used to keep silent SSO from reusing a session that belongs
|
||||
// to a DIFFERENT org than the app being signed into.
|
||||
async function sessionOwner(): Promise<string | null> {
|
||||
try {
|
||||
const res = await f(new URL('/v1/iam/get-account', org.iamUrl).toString(), {
|
||||
credentials: 'include',
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
if (!res.ok) return null
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
if (body.status === 'error') return null
|
||||
const nested = (typeof body.data === 'object' && body.data ? body.data : {}) as Record<string, unknown>
|
||||
const owner = typeof body.owner === 'string' ? body.owner : nested.owner
|
||||
return typeof owner === 'string' && owner ? owner : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function silentLogin(req: SilentLoginRequest): Promise<LoginResponse> {
|
||||
// Silent SSO may reuse the ambient IAM session ONLY when that session's user
|
||||
// belongs to the SAME org as the app being signed into. A cross-org app —
|
||||
// e.g. the admin-guard (client_id=hanzo-admin-guard, org=admin) reached from
|
||||
// a browser that already holds a hanzo/* session — must NOT mint a code from
|
||||
// the wrong-org session: that confers owner=hanzo and silently shadows the
|
||||
// org-scoped credential form (which resolves the admin/* identity). Resolve
|
||||
// the app's org and the session owner; on no session or an org mismatch,
|
||||
// return an empty response so Login.tsx falls back to the interactive form,
|
||||
// which authenticates in the app's own org. Same-org SSO (the common case)
|
||||
// still mints silently, so seamless sign-in is preserved.
|
||||
const [app, owner] = await Promise.all([getAppLogin(req.clientId), sessionOwner()])
|
||||
if (!owner) return {}
|
||||
const appOrg = app?.organization
|
||||
if (appOrg && owner !== appOrg) return {}
|
||||
|
||||
const url = new URL('/v1/iam/login', org.iamUrl)
|
||||
url.searchParams.set('clientId', req.clientId)
|
||||
url.searchParams.set('responseType', 'code')
|
||||
url.searchParams.set('redirectUri', req.redirectUri)
|
||||
url.searchParams.set('scope', req.scope ?? 'openid profile email')
|
||||
if (req.state) url.searchParams.set('state', req.state)
|
||||
if (req.nonce) url.searchParams.set('nonce', req.nonce)
|
||||
if (req.codeChallenge) {
|
||||
url.searchParams.set('code_challenge', req.codeChallenge)
|
||||
url.searchParams.set('code_challenge_method', req.codeChallengeMethod ?? 'S256')
|
||||
}
|
||||
url.searchParams.set('type', 'code')
|
||||
// NO username/password and NO provider: IAM's Login handler falls through to
|
||||
// its "already signed in to IAM" branch (`GetSessionUsername() != ""`) and
|
||||
// mints an authorization code for `application` from the existing
|
||||
// `iam_session_id` cookie. `credentials: 'include'` sends that cookie. When
|
||||
// there is no live session IAM responds `status:error` -> parseLoginResponse
|
||||
// returns `{ error }`, and Login.tsx renders the interactive form instead.
|
||||
const res = await f(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ type: 'code', application: req.application, autoSignin: true }),
|
||||
})
|
||||
return parseLoginResponse(res, { redirectUri: req.redirectUri, state: req.state })
|
||||
}
|
||||
|
||||
async function approveDevice(userCode: string): Promise<DeviceApprovalResult> {
|
||||
const code = normalizeUserCode(userCode)
|
||||
if (!code) return { ok: false, error: 'Enter the code shown on your device.' }
|
||||
const url = new URL('/v1/iam/login', org.iamUrl)
|
||||
// IAM's device branch keys the cache off the `userCode` in the BODY; the
|
||||
// `type` echo on the query mirrors the other login legs. NO credentials —
|
||||
// the user is already signed in, so this rides the session cookie
|
||||
// (`credentials:'include'`) and IAM resolves the user from the session.
|
||||
url.searchParams.set('type', 'device')
|
||||
const body: Record<string, unknown> = {
|
||||
type: 'device',
|
||||
userCode: code,
|
||||
application: org.appName,
|
||||
}
|
||||
// `organization` scopes the application lookup (FindApplicationByName); it
|
||||
// does NOT resolve the user (that comes from the session), so pinning the
|
||||
// org org here is safe — unlike password login, which omits it.
|
||||
if (org.orgId) body.organization = org.orgId
|
||||
let res: Response
|
||||
try {
|
||||
res = await f(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
} catch (e) {
|
||||
return { ok: false, error: String(e) }
|
||||
}
|
||||
let parsed: Record<string, unknown> = {}
|
||||
try {
|
||||
parsed = (await res.json()) as Record<string, unknown>
|
||||
} catch {
|
||||
return { ok: false, error: `HTTP ${res.status} non-JSON response` }
|
||||
}
|
||||
if (!res.ok || parsed.status === 'error') {
|
||||
return { ok: false, error: typeof parsed.msg === 'string' && parsed.msg ? parsed.msg : `HTTP ${res.status}` }
|
||||
}
|
||||
// Consent branch: {status:ok, data:{required:true}}. First-party apps skip
|
||||
// this; surface it so the caller can render consent rather than dead-ending.
|
||||
const data = parsed.data
|
||||
if (data !== null && typeof data === 'object' && (data as Record<string, unknown>).required === true) {
|
||||
return { ok: false, required: true }
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
async function deviceInfo(userCode: string): Promise<DeviceInfoResult> {
|
||||
const code = normalizeUserCode(userCode)
|
||||
if (!code) return { ok: false, error: 'Enter the code shown on your device.' }
|
||||
// POST, and the code rides the BODY — like `approveDevice` beside it, and for
|
||||
// the reason IAM's own introspection endpoint is POST: the user_code is the one
|
||||
// secret in this flow, and a request line is copied into ingress and proxy
|
||||
// access logs where a body is not. This page ships `scrubUrl()` to keep the
|
||||
// code out of the address bar; putting it into every request line would undo
|
||||
// that server-side. Same session cookie as the approval: whatever you may look
|
||||
// at is exactly what you may approve.
|
||||
const url = new URL('/v1/iam/oauth/device/info', org.iamUrl)
|
||||
let res: Response
|
||||
try {
|
||||
res = await f(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ userCode: code }),
|
||||
})
|
||||
} catch (e) {
|
||||
return { ok: false, error: String(e) }
|
||||
}
|
||||
let parsed: Record<string, unknown> = {}
|
||||
try {
|
||||
parsed = (await res.json()) as Record<string, unknown>
|
||||
} catch {
|
||||
return { ok: false, error: `HTTP ${res.status} non-JSON response` }
|
||||
}
|
||||
if (!res.ok || parsed.status === 'error') {
|
||||
const error = typeof parsed.msg === 'string' && parsed.msg ? parsed.msg : `HTTP ${res.status}`
|
||||
// IAM `CodeLoginRequired` (internal/oidc/oidc.go): the session lapsed between
|
||||
// the page's get-account check and this read. Not a dead end — sign in again.
|
||||
if (parsed.code === 'login_required') return { ok: false, error, loginRequired: true }
|
||||
return { ok: false, error }
|
||||
}
|
||||
// A name is only worth rendering if the server sent it. An answer with no
|
||||
// clientId names nothing, so it fails rather than letting the page fall back
|
||||
// to a guess — showing the WRONG application is the defect this endpoint exists
|
||||
// to fix. `displayName` falls back to the clientId, which IAM did confirm.
|
||||
const data = parsed.data as Record<string, unknown> | undefined
|
||||
const clientId = typeof data?.clientId === 'string' ? data.clientId : ''
|
||||
const displayName = typeof data?.displayName === 'string' ? data.displayName : ''
|
||||
if (!clientId) return { ok: false, error: 'IAM did not name the application for this code.' }
|
||||
return { ok: true, clientId, displayName: displayName || clientId }
|
||||
}
|
||||
|
||||
async function signup(req: SignupRequest): Promise<LoginResponse> {
|
||||
const url = new URL('/v1/iam/signup', org.iamUrl)
|
||||
const url = new URL('/v1/iam/signup', tenant.iamUrl)
|
||||
url.searchParams.set('clientId', req.clientId)
|
||||
const username = req.email.split('@')[0]
|
||||
const res = await f(url.toString(), {
|
||||
@@ -357,48 +99,22 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
|
||||
email: req.email,
|
||||
password: req.password,
|
||||
confirm: req.password,
|
||||
autoSignin: true,
|
||||
...(req.inviteCode ? { invitationCode: req.inviteCode } : {}),
|
||||
}),
|
||||
})
|
||||
|
||||
// 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,
|
||||
})
|
||||
return parseLoginResponse(res)
|
||||
}
|
||||
|
||||
async function forgot(req: ForgotRequest): Promise<{ ok: boolean; error?: string }> {
|
||||
const url = new URL('/v1/iam/send-verification-code', org.iamUrl)
|
||||
const url = new URL('/v1/iam/send-verification-code', tenant.iamUrl)
|
||||
url.searchParams.set('clientId', req.clientId)
|
||||
url.searchParams.set('organization', req.organization)
|
||||
const res = await f(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
applicationId: `admin/${org.appName}`,
|
||||
applicationId: `admin/${tenant.appName}`,
|
||||
organization: req.organization,
|
||||
dest: req.identifier,
|
||||
type: req.identifier.includes('@') ? 'email' : 'phone',
|
||||
@@ -413,31 +129,28 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
|
||||
}
|
||||
|
||||
function authorize(req: OAuthAuthorizeRequest): string {
|
||||
const url = new URL('/v1/iam/oauth/authorize', org.iamUrl)
|
||||
const url = new URL('/v1/iam/oauth/authorize', tenant.iamUrl)
|
||||
url.searchParams.set('client_id', req.clientId)
|
||||
url.searchParams.set('redirect_uri', req.redirectUri)
|
||||
url.searchParams.set('response_type', req.responseType ?? 'code')
|
||||
url.searchParams.set('scope', req.scope ?? 'openid profile email')
|
||||
url.searchParams.set('state', req.state)
|
||||
if (req.provider) url.searchParams.set('provider', req.provider)
|
||||
if (req.nonce) url.searchParams.set('nonce', req.nonce)
|
||||
if (req.codeChallenge) {
|
||||
url.searchParams.set('code_challenge', req.codeChallenge)
|
||||
url.searchParams.set('code_challenge_method', req.codeChallengeMethod ?? 'S256')
|
||||
}
|
||||
// Naming a provider federates the request to that external IdP instead of
|
||||
// the hosted credential login. The type has always declared this field;
|
||||
// never emitting it is why social sign-in had no server side at all.
|
||||
if (req.provider) url.searchParams.set('provider', req.provider)
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
async function exchange(code: string, codeVerifier?: string): Promise<TokenResponse> {
|
||||
const url = new URL('/v1/iam/oauth/token', org.iamUrl)
|
||||
const url = new URL('/v1/iam/oauth/token', tenant.iamUrl)
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
client_id: org.clientId,
|
||||
redirect_uri: `${org.publicOrigin}/callback`,
|
||||
client_id: tenant.clientId,
|
||||
redirect_uri: `${tenant.publicOrigin}/callback`,
|
||||
})
|
||||
if (codeVerifier) body.set('code_verifier', codeVerifier)
|
||||
const res = await f(url.toString(), {
|
||||
@@ -458,275 +171,122 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
|
||||
}
|
||||
|
||||
function logout(idTokenHint?: string, postLogoutRedirectUri?: string): string {
|
||||
const url = new URL('/v1/iam/oauth/logout', org.iamUrl)
|
||||
const url = new URL('/v1/iam/oauth/logout', tenant.iamUrl)
|
||||
if (idTokenHint) url.searchParams.set('id_token_hint', idTokenHint)
|
||||
url.searchParams.set(
|
||||
'post_logout_redirect_uri',
|
||||
postLogoutRedirectUri ?? `${org.publicOrigin}/login`,
|
||||
postLogoutRedirectUri ?? `${tenant.publicOrigin}/login`,
|
||||
)
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
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)
|
||||
// appLogin fetches the application's enabled providers + sign-in methods so
|
||||
// the UI renders exactly what the IAM app offers (social buttons, code login).
|
||||
async function appLogin(): Promise<AppLoginInfo> {
|
||||
const url = new URL('/v1/iam/get-app-login', tenant.iamUrl)
|
||||
url.searchParams.set('clientId', tenant.clientId)
|
||||
url.searchParams.set('responseType', 'code')
|
||||
// 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('redirectUri', `${tenant.publicOrigin}/callback`)
|
||||
url.searchParams.set('scope', 'openid profile email')
|
||||
url.searchParams.set('state', 'app-login')
|
||||
let body: Record<string, unknown>
|
||||
try {
|
||||
const res = await f(url.toString(), { headers: { Accept: 'application/json' } })
|
||||
if (!res.ok) return null
|
||||
body = (await res.json()) as Record<string, unknown>
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (body.status !== 'ok' || typeof body.data !== 'object' || body.data === null) return null
|
||||
return parseAppLogin(body.data as Record<string, unknown>, org.appName, org.orgId)
|
||||
}
|
||||
|
||||
async function getAccount(): Promise<MfaIdentity | null> {
|
||||
const url = new URL('/v1/iam/get-account', org.iamUrl)
|
||||
let body: Record<string, unknown>
|
||||
try {
|
||||
const res = await f(url.toString(), { headers: { Accept: 'application/json' }, credentials: 'include' })
|
||||
if (!res.ok) return null
|
||||
body = (await res.json()) as Record<string, unknown>
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const d = (typeof body.data === 'object' && body.data ? body.data : {}) as Record<string, unknown>
|
||||
if (typeof d.owner !== 'string' || typeof d.name !== 'string' || !d.owner || !d.name) return null
|
||||
return { owner: d.owner, name: d.name }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `/v1/iam/mfa/setup/*` POST URL with EVERY param on the query string
|
||||
* and send an EMPTY body. This is the one wire shape IAM's authz filter and
|
||||
* the MFA controller both accept: the controller reads `owner`/`name`/… from
|
||||
* the merged form (query + body), while the authz filter only extracts the
|
||||
* `{owner,name}` object from the query when the body is empty (a non-empty
|
||||
* body is JSON-unmarshalled, and a urlencoded body fails that parse → empty
|
||||
* object → the self-access match `sub==obj` fails → "Unauthorized operation").
|
||||
* `owner`/`name` ride the query on EVERY call — including `verify`, which
|
||||
* otherwise carries no identity — purely so that self-access check passes.
|
||||
*/
|
||||
async function mfaSetupPost(path: string, params: Record<string, string>): Promise<Record<string, unknown>> {
|
||||
const url = new URL(`/v1/iam/mfa/setup/${path}`, org.iamUrl)
|
||||
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v)
|
||||
const res = await f(url.toString(), { method: 'POST', credentials: 'include' })
|
||||
url.searchParams.set('state', 'login')
|
||||
const res = await f(url.toString(), { 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')
|
||||
const d = (body.data ?? {}) as Record<string, unknown>
|
||||
const providers = Array.isArray(d.providers)
|
||||
? (d.providers as Array<Record<string, unknown>>).map((p) => {
|
||||
const prov = (p.provider ?? {}) as Record<string, unknown>
|
||||
return {
|
||||
name: String(p.name ?? prov.name ?? ''),
|
||||
displayName: typeof prov.displayName === 'string' ? prov.displayName : undefined,
|
||||
type: typeof prov.type === 'string' ? prov.type : undefined,
|
||||
category: typeof prov.category === 'string' ? prov.category : undefined,
|
||||
canSignIn: p.canSignIn !== false,
|
||||
canSignUp: p.canSignUp !== false,
|
||||
}
|
||||
})
|
||||
: []
|
||||
const signinMethods = Array.isArray(d.signinMethods)
|
||||
? (d.signinMethods as Array<Record<string, unknown>>).map((m) => ({
|
||||
name: String(m.name ?? ''),
|
||||
rule: typeof m.rule === 'string' ? m.rule : undefined,
|
||||
}))
|
||||
: []
|
||||
return {
|
||||
mfaType: MFA_TOTP,
|
||||
secret,
|
||||
url,
|
||||
recoveryCodes: Array.isArray(d.recoveryCodes) ? d.recoveryCodes.filter((c): c is string => typeof c === 'string') : [],
|
||||
name: String(d.name ?? tenant.appName),
|
||||
displayName: typeof d.displayName === 'string' ? d.displayName : undefined,
|
||||
providers,
|
||||
signinMethods,
|
||||
enablePassword: d.enablePassword !== false,
|
||||
enableCodeSignin: d.enableCodeSignin === true,
|
||||
enableSignUp: d.enableSignUp !== false,
|
||||
}
|
||||
}
|
||||
|
||||
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 sendLoginCode(dest: string): Promise<{ ok: boolean; error?: string }> {
|
||||
const url = new URL('/v1/iam/send-verification-code', tenant.iamUrl)
|
||||
url.searchParams.set('clientId', tenant.clientId)
|
||||
url.searchParams.set('organization', tenant.orgId)
|
||||
const isEmail = dest.includes('@')
|
||||
const res = await f(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
applicationId: `admin/${tenant.appName}`,
|
||||
organization: tenant.orgId,
|
||||
dest,
|
||||
type: isEmail ? 'email' : 'phone',
|
||||
method: 'login',
|
||||
checkUser: dest,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) return { ok: false, error: `HTTP ${res.status}` }
|
||||
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>
|
||||
if (body.status === 'error') return { ok: false, error: typeof body.msg === 'string' ? body.msg : 'failed' }
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
async function mfaEnable(req: MfaIdentity & { secret: string; recoveryCode: string }): Promise<{ ok: boolean; error?: string }> {
|
||||
try {
|
||||
await mfaSetupPost('enable', {
|
||||
owner: req.owner,
|
||||
name: req.name,
|
||||
mfaType: MFA_TOTP,
|
||||
secret: req.secret,
|
||||
recoveryCodes: req.recoveryCode,
|
||||
})
|
||||
return { ok: true }
|
||||
} catch (e) {
|
||||
return { ok: false, error: e instanceof Error ? e.message : String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async function mfaChallenge(req: MfaChallengeRequest): Promise<LoginResponse> {
|
||||
async function loginWithCode(req: CodeLoginRequest): Promise<LoginResponse> {
|
||||
const type = req.redirectUri ? 'code' : 'login'
|
||||
const url = new URL('/v1/iam/login', org.iamUrl)
|
||||
const url = new URL('/v1/iam/login', tenant.iamUrl)
|
||||
url.searchParams.set('clientId', req.clientId)
|
||||
url.searchParams.set('responseType', 'code')
|
||||
if (req.redirectUri) url.searchParams.set('redirectUri', req.redirectUri)
|
||||
url.searchParams.set('scope', 'openid profile email')
|
||||
if (req.state) url.searchParams.set('state', req.state)
|
||||
if (req.codeChallenge) {
|
||||
url.searchParams.set('code_challenge', req.codeChallenge)
|
||||
url.searchParams.set('code_challenge_method', req.codeChallengeMethod ?? 'S256')
|
||||
}
|
||||
url.searchParams.set('type', type)
|
||||
const isEmail = req.dest.includes('@')
|
||||
const res = await f(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
type,
|
||||
// No username: IAM resolves the user from the MFA session cookie it set
|
||||
// when it answered NextMfa.
|
||||
mfaType: req.mfaType,
|
||||
passcode: req.passcode,
|
||||
username: req.dest,
|
||||
code: req.code,
|
||||
application: req.application,
|
||||
organization: req.organization,
|
||||
enableMfaRemember: req.rememberDevice ?? false,
|
||||
signinMethod: 'Verification code',
|
||||
...(isEmail ? { email: req.dest } : { phone: req.dest }),
|
||||
autoSignin: true,
|
||||
}),
|
||||
})
|
||||
return parseLoginResponse(res, req)
|
||||
}
|
||||
|
||||
return {
|
||||
org,
|
||||
tenant,
|
||||
login,
|
||||
silentLogin,
|
||||
approveDevice,
|
||||
deviceInfo,
|
||||
signup,
|
||||
forgot,
|
||||
authorize,
|
||||
exchange,
|
||||
logout,
|
||||
getAppLogin,
|
||||
getAccount,
|
||||
mfaInitiate,
|
||||
mfaVerify,
|
||||
mfaEnable,
|
||||
mfaChallenge,
|
||||
appLogin,
|
||||
sendLoginCode,
|
||||
loginWithCode,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonicalize a user-entered device code to the form IAM generated. IAM mints
|
||||
* user_codes from an UPPERCASE unambiguous alphabet ([A-HJ-NP-Z2-9], no
|
||||
* I/L/O/0/1) and keys its DeviceAuthMap on the exact string. A human may
|
||||
* transcribe it lower-cased or with stray spaces/dashes, so normalize TO
|
||||
* uppercase and strip separators — case-insensitive entry, an exact-match send.
|
||||
*/
|
||||
function normalizeUserCode(raw: string): string {
|
||||
return raw.trim().toUpperCase().replace(/[\s-]+/g, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider's DISPLAY key — `provider-github` → `github` — used to pick an
|
||||
* icon and a label (`PROVIDER_META`) and to match a `provider_hint`.
|
||||
*
|
||||
* It is NOT what the authorize endpoint wants. `federationProvider` matches the
|
||||
* record name exactly, so `?provider=` must carry the full `provider-github`;
|
||||
* live, `?provider=github` is refused "unknown or unavailable provider". This
|
||||
* comment used to assert the opposite — a bare key — which was never true of the
|
||||
* federation broker.
|
||||
*/
|
||||
function providerKey(name: string): string {
|
||||
return name.replace(/^provider-/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* A provider is renderable only when IAM holds a real OAuth clientId for it.
|
||||
* The seed ships obvious placeholders (`GITHUB_CLIENT_ID_PLACEHOLDER`,
|
||||
* `placeholder`); an empty or placeholder id means the provider isn't
|
||||
* provisioned, so its button is hidden rather than dead-ending the user. Real
|
||||
* OAuth client ids never contain "placeholder".
|
||||
*/
|
||||
function isConfiguredClientId(clientId: string): boolean {
|
||||
return clientId.length > 0 && !/placeholder/i.test(clientId)
|
||||
}
|
||||
|
||||
/** Shape the `/v1/iam/get-app-login` `data` payload into the {@link AppLogin} view. */
|
||||
function parseAppLogin(
|
||||
data: Record<string, unknown>,
|
||||
fallbackApp: string,
|
||||
fallbackOrg: string,
|
||||
): AppLogin {
|
||||
const rawProviders = Array.isArray(data.providers) ? data.providers : []
|
||||
const providers: AppProvider[] = rawProviders
|
||||
.map((p): AppProvider | null => {
|
||||
if (typeof p !== 'object' || p === null) return null
|
||||
const rec = p as Record<string, unknown>
|
||||
// The provider's IDENTITY is the nested provider record's name
|
||||
// (`rec.provider.name`, e.g. `provider-github`) — that is what the IAM
|
||||
// backend's social-login lookup (`GetProvider(admin/<name>)`) resolves.
|
||||
// The OUTER link object's `name` is the app's provider-LINK label, which
|
||||
// some IAM seeds set to a per-app default (e.g. `<org>-iam`); reading
|
||||
// it as the provider name made the hop POST `provider=<org>-iam`, which
|
||||
// the backend rejects ("The provider: <org>-iam does not exist"). Prefer
|
||||
// the inner record name; fall back to the outer label only when there is
|
||||
// no nested provider record. One source of truth: the provider record.
|
||||
const inner =
|
||||
typeof rec.provider === 'object' && rec.provider !== null
|
||||
? (rec.provider as Record<string, unknown>)
|
||||
: {}
|
||||
const innerName = typeof inner.name === 'string' ? inner.name : ''
|
||||
const outerName = typeof rec.name === 'string' ? rec.name : ''
|
||||
const name = innerName || outerName
|
||||
if (!name) return null
|
||||
const clientId = typeof inner.clientId === 'string' ? inner.clientId : ''
|
||||
return {
|
||||
name,
|
||||
key: providerKey(name),
|
||||
canSignIn: rec.canSignIn !== false,
|
||||
canSignUp: rec.canSignUp !== false,
|
||||
configured: isConfiguredClientId(clientId),
|
||||
type: typeof inner.type === 'string' ? inner.type : '',
|
||||
clientId,
|
||||
scopes: typeof inner.scopes === 'string' ? inner.scopes : '',
|
||||
}
|
||||
})
|
||||
.filter((p): p is AppProvider => p !== null)
|
||||
return {
|
||||
application: typeof data.name === 'string' ? data.name : fallbackApp,
|
||||
organization: typeof data.organization === 'string' ? data.organization : fallbackOrg,
|
||||
enablePassword: data.enablePassword !== false,
|
||||
enableSignUp: data.enableSignUp !== false,
|
||||
enableCodeSignin: data.enableCodeSignin === true,
|
||||
providers,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a create-only IAM response: `{status, msg, data}` where `data` is the
|
||||
* created row. Success carries nothing the caller can navigate to, so this
|
||||
* reports only whether it worked — never a redirect. Kept separate from
|
||||
* `parseLoginResponse` precisely because that one INVENTS a destination when no
|
||||
* `redirectUri` was requested, which is wrong for a row that is not a session.
|
||||
*/
|
||||
async function parseCreated(res: Response): Promise<{ error?: string }> {
|
||||
let body: Record<string, unknown> = {}
|
||||
try {
|
||||
body = (await res.json()) as Record<string, unknown>
|
||||
} catch {
|
||||
return { error: `HTTP ${res.status} non-JSON response` }
|
||||
}
|
||||
// IAM answers a REFUSAL with HTTP 200 + status:"error" (see the org-less login
|
||||
// note in this repo's LLM.md), so the status code alone proves nothing.
|
||||
if (!res.ok || body.status === 'error') {
|
||||
return { error: typeof body.msg === 'string' ? body.msg : `HTTP ${res.status}` }
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
async function parseLoginResponse(
|
||||
res: Response,
|
||||
req?: { redirectUri?: string; state?: string },
|
||||
@@ -742,25 +302,6 @@ async function parseLoginResponse(
|
||||
}
|
||||
const data = body.data
|
||||
|
||||
// Multi-factor signal — IAM answers a successful credential check with a
|
||||
// STRING in `data` (NOT a `mfa_required` boolean): `"RequiredMfa"` when org
|
||||
// policy forces MFA the user has not enrolled, `"NextMfa"` when the user has
|
||||
// MFA and must answer a challenge. Branch BEFORE any session/redirect return:
|
||||
// the password session is not yet usable, so the portal must render the
|
||||
// enrollment/challenge step rather than navigate on.
|
||||
if (data === 'RequiredMfa') {
|
||||
return { mfaRequired: true, mfaStage: 'enroll' }
|
||||
}
|
||||
if (data === 'NextMfa') {
|
||||
// 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) {
|
||||
@@ -770,12 +311,12 @@ async function parseLoginResponse(
|
||||
}
|
||||
}
|
||||
|
||||
// Bare portal sign-in: the IAM session cookie is now set; land on the
|
||||
// post-login onboarding flow. Onboarding's IAM writes ride the same
|
||||
// session cookie (`credentials: include`), so no bearer token is needed
|
||||
// for the password path.
|
||||
// Bare portal sign-in: the IAM session cookie is now set; land on the portal.
|
||||
// The `signed_in` marker tells the portal the session was just established
|
||||
// this tab — it shows the apps launcher even if the cross-proxy
|
||||
// `get-account` session lookup hasn't propagated yet.
|
||||
if (!req?.redirectUri) {
|
||||
return { redirectUrl: '/onboarding' }
|
||||
return { redirectUrl: '/?signed_in=1' }
|
||||
}
|
||||
|
||||
// Fallback: a nested token payload (future direct-token IAM responses).
|
||||
@@ -785,5 +326,7 @@ async function parseLoginResponse(
|
||||
refreshToken: typeof d.refresh_token === 'string' ? d.refresh_token : undefined,
|
||||
idToken: typeof d.id_token === 'string' ? d.id_token : undefined,
|
||||
expiresAt: typeof d.expires_at === 'number' ? d.expires_at : undefined,
|
||||
mfaRequired: d.mfa_required === true,
|
||||
mfaChannel: typeof d.mfa_channel === 'string' ? (d.mfa_channel as LoginResponse['mfaChannel']) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { OrgConfig } from '@hanzo/id-shared'
|
||||
import { IAM } from '@hanzo/iam/browser'
|
||||
|
||||
/**
|
||||
* One IAM browser-SDK instance per org, wired to the portal's own
|
||||
* `/callback` route. This is the single place that constructs the PKCE
|
||||
* client — social/web3 sign-in (here) and the callback handler
|
||||
* (`Callback.tsx`) share it so the PKCE verifier/state the SDK stores on
|
||||
* `signinRedirect` is the same one it reads on `handleCallback`. One way.
|
||||
*
|
||||
* The portal is its own OIDC client (`clientId` = the brand `-id` app), so
|
||||
* every flow it initiates lands back at `${publicOrigin}/callback`.
|
||||
*/
|
||||
export function createIam(org: OrgConfig, clientId?: string): IAM {
|
||||
return new IAM({
|
||||
serverUrl: org.iamUrl,
|
||||
clientId: clientId ?? org.clientId,
|
||||
redirectUri: `${org.publicOrigin}/callback`,
|
||||
scope: 'openid profile email',
|
||||
})
|
||||
}
|
||||
+5
-25
@@ -1,34 +1,14 @@
|
||||
export {
|
||||
createAuthClient,
|
||||
mfaChannelOf,
|
||||
MFA_TOTP,
|
||||
type AuthClient,
|
||||
type AuthClientOptions,
|
||||
} from './client'
|
||||
export { createIam } from './iam'
|
||||
export { authorizeRequest, matchProviderHint } from './social'
|
||||
export {
|
||||
loginWithWalletChain,
|
||||
detectWalletChains,
|
||||
ENABLED_WALLET_CHAINS,
|
||||
WALLET_CHAIN_LABELS,
|
||||
type WalletLoginContext,
|
||||
type WalletWindow,
|
||||
} from './web3'
|
||||
export { createAuthClient, type AuthClient, type AuthClientOptions } from './client'
|
||||
export type {
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
MfaChannel,
|
||||
MfaChallengeRequest,
|
||||
MfaIdentity,
|
||||
MfaSetup,
|
||||
SignupRequest,
|
||||
ForgotRequest,
|
||||
OAuthAuthorizeRequest,
|
||||
TokenResponse,
|
||||
AppLogin,
|
||||
AppProvider,
|
||||
DeviceApprovalResult,
|
||||
DeviceInfoResult,
|
||||
ProviderInfo,
|
||||
SigninMethod,
|
||||
AppLoginInfo,
|
||||
CodeLoginRequest,
|
||||
} from './types'
|
||||
export * from './ui'
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
/**
|
||||
* Federated sign-in — pure unit tests, no network. Run with:
|
||||
* pnpm --filter @hanzo/id-auth test
|
||||
*
|
||||
* The browser's whole job in a federated sign-in is to name the provider on
|
||||
* IAM's authorize endpoint and, when an app sent the user here, to hand that
|
||||
* app's own request back unchanged so IAM mints the code against it. Those two
|
||||
* are what these tests pin; the IdP leg belongs to IAM and is not modelled here.
|
||||
*/
|
||||
import { test } from 'vitest'
|
||||
import assert from 'node:assert/strict'
|
||||
import { authorizeRequest, matchProviderHint } from './social.ts'
|
||||
|
||||
const PORTAL = 'hanzo-console'
|
||||
|
||||
test('an app-initiated request is recovered whole, so IAM binds the code to that app', () => {
|
||||
// What IAM forwards to the hosted login (authorizeForwardQuery) when an app
|
||||
// sends a user here for a code.
|
||||
const req = authorizeRequest(
|
||||
'?client_id=hanzo-app&redirect_uri=https%3A%2F%2Fhanzo.app%2Fcallback&response_type=code' +
|
||||
'&scope=openid+profile&state=rp123&nonce=n1&code_challenge=C1&code_challenge_method=S256',
|
||||
PORTAL,
|
||||
)!
|
||||
assert.equal(req.clientId, 'hanzo-app')
|
||||
assert.equal(req.redirectUri, 'https://hanzo.app/callback')
|
||||
assert.equal(req.state, 'rp123')
|
||||
assert.equal(req.scope, 'openid profile')
|
||||
assert.equal(req.nonce, 'n1')
|
||||
// Load-bearing: the code IAM mints is bound to the APP's challenge, so the
|
||||
// app's own callback completes the exchange with the verifier it kept.
|
||||
assert.equal(req.codeChallenge, 'C1')
|
||||
assert.equal(req.codeChallengeMethod, 'S256')
|
||||
})
|
||||
|
||||
test('a bare portal sign-in has no app to return to', () => {
|
||||
// No redirect_uri → nothing to return a code to, so the portal starts its own
|
||||
// PKCE flow instead (the SDK owns the verifier; Callback reads it back).
|
||||
assert.equal(authorizeRequest('', PORTAL), null)
|
||||
assert.equal(authorizeRequest('?provider_hint=provider-github', PORTAL), null)
|
||||
})
|
||||
|
||||
test('the portal client id is the fallback, never an override', () => {
|
||||
const own = authorizeRequest('?redirect_uri=https%3A%2F%2Fhanzo.id%2Fcallback', PORTAL)!
|
||||
assert.equal(own.clientId, PORTAL, 'no client_id on the query → the portal is the client')
|
||||
|
||||
const app = authorizeRequest('?client_id=hanzo-app&redirect_uri=https%3A%2F%2Fhanzo.app%2Fcallback', PORTAL)!
|
||||
assert.equal(app.clientId, 'hanzo-app', "the app's own client_id wins — the code is minted for IT")
|
||||
})
|
||||
|
||||
test('a leading ? is optional and absent params stay absent', () => {
|
||||
const withMark = authorizeRequest('?redirect_uri=https%3A%2F%2Fhanzo.id%2Fcallback', PORTAL)!
|
||||
const without = authorizeRequest('redirect_uri=https%3A%2F%2Fhanzo.id%2Fcallback', PORTAL)!
|
||||
assert.deepEqual(withMark, without)
|
||||
// Undefined, not '' — client.authorize omits a param it was not given, and an
|
||||
// empty code_challenge is not the same request as no code_challenge.
|
||||
assert.equal(withMark.codeChallenge, undefined)
|
||||
assert.equal(withMark.nonce, undefined)
|
||||
assert.equal(withMark.scope, undefined)
|
||||
assert.equal(withMark.state, '', 'state is always sent, empty when the app sent none')
|
||||
})
|
||||
|
||||
test('only the two PKCE methods RFC 7636 defines are carried through', () => {
|
||||
const base = 'redirect_uri=https%3A%2F%2Fhanzo.id%2Fcallback&code_challenge=C1&code_challenge_method='
|
||||
assert.equal(authorizeRequest(base + 'S256', PORTAL)!.codeChallengeMethod, 'S256')
|
||||
assert.equal(authorizeRequest(base + 'plain', PORTAL)!.codeChallengeMethod, 'plain')
|
||||
// Anything else is dropped rather than forwarded, so client.authorize applies
|
||||
// its S256 default instead of asking IAM to honor a method it does not define.
|
||||
assert.equal(authorizeRequest(base + 'md5', PORTAL)!.codeChallengeMethod, undefined)
|
||||
})
|
||||
|
||||
test('matchProviderHint resolves the console hint, the bare key, and case, else undefined', () => {
|
||||
const providers = [
|
||||
{ name: 'provider-github', key: 'github' },
|
||||
{ name: 'provider-google', key: 'google' },
|
||||
]
|
||||
// The console sends the IAM record name verbatim (`provider-github`).
|
||||
assert.equal(matchProviderHint(providers, 'provider-github')?.key, 'github')
|
||||
assert.equal(matchProviderHint(providers, 'provider-google')?.key, 'google')
|
||||
// The bare key and any case also resolve, so the two sides need no shared constant.
|
||||
assert.equal(matchProviderHint(providers, 'github')?.key, 'github')
|
||||
assert.equal(matchProviderHint(providers, 'GitHub')?.key, 'github')
|
||||
// A hint for a provider this app doesn't offer, or an empty hint, matches nothing.
|
||||
assert.equal(matchProviderHint(providers, 'provider-apple'), undefined)
|
||||
assert.equal(matchProviderHint(providers, ''), undefined)
|
||||
})
|
||||
@@ -1,89 +0,0 @@
|
||||
import type { OAuthAuthorizeRequest } from './types'
|
||||
|
||||
/**
|
||||
* Federated sign-in — the portal's half of IAM identity federation.
|
||||
*
|
||||
* IAM is the relying party; this SPA is not. `/v1/iam/oauth/authorize?provider=…`
|
||||
* IS the entry point: having already validated the client_id, the EXACT
|
||||
* redirect_uri and the PKCE policy, IAM stashes the app-leg request server-side,
|
||||
* sets a single-use browser-binding cookie and sends the browser to the IdP
|
||||
* (`internal/oidc/federation.go::beginFederation`). The IdP returns to IAM's own
|
||||
* fixed callback — `/v1/iam/oauth/callback`, never a route in this SPA — where
|
||||
* IAM, which holds the client SECRET a browser cannot, exchanges the code, links
|
||||
* or provisions the user, and mints an IAM authorization code bound to the
|
||||
* original PKCE challenge, redirect_uri and nonce. The ordinary code→token
|
||||
* exchange then completes unchanged.
|
||||
*
|
||||
* This file used to build the IdP URL here in the browser, replicating a contract
|
||||
* from an IAM fork whose front end no longer exists. Nothing could ever finish it:
|
||||
* the SPA has no client secret and IAM has no endpoint that exchanges a raw
|
||||
* provider code, so GitHub returned to `/callback` with a code nobody could spend
|
||||
* and the flow died there. The browser's only job is to NAME the provider.
|
||||
*
|
||||
* The name is the IAM provider RECORD name (`provider-github`), never the bare
|
||||
* key: `federationProvider` matches `ProviderItem.Name` exactly, and
|
||||
* `EnrichProviders` resolves that same name to the record, so the two are one
|
||||
* string by construction. Verified live — `provider=github` is refused with
|
||||
* "unknown or unavailable provider"; `provider=provider-github` redirects to
|
||||
* GitHub.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The authorize request this portal is standing in for, read from its own URL.
|
||||
*
|
||||
* When an app sends a user here for a code, IAM forwards that app's validated
|
||||
* request on the query (`authorizeForwardQuery`). Re-entering authorize with it —
|
||||
* plus `provider` — is what makes IAM mint the code against THAT app: its
|
||||
* client_id, its redirect_uri, its PKCE challenge. The browser is returned
|
||||
* straight to the app, so this portal's own `/callback` never runs and no token
|
||||
* is ever handed across on a URL.
|
||||
*
|
||||
* Null when there is no app to return to (a bare portal sign-in), which is the
|
||||
* signal to start the portal's own PKCE flow instead. Keyed on `redirect_uri`
|
||||
* because that is the one parameter that makes a request returnable — the same
|
||||
* condition the password path branches on (`Login.completeAfterAuth`).
|
||||
*
|
||||
* The result is an {@link OAuthAuthorizeRequest} because that is exactly what
|
||||
* `client.authorize` consumes: one type, read and written in one shape.
|
||||
*/
|
||||
export function authorizeRequest(search: string, clientId: string): OAuthAuthorizeRequest | null {
|
||||
const q = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search)
|
||||
const redirectUri = q.get('redirect_uri')
|
||||
if (!redirectUri) return null
|
||||
const method = q.get('code_challenge_method')
|
||||
return {
|
||||
clientId: q.get('client_id') || clientId,
|
||||
redirectUri,
|
||||
state: q.get('state') ?? '',
|
||||
scope: q.get('scope') ?? undefined,
|
||||
nonce: q.get('nonce') ?? undefined,
|
||||
codeChallenge: q.get('code_challenge') ?? undefined,
|
||||
codeChallengeMethod: method === 'plain' || method === 'S256' ? method : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a `provider_hint` from the authorize query to one of the app's
|
||||
* configured providers. A client that already knows which provider the user
|
||||
* chose (the console passes `?provider_hint=provider-github` when a user clicks
|
||||
* "Continue with GitHub" over there) sends the hint so this portal launches that
|
||||
* provider straight away — no second button press, no bounce through a login
|
||||
* page. Accepts the IAM record name (`provider-github`), the normalized key
|
||||
* (`github`), or the record name with the `provider-` prefix stripped, so the
|
||||
* two sides agree without a shared constant. Returns undefined when nothing
|
||||
* matches (the caller falls back to the interactive form).
|
||||
*/
|
||||
export function matchProviderHint<P extends { name: string; key: string }>(
|
||||
providers: Iterable<P>,
|
||||
hint: string,
|
||||
): P | undefined {
|
||||
const h = hint.trim().toLowerCase()
|
||||
if (h === '') return undefined
|
||||
const bare = h.replace(/^provider-/, '')
|
||||
for (const p of providers) {
|
||||
const name = p.name.toLowerCase()
|
||||
const key = p.key.toLowerCase()
|
||||
if (name === h || key === h || key === bare) return p
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
+4
-217
@@ -3,193 +3,29 @@ export interface LoginRequest {
|
||||
readonly password: string
|
||||
readonly clientId: string
|
||||
readonly application: string
|
||||
/**
|
||||
* Org-resolution anchor for the credential lookup. OPTIONAL by design.
|
||||
*
|
||||
* IAM resolves the user by (org, identifier); if the in-org lookup misses it
|
||||
* falls back to a CROSS-ORG lookup by email/username and the session always
|
||||
* encodes the user's REAL owner-org (`GetOrganizationByUser`), never this
|
||||
* value. So this field is a lookup HINT, not the session's org.
|
||||
*
|
||||
* Leaving it empty/undefined makes login ORG-AGNOSTIC: every in-org lookup
|
||||
* misses, the cross-org fallback runs, and an identity that lives in the
|
||||
* global `admin` org (a global admin) resolves to `admin` (→ full multi-org
|
||||
* session) while a brand-only identity resolves to its own brand org. This is
|
||||
* why the portal does NOT pin the brand org here — pinning `hanzo` would
|
||||
* resolve a colliding `hanzo/<name>` row and truncate a global admin to one
|
||||
* org. Set it only to FORCE a specific org (e.g. a brand that deliberately
|
||||
* scopes its portal to a single org). Signup, by contrast, MUST carry a
|
||||
* concrete org (you cannot create a user in "no org").
|
||||
*/
|
||||
readonly organization?: string
|
||||
readonly organization: string
|
||||
readonly redirectUri?: string
|
||||
readonly state?: string
|
||||
readonly codeChallenge?: string
|
||||
readonly codeChallengeMethod?: 'S256' | 'plain'
|
||||
/**
|
||||
* OIDC nonce from the downstream authorize request. MUST be threaded through
|
||||
* the password-login path so the minted code (and resulting id_token) echo it.
|
||||
* Confidential OIDC clients that validate strictly (e.g. LibreChat /
|
||||
* openid-client with OPENID_REUSE_TOKENS) reject an id_token whose nonce
|
||||
* doesn't match the one they sent -> "unexpected JWT claim value" -> callback
|
||||
* 500. Forward, never default — only echo what the authorize URL carried.
|
||||
*/
|
||||
readonly nonce?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Inputs to {@link AuthClient.silentLogin} — the silent-SSO leg.
|
||||
*
|
||||
* Carries NO credentials. When the browser already holds an `iam_session_id`
|
||||
* cookie on the issuer host (the user signed in once for another app), IAM's
|
||||
* Login handler takes its "already signed in" branch and mints an authorization
|
||||
* code for `application` without a password or a provider hop. This is what
|
||||
* makes the 2nd/3rd app log in seamlessly. With no live session IAM returns an
|
||||
* error and the caller falls back to the interactive login form.
|
||||
*/
|
||||
export interface SilentLoginRequest {
|
||||
/** OAuth client id of the requesting app (== application name in Hanzo IAM). */
|
||||
readonly clientId: string
|
||||
/** IAM application name the code is minted for. */
|
||||
readonly application: string
|
||||
/** The requesting app's OAuth redirect_uri — the code is appended to it. */
|
||||
readonly redirectUri: string
|
||||
readonly state?: string
|
||||
readonly scope?: string
|
||||
readonly codeChallenge?: string
|
||||
readonly codeChallengeMethod?: 'S256' | 'plain'
|
||||
/** OIDC nonce, echoed into the minted code -> id_token (strict consumers). */
|
||||
readonly nonce?: string
|
||||
}
|
||||
|
||||
/** A multi-factor channel the portal can render a code entry for. */
|
||||
export type MfaChannel = 'totp' | 'sms' | 'email'
|
||||
|
||||
export interface LoginResponse {
|
||||
readonly accessToken?: string
|
||||
readonly refreshToken?: string
|
||||
readonly idToken?: string
|
||||
readonly expiresAt?: number
|
||||
readonly redirectUrl?: string
|
||||
/**
|
||||
* Set when IAM answered the login with a multi-factor signal instead of a
|
||||
* session/code. `mfaStage` discriminates the two IAM states:
|
||||
* - `'enroll'` — IAM returned `data:"RequiredMfa"`: org policy forces MFA
|
||||
* and the user has none yet → render forced TOTP enrollment.
|
||||
* - `'challenge'` — IAM returned `data:"NextMfa"`: the user has MFA enabled
|
||||
* → render a code challenge for one of `mfaTypes`.
|
||||
* The password session is NOT established until the enrollment/challenge
|
||||
* completes, so the portal must not navigate past this signal.
|
||||
*/
|
||||
readonly mfaRequired?: boolean
|
||||
readonly 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 mfaChannel?: 'totp' | 'sms' | 'email'
|
||||
readonly error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of approving an RFC 8628 device-authorization request
|
||||
* ({@link AuthClient.approveDevice}).
|
||||
*
|
||||
* `ok` — the device code was marked signed-in (the CLI's token poll now
|
||||
* succeeds). `required` — the application needs the user to grant consent
|
||||
* before approval can complete (`{status:ok, data:{required:true}}`); rare for
|
||||
* first-party apps. `error` — the IAM-surfaced failure message (e.g.
|
||||
* "UserCode Expired", "DeviceCode Invalid").
|
||||
*/
|
||||
export interface DeviceApprovalResult {
|
||||
readonly ok: boolean
|
||||
readonly required?: boolean
|
||||
readonly error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* WHICH application a pending device code belongs to
|
||||
* ({@link AuthClient.deviceInfo}) — the one thing the approval page exists to
|
||||
* tell a human, and the one thing it cannot know on its own.
|
||||
*
|
||||
* Both fields come off the device code's own application row, so a page that
|
||||
* renders them names the party it is actually authorizing. They are the ONLY
|
||||
* honest source: `org.appName` is this portal's static branding and names the
|
||||
* wrong app for every code minted by anything else.
|
||||
*
|
||||
* Discriminated on `ok` so a caller cannot read `displayName` without having
|
||||
* proved the server confirmed one. `loginRequired` singles out the expired
|
||||
* session (IAM `code:"login_required"`) — the page's cue to sign the human in
|
||||
* and come back, not an error to show. Every other failure is IAM's single
|
||||
* opaque refusal, surfaced verbatim.
|
||||
*/
|
||||
export type DeviceInfoResult =
|
||||
| { readonly ok: true; readonly clientId: string; readonly displayName: string }
|
||||
| { readonly ok: false; readonly error: string; readonly loginRequired?: boolean }
|
||||
|
||||
/**
|
||||
* The TOTP enrollment material minted by `/v1/iam/mfa/setup/initiate`. The
|
||||
* secret + `url` (an `otpauth://` URI) are rendered locally as a QR code — the
|
||||
* secret never leaves the browser to a third party. `recoveryCodes[0]` must be
|
||||
* echoed back to `/v1/iam/mfa/setup/enable`.
|
||||
*/
|
||||
export interface MfaSetup {
|
||||
/** IAM MFA type — `app` for TOTP. */
|
||||
readonly mfaType: string
|
||||
/** Base32 TOTP secret. */
|
||||
readonly secret: string
|
||||
/** `otpauth://totp/...` provisioning URI for the authenticator app. */
|
||||
readonly url: string
|
||||
/** One-time recovery codes issued alongside the secret. */
|
||||
readonly recoveryCodes: readonly string[]
|
||||
}
|
||||
|
||||
/** The signed-in user's identity, resolved from the IAM session for MFA setup. */
|
||||
export interface MfaIdentity {
|
||||
readonly owner: string
|
||||
readonly name: string
|
||||
}
|
||||
|
||||
/** A TOTP challenge submission for a user who already enrolled (`NextMfa`). */
|
||||
export interface MfaChallengeRequest {
|
||||
/** IAM MFA type, e.g. `app` (TOTP), `sms`, `email`. */
|
||||
readonly mfaType: string
|
||||
readonly passcode: string
|
||||
readonly clientId: string
|
||||
readonly application: string
|
||||
readonly organization: string
|
||||
readonly redirectUri?: string
|
||||
readonly state?: string
|
||||
readonly codeChallenge?: string
|
||||
readonly codeChallengeMethod?: 'S256' | 'plain'
|
||||
/** Honor the org's "remember this device" window after a successful code. */
|
||||
readonly rememberDevice?: boolean
|
||||
}
|
||||
|
||||
export interface SignupRequest {
|
||||
readonly email: string
|
||||
readonly password: string
|
||||
readonly clientId: string
|
||||
readonly application: string
|
||||
/**
|
||||
* The org to create the user in. REQUIRED — unlike login's optional
|
||||
* lookup hint, you cannot create a user in "no org", and IAM gates this
|
||||
* against the application's own org.
|
||||
*/
|
||||
readonly organization: string
|
||||
readonly inviteCode?: string
|
||||
/**
|
||||
* The downstream OIDC request, when an app sent the user here to register.
|
||||
* Registration completes by signing the new user in, so these are forwarded
|
||||
* to that sign-in: without them the minted code carries no PKCE binding and
|
||||
* there is nowhere to return the user to.
|
||||
*/
|
||||
readonly redirectUri?: string
|
||||
readonly state?: string
|
||||
readonly codeChallenge?: string
|
||||
readonly codeChallengeMethod?: 'S256' | 'plain'
|
||||
readonly nonce?: string
|
||||
}
|
||||
|
||||
export interface ForgotRequest {
|
||||
@@ -215,9 +51,9 @@ export interface OAuthAuthorizeRequest {
|
||||
export interface ProviderInfo {
|
||||
readonly name: string
|
||||
readonly displayName?: string
|
||||
/** IAM provider type, e.g. GitHub, Google, Apple, Web3Onboard. */
|
||||
/** Casdoor provider type, e.g. GitHub, Google, Apple, Web3Onboard. */
|
||||
readonly type?: string
|
||||
/** IAM category, e.g. OAuth, Web3, SAML. */
|
||||
/** Casdoor category, e.g. OAuth, Web3, SAML. */
|
||||
readonly category?: string
|
||||
readonly canSignIn?: boolean
|
||||
readonly canSignUp?: boolean
|
||||
@@ -260,52 +96,3 @@ export interface TokenResponse {
|
||||
readonly expiresIn?: number
|
||||
readonly scope?: string
|
||||
}
|
||||
|
||||
/** A social/web3 provider enabled on an IAM application. */
|
||||
export interface AppProvider {
|
||||
/** IAM provider record name, e.g. `provider-github`. */
|
||||
readonly name: string
|
||||
/** Normalized provider key passed to the authorize endpoint, e.g. `github`, `google`, `web3`. */
|
||||
readonly key: string
|
||||
/** Whether the provider may be used to sign in. */
|
||||
readonly canSignIn: boolean
|
||||
/** Whether the provider may be used to sign up. */
|
||||
readonly canSignUp: boolean
|
||||
/**
|
||||
* Whether IAM holds a real OAuth credential for this provider (a non-empty,
|
||||
* non-placeholder clientId). The login UI renders ONLY configured providers,
|
||||
* so an unprovisioned button never dead-ends the user — it appears
|
||||
* automatically once real credentials are seeded into IAM. The seed ships
|
||||
* obvious placeholders (`GITHUB_CLIENT_ID_PLACEHOLDER`, `placeholder`), which
|
||||
* read as not-configured.
|
||||
*/
|
||||
readonly configured: boolean
|
||||
/** IAM provider `type`, e.g. `GitHub` / `Google` / `Web3Onboard` (selects the OAuth endpoint). */
|
||||
readonly type: string
|
||||
/** The provider's OAuth client id (used to build the provider redirect; empty when unconfigured). */
|
||||
readonly clientId: string
|
||||
/** Override OAuth scopes, if the provider record sets them. */
|
||||
readonly scopes: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The enabled-auth-methods view of an IAM application, read live from
|
||||
* `/v1/iam/get-app-login`. This is the canonical source of truth for which
|
||||
* buttons to render — it reflects the per-app config in `init_data.json`
|
||||
* (password + GitHub + Google + Web3). The portal renders exactly what IAM
|
||||
* reports enabled, so there is no client/server method drift.
|
||||
*/
|
||||
export interface AppLogin {
|
||||
/** IAM application name (e.g. `hanzo-id`). */
|
||||
readonly application: string
|
||||
/** Owning organization slug. */
|
||||
readonly organization: string
|
||||
/** Email/username + password sign-in is enabled. */
|
||||
readonly enablePassword: boolean
|
||||
/** Self-service signup is enabled. */
|
||||
readonly enableSignUp: boolean
|
||||
/** Email/SMS verification-code sign-in is enabled. */
|
||||
readonly enableCodeSignin: boolean
|
||||
/** Social + Web3 providers enabled on the app, in display order. */
|
||||
readonly providers: readonly AppProvider[]
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
/** Labeled horizontal rule, e.g. "or", separating social from email sign-in. */
|
||||
export function Divider({ label = 'or' }: { label?: string }) {
|
||||
return (
|
||||
<div className="hanzo-id-divider" role="separator" aria-label={label}>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -20,8 +20,8 @@ export function ForgotForm(props: ForgotFormProps) {
|
||||
try {
|
||||
const res = await client.forgot({
|
||||
identifier,
|
||||
clientId: client.org.clientId,
|
||||
organization: client.org.orgId,
|
||||
clientId: client.tenant.clientId,
|
||||
organization: client.tenant.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-form" aria-busy={busy}>
|
||||
<label className="hanzo-id-field">
|
||||
<form onSubmit={onSubmit} className="hanzo-id-forgot-form" aria-busy={busy}>
|
||||
<label>
|
||||
<span>Email</span>
|
||||
<input className="hanzo-id-input" type="email" autoComplete="email" value={identifier} onChange={(e) => setIdentifier(e.target.value)} required />
|
||||
<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" className="hanzo-id-btn" disabled={busy}>{busy ? 'Sending…' : 'Send reset link'}</button>
|
||||
<button type="submit" disabled={busy}>{busy ? 'Sending…' : 'Send reset link'}</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
+169
-88
@@ -1,95 +1,91 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import type { AuthClient } from '../client'
|
||||
import type { LoginResponse } from '../types'
|
||||
import type { AppLoginInfo, LoginResponse } from '../types'
|
||||
import { ProviderButtons } from './ProviderButtons'
|
||||
import { OTPForm } from './OTPForm'
|
||||
|
||||
export interface LoginFormProps {
|
||||
readonly client: AuthClient
|
||||
readonly redirectUri?: string
|
||||
readonly state?: string
|
||||
readonly clientIdOverride?: string
|
||||
readonly codeChallenge?: string
|
||||
readonly codeChallengeMethod?: 'S256' | 'plain'
|
||||
readonly nonce?: string
|
||||
readonly onSuccess?: (res: LoginResponse) => void
|
||||
readonly onMfaRequired?: (res: LoginResponse) => void
|
||||
/**
|
||||
* Called after a successful sign-in INSTEAD of the form's default post-login
|
||||
* navigation. When provided, the form does not redirect (neither to a
|
||||
* downstream app nor to `/onboarding`) — the caller owns what happens next.
|
||||
* Used by the device-approval page to stay on-page and show the confirm step.
|
||||
*/
|
||||
readonly onAuthenticated?: (res: LoginResponse) => void
|
||||
}
|
||||
|
||||
export function LoginForm(props: LoginFormProps) {
|
||||
const { client } = props
|
||||
const [app, setApp] = useState<AppLoginInfo | null>(null)
|
||||
const [mode, setMode] = useState<'password' | 'code'>('password')
|
||||
const [identifier, setIdentifier] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [codeSent, setCodeSent] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [notice, setNotice] = useState<string | null>(null)
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
// Load the application's providers + sign-in methods so we render exactly
|
||||
// what IAM offers (social buttons, email/SMS code). Best-effort: on failure
|
||||
// we still show password login.
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
client
|
||||
.appLogin()
|
||||
.then((a) => {
|
||||
if (alive) setApp(a)
|
||||
})
|
||||
.catch(() => {})
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [client])
|
||||
|
||||
const codeEnabled =
|
||||
!!app &&
|
||||
(app.enableCodeSignin ||
|
||||
app.signinMethods.some((m) => m.name === 'Verification code' && m.rule !== 'None'))
|
||||
|
||||
function handleResult(res: LoginResponse) {
|
||||
if (res.error) setError(res.error)
|
||||
else if (res.mfaRequired) props.onMfaRequired?.(res)
|
||||
else if (res.redirectUrl) window.location.href = res.redirectUrl
|
||||
else props.onSuccess?.(res)
|
||||
}
|
||||
|
||||
async function onPasswordSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
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.org.clientId,
|
||||
application,
|
||||
organization,
|
||||
clientId: props.clientIdOverride ?? client.tenant.clientId,
|
||||
application: client.tenant.appName,
|
||||
organization: client.tenant.orgId,
|
||||
redirectUri: props.redirectUri,
|
||||
state: props.state,
|
||||
codeChallenge: props.codeChallenge,
|
||||
codeChallengeMethod: props.codeChallengeMethod,
|
||||
nonce: props.nonce,
|
||||
})
|
||||
if (res.error) {
|
||||
setError(res.error)
|
||||
} else if (res.mfaRequired) {
|
||||
props.onMfaRequired?.(res)
|
||||
} else if (props.onAuthenticated) {
|
||||
// Caller owns the next step (e.g. device approval) — suppress the
|
||||
// default navigation so we stay on-page.
|
||||
props.onAuthenticated(res)
|
||||
} else if (res.redirectUrl) {
|
||||
window.location.href = res.redirectUrl
|
||||
handleResult(res)
|
||||
} catch (err) {
|
||||
setError(String(err))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function onSendCode(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
setNotice(null)
|
||||
try {
|
||||
const r = await client.sendLoginCode(identifier)
|
||||
if (r.ok) {
|
||||
setCodeSent(true)
|
||||
setNotice(`Code sent to ${identifier}`)
|
||||
} else {
|
||||
props.onSuccess?.(res)
|
||||
setError(r.error ?? 'Could not send code')
|
||||
}
|
||||
} catch (err) {
|
||||
setError(String(err))
|
||||
@@ -98,32 +94,117 @@ export function LoginForm(props: LoginFormProps) {
|
||||
}
|
||||
}
|
||||
|
||||
async function onVerifyCode(code: string) {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await client.loginWithCode({
|
||||
dest: identifier,
|
||||
code,
|
||||
clientId: props.clientIdOverride ?? client.tenant.clientId,
|
||||
application: client.tenant.appName,
|
||||
organization: client.tenant.orgId,
|
||||
redirectUri: props.redirectUri,
|
||||
state: props.state,
|
||||
})
|
||||
handleResult(res)
|
||||
} catch (err) {
|
||||
setError(String(err))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="hanzo-id-form" aria-busy={busy}>
|
||||
<label className="hanzo-id-field">
|
||||
<span>Email or username</span>
|
||||
<input
|
||||
className="hanzo-id-input"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
value={identifier}
|
||||
onChange={(e) => setIdentifier(e.target.value)}
|
||||
required
|
||||
<div className="hanzo-id-login">
|
||||
{app && app.providers.length > 0 ? (
|
||||
<ProviderButtons
|
||||
client={client}
|
||||
providers={app.providers}
|
||||
mode="login"
|
||||
redirectUri={props.redirectUri}
|
||||
state={props.state}
|
||||
clientIdOverride={props.clientIdOverride}
|
||||
/>
|
||||
</label>
|
||||
<label className="hanzo-id-field">
|
||||
<span>Password</span>
|
||||
<input
|
||||
className="hanzo-id-input"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
|
||||
<button type="submit" className="hanzo-id-btn" disabled={busy}>{busy ? 'Signing in…' : 'Sign in'}</button>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{mode === 'password' ? (
|
||||
<form onSubmit={onPasswordSubmit} className="hanzo-id-login-form" aria-busy={busy}>
|
||||
<label>
|
||||
<span>Email or username</span>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
value={identifier}
|
||||
onChange={(e) => setIdentifier(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Password</span>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
|
||||
<button type="submit" disabled={busy}>{busy ? 'Signing in…' : 'Sign in'}</button>
|
||||
</form>
|
||||
) : (
|
||||
<div className="hanzo-id-code-login">
|
||||
{!codeSent ? (
|
||||
<form onSubmit={onSendCode} className="hanzo-id-login-form" aria-busy={busy}>
|
||||
<label>
|
||||
<span>Email or phone</span>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
placeholder="you@example.com or +1 555 555 5555"
|
||||
value={identifier}
|
||||
onChange={(e) => setIdentifier(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
|
||||
<button type="submit" disabled={busy}>{busy ? 'Sending…' : 'Send code'}</button>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
{notice ? <p className="hanzo-id-notice">{notice}</p> : null}
|
||||
<OTPForm channel={identifier.includes('@') ? 'email' : 'sms'} onSubmit={onVerifyCode} />
|
||||
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
|
||||
<button
|
||||
type="button"
|
||||
className="hanzo-id-linkbtn"
|
||||
onClick={() => {
|
||||
setCodeSent(false)
|
||||
setNotice(null)
|
||||
}}
|
||||
>
|
||||
Use a different address
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{codeEnabled ? (
|
||||
<button
|
||||
type="button"
|
||||
className="hanzo-id-linkbtn hanzo-id-toggle-mode"
|
||||
onClick={() => {
|
||||
setMode(mode === 'password' ? 'code' : 'password')
|
||||
setError(null)
|
||||
setNotice(null)
|
||||
setCodeSent(false)
|
||||
}}
|
||||
>
|
||||
{mode === 'password' ? 'Sign in with email or SMS code' : 'Sign in with password'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import encodeQR from '@paulmillr/qr'
|
||||
import type { AuthClient } from '../client'
|
||||
import type { MfaIdentity, MfaSetup } from '../types'
|
||||
import { OTPForm } from './OTPForm'
|
||||
|
||||
export interface MfaEnrollFormProps {
|
||||
readonly client: AuthClient
|
||||
/**
|
||||
* Called once the user has verified a TOTP code AND the enrollment is
|
||||
* persisted. The caller continues the session (onboarding or the OIDC
|
||||
* code redirect).
|
||||
*/
|
||||
readonly onComplete: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Forced TOTP enrollment, shown when IAM answers a login with `RequiredMfa`
|
||||
* (org policy requires MFA and the user has none). There is intentionally NO
|
||||
* skip / dismiss control — the only way past this screen is to enroll an
|
||||
* authenticator. The QR is rendered locally from the `otpauth://` URI, so the
|
||||
* TOTP secret never leaves the browser.
|
||||
*
|
||||
* Flow: `getAccount` (resolve identity from the session IAM set with
|
||||
* `RequiredMfa`) → `mfaInitiate` (secret + QR) → user scans → `mfaVerify`
|
||||
* (prove the code) → `mfaEnable` (persist) → `onComplete`.
|
||||
*/
|
||||
export function MfaEnrollForm({ client, onComplete }: MfaEnrollFormProps) {
|
||||
const [identity, setIdentity] = useState<MfaIdentity | null>(null)
|
||||
const [setup, setSetup] = useState<MfaSetup | null>(null)
|
||||
const [fatal, setFatal] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
async function begin() {
|
||||
try {
|
||||
const id = await client.getAccount()
|
||||
if (!id) throw new Error('Your session could not be resolved. Please sign in again.')
|
||||
const s = await client.mfaInitiate(id)
|
||||
if (cancelled) return
|
||||
setIdentity(id)
|
||||
setSetup(s)
|
||||
} catch (e) {
|
||||
if (!cancelled) setFatal(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}
|
||||
void begin()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [client])
|
||||
|
||||
const qrSvg = useMemo(() => (setup ? encodeQR(setup.url, 'svg') : ''), [setup])
|
||||
|
||||
async function onCode(code: string) {
|
||||
if (!identity || !setup || busy) return
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
const verified = await client.mfaVerify({ owner: identity.owner, name: identity.name, secret: setup.secret, passcode: code })
|
||||
if (!verified.ok) {
|
||||
setError(verified.error ?? 'That code did not match. Try the current code from your app.')
|
||||
return
|
||||
}
|
||||
const enabled = await client.mfaEnable({
|
||||
owner: identity.owner,
|
||||
name: identity.name,
|
||||
secret: setup.secret,
|
||||
recoveryCode: setup.recoveryCodes[0] ?? '',
|
||||
})
|
||||
if (!enabled.ok) {
|
||||
setError(enabled.error ?? 'Could not enable two-factor authentication.')
|
||||
return
|
||||
}
|
||||
onComplete()
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (fatal) {
|
||||
return (
|
||||
<div className="hanzo-id-mfa-enroll">
|
||||
<h2>Two-factor setup</h2>
|
||||
<p role="alert" className="hanzo-id-error">{fatal}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!setup) {
|
||||
return (
|
||||
<div className="hanzo-id-mfa-enroll">
|
||||
<h2>Two-factor setup</h2>
|
||||
<p className="lede">Preparing your authenticator…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const recoveryCode = setup.recoveryCodes[0]
|
||||
return (
|
||||
<div className="hanzo-id-mfa-enroll">
|
||||
<h2>Set up two-factor authentication</h2>
|
||||
<p className="lede">
|
||||
Your organization requires two-factor authentication. Scan this QR code with an
|
||||
authenticator app (Google Authenticator, 1Password, Authy), then enter the 6-digit code it
|
||||
shows.
|
||||
</p>
|
||||
<div
|
||||
className="hanzo-id-mfa-qr"
|
||||
role="img"
|
||||
aria-label="TOTP enrollment QR code"
|
||||
// Local SVG from @paulmillr/qr — the otpauth secret never leaves the browser.
|
||||
dangerouslySetInnerHTML={{ __html: qrSvg }}
|
||||
/>
|
||||
<details className="hanzo-id-mfa-manual">
|
||||
<summary>Can't scan? Enter this key manually</summary>
|
||||
<code className="hanzo-id-mfa-secret">{setup.secret}</code>
|
||||
</details>
|
||||
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
|
||||
<OTPForm channel="totp" onSubmit={onCode} />
|
||||
{recoveryCode ? (
|
||||
<p className="hanzo-id-mfa-recovery">
|
||||
Save this recovery code somewhere safe — it lets you sign in if you lose your device:
|
||||
<br />
|
||||
<code>{recoveryCode}</code>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { SmsConsentNotice } from './SmsConsent'
|
||||
|
||||
export interface OTPFormProps {
|
||||
readonly onSubmit: (code: string) => void | Promise<void>
|
||||
@@ -26,11 +25,10 @@ export function OTPForm(props: OTPFormProps) {
|
||||
const label = channel === 'sms' ? 'SMS code' : channel === 'email' ? 'Email code' : 'Authenticator code'
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="hanzo-id-form" aria-busy={busy}>
|
||||
<label className="hanzo-id-field">
|
||||
<form onSubmit={onSubmit} className="hanzo-id-otp-form" aria-busy={busy}>
|
||||
<label>
|
||||
<span>{label}</span>
|
||||
<input
|
||||
className="hanzo-id-input"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern={`\\d{${length}}`}
|
||||
@@ -41,8 +39,7 @@ export function OTPForm(props: OTPFormProps) {
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{channel === 'sms' ? <SmsConsentNotice /> : null}
|
||||
<button type="submit" className="hanzo-id-btn" disabled={busy || code.length !== length}>{busy ? 'Verifying…' : 'Verify'}</button>
|
||||
<button type="submit" disabled={busy || code.length !== length}>{busy ? 'Verifying…' : 'Verify'}</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
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,73 +1,51 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import type { AuthClient } from '../client'
|
||||
import type { AppLoginInfo } from '../types'
|
||||
import { ProviderButtons } from './ProviderButtons'
|
||||
|
||||
export interface SignupFormProps {
|
||||
readonly client: AuthClient
|
||||
readonly inviteCode?: string
|
||||
/**
|
||||
* 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
|
||||
readonly onSuccess?: () => void
|
||||
}
|
||||
|
||||
export function SignupForm(props: SignupFormProps) {
|
||||
const { client } = props
|
||||
const [app, setApp] = useState<AppLoginInfo | null>(null)
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
client
|
||||
.appLogin()
|
||||
.then((a) => {
|
||||
if (alive) setApp(a)
|
||||
})
|
||||
.catch(() => {})
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [client])
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
// 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({
|
||||
const res = await client.signup({
|
||||
email,
|
||||
password,
|
||||
clientId,
|
||||
application,
|
||||
organization,
|
||||
clientId: client.tenant.clientId,
|
||||
application: client.tenant.appName,
|
||||
organization: client.tenant.orgId,
|
||||
inviteCode: props.inviteCode,
|
||||
redirectUri: props.redirectUri,
|
||||
state: props.state,
|
||||
codeChallenge: props.codeChallenge,
|
||||
codeChallengeMethod: props.codeChallengeMethod,
|
||||
nonce: props.nonce,
|
||||
})
|
||||
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.')
|
||||
if (res.error) setError(res.error)
|
||||
else if (res.redirectUrl) window.location.href = res.redirectUrl
|
||||
else props.onSuccess?.()
|
||||
} catch (err) {
|
||||
setError(String(err))
|
||||
} finally {
|
||||
@@ -76,25 +54,29 @@ export function SignupForm(props: SignupFormProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="hanzo-id-form" aria-busy={busy}>
|
||||
<label className="hanzo-id-field">
|
||||
<span>Email</span>
|
||||
<input className="hanzo-id-input" type="email" autoComplete="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
|
||||
</label>
|
||||
<label className="hanzo-id-field">
|
||||
<span>Password</span>
|
||||
<input
|
||||
className="hanzo-id-input"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
minLength={12}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
|
||||
<button type="submit" className="hanzo-id-btn" disabled={busy}>{busy ? 'Creating account…' : 'Create account'}</button>
|
||||
</form>
|
||||
<div className="hanzo-id-signup">
|
||||
{app && app.providers.length > 0 ? (
|
||||
<ProviderButtons client={client} providers={app.providers} mode="signup" />
|
||||
) : null}
|
||||
<form onSubmit={onSubmit} className="hanzo-id-signup-form" aria-busy={busy}>
|
||||
<label>
|
||||
<span>Email</span>
|
||||
<input type="email" autoComplete="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
|
||||
</label>
|
||||
<label>
|
||||
<span>Password</span>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
minLength={12}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
|
||||
<button type="submit" disabled={busy}>{busy ? 'Creating account…' : 'Create account'}</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
// Canonical A2P 10DLC consent copy. This EXACT disclosure is reused at every
|
||||
// point where Hanzo collects or uses a phone number for messaging. It MUST stay
|
||||
// verbatim-identical to the public opt-in page (hanzo.ai/sms-opt-in,
|
||||
// `SMS_CONSENT_TEXT`) and to the IAM phone-login UI — Twilio / carrier campaign
|
||||
// review compares the wording across surfaces. One string, reused everywhere.
|
||||
export const SMS_CONSENT_TEXT =
|
||||
'I agree to receive text messages (SMS) from Hanzo AI at the number provided, ' +
|
||||
'including one-time passcodes and two-factor authentication, account and security ' +
|
||||
'alerts, and transactional notifications. Message frequency varies. Message and data ' +
|
||||
'rates may apply. Reply STOP to opt out at any time, or HELP for help. Consent is not ' +
|
||||
'a condition of any purchase.'
|
||||
|
||||
const TERMS_URL = 'https://hanzo.ai/terms'
|
||||
const PRIVACY_URL = 'https://hanzo.ai/privacy'
|
||||
|
||||
/**
|
||||
* SMS consent disclosure shown beneath any phone/SMS surface (disclosure-only,
|
||||
* no checkbox — the portal's SMS step is reached only after the user already
|
||||
* provided/opted-in their number in IAM, and after a code was sent).
|
||||
*
|
||||
* For a phone-number COLLECTION surface that requires affirmative opt-in (A2P),
|
||||
* gate the submit on a checkbox and reuse {@link SMS_CONSENT_TEXT} — see the IAM
|
||||
* SignupPage `SmsConsentCheckbox`. The portal does not yet render its own phone
|
||||
* field (collection happens in the IAM-hosted UI), so only the notice is used
|
||||
* here today.
|
||||
*/
|
||||
export function SmsConsentNotice() {
|
||||
return (
|
||||
<div className="hanzo-id-sms-consent" role="note">
|
||||
<p>{SMS_CONSENT_TEXT}</p>
|
||||
<p className="hanzo-id-sms-consent-links">
|
||||
By continuing, you agree to our{' '}
|
||||
<a href={TERMS_URL} target="_blank" rel="noreferrer">Terms of Service</a> and{' '}
|
||||
<a href={PRIVACY_URL} target="_blank" rel="noreferrer">Privacy Policy</a>.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,331 +0,0 @@
|
||||
import { Fragment, useEffect, useRef, useState } from 'react'
|
||||
import type { ComponentType, SVGProps } from 'react'
|
||||
import type { Chain } from '@hanzo/id-connect'
|
||||
import type { AuthClient } from '../client'
|
||||
import type { AppProvider } from '../types'
|
||||
import { authorizeRequest, matchProviderHint } from '../social'
|
||||
import { createIam } from '../iam'
|
||||
import {
|
||||
loginWithWalletChain,
|
||||
detectWalletChains,
|
||||
ENABLED_WALLET_CHAINS,
|
||||
WALLET_CHAIN_LABELS,
|
||||
} from '../web3'
|
||||
import { GitHubIcon, GitLabIcon, GoogleIcon, WalletIcon } from './icons'
|
||||
import { Divider } from './Divider'
|
||||
|
||||
/**
|
||||
* Social + multi-chain wallet sign-in buttons.
|
||||
*
|
||||
* The enabled set is read live from `/v1/iam/get-app-login` (via
|
||||
* `client.getAppLogin()`) — the canonical source of truth that mirrors the
|
||||
* per-app provider config in `init_data.json`. We render ONLY providers IAM
|
||||
* holds real credentials for (`AppProvider.configured`); a provider seeded with
|
||||
* placeholder creds is hidden so its button never dead-ends, and reappears once
|
||||
* real creds land. When the config is unreadable we render none.
|
||||
*
|
||||
* Two sign-in shapes, decomplected:
|
||||
* - OAuth (github/google/gitlab) → FEDERATION: name the provider on IAM's own
|
||||
* authorize endpoint (`?provider=provider-github`) and let IAM run the entire
|
||||
* IdP leg server-side, where the client secret lives. See `social.ts`. This
|
||||
* browser never builds an IdP URL and never sees a provider code.
|
||||
* - Web3/wallet → native Sign-In-With-X (`loginWithWalletChain`): connect a
|
||||
* wallet with `@hanzo/id-connect` (no WalletConnect, no projectId), sign the
|
||||
* IAM-minted challenge, POST `/v1/iam/web3/verify`, then follow the SAME
|
||||
* redirect the password flow returns. The wallet provider renders ONE
|
||||
* chain-agnostic "Connect Wallet" button: it auto-detects the injected
|
||||
* chain (`detectWalletChains`) and connects straight when exactly one is
|
||||
* present, else reveals a chooser so either EVM or Solana stays reachable.
|
||||
*/
|
||||
export interface SocialButtonsProps {
|
||||
readonly client: AuthClient
|
||||
/** Override the OAuth client_id (e.g. a downstream app's id). */
|
||||
readonly clientIdOverride?: string
|
||||
/** "signin" (default) or "signup" — only changes button copy. */
|
||||
readonly intent?: 'signin' | 'signup'
|
||||
/**
|
||||
* Downstream app's `redirect_uri`, if this portal is mid-flow for another
|
||||
* app. OAuth sign-in returns to the portal's own `/callback` (stashed here
|
||||
* and forwarded by `Callback`); wallet sign-in threads it straight into the
|
||||
* verify POST so IAM mints the auth-code redirect back to the app. Absent →
|
||||
* a bare portal sign-in that lands on onboarding.
|
||||
*/
|
||||
readonly postLoginRedirect?: string
|
||||
/**
|
||||
* A `provider_hint` from the authorize query — the console passes
|
||||
* `?provider_hint=provider-github` when a user clicks "Continue with GitHub"
|
||||
* over there. When set, once the app config resolves this component launches
|
||||
* the matching provider's hop straight away (the SAME hop the button runs) and
|
||||
* renders NOTHING: it is headless, a pure side-effect, so the caller shows its
|
||||
* own "signing you in" state. If the hint matches no configured provider,
|
||||
* `onAutoStartResolved(false)` fires so the caller can fall back to the form.
|
||||
*/
|
||||
readonly autoStart?: string
|
||||
/**
|
||||
* Called once, in `autoStart` mode, after the app config resolves: `true` when
|
||||
* the hinted provider launched, `false` when the hint matched nothing (so the
|
||||
* caller can drop to the interactive form instead of a blank redirect state).
|
||||
*/
|
||||
readonly onAutoStartResolved?: (started: boolean) => void
|
||||
}
|
||||
|
||||
interface ProviderMeta {
|
||||
readonly key: string
|
||||
readonly label: string
|
||||
readonly Icon: ComponentType<SVGProps<SVGSVGElement>>
|
||||
}
|
||||
|
||||
/** Display metadata for the providers the portal knows how to render. */
|
||||
const PROVIDER_META: Record<string, ProviderMeta> = {
|
||||
github: { key: 'github', label: 'GitHub', Icon: GitHubIcon },
|
||||
gitlab: { key: 'gitlab', label: 'GitLab', Icon: GitLabIcon },
|
||||
google: { key: 'google', label: 'Google', Icon: GoogleIcon },
|
||||
web3: { key: 'web3', label: 'Wallet', Icon: WalletIcon },
|
||||
}
|
||||
|
||||
/** Canonical render order. */
|
||||
const ORDER = ['github', 'gitlab', 'google', 'web3']
|
||||
|
||||
interface Resolved {
|
||||
/** Configured + renderable providers, keyed by their normalized key. */
|
||||
readonly providers: Record<string, AppProvider>
|
||||
}
|
||||
|
||||
export function SocialButtons({
|
||||
client,
|
||||
clientIdOverride,
|
||||
intent = 'signin',
|
||||
postLoginRedirect,
|
||||
autoStart,
|
||||
onAutoStartResolved,
|
||||
}: SocialButtonsProps) {
|
||||
const [resolved, setResolved] = useState<Resolved | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busyChain, setBusyChain] = useState<Chain | null>(null)
|
||||
// The chain-agnostic wallet entry reveals a chooser only when it can't decide
|
||||
// for the user (zero or multiple injected wallets); a single injected wallet
|
||||
// connects straight without ever showing it.
|
||||
const [walletMenu, setWalletMenu] = useState(false)
|
||||
const autoStarted = useRef(false)
|
||||
|
||||
// Start federation: hand the provider's NAME to IAM's authorize endpoint and
|
||||
// let IAM run the whole IdP leg. Shared by the button click and the `autoStart`
|
||||
// auto-launch so both take the identical path.
|
||||
//
|
||||
// Two arms, and they are the same two the password path already branches on
|
||||
// (`Login.completeAfterAuth`) — the question is only who owns the PKCE verifier:
|
||||
//
|
||||
// an app sent the user here → re-enter authorize with THAT app's request, so
|
||||
// IAM mints the code against its client_id, redirect_uri and challenge and
|
||||
// returns the browser straight to it. The app holds the verifier; this portal
|
||||
// is never in the return path and never touches a token.
|
||||
//
|
||||
// a bare portal sign-in → the portal is its own client, so the IAM SDK mints
|
||||
// and stores the verifier that `Callback` reads back. `post_login_redirect`
|
||||
// carries a non-OIDC "come back here" target (device approval), which is why
|
||||
// it belongs to this arm alone: it is only ever read by the portal's own
|
||||
// callback, and only this arm runs it.
|
||||
function hop(provider: AppProvider) {
|
||||
const app = authorizeRequest(window.location.search, clientIdOverride ?? client.org.clientId)
|
||||
if (app) {
|
||||
sessionStorage.removeItem('post_login_redirect')
|
||||
window.location.assign(client.authorize({ ...app, provider: provider.name }))
|
||||
return
|
||||
}
|
||||
if (postLoginRedirect) sessionStorage.setItem('post_login_redirect', postLoginRedirect)
|
||||
else sessionStorage.removeItem('post_login_redirect')
|
||||
createIam(client.org, clientIdOverride)
|
||||
.signinRedirect({ additionalParams: { provider: provider.name } })
|
||||
.catch((e) => setError(String(e)))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
// Read the app config against the DOWNSTREAM app's own redirect_uri (carried
|
||||
// on the authorize query in the SSO flow), not the portal's /callback — IAM
|
||||
// validates it against the app's registered list, and a cross-app clientId
|
||||
// (e.g. console's `hanzo-cloud` viewed from hanzo.id) does NOT register the
|
||||
// portal callback, so hardcoding it drops the whole response and no social
|
||||
// resolves. Absent (bare portal / device flow) → getAppLogin defaults it.
|
||||
const oidcRedirectUri =
|
||||
typeof window !== 'undefined'
|
||||
? new URLSearchParams(window.location.search).get('redirect_uri') ?? undefined
|
||||
: undefined
|
||||
client
|
||||
.getAppLogin(clientIdOverride, oidcRedirectUri)
|
||||
.then((app) => {
|
||||
if (cancelled) return
|
||||
if (!app) {
|
||||
// Can't read the app config → render no social rather than risk a
|
||||
// dead-end button. Password / email-code still render.
|
||||
setResolved({ providers: {} })
|
||||
onAutoStartResolved?.(false)
|
||||
return
|
||||
}
|
||||
const want = intent === 'signup' ? (p: AppProvider) => p.canSignUp : (p: AppProvider) => p.canSignIn
|
||||
// Render ONLY providers IAM actually holds credentials for. A provider
|
||||
// with placeholder/empty creds would dead-end the OAuth redirect, so we
|
||||
// hide it; it reappears automatically once real creds are seeded. Web3
|
||||
// needs no IAM-side OAuth credential (the wallet IS the credential), so
|
||||
// it renders whenever the app enables it.
|
||||
const providers: Record<string, AppProvider> = {}
|
||||
for (const p of app.providers) {
|
||||
const enabled = p.key === 'web3' ? want(p) : want(p) && p.configured
|
||||
if (enabled && p.key in PROVIDER_META) providers[p.key] = p
|
||||
}
|
||||
setResolved({ providers })
|
||||
// A client that already knows the provider (console `?provider_hint=…`)
|
||||
// launches it straight away — the SAME hop the button runs, so a click
|
||||
// over there lands directly in the provider flow, no second press and no
|
||||
// bounce through this login page.
|
||||
if (autoStart && !autoStarted.current) {
|
||||
autoStarted.current = true
|
||||
const target = matchProviderHint(Object.values(providers), autoStart)
|
||||
if (target) {
|
||||
onAutoStartResolved?.(true)
|
||||
hop(target)
|
||||
} else {
|
||||
// Hint names a provider this app doesn't offer → let the caller show
|
||||
// the form rather than dead-end on a blank "signing you in".
|
||||
onAutoStartResolved?.(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return
|
||||
setResolved({ providers: {} })
|
||||
onAutoStartResolved?.(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
// Run once on mount: getAppLogin is a one-shot and autoStart is fixed for
|
||||
// the life of the page; the ref guards the hop against a double-fire.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [client, clientIdOverride, intent])
|
||||
|
||||
// In autoStart mode the component is headless — it exists only to run the hop
|
||||
// above; the caller renders its own "signing you in" state. Render nothing.
|
||||
if (autoStart) return null
|
||||
if (resolved === null) return null // resolving — render nothing rather than flicker
|
||||
const ordered = ORDER.filter((k) => k in resolved.providers)
|
||||
if (ordered.length === 0) return null
|
||||
|
||||
const verb = intent === 'signup' ? 'Sign up' : 'Continue'
|
||||
|
||||
function startOAuth(provider: AppProvider) {
|
||||
setError(null)
|
||||
hop(provider)
|
||||
}
|
||||
|
||||
async function startWallet(chain: Chain) {
|
||||
setError(null)
|
||||
setBusyChain(chain)
|
||||
try {
|
||||
const sp = new URLSearchParams(typeof window !== 'undefined' ? window.location.search : '')
|
||||
const res = await loginWithWalletChain(client, chain, {
|
||||
clientId: clientIdOverride,
|
||||
redirectUri: postLoginRedirect,
|
||||
state: sp.get('state') ?? undefined,
|
||||
nonce: sp.get('nonce') ?? undefined,
|
||||
codeChallenge: sp.get('code_challenge') ?? undefined,
|
||||
codeChallengeMethod: (sp.get('code_challenge_method') as 'S256' | 'plain' | null) ?? undefined,
|
||||
})
|
||||
if (res.error) {
|
||||
setError(res.error)
|
||||
} else if (res.redirectUrl) {
|
||||
// Same post-login redirect the password flow performs.
|
||||
window.location.href = res.redirectUrl
|
||||
}
|
||||
} catch (e) {
|
||||
setError(String(e))
|
||||
} finally {
|
||||
setBusyChain(null)
|
||||
}
|
||||
}
|
||||
|
||||
// The chain-agnostic entry: auto-detect the injected wallet and connect
|
||||
// straight when exactly one chain is available; otherwise reveal the chooser
|
||||
// so the user picks EVM or Solana. Both underlying flows stay reachable.
|
||||
function onConnectWallet() {
|
||||
setError(null)
|
||||
const detected = detectWalletChains()
|
||||
if (detected.length === 1) startWallet(detected[0]!)
|
||||
else setWalletMenu(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="hanzo-id-social">
|
||||
{ordered.map((k) => {
|
||||
const provider = resolved.providers[k]!
|
||||
// Web3 expands into one connect button per ENABLED chain; OAuth
|
||||
// providers render a single hop button.
|
||||
if (k === 'web3') {
|
||||
// ONE chain-agnostic entry. It connects straight when a single
|
||||
// wallet is detected, else expands into the chooser below — so the
|
||||
// page always shows exactly one "Connect Wallet" button, with both
|
||||
// EVM and Solana reachable from it.
|
||||
return (
|
||||
<Fragment key="web3">
|
||||
<button
|
||||
type="button"
|
||||
className="hanzo-id-btn ghost"
|
||||
data-provider="web3"
|
||||
data-wallet-connect="true"
|
||||
aria-expanded={walletMenu}
|
||||
disabled={busyChain !== null}
|
||||
onClick={onConnectWallet}
|
||||
>
|
||||
<WalletIcon />
|
||||
<span>{busyChain !== null && !walletMenu ? 'Connecting…' : 'Connect Wallet'}</span>
|
||||
</button>
|
||||
{walletMenu ? (
|
||||
<div
|
||||
className="hanzo-id-wallet-chains"
|
||||
role="group"
|
||||
aria-label="Choose a wallet network"
|
||||
>
|
||||
{ENABLED_WALLET_CHAINS.map((chain) => (
|
||||
<button
|
||||
key={`web3-${chain}`}
|
||||
type="button"
|
||||
className="hanzo-id-btn ghost"
|
||||
data-provider="web3"
|
||||
data-chain={chain}
|
||||
disabled={busyChain !== null}
|
||||
onClick={() => startWallet(chain)}
|
||||
>
|
||||
<WalletIcon />
|
||||
<span>{busyChain === chain ? 'Connecting…' : WALLET_CHAIN_LABELS[chain]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
const meta = PROVIDER_META[k]!
|
||||
const { Icon } = meta
|
||||
return (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
className="hanzo-id-btn ghost"
|
||||
data-provider={k}
|
||||
onClick={() => startOAuth(provider)}
|
||||
>
|
||||
<Icon />
|
||||
<span>{verb} with {meta.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
|
||||
</div>
|
||||
{/* The "or" separator belongs WITH the social block — render it only when
|
||||
there are buttons, so it never dangles above the password form when
|
||||
no providers are configured. */}
|
||||
<Divider />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
/**
|
||||
* Minimal inline provider marks. Brand-neutral, currentColor-driven, no
|
||||
* external icon dependency. One 18px glyph per supported sign-in provider.
|
||||
*/
|
||||
import type { SVGProps } from 'react'
|
||||
|
||||
const base = (props: SVGProps<SVGSVGElement>) => ({
|
||||
width: 18,
|
||||
height: 18,
|
||||
viewBox: '0 0 24 24',
|
||||
'aria-hidden': true,
|
||||
focusable: false as const,
|
||||
...props,
|
||||
})
|
||||
|
||||
export function GitHubIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...base(props)} fill="currentColor">
|
||||
<path d="M12 .5C5.73.5.5 5.73.5 12a11.5 11.5 0 0 0 7.86 10.92c.58.1.79-.25.79-.56v-2c-3.2.7-3.88-1.37-3.88-1.37-.53-1.34-1.3-1.7-1.3-1.7-1.05-.72.08-.7.08-.7 1.17.08 1.78 1.2 1.78 1.2 1.04 1.78 2.73 1.27 3.4.97.1-.75.4-1.27.73-1.56-2.56-.29-5.26-1.28-5.26-5.7 0-1.26.45-2.29 1.2-3.1-.12-.3-.52-1.48.11-3.08 0 0 .97-.31 3.18 1.18a11 11 0 0 1 5.8 0c2.2-1.5 3.17-1.18 3.17-1.18.63 1.6.23 2.78.11 3.08.75.81 1.2 1.84 1.2 3.1 0 4.43-2.7 5.4-5.28 5.69.42.36.79 1.07.79 2.16v3.2c0 .31.21.67.8.56A11.5 11.5 0 0 0 23.5 12C23.5 5.73 18.27.5 12 .5Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function GoogleIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...base(props)}>
|
||||
<path fill="#4285F4" d="M23.52 12.27c0-.82-.07-1.6-.21-2.36H12v4.46h6.46a5.52 5.52 0 0 1-2.4 3.62v3h3.88c2.27-2.09 3.58-5.17 3.58-8.72Z" />
|
||||
<path fill="#34A853" d="M12 24c3.24 0 5.96-1.08 7.94-2.91l-3.88-3c-1.08.72-2.45 1.15-4.06 1.15-3.12 0-5.77-2.11-6.71-4.95H1.28v3.1A12 12 0 0 0 12 24Z" />
|
||||
<path fill="#FBBC05" d="M5.29 14.29A7.2 7.2 0 0 1 4.91 12c0-.8.14-1.57.38-2.29v-3.1H1.28A12 12 0 0 0 0 12c0 1.94.46 3.77 1.28 5.39l4.01-3.1Z" />
|
||||
<path fill="#EA4335" d="M12 4.76c1.76 0 3.34.61 4.58 1.8l3.43-3.43A11.99 11.99 0 0 0 12 0 12 12 0 0 0 1.28 6.61l4.01 3.1C6.23 6.87 8.88 4.76 12 4.76Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function GitLabIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...base(props)} fill="currentColor">
|
||||
<path d="M23.955 13.587l-1.342-4.135-2.664-8.189a.455.455 0 0 0-.867 0L16.418 9.45H7.582L4.919 1.263a.455.455 0 0 0-.867 0L1.388 9.452-.001 13.587a.924.924 0 0 0 .331 1.023L12 23.054l11.625-8.443a.92.92 0 0 0 .33-1.024" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function WalletIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...base(props)} fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 7a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v1H5a2 2 0 0 0-2 2V7Z" />
|
||||
<path d="M3 9a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V9Z" />
|
||||
<circle cx="16.5" cy="13" r="1.25" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,4 @@ export { LoginForm } from './LoginForm'
|
||||
export { SignupForm } from './SignupForm'
|
||||
export { ForgotForm } from './ForgotForm'
|
||||
export { OTPForm } from './OTPForm'
|
||||
export { MfaEnrollForm, type MfaEnrollFormProps } from './MfaEnrollForm'
|
||||
export { SmsConsentNotice, SMS_CONSENT_TEXT } from './SmsConsent'
|
||||
export { SocialButtons, type SocialButtonsProps } from './SocialButtons'
|
||||
export { Divider } from './Divider'
|
||||
export { ProviderButtons } from './ProviderButtons'
|
||||
|
||||
@@ -1,234 +0,0 @@
|
||||
/**
|
||||
* Multi-chain wallet login orchestration tests — pure, no network, no wallet
|
||||
* libs. Run with: pnpm --filter @hanzo/id-auth test
|
||||
*
|
||||
* Locks the connect→nonce→sign→verify→redirect contract against
|
||||
* `hanzoai/iam` controllers/web3_auth.go using a capturing fetch double and a
|
||||
* fake signer (the injectable `WalletSigner` seam — the real one lazy-loads the
|
||||
* wallet libs, which this test never touches).
|
||||
*/
|
||||
import { test } from 'vitest'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createAuthClient } from './client.ts'
|
||||
import {
|
||||
loginWithWalletChain,
|
||||
detectWalletChains,
|
||||
ENABLED_WALLET_CHAINS,
|
||||
WALLET_CHAIN_LABELS,
|
||||
type WalletSigner,
|
||||
} from './web3.ts'
|
||||
import type { Chain, LoginChallenge, SignedProof } from '@hanzo/id-connect'
|
||||
import type { OrgConfig } from '@hanzo/id-shared'
|
||||
|
||||
function org(overrides: Partial<OrgConfig> = {}): OrgConfig {
|
||||
return {
|
||||
orgId: 'hanzo',
|
||||
iamUrl: 'https://hanzo.id',
|
||||
iamIssuer: 'https://hanzo.id',
|
||||
clientId: 'hanzo-id',
|
||||
appName: 'hanzo-id',
|
||||
publicOrigin: 'https://hanzo.id',
|
||||
oauthCallbackOrigin: 'https://hanzo.id',
|
||||
brandPackage: '@hanzo/brand',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const CHALLENGE: LoginChallenge = {
|
||||
domain: 'hanzo.id',
|
||||
uri: 'https://hanzo.id',
|
||||
nonce: 'NONCE1234567890A',
|
||||
issuedAt: '2026-01-01T00:00:00.000Z',
|
||||
expirationTime: '2026-01-01T00:10:00.000Z',
|
||||
version: '1',
|
||||
}
|
||||
|
||||
/**
|
||||
* Capturing fetch: returns the minted CHALLENGE for the nonce GET, and a canned
|
||||
* IAM "ok" body for the verify POST (an auth code in `data`, like /v1/iam/login).
|
||||
* Records every call so the test can assert the exact wire shape.
|
||||
*/
|
||||
function capturingFetch(verifyData: unknown = 'AUTHCODE') {
|
||||
const calls: { url: string; method: string; body: Record<string, unknown> }[] = []
|
||||
const fetchImpl: typeof fetch = async (input, init) => {
|
||||
const url = typeof input === 'string' ? input : input.toString()
|
||||
const method = init?.method ?? 'GET'
|
||||
let body: Record<string, unknown> = {}
|
||||
if (init?.body && typeof init.body === 'string') body = JSON.parse(init.body)
|
||||
calls.push({ url, method, body })
|
||||
if (url.includes('/v1/iam/web3/nonce')) {
|
||||
return new Response(JSON.stringify({ status: 'ok', data: CHALLENGE }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
// verify
|
||||
return new Response(JSON.stringify({ status: 'ok', data: verifyData }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
return { calls, fetchImpl }
|
||||
}
|
||||
|
||||
/** Fake signer: records the (chain, challenge) it was handed, returns a proof. */
|
||||
function fakeSigner() {
|
||||
const seen: { chain: Chain; challenge: LoginChallenge }[] = []
|
||||
const proof: SignedProof = {
|
||||
chain: 'evm',
|
||||
scheme: 'secp256k1-eip191',
|
||||
address: '0xabc0000000000000000000000000000000000def',
|
||||
message: 'rendered CAIP-122 message',
|
||||
signature: '0xdeadbeef',
|
||||
}
|
||||
const sign: WalletSigner = async (chain, challenge) => {
|
||||
seen.push({ chain, challenge })
|
||||
return { ...proof, chain }
|
||||
}
|
||||
return { seen, sign, proof }
|
||||
}
|
||||
|
||||
test('fetches the nonce for the chosen chain, signs the returned challenge, POSTs the proof', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
const { seen, sign } = fakeSigner()
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
|
||||
const res = await loginWithWalletChain(client, 'evm', {}, fetchImpl, sign)
|
||||
|
||||
// 1) nonce GET first, scoped to the chain, on the brand's own iamUrl.
|
||||
assert.equal(calls[0]!.method, 'GET')
|
||||
assert.match(calls[0]!.url, /^https:\/\/hanzo\.id\/v1\/iam\/web3\/nonce\?chain=evm$/)
|
||||
|
||||
// 2) the server challenge was handed to the signer (nonce/domain/uri/times
|
||||
// intact — the server-minted, single-use values the proof must bind).
|
||||
assert.equal(seen.length, 1)
|
||||
assert.equal(seen[0]!.chain, 'evm')
|
||||
assert.equal(seen[0]!.challenge.nonce, CHALLENGE.nonce)
|
||||
assert.equal(seen[0]!.challenge.domain, CHALLENGE.domain)
|
||||
assert.equal(seen[0]!.challenge.uri, CHALLENGE.uri)
|
||||
assert.equal(seen[0]!.challenge.issuedAt, CHALLENGE.issuedAt)
|
||||
assert.equal(seen[0]!.challenge.expirationTime, CHALLENGE.expirationTime)
|
||||
|
||||
// 3) the proof + routing was POSTed to verify.
|
||||
const verify = calls[1]!
|
||||
assert.equal(verify.method, 'POST')
|
||||
assert.match(verify.url, /\/v1\/iam\/web3\/verify$/)
|
||||
assert.equal(verify.body.chain, 'evm')
|
||||
assert.equal(verify.body.scheme, 'secp256k1-eip191')
|
||||
assert.equal(verify.body.address, '0xabc0000000000000000000000000000000000def')
|
||||
assert.equal(verify.body.message, 'rendered CAIP-122 message')
|
||||
assert.equal(verify.body.signature, '0xdeadbeef')
|
||||
// routing fields the controller needs.
|
||||
assert.equal(verify.body.application, 'hanzo-id')
|
||||
assert.equal(verify.body.method, 'login')
|
||||
assert.equal(verify.body.clientId, 'hanzo-id')
|
||||
// bare sign-in (no downstream redirectUri) → type=login.
|
||||
assert.equal(verify.body.type, 'login')
|
||||
|
||||
// 4) bare sign-in lands on onboarding — same destination as the password flow.
|
||||
assert.equal(res.redirectUrl, '/onboarding')
|
||||
assert.equal(res.error, undefined)
|
||||
})
|
||||
|
||||
test('SSO flow (downstream redirectUri) sends type=code and returns the app redirect with the minted code', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch('CODE_XYZ')
|
||||
const { sign } = fakeSigner()
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
|
||||
const res = await loginWithWalletChain(
|
||||
client,
|
||||
'evm',
|
||||
{ redirectUri: 'https://console.hanzo.ai/auth/iam/callback', state: 'rp123', clientId: 'hanzo-console' },
|
||||
fetchImpl,
|
||||
sign,
|
||||
)
|
||||
|
||||
const verify = calls[1]!
|
||||
assert.equal(verify.body.type, 'code')
|
||||
assert.equal(verify.body.redirectUri, 'https://console.hanzo.ai/auth/iam/callback')
|
||||
assert.equal(verify.body.state, 'rp123')
|
||||
assert.equal(verify.body.clientId, 'hanzo-console')
|
||||
assert.equal(
|
||||
res.redirectUrl,
|
||||
'https://console.hanzo.ai/auth/iam/callback?code=CODE_XYZ&state=rp123',
|
||||
)
|
||||
})
|
||||
|
||||
test('disabled chains are not offered and fail closed without any network or signer call', async () => {
|
||||
// The stub-verifier chains must NOT be in the enabled set...
|
||||
for (const stub of ['ton', 'xrp', 'bitcoin'] as const) {
|
||||
assert.equal(ENABLED_WALLET_CHAINS.includes(stub), false, `${stub} must be disabled`)
|
||||
}
|
||||
// ...and only the production-verifier chains are.
|
||||
assert.deepEqual([...ENABLED_WALLET_CHAINS], ['evm', 'solana'])
|
||||
// every enabled chain has a render label.
|
||||
for (const c of ENABLED_WALLET_CHAINS) assert.ok(WALLET_CHAIN_LABELS[c])
|
||||
|
||||
// Calling a disabled chain returns an error and touches neither fetch nor signer.
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
let signed = false
|
||||
const sign: WalletSigner = async () => {
|
||||
signed = true
|
||||
throw new Error('signer must not run for a disabled chain')
|
||||
}
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const res = await loginWithWalletChain(client, 'ton', {}, fetchImpl, sign)
|
||||
assert.match(res.error ?? '', /not enabled/)
|
||||
assert.equal(calls.length, 0)
|
||||
assert.equal(signed, false)
|
||||
})
|
||||
|
||||
test('a wallet rejection surfaces as { error } (not a throw), and verify is never called', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
const sign: WalletSigner = async () => {
|
||||
throw new Error('User rejected the request')
|
||||
}
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const res = await loginWithWalletChain(client, 'evm', {}, fetchImpl, sign)
|
||||
assert.equal(res.error, 'User rejected the request')
|
||||
// nonce was fetched (1 call) but verify was NOT (no 2nd call).
|
||||
assert.equal(calls.length, 1)
|
||||
assert.match(calls[0]!.url, /\/v1\/iam\/web3\/nonce/)
|
||||
})
|
||||
|
||||
test('detectWalletChains: a single injected wallet resolves to exactly its chain', () => {
|
||||
// EVM only → [evm]; the UI connects straight, no chooser.
|
||||
assert.deepEqual(detectWalletChains({ ethereum: {} }), ['evm'])
|
||||
// Solana only, via any of the recognized injected providers → [solana].
|
||||
assert.deepEqual(detectWalletChains({ solana: {} }), ['solana'])
|
||||
assert.deepEqual(detectWalletChains({ solflare: {} }), ['solana'])
|
||||
assert.deepEqual(detectWalletChains({ backpack: {} }), ['solana'])
|
||||
})
|
||||
|
||||
test('detectWalletChains: both injected → both, in enabled order (chooser)', () => {
|
||||
assert.deepEqual(detectWalletChains({ ethereum: {}, solana: {} }), ['evm', 'solana'])
|
||||
})
|
||||
|
||||
test('detectWalletChains: nothing injected → [] (chooser, both still reachable)', () => {
|
||||
// No window (server / node) and an empty window both resolve to none — the UI
|
||||
// then reveals the chooser so EVM and Solana stay selectable regardless.
|
||||
assert.deepEqual(detectWalletChains({}), [])
|
||||
assert.deepEqual(detectWalletChains(undefined), [])
|
||||
assert.deepEqual(detectWalletChains(), []) // node has no global window
|
||||
// Every detectable chain is one the wallet flow actually enables.
|
||||
for (const c of detectWalletChains({ ethereum: {}, solana: {} })) {
|
||||
assert.ok(ENABLED_WALLET_CHAINS.includes(c))
|
||||
}
|
||||
})
|
||||
|
||||
test('an IAM verify error is returned as { error }', async () => {
|
||||
const calls: string[] = []
|
||||
const fetchImpl: typeof fetch = async (input) => {
|
||||
const url = typeof input === 'string' ? input : input.toString()
|
||||
calls.push(url)
|
||||
if (url.includes('/web3/nonce')) {
|
||||
return new Response(JSON.stringify({ status: 'ok', data: CHALLENGE }), { status: 200 })
|
||||
}
|
||||
return new Response(JSON.stringify({ status: 'error', msg: 'web3: bad signature' }), { status: 200 })
|
||||
}
|
||||
const { sign } = fakeSigner()
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const res = await loginWithWalletChain(client, 'evm', {}, fetchImpl, sign)
|
||||
assert.equal(res.error, 'web3: bad signature')
|
||||
assert.equal(res.redirectUrl, undefined)
|
||||
})
|
||||
@@ -1,245 +0,0 @@
|
||||
/**
|
||||
* Multi-chain wallet Sign-In-With-X — the ONE client-side orchestration.
|
||||
*
|
||||
* Decomplected: connect+sign is the BROWSER's job (native `@hanzo/id-connect`
|
||||
* connectors — viem / @solana injected wallets, no WalletConnect, no projectId),
|
||||
* verify is the SERVER's job (IAM `POST /v1/iam/web3/verify`, which runs the same
|
||||
* `walletconnect.VerifyProof` the connectors target). This module ties the two
|
||||
* into a single call so the UI only picks a chain and follows the redirect.
|
||||
*
|
||||
* client: nonce ─► connect ─► signLogin(challenge) ─► SignedProof
|
||||
* ─► POST verify ─► IAM signs in ─► same redirect the password flow uses
|
||||
*
|
||||
* Wire contract (verified against `hanzoai/iam` controllers/web3_auth.go):
|
||||
* GET {iamUrl}/v1/iam/web3/nonce?chain=<c>&address=<a>
|
||||
* → {status:'ok', data:{domain,uri,statement,nonce,issuedAt,
|
||||
* expirationTime,version}} (a LoginChallenge)
|
||||
* POST {iamUrl}/v1/iam/web3/verify body = SignedProof + routing fields
|
||||
* → same success shape as /v1/iam/login (auth code | session cookie).
|
||||
*/
|
||||
import type { OrgConfig } from '@hanzo/id-shared'
|
||||
import type { Chain, LoginChallenge, SignedProof } from '@hanzo/id-connect'
|
||||
import type { AuthClient } from './client'
|
||||
import type { LoginResponse } from './types'
|
||||
|
||||
/**
|
||||
* Connect a wallet on `chain` and sign `challenge`, returning the proof. The
|
||||
* single seam between this orchestrator and the browser wallet libs: the default
|
||||
* lazy-loads `@hanzo/id-connect/login` (so importing this module never pulls
|
||||
* viem/sats-connect, and the wallet bundle is code-split until first use); tests
|
||||
* inject a fake. One signature, one way.
|
||||
*/
|
||||
export type WalletSigner = (chain: Chain, challenge: LoginChallenge) => Promise<SignedProof>
|
||||
|
||||
const defaultSigner: WalletSigner = async (chain, challenge) => {
|
||||
const { loginWithWallet } = await import('@hanzo/id-connect/login')
|
||||
const { proof } = await loginWithWallet({ chain, challenge })
|
||||
return proof
|
||||
}
|
||||
|
||||
/**
|
||||
* Chains whose wallet login is ENABLED. The server-side verifiers for EVM and
|
||||
* Solana are production-grade; TON / XRP / Bitcoin verifiers are still stubs and
|
||||
* would fail closed, so they are NOT offered. Adding a chain later is one line
|
||||
* here (once its Go verifier is real). Single source of truth — the UI renders
|
||||
* exactly this set.
|
||||
*/
|
||||
export const ENABLED_WALLET_CHAINS: readonly Chain[] = ['evm', 'solana']
|
||||
|
||||
/** Display label per chain, shown on each connect button. */
|
||||
export const WALLET_CHAIN_LABELS: Record<Chain, string> = {
|
||||
evm: 'Ethereum / EVM',
|
||||
solana: 'Solana',
|
||||
bitcoin: 'Bitcoin',
|
||||
ton: 'TON',
|
||||
xrp: 'XRP',
|
||||
}
|
||||
|
||||
/** The `window` fields the injected-wallet sniff reads — kept local so the
|
||||
* wallet libs stay out of this module (detection is a pure property read). */
|
||||
export interface WalletWindow {
|
||||
readonly ethereum?: unknown
|
||||
readonly solana?: unknown
|
||||
readonly solflare?: unknown
|
||||
readonly backpack?: unknown
|
||||
}
|
||||
|
||||
/** Is an injected wallet for `chain` present on `w`? Mirrors the connectors'
|
||||
* own discovery: EVM = `window.ethereum` (EIP-1193 / EIP-6963 legacy handle),
|
||||
* Solana = Phantom/Solflare/Backpack injected providers. */
|
||||
function chainInjected(chain: Chain, w: WalletWindow): boolean {
|
||||
switch (chain) {
|
||||
case 'evm':
|
||||
return Boolean(w.ethereum)
|
||||
case 'solana':
|
||||
return Boolean(w.solana || w.solflare || w.backpack)
|
||||
default:
|
||||
// A chain with no sniff is never auto-detected; the chooser still offers
|
||||
// it. Only the ENABLED set is ever consulted, so this stays unreachable.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The ENABLED wallet chains that currently have an injected provider. A pure
|
||||
* `window` sniff — no connect, no I/O — that powers the chain-agnostic "Connect
|
||||
* Wallet" entry: exactly one match → connect straight; zero or many → let the
|
||||
* user pick. Derived from {@link ENABLED_WALLET_CHAINS} so there is ONE source
|
||||
* of truth for what wallet login offers.
|
||||
*/
|
||||
export function detectWalletChains(
|
||||
w: WalletWindow | undefined = typeof window === 'undefined' ? undefined : (window as WalletWindow),
|
||||
): Chain[] {
|
||||
if (!w) return []
|
||||
return ENABLED_WALLET_CHAINS.filter((c) => chainInjected(c, w))
|
||||
}
|
||||
|
||||
/** Routing context for the verify POST — exactly what the password flow carries. */
|
||||
export interface WalletLoginContext {
|
||||
/** Override the OAuth client_id (downstream app); defaults to org.clientId. */
|
||||
readonly clientId?: string
|
||||
/** Downstream app `redirect_uri`; presence flips the flow to the auth-code (SSO) path. */
|
||||
readonly redirectUri?: string
|
||||
readonly state?: string
|
||||
/** OIDC nonce from the downstream authorize request, echoed into the minted code. */
|
||||
readonly nonce?: string
|
||||
readonly codeChallenge?: string
|
||||
readonly codeChallengeMethod?: 'S256' | 'plain'
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect a wallet on `chain`, sign the IAM-minted challenge, and verify it —
|
||||
* resolving to the SAME {@link LoginResponse} the password login returns (so the
|
||||
* caller reuses one redirect path). Throws only on a programming/transport error
|
||||
* the UI can't act on; expected failures (user rejects, bad signature) come back
|
||||
* as `{ error }`.
|
||||
*
|
||||
* `client.org.iamUrl` is the fetch base (HIP-0111 host-relative — the brand's
|
||||
* own `*.id` host), matching every other AuthClient call.
|
||||
*/
|
||||
export async function loginWithWalletChain(
|
||||
client: AuthClient,
|
||||
chain: Chain,
|
||||
ctx: WalletLoginContext = {},
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
sign: WalletSigner = defaultSigner,
|
||||
): Promise<LoginResponse> {
|
||||
const org = client.org
|
||||
if (!ENABLED_WALLET_CHAINS.includes(chain)) {
|
||||
return { error: `wallet login not enabled for ${chain}` }
|
||||
}
|
||||
|
||||
// 1. Mint the challenge, then connect+sign atomically (the connector
|
||||
// disconnects on failure). The nonce is fetched without an address — the
|
||||
// controller treats (chain,address) as advisory and binds the real address
|
||||
// from the SIGNED message, so there is no second round-trip to scope it.
|
||||
let proof: SignedProof
|
||||
try {
|
||||
const challenge = await fetchNonce(org, chain, fetchImpl)
|
||||
proof = await sign(chain, challenge)
|
||||
} catch (err) {
|
||||
return { error: errMessage(err) }
|
||||
}
|
||||
|
||||
// 2. Verify the proof + routing at IAM. Type defaults to "login" (session
|
||||
// cookie) server-side; a downstream redirectUri makes it the code flow.
|
||||
const url = new URL('/v1/iam/web3/verify', org.iamUrl)
|
||||
const res = await fetchImpl(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
// routing
|
||||
organization: org.loginOrg ?? '',
|
||||
application: org.appName,
|
||||
method: 'login',
|
||||
clientId: ctx.clientId ?? org.clientId,
|
||||
redirectUri: ctx.redirectUri ?? '',
|
||||
state: ctx.state ?? '',
|
||||
scope: 'openid profile email',
|
||||
type: ctx.redirectUri ? 'code' : 'login',
|
||||
nonce: ctx.nonce ?? '',
|
||||
codeChallenge: ctx.codeChallenge ?? '',
|
||||
codeChallengeMethod: ctx.codeChallengeMethod ?? '',
|
||||
// proof
|
||||
chain: proof.chain,
|
||||
scheme: proof.scheme,
|
||||
address: proof.address,
|
||||
publicKey: proof.publicKey ?? '',
|
||||
message: proof.message,
|
||||
signature: proof.signature,
|
||||
extra: proof.extra ?? {},
|
||||
}),
|
||||
})
|
||||
|
||||
return parseVerifyResponse(res, ctx)
|
||||
}
|
||||
|
||||
/** GET the CAIP-122 challenge for (chain) from IAM; throws on a non-ok payload. */
|
||||
async function fetchNonce(
|
||||
org: OrgConfig,
|
||||
chain: Chain,
|
||||
fetchImpl: typeof fetch,
|
||||
): Promise<LoginChallenge> {
|
||||
const url = new URL('/v1/iam/web3/nonce', org.iamUrl)
|
||||
url.searchParams.set('chain', chain)
|
||||
const res = await fetchImpl(url.toString(), { headers: { Accept: 'application/json' } })
|
||||
let body: Record<string, unknown> = {}
|
||||
try {
|
||||
body = (await res.json()) as Record<string, unknown>
|
||||
} catch {
|
||||
throw new Error(`web3 nonce: HTTP ${res.status} non-JSON response`)
|
||||
}
|
||||
if (!res.ok || body.status !== 'ok' || typeof body.data !== 'object' || body.data === null) {
|
||||
throw new Error(typeof body.msg === 'string' ? body.msg : `web3 nonce: HTTP ${res.status}`)
|
||||
}
|
||||
const d = body.data as Record<string, unknown>
|
||||
return {
|
||||
domain: String(d.domain ?? ''),
|
||||
uri: String(d.uri ?? ''),
|
||||
statement: typeof d.statement === 'string' ? d.statement : undefined,
|
||||
nonce: String(d.nonce ?? ''),
|
||||
issuedAt: String(d.issuedAt ?? ''),
|
||||
expirationTime: typeof d.expirationTime === 'string' ? d.expirationTime : undefined,
|
||||
version: typeof d.version === 'string' ? d.version : '1',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape the `/v1/iam/web3/verify` response into a {@link LoginResponse}, mirroring
|
||||
* the password flow's `parseLoginResponse`: auth-code flow → a redirect back to
|
||||
* the downstream app; bare sign-in → land on onboarding.
|
||||
*/
|
||||
async function parseVerifyResponse(
|
||||
res: Response,
|
||||
ctx: WalletLoginContext,
|
||||
): Promise<LoginResponse> {
|
||||
let body: Record<string, unknown> = {}
|
||||
try {
|
||||
body = (await res.json()) as Record<string, unknown>
|
||||
} catch {
|
||||
return { error: `HTTP ${res.status} non-JSON response` }
|
||||
}
|
||||
if (!res.ok || body.status === 'error') {
|
||||
return { error: typeof body.msg === 'string' ? body.msg : `HTTP ${res.status}` }
|
||||
}
|
||||
const data = body.data
|
||||
|
||||
// Auth-code (SSO) flow: a downstream redirectUri is present and `data` is the
|
||||
// minted code — hand back a fully-formed redirect to the app.
|
||||
if (ctx.redirectUri && typeof data === 'string' && data.length > 0) {
|
||||
const sep = ctx.redirectUri.includes('?') ? '&' : '?'
|
||||
return {
|
||||
redirectUrl: `${ctx.redirectUri}${sep}code=${encodeURIComponent(data)}&state=${encodeURIComponent(ctx.state ?? '')}`,
|
||||
}
|
||||
}
|
||||
|
||||
// Bare portal sign-in: the IAM session cookie is set; land on onboarding —
|
||||
// identical to the password path so there is one post-login destination.
|
||||
return { redirectUrl: '/onboarding' }
|
||||
}
|
||||
|
||||
function errMessage(err: unknown): string {
|
||||
if (err instanceof Error) return err.message
|
||||
return String(err)
|
||||
}
|
||||
@@ -4,6 +4,5 @@
|
||||
"outDir": "dist",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
# @luxwallet/connect
|
||||
|
||||
Multi-chain wallet connect + **Sign-In-With-X** for **EVM, Solana, Bitcoin, TON, XRP**.
|
||||
|
||||
One vocabulary, one canonical login message ([CAIP-122](https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-122.md)),
|
||||
one verifier. **MIT licensed — zero GPL.** This is the clean wallet stack; the
|
||||
Uniswap-derived GPL bones stay quarantined in `luxfi/exchange`.
|
||||
|
||||
## Why
|
||||
|
||||
`@luxfi/wallet` (Uniswap "Universe" fork) is GPL-3.0 and EVM-only. This package
|
||||
is a from-scratch, permissively-licensed connector that any Hanzo/Lux/Zoo/Pars
|
||||
surface — `hanzo.id` login, the browser extension, web and mobile apps — can use
|
||||
to authenticate a wallet on **any** supported chain.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
connect(chain) ─► Account ─► signLogin(challenge) ─► SignedProof ─► verifyProof()
|
||||
(browser, per-chain connector) (server, one pure fn)
|
||||
```
|
||||
|
||||
- **`caip122.ts`** — render/parse the canonical login message. `build ∘ parse` round-trips.
|
||||
- **`verify.ts`** — `verifyProof(proof, expected)`: parse → enforce domain/nonce/time → dispatch to the per-chain crypto verifier. Pure, fails closed, never throws.
|
||||
- **`<chain>/`** — per-chain connector (browser) + verifier (pure crypto).
|
||||
- **`go/walletconnect`** — Go port of `verifyProof`, imported by Hanzo IAM so the server verifies identically.
|
||||
|
||||
## Chain support
|
||||
|
||||
| Chain | Connect lib (license) | Login proof | Connector | Verifier |
|
||||
|-------|-----------------------|-------------|-----------|----------|
|
||||
| EVM | `viem` (MIT) — EIP-6963 / `window.ethereum` | EIP-191 `personal_sign` | ✅ `evm/connect.ts` | ✅ secp256k1 recover |
|
||||
| Solana | injected provider (Phantom/Solflare/Backpack) | ed25519 `signMessage` | ✅ `solana/connect.ts` | ✅ ed25519 |
|
||||
| Bitcoin | `sats-connect` (MIT) — Xverse/Leather/Unisat | BIP-322 | ✅ `bitcoin/connect.ts` | ✅ legacy + BIP-322 |
|
||||
| TON | `@tonconnect/sdk` (Apache-2.0) | `ton_proof` | ✅ `ton/connect.ts` | ✅ ed25519 envelope |
|
||||
| XRP | `@crossmarkio/sdk` (MIT) — Crossmark | `signInAndWait` | ✅ `xrp/connect.ts` | ✅ secp256k1 + ed25519 |
|
||||
|
||||
All connect libs are MIT/Apache/ISC — **no GPL anywhere** in the dependency tree.
|
||||
GemWallet is intentionally not wired: its only client, `@gemwallet/api`, ships
|
||||
under a custom dual license requiring GemWallet's permission for public/commercial
|
||||
use — incompatible with the MIT/Apache/ISC-only rule. Crossmark covers both XRPL
|
||||
key types, so the XRP path is complete without it.
|
||||
|
||||
### Architecture: server verify never pulls a wallet lib
|
||||
|
||||
The wallet libraries are **optional peer dependencies**. The server-side
|
||||
`verifyProof` path imports only `@noble/*` + `bs58`:
|
||||
|
||||
```ts
|
||||
import { verifyProof } from '@luxwallet/connect/verify'; // zero wallet libs
|
||||
import { buildSiwxMessage } from '@luxwallet/connect/caip122'; // zero deps
|
||||
```
|
||||
|
||||
Connectors live behind separate entrypoints, so a server bundle stays clean:
|
||||
|
||||
```ts
|
||||
import { loginWithWallet, getConnector } from '@luxwallet/connect/connectors';
|
||||
import { EvmConnector } from '@luxwallet/connect/evm/connect';
|
||||
```
|
||||
|
||||
## Use
|
||||
|
||||
```ts
|
||||
// Server: mint a challenge
|
||||
import { newChallenge, verifyProof } from '@luxwallet/connect';
|
||||
const challenge = newChallenge({ domain: 'hanzo.id', uri: 'https://hanzo.id/login' });
|
||||
// → store challenge.nonce, send challenge to the client
|
||||
|
||||
// Server: verify what comes back
|
||||
const res = verifyProof(proof, { domain: 'hanzo.id', nonce: challenge.nonce });
|
||||
if (res.ok) { /* res.address is authenticated on res.chain */ }
|
||||
```
|
||||
|
||||
```ts
|
||||
// Client (browser): connect a wallet and sign the challenge in one call.
|
||||
import { loginWithWallet } from '@luxwallet/connect/connectors';
|
||||
|
||||
const { account, proof } = await loginWithWallet({ chain: 'evm', challenge });
|
||||
// → POST `proof` to the server, which calls verifyProof(proof, { domain, nonce }).
|
||||
|
||||
// Or drive a connector directly:
|
||||
import { getConnector } from '@luxwallet/connect/connectors';
|
||||
const c = getConnector('solana');
|
||||
const acct = await c.connect(); // provider.connect()
|
||||
const p = await c.signLogin(acct, challenge); // ed25519 signMessage → SignedProof
|
||||
```
|
||||
|
||||
## Develop
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm test # vitest — crypto verifiers run against generated keypairs
|
||||
pnpm typecheck
|
||||
pnpm build
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT © Lux Industries Inc.
|
||||
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"name": "@hanzo/id-connect",
|
||||
"version": "0.1.0",
|
||||
"description": "Multi-chain wallet connect + Sign-In-With-X (EVM, Solana, Bitcoin, TON, XRP). Vendored from luxwallet/connect (MIT).",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./verify": "./src/verify.ts",
|
||||
"./caip122": "./src/caip122.ts",
|
||||
"./connectors": "./src/connectors.ts",
|
||||
"./login": "./src/login.ts",
|
||||
"./evm/connect": "./src/evm/connect.ts",
|
||||
"./solana/connect": "./src/solana/connect.ts",
|
||||
"./bitcoin/connect": "./src/bitcoin/connect.ts",
|
||||
"./ton/connect": "./src/ton/connect.ts",
|
||||
"./xrp/connect": "./src/xrp/connect.ts",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"scripts": {
|
||||
"tc": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@noble/curves": "^1.6.0",
|
||||
"@noble/hashes": "^1.5.0",
|
||||
"bs58": "^6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@crossmarkio/sdk": "^0.4.0",
|
||||
"@tonconnect/sdk": "^4.0.0",
|
||||
"sats-connect": "^4.2.1",
|
||||
"viem": "^2.53.1"
|
||||
}
|
||||
}
|
||||
@@ -1,434 +0,0 @@
|
||||
/**
|
||||
* Bitcoin verifier tests.
|
||||
*
|
||||
* Coverage:
|
||||
* • Legacy "Bitcoin Signed Message" (recoverable ECDSA) over a real CAIP-122
|
||||
* login message — for P2PKH, P2WPKH and P2TR (BIP-86) addresses.
|
||||
* • BIP-322 "simple" for P2WPKH (ECDSA / BIP-143) and P2TR key-path
|
||||
* (Schnorr / BIP-341).
|
||||
* • Tamper / wrong-address / wrong-type negatives (fail closed).
|
||||
* • Anchors: the BIP-322 message hash + to_spend txid + P2WPKH derivation
|
||||
* are pinned to the official Bitcoin Core BIP-322 test vectors, so the
|
||||
* sighash construction is verified against a known-answer source rather
|
||||
* than only self-consistently.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { secp256k1, schnorr } from '@noble/curves/secp256k1';
|
||||
import { sha256 } from '@noble/hashes/sha2';
|
||||
import { ripemd160 } from '@noble/hashes/legacy';
|
||||
import { verifyBitcoin } from '../bitcoin/verify.js';
|
||||
import { encodeSegwitAddress } from '../bitcoin/bech32.js';
|
||||
import { base58checkEncode } from '../bitcoin/base58check.js';
|
||||
import { buildSiwxMessage } from '../caip122.js';
|
||||
import { newChallenge } from '../nonce.js';
|
||||
import { utf8ToBytes, concatBytes, bytesToHex } from '../bytes.js';
|
||||
import type { SignedProof } from '../types.js';
|
||||
|
||||
// ── local crypto helpers (independent of the verifier internals) ─────────────
|
||||
|
||||
const enc = (s: string) => utf8ToBytes(s);
|
||||
const sha256d = (b: Uint8Array) => sha256(sha256(b));
|
||||
const hash160 = (b: Uint8Array) => ripemd160(sha256(b));
|
||||
|
||||
function taggedHash(tag: string, ...m: Uint8Array[]): Uint8Array {
|
||||
const t = sha256(enc(tag));
|
||||
return sha256(concatBytes(t, t, ...m));
|
||||
}
|
||||
|
||||
function compactSize(n: number): Uint8Array {
|
||||
if (n < 0xfd) return new Uint8Array([n]);
|
||||
if (n <= 0xffff) return new Uint8Array([0xfd, n & 0xff, (n >>> 8) & 0xff]);
|
||||
return new Uint8Array([0xfe, n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, (n >>> 24) & 0xff]);
|
||||
}
|
||||
const u32le = (n: number) =>
|
||||
new Uint8Array([n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, (n >>> 24) & 0xff]);
|
||||
const u64le = (n: bigint) => {
|
||||
const o = new Uint8Array(8);
|
||||
let v = n;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
o[i] = Number(v & 0xffn);
|
||||
v >>= 8n;
|
||||
}
|
||||
return o;
|
||||
};
|
||||
const varBytes = (b: Uint8Array) => concatBytes(compactSize(b.length), b);
|
||||
|
||||
function bytesToBig(b: Uint8Array): bigint {
|
||||
let v = 0n;
|
||||
for (const x of b) v = (v << 8n) | BigInt(x);
|
||||
return v;
|
||||
}
|
||||
function bigToXonly(x: bigint): Uint8Array {
|
||||
const o = new Uint8Array(32);
|
||||
let v = x;
|
||||
for (let i = 31; i >= 0; i--) {
|
||||
o[i] = Number(v & 0xffn);
|
||||
v >>= 8n;
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
function toBase64(b: Uint8Array): string {
|
||||
return Buffer.from(b).toString('base64');
|
||||
}
|
||||
|
||||
// Address derivations (must match the verifier's, independently written).
|
||||
function p2pkh(pubkey: Uint8Array): string {
|
||||
return base58checkEncode(0x00, hash160(pubkey));
|
||||
}
|
||||
function p2wpkh(pubCompressed: Uint8Array): string {
|
||||
return encodeSegwitAddress('bc', 0, hash160(pubCompressed))!;
|
||||
}
|
||||
function taprootTweak(internalXonly: Uint8Array): Uint8Array {
|
||||
const n = secp256k1.CURVE.n;
|
||||
const P = schnorr.utils.lift_x(bytesToBig(internalXonly));
|
||||
const t = bytesToBig(taggedHash('TapTweak', internalXonly)) % n;
|
||||
const Q = P.add(secp256k1.Point.BASE.multiply(t));
|
||||
return bigToXonly(Q.toAffine().x);
|
||||
}
|
||||
function p2tr(internalXonly: Uint8Array): { address: string; program: Uint8Array } {
|
||||
const program = taprootTweak(internalXonly);
|
||||
return { address: encodeSegwitAddress('bc', 1, program)!, program };
|
||||
}
|
||||
|
||||
// ── legacy "Bitcoin Signed Message" signer ───────────────────────────────────
|
||||
|
||||
function legacyDigest(message: string): Uint8Array {
|
||||
const msg = enc(message);
|
||||
const magic = enc('\x18Bitcoin Signed Message:\n');
|
||||
return sha256d(concatBytes(magic, compactSize(msg.length), msg));
|
||||
}
|
||||
|
||||
/** Produce a 65-byte [header || r || s] legacy signature. */
|
||||
function signLegacy(priv: Uint8Array, message: string, compressed: boolean): Uint8Array {
|
||||
const digest = legacyDigest(message);
|
||||
const sig = secp256k1.sign(digest, priv);
|
||||
const recid = sig.recovery!;
|
||||
const header = 27 + recid + (compressed ? 4 : 0);
|
||||
return concatBytes(new Uint8Array([header]), sig.toBytes('compact'));
|
||||
}
|
||||
|
||||
// ── BIP-322 simple signers (sign exactly the verifier's sighash) ─────────────
|
||||
|
||||
function toSpendTxid(message: string, scriptPubKey: Uint8Array): Uint8Array {
|
||||
const msgHash = taggedHash('BIP0322-signed-message', enc(message));
|
||||
const scriptSig = concatBytes(new Uint8Array([0x00, 0x20]), msgHash);
|
||||
const ser = concatBytes(
|
||||
u32le(0),
|
||||
compactSize(1),
|
||||
new Uint8Array(32),
|
||||
u32le(0xffffffff),
|
||||
varBytes(scriptSig),
|
||||
u32le(0),
|
||||
compactSize(1),
|
||||
u64le(0n),
|
||||
varBytes(scriptPubKey),
|
||||
u32le(0),
|
||||
);
|
||||
return sha256d(ser);
|
||||
}
|
||||
|
||||
function bip143SighashP2WPKH(txid: Uint8Array, h160: Uint8Array): Uint8Array {
|
||||
const outpoint = concatBytes(txid, u32le(0));
|
||||
const nSequence = u32le(0);
|
||||
const hashPrevouts = sha256d(outpoint);
|
||||
const hashSequence = sha256d(nSequence);
|
||||
const scriptCode = concatBytes(
|
||||
new Uint8Array([0x19, 0x76, 0xa9, 0x14]),
|
||||
h160,
|
||||
new Uint8Array([0x88, 0xac]),
|
||||
);
|
||||
const output = concatBytes(u64le(0n), varBytes(new Uint8Array([0x6a])));
|
||||
const hashOutputs = sha256d(output);
|
||||
const preimage = concatBytes(
|
||||
u32le(0),
|
||||
hashPrevouts,
|
||||
hashSequence,
|
||||
outpoint,
|
||||
scriptCode,
|
||||
u64le(0n),
|
||||
nSequence,
|
||||
hashOutputs,
|
||||
u32le(0),
|
||||
u32le(1),
|
||||
);
|
||||
return sha256d(preimage);
|
||||
}
|
||||
|
||||
function bip341SighashP2TR(txid: Uint8Array, scriptPubKey: Uint8Array): Uint8Array {
|
||||
const outpoint = concatBytes(txid, u32le(0));
|
||||
const nSequence = u32le(0);
|
||||
const shaPrevouts = sha256(outpoint);
|
||||
const shaAmounts = sha256(u64le(0n));
|
||||
const shaScriptPubkeys = sha256(varBytes(scriptPubKey));
|
||||
const shaSequences = sha256(nSequence);
|
||||
const output = concatBytes(u64le(0n), varBytes(new Uint8Array([0x6a])));
|
||||
const shaOutputs = sha256(output);
|
||||
const sigMsg = concatBytes(
|
||||
new Uint8Array([0x00]), // hash_type SIGHASH_DEFAULT
|
||||
u32le(0),
|
||||
u32le(0),
|
||||
shaPrevouts,
|
||||
shaAmounts,
|
||||
shaScriptPubkeys,
|
||||
shaSequences,
|
||||
shaOutputs,
|
||||
new Uint8Array([0x00]), // spend_type
|
||||
u32le(0), // input index
|
||||
);
|
||||
return taggedHash('TapSighash', concatBytes(new Uint8Array([0x00]), sigMsg));
|
||||
}
|
||||
|
||||
function serializeWitness(items: Uint8Array[]): Uint8Array {
|
||||
let out = compactSize(items.length);
|
||||
for (const it of items) out = concatBytes(out, varBytes(it));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** BIP-322 simple P2WPKH signature (serialized witness [sig||SIGHASH_ALL, pubkey]). */
|
||||
function signBip322P2WPKH(priv: Uint8Array, message: string): Uint8Array {
|
||||
const pub = secp256k1.getPublicKey(priv, true);
|
||||
const h160 = hash160(pub);
|
||||
const spk = concatBytes(new Uint8Array([0x00, 0x14]), h160);
|
||||
const txid = toSpendTxid(message, spk);
|
||||
const sighash = bip143SighashP2WPKH(txid, h160);
|
||||
const sig = secp256k1.sign(sighash, priv, { lowS: true });
|
||||
const der = concatBytes(sig.toBytes('der'), new Uint8Array([0x01])); // SIGHASH_ALL
|
||||
return serializeWitness([der, pub]);
|
||||
}
|
||||
|
||||
/** BIP-322 simple P2TR key-path signature (serialized witness [schnorr_sig]). */
|
||||
function signBip322P2TR(priv: Uint8Array, message: string): { sig: Uint8Array; address: string } {
|
||||
const internalXonly = secp256k1.getPublicKey(priv, true).slice(1);
|
||||
const { address, program } = p2tr(internalXonly);
|
||||
const spk = concatBytes(new Uint8Array([0x51, 0x20]), program);
|
||||
const txid = toSpendTxid(message, spk);
|
||||
const sighash = bip341SighashP2TR(txid, spk);
|
||||
// Taproot key-path must sign with the *tweaked* private key.
|
||||
const n = secp256k1.CURVE.n;
|
||||
let d = bytesToBig(priv) % n;
|
||||
// BIP-340: if the internal pubkey has odd Y, negate d.
|
||||
const Pfull = secp256k1.Point.BASE.multiply(d);
|
||||
if (Pfull.toAffine().y % 2n === 1n) d = n - d;
|
||||
const t = bytesToBig(taggedHash('TapTweak', internalXonly)) % n;
|
||||
const tweaked = (d + t) % n;
|
||||
const tweakedBytes = bigToXonly(tweaked);
|
||||
const sig = schnorr.sign(sighash, tweakedBytes);
|
||||
return { sig: serializeWitness([sig]), address };
|
||||
}
|
||||
|
||||
// ── fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeMessage(address: string): string {
|
||||
const challenge = newChallenge({
|
||||
domain: 'hanzo.id',
|
||||
uri: 'https://hanzo.id/login',
|
||||
statement: 'Sign in to Hanzo',
|
||||
nonce: 'abc123XYZ789',
|
||||
now: Date.UTC(2026, 0, 1),
|
||||
});
|
||||
return buildSiwxMessage({ challenge, address, chain: 'bitcoin' });
|
||||
}
|
||||
|
||||
// A fixed key so failures are reproducible.
|
||||
const PRIV = new Uint8Array(32).fill(0);
|
||||
PRIV[31] = 0x2a; // d = 42
|
||||
|
||||
describe('verifyBitcoin — anchors against Bitcoin Core BIP-322 vectors', () => {
|
||||
it('message_hash + to_spend txid match the official vectors', () => {
|
||||
const addr = 'bc1q9vza2e8x573nczrlzms0wvx3gsqjx7vavgkx0l';
|
||||
// Reconstruct that address's witness program from the known WIF private key.
|
||||
// WIF L3VFe…: 0x80 || priv(32) || 0x01 || checksum(4) → priv extracted below.
|
||||
const wifPriv = hexToBytesLocal(
|
||||
'bb051cd0dda0246f33c5a9e133ebd8e7bc02a92af6c41adc131ccd7826c5b004',
|
||||
);
|
||||
const pub = secp256k1.getPublicKey(wifPriv, true);
|
||||
expect(p2wpkh(pub)).toBe(addr); // P2WPKH derivation anchor
|
||||
|
||||
expect(bytesToHex(taggedHash('BIP0322-signed-message', enc('')))).toBe(
|
||||
'c90c269c4f8fcbe6880f72a721ddfbf1914268a794cbb21cfafee13770ae19f1',
|
||||
);
|
||||
expect(bytesToHex(taggedHash('BIP0322-signed-message', enc('Hello World')))).toBe(
|
||||
'f0eb03b1a75ac6d9847f55c624a99169b5dccba2a31f5b23bea77ba270de0a7a',
|
||||
);
|
||||
const spk = concatBytes(new Uint8Array([0x00, 0x14]), hash160(pub));
|
||||
const display = (b: Uint8Array) => bytesToHex(Uint8Array.from([...b].reverse()));
|
||||
expect(display(toSpendTxid('', spk))).toBe(
|
||||
'c5680aa69bb8d860bf82d4e9cd3504b55dde018de765a91bb566283c545a99a7',
|
||||
);
|
||||
expect(display(toSpendTxid('Hello World', spk))).toBe(
|
||||
'b79d196740ad5217771c1098fc4a4b51e0535c32236c71f1ea4d61a2d603352b',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function hexToBytesLocal(hex: string): Uint8Array {
|
||||
const out = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('verifyBitcoin — legacy Bitcoin Signed Message', () => {
|
||||
const pubC = secp256k1.getPublicKey(PRIV, true);
|
||||
const pubU = secp256k1.getPublicKey(PRIV, false);
|
||||
|
||||
it('P2PKH (compressed) verifies, tamper + wrong address fail', () => {
|
||||
const address = p2pkh(pubC);
|
||||
const message = makeMessage(address);
|
||||
const sig = signLegacy(PRIV, message, true);
|
||||
const proof: SignedProof = {
|
||||
chain: 'bitcoin',
|
||||
scheme: 'bip322',
|
||||
address,
|
||||
message,
|
||||
signature: toBase64(sig),
|
||||
};
|
||||
expect(verifyBitcoin(proof)).toBe(true);
|
||||
|
||||
// Tamper the signature (flip a byte in r).
|
||||
const bad = sig.slice();
|
||||
bad[5] = bad[5]! ^ 0xff;
|
||||
expect(verifyBitcoin({ ...proof, signature: toBase64(bad) })).toBe(false);
|
||||
|
||||
// Tamper the message.
|
||||
expect(verifyBitcoin({ ...proof, message: message + ' ' })).toBe(false);
|
||||
|
||||
// Wrong address (different key's P2PKH).
|
||||
const other = secp256k1.getPublicKey(hexToBytesLocal('11'.repeat(32)), true);
|
||||
expect(verifyBitcoin({ ...proof, address: p2pkh(other) })).toBe(false);
|
||||
});
|
||||
|
||||
it('P2PKH (uncompressed) verifies and is key-encoding-bound', () => {
|
||||
const address = p2pkh(pubU);
|
||||
const message = makeMessage(address);
|
||||
const sig = signLegacy(PRIV, message, false);
|
||||
expect(
|
||||
verifyBitcoin({ chain: 'bitcoin', scheme: 'bip322', address, message, signature: toBase64(sig) }),
|
||||
).toBe(true);
|
||||
|
||||
// The compressed-key address must NOT verify against an uncompressed-header sig.
|
||||
const cAddr = p2pkh(pubC);
|
||||
const cMsg = makeMessage(cAddr);
|
||||
const uncompSig = signLegacy(PRIV, cMsg, false);
|
||||
expect(
|
||||
verifyBitcoin({ chain: 'bitcoin', scheme: 'bip322', address: cAddr, message: cMsg, signature: toBase64(uncompSig) }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('P2WPKH verifies via legacy recoverable sig, rejects uncompressed header', () => {
|
||||
const address = p2wpkh(pubC);
|
||||
const message = makeMessage(address);
|
||||
const sig = signLegacy(PRIV, message, true);
|
||||
expect(
|
||||
verifyBitcoin({ chain: 'bitcoin', scheme: 'bip322', address, message, signature: toBase64(sig) }),
|
||||
).toBe(true);
|
||||
|
||||
// Uncompressed header can't back a segwit address → reject.
|
||||
const uncompSig = signLegacy(PRIV, message, false);
|
||||
expect(
|
||||
verifyBitcoin({ chain: 'bitcoin', scheme: 'bip322', address, message, signature: toBase64(uncompSig) }),
|
||||
).toBe(false);
|
||||
|
||||
// A P2WPKH address from a DIFFERENT key must not verify against this sig.
|
||||
const otherP2wpkh = p2wpkh(secp256k1.getPublicKey(hexToBytesLocal('05'.repeat(32)), true));
|
||||
expect(
|
||||
verifyBitcoin({
|
||||
chain: 'bitcoin',
|
||||
scheme: 'bip322',
|
||||
address: otherP2wpkh,
|
||||
message,
|
||||
signature: toBase64(sig),
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('P2TR (BIP-86) verifies via legacy recoverable sig', () => {
|
||||
const internalXonly = pubC.slice(1);
|
||||
const { address } = p2tr(internalXonly);
|
||||
const message = makeMessage(address);
|
||||
const sig = signLegacy(PRIV, message, true);
|
||||
expect(
|
||||
verifyBitcoin({ chain: 'bitcoin', scheme: 'bip322', address, message, signature: toBase64(sig) }),
|
||||
).toBe(true);
|
||||
|
||||
// Tamper → false.
|
||||
const bad = sig.slice();
|
||||
bad[40] = bad[40]! ^ 0x01;
|
||||
expect(
|
||||
verifyBitcoin({ chain: 'bitcoin', scheme: 'bip322', address, message, signature: toBase64(bad) }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyBitcoin — BIP-322 simple', () => {
|
||||
it('P2WPKH (BIP-143 / ECDSA) verifies, tamper + wrong address fail', () => {
|
||||
const pub = secp256k1.getPublicKey(PRIV, true);
|
||||
const address = p2wpkh(pub);
|
||||
const message = makeMessage(address);
|
||||
const sig = signBip322P2WPKH(PRIV, message);
|
||||
const proof: SignedProof = {
|
||||
chain: 'bitcoin',
|
||||
scheme: 'bip322',
|
||||
address,
|
||||
message,
|
||||
signature: toBase64(sig),
|
||||
extra: { addressType: 'p2wpkh' },
|
||||
};
|
||||
expect(verifyBitcoin(proof)).toBe(true);
|
||||
|
||||
// Tamper the message → sighash changes → false.
|
||||
expect(verifyBitcoin({ ...proof, message: message + 'x' })).toBe(false);
|
||||
|
||||
// Wrong address → witness pubkey no longer hashes to it → false.
|
||||
const other = p2wpkh(secp256k1.getPublicKey(hexToBytesLocal('07'.repeat(32)), true));
|
||||
expect(verifyBitcoin({ ...proof, address: other })).toBe(false);
|
||||
|
||||
// Truncated witness → false.
|
||||
expect(verifyBitcoin({ ...proof, signature: toBase64(sig.slice(0, sig.length - 3)) })).toBe(false);
|
||||
});
|
||||
|
||||
it('P2TR key-path (BIP-341 / Schnorr) verifies, tamper fails', () => {
|
||||
const message0 = 'placeholder';
|
||||
const { address } = signBip322P2TR(PRIV, message0); // get the address first
|
||||
const message = makeMessage(address);
|
||||
const { sig } = signBip322P2TR(PRIV, message);
|
||||
const proof: SignedProof = {
|
||||
chain: 'bitcoin',
|
||||
scheme: 'bip322',
|
||||
address,
|
||||
message,
|
||||
signature: toBase64(sig),
|
||||
extra: { addressType: 'p2tr' },
|
||||
};
|
||||
expect(verifyBitcoin(proof)).toBe(true);
|
||||
|
||||
// Tamper the message → false.
|
||||
expect(verifyBitcoin({ ...proof, message: message + 'z' })).toBe(false);
|
||||
|
||||
// Flip a byte in the schnorr sig → false.
|
||||
const bad = sig.slice();
|
||||
bad[bad.length - 1] = bad[bad.length - 1]! ^ 0x01;
|
||||
expect(verifyBitcoin({ ...proof, signature: toBase64(bad) })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyBitcoin — malformed input fails closed', () => {
|
||||
const address = p2wpkh(secp256k1.getPublicKey(PRIV, true));
|
||||
const message = makeMessage(address);
|
||||
const base: SignedProof = { chain: 'bitcoin', scheme: 'bip322', address, message, signature: '' };
|
||||
|
||||
it('empty signature → false', () => {
|
||||
expect(verifyBitcoin(base)).toBe(false);
|
||||
});
|
||||
it('garbage base64 → false', () => {
|
||||
expect(verifyBitcoin({ ...base, signature: '!!!notbase64!!!' })).toBe(false);
|
||||
});
|
||||
it('unknown address prefix → false', () => {
|
||||
expect(verifyBitcoin({ ...base, address: '3unsupportedP2SHaddress', signature: 'AQID' })).toBe(false);
|
||||
});
|
||||
it('legacy sig with out-of-range header → false', () => {
|
||||
const sig = new Uint8Array(65);
|
||||
sig[0] = 99; // invalid header
|
||||
expect(verifyBitcoin({ ...base, signature: toBase64(sig) })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildSiwxMessage, parseSiwxMessage } from '../caip122.js';
|
||||
import { newChallenge } from '../nonce.js';
|
||||
|
||||
describe('CAIP-122 message', () => {
|
||||
const now = 1_700_000_000_000; // fixed epoch for determinism
|
||||
|
||||
it('build → parse round-trips all fields', () => {
|
||||
const challenge = newChallenge({
|
||||
domain: 'hanzo.id',
|
||||
uri: 'https://hanzo.id/login',
|
||||
statement: 'Sign in to Hanzo.',
|
||||
nonce: 'abc12345',
|
||||
now,
|
||||
ttlSeconds: 600,
|
||||
requestId: 'req-1',
|
||||
resources: ['https://hanzo.ai/api', 'https://hanzo.chat'],
|
||||
});
|
||||
const msg = buildSiwxMessage({
|
||||
challenge,
|
||||
address: '0x1111111111111111111111111111111111111111',
|
||||
chain: 'evm',
|
||||
chainId: 'eip155:1',
|
||||
});
|
||||
const p = parseSiwxMessage(msg);
|
||||
expect(p.domain).toBe('hanzo.id');
|
||||
expect(p.address).toBe('0x1111111111111111111111111111111111111111');
|
||||
expect(p.statement).toBe('Sign in to Hanzo.');
|
||||
expect(p.uri).toBe('https://hanzo.id/login');
|
||||
expect(p.version).toBe('1');
|
||||
expect(p.chainId).toBe('eip155:1');
|
||||
expect(p.nonce).toBe('abc12345');
|
||||
expect(p.issuedAt).toBe(new Date(now).toISOString());
|
||||
expect(p.expirationTime).toBe(new Date(now + 600_000).toISOString());
|
||||
expect(p.requestId).toBe('req-1');
|
||||
expect(p.resources).toEqual(['https://hanzo.ai/api', 'https://hanzo.chat']);
|
||||
});
|
||||
|
||||
it('renders the chain label on the header line', () => {
|
||||
const c = newChallenge({ domain: 'hanzo.id', uri: 'https://hanzo.id', nonce: 'nonce123', now });
|
||||
expect(buildSiwxMessage({ challenge: c, address: 'So1aNa', chain: 'solana' })).toContain(
|
||||
'wants you to sign in with your Solana account:',
|
||||
);
|
||||
expect(buildSiwxMessage({ challenge: c, address: 'bc1q', chain: 'bitcoin' })).toContain(
|
||||
'with your Bitcoin account:',
|
||||
);
|
||||
expect(buildSiwxMessage({ challenge: c, address: 'EQxx', chain: 'ton' })).toContain(
|
||||
'with your TON account:',
|
||||
);
|
||||
expect(buildSiwxMessage({ challenge: c, address: 'rXYZ', chain: 'xrp' })).toContain(
|
||||
'with your XRP Ledger account:',
|
||||
);
|
||||
});
|
||||
|
||||
it('omits optional fields when absent', () => {
|
||||
const c = newChallenge({ domain: 'd', uri: 'https://d', nonce: 'nonce123', now });
|
||||
const msg = buildSiwxMessage({ challenge: { ...c, expirationTime: undefined }, address: 'a', chain: 'evm' });
|
||||
expect(msg).not.toContain('Chain ID:');
|
||||
expect(msg).not.toContain('Request ID:');
|
||||
expect(msg).not.toContain('Resources:');
|
||||
});
|
||||
|
||||
it('rejects a multi-line statement', () => {
|
||||
const c = newChallenge({ domain: 'd', uri: 'https://d', nonce: 'nonce123', now, statement: 'a\nb' });
|
||||
expect(() => buildSiwxMessage({ challenge: c, address: 'a', chain: 'evm' })).toThrow();
|
||||
});
|
||||
|
||||
it('throws on malformed message', () => {
|
||||
expect(() => parseSiwxMessage('not a siwx message')).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,265 +0,0 @@
|
||||
/**
|
||||
* Connector tests.
|
||||
*
|
||||
* Two things are exercised without a real wallet or browser:
|
||||
* 1. getConnector(chain) returns a connector whose `.chain` matches.
|
||||
* 2. EVM + Solana round-trip: a MOCKED injected provider (backed by a real
|
||||
* keypair) signs the CAIP-122 message via the connector's signLogin, and
|
||||
* the resulting SignedProof passes the server-side verifyProof.
|
||||
*
|
||||
* The other connectors (Bitcoin/TON/XRP) drive third-party SDKs whose wallet
|
||||
* handshakes cannot be faithfully mocked headlessly; they are covered by their
|
||||
* verifiers' round-trip tests and need a real wallet to exercise end-to-end.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { secp256k1 } from '@noble/curves/secp256k1';
|
||||
import { ed25519 } from '@noble/curves/ed25519';
|
||||
import bs58 from 'bs58';
|
||||
import { getConnector, allConnectors } from '../connectors.js';
|
||||
import { EvmConnector } from '../evm/connect.js';
|
||||
import { SolanaConnector } from '../solana/connect.js';
|
||||
import { verifyProof } from '../verify.js';
|
||||
import { newChallenge } from '../nonce.js';
|
||||
import { CHAINS, type Chain } from '../types.js';
|
||||
import {
|
||||
eip191Digest,
|
||||
addressFromPublicKey,
|
||||
recoverEvmAddress,
|
||||
} from '../evm/verify.js';
|
||||
import { bytesToHex, utf8ToBytes, hexToBytes } from '../bytes.js';
|
||||
|
||||
// ── factory ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getConnector', () => {
|
||||
it('returns a connector whose chain matches, for every chain', () => {
|
||||
for (const chain of CHAINS) {
|
||||
const c = getConnector(chain);
|
||||
expect(c.chain).toBe(chain);
|
||||
}
|
||||
});
|
||||
|
||||
it('binds the right class per chain', () => {
|
||||
expect(getConnector('evm')).toBeInstanceOf(EvmConnector);
|
||||
expect(getConnector('solana')).toBeInstanceOf(SolanaConnector);
|
||||
});
|
||||
|
||||
it('allConnectors() yields one connector per chain, in canonical order', () => {
|
||||
const all = allConnectors();
|
||||
expect(all.map((c) => c.chain)).toEqual(CHAINS as Chain[]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── shared window shim ───────────────────────────────────────────────────────
|
||||
// A bare window without addEventListener/dispatchEvent so the EVM connector's
|
||||
// EIP-6963 discovery short-circuits to the legacy window.ethereum path (fast,
|
||||
// deterministic — no 300ms announce wait).
|
||||
|
||||
const realWindow = (globalThis as Record<string, unknown>).window;
|
||||
|
||||
function setWindow(props: Record<string, unknown>): void {
|
||||
(globalThis as Record<string, unknown>).window = props;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
if (realWindow === undefined) {
|
||||
delete (globalThis as Record<string, unknown>).window;
|
||||
} else {
|
||||
(globalThis as Record<string, unknown>).window = realWindow;
|
||||
}
|
||||
});
|
||||
|
||||
// ── EVM mock provider (EIP-1193, real secp256k1 key) ─────────────────────────
|
||||
|
||||
function makeEvmProvider() {
|
||||
const priv = secp256k1.utils.randomPrivateKey();
|
||||
const pub = secp256k1.getPublicKey(priv, false);
|
||||
const address = addressFromPublicKey(pub); // lowercased 0x…
|
||||
|
||||
const provider = {
|
||||
async request({ method, params }: { method: string; params?: unknown[] }): Promise<unknown> {
|
||||
switch (method) {
|
||||
case 'eth_requestAccounts':
|
||||
case 'eth_accounts':
|
||||
return [address];
|
||||
case 'eth_chainId':
|
||||
return '0x1';
|
||||
case 'personal_sign': {
|
||||
// viem sends [data, account]; data is the 0x-hex of the UTF-8 message.
|
||||
const dataHex = params?.[0] as string;
|
||||
const msgBytes = hexToBytes(dataHex);
|
||||
const message = new TextDecoder().decode(msgBytes);
|
||||
const sig = secp256k1.sign(eip191Digest(message), priv);
|
||||
const full = new Uint8Array(65);
|
||||
full.set(sig.toCompactRawBytes(), 0);
|
||||
full[64] = (sig.recovery ?? 0) + 27;
|
||||
return '0x' + bytesToHex(full);
|
||||
}
|
||||
default:
|
||||
throw new Error(`unexpected method ${method}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
return { provider, address };
|
||||
}
|
||||
|
||||
describe('EvmConnector round-trip (mocked injected wallet)', () => {
|
||||
beforeEach(() => {
|
||||
const { provider } = makeEvmProvider();
|
||||
setWindow({ ethereum: provider });
|
||||
});
|
||||
|
||||
it('connects, signs the CAIP-122 message, and verifyProof accepts it', async () => {
|
||||
const c = new EvmConnector();
|
||||
const account = await c.connect();
|
||||
expect(account.chain).toBe('evm');
|
||||
expect(account.address).toMatch(/^0x[0-9a-fA-F]{40}$/);
|
||||
|
||||
const now = 1_700_000_000_000;
|
||||
const challenge = newChallenge({
|
||||
domain: 'hanzo.id',
|
||||
uri: 'https://hanzo.id/login',
|
||||
nonce: 'evmNonce123',
|
||||
now,
|
||||
});
|
||||
const proof = await c.signLogin(account, challenge);
|
||||
|
||||
expect(proof.scheme).toBe('secp256k1-eip191');
|
||||
expect(proof.chain).toBe('evm');
|
||||
// The signature recovers the connected address (the verifier's core check).
|
||||
expect(recoverEvmAddress(proof.message, proof.signature)?.toLowerCase()).toBe(
|
||||
account.address.toLowerCase(),
|
||||
);
|
||||
|
||||
const res = verifyProof(proof, { domain: 'hanzo.id', nonce: 'evmNonce123', now });
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.address?.toLowerCase()).toBe(account.address.toLowerCase());
|
||||
expect(res.chain).toBe('evm');
|
||||
});
|
||||
|
||||
it('available() discovers the injected wallet via the legacy path', async () => {
|
||||
const c = new EvmConnector();
|
||||
const wallets = await c.available();
|
||||
expect(wallets.length).toBe(1);
|
||||
expect(wallets[0]?.chain).toBe('evm');
|
||||
expect(wallets[0]?.installed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Solana mock provider (real ed25519 key) ──────────────────────────────────
|
||||
|
||||
function makeSolanaProvider() {
|
||||
const priv = ed25519.utils.randomPrivateKey();
|
||||
const pub = ed25519.getPublicKey(priv);
|
||||
const address = bs58.encode(pub);
|
||||
|
||||
const publicKey = {
|
||||
toBytes: () => pub,
|
||||
toString: () => address,
|
||||
};
|
||||
const provider = {
|
||||
isPhantom: true,
|
||||
publicKey,
|
||||
async connect() {
|
||||
return { publicKey };
|
||||
},
|
||||
async signMessage(message: Uint8Array, _encoding?: string) {
|
||||
return { signature: ed25519.sign(message, priv) };
|
||||
},
|
||||
async disconnect() {},
|
||||
};
|
||||
return { provider, address };
|
||||
}
|
||||
|
||||
describe('SolanaConnector round-trip (mocked injected wallet)', () => {
|
||||
let expectedAddress: string;
|
||||
|
||||
beforeEach(() => {
|
||||
const { provider, address } = makeSolanaProvider();
|
||||
expectedAddress = address;
|
||||
setWindow({ solana: provider });
|
||||
});
|
||||
|
||||
it('connects, signs the CAIP-122 message, and verifyProof accepts it', async () => {
|
||||
const c = new SolanaConnector();
|
||||
const account = await c.connect();
|
||||
expect(account.chain).toBe('solana');
|
||||
expect(account.address).toBe(expectedAddress);
|
||||
|
||||
const now = 1_700_000_000_000;
|
||||
const challenge = newChallenge({
|
||||
domain: 'hanzo.id',
|
||||
uri: 'https://hanzo.id/login',
|
||||
nonce: 'solNonce4567',
|
||||
now,
|
||||
});
|
||||
const proof = await c.signLogin(account, challenge);
|
||||
|
||||
expect(proof.scheme).toBe('ed25519');
|
||||
expect(proof.chain).toBe('solana');
|
||||
expect(proof.address).toBe(expectedAddress);
|
||||
|
||||
const res = verifyProof(proof, { domain: 'hanzo.id', nonce: 'solNonce4567', now });
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.address).toBe(expectedAddress);
|
||||
expect(res.chain).toBe('solana');
|
||||
});
|
||||
|
||||
it('handles the bare-Uint8Array signMessage return shape', async () => {
|
||||
// Some wallets return the raw signature bytes instead of {signature}.
|
||||
const priv = ed25519.utils.randomPrivateKey();
|
||||
const pub = ed25519.getPublicKey(priv);
|
||||
const address = bs58.encode(pub);
|
||||
const publicKey = { toBytes: () => pub, toString: () => address };
|
||||
setWindow({
|
||||
solana: {
|
||||
isPhantom: true,
|
||||
publicKey,
|
||||
connect: async () => ({ publicKey }),
|
||||
signMessage: async (m: Uint8Array) => ed25519.sign(m, priv),
|
||||
},
|
||||
});
|
||||
|
||||
const c = new SolanaConnector();
|
||||
const account = await c.connect();
|
||||
const now = 1_700_000_000_000;
|
||||
const challenge = newChallenge({
|
||||
domain: 'hanzo.id',
|
||||
uri: 'https://hanzo.id/login',
|
||||
nonce: 'solBareSig99',
|
||||
now,
|
||||
});
|
||||
const proof = await c.signLogin(account, challenge);
|
||||
expect(verifyProof(proof, { domain: 'hanzo.id', nonce: 'solBareSig99', now }).ok).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a proof whose nonce was tampered after signing', async () => {
|
||||
const c = new SolanaConnector();
|
||||
const account = await c.connect();
|
||||
const now = 1_700_000_000_000;
|
||||
const challenge = newChallenge({
|
||||
domain: 'hanzo.id',
|
||||
uri: 'https://hanzo.id/login',
|
||||
nonce: 'solGood0001',
|
||||
now,
|
||||
});
|
||||
const proof = await c.signLogin(account, challenge);
|
||||
// Server expects a different nonce → rejected before crypto.
|
||||
expect(verifyProof(proof, { domain: 'hanzo.id', nonce: 'solOther0002', now }).reason).toBe(
|
||||
'nonce-mismatch',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── browser-only guard ───────────────────────────────────────────────────────
|
||||
|
||||
describe('connectors are browser-only', () => {
|
||||
it('EVM connect throws without a window', async () => {
|
||||
delete (globalThis as Record<string, unknown>).window;
|
||||
await expect(new EvmConnector().connect()).rejects.toThrow(/browser-only/);
|
||||
});
|
||||
|
||||
it('Solana connect throws without a window', async () => {
|
||||
delete (globalThis as Record<string, unknown>).window;
|
||||
await expect(new SolanaConnector().connect()).rejects.toThrow(/browser-only/);
|
||||
});
|
||||
});
|
||||
@@ -1,222 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ed25519 } from '@noble/curves/ed25519';
|
||||
import { sha256 } from '@noble/hashes/sha256';
|
||||
import { verifyTon } from '../ton/verify.js';
|
||||
import { buildSiwxMessage } from '../caip122.js';
|
||||
import { newChallenge } from '../nonce.js';
|
||||
import { bytesToHex, bytesToBase64, utf8ToBytes, concatBytes } from '../bytes.js';
|
||||
import type { SignedProof } from '../types.js';
|
||||
|
||||
// --- Reproduce the TON Connect ton_proof signing algorithm (the wallet side). ---
|
||||
// This MUST mirror src/ton/verify.ts byte-for-byte; if they ever drift, the
|
||||
// round-trip "accepts a valid proof" test fails — which is the whole point.
|
||||
|
||||
interface ProofEnvelope {
|
||||
timestamp: number;
|
||||
domain: string;
|
||||
payload: string;
|
||||
workchain: number;
|
||||
addressHashHex: string;
|
||||
}
|
||||
|
||||
function tonProofDigest(env: ProofEnvelope): Uint8Array {
|
||||
const addressHash = hexFix(env.addressHashHex);
|
||||
|
||||
const wc = new Uint8Array(4);
|
||||
new DataView(wc.buffer).setInt32(0, env.workchain, false); // big-endian, signed
|
||||
|
||||
const domainBytes = utf8ToBytes(env.domain);
|
||||
const dlen = new Uint8Array(4);
|
||||
new DataView(dlen.buffer).setUint32(0, domainBytes.length, true); // little-endian
|
||||
|
||||
const ts = new Uint8Array(8);
|
||||
new DataView(ts.buffer).setBigUint64(0, BigInt(env.timestamp), true); // little-endian
|
||||
|
||||
const message = concatBytes(
|
||||
utf8ToBytes('ton-proof-item-v2/'),
|
||||
wc,
|
||||
addressHash,
|
||||
dlen,
|
||||
domainBytes,
|
||||
ts,
|
||||
utf8ToBytes(env.payload),
|
||||
);
|
||||
|
||||
const fullMsg = concatBytes(Uint8Array.of(0xff, 0xff), utf8ToBytes('ton-connect'), sha256(message));
|
||||
return sha256(fullMsg);
|
||||
}
|
||||
|
||||
/** Local hex→bytes (no 0x) so the test does not depend on verifier internals. */
|
||||
function hexFix(hex: string): Uint8Array {
|
||||
const h = hex.startsWith('0x') ? hex.slice(2) : hex;
|
||||
const out = new Uint8Array(h.length / 2);
|
||||
for (let i = 0; i < out.length; i++) out[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a fresh, self-consistent TON proof: random ed25519 key, a CAIP-122
|
||||
* message whose Nonce equals the ton_proof payload, and a real signature over
|
||||
* the reconstructed digest.
|
||||
*/
|
||||
function mintProof(overrides?: {
|
||||
workchain?: number;
|
||||
domain?: string;
|
||||
now?: number;
|
||||
}): { proof: SignedProof; env: ProofEnvelope; priv: Uint8Array; pub: Uint8Array } {
|
||||
const priv = ed25519.utils.randomPrivateKey();
|
||||
const pub = ed25519.getPublicKey(priv);
|
||||
|
||||
// A TON address-hash (account state-init hash). For the verifier it is just
|
||||
// 32 opaque bytes; use a deterministic-but-arbitrary value here.
|
||||
const addressHash = sha256(pub); // 32 bytes
|
||||
const addressHashHex = bytesToHex(addressHash);
|
||||
|
||||
const workchain = overrides?.workchain ?? 0;
|
||||
const domain = overrides?.domain ?? 'hanzo.id';
|
||||
const now = overrides?.now ?? 1_700_000_000_000;
|
||||
const timestamp = Math.floor(now / 1000);
|
||||
|
||||
// Server mints the nonce; the connector reuses it as the ton_proof payload.
|
||||
const challenge = newChallenge({ domain, uri: `https://${domain}/login`, now });
|
||||
const payload = challenge.nonce;
|
||||
|
||||
// The on-chain "address" we use for binding: friendly form would be base64url,
|
||||
// but for verifier purposes the address must simply equal the SIWx address
|
||||
// line. Use "<workchain>:<addressHashHex>" (raw TON address form).
|
||||
const address = `${workchain}:${addressHashHex}`;
|
||||
|
||||
const message = buildSiwxMessage({ challenge, address, chain: 'ton' });
|
||||
|
||||
const env: ProofEnvelope = { timestamp, domain, payload, workchain, addressHashHex };
|
||||
const digest = tonProofDigest(env);
|
||||
const signature = bytesToBase64(ed25519.sign(digest, priv));
|
||||
|
||||
const proof: SignedProof = {
|
||||
chain: 'ton',
|
||||
scheme: 'ton-proof',
|
||||
address,
|
||||
publicKey: bytesToHex(pub),
|
||||
message,
|
||||
signature,
|
||||
extra: { ...env },
|
||||
};
|
||||
return { proof, env, priv, pub };
|
||||
}
|
||||
|
||||
describe('TON ton_proof verify', () => {
|
||||
it('accepts a valid proof (full round-trip)', () => {
|
||||
const { proof } = mintProof();
|
||||
expect(verifyTon(proof)).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a valid proof on the masterchain (workchain = -1)', () => {
|
||||
// Exercises signed int32BE encoding: -1 must serialize as 0xFFFFFFFF on
|
||||
// both the signing and verifying sides.
|
||||
const { proof } = mintProof({ workchain: -1 });
|
||||
expect(verifyTon(proof)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a tampered timestamp', () => {
|
||||
const { proof } = mintProof();
|
||||
const bad: SignedProof = {
|
||||
...proof,
|
||||
extra: { ...(proof.extra as object), timestamp: (proof.extra as any).timestamp + 1 },
|
||||
};
|
||||
expect(verifyTon(bad)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a tampered domain', () => {
|
||||
const { proof } = mintProof();
|
||||
const bad: SignedProof = {
|
||||
...proof,
|
||||
extra: { ...(proof.extra as object), domain: 'evil.com' },
|
||||
};
|
||||
expect(verifyTon(bad)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a tampered payload (signature no longer matches)', () => {
|
||||
const { proof } = mintProof();
|
||||
// Mutate BOTH the SIWx nonce and the envelope payload so the binding check
|
||||
// passes and we isolate the cryptographic rejection.
|
||||
const tamperedPayload = (proof.extra as any).payload + 'X';
|
||||
const bad: SignedProof = {
|
||||
...proof,
|
||||
message: proof.message.replace(/Nonce: .*/, `Nonce: ${tamperedPayload}`),
|
||||
address: proof.address,
|
||||
extra: { ...(proof.extra as object), payload: tamperedPayload },
|
||||
};
|
||||
expect(verifyTon(bad)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a wrong public key', () => {
|
||||
const { proof } = mintProof();
|
||||
const otherPub = ed25519.getPublicKey(ed25519.utils.randomPrivateKey());
|
||||
const bad: SignedProof = { ...proof, publicKey: bytesToHex(otherPub) };
|
||||
expect(verifyTon(bad)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a nonce/payload mismatch (binding failure, before crypto)', () => {
|
||||
const { proof } = mintProof();
|
||||
// Envelope payload no longer equals the SIWx Nonce → binding rejects it
|
||||
// even though the (still-valid-for-old-payload) signature is untouched.
|
||||
const bad: SignedProof = {
|
||||
...proof,
|
||||
extra: { ...(proof.extra as object), payload: 'a-different-nonce' },
|
||||
};
|
||||
expect(verifyTon(bad)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an address that does not match the SIWx message', () => {
|
||||
const { proof } = mintProof();
|
||||
const bad: SignedProof = { ...proof, address: '0:deadbeef' };
|
||||
expect(verifyTon(bad)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a malformed signature (wrong length)', () => {
|
||||
const { proof } = mintProof();
|
||||
const bad: SignedProof = { ...proof, signature: bytesToBase64(new Uint8Array(63)) };
|
||||
expect(verifyTon(bad)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a malformed public key (wrong length)', () => {
|
||||
const { proof } = mintProof();
|
||||
const bad: SignedProof = { ...proof, publicKey: bytesToHex(new Uint8Array(31)) };
|
||||
expect(verifyTon(bad)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a malformed address hash (wrong length)', () => {
|
||||
const { proof } = mintProof();
|
||||
const bad: SignedProof = {
|
||||
...proof,
|
||||
extra: { ...(proof.extra as object), addressHashHex: 'dead' },
|
||||
};
|
||||
expect(verifyTon(bad)).toBe(false);
|
||||
});
|
||||
|
||||
it('fails closed on a missing envelope', () => {
|
||||
const { proof } = mintProof();
|
||||
const bad: SignedProof = { ...proof, extra: undefined };
|
||||
expect(verifyTon(bad)).toBe(false);
|
||||
});
|
||||
|
||||
it('fails closed on a missing public key', () => {
|
||||
const { proof } = mintProof();
|
||||
const bad = { ...proof, publicKey: undefined } as SignedProof;
|
||||
expect(verifyTon(bad)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not throw on garbage input', () => {
|
||||
const garbage = {
|
||||
chain: 'ton',
|
||||
scheme: 'ton-proof',
|
||||
address: 'x',
|
||||
publicKey: 'nothex',
|
||||
message: 'not a siwx message',
|
||||
signature: '!!!!',
|
||||
extra: { timestamp: 'soon', domain: 1, payload: null, workchain: 0.5, addressHashHex: 7 },
|
||||
} as unknown as SignedProof;
|
||||
expect(() => verifyTon(garbage)).not.toThrow();
|
||||
expect(verifyTon(garbage)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,140 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { secp256k1 } from '@noble/curves/secp256k1';
|
||||
import { ed25519 } from '@noble/curves/ed25519';
|
||||
import bs58 from 'bs58';
|
||||
import { verifyEvm, eip191Digest, addressFromPublicKey } from '../evm/verify.js';
|
||||
import { verifySolana } from '../solana/verify.js';
|
||||
import { verifyProof } from '../verify.js';
|
||||
import { buildSiwxMessage } from '../caip122.js';
|
||||
import { newChallenge } from '../nonce.js';
|
||||
import { bytesToHex, bytesToBase64, utf8ToBytes } from '../bytes.js';
|
||||
import type { SignedProof } from '../types.js';
|
||||
|
||||
// --- test signers (produce real signatures the verifier must accept) ---
|
||||
|
||||
function evmSign(message: string) {
|
||||
const priv = secp256k1.utils.randomPrivateKey();
|
||||
const pub = secp256k1.getPublicKey(priv, false);
|
||||
const address = addressFromPublicKey(pub);
|
||||
const sig = secp256k1.sign(eip191Digest(message), priv);
|
||||
const full = new Uint8Array(65);
|
||||
full.set(sig.toCompactRawBytes(), 0);
|
||||
full[64] = (sig.recovery ?? 0) + 27;
|
||||
return { address, signature: '0x' + bytesToHex(full) };
|
||||
}
|
||||
|
||||
function solanaSign(message: string) {
|
||||
const priv = ed25519.utils.randomPrivateKey();
|
||||
const pub = ed25519.getPublicKey(priv);
|
||||
const address = bs58.encode(pub);
|
||||
const signature = bytesToBase64(ed25519.sign(utf8ToBytes(message), priv));
|
||||
return { address, signature };
|
||||
}
|
||||
|
||||
describe('EVM EIP-191 verify', () => {
|
||||
it('accepts a valid signature', () => {
|
||||
const message = 'hello hanzo';
|
||||
const { address, signature } = evmSign(message);
|
||||
expect(verifyEvm(message, signature, address)).toBe(true);
|
||||
});
|
||||
it('is case-insensitive on the address', () => {
|
||||
const message = 'hello';
|
||||
const { address, signature } = evmSign(message);
|
||||
expect(verifyEvm(message, signature, address.toUpperCase().replace('0X', '0x'))).toBe(true);
|
||||
});
|
||||
it('rejects a tampered message', () => {
|
||||
const { address, signature } = evmSign('original');
|
||||
expect(verifyEvm('tampered', signature, address)).toBe(false);
|
||||
});
|
||||
it('rejects a wrong address', () => {
|
||||
const { signature } = evmSign('m');
|
||||
expect(verifyEvm('m', signature, '0x0000000000000000000000000000000000000000')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Solana ed25519 verify', () => {
|
||||
it('accepts a valid signature', () => {
|
||||
const message = 'hello solana';
|
||||
const { address, signature } = solanaSign(message);
|
||||
expect(verifySolana(message, signature, address)).toBe(true);
|
||||
});
|
||||
it('rejects a tampered message', () => {
|
||||
const { address, signature } = solanaSign('original');
|
||||
expect(verifySolana('tampered', signature, address)).toBe(false);
|
||||
});
|
||||
it('rejects a wrong address', () => {
|
||||
const { signature } = solanaSign('m');
|
||||
const other = bs58.encode(ed25519.getPublicKey(ed25519.utils.randomPrivateKey()));
|
||||
expect(verifySolana('m', signature, other)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyProof end-to-end', () => {
|
||||
const now = 1_700_000_000_000;
|
||||
const base = { domain: 'hanzo.id', uri: 'https://hanzo.id/login', nonce: 'abc12345', now };
|
||||
|
||||
it('accepts a fresh EVM proof', () => {
|
||||
const challenge = newChallenge(base);
|
||||
// EVM: derive the address from the key, embed it in the message, then sign.
|
||||
const priv = secp256k1.utils.randomPrivateKey();
|
||||
const address = addressFromPublicKey(secp256k1.getPublicKey(priv, false));
|
||||
const message = buildSiwxMessage({ challenge, address, chain: 'evm' });
|
||||
const sig = secp256k1.sign(eip191Digest(message), priv);
|
||||
const full = new Uint8Array(65);
|
||||
full.set(sig.toCompactRawBytes(), 0);
|
||||
full[64] = (sig.recovery ?? 0) + 27;
|
||||
const proof: SignedProof = {
|
||||
chain: 'evm', scheme: 'secp256k1-eip191', address, message,
|
||||
signature: '0x' + bytesToHex(full),
|
||||
};
|
||||
const res = verifyProof(proof, { domain: 'hanzo.id', nonce: 'abc12345', now });
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.address).toBe(address);
|
||||
});
|
||||
|
||||
it('accepts a fresh Solana proof', () => {
|
||||
const challenge = newChallenge(base);
|
||||
// address is the pubkey; sign the message that embeds that address
|
||||
const priv = ed25519.utils.randomPrivateKey();
|
||||
const address = bs58.encode(ed25519.getPublicKey(priv));
|
||||
const message = buildSiwxMessage({ challenge, address, chain: 'solana' });
|
||||
const signature = bytesToBase64(ed25519.sign(utf8ToBytes(message), priv));
|
||||
const proof: SignedProof = { chain: 'solana', scheme: 'ed25519', address, message, signature };
|
||||
expect(verifyProof(proof, { domain: 'hanzo.id', nonce: 'abc12345', now }).ok).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects wrong nonce / domain', () => {
|
||||
const challenge = newChallenge(base);
|
||||
const address = bs58.encode(ed25519.getPublicKey(ed25519.utils.randomPrivateKey()));
|
||||
const message = buildSiwxMessage({ challenge, address, chain: 'solana' });
|
||||
const proof: SignedProof = { chain: 'solana', scheme: 'ed25519', address, message, signature: 'AAAA' };
|
||||
expect(verifyProof(proof, { domain: 'hanzo.id', nonce: 'WRONG', now }).reason).toBe('nonce-mismatch');
|
||||
expect(verifyProof(proof, { domain: 'evil.com', nonce: 'abc12345', now }).reason).toBe('domain-mismatch');
|
||||
});
|
||||
|
||||
it('rejects an expired proof', () => {
|
||||
const challenge = newChallenge({ ...base, ttlSeconds: 60 });
|
||||
const priv = ed25519.utils.randomPrivateKey();
|
||||
const address = bs58.encode(ed25519.getPublicKey(priv));
|
||||
const message = buildSiwxMessage({ challenge, address, chain: 'solana' });
|
||||
const signature = bytesToBase64(ed25519.sign(utf8ToBytes(message), priv));
|
||||
const proof: SignedProof = { chain: 'solana', scheme: 'ed25519', address, message, signature };
|
||||
// now is 1h after issuance, well past 60s ttl + skew
|
||||
const res = verifyProof(proof, { domain: 'hanzo.id', nonce: 'abc12345', now: now + 3_600_000 });
|
||||
expect(res.reason).toBe('expired');
|
||||
});
|
||||
|
||||
it('fails closed on an unknown scheme', () => {
|
||||
const challenge = newChallenge(base);
|
||||
const message = buildSiwxMessage({ challenge, address: 'rXYZ', chain: 'xrp' });
|
||||
const proof = { chain: 'xrp', scheme: 'totally-unknown', address: 'rXYZ', message, signature: '00' } as unknown as SignedProof;
|
||||
expect(verifyProof(proof, { domain: 'hanzo.id', nonce: 'abc12345', now }).reason).toBe('unsupported-scheme');
|
||||
});
|
||||
|
||||
it('fails closed (bad-signature) on a wired-but-unverifiable proof', () => {
|
||||
const challenge = newChallenge(base);
|
||||
const message = buildSiwxMessage({ challenge, address: 'rXYZ', chain: 'xrp' });
|
||||
const proof: SignedProof = { chain: 'xrp', scheme: 'secp256k1-xrpl', address: 'rXYZ', message, signature: '00' };
|
||||
expect(verifyProof(proof, { domain: 'hanzo.id', nonce: 'abc12345', now }).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,231 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { secp256k1 } from '@noble/curves/secp256k1';
|
||||
import { ed25519 } from '@noble/curves/ed25519';
|
||||
import { sha256 } from '@noble/hashes/sha256';
|
||||
import { sha512 } from '@noble/hashes/sha512';
|
||||
import { ripemd160 } from '@noble/hashes/ripemd160';
|
||||
import { verifyXrp } from '../xrp/verify.js';
|
||||
import { buildSiwxMessage } from '../caip122.js';
|
||||
import { newChallenge } from '../nonce.js';
|
||||
import { bytesToHex, concatBytes, utf8ToBytes } from '../bytes.js';
|
||||
import type { SignedProof } from '../types.js';
|
||||
|
||||
// --- Reproduce the XRPL signing + r-address derivation (the wallet side). ---
|
||||
// This is an INDEPENDENT implementation of the same spec the verifier uses; if
|
||||
// the two ever drift, the round-trip "accepts a valid proof" test fails. That
|
||||
// is the whole point of mirroring rather than importing verifier internals.
|
||||
|
||||
const XRPL_ALPHABET = 'rpshnaf39wBUDNEGHJKLM4PQRST7VWXYZ2bcdeCg65jkm8oFqi1tuvAxyz';
|
||||
|
||||
/** Independent XRPL base58check (account/version-prefixed payload in). */
|
||||
function base58CheckXrpl(payload: Uint8Array): string {
|
||||
const checksum = sha256(sha256(payload)).slice(0, 4);
|
||||
const full = concatBytes(payload, checksum);
|
||||
let acc = 0n;
|
||||
for (const b of full) acc = (acc << 8n) | BigInt(b);
|
||||
let out = '';
|
||||
while (acc > 0n) {
|
||||
out = XRPL_ALPHABET[Number(acc % 58n)] + out;
|
||||
acc /= 58n;
|
||||
}
|
||||
for (let i = 0; i < full.length && full[i] === 0; i++) out = XRPL_ALPHABET[0] + out;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** r-address from a full 33-byte XRPL public key. */
|
||||
function rAddressFromPubkey(pubkey33: Uint8Array): string {
|
||||
const accountId = ripemd160(sha256(pubkey33));
|
||||
return base58CheckXrpl(concatBytes(Uint8Array.of(0x00), accountId));
|
||||
}
|
||||
|
||||
function sha512Half(d: Uint8Array): Uint8Array {
|
||||
return sha512(d).slice(0, 32);
|
||||
}
|
||||
|
||||
interface Minted {
|
||||
proof: SignedProof;
|
||||
address: string;
|
||||
}
|
||||
|
||||
/** Mint a self-consistent ed25519-xrpl proof: key → r-address → SIWx → raw sig. */
|
||||
function mintEd25519(now = 1_700_000_000_000): Minted {
|
||||
const seed = ed25519.utils.randomPrivateKey();
|
||||
const raw32 = ed25519.getPublicKey(seed);
|
||||
// XRPL ed25519 public key = 0xED || 32-byte Edwards key.
|
||||
const pubkey33 = concatBytes(Uint8Array.of(0xed), raw32);
|
||||
const address = rAddressFromPubkey(pubkey33);
|
||||
|
||||
const challenge = newChallenge({ domain: 'hanzo.id', uri: 'https://hanzo.id/login', now });
|
||||
const message = buildSiwxMessage({ challenge, address, chain: 'xrp' });
|
||||
|
||||
// ed25519-xrpl signs the raw UTF-8 message bytes.
|
||||
const sig = ed25519.sign(utf8ToBytes(message), seed);
|
||||
|
||||
return {
|
||||
address,
|
||||
proof: {
|
||||
chain: 'xrp',
|
||||
scheme: 'ed25519-xrpl',
|
||||
address,
|
||||
publicKey: bytesToHex(pubkey33),
|
||||
message,
|
||||
signature: bytesToHex(sig),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Mint a self-consistent secp256k1-xrpl proof: key → r-address → SIWx → DER sig. */
|
||||
function mintSecp256k1(now = 1_700_000_000_000): Minted {
|
||||
// Reject keys whose compressed form is not the usual length (defensive).
|
||||
const priv = secp256k1.utils.randomPrivateKey();
|
||||
const pubkey33 = secp256k1.getPublicKey(priv, true); // compressed: 0x02/0x03 || 32
|
||||
const address = rAddressFromPubkey(pubkey33);
|
||||
|
||||
const challenge = newChallenge({ domain: 'hanzo.id', uri: 'https://hanzo.id/login', now });
|
||||
const message = buildSiwxMessage({ challenge, address, chain: 'xrp' });
|
||||
|
||||
// secp256k1-xrpl signs the sha512half of the message, DER-encoded.
|
||||
const digest = sha512Half(utf8ToBytes(message));
|
||||
const sig = secp256k1.sign(digest, priv, { prehash: false, lowS: true });
|
||||
const der = bytesToHex(sig.toBytes('der'));
|
||||
|
||||
return {
|
||||
address,
|
||||
proof: {
|
||||
chain: 'xrp',
|
||||
scheme: 'secp256k1-xrpl',
|
||||
address,
|
||||
publicKey: bytesToHex(pubkey33),
|
||||
message,
|
||||
signature: der,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('XRPL base58check (known-answer vector)', () => {
|
||||
it('encodes the canonical xrpl.org AccountID example', () => {
|
||||
// From https://xrpl.org/addresses.html (Address Encoding worked example):
|
||||
// AccountID = BA8E78626EE42C41B46D46C3048DF3A1C3C87072
|
||||
// r-address = rJrRMgiRgrU6hDF4pgu5DXQdWyPbY35ErN
|
||||
const accountId = Uint8Array.from(
|
||||
'BA8E78626EE42C41B46D46C3048DF3A1C3C87072'.match(/../g)!.map((h) => parseInt(h, 16)),
|
||||
);
|
||||
const addr = base58CheckXrpl(concatBytes(Uint8Array.of(0x00), accountId));
|
||||
expect(addr).toBe('rJrRMgiRgrU6hDF4pgu5DXQdWyPbY35ErN');
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyXrp — ed25519-xrpl', () => {
|
||||
it('accepts a valid proof (full round-trip)', () => {
|
||||
const { proof } = mintEd25519();
|
||||
expect(verifyXrp(proof)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a tampered message (signature no longer matches)', () => {
|
||||
const { proof } = mintEd25519();
|
||||
const bad: SignedProof = { ...proof, message: proof.message + ' ' };
|
||||
expect(verifyXrp(bad)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a flipped signature bit', () => {
|
||||
const { proof } = mintEd25519();
|
||||
const sig = Uint8Array.from(proof.signature.match(/../g)!.map((h) => parseInt(h, 16)));
|
||||
sig[0] = (sig[0]! ^ 0x01) & 0xff;
|
||||
expect(verifyXrp({ ...proof, signature: bytesToHex(sig) })).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a wrong address (binding failure, key/sig still valid)', () => {
|
||||
const { proof } = mintEd25519();
|
||||
const other = mintEd25519();
|
||||
expect(verifyXrp({ ...proof, address: other.address })).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a mismatched public key', () => {
|
||||
const { proof } = mintEd25519();
|
||||
const other = mintEd25519();
|
||||
// Valid 0xED-prefixed key but not the signer → sig verify fails.
|
||||
expect(verifyXrp({ ...proof, publicKey: other.proof.publicKey })).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a missing 0xED prefix on the public key', () => {
|
||||
const { proof } = mintEd25519();
|
||||
const bytes = Uint8Array.from(proof.publicKey!.match(/../g)!.map((h) => parseInt(h, 16)));
|
||||
bytes[0] = 0xee; // wrong family tag
|
||||
expect(verifyXrp({ ...proof, publicKey: bytesToHex(bytes) })).toBe(false);
|
||||
});
|
||||
|
||||
it('fails closed on a missing public key', () => {
|
||||
const { proof } = mintEd25519();
|
||||
expect(verifyXrp({ ...proof, publicKey: undefined })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyXrp — secp256k1-xrpl', () => {
|
||||
it('accepts a valid proof (full round-trip)', () => {
|
||||
const { proof } = mintSecp256k1();
|
||||
expect(verifyXrp(proof)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a tampered message (signature no longer matches)', () => {
|
||||
const { proof } = mintSecp256k1();
|
||||
const bad: SignedProof = { ...proof, message: proof.message + ' ' };
|
||||
expect(verifyXrp(bad)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a corrupted DER signature', () => {
|
||||
const { proof } = mintSecp256k1();
|
||||
const der = Uint8Array.from(proof.signature.match(/../g)!.map((h) => parseInt(h, 16)));
|
||||
const last = der.length - 1;
|
||||
der[last] = (der[last]! ^ 0x01) & 0xff; // mangle last byte of s
|
||||
expect(verifyXrp({ ...proof, signature: bytesToHex(der) })).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a wrong address (binding failure, key/sig still valid)', () => {
|
||||
const { proof } = mintSecp256k1();
|
||||
const other = mintSecp256k1();
|
||||
expect(verifyXrp({ ...proof, address: other.address })).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a mismatched public key', () => {
|
||||
const { proof } = mintSecp256k1();
|
||||
const other = mintSecp256k1();
|
||||
expect(verifyXrp({ ...proof, publicKey: other.proof.publicKey })).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an ed25519-tagged key under the secp256k1 scheme', () => {
|
||||
const { proof } = mintSecp256k1();
|
||||
const bytes = Uint8Array.from(proof.publicKey!.match(/../g)!.map((h) => parseInt(h, 16)));
|
||||
bytes[0] = 0xed; // not a valid compressed-point tag
|
||||
expect(verifyXrp({ ...proof, publicKey: bytesToHex(bytes) })).toBe(false);
|
||||
});
|
||||
|
||||
it('fails closed on a missing public key', () => {
|
||||
const { proof } = mintSecp256k1();
|
||||
expect(verifyXrp({ ...proof, publicKey: undefined })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyXrp — fail-closed hardening', () => {
|
||||
it('rejects an unknown scheme via this verifier', () => {
|
||||
const { proof } = mintEd25519();
|
||||
expect(verifyXrp({ ...proof, scheme: 'ed25519' as SignedProof['scheme'] })).toBe(false);
|
||||
});
|
||||
|
||||
it('does not throw on garbage input', () => {
|
||||
const garbage = {
|
||||
chain: 'xrp',
|
||||
scheme: 'ed25519-xrpl',
|
||||
address: 'x',
|
||||
publicKey: 'nothex',
|
||||
message: 'not a siwx message',
|
||||
signature: '!!!!',
|
||||
} as unknown as SignedProof;
|
||||
expect(() => verifyXrp(garbage)).not.toThrow();
|
||||
expect(verifyXrp(garbage)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a wrong-length public key', () => {
|
||||
const { proof } = mintEd25519();
|
||||
expect(verifyXrp({ ...proof, publicKey: bytesToHex(new Uint8Array(31)) })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
/**
|
||||
* Base58Check (Bitcoin alphabet) — inline, no deps. Encode-only: we build a
|
||||
* P2PKH address from a recovered pubkey-hash and compare it byte-for-byte
|
||||
* against `proof.address`. Checksum = first 4 bytes of sha256d(payload).
|
||||
*/
|
||||
import { sha256 } from '@noble/hashes/sha2';
|
||||
import { concatBytes } from '../bytes.js';
|
||||
|
||||
const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
||||
|
||||
/** Plain base58 (big-endian) encode. */
|
||||
function base58encode(bytes: Uint8Array): string {
|
||||
// Count leading zero bytes → leading '1's.
|
||||
let zeros = 0;
|
||||
while (zeros < bytes.length && bytes[zeros] === 0) zeros++;
|
||||
|
||||
// Convert base-256 → base-58 via repeated division on a digit buffer.
|
||||
const digits: number[] = [0];
|
||||
for (let i = zeros; i < bytes.length; i++) {
|
||||
let carry = bytes[i]!;
|
||||
for (let j = 0; j < digits.length; j++) {
|
||||
carry += digits[j]! << 8;
|
||||
digits[j] = carry % 58;
|
||||
carry = (carry / 58) | 0;
|
||||
}
|
||||
while (carry > 0) {
|
||||
digits.push(carry % 58);
|
||||
carry = (carry / 58) | 0;
|
||||
}
|
||||
}
|
||||
|
||||
let out = '';
|
||||
for (let i = 0; i < zeros; i++) out += '1';
|
||||
for (let i = digits.length - 1; i >= 0; i--) out += ALPHABET[digits[i]!];
|
||||
return out;
|
||||
}
|
||||
|
||||
/** version-byte || payload, append sha256d checksum, base58-encode. */
|
||||
export function base58checkEncode(version: number, payload: Uint8Array): string {
|
||||
const data = concatBytes(new Uint8Array([version & 0xff]), payload);
|
||||
const checksum = sha256(sha256(data)).slice(0, 4);
|
||||
return base58encode(concatBytes(data, checksum));
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
/**
|
||||
* bech32 (BIP-173) and bech32m (BIP-350) segwit address encoding — inline, no
|
||||
* deps. Encode-only: we derive an address from a recovered pubkey and compare
|
||||
* it against the claimed `proof.address` string, so we never need to decode.
|
||||
*
|
||||
* A SegWit v0 address (P2WPKH) uses the bech32 constant; v1+ (P2TR) uses
|
||||
* bech32m. The two differ only in the final XOR constant of the checksum —
|
||||
* the source of the 2017-era "bech32 is malleable for v1" fix.
|
||||
*/
|
||||
|
||||
const CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
|
||||
|
||||
const BECH32_CONST = 1;
|
||||
const BECH32M_CONST = 0x2bc830a3;
|
||||
|
||||
function polymod(values: number[]): number {
|
||||
const GEN = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3];
|
||||
let chk = 1;
|
||||
for (const v of values) {
|
||||
const top = chk >>> 25;
|
||||
chk = ((chk & 0x1ffffff) << 5) ^ v;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if ((top >>> i) & 1) chk ^= GEN[i]!;
|
||||
}
|
||||
}
|
||||
return chk >>> 0;
|
||||
}
|
||||
|
||||
function hrpExpand(hrp: string): number[] {
|
||||
const out: number[] = [];
|
||||
for (let i = 0; i < hrp.length; i++) out.push(hrp.charCodeAt(i) >>> 5);
|
||||
out.push(0);
|
||||
for (let i = 0; i < hrp.length; i++) out.push(hrp.charCodeAt(i) & 31);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Convert a byte array (8-bit) to 5-bit groups (frombits=8, tobits=5, pad=true). */
|
||||
function convert8to5(data: Uint8Array): number[] | null {
|
||||
let acc = 0;
|
||||
let bits = 0;
|
||||
const out: number[] = [];
|
||||
const maxv = 31;
|
||||
for (const value of data) {
|
||||
if (value < 0 || value >> 8 !== 0) return null;
|
||||
acc = ((acc << 8) | value) & 0xffffffff;
|
||||
bits += 8;
|
||||
while (bits >= 5) {
|
||||
bits -= 5;
|
||||
out.push((acc >>> bits) & maxv);
|
||||
}
|
||||
}
|
||||
if (bits > 0) out.push((acc << (5 - bits)) & maxv);
|
||||
return out;
|
||||
}
|
||||
|
||||
function createChecksum(hrp: string, data5: number[], constant: number): number[] {
|
||||
const values = hrpExpand(hrp).concat(data5);
|
||||
const mod = polymod(values.concat([0, 0, 0, 0, 0, 0])) ^ constant;
|
||||
const out: number[] = [];
|
||||
for (let i = 0; i < 6; i++) out.push((mod >>> (5 * (5 - i))) & 31);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a SegWit address. witver 0 → bech32 (P2WPKH); witver 1 → bech32m
|
||||
* (P2TR). Returns null on any invalid input (fail closed; never throws).
|
||||
*/
|
||||
export function encodeSegwitAddress(
|
||||
hrp: string,
|
||||
witver: number,
|
||||
program: Uint8Array,
|
||||
): string | null {
|
||||
if (witver < 0 || witver > 16) return null;
|
||||
// BIP-141 program length bounds: 2..40 bytes; v0 must be 20 or 32.
|
||||
if (program.length < 2 || program.length > 40) return null;
|
||||
if (witver === 0 && program.length !== 20 && program.length !== 32) return null;
|
||||
|
||||
const data5 = convert8to5(program);
|
||||
if (data5 === null) return null;
|
||||
const payload = [witver, ...data5];
|
||||
const constant = witver === 0 ? BECH32_CONST : BECH32M_CONST;
|
||||
const checksum = createChecksum(hrp, payload, constant);
|
||||
const combined = payload.concat(checksum);
|
||||
|
||||
let out = hrp + '1';
|
||||
for (const d of combined) {
|
||||
if (d < 0 || d > 31) return null;
|
||||
out += CHARSET[d];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
/**
|
||||
* Bitcoin wallet connector — message signing via `sats-connect` (Xverse,
|
||||
* Leather, Unisat, and any wallet implementing the sats-connect provider RPC).
|
||||
*
|
||||
* Connect requests the wallet's addresses and prefers a P2WPKH ('bc1q…')
|
||||
* payment address (the broadest-compatibility key-path login form). Signing
|
||||
* uses `signMessage` with the BIP-322 protocol, which returns a base64
|
||||
* signature — a serialized witness stack for segwit/taproot addresses, or a
|
||||
* recoverable ECDSA sig for legacy. {@link verifyBitcoin} dispatches on that
|
||||
* shape, so the proof here carries the address *type* in `extra.addressType`
|
||||
* and scheme `bip322`.
|
||||
*/
|
||||
import Wallet, {
|
||||
AddressPurpose,
|
||||
MessageSigningProtocols,
|
||||
type Address,
|
||||
} from 'sats-connect';
|
||||
import type {
|
||||
Account,
|
||||
LoginChallenge,
|
||||
SignedProof,
|
||||
WalletConnector,
|
||||
WalletInfo,
|
||||
} from '../types.js';
|
||||
import { buildSiwxMessage } from '../caip122.js';
|
||||
|
||||
/** sats-connect addressType → the hint string {@link verifyBitcoin} expects. */
|
||||
type BtcAddressTypeHint = 'p2pkh' | 'p2wpkh' | 'p2tr';
|
||||
|
||||
function toAddressTypeHint(addressType: string): BtcAddressTypeHint | null {
|
||||
if (addressType === 'p2pkh' || addressType === 'p2wpkh' || addressType === 'p2tr') {
|
||||
return addressType;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose the address to sign with. Order of preference:
|
||||
* 1. P2WPKH payment ('bc1q…') — widest wallet + verifier support
|
||||
* 2. P2TR payment/ordinals — taproot key-path
|
||||
* 3. P2PKH — legacy
|
||||
* Returns the chosen entry plus its verifier address-type hint.
|
||||
*/
|
||||
function chooseAddress(addresses: readonly Address[]): { addr: Address; hint: BtcAddressTypeHint } | null {
|
||||
const ranked: BtcAddressTypeHint[] = ['p2wpkh', 'p2tr', 'p2pkh'];
|
||||
for (const want of ranked) {
|
||||
const found = addresses.find((a) => toAddressTypeHint(a.addressType) === want);
|
||||
if (found) return { addr: found, hint: want };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export class BitcoinConnector implements WalletConnector {
|
||||
readonly chain = 'bitcoin' as const;
|
||||
|
||||
#address: string | null = null;
|
||||
#addressType: BtcAddressTypeHint | null = null;
|
||||
#walletId = 'sats-connect';
|
||||
|
||||
/**
|
||||
* sats-connect resolves the concrete wallet at request time (it shows its own
|
||||
* provider picker), so discovery here advertises the aggregate provider.
|
||||
*/
|
||||
async available(): Promise<WalletInfo[]> {
|
||||
if (typeof window === 'undefined') return [];
|
||||
return [
|
||||
{
|
||||
id: 'sats-connect',
|
||||
name: 'Bitcoin Wallet (Xverse / Leather / Unisat)',
|
||||
chain: this.chain,
|
||||
installed: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect and pick a signing address. `walletId` is forwarded to sats-connect
|
||||
* as the provider id when given; otherwise its built-in picker is used.
|
||||
*/
|
||||
async connect(walletId?: string): Promise<Account> {
|
||||
if (typeof window === 'undefined') {
|
||||
throw new Error('bitcoin: no window — connectors are browser-only');
|
||||
}
|
||||
if (walletId != null) this.#walletId = walletId;
|
||||
|
||||
const res = await Wallet.request('getAddresses', {
|
||||
purposes: [AddressPurpose.Payment, AddressPurpose.Ordinals],
|
||||
message: 'Connect to sign in',
|
||||
});
|
||||
if (res.status !== 'success') {
|
||||
throw new Error(`bitcoin: getAddresses failed (${res.error?.message ?? 'rejected'})`);
|
||||
}
|
||||
|
||||
const chosen = chooseAddress(res.result.addresses);
|
||||
if (!chosen) throw new Error('bitcoin: wallet returned no usable address');
|
||||
|
||||
this.#address = chosen.addr.address;
|
||||
this.#addressType = chosen.hint;
|
||||
|
||||
return {
|
||||
chain: this.chain,
|
||||
address: chosen.addr.address,
|
||||
publicKey: chosen.addr.publicKey,
|
||||
walletId: this.#walletId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the CAIP-122 message and have the wallet sign it (BIP-322).
|
||||
* Produces a `bip322` proof carrying `extra.addressType` so the verifier
|
||||
* derives the correct address form.
|
||||
*/
|
||||
async signLogin(account: Account, challenge: LoginChallenge): Promise<SignedProof> {
|
||||
if (!this.#address || !this.#addressType) {
|
||||
throw new Error('bitcoin: not connected — call connect() first');
|
||||
}
|
||||
const message = buildSiwxMessage({
|
||||
challenge,
|
||||
address: account.address,
|
||||
chain: this.chain,
|
||||
});
|
||||
|
||||
const res = await Wallet.request('signMessage', {
|
||||
address: account.address,
|
||||
message,
|
||||
protocol: MessageSigningProtocols.BIP322,
|
||||
});
|
||||
if (res.status !== 'success') {
|
||||
throw new Error(`bitcoin: signMessage failed (${res.error?.message ?? 'rejected'})`);
|
||||
}
|
||||
|
||||
return {
|
||||
chain: this.chain,
|
||||
scheme: 'bip322',
|
||||
address: account.address,
|
||||
message,
|
||||
signature: res.result.signature, // base64
|
||||
extra: { addressType: this.#addressType },
|
||||
};
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
try {
|
||||
await Wallet.disconnect();
|
||||
} catch {
|
||||
// sats-connect throws if no session; ignore on teardown.
|
||||
} finally {
|
||||
this.#address = null;
|
||||
this.#addressType = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,555 +0,0 @@
|
||||
/**
|
||||
* Bitcoin login-signature verifier.
|
||||
*
|
||||
* Two signing conventions are supported, both verifying a CAIP-122 message
|
||||
* against a BTC address (P2PKH '1…', P2WPKH 'bc1q…', P2TR 'bc1p…'):
|
||||
*
|
||||
* 1. Legacy "Bitcoin Signed Message" — recoverable ECDSA over the
|
||||
* double-SHA256 of the magic-prefixed message. Primary path; works for
|
||||
* every address type (the wallet picks the key form via the header byte).
|
||||
*
|
||||
* 2. BIP-322 "simple" — a virtual to_spend/to_sign transaction pair whose
|
||||
* witness is verified with BIP-143 (P2WPKH, ECDSA) or BIP-341 (P2TR,
|
||||
* Schnorr) sighash. Used when the signature is a serialized witness stack
|
||||
* rather than a 65-byte recoverable sig.
|
||||
*
|
||||
* Security posture: fail closed. Every parse/branch returns `false` on the
|
||||
* slightest irregularity and the whole function is wrapped so it never throws.
|
||||
* The recovered/derived address must match the *type* claimed by the proof and
|
||||
* be byte-for-byte equal to `proof.address`.
|
||||
*/
|
||||
import { secp256k1, schnorr } from '@noble/curves/secp256k1';
|
||||
import { sha256 } from '@noble/hashes/sha2';
|
||||
import { ripemd160 } from '@noble/hashes/legacy';
|
||||
import type { SignedProof } from '../types.js';
|
||||
import { base64ToBytes, utf8ToBytes, concatBytes } from '../bytes.js';
|
||||
import { encodeSegwitAddress } from './bech32.js';
|
||||
import { base58checkEncode } from './base58check.js';
|
||||
|
||||
// ── address type ──────────────────────────────────────────────────────────
|
||||
|
||||
type BtcAddressType = 'p2pkh' | 'p2wpkh' | 'p2tr';
|
||||
|
||||
/** Bitcoin mainnet bech32 human-readable part. */
|
||||
const HRP = 'bc';
|
||||
|
||||
/** Determine the address type from an explicit hint, else from the prefix. */
|
||||
function addressType(proof: SignedProof): BtcAddressType | null {
|
||||
const hint = (proof.extra?.addressType as string | undefined)?.toLowerCase();
|
||||
if (hint === 'p2pkh' || hint === 'p2wpkh' || hint === 'p2tr') return hint;
|
||||
|
||||
const a = proof.address;
|
||||
if (a.startsWith('bc1p')) return 'p2tr';
|
||||
if (a.startsWith('bc1q')) return 'p2wpkh';
|
||||
if (a.startsWith('1')) return 'p2pkh';
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── hashing helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
const sha256d = (b: Uint8Array): Uint8Array => sha256(sha256(b));
|
||||
const hash160 = (b: Uint8Array): Uint8Array => ripemd160(sha256(b));
|
||||
|
||||
/** BIP-340 tagged hash: sha256(sha256(tag) || sha256(tag) || msg…). */
|
||||
function taggedHash(tag: string, ...messages: Uint8Array[]): Uint8Array {
|
||||
const tagHash = sha256(utf8ToBytes(tag));
|
||||
return sha256(concatBytes(tagHash, tagHash, ...messages));
|
||||
}
|
||||
|
||||
// ── address derivation (pubkey → address string) ────────────────────────────
|
||||
|
||||
function deriveP2PKH(pubkey: Uint8Array): string {
|
||||
// base58check( 0x00 || hash160(pubkey) )
|
||||
return base58checkEncode(0x00, hash160(pubkey));
|
||||
}
|
||||
|
||||
function deriveP2WPKH(pubkeyCompressed: Uint8Array): string | null {
|
||||
// bech32(hrp='bc', witver=0, program=hash160(compressed pubkey))
|
||||
return encodeSegwitAddress(HRP, 0, hash160(pubkeyCompressed));
|
||||
}
|
||||
|
||||
/** x-only (32-byte) coordinate of a point given as its affine x bigint. */
|
||||
function xonlyFromBigInt(x: bigint): Uint8Array {
|
||||
const out = new Uint8Array(32);
|
||||
let v = x;
|
||||
for (let i = 31; i >= 0; i--) {
|
||||
out[i] = Number(v & 0xffn);
|
||||
v >>= 8n;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* BIP-86 key-path taproot output for an internal pubkey.
|
||||
* t = taggedHash('TapTweak', xonly(P))
|
||||
* Q = lift_x(xonly(P)) + t*G // internal key is the even-Y lift
|
||||
* program = xonly(Q)
|
||||
* Returns the 32-byte tweaked x-only program, or null on any failure.
|
||||
*/
|
||||
function taprootTweak(internalXonly: Uint8Array): Uint8Array | null {
|
||||
try {
|
||||
const Point = secp256k1.Point;
|
||||
const n = secp256k1.CURVE.n;
|
||||
const x = bytesToBigInt(internalXonly);
|
||||
const P = schnorr.utils.lift_x(x); // even-Y point with this x
|
||||
const t = bytesToBigInt(taggedHash('TapTweak', internalXonly)) % n;
|
||||
if (t === 0n) return null;
|
||||
const Q = P.add(Point.BASE.multiply(t));
|
||||
const qx = Q.toAffine().x;
|
||||
return xonlyFromBigInt(qx);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function deriveP2TR(internalXonly: Uint8Array): string | null {
|
||||
const program = taprootTweak(internalXonly);
|
||||
if (program === null) return null;
|
||||
return encodeSegwitAddress(HRP, 1, program);
|
||||
}
|
||||
|
||||
function bytesToBigInt(b: Uint8Array): bigint {
|
||||
let v = 0n;
|
||||
for (const byte of b) v = (v << 8n) | BigInt(byte);
|
||||
return v;
|
||||
}
|
||||
|
||||
// ── Bitcoin var-int / serialization (CompactSize) ───────────────────────────
|
||||
|
||||
function compactSize(n: number): Uint8Array {
|
||||
if (n < 0) throw new Error('negative compactSize');
|
||||
if (n < 0xfd) return new Uint8Array([n]);
|
||||
if (n <= 0xffff) return new Uint8Array([0xfd, n & 0xff, (n >>> 8) & 0xff]);
|
||||
if (n <= 0xffffffff) {
|
||||
return new Uint8Array([0xfe, n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, (n >>> 24) & 0xff]);
|
||||
}
|
||||
// 64-bit lengths are never needed for login messages.
|
||||
const out = new Uint8Array(9);
|
||||
out[0] = 0xff;
|
||||
let v = BigInt(n);
|
||||
for (let i = 1; i <= 8; i++) {
|
||||
out[i] = Number(v & 0xffn);
|
||||
v >>= 8n;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function u32le(n: number): Uint8Array {
|
||||
return new Uint8Array([n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, (n >>> 24) & 0xff]);
|
||||
}
|
||||
|
||||
function u64le(n: bigint): Uint8Array {
|
||||
const out = new Uint8Array(8);
|
||||
let v = n;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
out[i] = Number(v & 0xffn);
|
||||
v >>= 8n;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** length-prefixed (CompactSize) byte string. */
|
||||
function varBytes(b: Uint8Array): Uint8Array {
|
||||
return concatBytes(compactSize(b.length), b);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// 1. LEGACY "Bitcoin Signed Message" (recoverable ECDSA)
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const MSG_MAGIC = utf8ToBytes('\x18Bitcoin Signed Message:\n');
|
||||
|
||||
/** digest = sha256d( magic || varint(len) || message ). */
|
||||
function legacyMessageDigest(message: string): Uint8Array {
|
||||
const msg = utf8ToBytes(message);
|
||||
return sha256d(concatBytes(MSG_MAGIC, compactSize(msg.length), msg));
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a 65-byte recoverable signature [header || r || s] over the legacy
|
||||
* message digest, deriving the address of `type` and comparing to `address`.
|
||||
*/
|
||||
function verifyLegacy(
|
||||
sig: Uint8Array,
|
||||
message: string,
|
||||
address: string,
|
||||
type: BtcAddressType,
|
||||
): boolean {
|
||||
if (sig.length !== 65) return false;
|
||||
const header = sig[0]!;
|
||||
// 27-30: uncompressed key; 31-34: compressed key. (BIP-137 also defines
|
||||
// 35-42 for segwit, but the recovered key form is what matters here, so we
|
||||
// accept the canonical 27-34 range and infer compression from it.)
|
||||
if (header < 27 || header > 34) return false;
|
||||
const recid = (header - 27) & 3;
|
||||
const compressed = header >= 31;
|
||||
|
||||
const r = sig.slice(1, 33);
|
||||
const s = sig.slice(33, 65);
|
||||
const digest = legacyMessageDigest(message);
|
||||
|
||||
let point;
|
||||
try {
|
||||
point = secp256k1.Signature.fromCompact(concatBytes(r, s))
|
||||
.addRecoveryBit(recid)
|
||||
.recoverPublicKey(digest);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
// For segwit address types the key MUST be compressed — an uncompressed key
|
||||
// cannot back a P2WPKH/P2TR script, so reject rather than silently coercing.
|
||||
if ((type === 'p2wpkh' || type === 'p2tr') && !compressed) return false;
|
||||
|
||||
let derived: string | null;
|
||||
switch (type) {
|
||||
case 'p2pkh': {
|
||||
// P2PKH commits to the exact key encoding chosen by the header byte.
|
||||
const pub = point.toBytes(compressed);
|
||||
derived = deriveP2PKH(pub);
|
||||
break;
|
||||
}
|
||||
case 'p2wpkh': {
|
||||
derived = deriveP2WPKH(point.toBytes(true));
|
||||
break;
|
||||
}
|
||||
case 'p2tr': {
|
||||
// Internal key = x-only of the recovered (compressed) key.
|
||||
const xonly = point.toBytes(true).slice(1);
|
||||
derived = deriveP2TR(xonly);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return derived !== null && constTimeStrEq(derived, address);
|
||||
}
|
||||
|
||||
/** Length-checked, content-comparing string equality (addresses are public). */
|
||||
function constTimeStrEq(a: string, b: string): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// 2. BIP-322 "simple"
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/** Parse a serialized witness stack: count, then length-prefixed elements. */
|
||||
function parseWitness(buf: Uint8Array): Uint8Array[] | null {
|
||||
let off = 0;
|
||||
const readCompact = (): number | null => {
|
||||
if (off >= buf.length) return null;
|
||||
const first = buf[off++]!;
|
||||
if (first < 0xfd) return first;
|
||||
if (first === 0xfd) {
|
||||
if (off + 2 > buf.length) return null;
|
||||
const v = buf[off]! | (buf[off + 1]! << 8);
|
||||
off += 2;
|
||||
return v;
|
||||
}
|
||||
if (first === 0xfe) {
|
||||
if (off + 4 > buf.length) return null;
|
||||
const v = buf[off]! | (buf[off + 1]! << 8) | (buf[off + 2]! << 16) | (buf[off + 3]! * 0x1000000);
|
||||
off += 4;
|
||||
return v;
|
||||
}
|
||||
return null; // 64-bit lengths never appear in witness logins
|
||||
};
|
||||
|
||||
const count = readCompact();
|
||||
if (count === null || count < 1 || count > 4) return null;
|
||||
const items: Uint8Array[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const len = readCompact();
|
||||
if (len === null || len < 0 || off + len > buf.length) return null;
|
||||
items.push(buf.slice(off, off + len));
|
||||
off += len;
|
||||
}
|
||||
if (off !== buf.length) return null; // no trailing garbage
|
||||
return items;
|
||||
}
|
||||
|
||||
/** scriptPubKey bytes for each supported address type given its program. */
|
||||
function scriptPubKeyP2WPKH(hash160Pub: Uint8Array): Uint8Array {
|
||||
// OP_0 PUSH20 <hash160>
|
||||
return concatBytes(new Uint8Array([0x00, 0x14]), hash160Pub);
|
||||
}
|
||||
function scriptPubKeyP2TR(programXonly: Uint8Array): Uint8Array {
|
||||
// OP_1 PUSH32 <tweaked xonly>
|
||||
return concatBytes(new Uint8Array([0x51, 0x20]), programXonly);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the BIP-322 to_spend txid.
|
||||
* to_spend: nVersion=0, vin=[ {prevout=0..00:0xFFFFFFFF,
|
||||
* scriptSig=OP_0 PUSH32 <msgHash>, nSequence=0} ],
|
||||
* vout=[ {value=0, scriptPubKey} ], nLockTime=0
|
||||
* txid = sha256d(serialization without witness).
|
||||
*/
|
||||
function toSpendTxid(message: string, scriptPubKey: Uint8Array): Uint8Array {
|
||||
const msgHash = taggedHash('BIP0322-signed-message', utf8ToBytes(message));
|
||||
const scriptSig = concatBytes(new Uint8Array([0x00, 0x20]), msgHash); // OP_0 PUSH32
|
||||
|
||||
const ser = concatBytes(
|
||||
u32le(0), // nVersion = 0
|
||||
compactSize(1), // vin count
|
||||
new Uint8Array(32), // prevout hash = 0
|
||||
u32le(0xffffffff), // prevout index = 0xFFFFFFFF
|
||||
varBytes(scriptSig),
|
||||
u32le(0), // nSequence = 0
|
||||
compactSize(1), // vout count
|
||||
u64le(0n), // value = 0
|
||||
varBytes(scriptPubKey),
|
||||
u32le(0), // nLockTime = 0
|
||||
);
|
||||
return sha256d(ser);
|
||||
}
|
||||
|
||||
/**
|
||||
* BIP-143 sighash for the single input of the BIP-322 to_sign tx (P2WPKH).
|
||||
* SIGHASH_ALL. scriptCode for P2WPKH = OP_DUP OP_HASH160 PUSH20 <h160>
|
||||
* OP_EQUALVERIFY OP_CHECKSIG.
|
||||
*/
|
||||
function bip143SighashP2WPKH(toSpendTxid: Uint8Array, hash160Pub: Uint8Array): Uint8Array {
|
||||
const outpoint = concatBytes(toSpendTxid, u32le(0)); // to_spend:0
|
||||
const nSequence = u32le(0);
|
||||
const hashPrevouts = sha256d(outpoint);
|
||||
const hashSequence = sha256d(nSequence);
|
||||
|
||||
const scriptCode = concatBytes(
|
||||
new Uint8Array([0x19, 0x76, 0xa9, 0x14]), // len(25) OP_DUP OP_HASH160 PUSH20
|
||||
hash160Pub,
|
||||
new Uint8Array([0x88, 0xac]), // OP_EQUALVERIFY OP_CHECKSIG
|
||||
);
|
||||
|
||||
// to_sign single output: value=0, scriptPubKey = OP_RETURN (0x6a).
|
||||
const output = concatBytes(u64le(0n), varBytes(new Uint8Array([0x6a])));
|
||||
const hashOutputs = sha256d(output);
|
||||
|
||||
const preimage = concatBytes(
|
||||
u32le(0), // nVersion = 0
|
||||
hashPrevouts,
|
||||
hashSequence,
|
||||
outpoint,
|
||||
scriptCode,
|
||||
u64le(0n), // amount of the spent output = 0
|
||||
nSequence,
|
||||
hashOutputs,
|
||||
u32le(0), // nLockTime = 0
|
||||
u32le(1), // SIGHASH_ALL
|
||||
);
|
||||
return sha256d(preimage);
|
||||
}
|
||||
|
||||
/**
|
||||
* BIP-341 (taproot key-path) sighash for the single input of the to_sign tx,
|
||||
* SIGHASH_DEFAULT (0x00). Single P2TR input, single OP_RETURN output.
|
||||
*/
|
||||
function bip341SighashP2TR(toSpendTxid: Uint8Array, scriptPubKey: Uint8Array): Uint8Array {
|
||||
const outpoint = concatBytes(toSpendTxid, u32le(0));
|
||||
const nSequence = u32le(0);
|
||||
|
||||
const shaPrevouts = sha256(outpoint);
|
||||
const shaAmounts = sha256(u64le(0n)); // single spent amount = 0
|
||||
const shaScriptPubkeys = sha256(varBytes(scriptPubKey));
|
||||
const shaSequences = sha256(nSequence);
|
||||
const output = concatBytes(u64le(0n), varBytes(new Uint8Array([0x6a]))); // value=0, OP_RETURN
|
||||
const shaOutputs = sha256(output);
|
||||
|
||||
const epoch = new Uint8Array([0x00]);
|
||||
const hashType = new Uint8Array([0x00]); // SIGHASH_DEFAULT
|
||||
const spendType = new Uint8Array([0x00]); // no annex, key-path
|
||||
const inputIndex = u32le(0);
|
||||
|
||||
const sigMsg = concatBytes(
|
||||
hashType,
|
||||
u32le(0), // nVersion = 0
|
||||
u32le(0), // nLockTime = 0
|
||||
shaPrevouts,
|
||||
shaAmounts,
|
||||
shaScriptPubkeys,
|
||||
shaSequences,
|
||||
shaOutputs,
|
||||
spendType,
|
||||
inputIndex,
|
||||
);
|
||||
// BIP-341: tagged hash "TapSighash" over (epoch || sigMsg).
|
||||
return taggedHash('TapSighash', concatBytes(epoch, sigMsg));
|
||||
}
|
||||
|
||||
/** Strip a trailing SIGHASH byte from a DER ECDSA signature, returning (der, sighash). */
|
||||
function splitDerSighash(witnessSig: Uint8Array): { der: Uint8Array; sighash: number } | null {
|
||||
if (witnessSig.length < 1) return null;
|
||||
const sighash = witnessSig[witnessSig.length - 1]!;
|
||||
return { der: witnessSig.slice(0, witnessSig.length - 1), sighash };
|
||||
}
|
||||
|
||||
/** Parse a 64- or 65-byte BIP-340 schnorr sig (optional trailing sighash). */
|
||||
function splitSchnorrSighash(witnessSig: Uint8Array): { sig: Uint8Array; sighash: number } | null {
|
||||
if (witnessSig.length === 64) return { sig: witnessSig, sighash: 0x00 };
|
||||
if (witnessSig.length === 65) {
|
||||
const sighash = witnessSig[64]!;
|
||||
return { sig: witnessSig.slice(0, 64), sighash };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function verifyBip322P2WPKH(
|
||||
witness: Uint8Array[],
|
||||
message: string,
|
||||
address: string,
|
||||
): boolean {
|
||||
// Witness stack for P2WPKH is exactly [signature, pubkey].
|
||||
if (witness.length !== 2) return false;
|
||||
const [sigBytes, pubkey] = witness as [Uint8Array, Uint8Array];
|
||||
if (pubkey.length !== 33 || (pubkey[0] !== 0x02 && pubkey[0] !== 0x03)) return false;
|
||||
|
||||
// Address binding: the witness pubkey must hash to the claimed P2WPKH address.
|
||||
const h160 = hash160(pubkey);
|
||||
const derived = deriveP2WPKH(pubkey);
|
||||
if (derived === null || !constTimeStrEq(derived, address)) return false;
|
||||
|
||||
const parsed = splitDerSighash(sigBytes);
|
||||
if (parsed === null) return false;
|
||||
// BIP-322 simple for single-key uses SIGHASH_ALL.
|
||||
if (parsed.sighash !== 0x01) return false;
|
||||
|
||||
const txid = toSpendTxid(message, scriptPubKeyP2WPKH(h160));
|
||||
const sighash = bip143SighashP2WPKH(txid, h160);
|
||||
|
||||
try {
|
||||
const sig = secp256k1.Signature.fromDER(parsed.der);
|
||||
// Reject high-S (BIP-146 / consensus-standardness, anti-malleability).
|
||||
if (sig.hasHighS()) return false;
|
||||
return secp256k1.verify(sig.toCompactRawBytes(), sighash, pubkey, { lowS: true });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function verifyBip322P2TR(
|
||||
witness: Uint8Array[],
|
||||
message: string,
|
||||
address: string,
|
||||
): boolean {
|
||||
// Key-path spend: witness is exactly [schnorr_sig].
|
||||
if (witness.length !== 1) return false;
|
||||
const parsed = splitSchnorrSighash(witness[0]!);
|
||||
if (parsed === null) return false;
|
||||
if (parsed.sighash !== 0x00) return false; // SIGHASH_DEFAULT only
|
||||
|
||||
// Recover the tweaked output key from the claimed address by re-deriving the
|
||||
// scriptPubKey from… the address itself: we must decode the program. Since we
|
||||
// only have the address string, derive the program by trusting the bech32m
|
||||
// body is the output key. We re-encode and compare, then verify schnorr
|
||||
// against that x-only output key.
|
||||
const program = decodeP2TRProgram(address);
|
||||
if (program === null) return false;
|
||||
|
||||
const txid = toSpendTxid(message, scriptPubKeyP2TR(program));
|
||||
const sighash = bip341SighashP2TR(txid, scriptPubKeyP2TR(program));
|
||||
|
||||
try {
|
||||
return schnorr.verify(parsed.sig, sighash, program);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a bech32m P2TR ('bc1p…') address to its 32-byte witness program.
|
||||
* Minimal decoder used only to recover the output key for schnorr verify; it
|
||||
* re-validates the checksum by re-encoding and comparing (fail closed).
|
||||
*/
|
||||
function decodeP2TRProgram(address: string): Uint8Array | null {
|
||||
const CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
|
||||
const lower = address.toLowerCase();
|
||||
if (lower !== address && address.toUpperCase() !== address) return null; // mixed case
|
||||
const pos = lower.lastIndexOf('1');
|
||||
if (pos < 1) return null;
|
||||
const hrp = lower.slice(0, pos);
|
||||
if (hrp !== HRP) return null;
|
||||
const dataPart = lower.slice(pos + 1);
|
||||
if (dataPart.length < 7) return null; // 1 (witver) + program + 6 checksum
|
||||
|
||||
const values: number[] = [];
|
||||
for (const ch of dataPart) {
|
||||
const v = CHARSET.indexOf(ch);
|
||||
if (v === -1) return null;
|
||||
values.push(v);
|
||||
}
|
||||
const witver = values[0]!;
|
||||
if (witver !== 1) return null; // only taproot here
|
||||
|
||||
// Convert 5-bit data (excluding witver and 6-byte checksum) → 8-bit program.
|
||||
const data5 = values.slice(1, values.length - 6);
|
||||
const program = convert5to8(data5);
|
||||
if (program === null || program.length !== 32) return null;
|
||||
|
||||
// Re-encode with bech32m and compare to validate the checksum.
|
||||
const reencoded = encodeSegwitAddress(HRP, 1, program);
|
||||
if (reencoded === null || reencoded !== lower) return null;
|
||||
return program;
|
||||
}
|
||||
|
||||
/** 5-bit groups → 8-bit bytes (frombits=5, tobits=8, pad=false). */
|
||||
function convert5to8(data: number[]): Uint8Array | null {
|
||||
let acc = 0;
|
||||
let bits = 0;
|
||||
const out: number[] = [];
|
||||
for (const value of data) {
|
||||
if (value < 0 || value >> 5 !== 0) return null;
|
||||
acc = ((acc << 5) | value) & 0xffffffff;
|
||||
bits += 5;
|
||||
while (bits >= 8) {
|
||||
bits -= 8;
|
||||
out.push((acc >>> bits) & 0xff);
|
||||
}
|
||||
}
|
||||
// Reject if leftover bits form a non-zero pad (strict, per BIP-173).
|
||||
if (bits >= 5) return null;
|
||||
if ((acc << (8 - bits)) & 0xff) return null;
|
||||
return Uint8Array.from(out);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// dispatch
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export function verifyBitcoin(proof: SignedProof): boolean {
|
||||
try {
|
||||
const type = addressType(proof);
|
||||
if (type === null) return false;
|
||||
|
||||
let sig: Uint8Array;
|
||||
try {
|
||||
sig = base64ToBytes(proof.signature);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (sig.length === 0) return false;
|
||||
|
||||
// Shape-based dispatch (exactly one path per shape):
|
||||
// • 65 bytes with a valid header → legacy recoverable ECDSA.
|
||||
// • otherwise → BIP-322 simple (serialized witness stack).
|
||||
const header = sig[0]!;
|
||||
const looksLegacy = sig.length === 65 && header >= 27 && header <= 34;
|
||||
|
||||
if (looksLegacy) {
|
||||
return verifyLegacy(sig, proof.message, proof.address, type);
|
||||
}
|
||||
|
||||
// BIP-322 simple — only P2WPKH and P2TR are defined for key-path here.
|
||||
const witness = parseWitness(sig);
|
||||
if (witness === null) return false;
|
||||
if (type === 'p2wpkh') return verifyBip322P2WPKH(witness, proof.message, proof.address);
|
||||
if (type === 'p2tr') return verifyBip322P2TR(witness, proof.message, proof.address);
|
||||
// BIP-322 for P2PKH is not standardized for "simple"; legacy covers it.
|
||||
return false;
|
||||
} catch {
|
||||
// Absolute backstop: never throw out of a verifier.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
/** Byte helpers shared by every verifier. Cross-runtime (Node + browser). */
|
||||
import { hexToBytes as nobleHexToBytes, bytesToHex, utf8ToBytes, concatBytes } from '@noble/hashes/utils';
|
||||
|
||||
export { bytesToHex, utf8ToBytes, concatBytes };
|
||||
|
||||
/** Hex → bytes, tolerant of a leading 0x. */
|
||||
export function hexToBytes(hex: string): Uint8Array {
|
||||
return nobleHexToBytes(hex.startsWith('0x') || hex.startsWith('0X') ? hex.slice(2) : hex);
|
||||
}
|
||||
|
||||
/** base64 (standard, with padding) → bytes. */
|
||||
export function base64ToBytes(b64: string): Uint8Array {
|
||||
if (typeof atob === 'function') {
|
||||
const bin = atob(b64);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
// Node
|
||||
return new Uint8Array(Buffer.from(b64, 'base64'));
|
||||
}
|
||||
|
||||
export function bytesToBase64(bytes: Uint8Array): string {
|
||||
if (typeof btoa === 'function') {
|
||||
let bin = '';
|
||||
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]!);
|
||||
return btoa(bin);
|
||||
}
|
||||
return Buffer.from(bytes).toString('base64');
|
||||
}
|
||||
|
||||
/** Decode a signature that may be hex (0x…) or base64 into raw bytes. */
|
||||
export function decodeSignature(sig: string): Uint8Array {
|
||||
const s = sig.trim();
|
||||
if (s.startsWith('0x') || s.startsWith('0X')) return hexToBytes(s);
|
||||
// Heuristic: pure hex (even length, [0-9a-f]) → hex, else base64.
|
||||
if (/^[0-9a-fA-F]+$/.test(s) && s.length % 2 === 0) return hexToBytes(s);
|
||||
return base64ToBytes(s);
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
/**
|
||||
* CAIP-122 "Sign-In-With-X" message — the one canonical login string for every
|
||||
* chain. Generalizes EIP-4361 (SIWE) so a Solana / Bitcoin / TON / XRP wallet
|
||||
* signs the exact same human-readable assertion an Ethereum wallet does.
|
||||
*
|
||||
* Spec: https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-122.md
|
||||
* (which itself generalizes https://eips.ethereum.org/EIPS/eip-4361)
|
||||
*
|
||||
* build ∘ parse round-trips. Both are pure — no I/O, no clock — so the Go port
|
||||
* for IAM can mirror them byte-for-byte.
|
||||
*/
|
||||
import type { Chain, LoginChallenge } from './types.js';
|
||||
|
||||
/** Human chain label used on the first line of the message. */
|
||||
const CHAIN_LABEL: Record<Chain, string> = {
|
||||
evm: 'Ethereum',
|
||||
solana: 'Solana',
|
||||
bitcoin: 'Bitcoin',
|
||||
ton: 'TON',
|
||||
xrp: 'XRP Ledger',
|
||||
};
|
||||
|
||||
export interface ParsedSiwx {
|
||||
domain: string;
|
||||
address: string;
|
||||
statement?: string;
|
||||
uri: string;
|
||||
version?: string;
|
||||
chainId?: string;
|
||||
nonce: string;
|
||||
issuedAt: string;
|
||||
expirationTime?: string;
|
||||
notBefore?: string;
|
||||
requestId?: string;
|
||||
resources?: string[];
|
||||
}
|
||||
|
||||
export interface BuildParams {
|
||||
challenge: LoginChallenge;
|
||||
address: string;
|
||||
chain: Chain;
|
||||
/** CAIP-2 network id, e.g. 'eip155:1', 'solana:5eykt...'. Optional. */
|
||||
chainId?: string;
|
||||
}
|
||||
|
||||
/** Render a {@link LoginChallenge} to the canonical CAIP-122 message string. */
|
||||
export function buildSiwxMessage(params: BuildParams): string {
|
||||
const { challenge: c, address, chain, chainId } = params;
|
||||
const label = CHAIN_LABEL[chain];
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(`${c.domain} wants you to sign in with your ${label} account:`);
|
||||
lines.push(address);
|
||||
lines.push('');
|
||||
// Statement block is optional. When present it sits on its own line between
|
||||
// two blank lines (per EIP-4361 ABNF).
|
||||
if (c.statement != null && c.statement.length > 0) {
|
||||
if (c.statement.includes('\n')) {
|
||||
throw new Error('caip122: statement must be a single line');
|
||||
}
|
||||
lines.push(c.statement);
|
||||
lines.push('');
|
||||
}
|
||||
lines.push(`URI: ${c.uri}`);
|
||||
lines.push(`Version: ${c.version ?? '1'}`);
|
||||
if (chainId != null) {
|
||||
lines.push(`Chain ID: ${chainId}`);
|
||||
}
|
||||
lines.push(`Nonce: ${c.nonce}`);
|
||||
lines.push(`Issued At: ${c.issuedAt}`);
|
||||
if (c.expirationTime != null) {
|
||||
lines.push(`Expiration Time: ${c.expirationTime}`);
|
||||
}
|
||||
if (c.notBefore != null) {
|
||||
lines.push(`Not Before: ${c.notBefore}`);
|
||||
}
|
||||
if (c.requestId != null) {
|
||||
lines.push(`Request ID: ${c.requestId}`);
|
||||
}
|
||||
if (c.resources != null && c.resources.length > 0) {
|
||||
lines.push('Resources:');
|
||||
for (const r of c.resources) {
|
||||
lines.push(`- ${r}`);
|
||||
}
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
const HEADER_RE = /^(?<domain>[^\n]+?) wants you to sign in with your .+ account:$/;
|
||||
const FIELD_RE = /^(?<key>URI|Version|Chain ID|Nonce|Issued At|Expiration Time|Not Before|Request ID): (?<val>.*)$/;
|
||||
|
||||
/** Parse a CAIP-122 message back into its fields. Throws on malformed input. */
|
||||
export function parseSiwxMessage(message: string): ParsedSiwx {
|
||||
const raw = message.split('\n');
|
||||
if (raw.length < 2) {
|
||||
throw new Error('caip122: message too short');
|
||||
}
|
||||
const header = HEADER_RE.exec(raw[0] ?? '');
|
||||
if (!header?.groups) {
|
||||
throw new Error('caip122: malformed header line');
|
||||
}
|
||||
const domain = header.groups.domain;
|
||||
const address = (raw[1] ?? '').trim();
|
||||
if (address.length === 0) {
|
||||
throw new Error('caip122: missing address line');
|
||||
}
|
||||
|
||||
// Everything from line 2 onward: an optional statement block, then fields.
|
||||
const out: Partial<ParsedSiwx> = { domain, address };
|
||||
const resources: string[] = [];
|
||||
let inResources = false;
|
||||
let statementParts: string[] = [];
|
||||
let sawField = false;
|
||||
|
||||
for (let i = 2; i < raw.length; i++) {
|
||||
const line = raw[i] ?? '';
|
||||
if (inResources) {
|
||||
if (line.startsWith('- ')) {
|
||||
resources.push(line.slice(2));
|
||||
continue;
|
||||
}
|
||||
inResources = false;
|
||||
}
|
||||
if (line === 'Resources:') {
|
||||
inResources = true;
|
||||
sawField = true;
|
||||
continue;
|
||||
}
|
||||
const f = FIELD_RE.exec(line);
|
||||
if (f?.groups) {
|
||||
sawField = true;
|
||||
const v = f.groups.val;
|
||||
switch (f.groups.key) {
|
||||
case 'URI': out.uri = v; break;
|
||||
case 'Version': out.version = v; break;
|
||||
case 'Chain ID': out.chainId = v; break;
|
||||
case 'Nonce': out.nonce = v; break;
|
||||
case 'Issued At': out.issuedAt = v; break;
|
||||
case 'Expiration Time': out.expirationTime = v; break;
|
||||
case 'Not Before': out.notBefore = v; break;
|
||||
case 'Request ID': out.requestId = v; break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Pre-field, non-empty, non-field lines are the statement.
|
||||
if (!sawField && line.length > 0) {
|
||||
statementParts.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (statementParts.length > 0) {
|
||||
out.statement = statementParts.join('\n');
|
||||
}
|
||||
if (resources.length > 0) {
|
||||
out.resources = resources;
|
||||
}
|
||||
|
||||
if (out.uri == null || out.nonce == null || out.issuedAt == null) {
|
||||
throw new Error('caip122: missing required field (URI / Nonce / Issued At)');
|
||||
}
|
||||
return out as ParsedSiwx;
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
/**
|
||||
* Browser wallet connectors — the client side of @luxwallet/connect.
|
||||
*
|
||||
* One factory, one vocabulary: {@link getConnector} returns the
|
||||
* {@link WalletConnector} for a {@link Chain}; every connector produces a
|
||||
* {@link SignedProof} the matching server-side verifier accepts.
|
||||
*
|
||||
* IMPORTANT: this module (and everything it imports) pulls the wallet libraries
|
||||
* (viem, sats-connect, @tonconnect/sdk, @crossmarkio/sdk). The server verify
|
||||
* path (`@luxwallet/connect/verify`) imports NONE of this — keep it that way.
|
||||
*/
|
||||
import type { Chain, WalletConnector } from './types.js';
|
||||
import { EvmConnector } from './evm/connect.js';
|
||||
import { SolanaConnector } from './solana/connect.js';
|
||||
import { BitcoinConnector } from './bitcoin/connect.js';
|
||||
import { TonConnector, type TonConnectorOptions } from './ton/connect.js';
|
||||
import { XrpConnector } from './xrp/connect.js';
|
||||
|
||||
export { EvmConnector } from './evm/connect.js';
|
||||
export { SolanaConnector } from './solana/connect.js';
|
||||
export { BitcoinConnector } from './bitcoin/connect.js';
|
||||
export { TonConnector, type TonConnectorOptions } from './ton/connect.js';
|
||||
export { XrpConnector } from './xrp/connect.js';
|
||||
|
||||
/** Per-chain construction options. Only TON needs one (its dApp manifest URL). */
|
||||
export interface ConnectorOptions {
|
||||
ton?: TonConnectorOptions;
|
||||
}
|
||||
|
||||
/** Build the connector for a chain. Pure construction — no I/O, no window touch. */
|
||||
export function getConnector(chain: Chain, options: ConnectorOptions = {}): WalletConnector {
|
||||
switch (chain) {
|
||||
case 'evm':
|
||||
return new EvmConnector();
|
||||
case 'solana':
|
||||
return new SolanaConnector();
|
||||
case 'bitcoin':
|
||||
return new BitcoinConnector();
|
||||
case 'ton':
|
||||
return new TonConnector(options.ton);
|
||||
case 'xrp':
|
||||
return new XrpConnector();
|
||||
default: {
|
||||
// Exhaustiveness: a new Chain must be handled here.
|
||||
const _never: never = chain;
|
||||
throw new Error(`no connector for chain '${String(_never)}'`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One connector per supported chain, in canonical order. */
|
||||
export function allConnectors(options: ConnectorOptions = {}): WalletConnector[] {
|
||||
return [
|
||||
getConnector('evm', options),
|
||||
getConnector('solana', options),
|
||||
getConnector('bitcoin', options),
|
||||
getConnector('ton', options),
|
||||
getConnector('xrp', options),
|
||||
];
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
/**
|
||||
* EVM wallet connector — EIP-191 `personal_sign` over the CAIP-122 message.
|
||||
*
|
||||
* Discovery: EIP-6963 multi-injection (`window.dispatchEvent` /
|
||||
* `eip6963:requestProvider`) when wallets announce themselves, with a fallback
|
||||
* to the legacy single `window.ethereum`. Connection uses viem's `custom`
|
||||
* transport over the chosen EIP-1193 provider; signing uses `personal_sign`.
|
||||
*
|
||||
* The produced {@link SignedProof} is exactly what {@link verifyEvm} accepts:
|
||||
* a 65-byte hex signature, address recoverable from it, scheme
|
||||
* `secp256k1-eip191`. viem stays out of the verify core (see ../verify.ts) —
|
||||
* it lives here, on the browser side only.
|
||||
*/
|
||||
import {
|
||||
createWalletClient,
|
||||
custom,
|
||||
getAddress as toChecksum,
|
||||
type WalletClient,
|
||||
type EIP1193Provider,
|
||||
} from 'viem';
|
||||
import type {
|
||||
Account,
|
||||
LoginChallenge,
|
||||
SignedProof,
|
||||
WalletConnector,
|
||||
WalletInfo,
|
||||
} from '../types.js';
|
||||
import { buildSiwxMessage } from '../caip122.js';
|
||||
|
||||
/** EIP-6963 provider announcement detail. */
|
||||
interface Eip6963ProviderInfo {
|
||||
uuid: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
rdns: string;
|
||||
}
|
||||
interface Eip6963ProviderDetail {
|
||||
info: Eip6963ProviderInfo;
|
||||
provider: EIP1193Provider;
|
||||
}
|
||||
|
||||
interface Eip6963AnnounceEvent extends Event {
|
||||
detail: Eip6963ProviderDetail;
|
||||
}
|
||||
|
||||
/** A discovered injected provider, keyed by a stable id. */
|
||||
interface DiscoveredProvider {
|
||||
id: string;
|
||||
name: string;
|
||||
icon?: string;
|
||||
provider: EIP1193Provider;
|
||||
}
|
||||
|
||||
/** window shape we touch — kept local so the browser libs stay optional. */
|
||||
interface EvmWindow {
|
||||
ethereum?: EIP1193Provider & { providers?: EIP1193Provider[] };
|
||||
addEventListener?: typeof addEventListener;
|
||||
removeEventListener?: typeof removeEventListener;
|
||||
dispatchEvent?: typeof dispatchEvent;
|
||||
}
|
||||
|
||||
function getWindow(): EvmWindow | undefined {
|
||||
return typeof window === 'undefined' ? undefined : (window as unknown as EvmWindow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect EIP-6963 providers. Wallets respond to `eip6963:requestProvider`
|
||||
* synchronously by dispatching `eip6963:announceProvider`; we listen for a
|
||||
* short window and dedupe by rdns.
|
||||
*/
|
||||
function discoverEip6963(win: EvmWindow, waitMs = 300): Promise<DiscoveredProvider[]> {
|
||||
if (typeof win.addEventListener !== 'function' || typeof win.dispatchEvent !== 'function') {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const byRdns = new Map<string, DiscoveredProvider>();
|
||||
const onAnnounce = (ev: Event): void => {
|
||||
const e = ev as Eip6963AnnounceEvent;
|
||||
const d = e.detail;
|
||||
if (d?.info?.rdns && d.provider && !byRdns.has(d.info.rdns)) {
|
||||
byRdns.set(d.info.rdns, {
|
||||
id: d.info.rdns,
|
||||
name: d.info.name,
|
||||
icon: d.info.icon,
|
||||
provider: d.provider,
|
||||
});
|
||||
}
|
||||
};
|
||||
win.addEventListener!('eip6963:announceProvider', onAnnounce as EventListener);
|
||||
win.dispatchEvent!(new Event('eip6963:requestProvider'));
|
||||
setTimeout(() => {
|
||||
win.removeEventListener?.('eip6963:announceProvider', onAnnounce as EventListener);
|
||||
resolve([...byRdns.values()]);
|
||||
}, waitMs);
|
||||
});
|
||||
}
|
||||
|
||||
/** Legacy fallback: window.ethereum (and any window.ethereum.providers fan-out). */
|
||||
function discoverLegacy(win: EvmWindow): DiscoveredProvider[] {
|
||||
const eth = win.ethereum;
|
||||
if (!eth) return [];
|
||||
const list = Array.isArray(eth.providers) && eth.providers.length > 0 ? eth.providers : [eth];
|
||||
return list.map((provider, i) => ({
|
||||
id: i === 0 ? 'injected' : `injected-${i}`,
|
||||
name: 'Injected Wallet',
|
||||
provider,
|
||||
}));
|
||||
}
|
||||
|
||||
export class EvmConnector implements WalletConnector {
|
||||
readonly chain = 'evm' as const;
|
||||
|
||||
#provider: EIP1193Provider | null = null;
|
||||
#client: WalletClient | null = null;
|
||||
|
||||
/** Discover injected EVM wallets via EIP-6963, falling back to window.ethereum. */
|
||||
async available(): Promise<WalletInfo[]> {
|
||||
const win = getWindow();
|
||||
if (!win) return [];
|
||||
const discovered = await this.#discover(win);
|
||||
return discovered.map((d) => ({
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
chain: this.chain,
|
||||
icon: d.icon,
|
||||
installed: true,
|
||||
}));
|
||||
}
|
||||
|
||||
async #discover(win: EvmWindow): Promise<DiscoveredProvider[]> {
|
||||
const announced = await discoverEip6963(win);
|
||||
if (announced.length > 0) return announced;
|
||||
return discoverLegacy(win);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to an injected wallet. `walletId` selects an EIP-6963 provider by
|
||||
* its rdns (or the legacy `injected[-n]` id); omit it to use the first.
|
||||
*/
|
||||
async connect(walletId?: string): Promise<Account> {
|
||||
const win = getWindow();
|
||||
if (!win) throw new Error('evm: no window — connectors are browser-only');
|
||||
|
||||
const discovered = await this.#discover(win);
|
||||
if (discovered.length === 0) {
|
||||
throw new Error('evm: no injected EVM wallet found');
|
||||
}
|
||||
const chosen = walletId != null ? discovered.find((d) => d.id === walletId) : discovered[0];
|
||||
if (!chosen) {
|
||||
throw new Error(`evm: wallet '${walletId}' not found`);
|
||||
}
|
||||
|
||||
const provider = chosen.provider;
|
||||
const accounts = (await provider.request({ method: 'eth_requestAccounts' })) as string[];
|
||||
if (!Array.isArray(accounts) || accounts.length === 0) {
|
||||
throw new Error('evm: wallet returned no accounts');
|
||||
}
|
||||
const address = toChecksum(accounts[0]!);
|
||||
|
||||
let caip2: string | undefined;
|
||||
try {
|
||||
const chainIdHex = (await provider.request({ method: 'eth_chainId' })) as string;
|
||||
const chainId = Number.parseInt(chainIdHex, 16);
|
||||
if (Number.isFinite(chainId)) caip2 = `eip155:${chainId}`;
|
||||
} catch {
|
||||
// chainId is best-effort; signing does not require it.
|
||||
}
|
||||
|
||||
this.#provider = provider;
|
||||
this.#client = createWalletClient({ account: address, transport: custom(provider) });
|
||||
|
||||
return { chain: this.chain, address, walletId: chosen.id, caip2 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the CAIP-122 message and have the wallet `personal_sign` it.
|
||||
* Produces a `secp256k1-eip191` proof: 65-byte hex signature over the EIP-191
|
||||
* digest, with the signer recoverable from the signature.
|
||||
*/
|
||||
async signLogin(account: Account, challenge: LoginChallenge): Promise<SignedProof> {
|
||||
if (!this.#client || !this.#provider) {
|
||||
throw new Error('evm: not connected — call connect() first');
|
||||
}
|
||||
const chainId = account.caip2 ?? undefined;
|
||||
const message = buildSiwxMessage({
|
||||
challenge,
|
||||
address: account.address,
|
||||
chain: this.chain,
|
||||
chainId,
|
||||
});
|
||||
|
||||
// personal_sign returns a 0x-prefixed 65-byte signature (r‖s‖v).
|
||||
const signature = await this.#client.signMessage({
|
||||
account: account.address as `0x${string}`,
|
||||
message,
|
||||
});
|
||||
|
||||
return {
|
||||
chain: this.chain,
|
||||
scheme: 'secp256k1-eip191',
|
||||
address: account.address,
|
||||
message,
|
||||
signature,
|
||||
};
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
this.#provider = null;
|
||||
this.#client = null;
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
/**
|
||||
* EVM verifier — EIP-191 `personal_sign` over the CAIP-122 message.
|
||||
*
|
||||
* Recovers the secp256k1 public key from the signature, derives the Ethereum
|
||||
* address (keccak256 of the uncompressed pubkey, last 20 bytes), and compares
|
||||
* it case-insensitively to the claimed address. No viem dependency — pure
|
||||
* @noble so this mirrors 1:1 in the Go port.
|
||||
*/
|
||||
import { secp256k1 } from '@noble/curves/secp256k1';
|
||||
import { keccak_256 } from '@noble/hashes/sha3';
|
||||
import { utf8ToBytes, concatBytes, hexToBytes, bytesToHex } from '../bytes.js';
|
||||
|
||||
/** keccak256(\x19Ethereum Signed Message:\n<len><msg>). */
|
||||
export function eip191Digest(message: string): Uint8Array {
|
||||
const msg = utf8ToBytes(message);
|
||||
const prefix = utf8ToBytes(`\x19Ethereum Signed Message:\n${msg.length}`);
|
||||
return keccak_256(concatBytes(prefix, msg));
|
||||
}
|
||||
|
||||
/** Lowercased 0x-address derived from an uncompressed (65-byte) public key. */
|
||||
export function addressFromPublicKey(pubUncompressed: Uint8Array): string {
|
||||
// Drop the 0x04 prefix → 64 bytes, hash, take last 20.
|
||||
const body = pubUncompressed.length === 65 ? pubUncompressed.slice(1) : pubUncompressed;
|
||||
const hash = keccak_256(body);
|
||||
return '0x' + bytesToHex(hash.slice(-20));
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an EIP-191 signature. Returns the recovered lowercased address, or
|
||||
* null if the signature is malformed / unrecoverable.
|
||||
*/
|
||||
export function recoverEvmAddress(message: string, signature: string): string | null {
|
||||
try {
|
||||
const sig = hexToBytes(signature);
|
||||
if (sig.length !== 65) return null;
|
||||
const compact = sig.slice(0, 64);
|
||||
let v = sig[64]!;
|
||||
// Accept 27/28 (Ethereum) and raw 0/1 recovery ids.
|
||||
if (v >= 27) v -= 27;
|
||||
if (v !== 0 && v !== 1) return null;
|
||||
const digest = eip191Digest(message);
|
||||
const recovered = secp256k1.Signature.fromCompact(compact)
|
||||
.addRecoveryBit(v)
|
||||
.recoverPublicKey(digest);
|
||||
return addressFromPublicKey(recovered.toRawBytes(false));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** True iff `signature` over `message` was produced by `address`. */
|
||||
export function verifyEvm(message: string, signature: string, address: string): boolean {
|
||||
const recovered = recoverEvmAddress(message, signature);
|
||||
if (recovered == null) return false;
|
||||
return recovered.toLowerCase() === address.trim().toLowerCase();
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
/**
|
||||
* @luxwallet/connect — multi-chain wallet connect + Sign-In-With-X.
|
||||
*
|
||||
* Public surface. One vocabulary ({@link Chain}, {@link SignedProof}), one
|
||||
* canonical login message (CAIP-122), one verifier ({@link verifyProof}).
|
||||
*
|
||||
* MIT licensed, zero GPL — clean of the Uniswap-derived bones that stay
|
||||
* quarantined in luxfi/exchange.
|
||||
*/
|
||||
export type {
|
||||
Chain,
|
||||
SignatureScheme,
|
||||
Account,
|
||||
LoginChallenge,
|
||||
SignedProof,
|
||||
VerifyExpectation,
|
||||
VerifyResult,
|
||||
WalletConnector,
|
||||
WalletInfo,
|
||||
} from './types.js';
|
||||
export { CHAINS } from './types.js';
|
||||
|
||||
export { buildSiwxMessage, parseSiwxMessage } from './caip122.js';
|
||||
export type { ParsedSiwx, BuildParams } from './caip122.js';
|
||||
|
||||
export { generateNonce, newChallenge } from './nonce.js';
|
||||
|
||||
export { verifyProof } from './verify.js';
|
||||
|
||||
// Per-chain primitives (useful standalone; the connectors build on them).
|
||||
export { verifyEvm, recoverEvmAddress, eip191Digest } from './evm/verify.js';
|
||||
export { verifySolana } from './solana/verify.js';
|
||||
export { verifyTon } from './ton/verify.js';
|
||||
export { verifyBitcoin } from './bitcoin/verify.js';
|
||||
export { verifyXrp } from './xrp/verify.js';
|
||||
|
||||
// Browser wallet connectors + the high-level login flow. These import the
|
||||
// wallet libraries (viem, sats-connect, @tonconnect/sdk, @crossmarkio/sdk);
|
||||
// the server verify path above pulls NONE of them.
|
||||
export {
|
||||
getConnector,
|
||||
allConnectors,
|
||||
EvmConnector,
|
||||
SolanaConnector,
|
||||
BitcoinConnector,
|
||||
TonConnector,
|
||||
XrpConnector,
|
||||
} from './connectors.js';
|
||||
export type { ConnectorOptions, TonConnectorOptions } from './connectors.js';
|
||||
export { loginWithWallet } from './login.js';
|
||||
export type { LoginWithWalletParams, LoginResult } from './login.js';
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* loginWithWallet — the one client-side login flow.
|
||||
*
|
||||
* Ties a connector's connect → signLogin into a single call: pick the chain,
|
||||
* connect the wallet, sign the server-issued {@link LoginChallenge}, and return
|
||||
* the {@link SignedProof}. The caller (or its server) mints the challenge and
|
||||
* later verifies the proof with {@link import('./verify.js').verifyProof}.
|
||||
*
|
||||
* server: newChallenge() ─► client: loginWithWallet({chain, challenge})
|
||||
* ─► SignedProof ─► server: verifyProof(proof, {domain, nonce})
|
||||
*
|
||||
* This module imports connectors, so it carries the wallet libs. Keep it out of
|
||||
* the server verify path.
|
||||
*/
|
||||
import type { Account, Chain, LoginChallenge, SignedProof } from './types.js';
|
||||
import { getConnector, type ConnectorOptions } from './connectors.js';
|
||||
|
||||
export interface LoginWithWalletParams {
|
||||
/** Which chain's wallet to authenticate. */
|
||||
chain: Chain;
|
||||
/** Server-minted challenge to sign (domain, nonce, uri, times). */
|
||||
challenge: LoginChallenge;
|
||||
/** Specific wallet id to target (else the connector's default). */
|
||||
walletId?: string;
|
||||
/** Per-chain connector options (e.g. TON manifest URL). */
|
||||
options?: ConnectorOptions;
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
account: Account;
|
||||
proof: SignedProof;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect a wallet on `chain` and sign `challenge`, returning the connected
|
||||
* account and the {@link SignedProof}. Throws if no wallet is available or the
|
||||
* user rejects; the connector is disconnected on failure to avoid a dangling
|
||||
* session.
|
||||
*/
|
||||
export async function loginWithWallet(params: LoginWithWalletParams): Promise<LoginResult> {
|
||||
const connector = getConnector(params.chain, params.options);
|
||||
try {
|
||||
const account = await connector.connect(params.walletId);
|
||||
const proof = await connector.signLogin(account, params.challenge);
|
||||
return { account, proof };
|
||||
} catch (err) {
|
||||
await connector.disconnect().catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
/**
|
||||
* Single-use login nonces. The server mints one per challenge, stores it, and
|
||||
* burns it on verify so a captured proof cannot be replayed.
|
||||
*/
|
||||
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
|
||||
/** Cryptographically-random alphanumeric nonce (default 16 chars, ~95 bits). */
|
||||
export function generateNonce(length = 16): string {
|
||||
if (length < 8) {
|
||||
throw new Error('nonce: length must be >= 8 (CAIP-122 minimum)');
|
||||
}
|
||||
const bytes = new Uint8Array(length);
|
||||
globalThis.crypto.getRandomValues(bytes);
|
||||
let out = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
out += ALPHABET[bytes[i]! % ALPHABET.length];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Build a {@link import('./types.js').LoginChallenge} with sane defaults. */
|
||||
export function newChallenge(opts: {
|
||||
domain: string;
|
||||
uri: string;
|
||||
statement?: string;
|
||||
nonce?: string;
|
||||
/** TTL in seconds for the expirationTime field. Default 600 (10 min). */
|
||||
ttlSeconds?: number;
|
||||
/** Epoch ms for "now"; injectable for tests. */
|
||||
now?: number;
|
||||
requestId?: string;
|
||||
resources?: string[];
|
||||
}) {
|
||||
const nowMs = opts.now ?? Date.now();
|
||||
const issuedAt = new Date(nowMs).toISOString();
|
||||
const ttl = opts.ttlSeconds ?? 600;
|
||||
const expirationTime = new Date(nowMs + ttl * 1000).toISOString();
|
||||
return {
|
||||
domain: opts.domain,
|
||||
uri: opts.uri,
|
||||
statement: opts.statement,
|
||||
nonce: opts.nonce ?? generateNonce(),
|
||||
issuedAt,
|
||||
expirationTime,
|
||||
version: '1',
|
||||
requestId: opts.requestId,
|
||||
resources: opts.resources,
|
||||
};
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
/**
|
||||
* Solana wallet connector — ed25519 `signMessage` over the CAIP-122 message.
|
||||
*
|
||||
* Uses the injected provider directly (Phantom `window.solana`, Solflare
|
||||
* `window.solflare`) — no adapter library needed; the Wallet Standard surface
|
||||
* these expose is a thin `connect()` / `signMessage()` pair. The account
|
||||
* address IS the base58 ed25519 public key, which is exactly what
|
||||
* {@link verifySolana} needs (it decodes the address as the verifying key).
|
||||
*
|
||||
* Produced {@link SignedProof}: scheme `ed25519`, base64 signature over the
|
||||
* raw UTF-8 message bytes, address = base58 public key.
|
||||
*/
|
||||
import bs58 from 'bs58';
|
||||
import type {
|
||||
Account,
|
||||
LoginChallenge,
|
||||
SignedProof,
|
||||
WalletConnector,
|
||||
WalletInfo,
|
||||
} from '../types.js';
|
||||
import { buildSiwxMessage } from '../caip122.js';
|
||||
import { utf8ToBytes, bytesToBase64 } from '../bytes.js';
|
||||
|
||||
/** Minimal shape of an injected Solana provider (Phantom / Solflare / Backpack). */
|
||||
interface SolanaProvider {
|
||||
isPhantom?: boolean;
|
||||
isSolflare?: boolean;
|
||||
isBackpack?: boolean;
|
||||
publicKey?: { toBytes(): Uint8Array; toString(): string } | null;
|
||||
connect(opts?: { onlyIfTrusted?: boolean }): Promise<{ publicKey: { toBytes(): Uint8Array; toString(): string } }>;
|
||||
disconnect?(): Promise<void>;
|
||||
signMessage(message: Uint8Array, encoding?: 'utf8' | 'hex'): Promise<{ signature: Uint8Array } | Uint8Array>;
|
||||
}
|
||||
|
||||
interface SolanaWindow {
|
||||
solana?: SolanaProvider;
|
||||
solflare?: SolanaProvider;
|
||||
backpack?: SolanaProvider;
|
||||
}
|
||||
|
||||
function getWindow(): SolanaWindow | undefined {
|
||||
return typeof window === 'undefined' ? undefined : (window as unknown as SolanaWindow);
|
||||
}
|
||||
|
||||
interface ProviderEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: SolanaProvider;
|
||||
}
|
||||
|
||||
/** Enumerate the injected providers we know how to drive. */
|
||||
function discover(win: SolanaWindow): ProviderEntry[] {
|
||||
const out: ProviderEntry[] = [];
|
||||
if (win.solana) {
|
||||
out.push({ id: win.solana.isPhantom ? 'phantom' : 'solana', name: win.solana.isPhantom ? 'Phantom' : 'Solana', provider: win.solana });
|
||||
}
|
||||
if (win.solflare && win.solflare !== win.solana) {
|
||||
out.push({ id: 'solflare', name: 'Solflare', provider: win.solflare });
|
||||
}
|
||||
if (win.backpack && win.backpack !== win.solana) {
|
||||
out.push({ id: 'backpack', name: 'Backpack', provider: win.backpack });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Normalize the two shapes signMessage can return into raw signature bytes. */
|
||||
function extractSignature(res: { signature: Uint8Array } | Uint8Array): Uint8Array {
|
||||
if (res instanceof Uint8Array) return res;
|
||||
if (res && res.signature instanceof Uint8Array) return res.signature;
|
||||
throw new Error('solana: wallet returned an unrecognized signMessage result');
|
||||
}
|
||||
|
||||
export class SolanaConnector implements WalletConnector {
|
||||
readonly chain = 'solana' as const;
|
||||
|
||||
#provider: SolanaProvider | null = null;
|
||||
|
||||
async available(): Promise<WalletInfo[]> {
|
||||
const win = getWindow();
|
||||
if (!win) return [];
|
||||
return discover(win).map((e) => ({
|
||||
id: e.id,
|
||||
name: e.name,
|
||||
chain: this.chain,
|
||||
installed: true,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Connect to an injected wallet (Phantom by default) and return the account. */
|
||||
async connect(walletId?: string): Promise<Account> {
|
||||
const win = getWindow();
|
||||
if (!win) throw new Error('solana: no window — connectors are browser-only');
|
||||
|
||||
const entries = discover(win);
|
||||
if (entries.length === 0) throw new Error('solana: no injected Solana wallet found');
|
||||
|
||||
const chosen = walletId != null ? entries.find((e) => e.id === walletId) : entries[0];
|
||||
if (!chosen) throw new Error(`solana: wallet '${walletId}' not found`);
|
||||
|
||||
const { publicKey } = await chosen.provider.connect();
|
||||
const address = bs58.encode(publicKey.toBytes());
|
||||
|
||||
this.#provider = chosen.provider;
|
||||
// For Solana the address IS the base58 ed25519 public key — one value.
|
||||
return { chain: this.chain, address, publicKey: address, walletId: chosen.id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the CAIP-122 message and have the wallet sign its UTF-8 bytes.
|
||||
* Produces an `ed25519` proof whose signature {@link verifySolana} checks
|
||||
* against the base58 address (the public key).
|
||||
*/
|
||||
async signLogin(account: Account, challenge: LoginChallenge): Promise<SignedProof> {
|
||||
if (!this.#provider) throw new Error('solana: not connected — call connect() first');
|
||||
|
||||
const message = buildSiwxMessage({
|
||||
challenge,
|
||||
address: account.address,
|
||||
chain: this.chain,
|
||||
});
|
||||
const res = await this.#provider.signMessage(utf8ToBytes(message), 'utf8');
|
||||
const signature = bytesToBase64(extractSignature(res));
|
||||
|
||||
return {
|
||||
chain: this.chain,
|
||||
scheme: 'ed25519',
|
||||
address: account.address,
|
||||
message,
|
||||
signature,
|
||||
};
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
try {
|
||||
await this.#provider?.disconnect?.();
|
||||
} finally {
|
||||
this.#provider = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
/**
|
||||
* Solana verifier — ed25519 over the raw UTF-8 CAIP-122 message
|
||||
* (the bytes a wallet's `signMessage` returns). The account address IS the
|
||||
* base58-encoded ed25519 public key, so the key needed to verify is the
|
||||
* address itself.
|
||||
*/
|
||||
import { ed25519 } from '@noble/curves/ed25519';
|
||||
import bs58 from 'bs58';
|
||||
import { utf8ToBytes, decodeSignature } from '../bytes.js';
|
||||
|
||||
/** True iff `signature` over `message` was produced by the key behind `address`. */
|
||||
export function verifySolana(message: string, signature: string, address: string): boolean {
|
||||
try {
|
||||
const pub = bs58.decode(address.trim());
|
||||
if (pub.length !== 32) return false;
|
||||
const sig = decodeSignature(signature);
|
||||
if (sig.length !== 64) return false;
|
||||
return ed25519.verify(sig, utf8ToBytes(message), pub);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
/**
|
||||
* TON wallet connector — TON Connect `ton_proof` (ed25519).
|
||||
*
|
||||
* TON differs from text-signing chains: the wallet does not sign the CAIP-122
|
||||
* string. It signs a structured `ton_proof` envelope whose `payload` we pin to
|
||||
* the server nonce, and returns the ed25519 signature plus the domain /
|
||||
* timestamp it bound in. {@link verifyTon} reconstructs that envelope and
|
||||
* checks the signature, then binds it back to the CAIP-122 message
|
||||
* (nonce === payload, address === signer).
|
||||
*
|
||||
* Because TON Connect binds the proof payload at connect time, `signLogin`
|
||||
* re-runs the connect handshake with `tonProof: challenge.nonce` and waits for
|
||||
* the wallet's `ton_proof` reply. The produced {@link SignedProof} carries:
|
||||
* - scheme `ton-proof`
|
||||
* - publicKey: ed25519 key, hex
|
||||
* - signature: base64
|
||||
* - extra: { timestamp, domain, payload, workchain, addressHashHex }
|
||||
*/
|
||||
import TonConnect, {
|
||||
isWalletInfoCurrentlyInjected,
|
||||
type Wallet,
|
||||
type WalletInfo as TonWalletInfo,
|
||||
type TonProofItemReply,
|
||||
} from '@tonconnect/sdk';
|
||||
import type {
|
||||
Account,
|
||||
LoginChallenge,
|
||||
SignedProof,
|
||||
WalletConnector,
|
||||
WalletInfo,
|
||||
} from '../types.js';
|
||||
import { buildSiwxMessage } from '../caip122.js';
|
||||
|
||||
/** Default manifest URL used when the host page does not supply one. */
|
||||
const DEFAULT_MANIFEST = 'https://hanzo.id/tonconnect-manifest.json';
|
||||
|
||||
export interface TonConnectorOptions {
|
||||
/** TON Connect dApp manifest URL (defaults to hanzo.id's). */
|
||||
manifestUrl?: string;
|
||||
}
|
||||
|
||||
/** A successful ton_proof reply (the only variant we accept). */
|
||||
function readProof(reply: TonProofItemReply | undefined): {
|
||||
timestamp: number;
|
||||
domain: string;
|
||||
payload: string;
|
||||
signature: string;
|
||||
} | null {
|
||||
if (!reply || reply.name !== 'ton_proof' || !('proof' in reply)) return null;
|
||||
const p = reply.proof;
|
||||
return { timestamp: p.timestamp, domain: p.domain.value, payload: p.payload, signature: p.signature };
|
||||
}
|
||||
|
||||
/** Split a raw TON address `"<workchain>:<hex>"` into its parts. */
|
||||
function parseRawAddress(address: string): { workchain: number; addressHashHex: string } | null {
|
||||
const i = address.indexOf(':');
|
||||
if (i < 0) return null;
|
||||
const workchain = Number.parseInt(address.slice(0, i), 10);
|
||||
const addressHashHex = address.slice(i + 1);
|
||||
if (!Number.isInteger(workchain)) return null;
|
||||
if (!/^[0-9a-fA-F]{64}$/.test(addressHashHex)) return null;
|
||||
return { workchain, addressHashHex };
|
||||
}
|
||||
|
||||
export class TonConnector implements WalletConnector {
|
||||
readonly chain = 'ton' as const;
|
||||
|
||||
readonly #manifestUrl: string;
|
||||
#connector: TonConnect | null = null;
|
||||
|
||||
constructor(options: TonConnectorOptions = {}) {
|
||||
// Pure construction: TonConnect's constructor touches localStorage, so it is
|
||||
// created lazily on first use (in a browser) rather than here.
|
||||
this.#manifestUrl = options.manifestUrl ?? DEFAULT_MANIFEST;
|
||||
}
|
||||
|
||||
/** Lazily build the underlying TonConnect (requires a browser w/ localStorage). */
|
||||
#sdk(): TonConnect {
|
||||
if (typeof window === 'undefined') {
|
||||
throw new Error('ton: no window — connectors are browser-only');
|
||||
}
|
||||
if (!this.#connector) {
|
||||
this.#connector = new TonConnect({ manifestUrl: this.#manifestUrl });
|
||||
}
|
||||
return this.#connector;
|
||||
}
|
||||
|
||||
/** List injected TON wallets (Tonkeeper, MyTonWallet, …) detected on the page. */
|
||||
async available(): Promise<WalletInfo[]> {
|
||||
if (typeof window === 'undefined') return [];
|
||||
const wallets = await this.#sdk().getWallets();
|
||||
return wallets.filter(isWalletInfoCurrentlyInjected).map((w: TonWalletInfo) => ({
|
||||
id: (w as { jsBridgeKey: string }).jsBridgeKey,
|
||||
name: w.name,
|
||||
chain: this.chain,
|
||||
icon: w.imageUrl,
|
||||
installed: true,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Establish a session (no proof yet) and return the account. `walletId` is the
|
||||
* wallet's `jsBridgeKey`; omit it to use the first injected wallet.
|
||||
*/
|
||||
async connect(walletId?: string): Promise<Account> {
|
||||
const wallet = await this.#handshake(walletId);
|
||||
return this.#toAccount(wallet, walletId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-run the handshake with `tonProof: nonce`, wait for the wallet's signed
|
||||
* envelope, and assemble the {@link SignedProof} {@link verifyTon} accepts.
|
||||
*/
|
||||
async signLogin(account: Account, challenge: LoginChallenge): Promise<SignedProof> {
|
||||
const parsed = parseRawAddress(account.address);
|
||||
if (!parsed) throw new Error(`ton: address '${account.address}' is not raw '<wc>:<hex>' form`);
|
||||
|
||||
// Bind the ton_proof payload to the server nonce.
|
||||
const wallet = await this.#handshake(account.walletId, challenge.nonce);
|
||||
|
||||
const publicKey = wallet.account.publicKey;
|
||||
if (!publicKey) throw new Error('ton: wallet did not return a public key');
|
||||
|
||||
const proof = readProof(wallet.connectItems?.tonProof);
|
||||
if (!proof) throw new Error('ton: wallet did not return a ton_proof');
|
||||
if (proof.payload !== challenge.nonce) {
|
||||
throw new Error('ton: wallet signed a different payload than the requested nonce');
|
||||
}
|
||||
|
||||
// CAIP-122 message: address line is the raw TON address; its Nonce equals
|
||||
// the ton_proof payload (the verifier enforces both bindings).
|
||||
const message = buildSiwxMessage({
|
||||
challenge,
|
||||
address: account.address,
|
||||
chain: this.chain,
|
||||
});
|
||||
|
||||
return {
|
||||
chain: this.chain,
|
||||
scheme: 'ton-proof',
|
||||
address: account.address,
|
||||
publicKey,
|
||||
message,
|
||||
signature: proof.signature,
|
||||
extra: {
|
||||
timestamp: proof.timestamp,
|
||||
domain: proof.domain,
|
||||
payload: proof.payload,
|
||||
workchain: parsed.workchain,
|
||||
addressHashHex: parsed.addressHashHex,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
// Only act if the SDK was ever created (avoids touching localStorage in SSR).
|
||||
if (this.#connector?.connected) await this.#connector.disconnect();
|
||||
}
|
||||
|
||||
/** Resolve the chosen injected wallet's jsBridgeKey. */
|
||||
async #resolveBridgeKey(walletId?: string): Promise<string> {
|
||||
if (walletId != null) return walletId;
|
||||
const wallets = (await this.#sdk().getWallets()).filter(isWalletInfoCurrentlyInjected);
|
||||
const first = wallets[0] as { jsBridgeKey?: string } | undefined;
|
||||
if (!first?.jsBridgeKey) throw new Error('ton: no injected TON wallet found');
|
||||
return first.jsBridgeKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive one connect handshake and resolve with the resulting Wallet. When
|
||||
* `proofPayload` is given, the wallet returns a ton_proof bound to it.
|
||||
*/
|
||||
async #handshake(walletId?: string, proofPayload?: string): Promise<Wallet> {
|
||||
const sdk = this.#sdk(); // throws outside a browser
|
||||
const jsBridgeKey = await this.#resolveBridgeKey(walletId);
|
||||
|
||||
return new Promise<Wallet>((resolve, reject) => {
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
const done = (fn: () => void): void => {
|
||||
unsubscribe?.();
|
||||
fn();
|
||||
};
|
||||
unsubscribe = sdk.onStatusChange(
|
||||
(wallet) => {
|
||||
if (wallet) done(() => resolve(wallet));
|
||||
},
|
||||
(err) => done(() => reject(err)),
|
||||
);
|
||||
try {
|
||||
// Injected connect returns void; the reply arrives via onStatusChange.
|
||||
sdk.connect(
|
||||
{ jsBridgeKey },
|
||||
proofPayload != null ? { tonProof: proofPayload } : undefined,
|
||||
);
|
||||
} catch (err) {
|
||||
done(() => reject(err instanceof Error ? err : new Error(String(err))));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#toAccount(wallet: Wallet, walletId?: string): Account {
|
||||
return {
|
||||
chain: this.chain,
|
||||
address: wallet.account.address,
|
||||
publicKey: wallet.account.publicKey,
|
||||
walletId: walletId ?? wallet.device.appName,
|
||||
caip2: `ton:${wallet.account.chain}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
/**
|
||||
* TON verifier — TON Connect `ton_proof` (ed25519).
|
||||
*
|
||||
* Unlike text-signing chains, TON signs a structured `ton_proof` envelope, not
|
||||
* the CAIP-122 string. The connector carries the envelope in `proof.extra` and
|
||||
* the wallet public key in `proof.publicKey`; this verifier reconstructs the
|
||||
* ton_proof signing message exactly as the TON Connect spec defines it, checks
|
||||
* the ed25519 signature over the double-SHA-256 digest, and binds the envelope
|
||||
* to the CAIP-122 login message (nonce == payload, address == signer).
|
||||
*
|
||||
* Reference (TON Connect ton_proof):
|
||||
* message = "ton-proof-item-v2/"
|
||||
* ‖ int32BE(workchain)
|
||||
* ‖ addressHash(32)
|
||||
* ‖ uint32LE(len(domain))
|
||||
* ‖ domain
|
||||
* ‖ uint64LE(timestamp)
|
||||
* ‖ payload
|
||||
* signed = sha256( 0xffff ‖ "ton-connect" ‖ sha256(message) )
|
||||
* ok = ed25519.verify(signature, signed, publicKey)
|
||||
*
|
||||
* Pure: no I/O, no network, no clock. Fails closed — any malformed or missing
|
||||
* field returns false; never throws.
|
||||
*/
|
||||
import { ed25519 } from '@noble/curves/ed25519';
|
||||
import { sha256 } from '@noble/hashes/sha256';
|
||||
import type { SignedProof } from '../types.js';
|
||||
import { parseSiwxMessage } from '../caip122.js';
|
||||
import { hexToBytes, base64ToBytes, utf8ToBytes, concatBytes } from '../bytes.js';
|
||||
|
||||
/** ton_proof static prefixes (TON Connect v2). */
|
||||
const PROOF_PREFIX = utf8ToBytes('ton-proof-item-v2/');
|
||||
const CONNECT_PREFIX = utf8ToBytes('ton-connect');
|
||||
|
||||
/** ed25519 public keys are 32 bytes; signatures are 64 bytes; addr hash 32. */
|
||||
const ED25519_PUBKEY_LEN = 32;
|
||||
const ED25519_SIG_LEN = 64;
|
||||
const ADDR_HASH_LEN = 32;
|
||||
|
||||
/** The `extra` envelope a TON connector attaches to a ton_proof. */
|
||||
interface TonProofExtra {
|
||||
timestamp: number;
|
||||
domain: string;
|
||||
payload: string;
|
||||
workchain: number;
|
||||
addressHashHex: string;
|
||||
}
|
||||
|
||||
/** Narrow `proof.extra` to the ton_proof envelope, validating field shapes. */
|
||||
function readExtra(extra: unknown): TonProofExtra | null {
|
||||
if (extra == null || typeof extra !== 'object') return null;
|
||||
const e = extra as Record<string, unknown>;
|
||||
const { timestamp, domain, payload, workchain, addressHashHex } = e;
|
||||
// timestamp: a finite, non-negative integer number of unix seconds.
|
||||
if (typeof timestamp !== 'number' || !Number.isInteger(timestamp) || timestamp < 0) return null;
|
||||
// workchain: a finite integer (0 = basechain, -1 = masterchain typically).
|
||||
if (typeof workchain !== 'number' || !Number.isInteger(workchain)) return null;
|
||||
if (typeof domain !== 'string') return null;
|
||||
if (typeof payload !== 'string') return null;
|
||||
if (typeof addressHashHex !== 'string') return null;
|
||||
return { timestamp, domain, payload, workchain, addressHashHex };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct the ton_proof message body that the wallet hashed:
|
||||
* "ton-proof-item-v2/" ‖ int32BE(wc) ‖ addrHash ‖ uint32LE(|domain|) ‖ domain
|
||||
* ‖ uint64LE(ts) ‖ payload
|
||||
*
|
||||
* Integer widths/endianness are spec-exact:
|
||||
* - workchain: 4 bytes, big-endian, SIGNED (so -1 → 0xFFFFFFFF).
|
||||
* - domain length: 4 bytes, little-endian, the UTF-8 BYTE length.
|
||||
* - timestamp: 8 bytes, little-endian (BigInt to span > 2^53 safely).
|
||||
*/
|
||||
function buildProofMessage(
|
||||
workchain: number,
|
||||
addressHash: Uint8Array,
|
||||
domainBytes: Uint8Array,
|
||||
timestamp: number,
|
||||
payloadBytes: Uint8Array,
|
||||
): Uint8Array {
|
||||
// workchain — int32 big-endian (signed two's-complement via setInt32).
|
||||
const wc = new Uint8Array(4);
|
||||
new DataView(wc.buffer).setInt32(0, workchain, /* littleEndian */ false);
|
||||
|
||||
// domain length — uint32 little-endian over the UTF-8 byte length.
|
||||
const dlen = new Uint8Array(4);
|
||||
new DataView(dlen.buffer).setUint32(0, domainBytes.length, /* littleEndian */ true);
|
||||
|
||||
// timestamp — uint64 little-endian.
|
||||
const ts = new Uint8Array(8);
|
||||
new DataView(ts.buffer).setBigUint64(0, BigInt(timestamp), /* littleEndian */ true);
|
||||
|
||||
return concatBytes(PROOF_PREFIX, wc, addressHash, dlen, domainBytes, ts, payloadBytes);
|
||||
}
|
||||
|
||||
/** TON Connect's full pre-image and the double hash that ed25519 actually signs. */
|
||||
function proofDigest(message: Uint8Array): Uint8Array {
|
||||
// fullMsg = 0xff 0xff ‖ "ton-connect" ‖ sha256(message)
|
||||
const fullMsg = concatBytes(Uint8Array.of(0xff, 0xff), CONNECT_PREFIX, sha256(message));
|
||||
return sha256(fullMsg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a TON Connect `ton_proof` login proof.
|
||||
*
|
||||
* @returns true iff the ed25519 signature is valid over the reconstructed
|
||||
* ton_proof digest AND the envelope is bound to the CAIP-122 message
|
||||
* (nonce == payload, address == signer). Any other condition → false.
|
||||
*/
|
||||
export function verifyTon(proof: SignedProof): boolean {
|
||||
try {
|
||||
// --- 0. Structural presence: scheme, public key, signature, envelope. ---
|
||||
if (proof.scheme !== 'ton-proof') return false;
|
||||
if (typeof proof.publicKey !== 'string' || proof.publicKey.length === 0) return false;
|
||||
if (typeof proof.signature !== 'string' || proof.signature.length === 0) return false;
|
||||
if (typeof proof.message !== 'string' || proof.message.length === 0) return false;
|
||||
if (typeof proof.address !== 'string' || proof.address.length === 0) return false;
|
||||
|
||||
const extra = readExtra(proof.extra);
|
||||
if (extra === null) return false;
|
||||
|
||||
// --- 1. Binding to the CAIP-122 login message (anti-replay, anti-phishing).
|
||||
// parseSiwxMessage throws on malformed input; the try/catch fails closed.
|
||||
const parsed = parseSiwxMessage(proof.message);
|
||||
// The signed payload MUST be the server-minted nonce carried in the SIWx msg.
|
||||
if (parsed.nonce !== extra.payload) return false;
|
||||
// The signer MUST be the address embedded in the message.
|
||||
if (parsed.address !== proof.address) return false;
|
||||
|
||||
// --- 2. Decode + length-check the fixed-width cryptographic material. ---
|
||||
const publicKey = hexToBytes(proof.publicKey);
|
||||
if (publicKey.length !== ED25519_PUBKEY_LEN) return false;
|
||||
|
||||
const signature = base64ToBytes(proof.signature);
|
||||
if (signature.length !== ED25519_SIG_LEN) return false;
|
||||
|
||||
const addressHash = hexToBytes(extra.addressHashHex);
|
||||
if (addressHash.length !== ADDR_HASH_LEN) return false;
|
||||
|
||||
// --- 3. Reconstruct the ton_proof message and the digest the wallet signed.
|
||||
const domainBytes = utf8ToBytes(extra.domain);
|
||||
const payloadBytes = utf8ToBytes(extra.payload);
|
||||
const message = buildProofMessage(
|
||||
extra.workchain,
|
||||
addressHash,
|
||||
domainBytes,
|
||||
extra.timestamp,
|
||||
payloadBytes,
|
||||
);
|
||||
const digest = proofDigest(message);
|
||||
|
||||
// --- 4. ed25519 signature check over the 32-byte digest. ---
|
||||
return ed25519.verify(signature, digest, publicKey);
|
||||
} catch {
|
||||
// Bad hex/base64, malformed SIWx, etc. — fail closed.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
/**
|
||||
* @luxwallet/connect — core types.
|
||||
*
|
||||
* One vocabulary across every chain. A wallet on any chain produces a
|
||||
* {@link SignedProof}; the server verifies it with one {@link verifyProof}
|
||||
* call. The login message itself is chain-agnostic (CAIP-122 "Sign-In-With-X").
|
||||
*/
|
||||
|
||||
/** Supported chain families. Values, not places — namespaced by this union. */
|
||||
export type Chain = 'evm' | 'solana' | 'bitcoin' | 'ton' | 'xrp';
|
||||
|
||||
export const CHAINS: readonly Chain[] = ['evm', 'solana', 'bitcoin', 'ton', 'xrp'];
|
||||
|
||||
/**
|
||||
* Signature scheme used to produce a proof. The verifier dispatches on this,
|
||||
* not on {@link Chain}, so a chain could in principle offer more than one.
|
||||
*/
|
||||
export type SignatureScheme =
|
||||
| 'secp256k1-eip191' // EVM personal_sign (EIP-191)
|
||||
| 'ed25519' // Solana, TON
|
||||
| 'bip322' // Bitcoin message signing (BIP-322)
|
||||
| 'ton-proof' // TON Connect ton_proof envelope (ed25519 inside)
|
||||
| 'secp256k1-xrpl' // XRPL signMessage
|
||||
| 'ed25519-xrpl'; // XRPL ed25519 keypair
|
||||
|
||||
/** A connected wallet account. `publicKey` is required where the address is not recoverable from the signature (Solana, TON, XRP). */
|
||||
export interface Account {
|
||||
chain: Chain;
|
||||
/** Canonical address string for the chain (checksum EVM, base58 Solana, etc.). */
|
||||
address: string;
|
||||
/** Raw public key, hex (no 0x) or base64 — needed by ed25519/XRPL verifiers. */
|
||||
publicKey?: string;
|
||||
/** Identifier of the wallet that produced it (e.g. 'metamask', 'phantom'). */
|
||||
walletId: string;
|
||||
/** CAIP-2 chain id of the specific network, when known (e.g. 'eip155:1'). */
|
||||
caip2?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The login challenge a server asks a wallet to sign. Mirrors EIP-4361 /
|
||||
* CAIP-122 fields. The server mints `nonce` and stores it until verification.
|
||||
*/
|
||||
export interface LoginChallenge {
|
||||
/** RFC 4501 dnsauthority that is requesting the signing (e.g. 'hanzo.id'). */
|
||||
domain: string;
|
||||
/** RFC 3986 URI referring to the resource that is the subject of the signing. */
|
||||
uri: string;
|
||||
/** Human-readable assertion the user signs (one line, no newlines). */
|
||||
statement?: string;
|
||||
/** Server-minted single-use nonce (>= 8 alphanumerics). */
|
||||
nonce: string;
|
||||
/** ISO-8601 issuance time. */
|
||||
issuedAt: string;
|
||||
/** ISO-8601 expiry; after this the proof is rejected. */
|
||||
expirationTime?: string;
|
||||
/** ISO-8601 not-before; before this the proof is rejected. */
|
||||
notBefore?: string;
|
||||
/** Opaque request correlation id. */
|
||||
requestId?: string;
|
||||
/** Version of the message spec; '1' for CAIP-122/EIP-4361. */
|
||||
version?: string;
|
||||
/** Resource URIs the sign-in grants access to. */
|
||||
resources?: string[];
|
||||
}
|
||||
|
||||
/** What a wallet hands back after signing — everything a server needs to verify. */
|
||||
export interface SignedProof {
|
||||
chain: Chain;
|
||||
scheme: SignatureScheme;
|
||||
/** Address that signed (must match the address embedded in `message`). */
|
||||
address: string;
|
||||
/** Public key (hex/base64) when required by the scheme. */
|
||||
publicKey?: string;
|
||||
/** The exact UTF-8 string that was signed (the rendered CAIP-122 message). */
|
||||
message: string;
|
||||
/** Signature bytes, hex (0x-prefixed allowed) or base64 per scheme. */
|
||||
signature: string;
|
||||
/** Scheme-specific extra fields (e.g. TON proof envelope, BTC address type). */
|
||||
extra?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Server-side expectations checked against the parsed message during verify. */
|
||||
export interface VerifyExpectation {
|
||||
/** Must equal the message `domain`. */
|
||||
domain: string;
|
||||
/** Must equal the message `nonce` (single-use; server also burns it). */
|
||||
nonce: string;
|
||||
/** Optional: require an exact address (case-insensitive for EVM). */
|
||||
address?: string;
|
||||
/** Override "now" for deterministic tests (epoch ms). */
|
||||
now?: number;
|
||||
/** Max clock skew tolerated on issuedAt/notBefore, ms. Default 5 min. */
|
||||
clockSkewMs?: number;
|
||||
}
|
||||
|
||||
export interface VerifyResult {
|
||||
ok: boolean;
|
||||
/** Present when ok=false: machine-readable reason. */
|
||||
reason?:
|
||||
| 'bad-signature'
|
||||
| 'address-mismatch'
|
||||
| 'domain-mismatch'
|
||||
| 'nonce-mismatch'
|
||||
| 'expired'
|
||||
| 'not-yet-valid'
|
||||
| 'malformed-message'
|
||||
| 'unsupported-scheme'
|
||||
| 'missing-public-key';
|
||||
/** The verified address (canonicalized) when ok=true. */
|
||||
address?: string;
|
||||
chain?: Chain;
|
||||
}
|
||||
|
||||
/**
|
||||
* A per-chain connector. Browser/runtime side only — the verifier never needs
|
||||
* it. Implementations live under src/<chain>/.
|
||||
*/
|
||||
export interface WalletConnector {
|
||||
readonly chain: Chain;
|
||||
/** Wallets this connector can discover/offer in the current runtime. */
|
||||
available(): Promise<WalletInfo[]>;
|
||||
/** Connect (optionally to a specific wallet) and return the active account. */
|
||||
connect(walletId?: string): Promise<Account>;
|
||||
/** Render the challenge to the canonical message and have the wallet sign it. */
|
||||
signLogin(account: Account, challenge: LoginChallenge): Promise<SignedProof>;
|
||||
/** Disconnect / forget the session. */
|
||||
disconnect(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface WalletInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
chain: Chain;
|
||||
/** Data URI or URL to the wallet icon. */
|
||||
icon?: string;
|
||||
/** True if detected/installed in the current runtime. */
|
||||
installed: boolean;
|
||||
/** Where to get it if not installed. */
|
||||
downloadUrl?: string;
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
/**
|
||||
* verifyProof — the one server-side entry point. Chain-agnostic: parse the
|
||||
* CAIP-122 message, enforce domain/nonce/time, then dispatch to the per-chain
|
||||
* cryptographic verifier. Fails closed: any unknown scheme or malformed input
|
||||
* returns `{ ok: false, reason }`, never throws.
|
||||
*
|
||||
* This pure function is mirrored by the Go port in go/walletconnect so IAM
|
||||
* verifies identically.
|
||||
*/
|
||||
import type { SignedProof, VerifyExpectation, VerifyResult, Chain } from './types.js';
|
||||
import { parseSiwxMessage } from './caip122.js';
|
||||
import { verifyEvm } from './evm/verify.js';
|
||||
import { verifySolana } from './solana/verify.js';
|
||||
import { verifyTon } from './ton/verify.js';
|
||||
import { verifyBitcoin } from './bitcoin/verify.js';
|
||||
import { verifyXrp } from './xrp/verify.js';
|
||||
|
||||
const DEFAULT_SKEW_MS = 5 * 60 * 1000;
|
||||
|
||||
function fail(reason: NonNullable<VerifyResult['reason']>): VerifyResult {
|
||||
return { ok: false, reason };
|
||||
}
|
||||
|
||||
/** Case-insensitive only for EVM (checksummed hex); all others are exact. */
|
||||
function addressesEqual(chain: Chain, a: string, b: string): boolean {
|
||||
const x = a.trim();
|
||||
const y = b.trim();
|
||||
return chain === 'evm' ? x.toLowerCase() === y.toLowerCase() : x === y;
|
||||
}
|
||||
|
||||
function parseTime(s: string | undefined): number | null {
|
||||
if (s == null) return null;
|
||||
const t = Date.parse(s);
|
||||
return Number.isNaN(t) ? null : t;
|
||||
}
|
||||
|
||||
/** Cryptographic dispatch. Returns null for not-yet-supported schemes. */
|
||||
function verifyCrypto(proof: SignedProof): boolean | null {
|
||||
switch (proof.scheme) {
|
||||
case 'secp256k1-eip191':
|
||||
return verifyEvm(proof.message, proof.signature, proof.address);
|
||||
case 'ed25519':
|
||||
// ed25519-over-message is Solana today; TON uses 'ton-proof'.
|
||||
return verifySolana(proof.message, proof.signature, proof.address);
|
||||
case 'ton-proof':
|
||||
return verifyTon(proof);
|
||||
case 'bip322':
|
||||
return verifyBitcoin(proof);
|
||||
case 'secp256k1-xrpl':
|
||||
case 'ed25519-xrpl':
|
||||
return verifyXrp(proof);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function verifyProof(proof: SignedProof, expected: VerifyExpectation): VerifyResult {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = parseSiwxMessage(proof.message);
|
||||
} catch {
|
||||
return fail('malformed-message');
|
||||
}
|
||||
|
||||
// 1. Binding: the signer in the message must match the proof's address.
|
||||
if (!addressesEqual(proof.chain, parsed.address, proof.address)) {
|
||||
return fail('address-mismatch');
|
||||
}
|
||||
if (expected.address != null && !addressesEqual(proof.chain, proof.address, expected.address)) {
|
||||
return fail('address-mismatch');
|
||||
}
|
||||
|
||||
// 2. Domain + nonce binding (anti-phishing, anti-replay).
|
||||
if (parsed.domain !== expected.domain) {
|
||||
return fail('domain-mismatch');
|
||||
}
|
||||
if (parsed.nonce !== expected.nonce) {
|
||||
return fail('nonce-mismatch');
|
||||
}
|
||||
|
||||
// 3. Time window.
|
||||
const now = expected.now ?? Date.now();
|
||||
const skew = expected.clockSkewMs ?? DEFAULT_SKEW_MS;
|
||||
const exp = parseTime(parsed.expirationTime);
|
||||
if (exp != null && now > exp + skew) {
|
||||
return fail('expired');
|
||||
}
|
||||
const nbf = parseTime(parsed.notBefore);
|
||||
if (nbf != null && now + skew < nbf) {
|
||||
return fail('not-yet-valid');
|
||||
}
|
||||
const iat = parseTime(parsed.issuedAt);
|
||||
if (iat != null && iat - skew > now) {
|
||||
return fail('not-yet-valid');
|
||||
}
|
||||
|
||||
// 4. Cryptographic signature.
|
||||
const crypto = verifyCrypto(proof);
|
||||
if (crypto == null) {
|
||||
return fail('unsupported-scheme');
|
||||
}
|
||||
if (!crypto) {
|
||||
return fail('bad-signature');
|
||||
}
|
||||
|
||||
return { ok: true, address: proof.address, chain: proof.chain };
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
/**
|
||||
* XRP (XRP Ledger) wallet connector — Crossmark (`@crossmarkio/sdk`, MIT).
|
||||
*
|
||||
* Crossmark's `signInAndWait(hex)` performs a sign-in that also signs the hex
|
||||
* bytes we pass, returning the account's r-address, its 33-byte public key
|
||||
* (hex, with the XRPL family tag — `0xED` for ed25519, `0x02/0x03` for
|
||||
* secp256k1), and the signature. {@link verifyXrp} digests the CAIP-122 message
|
||||
* the same way XRPL does and checks the signature under that key, then binds
|
||||
* the key to the r-address.
|
||||
*
|
||||
* The signing input must be the CAIP-122 message bytes: we pass
|
||||
* `hex(utf8(message))` to the wallet and set `proof.message` to the same
|
||||
* string, so the verifier's `utf8ToBytes(proof.message)` digest matches what
|
||||
* the wallet signed. The scheme is chosen from the public key's family tag.
|
||||
*
|
||||
* GemWallet is intentionally NOT wired: its only client (`@gemwallet/api`) ships
|
||||
* under a custom dual license that requires GemWallet's permission for
|
||||
* public/commercial use — incompatible with this package's MIT/Apache/ISC-only
|
||||
* rule. Crossmark covers both XRPL key types, so the XRP path stays complete.
|
||||
*/
|
||||
import sdk from '@crossmarkio/sdk';
|
||||
import type {
|
||||
Account,
|
||||
LoginChallenge,
|
||||
SignedProof,
|
||||
SignatureScheme,
|
||||
WalletConnector,
|
||||
WalletInfo,
|
||||
} from '../types.js';
|
||||
import { buildSiwxMessage } from '../caip122.js';
|
||||
import { utf8ToBytes, bytesToHex } from '../bytes.js';
|
||||
|
||||
/** XRPL public-key family tag → signature scheme. */
|
||||
function schemeForPublicKey(publicKeyHex: string): SignatureScheme {
|
||||
const tag = publicKeyHex.slice(0, 2).toLowerCase();
|
||||
return tag === 'ed' ? 'ed25519-xrpl' : 'secp256k1-xrpl';
|
||||
}
|
||||
|
||||
export class XrpConnector implements WalletConnector {
|
||||
readonly chain = 'xrp' as const;
|
||||
|
||||
#publicKey: string | null = null;
|
||||
|
||||
/** Crossmark is the supported XRP wallet; report it when installed. */
|
||||
async available(): Promise<WalletInfo[]> {
|
||||
if (typeof window === 'undefined') return [];
|
||||
const installed = sdk.sync.isInstalled() === true;
|
||||
return [
|
||||
{
|
||||
id: 'crossmark',
|
||||
name: 'Crossmark',
|
||||
chain: this.chain,
|
||||
installed,
|
||||
downloadUrl: installed ? undefined : 'https://crossmark.io',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect via Crossmark sign-in. We do a bare sign-in here to capture the
|
||||
* address + public key; the actual login signature is produced in signLogin.
|
||||
*/
|
||||
async connect(walletId?: string): Promise<Account> {
|
||||
if (typeof window === 'undefined') {
|
||||
throw new Error('xrp: no window — connectors are browser-only');
|
||||
}
|
||||
if (walletId != null && walletId !== 'crossmark') {
|
||||
throw new Error(`xrp: unsupported wallet '${walletId}' (only 'crossmark')`);
|
||||
}
|
||||
|
||||
const res = await sdk.async.signInAndWait();
|
||||
const data = res?.response?.data;
|
||||
if (!data?.address || !data?.publicKey) {
|
||||
throw new Error('xrp: Crossmark sign-in returned no address/public key');
|
||||
}
|
||||
|
||||
this.#publicKey = data.publicKey;
|
||||
|
||||
return {
|
||||
chain: this.chain,
|
||||
address: data.address,
|
||||
publicKey: data.publicKey,
|
||||
walletId: 'crossmark',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the CAIP-122 message, have Crossmark sign its UTF-8 bytes (passed as
|
||||
* hex), and assemble a proof under the key's scheme. The signature lands in
|
||||
* the sign-in response's `signature` field when a hex challenge is supplied.
|
||||
*/
|
||||
async signLogin(account: Account, challenge: LoginChallenge): Promise<SignedProof> {
|
||||
if (!this.#publicKey) throw new Error('xrp: not connected — call connect() first');
|
||||
|
||||
const message = buildSiwxMessage({
|
||||
challenge,
|
||||
address: account.address,
|
||||
chain: this.chain,
|
||||
});
|
||||
const hex = bytesToHex(utf8ToBytes(message));
|
||||
|
||||
const res = await sdk.async.signInAndWait(hex);
|
||||
const data = res?.response?.data;
|
||||
if (!data?.signature) {
|
||||
throw new Error('xrp: Crossmark did not return a signature');
|
||||
}
|
||||
// The public key may refine on the signing response; prefer it if present.
|
||||
const publicKey = data.publicKey ?? this.#publicKey;
|
||||
|
||||
return {
|
||||
chain: this.chain,
|
||||
scheme: schemeForPublicKey(publicKey),
|
||||
address: account.address,
|
||||
publicKey,
|
||||
message,
|
||||
signature: data.signature,
|
||||
};
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
this.#publicKey = null;
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
/**
|
||||
* XRP (XRP Ledger) verifier — wallet login-message signatures.
|
||||
*
|
||||
* XRPL accounts use either a secp256k1 or an ed25519 keypair. The connector
|
||||
* carries the public key in `proof.publicKey` (33 bytes in XRPL's canonical
|
||||
* form). This verifier does two independent checks, both of which must hold:
|
||||
*
|
||||
* 1. Signature: the signature is valid over the CAIP-122 message under the
|
||||
* declared key, using XRPL's signing convention for the scheme.
|
||||
* - ed25519-xrpl : raw EdDSA over the UTF-8 message bytes.
|
||||
* - secp256k1-xrpl : ECDSA over the "sha512half" digest
|
||||
* (first 32 bytes of SHA-512 of the message), DER-encoded.
|
||||
* 2. Address binding: the public key derives the claimed r-address via the
|
||||
* standard XRPL AccountID derivation (RIPEMD160(SHA256(pubkey)) under the
|
||||
* 0x00 account prefix, base58check with the XRPL alphabet).
|
||||
*
|
||||
* Decomplected: signature verification and address binding are separate,
|
||||
* each complete on its own. Fails closed — every error path returns false,
|
||||
* nothing throws. Pure: no I/O, no clock. Mirrors 1:1 in the Go port.
|
||||
*
|
||||
* Refs:
|
||||
* - https://xrpl.org/cryptographic-keys.html (key prefixes, AccountID)
|
||||
* - https://xrpl.org/base58-encodings.html (XRPL base58 alphabet, type prefix)
|
||||
* - https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-122.md
|
||||
*/
|
||||
import { secp256k1 } from '@noble/curves/secp256k1';
|
||||
import { ed25519 } from '@noble/curves/ed25519';
|
||||
import { sha256 } from '@noble/hashes/sha256';
|
||||
import { sha512 } from '@noble/hashes/sha512';
|
||||
import { ripemd160 } from '@noble/hashes/ripemd160';
|
||||
import type { SignedProof } from '../types.js';
|
||||
import { hexToBytes, decodeSignature, utf8ToBytes, concatBytes } from '../bytes.js';
|
||||
|
||||
/** XRPL's base58 alphabet (NOT the Bitcoin/IPFS alphabet — different order). */
|
||||
const XRPL_ALPHABET = 'rpshnaf39wBUDNEGHJKLM4PQRST7VWXYZ2bcdeCg65jkm8oFqi1tuvAxyz';
|
||||
|
||||
/** Account address type prefix byte (the leading 'r' once base58-encoded). */
|
||||
const ACCOUNT_ID_PREFIX = 0x00;
|
||||
|
||||
/** XRPL public keys are always 33 bytes: a 1-byte family tag + 32-byte key. */
|
||||
const PUBKEY_LEN = 33;
|
||||
const ED25519_PREFIX = 0xed;
|
||||
const ED25519_SIG_LEN = 64;
|
||||
|
||||
/** XRPL "sha512half": the first half (32 bytes) of SHA-512 over the input. */
|
||||
function sha512Half(data: Uint8Array): Uint8Array {
|
||||
return sha512(data).slice(0, 32);
|
||||
}
|
||||
|
||||
/**
|
||||
* Base58Check encode using the XRPL alphabet. `payload` is the version-prefixed
|
||||
* data; a 4-byte double-SHA256 checksum is appended before encoding. Pure
|
||||
* big-integer base conversion so it matches the Go port byte-for-byte.
|
||||
*/
|
||||
function base58CheckXrpl(payload: Uint8Array): string {
|
||||
const checksum = sha256(sha256(payload)).slice(0, 4);
|
||||
const full = concatBytes(payload, checksum);
|
||||
|
||||
// Big-endian base-256 → base-58 via repeated division.
|
||||
let acc = 0n;
|
||||
for (const b of full) acc = (acc << 8n) | BigInt(b);
|
||||
|
||||
let out = '';
|
||||
while (acc > 0n) {
|
||||
const rem = Number(acc % 58n);
|
||||
acc = acc / 58n;
|
||||
out = XRPL_ALPHABET[rem] + out;
|
||||
}
|
||||
// Each leading zero byte encodes as the alphabet's zeroth character.
|
||||
for (let i = 0; i < full.length && full[i] === 0; i++) {
|
||||
out = XRPL_ALPHABET[0] + out;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the canonical r-address from a 33-byte XRPL public key:
|
||||
* accountID = ripemd160(sha256(pubkey))
|
||||
* address = base58check( 0x00 || accountID )
|
||||
* The FULL 33-byte key (with its 0xED / 0x02 / 0x03 family tag) is hashed —
|
||||
* this matches rippled's AccountID derivation for both key types.
|
||||
*/
|
||||
function deriveAddress(publicKey33: Uint8Array): string {
|
||||
const accountId = ripemd160(sha256(publicKey33));
|
||||
const versioned = concatBytes(Uint8Array.of(ACCOUNT_ID_PREFIX), accountId);
|
||||
return base58CheckXrpl(versioned);
|
||||
}
|
||||
|
||||
export function verifyXrp(proof: SignedProof): boolean {
|
||||
try {
|
||||
if (proof.publicKey == null || proof.publicKey.length === 0) return false;
|
||||
|
||||
const publicKey = hexToBytes(proof.publicKey);
|
||||
if (publicKey.length !== PUBKEY_LEN) return false;
|
||||
|
||||
const messageBytes = utf8ToBytes(proof.message);
|
||||
const sigBytes = decodeSignature(proof.signature);
|
||||
|
||||
// 1. Cryptographic signature check, per scheme.
|
||||
let sigOk: boolean;
|
||||
if (proof.scheme === 'ed25519-xrpl') {
|
||||
// Family tag must be 0xED; verify over the bare 32-byte Edwards key.
|
||||
if (publicKey[0] !== ED25519_PREFIX) return false;
|
||||
if (sigBytes.length !== ED25519_SIG_LEN) return false;
|
||||
const pub32 = publicKey.slice(1);
|
||||
sigOk = ed25519.verify(sigBytes, messageBytes, pub32);
|
||||
} else if (proof.scheme === 'secp256k1-xrpl') {
|
||||
// Compressed point: family tag is 0x02 or 0x03.
|
||||
if (publicKey[0] !== 0x02 && publicKey[0] !== 0x03) return false;
|
||||
const digest = sha512Half(messageBytes);
|
||||
// DER signature over the prehashed digest. lowS:false — rippled does not
|
||||
// require low-S of wallet signatures, and malleability is irrelevant for
|
||||
// a login proof already bound to a server nonce.
|
||||
sigOk = secp256k1.verify(sigBytes, digest, publicKey, {
|
||||
prehash: false,
|
||||
lowS: false,
|
||||
format: 'der',
|
||||
});
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
if (!sigOk) return false;
|
||||
|
||||
// 2. Address binding: the key must derive exactly the claimed r-address.
|
||||
const derived = deriveAddress(publicKey);
|
||||
return derived === proof.address.trim();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"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.1",
|
||||
"version": "0.1.0",
|
||||
"description": "Pluggable identity verification (KYC / KYB / liveness). Provider-agnostic — wires Persona, Onfido, Veriff, Sumsub, or any custom backend behind a single React surface.",
|
||||
"license": "BSD-3-Clause",
|
||||
"type": "module",
|
||||
|
||||
@@ -30,7 +30,7 @@ export type IDVStatus =
|
||||
export interface IDVSubject {
|
||||
/** Stable subject identifier (typically the IAM user id). */
|
||||
readonly subjectId: string
|
||||
/** Org org slug (for multi-org providers). */
|
||||
/** Tenant org slug (for multi-tenant providers). */
|
||||
readonly orgId: string
|
||||
/** Email + display name carried through for vendor pre-fill. */
|
||||
readonly email?: string
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"name": "@hanzo/id-onboarding",
|
||||
"version": "0.1.5",
|
||||
"description": "Post-login onboarding for the Hanzo ID portal: choose/create org → optional project → optional wallet link. White-labeled by host. Domain / service / UI split.",
|
||||
"license": "BSD-3-Clause",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./service": "./src/service/onboarding.ts",
|
||||
"./flow": "./src/ui/OnboardingFlow.tsx",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": ["src", "!src/**/*.test.ts"],
|
||||
"scripts": {
|
||||
"build": "tsc --noEmit",
|
||||
"tc": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hanzo/id-shared": "workspace:*",
|
||||
"@hanzo/iam": "^0.21.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=19",
|
||||
"react-dom": ">=19"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
/**
|
||||
* Onboarding domain types — React-free, serializable.
|
||||
*
|
||||
* The post-login onboarding is a three-step linear flow:
|
||||
*
|
||||
* 1. org — choose an existing org the user already belongs to, or
|
||||
* create a new one. Required (every account needs a home org).
|
||||
* 2. project — create a first project inside the chosen org. Optional
|
||||
* (skippable; the org ships with a default project).
|
||||
* 3. wallet — link a Web3 wallet to the account. Optional (skippable).
|
||||
*
|
||||
* The flow is declared as data here so the UI layer can render it without
|
||||
* the domain importing React. `OnboardingService` (the service layer) does
|
||||
* the actual IAM writes; this module only describes the shape of the flow
|
||||
* and its accumulated state.
|
||||
*/
|
||||
|
||||
/** Identifier for each step in the onboarding flow. */
|
||||
export type StepId = 'org' | 'project' | 'wallet' | 'consent' | 'plan' | 'done'
|
||||
|
||||
/** A step's place in the linear flow. */
|
||||
export interface StepDesc {
|
||||
readonly id: StepId
|
||||
/** Heading shown at the top of the step. */
|
||||
readonly title: string
|
||||
/** One-line subhead under the title. */
|
||||
readonly byline: string
|
||||
/** Whether the user may skip this step (Continue without acting). */
|
||||
readonly skippable: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The canonical step sequence. `done` is a terminal pseudo-step the flow
|
||||
* lands on after `wallet`; it renders the success state and hands control
|
||||
* back to the host via `onComplete`.
|
||||
*/
|
||||
export const STEPS: readonly StepDesc[] = [
|
||||
{
|
||||
id: 'org',
|
||||
title: 'Choose your organization',
|
||||
byline: 'Pick an organization you belong to, or create a new one.',
|
||||
skippable: false,
|
||||
},
|
||||
{
|
||||
id: 'project',
|
||||
title: 'Create your first project',
|
||||
byline: 'Projects group your apps, keys, and usage. You can add more later.',
|
||||
skippable: true,
|
||||
},
|
||||
{
|
||||
id: 'wallet',
|
||||
title: 'Link a wallet',
|
||||
byline: 'Connect a Web3 wallet to sign and pay onchain. Optional.',
|
||||
skippable: true,
|
||||
},
|
||||
{
|
||||
id: 'consent',
|
||||
title: 'Data sharing',
|
||||
byline: 'Choose whether to share usage data to improve the products.',
|
||||
// Not skippable: the agreement needs an explicit ANSWER (yes or no, both
|
||||
// valid), recorded once on the user so it is never re-asked. Skipping is
|
||||
// how this page kept going missing.
|
||||
skippable: false,
|
||||
},
|
||||
{
|
||||
id: 'plan',
|
||||
title: 'Choose how you pay',
|
||||
byline: 'Pick a plan, or pay as you go with a prepaid balance.',
|
||||
// The LAST page, and a required choice: the platform is prepay-only, so an
|
||||
// account is not usable until a plan or a balance exists. "Pay as you go"
|
||||
// IS a choice — there is nothing to skip to.
|
||||
skippable: false,
|
||||
},
|
||||
] as const
|
||||
|
||||
/** A minimal org reference the UI lists in the "choose org" step. */
|
||||
export interface OrgRef {
|
||||
/** IAM org slug (the `<org>` in `<org>-<app>`). */
|
||||
readonly name: string
|
||||
/** Human-facing name; falls back to `name` when unset. */
|
||||
readonly displayName: string
|
||||
}
|
||||
|
||||
/** A minimal project reference returned after creation. */
|
||||
export interface ProjectRef {
|
||||
readonly owner: string
|
||||
readonly name: string
|
||||
readonly displayName: string
|
||||
readonly organization: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Accumulated flow state. Each step writes its result here; the success
|
||||
* screen and `onComplete` read it. Serializable so the host can persist a
|
||||
* resume point if it wants (this pkg does not persist on its own).
|
||||
*/
|
||||
export interface OnboardingState {
|
||||
/** Slug of the org the user landed in (chosen or created). */
|
||||
readonly orgName?: string
|
||||
/** Whether the org was freshly created in this flow (vs. pre-existing). */
|
||||
readonly orgCreated?: boolean
|
||||
/** Name of the project created in step 2, if any. */
|
||||
readonly projectName?: string
|
||||
/** Wallet address linked in step 3, if any. */
|
||||
readonly walletAddress?: string
|
||||
/** The data-sharing answer given in step 4 (true = opted in). */
|
||||
readonly dataSharingConsent?: boolean
|
||||
/**
|
||||
* The payment choice made on the final step: a plan slug from the billing
|
||||
* catalog, or the literal 'payg' for a prepaid pay-as-you-go balance.
|
||||
*/
|
||||
readonly planChoice?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* One purchasable plan as the billing catalog serves it (GET /v1/billing/plans
|
||||
* on the pay origin). Prices are the CATALOG's — this pkg never states one.
|
||||
*/
|
||||
export interface PlanInfo {
|
||||
readonly slug: string
|
||||
readonly name: string
|
||||
readonly description?: string
|
||||
/** Monthly price in CENTS (the catalog's `price` — 900 = $9/mo). */
|
||||
readonly priceCents: number
|
||||
/**
|
||||
* Monthly-equivalent price in CENTS when billed annually (the catalog's
|
||||
* `priceAnnual` — 825 = $8.25/mo ≈ $99/yr). Absent when the plan has no
|
||||
* annual rate.
|
||||
*/
|
||||
readonly priceAnnualCents?: number
|
||||
readonly popular?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* User-record keys the onboarding persists under `Properties`. ONE writer
|
||||
* (saveOnboarding) and one reader (readOnboarding); the names are part of the
|
||||
* user record's public shape, so change them never.
|
||||
*/
|
||||
export const PROP_COMPLETED = 'onboarding.completedAt'
|
||||
export const PROP_CONSENT = 'onboarding.dataSharingConsent'
|
||||
export const PROP_PLAN = 'onboarding.plan'
|
||||
|
||||
/** Resolve a step descriptor by id. */
|
||||
export function stepById(id: StepId): StepDesc | undefined {
|
||||
return STEPS.find((s) => s.id === id)
|
||||
}
|
||||
|
||||
/** The step that follows `id` in the linear flow (`done` is terminal). */
|
||||
export function nextStep(id: StepId): StepId {
|
||||
if (id === 'done') return 'done'
|
||||
const i = STEPS.findIndex((s) => s.id === id)
|
||||
if (i < 0 || i + 1 >= STEPS.length) return 'done'
|
||||
return STEPS[i + 1]!.id
|
||||
}
|
||||
|
||||
/** The step that precedes `id`, or undefined at the first step. */
|
||||
export function prevStep(id: StepId): StepId | undefined {
|
||||
const i = STEPS.findIndex((s) => s.id === id)
|
||||
if (i <= 0) return undefined
|
||||
return STEPS[i - 1]!.id
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
// @hanzo/id-onboarding — post-login onboarding for the Hanzo ID portal.
|
||||
//
|
||||
// Five-step flow: choose/create org → optional project → optional wallet
|
||||
// link → data-sharing consent → plan or pay-as-you-go. White-labeled by the
|
||||
// host's brand name. Domain (serializable types + step machine) / service
|
||||
// (IAM-backed writes) / UI (self-contained flow) split. Auth lives in
|
||||
// @hanzo/id-auth — import login/signup from there.
|
||||
|
||||
// ── Domain ──────────────────────────────────────────────────────
|
||||
export {
|
||||
STEPS,
|
||||
stepById,
|
||||
nextStep,
|
||||
prevStep,
|
||||
type StepId,
|
||||
type StepDesc,
|
||||
type OrgRef,
|
||||
type ProjectRef,
|
||||
type OnboardingState,
|
||||
type PlanInfo,
|
||||
} from './domain/types'
|
||||
|
||||
// ── Service ─────────────────────────────────────────────────────
|
||||
export {
|
||||
createOnboardingService,
|
||||
type OnboardingService,
|
||||
type OnboardingServiceOptions,
|
||||
type Result,
|
||||
} from './service/onboarding'
|
||||
|
||||
// ── UI ──────────────────────────────────────────────────────────
|
||||
export { OnboardingFlow, type OnboardingFlowProps } from './ui/OnboardingFlow'
|
||||
@@ -1,252 +0,0 @@
|
||||
/**
|
||||
* Onboarding unit tests — run with the Node built-in test runner and native
|
||||
* TypeScript stripping (no test-framework dependency):
|
||||
*
|
||||
* node --test --experimental-strip-types src/onboarding.test.ts
|
||||
*
|
||||
* Covers the React-free surface: the domain step machine and the service's
|
||||
* request shaping + IAM response translation (with an injected fake fetch).
|
||||
*/
|
||||
import { test } from 'vitest'
|
||||
import assert from 'node:assert/strict'
|
||||
import { STEPS, stepById, nextStep, prevStep } from './domain/types.ts'
|
||||
import { createOnboardingService } from './service/onboarding.ts'
|
||||
|
||||
// ── Domain: step machine ────────────────────────────────────────────
|
||||
|
||||
test('step machine walks org → project → wallet → consent → plan → done', () => {
|
||||
assert.equal(STEPS[0]!.id, 'org')
|
||||
assert.equal(nextStep('org'), 'project')
|
||||
assert.equal(nextStep('project'), 'wallet')
|
||||
assert.equal(nextStep('wallet'), 'consent')
|
||||
assert.equal(nextStep('consent'), 'plan')
|
||||
assert.equal(nextStep('plan'), 'done')
|
||||
assert.equal(nextStep('done'), 'done') // terminal is a fixpoint
|
||||
})
|
||||
|
||||
test('prevStep is the inverse within the flow, undefined at the head', () => {
|
||||
assert.equal(prevStep('org'), undefined)
|
||||
assert.equal(prevStep('project'), 'org')
|
||||
assert.equal(prevStep('wallet'), 'project')
|
||||
assert.equal(prevStep('consent'), 'wallet')
|
||||
assert.equal(prevStep('plan'), 'consent')
|
||||
})
|
||||
|
||||
test('project and wallet are skippable; org, consent and plan are not', () => {
|
||||
assert.equal(stepById('org')!.skippable, false)
|
||||
assert.equal(stepById('project')!.skippable, true)
|
||||
assert.equal(stepById('wallet')!.skippable, true)
|
||||
// Consent needs an ANSWER (either answer) and plan is the prepay gate —
|
||||
// neither may be walked past. plan is LAST so the choice hands straight
|
||||
// off to the pay surface.
|
||||
assert.equal(stepById('consent')!.skippable, false)
|
||||
assert.equal(stepById('plan')!.skippable, false)
|
||||
assert.equal(STEPS[STEPS.length - 1]!.id, 'plan')
|
||||
})
|
||||
|
||||
// ── Service: fake-fetch harness ─────────────────────────────────────
|
||||
|
||||
interface Recorded {
|
||||
url: string
|
||||
method: string
|
||||
body?: string
|
||||
headers: Record<string, string>
|
||||
}
|
||||
|
||||
/** Build a service whose fetch records calls and returns scripted JSON. */
|
||||
function harness(script: (rec: Recorded) => { status?: number; json: unknown }) {
|
||||
const calls: Recorded[] = []
|
||||
const fetchImpl = (async (input: string | URL, init?: RequestInit) => {
|
||||
const headers: Record<string, string> = {}
|
||||
const h = init?.headers as Record<string, string> | undefined
|
||||
if (h) for (const k of Object.keys(h)) headers[k] = h[k]!
|
||||
const rec: Recorded = {
|
||||
url: String(input),
|
||||
method: init?.method ?? 'GET',
|
||||
body: typeof init?.body === 'string' ? init.body : undefined,
|
||||
headers,
|
||||
}
|
||||
calls.push(rec)
|
||||
const { status = 200, json } = script(rec)
|
||||
return new Response(JSON.stringify(json), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}) as unknown as typeof fetch
|
||||
|
||||
const service = createOnboardingService({
|
||||
iamUrl: 'https://hanzo.id',
|
||||
orgId: 'hanzo',
|
||||
getAccessToken: () => 'tok-123',
|
||||
fetchImpl,
|
||||
})
|
||||
return { service, calls }
|
||||
}
|
||||
|
||||
test('listOrgs hits get-organizations with the bearer token and maps rows', async () => {
|
||||
const { service, calls } = harness(() => ({
|
||||
json: { status: 'ok', data: [{ name: 'hanzo', displayName: 'Hanzo' }, { name: 'acme' }] },
|
||||
}))
|
||||
const orgs = await service.listOrgs()
|
||||
assert.equal(calls[0]!.url, 'https://hanzo.id/v1/iam/get-organizations')
|
||||
assert.equal(calls[0]!.headers.Authorization, 'Bearer tok-123')
|
||||
assert.deepEqual(orgs, [
|
||||
{ name: 'hanzo', displayName: 'Hanzo' },
|
||||
{ name: 'acme', displayName: 'acme' }, // displayName falls back to name
|
||||
])
|
||||
})
|
||||
|
||||
test('listOrgs decodes rows from the legacy data2 slot until IAM stops emitting it', async () => {
|
||||
const { service } = harness(() => ({ json: { status: 'ok', data2: [{ name: 'acme' }] } }))
|
||||
assert.deepEqual(await service.listOrgs(), [{ name: 'acme', displayName: 'acme' }])
|
||||
})
|
||||
|
||||
test('listOrgs returns [] (not throw) on a server error', async () => {
|
||||
const { service } = harness(() => ({ status: 500, json: { status: 'error', msg: 'boom' } }))
|
||||
assert.deepEqual(await service.listOrgs(), [])
|
||||
})
|
||||
|
||||
// Founding an org goes through the SELF-SERVICE front door, never the
|
||||
// add-organization admin verb — that one is bearer-only entity CRUD filed under
|
||||
// owner "admin", so a person founding their first org gets 401/403 there. This is
|
||||
// the regression guard for the hanzo.id/onboarding "HTTP 401".
|
||||
test('createOrg founds the org through /v1/iam/onboard, never the admin verb', async () => {
|
||||
const ok = harness(() => ({ json: { org: 'acme', accessKey: 'pk-live-x' } }))
|
||||
const res = await ok.service.createOrg({ name: 'acme', displayName: 'Acme Inc' })
|
||||
assert.equal(ok.calls[0]!.url, 'https://hanzo.id/v1/iam/onboard')
|
||||
assert.equal(ok.calls[0]!.method, 'POST')
|
||||
assert.ok(!ok.calls.some((c) => c.url.includes('add-organization')))
|
||||
// The DISPLAY name is what travels: the server owns the slug policy.
|
||||
assert.deepEqual(JSON.parse(ok.calls[0]!.body!), { name: 'Acme Inc' })
|
||||
// …and the slug it answers with is authoritative, not the client's guess.
|
||||
assert.deepEqual(res, { ok: true, value: { name: 'acme', displayName: 'Acme Inc' } })
|
||||
})
|
||||
|
||||
test('createOrg carries BOTH credentials — the portal session mints no bearer', async () => {
|
||||
const { service, calls } = harness(() => ({ json: { org: 'acme' } }))
|
||||
await service.createOrg({ name: 'acme', displayName: 'Acme Inc' })
|
||||
assert.equal(calls[0]!.headers.Authorization, 'Bearer tok-123')
|
||||
assert.equal(calls[0]!.headers['Content-Type'], 'application/json')
|
||||
})
|
||||
|
||||
test('createOrg surfaces the front 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 () => {
|
||||
const { service, calls } = harness(() => ({ json: { status: 'ok' } }))
|
||||
const res = await service.linkWallet('not-an-address')
|
||||
assert.deepEqual(res, { ok: false, error: 'invalid wallet address' })
|
||||
assert.equal(calls.length, 0)
|
||||
})
|
||||
|
||||
// IAM's update-user is a FULL-ROW write that ignores `columns=` — a minimal
|
||||
// body silently blanks every field it omits. So the contract under test is
|
||||
// read-merge-write: the row that comes back from get-account goes back OUT
|
||||
// with only the mutation applied. This is the regression guard for the wallet
|
||||
// step wiping displayName/email on every link.
|
||||
test('linkWallet reads the full row and writes it back with web3onboard merged in', async () => {
|
||||
const addr = '0x' + 'a'.repeat(40)
|
||||
const { service, calls } = harness((rec) => {
|
||||
if (rec.url.includes('get-account'))
|
||||
return {
|
||||
json: {
|
||||
status: 'ok',
|
||||
data: { owner: 'hanzo', name: 'alice', displayName: 'Alice', email: 'alice@hanzo.ai' },
|
||||
},
|
||||
}
|
||||
return { json: { status: 'ok' } }
|
||||
})
|
||||
const res = await service.linkWallet(addr)
|
||||
assert.deepEqual(res, { ok: true, value: addr })
|
||||
// 1) get-account, 2) update-user keyed by owner/name with the WHOLE row
|
||||
assert.match(calls[0]!.url, /get-account$/)
|
||||
const upd = calls[1]!
|
||||
assert.ok(upd.url.includes('/v1/iam/update-user'))
|
||||
assert.ok(upd.url.includes('id=hanzo%2Falice') || upd.url.includes('id=hanzo/alice'))
|
||||
const sent = JSON.parse(upd.body!)
|
||||
assert.equal(sent.web3onboard, addr)
|
||||
assert.equal(sent.owner, 'hanzo')
|
||||
assert.equal(sent.name, 'alice')
|
||||
// The fields the mutation did not touch MUST survive the round trip.
|
||||
assert.equal(sent.displayName, 'Alice')
|
||||
assert.equal(sent.email, 'alice@hanzo.ai')
|
||||
})
|
||||
|
||||
test('saveOnboarding merges properties without dropping existing ones; readOnboarding decodes them', async () => {
|
||||
const { service, calls } = harness((rec) => {
|
||||
if (rec.url.includes('get-account'))
|
||||
return {
|
||||
json: {
|
||||
status: 'ok',
|
||||
data: {
|
||||
owner: 'hanzo',
|
||||
name: 'alice',
|
||||
properties: { 'onboarding.dataSharingConsent': 'true', unrelated: 'kept' },
|
||||
},
|
||||
},
|
||||
}
|
||||
return { json: { status: 'ok' } }
|
||||
})
|
||||
const res = await service.saveOnboarding({ plan: 'pro', completedAt: '2026-08-04T00:00:00Z' })
|
||||
assert.deepEqual(res, { ok: true, value: true })
|
||||
const sent = JSON.parse(calls[1]!.body!)
|
||||
assert.deepEqual(sent.properties, {
|
||||
'onboarding.dataSharingConsent': 'true',
|
||||
unrelated: 'kept',
|
||||
'onboarding.plan': 'pro',
|
||||
'onboarding.completedAt': '2026-08-04T00:00:00Z',
|
||||
})
|
||||
})
|
||||
|
||||
test('readOnboarding reports null completedAt/consent/plan for a fresh user', async () => {
|
||||
const { service } = harness(() => ({ json: { status: 'ok', data: { owner: 'hanzo', name: 'bob' } } }))
|
||||
assert.deepEqual(await service.readOnboarding(), { completedAt: null, consent: null, plan: null })
|
||||
})
|
||||
|
||||
// The live catalog prices in CENTS (go=900 means $9/mo, priceAnnual=825 means
|
||||
// $8.25/mo billed annually) and carries other product lines (dns-*) in the
|
||||
// same list. This test pins both facts with production-shaped rows.
|
||||
test('listPlans keeps cents unscaled, keeps personal+team only; [] on failure', async () => {
|
||||
const { service, calls } = harness(() => ({
|
||||
json: [
|
||||
{ slug: 'pro', name: 'Pro', category: 'personal', price: 4900, priceAnnual: 4150, popular: true },
|
||||
{ slug: 'go', name: 'Go', category: 'personal', price: 900, priceAnnual: 825 },
|
||||
{ slug: 'team', name: 'Team', category: 'team', price: 2500, priceAnnual: 2000 },
|
||||
{ slug: 'dns-pro', name: 'DNS Pro', category: 'dns', price: 500 }, // other product line → dropped
|
||||
{ slug: 'enterprise', name: 'Enterprise', category: 'enterprise', price: 0 }, // not self-serve → dropped
|
||||
{ slug: '', name: 'broken', category: 'personal', price: 500 }, // no slug → dropped
|
||||
],
|
||||
}))
|
||||
const plans = await service.listPlans('https://pay.hanzo.ai/')
|
||||
assert.equal(calls[0]!.url, 'https://pay.hanzo.ai/v1/billing/plans')
|
||||
assert.deepEqual(
|
||||
plans.map((p) => p.slug),
|
||||
['pro', 'go', 'team'],
|
||||
)
|
||||
assert.equal(plans[0]!.priceCents, 4900)
|
||||
assert.equal(plans[0]!.priceAnnualCents, 4150)
|
||||
assert.equal(plans[0]!.popular, true)
|
||||
|
||||
const down = harness(() => ({ status: 503, json: { error: 'nope' } }))
|
||||
assert.deepEqual(await down.service.listPlans('https://pay.hanzo.ai'), [])
|
||||
})
|
||||
|
||||
test('linkWallet fails closed when there is no signed-in user', async () => {
|
||||
const addr = '0x' + 'b'.repeat(40)
|
||||
const { service } = harness((rec) => {
|
||||
if (rec.url.includes('get-account')) return { status: 401, json: { status: 'error', msg: 'not signed in' } }
|
||||
return { json: { status: 'ok' } }
|
||||
})
|
||||
assert.deepEqual(await service.linkWallet(addr), { ok: false, error: 'not signed in' })
|
||||
})
|
||||
@@ -1,335 +0,0 @@
|
||||
/**
|
||||
* Onboarding service — the IAM-backed implementation of the org/project/
|
||||
* wallet flow.
|
||||
*
|
||||
* One way: every write goes through the canonical IAM REST surface under
|
||||
* `/v1/iam/*` (the same IAM paths the auth client uses). There is no separate
|
||||
* onboarding backend — the org and project records live in IAM, which is the
|
||||
* identity registry.
|
||||
*
|
||||
* listOrgs() GET /v1/iam/get-organizations (user-scoped server-side)
|
||||
* createOrg() POST /v1/iam/onboard (the self-service front door)
|
||||
* createProject POST /v1/iam/add-project
|
||||
* linkWallet() client-side wallet connect → IAM update-user (host-driven)
|
||||
*
|
||||
* Founding an org goes through `onboard`, NOT the `add-organization` admin verb.
|
||||
* They are different doors: add-organization is entity CRUD behind IAM's
|
||||
* authenticated Guard, filed under owner "admin", and a human may only write an
|
||||
* org row named after the org they are already in — so a person founding their
|
||||
* FIRST org is refused there by construction (403), and with no bearer at all the
|
||||
* Guard refuses before that (401). `onboard` is the door built for this: it
|
||||
* resolves the caller from their own session or bearer and provisions the whole
|
||||
* org — org stamped with them as Founder, them moved in as its owner, one
|
||||
* metered API key — under their own authority as its founder.
|
||||
*
|
||||
* Both credentials are offered on every call: `credentials: 'include'` for the
|
||||
* portal session cookie (a bare portal sign-in mints NO bearer, which is why the
|
||||
* bearer-only door 401'd), and `Authorization` when the host does hold a token.
|
||||
* IAM resolves session first, then bearer.
|
||||
*/
|
||||
import type { Project } from '@hanzo/iam'
|
||||
import {
|
||||
PROP_COMPLETED,
|
||||
PROP_CONSENT,
|
||||
PROP_PLAN,
|
||||
type OrgRef,
|
||||
type PlanInfo,
|
||||
type ProjectRef,
|
||||
} from '../domain/types'
|
||||
|
||||
/** Result of a write that can fail gracefully (no throw on expected errors). */
|
||||
export type Result<T> = { readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: string }
|
||||
|
||||
export interface OnboardingService {
|
||||
/**
|
||||
* List organizations the signed-in user can land in. IAM scopes
|
||||
* `get-organizations` to the caller's memberships server-side from the
|
||||
* bearer token. Returns [] (not an error) when the user belongs to none.
|
||||
*/
|
||||
listOrgs(): Promise<OrgRef[]>
|
||||
/** Create a new organization owned by the user. */
|
||||
createOrg(input: { name: string; displayName: string }): Promise<Result<OrgRef>>
|
||||
/** Create a project inside `organization`. */
|
||||
createProject(input: { organization: string; name: string; displayName: string }): Promise<Result<ProjectRef>>
|
||||
/**
|
||||
* Attach a wallet address to the signed-in user (IAM `update-user`,
|
||||
* `web3Onboard` address field). The actual wallet connect happens in the
|
||||
* browser via the host-supplied `connectWallet`; this only persists the
|
||||
* resulting address.
|
||||
*/
|
||||
linkWallet(address: string): Promise<Result<string>>
|
||||
/**
|
||||
* Read the persisted onboarding record from the signed-in user's
|
||||
* `properties`. All-null when the user has never completed onboarding —
|
||||
* which is the ONLY case the host should mount the flow for.
|
||||
*/
|
||||
readOnboarding(): Promise<{ completedAt: string | null; consent: boolean | null; plan: string | null }>
|
||||
/**
|
||||
* Persist onboarding fields onto the user record, read-merge-write. THIS is
|
||||
* what stops the flow repeating: completion lives on the USER, not in any
|
||||
* browser storage, so a new device, a cleared cache and a re-login all see
|
||||
* it done.
|
||||
*/
|
||||
saveOnboarding(patch: { completedAt?: string; consent?: boolean; plan?: string }): Promise<Result<true>>
|
||||
/**
|
||||
* List purchasable plans from the billing catalog on the PAY origin. The
|
||||
* catalog is the only price authority — this pkg renders what it serves and
|
||||
* states no price of its own. Returns [] on any failure; the plan step then
|
||||
* offers the two choices without a price grid.
|
||||
*/
|
||||
listPlans(payUrl: string): Promise<PlanInfo[]>
|
||||
}
|
||||
|
||||
export interface OnboardingServiceOptions {
|
||||
/** IAM origin, no trailing slash (the org's `iamUrl`, i.e. hanzo.id). */
|
||||
readonly iamUrl: string
|
||||
/** Owning org slug used as the default `owner` for new records. */
|
||||
readonly orgId: string
|
||||
/** Bearer-token provider; resolves null when no session is present. */
|
||||
readonly getAccessToken: () => Promise<string | null> | string | null
|
||||
/** Override fetch (testing). Defaults to global fetch. */
|
||||
readonly fetchImpl?: typeof fetch
|
||||
}
|
||||
|
||||
const trimSlash = (s: string): string => s.replace(/\/+$/, '')
|
||||
|
||||
export function createOnboardingService(opts: OnboardingServiceOptions): OnboardingService {
|
||||
const base = trimSlash(opts.iamUrl)
|
||||
const f = opts.fetchImpl ?? fetch
|
||||
|
||||
async function authHeaders(json = true): Promise<HeadersInit> {
|
||||
const token = await opts.getAccessToken()
|
||||
const h: Record<string, string> = { Accept: 'application/json' }
|
||||
if (json) h['Content-Type'] = 'application/json'
|
||||
if (token) h.Authorization = `Bearer ${token}`
|
||||
return h
|
||||
}
|
||||
|
||||
async function listOrgs(): Promise<OrgRef[]> {
|
||||
const url = new URL('/v1/iam/get-organizations', base)
|
||||
let body: Record<string, unknown>
|
||||
try {
|
||||
const res = await f(url.toString(), { headers: await authHeaders(false), credentials: 'include' })
|
||||
if (!res.ok) return []
|
||||
body = (await res.json()) as Record<string, unknown>
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
const rows = extractRows(body)
|
||||
return rows.map(toOrgRef).filter((o): o is OrgRef => o !== null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Found the caller's own organization through the self-service front door.
|
||||
*
|
||||
* The server owns the slug: it derives it from the display name under the ONE
|
||||
* policy every surface shares, so the returned `org` is authoritative and the
|
||||
* client's slug preview is only a preview. It answers `{org}` on success and
|
||||
* `{error}` with a 4xx/5xx on failure — not the casibase `{status,msg}`
|
||||
* envelope the entity CRUD returns — so read it directly.
|
||||
*/
|
||||
async function createOrg(input: { name: string; displayName: string }): Promise<Result<OrgRef>> {
|
||||
const url = new URL('/v1/iam/onboard', base)
|
||||
const displayName = input.displayName || input.name
|
||||
try {
|
||||
const res = await f(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: await authHeaders(),
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ name: displayName }),
|
||||
})
|
||||
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>
|
||||
if (!res.ok) {
|
||||
const msg = typeof body.error === 'string' && body.error ? body.error : `HTTP ${res.status}`
|
||||
return { ok: false, error: msg }
|
||||
}
|
||||
const org = typeof body.org === 'string' ? body.org : ''
|
||||
if (!org) return { ok: false, error: 'request failed' }
|
||||
return { ok: true, value: { name: org, displayName } }
|
||||
} catch (e) {
|
||||
return { ok: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async function createProject(input: {
|
||||
organization: string
|
||||
name: string
|
||||
displayName: string
|
||||
}): Promise<Result<ProjectRef>> {
|
||||
const url = new URL('/v1/iam/add-project', base)
|
||||
const project: Partial<Project> = {
|
||||
owner: input.organization,
|
||||
name: input.name,
|
||||
displayName: input.displayName,
|
||||
organization: input.organization,
|
||||
isDefault: false,
|
||||
}
|
||||
return writeRecord(url, project, () => ({
|
||||
owner: input.organization,
|
||||
name: input.name,
|
||||
displayName: input.displayName,
|
||||
organization: input.organization,
|
||||
}))
|
||||
}
|
||||
|
||||
async function linkWallet(address: string): Promise<Result<string>> {
|
||||
const trimmed = address.trim()
|
||||
if (!isHexAddress(trimmed)) return { ok: false, error: 'invalid wallet address' }
|
||||
const res = await updateSelf((row) => {
|
||||
row.web3onboard = trimmed
|
||||
})
|
||||
return res.ok ? { ok: true, value: trimmed } : res
|
||||
}
|
||||
|
||||
async function readOnboarding(): Promise<{
|
||||
completedAt: string | null
|
||||
consent: boolean | null
|
||||
plan: string | null
|
||||
}> {
|
||||
const row = await getAccount()
|
||||
const props = (row?.properties ?? {}) as Record<string, unknown>
|
||||
const str = (k: string): string | null => (typeof props[k] === 'string' && props[k] ? (props[k] as string) : null)
|
||||
const consentRaw = str(PROP_CONSENT)
|
||||
return {
|
||||
completedAt: str(PROP_COMPLETED),
|
||||
consent: consentRaw === null ? null : consentRaw === 'true',
|
||||
plan: str(PROP_PLAN),
|
||||
}
|
||||
}
|
||||
|
||||
async function saveOnboarding(patch: {
|
||||
completedAt?: string
|
||||
consent?: boolean
|
||||
plan?: string
|
||||
}): Promise<Result<true>> {
|
||||
const res = await updateSelf((row) => {
|
||||
const props = { ...((row.properties as Record<string, string> | undefined) ?? {}) }
|
||||
if (patch.completedAt !== undefined) props[PROP_COMPLETED] = patch.completedAt
|
||||
if (patch.consent !== undefined) props[PROP_CONSENT] = String(patch.consent)
|
||||
if (patch.plan !== undefined) props[PROP_PLAN] = patch.plan
|
||||
row.properties = props
|
||||
})
|
||||
return res.ok ? { ok: true, value: true } : res
|
||||
}
|
||||
|
||||
async function listPlans(payUrl: string): Promise<PlanInfo[]> {
|
||||
try {
|
||||
const res = await f(trimSlash(payUrl) + '/v1/billing/plans', { headers: { Accept: 'application/json' } })
|
||||
if (!res.ok) return []
|
||||
const body = (await res.json()) as unknown
|
||||
const rows = Array.isArray(body) ? body : []
|
||||
return rows
|
||||
.filter((r): r is Record<string, unknown> => typeof r === 'object' && r !== null)
|
||||
// Onboarding offers the account plans; other product lines in the same
|
||||
// catalog (dns-*, enterprise) have their own surfaces.
|
||||
.filter((r) => r.category === 'personal' || r.category === 'team')
|
||||
.map((r) => ({
|
||||
slug: typeof r.slug === 'string' ? r.slug : '',
|
||||
name: typeof r.name === 'string' ? r.name : '',
|
||||
description: typeof r.description === 'string' ? r.description : undefined,
|
||||
// Catalog prices are CENTS (900 = $9/mo); passed through unscaled.
|
||||
priceCents: typeof r.price === 'number' ? r.price : NaN,
|
||||
priceAnnualCents: typeof r.priceAnnual === 'number' && r.priceAnnual > 0 ? r.priceAnnual : undefined,
|
||||
popular: r.popular === true,
|
||||
}))
|
||||
.filter((p) => p.slug && p.name && Number.isFinite(p.priceCents) && p.priceCents > 0)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-merge-write the signed-in user's FULL row. IAM's update-user is a
|
||||
* FULL-ROW write (internal/users Update: "this is a full-row write") and it
|
||||
* ignores the v1 `columns=` scoping param — so a minimal body silently
|
||||
* blanks every field it omits. The wallet step used to do exactly that,
|
||||
* wiping displayName/email on every link. Every self-write goes through
|
||||
* here now: fetch the row, mutate, post the whole thing back.
|
||||
*/
|
||||
async function updateSelf(mutate: (row: Record<string, unknown>) => void): Promise<Result<true>> {
|
||||
const row = await getAccount()
|
||||
if (!row) return { ok: false, error: 'not signed in' }
|
||||
const owner = typeof row.owner === 'string' ? row.owner : ''
|
||||
const name = typeof row.name === 'string' ? row.name : ''
|
||||
if (!owner || !name) return { ok: false, error: 'not signed in' }
|
||||
mutate(row)
|
||||
row.owner = owner
|
||||
row.name = name
|
||||
const url = new URL('/v1/iam/update-user', base)
|
||||
url.searchParams.set('id', `${owner}/${name}`)
|
||||
try {
|
||||
const res = await f(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: await authHeaders(),
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(row),
|
||||
})
|
||||
if (!res.ok) return { ok: false, error: `HTTP ${res.status}` }
|
||||
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>
|
||||
if (body.status === 'error') return { ok: false, error: msgOf(body) }
|
||||
return { ok: true, value: true }
|
||||
} catch (e) {
|
||||
return { ok: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the signed-in user's FULL row from `/v1/iam/get-account`. */
|
||||
async function getAccount(): Promise<Record<string, unknown> | null> {
|
||||
const url = new URL('/v1/iam/get-account', base)
|
||||
try {
|
||||
const res = await f(url.toString(), { headers: await authHeaders(false), credentials: 'include' })
|
||||
if (!res.ok) return null
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
const data = (body.data ?? body) as Record<string, unknown>
|
||||
if (typeof data !== 'object' || data === null) return null
|
||||
return data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function writeRecord<T>(
|
||||
url: URL,
|
||||
payload: unknown,
|
||||
onOk: () => T,
|
||||
): Promise<Result<T>> {
|
||||
try {
|
||||
const res = await f(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: await authHeaders(),
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
if (!res.ok) return { ok: false, error: `HTTP ${res.status}` }
|
||||
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>
|
||||
if (body.status === 'error') return { ok: false, error: msgOf(body) }
|
||||
return { ok: true, value: onOk() }
|
||||
} catch (e) {
|
||||
return { ok: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
return { listOrgs, createOrg, createProject, linkWallet, readOnboarding, saveOnboarding, listPlans }
|
||||
}
|
||||
|
||||
/** Rows of an IAM list response: the named `data` slot, falling back to the legacy `data2` slot until IAM stops emitting it. */
|
||||
function extractRows(body: Record<string, unknown>): Record<string, unknown>[] {
|
||||
const candidate = Array.isArray(body.data) ? body.data : Array.isArray(body.data2) ? body.data2 : []
|
||||
return candidate.filter((r): r is Record<string, unknown> => typeof r === 'object' && r !== null)
|
||||
}
|
||||
|
||||
function toOrgRef(row: Record<string, unknown>): OrgRef | null {
|
||||
const name = typeof row.name === 'string' ? row.name : ''
|
||||
if (!name) return null
|
||||
const displayName = typeof row.displayName === 'string' && row.displayName ? row.displayName : name
|
||||
return { name, displayName }
|
||||
}
|
||||
|
||||
function msgOf(body: Record<string, unknown>): string {
|
||||
return typeof body.msg === 'string' && body.msg ? body.msg : 'request failed'
|
||||
}
|
||||
|
||||
/** EIP-55-agnostic 0x-prefixed 20-byte address check. */
|
||||
function isHexAddress(s: string): boolean {
|
||||
return /^0x[0-9a-fA-F]{40}$/.test(s)
|
||||
}
|
||||
@@ -1,604 +0,0 @@
|
||||
import { useCallback, useEffect, useReducer, useState, type FormEvent } from 'react'
|
||||
import {
|
||||
STEPS,
|
||||
nextStep,
|
||||
prevStep,
|
||||
stepById,
|
||||
type OnboardingState,
|
||||
type PlanInfo,
|
||||
type StepId,
|
||||
} from '../domain/types'
|
||||
import type { OnboardingService } from '../service/onboarding'
|
||||
|
||||
/**
|
||||
* Post-login onboarding flow.
|
||||
*
|
||||
* A self-contained three-step wizard (org → project → wallet) driven by an
|
||||
* internal step machine — no router lib, consistent with the rest of the
|
||||
* portal which routes on `window.location` and keeps page-local state in
|
||||
* React. The host renders this once after login and gets the accumulated
|
||||
* {@link OnboardingState} back via `onComplete`.
|
||||
*
|
||||
* White-label: all copy comes from the domain `STEPS` table + the `brandName`
|
||||
* prop. No brand-specific strings live in this component. Styling reuses the
|
||||
* portal's `hanzo-id-*` classes (defined in the web app's app.css).
|
||||
*/
|
||||
export interface OnboardingFlowProps {
|
||||
readonly service: OnboardingService
|
||||
/** Brand display name for headings (e.g. the resolved org brand). */
|
||||
readonly brandName: string
|
||||
/**
|
||||
* Host-supplied wallet connector. Returns the connected address (0x…) or
|
||||
* null if the user cancels. Kept as a prop so this pkg stays free of any
|
||||
* specific wallet library — the host wires Web3Onboard / wagmi / window
|
||||
* .ethereum. When omitted, the wallet step shows a "not available" note
|
||||
* and can only be skipped.
|
||||
*/
|
||||
readonly connectWallet?: () => Promise<string | null>
|
||||
/** Called once the flow reaches `done`, with the final accumulated state. */
|
||||
readonly onComplete: (state: OnboardingState) => void
|
||||
/**
|
||||
* Pay origin serving the billing catalog (GET /v1/billing/plans). The plan
|
||||
* step renders the catalog's own prices — no price is stated here.
|
||||
*/
|
||||
readonly payUrl: string
|
||||
}
|
||||
|
||||
interface FlowState {
|
||||
readonly step: StepId
|
||||
readonly data: OnboardingState
|
||||
}
|
||||
|
||||
type FlowAction =
|
||||
| { type: 'advance'; patch: Partial<OnboardingState> }
|
||||
| { type: 'back' }
|
||||
|
||||
function reducer(state: FlowState, action: FlowAction): FlowState {
|
||||
switch (action.type) {
|
||||
case 'advance':
|
||||
return { step: nextStep(state.step), data: { ...state.data, ...action.patch } }
|
||||
case 'back': {
|
||||
const prev = prevStep(state.step)
|
||||
return prev ? { ...state, step: prev } : state
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function OnboardingFlow({ service, brandName, connectWallet, onComplete, payUrl }: OnboardingFlowProps) {
|
||||
const [state, dispatch] = useReducer(reducer, { step: 'org', data: {} })
|
||||
|
||||
// Terminal step: hand the accumulated state back to the host exactly once.
|
||||
useEffect(() => {
|
||||
if (state.step === 'done') onComplete(state.data)
|
||||
}, [state.step, state.data, onComplete])
|
||||
|
||||
const advance = useCallback((patch: Partial<OnboardingState>) => dispatch({ type: 'advance', patch }), [])
|
||||
const back = useCallback(() => dispatch({ type: 'back' }), [])
|
||||
|
||||
const desc = stepById(state.step)
|
||||
const stepIndex = STEPS.findIndex((s) => s.id === state.step)
|
||||
const showBack = stepIndex > 0
|
||||
|
||||
return (
|
||||
<div className="hanzo-id-onboarding">
|
||||
{state.step !== 'done' && desc ? (
|
||||
<>
|
||||
<StepDots active={stepIndex} total={STEPS.length} />
|
||||
<header className="hanzo-id-onboarding-head">
|
||||
<h1>{desc.title}</h1>
|
||||
<p className="lede">{desc.byline}</p>
|
||||
</header>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{state.step === 'org' ? (
|
||||
<OrgStep service={service} onNext={advance} />
|
||||
) : null}
|
||||
{state.step === 'project' ? (
|
||||
<ProjectStep
|
||||
service={service}
|
||||
orgName={state.data.orgName}
|
||||
showBack={showBack}
|
||||
onBack={back}
|
||||
onNext={advance}
|
||||
/>
|
||||
) : null}
|
||||
{state.step === 'wallet' ? (
|
||||
<WalletStep
|
||||
service={service}
|
||||
connectWallet={connectWallet}
|
||||
showBack={showBack}
|
||||
onBack={back}
|
||||
onNext={advance}
|
||||
/>
|
||||
) : null}
|
||||
{state.step === 'consent' ? (
|
||||
<ConsentStep service={service} showBack={showBack} onBack={back} onNext={advance} />
|
||||
) : null}
|
||||
{state.step === 'plan' ? (
|
||||
<PlanStep service={service} payUrl={payUrl} showBack={showBack} onBack={back} onNext={advance} />
|
||||
) : null}
|
||||
{state.step === 'done' ? <DoneStep brandName={brandName} data={state.data} /> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Linear progress dots. */
|
||||
function StepDots({ active, total }: { active: number; total: number }) {
|
||||
return (
|
||||
<div className="hanzo-id-stepdots" role="progressbar" aria-valuenow={active + 1} aria-valuemax={total}>
|
||||
{Array.from({ length: total }, (_, i) => (
|
||||
<span key={i} className={i <= active ? 'on' : ''} aria-hidden />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Step 1: organization ────────────────────────────────────────────
|
||||
|
||||
function OrgStep({
|
||||
service,
|
||||
onNext,
|
||||
}: {
|
||||
service: OnboardingService
|
||||
onNext: (patch: Partial<OnboardingState>) => void
|
||||
}) {
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function create(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
const name = slugify(displayName)
|
||||
if (!name) {
|
||||
setError('Enter an organization name.')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
const res = await service.createOrg({ name, displayName: displayName.trim() })
|
||||
setBusy(false)
|
||||
if (!res.ok) {
|
||||
setError(humanizeError(res.error))
|
||||
return
|
||||
}
|
||||
onNext({ orgName: res.value.name, orgCreated: true })
|
||||
}
|
||||
|
||||
// Onboarding never lists other orgs' organizations — a brand-new user only
|
||||
// ever creates their own org or skips. Listing the org directory would leak
|
||||
// every org's name to anyone who signs up. Joining an existing org happens
|
||||
// by invitation, handled outside this flow.
|
||||
return (
|
||||
<div className="hanzo-id-onboarding-body">
|
||||
<form onSubmit={create} className="hanzo-id-form" aria-busy={busy}>
|
||||
<label className="hanzo-id-field">
|
||||
<span>Organization name</span>
|
||||
<input
|
||||
className="hanzo-id-input"
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="Acme Inc"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{displayName ? <p className="hanzo-id-slug-preview">slug: {slugify(displayName) || '—'}</p> : null}
|
||||
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
|
||||
<div className="hanzo-id-onboarding-actions">
|
||||
<button type="button" className="hanzo-id-btn ghost" onClick={() => onNext({})} disabled={busy}>
|
||||
Skip for now
|
||||
</button>
|
||||
<button type="submit" className="hanzo-id-btn" disabled={busy}>
|
||||
{busy ? 'Creating…' : 'Create organization'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Step 2: project (optional) ──────────────────────────────────────
|
||||
|
||||
function ProjectStep({
|
||||
service,
|
||||
orgName,
|
||||
showBack,
|
||||
onBack,
|
||||
onNext,
|
||||
}: {
|
||||
service: OnboardingService
|
||||
orgName?: string
|
||||
showBack: boolean
|
||||
onBack: () => void
|
||||
onNext: (patch: Partial<OnboardingState>) => void
|
||||
}) {
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function create(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!orgName) return // can't create a project without a home org
|
||||
const name = slugify(displayName)
|
||||
if (!name) {
|
||||
setError('Enter a project name.')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
const res = await service.createProject({ organization: orgName, name, displayName: displayName.trim() })
|
||||
setBusy(false)
|
||||
if (!res.ok) {
|
||||
setError(humanizeError(res.error))
|
||||
return
|
||||
}
|
||||
onNext({ projectName: res.value.name })
|
||||
}
|
||||
|
||||
// No org was chosen (org step skipped) — a project needs a home org, so
|
||||
// offer only to continue.
|
||||
if (!orgName) {
|
||||
return (
|
||||
<div className="hanzo-id-onboarding-body">
|
||||
<p className="hanzo-id-info">Choose an organization first to create a project. You can do this later.</p>
|
||||
<div className="hanzo-id-onboarding-actions">
|
||||
{showBack ? (
|
||||
<button type="button" className="hanzo-id-btn ghost" onClick={onBack}>
|
||||
Back
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" className="hanzo-id-btn" onClick={() => onNext({})}>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="hanzo-id-onboarding-body">
|
||||
<form onSubmit={create} className="hanzo-id-form" aria-busy={busy}>
|
||||
<label className="hanzo-id-field">
|
||||
<span>Project name</span>
|
||||
<input
|
||||
className="hanzo-id-input"
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="Production"
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
{displayName ? <p className="hanzo-id-slug-preview">slug: {slugify(displayName) || '—'}</p> : null}
|
||||
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
|
||||
<div className="hanzo-id-onboarding-actions">
|
||||
{showBack ? (
|
||||
<button type="button" className="hanzo-id-btn ghost" onClick={onBack}>
|
||||
Back
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" className="hanzo-id-btn ghost" onClick={() => onNext({})} disabled={busy}>
|
||||
Skip
|
||||
</button>
|
||||
<button type="submit" className="hanzo-id-btn" disabled={busy}>
|
||||
{busy ? 'Creating…' : 'Create project'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Step 3: wallet (optional) ───────────────────────────────────────
|
||||
|
||||
function WalletStep({
|
||||
service,
|
||||
connectWallet,
|
||||
showBack,
|
||||
onBack,
|
||||
onNext,
|
||||
}: {
|
||||
service: OnboardingService
|
||||
connectWallet?: () => Promise<string | null>
|
||||
showBack: boolean
|
||||
onBack: () => void
|
||||
onNext: (patch: Partial<OnboardingState>) => void
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function link() {
|
||||
if (!connectWallet) return
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
const address = await connectWallet()
|
||||
if (!address) {
|
||||
setBusy(false)
|
||||
return // user cancelled the wallet prompt
|
||||
}
|
||||
const res = await service.linkWallet(address)
|
||||
setBusy(false)
|
||||
if (!res.ok) {
|
||||
setError(res.error)
|
||||
return
|
||||
}
|
||||
onNext({ walletAddress: res.value })
|
||||
} catch (e) {
|
||||
setBusy(false)
|
||||
setError(String(e))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="hanzo-id-onboarding-body">
|
||||
{connectWallet ? null : (
|
||||
<p className="hanzo-id-info">Wallet linking isn’t available here. You can add one later in settings.</p>
|
||||
)}
|
||||
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
|
||||
<div className="hanzo-id-onboarding-actions">
|
||||
{showBack ? (
|
||||
<button type="button" className="hanzo-id-btn ghost" onClick={onBack}>
|
||||
Back
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" className="hanzo-id-btn ghost" onClick={() => onNext({})} disabled={busy}>
|
||||
Skip
|
||||
</button>
|
||||
{connectWallet ? (
|
||||
<button type="button" className="hanzo-id-btn" onClick={link} disabled={busy}>
|
||||
{busy ? 'Connecting…' : 'Connect wallet'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Step 4: data-sharing consent ────────────────────────────────────
|
||||
|
||||
function ConsentStep({
|
||||
service,
|
||||
showBack,
|
||||
onBack,
|
||||
onNext,
|
||||
}: {
|
||||
service: OnboardingService
|
||||
showBack: boolean
|
||||
onBack: () => void
|
||||
onNext: (patch: Partial<OnboardingState>) => void
|
||||
}) {
|
||||
const [agreed, setAgreed] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Either answer continues; the answer itself is what must exist. It is
|
||||
// persisted on the USER (not browser storage) before the flow advances, so
|
||||
// this page is asked exactly once per account, ever.
|
||||
async function answer() {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
const res = await service.saveOnboarding({ consent: agreed })
|
||||
setBusy(false)
|
||||
if (!res.ok) {
|
||||
setError(res.error)
|
||||
return
|
||||
}
|
||||
onNext({ dataSharingConsent: agreed })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="hanzo-id-onboarding-body">
|
||||
<div className="hanzo-id-consent">
|
||||
<p>
|
||||
Sharing usage data helps improve the models and products you use. It
|
||||
covers product usage patterns and diagnostics — never the content of
|
||||
your conversations, code, or files. You can change this any time in
|
||||
account settings.
|
||||
</p>
|
||||
<label className="hanzo-id-consent-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={agreed}
|
||||
onChange={(e) => setAgreed(e.target.checked)}
|
||||
/>
|
||||
<span>I agree to share usage data to improve products and models.</span>
|
||||
</label>
|
||||
</div>
|
||||
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
|
||||
<div className="hanzo-id-onboarding-actions">
|
||||
{showBack ? (
|
||||
<button type="button" className="hanzo-id-btn ghost" onClick={onBack} disabled={busy}>
|
||||
Back
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" className="hanzo-id-btn" onClick={answer} disabled={busy}>
|
||||
{busy ? 'Saving…' : 'Continue'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Step 5 (last): plan or pay-as-you-go ────────────────────────────
|
||||
|
||||
/** Format catalog CENTS as dollars — "$9" or "$8.25", never "$9.00". */
|
||||
function usd(cents: number): string {
|
||||
const dollars = cents / 100
|
||||
return Number.isInteger(dollars) ? `$${dollars}` : `$${dollars.toFixed(2)}`
|
||||
}
|
||||
|
||||
function PlanStep({
|
||||
service,
|
||||
payUrl,
|
||||
showBack,
|
||||
onBack,
|
||||
onNext,
|
||||
}: {
|
||||
service: OnboardingService
|
||||
payUrl: string
|
||||
showBack: boolean
|
||||
onBack: () => void
|
||||
onNext: (patch: Partial<OnboardingState>) => void
|
||||
}) {
|
||||
const [plans, setPlans] = useState<PlanInfo[] | null>(null)
|
||||
const [busy, setBusy] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
service.listPlans(payUrl).then((p) => {
|
||||
if (alive) setPlans(p)
|
||||
})
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
// payUrl is fixed for the page's life.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// The choice is persisted (with completion) BEFORE the flow advances, so a
|
||||
// user who bounces off the payment page still never re-enters onboarding —
|
||||
// they land on the portal, where the top-up surface remains one click away.
|
||||
async function choose(choice: string) {
|
||||
setBusy(choice)
|
||||
setError(null)
|
||||
const res = await service.saveOnboarding({
|
||||
plan: choice,
|
||||
completedAt: new Date().toISOString(),
|
||||
})
|
||||
setBusy(null)
|
||||
if (!res.ok) {
|
||||
setError(res.error)
|
||||
return
|
||||
}
|
||||
onNext({ planChoice: choice })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="hanzo-id-onboarding-body">
|
||||
{plans === null ? (
|
||||
<p className="lede">Loading plans…</p>
|
||||
) : (
|
||||
<div className="hanzo-id-plans" role="list">
|
||||
{plans.length === 0 ? (
|
||||
// The catalog fetch failed or came back empty. Say so — a plan
|
||||
// picker showing ONLY pay-as-you-go with no explanation reads as
|
||||
// "there are no plans", which is false. Pay as you go still works,
|
||||
// and plans remain choosable later from billing.
|
||||
<p role="alert" className="hanzo-id-plans-empty">
|
||||
Plans are unavailable right now — you can start with pay as you
|
||||
go and pick a plan later from Billing.
|
||||
</p>
|
||||
) : null}
|
||||
{plans.map((p) => (
|
||||
<button
|
||||
key={p.slug}
|
||||
type="button"
|
||||
role="listitem"
|
||||
className={p.popular ? 'hanzo-id-plan popular' : 'hanzo-id-plan'}
|
||||
onClick={() => choose(p.slug)}
|
||||
disabled={busy !== null}
|
||||
aria-busy={busy === p.slug}
|
||||
>
|
||||
{p.popular ? <span className="hanzo-id-plan-badge">Popular</span> : null}
|
||||
<span className="hanzo-id-plan-name">{p.name}</span>
|
||||
<span className="hanzo-id-plan-price">
|
||||
{usd(p.priceCents)}/mo
|
||||
{p.priceAnnualCents ? <em> · {usd(p.priceAnnualCents * 12)}/yr billed annually</em> : null}
|
||||
</span>
|
||||
{p.description ? <span className="hanzo-id-plan-desc">{p.description}</span> : null}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
role="listitem"
|
||||
className="hanzo-id-plan payg"
|
||||
onClick={() => choose('payg')}
|
||||
disabled={busy !== null}
|
||||
aria-busy={busy === 'payg'}
|
||||
>
|
||||
<span className="hanzo-id-plan-name">Pay as you go</span>
|
||||
<span className="hanzo-id-plan-price">Prepaid balance · $5 minimum</span>
|
||||
<span className="hanzo-id-plan-desc">
|
||||
No subscription. Top up a balance and pay only for what you use.
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
|
||||
{showBack ? (
|
||||
<div className="hanzo-id-onboarding-actions">
|
||||
<button type="button" className="hanzo-id-btn ghost" onClick={onBack} disabled={busy !== null}>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Terminal: success ───────────────────────────────────────────────
|
||||
|
||||
function DoneStep({ brandName, data }: { brandName: string; data: OnboardingState }) {
|
||||
return (
|
||||
<div className="hanzo-id-onboarding-done">
|
||||
<h1>You’re all set</h1>
|
||||
<p className="lede">Welcome to {brandName}.</p>
|
||||
<dl className="hanzo-id-summary">
|
||||
{data.orgName ? (
|
||||
<>
|
||||
<dt>Organization</dt>
|
||||
<dd>{data.orgName}</dd>
|
||||
</>
|
||||
) : null}
|
||||
{data.projectName ? (
|
||||
<>
|
||||
<dt>Project</dt>
|
||||
<dd>{data.projectName}</dd>
|
||||
</>
|
||||
) : null}
|
||||
{data.walletAddress ? (
|
||||
<>
|
||||
<dt>Wallet</dt>
|
||||
<dd>{shortAddr(data.walletAddress)}</dd>
|
||||
</>
|
||||
) : null}
|
||||
</dl>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Map raw IAM errors to a human sentence. Org/project creation is admin-gated
|
||||
* in IAM authz (`add-organization` requires the `admin` role; `add-project`
|
||||
* default-denies for non-admins), so a normal member hits a permission error
|
||||
* — say so plainly instead of leaking an HTTP code, and the step stays
|
||||
* skippable so onboarding never hard-blocks.
|
||||
*/
|
||||
function humanizeError(raw: string): string {
|
||||
const lower = raw.toLowerCase()
|
||||
if (lower.includes('403') || lower.includes('permission') || lower.includes('not allowed') || lower.includes('unauthorized')) {
|
||||
return 'You don’t have permission to create this here. Pick an existing organization, or ask an admin to invite you.'
|
||||
}
|
||||
if (lower.includes('already') || lower.includes('exist') || lower.includes('conflict') || lower.includes('409')) {
|
||||
return 'That name is taken. Try a different one.'
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
/** Lower-kebab a display name into an org/project slug. */
|
||||
function slugify(s: string): string {
|
||||
return s
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 40)
|
||||
}
|
||||
|
||||
function shortAddr(a: string): string {
|
||||
return a.length > 12 ? `${a.slice(0, 6)}…${a.slice(-4)}` : a
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user