diff --git a/.changeset/storage-signed-endpoint.md b/.changeset/storage-signed-endpoint.md new file mode 100644 index 00000000..746b0fb9 --- /dev/null +++ b/.changeset/storage-signed-endpoint.md @@ -0,0 +1,7 @@ +--- +'@transloadit/node': minor +'@transloadit/mcp-server': patch +'transloadit': minor +--- + +Storage requests (`getStoredAsset`, `listStoredAssets`, `moveStoredAsset` and `deleteStoredAsset`) now sign their HTTP method and URL path into `params.auth` as `method` and `path`, as the Transloadit API requires for Storage requests. diff --git a/packages/node/src/Transloadit.ts b/packages/node/src/Transloadit.ts index f1d97749..8b9ae86f 100644 --- a/packages/node/src/Transloadit.ts +++ b/packages/node/src/Transloadit.ts @@ -605,6 +605,7 @@ export class Transloadit { const result = await this._remoteJson({ urlSuffix: `/dam/assets/${id}`, method: 'get', + signEndpoint: true, params, signal: options.signal, }) @@ -629,6 +630,7 @@ export class Transloadit { const result = await this._remoteJson({ urlSuffix: '/dam/assets', method: 'get', + signEndpoint: true, params, signal: options.signal, }) @@ -653,6 +655,7 @@ export class Transloadit { const result = await this._remoteJson({ urlSuffix: `/dam/assets/${id}`, method: 'patch', + signEndpoint: true, params, signal: options.signal, }) @@ -676,6 +679,7 @@ export class Transloadit { const result = await this._remoteJson({ urlSuffix: `/dam/assets/${id}`, method: 'delete', + signEndpoint: true, signal: options.signal, }) checkResult(result) @@ -1712,6 +1716,8 @@ export class Transloadit { timeout?: Delays method?: 'delete' | 'get' | 'patch' | 'post' | 'put' params?: TParams + /** Sign the request's method and path into `params.auth`, as Storage requests require. */ + signEndpoint?: boolean fields?: Fields headers?: Headers signal?: AbortSignal @@ -1722,7 +1728,8 @@ export class Transloadit { isTrustedUrl = false, timeout = { request: this._defaultTimeout }, method = 'get', - params = {}, + params: paramsInput = {}, + signEndpoint = false, fields, headers, signal, @@ -1739,6 +1746,14 @@ export class Transloadit { } } + const requested: OptionalAuthParams = paramsInput + const params: OptionalAuthParams = signEndpoint + ? { + ...requested, + auth: { ...requested.auth, method: method.toUpperCase(), path: new URL(url).pathname }, + } + : requested + if (method === 'get') { url = this._appendParamsToUrl(url, params) } diff --git a/packages/node/src/apiTypes.ts b/packages/node/src/apiTypes.ts index c57fbc6e..2fdf9653 100644 --- a/packages/node/src/apiTypes.ts +++ b/packages/node/src/apiTypes.ts @@ -10,7 +10,14 @@ export { export { assemblyInstructionsSchema } from './alphalib/types/template.ts' export interface OptionalAuthParams { - auth?: { key?: string; expires?: string } + auth?: { + key?: string + expires?: string + /** HTTP method of the request these params are signed for, in upper case. */ + method?: string + /** URL path of the request these params are signed for, without the query string. */ + path?: string + } } // todo make zod schemas for these types in the backend for these too (in alphalib?) diff --git a/packages/node/test/unit/storageAssets.test.ts b/packages/node/test/unit/storageAssets.test.ts index c407a262..a392b0c0 100644 --- a/packages/node/test/unit/storageAssets.test.ts +++ b/packages/node/test/unit/storageAssets.test.ts @@ -176,6 +176,85 @@ test('soft-deletes by identity and returns the deletion receipt', async () => { }) }) +/** The signed `auth` of a multipart Storage mutation. */ +function formAuth(body: unknown): unknown { + const params = /name="params"\r\n\r\n([^\r\n]+)/.exec(String(body))?.[1] + return params === undefined ? undefined : JSON.parse(params).auth +} + +test('signs every Storage request for its own method and path', async () => { + const signedFor: unknown[] = [] + const queryAuth = (query: Record) => { + signedFor.push(JSON.parse(String(query.params)).auth) + return true + } + const found = { ok: 'DAM_ASSET_FOUND', message: 'Found', asset } + nock(origin).get(`/dam/assets/${asset.asset_id}`).query(queryAuth).reply(200, found) + nock(origin).get('/dam/assets').query(queryAuth).reply(200, { + ok: 'DAM_ASSETS_LISTED', + message: 'Listed', + workspace: asset.workspace, + assets: [], + next_cursor: null, + }) + nock(origin) + .patch(`/dam/assets/${asset.asset_id}`, (body) => signedFor.push(formAuth(body)) > 0) + .reply(200, { ok: 'DAM_ASSET_MOVED', message: 'Moved', asset }) + nock(origin) + .delete(`/dam/assets/${asset.asset_id}`, (body) => signedFor.push(formAuth(body)) > 0) + .reply(200, { + ok: 'DAM_ASSET_DELETED', + message: 'Deleted', + asset_id: asset.asset_id, + deleted_at: '2026-09-17T12:00:00.000Z', + }) + + await client.getStoredAsset(asset.asset_id) + await client.listStoredAssets({ prefix: 'photos/' }) + await client.moveStoredAsset(asset.asset_id, { filename: 'renamed.jpg' }) + await client.deleteStoredAsset(asset.asset_id) + + const assetPath = `/dam/assets/${asset.asset_id}` + expect(signedFor).toEqual([ + expect.objectContaining({ key: 'key', method: 'GET', path: assetPath }), + expect.objectContaining({ key: 'key', method: 'GET', path: '/dam/assets' }), + expect.objectContaining({ key: 'key', method: 'PATCH', path: assetPath }), + expect.objectContaining({ key: 'key', method: 'DELETE', path: assetPath }), + ]) +}) + +test('signs the path the API receives when the endpoint has a base path', async () => { + const proxied = new Transloadit({ + authKey: 'key', + authSecret: 'secret', + endpoint: `${origin}/transloadit`, + }) + const request = nock(origin) + .get('/transloadit/dam/assets') + .query((query) => JSON.parse(String(query.params)).auth.path === '/transloadit/dam/assets') + .reply(200, { + ok: 'DAM_ASSETS_LISTED', + message: 'Listed', + workspace: asset.workspace, + assets: [], + next_cursor: null, + }) + await proxied.listStoredAssets() + expect(request.isDone()).toBe(true) +}) + +test('leaves the method and path out of other signed requests', async () => { + const request = nock(origin) + .get('/bill/2026-11') + .query((query) => { + const { auth } = JSON.parse(String(query.params)) + return auth.key === 'key' && !('method' in auth) && !('path' in auth) + }) + .reply(200, { ok: 'BILL_FOUND' }) + await client.getBill('2026-11') + expect(request.isDone()).toBe(true) +}) + test('rejects an empty move and identifiers before sending a mutation', async () => { await expect(client.moveStoredAsset(asset.asset_id, {})).rejects.toThrow() await expect(client.moveStoredAsset('../escape', { filename: 'photo.jpg' })).rejects.toThrow()