fix(console): route org IAM (projects, members) through /v1/iam

Projects + the member roster called the /org/iam BFF proxy, which the one-binary
(static-export) console cannot run — so on console.hanzo.ai they returned the SPA
shell and the Platform page showed "Request failed (HTTP 200)". Route them through
the main client at /v1/iam (the cloud IAM edge) instead, so ONE path serves both
the one-binary and split topologies, with CSRF + X-Org-Id + retry + envelope
unwrap for free. IAM's {status,msg,data,data2} IS our ApiResponse — no second
client (iamList/iamOne/iamMutate on client.ts).

tsc --noEmit clean.
This commit is contained in:
hanzo-dev
2026-07-16 20:05:37 -07:00
parent c4d309e081
commit 487a06a33d
3 changed files with 60 additions and 40 deletions
+25
View File
@@ -328,6 +328,31 @@ export async function post<T = string>(path: string, body?: unknown, query?: Que
return r
}
/**
* IAM over the SAME `/v1` path + resilient fetch as every cloud call — IAM's
* `{status,msg,data,data2}` envelope IS our `ApiResponse`, so there is no second
* client. These target `/v1/iam/<segment>`, which the cloud IAM edge (org-scoped)
* serves in the one-binary console and the `/v1` bearer proxy forwards in the split
* one — ONE path, ONE gate, in both topologies. `iamList` surfaces `data2` (the
* total) that plain `get` drops.
*/
export async function iamList<T>(segment: string, query?: Query): Promise<{ rows: T[]; total: number }> {
const r = await request<T[]>('GET', `iam/${segment}`, { query })
if (r.status !== 'ok') throw new ApiError(r.msg || 'Request failed')
const rows = Array.isArray(r.data) ? r.data : []
return { rows, total: typeof r.data2 === 'number' ? r.data2 : rows.length }
}
export async function iamOne<T>(segment: string, query?: Query): Promise<T> {
const r = await request<T>('GET', `iam/${segment}`, { query })
if (r.status !== 'ok') throw new ApiError(r.msg || 'Request failed')
if (r.data === undefined || r.data === null) throw new ApiError('Not found', 404)
return r.data
}
export const iamMutate = (segment: string, body?: unknown, query?: Query): Promise<void> =>
post<unknown>(`iam/${segment}`, body, query).then(() => undefined)
/** Owner/name -> the `id` query param the backend expects (`owner/name`). */
export const idOf = (owner: string, name: string): string => `${owner}/${encodeURIComponent(name)}`
+15 -17
View File
@@ -6,20 +6,19 @@
* an indexed `organization`; we set owner = organization = the brand org so the
* record is owned and listed under it.
*
* ROUTING (the "projects not routed" fix): these are `/v1/iam/*` endpoints, which
* the console host's `/v1` sends to the CLOUD binary → 404 (cloud doesn't serve
* IAM). So Projects goes through the same-origin **`/org/iam`** BFF proxy, which
* mints a user-bound Bearer server-side and forwards to `iam.hanzo.svc` — the org
* is resolved from the token owner claim (per-tenant), the same pattern the member
* roster already uses. The proxy pins the `organization` param to the caller's own
* org, so one tenant can never list another's projects.
* ROUTING: these are `/v1/iam/*` endpoints reached over the ONE cloud IAM edge
* (iam_edge.go). The one-binary console (console.hanzo.ai served by cloud) calls
* it directly with the session cookie; a split console forwards `/v1/iam/*` through
* its `/v1` bearer proxy. The edge pins `organization` to the caller's validated,
* server-minted org — one tenant can never list another's projects, since IAM's own
* authz is permissive on this route. This replaces the old `/org/iam` BFF, which
* the static one-binary console cannot run (it has no server routes).
*
* Environments (mainnet/testnet/devnet + custom) are a console-side scoping
* dimension — IAM's Project has no environments column — so they live in
* `lib/scope.ts`, not in this payload.
*/
import { idOf } from './client'
import { makeIamClient } from './iam-envelope'
import { idOf, iamList, iamMutate } from './client'
import { currentOrg } from '~/lib/org-scope'
import { STOCK_ENVIRONMENTS } from '~/lib/scope'
@@ -48,18 +47,17 @@ export const projectEnvironments = (p?: Project): string[] => {
return [...STOCK_ENVIRONMENTS, ...custom]
}
/** The IAM member proxy — mints the user Bearer server-side, scopes to the caller's org. */
const iam = makeIamClient('/org/iam')
const org = () => currentOrg()
export const ProjectApi = {
/**
* List the org's projects via the `/org/iam` Bearer proxy. IAM's
* `get-organization-projects?organization=<org>` returns exactly the projects
* under the org; the proxy pins `organization` to the caller's own scope.
* List the org's projects via `/v1/iam` → the cloud IAM edge, which pins
* `organization` to the caller's own validated scope (the org-isolation gate,
* since IAM's own authz is permissive on this route). Returns exactly the
* projects under the caller's org.
*/
list: (): Promise<Project[]> =>
iam.iamList<Project>('get-organization-projects', { organization: org() }).then((r) => r.rows),
iamList<Project>('get-organization-projects', { organization: org() }).then((r) => r.rows),
/**
* Create a project under the org (`POST /org/iam/add-project`). `name` is the
@@ -68,7 +66,7 @@ export const ProjectApi = {
* `name` for callers that don't distinguish them).
*/
create: (p: { name: string; displayName?: string; description?: string }): Promise<void> =>
iam.iamMutate('add-project', {
iamMutate('add-project', {
owner: org(),
name: p.name,
displayName: p.displayName ?? p.name,
@@ -78,5 +76,5 @@ export const ProjectApi = {
/** Delete a project (`POST /org/iam/delete-project`, keyed by `owner/name`). */
remove: (name: string): Promise<void> =>
iam.iamMutate('delete-project', { owner: org(), name, organization: org() }, { id: idOf(org(), name) }),
iamMutate('delete-project', { owner: org(), name, organization: org() }, { id: idOf(org(), name) }),
}
+20 -23
View File
@@ -1,25 +1,22 @@
/**
* Team API — an ORG admin managing their OWN organization's members, over the
* console's own server-gated `/org/iam` proxy.
* Team API — an ORG admin managing their OWN organization's members, over the ONE
* cloud IAM edge (`/v1/iam/*`, iam_edge.go).
*
* Distinct from `IamAdminApi` (the `/admin/iam` GLOBAL cross-tenant proxy, which a
* customer can't reach): this proxy authorizes ANY authenticated member to READ
* their own org's members/roles and an ORG ADMIN to invite / change-role / remove
* — always pinned to the caller's OWN org (`X-Org-Id` / the resolved owner),
* server-side, so no tenant can touch another's users. This is what closes
* "can't manage members in an org" for a customer like maxpower.
* The edge authorizes ANY authenticated member to READ their own org's
* members/roles and an ORG ADMIN to invite / change-role / remove — always pinned
* to the caller's OWN validated, server-minted org (`X-Org-Id`), so no tenant can
* touch another's users. This is what closes "can't manage members in an org" for a
* customer like maxpower.
*
* The browser holds no IAM credential — it calls same-origin with the session
* cookie; the proxy forwards to IAM as the user. Shares the ONE envelope client
* with the admin API (DRY), differing only in the gated base path.
* The browser holds no IAM credential — it calls same-origin (`/v1/iam`) with the
* session cookie; cloud gates + forwards to IAM as the service. The GLOBAL
* cross-tenant admin surface (`IamAdminApi`) is distinct — a super admin crosses.
*/
import { makeIamClient, DEFAULT_PAGE_SIZE, type Paged } from './iam-envelope'
import { ApiError } from './client'
import { DEFAULT_PAGE_SIZE, type Paged } from './iam-envelope'
import { ApiError, iamList, iamOne, iamMutate } from './client'
import { listQuery, type ListParams } from './types'
import type { Organization, IamUser, Role } from './admin'
const org = makeIamClient('/org/iam')
/** A shareable accept link for a pending member (delivery is a link hand-off —
* email/OTP is not wired on this deployment). */
export type InviteLink = { link: string; org: string; name: string; email: string }
@@ -27,18 +24,18 @@ export type InviteLink = { link: string; org: string; name: string; email: strin
export const TeamApi = {
/** Members of `orgName` (the caller's own org, or any for a global admin). */
members: (orgName: string, params: ListParams = {}): Promise<Paged<IamUser>> =>
org.iamList<IamUser>('get-users', listQuery({ owner: orgName, pageSize: DEFAULT_PAGE_SIZE, ...params })),
iamList<IamUser>('get-users', listQuery({ owner: orgName, pageSize: DEFAULT_PAGE_SIZE, ...params })),
/** A single member by id (`owner/name`). */
member: (id: string): Promise<IamUser> => org.iamOne<IamUser>('get-user', { id }),
member: (id: string): Promise<IamUser> => iamOne<IamUser>('get-user', { id }),
/** RBAC roles defined in `orgName` (read-only surface). */
roles: (orgName: string, params: ListParams = {}): Promise<Paged<Role>> =>
org.iamList<Role>('get-roles', listQuery({ owner: orgName, pageSize: DEFAULT_PAGE_SIZE, ...params })),
iamList<Role>('get-roles', listQuery({ owner: orgName, pageSize: DEFAULT_PAGE_SIZE, ...params })),
/** The org record (name, displayName, created) for the Settings General tab. */
organization: (orgName: string): Promise<Organization> =>
org.iamOne<Organization>('get-organization', { id: `admin/${orgName}` }),
iamOne<Organization>('get-organization', { id: `admin/${orgName}` }),
/**
* Save the org's branding/settings (displayName, logo, favicon, website, theme).
@@ -48,17 +45,17 @@ export const TeamApi = {
* write). Org objects are owned by the `admin` metadata org.
*/
updateOrganization: (organization: Organization): Promise<void> =>
org.iamMutate('update-organization', organization, { id: `admin/${organization.name}` }),
iamMutate('update-organization', organization, { id: `admin/${organization.name}` }),
/** Invite (create) a member. `user.owner` MUST be the caller's org (the proxy
* rejects any other owner in the body — the cross-tenant write guard). */
invite: (user: IamUser): Promise<void> => org.iamMutate('add-user', user),
invite: (user: IamUser): Promise<void> => iamMutate('add-user', user),
/** Change a member (role/admin flag). `id` = `owner/name`; body is the full user. */
update: (id: string, user: IamUser): Promise<void> => org.iamMutate('update-user', user, { id }),
update: (id: string, user: IamUser): Promise<void> => iamMutate('update-user', user, { id }),
/** Remove a member. Body is the full user object (owner must be the caller's org). */
remove: (user: IamUser): Promise<void> => org.iamMutate('delete-user', user),
remove: (user: IamUser): Promise<void> => iamMutate('delete-user', user),
/**
* Mint a shareable ACCEPT LINK for a pending member (`/console/invite-link`, the