From e8c55d17d27ecf00a688e391258d37c5e69a49b0 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Sat, 9 May 2026 22:19:38 +0530 Subject: [PATCH 1/7] feat(api): add sandbox staged-files token handshake endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds POST /api/sandboxes/staged-files — the Vercel Blob client-upload handshake half of the sandbox upload flow. Mirrors chat's existing /api/sandbox/upload handler so the chat side can flip its handleUploadUrl to api and delete the local route. Auth follows the minimum-port shape: validates clientPayload.token because @vercel/blob/client.upload() does not allow setting an Authorization header on the handshake POST. The downstream commit (POST /api/sandboxes/files) re-authenticates with a real Bearer token. OPTIONS preflight is wired with getCorsHeaders() so cross-origin calls from chat.recoupable.com work the same way the existing /api/sandboxes/files route does. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/sandboxes/staged-files/route.ts | 44 +++++++ .../postSandboxesUploadTokensHandler.test.ts | 110 ++++++++++++++++++ .../postSandboxesUploadTokensHandler.ts | 48 ++++++++ 3 files changed, 202 insertions(+) create mode 100644 app/api/sandboxes/staged-files/route.ts create mode 100644 lib/sandbox/__tests__/postSandboxesUploadTokensHandler.test.ts create mode 100644 lib/sandbox/postSandboxesUploadTokensHandler.ts diff --git a/app/api/sandboxes/staged-files/route.ts b/app/api/sandboxes/staged-files/route.ts new file mode 100644 index 000000000..638be7357 --- /dev/null +++ b/app/api/sandboxes/staged-files/route.ts @@ -0,0 +1,44 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { postSandboxesUploadTokensHandler } from "@/lib/sandbox/postSandboxesUploadTokensHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A NextResponse with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * POST /api/sandboxes/staged-files + * + * Issues presigned client-upload tokens for the Vercel Blob handshake used by + * the sandbox file-staging flow. The browser POSTs the `HandleUploadBody` + * here, uploads directly to Vercel Blob with the returned token, and then + * passes the resulting blob URLs to `POST /api/sandboxes/files` which commits + * them to the account's sandbox GitHub repo and cleans up the blobs. + * + * Authentication: pass the caller's Privy access token in + * `clientPayload` as `JSON.stringify({ token })`. `@vercel/blob/client.upload()` + * does not allow setting arbitrary `Authorization` headers on the handshake + * POST, so token transport rides on `clientPayload` rather than `Authorization`. + * + * Request body: `HandleUploadBody` (from `@vercel/blob/client`). + * + * Response (200): the JSON envelope from `handleUpload()` (either a generated + * client token or an upload-completed acknowledgement). + * + * Error (400): + * - error: string + * + * @param request - The request object + * @returns A NextResponse with the handshake result or error + */ +export async function POST(request: Request): 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..37dc09e71 --- /dev/null +++ b/lib/sandbox/__tests__/postSandboxesUploadTokensHandler.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { handleUpload } from "@vercel/blob/client"; + +import { postSandboxesUploadTokensHandler } from "../postSandboxesUploadTokensHandler"; + +vi.mock("@vercel/blob/client", () => ({ + handleUpload: vi.fn(), +})); + +function createMockRequest(body: unknown): Request { + return new Request("http://localhost:3000/api/sandboxes/staged-files", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("postSandboxesUploadTokensHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 200 with the handleUpload result on success", async () => { + const blobResponse = { type: "blob.generate-client-token", clientToken: "tkn_abc" }; + vi.mocked(handleUpload).mockResolvedValue(blobResponse as never); + + const request = createMockRequest({ pathname: "file.png", callbackUrl: "x" }); + const response = await postSandboxesUploadTokensHandler(request); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual(blobResponse); + expect(handleUpload).toHaveBeenCalledOnce(); + }); + + it("rejects when clientPayload is missing a token", async () => { + vi.mocked(handleUpload).mockImplementation(async ({ onBeforeGenerateToken }) => { + await onBeforeGenerateToken!("file.png", null, false); + return {} as never; + }); + + const request = createMockRequest({}); + const response = await postSandboxesUploadTokensHandler(request); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toBe("Authentication required"); + }); + + it("rejects when clientPayload token field is missing", async () => { + vi.mocked(handleUpload).mockImplementation(async ({ onBeforeGenerateToken }) => { + await onBeforeGenerateToken!("file.png", JSON.stringify({ foo: "bar" }), false); + return {} as never; + }); + + const request = createMockRequest({}); + const response = await postSandboxesUploadTokensHandler(request); + + expect(response.status).toBe(400); + expect((await response.json()).error).toBe("Authentication required"); + }); + + it("returns the configured upload constraints when clientPayload has a token", async () => { + let constraints: unknown; + vi.mocked(handleUpload).mockImplementation(async ({ onBeforeGenerateToken }) => { + constraints = await onBeforeGenerateToken!( + "file.png", + JSON.stringify({ token: "user-access-token" }), + false, + ); + return { type: "blob.generate-client-token" } as never; + }); + + const request = createMockRequest({}); + const response = await postSandboxesUploadTokensHandler(request); + + expect(response.status).toBe(200); + expect(constraints).toEqual({ + maximumSizeInBytes: 100 * 1024 * 1024, + addRandomSuffix: true, + }); + }); + + it("returns 400 when handleUpload throws", async () => { + vi.mocked(handleUpload).mockRejectedValue(new Error("blob client failure")); + + const request = createMockRequest({}); + const response = await postSandboxesUploadTokensHandler(request); + + expect(response.status).toBe(400); + expect((await response.json()).error).toBe("blob client failure"); + }); + + it("includes CORS headers on success", async () => { + vi.mocked(handleUpload).mockResolvedValue({ type: "blob.generate-client-token" } as never); + + const request = createMockRequest({}); + const response = await postSandboxesUploadTokensHandler(request); + + expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*"); + }); + + it("includes CORS headers on error", async () => { + vi.mocked(handleUpload).mockRejectedValue(new Error("nope")); + + const request = createMockRequest({}); + const response = await postSandboxesUploadTokensHandler(request); + + expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*"); + }); +}); diff --git a/lib/sandbox/postSandboxesUploadTokensHandler.ts b/lib/sandbox/postSandboxesUploadTokensHandler.ts new file mode 100644 index 000000000..d7aaa14a9 --- /dev/null +++ b/lib/sandbox/postSandboxesUploadTokensHandler.ts @@ -0,0 +1,48 @@ +import { handleUpload, type HandleUploadBody } from "@vercel/blob/client"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; + +const MAX_UPLOAD_SIZE_BYTES = 100 * 1024 * 1024; // 100MB + +/** + * Handler for issuing client-upload tokens for sandbox file staging. + * + * Vercel Blob client uploads are a two-step handshake: + * 1. Browser POSTs `HandleUploadBody` here to get a presigned token. + * 2. Browser uploads directly to Vercel Blob, then Blob calls back to this + * same URL with a completion event. + * + * The `@vercel/blob/client` library does not allow setting an `Authorization` + * header on the handshake POST, so callers pass the Privy access token via + * `clientPayload`. The token is validated by presence here; downstream commit + * (POST /api/sandboxes/files) re-authenticates with a real Bearer token. + */ +export async function postSandboxesUploadTokensHandler(request: Request): Promise { + try { + const body = (await request.json()) as HandleUploadBody; + + const jsonResponse = await handleUpload({ + body, + request, + onBeforeGenerateToken: async (_pathname, clientPayload) => { + const payload = clientPayload ? JSON.parse(clientPayload) : null; + if (!payload?.token) { + throw new Error("Authentication required"); + } + + return { + maximumSizeInBytes: MAX_UPLOAD_SIZE_BYTES, + addRandomSuffix: true, + }; + }, + onUploadCompleted: async () => {}, + }); + + return NextResponse.json(jsonResponse, { headers: getCorsHeaders() }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Upload failed" }, + { status: 400, headers: getCorsHeaders() }, + ); + } +} From cb60eeb8dcafa9a8d09397dcf828c49dc1464a95 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Sat, 9 May 2026 22:46:59 +0530 Subject: [PATCH 2/7] refactor(api): rename /api/sandboxes/staged-files to /api/sandboxes/stage-files Verb form matches /api/sandboxes/files (commit) and /api/sandboxes/file (get). Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/sandboxes/{staged-files => stage-files}/route.ts | 2 +- lib/sandbox/__tests__/postSandboxesUploadTokensHandler.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename app/api/sandboxes/{staged-files => stage-files}/route.ts (97%) diff --git a/app/api/sandboxes/staged-files/route.ts b/app/api/sandboxes/stage-files/route.ts similarity index 97% rename from app/api/sandboxes/staged-files/route.ts rename to app/api/sandboxes/stage-files/route.ts index 638be7357..655906c9b 100644 --- a/app/api/sandboxes/staged-files/route.ts +++ b/app/api/sandboxes/stage-files/route.ts @@ -15,7 +15,7 @@ export async function OPTIONS() { } /** - * POST /api/sandboxes/staged-files + * POST /api/sandboxes/stage-files * * Issues presigned client-upload tokens for the Vercel Blob handshake used by * the sandbox file-staging flow. The browser POSTs the `HandleUploadBody` diff --git a/lib/sandbox/__tests__/postSandboxesUploadTokensHandler.test.ts b/lib/sandbox/__tests__/postSandboxesUploadTokensHandler.test.ts index 37dc09e71..081cf32bc 100644 --- a/lib/sandbox/__tests__/postSandboxesUploadTokensHandler.test.ts +++ b/lib/sandbox/__tests__/postSandboxesUploadTokensHandler.test.ts @@ -8,7 +8,7 @@ vi.mock("@vercel/blob/client", () => ({ })); function createMockRequest(body: unknown): Request { - return new Request("http://localhost:3000/api/sandboxes/staged-files", { + return new Request("http://localhost:3000/api/sandboxes/stage-files", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), From e0b1cfe43af0a2ffc050135d1afb7d9835cee946 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Sat, 9 May 2026 22:50:31 +0530 Subject: [PATCH 3/7] refactor(api): rename /api/sandboxes/stage-files to /api/sandboxes/staged-file Noun-shaped resource matching /api/sandboxes/file (singular getter) and /api/sandboxes/files (collection commit). Each call generates a token to stage one file. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/sandboxes/{stage-files => staged-file}/route.ts | 2 +- lib/sandbox/__tests__/postSandboxesUploadTokensHandler.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename app/api/sandboxes/{stage-files => staged-file}/route.ts (97%) diff --git a/app/api/sandboxes/stage-files/route.ts b/app/api/sandboxes/staged-file/route.ts similarity index 97% rename from app/api/sandboxes/stage-files/route.ts rename to app/api/sandboxes/staged-file/route.ts index 655906c9b..3827ba72d 100644 --- a/app/api/sandboxes/stage-files/route.ts +++ b/app/api/sandboxes/staged-file/route.ts @@ -15,7 +15,7 @@ export async function OPTIONS() { } /** - * POST /api/sandboxes/stage-files + * POST /api/sandboxes/staged-file * * Issues presigned client-upload tokens for the Vercel Blob handshake used by * the sandbox file-staging flow. The browser POSTs the `HandleUploadBody` diff --git a/lib/sandbox/__tests__/postSandboxesUploadTokensHandler.test.ts b/lib/sandbox/__tests__/postSandboxesUploadTokensHandler.test.ts index 081cf32bc..c861997a9 100644 --- a/lib/sandbox/__tests__/postSandboxesUploadTokensHandler.test.ts +++ b/lib/sandbox/__tests__/postSandboxesUploadTokensHandler.test.ts @@ -8,7 +8,7 @@ vi.mock("@vercel/blob/client", () => ({ })); function createMockRequest(body: unknown): Request { - return new Request("http://localhost:3000/api/sandboxes/stage-files", { + return new Request("http://localhost:3000/api/sandboxes/staged-file", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), From bf5e5d8c9642910f9e3ed98b34a917601a01d88b Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Sat, 9 May 2026 23:04:46 +0530 Subject: [PATCH 4/7] refactor(api): authenticate staged-file via Authorization header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops clientPayload.token in favor of validateAuthContext() so the endpoint matches the auth surface of every other api route. The chat-side upload() now forwards an Authorization Bearer header via the library's headers option (which I missed earlier — that field is documented for exactly this use case). Branches on body.type to skip auth on the upload-completed callback; that POST comes from Vercel Blob's backend without the user's auth header, and handleUpload() verifies its signature internally. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/sandboxes/staged-file/route.ts | 17 +++-- .../postSandboxesUploadTokensHandler.test.ts | 72 ++++++++++--------- .../postSandboxesUploadTokensHandler.ts | 46 ++++++------ 3 files changed, 75 insertions(+), 60 deletions(-) diff --git a/app/api/sandboxes/staged-file/route.ts b/app/api/sandboxes/staged-file/route.ts index 3827ba72d..06bda715d 100644 --- a/app/api/sandboxes/staged-file/route.ts +++ b/app/api/sandboxes/staged-file/route.ts @@ -1,3 +1,4 @@ +import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { postSandboxesUploadTokensHandler } from "@/lib/sandbox/postSandboxesUploadTokensHandler"; @@ -23,22 +24,24 @@ export async function OPTIONS() { * passes the resulting blob URLs to `POST /api/sandboxes/files` which commits * them to the account's sandbox GitHub repo and cleans up the blobs. * - * Authentication: pass the caller's Privy access token in - * `clientPayload` as `JSON.stringify({ token })`. `@vercel/blob/client.upload()` - * does not allow setting arbitrary `Authorization` headers on the handshake - * POST, so token transport rides on `clientPayload` rather than `Authorization`. + * Authentication: x-api-key header or Authorization Bearer token, matching + * other sandbox endpoints. `@vercel/blob/client.upload()` forwards the + * caller's headers onto the handshake POST. The upload-completed callback + * from Vercel Blob's backend does not carry the user's auth header — its + * signature is verified internally by `handleUpload()` against the token + * issued during the handshake. * * Request body: `HandleUploadBody` (from `@vercel/blob/client`). * * Response (200): the JSON envelope from `handleUpload()` (either a generated * client token or an upload-completed acknowledgement). * - * Error (400): - * - error: string + * Error (401): missing or invalid auth on the handshake POST. + * Error (400): invalid body or upstream Vercel Blob failure. * * @param request - The request object * @returns A NextResponse with the handshake result or error */ -export async function POST(request: Request): Promise { +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 index c861997a9..2564649a0 100644 --- a/lib/sandbox/__tests__/postSandboxesUploadTokensHandler.test.ts +++ b/lib/sandbox/__tests__/postSandboxesUploadTokensHandler.test.ts @@ -1,76 +1,84 @@ 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(), })); -function createMockRequest(body: unknown): Request { +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: { "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 success", async () => { + 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({ pathname: "file.png", callbackUrl: "x" }); + 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("rejects when clientPayload is missing a token", async () => { - vi.mocked(handleUpload).mockImplementation(async ({ onBeforeGenerateToken }) => { - await onBeforeGenerateToken!("file.png", null, false); - return {} as never; - }); + it("returns 401 when handshake auth fails", async () => { + vi.mocked(validateAuthContext).mockResolvedValue( + NextResponse.json({ error: "Unauthorized" }, { status: 401 }), + ); - const request = createMockRequest({}); + const request = createMockRequest(handshakeBody); const response = await postSandboxesUploadTokensHandler(request); - expect(response.status).toBe(400); - const body = await response.json(); - expect(body.error).toBe("Authentication required"); + expect(response.status).toBe(401); + expect(handleUpload).not.toHaveBeenCalled(); }); - it("rejects when clientPayload token field is missing", async () => { - vi.mocked(handleUpload).mockImplementation(async ({ onBeforeGenerateToken }) => { - await onBeforeGenerateToken!("file.png", JSON.stringify({ foo: "bar" }), false); - return {} as never; - }); + it("skips auth on the upload-completed callback", async () => { + vi.mocked(handleUpload).mockResolvedValue({ type: "blob.upload-completed" } as never); - const request = createMockRequest({}); + const request = createMockRequest(callbackBody); const response = await postSandboxesUploadTokensHandler(request); - expect(response.status).toBe(400); - expect((await response.json()).error).toBe("Authentication required"); + expect(response.status).toBe(200); + expect(validateAuthContext).not.toHaveBeenCalled(); + expect(handleUpload).toHaveBeenCalledOnce(); }); - it("returns the configured upload constraints when clientPayload has a token", async () => { + it("configures the upload constraints in onBeforeGenerateToken", async () => { let constraints: unknown; vi.mocked(handleUpload).mockImplementation(async ({ onBeforeGenerateToken }) => { - constraints = await onBeforeGenerateToken!( - "file.png", - JSON.stringify({ token: "user-access-token" }), - false, - ); + constraints = await onBeforeGenerateToken!("file.png", null, false); return { type: "blob.generate-client-token" } as never; }); - const request = createMockRequest({}); + const request = createMockRequest(handshakeBody, { Authorization: "Bearer xyz" }); const response = await postSandboxesUploadTokensHandler(request); expect(response.status).toBe(200); @@ -83,7 +91,7 @@ describe("postSandboxesUploadTokensHandler", () => { it("returns 400 when handleUpload throws", async () => { vi.mocked(handleUpload).mockRejectedValue(new Error("blob client failure")); - const request = createMockRequest({}); + const request = createMockRequest(handshakeBody, { Authorization: "Bearer xyz" }); const response = await postSandboxesUploadTokensHandler(request); expect(response.status).toBe(400); @@ -93,7 +101,7 @@ describe("postSandboxesUploadTokensHandler", () => { it("includes CORS headers on success", async () => { vi.mocked(handleUpload).mockResolvedValue({ type: "blob.generate-client-token" } as never); - const request = createMockRequest({}); + const request = createMockRequest(handshakeBody, { Authorization: "Bearer xyz" }); const response = await postSandboxesUploadTokensHandler(request); expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*"); @@ -102,7 +110,7 @@ describe("postSandboxesUploadTokensHandler", () => { it("includes CORS headers on error", async () => { vi.mocked(handleUpload).mockRejectedValue(new Error("nope")); - const request = createMockRequest({}); + const request = createMockRequest(handshakeBody, { Authorization: "Bearer xyz" }); const response = await postSandboxesUploadTokensHandler(request); expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*"); diff --git a/lib/sandbox/postSandboxesUploadTokensHandler.ts b/lib/sandbox/postSandboxesUploadTokensHandler.ts index d7aaa14a9..4f0c84e8d 100644 --- a/lib/sandbox/postSandboxesUploadTokensHandler.ts +++ b/lib/sandbox/postSandboxesUploadTokensHandler.ts @@ -1,40 +1,44 @@ 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 /** * Handler for issuing client-upload tokens for sandbox file staging. * - * Vercel Blob client uploads are a two-step handshake: - * 1. Browser POSTs `HandleUploadBody` here to get a presigned token. - * 2. Browser uploads directly to Vercel Blob, then Blob calls back to this - * same URL with a completion event. - * - * The `@vercel/blob/client` library does not allow setting an `Authorization` - * header on the handshake POST, so callers pass the Privy access token via - * `clientPayload`. The token is validated by presence here; downstream commit - * (POST /api/sandboxes/files) re-authenticates with a real Bearer token. + * Vercel Blob client uploads are a two-phase handshake: + * 1. Browser POSTs `HandleUploadBody` here (`type: "blob.generate-client-token"`) + * to get a presigned token. This phase carries the user's Bearer token in + * the `Authorization` header — `@vercel/blob/client.upload()` forwards + * headers from its `headers` option onto the handshake POST. + * 2. Browser uploads directly to Vercel Blob, then Vercel Blob's backend + * POSTs back here (`type: "blob.upload-completed"`). This callback does + * not carry the user's auth header — instead, `handleUpload()` verifies + * the callback's signature against the token issued in phase 1. */ -export async function postSandboxesUploadTokensHandler(request: Request): Promise { +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 (_pathname, clientPayload) => { - const payload = clientPayload ? JSON.parse(clientPayload) : null; - if (!payload?.token) { - throw new Error("Authentication required"); - } - - return { - maximumSizeInBytes: MAX_UPLOAD_SIZE_BYTES, - addRandomSuffix: true, - }; - }, + onBeforeGenerateToken: async () => ({ + maximumSizeInBytes: MAX_UPLOAD_SIZE_BYTES, + addRandomSuffix: true, + }), onUploadCompleted: async () => {}, }); From 0ba4dafac1df675146c118e6921cfdf090ead33f Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Sat, 9 May 2026 23:17:06 +0530 Subject: [PATCH 5/7] refactor(api): hide raw exception details on staged-file 500s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Log unexpected handleUpload errors server-side via console.error and return a generic 500 — matches the createSandboxHandler pattern and avoids leaking internal context in error responses. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/sandboxes/staged-file/route.ts | 2 +- .../postSandboxesUploadTokensHandler.test.ts | 12 +++++++++--- lib/sandbox/postSandboxesUploadTokensHandler.ts | 5 +++-- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/app/api/sandboxes/staged-file/route.ts b/app/api/sandboxes/staged-file/route.ts index 06bda715d..63a80e268 100644 --- a/app/api/sandboxes/staged-file/route.ts +++ b/app/api/sandboxes/staged-file/route.ts @@ -37,7 +37,7 @@ export async function OPTIONS() { * client token or an upload-completed acknowledgement). * * Error (401): missing or invalid auth on the handshake POST. - * Error (400): invalid body or upstream Vercel Blob failure. + * Error (500): invalid body or upstream Vercel Blob failure. * * @param request - The request object * @returns A NextResponse with the handshake result or error diff --git a/lib/sandbox/__tests__/postSandboxesUploadTokensHandler.test.ts b/lib/sandbox/__tests__/postSandboxesUploadTokensHandler.test.ts index 2564649a0..fbba4a814 100644 --- a/lib/sandbox/__tests__/postSandboxesUploadTokensHandler.test.ts +++ b/lib/sandbox/__tests__/postSandboxesUploadTokensHandler.test.ts @@ -88,14 +88,18 @@ describe("postSandboxesUploadTokensHandler", () => { }); }); - it("returns 400 when handleUpload throws", async () => { + 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(400); - expect((await response.json()).error).toBe("blob client failure"); + 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 () => { @@ -108,11 +112,13 @@ describe("postSandboxesUploadTokensHandler", () => { }); 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 index 4f0c84e8d..f3d68a239 100644 --- a/lib/sandbox/postSandboxesUploadTokensHandler.ts +++ b/lib/sandbox/postSandboxesUploadTokensHandler.ts @@ -44,9 +44,10 @@ export async function postSandboxesUploadTokensHandler( return NextResponse.json(jsonResponse, { headers: getCorsHeaders() }); } catch (error) { + console.error("[postSandboxesUploadTokensHandler] handleUpload failed:", error); return NextResponse.json( - { error: error instanceof Error ? error.message : "Upload failed" }, - { status: 400, headers: getCorsHeaders() }, + { status: "error", error: "Failed to issue upload token" }, + { status: 500, headers: getCorsHeaders() }, ); } } From 5b25ccd2c0e418ffec10053f099e1dfd86a03581 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Sat, 9 May 2026 23:34:04 +0530 Subject: [PATCH 6/7] refactor(api): trim JSDoc on staged-file handler and route Drop the multi-paragraph commentary; keep a one-liner on the handler explaining the asymmetric auth, and minimal JSDoc on the route per project convention. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/sandboxes/staged-file/route.ts | 39 ++----------------- .../postSandboxesUploadTokensHandler.ts | 14 +------ 2 files changed, 4 insertions(+), 49 deletions(-) diff --git a/app/api/sandboxes/staged-file/route.ts b/app/api/sandboxes/staged-file/route.ts index 63a80e268..fa45b0208 100644 --- a/app/api/sandboxes/staged-file/route.ts +++ b/app/api/sandboxes/staged-file/route.ts @@ -3,45 +3,12 @@ import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { postSandboxesUploadTokensHandler } from "@/lib/sandbox/postSandboxesUploadTokensHandler"; -/** - * OPTIONS handler for CORS preflight requests. - * - * @returns A NextResponse with CORS headers. - */ +/** CORS preflight. */ export async function OPTIONS() { - return new NextResponse(null, { - status: 200, - headers: getCorsHeaders(), - }); + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); } -/** - * POST /api/sandboxes/staged-file - * - * Issues presigned client-upload tokens for the Vercel Blob handshake used by - * the sandbox file-staging flow. The browser POSTs the `HandleUploadBody` - * here, uploads directly to Vercel Blob with the returned token, and then - * passes the resulting blob URLs to `POST /api/sandboxes/files` which commits - * them to the account's sandbox GitHub repo and cleans up the blobs. - * - * Authentication: x-api-key header or Authorization Bearer token, matching - * other sandbox endpoints. `@vercel/blob/client.upload()` forwards the - * caller's headers onto the handshake POST. The upload-completed callback - * from Vercel Blob's backend does not carry the user's auth header — its - * signature is verified internally by `handleUpload()` against the token - * issued during the handshake. - * - * Request body: `HandleUploadBody` (from `@vercel/blob/client`). - * - * Response (200): the JSON envelope from `handleUpload()` (either a generated - * client token or an upload-completed acknowledgement). - * - * Error (401): missing or invalid auth on the handshake POST. - * Error (500): invalid body or upstream Vercel Blob failure. - * - * @param request - The request object - * @returns A NextResponse with the handshake result or error - */ +/** POST /api/sandboxes/staged-file — Vercel Blob client-upload token handshake. */ export async function POST(request: NextRequest): Promise { return postSandboxesUploadTokensHandler(request); } diff --git a/lib/sandbox/postSandboxesUploadTokensHandler.ts b/lib/sandbox/postSandboxesUploadTokensHandler.ts index f3d68a239..044b52735 100644 --- a/lib/sandbox/postSandboxesUploadTokensHandler.ts +++ b/lib/sandbox/postSandboxesUploadTokensHandler.ts @@ -6,19 +6,7 @@ import { validateAuthContext } from "@/lib/auth/validateAuthContext"; const MAX_UPLOAD_SIZE_BYTES = 100 * 1024 * 1024; // 100MB -/** - * Handler for issuing client-upload tokens for sandbox file staging. - * - * Vercel Blob client uploads are a two-phase handshake: - * 1. Browser POSTs `HandleUploadBody` here (`type: "blob.generate-client-token"`) - * to get a presigned token. This phase carries the user's Bearer token in - * the `Authorization` header — `@vercel/blob/client.upload()` forwards - * headers from its `headers` option onto the handshake POST. - * 2. Browser uploads directly to Vercel Blob, then Vercel Blob's backend - * POSTs back here (`type: "blob.upload-completed"`). This callback does - * not carry the user's auth header — instead, `handleUpload()` verifies - * the callback's signature against the token issued in phase 1. - */ +// Auth applies only to the handshake — the upload-completed callback is signature-verified by handleUpload(). export async function postSandboxesUploadTokensHandler( request: NextRequest, ): Promise { From 27163a98882fbf62ea86c61bc6f72db5d29151e7 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Sat, 9 May 2026 23:37:50 +0530 Subject: [PATCH 7/7] fix(api): satisfy jsdoc/require-returns + require-param on staged-file route Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/sandboxes/staged-file/route.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/app/api/sandboxes/staged-file/route.ts b/app/api/sandboxes/staged-file/route.ts index fa45b0208..408df9640 100644 --- a/app/api/sandboxes/staged-file/route.ts +++ b/app/api/sandboxes/staged-file/route.ts @@ -3,12 +3,21 @@ import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { postSandboxesUploadTokensHandler } from "@/lib/sandbox/postSandboxesUploadTokensHandler"; -/** CORS preflight. */ +/** + * 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. */ +/** + * 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); }