chore: initial deploy bundle

This commit is contained in:
ZOMBIIIIIII
2026-04-20 17:39:38 +03:00
parent 5f7c098f0b
commit 9329b76e9b
16 changed files with 386 additions and 303 deletions

View File

@@ -7,6 +7,7 @@ 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 { errorHandler } from './middleware/error-handler';
import walletRoutes from './routes/wallet.routes';
import relayProxyRoutes from './routes/relay-proxy.routes';
@@ -19,12 +20,12 @@ import bscSwapProxyRoutes from './routes/bsc-swap-proxy.routes';
const app = express();
app.use(helmet());
app.use(cors({ origin: env.frontendUrl, credentials: true }));
app.use(cors({ origin: env.cors.origins, credentials: env.cors.allowCredentials }));
app.use(express.json());
app.use(cookieParser());
app.use(traceMiddleware);
// ── PUBLIC endpoints (no auth) ────────────────────────────────────────────────
// ── PUBLIC endpoints ─────────────────────────────────────────────────────────
app.get('/api/health', (_req, res) => {
res.json({ success: true, data: { status: 'ok' } });
});
@@ -34,14 +35,16 @@ app.get('/api/docs/swagger.json', (_req, res) => {
res.json(swaggerSpec);
});
// ── PROTECTED endpoints (JWT required) ────────────────────────────────────────
app.use('/api/wallets', authMiddleware, walletRoutes);
app.use('/api/relay', authMiddleware, relayProxyRoutes);
app.use('/api/tron', authMiddleware, tronProxyRoutes);
app.use('/api/sol/swap', authMiddleware, solSwapProxyRoutes);
app.use('/api/tron/swap', authMiddleware, tronSwapProxyRoutes);
app.use('/api/btc', authMiddleware, btcProxyRoutes);
app.use('/api/bsc/swap', authMiddleware, bscSwapProxyRoutes);
// ── PROTECTED endpoints (JWT + CSRF for mutating methods) ────────────────────
const protect = [authMiddleware, csrfMiddleware];
app.use('/api/wallets', ...protect, walletRoutes);
app.use('/api/relay', ...protect, relayProxyRoutes);
app.use('/api/tron', ...protect, tronProxyRoutes);
app.use('/api/sol/swap', ...protect, solSwapProxyRoutes);
app.use('/api/tron/swap', ...protect, tronSwapProxyRoutes);
app.use('/api/btc', ...protect, btcProxyRoutes);
app.use('/api/bsc/swap', ...protect, bscSwapProxyRoutes);
app.use(errorHandler);

View File

@@ -9,23 +9,16 @@ const p = process.env;
export let env = {
db: {
host: p.DB_HOST || 'localhost',
host: p.DB_HOST || '',
port: parseInt(p.DB_PORT || '5432'),
user: p.DB_USER || 'postgres',
password: p.DB_PASSWORD || 'postgres',
name: p.DB_NAME || 'cryptowallet_v2',
poolSize: parseInt(p.DATABASE_POOL_SIZE || '10'),
maxOverflow: parseInt(p.DATABASE_MAX_OVERFLOW || '20'),
poolTimeout: parseInt(p.DATABASE_POOL_TIMEOUT || '30'),
poolRecycle: parseInt(p.DATABASE_POOL_RECYCLE || '3600'),
echo: p.DATABASE_ECHO === 'true',
user: p.DB_USER || '',
password: p.DB_PASSWORD || '',
name: p.DB_NAME || '',
},
jwt: {
algorithm: p.JWT_ALGORITHM || 'RS256',
issuer: p.JWT_ISSUER || 'auth-service',
audience: p.JWT_AUDIENCE || 'bitforce',
accessTtl: parseInt(p.JWT_ACCESS_TTL_SECONDS || '900'),
refreshTtl: parseInt(p.JWT_REFRESH_TTL_SECONDS || '2592000'),
audience: p.JWT_AUDIENCE || 'elcsa',
},
vault: {
addr: p.VAULT_ADDR || '',
@@ -35,43 +28,13 @@ export let env = {
secretPath: p.VAULT_SECRET_PATH || 'database',
jwtKidPath: p.VAULT_JWT_KID_PATH || 'jwt/kid',
jwtKidsPrefix: p.VAULT_JWT_KIDS_PREFIX || 'jwt/kids',
},
csrf: {
cookieSecure: p.CSRF_COOKIE_SECURE === 'true',
cookieHttpOnly: p.CSRF_COOKIE_HTTPONLY !== 'false',
cookieSameSite: p.CSRF_COOKIE_SAMESITE || 'Lax',
cookiePath: p.CSRF_COOKIE_PATH || '/',
cookieDomain: p.CSRF_COOKIE_DOMAIN || '',
},
docs: {
username: p.DOCS_USERNAME || 'admin',
password: p.DOCS_PASSWORD || 'admin',
},
redis: {
host: p.REDIS_HOST || 'keydb',
port: parseInt(p.REDIS_PORT || '6379'),
password: p.REDIS_PASSWORD || 'keydb',
db: parseInt(p.REDIS_DB || '0'),
},
rabbit: {
emailCodeQueue: p.RABBIT_EMAIL_CODE_QUEUE || 'email.verification_code',
publishPersist: p.RABBIT_PUBLISH_PERSIST !== 'false',
connectTimeout: parseInt(p.RABBIT_CONNECT_TIMEOUT || '5'),
},
log: {
level: p.LOG_LEVEL || 'INFO',
format: p.LOG_FORMAT || 'JSON',
csrfPath: p.VAULT_CSRF_PATH || 'csrf',
},
cors: {
origins: (p.CORS_ORIGINS || 'http://localhost:3000').split(','),
allowCredentials: p.CORS_ALLOW_CREDENTIALS !== 'false',
},
rateLimit: {
requests: parseInt(p.RATE_LIMIT_REQUESTS || '60'),
window: parseInt(p.RATE_LIMIT_WINDOW || '60'),
},
port: parseInt(p.API_PORT || '3001'),
frontendUrl: p.FRONTEND_URL || 'http://localhost:3000',
relayApiKey: p.RELAY_API_KEY || null,
tronApiKey: p.TRON_API_KEY || null,
jupiterApiKey: p.JUPITER_API_KEY || null,
@@ -116,41 +79,26 @@ export async function initEnv(): Promise<void> {
return v ? parseInt(v) : fallback;
};
// Vault stores DB secrets in lowercase (host, user, password, name, port).
// Accept uppercase DB_* as fallback for compatibility.
env = {
...env,
db: {
host: s('DB_HOST') || env.db.host,
port: si('DB_PORT', env.db.port),
user: s('DB_USER') || env.db.user,
password: s('DB_PASSWORD') || env.db.password,
name: s('DB_NAME') || env.db.name,
poolSize: si('DATABASE_POOL_SIZE', env.db.poolSize),
maxOverflow: si('DATABASE_MAX_OVERFLOW', env.db.maxOverflow),
poolTimeout: si('DATABASE_POOL_TIMEOUT', env.db.poolTimeout),
poolRecycle: si('DATABASE_POOL_RECYCLE', env.db.poolRecycle),
echo: secrets['DATABASE_ECHO'] === 'true',
host: s('host') || s('DB_HOST') || env.db.host,
port: si('port', si('DB_PORT', env.db.port)),
user: s('user') || s('DB_USER') || env.db.user,
password: s('password') || s('DB_PASSWORD') || env.db.password,
name: s('name') || s('DB_NAME') || env.db.name,
},
jwt: {
...env.jwt,
issuer: s('JWT_ISSUER') || env.jwt.issuer,
audience: s('JWT_AUDIENCE') || env.jwt.audience,
accessTtl: si('JWT_ACCESS_TTL_SECONDS', env.jwt.accessTtl),
refreshTtl: si('JWT_REFRESH_TTL_SECONDS', env.jwt.refreshTtl),
},
redis: {
host: s('REDIS_HOST') || env.redis.host,
port: si('REDIS_PORT', env.redis.port),
password: s('REDIS_PASSWORD') || env.redis.password,
db: si('REDIS_DB', env.redis.db),
},
cors: {
origins: s('CORS_ORIGINS') ? s('CORS_ORIGINS')!.split(',') : env.cors.origins,
allowCredentials: secrets['CORS_ALLOW_CREDENTIALS'] !== 'false',
},
rateLimit: {
requests: si('RATE_LIMIT_REQUESTS', env.rateLimit.requests),
window: si('RATE_LIMIT_WINDOW', env.rateLimit.window),
},
relayApiKey: s('RELAY_API_KEY') || env.relayApiKey,
tronApiKey: s('TRON_API_KEY') || env.tronApiKey,
jupiterApiKey: s('JUPITER_API_KEY') || env.jupiterApiKey,

View File

@@ -1,23 +0,0 @@
import type { Knex } from 'knex';
import path from 'path';
import dotenv from 'dotenv';
// Load .env from repo root when running migrations directly
dotenv.config({ path: path.resolve(__dirname, '../../../../.env') });
const config: Knex.Config = {
client: 'pg',
connection: {
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432'),
user: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || 'postgres',
database: process.env.DB_NAME || 'cryptowallet_v2',
},
migrations: {
directory: path.resolve(__dirname, 'migrations'),
extension: __filename.endsWith('.js') ? 'js' : 'ts',
},
};
export default config;

View File

@@ -1,28 +0,0 @@
import type { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
await knex.schema.createTable('users', (t) => {
t.string('id', 26).primary();
t.string('email', 255).notNullable().unique();
t.string('password_hash', 255).notNullable();
t.string('last_name', 128).nullable();
t.string('first_name', 128).nullable();
t.string('middle_name', 128).nullable();
t.date('birth_date').nullable();
t.string('crypto_wallet', 255).nullable();
t.string('phone', 16).nullable();
t.string('bik', 9).nullable();
t.string('account_number', 20).nullable();
t.string('card_number', 19).nullable();
t.string('inn', 12).nullable();
t.boolean('kyc_verified').notNullable().defaultTo(false);
t.timestamp('kyc_verified_at', { useTz: true }).nullable();
t.boolean('is_deleted').notNullable().defaultTo(false);
t.timestamp('created_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
t.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
});
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists('users');
}

View File

@@ -1,20 +0,0 @@
import type { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
await knex.schema.createTable('wallets', (t) => {
t.string('id', 26).primary();
t.string('user_id', 26).notNullable().references('id').inTable('users').onDelete('CASCADE');
t.string('chain', 10).notNullable();
t.string('address', 256).notNullable();
t.string('derivation_path', 64).notNullable();
t.timestamp('created_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
t.unique(['user_id', 'chain']);
});
await knex.schema.raw('CREATE INDEX idx_wallets_user_id ON wallets(user_id)');
await knex.schema.raw('CREATE INDEX idx_wallets_address ON wallets(address)');
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists('wallets');
}

View File

@@ -1,26 +0,0 @@
import type { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
await knex.schema.createTable('sessions', (t) => {
t.string('id', 26).primary();
t.string('sid', 26).notNullable().unique();
t.string('user_id', 26).notNullable().references('id').inTable('users').onDelete('CASCADE');
t.string('device_id', 26).nullable();
t.string('user_agent', 500).nullable();
t.string('first_ip', 64).nullable();
t.string('last_ip', 64).nullable();
t.timestamp('last_seen_at', { useTz: true }).nullable();
t.timestamp('revoked_at', { useTz: true }).nullable();
t.string('refresh_jti_hash', 255).nullable();
t.timestamp('refresh_expires_at', { useTz: true }).nullable();
t.timestamp('created_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
t.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
});
await knex.schema.raw('CREATE INDEX idx_sessions_user_id ON sessions(user_id)');
await knex.schema.raw('CREATE INDEX idx_sessions_sid ON sessions(sid)');
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists('sessions');
}

View File

@@ -1,40 +1,29 @@
import knex from 'knex';
import knexConfig from './db/knexfile';
import app from './app';
import { env, initEnv, getVaultToken } from './config/env';
import { loadJwtKeysFromVault } from './services/jwt.service';
import { env, initEnv } from './config/env';
import { refreshAllKeys, startKeyRotation, stopKeyRotation } from './services/key-rotation.service';
import { logger } from './lib/logger';
async function main() {
logger.info(`Wallet service instance started with id ${logger.instanceId}`);
await initEnv();
await refreshAllKeys();
startKeyRotation();
// Load JWT public keys from Vault if available
const vaultToken = getVaultToken();
if (vaultToken && env.vault.addr) {
await loadJwtKeysFromVault(
env.vault.addr,
vaultToken,
env.vault.mount,
env.vault.jwtKidPath,
env.vault.jwtKidsPrefix,
);
} else {
logger.warn('JWT keys not loaded: Vault not available');
}
const db = knex(knexConfig);
logger.info('Running migrations...');
await db.migrate.latest();
logger.info('Migrations complete');
await db.destroy();
app.listen(env.port, () => {
const server = app.listen(env.port, () => {
logger.info(`Server running on port ${env.port}`);
});
const shutdown = (signal: string) => {
logger.info(`${signal} received, shutting down gracefully`);
stopKeyRotation();
server.close(() => process.exit(0));
// Force exit if shutdown takes too long
setTimeout(() => process.exit(1), 10_000).unref();
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
}
main().catch((err) => {

View File

@@ -0,0 +1,36 @@
import { Request, Response, NextFunction } from 'express';
import { verifyCsrfToken, isCsrfConfigured } from '../services/csrf.service';
import { logger } from '../lib/logger';
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
export function csrfMiddleware(req: Request, res: Response, next: NextFunction): void {
if (SAFE_METHODS.has(req.method)) {
next();
return;
}
// If CSRF is not configured (Vault down при старте) — пропускаем, чтобы не блокировать сервис.
// В логах будет warning — легко заметить.
if (!isCsrfConfigured()) {
logger.warn('CSRF check skipped: secret not loaded');
next();
return;
}
const token = req.cookies?.csrf_token || req.headers['x-csrf-token'];
if (!token || typeof token !== 'string') {
res.status(403).json({ success: false, error: 'CSRF token missing' });
return;
}
const result = verifyCsrfToken(token);
if (!result.valid) {
logger.warn(`CSRF validation failed: ${result.reason}`);
res.status(403).json({ success: false, error: 'Invalid CSRF token' });
return;
}
next();
}

View File

@@ -1,17 +0,0 @@
import { Request, Response, NextFunction } from 'express';
import { ZodSchema } from 'zod';
export function validate(schema: ZodSchema) {
return (req: Request, res: Response, next: NextFunction): void => {
const result = schema.safeParse(req.body);
if (!result.success) {
res.status(400).json({
success: false,
error: result.error.errors.map((e) => e.message).join(', '),
});
return;
}
req.body = result.data;
next();
};
}

View File

@@ -0,0 +1,130 @@
import crypto from 'crypto';
import { logger } from '../lib/logger';
/**
* CSRF token validation compatible with Python's `itsdangerous`
* `URLSafeTimedSerializer` (which Flask-WTF uses).
*
* Token format: <b64url_payload>.<b64url_timestamp>.<b64url_signature>
*
* Default algorithm (itsdangerous ≥ 2.0):
* - digest: SHA-512 (HMAC)
* - salt: "itsdangerous.Signer" (or app-specific, e.g. "csrf-token")
* - derived_key = HMAC(secret, salt + "signer").digest()
* - signature = HMAC(derived_key, payload + "." + timestamp).digest()
*
* Timestamp: seconds since 2011-01-01 (itsdangerous epoch), base64url-encoded big-endian.
*/
const ITSDANGEROUS_EPOCH = 1293840000; // 2011-01-01 UTC in unix time
let csrfSecret: string | null = null;
let csrfSalt = 'itsdangerous.Signer';
let csrfDigest: 'sha1' | 'sha256' | 'sha512' = 'sha512';
let csrfMaxAgeSec = 60 * 60 * 24 * 7; // 7 days
export async function loadCsrfSecret(
addr: string,
token: string,
mount: string,
path: string,
): Promise<void> {
const { fetchVaultKV2 } = await import('../config/vault');
const secrets = await fetchVaultKV2(addr, token, mount, path);
if (!secrets) {
logger.warn('Failed to load CSRF secret from Vault');
return;
}
const secret = secrets.secret_key || secrets.SECRET_KEY || secrets.key || secrets.csrf_secret;
if (!secret) {
logger.warn('CSRF secret not found in Vault payload (expected key: secret_key)');
return;
}
csrfSecret = secret;
if (secrets.salt) csrfSalt = secrets.salt;
if (secrets.digest === 'sha1' || secrets.digest === 'sha256' || secrets.digest === 'sha512') {
csrfDigest = secrets.digest as 'sha1' | 'sha256' | 'sha512';
}
if (secrets.max_age_sec) {
const n = parseInt(secrets.max_age_sec);
if (!Number.isNaN(n) && n > 0) csrfMaxAgeSec = n;
}
logger.info(`CSRF secret loaded (digest=${csrfDigest}, salt="${csrfSalt}", max_age=${csrfMaxAgeSec}s)`);
}
export function isCsrfConfigured(): boolean {
return csrfSecret !== null;
}
function b64urlDecode(s: string): Buffer {
// itsdangerous strips padding
const pad = s.length % 4 === 0 ? 0 : 4 - (s.length % 4);
const padded = s + '='.repeat(pad);
return Buffer.from(padded.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
}
function deriveKey(secret: string, salt: string, digest: string): Buffer {
// itsdangerous `Signer.derive_key`: HMAC(secret, salt + "signer")
return crypto.createHmac(digest, secret).update(salt + 'signer').digest();
}
function decodeTimestamp(encoded: string): number {
const raw = b64urlDecode(encoded);
let ts = 0;
for (const b of raw) ts = (ts << 8) | b;
return ts + ITSDANGEROUS_EPOCH;
}
export interface CsrfVerifyResult {
valid: boolean;
reason?: string;
}
export function verifyCsrfToken(token: string): CsrfVerifyResult {
if (!csrfSecret) return { valid: false, reason: 'CSRF secret not loaded' };
if (!token || typeof token !== 'string') return { valid: false, reason: 'Empty token' };
const lastDot = token.lastIndexOf('.');
if (lastDot < 0) return { valid: false, reason: 'Malformed token (no signature)' };
const payloadTs = token.slice(0, lastDot); // "<payload>.<timestamp>"
const sigStr = token.slice(lastDot + 1);
const prevDot = payloadTs.lastIndexOf('.');
if (prevDot < 0) return { valid: false, reason: 'Malformed token (no timestamp)' };
const tsStr = payloadTs.slice(prevDot + 1);
const derived = deriveKey(csrfSecret, csrfSalt, csrfDigest);
const expectedSig = crypto.createHmac(csrfDigest, derived).update(payloadTs).digest();
let actualSig: Buffer;
try {
actualSig = b64urlDecode(sigStr);
} catch {
return { valid: false, reason: 'Invalid signature encoding' };
}
if (expectedSig.length !== actualSig.length) {
return { valid: false, reason: 'Signature length mismatch' };
}
if (!crypto.timingSafeEqual(expectedSig, actualSig)) {
return { valid: false, reason: 'Signature mismatch' };
}
// Timestamp check
try {
const issuedAt = decodeTimestamp(tsStr);
const now = Math.floor(Date.now() / 1000);
if (issuedAt > now + 60) return { valid: false, reason: 'Token from the future' };
if (now - issuedAt > csrfMaxAgeSec) return { valid: false, reason: 'Token expired' };
} catch {
return { valid: false, reason: 'Invalid timestamp' };
}
return { valid: true };
}

View File

@@ -0,0 +1,70 @@
import { env, getVaultToken } from '../config/env';
import { vaultAppRoleLogin } from '../config/vault';
import { loadJwtKeysFromVault } from './jwt.service';
import { loadCsrfSecret } from './csrf.service';
import { logger } from '../lib/logger';
const DEFAULT_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
let timer: NodeJS.Timeout | null = null;
let currentVaultToken: string | null = null;
/**
* Refresh JWT public keys (active + previous) and CSRF secret from Vault.
* Errors are logged but do NOT throw — старые значения остаются в памяти,
* сервис продолжает работать до следующего успешного refresh.
*/
export async function refreshAllKeys(): Promise<void> {
const { addr, roleId, secretId, mount, jwtKidPath, jwtKidsPrefix, csrfPath } = env.vault;
if (!addr || !roleId || !secretId) {
logger.warn('Vault not configured, skipping key refresh');
return;
}
// Use token from initEnv first call; re-login only if we don't have one yet.
let token = currentVaultToken || getVaultToken();
if (!token) {
const fresh = await vaultAppRoleLogin(addr, roleId, secretId);
if (!fresh) {
logger.error('Key refresh: Vault AppRole login failed');
return;
}
token = fresh;
currentVaultToken = fresh;
}
try {
await loadJwtKeysFromVault(addr, token, mount, jwtKidPath, jwtKidsPrefix);
} catch (err: any) {
logger.error(`Failed to refresh JWT keys: ${err.message}`);
}
try {
await loadCsrfSecret(addr, token, mount, csrfPath);
} catch (err: any) {
logger.error(`Failed to refresh CSRF secret: ${err.message}`);
}
}
export function startKeyRotation(intervalMs: number = DEFAULT_INTERVAL_MS): void {
if (timer) return;
timer = setInterval(() => {
logger.info('Refreshing keys from Vault...');
void refreshAllKeys().catch((err) =>
logger.error(`Key rotation tick failed: ${err?.message || err}`)
);
// On token expiry Vault will return 403 — we need to re-login.
// Reset cached token so refreshAllKeys re-logs in on next call.
currentVaultToken = null;
}, intervalMs);
logger.info(`Key rotation scheduled (every ${intervalMs}ms)`);
}
export function stopKeyRotation(): void {
if (timer) {
clearInterval(timer);
timer = null;
logger.info('Key rotation stopped');
}
}