Skip to content
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> {
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.

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

New test file exceeds the repository’s 100-line file-length 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>New test file exceeds the repository’s 100-line file-length limit.</comment>

<file context>
@@ -0,0 +1,110 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { handleUpload } from "@vercel/blob/client";
+
</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;

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(
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
{ status: "error", error: "Failed to issue upload token" },
{ status: 500, headers: getCorsHeaders() },
);
}
}
Loading