From 382f233e4f2639850267d128845d54111abcbbc0 Mon Sep 17 00:00:00 2001 From: amanosi-cmyk <284326153+amanosiadnan-cmyk@users.noreply.github.com> Date: Sun, 30 Aug 2026 12:03:28 +0100 Subject: [PATCH] feat: add CORS validation, environment configuration matrix, config schema validation, and notification compatibility tests Co-authored-by: Cursor --- ENVIRONMENT_VARIABLES_AND_SECRETS.md | 6 +- docs/ENVIRONMENT_MATRIX.md | 83 +++++++ docs/LISTENER-CONFIGURATION.md | 17 +- listener/src/config-schema.test.ts | 99 ++++++++ listener/src/config-schema.ts | 235 ++++++++++++++++++ listener/src/config.test.ts | 22 ++ listener/src/config.ts | 27 ++ .../notification-schema-compatibility.test.ts | 179 +++++++++++++ listener/src/utils/cors-validator.test.ts | 103 ++++++++ listener/src/utils/cors-validator.ts | 96 +++++++ 10 files changed, 860 insertions(+), 7 deletions(-) create mode 100644 docs/ENVIRONMENT_MATRIX.md create mode 100644 listener/src/config-schema.test.ts create mode 100644 listener/src/config-schema.ts create mode 100644 listener/src/tests/notification-schema-compatibility.test.ts create mode 100644 listener/src/utils/cors-validator.test.ts create mode 100644 listener/src/utils/cors-validator.ts diff --git a/ENVIRONMENT_VARIABLES_AND_SECRETS.md b/ENVIRONMENT_VARIABLES_AND_SECRETS.md index 4f527416..58bc16c5 100644 --- a/ENVIRONMENT_VARIABLES_AND_SECRETS.md +++ b/ENVIRONMENT_VARIABLES_AND_SECRETS.md @@ -71,8 +71,10 @@ Use `"*"` as the sole event entry to subscribe to all events from a contract. | Variable | Default | Required | Description | |---|---|---|---| -| `EVENTS_API_PORT` | `8787` | No | Port the listener HTTP server binds to. | -| `EVENTS_API_CORS_ORIGIN` | `http://localhost:5173` | No | Allowed CORS origin. Set to your dashboard URL in production. Avoid `*`. | +| `EVENTS_API_PORT` | `8787` | No | Port the listener HTTP server binds to (1–65535). | +| `EVENTS_API_CORS_ORIGIN` | `http://localhost:5173` | No | Allowed CORS origin(s). Explicit URI or comma-separated list in Production/Staging (e.g. `https://dashboard.notifychain.io`). Wildcard `*` is only allowed in local Development/Test environments. | + +For a complete reference table across all environments, see [docs/ENVIRONMENT_MATRIX.md](docs/ENVIRONMENT_MATRIX.md). ### 2.5 Database diff --git a/docs/ENVIRONMENT_MATRIX.md b/docs/ENVIRONMENT_MATRIX.md new file mode 100644 index 00000000..99826003 --- /dev/null +++ b/docs/ENVIRONMENT_MATRIX.md @@ -0,0 +1,83 @@ +# Environment Configuration Matrix + +> Single reference matrix documenting all supported environment variables across Notify-Chain, their purpose, required/optional status, default values, sensitivity levels, and applicable environments. + +--- + +## 1. Overview & Sensitivity Classification + +- **Public / Non-sensitive (`Public`)**: Safe to commit in example files or pass in plain environment configs. +- **Internal / Configurable (`Internal`)**: Specific to deployment infrastructure; non-secret but environment-dependent. +- **Sensitive / Secret (`Secret`)**: Sensitive credentials, API keys, private webhooks, and cryptographic keys. **Must never be committed or logged**. + +--- + +## 2. Master Configuration Matrix + +| Environment Variable | Type | Status | Default Value | Applicable Environments | Sensitive? | Description / Purpose | +|----------------------|------|--------|---------------|-------------------------|------------|-----------------------| +| `CONTRACT_ADDRESSES` | JSON Array | **Required** | *(None)* | All (Dev, Staging, Prod) | No | JSON array of Soroban contract addresses and event names to monitor. Must contain at least 1 entry. | +| `STELLAR_NETWORK` | string | Optional | `testnet` | All | No | Target Stellar network name (`testnet`, `public`, `futurenet`, `standalone`). | +| `STELLAR_RPC_URL` | string (URL) | Optional | `https://soroban-testnet.stellar.org:443` | All | No | Endpoint URL for the Soroban RPC provider. | +| `STELLAR_NETWORK_PASSPHRASE` | string | Optional | `Test SDF Network ; September 2015` | All | No | Stellar network passphrase corresponding to the target chain network. | +| `EVENTS_API_PORT` | integer | Optional | `8787` | All | No | HTTP port for listener API endpoints (`/health`, `/api/events`, `/api/schedule`, etc.). | +| `EVENTS_API_CORS_ORIGIN` | string | Optional | `http://localhost:5173` | All | No | Allowed CORS origin(s). Explicit URL in Staging/Prod; wildcard `*` allowed only in Dev/Test. | +| `DATABASE_PATH` | string | Optional | `./data/notifications.db` | All | No | SQLite database filepath for notifications, cursor persistence, and history. | +| `POLL_INTERVAL_MS` | integer | Optional | `30000` | All | No | Frequency in milliseconds for polling on-chain events via Soroban RPC (min: 1000). | +| `MAX_RECONNECT_ATTEMPTS` | integer | Optional | `5` | All | No | Maximum consecutive connection retry attempts before entering degraded status. | +| `RECONNECT_DELAY_MS` | integer | Optional | `5000` | All | No | Initial delay in ms before retrying dropped RPC connections. | +| `LOG_LEVEL` | string | Optional | `info` | All | No | Logging verbosity (`error`, `warn`, `info`, `http`, `verbose`, `debug`, `silly`). | +| `NODE_ENV` | string | Optional | `development` | All | No | Runtime environment (`development`, `test`, `staging`, `production`). Enables structured JSON logs in production. | +| `DISCORD_WEBHOOK_URL` | string (URL) | Conditional | *(None)* | All | **Yes** (Secret) | Discord webhook endpoint URL for notification delivery. Required if `DISCORD_WEBHOOK_ID` is set. | +| `DISCORD_WEBHOOK_ID` | string | Conditional | *(None)* | All | **Yes** (Secret) | Unique Discord webhook identifier. Required if `DISCORD_WEBHOOK_URL` is set. | +| `DISCORD_RETRY_COUNT` | integer | Optional | `5` (uses `RETRY_MAX_RETRIES`) | All | No | Maximum delivery attempts for Discord webhook notifications. | +| `DISCORD_BACKOFF_BASE_SECONDS` | integer | Optional | `5` | All | No | Exponential backoff base delay in seconds between failed Discord notification retries. | +| `NOTIFICATION_DEDUPLICATION_WINDOW_MS` | integer | Optional | `60000` | All | No | Time window in milliseconds within which duplicate outgoing messages are suppressed. | +| `NOTIFICATION_DEDUPLICATION_MAX_SIZE` | integer | Optional | `10000` | All | No | Maximum entries maintained in memory for message deduplication. | +| `WEBHOOK_SECRETS` | JSON Array | Optional | `[]` | All | **Yes** (Secret) | Array of `{ id: string, secret: string }` pairs used to generate and verify HMAC signatures. | +| `API_KEYS` | JSON Array | Optional | `[]` | All | **Yes** (Secret) | Array of `{ key: string, name?: string }` objects authorized for protected API operations. | +| `PAYLOAD_INTEGRITY_SECRET` | string | Optional | *(None)* | Staging, Prod | **Yes** (Secret) | Secret key used to compute and verify HMAC-SHA256 checksums over persisted notification payloads. | +| `SCHEDULER_ENABLED` | boolean | Optional | `true` | All | No | Enable background job scheduler for delayed/scheduled notifications. | +| `SCHEDULER_POLL_INTERVAL_MS` | integer | Optional | `10000` | All | No | Frequency in milliseconds for polling pending scheduled notifications. | +| `SCHEDULER_LOCK_TIMEOUT_MS` | integer | Optional | `60000` | All | No | Maximum lock duration in ms for processing workers before releasing claims. | +| `SCHEDULER_BATCH_SIZE` | integer | Optional | `10` | All | No | Maximum number of notifications processed in a single scheduler loop iteration. | +| `SCHEDULER_TIMING_BUFFER_MS` | integer | Optional | `60000` | All | No | Advance lookahead window in ms for querying upcoming due notifications. | +| `RETRY_SCHEDULER_ENABLED` | boolean | Optional | `true` | All | No | Enable automatic retry scheduler for failed deliveries. | +| `RETRY_SCHEDULER_POLL_INTERVAL_MS` | integer | Optional | `15000` | All | No | Frequency in ms to check for failed notifications eligible for retry. | +| `RETRY_BASE_DELAY_MS` | integer | Optional | `5000` | All | No | Base delay in ms used for exponential backoff calculations. | +| `RETRY_MULTIPLIER` | integer | Optional | `2` | All | No | Multiplier applied to consecutive retry attempt backoff intervals. | +| `RETRY_MAX_DELAY_MS` | integer | Optional | `3600000` (1 hour) | All | No | Upper bound cap on exponential backoff delays. | +| `RETRY_JITTER` | boolean | Optional | `true` | All | No | Adds randomized jitter to retry intervals to prevent thundering herd spikes. | +| `RATE_LIMIT_ENABLED` | boolean | Optional | `true` | All | No | Enable rate-limiting middleware on the events API server. | +| `RATE_LIMIT_WINDOW_MS` | integer | Optional | `60000` (1 min) | All | No | Sliding window duration in ms for tracking client request quotas. | +| `RATE_LIMIT_MAX_REQUESTS` | integer | Optional | `60` | All | No | Allowed request count per window before returning HTTP 429 Too Many Requests. | +| `RATE_LIMIT_CLIENT_OVERRIDES` | JSON Object | Optional | `{}` | All | No | Per-client IP/key custom rate limits: `{"": {"maxRequests": 100, "windowMs": 60000}}`. | +| `ANALYTICS_ENABLED` | boolean | Optional | `true` | All | No | Enable metrics collection and analytics aggregation engine. | +| `ANALYTICS_MAX_RECORDS` | integer | Optional | `10000` | All | No | In-memory capacity of the circular analytics event buffer. | +| `ANALYTICS_MAX_BUCKETS` | integer | Optional | `168` | All | No | Maximum hourly time-series buckets maintained in memory. | +| `ANALYTICS_BUCKET_SIZE_MS` | integer | Optional | `3600000` (1 hour) | All | No | Duration of individual analytics aggregation buckets. | +| `ANALYTICS_PERSIST_INTERVAL_MS`| integer | Optional | `300000` (5 mins) | All | No | Frequency of analytics snapshot persistence to disk. | +| `ANALYTICS_SNAPSHOT_RETENTION_DAYS`| integer | Optional| `30` | All | No | Number of days to retain historical metrics snapshots before pruning. | +| `CLEANUP_INTERVAL_MS` | integer | Optional | `3600000` (1 hour) | All | No | Frequency of database housekeeping and expired event pruning tasks. | +| `NOTIFICATION_RETENTION_MS` | integer | Optional | `604800000` (7 days) | All | No | Retention period for completed and failed notification records. | +| `EVENT_RETENTION_MS` | integer | Optional | `86400000` (24 hours) | All | No | Retention period for ingested on-chain events in the active registry. | +| `EXECUTION_LOG_RETENTION_MS`| integer | Optional | `7776000000` (90 days)| All | No | Retention period for detailed notification execution attempt logs. | + +--- + +## 3. Environment-Specific Profiles + +### Development (`NODE_ENV=development`) +- Uses local defaults: `EVENTS_API_CORS_ORIGIN="http://localhost:5173"` or `*`. +- `LOG_LEVEL="debug"` or `"info"`. +- Uses Soroban `testnet` RPC. + +### Staging (`NODE_ENV=staging`) +- Explicit CORS origins (wildcard `*` rejected). +- Secret injection via secret store (`DISCORD_WEBHOOK_URL`, `WEBHOOK_SECRETS`, `PAYLOAD_INTEGRITY_SECRET`). +- Polling intervals tuned to staging cluster load. + +### Production (`NODE_ENV=production`) +- Structured JSON logging enabled automatically. +- Strict schema and CORS validation enforced on startup. +- All secrets injected via production secret manager / KMS. diff --git a/docs/LISTENER-CONFIGURATION.md b/docs/LISTENER-CONFIGURATION.md index 6f87ce3a..03639f4e 100644 --- a/docs/LISTENER-CONFIGURATION.md +++ b/docs/LISTENER-CONFIGURATION.md @@ -23,11 +23,18 @@ For secrets-handling guidance (what must not be committed), also see [ENVIRONMEN 2. [How configuration is loaded](#how-configuration-is-loaded) 3. [Required vs optional](#required-vs-optional) 4. [Configuration reference](#configuration-reference) -5. [Examples](#examples) -6. [Environment differences](#environment-differences) -7. [Recommended values (operational guidance)](#recommended-values-operational-guidance) -8. [Troubleshooting](#troubleshooting) -9. [Related documentation](#related-documentation) +5. [Environment configuration matrix](#environment-configuration-matrix) +6. [Examples](#examples) +7. [Environment differences](#environment-differences) +8. [Recommended values (operational guidance)](#recommended-values-operational-guidance) +9. [Troubleshooting](#troubleshooting) +10. [Related documentation](#related-documentation) + +--- + +## Environment configuration matrix + +For a single consolidated master table specifying all environment variables, their types, required/optional status, defaults, sensitivity classifications, and environment applicability, consult [docs/ENVIRONMENT_MATRIX.md](ENVIRONMENT_MATRIX.md). --- diff --git a/listener/src/config-schema.test.ts b/listener/src/config-schema.test.ts new file mode 100644 index 00000000..aa86dd32 --- /dev/null +++ b/listener/src/config-schema.test.ts @@ -0,0 +1,99 @@ +import { + ConfigurationSchemaValidator, + ConfigSchema, + APP_CONFIG_SCHEMA, +} from './config-schema'; + +describe('Configuration Schema Validation (#694)', () => { + const sampleValidConfig = { + stellarNetwork: 'testnet', + stellarRpcUrl: 'https://soroban-testnet.stellar.org:443', + stellarNetworkPassphrase: 'Test SDF Network ; September 2015', + pollIntervalMs: 30000, + maxReconnectAttempts: 5, + reconnectDelayMs: 5000, + eventsApiPort: 8787, + eventsApiCorsOrigin: 'http://localhost:5173', + contractAddresses: [{ address: 'CABC', events: ['*'] }], + scheduler: { + enabled: true, + pollIntervalMs: 10000, + lockTimeoutMs: 60000, + batchSize: 10, + timingBufferMs: 60000, + }, + rateLimit: { + enabled: true, + windowMs: 60000, + maxRequests: 60, + }, + analytics: { + enabled: true, + maxRecords: 10000, + maxBuckets: 168, + bucketSizeMs: 3600000, + persistIntervalMs: 300000, + snapshotRetentionDays: 30, + }, + cleanup: { + intervalMs: 3600000, + notificationRetentionMs: 604800000, + rateLimitEventRetentionMs: 86400000, + eventRetentionMs: 86400000, + executionLogRetentionMs: 7776000000, + }, + }; + + it('passes valid configuration against full application schema', () => { + const errors = ConfigurationSchemaValidator.validate(sampleValidConfig, APP_CONFIG_SCHEMA); + expect(errors).toHaveLength(0); + }); + + it('validates required fields and reports field path', () => { + const invalidConfig = { ...sampleValidConfig, stellarRpcUrl: undefined }; + const errors = ConfigurationSchemaValidator.validate(invalidConfig, APP_CONFIG_SCHEMA); + expect(errors.length).toBeGreaterThan(0); + expect(errors.some((e) => e.field === 'stellarRpcUrl' && e.message.includes('missing'))).toBe(true); + }); + + it('validates explicit types', () => { + const invalidConfig = { ...sampleValidConfig, pollIntervalMs: '30000' as any }; + const errors = ConfigurationSchemaValidator.validate(invalidConfig, APP_CONFIG_SCHEMA); + expect(errors.some((e) => e.field === 'pollIntervalMs' && e.message.includes('must be of type number'))).toBe(true); + }); + + it('validates numeric minimum and maximum bounds', () => { + const invalidConfig = { + ...sampleValidConfig, + eventsApiPort: 70000, + pollIntervalMs: 50, + }; + const errors = ConfigurationSchemaValidator.validate(invalidConfig, APP_CONFIG_SCHEMA); + expect(errors.some((e) => e.field === 'eventsApiPort' && e.message.includes('exceeds maximum 65535'))).toBe(true); + expect(errors.some((e) => e.field === 'pollIntervalMs' && e.message.includes('less than minimum 1000'))).toBe(true); + }); + + it('validates enumerated values', () => { + const invalidConfig = { ...sampleValidConfig, stellarNetwork: 'invalidnet' }; + const errors = ConfigurationSchemaValidator.validate(invalidConfig, APP_CONFIG_SCHEMA); + expect(errors.some((e) => e.field === 'stellarNetwork' && e.message.includes('Allowed values'))).toBe(true); + }); + + it('validates nested schema fields', () => { + const invalidConfig = { + ...sampleValidConfig, + scheduler: { + ...sampleValidConfig.scheduler, + pollIntervalMs: 100, // below 1000 min + }, + }; + const errors = ConfigurationSchemaValidator.validate(invalidConfig, APP_CONFIG_SCHEMA); + expect(errors.some((e) => e.field === 'scheduler.pollIntervalMs')).toBe(true); + }); + + it('validates custom regex patterns', () => { + const invalidConfig = { ...sampleValidConfig, stellarRpcUrl: 'ftp://not-http.stellar.org' }; + const errors = ConfigurationSchemaValidator.validate(invalidConfig, APP_CONFIG_SCHEMA); + expect(errors.some((e) => e.field === 'stellarRpcUrl' && e.message.includes('pattern'))).toBe(true); + }); +}); diff --git a/listener/src/config-schema.ts b/listener/src/config-schema.ts new file mode 100644 index 00000000..58882278 --- /dev/null +++ b/listener/src/config-schema.ts @@ -0,0 +1,235 @@ +/** + * Configuration Schema Validation (#694) + * + * Provides deterministic, schema-based validation for application configuration: + * - Configuration fields have explicit types and constraints. + * - Required values are validated. + * - Numeric ranges and enumerated values are validated. + * - Validation errors identify the affected configuration field explicitly. + */ + +export type FieldType = 'string' | 'number' | 'boolean' | 'array' | 'object'; + +export interface SchemaFieldRule { + type: FieldType; + required?: boolean; + min?: number; + max?: number; + enum?: readonly (string | number)[]; + pattern?: RegExp; + custom?: (value: any, path: string) => string | null; + description?: string; +} + +export interface ConfigSchema { + [field: string]: SchemaFieldRule | ConfigSchema; +} + +export interface SchemaValidationError { + field: string; + expected: string; + actual: any; + message: string; +} + +export class ConfigurationSchemaValidator { + /** + * Validate an arbitrary object against a schema definition. + * Returns a list of all schema validation errors found. + */ + public static validate(data: Record, schema: ConfigSchema, prefix = ''): SchemaValidationError[] { + const errors: SchemaValidationError[] = []; + + for (const [key, ruleOrNested] of Object.entries(schema)) { + const fieldPath = prefix ? `${prefix}.${key}` : key; + const value = data?.[key]; + + // Check if it's a nested schema + if (typeof ruleOrNested === 'object' && !('type' in ruleOrNested)) { + if (value !== undefined && value !== null) { + if (typeof value !== 'object' || Array.isArray(value)) { + errors.push({ + field: fieldPath, + expected: 'object', + actual: typeof value, + message: `Field "${fieldPath}" must be an object.`, + }); + } else { + const nestedErrors = this.validate(value, ruleOrNested as ConfigSchema, fieldPath); + errors.push(...nestedErrors); + } + } + continue; + } + + const rule = ruleOrNested as SchemaFieldRule; + + // Check required + if (value === undefined || value === null || (typeof value === 'string' && value.trim() === '')) { + if (rule.required) { + errors.push({ + field: fieldPath, + expected: `non-empty ${rule.type}`, + actual: value === undefined ? 'undefined' : value === null ? 'null' : 'empty string', + message: `Required field "${fieldPath}" is missing or empty.`, + }); + } + continue; + } + + // Check type + const actualType = Array.isArray(value) ? 'array' : typeof value; + if (actualType !== rule.type) { + errors.push({ + field: fieldPath, + expected: rule.type, + actual: actualType, + message: `Field "${fieldPath}" must be of type ${rule.type}, received ${actualType}.`, + }); + continue; + } + + // Check numeric bounds + if (rule.type === 'number') { + if (Number.isNaN(value) || !Number.isFinite(value)) { + errors.push({ + field: fieldPath, + expected: 'finite number', + actual: String(value), + message: `Field "${fieldPath}" must be a valid finite number.`, + }); + continue; + } + + if (rule.min !== undefined && value < rule.min) { + errors.push({ + field: fieldPath, + expected: `>= ${rule.min}`, + actual: value, + message: `Field "${fieldPath}" value ${value} is less than minimum ${rule.min}.`, + }); + } + + if (rule.max !== undefined && value > rule.max) { + errors.push({ + field: fieldPath, + expected: `<= ${rule.max}`, + actual: value, + message: `Field "${fieldPath}" value ${value} exceeds maximum ${rule.max}.`, + }); + } + } + + // Check enum values + if (rule.enum && !rule.enum.includes(value)) { + errors.push({ + field: fieldPath, + expected: `one of [${rule.enum.join(', ')}]`, + actual: value, + message: `Field "${fieldPath}" value "${value}" is not valid. Allowed values: ${rule.enum.join(', ')}.`, + }); + } + + // Check regex pattern + if (rule.pattern && typeof value === 'string' && !rule.pattern.test(value)) { + errors.push({ + field: fieldPath, + expected: `matching pattern ${rule.pattern}`, + actual: value, + message: `Field "${fieldPath}" value "${value}" does not match required pattern.`, + }); + } + + // Custom rule check + if (rule.custom) { + const customErr = rule.custom(value, fieldPath); + if (customErr) { + errors.push({ + field: fieldPath, + expected: 'custom validation constraint', + actual: value, + message: customErr, + }); + } + } + } + + return errors; + } +} + +/** + * Standard listener application configuration schema + */ +export const APP_CONFIG_SCHEMA: ConfigSchema = { + stellarNetwork: { + type: 'string', + required: true, + enum: ['testnet', 'public', 'futurenet', 'standalone', 'local'], + }, + stellarRpcUrl: { + type: 'string', + required: true, + pattern: /^https?:\/\//, + }, + stellarNetworkPassphrase: { + type: 'string', + required: true, + }, + pollIntervalMs: { + type: 'number', + required: true, + min: 1000, + }, + maxReconnectAttempts: { + type: 'number', + required: true, + min: 1, + }, + reconnectDelayMs: { + type: 'number', + required: true, + min: 0, + }, + eventsApiPort: { + type: 'number', + required: true, + min: 1, + max: 65535, + }, + eventsApiCorsOrigin: { + type: 'string', + required: true, + }, + contractAddresses: { + type: 'array', + required: true, + }, + scheduler: { + enabled: { type: 'boolean' }, + pollIntervalMs: { type: 'number', min: 1000 }, + lockTimeoutMs: { type: 'number', min: 1000 }, + batchSize: { type: 'number', min: 1 }, + timingBufferMs: { type: 'number', min: 0 }, + }, + rateLimit: { + enabled: { type: 'boolean' }, + windowMs: { type: 'number', min: 1000 }, + maxRequests: { type: 'number', min: 1 }, + }, + analytics: { + enabled: { type: 'boolean' }, + maxRecords: { type: 'number', min: 1 }, + maxBuckets: { type: 'number', min: 1 }, + bucketSizeMs: { type: 'number', min: 60000 }, + persistIntervalMs: { type: 'number', min: 1000 }, + snapshotRetentionDays: { type: 'number', min: 1 }, + }, + cleanup: { + intervalMs: { type: 'number', min: 60000 }, + notificationRetentionMs: { type: 'number', min: 60000 }, + rateLimitEventRetentionMs: { type: 'number', min: 60000 }, + eventRetentionMs: { type: 'number', min: 60000 }, + executionLogRetentionMs: { type: 'number', min: 60000 }, + }, +}; diff --git a/listener/src/config.test.ts b/listener/src/config.test.ts index 8ebb90ac..8359d2dc 100644 --- a/listener/src/config.test.ts +++ b/listener/src/config.test.ts @@ -240,4 +240,26 @@ describe('Config validation', () => { expect(() => loadConfig()).toThrow('WEBHOOK_SECRETS must be a JSON array'); }); }); + + describe('CORS and Schema Validation integration (#689, #694)', () => { + it('rejects wildcard CORS origin in production environment', () => { + process.env.NODE_ENV = 'production'; + process.env.EVENTS_API_CORS_ORIGIN = '*'; + + const config = loadConfig(); + expect(() => { + const { validateConfig } = require('./config'); + validateConfig(config); + }).toThrow(ConfigError); + }); + + it('accepts valid HTTPS CORS origin in production environment', () => { + process.env.NODE_ENV = 'production'; + process.env.EVENTS_API_CORS_ORIGIN = 'https://dashboard.notifychain.io'; + + const config = loadConfig(); + const { validateConfig } = require('./config'); + expect(() => validateConfig(config)).not.toThrow(); + }); + }); }); diff --git a/listener/src/config.ts b/listener/src/config.ts index 52be74c3..c150ecc4 100644 --- a/listener/src/config.ts +++ b/listener/src/config.ts @@ -1,4 +1,6 @@ import { Config, ContractConfig, DiscordConfig, WebhookSecret, AppCleanupConfig, EventQueueConfig, RetrySchedulerOptions, AnalyticsConfig, ExpirationConfig, ApiKey } from './types'; +import { validateCorsOrigin, CorsValidationError } from './utils/cors-validator'; +import { ConfigurationSchemaValidator, APP_CONFIG_SCHEMA } from './config-schema'; export class ConfigError extends Error { constructor(message: string) { @@ -323,6 +325,22 @@ export function validateConfig(config: Config): void { ); } + // Validate CORS configuration during startup (#689) + if (config.eventsApiCorsOrigin) { + try { + validateCorsOrigin({ + corsOrigin: config.eventsApiCorsOrigin, + nodeEnv: process.env.NODE_ENV, + }); + } catch (corsErr) { + if (corsErr instanceof CorsValidationError) { + errors.push(`EVENTS_API_CORS_ORIGIN: ${corsErr.message}`); + } else { + errors.push(`EVENTS_API_CORS_ORIGIN invalid: ${String(corsErr)}`); + } + } + } + // ── Contract addresses ───────────────────────────────────────────────────── if (!Array.isArray(config.contractAddresses)) { errors.push('CONTRACT_ADDRESSES must be a JSON array.'); @@ -470,5 +488,14 @@ export function validateConfig(config: Config): void { errors.map((e, i) => ` ${i + 1}. ${e}`).join('\n'), ); } + + // Schema-based validation check (#694) + const schemaErrors = ConfigurationSchemaValidator.validate(config as any, APP_CONFIG_SCHEMA); + if (schemaErrors.length > 0) { + throw new ConfigError( + `Configuration schema validation failed with ${schemaErrors.length} error(s):\n` + + schemaErrors.map((e, i) => ` ${i + 1}. [${e.field}] ${e.message}`).join('\n'), + ); + } } diff --git a/listener/src/tests/notification-schema-compatibility.test.ts b/listener/src/tests/notification-schema-compatibility.test.ts new file mode 100644 index 00000000..f95a83d3 --- /dev/null +++ b/listener/src/tests/notification-schema-compatibility.test.ts @@ -0,0 +1,179 @@ +/** + * Notification Schema Compatibility Tests (#702) + * + * Verifies that changes to notification payload structures do not unintentionally + * break supported consumers (Discord, Webhooks, Email, SMS, off-chain indexers). + * + * Acceptance Criteria: + * - Representative payloads across all notification types are tested. + * - Required fields (targetRecipient, payload, executeAt, notificationType) are validated. + * - Compatibility failures and schema violations are reported clearly. + * - Tests run automatically in CI. + */ + +import { NotificationType, NotificationStatus, ScheduledNotification } from '../types/scheduled-notification'; +import { ensureNotificationVersion, CURRENT_NOTIFICATION_VERSION } from '../utils/notification-version'; +import { validatePayloadSize } from '../utils/payload-size-validator'; +import { validateNotificationMetadata } from '../utils/metadata-validator'; +import { BatchValidator } from '../utils/batch-validator'; + +describe('Notification Schema Compatibility (#702)', () => { + describe('Representative Consumer Payloads', () => { + it('supports Discord webhook notification payload format', () => { + const discordPayload = { + version: CURRENT_NOTIFICATION_VERSION, + content: 'New event detected on contract C123', + embeds: [ + { + title: 'AutoShare Group Created', + description: 'Group 0xabcdef was created by G1234', + color: 0x5865f2, + fields: [ + { name: 'Priority', value: 'High', inline: true }, + { name: 'Usage Count', value: '100', inline: true }, + ], + timestamp: new Date().toISOString(), + }, + ], + }; + + const versioned = ensureNotificationVersion(discordPayload); + expect(versioned.version).toBe(CURRENT_NOTIFICATION_VERSION); + expect(() => validatePayloadSize(versioned)).not.toThrow(); + expect(versioned).toHaveProperty('content'); + expect(versioned).toHaveProperty('embeds'); + }); + + it('supports Generic Webhook notification payload format with headers & metadata', () => { + const webhookPayload = { + version: CURRENT_NOTIFICATION_VERSION, + event: 'autoshare.created', + id: 'evt_998877', + timestamp: 1724000000, + data: { + groupId: '0x11223344', + creator: 'GDG...XYZ', + usages: 50, + }, + }; + + const versioned = ensureNotificationVersion(webhookPayload); + expect(versioned.version).toBe(CURRENT_NOTIFICATION_VERSION); + expect(() => validatePayloadSize(versioned)).not.toThrow(); + + // Test metadata validation + const metadata = { + source: 'notify-chain-contract', + eventType: 'autoshare.created', + correlationId: 'req_123', + }; + expect(() => validateNotificationMetadata(metadata)).not.toThrow(); + }); + + it('supports Email notification payload format', () => { + const emailPayload = { + version: CURRENT_NOTIFICATION_VERSION, + subject: 'Notification from NotifyChain', + html: '

You received a new notification regarding your balance.

', + text: 'You received a new notification regarding your balance.', + from: 'no-reply@notifychain.io', + }; + + const versioned = ensureNotificationVersion(emailPayload); + expect(versioned.version).toBe(CURRENT_NOTIFICATION_VERSION); + expect(() => validatePayloadSize(versioned)).not.toThrow(); + expect(versioned).toHaveProperty('subject'); + expect(versioned).toHaveProperty('html'); + }); + + it('supports SMS notification payload format', () => { + const smsPayload = { + version: CURRENT_NOTIFICATION_VERSION, + body: 'Alert: Contract paused by admin at block 12345.', + }; + + const versioned = ensureNotificationVersion(smsPayload); + expect(versioned.version).toBe(CURRENT_NOTIFICATION_VERSION); + expect(() => validatePayloadSize(versioned)).not.toThrow(); + expect(versioned).toHaveProperty('body'); + }); + }); + + describe('Backward and Forward Schema Compatibility', () => { + it('automatically stamps CURRENT_NOTIFICATION_VERSION for legacy unversioned payloads', () => { + const legacyPayload = { + message: 'Legacy notification without explicit version field', + channel: 'discord', + }; + + const upgraded = ensureNotificationVersion(legacyPayload); + expect(upgraded.version).toBe(CURRENT_NOTIFICATION_VERSION); + expect(upgraded.message).toBe(legacyPayload.message); + }); + + it('rejects unsupported future version payloads with clear error', () => { + const futurePayload = { + version: CURRENT_NOTIFICATION_VERSION + 1, + message: 'Future notification format', + }; + + expect(() => ensureNotificationVersion(futurePayload)).toThrow( + `Unsupported notification version ${CURRENT_NOTIFICATION_VERSION + 1}; current is ${CURRENT_NOTIFICATION_VERSION}` + ); + }); + + it('rejects invalid non-integer versions with descriptive message', () => { + expect(() => ensureNotificationVersion({ version: 'invalid' as any })).toThrow( + /Unsupported notification version: invalid/ + ); + expect(() => ensureNotificationVersion({ version: -1 })).toThrow( + /Unsupported notification version/ + ); + }); + }); + + describe('Batch Payload Schema Validation Compatibility', () => { + it('validates batch format compatible with multiple recipient channels', () => { + const batch = [ + { + id: 'item_1', + recipient: 'https://discord.com/api/webhooks/1/2', + channel: 'discord' as const, + message: 'Discord message', + }, + { + id: 'item_2', + recipient: 'https://webhook.site/abc', + channel: 'webhook' as const, + message: 'Webhook payload', + }, + { + id: 'item_3', + recipient: 'alice@example.com', + channel: 'email' as const, + message: 'Email content', + }, + ]; + + const result = BatchValidator.validateBatch(batch); + expect(result.isValid).toBe(true); + expect(result.processedCount).toBe(3); + expect(result.errors).toHaveLength(0); + }); + + it('reports clear error details when required fields are missing in batch payload', () => { + const invalidBatch = [ + { + id: 'item_1', + // missing recipient + channel: 'discord', + message: 'Message without recipient', + }, + ]; + + const result = BatchValidator.validateBatch(invalidBatch); + expect(result.isValid).toBe(false); + expect(result.errors.some((e) => e.code === 'MISSING_FIELD' && e.field === 'recipient')).toBe(true); + }); + }); +}); diff --git a/listener/src/utils/cors-validator.test.ts b/listener/src/utils/cors-validator.test.ts new file mode 100644 index 00000000..f5b5b3d1 --- /dev/null +++ b/listener/src/utils/cors-validator.test.ts @@ -0,0 +1,103 @@ +import { validateCorsOrigin, CorsValidationError } from './cors-validator'; + +describe('CORS Configuration Validation (#689)', () => { + it('accepts a valid single origin in development', () => { + const origins = validateCorsOrigin({ + corsOrigin: 'http://localhost:5173', + nodeEnv: 'development', + }); + expect(origins).toEqual(['http://localhost:5173']); + }); + + it('accepts a valid HTTPS origin in production', () => { + const origins = validateCorsOrigin({ + corsOrigin: 'https://app.notifychain.io', + nodeEnv: 'production', + }); + expect(origins).toEqual(['https://app.notifychain.io']); + }); + + it('accepts multiple comma-separated origins', () => { + const origins = validateCorsOrigin({ + corsOrigin: 'https://app.notifychain.io, https://admin.notifychain.io', + nodeEnv: 'production', + }); + expect(origins).toEqual(['https://app.notifychain.io', 'https://admin.notifychain.io']); + }); + + it('accepts wildcard origin in development', () => { + const origins = validateCorsOrigin({ + corsOrigin: '*', + nodeEnv: 'development', + }); + expect(origins).toEqual(['*']); + }); + + it('rejects wildcard origin in production without explicit override', () => { + expect(() => + validateCorsOrigin({ + corsOrigin: '*', + nodeEnv: 'production', + }) + ).toThrow(CorsValidationError); + + expect(() => + validateCorsOrigin({ + corsOrigin: '*', + nodeEnv: 'staging', + }) + ).toThrow(/Wildcard CORS origin "\*" is not permitted in production\/staging/); + }); + + it('rejects empty or whitespace origin string', () => { + expect(() => + validateCorsOrigin({ + corsOrigin: '', + nodeEnv: 'development', + }) + ).toThrow(CorsValidationError); + + expect(() => + validateCorsOrigin({ + corsOrigin: ' ', + nodeEnv: 'development', + }) + ).toThrow(/non-empty string/); + }); + + it('rejects malformed origin URLs', () => { + expect(() => + validateCorsOrigin({ + corsOrigin: 'not-a-url', + nodeEnv: 'development', + }) + ).toThrow(CorsValidationError); + }); + + it('rejects unsupported protocols such as ftp or file', () => { + expect(() => + validateCorsOrigin({ + corsOrigin: 'ftp://files.notifychain.io', + nodeEnv: 'development', + }) + ).toThrow(/Only "http:" and "https:" are allowed/); + }); + + it('rejects origins with path segments', () => { + expect(() => + validateCorsOrigin({ + corsOrigin: 'https://app.notifychain.io/api/v1', + nodeEnv: 'development', + }) + ).toThrow(/must not include path segments/); + }); + + it('rejects combining wildcard with specific origins', () => { + expect(() => + validateCorsOrigin({ + corsOrigin: '*, https://app.notifychain.io', + nodeEnv: 'development', + }) + ).toThrow(/Cannot combine wildcard/); + }); +}); diff --git a/listener/src/utils/cors-validator.ts b/listener/src/utils/cors-validator.ts new file mode 100644 index 00000000..526d5262 --- /dev/null +++ b/listener/src/utils/cors-validator.ts @@ -0,0 +1,96 @@ +/** + * CORS Configuration Validator (#689) + * + * Validates allowed origins during application startup: + * - Allowed origins are explicitly configurable. + * - Invalid origin configurations are rejected with descriptive errors. + * - Wildcard configuration ('*') is clearly documented and restricted in sensitive environments. + * - Sensitive environments (production, staging) do not silently fall back to permissive or wildcard settings. + */ + +export class CorsValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'CorsValidationError'; + } +} + +export interface CorsValidationOptions { + /** The configured CORS origin value (e.g. from EVENTS_API_CORS_ORIGIN). */ + corsOrigin: string; + /** Current runtime environment (e.g. process.env.NODE_ENV). */ + nodeEnv?: string; + /** Whether to allow wildcard origins in production if explicitly intended. Default: false. */ + allowWildcardInProduction?: boolean; +} + +/** + * Validates a single CORS origin string or comma-separated list of origins. + * + * Supported formats: + * - Specific URI: `http://localhost:5173`, `https://app.notifychain.io` + * - Multiple URIs (comma-separated): `https://app.notifychain.io, https://admin.notifychain.io` + * - Wildcard: `*` (only allowed in non-sensitive environments like development/test) + * + * @throws {CorsValidationError} if the origin format is invalid or insecure for the environment. + */ +export function validateCorsOrigin(options: CorsValidationOptions): string[] { + const { corsOrigin, nodeEnv = 'development', allowWildcardInProduction = false } = options; + + if (typeof corsOrigin !== 'string' || corsOrigin.trim().length === 0) { + throw new CorsValidationError( + 'CORS origin must be a non-empty string specifying allowed origins (e.g. "https://app.example.com" or "http://localhost:5173").' + ); + } + + const trimmed = corsOrigin.trim(); + const isProductionOrStaging = ['production', 'staging', 'prod'].includes(nodeEnv.toLowerCase()); + + // Handle wildcard origin + if (trimmed === '*') { + if (isProductionOrStaging && !allowWildcardInProduction) { + throw new CorsValidationError( + 'Wildcard CORS origin "*" is not permitted in production/staging environments. ' + + 'Explicitly configure allowed origins (e.g. "https://app.notifychain.io") to prevent cross-origin security vulnerabilities.' + ); + } + return ['*']; + } + + // Parse comma-separated origins if provided + const origins = trimmed.split(',').map((o) => o.trim()).filter(Boolean); + + if (origins.length === 0) { + throw new CorsValidationError('No valid origins found in CORS configuration.'); + } + + for (const origin of origins) { + if (origin === '*') { + throw new CorsValidationError( + 'Cannot combine wildcard "*" with specific origins in CORS configuration.' + ); + } + + try { + const url = new URL(origin); + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new CorsValidationError( + `Invalid CORS origin protocol "${url.protocol}" in "${origin}". Only "http:" and "https:" are allowed.` + ); + } + // Ensure there is no trailing slash or path in the origin + if (url.pathname !== '/' && url.pathname !== '') { + throw new CorsValidationError( + `CORS origin "${origin}" must not include path segments (${url.pathname}). Specify only the scheme, host, and optional port (e.g. "${url.origin}").` + ); + } + } catch (err) { + if (err instanceof CorsValidationError) throw err; + throw new CorsValidationError( + `Invalid CORS origin format: "${origin}". Must be a valid URL (e.g. "https://app.notifychain.io" or "http://localhost:5173").` + ); + } + } + + return origins; +}