-
Notifications
You must be signed in to change notification settings - Fork 9
feat(api): migrate POST /api/sandbox/upload to /api/sandboxes/staged-files #541
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+188
−0
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
e8c55d1
feat(api): add sandbox staged-files token handshake endpoint
arpitgupta1214 cb60eeb
refactor(api): rename /api/sandboxes/staged-files to /api/sandboxes/s…
arpitgupta1214 e0b1cfe
refactor(api): rename /api/sandboxes/stage-files to /api/sandboxes/st…
arpitgupta1214 bf5e5d8
refactor(api): authenticate staged-file via Authorization header
arpitgupta1214 0ba4daf
refactor(api): hide raw exception details on staged-file 500s
arpitgupta1214 5b25ccd
refactor(api): trim JSDoc on staged-file handler and route
arpitgupta1214 27163a9
fix(api): satisfy jsdoc/require-returns + require-param on staged-fil…
arpitgupta1214 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
124
lib/sandbox/__tests__/postSandboxesUploadTokensHandler.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<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(); | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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( | ||
|
cubic-dev-ai[bot] marked this conversation as resolved.
|
||
| { status: "error", error: "Failed to issue upload token" }, | ||
| { status: 500, headers: getCorsHeaders() }, | ||
| ); | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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