import { NextRequest, NextResponse } from "next/server"; import { getSessionUser, canMutate } from "@/lib/session"; import { canUserSeeTenant } from "@/lib/visibility"; import { getTenant, patchTenantSpec } from "@/lib/k8s"; import { getPackageDef } from "@/lib/packages"; import { createSkillActivationRequest, getOrgBilling, recordSkillEvents, } from "@/lib/db"; import { sendSkillActivationAdminNotification } from "@/lib/email"; import { safeError } from "@/lib/errors"; const ALLOWED_WORKSPACE_FILES = ["SOUL.md", "AGENTS.md", "TOOLS.md"]; const MAX_WORKSPACE_FILE_SIZE = 10_000; export async function GET( _req: NextRequest, { params }: { params: Promise<{ name: string }> } ) { const user = await getSessionUser(); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); const { name } = await params; try { const tenant = await getTenant(name); if (!tenant) return NextResponse.json({ error: "Not found" }, { status: 404 }); // Slice 6: visibility now includes assignment-table check for // user-role members. We return 404 (not 403) to avoid leaking // tenant existence — same as cross-org reads. if (!(await canUserSeeTenant(user, tenant))) { return NextResponse.json({ error: "Not found" }, { status: 404 }); } return NextResponse.json(tenant); } catch (e: any) { return NextResponse.json( { error: safeError(e, "Failed to fetch tenant") }, { status: e.statusCode || 500 } ); } } export async function PATCH( req: NextRequest, { params }: { params: Promise<{ name: string }> } ) { const user = await getSessionUser(); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); if (!canMutate(user)) { return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } const { name } = await params; const body = await req.json(); try { const existing = await getTenant(name); if (!existing) return NextResponse.json({ error: "Not found" }, { status: 404 }); if ( !user.isPlatform && existing.metadata.labels?.["pieced.ch/zitadel-org-id"] !== user.orgId ) { return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } const specPatch: Record = {}; // Track manual-setup gate activations created during this PATCH. // We push to the K8s spec only the non-gated skills; the gated // ones live in skill_activation_requests until admin approves // and adds them via the admin endpoint. Platform admins bypass // the gate (direct enable from /admin still applies immediately). let gatedRequests: Array<{ skillId: string; requestId: string; skillName: string; }> = []; // ── Validate packages against catalog ── if (body.packages !== undefined) { if (!Array.isArray(body.packages) || body.packages.length > 10) { return NextResponse.json( { error: "Invalid packages: must be an array of at most 10 items" }, { status: 400 } ); } for (const pkg of body.packages) { if (typeof pkg !== "string" || !getPackageDef(pkg)) { return NextResponse.json( { error: `Unknown package: ${pkg}` }, { status: 400 } ); } } // Compute the to-be-added set against the existing spec. const existingPackages = new Set(existing.spec.packages ?? []); const desiredPackages: string[] = body.packages; const newlyAdded = desiredPackages.filter( (p) => !existingPackages.has(p) ); // Manual-setup gate. Customer adds get routed to the queue; // platform admins go straight through. if (!user.isPlatform && newlyAdded.length > 0) { const orgIdForGate = existing.metadata.labels?.["pieced.ch/zitadel-org-id"]; if (!orgIdForGate) { // Defensive: every customer-visible tenant should have the // org label. Without it we can't attribute the request. return NextResponse.json( { error: "Tenant missing org binding; contact support." }, { status: 500 } ); } const gatedSet = new Set(); for (const skillId of newlyAdded) { const def = getPackageDef(skillId); if (!def?.requiresManualSetup) continue; gatedSet.add(skillId); try { const req = await createSkillActivationRequest({ tenantName: name, zitadelOrgId: orgIdForGate, zitadelUserId: user.id, skillId, }); gatedRequests.push({ skillId, requestId: req.id, skillName: def.name, }); } catch (e: any) { if (e?.code === "REQUEST_ALREADY_PENDING") { // Idempotent: a pending row already exists; just keep // the skill out of the K8s spec and surface it as // gated without creating a duplicate. gatedRequests.push({ skillId, requestId: "", skillName: def.name, }); } else { throw e; } } } // Strip gated skills from the desired spec — they must not // reach K8s until approved. specPatch.packages = desiredPackages.filter((p) => !gatedSet.has(p)); } else { specPatch.packages = desiredPackages; } } // ── Validate workspaceFiles ── if (body.workspaceFiles !== undefined) { if ( typeof body.workspaceFiles !== "object" || body.workspaceFiles === null || Array.isArray(body.workspaceFiles) ) { return NextResponse.json( { error: "Invalid workspaceFiles: must be an object" }, { status: 400 } ); } for (const [key, value] of Object.entries(body.workspaceFiles)) { if (!ALLOWED_WORKSPACE_FILES.includes(key)) { return NextResponse.json( { error: `Invalid workspace file: ${key}. Allowed: ${ALLOWED_WORKSPACE_FILES.join(", ")}`, }, { status: 400 } ); } if ( typeof value !== "string" || value.length > MAX_WORKSPACE_FILE_SIZE ) { return NextResponse.json( { error: `Workspace file ${key} must be a string of at most ${MAX_WORKSPACE_FILE_SIZE} characters`, }, { status: 400 } ); } } specPatch.workspaceFiles = body.workspaceFiles; } // ── Simple string fields ── if (body.displayName !== undefined) { if ( typeof body.displayName !== "string" || body.displayName.length < 1 || body.displayName.length > 100 ) { return NextResponse.json( { error: "displayName must be 1-100 characters" }, { status: 400 } ); } specPatch.displayName = body.displayName; } if (body.agentName !== undefined) { if ( typeof body.agentName !== "string" || body.agentName.length < 1 || body.agentName.length > 50 ) { return NextResponse.json( { error: "agentName must be 1-50 characters" }, { status: 400 } ); } specPatch.agentName = body.agentName; } // ── channelUsers (basic shape validation) ── if (body.channelUsers !== undefined) { if ( typeof body.channelUsers !== "object" || body.channelUsers === null || Array.isArray(body.channelUsers) ) { return NextResponse.json( { error: "Invalid channelUsers: must be an object" }, { status: 400 } ); } for (const [channel, users] of Object.entries(body.channelUsers)) { if (typeof channel !== "string" || channel.length > 50) { return NextResponse.json( { error: `Invalid channel name: ${channel}` }, { status: 400 } ); } if ( !Array.isArray(users) || (users as any[]).some( (u: any) => typeof u !== "string" || u.length > 100 ) ) { return NextResponse.json( { error: `Invalid user IDs for channel ${channel}` }, { status: 400 } ); } } specPatch.channelUsers = body.channelUsers; } const updated = await patchTenantSpec(name, specPatch); // Billing — Phase 1: if packages changed, record enable/disable // events. The diff is computed against the patched CR (the // returned state) rather than `existing` so the events match // what K8s actually committed. Best-effort: a logging failure // never poisons the PATCH response — drift would be reconciled // on the next backfill or by the next normal toggle. // // Note on races: two concurrent PATCHes could each see the // same `existing` and both succeed at the K8s layer (last write // wins for spec.packages, which is replaced wholesale). The // events from the losing PATCH would then describe a transition // that no longer reflects reality. Acceptable trade-off for v1 // — the toggle UI sends one request at a time and races would // only matter for adjacent same-day toggles, which the billing // computation collapses to a single billable day anyway. if (specPatch.packages !== undefined) { try { const orgId = existing.metadata.labels?.["pieced.ch/zitadel-org-id"] ?? null; if (orgId) { const oldSet = new Set(existing.spec.packages ?? []); const newSet = new Set(updated.spec.packages ?? []); const added = [...newSet].filter((x) => !oldSet.has(x)); const removed = [...oldSet].filter((x) => !newSet.has(x)); if (added.length > 0 || removed.length > 0) { await recordSkillEvents(name, orgId, added, removed); } } else { // A tenant without the org label is a pre-Slice-3 artifact // — we can't attribute its skill events to any org. Log // and skip rather than guess. console.warn( `billing: tenant ${name} has no zitadel-org-id label; skill events not recorded` ); } } catch (e) { console.error( `billing: failed to record skill events for ${name}:`, e ); } } // Phase 2.5: notify admin of newly created activation requests. // Best-effort — email failure must not poison the PATCH response. // requestId === "" means an existing-pending row was reused, so // skip the email in that case (admin already knows). if (gatedRequests.length > 0) { const orgIdForEmail = existing.metadata.labels?.["pieced.ch/zitadel-org-id"] ?? null; const companyName = orgIdForEmail ? await getOrgBilling(orgIdForEmail) .then((b) => b?.companyName ?? null) .catch(() => null) : null; for (const g of gatedRequests) { if (!g.requestId) continue; try { await sendSkillActivationAdminNotification({ tenantName: name, skillId: g.skillId, skillName: g.skillName, requesterEmail: user.email, requesterName: user.name, companyName, }); } catch (e) { console.error( `Failed to send admin notification for skill activation request:`, e ); } } } return NextResponse.json({ ...updated, // Phase 2.5: tells the client which requested-to-enable skills // didn't actually land in the spec because they're awaiting // admin approval. UI uses this to render the "pending review" // state on those skill cards. pendingActivationRequests: gatedRequests.map((g) => ({ skillId: g.skillId, skillName: g.skillName, })), }); } catch (e: any) { return NextResponse.json( { error: safeError(e, "Failed to update tenant") }, { status: e.statusCode || 500 } ); } }