diff --git a/.env.example b/.env.example index bd073ffd..86091e15 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 @@ -31,7 +31,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 @@ -100,6 +100,10 @@ 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 # DeFindex vault factory contract (56-char C... id) — from @@ -116,6 +120,10 @@ PHOENIX_ENABLED_TESTNET=true # 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= # Blend lending pools (comma-separated 56-char C... ids) on testnet. BLEND_POOLS_TESTNET=CCEBVDYM32YNYCVNRXQKDFFPISJJCV557CDZEIRBEE4NCV4KHPQ44HGF # Blend backstop contract on testnet (56-char C... id). @@ -152,6 +160,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 # DeFindex vault factory contract on mainnet (56-char C... id) — from # https://github.com/defindex-io/stellar-contracts (public/mainnet.contracts.json). # A malformed id disables DeFindex on this network and logs a warning naming this variable. @@ -168,7 +184,6 @@ PHOENIX_FACTORY_ADDRESS_MAINNET=CB4SVAWJA6TSRNOJZ7W2AWFW46D5VR4ZMFZKDIKXEINZCZEG PHOENIX_ENABLED_MAINNET=true # 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= # Blend lending pools (comma-separated 56-char C... ids) on mainnet. BLEND_POOLS_MAINNET=CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD,CCCCIQSDILITHMM7PBSLVDT5MISSY7R26MNZXCX4H7J5JQ5FPIYOGYFS,CDVQVKOY2YSXS2IC7KN6MNASSHPAO7UN2UR2ON4OI2SKMFJNVAMDX6DP,CBP7NO6F7FRDHSOFQBT2L2UWYIZ2PU76JKVRYAQTG3KZSQLYAOKIF2WB # Blend backstop contract on mainnet (56-char C... id). @@ -261,6 +276,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 # How often to poll Blend pool reserves in milliseconds (default: 60000 = 60s) BLEND_POLL_INTERVAL_MS=60000 # Per-ingester cycle deadline in milliseconds (default: 60000 = 60s). A poll @@ -305,9 +322,11 @@ WS_PING_INTERVAL_MS=30000 WS_MAX_MISSED_PINGS=2 # --- 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); required in production +# and the `npm run key:issue` CLI. Send as the X-Admin-Token header. # Shared secret guarding the API-key admin endpoints (POST/DELETE /admin/keys, # GET /admin/usage) and the `npm run key:issue` CLI. Send as X-Admin-Token header # or Authorization: Bearer . When unset, empty, or a placeholder, the admin @@ -497,7 +516,6 @@ RATE_PRUNE_BATCH_SIZE=1000 # Most a single payment may cost. Enforced as the x402 client's per-payment cap # (spendControls.maxAmountPerPayment), so a call priced higher is refused before # any money moves (default: $1.00). -MCP_MAX_PAYMENT_PRICE=$1.00 # Cumulative ceiling on what paid calls may cost in one UTC day. The per-call cap # above only bounds one mistake; without this, an agent in a retry loop can pay # that amount over and over. Once a further call's worst case no longer fits the @@ -559,7 +577,6 @@ COINBASE_EXCHANGE_BASE_URL= # Default: 30 days (defensible because downstream volume queries inspect up to # 30-day windows, e.g. /volumes/:asset?window=30d, and it aligns with snapshot # retention while preventing unbounded table growth). -PRICE_POINTS_RETENTION_DAYS=30 # Maximum rows removed by one database statement during a retention run. # The job deletes in bounded batches using FOR UPDATE SKIP LOCKED and pauses diff --git a/src/__tests__/aggregateRefreshNetwork.test.ts b/src/__tests__/aggregateRefreshNetwork.test.ts index 8892cecd..27ce036d 100644 --- a/src/__tests__/aggregateRefreshNetwork.test.ts +++ b/src/__tests__/aggregateRefreshNetwork.test.ts @@ -71,7 +71,8 @@ const { mainnetPair, testnetPair } = vi.hoisted(() => ({ }, })) -vi.mock('../config', () => ({ +vi.mock('../config', async () => ({ + ...(await vi.importActual('../config')), // activeNetwork and config.pairs are what the pre-fix worker read. They stay // here so a regression fails on an assertion rather than at import time. activeNetwork: 'testnet', diff --git a/src/__tests__/aquariusIngester.test.ts b/src/__tests__/aquariusIngester.test.ts index 318e64fb..e786f4e9 100644 --- a/src/__tests__/aquariusIngester.test.ts +++ b/src/__tests__/aquariusIngester.test.ts @@ -12,7 +12,8 @@ const mocks = vi.hoisted(() => ({ }, })) -vi.mock('../config', () => ({ +vi.mock('../config', async () => ({ + ...(await vi.importActual('../config')), config: mocks.config, activeNetwork: 'testnet', // startAquariusIngester / ingestAquariusPair resolve the per-network Aquarius diff --git a/src/__tests__/configEnvironment.test.ts b/src/__tests__/configEnvironment.test.ts new file mode 100644 index 00000000..83dda6b7 --- /dev/null +++ b/src/__tests__/configEnvironment.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it, vi, afterEach } from 'vitest' +import { parseEnvironment } from '../config' + +describe('environment validation', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('ignores a malformed numeric knob with a warning that names the variable and not its value', () => { + const env = parseEnvironment({ RATE_LIMIT_IP_MAX: 'lots' }) + expect(env.RATE_LIMIT_IP_MAX).toBeUndefined() + }) + + it('ignores a malformed service URL so its default applies, instead of refusing to boot', () => { + const env = parseEnvironment({ REDIS_URL: 'cache-address' }) + expect(env.REDIS_URL).toBeUndefined() + }) + + it('requires DATABASE_URL in production', () => { + expect(() => parseEnvironment({ NODE_ENV: 'production' })) + .toThrow('[config] Invalid environment: DATABASE_URL is required in production') + }) + + it('does not require optional services or admin secrets in production; their routes fail closed on their own', () => { + expect(() => parseEnvironment({ + NODE_ENV: 'production', + DATABASE_URL: 'postgresql://lens:secret@db.example.com/lens', + })).not.toThrow() + }) + + 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') + }) + + it('keeps well-formed values and drops only the malformed ones', () => { + const env = parseEnvironment({ PORT: '3002', HTTP_TIMEOUT_MS: 'soon', REQUIRE_API_KEY: 'false' }) + expect(env.PORT).toBe('3002') + expect(env.HTTP_TIMEOUT_MS).toBeUndefined() + expect(env.REQUIRE_API_KEY).toBe('false') + }) +}) diff --git a/src/__tests__/correlation.test.ts b/src/__tests__/correlation.test.ts index 4a5b45ad..501bed05 100644 --- a/src/__tests__/correlation.test.ts +++ b/src/__tests__/correlation.test.ts @@ -8,7 +8,8 @@ const { mockQuery } = vi.hoisted(() => ({ vi.mock('../db', () => ({ pgPool: { query: mockQuery }, })) -vi.mock('../config', () => ({ +vi.mock('../config', async () => ({ + ...(await vi.importActual('../config')), activeNetwork: 'testnet', })) diff --git a/src/__tests__/defindexIngester.test.ts b/src/__tests__/defindexIngester.test.ts index 1a04a5d1..3501b89a 100644 --- a/src/__tests__/defindexIngester.test.ts +++ b/src/__tests__/defindexIngester.test.ts @@ -28,6 +28,7 @@ vi.mock('../config', () => { facilitator: { feeStroops: 50000 }, } return { + getEnvironment: () => ({}), getEnvironmentValue: () => undefined, activeNetwork: 'testnet', getNetworkConfig: () => block, config: { indexer: { pollIntervalMs: 5000 }, aquarius: { apiUrl: '' } }, diff --git a/src/__tests__/enabledNetworks.test.ts b/src/__tests__/enabledNetworks.test.ts index 5f7b919d..a434a034 100644 --- a/src/__tests__/enabledNetworks.test.ts +++ b/src/__tests__/enabledNetworks.test.ts @@ -2,7 +2,8 @@ import { describe, it, expect, afterEach, vi } from 'vitest' // activeNetwork is derived from STELLAR_NETWORK at config-module load; pin it so // the fallback path is deterministic regardless of the ambient env. -vi.mock('../config', () => ({ activeNetwork: 'testnet' })) +vi.mock('../config', async () => ({ + ...(await vi.importActual('../config')), activeNetwork: 'testnet' })) import { getEnabledNetworks } from '../network/enabledNetworks' diff --git a/src/__tests__/envDrift.test.ts b/src/__tests__/envDrift.test.ts index 937a5558..cd033aa8 100644 --- a/src/__tests__/envDrift.test.ts +++ b/src/__tests__/envDrift.test.ts @@ -62,8 +62,6 @@ export const UNDOCUMENTED_ENV_ALLOW_LIST: Record = { 'Legacy unsuffixed fallback for testnet; FACILITATOR_FEE_STROOPS_TESTNET and FACILITATOR_FEE_STROOPS_MAINNET are documented in .env.example.', API_KEY_LAST_USED_THROTTLE_SECONDS: 'Optional alias fallback; primary variable KEY_LAST_USED_THROTTLE_SECONDS is documented in .env.example.', - API_KEY_LAST_USED_THROTTLE_SECONDS: - 'Optional alias fallback; primary variable KEY_LAST_USED_THROTTLE_SECONDS is documented in .env.example.', } /** @@ -147,6 +145,10 @@ export function extractEnvReadsFromSource(sourceCode: string, fileName: string): if (isProcessEnv(node.expression)) { addRead(node.name.getText(sourceFile), node) } + // Typed access through the shared validated config module. + if (node.expression.getText(sourceFile) === 'getEnvironment()') { + addRead(node.name.getText(sourceFile), node) + } } // 2. process.env['X'] or process.env[`X_${suffix}`] @@ -165,6 +167,19 @@ export function extractEnvReadsFromSource(sourceCode: string, fileName: string): } } + // Keyed 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/__tests__/facilitator.test.ts b/src/__tests__/facilitator.test.ts index 00a25f34..36fa369d 100644 --- a/src/__tests__/facilitator.test.ts +++ b/src/__tests__/facilitator.test.ts @@ -24,7 +24,8 @@ vi.mock('@x402/stellar', () => ({ createEd25519Signer: vi.fn(), })) -vi.mock('../config', () => ({ +vi.mock('../config', async () => ({ + ...(await vi.importActual('../config')), getNetworkConfig: vi.fn(() => ({ rpc: { url: 'http://localhost' }, facilitator: { secretKey: 'S_MOCK_SECRET', feeStroops: 50000 }, diff --git a/src/__tests__/graphqlErrorMasking.test.ts b/src/__tests__/graphqlErrorMasking.test.ts index 7c08e7c7..b391274d 100644 --- a/src/__tests__/graphqlErrorMasking.test.ts +++ b/src/__tests__/graphqlErrorMasking.test.ts @@ -6,7 +6,8 @@ vi.mock('../redis', () => ({ getCachedPrice: vi.fn() })) vi.mock('../db', () => ({ pgPool: { query: vi.fn() } })) vi.mock('../aggregator/vwap', () => ({ getAggregatedPrice: vi.fn() })) vi.mock('../aggregator/bestRoute', () => ({ getBestRoute: vi.fn() })) -vi.mock('../config', () => ({ config: { pairs: [] }, activeNetwork: 'testnet' })) +vi.mock('../config', async () => ({ + ...(await vi.importActual('../config')), config: { pairs: [] }, activeNetwork: 'testnet' })) import { registerGraphQL } from '../api/graphql' import { pgPool } from '../db' diff --git a/src/__tests__/graphqlOracle.test.ts b/src/__tests__/graphqlOracle.test.ts index 7981ddf2..4f2fb43a 100644 --- a/src/__tests__/graphqlOracle.test.ts +++ b/src/__tests__/graphqlOracle.test.ts @@ -14,7 +14,8 @@ vi.mock('../redis', () => ({ getCachedPrice: vi.fn() })) vi.mock('../db', () => ({ pgPool: { query: mockQuery } })) vi.mock('../aggregator/vwap', () => ({ getAggregatedPrice: vi.fn() })) vi.mock('../aggregator/bestRoute', () => ({ getBestRoute: vi.fn() })) -vi.mock('../config', () => ({ config: { pairs: [] }, activeNetwork: 'testnet' })) +vi.mock('../config', async () => ({ + ...(await vi.importActual('../config')), config: { pairs: [] }, activeNetwork: 'testnet' })) vi.mock('../ingest/oracles/reflector', () => ({ reflectorOracleAdapter: mockAdapter })) import { registerGraphQL } from '../api/graphql' diff --git a/src/__tests__/graphqlPairResolution.test.ts b/src/__tests__/graphqlPairResolution.test.ts index 5f51a711..d850608d 100644 --- a/src/__tests__/graphqlPairResolution.test.ts +++ b/src/__tests__/graphqlPairResolution.test.ts @@ -11,7 +11,8 @@ vi.mock('../redis', () => ({ getCachedPrice: mocks.getCachedPrice })) vi.mock('../db', () => ({ pgPool: { query: vi.fn() }, prisma: {} })) vi.mock('../aggregator/vwap', () => ({ getAggregatedPrice: mocks.getAggregatedPrice })) vi.mock('../aggregator/bestRoute', () => ({ getBestRoute: mocks.getBestRoute })) -vi.mock('../config', () => ({ +vi.mock('../config', async () => ({ + ...(await vi.importActual('../config')), activeNetwork: 'testnet', config: { pairs: [ diff --git a/src/__tests__/graphqlPairs.test.ts b/src/__tests__/graphqlPairs.test.ts index 6d31bbb2..719d7959 100644 --- a/src/__tests__/graphqlPairs.test.ts +++ b/src/__tests__/graphqlPairs.test.ts @@ -31,7 +31,8 @@ vi.mock('../redis', () => ({ getCachedPrice: mocks.getCachedPrice })) vi.mock('../db', () => ({ pgPool: { query: mocks.pgPoolQuery }, prisma: {} })) vi.mock('../aggregator/vwap', () => ({ getAggregatedPrice: mocks.getAggregatedPrice })) vi.mock('../aggregator/bestRoute', () => ({ getBestRoute: mocks.getBestRoute })) -vi.mock('../config', () => ({ +vi.mock('../config', async () => ({ + ...(await vi.importActual('../config')), activeNetwork: 'mainnet', config: { pairs: mocks.pairs, diff --git a/src/__tests__/graphqlPriceHistory.test.ts b/src/__tests__/graphqlPriceHistory.test.ts index 733c27ac..ff55e21c 100644 --- a/src/__tests__/graphqlPriceHistory.test.ts +++ b/src/__tests__/graphqlPriceHistory.test.ts @@ -10,7 +10,8 @@ vi.mock('../db', () => ({ })) vi.mock('../aggregator/vwap', () => ({ getAggregatedPrice: vi.fn() })) vi.mock('../aggregator/bestRoute', () => ({ getBestRoute: vi.fn() })) -vi.mock('../config', () => ({ +vi.mock('../config', async () => ({ + ...(await vi.importActual('../config')), config: { pairs: [], cache: { priceTtl: 60 } }, activeNetwork: 'testnet', })) diff --git a/src/__tests__/graphqlQueryDepth.test.ts b/src/__tests__/graphqlQueryDepth.test.ts index 717321de..ce3dbc82 100644 --- a/src/__tests__/graphqlQueryDepth.test.ts +++ b/src/__tests__/graphqlQueryDepth.test.ts @@ -7,7 +7,8 @@ vi.mock('../redis', () => ({ getCachedPrice: vi.fn() })) vi.mock('../db', () => ({ pgPool: { query: vi.fn() } })) vi.mock('../aggregator/vwap', () => ({ getAggregatedPrice: vi.fn() })) vi.mock('../aggregator/bestRoute', () => ({ getBestRoute: vi.fn() })) -vi.mock('../config', () => ({ +vi.mock('../config', async () => ({ + ...(await vi.importActual('../config')), activeNetwork: 'testnet', config: { pairs: [{ diff --git a/src/__tests__/graphqlScreener.test.ts b/src/__tests__/graphqlScreener.test.ts index 87706081..f0ca1793 100644 --- a/src/__tests__/graphqlScreener.test.ts +++ b/src/__tests__/graphqlScreener.test.ts @@ -7,7 +7,8 @@ vi.mock('../db', () => ({ pgPool: { query: mockQuery }, prisma: {} })) vi.mock('../redis', () => ({ getCachedPrice: vi.fn() })) vi.mock('../aggregator/vwap', () => ({ getAggregatedPrice: vi.fn() })) vi.mock('../aggregator/bestRoute', () => ({ getBestRoute: vi.fn() })) -vi.mock('../config', () => ({ config: { pairs: [] }, activeNetwork: 'testnet' })) +vi.mock('../config', async () => ({ + ...(await vi.importActual('../config')), config: { pairs: [] }, activeNetwork: 'testnet' })) import { registerGraphQL } from '../api/graphql' import { MARKET_CAP_UNSUPPORTED } from '../services/screener' diff --git a/src/__tests__/graphqlSubscription.test.ts b/src/__tests__/graphqlSubscription.test.ts index b2061917..6099d354 100644 --- a/src/__tests__/graphqlSubscription.test.ts +++ b/src/__tests__/graphqlSubscription.test.ts @@ -10,7 +10,8 @@ vi.mock('../redis', () => ({ getCachedPrice: vi.fn() })) vi.mock('../db', () => ({ pgPool: { query: vi.fn() } })) vi.mock('../aggregator/vwap', () => ({ getAggregatedPrice: vi.fn() })) vi.mock('../aggregator/bestRoute', () => ({ getBestRoute: vi.fn() })) -vi.mock('../config', () => ({ config: { pairs: [] } })) +vi.mock('../config', async () => ({ + ...(await vi.importActual('../config')), config: { pairs: [] } })) import { registerGraphQL } from '../api/graphql' import { publishPriceUpdate, priceEmitter } from '../events' diff --git a/src/__tests__/oracle.test.ts b/src/__tests__/oracle.test.ts index dd1ac7a0..cf1bcc07 100644 --- a/src/__tests__/oracle.test.ts +++ b/src/__tests__/oracle.test.ts @@ -4,7 +4,8 @@ import Fastify from 'fastify' const { mockQuery } = vi.hoisted(() => ({ mockQuery: vi.fn() })) vi.mock('../db', () => ({ pgPool: { query: mockQuery } })) -vi.mock('../config', () => ({ activeNetwork: 'testnet' })) +vi.mock('../config', async () => ({ + ...(await vi.importActual('../config')), activeNetwork: 'testnet' })) vi.mock('../ingest/oracles/reflector', () => ({ reflectorOracleAdapter: { name: 'reflector', diff --git a/src/__tests__/phoenixIngester.test.ts b/src/__tests__/phoenixIngester.test.ts index 939946ff..b4e34062 100644 --- a/src/__tests__/phoenixIngester.test.ts +++ b/src/__tests__/phoenixIngester.test.ts @@ -83,7 +83,8 @@ const { const mocks = fixtures -vi.mock('../config', () => ({ +vi.mock('../config', async () => ({ + ...(await vi.importActual('../config')), config: { indexer: { pollIntervalMs: 5_000 } }, activeNetwork: 'testnet', getNetworkConfig: (network: string) => mocks.networks[network], diff --git a/src/__tests__/price.test.ts b/src/__tests__/price.test.ts index 69699583..cf53f538 100644 --- a/src/__tests__/price.test.ts +++ b/src/__tests__/price.test.ts @@ -37,7 +37,8 @@ const { testnetPairs, mainnetPairs } = vi.hoisted(() => ({ ], })) -vi.mock('../config', () => ({ +vi.mock('../config', async () => ({ + ...(await vi.importActual('../config')), config: { pairs: testnetPairs, cache: { priceTtl: 10 }, diff --git a/src/__tests__/rates.test.ts b/src/__tests__/rates.test.ts index e3f14472..ee87df5f 100644 --- a/src/__tests__/rates.test.ts +++ b/src/__tests__/rates.test.ts @@ -4,7 +4,8 @@ import Fastify from 'fastify' const { mockQuery } = vi.hoisted(() => ({ mockQuery: vi.fn() })) vi.mock('../db', () => ({ pgPool: { query: mockQuery } })) -vi.mock('../config', () => ({ activeNetwork: 'testnet' })) +vi.mock('../config', async () => ({ + ...(await vi.importActual('../config')), activeNetwork: 'testnet' })) import { registerRateRoutes } from '../routes/rates' diff --git a/src/__tests__/schemaValidation.test.ts b/src/__tests__/schemaValidation.test.ts index 55f96239..10cbe4dc 100644 --- a/src/__tests__/schemaValidation.test.ts +++ b/src/__tests__/schemaValidation.test.ts @@ -43,7 +43,8 @@ const { schemaTestPairs } = vi.hoisted(() => ({ ], })) -vi.mock('../config', () => ({ +vi.mock('../config', async () => ({ + ...(await vi.importActual('../config')), config: { pairs: schemaTestPairs, cache: { priceTtl: 10 }, diff --git a/src/__tests__/sep40Encoding.test.ts b/src/__tests__/sep40Encoding.test.ts index 8fcbde13..8e7c01f2 100644 --- a/src/__tests__/sep40Encoding.test.ts +++ b/src/__tests__/sep40Encoding.test.ts @@ -10,7 +10,8 @@ import { describe, it, expect, vi } from 'vitest' import { Asset, Networks, StrKey } from '@stellar/stellar-sdk' -vi.mock('../config', () => ({ +vi.mock('../config', async () => ({ + ...(await vi.importActual('../config')), activeNetwork: 'testnet', getNetworkConfig: (network: string) => ({ network: { diff --git a/src/__tests__/sep40Publisher.test.ts b/src/__tests__/sep40Publisher.test.ts index 524133fb..f1dd0c8f 100644 --- a/src/__tests__/sep40Publisher.test.ts +++ b/src/__tests__/sep40Publisher.test.ts @@ -52,7 +52,7 @@ const { configMock } = vi.hoisted(() => { } }) -vi.mock('../config', () => configMock) +vi.mock('../config', () => ({ ...configMock, getEnvironment: () => ({}), getEnvironmentValue: () => undefined })) vi.mock('../db', () => ({ pgPool: { query: vi.fn().mockResolvedValue({ rows: [] }) }, prisma: { oraclePublication: { create: vi.fn().mockResolvedValue({}) } }, diff --git a/src/__tests__/snapshot.test.ts b/src/__tests__/snapshot.test.ts index db69b8f0..47f4ba2b 100644 --- a/src/__tests__/snapshot.test.ts +++ b/src/__tests__/snapshot.test.ts @@ -19,7 +19,8 @@ vi.mock('../redis', () => ({ setCachedSnapshot: mockSetCachedSnapshot, })) -vi.mock('../config', () => ({ +vi.mock('../config', async () => ({ + ...(await vi.importActual('../config')), activeNetwork: configState.active, getNetworkConfig: (network: string) => ({ pairs: configState.pairs[network] ?? [], diff --git a/src/__tests__/soroswapEnabled.test.ts b/src/__tests__/soroswapEnabled.test.ts index 14edb9bf..7654ce2e 100644 --- a/src/__tests__/soroswapEnabled.test.ts +++ b/src/__tests__/soroswapEnabled.test.ts @@ -23,7 +23,8 @@ const mocks = vi.hoisted(() => ({ // The ingester now takes `network` (defaulting to `activeNetwork`) and reads // via getNetworkConfig, so the mock has to supply both. getNetworkConfig // returns the same object the test mutates, keeping `enabled` toggleable. -vi.mock('../config', () => ({ +vi.mock('../config', async () => ({ + ...(await vi.importActual('../config')), config: mocks.config, activeNetwork: 'testnet', getNetworkConfig: () => mocks.config, diff --git a/src/__tests__/spreads.test.ts b/src/__tests__/spreads.test.ts index 308163c9..4fb0f81d 100644 --- a/src/__tests__/spreads.test.ts +++ b/src/__tests__/spreads.test.ts @@ -4,7 +4,8 @@ import Fastify from 'fastify' const { mockQuery } = vi.hoisted(() => ({ mockQuery: vi.fn() })) vi.mock('../db', () => ({ pgPool: { query: mockQuery } })) -vi.mock('../config', () => ({ activeNetwork: 'testnet' })) +vi.mock('../config', async () => ({ + ...(await vi.importActual('../config')), activeNetwork: 'testnet' })) import { registerSpreadsRoutes } from '../routes/spreads' diff --git a/src/__tests__/status.test.ts b/src/__tests__/status.test.ts index 9c4995e2..44abaf23 100644 --- a/src/__tests__/status.test.ts +++ b/src/__tests__/status.test.ts @@ -39,7 +39,8 @@ const { pairsByNetwork } = vi.hoisted(() => ({ } as Record, })) -vi.mock('../config', () => ({ +vi.mock('../config', async () => ({ + ...(await vi.importActual('../config')), config: { pairs: pairsByNetwork.testnet, cache: { priceTtl: 10 }, diff --git a/src/__tests__/vwapRoute.test.ts b/src/__tests__/vwapRoute.test.ts index 49f4e9e3..1d06b782 100644 --- a/src/__tests__/vwapRoute.test.ts +++ b/src/__tests__/vwapRoute.test.ts @@ -5,7 +5,8 @@ const { mockComputeVWAP } = vi.hoisted(() => ({ mockComputeVWAP: vi.fn(), })) -vi.mock('../config', () => ({ +vi.mock('../config', async () => ({ + ...(await vi.importActual('../config')), config: { cache: { priceTtl: 10 }, }, diff --git a/src/api/admin.ts b/src/api/admin.ts index 295636a1..925c609f 100644 --- a/src/api/admin.ts +++ b/src/api/admin.ts @@ -4,6 +4,7 @@ import { prisma } from '../db' import { admin_auth_failures_total } from '../metrics' import { hashApiKey, isAdminAuthorized, checkAdminAuth, invalidateApiKey } from './auth' import { copyUsageHistory } from '../x402/metering' +import { getEnvironment } from '../config' /** Generates a new opaque API key with a recognizable `lens_` prefix. */ export function generateApiKey(): string { @@ -230,7 +231,7 @@ export async function registerAdminRoutes(app: FastifyInstance) { return reply.status(400).send({ error: 'Cannot rotate a revoked API key' }) } - const defaultOverlap = parseInt(process.env.KEY_ROTATION_OVERLAP_HOURS ?? '24', 10) || 24 + const defaultOverlap = parseInt(getEnvironment().KEY_ROTATION_OVERLAP_HOURS ?? '24', 10) || 24 const overlapHours = req.body?.overlapHours !== undefined && Number.isFinite(req.body.overlapHours) && diff --git a/src/api/auth.ts b/src/api/auth.ts index f95b412e..97c48970 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -4,7 +4,7 @@ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify' import { createHash, timingSafeEqual } from 'crypto' import fp from 'fastify-plugin' import { prisma } from '../db' -import { config } from '../config' +import { config, getEnvironment } from '../config' import { api_key_auth_failures_total, admin_auth_failures_total, api_key_cache_lookups_total } from '../metrics' const log = createLogger('auth') @@ -169,8 +169,8 @@ const keyLastUsedTimestamps = new Map() */ export function getKeyLastUsedThrottleMs(): number { const envVal = - process.env.KEY_LAST_USED_THROTTLE_SECONDS ?? - process.env.API_KEY_LAST_USED_THROTTLE_SECONDS + getEnvironment().KEY_LAST_USED_THROTTLE_SECONDS ?? + getEnvironment().API_KEY_LAST_USED_THROTTLE_SECONDS if (envVal !== undefined) { const parsed = parseInt(envVal, 10) if (Number.isFinite(parsed) && parsed >= 0) { @@ -275,7 +275,7 @@ export type AdminAuthResult = 'authorized' | 'unauthorized' | 'not_configured' * An unset token, whitespace-only string, or obvious placeholder disables the admin surface. */ export function isAdminConfigured(): boolean { - const adminToken = process.env.ADMIN_TOKEN?.trim() + const adminToken = getEnvironment().ADMIN_TOKEN?.trim() if (!adminToken || adminToken.length === 0) return false const weakPlaceholders = new Set(['your-admin-token-here', 'change-me', 'placeholder']) return !weakPlaceholders.has(adminToken.toLowerCase()) @@ -291,7 +291,7 @@ export function checkAdminAuth(req: FastifyRequest): AdminAuthResult { if (!isAdminConfigured()) { return 'not_configured' } - const adminToken = process.env.ADMIN_TOKEN!.trim() + const adminToken = getEnvironment().ADMIN_TOKEN!.trim() const supplied = (req.headers['x-admin-token'] as string | undefined) ?? req.headers['authorization']?.replace(/^Bearer\s+/i, '') diff --git a/src/api/queryDepth.ts b/src/api/queryDepth.ts index 1b51da94..366150b1 100644 --- a/src/api/queryDepth.ts +++ b/src/api/queryDepth.ts @@ -1,3 +1,4 @@ +import { getEnvironment } from '../config' import { GraphQLError, Kind, @@ -12,7 +13,7 @@ import { export const DEFAULT_MAX_GRAPHQL_QUERY_DEPTH = 3 /** Reads the GraphQL selection-depth ceiling at server registration time. */ -export function getMaxGraphQLQueryDepth(env: NodeJS.ProcessEnv = process.env): number { +export function getMaxGraphQLQueryDepth(env: Record = getEnvironment()): number { const raw = env.GRAPHQL_MAX_QUERY_DEPTH if (raw === undefined) return DEFAULT_MAX_GRAPHQL_QUERY_DEPTH diff --git a/src/api/schemas.ts b/src/api/schemas.ts index c7a4ebe6..aeb3bc23 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`. @@ -880,7 +881,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, allowUnionTypes: true }) diff --git a/src/api/websocket.ts b/src/api/websocket.ts index 268fa987..4b3baaf9 100644 --- a/src/api/websocket.ts +++ b/src/api/websocket.ts @@ -1,14 +1,14 @@ import type { FastifyInstance, FastifyRequest } from 'fastify' import websocket from '@fastify/websocket' import { priceEmitter, PRICE_UPDATE, PriceUpdateEvent } from '../events' -import { activeNetwork, config, type NetworkName } from '../config' +import { activeNetwork, config, type NetworkName, getEnvironment } from '../config' import { X402_NETWORK_LABEL, paymentAddressFor, getX402ResourceServer } from '../x402/network' import { resolveNetworkName } from '../middleware/network' import { fanOutManager } from '../ws/fanout' import { v4 as uuid } from 'uuid' import { createInternalErrorBody, getCorrelationId } from '../errors' -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) diff --git a/src/app.ts b/src/app.ts index bb11014b..5c95eb68 100644 --- a/src/app.ts +++ b/src/app.ts @@ -43,6 +43,7 @@ import { getMetrics, rate_limit_rejections_total } from './metrics' import { loggerOptions, createDestination } from './logger' import { isPoolExhaustionError, recordPoolExhaustion } from './db' import { createCorsDelegator, type CorsConfig } from './cors' +import { getEnvironment } from './config' /** * Routes the rate limiter skips. @@ -132,7 +133,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise req.apiKey?.ratePerMin ?? ipRateLimitMax, timeWindow: '1 minute', @@ -185,7 +186,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 f0fe496a..6ee648f7 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,10 +1,199 @@ 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') +/** + * Every environment variable the process reads. This module is the only place + * that reads `process.env`; everything else goes through `getEnvironment()` or + * `getEnvironmentValue()`, so a variable cannot be read without being declared + * here and documented in .env.example. + */ +const PER_NETWORK_BASES = [ + 'HORIZON_URL', 'RPC_URL', 'NETWORK_PASSPHRASE', 'SOROSWAP_FACTORY_ADDRESS', 'SOROSWAP_TOKEN_LIST_URL', + 'SOROSWAP_ENABLED', 'SOROSWAP_POLL_INTERVAL_MS', 'AQUARIUS_API_URL', 'AQUARIUS_ENABLED', + 'REFLECTOR_CONTRACT_ID', 'REFLECTOR_ENABLED', 'WATCHED_PAIRS', 'FACILITATOR_SECRET_KEY', 'FACILITATOR_FEE_STROOPS', + 'DEFINDEX_FACTORY_ADDRESS', 'DEFINDEX_ENABLED', 'DEFINDEX_POLL_INTERVAL_MS', + 'PHOENIX_FACTORY_ADDRESS', 'PHOENIX_ENABLED', 'BLEND_POOLS', 'BLEND_BACKSTOP', 'BLEND_ENABLED', 'BLEND_POLL_INTERVAL_MS', + 'SEP40_CONTRACT_ID', 'SEP40_PUBLISHER_SECRET_KEY', 'SEP40_ENABLED', + 'ROUTING_MAX_HOPS', 'ROUTING_INTERMEDIATES', 'ORACLE_PAYMENT_ADDRESS', +] as const + +const GLOBAL_KEYS = [ + 'NODE_ENV', 'STELLAR_NETWORK', 'ENABLED_NETWORKS', 'LOG_LEVEL', + 'DATABASE_URL', 'DIRECT_DATABASE_URL', 'DB_POOL_SIZE', 'DB_POOL_TIMEOUT_MS', + 'REDIS_URL', 'READINESS_REDIS_CHECK', 'PORT', 'HOST', + 'POLL_INTERVAL_MS', 'SDEX_PAGE_SIZE', 'AMM_PAGE_SIZE', 'PRICE_CACHE_TTL', 'SNAPSHOT_CACHE_TTL', + 'API_KEY_CACHE_TTL', 'PRICE_MIN_PLAUSIBLE', 'PRICE_MAX_PLAUSIBLE', 'HTTP_TIMEOUT_MS', + 'OUTBOUND_ALLOWED_HOSTS', 'X402_FACILITATOR_URL', 'COINBASE_EXCHANGE_BASE_URL', + 'WS_PING_INTERVAL_MS', 'WS_MAX_MISSED_PINGS', + 'MCP_MAX_RESULT_BYTES', 'MCP_MAX_UPSTREAM_BYTES', 'MCP_DEFAULT_PAGE_LIMIT', 'MCP_MAX_PAGE_LIMIT', + 'MCP_OPENAPI_PATH', 'MCP_TRANSPORT', 'MCP_HTTP_AUTH_TOKEN', 'MCP_HTTP_HOST', 'MCP_HTTP_PORT', 'MCP_HTTP_PATH', + 'SEP40_DEVIATION_PCT', 'SEP40_MAX_INTERVAL_MS', 'SEP40_MIN_WRITE_INTERVAL_MS', 'SEP40_MAX_AGE_SECONDS', 'SEP40_POLL_INTERVAL_MS', + 'PHOENIX_POLL_INTERVAL_MS', 'PHOENIX_POOL_CACHE_TTL_MS', + 'INGEST_CYCLE_DEADLINE_MS', + 'GLOBAL_RATE_LIMIT_MAX', 'ADMIN_API_KEY', 'ADMIN_TOKEN', 'REQUIRE_API_KEY', 'RATE_LIMIT_IP_MAX', + 'KEY_ROTATION_OVERLAP_HOURS', 'KEY_LAST_USED_THROTTLE_SECONDS', 'API_KEY_LAST_USED_THROTTLE_SECONDS', + 'CORS_ALLOWED_ORIGINS', 'CORS_ADMIN_ALLOWED_ORIGINS', 'CORS_PUBLIC_BLOCKED_ORIGINS', + 'SNAPSHOT_PRUNE_BATCH_SIZE', 'RATE_PRUNE_BATCH_SIZE', + 'PRICE_POINTS_RETENTION_DAYS', 'PRICE_POINTS_PRECREATE_DAYS', 'PRICE_POINTS_DROP_EXPIRED', 'PRICE_POINTS_PRUNE_BATCH_SIZE', + 'BAZAAR_CATALOG_WRITES_PER_MIN', 'BAZAAR_CATALOG_WRITES_PER_DAY', + 'LIVE_API_URL', 'LENS_URL', 'GITHUB_TOKEN', 'GH_TOKEN', 'GITHUB_REPOSITORY', 'GITHUB_STEP_SUMMARY', + 'GRAPHQL_MAX_QUERY_DEPTH', 'READINESS_TIMEOUT_MS', 'READINESS_CACHE_MS', + 'WS_BUFFER_SWEEP_INTERVAL_MS', 'WS_COALESCE_INTERVAL_MS', 'WS_MAX_CONGESTED_MS', 'WS_MAX_BUFFERED_BYTES', 'WS_SLOW_CLIENT_BUFFER_BYTES', +] as const + +/** + * Per-venue cycle deadlines are keyed by venue name, which the adapter registry + * owns and which can grow, so they are read by prefix rather than listed here. + */ +const CYCLE_DEADLINE_VENUE_PREFIX = 'INGEST_CYCLE_DEADLINE_MS_' +const CYCLE_DEADLINE_KEYS = ['INGEST_CYCLE_DEADLINE_MS_TESTNET', 'INGEST_CYCLE_DEADLINE_MS_MAINNET'] as const + +const ENVIRONMENT_KEYS = [ + ...GLOBAL_KEYS, + ...PER_NETWORK_BASES, + ...PER_NETWORK_BASES.flatMap(base => [`${base}_TESTNET`, `${base}_MAINNET`]), + ...CYCLE_DEADLINE_KEYS, +] as const + +export type EnvironmentKey = (typeof ENVIRONMENT_KEYS)[number] + +/** Whether a name is a declared variable, for lookups keyed by a computed name. */ +export function isDeclaredEnvironmentKey(name: string): name is EnvironmentKey { + return (ENVIRONMENT_KEYS as readonly string[]).includes(name) +} + +const envSchema = z.object( + Object.fromEntries(ENVIRONMENT_KEYS.map(key => [key, z.string().optional()])) as Record> +) + +export type Environment = z.infer + +/** Variables that must be an integer (a number of ms, pages, bytes, days, ...). */ +const numericEnvironmentKeys: readonly EnvironmentKey[] = [ + ...CYCLE_DEADLINE_KEYS, + 'PORT', 'DB_POOL_SIZE', 'DB_POOL_TIMEOUT_MS', 'POLL_INTERVAL_MS', 'SDEX_PAGE_SIZE', 'AMM_PAGE_SIZE', + 'PRICE_CACHE_TTL', 'SNAPSHOT_CACHE_TTL', 'API_KEY_CACHE_TTL', 'HTTP_TIMEOUT_MS', + 'WS_PING_INTERVAL_MS', 'WS_MAX_MISSED_PINGS', 'GLOBAL_RATE_LIMIT_MAX', 'RATE_LIMIT_IP_MAX', + 'KEY_ROTATION_OVERLAP_HOURS', 'KEY_LAST_USED_THROTTLE_SECONDS', 'API_KEY_LAST_USED_THROTTLE_SECONDS', + 'SNAPSHOT_PRUNE_BATCH_SIZE', 'RATE_PRUNE_BATCH_SIZE', 'PRICE_POINTS_PRUNE_BATCH_SIZE', + 'PRICE_POINTS_RETENTION_DAYS', 'PRICE_POINTS_PRECREATE_DAYS', + 'BAZAAR_CATALOG_WRITES_PER_MIN', 'BAZAAR_CATALOG_WRITES_PER_DAY', + 'SEP40_MAX_INTERVAL_MS', 'SEP40_MIN_WRITE_INTERVAL_MS', 'SEP40_MAX_AGE_SECONDS', 'SEP40_POLL_INTERVAL_MS', + 'PHOENIX_POLL_INTERVAL_MS', 'PHOENIX_POOL_CACHE_TTL_MS', 'INGEST_CYCLE_DEADLINE_MS', + 'MCP_MAX_RESULT_BYTES', 'MCP_MAX_UPSTREAM_BYTES', 'MCP_DEFAULT_PAGE_LIMIT', 'MCP_MAX_PAGE_LIMIT', 'MCP_HTTP_PORT', + 'ROUTING_MAX_HOPS', + 'WS_BUFFER_SWEEP_INTERVAL_MS', 'WS_COALESCE_INTERVAL_MS', 'WS_MAX_CONGESTED_MS', 'WS_MAX_BUFFERED_BYTES', 'WS_SLOW_CLIENT_BUFFER_BYTES', + 'GRAPHQL_MAX_QUERY_DEPTH', 'READINESS_TIMEOUT_MS', 'READINESS_CACHE_MS', + ...PER_NETWORK_BASES.filter(b => /_(POLL_INTERVAL_MS|FEE_STROOPS|MAX_HOPS)$/.test(b)) + .flatMap(base => [base, `${base}_TESTNET`, `${base}_MAINNET`] as EnvironmentKey[]), +] + +/** Variables that must be true or false when set. */ +const booleanEnvironmentKeys: readonly EnvironmentKey[] = [ + 'REQUIRE_API_KEY', 'READINESS_REDIS_CHECK', + ...PER_NETWORK_BASES.filter(b => /_ENABLED$/.test(b)).flatMap(base => [base, `${base}_TESTNET`, `${base}_MAINNET`] as EnvironmentKey[]), +] + +/** Variables that must parse as an absolute URL when set. */ +const urlEnvironmentKeys: readonly EnvironmentKey[] = [ + 'REDIS_URL', 'X402_FACILITATOR_URL', 'COINBASE_EXCHANGE_BASE_URL', + 'SOROSWAP_TOKEN_LIST_URL', 'AQUARIUS_API_URL', 'HORIZON_URL', 'RPC_URL', + ...(['SOROSWAP_TOKEN_LIST_URL', 'AQUARIUS_API_URL', 'HORIZON_URL', 'RPC_URL'] as const).flatMap(base => [`${base}_TESTNET`, `${base}_MAINNET`] as EnvironmentKey[]), +] + +function isBlank(value: string | undefined): boolean { + return value === undefined || value.trim() === '' +} + +/** Describe a malformed value without echoing it (it may be a secret). */ +function malformedReason(key: EnvironmentKey, value: string): string | undefined { + if (numericEnvironmentKeys.includes(key)) { + const trimmed = value.trim() + if (!/^-?\d+$/.test(trimmed) || !Number.isSafeInteger(Number(trimmed))) return 'must be a safe integer' + } + if (booleanEnvironmentKeys.includes(key) && value !== 'true' && value !== 'false') return 'must be true or false' + if (urlEnvironmentKeys.includes(key)) { + try { + new URL(value) + } catch { + return 'must be a valid URL' + } + } + if (key === 'NODE_ENV' && !['development', 'test', 'production'].includes(value)) return 'must be development, test, or production' + if (key === 'STELLAR_NETWORK' && !['testnet', 'mainnet'].includes(value.toLowerCase())) return 'must be testnet or mainnet' + return undefined +} + +/** + * Only the database URL is checked at boot, because without it the process does + * nothing useful. Every other malformed value is logged and ignored, so its + * default applies at the point of use: a bad cache address or a bad optional + * tuning knob must not take the whole API down at import time. + */ +export function parseEnvironment(source: Record): Environment { + const accepted: Record = {} + for (const key of ENVIRONMENT_KEYS) { + const value = source[key] + if (isBlank(value)) continue + const reason = malformedReason(key, value as string) + if (reason) { + log.warn({ envVar: key }, `${key} ${reason}; ignoring it and using the default`) + continue + } + accepted[key] = value as string + } + + if (source.NODE_ENV === 'production' && isBlank(accepted.DATABASE_URL)) { + throw new Error('[config] Invalid environment: DATABASE_URL is required in production') + } + return envSchema.parse(accepted) +} + +let parsedEnvironment: Environment | undefined +let environmentFingerprint = '' + +/** Parse the complete declared environment once, and again only if a caller changes it. */ +export function getEnvironment(): Environment { + const source = Object.fromEntries(ENVIRONMENT_KEYS.map(key => [key, process.env[key]])) + const fingerprint = JSON.stringify(source) + if (!parsedEnvironment || fingerprint !== environmentFingerprint) { + parsedEnvironment = parseEnvironment(source) + environmentFingerprint = fingerprint + } + return parsedEnvironment +} + +/** Read a declared variable selected dynamically by a network suffix or venue name. */ +export function getEnvironmentValue(name: string): string | undefined { + if (!(ENVIRONMENT_KEYS as readonly string[]).includes(name)) { + throw new Error(`[config] ${name} is not a declared environment variable`) + } + return (getEnvironment() as Record)[name] +} + +/** + * Read a per-venue override whose name is built from a venue name, under the + * one prefix that allows it. Any other name is a programming error. + */ +export function getPrefixedEnvironmentValue(name: string): string | undefined { + if (!name.startsWith(CYCLE_DEADLINE_VENUE_PREFIX)) { + throw new Error(`[config] ${name} is not a prefixed environment variable`) + } + const raw = process.env[name] + return raw === undefined || raw.trim() === '' ? undefined : raw +} + +/** 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 ───────────────────────────────────────────────────────────────────── export type NetworkName = 'testnet' | 'mainnet' @@ -228,30 +417,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') @@ -259,17 +448,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 ) @@ -278,12 +467,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' // ── DeFindex vault factory ──────────────────────────────────────────────── // DeFindex vaults are enumerable from the factory contract. The factory ids @@ -292,19 +481,19 @@ function buildNetworkConfig(network: NetworkName): NetworkConfig { // and public/testnet.contracts.json). Testnet redeploys often, so override // with DEFINDEX_FACTORY_ADDRESS_TESTNET when the published id changes. const defindexFactory = - process.env[`DEFINDEX_FACTORY_ADDRESS_${suffix}`] || - process.env.DEFINDEX_FACTORY_ADDRESS || + getEnvironmentValue(`DEFINDEX_FACTORY_ADDRESS_${suffix}`) || + getEnvironment().DEFINDEX_FACTORY_ADDRESS || (network === 'mainnet' ? 'CDKFHFJIET3A73A2YN4KV7NSV32S6YGQMUFH3DNJXLBWL4SKEGVRNFKI' : 'CDSCWE4GLNBYYTES2OCYDFQA2LLY4RBIAX6ZI32VSUXD7GO6HRPO4A32') const defindexEnabled = - (process.env[`DEFINDEX_ENABLED_${suffix}`] ?? 'true').toLowerCase() !== 'false' && + (getEnvironmentValue(`DEFINDEX_ENABLED_${suffix}`) ?? 'true').toLowerCase() !== 'false' && isUsableContractId(defindexFactory, `DEFINDEX_FACTORY_ADDRESS_${suffix}`) const defindexPollMs = parseInt( - process.env[`DEFINDEX_POLL_INTERVAL_MS_${suffix}`] || - process.env.DEFINDEX_POLL_INTERVAL_MS || + getEnvironmentValue(`DEFINDEX_POLL_INTERVAL_MS_${suffix}`) || + getEnvironment().DEFINDEX_POLL_INTERVAL_MS || '60000', 10 ) @@ -321,25 +510,25 @@ function buildNetworkConfig(network: NetworkName): NetworkConfig { // names it here. An id that is empty or malformed leaves the venue disabled // on that network rather than pointing the ingester at something invented. const phoenixFactory = - process.env[`PHOENIX_FACTORY_ADDRESS_${suffix}`] || - (network === 'testnet' ? process.env.PHOENIX_FACTORY_ADDRESS : undefined) || + getEnvironmentValue(`PHOENIX_FACTORY_ADDRESS_${suffix}`) || + (network === 'testnet' ? getEnvironment().PHOENIX_FACTORY_ADDRESS : undefined) || (network === 'mainnet' ? 'CB4SVAWJA6TSRNOJZ7W2AWFW46D5VR4ZMFZKDIKXEINZCZEGZCJZCKMI' : '') const phoenixEnabled = - (process.env[`PHOENIX_ENABLED_${suffix}`] ?? 'true').toLowerCase() !== 'false' && + (getEnvironmentValue(`PHOENIX_ENABLED_${suffix}`) ?? 'true').toLowerCase() !== 'false' && isUsableContractId(phoenixFactory, `PHOENIX_FACTORY_ADDRESS_${suffix}`) const phoenixPollMs = parseInt( - process.env.PHOENIX_POLL_INTERVAL_MS || '60000', + getEnvironment().PHOENIX_POLL_INTERVAL_MS || '60000', 10 ) // Pool enumeration is one RPC read of the whole factory, so it is reused // across pairs and cycles instead of being paid for on every price poll. const phoenixPoolCacheTtlMs = parseInt( - process.env.PHOENIX_POOL_CACHE_TTL_MS || '600000', + getEnvironment().PHOENIX_POOL_CACHE_TTL_MS || '600000', 10 ) @@ -349,12 +538,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}`) // ── Blend lending protocol ──────────────────────────────────────────────── @@ -372,13 +561,13 @@ function buildNetworkConfig(network: NetworkName): NetworkConfig { const TESTNET_BLEND_BACKSTOP = 'CBDVWXT433PRVTUNM56C3JREF3HIZHRBA64NB2C3B2UNCKIS65ZYCLZA' const blendBackstop = - process.env[`BLEND_BACKSTOP_${suffix}`] || - (network === 'testnet' ? process.env.BLEND_BACKSTOP : undefined) || + getEnvironmentValue(`BLEND_BACKSTOP_${suffix}`) || + (network === 'testnet' ? getEnvironment().BLEND_BACKSTOP : undefined) || (network === 'mainnet' ? MAINNET_BLEND_BACKSTOP : TESTNET_BLEND_BACKSTOP) const rawBlendPools = - process.env[`BLEND_POOLS_${suffix}`] || - (network === 'testnet' ? process.env.BLEND_POOLS : undefined) + getEnvironmentValue(`BLEND_POOLS_${suffix}`) || + (network === 'testnet' ? getEnvironment().BLEND_POOLS : undefined) const parsedBlendPools: string[] = rawBlendPools ? rawBlendPools @@ -388,13 +577,13 @@ function buildNetworkConfig(network: NetworkName): NetworkConfig { : (network === 'mainnet' ? MAINNET_BLEND_POOLS : TESTNET_BLEND_POOLS) const blendEnabled = - (process.env[`BLEND_ENABLED_${suffix}`] ?? (network === 'testnet' ? (process.env.BLEND_ENABLED ?? 'true') : 'true')).toLowerCase() !== 'false' && + (getEnvironmentValue(`BLEND_ENABLED_${suffix}`) ?? (network === 'testnet' ? (getEnvironment().BLEND_ENABLED ?? 'true') : 'true')).toLowerCase() !== 'false' && parsedBlendPools.length > 0 && isUsableContractId(blendBackstop, `BLEND_BACKSTOP_${suffix}`) const blendPollMs = parseInt( - process.env[`BLEND_POLL_INTERVAL_MS_${suffix}`] || - process.env.BLEND_POLL_INTERVAL_MS || + getEnvironmentValue(`BLEND_POLL_INTERVAL_MS_${suffix}`) || + getEnvironment().BLEND_POLL_INTERVAL_MS || '60000', 10 ) @@ -405,24 +594,24 @@ function buildNetworkConfig(network: NetworkName): NetworkConfig { // somebody else's contract — or nowhere. `isUsableContractId` rejects a // malformed id the same way every other contract address in this file does. const sep40ContractId = - process.env[`SEP40_CONTRACT_ID_${suffix}`] || - process.env.SEP40_CONTRACT_ID || + getEnvironmentValue(`SEP40_CONTRACT_ID_${suffix}`) || + getEnvironment().SEP40_CONTRACT_ID || '' const sep40SecretKey = - process.env[`SEP40_PUBLISHER_SECRET_KEY_${suffix}`] || - (network === 'testnet' ? process.env.SEP40_PUBLISHER_SECRET_KEY : undefined) + getEnvironmentValue(`SEP40_PUBLISHER_SECRET_KEY_${suffix}`) || + (network === 'testnet' ? getEnvironment().SEP40_PUBLISHER_SECRET_KEY : undefined) // A key with no contract, or a contract with no key, is an incomplete // deployment rather than a mistake worth crashing on — publication stays off // and says so. const publisherEnabled = - (process.env[`SEP40_ENABLED_${suffix}`] ?? 'false').toLowerCase() !== 'false' && + (getEnvironmentValue(`SEP40_ENABLED_${suffix}`) ?? 'false').toLowerCase() !== 'false' && isUsableContractId(sep40ContractId, `SEP40_CONTRACT_ID_${suffix}`) && Boolean(sep40SecretKey) if ( - (process.env[`SEP40_ENABLED_${suffix}`] ?? '').toLowerCase() === 'true' && + (getEnvironmentValue(`SEP40_ENABLED_${suffix}`) ?? '').toLowerCase() === 'true' && !publisherEnabled ) { log.warn( @@ -440,33 +629,33 @@ 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 : '') // ── Multi-hop Routing ─────────────────────────────────────────────────── const maxHopsRaw = - process.env[`ROUTING_MAX_HOPS_${suffix}`] || - (network === 'testnet' ? process.env.ROUTING_MAX_HOPS : undefined) || + getEnvironmentValue(`ROUTING_MAX_HOPS_${suffix}`) || + (network === 'testnet' ? getEnvironment().ROUTING_MAX_HOPS : undefined) || '3' const maxHops = Math.max(1, parseInt(maxHopsRaw, 10) || 3) const defaultIntermediates = network === 'mainnet' ? MAINNET_DEFAULT_INTERMEDIATES : TESTNET_DEFAULT_INTERMEDIATES const rawIntermediates = - process.env[`ROUTING_INTERMEDIATES_${suffix}`] || - (network === 'testnet' ? process.env.ROUTING_INTERMEDIATES : undefined) || + getEnvironmentValue(`ROUTING_INTERMEDIATES_${suffix}`) || + (network === 'testnet' ? getEnvironment().ROUTING_INTERMEDIATES : undefined) || defaultIntermediates const intermediates = parseIntermediateAssets(rawIntermediates) // ── 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 ) @@ -518,21 +707,23 @@ function buildNetworkConfig(network: NetworkName): NetworkConfig { const globalConfig = { db: { - url: process.env.DATABASE_URL ?? 'postgresql://lens:lens@localhost:5432/lens', - connectionLimit: parseInt(process.env.DB_POOL_SIZE ?? '10', 10), - poolTimeoutMs: parseInt(process.env.DB_POOL_TIMEOUT_MS ?? '5000', 10), + // DATABASE_URL is required when NODE_ENV=production (see parseEnvironment), + // so this local default only applies in development and test. + url: getEnvironment().DATABASE_URL ?? 'postgresql://lens:lens@localhost:5432/lens', + connectionLimit: parseInt(getEnvironment().DB_POOL_SIZE ?? '10', 10), + poolTimeoutMs: parseInt(getEnvironment().DB_POOL_TIMEOUT_MS ?? '5000', 10), }, 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), }, /** * When the publisher writes to the SEP-40 feed. @@ -557,37 +748,37 @@ const globalConfig = { * it", a price a contract settles against cannot. */ publisher: { - deviationPct: positiveNumber(process.env.SEP40_DEVIATION_PCT, 0.5), - maxIntervalMs: positiveNumber(process.env.SEP40_MAX_INTERVAL_MS, 15 * 60 * 1000), - minWriteIntervalMs: positiveNumber(process.env.SEP40_MIN_WRITE_INTERVAL_MS, 60 * 1000), - maxAgeSeconds: positiveNumber(process.env.SEP40_MAX_AGE_SECONDS, 120), - pollIntervalMs: positiveNumber(process.env.SEP40_POLL_INTERVAL_MS, 30 * 1000), + deviationPct: positiveNumber(getEnvironment().SEP40_DEVIATION_PCT, 0.5), + maxIntervalMs: positiveNumber(getEnvironment().SEP40_MAX_INTERVAL_MS, 15 * 60 * 1000), + minWriteIntervalMs: positiveNumber(getEnvironment().SEP40_MIN_WRITE_INTERVAL_MS, 60 * 1000), + maxAgeSeconds: positiveNumber(getEnvironment().SEP40_MAX_AGE_SECONDS, 120), + pollIntervalMs: positiveNumber(getEnvironment().SEP40_POLL_INTERVAL_MS, 30 * 1000), }, 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), // Issue #495: how long an API-key lookup (positive OR negative) is held in // the in-process auth cache before it must be re-read from Postgres. This // is the worst-case window in which a key revoked on ANOTHER instance can // still authenticate here — on the instance that handles the revoke, // invalidation is immediate. Expiry is re-checked on every cache hit, so // this TTL never extends a key past its own `expiresAt`. Keep it short. - apiKeyTtl: parseInt(process.env.API_KEY_CACHE_TTL ?? '30', 10), + apiKeyTtl: parseInt(getEnvironment().API_KEY_CACHE_TTL ?? '30', 10), }, plausibility: { - minPrice: parseFloat(process.env.PRICE_MIN_PLAUSIBLE ?? '0.0000000001'), - maxPrice: parseFloat(process.env.PRICE_MAX_PLAUSIBLE ?? '10000000000'), + minPrice: parseFloat(getEnvironment().PRICE_MIN_PLAUSIBLE ?? '0.0000000001'), + maxPrice: parseFloat(getEnvironment().PRICE_MAX_PLAUSIBLE ?? '10000000000'), }, http: { - timeoutMs: parseInt(process.env.HTTP_TIMEOUT_MS ?? '10000', 10), + timeoutMs: parseInt(getEnvironment().HTTP_TIMEOUT_MS ?? '10000', 10), }, ws: { - pingIntervalMs: parseInt(process.env.WS_PING_INTERVAL_MS ?? '30000', 10), - maxMissedPings: parseInt(process.env.WS_MAX_MISSED_PINGS ?? '2', 10), + pingIntervalMs: parseInt(getEnvironment().WS_PING_INTERVAL_MS ?? '30000', 10), + maxMissedPings: parseInt(getEnvironment().WS_MAX_MISSED_PINGS ?? '2', 10), }, outbound: { // One operator-maintained list for every outbound fetch the app makes to a @@ -595,18 +786,18 @@ 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 ?? ''), }, mcp: { // Tool results land directly in an agent's context window, so both the body // read from a node and the text handed back are capped (#416). - maxResultBytes: clampInt(process.env.MCP_MAX_RESULT_BYTES, 16_384, 1_024, 1_048_576), - maxUpstreamBytes: clampInt(process.env.MCP_MAX_UPSTREAM_BYTES, 262_144, 4_096, 8_388_608), + maxResultBytes: clampInt(getEnvironment().MCP_MAX_RESULT_BYTES, 16_384, 1_024, 1_048_576), + maxUpstreamBytes: clampInt(getEnvironment().MCP_MAX_UPSTREAM_BYTES, 262_144, 4_096, 8_388_608), // Page size for collection results, mirroring the /discovery limits: a // request without an explicit limit gets this many items, and an over-large // ask is clamped rather than honoured. - defaultPageLimit: clampInt(process.env.MCP_DEFAULT_PAGE_LIMIT, 50, 1, 200), - maxPageLimit: clampInt(process.env.MCP_MAX_PAGE_LIMIT, 200, 1, 1000), + defaultPageLimit: clampInt(getEnvironment().MCP_DEFAULT_PAGE_LIMIT, 50, 1, 200), + maxPageLimit: clampInt(getEnvironment().MCP_MAX_PAGE_LIMIT, 200, 1, 1000), }, } as const @@ -644,7 +835,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/cors.ts b/src/cors.ts index da9e74cf..4697585f 100644 --- a/src/cors.ts +++ b/src/cors.ts @@ -1,5 +1,6 @@ import type { FastifyRequest } from 'fastify' import type { FastifyCorsOptions } from '@fastify/cors' +import { getEnvironment } from './config' /** * Route classification categories for CORS and security boundary enforcement. @@ -57,7 +58,7 @@ export function getCredentialedAllowedOrigins(config?: CorsConfig): string[] { if (config?.credentialedAllowedOrigins !== undefined) { return config.credentialedAllowedOrigins } - return parseOrigins(process.env.CORS_ALLOWED_ORIGINS) + return parseOrigins(getEnvironment().CORS_ALLOWED_ORIGINS) } /** @@ -67,7 +68,7 @@ export function getAdminAllowedOrigins(config?: CorsConfig): string[] { if (config?.adminAllowedOrigins !== undefined) { return config.adminAllowedOrigins } - return parseOrigins(process.env.CORS_ADMIN_ALLOWED_ORIGINS) + return parseOrigins(getEnvironment().CORS_ADMIN_ALLOWED_ORIGINS) } /** @@ -77,7 +78,7 @@ export function getPublicBlockedOrigins(config?: CorsConfig): string[] { if (config?.publicBlockedOrigins !== undefined) { return config.publicBlockedOrigins } - return parseOrigins(process.env.CORS_PUBLIC_BLOCKED_ORIGINS) + return parseOrigins(getEnvironment().CORS_PUBLIC_BLOCKED_ORIGINS) } /** diff --git a/src/db.ts b/src/db.ts index c74ff322..d39b5a3f 100644 --- a/src/db.ts +++ b/src/db.ts @@ -9,7 +9,7 @@ import { db_pool_exhaustion_errors_total, setPoolMetricsUpdater, } from './metrics' -import { config, type NetworkName } from './config' +import { config, type NetworkName, getEnvironment } from './config' import { createLogger } from './logger' const log = createLogger('db') @@ -66,9 +66,9 @@ const configuredDbUrl = buildDatabaseUrl( const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient } export const prisma = globalForPrisma.prisma ?? new PrismaClient({ datasourceUrl: configuredDbUrl, - 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/health.ts b/src/health.ts index 55a778ee..68bbddb9 100644 --- a/src/health.ts +++ b/src/health.ts @@ -20,6 +20,7 @@ import { pgPool } from './db' import { redis } from './redis' import { createLogger } from './logger' +import { getEnvironment, getEnvironmentValue } from './config' const log = createLogger('health') @@ -47,7 +48,7 @@ export function isBootComplete(): boolean { // ─── Probe configuration ────────────────────────────────────────────────────── function envMs(name: string, fallback: number): number { - const raw = Number(process.env[name]) + const raw = Number(getEnvironmentValue(name)) return Number.isFinite(raw) && raw >= 0 ? Math.floor(raw) : fallback } @@ -88,7 +89,7 @@ function cacheTtlMs(): number { /** Whether Redis is part of readiness. See {@link checkReadiness}. */ function redisCheckEnabled(): boolean { - return process.env.READINESS_REDIS_CHECK !== 'false' + return getEnvironment().READINESS_REDIS_CHECK !== 'false' } // ─── Result shapes ──────────────────────────────────────────────────────────── diff --git a/src/index.ts b/src/index.ts index 7d4bb162..10f4a36d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,7 @@ import 'dotenv/config' import Fastify from 'fastify' - -if (!process.env.DIRECT_DATABASE_URL && process.env.DATABASE_URL) { - process.env.DIRECT_DATABASE_URL = process.env.DATABASE_URL -} import { installProcessErrorHandlers } from './processErrorHandlers' -import { config, getNetworkConfig, type NetworkName } from './config' +import { config, ensureDirectDatabaseUrl, getNetworkConfig, type NetworkName } from './config' import { getEnabledNetworks } from './network/enabledNetworks' import { createLogger } from './logger' import { redis } from './redis' @@ -41,6 +37,7 @@ const log = createLogger('startup') installProcessErrorHandlers(log) async function main() { + ensureDirectDatabaseUrl() // ── Ensure DB schema is up-to-date ──────────────────────────────────────── // #233: apply committed, reviewable migrations — never `db push`. `migrate // deploy` only runs migrations already in prisma/migrations and fails loudly diff --git a/src/ingest/cex/coinbase.ts b/src/ingest/cex/coinbase.ts index 276c0ef1..2c921ad7 100644 --- a/src/ingest/cex/coinbase.ts +++ b/src/ingest/cex/coinbase.ts @@ -28,6 +28,7 @@ import type { NetworkName } from '../../config' import type { WatchedPair } from '../../types' import type { VenuePricePoint } from '../venues/types' import type { CexAdapter, CexOrderBook, CexTicker } from './index' +import { getEnvironment } from '../../config' const log = createLogger('ingest:cex:coinbase') @@ -198,7 +199,7 @@ export class CoinbaseAdapter implements CexAdapter { constructor(options: CoinbaseAdapterOptions = {}) { this.baseUrl = - options.baseUrl ?? process.env.COINBASE_EXCHANGE_BASE_URL ?? COINBASE_DEFAULT_BASE_URL + options.baseUrl ?? getEnvironment().COINBASE_EXCHANGE_BASE_URL ?? COINBASE_DEFAULT_BASE_URL this.maxRetries = options.maxRetries ?? 3 this.initialBackoffMs = options.initialBackoffMs ?? 500 this.maxBackoffMs = options.maxBackoffMs ?? 60_000 diff --git a/src/ingest/cycleDeadline.ts b/src/ingest/cycleDeadline.ts index fd8b9b0f..4d29fc81 100644 --- a/src/ingest/cycleDeadline.ts +++ b/src/ingest/cycleDeadline.ts @@ -32,7 +32,7 @@ * no checkpoint sits between statements a unit commits together. */ -import type { NetworkName } from '../config' +import { getEnvironmentValue, getPrefixedEnvironmentValue, isDeclaredEnvironmentKey, type NetworkName } from '../config' import { createLogger } from '../logger' import { ingester_abandoned_cycles_total } from '../metrics' @@ -126,11 +126,13 @@ export function getCycleDeadlineMs(venue: string, network: NetworkName): number const venueKeyEnv = `INGEST_CYCLE_DEADLINE_MS_${venueKey}` const networkKey = `INGEST_CYCLE_DEADLINE_MS_${suffix}` + // Venue keys are read through the config module's prefix reader; the + // network and global keys are declared there and read by name. const resolved = - readDeadlineMs(process.env[venueNetworkKey], venueNetworkKey) ?? - readDeadlineMs(process.env[venueKeyEnv], venueKeyEnv) ?? - readDeadlineMs(process.env[networkKey], networkKey) ?? - readDeadlineMs(process.env.INGEST_CYCLE_DEADLINE_MS, 'INGEST_CYCLE_DEADLINE_MS') ?? + readDeadlineMs(getPrefixedEnvironmentValue(venueNetworkKey), venueNetworkKey) ?? + readDeadlineMs(getPrefixedEnvironmentValue(venueKeyEnv), venueKeyEnv) ?? + readDeadlineMs(isDeclaredEnvironmentKey(networkKey) ? getEnvironmentValue(networkKey) : undefined, networkKey) ?? + readDeadlineMs(getEnvironmentValue('INGEST_CYCLE_DEADLINE_MS'), 'INGEST_CYCLE_DEADLINE_MS') ?? DEFAULT_CYCLE_DEADLINE_MS return resolved 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/lendingRateRetention.ts b/src/jobs/lendingRateRetention.ts index 2e2b25fb..d2a9e31a 100644 --- a/src/jobs/lendingRateRetention.ts +++ b/src/jobs/lendingRateRetention.ts @@ -1,6 +1,6 @@ import { Queue, Worker } from 'bullmq' import { pgPool } from '../db' -import { activeNetwork, type NetworkName } from '../config' +import { activeNetwork, type NetworkName, getEnvironment } from '../config' import { getEnabledNetworks } from '../network/enabledNetworks' import { createLogger } from '../logger' @@ -11,7 +11,7 @@ const QUEUE_NAME = `${activeNetwork}:rate-retention` /** Lending rates older than this many days are pruned by the retention job. */ export const RATE_RETENTION_DAYS = 30 /** Maximum rows removed by one database statement during a retention run. */ -export const RATE_PRUNE_BATCH_SIZE = Number(process.env.RATE_PRUNE_BATCH_SIZE ?? 1_000) +export const RATE_PRUNE_BATCH_SIZE = Number(getEnvironment().RATE_PRUNE_BATCH_SIZE ?? 1_000) export interface RatePruneProgress { network: NetworkName @@ -21,7 +21,7 @@ export interface RatePruneProgress { } 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/pricePointsPartitionJob.ts b/src/jobs/pricePointsPartitionJob.ts index acf0eeb7..89496d6f 100644 --- a/src/jobs/pricePointsPartitionJob.ts +++ b/src/jobs/pricePointsPartitionJob.ts @@ -1,6 +1,6 @@ import { Queue, Worker } from 'bullmq' import { pgPool } from '../db' -import { activeNetwork } from '../config' +import { activeNetwork, getEnvironment } from '../config' import { ensurePricePointsPartitions, dropExpiredPricePointsPartitions, @@ -15,18 +15,20 @@ const log = createLogger('job:price-points-partition') const QUEUE_NAME = `${activeNetwork}:price-points-partition` -export const PRICE_POINTS_RETENTION_DAYS = process.env.PRICE_POINTS_RETENTION_DAYS - ? parseInt(process.env.PRICE_POINTS_RETENTION_DAYS, 10) +const retentionDaysRaw = getEnvironment().PRICE_POINTS_RETENTION_DAYS +export const PRICE_POINTS_RETENTION_DAYS = retentionDaysRaw + ? parseInt(retentionDaysRaw, 10) : DEFAULT_RETENTION_DAYS -export const PRICE_POINTS_PRECREATE_DAYS = process.env.PRICE_POINTS_PRECREATE_DAYS - ? parseInt(process.env.PRICE_POINTS_PRECREATE_DAYS, 10) +const precreateDaysRaw = getEnvironment().PRICE_POINTS_PRECREATE_DAYS +export const PRICE_POINTS_PRECREATE_DAYS = precreateDaysRaw + ? parseInt(precreateDaysRaw, 10) : DEFAULT_PRECREATE_DAYS -export const PRICE_POINTS_DROP_EXPIRED = process.env.PRICE_POINTS_DROP_EXPIRED === 'true' +export const PRICE_POINTS_DROP_EXPIRED = getEnvironment().PRICE_POINTS_DROP_EXPIRED === 'true' 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/pricePointsRetention.ts b/src/jobs/pricePointsRetention.ts index 3335ebfd..9f6712e1 100644 --- a/src/jobs/pricePointsRetention.ts +++ b/src/jobs/pricePointsRetention.ts @@ -1,6 +1,6 @@ import { Queue, Worker } from 'bullmq' import { pgPool } from '../db' -import { activeNetwork, type NetworkName } from '../config' +import { activeNetwork, type NetworkName, getEnvironment } from '../config' import { getEnabledNetworks } from '../network/enabledNetworks' import { price_points_pruned_total, @@ -29,10 +29,10 @@ const QUEUE_NAME = `${activeNetwork}:price-points-retention` * deletion, preserving historical OHLCV, VWAP, venue volume breakdown, and * trade count permanently while reclaiming >99% of storage. */ -export const PRICE_POINTS_RETENTION_DAYS = Number(process.env.PRICE_POINTS_RETENTION_DAYS ?? 30) +export const PRICE_POINTS_RETENTION_DAYS = Number(getEnvironment().PRICE_POINTS_RETENTION_DAYS ?? 30) /** Maximum rows removed by one database statement during a retention run. */ -export const PRICE_POINTS_PRUNE_BATCH_SIZE = Number(process.env.PRICE_POINTS_PRUNE_BATCH_SIZE ?? 1_000) +export const PRICE_POINTS_PRUNE_BATCH_SIZE = Number(getEnvironment().PRICE_POINTS_PRUNE_BATCH_SIZE ?? 1_000) /** Default pause in milliseconds between deletion batches to yield database locks. */ export const DEFAULT_PRUNE_PAUSE_MS = 50 @@ -52,7 +52,7 @@ export interface PricePointsPruneResult { } 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/mcp/config.ts b/src/mcp/config.ts index c5a1895e..33396ef2 100644 --- a/src/mcp/config.ts +++ b/src/mcp/config.ts @@ -1,3 +1,5 @@ +import { getEnvironment } from '../config' + export const DEFAULT_MCP_HTTP_HOST = '127.0.0.1'; export const DEFAULT_MCP_HTTP_PORT = 3003; export const DEFAULT_MCP_HTTP_PATH = '/mcp'; @@ -68,7 +70,7 @@ export function parseMetricLabelAllowList(raw: string | undefined, fallback: rea .filter(Boolean); } -export function resolveMcpTransportConfig(env: NodeJS.ProcessEnv = process.env): McpTransportConfig { +export function resolveMcpTransportConfig(env: Record = getEnvironment()): McpTransportConfig { const transport = (env.MCP_TRANSPORT ?? 'stdio').trim().toLowerCase(); if (!transport || transport === 'stdio') { diff --git a/src/mcp/resources.ts b/src/mcp/resources.ts index 4e83dc2d..37701c01 100644 --- a/src/mcp/resources.ts +++ b/src/mcp/resources.ts @@ -2,6 +2,7 @@ import { createHash } from 'node:crypto'; import { readFileSync, statSync } from 'node:fs'; import { join } from 'node:path'; import { config, activeNetwork } from '../config.js'; +import { getEnvironment } from '../config' /** * A resource an agent can attach to its context once, rather than re-fetching by @@ -81,7 +82,8 @@ function computeVersion(text: string): string { * `rootDir` is `src` and `outDir` is `dist`. */ function openapiPath(): string { - if (process.env.MCP_OPENAPI_PATH) return process.env.MCP_OPENAPI_PATH; + const configured = getEnvironment().MCP_OPENAPI_PATH + if (configured) return configured; return join(__dirname, '..', '..', 'openapi.json'); } diff --git a/src/middleware/cachePolicy.ts b/src/middleware/cachePolicy.ts index 7931ad2c..17397fd9 100644 --- a/src/middleware/cachePolicy.ts +++ b/src/middleware/cachePolicy.ts @@ -3,6 +3,7 @@ import fp from 'fastify-plugin' // Single source of truth for candle intervals — shared with the candles // route module, so the two can never drift apart (issue #266 review). import { INTERVAL_SECONDS as CANDLE_INTERVAL_SECONDS } from '../candleIntervals' +import { getEnvironment } from '../config' /** * Whether x402 payment gating is active. Mirrors isX402Configured() in @@ -11,9 +12,9 @@ import { INTERVAL_SECONDS as CANDLE_INTERVAL_SECONDS } from '../candleIntervals' */ function isX402Active(): boolean { return Boolean( - process.env.ORACLE_PAYMENT_ADDRESS_TESTNET || - process.env.ORACLE_PAYMENT_ADDRESS_MAINNET || - process.env.ORACLE_PAYMENT_ADDRESS, + getEnvironment().ORACLE_PAYMENT_ADDRESS_TESTNET || + getEnvironment().ORACLE_PAYMENT_ADDRESS_MAINNET || + getEnvironment().ORACLE_PAYMENT_ADDRESS, ) } 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/openapi/liveValidator.ts b/src/openapi/liveValidator.ts index 34a12055..8e4ff075 100644 --- a/src/openapi/liveValidator.ts +++ b/src/openapi/liveValidator.ts @@ -4,6 +4,7 @@ import { resolve } from 'node:path' // @ts-expect-error js-yaml types import YAML from 'js-yaml' import Ajv, { type ErrorObject } from 'ajv' +import { getEnvironment } from '../config' const log = createLogger('liveValidator') @@ -359,16 +360,17 @@ export async function postOrUpdateGitHubReport( // Precedence: options.token > GITHUB_TOKEN > GH_TOKEN. // In automated GitHub Actions CI runners, GITHUB_TOKEN is injected natively into the runner environment. // GH_TOKEN is supported as a fallback when running under CLI contexts (e.g. GitHub CLI `gh`). - const token = options.token ?? process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN - const repo = options.repo ?? process.env.GITHUB_REPOSITORY ?? 'Divinity-Labs/Lens' + const token = options.token ?? getEnvironment().GITHUB_TOKEN ?? getEnvironment().GH_TOKEN + const repo = options.repo ?? getEnvironment().GITHUB_REPOSITORY ?? 'Divinity-Labs/Lens' const title = options.issueTitle ?? 'Live Service Spec Drift Report' const label = options.label ?? 'live-spec-report' // If in GitHub Actions, always write to GITHUB_STEP_SUMMARY - if (process.env.GITHUB_STEP_SUMMARY) { + const stepSummaryPath = getEnvironment().GITHUB_STEP_SUMMARY + if (stepSummaryPath) { try { const fs = await import('node:fs') - fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, reportMarkdown + '\n') + fs.appendFileSync(stepSummaryPath, reportMarkdown + '\n') } catch { // Ignore step summary write error } diff --git a/src/routes/pairs.ts b/src/routes/pairs.ts index 38b26d5d..416dea76 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/ws/backpressure.ts b/src/ws/backpressure.ts index 3a098a4f..11214b3a 100644 --- a/src/ws/backpressure.ts +++ b/src/ws/backpressure.ts @@ -1,3 +1,5 @@ +import { getEnvironment } from '../config' + /** * Websocket backpressure policy. * @@ -62,7 +64,7 @@ export const DEFAULT_BACKPRESSURE_LIMITS: BackpressureLimits = { maxCongestedMs: 30_000, } -function intFromEnv(env: NodeJS.ProcessEnv, name: string, fallback: number, allowZero: boolean): number { +function intFromEnv(env: Record, name: string, fallback: number, allowZero: boolean): number { const raw = Number(env[name]) if (!Number.isFinite(raw)) return fallback if (raw === 0) return allowZero ? 0 : fallback @@ -79,7 +81,7 @@ function intFromEnv(env: NodeJS.ProcessEnv, name: string, fallback: number, allo * and the coalescing tier — the one that keeps a merely-slow client connected — * never runs. So the soft limit is clamped to half the hard one. */ -export function resolveBackpressureLimits(env: NodeJS.ProcessEnv = process.env): BackpressureLimits { +export function resolveBackpressureLimits(env: Record = getEnvironment()): BackpressureLimits { const coalesceIntervalMs = intFromEnv( env, 'WS_COALESCE_INTERVAL_MS', diff --git a/src/ws/fanout.ts b/src/ws/fanout.ts index c6cacd6f..5f312c71 100644 --- a/src/ws/fanout.ts +++ b/src/ws/fanout.ts @@ -1,5 +1,5 @@ import { redis } from '../redis' -import { activeNetwork, config } from '../config' +import { activeNetwork, config, getEnvironmentValue } from '../config' import { priceEmitter, PRICE_UPDATE, PriceUpdateEvent } from '../events' import { Gauge, Counter } from 'prom-client' import { register } from '../metrics' @@ -52,7 +52,7 @@ export const ws_buffered_bytes = new Gauge({ const REDIS_CHANNEL = `lens:${activeNetwork}:price:updates` function envInt(name: string, fallback: number): number { - const raw = Number(process.env[name]) + const raw = Number(getEnvironmentValue(name)) return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : fallback } 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. */ diff --git a/tests/decimalStrings.test.ts b/tests/decimalStrings.test.ts index c264fd18..6561c4a2 100644 --- a/tests/decimalStrings.test.ts +++ b/tests/decimalStrings.test.ts @@ -36,7 +36,8 @@ const { decimalTestPairs } = vi.hoisted(() => ({ ], })) -vi.mock('../src/config', () => ({ +vi.mock('../src/config', async () => ({ + ...(await vi.importActual('../src/config')), config: { pairs: decimalTestPairs, cache: { priceTtl: 10 }, diff --git a/tests/staleness.test.ts b/tests/staleness.test.ts index 2b54f0b2..92b6c5a5 100644 --- a/tests/staleness.test.ts +++ b/tests/staleness.test.ts @@ -30,7 +30,8 @@ const { stalenessTestPairs } = vi.hoisted(() => ({ ], })) -vi.mock('../src/config', () => ({ +vi.mock('../src/config', async () => ({ + ...(await vi.importActual('../src/config')), config: { pairs: stalenessTestPairs, cache: { priceTtl: 10 },