diff --git a/.env.example b/.env.example index d8ad42b..66879e3 100644 --- a/.env.example +++ b/.env.example @@ -1,56 +1,66 @@ -# Server -PORT=4000 - -# Redis -REDIS_HOST=redis -REDIS_PORT=6379 -REDIS_PASSWORD= -REDIS_URL=redis://redis:6379 - -# Database -DATABASE_URL=postgres://smartdrop:smartdrop@postgres:5432/smartdrop -# Runtime environment -# NODE_ENV: string enum (development, test, production). Default: development. -NODE_ENV=development - -# PORT: number. Default: 3000. -PORT=3000 - -# 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_URL: URL. Default: https://horizon.stellar.org. -STELLAR_HORIZON_URL=https://horizon.stellar.org - -# USDC_ISSUER: Stellar public key. Default: Stellar USDC issuer. -USDC_ISSUER=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA - -# COINGECKO_API_KEY: string. Optional. Default: empty. -COINGECKO_API_KEY= - -# COINMARKETCAP_API_KEY: string. Optional. Default: empty. -COINMARKETCAP_API_KEY= - -# 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 -ADMIN_API_KEY= - -# LOG_LEVEL: string enum (debug, info, warn, error). Default: info. -LOG_LEVEL=info - -# CORS +# Server +PORT=4000 + +# Redis +REDIS_HOST=redis +REDIS_PORT=6379 +REDIS_PASSWORD= +REDIS_URL=redis://redis:6379 + +# Database +DATABASE_URL=postgres://smartdrop:smartdrop@postgres:5432/smartdrop +# Runtime environment +# NODE_ENV: string enum (development, test, production). Default: development. +NODE_ENV=development + +# PORT: number. Default: 3000. +PORT=3000 + +# 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_URL: URL. Default: https://horizon.stellar.org. +STELLAR_HORIZON_URL=https://horizon.stellar.org + +# USDC_ISSUER: Stellar public key. Default: Stellar USDC issuer. +USDC_ISSUER=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA + +# COINGECKO_API_KEY: string. Optional. Default: empty. +COINGECKO_API_KEY= + +# COINMARKETCAP_API_KEY: string. Optional. Default: empty. +COINMARKETCAP_API_KEY= + +# 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 +ADMIN_API_KEY= + +# LOG_LEVEL: string enum (debug, info, warn, error). Default: info. +LOG_LEVEL=info + +# Rate limiting (Redis-backed, per IP) +# RATE_LIMIT_WINDOW_MS: global API window in milliseconds. Default: 60000 (1 minute). +RATE_LIMIT_WINDOW_MS=60000 +# RATE_LIMIT_MAX: max requests per IP per global window. Default: 100. +RATE_LIMIT_MAX=100 +# PRICE_RATELIMIT_WINDOW: price endpoint window in seconds. Default: 60. +PRICE_RATELIMIT_WINDOW=60 +# PRICE_RATELIMIT_MAX: max price requests per IP per window. Default: 30. +PRICE_RATELIMIT_MAX=30 + +# CORS CORS_ALLOWED_ORIGINS=http://localhost:4000,http://localhost:3001 \ No newline at end of file diff --git a/src/config.js b/src/config.js index 80f1547..2bb2906 100644 --- a/src/config.js +++ b/src/config.js @@ -1,101 +1,109 @@ -require('dotenv').config(); - -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 = { - nodeEnv: env.NODE_ENV, - port: env.PORT, - databaseUrl: env.DATABASE_URL, - redis: { - url: env.REDIS_URL, - }, - stellar: { - horizonUrl: env.STELLAR_HORIZON_URL, - usdcIssuer, - }, - coingecko: { - apiKey: env.COINGECKO_API_KEY, - baseUrl: 'https://api.coingecko.com/api/v3', - }, - coinmarketcap: { - apiKey: env.COINMARKETCAP_API_KEY, - baseUrl: 'https://pro-api.coinmarketcap.com/v1', - assetIssuerMap: { - XLM: { symbol: 'XLM' }, - [`USDC:${usdcIssuer}`]: { id: 3408 }, - }, - }, - price: { - 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: env.ADMIN_API_KEY, - }, - corsAllowedOrigins: (process.env.CORS_ALLOWED_ORIGINS || 'http://localhost:3000,http://localhost:3001') - .split(',') - .map((o) => o.trim()) - .filter(Boolean), - webhooks: { - maxAttempts: parseInt(process.env.WEBHOOK_MAX_ATTEMPTS, 10) || 3, - retryBaseMs: parseInt(process.env.WEBHOOK_RETRY_BASE_MS, 10) || 30000, - retryFactor: parseFloat(process.env.WEBHOOK_RETRY_FACTOR) || 2, - timeoutMs: parseInt(process.env.WEBHOOK_TIMEOUT_MS, 10) || 5000, - retryPollMs: parseInt(process.env.WEBHOOK_RETRY_POLL_MS, 10) || 5000, - retryBatchSize: parseInt(process.env.WEBHOOK_RETRY_BATCH, 10) || 25, - rateLimit: { - windowSeconds: parseInt(process.env.WEBHOOK_RATELIMIT_WINDOW, 10) || 60, - max: parseInt(process.env.WEBHOOK_RATELIMIT_MAX, 10) || 60, - }, - testRateLimit: { - windowSeconds: parseInt(process.env.WEBHOOK_TEST_RATELIMIT_WINDOW, 10) || 60, - max: parseInt(process.env.WEBHOOK_TEST_RATELIMIT_MAX, 10) || 5, - }, - }, -}; +require('dotenv').config(); + +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 = { + nodeEnv: env.NODE_ENV, + port: env.PORT, + databaseUrl: env.DATABASE_URL, + redis: { + url: env.REDIS_URL, + }, + stellar: { + horizonUrl: env.STELLAR_HORIZON_URL, + usdcIssuer, + }, + coingecko: { + apiKey: env.COINGECKO_API_KEY, + baseUrl: 'https://api.coingecko.com/api/v3', + }, + coinmarketcap: { + apiKey: env.COINMARKETCAP_API_KEY, + baseUrl: 'https://pro-api.coinmarketcap.com/v1', + assetIssuerMap: { + XLM: { symbol: 'XLM' }, + [`USDC:${usdcIssuer}`]: { id: 3408 }, + }, + }, + price: { + 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: env.ADMIN_API_KEY, + }, + corsAllowedOrigins: (process.env.CORS_ALLOWED_ORIGINS || 'http://localhost:3000,http://localhost:3001') + .split(',') + .map((o) => o.trim()) + .filter(Boolean), + rateLimit: { + windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10) || 60000, + max: parseInt(process.env.RATE_LIMIT_MAX, 10) || 100, + }, + priceRateLimit: { + windowSeconds: parseInt(process.env.PRICE_RATELIMIT_WINDOW, 10) || 60, + max: parseInt(process.env.PRICE_RATELIMIT_MAX, 10) || 30, + }, + webhooks: { + maxAttempts: parseInt(process.env.WEBHOOK_MAX_ATTEMPTS, 10) || 3, + retryBaseMs: parseInt(process.env.WEBHOOK_RETRY_BASE_MS, 10) || 30000, + retryFactor: parseFloat(process.env.WEBHOOK_RETRY_FACTOR) || 2, + timeoutMs: parseInt(process.env.WEBHOOK_TIMEOUT_MS, 10) || 5000, + retryPollMs: parseInt(process.env.WEBHOOK_RETRY_POLL_MS, 10) || 5000, + retryBatchSize: parseInt(process.env.WEBHOOK_RETRY_BATCH, 10) || 25, + rateLimit: { + windowSeconds: parseInt(process.env.WEBHOOK_RATELIMIT_WINDOW, 10) || 60, + max: parseInt(process.env.WEBHOOK_RATELIMIT_MAX, 10) || 60, + }, + testRateLimit: { + windowSeconds: parseInt(process.env.WEBHOOK_TEST_RATELIMIT_WINDOW, 10) || 60, + max: parseInt(process.env.WEBHOOK_TEST_RATELIMIT_MAX, 10) || 5, + }, + }, +}; diff --git a/src/index.js b/src/index.js index f7a5edf..e4cebda 100644 --- a/src/index.js +++ b/src/index.js @@ -1,82 +1,91 @@ -'use strict'; - -const express = require('express'); -const helmet = require('helmet'); -const config = require('./config'); -const logger = require('./logger'); -const cache = require('./services/cache'); -const priceRefreshJob = require('./jobs/priceRefresh'); -const webhookRetryWorker = require('./jobs/webhookRetryWorker'); -const buildCorsMiddleware = require('./middleware/cors'); -const { requestIdMiddleware } = require('./middleware/requestId'); -const { requireApiKey } = require('./middleware/auth'); -const { errorHandler, notFoundHandler } = require('./middleware/errorHandler'); -const pricesRouter = require('./routes/prices'); -const alertsRouter = require('./routes/alerts'); -const keysRouter = require('./routes/keys'); -const webhooksRouter = require('./routes/webhooks'); -const airdropsRouter = require('./routes/airdrops'); -const apiDocsRouter = require('./routes/apiDocs'); - -const priceWebSocket = require('./ws/priceWebSocket'); - -const app = express(); -let server; - -app.use(requestIdMiddleware); -app.use(helmet()); -app.use(buildCorsMiddleware(config.corsAllowedOrigins)); -app.use(express.json()); - -app.get('/health', (req, res) => { - const redisConnected = cache.isConnected(); - res.json({ - status: 'ok', - timestamp: new Date().toISOString(), - redis_connected: redisConnected, - redis_unavailable: !redisConnected, - }); -}); - -app.use('/api/v1', pricesRouter); -app.use('/api/v1', keysRouter); -app.use('/api/v1/alerts', requireApiKey()); -app.use('/api/v1', alertsRouter); -app.use('/api/v1', webhooksRouter); -app.use('/api/v1', airdropsRouter); -app.use('/api-docs', apiDocsRouter); - -app.use(notFoundHandler); -app.use(errorHandler); - -function shutdown(signal) { - return async () => { - logger.info(`${signal} received, shutting down`); - priceRefreshJob.stop(); - webhookRetryWorker.stop(); - require('./ws/PriceSubscriptionManager').stopHeartbeat(); - if (server) server.close(); - await cache.disconnect(); - process.exit(0); - }; -} - -if (require.main === module) { - server = app.listen(config.port, () => { - logger.info(`SmartDrop backend running on port ${config.port}`); - priceWebSocket.attach(server); - priceRefreshJob.start(); - webhookRetryWorker.start(); - }); - - process.on('SIGTERM', shutdown('SIGTERM')); - process.on('SIGINT', shutdown('SIGINT')); -} - -module.exports = app; -module.exports.app = app; -module.exports.server = server || { - close(callback) { - if (callback) callback(); - }, -}; +'use strict'; + +const express = require('express'); +const helmet = require('helmet'); +const config = require('./config'); +const logger = require('./logger'); +const cache = require('./services/cache'); +const priceRefreshJob = require('./jobs/priceRefresh'); +const webhookRetryWorker = require('./jobs/webhookRetryWorker'); +const buildCorsMiddleware = require('./middleware/cors'); +const buildRateLimit = require('./middleware/rateLimit'); +const { requestIdMiddleware } = require('./middleware/requestId'); +const { requireApiKey } = require('./middleware/auth'); +const { errorHandler, notFoundHandler } = require('./middleware/errorHandler'); +const pricesRouter = require('./routes/prices'); +const alertsRouter = require('./routes/alerts'); +const keysRouter = require('./routes/keys'); +const webhooksRouter = require('./routes/webhooks'); +const airdropsRouter = require('./routes/airdrops'); +const apiDocsRouter = require('./routes/apiDocs'); + +const priceWebSocket = require('./ws/priceWebSocket'); + +const app = express(); +let server; + +app.use(requestIdMiddleware); +app.use(helmet()); +app.use(buildCorsMiddleware(config.corsAllowedOrigins)); +app.use(express.json()); + +app.get('/health', (req, res) => { + const redisConnected = cache.isConnected(); + res.json({ + status: 'ok', + timestamp: new Date().toISOString(), + redis_connected: redisConnected, + redis_unavailable: !redisConnected, + }); +}); + +const globalApiLimit = buildRateLimit({ + windowSeconds: Math.floor(config.rateLimit.windowMs / 1000), + max: config.rateLimit.max, + keyPrefix: 'api', +}); + +app.use('/api/v1', globalApiLimit); +app.use('/api/v1', pricesRouter); +app.use('/api/v1', keysRouter); +app.use('/api/v1/alerts', requireApiKey()); +app.use('/api/v1', alertsRouter); +app.use('/api/v1', webhooksRouter); +app.use('/api/v1', airdropsRouter); +app.use('/api-docs', globalApiLimit); +app.use('/api-docs', apiDocsRouter); + +app.use(notFoundHandler); +app.use(errorHandler); + +function shutdown(signal) { + return async () => { + logger.info(`${signal} received, shutting down`); + priceRefreshJob.stop(); + webhookRetryWorker.stop(); + require('./ws/PriceSubscriptionManager').stopHeartbeat(); + if (server) server.close(); + await cache.disconnect(); + process.exit(0); + }; +} + +if (require.main === module) { + server = app.listen(config.port, () => { + logger.info(`SmartDrop backend running on port ${config.port}`); + priceWebSocket.attach(server); + priceRefreshJob.start(); + webhookRetryWorker.start(); + }); + + process.on('SIGTERM', shutdown('SIGTERM')); + process.on('SIGINT', shutdown('SIGINT')); +} + +module.exports = app; +module.exports.app = app; +module.exports.server = server || { + close(callback) { + if (callback) callback(); + }, +}; diff --git a/src/middleware/rateLimit.js b/src/middleware/rateLimit.js index 7a46cbb..3d11bb4 100644 --- a/src/middleware/rateLimit.js +++ b/src/middleware/rateLimit.js @@ -1,48 +1,56 @@ -'use strict'; - -const cache = require('../services/cache'); -const logger = require('../logger'); -const AppError = require('../errors/AppError'); - -/** - * Fixed-window rate limiter backed by Redis INCR + EXPIRE. - * Fails open if Redis is unreachable so a cache outage cannot lock out users. - */ -function buildRateLimit({ windowSeconds, max, keyPrefix }) { - if (!Number.isFinite(windowSeconds) || windowSeconds <= 0) { - throw new Error('windowSeconds must be a positive number'); - } - if (!Number.isFinite(max) || max <= 0) { - throw new Error('max must be a positive number'); - } - if (!keyPrefix || typeof keyPrefix !== 'string') { - throw new Error('keyPrefix is required'); - } - - return async function rateLimit(req, res, next) { - const identifier = req.ip || req.connection?.remoteAddress || 'unknown'; - const bucket = Math.floor(Date.now() / 1000 / windowSeconds); - const key = `ratelimit:${keyPrefix}:${identifier}:${bucket}`; - - try { - const redis = cache.getClient(); - const count = await redis.incr(key); - if (count === 1) { - await redis.expire(key, windowSeconds); - } - const remaining = Math.max(0, max - count); - res.setHeader('X-RateLimit-Limit', String(max)); - res.setHeader('X-RateLimit-Remaining', String(remaining)); - res.setHeader('X-RateLimit-Reset', String((bucket + 1) * windowSeconds)); - if (count > max) { - return next(new AppError('RATE_LIMITED', `Rate limit of ${max} requests per ${windowSeconds}s exceeded`, 429, { limit: max, window_seconds: windowSeconds })); - } - return next(); - } catch (err) { - logger.warn('Rate limit fail-open due to cache error', { error: err.message }); - return next(); - } - }; -} - -module.exports = buildRateLimit; +'use strict'; + +const cache = require('../services/cache'); +const logger = require('../logger'); +const AppError = require('../errors/AppError'); + +/** + * Fixed-window rate limiter backed by Redis INCR + EXPIRE. + * Fails open if Redis is unreachable so a cache outage cannot lock out users. + */ +function buildRateLimit({ windowSeconds, max, keyPrefix }) { + if (!Number.isFinite(windowSeconds) || windowSeconds <= 0) { + throw new Error('windowSeconds must be a positive number'); + } + if (!Number.isFinite(max) || max <= 0) { + throw new Error('max must be a positive number'); + } + if (!keyPrefix || typeof keyPrefix !== 'string') { + throw new Error('keyPrefix is required'); + } + + return async function rateLimit(req, res, next) { + const identifier = req.ip || req.connection?.remoteAddress || 'unknown'; + const bucket = Math.floor(Date.now() / 1000 / windowSeconds); + const key = `ratelimit:${keyPrefix}:${identifier}:${bucket}`; + + try { + const redis = cache.getClient(); + const count = await redis.incr(key); + if (count === 1) { + await redis.expire(key, windowSeconds); + } + const remaining = Math.max(0, max - count); + const resetAt = (bucket + 1) * windowSeconds; + const retryAfterSeconds = Math.max(1, resetAt - Math.floor(Date.now() / 1000)); + res.setHeader('X-RateLimit-Limit', String(max)); + res.setHeader('X-RateLimit-Remaining', String(remaining)); + res.setHeader('X-RateLimit-Reset', String(resetAt)); + if (count > max) { + res.setHeader('Retry-After', String(retryAfterSeconds)); + return next(new AppError( + 'RATE_LIMITED', + `Rate limit of ${max} requests per ${windowSeconds}s exceeded`, + 429, + { limit: max, window_seconds: windowSeconds, retry_after_seconds: retryAfterSeconds }, + )); + } + return next(); + } catch (err) { + logger.warn('Rate limit fail-open due to cache error', { error: err.message }); + return next(); + } + }; +} + +module.exports = buildRateLimit; diff --git a/src/routes/prices.js b/src/routes/prices.js index 1eabf86..c1d2ccb 100644 --- a/src/routes/prices.js +++ b/src/routes/prices.js @@ -1,73 +1,83 @@ -const express = require('express'); -const { requireApiKey } = require('../middleware/auth'); -const priceOracle = require('../services/priceOracle'); -const AppError = require('../errors/AppError'); - -const router = express.Router(); - -function validateAssetCode(assetCode) { - if (!assetCode || typeof assetCode !== 'string') return false; - if (assetCode.length < 1 || assetCode.length > 12) return false; - return /^[A-Z0-9]+$/.test(assetCode); -} - -function validateIssuer(issuer) { - if (!issuer) return true; - return /^G[A-Z0-9]{55}$/.test(issuer); -} - -function validatePriceRequest(assetCode, issuer) { - if (!validateAssetCode(assetCode)) { - throw new AppError('VALIDATION_ERROR', 'Asset code must be 1-12 uppercase alphanumeric characters', 400, { - field: 'assetCode', - received: assetCode, - constraint: 'regex', - }); - } - - if (!validateIssuer(issuer)) { - throw new AppError('VALIDATION_ERROR', 'Issuer must be a valid Stellar address (G...)', 400, { - field: 'issuer', - received: issuer, - constraint: 'stellar_public_key', - }); - } -} - -router.get('/prices/:asset_code', async (req, res, next) => { - try { - const { asset_code } = req.params; - const { issuer } = req.query; - const normalizedCode = asset_code.toUpperCase(); - validatePriceRequest(normalizedCode, issuer); - - const priceData = await priceOracle.getPrice(normalizedCode, issuer || null); - - if (priceData.price_usd === null) { - throw new AppError('NOT_FOUND', `No price data found for ${normalizedCode}`, 404, { asset_code: normalizedCode, issuer: issuer || null }); - } - - return res.json(priceData); - } catch (err) { - return next(err); - } -}); - -router.get('/prices/:asset_code/refresh', requireApiKey(), async (req, res, next) => { - try { - const { asset_code } = req.params; - const { issuer } = req.query; - const normalizedCode = asset_code.toUpperCase(); - validatePriceRequest(normalizedCode, issuer); - - const priceData = await priceOracle.fetchFreshPrice(normalizedCode, issuer || null); - if (priceData.price_usd === null) { - throw new AppError('UPSTREAM_ERROR', 'All price sources failed', 502, { asset_code: normalizedCode, issuer: issuer || null }); - } - return res.json(priceData); - } catch (err) { - return next(err); - } -}); - -module.exports = router; +const express = require('express'); +const config = require('../config'); +const { requireApiKey } = require('../middleware/auth'); +const buildRateLimit = require('../middleware/rateLimit'); +const priceOracle = require('../services/priceOracle'); +const AppError = require('../errors/AppError'); + +const router = express.Router(); + +const priceLimit = buildRateLimit({ + windowSeconds: config.priceRateLimit.windowSeconds, + max: config.priceRateLimit.max, + keyPrefix: 'prices', +}); + +router.use(priceLimit); + +function validateAssetCode(assetCode) { + if (!assetCode || typeof assetCode !== 'string') return false; + if (assetCode.length < 1 || assetCode.length > 12) return false; + return /^[A-Z0-9]+$/.test(assetCode); +} + +function validateIssuer(issuer) { + if (!issuer) return true; + return /^G[A-Z0-9]{55}$/.test(issuer); +} + +function validatePriceRequest(assetCode, issuer) { + if (!validateAssetCode(assetCode)) { + throw new AppError('VALIDATION_ERROR', 'Asset code must be 1-12 uppercase alphanumeric characters', 400, { + field: 'assetCode', + received: assetCode, + constraint: 'regex', + }); + } + + if (!validateIssuer(issuer)) { + throw new AppError('VALIDATION_ERROR', 'Issuer must be a valid Stellar address (G...)', 400, { + field: 'issuer', + received: issuer, + constraint: 'stellar_public_key', + }); + } +} + +router.get('/prices/:asset_code', async (req, res, next) => { + try { + const { asset_code } = req.params; + const { issuer } = req.query; + const normalizedCode = asset_code.toUpperCase(); + validatePriceRequest(normalizedCode, issuer); + + const priceData = await priceOracle.getPrice(normalizedCode, issuer || null); + + if (priceData.price_usd === null) { + throw new AppError('NOT_FOUND', `No price data found for ${normalizedCode}`, 404, { asset_code: normalizedCode, issuer: issuer || null }); + } + + return res.json(priceData); + } catch (err) { + return next(err); + } +}); + +router.get('/prices/:asset_code/refresh', requireApiKey(), async (req, res, next) => { + try { + const { asset_code } = req.params; + const { issuer } = req.query; + const normalizedCode = asset_code.toUpperCase(); + validatePriceRequest(normalizedCode, issuer); + + const priceData = await priceOracle.fetchFreshPrice(normalizedCode, issuer || null); + if (priceData.price_usd === null) { + throw new AppError('UPSTREAM_ERROR', 'All price sources failed', 502, { asset_code: normalizedCode, issuer: issuer || null }); + } + return res.json(priceData); + } catch (err) { + return next(err); + } +}); + +module.exports = router; diff --git a/test/apiRateLimit.test.js b/test/apiRateLimit.test.js new file mode 100644 index 0000000..b766bf7 --- /dev/null +++ b/test/apiRateLimit.test.js @@ -0,0 +1,97 @@ +'use strict'; + +const express = require('express'); +const request = require('supertest'); +const { createCacheMock } = require('./helpers/cacheMock'); + +const mockHelper = createCacheMock(); +const { reset } = mockHelper; + +jest.mock('../src/services/cache', () => mockHelper.cacheMock); +jest.mock('../src/logger', () => ({ + info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(), +})); + +const mockGetPrice = jest.fn(); +jest.mock('../src/services/priceOracle', () => ({ + getPrice: mockGetPrice, + fetchFreshPrice: jest.fn(), +})); + +const buildRateLimit = require('../src/middleware/rateLimit'); +const pricesRouter = require('../src/routes/prices'); +const { errorHandler } = require('../src/middleware/errorHandler'); + +function priceResponse() { + return { + asset_code: 'XLM', + issuer: null, + price_usd: 0.12, + source: 'coingecko', + fetched_at: '2026-06-25T00:00:00.000Z', + is_stale: false, + stale_warning: null, + sources_attempted: ['coingecko'], + redis_unavailable: false, + }; +} + +function buildApiApp({ globalMax = 100, globalWindowSeconds = 60 } = {}) { + const app = express(); + app.use(express.json()); + app.use('/api/v1', buildRateLimit({ + windowSeconds: globalWindowSeconds, + max: globalMax, + keyPrefix: 'api', + })); + app.use('/api/v1', pricesRouter); + app.use(errorHandler); + return app; +} + +beforeEach(() => { + reset(); + mockGetPrice.mockReset(); + mockGetPrice.mockResolvedValue(priceResponse()); +}); + +describe('API rate limiting integration', () => { + test('global limit returns 429 after max requests per IP', async () => { + const app = buildApiApp({ globalMax: 2, globalWindowSeconds: 60 }); + + await request(app).get('/api/v1/prices/XLM'); + await request(app).get('/api/v1/prices/XLM'); + const blocked = await request(app).get('/api/v1/prices/XLM'); + + expect(blocked.status).toBe(429); + expect(blocked.body.error.code).toBe('RATE_LIMITED'); + expect(blocked.body.error.details.retry_after_seconds).toBeGreaterThan(0); + expect(blocked.headers['x-ratelimit-limit']).toBe('2'); + expect(blocked.headers['retry-after']).toBeDefined(); + }); + + test('prices routes enforce stricter 30 req/min limit', async () => { + const app = buildApiApp({ globalMax: 100, globalWindowSeconds: 60 }); + + for (let i = 0; i < 30; i += 1) { + const res = await request(app).get('/api/v1/prices/XLM'); + expect(res.status).toBe(200); + expect(res.headers['x-ratelimit-limit']).toBe('30'); + } + + const blocked = await request(app).get('/api/v1/prices/XLM'); + expect(blocked.status).toBe(429); + expect(blocked.body.error.code).toBe('RATE_LIMITED'); + expect(blocked.headers['x-ratelimit-limit']).toBe('30'); + }); + + test('successful responses include rate-limit headers', async () => { + const app = buildApiApp({ globalMax: 100, globalWindowSeconds: 60 }); + const res = await request(app).get('/api/v1/prices/XLM'); + + expect(res.status).toBe(200); + expect(res.headers['x-ratelimit-limit']).toBe('30'); + expect(res.headers['x-ratelimit-remaining']).toBeDefined(); + expect(res.headers['x-ratelimit-reset']).toBeDefined(); + }); +}); diff --git a/test/errorHandler.test.js b/test/errorHandler.test.js index 6a81178..ea06933 100644 --- a/test/errorHandler.test.js +++ b/test/errorHandler.test.js @@ -1,100 +1,104 @@ -const request = require('supertest'); -const express = require('express'); -const AppError = require('../src/errors/AppError'); -const { requestIdMiddleware } = require('../src/middleware/requestId'); -const { errorHandler, notFoundHandler } = require('../src/middleware/errorHandler'); -const buildRateLimit = require('../src/middleware/rateLimit'); -const cache = require('../src/services/cache'); - -jest.mock('../src/logger', () => ({ - info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(), -})); - -jest.mock('../src/services/cache', () => ({ - getClient: jest.fn(), -})); - -function buildApp(route) { - const app = express(); - app.use(express.json()); - app.use(requestIdMiddleware); - route(app); - app.use(notFoundHandler); - app.use(errorHandler); - return app; -} - -describe('structured error responses', () => { - test.each([ - ['VALIDATION_ERROR', 400], - ['UNAUTHORIZED', 401], - ['NOT_FOUND', 404], - ['UPSTREAM_ERROR', 502], - ['INTERNAL_ERROR', 500], - ])('returns standard shape for %s', async (code, status) => { - const app = buildApp((app) => { - app.get('/boom', (_req, _res, next) => next(new AppError(code, `${code} message`, status, { field: 'x' }))); - }); - - const res = await request(app).get('/boom'); - - expect(res.status).toBe(status); - expect(res.body).toEqual({ - error: { - code, - message: `${code} message`, - details: { field: 'x' }, - request_id: expect.stringMatching(/^req_/), - } - }); - expect(res.headers['x-request-id']).toBe(res.body.error.request_id); - }); - - test('omits stack traces for unhandled errors', async () => { - const app = buildApp((app) => { - app.get('/boom', () => { throw new Error('secret stack details'); }); - }); - - const res = await request(app).get('/boom'); - - expect(res.status).toBe(500); - expect(res.body.error).toEqual({ - code: 'INTERNAL_ERROR', - message: 'An unexpected error occurred', - request_id: expect.stringMatching(/^req_/) - }); - expect(JSON.stringify(res.body)).not.toContain('secret stack details'); - expect(JSON.stringify(res.body)).not.toContain('stack'); - }); - - test('adds request_id to success responses', async () => { - const app = buildApp((app) => { - app.get('/ok', (_req, res) => res.json({ ok: true })); - }); - - const res = await request(app).get('/ok'); - - expect(res.status).toBe(200); - expect(res.body).toEqual({ ok: true, request_id: expect.stringMatching(/^req_/) }); - }); - - test('returns structured 404 for undefined routes', async () => { - const app = buildApp(() => {}); - const res = await request(app).get('/missing'); - expect(res.status).toBe(404); - expect(res.body.error.code).toBe('NOT_FOUND'); - expect(res.body.error.request_id).toMatch(/^req_/); - }); - - test('returns RATE_LIMITED shape', async () => { - cache.getClient.mockReturnValue({ incr: jest.fn().mockResolvedValue(2), expire: jest.fn().mockResolvedValue(1) }); - const app = buildApp((app) => { - app.get('/limited', buildRateLimit({ windowSeconds: 60, max: 1, keyPrefix: 'test' }), (_req, res) => res.json({ ok: true })); - }); - - const res = await request(app).get('/limited'); - expect(res.status).toBe(429); - expect(res.body.error.code).toBe('RATE_LIMITED'); - expect(res.body.error.details).toEqual({ limit: 1, window_seconds: 60 }); - }); -}); +const request = require('supertest'); +const express = require('express'); +const AppError = require('../src/errors/AppError'); +const { requestIdMiddleware } = require('../src/middleware/requestId'); +const { errorHandler, notFoundHandler } = require('../src/middleware/errorHandler'); +const buildRateLimit = require('../src/middleware/rateLimit'); +const cache = require('../src/services/cache'); + +jest.mock('../src/logger', () => ({ + info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(), +})); + +jest.mock('../src/services/cache', () => ({ + getClient: jest.fn(), +})); + +function buildApp(route) { + const app = express(); + app.use(express.json()); + app.use(requestIdMiddleware); + route(app); + app.use(notFoundHandler); + app.use(errorHandler); + return app; +} + +describe('structured error responses', () => { + test.each([ + ['VALIDATION_ERROR', 400], + ['UNAUTHORIZED', 401], + ['NOT_FOUND', 404], + ['UPSTREAM_ERROR', 502], + ['INTERNAL_ERROR', 500], + ])('returns standard shape for %s', async (code, status) => { + const app = buildApp((app) => { + app.get('/boom', (_req, _res, next) => next(new AppError(code, `${code} message`, status, { field: 'x' }))); + }); + + const res = await request(app).get('/boom'); + + expect(res.status).toBe(status); + expect(res.body).toEqual({ + error: { + code, + message: `${code} message`, + details: { field: 'x' }, + request_id: expect.stringMatching(/^req_/), + } + }); + expect(res.headers['x-request-id']).toBe(res.body.error.request_id); + }); + + test('omits stack traces for unhandled errors', async () => { + const app = buildApp((app) => { + app.get('/boom', () => { throw new Error('secret stack details'); }); + }); + + const res = await request(app).get('/boom'); + + expect(res.status).toBe(500); + expect(res.body.error).toEqual({ + code: 'INTERNAL_ERROR', + message: 'An unexpected error occurred', + request_id: expect.stringMatching(/^req_/) + }); + expect(JSON.stringify(res.body)).not.toContain('secret stack details'); + expect(JSON.stringify(res.body)).not.toContain('stack'); + }); + + test('adds request_id to success responses', async () => { + const app = buildApp((app) => { + app.get('/ok', (_req, res) => res.json({ ok: true })); + }); + + const res = await request(app).get('/ok'); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ ok: true, request_id: expect.stringMatching(/^req_/) }); + }); + + test('returns structured 404 for undefined routes', async () => { + const app = buildApp(() => {}); + const res = await request(app).get('/missing'); + expect(res.status).toBe(404); + expect(res.body.error.code).toBe('NOT_FOUND'); + expect(res.body.error.request_id).toMatch(/^req_/); + }); + + test('returns RATE_LIMITED shape', async () => { + cache.getClient.mockReturnValue({ incr: jest.fn().mockResolvedValue(2), expire: jest.fn().mockResolvedValue(1) }); + const app = buildApp((app) => { + app.get('/limited', buildRateLimit({ windowSeconds: 60, max: 1, keyPrefix: 'test' }), (_req, res) => res.json({ ok: true })); + }); + + const res = await request(app).get('/limited'); + expect(res.status).toBe(429); + expect(res.body.error.code).toBe('RATE_LIMITED'); + expect(res.body.error.details).toEqual({ + limit: 1, + window_seconds: 60, + retry_after_seconds: expect.any(Number), + }); + }); +}); diff --git a/test/rateLimit.test.js b/test/rateLimit.test.js index 4d622ee..55248a1 100644 --- a/test/rateLimit.test.js +++ b/test/rateLimit.test.js @@ -1,51 +1,53 @@ -'use strict'; - -const express = require('express'); -const request = require('supertest'); -const { createCacheMock } = require('./helpers/cacheMock'); - -const mockHelper = createCacheMock(); -const { reset } = mockHelper; - -jest.mock('../src/services/cache', () => mockHelper.cacheMock); -jest.mock('../src/logger', () => ({ - info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(), -})); - -const buildRateLimit = require('../src/middleware/rateLimit'); -const { errorHandler } = require('../src/middleware/errorHandler'); - -function buildApp(limiter) { - const app = express(); - app.use(limiter); - app.get('/test', (_req, res) => res.json({ ok: true })); - app.use(errorHandler); - return app; -} - -beforeEach(() => reset()); - -describe('rateLimit middleware', () => { - test('allows requests under the limit and sets rate-limit headers', async () => { - const app = buildApp(buildRateLimit({ windowSeconds: 60, max: 3, keyPrefix: 't' })); - const r1 = await request(app).get('/test'); - expect(r1.status).toBe(200); - expect(r1.headers['x-ratelimit-limit']).toBe('3'); - expect(r1.headers['x-ratelimit-remaining']).toBe('2'); - }); - - test('returns 429 once the limit is exceeded', async () => { - const app = buildApp(buildRateLimit({ windowSeconds: 60, max: 2, keyPrefix: 'lim' })); - await request(app).get('/test'); - await request(app).get('/test'); - const blocked = await request(app).get('/test'); - expect(blocked.status).toBe(429); - expect(blocked.body.error).toMatchObject({ code: 'RATE_LIMITED' }); - }); - - test('throws when configured with invalid options', () => { - expect(() => buildRateLimit({ windowSeconds: 0, max: 10, keyPrefix: 'x' })).toThrow(); - expect(() => buildRateLimit({ windowSeconds: 60, max: 0, keyPrefix: 'x' })).toThrow(); - expect(() => buildRateLimit({ windowSeconds: 60, max: 10 })).toThrow(); - }); -}); +'use strict'; + +const express = require('express'); +const request = require('supertest'); +const { createCacheMock } = require('./helpers/cacheMock'); + +const mockHelper = createCacheMock(); +const { reset } = mockHelper; + +jest.mock('../src/services/cache', () => mockHelper.cacheMock); +jest.mock('../src/logger', () => ({ + info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(), +})); + +const buildRateLimit = require('../src/middleware/rateLimit'); +const { errorHandler } = require('../src/middleware/errorHandler'); + +function buildApp(limiter) { + const app = express(); + app.use(limiter); + app.get('/test', (_req, res) => res.json({ ok: true })); + app.use(errorHandler); + return app; +} + +beforeEach(() => reset()); + +describe('rateLimit middleware', () => { + test('allows requests under the limit and sets rate-limit headers', async () => { + const app = buildApp(buildRateLimit({ windowSeconds: 60, max: 3, keyPrefix: 't' })); + const r1 = await request(app).get('/test'); + expect(r1.status).toBe(200); + expect(r1.headers['x-ratelimit-limit']).toBe('3'); + expect(r1.headers['x-ratelimit-remaining']).toBe('2'); + }); + + test('returns 429 once the limit is exceeded', async () => { + const app = buildApp(buildRateLimit({ windowSeconds: 60, max: 2, keyPrefix: 'lim' })); + await request(app).get('/test'); + await request(app).get('/test'); + const blocked = await request(app).get('/test'); + expect(blocked.status).toBe(429); + expect(blocked.body.error).toMatchObject({ code: 'RATE_LIMITED' }); + expect(blocked.body.error.details.retry_after_seconds).toBeGreaterThan(0); + expect(blocked.headers['retry-after']).toBeDefined(); + }); + + test('throws when configured with invalid options', () => { + expect(() => buildRateLimit({ windowSeconds: 0, max: 10, keyPrefix: 'x' })).toThrow(); + expect(() => buildRateLimit({ windowSeconds: 60, max: 0, keyPrefix: 'x' })).toThrow(); + expect(() => buildRateLimit({ windowSeconds: 60, max: 10 })).toThrow(); + }); +});