diff --git a/app/api/sandboxes/staged-file/route.ts b/app/api/sandboxes/staged-file/route.ts new file mode 100644 index 000000000..408df9640 --- /dev/null +++ b/app/api/sandboxes/staged-file/route.ts @@ -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 { + return postSandboxesUploadTokensHandler(request); +} diff --git a/lib/sandbox/__tests__/postSandboxesUploadTokensHandler.test.ts b/lib/sandbox/__tests__/postSandboxesUploadTokensHandler.test.ts new file mode 100644 index 000000000..fbba4a814 --- /dev/null +++ b/lib/sandbox/__tests__/postSandboxesUploadTokensHandler.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +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 = {}): 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(); + }); +}); diff --git a/lib/sandbox/postSandboxesUploadTokensHandler.ts b/lib/sandbox/postSandboxesUploadTokensHandler.ts new file mode 100644 index 000000000..044b52735 --- /dev/null +++ b/lib/sandbox/postSandboxesUploadTokensHandler.ts @@ -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 { + 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( + { status: "error", error: "Failed to issue upload token" }, + { status: 500, headers: getCorsHeaders() }, + ); + } +}