1
1
mirror of https://github.com/n8n-io/n8n.git synced 2024-10-26 13:29:14 +03:00

feat(core): Allow transferring workflows from any project to any team project (#9534)

This commit is contained in:
Danny Martini 2024-06-03 16:57:04 +02:00 committed by GitHub
parent 68420ca6be
commit d6db8cbf23
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 572 additions and 29 deletions

View File

@ -35,7 +35,7 @@ export type CommunityPackageScope = ResourceScope<
'communityPackage',
'install' | 'uninstall' | 'update' | 'list' | 'manage'
>;
export type CredentialScope = ResourceScope<'credential', DefaultOperations | 'share'>;
export type CredentialScope = ResourceScope<'credential', DefaultOperations | 'share' | 'move'>;
export type ExternalSecretScope = ResourceScope<'externalSecret', 'list' | 'use'>;
export type ExternalSecretProviderScope = ResourceScope<
'externalSecretsProvider',
@ -58,7 +58,10 @@ export type TagScope = ResourceScope<'tag'>;
export type UserScope = ResourceScope<'user', DefaultOperations | 'resetPassword' | 'changeRole'>;
export type VariableScope = ResourceScope<'variable'>;
export type WorkersViewScope = ResourceScope<'workersView', 'manage'>;
export type WorkflowScope = ResourceScope<'workflow', DefaultOperations | 'share' | 'execute'>;
export type WorkflowScope = ResourceScope<
'workflow',
DefaultOperations | 'share' | 'execute' | 'move'
>;
export type Scope =
| AuditLogsScope

View File

@ -1,6 +1,16 @@
import { ResponseError } from './abstract/response.error';
export class NotFoundError extends ResponseError {
static isDefinedAndNotNull<T>(
value: T | undefined | null,
message: string,
hint?: string,
): asserts value is T {
if (value === undefined || value === null) {
throw new NotFoundError(message, hint);
}
}
constructor(message: string, hint: string | undefined = undefined) {
super(message, 404, 404, hint);
}

View File

@ -0,0 +1,7 @@
import { ResponseError } from './abstract/response.error';
export class TransferWorkflowError extends ResponseError {
constructor(message: string) {
super(message, 400, 400);
}
}

View File

@ -9,6 +9,7 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [
'credential:delete',
'credential:list',
'credential:share',
'credential:move',
'communityPackage:install',
'communityPackage:uninstall',
'communityPackage:update',
@ -68,6 +69,7 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [
'workflow:list',
'workflow:share',
'workflow:execute',
'workflow:move',
'workersView:manage',
'project:list',
'project:create',

View File

@ -13,11 +13,13 @@ export const REGULAR_PROJECT_ADMIN_SCOPES: Scope[] = [
'workflow:delete',
'workflow:list',
'workflow:execute',
'workflow:move',
'credential:create',
'credential:read',
'credential:update',
'credential:delete',
'credential:list',
'credential:move',
'project:list',
'project:read',
'project:update',
@ -32,12 +34,14 @@ export const PERSONAL_PROJECT_OWNER_SCOPES: Scope[] = [
'workflow:list',
'workflow:execute',
'workflow:share',
'workflow:move',
'credential:create',
'credential:read',
'credential:update',
'credential:delete',
'credential:list',
'credential:share',
'credential:move',
'project:list',
'project:read',
];

View File

@ -5,6 +5,7 @@ export const CREDENTIALS_SHARING_OWNER_SCOPES: Scope[] = [
'credential:update',
'credential:delete',
'credential:share',
'credential:move',
];
export const CREDENTIALS_SHARING_USER_SCOPES: Scope[] = ['credential:read'];
@ -15,6 +16,7 @@ export const WORKFLOW_SHARING_OWNER_SCOPES: Scope[] = [
'workflow:delete',
'workflow:execute',
'workflow:share',
'workflow:move',
];
export const WORKFLOW_SHARING_EDITOR_SCOPES: Scope[] = [

View File

@ -54,5 +54,11 @@ export declare namespace WorkflowRequest {
type Share = AuthenticatedRequest<{ workflowId: string }, {}, { shareWithIds: string[] }>;
type Transfer = AuthenticatedRequest<
{ workflowId: string },
{},
{ destinationProjectId: string }
>;
type FromUrl = AuthenticatedRequest<{}, {}, {}, { url?: string }>;
}

View File

@ -1,6 +1,6 @@
import { Service } from 'typedi';
import omit from 'lodash/omit';
import { ApplicationError, NodeOperationError } from 'n8n-workflow';
import { ApplicationError, NodeOperationError, WorkflowActivationError } from 'n8n-workflow';
import type { CredentialsEntity } from '@db/entities/CredentialsEntity';
import type { User } from '@db/entities/User';
@ -20,6 +20,10 @@ import type {
import { OwnershipService } from '@/services/ownership.service';
import { In, type EntityManager } from '@n8n/typeorm';
import { Project } from '@/databases/entities/Project';
import { ProjectService } from '@/services/project.service';
import { ActiveWorkflowManager } from '@/ActiveWorkflowManager';
import { TransferWorkflowError } from '@/errors/response-errors/transfer-workflow.error';
import { SharedWorkflow } from '@/databases/entities/SharedWorkflow';
@Service()
export class EnterpriseWorkflowService {
@ -30,6 +34,8 @@ export class EnterpriseWorkflowService {
private readonly credentialsRepository: CredentialsRepository,
private readonly credentialsService: CredentialsService,
private readonly ownershipService: OwnershipService,
private readonly projectService: ProjectService,
private readonly activeWorkflowManager: ActiveWorkflowManager,
) {}
async shareWithProjects(
@ -235,4 +241,100 @@ export class EnterpriseWorkflowService {
);
});
}
async transferOne(user: User, workflowId: string, destinationProjectId: string) {
// 1. get workflow
const workflow = await this.sharedWorkflowRepository.findWorkflowForUser(workflowId, user, [
'workflow:move',
]);
NotFoundError.isDefinedAndNotNull(
workflow,
`Could not find workflow with the id "${workflowId}". Make sure you have the permission to delete it.`,
);
// 2. get owner-sharing
const ownerSharing = workflow.shared.find((s) => s.role === 'workflow:owner')!;
NotFoundError.isDefinedAndNotNull(
ownerSharing,
`Could not find owner for workflow ${workflow.id}`,
);
// 3. get source project
const sourceProject = ownerSharing.project;
// 4. get destination project
const destinationProject = await this.projectService.getProjectWithScope(
user,
destinationProjectId,
['workflow:create'],
);
NotFoundError.isDefinedAndNotNull(
destinationProject,
`Could not find project with the id "${destinationProjectId}". Make sure you have the permission to create workflows in it.`,
);
// 5. checks
if (sourceProject.id === destinationProject.id) {
throw new TransferWorkflowError(
"You can't transfer a workflow into the project that's already owning it.",
);
}
if (sourceProject.type !== 'team' && sourceProject.type !== 'personal') {
throw new TransferWorkflowError(
'You can only transfer workflows out of personal or team projects.',
);
}
if (destinationProject.type !== 'team') {
throw new TransferWorkflowError('You can only transfer workflows into team projects.');
}
// 6. deactivate workflow if necessary
const wasActive = workflow.active;
if (wasActive) {
await this.activeWorkflowManager.remove(workflowId);
}
// 7. transfer the workflow
await this.workflowRepository.manager.transaction(async (trx) => {
// remove all sharings
await trx.remove(workflow.shared);
// create new owner-sharing
await trx.save(
trx.create(SharedWorkflow, {
workflowId: workflow.id,
projectId: destinationProject.id,
role: 'workflow:owner',
}),
);
});
// 8. try to activate it again if it was active
if (wasActive) {
try {
await this.activeWorkflowManager.add(workflowId, 'update');
return;
} catch (error) {
await this.workflowRepository.updateActiveState(workflowId, false);
// Since the transfer worked we return a 200 but also return the
// activation error as data.
if (error instanceof WorkflowActivationError) {
return {
error: error.toJSON
? error.toJSON()
: {
name: error.name,
message: error.message,
},
};
}
throw error;
}
}
return;
}
}

View File

@ -40,6 +40,7 @@ import { ApplicationError } from 'n8n-workflow';
import { In, type FindOptionsRelations } from '@n8n/typeorm';
import type { Project } from '@/databases/entities/Project';
import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository';
import { z } from 'zod';
@RestController('/workflows')
export class WorkflowsController {
@ -460,4 +461,16 @@ export class WorkflowsController {
workflow,
});
}
@Put('/:workflowId/transfer')
@ProjectScope('workflow:move')
async transfer(req: WorkflowRequest.Transfer) {
const body = z.object({ destinationProjectId: z.string() }).parse(req.body);
return await this.enterpriseWorkflowService.transferOne(
req.user,
req.params.workflowId,
body.destinationProjectId,
);
}
}

View File

@ -142,7 +142,7 @@ describe('GET /credentials', () => {
// Team cred
expect(cred1.id).toBe(savedCredential1.id);
expect(cred1.scopes).toEqual(
['credential:read', 'credential:update', 'credential:delete'].sort(),
['credential:move', 'credential:read', 'credential:update', 'credential:delete'].sort(),
);
// Shared cred
@ -169,7 +169,13 @@ describe('GET /credentials', () => {
// Shared cred
expect(cred2.id).toBe(savedCredential2.id);
expect(cred2.scopes).toEqual(
['credential:read', 'credential:update', 'credential:delete', 'credential:share'].sort(),
[
'credential:delete',
'credential:move',
'credential:read',
'credential:share',
'credential:update',
].sort(),
);
}
@ -188,11 +194,12 @@ describe('GET /credentials', () => {
expect(cred1.scopes).toEqual(
[
'credential:create',
'credential:read',
'credential:update',
'credential:delete',
'credential:list',
'credential:move',
'credential:read',
'credential:share',
'credential:update',
].sort(),
);
@ -201,11 +208,12 @@ describe('GET /credentials', () => {
expect(cred2.scopes).toEqual(
[
'credential:create',
'credential:read',
'credential:update',
'credential:delete',
'credential:list',
'credential:move',
'credential:read',
'credential:share',
'credential:update',
].sort(),
);
}
@ -573,7 +581,13 @@ describe('POST /credentials', () => {
expect(encryptedData).not.toBe(payload.data);
expect(scopes).toEqual(
['credential:read', 'credential:update', 'credential:delete', 'credential:share'].sort(),
[
'credential:delete',
'credential:move',
'credential:read',
'credential:share',
'credential:update',
].sort(),
);
const credential = await Container.get(CredentialsRepository).findOneByOrFail({ id });
@ -816,11 +830,12 @@ describe('PATCH /credentials/:id', () => {
expect(scopes).toEqual(
[
'credential:create',
'credential:read',
'credential:update',
'credential:delete',
'credential:list',
'credential:move',
'credential:read',
'credential:share',
'credential:update',
].sort(),
);

View File

@ -0,0 +1,58 @@
import type { User } from '@db/entities/User';
import * as utils from '../shared/utils/';
import * as testDb from '../shared/testDb';
import { createUser } from '../shared/db/users';
import { createWorkflowWithTrigger } from '../shared/db/workflows';
import { createTeamProject } from '../shared/db/projects';
import { mockInstance } from '../../shared/mocking';
import { WaitTracker } from '@/WaitTracker';
let member: User;
let anotherMember: User;
const testServer = utils.setupTestServer({
endpointGroups: ['workflows'],
enabledFeatures: ['feat:sharing', 'feat:advancedPermissions'],
});
// This is necessary for the tests to shutdown cleanly.
mockInstance(WaitTracker);
beforeAll(async () => {
member = await createUser({ role: 'global:member' });
anotherMember = await createUser({ role: 'global:member' });
await utils.initNodeTypes();
});
beforeEach(async () => {
await testDb.truncate(['Workflow', 'SharedWorkflow']);
});
describe('PUT /:workflowId/transfer', () => {
// This tests does not mock the ActiveWorkflowManager, which helps catching
// possible deadlocks when using transactions wrong.
test('can transfer an active workflow', async () => {
//
// ARRANGE
//
const destinationProject = await createTeamProject('Team Project', member);
const workflow = await createWorkflowWithTrigger({ active: true }, member);
//
// ACT
//
const response = await testServer
.authAgentFor(member)
.put(`/workflows/${workflow.id}/transfer`)
.send({ destinationProjectId: destinationProject.id })
.expect(200);
//
// ASSERT
//
expect(response.body).toEqual({});
});
});

View File

@ -1,6 +1,6 @@
import Container from 'typedi';
import { v4 as uuid } from 'uuid';
import type { INode } from 'n8n-workflow';
import { ApplicationError, WorkflowActivationError, type INode } from 'n8n-workflow';
import config from '@/config';
import type { Project } from '@db/entities/Project';
@ -19,12 +19,15 @@ import type { SaveCredentialFunction } from '../shared/types';
import { makeWorkflow } from '../shared/utils/';
import { randomCredentialPayload } from '../shared/random';
import { affixRoleToSaveCredential, shareCredentialWithUsers } from '../shared/db/credentials';
import { createUser, createUserShell } from '../shared/db/users';
import { createAdmin, createOwner, createUser, createUserShell } from '../shared/db/users';
import { createWorkflow, getWorkflowSharing, shareWorkflowWithUsers } from '../shared/db/workflows';
import { createTag } from '../shared/db/tags';
import type { SuperAgentTest } from '../shared/types';
import { createTeamProject, linkUserToProject } from '../shared/db/projects';
import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
let owner: User;
let admin: User;
let ownerPersonalProject: Project;
let member: User;
let memberPersonalProject: Project;
@ -36,21 +39,24 @@ let authAnotherMemberAgent: SuperAgentTest;
let saveCredential: SaveCredentialFunction;
let projectRepository: ProjectRepository;
let workflowRepository: WorkflowRepository;
const activeWorkflowManager = mockInstance(ActiveWorkflowManager);
const sharingSpy = jest.spyOn(License.prototype, 'isSharingEnabled').mockReturnValue(true);
const testServer = utils.setupTestServer({
endpointGroups: ['workflows'],
enabledFeatures: ['feat:sharing'],
enabledFeatures: ['feat:sharing', 'feat:advancedPermissions'],
});
const license = testServer.license;
const mailer = mockInstance(UserManagementMailer);
beforeAll(async () => {
projectRepository = Container.get(ProjectRepository);
workflowRepository = Container.get(WorkflowRepository);
owner = await createUser({ role: 'global:owner' });
owner = await createOwner();
admin = await createAdmin();
ownerPersonalProject = await projectRepository.getPersonalProjectForUserOrFail(owner.id);
member = await createUser({ role: 'global:member' });
memberPersonalProject = await projectRepository.getPersonalProjectForUserOrFail(member.id);
@ -1236,3 +1242,309 @@ describe('PATCH /workflows/:workflowId - activate workflow', () => {
expect(active).toBe(false);
});
});
describe('PUT /:workflowId/transfer', () => {
test('cannot transfer into the same project', async () => {
const destinationProject = await createTeamProject('Team Project', member);
const workflow = await createWorkflow({}, destinationProject);
await testServer
.authAgentFor(member)
.put(`/workflows/${workflow.id}/transfer`)
.send({ destinationProjectId: destinationProject.id })
.expect(400);
});
test('cannot transfer into a personal project', async () => {
const destinationProject = await createTeamProject('Team Project', member);
const workflow = await createWorkflow({}, destinationProject);
await testServer
.authAgentFor(member)
.put(`/workflows/${workflow.id}/transfer`)
.send({ destinationProjectId: memberPersonalProject.id })
.expect(400);
});
test('cannot transfer without workflow:move scope for the workflow', async () => {
const destinationProject = await createTeamProject('Team Project', member);
const workflow = await createWorkflow({}, anotherMember);
await testServer
.authAgentFor(member)
.put(`/workflows/${workflow.id}/transfer`)
.send({ destinationProjectId: destinationProject.id })
.expect(403);
});
test('cannot transfer without workflow:create scope in destination project', async () => {
const destinationProject = await createTeamProject('Team Project', anotherMember);
const workflow = await createWorkflow({}, member);
await testServer
.authAgentFor(member)
.put(`/workflows/${workflow.id}/transfer`)
.send({ destinationProjectId: destinationProject.id })
.expect(404);
});
test('project:editors cannot transfer workflows', async () => {
//
// ARRANGE
//
const sourceProject = await createTeamProject('Team Project 1');
await linkUserToProject(member, sourceProject, 'project:editor');
const destinationProject = await createTeamProject();
await linkUserToProject(member, destinationProject, 'project:admin');
const workflow = await createWorkflow({}, sourceProject);
//
// ACT & ASSERT
//
await testServer
.authAgentFor(member)
.put(`/workflows/${workflow.id}/transfer`)
.send({ destinationProjectId: destinationProject.id })
.expect(403);
});
test('transferring from a personal project to a team project severs all sharings', async () => {
//
// ARRANGE
//
const workflow = await createWorkflow({}, member);
// this sharing should be deleted by the transfer
await shareWorkflowWithUsers(workflow, [anotherMember, owner]);
const destinationProject = await createTeamProject('Team Project', member);
//
// ACT
//
const response = await testServer
.authAgentFor(member)
.put(`/workflows/${workflow.id}/transfer`)
.send({ destinationProjectId: destinationProject.id })
.expect(200);
//
// ASSERT
//
expect(response.body).toEqual({});
const allSharings = await getWorkflowSharing(workflow);
expect(allSharings).toHaveLength(1);
expect(allSharings).not.toContainEqual({
projectId: destinationProject.id,
workflowId: workflow.id,
role: 'workflow:owner',
});
});
test('can transfer from team to another team project', async () => {
//
// ARRANGE
//
const sourceProject = await createTeamProject('Team Project 1', member);
const destinationProject = await createTeamProject('Team Project 2', member);
const workflow = await createWorkflow({}, sourceProject);
//
// ACT
//
const response = await testServer
.authAgentFor(member)
.put(`/workflows/${workflow.id}/transfer`)
.send({ destinationProjectId: destinationProject.id })
.expect(200);
//
// ASSERT
//
expect(response.body).toEqual({});
const allSharings = await getWorkflowSharing(workflow);
expect(allSharings).toHaveLength(1);
expect(allSharings[0]).toMatchObject({
projectId: destinationProject.id,
workflowId: workflow.id,
role: 'workflow:owner',
});
});
test.each([
['owners', () => owner],
['admins', () => admin],
])(
'global %s can always transfer from any personal or team project into any team project',
async (_name, actor) => {
//
// ARRANGE
//
const sourceProject = await createTeamProject('Source Project', member);
const destinationProject = await createTeamProject('Destination Project', member);
const teamWorkflow = await createWorkflow({}, sourceProject);
const personalWorkflow = await createWorkflow({}, member);
//
// ACT
//
const response1 = await testServer
.authAgentFor(actor())
.put(`/workflows/${teamWorkflow.id}/transfer`)
.send({ destinationProjectId: destinationProject.id })
.expect(200);
const response2 = await testServer
.authAgentFor(actor())
.put(`/workflows/${personalWorkflow.id}/transfer`)
.send({ destinationProjectId: destinationProject.id })
.expect(200);
//
// ASSERT
//
expect(response1.body).toEqual({});
expect(response2.body).toEqual({});
{
const allSharings = await getWorkflowSharing(teamWorkflow);
expect(allSharings).toHaveLength(1);
expect(allSharings[0]).toMatchObject({
projectId: destinationProject.id,
workflowId: teamWorkflow.id,
role: 'workflow:owner',
});
}
{
const allSharings = await getWorkflowSharing(personalWorkflow);
expect(allSharings).toHaveLength(1);
expect(allSharings[0]).toMatchObject({
projectId: destinationProject.id,
workflowId: personalWorkflow.id,
role: 'workflow:owner',
});
}
},
);
test.each([
['owners', () => owner],
['admins', () => admin],
])('global %s cannot transfer into personal projects', async (_name, actor) => {
//
// ARRANGE
//
const sourceProject = await createTeamProject('Source Project', member);
const destinationProject = anotherMemberPersonalProject;
const teamWorkflow = await createWorkflow({}, sourceProject);
const personalWorkflow = await createWorkflow({}, member);
//
// ACT & ASSERT
//
await testServer
.authAgentFor(actor())
.put(`/workflows/${teamWorkflow.id}/transfer`)
.send({ destinationProjectId: destinationProject.id })
.expect(400);
await testServer
.authAgentFor(actor())
.put(`/workflows/${personalWorkflow.id}/transfer`)
.send({ destinationProjectId: destinationProject.id })
.expect(400);
});
test('removes and re-adds the workflow from the active workflow manager during the transfer', async () => {
//
// ARRANGE
//
const destinationProject = await createTeamProject('Team Project', member);
const workflow = await createWorkflow({ active: true }, member);
//
// ACT
//
const response = await testServer
.authAgentFor(member)
.put(`/workflows/${workflow.id}/transfer`)
.send({ destinationProjectId: destinationProject.id })
.expect(200);
//
// ASSERT
//
expect(response.body).toEqual({});
expect(activeWorkflowManager.remove).toHaveBeenCalledWith(workflow.id);
expect(activeWorkflowManager.add).toHaveBeenCalledWith(workflow.id, 'update');
});
test('deactivates the workflow if it cannot be added to the active workflow manager again and returns the WorkflowActivationError as data', async () => {
//
// ARRANGE
//
const destinationProject = await createTeamProject('Team Project', member);
const workflow = await createWorkflow({ active: true }, member);
activeWorkflowManager.add.mockRejectedValue(new WorkflowActivationError('Failed'));
//
// ACT
//
const response = await testServer
.authAgentFor(member)
.put(`/workflows/${workflow.id}/transfer`)
.send({ destinationProjectId: destinationProject.id })
.expect(200);
//
// ASSERT
//
expect(response.body).toMatchObject({
data: {
error: {
message: 'Failed',
name: 'WorkflowActivationError',
},
},
});
expect(activeWorkflowManager.remove).toHaveBeenCalledWith(workflow.id);
expect(activeWorkflowManager.add).toHaveBeenCalledWith(workflow.id, 'update');
const workflowFromDB = await workflowRepository.findOneByOrFail({ id: workflow.id });
expect(workflowFromDB).toMatchObject({ active: false });
});
test('returns a 500 if the workflow cannot be activated due to an unknown error', async () => {
//
// ARRANGE
//
const destinationProject = await createTeamProject('Team Project', member);
const workflow = await createWorkflow({ active: true }, member);
activeWorkflowManager.add.mockRejectedValue(new ApplicationError('Oh no!'));
//
// ACT & ASSERT
//
await testServer
.authAgentFor(member)
.put(`/workflows/${workflow.id}/transfer`)
.send({ destinationProjectId: destinationProject.id })
.expect(500);
});
});

View File

@ -116,6 +116,7 @@ describe('POST /workflows', () => {
[
'workflow:delete',
'workflow:execute',
'workflow:move',
'workflow:read',
'workflow:share',
'workflow:update',
@ -519,7 +520,13 @@ describe('GET /workflows', () => {
// Team workflow
expect(wf1.id).toBe(savedWorkflow1.id);
expect(wf1.scopes).toEqual(
['workflow:read', 'workflow:update', 'workflow:delete', 'workflow:execute'].sort(),
[
'workflow:delete',
'workflow:execute',
'workflow:move',
'workflow:read',
'workflow:update',
].sort(),
);
// Shared workflow
@ -550,11 +557,12 @@ describe('GET /workflows', () => {
expect(wf2.id).toBe(savedWorkflow2.id);
expect(wf2.scopes).toEqual(
[
'workflow:read',
'workflow:update',
'workflow:delete',
'workflow:execute',
'workflow:move',
'workflow:read',
'workflow:share',
'workflow:update',
].sort(),
);
}
@ -574,12 +582,13 @@ describe('GET /workflows', () => {
expect(wf1.scopes).toEqual(
[
'workflow:create',
'workflow:read',
'workflow:update',
'workflow:delete',
'workflow:list',
'workflow:share',
'workflow:execute',
'workflow:list',
'workflow:move',
'workflow:read',
'workflow:share',
'workflow:update',
].sort(),
);
@ -588,12 +597,13 @@ describe('GET /workflows', () => {
expect(wf2.scopes).toEqual(
[
'workflow:create',
'workflow:read',
'workflow:update',
'workflow:delete',
'workflow:list',
'workflow:share',
'workflow:execute',
'workflow:list',
'workflow:move',
'workflow:read',
'workflow:share',
'workflow:update',
].sort(),
);
}

View File

@ -37,8 +37,7 @@ export abstract class ExecutionBaseError extends ApplicationError {
if (errorResponse) this.errorResponse = errorResponse;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
toJSON?(): any {
toJSON?() {
return {
message: this.message,
lineNumber: this.lineNumber,