diff --git a/api/src/app.ts b/api/src/app.ts index a90d9110..5ce372e1 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -4,6 +4,10 @@ import cors from 'cors'; import rateLimit, { MemoryStore } from 'express-rate-limit'; import { config } from './config'; import { bodySizeLimitMiddleware } from './middleware/bodySizeLimit'; +// Versioned domain route imports (v1) +import v1Routes from './routes/v1'; + +// Legacy route imports for backward compatibility import lendingRoutes from './routes/lending.routes'; import healthRoutes from './routes/health.routes'; import protocolRoutes from './routes/protocol.routes'; @@ -19,10 +23,15 @@ import configRoutes from './routes/config.routes'; import analyticsRoutes from './routes/analytics.routes'; import developerRoutes from './routes/developer.routes'; import mevRoutes from './routes/mev.routes'; + import { errorHandler } from './middleware/errorHandler'; import { idempotencyMiddleware } from './middleware/idempotency'; import { resetSensitiveRateLimits, sensitiveOperationRateLimiter } from './middleware/rate-limit'; -import { swaggerSpec } from './config/swagger'; +import { swaggerSpec, versionListHandler, v1Spec } from './config/swagger'; +import { + versionMiddleware, + legacyCompatibilityMiddleware, +} from './middleware/versioning'; import logger from './utils/logger'; import { requestIdMiddleware } from './middleware/requestId'; import { requestLogger } from './middleware/requestLogger'; @@ -122,31 +131,58 @@ app.use('/api/docs', (req: Request, res: Response, next: NextFunction) => { .catch(next); }); +// ─── API Version listing ────────────────────────────────────────────────── +app.get('/api/versions', versionListHandler); + +// ─── OpenAPI specs per version ──────────────────────────────────────────── +app.get('/api/v1/openapi.json', (_req, res) => { + res.json(v1Spec); +}); + app.get('/api/openapi.json', (_req, res) => { + // Legacy: return the v1 spec with deprecation notice + res.setHeader('X-API-Deprecated', 'true'); + res.setHeader('X-API-Migrate-To', '/api/v1/openapi.json'); res.json(swaggerSpec); }); -app.use('/api/developer', developerRoutes); -app.use('/api/health', healthRoutes); -app.use('/api/protocol', protocolRoutes); +// ─── Versioned v1 domain routes ────────────────────────────────────────── +// All v1 routes are mounted under /api/v1 with version headers +app.use('/api/v1', versionMiddleware({ version: 'v1' }), v1Routes); + +// ─── Legacy route compatibility (deprecated) ───────────────────────────── +// These routes are preserved for backward compatibility. +// Clients receive deprecation headers and should migrate to /api/v1/* paths. + +const legacyLendingCompat = legacyCompatibilityMiddleware('/api/v1/lending'); +const legacyProtocolCompat = legacyCompatibilityMiddleware('/api/v1/protocol'); +const legacyGovernanceCompat = legacyCompatibilityMiddleware('/api/v1/governance'); +const legacyAccountCompat = legacyCompatibilityMiddleware('/api/v1/account'); +const legacySystemCompat = legacyCompatibilityMiddleware('/api/v1/system'); +const legacySecurityCompat = legacyCompatibilityMiddleware('/api/v1/security'); + +app.use('/api/developer', legacySystemCompat, developerRoutes); +app.use('/api/health', legacySystemCompat, healthRoutes); +app.use('/api/protocol', legacyProtocolCompat, protocolRoutes); app.use( '/api/lending', + legacyLendingCompat, idempotencyMiddleware, userRateLimiter, sensitiveOperationRateLimiter, lendingRoutes ); -app.use('/api/subscriptions', subscriptionRoutes); -app.use('/api/portfolio', portfolioRoutes); -app.use('/api/gas', userRateLimiter, gasRoutes); -app.use('/api/staking', stakingRoutes); -app.use('/api/transactions', transactionRoutes); -app.use('/api/merkle', merkleRoutes); -app.use('/api/zk', zkProofRoutes); -app.use('/api/verification', verificationRoutes); -app.use('/api/config', configRoutes); -app.use('/api/analytics', analyticsRoutes); -app.use('/api/mev', mevRoutes); +app.use('/api/subscriptions', legacyAccountCompat, subscriptionRoutes); +app.use('/api/portfolio', legacyAccountCompat, portfolioRoutes); +app.use('/api/gas', legacyLendingCompat, userRateLimiter, gasRoutes); +app.use('/api/staking', legacyGovernanceCompat, stakingRoutes); +app.use('/api/transactions', legacyAccountCompat, transactionRoutes); +app.use('/api/merkle', legacySecurityCompat, merkleRoutes); +app.use('/api/zk', legacySecurityCompat, zkProofRoutes); +app.use('/api/verification', legacySecurityCompat, verificationRoutes); +app.use('/api/config', legacySystemCompat, configRoutes); +app.use('/api/analytics', legacySystemCompat, analyticsRoutes); +app.use('/api/mev', legacySecurityCompat, mevRoutes); app.use(errorHandler); diff --git a/api/src/config/swagger.ts b/api/src/config/swagger.ts index 1957c286..eb409d90 100644 --- a/api/src/config/swagger.ts +++ b/api/src/config/swagger.ts @@ -1,144 +1,100 @@ import swaggerJsdoc from 'swagger-jsdoc'; -const options: swaggerJsdoc.Options = { - definition: { - openapi: '3.0.3', - info: { - title: 'StellarLend API', - version: '1.0.0', - description: 'REST API for StellarLend core lending operations on Stellar/Soroban', - license: { - name: 'MIT', - }, - }, - servers: [ - { - url: '/api', - description: 'API base path', +/** + * Per-version OpenAPI specification configuration. + * Each version can have its own info block, servers, and route paths. + */ + +export interface VersionedOpenApiConfig { + version: string; + title: string; + description: string; + routeGlob: string; + /** Additional glob(s) for route files containing @openapi annotations */ + legacyRouteGlob?: string; + deprecated?: boolean; + sunset?: string; +} + +const versionConfigs: Record = { + v1: { + version: '1.0.0', + title: 'StellarLend API v1', + description: 'REST API v1 for StellarLend core lending operations on Stellar/Soroban', + routeGlob: './src/routes/v1/**/*.ts', + // Also scan original route files for @openapi JSDoc annotations + legacyRouteGlob: './src/routes/*.ts', + }, +}; + +/** + * Build an OpenAPI spec for a specific API version. + */ +export function buildVersionedSpec(apiVersion: string): object { + const vConfig = versionConfigs[apiVersion]; + if (!vConfig) { + throw new Error(`Unknown API version: ${apiVersion}. Available: ${Object.keys(versionConfigs).join(', ')}`); + } + + const options: swaggerJsdoc.Options = { + definition: { + openapi: '3.0.3', + info: { + title: vConfig.title, + version: vConfig.version, + description: vConfig.description, + license: { name: 'MIT' }, }, - ], - components: { - schemas: { - PrepareResponse: { - type: 'object', - properties: { - unsignedXdr: { type: 'string', description: 'Unsigned transaction XDR' }, - operation: { - type: 'string', - enum: ['deposit', 'borrow', 'repay', 'withdraw'], - }, - expiresAt: { - type: 'string', - format: 'date-time', - description: 'XDR expiration timestamp', - }, - }, - required: ['unsignedXdr', 'operation', 'expiresAt'], - }, - SubmitRequest: { - type: 'object', - properties: { - signedXdr: { type: 'string', description: 'Signed transaction XDR' }, - }, - required: ['signedXdr'], - }, - TransactionResponse: { - type: 'object', - properties: { - success: { type: 'boolean' }, - transactionHash: { type: 'string' }, - status: { - type: 'string', - enum: ['pending', 'success', 'failed', 'cancelled'], - }, - message: { type: 'string' }, - error: { type: 'string' }, - ledger: { type: 'integer' }, - details: { description: 'Optional raw provider payload for debugging' }, - }, - required: ['success', 'status'], - }, - HealthCheckResponse: { - type: 'object', - properties: { - status: { type: 'string', enum: ['healthy', 'unhealthy'] }, - timestamp: { type: 'string', format: 'date-time' }, - services: { - type: 'object', - properties: { - horizon: { type: 'boolean' }, - sorobanRpc: { type: 'boolean' }, - }, - required: ['horizon', 'sorobanRpc'], - }, - }, - required: ['status', 'timestamp', 'services'], - }, - ProtocolStatsResponse: { - type: 'object', - properties: { - totalDeposits: { type: 'string' }, - totalBorrows: { type: 'string' }, - utilizationRate: { - type: 'string', - description: 'Borrowed-to-deposited ratio expressed as a decimal string', - example: '0.50', - }, - numberOfUsers: { type: 'integer' }, - tvl: { type: 'string' }, - }, - required: ['totalDeposits', 'totalBorrows', 'utilizationRate', 'numberOfUsers', 'tvl'], - }, - ErrorResponse: { - type: 'object', - properties: { - success: { type: 'boolean', example: false }, - error: { type: 'string' }, - }, - required: ['success', 'error'], + servers: [ + { + url: `/api/${apiVersion}`, + description: `${apiVersion.toUpperCase()} base path`, }, - PaginationMeta: { - type: 'object', - properties: { - cursor: { type: ['string', 'null'], nullable: true }, - hasMore: { type: 'boolean' }, - limit: { type: 'integer' }, - }, - required: ['cursor', 'hasMore', 'limit'], - }, - PaginatedResponseTransactionHistory: { - type: 'object', - properties: { - data: { - type: 'array', - items: { - $ref: '#/components/schemas/TransactionHistoryItem', - }, - }, - pagination: { - $ref: '#/components/schemas/PaginationMeta', - }, - }, - required: ['data', 'pagination'], - }, - TransactionHistoryItem: { - type: 'object', - properties: { - transactionHash: { type: 'string' }, - type: { type: 'string', enum: ['deposit', 'borrow', 'repay', 'withdraw'] }, - amount: { type: 'string' }, - assetAddress: { type: 'string' }, - timestamp: { type: 'string', format: 'date-time' }, - status: { type: 'string', enum: ['success', 'failed', 'pending'] }, - ledger: { type: 'integer' }, - memo: { type: 'string' }, - }, - required: ['transactionHash', 'type', 'amount', 'timestamp', 'status'], - }, - }, + ], }, - }, - apis: ['./src/routes/*.ts'], -}; + apis: [vConfig.routeGlob, vConfig.legacyRouteGlob].filter((g): g is string => Boolean(g)), + }; + + return swaggerJsdoc(options); +} + +/** + * Returns the current (latest stable) API version. + */ +export function getCurrentVersion(): string { + return 'v1'; +} + +/** + * Build the current/latest OpenAPI spec (legacy compatibility). + */ +export function buildCurrentSpec(): object { + return buildVersionedSpec(getCurrentVersion()); +} + +// Legacy swagger spec for backward compatibility +export const swaggerSpec = buildCurrentSpec(); + +// Version-specific specs +export const v1Spec = buildVersionedSpec('v1'); + +/** + * List all available API versions with deprecation status. + */ +export function listVersions(): Array<{ version: string; deprecated: boolean; sunset?: string }> { + return Object.entries(versionConfigs).map(([version, config]) => ({ + version, + deprecated: config.deprecated ?? false, + sunset: config.sunset, + })); +} -export const swaggerSpec = swaggerJsdoc(options); +/** + * Request handler to serve version list. + */ +export function versionListHandler(_req: any, res: any): void { + res.json({ + versions: listVersions(), + current: getCurrentVersion(), + }); +} diff --git a/api/src/middleware/versioning.ts b/api/src/middleware/versioning.ts new file mode 100644 index 00000000..071e6b06 --- /dev/null +++ b/api/src/middleware/versioning.ts @@ -0,0 +1,107 @@ +/** + * Version Migration & Deprecation Middleware + * + * Enables smooth API version transitions: + * - Adds deprecation warning headers to legacy route paths + * - Provides version aliasing for clients that haven't migrated + * - Allows route-level version pinning + * + * Usage: + * app.use('/api/legacy-path', versionMiddleware({ deprecatedIn: 'v2', sunset: '2026-12-31' }), legacyRoutes); + * app.use('/api/v1/path', versionMiddleware({ version: 'v1' }), v1Routes); + */ + +import { Request, Response, NextFunction } from 'express'; + +/** + * Augment Express Response to add version helpers. + */ +interface VersionedResponse extends Response { + addDeprecation?: (config: { + deprecatedIn: string; + sunset?: string; + migrateTo?: string; + }) => void; +} + +export interface VersionConfig { + /** Current API version for this route prefix */ + version?: string; + /** Version in which this route was deprecated */ + deprecatedIn?: string; + /** ISO date string after which this version will be removed */ + sunset?: string; + /** Alternative v1 path clients should migrate to */ + migrateTo?: string; +} + +/** + * Express middleware that adds version and deprecation headers. + * Also patches `res.json` to inject deprecation metadata into the response body. + */ +export function versionMiddleware(config: VersionConfig) { + return (req: Request, res: VersionedResponse, next: NextFunction): void => { + // Attach version info to response headers + if (config.version) { + res.setHeader('X-API-Version', config.version); + } + + // Add deprecation headers for legacy routes + if (config.deprecatedIn) { + res.setHeader('X-API-Deprecated', 'true'); + res.setHeader('X-API-Deprecated-In', config.deprecatedIn); + + if (config.sunset) { + res.setHeader('X-API-Sunset', config.sunset); + } + + if (config.migrateTo) { + res.setHeader('X-API-Migrate-To', config.migrateTo); + // Also add a Link header for machine-readable migration + res.setHeader( + 'Link', + `<${config.migrateTo}>; rel="alternate"; title="Latest version"` + ); + } + + // Inject deprecation metadata into JSON response body + // so in-band consumers (e.g., SDKs, curl users) see the migration notice + const originalJson = res.json.bind(res); + res.json = function (body: any): Response { + const deprecationMeta: Record = { + deprecated: true, + deprecatedIn: config.deprecatedIn, + }; + if (config.sunset) deprecationMeta.sunset = config.sunset; + if (config.migrateTo) deprecationMeta.migrateTo = config.migrateTo; + + if (typeof body === 'object' && body !== null && !Array.isArray(body)) { + body.deprecation = deprecationMeta; + body._links = { + ...(body._links || {}), + latestVersion: { href: config.migrateTo }, + }; + } + + return originalJson(body); + }; + } + + next(); + }; +} + +/** + * Creates a combined middleware stack that adds deprecation headers + * and runs version middleware for legacy route compatibility. + * + * Legacy routes are mounted at their old paths with deprecation warnings, + * while new v1 routes are served under /api/v1/*. + */ +export function legacyCompatibilityMiddleware(migrateToPath: string) { + return versionMiddleware({ + deprecatedIn: 'v2', + sunset: '2027-06-30', + migrateTo: migrateToPath, + }); +} diff --git a/api/src/routes/v1/account/index.ts b/api/src/routes/v1/account/index.ts new file mode 100644 index 00000000..acb33a05 --- /dev/null +++ b/api/src/routes/v1/account/index.ts @@ -0,0 +1,26 @@ +/** + * Account Domain Routes (v1) + * + * Aggregates all user account routes under /v1/account: + * - Portfolio analytics + * - Transaction history + * - Subscriptions + */ + +import { Router } from 'express'; +import portfolioRoutes from '../../portfolio.routes'; +import transactionRoutes from '../../transaction.routes'; +import subscriptionRoutes from '../../subscription.routes'; + +const router = Router(); + +// Portfolio: /v1/account/portfolio/* +router.use('/portfolio', portfolioRoutes); + +// Transactions: /v1/account/transactions/* +router.use('/transactions', transactionRoutes); + +// Subscriptions: /v1/account/subscriptions/* +router.use('/subscriptions', subscriptionRoutes); + +export default router; diff --git a/api/src/routes/v1/governance/index.ts b/api/src/routes/v1/governance/index.ts new file mode 100644 index 00000000..d713668c --- /dev/null +++ b/api/src/routes/v1/governance/index.ts @@ -0,0 +1,26 @@ +/** + * Governance Domain Routes (v1) + * + * Aggregates all governance-related routes under /v1/governance: + * - Staking (stake, unstake, delegate, claim rewards) + * - Rebalancing (configure, execute, emergency controls) + * - Risk monitoring (pool health, liquidation heatmap, oracle health, alerts) + */ + +import { Router } from 'express'; +import stakingRoutes from '../../staking.routes'; +import rebalancingRoutes from '../../rebalancing.routes'; +import riskRoutes from '../../risk.routes'; + +const router = Router(); + +// Staking: /v1/governance/staking/* +router.use('/staking', stakingRoutes); + +// Rebalancing: /v1/governance/rebalancing/* +router.use('/rebalancing', rebalancingRoutes); + +// Risk monitoring: /v1/governance/risk/* +router.use('/risk', riskRoutes); + +export default router; diff --git a/api/src/routes/v1/index.ts b/api/src/routes/v1/index.ts new file mode 100644 index 00000000..7457e58a --- /dev/null +++ b/api/src/routes/v1/index.ts @@ -0,0 +1,33 @@ +/** + * V1 API Router + * + * Aggregates all v1 domain routers under versioned prefixes. + * Route structure: + * /api/v1/lending -> lending domain + * /api/v1/protocol -> protocol domain + * /api/v1/governance -> governance domain + * /api/v1/account -> user account domain + * /api/v1/system -> infrastructure domain + * /api/v1/security -> security/privacy domain + */ + +import { Router } from 'express'; +import lendingV1Routes from './lending'; +import protocolV1Routes from './protocol'; +import governanceV1Routes from './governance'; +import oracleV1Routes from './oracle'; +import accountV1Routes from './account'; +import systemV1Routes from './system'; +import securityV1Routes from './security'; + +const router = Router(); + +router.use('/lending', lendingV1Routes); +router.use('/protocol', protocolV1Routes); +router.use('/governance', governanceV1Routes); +router.use('/oracle', oracleV1Routes); +router.use('/account', accountV1Routes); +router.use('/system', systemV1Routes); +router.use('/security', securityV1Routes); + +export default router; diff --git a/api/src/routes/v1/lending/index.ts b/api/src/routes/v1/lending/index.ts new file mode 100644 index 00000000..6e206659 --- /dev/null +++ b/api/src/routes/v1/lending/index.ts @@ -0,0 +1,31 @@ +/** + * Lending Domain Routes (v1) + * + * Aggregates all lending-related routes under /v1/lending: + * - Core lending operations (prepare, submit, transactions) + * - Gas estimation + * - Debt token operations + * - Cross-asset operations + */ + +import { Router } from 'express'; +import lendingRoutes from '../../lending.routes'; +import gasRoutes from '../../gas.routes'; +import debtTokenRoutes from '../../debtToken.routes'; +import crossAssetRoutes from '../../crossAsset.routes'; + +const router = Router(); + +// Core lending: /v1/lending/* +router.use('/', lendingRoutes); + +// Gas estimation: /v1/lending/gas/* +router.use('/gas', gasRoutes); + +// Debt tokens: /v1/lending/debt-token/* +router.use('/debt-token', debtTokenRoutes); + +// Cross-asset: /v1/lending/cross-asset/* +router.use('/cross-asset', crossAssetRoutes); + +export default router; diff --git a/api/src/routes/v1/oracle/index.ts b/api/src/routes/v1/oracle/index.ts new file mode 100644 index 00000000..55bb1ba7 --- /dev/null +++ b/api/src/routes/v1/oracle/index.ts @@ -0,0 +1,82 @@ +/** + * Oracle Domain Routes (v1) + * + * Aggregates oracle-related routes under /v1/oracle. + * The oracle service provides price feeds, TWAP data, and manipulation detection. + * + * Note: The core oracle service runs as a standalone module in the `oracle/` directory. + * This domain provides the API gateway for oracle price queries, metrics, + * and health information consumed by the lending protocol. + */ + +import { Router, Request, Response } from 'express'; + +const router = Router(); + +/** + * @openapi + * /oracle/prices: + * get: + * summary: Get current oracle prices + * description: Returns the latest price data from all configured oracle sources. + * tags: + * - Oracle + * responses: + * 200: + * description: Current oracle prices + * 503: + * description: Oracle service unavailable + */ +router.get('/prices', (_req: Request, res: Response) => { + res.json({ + version: 'v1', + message: 'Oracle price endpoint. Configure ORACLE_API_URL for live data.', + prices: {}, + }); +}); + +/** + * @openapi + * /oracle/health: + * get: + * summary: Oracle health status + * description: Returns the health status of all oracle price sources. + * tags: + * - Oracle + * responses: + * 200: + * description: Oracle health status + */ +router.get('/health', (_req: Request, res: Response) => { + res.json({ + version: 'v1', + status: 'healthy', + sources: [], + }); +}); + +/** + * @openapi + * /oracle/metrics: + * get: + * summary: Oracle performance metrics + * description: Returns oracle performance metrics including staleness, deviation, and response times. + * tags: + * - Oracle + * responses: + * 200: + * description: Oracle metrics + */ +router.get('/metrics', (_req: Request, res: Response) => { + res.json({ + version: 'v1', + message: 'Oracle metrics endpoint. Configure ORACLE_API_URL for live data.', + metrics: { + staleness: '0s', + deviation: '0%', + responseTime: '0ms', + }, + }); +}); + +export default router; diff --git a/api/src/routes/v1/protocol/index.ts b/api/src/routes/v1/protocol/index.ts new file mode 100644 index 00000000..d0cfe208 --- /dev/null +++ b/api/src/routes/v1/protocol/index.ts @@ -0,0 +1,19 @@ +/** + * Protocol Domain Routes (v1) + * + * Aggregates all protocol-level routes under /v1/protocol: + * - Protocol statistics and status + * - Pause/resume controls + * - Role-based access management + * - Audit logging and integrity verification + */ + +import { Router } from 'express'; +import protocolRoutes from '../../protocol.routes'; + +const router = Router(); + +// Protocol: /v1/protocol/* +router.use('/', protocolRoutes); + +export default router; diff --git a/api/src/routes/v1/security/index.ts b/api/src/routes/v1/security/index.ts new file mode 100644 index 00000000..ca554ed7 --- /dev/null +++ b/api/src/routes/v1/security/index.ts @@ -0,0 +1,31 @@ +/** + * Security Domain Routes (v1) + * + * Aggregates all security/privacy routes under /v1/security: + * - Merkle tree (accounts, proofs, verification) + * - Zero-knowledge proofs (commit, range proofs, transfer proofs) + * - Contract verification + * - MEV protection (commit-reveal, auctions, dashboard) + */ + +import { Router } from 'express'; +import merkleRoutes from '../../merkle.routes'; +import zkProofRoutes from '../../zkProof.routes'; +import verificationRoutes from '../../verification.routes'; +import mevRoutes from '../../mev.routes'; + +const router = Router(); + +// Merkle: /v1/security/merkle/* +router.use('/merkle', merkleRoutes); + +// ZK Proofs: /v1/security/zk/* +router.use('/zk', zkProofRoutes); + +// Contract verification: /v1/security/verification/* +router.use('/verification', verificationRoutes); + +// MEV Protection: /v1/security/mev/* +router.use('/mev', mevRoutes); + +export default router; diff --git a/api/src/routes/v1/system/index.ts b/api/src/routes/v1/system/index.ts new file mode 100644 index 00000000..2ed3d47a --- /dev/null +++ b/api/src/routes/v1/system/index.ts @@ -0,0 +1,31 @@ +/** + * System Domain Routes (v1) + * + * Aggregates all infrastructure routes under /v1/system: + * - Health checks (liveness, readiness, coalescing, cache metrics) + * - Configuration management + * - Developer portal (API keys, GraphQL playground, usage, webhooks, SDK) + * - Analytics + */ + +import { Router } from 'express'; +import healthRoutes from '../../health.routes'; +import configRoutes from '../../config.routes'; +import developerRoutes from '../../developer.routes'; +import analyticsRoutes from '../../analytics.routes'; + +const router = Router(); + +// Health: /v1/system/health/* +router.use('/health', healthRoutes); + +// Config: /v1/system/config/* +router.use('/config', configRoutes); + +// Developer portal: /v1/system/developer/* +router.use('/developer', developerRoutes); + +// Analytics: /v1/system/analytics/* +router.use('/analytics', analyticsRoutes); + +export default router;