diff --git a/src/app/api/admin/requests/[id]/approve/route.ts b/src/app/api/admin/requests/[id]/approve/route.ts index 449cf75..1d842c6 100644 --- a/src/app/api/admin/requests/[id]/approve/route.ts +++ b/src/app/api/admin/requests/[id]/approve/route.ts @@ -14,6 +14,7 @@ import { getDefaultAgentsMd, generateToolsMd, } from "@/lib/workspace-defaults"; +import { safeError } from "@/lib/errors"; /** * POST /api/admin/requests/[id]/approve @@ -133,7 +134,7 @@ export async function POST( } catch (e: any) { console.error("Failed to create tenant:", e); return NextResponse.json( - { error: `Failed to create tenant: ${e.message}` }, + { error: safeError(e, "Failed to create tenant") }, { status: 500 } ); } diff --git a/src/app/api/admin/tenants/[name]/delete/route.ts b/src/app/api/admin/tenants/[name]/delete/route.ts index 851870d..147a461 100644 --- a/src/app/api/admin/tenants/[name]/delete/route.ts +++ b/src/app/api/admin/tenants/[name]/delete/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { requirePlatformRole } from "@/lib/session"; import { getTenant, deleteTenant } from "@/lib/k8s"; import { markTenantRequestDeletedByTenantName } from "@/lib/db"; +import { safeError } from "@/lib/errors"; /** * POST /api/admin/tenants/[name]/delete @@ -42,7 +43,7 @@ export async function POST( } catch (e: any) { console.error("Failed to delete tenant:", e); return NextResponse.json( - { error: `Failed to delete tenant: ${e.message}` }, + { error: safeError(e, "Failed to delete tenant") }, { status: 500 } ); } diff --git a/src/app/api/admin/tenants/[name]/suspend/route.ts b/src/app/api/admin/tenants/[name]/suspend/route.ts index 13cffd2..a982920 100644 --- a/src/app/api/admin/tenants/[name]/suspend/route.ts +++ b/src/app/api/admin/tenants/[name]/suspend/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { requirePlatformRole } from "@/lib/session"; import { getTenant, patchTenantSpec } from "@/lib/k8s"; +import { safeError } from "@/lib/errors"; /** * POST /api/admin/tenants/[name]/suspend @@ -35,7 +36,7 @@ export async function POST( } catch (e: any) { console.error("Failed to update tenant suspend state:", e); return NextResponse.json( - { error: `Failed to update tenant: ${e.message}` }, + { error: safeError(e, "Failed to update tenant") }, { status: 500 } ); } diff --git a/src/app/api/tenants/[name]/route.ts b/src/app/api/tenants/[name]/route.ts index dad03b7..0f06453 100644 --- a/src/app/api/tenants/[name]/route.ts +++ b/src/app/api/tenants/[name]/route.ts @@ -1,6 +1,11 @@ import { NextRequest, NextResponse } from "next/server"; import { getSessionUser } from "@/lib/session"; 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, @@ -27,7 +32,7 @@ export async function GET( return NextResponse.json(tenant); } catch (e: any) { return NextResponse.json( - { error: e.message }, + { error: safeError(e, "Failed to fetch tenant") }, { status: e.statusCode || 500 } ); } @@ -61,20 +66,130 @@ export async function PATCH( } const specPatch: Record = {}; - if (body.packages !== undefined) specPatch.packages = body.packages; - if (body.workspaceFiles !== undefined) + + // ── 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; - if (body.displayName !== undefined) + } + + // ── 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) specPatch.agentName = body.agentName; - if (body.channelUsers !== undefined) + } + + 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: e.message }, + { error: safeError(e, "Failed to update tenant") }, { status: e.statusCode || 500 } ); } diff --git a/src/app/api/tenants/route.ts b/src/app/api/tenants/route.ts index 3c0f203..4caa72e 100644 --- a/src/app/api/tenants/route.ts +++ b/src/app/api/tenants/route.ts @@ -1,7 +1,6 @@ import { NextResponse } from "next/server"; import { getSessionUser } from "@/lib/session"; -import { listTenants, getTenant, createTenant } from "@/lib/k8s"; -import type { PiecedTenantSpec } from "@/types"; +import { listTenants } from "@/lib/k8s"; export async function GET() { const user = await getSessionUser(); @@ -20,37 +19,3 @@ export async function GET() { ); return NextResponse.json(own); } - -export async function POST(request: Request) { - const user = await getSessionUser(); - if (!user) - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (!user.isPlatform && !user.roles.includes("owner")) { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } - - const body = (await request.json()) as { - name: string; - spec: PiecedTenantSpec; - }; - - if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(body.name) || body.name.length > 63) { - return NextResponse.json( - { error: "Invalid tenant name: lowercase alphanumeric and hyphens, 2-63 chars" }, - { status: 400 } - ); - } - - const existing = await getTenant(body.name); - if (existing) { - return NextResponse.json( - { error: "Tenant already exists" }, - { status: 409 } - ); - } - - const tenant = await createTenant(body.name, body.spec, { - "pieced.ch/zitadel-org-id": user.orgId, - }); - return NextResponse.json(tenant, { status: 201 }); -} diff --git a/src/app/api/usage/route.ts b/src/app/api/usage/route.ts index bca1c9a..de060af 100644 --- a/src/app/api/usage/route.ts +++ b/src/app/api/usage/route.ts @@ -1,20 +1,49 @@ import { NextRequest, NextResponse } from "next/server"; import { getSessionUser } from "@/lib/session"; +import { listTenants } from "@/lib/k8s"; import { getTeamInfo, getTeamSpendLogsV2 } from "@/lib/litellm"; +import { safeError } from "@/lib/errors"; +/** + * GET /api/usage + * + * Customers: teamId is resolved server-side from the tenant matching the + * user's orgId. No client-supplied teamId accepted. + * Platform admins: may pass ?teamId=... to inspect any tenant's usage. + */ export async function GET(req: NextRequest) { const user = await getSessionUser(); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - const teamId = req.nextUrl.searchParams.get("teamId"); - if (!teamId) - return NextResponse.json({ error: "teamId required" }, { status: 400 }); + let teamId: string | null = null; + + if (user.isPlatform) { + // Admins may pass a specific teamId to inspect any tenant + teamId = req.nextUrl.searchParams.get("teamId") ?? null; + } + + // For customers (or admins without explicit teamId): resolve from their tenant + if (!teamId) { + const tenants = await listTenants(); + const orgTenant = tenants.find( + (t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId + ); + + if (!orgTenant?.status?.litellmTeamId) { + return NextResponse.json( + { error: "No active tenant found for your organization" }, + { status: 404 } + ); + } + teamId = orgTenant.status.litellmTeamId; + } // Month param: YYYY-MM, defaults to current month const now = new Date(); - const monthParam = req.nextUrl.searchParams.get("month") - || `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`; + const monthParam = + req.nextUrl.searchParams.get("month") || + `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`; const [year, month] = monthParam.split("-").map(Number); const startDate = new Date(year, month - 1, 1); @@ -30,18 +59,28 @@ export async function GET(req: NextRequest) { const allRequests: any[] = []; let page = 1; while (true) { - const result = await getTeamSpendLogsV2(teamId, startStr, endStr, page, 100); + const result = await getTeamSpendLogsV2( + teamId, + startStr, + endStr, + page, + 100 + ); allRequests.push(...(result.data || [])); if (page >= (result.total_pages || 1)) break; page++; } // Aggregate by day - const byDay: Record = {}; + const byDay: Record< + string, + { inputTokens: number; outputTokens: number; spend: number } + > = {}; for (const r of allRequests) { const day = (r.startTime || r.endTime || "").slice(0, 10); if (!day) continue; - if (!byDay[day]) byDay[day] = { inputTokens: 0, outputTokens: 0, spend: 0 }; + if (!byDay[day]) + byDay[day] = { inputTokens: 0, outputTokens: 0, spend: 0 }; byDay[day].inputTokens += r.prompt_tokens || 0; byDay[day].outputTokens += r.completion_tokens || 0; byDay[day].spend += r.spend || 0; @@ -51,8 +90,14 @@ export async function GET(req: NextRequest) { .sort(([a], [b]) => a.localeCompare(b)) .map(([date, d]) => ({ date, ...d })); - const totalInput = allRequests.reduce((s, r) => s + (r.prompt_tokens || 0), 0); - const totalOutput = allRequests.reduce((s, r) => s + (r.completion_tokens || 0), 0); + const totalInput = allRequests.reduce( + (s, r) => s + (r.prompt_tokens || 0), + 0 + ); + const totalOutput = allRequests.reduce( + (s, r) => s + (r.completion_tokens || 0), + 0 + ); const totalSpend = allRequests.reduce((s, r) => s + (r.spend || 0), 0); return NextResponse.json({ @@ -79,6 +124,9 @@ export async function GET(req: NextRequest) { }); } catch (e: any) { console.error("Usage fetch error:", e.message); - return NextResponse.json({ error: "Failed to fetch usage" }, { status: 500 }); + return NextResponse.json( + { error: safeError(e, "Failed to fetch usage") }, + { status: 500 } + ); } -} \ No newline at end of file +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index cb2f47c..ecabb8d 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -39,7 +39,6 @@ export const authConfig: NextAuthConfig = { callbacks: { async jwt({ token, account, profile }) { if (account && profile) { - console.log("ZITADEL profile claims:", JSON.stringify(profile, null, 2)); const claims = profile as unknown as ZitadelClaims; token.orgId = claims["urn:zitadel:iam:user:resourceowner:id"]; token.orgName = claims["urn:zitadel:iam:user:resourceowner:name"]; diff --git a/src/lib/email.ts b/src/lib/email.ts index 9620fc2..0311589 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -42,11 +42,26 @@ function getFrom(): string { ); } +/** + * Escape HTML entities to prevent injection in HTML emails. + */ +function escapeHtml(str: string): string { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + export async function sendApprovalEmail( to: string, contactName: string, companyName: string ): Promise { + const safeName = escapeHtml(contactName); + const safeCompany = escapeHtml(companyName); + try { await getTransporter().sendMail({ from: getFrom(), @@ -68,8 +83,8 @@ export async function sendApprovalEmail( html: `

Your AI assistant is being set up

-

Hello ${contactName},

-

Great news! Your onboarding request for ${companyName} has been approved.

+

Hello ${safeName},

+

Great news! Your onboarding request for ${safeCompany} has been approved.

Your AI assistant instance is now being provisioned. This usually takes a few minutes.

@@ -95,14 +110,18 @@ export async function sendRejectionEmail( companyName: string, adminNotes?: string ): Promise { + const safeName = escapeHtml(contactName); + const safeCompany = escapeHtml(companyName); + const safeNotes = adminNotes ? escapeHtml(adminNotes) : ""; + try { const notesBlock = adminNotes ? `\nNote from our team:\n${adminNotes}\n` : ""; - const notesHtml = adminNotes + const notesHtml = safeNotes ? `

Note from our team:

-

${adminNotes}

+

${safeNotes}

` : ""; @@ -123,8 +142,8 @@ export async function sendRejectionEmail( html: `

Update on your onboarding request

-

Hello ${contactName},

-

Thank you for your interest in PieCed IT. Unfortunately, we were unable to approve your onboarding request for ${companyName} at this time.

+

Hello ${safeName},

+

Thank you for your interest in PieCed IT. Unfortunately, we were unable to approve your onboarding request for ${safeCompany} at this time.

${notesHtml}

If you have questions or would like to discuss this further, please reply to this email.


@@ -145,6 +164,10 @@ export async function sendAdminNotificationEmail( const adminEmail = process.env.ADMIN_NOTIFICATION_EMAIL; if (!adminEmail) return; + const safeCompany = escapeHtml(companyName); + const safeName = escapeHtml(contactName); + const safeEmail = escapeHtml(contactEmail); + try { await getTransporter().sendMail({ from: getFrom(), @@ -158,6 +181,23 @@ export async function sendAdminNotificationEmail( "", `Review it at https://app.pieced.ch/admin`, ].join("\n"), + html: ` +
+ `, }); } catch (err) { console.error("Failed to send admin notification email:", err); diff --git a/src/lib/errors.ts b/src/lib/errors.ts new file mode 100644 index 0000000..f54b3ef --- /dev/null +++ b/src/lib/errors.ts @@ -0,0 +1,37 @@ +/** + * Error sanitization for API responses. + * + * By default, returns a generic message to the client and logs the full + * error server-side. Set PORTAL_DEBUG_ERRORS=true to return the raw + * error message to the client (useful during development/debugging). + */ + +const DEBUG_ERRORS = process.env.PORTAL_DEBUG_ERRORS === "true"; + +/** + * Returns a safe error string for API responses. + * + * - In debug mode (PORTAL_DEBUG_ERRORS=true): returns the raw e.message + * - In production mode: returns the fallback string and logs the real error + * + * Recognises common HTTP status codes from k8s/vault errors and returns + * appropriate short messages even in production mode. + */ +export function safeError(e: unknown, fallback: string): string { + const err = e instanceof Error ? e : new Error(String(e)); + const statusCode = (err as any).statusCode as number | undefined; + + if (DEBUG_ERRORS) { + return err.message; + } + + // Map well-known status codes to safe messages + if (statusCode === 404) return "Not found"; + if (statusCode === 403) return "Forbidden"; + if (statusCode === 409) return "Conflict"; + if (statusCode === 401) return "Unauthorized"; + + // Log full error server-side, return generic to client + console.error(`${fallback}:`, err.message); + return fallback; +}