diff --git a/.env.example b/.env.example index de61714..75d0226 100644 --- a/.env.example +++ b/.env.example @@ -215,6 +215,11 @@ LOG_SERVICE_NAME=vortex-backend # Sentry DSN for error reporting. Leave blank to disable Sentry entirely. SENTRY_DSN= +# Bearer token for GET /metrics (issue #298). +# Empty = unauthenticated in dev/test; production always requires a value. +# Generate with: openssl rand -hex 32 +METRICS_TOKEN= + # Optional log shipping to a central collector. Off by default so local dev and # CI stay stdout-only. When enabled, LOG_SHIPPING_HOST is required. LOG_SHIPPING_ENABLED=false diff --git a/.env.mainnet.example b/.env.mainnet.example index 455f649..f284d45 100644 --- a/.env.mainnet.example +++ b/.env.mainnet.example @@ -130,6 +130,13 @@ KILLSWITCH_PERSISTENCE=prisma # Recommended in production: set to your Sentry project DSN. SENTRY_DSN= +# Bearer token for GET /metrics (issue #298). REQUIRED in production. +# Without this the endpoint returns 401 (fail-closed). Generate with: +# openssl rand -hex 32 +# Configure your Prometheus scrape job with: +# authorization: { type: Bearer, credentials: } +METRICS_TOKEN= + # info is the right level for production — "debug" is too noisy. LOG_LEVEL=info diff --git a/.env.testnet.example b/.env.testnet.example index e69de29..cd11514 100644 --- a/.env.testnet.example +++ b/.env.testnet.example @@ -0,0 +1,267 @@ +# .env.testnet.example +# +# Environment template for LOCAL DEVELOPMENT against Stellar TESTNET. +# Copy to .env and fill in any values marked with . +# +# cp .env.testnet.example .env +# +# Testnet is safe to experiment with — tokens have no real value and contract +# deployments are free via Friendbot. Never reuse testnet keys on mainnet. +# +# Closes #136 + +# ─── Database ──────────────────────────────────────────────────────────────── +# Local Docker Compose default. Adjust if you use a remote or managed DB. +DATABASE_URL=postgresql://vortex:vortex@localhost:5432/vortex?schema=public + +# ─── Server ────────────────────────────────────────────────────────────────── +PORT=4000 +NODE_ENV=development + +# ─── Stellar / Soroban ─────────────────────────────────────────────────────── +STELLAR_NETWORK=testnet +INTENTS_PERSISTENCE=prisma +ETHEREUM_RPC_URL= +ETHEREUM_ESCROW_ADDRESS= +BASE_RPC_URL= +BASE_ESCROW_ADDRESS= +POLYGON_RPC_URL= +POLYGON_ESCROW_ADDRESS= +ARBITRUM_RPC_URL= +ARBITRUM_ESCROW_ADDRESS= +OPTIMISM_RPC_URL= +OPTIMISM_ESCROW_ADDRESS= +AVALANCHE_RPC_URL= +AVALANCHE_ESCROW_ADDRESS= +EVM_RPC_ALLOWLIST= +ALLOW_LEGACY_STELLAR_SIGNATURES=false +SOROBAN_RPC_URL=https://soroban-testnet.stellar.org + +# Testnet contract IDs — leave blank until you have deployed contracts. +# The service boots without them; on-chain write paths are no-ops when empty. +SETTLEMENT_CONTRACT_ID= +SOLVER_REGISTRY_CONTRACT_ID= + +# Testnet signing key — generate a throwaway keypair, fund it with Friendbot, +# and paste the secret seed here. Never reuse this key on mainnet. +# +# # Generate a new key: +# npx @stellar/stellar-cli keys generate local-dev --network testnet +# npx @stellar/stellar-cli keys show local-dev +# +# # Or via the SDK: +# node -e "console.log(require('@stellar/stellar-sdk').Keypair.random().secret())" +# +# # Fund it (testnet only): +# curl "https://friendbot.stellar.org/?addr=" +# +# Optional in development — leave blank to skip on-chain writes. +SOROBAN_SIGNING_KEY= + +# Fee percentile used when estimating Soroban inclusion fees. +# p50 is a safe default for testnet; raise to p90+ for time-sensitive mainnet txs. +SOROBAN_FEE_PERCENTILE=p50 + +# ─── CORS ──────────────────────────────────────────────────────────────────── +# Wildcard is fine for local development — tighten this in staging/production. +CORS_ORIGIN=* + +# ─── WebSocket ─────────────────────────────────────────────────────────────── +WS_MAX_CONNECTIONS=1000 + +# ─── Pluggable signer backend (issue #400) ─────────────────────────────────── +# SIGNER_BACKEND=local is the default for development. +# In production use SIGNER_BACKEND=vault and supply VAULT_ADDR + VAULT_TOKEN. +SIGNER_BACKEND=local +VAULT_ADDR= +VAULT_TOKEN= +VAULT_TRANSIT_KEY_NAME=vortex-signer +ALLOW_LOCAL_SIGNER_IN_PROD=false +# ─── Resource-exhaustion limits (issue #476) ───────────────────────────────── +# Maximum JSON nesting depth — rejects deeply-nested body attacks (default 10). +JSON_MAX_DEPTH=10 +# Maximum chain values in a single WS subscribe message (default 20). +WS_MAX_FILTER_CHAINS=20 +# Maximum active subscriptions per WS connection (default 10). +WS_MAX_SUBSCRIPTIONS=10 +# Postgres statement_timeout for standard queries in ms (default 5000). +DB_QUERY_TIMEOUT_MS=5000 +# Postgres statement_timeout for batch queries in ms (default 10000). +DB_BATCH_QUERY_TIMEOUT_MS=10000 +# Postgres statement_timeout for stats queries in ms (default 15000). +DB_STATS_QUERY_TIMEOUT_MS=15000 + +# Emergency kill-switch (issue #477) +# Postgres-backed so a pause survives a restart and reaches every replica. +KILLSWITCH_OPERATOR_TOKEN= +KILLSWITCH_REDIS_URL= +KILLSWITCH_POLL_MS=2000 +KILLSWITCH_PERSISTENCE=prisma + +# ─── Observability (optional) ──────────────────────────────────────────────── +# Leave blank to disable Sentry error reporting. +SENTRY_DSN= + +# Bearer token for GET /metrics (issue #298). +# Leave blank for unauthenticated local Prometheus scraping. +# In production: generate with `openssl rand -hex 32` and set a real value. +METRICS_TOKEN= + +# debug | info | warn | error (defaults to "debug" in development) +LOG_LEVEL=debug + +# ── Shadow-mode divergence monitor (issue #401) ───────────────────────── +# Off by default in every environment. It runs read-only `simulateTransaction` +# calls against SETTLEMENT_CONTRACT_ID in parallel with the off-chain intent +# path and never signs or submits anything. +# +# SHADOW_SOURCE_ACCOUNT only has to be a valid Stellar public key: it is used to +# populate the source-account field of the simulated envelope and is never +# signed, never charged a fee and never broadcast. It must still be set, or +# every transition reports "contract_unconfigured". +SHADOW_MODE_ENABLED=false +SHADOW_SAMPLE_RATE=1 +SHADOW_QUEUE_MAX=256 +SHADOW_CONCURRENCY=4 +SHADOW_SOURCE_ACCOUNT= +# ─── Governance / Protocol Parameters ──────────────────────────────────────── +# On-chain governance parameters contract ID — leave blank to use code defaults. +PARAMS_CONTRACT_ID= + +# Poll interval in ms. 30 000 is fine for testnet. +PARAMS_POLL_INTERVAL_MS=30000 +# ─── Leader election ───────────────────────────────────────────────────────── +# Enable for multi-replica testnet deployments. +LEADER_ELECTION_ENABLED=false +LEADER_ELECTION_HEARTBEAT_MS=5000 + +# ─── Background jobs (issue #494) ──────────────────────────────────────────── +# api | worker | all — queue workers only run in "worker" or "all". +PROCESS_ROLE=all +# memory (single-process, dev/test) | bullmq (Redis-backed, uses REDIS_URL) +JOBS_DRIVER=memory +# Grace period for in-flight jobs on SIGTERM before they are returned to the queue. +JOBS_SHUTDOWN_TIMEOUT_MS=25000 + +# ─── Runtime feature flags (issue #495) ────────────────────────────────────── +# Change propagation across instances: memory (single instance) | redis +FLAGS_PUBSUB=memory +# Safety-net cache reload interval (ms) +FLAGS_REFRESH_MS=30000 +# Break-glass pins that win over DB state, e.g. onchain-dry-run=true +FLAG_OVERRIDES= + +# ─── Admin RBAC ────────────────────────────────────────────────────────────── +# Comma-separated id:role:secret (role = admin | superadmin, secret >= 16 chars). +# Sent as the x-admin-key header (the secret part). Empty disables admin APIs. +ADMIN_API_KEYS= + +# ─── Guardian emergency ingestion (issue #507) ─────────────────────────────── +# Guardian / security-council contract ID. Leave blank to disable ingestion. +GUARDIAN_CONTRACT_ID= + +# ─── Synthetic canary (issue #496) ─────────────────────────────────────────── +# Canary user + solver addresses; excluded from public stats and leaderboards. +CANARY_ADDRESSES= + +# Public anonymised datasets (docs/rfcs/0001) +# Master switch for the public dataset publication job. +DATASETS_ENABLED=false +# Hash user addresses with the rotating salt before export. +DATASETS_ANONYMIZE=true +# Base anonymisation salt. Required (>= 32 chars) when datasets are enabled +# and anonymisation is on; generate with `openssl rand -hex 32`. +DATASETS_SALT= +# How often the anonymisation salt rotates, in hours. +DATASETS_SALT_ROTATION_HOURS=24 +# How many previous salt windows are retained for continuity. +DATASETS_SALT_RETENTION_WINDOWS=2 +# Public bucket/prefix the published datasets live under. +DATASETS_PUBLIC_BUCKET=vortex-public-datasets +# Storage backend: local (writes to disk) | memory (tests only). +DATASETS_STORAGE=local +# Root directory for the local storage backend. +DATASETS_LOCAL_DIR=.datasets + +# Secrets Manager (issue #465) +# Provider: env | aws-secrets-manager | vault-kv +SECRETS_PROVIDER=env +# Poll interval for secret rotation (ms) +SECRETS_REFRESH_INTERVAL_MS=60000 +# Extra secrets: comma-separated "name:envVar:required" +SECRETS_EXTRA= + +# AWS Secrets Manager +AWS_SECRETS_MANAGER_PREFIX= +AWS_SECRETS_MANAGER_POLL_INTERVAL_MS=60000 + +# Vault KV +VAULT_KV_MOUNT=secret +VAULT_KV_PREFIX=vortex/ +VAULT_KV_POLL_INTERVAL_MS=60000 + +# Extra secret env vars referenced by the default SecretConfig +JWT_SIGNING_KEY= +WEBHOOK_SECRET= +CHANNEL_KEY= + +# Egress/SSRF Protection +EGRESS_TIMEOUT_MS=10000 +EGRESS_MAX_REDIRECTS=3 +EGRESS_MAX_BODY_SIZE_BYTES=10485760 +SOROBAN_RPC_ALLOWLIST=soroban-testnet.stellar.org,soroban-rpc.stellar.org +WEBHOOK_ALLOWLIST=hooks.example.com,hooks.trusted.com +ORACLE_ALLOWLIST=oracle.trusted.io +# ─── WS gateway hardening (issue #455) ─────────────────────────────────────── +# Inbound frames larger than this close the socket (1009). +WS_MAX_PAYLOAD_BYTES=16384 +# Concurrent WS connections per client IP (0 = unlimited). +WS_MAX_CONNECTIONS_PER_IP=20 +# Trusted reverse-proxy hops for X-Forwarded-For (0 = socket address only). +WS_TRUST_PROXY_HOPS=0 +# Inbound token bucket per connection; repeat violators are disconnected. +WS_RATE_LIMIT_PER_SEC=10 +WS_RATE_LIMIT_BURST=20 +WS_RATE_LIMIT_MAX_VIOLATIONS=5 +# Outbound backpressure: messages held per slow consumer, socket buffer +# threshold (bytes), and what to do when the queue is full. +WS_OUTBOUND_QUEUE_MAX=1000 +WS_OUTBOUND_BUFFER_BYTES=1048576 +WS_SLOW_CONSUMER_POLICY=drop_oldest +# HS256 secret for solver JWTs from the SEP-10 auth flow (#442); >= 32 chars. +# Empty disables JWT auth on the WS gateway. +AUTH_JWT_SECRET= + +# ─── API keys & distributed rate limiting (issue #441) ───────────────────────── +RATE_LIMIT_LOCAL_PRUNE_MS=60000 +# Redis URL for the shared rate-limit window. Empty = bounded local limiter. +RATE_LIMIT_REDIS_URL= + +# ─── Scoped solver credentials (issue #443) ─────────────────────────────────── +CREDENTIAL_REVOCATION_PUBSUB=memory + +# ─── SSE intent feed (issue #433) ───────────────────────────────────────────── +SSE_HEARTBEAT_MS=15000 +SSE_MAX_BUFFER_BYTES=1048576 + +# ─── Public anonymised datasets ────────────────────────────────────────────── +DATASETS_ENABLED=false +DATASETS_ANONYMIZE=true +DATASETS_SALT= +DATASETS_SALT_ROTATION_HOURS=24 +DATASETS_SALT_RETENTION_WINDOWS=2 +DATASETS_PUBLIC_BUCKET= +DATASETS_STORAGE_KIND=memory +DATASETS_LOCAL_DIR= + +# ─── Health probes (issue #492) ────────────────────────────────────────────── +# Roles served by this process (api, ws, worker); readiness checks follow them. +SERVICE_ROLES=api,ws,worker +HEALTH_CHECK_INTERVAL_MS=5000 +# Readiness hysteresis: failures before not-ready, successes before ready again. +HEALTH_READY_FAILURE_THRESHOLD=3 +HEALTH_READY_SUCCESS_THRESHOLD=2 +# Liveness fails when event-loop delay exceeds this. +HEALTH_EVENT_LOOP_MAX_LAG_MS=1000 +# Soroban RPC endpoints for the quorum check (default: SOROBAN_RPC_URL). +SOROBAN_RPC_HEALTH_URLS= diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml new file mode 100644 index 0000000..6682b0a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -0,0 +1,81 @@ +name: Bug Report +description: Report a bug you found while using or working on vortex-backend. +title: "[Bug]: " +labels: ["bug", "needs-triage"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to report a bug! + Please fill out as much of the form as possible so we can reproduce and fix it quickly. + + - type: textarea + id: description + attributes: + label: What happened? + description: A clear and concise description of the bug. + placeholder: Describe what went wrong. + validations: + required: true + + - type: textarea + id: expected + attributes: + label: What did you expect to happen? + description: What should have happened instead? + placeholder: Describe the expected behaviour. + validations: + required: true + + - type: textarea + id: reproduction + attributes: + label: Steps to reproduce + description: Minimal steps that reliably trigger the bug. + placeholder: | + 1. Start the server with `npm run dev` + 2. Send `POST /api/v1/intents` with body `{ ... }` + 3. See error ... + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Relevant logs or error output + description: Paste any stack traces, structured log lines, or Sentry event IDs here. + render: shell + validations: + required: false + + - type: input + id: version + attributes: + label: Version / commit SHA + description: What version or git SHA are you running? + placeholder: e.g. 0.1.0 or abc1234 + validations: + required: false + + - type: dropdown + id: environment + attributes: + label: Environment + options: + - Local development + - Testnet + - Mainnet / production + - CI + - Other + validations: + required: true + + - type: checkboxes + id: checklist + attributes: + label: Checklist + options: + - label: I searched existing issues and this is not a duplicate. + required: true + - label: I have included enough information to reproduce the issue. + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..3d86cdf --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Security vulnerability + url: https://github.com/vortex-protocol/vortex-backend/security/advisories/new + about: Please report security issues via GitHub's private vulnerability reporting — do not open a public issue. diff --git a/.github/ISSUE_TEMPLATE/contributor-claim.yml b/.github/ISSUE_TEMPLATE/contributor-claim.yml new file mode 100644 index 0000000..ceef1ac --- /dev/null +++ b/.github/ISSUE_TEMPLATE/contributor-claim.yml @@ -0,0 +1,61 @@ +name: Contributor Claim (Drips Wave) +description: Signal that you are picking up a numbered item from issues.md. +title: "[Claim]: # — " +labels: ["claim", "drips-wave"] +body: + - type: markdown + attributes: + value: | + Use this form to claim a numbered issue from + [issues.md](../blob/main/issues.md) so other contributors know it is + being worked on. One claim per person per issue; duplicate claims on + the same item will be closed. + + - type: input + id: issue_number + attributes: + label: Issue number (from issues.md) + description: The `#NNN` identifier listed in issues.md. + placeholder: "e.g. #298" + validations: + required: true + + - type: input + id: issue_title + attributes: + label: Issue title + description: The title as it appears in issues.md. + placeholder: "e.g. Restrict access to GET /metrics" + validations: + required: true + + - type: textarea + id: plan + attributes: + label: Brief implementation plan + description: | + A 2–5 sentence summary of how you plan to tackle it. + This is to surface approach mismatches early, not to gate your work. + placeholder: | + I'll add a MetricsTokenGuard that reads METRICS_TOKEN from env … + validations: + required: true + + - type: input + id: eta + attributes: + label: Estimated PR date + description: Rough target (week or sprint). We use this to reclaim stale items. + placeholder: "e.g. 2025-11-15 or 'within 2 weeks'" + validations: + required: false + + - type: checkboxes + id: checklist + attributes: + label: Checklist + options: + - label: The item is not already assigned or claimed in issues.md / open PRs. + required: true + - label: I have read CONTRIBUTING.md and understand the PR checklist. + required: true diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml new file mode 100644 index 0000000..36f18a4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -0,0 +1,63 @@ +name: Feature Request +description: Propose a new feature or improvement for vortex-backend. +title: "[Feature]: " +labels: ["enhancement", "needs-triage"] +body: + - type: markdown + attributes: + value: | + Thanks for suggesting a feature! Please describe the problem you're trying to solve + and your proposed solution so we can discuss it effectively. + + - type: textarea + id: problem + attributes: + label: Problem statement + description: | + What problem does this feature solve? Why is the current behaviour insufficient? + placeholder: | + As a solver, I need to ... because currently ... + validations: + required: true + + - type: textarea + id: solution + attributes: + label: Proposed solution + description: Describe the feature you'd like. Include API/interface sketches where relevant. + placeholder: Add a new endpoint / guard / config option that ... + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: What other approaches did you consider and why did you rule them out? + validations: + required: false + + - type: dropdown + id: area + attributes: + label: Area + options: + - Intents / solver flow + - WebSocket / real-time feed + - Soroban / on-chain integration + - Auth / access control + - Observability / metrics + - Developer experience / CI + - Other + validations: + required: true + + - type: checkboxes + id: checklist + attributes: + label: Checklist + options: + - label: I searched existing issues and this is not a duplicate. + required: true + - label: I am willing to help implement or review this feature. + required: false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8d1a9ab..eadf217 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1045,6 +1045,39 @@ jobs: push: false tags: vortex-backend:ci + # ── OpenAPI drift check (issue #318) ────────────────────────────────────── + # Verifies that src/generated/openapi.json (and the api-types.ts it drives) + # still matches what the current controllers would produce. A PR that + # changes a controller DTO or route without re-running `npm run generate:client` + # will fail here before stale types ship to SDK consumers. + # + # The job runs only on pull_request events so we don't block main pushes that + # are the *result* of running the generator. The sdk-publish job below + # re-generates on tags anyway, so production is always up-to-date. + openapi-drift: + name: OpenAPI drift check + runs-on: ubuntu-latest + needs: backend + if: github.event_name == 'pull_request' + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: vortex + POSTGRES_PASSWORD: vortex + POSTGRES_DB: vortex + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready -U vortex" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + env: + DATABASE_URL: postgresql://vortex:vortex@localhost:5432/vortex?schema=public + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + # ── Solver SDK (issue #446): build + publish dry-run on every PR ─────────── solver-sdk: name: Solver SDK – build + publish dry-run @@ -1055,6 +1088,43 @@ jobs: with: node-version: 20 cache: npm + + - name: Install dependencies + run: npm ci + + - name: Restore cached Prisma client + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: node_modules/.prisma + key: prisma-${{ runner.os }}-node20-${{ hashFiles('prisma/schema.prisma', 'package-lock.json') }} + + - name: Generate Prisma client + run: npm run db:generate + + - name: Run database migrations + run: npm run db:migrate:prod + + # Build first — generate:client requires dist/ to boot the throw-away app. + - name: Build + run: npm run build + + # Re-generate the client from the current controllers into a temp directory, + # then diff against the checked-in src/generated/. Any difference means + # a controller change was not followed by `npm run generate:client`. + - name: Regenerate OpenAPI client + run: npm run generate:client + + - name: Check for OpenAPI drift + run: | + if ! git diff --exit-code src/generated/; then + echo "" + echo "::error::src/generated/ is out of date. Run \`npm run generate:client\` locally," + echo "::error::commit the updated files, and push again." + echo "" + git diff src/generated/ + exit 1 + fi + echo "✅ src/generated/ is in sync with the current controllers." - run: npm ci - run: npm run check:version -w @vortex/solver-sdk - run: npm run build -w @vortex/solver-sdk diff --git a/src/common/http-exception.filter.spec.ts b/src/common/http-exception.filter.spec.ts index d4b182c..4e59226 100644 --- a/src/common/http-exception.filter.spec.ts +++ b/src/common/http-exception.filter.spec.ts @@ -1,14 +1,16 @@ import { HttpException, HttpStatus, ArgumentsHost } from "@nestjs/common"; import { HttpExceptionFilter } from "./http-exception.filter"; import * as sentryModule from "./sentry"; +import { logger } from "./logger"; // ── helpers ─────────────────────────────────────────────────────────────────── -function makeHost(json: jest.Mock, requestId?: string): ArgumentsHost { +function makeHost(json: jest.Mock, requestId?: string, setHeader?: jest.Mock): ArgumentsHost { return { switchToHttp: () => ({ getResponse: () => ({ status: (_code: number) => ({ json }), + setHeader: setHeader ?? jest.fn(), }), getRequest: () => (requestId ? { requestId } : {}), }), @@ -124,4 +126,39 @@ describe("HttpExceptionFilter", () => { expect(json).toHaveBeenCalledWith({ error: "boom" }); }); }); + + describe("custom-shaped body passthrough (issue #304)", () => { + it("forwards allowlisted fields from a custom-shaped exception body", () => { + const host = makeHost(json); + const body = { error: "fill-check failed", intentId: "abc-123", fillAmount: "100", minDstAmount: "90" }; + filter.catch(new HttpException(body, HttpStatus.BAD_REQUEST), host); + expect(json).toHaveBeenCalledWith({ + error: "fill-check failed", + intentId: "abc-123", + fillAmount: "100", + minDstAmount: "90", + }); + }); + + it("strips unknown fields from a custom-shaped body and logs a warning", () => { + const host = makeHost(json); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const warnSpy = jest.spyOn(logger, "warn").mockImplementation((() => logger) as any); + const body = { error: "something failed", intentId: "abc-123", internalDebugField: "secret" }; + filter.catch(new HttpException(body, HttpStatus.BAD_REQUEST), host); + const response = json.mock.calls[0][0] as Record; + expect(response).not.toHaveProperty("internalDebugField"); + expect(response).toHaveProperty("error"); + expect(response).toHaveProperty("intentId"); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("internalDebugField")); + warnSpy.mockRestore(); + }); + + it("injects requestId into a custom-shaped body response", () => { + const host = makeHost(json, "req-xyz-456"); + const body = { error: "fill-check failed", intentId: "abc-123" }; + filter.catch(new HttpException(body, HttpStatus.BAD_REQUEST), host); + expect(json).toHaveBeenCalledWith(expect.objectContaining({ requestId: "req-xyz-456" })); + }); + }); }); diff --git a/src/common/http-exception.filter.ts b/src/common/http-exception.filter.ts index a033bd1..0cb6bdb 100644 --- a/src/common/http-exception.filter.ts +++ b/src/common/http-exception.filter.ts @@ -2,6 +2,29 @@ import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from "@nestjs/co import { captureException } from "./sentry"; import { logger } from "./logger"; +/** + * Fields that are explicitly allowed to pass through in a custom-shaped exception + * body (i.e. the `b.error && !b.statusCode` branch). + * + * Any key NOT in this set will be stripped and a warning emitted in development + * so the author learns about the leak before it reaches production. In + * production the field is silently dropped so no internal detail escapes. + * + * Today's only known usage is IntentsController.fill(): + * throw new BadRequestException({ error, intentId, minDstAmount, fillAmount }) + * — all four fields are intentionally public. + * + * To expose a new field from a custom-shaped exception, add its name here and + * document why it is safe to return to API consumers. Closes #304. + */ +const CUSTOM_BODY_ALLOWLIST = new Set([ + "error", // human-readable error message (required) + "intentId", // the intent that failed — already in the URL, safe to echo + "fillAmount", // the amount the solver attempted — safe to echo to the solver + "minDstAmount", // the required minimum — safe to echo to the solver + "requestId", // injected below; listed for clarity +]); + interface JsonResponse { status: (code: number) => { json: (body: unknown) => void }; setHeader?: (name: string, value: string) => void; @@ -57,8 +80,24 @@ export class HttpExceptionFilter implements ExceptionFilter { // Custom-shaped bodies passed directly to an exception constructor, // e.g. new BadRequestException({ error: "...", fillAmount, minDstAmount }) + // Only fields on CUSTOM_BODY_ALLOWLIST are forwarded to the client. + // Any extra field is stripped and a warning is emitted so that future + // contributors learn about the leak before it reaches production. + // Closes #304. if (typeof b.error === "string" && !b.statusCode) { - response.status(status).json(b); + const sanitized: Record = {}; + for (const [key, value] of Object.entries(b)) { + if (CUSTOM_BODY_ALLOWLIST.has(key)) { + sanitized[key] = value; + } else { + logger.warn( + `HttpExceptionFilter: custom exception body contains unexpected field "${key}" — ` + + "it has been stripped from the response. If this field is safe to expose to " + + "API consumers, add it to CUSTOM_BODY_ALLOWLIST in http-exception.filter.ts.", + ); + } + } + response.status(status).json(addRequestId(sanitized, requestId)); return; } diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index e69de29..bab0459 100644 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -0,0 +1,489 @@ +import * as Joi from "joi"; + +// Stellar secret seeds ("S..." strkeys) are 56-char base32: prefix + 32-byte +// payload + checksum. This rejects placeholders like "changeme" outright — +// it does not by itself prove the key is a *real, funded* signer. +const STELLAR_SECRET_KEY_PATTERN = /^S[A-Z2-7]{55}$/; + +// One message for both "absent" and "empty". Joi's .required() alone accepts an +// empty string, which for the kill-switch would be a silently disabled control +// plane — the exact condition this rule exists to prevent, so both cases must +// produce the same actionable error. +const KILLSWITCH_TOKEN_REQUIRED_MESSAGE = + "KILLSWITCH_OPERATOR_TOKEN must be a non-empty secret in production so the " + + "emergency pause control plane (/api/v1/ops/killswitch) is usable. Generate " + + "one with `openssl rand -hex 32`. See docs/runbooks/killswitch.md."; + +export const envValidationSchema = Joi.object({ + NODE_ENV: Joi.string().valid("development", "production", "test").default("development"), + PORT: Joi.number().port().default(4000), + + // Prisma requires DATABASE_URL in production; optional (with a default) in + // development/test so the app can boot without a live database for unit tests. + DATABASE_URL: Joi.string() + .uri({ scheme: ["postgresql", "postgres"] }) + .default("postgresql://vortex:vortex@localhost:5432/vortex?schema=public"), + + STELLAR_NETWORK: Joi.string().valid("testnet", "futurenet", "mainnet").default("testnet"), + SOROBAN_RPC_URL: Joi.string().uri().default("https://soroban-testnet.stellar.org"), + // Horizon base URL, used for account/balance reads (treasury, canary tooling). + HORIZON_URL: Joi.string().uri().default("https://horizon-testnet.stellar.org"), + SETTLEMENT_CONTRACT_ID: Joi.string().allow("").default(""), + SOLVER_REGISTRY_CONTRACT_ID: Joi.string().allow("").default(""), + STELLAR_SIGNER_SECRET_KEY: Joi.string().allow("").default(""), + + // Secret key for the backend's own Soroban signer (submits on-chain writes + // such as settlement and slashing calls). No default is provided anywhere + // in this schema — an unset value fails closed (empty string) rather than + // ever falling back to a placeholder that could be mistaken for a real key. + SOROBAN_SIGNING_KEY: Joi.string() + .pattern(STELLAR_SECRET_KEY_PATTERN) + .messages({ + "string.pattern.base": + 'SOROBAN_SIGNING_KEY must be a valid Stellar secret seed (starts with "S", 56 base32 characters). ' + + "Generate a throwaway testnet key for local dev — see README's Signing Key section — never commit a real one.", + }) + .when("NODE_ENV", { + is: "production", + then: Joi.required(), + otherwise: Joi.string().allow("").default(""), + }), + + ONCHAIN_INTENTS_ENABLED: Joi.boolean().default(false), + // Stellar public key of the treasury account (fee/slash/refund accumulator). + TREASURY_ADDRESS: Joi.string().allow("").default(""), + + // Stellar public key of the treasury account (fee accumulator). + TREASURY_ADDRESS: Joi.string().allow("").default(""), + + ALLOW_LEGACY_STELLAR_SIGNATURES: Joi.boolean().default(false), + ETHEREUM_RPC_URL: Joi.string().uri({ scheme: ["http", "https"] }).allow("").default(""), + ETHEREUM_ESCROW_ADDRESS: Joi.string().pattern(/^$|^0x[a-fA-F0-9]{40}$/).default(""), + BASE_RPC_URL: Joi.string().uri({ scheme: ["http", "https"] }).allow("").default(""), + BASE_ESCROW_ADDRESS: Joi.string().pattern(/^$|^0x[a-fA-F0-9]{40}$/).default(""), + POLYGON_RPC_URL: Joi.string().uri({ scheme: ["http", "https"] }).allow("").default(""), + POLYGON_ESCROW_ADDRESS: Joi.string().pattern(/^$|^0x[a-fA-F0-9]{40}$/).default(""), + ARBITRUM_RPC_URL: Joi.string().uri({ scheme: ["http", "https"] }).allow("").default(""), + ARBITRUM_ESCROW_ADDRESS: Joi.string().pattern(/^$|^0x[a-fA-F0-9]{40}$/).default(""), + OPTIMISM_RPC_URL: Joi.string().uri({ scheme: ["http", "https"] }).allow("").default(""), + OPTIMISM_ESCROW_ADDRESS: Joi.string().pattern(/^$|^0x[a-fA-F0-9]{40}$/).default(""), + AVALANCHE_RPC_URL: Joi.string().uri({ scheme: ["http", "https"] }).allow("").default(""), + AVALANCHE_ESCROW_ADDRESS: Joi.string().pattern(/^$|^0x[a-fA-F0-9]{40}$/).default(""), + EVM_RPC_ALLOWLIST: Joi.string().allow("").default(""), + CORS_ORIGIN: Joi.string().default("*"), + WS_MAX_CONNECTIONS: Joi.number().integer().min(0).default(1000), + SOROBAN_FEE_PERCENTILE: Joi.string() + .valid( + "min", + "mode", + "p10", + "p20", + "p30", + "p40", + "p50", + "p60", + "p70", + "p80", + "p90", + "p95", + "p99", + "max", + ) + .default("p50"), + + WS_BACKPLANE: Joi.string().valid("memory", "redis").default("memory"), + REDIS_URL: Joi.string().uri({ scheme: ["redis", "rediss"] }).default("redis://localhost:6379"), + + // ── Persistence adapter selection ───────────────────────────────────────── + // Controls which repository adapter is used for intents and solvers. + // "memory" (default) keeps everything in-process — no database required. + // "prisma" writes to PostgreSQL via Prisma — requires DATABASE_URL to point + // to a live database. Intended for production / staging. + INTENTS_PERSISTENCE: Joi.string().valid("memory", "prisma").default("memory"), + SOLVERS_PERSISTENCE: Joi.string().valid("memory", "prisma").default("memory"), + + // ── Intent retention (in-memory store hygiene) ───────────────────────────── + // How long terminal intents are kept in the in-memory adapter, and how often + // the eviction sweep runs. Both are read by IntentsService. + INTENT_RETENTION_DAYS: Joi.number().integer().min(0).default(30), + INTENT_RETENTION_SWEEP_MS: Joi.number().integer().min(0).default(60000), + + // ── Reference solver bot (scripts/solver-bot.ts) ─────────────────────────── + // Read by the standalone bot process rather than by the server, but declared + // here so `npm run check:env-drift` sees one consistent variable set across + // env.validation.ts, configuration.ts and the .env*.example files. + SOLVER_SECRET: Joi.string().allow("").default(""), + SOLVER_ADDRESS: Joi.string().allow("").default(""), + SOLVER_CHAINS: Joi.string().allow("").default(""), + + // ── Observability ───────────────────────────────────────────────────────── + // Sentry DSN for error alerting. Omit (or leave blank) to disable Sentry. + SENTRY_DSN: Joi.string().uri().allow("").default(""), + + // Bearer token that guards GET /metrics (issue #298). + // When set, Prometheus scrape jobs must supply: + // Authorization: Bearer + // When empty (the default): + // - non-production: unauthenticated scraping allowed (local dev Prometheus) + // - production: endpoint returns 401 (fail-closed — set the token before deploying) + // Generate with: openssl rand -hex 32 + METRICS_TOKEN: Joi.string().allow("").default(""), + + // Winston log level. Defaults to "debug" in dev/test and "info" in production. + LOG_LEVEL: Joi.string() + .valid("error", "warn", "info", "http", "verbose", "debug", "silly") + .default( + // Joi.ref doesn't evaluate lazily here, so we rely on the logger's own + // resolveLogLevel() for the runtime default — this schema default acts + // as a documentation hint and config validation guard only. + "debug", + ), + + // Log shipping — off by default so local dev/CI remain stdout-only. When + // enabled, structured logs are also shipped to LOG_SHIPPING_HOST:PORT. + LOG_SHIPPING_ENABLED: Joi.boolean().default(false), + LOG_SHIPPING_HOST: Joi.string().when("LOG_SHIPPING_ENABLED", { + is: true, + then: Joi.required(), + otherwise: Joi.string().allow("").default(""), + }), + LOG_SHIPPING_PORT: Joi.number().port().when("LOG_SHIPPING_ENABLED", { + is: true, + then: Joi.required(), + otherwise: Joi.number().optional(), + }), + LOG_SHIPPING_PATH: Joi.string().default("/"), + LOG_SHIPPING_SSL: Joi.boolean().default(false), + LOG_SERVICE_NAME: Joi.string().default("vortex-backend"), + + // ── Pluggable signer backend (issue #400) ──────────────────────────────── + // SIGNER_BACKEND selects which signing implementation is used: + // "local" (default) — LocalKeypairSigner: key loaded from SOROBAN_SIGNING_KEY / file. + // Refused in production unless ALLOW_LOCAL_SIGNER_IN_PROD=true. + // "vault" — VaultTransitSigner: signs via HashiCorp Vault Transit (ed25519). + // Requires VAULT_ADDR + VAULT_TOKEN. Key never enters RAM. + SIGNER_BACKEND: Joi.string().valid("local", "vault").default("local"), + + // Required when SIGNER_BACKEND=vault. + VAULT_ADDR: Joi.string().uri({ scheme: ["http", "https"] }).when("SIGNER_BACKEND", { + is: "vault", + then: Joi.required(), + otherwise: Joi.string().allow("").default(""), + }), + VAULT_TOKEN: Joi.string().when("SIGNER_BACKEND", { + is: "vault", + then: Joi.required(), + otherwise: Joi.string().allow("").default(""), + }), + // Name of the Vault Transit key (default: "vortex-signer"). + VAULT_TRANSIT_KEY_NAME: Joi.string().default("vortex-signer"), + + // Escape hatch: allow LocalKeypairSigner in production. + // Must be explicitly set to "true" — any other value is treated as false. + // A startup warning is emitted when this is enabled in production. + ALLOW_LOCAL_SIGNER_IN_PROD: Joi.boolean().default(false), + + // ── Resource-exhaustion limits (issue #476) ─────────────────────────────── + // These values are consumed by src/config/limits.config.ts at startup and + // override the compile-time defaults when set. All have safe defaults so + // the service can boot without them. + + /** Max JSON nesting depth before the body is rejected (default 10). */ + JSON_MAX_DEPTH: Joi.number().integer().min(1).max(100).default(10), + + /** Max WS chain-filter values per subscribe message (default 20). */ + WS_MAX_FILTER_CHAINS: Joi.number().integer().min(1).max(100).default(20), + + /** Max active subscriptions per WS connection (default 10). */ + WS_MAX_SUBSCRIPTIONS: Joi.number().integer().min(1).max(100).default(10), + + /** Default Postgres statement_timeout in ms for standard route queries (default 5000). */ + DB_QUERY_TIMEOUT_MS: Joi.number().integer().min(100).max(60000).default(5000), + + /** Postgres statement_timeout in ms for batch-lookup queries (default 10000). */ + DB_BATCH_QUERY_TIMEOUT_MS: Joi.number().integer().min(100).max(60000).default(10000), + + /** Postgres statement_timeout in ms for stats/aggregate queries (default 15000). */ + DB_STATS_QUERY_TIMEOUT_MS: Joi.number().integer().min(100).max(60000).default(15000), + + // ── Emergency kill-switch (issue #477) ───────────────────────────────────── + // Shared secret for the operator control plane. Empty (the default) leaves + // /api/v1/ops/killswitch disabled — fail closed, never open. + // + // The kill-switch is the only way to stop writes at runtime, so a production + // deploy without a token ships a protocol that cannot be paused. Requiring it + // in production fails validation rather than silently running with the + // control plane disabled. + KILLSWITCH_OPERATOR_TOKEN: Joi.string() + .when("NODE_ENV", { + is: Joi.valid("production"), + then: Joi.string() + .required() + .invalid("") + .messages({ + "any.required": KILLSWITCH_TOKEN_REQUIRED_MESSAGE, + "string.empty": KILLSWITCH_TOKEN_REQUIRED_MESSAGE, + "any.invalid": KILLSWITCH_TOKEN_REQUIRED_MESSAGE, + }), + otherwise: Joi.string().allow("").default(""), + }), + + /** + * Redis URL for cross-replica pause propagation. Empty means "polling only", + * which still meets the 5 s budget. Defaults to reusing REDIS_URL when + * WS_BACKPLANE=redis, so existing deployments propagate without new config. + */ + KILLSWITCH_REDIS_URL: Joi.string().allow("").optional(), + + /** + * DB change-probe interval (ms) that backstops Redis pub/sub. Capped at 5000 + * so the worst-case propagation delay cannot exceed the requirement, however + * misconfigured. + */ + KILLSWITCH_POLL_MS: Joi.number().integer().min(100).max(5000).default(2000), + + // Same adapter-selection convention as the other repositories. + KILLSWITCH_PERSISTENCE: Joi.string().valid("memory", "prisma").default("memory"), + + // ── On-chain write safety flag (issue #35 / issue #260) ────────────────── + // When true, every on-chain-write code path (invokeContract, slashSolver) + // builds and simulates the transaction, logs what it *would* submit, and + // returns without broadcasting — safe by construction. + // + // Default behaviour: + // - Outside production: defaults to true (simulate-only, fail closed + // toward safety — no real funds moved without an explicit opt-out). + // - In production: *required* to be explicitly set. Omitting it in a + // production deploy fails validation so the operator must consciously + // decide between dry-run and live mode before traffic reaches + // on-chain write paths. This matches the fail-closed pattern used + // for SOROBAN_SIGNING_KEY. + // + // This is the env default for the `onchain-dry-run` runtime feature flag + // (issue #495); the flag can override it without a restart, and turning + // dry-run off in production through the flag requires two approvals. + // Set ONCHAIN_DRY_RUN=false only after completing the dry-run soak + // described in docs/runbooks/onchain-cutover.md. + ONCHAIN_DRY_RUN: Joi.boolean() + .when("NODE_ENV", { + is: "production", + then: Joi.required().messages({ + "any.required": + "ONCHAIN_DRY_RUN must be explicitly set in production. " + + "Set to true to remain in simulate-only mode, or false to enable live on-chain writes. " + + "See docs/runbooks/onchain-cutover.md for the staged rollout procedure.", + }), + otherwise: Joi.boolean().default(true), + }), + + // ── Shadow-mode divergence monitor (issue #401) ─────────────────────────── + // Runs read-only on-chain simulations of every intent state transition in + // parallel with the authoritative off-chain path and reports where the two + // disagree. Never submits a transaction; see src/soroban/shadow.service.ts. + // + // Off by default: a sampled simulation is a real RPC call with a real + // rate-limit footprint, so it is an explicit per-environment opt-in. + SHADOW_MODE_ENABLED: Joi.boolean().default(false), + + // Fraction of transitions to simulate, as a probability in [0, 1]. + // 1 (the default) compares every transition; 0 disables sampling entirely + // while leaving the monitor "enabled" — useful for a canary that only wants + // the queue/metric plumbing live. + SHADOW_SAMPLE_RATE: Joi.number().min(0).max(1).default(1), + + // Hard cap on queued observations. Beyond this, observations are dropped and + // counted (`vortex_shadow_dropped_total`) rather than queued, so a slow or + // unreachable RPC degrades the monitor instead of the service. + SHADOW_QUEUE_MAX: Joi.number().integer().min(1).default(256), + + // How many queued observations the background drain simulates concurrently. + SHADOW_CONCURRENCY: Joi.number().integer().min(1).max(32).default(4), + + // Public key used as the transaction source for shadow simulations. A Stellar + // public key (strkey G...). It is never signed, never submitted and never + // charged a fee — it only has to be a valid address for the envelope. + // Optional: when empty the monitor reports `contract_unconfigured` rather + // than silently recording zero divergence. + SHADOW_SOURCE_ACCOUNT: Joi.string().allow("").default(""), + + // ── Governance parameters contract ──────────────────────────────────────── + // When set, ProtocolParamsService reads current + scheduled protocol + // parameters (fee bps, fill windows, deadlines, exposure ratio, slash + // amount) from this Soroban contract address. Leave blank to use code + // and env defaults. + PARAMS_CONTRACT_ID: Joi.string().allow("").default(""), + + // How often (ms) to poll the parameters contract. 30 s is the default; + // lower values increase RPC load; raise in production if rate-limited. + PARAMS_POLL_INTERVAL_MS: Joi.number().integer().min(5_000).default(30_000), + + // ── Leader election (issue #493) ────────────────────────────────────────── + // Controls whether Postgres advisory-lock based leader election is enabled + // for singleton workers (sweeper, event-ingestion). + // + // Set LEADER_ELECTION_ENABLED=false in single-instance dev deployments or + // when no database is available. When disabled, every worker considers + // itself leader unconditionally — the pre-election behaviour. + // + // IMPORTANT: Do NOT route the leader election connection through PgBouncer + // in transaction-pooling mode. Advisory locks are session-scoped; they are + // released when the connection is returned to the pool. Use a direct + // connection or PgBouncer in session mode. + LEADER_ELECTION_ENABLED: Joi.boolean().default(false), + + // Heartbeat interval in milliseconds — how often non-leaders attempt to + // acquire the lock and leaders renew it. Lower values reduce failover time + // but increase DB load. Default 5 s gives ≤ 15 s failover. + LEADER_ELECTION_HEARTBEAT_MS: Joi.number().integer().min(1000).max(60000).default(5000), + + // ── Background jobs (issue #494) ────────────────────────────────────────── + // PROCESS_ROLE: "api" serves HTTP/WS only, "worker" runs queue workers, + // "all" does both (single-process dev default). Producers work in any role. + PROCESS_ROLE: Joi.string().valid("api", "worker", "all").default("all"), + // JOBS_DRIVER: "memory" is single-process and non-durable (dev/test); + // "bullmq" uses REDIS_URL and is required for multi-instance deploys. + JOBS_DRIVER: Joi.string().valid("memory", "bullmq").default("memory"), + JOBS_SHUTDOWN_TIMEOUT_MS: Joi.number().integer().min(0).default(25000), + + // ── Runtime feature flags (issue #495) ──────────────────────────────────── + FLAGS_PUBSUB: Joi.string().valid("memory", "redis").default("memory"), + FLAGS_REFRESH_MS: Joi.number().integer().min(1000).default(30000), + // Comma-separated "key=true|false" pins that win over DB state (break-glass). + FLAG_OVERRIDES: Joi.string() + .allow("") + .pattern(/^([a-z0-9-]+=(true|false))(,[a-z0-9-]+=(true|false))*$/) + .default(""), + + // ── Admin RBAC ──────────────────────────────────────────────────────────── + // Comma-separated "id:role:secret" entries; role is "admin" or "superadmin". + // Empty disables every admin endpoint (401). + ADMIN_API_KEYS: Joi.string() + .allow("") + .pattern(/^([A-Za-z0-9_.-]+:(admin|superadmin):[^,:]{16,})(,[A-Za-z0-9_.-]+:(admin|superadmin):[^,:]{16,})*$/) + .default(""), + + // ── Public anonymised datasets ──────────────────────────────────────────── + DATASETS_ENABLED: Joi.boolean().default(false), + DATASETS_ANONYMIZE: Joi.boolean().default(true), + DATASETS_SALT: Joi.string().allow("").default(""), + DATASETS_SALT_ROTATION_HOURS: Joi.number().integer().min(1).max(720).default(24), + DATASETS_SALT_RETENTION_WINDOWS: Joi.number().integer().min(0).max(30).default(2), + DATASETS_PUBLIC_BUCKET: Joi.string().allow("").default(""), + DATASETS_STORAGE_KIND: Joi.string().valid("local", "memory").default("memory"), + DATASETS_LOCAL_DIR: Joi.string().allow("").default(""), + + // ── Guardian emergency ingestion (issue #507) ───────────────────────────── + GUARDIAN_CONTRACT_ID: Joi.string().allow("").default(""), + + // ── Synthetic canary (issue #496) ───────────────────────────────────────── + // Comma-separated canary user/solver addresses, excluded from public stats + // and leaderboards. + CANARY_ADDRESSES: Joi.string().allow("").default(""), + + // ── Public anonymised datasets (docs/rfcs/0001) ─────────────────────────── + DATASETS_ENABLED: Joi.boolean().default(false), + DATASETS_ANONYMIZE: Joi.boolean().default(true), + // Required only when datasets are enabled AND anonymisation is on — an + // empty/weak salt would collapse pseudonymisation to a fixed, reversible + // transform. It stays optional (default "") otherwise so existing dev/test + // configs are unaffected. + DATASETS_SALT: Joi.string() + .when("DATASETS_ENABLED", { + is: true, + then: Joi.string().when("DATASETS_ANONYMIZE", { + is: true, + then: Joi.string() + .min(32) + .required() + .messages({ + "any.required": + "DATASETS_SALT must be set when DATASETS_ENABLED=true and DATASETS_ANONYMIZE=true. " + + "Generate a strong random secret (e.g. `openssl rand -hex 32`).", + "string.min": "DATASETS_SALT must be at least 32 characters.", + }), + otherwise: Joi.string().allow("").default(""), + }), + otherwise: Joi.string().allow("").default(""), + }), + DATASETS_SALT_ROTATION_HOURS: Joi.number().integer().min(1).default(24), + DATASETS_SALT_RETENTION_WINDOWS: Joi.number().integer().min(0).default(2), + DATASETS_PUBLIC_BUCKET: Joi.string().default("vortex-public-datasets"), + DATASETS_STORAGE: Joi.string().valid("local", "memory").default("local"), + DATASETS_LOCAL_DIR: Joi.string().default(".datasets"), + + // ── Secrets Manager (issue #465) ──────────────────────────────────────────── + SECRETS_PROVIDER: Joi.string().valid("env", "aws-secrets-manager", "vault-kv").default("env"), + SECRETS_REFRESH_INTERVAL_MS: Joi.number().integer().min(5000).default(60000), + SECRETS_EXTRA: Joi.string().allow("").default(""), + + // AWS Secrets Manager + AWS_SECRETS_MANAGER_PREFIX: Joi.string().allow("").default(""), + AWS_SECRETS_MANAGER_POLL_INTERVAL_MS: Joi.number().integer().min(5000).default(60000), + + // Vault KV + VAULT_KV_MOUNT: Joi.string().default("secret"), + VAULT_KV_PREFIX: Joi.string().default("vortex/"), + VAULT_KV_POLL_INTERVAL_MS: Joi.number().integer().min(5000).default(60000), + + // Extra secret env vars referenced by the default SecretConfig + JWT_SIGNING_KEY: Joi.string().allow("").default(""), + WEBHOOK_SECRET: Joi.string().allow("").default(""), + CHANNEL_KEY: Joi.string().allow("").default(""), + // ── Egress / SSRF Protection (issue #468) ───────────────────────────────── + // Controls the centralized HttpEgressService used for all outbound HTTP requests + // (RPC, Horizon, oracles, webhooks) to prevent SSRF attacks. + EGRESS_TIMEOUT_MS: Joi.number().integer().min(1000).max(60000).default(10000), + EGRESS_MAX_REDIRECTS: Joi.number().integer().min(0).max(5).default(3), + EGRESS_MAX_BODY_SIZE_BYTES: Joi.number().integer().min(1024).default(10485760), // 10MB + SOROBAN_RPC_ALLOWLIST: Joi.string().allow("").default(""), + WEBHOOK_ALLOWLIST: Joi.string().allow("").default(""), + ORACLE_ALLOWLIST: Joi.string().allow("").default(""), + + // ── WS gateway hardening (issue #455) ───────────────────────────────────── + WS_MAX_PAYLOAD_BYTES: Joi.number().integer().min(1024).default(16384), + WS_MAX_CONNECTIONS_PER_IP: Joi.number().integer().min(0).default(20), + // Hops of trusted reverse proxies in front of the service. 0 ignores + // X-Forwarded-For entirely so clients cannot spoof their IP. + WS_TRUST_PROXY_HOPS: Joi.number().integer().min(0).default(0), + WS_RATE_LIMIT_PER_SEC: Joi.number().positive().default(10), + WS_RATE_LIMIT_BURST: Joi.number().integer().min(1).default(20), + WS_RATE_LIMIT_MAX_VIOLATIONS: Joi.number().integer().min(1).default(5), + WS_OUTBOUND_QUEUE_MAX: Joi.number().integer().min(1).default(1000), + WS_OUTBOUND_BUFFER_BYTES: Joi.number().integer().min(1024).default(1048576), + WS_SLOW_CONSUMER_POLICY: Joi.string().valid("drop_oldest", "disconnect").default("drop_oldest"), + // HS256 secret shared with the SEP-10 auth endpoint (#442). Empty disables + // JWT auth; signature auth keeps working. + AUTH_JWT_SECRET: Joi.string().allow("").min(32).default(""), + + // ── Distributed rate limiting (issue #441) ───────────────────────────────── + // How often the local rate-limiter fallback prunes expired window entries. + // Only relevant during a Redis outage; keeps the fallback map bounded. + RATE_LIMIT_LOCAL_PRUNE_MS: Joi.number().integer().min(1000).max(60000).default(60000), + + // Redis URL for the distributed rate limiter (#441). Leave empty to force the + // bounded local limiter (the limit is still enforced, per process). + RATE_LIMIT_REDIS_URL: Joi.string().allow("").optional(), + + // ── Scoped solver credentials (issue #443) ───────────────────────────────── + // Cross-replica transport for credential revocation invalidation. + CREDENTIAL_REVOCATION_PUBSUB: Joi.string().valid("memory", "redis").default("memory"), + + // ── SSE intent feed (issue #433) ─────────────────────────────────────────── + // Heartbeat comment interval and per-client backpressure limit for the + // Server-Sent Events intent stream. + SSE_HEARTBEAT_MS: Joi.number().integer().min(1000).max(60000).default(15000), + SSE_MAX_BUFFER_BYTES: Joi.number().integer().min(1024).default(1048576), + + // ── Health probes (issue #492) ──────────────────────────────────────────── + // Comma-separated roles this process serves: api, ws, worker. + SERVICE_ROLES: Joi.string() + .pattern(/^(api|ws|worker)(,(api|ws|worker))*$/) + .default("api,ws,worker"), + HEALTH_CHECK_INTERVAL_MS: Joi.number().integer().min(500).default(5000), + HEALTH_READY_FAILURE_THRESHOLD: Joi.number().integer().min(1).default(3), + HEALTH_READY_SUCCESS_THRESHOLD: Joi.number().integer().min(1).default(2), + HEALTH_EVENT_LOOP_MAX_LAG_MS: Joi.number().integer().min(50).default(1000), + // Comma-separated Soroban RPC URLs for the RPC-quorum readiness check. + // Defaults to SOROBAN_RPC_URL. + SOROBAN_RPC_HEALTH_URLS: Joi.string().allow("").default(""), +}); diff --git a/src/metrics/metrics-token.guard.spec.ts b/src/metrics/metrics-token.guard.spec.ts new file mode 100644 index 0000000..906df60 --- /dev/null +++ b/src/metrics/metrics-token.guard.spec.ts @@ -0,0 +1,78 @@ +import { ExecutionContext, UnauthorizedException } from "@nestjs/common"; +import { MetricsTokenGuard } from "./metrics-token.guard"; + +function makeContext(authHeader?: string): ExecutionContext { + return { + switchToHttp: () => ({ + getRequest: () => ({ + headers: authHeader ? { authorization: authHeader } : {}, + }), + }), + } as unknown as ExecutionContext; +} + +describe("MetricsTokenGuard", () => { + let guard: MetricsTokenGuard; + const originalEnv = process.env; + + beforeEach(() => { + guard = new MetricsTokenGuard(); + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe("no METRICS_TOKEN configured", () => { + beforeEach(() => { + delete process.env.METRICS_TOKEN; + }); + + it("allows unauthenticated access in development", () => { + process.env.NODE_ENV = "development"; + expect(guard.canActivate(makeContext())).toBe(true); + }); + + it("allows unauthenticated access in test", () => { + process.env.NODE_ENV = "test"; + expect(guard.canActivate(makeContext())).toBe(true); + }); + + it("denies access in production (fail-closed)", () => { + process.env.NODE_ENV = "production"; + expect(() => guard.canActivate(makeContext())).toThrow(UnauthorizedException); + }); + }); + + describe("METRICS_TOKEN configured", () => { + beforeEach(() => { + process.env.METRICS_TOKEN = "secret-token-abc"; + }); + + it("allows a request with the correct bearer token", () => { + expect(guard.canActivate(makeContext("Bearer secret-token-abc"))).toBe(true); + }); + + it("denies a request with the wrong bearer token", () => { + expect(() => guard.canActivate(makeContext("Bearer wrong-token"))).toThrow( + UnauthorizedException, + ); + }); + + it("denies a request with no Authorization header", () => { + expect(() => guard.canActivate(makeContext())).toThrow(UnauthorizedException); + }); + + it("denies a request with a non-Bearer scheme", () => { + expect(() => guard.canActivate(makeContext("Basic secret-token-abc"))).toThrow( + UnauthorizedException, + ); + }); + + it("works the same in production when token is set", () => { + process.env.NODE_ENV = "production"; + expect(guard.canActivate(makeContext("Bearer secret-token-abc"))).toBe(true); + }); + }); +}); diff --git a/src/metrics/metrics-token.guard.ts b/src/metrics/metrics-token.guard.ts new file mode 100644 index 0000000..b8c949b --- /dev/null +++ b/src/metrics/metrics-token.guard.ts @@ -0,0 +1,56 @@ +import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common"; + +/** + * Guards GET /metrics behind a bearer token. + * + * When METRICS_TOKEN is set in the environment the client must supply it as: + * Authorization: Bearer + * + * When METRICS_TOKEN is empty or absent (the default) the guard still blocks + * all external traffic in production (NODE_ENV=production) so that an operator + * who forgets to set the token does not accidentally expose the endpoint. In + * non-production environments an empty token allows unauthenticated scraping + * from localhost — useful for local Prometheus dev stacks. + * + * Why a bearer token rather than an IP allowlist? + * An IP allowlist requires infrastructure-level knowledge (Prometheus pod + * CIDR, sidecar addresses) that varies between environments and is awkward + * to configure via env vars. A shared secret is portable, easy to rotate, + * and understood by every Prometheus scrape config via + * `authorization: { type: Bearer, credentials: }`. + * + * Closes #298. + */ +@Injectable() +export class MetricsTokenGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const metricsToken = process.env.METRICS_TOKEN ?? ""; + const nodeEnv = process.env.NODE_ENV ?? "development"; + + // In production without a configured token: always deny. Fail closed so + // a misconfigured deploy never silently exposes internal metrics. + if (!metricsToken && nodeEnv === "production") { + throw new UnauthorizedException( + "GET /metrics is not available: set METRICS_TOKEN to enable authenticated scraping. " + + "See docs/runbooks/metrics.md.", + ); + } + + // No token configured outside production: allow (supports local dev + // Prometheus stacks that scrape without credentials). + if (!metricsToken) { + return true; + } + + // Token configured: require Authorization: Bearer . + const request = context.switchToHttp().getRequest<{ headers: Record }>(); + const authHeader = request.headers["authorization"] ?? ""; + const [scheme, provided] = authHeader.split(" "); + + if (scheme !== "Bearer" || provided !== metricsToken) { + throw new UnauthorizedException("Invalid or missing metrics bearer token."); + } + + return true; + } +} diff --git a/src/metrics/metrics.controller.ts b/src/metrics/metrics.controller.ts index 861cdfe..1476358 100644 --- a/src/metrics/metrics.controller.ts +++ b/src/metrics/metrics.controller.ts @@ -1,13 +1,30 @@ -import { Controller, Get, Header, Inject } from "@nestjs/common"; +import { Controller, Get, Header, Inject, UseGuards } from "@nestjs/common"; import { ApiTags } from "@nestjs/swagger"; import { MetricsService } from "./metrics.service"; +import { MetricsTokenGuard } from "./metrics-token.guard"; @ApiTags("metrics") @Controller("metrics") export class MetricsController { constructor(@Inject(MetricsService) private readonly metricsService: MetricsService) {} + /** + * Prometheus scrape endpoint. + * + * Access is controlled by MetricsTokenGuard: + * - With METRICS_TOKEN set: require `Authorization: Bearer `. + * - Without METRICS_TOKEN in non-production: open (local dev / test). + * - Without METRICS_TOKEN in production: always 401 (fail closed). + * + * Configure your Prometheus scrape job with: + * authorization: + * type: Bearer + * credentials: + * + * Closes #298. + */ @Get() + @UseGuards(MetricsTokenGuard) @Header("Content-Type", "text/plain; charset=utf-8") async index(): Promise { return this.metricsService.metrics();