import 'dart:io'; import 'alpaca/alpaca_env.dart'; import 'market_history_env.dart'; import 'package:dotenv/dotenv.dart'; class ServerEnv { ServerEnv._({ required this.databaseUrl, required this.port, required this.firebaseWebApiKey, required this.questionWorkerEnabled, required this.questionWorkerIntervalSeconds, required this.questionPipelineTestMode, required this.prospectiveAnswerClosenessEnabled, required this.tradingEnabled, required this.tradingWorkerIngestEnabled, required this.tradingWorkerEvalEnabled, required this.tradingDevEndpointsEnabled, required this.adminPortalEnabled, required this.adminFirebaseUids, required this.marketHistory, required this.alpaca, required this.questionDispatcherDebugLoggingEnabled, required this.merriamWebsterApiKey, required this.googleCloudApiKey, }); final String databaseUrl; final int port; final String firebaseWebApiKey; final bool questionWorkerEnabled; final int questionWorkerIntervalSeconds; final bool questionPipelineTestMode; final bool questionDispatcherDebugLoggingEnabled; final String? merriamWebsterApiKey; final String? googleCloudApiKey; /// When true, prospective answers earn closeness bonus points and wrong /// direction scores `-2` instead of `-1`. Default false (`+1`/`-1` only). final bool prospectiveAnswerClosenessEnabled; final bool tradingEnabled; final bool tradingWorkerIngestEnabled; final bool tradingWorkerEvalEnabled; /// Mounts dev-only endpoints under `/v1/me/trading/dev/*` (e.g. `force-fire`). /// Default false — never enable in production. final bool tradingDevEndpointsEnabled; final bool adminPortalEnabled; final Set adminFirebaseUids; /// Rolling market-history sync, retention, and guess-rule settings. final MarketHistoryEnv marketHistory; /// Shorthand for [MarketHistoryEnv.syncEnabled]. bool get marketHistorySyncEnabled => marketHistory.syncEnabled; final AlpacaEnv alpaca; /// Validates cross-flag constraints after [load]. void assertConsistent() { marketHistory.assertConsistent(tradingEnabled: tradingEnabled); } static ServerEnv load() { final DotEnv env = DotEnv(includePlatformEnvironment: true) ..load(['.env']); // Build a sanitized snapshot of the Alpaca-relevant keys for AlpacaEnv. const List alpacaKeys = [ 'ALPACA_API_KEY_ID', 'ALPACA_API_SECRET_KEY', 'ALPACA_TRADING_BASE_URL', 'ALPACA_DATA_BASE_URL', 'ALPACA_DATA_FEED', 'ALPACA_ALLOW_LIVE', ]; final Map envMap = { for (final String key in alpacaKeys) if (env[key] != null && env[key]!.isNotEmpty) key: env[key]!, }; final String? databaseUrl = env['DATABASE_URL']; if (databaseUrl == null || databaseUrl.isEmpty) { stderr.writeln('DATABASE_URL is required in server/.env'); exit(1); } final String? apiKey = env['FIREBASE_WEB_API_KEY']; if (apiKey == null || apiKey.isEmpty) { stderr.writeln('FIREBASE_WEB_API_KEY is required in server/.env'); exit(1); } final int port = int.tryParse(env['PORT'] ?? '3000') ?? 3000; final bool workerEnabled = (env['QUESTION_WORKER_ENABLED'] ?? 'true').toLowerCase() != 'false'; final int workerIntervalSeconds = int.tryParse(env['QUESTION_WORKER_INTERVAL_SECONDS'] ?? '60') ?? 60; final bool pipelineTestMode = (env['QUESTION_PIPELINE_TEST_MODE'] ?? 'false').toLowerCase() == 'true'; final bool prospectiveAnswerClosenessEnabled = (env['PROSPECTIVE_ANSWER_CLOSENESS_ENABLED'] ?? 'false') .toLowerCase() == 'true'; final bool tradingEnabled = (env['TRADING_ENABLED'] ?? 'false').toLowerCase() == 'true'; final bool tradingWorkerIngestEnabled = (env['TRADING_WORKER_INGEST_ENABLED'] ?? 'true').toLowerCase() != 'false'; final bool tradingWorkerEvalEnabled = (env['TRADING_WORKER_EVAL_ENABLED'] ?? 'true').toLowerCase() != 'false'; final bool tradingDevEndpointsEnabled = (env['TRADING_DEV_ENDPOINTS_ENABLED'] ?? 'false').toLowerCase() == 'true'; final bool adminPortalEnabled = (env['ADMIN_PORTAL_ENABLED'] ?? 'false').toLowerCase() == 'true'; final Set adminFirebaseUids = (env['ADMIN_FIREBASE_UIDS'] ?? '') .split(',') .map((String value) => value.trim()) .where((String value) => value.isNotEmpty) .toSet(); final bool questionDispatcherDebugLoggingEnabled = (env['QUESTION_DISPATCHER_DEBUG_LOGGING_ENABLED'] ?? 'false') .toLowerCase() == 'true'; final String? merriamWebsterApiKey = env['MERRIAM_WEBSTER_API_KEY']; final String? googleCloudApiKey = env['GOOGLE_CLOUD_API_KEY']; final Map stringEnv = Map.from(Platform.environment); const List marketHistoryKeys = [ 'MARKET_HISTORY_SYNC_ENABLED', 'MARKET_HISTORY_WINDOW_DAYS', 'MARKET_HISTORY_RETENTION_DAYS', 'MARKET_HISTORY_ARCHIVE_ENABLED', 'MARKET_UNIVERSE_REFRESH_HOURS', 'MARKET_HISTORY_SYNC_HOURS', 'MARKET_HISTORY_CLEANUP_HOURS', 'MARKET_HISTORY_SYNC_HOUR_UTC', 'HISTORY_SYNC_BATCH_SIZE', 'HISTORY_SYNC_MAX_SYMBOLS', 'MIN_BARS_FOR_GUESS', 'GUESS_COOLDOWN_HOURS', ]; for (final String key in marketHistoryKeys) { final String? value = env[key]; if (value != null && value.isNotEmpty) { stringEnv[key] = value; } } final MarketHistoryEnv marketHistory = MarketHistoryEnv.fromMap(stringEnv); final AlpacaEnv alpaca = AlpacaEnv.fromMap(envMap)..assertPaperOnly(); final ServerEnv loaded = ServerEnv._( databaseUrl: databaseUrl, port: port, firebaseWebApiKey: apiKey, questionWorkerEnabled: workerEnabled, questionWorkerIntervalSeconds: workerIntervalSeconds, questionPipelineTestMode: pipelineTestMode, prospectiveAnswerClosenessEnabled: prospectiveAnswerClosenessEnabled, tradingEnabled: tradingEnabled, tradingWorkerIngestEnabled: tradingWorkerIngestEnabled, tradingWorkerEvalEnabled: tradingWorkerEvalEnabled, tradingDevEndpointsEnabled: tradingDevEndpointsEnabled, adminPortalEnabled: adminPortalEnabled, adminFirebaseUids: adminFirebaseUids, marketHistory: marketHistory, alpaca: alpaca, questionDispatcherDebugLoggingEnabled: questionDispatcherDebugLoggingEnabled, merriamWebsterApiKey: merriamWebsterApiKey, googleCloudApiKey: googleCloudApiKey, ); loaded.assertConsistent(); return loaded; } }