Files
pieced-portal/src/app/api/tenants/[name]/route.ts
admin 49b085e59e
All checks were successful
Build and Push / build (push) Successful in 1m39s
Phase2.5: Skill SetUp Process
2026-05-24 17:25:08 +02:00

357 lines
12 KiB
TypeScript

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<string, any> = {};
// 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<string>(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<string>();
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<string>(existing.spec.packages ?? []);
const newSet = new Set<string>(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 }
);
}
}