fix: migrate driver modules to DriverFactoryBase lazy-loading pattern (#18731)

## Summary

- Migrates `LogicFunctionModule`, `CodeInterpreterModule`, and
`CaptchaModule` from the `forRootAsync` + injection token pattern to the
`DriverFactoryBase` lazy-loading pattern (matching `EmailModule` and
`FileStorageModule`)
- Fixes #18724 where `LOGIC_FUNCTION_TYPE` was not respected in worker
processes because the driver was created at module boot time before the
DB config cache was loaded
- Removes `isEnvOnly` from `LOGIC_FUNCTION_TYPE`,
`CODE_INTERPRETER_TYPE`, `CAPTCHA_DRIVER`, `IS_MULTIWORKSPACE_ENABLED`,
and `FRONTEND_URL` — these can now be safely configured via the database
at runtime

## How it works

Each migrated module now uses a `DriverFactory` (extending
`DriverFactoryBase`) instead of a module-level async factory + Symbol
injection token:

1. **Lazy creation**: `getCurrentDriver()` creates the driver on first
call, after `DatabaseConfigDriver.onModuleInit()` has loaded the DB
cache
2. **Auto-recreation**: If config changes in the DB, the next
`getCurrentDriver()` call detects the key mismatch and creates a new
driver instance
3. **Unified config**: Both server and worker read from the same
database — driver config only needs to be set once

### Files deleted (old pattern)
- `logic-function-module.factory.ts`,
`logic-function-drivers.module.ts`, `logic-function-driver.constants.ts`
- `code-interpreter-module.factory.ts`
- `captcha.module-factory.ts`, `captcha-driver.constants.ts`

### Files created (new pattern)
- `logic-function-driver.factory.ts`
- `code-interpreter-driver.factory.ts`
- `captcha-driver.factory.ts`

Net: **-150 lines**

## Test plan

- [x] `npx nx typecheck twenty-server` passes
- [x] `npx nx lint:diff-with-main twenty-server` passes
- [ ] Integration tests pass (`npx nx run
twenty-server:test:integration:with-db-reset`)
- [ ] Verify logic functions execute in workflow runs (the original bug)
- [ ] Verify code interpreter works in workflow code steps
- [ ] Verify captcha validation works on sign-up (when captcha is
configured)


Made with [Cursor](https://cursor.com)
This commit is contained in:
Félix Malfait
2026-03-18 16:00:45 +01:00
committed by GitHub
parent fe4512f80c
commit c6f11d8adb
49 changed files with 420 additions and 769 deletions
@@ -0,0 +1,79 @@
import { Injectable } from '@nestjs/common';
import { type CaptchaDriver } from 'src/engine/core-modules/captcha/drivers/interfaces/captcha-driver.interface';
import { GoogleRecaptchaDriver } from 'src/engine/core-modules/captcha/drivers/google-recaptcha.driver';
import { TurnstileDriver } from 'src/engine/core-modules/captcha/drivers/turnstile.driver';
import { CaptchaDriverType } from 'src/engine/core-modules/captcha/interfaces';
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
import { DriverFactoryBase } from 'src/engine/core-modules/twenty-config/dynamic-factory.base';
import { ConfigVariablesGroup } from 'src/engine/core-modules/twenty-config/enums/config-variables-group.enum';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@Injectable()
export class CaptchaDriverFactory extends DriverFactoryBase<CaptchaDriver | null> {
constructor(
twentyConfigService: TwentyConfigService,
private readonly secureHttpClientService: SecureHttpClientService,
) {
super(twentyConfigService);
}
protected buildConfigKey(): string {
const driver = this.twentyConfigService.get('CAPTCHA_DRIVER');
if (!driver) {
return 'disabled';
}
return `${driver}|${this.getConfigGroupHash(ConfigVariablesGroup.CAPTCHA_CONFIG)}`;
}
protected createDriver(): CaptchaDriver | null {
const driver = this.twentyConfigService.get('CAPTCHA_DRIVER');
const siteKey = this.twentyConfigService.get('CAPTCHA_SITE_KEY');
const secretKey = this.twentyConfigService.get('CAPTCHA_SECRET_KEY');
if (!driver) {
return null;
}
if (!siteKey || !secretKey) {
throw new Error('Captcha driver requires site key and secret key');
}
const captchaOptions = { siteKey, secretKey };
switch (driver) {
case CaptchaDriverType.GOOGLE_RECAPTCHA:
return new GoogleRecaptchaDriver(
captchaOptions,
this.secureHttpClientService.getHttpClient({
baseURL: 'https://www.google.com/recaptcha/api/siteverify',
}),
);
case CaptchaDriverType.TURNSTILE:
return new TurnstileDriver(
captchaOptions,
this.secureHttpClientService.getHttpClient({
baseURL:
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
}),
);
default:
throw new Error(`Invalid captcha driver type: ${driver}`);
}
}
getCurrentDriver(): CaptchaDriver | null {
const driver = this.twentyConfigService.get('CAPTCHA_DRIVER');
if (!driver) {
return null;
}
return super.getCurrentDriver();
}
}
@@ -1,31 +0,0 @@
import {
type CaptchaDriverOptions,
type CaptchaModuleOptions,
} from 'src/engine/core-modules/captcha/interfaces';
import { type TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
export const captchaModuleFactory = (
twentyConfigService: TwentyConfigService,
): CaptchaModuleOptions | undefined => {
const driver = twentyConfigService.get('CAPTCHA_DRIVER');
const siteKey = twentyConfigService.get('CAPTCHA_SITE_KEY');
const secretKey = twentyConfigService.get('CAPTCHA_SECRET_KEY');
if (!driver) {
return;
}
if (!siteKey || !secretKey) {
throw new Error('Captcha driver requires site key and secret key');
}
const captchaOptions: CaptchaDriverOptions = {
siteKey,
secretKey,
};
return {
type: driver,
options: captchaOptions,
};
};
@@ -1,59 +1,17 @@
import { type DynamicModule, Global } from '@nestjs/common';
import { CaptchaDriverFactory } from 'src/engine/core-modules/captcha/captcha-driver.factory';
import { CaptchaService } from 'src/engine/core-modules/captcha/captcha.service';
import { CAPTCHA_DRIVER } from 'src/engine/core-modules/captcha/constants/captcha-driver.constants';
import { GoogleRecaptchaDriver } from 'src/engine/core-modules/captcha/drivers/google-recaptcha.driver';
import { TurnstileDriver } from 'src/engine/core-modules/captcha/drivers/turnstile.driver';
import {
CaptchaDriverType,
type CaptchaModuleAsyncOptions,
} from 'src/engine/core-modules/captcha/interfaces';
import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-client/secure-http-client.module';
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
@Global()
export class CaptchaModule {
static forRoot(options: CaptchaModuleAsyncOptions): DynamicModule {
const provider = {
provide: CAPTCHA_DRIVER,
useFactory: async (
secureHttpClientService: SecureHttpClientService,
// oxlint-disable-next-line @typescripttypescript/no-explicit-any
...args: any[]
) => {
const config = await options.useFactory(...args);
if (!config) {
return;
}
switch (config.type) {
case CaptchaDriverType.GOOGLE_RECAPTCHA:
return new GoogleRecaptchaDriver(
config.options,
secureHttpClientService.getHttpClient({
baseURL: 'https://www.google.com/recaptcha/api/siteverify',
}),
);
case CaptchaDriverType.TURNSTILE:
return new TurnstileDriver(
config.options,
secureHttpClientService.getHttpClient({
baseURL:
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
}),
);
default:
return;
}
},
inject: [SecureHttpClientService, ...(options.inject || [])],
};
static forRoot(): DynamicModule {
return {
module: CaptchaModule,
imports: [SecureHttpClientModule],
providers: [CaptchaService, provider],
imports: [TwentyConfigModule, SecureHttpClientModule],
providers: [CaptchaDriverFactory, CaptchaService],
exports: [CaptchaService],
};
}
@@ -1,21 +1,20 @@
import { Inject, Injectable } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { CaptchaDriver } from 'src/engine/core-modules/captcha/drivers/interfaces/captcha-driver.interface';
import { CAPTCHA_DRIVER } from 'src/engine/core-modules/captcha/constants/captcha-driver.constants';
import { CaptchaDriverFactory } from 'src/engine/core-modules/captcha/captcha-driver.factory';
import { type CaptchaDriver } from 'src/engine/core-modules/captcha/drivers/interfaces/captcha-driver.interface';
import { type CaptchaValidateResult } from 'src/engine/core-modules/captcha/interfaces';
@Injectable()
export class CaptchaService implements CaptchaDriver {
constructor(@Inject(CAPTCHA_DRIVER) private driver: CaptchaDriver) {}
constructor(private readonly captchaDriverFactory: CaptchaDriverFactory) {}
async validate(token: string): Promise<CaptchaValidateResult> {
if (this.driver) {
return await this.driver.validate(token);
} else {
return {
success: true,
};
const driver = this.captchaDriverFactory.getCurrentDriver();
if (!driver) {
return { success: true };
}
return driver.validate(token);
}
}
@@ -1 +0,0 @@
export const CAPTCHA_DRIVER = Symbol('CAPTCHA_DRIVER');
@@ -1,4 +1,3 @@
import { type FactoryProvider, type ModuleMetadata } from '@nestjs/common';
import { registerEnumType } from '@nestjs/graphql';
export enum CaptchaDriverType {
@@ -15,25 +14,4 @@ export type CaptchaDriverOptions = {
secretKey: string;
};
export interface GoogleRecaptchaDriverFactoryOptions {
type: CaptchaDriverType.GOOGLE_RECAPTCHA;
options: CaptchaDriverOptions;
}
export interface TurnstileDriverFactoryOptions {
type: CaptchaDriverType.TURNSTILE;
options: CaptchaDriverOptions;
}
export type CaptchaModuleOptions =
| GoogleRecaptchaDriverFactoryOptions
| TurnstileDriverFactoryOptions;
export type CaptchaModuleAsyncOptions = {
useFactory: (
// oxlint-disable-next-line @typescripttypescript/no-explicit-any
...args: any[]
) => CaptchaModuleOptions | Promise<CaptchaModuleOptions> | undefined;
} & Pick<ModuleMetadata, 'imports'> &
Pick<FactoryProvider, 'inject'>;
export type CaptchaValidateResult = { success: boolean; error?: string };