From e963c2a24072b8b6dd59faf2daa329173c78c97e Mon Sep 17 00:00:00 2001 From: Karson Date: Sun, 5 Jul 2026 08:48:46 -0500 Subject: [PATCH] feat(blob): allow client uploads to set `access` `handleUpload`'s `onBeforeGenerateToken` callback could not return `access`, so every client-uploaded blob was forced to public even after private Blob shipped (#816). Add optional `access` to the client-token options and to the `onBeforeGenerateToken` return `Pick`; it is serialized into the signed client token by the existing generic token path, so no runtime-logic change is needed. When omitted, behavior is unchanged (public). Fixes #1079 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014yndSRPZ2WRRFJajNWX1F1 --- .changeset/blob-client-upload-access.md | 5 +++ packages/blob/src/client.node.test.ts | 56 +++++++++++++++++++++++++ packages/blob/src/client.ts | 11 +++++ 3 files changed, 72 insertions(+) create mode 100644 .changeset/blob-client-upload-access.md diff --git a/.changeset/blob-client-upload-access.md b/.changeset/blob-client-upload-access.md new file mode 100644 index 000000000..5b8485942 --- /dev/null +++ b/.changeset/blob-client-upload-access.md @@ -0,0 +1,5 @@ +--- +'@vercel/blob': minor +--- + +Allow client uploads to set `access`. `handleUpload`'s `onBeforeGenerateToken` callback can now return `access: 'public' | 'private'`, and it is baked into the signed client token — bringing client uploads to parity with the server-side `put({ access })` API. When omitted, behavior is unchanged (public). Fixes #1079. diff --git a/packages/blob/src/client.node.test.ts b/packages/blob/src/client.node.test.ts index 80dbb164c..fa83f6688 100644 --- a/packages/blob/src/client.node.test.ts +++ b/packages/blob/src/client.node.test.ts @@ -70,6 +70,31 @@ describe('client uploads', () => { }); }); + it('bakes `access` into the client token payload', async () => { + const uploadToken = await generateClientTokenFromReadWriteToken({ + pathname: 'foo.txt', + access: 'private', + token: 'vercel_blob_rw_12345fakeStoreId_30FakeRandomCharacters12345678', + }); + + expect(getPayloadFromClientToken(uploadToken)).toEqual({ + pathname: 'foo.txt', + access: 'private', + validUntil: 1672531230000, + }); + }); + + it('omits `access` from the payload when not provided (unchanged default)', async () => { + const uploadToken = await generateClientTokenFromReadWriteToken({ + pathname: 'foo.txt', + token: 'vercel_blob_rw_12345fakeStoreId_30FakeRandomCharacters12345678', + }); + + expect(getPayloadFromClientToken(uploadToken)).not.toHaveProperty( + 'access', + ); + }); + it('accepts a tokenPayload property', async () => { const uploadToken = await generateClientTokenFromReadWriteToken({ pathname: 'foo.txt', @@ -153,6 +178,37 @@ describe('client uploads', () => { }); }); + it('threads `access` from onBeforeGenerateToken into the client token', async () => { + const token = + 'vercel_blob_rw_12345fakeStoreId_30FakeRandomCharacters12345678'; + const jsonResponse = await handleUpload({ + token, + request: { + headers: { 'x-vercel-signature': '123' }, + } as unknown as IncomingMessage, + body: { + type: 'blob.generate-client-token', + payload: { + pathname: 'newfile.txt', + multipart: false, + clientPayload: null, + }, + }, + onBeforeGenerateToken: async () => { + await Promise.resolve(); + return { access: 'private' }; + }, + }); + + expect( + getPayloadFromClientToken((jsonResponse as any).clientToken), + ).toEqual({ + access: 'private', + pathname: 'newfile.txt', + validUntil: 1672534800000, + }); + }); + it('should run onCompleted when called with blob.upload-completed', async () => { const token = 'vercel_blob_client_123456789_TEST_TOKEN'; const spy = jest.fn(); diff --git a/packages/blob/src/client.ts b/packages/blob/src/client.ts index b84d8d096..7936a72f9 100644 --- a/packages/blob/src/client.ts +++ b/packages/blob/src/client.ts @@ -710,6 +710,7 @@ export interface HandleUploadOptions { ) => Promise< Pick< GenerateClientTokenOptions, + | 'access' | 'allowedContentTypes' | 'maximumSizeInBytes' | 'validUntil' @@ -1231,6 +1232,16 @@ export interface GenerateClientTokenOptions * The destination path for the blob */ pathname: string; + /** + * The access level the client upload should be created with. + * - 'public': The blob will be publicly accessible via its URL. + * - 'private': The blob will require authentication to access. + * + * Baked into the signed client token so `handleUpload` / `onBeforeGenerateToken` + * can mint private client uploads, mirroring the server-side `put({ access })`. + * When omitted, existing behavior is unchanged (public). + */ + access?: BlobAccessType; } /**