Skip to content

Commit 54bb2ec

Browse files
authored
Merge pull request #72 from Gbangbolaoluwagbemiga/issue-56-secure-secret-management
feat(security): implement secure secret management and redact debug routes
2 parents d7f51ee + ce4e463 commit 54bb2ec

File tree

5 files changed

+330
-34
lines changed

5 files changed

+330
-34
lines changed

backend/src/config/featureFlags.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export const getFeatureFlags = (env: NodeJS.ProcessEnv = process.env): FeatureFl
2020

2121
const demoMode = parseBoolean(env.DEMO_MODE, !isProduction)
2222
const allowFallbackPrices = parseBoolean(env.ALLOW_FALLBACK_PRICES, !isProduction)
23-
const enableDebugRoutes = parseBoolean(env.ENABLE_DEBUG_ROUTES, !isProduction)
23+
const enableDebugRoutes = parseBoolean(env.ENABLE_DEBUG_ROUTES, false) // Default to false even in dev
2424
const allowMockPriceHistory = parseBoolean(env.ALLOW_MOCK_PRICE_HISTORY, demoMode)
2525
const allowDemoBalanceFallback = parseBoolean(env.ALLOW_DEMO_BALANCE_FALLBACK, demoMode)
2626
const enableDemoDbSeed = parseBoolean(env.ENABLE_DEMO_DB_SEED, demoMode)

backend/src/index.ts

Lines changed: 235 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,241 @@ import { startPortfolioCheckWorker, stopPortfolioCheckWorker } from './queue/wor
1919
import { startRebalanceWorker, stopRebalanceWorker } from './queue/workers/rebalanceWorker.js'
2020
import { startAnalyticsSnapshotWorker, stopAnalyticsSnapshotWorker } from './queue/workers/analyticsSnapshotWorker.js'
2121

22+
let startupConfig: StartupConfig
23+
try {
24+
startupConfig = validateStartupConfigOrThrow(process.env)
25+
logger.info('[STARTUP-CONFIG] Validation successful', buildStartupSummary(startupConfig))
26+
} catch (error) {
27+
const message = error instanceof Error ? error.message : String(error)
28+
console.error(message)
29+
process.exit(1)
30+
}
31+
32+
const app = express()
33+
const port = startupConfig.port
34+
const featureFlags = getFeatureFlags()
35+
const publicFeatureFlags = getPublicFeatureFlags()
36+
37+
const isProduction = startupConfig.nodeEnv === 'production'
38+
const allowedOrigins = startupConfig.corsOrigins
39+
40+
const corsOptions: cors.CorsOptions = {
41+
origin: isProduction
42+
? allowedOrigins.length > 0
43+
? (origin, cb) => {
44+
if (!origin || allowedOrigins.includes(origin)) cb(null, origin || true)
45+
else cb(new Error('Not allowed by CORS'))
46+
}
47+
: false
48+
: true,
49+
credentials: true,
50+
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'PATCH', 'HEAD'],
51+
allowedHeaders: ['Content-Type', 'Authorization', 'Accept', 'Origin', 'X-Requested-With', 'X-Public-Key', 'X-Message', 'X-Signature']
52+
}
53+
app.use(cors(corsOptions))
54+
55+
app.options('*', (req, res) => {
56+
const origin = req.get('Origin')
57+
if (isProduction && allowedOrigins.length > 0) {
58+
if (origin && allowedOrigins.includes(origin)) res.setHeader('Access-Control-Allow-Origin', origin)
59+
} else {
60+
res.setHeader('Access-Control-Allow-Origin', origin || '*')
61+
}
62+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS, PATCH')
63+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Accept, Origin, X-Requested-With, X-Public-Key, X-Message, X-Signature')
64+
res.status(204).end()
65+
})
66+
67+
// Trust proxy
68+
app.set('trust proxy', 1)
69+
70+
// Body parsing
71+
app.use(express.json({ limit: '10mb' }))
72+
app.use(express.urlencoded({ extended: true, limit: '10mb' }))
73+
74+
// Basic logging
75+
app.use((req, res, next) => {
76+
console.log(`${req.method} ${req.url}`)
77+
next()
78+
})
79+
80+
app.use(globalRateLimiter)
81+
82+
// Create auto-rebalancer instance
83+
const autoRebalancer = new AutoRebalancerService()
84+
85+
// Health check endpoint
86+
app.get('/health', (req, res) => {
87+
res.json({
88+
status: 'healthy',
89+
timestamp: new Date().toISOString(),
90+
environment: process.env.NODE_ENV || 'development',
91+
autoRebalancer: autoRebalancer ? autoRebalancer.getStatus() : { isRunning: false }
92+
})
93+
})
94+
95+
// CORS test endpoint
96+
app.get('/test/cors', (req, res) => {
97+
if (!featureFlags.enableDebugRoutes) {
98+
return res.status(404).json({ error: 'Route not found' })
99+
}
100+
res.json({
101+
success: true,
102+
message: 'CORS working!',
103+
origin: req.get('Origin'),
104+
timestamp: new Date().toISOString()
105+
})
106+
})
107+
108+
// CoinGecko test endpoint with detailed debugging
109+
app.get('/test/coingecko', async (req, res) => {
110+
if (!featureFlags.enableDebugRoutes) {
111+
return res.status(404).json({ error: 'Route not found' })
112+
}
113+
try {
114+
console.log('[TEST] Testing CoinGecko API...')
115+
const { ReflectorService } = await import('./services/reflector.js')
116+
const reflector = new ReflectorService()
117+
118+
// Test connectivity first
119+
const testResult = await reflector.testApiConnectivity()
120+
121+
if (!testResult.success) {
122+
return res.status(500).json({
123+
success: false,
124+
error: testResult.error
125+
})
126+
}
127+
128+
// Try to get actual prices
129+
reflector.clearCache()
130+
const prices = await reflector.getCurrentPrices()
131+
132+
res.json({
133+
success: true,
134+
prices,
135+
testResult,
136+
environment: process.env.NODE_ENV
137+
})
138+
} catch (error) {
139+
res.status(500).json({
140+
success: false,
141+
error: error instanceof Error ? error.message : String(error)
142+
})
143+
}
144+
})
145+
146+
// Root route
147+
app.get('/', (req, res) => {
148+
res.json({
149+
message: 'Stellar Portfolio Rebalancer API',
150+
status: 'running',
151+
version: '1.0.0',
152+
timestamp: new Date().toISOString(),
153+
features: {
154+
automaticRebalancing: !!autoRebalancer?.getStatus().isRunning,
155+
priceFeeds: true,
156+
riskManagement: true,
157+
portfolioManagement: true,
158+
featureFlags: publicFeatureFlags
159+
},
160+
endpoints: {
161+
health: '/health',
162+
corsTest: '/test/cors',
163+
coinGeckoTest: '/test/coingecko',
164+
autoRebalancerStatus: '/api/auto-rebalancer/status',
165+
queueHealth: '/api/queue/health'
166+
}
167+
})
168+
})
169+
170+
// Mount API routes
171+
app.use('/api', portfolioRouter)
172+
app.use('/', portfolioRouter)
173+
174+
// 404 handler
175+
app.use((req, res) => {
176+
console.log(`404 - Route not found: ${req.method} ${req.url}`)
177+
res.status(404).json({
178+
error: 'Route not found',
179+
method: req.method,
180+
url: req.url,
181+
availableEndpoints: {
182+
health: '/health',
183+
api: '/api/*',
184+
autoRebalancer: '/api/auto-rebalancer/*',
185+
queueHealth: '/api/queue/health'
186+
}
187+
})
188+
})
189+
190+
// Error handler
191+
app.use((error: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
192+
console.error('Server error:', error)
193+
res.status(500).json({
194+
error: 'Internal server error',
195+
message: error.message || 'Unknown error'
196+
})
197+
})
198+
199+
// Create server
200+
const server = createServer(app)
201+
202+
// WebSocket setup
203+
const wss = new WebSocketServer({ server })
204+
205+
wss.on('connection', (ws) => {
206+
console.log('WebSocket connection established')
207+
ws.send(JSON.stringify({
208+
type: 'connection',
209+
message: 'Connected',
210+
autoRebalancerStatus: autoRebalancer.getStatus()
211+
}))
212+
213+
ws.on('error', (error) => {
214+
console.error('WebSocket error:', error)
215+
})
216+
})
217+
218+
// Start existing rebalancing service (now queue-backed, no cron)
219+
try {
220+
const rebalancingService = new RebalancingService(wss)
221+
rebalancingService.start()
222+
console.log('[REBALANCING-SERVICE] Monitoring service started (queue-backed)')
223+
} catch (error) {
224+
console.error('Failed to start rebalancing service:', error)
225+
}
226+
227+
// Start server
228+
server.listen(port, async () => {
229+
console.log(`🚀 Server running on port ${port}`)
230+
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`)
231+
console.log(`CoinGecko API Key: ${!!process.env.COINGECKO_API_KEY ? 'SET' : 'NOT SET'}`)
232+
233+
// ── BullMQ / Redis setup ────────────────────────────────────────────────
234+
const redisAvailable = await isRedisAvailable()
235+
logQueueStartup(redisAvailable)
236+
237+
if (redisAvailable) {
238+
// Start all three workers
239+
startPortfolioCheckWorker()
240+
startRebalanceWorker()
241+
startAnalyticsSnapshotWorker()
242+
243+
// Register repeatable jobs (scheduler)
244+
try {
245+
await startQueueScheduler()
246+
console.log('[SCHEDULER] ✅ Queue scheduler registered')
247+
} catch (err) {
248+
console.error('[SCHEDULER] ❌ Failed to register scheduler:', err)
249+
}
250+
}
251+
252+
// ── Auto-rebalancer (queue-backed) ──────────────────────────────────────
253+
const shouldStartAutoRebalancer =
254+
process.env.NODE_ENV === 'production' ||
255+
process.env.ENABLE_AUTO_REBALANCER === 'true'
256+
22257
if (shouldStartAutoRebalancer) {
23258
try {
24259
console.log('[AUTO-REBALANCER] Starting automatic rebalancing service...')
@@ -43,13 +278,6 @@ import { startAnalyticsSnapshotWorker, stopAnalyticsSnapshotWorker } from './que
43278
console.log('[AUTO-REBALANCER] Set ENABLE_AUTO_REBALANCER=true to enable in development')
44279
}
45280

46-
// Contract event indexer (on-chain source-of-truth history)
47-
try {
48-
await contractEventIndexerService.start()
49-
} catch (error) {
50-
console.error('[CHAIN-INDEXER] Failed to start:', error)
51-
}
52-
53281
console.log('Available endpoints:')
54282
console.log(` Health: http://localhost:${port}/health`)
55283
console.log(` CORS Test: http://localhost:${port}/test/cors`)
@@ -91,13 +319,6 @@ const gracefulShutdown = async (signal: string) => {
91319
}
92320

93321
// Close database connection
94-
try {
95-
await contractEventIndexerService.stop()
96-
console.log('[SHUTDOWN] Contract event indexer stopped')
97-
} catch (error) {
98-
console.error('[SHUTDOWN] Error stopping contract event indexer:', error)
99-
}
100-
101322
try {
102323
databaseService.close()
103324
console.log('[SHUTDOWN] Database connection closed')

backend/src/services/reflector.ts

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { SorobanRpc } from '@stellar/stellar-sdk'
22
import type { PricesMap, PriceData } from '../types/index.js'
33
import { getFeatureFlags } from '../config/featureFlags.js'
4+
import { logger } from '../utils/logger.js' // Added logger import
45

56
export class ReflectorService {
67
private coinGeckoApiKey: string
@@ -25,20 +26,20 @@ export class ReflectorService {
2526

2627
async getCurrentPrices(): Promise<PricesMap> {
2728
try {
28-
console.log('[DEBUG] Fetching prices from CoinGecko with smart caching')
29+
logger.info('[DEBUG] Fetching prices from CoinGecko with smart caching')
2930
const assets = ['XLM', 'BTC', 'ETH', 'USDC']
3031

3132
// Check if we have fresh cached data for all assets
3233
const cachedPrices = this.getCachedPrices(assets)
3334
if (Object.keys(cachedPrices).length === assets.length) {
34-
console.log('[DEBUG] Using cached prices for all assets')
35+
logger.info('[DEBUG] Using cached prices for all assets')
3536
return cachedPrices
3637
}
3738

3839
// Check rate limiting more strictly
3940
const now = Date.now()
4041
if (now - this.lastRequestTime < this.MIN_REQUEST_INTERVAL) {
41-
console.log('[DEBUG] Rate limiting - using cached prices only')
42+
logger.info('[DEBUG] Rate limiting - using cached prices only')
4243
if (Object.keys(cachedPrices).length > 0) {
4344
return cachedPrices
4445
}
@@ -60,7 +61,7 @@ export class ReflectorService {
6061
const assets = ['XLM', 'BTC', 'ETH', 'USDC']
6162
const cachedPrices = this.getCachedPrices(assets)
6263
if (Object.keys(cachedPrices).length > 0) {
63-
console.log('[DEBUG] Using cached prices due to API error')
64+
logger.info('[DEBUG] Using cached prices due to API error')
6465
return cachedPrices
6566
}
6667

@@ -91,7 +92,7 @@ export class ReflectorService {
9192

9293
// Rate limiting - don't make requests too frequently
9394
if (now - this.lastRequestTime < this.MIN_REQUEST_INTERVAL) {
94-
console.log('[DEBUG] Rate limiting - using cached prices')
95+
logger.info('[DEBUG] Rate limiting - using cached prices')
9596
return {}
9697
}
9798

@@ -102,8 +103,8 @@ export class ReflectorService {
102103

103104
// FIXED: Use correct API endpoints
104105
const baseUrl = 'https://api.coingecko.com/api/v3'
105-
console.log('[DEBUG] Using API:', apiKey ? 'CoinGecko Pro' : 'CoinGecko Free')
106-
console.log('[DEBUG] Base URL:', baseUrl)
106+
logger.info('[DEBUG] Using API:', apiKey ? 'CoinGecko Pro' : 'CoinGecko Free')
107+
logger.info('[DEBUG] Base URL:', baseUrl)
107108

108109
const headers: Record<string, string> = {
109110
'Accept': 'application/json',
@@ -116,7 +117,7 @@ export class ReflectorService {
116117
.filter(Boolean)
117118
.join(',')
118119

119-
console.log('[DEBUG] Coin IDs:', coinIds)
120+
logger.info('[DEBUG] Coin IDs:', coinIds)
120121

121122
// FIXED: Correct API endpoint and parameters
122123
const endpoint = '/simple/price'
@@ -128,8 +129,8 @@ export class ReflectorService {
128129
})
129130

130131
const url = `${baseUrl}${endpoint}?${params.toString()}`
131-
console.log('[DEBUG] Full URL:', url)
132-
console.log('[DEBUG] Headers:', headers)
132+
logger.info('[DEBUG] Full URL:', url)
133+
logger.info('[DEBUG] Headers:', headers)
133134

134135
const controller = new AbortController()
135136
const timeoutId = setTimeout(() => controller.abort(), 15000)
@@ -142,8 +143,8 @@ export class ReflectorService {
142143

143144
clearTimeout(timeoutId)
144145

145-
console.log('[DEBUG] Response status:', response.status)
146-
console.log('[DEBUG] Response headers:', Object.fromEntries(response.headers.entries()))
146+
logger.info('[DEBUG] Response status:', response.status)
147+
logger.info('[DEBUG] Response headers:', Object.fromEntries(response.headers.entries()))
147148

148149
if (!response.ok) {
149150
// Get the actual error response
@@ -169,7 +170,7 @@ export class ReflectorService {
169170
}
170171

171172
const data = await response.json()
172-
console.log('[DEBUG] CoinGecko response data:', data)
173+
logger.info('[DEBUG] CoinGecko response data:', data)
173174

174175
const prices: PricesMap = {}
175176

0 commit comments

Comments
 (0)