diff --git a/ENVIRONMENT_VARIABLES_AND_SECRETS.md b/ENVIRONMENT_VARIABLES_AND_SECRETS.md index 1da1650..4f52741 100644 --- a/ENVIRONMENT_VARIABLES_AND_SECRETS.md +++ b/ENVIRONMENT_VARIABLES_AND_SECRETS.md @@ -33,6 +33,8 @@ The Rust smart contracts in `contract/` contain no runtime environment variables The listener is the only service with server-side secrets. All variables below are read from `process.env` in `listener/src/config.ts` (and supporting files) at startup. +Variables marked **Required** are checked by `validateRequiredEnvVars()` in `listener/src/config.ts` before the rest of the config is loaded. If any are missing, `loadConfig()` throws a `ConfigError` listing every missing variable and the process exits instead of starting with an invalid configuration. + ### 2.1 Logging / runtime | Variable | Default | Required | Description | @@ -52,7 +54,7 @@ The listener is the only service with server-side secrets. All variables below a | Variable | Default | Required | Description | |---|---|---|---| -| `CONTRACT_ADDRESSES` | `[]` | **Yes** (for any useful work) | JSON array of contract objects to monitor. See shape below. | +| `CONTRACT_ADDRESSES` | *(none — enforced at startup)* | **Yes** | JSON array of contract objects to monitor. Missing this variable prevents the listener from starting; see note above. | **Shape:** ```json diff --git a/listener/package.json b/listener/package.json index ab17054..498caf2 100644 --- a/listener/package.json +++ b/listener/package.json @@ -11,8 +11,6 @@ "test": "node ./node_modules/jest/bin/jest.js", "migrate": "ts-node src/scripts/migrate-db.ts", "migrate:templates": "ts-node src/scripts/migrate-templates.ts", - "typecheck": "node ./node_modules/typescript/bin/tsc --noEmit", - "lint": "node ./node_modules/typescript/bin/tsc --noEmit", "check-migrations": "ts-node src/scripts/check-migrations.ts", "validate:batch": "ts-node src/utils/batch-validator.ts" }, diff --git a/listener/src/__tests__/integration.test.ts b/listener/src/__tests__/integration.test.ts index b3a6d1b..6aacadc 100644 --- a/listener/src/__tests__/integration.test.ts +++ b/listener/src/__tests__/integration.test.ts @@ -51,4 +51,29 @@ describe("Full Notification Lifecycle Integration Test", () => { // 5. Simulate acknowledgement (event is processed) expect(retrievedEvents[0].receivedAt).toBeLessThanOrEqual(Date.now()); }); + + test("evicts oldest events instead of failing when registry is at capacity", () => { + const boundedRegistry = new EventRegistry(2); + const topic = [xdr.ScVal.scvSymbol("task"), xdr.ScVal.scvSymbol("created")]; + const value = xdr.ScVal.scvU32(1); + + for (let i = 0; i < 3; i++) { + boundedRegistry.addFromInput({ + eventId: `event-${i}`, + contractAddress: "CDNJ3YJ5F4U5YF4O5U6Y7I8U9Y0U1I2O3P4I5U6Y7I8", + eventName: "task", + ledger: 100 + i, + type: "contract", + topic, + value, + txHash: `hash-${i}`, + }); + } + + // The registry should degrade gracefully (evict oldest) rather than + // throwing or silently dropping the newest notification. + const remaining = boundedRegistry.getEvents(); + expect(remaining.length).toBe(2); + expect(remaining.map((e) => e.eventId)).toEqual(["event-1", "event-2"]); + }); }); diff --git a/listener/src/config.test.ts b/listener/src/config.test.ts index aaf3670..ccbce80 100644 --- a/listener/src/config.test.ts +++ b/listener/src/config.test.ts @@ -5,6 +5,9 @@ describe('Config validation', () => { beforeEach(() => { process.env = { ...originalEnv }; + // CONTRACT_ADDRESSES is a required variable; give it a valid default so + // tests unrelated to required-variable validation aren't affected. + process.env.CONTRACT_ADDRESSES = JSON.stringify([{ address: 'CTEST', events: ['*'] }]); }); afterEach(() => { @@ -36,6 +39,15 @@ describe('Config validation', () => { expect(() => loadConfig()).toThrow('CONTRACT_ADDRESSES must be valid JSON. Received: not-json'); }); + it('throws a descriptive error when a required environment variable is missing', () => { + delete process.env.CONTRACT_ADDRESSES; + + expect(() => loadConfig()).toThrow(ConfigError); + expect(() => loadConfig()).toThrow( + 'Missing required environment variable(s): CONTRACT_ADDRESSES.' + ); + }); + it('throws a descriptive error for invalid integer variables', () => { process.env.EVENTS_API_PORT = 'eighty'; @@ -44,9 +56,9 @@ describe('Config validation', () => { }); it('loads default values when optional environment variables are omitted', () => { + process.env.CONTRACT_ADDRESSES = JSON.stringify([{ address: 'CTEST', events: ['*'] }]); 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; @@ -64,7 +76,7 @@ describe('Config validation', () => { expect(config).toMatchObject({ stellarNetwork: 'testnet', stellarRpcUrl: 'https://soroban-testnet.stellar.org:443', - contractAddresses: [], + contractAddresses: [{ address: 'CTEST', events: ['*'] }], pollIntervalMs: 30000, maxReconnectAttempts: 5, reconnectDelayMs: 5000, diff --git a/listener/src/config.ts b/listener/src/config.ts index fe5c962..cea28cc 100644 --- a/listener/src/config.ts +++ b/listener/src/config.ts @@ -1,5 +1,4 @@ -import { Config, ContractConfig, DiscordConfig, WebhookSecret, AppCleanupConfig, EventQueueConfig, RetrySchedulerOptions } from './types'; -import { Config, ContractConfig, DiscordConfig, WebhookSecret, AppCleanupConfig, EventQueueConfig, RetrySchedulerOptions, AnalyticsConfig } from './types'; +import { Config, ContractConfig, DiscordConfig, WebhookSecret, ApiKey, AppCleanupConfig, EventQueueConfig, RetrySchedulerOptions, AnalyticsConfig } from './types'; export class ConfigError extends Error { constructor(message: string) { @@ -13,6 +12,22 @@ function trimEnv(name: string): string | undefined { return value === undefined ? undefined : value.trim(); } +// Environment variables the listener cannot safely start without. +// Keep this list in sync with the "Required" column in +// ENVIRONMENT_VARIABLES_AND_SECRETS.md. +const REQUIRED_ENV_VARS = ['CONTRACT_ADDRESSES']; + +function validateRequiredEnvVars(): void { + const missing = REQUIRED_ENV_VARS.filter((name) => !trimEnv(name)); + + if (missing.length > 0) { + throw new ConfigError( + `Missing required environment variable(s): ${missing.join(', ')}. ` + + 'Copy .env.example to .env and set them before starting the listener.' + ); + } +} + function parseIntegerEnv(name: string, defaultValue: string): number { const rawValue = trimEnv(name); const value = rawValue !== undefined ? rawValue : defaultValue; @@ -175,6 +190,8 @@ function loadRetrySchedulerConfig(): RetrySchedulerOptions { } export function loadConfig(): Config { + validateRequiredEnvVars(); + const discord = loadDiscordConfig(); const rawContractAddresses = parseJsonEnv('CONTRACT_ADDRESSES', '[]'); const rawWebhookSecrets = parseJsonEnv('WEBHOOK_SECRETS', '[]'); @@ -182,6 +199,7 @@ export function loadConfig(): Config { 'RATE_LIMIT_CLIENT_OVERRIDES', '{}' ); + const rawApiKeys = parseJsonEnv('API_KEYS', '[]'); return { stellarNetwork: trimEnv('STELLAR_NETWORK') || 'testnet', diff --git a/listener/src/services/notification-retry-queue.ts b/listener/src/services/notification-retry-queue.ts index a9fa5a9..3633fcd 100644 --- a/listener/src/services/notification-retry-queue.ts +++ b/listener/src/services/notification-retry-queue.ts @@ -68,15 +68,6 @@ export class NotificationRetryQueue { processingTimes: [] as number[], }; - // Metrics - private metrics = { - totalEnqueued: 0, - totalProcessed: 0, - totalSucceeded: 0, - totalFailed: 0, - processingTimes: [] as number[], - }; - constructor(notificationFn: NotificationFn, options?: RetryQueueOptions) { this.notificationFn = notificationFn; this.baseDelayMs = options?.baseDelayMs ?? DEFAULTS.baseDelayMs; @@ -120,9 +111,8 @@ export class NotificationRetryQueue { }); this.queuedFingerprints.add(fingerprint); - this.queue.push({ event, contractConfig, retryCount: 0, nextRetryAt, requestId }); - this.metrics.totalEnqueued++; this.queue.push({ event, contractConfig, retryCount: 0, nextRetryAt, requestId, priority, enqueuedAt: Date.now() }); + this.metrics.totalEnqueued++; } start(): void { diff --git a/listener/src/store/event-registry.ts b/listener/src/store/event-registry.ts index 6d37485..fb3985f 100644 --- a/listener/src/store/event-registry.ts +++ b/listener/src/store/event-registry.ts @@ -29,10 +29,6 @@ export class EventRegistry { this.cleanupTimer = setInterval(() => this.pruneExpired(), intervalMs); } - setTtlMs(ms: number): void { - this.ttlMs = ms; - } - stopCleanup(): void { if (this.cleanupTimer) { clearInterval(this.cleanupTimer); @@ -119,3 +115,5 @@ export class EventRegistry { } export const eventRegistry = new EventRegistry(); + +