import { NextRequest, NextResponse } from "next/server"; import { getSessionUser, canMutate } from "@/lib/session"; import { createTenantRequest, getTenantRequestById, listTenantRequestsByOrgId, listActiveTenantRequestsByOrgId, getMostRecentApprovedRequestForOrg, getOrgBilling, upsertOrgBilling, } from "@/lib/db"; import { getTenant, listTenants } from "@/lib/k8s"; import { listVisibleTenants, canUserSeeTenant, canSeeInflightRequests, } from "@/lib/visibility"; import { sendAdminNotificationEmail } from "@/lib/email"; import { encryptSecrets } from "@/lib/crypto"; import { isPersonalOrgName } from "@/lib/personal-org"; import { onboardingSchema, billingAddressSchema } from "@/lib/validation"; import type { OnboardingInput, PiecedTenant, TenantRequest } from "@/types"; import { z } from "zod"; /** * Helper: shape a TenantRequest row for client consumption. * Hides server-only fields (encryptedSecrets, internal db ids). */ /** * Helper: shape a TenantRequest row for client consumption. * Hides server-only fields (encryptedSecrets, internal db ids). * * Slice 7 / Bug 6: surfaces enough fields for the customer-side edit * flow to pre-fill the wizard. soulMd, agentsMd, billingAddress, * billingNotes were previously kept off the public shape because the * pre-Slice-3 dashboard didn't render them. Edit needs them. * * Bug 13: surfaces dismissedAt so the dashboard can distinguish * "freshly rejected, show prominently" from "rejected and acknowledged, * keep hidden" without an extra API call. */ function publicRequestShape(r: TenantRequest) { return { id: r.id, instanceName: r.instanceName, agentName: r.agentName, soulMd: r.soulMd, agentsMd: r.agentsMd, packages: r.packages, billingAddress: r.billingAddress, billingNotes: r.billingNotes, status: r.status, adminNotes: r.adminNotes, tenantName: r.tenantName, dismissedAt: r.dismissedAt ?? null, createdAt: r.createdAt, updatedAt: r.updatedAt, }; } function publicTenantShape(t: PiecedTenant) { return { name: t.metadata.name, displayName: t.spec.displayName, phase: t.status?.phase ?? "Pending", suspended: t.spec.suspend ?? false, packages: t.spec.packages ?? [], creationTimestamp: t.metadata.creationTimestamp, conditions: t.status?.conditions ?? [], }; } /** * GET /api/onboarding * * Two response shapes depending on the `?id=` query: * * - With `?id=`: returns the single request's status plus * the linked tenant's phase if approved. Used by ProvisioningStatus * to poll a specific request. The id is validated against the * caller's orgId so admins-and-only-admins can read across orgs. * * - Without `id`: returns lists of all in-flight requests and active * tenants for the caller's org. Used by the dashboard to render the * multi-tenant view. * * Slice 3 note: this replaces the old single-state response shape * (`{ state: "...", request: {...} }`). Pre-Slice-3 callers will see * the new shape and need to be updated. The only known caller is * ``, updated in lockstep. */ export async function GET(req: NextRequest) { const user = await getSessionUser(); if (!user) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } const requestedId = req.nextUrl.searchParams.get("id"); if (requestedId) { const tr = await getTenantRequestById(requestedId); if (!tr) { return NextResponse.json({ error: "Not found" }, { status: 404 }); } // Customers may only read their own org's requests; platform // admins/operators may read any. if (!user.isPlatform && tr.zitadelOrgId !== user.orgId) { return NextResponse.json({ error: "Not found" }, { status: 404 }); } // Slice 6: a `user`-role customer doesn't see in-flight requests // even within their own org — they can't act on them and showing // the row would be a permanent "pending" state with no exit. Owner // and platform skip this gate. if (!canSeeInflightRequests(user)) { return NextResponse.json({ error: "Not found" }, { status: 404 }); } let tenant: PiecedTenant | null = null; if (tr.tenantName) { tenant = (await getTenant(tr.tenantName)) ?? null; // If a request is already linked to a tenant CR and the caller // can't see that tenant (assignment scope), don't expose it via // the request endpoint either. canSeeInflightRequests above // already shortcuts this for `user`-role, but defense in depth. if (tenant && !(await canUserSeeTenant(user, tenant))) { return NextResponse.json({ error: "Not found" }, { status: 404 }); } } return NextResponse.json({ request: publicRequestShape(tr), tenant: tenant ? publicTenantShape(tenant) : null, }); } // List view: requests + tenants for this org, filtered by visibility. // For owner/platform, this returns the same data as pre-Slice-6. // For user-role, requests is forced to [] and tenants is narrowed to // assignments. const [requests, allTenants] = await Promise.all([ listActiveTenantRequestsByOrgId(user.orgId), listTenants(), ]); const visibleTenants = await listVisibleTenants(user, allTenants); const visibleRequests = canSeeInflightRequests(user) ? requests : []; return NextResponse.json({ requests: visibleRequests.map(publicRequestShape), tenants: visibleTenants.map(publicTenantShape), }); } /** * POST /api/onboarding * * Always creates a NEW tenant_request row, regardless of how many other * rows already exist for this org. The pre-Slice-3 409 ("you already * have a request") is gone — multi-tenant is the design now. * * For additional instances in an existing company, the customer's prior * approved row is used to seed billing/contact info, so the wizard * doesn't need to re-collect data already on file. The wizard *does* * still send a billingAddress payload (the field is required by the * schema), but in practice the client can pre-fill it from * `getMostRecentApprovedRequestForOrg`. * * Encrypted package secrets, if provided, are AES-256-GCM-sealed and * stored as a BYTEA blob. They are decrypted only during admin approval * to write to OpenBao. */ export async function POST(request: Request) { const user = await getSessionUser(); if (!user) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } // Slice 5: only owners (or platform users) may create new instances. // A `user`-role member of an existing org cannot self-provision. if (!canMutate(user)) { return NextResponse.json( { error: "Only the organization owner can create new instances." }, { status: 403 } ); } const body = await request.json(); const parsed = onboardingSchema.safeParse(body); if (!parsed.success) { return NextResponse.json( { error: "Invalid input", details: parsed.error.flatten() }, { status: 400 } ); } const input: OnboardingInput & { packageSecrets?: Record>; } = parsed.data; // Look up an existing approved request for this org to inherit // company-level billing data. For brand-new orgs (first registration), // there is no prior row and we use the form-supplied billingAddress // verbatim. For follow-up requests, we ignore the form-supplied // company line in favour of the recorded company name. const prior = await getMostRecentApprovedRequestForOrg(user.orgId); // Slice 4: detect personal-account orgs by the canonical " (Personal)" // suffix on the ZITADEL org name. Set at registration, stable for the // lifetime of the org. Persisted on the row so admin views and the // approve handler don't have to re-derive it. // // If any prior row has is_personal set, prefer that — it's the same // org and the value can't change. (The prior-row check is defensive; // the org-name check should agree.) const isPersonal = prior?.isPersonal ?? isPersonalOrgName(user.orgName); // Bug 5: personal accounts are 1-instance by design. If there's // already an active tenant or an in-flight request for this user's // org, reject the submission outright. Server-side only check; // matching UI guards live on /dashboard (button hidden) and // /dashboard/new (server-redirect to /dashboard). if (isPersonal) { const [allTenants, activeRequests] = await Promise.all([ listTenants(), listActiveTenantRequestsByOrgId(user.orgId), ]); const ownTenants = allTenants.filter( (t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId ); if (ownTenants.length > 0 || activeRequests.length > 0) { return NextResponse.json( { error: "Personal accounts are limited to one instance. Cancel your existing request or contact support to change plan.", code: "personal_account_at_capacity", }, { status: 403 } ); } } // Encrypt package secrets if provided let encryptedSecrets: Buffer | 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 } ); } } // For follow-up instances, prefer the on-file company name and contact // details; the user can't change those by re-typing them in the wizard. const companyName = prior?.companyName ?? user.orgName; const contactName = prior?.contactName ?? user.name; const contactEmail = prior?.contactEmail ?? user.email; // Bug 35: org-scoped billing. // // Resolution rules: // 1. If org_billing exists, use it (synthesise a BillingAddress // shape for the audit copy on tenant_requests). Wizard's // submitted billingAddress is ignored — the org has billing // on file, the wizard skipped that step. // 2. If no org_billing AND wizard supplied billingAddress, use // the wizard's data and save to org_billing for next time. // VAT is enforced by billingAddressSchema (required for // everyone). // 3. If no org_billing AND no wizard billingAddress: reject. // Billing is required for all customers regardless of // personal/company org structure — we're a commercial // product. Personal accounts (sole proprietors, individuals) // are still subject to billing capture. // // The synthetic BillingAddress for case 1 collapses fields that // org_billing has more granularly; good enough for audit, since // /settings/billing is the authoritative editor going forward. const orgBilling = await getOrgBilling(user.orgId); let billingAddress: TenantRequest["billingAddress"]; let billingNotes = input.billingNotes ?? prior?.billingNotes; if (orgBilling) { billingAddress = { company: orgBilling.companyName, street: orgBilling.streetAddress, postalCode: orgBilling.postalCode, city: orgBilling.city, country: orgBilling.country, vatNumber: orgBilling.vatNumber ?? undefined, }; } else if (input.billingAddress) { // Wizard supplied billing — re-validate the strict shape (the // outer onboardingSchema marks it optional now, so we can't rely // on its enforcement of the inner required fields). const billingCheck = billingAddressSchema.safeParse(input.billingAddress); if (!billingCheck.success) { return NextResponse.json( { error: "Invalid billing address", details: billingCheck.error.flatten(), }, { status: 400 } ); } // Company orgs (B2B) require companyName AND vatNumber. // Personal orgs (B2C — private individuals) require neither; // the wizard hides both fields for them and the API doesn't // enforce. if (!isPersonal) { const missing: Record = {}; if ( !billingCheck.data.company || billingCheck.data.company.trim().length === 0 ) { missing["billingAddress.company"] = ["Required for companies"]; } if ( !billingCheck.data.vatNumber || billingCheck.data.vatNumber.length === 0 ) { missing["billingAddress.vatNumber"] = ["Required for companies"]; } if (Object.keys(missing).length > 0) { return NextResponse.json( { error: "Company name and VAT number are required for company accounts.", details: { fieldErrors: missing }, }, { status: 400 } ); } } billingAddress = billingCheck.data; // Persist to org_billing. For personal customers (B2C, no // company line), fall back to their display name from the // session — invoices addressed to their actual name rather than // an opaque org id like "personal-3f2a8b1c". For companies the // wizard's company field is filled. const personalDisplayName = (user.name || user.email || "").trim(); try { await upsertOrgBilling({ zitadelOrgId: user.orgId, companyName: (billingCheck.data.company || "").trim() || (isPersonal ? personalDisplayName : user.orgName) || user.orgName, streetAddress: billingCheck.data.street, postalCode: billingCheck.data.postalCode, city: billingCheck.data.city, country: billingCheck.data.country, // Personal: undefined (no VAT). Company: enforced non-empty // by the check above. vatNumber: isPersonal ? null : billingCheck.data.vatNumber!, billingEmail: contactEmail, notes: billingNotes ?? null, }); } catch (e) { // Non-fatal — the tenant_request still gets created with the // billingAddress audit copy. The customer can re-save via // /settings/billing if this failed. console.warn( "failed to save org_billing on first capture; tenant_request still created with audit copy", e ); } } else { // No billing supplied AND no org_billing record. Required for // everyone — commercial product, no personal-orgs-skip // shortcut. Customer must complete the wizard's billing step // or set up /settings/billing first. return NextResponse.json( { error: "Billing information is required. Please complete the billing step or set it up at /settings/billing.", details: { fieldErrors: { billingAddress: ["Required"], }, }, }, { status: 400 } ); } const tenantRequest = await createTenantRequest({ zitadelOrgId: user.orgId, zitadelUserId: user.id, companyName, instanceName: input.instanceName, contactName, contactEmail, agentName: input.agentName, soulMd: input.soulMd, agentsMd: input.agentsMd, packages: input.packages ?? [], billingAddress, billingNotes, encryptedSecrets, isPersonal, }); // Notify admin about the new request. For follow-up instances, include // the instance name in the notification so the admin sees what's // being requested without opening the panel. try { await sendAdminNotificationEmail( tenantRequest.contactEmail, tenantRequest.contactName, tenantRequest.instanceName ? `${tenantRequest.companyName} (${tenantRequest.instanceName})` : tenantRequest.companyName ); } catch (e) { console.error("Failed to send admin notification:", e); } // For diagnostics: how many other in-flight requests does this org // already have? Useful for the admin queue. const allRequests = await listTenantRequestsByOrgId(user.orgId); return NextResponse.json( { message: "Request submitted.", request: publicRequestShape(tenantRequest), orgRequestCount: allRequests.length, }, { status: 201 } ); }