fix(C6): require explicit FMP API key

Remove the silent shared demo-key fallback for FMP-backed research and screener routes, document the required key, and make backend/.env.example trackable so setup guidance has one source of truth.

Refs: docs/AUDIT_REDESIGN.md item C6.

Co-Authored-By: GPT-5 Codex <noreply@openai.com>
This commit is contained in:
Saravana Achu Mac 2026-05-04 17:01:00 -07:00
parent 5ce3cc92f0
commit 1377bf2453
6 changed files with 161 additions and 6 deletions

View File

@ -126,6 +126,7 @@ Each surface has its own `.env.example`. The root `.env.example` is the comprehe
| `PLATFORM_JWT_PUBLIC_KEY` or `PLATFORM_JWT_JWKS_URL` | Prod | Platform JWT verification key | | `PLATFORM_JWT_PUBLIC_KEY` or `PLATFORM_JWT_JWKS_URL` | Prod | Platform JWT verification key |
| `JWT_SECRET` | Dev | Legacy/local JWT secret when `PLATFORM_AUTH_ENABLED=false` | | `JWT_SECRET` | Dev | Legacy/local JWT secret when `PLATFORM_AUTH_ENABLED=false` |
| `ALPACA_API_KEY` / `ALPACA_API_SECRET` | For trading | Exchange credentials | | `ALPACA_API_KEY` / `ALPACA_API_SECRET` | For trading | Exchange credentials |
| `FMP_API_KEY` | For research/screener | Financial Modeling Prep key required by `/api/research/*` and `/api/screener`; the shared `demo` key is intentionally rejected by the backend |
| `OPENAI_API_KEY` | For AI rules | Primary LLM provider | | `OPENAI_API_KEY` | For AI rules | Primary LLM provider |
| `ENABLE_TRADING` | — | Set `true` to enable live order execution (default `false`) | | `ENABLE_TRADING` | — | Set `true` to enable live order execution (default `false`) |
| `PAPER_TRADING` | — | Set `true` for paper mode | | `PAPER_TRADING` | — | Set `true` for paper mode |

128
backend/.env.example Normal file
View File

@ -0,0 +1,128 @@
# =============================================================================
# ByteLyst Trading — Backend Environment Configuration
# Copy this file to .env and fill in your values.
# Production: pull secrets from Azure Key Vault (see docs/AZURE_INFRASTRUCTURE.md)
# =============================================================================
# --- Server ---
API_PORT=4018
CORS_ALLOWED_ORIGINS=http://localhost:5173
# --- Platform Auth (required for production) ---
# Set PLATFORM_AUTH_ENABLED=true to require platform JWT (RS256) verification.
# For local dev with legacy Supabase JWTs, set false and provide JWT_SECRET.
PLATFORM_AUTH_ENABLED=false
PLATFORM_API_URL=http://localhost:4003/api
PLATFORM_JWT_ISSUER=bytelyst-platform
PLATFORM_JWT_PUBLIC_KEY=
PLATFORM_JWT_JWKS_URL=
JWT_SECRET=change-me-for-local-dev
# --- Azure Key Vault (optional — skipped if unset) ---
# Set to trigger automatic secret resolution at startup via DefaultAzureCredential.
# Pulls invttrdg-* secrets into the env vars below before the app starts.
AZURE_KEYVAULT_URL=
# --- Cosmos DB (primary data store) ---
# Pulled from Key Vault in production (invttrdg-cosmos-*).
COSMOS_ENDPOINT=https://cosmos-mywisprai.documents.azure.com:443/
COSMOS_KEY=your_cosmos_key
COSMOS_DATABASE=invttrdg
# --- Azure OpenAI (AI Foundry — preferred over direct OpenAI in production) ---
# Pulled from Key Vault in production (invttrdg-azure-openai-*).
# When set, LLM_PROVIDER is auto-detected as 'azure'.
AZURE_OPENAI_ENDPOINT=
AZURE_OPENAI_KEY=
AZURE_OPENAI_DEPLOYMENT=gpt-4o
# --- Plug-and-Play Configuration ---
PROVIDER=alpaca # Options: 'alpaca' or 'ccxt'
# Asset Details
SYMBOL=BTC/USD
TIMEFRAME=1Min
POLLING_INTERVAL=60000
# --- Alpaca Settings ---
ALPACA_API_KEY=your_key
ALPACA_API_SECRET=your_secret
PAPER_TRADING=true
ASSET_CLASS=crypto # 'crypto' or 'us_equity'
# --- Research Data (Financial Modeling Prep) ---
# Required for /api/research/* and /api/screener. Free tier: 250 req/day.
# Register at https://financialmodelingprep.com/developer/docs; do not use the
# shared "demo" key outside ad-hoc manual experiments.
FMP_API_KEY=your_fmp_key
# --- CCXT Settings ---
EXCHANGE=binance
CCXT_API_KEY=your_key
CCXT_API_SECRET=your_secret
# --- Notifications ---
WEBHOOK_URL=https://discord.com/api/webhooks/your/url
# --- AI Configuration ---
AI_PROVIDER=perplexity
PERPLEXITY_API_KEY=your_perplexity_key
OPENAI_API_KEY=your_openai_key
GEMINI_API_KEY=your_gemini_key
AI_FALLBACK_LIST=perplexity,openai,gemini
AI_MODEL=sonar
AI_CONFIDENCE_THRESHOLD=70
AI_CACHE_HOURS=4
# --- Features ---
LOW_STRESS_MODE=false
ENABLE_TREND_ALERTS=true
ENABLE_PULSE_ALERTS=true
ENABLE_TRADING=true
TOTAL_CAPITAL=1000
MAX_OPEN_TRADES=3
# Feature flags (opt-out: omit or true = enabled, false = disabled)
ENABLE_BACKTEST=false
BACKTEST_CUSTOMER_ENABLED=false
TAB_MARKETPLACE_ENABLED=true
TAB_MEMBERSHIP_ENABLED=true
ENABLE_STRICT_CAPITAL_GUARD=true
STRICT_CAPITAL_SLIPPAGE_BUFFER_PCT=1.0
STRICT_CAPITAL_FEE_BUFFER_PCT=0.15
STRICT_CAPITAL_MIN_RESERVE_USD=0
# --- Alpaca Omnibus Sub-tagging ---
ENABLE_ALPACA_SUBTAG=false
SUBTAG_OMNIBUS_ONLY=true
ALPACA_SUBTAG_ENV=paper
ALPACA_SUBTAG_MAX_LENGTH=48
ALPACA_SUBTAG_DISABLE_FOR_EXCHANGE=
ALPACA_OMNIBUS_PROFILE_ALLOWLIST=
# --- Reconciliation EXIT Backfill Safety ---
ENABLE_RECON_EXIT_BACKFILL=true
RECON_EXIT_BACKFILL_DRY_RUN=true
RECON_EXIT_BACKFILL_REQUIRE_PAUSE=true
RECON_EXIT_BACKFILL_DUST_ABS_QTY=0.001
RECON_EXIT_BACKFILL_DUST_REL_PCT=0.002
RECON_EXIT_BACKFILL_LOOKBACK_HOURS=72
RECON_EXIT_BACKFILL_REQUIRE_STRONG_ATTRIBUTION=true
RECON_EXIT_BACKFILL_ALLOW_HEURISTIC_MATCH=false
RECON_EXIT_BACKFILL_FILL_AFTER_TRADE_GRACE_MINUTES=5
# --- Reconciliation Parity Watchdog Auto-Resume ---
ENABLE_RECON_WATCHDOG_AUTO_RESUME=true
RECON_WATCHDOG_AUTO_RESUME_MIN_PAUSE_MS=900000
RECON_WATCHDOG_AUTO_RESUME_CLEAN_CYCLES=2
RECON_WATCHDOG_AUTO_RESUME_COOLDOWN_MS=1800000
# --- Supabase (legacy fallback — Cosmos DB is primary) ---
# Required only if Cosmos DB is not configured or for legacy auth fallback.
SUPABASE_URL=your_supabase_url
SUPABASE_KEY=your_supabase_key
# --- Product Identity ---
# Auto-set from Key Vault (invttrdg-product-id) in production; default is fine for local dev.
PRODUCT_ID=invttrdg

2
backend/.gitignore vendored
View File

@ -1,8 +1,8 @@
node_modules node_modules
! .env.example
.env .env
.env.* .env.*
*.env *.env
!.env.example
old_env.txt old_env.txt
new_env.txt new_env.txt
temp_env.txt temp_env.txt

View File

@ -61,7 +61,7 @@ export const config = {
// Research data — Financial Modeling Prep (free tier: 250 req/day) // Research data — Financial Modeling Prep (free tier: 250 req/day)
// Register free at https://financialmodelingprep.com/developer/docs // Register free at https://financialmodelingprep.com/developer/docs
FMP_API_KEY: process.env.FMP_API_KEY || 'demo', FMP_API_KEY: process.env.FMP_API_KEY || '',
// Supabase // Supabase
SUPABASE_URL: process.env.SUPABASE_URL || '', SUPABASE_URL: process.env.SUPABASE_URL || '',

View File

@ -130,6 +130,15 @@ const normalizeNewsSymbolsQuery = (value: unknown): string => {
return symbols.join(','); return symbols.join(',');
}; };
const getConfiguredFmpApiKey = (): string => {
const apiKey = config.FMP_API_KEY.trim();
if (!apiKey || apiKey.toLowerCase() === 'demo') {
throw new Error('FMP_API_KEY is required for research and screener endpoints');
}
return apiKey;
};
interface TradeAuditEvent { interface TradeAuditEvent {
event: string; event: string;
userId?: string; userId?: string;
@ -2805,7 +2814,7 @@ RULES:
try { try {
const symbol = String(req.query.symbol || '').trim().toUpperCase(); const symbol = String(req.query.symbol || '').trim().toUpperCase();
if (!symbol) return res.status(400).json({ error: 'symbol required' }); if (!symbol) return res.status(400).json({ error: 'symbol required' });
const apiKey = process.env.FMP_API_KEY || 'demo'; const apiKey = getConfiguredFmpApiKey();
const url = `https://financialmodelingprep.com/api/v3/profile/${symbol}?apikey=${apiKey}`; const url = `https://financialmodelingprep.com/api/v3/profile/${symbol}?apikey=${apiKey}`;
const data = await fetchFmpJson(url) as any; const data = await fetchFmpJson(url) as any;
res.json(Array.isArray(data) ? data[0] ?? {} : data); res.json(Array.isArray(data) ? data[0] ?? {} : data);
@ -2822,7 +2831,7 @@ RULES:
try { try {
const symbol = String(req.query.symbol || '').trim().toUpperCase(); const symbol = String(req.query.symbol || '').trim().toUpperCase();
if (!symbol) return res.status(400).json({ error: 'symbol required' }); if (!symbol) return res.status(400).json({ error: 'symbol required' });
const apiKey = process.env.FMP_API_KEY || 'demo'; const apiKey = getConfiguredFmpApiKey();
const url = `https://financialmodelingprep.com/api/v3/key-metrics/${symbol}?limit=4&apikey=${apiKey}`; const url = `https://financialmodelingprep.com/api/v3/key-metrics/${symbol}?limit=4&apikey=${apiKey}`;
const data = await fetchFmpJson(url) as any; const data = await fetchFmpJson(url) as any;
res.json(Array.isArray(data) ? data[0] ?? {} : data); res.json(Array.isArray(data) ? data[0] ?? {} : data);
@ -2839,7 +2848,7 @@ RULES:
try { try {
const symbol = String(req.query.symbol || '').trim().toUpperCase(); const symbol = String(req.query.symbol || '').trim().toUpperCase();
if (!symbol) return res.status(400).json({ error: 'symbol required' }); if (!symbol) return res.status(400).json({ error: 'symbol required' });
const apiKey = process.env.FMP_API_KEY || 'demo'; const apiKey = getConfiguredFmpApiKey();
const url = `https://financialmodelingprep.com/api/v3/historical/earning_calendar/${symbol}?limit=8&apikey=${apiKey}`; const url = `https://financialmodelingprep.com/api/v3/historical/earning_calendar/${symbol}?limit=8&apikey=${apiKey}`;
const data = await fetchFmpJson(url) as any; const data = await fetchFmpJson(url) as any;
res.json({ earnings: Array.isArray(data) ? data : [] }); res.json({ earnings: Array.isArray(data) ? data : [] });
@ -2854,7 +2863,7 @@ RULES:
// ── Screener: stock screener from FMP ──────────────────────────────── // ── Screener: stock screener from FMP ────────────────────────────────
this.app.get('/api/screener', this.requireAuth, async (req, res) => { this.app.get('/api/screener', this.requireAuth, async (req, res) => {
try { try {
const apiKey = process.env.FMP_API_KEY || 'demo'; const apiKey = getConfiguredFmpApiKey();
const qs = new URLSearchParams(); const qs = new URLSearchParams();
const sector = String(req.query.sector || '').trim(); const sector = String(req.query.sector || '').trim();
if (sector && sector !== 'All') { if (sector && sector !== 'All') {

View File

@ -13,6 +13,7 @@ import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
const apiServerSource = readFileSync(join(__dirname, 'src/services/apiServer.ts'), 'utf8'); const apiServerSource = readFileSync(join(__dirname, 'src/services/apiServer.ts'), 'utf8');
const configSource = readFileSync(join(__dirname, 'src/config/index.ts'), 'utf8');
function escapeRegExp(value: string) { function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@ -33,6 +34,10 @@ function assertSourceIncludes(fragment: string, message: string) {
assert.ok(apiServerSource.includes(fragment), message); assert.ok(apiServerSource.includes(fragment), message);
} }
function assertConfigIncludes(fragment: string, message: string) {
assert.ok(configSource.includes(fragment), message);
}
function assertSourceMatches(pattern: RegExp, message: string) { function assertSourceMatches(pattern: RegExp, message: string) {
assert.match(apiServerSource, pattern, message); assert.match(apiServerSource, pattern, message);
} }
@ -151,6 +156,18 @@ function testFmpProxyContracts() {
"import { fetchFmpJson, FmpFetchError } from './fmpCache.js';", "import { fetchFmpJson, FmpFetchError } from './fmpCache.js';",
'FMP routes must use the shared cache helper and typed upstream error', 'FMP routes must use the shared cache helper and typed upstream error',
); );
assertConfigIncludes(
"FMP_API_KEY: process.env.FMP_API_KEY || '',",
'FMP config must not silently default to the shared demo key',
);
assertSourceIncludes(
"apiKey.toLowerCase() === 'demo'",
'FMP routes must reject the shared demo key explicitly',
);
assertSourceIncludes(
'FMP_API_KEY is required for research and screener endpoints',
'FMP routes must surface an explicit missing-key error',
);
const fetchFmpCalls = apiServerSource.match(/fetchFmpJson\(url\)/g) ?? []; const fetchFmpCalls = apiServerSource.match(/fetchFmpJson\(url\)/g) ?? [];
assert.equal(fetchFmpCalls.length, 4, 'each FMP route must fetch through fetchFmpJson(url)'); assert.equal(fetchFmpCalls.length, 4, 'each FMP route must fetch through fetchFmpJson(url)');