From 002a0233c31e2a15183d9c8af92d7e2fe01de3bd Mon Sep 17 00:00:00 2001 From: Gabriel-123-diamond <96881956+Gabriel-123-diamond@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:55:54 +0100 Subject: [PATCH] feat(demo): guard unsupported PostgreSQL and Soroban capabilities Centralize demo configuration, return typed capability errors, prevent external database/client initialization, and expose the runtime health mode. Add route-table, configuration, lazy-pool and application regression tests. Closes #1515 --- README.md | 25 +- docs/error-codes.md | 1 + docs/error-codes.yaml | 4 + docs/openapi.json | 12 +- src/app.ts | 760 +----------------- src/config/core-settings.ts | 157 ++++ src/config/demoMode.test.ts | 44 + src/config/env.ts | 628 +-------------- src/config/execution-env.ts | 164 ++++ src/config/index.ts | 376 +-------- src/config/integrations-env.ts | 172 ++++ src/config/server-env.ts | 188 +++++ src/config/service-settings.ts | 102 +++ src/config/slo-env.ts | 74 ++ src/config/stellar-env-keys.ts | 26 + src/config/stellar.ts | 117 +++ .../vaultController.onchain.test.ts | 68 +- src/controllers/vaultController.ts | 2 +- src/db.demo.test.ts | 36 + src/db.ts | 42 +- src/db/index.ts | 10 +- src/errors/codes.ts | 3 + src/lib/prisma.ts | 3 +- src/middleware/appMiddleware.ts | 180 +++++ src/middleware/demoGuard.test.ts | 98 +++ src/middleware/demoGuard.ts | 44 + src/middleware/envelope.ts | 241 +----- src/middleware/envelopeBuilders.ts | 74 ++ src/middleware/envelopeSchemas.ts | 50 ++ src/middleware/responseValidator.ts | 120 +++ src/routes/adminRoutes.ts | 25 + src/routes/developerAnalyticsRoutes.ts | 184 +++++ src/routes/developerRegistrationRoute.ts | 76 ++ src/routes/vaultRoutes.ts | 77 ++ src/services/appExports.ts | 83 ++ src/services/createSorobanBillingService.ts | 1 + tests/helpers/vaultControllerHarness.ts | 59 ++ tests/integration/demoMode.test.ts | 98 +++ 38 files changed, 2407 insertions(+), 2017 deletions(-) create mode 100644 src/config/core-settings.ts create mode 100644 src/config/demoMode.test.ts create mode 100644 src/config/execution-env.ts create mode 100644 src/config/integrations-env.ts create mode 100644 src/config/server-env.ts create mode 100644 src/config/service-settings.ts create mode 100644 src/config/slo-env.ts create mode 100644 src/config/stellar-env-keys.ts create mode 100644 src/config/stellar.ts create mode 100644 src/db.demo.test.ts create mode 100644 src/middleware/appMiddleware.ts create mode 100644 src/middleware/demoGuard.test.ts create mode 100644 src/middleware/demoGuard.ts create mode 100644 src/middleware/envelopeBuilders.ts create mode 100644 src/middleware/envelopeSchemas.ts create mode 100644 src/middleware/responseValidator.ts create mode 100644 src/routes/adminRoutes.ts create mode 100644 src/routes/developerAnalyticsRoutes.ts create mode 100644 src/routes/developerRegistrationRoute.ts create mode 100644 src/routes/vaultRoutes.ts create mode 100644 src/services/appExports.ts create mode 100644 tests/helpers/vaultControllerHarness.ts create mode 100644 tests/integration/demoMode.test.ts diff --git a/README.md b/README.md index 9fe158cc..9ed616af 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ Configuration is validated with Zod at startup (`src/config/env.ts`); the server | Variable | Required | Description | | --- | --- | --- | | `PORT` | no (3000) | HTTP port | -| `NODE_ENV` | no | `development` \| `production` \| `test`. Production additionally requires `SOROBAN_BILLING_CONTRACT_ID`, `UPSTREAM_HOST_ALLOWLIST` | +| `NODE_ENV` | no | `development` \| `production` \| `test`. Production requires `UPSTREAM_HOST_ALLOWLIST` and, outside demo mode, `SOROBAN_BILLING_CONTRACT_ID` | | `DATABASE_URL` | yes | PostgreSQL connection string (metering, billing, audit) | | `JWT_SECRET` | yes | Secret for access/refresh tokens | | `ADMIN_API_KEY` | yes | Key for `/api/admin/*` (`x-admin-api-key` header) | @@ -131,12 +131,33 @@ See [ARCHITECTURE.md](ARCHITECTURE.md) for the route map and design notes. ## Demo mode +Unsupported capabilities return HTTP **503** with the canonical error envelope, +`error.code: "DEMO_MODE_UNSUPPORTED"`, `error.details: { feature }`, and the +request ID. There is no `Retry-After`: these features require standard mode. +The guard runs before authentication, validation, and database-backed handlers. + +| Route prefix (including subpaths) | Methods unavailable in demo | +| --- | --- | +| `/api/billing`, `/api/invoices` | All | +| `/api/usage`, `/api/exports` | All | +| `/api/admin/usage`, `/api/admin/db/explain` | All | +| `/api/vault/deposit/prepare` | POST | +| `/api/vault/onchain`, `/api/vault/balance` | GET, HEAD | +| `/api/audit` | POST, PUT, PATCH, DELETE (reads remain available) | +| `/api/credits`, `/api/refunds` | All | +| `/api/health/health`, `/api/health/dependencies` | GET, HEAD | + +`GET /api/health` remains available and reports `data.mode: "demo"` or +`"standard"`. `GET /api/apis` continues serving the seeded SQLite catalog. +Demo mode never constructs the billing Soroban client or opens the shared +PostgreSQL pool; dependency probes cannot contact external services. + `DEMO_MODE=true` is what the public deployment uses. On startup it classifies migrations (via `-- dialect: sqlite|postgres|both` header comments or the built-in manifest) and applies every SQLite-compatible migration to an ephemeral SQLite database, logging applied/skipped/failed counts. Any genuine failure in a SQLite-applicable migration fails fast naming the broken file. It then executes an idempotent seed that creates a demo developer and 3 sample APIs, referencing the developer's dynamic ID (`lastInsertRowid`) rather than assuming ID 1. Consequences: - Catalog/marketplace/health/docs endpoints return real data; state is **not persistent** (serverless instances are ephemeral). -- Features needing PostgreSQL (metering, billing, invoices, audit persistence, usage exports) respond with standard 503 error envelopes (`SERVICE_UNAVAILABLE`) in demo mode, avoiding module-resolution crashes on missing Prisma clients. +- Features needing PostgreSQL (metering, billing, invoices, audit persistence, usage exports) respond with typed 503 error envelopes (`DEMO_MODE_UNSUPPORTED`) in demo mode, avoiding module-resolution crashes on missing Prisma clients. - OpenAPI *response* validation is switched off in demo mode (some responses omit optional contract fields); request validation stays on. - Stellar/Soroban calls are not made. diff --git a/docs/error-codes.md b/docs/error-codes.md index 20a8c5f9..1af6d928 100644 --- a/docs/error-codes.md +++ b/docs/error-codes.md @@ -13,6 +13,7 @@ This section is generated from `docs/error-codes.yaml`. Run `npm run error-codes | Code | Catalog section | |---|---| +| `DEMO_MODE_UNSUPPORTED` | HTTP status derived / base app codes | | `BAD_REQUEST` | HTTP status derived / base app codes | | `UNAUTHORIZED` | HTTP status derived / base app codes | | `FORBIDDEN` | HTTP status derived / base app codes | diff --git a/docs/error-codes.yaml b/docs/error-codes.yaml index ee062075..9c24d5d0 100644 --- a/docs/error-codes.yaml +++ b/docs/error-codes.yaml @@ -14,6 +14,10 @@ # The 'code' field becomes both the TypeScript enum key and value. error_codes: + - code: DEMO_MODE_UNSUPPORTED + section: HTTP status derived / base app codes + description: The feature requires PostgreSQL or Stellar integrations unavailable in demo mode (HTTP 503) + # HTTP status derived / base app codes - code: BAD_REQUEST section: HTTP status derived / base app codes diff --git a/docs/openapi.json b/docs/openapi.json index eccc4ad1..d5a7c416 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -691,7 +691,8 @@ "success": true, "data": { "status": "ok", - "service": "callora-backend" + "service": "callora-backend", + "mode": "standard" }, "requestId": "req-mock-uuid", "timestamp": "2026-07-25T10:00:00.000Z" @@ -8278,6 +8279,7 @@ "ErrorCode": { "type": "string", "enum": [ + "DEMO_MODE_UNSUPPORTED", "BAD_REQUEST", "UNAUTHORIZED", "FORBIDDEN", @@ -8514,6 +8516,14 @@ }, "checks": { "$ref": "#/components/schemas/HealthChecks" + }, + "mode": { + "type": "string", + "enum": [ + "demo", + "standard" + ], + "description": "Runtime capabilities: demo uses seeded SQLite without PostgreSQL or Stellar integrations." } } }, diff --git a/src/app.ts b/src/app.ts index cc663f83..224348fa 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,124 +1,67 @@ import express from 'express'; -import cors from 'cors'; -import helmet from 'helmet'; -import adminRouter from './routes/admin.js'; -import { createExplainRouter } from './routes/admin/explain.js'; -import { createUsageAnomaliesRouter } from './routes/admin/usage/anomalies.js'; -import { createAdminUsageByEndpointRouter } from './routes/admin/usage/by-endpoint.js'; -import { createSpikeRouter } from './routes/admin/usage/spike.js'; -import publicMaintenanceRouter from './routes/maintenance.js'; -import { createApiRouter } from './routes/index.js'; -import { createDocsRouter } from './routes/docs.js'; -import { getEmbeddedAssets } from './lib/embeddedAssets.js'; -import { createApisRouter } from './routes/apis.js'; -import { createWebhooksRouter } from './routes/webhooks.js'; -import { createPluginsRouter } from './routes/marketplace/plugins.js'; -import { createGatewayHealthRouter } from './routes/gatewayRoutes.js'; -import { - HmacObjectStorageClient, - InMemoryScheduleStore, - ScheduledExportsService, -} from './services/scheduledExports.js'; -import { - InMemoryExportStore, - ReportExporterService, -} from './services/reportExporter.js'; -import { createLogsRouter } from './routes/logs.js'; +import { createAppExports } from "./services/appExports.js"; +import { registerVaultRoutes } from "./routes/vaultRoutes.js"; +import type { VaultStateReader } from "./services/sorobanVaultReader.js"; import { pool } from './db.js'; +import type { Developer } from "./db/schema.js"; +import { registerAppMiddleware } from "./middleware/appMiddleware.js"; +import { errorHandler } from "./middleware/errorHandler.js"; import { - InMemoryUsageEventsRepository, - type GroupBy, - type UsageEventsRepository, -} from "./repositories/usageEventsRepository.js"; -import { + createApi, defaultApiRepository, type ApiRepository, - type CreateApiInput, type ApiWithEndpoints, - createApi, + type CreateApiInput, } from "./repositories/apiRepository.js"; import { defaultDeveloperRepository, - type DeveloperRepository, findByUserId, + type DeveloperRepository, } from "./repositories/developerRepository.js"; import { defaultSubscriptionRepository } from "./repositories/subscriptionRepository.js"; -import { apiStatusEnum, type ApiStatus } from "./db/schema.js"; -import type { Developer } from "./db/schema.js"; -import { - requireAuth, - type AuthenticatedLocals, -} from "./middleware/requireAuth.js"; -import { bodyValidator } from "./middleware/validate.js"; -import { buildDeveloperAnalytics } from "./services/developerAnalytics.js"; -import { errorHandler } from "./middleware/errorHandler.js"; -import { envelopeValidator } from "./middleware/envelopeValidator.js"; import { - performHealthCheck, - type HealthCheckConfig, -} from "./services/healthCheck.js"; -import { createDependenciesRouter } from "./routes/health/dependencies.js"; -import { createRateLimitRouter } from "./routes/rate-limit.js"; -import quotaRequestsRouter from "./routes/quota/requests.js"; -import quotaCountsRouter from "./routes/quotas/counts.js"; -import { createQuotasRouter } from "./routes/quotas.js"; -import { parsePagination, paginatedResponse } from "./lib/pagination.js"; + InMemoryUsageEventsRepository, + type UsageEventsRepository +} from "./repositories/usageEventsRepository.js"; import { InMemoryVaultRepository, type VaultRepository, } from "./repositories/vaultRepository.js"; -import { DepositController } from "./controllers/depositController.js"; -import { VaultController } from "./controllers/vaultController.js"; -import { - createSorobanVaultReader, - type VaultStateReader, -} from "./services/sorobanVaultReader.js"; -import { TransactionBuilderService } from "./services/transactionBuilder.js"; +import { registerAdminRoutes } from "./routes/adminRoutes.js"; +import { createApisRouter } from './routes/apis.js'; +import { registerDeveloperAnalyticsRoutes } from "./routes/developerAnalyticsRoutes.js"; +import { registerDeveloperRegistrationRoute } from "./routes/developerRegistrationRoute.js"; +import { createGatewayHealthRouter } from './routes/gatewayRoutes.js'; +import { createDependenciesRouter } from "./routes/health/dependencies.js"; +import { createApiRouter } from './routes/index.js'; +import { createLogsRouter } from './routes/logs.js'; +import publicMaintenanceRouter from './routes/maintenance.js'; +import { createPluginsRouter } from './routes/marketplace/plugins.js'; +import quotaRequestsRouter from "./routes/quota/requests.js"; +import { createQuotasRouter } from "./routes/quotas.js"; +import { createRateLimitRouter } from "./routes/rate-limit.js"; +import { createWebhooksRouter } from './routes/webhooks.js'; import { - requestIdMiddleware, - responseEnrichMiddleware, -} from "./middleware/requestId.js"; -import { createTimeoutMiddleware } from "./middleware/timeout.js"; + type HealthCheckConfig +} from "./services/healthCheck.js"; //import { envelopeValidator } from './middleware/envelopeValidator.js'; +import { config } from "./config/index.js"; import { - successEnvelope, - errorEnvelope, getRequestId, + successEnvelope } from "./lib/envelope.js"; -import { createMemoryAccountingMiddleware } from "./middleware/memoryAccounting.js"; -import { validate } from "./middleware/validate.js"; -import { createAccessLogMiddleware } from "./middleware/accessLog.js"; +import { metricsEndpoint } from "./metrics.js"; +import { openApiErrorHandler } from "./middleware/openApiErrorHandler.js"; +import { createPerDevConcurrencyMiddleware } from "./middleware/perDevConcurrency.js"; +import type { RestRateLimitOptions } from "./middleware/restRateLimit.js"; import { InMemoryRestRateLimiter, createRestRateLimitMiddleware, } from "./middleware/restRateLimit.js"; -import type { RestRateLimitOptions } from "./middleware/restRateLimit.js"; -import { createPerDevConcurrencyMiddleware } from "./middleware/perDevConcurrency.js"; -import { auditEnrichMiddleware } from "./middleware/auditEnrich.js"; -import { asyncHandler } from "./utils/asyncHandler.js"; -import { createRouteBodyLimitMiddleware } from "./middleware/routeBodyLimit.js"; -import { metricsMiddleware, metricsEndpoint } from "./metrics.js"; -import { config } from "./config/index.js"; -import { validateStorageEndpoint } from "./lib/storageEndpoint.js"; import { createSorobanBillingService, type SorobanBillingDependencies, } from "./services/createSorobanBillingService.js"; -import { - BadRequestError, - ForbiddenError, - NotFoundError, - UnauthorizedError, -} from "./errors/index.js"; -import { apiRegistrationSchema } from "./validators/apiRegistration.js"; -import { stellarNetworkQuerySchema } from "./validators/networkSchema.js"; -import path from "path"; -import OpenApiValidator from "express-openapi-validator"; -import { - envelopeMiddleware, - createResponseValidatorMiddleware, -} from "./middleware/envelope.js"; -import { openApiErrorHandler } from "./middleware/openApiErrorHandler.js"; //import * as OpenApiValidator from 'express-openapi-validator'; interface AppDependencies extends SorobanBillingDependencies { @@ -132,40 +75,8 @@ interface AppDependencies extends SorobanBillingDependencies { createApiWithEndpoints?: (input: CreateApiInput) => Promise; } -/** - * Re-export the quotas drain tracker so application entry-points (e.g. - * `src/index.ts`) can register it with {@link createGracefulShutdownHandler} - * without importing directly from the route module. - * - * @example Wire into shutdown handler - * ```ts - * import { quotasDrainTracker } from './app.js'; - * - * const shutdown = createGracefulShutdownHandler({ - * server, - * activeConnections, - * closeDatabase, - * subsystems: [quotasDrainTracker.subsystem], - * }); - * ``` - */ export { quotasDrainTracker } from './routes/quotas/counts.js'; -const isValidGroupBy = (value: string): value is GroupBy => - value === "day" || value === "week" || value === "month"; - -const parseDate = (value: unknown): Date | null => { - if (typeof value !== "string") { - return null; - } - - const date = new Date(value); - if (Number.isNaN(date.getTime())) { - return null; - } - return date; -}; - export const createApp = (dependencies?: Partial) => { const app = express(); const restRateLimitOptions: RestRateLimitOptions = { @@ -193,285 +104,13 @@ export const createApp = (dependencies?: Partial) => { dependencies?.vaultRepository ?? new InMemoryVaultRepository(); const lookupDeveloper = dependencies?.findDeveloperByUserId ?? findByUserId; const persistApi = dependencies?.createApiWithEndpoints ?? createApi; - const getExportEvents = async () => { - const events = await usageEventsRepository.getEvents?.(); - if (!events) { - throw new Error('The configured usage repository does not support export event retrieval.'); - } - return events.map((event) => ({ - id: event.id, - userId: event.userId, - apiId: event.apiId, - endpointId: event.endpoint, - apiKeyId: '', - developerId: event.developerId, - amount: event.revenue, - requestId: event.id, - stellarTxHash: event.stellarTxHash ?? null, - createdAt: event.occurredAt, - })); - }; - const exportConfig = config.exports; - const exportsConfigured = Boolean( - exportConfig.enabled && - exportConfig.bucket && - exportConfig.endpoint && - exportConfig.region && - exportConfig.signingSecret, - ); - const objectStorageClient = new HmacObjectStorageClient(); - const exportEndpoint = exportsConfigured - ? validateStorageEndpoint(exportConfig.endpoint!, { - production: config.nodeEnv === 'production', - allowedHosts: exportConfig.endpointAllowlist, - }) - : undefined; - const reportExporterService = exportsConfigured - ? new ReportExporterService( - { - // Adapt the in-process repository to the paged range interface: - // [from, to) by createdAt, optional developer filter, id keyset cursor. - getEventsInRange: async (from, to, options) => { - const events = (await getExportEvents()) - .filter((event) => event.createdAt >= from && event.createdAt < to) - .filter((event) => options.developerId === undefined || event.developerId === options.developerId) - .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); - const start = options.cursor === undefined - ? 0 - : events.findIndex((event) => event.id > options.cursor!); - if (start === -1) return []; - return events.slice(start, start + options.limit); - }, - }, - objectStorageClient, - new InMemoryExportStore(), - { - s3Bucket: exportConfig.bucket!, - s3Endpoint: exportEndpoint!, - s3SecretAccessKey: exportConfig.signingSecret!, - }, - ) - : undefined; - const scheduledExportsService = exportsConfigured - ? new ScheduledExportsService( - { - findByApiId: async (apiId, from, to, limit, offset) => - (await getExportEvents()) - .filter((event) => !apiId || event.apiId === apiId) - .filter((event) => !from || event.createdAt >= from) - .filter((event) => !to || event.createdAt <= to) - .slice(offset ?? 0, limit === undefined ? undefined : (offset ?? 0) + limit), - }, - new InMemoryScheduleStore(exportConfig.signingSecret!), - objectStorageClient, - ) - : undefined; - - // Initialize deposit and vault controllers - const transactionBuilder = new TransactionBuilderService(); - const depositController = new DepositController( - vaultRepository, - transactionBuilder, - ); - const vaultReader = - dependencies?.vaultReader ?? - createSorobanVaultReader( - config.stellar.networks, - config.sorobanRpc?.timeout ?? 2_000, - ); - const vaultController = new VaultController( - vaultRepository, - vaultReader, - config.vaultSnapshotMaxAgeMs, - ); + const { reportExporterService, scheduledExportsService } = createAppExports(usageEventsRepository); const apiRepository = dependencies?.apiRepository ?? defaultApiRepository; const developerRepository = dependencies?.developerRepository ?? defaultDeveloperRepository; - // Public documentation routes (own CSP; mounted ahead of the global Helmet policy) - app.use(createDocsRouter()); - - // Production-safe security headers with environment-based configuration - const isProduction = process.env.NODE_ENV === "production"; - const isDevelopment = process.env.NODE_ENV === "development"; - - // Apply Helmet with production-safe defaults - app.use( - helmet({ - // Content Security Policy - stricter in production - contentSecurityPolicy: { - directives: { - defaultSrc: ["'self'"], - styleSrc: ["'self'", "'unsafe-inline'"], // Allow inline styles for development - scriptSrc: ["'self'"], - imgSrc: ["'self'", "data:", "https:"], - connectSrc: ["'self'", ...(isDevelopment ? ["ws:", "wss:"] : [])], - fontSrc: ["'self'"], - objectSrc: ["'none'"], - mediaSrc: ["'self'"], - frameSrc: ["'none'"], - }, - }, - // Cross-Origin Embedder Policy - crossOriginEmbedderPolicy: isProduction - ? { policy: "require-corp" } - : false, - // HSTS - only in production with HTTPS - hsts: isProduction - ? { - maxAge: 31536000, // 1 year - includeSubDomains: true, - preload: true, - } - : false, - // Other security headers - referrerPolicy: { policy: "strict-origin-when-cross-origin" }, - permittedCrossDomainPolicies: false, - // Allow dev tools in development - hidePoweredBy: !isDevelopment, - }), - ); - - app.use(requestIdMiddleware); - app.use(createResponseValidatorMiddleware()); - app.use(envelopeMiddleware); - app.use(responseEnrichMiddleware); - const memoryAccountingMiddleware = createMemoryAccountingMiddleware( - config.memoryAccounting, - ); - app.use(memoryAccountingMiddleware); - app.use(metricsMiddleware); - - app.use( - createAccessLogMiddleware({ - sampleRate: config.accessLog.sampleRate, - redactFields: config.accessLog.redactFields, - }), - ); - - // Parse allowed origins with validation - const allowedOrigins = ( - process.env.CORS_ALLOWED_ORIGINS ?? "http://localhost:5173" - ) - .split(",") - .map((o: string) => o.trim()) - .filter((o: string) => o.length > 0); + registerAppMiddleware(app); - // Validate origins in production - if (isProduction && allowedOrigins.length === 0) { - console.warn("WARNING: No CORS_ALLOWED_ORIGINS configured in production"); - } - - // Regex for localhost with optional port (e.g., http://localhost:5173) - const localhostRegex = /^http:\/\/localhost(:\d+)?$/; - - app.use( - cors({ - origin: ( - origin: string | undefined, - callback: (err: Error | null, allow?: boolean) => void, - ) => { - // Allow requests with no origin (mobile apps, curl, etc.) - if (!origin) { - return callback(null, true); - } - - // Check if origin is in allowlist - if (allowedOrigins.includes(origin)) { - return callback(null, true); - } - - // In development, allow localhost with any port using strict regex - if (isDevelopment && localhostRegex.test(origin)) { - return callback(null, true); - } - - // Log blocked attempts in production - if (isProduction) { - console.warn(`CORS blocked origin: ${origin}`); - } - - // Pass false instead of Error to prevent Express from returning 500 - callback(null, false); - }, - methods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"], - allowedHeaders: [ - "Content-Type", - "Authorization", - "x-admin-api-key", - "x-request-id", // Added for tracing - ], - credentials: true, - exposedHeaders: ["X-Request-Id", "Retry-After", "ETag", "X-Correlation-Id", "RateLimit-Limit", "RateLimit-Remaining", "RateLimit-Reset"], - // Reduce preflight cache time in production for security - maxAge: isProduction ? 600 : 86400, // 10 minutes vs 24 hours - optionsSuccessStatus: 204, // No content for preflight - }), - ); - const requestBodyLimit = process.env.REQUEST_BODY_LIMIT ?? "100kb"; - app.use(createRouteBodyLimitMiddleware(config.routeBodyLimits)); - app.use(express.json({ limit: requestBodyLimit })); - app.use(express.urlencoded({ extended: false, limit: requestBodyLimit })); - // Attach req.auditContext (IP, UA, tenantId, correlationId, bodyHash) for all routes. - app.use(auditEnrichMiddleware); - - // OpenAPI contract validation — only validates paths defined in the spec. - // Security is handled by custom middleware, not the validator. - // Skip in test environment to avoid interfering with integration tests. - const embeddedOpenApi = getEmbeddedAssets()?.openapi; - if (process.env.NODE_ENV !== "test") { - app.use( - OpenApiValidator.middleware({ - apiSpec: embeddedOpenApi - ? JSON.parse(embeddedOpenApi) - : path.resolve(process.cwd(), "docs/openapi.json"), - validateRequests: true, - // Response validation is disabled in demo mode, where some responses - // intentionally omit optional contract fields (see README). - validateResponses: process.env.DEMO_MODE !== "true", - validateSecurity: false, - ignoreUndocumented: true, - }), - ); - } - - // Register envelope validator after body parser but before routes - app.use(envelopeValidator); - - /** - * GET /api/health - * - * Provides health status of the application and its dependencies. - * If health check config is minimally configured, returns a basic status. - * - * @schema HealthCheckResult | BasicHealthResult - * @example Basic - * { - * "success": true, - * "data": { - * "status": "ok", - * "service": "callora-backend" - * }, - * "requestId": "...", - * "timestamp": "..." - * } - * @example Full - * { - * "success": true, - * "data": { - * "status": "ok", - * "version": "1.0.0", - * "timestamp": "2026-03-27T10:00:00.000Z", - * "checks": { - * "api": "ok", - * "database": "ok", - * "soroban_rpc": "ok" - * } - * }, - * "requestId": "...", - * "timestamp": "..." - * } - */ // Per-dependency health probe — detailed status for each configured dependency app.use( "/api/health/dependencies", @@ -492,7 +131,7 @@ export const createApp = (dependencies?: Partial) => { app.get("/api/health", (req, res) => { const requestId = getRequestId(req); - const data = { status: "ok", service: "callora-backend" }; + const data = { status: "ok", service: "callora-backend", mode: config.demoMode ? "demo" : "standard" }; res.json(successEnvelope(data, requestId)); }); @@ -500,20 +139,7 @@ export const createApp = (dependencies?: Partial) => { // Mounted in front of the admin routers so it cannot be shadowed by their catch-alls. app.use("/api/maintenance", publicMaintenanceRouter); - // Mounted before the generic admin router so the specific path is not - // shadowed by adminRouter's `/usage/:developerId` route. - app.use("/api/admin/usage/anomalies", createUsageAnomaliesRouter({ pool })); - app.use( - "/api/admin/usage/by-endpoint", - createAdminUsageByEndpointRouter({ pool }), - ); - app.use("/api/admin", adminRouter); - app.use("/api/admin/db/explain", createExplainRouter({ pool })); - app.use('/api/admin/usage/anomalies', createUsageAnomaliesRouter({ pool })); - app.use('/api/admin/usage/by-endpoint', createAdminUsageByEndpointRouter({ pool })); - app.use('/api/admin/usage/spike', createSpikeRouter({ pool })); - app.use('/api/admin', adminRouter); - app.use('/api/admin/db/explain', createExplainRouter({ pool })); + registerAdminRoutes(app); // Quota self-service — developers submit requests, admins manage via /api/admin/quota/requests app.use("/api/quota/requests", quotaRequestsRouter); @@ -540,8 +166,6 @@ export const createApp = (dependencies?: Partial) => { app.use("/api/marketplace/plugins", createPluginsRouter()); app.use("/api/gateway", createGatewayHealthRouter()); - - // Webhook management routes app.use('/api/webhooks', createWebhooksRouter()); @@ -561,313 +185,11 @@ export const createApp = (dependencies?: Partial) => { }), ); - app.get( - "/api/developers/apis", - requireAuth, - asyncHandler(async (req, res: express.Response, next) => { - const requestId = getRequestId(req); - const user = res.locals.authenticatedUser; - if (!user) { - next(new UnauthorizedError()); - return; - } - - const developer = await developerRepository.findByUserId(user.id); - if (!developer) { - next(new NotFoundError("Developer profile not found")); - return; - } - - const statusParam = - typeof req.query.status === "string" ? req.query.status : undefined; - let statusFilter: ApiStatus | undefined; - if (statusParam) { - if (!apiStatusEnum.includes(statusParam as ApiStatus)) { - next( - new BadRequestError( - `status must be one of: ${apiStatusEnum.join(", ")}`, - ), - ); - return; - } - statusFilter = statusParam as ApiStatus; - } - - const { limit, offset } = parsePagination( - req.query as Record, - ); - - const apis = await apiRepository.listByDeveloper(developer.id, { - status: statusFilter, - limit, - offset, - }); - - const usageStats = await usageEventsRepository.aggregateByDeveloper( - user.id, - ); - const statsByApi = new Map(usageStats.map((stat) => [stat.apiId, stat])); - - const payload = apis.map((api) => { - const stats = statsByApi.get(String(api.id)); - const entry: { - id: number; - name: string; - status: ApiStatus; - callCount: number; - revenue?: string; - } = { - id: api.id, - name: api.name, - status: api.status, - callCount: stats?.calls ?? 0, - }; - if (stats) { - entry.revenue = stats.revenue.toString(); - } - return entry; - }); - - res.json( - successEnvelope( - paginatedResponse(payload, { limit, offset }), - requestId, - ), - ); - }), - ); - - /** - * GET /api/developers/analytics - * - * Retrieves usage and revenue analytics for the authenticated developer. - * - * Query params: - * from - Start date (ISO-8601 string) (required) - * to - End date (ISO-8601 string) (required) - * groupBy - Aggregation period: 'day', 'week', 'month' (default 'day') - * apiId - Filter by specific API ID (optional) - * includeTop - Include top endpoints and users (optional, default false) - * - * @schema DeveloperAnalyticsResponse - * @example - * { - * "data": [ - * { - * "period": "2026-02-01", - * "calls": 2, - * "revenue": "240" - * } - * ], - * "topEndpoints": [ - * { "endpoint": "/v1/search", "calls": 2 } - * ], - * "topUsers": [ - * { "userId": "user-a", "calls": 2 } - * ] - * } - */ - app.get( - "/api/developers/analytics", - requireAuth, - asyncHandler(async (req, res: express.Response, next) => { - const requestId = getRequestId(req); - const user = res.locals.authenticatedUser; - if (!user) { - next(new UnauthorizedError()); - return; - } - - const groupBy = req.query.groupBy ?? "day"; - if (typeof groupBy !== "string" || !isValidGroupBy(groupBy)) { - next(new BadRequestError("groupBy must be one of: day, week, month")); - return; - } - - const from = parseDate(req.query.from); - const to = parseDate(req.query.to); - if (!from || !to) { - next(new BadRequestError("from and to are required ISO date values")); - return; - } - if (from > to) { - next(new BadRequestError("from must be before or equal to to")); - return; - } - - const apiId = - typeof req.query.apiId === "string" ? req.query.apiId : undefined; - if (apiId) { - const ownsApi = await usageEventsRepository.developerOwnsApi( - user.id, - apiId, - ); - if (!ownsApi) { - next( - new ForbiddenError( - "Forbidden: API does not belong to authenticated developer", - ), - ); - return; - } - } + registerDeveloperAnalyticsRoutes(app, developerRepository, apiRepository, usageEventsRepository); - const includeTop = req.query.includeTop === "true"; - const events = await usageEventsRepository.findByDeveloper({ - developerId: user.id, - from, - to, - apiId, - }); + registerVaultRoutes(app, vaultRepository, dependencies?.vaultReader); - const analytics = buildDeveloperAnalytics(events, groupBy, includeTop); - res.json(successEnvelope(analytics, requestId)); - }), - ); - - // Deposit transaction preparation endpoint - app.post( - "/api/vault/deposit/prepare", - requireAuth, - (req, res: express.Response) => { - depositController.prepareDeposit(req, res); - }, - ); - - /** - * GET /api/vault/balance - * - * Returns the authenticated user's vault balance for the requested Stellar network. - * - * Query params: - * network - optional Stellar network identifier (`testnet` or `mainnet`) - * default: the configured Stellar network - */ - // Vault balance endpoint - app.get( - "/api/vault/balance", - requireAuth, - validate({ query: stellarNetworkQuerySchema }), - (req, res: express.Response, next) => { - vaultController.getBalance(req, res, next); - }, - ); - - const onchainVaultAuth = - process.env.DEMO_MODE === "true" - ? (req: express.Request, res: express.Response, next: express.NextFunction) => { - if (!req.headers.authorization) { - next(); - return; - } - requireAuth(req, res, next); - } - : requireAuth; - app.get( - "/api/vault/onchain", - onchainVaultAuth, - validate({ query: stellarNetworkQuerySchema }), - (req, res: express.Response, next) => { - vaultController.getOnchain(req, res, next); - }, - ); - - /** - * POST /api/developers/apis - * - * Publishes a new API for the authenticated developer. - * - * @schema CreateApiInput -> ApiWithEndpoints - * @example Request - * { - * "name": "My Weather API", - * "description": "Real-time weather data", - * "base_url": "https://api.weather.example.com", - * "category": "weather", - * "status": "draft", - * "endpoints": [ - * { - * "path": "/forecast", - * "method": "GET", - * "price_per_call_usdc": "0.01", - * "description": "Get forecast" - * } - * ] - * } - * @example Response (201 Created) - * { - * "id": 1, - * "developer_id": 42, - * "name": "My Weather API", - * "description": "Real-time weather data", - * "base_url": "https://api.weather.example.com", - * "logo_url": null, - * "category": "weather", - * "status": "draft", - * "created_at": "2026-03-27T10:00:00.000Z", - * "updated_at": "2026-03-27T10:00:00.000Z", - * "endpoints": [ - * { - * "id": 1, - * "api_id": 1, - * "path": "/forecast", - * "method": "GET", - * "price_per_call_usdc": "0.01", - * "description": "Get forecast", - * "created_at": "2026-03-27T10:00:00.000Z", - * "updated_at": "2026-03-27T10:00:00.000Z" - * } - * ] - * } - */ - app.post( - "/api/developers/apis", - requireAuth, - bodyValidator(apiRegistrationSchema), - async (req, res: express.Response, next) => { - try { - const requestId = getRequestId(req); - const user = res.locals.authenticatedUser; - if (!user) { - next(new UnauthorizedError()); - return; - } - - const payload = apiRegistrationSchema.parse(req.body); - - // Ensure the caller has a developer profile - const developer = await lookupDeveloper(user.id); - if (!developer) { - next( - new BadRequestError( - "Developer profile not found. Create a developer profile first.", - "DEVELOPER_NOT_FOUND", - ), - ); - return; - } - - const api = await persistApi({ - developer_id: developer.id, - name: payload.name, - description: payload.description ?? null, - base_url: payload.base_url, - category: payload.category, - status: "active", - endpoints: payload.endpoints.map((ep) => ({ - path: ep.path, - method: ep.method, - price_per_call_usdc: ep.price_per_call_usdc, - description: ep.description ?? null, - })), - }); - - res.status(201).json(successEnvelope(api, requestId)); - } catch (err) { - next(err); - } - }, - ); + registerDeveloperRegistrationRoute(app, lookupDeveloper, persistApi); // OpenAPI validation errors app.use(openApiErrorHandler); diff --git a/src/config/core-settings.ts b/src/config/core-settings.ts new file mode 100644 index 00000000..a39724ab --- /dev/null +++ b/src/config/core-settings.ts @@ -0,0 +1,157 @@ +import { parseUpstreamHostAllowlist, validateUpstreamBaseUrl } from "../lib/upstreamTarget.js"; +import { env } from "./env.js"; +const upstreamHostAllowlist = parseUpstreamHostAllowlist(env.UPSTREAM_HOST_ALLOWLIST); +const validatedUpstreamUrl = validateUpstreamBaseUrl(env.UPSTREAM_URL, { allowedHosts: upstreamHostAllowlist }); + +export const coreSettings = { + port: env.PORT, + demoMode: env.DEMO_MODE, + nodeEnv: env.NODE_ENV, + version: env.APP_VERSION, + accessLog: { + sampleRate: env.ACCESS_LOG_SAMPLE_RATE, + redactFields: (env.ACCESS_LOG_REDACT_FIELDS ?? "") + .split(",") + .map((field) => field.trim()) + .filter((field) => field.length > 0), + }, + usageAccessLog: { + redactFields: (env.USAGE_ACCESS_LOG_REDACT_FIELDS ?? "") + .split(",") + .map((field) => field.trim()) + .filter((field) => field.length > 0), + }, + + databaseUrl: env.DATABASE_URL, + replicaUrls: env.REPLICA_URLS, + database: { + pool: { + host: env.DB_HOST, + port: env.DB_PORT, + user: env.DB_USER, + password: env.DB_PASSWORD, + database: env.DB_NAME, + max: env.DB_POOL_MAX, + idleTimeoutMillis: env.DB_IDLE_TIMEOUT_MS, + connectionTimeoutMillis: env.DB_CONN_TIMEOUT_MS, + }, + timeout: env.HEALTH_CHECK_DB_TIMEOUT, + }, + /** Per-request timeout for GET /api/health (ms). Sends 504 on exceed. */ + healthRequestTimeoutMs: env.HEALTH_REQUEST_TIMEOUT_MS, + dbPool: { + max: env.DB_POOL_MAX, + idleTimeoutMillis: env.DB_IDLE_TIMEOUT_MS, + connectionTimeoutMillis: env.DB_CONN_TIMEOUT_MS, + }, + jwt: { + secret: env.JWT_SECRET, + }, + metrics: { + apiKey: env.METRICS_API_KEY, + }, + + exports: { + enabled: env.EXPORTS_ENABLED, + bucket: env.EXPORTS_S3_BUCKET, + endpoint: env.EXPORTS_S3_ENDPOINT, + region: env.EXPORTS_S3_REGION, + signingSecret: env.EXPORTS_SIGNING_SECRET, + endpointAllowlist: (env.EXPORTS_S3_ENDPOINT_ALLOWLIST ?? "") + .split(",") + .map((host) => host.trim()) + .filter(Boolean), + }, + + proxy: { + upstreamUrl: validatedUpstreamUrl, + timeoutMs: env.PROXY_TIMEOUT_MS, + allowedHosts: upstreamHostAllowlist, + }, + + restRateLimit: { + windowMs: env.REST_RATE_LIMIT_WINDOW_MS, + maxRequests: env.REST_RATE_LIMIT_MAX_REQUESTS, + }, + + gatewayRateLimit: { + windowMs: env.GATEWAY_RATE_LIMIT_WINDOW_MS, + maxRequests: env.GATEWAY_RATE_LIMIT_MAX_REQUESTS, + }, + + webhookRateLimit: { + windowMs: env.WEBHOOK_RATE_LIMIT_WINDOW_MS ?? env.REST_RATE_LIMIT_WINDOW_MS, + maxRequests: + env.WEBHOOK_RATE_LIMIT_MAX_REQUESTS ?? env.REST_RATE_LIMIT_MAX_REQUESTS, + }, + + webhooks: { + secretRotationGraceMs: env.WEBHOOK_SECRET_ROTATION_GRACE_MS, + }, + + authTimeoutMs: env.AUTH_TIMEOUT_MS, + + loginRateLimit: { + windowMs: env.LOGIN_RATE_LIMIT_WINDOW_MS, + maxRequests: env.LOGIN_RATE_LIMIT_MAX_REQUESTS, + }, + + creditsRateLimit: { + capacity: env.CREDITS_RATE_LIMIT_CAPACITY, + refillRate: env.CREDITS_RATE_LIMIT_REFILL_RATE, + }, + + billingRateLimit: { + windowMs: env.BILLING_RATE_LIMIT_WINDOW_MS, + maxRequests: env.BILLING_RATE_LIMIT_MAX_REQUESTS, + }, + + quotaRateLimit: { + // Token-bucket parameters for the /api/quotas endpoint group. + // Burst: up to `capacity` requests are allowed immediately. + // Steady-state: `refillRate` tokens per second are added back to each bucket. + capacity: env.QUOTA_RATE_LIMIT_CAPACITY, + refillRate: env.QUOTA_RATE_LIMIT_REFILL_RATE, + }, + + rateLimiter: { + maxRequests: env.RATE_LIMIT_MAX_REQUESTS, + windowMs: env.RATE_LIMIT_WINDOW_MS, + store: env.RATE_LIMIT_STORE, + postgresTable: env.RATE_LIMIT_PG_TABLE, + outageMode: env.RATE_LIMIT_OUTAGE_MODE, + fallbackMaxRequests: env.RATE_LIMIT_FALLBACK_MAX_REQUESTS, + fallbackWindowMs: env.RATE_LIMIT_FALLBACK_WINDOW_MS, + maxFallbackBuckets: env.RATE_LIMIT_FALLBACK_MAX_BUCKETS, + }, + + sorobanRpc: + !env.DEMO_MODE && env.SOROBAN_RPC_ENABLED && env.SOROBAN_RPC_URL + ? { + url: env.SOROBAN_RPC_URL, + timeout: env.SOROBAN_RPC_TIMEOUT, + } + : undefined, + + horizon: + !env.DEMO_MODE && env.HORIZON_ENABLED && env.HORIZON_URL + ? { + url: env.HORIZON_URL, + timeout: env.HORIZON_TIMEOUT, + } + : undefined, + + settlementSync: { + intervalMs: env.SETTLEMENT_STATUS_SYNC_INTERVAL_MS, + timeoutMs: env.SETTLEMENT_STATUS_SYNC_TIMEOUT_MS, + maxRetries: env.SETTLEMENT_MAX_RETRIES, + }, + settlementRecon: { + intervalMs: env.SETTLEMENT_RECON_INTERVAL_MS, + }, + revenueLedgerIndexer: { + intervalMs: env.REVENUE_LEDGER_INDEXER_INTERVAL_MS, + batchSize: env.REVENUE_LEDGER_INDEXER_BATCH_SIZE, + }, + +} as const; diff --git a/src/config/demoMode.test.ts b/src/config/demoMode.test.ts new file mode 100644 index 00000000..c4ebc461 --- /dev/null +++ b/src/config/demoMode.test.ts @@ -0,0 +1,44 @@ +import { envSchema } from "./env.js"; + +describe("DEMO_MODE configuration", () => { + const required = { JWT_SECRET: "dummy-jwt", ADMIN_API_KEY: "dummy-admin", METRICS_API_KEY: "dummy-metrics" }; + test.each([[undefined, false], ["false", false], ["true", true]])("parses %s as %s", (value, expected) => { + expect(envSchema.parse({ ...required, DEMO_MODE: value }).DEMO_MODE).toBe(expected); + }); + test.each(["yes", "1", "TRUE", "", true])("rejects an ambiguous value: %s", (value) => { + expect(envSchema.safeParse({ ...required, DEMO_MODE: value }).success).toBe(false); + }); + test("does not require a billing contract for a production demo", () => { + const production = { + ...required, + NODE_ENV: "production", + UPSTREAM_HOST_ALLOWLIST: "localhost", + STELLAR_NETWORK: "testnet", + ALLOW_TESTNET_IN_PRODUCTION: "true", + }; + expect(envSchema.safeParse({ ...production, DEMO_MODE: "true" }).success).toBe(true); + expect(envSchema.safeParse({ ...production, DEMO_MODE: "false" }).success).toBe(false); + }); + + test.each(["true", "false"])("preserves production security checks with DEMO_MODE=%s", (demoMode) => { + const result = envSchema.safeParse({ + ...required, + NODE_ENV: "production", + DEMO_MODE: demoMode, + STELLAR_NETWORK: "testnet", + FORWARDED_IDENTITY_ALLOW_LEGACY: "true", + SOROBAN_BILLING_CONTRACT_ID: "invalid-contract", + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.map((issue) => issue.path.join("."))).toEqual( + expect.arrayContaining([ + "UPSTREAM_HOST_ALLOWLIST", + "STELLAR_NETWORK", + "FORWARDED_IDENTITY_ALLOW_LEGACY", + "SOROBAN_BILLING_CONTRACT_ID", + ]), + ); + } + }); +}); diff --git a/src/config/env.ts b/src/config/env.ts index 23384c94..c92ed0fc 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -1,619 +1,17 @@ import "dotenv/config"; import { z } from "zod"; -import { Keypair, StrKey } from "@stellar/stellar-sdk"; - -// StrKey validators are defined inline here (rather than imported from -// src/validators/stellarKeys.ts) so that env parsing has no dependency on -// modules that may be mid-edit. Each schema reports only the variable name -// and the failing rule — never the key material itself. -const contractId = z - .string() - .trim() - .min(1) - .refine((v) => StrKey.isValidContract(v), { - message: "must be a valid Stellar contract id (C...)", - }); - -const publicKey = z - .string() - .trim() - .min(1) - .refine((v) => StrKey.isValidEd25519PublicKey(v), { - message: "must be a valid Stellar public key (G...)", - }); - -const secretKey = z - .string() - .trim() - .min(1) - .refine((v) => StrKey.isValidEd25519SecretSeed(v), { - message: "must be a valid Stellar secret seed (S...)", - }); - -const stellarNetworkSchema = z.enum(["testnet", "mainnet"]); - -export const envSchema = z - .object({ - // Server - PORT: z.coerce.number().default(3000), - NODE_ENV: z - .enum(["development", "production", "test"]) - .default("development"), - - // Database (primary connection string) - DATABASE_URL: z - .string() - .default( - "postgresql://postgres:postgres@localhost:5432/callora?schema=public", - ), - - // Database pool - DB_POOL_MAX: z.coerce.number().default(10), - DB_IDLE_TIMEOUT_MS: z.coerce.number().default(30_000), - DB_CONN_TIMEOUT_MS: z.coerce.number().default(2_000), - - /** - * REPLICA_URLS — optional comma-separated list of PostgreSQL read-replica - * connection strings. - * - * Format: - * REPLICA_URLS=postgresql://user:pass@replica1:5432/db,postgresql://user:pass@replica2:5432/db - * - * Behaviour: - * - When set, read-only repository queries are routed round-robin to - * the listed replicas. Write queries always use DATABASE_URL (primary). - * - On replica failure the query is automatically retried against the - * primary; see src/db/replicaPool.ts for details. - * - When absent or empty, all queries continue to use the primary pool. - * - * Each URL must use the postgresql:// or postgres:// scheme. Individual - * URL validation (scheme, format) is performed at application startup by - * the replica pool initialisation code in src/db/replicaPool.ts. - */ - REPLICA_URLS: z - .string() - .optional() - .refine( - (val) => { - if (!val || val.trim() === "") return true; - // Validate that each entry is a parseable postgresql:// URL - return val.split(",").every((raw) => { - const url = raw.trim(); - if (!url) return false; - try { - const parsed = new URL(url); - return ( - parsed.protocol === "postgresql:" || - parsed.protocol === "postgres:" - ); - } catch { - return false; - } - }); - }, - { - message: - "REPLICA_URLS must be a comma-separated list of valid postgresql:// or postgres:// connection strings.", - }, - ), - - // Database (individual fields for health checks) - DB_HOST: z.string().default("localhost"), - DB_PORT: z.coerce.number().default(5432), - DB_USER: z.string().default("postgres"), - DB_PASSWORD: z.string().default("postgres"), - DB_NAME: z.string().default("callora"), - - // Auth - JWT_SECRET: z.string().min(1, "JWT_SECRET is required"), - ADMIN_API_KEY: z.string().min(1, "ADMIN_API_KEY is required"), - METRICS_API_KEY: z.string().min(1, "METRICS_API_KEY is required"), - - // Export object storage. Disabled unless explicitly enabled. - EXPORTS_ENABLED: z.string().optional().transform((value) => value === "true").default(false), - EXPORTS_S3_BUCKET: z.string().trim().min(1).optional(), - EXPORTS_S3_ENDPOINT: z.string().url().optional(), - EXPORTS_S3_REGION: z.string().trim().min(1).optional(), - EXPORTS_SIGNING_SECRET: z.string().min(32).optional(), - EXPORTS_S3_ENDPOINT_ALLOWLIST: z.string().optional(), - TRUST_FORWARDED_USER_ID: z - .string() - .optional() - .transform((v) => v === "true") - .default(false), - FORWARDED_IDENTITY_TOLERANCE_SECONDS: z.coerce.number().int().min(1).max(3600).default(60), - FORWARDED_IDENTITY_ALLOW_LEGACY: z.string().optional().transform(v => v === "true").default(false), - FORWARDED_IDENTITY_REPLAY_STORE: z.enum(["memory", "postgres"]).default("memory"), - FORWARDED_IDENTITY_REPLAY_MAX_ENTRIES: z.coerce.number().int().positive().max(1_000_000).default(10_000), - FORWARDED_USER_ID_SECRET: z.string().optional(), - INTERNAL_GATEWAY_SECRET: z.string().optional(), - - // Proxy / Gateway - UPSTREAM_URL: z.string().url().default("http://localhost:4000"), - UPSTREAM_HOST_ALLOWLIST: z.string().optional(), - PROXY_TIMEOUT_MS: z.coerce.number().default(30_000), - PROXY_BREAKER_FAILURE_THRESHOLD: z.coerce.number().int().positive().default(5), - PROXY_BREAKER_COOLDOWN_MS: z.coerce.number().int().positive().default(30_000), - PROXY_BREAKER_SUCCESS_THRESHOLD: z.coerce.number().int().positive().default(1), - // REST API rate limiting (per-user with IP fallback) - REST_RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(60_000), - REST_RATE_LIMIT_MAX_REQUESTS: z.coerce.number().int().positive().default(100), - // Gateway/proxy rate limiting (per authenticated user only) - GATEWAY_RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(60_000), - GATEWAY_RATE_LIMIT_MAX_REQUESTS: z.coerce.number().int().positive().default(100), - WEBHOOK_RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().optional(), - WEBHOOK_RATE_LIMIT_MAX_REQUESTS: z.coerce - .number() - .int() - .positive() - .optional(), - WEBHOOK_SECRET_ROTATION_GRACE_MS: z.coerce - .number() - .int() - .positive() - .default(24 * 60 * 60 * 1000), - // Per-API-key token-bucket rate limit applied to /api/gateway and /v1/call. - RATE_LIMIT_MAX_REQUESTS: z.coerce.number().int().positive().default(5), - RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(60_000), - RATE_LIMIT_STORE: z.enum(["memory", "postgres"]).default("memory"), - RATE_LIMIT_OUTAGE_MODE: z.enum(["fail-closed", "fallback"]).default("fail-closed"), - RATE_LIMIT_FALLBACK_MAX_REQUESTS: z.coerce.number().int().positive().default(10), - RATE_LIMIT_FALLBACK_WINDOW_MS: z.coerce.number().int().positive().default(60_000), - RATE_LIMIT_FALLBACK_MAX_BUCKETS: z.coerce.number().int().positive().default(10_000), - RATE_LIMIT_PG_TABLE: z - .string() - .regex( - /^[a-z_][a-z0-9_]*$/i, - "RATE_LIMIT_PG_TABLE must contain only letters, numbers, and underscores", - ) - .default("gateway_rate_limit_buckets"), - - // Auth per-request timeout (graceful timeout with 504 Gateway Timeout) - AUTH_TIMEOUT_MS: z.coerce.number().int().positive().default(10_000), - - // Login rate limiting (IP-based throttling for auth attempts) - LOGIN_RATE_LIMIT_MAX_REQUESTS: z.coerce - .number() - .int() - .positive() - .default(5), - LOGIN_RATE_LIMIT_WINDOW_MS: z.coerce - .number() - .int() - .positive() - .default(60_000), // 1 minute sliding window - - // Credits endpoint token-bucket rate limiting - CREDITS_RATE_LIMIT_CAPACITY: z.coerce.number().int().positive().default(10), - CREDITS_RATE_LIMIT_REFILL_RATE: z.coerce.number().positive().default(1), - - // /api/quotas per-user token-bucket rate limiting. - // capacity: maximum burst of requests before the bucket empties (default 60). - // refillRate: tokens added per second (default 1 → steady-state of 1 req/s). - QUOTA_RATE_LIMIT_CAPACITY: z.coerce.number().int().positive().default(60), - QUOTA_RATE_LIMIT_REFILL_RATE: z.coerce.number().positive().default(1), - - // Billing endpoint per-user rate limiting (fixed-window) - BILLING_RATE_LIMIT_WINDOW_MS: z.coerce - .number() - .int() - .positive() - .default(60_000), - BILLING_RATE_LIMIT_MAX_REQUESTS: z.coerce - .number() - .int() - .positive() - .default(100), - - // CORS - CORS_ALLOWED_ORIGINS: z.string().default("http://localhost:5173"), - - // Subscription CORS allowlist (comma-separated origins; deny by default when empty). - // - // This entry mirrors the pattern used by MAINTENANCE_CORS_ALLOWED_ORIGINS: - // the runtime parser lives in - // {@link createSubscriptionCorsMiddleware} (src/middleware/cors.ts), which - // reads `process.env` lazily so tests that mutate the env after module - // load still work. If this entry is transformed into an array here, the - // middleware will continue to read the raw string and the two sources of - // truth will silently diverge. - SUBSCRIPTION_CORS_ALLOWED_ORIGINS: z.string().default(""), - - // Maintenance CORS allowlist (comma-separated origins; deny by default when empty). - // - // This entry is intentionally left as a raw string — it exists in the - // schema for documentation and `.env.example` cross-referencing purposes - // only. The runtime parser lives in - // {@link createMaintenanceCorsMiddleware} (src/middleware/cors.ts), which - // reads `process.env` lazily so tests that mutate the env after module - // load still work. If this entry is transformed into an array here, the - // middleware will continue to read the raw string and the two sources of - // truth will silently diverge. - MAINTENANCE_CORS_ALLOWED_ORIGINS: z.string().default(""), - - // Apis CORS allowlist (comma-separated origins; deny by default when empty). - // - // This entry is intentionally left as a raw string — it exists in the - // schema for documentation and `.env.example` cross-referencing purposes - // only. The runtime parser lives in - // {@link createApisCorsMiddleware} (src/middleware/cors.ts), which - // reads `process.env` lazily so tests that mutate the env after module - // load still work. If this entry is transformed into an array here, the - // middleware will continue to read the raw string and the two sources of - // truth will silently diverge. - APIS_CORS_ALLOWED_ORIGINS: z.string().default(""), - - // Soroban RPC (optional) - SOROBAN_RPC_ENABLED: z - .string() - .transform((v) => v === "true") - .default(false), - SOROBAN_RPC_URL: z.string().url().optional(), - SOROBAN_RPC_TIMEOUT: z.coerce.number().default(2_000), - - // Billing RPC (the contract must be configured in production) - SOROBAN_BILLING_RPC_URL: z.string().url().optional(), - SOROBAN_BILLING_CONTRACT_ID: contractId.optional(), - SOROBAN_BILLING_SOURCE_ACCOUNT: publicKey.optional(), - SOROBAN_BILLING_NETWORK_PASSPHRASE: z.string().min(1).optional(), - SOROBAN_BILLING_BACKEND_SECRET_KEY: secretKey.optional(), - SOROBAN_BILLING_RPC_TIMEOUT_MS: z.coerce.number().int().positive().default(5_000), - SOROBAN_BILLING_NETWORK: stellarNetworkSchema.default("testnet"), - - // Stellar accounts / contracts - STELLAR_FEE_ACCOUNT_SECRET_KEY: z.string().min(1).optional(), - STELLAR_FEE_ACCOUNT_PUBLIC_KEY: z.string().min(1).optional(), - SOROBAN_BILLING_BALANCE_FN: z.string().min(1).default("balance"), - SOROBAN_BILLING_DEDUCT_FN: z.string().min(1).default("deduct"), - - // Horizon (optional) - HORIZON_ENABLED: z - .string() - .transform((v) => v === "true") - .default(false), - HORIZON_URL: z.string().url().optional(), - HORIZON_TIMEOUT: z.coerce.number().default(2_000), - SETTLEMENT_STATUS_SYNC_INTERVAL_MS: z.coerce - .number() - .int() - .positive() - .default(60_000), - SETTLEMENT_STATUS_SYNC_TIMEOUT_MS: z.coerce - .number() - .int() - .positive() - .default(5_000), - SETTLEMENT_MAX_RETRIES: z.coerce.number().int().positive().default(5), - SETTLEMENT_RECON_INTERVAL_MS: z.coerce - .number() - .int() - .positive() - .default(86_400_000), - REVENUE_LEDGER_INDEXER_INTERVAL_MS: z.coerce - .number() - .int() - .positive() - .default(30_000), - REVENUE_LEDGER_INDEXER_BATCH_SIZE: z.coerce - .number() - .int() - .positive() - .default(500), - - // Stellar network configuration - STELLAR_NETWORK: stellarNetworkSchema.optional(), - SOROBAN_NETWORK: stellarNetworkSchema.optional(), - - STELLAR_TESTNET_HORIZON_URL: z - .string() - .url() - .default("https://horizon-testnet.stellar.org"), - STELLAR_MAINNET_HORIZON_URL: z - .string() - .url() - .default("https://horizon.stellar.org"), - SOROBAN_TESTNET_RPC_URL: z - .string() - .url() - .default("https://soroban-testnet.stellar.org"), - SOROBAN_MAINNET_RPC_URL: z - .string() - .url() - .default("https://soroban-mainnet.stellar.org"), - - STELLAR_TESTNET_VAULT_CONTRACT_ID: contractId.optional(), - STELLAR_MAINNET_VAULT_CONTRACT_ID: contractId.optional(), - STELLAR_TESTNET_SETTLEMENT_CONTRACT_ID: contractId.optional(), - STELLAR_MAINNET_SETTLEMENT_CONTRACT_ID: contractId.optional(), - VAULT_SNAPSHOT_MAX_AGE_MS: z.coerce.number().int().positive().default(60_000), - - STELLAR_BASE_FEE: z.coerce.number().int().positive().default(100), - STELLAR_TRANSACTION_TIMEOUT: z.coerce.number().int().positive().optional(), - TRANSACTION_TIMEOUT: z.coerce.number().int().positive().optional(), - - // Health check - HEALTH_CHECK_DB_TIMEOUT: z.coerce.number().default(2_000), - /** - * HEALTH_REQUEST_TIMEOUT_MS — maximum wall-clock time (ms) allowed for a - * full GET /api/health response. When the deadline is exceeded the request - * is cooperatively aborted and the caller receives HTTP 504. - * Default: 5 000 ms. - */ - HEALTH_REQUEST_TIMEOUT_MS: z.coerce.number().int().positive().default(5_000), - APIS_CACHE_TTL_MS: z.coerce.number().int().positive().optional(), - LISTINGS_CACHE_WARMUP_TIMEOUT_MS: z.coerce - .number() - .int() - .positive() - .default(5_000), - REFUNDS_CACHE_WARMUP_TIMEOUT_MS: z.coerce - .number() - .int() - .positive() - .default(5_000), - BULK_ENDPOINT_LIMIT: z.coerce.number().int().positive().default(100), - APP_VERSION: z.string().default("1.0.0"), - - // Logging - LOG_LEVEL: z - .enum(["trace", "debug", "info", "warn", "error", "fatal"]) - .default("info"), - ACCESS_LOG_SAMPLE_RATE: z.coerce.number().min(0).max(1).default(1), - ACCESS_LOG_REDACT_FIELDS: z.string().optional(), - USAGE_ACCESS_LOG_REDACT_FIELDS: z.string().optional(), - - // Profiling - GATEWAY_PROFILING_ENABLED: z - .string() - .transform((v) => v === "true") - .default(false), - - // Memory accounting - MEMORY_ACCOUNTING_ENABLED: z - .string() - .transform((v) => v === "true") - .default(false), - MEMORY_ACCOUNTING_THRESHOLD_MB: z.coerce.number().nonnegative().default(50), - // Test-only chaos harness - SOROBAN_CHAOS: z - .string() - .transform((v) => v === "1") - .default(false), - - // Body size limits - REQUEST_BODY_LIMIT: z.string().default("100kb"), - GATEWAY_BODY_LIMIT: z.string().default("1mb"), - ROUTE_BODY_LIMITS: z - .string() - .optional() - .transform((value, ctx) => { - if (!value || value.trim().length === 0) { - return []; - } - - let parsed: unknown; - try { - parsed = JSON.parse(value); - } catch (error) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `ROUTE_BODY_LIMITS must be valid JSON: ${(error as Error).message}`, - }); - return z.NEVER; - } - - if (!Array.isArray(parsed)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: - "ROUTE_BODY_LIMITS must be a JSON array of route body-limit objects", - }); - return z.NEVER; - } - - return parsed.filter( - (entry): entry is Record => - !!entry && typeof entry === "object", - ); - }) - .pipe( - z.array( - z.object({ - method: z.string().min(1), - route: z.string().min(1), - limit: z.string().min(1), - }), - ), - ) - .default([]), - - // Security - BCRYPT_COST_FACTOR: z.coerce.number().int().min(10).max(31).default(12), - - // Billing concurrency control - BILLING_MAX_CONCURRENCY_PER_DEV: z.coerce - .number() - .int() - .positive() - .default(1), - BILLING_SEMAPHORE_TTL_MS: z.coerce - .number() - .int() - .positive() - .default(300000), - - // Gateway per-API-key concurrency control. - // The default ceiling is deliberately generous: the primary purpose is - // observability via GET /api/admin/keys/concurrency. Operators opt into - // enforcement by lowering KEY_MAX_CONCURRENCY_PER_KEY. - KEY_MAX_CONCURRENCY_PER_KEY: z.coerce.number().int().positive().default(50), - KEY_SEMAPHORE_TTL_MS: z.coerce.number().int().positive().default(300000), - - // Idempotency - IDEMPOTENCY_LEASE_SECONDS: z.coerce.number().int().positive().default(60), - IDEMPOTENCY_RETENTION_WINDOW_SECONDS: z.coerce - .number() - .int() - .positive() - .default(86400), - IDEMPOTENCY_SWEEPER_ENABLED: z - .string() - .optional() - .transform((v) => v !== "false") - .default(true), - IDEMPOTENCY_SWEEPER_INTERVAL_MS: z.coerce - .number() - .int() - .positive() - .default(60_000), - IDEMPOTENCY_SWEEPER_BATCH_SIZE: z.coerce - .number() - .int() - .positive() - .default(5000), - IDEMPOTENCY_SWEEPER_TIME_BUDGET_MS: z.coerce - .number() - .int() - .positive() - .default(5000), - - // Slow query alerting - SLOW_QUERY_ALERT_WEBHOOK_URL: z.string().url().optional(), - SLOW_QUERY_P95_THRESHOLD_MS: z.coerce.number().positive().default(500), - SLOW_QUERY_POLL_INTERVAL_MS: z.coerce - .number() - .int() - .positive() - .default(300_000), - SLOW_QUERY_DEDUP_WINDOW_SECONDS: z.coerce - .number() - .int() - .positive() - .default(3600), - - // Usage anomaly detector (5-minute rolling baseline) - USAGE_ANOMALY_DETECTOR_ENABLED: z - .enum(["true", "false"]) - .default("true") - .transform((v) => v === "true"), - USAGE_ANOMALY_MULTIPLIER: z.coerce.number().positive().default(5), - USAGE_ANOMALY_POLL_INTERVAL_MS: z.coerce - .number() - .int() - .positive() - .default(300_000), - USAGE_ANOMALY_WINDOW_MS: z.coerce - .number() - .int() - .positive() - .default(300_000), - USAGE_ANOMALY_BASELINE_WINDOWS: z.coerce - .number() - .int() - .positive() - .default(12), - USAGE_ANOMALY_DEDUP_WINDOW_MS: z.coerce - .number() - .int() - .positive() - .optional(), - - // Monthly invoice job - MONTHLY_INVOICE_JOB_INTERVAL_MS: z.coerce - .number() - .int() - .positive() - .default(86400000), - - // ──────────────────────────────────────────────────────────────────────── - // SLO burn-rate alerting (issue #706) - // - // Each entry in `SLO_ROUTE_CONFIGS` defines a per-route burn threshold - // evaluated against a rolling 96-hour window by the SLO alerter worker. - // At least one of `maxErrorRate` or `maxLatencyP95Ms` must be set, and - // `method`/`route` must match the parameterised Express route label - // emitted by `http_request_duration_seconds` (e.g. `/api/billing/deduct` - // or `/v1/call/:apiId`). - // - // Implementation note: the env variable is a JSON string but the schema - // resolves to a typed `SloRouteConfig[]`. The `transform` clause parses - // and validates the JSON before the `pipe(z.array(...))` step enforces - // per-entry shape (and the `refine` rejects entries missing either - // threshold). Map to `[]` when the env var is unset or empty — there is - // no need to also call `.default()` here because the transform already - // covers the `undefined` case. - // ──────────────────────────────────────────────────────────────────────── - SLO_ROUTE_CONFIGS: z - .string() - .optional() - .transform((val, ctx) => { - if (!val || val.trim().length === 0) { - return []; - } - let parsed: unknown; - try { - parsed = JSON.parse(val); - } catch (err) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `SLO_ROUTE_CONFIGS must be valid JSON: ${(err as Error).message}`, - }); - return z.NEVER; - } - if (!Array.isArray(parsed)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "SLO_ROUTE_CONFIGS must be a JSON array of route objects", - }); - return z.NEVER; - } - return parsed as Array; - }) - .pipe( - z.array( - z - .object({ - method: z.string().min(1, "method is required"), - route: z - .string() - .min(1, "route is required") - .startsWith("/", 'route must start with "/"'), - maxErrorRate: z.number().min(0).max(1).optional(), - maxLatencyP95Ms: z.number().positive().optional(), - }) - .refine( - (cfg) => - cfg.maxErrorRate !== undefined || - cfg.maxLatencyP95Ms !== undefined, - "each SLO route config must define at least one of maxErrorRate or maxLatencyP95Ms", - ), - ), - ), - SLO_ALERT_WEBHOOK_URL: z.string().url().optional(), - SLO_ALERT_POLL_INTERVAL_MS: z.coerce - .number() - .int() - .positive() - .default(300_000), - SLO_ALERT_DEDUP_WINDOW_MS: z.coerce - .number() - .int() - .positive() - .default(86_400_000), // 24h - SLO_ALERT_OBSERVATION_WINDOW_MS: z.coerce - .number() - .int() - .positive() - .default(345_600_000), // 96h = 4 days, the slow-burn window - - // Explicit opt-in to allow the testnet passphrase when NODE_ENV=production. - // The demo deployment sets this deliberately; every other production - // deployment must leave it unset so a testnet/mainnet mix-up fails fast. - ALLOW_TESTNET_IN_PRODUCTION: z - .string() - .transform((v) => v === "true") - .default(false), - }) +import { Keypair } from "@stellar/stellar-sdk"; +import { serverEnvironmentFields } from "./server-env.js"; +import { integrationEnvironmentFields } from "./integrations-env.js"; +import { executionEnvironmentFields } from "./execution-env.js"; +import { sloEnvironmentFields } from "./slo-env.js"; + +export const envSchema = z.object({ + ...serverEnvironmentFields, + ...integrationEnvironmentFields, + ...executionEnvironmentFields, + ...sloEnvironmentFields, +}) .superRefine((data, ctx) => { if (data.EXPORTS_ENABLED) { for (const [key, value] of [ @@ -635,7 +33,7 @@ export const envSchema = z if (data.NODE_ENV === "production" && data.FORWARDED_IDENTITY_ALLOW_LEGACY) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["FORWARDED_IDENTITY_ALLOW_LEGACY"], message: "Legacy forwarded identities are forbidden in production" }); } - if (data.NODE_ENV === "production" && !data.SOROBAN_BILLING_CONTRACT_ID) { + if (data.NODE_ENV === "production" && !data.DEMO_MODE && !data.SOROBAN_BILLING_CONTRACT_ID) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["SOROBAN_BILLING_CONTRACT_ID"], diff --git a/src/config/execution-env.ts b/src/config/execution-env.ts new file mode 100644 index 00000000..51144390 --- /dev/null +++ b/src/config/execution-env.ts @@ -0,0 +1,164 @@ +import { z } from "zod"; + +export const executionEnvironmentFields = { + ROUTE_BODY_LIMITS: z + .string() + .optional() + .transform((value, ctx) => { + if (!value || value.trim().length === 0) { + return []; + } + + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch (error) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `ROUTE_BODY_LIMITS must be valid JSON: ${(error as Error).message}`, + }); + return z.NEVER; + } + + if (!Array.isArray(parsed)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "ROUTE_BODY_LIMITS must be a JSON array of route body-limit objects", + }); + return z.NEVER; + } + + return parsed.filter( + (entry): entry is Record => + !!entry && typeof entry === "object", + ); + }) + .pipe( + z.array( + z.object({ + method: z.string().min(1), + route: z.string().min(1), + limit: z.string().min(1), + }), + ), + ) + .default([]), + + // Security + BCRYPT_COST_FACTOR: z.coerce.number().int().min(10).max(31).default(12), + + // Billing concurrency control + BILLING_MAX_CONCURRENCY_PER_DEV: z.coerce + .number() + .int() + .positive() + .default(1), + BILLING_SEMAPHORE_TTL_MS: z.coerce + .number() + .int() + .positive() + .default(300000), + + // Gateway per-API-key concurrency control. + // The default ceiling is deliberately generous: the primary purpose is + // observability via GET /api/admin/keys/concurrency. Operators opt into + // enforcement by lowering KEY_MAX_CONCURRENCY_PER_KEY. + KEY_MAX_CONCURRENCY_PER_KEY: z.coerce.number().int().positive().default(50), + KEY_SEMAPHORE_TTL_MS: z.coerce.number().int().positive().default(300000), + + // Idempotency + IDEMPOTENCY_LEASE_SECONDS: z.coerce.number().int().positive().default(60), + IDEMPOTENCY_RETENTION_WINDOW_SECONDS: z.coerce + .number() + .int() + .positive() + .default(86400), + IDEMPOTENCY_SWEEPER_ENABLED: z + .string() + .optional() + .transform((v) => v !== "false") + .default(true), + IDEMPOTENCY_SWEEPER_INTERVAL_MS: z.coerce + .number() + .int() + .positive() + .default(60_000), + IDEMPOTENCY_SWEEPER_BATCH_SIZE: z.coerce + .number() + .int() + .positive() + .default(5000), + IDEMPOTENCY_SWEEPER_TIME_BUDGET_MS: z.coerce + .number() + .int() + .positive() + .default(5000), + + // Slow query alerting + SLOW_QUERY_ALERT_WEBHOOK_URL: z.string().url().optional(), + SLOW_QUERY_P95_THRESHOLD_MS: z.coerce.number().positive().default(500), + SLOW_QUERY_POLL_INTERVAL_MS: z.coerce + .number() + .int() + .positive() + .default(300_000), + SLOW_QUERY_DEDUP_WINDOW_SECONDS: z.coerce + .number() + .int() + .positive() + .default(3600), + + // Usage anomaly detector (5-minute rolling baseline) + USAGE_ANOMALY_DETECTOR_ENABLED: z + .enum(["true", "false"]) + .default("true") + .transform((v) => v === "true"), + USAGE_ANOMALY_MULTIPLIER: z.coerce.number().positive().default(5), + USAGE_ANOMALY_POLL_INTERVAL_MS: z.coerce + .number() + .int() + .positive() + .default(300_000), + USAGE_ANOMALY_WINDOW_MS: z.coerce + .number() + .int() + .positive() + .default(300_000), + USAGE_ANOMALY_BASELINE_WINDOWS: z.coerce + .number() + .int() + .positive() + .default(12), + USAGE_ANOMALY_DEDUP_WINDOW_MS: z.coerce + .number() + .int() + .positive() + .optional(), + + // Monthly invoice job + MONTHLY_INVOICE_JOB_INTERVAL_MS: z.coerce + .number() + .int() + .positive() + .default(86400000), + + // ──────────────────────────────────────────────────────────────────────── + // SLO burn-rate alerting (issue #706) + // + // Each entry in `SLO_ROUTE_CONFIGS` defines a per-route burn threshold + // evaluated against a rolling 96-hour window by the SLO alerter worker. + // At least one of `maxErrorRate` or `maxLatencyP95Ms` must be set, and + // `method`/`route` must match the parameterised Express route label + // emitted by `http_request_duration_seconds` (e.g. `/api/billing/deduct` + // or `/v1/call/:apiId`). + // + // Implementation note: the env variable is a JSON string but the schema + // resolves to a typed `SloRouteConfig[]`. The `transform` clause parses + // and validates the JSON before the `pipe(z.array(...))` step enforces + // per-entry shape (and the `refine` rejects entries missing either + // threshold). Map to `[]` when the env var is unset or empty — there is + // no need to also call `.default()` here because the transform already + // covers the `undefined` case. + // ──────────────────────────────────────────────────────────────────────── +} as const; diff --git a/src/config/index.ts b/src/config/index.ts index ec40af4c..571bf3ea 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -1,374 +1,8 @@ -import { env } from "./env.js"; -import { - parseUpstreamHostAllowlist, - validateUpstreamBaseUrl, -} from "../lib/upstreamTarget.js"; - -export type StellarNetwork = "testnet" | "mainnet"; - -interface StellarNetworkConfig { - horizonUrl: string; - sorobanRpcUrl: string; - networkPassphrase: string; - vaultContractId?: string; - settlementContractId?: string; -} - -const TESTNET_NETWORK_PASSPHRASE = "Test SDF Network ; September 2015"; -const MAINNET_NETWORK_PASSPHRASE = - "Public Global Stellar Network ; September 2015"; - -function isLocalStellarHost(hostname: string): boolean { - return ( - hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" - ); -} - -function validateStellarEndpointUrl(name: string, rawUrl: string): string { - let parsed: URL; - - try { - parsed = new URL(rawUrl); - } catch { - throw new Error(`${name} must be a valid absolute URL.`); - } - - if ( - parsed.protocol !== "https:" && - !(parsed.protocol === "http:" && isLocalStellarHost(parsed.hostname)) - ) { - throw new Error( - `${name} must use HTTPS unless it targets localhost for local development.`, - ); - } - - if (!parsed.hostname) { - throw new Error(`${name} must include a hostname.`); - } - - if (parsed.username || parsed.password) { - throw new Error(`${name} must not include embedded credentials.`); - } - - if (parsed.search || parsed.hash) { - throw new Error(`${name} must not include query strings or fragments.`); - } - - return parsed.toString(); -} - -const selectedNetwork: StellarNetwork = - env.STELLAR_NETWORK ?? env.SOROBAN_NETWORK ?? "testnet"; - -const testnetConfig: StellarNetworkConfig = { - horizonUrl: validateStellarEndpointUrl( - "STELLAR_TESTNET_HORIZON_URL", - env.STELLAR_TESTNET_HORIZON_URL, - ), - sorobanRpcUrl: validateStellarEndpointUrl( - "SOROBAN_TESTNET_RPC_URL", - env.SOROBAN_TESTNET_RPC_URL, - ), - networkPassphrase: TESTNET_NETWORK_PASSPHRASE, - vaultContractId: env.STELLAR_TESTNET_VAULT_CONTRACT_ID, - settlementContractId: env.STELLAR_TESTNET_SETTLEMENT_CONTRACT_ID, -}; - -const mainnetConfig: StellarNetworkConfig = { - horizonUrl: validateStellarEndpointUrl( - "STELLAR_MAINNET_HORIZON_URL", - env.STELLAR_MAINNET_HORIZON_URL, - ), - sorobanRpcUrl: validateStellarEndpointUrl( - "SOROBAN_MAINNET_RPC_URL", - env.SOROBAN_MAINNET_RPC_URL, - ), - networkPassphrase: MAINNET_NETWORK_PASSPHRASE, - vaultContractId: env.STELLAR_MAINNET_VAULT_CONTRACT_ID, - settlementContractId: env.STELLAR_MAINNET_SETTLEMENT_CONTRACT_ID, -}; - -const activeConfig = - selectedNetwork === "mainnet" ? mainnetConfig : testnetConfig; - -// Derive billing network settings from the active network unless explicitly overridden. -// The override must match the active network passphrase (enforced in env superRefine). -const billingNetworkPassphrase = - env.SOROBAN_BILLING_NETWORK_PASSPHRASE ?? activeConfig.networkPassphrase; - -const billingRpcUrl = - env.SOROBAN_BILLING_RPC_URL ?? - env.SOROBAN_RPC_URL ?? - activeConfig.sorobanRpcUrl; - -const billingContractId = - env.SOROBAN_BILLING_CONTRACT_ID ?? activeConfig.vaultContractId; - -// Log once at startup when the billing contract id is an explicit override -// of the active network's vault contract id. -const billingContractIsOverride = - Boolean(env.SOROBAN_BILLING_CONTRACT_ID) && - Boolean(activeConfig.vaultContractId) === true && - env.SOROBAN_BILLING_CONTRACT_ID !== activeConfig.vaultContractId; - -if (billingContractIsOverride) { - console.warn( - "SOROBAN_BILLING_CONTRACT_ID differs from the active network's vault contract id; treating it as an explicit override.", - ); -} - -const upstreamHostAllowlist = parseUpstreamHostAllowlist( - env.UPSTREAM_HOST_ALLOWLIST, -); -const validatedUpstreamUrl = validateUpstreamBaseUrl(env.UPSTREAM_URL, { - allowedHosts: upstreamHostAllowlist, -}); +import { coreSettings } from "./core-settings.js"; +import { serviceSettings } from "./service-settings.js"; +export type { StellarNetwork } from "./stellar.js"; export const config = { - port: env.PORT, - nodeEnv: env.NODE_ENV, - version: env.APP_VERSION, - accessLog: { - sampleRate: env.ACCESS_LOG_SAMPLE_RATE, - redactFields: (env.ACCESS_LOG_REDACT_FIELDS ?? "") - .split(",") - .map((field) => field.trim()) - .filter((field) => field.length > 0), - }, - usageAccessLog: { - redactFields: (env.USAGE_ACCESS_LOG_REDACT_FIELDS ?? "") - .split(",") - .map((field) => field.trim()) - .filter((field) => field.length > 0), - }, - - databaseUrl: env.DATABASE_URL, - replicaUrls: env.REPLICA_URLS, - database: { - pool: { - host: env.DB_HOST, - port: env.DB_PORT, - user: env.DB_USER, - password: env.DB_PASSWORD, - database: env.DB_NAME, - max: env.DB_POOL_MAX, - idleTimeoutMillis: env.DB_IDLE_TIMEOUT_MS, - connectionTimeoutMillis: env.DB_CONN_TIMEOUT_MS, - }, - timeout: env.HEALTH_CHECK_DB_TIMEOUT, - }, - /** Per-request timeout for GET /api/health (ms). Sends 504 on exceed. */ - healthRequestTimeoutMs: env.HEALTH_REQUEST_TIMEOUT_MS, - dbPool: { - max: env.DB_POOL_MAX, - idleTimeoutMillis: env.DB_IDLE_TIMEOUT_MS, - connectionTimeoutMillis: env.DB_CONN_TIMEOUT_MS, - }, - jwt: { - secret: env.JWT_SECRET, - }, - metrics: { - apiKey: env.METRICS_API_KEY, - }, - - exports: { - enabled: env.EXPORTS_ENABLED, - bucket: env.EXPORTS_S3_BUCKET, - endpoint: env.EXPORTS_S3_ENDPOINT, - region: env.EXPORTS_S3_REGION, - signingSecret: env.EXPORTS_SIGNING_SECRET, - endpointAllowlist: (env.EXPORTS_S3_ENDPOINT_ALLOWLIST ?? "") - .split(",") - .map((host) => host.trim()) - .filter(Boolean), - }, - - proxy: { - upstreamUrl: validatedUpstreamUrl, - timeoutMs: env.PROXY_TIMEOUT_MS, - allowedHosts: upstreamHostAllowlist, - }, - - restRateLimit: { - windowMs: env.REST_RATE_LIMIT_WINDOW_MS, - maxRequests: env.REST_RATE_LIMIT_MAX_REQUESTS, - }, - - gatewayRateLimit: { - windowMs: env.GATEWAY_RATE_LIMIT_WINDOW_MS, - maxRequests: env.GATEWAY_RATE_LIMIT_MAX_REQUESTS, - }, - - webhookRateLimit: { - windowMs: env.WEBHOOK_RATE_LIMIT_WINDOW_MS ?? env.REST_RATE_LIMIT_WINDOW_MS, - maxRequests: - env.WEBHOOK_RATE_LIMIT_MAX_REQUESTS ?? env.REST_RATE_LIMIT_MAX_REQUESTS, - }, - - webhooks: { - secretRotationGraceMs: env.WEBHOOK_SECRET_ROTATION_GRACE_MS, - }, - - authTimeoutMs: env.AUTH_TIMEOUT_MS, - - loginRateLimit: { - windowMs: env.LOGIN_RATE_LIMIT_WINDOW_MS, - maxRequests: env.LOGIN_RATE_LIMIT_MAX_REQUESTS, - }, - - creditsRateLimit: { - capacity: env.CREDITS_RATE_LIMIT_CAPACITY, - refillRate: env.CREDITS_RATE_LIMIT_REFILL_RATE, - }, - - billingRateLimit: { - windowMs: env.BILLING_RATE_LIMIT_WINDOW_MS, - maxRequests: env.BILLING_RATE_LIMIT_MAX_REQUESTS, - }, - - quotaRateLimit: { - // Token-bucket parameters for the /api/quotas endpoint group. - // Burst: up to `capacity` requests are allowed immediately. - // Steady-state: `refillRate` tokens per second are added back to each bucket. - capacity: env.QUOTA_RATE_LIMIT_CAPACITY, - refillRate: env.QUOTA_RATE_LIMIT_REFILL_RATE, - }, - - rateLimiter: { - maxRequests: env.RATE_LIMIT_MAX_REQUESTS, - windowMs: env.RATE_LIMIT_WINDOW_MS, - store: env.RATE_LIMIT_STORE, - postgresTable: env.RATE_LIMIT_PG_TABLE, - outageMode: env.RATE_LIMIT_OUTAGE_MODE, - fallbackMaxRequests: env.RATE_LIMIT_FALLBACK_MAX_REQUESTS, - fallbackWindowMs: env.RATE_LIMIT_FALLBACK_WINDOW_MS, - maxFallbackBuckets: env.RATE_LIMIT_FALLBACK_MAX_BUCKETS, - }, - - sorobanRpc: - env.SOROBAN_RPC_ENABLED && env.SOROBAN_RPC_URL - ? { - url: env.SOROBAN_RPC_URL, - timeout: env.SOROBAN_RPC_TIMEOUT, - } - : undefined, - - horizon: - env.HORIZON_ENABLED && env.HORIZON_URL - ? { - url: env.HORIZON_URL, - timeout: env.HORIZON_TIMEOUT, - } - : undefined, - - settlementSync: { - intervalMs: env.SETTLEMENT_STATUS_SYNC_INTERVAL_MS, - timeoutMs: env.SETTLEMENT_STATUS_SYNC_TIMEOUT_MS, - maxRetries: env.SETTLEMENT_MAX_RETRIES, - }, - settlementRecon: { - intervalMs: env.SETTLEMENT_RECON_INTERVAL_MS, - }, - revenueLedgerIndexer: { - intervalMs: env.REVENUE_LEDGER_INDEXER_INTERVAL_MS, - batchSize: env.REVENUE_LEDGER_INDEXER_BATCH_SIZE, - }, - - stellar: { - network: selectedNetwork, - baseFee: String(env.STELLAR_BASE_FEE), - transactionTimeout: - env.STELLAR_TRANSACTION_TIMEOUT ?? env.TRANSACTION_TIMEOUT ?? 300, - networkPassphrase: activeConfig.networkPassphrase, - horizonUrl: activeConfig.horizonUrl, - sorobanRpcUrl: activeConfig.sorobanRpcUrl, - vaultContractId: activeConfig.vaultContractId, - settlementContractId: activeConfig.settlementContractId, - networks: { - testnet: testnetConfig, - mainnet: mainnetConfig, - }, - }, - billing: { - networkPassphrase: billingNetworkPassphrase, - rpcUrl: billingRpcUrl, - contractId: billingContractId, - }, - vaultSnapshotMaxAgeMs: env.VAULT_SNAPSHOT_MAX_AGE_MS, - - bcrypt: { - // Number of bcrypt hashing rounds (BCRYPT_COST_FACTOR, defaults to 12). - rounds: env.BCRYPT_COST_FACTOR, - }, - billingTimeoutMs: Number(process.env.BILLING_TIMEOUT_MS ?? 5000), - - billingConcurrency: { - maxPerDeveloper: env.BILLING_MAX_CONCURRENCY_PER_DEV, - semaphoreTtlMs: env.BILLING_SEMAPHORE_TTL_MS, - }, - keyConcurrency: { - maxPerKey: env.KEY_MAX_CONCURRENCY_PER_KEY, - semaphoreTtlMs: env.KEY_SEMAPHORE_TTL_MS, - }, - routeBodyLimits: env.ROUTE_BODY_LIMITS, - idempotency: { - leaseSeconds: env.IDEMPOTENCY_LEASE_SECONDS, - retentionWindowSeconds: env.IDEMPOTENCY_RETENTION_WINDOW_SECONDS, - sweeperIntervalMs: env.IDEMPOTENCY_SWEEPER_INTERVAL_MS, - }, - listingsCache: { - warmupTimeoutMs: env.LISTINGS_CACHE_WARMUP_TIMEOUT_MS, - }, - refundsCache: { - warmupTimeoutMs: env.REFUNDS_CACHE_WARMUP_TIMEOUT_MS, - }, - bulkEndpointLimit: env.BULK_ENDPOINT_LIMIT, - - slowQueryAlerter: { - webhookUrl: env.SLOW_QUERY_ALERT_WEBHOOK_URL, - p95ThresholdMs: env.SLOW_QUERY_P95_THRESHOLD_MS, - pollIntervalMs: env.SLOW_QUERY_POLL_INTERVAL_MS, - dedupWindowMs: env.SLOW_QUERY_DEDUP_WINDOW_SECONDS * 1000, - }, - - memoryAccounting: { - enabled: env.MEMORY_ACCOUNTING_ENABLED, - thresholdMb: env.MEMORY_ACCOUNTING_THRESHOLD_MB, - }, - - usageAnomalyDetector: { - enabled: env.USAGE_ANOMALY_DETECTOR_ENABLED, - multiplier: env.USAGE_ANOMALY_MULTIPLIER, - pollIntervalMs: env.USAGE_ANOMALY_POLL_INTERVAL_MS, - windowMs: env.USAGE_ANOMALY_WINDOW_MS, - baselineWindows: env.USAGE_ANOMALY_BASELINE_WINDOWS, - dedupWindowMs: - env.USAGE_ANOMALY_DEDUP_WINDOW_MS ?? env.USAGE_ANOMALY_WINDOW_MS, - }, - - monthlyInvoiceJob: { - intervalMs: env.MONTHLY_INVOICE_JOB_INTERVAL_MS, - }, - - idempotencySweeper: { - enabled: env.IDEMPOTENCY_SWEEPER_ENABLED, - intervalMs: env.IDEMPOTENCY_SWEEPER_INTERVAL_MS, - batchSize: env.IDEMPOTENCY_SWEEPER_BATCH_SIZE, - timeBudgetMs: env.IDEMPOTENCY_SWEEPER_TIME_BUDGET_MS, - }, - - sloAlert: { - enabled: - Boolean(env.SLO_ALERT_WEBHOOK_URL) && env.SLO_ROUTE_CONFIGS.length > 0, - webhookUrl: env.SLO_ALERT_WEBHOOK_URL, - pollIntervalMs: env.SLO_ALERT_POLL_INTERVAL_MS, - dedupWindowMs: env.SLO_ALERT_DEDUP_WINDOW_MS, - observationWindowMs: env.SLO_ALERT_OBSERVATION_WINDOW_MS, - configs: env.SLO_ROUTE_CONFIGS as Array<{ - method: string; - route: string; - maxErrorRate?: number; - maxLatencyP95Ms?: number; - }>, - }, + ...coreSettings, + ...serviceSettings, } as const; diff --git a/src/config/integrations-env.ts b/src/config/integrations-env.ts new file mode 100644 index 00000000..d211a700 --- /dev/null +++ b/src/config/integrations-env.ts @@ -0,0 +1,172 @@ +import { z } from "zod"; +import { contractId, publicKey, secretKey } from "./stellar-env-keys.js"; +const stellarNetworkSchema = z.enum(["testnet", "mainnet"]); + +export const integrationEnvironmentFields = { + SUBSCRIPTION_CORS_ALLOWED_ORIGINS: z.string().default(""), + + // Maintenance CORS allowlist (comma-separated origins; deny by default when empty). + // + // This entry is intentionally left as a raw string — it exists in the + // schema for documentation and `.env.example` cross-referencing purposes + // only. The runtime parser lives in + // {@link createMaintenanceCorsMiddleware} (src/middleware/cors.ts), which + // reads `process.env` lazily so tests that mutate the env after module + // load still work. If this entry is transformed into an array here, the + // middleware will continue to read the raw string and the two sources of + // truth will silently diverge. + MAINTENANCE_CORS_ALLOWED_ORIGINS: z.string().default(""), + + // Apis CORS allowlist (comma-separated origins; deny by default when empty). + // + // This entry is intentionally left as a raw string — it exists in the + // schema for documentation and `.env.example` cross-referencing purposes + // only. The runtime parser lives in + // {@link createApisCorsMiddleware} (src/middleware/cors.ts), which + // reads `process.env` lazily so tests that mutate the env after module + // load still work. If this entry is transformed into an array here, the + // middleware will continue to read the raw string and the two sources of + // truth will silently diverge. + APIS_CORS_ALLOWED_ORIGINS: z.string().default(""), + + // Soroban RPC (optional) + SOROBAN_RPC_ENABLED: z + .string() + .transform((v) => v === "true") + .default(false), + SOROBAN_RPC_URL: z.string().url().optional(), + SOROBAN_RPC_TIMEOUT: z.coerce.number().default(2_000), + + // Billing RPC (the contract must be configured in production) + SOROBAN_BILLING_RPC_URL: z.string().url().optional(), + SOROBAN_BILLING_CONTRACT_ID: contractId.optional(), + SOROBAN_BILLING_SOURCE_ACCOUNT: publicKey.optional(), + SOROBAN_BILLING_NETWORK_PASSPHRASE: z.string().min(1).optional(), + SOROBAN_BILLING_BACKEND_SECRET_KEY: secretKey.optional(), + SOROBAN_BILLING_RPC_TIMEOUT_MS: z.coerce.number().int().positive().default(5_000), + SOROBAN_BILLING_NETWORK: stellarNetworkSchema.default("testnet"), + + // Stellar accounts / contracts + STELLAR_FEE_ACCOUNT_SECRET_KEY: z.string().min(1).optional(), + STELLAR_FEE_ACCOUNT_PUBLIC_KEY: z.string().min(1).optional(), + SOROBAN_BILLING_BALANCE_FN: z.string().min(1).default("balance"), + SOROBAN_BILLING_DEDUCT_FN: z.string().min(1).default("deduct"), + + // Horizon (optional) + HORIZON_ENABLED: z + .string() + .transform((v) => v === "true") + .default(false), + HORIZON_URL: z.string().url().optional(), + HORIZON_TIMEOUT: z.coerce.number().default(2_000), + SETTLEMENT_STATUS_SYNC_INTERVAL_MS: z.coerce + .number() + .int() + .positive() + .default(60_000), + SETTLEMENT_STATUS_SYNC_TIMEOUT_MS: z.coerce + .number() + .int() + .positive() + .default(5_000), + SETTLEMENT_MAX_RETRIES: z.coerce.number().int().positive().default(5), + SETTLEMENT_RECON_INTERVAL_MS: z.coerce + .number() + .int() + .positive() + .default(86_400_000), + REVENUE_LEDGER_INDEXER_INTERVAL_MS: z.coerce + .number() + .int() + .positive() + .default(30_000), + REVENUE_LEDGER_INDEXER_BATCH_SIZE: z.coerce + .number() + .int() + .positive() + .default(500), + + // Stellar network configuration + STELLAR_NETWORK: stellarNetworkSchema.optional(), + SOROBAN_NETWORK: stellarNetworkSchema.optional(), + + STELLAR_TESTNET_HORIZON_URL: z + .string() + .url() + .default("https://horizon-testnet.stellar.org"), + STELLAR_MAINNET_HORIZON_URL: z + .string() + .url() + .default("https://horizon.stellar.org"), + SOROBAN_TESTNET_RPC_URL: z + .string() + .url() + .default("https://soroban-testnet.stellar.org"), + SOROBAN_MAINNET_RPC_URL: z + .string() + .url() + .default("https://soroban-mainnet.stellar.org"), + + STELLAR_TESTNET_VAULT_CONTRACT_ID: contractId.optional(), + STELLAR_MAINNET_VAULT_CONTRACT_ID: contractId.optional(), + STELLAR_TESTNET_SETTLEMENT_CONTRACT_ID: contractId.optional(), + STELLAR_MAINNET_SETTLEMENT_CONTRACT_ID: contractId.optional(), + VAULT_SNAPSHOT_MAX_AGE_MS: z.coerce.number().int().positive().default(60_000), + + STELLAR_BASE_FEE: z.coerce.number().int().positive().default(100), + STELLAR_TRANSACTION_TIMEOUT: z.coerce.number().int().positive().optional(), + TRANSACTION_TIMEOUT: z.coerce.number().int().positive().optional(), + + // Health check + HEALTH_CHECK_DB_TIMEOUT: z.coerce.number().default(2_000), + /** + * HEALTH_REQUEST_TIMEOUT_MS — maximum wall-clock time (ms) allowed for a + * full GET /api/health response. When the deadline is exceeded the request + * is cooperatively aborted and the caller receives HTTP 504. + * Default: 5 000 ms. + */ + HEALTH_REQUEST_TIMEOUT_MS: z.coerce.number().int().positive().default(5_000), + APIS_CACHE_TTL_MS: z.coerce.number().int().positive().optional(), + LISTINGS_CACHE_WARMUP_TIMEOUT_MS: z.coerce + .number() + .int() + .positive() + .default(5_000), + REFUNDS_CACHE_WARMUP_TIMEOUT_MS: z.coerce + .number() + .int() + .positive() + .default(5_000), + BULK_ENDPOINT_LIMIT: z.coerce.number().int().positive().default(100), + APP_VERSION: z.string().default("1.0.0"), + + // Logging + LOG_LEVEL: z + .enum(["trace", "debug", "info", "warn", "error", "fatal"]) + .default("info"), + ACCESS_LOG_SAMPLE_RATE: z.coerce.number().min(0).max(1).default(1), + ACCESS_LOG_REDACT_FIELDS: z.string().optional(), + USAGE_ACCESS_LOG_REDACT_FIELDS: z.string().optional(), + + // Profiling + GATEWAY_PROFILING_ENABLED: z + .string() + .transform((v) => v === "true") + .default(false), + + // Memory accounting + MEMORY_ACCOUNTING_ENABLED: z + .string() + .transform((v) => v === "true") + .default(false), + MEMORY_ACCOUNTING_THRESHOLD_MB: z.coerce.number().nonnegative().default(50), + // Test-only chaos harness + SOROBAN_CHAOS: z + .string() + .transform((v) => v === "1") + .default(false), + + // Body size limits + REQUEST_BODY_LIMIT: z.string().default("100kb"), + GATEWAY_BODY_LIMIT: z.string().default("1mb"), +} as const; diff --git a/src/config/server-env.ts b/src/config/server-env.ts new file mode 100644 index 00000000..a5b3e7e2 --- /dev/null +++ b/src/config/server-env.ts @@ -0,0 +1,188 @@ +import { z } from "zod"; + +export const serverEnvironmentFields = { + // Server + PORT: z.coerce.number().default(3000), + DEMO_MODE: z.enum(["true", "false"]).default("false").transform((value) => value === "true"), + NODE_ENV: z + .enum(["development", "production", "test"]) + .default("development"), + + // Database (primary connection string) + DATABASE_URL: z + .string() + .default( + "postgresql://postgres:postgres@localhost:5432/callora?schema=public", + ), + + // Database pool + DB_POOL_MAX: z.coerce.number().default(10), + DB_IDLE_TIMEOUT_MS: z.coerce.number().default(30_000), + DB_CONN_TIMEOUT_MS: z.coerce.number().default(2_000), + + /** + * REPLICA_URLS — optional comma-separated list of PostgreSQL read-replica + * connection strings. + * + * Format: + * REPLICA_URLS=postgresql://user:pass@replica1:5432/db,postgresql://user:pass@replica2:5432/db + * + * Behaviour: + * - When set, read-only repository queries are routed round-robin to + * the listed replicas. Write queries always use DATABASE_URL (primary). + * - On replica failure the query is automatically retried against the + * primary; see src/db/replicaPool.ts for details. + * - When absent or empty, all queries continue to use the primary pool. + * + * Each URL must use the postgresql:// or postgres:// scheme. Individual + * URL validation (scheme, format) is performed at application startup by + * the replica pool initialisation code in src/db/replicaPool.ts. + */ + REPLICA_URLS: z + .string() + .optional() + .refine( + (val) => { + if (!val || val.trim() === "") return true; + // Validate that each entry is a parseable postgresql:// URL + return val.split(",").every((raw) => { + const url = raw.trim(); + if (!url) return false; + try { + const parsed = new URL(url); + return ( + parsed.protocol === "postgresql:" || + parsed.protocol === "postgres:" + ); + } catch { + return false; + } + }); + }, + { + message: + "REPLICA_URLS must be a comma-separated list of valid postgresql:// or postgres:// connection strings.", + }, + ), + + // Database (individual fields for health checks) + DB_HOST: z.string().default("localhost"), + DB_PORT: z.coerce.number().default(5432), + DB_USER: z.string().default("postgres"), + DB_PASSWORD: z.string().default("postgres"), + DB_NAME: z.string().default("callora"), + + // Auth + JWT_SECRET: z.string().min(1, "JWT_SECRET is required"), + ADMIN_API_KEY: z.string().min(1, "ADMIN_API_KEY is required"), + METRICS_API_KEY: z.string().min(1, "METRICS_API_KEY is required"), + + // Export object storage. Disabled unless explicitly enabled. + EXPORTS_ENABLED: z.string().optional().transform((value) => value === "true").default(false), + EXPORTS_S3_BUCKET: z.string().trim().min(1).optional(), + EXPORTS_S3_ENDPOINT: z.string().url().optional(), + EXPORTS_S3_REGION: z.string().trim().min(1).optional(), + EXPORTS_SIGNING_SECRET: z.string().min(32).optional(), + EXPORTS_S3_ENDPOINT_ALLOWLIST: z.string().optional(), + TRUST_FORWARDED_USER_ID: z + .string() + .optional() + .transform((v) => v === "true") + .default(false), + FORWARDED_IDENTITY_TOLERANCE_SECONDS: z.coerce.number().int().min(1).max(3600).default(60), + FORWARDED_IDENTITY_ALLOW_LEGACY: z.string().optional().transform(v => v === "true").default(false), + FORWARDED_IDENTITY_REPLAY_STORE: z.enum(["memory", "postgres"]).default("memory"), + FORWARDED_IDENTITY_REPLAY_MAX_ENTRIES: z.coerce.number().int().positive().max(1_000_000).default(10_000), + FORWARDED_USER_ID_SECRET: z.string().optional(), + INTERNAL_GATEWAY_SECRET: z.string().optional(), + + // Proxy / Gateway + UPSTREAM_URL: z.string().url().default("http://localhost:4000"), + UPSTREAM_HOST_ALLOWLIST: z.string().optional(), + PROXY_TIMEOUT_MS: z.coerce.number().default(30_000), + PROXY_BREAKER_FAILURE_THRESHOLD: z.coerce.number().int().positive().default(5), + PROXY_BREAKER_COOLDOWN_MS: z.coerce.number().int().positive().default(30_000), + PROXY_BREAKER_SUCCESS_THRESHOLD: z.coerce.number().int().positive().default(1), + // REST API rate limiting (per-user with IP fallback) + REST_RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(60_000), + REST_RATE_LIMIT_MAX_REQUESTS: z.coerce.number().int().positive().default(100), + // Gateway/proxy rate limiting (per authenticated user only) + GATEWAY_RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(60_000), + GATEWAY_RATE_LIMIT_MAX_REQUESTS: z.coerce.number().int().positive().default(100), + WEBHOOK_RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().optional(), + WEBHOOK_RATE_LIMIT_MAX_REQUESTS: z.coerce + .number() + .int() + .positive() + .optional(), + WEBHOOK_SECRET_ROTATION_GRACE_MS: z.coerce + .number() + .int() + .positive() + .default(24 * 60 * 60 * 1000), + // Per-API-key token-bucket rate limit applied to /api/gateway and /v1/call. + RATE_LIMIT_MAX_REQUESTS: z.coerce.number().int().positive().default(5), + RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(60_000), + RATE_LIMIT_STORE: z.enum(["memory", "postgres"]).default("memory"), + RATE_LIMIT_OUTAGE_MODE: z.enum(["fail-closed", "fallback"]).default("fail-closed"), + RATE_LIMIT_FALLBACK_MAX_REQUESTS: z.coerce.number().int().positive().default(10), + RATE_LIMIT_FALLBACK_WINDOW_MS: z.coerce.number().int().positive().default(60_000), + RATE_LIMIT_FALLBACK_MAX_BUCKETS: z.coerce.number().int().positive().default(10_000), + RATE_LIMIT_PG_TABLE: z + .string() + .regex( + /^[a-z_][a-z0-9_]*$/i, + "RATE_LIMIT_PG_TABLE must contain only letters, numbers, and underscores", + ) + .default("gateway_rate_limit_buckets"), + + // Auth per-request timeout (graceful timeout with 504 Gateway Timeout) + AUTH_TIMEOUT_MS: z.coerce.number().int().positive().default(10_000), + + // Login rate limiting (IP-based throttling for auth attempts) + LOGIN_RATE_LIMIT_MAX_REQUESTS: z.coerce + .number() + .int() + .positive() + .default(5), + LOGIN_RATE_LIMIT_WINDOW_MS: z.coerce + .number() + .int() + .positive() + .default(60_000), // 1 minute sliding window + + // Credits endpoint token-bucket rate limiting + CREDITS_RATE_LIMIT_CAPACITY: z.coerce.number().int().positive().default(10), + CREDITS_RATE_LIMIT_REFILL_RATE: z.coerce.number().positive().default(1), + + // /api/quotas per-user token-bucket rate limiting. + // capacity: maximum burst of requests before the bucket empties (default 60). + // refillRate: tokens added per second (default 1 → steady-state of 1 req/s). + QUOTA_RATE_LIMIT_CAPACITY: z.coerce.number().int().positive().default(60), + QUOTA_RATE_LIMIT_REFILL_RATE: z.coerce.number().positive().default(1), + + // Billing endpoint per-user rate limiting (fixed-window) + BILLING_RATE_LIMIT_WINDOW_MS: z.coerce + .number() + .int() + .positive() + .default(60_000), + BILLING_RATE_LIMIT_MAX_REQUESTS: z.coerce + .number() + .int() + .positive() + .default(100), + + // CORS + CORS_ALLOWED_ORIGINS: z.string().default("http://localhost:5173"), + + // Subscription CORS allowlist (comma-separated origins; deny by default when empty). + // + // This entry mirrors the pattern used by MAINTENANCE_CORS_ALLOWED_ORIGINS: + // the runtime parser lives in + // {@link createSubscriptionCorsMiddleware} (src/middleware/cors.ts), which + // reads `process.env` lazily so tests that mutate the env after module + // load still work. If this entry is transformed into an array here, the + // middleware will continue to read the raw string and the two sources of + // truth will silently diverge. +} as const; diff --git a/src/config/service-settings.ts b/src/config/service-settings.ts new file mode 100644 index 00000000..0f02fc61 --- /dev/null +++ b/src/config/service-settings.ts @@ -0,0 +1,102 @@ +import { env } from "./env.js"; +import { activeConfig, mainnetConfig, selectedNetwork, testnetConfig, billingNetworkPassphrase, billingRpcUrl, billingContractId } from "./stellar.js"; + +export const serviceSettings = { + stellar: { + network: selectedNetwork, + baseFee: String(env.STELLAR_BASE_FEE), + transactionTimeout: + env.STELLAR_TRANSACTION_TIMEOUT ?? env.TRANSACTION_TIMEOUT ?? 300, + networkPassphrase: activeConfig.networkPassphrase, + horizonUrl: activeConfig.horizonUrl, + sorobanRpcUrl: activeConfig.sorobanRpcUrl, + vaultContractId: activeConfig.vaultContractId, + settlementContractId: activeConfig.settlementContractId, + networks: { + testnet: testnetConfig, + mainnet: mainnetConfig, + }, + }, + billing: { + networkPassphrase: billingNetworkPassphrase, + rpcUrl: billingRpcUrl, + contractId: billingContractId, + }, + vaultSnapshotMaxAgeMs: env.VAULT_SNAPSHOT_MAX_AGE_MS, + + bcrypt: { + // Number of bcrypt hashing rounds (BCRYPT_COST_FACTOR, defaults to 12). + rounds: env.BCRYPT_COST_FACTOR, + }, + billingTimeoutMs: Number(process.env.BILLING_TIMEOUT_MS ?? 5000), + + billingConcurrency: { + maxPerDeveloper: env.BILLING_MAX_CONCURRENCY_PER_DEV, + semaphoreTtlMs: env.BILLING_SEMAPHORE_TTL_MS, + }, + keyConcurrency: { + maxPerKey: env.KEY_MAX_CONCURRENCY_PER_KEY, + semaphoreTtlMs: env.KEY_SEMAPHORE_TTL_MS, + }, + routeBodyLimits: env.ROUTE_BODY_LIMITS, + idempotency: { + leaseSeconds: env.IDEMPOTENCY_LEASE_SECONDS, + retentionWindowSeconds: env.IDEMPOTENCY_RETENTION_WINDOW_SECONDS, + sweeperIntervalMs: env.IDEMPOTENCY_SWEEPER_INTERVAL_MS, + }, + listingsCache: { + warmupTimeoutMs: env.LISTINGS_CACHE_WARMUP_TIMEOUT_MS, + }, + refundsCache: { + warmupTimeoutMs: env.REFUNDS_CACHE_WARMUP_TIMEOUT_MS, + }, + bulkEndpointLimit: env.BULK_ENDPOINT_LIMIT, + + slowQueryAlerter: { + webhookUrl: env.SLOW_QUERY_ALERT_WEBHOOK_URL, + p95ThresholdMs: env.SLOW_QUERY_P95_THRESHOLD_MS, + pollIntervalMs: env.SLOW_QUERY_POLL_INTERVAL_MS, + dedupWindowMs: env.SLOW_QUERY_DEDUP_WINDOW_SECONDS * 1000, + }, + + memoryAccounting: { + enabled: env.MEMORY_ACCOUNTING_ENABLED, + thresholdMb: env.MEMORY_ACCOUNTING_THRESHOLD_MB, + }, + + usageAnomalyDetector: { + enabled: env.USAGE_ANOMALY_DETECTOR_ENABLED, + multiplier: env.USAGE_ANOMALY_MULTIPLIER, + pollIntervalMs: env.USAGE_ANOMALY_POLL_INTERVAL_MS, + windowMs: env.USAGE_ANOMALY_WINDOW_MS, + baselineWindows: env.USAGE_ANOMALY_BASELINE_WINDOWS, + dedupWindowMs: + env.USAGE_ANOMALY_DEDUP_WINDOW_MS ?? env.USAGE_ANOMALY_WINDOW_MS, + }, + + monthlyInvoiceJob: { + intervalMs: env.MONTHLY_INVOICE_JOB_INTERVAL_MS, + }, + + idempotencySweeper: { + enabled: env.IDEMPOTENCY_SWEEPER_ENABLED, + intervalMs: env.IDEMPOTENCY_SWEEPER_INTERVAL_MS, + batchSize: env.IDEMPOTENCY_SWEEPER_BATCH_SIZE, + timeBudgetMs: env.IDEMPOTENCY_SWEEPER_TIME_BUDGET_MS, + }, + + sloAlert: { + enabled: + Boolean(env.SLO_ALERT_WEBHOOK_URL) && env.SLO_ROUTE_CONFIGS.length > 0, + webhookUrl: env.SLO_ALERT_WEBHOOK_URL, + pollIntervalMs: env.SLO_ALERT_POLL_INTERVAL_MS, + dedupWindowMs: env.SLO_ALERT_DEDUP_WINDOW_MS, + observationWindowMs: env.SLO_ALERT_OBSERVATION_WINDOW_MS, + configs: env.SLO_ROUTE_CONFIGS as Array<{ + method: string; + route: string; + maxErrorRate?: number; + maxLatencyP95Ms?: number; + }>, + }, +} as const; diff --git a/src/config/slo-env.ts b/src/config/slo-env.ts new file mode 100644 index 00000000..f5d164f8 --- /dev/null +++ b/src/config/slo-env.ts @@ -0,0 +1,74 @@ +import { z } from "zod"; + +export const sloEnvironmentFields = { + SLO_ROUTE_CONFIGS: z + .string() + .optional() + .transform((val, ctx) => { + if (!val || val.trim().length === 0) { + return []; + } + let parsed: unknown; + try { + parsed = JSON.parse(val); + } catch (err) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `SLO_ROUTE_CONFIGS must be valid JSON: ${(err as Error).message}`, + }); + return z.NEVER; + } + if (!Array.isArray(parsed)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "SLO_ROUTE_CONFIGS must be a JSON array of route objects", + }); + return z.NEVER; + } + return parsed as Array; + }) + .pipe( + z.array( + z + .object({ + method: z.string().min(1, "method is required"), + route: z + .string() + .min(1, "route is required") + .startsWith("/", 'route must start with "/"'), + maxErrorRate: z.number().min(0).max(1).optional(), + maxLatencyP95Ms: z.number().positive().optional(), + }) + .refine( + (cfg) => + cfg.maxErrorRate !== undefined || + cfg.maxLatencyP95Ms !== undefined, + "each SLO route config must define at least one of maxErrorRate or maxLatencyP95Ms", + ), + ), + ), + SLO_ALERT_WEBHOOK_URL: z.string().url().optional(), + SLO_ALERT_POLL_INTERVAL_MS: z.coerce + .number() + .int() + .positive() + .default(300_000), + SLO_ALERT_DEDUP_WINDOW_MS: z.coerce + .number() + .int() + .positive() + .default(86_400_000), // 24h + SLO_ALERT_OBSERVATION_WINDOW_MS: z.coerce + .number() + .int() + .positive() + .default(345_600_000), // 96h = 4 days, the slow-burn window + + // Explicit opt-in to allow the testnet passphrase when NODE_ENV=production. + // The demo deployment sets this deliberately; every other production + // deployment must leave it unset so a testnet/mainnet mix-up fails fast. + ALLOW_TESTNET_IN_PRODUCTION: z + .string() + .transform((v) => v === "true") + .default(false), +} as const; diff --git a/src/config/stellar-env-keys.ts b/src/config/stellar-env-keys.ts new file mode 100644 index 00000000..7f9a6f9d --- /dev/null +++ b/src/config/stellar-env-keys.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; +import { StrKey } from "@stellar/stellar-sdk"; + +export const contractId = z + .string() + .trim() + .min(1) + .refine((v) => StrKey.isValidContract(v), { + message: "must be a valid Stellar contract id (C...)", + }); + +export const publicKey = z + .string() + .trim() + .min(1) + .refine((v) => StrKey.isValidEd25519PublicKey(v), { + message: "must be a valid Stellar public key (G...)", + }); + +export const secretKey = z + .string() + .trim() + .min(1) + .refine((v) => StrKey.isValidEd25519SecretSeed(v), { + message: "must be a valid Stellar secret seed (S...)", + }); diff --git a/src/config/stellar.ts b/src/config/stellar.ts new file mode 100644 index 00000000..02e5e3d2 --- /dev/null +++ b/src/config/stellar.ts @@ -0,0 +1,117 @@ +import { env } from "./env.js"; + +export type StellarNetwork = "testnet" | "mainnet"; + +interface StellarNetworkConfig { + horizonUrl: string; + sorobanRpcUrl: string; + networkPassphrase: string; + vaultContractId?: string; + settlementContractId?: string; +} + +const TESTNET_NETWORK_PASSPHRASE = "Test SDF Network ; September 2015"; +const MAINNET_NETWORK_PASSPHRASE = + "Public Global Stellar Network ; September 2015"; + +function isLocalStellarHost(hostname: string): boolean { + return ( + hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" + ); +} + +function validateStellarEndpointUrl(name: string, rawUrl: string): string { + let parsed: URL; + + try { + parsed = new URL(rawUrl); + } catch { + throw new Error(`${name} must be a valid absolute URL.`); + } + + if ( + parsed.protocol !== "https:" && + !(parsed.protocol === "http:" && isLocalStellarHost(parsed.hostname)) + ) { + throw new Error( + `${name} must use HTTPS unless it targets localhost for local development.`, + ); + } + + if (!parsed.hostname) { + throw new Error(`${name} must include a hostname.`); + } + + if (parsed.username || parsed.password) { + throw new Error(`${name} must not include embedded credentials.`); + } + + if (parsed.search || parsed.hash) { + throw new Error(`${name} must not include query strings or fragments.`); + } + + return parsed.toString(); +} + +const selectedNetwork: StellarNetwork = + env.STELLAR_NETWORK ?? env.SOROBAN_NETWORK ?? "testnet"; + +const testnetConfig: StellarNetworkConfig = { + horizonUrl: validateStellarEndpointUrl( + "STELLAR_TESTNET_HORIZON_URL", + env.STELLAR_TESTNET_HORIZON_URL, + ), + sorobanRpcUrl: validateStellarEndpointUrl( + "SOROBAN_TESTNET_RPC_URL", + env.SOROBAN_TESTNET_RPC_URL, + ), + networkPassphrase: TESTNET_NETWORK_PASSPHRASE, + vaultContractId: env.STELLAR_TESTNET_VAULT_CONTRACT_ID, + settlementContractId: env.STELLAR_TESTNET_SETTLEMENT_CONTRACT_ID, +}; + +const mainnetConfig: StellarNetworkConfig = { + horizonUrl: validateStellarEndpointUrl( + "STELLAR_MAINNET_HORIZON_URL", + env.STELLAR_MAINNET_HORIZON_URL, + ), + sorobanRpcUrl: validateStellarEndpointUrl( + "SOROBAN_MAINNET_RPC_URL", + env.SOROBAN_MAINNET_RPC_URL, + ), + networkPassphrase: MAINNET_NETWORK_PASSPHRASE, + vaultContractId: env.STELLAR_MAINNET_VAULT_CONTRACT_ID, + settlementContractId: env.STELLAR_MAINNET_SETTLEMENT_CONTRACT_ID, +}; + +const activeConfig = + selectedNetwork === "mainnet" ? mainnetConfig : testnetConfig; + +// Derive billing network settings from the active network unless explicitly overridden. +// The override must match the active network passphrase (enforced in env superRefine). +const billingNetworkPassphrase = + env.SOROBAN_BILLING_NETWORK_PASSPHRASE ?? activeConfig.networkPassphrase; + +const billingRpcUrl = + env.SOROBAN_BILLING_RPC_URL ?? + env.SOROBAN_RPC_URL ?? + activeConfig.sorobanRpcUrl; + +const billingContractId = + env.SOROBAN_BILLING_CONTRACT_ID ?? activeConfig.vaultContractId; + +// Log once at startup when the billing contract id is an explicit override +// of the active network's vault contract id. +const billingContractIsOverride = + Boolean(env.SOROBAN_BILLING_CONTRACT_ID) && + Boolean(activeConfig.vaultContractId) === true && + env.SOROBAN_BILLING_CONTRACT_ID !== activeConfig.vaultContractId; + +if (billingContractIsOverride) { + console.warn( + "SOROBAN_BILLING_CONTRACT_ID differs from the active network's vault contract id; treating it as an explicit override.", + ); +} + + +export { activeConfig, mainnetConfig, selectedNetwork, testnetConfig, billingNetworkPassphrase, billingRpcUrl, billingContractId }; diff --git a/src/controllers/vaultController.onchain.test.ts b/src/controllers/vaultController.onchain.test.ts index 79299da7..17b57297 100644 --- a/src/controllers/vaultController.onchain.test.ts +++ b/src/controllers/vaultController.onchain.test.ts @@ -1,62 +1,8 @@ -import express from 'express'; import request from 'supertest'; -import { VaultController } from './vaultController.js'; -import { - InMemoryVaultRepository, - type VaultRepository, -} from '../repositories/vaultRepository.js'; -import { errorHandler } from '../middleware/errorHandler.js'; -import { requestIdMiddleware } from '../middleware/requestId.js'; -import { validate } from '../middleware/validate.js'; -import { stellarNetworkQuerySchema } from '../validators/networkSchema.js'; -import type { - OnchainVaultState, - VaultStateReader, -} from '../services/sorobanVaultReader.js'; - -const state: OnchainVaultState = { - balance: 1_234_567n, - maxDeduct: 23_456_789n, - paused: true, - usdcToken: 'GUSDC', - settlement: 'GSETTLEMENT', - ledger: 987654, -}; - -function createApp( - repository: VaultRepository, - reader?: VaultStateReader, - snapshotMaxAgeMs = 60_000, - demoTestnetContractId?: string, -) { - const app = express(); - app.use(requestIdMiddleware); - app.use((req, res, next) => { - const userId = req.headers['x-user-id']; - if (typeof userId === 'string' && userId.length > 0) { - res.locals.authenticatedUser = { id: userId, email: `${userId}@example.com` }; - } - next(); - }); - const controller = new VaultController( - repository, - reader, - snapshotMaxAgeMs, - demoTestnetContractId, - ); - app.get( - '/api/vault/balance', - validate({ query: stellarNetworkQuerySchema }), - controller.getBalance.bind(controller), - ); - app.get( - '/api/vault/onchain', - validate({ query: stellarNetworkQuerySchema }), - controller.getOnchain.bind(controller), - ); - app.use(errorHandler); - return app; -} +import { config } from '../config/index.js'; +import { InMemoryVaultRepository } from '../repositories/vaultRepository.js'; +import type { VaultStateReader } from '../services/sorobanVaultReader.js'; +import { createApp, state } from '../../tests/helpers/vaultControllerHarness.js'; describe('vault on-chain controller behavior', () => { it('refreshes and persists a snapshot older than the configured maximum age', async () => { @@ -190,8 +136,7 @@ describe('vault on-chain controller behavior', () => { }); it('returns a configured testnet vault to anonymous demo requests', async () => { - const priorDemoMode = process.env.DEMO_MODE; - process.env.DEMO_MODE = 'true'; + const demoMode = jest.replaceProperty(config, 'demoMode', true); try { const reader: VaultStateReader = { readVault: jest.fn(async () => state), @@ -212,8 +157,7 @@ describe('vault on-chain controller behavior', () => { 'testnet', ); } finally { - if (priorDemoMode === undefined) delete process.env.DEMO_MODE; - else process.env.DEMO_MODE = priorDemoMode; + demoMode.restore(); } }); diff --git a/src/controllers/vaultController.ts b/src/controllers/vaultController.ts index 36acdc3d..6cbc51d8 100644 --- a/src/controllers/vaultController.ts +++ b/src/controllers/vaultController.ts @@ -110,7 +110,7 @@ export class VaultController { const requestId = getRequestId(req); const user = res.locals.authenticatedUser; const demoPublicRead = - process.env.DEMO_MODE === 'true' && + config.demoMode && !req.headers.authorization && !user; let contractId: string | undefined; diff --git a/src/db.demo.test.ts b/src/db.demo.test.ts new file mode 100644 index 00000000..fdada644 --- /dev/null +++ b/src/db.demo.test.ts @@ -0,0 +1,36 @@ +import { Pool } from "pg"; +import { config } from "./config/index.js"; +import { closePgPool, pool, readQuery, writeQuery } from "./db.js"; + +jest.mock("pg", () => ({ + Pool: jest.fn(() => ({ query: jest.fn().mockResolvedValue({ rows: [] }) })), +})); + +describe("lazy PostgreSQL pool", () => { + afterEach(() => jest.restoreAllMocks()); + + test("does not construct or connect a PostgreSQL pool in demo mode", async () => { + jest.replaceProperty(config, "demoMode", true); + expect(Pool).not.toHaveBeenCalled(); + expect(pool.totalCount).toBe(0); + expect(pool.idleCount).toBe(0); + expect(pool.waitingCount).toBe(0); + expect(() => pool.query("SELECT 1")).toThrow("unavailable in demo mode"); + expect(() => pool.connect()).toThrow("unavailable in demo mode"); + expect(() => readQuery("SELECT 1")).toThrow("unavailable in demo mode"); + expect(() => writeQuery("SELECT 1")).toThrow("unavailable in demo mode"); + await closePgPool(); + expect(Pool).not.toHaveBeenCalled(); + }); + + test("constructs one shared pool on first standard-mode use", async () => { + jest.replaceProperty(config, "demoMode", false); + expect(Pool).not.toHaveBeenCalled(); + expect(await pool.query("SELECT 1")).toEqual({ rows: [] }); + expect(await pool.query("SELECT 2")).toEqual({ rows: [] }); + expect(Pool).toHaveBeenCalledTimes(1); + const spy = jest.spyOn(pool, "query").mockResolvedValue({ rows: [] } as never); + await pool.query("SELECT 3"); + expect(spy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/db.ts b/src/db.ts index fd6c877e..fc727a34 100644 --- a/src/db.ts +++ b/src/db.ts @@ -1,7 +1,7 @@ import { Pool } from 'pg'; import { config } from './config/index.js'; -import { logger } from './logger.js'; import { getReplicaPool, type Queryable } from './db/replicaPool.js'; +import { logger } from './logger.js'; function createTimeoutPromise(timeoutMs: number, message: string): { promise: Promise; @@ -30,11 +30,37 @@ function createTimeoutPromise(timeoutMs: number, message: string): { * - idleTimeoutMillis: how long idle clients stay open before being closed (DB_IDLE_TIMEOUT_MS, default 30s) * - connectionTimeoutMillis: how long to wait when acquiring a client from the pool (DB_CONN_TIMEOUT_MS, default 2s) */ -export const pool = new Pool({ - connectionString: config.databaseUrl, - max: config.dbPool.max, - idleTimeoutMillis: config.dbPool.idleTimeoutMillis, - connectionTimeoutMillis: config.dbPool.connectionTimeoutMillis, +let sharedPool: Pool | undefined; + +function getPool(): Pool { + if (config.demoMode) { + throw new Error('PostgreSQL is unavailable in demo mode'); + } + sharedPool ??= new Pool({ + connectionString: config.databaseUrl, + max: config.dbPool.max, + idleTimeoutMillis: config.dbPool.idleTimeoutMillis, + connectionTimeoutMillis: config.dbPool.connectionTimeoutMillis, + }); + return sharedPool; +} + +/** Preserve the shared Pool API without constructing a pool at import time. */ +export const pool = new Proxy({} as Pool, { + get(target, property) { + if (Object.hasOwn(target, property)) { + return Reflect.get(target, property); + } + if (config.demoMode && ['totalCount', 'idleCount', 'waitingCount'].includes(String(property))) { + return 0; + } + const instance = getPool(); + const value = Reflect.get(instance, property, instance); + return typeof value === 'function' ? value.bind(instance) : value; + }, + set(target, property, value) { + return Reflect.set(target, property, value); + }, }); let poolClosed = false; @@ -72,6 +98,7 @@ export function readQuery( text: string, params?: unknown[], ): Promise<{ rows: T[] }> { + getPool(); return getReplicaPool(pool).read(text, params); } @@ -90,6 +117,7 @@ export function writeQuery( text: string, params?: unknown[], ): Promise<{ rows: T[] }> { + getPool(); return getReplicaPool(pool).write(text, params); } @@ -123,7 +151,7 @@ export async function checkDbHealth(): Promise<{ ok: boolean; error?: string }> } export async function closePgPool(): Promise { - if (poolClosed) { + if (poolClosed || !sharedPool) { return; } // Close replica pools before the primary so no in-flight replica queries diff --git a/src/db/index.ts b/src/db/index.ts index 8fa03a3b..bf40e318 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -1,10 +1,10 @@ import Database from 'better-sqlite3'; import { drizzle } from 'drizzle-orm/better-sqlite3'; -import * as schema from './schema.js'; -import { readFileSync } from 'fs'; import { join } from 'path'; +import { config } from '../config/index.js'; import { applyMigrations, validateSchemaState } from '../migrate.js'; import { bootstrapDemoDb } from './demo.js'; +import * as schema from './schema.js'; const logger = console; let sqliteClosed = false; @@ -23,16 +23,16 @@ export const db = drizzle(sqlite, { schema }); export async function initializeDb() { try { const migrationDir = join(process.cwd(), 'migrations'); - + // In production, we just want to validate the schema is up-to-date. // In dev/test environments, we automatically apply pending migrations. const isProd = process.env.NODE_ENV === 'production'; - if (process.env.DEMO_MODE === 'true') { + if (config.demoMode) { bootstrapDemoDb(sqlite, migrationDir); return; } - + if (isProd) { logger.info('Validating schema state...'); validateSchemaState(sqlite, migrationDir); diff --git a/src/errors/codes.ts b/src/errors/codes.ts index 3ba68652..eb59ca4b 100644 --- a/src/errors/codes.ts +++ b/src/errors/codes.ts @@ -12,6 +12,9 @@ */ export const ErrorCode = { + /** The feature requires PostgreSQL or Stellar integrations unavailable in demo mode (HTTP 503) */ + DEMO_MODE_UNSUPPORTED: "DEMO_MODE_UNSUPPORTED", + /** The request is malformed, missing required input, or otherwise invalid */ BAD_REQUEST: "BAD_REQUEST", diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts index 5ebc1ddd..67b893e3 100644 --- a/src/lib/prisma.ts +++ b/src/lib/prisma.ts @@ -1,6 +1,7 @@ import { createRequire } from 'node:module'; import { PrismaPg } from '@prisma/adapter-pg'; import { ServiceUnavailableError } from '../errors/index.js'; +import { config } from '../config/index.js'; type PrismaClientLike = { $disconnect: () => Promise; @@ -17,7 +18,7 @@ const nodeRequire: NodeRequire = type PrismaClientConstructor = new (options?: unknown) => PrismaClientLike; function getPrismaClient(): PrismaClientLike { - if (process.env.DEMO_MODE === 'true') { + if (config.demoMode) { throw new ServiceUnavailableError('Database service is unavailable in demo mode'); } diff --git a/src/middleware/appMiddleware.ts b/src/middleware/appMiddleware.ts new file mode 100644 index 00000000..a8f1c38f --- /dev/null +++ b/src/middleware/appMiddleware.ts @@ -0,0 +1,180 @@ +import cors from 'cors'; +import express from 'express'; +import helmet from 'helmet'; +import { getEmbeddedAssets } from '../lib/embeddedAssets.js'; +import { envelopeValidator } from "../middleware/envelopeValidator.js"; +import { + requestIdMiddleware, + responseEnrichMiddleware, +} from "../middleware/requestId.js"; +import { createDocsRouter } from '../routes/docs.js'; +//import { envelopeValidator } from '../middleware/envelopeValidator.js'; +import OpenApiValidator from "express-openapi-validator"; +import path from "path"; +import { config } from "../config/index.js"; +import { metricsMiddleware } from "../metrics.js"; +import { createAccessLogMiddleware } from "../middleware/accessLog.js"; +import { auditEnrichMiddleware } from "../middleware/auditEnrich.js"; +import { demoGuard } from "../middleware/demoGuard.js"; +import { + createResponseValidatorMiddleware, + envelopeMiddleware, +} from "../middleware/envelope.js"; +import { createMemoryAccountingMiddleware } from "../middleware/memoryAccounting.js"; +import { createRouteBodyLimitMiddleware } from "../middleware/routeBodyLimit.js"; +//import * as OpenApiValidator from 'express-openapi-validator'; + + +export function registerAppMiddleware(app: express.Express): void { + // Public documentation routes (own CSP; mounted ahead of the global Helmet policy) + app.use(createDocsRouter()); + + // Production-safe security headers with environment-based configuration + const isProduction = process.env.NODE_ENV === "production"; + const isDevelopment = process.env.NODE_ENV === "development"; + + // Apply Helmet with production-safe defaults + app.use( + helmet({ + // Content Security Policy - stricter in production + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + styleSrc: ["'self'", "'unsafe-inline'"], // Allow inline styles for development + scriptSrc: ["'self'"], + imgSrc: ["'self'", "data:", "https:"], + connectSrc: ["'self'", ...(isDevelopment ? ["ws:", "wss:"] : [])], + fontSrc: ["'self'"], + objectSrc: ["'none'"], + mediaSrc: ["'self'"], + frameSrc: ["'none'"], + }, + }, + // Cross-Origin Embedder Policy + crossOriginEmbedderPolicy: isProduction + ? { policy: "require-corp" } + : false, + // HSTS - only in production with HTTPS + hsts: isProduction + ? { + maxAge: 31536000, // 1 year + includeSubDomains: true, + preload: true, + } + : false, + // Other security headers + referrerPolicy: { policy: "strict-origin-when-cross-origin" }, + permittedCrossDomainPolicies: false, + // Allow dev tools in development + hidePoweredBy: !isDevelopment, + }), + ); + + app.use(requestIdMiddleware); + app.use(createResponseValidatorMiddleware()); + app.use(envelopeMiddleware); + app.use(responseEnrichMiddleware); + const memoryAccountingMiddleware = createMemoryAccountingMiddleware( + config.memoryAccounting, + ); + app.use(memoryAccountingMiddleware); + app.use(metricsMiddleware); + + app.use( + createAccessLogMiddleware({ + sampleRate: config.accessLog.sampleRate, + redactFields: config.accessLog.redactFields, + }), + ); + + // Parse allowed origins with validation + const allowedOrigins = ( + process.env.CORS_ALLOWED_ORIGINS ?? "http://localhost:5173" + ) + .split(",") + .map((o: string) => o.trim()) + .filter((o: string) => o.length > 0); + + // Validate origins in production + if (isProduction && allowedOrigins.length === 0) { + console.warn("WARNING: No CORS_ALLOWED_ORIGINS configured in production"); + } + + // Regex for localhost with optional port (e.g., http://localhost:5173) + const localhostRegex = /^http:\/\/localhost(:\d+)?$/; + + app.use( + cors({ + origin: ( + origin: string | undefined, + callback: (err: Error | null, allow?: boolean) => void, + ) => { + // Allow requests with no origin (mobile apps, curl, etc.) + if (!origin) { + return callback(null, true); + } + + // Check if origin is in allowlist + if (allowedOrigins.includes(origin)) { + return callback(null, true); + } + + // In development, allow localhost with any port using strict regex + if (isDevelopment && localhostRegex.test(origin)) { + return callback(null, true); + } + + // Log blocked attempts in production + if (isProduction) { + console.warn(`CORS blocked origin: ${origin}`); + } + + // Pass false instead of Error to prevent Express from returning 500 + callback(null, false); + }, + methods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"], + allowedHeaders: [ + "Content-Type", + "Authorization", + "x-admin-api-key", + "x-request-id", // Added for tracing + ], + credentials: true, + exposedHeaders: ["X-Request-Id", "Retry-After", "ETag", "X-Correlation-Id", "RateLimit-Limit", "RateLimit-Remaining", "RateLimit-Reset"], + // Reduce preflight cache time in production for security + maxAge: isProduction ? 600 : 86400, // 10 minutes vs 24 hours + optionsSuccessStatus: 204, // No content for preflight + }), + ); + const requestBodyLimit = process.env.REQUEST_BODY_LIMIT ?? "100kb"; + app.use(demoGuard); + app.use(createRouteBodyLimitMiddleware(config.routeBodyLimits)); + app.use(express.json({ limit: requestBodyLimit })); + app.use(express.urlencoded({ extended: false, limit: requestBodyLimit })); + // Attach req.auditContext (IP, UA, tenantId, correlationId, bodyHash) for all routes. + app.use(auditEnrichMiddleware); + + // OpenAPI contract validation — only validates paths defined in the spec. + // Security is handled by custom middleware, not the validator. + // Skip in test environment to avoid interfering with integration tests. + const embeddedOpenApi = getEmbeddedAssets()?.openapi; + if (process.env.NODE_ENV !== "test") { + app.use( + OpenApiValidator.middleware({ + apiSpec: embeddedOpenApi + ? JSON.parse(embeddedOpenApi) + : path.resolve(process.cwd(), "docs/openapi.json"), + validateRequests: true, + // Response validation is disabled in demo mode, where some responses + // intentionally omit optional contract fields (see README). + validateResponses: !config.demoMode, + validateSecurity: false, + ignoreUndocumented: true, + }), + ); + } + + // Register envelope validator after body parser but before routes + app.use(envelopeValidator); + +} diff --git a/src/middleware/demoGuard.test.ts b/src/middleware/demoGuard.test.ts new file mode 100644 index 00000000..435578b6 --- /dev/null +++ b/src/middleware/demoGuard.test.ts @@ -0,0 +1,98 @@ +import express from "express"; +import request from "supertest"; +import { createApp } from "../app.js"; +import { config } from "../config/index.js"; +import { DEMO_UNSUPPORTED_ROUTES, demoGuard } from "./demoGuard.js"; + +describe("demo capability guard", () => { + beforeEach(() => jest.replaceProperty(config, "demoMode", true)); + afterEach(() => jest.restoreAllMocks()); + + function app() { + const server = express(); + server.use(demoGuard); + server.use((_req, res) => res.json({ passed: true })); + return server; + } + + test.each(DEMO_UNSUPPORTED_ROUTES)("guards $prefix with feature details", async ({ prefix, methods, feature }) => { + const method = methods[0] === "*" ? "get" : methods[0].toLowerCase(); + const response = await request(app())[method as "get" | "post"](`${prefix}/`) + .set("x-request-id", "guard-test"); + expect(response.status).toBe(503); + expect(response.body.error).toEqual({ + code: "DEMO_MODE_UNSUPPORTED", + message: "This feature is unavailable in demo mode.", + details: { feature }, + }); + expect(response.body.requestId).toBe("guard-test"); + expect(response.headers["retry-after"]).toBeUndefined(); + }); + + test.each(["post", "put", "patch", "delete"] as const)("blocks audit %s writes", async (method) => { + expect((await request(app())[method]("/api/audit/123")).status).toBe(503); + }); + + test.each(["/api/health", "/api/apis", "/api/audit", "/api/billing-lookalike", "/api/usage-report"])("allows GET %s", async (path) => { + expect((await request(app()).get(path)).body).toEqual({ passed: true }); + }); + + test("matches Express case-insensitive paths and generated request IDs", async () => { + const response = await request(app()).post("/API/BILLING/deduct"); + expect(response.status).toBe(503); + expect(response.body.requestId).toEqual(expect.any(String)); + expect(response.body.timestamp).toEqual(expect.any(String)); + }); + + test("passes every guarded prefix through in standard mode", async () => { + jest.replaceProperty(config, "demoMode", false); + for (const { prefix } of DEMO_UNSUPPORTED_ROUTES) { + expect((await request(app()).post(prefix)).body).toEqual({ passed: true }); + } + }); + + test("every declared capability corresponds to a mounted handler", () => { + interface Layer { + regexp: RegExp; + route?: { methods: Record }; + handle: { stack?: Layer[] }; + } + jest.replaceProperty(config, "exports", { + ...config.exports, + enabled: true, + bucket: "test-exports", + endpoint: "https://storage.example.com", + region: "test-region", + signingSecret: "dummy-export-signing-secret-for-tests", + }); + const server = createApp() as ReturnType & { _router: { stack: Layer[] } }; + function mounted(stack: Layer[], path: string, method: string): boolean { + return stack.some((layer) => { + const match = layer.regexp.exec(path); + if (!match) return false; + if (layer.route) return !!layer.route.methods[method]; + return !!layer.handle.stack && mounted(layer.handle.stack, path.slice(match[0].length) || "/", method); + }); + } + const examples: Record = { + "/api/billing": ["/api/billing/deduct", "post"], + "/api/invoices": ["/api/invoices", "get"], + "/api/usage": ["/api/usage", "get"], + "/api/exports": ["/api/exports", "get"], + "/api/admin/usage": ["/api/admin/usage/anomalies", "get"], + "/api/admin/db/explain": ["/api/admin/db/explain", "post"], + "/api/vault/deposit/prepare": ["/api/vault/deposit/prepare", "post"], + "/api/vault/onchain": ["/api/vault/onchain", "get"], + "/api/vault/balance": ["/api/vault/balance", "get"], + "/api/audit": ["/api/audit", "post"], + "/api/credits": ["/api/credits", "get"], + "/api/refunds": ["/api/refunds", "get"], + "/api/health/health": ["/api/health/health", "get"], + "/api/health/dependencies": ["/api/health/dependencies", "get"], + }; + for (const { prefix } of DEMO_UNSUPPORTED_ROUTES) { + const [path, method] = examples[prefix]; + expect({ prefix, mounted: mounted(server._router.stack, path, method) }).toEqual({ prefix, mounted: true }); + } + }); +}); diff --git a/src/middleware/demoGuard.ts b/src/middleware/demoGuard.ts new file mode 100644 index 00000000..5d77043d --- /dev/null +++ b/src/middleware/demoGuard.ts @@ -0,0 +1,44 @@ +import type { RequestHandler } from "express"; +import { config } from "../config/index.js"; +import { ErrorCode } from "../errors/codes.js"; +import { errorEnvelope, getRequestId } from "../lib/envelope.js"; + +/** Paths are matched as Express prefixes, including subpaths but not lookalikes. */ +export const DEMO_UNSUPPORTED_ROUTES = [ + { prefix: "/api/billing", methods: ["*"], feature: "billing" }, + { prefix: "/api/invoices", methods: ["*"], feature: "invoices" }, + { prefix: "/api/usage", methods: ["*"], feature: "usage" }, + { prefix: "/api/exports", methods: ["*"], feature: "exports" }, + { prefix: "/api/admin/usage", methods: ["*"], feature: "admin usage" }, + { prefix: "/api/admin/db/explain", methods: ["*"], feature: "database explain" }, + { prefix: "/api/vault/deposit/prepare", methods: ["POST"], feature: "vault deposit" }, + { prefix: "/api/vault/onchain", methods: ["GET", "HEAD"], feature: "on-chain vault" }, + { prefix: "/api/vault/balance", methods: ["GET", "HEAD"], feature: "vault balance" }, + { prefix: "/api/audit", methods: ["POST", "PUT", "PATCH", "DELETE"], feature: "audit persistence" }, + { prefix: "/api/credits", methods: ["*"], feature: "credits" }, + { prefix: "/api/refunds", methods: ["*"], feature: "refunds" }, + { prefix: "/api/health/health", methods: ["GET", "HEAD"], feature: "dependency health" }, + { prefix: "/api/health/dependencies", methods: ["GET", "HEAD"], feature: "dependency health" }, +] as const; + +export const demoGuard: RequestHandler = (req, res, next) => { + if (!config.demoMode) { + next(); + return; + } + const path = req.path.toLowerCase(); + const match = DEMO_UNSUPPORTED_ROUTES.find(({ prefix, methods }) => + (path === prefix || path.startsWith(`${prefix}/`)) && + (methods as readonly string[]).some((method) => method === "*" || method === req.method), + ); + if (!match) { + next(); + return; + } + res.status(503).json(errorEnvelope( + ErrorCode.DEMO_MODE_UNSUPPORTED, + "This feature is unavailable in demo mode.", + getRequestId(req), + { feature: match.feature }, + )); +}; diff --git a/src/middleware/envelope.ts b/src/middleware/envelope.ts index da449ce1..7aff02b1 100644 --- a/src/middleware/envelope.ts +++ b/src/middleware/envelope.ts @@ -1,127 +1,15 @@ -import { Request, Response, NextFunction } from 'express'; -import { z, ZodSchema, ZodError } from 'zod'; -import type { ValidationErrorDetail } from './validate.js'; -import { InternalServerError } from '../errors/index.js'; -import { safeSimulationDetails } from '../errors/errorEnvelopePolicy.js'; +import { NextFunction, Request, Response } from 'express'; import type { RedactedSimulationDetailsSummary } from '../errors/errorEnvelopePolicy.js'; -import { logger } from '../logger.js'; - -const SKIP_CONTENT_TYPES = [ - 'text/csv', - 'application/octet-stream', - 'application/pdf', - 'text/event-stream', - 'text/plain', - 'multipart/form-data', - 'application/zip', - 'application/gzip', - 'image/', - 'audio/', - 'video/', -]; - -export const successEnvelopeSchema = z.object({ - success: z.literal(true), - data: z.unknown(), - meta: z.record(z.string(), z.unknown()).optional(), - requestId: z.string(), - timestamp: z.string().datetime(), -}); - -/** - * Optional, additive member of `error` describing a failed Soroban - * simulation. Only the redacted summary fields are representable here, so a - * client can rely on `error.simulationDetails` never containing addresses, - * balances, XDR or signatures. - */ -export const simulationDetailsSchema = z.object({ - errorCode: z.union([z.string(), z.number()]).optional(), - errorMessage: z.string().optional(), - eventCount: z.number().optional(), - footprintPresent: z.boolean().optional(), -}); - -export const errorEnvelopeSchema = z.object({ - success: z.literal(false), - error: z.object({ - code: z.string(), - message: z.string(), - details: z.array(z.object({ - field: z.string(), - message: z.string(), - code: z.string(), - })).optional(), - retryAfterMs: z.number().optional(), - simulationDetails: simulationDetailsSchema.optional(), - }), - requestId: z.string(), - timestamp: z.string().datetime(), -}); - -export const envelopeSchema = z.union([successEnvelopeSchema, errorEnvelopeSchema]); +import { safeSimulationDetails } from '../errors/errorEnvelopePolicy.js'; +import type { ValidationErrorDetail } from './validate.js'; -export type SuccessEnvelope = z.infer & { data: T }; -export type ErrorEnvelope = z.infer; -export type Envelope = SuccessEnvelope | ErrorEnvelope; -export interface EnvelopeMeta { - [key: string]: unknown; -} +import { buildErrorEnvelope, buildSuccessEnvelope, shouldSkipContentType } from "./envelopeBuilders.js"; +import type { EnvelopeMeta } from "./envelopeSchemas.js"; -function shouldSkipContentType(contentType: unknown): boolean { - if (!contentType || typeof contentType !== 'string') { - return false; - } - const lower = contentType.toLowerCase(); - return SKIP_CONTENT_TYPES.some((t) => lower.includes(t)); -} - -export function buildSuccessEnvelope( - data: T, - requestId: string, - meta?: EnvelopeMeta, -): SuccessEnvelope { - const envelope: SuccessEnvelope = { - success: true, - data, - requestId, - timestamp: new Date().toISOString(), - }; - if (meta && Object.keys(meta).length > 0) { - envelope.meta = meta; - } - return envelope; -} - -export function buildErrorEnvelope( - code: string, - message: string, - requestId: string, - details?: ValidationErrorDetail[], - retryAfterMs?: number, - simulationDetails?: RedactedSimulationDetailsSummary, -): ErrorEnvelope { - const envelope: ErrorEnvelope = { - success: false, - error: { - code, - message, - }, - requestId, - timestamp: new Date().toISOString(), - }; - if (details && details.length > 0) { - envelope.error.details = details; - } - if (retryAfterMs !== undefined) { - envelope.error.retryAfterMs = retryAfterMs; - } - const safeDetails = safeSimulationDetails(simulationDetails); - if (safeDetails) { - envelope.error.simulationDetails = safeDetails; - } - return envelope; -} +export { buildErrorEnvelope, buildSuccessEnvelope } from "./envelopeBuilders.js"; +export * from "./envelopeSchemas.js"; +export { createResponseValidatorMiddleware } from "./responseValidator.js"; export function envelopeMiddleware(req: Request, res: Response, next: NextFunction): void { const originalJson = res.json.bind(res); @@ -242,116 +130,3 @@ export function envelopeMiddleware(req: Request, res: Response, next: NextFuncti next(); } - -export function createResponseValidatorMiddleware(schema?: ZodSchema) { - return function responseValidatorMiddleware( - req: Request, - res: Response, - next: NextFunction, - ): void { - const originalSend = res.send.bind(res); - const originalJson = res.json.bind(res); - const requestId = req.id || 'unknown'; - - function validateAndSend(body: unknown): Response { - if (res.headersSent) { - return originalSend(body as string); - } - - const contentType = res.getHeader('Content-Type'); - if (shouldSkipContentType(contentType)) { - return originalSend(body as string); - } - - let parsedBody = body; - if (typeof body === 'string') { - const trimmed = body.trim(); - if (trimmed.length === 0 || (trimmed[0] !== '{' && trimmed[0] !== '[')) { - return originalSend(body as string); - } - try { - parsedBody = JSON.parse(trimmed); - } catch { - return originalSend(body as string); - } - } - - if (parsedBody === undefined || parsedBody === null) { - return originalSend(body as string); - } - - try { - envelopeSchema.parse(parsedBody); - } catch (err) { - if (err instanceof ZodError) { - logger.error('[responseValidator] Response failed envelope validation', { - requestId, - path: req.path, - issues: err.issues, - }); - - if (!res.headersSent) { - const devError = new InternalServerError( - 'Response contract violation: envelope validation failed', - 'INTERNAL_SERVER_ERROR', - ); - const errorEnvelope = buildErrorEnvelope( - devError.code ?? 'INTERNAL_SERVER_ERROR', - devError.message, - requestId, - ); - res.status(500); - return originalJson(errorEnvelope); - } - } - throw err; - } - - if (schema) { - try { - const env = parsedBody as Envelope; - if (env.success) { - schema.parse(env.data); - } - } catch (err) { - if (err instanceof ZodError) { - logger.error('[responseValidator] Response failed data schema validation', { - requestId, - path: req.path, - issues: err.issues, - }); - if (!res.headersSent) { - const devError = new InternalServerError( - 'Response contract violation: data schema validation failed', - 'INTERNAL_SERVER_ERROR', - ); - const errorEnvelope = buildErrorEnvelope( - devError.code ?? 'INTERNAL_SERVER_ERROR', - devError.message, - requestId, - ); - res.status(500); - return originalJson(errorEnvelope); - } - } - throw err; - } - } - - if (typeof body === 'string') { - return originalSend(body); - } - return originalJson(parsedBody as Record); - } - - res.send = function sendWrapper(body: unknown): Response { - return validateAndSend(body); - }; - - res.json = function jsonWrapper(body: unknown): Response { - return validateAndSend(body); - }; - - next(); - }; -} diff --git a/src/middleware/envelopeBuilders.ts b/src/middleware/envelopeBuilders.ts new file mode 100644 index 00000000..903669f3 --- /dev/null +++ b/src/middleware/envelopeBuilders.ts @@ -0,0 +1,74 @@ +import type { RedactedSimulationDetailsSummary } from '../errors/errorEnvelopePolicy.js'; +import { safeSimulationDetails } from '../errors/errorEnvelopePolicy.js'; +import type { ValidationErrorDetail } from './validate.js'; + + +import { type EnvelopeMeta, type ErrorEnvelope, type SuccessEnvelope } from "./envelopeSchemas.js"; +const SKIP_CONTENT_TYPES = [ + 'text/csv', + 'application/octet-stream', + 'application/pdf', + 'text/event-stream', + 'text/plain', + 'multipart/form-data', + 'application/zip', + 'application/gzip', + 'image/', + 'audio/', + 'video/', +]; + +export function shouldSkipContentType(contentType: unknown): boolean { + if (!contentType || typeof contentType !== 'string') { + return false; + } + const lower = contentType.toLowerCase(); + return SKIP_CONTENT_TYPES.some((t) => lower.includes(t)); +} + +export function buildSuccessEnvelope( + data: T, + requestId: string, + meta?: EnvelopeMeta, +): SuccessEnvelope { + const envelope: SuccessEnvelope = { + success: true, + data, + requestId, + timestamp: new Date().toISOString(), + }; + if (meta && Object.keys(meta).length > 0) { + envelope.meta = meta; + } + return envelope; +} + +export function buildErrorEnvelope( + code: string, + message: string, + requestId: string, + details?: ValidationErrorDetail[], + retryAfterMs?: number, + simulationDetails?: RedactedSimulationDetailsSummary, +): ErrorEnvelope { + const envelope: ErrorEnvelope = { + success: false, + error: { + code, + message, + }, + requestId, + timestamp: new Date().toISOString(), + }; + if (details && details.length > 0) { + envelope.error.details = details; + } + if (retryAfterMs !== undefined) { + envelope.error.retryAfterMs = retryAfterMs; + } + const safeDetails = safeSimulationDetails(simulationDetails); + if (safeDetails) { + envelope.error.simulationDetails = safeDetails; + } + return envelope; +} diff --git a/src/middleware/envelopeSchemas.ts b/src/middleware/envelopeSchemas.ts new file mode 100644 index 00000000..b0d33bb7 --- /dev/null +++ b/src/middleware/envelopeSchemas.ts @@ -0,0 +1,50 @@ +import { z } from 'zod'; + + +export const successEnvelopeSchema = z.object({ + success: z.literal(true), + data: z.unknown(), + meta: z.record(z.string(), z.unknown()).optional(), + requestId: z.string(), + timestamp: z.string().datetime(), +}); + +/** + * Optional, additive member of `error` describing a failed Soroban + * simulation. Only the redacted summary fields are representable here, so a + * client can rely on `error.simulationDetails` never containing addresses, + * balances, XDR or signatures. + */ +export const simulationDetailsSchema = z.object({ + errorCode: z.union([z.string(), z.number()]).optional(), + errorMessage: z.string().optional(), + eventCount: z.number().optional(), + footprintPresent: z.boolean().optional(), +}); + +export const errorEnvelopeSchema = z.object({ + success: z.literal(false), + error: z.object({ + code: z.string(), + message: z.string(), + details: z.union([z.array(z.object({ + field: z.string(), + message: z.string(), + code: z.string(), + })), z.object({ feature: z.string() })]).optional(), + retryAfterMs: z.number().optional(), + simulationDetails: simulationDetailsSchema.optional(), + }), + requestId: z.string(), + timestamp: z.string().datetime(), +}); + +export const envelopeSchema = z.union([successEnvelopeSchema, errorEnvelopeSchema]); + +export type SuccessEnvelope = z.infer & { data: T }; +export type ErrorEnvelope = z.infer; +export type Envelope = SuccessEnvelope | ErrorEnvelope; + +export interface EnvelopeMeta { + [key: string]: unknown; +} diff --git a/src/middleware/responseValidator.ts b/src/middleware/responseValidator.ts new file mode 100644 index 00000000..73e770b5 --- /dev/null +++ b/src/middleware/responseValidator.ts @@ -0,0 +1,120 @@ +import { NextFunction, Request, Response } from 'express'; +import { ZodError, ZodSchema } from 'zod'; +import { InternalServerError } from '../errors/index.js'; +import { logger } from '../logger.js'; + + +import { buildErrorEnvelope, shouldSkipContentType } from "./envelopeBuilders.js"; +import { envelopeSchema, type Envelope } from "./envelopeSchemas.js"; +export function createResponseValidatorMiddleware(schema?: ZodSchema) { + return function responseValidatorMiddleware( + req: Request, + res: Response, + next: NextFunction, + ): void { + const originalSend = res.send.bind(res); + const originalJson = res.json.bind(res); + const requestId = req.id || 'unknown'; + + function validateAndSend(body: unknown): Response { + if (res.headersSent) { + return originalSend(body as string); + } + + const contentType = res.getHeader('Content-Type'); + if (shouldSkipContentType(contentType)) { + return originalSend(body as string); + } + + let parsedBody = body; + if (typeof body === 'string') { + const trimmed = body.trim(); + if (trimmed.length === 0 || (trimmed[0] !== '{' && trimmed[0] !== '[')) { + return originalSend(body as string); + } + try { + parsedBody = JSON.parse(trimmed); + } catch { + return originalSend(body as string); + } + } + + if (parsedBody === undefined || parsedBody === null) { + return originalSend(body as string); + } + + try { + envelopeSchema.parse(parsedBody); + } catch (err) { + if (err instanceof ZodError) { + logger.error('[responseValidator] Response failed envelope validation', { + requestId, + path: req.path, + issues: err.issues, + }); + + if (!res.headersSent) { + const devError = new InternalServerError( + 'Response contract violation: envelope validation failed', + 'INTERNAL_SERVER_ERROR', + ); + const errorEnvelope = buildErrorEnvelope( + devError.code ?? 'INTERNAL_SERVER_ERROR', + devError.message, + requestId, + ); + res.status(500); + return originalJson(errorEnvelope); + } + } + throw err; + } + + if (schema) { + try { + const env = parsedBody as Envelope; + if (env.success) { + schema.parse(env.data); + } + } catch (err) { + if (err instanceof ZodError) { + logger.error('[responseValidator] Response failed data schema validation', { + requestId, + path: req.path, + issues: err.issues, + }); + if (!res.headersSent) { + const devError = new InternalServerError( + 'Response contract violation: data schema validation failed', + 'INTERNAL_SERVER_ERROR', + ); + const errorEnvelope = buildErrorEnvelope( + devError.code ?? 'INTERNAL_SERVER_ERROR', + devError.message, + requestId, + ); + res.status(500); + return originalJson(errorEnvelope); + } + } + throw err; + } + } + + if (typeof body === 'string') { + return originalSend(body); + } + return originalJson(parsedBody as Record); + } + + res.send = function sendWrapper(body: unknown): Response { + return validateAndSend(body); + }; + + res.json = function jsonWrapper(body: unknown): Response { + return validateAndSend(body); + }; + + next(); + }; +} diff --git a/src/routes/adminRoutes.ts b/src/routes/adminRoutes.ts new file mode 100644 index 00000000..c517ec74 --- /dev/null +++ b/src/routes/adminRoutes.ts @@ -0,0 +1,25 @@ +import type { Express } from "express"; +import { pool } from "../db.js"; +import adminRouter from "./admin.js"; +import { createExplainRouter } from "./admin/explain.js"; +import { createUsageAnomaliesRouter } from "./admin/usage/anomalies.js"; +import { createAdminUsageByEndpointRouter } from "./admin/usage/by-endpoint.js"; +import { createSpikeRouter } from "./admin/usage/spike.js"; + +export function registerAdminRoutes(app: Express): void { + // Mounted before the generic admin router so the specific path is not + // shadowed by adminRouter's `/usage/:developerId` route. + app.use("/api/admin/usage/anomalies", createUsageAnomaliesRouter({ pool })); + app.use( + "/api/admin/usage/by-endpoint", + createAdminUsageByEndpointRouter({ pool }), + ); + app.use("/api/admin", adminRouter); + app.use("/api/admin/db/explain", createExplainRouter({ pool })); + app.use('/api/admin/usage/anomalies', createUsageAnomaliesRouter({ pool })); + app.use('/api/admin/usage/by-endpoint', createAdminUsageByEndpointRouter({ pool })); + app.use('/api/admin/usage/spike', createSpikeRouter({ pool })); + app.use('/api/admin', adminRouter); + app.use('/api/admin/db/explain', createExplainRouter({ pool })); + +} diff --git a/src/routes/developerAnalyticsRoutes.ts b/src/routes/developerAnalyticsRoutes.ts new file mode 100644 index 00000000..4121a13c --- /dev/null +++ b/src/routes/developerAnalyticsRoutes.ts @@ -0,0 +1,184 @@ +import express from 'express'; +import { apiStatusEnum, type ApiStatus } from "../db/schema.js"; +import { paginatedResponse, parsePagination } from "../lib/pagination.js"; +import { + requireAuth, + type AuthenticatedLocals, +} from "../middleware/requireAuth.js"; +import { + type ApiRepository +} from "../repositories/apiRepository.js"; +import { + type DeveloperRepository +} from "../repositories/developerRepository.js"; +import { + type GroupBy, + type UsageEventsRepository +} from "../repositories/usageEventsRepository.js"; +import { buildDeveloperAnalytics } from "../services/developerAnalytics.js"; +//import { envelopeValidator } from '../middleware/envelopeValidator.js'; +import { + BadRequestError, + ForbiddenError, + NotFoundError, + UnauthorizedError, +} from "../errors/index.js"; +import { + getRequestId, + successEnvelope +} from "../lib/envelope.js"; +import { asyncHandler } from "../utils/asyncHandler.js"; +//import * as OpenApiValidator from 'express-openapi-validator'; + +const isValidGroupBy = (value: string): value is GroupBy => + value === "day" || value === "week" || value === "month"; + +const parseDate = (value: unknown): Date | null => { + if (typeof value !== "string") { + return null; + } + + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return null; + } + return date; +}; + + +export function registerDeveloperAnalyticsRoutes(app: express.Express, developerRepository: DeveloperRepository, apiRepository: ApiRepository, usageEventsRepository: UsageEventsRepository): void { + app.get( + "/api/developers/apis", + requireAuth, + asyncHandler(async (req, res: express.Response, next) => { + const requestId = getRequestId(req); + const user = res.locals.authenticatedUser; + if (!user) { + next(new UnauthorizedError()); + return; + } + + const developer = await developerRepository.findByUserId(user.id); + if (!developer) { + next(new NotFoundError("Developer profile not found")); + return; + } + + const statusParam = + typeof req.query.status === "string" ? req.query.status : undefined; + let statusFilter: ApiStatus | undefined; + if (statusParam) { + if (!apiStatusEnum.includes(statusParam as ApiStatus)) { + next( + new BadRequestError( + `status must be one of: ${apiStatusEnum.join(", ")}`, + ), + ); + return; + } + statusFilter = statusParam as ApiStatus; + } + + const { limit, offset } = parsePagination( + req.query as Record, + ); + + const apis = await apiRepository.listByDeveloper(developer.id, { + status: statusFilter, + limit, + offset, + }); + + const usageStats = await usageEventsRepository.aggregateByDeveloper( + user.id, + ); + const statsByApi = new Map(usageStats.map((stat) => [stat.apiId, stat])); + + const payload = apis.map((api) => { + const stats = statsByApi.get(String(api.id)); + const entry: { + id: number; + name: string; + status: ApiStatus; + callCount: number; + revenue?: string; + } = { + id: api.id, + name: api.name, + status: api.status, + callCount: stats?.calls ?? 0, + }; + if (stats) { + entry.revenue = stats.revenue.toString(); + } + return entry; + }); + + res.json( + successEnvelope( + paginatedResponse(payload, { limit, offset }), + requestId, + ), + ); + }), + ); + + app.get( + "/api/developers/analytics", + requireAuth, + asyncHandler(async (req, res: express.Response, next) => { + const requestId = getRequestId(req); + const user = res.locals.authenticatedUser; + if (!user) { + next(new UnauthorizedError()); + return; + } + + const groupBy = req.query.groupBy ?? "day"; + if (typeof groupBy !== "string" || !isValidGroupBy(groupBy)) { + next(new BadRequestError("groupBy must be one of: day, week, month")); + return; + } + + const from = parseDate(req.query.from); + const to = parseDate(req.query.to); + if (!from || !to) { + next(new BadRequestError("from and to are required ISO date values")); + return; + } + if (from > to) { + next(new BadRequestError("from must be before or equal to to")); + return; + } + + const apiId = + typeof req.query.apiId === "string" ? req.query.apiId : undefined; + if (apiId) { + const ownsApi = await usageEventsRepository.developerOwnsApi( + user.id, + apiId, + ); + if (!ownsApi) { + next( + new ForbiddenError( + "Forbidden: API does not belong to authenticated developer", + ), + ); + return; + } + } + + const includeTop = req.query.includeTop === "true"; + const events = await usageEventsRepository.findByDeveloper({ + developerId: user.id, + from, + to, + apiId, + }); + + const analytics = buildDeveloperAnalytics(events, groupBy, includeTop); + res.json(successEnvelope(analytics, requestId)); + }), + ); + +} diff --git a/src/routes/developerRegistrationRoute.ts b/src/routes/developerRegistrationRoute.ts new file mode 100644 index 00000000..58743153 --- /dev/null +++ b/src/routes/developerRegistrationRoute.ts @@ -0,0 +1,76 @@ +import express from 'express'; +import { + requireAuth, + type AuthenticatedLocals, +} from "../middleware/requireAuth.js"; +import { bodyValidator } from "../middleware/validate.js"; +import { + createApi +} from "../repositories/apiRepository.js"; +import { + findByUserId +} from "../repositories/developerRepository.js"; +//import { envelopeValidator } from '../middleware/envelopeValidator.js'; +import { + BadRequestError, + UnauthorizedError +} from "../errors/index.js"; +import { + getRequestId, + successEnvelope +} from "../lib/envelope.js"; +import { apiRegistrationSchema } from "../validators/apiRegistration.js"; +//import * as OpenApiValidator from 'express-openapi-validator'; + + +export function registerDeveloperRegistrationRoute(app: express.Express, lookupDeveloper: typeof findByUserId, persistApi: typeof createApi): void { + app.post( + "/api/developers/apis", + requireAuth, + bodyValidator(apiRegistrationSchema), + async (req, res: express.Response, next) => { + try { + const requestId = getRequestId(req); + const user = res.locals.authenticatedUser; + if (!user) { + next(new UnauthorizedError()); + return; + } + + const payload = apiRegistrationSchema.parse(req.body); + + // Ensure the caller has a developer profile + const developer = await lookupDeveloper(user.id); + if (!developer) { + next( + new BadRequestError( + "Developer profile not found. Create a developer profile first.", + "DEVELOPER_NOT_FOUND", + ), + ); + return; + } + + const api = await persistApi({ + developer_id: developer.id, + name: payload.name, + description: payload.description ?? null, + base_url: payload.base_url, + category: payload.category, + status: "active", + endpoints: payload.endpoints.map((ep) => ({ + path: ep.path, + method: ep.method, + price_per_call_usdc: ep.price_per_call_usdc, + description: ep.description ?? null, + })), + }); + + res.status(201).json(successEnvelope(api, requestId)); + } catch (err) { + next(err); + } + }, + ); + +} diff --git a/src/routes/vaultRoutes.ts b/src/routes/vaultRoutes.ts new file mode 100644 index 00000000..134e2ae6 --- /dev/null +++ b/src/routes/vaultRoutes.ts @@ -0,0 +1,77 @@ +import express from "express"; +import { config } from "../config/index.js"; +import { DepositController } from "../controllers/depositController.js"; +import { VaultController } from "../controllers/vaultController.js"; +import { requireAuth, type AuthenticatedLocals } from "../middleware/requireAuth.js"; +import { validate } from "../middleware/validate.js"; +import type { VaultRepository } from "../repositories/vaultRepository.js"; +import { createSorobanVaultReader, type VaultStateReader } from "../services/sorobanVaultReader.js"; +import { TransactionBuilderService } from "../services/transactionBuilder.js"; +import { stellarNetworkQuerySchema } from "../validators/networkSchema.js"; + +export function registerVaultRoutes(app: express.Express, vaultRepository: VaultRepository, injectedReader?: VaultStateReader): void { + // Initialize deposit and vault controllers + const transactionBuilder = new TransactionBuilderService(); + const depositController = new DepositController( + vaultRepository, + transactionBuilder, + ); + const vaultReader = + injectedReader ?? + createSorobanVaultReader( + config.stellar.networks, + config.sorobanRpc?.timeout ?? 2_000, + ); + const vaultController = new VaultController( + vaultRepository, + vaultReader, + config.vaultSnapshotMaxAgeMs, + ); + // Deposit transaction preparation endpoint + app.post( + "/api/vault/deposit/prepare", + requireAuth, + (req, res: express.Response) => { + depositController.prepareDeposit(req, res); + }, + ); + + /** + * GET /api/vault/balance + * + * Returns the authenticated user's vault balance for the requested Stellar network. + * + * Query params: + * network - optional Stellar network identifier (`testnet` or `mainnet`) + * default: the configured Stellar network + */ + // Vault balance endpoint + app.get( + "/api/vault/balance", + requireAuth, + validate({ query: stellarNetworkQuerySchema }), + (req, res: express.Response, next) => { + vaultController.getBalance(req, res, next); + }, + ); + + const onchainVaultAuth = + config.demoMode + ? (req: express.Request, res: express.Response, next: express.NextFunction) => { + if (!req.headers.authorization) { + next(); + return; + } + requireAuth(req, res, next); + } + : requireAuth; + app.get( + "/api/vault/onchain", + onchainVaultAuth, + validate({ query: stellarNetworkQuerySchema }), + (req, res: express.Response, next) => { + vaultController.getOnchain(req, res, next); + }, + ); + +} diff --git a/src/services/appExports.ts b/src/services/appExports.ts new file mode 100644 index 00000000..509e18ec --- /dev/null +++ b/src/services/appExports.ts @@ -0,0 +1,83 @@ +import { config } from "../config/index.js"; +import { validateStorageEndpoint } from "../lib/storageEndpoint.js"; +import type { UsageEventsRepository } from "../repositories/usageEventsRepository.js"; +import { HmacObjectStorageClient, InMemoryScheduleStore, ScheduledExportsService } from "./scheduledExports.js"; +import { InMemoryExportStore, ReportExporterService } from "./reportExporter.js"; + +export function createAppExports(usageEventsRepository: UsageEventsRepository) { + const getExportEvents = async () => { + const events = await usageEventsRepository.getEvents?.(); + if (!events) { + throw new Error('The configured usage repository does not support export event retrieval.'); + } + return events.map((event) => ({ + id: event.id, + userId: event.userId, + apiId: event.apiId, + endpointId: event.endpoint, + apiKeyId: '', + developerId: event.developerId, + amount: event.revenue, + requestId: event.id, + stellarTxHash: event.stellarTxHash ?? null, + createdAt: event.occurredAt, + })); + }; + const exportConfig = config.exports; + const exportsConfigured = Boolean( + exportConfig.enabled && + exportConfig.bucket && + exportConfig.endpoint && + exportConfig.region && + exportConfig.signingSecret, + ); + const objectStorageClient = new HmacObjectStorageClient(); + const exportEndpoint = exportsConfigured + ? validateStorageEndpoint(exportConfig.endpoint!, { + production: config.nodeEnv === 'production', + allowedHosts: exportConfig.endpointAllowlist, + }) + : undefined; + const reportExporterService = exportsConfigured + ? new ReportExporterService( + { + // Adapt the in-process repository to the paged range interface: + // [from, to) by createdAt, optional developer filter, id keyset cursor. + getEventsInRange: async (from, to, options) => { + const events = (await getExportEvents()) + .filter((event) => event.createdAt >= from && event.createdAt < to) + .filter((event) => options.developerId === undefined || event.developerId === options.developerId) + .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + const start = options.cursor === undefined + ? 0 + : events.findIndex((event) => event.id > options.cursor!); + if (start === -1) return []; + return events.slice(start, start + options.limit); + }, + }, + objectStorageClient, + new InMemoryExportStore(), + { + s3Bucket: exportConfig.bucket!, + s3Endpoint: exportEndpoint!, + s3SecretAccessKey: exportConfig.signingSecret!, + }, + ) + : undefined; + const scheduledExportsService = exportsConfigured + ? new ScheduledExportsService( + { + findByApiId: async (apiId, from, to, limit, offset) => + (await getExportEvents()) + .filter((event) => !apiId || event.apiId === apiId) + .filter((event) => !from || event.createdAt >= from) + .filter((event) => !to || event.createdAt <= to) + .slice(offset ?? 0, limit === undefined ? undefined : (offset ?? 0) + limit), + }, + new InMemoryScheduleStore(exportConfig.signingSecret!), + objectStorageClient, + ) + : undefined; + + return { reportExporterService, scheduledExportsService }; +} diff --git a/src/services/createSorobanBillingService.ts b/src/services/createSorobanBillingService.ts index aebb1dea..c5577852 100644 --- a/src/services/createSorobanBillingService.ts +++ b/src/services/createSorobanBillingService.ts @@ -46,6 +46,7 @@ export function createSorobanBillingService( pool: Pool, dependencies?: SorobanBillingDependencies, ): BillingService | undefined { + if (config.demoMode) return undefined; if (dependencies?.billingService) return dependencies.billingService; const injectedClient = dependencies?.billingSorobanClient ?? diff --git a/tests/helpers/vaultControllerHarness.ts b/tests/helpers/vaultControllerHarness.ts new file mode 100644 index 00000000..a75abd9f --- /dev/null +++ b/tests/helpers/vaultControllerHarness.ts @@ -0,0 +1,59 @@ +import express from 'express'; +import request from 'supertest'; +import { VaultController } from '../../src/../src/controllers/vaultController.js'; +import { + InMemoryVaultRepository, + type VaultRepository, +} from '../../src/repositories/vaultRepository.js'; +import { errorHandler } from '../../src/middleware/errorHandler.js'; +import { requestIdMiddleware } from '../../src/middleware/requestId.js'; +import { validate } from '../../src/middleware/validate.js'; +import { stellarNetworkQuerySchema } from '../../src/validators/networkSchema.js'; +import type { + OnchainVaultState, + VaultStateReader, +} from '../../src/services/sorobanVaultReader.js'; + +export const state: OnchainVaultState = { + balance: 1_234_567n, + maxDeduct: 23_456_789n, + paused: true, + usdcToken: 'GUSDC', + settlement: 'GSETTLEMENT', + ledger: 987654, +}; + +export function createApp( + repository: VaultRepository, + reader?: VaultStateReader, + snapshotMaxAgeMs = 60_000, + demoTestnetContractId?: string, +) { + const app = express(); + app.use(requestIdMiddleware); + app.use((req, res, next) => { + const userId = req.headers['x-user-id']; + if (typeof userId === 'string' && userId.length > 0) { + res.locals.authenticatedUser = { id: userId, email: `${userId}@example.com` }; + } + next(); + }); + const controller = new VaultController( + repository, + reader, + snapshotMaxAgeMs, + demoTestnetContractId, + ); + app.get( + '/api/vault/balance', + validate({ query: stellarNetworkQuerySchema }), + controller.getBalance.bind(controller), + ); + app.get( + '/api/vault/onchain', + validate({ query: stellarNetworkQuerySchema }), + controller.getOnchain.bind(controller), + ); + app.use(errorHandler); + return app; +} diff --git a/tests/integration/demoMode.test.ts b/tests/integration/demoMode.test.ts new file mode 100644 index 00000000..c1c33c78 --- /dev/null +++ b/tests/integration/demoMode.test.ts @@ -0,0 +1,98 @@ +import { Pool } from "pg"; +import request from "supertest"; +import { createApp } from "../../src/app.js"; +import { config } from "../../src/config/index.js"; +import { closeDb, initializeDb } from "../../src/db/index.js"; +import { createSorobanRpcBillingClient } from "../../src/services/sorobanBilling.js"; + +jest.mock("../../src/services/sorobanBilling.js", () => ({ + createSorobanRpcBillingClient: jest.fn(), +})); +jest.mock("../../src/config/index.js", () => { + const actual = jest.requireActual("../../src/config/index.js"); + return { ...actual, config: { ...actual.config, demoMode: true } }; +}); + +describe("demo capability integration", () => { + const previousCors = process.env.CORS_ALLOW_MISSING_ORIGIN; + beforeAll(async () => { + process.env.CORS_ALLOW_MISSING_ORIGIN = "true"; + jest.replaceProperty(config, "demoMode", true); + await initializeDb(); + }); + afterAll(async () => { + if (previousCors === undefined) delete process.env.CORS_ALLOW_MISSING_ORIGIN; + else process.env.CORS_ALLOW_MISSING_ORIGIN = previousCors; + jest.restoreAllMocks(); + await closeDb(); + }); + + test("rejects billing before handlers, preserves requestId and omits retry headers", async () => { + const connect = jest.spyOn(Pool.prototype, "connect"); + const query = jest.spyOn(Pool.prototype, "query"); + const app = createApp(); + const response = await request(app).post("/api/billing/deduct") + .set("x-request-id", "demo-regression"); + expect(response.status).toBe(503); + expect(response.body).toMatchObject({ + success: false, + requestId: "demo-regression", + error: { code: "DEMO_MODE_UNSUPPORTED", details: { feature: "billing" } }, + }); + expect(response.headers["retry-after"]).toBeUndefined(); + expect(connect).not.toHaveBeenCalled(); + expect(query).not.toHaveBeenCalled(); + }); + + test("blocks invoices while keeping the seeded SQLite catalog available", async () => { + const app = createApp(); + expect((await request(app).get("/api/invoices")).status).toBe(503); + const catalog = await request(app).get("/api/apis"); + expect(catalog.status).toBe(200); + expect(JSON.stringify(catalog.body)).toContain("Geo Lookup"); + }); + + test("reports demo mode and never constructs the billing RPC client", async () => { + const createClient = jest.fn(); + const app = createApp({ createBillingSorobanClient: createClient }); + const response = await request(app).get("/api/health"); + expect(response.body.data).toEqual({ + status: "ok", service: "callora-backend", mode: "demo", + }); + expect(createClient).not.toHaveBeenCalled(); + expect(createSorobanRpcBillingClient).not.toHaveBeenCalled(); + expect(app.locals.billingService).toBeUndefined(); + }); + + test("blocks newer on-chain vault routes before the injected reader is called", async () => { + const readVault = jest.fn(); + const app = createApp({ vaultReader: { readVault } }); + for (const path of ["/api/vault/onchain", "/api/vault/balance"]) { + const response = await request(app).get(path); + expect(response.status).toBe(503); + expect(response.body.error.code).toBe("DEMO_MODE_UNSUPPORTED"); + expect(response.headers["retry-after"]).toBeUndefined(); + expect((await request(app).head(path)).status).toBe(503); + } + expect(readVault).not.toHaveBeenCalled(); + }); + + test("standard mode keeps the existing handler response and health shape", async () => { + jest.replaceProperty(config, "demoMode", false); + try { + const app = createApp(); + const billing = await request(app).post("/api/billing/deduct"); + expect(billing.status).toBe(401); + const health = await request(app).get("/api/health"); + expect(health.body.data).toMatchInlineSnapshot(` + { + "mode": "standard", + "service": "callora-backend", + "status": "ok", + } + `); + } finally { + jest.replaceProperty(config, "demoMode", true); + } + }); +});