From 04a715f953ba97a9f744de90844d5999de0037e1 Mon Sep 17 00:00:00 2001 From: Dantama022 Date: Mon, 19 Oct 2026 22:03:26 +0100 Subject: [PATCH] feat: implement backend prisma client, rounding math library, and api infrastructure --- .gitignore | 1 + backend/.env.example | 20 +- backend/.env.production.example | 20 +- backend/openapi.json | 960 +++++++++++---------- backend/src/__tests__/cors.test.ts | 198 +++-- backend/src/__tests__/openapi.test.ts | 133 ++- backend/src/__tests__/rateLimit.test.ts | 160 +++- backend/src/index.ts | 93 +- backend/src/middleware/cors.ts | 29 +- backend/src/prismaClient.ts | 2 +- backend/src/rateLimiter.ts | 51 +- backend/src/swagger.ts | 34 +- contracts/share-price-math/src/rounding.rs | 86 +- docs/ENV_VARIABLE_MATRIX.md | 5 +- 14 files changed, 1185 insertions(+), 607 deletions(-) diff --git a/.gitignore b/.gitignore index 732cbba7e..0dd9e6bfd 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ /contracts/vault/target # Node +node_modules/ /frontend/node_modules /backend/node_modules /backend/prisma/*.db diff --git a/backend/.env.example b/backend/.env.example index ffe7db373..9184ae692 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -12,6 +12,22 @@ PORT=3000 NODE_ENV=development +# Trust Proxy Configuration (#1678) +# Non-negative integer hop count of trusted reverse proxies between clients and Express. +# Defaults to 0 (direct connection / untrusted X-Forwarded-For). +# Guidelines: +# - Direct access / local development: 0 (or omit) +# - Behind a single reverse proxy/load balancer (e.g., AWS ALB, Nginx): 1 +# - Behind two hops (e.g., Cloudflare CDN in front of an AWS ALB): 2 +TRUST_PROXY=0 + +# Swagger / OpenAPI Documentation (#1674) +# In production (NODE_ENV=production), Swagger UI and /docs/openapi.json are disabled by default. +# - Set SWAGGER_ENABLED=true to explicitly enable Swagger in production. +# - Set DISABLE_SWAGGER=true to force-disable Swagger across all environments. +SWAGGER_ENABLED=false +DISABLE_SWAGGER=false + # Rate Limiting Configuration # Global rate limiter: 100 requests per 15 minutes (900000 ms) RATE_LIMIT_WINDOW_MS=900000 @@ -145,8 +161,10 @@ CACHE_MAX_ENTRIES=500 # Per-command timeout for Redis cache operations (ms) # REDIS_CACHE_COMMAND_TIMEOUT_MS=500 -# CORS Configuration +# CORS Configuration (#1677) # Comma-separated list of allowed origins. Supports strings or regex like /https?:\/\/.*\.yieldvault\.finance/ +# In production (NODE_ENV=production), CORS_ALLOWED_ORIGINS is strictly REQUIRED. +# The server fails fast at startup if it is unset, empty, or whitespace-only. CORS_ALLOWED_ORIGINS=http://localhost:3000,https://app.yieldvault.finance # Email Notification Configuration diff --git a/backend/.env.production.example b/backend/.env.production.example index 4a57fceb2..d04341cef 100644 --- a/backend/.env.production.example +++ b/backend/.env.production.example @@ -6,6 +6,20 @@ PORT=3000 NODE_ENV=production +# Trust Proxy Configuration (#1678) +# Non-negative integer hop count of trusted reverse proxies between clients and Express. +# Default: 0 (direct connection). +# Set to 1 if deployed behind an AWS ALB, GCP HTTPS Load Balancer, or Nginx. +# Set to 2 if deployed behind Cloudflare CDN + AWS ALB. +# CRITICAL: Do NOT set to true (unrestricted trust is rejected). +TRUST_PROXY=1 + +# Swagger UI and OpenAPI JSON Specification (#1674) +# Disabled by default in production for security. +# Set to true only if you explicitly intend to expose documentation in production. +SWAGGER_ENABLED=false +DISABLE_SWAGGER=false + # Rate Limiting Configuration (strict for production) RATE_LIMIT_WINDOW_MS=900000 RATE_LIMIT_MAX_REQUESTS=100 @@ -41,8 +55,10 @@ PRISMA_QUERY_TIMEOUT_MS=5000 PRISMA_TX_MAX_WAIT_MS=5000 PRISMA_TX_TIMEOUT_MS=10000 -# CORS Configuration - PRODUCTION -# IMPORTANT: Restrict to your production domains only +# CORS Configuration - PRODUCTION (REQUIRED - Issue #1677) +# CRITICAL: CORS_ALLOWED_ORIGINS is strictly required in production. +# The server will FAIL STARTUP before listening if this variable is unset, empty, or whitespace-only. +# Restrict to your exact production domains only (never use wildcard `*`). CORS_ALLOWED_ORIGINS=https://app.yieldvault.finance,https://www.yieldvault.finance # Email Notification Configuration - PRODUCTION diff --git a/backend/openapi.json b/backend/openapi.json index cb8899594..dbf3cb1dd 100644 --- a/backend/openapi.json +++ b/backend/openapi.json @@ -437,95 +437,6 @@ } ], "paths": { - "/api/v1/vaults": { - "get": { - "tags": [ - "Vault" - ], - "summary": "List vaults", - "description": "Paginated listing of active (non-deleted) vaults. `limit` is capped at 50 and `page` at 1000; exceeding `limit` fails fast with `400` / `LIMIT_EXCEEDED` instead of silently clamping, so a client that asks for an oversized page is never handed a response that looks complete. Rate limited: global 100 req / 15 min per IP; authenticated APIs 30 req / min per key. `429` responses follow the standard error envelope with a `retryAfter` detail.", - "parameters": [ - { - "$ref": "#/components/parameters/pageSize" - }, - { - "$ref": "#/components/parameters/pageNumber" - }, - { - "$ref": "#/components/parameters/correlationId" - } - ], - "responses": { - "200": { - "description": "One page of vaults", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/VaultListResponse" - }, - "example": { - "data": [ - { - "id": "0f1b3a2c-6d4e-4a1b-9f0e-2c5d7e8b9a01", - "aum": 1250000, - "tvlUsd": "1250000.00", - "createdAt": "2026-01-01T00:00:00.000Z", - "updatedAt": "2026-01-02T00:00:00.000Z" - } - ], - "pagination": { - "count": 1, - "limit": 20, - "total": 1, - "nextCursor": null, - "prevCursor": null, - "currentPage": 1, - "totalPages": 1, - "hasNextPage": false, - "hasPrevPage": false - }, - "timestamp": "2026-01-01T00:00:00.000Z" - } - } - } - }, - "400": { - "description": "`limit` above the published ceiling", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorEnvelope" - }, - "example": { - "error": "Bad Request", - "status": 400, - "code": "LIMIT_EXCEEDED", - "message": "limit must not exceed 50 (received 100000).", - "retryable": false, - "details": { - "field": "limit", - "requested": 100000, - "maxLimit": 50, - "defaultLimit": 20, - "maxPage": 1000 - } - } - } - } - }, - "429": { - "description": "Rate limited", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - } - } - }, "/health": { "get": { "tags": [ @@ -754,6 +665,95 @@ } } }, + "/api/v1/vaults": { + "get": { + "tags": [ + "Vault" + ], + "summary": "List vaults", + "description": "Paginated listing of active (non-deleted) vaults. `limit` is capped at 50 and `page` at 1000; exceeding `limit` fails fast with `400` / `LIMIT_EXCEEDED` instead of silently clamping, so a client that asks for an oversized page is never handed a response that looks complete. Rate limited: global 100 req / 15 min per IP; authenticated APIs 30 req / min per key. `429` responses follow the standard error envelope with a `retryAfter` detail.", + "parameters": [ + { + "$ref": "#/components/parameters/pageSize" + }, + { + "$ref": "#/components/parameters/pageNumber" + }, + { + "$ref": "#/components/parameters/correlationId" + } + ], + "responses": { + "200": { + "description": "One page of vaults", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VaultListResponse" + }, + "example": { + "data": [ + { + "id": "0f1b3a2c-6d4e-4a1b-9f0e-2c5d7e8b9a01", + "aum": 1250000, + "tvlUsd": "1250000.00", + "createdAt": "2026-01-01T00:00:00.000Z", + "updatedAt": "2026-01-02T00:00:00.000Z" + } + ], + "pagination": { + "count": 1, + "limit": 20, + "total": 1, + "nextCursor": null, + "prevCursor": null, + "currentPage": 1, + "totalPages": 1, + "hasNextPage": false, + "hasPrevPage": false + }, + "timestamp": "2026-01-01T00:00:00.000Z" + } + } + } + }, + "400": { + "description": "`limit` above the published ceiling", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + }, + "example": { + "error": "Bad Request", + "status": 400, + "code": "LIMIT_EXCEEDED", + "message": "limit must not exceed 50 (received 100000).", + "retryable": false, + "details": { + "field": "limit", + "requested": 100000, + "maxLimit": 50, + "defaultLimit": 20, + "maxPage": 1000 + } + } + } + } + }, + "429": { + "description": "Rate limited", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, "/api/v1/vault/deposits": { "post": { "tags": [ @@ -1166,6 +1166,42 @@ } } }, + "/admin/transfers/in-doubt": { + "get": { + "tags": [ + "admin" + ], + "summary": "GET /admin/transfers/in-doubt", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/transfers/{key}/resolve": { + "post": { + "tags": [ + "admin" + ], + "summary": "POST /admin/transfers/{key}/resolve", + "parameters": [ + { + "name": "key", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, "/admin/rate-limits": { "get": { "tags": [ @@ -1391,19 +1427,6 @@ } } }, - "/api/v1/vault/transactions/export": { - "get": { - "tags": [ - "api" - ], - "summary": "GET /api/v1/vault/transactions/export", - "responses": { - "200": { - "description": "Successful response" - } - } - } - }, "/api/v1/portfolio/holdings": { "get": { "tags": [ @@ -1545,19 +1568,6 @@ } } }, - "/api/vault/transactions/export": { - "get": { - "tags": [ - "api" - ], - "summary": "GET /api/vault/transactions/export", - "responses": { - "200": { - "description": "Successful response" - } - } - } - }, "/api/vaults": { "get": { "tags": [ @@ -1649,19 +1659,6 @@ } } }, - "/webhooks/verify": { - "post": { - "tags": [ - "webhooks" - ], - "summary": "POST /webhooks/verify", - "responses": { - "200": { - "description": "Successful response" - } - } - } - }, "/api/v1/vaults/{id}/health": { "get": { "tags": [ @@ -1734,12 +1731,12 @@ } } }, - "/admin/apy/backfill": { - "post": { + "/api/vault/transactions/export": { + "get": { "tags": [ - "admin" + "api" ], - "summary": "POST /admin/apy/backfill", + "summary": "GET /api/vault/transactions/export", "responses": { "200": { "description": "Successful response" @@ -1747,23 +1744,12 @@ } } }, - "/admin/maintenance": { + "/api/v1/vault/transactions/export": { "get": { "tags": [ - "admin" - ], - "summary": "GET /admin/maintenance", - "responses": { - "200": { - "description": "Successful response" - } - } - }, - "post": { - "tags": [ - "admin" + "api" ], - "summary": "POST /admin/maintenance", + "summary": "GET /api/v1/vault/transactions/export", "responses": { "200": { "description": "Successful response" @@ -1771,23 +1757,23 @@ } } }, - "/admin/maintenance/windows": { - "get": { + "/admin/transactions/backfill": { + "post": { "tags": [ "admin" ], - "summary": "GET /admin/maintenance/windows", + "summary": "POST /admin/transactions/backfill", "responses": { "200": { "description": "Successful response" } } }, - "post": { + "get": { "tags": [ "admin" ], - "summary": "POST /admin/maintenance/windows", + "summary": "GET /admin/transactions/backfill", "responses": { "200": { "description": "Successful response" @@ -1795,15 +1781,15 @@ } } }, - "/admin/maintenance/windows/{windowId}": { - "delete": { + "/admin/transactions/backfill/{jobId}": { + "get": { "tags": [ "admin" ], - "summary": "DELETE /admin/maintenance/windows/{windowId}", + "summary": "GET /admin/transactions/backfill/{jobId}", "parameters": [ { - "name": "windowId", + "name": "jobId", "in": "path", "required": true, "schema": { @@ -1818,12 +1804,12 @@ } } }, - "/admin/config-changes": { - "get": { + "/webhooks/verify": { + "post": { "tags": [ - "admin" + "webhooks" ], - "summary": "GET /admin/config-changes", + "summary": "POST /webhooks/verify", "responses": { "200": { "description": "Successful response" @@ -1831,23 +1817,22 @@ } } }, - "/admin/feature-flags/overrides": { - "get": { + "/admin/webhooks/{id}/verify": { + "post": { "tags": [ "admin" ], - "summary": "GET /admin/feature-flags/overrides", - "responses": { - "200": { - "description": "Successful response" + "summary": "POST /admin/webhooks/{id}/verify", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } } - } - }, - "post": { - "tags": [ - "admin" ], - "summary": "POST /admin/feature-flags/overrides", "responses": { "200": { "description": "Successful response" @@ -1855,12 +1840,12 @@ } } }, - "/admin/feature-flags/overrides/{id}": { - "delete": { + "/admin/webhooks/{id}": { + "patch": { "tags": [ "admin" ], - "summary": "DELETE /admin/feature-flags/overrides/{id}", + "summary": "PATCH /admin/webhooks/{id}", "parameters": [ { "name": "id", @@ -1876,14 +1861,22 @@ "description": "Successful response" } } - } - }, - "/admin/cache/stats": { - "get": { + }, + "delete": { "tags": [ "admin" ], - "summary": "GET /admin/cache/stats", + "summary": "DELETE /admin/webhooks/{id}", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Successful response" @@ -1891,12 +1884,22 @@ } } }, - "/admin/cache/redis-status": { - "get": { + "/admin/webhooks/{id}/restore": { + "post": { "tags": [ "admin" ], - "summary": "GET /admin/cache/redis-status", + "summary": "POST /admin/webhooks/{id}/restore", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Successful response" @@ -1904,12 +1907,12 @@ } } }, - "/admin/cache": { - "delete": { + "/admin/webhooks/dead-letter": { + "get": { "tags": [ "admin" ], - "summary": "DELETE /admin/cache", + "summary": "GET /admin/webhooks/dead-letter", "responses": { "200": { "description": "Successful response" @@ -1917,12 +1920,22 @@ } } }, - "/admin/cache/invalidate": { + "/admin/webhooks/dead-letter/{id}/retry": { "post": { "tags": [ "admin" ], - "summary": "POST /admin/cache/invalidate", + "summary": "POST /admin/webhooks/dead-letter/{id}/retry", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Successful response" @@ -1930,12 +1943,12 @@ } } }, - "/admin/events/replay": { - "post": { + "/admin/webhooks/deliveries": { + "get": { "tags": [ "admin" ], - "summary": "POST /admin/events/replay", + "summary": "GET /admin/webhooks/deliveries", "responses": { "200": { "description": "Successful response" @@ -1943,12 +1956,12 @@ } } }, - "/admin/withdrawal-limits/override": { + "/api/v1/webhooks/verify": { "post": { "tags": [ - "admin" + "api" ], - "summary": "POST /admin/withdrawal-limits/override", + "summary": "POST /api/v1/webhooks/verify", "responses": { "200": { "description": "Successful response" @@ -1956,12 +1969,12 @@ } } }, - "/admin/withdrawal-limits/audit": { + "/admin/webhooks/deduplication/metrics": { "get": { "tags": [ "admin" ], - "summary": "GET /admin/withdrawal-limits/audit", + "summary": "GET /admin/webhooks/deduplication/metrics", "responses": { "200": { "description": "Successful response" @@ -1969,12 +1982,12 @@ } } }, - "/admin/emails/queue": { + "/admin/webhooks/deduplication/entries": { "get": { "tags": [ "admin" ], - "summary": "GET /admin/emails/queue", + "summary": "GET /admin/webhooks/deduplication/entries", "responses": { "200": { "description": "Successful response" @@ -1982,15 +1995,15 @@ } } }, - "/admin/emails/replay/{id}": { - "post": { + "/admin/webhooks/deduplication/{eventId}": { + "delete": { "tags": [ "admin" ], - "summary": "POST /admin/emails/replay/{id}", + "summary": "DELETE /admin/webhooks/deduplication/{eventId}", "parameters": [ { - "name": "id", + "name": "eventId", "in": "path", "required": true, "schema": { @@ -2005,25 +2018,12 @@ } } }, - "/admin/allowlist/add": { - "post": { - "tags": [ - "admin" - ], - "summary": "POST /admin/allowlist/add", - "responses": { - "200": { - "description": "Successful response" - } - } - } - }, - "/admin/allowlist/remove": { + "/admin/webhooks/deduplication": { "delete": { "tags": [ "admin" ], - "summary": "DELETE /admin/allowlist/remove", + "summary": "DELETE /admin/webhooks/deduplication", "responses": { "200": { "description": "Successful response" @@ -2031,12 +2031,12 @@ } } }, - "/admin/wallet-aliases": { + "/admin/exports/jobs": { "get": { "tags": [ "admin" ], - "summary": "GET /admin/wallet-aliases", + "summary": "GET /admin/exports/jobs", "responses": { "200": { "description": "Successful response" @@ -2044,15 +2044,15 @@ } } }, - "/admin/wallet-aliases/{canonicalId}": { - "delete": { + "/admin/exports/jobs/{id}/verify": { + "post": { "tags": [ "admin" ], - "summary": "DELETE /admin/wallet-aliases/{canonicalId}", + "summary": "POST /admin/exports/jobs/{id}/verify", "parameters": [ { - "name": "canonicalId", + "name": "id", "in": "path", "required": true, "schema": { @@ -2067,23 +2067,25 @@ } } }, - "/admin/impersonate/sessions": { + "/admin/exports/bulk": { "post": { "tags": [ "admin" ], - "summary": "POST /admin/impersonate/sessions", + "summary": "POST /admin/exports/bulk", "responses": { "200": { "description": "Successful response" } } - }, + } + }, + "/admin/exports/bulk/jobs": { "get": { "tags": [ "admin" ], - "summary": "GET /admin/impersonate/sessions", + "summary": "GET /admin/exports/bulk/jobs", "responses": { "200": { "description": "Successful response" @@ -2091,12 +2093,12 @@ } } }, - "/admin/impersonate/sessions/{id}": { - "delete": { + "/admin/exports/bulk/jobs/{id}": { + "get": { "tags": [ "admin" ], - "summary": "DELETE /admin/impersonate/sessions/{id}", + "summary": "GET /admin/exports/bulk/jobs/{id}", "parameters": [ { "name": "id", @@ -2114,15 +2116,38 @@ } } }, - "/admin/impersonate/{wallet}": { + "/admin/exports/bulk/jobs/{id}/cancel": { + "post": { + "tags": [ + "admin" + ], + "summary": "POST /admin/exports/bulk/jobs/{id}/cancel", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/exports/bulk/artifacts/{artifactId}": { "get": { "tags": [ "admin" ], - "summary": "GET /admin/impersonate/{wallet}", + "summary": "GET /admin/exports/bulk/artifacts/{artifactId}", "parameters": [ { - "name": "wallet", + "name": "artifactId", "in": "path", "required": true, "schema": { @@ -2137,12 +2162,25 @@ } } }, - "/admin/receipts": { + "/admin/reports/exports": { + "post": { + "tags": [ + "admin" + ], + "summary": "POST /admin/reports/exports", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/reports/exports/manifests": { "get": { "tags": [ "admin" ], - "summary": "GET /admin/receipts", + "summary": "GET /admin/reports/exports/manifests", "responses": { "200": { "description": "Successful response" @@ -2150,12 +2188,12 @@ } } }, - "/admin/receipts/{id}": { + "/admin/reports/exports/manifests/{id}": { "get": { "tags": [ "admin" ], - "summary": "GET /admin/receipts/{id}", + "summary": "GET /admin/reports/exports/manifests/{id}", "parameters": [ { "name": "id", @@ -2173,12 +2211,12 @@ } } }, - "/admin/receipts/{id}/verify": { - "get": { + "/admin/reports/exports/manifests/{id}/verify": { + "post": { "tags": [ "admin" ], - "summary": "GET /admin/receipts/{id}/verify", + "summary": "POST /admin/reports/exports/manifests/{id}/verify", "parameters": [ { "name": "id", @@ -2196,12 +2234,12 @@ } } }, - "/admin/api-keys/register": { + "/admin/apy/backfill": { "post": { "tags": [ "admin" ], - "summary": "POST /admin/api-keys/register", + "summary": "POST /admin/apy/backfill", "responses": { "200": { "description": "Successful response" @@ -2209,25 +2247,23 @@ } } }, - "/admin/api-keys/rotate": { - "post": { + "/admin/maintenance": { + "get": { "tags": [ "admin" ], - "summary": "POST /admin/api-keys/rotate", + "summary": "GET /admin/maintenance", "responses": { "200": { "description": "Successful response" } } - } - }, - "/admin/api-keys/revoke": { + }, "post": { "tags": [ "admin" ], - "summary": "POST /admin/api-keys/revoke", + "summary": "POST /admin/maintenance", "responses": { "200": { "description": "Successful response" @@ -2235,12 +2271,23 @@ } } }, - "/admin/api-keys/audit-events": { + "/admin/maintenance/windows": { "get": { "tags": [ "admin" ], - "summary": "GET /admin/api-keys/audit-events", + "summary": "GET /admin/maintenance/windows", + "responses": { + "200": { + "description": "Successful response" + } + } + }, + "post": { + "tags": [ + "admin" + ], + "summary": "POST /admin/maintenance/windows", "responses": { "200": { "description": "Successful response" @@ -2248,15 +2295,15 @@ } } }, - "/admin/webhooks/{id}/verify": { - "post": { + "/admin/maintenance/windows/{windowId}": { + "delete": { "tags": [ "admin" ], - "summary": "POST /admin/webhooks/{id}/verify", + "summary": "DELETE /admin/maintenance/windows/{windowId}", "parameters": [ { - "name": "id", + "name": "windowId", "in": "path", "required": true, "schema": { @@ -2271,33 +2318,49 @@ } } }, - "/admin/webhooks/{id}": { - "patch": { + "/admin/config-changes": { + "get": { "tags": [ "admin" ], - "summary": "PATCH /admin/webhooks/{id}", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } + "summary": "GET /admin/config-changes", + "responses": { + "200": { + "description": "Successful response" } + } + } + }, + "/admin/feature-flags/overrides": { + "get": { + "tags": [ + "admin" ], + "summary": "GET /admin/feature-flags/overrides", "responses": { "200": { "description": "Successful response" } } }, + "post": { + "tags": [ + "admin" + ], + "summary": "POST /admin/feature-flags/overrides", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/feature-flags/overrides/{id}": { "delete": { "tags": [ "admin" ], - "summary": "DELETE /admin/webhooks/{id}", + "summary": "DELETE /admin/feature-flags/overrides/{id}", "parameters": [ { "name": "id", @@ -2315,22 +2378,25 @@ } } }, - "/admin/webhooks/{id}/restore": { - "post": { + "/admin/cache/stats": { + "get": { "tags": [ "admin" ], - "summary": "POST /admin/webhooks/{id}/restore", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } + "summary": "GET /admin/cache/stats", + "responses": { + "200": { + "description": "Successful response" } + } + } + }, + "/admin/cache/redis-status": { + "get": { + "tags": [ + "admin" ], + "summary": "GET /admin/cache/redis-status", "responses": { "200": { "description": "Successful response" @@ -2338,12 +2404,12 @@ } } }, - "/admin/webhooks/dead-letter": { - "get": { + "/admin/cache": { + "delete": { "tags": [ "admin" ], - "summary": "GET /admin/webhooks/dead-letter", + "summary": "DELETE /admin/cache", "responses": { "200": { "description": "Successful response" @@ -2351,22 +2417,25 @@ } } }, - "/admin/webhooks/dead-letter/{id}/retry": { + "/admin/cache/invalidate": { "post": { "tags": [ "admin" ], - "summary": "POST /admin/webhooks/dead-letter/{id}/retry", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } + "summary": "POST /admin/cache/invalidate", + "responses": { + "200": { + "description": "Successful response" } + } + } + }, + "/admin/events/replay": { + "post": { + "tags": [ + "admin" ], + "summary": "POST /admin/events/replay", "responses": { "200": { "description": "Successful response" @@ -2374,12 +2443,25 @@ } } }, - "/admin/webhooks/deliveries": { + "/admin/withdrawal-limits/override": { + "post": { + "tags": [ + "admin" + ], + "summary": "POST /admin/withdrawal-limits/override", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/withdrawal-limits/audit": { "get": { "tags": [ "admin" ], - "summary": "GET /admin/webhooks/deliveries", + "summary": "GET /admin/withdrawal-limits/audit", "responses": { "200": { "description": "Successful response" @@ -2387,12 +2469,48 @@ } } }, - "/api/v1/webhooks/verify": { + "/admin/emails/queue": { + "get": { + "tags": [ + "admin" + ], + "summary": "GET /admin/emails/queue", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/emails/replay/{id}": { + "post": { + "tags": [ + "admin" + ], + "summary": "POST /admin/emails/replay/{id}", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/allowlist/add": { "post": { "tags": [ - "api" + "admin" ], - "summary": "POST /api/v1/webhooks/verify", + "summary": "POST /admin/allowlist/add", "responses": { "200": { "description": "Successful response" @@ -2400,12 +2518,12 @@ } } }, - "/admin/audit/logs": { - "get": { + "/admin/allowlist/remove": { + "delete": { "tags": [ "admin" ], - "summary": "GET /admin/audit/logs", + "summary": "DELETE /admin/allowlist/remove", "responses": { "200": { "description": "Successful response" @@ -2413,12 +2531,12 @@ } } }, - "/admin/exports/jobs": { + "/admin/wallet-aliases": { "get": { "tags": [ "admin" ], - "summary": "GET /admin/exports/jobs", + "summary": "GET /admin/wallet-aliases", "responses": { "200": { "description": "Successful response" @@ -2426,15 +2544,15 @@ } } }, - "/admin/exports/jobs/{id}/verify": { - "post": { + "/admin/wallet-aliases/{canonicalId}": { + "delete": { "tags": [ "admin" ], - "summary": "POST /admin/exports/jobs/{id}/verify", + "summary": "DELETE /admin/wallet-aliases/{canonicalId}", "parameters": [ { - "name": "id", + "name": "canonicalId", "in": "path", "required": true, "schema": { @@ -2449,25 +2567,23 @@ } } }, - "/admin/exports/bulk": { + "/admin/impersonate/sessions": { "post": { "tags": [ "admin" ], - "summary": "POST /admin/exports/bulk", + "summary": "POST /admin/impersonate/sessions", "responses": { "200": { "description": "Successful response" } } - } - }, - "/admin/exports/bulk/jobs": { + }, "get": { "tags": [ "admin" ], - "summary": "GET /admin/exports/bulk/jobs", + "summary": "GET /admin/impersonate/sessions", "responses": { "200": { "description": "Successful response" @@ -2475,12 +2591,12 @@ } } }, - "/admin/exports/bulk/jobs/{id}": { - "get": { + "/admin/impersonate/sessions/{id}": { + "delete": { "tags": [ "admin" ], - "summary": "GET /admin/exports/bulk/jobs/{id}", + "summary": "DELETE /admin/impersonate/sessions/{id}", "parameters": [ { "name": "id", @@ -2498,15 +2614,15 @@ } } }, - "/admin/exports/bulk/jobs/{id}/cancel": { - "post": { + "/admin/impersonate/{wallet}": { + "get": { "tags": [ "admin" ], - "summary": "POST /admin/exports/bulk/jobs/{id}/cancel", + "summary": "GET /admin/impersonate/{wallet}", "parameters": [ { - "name": "id", + "name": "wallet", "in": "path", "required": true, "schema": { @@ -2521,22 +2637,12 @@ } } }, - "/admin/exports/bulk/artifacts/{artifactId}": { + "/admin/receipts": { "get": { "tags": [ "admin" ], - "summary": "GET /admin/exports/bulk/artifacts/{artifactId}", - "parameters": [ - { - "name": "artifactId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], + "summary": "GET /admin/receipts", "responses": { "200": { "description": "Successful response" @@ -2544,12 +2650,22 @@ } } }, - "/admin/prisma/config": { + "/admin/receipts/{id}": { "get": { "tags": [ "admin" ], - "summary": "GET /admin/prisma/config", + "summary": "GET /admin/receipts/{id}", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Successful response" @@ -2557,12 +2673,22 @@ } } }, - "/admin/jobs/monitor": { + "/admin/receipts/{id}/verify": { "get": { "tags": [ "admin" ], - "summary": "GET /admin/jobs/monitor", + "summary": "GET /admin/receipts/{id}/verify", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Successful response" @@ -2570,12 +2696,12 @@ } } }, - "/admin/jobs/metrics": { - "get": { + "/admin/api-keys/register": { + "post": { "tags": [ "admin" ], - "summary": "GET /admin/jobs/metrics", + "summary": "POST /admin/api-keys/register", "responses": { "200": { "description": "Successful response" @@ -2583,23 +2709,25 @@ } } }, - "/admin/transactions/backfill": { + "/admin/api-keys/rotate": { "post": { "tags": [ "admin" ], - "summary": "POST /admin/transactions/backfill", + "summary": "POST /admin/api-keys/rotate", "responses": { "200": { "description": "Successful response" } } - }, - "get": { + } + }, + "/admin/api-keys/revoke": { + "post": { "tags": [ "admin" ], - "summary": "GET /admin/transactions/backfill", + "summary": "POST /admin/api-keys/revoke", "responses": { "200": { "description": "Successful response" @@ -2607,22 +2735,12 @@ } } }, - "/admin/transactions/backfill/{jobId}": { + "/admin/api-keys/audit-events": { "get": { "tags": [ "admin" ], - "summary": "GET /admin/transactions/backfill/{jobId}", - "parameters": [ - { - "name": "jobId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], + "summary": "GET /admin/api-keys/audit-events", "responses": { "200": { "description": "Successful response" @@ -2630,12 +2748,12 @@ } } }, - "/admin/governance/snapshots": { + "/admin/audit/logs": { "get": { "tags": [ "admin" ], - "summary": "GET /admin/governance/snapshots", + "summary": "GET /admin/audit/logs", "responses": { "200": { "description": "Successful response" @@ -2643,12 +2761,12 @@ } } }, - "/admin/governance/snapshots/export": { - "post": { + "/admin/prisma/config": { + "get": { "tags": [ "admin" ], - "summary": "POST /admin/governance/snapshots/export", + "summary": "GET /admin/prisma/config", "responses": { "200": { "description": "Successful response" @@ -2656,12 +2774,12 @@ } } }, - "/admin/reports/exports": { - "post": { + "/admin/jobs/monitor": { + "get": { "tags": [ "admin" ], - "summary": "POST /admin/reports/exports", + "summary": "GET /admin/jobs/monitor", "responses": { "200": { "description": "Successful response" @@ -2669,12 +2787,12 @@ } } }, - "/admin/reports/exports/manifests": { + "/admin/jobs/metrics": { "get": { "tags": [ "admin" ], - "summary": "GET /admin/reports/exports/manifests", + "summary": "GET /admin/jobs/metrics", "responses": { "200": { "description": "Successful response" @@ -2682,22 +2800,12 @@ } } }, - "/admin/reports/exports/manifests/{id}": { + "/admin/governance/snapshots": { "get": { "tags": [ "admin" ], - "summary": "GET /admin/reports/exports/manifests/{id}", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], + "summary": "GET /admin/governance/snapshots", "responses": { "200": { "description": "Successful response" @@ -2705,22 +2813,12 @@ } } }, - "/admin/reports/exports/manifests/{id}/verify": { + "/admin/governance/snapshots/export": { "post": { "tags": [ "admin" ], - "summary": "POST /admin/reports/exports/manifests/{id}/verify", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], + "summary": "POST /admin/governance/snapshots/export", "responses": { "200": { "description": "Successful response" @@ -2969,68 +3067,6 @@ } } }, - "/admin/webhooks/deduplication/metrics": { - "get": { - "tags": [ - "admin" - ], - "summary": "GET /admin/webhooks/deduplication/metrics", - "responses": { - "200": { - "description": "Successful response" - } - } - } - }, - "/admin/webhooks/deduplication/entries": { - "get": { - "tags": [ - "admin" - ], - "summary": "GET /admin/webhooks/deduplication/entries", - "responses": { - "200": { - "description": "Successful response" - } - } - } - }, - "/admin/webhooks/deduplication/{eventId}": { - "delete": { - "tags": [ - "admin" - ], - "summary": "DELETE /admin/webhooks/deduplication/{eventId}", - "parameters": [ - { - "name": "eventId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Successful response" - } - } - } - }, - "/admin/webhooks/deduplication": { - "delete": { - "tags": [ - "admin" - ], - "summary": "DELETE /admin/webhooks/deduplication", - "responses": { - "200": { - "description": "Successful response" - } - } - } - }, "/admin/analytics/wallet-activity/heatmap": { "get": { "tags": [ @@ -3044,19 +3080,6 @@ } } }, - "/health/probes": { - "get": { - "tags": [ - "health" - ], - "summary": "GET /health/probes", - "responses": { - "200": { - "description": "Successful response" - } - } - } - }, "/admin/wal/entries": { "get": { "tags": [ @@ -3368,6 +3391,19 @@ } } } + }, + "/health/probes": { + "get": { + "tags": [ + "health" + ], + "summary": "GET /health/probes", + "responses": { + "200": { + "description": "Successful response" + } + } + } } } } \ No newline at end of file diff --git a/backend/src/__tests__/cors.test.ts b/backend/src/__tests__/cors.test.ts index bd0876633..165fcf33f 100644 --- a/backend/src/__tests__/cors.test.ts +++ b/backend/src/__tests__/cors.test.ts @@ -1,76 +1,162 @@ import request from 'supertest'; import express, { Express } from 'express'; -import { corsMiddleware } from '../middleware/cors'; +import { corsMiddleware, validateCorsConfig, getCORSOrigins } from '../middleware/cors'; describe('CORS Middleware', () => { - let app: Express; - - beforeAll(() => { - app = express(); - // Set a dummy environment origin for testing - process.env.CORS_ALLOWED_ORIGINS = 'http://trusted-frontend.com,https://app.yieldvault.finance'; - process.env.NODE_ENV = 'production'; - - app.use(corsMiddleware); - app.get('/test', (req, res) => { - res.status(200).json({ message: 'Success' }); - }); - }); + const originalEnv = process.env; - it('should allow requests from a trusted origin', async () => { - const response = await request(app) - .get('/test') - .set('Origin', 'http://trusted-frontend.com'); + beforeEach(() => { + process.env = { ...originalEnv }; + }); - expect(response.status).toBe(200); - expect(response.header['access-control-allow-origin']).toBe('http://trusted-frontend.com'); - expect(response.header['access-control-allow-credentials']).toBe('true'); + afterAll(() => { + process.env = originalEnv; }); - it('should allow requests from another trusted origin', async () => { - const response = await request(app) - .get('/test') - .set('Origin', 'https://app.yieldvault.finance'); + describe('Configuration validation (#1677)', () => { + it('fails fast when NODE_ENV=production and CORS_ALLOWED_ORIGINS is unset', () => { + delete process.env.CORS_ALLOWED_ORIGINS; + process.env.NODE_ENV = 'production'; - expect(response.status).toBe(200); - expect(response.header['access-control-allow-origin']).toBe('https://app.yieldvault.finance'); - }); + expect(() => validateCorsConfig()).toThrow( + /Missing required environment variable: CORS_ALLOWED_ORIGINS is not set or is empty in production/i, + ); + expect(() => getCORSOrigins()).toThrow( + /Missing required environment variable: CORS_ALLOWED_ORIGINS/i, + ); + }); - it('should reject requests from an untrusted origin with 403', async () => { - const response = await request(app) - .get('/test') - .set('Origin', 'http://malicious-site.com'); + it('fails fast when NODE_ENV=production and CORS_ALLOWED_ORIGINS is empty', () => { + process.env.CORS_ALLOWED_ORIGINS = ''; + process.env.NODE_ENV = 'production'; - expect(response.status).toBe(403); - expect(response.body).toHaveProperty('error', 'Forbidden'); - expect(response.body).toHaveProperty('message', 'CORS policy: This origin is not allowed access.'); - expect(response.header['access-control-allow-origin']).toBeUndefined(); - }); + expect(() => validateCorsConfig()).toThrow( + /Missing required environment variable: CORS_ALLOWED_ORIGINS is not set or is empty in production/i, + ); + }); - it('should allow requests with no origin (e.g., mobile apps, curl)', async () => { - const response = await request(app).get('/test'); + it('fails fast when NODE_ENV=production and CORS_ALLOWED_ORIGINS is whitespace-only', () => { + process.env.CORS_ALLOWED_ORIGINS = ' \t \n '; + process.env.NODE_ENV = 'production'; - expect(response.status).toBe(200); - expect(response.body).toHaveProperty('message', 'Success'); - }); + expect(() => validateCorsConfig()).toThrow( + /Missing required environment variable: CORS_ALLOWED_ORIGINS is not set or is empty in production/i, + ); + }); + + it('provides clear corrective instructions in the error message', () => { + process.env.NODE_ENV = 'production'; + delete process.env.CORS_ALLOWED_ORIGINS; - it('should return correct headers for preflight OPTIONS requests from trusted origin', async () => { - const response = await request(app) - .options('/test') - .set('Origin', 'http://trusted-frontend.com') - .set('Access-Control-Request-Method', 'POST'); + expect(() => validateCorsConfig()).toThrow( + /Please set CORS_ALLOWED_ORIGINS to a comma-separated list of trusted origins/i, + ); + }); + + it('allows valid origins in production without wildcard fallback', () => { + process.env.NODE_ENV = 'production'; + process.env.CORS_ALLOWED_ORIGINS = 'https://app.yieldvault.finance, https://admin.yieldvault.finance'; + + expect(() => validateCorsConfig()).not.toThrow(); + const origins = getCORSOrigins(); + expect(origins).toEqual([ + 'https://app.yieldvault.finance', + 'https://admin.yieldvault.finance', + ]); + expect(origins).not.toContain('*'); + }); + + it('leaves development behavior unchanged when CORS_ALLOWED_ORIGINS is unset', () => { + process.env.NODE_ENV = 'development'; + delete process.env.CORS_ALLOWED_ORIGINS; - expect(response.status).toBe(200); - expect(response.header['access-control-allow-origin']).toBe('http://trusted-frontend.com'); - expect(response.header['access-control-allow-methods']).toContain('POST'); + expect(() => validateCorsConfig()).not.toThrow(); + const origins = getCORSOrigins(); + expect(origins).toHaveLength(1); + expect(origins[0]).toBeInstanceOf(RegExp); + expect((origins[0] as RegExp).test('http://localhost:3000')).toBe(true); + }); + + it('respects explicitly configured origins in development', () => { + process.env.NODE_ENV = 'development'; + process.env.CORS_ALLOWED_ORIGINS = 'http://localhost:5173,http://localhost:3000'; + + expect(() => validateCorsConfig()).not.toThrow(); + const origins = getCORSOrigins(); + expect(origins).toEqual(['http://localhost:5173', 'http://localhost:3000']); + }); }); - it('should reject preflight OPTIONS requests from untrusted origin with 403', async () => { - const response = await request(app) - .options('/test') - .set('Origin', 'http://malicious-site.com') - .set('Access-Control-Request-Method', 'POST'); + describe('HTTP request handling', () => { + let app: Express; + + beforeEach(() => { + app = express(); + process.env.CORS_ALLOWED_ORIGINS = 'http://trusted-frontend.com,https://app.yieldvault.finance'; + process.env.NODE_ENV = 'production'; - expect(response.status).toBe(403); + app.use(corsMiddleware); + app.get('/test', (req, res) => { + res.status(200).json({ message: 'Success' }); + }); + }); + + it('should allow requests from a trusted origin', async () => { + const response = await request(app) + .get('/test') + .set('Origin', 'http://trusted-frontend.com'); + + expect(response.status).toBe(200); + expect(response.header['access-control-allow-origin']).toBe('http://trusted-frontend.com'); + expect(response.header['access-control-allow-credentials']).toBe('true'); + }); + + it('should allow requests from another trusted origin', async () => { + const response = await request(app) + .get('/test') + .set('Origin', 'https://app.yieldvault.finance'); + + expect(response.status).toBe(200); + expect(response.header['access-control-allow-origin']).toBe('https://app.yieldvault.finance'); + }); + + it('should reject requests from an untrusted origin with 403', async () => { + const response = await request(app) + .get('/test') + .set('Origin', 'http://malicious-site.com'); + + expect(response.status).toBe(403); + expect(response.body).toHaveProperty('error', 'Forbidden'); + expect(response.body).toHaveProperty('message', 'CORS policy: This origin is not allowed access.'); + expect(response.header['access-control-allow-origin']).toBeUndefined(); + }); + + it('should allow requests with no origin (e.g., mobile apps, curl)', async () => { + const response = await request(app).get('/test'); + + expect(response.status).toBe(200); + expect(response.body).toHaveProperty('message', 'Success'); + }); + + it('should return correct headers for preflight OPTIONS requests from trusted origin', async () => { + const response = await request(app) + .options('/test') + .set('Origin', 'http://trusted-frontend.com') + .set('Access-Control-Request-Method', 'POST'); + + expect(response.status).toBe(200); + expect(response.header['access-control-allow-origin']).toBe('http://trusted-frontend.com'); + expect(response.header['access-control-allow-methods']).toContain('POST'); + }); + + it('should reject preflight OPTIONS requests from untrusted origin with 403', async () => { + const response = await request(app) + .options('/test') + .set('Origin', 'http://malicious-site.com') + .set('Access-Control-Request-Method', 'POST'); + + expect(response.status).toBe(403); + }); }); }); + diff --git a/backend/src/__tests__/openapi.test.ts b/backend/src/__tests__/openapi.test.ts index 2b02dc4e3..fc2d13778 100644 --- a/backend/src/__tests__/openapi.test.ts +++ b/backend/src/__tests__/openapi.test.ts @@ -1,8 +1,18 @@ import request from 'supertest'; import express from 'express'; -import { specs, setupSwagger } from '../swagger'; +import { specs, setupSwagger, isSwaggerEnabled } from '../swagger'; describe('OpenAPI documentation', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterAll(() => { + process.env = originalEnv; + }); + const spec = specs as unknown as { openapi: string; info: { title: string; description: string }; @@ -51,16 +61,119 @@ describe('OpenAPI documentation', () => { ); }); - it('serves the spec and Swagger UI over HTTP', async () => { - const app = express(); - setupSwagger(app); + describe('isSwaggerEnabled policy (#1674)', () => { + it('is disabled by default in production', () => { + expect(isSwaggerEnabled({ NODE_ENV: 'production' })).toBe(false); + expect(isSwaggerEnabled({ NODE_ENV: 'production', SWAGGER_ENABLED: undefined })).toBe(false); + expect(isSwaggerEnabled({ NODE_ENV: 'production', SWAGGER_ENABLED: '' })).toBe(false); + }); + + it('is enabled in production when SWAGGER_ENABLED=true', () => { + expect(isSwaggerEnabled({ NODE_ENV: 'production', SWAGGER_ENABLED: 'true' })).toBe(true); + }); + + it('rejects invalid or missing SWAGGER_ENABLED values in production', () => { + expect(isSwaggerEnabled({ NODE_ENV: 'production', SWAGGER_ENABLED: '1' })).toBe(false); + expect(isSwaggerEnabled({ NODE_ENV: 'production', SWAGGER_ENABLED: 'yes' })).toBe(false); + expect(isSwaggerEnabled({ NODE_ENV: 'production', SWAGGER_ENABLED: 'TRUE' })).toBe(false); + expect(isSwaggerEnabled({ NODE_ENV: 'production', SWAGGER_ENABLED: 'false' })).toBe(false); + }); + + it('is disabled in production even if SWAGGER_ENABLED=true when DISABLE_SWAGGER=true', () => { + expect( + isSwaggerEnabled({ + NODE_ENV: 'production', + SWAGGER_ENABLED: 'true', + DISABLE_SWAGGER: 'true', + }), + ).toBe(false); + }); + + it('is enabled by default in development', () => { + expect(isSwaggerEnabled({ NODE_ENV: 'development' })).toBe(true); + expect(isSwaggerEnabled({ NODE_ENV: 'development', SWAGGER_ENABLED: 'false' })).toBe(true); + }); + + it('is enabled by default in test', () => { + expect(isSwaggerEnabled({ NODE_ENV: 'test' })).toBe(true); + }); + + it('is enabled when NODE_ENV is unset or empty', () => { + expect(isSwaggerEnabled({})).toBe(true); + expect(isSwaggerEnabled({ NODE_ENV: '' })).toBe(true); + }); + + it('is disabled in non-production when DISABLE_SWAGGER=true', () => { + expect(isSwaggerEnabled({ NODE_ENV: 'development', DISABLE_SWAGGER: 'true' })).toBe(false); + expect(isSwaggerEnabled({ NODE_ENV: 'test', DISABLE_SWAGGER: 'true' })).toBe(false); + expect(isSwaggerEnabled({ DISABLE_SWAGGER: 'true' })).toBe(false); + }); + }); + + describe('HTTP mount behavior', () => { + it('serves the spec and Swagger UI over HTTP in non-production', async () => { + delete process.env.DISABLE_SWAGGER; + delete process.env.SWAGGER_ENABLED; + process.env.NODE_ENV = 'development'; - const res = await request(app).get('/docs/openapi.json'); - expect(res.status).toBe(200); - expect(res.body.openapi).toBe('3.1.0'); + const app = express(); + setupSwagger(app); - const ui = await request(app).get('/docs/'); - expect(ui.status).toBe(200); - expect(ui.text).toMatch(/swagger/i); + const res = await request(app).get('/docs/openapi.json'); + expect(res.status).toBe(200); + expect(res.body.openapi).toBe('3.1.0'); + + const ui = await request(app).get('/docs/'); + expect(ui.status).toBe(200); + expect(ui.text).toMatch(/swagger/i); + }); + + it('does not mount Swagger UI or openapi.json in production by default', async () => { + process.env.NODE_ENV = 'production'; + delete process.env.SWAGGER_ENABLED; + delete process.env.DISABLE_SWAGGER; + + const app = express(); + setupSwagger(app); + + const res = await request(app).get('/docs/openapi.json'); + expect(res.status).toBe(404); + + const ui = await request(app).get('/docs/'); + expect(ui.status).toBe(404); + }); + + it('mounts Swagger UI and openapi.json in production when SWAGGER_ENABLED=true', async () => { + process.env.NODE_ENV = 'production'; + process.env.SWAGGER_ENABLED = 'true'; + delete process.env.DISABLE_SWAGGER; + + const app = express(); + setupSwagger(app); + + const res = await request(app).get('/docs/openapi.json'); + expect(res.status).toBe(200); + expect(res.body.openapi).toBe('3.1.0'); + + const ui = await request(app).get('/docs/'); + expect(ui.status).toBe(200); + expect(ui.text).toMatch(/swagger/i); + }); + + it('does not mount Swagger in production when SWAGGER_ENABLED=true but DISABLE_SWAGGER=true', async () => { + process.env.NODE_ENV = 'production'; + process.env.SWAGGER_ENABLED = 'true'; + process.env.DISABLE_SWAGGER = 'true'; + + const app = express(); + setupSwagger(app); + + const res = await request(app).get('/docs/openapi.json'); + expect(res.status).toBe(404); + + const ui = await request(app).get('/docs/'); + expect(ui.status).toBe(404); + }); }); }); + diff --git a/backend/src/__tests__/rateLimit.test.ts b/backend/src/__tests__/rateLimit.test.ts index 99b776b5a..54ea3fcf7 100644 --- a/backend/src/__tests__/rateLimit.test.ts +++ b/backend/src/__tests__/rateLimit.test.ts @@ -1,6 +1,6 @@ import express, { Request, Response } from 'express'; import request from 'supertest'; -import { createLimiter, extractRateLimitKey, resolveClientIp } from '../rateLimiter'; +import { createLimiter, extractRateLimitKey, resolveClientIp, parseTrustProxyConfig } from '../rateLimiter'; function fakeReq(opts: { trustProxy?: unknown; @@ -74,3 +74,161 @@ describe('rate limiter client IP resolution', () => { expect((await request(app).get('/t')).status).toBe(429); }); }); + +describe('TRUST_PROXY configuration and rate limiter integration (#1678)', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + describe('parseTrustProxyConfig', () => { + it('defaults to 0 when absent or empty', () => { + expect(parseTrustProxyConfig(undefined)).toBe(0); + expect(parseTrustProxyConfig('')).toBe(0); + }); + + it('parses valid non-negative integer hop counts', () => { + expect(parseTrustProxyConfig('0')).toBe(0); + expect(parseTrustProxyConfig('1')).toBe(1); + expect(parseTrustProxyConfig('2')).toBe(2); + expect(parseTrustProxyConfig(' 3 ')).toBe(3); + }); + + it('rejects whitespace-only values with a descriptive error', () => { + expect(() => parseTrustProxyConfig(' ')).toThrow( + /Invalid TRUST_PROXY configuration: value cannot be whitespace-only/i, + ); + }); + + it('rejects negative numbers', () => { + expect(() => parseTrustProxyConfig('-1')).toThrow( + /TRUST_PROXY must be a non-negative integer hop count/i, + ); + }); + + it('rejects fractional numbers', () => { + expect(() => parseTrustProxyConfig('1.5')).toThrow( + /TRUST_PROXY must be a non-negative integer hop count/i, + ); + }); + + it('rejects non-numeric strings and booleans to prevent permissive true fallback', () => { + expect(() => parseTrustProxyConfig('true')).toThrow( + /TRUST_PROXY must be a non-negative integer hop count/i, + ); + expect(() => parseTrustProxyConfig('yes')).toThrow( + /TRUST_PROXY must be a non-negative integer hop count/i, + ); + expect(() => parseTrustProxyConfig('none')).toThrow( + /TRUST_PROXY must be a non-negative integer hop count/i, + ); + }); + }); + + describe('Express trust proxy rate-limit behavior with real middleware chain', () => { + it('isolates different client IPs behind a trusted single-hop proxy (TRUST_PROXY=1)', async () => { + const app = express(); + app.set('trust proxy', parseTrustProxyConfig('1')); + app.get( + '/api/test', + createLimiter({ tier: 'test-proxy-1', max: 1, windowMs: 60000 }), + (_req: Request, res: Response) => { + res.json({ ok: true }); + }, + ); + + // Client A makes request through proxy (X-Forwarded-For: 203.0.113.1) + const resA1 = await request(app) + .get('/api/test') + .set('X-Forwarded-For', '203.0.113.1'); + expect(resA1.status).toBe(200); + + // Client A hits the rate limit + const resA2 = await request(app) + .get('/api/test') + .set('X-Forwarded-For', '203.0.113.1'); + expect(resA2.status).toBe(429); + + // Client B from a different IP (203.0.113.2) is NOT blocked + const resB1 = await request(app) + .get('/api/test') + .set('X-Forwarded-For', '203.0.113.2'); + expect(resB1.status).toBe(200); + }); + + it('prevents IP spoofing: cannot rotate client IP when TRUST_PROXY=1', async () => { + const app = express(); + app.set('trust proxy', parseTrustProxyConfig('1')); + app.get( + '/api/test-spoof', + createLimiter({ tier: 'test-spoof', max: 1, windowMs: 60000 }), + (_req: Request, res: Response) => { + res.json({ ok: true }); + }, + ); + + // Real client is 203.0.113.50. Client tries to prepend a fake IP 198.51.100.1. + // The load balancer appends real client IP: "198.51.100.1, 203.0.113.50" + const res1 = await request(app) + .get('/api/test-spoof') + .set('X-Forwarded-For', '198.51.100.1, 203.0.113.50'); + expect(res1.status).toBe(200); + + // Client attempts to spoof a new IP by rotating the prefix: "198.51.100.2, 203.0.113.50" + // Express trust proxy (1 hop) resolves req.ip to the rightmost hop (203.0.113.50). + // The rate limiter must see 203.0.113.50 and return 429. + const res2 = await request(app) + .get('/api/test-spoof') + .set('X-Forwarded-For', '198.51.100.2, 203.0.113.50'); + expect(res2.status).toBe(429); + }); + + it('ignores X-Forwarded-For entirely when TRUST_PROXY=0 (direct connection)', async () => { + const app = express(); + app.set('trust proxy', parseTrustProxyConfig('0')); + app.get( + '/api/test-direct', + createLimiter({ tier: 'test-direct', max: 1, windowMs: 60000 }), + (_req: Request, res: Response) => { + res.json({ ok: true }); + }, + ); + + // Request 1 with arbitrary XFF + const res1 = await request(app) + .get('/api/test-direct') + .set('X-Forwarded-For', '203.0.113.10'); + expect(res1.status).toBe(200); + + // Request 2 with different XFF from the same socket is still rate limited + const res2 = await request(app) + .get('/api/test-direct') + .set('X-Forwarded-For', '203.0.113.20'); + expect(res2.status).toBe(429); + }); + + it('works as expected without any X-Forwarded-For header', async () => { + const app = express(); + app.set('trust proxy', parseTrustProxyConfig('1')); + app.get( + '/api/test-no-header', + createLimiter({ tier: 'test-no-header', max: 1, windowMs: 60000 }), + (_req: Request, res: Response) => { + res.json({ ok: true }); + }, + ); + + const res1 = await request(app).get('/api/test-no-header'); + expect(res1.status).toBe(200); + + const res2 = await request(app).get('/api/test-no-header'); + expect(res2.status).toBe(429); + }); + }); +}); + diff --git a/backend/src/index.ts b/backend/src/index.ts index 9523e765d..28e4deec4 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -10,9 +10,9 @@ initTracing(); import 'express-async-errors'; import express, { Express, Request, Response, NextFunction, ErrorRequestHandler } from 'express'; +import NodeCache from 'node-cache'; import { loginHandler, nonceHandler, refreshHandler, requireAuth, revokeAccessToken, revokeAllAccessTokens, revokeCurrentSession, revokeAllSessions } from './auth'; import vaultListRouter from './routes/vaults'; -import { loginHandler, nonceHandler, refreshHandler, requireAuth } from './auth'; import { authLimiter, authIpLimiter, @@ -23,9 +23,8 @@ import { identityRateLimiter, getRateLimitMonitorSnapshot, loadConfig as loadRateLimiterConfig, + parseTrustProxyConfig, } from './rateLimiter'; -import { createAdminAuditMiddleware } from './auditLog'; -import { getActorFromRequest } from './adminConfigChangeAudit'; import { idempotencyStore } from './idempotency'; import { createAdminAuditMiddleware, getAuditLogs, countAuditLogs, getAuditLogMetrics } from './auditLog'; import { AuditLogQuerySchema } from './middleware/validate'; @@ -60,7 +59,7 @@ import { InternalError, RateLimitError, } from './errors'; -import { corsMiddleware } from './middleware/cors'; +import { corsMiddleware, validateCorsConfig } from './middleware/cors'; import { geofencingMiddleware } from './middleware/geofencing'; import { registerInvalidationHook } from './middleware/cache'; import { invalidateVaultCaches } from './vaultDataCache'; @@ -158,6 +157,13 @@ declare global { } const app: Express = express(); + +// Validate CORS configuration at startup before server initialization (#1677) +validateCorsConfig(); + +// Configure trust proxy explicitly before any rate-limiting middleware processes requests (#1678) +app.set('trust proxy', parseTrustProxyConfig()); + const port = process.env.PORT || 3000; const nodeEnv = process.env.NODE_ENV || 'development'; const logLevel = (process.env.LOG_LEVEL || (nodeEnv === 'development' ? 'debug' : 'info')) as LogLevel; @@ -1141,61 +1147,36 @@ app.use(exportRoutes); app.use(adminRoutes); /** - * Mock vault metrics poll cycle - * In a real application, this would fetch data from a database or Stellar RPC + * POST /admin/apy/backfill - backfill missing APY snapshots for a date range + * Body: { start: "YYYY-MM-DD", end: "YYYY-MM-DD" } + * Requires API key authentication. */ -const pollVaultMetrics = () => { - // Mock data for TVL and Share Price - const mockTvl = 1000000 + Math.random() * 100000; - const mockSharePrice = 1.25 + Math.random() * 0.05; - - updateVaultMetrics(mockTvl, mockSharePrice); - - logger.log('info', 'Vault metrics updated in Prometheus gauges', { - tvl: mockTvl, - sharePrice: mockSharePrice, - }); -}; - -// Start poll cycle every 60 seconds (configurable) -const METRICS_POLL_INTERVAL = parseInt(process.env.METRICS_POLL_INTERVAL_MS || '60000', 10); -const metricsInterval = - process.env.NODE_ENV === 'test' - ? null - : setInterval(pollVaultMetrics, METRICS_POLL_INTERVAL); - -if (process.env.NODE_ENV !== 'test') { - pollVaultMetrics(); // Initial call - void initializeWebhookEndpoints(); -} - -// Start latency monitoring -latencyMonitoringService.startMonitoring(); - -// ─── Event Polling Service (Issue: Event Replay) ──────────────────────────── -if (process.env.NODE_ENV !== 'test' && process.env.VAULT_CONTRACT_ID) { - startEventPollingService({ - rpcUrl: process.env.STELLAR_RPC_URL || 'https://soroban-testnet.stellar.org', - contractId: process.env.VAULT_CONTRACT_ID, - pollIntervalMs: parseInt(process.env.EVENT_POLL_INTERVAL_MS || '10000', 10), - batchSize: parseInt(process.env.EVENT_REPLAY_BATCH_SIZE || '100', 10), - }); +app.post('/admin/apy/backfill', validateApiKey, validate({ body: ApyBackfillBodySchema }), async (req: Request, res: Response) => { + const { start, end } = req.body; + if (!start || !end || typeof start !== 'string' || typeof end !== 'string') { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: '`start` and `end` (YYYY-MM-DD) are required', + }); + return; + } - // stopEventPollingService() is a no-op if the service was never started, - // so this is safe to register unconditionally alongside the block above. - shutdownHandler.onShutdown(async () => { - stopEventPollingService(); - }); -} + const datePattern = /^\d{4}-\d{2}-\d{2}$/; + if (!datePattern.test(start) || !datePattern.test(end)) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: '`start` and `end` must be in YYYY-MM-DD format', + }); + return; + } -// ─── Outbox Pattern Processor ───────────────────────────────────────── -// Starts the background outbox event relay processor. -// Writes from vault operations go to the EventOutbox table atomically; -// this processor reads them and delivers via the webhook system. -if (process.env.NODE_ENV !== 'test') { - eventOutboxService.replayOnStartup().catch((err) => { - logger.log('error', 'Outbox startup replay failed', { - error: err instanceof Error ? err.message : String(err), + if (end < start) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: '`end` must be >= `start`', }); return; } diff --git a/backend/src/middleware/cors.ts b/backend/src/middleware/cors.ts index deb3e0849..2b671f0f4 100644 --- a/backend/src/middleware/cors.ts +++ b/backend/src/middleware/cors.ts @@ -10,12 +10,33 @@ import { sendApiError } from './apiError'; * Supports an explicit allowlist of origins via environment variables. */ -const getCORSOrigins = (): (string | RegExp)[] => { - const envOrigins = process.env.CORS_ALLOWED_ORIGINS; +/** + * Validates CORS configuration for the current environment. + * + * In production (`NODE_ENV === 'production'`), `CORS_ALLOWED_ORIGINS` is strictly required. + * It must not be unset, empty, or whitespace-only. If it is invalid or missing, an Error + * is thrown with clear remediation instructions so the application fails fast before listening. + */ +export function validateCorsConfig(env: NodeJS.ProcessEnv = process.env): void { + if (env.NODE_ENV === 'production') { + const origins = env.CORS_ALLOWED_ORIGINS; + if (!origins || origins.trim().length === 0) { + throw new Error( + 'Missing required environment variable: CORS_ALLOWED_ORIGINS is not set or is empty in production. ' + + 'Please set CORS_ALLOWED_ORIGINS to a comma-separated list of trusted origins (e.g., ' + + 'CORS_ALLOWED_ORIGINS="https://app.yieldvault.finance,https://www.yieldvault.finance") in your environment.' + ); + } + } +} + +export const getCORSOrigins = (env: NodeJS.ProcessEnv = process.env): (string | RegExp)[] => { + validateCorsConfig(env); + const envOrigins = env.CORS_ALLOWED_ORIGINS; - if (!envOrigins) { + if (!envOrigins || envOrigins.trim().length === 0) { // In development, allow localhost by default if no origins are specified - if (process.env.NODE_ENV !== 'production') { + if (env.NODE_ENV !== 'production') { return [/http:\/\/localhost:\d+/]; } return []; diff --git a/backend/src/prismaClient.ts b/backend/src/prismaClient.ts index fa5bd47cd..27b8edff7 100644 --- a/backend/src/prismaClient.ts +++ b/backend/src/prismaClient.ts @@ -8,7 +8,7 @@ */ import { PrismaClient } from '@prisma/client'; -import { PrismaClient as PrismaClientRuntime } from '.prisma/client'; +const PrismaClientRuntime = PrismaClient; import { assertCriticalEntityMutationAllowed } from './criticalEntityPolicy'; import { logger } from './middleware/structuredLogging'; import { recordQueryPerformance } from './queryBudgets'; diff --git a/backend/src/rateLimiter.ts b/backend/src/rateLimiter.ts index 89e67710b..bf617dae4 100644 --- a/backend/src/rateLimiter.ts +++ b/backend/src/rateLimiter.ts @@ -179,13 +179,58 @@ export function maskWalletAddress(addr: string): string { return addr; } -// ─── Client IP Resolution ──────────────────────────────────────────────────── +// ─── Client IP Resolution & Trust Proxy Configuration ───────────────────────── const trustProxyWarnedApps = new WeakSet(); +/** + * Parses and validates the TRUST_PROXY environment variable into a non-negative integer hop count. + * + * Rules: + * - Defaults to `0` when absent or undefined (safe default: only direct socket connection is trusted). + * - Accepts non-negative integers (e.g. 0, 1, 2). + * - Rejects invalid, negative, fractional, and non-numeric strings with a descriptive configuration error. + * - Never enables unrestricted proxy trust (`true`). + * + * Deployment guidelines: + * - Direct connection / no reverse proxy: set `TRUST_PROXY=0` (or leave unset). + * - Behind a single reverse proxy or load balancer (e.g., AWS ALB, Nginx, GCP HTTPS LB): set `TRUST_PROXY=1`. + * - Behind two hops (e.g., Cloudflare CDN in front of an AWS ALB): set `TRUST_PROXY=2`. + */ +export function parseTrustProxyConfig(raw: string | undefined = process.env.TRUST_PROXY): number { + if (raw === undefined || raw === '') { + return 0; + } + + const trimmed = raw.trim(); + if (trimmed === '') { + throw new Error( + 'Invalid TRUST_PROXY configuration: value cannot be whitespace-only. ' + + 'TRUST_PROXY must be a non-negative integer hop count (e.g., "0" for direct access, "1" for a single load balancer).' + ); + } + + // Reject negative, fractional, or non-numeric values + if (!/^\d+$/.test(trimmed)) { + throw new Error( + `Invalid TRUST_PROXY value "${raw}". TRUST_PROXY must be a non-negative integer hop count (e.g. 0, 1, 2). ` + + 'Choose the number of trusted reverse proxies between the client and Express (e.g. 1 for an AWS ALB).' + ); + } + + const hopCount = Number(trimmed); + if (!Number.isSafeInteger(hopCount) || hopCount < 0) { + throw new Error( + `Invalid TRUST_PROXY value "${raw}". Hop count must be a non-negative integer.` + ); + } + + return hopCount; +} + /** * Resolves the client IP for rate limiting. - * When Express `trust proxy` is enabled, `req.ip` is derived from the + * When Express `trust proxy` is configured, `req.ip` is derived from the * X-Forwarded-For chain according to that setting. Otherwise the header is * unverified and attacker-controlled, so the socket's remote address is used * to prevent bucket rotation via spoofed X-Forwarded-For values. @@ -206,7 +251,7 @@ export function resolveClientIp(req: Request): string { ); } - if (trustProxy) { + if (trustProxy !== undefined) { return req.ip || req.socket?.remoteAddress || 'unknown'; } return req.socket?.remoteAddress || req.ip || 'unknown'; diff --git a/backend/src/swagger.ts b/backend/src/swagger.ts index 218273e07..d43db3651 100644 --- a/backend/src/swagger.ts +++ b/backend/src/swagger.ts @@ -598,12 +598,37 @@ const options: swaggerJsdoc.Options = { export const specs = swaggerJsdoc(options); /** - * Mounts Swagger UI at `/docs` (unless explicitly disabled via - * `DISABLE_SWAGGER=true`). The spec is also available as raw JSON at - * `/docs/openapi.json` for CI verification and third-party tooling. + * Centralized decision helper for Swagger UI and `/docs/openapi.json` endpoints. + * + * Rules: + * 1. `DISABLE_SWAGGER=true` always disables Swagger in all environments, even if `SWAGGER_ENABLED=true`. + * 2. In production (`NODE_ENV=production`), Swagger is disabled by default. It is only enabled + * if `SWAGGER_ENABLED=true` is explicitly set. + * 3. Non-production behavior remains unchanged: Swagger is enabled by default unless `DISABLE_SWAGGER=true`. + * 4. Missing or invalid `SWAGGER_ENABLED` values never implicitly enable production Swagger. + */ +export function isSwaggerEnabled(env: NodeJS.ProcessEnv = process.env): boolean { + if (env.DISABLE_SWAGGER === 'true') { + return false; + } + + const isProduction = env.NODE_ENV === 'production'; + if (isProduction) { + return env.SWAGGER_ENABLED === 'true'; + } + + return true; +} + +/** + * Mounts Swagger UI at `/docs` and the spec at `/docs/openapi.json` + * according to the centralized `isSwaggerEnabled` policy. + * + * In production, Swagger is disabled by default unless explicitly enabled via `SWAGGER_ENABLED=true`. + * `DISABLE_SWAGGER=true` disables Swagger across all environments. */ export function setupSwagger(app: Express): void { - if (process.env.DISABLE_SWAGGER === 'true') { + if (!isSwaggerEnabled()) { return; } @@ -623,3 +648,4 @@ export function setupSwagger(app: Express): void { }), ); } + diff --git a/contracts/share-price-math/src/rounding.rs b/contracts/share-price-math/src/rounding.rs index 1ad3230e9..d334d32d5 100644 --- a/contracts/share-price-math/src/rounding.rs +++ b/contracts/share-price-math/src/rounding.rs @@ -4,12 +4,23 @@ //! residual dust with the vault, so `deposit -> withdraw` round trips can never //! mint value out of nothing. //! -//! | Operation | Direction | Rounding | -//! |------------|-----------|----------| -//! | `deposit` | assets to shares | [`Rounding::Down`] | -//! | `mint` | shares to assets | [`Rounding::Up`] | -//! | `withdraw` | assets to shares | [`Rounding::Up`] | -//! | `redeem` | shares to assets | [`Rounding::Down`] | +//! ### Conversion Operations & Rounding Directions +//! +//! The table below defines the canonical ERC-4626 rounding policy implemented by this module: +//! +//! | Operation | Direction | Rounding | Vault Public Entrypoint / Application Context | +//! |------------|------------------|--------------------|-----------------------------------------------| +//! | `deposit` | assets to shares | [`Rounding::Down`] | `vault::deposit(user, amount)` (mints shares) | +//! | `redeem` | shares to assets | [`Rounding::Down`] | `vault::withdraw(user, shares)` (burns shares)| +//! | `mint` | shares to assets | [`Rounding::Up`] | Required asset quote for exact shares | +//! | `withdraw` | assets to shares | [`Rounding::Up`] | Required share quote for exact assets (ERC-4626)| +//! +//! **Important note on vault `withdraw` semantics**: +//! In ERC-4626 standard terminology, `withdraw` refers to asset-denominated withdrawals +//! (specifying assets out, burning shares rounded up). However, in this vault contract, +//! the public entrypoint is `withdraw(user, shares)` which takes **shares as input** and converts +//! them to assets using round-down ([`Rounding::Down`]), matching the `redeem` operation. +//! The vault does not currently provide an asset-denominated withdrawal entrypoint. //! //! [`FIXTURES`] is the canonical cross-language conformance table. The same //! vectors are published as `fixtures/rounding_fixtures.json` so that the @@ -319,4 +330,67 @@ mod tests { assert_eq!(Op::Mint.rounding(), Rounding::Up); assert_eq!(Op::Withdraw.rounding(), Rounding::Up); } + + #[test] + fn test_rounding_up_calculation_branch_and_boundaries() { + // Issue #1679: Exercise Rounding::Up calculation branch. + // 1. Non-exact conversion ratio proving round-up produces a different result from round-down: + // Case A: mul_div directly + // 100 * 1000 / 1500 = 66 remainder 1000 + let down_res = mul_div(100, 1000, 1500, Rounding::Down).unwrap(); + let up_res = mul_div(100, 1000, 1500, Rounding::Up).unwrap(); + assert_eq!(down_res, 66); + assert_eq!(up_res, 67); + assert_ne!( + up_res, down_res, + "Round-up must produce a strictly greater result than round-down when a remainder exists" + ); + + // Case B: assets_to_shares_rounded (Op::Deposit vs Op::Withdraw) + let deposit_shares = assets_to_shares_rounded(100, 1000, 1500, Op::Deposit).unwrap(); + let withdraw_shares = assets_to_shares_rounded(100, 1000, 1500, Op::Withdraw).unwrap(); + assert_eq!(deposit_shares, 66); + assert_eq!(withdraw_shares, 67); + assert_ne!(withdraw_shares, deposit_shares); + + // Case C: shares_to_assets_rounded (Op::Redeem vs Op::Mint) + // 99 * 1500 / 1000 = 148,500 / 1000 = 148 remainder 500 + let redeem_assets = shares_to_assets_rounded(99, 1000, 1500, Op::Redeem).unwrap(); + let mint_assets = shares_to_assets_rounded(99, 1000, 1500, Op::Mint).unwrap(); + assert_eq!(redeem_assets, 148); + assert_eq!(mint_assets, 149); + assert_ne!(mint_assets, redeem_assets); + + // 2. Exact-divisibility behavior as a boundary case: + // When product % denom == 0, Rounding::Up must NOT bump (up_res == down_res). + let exact_down = mul_div(300, 1000, 1500, Rounding::Down).unwrap(); + let exact_up = mul_div(300, 1000, 1500, Rounding::Up).unwrap(); + assert_eq!(exact_down, 200); + assert_eq!(exact_up, 200); + + let exact_withdraw = assets_to_shares_rounded(300, 1000, 1500, Op::Withdraw).unwrap(); + let exact_deposit = assets_to_shares_rounded(300, 1000, 1500, Op::Deposit).unwrap(); + assert_eq!(exact_withdraw, 200); + assert_eq!(exact_deposit, 200); + + // Zero amount boundary case: + assert_eq!(mul_div(0, 1000, 1500, Rounding::Up), Ok(0)); + assert_eq!(mul_div(0, 1000, 1500, Rounding::Down), Ok(0)); + + // Single unit dust (remainder only, quotient 0): + // 1 * 1000 / 1500 = quotient 0, remainder 1000 -> Down: 0, Up: 1 + assert_eq!(mul_div(1, 1000, 1500, Rounding::Down), Ok(0)); + assert_eq!(mul_div(1, 1000, 1500, Rounding::Up), Ok(1)); + + // 3. Supported input domain / large values without overflow: + let large_a = 1_000_000_000_000i128; + let large_b = 2_000_000_000_000i128; + let denom = 3i128; + let large_down = mul_div(large_a, large_b, denom, Rounding::Down).unwrap(); + let large_up = mul_div(large_a, large_b, denom, Rounding::Up).unwrap(); + assert_eq!(large_up, large_down + 1); + + // Bounded overflow test + assert_eq!(mul_div(i128::MAX, 2, 1, Rounding::Up), Err(MathError::Overflow)); + } } diff --git a/docs/ENV_VARIABLE_MATRIX.md b/docs/ENV_VARIABLE_MATRIX.md index f601e2be4..833473485 100644 --- a/docs/ENV_VARIABLE_MATRIX.md +++ b/docs/ENV_VARIABLE_MATRIX.md @@ -22,6 +22,9 @@ Complete reference for all environment variables across the YieldVault RWA stack | `NODE_ENV` | `development` | ✅ always | Runtime environment (`development`, `production`, `test`) | `backend/src/index.ts`, `auth.ts`, `database.ts`, `prismaClient.ts`, `tracing.ts`, `rateLimiter.ts`, `cors.ts`, `geofencing.ts`, `allowlist.ts`, `maintenanceMode.ts`, `walletSignature.ts` (30+ files) | | `LOG_LEVEL` | `debug` (dev) / `info` (prod) | ⬜ optional | Structured logger level (`debug`, `info`, `warn`, `error`) | `backend/src/index.ts` | | `DRAIN_TIMEOUT_MS` | `30000` | ⬜ optional | Graceful shutdown drain timeout in ms | `backend/src/index.ts`, `gracefulShutdown.ts` | +| `TRUST_PROXY` | `0` | ⬜ optional | Trusted reverse proxy hop count (0 direct, 1 single LB/ALB, 2 Cloudflare+ALB); configures Express `trust proxy` safely | `backend/src/rateLimiter.ts`, `backend/src/index.ts` | +| `SWAGGER_ENABLED` | `false` (prod) | ⬜ optional | Explicitly enable Swagger UI and `/docs/openapi.json` in production (`true`) | `backend/src/swagger.ts` | +| `DISABLE_SWAGGER` | `false` | ⬜ optional | Force disable Swagger UI and `/docs/openapi.json` in all environments (`true`) | `backend/src/swagger.ts` | | `CACHE_TTL_MS` | `60000` | ⬜ optional | Response cache TTL (vault summary, metrics, APY) | `backend/src/index.ts` | | `CACHE_VAULT_METRICS_TTL_MS` | `60000` | ⬜ optional | Alias for `CACHE_TTL_MS` | `backend/src/index.ts` | | `METRICS_POLL_INTERVAL_MS` | `60000` | ⬜ optional | Prometheus gauge sync interval | `backend/src/index.ts` | @@ -156,7 +159,7 @@ Complete reference for all environment variables across the YieldVault RWA stack | Variable | Default | Req. | Description | Source | |---|---|---|---|---| -| `CORS_ALLOWED_ORIGINS` | `http://localhost:3000,https://app.yieldvault.finance` | ✅ always | Comma-separated allowed CORS origins | `backend/src/middleware/cors.ts` | +| `CORS_ALLOWED_ORIGINS` | `http://localhost:3000,https://app.yieldvault.finance` | 🔶 prod | Comma-separated allowed CORS origins. In production (`NODE_ENV=production`), required and must not be empty or whitespace-only; server fails fast at startup if unset. | `backend/src/middleware/cors.ts`, `backend/src/index.ts` | ---