From e0eea7d1871bb9577c7da109d3cea15b7e11731d Mon Sep 17 00:00:00 2001 From: iammatthias Date: Mon, 2 Feb 2026 12:14:18 -0800 Subject: [PATCH] Add support for `expires_at` to upload and update for public and private files --- src/core/functions/files/updateFile.ts | 8 ++- src/core/functions/uploads/file.ts | 8 +++ src/core/types/files.ts | 4 ++ src/core/types/uploads.ts | 4 ++ tests/files/updateFile.test.ts | 93 +++++++++++++++++++++++++ tests/uploads/file.test.ts | 94 ++++++++++++++++++++++++++ 6 files changed, 209 insertions(+), 2 deletions(-) diff --git a/src/core/functions/files/updateFile.ts b/src/core/functions/files/updateFile.ts index 5763cad..5214687 100644 --- a/src/core/functions/files/updateFile.ts +++ b/src/core/functions/files/updateFile.ts @@ -21,10 +21,11 @@ export const updateFile = async ( if ( !options.name && - (!options.keyvalues || Object.keys(options.keyvalues).length === 0) + (!options.keyvalues || Object.keys(options.keyvalues).length === 0) && + options.expires_at === undefined ) { throw new ValidationError( - "At least one of 'name' or 'keyvalues' must be provided", + "At least one of 'name', 'keyvalues', or 'expires_at' must be provided", ); } @@ -36,6 +37,9 @@ export const updateFile = async ( if (options.keyvalues && Object.keys(options.keyvalues).length > 0) { data.keyvalues = options.keyvalues; } + if (options.expires_at !== undefined) { + data.expires_at = options.expires_at; + } const body = JSON.stringify(data); diff --git a/src/core/functions/uploads/file.ts b/src/core/functions/uploads/file.ts index e7fb40d..b5a90dc 100644 --- a/src/core/functions/uploads/file.ts +++ b/src/core/functions/uploads/file.ts @@ -63,6 +63,10 @@ export const uploadFile = async ( metadata += `,cid_version ${btoa(options.cid_version)}`; } + if (options?.expires_at !== undefined) { + metadata += `,expires_at ${btoa(options.expires_at.toString())}`; + } + // Build URL with query parameters for chunked uploads let updatedEndpoint: string = `${endpoint}/files`; if (options?.url) { @@ -264,6 +268,10 @@ export const uploadFile = async ( data.append("cid_version", options.cid_version.toString()); } + if (options?.expires_at !== undefined) { + data.append("expires_at", options.expires_at.toString()); + } + if (options?.url) { try { const url = new URL(options.url); diff --git a/src/core/types/files.ts b/src/core/types/files.ts index e4a81e6..427365a 100644 --- a/src/core/types/files.ts +++ b/src/core/types/files.ts @@ -17,6 +17,10 @@ export type UpdateFileOptions = { id: string; name?: string; keyvalues?: Record; + /** + * Unix timestamp (in seconds) when the file should expire (must be in the future) + */ + expires_at?: number; }; export type DeleteResponse = { diff --git a/src/core/types/uploads.ts b/src/core/types/uploads.ts index 100159d..7869e4d 100644 --- a/src/core/types/uploads.ts +++ b/src/core/types/uploads.ts @@ -32,6 +32,10 @@ export type UploadOptions = { * CID version "v1" or "v0" (defaults to v1 if falsy) */ cid_version?: CidVersion; + /** + * Unix timestamp (in seconds) when the file should expire (must be in the future) + */ + expires_at?: number; }; export type SignedUploadUrlOptions = { diff --git a/tests/files/updateFile.test.ts b/tests/files/updateFile.test.ts index f5878d2..15f494b 100644 --- a/tests/files/updateFile.test.ts +++ b/tests/files/updateFile.test.ts @@ -249,4 +249,97 @@ describe("updateFile function", () => { expect.any(Object), ); }); + + it("should update file with only expires_at", async () => { + const futureTimestamp = Math.floor(Date.now() / 1000) + 86400; // 24 hours from now + const expiresAtOptions: UpdateFileOptions = { + id: "testId", + expires_at: futureTimestamp, + }; + + const mockResponse: FileListItem = { + id: "testId", + name: "Test File", + cid: "QmTest...", + size: 1000, + number_of_files: 1, + mime_type: "text/plain", + group_id: "groupId", + created_at: "2023-01-01T00:00:00Z", + keyvalues: {}, + }; + + global.fetch = jest.fn().mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ data: mockResponse }), + }); + + const result = await updateFile(mockConfig, expiresAtOptions, "public"); + + expect(global.fetch).toHaveBeenCalledWith( + "https://api.pinata.cloud/v3/files/public/testId", + expect.objectContaining({ + method: "PUT", + headers: expect.objectContaining({ + Authorization: `Bearer ${mockConfig.pinataJwt}`, + }), + body: JSON.stringify({ + expires_at: futureTimestamp, + }), + }), + ); + expect(result).toEqual(mockResponse); + }); + + it("should update file with expires_at combined with name and keyvalues", async () => { + const futureTimestamp = Math.floor(Date.now() / 1000) + 86400; + const combinedOptions: UpdateFileOptions = { + id: "testId", + name: "Updated Name", + keyvalues: { key: "value" }, + expires_at: futureTimestamp, + }; + + const mockResponse: FileListItem = { + id: "testId", + name: "Updated Name", + cid: "QmTest...", + size: 1000, + number_of_files: 1, + mime_type: "text/plain", + group_id: "groupId", + created_at: "2023-01-01T00:00:00Z", + keyvalues: { key: "value" }, + }; + + global.fetch = jest.fn().mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ data: mockResponse }), + }); + + const result = await updateFile(mockConfig, combinedOptions, "private"); + + expect(global.fetch).toHaveBeenCalledWith( + "https://api.pinata.cloud/v3/files/private/testId", + expect.objectContaining({ + method: "PUT", + body: JSON.stringify({ + name: "Updated Name", + keyvalues: { key: "value" }, + expires_at: futureTimestamp, + }), + }), + ); + expect(result).toEqual(mockResponse); + }); + + it("should throw ValidationError if no name, keyvalues, or expires_at provided", async () => { + const invalidOptions: UpdateFileOptions = { + id: "testId", + }; + + await expect( + updateFile(mockConfig, invalidOptions, "public"), + ).rejects.toThrow(ValidationError); + }); }); diff --git a/tests/uploads/file.test.ts b/tests/uploads/file.test.ts index b69b0a3..cbd7ab8 100644 --- a/tests/uploads/file.test.ts +++ b/tests/uploads/file.test.ts @@ -341,6 +341,63 @@ describe("uploadFile function", () => { expect(formData.get("streamable")).toBe("true"); }); + it("should upload file with expires_at timestamp", async () => { + global.fetch = jest.fn().mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ data: mockResponse }), + }); + + const futureTimestamp = Math.floor(Date.now() / 1000) + 86400; // 24 hours from now + const mockOptions: UploadOptions = { + expires_at: futureTimestamp, + }; + + await uploadFile(mockConfig, mockFile, "public", mockOptions); + + const fetchCall = (global.fetch as jest.Mock).mock.calls[0]; + const formData = fetchCall[1].body as FormData; + + expect(formData.get("expires_at")).toBe(futureTimestamp.toString()); + expect(global.fetch).toHaveBeenCalledWith( + "https://uploads.pinata.cloud/v3/files", + expect.objectContaining({ + method: "POST", + headers: { + Source: "sdk/file", + Authorization: "Bearer test-jwt", + }, + body: expect.any(FormData), + }), + ); + }); + + it("should upload file with expires_at combined with other options", async () => { + global.fetch = jest.fn().mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ data: mockResponse }), + }); + + const futureTimestamp = Math.floor(Date.now() / 1000) + 86400; + const mockOptions: UploadOptions = { + expires_at: futureTimestamp, + groupId: "test-group", + metadata: { + name: "Test File", + keyvalues: { key: "value" }, + }, + }; + + await uploadFile(mockConfig, mockFile, "private", mockOptions); + + const fetchCall = (global.fetch as jest.Mock).mock.calls[0]; + const formData = fetchCall[1].body as FormData; + + expect(formData.get("expires_at")).toBe(futureTimestamp.toString()); + expect(formData.get("group_id")).toBe("test-group"); + expect(formData.get("name")).toBe("Test File"); + expect(formData.get("keyvalues")).toBe(JSON.stringify({ key: "value" })); + }); + it("should include CID version in metadata for large file upload", async () => { // Create a large file (over 90MB) to trigger chunked upload metadata path const largeFileSize = 94371841; // Just over the threshold @@ -378,4 +435,41 @@ describe("uploadFile function", () => { expect(uploadMetadata).toContain("cid_version"); expect(uploadMetadata).toContain(btoa("v1")); }); + + it("should include expires_at in metadata for large file upload", async () => { + // Create a large file (over 90MB) to trigger chunked upload metadata path + const largeFileSize = 94371841; // Just over the threshold + const largeFile = new File( + [new ArrayBuffer(largeFileSize)], + "large-test.txt", + { + type: "text/plain", + }, + ); + + // Mock the first call (initial upload request) to verify metadata + global.fetch = jest.fn().mockImplementationOnce(() => { + return Promise.reject(new Error("Test stopped after metadata check")); + }); + + const futureTimestamp = Math.floor(Date.now() / 1000) + 86400; + const mockOptions: UploadOptions = { + expires_at: futureTimestamp, + }; + + try { + await uploadFile(mockConfig, largeFile, "private", mockOptions); + } catch (error) { + // Expected to fail, we just want to check the metadata + } + + // Check that the initial request contains expires_at in Upload-Metadata + expect(global.fetch).toHaveBeenCalledTimes(1); + const initialCall = (global.fetch as jest.Mock).mock.calls[0]; + const uploadMetadata = initialCall[1].headers["Upload-Metadata"]; + + // The metadata should contain base64 encoded expires_at + expect(uploadMetadata).toContain("expires_at"); + expect(uploadMetadata).toContain(btoa(futureTimestamp.toString())); + }); });