diff --git a/packages/@n8n/api-types/src/dto/folders/__tests__/folder-public.dto.test.ts b/packages/@n8n/api-types/src/dto/folders/__tests__/folder-public.dto.test.ts index 98577b6a214..aa38ad62533 100644 --- a/packages/@n8n/api-types/src/dto/folders/__tests__/folder-public.dto.test.ts +++ b/packages/@n8n/api-types/src/dto/folders/__tests__/folder-public.dto.test.ts @@ -1,4 +1,5 @@ import { + CreateFolderPublicDto, FolderDetailsPublicDto, FolderListPublicDto, folderPublicSchema, @@ -201,3 +202,34 @@ describe('UpdatedFolderPublicDto', () => { expect(result.error?.errors[0].code).toBe('unrecognized_keys'); }); }); + +describe('CreateFolderPublicDto', () => { + test('accepts a name on its own', () => { + expect(CreateFolderPublicDto.safeParse({ name: 'My Folder' }).success).toBe(true); + }); + + test('accepts a name and a parentFolderId', () => { + const result = CreateFolderPublicDto.safeParse({ name: 'Child', parentFolderId: 'abc123' }); + + expect(result.success).toBe(true); + expect(result.data).toEqual({ name: 'Child', parentFolderId: 'abc123' }); + }); + + test.each([ + ['a missing name', {}, ['name']], + ['an empty name', { name: '' }, ['name']], + ['a name with an illegal character', { name: 'a/b' }, ['name']], + ['a name over 128 characters', { name: 'a'.repeat(129) }, ['name']], + [ + 'a parentFolderId over 36 characters', + { name: 'a', parentFolderId: 'b'.repeat(37) }, + ['parentFolderId'], + ], + ['an unknown key', { name: 'My Folder', tags: [] }, []], + ])('rejects %s', (_label, payload, path) => { + const result = CreateFolderPublicDto.safeParse(payload); + + expect(result.success).toBe(false); + expect(result.error?.issues[0].path).toEqual(path); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/folders/folder-public.dto.ts b/packages/@n8n/api-types/src/dto/folders/folder-public.dto.ts index 990ebd83b5c..d6068490b11 100644 --- a/packages/@n8n/api-types/src/dto/folders/folder-public.dto.ts +++ b/packages/@n8n/api-types/src/dto/folders/folder-public.dto.ts @@ -10,6 +10,7 @@ import { folderListQueryFieldDocs, folderProjectFieldDocs, folderProjectIconOpenApi, + folderProjectIdParamDocs, updateFolderFieldDocs, } from './folder-public.openapi'; import { @@ -19,11 +20,18 @@ import { takeValidator, VALID_SORT_OPTIONS, } from './list-folder-query.dto'; -import { folderIdSchema, folderNameSchema } from '../../schemas/folder.schema'; +import { + FOLDER_NAME_MAX_LENGTH, + folderIdSchema, + folderNameSchema, +} from '../../schemas/folder.schema'; import { nullableObjectGuardSchema } from '../../schemas/object-guard.schema'; import { projectTypeSchema, type ProjectIcon } from '../../schemas/project.schema'; import { Z } from '../../zod-class'; +/** `personal` is accepted next to a real id, so this cannot reuse `projectIdParamSchema`. */ +export const folderProjectIdParamSchema = z.string().openapi(folderProjectIdParamDocs); + const folderProjectPublicSchema = z.object({ id: z.string().openapi(folderProjectFieldDocs.id), name: z.string().openapi(folderProjectFieldDocs.name), @@ -101,7 +109,10 @@ export class FolderDetailsPublicDto extends Z.class({ const updateFolderPublicSchema = z .object({ - name: folderNameSchema.optional().openapi(updateFolderFieldDocs.name), + name: folderNameSchema.optional().openapi({ + ...updateFolderFieldDocs.name, + maxLength: FOLDER_NAME_MAX_LENGTH, + }), parentFolderId: folderIdSchema.optional().openapi(updateFolderFieldDocs.parentFolderId), }) .strict() @@ -141,3 +152,17 @@ export class DeleteFolderQueryPublicDto extends Z.class( }, { strict: true }, ) {} + +export class CreateFolderPublicDto extends Z.class( + { + name: folderNameSchema.openapi({ + ...folderFieldDocs.name, + maxLength: FOLDER_NAME_MAX_LENGTH, + }), + parentFolderId: folderIdSchema.optional().openapi(folderFieldDocs.parentFolderId), + }, + { strict: true }, +) {} + +// Own class so create does not share UpdatedFolderPublicDto's OpenAPI component. +export class CreatedFolderPublicDto extends Z.class(folderCorePublicShape, { strict: true }) {} diff --git a/packages/@n8n/api-types/src/dto/folders/folder-public.openapi.ts b/packages/@n8n/api-types/src/dto/folders/folder-public.openapi.ts index d07645e236a..aa8b489c532 100644 --- a/packages/@n8n/api-types/src/dto/folders/folder-public.openapi.ts +++ b/packages/@n8n/api-types/src/dto/folders/folder-public.openapi.ts @@ -77,3 +77,11 @@ export const deleteFolderQueryFieldDocs = { 'Optional target folder ID to move workflows and sub-folders into before deleting.', }, } as const satisfies Record; + +export const folderProjectIdParamDocs = { + param: { + description: + "The ID of the project, or `personal` to create the folder in the calling user's own " + + 'personal project.', + }, +} as const satisfies ZodOpenAPIMetadata; diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index d9a3cedab44..f5ec88aa88f 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -274,14 +274,17 @@ export { DeleteFolderDto } from './folders/delete-folder.dto'; export { ListFolderQueryDto } from './folders/list-folder-query.dto'; export { TransferFolderBodyDto } from './folders/transfer-folder.dto'; export { + CreateFolderPublicDto, + CreatedFolderPublicDto, DeleteFolderQueryPublicDto, + FolderDetailsPublicDto, FolderListPublicDto, FolderPublicDto, - FolderDetailsPublicDto, ListFoldersQueryPublicDto, - folderPublicSchema, UpdateFolderPublicDto, UpdatedFolderPublicDto, + folderProjectIdParamSchema, + folderPublicSchema, } from './folders/folder-public.dto'; export type { FolderPublic } from './folders/folder-public.dto'; diff --git a/packages/@n8n/api-types/src/schemas/folder.schema.ts b/packages/@n8n/api-types/src/schemas/folder.schema.ts index 879460ac5c6..098abd962bb 100644 --- a/packages/@n8n/api-types/src/schemas/folder.schema.ts +++ b/packages/@n8n/api-types/src/schemas/folder.schema.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; const illegalCharacterRegex = /[[\]^\\/:*?"<>|]/; const dotsOnlyRegex = /^\.+$/; -const FOLDER_NAME_MAX_LENGTH = 128; +export const FOLDER_NAME_MAX_LENGTH = 128; export const folderNameSchema = z .string() diff --git a/packages/cli/eslint.config.mjs b/packages/cli/eslint.config.mjs index fba891470fa..65468c694bf 100644 --- a/packages/cli/eslint.config.mjs +++ b/packages/cli/eslint.config.mjs @@ -156,7 +156,6 @@ export default defineConfig( './src/public-api/v1/handlers/data-tables/data-tables.handler.ts', './src/public-api/v1/handlers/data-tables/data-tables.rows.handler.ts', './src/public-api/v1/handlers/evaluations/evaluations.handler.ts', - './src/public-api/v1/handlers/folders/folders.handler.ts', './src/public-api/v1/handlers/ldap/ldap.handler.ts', './src/public-api/v1/handlers/log-streaming/log-streaming.handler.ts', './src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts', diff --git a/packages/cli/oxlint.config.mts b/packages/cli/oxlint.config.mts index 434380ca284..9578c1b9cab 100644 --- a/packages/cli/oxlint.config.mts +++ b/packages/cli/oxlint.config.mts @@ -161,7 +161,6 @@ export default defineConfig({ './src/public-api/v1/handlers/data-tables/data-tables.rows.handler.ts', './src/public-api/v1/handlers/discover/discover.handler.ts', './src/public-api/v1/handlers/evaluations/evaluations.handler.ts', - './src/public-api/v1/handlers/folders/folders.handler.ts', './src/public-api/v1/handlers/insights/insights.handler.ts', './src/public-api/v1/handlers/ldap/ldap.handler.ts', './src/public-api/v1/handlers/log-streaming/log-streaming.handler.ts', diff --git a/packages/cli/src/public-api/v1/controllers/folders.public.controller.ts b/packages/cli/src/public-api/v1/controllers/folders.public.controller.ts index 64ccc9627e1..ad9812c4272 100644 --- a/packages/cli/src/public-api/v1/controllers/folders.public.controller.ts +++ b/packages/cli/src/public-api/v1/controllers/folders.public.controller.ts @@ -1,4 +1,6 @@ import { + CreateFolderPublicDto, + CreatedFolderPublicDto, DeleteFolderQueryPublicDto, FolderDetailsPublicDto, FolderListPublicDto, @@ -7,6 +9,7 @@ import { UpdateFolderPublicDto, UpdatedFolderPublicDto, folderIdParamSchema, + folderProjectIdParamSchema, projectIdParamSchema, } from '@n8n/api-types'; import { LICENSE_FEATURES } from '@n8n/constants'; @@ -28,6 +31,7 @@ import { Licensed, Param, Patch, + Post, PublicApiController, Query, } from '@n8n/decorators'; @@ -39,9 +43,12 @@ import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { assertProjectScope } from '@/public-api/v1/shared/services/utils.service'; import { FolderService } from '@/services/folder.service'; +import { ProjectService } from '@/services/project.service.ee'; const tags = ['Folders']; +const PERSONAL_PROJECT_ALIAS = 'personal'; + /** * The `select` query parameter decides which columns the query loads, so a field the caller did not * ask for is left `undefined` and drops out of the JSON body. @@ -91,6 +98,16 @@ const toUpdatedFolderPublicDto = (folder: Folder): UpdatedFolderPublicDto => ({ updatedAt: folder.updatedAt.toISOString(), }); +type CreatedFolder = Omit; + +const toCreatedFolderPublicDto = (folder: CreatedFolder): CreatedFolderPublicDto => ({ + id: folder.id, + name: folder.name, + parentFolderId: folder.parentFolderId ?? folder.parentFolder?.id ?? null, + createdAt: folder.createdAt.toISOString(), + updatedAt: folder.updatedAt.toISOString(), +}); + const handleError = (error: unknown): never => { if (error instanceof FolderNotFoundError) { throw new NotFoundError(error.message); @@ -104,7 +121,10 @@ const handleError = (error: unknown): never => { @PublicApiController('/projects/:projectId/folders') export class FoldersPublicController { - constructor(private readonly folderService: FolderService) {} + constructor( + private readonly folderService: FolderService, + private readonly projectService: ProjectService, + ) {} @Get('/') @Licensed(LICENSE_FEATURES.FOLDERS) @@ -130,6 +150,34 @@ export class FoldersPublicController { return { count, data: data.map(toFolderPublicDto) }; } + @Post('/') + @Licensed(LICENSE_FEATURES.FOLDERS) + @ApiKeyScope('folder:create') + @ApiSummary('Create a folder') + @ApiDescription('Create a folder within a project.') + @ApiTags(tags) + @ApiResponse(201, CreatedFolderPublicDto) + @ApiErrorResponse(404) + async createFolder( + req: AuthenticatedRequest, + _res: Response, + @Param('projectId', folderProjectIdParamSchema) projectId: string, + @Body body: CreateFolderPublicDto, + ): Promise { + const resolvedProjectId = await this.resolveProjectId(req, projectId); + + // A 404 for an unknown project, so `@ProjectScope` (which answers 403) cannot be used here. + await assertProjectScope(req.user, resolvedProjectId, ['folder:create']); + + try { + return toCreatedFolderPublicDto( + await this.folderService.createFolder(body, resolvedProjectId), + ); + } catch (error) { + return handleError(error); + } + } + @Get('/:folderId') @ApiKeyScope('folder:read') @Licensed(LICENSE_FEATURES.FOLDERS) @@ -211,4 +259,15 @@ export class FoldersPublicController { return handleError(error); } } + + private async resolveProjectId(req: AuthenticatedRequest, projectId: string): Promise { + if (projectId !== PERSONAL_PROJECT_ALIAS) return projectId; + + const personalProject = await this.projectService.getPersonalProject(req.user); + if (!personalProject) { + throw new NotFoundError('Could not find a personal project for this user'); + } + + return personalProject.id; + } } diff --git a/packages/cli/src/public-api/v1/handlers/folders/folders.handler.ts b/packages/cli/src/public-api/v1/handlers/folders/folders.handler.ts deleted file mode 100644 index 085a6c188b2..00000000000 --- a/packages/cli/src/public-api/v1/handlers/folders/folders.handler.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { CreateFolderDto } from '@n8n/api-types'; -import type { AuthenticatedRequest } from '@n8n/db'; -import { Container } from '@n8n/di'; -import { UserError } from 'n8n-workflow'; - -import { FolderNotFoundError } from '@/errors/folder-not-found.error'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { FolderService } from '@/services/folder.service'; -import { ProjectService } from '@/services/project.service.ee'; - -import type { PublicAPIEndpoint } from '../../shared/handler.types'; -import { - apiKeyHasScopeWithGlobalScopeFallback, - isLicensed, -} from '../../shared/middlewares/global.middleware'; -import { assertProjectScope } from '../../shared/services/utils.service'; - -const PERSONAL_PROJECT_ALIAS = 'personal'; - -const handleError = (error: unknown) => { - if (error instanceof FolderNotFoundError) { - throw new NotFoundError(error.message); - } - if (error instanceof UserError) { - throw new BadRequestError(error.message); - } - - throw error; -}; - -type FolderHandlers = { - createFolder: PublicAPIEndpoint>; -}; - -const folderHandlers: FolderHandlers = { - createFolder: [ - isLicensed('feat:folders'), - apiKeyHasScopeWithGlobalScopeFallback({ scope: 'folder:create' }), - async (req, res) => { - let { projectId } = req.params; - if (projectId === PERSONAL_PROJECT_ALIAS) { - const personalProject = await Container.get(ProjectService).getPersonalProject(req.user); - if (!personalProject) { - throw new NotFoundError('Could not find a personal project for this user'); - } - projectId = personalProject.id; - } - - await assertProjectScope(req.user, projectId, ['folder:create']); - - const payload = CreateFolderDto.safeParse(req.body); - if (payload.error) { - throw new BadRequestError(payload.error.errors[0].message); - } - - try { - const folder = await Container.get(FolderService).createFolder(payload.data, projectId); - return res.status(201).json(folder); - } catch (error) { - return handleError(error); - } - }, - ], -}; - -export = folderHandlers; diff --git a/packages/cli/src/public-api/v1/handlers/folders/spec/paths/folders.yml b/packages/cli/src/public-api/v1/handlers/folders/spec/paths/folders.yml deleted file mode 100644 index a0b4a4f5e12..00000000000 --- a/packages/cli/src/public-api/v1/handlers/folders/spec/paths/folders.yml +++ /dev/null @@ -1,39 +0,0 @@ -post: - x-eov-operation-id: createFolder - x-required-scope: folder:create - x-eov-operation-handler: v1/handlers/folders/folders.handler - tags: - - Folders - summary: Create a folder - description: Create a folder within a project. - parameters: - - name: projectId - in: path - description: >- - The ID of the project, or `personal` to create the folder in the - calling user's own personal project. - required: true - schema: - type: string - requestBody: - description: Payload for folder to create. - content: - application/json: - schema: - $ref: '../schemas/folder.create.yml' - required: true - responses: - '201': - description: Operation successful. - content: - application/json: - schema: - $ref: '../schemas/folder.yml' - '400': - $ref: '../../../../shared/spec/responses/badRequest.yml' - '401': - $ref: '../../../../shared/spec/responses/unauthorized.yml' - '403': - $ref: '../../../../shared/spec/responses/forbidden.yml' - '404': - $ref: '../../../../shared/spec/responses/notFound.yml' diff --git a/packages/cli/src/public-api/v1/handlers/folders/spec/schemas/folder.create.yml b/packages/cli/src/public-api/v1/handlers/folders/spec/schemas/folder.create.yml deleted file mode 100644 index 74c47d8378f..00000000000 --- a/packages/cli/src/public-api/v1/handlers/folders/spec/schemas/folder.create.yml +++ /dev/null @@ -1,11 +0,0 @@ -type: object -additionalProperties: false -required: - - name -properties: - name: - type: string - example: My Folder - parentFolderId: - type: string - example: abc123 diff --git a/packages/cli/src/public-api/v1/handlers/folders/spec/schemas/folder.yml b/packages/cli/src/public-api/v1/handlers/folders/spec/schemas/folder.yml deleted file mode 100644 index 2518c2705b8..00000000000 --- a/packages/cli/src/public-api/v1/handlers/folders/spec/schemas/folder.yml +++ /dev/null @@ -1,20 +0,0 @@ -type: object -additionalProperties: false -properties: - id: - type: string - readOnly: true - name: - type: string - example: My Folder - parentFolderId: - type: string - nullable: true - createdAt: - type: string - format: date-time - readOnly: true - updatedAt: - type: string - format: date-time - readOnly: true diff --git a/packages/cli/src/public-api/v1/handlers/projects/spec/paths/createFolder.generated.yml b/packages/cli/src/public-api/v1/handlers/projects/spec/paths/createFolder.generated.yml new file mode 100644 index 00000000000..4b344d0f52e --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/projects/spec/paths/createFolder.generated.yml @@ -0,0 +1,78 @@ +operationId: createFolder +tags: + - Folders +summary: Create a folder +description: Create a folder within a project. +x-required-scope: folder:create +x-eov-operation-id: unreachable +x-eov-operation-handler: v1/handlers/decorator-routed.handler +x-decorator-routed: true +parameters: + - schema: + type: string + required: true + description: The ID of the project, or `personal` to create the folder in the calling user's own personal project. + name: projectId + in: path +requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + maxLength: 128 + example: My Folder + parentFolderId: + type: string + maxLength: 36 + example: kRZ0ewrAEAhCkTBd + required: + - name + additionalProperties: false +responses: + '201': + description: Operation successful. + content: + application/json: + schema: + type: object + properties: + id: + type: string + readOnly: true + example: zLXWgYyZrZ4Gn1Vk + name: + type: string + example: My Folder + parentFolderId: + type: string + nullable: true + example: kRZ0ewrAEAhCkTBd + createdAt: + type: string + format: date-time + readOnly: true + updatedAt: + type: string + format: date-time + readOnly: true + required: + - id + - name + - parentFolderId + - createdAt + - updatedAt + additionalProperties: false + '400': + $ref: ../../../../shared/spec/responses/badRequest.yml + '401': + $ref: ../../../../shared/spec/responses/unauthorized.yml + '403': + $ref: ../../../../shared/spec/responses/forbidden.yml + '404': + $ref: ../../../../shared/spec/responses/notFound.yml + '415': + $ref: ../../../../shared/spec/responses/unsupportedMediaType.yml diff --git a/packages/cli/src/public-api/v1/handlers/projects/spec/paths/updateFolder.generated.yml b/packages/cli/src/public-api/v1/handlers/projects/spec/paths/updateFolder.generated.yml index 7ed3b54056b..9ee2479d8f8 100644 --- a/packages/cli/src/public-api/v1/handlers/projects/spec/paths/updateFolder.generated.yml +++ b/packages/cli/src/public-api/v1/handlers/projects/spec/paths/updateFolder.generated.yml @@ -30,6 +30,7 @@ requestBody: name: type: string example: Renamed Folder + maxLength: 128 parentFolderId: type: string description: ID of the parent folder to move this folder into. Set to "0" to move the folder to the project root. diff --git a/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml b/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml index dd315d0db2b..3480c7f05e9 100644 --- a/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml +++ b/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml @@ -57,6 +57,8 @@ paths: /projects/{projectId}/folders: get: $ref: ./handlers/projects/spec/paths/listFolders.generated.yml + post: + $ref: ./handlers/projects/spec/paths/createFolder.generated.yml /projects/{projectId}/folders/{folderId}: get: $ref: ./handlers/projects/spec/paths/getFolder.generated.yml diff --git a/packages/cli/src/public-api/v1/openapi.yml b/packages/cli/src/public-api/v1/openapi.yml index 56ebf8ecdb0..c857cf5f6b6 100644 --- a/packages/cli/src/public-api/v1/openapi.yml +++ b/packages/cli/src/public-api/v1/openapi.yml @@ -123,8 +123,6 @@ paths: $ref: './handlers/community-packages/spec/paths/community-packages.yml' /community-packages/{name}: $ref: './handlers/community-packages/spec/paths/community-packages.packageName.yml' - /projects/{projectId}/folders: - $ref: './handlers/folders/spec/paths/folders.yml' /n8n-packages/export: $ref: './handlers/n8n-packages/spec/paths/n8n-packages.export.yml' /n8n-packages/import: diff --git a/packages/cli/test/integration/public-api/folders.test.ts b/packages/cli/test/integration/public-api/folders.test.ts index 7fefdfc5a44..ce0b74f3558 100644 --- a/packages/cli/test/integration/public-api/folders.test.ts +++ b/packages/cli/test/integration/public-api/folders.test.ts @@ -132,6 +132,30 @@ describe('POST /projects/:projectId/folders', () => { .send({}); expect(response.statusCode).toBe(400); + expect(response.body).toEqual({ + message: "request/body must have required property 'name'", + }); + }); + + test('should return 400 when folder name is empty', async () => { + testServer.license.enable('feat:folders'); + + const response = await authOwnerAgent + .post(`/projects/${ownerPersonalProject.id}/folders`) + .send({ name: '' }); + + expect(response.statusCode).toBe(400); + expect(response.body.message).toContain('Folder name cannot be empty'); + }); + + test('should return 400 when the body carries an unknown key', async () => { + testServer.license.enable('feat:folders'); + + const response = await authOwnerAgent + .post(`/projects/${ownerPersonalProject.id}/folders`) + .send({ name: 'Folder', unknownKey: 'nope' }); + + expect(response.statusCode).toBe(400); }); test('should return 404 when parentFolderId is invalid', async () => { @@ -162,8 +186,13 @@ describe('POST /projects/:projectId/folders', () => { .send({ name: 'My Folder' }); expect(response.statusCode).toBe(201); - expect(response.body).toHaveProperty('id'); - expect(response.body).toHaveProperty('name', 'My Folder'); + expect(response.body).toStrictEqual({ + id: expect.any(String), + name: 'My Folder', + parentFolderId: null, + createdAt: expect.stringMatching(ISO_DATE_TIME), + updatedAt: expect.stringMatching(ISO_DATE_TIME), + }); }); test('should create a folder with parentFolderId', async () => { @@ -177,6 +206,7 @@ describe('POST /projects/:projectId/folders', () => { expect(response.statusCode).toBe(201); expect(response.body).toHaveProperty('name', 'Child'); + expect(response.body).toHaveProperty('parentFolderId', parentFolder.id); }); test('should return 500 when createFolder throws an unexpected error', async () => { diff --git a/scripts/lint-parity/samples.json b/scripts/lint-parity/samples.json index abb04ac4ffd..92e779bf8b4 100644 --- a/scripts/lint-parity/samples.json +++ b/scripts/lint-parity/samples.json @@ -240,7 +240,6 @@ "src/public-api/v1/handlers/data-tables/data-tables.rows.handler.ts", "src/public-api/v1/handlers/discover/discover.handler.ts", "src/public-api/v1/handlers/evaluations/evaluations.handler.ts", - "src/public-api/v1/handlers/folders/folders.handler.ts", "src/public-api/v1/handlers/insights/insights.handler.ts", "src/public-api/v1/handlers/ldap/ldap.handler.ts", "src/public-api/v1/handlers/log-streaming/log-streaming.handler.ts",