Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions ENVIRONMENT_VARIABLES_AND_SECRETS.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,23 @@ Variables marked **Required** are checked by `validateRequiredEnvVars()` in `lis

| Variable | Default | Required | Description |
|---|---|---|---|
| `LOG_LEVEL` | `info` | No | Winston log level. Values: `error` \| `warn` \| `info` \| `http` \| `verbose` \| `debug` \| `silly`. |
| `NODE_ENV` | *(unset)* | No | Set to `production` to enable newline-delimited JSON (structured) log output. Leave unset for human-readable pretty-print during development. |
| `LOG_LEVEL` | `info` | No | Log verbosity. Exactly one of: `error` \| `warn` \| `info` \| `debug`. Any other value is **rejected at startup** with a `ConfigError`. |
| `LOG_FORMAT` | *(env-dependent)* | No | Log output format: `json` (newline-delimited JSON for log aggregators) or `pretty` (colourised, human-readable). Defaults to `json` when `NODE_ENV=production`, `pretty` otherwise. Any other value is rejected at startup. |
| `NODE_ENV` | *(unset)* | No | Set to `production` for production defaults. Affects the `LOG_FORMAT` default only when `LOG_FORMAT` is unset; an explicit `LOG_FORMAT` always wins. |

> **Note on log levels.** Earlier revisions of this table listed `http`,
> `verbose` and `silly`. The service has only ever implemented
> `error | warn | info | debug` — the other values were silently downgraded to
> `info`. They are now rejected at startup rather than accepted-and-ignored, so
> a deployment that sets one will fail fast with a message naming the valid
> values instead of running at unexpected verbosity.

**Secret redaction.** Log records are scanned before emission and any field
whose name looks like a credential (`password`, `secret`, `token`, `apiKey`,
`authorization`, `signature`, `cookie`, `privateKey`, and case/underscore
variants) has its value replaced with `[REDACTED]`. Query-string parameters in
logged request URLs are redacted the same way. The field itself is kept so
record shape stays stable for aggregator indexing.

### 2.2 Stellar / chain connectivity

Expand Down Expand Up @@ -182,6 +197,7 @@ The DB-backed retry scheduler persists retry state across process restarts.
| `RATE_LIMIT_WINDOW_MS` | `60000` | No | Rate limit sliding window size (ms). |
| `RATE_LIMIT_MAX_REQUESTS` | `60` | No | Maximum requests allowed per window (applies to all clients unless overridden). |
| `RATE_LIMIT_CLIENT_OVERRIDES` | `{}` | No | Per-client rate limit overrides. JSON object. See shape below. |
| `API_MAX_BODY_BYTES` | `1048576` | No | Largest request body the events API accepts, in bytes (default 1 MiB). Oversized `POST`/`PUT`/`PATCH` requests get `413 Payload Too Large` with code `PAYLOAD_TOO_LARGE`, and the payload is never parsed. Must be a positive integer. |

**`RATE_LIMIT_CLIENT_OVERRIDES` shape:**
```json
Expand Down
31 changes: 31 additions & 0 deletions listener/src/api/events-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ import { NotificationHealthMonitor } from '../services/notification-health-monit
import { getJobMonitor } from '../services/job-monitor';
import { NotificationImportService } from '../services/notification-import-service';
import { ResponseTimeMiddleware } from '../middleware/response-time';
import { DEFAULT_MAX_BODY_BYTES, enforceBodyLimit } from '../middleware/body-limit';
import { sanitizeUrl } from '../utils/logger';

export interface EventsServerOptions {
port: number;
Expand Down Expand Up @@ -98,6 +100,11 @@ export interface EventsServerOptions {
* When omitted a new instance is created using `slowRequestThresholdMs`.
*/
responseTimeMiddleware?: ResponseTimeMiddleware | null;
/**
* Largest request body accepted, in bytes. Oversized requests get a 413 and
* are never parsed. Defaults to {@link DEFAULT_MAX_BODY_BYTES}.
*/
maxBodyBytes?: number;
}

type ServiceStatus = 'ok' | 'error' | 'not_configured';
Expand Down Expand Up @@ -425,6 +432,30 @@ export function createEventsServer(options: EventsServerOptions): http.Server {
// Start response-time tracking for this request (#491)
responseTime.start(res);

// Request body size limit.
//
// Screened here, before routing, so an oversized payload is answered and
// the socket destroyed before any route handler attaches its own `data`
// listener and starts accumulating the body in memory.
const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
const bodyLimit = enforceBodyLimit(req, res, {
maxBytes: maxBodyBytes,
onRejected: (reason, observedBytes) => {
logger.warn('Request body exceeded size limit', {
requestId,
correlationId,
method: req.method,
url: sanitizeUrl(req.url ?? '/'),
reason,
observedBytes,
maxBytes: maxBodyBytes,
});
},
});
if (!bodyLimit.allowed) {
return;
}

res.setHeader('Access-Control-Allow-Origin', corsOrigin);
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.setHeader(
Expand Down
169 changes: 169 additions & 0 deletions listener/src/config-logging.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
/**
* Configuration of log verbosity, log format and the request-size limit.
*
* These go through the same loadConfig/validateConfig mechanism as every other
* setting, so an operator changes them the same way they change anything else
* — and a typo is caught at startup rather than silently ignored.
*/

import { loadConfig, validateConfig, ConfigError } from './config';

/** A well-formed 56-character contract address, so the shared required-env
* check passes and these tests fail only on the fields they are about. */
const TEST_CONTRACT_ADDRESS = `C${'A'.repeat(55)}`;

const BASE_ENV: Record<string, string> = {
CONTRACT_ADDRESSES: `[{"address":"${TEST_CONTRACT_ADDRESS}","events":["notify"]}]`,
};

function withEnv(overrides: Record<string, string | undefined>, fn: () => void): void {
const saved = { ...process.env };
try {
for (const key of Object.keys(process.env)) {
if (key.startsWith('LOG_') || key.startsWith('API_MAX_')) delete process.env[key];
}
Object.assign(process.env, BASE_ENV);
for (const [key, value] of Object.entries(overrides)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
fn();
} finally {
process.env = saved;
}
}

// ── Log level ───────────────────────────────────────────────────────────────

describe('LOG_LEVEL configuration', () => {
it.each(['error', 'warn', 'info', 'debug'])('accepts %s', (level) => {
withEnv({ LOG_LEVEL: level }, () => {
const config = loadConfig();
expect(config.logging?.level).toBe(level);
expect(() => validateConfig(config)).not.toThrow();
});
});

it('defaults to info when unset', () => {
// Production default stays conservative: info, not debug.
withEnv({ LOG_LEVEL: undefined }, () => {
expect(loadConfig().logging?.level).toBe('info');
});
});

it('rejects an unrecognised level instead of silently downgrading', () => {
// A typo that quietly resolves to "info" hides the debug output the
// operator asked for, with no signal that the setting did not take.
withEnv({ LOG_LEVEL: 'verbose' }, () => {
const config = loadConfig();
expect(() => validateConfig(config)).toThrow(ConfigError);
});
});

it('names the supported levels in the rejection message', () => {
withEnv({ LOG_LEVEL: 'trace' }, () => {
try {
validateConfig(loadConfig());
throw new Error('expected validateConfig to throw');
} catch (error) {
const message = (error as Error).message;
expect(message).toContain('LOG_LEVEL');
expect(message).toContain('debug');
expect(message).toContain('trace');
}
});
});
});

// ── Log format ──────────────────────────────────────────────────────────────

describe('LOG_FORMAT configuration', () => {
it('accepts json', () => {
withEnv({ LOG_FORMAT: 'json' }, () => {
const config = loadConfig();
expect(config.logging?.format).toBe('json');
expect(() => validateConfig(config)).not.toThrow();
});
});

it('accepts pretty', () => {
withEnv({ LOG_FORMAT: 'pretty' }, () => {
expect(loadConfig().logging?.format).toBe('pretty');
});
});

it('enables JSON in a non-production environment when asked', () => {
// The point of making this configurable: reproducing an aggregator
// problem locally should not require pretending to be production.
withEnv({ LOG_FORMAT: 'json', NODE_ENV: 'development' }, () => {
expect(loadConfig().logging?.format).toBe('json');
});
});

it('preserves the previous environment-based default when unset', () => {
withEnv({ LOG_FORMAT: undefined, NODE_ENV: 'production' }, () => {
expect(loadConfig().logging?.format).toBe('json');
});
withEnv({ LOG_FORMAT: undefined, NODE_ENV: 'development' }, () => {
expect(loadConfig().logging?.format).toBe('pretty');
});
});

it('rejects an unrecognised format', () => {
withEnv({ LOG_FORMAT: 'logfmt' }, () => {
expect(() => validateConfig(loadConfig())).toThrow(ConfigError);
});
});
});

// ── Request size limit ──────────────────────────────────────────────────────

describe('API_MAX_BODY_BYTES configuration', () => {
it('defaults to 1 MiB', () => {
withEnv({ API_MAX_BODY_BYTES: undefined }, () => {
expect(loadConfig().api?.maxBodyBytes).toBe(1_048_576);
});
});

it('accepts an explicit limit', () => {
withEnv({ API_MAX_BODY_BYTES: '65536' }, () => {
const config = loadConfig();
expect(config.api?.maxBodyBytes).toBe(65_536);
expect(() => validateConfig(config)).not.toThrow();
});
});

it('rejects a non-positive limit', () => {
// Zero would refuse every request with a body; negative is meaningless.
withEnv({ API_MAX_BODY_BYTES: '0' }, () => {
expect(() => validateConfig(loadConfig())).toThrow(ConfigError);
});
withEnv({ API_MAX_BODY_BYTES: '-1' }, () => {
expect(() => validateConfig(loadConfig())).toThrow(ConfigError);
});
});

it('rejects a non-numeric limit at load time', () => {
withEnv({ API_MAX_BODY_BYTES: 'huge' }, () => {
expect(() => loadConfig()).toThrow(ConfigError);
});
});
});

// ── Combined reporting ──────────────────────────────────────────────────────

describe('validation reporting', () => {
it('reports every bad value at once rather than one per restart', () => {
withEnv({ LOG_LEVEL: 'nope', LOG_FORMAT: 'nope', API_MAX_BODY_BYTES: '0' }, () => {
try {
validateConfig(loadConfig());
throw new Error('expected validateConfig to throw');
} catch (error) {
const message = (error as Error).message;
expect(message).toContain('LOG_LEVEL');
expect(message).toContain('LOG_FORMAT');
expect(message).toContain('API_MAX_BODY_BYTES');
}
});
});
});
66 changes: 65 additions & 1 deletion listener/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { Config, ContractConfig, DiscordConfig, WebhookSecret, AppCleanupConfig, EventQueueConfig, RetrySchedulerOptions, AnalyticsConfig, ExpirationConfig, ApiKey, BackfillConfig } from './types';
import { Config, ContractConfig, DiscordConfig, WebhookSecret, AppCleanupConfig, EventQueueConfig, RetrySchedulerOptions, AnalyticsConfig, ExpirationConfig, ApiKey, BackfillConfig, LoggingConfig, ApiConfig } from './types';
import {
SUPPORTED_LOG_FORMATS,
SUPPORTED_LOG_LEVELS,
parseLogFormat,
parseLogLevel,
} from './utils/logger';
import { DEFAULT_MAX_BODY_BYTES } from './middleware/body-limit';

export class ConfigError extends Error {
constructor(message: string) {
Expand Down Expand Up @@ -289,6 +296,33 @@ export function loadConfig(): Config {
analytics: loadAnalyticsConfig(),
expiration: loadExpirationConfig(),
backfill: loadBackfillConfig(),
logging: loadLoggingConfig(),
api: loadApiConfig(),
};
}

/**
* Observability settings.
*
* Raw strings are carried through and validated in `validateConfig`, matching
* how the rest of this loader works: collect everything, then report every
* problem at once rather than throwing on the first bad field.
*/
function loadLoggingConfig(): LoggingConfig {
return {
level: trimEnv('LOG_LEVEL') || 'info',
// Preserves the previous implicit behaviour when LOG_FORMAT is unset:
// JSON in production, human-readable elsewhere.
format:
trimEnv('LOG_FORMAT') ||
(process.env.NODE_ENV === 'production' ? 'json' : 'pretty'),
};
}

/** HTTP surface settings. */
function loadApiConfig(): ApiConfig {
return {
maxBodyBytes: parseIntegerEnv('API_MAX_BODY_BYTES', String(DEFAULT_MAX_BODY_BYTES)),
};
}

Expand Down Expand Up @@ -559,6 +593,36 @@ export function validateConfig(config: Config): void {
}
}

// ── Logging ────────────────────────────────────────────────────────────────
// Rejected rather than silently downgraded: a typo in LOG_LEVEL that quietly
// resolves to "info" hides debug output an operator explicitly asked for, and
// they have no signal that the setting did not take.
if (config.logging) {
if (parseLogLevel(config.logging.level) === null) {
errors.push(
`LOG_LEVEL must be one of: ${SUPPORTED_LOG_LEVELS.join(', ')} ` +
`(received: "${config.logging.level}").`,
);
}

if (parseLogFormat(config.logging.format) === null) {
errors.push(
`LOG_FORMAT must be one of: ${SUPPORTED_LOG_FORMATS.join(', ')} ` +
`(received: "${config.logging.format}").`,
);
}
}

// ── API surface ────────────────────────────────────────────────────────────
if (config.api) {
if (!Number.isInteger(config.api.maxBodyBytes) || config.api.maxBodyBytes <= 0) {
errors.push(
`API_MAX_BODY_BYTES must be a positive integer ` +
`(received: ${config.api.maxBodyBytes}).`,
);
}
}

if (errors.length > 0) {
throw new ConfigError(
`Configuration validation failed with ${errors.length} error(s):\n` +
Expand Down
Loading