diff --git a/.env.example b/.env.example index 1d8ed5ef1..c0c857a91 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,8 @@ # Local: # DATABASE_URL="postgresql://wraith:wraith@localhost:5432/wraith" # DIRECT_DATABASE_URL="postgresql://wraith:wraith@localhost:5432/wraith" +# Both database URLs are required at startup. All other values below are +# optional unless a comment describes a conditional requirement. # # Supabase (pooler for queries, direct for migrations): # DATABASE_URL="postgresql://postgres.[ref]:[password]@aws-0-[region].pooler.supabase.com:6543/postgres?pgbouncer=true&sslmode=require" @@ -151,3 +153,68 @@ LP_POOL_CONTRACT_IDS_MAINNET= # HTTP tests run against a seeded database without an ingest racing them, and # without needing live RPC. Not for production. SKIP_INDEXER=false + +# ─── Additional runtime settings ──────────────────────────────────────────── +# Every value in this section is optional. Empty values are placeholders only. + +# Prisma read replicas (comma-separated connection URLs). +DATABASE_REPLICAS= # Optional; URLs for additional read-only database replicas. +# Use "development", "test", or "production" to control Prisma logging and caching. +NODE_ENV= # Optional; runtime environment name. + +# Indexer network selection; NETWORKS overrides the single STELLAR_NETWORK default. +NETWORKS= # Optional; comma-separated networks to index (testnet,mainnet). +# Optional per-network RPC overrides and legacy alias for STELLAR_RPC_URL. +SOROBAN_RPC_URL_TESTNET= # Optional; testnet Soroban RPC endpoint. +SOROBAN_RPC_URL_MAINNET= # Required when indexing mainnet, unless a legacy RPC alias is used. +STELLAR_RPC_URL= # Optional; legacy RPC endpoint for a single-network deployment. +STELLAR_RPC_URL_TESTNET= # Optional; legacy testnet RPC endpoint. +STELLAR_RPC_URL_MAINNET= # Required when indexing mainnet unless another mainnet RPC URL is set. +# Optional Horizon endpoints and event path for indexing / price lookup. +HORIZON_URL= # Optional; legacy Horizon endpoint. +HORIZON_URL_TESTNET= # Optional; testnet Horizon endpoint. +HORIZON_URL_MAINNET= # Optional; mainnet Horizon endpoint and price lookup source. +HORIZON_EVENTS_PATH= # Optional; Horizon events API path. +# Optional per-network start ledger overrides; blank resumes from database state. +START_LEDGER_TESTNET= # Optional; testnet ledger to start from. +START_LEDGER_MAINNET= # Optional; mainnet ledger to start from. + +# Optional rate limits (defaults: 60 seconds and 60 requests). +RATE_LIMIT_WINDOW_MS=60000 # Optional; rate-limit window in milliseconds. +RATE_LIMIT_MAX=60 # Optional; requests allowed per IP in each window. +# Optional maximum number of rows returned by transfer exports (default: 50000). +EXPORT_MAX_ROWS=50000 # Optional; maximum rows per export. +# Optional interval between partition-retention runs (default: six hours). +PARTITION_RETENTION_INTERVAL_MS=21600000 # Optional; retention job interval in milliseconds. +# Optional persisted GraphQL query file path and query complexity limits. +PERSISTED_QUERIES_PATH= # Optional; persisted GraphQL query file. +GRAPHQL_MAX_DEPTH=10 # Optional; GraphQL query depth limit. +GRAPHQL_MAX_COST=1000 # Optional; GraphQL query complexity limit. +# npm supplies this automatically when the service is launched by npm. +# npm_package_version=1.0.0 + +# Optional OPA authorization settings (defaults to localhost:8181 and wraith/authz). +OPA_URL=http://localhost:8181 # Optional; Open Policy Agent URL. +OPA_POLICY=wraith/authz # Optional; OPA policy path. +# Optional mainnet CoinGecko/Horizon price lookup endpoint. +# HORIZON_URL_MAINNET is also used by the indexer; leave blank to use its code default. + +# Optional LINQ integration. Keep actual credentials in deployment secrets, never here. +LINQ_API_KEY= # Optional; secret API key for LINQ integrations. +LINQ_BASE_URL= # Optional; LINQ API base URL. +LINQ_WEBHOOK_SECRET= # Optional; secret used to verify LINQ webhook requests. +OFFRAMP_LOOKUP_WINDOW_MS=900000 # Optional; off-ramp lookup rate-limit window. +OFFRAMP_LOOKUP_MAX_FAILURES=10 # Optional; failed lookups allowed per window. + +# Optional platform fee configuration. Fees are disabled unless a valid destination +# and asset configuration are supplied. +VEIL_FEE_DESTINATION= # Optional; fee recipient account (required to enable fees). +VEIL_FEE_PERCENT= # Optional; fee rate percentage. +VEIL_FEE_AMOUNT= # Optional; flat fee for the configured issued asset. +VEIL_FEE_MIN= # Optional; minimum fee for the configured issued asset. +VEIL_FEE_MAX= # Optional; maximum fee for the configured issued asset. +VEIL_FEE_ASSET_CODE=USDC # Optional; issued asset code (default: USDC). +VEIL_FEE_ASSET_ISSUER= # Optional; issued asset issuer account. +VEIL_FEE_AMOUNT_XLM= # Optional; flat fee in native XLM. +VEIL_FEE_MIN_XLM= # Optional; minimum fee in native XLM. +VEIL_FEE_MAX_XLM= # Optional; maximum fee in native XLM. diff --git a/src/__tests__/envValidation.test.ts b/src/__tests__/envValidation.test.ts new file mode 100644 index 000000000..a31fb966e --- /dev/null +++ b/src/__tests__/envValidation.test.ts @@ -0,0 +1,95 @@ +import { readFileSync, readdirSync } from "fs"; +import path from "path"; +import { validateEnv } from "../validateEnv"; + +const sourceRoot = path.resolve(__dirname, ".."); +const repositoryRoot = path.resolve(sourceRoot, ".."); + +function sourceFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const fullPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + return entry.name === "__tests__" ? [] : sourceFiles(fullPath); + } + return entry.name.endsWith(".ts") && !entry.name.endsWith(".test.ts") + ? [fullPath] + : []; + }); +} + +function environmentReads(): Set { + const names = new Set(); + const staticRead = /\b(?:process\.)?env\.([A-Z][A-Z0-9_]*)|\bprocess\.env\[\s*["']([A-Z][A-Z0-9_]*)["']\s*\]/g; + const dynamicNetworkRead = /\bprocess\.env\[\s*`([A-Z][A-Z0-9_]*)\$\{/g; + const namedRead = /readPositiveInt\(\s*["']([A-Z][A-Z0-9_]*)["']/g; + + for (const file of sourceFiles(sourceRoot)) { + const source = readFileSync(file, "utf8"); + for (const match of source.matchAll(staticRead)) { + names.add(match[1] ?? match[2]); + } + for (const match of source.matchAll(dynamicNetworkRead)) { + names.add(`${match[1]}TESTNET`); + names.add(`${match[1]}MAINNET`); + } + for (const match of source.matchAll(namedRead)) { + names.add(match[1]); + } + } + + return names; +} + +describe("environment configuration", () => { + it("documents every environment variable read by runtime source", () => { + const example = readFileSync(path.join(repositoryRoot, ".env.example"), "utf8"); + const documented = new Set( + [...example.matchAll(/^\s*#?\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/gm)].map( + (match) => match[1], + ), + ); + const undocumented = [...environmentReads()].filter((name) => !documented.has(name)).sort(); + + expect(undocumented).toEqual([]); + }); + + it("reports all missing required variables together", () => { + expect(() => validateEnv({ STELLAR_NETWORK: "mainnet" })).toThrow( + "Missing required env vars: DATABASE_URL, DIRECT_DATABASE_URL, SOROBAN_RPC_URL_MAINNET", + ); + }); + + it("accepts the legacy RPC URL for a single-network mainnet deployment", () => { + expect(() => + validateEnv({ + DATABASE_URL: "postgresql://localhost/wraith", + DIRECT_DATABASE_URL: "postgresql://localhost/wraith", + STELLAR_NETWORK: "mainnet", + SOROBAN_RPC_URL: "https://rpc.example.invalid", + }), + ).not.toThrow(); + }); + + it("requires a network-specific RPC URL when indexing multiple networks", () => { + expect(() => + validateEnv({ + DATABASE_URL: "postgresql://localhost/wraith", + DIRECT_DATABASE_URL: "postgresql://localhost/wraith", + STELLAR_NETWORK: "testnet", + NETWORKS: "testnet,mainnet", + SOROBAN_RPC_URL: "https://rpc.example.invalid", + }), + ).toThrow(/SOROBAN_RPC_URL_MAINNET/); + }); + + it("does not require RPC when the indexer is disabled", () => { + expect(() => + validateEnv({ + DATABASE_URL: "postgresql://localhost/wraith", + DIRECT_DATABASE_URL: "postgresql://localhost/wraith", + STELLAR_NETWORK: "mainnet", + SKIP_INDEXER: "true", + }), + ).not.toThrow(); + }); +}); \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 5a73c0df2..3c919ee29 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,16 +1,22 @@ import "dotenv/config"; -import http from "http"; -import { execSync } from "child_process"; -import { createApp } from "./api"; -import { startAllIndexers } from "./indexer"; -import { prisma } from "./db"; -import { attachWebSocketServer } from "./ws"; -import { attachGraphQLSubscriptions, SUBSCRIPTIONS_PATH } from "./graphql/subscriptions"; -import { startWebhookWorker } from "./workers/webhooks"; -import { startPartitionRetentionJob } from "./jobs/retention"; -import { initTokenCache } from "./tokenCache"; -import { enabledNetworks } from "./network"; -import { onlyAddsUniqueConstraints, pushSchema } from "./schemaGuard"; +import { validateEnv } from "./validateEnv"; + +validateEnv(); + +const http = require("http") as typeof import("http"); +const { createApp } = require("./api") as typeof import("./api"); +const { startAllIndexers } = require("./indexer") as typeof import("./indexer"); +const { prisma } = require("./db") as typeof import("./db"); +const { attachWebSocketServer } = require("./ws") as typeof import("./ws"); +const { + attachGraphQLSubscriptions, + SUBSCRIPTIONS_PATH, +} = require("./graphql/subscriptions") as typeof import("./graphql/subscriptions"); +const { startWebhookWorker } = require("./workers/webhooks") as typeof import("./workers/webhooks"); +const { startPartitionRetentionJob } = require("./jobs/retention") as typeof import("./jobs/retention"); +const { initTokenCache } = require("./tokenCache") as typeof import("./tokenCache"); +const { enabledNetworks } = require("./network") as typeof import("./network"); +const { onlyAddsUniqueConstraints, pushSchema } = require("./schemaGuard") as typeof import("./schemaGuard"); const PORT = parseInt(process.env.PORT ?? "3000", 10); diff --git a/src/validateEnv.ts b/src/validateEnv.ts new file mode 100644 index 000000000..979d54516 --- /dev/null +++ b/src/validateEnv.ts @@ -0,0 +1,34 @@ +/** Validate startup configuration before the service opens its listeners. */ +export function validateEnv(env: NodeJS.ProcessEnv = process.env): void { + const missing = ["DATABASE_URL", "DIRECT_DATABASE_URL"].filter( + (name) => !env[name]?.trim(), + ); + + const configuredNetwork = env.STELLAR_NETWORK?.trim().toLowerCase(); + const defaultNetwork = configuredNetwork === "mainnet" ? "mainnet" : "testnet"; + const networks = (env.NETWORKS ?? "") + .split(",") + .map((network) => network.trim().toLowerCase()) + .filter((network): network is "testnet" | "mainnet" => + network === "testnet" || network === "mainnet", + ); + const enabledNetworks = networks.length > 0 ? [...new Set(networks)] : [defaultNetwork]; + + if (env.SKIP_INDEXER !== "true" && enabledNetworks.includes("mainnet")) { + const hasMainnetRpc = + env.SOROBAN_RPC_URL_MAINNET?.trim() || + env.STELLAR_RPC_URL_MAINNET?.trim() || + (defaultNetwork === "mainnet" && + (env.SOROBAN_RPC_URL?.trim() || env.STELLAR_RPC_URL?.trim())); + + if (!hasMainnetRpc) { + missing.push( + "SOROBAN_RPC_URL_MAINNET (or STELLAR_RPC_URL_MAINNET; single-network deployments may use SOROBAN_RPC_URL or STELLAR_RPC_URL)", + ); + } + } + + if (missing.length > 0) { + throw new Error(`[wraith] Missing required env vars: ${missing.join(", ")}`); + } +} \ No newline at end of file