import { NextRequest, NextResponse } from "next/server"; import { getSessionUser, canMutate } from "@/lib/session"; import { createTenantRequest, getTenantRequestById, listTenantRequestsByOrgId, listActiveTenantRequestsByOrgId, getMostRecentApprovedRequestForOrg, } 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 type { OnboardingInput, PiecedTenant, TenantRequest } from "@/types"; import { z } from "zod"; const onboardingSchema = z.object({ instanceName: z .string() .trim() .max(80) .optional() // Empty string from a form input → drop to undefined so the DB stores NULL .transform((v) => (v && v.length > 0 ? v : undefined)), agentName: z.string().min(1).max(50), soulMd: z.string().max(10_000).optional(), agentsMd: z.string().max(10_000).optional(), packages: z.array(z.string()).optional(), packageSecrets: z .record(z.string(), z.record(z.string(), z.string())) .optional(), billingAddress: z.object({ company: z.string().optional(), street: z.string().optional(), city: z.string().optional(), postalCode: z.string().optional(), country: z.string().optional(), }), billingNotes: z.string().max(2_000).optional(), }); /** * Helper: shape a TenantRequest row for client consumption. * Hides server-only fields (encryptedSecrets, internal db ids). */ function publicRequestShape(r: TenantRequest) { return { id: r.id, instanceName: r.instanceName, agentName: r.agentName, packages: r.packages, status: r.status, adminNotes: r.adminNotes, tenantName: r.tenantName, 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); // 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; const billingAddress = prior?.billingAddress ?? input.billingAddress; const billingNotes = input.billingNotes ?? prior?.billingNotes; 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 } ); }