From 78a3d4819b0f0a27986cf2356d9dc3b300d0e62b Mon Sep 17 00:00:00 2001 From: Jossy-ossy Date: Fri, 16 Oct 2026 22:59:10 +0100 Subject: [PATCH 1/2] feat: validate environment configuration at startup --- .env.example | 15 +++ src/__tests__/bazaarRateLimit.test.ts | 4 +- src/__tests__/configEnvironment.test.ts | 28 ++++ src/__tests__/envDrift.test.ts | 37 +++-- src/api/auth.ts | 3 +- src/api/schemas.ts | 3 +- src/api/websocket.ts | 5 +- src/app.ts | 7 +- src/bazaar/rateLimit.ts | 9 +- src/config.ts | 172 +++++++++++++++++++----- src/db.ts | 6 +- src/index.ts | 10 +- src/jobs/aggregateRefresh.ts | 3 +- src/jobs/snapshotRetention.ts | 6 +- src/middleware/x402.ts | 3 +- src/network/enabledNetworks.ts | 4 +- src/routes/pairs.ts | 4 +- src/x402/network.ts | 4 +- 18 files changed, 231 insertions(+), 92 deletions(-) create mode 100644 src/__tests__/configEnvironment.test.ts diff --git a/.env.example b/.env.example index a23c6be6..0d3dcada 100644 --- a/.env.example +++ b/.env.example @@ -75,11 +75,19 @@ NETWORK_PASSPHRASE_TESTNET=Test SDF Network ; September 2015 SOROSWAP_FACTORY_ADDRESS_TESTNET=CDP3HMUH6SMS3S7NPGNDJLULCOXXEPSHY4JKUKMBNQMATHDHWXRRJTBY # Soroswap has a testnet deployment; leave enabled. Set to "false" to disable. SOROSWAP_ENABLED_TESTNET=true +# Optional per-network token-list override; otherwise SOROSWAP_TOKEN_LIST_URL is used. +SOROSWAP_TOKEN_LIST_URL_TESTNET= +# Optional per-network poll interval override in milliseconds. +SOROSWAP_POLL_INTERVAL_MS_TESTNET= # Aquarius has no public testnet deployment today — disabled by default. AQUARIUS_ENABLED_TESTNET=false # Reflector oracle contract (56-char C... id) — see https://reflector.network. Empty = oracle disabled. # A malformed id disables the oracle on this network and logs a warning naming this variable. REFLECTOR_CONTRACT_ID_TESTNET= +# Set false to keep the oracle disabled even when a contract ID is configured. +REFLECTOR_ENABLED_TESTNET=true +# Optional per-network Aquarius endpoint override (falls back to AQUARIUS_API_URL). +AQUARIUS_API_URL_TESTNET= # Comma-separated pairs to watch on testnet. # Format: "CODE:ISSUER/CODE:ISSUER". Use "native" for XLM. WATCHED_PAIRS_TESTNET=XLM:native/USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 @@ -94,9 +102,14 @@ NETWORK_PASSPHRASE_MAINNET=Public Global Stellar Network ; September 2015 SOROSWAP_FACTORY_ADDRESS_MAINNET=CA4HEQTL2WPEUYKYKCDOHCDNIV4QHNJ7EL4J4NQ6VADP7SYHVRYZ7AW2 SOROSWAP_ENABLED_MAINNET=true AQUARIUS_ENABLED_MAINNET=true +# Optional per-network endpoint overrides (fall back to shared values). +AQUARIUS_API_URL_MAINNET= +SOROSWAP_TOKEN_LIST_URL_MAINNET= +SOROSWAP_POLL_INTERVAL_MS_MAINNET= # Reflector oracle contract on mainnet (56-char C... id) — copy it from https://reflector.network. # There is no built-in default: leave empty to keep the oracle disabled. REFLECTOR_CONTRACT_ID_MAINNET= +REFLECTOR_ENABLED_MAINNET=true # Comma-separated pairs to watch on mainnet. WATCHED_PAIRS_MAINNET=XLM:native/USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN @@ -127,6 +140,8 @@ SDEX_PAGE_SIZE=200 AMM_PAGE_SIZE=200 # How often to poll Soroswap pool reserves in milliseconds (default: 60000 = 60s) SOROSWAP_POLL_INTERVAL_MS=60000 +# Legacy single-network facilitator fee setting (testnet fallback). +FACILITATOR_FEE_STROOPS=50000 # --- WebSocket backpressure (/ws) --- # See docs/websocket-backpressure.md for the full policy. diff --git a/src/__tests__/bazaarRateLimit.test.ts b/src/__tests__/bazaarRateLimit.test.ts index 0ae4f69a..9dd8031f 100644 --- a/src/__tests__/bazaarRateLimit.test.ts +++ b/src/__tests__/bazaarRateLimit.test.ts @@ -91,9 +91,9 @@ describe('checkCatalogWriteRate', () => { expect(perDayLimit()).toBe(30) }) - it('falls back to its defaults for a nonsense limit', () => { + it('fails on a nonsense limit instead of silently accepting invalid configuration', () => { process.env.BAZAAR_CATALOG_WRITES_PER_MIN = 'lots' - expect(perMinuteLimit()).toBe(10) + expect(() => perMinuteLimit()).toThrow(/BAZAAR_CATALOG_WRITES_PER_MIN must be a safe integer/) }) }) diff --git a/src/__tests__/configEnvironment.test.ts b/src/__tests__/configEnvironment.test.ts new file mode 100644 index 00000000..9998ff80 --- /dev/null +++ b/src/__tests__/configEnvironment.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { parseEnvironment } from '../config' + +describe('environment validation', () => { + it('rejects invalid numeric values and names the variable without echoing its value', () => { + expect(() => parseEnvironment({ RATE_LIMIT_IP_MAX: 'lots' })) + .toThrow('[config] Invalid environment: RATE_LIMIT_IP_MAX must be a safe integer') + }) + + it('requires runtime secrets and service URLs in production', () => { + expect(() => parseEnvironment({ NODE_ENV: 'production' })) + .toThrow(/DATABASE_URL is required in production.*REDIS_URL is required in production.*ADMIN_API_KEY is required in production.*ADMIN_TOKEN is required in production/) + }) + + it('accepts a complete production configuration and derives typed keys from the schema', () => { + const env = parseEnvironment({ + NODE_ENV: 'production', + DATABASE_URL: 'postgresql://lens:secret@db.example.com/lens', + REDIS_URL: 'redis://cache.example.com:6379', + ADMIN_API_KEY: 'admin-key', + ADMIN_TOKEN: 'admin-token', + RATE_LIMIT_IP_MAX: '120', + REQUIRE_API_KEY: 'true', + }) + + expect(env.RATE_LIMIT_IP_MAX).toBe('120') + }) +}) diff --git a/src/__tests__/envDrift.test.ts b/src/__tests__/envDrift.test.ts index 2187798b..bdbe2ac6 100644 --- a/src/__tests__/envDrift.test.ts +++ b/src/__tests__/envDrift.test.ts @@ -21,27 +21,6 @@ export const UNDOCUMENTED_ENV_ALLOW_LIST: Record = { GITHUB_ACTIONS: 'Platform-injected environment variable present in GitHub Actions runners; not operator-configurable.', RENDER: 'Platform-injected environment variable set by the Render hosting platform; not operator-configurable.', - // Optional per-network venue overrides where the shared fallback is documented in .env.example - SOROSWAP_TOKEN_LIST_URL_TESTNET: - 'Optional per-network override; shared fallback SOROSWAP_TOKEN_LIST_URL is documented in .env.example.', - SOROSWAP_TOKEN_LIST_URL_MAINNET: - 'Optional per-network override; shared fallback SOROSWAP_TOKEN_LIST_URL is documented in .env.example.', - SOROSWAP_POLL_INTERVAL_MS_TESTNET: - 'Optional per-network override for polling interval; shared fallback SOROSWAP_POLL_INTERVAL_MS is documented in .env.example.', - SOROSWAP_POLL_INTERVAL_MS_MAINNET: - 'Optional per-network override for polling interval; shared fallback SOROSWAP_POLL_INTERVAL_MS is documented in .env.example.', - AQUARIUS_API_URL_TESTNET: - 'Optional per-network venue override; shared fallback AQUARIUS_API_URL is documented in .env.example.', - AQUARIUS_API_URL_MAINNET: - 'Optional per-network venue override; shared fallback AQUARIUS_API_URL is documented in .env.example.', - REFLECTOR_ENABLED_TESTNET: - 'Optional per-network feature flag override; enabled by default on testnet if reflectorContractId is configured.', - REFLECTOR_ENABLED_MAINNET: - 'Optional per-network feature flag override; enabled by default on mainnet if reflectorContractId is configured.', - - // Legacy single-network fallbacks where paired network-specific variables are documented - FACILITATOR_FEE_STROOPS: - 'Legacy unsuffixed fallback for testnet; FACILITATOR_FEE_STROOPS_TESTNET and FACILITATOR_FEE_STROOPS_MAINNET are documented in .env.example.', } /** @@ -125,6 +104,9 @@ export function extractEnvReadsFromSource(sourceCode: string, fileName: string): if (isProcessEnv(node.expression)) { addRead(node.name.getText(sourceFile), node) } + if (node.expression.getText(sourceFile) === 'getEnvironment()') { + addRead(node.name.getText(sourceFile), node) + } } // 2. process.env['X'] or process.env[`X_${suffix}`] @@ -143,6 +125,19 @@ export function extractEnvReadsFromSource(sourceCode: string, fileName: string): } } + // Typed environment access through the shared validated config module. + if (ts.isCallExpression(node) && node.expression.getText(sourceFile) === 'getEnvironmentValue') { + const arg = node.arguments[0] + if (arg && ts.isStringLiteral(arg)) addRead(arg.text, node) + else if (arg && ts.isTemplateExpression(arg)) { + const head = arg.head.text + if (head.endsWith('_')) { + addRead(`${head}TESTNET`, node) + addRead(`${head}MAINNET`, node) + } + } + } + ts.forEachChild(node, visit) } diff --git a/src/api/auth.ts b/src/api/auth.ts index d0eec6dd..a1f30b2b 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -3,6 +3,7 @@ import { createHash, timingSafeEqual } from 'crypto' import fp from 'fastify-plugin' import { prisma } from '../db' import { api_key_auth_failures_total } from '../metrics' +import { getEnvironment } from '../config' type ApiKeyAuthFailureReason = 'missing' | 'malformed' | 'unknown' | 'revoked' | 'expired' @@ -76,7 +77,7 @@ async function lookupApiKeyResult( * usage), so the two surfaces cannot drift apart. */ export function isAdminAuthorized(req: FastifyRequest): boolean { - const adminToken = process.env.ADMIN_TOKEN + const adminToken = getEnvironment().ADMIN_TOKEN if (!adminToken) return false const supplied = (req.headers['x-admin-token'] as string | undefined) ?? diff --git a/src/api/schemas.ts b/src/api/schemas.ts index 4c9a2233..aba6e9ee 100644 --- a/src/api/schemas.ts +++ b/src/api/schemas.ts @@ -15,6 +15,7 @@ */ import Ajv from 'ajv' import type { FastifyInstance, FastifySchema } from 'fastify' +import { getEnvironment } from '../config' /** * One dependency probe result, shared by `/ready`. @@ -672,7 +673,7 @@ export const snapshotResponseSchema = { * Skipped entirely in production so there is no added runtime cost there. */ export function installResponseValidation(app: FastifyInstance): void { - if (process.env.NODE_ENV === 'production') return + if (getEnvironment().NODE_ENV === 'production') return const ajv = new Ajv({ allErrors: true, coerceTypes: false }) diff --git a/src/api/websocket.ts b/src/api/websocket.ts index acd8dada..21f4c2aa 100644 --- a/src/api/websocket.ts +++ b/src/api/websocket.ts @@ -6,8 +6,9 @@ import { X402_NETWORK_LABEL, paymentAddressFor, getX402ResourceServer } from '.. import { resolveNetworkName } from '../middleware/network' import { fanOutManager } from '../ws/fanout' import { v4 as uuid } from 'uuid' +import { getEnvironment } from '../config' -const FACILITATOR_URL = process.env.X402_FACILITATOR_URL ?? 'https://x402.org/facilitator' +const FACILITATOR_URL = getEnvironment().X402_FACILITATOR_URL ?? 'https://x402.org/facilitator' export async function registerWebSocket(app: FastifyInstance) { await app.register(websocket) @@ -135,4 +136,4 @@ async function verifyPayment(paymentHeader: string, requirements: any, resourceS } catch { return false } -} \ No newline at end of file +} diff --git a/src/app.ts b/src/app.ts index 405e4c48..596b280b 100644 --- a/src/app.ts +++ b/src/app.ts @@ -33,6 +33,7 @@ import { registerCachePolicy } from './middleware/cachePolicy' import { registerGlobalRateLimit, parseGlobalRateLimit } from './middleware/globalRateLimit' import { getMetrics, rate_limit_rejections_total } from './metrics' import { loggerOptions, createDestination } from './logger' +import { getEnvironment } from './config' /** * Routes the rate limiter skips. @@ -99,7 +100,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise req.apiKey?.ratePerMin ?? ipRateLimitMax, timeWindow: '1 minute', @@ -132,7 +133,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise 0 ? Math.floor(raw) : fallback } /** Writes allowed per `payTo` per minute. */ export function perMinuteLimit(): number { - return envLimit('BAZAAR_CATALOG_WRITES_PER_MIN', 10) + return envLimit(getEnvironment().BAZAAR_CATALOG_WRITES_PER_MIN, 10) } /** Writes allowed per `payTo` per UTC day. */ export function perDayLimit(): number { - return envLimit('BAZAAR_CATALOG_WRITES_PER_DAY', 200) + return envLimit(getEnvironment().BAZAAR_CATALOG_WRITES_PER_DAY, 200) } export interface RateLimitDecision { diff --git a/src/config.ts b/src/config.ts index 35c54518..f891670b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,9 +1,107 @@ import 'dotenv/config' import { Networks, StrKey } from '@stellar/stellar-sdk' +import { z } from 'zod' import { createLogger } from './logger' import type { WatchedPair, AssetId } from './types' const log = createLogger('config') +const envSchema = z.object({ + NODE_ENV: z.string().optional(), DATABASE_URL: z.string().optional(), DIRECT_DATABASE_URL: z.string().optional(), + REDIS_URL: z.string().optional(), PORT: z.string().optional(), HOST: z.string().optional(), + STELLAR_NETWORK: z.string().optional(), ENABLED_NETWORKS: z.string().optional(), + HORIZON_URL: z.string().optional(), HORIZON_URL_TESTNET: z.string().optional(), HORIZON_URL_MAINNET: z.string().optional(), + RPC_URL: z.string().optional(), RPC_URL_TESTNET: z.string().optional(), RPC_URL_MAINNET: z.string().optional(), + NETWORK_PASSPHRASE: z.string().optional(), NETWORK_PASSPHRASE_TESTNET: z.string().optional(), NETWORK_PASSPHRASE_MAINNET: z.string().optional(), + SOROSWAP_FACTORY_ADDRESS: z.string().optional(), SOROSWAP_FACTORY_ADDRESS_TESTNET: z.string().optional(), SOROSWAP_FACTORY_ADDRESS_MAINNET: z.string().optional(), + SOROSWAP_TOKEN_LIST_URL: z.string().optional(), SOROSWAP_TOKEN_LIST_URL_TESTNET: z.string().optional(), SOROSWAP_TOKEN_LIST_URL_MAINNET: z.string().optional(), + SOROSWAP_ENABLED_TESTNET: z.string().optional(), SOROSWAP_ENABLED_MAINNET: z.string().optional(), + SOROSWAP_POLL_INTERVAL_MS: z.string().optional(), SOROSWAP_POLL_INTERVAL_MS_TESTNET: z.string().optional(), SOROSWAP_POLL_INTERVAL_MS_MAINNET: z.string().optional(), + AQUARIUS_API_URL: z.string().optional(), AQUARIUS_API_URL_TESTNET: z.string().optional(), AQUARIUS_API_URL_MAINNET: z.string().optional(), + AQUARIUS_ENABLED_TESTNET: z.string().optional(), AQUARIUS_ENABLED_MAINNET: z.string().optional(), + REFLECTOR_CONTRACT_ID: z.string().optional(), REFLECTOR_CONTRACT_ID_TESTNET: z.string().optional(), REFLECTOR_CONTRACT_ID_MAINNET: z.string().optional(), + REFLECTOR_ENABLED_TESTNET: z.string().optional(), REFLECTOR_ENABLED_MAINNET: z.string().optional(), + WATCHED_PAIRS: z.string().optional(), WATCHED_PAIRS_TESTNET: z.string().optional(), WATCHED_PAIRS_MAINNET: z.string().optional(), + FACILITATOR_SECRET_KEY: z.string().optional(), FACILITATOR_SECRET_KEY_TESTNET: z.string().optional(), FACILITATOR_SECRET_KEY_MAINNET: z.string().optional(), + FACILITATOR_FEE_STROOPS: z.string().optional(), FACILITATOR_FEE_STROOPS_TESTNET: z.string().optional(), FACILITATOR_FEE_STROOPS_MAINNET: z.string().optional(), + POLL_INTERVAL_MS: z.string().optional(), SDEX_PAGE_SIZE: z.string().optional(), AMM_PAGE_SIZE: z.string().optional(), PRICE_CACHE_TTL: z.string().optional(), + SNAPSHOT_CACHE_TTL: z.string().optional(), HTTP_TIMEOUT_MS: z.string().optional(), OUTBOUND_ALLOWED_HOSTS: z.string().optional(), + GLOBAL_RATE_LIMIT_MAX: z.string().optional(), + ADMIN_API_KEY: z.string().optional(), ADMIN_TOKEN: z.string().optional(), REQUIRE_API_KEY: z.string().optional(), RATE_LIMIT_IP_MAX: z.string().optional(), + X402_FACILITATOR_URL: z.string().optional(), ORACLE_PAYMENT_ADDRESS: z.string().optional(), + ORACLE_PAYMENT_ADDRESS_TESTNET: z.string().optional(), ORACLE_PAYMENT_ADDRESS_MAINNET: z.string().optional(), + SNAPSHOT_PRUNE_BATCH_SIZE: z.string().optional(), BAZAAR_CATALOG_WRITES_PER_MIN: z.string().optional(), BAZAAR_CATALOG_WRITES_PER_DAY: z.string().optional(), +}).strict() + +const numericEnvironmentKeys = [ + 'PORT', 'POLL_INTERVAL_MS', 'SDEX_PAGE_SIZE', 'AMM_PAGE_SIZE', 'PRICE_CACHE_TTL', 'RATE_LIMIT_IP_MAX', + 'SNAPSHOT_CACHE_TTL', 'HTTP_TIMEOUT_MS', 'GLOBAL_RATE_LIMIT_MAX', + 'SNAPSHOT_PRUNE_BATCH_SIZE', 'BAZAAR_CATALOG_WRITES_PER_MIN', 'BAZAAR_CATALOG_WRITES_PER_DAY', + 'SOROSWAP_POLL_INTERVAL_MS', 'SOROSWAP_POLL_INTERVAL_MS_TESTNET', 'SOROSWAP_POLL_INTERVAL_MS_MAINNET', + 'FACILITATOR_FEE_STROOPS', 'FACILITATOR_FEE_STROOPS_TESTNET', 'FACILITATOR_FEE_STROOPS_MAINNET', +] as const +const booleanEnvironmentKeys = [ + 'REQUIRE_API_KEY', + 'SOROSWAP_ENABLED_TESTNET', 'SOROSWAP_ENABLED_MAINNET', 'AQUARIUS_ENABLED_TESTNET', 'AQUARIUS_ENABLED_MAINNET', + 'REFLECTOR_ENABLED_TESTNET', 'REFLECTOR_ENABLED_MAINNET', +] as const +const parsedEnvironmentSchema = envSchema.superRefine((env, ctx) => { + for (const key of numericEnvironmentKeys) { + const value = env[key] + if (value !== undefined && value.trim() !== '' && (!/^-?\d+$/.test(value.trim()) || !Number.isSafeInteger(Number(value)))) { + ctx.addIssue({ code: 'custom', path: [key], message: 'must be a safe integer' }) + } + } + for (const key of booleanEnvironmentKeys) { + const value = env[key] + if (value !== undefined && value !== '' && value !== 'true' && value !== 'false') { + ctx.addIssue({ code: 'custom', path: [key], message: 'must be true or false' }) + } + } + if (env.STELLAR_NETWORK && !['testnet', 'mainnet'].includes(env.STELLAR_NETWORK.toLowerCase())) { + ctx.addIssue({ code: 'custom', path: ['STELLAR_NETWORK'], message: 'must be testnet or mainnet' }) + } + if (env.NODE_ENV === 'production') { + for (const key of ['DATABASE_URL', 'REDIS_URL', 'ADMIN_API_KEY', 'ADMIN_TOKEN'] as const) { + if (!env[key]?.trim()) ctx.addIssue({ code: 'custom', path: [key], message: 'is required in production' }) + } + } +}) + +export type Environment = z.infer +let parsedEnvironment: Environment | undefined +let environmentFingerprint = '' + +export function parseEnvironment(source: Record): Environment { + const defined = Object.fromEntries(Object.entries(source).filter((entry): entry is [string, string] => entry[1] !== undefined)) + const parsed = parsedEnvironmentSchema.safeParse(defined) + if (!parsed.success) { + const details = parsed.error.issues.map(issue => `${String(issue.path[0] ?? 'environment')} ${issue.message}`) + throw new Error(`[config] Invalid environment: ${details.join('; ')}`) + } + return parsed.data +} + +/** Parse the complete process environment once, and again only if a caller changes it. */ +export function getEnvironment(): Environment { + const source = Object.fromEntries(Object.keys(envSchema.shape).map(key => [key, process.env[key]])) + const fingerprint = JSON.stringify(source) + if (!parsedEnvironment || fingerprint !== environmentFingerprint) { + parsedEnvironment = parseEnvironment(source) + environmentFingerprint = fingerprint + } + return parsedEnvironment +} + +/** Read a schema-declared variable selected dynamically by a network suffix. */ +export function getEnvironmentValue(name: string): string | undefined { + return (getEnvironment() as Record)[name] +} + +/** Prisma's CLI uses DIRECT_DATABASE_URL; fall back to the application URL. */ +export function ensureDirectDatabaseUrl(): void { + const env = getEnvironment() + if (!env.DIRECT_DATABASE_URL && env.DATABASE_URL) process.env.DIRECT_DATABASE_URL = env.DATABASE_URL +} // ── Types ───────────────────────────────────────────────────────────────────── @@ -133,30 +231,30 @@ function buildNetworkConfig(network: NetworkName): NetworkConfig { // ── Horizon ────────────────────────────────────────────────────────────── const horizonUrl = - process.env[`HORIZON_URL_${suffix}`] || - (network === 'testnet' ? process.env.HORIZON_URL : undefined) || + getEnvironmentValue(`HORIZON_URL_${suffix}`) || + (network === 'testnet' ? getEnvironment().HORIZON_URL : undefined) || (network === 'mainnet' ? 'https://horizon.stellar.org' : 'https://horizon-testnet.stellar.org') // ── Soroban RPC ─────────────────────────────────────────────────────────── const rpcUrl = - process.env[`RPC_URL_${suffix}`] || - (network === 'testnet' ? process.env.RPC_URL : undefined) || + getEnvironmentValue(`RPC_URL_${suffix}`) || + (network === 'testnet' ? getEnvironment().RPC_URL : undefined) || (network === 'mainnet' ? 'https://mainnet.sorobanrpc.com' : 'https://soroban-testnet.stellar.org') // ── Network passphrase ──────────────────────────────────────────────────── const passphrase = - process.env[`NETWORK_PASSPHRASE_${suffix}`] || - (network === 'testnet' ? process.env.NETWORK_PASSPHRASE : undefined) || + getEnvironmentValue(`NETWORK_PASSPHRASE_${suffix}`) || + (network === 'testnet' ? getEnvironment().NETWORK_PASSPHRASE : undefined) || (network === 'mainnet' ? Networks.PUBLIC : Networks.TESTNET) // ── Soroswap factory ────────────────────────────────────────────────────── const soroswapFactory = - process.env[`SOROSWAP_FACTORY_ADDRESS_${suffix}`] || - process.env.SOROSWAP_FACTORY_ADDRESS || + getEnvironmentValue(`SOROSWAP_FACTORY_ADDRESS_${suffix}`) || + getEnvironment().SOROSWAP_FACTORY_ADDRESS || (network === 'mainnet' ? 'CA4HEQTL2WPEUYKYKCDOHCDNIV4QHNJ7EL4J4NQ6VADP7SYHVRYZ7AW2' : 'CDP3HMUH6SMS3S7NPGNDJLULCOXXEPSHY4JKUKMBNQMATHDHWXRRJTBY') @@ -164,17 +262,17 @@ function buildNetworkConfig(network: NetworkName): NetworkConfig { // Soroswap token-list is a single canonical list covering both networks by // default, but can be overridden per network (e.g. a testnet-specific list). const soroswapTokenListUrl = - process.env[`SOROSWAP_TOKEN_LIST_URL_${suffix}`] || - process.env.SOROSWAP_TOKEN_LIST_URL || + getEnvironmentValue(`SOROSWAP_TOKEN_LIST_URL_${suffix}`) || + getEnvironment().SOROSWAP_TOKEN_LIST_URL || 'https://raw.githubusercontent.com/soroswap/token-list/main/tokenList.json' const soroswapEnabled = - (process.env[`SOROSWAP_ENABLED_${suffix}`] ?? 'true').toLowerCase() !== 'false' && + (getEnvironmentValue(`SOROSWAP_ENABLED_${suffix}`) ?? 'true').toLowerCase() !== 'false' && isUsableContractId(soroswapFactory, `SOROSWAP_FACTORY_ADDRESS_${suffix}`) const soroswapPollMs = parseInt( - process.env[`SOROSWAP_POLL_INTERVAL_MS_${suffix}`] || - process.env.SOROSWAP_POLL_INTERVAL_MS || + getEnvironmentValue(`SOROSWAP_POLL_INTERVAL_MS_${suffix}`) || + getEnvironment().SOROSWAP_POLL_INTERVAL_MS || '60000', 10 ) @@ -183,12 +281,12 @@ function buildNetworkConfig(network: NetworkName): NetworkConfig { // Aquarius only runs on Stellar classic mainnet today — there is no public // testnet deployment, so it is disabled there by default. const aquariusApiUrl = - process.env[`AQUARIUS_API_URL_${suffix}`] || - process.env.AQUARIUS_API_URL || + getEnvironmentValue(`AQUARIUS_API_URL_${suffix}`) || + getEnvironment().AQUARIUS_API_URL || 'https://amm.aquarius.network/api/v1/pools/' const aquariusEnabled = - (process.env[`AQUARIUS_ENABLED_${suffix}`] ?? (network === 'mainnet' ? 'true' : 'false')).toLowerCase() !== 'false' + (getEnvironmentValue(`AQUARIUS_ENABLED_${suffix}`) ?? (network === 'mainnet' ? 'true' : 'false')).toLowerCase() !== 'false' // ── Reflector oracle ────────────────────────────────────────────────────── // No built-in default: the previous mainnet literal was a malformed @@ -196,12 +294,12 @@ function buildNetworkConfig(network: NetworkName): NetworkConfig { // Reflector's published deployments. Set REFLECTOR_CONTRACT_ID_ to // the oracle contract listed at https://reflector.network to enable it. const reflectorContractId = - process.env[`REFLECTOR_CONTRACT_ID_${suffix}`] || - process.env.REFLECTOR_CONTRACT_ID || + getEnvironmentValue(`REFLECTOR_CONTRACT_ID_${suffix}`) || + getEnvironment().REFLECTOR_CONTRACT_ID || '' const oracleEnabled = - (process.env[`REFLECTOR_ENABLED_${suffix}`] ?? 'true').toLowerCase() !== 'false' && + (getEnvironmentValue(`REFLECTOR_ENABLED_${suffix}`) ?? 'true').toLowerCase() !== 'false' && isUsableContractId(reflectorContractId, `REFLECTOR_CONTRACT_ID_${suffix}`) // ── Watched pairs ───────────────────────────────────────────────────────── @@ -213,18 +311,18 @@ function buildNetworkConfig(network: NetworkName): NetworkConfig { // than by asset code — Horizon lists many unrelated assets also called USDC, // and picking the wrong one would quote a price no one trades at. const rawPairs = - process.env[`WATCHED_PAIRS_${suffix}`] || - (network === 'testnet' ? process.env.WATCHED_PAIRS : undefined) || + getEnvironmentValue(`WATCHED_PAIRS_${suffix}`) || + (network === 'testnet' ? getEnvironment().WATCHED_PAIRS : undefined) || (network === 'mainnet' ? MAINNET_DEFAULT_PAIRS : '') // ── Facilitator ─────────────────────────────────────────────────────────── const facilitatorSecretKey = - process.env[`FACILITATOR_SECRET_KEY_${suffix}`] || - (network === 'testnet' ? process.env.FACILITATOR_SECRET_KEY : undefined) + getEnvironmentValue(`FACILITATOR_SECRET_KEY_${suffix}`) || + (network === 'testnet' ? getEnvironment().FACILITATOR_SECRET_KEY : undefined) const facilitatorFeeStroops = parseInt( - process.env[`FACILITATOR_FEE_STROOPS_${suffix}`] || - process.env.FACILITATOR_FEE_STROOPS || + getEnvironmentValue(`FACILITATOR_FEE_STROOPS_${suffix}`) || + getEnvironment().FACILITATOR_FEE_STROOPS || '50000', 10 ) @@ -253,30 +351,30 @@ function buildNetworkConfig(network: NetworkName): NetworkConfig { const globalConfig = { db: { - url: process.env.DATABASE_URL ?? 'postgresql://lens:lens@localhost:5432/lens', + url: getEnvironment().DATABASE_URL ?? 'postgresql://lens:lens@localhost:5432/lens', }, redis: { - url: process.env.REDIS_URL ?? 'redis://localhost:6379', + url: getEnvironment().REDIS_URL ?? 'redis://localhost:6379', }, api: { - port: parseInt(process.env.PORT ?? '3002', 10), - host: process.env.HOST ?? '0.0.0.0', + port: parseInt(getEnvironment().PORT ?? '3002', 10), + host: getEnvironment().HOST ?? '0.0.0.0', }, indexer: { - pollIntervalMs: parseInt(process.env.POLL_INTERVAL_MS ?? '5000', 10), - sdexPageSize: parseInt(process.env.SDEX_PAGE_SIZE ?? '200', 10), - ammPageSize: parseInt(process.env.AMM_PAGE_SIZE ?? '200', 10), + pollIntervalMs: parseInt(getEnvironment().POLL_INTERVAL_MS ?? '5000', 10), + sdexPageSize: parseInt(getEnvironment().SDEX_PAGE_SIZE ?? '200', 10), + ammPageSize: parseInt(getEnvironment().AMM_PAGE_SIZE ?? '200', 10), }, cache: { - priceTtl: parseInt(process.env.PRICE_CACHE_TTL ?? '10', 10), + priceTtl: parseInt(getEnvironment().PRICE_CACHE_TTL ?? '10', 10), // How long a network's GET /snapshot response is served from Redis before // it is recomputed from Postgres. Deliberately short: the endpoint is a // cheap, pollable summary, so a slightly stale copy is preferable to a // per-request scan, but callers must never be shown a minutes-old market. - snapshotTtl: parseInt(process.env.SNAPSHOT_CACHE_TTL ?? '15', 10), + snapshotTtl: parseInt(getEnvironment().SNAPSHOT_CACHE_TTL ?? '15', 10), }, http: { - timeoutMs: parseInt(process.env.HTTP_TIMEOUT_MS ?? '10000', 10), + timeoutMs: parseInt(getEnvironment().HTTP_TIMEOUT_MS ?? '10000', 10), }, outbound: { // One operator-maintained list for every outbound fetch the app makes to a @@ -284,7 +382,7 @@ const globalConfig = { // Empty = "any public host"; a non-empty list narrows reach to these // hostnames. It never re-opens a private/link-local/metadata address — that // block is unconditional (see net/ssrf.ts). - allowedHosts: parseAllowedHosts(process.env.OUTBOUND_ALLOWED_HOSTS ?? ''), + allowedHosts: parseAllowedHosts(getEnvironment().OUTBOUND_ALLOWED_HOSTS ?? ''), }, } as const @@ -322,7 +420,7 @@ export function getNetworkConfig(network: NetworkName): NetworkConfig { * Falls back to `'testnet'` when unset so local dev always works. */ export const activeNetwork: NetworkName = - (process.env.STELLAR_NETWORK?.toLowerCase() as NetworkName | undefined) === 'mainnet' + (getEnvironment().STELLAR_NETWORK?.toLowerCase() as NetworkName | undefined) === 'mainnet' ? 'mainnet' : 'testnet' diff --git a/src/db.ts b/src/db.ts index a98b66cc..6238ea35 100644 --- a/src/db.ts +++ b/src/db.ts @@ -1,14 +1,14 @@ import { PrismaClient } from '@prisma/client' import { Pool } from 'pg' import { db_query_duration_seconds } from './metrics' -import { config, type NetworkName } from './config' +import { config, getEnvironment, type NetworkName } from './config' // Prisma for schema management + simple queries const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient } export const prisma = globalForPrisma.prisma ?? new PrismaClient({ - log: process.env.NODE_ENV === 'development' ? ['warn', 'error'] : ['error'], + log: getEnvironment().NODE_ENV === 'development' ? ['warn', 'error'] : ['error'], }) -if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma +if (getEnvironment().NODE_ENV !== 'production') globalForPrisma.prisma = prisma // Raw pg pool for time-series queries (VWAP, aggregates) // ssl: true with rejectUnauthorized: false is required for Supabase pgbouncer diff --git a/src/index.ts b/src/index.ts index 5cf4a54f..32262988 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,5 @@ -import 'dotenv/config' import { execSync } from 'child_process' - -if (!process.env.DIRECT_DATABASE_URL && process.env.DATABASE_URL) { - process.env.DIRECT_DATABASE_URL = process.env.DATABASE_URL -} -import { config, type NetworkName } from './config' +import { config, ensureDirectDatabaseUrl, type NetworkName } from './config' import { getEnabledNetworks } from './network/enabledNetworks' import { createLogger } from './logger' import { redis } from './redis' @@ -29,6 +24,7 @@ import { markBootComplete } from './health' const log = createLogger('startup') async function main() { + ensureDirectDatabaseUrl() // ── Ensure DB schema is up-to-date ──────────────────────────────────────── log.info('running database migrations') execSync('node node_modules/prisma/build/index.js db push --accept-data-loss', { stdio: 'inherit' }) @@ -221,4 +217,4 @@ async function main() { main().catch(err => { log.error({ err }, 'fatal startup error') process.exit(1) -}) \ No newline at end of file +}) diff --git a/src/jobs/aggregateRefresh.ts b/src/jobs/aggregateRefresh.ts index 4b49b9b7..b9099590 100644 --- a/src/jobs/aggregateRefresh.ts +++ b/src/jobs/aggregateRefresh.ts @@ -1,6 +1,7 @@ import { Queue, Worker } from 'bullmq' import { config, getNetworkConfig, type NetworkName } from '../config' import { prisma } from '../db' +import { getEnvironment } from '../config' import { setCachedPrice } from '../redis' import { calculateVWAP, calculateOHLCV, getAggregatedPrice } from '../aggregator/vwap' import { getBestRoute } from '../aggregator/bestRoute' @@ -20,7 +21,7 @@ function queueName(network: NetworkName): string { } function redisConnection() { - const url = process.env.REDIS_URL + const url = getEnvironment().REDIS_URL if (url) return { url } return { host: 'localhost', port: 6379 } } diff --git a/src/jobs/snapshotRetention.ts b/src/jobs/snapshotRetention.ts index ab5c94e3..d3c2b3ac 100644 --- a/src/jobs/snapshotRetention.ts +++ b/src/jobs/snapshotRetention.ts @@ -1,6 +1,6 @@ import { Queue, Worker } from 'bullmq' import { pgPool } from '../db' -import { activeNetwork, type NetworkName } from '../config' +import { activeNetwork, getEnvironment, type NetworkName } from '../config' import { getEnabledNetworks } from '../network/enabledNetworks' import { createLogger } from '../logger' @@ -11,7 +11,7 @@ const QUEUE_NAME = `${activeNetwork}:snapshot-retention` /** Snapshots older than this many days are pruned by the retention job. */ export const SNAPSHOT_RETENTION_DAYS = 30 /** Maximum rows removed by one database statement during a retention run. */ -export const SNAPSHOT_PRUNE_BATCH_SIZE = Number(process.env.SNAPSHOT_PRUNE_BATCH_SIZE ?? 1_000) +export const SNAPSHOT_PRUNE_BATCH_SIZE = Number(getEnvironment().SNAPSHOT_PRUNE_BATCH_SIZE ?? 1_000) export interface SnapshotPruneProgress { network: NetworkName @@ -21,7 +21,7 @@ export interface SnapshotPruneProgress { } function redisConnection() { - const url = process.env.REDIS_URL + const url = getEnvironment().REDIS_URL if (url) return { url } return { host: 'localhost', port: 6379 } } diff --git a/src/middleware/x402.ts b/src/middleware/x402.ts index a2651f0a..f318344d 100644 --- a/src/middleware/x402.ts +++ b/src/middleware/x402.ts @@ -4,6 +4,7 @@ import { checkQuota, recordUsage, parseCents, getQuotaConfig } from '../x402/met import { X402_NETWORK_LABEL, paymentAddressFor, isX402Configured, getX402ResourceServer } from '../x402/network' import fp from 'fastify-plugin' import './network' // declares req.network on the FastifyRequest type +import { getEnvironment } from '../config' export interface GatedRoute { readonly path: string @@ -88,7 +89,7 @@ export function matchGatedRoute( */ async function x402Plugin(app: FastifyInstance) { // Read at plugin init time (not module load) so tests can inject env vars before app.register() - const FACILITATOR_URL = process.env.X402_FACILITATOR_URL ?? 'https://x402.org/facilitator' + const FACILITATOR_URL = getEnvironment().X402_FACILITATOR_URL ?? 'https://x402.org/facilitator' if (!isX402Configured()) { app.log.warn('[oracle] ORACLE_PAYMENT_ADDRESS not set — x402 gating disabled') diff --git a/src/network/enabledNetworks.ts b/src/network/enabledNetworks.ts index 9bf34b98..fd835356 100644 --- a/src/network/enabledNetworks.ts +++ b/src/network/enabledNetworks.ts @@ -1,4 +1,4 @@ -import { activeNetwork, type NetworkName } from '../config' +import { activeNetwork, getEnvironment, type NetworkName } from '../config' const KNOWN_NETWORKS: readonly NetworkName[] = ['testnet', 'mainnet'] @@ -17,7 +17,7 @@ const KNOWN_NETWORKS: readonly NetworkName[] = ['testnet', 'mainnet'] * `[activeNetwork]`. */ export function getEnabledNetworks(): NetworkName[] { - const raw = process.env.ENABLED_NETWORKS + const raw = getEnvironment().ENABLED_NETWORKS if (raw && raw.trim()) { const parsed = raw .split(',') diff --git a/src/routes/pairs.ts b/src/routes/pairs.ts index cf95e555..530a1ca3 100644 --- a/src/routes/pairs.ts +++ b/src/routes/pairs.ts @@ -1,7 +1,7 @@ import type { FastifyInstance } from 'fastify' import { getActivePairs, parseAssetStr, makePairKey, registerPair, persistPair, hasPair } from '../pairsRegistry' import { pgPool } from '../db' -import { activeNetwork } from '../config' +import { activeNetwork, getEnvironment } from '../config' import '../middleware/network' // declares req.network on the FastifyRequest type import { admin_auth_failures_total } from '../metrics' @@ -63,7 +63,7 @@ export async function registerPairsRoutes(app: FastifyInstance) { }>('/pairs', async (req, reply) => { const log = req.log.child({ component: 'route:pairs' }) // Auth check — read at request time so env can be set after module load - const ADMIN_API_KEY = process.env.ADMIN_API_KEY + const ADMIN_API_KEY = getEnvironment().ADMIN_API_KEY const key = req.headers['x-admin-key'] ?? req.headers['authorization']?.replace(/^Bearer /, '') if (!ADMIN_API_KEY || key !== ADMIN_API_KEY) { admin_auth_failures_total.inc() diff --git a/src/x402/network.ts b/src/x402/network.ts index b2e9aede..085ba908 100644 --- a/src/x402/network.ts +++ b/src/x402/network.ts @@ -1,4 +1,4 @@ -import type { NetworkName } from '../config' +import { getEnvironmentValue, type NetworkName } from '../config' // @ts-ignore — @x402 packages ship ESM-only types incompatible with commonjs moduleResolution import { x402ResourceServer, HTTPFacilitatorClient } from '@x402/core/server' // @ts-ignore @@ -20,7 +20,7 @@ export const X402_NETWORK_LABEL: Record = { */ export function paymentAddressFor(network: NetworkName): string | undefined { const suffix = network.toUpperCase() - return process.env[`ORACLE_PAYMENT_ADDRESS_${suffix}`] || process.env.ORACLE_PAYMENT_ADDRESS + return getEnvironmentValue(`ORACLE_PAYMENT_ADDRESS_${suffix}`) || getEnvironmentValue('ORACLE_PAYMENT_ADDRESS') } /** True if x402 gating should be active for at least one network. */ From 9c00b41d56b48b078862d7df849aa8f10514eaf8 Mon Sep 17 00:00:00 2001 From: Jossy-ossy Date: Fri, 16 Oct 2026 23:04:58 +0100 Subject: [PATCH 2/2] fix: validate service URLs and document config overrides --- .env.example | 8 ++++---- src/__tests__/configEnvironment.test.ts | 5 +++++ src/config.ts | 20 ++++++++++++++++++++ 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index 0d3dcada..b0f92c16 100644 --- a/.env.example +++ b/.env.example @@ -12,7 +12,7 @@ PORT=3002 HOST=0.0.0.0 # --- Database --- -# PostgreSQL connection string with credentials +# PostgreSQL connection string with credentials (required in production) DATABASE_URL=postgresql://lens:lens@localhost:5432/lens # Direct unpooled PostgreSQL connection string (used by Prisma migrations and CLI to bypass connection poolers) DIRECT_DATABASE_URL=postgresql://lens:lens@localhost:5432/lens @@ -25,7 +25,7 @@ PRICE_POINTS_DROP_EXPIRED=false PRICE_POINTS_PRECREATE_DAYS=7 # --- Cache & Performance --- -# Redis connection URL for caching and queues +# Redis connection URL for caching and queues (required in production) REDIS_URL=redis://localhost:6379 # How long to cache price responses in seconds (default: 10) PRICE_CACHE_TTL=10 @@ -163,10 +163,10 @@ WS_MAX_CONGESTED_MS=30000 WS_BUFFER_SWEEP_INTERVAL_MS=5000 # --- Auth & Security --- -# Secret key required for admin endpoints (e.g., adding/removing pairs) +# Secret key required for admin endpoints (e.g., adding/removing pairs); required in production ADMIN_API_KEY=super-secret-admin-key -# Shared secret guarding the API-key admin endpoints (POST/DELETE /admin/keys) +# Shared secret guarding the API-key admin endpoints (POST/DELETE /admin/keys); required in production # and the `npm run key:issue` CLI. Send as the X-Admin-Token header. ADMIN_TOKEN=super-secret-admin-token # Set to "false" to disable API-key auth entirely (e.g. local dev). Any other diff --git a/src/__tests__/configEnvironment.test.ts b/src/__tests__/configEnvironment.test.ts index 9998ff80..196525fc 100644 --- a/src/__tests__/configEnvironment.test.ts +++ b/src/__tests__/configEnvironment.test.ts @@ -7,6 +7,11 @@ describe('environment validation', () => { .toThrow('[config] Invalid environment: RATE_LIMIT_IP_MAX must be a safe integer') }) + it('rejects malformed service URLs with the setting name', () => { + expect(() => parseEnvironment({ REDIS_URL: 'cache-address' })) + .toThrow('[config] Invalid environment: REDIS_URL must be a valid URL') + }) + it('requires runtime secrets and service URLs in production', () => { expect(() => parseEnvironment({ NODE_ENV: 'production' })) .toThrow(/DATABASE_URL is required in production.*REDIS_URL is required in production.*ADMIN_API_KEY is required in production.*ADMIN_TOKEN is required in production/) diff --git a/src/config.ts b/src/config.ts index f891670b..88663d80 100644 --- a/src/config.ts +++ b/src/config.ts @@ -44,6 +44,13 @@ const booleanEnvironmentKeys = [ 'SOROSWAP_ENABLED_TESTNET', 'SOROSWAP_ENABLED_MAINNET', 'AQUARIUS_ENABLED_TESTNET', 'AQUARIUS_ENABLED_MAINNET', 'REFLECTOR_ENABLED_TESTNET', 'REFLECTOR_ENABLED_MAINNET', ] as const +const urlEnvironmentKeys = [ + 'DATABASE_URL', 'DIRECT_DATABASE_URL', 'REDIS_URL', + 'HORIZON_URL', 'HORIZON_URL_TESTNET', 'HORIZON_URL_MAINNET', + 'RPC_URL', 'RPC_URL_TESTNET', 'RPC_URL_MAINNET', + 'SOROSWAP_TOKEN_LIST_URL', 'SOROSWAP_TOKEN_LIST_URL_TESTNET', 'SOROSWAP_TOKEN_LIST_URL_MAINNET', + 'AQUARIUS_API_URL', 'AQUARIUS_API_URL_TESTNET', 'AQUARIUS_API_URL_MAINNET', 'X402_FACILITATOR_URL', +] as const const parsedEnvironmentSchema = envSchema.superRefine((env, ctx) => { for (const key of numericEnvironmentKeys) { const value = env[key] @@ -57,6 +64,19 @@ const parsedEnvironmentSchema = envSchema.superRefine((env, ctx) => { ctx.addIssue({ code: 'custom', path: [key], message: 'must be true or false' }) } } + for (const key of urlEnvironmentKeys) { + const value = env[key] + if (value?.trim()) { + try { + new URL(value) + } catch { + ctx.addIssue({ code: 'custom', path: [key], message: 'must be a valid URL' }) + } + } + } + if (env.NODE_ENV && !['development', 'test', 'production'].includes(env.NODE_ENV)) { + ctx.addIssue({ code: 'custom', path: ['NODE_ENV'], message: 'must be development, test, or production' }) + } if (env.STELLAR_NETWORK && !['testnet', 'mainnet'].includes(env.STELLAR_NETWORK.toLowerCase())) { ctx.addIssue({ code: 'custom', path: ['STELLAR_NETWORK'], message: 'must be testnet or mainnet' }) }