Files
pieced-portal/src/lib/zitadel.ts
2026-04-10 21:56:31 +02:00

301 lines
8.8 KiB
TypeScript

/**
* 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<T>(
path: string,
method: string = "GET",
body?: unknown,
headers?: Record<string, string>
): Promise<T> {
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<T>;
}
/**
* Connect RPC call — ZITADEL v2 services use Connect protocol.
* Same as REST but requires Connect-Protocol-Version header.
*/
async function connectRpc<T>(
service: string,
method: string,
body: unknown
): Promise<T> {
return zitadelFetch<T>(
`/${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<CreateOrgResult> {
return zitadelFetch<CreateOrgResult>("/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<CreateUserResult> {
return zitadelFetch<CreateUserResult>("/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<void> {
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<ProjectGrantResult> {
return connectRpc<ProjectGrantResult>(
"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<AuthorizationResult> {
return connectRpc<AuthorizationResult>(
"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<void> {
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<RegistrationResult> {
// 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;
}
}