From c310daab01653fef4ce51b4f0651b77d075f8fde Mon Sep 17 00:00:00 2001 From: Nemenwq Date: Sat, 20 Jun 2026 19:58:15 +0100 Subject: [PATCH] Refactor module feature flags to use typed configuration and selection factory --- app/backend/.env.example | 16 +++ app/backend/src/app.module.ts | 119 +++++++------------ app/backend/src/config/app-config.service.ts | 27 +++++ app/backend/src/config/env.schema.ts | 14 +++ app/backend/src/module-factory.spec.ts | 73 ++++++++++++ app/backend/src/module-factory.ts | 44 +++++++ 6 files changed, 220 insertions(+), 73 deletions(-) create mode 100644 app/backend/src/module-factory.spec.ts create mode 100644 app/backend/src/module-factory.ts diff --git a/app/backend/.env.example b/app/backend/.env.example index 24ebab187..ab4abb6ff 100644 --- a/app/backend/.env.example +++ b/app/backend/.env.example @@ -107,3 +107,19 @@ STAGING_SEED_DATA_ENABLED=false # Explicit environment name for parity tracking # Options: development, staging, production, test ENVIRONMENT_NAME=development + +# ----------------------------------------------------------------------------- +# Feature Flags +# ----------------------------------------------------------------------------- + +# Whether the reconciliation module is enabled (default: true) +# Should be true in production, can be false in local dev if Supabase is unavailable. +FEATURES_RECONCILIATION_ENABLED=true + +# Whether the notifications module is enabled (default: true) +# Should be true in production, can be false in local dev if Supabase is unavailable. +FEATURES_NOTIFICATIONS_ENABLED=true + +# Whether developer-only routes and modules are enabled (default: false) +# MUST be false in production. Can be true in local/staging for debugging. +FEATURES_DEVELOPER_ROUTES_ENABLED=false diff --git a/app/backend/src/app.module.ts b/app/backend/src/app.module.ts index 128e4ad4d..5ac27cc2a 100644 --- a/app/backend/src/app.module.ts +++ b/app/backend/src/app.module.ts @@ -11,7 +11,7 @@ import { ThrottlerModule } from "@nestjs/throttler"; import { ScheduleModule } from "@nestjs/schedule"; import { APP_GUARD, APP_INTERCEPTOR } from "@nestjs/core"; -import { AppConfigModule } from "./config"; +import { AppConfigModule, envSchema, EnvConfig } from "./config"; import { AssetMetadataModule } from "./asset-metadata/asset-metadata.module"; import { HealthModule } from "./health/health.module"; import { StellarModule } from "./stellar/stellar.module"; @@ -50,81 +50,54 @@ import { throttlerModuleProfiles } from "./config/rate-limit.config"; import { EnvironmentParityModule } from "./environment-parity/environment-parity.module"; import { IndexerLagModule } from "./indexer-lag"; import { SupportBundleModule } from "./support-bundle/support-bundle.module"; +import { AppImport, getDynamicModules } from "./module-factory"; -type AppImport = - | Type - | DynamicModule - | Promise - | ForwardReference; +// Validate environment variables for module composition. +// This ensures that feature flags are deterministic and typed. +const validatedEnv = envSchema.validate(process.env, { + allowUnknown: true, + abortEarly: false, +}).value as EnvConfig; @Module({ - imports: ((): AppImport[] => { - const baseImports: AppImport[] = [ - SentryModule, - AppConfigModule, - // ScheduleModule registered once here — shared by NotificationsModule and ReconciliationModule - ScheduleModule.forRoot(), - EventEmitterModule.forRoot({ - wildcard: true, - delimiter: ".", - }), - ThrottlerModule.forRoot(throttlerModuleProfiles), - SupabaseModule, - HealthModule, - AssetMetadataModule, - StellarModule, - UsernamesModule, - MetricsModule, - AnalyticsModule, - LinksModule, - ScamAlertsModule, - TransactionsModule, - PaymentsModule, - IngestionModule, - ApiKeysModule, - MarketplaceModule, - FiatRampsModule, - RefundsModule, - ExportsModule, - JobQueueModule, - AuditModule, - ContractsModule, - FeatureFlagsModule, - PrivacyModule, - SorobanToolingModule, - EnvironmentParityModule, - IndexerLagModule, - SupportBundleModule, - ]; - - // In development, if SUPABASE_URL points to a localhost placeholder (i.e. you don't - // have a running Supabase instance), skip loading the Reconciliation module which - // interacts with Supabase and runs scheduled jobs. This avoids noisy network errors - // during local development and recording sessions. - try { - const supabaseUrl = process.env.SUPABASE_URL ?? ""; - const isLocalSupabase = - supabaseUrl.includes("localhost") || supabaseUrl.includes("127.0.0.1"); - - // Only load Reconciliation & Notifications modules when Supabase is real/reachable. - if (!isLocalSupabase) { - baseImports.push(ReconciliationModule as AppImport); - baseImports.push(NotificationsModule as AppImport); - baseImports.push(DeveloperModule as AppImport); - } else { - // eslint-disable-next-line no-console - console.log( - "Skipping Reconciliation & Notifications modules in dev (local Supabase)", - ); - } - } catch (e) { - // If anything goes wrong, default to including the modules. - baseImports.push(ReconciliationModule as AppImport); - baseImports.push(NotificationsModule as AppImport); - baseImports.push(DeveloperModule as AppImport); - } - return baseImports; - })(), + imports: [ + SentryModule, + AppConfigModule, + // ScheduleModule registered once here — shared by NotificationsModule and ReconciliationModule + ScheduleModule.forRoot(), + EventEmitterModule.forRoot({ + wildcard: true, + delimiter: ".", + }), + ThrottlerModule.forRoot(throttlerModuleProfiles), + SupabaseModule, + HealthModule, + AssetMetadataModule, + StellarModule, + UsernamesModule, + MetricsModule, + AnalyticsModule, + LinksModule, + ScamAlertsModule, + TransactionsModule, + PaymentsModule, + IngestionModule, + ApiKeysModule, + MarketplaceModule, + FiatRampsModule, + RefundsModule, + ExportsModule, + JobQueueModule, + AuditModule, + ContractsModule, + FeatureFlagsModule, + PrivacyModule, + SorobanToolingModule, + EnvironmentParityModule, + IndexerLagModule, + SupportBundleModule, + ...getDynamicModules(validatedEnv), + ], providers: [ { provide: APP_GUARD, diff --git a/app/backend/src/config/app-config.service.ts b/app/backend/src/config/app-config.service.ts index f4ff9cf4c..5da269f40 100644 --- a/app/backend/src/config/app-config.service.ts +++ b/app/backend/src/config/app-config.service.ts @@ -274,4 +274,31 @@ export class AppConfigService { infer: true, }); } + + /** + * Whether the reconciliation module is enabled + */ + get reconciliationEnabled(): boolean { + return this.configService.get("FEATURES_RECONCILIATION_ENABLED", { + infer: true, + }); + } + + /** + * Whether the notifications module is enabled + */ + get notificationsEnabled(): boolean { + return this.configService.get("FEATURES_NOTIFICATIONS_ENABLED", { + infer: true, + }); + } + + /** + * Whether the developer routes/module is enabled + */ + get developerRoutesEnabled(): boolean { + return this.configService.get("FEATURES_DEVELOPER_ROUTES_ENABLED", { + infer: true, + }); + } } diff --git a/app/backend/src/config/env.schema.ts b/app/backend/src/config/env.schema.ts index 62f1006f4..9ad0fc1e9 100644 --- a/app/backend/src/config/env.schema.ts +++ b/app/backend/src/config/env.schema.ts @@ -362,6 +362,17 @@ export const envSchema = Joi.object({ .description( "Admin override to disable lag guard temporarily (for emergencies)", ), + + // ── Feature Flags ───────────────────────────────────────────────────────── + FEATURES_RECONCILIATION_ENABLED: Joi.boolean() + .default(true) + .description("Whether the reconciliation module is enabled"), + FEATURES_NOTIFICATIONS_ENABLED: Joi.boolean() + .default(true) + .description("Whether the notifications module is enabled"), + FEATURES_DEVELOPER_ROUTES_ENABLED: Joi.boolean() + .default(false) + .description("Whether the developer routes/module is enabled"), }); /** @@ -424,4 +435,7 @@ export interface EnvConfig { INDEXER_LAG_THRESHOLD_LEDGERS: number; INDEXER_LAG_GUARD_ENABLED: boolean; INDEXER_LAG_GUARD_OVERRIDE: boolean; + FEATURES_RECONCILIATION_ENABLED: boolean; + FEATURES_NOTIFICATIONS_ENABLED: boolean; + FEATURES_DEVELOPER_ROUTES_ENABLED: boolean; } diff --git a/app/backend/src/module-factory.spec.ts b/app/backend/src/module-factory.spec.ts new file mode 100644 index 000000000..76d3528c4 --- /dev/null +++ b/app/backend/src/module-factory.spec.ts @@ -0,0 +1,73 @@ +import { getDynamicModules } from "./module-factory"; +import { EnvConfig } from "./config/env.schema"; +import { ReconciliationModule } from "./reconciliation/reconciliation.module"; +import { NotificationsModule } from "./notifications/notifications.module"; +import { DeveloperModule } from "./developer/developer.module"; + +describe("getDynamicModules", () => { + const baseConfig: Partial = { + NODE_ENV: "development", + FEATURES_RECONCILIATION_ENABLED: false, + FEATURES_NOTIFICATIONS_ENABLED: false, + FEATURES_DEVELOPER_ROUTES_ENABLED: false, + }; + + it("should return an empty array when all optional modules are disabled", () => { + const modules = getDynamicModules(baseConfig as EnvConfig); + expect(modules).toEqual([]); + }); + + it("should include ReconciliationModule when enabled", () => { + const config = { + ...baseConfig, + FEATURES_RECONCILIATION_ENABLED: true, + }; + const modules = getDynamicModules(config as EnvConfig); + expect(modules).toContain(ReconciliationModule); + expect(modules).not.toContain(NotificationsModule); + expect(modules).not.toContain(DeveloperModule); + }); + + it("should include NotificationsModule when enabled", () => { + const config = { + ...baseConfig, + FEATURES_NOTIFICATIONS_ENABLED: true, + }; + const modules = getDynamicModules(config as EnvConfig); + expect(modules).toContain(NotificationsModule); + expect(modules).not.toContain(ReconciliationModule); + expect(modules).not.toContain(DeveloperModule); + }); + + it("should include DeveloperModule when enabled in non-production", () => { + const config = { + ...baseConfig, + FEATURES_DEVELOPER_ROUTES_ENABLED: true, + }; + const modules = getDynamicModules(config as EnvConfig); + expect(modules).toContain(DeveloperModule); + }); + + it("should throw an error if DeveloperModule is enabled in production", () => { + const config = { + ...baseConfig, + NODE_ENV: "production", + FEATURES_DEVELOPER_ROUTES_ENABLED: true, + }; + expect(() => getDynamicModules(config as EnvConfig)).toThrow( + /Developer routes are enabled in production/, + ); + }); + + it("should include multiple modules when multiple are enabled", () => { + const config = { + ...baseConfig, + FEATURES_RECONCILIATION_ENABLED: true, + FEATURES_NOTIFICATIONS_ENABLED: true, + }; + const modules = getDynamicModules(config as EnvConfig); + expect(modules).toContain(ReconciliationModule); + expect(modules).toContain(NotificationsModule); + expect(modules).not.toContain(DeveloperModule); + }); +}); diff --git a/app/backend/src/module-factory.ts b/app/backend/src/module-factory.ts new file mode 100644 index 000000000..f93ebed7f --- /dev/null +++ b/app/backend/src/module-factory.ts @@ -0,0 +1,44 @@ +import { Type, DynamicModule, ForwardReference } from "@nestjs/common"; +import { EnvConfig } from "./config/env.schema"; +import { ReconciliationModule } from "./reconciliation/reconciliation.module"; +import { NotificationsModule } from "./notifications/notifications.module"; +import { DeveloperModule } from "./developer/developer.module"; + +export type AppImport = + | Type + | DynamicModule + | Promise + | ForwardReference; + +/** + * Returns the list of dynamic modules to be loaded based on the application configuration. + * This factory ensures that module loading is deterministic and based on typed config. + * + * @param config The application configuration object (validated EnvConfig) + * @returns An array of modules to be imported + */ +export function getDynamicModules(config: EnvConfig): AppImport[] { + const dynamicModules: AppImport[] = []; + + // Fail-fast check for production: DeveloperModule must not be enabled + if (config.NODE_ENV === "production" && config.FEATURES_DEVELOPER_ROUTES_ENABLED) { + throw new Error( + "CONFIGURATION ERROR: Developer routes are enabled in production! " + + "Ensure FEATURES_DEVELOPER_ROUTES_ENABLED is set to 'false' in production environments.", + ); + } + + if (config.FEATURES_RECONCILIATION_ENABLED) { + dynamicModules.push(ReconciliationModule as AppImport); + } + + if (config.FEATURES_NOTIFICATIONS_ENABLED) { + dynamicModules.push(NotificationsModule as AppImport); + } + + if (config.FEATURES_DEVELOPER_ROUTES_ENABLED) { + dynamicModules.push(DeveloperModule as AppImport); + } + + return dynamicModules; +}