Session 6.3
This commit is contained in:
175
src/lib/db.ts
Normal file
175
src/lib/db.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Database client for the portal-db PostgreSQL database.
|
||||
*
|
||||
* Uses the `pg` package directly — no ORM overhead for a single table.
|
||||
* The tenant_requests table acts as the approval gate between customer
|
||||
* registration and actual PiecedTenant CR creation.
|
||||
*
|
||||
* Connection: via DATABASE_URL env var pointing to CloudNativePG cluster.
|
||||
*/
|
||||
|
||||
import { Pool } from "pg";
|
||||
import type { TenantRequest, TenantRequestStatus } from "@/types";
|
||||
|
||||
// Lazy-init: pool is created on first use, not at module import time.
|
||||
// This avoids "Invalid URL" errors during Next.js build when env vars
|
||||
// aren't available yet.
|
||||
let _pool: Pool | null = null;
|
||||
|
||||
function getPool(): Pool {
|
||||
if (!_pool) {
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) throw new Error("DATABASE_URL is not set");
|
||||
_pool = new Pool({
|
||||
connectionString: url,
|
||||
max: 5,
|
||||
idleTimeoutMillis: 30_000,
|
||||
});
|
||||
}
|
||||
return _pool;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schema migration (idempotent)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MIGRATION_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS tenant_requests (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
zitadel_org_id TEXT NOT NULL UNIQUE,
|
||||
zitadel_user_id TEXT NOT NULL,
|
||||
company_name TEXT NOT NULL,
|
||||
contact_name TEXT NOT NULL,
|
||||
contact_email TEXT NOT NULL,
|
||||
agent_name TEXT NOT NULL DEFAULT 'Assistant',
|
||||
soul_md TEXT,
|
||||
packages TEXT[] DEFAULT '{}',
|
||||
billing_address JSONB DEFAULT '{}',
|
||||
billing_notes TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
admin_notes TEXT,
|
||||
tenant_name TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tenant_requests_status ON tenant_requests(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_tenant_requests_org_id ON tenant_requests(zitadel_org_id);
|
||||
`;
|
||||
|
||||
let migrated = false;
|
||||
|
||||
export async function ensureSchema(): Promise<void> {
|
||||
if (migrated) return;
|
||||
await getPool().query(MIGRATION_SQL);
|
||||
migrated = true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function createTenantRequest(
|
||||
params: Omit<TenantRequest, "id" | "status" | "createdAt" | "updatedAt">
|
||||
): Promise<TenantRequest> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query<TenantRequest>(
|
||||
`INSERT INTO tenant_requests
|
||||
(zitadel_org_id, zitadel_user_id, company_name, contact_name,
|
||||
contact_email, agent_name, soul_md, packages, billing_address, billing_notes)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING *`,
|
||||
[
|
||||
params.zitadelOrgId,
|
||||
params.zitadelUserId,
|
||||
params.companyName,
|
||||
params.contactName,
|
||||
params.contactEmail,
|
||||
params.agentName,
|
||||
params.soulMd,
|
||||
params.packages,
|
||||
JSON.stringify(params.billingAddress),
|
||||
params.billingNotes,
|
||||
]
|
||||
);
|
||||
return mapRow(result.rows[0]);
|
||||
}
|
||||
|
||||
export async function getTenantRequestByOrgId(
|
||||
orgId: string
|
||||
): Promise<TenantRequest | null> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
"SELECT * FROM tenant_requests WHERE zitadel_org_id = $1",
|
||||
[orgId]
|
||||
);
|
||||
return result.rows[0] ? mapRow(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
export async function getTenantRequestById(
|
||||
id: string
|
||||
): Promise<TenantRequest | null> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
"SELECT * FROM tenant_requests WHERE id = $1",
|
||||
[id]
|
||||
);
|
||||
return result.rows[0] ? mapRow(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
export async function listTenantRequests(
|
||||
status?: TenantRequestStatus
|
||||
): Promise<TenantRequest[]> {
|
||||
await ensureSchema();
|
||||
const pool = getPool();
|
||||
const query = status
|
||||
? { text: "SELECT * FROM tenant_requests WHERE status = $1 ORDER BY created_at DESC", values: [status] }
|
||||
: { text: "SELECT * FROM tenant_requests ORDER BY created_at DESC", values: [] };
|
||||
const result = await pool.query(query);
|
||||
return result.rows.map(mapRow);
|
||||
}
|
||||
|
||||
export async function updateTenantRequestStatus(
|
||||
id: string,
|
||||
status: TenantRequestStatus,
|
||||
extra?: { adminNotes?: string; tenantName?: string }
|
||||
): Promise<TenantRequest> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
`UPDATE tenant_requests
|
||||
SET status = $1, admin_notes = COALESCE($2, admin_notes),
|
||||
tenant_name = COALESCE($3, tenant_name), updated_at = now()
|
||||
WHERE id = $4
|
||||
RETURNING *`,
|
||||
[status, extra?.adminNotes ?? null, extra?.tenantName ?? null, id]
|
||||
);
|
||||
if (!result.rows[0]) throw new Error(`TenantRequest ${id} not found`);
|
||||
return mapRow(result.rows[0]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Row mapping (snake_case → camelCase)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function mapRow(row: any): TenantRequest {
|
||||
return {
|
||||
id: row.id,
|
||||
zitadelOrgId: row.zitadel_org_id,
|
||||
zitadelUserId: row.zitadel_user_id,
|
||||
companyName: row.company_name,
|
||||
contactName: row.contact_name,
|
||||
contactEmail: row.contact_email,
|
||||
agentName: row.agent_name,
|
||||
soulMd: row.soul_md,
|
||||
packages: row.packages ?? [],
|
||||
billingAddress: typeof row.billing_address === "string"
|
||||
? JSON.parse(row.billing_address)
|
||||
: row.billing_address ?? {},
|
||||
billingNotes: row.billing_notes,
|
||||
status: row.status as TenantRequestStatus,
|
||||
adminNotes: row.admin_notes,
|
||||
tenantName: row.tenant_name,
|
||||
createdAt: row.created_at?.toISOString?.() ?? row.created_at,
|
||||
updatedAt: row.updated_at?.toISOString?.() ?? row.updated_at,
|
||||
};
|
||||
}
|
||||
300
src/lib/zitadel.ts
Normal file
300
src/lib/zitadel.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user