Skip to content

Commit bfcc7b8

Browse files
authored
Merge pull request #63 from anoncon/feat/issue-52-feature-flags-demo-vs-prod
Introduce feature flags for demo vs production behavior
2 parents 8f4132e + 6342433 commit bfcc7b8

File tree

10 files changed

+241
-50
lines changed

10 files changed

+241
-50
lines changed

backend/.env.example

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -199,15 +199,33 @@ ENABLE_REQUEST_VALIDATION=true
199199
# DEVELOPMENT/DEBUG SETTINGS
200200
# ============================================
201201

202-
# Enable demo mode (simulated balances)
202+
# Feature Flags
203+
# In production, defaults are strict if unset:
204+
# DEMO_MODE=false
205+
# ALLOW_FALLBACK_PRICES=false
206+
# ENABLE_DEBUG_ROUTES=false
207+
208+
# Enable demo mode (simulated portfolio creation paths)
203209
DEMO_MODE=true
204210

211+
# Allow fallback market prices when external sources fail
212+
ALLOW_FALLBACK_PRICES=true
213+
214+
# Allow mock/generated price history when market history APIs fail
215+
ALLOW_MOCK_PRICE_HISTORY=true
216+
217+
# Allow debug and test endpoints (/debug/* and /test/*)
218+
ENABLE_DEBUG_ROUTES=true
219+
220+
# Allow demo balances when on-chain balance fetch fails
221+
ALLOW_DEMO_BALANCE_FALLBACK=true
222+
223+
# Seed demo portfolio/history into DB on first run
224+
ENABLE_DEMO_DB_SEED=true
225+
205226
# Demo portfolio initial balance (USD)
206227
DEMO_INITIAL_BALANCE=10000
207228

208-
# Enable test endpoints
209-
ENABLE_TEST_ENDPOINTS=true
210-
211229
# Mock external API calls (for testing)
212230
MOCK_EXTERNAL_APIS=false
213231

@@ -217,7 +235,11 @@ MOCK_EXTERNAL_APIS=false
217235

218236
# NODE_ENV=production
219237
# DEMO_MODE=false
220-
# ENABLE_TEST_ENDPOINTS=false
238+
# ALLOW_FALLBACK_PRICES=false
239+
# ALLOW_MOCK_PRICE_HISTORY=false
240+
# ENABLE_DEBUG_ROUTES=false
241+
# ALLOW_DEMO_BALANCE_FALLBACK=false
242+
# ENABLE_DEMO_DB_SEED=false
221243
# LOG_LEVEL=warn
222244
# DEBUG_PRICE_FEEDS=false
223245
# ENABLE_AUTO_REBALANCER=true

backend/src/api/routes.ts

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,16 @@ import { blockDebugInProduction } from '../middleware/debugGate.js'
1313
import { writeRateLimiter } from '../middleware/rateLimit.js'
1414
import { ConflictError } from '../types/index.js'
1515
import { getQueueMetrics } from '../queue/queueMetrics.js'
16+
import { getFeatureFlags, getPublicFeatureFlags } from '../config/featureFlags.js'
1617

1718
const router = Router()
1819
const stellarService = new StellarService()
1920
const reflectorService = new ReflectorService()
2021
const rebalanceHistoryService = new RebalanceHistoryService()
2122
const riskManagementService = new RiskManagementService()
23+
const featureFlags = getFeatureFlags()
24+
const publicFeatureFlags = getPublicFeatureFlags()
25+
const deploymentMode = featureFlags.demoMode ? 'demo' : 'production'
2226

2327
// Import autoRebalancer from index.js (will be available after server starts)
2428
let autoRebalancer: any = null
@@ -85,16 +89,17 @@ router.get('/health', (req, res) => {
8589
res.json({
8690
status: 'ok',
8791
timestamp: new Date().toISOString(),
88-
mode: 'demo',
92+
mode: deploymentMode,
8993
features: {
9094
contract_deployed: true,
9195
real_price_feeds: true,
9296
automatic_monitoring: true,
9397
circuit_breakers: true,
94-
demo_portfolios: true,
98+
demo_portfolios: featureFlags.demoMode,
9599
risk_management: true,
96100
rebalance_history: true,
97-
auto_rebalancer: autoRebalancer ? autoRebalancer.getStatus().isRunning : false
101+
auto_rebalancer: autoRebalancer ? autoRebalancer.getStatus().isRunning : false,
102+
flags: publicFeatureFlags
98103
}
99104
})
100105
})
@@ -150,14 +155,16 @@ router.post('/portfolio', writeRateLimiter, async (req, res) => {
150155
userAddress,
151156
allocations,
152157
threshold,
153-
mode: 'demo'
158+
mode: deploymentMode
154159
})
155160

156161
res.json({
157162
portfolioId,
158163
status: 'created',
159-
mode: 'demo',
160-
message: 'Portfolio created with simulated $10,000 balance'
164+
mode: deploymentMode,
165+
message: featureFlags.demoMode
166+
? 'Portfolio created with simulated $10,000 balance'
167+
: 'Portfolio created with real on-chain balances'
161168
})
162169
} catch (error) {
163170
logger.error('Failed to create portfolio', { error: getErrorObject(error) })
@@ -193,7 +200,7 @@ router.get('/portfolio/:id', async (req, res) => {
193200
portfolio,
194201
prices,
195202
riskMetrics,
196-
mode: 'demo',
203+
mode: deploymentMode,
197204
lastUpdated: new Date().toISOString()
198205
})
199206
} catch (error) {
@@ -322,7 +329,7 @@ router.post('/portfolio/:id/rebalance', writeRateLimiter, async (req, res) => {
322329
res.status(responseStatus).json({
323330
result,
324331
status: result.status === 'failed' ? 'failed' : 'completed',
325-
mode: 'demo',
332+
mode: deploymentMode,
326333
message: result.status === 'failed'
327334
? 'Rebalance execution failed safely'
328335
: result.status === 'partial'
@@ -578,7 +585,14 @@ router.get('/prices', async (req, res) => {
578585
} catch (error) {
579586
console.error('[ERROR] Prices endpoint failed:', error)
580587

581-
// Always return valid fallback data in correct format
588+
if (!featureFlags.allowFallbackPrices) {
589+
return res.status(503).json({
590+
success: false,
591+
error: 'Price feeds unavailable and ALLOW_FALLBACK_PRICES is disabled'
592+
})
593+
}
594+
595+
// Return explicit fallback data only when feature flag allows it.
582596
const fallbackPrices = {
583597
XLM: { price: 0.358878, change: -0.60, timestamp: Date.now() / 1000, source: 'fallback' },
584598
BTC: { price: 111150, change: 0.23, timestamp: Date.now() / 1000, source: 'fallback' },
@@ -817,8 +831,13 @@ router.get('/system/status', async (req, res) => {
817831
const circuitBreakers = riskManagementService.getCircuitBreakerStatus()
818832

819833
// Check API health
820-
const prices = await reflectorService.getCurrentPrices()
821-
const priceSourcesHealthy = Object.keys(prices).length > 0
834+
let priceSourcesHealthy = false
835+
try {
836+
const prices = await reflectorService.getCurrentPrices()
837+
priceSourcesHealthy = Object.keys(prices).length > 0
838+
} catch {
839+
priceSourcesHealthy = false
840+
}
822841

823842
// Auto-rebalancer status
824843
const autoRebalancerStatus = autoRebalancer ? autoRebalancer.getStatus() : { isRunning: false }
@@ -827,14 +846,14 @@ router.get('/system/status', async (req, res) => {
827846
res.json({
828847
success: true,
829848
system: {
830-
status: 'operational',
849+
status: priceSourcesHealthy ? 'operational' : 'degraded',
831850
uptime: process.uptime(),
832851
timestamp: new Date().toISOString(),
833852
version: '1.0.0'
834853
},
835854
portfolios: {
836855
total: portfolioCount,
837-
active: portfolioCount // Assuming all are active for demo
856+
active: portfolioCount
838857
},
839858
rebalanceHistory: historyStats,
840859
riskManagement: {
@@ -853,7 +872,8 @@ router.get('/system/status', async (req, res) => {
853872
webSockets: true,
854873
autoRebalancing: autoRebalancerStatus.isRunning,
855874
stellarNetwork: true
856-
}
875+
},
876+
featureFlags: publicFeatureFlags
857877
})
858878
} catch (error) {
859879
console.error('[ERROR] Failed to get system status:', error)

backend/src/config/featureFlags.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
export interface FeatureFlags {
2+
demoMode: boolean
3+
allowFallbackPrices: boolean
4+
enableDebugRoutes: boolean
5+
allowMockPriceHistory: boolean
6+
allowDemoBalanceFallback: boolean
7+
enableDemoDbSeed: boolean
8+
}
9+
10+
const parseBoolean = (value: string | undefined, fallback: boolean): boolean => {
11+
if (value === undefined || value === null || value.trim() === '') return fallback
12+
const normalized = value.trim().toLowerCase()
13+
if (normalized === 'true') return true
14+
if (normalized === 'false') return false
15+
return fallback
16+
}
17+
18+
export const getFeatureFlags = (env: NodeJS.ProcessEnv = process.env): FeatureFlags => {
19+
const isProduction = (env.NODE_ENV || 'development').trim().toLowerCase() === 'production'
20+
21+
const demoMode = parseBoolean(env.DEMO_MODE, !isProduction)
22+
const allowFallbackPrices = parseBoolean(env.ALLOW_FALLBACK_PRICES, !isProduction)
23+
const enableDebugRoutes = parseBoolean(env.ENABLE_DEBUG_ROUTES, !isProduction)
24+
const allowMockPriceHistory = parseBoolean(env.ALLOW_MOCK_PRICE_HISTORY, demoMode)
25+
const allowDemoBalanceFallback = parseBoolean(env.ALLOW_DEMO_BALANCE_FALLBACK, demoMode)
26+
const enableDemoDbSeed = parseBoolean(env.ENABLE_DEMO_DB_SEED, demoMode)
27+
28+
return {
29+
demoMode,
30+
allowFallbackPrices,
31+
enableDebugRoutes,
32+
allowMockPriceHistory,
33+
allowDemoBalanceFallback,
34+
enableDemoDbSeed
35+
}
36+
}
37+
38+
export const getPublicFeatureFlags = (env: NodeJS.ProcessEnv = process.env): Record<string, boolean> => {
39+
const flags = getFeatureFlags(env)
40+
return {
41+
DEMO_MODE: flags.demoMode,
42+
ALLOW_FALLBACK_PRICES: flags.allowFallbackPrices,
43+
ENABLE_DEBUG_ROUTES: flags.enableDebugRoutes,
44+
ALLOW_MOCK_PRICE_HISTORY: flags.allowMockPriceHistory,
45+
ALLOW_DEMO_BALANCE_FALLBACK: flags.allowDemoBalanceFallback,
46+
ENABLE_DEMO_DB_SEED: flags.enableDemoDbSeed
47+
}
48+
}

backend/src/config/startupConfig.ts

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { getFeatureFlags, type FeatureFlags } from './featureFlags.js'
2+
13
export interface StartupConfig {
24
nodeEnv: 'development' | 'test' | 'production'
35
port: number
@@ -7,6 +9,7 @@ export interface StartupConfig {
79
autoRebalancerEnabled: boolean
810
corsOrigins: string[]
911
hasRebalanceSigner: boolean
12+
featureFlags: FeatureFlags
1013
}
1114

1215
const NODE_ENVS = new Set(['development', 'test', 'production'])
@@ -30,6 +33,23 @@ export function validateStartupConfigOrThrow(env: NodeJS.ProcessEnv = process.en
3033
errors.push(`PORT '${env.PORT}' is invalid. Provide an integer between 1 and 65535.`)
3134
}
3235

36+
const featureFlags = getFeatureFlags(env)
37+
if (nodeEnv === 'production' && featureFlags.demoMode) {
38+
errors.push('DEMO_MODE cannot be true in production.')
39+
}
40+
if (nodeEnv === 'production' && featureFlags.allowDemoBalanceFallback) {
41+
warnings.push('ALLOW_DEMO_BALANCE_FALLBACK is enabled in production.')
42+
}
43+
if (nodeEnv === 'production' && featureFlags.enableDemoDbSeed) {
44+
warnings.push('ENABLE_DEMO_DB_SEED is enabled in production.')
45+
}
46+
if (nodeEnv === 'production' && featureFlags.allowMockPriceHistory) {
47+
warnings.push('ALLOW_MOCK_PRICE_HISTORY is enabled in production.')
48+
}
49+
if (nodeEnv === 'production' && featureFlags.allowFallbackPrices) {
50+
warnings.push('ALLOW_FALLBACK_PRICES is enabled in production.')
51+
}
52+
3353
const stellarNetworkRaw = (env.STELLAR_NETWORK || 'testnet').trim().toLowerCase()
3454
const stellarNetwork = STELLAR_NETWORKS.has(stellarNetworkRaw)
3555
? (stellarNetworkRaw as StartupConfig['stellarNetwork'])
@@ -71,9 +91,13 @@ export function validateStartupConfigOrThrow(env: NodeJS.ProcessEnv = process.en
7191
}
7292

7393
const signerSecret = (env.STELLAR_REBALANCE_SECRET || env.STELLAR_SECRET_KEY || '').trim()
74-
if (!signerSecret) {
75-
errors.push('Set STELLAR_REBALANCE_SECRET (or STELLAR_SECRET_KEY) for signed DEX rebalance execution.')
76-
} else if (!STELLAR_SECRET_REGEX.test(signerSecret)) {
94+
if (!featureFlags.demoMode || !featureFlags.allowDemoBalanceFallback) {
95+
if (!signerSecret) {
96+
errors.push('Set STELLAR_REBALANCE_SECRET (or STELLAR_SECRET_KEY) for signed DEX rebalance execution.')
97+
} else if (!STELLAR_SECRET_REGEX.test(signerSecret)) {
98+
errors.push('STELLAR_REBALANCE_SECRET format is invalid. Expected a Stellar secret starting with S.')
99+
}
100+
} else if (signerSecret && !STELLAR_SECRET_REGEX.test(signerSecret)) {
77101
errors.push('STELLAR_REBALANCE_SECRET format is invalid. Expected a Stellar secret starting with S.')
78102
}
79103

@@ -119,7 +143,8 @@ export function validateStartupConfigOrThrow(env: NodeJS.ProcessEnv = process.en
119143
stellarContractAddress: contractAddress,
120144
autoRebalancerEnabled,
121145
corsOrigins,
122-
hasRebalanceSigner: true
146+
hasRebalanceSigner: !!signerSecret,
147+
featureFlags
123148
}
124149
}
125150

@@ -132,7 +157,15 @@ export function buildStartupSummary(config: StartupConfig): Record<string, unkno
132157
contractAddress: maskValue(config.stellarContractAddress, 6, 4),
133158
autoRebalancerEnabled: config.autoRebalancerEnabled,
134159
rebalanceSignerConfigured: config.hasRebalanceSigner,
135-
corsOriginsConfigured: config.corsOrigins.length
160+
corsOriginsConfigured: config.corsOrigins.length,
161+
featureFlags: {
162+
demoMode: config.featureFlags.demoMode,
163+
allowFallbackPrices: config.featureFlags.allowFallbackPrices,
164+
enableDebugRoutes: config.featureFlags.enableDebugRoutes,
165+
allowMockPriceHistory: config.featureFlags.allowMockPriceHistory,
166+
allowDemoBalanceFallback: config.featureFlags.allowDemoBalanceFallback,
167+
enableDemoDbSeed: config.featureFlags.enableDemoDbSeed
168+
}
136169
}
137170
}
138171

backend/src/index.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,28 @@ import { RebalancingService } from './monitoring/rebalancer.js'
1010
import { AutoRebalancerService } from './services/autoRebalancer.js'
1111
import { logger } from './utils/logger.js'
1212
import { databaseService } from './services/databaseService.js'
13-
13+
import { validateStartupConfigOrThrow, buildStartupSummary, type StartupConfig } from './config/startupConfig.js'
14+
import { getFeatureFlags, getPublicFeatureFlags } from './config/featureFlags.js'
15+
import { isRedisAvailable, logQueueStartup } from './queue/connection.js'
16+
import { startQueueScheduler } from './queue/scheduler.js'
17+
import { startPortfolioCheckWorker } from './queue/workers/portfolioCheckWorker.js'
18+
import { startRebalanceWorker } from './queue/workers/rebalanceWorker.js'
19+
import { startAnalyticsSnapshotWorker } from './queue/workers/analyticsSnapshotWorker.js'
20+
21+
let startupConfig: StartupConfig
22+
try {
23+
startupConfig = validateStartupConfigOrThrow(process.env)
24+
logger.info('[STARTUP-CONFIG] Validation successful', buildStartupSummary(startupConfig))
25+
} catch (error) {
26+
const message = error instanceof Error ? error.message : String(error)
27+
console.error(message)
28+
process.exit(1)
29+
}
1430

1531
const app = express()
1632
const port = startupConfig.port
33+
const featureFlags = getFeatureFlags()
34+
const publicFeatureFlags = getPublicFeatureFlags()
1735

1836
const isProduction = startupConfig.nodeEnv === 'production'
1937
const allowedOrigins = startupConfig.corsOrigins
@@ -75,6 +93,9 @@ app.get('/health', (req, res) => {
7593

7694
// CORS test endpoint
7795
app.get('/test/cors', (req, res) => {
96+
if (!featureFlags.enableDebugRoutes) {
97+
return res.status(404).json({ error: 'Route not found' })
98+
}
7899
res.json({
79100
success: true,
80101
message: 'CORS working!',
@@ -85,6 +106,9 @@ app.get('/test/cors', (req, res) => {
85106

86107
// CoinGecko test endpoint with detailed debugging
87108
app.get('/test/coingecko', async (req, res) => {
109+
if (!featureFlags.enableDebugRoutes) {
110+
return res.status(404).json({ error: 'Route not found' })
111+
}
88112
try {
89113
console.log('[TEST] Testing CoinGecko API...')
90114
const { ReflectorService } = await import('./services/reflector.js')
@@ -134,7 +158,8 @@ app.get('/', (req, res) => {
134158
automaticRebalancing: !!autoRebalancer?.getStatus().isRunning,
135159
priceFeeds: true,
136160
riskManagement: true,
137-
portfolioManagement: true
161+
portfolioManagement: true,
162+
featureFlags: publicFeatureFlags
138163
},
139164
endpoints: {
140165
health: '/health',

backend/src/middleware/debugGate.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { Request, Response, NextFunction } from 'express'
2+
import { getFeatureFlags } from '../config/featureFlags.js'
23

34
export function blockDebugInProduction(req: Request, res: Response, next: NextFunction): void {
4-
if (process.env.NODE_ENV === 'production') {
5+
const flags = getFeatureFlags()
6+
if (!flags.enableDebugRoutes) {
57
res.status(404).json({ success: false, error: 'Not Found' })
68
return
79
}

0 commit comments

Comments
 (0)