feat(core): Add support for signed URLs for binary data (#14492)

Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <aditya@netroy.in>
This commit is contained in:
Dana
2025-04-14 19:59:40 +02:00
committed by GitHub
parent 23f25cefbf
commit 7723a138a1
22 changed files with 537 additions and 122 deletions

View File

@@ -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);
}
});
});
});

View File

@@ -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);
}
});
});
});

View File

@@ -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(),
}) {}

View File

@@ -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',
}),
}) {}

View File

@@ -3,6 +3,9 @@ export { AiChatRequestDto } from './ai/ai-chat-request.dto';
export { AiApplySuggestionRequestDto } from './ai/ai-apply-suggestion-request.dto'; export { AiApplySuggestionRequestDto } from './ai/ai-apply-suggestion-request.dto';
export { AiFreeCreditsRequestDto } from './ai/ai-free-credits-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 { LoginRequestDto } from './auth/login-request.dto';
export { ResolveSignupTokenQueryDto } from './auth/resolve-signup-token-query.dto'; export { ResolveSignupTokenQueryDto } from './auth/resolve-signup-token-query.dto';

View File

@@ -64,7 +64,7 @@
"@types/flat": "^5.0.5", "@types/flat": "^5.0.5",
"@types/formidable": "^3.4.5", "@types/formidable": "^3.4.5",
"@types/json-diff": "^1.0.0", "@types/json-diff": "^1.0.0",
"@types/jsonwebtoken": "^9.0.6", "@types/jsonwebtoken": "catalog:",
"@types/lodash": "catalog:", "@types/lodash": "catalog:",
"@types/psl": "^1.1.0", "@types/psl": "^1.1.0",
"@types/replacestream": "^4.0.1", "@types/replacestream": "^4.0.1",
@@ -132,7 +132,7 @@
"isbot": "3.6.13", "isbot": "3.6.13",
"json-diff": "1.0.6", "json-diff": "1.0.6",
"jsonschema": "1.4.1", "jsonschema": "1.4.1",
"jsonwebtoken": "9.0.2", "jsonwebtoken": "catalog:",
"ldapts": "4.2.6", "ldapts": "4.2.6",
"lodash": "catalog:", "lodash": "catalog:",
"luxon": "catalog:", "luxon": "catalog:",

View File

@@ -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 { mock } from 'jest-mock-extended';
import { JsonWebTokenError } from 'jsonwebtoken';
import type { BinaryDataService } from 'n8n-core'; import type { BinaryDataService } from 'n8n-core';
import { FileNotFoundError } from 'n8n-core'; import { FileNotFoundError } from 'n8n-core';
import type { Readable } from 'node:stream'; import type { Readable } from 'node:stream';
import type { BinaryDataRequest } from '@/requests';
import { BinaryDataController } from '../binary-data.controller'; import { BinaryDataController } from '../binary-data.controller';
describe('BinaryDataController', () => { describe('BinaryDataController', () => {
const request = mock<BinaryDataRequest>(); const request = mock<Request>();
const response = mock<Response>(); const response = mock<Response>();
const binaryDataService = mock<BinaryDataService>(); const binaryDataService = mock<BinaryDataService>();
const controller = new BinaryDataController(binaryDataService); const controller = new BinaryDataController(binaryDataService);
@@ -20,55 +20,45 @@ describe('BinaryDataController', () => {
}); });
describe('get', () => { 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 () => { 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.status).toHaveBeenCalledWith(400);
expect(response.end).toHaveBeenCalledWith('Missing binary data mode'); expect(response.end).toHaveBeenCalledWith('Missing binary data mode');
}); });
it('should return 400 if binary data mode is invalid', async () => { 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.status).toHaveBeenCalledWith(400);
expect(response.end).toHaveBeenCalledWith('Invalid binary data mode'); expect(response.end).toHaveBeenCalledWith('Invalid binary data mode');
}); });
it('should return 404 if file is not found', async () => { 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')); 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.status).toHaveBeenCalledWith(404);
expect(response.end).toHaveBeenCalled(); expect(response.end).toHaveBeenCalled();
}); });
it('should set Content-Type and Content-Length from query if provided', async () => { it('should set Content-Type and Content-Length from query if provided', async () => {
request.query = { const query = {
id: 'filesystem:123', id: 'filesystem:123',
action: 'view', action: 'view',
fileName: 'test.txt', fileName: 'test.txt',
mimeType: 'text/plain', mimeType: 'text/plain',
}; } as BinaryDataQueryDto;
binaryDataService.getAsStream.mockResolvedValue(mock()); binaryDataService.getAsStream.mockResolvedValue(mock());
await controller.get(request, response); await controller.get(request, response, query);
expect(binaryDataService.getMetadata).not.toHaveBeenCalled(); expect(binaryDataService.getMetadata).not.toHaveBeenCalled();
expect(response.setHeader).toHaveBeenCalledWith('Content-Type', 'text/plain'); 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 () => { 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({ binaryDataService.getMetadata.mockResolvedValue({
fileName: 'test.txt', fileName: 'test.txt',
@@ -86,7 +76,7 @@ describe('BinaryDataController', () => {
}); });
binaryDataService.getAsStream.mockResolvedValue(mock()); binaryDataService.getAsStream.mockResolvedValue(mock());
await controller.get(request, response); await controller.get(request, response, query);
expect(binaryDataService.getMetadata).toHaveBeenCalledWith('filesystem:123'); expect(binaryDataService.getMetadata).toHaveBeenCalledWith('filesystem:123');
expect(response.setHeader).toHaveBeenCalledWith('Content-Type', 'text/plain'); expect(response.setHeader).toHaveBeenCalledWith('Content-Type', 'text/plain');
@@ -95,11 +85,15 @@ describe('BinaryDataController', () => {
}); });
it('should set Content-Disposition for download action', async () => { 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()); binaryDataService.getAsStream.mockResolvedValue(mock());
await controller.get(request, response); await controller.get(request, response, query);
expect(response.setHeader).toHaveBeenCalledWith( expect(response.setHeader).toHaveBeenCalledWith(
'Content-Disposition', 'Content-Disposition',
@@ -108,42 +102,95 @@ describe('BinaryDataController', () => {
}); });
it('should set Content-Security-Policy for HTML in view mode', async () => { it('should set Content-Security-Policy for HTML in view mode', async () => {
request.query = { const query = {
id: 'filesystem:123', id: 'filesystem:123',
action: 'view', action: 'view',
fileName: 'test.html', fileName: 'test.html',
mimeType: 'text/html', mimeType: 'text/html',
}; } as BinaryDataQueryDto;
binaryDataService.getAsStream.mockResolvedValue(mock()); binaryDataService.getAsStream.mockResolvedValue(mock());
await controller.get(request, response); await controller.get(request, response, query);
expect(response.header).toHaveBeenCalledWith('Content-Security-Policy', 'sandbox'); expect(response.header).toHaveBeenCalledWith('Content-Security-Policy', 'sandbox');
}); });
it('should not set Content-Security-Policy for HTML in download mode', async () => { it('should not set Content-Security-Policy for HTML in download mode', async () => {
request.query = { const query = {
id: 'filesystem:123', id: 'filesystem:123',
action: 'download', action: 'download',
fileName: 'test.html', fileName: 'test.html',
mimeType: 'text/html', mimeType: 'text/html',
}; } as BinaryDataQueryDto;
binaryDataService.getAsStream.mockResolvedValue(mock()); binaryDataService.getAsStream.mockResolvedValue(mock());
await controller.get(request, response); await controller.get(request, response, query);
expect(response.header).not.toHaveBeenCalledWith('Content-Security-Policy', 'sandbox'); expect(response.header).not.toHaveBeenCalledWith('Content-Security-Policy', 'sandbox');
}); });
it('should return the file stream on success', async () => { 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>(); const stream = mock<Readable>();
binaryDataService.getAsStream.mockResolvedValue(stream); 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(result).toBe(stream);
expect(binaryDataService.getAsStream).toHaveBeenCalledWith('filesystem:123'); expect(binaryDataService.getAsStream).toHaveBeenCalledWith('filesystem:123');

View File

@@ -1,61 +1,90 @@
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 { BinaryDataService, FileNotFoundError, isValidNonDefaultMode } from 'n8n-core';
import { Get, RestController } from '@/decorators'; import { Get, Query, RestController } from '@/decorators';
import { BinaryDataRequest } from '@/requests'; import { BadRequestError } from '@/errors/response-errors/bad-request.error';
@RestController('/binary-data') @RestController('/binary-data')
export class BinaryDataController { export class BinaryDataController {
constructor(private readonly binaryDataService: BinaryDataService) {} constructor(private readonly binaryDataService: BinaryDataService) {}
@Get('/') @Get('/')
async get(req: BinaryDataRequest, res: express.Response) { async get(
const { id: binaryDataId, action } = req.query; _: Request,
res: Response,
if (!binaryDataId) { @Query { id: binaryDataId, action, fileName, mimeType }: BinaryDataQueryDto,
return res.status(400).end('Missing binary data ID'); ) {
}
if (!binaryDataId.includes(':')) {
return res.status(400).end('Missing binary data mode');
}
const [mode] = binaryDataId.split(':');
if (!isValidNonDefaultMode(mode)) {
return res.status(400).end('Invalid binary data mode');
}
let { fileName, mimeType } = req.query;
try { try {
if (!fileName || !mimeType) { this.validateBinaryDataId(binaryDataId);
try { await this.setContentHeaders(binaryDataId, action, res, fileName, mimeType);
const metadata = await this.binaryDataService.getMetadata(binaryDataId);
fileName = metadata.fileName;
mimeType = metadata.mimeType;
res.setHeader('Content-Length', metadata.fileSize);
} catch {}
}
if (mimeType) {
res.setHeader('Content-Type', mimeType);
// Sandbox html files when viewed in a browser
if (mimeType.includes('html') && action === 'view') {
res.header('Content-Security-Policy', 'sandbox');
}
}
if (action === 'download' && fileName) {
const encodedFilename = encodeURIComponent(fileName);
res.setHeader('Content-Disposition', `attachment; filename="${encodedFilename}"`);
}
return await this.binaryDataService.getAsStream(binaryDataId); return await this.binaryDataService.getAsStream(binaryDataId);
} catch (error) { } catch (error) {
if (error instanceof FileNotFoundError) return res.status(404).end(); if (error instanceof FileNotFoundError) return res.status(404).end();
if (error instanceof BadRequestError) return res.status(400).end(error.message);
else throw error; 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) {
throw new BadRequestError('Missing binary data ID');
}
if (!binaryDataId.includes(':')) {
throw new BadRequestError('Missing binary data mode');
}
const [mode] = binaryDataId.split(':');
if (!isValidNonDefaultMode(mode)) {
throw new BadRequestError('Invalid binary data mode');
}
}
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);
fileName = metadata.fileName;
mimeType = metadata.mimeType;
res.setHeader('Content-Length', metadata.fileSize);
} catch {}
}
if (mimeType) {
res.setHeader('Content-Type', mimeType);
// Sandbox html files when viewed in a browser
if (mimeType.includes('html') && action === 'view') {
res.header('Content-Security-Policy', 'sandbox');
}
}
if (action === 'download' && fileName) {
const encodedFilename = encodeURIComponent(fileName);
res.setHeader('Content-Disposition', `attachment; filename="${encodedFilename}"`);
}
}
} }

View File

@@ -296,18 +296,6 @@ export declare namespace LicenseRequest {
type Activate = AuthenticatedRequest<{}, {}, { activationKey: string }, {}>; type Activate = AuthenticatedRequest<{}, {}, { activationKey: string }, {}>;
} }
export type BinaryDataRequest = AuthenticatedRequest<
{},
{},
{},
{
id: string;
action: 'view' | 'download';
fileName?: string;
mimeType?: string;
}
>;
// ---------------------------------- // ----------------------------------
// /variables // /variables
// ---------------------------------- // ----------------------------------

View File

@@ -110,7 +110,7 @@ export async function initNodeTypes(customNodes?: INodeTypeData) {
* Initialize a BinaryDataService for test runs. * Initialize a BinaryDataService for test runs.
*/ */
export async function initBinaryDataService(mode: 'default' | 'filesystem' = 'default') { export async function initBinaryDataService(mode: 'default' | 'filesystem' = 'default') {
const binaryDataService = new BinaryDataService(); const binaryDataService = new BinaryDataService(mock(), mock());
await binaryDataService.init({ await binaryDataService.init({
mode, mode,
availableModes: [mode], availableModes: [mode],

View File

@@ -31,6 +31,7 @@
"@types/aws4": "^1.5.1", "@types/aws4": "^1.5.1",
"@types/concat-stream": "^2.0.0", "@types/concat-stream": "^2.0.0",
"@types/express": "catalog:", "@types/express": "catalog:",
"@types/jsonwebtoken": "catalog:",
"@types/lodash": "catalog:", "@types/lodash": "catalog:",
"@types/mime-types": "^2.1.0", "@types/mime-types": "^2.1.0",
"@types/uuid": "catalog:", "@types/uuid": "catalog:",
@@ -52,6 +53,7 @@
"file-type": "16.5.4", "file-type": "16.5.4",
"form-data": "catalog:", "form-data": "catalog:",
"iconv-lite": "catalog:", "iconv-lite": "catalog:",
"jsonwebtoken": "catalog:",
"lodash": "catalog:", "lodash": "catalog:",
"luxon": "catalog:", "luxon": "catalog:",
"mime-types": "2.1.35", "mime-types": "2.1.35",

View File

@@ -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);
});
});
});

View 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;
}

View File

@@ -1,10 +1,15 @@
import { Container, Service } from '@n8n/di'; 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 type { INodeExecutionData, IBinaryData } from 'n8n-workflow';
import { readFile, stat } from 'node:fs/promises'; import { readFile, stat } from 'node:fs/promises';
import prettyBytes from 'pretty-bytes'; import prettyBytes from 'pretty-bytes';
import type { Readable } from 'stream'; import type { Readable } from 'stream';
import { InstanceSettings } from '@/instance-settings';
import { BinaryDataConfig } from './binary-data.config';
import type { BinaryData } from './types'; import type { BinaryData } from './types';
import { areConfigModes, binaryToBuffer } from './utils'; import { areConfigModes, binaryToBuffer } from './utils';
import { InvalidManagerError } from '../errors/invalid-manager.error'; import { InvalidManagerError } from '../errors/invalid-manager.error';
@@ -16,6 +21,14 @@ export class BinaryDataService {
private managers: Record<string, BinaryData.Manager> = {}; 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) { async init(config: BinaryData.Config) {
if (!areConfigModes(config.availableModes)) throw new InvalidModeError(); 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( async copyBinaryFile(
workflowId: string, workflowId: string,
executionId: string, executionId: string,

View File

@@ -70,4 +70,8 @@ export namespace BinaryData {
rename(oldFileId: string, newFileId: string): Promise<void>; rename(oldFileId: string, newFileId: string): Promise<void>;
} }
export type SigningPayload = {
id: string;
};
} }

View File

@@ -13,6 +13,7 @@ import { join } from 'path';
import { Readable } from 'stream'; import { Readable } from 'stream';
import { BinaryDataService } from '@/binary-data/binary-data.service'; import { BinaryDataService } from '@/binary-data/binary-data.service';
import { type InstanceSettings } from '@/instance-settings';
import { import {
assertBinaryData, assertBinaryData,
@@ -41,7 +42,7 @@ describe('test binary data helper methods', () => {
const temporaryDir = mkdtempSync(join(tmpdir(), 'n8n')); const temporaryDir = mkdtempSync(join(tmpdir(), 'n8n'));
beforeEach(() => { beforeEach(() => {
binaryDataService = new BinaryDataService(); binaryDataService = new BinaryDataService(mock<InstanceSettings>(), mock());
Container.set(BinaryDataService, binaryDataService); 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);
});
});

View File

@@ -271,7 +271,7 @@ export async function prepareBinaryData(
} }
export const getBinaryHelperFunctions = ( export const getBinaryHelperFunctions = (
{ executionId }: IWorkflowExecuteAdditionalData, { executionId, restApiUrl }: IWorkflowExecuteAdditionalData,
workflowId: string, workflowId: string,
): BinaryHelperFunctions => ({ ): BinaryHelperFunctions => ({
getBinaryPath, getBinaryPath,
@@ -279,6 +279,10 @@ export const getBinaryHelperFunctions = (
getBinaryMetadata, getBinaryMetadata,
binaryToBuffer, binaryToBuffer,
binaryToString, 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) => prepareBinaryData: async (binaryData, filePath, mimeType) =>
await prepareBinaryData(binaryData, executionId!, workflowId, filePath, mimeType), await prepareBinaryData(binaryData, executionId!, workflowId, filePath, mimeType),
setBinaryDataBuffer: async (data, binaryData) => setBinaryDataBuffer: async (data, binaryData) =>

View File

@@ -848,7 +848,7 @@
"@types/html-to-text": "^9.0.1", "@types/html-to-text": "^9.0.1",
"@types/gm": "^1.25.0", "@types/gm": "^1.25.0",
"@types/js-nacl": "^1.3.0", "@types/js-nacl": "^1.3.0",
"@types/jsonwebtoken": "^9.0.6", "@types/jsonwebtoken": "catalog:",
"@types/lodash": "catalog:", "@types/lodash": "catalog:",
"@types/lossless-json": "^1.0.0", "@types/lossless-json": "^1.0.0",
"@types/mailparser": "^3.4.4", "@types/mailparser": "^3.4.4",
@@ -895,7 +895,7 @@
"isbot": "3.6.13", "isbot": "3.6.13",
"iso-639-1": "2.1.15", "iso-639-1": "2.1.15",
"js-nacl": "1.4.0", "js-nacl": "1.4.0",
"jsonwebtoken": "9.0.2", "jsonwebtoken": "catalog:",
"kafkajs": "2.2.4", "kafkajs": "2.2.4",
"ldapts": "4.2.6", "ldapts": "4.2.6",
"lodash": "catalog:", "lodash": "catalog:",

View File

@@ -1,5 +1,6 @@
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import { readFileSync, readdirSync, mkdtempSync } from 'fs'; import { readFileSync, readdirSync, mkdtempSync } from 'fs';
import { mock } from 'jest-mock-extended';
import { get } from 'lodash'; import { get } from 'lodash';
import { isEmpty } from 'lodash'; import { isEmpty } from 'lodash';
import { BinaryDataService, constructExecutionMetaData } from 'n8n-core'; import { BinaryDataService, constructExecutionMetaData } from 'n8n-core';
@@ -36,7 +37,7 @@ export function createTemporaryDir(prefix = 'n8n') {
} }
export async function initBinaryDataService() { export async function initBinaryDataService() {
const binaryDataService = new BinaryDataService(); const binaryDataService = new BinaryDataService(mock(), mock());
await binaryDataService.init({ await binaryDataService.init({
mode: 'default', mode: 'default',
availableModes: ['default'], availableModes: ['default'],

View File

@@ -690,6 +690,7 @@ export interface BinaryHelperFunctions {
binaryToString(body: Buffer | Readable, encoding?: BufferEncoding): Promise<string>; binaryToString(body: Buffer | Readable, encoding?: BufferEncoding): Promise<string>;
getBinaryPath(binaryDataId: string): string; getBinaryPath(binaryDataId: string): string;
getBinaryStream(binaryDataId: string, chunkSize?: number): Promise<Readable>; getBinaryStream(binaryDataId: string, chunkSize?: number): Promise<Readable>;
createBinarySignedUrl(binaryData: IBinaryData, expiresIn?: string): string;
getBinaryMetadata(binaryDataId: string): Promise<{ getBinaryMetadata(binaryDataId: string): Promise<{
fileName?: string; fileName?: string;
mimeType?: string; mimeType?: string;

55
pnpm-lock.yaml generated
View File

@@ -18,6 +18,9 @@ catalogs:
'@types/express': '@types/express':
specifier: ^5.0.1 specifier: ^5.0.1
version: 5.0.1 version: 5.0.1
'@types/jsonwebtoken':
specifier: ^9.0.9
version: 9.0.9
'@types/lodash': '@types/lodash':
specifier: ^4.14.195 specifier: ^4.14.195
version: 4.14.195 version: 4.14.195
@@ -48,6 +51,9 @@ catalogs:
iconv-lite: iconv-lite:
specifier: 0.6.3 specifier: 0.6.3
version: 0.6.3 version: 0.6.3
jsonwebtoken:
specifier: 9.0.2
version: 9.0.2
lodash: lodash:
specifier: 4.17.21 specifier: 4.17.21
version: 4.17.21 version: 4.17.21
@@ -552,7 +558,7 @@ importers:
version: 3.666.0(@aws-sdk/client-sts@3.666.0) version: 3.666.0(@aws-sdk/client-sts@3.666.0)
'@getzep/zep-cloud': '@getzep/zep-cloud':
specifier: 1.0.12 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': '@getzep/zep-js':
specifier: 0.9.0 specifier: 0.9.0
version: 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) 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': '@langchain/community':
specifier: 0.3.24 specifier: 0.3.24
version: 0.3.24(c5fc7e11d6e6167a46cb8d3fd9b490a5) version: 0.3.24(bae0580ee8bea2ce19e4657a460c92d0)
'@langchain/core': '@langchain/core':
specifier: 'catalog:' specifier: 'catalog:'
version: 0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)) 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 version: 23.0.1
langchain: langchain:
specifier: 0.3.11 specifier: 0.3.11
version: 0.3.11(fd386e1130022c8548c06dd951c5cbf0) version: 0.3.11(d1e86e144e3517fab3dbb7a92ab7f45a)
lodash: lodash:
specifier: 'catalog:' specifier: 'catalog:'
version: 4.17.21 version: 4.17.21
@@ -1035,7 +1041,7 @@ importers:
specifier: 1.4.1 specifier: 1.4.1
version: 1.4.1 version: 1.4.1
jsonwebtoken: jsonwebtoken:
specifier: 9.0.2 specifier: 'catalog:'
version: 9.0.2 version: 9.0.2
ldapts: ldapts:
specifier: 4.2.6 specifier: 4.2.6
@@ -1195,8 +1201,8 @@ importers:
specifier: ^1.0.0 specifier: ^1.0.0
version: 1.0.0 version: 1.0.0
'@types/jsonwebtoken': '@types/jsonwebtoken':
specifier: ^9.0.6 specifier: 'catalog:'
version: 9.0.6 version: 9.0.9
'@types/lodash': '@types/lodash':
specifier: 'catalog:' specifier: 'catalog:'
version: 4.14.195 version: 4.14.195
@@ -1302,6 +1308,9 @@ importers:
iconv-lite: iconv-lite:
specifier: 'catalog:' specifier: 'catalog:'
version: 0.6.3 version: 0.6.3
jsonwebtoken:
specifier: 'catalog:'
version: 9.0.2
lodash: lodash:
specifier: 'catalog:' specifier: 'catalog:'
version: 4.17.21 version: 4.17.21
@@ -1360,6 +1369,9 @@ importers:
'@types/express': '@types/express':
specifier: 'catalog:' specifier: 'catalog:'
version: 5.0.1 version: 5.0.1
'@types/jsonwebtoken':
specifier: 'catalog:'
version: 9.0.9
'@types/lodash': '@types/lodash':
specifier: 'catalog:' specifier: 'catalog:'
version: 4.14.195 version: 4.14.195
@@ -2083,7 +2095,7 @@ importers:
specifier: 1.4.0 specifier: 1.4.0
version: 1.4.0 version: 1.4.0
jsonwebtoken: jsonwebtoken:
specifier: 9.0.2 specifier: 'catalog:'
version: 9.0.2 version: 9.0.2
kafkajs: kafkajs:
specifier: 2.2.4 specifier: 2.2.4
@@ -2231,8 +2243,8 @@ importers:
specifier: ^1.3.0 specifier: ^1.3.0
version: 1.3.0 version: 1.3.0
'@types/jsonwebtoken': '@types/jsonwebtoken':
specifier: ^9.0.6 specifier: 'catalog:'
version: 9.0.6 version: 9.0.9
'@types/lodash': '@types/lodash':
specifier: 'catalog:' specifier: 'catalog:'
version: 4.14.195 version: 4.14.195
@@ -6066,8 +6078,8 @@ packages:
'@types/jsonpath@0.2.0': '@types/jsonpath@0.2.0':
resolution: {integrity: sha512-v7qlPA0VpKUlEdhghbDqRoKMxFB3h3Ch688TApBJ6v+XLDdvWCGLJIYiPKGZnS6MAOie+IorCfNYVHOPIHSWwQ==} resolution: {integrity: sha512-v7qlPA0VpKUlEdhghbDqRoKMxFB3h3Ch688TApBJ6v+XLDdvWCGLJIYiPKGZnS6MAOie+IorCfNYVHOPIHSWwQ==}
'@types/jsonwebtoken@9.0.6': '@types/jsonwebtoken@9.0.9':
resolution: {integrity: sha512-/5hndP5dCjloafCXns6SZyESp3Ldq7YjH3zwzwczYnjxIT0Fqzk5ROSYVGfFyczIue7IUEj8hkvLbPoLQ18vQw==} resolution: {integrity: sha512-uoe+GxEuHbvy12OUQct2X9JenKM3qAscquYymuQN4fMWG9DBQtykrQEFcAbVACF7qaLw9BePSodUL0kquqBJpQ==}
'@types/k6@0.52.0': '@types/k6@0.52.0':
resolution: {integrity: sha512-yaw2wg61nKQtToDML+nngzgXVjZ6wNA4R0Q3jKDTeadG5EqfZgis5a1Q2hwY7kjuGuXmu8eM6gHg3tgnOj4vNw==} resolution: {integrity: sha512-yaw2wg61nKQtToDML+nngzgXVjZ6wNA4R0Q3jKDTeadG5EqfZgis5a1Q2hwY7kjuGuXmu8eM6gHg3tgnOj4vNw==}
@@ -16137,7 +16149,7 @@ snapshots:
'@gar/promisify@1.1.3': '@gar/promisify@1.1.3':
optional: true 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: dependencies:
form-data: 4.0.0 form-data: 4.0.0
node-fetch: 2.7.0(encoding@0.1.13) node-fetch: 2.7.0(encoding@0.1.13)
@@ -16146,7 +16158,7 @@ snapshots:
zod: 3.24.1 zod: 3.24.1
optionalDependencies: optionalDependencies:
'@langchain/core': 0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)) '@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: transitivePeerDependencies:
- encoding - encoding
@@ -16661,7 +16673,7 @@ snapshots:
- aws-crt - aws-crt
- encoding - encoding
'@langchain/community@0.3.24(c5fc7e11d6e6167a46cb8d3fd9b490a5)': '@langchain/community@0.3.24(bae0580ee8bea2ce19e4657a460c92d0)':
dependencies: 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) '@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 '@ibm-cloud/watsonx-ai': 1.1.2
@@ -16672,7 +16684,7 @@ snapshots:
flat: 5.0.2 flat: 5.0.2
ibm-cloud-sdk-core: 5.1.0 ibm-cloud-sdk-core: 5.1.0
js-yaml: 4.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)) 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) openai: 4.78.1(encoding@0.1.13)(zod@3.24.1)
uuid: 10.0.0 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) '@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) '@azure/storage-blob': 12.18.0(encoding@0.1.13)
'@browserbasehq/sdk': 2.0.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 '@getzep/zep-js': 0.9.0
'@google-ai/generativelanguage': 2.6.0(encoding@0.1.13) '@google-ai/generativelanguage': 2.6.0(encoding@0.1.13)
'@google-cloud/storage': 7.12.1(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/jsonpath@0.2.0': {}
'@types/jsonwebtoken@9.0.6': '@types/jsonwebtoken@9.0.9':
dependencies: dependencies:
'@types/ms': 0.7.34
'@types/node': 18.16.16 '@types/node': 18.16.16
'@types/k6@0.52.0': {} '@types/k6@0.52.0': {}
@@ -22878,7 +22891,7 @@ snapshots:
'@types/debug': 4.1.12 '@types/debug': 4.1.12
'@types/node': 18.16.16 '@types/node': 18.16.16
'@types/tough-cookie': 4.0.2 '@types/tough-cookie': 4.0.2
axios: 1.8.2(debug@4.4.0) axios: 1.8.2
camelcase: 6.3.0 camelcase: 6.3.0
debug: 4.4.0(supports-color@8.1.1) debug: 4.4.0(supports-color@8.1.1)
dotenv: 16.4.5 dotenv: 16.4.5
@@ -22888,7 +22901,7 @@ snapshots:
isstream: 0.1.2 isstream: 0.1.2
jsonwebtoken: 9.0.2 jsonwebtoken: 9.0.2
mime-types: 2.1.35 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 tough-cookie: 4.1.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -23882,7 +23895,7 @@ snapshots:
kuler@2.0.0: {} kuler@2.0.0: {}
langchain@0.3.11(fd386e1130022c8548c06dd951c5cbf0): langchain@0.3.11(d1e86e144e3517fab3dbb7a92ab7f45a):
dependencies: dependencies:
'@langchain/core': 0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)) '@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) '@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 onetime: 5.1.2
signal-exit: 3.0.7 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: dependencies:
axios: 1.8.2 axios: 1.8.2

View File

@@ -9,6 +9,7 @@ catalog:
'@sentry/node': 8.52.1 '@sentry/node': 8.52.1
'@types/basic-auth': ^1.1.3 '@types/basic-auth': ^1.1.3
'@types/express': ^5.0.1 '@types/express': ^5.0.1
'@types/jsonwebtoken': ^9.0.9
'@types/lodash': ^4.14.195 '@types/lodash': ^4.14.195
'@types/uuid': ^10.0.0 '@types/uuid': ^10.0.0
'@types/xml2js': ^0.4.14 '@types/xml2js': ^0.4.14
@@ -20,6 +21,7 @@ catalog:
flatted: 3.2.7 flatted: 3.2.7
form-data: 4.0.0 form-data: 4.0.0
iconv-lite: 0.6.3 iconv-lite: 0.6.3
jsonwebtoken: 9.0.2
lodash: 4.17.21 lodash: 4.17.21
luxon: 3.4.4 luxon: 3.4.4
nanoid: 3.3.8 nanoid: 3.3.8