/** * 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. * 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"], } ); } // --------------------------------------------------------------------------- // 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"], } ); } // --------------------------------------------------------------------------- // Delete Organization (for rollback on partial failure) // --------------------------------------------------------------------------- export async function deleteOrganization(orgId: string): Promise { await zitadelFetch(`/v2/organizations/${orgId}`, "DELETE"); } // --------------------------------------------------------------------------- // 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 const grant = await createProjectGrant(org.organizationId, ["owner"]); // 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; } }