diff --git a/docs/configuration.md b/docs/configuration.md index 957c6e6..2be74d8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -114,34 +114,34 @@ These values **must** be provided via environment variables: These values have defaults and can be overridden: -| Variable | Type | Default | Description | -| -------------------------------------- | ------- | ------------- | ----------------------------- | -| `PORT` | number | `3000` | Server port | -| `MODE` | enum | `development` | Environment mode | -| `DB_QUERY_TIMEOUT_MS` | number | `5000` | Prisma query timeout in ms | -| `APP_SECRET` | string | (dev key) | Secret for signing/encryption | -| `API_VERSION` | string | `1.0.0` | API version string | -| `ENABLE_RESPONSE_TIMING` | boolean | `true` | Enable timing headers | -| `ENABLE_API_VERSION_HEADER` | boolean | `true` | Enable version header | -| `ENABLE_SCHEMA_VERSION_HEADER` | boolean | `true` | Enable schema header | -| `ENABLE_REQUEST_LOGGING` | boolean | `true` | Enable request logging | -| `INDEXER_JITTER_FACTOR` | number | `0.1` | Jitter factor (0-1) | -| `BACKGROUND_JOB_LOCK_TTL_MS` | number | `300000` | Job lock TTL (5 min) | -| `CREATOR_LIST_SLOW_QUERY_THRESHOLD_MS` | number | `500` | Slow query threshold | -| `INDEXER_CURSOR_STALE_AGE_WARNING_MS` | number | `300000` | Stale cursor warning (5 min) | -| `INDEXER_HEARTBEAT_STALE_THRESHOLD_MS` | number | `300000` | Heartbeat stale threshold | -| `ENABLE_INDEXER_DEDUPE` | boolean | `true` | Enable dedupe guard | -| `ENABLE_INDEXER_DLQ` | boolean | `true` | Enable indexer dead-lettering | -| `ENABLE_INDEXER_CURSOR_STALENESS_WARNING` | boolean | `true` | Warn on stale cursors | -| `STELLAR_NETWORK` | enum | `testnet` | Stellar network selection | -| `STELLAR_HORIZON_URL` | URL | testnet URL | Horizon endpoint | -| `STELLAR_SOROBAN_RPC_URL` | URL | testnet URL | Soroban RPC endpoint | -| `OWNERSHIP_SNAPSHOT_TABLE_NAME` | string | `creator_ownership_snapshots` | Snapshot table name | -| `OWNERSHIP_SNAPSHOT_CLEANUP_DRY_RUN` | boolean | `true` | Log deletes without executing | -| `OWNERSHIP_SNAPSHOT_RETENTION_DAYS` | number | `30` | Retention window in days | -| `OWNERSHIP_SNAPSHOT_CLEANUP_ENABLED` | boolean | `false` | Enable cleanup scheduler | -| `OWNERSHIP_SNAPSHOT_CLEANUP_INTERVAL_MINUTES` | number | `60` | Cleanup scheduler interval | -| `PAYSTACK_PUBLIC_KEY` | string | (optional) | Paystack public key | +| Variable | Type | Default | Description | +| --------------------------------------------- | ------- | ----------------------------- | ----------------------------- | +| `PORT` | number | `3000` | Server port | +| `MODE` | enum | `development` | Environment mode | +| `DB_QUERY_TIMEOUT_MS` | number | `5000` | Prisma query timeout in ms | +| `APP_SECRET` | string | (dev key) | Secret for signing/encryption | +| `API_VERSION` | string | `1.0.0` | API version string | +| `ENABLE_RESPONSE_TIMING` | boolean | `true` | Enable timing headers | +| `ENABLE_API_VERSION_HEADER` | boolean | `true` | Enable version header | +| `ENABLE_SCHEMA_VERSION_HEADER` | boolean | `true` | Enable schema header | +| `ENABLE_REQUEST_LOGGING` | boolean | `true` | Enable request logging | +| `INDEXER_JITTER_FACTOR` | number | `0.1` | Jitter factor (0-1) | +| `BACKGROUND_JOB_LOCK_TTL_MS` | number | `300000` | Job lock TTL (5 min) | +| `CREATOR_LIST_SLOW_QUERY_THRESHOLD_MS` | number | `500` | Slow query threshold | +| `INDEXER_CURSOR_STALE_AGE_WARNING_MS` | number | `300000` | Stale cursor warning (5 min) | +| `INDEXER_HEARTBEAT_STALE_THRESHOLD_MS` | number | `300000` | Heartbeat stale threshold | +| `ENABLE_INDEXER_DEDUPE` | boolean | `true` | Enable dedupe guard | +| `ENABLE_INDEXER_DLQ` | boolean | `true` | Enable indexer dead-lettering | +| `ENABLE_INDEXER_CURSOR_STALENESS_WARNING` | boolean | `true` | Warn on stale cursors | +| `STELLAR_NETWORK` | enum | `testnet` | Stellar network selection | +| `STELLAR_HORIZON_URL` | URL | testnet URL | Horizon endpoint | +| `STELLAR_SOROBAN_RPC_URL` | URL | testnet URL | Soroban RPC endpoint | +| `OWNERSHIP_SNAPSHOT_TABLE_NAME` | string | `creator_ownership_snapshots` | Snapshot table name | +| `OWNERSHIP_SNAPSHOT_CLEANUP_DRY_RUN` | boolean | `true` | Log deletes without executing | +| `OWNERSHIP_SNAPSHOT_RETENTION_DAYS` | number | `30` | Retention window in days | +| `OWNERSHIP_SNAPSHOT_CLEANUP_ENABLED` | boolean | `false` | Enable cleanup scheduler | +| `OWNERSHIP_SNAPSHOT_CLEANUP_INTERVAL_MINUTES` | number | `60` | Cleanup scheduler interval | +| `PAYSTACK_PUBLIC_KEY` | string | (optional) | Paystack public key | **Startup Behavior:** Uses default if not provided in environment. @@ -161,6 +161,52 @@ export const appConfig = { **Source:** Computed at startup from `envConfig` values. +## Startup Configuration Summary + +On boot, the server emits a single structured log line summarizing the loaded +runtime configuration. This lets operators confirm how the process is +configured without inspecting the environment directly. + +The summary is a **curated subset** of the configuration — the environment +context and the key feature flags — not a full environment dump. It is built by +`buildStartupConfigSummary` in [`src/utils/config-summary.utils.ts`](../src/utils/config-summary.utils.ts) +and logged by [`src/server.ts`](../src/server.ts): + +```jsonc +{ + "level": 30, + "msg": "Loaded runtime configuration summary", + "environment": { + "mode": "development", + "port": 3000, + "apiVersion": "1.0.0", + "stellarNetwork": "testnet", + "backendUrl": "http://localhost:3000", + "frontendUrl": "http://localhost:5173", + }, + "featureFlags": { + "responseTiming": true, + "apiVersionHeader": true, + "schemaVersionHeader": true, + "requestLogging": true, + "indexerDedupe": true, + "indexerDlq": true, + "indexerCursorStalenessWarning": true, + "ownershipSnapshotCleanup": false, + }, +} +``` + +**No secrets are logged.** Values flow through `maskSensitiveConfigValues` +([`src/utils/config-mask.utils.ts`](../src/utils/config-mask.utils.ts)), and +only non-sensitive keys are selected. Secrets, passwords, API keys, tokens, and +the `DATABASE_URL` credentials are never included. + +**Validate locally:** start the server (`pnpm dev`) and look for the +`Loaded runtime configuration summary` line. To add a field to the summary, +extend `buildStartupConfigSummary` and its test +(`src/utils/config-summary.utils.test.ts`). + ## Configuration by Environment ### Development diff --git a/src/server.ts b/src/server.ts index b4a4dee..eb4f647 100644 --- a/src/server.ts +++ b/src/server.ts @@ -12,7 +12,7 @@ import { import { checkOptionalDependencies } from './utils/startup.utils'; import { describeDatabasePoolConfig } from './utils/db-pool-config.utils'; import { stopOwnershipSnapshotCleanupJob } from './jobs/ownership-snapshot-cleanup.job'; -import { maskSensitiveConfigValues } from './utils/config-mask.utils'; +import { buildStartupConfigSummary } from './utils/config-summary.utils'; async function startServer() { try { @@ -42,12 +42,13 @@ async function startServer() { 'Database connection pool configured' ); - // Log startup config summary with sensitive values masked. - // See `maskSensitiveConfigValues` in utils/config-mask.utils.ts for - // the list of patterns considered sensitive. + // Emit a structured summary of the loaded runtime config: environment + // context and key feature flags. Values flow through the masking helper, + // so no secrets or credentials are logged. See + // utils/config-summary.utils.ts for the curated field selection. logger.info( - maskSensitiveConfigValues(), - 'Startup configuration summary' + buildStartupConfigSummary(), + 'Loaded runtime configuration summary' ); // Verify migrations on startup diff --git a/src/utils/config-summary.utils.test.ts b/src/utils/config-summary.utils.test.ts new file mode 100644 index 0000000..4d387c8 --- /dev/null +++ b/src/utils/config-summary.utils.test.ts @@ -0,0 +1,92 @@ +jest.mock('../config', () => ({ + envConfig: { + PORT: 3000, + MODE: 'test', + DATABASE_URL: 'postgresql://user:supersecret@localhost:5432/testdb', + GMAIL_USER: 'test@example.com', + GMAIL_APP_PASSWORD: 'my-app-password', + GOOGLE_CLIENT_ID: 'test-client-id', + GOOGLE_CLIENT_SECRET: 'test-client-secret', + BACKEND_URL: 'http://localhost:3000', + FRONTEND_URL: 'http://localhost:5173', + CLOUDINARY_CLOUD_NAME: 'test-cloud', + CLOUDINARY_API_KEY: 'test-api-key', + CLOUDINARY_API_SECRET: 'test-api-secret', + PAYSTACK_SECRET_KEY: 'sk_test_123456789', + PAYSTACK_PUBLIC_KEY: 'pk_test_123456789', + APP_SECRET: 'accesslayer_test_secret_key_32_bytes_long_xxxx', + STELLAR_NETWORK: 'testnet', + STELLAR_HORIZON_URL: 'https://horizon-testnet.stellar.org', + STELLAR_SOROBAN_RPC_URL: 'https://soroban-testnet.stellar.org', + ENABLE_RESPONSE_TIMING: true, + API_VERSION: '1.0.0', + ENABLE_API_VERSION_HEADER: true, + ENABLE_SCHEMA_VERSION_HEADER: false, + ENABLE_REQUEST_LOGGING: true, + DB_QUERY_TIMEOUT_MS: 5000, + INDEXER_JITTER_FACTOR: 0.1, + BACKGROUND_JOB_LOCK_TTL_MS: 300000, + SLOW_QUERY_THRESHOLD_MS: 500, + CREATOR_LIST_SLOW_QUERY_THRESHOLD_MS: 500, + INDEXER_CURSOR_STALE_AGE_WARNING_MS: 300000, + INDEXER_HEARTBEAT_STALE_THRESHOLD_MS: 300000, + ENABLE_INDEXER_DEDUPE: true, + ENABLE_INDEXER_DLQ: false, + ENABLE_INDEXER_CURSOR_STALENESS_WARNING: true, + OWNERSHIP_SNAPSHOT_TABLE_NAME: 'creator_ownership_snapshots', + OWNERSHIP_SNAPSHOT_CLEANUP_DRY_RUN: true, + OWNERSHIP_SNAPSHOT_RETENTION_DAYS: 30, + OWNERSHIP_SNAPSHOT_CLEANUP_ENABLED: false, + OWNERSHIP_SNAPSHOT_CLEANUP_INTERVAL_MINUTES: 60, + }, +})); + +import { buildStartupConfigSummary } from './config-summary.utils'; + +describe('buildStartupConfigSummary', () => { + it('reports the environment context', () => { + const summary = buildStartupConfigSummary(); + expect(summary.environment).toEqual({ + mode: 'test', + port: 3000, + apiVersion: '1.0.0', + stellarNetwork: 'testnet', + backendUrl: 'http://localhost:3000', + frontendUrl: 'http://localhost:5173', + }); + }); + + it('reports the key feature flags with their values', () => { + const summary = buildStartupConfigSummary(); + expect(summary.featureFlags).toEqual({ + responseTiming: true, + apiVersionHeader: true, + schemaVersionHeader: false, + requestLogging: true, + indexerDedupe: true, + indexerDlq: false, + indexerCursorStalenessWarning: true, + ownershipSnapshotCleanup: false, + }); + }); + + it('does not include any sensitive config values', () => { + const summary = buildStartupConfigSummary(); + const serialized = JSON.stringify(summary); + + expect(serialized).not.toContain('supersecret'); + expect(serialized).not.toContain('my-app-password'); + expect(serialized).not.toContain('test-client-secret'); + expect(serialized).not.toContain('test-api-secret'); + expect(serialized).not.toContain('sk_test_123456789'); + expect(serialized).not.toContain('accesslayer_test_secret_key'); + }); + + it('exposes only the environment and featureFlags groups', () => { + const summary = buildStartupConfigSummary(); + expect(Object.keys(summary).sort()).toEqual([ + 'environment', + 'featureFlags', + ]); + }); +}); diff --git a/src/utils/config-summary.utils.ts b/src/utils/config-summary.utils.ts new file mode 100644 index 0000000..ee1617c --- /dev/null +++ b/src/utils/config-summary.utils.ts @@ -0,0 +1,69 @@ +import { maskSensitiveConfigValues } from './config-mask.utils'; + +/** + * Structured summary of the loaded runtime configuration, emitted once at + * startup. The summary is deliberately a curated subset of `envConfig` rather + * than a full dump: it surfaces the environment context and the key feature + * flags an operator needs to confirm how the process is configured, without + * the noise of every tuning knob. + * + * Values are sourced through {@link maskSensitiveConfigValues}, so even though + * only non-sensitive keys are selected here, any value that flows through is + * already redacted. No secrets, passwords, keys, tokens, or connection-string + * credentials are included. + */ +export interface StartupConfigSummary { + environment: { + mode: unknown; + port: unknown; + apiVersion: unknown; + stellarNetwork: unknown; + backendUrl: unknown; + frontendUrl: unknown; + }; + featureFlags: { + responseTiming: unknown; + apiVersionHeader: unknown; + schemaVersionHeader: unknown; + requestLogging: unknown; + indexerDedupe: unknown; + indexerDlq: unknown; + indexerCursorStalenessWarning: unknown; + ownershipSnapshotCleanup: unknown; + }; +} + +/** + * Build the structured startup configuration summary. + * + * @example + * import { logger } from './utils/logger.utils'; + * import { buildStartupConfigSummary } from './utils/config-summary.utils'; + * + * logger.info(buildStartupConfigSummary(), 'Loaded runtime configuration'); + */ +export function buildStartupConfigSummary(): StartupConfigSummary { + const config = maskSensitiveConfigValues(); + + return { + environment: { + mode: config.MODE, + port: config.PORT, + apiVersion: config.API_VERSION, + stellarNetwork: config.STELLAR_NETWORK, + backendUrl: config.BACKEND_URL, + frontendUrl: config.FRONTEND_URL, + }, + featureFlags: { + responseTiming: config.ENABLE_RESPONSE_TIMING, + apiVersionHeader: config.ENABLE_API_VERSION_HEADER, + schemaVersionHeader: config.ENABLE_SCHEMA_VERSION_HEADER, + requestLogging: config.ENABLE_REQUEST_LOGGING, + indexerDedupe: config.ENABLE_INDEXER_DEDUPE, + indexerDlq: config.ENABLE_INDEXER_DLQ, + indexerCursorStalenessWarning: + config.ENABLE_INDEXER_CURSOR_STALENESS_WARNING, + ownershipSnapshotCleanup: config.OWNERSHIP_SNAPSHOT_CLEANUP_ENABLED, + }, + }; +}