Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions app/backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
119 changes: 46 additions & 73 deletions app/backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@
Module,
MiddlewareConsumer,
NestModule,
Type,

Check failure on line 5 in app/backend/src/app.module.ts

View workflow job for this annotation

GitHub Actions / Build and Test

'Type' is defined but never used
DynamicModule,

Check failure on line 6 in app/backend/src/app.module.ts

View workflow job for this annotation

GitHub Actions / Build and Test

'DynamicModule' is defined but never used
ForwardReference,

Check failure on line 7 in app/backend/src/app.module.ts

View workflow job for this annotation

GitHub Actions / Build and Test

'ForwardReference' is defined but never used
} from "@nestjs/common";
import { EventEmitterModule } from "@nestjs/event-emitter";
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";
Expand All @@ -23,13 +23,13 @@
import { ScamAlertsModule } from "./scam-alerts/scam-alerts.module";
import { TransactionsModule } from "./transactions/transactions.module";
import { PaymentsModule } from "./payments/payments.module";
import { ReconciliationModule } from "./reconciliation/reconciliation.module";

Check failure on line 26 in app/backend/src/app.module.ts

View workflow job for this annotation

GitHub Actions / Build and Test

'ReconciliationModule' is defined but never used
import { MetricsMiddleware } from "./metrics/metrics.middleware";
import { MetricsInterceptor } from "./metrics/metrics.interceptor";
import { CorrelationIdMiddleware } from "./common/middleware/correlation-id.middleware";
import { OrganizationContextMiddleware } from "./common/middleware/organization-context.middleware";
import { ShadowTrafficMiddleware } from "./environment-parity/shadow-traffic.middleware";
import { NotificationsModule } from "./notifications/notifications.module";

Check failure on line 32 in app/backend/src/app.module.ts

View workflow job for this annotation

GitHub Actions / Build and Test

'NotificationsModule' is defined but never used
import { IngestionModule } from "./ingestion/ingestion.module";
import { ApiKeysModule } from "./api-keys/api-keys.module";
import { MarketplaceModule } from "./marketplace/marketplace.module";
Expand All @@ -40,7 +40,7 @@
import { JobQueueModule } from "./job-queue/job-queue.module";
import { AuditModule } from "./audit/audit.module";
import { FeatureFlagsModule } from "./feature-flags/feature-flags.module";
import { DeveloperModule } from "./developer/developer.module";

Check failure on line 43 in app/backend/src/app.module.ts

View workflow job for this annotation

GitHub Actions / Build and Test

'DeveloperModule' is defined but never used
import { PrivacyModule } from "./privacy/privacy.module";
import { ContractsModule } from "./contracts/contracts.module";
import { SorobanToolingModule } from "./soroban-tooling/soroban-tooling.module";
Expand All @@ -50,81 +50,54 @@
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";

Check failure on line 53 in app/backend/src/app.module.ts

View workflow job for this annotation

GitHub Actions / Build and Test

'AppImport' is defined but never used

type AppImport =
| Type<unknown>
| DynamicModule
| Promise<DynamicModule>
| ForwardReference<unknown>;
// 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,
Expand Down
27 changes: 27 additions & 0 deletions app/backend/src/config/app-config.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}
}
14 changes: 14 additions & 0 deletions app/backend/src/config/env.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
});

/**
Expand Down Expand Up @@ -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;
}
73 changes: 73 additions & 0 deletions app/backend/src/module-factory.spec.ts
Original file line number Diff line number Diff line change
@@ -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<EnvConfig> = {
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);
});
});
44 changes: 44 additions & 0 deletions app/backend/src/module-factory.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>
| DynamicModule
| Promise<DynamicModule>
| ForwardReference<unknown>;

/**
* 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;
}
Loading