Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions app/api/sandboxes/staged-file/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { postSandboxesUploadTokensHandler } from "@/lib/sandbox/postSandboxesUploadTokensHandler";

/**
* CORS preflight.
*
* @returns A NextResponse with CORS headers.
*/
export async function OPTIONS() {
return new NextResponse(null, { status: 200, headers: getCorsHeaders() });
}

/**
* POST /api/sandboxes/staged-file β€” Vercel Blob client-upload token handshake.
*
* @param request - The request object.
* @returns A NextResponse with the handshake result or error.
*/
export async function POST(request: NextRequest): Promise<Response> {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Route path appears to be singular (staged-file) while the migration target is documented as plural (staged-files), which can break the intended endpoint.

Prompt for AI agents
Check if this issue is valid β€” if so, understand the root cause and fix it. At app/api/sandboxes/staged-file/route.ts, line 21:

<comment>Route path appears to be singular (`staged-file`) while the migration target is documented as plural (`staged-files`), which can break the intended endpoint.</comment>

<file context>
@@ -0,0 +1,23 @@
+ * @param request - The request object.
+ * @returns A NextResponse with the handshake result or error.
+ */
+export async function POST(request: NextRequest): Promise<Response> {
+  return postSandboxesUploadTokensHandler(request);
+}
</file context>

return postSandboxesUploadTokensHandler(request);
}
124 changes: 124 additions & 0 deletions lib/sandbox/__tests__/postSandboxesUploadTokensHandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Custom agent: Enforce Clear Code Style and Maintainability Practices

This new test file exceeds the repository’s 100-line maintainability limit.

Prompt for AI agents
Check if this issue is valid β€” if so, understand the root cause and fix it. At lib/sandbox/__tests__/postSandboxesUploadTokensHandler.test.ts, line 1:

<comment>This new test file exceeds the repository’s 100-line maintainability limit.</comment>

<file context>
@@ -0,0 +1,124 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { NextResponse } from "next/server";
+import type { NextRequest } from "next/server";
</file context>

import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { handleUpload } from "@vercel/blob/client";

import { postSandboxesUploadTokensHandler } from "../postSandboxesUploadTokensHandler";
import { validateAuthContext } from "@/lib/auth/validateAuthContext";

vi.mock("@vercel/blob/client", () => ({
handleUpload: vi.fn(),
}));

vi.mock("@/lib/auth/validateAuthContext", () => ({
validateAuthContext: vi.fn(),
}));

function createMockRequest(body: unknown, headers: Record<string, string> = {}): NextRequest {
return new Request("http://localhost:3000/api/sandboxes/staged-file", {
method: "POST",
headers: { "Content-Type": "application/json", ...headers },
body: JSON.stringify(body),
}) as unknown as NextRequest;
}

const handshakeBody = { type: "blob.generate-client-token", payload: {} };
const callbackBody = { type: "blob.upload-completed", payload: {} };

describe("postSandboxesUploadTokensHandler", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: "acc_123",
orgId: null,
authToken: "tkn",
});
});

it("returns 200 with the handleUpload result on a valid handshake", async () => {
const blobResponse = { type: "blob.generate-client-token", clientToken: "tkn_abc" };
vi.mocked(handleUpload).mockResolvedValue(blobResponse as never);

const request = createMockRequest(handshakeBody, { Authorization: "Bearer xyz" });
const response = await postSandboxesUploadTokensHandler(request);

expect(response.status).toBe(200);
expect(await response.json()).toEqual(blobResponse);
expect(validateAuthContext).toHaveBeenCalledOnce();
expect(handleUpload).toHaveBeenCalledOnce();
});

it("returns 401 when handshake auth fails", async () => {
vi.mocked(validateAuthContext).mockResolvedValue(
NextResponse.json({ error: "Unauthorized" }, { status: 401 }),
);

const request = createMockRequest(handshakeBody);
const response = await postSandboxesUploadTokensHandler(request);

expect(response.status).toBe(401);
expect(handleUpload).not.toHaveBeenCalled();
});

it("skips auth on the upload-completed callback", async () => {
vi.mocked(handleUpload).mockResolvedValue({ type: "blob.upload-completed" } as never);

const request = createMockRequest(callbackBody);
const response = await postSandboxesUploadTokensHandler(request);

expect(response.status).toBe(200);
expect(validateAuthContext).not.toHaveBeenCalled();
expect(handleUpload).toHaveBeenCalledOnce();
});

it("configures the upload constraints in onBeforeGenerateToken", async () => {
let constraints: unknown;
vi.mocked(handleUpload).mockImplementation(async ({ onBeforeGenerateToken }) => {
constraints = await onBeforeGenerateToken!("file.png", null, false);
return { type: "blob.generate-client-token" } as never;
});

const request = createMockRequest(handshakeBody, { Authorization: "Bearer xyz" });
const response = await postSandboxesUploadTokensHandler(request);

expect(response.status).toBe(200);
expect(constraints).toEqual({
maximumSizeInBytes: 100 * 1024 * 1024,
addRandomSuffix: true,
});
});

it("returns 500 with a generic message when handleUpload throws", async () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
vi.mocked(handleUpload).mockRejectedValue(new Error("blob client failure"));

const request = createMockRequest(handshakeBody, { Authorization: "Bearer xyz" });
const response = await postSandboxesUploadTokensHandler(request);

expect(response.status).toBe(500);
const body = await response.json();
expect(body).toEqual({ status: "error", error: "Failed to issue upload token" });
expect(consoleSpy).toHaveBeenCalledOnce();
consoleSpy.mockRestore();
});

it("includes CORS headers on success", async () => {
vi.mocked(handleUpload).mockResolvedValue({ type: "blob.generate-client-token" } as never);

const request = createMockRequest(handshakeBody, { Authorization: "Bearer xyz" });
const response = await postSandboxesUploadTokensHandler(request);

expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*");
});

it("includes CORS headers on error", async () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
vi.mocked(handleUpload).mockRejectedValue(new Error("nope"));

const request = createMockRequest(handshakeBody, { Authorization: "Bearer xyz" });
const response = await postSandboxesUploadTokensHandler(request);

expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*");
consoleSpy.mockRestore();
});
});
41 changes: 41 additions & 0 deletions lib/sandbox/postSandboxesUploadTokensHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { handleUpload, type HandleUploadBody } from "@vercel/blob/client";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { validateAuthContext } from "@/lib/auth/validateAuthContext";

const MAX_UPLOAD_SIZE_BYTES = 100 * 1024 * 1024; // 100MB

// Auth applies only to the handshake β€” the upload-completed callback is signature-verified by handleUpload().
export async function postSandboxesUploadTokensHandler(
request: NextRequest,
): Promise<NextResponse> {
try {
const body = (await request.json()) as HandleUploadBody;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Invalid/malformed request bodies are handled as 500s because JSON parsing isn’t validated separately. Return a 400 for bad JSON before calling handleUpload.

Prompt for AI agents
Check if this issue is valid β€” if so, understand the root cause and fix it. At lib/sandbox/postSandboxesUploadTokensHandler.ts, line 14:

<comment>Invalid/malformed request bodies are handled as 500s because JSON parsing isn’t validated separately. Return a 400 for bad JSON before calling `handleUpload`.</comment>

<file context>
@@ -0,0 +1,41 @@
+  request: NextRequest,
+): Promise<NextResponse> {
+  try {
+    const body = (await request.json()) as HandleUploadBody;
+
+    if (body.type === "blob.generate-client-token") {
</file context>


if (body.type === "blob.generate-client-token") {
const auth = await validateAuthContext(request);
if (auth instanceof NextResponse) {
return auth;
}
}

const jsonResponse = await handleUpload({
body,
request,
onBeforeGenerateToken: async () => ({
maximumSizeInBytes: MAX_UPLOAD_SIZE_BYTES,
addRandomSuffix: true,
}),
onUploadCompleted: async () => {},
});

return NextResponse.json(jsonResponse, { headers: getCorsHeaders() });
} catch (error) {
console.error("[postSandboxesUploadTokensHandler] handleUpload failed:", error);
return NextResponse.json(
{ status: "error", error: "Failed to issue upload token" },
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Use the standardized 500 error message "Internal server error" in API responses.

(Based on your team's feedback about standardizing 500 responses to avoid leaking internal details.)

View Feedback

Prompt for AI agents
Check if this issue is valid β€” if so, understand the root cause and fix it. At lib/sandbox/postSandboxesUploadTokensHandler.ts, line 37:

<comment>Use the standardized 500 error message `"Internal server error"` in API responses.

(Based on your team's feedback about standardizing 500 responses to avoid leaking internal details.) </comment>

<file context>
@@ -0,0 +1,41 @@
+  } catch (error) {
+    console.error("[postSandboxesUploadTokensHandler] handleUpload failed:", error);
+    return NextResponse.json(
+      { status: "error", error: "Failed to issue upload token" },
+      { status: 500, headers: getCorsHeaders() },
+    );
</file context>
Suggested change
{ status: "error", error: "Failed to issue upload token" },
{ status: "error", error: "Internal server error" },

{ status: 500, headers: getCorsHeaders() },
);
}
}
Loading