From e27b1025a04c26067d22bd61805a9ed1b253ec57 Mon Sep 17 00:00:00 2001 From: "vercel[bot]" <35613825+vercel[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:17:26 +0000 Subject: [PATCH 1/7] fix(vercel): Blob direct HTTP parity Co-Authored-By: Scott Trinh <1682194+scotttrinh@users.noreply.github.com> --- README.md | 13 +++- apps/web/content/docs/vercel.mdx | 13 +++- .../vercel/src/__tests__/blob.test.ts | 18 ++--- packages/@emulators/vercel/src/entities.ts | 21 ++++++ .../vercel/src/routes/blob/constants.ts | 2 + .../vercel/src/routes/blob/delete.ts | 25 ++++--- .../@emulators/vercel/src/routes/blob/etag.ts | 8 +++ .../vercel/src/routes/blob/head-list.ts | 40 +++++------ .../vercel/src/routes/blob/multipart.ts | 36 ++++++++-- .../vercel/src/routes/blob/paths.ts | 10 ++- .../@emulators/vercel/src/routes/blob/put.ts | 68 +++++++------------ .../vercel/src/routes/blob/upload.ts | 38 +++++++++++ packages/@emulators/vercel/src/store.ts | 3 + skills/vercel/SKILL.md | 22 +++++- 14 files changed, 223 insertions(+), 94 deletions(-) create mode 100644 packages/@emulators/vercel/src/routes/blob/etag.ts create mode 100644 packages/@emulators/vercel/src/routes/blob/upload.ts diff --git a/README.md b/README.md index 1f88ea337..bed0d0096 100644 --- a/README.md +++ b/README.md @@ -813,11 +813,20 @@ Every endpoint below is fully stateful with Vercel-style JSON responses and curs Implements the Vercel Blob API used by the `@vercel/blob` SDK (`put`, `head`, `list`, `del`). - `PUT /api/blob?pathname=` - upload a blob (honors `x-add-random-suffix`, `x-allow-overwrite`, `x-content-type`, `x-cache-control-max-age`, `x-if-match` headers) +- `PUT /api/blob?pathname=&fromUrl=` - copy a blob (`copy()`); `x-if-match` is compared with the source blob's ETag, and `x-allow-overwrite` guards the destination - `GET /api/blob?url=` - blob metadata (`head()`) -- `GET /api/blob?prefix=&limit=&cursor=&mode=` - list blobs (`list()`, including folded mode) +- `GET /api/blob?prefix=&limit=&cursor=&mode=` - list blobs (`list()`, including folded mode, where folders and root blobs share one sorted page and cursor) - `POST /api/blob/delete` - delete blobs (`del()`) +- `POST /api/blob/mpu` with `x-mpu-action: create` - start a multipart upload (`createMultipartUpload()`), returning `{ key, uploadId }` - `GET /blob/:storeId/` - serve blob content (public, no auth; `?download=1` adds an attachment disposition) +Upload and deletion rules: + +- Leading slashes are removed from the pathname before validation, so `/docs/readme.txt` is stored as `docs/readme.txt`. This also applies to copy destinations. +- The content type is taken from `x-content-type`, then the pathname extension when it is a known type, then the request `Content-Type` header, then `application/octet-stream`. +- `x-cache-control-max-age` has a minimum of 60 seconds (`0` is stored as `public, max-age=60`). Absent or invalid values default to 30 days. +- Deleting with an empty `urls` array returns 400 `Missing urls in request body`. Malformed JSON or more than 1,000 urls returns 400 `Cannot delete more than 1,000 urls at once or body is malformed`. Nothing is deleted in either case. + Point the SDK at the emulator with two environment variables: ```bash @@ -825,7 +834,7 @@ VERCEL_BLOB_API_URL=http://localhost:4000/api/blob BLOB_READ_WRITE_TOKEN=vercel_blob_rw_mystore_secret ``` -Any token of the form `vercel_blob_rw__` is accepted; the store id is parsed from the token. Multipart uploads and client (browser) uploads are not supported yet. +Any token of the form `vercel_blob_rw__` is accepted; the store id is parsed from the token. Multipart uploads can be created, but uploading parts and completing the upload are not supported yet and return 400. Client (browser) uploads are not supported yet. ## GitHub API diff --git a/apps/web/content/docs/vercel.mdx b/apps/web/content/docs/vercel.mdx index ffe5be1d4..1a9bfd3e8 100644 --- a/apps/web/content/docs/vercel.mdx +++ b/apps/web/content/docs/vercel.mdx @@ -61,11 +61,20 @@ Every endpoint below is fully stateful with Vercel-style JSON responses and curs Implements the Vercel Blob API used by the `@vercel/blob` SDK (`put`, `head`, `list`, `del`). - `PUT /api/blob?pathname=` - upload a blob (honors `x-add-random-suffix`, `x-allow-overwrite`, `x-content-type`, `x-cache-control-max-age`, `x-if-match` headers) +- `PUT /api/blob?pathname=&fromUrl=` - copy a blob (`copy()`); `x-if-match` is compared with the source blob's ETag, and `x-allow-overwrite` guards the destination - `GET /api/blob?url=` - blob metadata (`head()`) -- `GET /api/blob?prefix=&limit=&cursor=&mode=` - list blobs (`list()`, including folded mode) +- `GET /api/blob?prefix=&limit=&cursor=&mode=` - list blobs (`list()`, including folded mode, where folders and root blobs share one sorted page and cursor) - `POST /api/blob/delete` - delete blobs (`del()`) +- `POST /api/blob/mpu` with `x-mpu-action: create` - start a multipart upload (`createMultipartUpload()`), returning `{ key, uploadId }` - `GET /blob/:storeId/` - serve blob content (public, no auth; `?download=1` adds an attachment disposition) +Upload and deletion rules: + +- Leading slashes are removed from the pathname before validation, so `/docs/readme.txt` is stored as `docs/readme.txt`. This also applies to copy destinations. +- The content type is taken from `x-content-type`, then the pathname extension when it is a known type, then the request `Content-Type` header, then `application/octet-stream`. +- `x-cache-control-max-age` has a minimum of 60 seconds (`0` is stored as `public, max-age=60`). Absent or invalid values default to 30 days. +- Deleting with an empty `urls` array returns 400 `Missing urls in request body`. Malformed JSON or more than 1,000 urls returns 400 `Cannot delete more than 1,000 urls at once or body is malformed`. Nothing is deleted in either case. + Point the SDK at the emulator with two environment variables: ```bash @@ -73,4 +82,4 @@ VERCEL_BLOB_API_URL=http://localhost:4000/api/blob BLOB_READ_WRITE_TOKEN=vercel_blob_rw_mystore_secret ``` -Any token of the form `vercel_blob_rw__` is accepted; the store id is parsed from the token. Multipart uploads and client (browser) uploads are not supported yet. +Any token of the form `vercel_blob_rw__` is accepted; the store id is parsed from the token. Multipart uploads can be created, but uploading parts and completing the upload are not supported yet and return 400. Client (browser) uploads are not supported yet. diff --git a/packages/@emulators/vercel/src/__tests__/blob.test.ts b/packages/@emulators/vercel/src/__tests__/blob.test.ts index be5857218..aae301d54 100644 --- a/packages/@emulators/vercel/src/__tests__/blob.test.ts +++ b/packages/@emulators/vercel/src/__tests__/blob.test.ts @@ -365,7 +365,7 @@ describe("Vercel Blob direct HTTP behavior", () => { expect(await response.text()).toBe(""); }); - it.fails("rejects an empty deletion batch without changing stored content", async () => { + it("rejects an empty deletion batch without changing stored content", async () => { const response = await fetch(`${apiUrl}/delete`, { method: "POST", headers: { ...headers, "content-type": "application/json" }, @@ -380,7 +380,7 @@ describe("Vercel Blob direct HTTP behavior", () => { }); }); - it.fails("rejects malformed deletion JSON without changing stored content", async () => { + it("rejects malformed deletion JSON without changing stored content", async () => { const response = await fetch(`${apiUrl}/delete`, { method: "POST", headers: { ...headers, "content-type": "application/json" }, @@ -397,7 +397,7 @@ describe("Vercel Blob direct HTTP behavior", () => { }); }); - it.fails("copies to a new pathname when ifMatch matches the source ETag", async () => { + it("copies to a new pathname when ifMatch matches the source ETag", async () => { const response = await fetch( `${apiUrl}?${new URLSearchParams({ pathname: "http-copy/new.bin", fromUrl: copySource.url })}`, { method: "PUT", headers: { ...headers, "x-if-match": copySource.etag } }, @@ -408,7 +408,7 @@ describe("Vercel Blob direct HTTP behavior", () => { expect(await (await fetch(copied.url)).text()).toBe("source"); }); - it.fails("rejects conditional copy when only the destination ETag matches", async () => { + it("rejects conditional copy when only the destination ETag matches", async () => { const response = await fetch( `${apiUrl}?${new URLSearchParams({ pathname: copyDestination.pathname, fromUrl: copySource.url })}`, { method: "PUT", headers: { ...headers, "x-if-match": copyDestination.etag } }, @@ -429,7 +429,7 @@ describe("Vercel Blob direct HTTP behavior", () => { }); }); - it.fails("paginates folded listings that contain only folders", async () => { + it("paginates folded listings that contain only folders", async () => { const first = await list({ prefix: "http-folded/", mode: "folded", limit: 1, token }); expect(first.blobs).toEqual([]); expect(first.folders).toEqual(["http-folded/a/"]); @@ -441,7 +441,7 @@ describe("Vercel Blob direct HTTP behavior", () => { expect(second.hasMore).toBe(false); }); - it.fails("uses the Content-Type header for an extensionless upload", async () => { + it("uses the Content-Type header for an extensionless upload", async () => { const response = await fetch(`${apiUrl}?pathname=http-metadata%2Fextensionless`, { method: "PUT", headers: { ...headers, "content-type": "application/x-test-fixture" }, @@ -461,7 +461,7 @@ describe("Vercel Blob direct HTTP behavior", () => { }); }); - it.fails("clamps modern cache max-age to at least one minute", async () => { + it("clamps modern cache max-age to at least one minute", async () => { const response = await fetch(`${apiUrl}?pathname=http-cache%2Fminimum.bin`, { method: "PUT", headers: { ...headers, "x-cache-control-max-age": "0" }, @@ -474,7 +474,7 @@ describe("Vercel Blob direct HTTP behavior", () => { expect(await (await fetch(uploaded.url)).text()).toBe("bytes"); }); - it.fails("removes a leading slash from the uploaded pathname", async () => { + it("removes a leading slash from the uploaded pathname", async () => { const response = await fetch(`${apiUrl}?pathname=%2Fhttp-paths%2Fleading.bin`, { method: "PUT", headers, @@ -489,7 +489,7 @@ describe("Vercel Blob direct HTTP behavior", () => { }); }); - it.fails("creates a multipart session without publishing a blob", async () => { + it("creates a multipart session without publishing a blob", async () => { const response = await fetch(`${apiUrl}/mpu?pathname=http-multipart%2Fsession.bin`, { method: "POST", headers: { ...headers, "x-mpu-action": "create" }, diff --git a/packages/@emulators/vercel/src/entities.ts b/packages/@emulators/vercel/src/entities.ts index 76a355350..ddcc4300d 100644 --- a/packages/@emulators/vercel/src/entities.ts +++ b/packages/@emulators/vercel/src/entities.ts @@ -248,6 +248,27 @@ export interface VercelBlob extends Entity { dataBase64: string; } +export interface VercelBlobUploadPart { + partNumber: number; + etag: string; + size: number; + dataBase64: string; +} + +// An in-progress multipart upload. The blob is only published on completion, +// using the put options captured when the upload was created. +export interface VercelBlobUpload extends Entity { + uploadId: string; + storeId: string; + pathname: string; + contentType: string; + cacheControl: string; + allowOverwrite: boolean; + ifMatch: string | null; + createdAt: string; + parts: VercelBlobUploadPart[]; +} + export interface VercelIntegration extends Entity { client_id: string; client_secret: string; diff --git a/packages/@emulators/vercel/src/routes/blob/constants.ts b/packages/@emulators/vercel/src/routes/blob/constants.ts index f7dbc99f6..825639d47 100644 --- a/packages/@emulators/vercel/src/routes/blob/constants.ts +++ b/packages/@emulators/vercel/src/routes/blob/constants.ts @@ -1,2 +1,4 @@ export const DEFAULT_CACHE_MAX_AGE = 2592000; +export const MIN_CACHE_MAX_AGE = 60; export const DEFAULT_LIST_LIMIT = 1000; +export const MAX_DELETE_URLS = 1000; diff --git a/packages/@emulators/vercel/src/routes/blob/delete.ts b/packages/@emulators/vercel/src/routes/blob/delete.ts index c288ca97a..a9d1f89bf 100644 --- a/packages/@emulators/vercel/src/routes/blob/delete.ts +++ b/packages/@emulators/vercel/src/routes/blob/delete.ts @@ -1,8 +1,10 @@ import type { BlobRouteContext } from "./context.js"; +import { MAX_DELETE_URLS } from "./constants.js"; import { blobErr, forbidden } from "./errors.js"; import { resolveStoreId } from "./auth.js"; import { resolveBlobRef } from "./paths.js"; import { findBlobRef } from "./lookup.js"; +import { etagMatches } from "./etag.js"; export function registerDelete({ app, vs, parsedBaseUrl }: BlobRouteContext): void { // Delete. @@ -11,23 +13,28 @@ export function registerDelete({ app, vs, parsedBaseUrl }: BlobRouteContext): vo const storeId = resolveStoreId(c); if (!storeId) return forbidden(c); - let body: { urls?: unknown }; + let body: unknown; try { - body = (await c.req.json()) as { urls?: unknown }; + body = await c.req.json(); } catch { - body = {}; + body = undefined; } - const urls = Array.isArray(body.urls) ? body.urls.filter((u): u is string => typeof u === "string") : []; + const isObject = typeof body === "object" && body !== null && !Array.isArray(body); + const rawUrls = isObject ? (body as { urls?: unknown }).urls : undefined; + if (isObject && (rawUrls === undefined || (Array.isArray(rawUrls) && rawUrls.length === 0))) { + return blobErr(c, 400, "bad_request", "Missing urls in request body"); + } + if (!Array.isArray(rawUrls) || rawUrls.length > MAX_DELETE_URLS) { + return blobErr(c, 400, "bad_request", "Cannot delete more than 1,000 urls at once or body is malformed"); + } + const urls = rawUrls.filter((u): u is string => typeof u === "string"); const ifMatch = c.req.header("x-if-match"); for (const urlOrPathname of urls) { const blob = findBlobRef(vs, storeId, resolveBlobRef(urlOrPathname, parsedBaseUrl)); if (!blob) continue; - if (ifMatch) { - const normalized = ifMatch.startsWith('"') ? ifMatch : `"${ifMatch}"`; - if (blob.etag !== normalized) { - return blobErr(c, 412, "precondition_failed", "Precondition failed: ETag mismatch."); - } + if (ifMatch && !etagMatches(blob.etag, ifMatch)) { + return blobErr(c, 412, "precondition_failed", "Precondition failed: ETag mismatch."); } vs.blobs.delete(blob.id); } diff --git a/packages/@emulators/vercel/src/routes/blob/etag.ts b/packages/@emulators/vercel/src/routes/blob/etag.ts new file mode 100644 index 000000000..1cf20cf96 --- /dev/null +++ b/packages/@emulators/vercel/src/routes/blob/etag.ts @@ -0,0 +1,8 @@ +// Clients may send x-if-match with or without the surrounding quotes. +export function normalizeEtag(etag: string): string { + return etag.startsWith('"') ? etag : `"${etag}"`; +} + +export function etagMatches(etag: string | undefined, ifMatch: string): boolean { + return etag !== undefined && etag === normalizeEtag(ifMatch); +} diff --git a/packages/@emulators/vercel/src/routes/blob/head-list.ts b/packages/@emulators/vercel/src/routes/blob/head-list.ts index 9ac73b82e..ac2474dbd 100644 --- a/packages/@emulators/vercel/src/routes/blob/head-list.ts +++ b/packages/@emulators/vercel/src/routes/blob/head-list.ts @@ -1,4 +1,5 @@ import type { Context } from "@emulators/core"; +import type { VercelBlob } from "../../entities.js"; import type { BlobRouteContext } from "./context.js"; import { DEFAULT_LIST_LIMIT } from "./constants.js"; import { blobErr, forbidden } from "./errors.js"; @@ -30,28 +31,27 @@ export function registerHeadList({ app, vs, baseUrl, parsedBaseUrl }: BlobRouteC const cursor = c.req.query("cursor"); const folded = c.req.query("mode") === "folded"; - let items = vs.blobs - .findBy("storeId", storeId) - .filter((b) => b.pathname.startsWith(prefix)) - .sort((a, b) => (a.pathname < b.pathname ? -1 : a.pathname > b.pathname ? 1 : 0)); - - const folders = new Set(); - if (folded) { - items = items.filter((b) => { - const rest = b.pathname.slice(prefix.length); - const slash = rest.indexOf("/"); - if (slash === -1) return true; - folders.add(prefix + rest.slice(0, slash + 1)); - return false; - }); + // Folded listings paginate over folders and root blobs together, so each + // entry is keyed by its folder prefix or its pathname. + const entries = new Map(); + for (const b of vs.blobs.findBy("storeId", storeId)) { + if (!b.pathname.startsWith(prefix)) continue; + const slash = folded ? b.pathname.indexOf("/", prefix.length) : -1; + if (slash === -1) { + entries.set(b.pathname, b); + } else { + entries.set(b.pathname.slice(0, slash + 1), null); + } } + let keys = [...entries.keys()].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); if (cursor) { - items = items.filter((b) => b.pathname > cursor); + keys = keys.filter((key) => key > cursor); } - const hasMore = items.length > limit; - const page = items.slice(0, limit); + const hasMore = keys.length > limit; + const pageKeys = keys.slice(0, limit); + const page = pageKeys.flatMap((key) => entries.get(key) ?? []); const result: Record = { blobs: page.map((b) => { @@ -67,11 +67,11 @@ export function registerHeadList({ app, vs, baseUrl, parsedBaseUrl }: BlobRouteC }), hasMore, }; - if (hasMore && page.length > 0) { - result.cursor = page[page.length - 1].pathname; + if (hasMore && pageKeys.length > 0) { + result.cursor = pageKeys[pageKeys.length - 1]; } if (folded) { - result.folders = [...folders].sort(); + result.folders = pageKeys.filter((key) => entries.get(key) === null); } return c.json(result); }; diff --git a/packages/@emulators/vercel/src/routes/blob/multipart.ts b/packages/@emulators/vercel/src/routes/blob/multipart.ts index 268bb91ab..63707b023 100644 --- a/packages/@emulators/vercel/src/routes/blob/multipart.ts +++ b/packages/@emulators/vercel/src/routes/blob/multipart.ts @@ -1,12 +1,40 @@ +import { randomBytes } from "crypto"; import type { Context } from "@emulators/core"; import type { BlobRouteContext } from "./context.js"; import { blobErr } from "./errors.js"; +import { inferContentType } from "./paths.js"; +import { cacheControlFor, resolveUploadTarget } from "./upload.js"; -export function registerMultipart({ app }: BlobRouteContext): void { - // Multipart uploads are not supported yet. +export function registerMultipart({ app, vs }: BlobRouteContext): void { + // Multipart uploads. Creation records a session; uploading parts and + // completing the upload are not supported yet. - const handleMpu = (c: Context): Response => - blobErr(c, 400, "bad_request", "Multipart uploads are not supported by the emulator yet"); + const handleCreate = (c: Context): Response => { + const target = resolveUploadTarget(c); + if (target instanceof Response) return target; + const { storeId, pathname } = target; + + const uploadId = randomBytes(16).toString("hex"); + vs.blobUploads.insert({ + uploadId, + storeId, + pathname, + contentType: c.req.header("x-content-type") || inferContentType(pathname), + cacheControl: cacheControlFor(c.req.header("x-cache-control-max-age")), + allowOverwrite: c.req.header("x-allow-overwrite") === "1", + ifMatch: c.req.header("x-if-match") || null, + createdAt: new Date().toISOString(), + parts: [], + }); + return c.json({ key: pathname, uploadId }); + }; + + const handleMpu = (c: Context): Response => { + if (c.req.method === "POST" && c.req.header("x-mpu-action") === "create") { + return handleCreate(c); + } + return blobErr(c, 400, "bad_request", "Multipart uploads are not supported by the emulator yet"); + }; app.post("/api/blob/mpu", handleMpu); app.put("/api/blob/mpu", handleMpu); } diff --git a/packages/@emulators/vercel/src/routes/blob/paths.ts b/packages/@emulators/vercel/src/routes/blob/paths.ts index daac00052..a6db579d9 100644 --- a/packages/@emulators/vercel/src/routes/blob/paths.ts +++ b/packages/@emulators/vercel/src/routes/blob/paths.ts @@ -28,12 +28,16 @@ export const MIME_TYPES: Record = { zip: "application/zip", }; -export function inferContentType(pathname: string): string { +export function knownContentType(pathname: string): string | undefined { const basename = pathname.split("/").pop() ?? ""; const dot = basename.lastIndexOf("."); - if (dot <= 0) return "application/octet-stream"; + if (dot <= 0) return undefined; const ext = basename.slice(dot + 1).toLowerCase(); - return MIME_TYPES[ext] ?? "application/octet-stream"; + return MIME_TYPES[ext]; +} + +export function inferContentType(pathname: string): string { + return knownContentType(pathname) ?? "application/octet-stream"; } export function randomSuffix(): string { diff --git a/packages/@emulators/vercel/src/routes/blob/put.ts b/packages/@emulators/vercel/src/routes/blob/put.ts index 882290b3a..1c803c62b 100644 --- a/packages/@emulators/vercel/src/routes/blob/put.ts +++ b/packages/@emulators/vercel/src/routes/blob/put.ts @@ -1,64 +1,46 @@ import { createHash } from "crypto"; import type { Context } from "@emulators/core"; import type { BlobRouteContext } from "./context.js"; -import { DEFAULT_CACHE_MAX_AGE } from "./constants.js"; -import { blobErr, forbidden } from "./errors.js"; -import { resolveStoreId } from "./auth.js"; -import { - blobUrl, - contentDispositionFor, - downloadUrl, - inferContentType, - resolveBlobRef, - withRandomSuffix, -} from "./paths.js"; +import { blobErr } from "./errors.js"; +import { blobUrl, contentDispositionFor, downloadUrl, knownContentType, resolveBlobRef } from "./paths.js"; import { findBlob, findBlobRef } from "./lookup.js"; +import { etagMatches } from "./etag.js"; +import { cacheControlFor, resolveUploadTarget } from "./upload.js"; export function registerPut({ app, vs, baseUrl, parsedBaseUrl }: BlobRouteContext): void { // Upload. The SDK sends PUT /?pathname= with the raw bytes as body. + // Copy sends the same request with ?fromUrl= and no body. const handlePut = async (c: Context): Promise => { - const storeId = resolveStoreId(c); - if (!storeId) return forbidden(c); + const target = resolveUploadTarget(c); + if (target instanceof Response) return target; + const { storeId, pathname } = target; - const rawPathname = c.req.query("pathname"); - if (!rawPathname) { - return blobErr(c, 400, "bad_request", "pathname is required"); - } - if (rawPathname.includes("//")) { - return blobErr(c, 400, "bad_request", "pathname cannot contain //"); - } - - const access = c.req.header("x-vercel-blob-access") ?? "public"; - if (access !== "public") { - return blobErr(c, 400, "bad_request", "Only access: public is supported by the emulator"); + const ifMatch = c.req.header("x-if-match") || undefined; + const fromUrl = c.req.query("fromUrl"); + const source = fromUrl === undefined ? undefined : findBlobRef(vs, storeId, resolveBlobRef(fromUrl, parsedBaseUrl)); + if (fromUrl !== undefined && !source) { + return blobErr(c, 404, "not_found", "The requested blob does not exist"); } - const pathname = c.req.header("x-add-random-suffix") === "1" ? withRandomSuffix(rawPathname) : rawPathname; - const existing = findBlob(vs, storeId, pathname); - const ifMatch = c.req.header("x-if-match"); - if (ifMatch) { - const normalized = ifMatch.startsWith('"') ? ifMatch : `"${ifMatch}"`; - if (!existing || existing.etag !== normalized) { - return blobErr(c, 412, "precondition_failed", "Precondition failed: ETag mismatch."); - } - } else if (existing && c.req.header("x-allow-overwrite") !== "1") { - return blobErr(c, 400, "bad_request", "This blob already exists, use allowOverwrite: true to overwrite it"); + // A copy's ifMatch guards the source; a plain upload's ifMatch guards the destination. + if (ifMatch && !etagMatches(source ? source.etag : existing?.etag, ifMatch)) { + return blobErr(c, 412, "precondition_failed", "Precondition failed: ETag mismatch."); } - - const fromUrl = c.req.query("fromUrl"); - const source = fromUrl === undefined ? undefined : findBlobRef(vs, storeId, resolveBlobRef(fromUrl, parsedBaseUrl)); - if (fromUrl !== undefined && !source) { - return blobErr(c, 404, "not_found", "The requested blob does not exist"); + const overwriteAllowed = c.req.header("x-allow-overwrite") === "1" || (ifMatch !== undefined && !source); + if (existing && !overwriteAllowed) { + return blobErr(c, 400, "bad_request", "This blob already exists, use allowOverwrite: true to overwrite it"); } const body = source ? Buffer.from(source.dataBase64, "base64") : Buffer.from(await c.req.arrayBuffer()); - const contentType = c.req.header("x-content-type") || inferContentType(pathname); - const maxAgeHeader = c.req.header("x-cache-control-max-age"); - const maxAge = maxAgeHeader ? parseInt(maxAgeHeader, 10) : NaN; - const cacheControl = `public, max-age=${Number.isFinite(maxAge) ? maxAge : DEFAULT_CACHE_MAX_AGE}`; + const contentType = + c.req.header("x-content-type") || + knownContentType(pathname) || + (source ? undefined : c.req.header("content-type")) || + "application/octet-stream"; + const cacheControl = cacheControlFor(c.req.header("x-cache-control-max-age")); const contentDisposition = contentDispositionFor(pathname); const etag = `"${createHash("sha256").update(body).digest("hex")}"`; const uploadedAt = new Date().toISOString(); diff --git a/packages/@emulators/vercel/src/routes/blob/upload.ts b/packages/@emulators/vercel/src/routes/blob/upload.ts new file mode 100644 index 000000000..8524d6c2d --- /dev/null +++ b/packages/@emulators/vercel/src/routes/blob/upload.ts @@ -0,0 +1,38 @@ +import type { Context } from "@emulators/core"; +import { DEFAULT_CACHE_MAX_AGE, MIN_CACHE_MAX_AGE } from "./constants.js"; +import { blobErr, forbidden } from "./errors.js"; +import { resolveStoreId } from "./auth.js"; +import { withRandomSuffix } from "./paths.js"; + +export interface UploadTarget { + storeId: string; + pathname: string; +} + +// Shared by single PUT uploads, copies and multipart creation: authenticates the +// request and turns the pathname query into the final stored pathname. +export function resolveUploadTarget(c: Context): UploadTarget | Response { + const storeId = resolveStoreId(c); + if (!storeId) return forbidden(c); + + const rawPathname = c.req.query("pathname")?.replace(/^\/+/, ""); + if (!rawPathname) { + return blobErr(c, 400, "bad_request", "pathname is required"); + } + if (rawPathname.includes("//")) { + return blobErr(c, 400, "bad_request", "pathname cannot contain //"); + } + + const access = c.req.header("x-vercel-blob-access") ?? "public"; + if (access !== "public") { + return blobErr(c, 400, "bad_request", "Only access: public is supported by the emulator"); + } + + const pathname = c.req.header("x-add-random-suffix") === "1" ? withRandomSuffix(rawPathname) : rawPathname; + return { storeId, pathname }; +} + +export function cacheControlFor(maxAgeHeader: string | undefined): string { + const maxAge = maxAgeHeader ? parseInt(maxAgeHeader, 10) : NaN; + return `public, max-age=${Number.isFinite(maxAge) ? Math.max(maxAge, MIN_CACHE_MAX_AGE) : DEFAULT_CACHE_MAX_AGE}`; +} diff --git a/packages/@emulators/vercel/src/store.ts b/packages/@emulators/vercel/src/store.ts index 8f487eae7..8aef70db5 100644 --- a/packages/@emulators/vercel/src/store.ts +++ b/packages/@emulators/vercel/src/store.ts @@ -16,6 +16,7 @@ import type { VercelIntegration, VercelApiKey, VercelBlob, + VercelBlobUpload, } from "./entities.js"; export interface VercelStore { @@ -35,6 +36,7 @@ export interface VercelStore { apiKeys: Collection; integrations: Collection; blobs: Collection; + blobUploads: Collection; } export function getVercelStore(store: Store): VercelStore { @@ -58,5 +60,6 @@ export function getVercelStore(store: Store): VercelStore { apiKeys: store.collection("vercel.api_keys", ["uid", "teamId", "userId"]), integrations: store.collection("vercel.integrations", ["client_id"]), blobs: store.collection("vercel.blobs", ["pathname", "storeId"]), + blobUploads: store.collection("vercel.blob_uploads", ["uploadId", "storeId"]), }; } diff --git a/skills/vercel/SKILL.md b/skills/vercel/SKILL.md index 9e2925d23..c45b8af11 100644 --- a/skills/vercel/SKILL.md +++ b/skills/vercel/SKILL.md @@ -381,11 +381,17 @@ curl -X PUT "http://localhost:4000/api/blob?pathname=docs/readme.txt" \ -H "Authorization: Bearer $BLOB_TOKEN" \ --data-binary @readme.txt +# Copy (x-if-match is compared with the source blob's ETag; +# x-allow-overwrite guards the destination) +curl -X PUT "http://localhost:4000/api/blob?pathname=docs/copy.txt&fromUrl=docs/readme.txt" \ + -H "Authorization: Bearer $BLOB_TOKEN" + # Metadata (head) curl "http://localhost:4000/api/blob?url=docs/readme.txt" \ -H "Authorization: Bearer $BLOB_TOKEN" -# List (prefix, limit, cursor, mode=folded) +# List (prefix, limit, cursor, mode=folded; folded pages share one +# sorted cursor across folders and root blobs) curl "http://localhost:4000/api/blob?prefix=docs/" \ -H "Authorization: Bearer $BLOB_TOKEN" @@ -395,11 +401,23 @@ curl -X POST http://localhost:4000/api/blob/delete \ -H "Content-Type: application/json" \ -d '{"urls": ["docs/readme.txt"]}' +# Start a multipart upload (returns { key, uploadId }) +curl -X POST "http://localhost:4000/api/blob/mpu?pathname=docs/large.bin" \ + -H "Authorization: Bearer $BLOB_TOKEN" \ + -H "x-mpu-action: create" + # Serve content (public, no auth; ?download=1 forces attachment) curl http://localhost:4000/blob/mystore/docs/readme.txt ``` -Multipart uploads and client (browser) uploads are not supported yet. +Upload and deletion rules: + +- Leading slashes are removed from the pathname before validation, so `/docs/readme.txt` is stored as `docs/readme.txt`. This also applies to copy destinations. +- The content type is taken from `x-content-type`, then the pathname extension when it is a known type, then the request `Content-Type` header, then `application/octet-stream`. +- `x-cache-control-max-age` has a minimum of 60 seconds (`0` is stored as `public, max-age=60`). Absent or invalid values default to 30 days. +- Deleting with an empty `urls` array returns 400 `Missing urls in request body`. Malformed JSON or more than 1,000 urls returns 400 `Cannot delete more than 1,000 urls at once or body is malformed`. Nothing is deleted in either case. + +Multipart uploads can be created, but uploading parts and completing the upload are not supported yet and return 400. Client (browser) uploads are not supported yet. ### API Keys From 8031d6e8ef3d259dee46072c486245c252699721 Mon Sep 17 00:00:00 2001 From: "vercel[bot]" <35613825+vercel[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:34:09 +0000 Subject: [PATCH 2/7] fix(vercel): align Blob with real service rules Co-Authored-By: Scott Trinh <1682194+scotttrinh@users.noreply.github.com> --- README.md | 6 +++--- apps/web/content/docs/vercel.mdx | 6 +++--- packages/@emulators/vercel/src/entities.ts | 5 ++--- .../@emulators/vercel/src/routes/blob/delete.ts | 16 ++++++++++------ .../vercel/src/routes/blob/multipart.ts | 7 ++----- .../@emulators/vercel/src/routes/blob/put.ts | 13 +++++-------- .../@emulators/vercel/src/routes/blob/upload.ts | 13 +++++++++++-- skills/vercel/SKILL.md | 6 +++--- 8 files changed, 39 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index bed0d0096..bd407c76e 100644 --- a/README.md +++ b/README.md @@ -813,7 +813,7 @@ Every endpoint below is fully stateful with Vercel-style JSON responses and curs Implements the Vercel Blob API used by the `@vercel/blob` SDK (`put`, `head`, `list`, `del`). - `PUT /api/blob?pathname=` - upload a blob (honors `x-add-random-suffix`, `x-allow-overwrite`, `x-content-type`, `x-cache-control-max-age`, `x-if-match` headers) -- `PUT /api/blob?pathname=&fromUrl=` - copy a blob (`copy()`); `x-if-match` is compared with the source blob's ETag, and `x-allow-overwrite` guards the destination +- `PUT /api/blob?pathname=&fromUrl=` - copy a blob (`copy()`); `x-if-match` is compared with the source blob's ETag, and an existing destination is always replaced - `GET /api/blob?url=` - blob metadata (`head()`) - `GET /api/blob?prefix=&limit=&cursor=&mode=` - list blobs (`list()`, including folded mode, where folders and root blobs share one sorted page and cursor) - `POST /api/blob/delete` - delete blobs (`del()`) @@ -822,10 +822,10 @@ Implements the Vercel Blob API used by the `@vercel/blob` SDK (`put`, `head`, `l Upload and deletion rules: -- Leading slashes are removed from the pathname before validation, so `/docs/readme.txt` is stored as `docs/readme.txt`. This also applies to copy destinations. +- A single leading slash is removed from the pathname before validation, so `/docs/readme.txt` is stored as `docs/readme.txt`. This also applies to copy destinations and multipart uploads. - The content type is taken from `x-content-type`, then the pathname extension when it is a known type, then the request `Content-Type` header, then `application/octet-stream`. - `x-cache-control-max-age` has a minimum of 60 seconds (`0` is stored as `public, max-age=60`). Absent or invalid values default to 30 days. -- Deleting with an empty `urls` array returns 400 `Missing urls in request body`. Malformed JSON or more than 1,000 urls returns 400 `Cannot delete more than 1,000 urls at once or body is malformed`. Nothing is deleted in either case. +- Deleting with a missing or empty `urls` array returns 400 `Missing urls in request body`. Malformed JSON returns 400 `Cannot delete more than 1,000 urls at once or body is malformed`, more than 1,000 urls returns 400 `Cannot delete more than 1,000 urls at once`, and any non-string entry returns 400 `Some urls are malformed`. Nothing is deleted in any of these cases. Point the SDK at the emulator with two environment variables: diff --git a/apps/web/content/docs/vercel.mdx b/apps/web/content/docs/vercel.mdx index 1a9bfd3e8..b813c21c2 100644 --- a/apps/web/content/docs/vercel.mdx +++ b/apps/web/content/docs/vercel.mdx @@ -61,7 +61,7 @@ Every endpoint below is fully stateful with Vercel-style JSON responses and curs Implements the Vercel Blob API used by the `@vercel/blob` SDK (`put`, `head`, `list`, `del`). - `PUT /api/blob?pathname=` - upload a blob (honors `x-add-random-suffix`, `x-allow-overwrite`, `x-content-type`, `x-cache-control-max-age`, `x-if-match` headers) -- `PUT /api/blob?pathname=&fromUrl=` - copy a blob (`copy()`); `x-if-match` is compared with the source blob's ETag, and `x-allow-overwrite` guards the destination +- `PUT /api/blob?pathname=&fromUrl=` - copy a blob (`copy()`); `x-if-match` is compared with the source blob's ETag, and an existing destination is always replaced - `GET /api/blob?url=` - blob metadata (`head()`) - `GET /api/blob?prefix=&limit=&cursor=&mode=` - list blobs (`list()`, including folded mode, where folders and root blobs share one sorted page and cursor) - `POST /api/blob/delete` - delete blobs (`del()`) @@ -70,10 +70,10 @@ Implements the Vercel Blob API used by the `@vercel/blob` SDK (`put`, `head`, `l Upload and deletion rules: -- Leading slashes are removed from the pathname before validation, so `/docs/readme.txt` is stored as `docs/readme.txt`. This also applies to copy destinations. +- A single leading slash is removed from the pathname before validation, so `/docs/readme.txt` is stored as `docs/readme.txt`. This also applies to copy destinations and multipart uploads. - The content type is taken from `x-content-type`, then the pathname extension when it is a known type, then the request `Content-Type` header, then `application/octet-stream`. - `x-cache-control-max-age` has a minimum of 60 seconds (`0` is stored as `public, max-age=60`). Absent or invalid values default to 30 days. -- Deleting with an empty `urls` array returns 400 `Missing urls in request body`. Malformed JSON or more than 1,000 urls returns 400 `Cannot delete more than 1,000 urls at once or body is malformed`. Nothing is deleted in either case. +- Deleting with a missing or empty `urls` array returns 400 `Missing urls in request body`. Malformed JSON returns 400 `Cannot delete more than 1,000 urls at once or body is malformed`, more than 1,000 urls returns 400 `Cannot delete more than 1,000 urls at once`, and any non-string entry returns 400 `Some urls are malformed`. Nothing is deleted in any of these cases. Point the SDK at the emulator with two environment variables: diff --git a/packages/@emulators/vercel/src/entities.ts b/packages/@emulators/vercel/src/entities.ts index ddcc4300d..29e14ade5 100644 --- a/packages/@emulators/vercel/src/entities.ts +++ b/packages/@emulators/vercel/src/entities.ts @@ -256,15 +256,14 @@ export interface VercelBlobUploadPart { } // An in-progress multipart upload. The blob is only published on completion, -// using the put options captured when the upload was created. +// using the metadata captured when the upload was created. Overwrite and +// ifMatch checks come from the completion request instead. export interface VercelBlobUpload extends Entity { uploadId: string; storeId: string; pathname: string; contentType: string; cacheControl: string; - allowOverwrite: boolean; - ifMatch: string | null; createdAt: string; parts: VercelBlobUploadPart[]; } diff --git a/packages/@emulators/vercel/src/routes/blob/delete.ts b/packages/@emulators/vercel/src/routes/blob/delete.ts index a9d1f89bf..fa4f849a7 100644 --- a/packages/@emulators/vercel/src/routes/blob/delete.ts +++ b/packages/@emulators/vercel/src/routes/blob/delete.ts @@ -17,17 +17,21 @@ export function registerDelete({ app, vs, parsedBaseUrl }: BlobRouteContext): vo try { body = await c.req.json(); } catch { - body = undefined; + return blobErr(c, 400, "bad_request", "Cannot delete more than 1,000 urls at once or body is malformed"); } - const isObject = typeof body === "object" && body !== null && !Array.isArray(body); - const rawUrls = isObject ? (body as { urls?: unknown }).urls : undefined; - if (isObject && (rawUrls === undefined || (Array.isArray(rawUrls) && rawUrls.length === 0))) { + const urls = typeof body === "object" && body !== null ? (body as { urls?: unknown }).urls : undefined; + if (!urls || (Array.isArray(urls) && urls.length === 0)) { return blobErr(c, 400, "bad_request", "Missing urls in request body"); } - if (!Array.isArray(rawUrls) || rawUrls.length > MAX_DELETE_URLS) { + if (!Array.isArray(urls)) { return blobErr(c, 400, "bad_request", "Cannot delete more than 1,000 urls at once or body is malformed"); } - const urls = rawUrls.filter((u): u is string => typeof u === "string"); + if (urls.length > MAX_DELETE_URLS) { + return blobErr(c, 400, "bad_request", "Cannot delete more than 1,000 urls at once"); + } + if (!urls.every((u): u is string => typeof u === "string")) { + return blobErr(c, 400, "bad_request", "Some urls are malformed"); + } const ifMatch = c.req.header("x-if-match"); for (const urlOrPathname of urls) { diff --git a/packages/@emulators/vercel/src/routes/blob/multipart.ts b/packages/@emulators/vercel/src/routes/blob/multipart.ts index 63707b023..5c0b39ff0 100644 --- a/packages/@emulators/vercel/src/routes/blob/multipart.ts +++ b/packages/@emulators/vercel/src/routes/blob/multipart.ts @@ -2,8 +2,7 @@ import { randomBytes } from "crypto"; import type { Context } from "@emulators/core"; import type { BlobRouteContext } from "./context.js"; import { blobErr } from "./errors.js"; -import { inferContentType } from "./paths.js"; -import { cacheControlFor, resolveUploadTarget } from "./upload.js"; +import { cacheControlFor, contentTypeFor, resolveUploadTarget } from "./upload.js"; export function registerMultipart({ app, vs }: BlobRouteContext): void { // Multipart uploads. Creation records a session; uploading parts and @@ -19,10 +18,8 @@ export function registerMultipart({ app, vs }: BlobRouteContext): void { uploadId, storeId, pathname, - contentType: c.req.header("x-content-type") || inferContentType(pathname), + contentType: contentTypeFor(c, pathname), cacheControl: cacheControlFor(c.req.header("x-cache-control-max-age")), - allowOverwrite: c.req.header("x-allow-overwrite") === "1", - ifMatch: c.req.header("x-if-match") || null, createdAt: new Date().toISOString(), parts: [], }); diff --git a/packages/@emulators/vercel/src/routes/blob/put.ts b/packages/@emulators/vercel/src/routes/blob/put.ts index 1c803c62b..bcdff9aed 100644 --- a/packages/@emulators/vercel/src/routes/blob/put.ts +++ b/packages/@emulators/vercel/src/routes/blob/put.ts @@ -2,10 +2,10 @@ import { createHash } from "crypto"; import type { Context } from "@emulators/core"; import type { BlobRouteContext } from "./context.js"; import { blobErr } from "./errors.js"; -import { blobUrl, contentDispositionFor, downloadUrl, knownContentType, resolveBlobRef } from "./paths.js"; +import { blobUrl, contentDispositionFor, downloadUrl, resolveBlobRef } from "./paths.js"; import { findBlob, findBlobRef } from "./lookup.js"; import { etagMatches } from "./etag.js"; -import { cacheControlFor, resolveUploadTarget } from "./upload.js"; +import { cacheControlFor, contentTypeFor, resolveUploadTarget } from "./upload.js"; export function registerPut({ app, vs, baseUrl, parsedBaseUrl }: BlobRouteContext): void { // Upload. The SDK sends PUT /?pathname= with the raw bytes as body. @@ -29,17 +29,14 @@ export function registerPut({ app, vs, baseUrl, parsedBaseUrl }: BlobRouteContex if (ifMatch && !etagMatches(source ? source.etag : existing?.etag, ifMatch)) { return blobErr(c, 412, "precondition_failed", "Precondition failed: ETag mismatch."); } - const overwriteAllowed = c.req.header("x-allow-overwrite") === "1" || (ifMatch !== undefined && !source); + // Copies always replace the destination, like the real service. + const overwriteAllowed = source !== undefined || ifMatch !== undefined || c.req.header("x-allow-overwrite") === "1"; if (existing && !overwriteAllowed) { return blobErr(c, 400, "bad_request", "This blob already exists, use allowOverwrite: true to overwrite it"); } const body = source ? Buffer.from(source.dataBase64, "base64") : Buffer.from(await c.req.arrayBuffer()); - const contentType = - c.req.header("x-content-type") || - knownContentType(pathname) || - (source ? undefined : c.req.header("content-type")) || - "application/octet-stream"; + const contentType = contentTypeFor(c, pathname, { useRequestHeader: !source }); const cacheControl = cacheControlFor(c.req.header("x-cache-control-max-age")); const contentDisposition = contentDispositionFor(pathname); const etag = `"${createHash("sha256").update(body).digest("hex")}"`; diff --git a/packages/@emulators/vercel/src/routes/blob/upload.ts b/packages/@emulators/vercel/src/routes/blob/upload.ts index 8524d6c2d..dfc794f33 100644 --- a/packages/@emulators/vercel/src/routes/blob/upload.ts +++ b/packages/@emulators/vercel/src/routes/blob/upload.ts @@ -2,7 +2,7 @@ import type { Context } from "@emulators/core"; import { DEFAULT_CACHE_MAX_AGE, MIN_CACHE_MAX_AGE } from "./constants.js"; import { blobErr, forbidden } from "./errors.js"; import { resolveStoreId } from "./auth.js"; -import { withRandomSuffix } from "./paths.js"; +import { knownContentType, withRandomSuffix } from "./paths.js"; export interface UploadTarget { storeId: string; @@ -15,7 +15,7 @@ export function resolveUploadTarget(c: Context): UploadTarget | Response { const storeId = resolveStoreId(c); if (!storeId) return forbidden(c); - const rawPathname = c.req.query("pathname")?.replace(/^\/+/, ""); + const rawPathname = c.req.query("pathname")?.replace(/^\//, ""); if (!rawPathname) { return blobErr(c, 400, "bad_request", "pathname is required"); } @@ -32,6 +32,15 @@ export function resolveUploadTarget(c: Context): UploadTarget | Response { return { storeId, pathname }; } +export function contentTypeFor(c: Context, pathname: string, { useRequestHeader = true } = {}): string { + return ( + c.req.header("x-content-type") || + knownContentType(pathname) || + (useRequestHeader ? c.req.header("content-type") : undefined) || + "application/octet-stream" + ); +} + export function cacheControlFor(maxAgeHeader: string | undefined): string { const maxAge = maxAgeHeader ? parseInt(maxAgeHeader, 10) : NaN; return `public, max-age=${Number.isFinite(maxAge) ? Math.max(maxAge, MIN_CACHE_MAX_AGE) : DEFAULT_CACHE_MAX_AGE}`; diff --git a/skills/vercel/SKILL.md b/skills/vercel/SKILL.md index c45b8af11..6227ba6c7 100644 --- a/skills/vercel/SKILL.md +++ b/skills/vercel/SKILL.md @@ -382,7 +382,7 @@ curl -X PUT "http://localhost:4000/api/blob?pathname=docs/readme.txt" \ --data-binary @readme.txt # Copy (x-if-match is compared with the source blob's ETag; -# x-allow-overwrite guards the destination) +# an existing destination is always replaced) curl -X PUT "http://localhost:4000/api/blob?pathname=docs/copy.txt&fromUrl=docs/readme.txt" \ -H "Authorization: Bearer $BLOB_TOKEN" @@ -412,10 +412,10 @@ curl http://localhost:4000/blob/mystore/docs/readme.txt Upload and deletion rules: -- Leading slashes are removed from the pathname before validation, so `/docs/readme.txt` is stored as `docs/readme.txt`. This also applies to copy destinations. +- A single leading slash is removed from the pathname before validation, so `/docs/readme.txt` is stored as `docs/readme.txt`. This also applies to copy destinations and multipart uploads. - The content type is taken from `x-content-type`, then the pathname extension when it is a known type, then the request `Content-Type` header, then `application/octet-stream`. - `x-cache-control-max-age` has a minimum of 60 seconds (`0` is stored as `public, max-age=60`). Absent or invalid values default to 30 days. -- Deleting with an empty `urls` array returns 400 `Missing urls in request body`. Malformed JSON or more than 1,000 urls returns 400 `Cannot delete more than 1,000 urls at once or body is malformed`. Nothing is deleted in either case. +- Deleting with a missing or empty `urls` array returns 400 `Missing urls in request body`. Malformed JSON returns 400 `Cannot delete more than 1,000 urls at once or body is malformed`, more than 1,000 urls returns 400 `Cannot delete more than 1,000 urls at once`, and any non-string entry returns 400 `Some urls are malformed`. Nothing is deleted in any of these cases. Multipart uploads can be created, but uploading parts and completing the upload are not supported yet and return 400. Client (browser) uploads are not supported yet. From 2a9bb74fd28807ce30db6acdaf9e4ed9f053f943 Mon Sep 17 00:00:00 2001 From: Scott Trinh Date: Wed, 14 Oct 2026 13:26:16 -0400 Subject: [PATCH 3/7] Stricter undefined check for delete All other falsy values fall through to later error handler Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- packages/@emulators/vercel/src/routes/blob/delete.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@emulators/vercel/src/routes/blob/delete.ts b/packages/@emulators/vercel/src/routes/blob/delete.ts index fa4f849a7..1ae14acb8 100644 --- a/packages/@emulators/vercel/src/routes/blob/delete.ts +++ b/packages/@emulators/vercel/src/routes/blob/delete.ts @@ -20,7 +20,7 @@ export function registerDelete({ app, vs, parsedBaseUrl }: BlobRouteContext): vo return blobErr(c, 400, "bad_request", "Cannot delete more than 1,000 urls at once or body is malformed"); } const urls = typeof body === "object" && body !== null ? (body as { urls?: unknown }).urls : undefined; - if (!urls || (Array.isArray(urls) && urls.length === 0)) { + if (urls === undefined || (Array.isArray(urls) && urls.length === 0)) { return blobErr(c, 400, "bad_request", "Missing urls in request body"); } if (!Array.isArray(urls)) { From 3ac3ac7716756a6997bc4314e0ec82d72e6a34b7 Mon Sep 17 00:00:00 2001 From: "vercel[bot]" <35613825+vercel[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:36:07 +0000 Subject: [PATCH 4/7] docs(vercel): note non-array Blob delete urls Co-Authored-By: Scott Trinh <1682194+scotttrinh@users.noreply.github.com> --- README.md | 2 +- apps/web/content/docs/vercel.mdx | 2 +- skills/vercel/SKILL.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index bd407c76e..1d6eef0ad 100644 --- a/README.md +++ b/README.md @@ -825,7 +825,7 @@ Upload and deletion rules: - A single leading slash is removed from the pathname before validation, so `/docs/readme.txt` is stored as `docs/readme.txt`. This also applies to copy destinations and multipart uploads. - The content type is taken from `x-content-type`, then the pathname extension when it is a known type, then the request `Content-Type` header, then `application/octet-stream`. - `x-cache-control-max-age` has a minimum of 60 seconds (`0` is stored as `public, max-age=60`). Absent or invalid values default to 30 days. -- Deleting with a missing or empty `urls` array returns 400 `Missing urls in request body`. Malformed JSON returns 400 `Cannot delete more than 1,000 urls at once or body is malformed`, more than 1,000 urls returns 400 `Cannot delete more than 1,000 urls at once`, and any non-string entry returns 400 `Some urls are malformed`. Nothing is deleted in any of these cases. +- Deleting with a missing or empty `urls` array returns 400 `Missing urls in request body`. Malformed JSON or a `urls` value that is not an array returns 400 `Cannot delete more than 1,000 urls at once or body is malformed`, more than 1,000 urls returns 400 `Cannot delete more than 1,000 urls at once`, and any non-string entry returns 400 `Some urls are malformed`. Nothing is deleted in any of these cases. Point the SDK at the emulator with two environment variables: diff --git a/apps/web/content/docs/vercel.mdx b/apps/web/content/docs/vercel.mdx index b813c21c2..027ea126d 100644 --- a/apps/web/content/docs/vercel.mdx +++ b/apps/web/content/docs/vercel.mdx @@ -73,7 +73,7 @@ Upload and deletion rules: - A single leading slash is removed from the pathname before validation, so `/docs/readme.txt` is stored as `docs/readme.txt`. This also applies to copy destinations and multipart uploads. - The content type is taken from `x-content-type`, then the pathname extension when it is a known type, then the request `Content-Type` header, then `application/octet-stream`. - `x-cache-control-max-age` has a minimum of 60 seconds (`0` is stored as `public, max-age=60`). Absent or invalid values default to 30 days. -- Deleting with a missing or empty `urls` array returns 400 `Missing urls in request body`. Malformed JSON returns 400 `Cannot delete more than 1,000 urls at once or body is malformed`, more than 1,000 urls returns 400 `Cannot delete more than 1,000 urls at once`, and any non-string entry returns 400 `Some urls are malformed`. Nothing is deleted in any of these cases. +- Deleting with a missing or empty `urls` array returns 400 `Missing urls in request body`. Malformed JSON or a `urls` value that is not an array returns 400 `Cannot delete more than 1,000 urls at once or body is malformed`, more than 1,000 urls returns 400 `Cannot delete more than 1,000 urls at once`, and any non-string entry returns 400 `Some urls are malformed`. Nothing is deleted in any of these cases. Point the SDK at the emulator with two environment variables: diff --git a/skills/vercel/SKILL.md b/skills/vercel/SKILL.md index 6227ba6c7..341459e0f 100644 --- a/skills/vercel/SKILL.md +++ b/skills/vercel/SKILL.md @@ -415,7 +415,7 @@ Upload and deletion rules: - A single leading slash is removed from the pathname before validation, so `/docs/readme.txt` is stored as `docs/readme.txt`. This also applies to copy destinations and multipart uploads. - The content type is taken from `x-content-type`, then the pathname extension when it is a known type, then the request `Content-Type` header, then `application/octet-stream`. - `x-cache-control-max-age` has a minimum of 60 seconds (`0` is stored as `public, max-age=60`). Absent or invalid values default to 30 days. -- Deleting with a missing or empty `urls` array returns 400 `Missing urls in request body`. Malformed JSON returns 400 `Cannot delete more than 1,000 urls at once or body is malformed`, more than 1,000 urls returns 400 `Cannot delete more than 1,000 urls at once`, and any non-string entry returns 400 `Some urls are malformed`. Nothing is deleted in any of these cases. +- Deleting with a missing or empty `urls` array returns 400 `Missing urls in request body`. Malformed JSON or a `urls` value that is not an array returns 400 `Cannot delete more than 1,000 urls at once or body is malformed`, more than 1,000 urls returns 400 `Cannot delete more than 1,000 urls at once`, and any non-string entry returns 400 `Some urls are malformed`. Nothing is deleted in any of these cases. Multipart uploads can be created, but uploading parts and completing the upload are not supported yet and return 400. Client (browser) uploads are not supported yet. From 0674be774b2ccb93445b3b3203207693b6ebedd1 Mon Sep 17 00:00:00 2001 From: "vercel[bot]" <35613825+vercel[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:46:39 +0000 Subject: [PATCH 5/7] Revert falsy Blob delete urls check The real service treats any falsy urls value as missing. Co-Authored-By: Scott Trinh <1682194+scotttrinh@users.noreply.github.com> --- README.md | 2 +- apps/web/content/docs/vercel.mdx | 2 +- packages/@emulators/vercel/src/routes/blob/delete.ts | 2 +- skills/vercel/SKILL.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 1d6eef0ad..bd407c76e 100644 --- a/README.md +++ b/README.md @@ -825,7 +825,7 @@ Upload and deletion rules: - A single leading slash is removed from the pathname before validation, so `/docs/readme.txt` is stored as `docs/readme.txt`. This also applies to copy destinations and multipart uploads. - The content type is taken from `x-content-type`, then the pathname extension when it is a known type, then the request `Content-Type` header, then `application/octet-stream`. - `x-cache-control-max-age` has a minimum of 60 seconds (`0` is stored as `public, max-age=60`). Absent or invalid values default to 30 days. -- Deleting with a missing or empty `urls` array returns 400 `Missing urls in request body`. Malformed JSON or a `urls` value that is not an array returns 400 `Cannot delete more than 1,000 urls at once or body is malformed`, more than 1,000 urls returns 400 `Cannot delete more than 1,000 urls at once`, and any non-string entry returns 400 `Some urls are malformed`. Nothing is deleted in any of these cases. +- Deleting with a missing or empty `urls` array returns 400 `Missing urls in request body`. Malformed JSON returns 400 `Cannot delete more than 1,000 urls at once or body is malformed`, more than 1,000 urls returns 400 `Cannot delete more than 1,000 urls at once`, and any non-string entry returns 400 `Some urls are malformed`. Nothing is deleted in any of these cases. Point the SDK at the emulator with two environment variables: diff --git a/apps/web/content/docs/vercel.mdx b/apps/web/content/docs/vercel.mdx index 027ea126d..b813c21c2 100644 --- a/apps/web/content/docs/vercel.mdx +++ b/apps/web/content/docs/vercel.mdx @@ -73,7 +73,7 @@ Upload and deletion rules: - A single leading slash is removed from the pathname before validation, so `/docs/readme.txt` is stored as `docs/readme.txt`. This also applies to copy destinations and multipart uploads. - The content type is taken from `x-content-type`, then the pathname extension when it is a known type, then the request `Content-Type` header, then `application/octet-stream`. - `x-cache-control-max-age` has a minimum of 60 seconds (`0` is stored as `public, max-age=60`). Absent or invalid values default to 30 days. -- Deleting with a missing or empty `urls` array returns 400 `Missing urls in request body`. Malformed JSON or a `urls` value that is not an array returns 400 `Cannot delete more than 1,000 urls at once or body is malformed`, more than 1,000 urls returns 400 `Cannot delete more than 1,000 urls at once`, and any non-string entry returns 400 `Some urls are malformed`. Nothing is deleted in any of these cases. +- Deleting with a missing or empty `urls` array returns 400 `Missing urls in request body`. Malformed JSON returns 400 `Cannot delete more than 1,000 urls at once or body is malformed`, more than 1,000 urls returns 400 `Cannot delete more than 1,000 urls at once`, and any non-string entry returns 400 `Some urls are malformed`. Nothing is deleted in any of these cases. Point the SDK at the emulator with two environment variables: diff --git a/packages/@emulators/vercel/src/routes/blob/delete.ts b/packages/@emulators/vercel/src/routes/blob/delete.ts index 1ae14acb8..fa4f849a7 100644 --- a/packages/@emulators/vercel/src/routes/blob/delete.ts +++ b/packages/@emulators/vercel/src/routes/blob/delete.ts @@ -20,7 +20,7 @@ export function registerDelete({ app, vs, parsedBaseUrl }: BlobRouteContext): vo return blobErr(c, 400, "bad_request", "Cannot delete more than 1,000 urls at once or body is malformed"); } const urls = typeof body === "object" && body !== null ? (body as { urls?: unknown }).urls : undefined; - if (urls === undefined || (Array.isArray(urls) && urls.length === 0)) { + if (!urls || (Array.isArray(urls) && urls.length === 0)) { return blobErr(c, 400, "bad_request", "Missing urls in request body"); } if (!Array.isArray(urls)) { diff --git a/skills/vercel/SKILL.md b/skills/vercel/SKILL.md index 341459e0f..6227ba6c7 100644 --- a/skills/vercel/SKILL.md +++ b/skills/vercel/SKILL.md @@ -415,7 +415,7 @@ Upload and deletion rules: - A single leading slash is removed from the pathname before validation, so `/docs/readme.txt` is stored as `docs/readme.txt`. This also applies to copy destinations and multipart uploads. - The content type is taken from `x-content-type`, then the pathname extension when it is a known type, then the request `Content-Type` header, then `application/octet-stream`. - `x-cache-control-max-age` has a minimum of 60 seconds (`0` is stored as `public, max-age=60`). Absent or invalid values default to 30 days. -- Deleting with a missing or empty `urls` array returns 400 `Missing urls in request body`. Malformed JSON or a `urls` value that is not an array returns 400 `Cannot delete more than 1,000 urls at once or body is malformed`, more than 1,000 urls returns 400 `Cannot delete more than 1,000 urls at once`, and any non-string entry returns 400 `Some urls are malformed`. Nothing is deleted in any of these cases. +- Deleting with a missing or empty `urls` array returns 400 `Missing urls in request body`. Malformed JSON returns 400 `Cannot delete more than 1,000 urls at once or body is malformed`, more than 1,000 urls returns 400 `Cannot delete more than 1,000 urls at once`, and any non-string entry returns 400 `Some urls are malformed`. Nothing is deleted in any of these cases. Multipart uploads can be created, but uploading parts and completing the upload are not supported yet and return 400. Client (browser) uploads are not supported yet. From 07745d26cf64ea3ec87a7c6feae77c267cbaf73f Mon Sep 17 00:00:00 2001 From: "vercel[bot]" <35613825+vercel[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:46:40 +0000 Subject: [PATCH 6/7] fix(vercel): reject // before stripping Blob slash Co-Authored-By: Scott Trinh <1682194+scotttrinh@users.noreply.github.com> --- README.md | 2 +- apps/web/content/docs/vercel.mdx | 2 +- packages/@emulators/vercel/src/routes/blob/upload.ts | 10 ++++++---- skills/vercel/SKILL.md | 2 +- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index bd407c76e..312a60b59 100644 --- a/README.md +++ b/README.md @@ -822,7 +822,7 @@ Implements the Vercel Blob API used by the `@vercel/blob` SDK (`put`, `head`, `l Upload and deletion rules: -- A single leading slash is removed from the pathname before validation, so `/docs/readme.txt` is stored as `docs/readme.txt`. This also applies to copy destinations and multipart uploads. +- A pathname containing `//` is rejected with 400. Otherwise a single leading slash is removed, so `/docs/readme.txt` is stored as `docs/readme.txt`. This also applies to copy destinations and multipart uploads. - The content type is taken from `x-content-type`, then the pathname extension when it is a known type, then the request `Content-Type` header, then `application/octet-stream`. - `x-cache-control-max-age` has a minimum of 60 seconds (`0` is stored as `public, max-age=60`). Absent or invalid values default to 30 days. - Deleting with a missing or empty `urls` array returns 400 `Missing urls in request body`. Malformed JSON returns 400 `Cannot delete more than 1,000 urls at once or body is malformed`, more than 1,000 urls returns 400 `Cannot delete more than 1,000 urls at once`, and any non-string entry returns 400 `Some urls are malformed`. Nothing is deleted in any of these cases. diff --git a/apps/web/content/docs/vercel.mdx b/apps/web/content/docs/vercel.mdx index b813c21c2..9ad82d731 100644 --- a/apps/web/content/docs/vercel.mdx +++ b/apps/web/content/docs/vercel.mdx @@ -70,7 +70,7 @@ Implements the Vercel Blob API used by the `@vercel/blob` SDK (`put`, `head`, `l Upload and deletion rules: -- A single leading slash is removed from the pathname before validation, so `/docs/readme.txt` is stored as `docs/readme.txt`. This also applies to copy destinations and multipart uploads. +- A pathname containing `//` is rejected with 400. Otherwise a single leading slash is removed, so `/docs/readme.txt` is stored as `docs/readme.txt`. This also applies to copy destinations and multipart uploads. - The content type is taken from `x-content-type`, then the pathname extension when it is a known type, then the request `Content-Type` header, then `application/octet-stream`. - `x-cache-control-max-age` has a minimum of 60 seconds (`0` is stored as `public, max-age=60`). Absent or invalid values default to 30 days. - Deleting with a missing or empty `urls` array returns 400 `Missing urls in request body`. Malformed JSON returns 400 `Cannot delete more than 1,000 urls at once or body is malformed`, more than 1,000 urls returns 400 `Cannot delete more than 1,000 urls at once`, and any non-string entry returns 400 `Some urls are malformed`. Nothing is deleted in any of these cases. diff --git a/packages/@emulators/vercel/src/routes/blob/upload.ts b/packages/@emulators/vercel/src/routes/blob/upload.ts index dfc794f33..902112cce 100644 --- a/packages/@emulators/vercel/src/routes/blob/upload.ts +++ b/packages/@emulators/vercel/src/routes/blob/upload.ts @@ -15,13 +15,15 @@ export function resolveUploadTarget(c: Context): UploadTarget | Response { const storeId = resolveStoreId(c); if (!storeId) return forbidden(c); - const rawPathname = c.req.query("pathname")?.replace(/^\//, ""); + // Check // before stripping the leading slash so "//a.txt" is still rejected. + const queryPathname = c.req.query("pathname"); + if (queryPathname?.includes("//")) { + return blobErr(c, 400, "bad_request", "pathname cannot contain //"); + } + const rawPathname = queryPathname?.replace(/^\//, ""); if (!rawPathname) { return blobErr(c, 400, "bad_request", "pathname is required"); } - if (rawPathname.includes("//")) { - return blobErr(c, 400, "bad_request", "pathname cannot contain //"); - } const access = c.req.header("x-vercel-blob-access") ?? "public"; if (access !== "public") { diff --git a/skills/vercel/SKILL.md b/skills/vercel/SKILL.md index 6227ba6c7..f3bb008a9 100644 --- a/skills/vercel/SKILL.md +++ b/skills/vercel/SKILL.md @@ -412,7 +412,7 @@ curl http://localhost:4000/blob/mystore/docs/readme.txt Upload and deletion rules: -- A single leading slash is removed from the pathname before validation, so `/docs/readme.txt` is stored as `docs/readme.txt`. This also applies to copy destinations and multipart uploads. +- A pathname containing `//` is rejected with 400. Otherwise a single leading slash is removed, so `/docs/readme.txt` is stored as `docs/readme.txt`. This also applies to copy destinations and multipart uploads. - The content type is taken from `x-content-type`, then the pathname extension when it is a known type, then the request `Content-Type` header, then `application/octet-stream`. - `x-cache-control-max-age` has a minimum of 60 seconds (`0` is stored as `public, max-age=60`). Absent or invalid values default to 30 days. - Deleting with a missing or empty `urls` array returns 400 `Missing urls in request body`. Malformed JSON returns 400 `Cannot delete more than 1,000 urls at once or body is malformed`, more than 1,000 urls returns 400 `Cannot delete more than 1,000 urls at once`, and any non-string entry returns 400 `Some urls are malformed`. Nothing is deleted in any of these cases. From 7def739a75badd6cb64726e5e0b8eda954f4677c Mon Sep 17 00:00:00 2001 From: "vercel[bot]" <35613825+vercel[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:50:24 +0000 Subject: [PATCH 7/7] fix(vercel): accept // in Blob pathnames like the service Co-Authored-By: Scott Trinh <1682194+scotttrinh@users.noreply.github.com> --- README.md | 2 +- apps/web/content/docs/vercel.mdx | 2 +- packages/@emulators/vercel/src/routes/blob/upload.ts | 9 +++------ skills/vercel/SKILL.md | 2 +- 4 files changed, 6 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 312a60b59..b3ae6d34d 100644 --- a/README.md +++ b/README.md @@ -822,7 +822,7 @@ Implements the Vercel Blob API used by the `@vercel/blob` SDK (`put`, `head`, `l Upload and deletion rules: -- A pathname containing `//` is rejected with 400. Otherwise a single leading slash is removed, so `/docs/readme.txt` is stored as `docs/readme.txt`. This also applies to copy destinations and multipart uploads. +- A single leading slash is removed from the pathname, so `/docs/readme.txt` is stored as `docs/readme.txt`. As in the real service, `//` is otherwise accepted over HTTP, so `//a.txt` is stored as `/a.txt` (the `@vercel/blob` SDK rejects `//` before sending a request). This also applies to copy destinations and multipart uploads. - The content type is taken from `x-content-type`, then the pathname extension when it is a known type, then the request `Content-Type` header, then `application/octet-stream`. - `x-cache-control-max-age` has a minimum of 60 seconds (`0` is stored as `public, max-age=60`). Absent or invalid values default to 30 days. - Deleting with a missing or empty `urls` array returns 400 `Missing urls in request body`. Malformed JSON returns 400 `Cannot delete more than 1,000 urls at once or body is malformed`, more than 1,000 urls returns 400 `Cannot delete more than 1,000 urls at once`, and any non-string entry returns 400 `Some urls are malformed`. Nothing is deleted in any of these cases. diff --git a/apps/web/content/docs/vercel.mdx b/apps/web/content/docs/vercel.mdx index 9ad82d731..403d35886 100644 --- a/apps/web/content/docs/vercel.mdx +++ b/apps/web/content/docs/vercel.mdx @@ -70,7 +70,7 @@ Implements the Vercel Blob API used by the `@vercel/blob` SDK (`put`, `head`, `l Upload and deletion rules: -- A pathname containing `//` is rejected with 400. Otherwise a single leading slash is removed, so `/docs/readme.txt` is stored as `docs/readme.txt`. This also applies to copy destinations and multipart uploads. +- A single leading slash is removed from the pathname, so `/docs/readme.txt` is stored as `docs/readme.txt`. As in the real service, `//` is otherwise accepted over HTTP, so `//a.txt` is stored as `/a.txt` (the `@vercel/blob` SDK rejects `//` before sending a request). This also applies to copy destinations and multipart uploads. - The content type is taken from `x-content-type`, then the pathname extension when it is a known type, then the request `Content-Type` header, then `application/octet-stream`. - `x-cache-control-max-age` has a minimum of 60 seconds (`0` is stored as `public, max-age=60`). Absent or invalid values default to 30 days. - Deleting with a missing or empty `urls` array returns 400 `Missing urls in request body`. Malformed JSON returns 400 `Cannot delete more than 1,000 urls at once or body is malformed`, more than 1,000 urls returns 400 `Cannot delete more than 1,000 urls at once`, and any non-string entry returns 400 `Some urls are malformed`. Nothing is deleted in any of these cases. diff --git a/packages/@emulators/vercel/src/routes/blob/upload.ts b/packages/@emulators/vercel/src/routes/blob/upload.ts index 902112cce..49cbaebac 100644 --- a/packages/@emulators/vercel/src/routes/blob/upload.ts +++ b/packages/@emulators/vercel/src/routes/blob/upload.ts @@ -15,12 +15,9 @@ export function resolveUploadTarget(c: Context): UploadTarget | Response { const storeId = resolveStoreId(c); if (!storeId) return forbidden(c); - // Check // before stripping the leading slash so "//a.txt" is still rejected. - const queryPathname = c.req.query("pathname"); - if (queryPathname?.includes("//")) { - return blobErr(c, 400, "bad_request", "pathname cannot contain //"); - } - const rawPathname = queryPathname?.replace(/^\//, ""); + // Like the real service, strip exactly one leading slash and accept "//" + // elsewhere; only the SDK rejects "//" before sending the request. + const rawPathname = c.req.query("pathname")?.replace(/^\//, ""); if (!rawPathname) { return blobErr(c, 400, "bad_request", "pathname is required"); } diff --git a/skills/vercel/SKILL.md b/skills/vercel/SKILL.md index f3bb008a9..f0ced5b5f 100644 --- a/skills/vercel/SKILL.md +++ b/skills/vercel/SKILL.md @@ -412,7 +412,7 @@ curl http://localhost:4000/blob/mystore/docs/readme.txt Upload and deletion rules: -- A pathname containing `//` is rejected with 400. Otherwise a single leading slash is removed, so `/docs/readme.txt` is stored as `docs/readme.txt`. This also applies to copy destinations and multipart uploads. +- A single leading slash is removed from the pathname, so `/docs/readme.txt` is stored as `docs/readme.txt`. As in the real service, `//` is otherwise accepted over HTTP, so `//a.txt` is stored as `/a.txt` (the `@vercel/blob` SDK rejects `//` before sending a request). This also applies to copy destinations and multipart uploads. - The content type is taken from `x-content-type`, then the pathname extension when it is a known type, then the request `Content-Type` header, then `application/octet-stream`. - `x-cache-control-max-age` has a minimum of 60 seconds (`0` is stored as `public, max-age=60`). Absent or invalid values default to 30 days. - Deleting with a missing or empty `urls` array returns 400 `Missing urls in request body`. Malformed JSON returns 400 `Cannot delete more than 1,000 urls at once or body is malformed`, more than 1,000 urls returns 400 `Cannot delete more than 1,000 urls at once`, and any non-string entry returns 400 `Some urls are malformed`. Nothing is deleted in any of these cases.