chore: initial deploy bundle
This commit is contained in:
79
.env.example
79
.env.example
@@ -1,20 +1,4 @@
|
|||||||
# PostgreSQL
|
# ── Vault (AppRole) ────────────────────────────────────────────────
|
||||||
# Для локального dev: DB_HOST=localhost
|
|
||||||
# Для Docker Compose: DB_HOST переопределяется на 'postgres' в docker-compose.yml
|
|
||||||
DB_HOST=localhost
|
|
||||||
DB_PORT=5432
|
|
||||||
DB_USER=postgres
|
|
||||||
DB_PASSWORD=postgres
|
|
||||||
DB_NAME=cryptowallet_v2
|
|
||||||
|
|
||||||
# Database Pool
|
|
||||||
DATABASE_POOL_SIZE=10
|
|
||||||
DATABASE_MAX_OVERFLOW=20
|
|
||||||
DATABASE_POOL_TIMEOUT=30
|
|
||||||
DATABASE_POOL_RECYCLE=3600
|
|
||||||
DATABASE_ECHO=false
|
|
||||||
|
|
||||||
# Vault (AppRole auth)
|
|
||||||
VAULT_ADDR=
|
VAULT_ADDR=
|
||||||
VAULT_ROLE_ID=
|
VAULT_ROLE_ID=
|
||||||
VAULT_SECRET_ID=
|
VAULT_SECRET_ID=
|
||||||
@@ -22,55 +6,28 @@ VAULT_MOUNT_POINT=dev-secrets
|
|||||||
VAULT_SECRET_PATH=database
|
VAULT_SECRET_PATH=database
|
||||||
VAULT_JWT_KID_PATH=jwt/kid
|
VAULT_JWT_KID_PATH=jwt/kid
|
||||||
VAULT_JWT_KIDS_PREFIX=jwt/kids
|
VAULT_JWT_KIDS_PREFIX=jwt/kids
|
||||||
|
VAULT_CSRF_PATH=csrf
|
||||||
|
|
||||||
# CSRF
|
# ── JWT ────────────────────────────────────────────────────────────
|
||||||
CSRF_COOKIE_SECURE=false
|
|
||||||
CSRF_COOKIE_HTTPONLY=true
|
|
||||||
CSRF_COOKIE_SAMESITE=Lax
|
|
||||||
CSRF_COOKIE_PATH=/
|
|
||||||
CSRF_COOKIE_DOMAIN=
|
|
||||||
|
|
||||||
# JWT
|
|
||||||
JWT_ALGORITHM=RS256
|
JWT_ALGORITHM=RS256
|
||||||
JWT_ACCESS_TTL_SECONDS=900
|
|
||||||
JWT_REFRESH_TTL_SECONDS=2592000
|
|
||||||
JWT_ISSUER=auth-service
|
JWT_ISSUER=auth-service
|
||||||
JWT_AUDIENCE=wallet-service
|
JWT_AUDIENCE=elcsa
|
||||||
|
|
||||||
# Docs
|
# ── Server ─────────────────────────────────────────────────────────
|
||||||
DOCS_USERNAME=admin
|
|
||||||
DOCS_PASSWORD=admin
|
|
||||||
|
|
||||||
# Redis
|
|
||||||
REDIS_HOST=keydb
|
|
||||||
REDIS_PORT=6379
|
|
||||||
REDIS_PASSWORD=keydb
|
|
||||||
REDIS_DB=0
|
|
||||||
|
|
||||||
# RabbitMQ
|
|
||||||
RABBIT_EMAIL_CODE_QUEUE=email.verification_code
|
|
||||||
RABBIT_PUBLISH_PERSIST=true
|
|
||||||
RABBIT_CONNECT_TIMEOUT=5
|
|
||||||
|
|
||||||
# Logging
|
|
||||||
LOG_LEVEL=INFO
|
|
||||||
LOG_FORMAT=JSON
|
|
||||||
|
|
||||||
# CORS
|
|
||||||
CORS_ORIGINS=http://localhost:3000,http://localhost:8000
|
|
||||||
CORS_ALLOW_CREDENTIALS=true
|
|
||||||
|
|
||||||
# Rate Limiting
|
|
||||||
RATE_LIMIT_REQUESTS=60
|
|
||||||
RATE_LIMIT_WINDOW=60
|
|
||||||
|
|
||||||
# Server
|
|
||||||
API_PORT=3001
|
API_PORT=3001
|
||||||
FRONTEND_URL=http://localhost:3000
|
CORS_ORIGINS=http://localhost:3000
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# ── External API keys (fallback, обычно приходят из Vault) ─────────
|
||||||
RELAY_API_KEY=
|
RELAY_API_KEY=
|
||||||
|
|
||||||
# TRON
|
|
||||||
TRON_API_KEY=
|
TRON_API_KEY=
|
||||||
|
|
||||||
# Jupiter (Solana DEX aggregator)
|
|
||||||
JUPITER_API_KEY=
|
JUPITER_API_KEY=
|
||||||
|
JUPITER_REFERRAL_ACCOUNT=
|
||||||
|
JUPITER_FEE_BPS=70
|
||||||
|
|
||||||
|
# ── DB fallback (используется если Vault недоступен при старте) ────
|
||||||
|
DB_HOST=
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_USER=
|
||||||
|
DB_PASSWORD=
|
||||||
|
DB_NAME=
|
||||||
|
|||||||
40
README.md
40
README.md
@@ -7,10 +7,12 @@
|
|||||||
```
|
```
|
||||||
deployserver/
|
deployserver/
|
||||||
├── Dockerfile # Multi-stage production build
|
├── Dockerfile # Multi-stage production build
|
||||||
├── docker-compose.yml # PostgreSQL + API
|
├── docker-compose.yml # Только API (БД внешняя, из Vault)
|
||||||
├── .env.example # Шаблон переменных окружения
|
├── .env.example # Шаблон переменных окружения
|
||||||
├── .dockerignore
|
├── .dockerignore
|
||||||
├── start.sh # Автоматический deploy скрипт
|
├── start.sh # Автоматический deploy скрипт
|
||||||
|
├── db/
|
||||||
|
│ └── schema.sql # DDL таблиц (users, wallets, sessions) — идемпотентный
|
||||||
├── apps/api/ # Исходник API
|
├── apps/api/ # Исходник API
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ ├── package.json
|
│ ├── package.json
|
||||||
@@ -27,11 +29,12 @@ deployserver/
|
|||||||
- Ubuntu 20.04+ / Debian 11+ / любой Linux с Docker 24+
|
- Ubuntu 20.04+ / Debian 11+ / любой Linux с Docker 24+
|
||||||
- Docker Compose plugin (`docker compose` команда)
|
- Docker Compose plugin (`docker compose` команда)
|
||||||
- Исходящий HTTPS на Vault (`corp.vault.elcsa.ru:443`)
|
- Исходящий HTTPS на Vault (`corp.vault.elcsa.ru:443`)
|
||||||
|
- Сетевой доступ к PostgreSQL (адрес приходит из Vault)
|
||||||
|
|
||||||
## Быстрый старт
|
## Быстрый старт
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Скопировать папку на сервер (или git clone и cd deployserver)
|
# 1. Скопировать папку на сервер
|
||||||
scp -r deployserver user@server:/opt/cryptowallet
|
scp -r deployserver user@server:/opt/cryptowallet
|
||||||
ssh user@server
|
ssh user@server
|
||||||
cd /opt/cryptowallet
|
cd /opt/cryptowallet
|
||||||
@@ -45,11 +48,15 @@ newgrp docker
|
|||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
nano .env # заполнить VAULT_ROLE_ID, VAULT_SECRET_ID
|
nano .env # заполнить VAULT_ROLE_ID, VAULT_SECRET_ID
|
||||||
|
|
||||||
# 4. Запустить
|
# 4. Применить схему БД (один раз, на пустой БД)
|
||||||
|
sudo apt install -y postgresql-client
|
||||||
|
PGPASSWORD=<пароль_из_Vault> psql -h <host> -U <user> -d <db> -f db/schema.sql
|
||||||
|
|
||||||
|
# 5. Запустить
|
||||||
chmod +x start.sh
|
chmod +x start.sh
|
||||||
./start.sh
|
./start.sh
|
||||||
|
|
||||||
# 5. Открыть порт наружу
|
# 6. Открыть порт наружу
|
||||||
sudo ufw allow 22/tcp
|
sudo ufw allow 22/tcp
|
||||||
sudo ufw allow 3001/tcp
|
sudo ufw allow 3001/tcp
|
||||||
sudo ufw enable
|
sudo ufw enable
|
||||||
@@ -71,29 +78,32 @@ Swagger UI: `http://<server-ip>:3001/api/docs`
|
|||||||
| Порт | Назначение | Открыть наружу? |
|
| Порт | Назначение | Открыть наружу? |
|
||||||
|------|-----------|-----------------|
|
|------|-----------|-----------------|
|
||||||
| 3001 | API HTTP | ✅ да (`ufw allow 3001`) |
|
| 3001 | API HTTP | ✅ да (`ufw allow 3001`) |
|
||||||
| 5432 | PostgreSQL | ❌ нет (только docker network) |
|
|
||||||
| 443 (out) | Vault | исходящий, обычно открыт |
|
| 443 (out) | Vault | исходящий, обычно открыт |
|
||||||
|
| 5432 (out) | PostgreSQL | исходящий к внешнему адресу БД |
|
||||||
|
|
||||||
## Управление
|
## Управление
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose logs -f api # смотреть логи
|
docker compose logs -f api # смотреть логи
|
||||||
docker compose restart api # рестарт
|
docker compose restart api # рестарт (например после смены .env)
|
||||||
docker compose down # остановить
|
docker compose down # остановить
|
||||||
docker compose down -v # + удалить БД (ОСТОРОЖНО)
|
|
||||||
docker compose ps # статус
|
docker compose ps # статус
|
||||||
docker compose exec postgres psql -U postgres cryptowallet_v2 # подключиться к БД
|
docker compose up -d --build # пересобрать и запустить (после обновления кода)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Обновление
|
## Обновление
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Скопировать новую версию deployserver/
|
# Скопировать новую версию deployserver/ (или git pull)
|
||||||
docker compose build --pull api
|
docker compose build --pull api
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Миграции применятся автоматически при старте API.
|
Схема БД не применяется автоматически — если добавились новые таблицы/колонки, выполни `schema.sql` вручную (он идемпотентный, безопасно запускать повторно).
|
||||||
|
|
||||||
|
## Ротация ключей
|
||||||
|
|
||||||
|
JWT public keys и CSRF secret читаются из Vault при старте и **каждый час** обновляются автоматически (см. `key-rotation.service.ts`). При ошибках Vault сервис продолжает работать со старыми ключами — в логах будет `ERROR: Failed to refresh ...`.
|
||||||
|
|
||||||
## Безопасность Dockerfile
|
## Безопасность Dockerfile
|
||||||
|
|
||||||
@@ -110,9 +120,12 @@ docker compose up -d
|
|||||||
- Проверь VAULT_ROLE_ID / VAULT_SECRET_ID в .env
|
- Проверь VAULT_ROLE_ID / VAULT_SECRET_ID в .env
|
||||||
- Проверь доступ: `curl -v https://corp.vault.elcsa.ru/v1/sys/health`
|
- Проверь доступ: `curl -v https://corp.vault.elcsa.ru/v1/sys/health`
|
||||||
|
|
||||||
**API рестартуется в цикле**
|
**`password authentication failed for user "postgres_user"`**
|
||||||
- `docker compose logs api` — смотри ошибку
|
- Креды в `.env` не совпадают с тем что в Vault (или с реальной БД)
|
||||||
- Скорее всего БД не поднялась: `docker compose logs postgres`
|
- Решение: либо подставь пароль из Vault в `.env`, либо оставь пустыми — Vault перекроет при логине
|
||||||
|
|
||||||
|
**Таблицы не существуют (relation does not exist)**
|
||||||
|
- Не применён `db/schema.sql` — см. шаг 4 в Quick Start
|
||||||
|
|
||||||
**Port 3001 занят**
|
**Port 3001 занят**
|
||||||
- `sudo lsof -i :3001`
|
- `sudo lsof -i :3001`
|
||||||
@@ -120,7 +133,6 @@ docker compose up -d
|
|||||||
|
|
||||||
**Нет места на диске**
|
**Нет места на диске**
|
||||||
- `docker system prune -a` — удалит старые образы
|
- `docker system prune -a` — удалит старые образы
|
||||||
- `docker compose logs --tail=0 --no-log-prefix > /dev/null` — логи ротейтятся автоматически
|
|
||||||
|
|
||||||
## Автозапуск при reboot
|
## Автозапуск при reboot
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,6 @@
|
|||||||
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
|
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"migrate": "node --require ts-node/register node_modules/knex/bin/cli.js migrate:latest --knexfile src/db/knexfile.ts",
|
|
||||||
"migrate:rollback": "node --require ts-node/register node_modules/knex/bin/cli.js migrate:rollback --knexfile src/db/knexfile.ts",
|
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "eslint src/ --ext .ts"
|
"lint": "eslint src/ --ext .ts"
|
||||||
},
|
},
|
||||||
@@ -22,8 +20,7 @@
|
|||||||
"knex": "^3.1.0",
|
"knex": "^3.1.0",
|
||||||
"pg": "^8.13.0",
|
"pg": "^8.13.0",
|
||||||
"swagger-ui-express": "^5.0.1",
|
"swagger-ui-express": "^5.0.1",
|
||||||
"ulidx": "^2.4.1",
|
"ulidx": "^2.4.1"
|
||||||
"zod": "^3.23.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/cookie-parser": "^1.4.7",
|
"@types/cookie-parser": "^1.4.7",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { env } from './config/env';
|
|||||||
import { swaggerSpec } from './config/swagger';
|
import { swaggerSpec } from './config/swagger';
|
||||||
import { traceMiddleware } from './middleware/trace';
|
import { traceMiddleware } from './middleware/trace';
|
||||||
import { authMiddleware } from './middleware/auth';
|
import { authMiddleware } from './middleware/auth';
|
||||||
|
import { csrfMiddleware } from './middleware/csrf';
|
||||||
import { errorHandler } from './middleware/error-handler';
|
import { errorHandler } from './middleware/error-handler';
|
||||||
import walletRoutes from './routes/wallet.routes';
|
import walletRoutes from './routes/wallet.routes';
|
||||||
import relayProxyRoutes from './routes/relay-proxy.routes';
|
import relayProxyRoutes from './routes/relay-proxy.routes';
|
||||||
@@ -19,12 +20,12 @@ import bscSwapProxyRoutes from './routes/bsc-swap-proxy.routes';
|
|||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
app.use(helmet());
|
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(express.json());
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
app.use(traceMiddleware);
|
app.use(traceMiddleware);
|
||||||
|
|
||||||
// ── PUBLIC endpoints (no auth) ────────────────────────────────────────────────
|
// ── PUBLIC endpoints ─────────────────────────────────────────────────────────
|
||||||
app.get('/api/health', (_req, res) => {
|
app.get('/api/health', (_req, res) => {
|
||||||
res.json({ success: true, data: { status: 'ok' } });
|
res.json({ success: true, data: { status: 'ok' } });
|
||||||
});
|
});
|
||||||
@@ -34,14 +35,16 @@ app.get('/api/docs/swagger.json', (_req, res) => {
|
|||||||
res.json(swaggerSpec);
|
res.json(swaggerSpec);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── PROTECTED endpoints (JWT required) ────────────────────────────────────────
|
// ── PROTECTED endpoints (JWT + CSRF for mutating methods) ────────────────────
|
||||||
app.use('/api/wallets', authMiddleware, walletRoutes);
|
const protect = [authMiddleware, csrfMiddleware];
|
||||||
app.use('/api/relay', authMiddleware, relayProxyRoutes);
|
|
||||||
app.use('/api/tron', authMiddleware, tronProxyRoutes);
|
app.use('/api/wallets', ...protect, walletRoutes);
|
||||||
app.use('/api/sol/swap', authMiddleware, solSwapProxyRoutes);
|
app.use('/api/relay', ...protect, relayProxyRoutes);
|
||||||
app.use('/api/tron/swap', authMiddleware, tronSwapProxyRoutes);
|
app.use('/api/tron', ...protect, tronProxyRoutes);
|
||||||
app.use('/api/btc', authMiddleware, btcProxyRoutes);
|
app.use('/api/sol/swap', ...protect, solSwapProxyRoutes);
|
||||||
app.use('/api/bsc/swap', authMiddleware, bscSwapProxyRoutes);
|
app.use('/api/tron/swap', ...protect, tronSwapProxyRoutes);
|
||||||
|
app.use('/api/btc', ...protect, btcProxyRoutes);
|
||||||
|
app.use('/api/bsc/swap', ...protect, bscSwapProxyRoutes);
|
||||||
|
|
||||||
app.use(errorHandler);
|
app.use(errorHandler);
|
||||||
|
|
||||||
|
|||||||
@@ -9,23 +9,16 @@ const p = process.env;
|
|||||||
|
|
||||||
export let env = {
|
export let env = {
|
||||||
db: {
|
db: {
|
||||||
host: p.DB_HOST || 'localhost',
|
host: p.DB_HOST || '',
|
||||||
port: parseInt(p.DB_PORT || '5432'),
|
port: parseInt(p.DB_PORT || '5432'),
|
||||||
user: p.DB_USER || 'postgres',
|
user: p.DB_USER || '',
|
||||||
password: p.DB_PASSWORD || 'postgres',
|
password: p.DB_PASSWORD || '',
|
||||||
name: p.DB_NAME || 'cryptowallet_v2',
|
name: p.DB_NAME || '',
|
||||||
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',
|
|
||||||
},
|
},
|
||||||
jwt: {
|
jwt: {
|
||||||
algorithm: p.JWT_ALGORITHM || 'RS256',
|
algorithm: p.JWT_ALGORITHM || 'RS256',
|
||||||
issuer: p.JWT_ISSUER || 'auth-service',
|
issuer: p.JWT_ISSUER || 'auth-service',
|
||||||
audience: p.JWT_AUDIENCE || 'bitforce',
|
audience: p.JWT_AUDIENCE || 'elcsa',
|
||||||
accessTtl: parseInt(p.JWT_ACCESS_TTL_SECONDS || '900'),
|
|
||||||
refreshTtl: parseInt(p.JWT_REFRESH_TTL_SECONDS || '2592000'),
|
|
||||||
},
|
},
|
||||||
vault: {
|
vault: {
|
||||||
addr: p.VAULT_ADDR || '',
|
addr: p.VAULT_ADDR || '',
|
||||||
@@ -35,43 +28,13 @@ export let env = {
|
|||||||
secretPath: p.VAULT_SECRET_PATH || 'database',
|
secretPath: p.VAULT_SECRET_PATH || 'database',
|
||||||
jwtKidPath: p.VAULT_JWT_KID_PATH || 'jwt/kid',
|
jwtKidPath: p.VAULT_JWT_KID_PATH || 'jwt/kid',
|
||||||
jwtKidsPrefix: p.VAULT_JWT_KIDS_PREFIX || 'jwt/kids',
|
jwtKidsPrefix: p.VAULT_JWT_KIDS_PREFIX || 'jwt/kids',
|
||||||
},
|
csrfPath: p.VAULT_CSRF_PATH || 'csrf',
|
||||||
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',
|
|
||||||
},
|
},
|
||||||
cors: {
|
cors: {
|
||||||
origins: (p.CORS_ORIGINS || 'http://localhost:3000').split(','),
|
origins: (p.CORS_ORIGINS || 'http://localhost:3000').split(','),
|
||||||
allowCredentials: p.CORS_ALLOW_CREDENTIALS !== 'false',
|
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'),
|
port: parseInt(p.API_PORT || '3001'),
|
||||||
frontendUrl: p.FRONTEND_URL || 'http://localhost:3000',
|
|
||||||
relayApiKey: p.RELAY_API_KEY || null,
|
relayApiKey: p.RELAY_API_KEY || null,
|
||||||
tronApiKey: p.TRON_API_KEY || null,
|
tronApiKey: p.TRON_API_KEY || null,
|
||||||
jupiterApiKey: p.JUPITER_API_KEY || null,
|
jupiterApiKey: p.JUPITER_API_KEY || null,
|
||||||
@@ -116,41 +79,26 @@ export async function initEnv(): Promise<void> {
|
|||||||
return v ? parseInt(v) : fallback;
|
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 = {
|
||||||
...env,
|
...env,
|
||||||
db: {
|
db: {
|
||||||
host: s('DB_HOST') || env.db.host,
|
host: s('host') || s('DB_HOST') || env.db.host,
|
||||||
port: si('DB_PORT', env.db.port),
|
port: si('port', si('DB_PORT', env.db.port)),
|
||||||
user: s('DB_USER') || env.db.user,
|
user: s('user') || s('DB_USER') || env.db.user,
|
||||||
password: s('DB_PASSWORD') || env.db.password,
|
password: s('password') || s('DB_PASSWORD') || env.db.password,
|
||||||
name: s('DB_NAME') || env.db.name,
|
name: s('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',
|
|
||||||
},
|
},
|
||||||
jwt: {
|
jwt: {
|
||||||
...env.jwt,
|
...env.jwt,
|
||||||
issuer: s('JWT_ISSUER') || env.jwt.issuer,
|
issuer: s('JWT_ISSUER') || env.jwt.issuer,
|
||||||
audience: s('JWT_AUDIENCE') || env.jwt.audience,
|
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: {
|
cors: {
|
||||||
origins: s('CORS_ORIGINS') ? s('CORS_ORIGINS')!.split(',') : env.cors.origins,
|
origins: s('CORS_ORIGINS') ? s('CORS_ORIGINS')!.split(',') : env.cors.origins,
|
||||||
allowCredentials: secrets['CORS_ALLOW_CREDENTIALS'] !== 'false',
|
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,
|
relayApiKey: s('RELAY_API_KEY') || env.relayApiKey,
|
||||||
tronApiKey: s('TRON_API_KEY') || env.tronApiKey,
|
tronApiKey: s('TRON_API_KEY') || env.tronApiKey,
|
||||||
jupiterApiKey: s('JUPITER_API_KEY') || env.jupiterApiKey,
|
jupiterApiKey: s('JUPITER_API_KEY') || env.jupiterApiKey,
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
@@ -1,40 +1,29 @@
|
|||||||
import knex from 'knex';
|
|
||||||
import knexConfig from './db/knexfile';
|
|
||||||
import app from './app';
|
import app from './app';
|
||||||
import { env, initEnv, getVaultToken } from './config/env';
|
import { env, initEnv } from './config/env';
|
||||||
import { loadJwtKeysFromVault } from './services/jwt.service';
|
import { refreshAllKeys, startKeyRotation, stopKeyRotation } from './services/key-rotation.service';
|
||||||
import { logger } from './lib/logger';
|
import { logger } from './lib/logger';
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
logger.info(`Wallet service instance started with id ${logger.instanceId}`);
|
logger.info(`Wallet service instance started with id ${logger.instanceId}`);
|
||||||
|
|
||||||
await initEnv();
|
await initEnv();
|
||||||
|
await refreshAllKeys();
|
||||||
|
startKeyRotation();
|
||||||
|
|
||||||
// Load JWT public keys from Vault if available
|
const server = app.listen(env.port, () => {
|
||||||
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, () => {
|
|
||||||
logger.info(`Server running on port ${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) => {
|
main().catch((err) => {
|
||||||
|
|||||||
36
apps/api/src/middleware/csrf.ts
Normal file
36
apps/api/src/middleware/csrf.ts
Normal 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();
|
||||||
|
}
|
||||||
@@ -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();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
130
apps/api/src/services/csrf.service.ts
Normal file
130
apps/api/src/services/csrf.service.ts
Normal 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 };
|
||||||
|
}
|
||||||
70
apps/api/src/services/key-rotation.service.ts
Normal file
70
apps/api/src/services/key-rotation.service.ts
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
63
db/schema.sql
Normal file
63
db/schema.sql
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- CryptoWallet API — Database Schema
|
||||||
|
-- Idempotent: safe to run multiple times (CREATE IF NOT EXISTS).
|
||||||
|
--
|
||||||
|
-- Применение:
|
||||||
|
-- psql "postgresql://user:pass@host:5432/db" -f deployserver/db/schema.sql
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- ── users ───────────────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id VARCHAR(26) PRIMARY KEY,
|
||||||
|
email VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
last_name VARCHAR(128),
|
||||||
|
first_name VARCHAR(128),
|
||||||
|
middle_name VARCHAR(128),
|
||||||
|
birth_date DATE,
|
||||||
|
crypto_wallet VARCHAR(255),
|
||||||
|
phone VARCHAR(16),
|
||||||
|
bik VARCHAR(9),
|
||||||
|
account_number VARCHAR(20),
|
||||||
|
card_number VARCHAR(19),
|
||||||
|
inn VARCHAR(12),
|
||||||
|
kyc_verified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
kyc_verified_at TIMESTAMPTZ,
|
||||||
|
is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ── wallets ─────────────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS wallets (
|
||||||
|
id VARCHAR(26) PRIMARY KEY,
|
||||||
|
user_id VARCHAR(26) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
chain VARCHAR(10) NOT NULL,
|
||||||
|
address VARCHAR(256) NOT NULL,
|
||||||
|
derivation_path VARCHAR(64) NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT wallets_user_id_chain_unique UNIQUE (user_id, chain)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wallets_user_id ON wallets(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wallets_address ON wallets(address);
|
||||||
|
|
||||||
|
-- ── sessions ────────────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id VARCHAR(26) PRIMARY KEY,
|
||||||
|
sid VARCHAR(26) NOT NULL UNIQUE,
|
||||||
|
user_id VARCHAR(26) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
device_id VARCHAR(26),
|
||||||
|
user_agent VARCHAR(500),
|
||||||
|
first_ip VARCHAR(64),
|
||||||
|
last_ip VARCHAR(64),
|
||||||
|
last_seen_at TIMESTAMPTZ,
|
||||||
|
revoked_at TIMESTAMPTZ,
|
||||||
|
refresh_jti_hash VARCHAR(255),
|
||||||
|
refresh_expires_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_sid ON sessions(sid);
|
||||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -50,9 +50,6 @@ importers:
|
|||||||
ulidx:
|
ulidx:
|
||||||
specifier: ^2.4.1
|
specifier: ^2.4.1
|
||||||
version: 2.4.1
|
version: 2.4.1
|
||||||
zod:
|
|
||||||
specifier: ^3.23.0
|
|
||||||
version: 3.25.76
|
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/cookie-parser':
|
'@types/cookie-parser':
|
||||||
specifier: ^1.4.7
|
specifier: ^1.4.7
|
||||||
@@ -1521,9 +1518,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
zod@3.25.76:
|
|
||||||
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
|
||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
'@cspotcode/source-map-support@0.8.1':
|
'@cspotcode/source-map-support@0.8.1':
|
||||||
@@ -3256,5 +3250,3 @@ snapshots:
|
|||||||
yn@3.1.1: {}
|
yn@3.1.1: {}
|
||||||
|
|
||||||
yocto-queue@0.1.0: {}
|
yocto-queue@0.1.0: {}
|
||||||
|
|
||||||
zod@3.25.76: {}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user