import { NextResponse } from "next/server"; import { requirePlatformRole } from "@/lib/session"; import { getTenantRequestById, updateTenantRequestStatus, clearEncryptedSecrets, recordTenantCreated, recordSkillEvents, recordSuspensionEvent, } from "@/lib/db"; import { createTenant, patchTenantSpec, setTenantAnnotation } from "@/lib/k8s"; import { sendApprovalEmail, sendResumeApprovalEmail } from "@/lib/email"; import { decryptSecrets } from "@/lib/crypto"; import { writePackageSecrets } from "@/lib/openbao"; import { getDefaultSoulMd, getDefaultAgentsMd, generateToolsMd, } from "@/lib/workspace-defaults"; import { deriveTenantName } from "@/lib/tenant-naming"; import { safeError } from "@/lib/errors"; /** * POST /api/admin/requests/[id]/approve * * Approve a request. Two paths depending on request_type: * * Provision (the original purpose): * 1. Decrypt stored package secrets (if any) * 2. Write each package's secrets to OpenBao * 3. Null the encrypted_secrets column * 4. Build workspace files (SOUL.md, AGENTS.md, TOOLS.md) * 5. Create PiecedTenant CR * 6. Update request status, notify customer. * Supports re-approving a previously rejected request (clears admin notes). * * Resume (Bug 37a): * 1. PATCH spec.suspend=false on the existing PiecedTenant CR. * 2. Clear the `pieced.ch/resume-request-pending` annotation so the * operator knows the request is settled (and doesn't pause its * 60-day TTL forever — though now that the tenant isn't suspended, * the timer is moot). * 3. Mark request approved, notify customer. * No CR creation, no secret materialisation, no workspace files. */ export async function POST( request: Request, { params }: { params: Promise<{ id: string }> } ) { try { await requirePlatformRole(); } catch { return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } const { id } = await params; const body = await request.json().catch(() => ({})); const adminNotes = body.adminNotes as string | undefined; const tenantRequest = await getTenantRequestById(id); if (!tenantRequest) { return NextResponse.json( { error: "Request not found" }, { status: 404 } ); } if ( tenantRequest.status !== "pending" && tenantRequest.status !== "rejected" ) { return NextResponse.json( { error: `Request is already ${tenantRequest.status}` }, { status: 400 } ); } // Resume request: short path. Just patch the existing tenant, clear // the annotation, mark approved. if (tenantRequest.requestType === "resume") { if (!tenantRequest.tenantName) { // Shouldn't happen — resume requests are created with tenant_name // set. Defensive 500 if it does. return NextResponse.json( { error: "Resume request has no tenant_name" }, { status: 500 } ); } try { await patchTenantSpec(tenantRequest.tenantName, { suspend: false }); // Billing — Phase 1: record the resume so monthly proration // counts the suspended segment correctly. Best-effort; if // logging fails, the approval still succeeds. try { await recordSuspensionEvent( tenantRequest.tenantName, tenantRequest.zitadelOrgId, "resumed" ); } catch (e) { console.error( "billing: failed to record resumed suspension event:", e ); } // Clear the annotation that pauses the operator's 60-day TTL. // Best-effort — annotation cleanup is also done by the operator // when it sees suspend=false on the next reconcile (it clears // status.suspendedAt), but explicitly clearing here keeps the // CR clean. try { await setTenantAnnotation( tenantRequest.tenantName, "pieced.ch/resume-request-pending", null ); } catch (e) { console.warn( "post-approve annotation clear failed; not blocking", e ); } await updateTenantRequestStatus(id, "approved", { adminNotes }); await sendResumeApprovalEmail( tenantRequest.contactEmail, tenantRequest.contactName, tenantRequest.companyName ).catch((e) => console.error("resume approval email failed:", e)); return NextResponse.json({ message: "Resume approved. Tenant is reactivating.", tenantName: tenantRequest.tenantName, }); } catch (e: any) { console.error("Resume approval failed:", e); return NextResponse.json( { error: safeError(e, "Failed to approve resume") }, { status: 500 } ); } } const isReApproval = tenantRequest.status === "rejected"; // Build the CR name: see `lib/tenant-naming.ts` for the format spec. // Slice 4: for personal accounts the slug is replaced by the literal // "p-" prefix so no PII is embedded in the K8s namespace name. const tenantName = deriveTenantName( tenantRequest.isPersonal ? "personal" : "company", tenantRequest.companyName, tenantRequest.id ); try { // Step 1: Decrypt and write package secrets to OpenBao (if collected during wizard) if (tenantRequest.encryptedSecrets) { const secrets = await decryptSecrets(tenantRequest.encryptedSecrets); for (const [packageId, pkgSecrets] of Object.entries(secrets)) { await writePackageSecrets( `tenant-${tenantName}`, packageId, pkgSecrets ); } // Step 2: Null the encrypted column — secrets are now safely in OpenBao await clearEncryptedSecrets(id); } // Step 3: Build workspace files const packages = tenantRequest.packages ?? []; const soulMd = tenantRequest.soulMd || (await getDefaultSoulMd(tenantRequest.companyName)); const agentsMd = tenantRequest.agentsMd || (await getDefaultAgentsMd()); const toolsMd = await generateToolsMd(packages); const workspaceFiles: Record = { "SOUL.md": soulMd, "AGENTS.md": agentsMd, "TOOLS.md": toolsMd, }; // Step 4: Create the PiecedTenant CR. // displayName precedence: // 1. customer-chosen instance name (Slice 3 multi-tenant) // 2. for personal accounts, the contact name (avoids exposing the // synthetic "{name} (Personal)" company name in the OpenClaw UI) // 3. company name otherwise const displayName = tenantRequest.instanceName && tenantRequest.instanceName.trim().length > 0 ? tenantRequest.instanceName.trim() : tenantRequest.isPersonal ? tenantRequest.contactName || "Assistant" : tenantRequest.companyName; await createTenant( tenantName, { displayName, agentName: tenantRequest.agentName, packages, workspaceFiles, }, { "pieced.ch/zitadel-org-id": tenantRequest.zitadelOrgId, // Bug 7: stamp the personal flag on the CR so callers (notably // the tenant detail page) can hide assignment-related UI // without an extra DB join. Slice 4 already tracks this on the // request row; the CR label is the same fact at the K8s layer. // Legacy tenants approved before this change won't carry the // label — operators can backfill with `kubectl label`. ...(tenantRequest.isPersonal ? { "pieced.ch/personal": "true" } : {}), } ); // Billing — Phase 1: record the tenant's creation and initial // package state. Anchored at "now" rather than the CR's // creationTimestamp because we don't get the timestamp back from // createTenant — the few-millisecond skew vs the CR's actual // creationTimestamp is irrelevant for monthly billing. // // Best-effort: tracking failures must never block provisioning. // The backfill helper can repair any gaps later if needed. const billingAnchor = new Date(); try { await recordTenantCreated( tenantName, tenantRequest.zitadelOrgId, billingAnchor ); await recordSkillEvents( tenantName, tenantRequest.zitadelOrgId, packages, [], billingAnchor ); } catch (e) { console.error( "billing: failed to record tenant creation / initial skill events:", e ); } // Step 5: Update request status — clear admin notes on re-approval const updated = await updateTenantRequestStatus(id, "provisioning", { adminNotes: isReApproval ? null : adminNotes, tenantName, clearAdminNotes: isReApproval, }); // Step 6: Notify customer await sendApprovalEmail( tenantRequest.contactEmail, tenantRequest.contactName, tenantRequest.companyName ); return NextResponse.json({ message: "Tenant approved and provisioning started.", request: updated, tenantName, }); } catch (e: any) { console.error("Failed to create tenant:", e); return NextResponse.json( { error: safeError(e, "Failed to create tenant") }, { status: 500 } ); } }