Files
cryptowallet/apps/api/src/app.ts
ZOMBIIIIIII e86ff7c063 init
2026-05-28 13:51:30 +03:00

134 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;