diff --git a/.github/workflows/node-ci.yml b/.github/workflows/node-ci.yml index 2a995ca..6b5fcd3 100644 --- a/.github/workflows/node-ci.yml +++ b/.github/workflows/node-ci.yml @@ -59,6 +59,9 @@ jobs: - name: Run format check run: npm run format:check + - name: Check OpenAPI spec + run: npm run openapi:check + # Build the project - name: Build project run: npm run build diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index a1e3be4..e2f0ac9 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -7,6 +7,8 @@ Comprehensive reference for all backend endpoints defined in src/routes. - Base URL (local): http://localhost:3001 - Content type: application/json unless otherwise specified - Auth header format: Authorization: Bearer +- OpenAPI JSON: GET /openapi.json in non-production with a valid bearer token +- Swagger UI: GET /docs in non-production ## Authentication and Authorization @@ -685,4 +687,7 @@ Response 404: - protocols.ts: GET /api/protocols/rates, GET /api/protocols/agent/status - deposit.ts: POST /api/deposit - withdraw.ts: POST /api/withdraw -- vault.ts: GET /api/vault/state, GET /api/vault/balance +- vault.ts: GET /api/vault/state, GET /api/vault/balance, POST /api/vault/build-transaction +- analytics.ts: GET /api/analytics/apy-history, GET /api/analytics/user-yield, GET /api/analytics/protocol-performance +- stellar.ts: GET /api/stellar/metrics +- admin.ts: GET /api/admin/stellar/metrics, GET /api/admin/dlq/inspect, POST /api/admin/dlq/retry, POST /api/admin/dlq/resolve, POST /api/admin/stellar/backfill diff --git a/package.json b/package.json index 921dfcc..28120c8 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "build": "tsc", "start": "node dist/index.js", "lint": "npm run lint:types && npm run lint:style", + "openapi:check": "ts-node --transpile-only scripts/check-openapi.ts", "lint:types": "tsc --noEmit", "lint:style": "eslint \"src/**/*.ts\" \"tests/**/*.ts\" \"prisma/**/*.ts\" \"jest.config.ts\"", "format": "prettier --write .github/workflows/node-ci.yml package.json .prettierrc.json eslint.config.mjs src/nlp/parser.ts src/stellar/dlq.ts src/whatsapp/handler.ts src/whatsapp/userManager.ts tests/helpers/testDb.ts tests/integration/stellar/events.test.ts tests/unit/nlp/parser.test.ts tests/unit/whatsapp/handler.test.ts", diff --git a/scripts/check-openapi.ts b/scripts/check-openapi.ts new file mode 100644 index 0000000..7a77b9d --- /dev/null +++ b/scripts/check-openapi.ts @@ -0,0 +1,88 @@ +import fs from 'node:fs' +import path from 'node:path' +import { openApiSpec } from '../src/openapi/spec' + +type RouteMount = { + file: string + prefix: string +} + +const routeMounts: RouteMount[] = [ + { file: 'agent.ts', prefix: '/api/agent' }, + { file: 'analytics.ts', prefix: '/api/analytics' }, + { file: 'admin.ts', prefix: '/api/admin' }, + { file: 'auth.ts', prefix: '/api/auth' }, + { file: 'deposit.ts', prefix: '/api/deposit' }, + { file: 'portfolio.ts', prefix: '/api/portfolio' }, + { file: 'protocols.ts', prefix: '/api/protocols' }, + { file: 'stellar.ts', prefix: '/api/stellar' }, + { file: 'transactions.ts', prefix: '/api/transactions' }, + { file: 'vault.ts', prefix: '/api/vault' }, + { file: 'whatsapp.ts', prefix: '/api/whatsapp' }, + { file: 'withdraw.ts', prefix: '/api/withdraw' }, +] + +function collectDiscoveredOperations(): Set { + const operations = new Set() + const routesDir = path.join(process.cwd(), 'src', 'routes') + + for (const mount of routeMounts) { + const filePath = path.join(routesDir, mount.file) + const source = fs.readFileSync(filePath, 'utf8') + const regex = /router\.(get|post|put|delete|patch)\s*\(\s*(['"])(.*?)\2/gs + + for (const match of source.matchAll(regex)) { + const method = match[1].toLowerCase() + const routePath = match[3] + const normalizedPath = `${mount.prefix}${routePath}` + .replace(/\/+/g, '/') + .replace(/:([A-Za-z0-9_]+)/g, '{$1}') + .replace(/\/$/, '') || '/' + operations.add(`${method} ${normalizedPath}`) + } + } + + return operations +} + +function collectSpecOperations(): Set { + const operations = new Set() + + for (const [routePath, pathItem] of Object.entries(openApiSpec.paths)) { + for (const method of Object.keys(pathItem)) { + if (['get', 'post', 'put', 'patch', 'delete'].includes(method)) { + operations.add(`${method} ${routePath}`) + } + } + } + + return operations +} + +const discovered = collectDiscoveredOperations() +const documented = collectSpecOperations() + +const missing = [...discovered].filter((operation) => !documented.has(operation)).sort() +const extra = [...documented].filter((operation) => !discovered.has(operation)).sort() + +if (missing.length || extra.length) { + console.error('OpenAPI spec is out of sync with the route table.') + + if (missing.length) { + console.error('\nMissing from spec:') + for (const operation of missing) { + console.error(` - ${operation}`) + } + } + + if (extra.length) { + console.error('\nExtra in spec:') + for (const operation of extra) { + console.error(` - ${operation}`) + } + } + + process.exit(1) +} + +console.log('OpenAPI spec is in sync with the route table.') diff --git a/src/index.ts b/src/index.ts index 22750e9..0ab7056 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,6 +24,7 @@ import analyticsRouter from './routes/analytics' import adminRouter from './routes/admin' import metricsRouter from './routes/metrics' import stellarRouter from './routes/stellar' +import docsRouter from './routes/docs' import { corsMiddleware, jsonBodyParser, payloadSizeErrorHandler, urlencodedBodyParser } from './middleware/corsandbody' // ── Readiness state ─────────────────────────────────────────────────────────── @@ -120,6 +121,7 @@ app.use('/api/withdraw', withdrawRouter) app.use('/api/vault', vaultRouter) app.use('/api/analytics', analyticsRouter) app.use('/api/stellar', stellarRouter) +app.use('/', docsRouter) app.use('/metrics', metricsRouter) // Admin routes (protected, strictest rate limit) @@ -303,4 +305,4 @@ if (require.main === module) { } export default app -export { serviceStatus, allServicesReady } \ No newline at end of file +export { serviceStatus, allServicesReady } diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts new file mode 100644 index 0000000..26152fd --- /dev/null +++ b/src/openapi/spec.ts @@ -0,0 +1,957 @@ +const ref = (name: string) => ({ $ref: `#/components/schemas/${name}` }) + +const jsonResponse = (schema: Record, description = 'Successful response') => ({ + description, + content: { + 'application/json': { + schema, + }, + }, +}) + +const textResponse = ( + contentType: 'text/plain' | 'text/xml', + schema: Record | { type: 'string'; example: string }, + description = 'Successful response', +) => ({ + description, + content: { + [contentType]: { + schema, + }, + }, +}) + +const bearerSecurity = [{ bearerAuth: [] }] +const adminSecurity = [{ adminToken: [] }] + +export const openApiSpec = { + openapi: '3.0.3', + info: { + title: 'Neurowealth Backend API', + version: '1.0.0', + description: + 'OpenAPI 3 reference for the Neurowealth backend /api/* routes and related integrator endpoints.', + }, + servers: [{ url: '/' }], + tags: [ + { name: 'Auth' }, + { name: 'Agent' }, + { name: 'Analytics' }, + { name: 'Admin' }, + { name: 'Deposit' }, + { name: 'Protocols' }, + { name: 'Portfolio' }, + { name: 'Stellar' }, + { name: 'Transactions' }, + { name: 'Vault' }, + { name: 'Withdraw' }, + { name: 'WhatsApp' }, + ], + components: { + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + }, + adminToken: { + type: 'apiKey', + in: 'header', + name: 'x-admin-token', + }, + }, + schemas: { + ErrorResponse: { + type: 'object', + properties: { + error: { type: 'string' }, + message: { type: 'string', nullable: true }, + details: { type: 'object', nullable: true }, + }, + required: ['error'], + }, + ValidationErrorResponse: { + type: 'object', + properties: { + error: { type: 'string' }, + details: { type: 'object' }, + }, + required: ['error', 'details'], + }, + AuthChallengeRequest: { + type: 'object', + properties: { + stellarPubKey: { type: 'string' }, + }, + required: ['stellarPubKey'], + }, + AuthChallengeResponse: { + type: 'object', + properties: { + nonce: { type: 'string' }, + expiresAt: { type: 'string', format: 'date-time' }, + }, + required: ['nonce', 'expiresAt'], + }, + AuthVerifyRequest: { + type: 'object', + properties: { + stellarPubKey: { type: 'string' }, + signature: { type: 'string' }, + }, + required: ['stellarPubKey', 'signature'], + }, + AuthVerifyResponse: { + type: 'object', + properties: { + token: { type: 'string' }, + userId: { type: 'string', format: 'uuid' }, + expiresAt: { type: 'string', format: 'date-time' }, + }, + required: ['token', 'userId', 'expiresAt'], + }, + LogoutResponse: { + type: 'object', + properties: { + message: { type: 'string' }, + }, + required: ['message'], + }, + AgentStatusResponse: { + type: 'object', + properties: { + success: { type: 'boolean' }, + data: { + type: 'object', + properties: { + isRunning: { type: 'boolean' }, + lastRebalanceAt: { type: 'string', format: 'date-time', nullable: true }, + currentProtocol: { type: 'string', nullable: true }, + currentApy: { type: 'string', nullable: true }, + nextScheduledCheck: { type: 'string', format: 'date-time', nullable: true }, + lastError: { type: 'string', nullable: true }, + healthStatus: { type: 'string' }, + timestamp: { type: 'string', format: 'date-time' }, + }, + required: [ + 'isRunning', + 'lastRebalanceAt', + 'currentProtocol', + 'currentApy', + 'nextScheduledCheck', + 'lastError', + 'healthStatus', + 'timestamp', + ], + }, + whatsappReply: { type: 'string' }, + }, + required: ['success', 'data', 'whatsappReply'], + }, + PortfolioPosition: { + type: 'object', + properties: { + id: { type: 'string' }, + protocolName: { type: 'string' }, + assetSymbol: { type: 'string' }, + currentValue: { type: 'number' }, + yieldEarned: { type: 'number' }, + status: { type: 'string' }, + }, + required: ['id', 'protocolName', 'assetSymbol', 'currentValue', 'yieldEarned', 'status'], + }, + PortfolioResponse: { + type: 'object', + properties: { + userId: { type: 'string', format: 'uuid' }, + totalBalance: { type: 'number' }, + totalEarnings: { type: 'number' }, + activePositions: { type: 'number' }, + positions: { + type: 'array', + items: ref('PortfolioPosition'), + }, + whatsappReply: { type: 'string' }, + }, + required: ['userId', 'totalBalance', 'totalEarnings', 'activePositions', 'positions', 'whatsappReply'], + }, + PortfolioHistoryPoint: { + type: 'object', + properties: { + date: { type: 'string' }, + yieldAmount: { type: 'number' }, + }, + required: ['date', 'yieldAmount'], + }, + PortfolioHistoryResponse: { + type: 'object', + properties: { + userId: { type: 'string', format: 'uuid' }, + period: { type: 'string', enum: ['7d', '30d', '90d'] }, + points: { + type: 'array', + items: ref('PortfolioHistoryPoint'), + }, + whatsappReply: { type: 'string' }, + }, + required: ['userId', 'period', 'points', 'whatsappReply'], + }, + PortfolioEarningsResponse: { + type: 'object', + properties: { + userId: { type: 'string', format: 'uuid' }, + totalEarnings: { type: 'number' }, + periodEarnings: { type: 'number' }, + averageApy: { type: 'number' }, + whatsappReply: { type: 'string' }, + }, + required: ['userId', 'totalEarnings', 'periodEarnings', 'averageApy', 'whatsappReply'], + }, + TransactionItem: { + type: 'object', + properties: { + id: { type: 'string' }, + txHash: { type: 'string' }, + type: { type: 'string' }, + status: { type: 'string' }, + amount: { type: 'number' }, + assetSymbol: { type: 'string' }, + protocolName: { type: 'string', nullable: true }, + createdAt: { type: 'string', format: 'date-time' }, + }, + required: ['id', 'txHash', 'type', 'status', 'amount', 'assetSymbol', 'createdAt'], + }, + TransactionDetailResponse: { + type: 'object', + properties: { + transaction: ref('TransactionItem'), + whatsappReply: { type: 'string' }, + }, + required: ['transaction', 'whatsappReply'], + }, + TransactionListResponse: { + type: 'object', + properties: { + page: { type: 'number' }, + limit: { type: 'number' }, + total: { type: 'number' }, + transactions: { + type: 'array', + items: ref('TransactionItem'), + }, + whatsappReply: { type: 'string' }, + }, + required: ['page', 'limit', 'total', 'transactions', 'whatsappReply'], + }, + ProtocolRate: { + type: 'object', + properties: { + protocolName: { type: 'string' }, + assetSymbol: { type: 'string' }, + supplyApy: { type: 'number' }, + borrowApy: { type: 'number', nullable: true }, + tvl: { type: 'number', nullable: true }, + network: { type: 'string' }, + fetchedAt: { type: 'string', format: 'date-time' }, + }, + required: ['protocolName', 'assetSymbol', 'supplyApy', 'borrowApy', 'tvl', 'network', 'fetchedAt'], + }, + ProtocolRatesResponse: { + type: 'object', + properties: { + rates: { + type: 'array', + items: ref('ProtocolRate'), + }, + whatsappReply: { type: 'string' }, + }, + required: ['rates', 'whatsappReply'], + }, + ProtocolAgentStatusResponse: { + type: 'object', + properties: { + success: { type: 'boolean' }, + data: { + type: 'object', + properties: { + isRunning: { type: 'boolean' }, + healthStatus: { type: 'string' }, + lastRebalanceAt: { type: 'string', format: 'date-time', nullable: true }, + currentProtocol: { type: 'string', nullable: true }, + currentApy: { type: 'number', nullable: true }, + nextScheduledCheck: { type: 'string', format: 'date-time' }, + lastError: { type: 'string', nullable: true }, + latestLog: { + type: 'object', + nullable: true, + properties: { + status: { type: 'string' }, + action: { type: 'string' }, + createdAt: { type: 'string', format: 'date-time' }, + }, + }, + timestamp: { type: 'string', format: 'date-time' }, + }, + required: [ + 'isRunning', + 'healthStatus', + 'lastRebalanceAt', + 'currentProtocol', + 'currentApy', + 'nextScheduledCheck', + 'lastError', + 'latestLog', + 'timestamp', + ], + }, + whatsappReply: { type: 'string' }, + }, + required: ['success', 'data', 'whatsappReply'], + }, + TransactionMutationRequest: { + type: 'object', + properties: { + userId: { type: 'string', format: 'uuid' }, + amount: { type: 'number', exclusiveMinimum: 0 }, + assetSymbol: { type: 'string' }, + protocolName: { type: 'string' }, + memo: { type: 'string', maxLength: 280 }, + }, + required: ['userId', 'amount', 'assetSymbol'], + }, + TransactionMutationResponse: { + type: 'object', + properties: { + txHash: { type: 'string' }, + status: { type: 'string' }, + transaction: { + type: 'object', + properties: { + id: { type: 'string' }, + txHash: { type: 'string' }, + status: { type: 'string' }, + amount: { type: 'number' }, + assetSymbol: { type: 'string' }, + protocolName: { type: 'string', nullable: true }, + }, + required: ['id', 'txHash', 'status', 'amount', 'assetSymbol'], + }, + whatsappReply: { type: 'string' }, + }, + required: ['txHash', 'status', 'transaction', 'whatsappReply'], + }, + VaultStateResponse: { + type: 'object', + properties: { + apy: { type: 'number' }, + activeProtocol: { type: 'string' }, + }, + required: ['apy', 'activeProtocol'], + }, + VaultBalanceResponse: { + type: 'object', + properties: { + balance: { type: 'number' }, + shares: { type: 'number' }, + }, + required: ['balance', 'shares'], + }, + VaultBuildTransactionResponse: { + type: 'object', + properties: { + xdr: { type: 'string' }, + type: { type: 'string', enum: ['deposit', 'withdraw'] }, + amount: { type: 'number' }, + walletAddress: { type: 'string' }, + }, + required: ['xdr', 'type', 'amount', 'walletAddress'], + }, + AnalyticsApyHistoryResponse: { + type: 'object', + properties: { + userId: { type: 'string', format: 'uuid' }, + period: { type: 'string', enum: ['7d', '30d', '90d'] }, + points: { + type: 'array', + items: { + type: 'object', + properties: { + date: { type: 'string' }, + apy: { type: 'number' }, + positionId: { type: 'string' }, + }, + required: ['date', 'apy', 'positionId'], + }, + }, + }, + required: ['userId', 'period', 'points'], + }, + AnalyticsUserYieldResponse: { + type: 'object', + properties: { + userId: { type: 'string', format: 'uuid' }, + period: { type: 'string', enum: ['7d', '30d', '90d'] }, + totalYield: { type: 'number' }, + periodYield: { type: 'number' }, + averageApy: { type: 'number' }, + points: { + type: 'array', + items: { + type: 'object', + properties: { + date: { type: 'string' }, + yieldAmount: { type: 'number' }, + apy: { type: 'number' }, + }, + required: ['date', 'yieldAmount', 'apy'], + }, + }, + }, + required: ['userId', 'period', 'totalYield', 'periodYield', 'averageApy', 'points'], + }, + AnalyticsProtocolPerformanceResponse: { + type: 'object', + properties: { + period: { type: 'string', enum: ['7d', '30d', '90d'] }, + protocols: { + type: 'array', + items: { + type: 'object', + properties: { + protocol: { type: 'string' }, + asset: { type: 'string' }, + network: { type: 'string' }, + points: { + type: 'array', + items: { + type: 'object', + properties: { + date: { type: 'string' }, + apy: { type: 'number' }, + tvl: { type: 'number', nullable: true }, + }, + required: ['date', 'apy', 'tvl'], + }, + }, + }, + required: ['protocol', 'asset', 'network', 'points'], + }, + }, + }, + required: ['period', 'protocols'], + }, + StellarMetricsResponse: { + type: 'object', + properties: { + success: { type: 'boolean' }, + data: { type: 'object' }, + }, + required: ['success', 'data'], + }, + AdminEnvelope: { + type: 'object', + properties: { + success: { type: 'boolean' }, + data: { type: 'object' }, + message: { type: 'string' }, + error: { type: 'string' }, + timestamp: { type: 'string', format: 'date-time' }, + }, + required: ['success', 'data', 'timestamp'], + }, + }, + }, + paths: { + '/api/agent/status': { + get: { + tags: ['Agent'], + summary: 'Agent status', + responses: { + 200: jsonResponse(ref('AgentStatusResponse')), + 500: jsonResponse(ref('ErrorResponse'), 'Internal server error'), + }, + }, + }, + '/api/auth/challenge': { + post: { + tags: ['Auth'], + summary: 'Create challenge nonce', + requestBody: { + required: true, + content: { + 'application/json': { + schema: ref('AuthChallengeRequest'), + }, + }, + }, + responses: { + 200: jsonResponse(ref('AuthChallengeResponse')), + 400: jsonResponse(ref('ErrorResponse'), 'Bad request'), + }, + }, + }, + '/api/auth/verify': { + post: { + tags: ['Auth'], + summary: 'Verify Stellar signature', + requestBody: { + required: true, + content: { + 'application/json': { + schema: ref('AuthVerifyRequest'), + }, + }, + }, + responses: { + 200: jsonResponse(ref('AuthVerifyResponse')), + 400: jsonResponse(ref('ErrorResponse'), 'Bad request'), + 401: jsonResponse(ref('ErrorResponse'), 'Unauthorized'), + 500: jsonResponse(ref('ErrorResponse'), 'Internal server error'), + }, + }, + }, + '/api/auth/logout': { + post: { + tags: ['Auth'], + summary: 'Revoke current session', + security: bearerSecurity, + responses: { + 200: jsonResponse(ref('LogoutResponse')), + 401: jsonResponse(ref('ErrorResponse'), 'Unauthorized'), + 500: jsonResponse(ref('ErrorResponse'), 'Internal server error'), + }, + }, + }, + '/api/whatsapp/webhook': { + get: { + tags: ['WhatsApp'], + summary: 'Twilio webhook liveness', + responses: { + 200: textResponse('text/plain', { type: 'string', example: 'WhatsApp webhook is alive' }), + }, + }, + post: { + tags: ['WhatsApp'], + summary: 'Receive WhatsApp webhook', + requestBody: { + required: false, + content: { + 'application/x-www-form-urlencoded': { + schema: { + type: 'object', + properties: { + From: { type: 'string' }, + Body: { type: 'string' }, + }, + }, + }, + }, + }, + responses: { + 200: textResponse('text/xml', { type: 'string', example: 'ok' }), + 403: jsonResponse(ref('ErrorResponse'), 'Forbidden'), + }, + }, + }, + '/api/portfolio/{userId}': { + get: { + tags: ['Portfolio'], + security: bearerSecurity, + parameters: [ + { + name: 'userId', + in: 'path', + required: true, + schema: { type: 'string', format: 'uuid' }, + }, + ], + responses: { + 200: jsonResponse(ref('PortfolioResponse')), + 401: jsonResponse(ref('ErrorResponse'), 'Unauthorized'), + 404: jsonResponse(ref('ErrorResponse'), 'Not found'), + }, + }, + }, + '/api/portfolio/{userId}/history': { + get: { + tags: ['Portfolio'], + security: bearerSecurity, + parameters: [ + { + name: 'userId', + in: 'path', + required: true, + schema: { type: 'string', format: 'uuid' }, + }, + { + name: 'period', + in: 'query', + required: false, + schema: { type: 'string', enum: ['7d', '30d', '90d'], default: '30d' }, + }, + ], + responses: { + 200: jsonResponse(ref('PortfolioHistoryResponse')), + 400: jsonResponse(ref('ValidationErrorResponse'), 'Validation error'), + 401: jsonResponse(ref('ErrorResponse'), 'Unauthorized'), + 404: jsonResponse(ref('ErrorResponse'), 'Not found'), + }, + }, + }, + '/api/portfolio/{userId}/earnings': { + get: { + tags: ['Portfolio'], + security: bearerSecurity, + parameters: [ + { + name: 'userId', + in: 'path', + required: true, + schema: { type: 'string', format: 'uuid' }, + }, + ], + responses: { + 200: jsonResponse(ref('PortfolioEarningsResponse')), + 401: jsonResponse(ref('ErrorResponse'), 'Unauthorized'), + 404: jsonResponse(ref('ErrorResponse'), 'Not found'), + }, + }, + }, + '/api/transactions/detail/{txHash}': { + get: { + tags: ['Transactions'], + security: bearerSecurity, + parameters: [ + { + name: 'txHash', + in: 'path', + required: true, + schema: { type: 'string' }, + }, + ], + responses: { + 200: jsonResponse(ref('TransactionDetailResponse')), + 401: jsonResponse(ref('ErrorResponse'), 'Unauthorized'), + 404: jsonResponse(ref('ErrorResponse'), 'Not found'), + }, + }, + }, + '/api/transactions/{userId}': { + get: { + tags: ['Transactions'], + security: bearerSecurity, + parameters: [ + { + name: 'userId', + in: 'path', + required: true, + schema: { type: 'string', format: 'uuid' }, + }, + { + name: 'page', + in: 'query', + required: false, + schema: { type: 'integer', minimum: 1, default: 1 }, + }, + { + name: 'limit', + in: 'query', + required: false, + schema: { type: 'integer', minimum: 1, maximum: 50, default: 5 }, + }, + ], + responses: { + 200: jsonResponse(ref('TransactionListResponse')), + 400: jsonResponse(ref('ValidationErrorResponse'), 'Validation error'), + 401: jsonResponse(ref('ErrorResponse'), 'Unauthorized'), + 404: jsonResponse(ref('ErrorResponse'), 'Not found'), + }, + }, + }, + '/api/protocols/rates': { + get: { + tags: ['Protocols'], + responses: { + 200: jsonResponse(ref('ProtocolRatesResponse')), + }, + }, + }, + '/api/protocols/agent/status': { + get: { + tags: ['Protocols'], + responses: { + 200: jsonResponse(ref('ProtocolAgentStatusResponse')), + 500: jsonResponse(ref('ErrorResponse'), 'Internal server error'), + }, + }, + }, + '/api/deposit': { + post: { + tags: ['Deposit'], + security: bearerSecurity, + requestBody: { + required: true, + content: { + 'application/json': { + schema: ref('TransactionMutationRequest'), + }, + }, + }, + responses: { + 201: jsonResponse(ref('TransactionMutationResponse'), 'Created'), + 400: jsonResponse(ref('ValidationErrorResponse'), 'Validation error'), + 401: jsonResponse(ref('ErrorResponse'), 'Unauthorized'), + 404: jsonResponse(ref('ErrorResponse'), 'Not found'), + 409: jsonResponse(ref('ErrorResponse'), 'Conflict'), + }, + }, + }, + '/api/withdraw': { + post: { + tags: ['Withdraw'], + security: bearerSecurity, + requestBody: { + required: true, + content: { + 'application/json': { + schema: ref('TransactionMutationRequest'), + }, + }, + }, + responses: { + 201: jsonResponse(ref('TransactionMutationResponse'), 'Created'), + 400: jsonResponse(ref('ValidationErrorResponse'), 'Validation error'), + 401: jsonResponse(ref('ErrorResponse'), 'Unauthorized'), + 404: jsonResponse(ref('ErrorResponse'), 'Not found'), + 409: jsonResponse(ref('ErrorResponse'), 'Conflict'), + }, + }, + }, + '/api/vault/state': { + get: { + tags: ['Vault'], + responses: { + 200: jsonResponse(ref('VaultStateResponse')), + }, + }, + }, + '/api/vault/balance': { + get: { + tags: ['Vault'], + security: bearerSecurity, + responses: { + 200: jsonResponse(ref('VaultBalanceResponse')), + 401: jsonResponse(ref('ErrorResponse'), 'Unauthorized'), + 404: jsonResponse(ref('ErrorResponse'), 'Not found'), + }, + }, + }, + '/api/vault/build-transaction': { + post: { + tags: ['Vault'], + security: bearerSecurity, + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + type: { type: 'string', enum: ['deposit', 'withdraw'] }, + amount: { type: 'number', exclusiveMinimum: 0 }, + assetSymbol: { type: 'string' }, + }, + required: ['type', 'amount', 'assetSymbol'], + }, + }, + }, + }, + responses: { + 200: jsonResponse(ref('VaultBuildTransactionResponse')), + 400: jsonResponse(ref('ValidationErrorResponse'), 'Validation error'), + 401: jsonResponse(ref('ErrorResponse'), 'Unauthorized'), + }, + }, + }, + '/api/analytics/apy-history': { + get: { + tags: ['Analytics'], + security: bearerSecurity, + parameters: [ + { + name: 'period', + in: 'query', + required: false, + schema: { type: 'string', enum: ['7d', '30d', '90d'], default: '30d' }, + }, + ], + responses: { + 200: jsonResponse(ref('AnalyticsApyHistoryResponse')), + 400: jsonResponse(ref('ValidationErrorResponse'), 'Validation error'), + }, + }, + }, + '/api/analytics/user-yield': { + get: { + tags: ['Analytics'], + security: bearerSecurity, + parameters: [ + { + name: 'period', + in: 'query', + required: false, + schema: { type: 'string', enum: ['7d', '30d', '90d'], default: '30d' }, + }, + ], + responses: { + 200: jsonResponse(ref('AnalyticsUserYieldResponse')), + 400: jsonResponse(ref('ValidationErrorResponse'), 'Validation error'), + }, + }, + }, + '/api/analytics/protocol-performance': { + get: { + tags: ['Analytics'], + parameters: [ + { + name: 'period', + in: 'query', + required: false, + schema: { type: 'string', enum: ['7d', '30d', '90d'], default: '30d' }, + }, + ], + responses: { + 200: jsonResponse(ref('AnalyticsProtocolPerformanceResponse')), + 400: jsonResponse(ref('ValidationErrorResponse'), 'Validation error'), + }, + }, + }, + '/api/stellar/metrics': { + get: { + tags: ['Stellar'], + responses: { + 200: jsonResponse(ref('StellarMetricsResponse')), + 500: jsonResponse(ref('ErrorResponse'), 'Internal server error'), + }, + }, + }, + '/api/admin/stellar/metrics': { + get: { + tags: ['Admin'], + security: adminSecurity, + responses: { + 200: jsonResponse(ref('AdminEnvelope')), + 403: jsonResponse(ref('ErrorResponse'), 'Forbidden'), + 500: jsonResponse(ref('ErrorResponse'), 'Internal server error'), + }, + }, + }, + '/api/admin/dlq/inspect': { + get: { + tags: ['Admin'], + security: adminSecurity, + parameters: [ + { + name: 'status', + in: 'query', + required: false, + schema: { type: 'string', enum: ['PENDING', 'RETRIED', 'RESOLVED'] }, + }, + { + name: 'limit', + in: 'query', + required: false, + schema: { type: 'integer', minimum: 1, maximum: 500, default: 50 }, + }, + ], + responses: { + 200: jsonResponse(ref('AdminEnvelope')), + 403: jsonResponse(ref('ErrorResponse'), 'Forbidden'), + 500: jsonResponse(ref('ErrorResponse'), 'Internal server error'), + }, + }, + }, + '/api/admin/dlq/retry': { + post: { + tags: ['Admin'], + security: adminSecurity, + requestBody: { + required: false, + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + dryRun: { type: 'boolean' }, + }, + }, + }, + }, + }, + responses: { + 200: jsonResponse(ref('AdminEnvelope')), + 400: jsonResponse(ref('ErrorResponse'), 'Bad request'), + 403: jsonResponse(ref('ErrorResponse'), 'Forbidden'), + 500: jsonResponse(ref('ErrorResponse'), 'Internal server error'), + }, + }, + }, + '/api/admin/dlq/resolve': { + post: { + tags: ['Admin'], + security: adminSecurity, + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + eventId: { type: 'string' }, + }, + required: ['eventId'], + }, + }, + }, + }, + responses: { + 200: jsonResponse(ref('AdminEnvelope')), + 400: jsonResponse(ref('ErrorResponse'), 'Bad request'), + 403: jsonResponse(ref('ErrorResponse'), 'Forbidden'), + 404: jsonResponse(ref('ErrorResponse'), 'Not found'), + 500: jsonResponse(ref('ErrorResponse'), 'Internal server error'), + }, + }, + }, + '/api/admin/stellar/backfill': { + post: { + tags: ['Admin'], + security: adminSecurity, + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + startLedger: { type: 'number' }, + endLedger: { type: 'number' }, + }, + required: ['startLedger'], + }, + }, + }, + }, + responses: { + 200: jsonResponse(ref('AdminEnvelope')), + 400: jsonResponse(ref('ErrorResponse'), 'Bad request'), + 403: jsonResponse(ref('ErrorResponse'), 'Forbidden'), + 500: jsonResponse(ref('ErrorResponse'), 'Internal server error'), + }, + }, + }, + }, +} as const + +export type OpenApiSpec = typeof openApiSpec diff --git a/src/routes/docs.ts b/src/routes/docs.ts new file mode 100644 index 0000000..d96c0dc --- /dev/null +++ b/src/routes/docs.ts @@ -0,0 +1,95 @@ +import { Router, Request, Response } from 'express' +import { AuthMiddleware } from '../middleware/authenticate' +import { openApiSpec } from '../openapi/spec' + +const router = Router() + +function isProduction(): boolean { + return process.env.NODE_ENV === 'production' +} + +function docsUnavailable(res: Response): Response { + return res.status(404).send('Not found') +} + +router.get('/openapi.json', (req: Request, res: Response) => { + if (isProduction()) { + return docsUnavailable(res) + } + + return AuthMiddleware.validateJwt(req, res, () => { + res.status(200).json(openApiSpec) + return undefined + }) +}) + +router.get('/docs', (_req: Request, res: Response) => { + if (isProduction()) { + return docsUnavailable(res) + } + + res.type('html').send(` + + + + + Neurowealth API Docs + + + + +
+

Neurowealth API Docs

+

Load the OpenAPI reference with a valid bearer token, then browse the available /api routes.

+
+
+ + + Token is stored in this browser's localStorage. +
+
+ + + +`) +}) + +export default router