357 lines
12 KiB
TypeScript
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 }
|
|
);
|
|
}
|
|
}
|