import { NextRequest, NextResponse } from "next/server"; import { getSessionUser, canMutate } from "@/lib/session"; import { getTenantRequestById, updateTenantRequestStatus, updateTenantRequestEditableFields, } from "@/lib/db"; import { encryptSecrets } from "@/lib/crypto"; import { setTenantAnnotation } from "@/lib/k8s"; import { onboardingSchema } from "@/lib/validation"; import { safeError } from "@/lib/errors"; /** * Customer-side controls for a single tenant_request row. * * - DELETE /api/onboarding/[id] → cancel a still-pending request * - PATCH /api/onboarding/[id] → edit fields of a still-pending * request (Bug 6) * * Both endpoints share the same authorization check: the caller must * be a customer owner (or platform staff) of the request's org. We * also enforce status === 'pending' on the row — once an admin has * acted on it, the customer can no longer mutate it from the portal. * * Reading these is via the existing GET /api/onboarding?id=... handler. */ async function loadAuthorized( id: string ): Promise< | { error: NextResponse } | { req: Awaited>; } > { const user = await getSessionUser(); if (!user) { return { error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }), }; } if (!canMutate(user)) { return { error: NextResponse.json({ error: "Forbidden" }, { status: 403 }), }; } const tr = await getTenantRequestById(id); if (!tr) { return { error: NextResponse.json({ error: "Not found" }, { status: 404 }), }; } // Customers may only read their own org's requests; platform users // may read any. Same scope as `GET /api/onboarding?id=...`. if (!user.isPlatform && tr.zitadelOrgId !== user.orgId) { return { error: NextResponse.json({ error: "Not found" }, { status: 404 }), }; } return { req: tr }; } /** * DELETE /api/onboarding/[id] * * Customer cancels a still-pending request. Status flips to 'cancelled'; * the row is preserved for audit. The customer can dismiss the * cancelled card afterwards (Bug 13 reuse — same dismissal mechanism). * * Once admin has approved/provisioned/rejected, this endpoint refuses * (409). Cancelling a tenant that's already running goes through the * subscription-suspend flow on the tenant detail page, not here. */ export async function DELETE( _req: NextRequest, { params }: { params: Promise<{ id: string }> } ) { const { id } = await params; const loaded = await loadAuthorized(id); if ("error" in loaded) return loaded.error; const tr = loaded.req!; if (tr.status !== "pending") { return NextResponse.json( { error: "Only pending requests can be cancelled. Approved or provisioning instances must be managed from the tenant page.", code: "not_pending", currentStatus: tr.status, }, { status: 409 } ); } try { await updateTenantRequestStatus(id, "cancelled"); // Customer cancels their own pending resume request: clear the // operator-side annotation so the 60-day TTL resumes counting. // Best-effort — the operator handles missing annotation gracefully. if (tr.requestType === "resume" && tr.tenantName) { try { await setTenantAnnotation( tr.tenantName, "pieced.ch/resume-request-pending", null ); } catch (e) { console.warn( "post-cancel annotation clear failed; not blocking", e ); } } return NextResponse.json({ message: "Request cancelled.", id }); } catch (e: any) { console.error("Failed to cancel request:", e); return NextResponse.json( { error: safeError(e, "Failed to cancel request") }, { status: 500 } ); } } /** * PATCH /api/onboarding/[id] * * Customer edits a still-pending request. Validation is the same as on * POST /api/onboarding (shared schema). Only customer-input fields are * editable; status/tenant_name/admin_notes/etc. are server-managed. * * Note on company-level fields * ---------------------------- * For a follow-up instance (org has prior approved rows), the POST * handler intentionally ignores the wizard's billingAddress and uses * the on-file value instead. We mirror that here: company-level fields * (companyName, contactName, contactEmail, billingAddress) on a * follow-up edit are NOT updated through this endpoint. The customer * should use a future settings page (Bug 11) for those. For now, * editing only mutates per-instance fields — agent name, instance * name, packages, soulMd, agentsMd, billingNotes, packageSecrets. * * For the FIRST instance (no prior approved rows), billingAddress IS * editable here, since the customer is still defining their company's * billing data. */ export async function PATCH( req: NextRequest, { params }: { params: Promise<{ id: string }> } ) { const { id } = await params; const loaded = await loadAuthorized(id); if ("error" in loaded) return loaded.error; const tr = loaded.req!; if (tr.status !== "pending") { return NextResponse.json( { error: "Only pending requests can be edited.", code: "not_pending", currentStatus: tr.status, }, { status: 409 } ); } const body = await req.json().catch(() => null); const parsed = onboardingSchema.safeParse(body); if (!parsed.success) { return NextResponse.json( { error: "Invalid input", details: parsed.error.flatten() }, { status: 400 } ); } const input = parsed.data; // Re-encrypt package secrets if present in the patch body. When the // user re-opens the wizard to edit, the secrets array is populated // afresh from the wizard (we never decrypt and return existing // secrets — that'd be a security regression). If the user didn't // touch any secret-bearing package, the wizard sends no // packageSecrets and we leave the existing encrypted blob alone. let encryptedSecrets: Buffer | null | undefined; if (input.packageSecrets && Object.keys(input.packageSecrets).length > 0) { try { encryptedSecrets = await encryptSecrets(input.packageSecrets); } catch (e: any) { console.error("Failed to encrypt package secrets:", e); return NextResponse.json( { error: "Failed to secure credentials. Please try again." }, { status: 500 } ); } } // Only first-instance edits get billingAddress; follow-ups inherit // company billing from the on-file approved row. const isFirstInstance = !tr.tenantName; // approximation; covers the // "no prior approved row for this org" case the POST handler treats // identically. A more rigorous check would call // getMostRecentApprovedRequestForOrg, but in practice an org with // an approved row for some other tenant has a tenantName on those // rows, not on the pending one being edited — so the simple check // here is fine for the only state the endpoint accepts (pending). try { const updated = await updateTenantRequestEditableFields(id, { instanceName: input.instanceName, agentName: input.agentName, soulMd: input.soulMd, agentsMd: input.agentsMd, packages: input.packages ?? [], billingAddress: isFirstInstance ? input.billingAddress : undefined, billingNotes: input.billingNotes, encryptedSecrets, }); if (!updated) { return NextResponse.json({ error: "Not found" }, { status: 404 }); } return NextResponse.json({ message: "Request updated.", id }); } catch (e: any) { console.error("Failed to edit request:", e); return NextResponse.json( { error: safeError(e, "Failed to edit request") }, { status: 500 } ); } }