diff --git a/.env.example b/.env.example index ba2f763..961c454 100644 --- a/.env.example +++ b/.env.example @@ -1,34 +1,45 @@ -# Server +# Runtime environment +# NODE_ENV: string enum (development, test, production). Default: development. +NODE_ENV=development + +# PORT: number. Default: 3000. PORT=3000 -# Redis -REDIS_HOST=localhost -REDIS_PORT=6379 -REDIS_PASSWORD= +# REDIS_URL: URL. Required in production. Development/test default: redis://localhost:6379. +REDIS_URL=redis://localhost:6379 + +# DATABASE_URL: URL. Required in production. Development default: postgres://localhost/smartdrop. Test default: postgres://localhost/smartdrop_test. +DATABASE_URL=postgres://localhost/smartdrop -# Stellar Horizon +# STELLAR_HORIZON_URL: URL. Default: https://horizon.stellar.org. STELLAR_HORIZON_URL=https://horizon.stellar.org -# Stellar USDC Issuer +# USDC_ISSUER: Stellar public key. Default: Stellar USDC issuer. USDC_ISSUER=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA -# CoinGecko (optional, free tier available) +# COINGECKO_API_KEY: string. Optional. Default: empty. COINGECKO_API_KEY= -# CoinMarketCap (requires API key) +# COINMARKETCAP_API_KEY: string. Optional. Default: empty. COINMARKETCAP_API_KEY= -# Price Oracle Settings -PRICE_CACHE_TTL=60 -PRICE_REFRESH_INTERVAL=30 -PRICE_STALE_THRESHOLD=5 -PRICE_ANOMALY_THRESHOLD=10 +# PRICE_CACHE_TTL_SECONDS: number. Default: 60. +PRICE_CACHE_TTL_SECONDS=60 + +# PRICE_REFRESH_INTERVAL_SECONDS: number. Default: 30. +PRICE_REFRESH_INTERVAL_SECONDS=30 + +# PRICE_STALE_THRESHOLD_MINUTES: number. Default: 5. +PRICE_STALE_THRESHOLD_MINUTES=5 + +# PRICE_ANOMALY_THRESHOLD_PCT: number. Default: 20. +PRICE_ANOMALY_THRESHOLD_PCT=20 # API key auth # Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" ADMIN_API_KEY= -# Logging +# LOG_LEVEL: string enum (debug, info, warn, error). Default: info. LOG_LEVEL=info # CORS diff --git a/README.md b/README.md index 2f02673..d23fff9 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Multi-source price oracle that fetches and caches USD prices for Stellar assets. - Redis caching with configurable TTL (default: 60s) - Background job refreshes prices every 30 seconds - Stale price detection (>5 minutes) -- Price anomaly logging (>10% changes) +- Price anomaly logging (>20% changes) - Fallback chain: DEX → CoinGecko → CoinMarketCap → cached ### Webhook Delivery System @@ -100,20 +100,20 @@ cp .env.example .env | Variable | Description | Default | Required | |----------|-------------|---------|----------| +| `NODE_ENV` | Runtime environment: `development`, `test`, or `production` | development | No | | `PORT` | Server port | 3000 | No | -| `REDIS_HOST` | Redis server host | localhost | No | -| `REDIS_PORT` | Redis server port | 6379 | No | -| `REDIS_PASSWORD` | Redis password | undefined | No | +| `REDIS_URL` | Redis connection URL | redis://localhost:6379 in development/test | Yes in production | +| `DATABASE_URL` | Database connection URL reserved for persistence-backed features | postgres://localhost/smartdrop in development, postgres://localhost/smartdrop_test in test | Yes in production | | `STELLAR_HORIZON_URL` | Horizon API URL | https://horizon.stellar.org | No | | `USDC_ISSUER` | USDC issuer address | GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA | No | -| `COINGECKO_API_KEY` | CoinGecko API key | undefined | No | -| `COINMARKETCAP_API_KEY` | CoinMarketCap API key | undefined | No | -| `PRICE_CACHE_TTL` | Cache TTL in seconds | 60 | No | -| `PRICE_REFRESH_INTERVAL` | Refresh interval in seconds | 30 | No | -| `PRICE_STALE_THRESHOLD` | Stale threshold in minutes | 5 | No | -| `PRICE_ANOMALY_THRESHOLD` | Anomaly detection threshold % | 10 | No | -| `ADMIN_API_KEY` | Bootstrap admin bearer token for API key management | undefined | Yes, for protected endpoints | -| `LOG_LEVEL` | Logging level | info | No | +| `COINGECKO_API_KEY` | CoinGecko API key | empty | No | +| `COINMARKETCAP_API_KEY` | CoinMarketCap API key | empty | No | +| `PRICE_CACHE_TTL_SECONDS` | Cache TTL in seconds | 60 | No | +| `PRICE_REFRESH_INTERVAL_SECONDS` | Refresh interval in seconds | 30 | No | +| `PRICE_STALE_THRESHOLD_MINUTES` | Stale threshold in minutes | 5 | No | +| `PRICE_ANOMALY_THRESHOLD_PCT` | Anomaly detection threshold % | 20 | No | +| `ADMIN_API_KEY` | Bootstrap admin bearer token for API key management | empty | Yes, for protected endpoints | +| `LOG_LEVEL` | Logging level: `debug`, `info`, `warn`, or `error` | info | No | ### Running @@ -303,8 +303,8 @@ module.exports = { fetchPrice }; If you see "Redis connection error" in logs: - Verify Redis is running: `redis-cli ping` -- Check Redis host and port in `.env` -- If using a password, ensure `REDIS_PASSWORD` is set correctly +- Check `REDIS_URL` in `.env` +- If Redis requires a password, include it in the connection URL ### Price Not Available @@ -325,7 +325,7 @@ External APIs may rate limit requests: The service logs important events: - Price fetches from each source -- Price anomalies (>10% changes) +- Price anomalies (>20% changes) - Stale price warnings - Cache refresh cycles - API errors diff --git a/package-lock.json b/package-lock.json index baea5fe..c338f95 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "cors": "^2.8.5", "csv-parser": "^3.2.1", "dotenv": "^16.4.5", + "envalid": "^8.2.0", "express": "^4.21.0", "helmet": "^8.2.0", "ioredis": "^5.4.1", @@ -19,8 +20,8 @@ "node-cron": "^3.0.3", "stellar-sdk": "^11.3.0", "winston": "^3.14.0", + "winston-daily-rotate-file": "^5.0.0", "zod": "^4.4.3" - "winston-daily-rotate-file": "^5.0.0" }, "devDependencies": { "jest": "^29.7.0", @@ -2317,6 +2318,18 @@ "node": ">= 0.8" } }, + "node_modules/envalid": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/envalid/-/envalid-8.2.0.tgz", + "integrity": "sha512-CkPvea95dwMYE1wnKX5mQXkOpiMs9O+ncv8NqZy+gW7FuzpUp06KTdUHb18xFG8CqQHmfmRqGLF5DuGaBWNrSw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -5461,6 +5474,12 @@ "node": ">= 14.0.0" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/tweetnacl": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", diff --git a/package.json b/package.json index 45dc22d..24f39a7 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "cors": "^2.8.5", "dotenv": "^16.4.5", "csv-parser": "^3.2.1", + "envalid": "^8.2.0", "express": "^4.21.0", "helmet": "^8.2.0", "ioredis": "^5.4.1", @@ -23,11 +24,11 @@ "node-cron": "^3.0.3", "stellar-sdk": "^11.3.0", "winston": "^3.14.0", - "zod": "^4.4.3" + "zod": "^4.4.3", "winston-daily-rotate-file": "^5.0.0" }, "devDependencies": { "jest": "^29.7.0", "supertest": "^7.2.2" } -} \ No newline at end of file +} diff --git a/src/config.js b/src/config.js index a56fbba..14ad113 100644 --- a/src/config.js +++ b/src/config.js @@ -1,24 +1,68 @@ require('dotenv').config(); -const usdcIssuer = process.env.USDC_ISSUER || 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA'; +const { cleanEnv, makeValidator, num, port, str, url } = require('envalid'); + +const stellarAddress = makeValidator((input) => { + if (!/^G[A-Z0-9]{55}$/.test(input)) { + throw new Error('must be a valid Stellar public key'); + } + return input; +}); + +const databaseDevDefault = + process.env.NODE_ENV === 'test' + ? 'postgres://localhost/smartdrop_test' + : 'postgres://localhost/smartdrop'; + +const rawEnv = { + ...process.env, + NODE_ENV: process.env.NODE_ENV || 'development', +}; + +const env = cleanEnv(rawEnv, { + NODE_ENV: str({ + default: 'development', + choices: ['development', 'test', 'production'], + }), + PORT: port({ default: 3000 }), + REDIS_URL: url({ devDefault: 'redis://localhost:6379' }), + DATABASE_URL: url({ devDefault: databaseDevDefault }), + STELLAR_HORIZON_URL: url({ default: 'https://horizon.stellar.org' }), + USDC_ISSUER: stellarAddress({ + default: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA', + }), + COINGECKO_API_KEY: str({ default: '' }), + COINMARKETCAP_API_KEY: str({ default: '' }), + ADMIN_API_KEY: str({ default: '' }), + PRICE_CACHE_TTL_SECONDS: num({ default: 60 }), + PRICE_REFRESH_INTERVAL_SECONDS: num({ default: 30 }), + PRICE_STALE_THRESHOLD_MINUTES: num({ default: 5 }), + PRICE_ANOMALY_THRESHOLD_PCT: num({ default: 20 }), + LOG_LEVEL: str({ + default: 'info', + choices: ['debug', 'info', 'warn', 'error'], + }), +}); + +const usdcIssuer = env.USDC_ISSUER; module.exports = { - port: process.env.PORT || 3000, + nodeEnv: env.NODE_ENV, + port: env.PORT, + databaseUrl: env.DATABASE_URL, redis: { - host: process.env.REDIS_HOST || 'localhost', - port: process.env.REDIS_PORT || 6379, - password: process.env.REDIS_PASSWORD || undefined, + url: env.REDIS_URL, }, stellar: { - horizonUrl: process.env.STELLAR_HORIZON_URL || 'https://horizon.stellar.org', + horizonUrl: env.STELLAR_HORIZON_URL, usdcIssuer, }, coingecko: { - apiKey: process.env.COINGECKO_API_KEY || '', + apiKey: env.COINGECKO_API_KEY, baseUrl: 'https://api.coingecko.com/api/v3', }, coinmarketcap: { - apiKey: process.env.COINMARKETCAP_API_KEY || '', + apiKey: env.COINMARKETCAP_API_KEY, baseUrl: 'https://pro-api.coinmarketcap.com/v1', assetIssuerMap: { XLM: { symbol: 'XLM' }, @@ -26,13 +70,13 @@ module.exports = { }, }, price: { - cacheTtl: parseInt(process.env.PRICE_CACHE_TTL, 10) || 60, - refreshInterval: parseInt(process.env.PRICE_REFRESH_INTERVAL, 10) || 30, - staleThresholdMinutes: parseInt(process.env.PRICE_STALE_THRESHOLD, 10) || 5, - anomalyThresholdPercent: parseFloat(process.env.PRICE_ANOMALY_THRESHOLD, 10) || 10, + cacheTtl: env.PRICE_CACHE_TTL_SECONDS, + refreshInterval: env.PRICE_REFRESH_INTERVAL_SECONDS, + staleThresholdMinutes: env.PRICE_STALE_THRESHOLD_MINUTES, + anomalyThresholdPercent: env.PRICE_ANOMALY_THRESHOLD_PCT, }, auth: { - adminApiKey: process.env.ADMIN_API_KEY || '', + adminApiKey: env.ADMIN_API_KEY, }, corsAllowedOrigins: (process.env.CORS_ALLOWED_ORIGINS || 'http://localhost:3000,http://localhost:3001') .split(',') diff --git a/src/index.js b/src/index.js index 431c8be..0fd7272 100644 --- a/src/index.js +++ b/src/index.js @@ -13,6 +13,7 @@ const webhooksRouter = require('./routes/webhooks'); const airdropsRouter = require('./routes/airdrops'); const app = express(); +let server; app.use(helmet()); app.use(buildCorsMiddleware(config.corsAllowedOrigins)); @@ -30,7 +31,7 @@ app.get('/health', (req, res) => { app.use('/api/v1', pricesRouter); app.use('/api/v1', keysRouter); -app.use('/api/v1', requireApiKey(), alertsRouter); +app.use('/api/v1/alerts', requireApiKey()); app.use('/api/v1', alertsRouter); app.use('/api/v1', webhooksRouter); app.use('/api/v1', airdropsRouter); @@ -42,7 +43,7 @@ app.use((err, req, res, _next) => { }); if (require.main === module) { - const server = app.listen(config.port, () => { + server = app.listen(config.port, () => { logger.info(`SmartDrop backend running on port ${config.port}`); priceRefreshJob.start(); }); @@ -64,4 +65,10 @@ if (require.main === module) { }); } -module.exports = {app, server}; +module.exports = app; +module.exports.app = app; +module.exports.server = server || { + close(callback) { + if (callback) callback(); + }, +}; diff --git a/src/routes/keys.js b/src/routes/keys.js index 3b4f8a8..fc3a130 100644 --- a/src/routes/keys.js +++ b/src/routes/keys.js @@ -5,7 +5,7 @@ const logger = require('../logger'); const router = express.Router(); -router.use(requireApiKey({ scopes: ['admin'] })); +router.use('/keys', requireApiKey({ scopes: ['admin'] })); function validateScopes(scopes) { if (scopes === undefined) return null; diff --git a/src/services/cache.js b/src/services/cache.js index e3ee57d..51b3fde 100644 --- a/src/services/cache.js +++ b/src/services/cache.js @@ -6,8 +6,7 @@ let client = null; function getClient() { if (!client) { - client = new Redis({ - ...config.redis, + client = new Redis(config.redis.url, { lazyConnect: true, enableOfflineQueue: false, }); diff --git a/test/alerts-routes.test.js b/test/alerts-routes.test.js index 87244e2..b96c20b 100644 --- a/test/alerts-routes.test.js +++ b/test/alerts-routes.test.js @@ -1,5 +1,8 @@ 'use strict'; +const adminApiKey = 'a'.repeat(64); +process.env.ADMIN_API_KEY = adminApiKey; + const mockRedis = { smembers: jest.fn(async () => []), }; @@ -36,7 +39,8 @@ describe('GET /api/v1/alerts pagination', () => { test('returns pagination envelope', async () => { const response = await request(app) - .get('/api/v1/alerts'); + .get('/api/v1/alerts') + .set('Authorization', `Bearer ${adminApiKey}`); expect(response.statusCode).toBe(200); @@ -51,4 +55,4 @@ describe('GET /api/v1/alerts pagination', () => { }); -}); \ No newline at end of file +}); diff --git a/test/config.test.js b/test/config.test.js new file mode 100644 index 0000000..ee0c74a --- /dev/null +++ b/test/config.test.js @@ -0,0 +1,75 @@ +'use strict'; + +const path = require('path'); +const { spawnSync } = require('child_process'); + +const repoRoot = path.join(__dirname, '..'); + +function cleanProcessEnv(overrides) { + return { + PATH: process.env.PATH, + Path: process.env.Path, + SystemRoot: process.env.SystemRoot, + COMSPEC: process.env.COMSPEC, + TEMP: process.env.TEMP, + TMP: process.env.TMP, + ...overrides, + }; +} + +function runConfig(script, env) { + return spawnSync(process.execPath, ['-e', script], { + cwd: repoRoot, + env: cleanProcessEnv(env), + encoding: 'utf8', + }); +} + +describe('configuration validation', () => { + test('exits before startup and reports every invalid production variable', () => { + const result = runConfig("require('./src/config')", { + NODE_ENV: 'production', + REDIS_URL: 'not-a-url', + LOG_LEVEL: 'verbose', + PRICE_CACHE_TTL_SECONDS: 'soon', + }); + + const output = `${result.stdout}\n${result.stderr}`; + + expect(result.status).toBe(1); + expect(output).toContain('DATABASE_URL'); + expect(output).toContain('REDIS_URL'); + expect(output).toContain('LOG_LEVEL'); + expect(output).toContain('PRICE_CACHE_TTL_SECONDS'); + }); + + test('loads safe in-process defaults under NODE_ENV=test', () => { + const result = runConfig( + [ + "const config = require('./src/config');", + 'console.log(JSON.stringify({', + ' port: config.port,', + ' databaseUrl: config.databaseUrl,', + ' redisUrl: config.redis.url,', + ' price: config.price,', + '}));', + ].join(' '), + { NODE_ENV: 'test' } + ); + + expect(result.status).toBe(0); + + const parsed = JSON.parse(result.stdout.trim()); + expect(parsed).toEqual({ + port: 3000, + databaseUrl: 'postgres://localhost/smartdrop_test', + redisUrl: 'redis://localhost:6379', + price: { + cacheTtl: 60, + refreshInterval: 30, + staleThresholdMinutes: 5, + anomalyThresholdPercent: 20, + }, + }); + }); +});