mirror of
https://github.com/hanzoai/id.git
synced 2026-10-16 18:38:15 +00:00
0.2.14 — reunite main with production, on top of the design-token pass
The running image (0.2.13, built frome620df2) was NOT on origin/main. The two had diverged at6d74def6with the live code unmerged: self-service org creation founding through /v1/iam/onboard instead of the admin-only add-organization verb (which is 401 without a bearer and 403 with one, so signup was dead), and the App/Chat/Cloud launcher. A release cut from main would have silently regressed both on hanzo.id, lux.id, pars.id and zoo.id at once. This merges them. main is once again what runs, and 0.2.14 is the first tag that carries BOTH the live fixes and the @hanzo/design token pass. The only conflict was the version line. 136 unit tests and every workspace typecheck green; the login, device-approval and spinner measurements re-taken in a real browser against the merged bundle. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# LLM.md — Hanzo ID
|
||||
|
||||
## One token layer, and no control painted by its ancestor (0.2.12)
|
||||
## One token layer, and no control painted by its ancestor (0.2.14)
|
||||
|
||||
The portal is now styled from **@hanzo/design tokens** — the same token layer
|
||||
hanzoai/pay renders from, so the two halves of one flow (sign in → pay) agree on
|
||||
@@ -47,6 +47,13 @@ Measured in a real browser against the built bundle (fresh context, empty
|
||||
storage): page `#000000`, controls 44px, one 6px radius, h1 21px, white focus
|
||||
ring, no control under the touch floor at 390px, no horizontal overflow.
|
||||
|
||||
**This release also reunites `main` with production.** The running image (0.2.13,
|
||||
built from `9477777`) was NOT on `origin/main`: the two had diverged at
|
||||
`7a72225f`, with the self-service-signup fix (`/v1/iam/onboard` instead of the
|
||||
admin-only `add-organization` verb) and the App/Chat/Cloud launcher live but
|
||||
unmerged. A release cut from `main` would have silently regressed both. 0.2.14 is
|
||||
the merge, so `main` is once again what runs.
|
||||
|
||||
## One chain-agnostic "Connect Wallet" button — merge EVM + Solana entries (0.2.9)
|
||||
|
||||
The login page rendered ONE wallet button PER enabled chain (`SocialButtons`
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@hanzo/id-web",
|
||||
"private": true,
|
||||
"version": "0.1.27",
|
||||
"version": "0.1.28",
|
||||
"description": "Hanzo ID — white-label login / signup / IDV portal. Vite + React 19, styled from @hanzo/design tokens. Same image serves hanzo.id / lux.id / zoo.id / pars.id.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -67,14 +67,23 @@ const MARKETING: Record<string, Marketing> = {
|
||||
},
|
||||
}
|
||||
|
||||
// The launcher lists PRODUCTS a person opens, not every host we run.
|
||||
//
|
||||
// Hanzo is three: App (build), Chat (talk), Cloud (the platform + its API).
|
||||
// "Console" is NOT a fourth — it is Cloud's former name, and console.hanzo.ai
|
||||
// now redirects to cloud.hanzo.ai, so listing both showed one product twice
|
||||
// under two names and sent half the traffic through a redirect. It is gone;
|
||||
// nothing here links to console.hanzo.ai.
|
||||
//
|
||||
// Analytics, Platform and Storage came out with it: s3.hanzo.ai answers a bare
|
||||
// XML AccessDenied to a browser (it is an S3 API endpoint, not a page), and the
|
||||
// other two are surfaces inside Cloud rather than products of their own. A
|
||||
// launcher that lands you on an error page teaches people the tiles are broken.
|
||||
const APPS: Record<string, readonly AppLink[]> = {
|
||||
hanzo: [
|
||||
{ name: 'Console', href: 'https://console.hanzo.ai', description: 'Observability & traces' },
|
||||
{ name: 'App', href: 'https://hanzo.app', description: 'Build with AI' },
|
||||
{ name: 'Chat', href: 'https://hanzo.chat', description: 'AI chat interface' },
|
||||
{ name: 'Cloud', href: 'https://cloud.hanzo.ai', description: 'AI model API' },
|
||||
{ name: 'Analytics', href: 'https://analytics.hanzo.ai', description: 'Web analytics' },
|
||||
{ name: 'Platform', href: 'https://platform.hanzo.ai', description: 'PaaS deployments' },
|
||||
{ name: 'Storage', href: 'https://s3.hanzo.ai', description: 'S3-compatible storage' },
|
||||
{ name: 'Cloud', href: 'https://cloud.hanzo.ai', description: 'Models, compute & API' },
|
||||
],
|
||||
lux: [
|
||||
{ name: 'Bridge', href: 'https://bridge.lux.network', description: 'Cross-chain bridge' },
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@hanzo/id",
|
||||
"private": true,
|
||||
"version": "0.2.12",
|
||||
"version": "0.2.14",
|
||||
"description": "Hanzo ID \u2014 white-label login + identity verification portal (Vite + @hanzo/gui)",
|
||||
"scripts": {
|
||||
"build": "pnpm -r build",
|
||||
|
||||
@@ -91,19 +91,41 @@ test('listOrgs returns [] (not throw) on a server error', async () => {
|
||||
assert.deepEqual(await service.listOrgs(), [])
|
||||
})
|
||||
|
||||
test('createOrg posts the org and reports IAM error messages', async () => {
|
||||
const ok = harness(() => ({ json: { status: 'ok' } }))
|
||||
// Founding an org goes through the SELF-SERVICE front door, never the
|
||||
// add-organization admin verb — that one is bearer-only entity CRUD filed under
|
||||
// owner "admin", so a person founding their first org gets 401/403 there. This is
|
||||
// the regression guard for the hanzo.id/onboarding "HTTP 401".
|
||||
test('createOrg founds the org through /v1/iam/onboard, never the admin verb', async () => {
|
||||
const ok = harness(() => ({ json: { org: 'acme', accessKey: 'pk-live-x' } }))
|
||||
const res = await ok.service.createOrg({ name: 'acme', displayName: 'Acme Inc' })
|
||||
assert.equal(ok.calls[0]!.url, 'https://hanzo.id/v1/iam/add-organization')
|
||||
assert.equal(ok.calls[0]!.url, 'https://hanzo.id/v1/iam/onboard')
|
||||
assert.equal(ok.calls[0]!.method, 'POST')
|
||||
const sent = JSON.parse(ok.calls[0]!.body!)
|
||||
assert.equal(sent.name, 'acme')
|
||||
assert.equal(sent.displayName, 'Acme Inc')
|
||||
assert.ok(!ok.calls.some((c) => c.url.includes('add-organization')))
|
||||
// The DISPLAY name is what travels: the server owns the slug policy.
|
||||
assert.deepEqual(JSON.parse(ok.calls[0]!.body!), { name: 'Acme Inc' })
|
||||
// …and the slug it answers with is authoritative, not the client's guess.
|
||||
assert.deepEqual(res, { ok: true, value: { name: 'acme', displayName: 'Acme Inc' } })
|
||||
})
|
||||
|
||||
const denied = harness(() => ({ status: 403, json: { status: 'error', msg: 'permission denied' } }))
|
||||
const fail = await denied.service.createOrg({ name: 'x', displayName: 'X' })
|
||||
assert.deepEqual(fail, { ok: false, error: 'HTTP 403' })
|
||||
test('createOrg carries BOTH credentials — the portal session mints no bearer', async () => {
|
||||
const { service, calls } = harness(() => ({ json: { org: 'acme' } }))
|
||||
await service.createOrg({ name: 'acme', displayName: 'Acme Inc' })
|
||||
assert.equal(calls[0]!.headers.Authorization, 'Bearer tok-123')
|
||||
assert.equal(calls[0]!.headers['Content-Type'], 'application/json')
|
||||
})
|
||||
|
||||
test('createOrg surfaces the front door’s own error text, not a bare HTTP code', async () => {
|
||||
const taken = harness(() => ({ status: 409, json: { error: 'the organization "acme" already exists' } }))
|
||||
assert.deepEqual(await taken.service.createOrg({ name: 'acme', displayName: 'Acme' }), {
|
||||
ok: false,
|
||||
error: 'the organization "acme" already exists',
|
||||
})
|
||||
|
||||
const anon = harness(() => ({ status: 401, json: { error: 'please sign in first' } }))
|
||||
assert.deepEqual(await anon.service.createOrg({ name: 'x', displayName: 'X' }), {
|
||||
ok: false,
|
||||
error: 'please sign in first',
|
||||
})
|
||||
})
|
||||
|
||||
test('linkWallet rejects a malformed address before any network call', async () => {
|
||||
|
||||
@@ -3,19 +3,31 @@
|
||||
* wallet flow.
|
||||
*
|
||||
* One way: every write goes through the canonical IAM REST surface under
|
||||
* `/v1/iam/*` (the same IAM paths the auth client uses), carrying
|
||||
* the user's bearer token. There is no separate onboarding backend — the org
|
||||
* and project records live in IAM, which is the identity registry.
|
||||
* `/v1/iam/*` (the same IAM paths the auth client uses). There is no separate
|
||||
* onboarding backend — the org and project records live in IAM, which is the
|
||||
* identity registry.
|
||||
*
|
||||
* listOrgs() GET /v1/iam/get-organizations (user-scoped server-side)
|
||||
* createOrg() POST /v1/iam/add-organization
|
||||
* createOrg() POST /v1/iam/onboard (the self-service front door)
|
||||
* createProject POST /v1/iam/add-project
|
||||
* linkWallet() client-side wallet connect → IAM update-user (host-driven)
|
||||
*
|
||||
* Token is supplied by the host through `getAccessToken` (the portal already
|
||||
* holds the session after login). The service never stores it.
|
||||
* Founding an org goes through `onboard`, NOT the `add-organization` admin verb.
|
||||
* They are different doors: add-organization is Casdoor 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
|
||||
* tenant — org stamped with them as Founder, them moved in as its owner, one
|
||||
* metered API key — under their own authority as its founder.
|
||||
*
|
||||
* Both credentials are offered on every call: `credentials: 'include'` for the
|
||||
* portal session cookie (a bare portal sign-in mints NO bearer, which is why the
|
||||
* bearer-only door 401'd), and `Authorization` when the host does hold a token.
|
||||
* IAM resolves session first, then bearer.
|
||||
*/
|
||||
import type { Organization, Project } from '@hanzo/iam'
|
||||
import type { Project } from '@hanzo/iam'
|
||||
import type { OrgRef, ProjectRef } from '../domain/types'
|
||||
|
||||
/** Result of a write that can fail gracefully (no throw on expected errors). */
|
||||
@@ -80,16 +92,36 @@ export function createOnboardingService(opts: OnboardingServiceOptions): Onboard
|
||||
return rows.map(toOrgRef).filter((o): o is OrgRef => o !== null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Found the caller's own organization through the self-service front door.
|
||||
*
|
||||
* The server owns the slug: it derives it from the display name under the ONE
|
||||
* policy every surface shares, so the returned `org` is authoritative and the
|
||||
* client's slug preview is only a preview. It answers `{org}` on success and
|
||||
* `{error}` with a 4xx/5xx on failure — not the casibase `{status,msg}`
|
||||
* envelope the entity CRUD returns — so read it directly.
|
||||
*/
|
||||
async function createOrg(input: { name: string; displayName: string }): Promise<Result<OrgRef>> {
|
||||
const url = new URL('/v1/iam/add-organization', base)
|
||||
const org: Partial<Organization> = {
|
||||
owner: 'admin',
|
||||
name: input.name,
|
||||
displayName: input.displayName,
|
||||
isPersonal: false,
|
||||
balanceCurrency: 'USD',
|
||||
const url = new URL('/v1/iam/onboard', base)
|
||||
const displayName = input.displayName || input.name
|
||||
try {
|
||||
const res = await f(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: await authHeaders(),
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ name: displayName }),
|
||||
})
|
||||
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>
|
||||
if (!res.ok) {
|
||||
const msg = typeof body.error === 'string' && body.error ? body.error : `HTTP ${res.status}`
|
||||
return { ok: false, error: msg }
|
||||
}
|
||||
const org = typeof body.org === 'string' ? body.org : ''
|
||||
if (!org) return { ok: false, error: 'request failed' }
|
||||
return { ok: true, value: { name: org, displayName } }
|
||||
} catch (e) {
|
||||
return { ok: false, error: String(e) }
|
||||
}
|
||||
return writeRecord(url, org, () => ({ name: input.name, displayName: input.displayName }))
|
||||
}
|
||||
|
||||
async function createProject(input: {
|
||||
|
||||
Reference in New Issue
Block a user