mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
feat(core): Add support for signed URLs for binary data (#14492)
Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <aditya@netroy.in>
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
import { BinaryDataQueryDto } from '../binary-data-query.dto';
|
||||
|
||||
describe('BinaryDataQueryDto', () => {
|
||||
describe('Valid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'filesystem mode with view action',
|
||||
request: {
|
||||
id: 'filesystem:some-id',
|
||||
action: 'view',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'filesystem-v2 mode with download action',
|
||||
request: {
|
||||
id: 'filesystem-v2:some-id',
|
||||
action: 'download',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 's3 mode with view action and optional fields',
|
||||
request: {
|
||||
id: 's3:some-id',
|
||||
action: 'view',
|
||||
fileName: 'test.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
},
|
||||
},
|
||||
])('should validate $name', ({ request }) => {
|
||||
const result = BinaryDataQueryDto.safeParse(request);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'missing mode separator',
|
||||
request: {
|
||||
id: 'filesystemsome-id',
|
||||
action: 'view',
|
||||
},
|
||||
expectedErrorPath: ['id'],
|
||||
},
|
||||
{
|
||||
name: 'invalid mode',
|
||||
request: {
|
||||
id: 'invalid:some-id',
|
||||
action: 'view',
|
||||
},
|
||||
expectedErrorPath: ['id'],
|
||||
},
|
||||
{
|
||||
name: 'invalid action',
|
||||
request: {
|
||||
id: 'filesystem:some-id',
|
||||
action: 'invalid',
|
||||
},
|
||||
expectedErrorPath: ['action'],
|
||||
},
|
||||
{
|
||||
name: 'missing id',
|
||||
request: {
|
||||
action: 'view',
|
||||
},
|
||||
expectedErrorPath: ['id'],
|
||||
},
|
||||
{
|
||||
name: 'missing action',
|
||||
request: {
|
||||
id: 'filesystem:some-id',
|
||||
},
|
||||
expectedErrorPath: ['action'],
|
||||
},
|
||||
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
|
||||
const result = BinaryDataQueryDto.safeParse(request);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
|
||||
if (expectedErrorPath) {
|
||||
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { BinaryDataSignedQueryDto } from '../binary-data-signed-query.dto';
|
||||
|
||||
describe('BinaryDataSignedQueryDto', () => {
|
||||
describe('Valid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'valid JWT token',
|
||||
request: {
|
||||
token:
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c',
|
||||
},
|
||||
},
|
||||
])('should validate $name', ({ request }) => {
|
||||
const result = BinaryDataSignedQueryDto.safeParse(request);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'missing token',
|
||||
request: {},
|
||||
expectedErrorPath: ['token'],
|
||||
},
|
||||
{
|
||||
name: 'empty token',
|
||||
request: {
|
||||
token: '',
|
||||
},
|
||||
expectedErrorPath: ['token'],
|
||||
},
|
||||
{
|
||||
name: 'non-string token',
|
||||
request: {
|
||||
token: 123,
|
||||
},
|
||||
expectedErrorPath: ['token'],
|
||||
},
|
||||
{
|
||||
name: 'token without three segments',
|
||||
request: {
|
||||
token: 'header.payload',
|
||||
},
|
||||
expectedErrorPath: ['token'],
|
||||
},
|
||||
{
|
||||
name: 'token with invalid characters',
|
||||
request: {
|
||||
token: 'header.payload.sign@ture',
|
||||
},
|
||||
expectedErrorPath: ['token'],
|
||||
},
|
||||
{
|
||||
name: 'token with too many segments',
|
||||
request: {
|
||||
token: 'header.payload.signature.extra',
|
||||
},
|
||||
expectedErrorPath: ['token'],
|
||||
},
|
||||
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
|
||||
const result = BinaryDataSignedQueryDto.safeParse(request);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
|
||||
if (expectedErrorPath) {
|
||||
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { z } from 'zod';
|
||||
import { Z } from 'zod-class';
|
||||
|
||||
export class BinaryDataQueryDto extends Z.class({
|
||||
id: z
|
||||
.string()
|
||||
.refine((id) => id.includes(':'), {
|
||||
message: 'Missing binary data mode',
|
||||
})
|
||||
.refine(
|
||||
(id) => {
|
||||
const [mode] = id.split(':');
|
||||
return ['filesystem', 'filesystem-v2', 's3'].includes(mode);
|
||||
},
|
||||
{
|
||||
message: 'Invalid binary data mode',
|
||||
},
|
||||
),
|
||||
action: z.enum(['view', 'download']),
|
||||
fileName: z.string().optional(),
|
||||
mimeType: z.string().optional(),
|
||||
}) {}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { z } from 'zod';
|
||||
import { Z } from 'zod-class';
|
||||
|
||||
export class BinaryDataSignedQueryDto extends Z.class({
|
||||
token: z.string().regex(/^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/, {
|
||||
message: 'Token must be a valid JWT format',
|
||||
}),
|
||||
}) {}
|
||||
@@ -3,6 +3,9 @@ export { AiChatRequestDto } from './ai/ai-chat-request.dto';
|
||||
export { AiApplySuggestionRequestDto } from './ai/ai-apply-suggestion-request.dto';
|
||||
export { AiFreeCreditsRequestDto } from './ai/ai-free-credits-request.dto';
|
||||
|
||||
export { BinaryDataQueryDto } from './binary-data/binary-data-query.dto';
|
||||
export { BinaryDataSignedQueryDto } from './binary-data/binary-data-signed-query.dto';
|
||||
|
||||
export { LoginRequestDto } from './auth/login-request.dto';
|
||||
export { ResolveSignupTokenQueryDto } from './auth/resolve-signup-token-query.dto';
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
"@types/flat": "^5.0.5",
|
||||
"@types/formidable": "^3.4.5",
|
||||
"@types/json-diff": "^1.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.6",
|
||||
"@types/jsonwebtoken": "catalog:",
|
||||
"@types/lodash": "catalog:",
|
||||
"@types/psl": "^1.1.0",
|
||||
"@types/replacestream": "^4.0.1",
|
||||
@@ -132,7 +132,7 @@
|
||||
"isbot": "3.6.13",
|
||||
"json-diff": "1.0.6",
|
||||
"jsonschema": "1.4.1",
|
||||
"jsonwebtoken": "9.0.2",
|
||||
"jsonwebtoken": "catalog:",
|
||||
"ldapts": "4.2.6",
|
||||
"lodash": "catalog:",
|
||||
"luxon": "catalog:",
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import type { Response } from 'express';
|
||||
import type { BinaryDataQueryDto, BinaryDataSignedQueryDto } from '@n8n/api-types';
|
||||
import type { Request, Response } from 'express';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import { JsonWebTokenError } from 'jsonwebtoken';
|
||||
import type { BinaryDataService } from 'n8n-core';
|
||||
import { FileNotFoundError } from 'n8n-core';
|
||||
import type { Readable } from 'node:stream';
|
||||
|
||||
import type { BinaryDataRequest } from '@/requests';
|
||||
|
||||
import { BinaryDataController } from '../binary-data.controller';
|
||||
|
||||
describe('BinaryDataController', () => {
|
||||
const request = mock<BinaryDataRequest>();
|
||||
const request = mock<Request>();
|
||||
const response = mock<Response>();
|
||||
const binaryDataService = mock<BinaryDataService>();
|
||||
const controller = new BinaryDataController(binaryDataService);
|
||||
@@ -20,55 +20,45 @@ describe('BinaryDataController', () => {
|
||||
});
|
||||
|
||||
describe('get', () => {
|
||||
it('should return 400 if binary data ID is missing', async () => {
|
||||
// @ts-expect-error invalid query object
|
||||
request.query = {};
|
||||
|
||||
await controller.get(request, response);
|
||||
|
||||
expect(response.status).toHaveBeenCalledWith(400);
|
||||
expect(response.end).toHaveBeenCalledWith('Missing binary data ID');
|
||||
});
|
||||
|
||||
it('should return 400 if binary data mode is missing', async () => {
|
||||
request.query = { id: '123', action: 'view' };
|
||||
const query = { id: '123', action: 'view' } as BinaryDataQueryDto;
|
||||
|
||||
await controller.get(request, response);
|
||||
await controller.get(request, response, query);
|
||||
|
||||
expect(response.status).toHaveBeenCalledWith(400);
|
||||
expect(response.end).toHaveBeenCalledWith('Missing binary data mode');
|
||||
});
|
||||
|
||||
it('should return 400 if binary data mode is invalid', async () => {
|
||||
request.query = { id: 'invalidMode:123', action: 'view' };
|
||||
const query = { id: 'invalidMode:123', action: 'view' } as BinaryDataQueryDto;
|
||||
|
||||
await controller.get(request, response);
|
||||
await controller.get(request, response, query);
|
||||
|
||||
expect(response.status).toHaveBeenCalledWith(400);
|
||||
expect(response.end).toHaveBeenCalledWith('Invalid binary data mode');
|
||||
});
|
||||
|
||||
it('should return 404 if file is not found', async () => {
|
||||
request.query = { id: 'filesystem:123', action: 'view' };
|
||||
const query = { id: 'filesystem:123', action: 'view' } as BinaryDataQueryDto;
|
||||
binaryDataService.getAsStream.mockRejectedValue(new FileNotFoundError('File not found'));
|
||||
|
||||
await controller.get(request, response);
|
||||
await controller.get(request, response, query);
|
||||
|
||||
expect(response.status).toHaveBeenCalledWith(404);
|
||||
expect(response.end).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should set Content-Type and Content-Length from query if provided', async () => {
|
||||
request.query = {
|
||||
const query = {
|
||||
id: 'filesystem:123',
|
||||
action: 'view',
|
||||
fileName: 'test.txt',
|
||||
mimeType: 'text/plain',
|
||||
};
|
||||
} as BinaryDataQueryDto;
|
||||
|
||||
binaryDataService.getAsStream.mockResolvedValue(mock());
|
||||
|
||||
await controller.get(request, response);
|
||||
await controller.get(request, response, query);
|
||||
|
||||
expect(binaryDataService.getMetadata).not.toHaveBeenCalled();
|
||||
expect(response.setHeader).toHaveBeenCalledWith('Content-Type', 'text/plain');
|
||||
@@ -77,7 +67,7 @@ describe('BinaryDataController', () => {
|
||||
});
|
||||
|
||||
it('should set Content-Type and Content-Length from metadata if not provided', async () => {
|
||||
request.query = { id: 'filesystem:123', action: 'view' };
|
||||
const query = { id: 'filesystem:123', action: 'view' } as BinaryDataQueryDto;
|
||||
|
||||
binaryDataService.getMetadata.mockResolvedValue({
|
||||
fileName: 'test.txt',
|
||||
@@ -86,7 +76,7 @@ describe('BinaryDataController', () => {
|
||||
});
|
||||
binaryDataService.getAsStream.mockResolvedValue(mock());
|
||||
|
||||
await controller.get(request, response);
|
||||
await controller.get(request, response, query);
|
||||
|
||||
expect(binaryDataService.getMetadata).toHaveBeenCalledWith('filesystem:123');
|
||||
expect(response.setHeader).toHaveBeenCalledWith('Content-Type', 'text/plain');
|
||||
@@ -95,11 +85,15 @@ describe('BinaryDataController', () => {
|
||||
});
|
||||
|
||||
it('should set Content-Disposition for download action', async () => {
|
||||
request.query = { id: 'filesystem:123', action: 'download', fileName: 'test.txt' };
|
||||
const query = {
|
||||
id: 'filesystem:123',
|
||||
action: 'download',
|
||||
fileName: 'test.txt',
|
||||
} as BinaryDataQueryDto;
|
||||
|
||||
binaryDataService.getAsStream.mockResolvedValue(mock());
|
||||
|
||||
await controller.get(request, response);
|
||||
await controller.get(request, response, query);
|
||||
|
||||
expect(response.setHeader).toHaveBeenCalledWith(
|
||||
'Content-Disposition',
|
||||
@@ -108,42 +102,95 @@ describe('BinaryDataController', () => {
|
||||
});
|
||||
|
||||
it('should set Content-Security-Policy for HTML in view mode', async () => {
|
||||
request.query = {
|
||||
const query = {
|
||||
id: 'filesystem:123',
|
||||
action: 'view',
|
||||
fileName: 'test.html',
|
||||
mimeType: 'text/html',
|
||||
};
|
||||
} as BinaryDataQueryDto;
|
||||
|
||||
binaryDataService.getAsStream.mockResolvedValue(mock());
|
||||
|
||||
await controller.get(request, response);
|
||||
await controller.get(request, response, query);
|
||||
|
||||
expect(response.header).toHaveBeenCalledWith('Content-Security-Policy', 'sandbox');
|
||||
});
|
||||
|
||||
it('should not set Content-Security-Policy for HTML in download mode', async () => {
|
||||
request.query = {
|
||||
const query = {
|
||||
id: 'filesystem:123',
|
||||
action: 'download',
|
||||
fileName: 'test.html',
|
||||
mimeType: 'text/html',
|
||||
};
|
||||
} as BinaryDataQueryDto;
|
||||
|
||||
binaryDataService.getAsStream.mockResolvedValue(mock());
|
||||
|
||||
await controller.get(request, response);
|
||||
await controller.get(request, response, query);
|
||||
|
||||
expect(response.header).not.toHaveBeenCalledWith('Content-Security-Policy', 'sandbox');
|
||||
});
|
||||
|
||||
it('should return the file stream on success', async () => {
|
||||
request.query = { id: 'filesystem:123', action: 'view' };
|
||||
const query = { id: 'filesystem:123', action: 'view' } as BinaryDataQueryDto;
|
||||
|
||||
const stream = mock<Readable>();
|
||||
binaryDataService.getAsStream.mockResolvedValue(stream);
|
||||
|
||||
const result = await controller.get(request, response);
|
||||
const result = await controller.get(request, response, query);
|
||||
|
||||
expect(result).toBe(stream);
|
||||
expect(binaryDataService.getAsStream).toHaveBeenCalledWith('filesystem:123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSigned', () => {
|
||||
const query = { token: '12344' } as BinaryDataSignedQueryDto;
|
||||
|
||||
it('should return 400 if binary data mode is missing', async () => {
|
||||
binaryDataService.validateSignedToken.mockReturnValueOnce('123');
|
||||
await controller.getSigned(request, response, query);
|
||||
|
||||
expect(response.status).toHaveBeenCalledWith(400);
|
||||
expect(response.end).toHaveBeenCalledWith('Missing binary data mode');
|
||||
});
|
||||
|
||||
it('should return 400 if binary data mode is invalid', async () => {
|
||||
binaryDataService.validateSignedToken.mockReturnValueOnce('invalid:123');
|
||||
|
||||
await controller.getSigned(request, response, query);
|
||||
|
||||
expect(response.status).toHaveBeenCalledWith(400);
|
||||
expect(response.end).toHaveBeenCalledWith('Invalid binary data mode');
|
||||
});
|
||||
|
||||
it('should return 400 if token is invalid', async () => {
|
||||
binaryDataService.validateSignedToken.mockImplementation(() => {
|
||||
throw new JsonWebTokenError('Invalid token');
|
||||
});
|
||||
|
||||
await controller.getSigned(request, response, query);
|
||||
|
||||
expect(response.status).toHaveBeenCalledWith(400);
|
||||
expect(response.end).toHaveBeenCalledWith('Invalid token');
|
||||
});
|
||||
|
||||
it('should return 404 if file is not found', async () => {
|
||||
binaryDataService.validateSignedToken.mockReturnValueOnce('filesystem:123');
|
||||
binaryDataService.getAsStream.mockRejectedValue(new FileNotFoundError('File not found'));
|
||||
|
||||
await controller.getSigned(request, response, query);
|
||||
|
||||
expect(response.status).toHaveBeenCalledWith(404);
|
||||
expect(response.end).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return the file stream on a valid signed token', async () => {
|
||||
binaryDataService.validateSignedToken.mockReturnValueOnce('filesystem:123');
|
||||
const stream = mock<Readable>();
|
||||
binaryDataService.getAsStream.mockResolvedValue(stream);
|
||||
|
||||
const result = await controller.getSigned(request, response, query);
|
||||
|
||||
expect(result).toBe(stream);
|
||||
expect(binaryDataService.getAsStream).toHaveBeenCalledWith('filesystem:123');
|
||||
|
||||
@@ -1,34 +1,69 @@
|
||||
import express from 'express';
|
||||
import { BinaryDataQueryDto, BinaryDataSignedQueryDto } from '@n8n/api-types';
|
||||
import { Request, Response } from 'express';
|
||||
import { JsonWebTokenError } from 'jsonwebtoken';
|
||||
import { BinaryDataService, FileNotFoundError, isValidNonDefaultMode } from 'n8n-core';
|
||||
|
||||
import { Get, RestController } from '@/decorators';
|
||||
import { BinaryDataRequest } from '@/requests';
|
||||
import { Get, Query, RestController } from '@/decorators';
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
|
||||
@RestController('/binary-data')
|
||||
export class BinaryDataController {
|
||||
constructor(private readonly binaryDataService: BinaryDataService) {}
|
||||
|
||||
@Get('/')
|
||||
async get(req: BinaryDataRequest, res: express.Response) {
|
||||
const { id: binaryDataId, action } = req.query;
|
||||
async get(
|
||||
_: Request,
|
||||
res: Response,
|
||||
@Query { id: binaryDataId, action, fileName, mimeType }: BinaryDataQueryDto,
|
||||
) {
|
||||
try {
|
||||
this.validateBinaryDataId(binaryDataId);
|
||||
await this.setContentHeaders(binaryDataId, action, res, fileName, mimeType);
|
||||
return await this.binaryDataService.getAsStream(binaryDataId);
|
||||
} catch (error) {
|
||||
if (error instanceof FileNotFoundError) return res.status(404).end();
|
||||
if (error instanceof BadRequestError) return res.status(400).end(error.message);
|
||||
else throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@Get('/signed', { skipAuth: true })
|
||||
async getSigned(_: Request, res: Response, @Query { token }: BinaryDataSignedQueryDto) {
|
||||
try {
|
||||
const binaryDataId = this.binaryDataService.validateSignedToken(token);
|
||||
this.validateBinaryDataId(binaryDataId);
|
||||
await this.setContentHeaders(binaryDataId, 'download', res);
|
||||
return await this.binaryDataService.getAsStream(binaryDataId);
|
||||
} catch (error) {
|
||||
if (error instanceof FileNotFoundError) return res.status(404).end();
|
||||
if (error instanceof BadRequestError || error instanceof JsonWebTokenError)
|
||||
return res.status(400).end(error.message);
|
||||
else throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private validateBinaryDataId(binaryDataId: string) {
|
||||
if (!binaryDataId) {
|
||||
return res.status(400).end('Missing binary data ID');
|
||||
throw new BadRequestError('Missing binary data ID');
|
||||
}
|
||||
|
||||
if (!binaryDataId.includes(':')) {
|
||||
return res.status(400).end('Missing binary data mode');
|
||||
throw new BadRequestError('Missing binary data mode');
|
||||
}
|
||||
|
||||
const [mode] = binaryDataId.split(':');
|
||||
|
||||
if (!isValidNonDefaultMode(mode)) {
|
||||
return res.status(400).end('Invalid binary data mode');
|
||||
throw new BadRequestError('Invalid binary data mode');
|
||||
}
|
||||
}
|
||||
|
||||
let { fileName, mimeType } = req.query;
|
||||
|
||||
try {
|
||||
private async setContentHeaders(
|
||||
binaryDataId: string,
|
||||
action: 'view' | 'download',
|
||||
res: Response,
|
||||
fileName?: string,
|
||||
mimeType?: string,
|
||||
) {
|
||||
if (!fileName || !mimeType) {
|
||||
try {
|
||||
const metadata = await this.binaryDataService.getMetadata(binaryDataId);
|
||||
@@ -51,11 +86,5 @@ export class BinaryDataController {
|
||||
const encodedFilename = encodeURIComponent(fileName);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${encodedFilename}"`);
|
||||
}
|
||||
|
||||
return await this.binaryDataService.getAsStream(binaryDataId);
|
||||
} catch (error) {
|
||||
if (error instanceof FileNotFoundError) return res.status(404).end();
|
||||
else throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,18 +296,6 @@ export declare namespace LicenseRequest {
|
||||
type Activate = AuthenticatedRequest<{}, {}, { activationKey: string }, {}>;
|
||||
}
|
||||
|
||||
export type BinaryDataRequest = AuthenticatedRequest<
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
{
|
||||
id: string;
|
||||
action: 'view' | 'download';
|
||||
fileName?: string;
|
||||
mimeType?: string;
|
||||
}
|
||||
>;
|
||||
|
||||
// ----------------------------------
|
||||
// /variables
|
||||
// ----------------------------------
|
||||
|
||||
@@ -110,7 +110,7 @@ export async function initNodeTypes(customNodes?: INodeTypeData) {
|
||||
* Initialize a BinaryDataService for test runs.
|
||||
*/
|
||||
export async function initBinaryDataService(mode: 'default' | 'filesystem' = 'default') {
|
||||
const binaryDataService = new BinaryDataService();
|
||||
const binaryDataService = new BinaryDataService(mock(), mock());
|
||||
await binaryDataService.init({
|
||||
mode,
|
||||
availableModes: [mode],
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"@types/aws4": "^1.5.1",
|
||||
"@types/concat-stream": "^2.0.0",
|
||||
"@types/express": "catalog:",
|
||||
"@types/jsonwebtoken": "catalog:",
|
||||
"@types/lodash": "catalog:",
|
||||
"@types/mime-types": "^2.1.0",
|
||||
"@types/uuid": "catalog:",
|
||||
@@ -52,6 +53,7 @@
|
||||
"file-type": "16.5.4",
|
||||
"form-data": "catalog:",
|
||||
"iconv-lite": "catalog:",
|
||||
"jsonwebtoken": "catalog:",
|
||||
"lodash": "catalog:",
|
||||
"luxon": "catalog:",
|
||||
"mime-types": "2.1.35",
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import { sign, JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken';
|
||||
import type { IBinaryData } from 'n8n-workflow';
|
||||
|
||||
import { type InstanceSettings } from '@/instance-settings';
|
||||
|
||||
import type { BinaryDataConfig } from '../binary-data.config';
|
||||
import { BinaryDataService } from '../binary-data.service';
|
||||
|
||||
const now = new Date('2025-01-01T01:23:45.678Z');
|
||||
jest.useFakeTimers({ now });
|
||||
|
||||
describe('BinaryDataService', () => {
|
||||
const signingSecret = 'test-signing-secret';
|
||||
const config = mock<BinaryDataConfig>({ signingSecret });
|
||||
const instanceSettings = mock<InstanceSettings>({ encryptionKey: 'test-encryption-key' });
|
||||
const binaryData = mock<IBinaryData>({ id: 'filesystem:id_123' });
|
||||
const validToken = sign({ id: binaryData.id }, signingSecret, { expiresIn: '1 day' });
|
||||
|
||||
let service: BinaryDataService;
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
|
||||
config.signingSecret = signingSecret;
|
||||
service = new BinaryDataService(instanceSettings, config);
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should derive the signingSecret from the encryption-key, if not provided via BinaryDataConfig', () => {
|
||||
config.signingSecret = undefined;
|
||||
|
||||
const service = new BinaryDataService(instanceSettings, config);
|
||||
|
||||
expect(service.signingSecret).toBe(
|
||||
'f7a78761c5cc17a2753e7e9d85d90e12de87d8131aea4479a11d1c7bb9655b20',
|
||||
);
|
||||
});
|
||||
|
||||
it('should use signingSecret as provided in BinaryDataConfig', () => {
|
||||
expect(service.signingSecret).toBe(signingSecret);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSignedToken', () => {
|
||||
it('should throw for binary-data without an id', () => {
|
||||
const binaryData = mock<IBinaryData>({ id: undefined });
|
||||
|
||||
expect(() => service.createSignedToken(binaryData)).toThrow();
|
||||
});
|
||||
|
||||
it('should create a signed token for valid binary-data', () => {
|
||||
const token = service.createSignedToken(binaryData);
|
||||
|
||||
expect(token).toBe(validToken);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateSignedToken', () => {
|
||||
const invalidToken = sign({ id: binaryData.id }, 'fake-secret');
|
||||
const expiredToken = sign({ id: binaryData.id }, signingSecret, { expiresIn: '-1 day' });
|
||||
|
||||
it('should throw on invalid tokens', () => {
|
||||
expect(() => service.validateSignedToken(invalidToken)).toThrow(JsonWebTokenError);
|
||||
});
|
||||
|
||||
it('should throw on expired tokens', () => {
|
||||
expect(() => service.validateSignedToken(expiredToken)).toThrow(TokenExpiredError);
|
||||
});
|
||||
|
||||
it('should return binary-data id on valid tokens', () => {
|
||||
const result = service.validateSignedToken(validToken);
|
||||
expect(result).toBe(binaryData.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
8
packages/core/src/binary-data/binary-data.config.ts
Normal file
8
packages/core/src/binary-data/binary-data.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Config, Env } from '@n8n/config';
|
||||
|
||||
@Config
|
||||
export class BinaryDataConfig {
|
||||
/** Secret for creating publicly-accesible signed URLs for binary data */
|
||||
@Env('N8N_BINARY_DATA_SIGNING_SECRET')
|
||||
signingSecret?: string = undefined;
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
import { Container, Service } from '@n8n/di';
|
||||
import { BINARY_ENCODING } from 'n8n-workflow';
|
||||
import { createHash } from 'crypto';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { BINARY_ENCODING, UnexpectedError } from 'n8n-workflow';
|
||||
import type { INodeExecutionData, IBinaryData } from 'n8n-workflow';
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import prettyBytes from 'pretty-bytes';
|
||||
import type { Readable } from 'stream';
|
||||
|
||||
import { InstanceSettings } from '@/instance-settings';
|
||||
|
||||
import { BinaryDataConfig } from './binary-data.config';
|
||||
import type { BinaryData } from './types';
|
||||
import { areConfigModes, binaryToBuffer } from './utils';
|
||||
import { InvalidManagerError } from '../errors/invalid-manager.error';
|
||||
@@ -16,6 +21,14 @@ export class BinaryDataService {
|
||||
|
||||
private managers: Record<string, BinaryData.Manager> = {};
|
||||
|
||||
readonly signingSecret: string;
|
||||
|
||||
constructor({ encryptionKey }: InstanceSettings, binaryDataConfig: BinaryDataConfig) {
|
||||
this.signingSecret =
|
||||
binaryDataConfig.signingSecret ??
|
||||
createHash('sha256').update(`url-signing:${encryptionKey}`).digest('hex');
|
||||
}
|
||||
|
||||
async init(config: BinaryData.Config) {
|
||||
if (!areConfigModes(config.availableModes)) throw new InvalidModeError();
|
||||
|
||||
@@ -40,6 +53,23 @@ export class BinaryDataService {
|
||||
}
|
||||
}
|
||||
|
||||
createSignedToken(binaryData: IBinaryData, expiresIn = '1 day') {
|
||||
if (!binaryData.id) {
|
||||
throw new UnexpectedError('URL signing is not available in memory mode');
|
||||
}
|
||||
|
||||
const signingPayload: BinaryData.SigningPayload = {
|
||||
id: binaryData.id,
|
||||
};
|
||||
|
||||
return jwt.sign(signingPayload, this.signingSecret, { expiresIn });
|
||||
}
|
||||
|
||||
validateSignedToken(token: string) {
|
||||
const signedPayload = jwt.verify(token, this.signingSecret) as BinaryData.SigningPayload;
|
||||
return signedPayload.id;
|
||||
}
|
||||
|
||||
async copyBinaryFile(
|
||||
workflowId: string,
|
||||
executionId: string,
|
||||
|
||||
@@ -70,4 +70,8 @@ export namespace BinaryData {
|
||||
|
||||
rename(oldFileId: string, newFileId: string): Promise<void>;
|
||||
}
|
||||
|
||||
export type SigningPayload = {
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { join } from 'path';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
import { BinaryDataService } from '@/binary-data/binary-data.service';
|
||||
import { type InstanceSettings } from '@/instance-settings';
|
||||
|
||||
import {
|
||||
assertBinaryData,
|
||||
@@ -41,7 +42,7 @@ describe('test binary data helper methods', () => {
|
||||
const temporaryDir = mkdtempSync(join(tmpdir(), 'n8n'));
|
||||
|
||||
beforeEach(() => {
|
||||
binaryDataService = new BinaryDataService();
|
||||
binaryDataService = new BinaryDataService(mock<InstanceSettings>(), mock());
|
||||
Container.set(BinaryDataService, binaryDataService);
|
||||
});
|
||||
|
||||
@@ -478,3 +479,24 @@ describe('getBinaryHelperFunctions', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createBinarySignedUrl', () => {
|
||||
const restApiUrl = 'https://n8n.host/rest';
|
||||
|
||||
it('should get a signed url', async () => {
|
||||
const additionalData = { restApiUrl } as IWorkflowExecuteAdditionalData;
|
||||
const helperFunctions = getBinaryHelperFunctions(additionalData, workflowId);
|
||||
|
||||
const binaryData = mock<IBinaryData>();
|
||||
const token = 'signed-token';
|
||||
|
||||
const binaryDataService = mock<BinaryDataService>();
|
||||
Container.set(BinaryDataService, binaryDataService);
|
||||
binaryDataService.createSignedToken.mockReturnValueOnce(token);
|
||||
|
||||
const result = helperFunctions.createBinarySignedUrl(binaryData);
|
||||
|
||||
expect(result).toBe(`${restApiUrl}/binary-data/signed?token=${token}`);
|
||||
expect(binaryDataService.createSignedToken).toHaveBeenCalledWith(binaryData, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -271,7 +271,7 @@ export async function prepareBinaryData(
|
||||
}
|
||||
|
||||
export const getBinaryHelperFunctions = (
|
||||
{ executionId }: IWorkflowExecuteAdditionalData,
|
||||
{ executionId, restApiUrl }: IWorkflowExecuteAdditionalData,
|
||||
workflowId: string,
|
||||
): BinaryHelperFunctions => ({
|
||||
getBinaryPath,
|
||||
@@ -279,6 +279,10 @@ export const getBinaryHelperFunctions = (
|
||||
getBinaryMetadata,
|
||||
binaryToBuffer,
|
||||
binaryToString,
|
||||
createBinarySignedUrl(binaryData: IBinaryData, expiresIn?: string) {
|
||||
const token = Container.get(BinaryDataService).createSignedToken(binaryData, expiresIn);
|
||||
return `${restApiUrl}/binary-data/signed?token=${token}`;
|
||||
},
|
||||
prepareBinaryData: async (binaryData, filePath, mimeType) =>
|
||||
await prepareBinaryData(binaryData, executionId!, workflowId, filePath, mimeType),
|
||||
setBinaryDataBuffer: async (data, binaryData) =>
|
||||
|
||||
@@ -848,7 +848,7 @@
|
||||
"@types/html-to-text": "^9.0.1",
|
||||
"@types/gm": "^1.25.0",
|
||||
"@types/js-nacl": "^1.3.0",
|
||||
"@types/jsonwebtoken": "^9.0.6",
|
||||
"@types/jsonwebtoken": "catalog:",
|
||||
"@types/lodash": "catalog:",
|
||||
"@types/lossless-json": "^1.0.0",
|
||||
"@types/mailparser": "^3.4.4",
|
||||
@@ -895,7 +895,7 @@
|
||||
"isbot": "3.6.13",
|
||||
"iso-639-1": "2.1.15",
|
||||
"js-nacl": "1.4.0",
|
||||
"jsonwebtoken": "9.0.2",
|
||||
"jsonwebtoken": "catalog:",
|
||||
"kafkajs": "2.2.4",
|
||||
"ldapts": "4.2.6",
|
||||
"lodash": "catalog:",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Container } from '@n8n/di';
|
||||
import { readFileSync, readdirSync, mkdtempSync } from 'fs';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import { get } from 'lodash';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { BinaryDataService, constructExecutionMetaData } from 'n8n-core';
|
||||
@@ -36,7 +37,7 @@ export function createTemporaryDir(prefix = 'n8n') {
|
||||
}
|
||||
|
||||
export async function initBinaryDataService() {
|
||||
const binaryDataService = new BinaryDataService();
|
||||
const binaryDataService = new BinaryDataService(mock(), mock());
|
||||
await binaryDataService.init({
|
||||
mode: 'default',
|
||||
availableModes: ['default'],
|
||||
|
||||
@@ -690,6 +690,7 @@ export interface BinaryHelperFunctions {
|
||||
binaryToString(body: Buffer | Readable, encoding?: BufferEncoding): Promise<string>;
|
||||
getBinaryPath(binaryDataId: string): string;
|
||||
getBinaryStream(binaryDataId: string, chunkSize?: number): Promise<Readable>;
|
||||
createBinarySignedUrl(binaryData: IBinaryData, expiresIn?: string): string;
|
||||
getBinaryMetadata(binaryDataId: string): Promise<{
|
||||
fileName?: string;
|
||||
mimeType?: string;
|
||||
|
||||
55
pnpm-lock.yaml
generated
55
pnpm-lock.yaml
generated
@@ -18,6 +18,9 @@ catalogs:
|
||||
'@types/express':
|
||||
specifier: ^5.0.1
|
||||
version: 5.0.1
|
||||
'@types/jsonwebtoken':
|
||||
specifier: ^9.0.9
|
||||
version: 9.0.9
|
||||
'@types/lodash':
|
||||
specifier: ^4.14.195
|
||||
version: 4.14.195
|
||||
@@ -48,6 +51,9 @@ catalogs:
|
||||
iconv-lite:
|
||||
specifier: 0.6.3
|
||||
version: 0.6.3
|
||||
jsonwebtoken:
|
||||
specifier: 9.0.2
|
||||
version: 9.0.2
|
||||
lodash:
|
||||
specifier: 4.17.21
|
||||
version: 4.17.21
|
||||
@@ -552,7 +558,7 @@ importers:
|
||||
version: 3.666.0(@aws-sdk/client-sts@3.666.0)
|
||||
'@getzep/zep-cloud':
|
||||
specifier: 1.0.12
|
||||
version: 1.0.12(@langchain/core@0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)))(encoding@0.1.13)(langchain@0.3.11(fd386e1130022c8548c06dd951c5cbf0))
|
||||
version: 1.0.12(@langchain/core@0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)))(encoding@0.1.13)(langchain@0.3.11(d1e86e144e3517fab3dbb7a92ab7f45a))
|
||||
'@getzep/zep-js':
|
||||
specifier: 0.9.0
|
||||
version: 0.9.0
|
||||
@@ -579,7 +585,7 @@ importers:
|
||||
version: 0.3.2(@aws-sdk/client-sso-oidc@3.666.0(@aws-sdk/client-sts@3.666.0))(@langchain/core@0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)))(encoding@0.1.13)
|
||||
'@langchain/community':
|
||||
specifier: 0.3.24
|
||||
version: 0.3.24(c5fc7e11d6e6167a46cb8d3fd9b490a5)
|
||||
version: 0.3.24(bae0580ee8bea2ce19e4657a460c92d0)
|
||||
'@langchain/core':
|
||||
specifier: 'catalog:'
|
||||
version: 0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1))
|
||||
@@ -678,7 +684,7 @@ importers:
|
||||
version: 23.0.1
|
||||
langchain:
|
||||
specifier: 0.3.11
|
||||
version: 0.3.11(fd386e1130022c8548c06dd951c5cbf0)
|
||||
version: 0.3.11(d1e86e144e3517fab3dbb7a92ab7f45a)
|
||||
lodash:
|
||||
specifier: 'catalog:'
|
||||
version: 4.17.21
|
||||
@@ -1035,7 +1041,7 @@ importers:
|
||||
specifier: 1.4.1
|
||||
version: 1.4.1
|
||||
jsonwebtoken:
|
||||
specifier: 9.0.2
|
||||
specifier: 'catalog:'
|
||||
version: 9.0.2
|
||||
ldapts:
|
||||
specifier: 4.2.6
|
||||
@@ -1195,8 +1201,8 @@ importers:
|
||||
specifier: ^1.0.0
|
||||
version: 1.0.0
|
||||
'@types/jsonwebtoken':
|
||||
specifier: ^9.0.6
|
||||
version: 9.0.6
|
||||
specifier: 'catalog:'
|
||||
version: 9.0.9
|
||||
'@types/lodash':
|
||||
specifier: 'catalog:'
|
||||
version: 4.14.195
|
||||
@@ -1302,6 +1308,9 @@ importers:
|
||||
iconv-lite:
|
||||
specifier: 'catalog:'
|
||||
version: 0.6.3
|
||||
jsonwebtoken:
|
||||
specifier: 'catalog:'
|
||||
version: 9.0.2
|
||||
lodash:
|
||||
specifier: 'catalog:'
|
||||
version: 4.17.21
|
||||
@@ -1360,6 +1369,9 @@ importers:
|
||||
'@types/express':
|
||||
specifier: 'catalog:'
|
||||
version: 5.0.1
|
||||
'@types/jsonwebtoken':
|
||||
specifier: 'catalog:'
|
||||
version: 9.0.9
|
||||
'@types/lodash':
|
||||
specifier: 'catalog:'
|
||||
version: 4.14.195
|
||||
@@ -2083,7 +2095,7 @@ importers:
|
||||
specifier: 1.4.0
|
||||
version: 1.4.0
|
||||
jsonwebtoken:
|
||||
specifier: 9.0.2
|
||||
specifier: 'catalog:'
|
||||
version: 9.0.2
|
||||
kafkajs:
|
||||
specifier: 2.2.4
|
||||
@@ -2231,8 +2243,8 @@ importers:
|
||||
specifier: ^1.3.0
|
||||
version: 1.3.0
|
||||
'@types/jsonwebtoken':
|
||||
specifier: ^9.0.6
|
||||
version: 9.0.6
|
||||
specifier: 'catalog:'
|
||||
version: 9.0.9
|
||||
'@types/lodash':
|
||||
specifier: 'catalog:'
|
||||
version: 4.14.195
|
||||
@@ -6066,8 +6078,8 @@ packages:
|
||||
'@types/jsonpath@0.2.0':
|
||||
resolution: {integrity: sha512-v7qlPA0VpKUlEdhghbDqRoKMxFB3h3Ch688TApBJ6v+XLDdvWCGLJIYiPKGZnS6MAOie+IorCfNYVHOPIHSWwQ==}
|
||||
|
||||
'@types/jsonwebtoken@9.0.6':
|
||||
resolution: {integrity: sha512-/5hndP5dCjloafCXns6SZyESp3Ldq7YjH3zwzwczYnjxIT0Fqzk5ROSYVGfFyczIue7IUEj8hkvLbPoLQ18vQw==}
|
||||
'@types/jsonwebtoken@9.0.9':
|
||||
resolution: {integrity: sha512-uoe+GxEuHbvy12OUQct2X9JenKM3qAscquYymuQN4fMWG9DBQtykrQEFcAbVACF7qaLw9BePSodUL0kquqBJpQ==}
|
||||
|
||||
'@types/k6@0.52.0':
|
||||
resolution: {integrity: sha512-yaw2wg61nKQtToDML+nngzgXVjZ6wNA4R0Q3jKDTeadG5EqfZgis5a1Q2hwY7kjuGuXmu8eM6gHg3tgnOj4vNw==}
|
||||
@@ -16137,7 +16149,7 @@ snapshots:
|
||||
'@gar/promisify@1.1.3':
|
||||
optional: true
|
||||
|
||||
'@getzep/zep-cloud@1.0.12(@langchain/core@0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)))(encoding@0.1.13)(langchain@0.3.11(fd386e1130022c8548c06dd951c5cbf0))':
|
||||
'@getzep/zep-cloud@1.0.12(@langchain/core@0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)))(encoding@0.1.13)(langchain@0.3.11(d1e86e144e3517fab3dbb7a92ab7f45a))':
|
||||
dependencies:
|
||||
form-data: 4.0.0
|
||||
node-fetch: 2.7.0(encoding@0.1.13)
|
||||
@@ -16146,7 +16158,7 @@ snapshots:
|
||||
zod: 3.24.1
|
||||
optionalDependencies:
|
||||
'@langchain/core': 0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1))
|
||||
langchain: 0.3.11(fd386e1130022c8548c06dd951c5cbf0)
|
||||
langchain: 0.3.11(d1e86e144e3517fab3dbb7a92ab7f45a)
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
|
||||
@@ -16661,7 +16673,7 @@ snapshots:
|
||||
- aws-crt
|
||||
- encoding
|
||||
|
||||
'@langchain/community@0.3.24(c5fc7e11d6e6167a46cb8d3fd9b490a5)':
|
||||
'@langchain/community@0.3.24(bae0580ee8bea2ce19e4657a460c92d0)':
|
||||
dependencies:
|
||||
'@browserbasehq/stagehand': 1.9.0(@playwright/test@1.49.1)(deepmerge@4.3.1)(dotenv@16.4.5)(encoding@0.1.13)(openai@4.78.1(encoding@0.1.13)(zod@3.24.1))(zod@3.24.1)
|
||||
'@ibm-cloud/watsonx-ai': 1.1.2
|
||||
@@ -16672,7 +16684,7 @@ snapshots:
|
||||
flat: 5.0.2
|
||||
ibm-cloud-sdk-core: 5.1.0
|
||||
js-yaml: 4.1.0
|
||||
langchain: 0.3.11(fd386e1130022c8548c06dd951c5cbf0)
|
||||
langchain: 0.3.11(d1e86e144e3517fab3dbb7a92ab7f45a)
|
||||
langsmith: 0.2.15(openai@4.78.1(encoding@0.1.13)(zod@3.24.1))
|
||||
openai: 4.78.1(encoding@0.1.13)(zod@3.24.1)
|
||||
uuid: 10.0.0
|
||||
@@ -16687,7 +16699,7 @@ snapshots:
|
||||
'@aws-sdk/credential-provider-node': 3.666.0(@aws-sdk/client-sso-oidc@3.666.0(@aws-sdk/client-sts@3.666.0))(@aws-sdk/client-sts@3.666.0)
|
||||
'@azure/storage-blob': 12.18.0(encoding@0.1.13)
|
||||
'@browserbasehq/sdk': 2.0.0(encoding@0.1.13)
|
||||
'@getzep/zep-cloud': 1.0.12(@langchain/core@0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)))(encoding@0.1.13)(langchain@0.3.11(fd386e1130022c8548c06dd951c5cbf0))
|
||||
'@getzep/zep-cloud': 1.0.12(@langchain/core@0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)))(encoding@0.1.13)(langchain@0.3.11(d1e86e144e3517fab3dbb7a92ab7f45a))
|
||||
'@getzep/zep-js': 0.9.0
|
||||
'@google-ai/generativelanguage': 2.6.0(encoding@0.1.13)
|
||||
'@google-cloud/storage': 7.12.1(encoding@0.1.13)
|
||||
@@ -18933,8 +18945,9 @@ snapshots:
|
||||
|
||||
'@types/jsonpath@0.2.0': {}
|
||||
|
||||
'@types/jsonwebtoken@9.0.6':
|
||||
'@types/jsonwebtoken@9.0.9':
|
||||
dependencies:
|
||||
'@types/ms': 0.7.34
|
||||
'@types/node': 18.16.16
|
||||
|
||||
'@types/k6@0.52.0': {}
|
||||
@@ -22878,7 +22891,7 @@ snapshots:
|
||||
'@types/debug': 4.1.12
|
||||
'@types/node': 18.16.16
|
||||
'@types/tough-cookie': 4.0.2
|
||||
axios: 1.8.2(debug@4.4.0)
|
||||
axios: 1.8.2
|
||||
camelcase: 6.3.0
|
||||
debug: 4.4.0(supports-color@8.1.1)
|
||||
dotenv: 16.4.5
|
||||
@@ -22888,7 +22901,7 @@ snapshots:
|
||||
isstream: 0.1.2
|
||||
jsonwebtoken: 9.0.2
|
||||
mime-types: 2.1.35
|
||||
retry-axios: 2.6.0(axios@1.8.2)
|
||||
retry-axios: 2.6.0(axios@1.8.2(debug@4.4.0))
|
||||
tough-cookie: 4.1.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -23882,7 +23895,7 @@ snapshots:
|
||||
|
||||
kuler@2.0.0: {}
|
||||
|
||||
langchain@0.3.11(fd386e1130022c8548c06dd951c5cbf0):
|
||||
langchain@0.3.11(d1e86e144e3517fab3dbb7a92ab7f45a):
|
||||
dependencies:
|
||||
'@langchain/core': 0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1))
|
||||
'@langchain/openai': 0.3.17(@langchain/core@0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)))(encoding@0.1.13)
|
||||
@@ -26264,7 +26277,7 @@ snapshots:
|
||||
onetime: 5.1.2
|
||||
signal-exit: 3.0.7
|
||||
|
||||
retry-axios@2.6.0(axios@1.8.2):
|
||||
retry-axios@2.6.0(axios@1.8.2(debug@4.4.0)):
|
||||
dependencies:
|
||||
axios: 1.8.2
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ catalog:
|
||||
'@sentry/node': 8.52.1
|
||||
'@types/basic-auth': ^1.1.3
|
||||
'@types/express': ^5.0.1
|
||||
'@types/jsonwebtoken': ^9.0.9
|
||||
'@types/lodash': ^4.14.195
|
||||
'@types/uuid': ^10.0.0
|
||||
'@types/xml2js': ^0.4.14
|
||||
@@ -20,6 +21,7 @@ catalog:
|
||||
flatted: 3.2.7
|
||||
form-data: 4.0.0
|
||||
iconv-lite: 0.6.3
|
||||
jsonwebtoken: 9.0.2
|
||||
lodash: 4.17.21
|
||||
luxon: 3.4.4
|
||||
nanoid: 3.3.8
|
||||
|
||||
Reference in New Issue
Block a user