import express from 'express'; import helmet from 'helmet'; import cors from 'cors'; import cookieParser from 'cookie-parser'; import swaggerUi from 'swagger-ui-express'; import { env } from './config/env'; import { swaggerSpec } from './config/swagger'; import { traceMiddleware } from './middleware/trace'; import { authMiddleware } from './middleware/auth'; import { csrfMiddleware } from './middleware/csrf'; import { globalLimiter, mutateLimiter, sensitiveLimiter, mnemonicRevealLimiter } from './middleware/rate-limit'; import { errorHandler } from './middleware/error-handler'; import walletRoutes from './routes/wallet.routes'; import relayProxyRoutes from './routes/relay-proxy.routes'; import jumperProxyRoutes from './routes/jumper-proxy.routes'; import tronProxyRoutes from './routes/tron-proxy.routes'; import btcProxyRoutes from './routes/btc-proxy.routes'; import pricesRoutes from './routes/prices.routes'; import tokensRoutes from './routes/tokens.routes'; import bridgeRoutes from './routes/bridge.routes'; const app = express(); // Trust proxy для корректного req.ip за reverse proxy / load balancer app.set('trust proxy', 1); app.use(helmet()); // CORS — поддерживаем 3 режима: // 1. wildcard ['*'] — любой origin (для dev/staging); credentials force=false (browser spec) // 2. whitelist [a, b, c] — только эти origins // 3. пустой массив — все cross-origin blocked (fail-secure default) const corsOrigins = env.cors.origins; const corsIsWildcard = corsOrigins.length === 1 && corsOrigins[0] === '*'; if (corsIsWildcard) { // eslint-disable-next-line no-console console.warn('[CORS] WILDCARD enabled (CORS_ORIGINS=*) — any origin can call API. Use only for dev/staging. Production: use explicit whitelist.'); } app.use( cors({ origin: corsIsWildcard ? '*' : (corsOrigins.length > 0 ? corsOrigins : false), // Wildcard incompatible с credentials per browser spec — force false при wildcard. credentials: corsIsWildcard ? false : env.cors.allowCredentials, }), ); app.use(express.json({ limit: '64kb' })); // защита от больших payload-DoS app.use(cookieParser()); app.use(traceMiddleware); // ── PUBLIC endpoints ───────────────────────────────────────────────────────── // H11 — /api/health with DB probe (не возвращает OK если DB down) import { db } from './config/database'; app.get('/api/health', async (_req, res) => { try { await Promise.race([ db.raw('select 1'), new Promise((_, reject) => setTimeout(() => reject(new Error('db-timeout')), 1000)), ]); res.json({ success: true, data: { status: 'ok' } }); } catch (err: any) { res.status(503).json({ success: false, error: 'db_unavailable' }); } }); // ── Глобальный rate limit на /api/* — ДО docs чтобы не было unauthenticated DoS на swagger.json app.use('/api', globalLimiter); // H1 — Swagger gated. В production требуется basic-auth ИЛИ NODE_ENV != production. // JSON endpoint ОБЯЗАТЕЛЬНО до app.use('/api/docs', ...) — иначе swagger-ui-express // перехватывает все /api/docs/* и возвращает HTML вместо JSON. const docsGate = (req: express.Request, res: express.Response, next: express.NextFunction) => { if (process.env.NODE_ENV !== 'production' || process.env.SWAGGER_PUBLIC === 'true') { return next(); } // Production без SWAGGER_PUBLIC=true → require basic auth (operator credentials) const auth = req.headers.authorization || ''; const expected = process.env.SWAGGER_BASIC_AUTH; // "user:pass" if (!expected || !auth.startsWith('Basic ')) { res.set('WWW-Authenticate', 'Basic realm="docs"'); res.status(401).json({ success: false, error: 'Docs auth required' }); return; } const decoded = Buffer.from(auth.slice(6), 'base64').toString('utf8'); if (decoded !== expected) { res.set('WWW-Authenticate', 'Basic realm="docs"'); res.status(401).json({ success: false, error: 'Invalid docs credentials' }); return; } return next(); }; app.get('/api/docs/swagger.json', docsGate, (_req, res) => { res.json(swaggerSpec); }); app.use('/api/docs', docsGate, swaggerUi.serve, swaggerUi.setup(swaggerSpec)); // ── PROTECTED endpoints (JWT + CSRF) ───────────────────────────────────────── const protect = [authMiddleware, csrfMiddleware]; // Sensitive — самый строгий лимит. Каждый POST защищён JWT + CSRF. app.use('/api/wallets/create', ...protect, sensitiveLimiter); app.use('/api/wallets/mnemonic/reveal', ...protect, mnemonicRevealLimiter); app.use('/api/wallets/:chain/send', ...protect, sensitiveLimiter); // Mutating (proxy + read endpoints) — повышенный лимит app.use('/api/wallets', ...protect, mutateLimiter, walletRoutes); app.use('/api/relay', ...protect, mutateLimiter, relayProxyRoutes); // Jumper.xyz — LiFi-backed bridge aggregator (50+ chains: ETH/BSC/SOL/TRX/BTC + others). // Используется когда Relay не поддерживает направление (TRX/BTC bridges). app.use('/api/jumper', ...protect, mutateLimiter, jumperProxyRoutes); app.use('/api/tron', ...protect, mutateLimiter, tronProxyRoutes); app.use('/api/btc', ...protect, mutateLimiter, btcProxyRoutes); // Legacy non-custodial swap proxies (/api/bsc/swap, /api/sol/swap, /api/tron/swap) // УДАЛЕНЫ. Custodial 2-step swap живёт под /api/wallets/{chain}/swap{,/quote}. // USD-цены (CoinGecko + KeyDB cache). GET-only, auth required, max 50 symbols. app.use('/api/prices', ...protect, mutateLimiter, pricesRoutes); // Token registry — всех известных contracts/mints по всем chain'ам. GET-only, auth required. app.use('/api/tokens', ...protect, mutateLimiter, tokensRoutes); // Bridge execute — one-click "Подтвердить" для bridge через Jumper (LiFi) / Relay. // Dispatcher по source chain: EVM (approve+fee+bridge) / SOL (versioned tx) / TRX (TRC20 approve+bridge) / BTC (PSBT deposit). // Sign + broadcast custodial через server (mnemonic не покидает API). app.use('/api/bridge', ...protect, mutateLimiter, bridgeRoutes); // 404 для всего что не сматчилось выше — единый JSON-ответ, не express default text app.use((_req, res) => { res.status(404).json({ success: false, error: 'Not found' }); }); app.use(errorHandler); export default app;