diff --git a/app/api/deploy/vercel/route.ts b/app/api/deploy/vercel/route.ts
index e4a1897b..0d81c214 100644
--- a/app/api/deploy/vercel/route.ts
+++ b/app/api/deploy/vercel/route.ts
@@ -1,40 +1,75 @@
-import { NextResponse } from "next/server";
+import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { VERCEL_API } from "@/lib/constants/config";
+import { rateLimit } from "@/lib/api-utils";
-export async function POST(req: Request) {
+export async function POST(req: NextRequest) {
try {
const session = await auth();
- if (!session?.user) {
+ if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
+ // Mirror the same rate limit as the Netlify deploy route (5 deploys / minute)
+ const { allowed, remaining } = await rateLimit(
+ `deploy-vercel:${session.user.id}`,
+ 5,
+ 60_000
+ );
+
+ if (!allowed) {
+ return NextResponse.json(
+ { error: "Rate limit exceeded. Please wait before deploying again." },
+ {
+ status: 429,
+ headers: {
+ "Retry-After": "60",
+ "X-RateLimit-Limit": "5",
+ "X-RateLimit-Remaining": String(remaining),
+ },
+ }
+ );
+ }
+
const { files, name, userApiKey } = await req.json();
if (!files || !Array.isArray(files)) {
return NextResponse.json({ error: "No files provided" }, { status: 400 });
}
- // Try user key first, fallback to Editron Master Key
- const token = userApiKey || process.env.VERCEL_MASTER_TOKEN;
+ // Require the caller to supply their own token.
+ // VERCEL_MASTER_TOKEN is intentionally NOT used as a fallback here —
+ // doing so would let any authenticated user deploy on the server's
+ // Vercel account without explicit per-deployment consent (issue #449).
+ if (typeof userApiKey !== "string") {
+ return NextResponse.json(
+ {
+ error:
+ "A Vercel API key is required. Please provide your own token in the deploy dialog.",
+ },
+ { status: 400 }
+ );
+ }
+ const token = userApiKey.trim();
if (!token) {
return NextResponse.json(
- { error: "No Vercel API token provided and no master token available." },
+ {
+ error:
+ "A Vercel API key is required. Please provide your own token in the deploy dialog.",
+ },
{ status: 400 }
);
}
- // Vercel API requires an array of standard file objects:
- // [{ file: "index.html", data: "..." }]
-
- // Convert our internal `TemplateData` format to flat Vercel format
- const flatFiles = files.map(f => ({
+ const flatFiles = files.map((f: { path: string; content: string }) => ({
file: f.path,
- data: f.content
+ data: f.content,
}));
- const projectName = name ? name.toLowerCase().replace(/[^a-z0-9-]/g, '-') : "editron-deploy";
+ const projectName = name
+ ? (name as string).toLowerCase().replace(/[^a-z0-9-]/g, "-")
+ : "editron-deploy";
const response = await fetch(VERCEL_API.DEPLOYMENTS, {
method: "POST",
@@ -45,7 +80,7 @@ export async function POST(req: Request) {
body: JSON.stringify({
name: projectName,
files: flatFiles,
- target: "production"
+ target: "production",
}),
});
@@ -61,14 +96,10 @@ export async function POST(req: Request) {
return NextResponse.json({
url: data.url,
deploymentId: data.id,
- readyState: data.readyState
+ readyState: data.readyState,
});
-
} catch (error) {
console.error("Vercel deployment error:", error);
- return NextResponse.json(
- { error: "Internal server error" },
- { status: 500 }
- );
+ return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
-}
+}
\ No newline at end of file
diff --git a/modules/playground/components/deploy-dialog.tsx b/modules/playground/components/deploy-dialog.tsx
index a0d314a8..708573c5 100644
--- a/modules/playground/components/deploy-dialog.tsx
+++ b/modules/playground/components/deploy-dialog.tsx
@@ -25,11 +25,12 @@ interface DeployDialogProps {
export function DeployDialog({ open, onOpenChange, templateData, projectName }: DeployDialogProps) {
const [provider, setProvider] = useState<"vercel" | "netlify" | "cloudflare">("vercel");
- const [useMasterKey, setUseMasterKey] = useState(true);
const [userKey, setUserKey] = useState("");
const [isDeploying, setIsDeploying] = useState(false);
const [deployedUrl, setDeployedUrl] = useState("");
+ const providerLabel = provider === "vercel" ? "Vercel" : provider === "netlify" ? "Netlify" : "Cloudflare";
+
const flattenFileTree = (data: TemplateFolder, parentPath = ""): { path: string; content: string }[] => {
let files: { path: string; content: string }[] = [];
if (!data) return files;
@@ -42,7 +43,7 @@ export function DeployDialog({ open, onOpenChange, templateData, projectName }:
const extension = item.fileExtension ? `.${item.fileExtension}` : "";
files.push({
path: `${parentPath}${item.filename}${extension}`,
- content: item.content
+ content: item.content,
});
}
}
@@ -50,8 +51,8 @@ export function DeployDialog({ open, onOpenChange, templateData, projectName }:
};
const handleDeploy = async () => {
- if (!useMasterKey && !userKey) {
- toast.error(`Please enter your ${provider === "vercel" ? "Vercel" : provider === "netlify" ? "Netlify" : "Cloudflare"} API key`);
+ if (!userKey.trim()) {
+ toast.error(`Please enter your ${providerLabel} API key.`);
return;
}
@@ -65,9 +66,9 @@ export function DeployDialog({ open, onOpenChange, templateData, projectName }:
try {
const flatFiles = flattenFileTree(templateData);
-
- // We don't deploy node_modules or big cache folders
- const filteredFiles = flatFiles.filter(f => !f.path.startsWith("node_modules/") && !f.path.startsWith(".next/"));
+ const filteredFiles = flatFiles.filter(
+ (f) => !f.path.startsWith("node_modules/") && !f.path.startsWith(".next/")
+ );
const res = await fetch(`/api/deploy/${provider}`, {
method: "POST",
@@ -75,8 +76,8 @@ export function DeployDialog({ open, onOpenChange, templateData, projectName }:
body: JSON.stringify({
files: filteredFiles,
name: projectName,
- userApiKey: useMasterKey ? undefined : userKey
- })
+ userApiKey: userKey.trim(),
+ }),
});
const data = await res.json();
@@ -85,12 +86,19 @@ export function DeployDialog({ open, onOpenChange, templateData, projectName }:
throw new Error(data.error || "Deployment failed");
}
+ if (typeof data.url !== "string" || !data.url.trim()) {
+ throw new Error("Deployment URL missing from response");
+ }
+
setDeployedUrl(data.url.startsWith("http") ? data.url : `https://${data.url}`);
toast.success("Successfully deployed!");
-
} catch (error: unknown) {
console.error(error);
- toast.error(error instanceof Error ? error.message : "Failed to deploy. Check your API key or try again.");
+ toast.error(
+ error instanceof Error
+ ? error.message
+ : "Failed to deploy. Check your API key or try again."
+ );
} finally {
setIsDeploying(false);
}
@@ -115,16 +123,33 @@ export function DeployDialog({ open, onOpenChange, templateData, projectName }:
Deployment Successful!
- + {deployedUrl} -