From 033907ed066f190a18efe17a3c26d9910fabad25 Mon Sep 17 00:00:00 2001 From: Hamfit Date: Thu, 30 Jul 2026 07:08:03 +0100 Subject: [PATCH 1/2] docs: update .env.example with all referenced backend environment variables --- backend/.env.example | 114 +++++++++++++++++++++++++++++++------------ 1 file changed, 82 insertions(+), 32 deletions(-) diff --git a/backend/.env.example b/backend/.env.example index 5035a28b..968d01a5 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,7 +1,22 @@ -# Database +# ─── Server ─────────────────────────────────────────────────────────────────── +# Port the Express server listens on (default: 3001) +PORT=3001 + +# Application environment mode ('development', 'production', or 'test') +NODE_ENV=development + +# Comma-separated list of allowed origins for CORS. In development, if unset, +# defaults to http://localhost:3000 +CORS_ALLOWED_ORIGINS="https://app.flowfi.xyz,https://flowfi.xyz" + +# Optional base URL for Swagger OpenAPI documentation server (e.g. http://localhost:3001) +API_BASE_URL=http://localhost:3001 + +# ─── Database ───────────────────────────────────────────────────────────────── +# Main PostgreSQL database connection URL DATABASE_URL="postgresql://user:password@localhost:5433/flowfi?schema=public" -# PostgreSQL pool settings +# PostgreSQL pool settings: # Maximum database connections per backend process (default: 10) PG_POOL_MAX=10 # How long an idle connection stays open before being closed (milliseconds, default: 30000) @@ -11,51 +26,58 @@ PG_CONNECTION_TIMEOUT_MS=5000 # Maximum time a PostgreSQL statement may run before cancellation (milliseconds, default: 30000) PG_STATEMENT_TIMEOUT_MS=30000 -# Server -PORT=3001 -NODE_ENV=development -CORS_ALLOWED_ORIGINS="https://app.flowfi.xyz,https://flowfi.xyz" -# Comma-separated list of allowed origins for CORS. In development, if unset, -# defaults to http://localhost:3000 - -# Stellar Network (Testnet/Mainnet) +# ─── Stellar & Soroban ──────────────────────────────────────────────────────── +# Stellar network target ('testnet' or 'mainnet') STELLAR_NETWORK=testnet -STELLAR_HORIZON_URL=https://horizon-testnet.stellar.org - -# Enable sandbox mode -SANDBOX_MODE_ENABLED=true -# Optional: Use a separate database for sandbox -# If not set, it will use {DATABASE_URL}_sandbox -SANDBOX_DATABASE_URL="postgresql://user:password@localhost:5433/flowfi_sandbox?schema=public" +# Stellar Horizon REST API server endpoint +STELLAR_HORIZON_URL=https://horizon-testnet.stellar.org -# ─── Soroban Event Indexer ──────────────────────────────────────────────────── -# Soroban RPC endpoint (testnet default shown) +# Soroban RPC endpoint URL (testnet default shown) SOROBAN_RPC_URL=https://soroban-testnet.stellar.org -# Deployed FlowFi stream contract address (C...) -# Leave empty to disable the event indexer +# Deployed FlowFi stream contract address (C...). Leave empty to disable indexers STREAM_CONTRACT_ID= -# How often the indexer polls for new events (milliseconds, default: 5000) +# Server-side Stellar secret key (S...) used to sign and submit on-chain transactions +# (cancel_stream, top_up_stream). Must be funded on the target network. +KEEPER_SECRET_KEY= + +# Max stream creation requests per wallet per minute (default: 10) +STREAM_CREATE_RATE_LIMIT=10 + +# Timeout for Soroban RPC requests in milliseconds (default: 10000) +SOROBAN_RPC_TIMEOUT_MS=10000 + +# Maximum retry attempts for failed Soroban RPC requests (default: 2) +SOROBAN_RPC_MAX_RETRIES=2 + +# Base delay in milliseconds for exponential backoff during Soroban RPC retries (default: 250) +SOROBAN_RPC_RETRY_BASE_MS=250 + +# ─── Soroban Event Indexer ──────────────────────────────────────────────────── +# Polling interval in milliseconds for the background Soroban event worker (default: 5000) INDEXER_POLL_INTERVAL_MS=5000 -# Ledger sequence to start indexing from on first run (0 = latest) +# Ledger sequence for event worker to start indexing from on first run (0 = latest, default: 0) INDEXER_START_LEDGER=0 -# Max stream creation requests per wallet per minute (default: 10) -STREAM_CREATE_RATE_LIMIT=10 +# Polling interval in milliseconds for Soroban indexer service (default: 15000) +SOROBAN_INDEXER_POLL_MS=15000 -# Server-side Stellar secret key used to sign and submit on-chain transactions -# (cancel_stream, top_up_stream). Must be funded on the target network. -KEEPER_SECRET_KEY= +# Ledger sequence to start indexing from for Soroban indexer service (default: 0) +SOROBAN_INDEXER_START_LEDGER=0 # ─── Auth ───────────────────────────────────────────────────────────────────── -# Secret used to sign JWTs (generate with: openssl rand -hex 32) +# Secret used to sign JWTs (generate with: openssl rand -hex 32). Required in production. JWT_SECRET= -# Stellar public key of the admin user (for /v1/admin/* endpoints) +# Stellar public key (G...) of the admin user (for /v1/admin/* endpoints) ADMIN_PUBLIC_KEY= + +# Interval in milliseconds to sweep and prune expired auth challenges (default: 300000) +AUTH_CHALLENGE_SWEEP_INTERVAL_MS=300000 + # ─── Redis (optional) ──────────────────────────────────────────────────────── # When set, enables horizontal SSE scaling via Redis pub/sub so events # emitted by any backend instance are broadcast to clients on all instances. @@ -63,8 +85,36 @@ ADMIN_PUBLIC_KEY= REDIS_URL= # ─── Caching (optional) ─────────────────────────────────────────────────────── -# Time in milliseconds between periodic sweeps to prune expired memory cache -# entries (default: 60000) +# Time in milliseconds between periodic sweeps to prune expired memory cache entries (default: 60000) MEMORY_CACHE_SWEEP_MS=60000 +# Cache time-to-live (TTL) for claimable amount calculations in milliseconds (default: 5000) +CLAIMABLE_CACHE_TTL_MS=5000 + +# ─── Logging ────────────────────────────────────────────────────────────────── +# Log output verbosity level ('error', 'warn', 'info', 'debug', default: info) +LOG_LEVEL=info + +# ─── Streaming & Server-Sent Events (SSE) ───────────────────────────────────── +# Maximum concurrent SSE client connections allowed (default: 10000) +MAX_SSE_CONNECTIONS=10000 + +# ─── Sandbox Mode ───────────────────────────────────────────────────────────── +# Enable or disable sandbox mode ('true' or 'false', default: true) +SANDBOX_MODE_ENABLED=true + +# Optional: Separate PostgreSQL connection URL for sandbox mode. +# If not set, defaults to {DATABASE_URL}_sandbox +SANDBOX_DATABASE_URL="postgresql://user:password@localhost:5433/flowfi_sandbox?schema=public" + +# Whether sandbox mode can be triggered via request headers ('true' or 'false', default: true) +SANDBOX_ALLOW_HEADER=true + +# Whether sandbox mode can be triggered via query parameters ('true' or 'false', default: true) +SANDBOX_ALLOW_QUERY_PARAM=true + +# Header name used to trigger sandbox mode (default: X-Sandbox-Mode) +SANDBOX_HEADER_NAME=X-Sandbox-Mode +# Query parameter name used to trigger sandbox mode (default: sandbox) +SANDBOX_QUERY_PARAM_NAME=sandbox From ee9cbae94c89cf20e8ef9c02f038cdcbdd6d71dd Mon Sep 17 00:00:00 2001 From: Hamfit Date: Thu, 30 Jul 2026 07:37:34 +0100 Subject: [PATCH 2/2] feat(sandbox): add strict boolean validation for sandbox environment variables --- backend/src/config/sandbox.ts | 38 +++++++++- backend/tests/sandbox.config.test.ts | 108 +++++++++++++++++++++++++++ 2 files changed, 143 insertions(+), 3 deletions(-) create mode 100644 backend/tests/sandbox.config.test.ts diff --git a/backend/src/config/sandbox.ts b/backend/src/config/sandbox.ts index b1903996..db9a3169 100644 --- a/backend/src/config/sandbox.ts +++ b/backend/src/config/sandbox.ts @@ -14,17 +14,48 @@ export interface SandboxConfig { queryParamName: string; } +/** + * Helper function to strictly validate boolean environment variables. + * + * Accepted values: + * - 'true': Enables the setting + * - 'false': Disables the setting + * - undefined: Omitted; uses the specified default value + * + * Invalid values (e.g. 'TRUE', 'yes', '1', '', etc.) throw a configuration error. + */ +function parseBooleanEnv(varName: string, value: string | undefined, defaultValue: boolean): boolean { + if (value === undefined) { + return defaultValue; + } + + if (value === 'true') { + return true; + } + + if (value === 'false') { + return false; + } + + throw new Error(`${varName} has invalid value '${value}'. Expected one of: 'true', 'false'.`); +} + /** * Get sandbox configuration from environment variables + * + * Accepted values for boolean environment variables: + * - SANDBOX_MODE_ENABLED: 'true' | 'false' (default: false) + * - SANDBOX_ALLOW_HEADER: 'true' | 'false' (default: true) + * - SANDBOX_ALLOW_QUERY_PARAM: 'true' | 'false' (default: true) */ export function getSandboxConfig(): SandboxConfig { - const enabled = process.env.SANDBOX_MODE_ENABLED === 'true'; + const enabled = parseBooleanEnv('SANDBOX_MODE_ENABLED', process.env.SANDBOX_MODE_ENABLED, false); const databaseUrl = process.env.SANDBOX_DATABASE_URL; const config: SandboxConfig = { enabled, - allowHeader: process.env.SANDBOX_ALLOW_HEADER !== 'false', // Default: true - allowQueryParam: process.env.SANDBOX_ALLOW_QUERY_PARAM !== 'false', // Default: true + allowHeader: parseBooleanEnv('SANDBOX_ALLOW_HEADER', process.env.SANDBOX_ALLOW_HEADER, true), + allowQueryParam: parseBooleanEnv('SANDBOX_ALLOW_QUERY_PARAM', process.env.SANDBOX_ALLOW_QUERY_PARAM, true), headerName: process.env.SANDBOX_HEADER_NAME || 'X-Sandbox-Mode', queryParamName: process.env.SANDBOX_QUERY_PARAM_NAME || 'sandbox', }; @@ -42,3 +73,4 @@ export function getSandboxConfig(): SandboxConfig { export function isSandboxModeEnabled(): boolean { return getSandboxConfig().enabled; } + diff --git a/backend/tests/sandbox.config.test.ts b/backend/tests/sandbox.config.test.ts new file mode 100644 index 00000000..7d460f66 --- /dev/null +++ b/backend/tests/sandbox.config.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { getSandboxConfig, isSandboxModeEnabled } from '../src/config/sandbox.js'; + +describe('Sandbox Configuration Parsing', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + delete process.env.SANDBOX_MODE_ENABLED; + delete process.env.SANDBOX_DATABASE_URL; + delete process.env.SANDBOX_ALLOW_HEADER; + delete process.env.SANDBOX_ALLOW_QUERY_PARAM; + delete process.env.SANDBOX_HEADER_NAME; + delete process.env.SANDBOX_QUERY_PARAM_NAME; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe('Valid values', () => { + it('should parse valid enabled and disabled values for SANDBOX_MODE_ENABLED', () => { + process.env.SANDBOX_MODE_ENABLED = 'true'; + expect(getSandboxConfig().enabled).toBe(true); + + process.env.SANDBOX_MODE_ENABLED = 'false'; + expect(getSandboxConfig().enabled).toBe(false); + }); + + it('should default to disabled when SANDBOX_MODE_ENABLED is unset', () => { + delete process.env.SANDBOX_MODE_ENABLED; + expect(getSandboxConfig().enabled).toBe(false); + }); + + it('should parse valid values for SANDBOX_ALLOW_HEADER', () => { + process.env.SANDBOX_ALLOW_HEADER = 'true'; + expect(getSandboxConfig().allowHeader).toBe(true); + + process.env.SANDBOX_ALLOW_HEADER = 'false'; + expect(getSandboxConfig().allowHeader).toBe(false); + }); + + it('should default to allowHeader: true when SANDBOX_ALLOW_HEADER is unset', () => { + delete process.env.SANDBOX_ALLOW_HEADER; + expect(getSandboxConfig().allowHeader).toBe(true); + }); + + it('should parse valid values for SANDBOX_ALLOW_QUERY_PARAM', () => { + process.env.SANDBOX_ALLOW_QUERY_PARAM = 'true'; + expect(getSandboxConfig().allowQueryParam).toBe(true); + + process.env.SANDBOX_ALLOW_QUERY_PARAM = 'false'; + expect(getSandboxConfig().allowQueryParam).toBe(false); + }); + + it('should default to allowQueryParam: true when SANDBOX_ALLOW_QUERY_PARAM is unset', () => { + delete process.env.SANDBOX_ALLOW_QUERY_PARAM; + expect(getSandboxConfig().allowQueryParam).toBe(true); + }); + + it('should correctly evaluate isSandboxModeEnabled()', () => { + process.env.SANDBOX_MODE_ENABLED = 'true'; + expect(isSandboxModeEnabled()).toBe(true); + + process.env.SANDBOX_MODE_ENABLED = 'false'; + expect(isSandboxModeEnabled()).toBe(false); + }); + }); + + describe('Invalid values', () => { + const invalidValues = [ + 'TRUE', + 'False', + 'True', + 'yes', + 'no', + '1', + '0', + 'enabled', + 'disabled', + '', + 'random text', + ]; + + invalidValues.forEach((invalidValue) => { + it(`should throw configuration error for SANDBOX_MODE_ENABLED='${invalidValue}'`, () => { + process.env.SANDBOX_MODE_ENABLED = invalidValue; + expect(() => getSandboxConfig()).toThrowError( + `SANDBOX_MODE_ENABLED has invalid value '${invalidValue}'. Expected one of: 'true', 'false'.` + ); + }); + + it(`should throw configuration error for SANDBOX_ALLOW_HEADER='${invalidValue}'`, () => { + process.env.SANDBOX_ALLOW_HEADER = invalidValue; + expect(() => getSandboxConfig()).toThrowError( + `SANDBOX_ALLOW_HEADER has invalid value '${invalidValue}'. Expected one of: 'true', 'false'.` + ); + }); + + it(`should throw configuration error for SANDBOX_ALLOW_QUERY_PARAM='${invalidValue}'`, () => { + process.env.SANDBOX_ALLOW_QUERY_PARAM = invalidValue; + expect(() => getSandboxConfig()).toThrowError( + `SANDBOX_ALLOW_QUERY_PARAM has invalid value '${invalidValue}'. Expected one of: 'true', 'false'.` + ); + }); + }); + }); +});