diff --git a/listener/package-lock.json b/listener/package-lock.json index 482cdf4..7d69c5d 100644 --- a/listener/package-lock.json +++ b/listener/package-lock.json @@ -53,7 +53,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -1228,7 +1227,6 @@ "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } @@ -1638,7 +1636,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -2952,7 +2949,6 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -4666,7 +4662,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -4748,7 +4743,6 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/listener/src/config.test.ts b/listener/src/config.test.ts new file mode 100644 index 0000000..7b7ec70 --- /dev/null +++ b/listener/src/config.test.ts @@ -0,0 +1,77 @@ +import { ConfigError, loadConfig } from './config'; + +describe('Config validation', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('throws a descriptive error when DISCORD_WEBHOOK_ID is set without DISCORD_WEBHOOK_URL', () => { + process.env.DISCORD_WEBHOOK_ID = '123'; + + expect(() => loadConfig()).toThrow(ConfigError); + expect(() => loadConfig()).toThrow( + 'DISCORD_WEBHOOK_URL is required when DISCORD_WEBHOOK_ID is provided.' + ); + }); + + it('throws a descriptive error when DISCORD_WEBHOOK_URL is set without DISCORD_WEBHOOK_ID', () => { + process.env.DISCORD_WEBHOOK_URL = 'https://discord.com/api/webhooks/123/abc'; + + expect(() => loadConfig()).toThrow(ConfigError); + expect(() => loadConfig()).toThrow( + 'DISCORD_WEBHOOK_ID is required when DISCORD_WEBHOOK_URL is provided.' + ); + }); + + it('throws a descriptive error for invalid CONTRACT_ADDRESSES JSON', () => { + process.env.CONTRACT_ADDRESSES = 'not-json'; + + expect(() => loadConfig()).toThrow(ConfigError); + expect(() => loadConfig()).toThrow('CONTRACT_ADDRESSES must be valid JSON. Received: not-json'); + }); + + it('throws a descriptive error for invalid integer variables', () => { + process.env.EVENTS_API_PORT = 'eighty'; + + expect(() => loadConfig()).toThrow(ConfigError); + expect(() => loadConfig()).toThrow('EVENTS_API_PORT must be a valid integer, got "eighty"'); + }); + + it('loads default values when optional environment variables are omitted', () => { + delete process.env.STELLAR_NETWORK; + delete process.env.STELLAR_RPC_URL; + delete process.env.CONTRACT_ADDRESSES; + delete process.env.POLL_INTERVAL_MS; + delete process.env.MAX_RECONNECT_ATTEMPTS; + delete process.env.RECONNECT_DELAY_MS; + delete process.env.EVENTS_API_PORT; + delete process.env.EVENTS_API_CORS_ORIGIN; + delete process.env.RETRY_BASE_DELAY_MS; + delete process.env.RETRY_MAX_RETRIES; + delete process.env.DISCORD_WEBHOOK_URL; + delete process.env.DISCORD_WEBHOOK_ID; + + const config = loadConfig(); + + expect(config).toMatchObject({ + stellarNetwork: 'testnet', + stellarRpcUrl: 'https://soroban-testnet.stellar.org:443', + contractAddresses: [], + pollIntervalMs: 30000, + maxReconnectAttempts: 5, + reconnectDelayMs: 5000, + eventsApiPort: 8787, + eventsApiCorsOrigin: 'http://localhost:5173', + retryQueue: { + baseDelayMs: 5000, + maxRetries: 5, + }, + }); + }); +}); diff --git a/listener/src/config.ts b/listener/src/config.ts new file mode 100644 index 0000000..455168c --- /dev/null +++ b/listener/src/config.ts @@ -0,0 +1,103 @@ +import { Config, ContractConfig, DiscordConfig } from './types'; + +export class ConfigError extends Error { + constructor(message: string) { + super(message); + this.name = 'ConfigError'; + } +} + +function trimEnv(name: string): string | undefined { + const value = process.env[name]; + return value === undefined ? undefined : value.trim(); +} + +function parseIntegerEnv(name: string, defaultValue: string): number { + const rawValue = trimEnv(name); + const value = rawValue !== undefined ? rawValue : defaultValue; + const parsed = Number.parseInt(value, 10); + if (Number.isNaN(parsed)) { + throw new ConfigError(`${name} must be a valid integer, got "${value}"`); + } + return parsed; +} + +function parseJsonEnv(name: string, defaultValue: string): T { + const rawValue = trimEnv(name) ?? defaultValue; + try { + return JSON.parse(rawValue) as T; + } catch { + throw new ConfigError(`${name} must be valid JSON. Received: ${rawValue}`); + } +} + +function validateContractAddresses(value: unknown): ContractConfig[] { + if (!Array.isArray(value)) { + throw new ConfigError('CONTRACT_ADDRESSES must be a JSON array of contract objects.'); + } + + return value.map((item, index) => { + if (typeof item !== 'object' || item === null) { + throw new ConfigError(`CONTRACT_ADDRESSES[${index}] must be an object with address and events.`); + } + + const address = (item as any).address; + const events = (item as any).events; + + if (typeof address !== 'string' || !address.trim()) { + throw new ConfigError(`CONTRACT_ADDRESSES[${index}].address must be a non-empty string.`); + } + + if (!Array.isArray(events) || events.some((event) => typeof event !== 'string')) { + throw new ConfigError( + `CONTRACT_ADDRESSES[${index}].events must be an array of string event names.` + ); + } + + return { + address: address.trim(), + events: events.map((event) => event.trim()), + }; + }); +} + +function loadDiscordConfig(): DiscordConfig | undefined { + const webhookUrl = trimEnv('DISCORD_WEBHOOK_URL'); + const webhookId = trimEnv('DISCORD_WEBHOOK_ID'); + + if (!webhookUrl && !webhookId) { + return undefined; + } + + if (!webhookUrl) { + throw new ConfigError('DISCORD_WEBHOOK_URL is required when DISCORD_WEBHOOK_ID is provided.'); + } + + if (!webhookId) { + throw new ConfigError('DISCORD_WEBHOOK_ID is required when DISCORD_WEBHOOK_URL is provided.'); + } + + return { webhookUrl, webhookId }; +} + +export function loadConfig(): Config { + const discord = loadDiscordConfig(); + const rawContractAddresses = parseJsonEnv('CONTRACT_ADDRESSES', '[]'); + + return { + stellarNetwork: trimEnv('STELLAR_NETWORK') || 'testnet', + stellarRpcUrl: + trimEnv('STELLAR_RPC_URL') || 'https://soroban-testnet.stellar.org:443', + contractAddresses: validateContractAddresses(rawContractAddresses), + pollIntervalMs: parseIntegerEnv('POLL_INTERVAL_MS', '30000'), + maxReconnectAttempts: parseIntegerEnv('MAX_RECONNECT_ATTEMPTS', '5'), + reconnectDelayMs: parseIntegerEnv('RECONNECT_DELAY_MS', '5000'), + eventsApiPort: parseIntegerEnv('EVENTS_API_PORT', '8787'), + eventsApiCorsOrigin: trimEnv('EVENTS_API_CORS_ORIGIN') || 'http://localhost:5173', + discord, + retryQueue: { + baseDelayMs: parseIntegerEnv('RETRY_BASE_DELAY_MS', '5000'), + maxRetries: parseIntegerEnv('RETRY_MAX_RETRIES', '5'), + }, + }; +} diff --git a/listener/src/index.ts b/listener/src/index.ts index 852256e..3f8e593 100644 --- a/listener/src/index.ts +++ b/listener/src/index.ts @@ -1,39 +1,11 @@ import dotenv from 'dotenv'; -import { Config, DiscordConfig } from './types'; import { startEventsServer } from './api/events-server'; import { EventSubscriber } from './services/event-subscriber'; import logger from './utils/logger'; +import { loadConfig, ConfigError } from './config'; dotenv.config(); -function loadDiscordConfig(): DiscordConfig | undefined { - const webhookUrl = process.env.DISCORD_WEBHOOK_URL; - const webhookId = process.env.DISCORD_WEBHOOK_ID; - if (!webhookUrl || !webhookId) { - return undefined; - } - return { webhookUrl, webhookId }; -} - -function loadConfig(): Config { - const discord = loadDiscordConfig(); - return { - stellarNetwork: process.env.STELLAR_NETWORK || 'testnet', - stellarRpcUrl: process.env.STELLAR_RPC_URL || 'https://soroban-testnet.stellar.org:443', - contractAddresses: JSON.parse(process.env.CONTRACT_ADDRESSES || '[]'), - pollIntervalMs: parseInt(process.env.POLL_INTERVAL_MS || '30000'), - maxReconnectAttempts: parseInt(process.env.MAX_RECONNECT_ATTEMPTS || '5'), - reconnectDelayMs: parseInt(process.env.RECONNECT_DELAY_MS || '5000'), - eventsApiPort: parseInt(process.env.EVENTS_API_PORT || '8787'), - eventsApiCorsOrigin: process.env.EVENTS_API_CORS_ORIGIN || 'http://localhost:5173', - discord, - retryQueue: { - baseDelayMs: parseInt(process.env.RETRY_BASE_DELAY_MS || '5000'), - maxRetries: parseInt(process.env.RETRY_MAX_RETRIES || '5'), - }, - }; -} - async function main() { const config = loadConfig(); const eventsServer = startEventsServer({ @@ -63,6 +35,10 @@ async function main() { } main().catch((err) => { - logger.error('Error starting service', { error: err }); + if (err instanceof ConfigError) { + logger.error('Configuration error', { error: err.message }); + } else { + logger.error('Error starting service', { error: err }); + } process.exit(1); });