198 lines
5.9 KiB
TypeScript
198 lines
5.9 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 { 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> = {};
|
|
|
|
// ── 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 }
|
|
);
|
|
}
|
|
}
|
|
specPatch.packages = body.packages;
|
|
}
|
|
|
|
// ── 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);
|
|
return NextResponse.json(updated);
|
|
} catch (e: any) {
|
|
return NextResponse.json(
|
|
{ error: safeError(e, "Failed to update tenant") },
|
|
{ status: e.statusCode || 500 }
|
|
);
|
|
}
|
|
}
|