import { NextResponse } from "next/server"; import { requirePlatformRole } from "@/lib/session"; import { getTenantRequestById, updateTenantRequestStatus, clearEncryptedSecrets, } 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 { createRoute as createRelayRoute } from "@/lib/threema-relay"; 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 }); // 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; // Phase 9b: split the customer's initial channel-user ids into // (a) ids the operator needs in spec.channelUsers (telegram, // discord, …) — passed straight into createTenant // (b) Threema ids that ALSO need a relay route registered so // inbound messages reach this tenant. Threema is in (a) // AND (b): spec.channelUsers tells the operator the id is // authorized; the relay's route maps inbound traffic from // that id to this tenant. const initialChannelUsers = tenantRequest.channelUsers ?? {}; // Strip channels the customer didn't actually enable (defensive // — the wizard already filters this, but the row could carry // stale data if the customer edited their request post-submit). const filteredChannelUsers: Record = {}; for (const [channel, ids] of Object.entries(initialChannelUsers)) { if (!packages.includes(channel)) continue; const cleaned = (ids ?? []) .map((s) => (s ?? "").trim()) .filter((s) => s.length > 0); if (cleaned.length > 0) { filteredChannelUsers[channel] = cleaned; } } await createTenant( tenantName, { displayName, agentName: tenantRequest.agentName, packages, workspaceFiles, ...(Object.keys(filteredChannelUsers).length > 0 ? { channelUsers: filteredChannelUsers } : {}), }, { "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" } : {}), } ); // Threema: register relay routes for each id the customer // entered. Best-effort — a route failure doesn't unwind the // tenant creation (admin can retry from the tenant page later). // The Threema package itself isn't enabled on the tenant until // the customer toggles it from the tenant detail page (which // also mints the per-tenant token); the routes here pre-warm // the relay so the first toggle works without re-typing the id. if ( packages.includes("threema") && filteredChannelUsers.threema && filteredChannelUsers.threema.length > 0 ) { for (const tid of filteredChannelUsers.threema) { try { const res = await createRelayRoute(tenantName, tid); if (!res.ok) { console.warn( `[approve] Threema route create for tenant=${tenantName} id=${tid} returned not-ok: ${res.message}` ); } } catch (e) { console.error( `[approve] Threema route create threw for tenant=${tenantName} id=${tid}:`, 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 } ); } }