/** * ZITADEL API client for portal-driven registration (Option B). * * Uses v2 APIs: * - OrganizationService: POST /v2/organizations * - UserService: POST /v2/users/new + POST /v2/users/{id}/invite * - ProjectService: Connect RPC CreateProjectGrant * - AuthorizationService: Connect RPC CreateAuthorization * * Registration flow (invite-based): * 1. Create Org * 2. Create User (no password, email unverified) * 3. Send invite → ZITADEL emails a link to set password + verify email * 4. Create Project Grant * 5. Create Authorization (role assignment) * * Auth: pieced-sa PAT (Personal Access Token) — passed as Bearer token. * The SA must have IAM_OWNER role to create orgs cross-tenant. */ const ZITADEL_URL = process.env.ZITADEL_ISSUER!; // https://auth.pieced.ch const ZITADEL_SA_PAT = process.env.ZITADEL_SA_PAT!; const ZITADEL_PROJECT_ID = process.env.ZITADEL_PROJECT_ID!; // 367435120493199793 // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- async function zitadelFetch( path: string, method: string = "GET", body?: unknown, headers?: Record ): Promise { const url = `${ZITADEL_URL}${path}`; const res = await fetch(url, { method, headers: { "Content-Type": "application/json", Accept: "application/json", Authorization: `Bearer ${ZITADEL_SA_PAT}`, ...headers, }, body: body ? JSON.stringify(body) : undefined, }); if (!res.ok) { const text = await res.text(); const err = new Error(`ZITADEL ${method} ${path}: ${res.status} ${text}`); (err as any).statusCode = res.status; throw err; } return res.json() as Promise; } /** * Connect RPC call — ZITADEL v2 services use Connect protocol. * Same as REST but requires Connect-Protocol-Version header. */ async function connectRpc( service: string, method: string, body: unknown ): Promise { return zitadelFetch( `/${service}/${method}`, "POST", body, { "Connect-Protocol-Version": "1" } ); } // --------------------------------------------------------------------------- // v2 Organization API — REST // --------------------------------------------------------------------------- export interface CreateOrgResult { organizationId: string; creationDate: string; } export async function createOrganization( name: string ): Promise { return zitadelFetch("/v2/organizations", "POST", { name, }); } // --------------------------------------------------------------------------- // v2 User API — REST // --------------------------------------------------------------------------- export interface CreateUserResult { id: string; creationDate: string; emailCode?: string; } /** * Create a human user in a specific organization WITHOUT a password. * The user cannot log in until they complete the invite flow * (set password + verify email via the link in the invite email). * * POST /v2/users/new */ export async function createHumanUser(params: { orgId: string; email: string; givenName: string; familyName: string; preferredLanguage?: string; }): Promise { return zitadelFetch("/v2/users/new", "POST", { organizationId: params.orgId, human: { profile: { givenName: params.givenName, familyName: params.familyName, displayName: `${params.givenName} ${params.familyName}`, preferredLanguage: params.preferredLanguage || "en", }, email: { email: params.email, // Not verified — invite flow will handle verification }, }, // No password — user sets it via invite link }); } /** * Send an invitation email to the user. * The email contains a link where the user sets their password * (or passkey/IdP) and verifies their email address in one step. * * Requires SMTP to be configured in ZITADEL. * If SMTP is not configured, this call succeeds but no email is sent. * * POST /v2/users/{userId}/invite */ export async function createInviteCode(userId: string): Promise { await zitadelFetch(`/v2/users/${userId}/invite`, "POST", { sendCode: {}, }); } // --------------------------------------------------------------------------- // v2 Project API — Connect RPC // --------------------------------------------------------------------------- export interface ProjectGrantResult { projectGrantId: string; creationDate: string; } /** * Grant the "OpenClaw Platform" project to a customer organization. * * The grant's `roleKeys` whitelist what authorizations the customer org * may self-manage: a grant containing only "owner" prevents the customer * from inviting members in the `user` role, because ZITADEL rejects * `CreateAuthorization` for any role outside the grant with * `Errors.Project.Role.NotFound`. * * Default is therefore `["owner", "user"]` — the full set of customer * roles defined in `types/index.ts::CustomerRole`. Platform roles are * intentionally NOT granted; those are administered separately and * should never be assignable from inside a customer org. * * Connect RPC: zitadel.project.v2.ProjectService/CreateProjectGrant */ export async function createProjectGrant( grantedOrgId: string, roleKeys?: string[] ): Promise { return connectRpc( "zitadel.project.v2.ProjectService", "CreateProjectGrant", { projectId: ZITADEL_PROJECT_ID, grantedOrganizationId: grantedOrgId, roleKeys: roleKeys || ["owner", "user"], } ); } /** * List the role keys defined on the OpenClaw Platform project. * * Used by the instrumentation self-check on startup to warn loudly if * the canonical role keys (owner / user / platform_admin / platform_operator) * are missing — a misconfiguration that silently breaks team management * and customer registration. See `scripts/zitadel-roles.mjs` for repair. * * Returns [] on any error (network, auth, shape drift) so callers can * decide what to do without inheriting a thrown exception during boot. * * Connect RPC: zitadel.project.v2.ProjectService/ListProjectRoles */ export async function listProjectRoles(): Promise { try { const data = await connectRpc<{ projectRoles?: any[] }>( "zitadel.project.v2.ProjectService", "ListProjectRoles", { projectId: ZITADEL_PROJECT_ID } ); if (!data?.projectRoles || !Array.isArray(data.projectRoles)) return []; return data.projectRoles .map((r: any) => (typeof r?.key === "string" ? r.key : "")) .filter(Boolean); } catch (err) { console.warn( `Failed to list project roles for ${ZITADEL_PROJECT_ID} (returning empty):`, err ); return []; } } // --------------------------------------------------------------------------- // v2 Authorization API — Connect RPC // --------------------------------------------------------------------------- export interface AuthorizationResult { id: string; creationDate: string; } /** * Create a role assignment (authorization) for a user. * This makes the role appear in the JWT claims. * Connect RPC: zitadel.authorization.v2.AuthorizationService/CreateAuthorization */ export async function createAuthorization(params: { userId: string; projectId?: string; organizationId: string; roleKeys?: string[]; }): Promise { return connectRpc( "zitadel.authorization.v2.AuthorizationService", "CreateAuthorization", { userId: params.userId, projectId: params.projectId || ZITADEL_PROJECT_ID, organizationId: params.organizationId, roleKeys: params.roleKeys || ["owner"], } ); } /** * Replace the role keys on an existing authorization. * * Connect RPC: zitadel.authorization.v2.AuthorizationService/UpdateAuthorization * * Replace, not merge: any role keys previously held by this authorization * that are NOT in the new list are revoked. Pass the complete desired * role set every time. The authorization's user/org/project bindings * are immutable — to move a user to a different org, delete and recreate. * * Used by the team UI's role change flow (Bug 25). For new role grants * use {@link createAuthorization}; for revocations of an entire role * assignment, delete the authorization (not yet exposed; not needed at * the time of writing). */ export async function updateAuthorizationRoles( authorizationId: string, roleKeys: string[] ): Promise<{ changeDate?: string }> { return connectRpc<{ changeDate?: string }>( "zitadel.authorization.v2.AuthorizationService", "UpdateAuthorization", { id: authorizationId, roleKeys, } ); } // --------------------------------------------------------------------------- // Delete Organization (for rollback on partial failure) // --------------------------------------------------------------------------- export async function deleteOrganization(orgId: string): Promise { await zitadelFetch(`/v2/organizations/${orgId}`, "DELETE"); } // --------------------------------------------------------------------------- // Slice 7: search/list APIs for team management // --------------------------------------------------------------------------- // // Two endpoints used by the Team UI: // - listOrgUsers → POST /v2/users (search with organizationIdQuery) // - listOrgAuthorizations → Connect RPC to AuthorizationService.ListAuthorizations // // Caveats // ------- // ZITADEL's v2 API surface evolves; the request/response shapes below were // written against the v2 schema as documented at the time of authoring // (organizationIdQuery filter on UserService.SearchUsers; ListAuthorizations // with a ListQuery + filter pair). If your installed ZITADEL version uses // slightly different field names, parsing here is intentionally tolerant — // the helpers return [] rather than throwing on shape drift, log a warning, // and the caller's UI shows an empty team list (which is recoverable). // // If you find a discrepancy, fix the request shape here and re-deploy; the // rest of the team UI doesn't care about the on-the-wire format. export interface OrgUser { userId: string; email: string; givenName: string; familyName: string; displayName: string; } /** * List all users belonging to a given ZITADEL organization. Paginated; * we cap at 200 per call which is generous for the pilot scale. */ export async function listOrgUsers(orgId: string): Promise { try { const data = await zitadelFetch<{ result?: any[] }>( "/v2/users", "POST", { queries: [{ organizationIdQuery: { organizationId: orgId } }], // Sort by username so the team list is deterministic across reloads sortingColumn: "USER_FIELD_NAME_USERNAME", query: { limit: 200, asc: true }, } ); if (!data?.result || !Array.isArray(data.result)) return []; return data.result.flatMap((row: any) => { // ZITADEL distinguishes human and machine users; we only want humans. const human = row?.human; if (!human) return []; const profile = human.profile ?? {}; const email = human.email?.email ?? ""; const userId = row.userId ?? row.id ?? ""; if (!userId) return []; return [ { userId, email, givenName: profile.givenName ?? "", familyName: profile.familyName ?? "", displayName: profile.displayName ?? `${profile.givenName ?? ""} ${profile.familyName ?? ""}`.trim() ?? email, } as OrgUser, ]; }); } catch (err) { console.warn( `Failed to list users for org ${orgId} (returning empty):`, err ); return []; } } export interface OrgAuthorization { authorizationId: string; userId: string; organizationId: string; projectId: string; roleKeys: string[]; } /** * List authorizations for the OpenClaw Platform project, filtered to a * single organization. Used by the team UI to render each member's * effective role. * * Connect RPC: zitadel.authorization.v2.AuthorizationService/ListAuthorizations * * Implementation note (filter shape & response parsing) * ----------------------------------------------------- * The v2 AuthorizationService accepts a `filters` array of oneof variants * (project_id, organization_id, role_key, …) but the JSON-over-Connect * wrapper naming differs between ZITADEL versions and isn't well-documented * for ID filters. Rather than chase a moving target, we fetch all * authorizations the SA can see and narrow client-side by project+org. * At pilot scale this is a single sub-100-row query — well within budget. * * Response shape (v2 stable, confirmed against ZITADEL v4.12): * authorizations: [{ * id, state, * project: { id, name, organizationId }, * organization: { id, name }, * user: { id, displayName, preferredLoginName, … }, * roles: [{ key, displayName, group }], * }] * * Returns [] on any error so the team page can render a degraded view * (members visible, roles blank) rather than blowing up entirely. */ export async function listOrgAuthorizations( orgId: string ): Promise { try { const data = await connectRpc<{ authorizations?: any[] }>( "zitadel.authorization.v2.AuthorizationService", "ListAuthorizations", { pagination: { limit: 1000 } } ); if (!data?.authorizations || !Array.isArray(data.authorizations)) { return []; } return data.authorizations .filter( (row: any) => row?.project?.id === ZITADEL_PROJECT_ID && row?.organization?.id === orgId ) .map((row: any) => ({ authorizationId: row.id ?? "", userId: row.user?.id ?? "", organizationId: row.organization?.id ?? orgId, projectId: row.project?.id ?? ZITADEL_PROJECT_ID, roleKeys: Array.isArray(row.roles) ? row.roles .map((r: any) => (typeof r?.key === "string" ? r.key : "")) .filter(Boolean) : [], })); } catch (err) { console.warn( `Failed to list authorizations for org ${orgId} (returning empty):`, err ); return []; } } // --------------------------------------------------------------------------- // Full registration flow // --------------------------------------------------------------------------- export interface RegistrationResult { orgId: string; userId: string; projectGrantId: string; } /** * Complete registration flow: * 1. Create ZITADEL Org * 2. Create Human User (no password, email unverified) * 3. Send invite code (ZITADEL emails link to set password + verify email) * 4. Create Project Grant (link OpenClaw Platform project to new org) * 5. Create Authorization (assign "owner" role to user) * * If any step after org creation fails, the org is deleted (rollback). */ export async function registerCustomer(params: { companyName: string; email: string; givenName: string; familyName: string; preferredLanguage?: string; }): Promise { // 1. Create org const org = await createOrganization(params.companyName); try { // 2. Create user in org (no password) const user = await createHumanUser({ orgId: org.organizationId, email: params.email, givenName: params.givenName, familyName: params.familyName, preferredLanguage: params.preferredLanguage, }); // 3. Send invite — user receives email to set password + verify email try { await createInviteCode(user.id); } catch (inviteErr) { // Log but don't fail — SMTP may not be configured yet. // Admin can resend the invite later from ZITADEL console. console.warn( `Invite email could not be sent for user ${user.id} (SMTP may not be configured):`, inviteErr ); } // 4. Grant project to org with both customer roles so the org's // owner can invite users in either `owner` or `user` role afterwards. const grant = await createProjectGrant(org.organizationId, [ "owner", "user", ]); // 5. Assign "owner" role to user await createAuthorization({ userId: user.id, organizationId: org.organizationId, roleKeys: ["owner"], }); return { orgId: org.organizationId, userId: user.id, projectGrantId: grant.projectGrantId, }; } catch (err) { // Rollback: delete the org so the customer can retry console.error( `Registration failed after org creation (${org.organizationId}), rolling back:`, err ); try { await deleteOrganization(org.organizationId); console.log(`Rolled back org ${org.organizationId}`); } catch (rollbackErr) { console.error( `Failed to rollback org ${org.organizationId}:`, rollbackErr ); } throw err; } } // --------------------------------------------------------------------------- // v2 User API — profile updates (Phase 6 fix5) // --------------------------------------------------------------------------- /** * Update a human user's profile (first name + last name + display * name). Returns the new `details.changeDate` from ZITADEL so the * caller can confirm the write landed. * * The v2 user service endpoint is technically a PUT but accepts * partial bodies — only the `profile` block is sent. ZITADEL * preserves email, password, and other fields across the call * (verified empirically in zitadel-server#7786 and documented in * v2.63+ of zitadel-server). * * `displayName` IS sent explicitly, set to "givenName familyName". * Empirically (and contra what some docs suggest), ZITADEL does * NOT recompute displayName when only the name parts change — it * keeps whatever displayName was previously stored, including the * one set at user creation time. That stale displayName is what * ZITADEL surfaces in the OIDC `name` claim, so without this * explicit write the portal session would never see the updated * name (even after sign-out / sign-in). * * Auth: the portal's service-account PAT (ZITADEL_SA_PAT). The PAT * must have user-write permission in the user's resource org. * Today portal-zitadel-sa-pat already has user-write for * createHumanUser etc. — same scope covers this. */ export interface UpdateHumanUserProfileResult { changeDate: string; /** The displayName ZITADEL stored, which the OIDC `name` claim will * carry on the user's next session. */ displayName: string; } export async function updateHumanUserProfile(params: { userId: string; givenName: string; familyName: string; preferredLanguage?: string; }): Promise { const path = `/v2/users/human/${encodeURIComponent(params.userId)}`; // Compose the displayName ourselves so ZITADEL stores something // sensible. Empty-string fallback only triggers if both name parts // are blank, which the API zod schema prevents anyway. const displayName = `${params.givenName.trim()} ${params.familyName.trim()}`.trim(); type ZitadelUpdateResponse = { details?: { changeDate?: string }; }; // preferredLanguage is part of the same `profile` block; include it // only when provided so a name-only update doesn't clobber it. const profile: { givenName: string; familyName: string; displayName: string; preferredLanguage?: string; } = { givenName: params.givenName, familyName: params.familyName, displayName, }; if (params.preferredLanguage) { profile.preferredLanguage = params.preferredLanguage; } await zitadelFetch(path, "PUT", { profile }); // Re-fetch the user to read back the canonical displayName ZITADEL // committed. Should match what we sent, but reading from the source // of truth catches any sanitization ZITADEL might apply. const detail = await getHumanUserDetail(params.userId); return { changeDate: new Date().toISOString(), displayName: detail.displayName || displayName, }; } /** * Fetch a human user's current profile (given/family/display name + * email). Used by the settings page to populate the form and by the * update helper above to read back the computed displayName. */ export interface HumanUserDetail { userId: string; givenName: string; familyName: string; displayName: string; email: string; /** ZITADEL profile preferredLanguage (e.g. "de"); "" if unset. */ preferredLanguage: string; } export async function getHumanUserDetail( userId: string ): Promise { type ZitadelGetUserResponse = { user?: { userId?: string; human?: { profile?: { givenName?: string; familyName?: string; displayName?: string; preferredLanguage?: string; }; email?: { email?: string }; }; }; }; const response = await zitadelFetch( `/v2/users/${encodeURIComponent(userId)}`, "GET" ); const human = response.user?.human; return { userId: response.user?.userId ?? userId, givenName: human?.profile?.givenName ?? "", familyName: human?.profile?.familyName ?? "", displayName: human?.profile?.displayName ?? "", email: human?.email?.email ?? "", preferredLanguage: human?.profile?.preferredLanguage ?? "", }; }