diff --git a/packages/@n8n/config/src/configs/external-hooks.config.ts b/packages/@n8n/config/src/configs/external-hooks.config.ts new file mode 100644 index 0000000000..2dc86b1429 --- /dev/null +++ b/packages/@n8n/config/src/configs/external-hooks.config.ts @@ -0,0 +1,17 @@ +import { Config, Env } from '../decorators'; + +class ColonSeperatedStringArray extends Array { + constructor(str: string) { + super(); + const parsed = str.split(':') as this; + const filtered = parsed.filter((i) => typeof i === 'string' && i.length); + return filtered.length ? filtered : []; + } +} + +@Config +export class ExternalHooksConfig { + /** Files containing external hooks. Multiple files can be separated by colon (":") */ + @Env('EXTERNAL_HOOK_FILES') + files: ColonSeperatedStringArray = []; +} diff --git a/packages/@n8n/config/src/index.ts b/packages/@n8n/config/src/index.ts index 00abdf6d7c..1f686999f7 100644 --- a/packages/@n8n/config/src/index.ts +++ b/packages/@n8n/config/src/index.ts @@ -6,6 +6,7 @@ import { DiagnosticsConfig } from './configs/diagnostics.config'; import { EndpointsConfig } from './configs/endpoints.config'; import { EventBusConfig } from './configs/event-bus.config'; import { ExecutionsConfig } from './configs/executions.config'; +import { ExternalHooksConfig } from './configs/external-hooks.config'; import { ExternalSecretsConfig } from './configs/external-secrets.config'; import { ExternalStorageConfig } from './configs/external-storage.config'; import { GenericConfig } from './configs/generic.config'; @@ -51,6 +52,9 @@ export class GlobalConfig { @Nested publicApi: PublicApiConfig; + @Nested + externalHooks: ExternalHooksConfig; + @Nested externalSecrets: ExternalSecretsConfig; diff --git a/packages/@n8n/config/test/config.test.ts b/packages/@n8n/config/test/config.test.ts index aa115364d9..2137760569 100644 --- a/packages/@n8n/config/test/config.test.ts +++ b/packages/@n8n/config/test/config.test.ts @@ -107,6 +107,9 @@ describe('GlobalConfig', () => { maxFileSizeInKB: 10240, }, }, + externalHooks: { + files: [], + }, externalSecrets: { preferGet: false, updateInterval: 300, diff --git a/packages/cli/src/__tests__/external-hooks.test.ts b/packages/cli/src/__tests__/external-hooks.test.ts new file mode 100644 index 0000000000..5e4aa6ee89 --- /dev/null +++ b/packages/cli/src/__tests__/external-hooks.test.ts @@ -0,0 +1,125 @@ +import type { GlobalConfig } from '@n8n/config'; +import { mock } from 'jest-mock-extended'; +import type { ErrorReporter, Logger } from 'n8n-core'; +import type { IWorkflowBase } from 'n8n-workflow'; +import { ApplicationError } from 'n8n-workflow'; + +import type { CredentialsRepository } from '@/databases/repositories/credentials.repository'; +import type { SettingsRepository } from '@/databases/repositories/settings.repository'; +import type { UserRepository } from '@/databases/repositories/user.repository'; +import type { WorkflowRepository } from '@/databases/repositories/workflow.repository'; +import { ExternalHooks } from '@/external-hooks'; + +describe('ExternalHooks', () => { + const logger = mock(); + const errorReporter = mock(); + const globalConfig = mock(); + const userRepository = mock(); + const settingsRepository = mock(); + const credentialsRepository = mock(); + const workflowRepository = mock(); + + const workflowData = mock({ id: '123', name: 'Test Workflow' }); + const hookFn = jest.fn(); + + let externalHooks: ExternalHooks; + + beforeEach(() => { + jest.resetAllMocks(); + globalConfig.externalHooks.files = []; + externalHooks = new ExternalHooks( + logger, + errorReporter, + globalConfig, + userRepository, + settingsRepository, + credentialsRepository, + workflowRepository, + ); + }); + + describe('init()', () => { + it('should not load hooks if no external hook files are configured', async () => { + // @ts-expect-error private method + const loadHooksSpy = jest.spyOn(externalHooks, 'loadHooks'); + await externalHooks.init(); + expect(loadHooksSpy).not.toHaveBeenCalled(); + }); + + it('should throw an error if hook file cannot be loaded', async () => { + globalConfig.externalHooks.files = ['/path/to/non-existent-hook.js']; + + jest.mock( + '/path/to/non-existent-hook.js', + () => { + throw new Error('File not found'); + }, + { virtual: true }, + ); + + await expect(externalHooks.init()).rejects.toThrow(ApplicationError); + }); + + it('should successfully load hooks from valid hook file', async () => { + const mockHookFile = { + workflow: { + create: [hookFn], + }, + }; + + globalConfig.externalHooks.files = ['/path/to/valid-hook.js']; + jest.mock('/path/to/valid-hook.js', () => mockHookFile, { virtual: true }); + + await externalHooks.init(); + + // eslint-disable-next-line @typescript-eslint/dot-notation + expect(externalHooks['registered']['workflow.create']).toHaveLength(1); + + await externalHooks.run('workflow.create', [workflowData]); + + expect(hookFn).toHaveBeenCalledTimes(1); + expect(hookFn).toHaveBeenCalledWith(workflowData); + }); + }); + + describe('run()', () => { + it('should not throw if no hooks are registered', async () => { + await externalHooks.run('n8n.stop'); + }); + + it('should execute registered hooks', async () => { + // eslint-disable-next-line @typescript-eslint/dot-notation + externalHooks['registered']['workflow.create'] = [hookFn]; + + await externalHooks.run('workflow.create', [workflowData]); + + expect(hookFn).toHaveBeenCalledTimes(1); + + const hookInvocationContext = hookFn.mock.instances[0]; + expect(hookInvocationContext).toHaveProperty('dbCollections'); + expect(hookInvocationContext.dbCollections).toEqual({ + User: userRepository, + Settings: settingsRepository, + Credentials: credentialsRepository, + Workflow: workflowRepository, + }); + }); + + it('should report error if hook execution fails', async () => { + hookFn.mockRejectedValueOnce(new Error('Hook failed')); + // eslint-disable-next-line @typescript-eslint/dot-notation + externalHooks['registered']['workflow.create'] = [hookFn]; + + await expect(externalHooks.run('workflow.create', [workflowData])).rejects.toThrow( + ApplicationError, + ); + + expect(errorReporter.error).toHaveBeenCalledWith(expect.any(ApplicationError), { + level: 'fatal', + }); + expect(logger.error).toHaveBeenCalledWith( + 'There was a problem running hook "workflow.create"', + ); + }); + }); +}); diff --git a/packages/cli/src/active-workflow-manager.ts b/packages/cli/src/active-workflow-manager.ts index 403e60f51d..af0a1a4230 100644 --- a/packages/cli/src/active-workflow-manager.ts +++ b/packages/cli/src/active-workflow-manager.ts @@ -89,7 +89,7 @@ export class ActiveWorkflowManager { await this.addActiveWorkflows('init'); - await this.externalHooks.run('activeWorkflows.initialized', []); + await this.externalHooks.run('activeWorkflows.initialized'); await this.webhookService.populateCache(); } diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 286b20c7c6..a255a95bb9 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -94,7 +94,7 @@ export class Start extends BaseCommand { Container.get(WaitTracker).stopTracking(); - await this.externalHooks?.run('n8n.stop', []); + await this.externalHooks?.run('n8n.stop'); await this.activeWorkflowManager.removeAllTriggerAndPollerBasedWorkflows(); diff --git a/packages/cli/src/commands/webhook.ts b/packages/cli/src/commands/webhook.ts index 8b6f318576..fd1e961b59 100644 --- a/packages/cli/src/commands/webhook.ts +++ b/packages/cli/src/commands/webhook.ts @@ -33,7 +33,7 @@ export class Webhook extends BaseCommand { this.logger.info('\nStopping n8n...'); try { - await this.externalHooks?.run('n8n.stop', []); + await this.externalHooks?.run('n8n.stop'); await Container.get(ActiveExecutions).shutdown(); } catch (error) { diff --git a/packages/cli/src/commands/worker.ts b/packages/cli/src/commands/worker.ts index f5138f1ef3..c6046a7772 100644 --- a/packages/cli/src/commands/worker.ts +++ b/packages/cli/src/commands/worker.ts @@ -49,7 +49,7 @@ export class Worker extends BaseCommand { this.logger.info('Stopping worker...'); try { - await this.externalHooks?.run('n8n.stop', []); + await this.externalHooks?.run('n8n.stop'); } catch (error) { await this.exitWithCrash('Error shutting down worker', error); } diff --git a/packages/cli/src/config/index.ts b/packages/cli/src/config/index.ts index 8839d180ff..d11fa795db 100644 --- a/packages/cli/src/config/index.ts +++ b/packages/cli/src/config/index.ts @@ -133,3 +133,5 @@ setGlobalState({ // eslint-disable-next-line import/no-default-export export default config; + +export type Config = typeof config; diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index 0e7f747fba..dba8611257 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -189,13 +189,6 @@ export const schema = { env: 'EXTERNAL_FRONTEND_HOOKS_URLS', }, - externalHookFiles: { - doc: 'Files containing external hooks. Multiple files can be separated by colon (":")', - format: String, - default: '', - env: 'EXTERNAL_HOOK_FILES', - }, - push: { backend: { format: ['sse', 'websocket'] as const, diff --git a/packages/cli/src/execution-lifecycle/execution-lifecycle-hooks.ts b/packages/cli/src/execution-lifecycle/execution-lifecycle-hooks.ts index c991e2a6ab..bb0542dba0 100644 --- a/packages/cli/src/execution-lifecycle/execution-lifecycle-hooks.ts +++ b/packages/cli/src/execution-lifecycle/execution-lifecycle-hooks.ts @@ -436,22 +436,13 @@ function hookFunctionsSaveWorker(): IWorkflowExecuteHooks { }, async function (this: WorkflowHooks, fullRunData: IRun) { const externalHooks = Container.get(ExternalHooks); - if (externalHooks.exists('workflow.postExecute')) { - try { - await externalHooks.run('workflow.postExecute', [ - fullRunData, - this.workflowData, - this.executionId, - ]); - } catch (error) { - Container.get(ErrorReporter).error(error); - Container.get(Logger).error( - 'There was a problem running hook "workflow.postExecute"', - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - error, - ); - } - } + try { + await externalHooks.run('workflow.postExecute', [ + fullRunData, + this.workflowData, + this.executionId, + ]); + } catch {} }, ], nodeFetchedData: [ diff --git a/packages/cli/src/external-hooks.ts b/packages/cli/src/external-hooks.ts index f6741dd544..7602ece806 100644 --- a/packages/cli/src/external-hooks.ts +++ b/packages/cli/src/external-hooks.ts @@ -1,27 +1,104 @@ -/* eslint-disable @typescript-eslint/no-var-requires */ +import type { FrontendSettings, UserUpdateRequestDto } from '@n8n/api-types'; +import type { ClientOAuth2Options } from '@n8n/client-oauth2'; +import { GlobalConfig } from '@n8n/config'; import { Service } from '@n8n/di'; -import { ErrorReporter } from 'n8n-core'; +import { ErrorReporter, Logger } from 'n8n-core'; +import type { IRun, IWorkflowBase, Workflow, WorkflowExecuteMode } from 'n8n-workflow'; import { ApplicationError } from 'n8n-workflow'; +import type clientOAuth1 from 'oauth-1.0a'; -import config from '@/config'; +import type { AbstractServer } from '@/abstract-server'; +import type { Config } from '@/config'; +import type { TagEntity } from '@/databases/entities/tag-entity'; +import type { User } from '@/databases/entities/user'; import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; import { SettingsRepository } from '@/databases/repositories/settings.repository'; import { UserRepository } from '@/databases/repositories/user.repository'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; -import type { IExternalHooksFileData, IExternalHooksFunctions } from '@/interfaces'; +import type { ICredentialsDb, PublicUser } from '@/interfaces'; + +type Repositories = { + User: UserRepository; + Settings: SettingsRepository; + Credentials: CredentialsRepository; + Workflow: WorkflowRepository; +}; + +type Hooks = { + 'n8n.ready': [server: AbstractServer, config: Config]; + 'n8n.stop': []; + 'worker.ready': []; + + 'activeWorkflows.initialized': []; + + 'credentials.create': [encryptedData: ICredentialsDb]; + 'credentials.update': [newCredentialData: ICredentialsDb]; + 'credentials.delete': [credentialId: string]; + + 'frontend.settings': [frontendSettings: FrontendSettings]; + + 'mfa.beforeSetup': [user: User]; + + 'oauth1.authenticate': [ + oAuthOptions: clientOAuth1.Options, + oauthRequestData: { oauth_callback: string }, + ]; + 'oauth2.authenticate': [oAuthOptions: ClientOAuth2Options]; + 'oauth2.callback': [oAuthOptions: ClientOAuth2Options]; + + 'tag.beforeCreate': [tag: TagEntity]; + 'tag.afterCreate': [tag: TagEntity]; + 'tag.beforeUpdate': [tag: TagEntity]; + 'tag.afterUpdate': [tag: TagEntity]; + 'tag.beforeDelete': [tagId: string]; + 'tag.afterDelete': [tagId: string]; + + 'user.deleted': [user: PublicUser]; + 'user.profile.beforeUpdate': [ + userId: string, + currentEmail: string, + payload: UserUpdateRequestDto, + ]; + 'user.profile.update': [currentEmail: string, publicUser: PublicUser]; + 'user.password.update': [updatedEmail: string, updatedPassword: string]; + 'user.invited': [emails: string[]]; + + 'workflow.create': [createdWorkflow: IWorkflowBase]; + 'workflow.afterCreate': [createdWorkflow: IWorkflowBase]; + 'workflow.activate': [updatedWorkflow: IWorkflowBase]; + 'workflow.update': [updatedWorkflow: IWorkflowBase]; + 'workflow.afterUpdate': [updatedWorkflow: IWorkflowBase]; + 'workflow.delete': [workflowId: string]; + 'workflow.afterDelete': [workflowId: string]; + + 'workflow.preExecute': [workflow: Workflow, mode: WorkflowExecuteMode]; + 'workflow.postExecute': [ + fullRunData: IRun | undefined, + workflowData: IWorkflowBase, + executionId: string, + ]; +}; +type HookNames = keyof Hooks; + +// TODO: Derive this type from Hooks +interface IExternalHooksFileData { + [Resource: string]: { + [Operation: string]: Array<(...args: unknown[]) => Promise>; + }; +} @Service() export class ExternalHooks { - externalHooks: { - [key: string]: Array<() => {}>; + private readonly registered: { + [hookName in HookNames]?: Array<(...args: Hooks[hookName]) => Promise>; } = {}; - private initDidRun = false; - - private dbCollections: IExternalHooksFunctions['dbCollections']; + private readonly dbCollections: Repositories; constructor( + private readonly logger: Logger, private readonly errorReporter: ErrorReporter, + private readonly globalConfig: GlobalConfig, userRepository: UserRepository, settingsRepository: SettingsRepository, credentialsRepository: CredentialsRepository, @@ -35,70 +112,53 @@ export class ExternalHooks { }; } - async init(): Promise { - if (this.initDidRun) { - return; - } - - await this.loadHooksFiles(); - - this.initDidRun = true; - } - - private async loadHooksFiles() { - const externalHookFiles = config.getEnv('externalHookFiles').split(':'); + async init() { + const externalHookFiles = this.globalConfig.externalHooks.files; // Load all the provided hook-files for (let hookFilePath of externalHookFiles) { hookFilePath = hookFilePath.trim(); - if (hookFilePath !== '') { - try { - const hookFile = require(hookFilePath) as IExternalHooksFileData; - this.loadHooks(hookFile); - } catch (e) { - const error = e instanceof Error ? e : new Error(`${e}`); + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const hookFile = require(hookFilePath) as IExternalHooksFileData; + this.loadHooks(hookFile); + } catch (e) { + const error = e instanceof Error ? e : new Error(`${e}`); - throw new ApplicationError('Problem loading external hook file', { - extra: { errorMessage: error.message, hookFilePath }, - cause: error, - }); - } + throw new ApplicationError('Problem loading external hook file', { + extra: { errorMessage: error.message, hookFilePath }, + cause: error, + }); } } } private loadHooks(hookFileData: IExternalHooksFileData) { - for (const resource of Object.keys(hookFileData)) { - for (const operation of Object.keys(hookFileData[resource])) { - // Save all the hook functions directly under their string - // format in an array - const hookString = `${resource}.${operation}`; - if (this.externalHooks[hookString] === undefined) { - this.externalHooks[hookString] = []; - } - - // eslint-disable-next-line prefer-spread - this.externalHooks[hookString].push.apply( - this.externalHooks[hookString], - hookFileData[resource][operation], - ); + const { registered } = this; + for (const [resource, operations] of Object.entries(hookFileData)) { + for (const operation of Object.keys(operations)) { + const hookName = `${resource}.${operation}` as HookNames; + registered[hookName] ??= []; + registered[hookName].push(...operations[operation]); } } } - async run(hookName: string, hookParameters?: any[]): Promise { - if (this.externalHooks[hookName] === undefined) { - return; - } + async run( + hookName: HookName, + hookParameters?: Hooks[HookName], + ): Promise { + const { registered, dbCollections } = this; + const hookFunctions = registered[hookName]; + if (!hookFunctions?.length) return; - const externalHookFunctions: IExternalHooksFunctions = { - dbCollections: this.dbCollections, - }; + const context = { dbCollections }; - for (const externalHookFunction of this.externalHooks[hookName]) { + for (const hookFunction of hookFunctions) { try { - await externalHookFunction.apply(externalHookFunctions, hookParameters); + await hookFunction.apply(context, hookParameters); } catch (cause) { + this.logger.error(`There was a problem running hook "${hookName}"`); // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const error = new ApplicationError(`External hook "${hookName}" failed`, { cause }); this.errorReporter.error(error, { level: 'fatal' }); @@ -106,8 +166,4 @@ export class ExternalHooks { } } } - - exists(hookName: string): boolean { - return !!this.externalHooks[hookName]; - } } diff --git a/packages/cli/src/interfaces.ts b/packages/cli/src/interfaces.ts index e50b96c384..c5cf76bd32 100644 --- a/packages/cli/src/interfaces.ts +++ b/packages/cli/src/interfaces.ts @@ -31,10 +31,6 @@ import type { AuthProviderType } from '@/databases/entities/auth-identity'; import type { SharedCredentials } from '@/databases/entities/shared-credentials'; import type { TagEntity } from '@/databases/entities/tag-entity'; import type { AssignableRole, GlobalRole, User } from '@/databases/entities/user'; -import type { CredentialsRepository } from '@/databases/repositories/credentials.repository'; -import type { SettingsRepository } from '@/databases/repositories/settings.repository'; -import type { UserRepository } from '@/databases/repositories/user.repository'; -import type { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import type { LICENSE_FEATURES, LICENSE_QUOTAS } from './constants'; import type { ExternalHooks } from './external-hooks'; @@ -220,46 +216,6 @@ export interface IExecutingWorkflowData { status: ExecutionStatus; } -export interface IExternalHooks { - credentials?: { - create?: Array<{ - (this: IExternalHooksFunctions, credentialsData: ICredentialsEncrypted): Promise; - }>; - delete?: Array<{ (this: IExternalHooksFunctions, credentialId: string): Promise }>; - update?: Array<{ - (this: IExternalHooksFunctions, credentialsData: ICredentialsDb): Promise; - }>; - }; - workflow?: { - activate?: Array<{ (this: IExternalHooksFunctions, workflowData: IWorkflowDb): Promise }>; - create?: Array<{ (this: IExternalHooksFunctions, workflowData: IWorkflowBase): Promise }>; - delete?: Array<{ (this: IExternalHooksFunctions, workflowId: string): Promise }>; - execute?: Array<{ - ( - this: IExternalHooksFunctions, - workflowData: IWorkflowDb, - mode: WorkflowExecuteMode, - ): Promise; - }>; - update?: Array<{ (this: IExternalHooksFunctions, workflowData: IWorkflowDb): Promise }>; - }; -} - -export interface IExternalHooksFileData { - [key: string]: { - [key: string]: Array<(...args: any[]) => Promise>; - }; -} - -export interface IExternalHooksFunctions { - dbCollections: { - User: UserRepository; - Settings: SettingsRepository; - Credentials: CredentialsRepository; - Workflow: WorkflowRepository; - }; -} - export interface IPersonalizationSurveyAnswers { email: string | null; codingSkill: string | null; diff --git a/packages/cli/src/services/tag.service.ts b/packages/cli/src/services/tag.service.ts index 09695f44ff..1f6cdd88fe 100644 --- a/packages/cli/src/services/tag.service.ts +++ b/packages/cli/src/services/tag.service.ts @@ -8,6 +8,8 @@ import type { ITagWithCountDb } from '@/interfaces'; type GetAllResult = T extends { withUsageCount: true } ? ITagWithCountDb[] : TagEntity[]; +type Action = 'Create' | 'Update'; + @Service() export class TagService { constructor( @@ -24,7 +26,7 @@ export class TagService { async save(tag: TagEntity, actionKind: 'create' | 'update') { await validateEntity(tag); - const action = actionKind[0].toUpperCase() + actionKind.slice(1); + const action = (actionKind[0].toUpperCase() + actionKind.slice(1)) as Action; await this.externalHooks.run(`tag.before${action}`, [tag]); diff --git a/packages/cli/src/workflow-execute-additional-data.ts b/packages/cli/src/workflow-execute-additional-data.ts index e7850c5cb9..98a736916a 100644 --- a/packages/cli/src/workflow-execute-additional-data.ts +++ b/packages/cli/src/workflow-execute-additional-data.ts @@ -182,9 +182,6 @@ async function startExecution( runData: IWorkflowExecutionDataProcess, workflowData: IWorkflowBase, ): Promise { - const externalHooks = Container.get(ExternalHooks); - await externalHooks.init(); - const nodeTypes = Container.get(NodeTypes); const activeExecutions = Container.get(ActiveExecutions); const executionRepository = Container.get(ExecutionRepository); @@ -306,6 +303,7 @@ async function startExecution( ); } + const externalHooks = Container.get(ExternalHooks); await externalHooks.run('workflow.postExecute', [data, workflowData, executionId]); // subworkflow either finished, or is in status waiting due to a wait node, both cases are considered successes here diff --git a/packages/cli/src/workflow-runner.ts b/packages/cli/src/workflow-runner.ts index 2248d0eb45..da48255faa 100644 --- a/packages/cli/src/workflow-runner.ts +++ b/packages/cli/src/workflow-runner.ts @@ -179,18 +179,13 @@ export class WorkflowRunner { const postExecutePromise = this.activeExecutions.getPostExecutePromise(executionId); postExecutePromise .then(async (executionData) => { - if (this.externalHooks.exists('workflow.postExecute')) { - try { - await this.externalHooks.run('workflow.postExecute', [ - executionData, - data.workflowData, - executionId, - ]); - } catch (error) { - this.errorReporter.error(error); - this.logger.error('There was a problem running hook "workflow.postExecute"', error); - } - } + try { + await this.externalHooks.run('workflow.postExecute', [ + executionData, + data.workflowData, + executionId, + ]); + } catch {} }) .catch((error) => { if (error instanceof ExecutionCancelledError) return; diff --git a/packages/cli/test/integration/active-workflow-manager.test.ts b/packages/cli/test/integration/active-workflow-manager.test.ts index 3c98c2a4f1..8c502bba94 100644 --- a/packages/cli/test/integration/active-workflow-manager.test.ts +++ b/packages/cli/test/integration/active-workflow-manager.test.ts @@ -76,10 +76,7 @@ describe('init()', () => { it('should call external hook', async () => { await activeWorkflowManager.init(); - const [hook, arg] = externalHooks.run.mock.calls[0]; - - expect(hook).toBe('activeWorkflows.initialized'); - expect(arg).toBeEmptyArray(); + expect(externalHooks.run).toHaveBeenCalledWith('activeWorkflows.initialized'); }); it('should check that workflow can be activated', async () => {