641 lines
21 KiB
TypeScript
641 lines
21 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.
|
|
*
|
|
* 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<ProjectGrantResult> {
|
|
return connectRpc<ProjectGrantResult>(
|
|
"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<string[]> {
|
|
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<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"],
|
|
}
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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<void> {
|
|
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<OrgUser[]> {
|
|
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<OrgAuthorization[]> {
|
|
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<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 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;
|
|
}): Promise<UpdateHumanUserProfileResult> {
|
|
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 };
|
|
};
|
|
await zitadelFetch<ZitadelUpdateResponse>(path, "PUT", {
|
|
profile: {
|
|
givenName: params.givenName,
|
|
familyName: params.familyName,
|
|
displayName,
|
|
},
|
|
});
|
|
// 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;
|
|
}
|
|
|
|
export async function getHumanUserDetail(
|
|
userId: string
|
|
): Promise<HumanUserDetail> {
|
|
type ZitadelGetUserResponse = {
|
|
user?: {
|
|
userId?: string;
|
|
human?: {
|
|
profile?: {
|
|
givenName?: string;
|
|
familyName?: string;
|
|
displayName?: string;
|
|
};
|
|
email?: { email?: string };
|
|
};
|
|
};
|
|
};
|
|
const response = await zitadelFetch<ZitadelGetUserResponse>(
|
|
`/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 ?? "",
|
|
};
|
|
}
|