new version
This commit is contained in:
20
apps/api/.eslintrc.json
Normal file
20
apps/api/.eslintrc.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"root": true,
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": ["@typescript-eslint"],
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
],
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2022,
|
||||
"sourceType": "module"
|
||||
},
|
||||
"rules": {
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }],
|
||||
"@typescript-eslint/no-namespace": "off",
|
||||
"no-console": "off"
|
||||
},
|
||||
"ignorePatterns": ["dist/", "node_modules/"]
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache python3 make g++
|
||||
RUN corepack enable && corepack prepare pnpm@10.28.2 --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy workspace config
|
||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml turbo.json ./
|
||||
COPY apps/api/package.json apps/api/
|
||||
COPY packages/shared/package.json packages/shared/
|
||||
|
||||
# Enable hoisting so tsc can find all deps
|
||||
RUN echo "node-linker=hoisted" > .npmrc
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Copy source
|
||||
COPY apps/api/ apps/api/
|
||||
COPY packages/shared/ packages/shared/
|
||||
|
||||
# Build api (node_modules are hoisted, tsc available at root)
|
||||
RUN cd apps/api && ../../node_modules/.bin/tsc \
|
||||
&& rm -f dist/db/migrations/*.d.ts dist/db/migrations/*.d.ts.map dist/db/migrations/*.js.map
|
||||
|
||||
# Runtime stage
|
||||
FROM node:20-alpine
|
||||
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy built output (includes compiled migrations + knexfile in dist/db/)
|
||||
COPY --from=builder /app/apps/api/dist ./dist
|
||||
COPY --from=builder /app/apps/api/package.json ./
|
||||
|
||||
# Copy node_modules (runtime deps including bcrypt native)
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/apps/api/node_modules ./apps_node_modules
|
||||
|
||||
# Entrypoint
|
||||
COPY apps/api/docker-entrypoint.sh ./docker-entrypoint.sh
|
||||
RUN chmod +x ./docker-entrypoint.sh
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
ENTRYPOINT ["./docker-entrypoint.sh"]
|
||||
@@ -1,15 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# If Vault init-keys exist, extract root token
|
||||
if [ -f /vault/file/init-keys.json ]; then
|
||||
export VAULT_TOKEN=$(tr -d ' \n' < /vault/file/init-keys.json | grep -o '"root_token":"[^"]*"' | cut -d'"' -f4)
|
||||
echo "[API] Vault token loaded from init-keys.json"
|
||||
fi
|
||||
|
||||
# Run migrations
|
||||
node node_modules/knex/bin/cli.js migrate:latest --knexfile dist/db/knexfile.js
|
||||
echo "[API] Migrations complete"
|
||||
|
||||
# Start server
|
||||
exec node dist/index.js
|
||||
@@ -8,39 +8,33 @@
|
||||
"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",
|
||||
"db:fresh": "pnpm db:reset && pnpm migrate",
|
||||
"db:reset": "node --require ts-node/register src/db/reset-db.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src/ --ext .ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cryptowallet/shared": "workspace:*",
|
||||
"amqplib": "^1.0.3",
|
||||
"bcrypt": "^5.1.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.0",
|
||||
"ethers": "5.7.2",
|
||||
"express": "^4.21.0",
|
||||
"express-rate-limit": "^7.4.0",
|
||||
"helmet": "^8.0.0",
|
||||
"jose": "^6.2.2",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"knex": "^3.1.0",
|
||||
"pg": "^8.13.0",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"ulidx": "^2.4.1",
|
||||
"uuid": "^11.0.0",
|
||||
"zod": "^3.23.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/amqplib": "^0.10.8",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/cookie-parser": "^1.4.7",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/express-serve-static-core": "^5.1.1",
|
||||
"@types/jsonwebtoken": "^9.0.0",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/swagger-ui-express": "^4.1.8",
|
||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"eslint": "^8.57.1",
|
||||
"ts-node": "^10.9.0",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"typescript": "^5.6.0"
|
||||
|
||||
@@ -2,11 +2,13 @@ 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 { errorHandler } from './middleware/error-handler';
|
||||
import walletSetupRoutes from './routes/wallet-setup.routes';
|
||||
import walletRoutes from './routes/wallet.routes';
|
||||
import vaultRoutes from './routes/vault.routes';
|
||||
import relayProxyRoutes from './routes/relay-proxy.routes';
|
||||
import tronProxyRoutes from './routes/tron-proxy.routes';
|
||||
import solSwapProxyRoutes from './routes/sol-swap-proxy.routes';
|
||||
@@ -20,20 +22,26 @@ app.use(helmet());
|
||||
app.use(cors({ origin: env.frontendUrl, credentials: true }));
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use(traceMiddleware);
|
||||
|
||||
// ── PUBLIC endpoints (no auth) ────────────────────────────────────────────────
|
||||
app.get('/api/health', (_req, res) => {
|
||||
res.json({ success: true, data: { status: 'ok' } });
|
||||
});
|
||||
|
||||
app.use('/api/wallet', walletSetupRoutes);
|
||||
app.use('/api/wallets', walletRoutes);
|
||||
app.use('/api/vault', vaultRoutes);
|
||||
app.use('/api/relay', relayProxyRoutes);
|
||||
app.use('/api/tron', tronProxyRoutes);
|
||||
app.use('/api/sol/swap', solSwapProxyRoutes);
|
||||
app.use('/api/tron/swap', tronSwapProxyRoutes);
|
||||
app.use('/api/btc', btcProxyRoutes);
|
||||
app.use('/api/bsc/swap', bscSwapProxyRoutes);
|
||||
app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec));
|
||||
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);
|
||||
|
||||
app.use(errorHandler);
|
||||
|
||||
|
||||
@@ -1,55 +1,158 @@
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
import { fetchVaultSecrets } from './vault';
|
||||
import { vaultAppRoleLogin, fetchVaultKV2 } from './vault';
|
||||
import { logger } from '../lib/logger';
|
||||
|
||||
dotenv.config({ path: path.resolve(__dirname, '../../../../.env') });
|
||||
|
||||
const p = process.env;
|
||||
|
||||
export let env = {
|
||||
db: {
|
||||
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',
|
||||
name: process.env.DB_NAME || 'cryptowallet',
|
||||
host: p.DB_HOST || 'localhost',
|
||||
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',
|
||||
},
|
||||
port: parseInt(process.env.API_PORT || '3001'),
|
||||
frontendUrl: process.env.FRONTEND_URL || 'http://localhost:3000',
|
||||
relayApiKey: process.env.RELAY_API_KEY || null,
|
||||
tronApiKey: process.env.TRON_API_KEY || null,
|
||||
jupiterApiKey: process.env.JUPITER_API_KEY || null,
|
||||
jupiterReferralAccount: process.env.JUPITER_REFERRAL_ACCOUNT || null,
|
||||
jupiterFeeBps: parseInt(process.env.JUPITER_FEE_BPS || '70'), // 0.7%
|
||||
|
||||
// BITOK auth service
|
||||
bitokJwksUrl: process.env.BITOK_JWKS_URL || 'http://localhost:8000/.well-known/jwks.json',
|
||||
bitokIssuer: process.env.BITOK_ISSUER || 'auth-service',
|
||||
bitokAudience: process.env.BITOK_AUDIENCE || 'wallet-service',
|
||||
|
||||
// RabbitMQ
|
||||
rabbitmqUrl: process.env.RABBITMQ_URL || 'amqp://guest:guest@localhost:5672/',
|
||||
rabbitmqExchange: process.env.RABBITMQ_EXCHANGE || 'bitok.events',
|
||||
rabbitmqWalletQueue: process.env.RABBITMQ_WALLET_QUEUE || 'wallet.user_events',
|
||||
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'),
|
||||
},
|
||||
vault: {
|
||||
addr: p.VAULT_ADDR || '',
|
||||
roleId: p.VAULT_ROLE_ID || '',
|
||||
secretId: p.VAULT_SECRET_ID || '',
|
||||
mount: p.VAULT_MOUNT_POINT || 'dev-secrets',
|
||||
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',
|
||||
},
|
||||
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,
|
||||
jupiterReferralAccount: p.JUPITER_REFERRAL_ACCOUNT || null,
|
||||
jupiterFeeBps: parseInt(p.JUPITER_FEE_BPS || '70'),
|
||||
};
|
||||
|
||||
export async function initEnv(): Promise<void> {
|
||||
const secrets = await fetchVaultSecrets();
|
||||
let vaultToken: string | null = null;
|
||||
|
||||
if (secrets) {
|
||||
console.log('[ENV] Loaded secrets from Vault');
|
||||
env = {
|
||||
...env,
|
||||
db: {
|
||||
host: secrets.db_host,
|
||||
port: parseInt(secrets.db_port),
|
||||
user: secrets.db_user,
|
||||
password: secrets.db_password,
|
||||
name: secrets.db_name,
|
||||
},
|
||||
relayApiKey: secrets.relay_api_key || null,
|
||||
tronApiKey: secrets.tron_api_key || env.tronApiKey,
|
||||
jupiterApiKey: secrets.jupiter_api_key || env.jupiterApiKey,
|
||||
};
|
||||
} else {
|
||||
console.log('[ENV] Vault not available, using env vars');
|
||||
}
|
||||
export function getVaultToken(): string | null {
|
||||
return vaultToken;
|
||||
}
|
||||
|
||||
export async function initEnv(): Promise<void> {
|
||||
const { addr, roleId, secretId, mount, secretPath } = env.vault;
|
||||
|
||||
if (!addr || !roleId || !secretId) {
|
||||
logger.info('Vault not configured, using .env');
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await vaultAppRoleLogin(addr, roleId, secretId);
|
||||
if (!token) {
|
||||
logger.warn('Vault AppRole login failed, using .env fallback');
|
||||
return;
|
||||
}
|
||||
|
||||
vaultToken = token;
|
||||
logger.info('Vault AppRole login successful');
|
||||
|
||||
const secrets = await fetchVaultKV2(addr, token, mount, secretPath);
|
||||
if (!secrets) {
|
||||
logger.warn('Failed to read DB secrets from Vault');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('Loaded DB secrets from Vault');
|
||||
|
||||
const s = (key: string) => secrets[key];
|
||||
const si = (key: string, fallback: number) => {
|
||||
const v = secrets[key];
|
||||
return v ? parseInt(v) : fallback;
|
||||
};
|
||||
|
||||
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',
|
||||
},
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
5
apps/api/src/config/swagger.ts
Normal file
5
apps/api/src/config/swagger.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const swaggerPath = path.resolve(__dirname, '../../swagger.json');
|
||||
export const swaggerSpec = JSON.parse(fs.readFileSync(swaggerPath, 'utf-8'));
|
||||
@@ -1,29 +1,42 @@
|
||||
interface VaultSecrets {
|
||||
db_host: string;
|
||||
db_port: string;
|
||||
db_user: string;
|
||||
db_password: string;
|
||||
db_name: string;
|
||||
relay_api_key: string;
|
||||
tron_api_key: string;
|
||||
jupiter_api_key: string;
|
||||
}
|
||||
|
||||
export async function fetchVaultSecrets(): Promise<VaultSecrets | null> {
|
||||
const vaultAddr = process.env.VAULT_ADDR;
|
||||
const vaultToken = process.env.VAULT_TOKEN;
|
||||
|
||||
if (!vaultAddr || !vaultToken) return null;
|
||||
|
||||
export async function vaultAppRoleLogin(
|
||||
addr: string,
|
||||
roleId: string,
|
||||
secretId: string,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch(`${vaultAddr}/v1/kv/data/cryptowallet`, {
|
||||
headers: { 'X-Vault-Token': vaultToken },
|
||||
const res = await fetch(`${addr}/v1/auth/approle/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ role_id: roleId, secret_id: secretId }),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
if (!res.ok) return null;
|
||||
|
||||
const body = (await res.json()) as { data: { data: VaultSecrets } };
|
||||
return body.data.data;
|
||||
const body = (await res.json()) as { auth?: { client_token?: string } };
|
||||
return body?.auth?.client_token ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchVaultKV2(
|
||||
addr: string,
|
||||
token: string,
|
||||
mount: string,
|
||||
path: string,
|
||||
): Promise<Record<string, string> | null> {
|
||||
try {
|
||||
const url = `${addr}/v1/${mount}/data/${path}`;
|
||||
const res = await fetch(url, {
|
||||
headers: { 'X-Vault-Token': token },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
if (!res.ok) return null;
|
||||
|
||||
const body = (await res.json()) as { data?: { data?: Record<string, string> } };
|
||||
return body?.data?.data ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { UserModel } from '../models/user.model';
|
||||
|
||||
export const VaultController = {
|
||||
async getVault(req: Request, res: Response) {
|
||||
try {
|
||||
const user = await UserModel.findByBitokUserId(req.user!.bitokUserId);
|
||||
if (!user) {
|
||||
res.status(404).json({ success: false, error: 'User not found' });
|
||||
return;
|
||||
}
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
encryptedVault: user.encrypted_vault,
|
||||
vaultSalt: user.vault_salt,
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ success: false, error: err.message });
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -1,118 +0,0 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { UserModel } from '../models/user.model';
|
||||
import { WalletModel } from '../models/wallet.model';
|
||||
import { db } from '../config/database';
|
||||
import { generateUlid } from '../utils/ulid';
|
||||
|
||||
export const WalletSetupController = {
|
||||
async setup(req: Request, res: Response) {
|
||||
try {
|
||||
const { bitokUserId, email } = req.user!;
|
||||
const { encryptedVault, vaultSalt, wallets } = req.body;
|
||||
|
||||
// Check if user already exists
|
||||
const existing = await UserModel.findByBitokUserId(bitokUserId);
|
||||
if (existing) {
|
||||
res.status(409).json({ success: false, error: 'Wallet already set up for this user' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await db.transaction(async (trx) => {
|
||||
const [user] = await trx('users')
|
||||
.insert({
|
||||
id: generateUlid(),
|
||||
bitok_user_id: bitokUserId,
|
||||
email: email || null,
|
||||
encrypted_vault: encryptedVault,
|
||||
vault_salt: vaultSalt,
|
||||
})
|
||||
.returning('*');
|
||||
|
||||
const walletRows = await trx('wallets')
|
||||
.insert(
|
||||
wallets.map((w: { chain: string; address: string; derivationPath: string }) => ({
|
||||
id: generateUlid(),
|
||||
user_id: user.id,
|
||||
chain: w.chain,
|
||||
address: w.address,
|
||||
derivation_path: w.derivationPath,
|
||||
}))
|
||||
)
|
||||
.returning('*');
|
||||
|
||||
return { user, wallets: walletRows };
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: {
|
||||
user: {
|
||||
id: result.user.id,
|
||||
bitokUserId: result.user.bitok_user_id,
|
||||
email: result.user.email,
|
||||
},
|
||||
wallets: result.wallets.map((w: any) => ({
|
||||
chain: w.chain,
|
||||
address: w.address,
|
||||
derivationPath: w.derivation_path,
|
||||
})),
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error('[WalletSetup] Error:', err.message);
|
||||
res.status(500).json({ success: false, error: 'Failed to set up wallet' });
|
||||
}
|
||||
},
|
||||
|
||||
async confirmMnemonic(req: Request, res: Response) {
|
||||
try {
|
||||
const { bitokUserId } = req.user!;
|
||||
const user = await UserModel.findByBitokUserId(bitokUserId);
|
||||
if (!user) {
|
||||
res.status(404).json({ success: false, error: 'Wallet not found' });
|
||||
return;
|
||||
}
|
||||
await UserModel.setMnemonicShown(user.id);
|
||||
res.json({ success: true, data: { mnemonicShown: true } });
|
||||
} catch (err: any) {
|
||||
console.error('[ConfirmMnemonic] Error:', err.message);
|
||||
res.status(500).json({ success: false, error: 'Failed to confirm mnemonic' });
|
||||
}
|
||||
},
|
||||
|
||||
async unlock(req: Request, res: Response) {
|
||||
try {
|
||||
const { bitokUserId } = req.user!;
|
||||
|
||||
const user = await UserModel.findByBitokUserId(bitokUserId);
|
||||
if (!user) {
|
||||
res.status(404).json({ success: false, error: 'Wallet not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (user.deleted) {
|
||||
res.status(403).json({ success: false, error: 'Account has been deleted' });
|
||||
return;
|
||||
}
|
||||
|
||||
const wallets = await WalletModel.findByUserId(user.id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
encryptedVault: user.encrypted_vault,
|
||||
vaultSalt: user.vault_salt,
|
||||
wallets: wallets.map((w) => ({
|
||||
chain: w.chain,
|
||||
address: w.address,
|
||||
derivationPath: w.derivation_path,
|
||||
})),
|
||||
mnemonicShown: user.mnemonic_shown,
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error('[WalletUnlock] Error:', err.message);
|
||||
res.status(500).json({ success: false, error: 'Failed to unlock wallet' });
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -1,17 +1,10 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { UserModel } from '../models/user.model';
|
||||
import { WalletModel } from '../models/wallet.model';
|
||||
|
||||
export const WalletController = {
|
||||
async getWallets(req: Request, res: Response) {
|
||||
try {
|
||||
const user = await UserModel.findByBitokUserId(req.user!.bitokUserId);
|
||||
if (!user) {
|
||||
res.status(404).json({ success: false, error: 'User not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const wallets = await WalletModel.findByUserId(user.id);
|
||||
const wallets = await WalletModel.findByUserId(req.auth!.userId);
|
||||
res.json({
|
||||
success: true,
|
||||
data: wallets.map((w) => ({
|
||||
|
||||
@@ -12,7 +12,7 @@ const config: Knex.Config = {
|
||||
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',
|
||||
database: process.env.DB_NAME || 'cryptowallet_v2',
|
||||
},
|
||||
migrations: {
|
||||
directory: path.resolve(__dirname, 'migrations'),
|
||||
|
||||
@@ -3,12 +3,21 @@ 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('username', 64).notNullable().unique();
|
||||
t.text('password_hash').notNullable();
|
||||
t.text('pin_hash').notNullable();
|
||||
t.text('encrypted_vault').notNullable();
|
||||
t.string('vault_salt', 128).notNullable();
|
||||
t.boolean('mnemonic_shown').notNullable().defaultTo(false);
|
||||
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());
|
||||
});
|
||||
|
||||
@@ -3,16 +3,22 @@ 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.text('refresh_token_hash').notNullable();
|
||||
t.string('user_agent').nullable();
|
||||
t.specificType('ip_address', 'inet').nullable();
|
||||
t.timestamp('expires_at', { useTz: true }).notNullable();
|
||||
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_expires ON sessions(expires_at)');
|
||||
await knex.schema.raw('CREATE INDEX idx_sessions_sid ON sessions(sid)');
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { Knex } from 'knex';
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
await knex.schema.createTable('login_attempts', (t) => {
|
||||
t.string('id', 26).primary();
|
||||
t.string('username', 64).notNullable();
|
||||
t.specificType('ip_address', 'inet').notNullable();
|
||||
t.boolean('success').notNullable().defaultTo(false);
|
||||
t.timestamp('created_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
|
||||
});
|
||||
|
||||
await knex.schema.raw('CREATE INDEX idx_login_attempts_username_created ON login_attempts(username, created_at)');
|
||||
await knex.schema.raw('CREATE INDEX idx_login_attempts_ip_created ON login_attempts(ip_address, created_at)');
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.schema.dropTableIfExists('login_attempts');
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import type { Knex } from 'knex';
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
await knex.schema.alterTable('users', (t) => {
|
||||
t.dropColumn('username');
|
||||
t.dropColumn('password_hash');
|
||||
t.dropColumn('pin_hash');
|
||||
|
||||
t.string('bitok_user_id', 26).notNullable().unique();
|
||||
t.string('email', 255).nullable();
|
||||
t.boolean('kyc_verified').notNullable().defaultTo(false);
|
||||
t.string('kyc_level', 20).nullable();
|
||||
t.boolean('deleted').notNullable().defaultTo(false);
|
||||
|
||||
t.index(['bitok_user_id'], 'idx_users_bitok_user_id');
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.schema.alterTable('users', (t) => {
|
||||
t.dropIndex(['bitok_user_id'], 'idx_users_bitok_user_id');
|
||||
|
||||
t.dropColumn('bitok_user_id');
|
||||
t.dropColumn('email');
|
||||
t.dropColumn('kyc_verified');
|
||||
t.dropColumn('kyc_level');
|
||||
t.dropColumn('deleted');
|
||||
|
||||
t.string('username', 64).notNullable().unique();
|
||||
t.text('password_hash').notNullable();
|
||||
t.text('pin_hash').notNullable();
|
||||
});
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { Knex } from 'knex';
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
await knex.schema.dropTableIfExists('sessions');
|
||||
await knex.schema.dropTableIfExists('login_attempts');
|
||||
|
||||
await knex.schema.createTable('processed_events', (t) => {
|
||||
t.string('event_id', 26).primary();
|
||||
t.string('event_type', 64).notNullable();
|
||||
t.string('payload_hash', 64).notNullable();
|
||||
t.timestamp('processed_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.schema.dropTableIfExists('processed_events');
|
||||
|
||||
await knex.schema.createTable('sessions', (t) => {
|
||||
t.string('id', 26).primary();
|
||||
t.string('user_id', 26).notNullable().references('id').inTable('users').onDelete('CASCADE');
|
||||
t.text('refresh_token_hash').notNullable();
|
||||
t.string('user_agent').nullable();
|
||||
t.specificType('ip_address', 'inet').nullable();
|
||||
t.timestamp('expires_at', { useTz: true }).notNullable();
|
||||
t.timestamp('created_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
|
||||
});
|
||||
|
||||
await knex.schema.createTable('login_attempts', (t) => {
|
||||
t.string('id', 26).primary();
|
||||
t.string('username', 64).notNullable();
|
||||
t.specificType('ip_address', 'inet').notNullable();
|
||||
t.boolean('success').notNullable().defaultTo(false);
|
||||
t.timestamp('created_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
|
||||
});
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
import knex from 'knex';
|
||||
|
||||
// Load .env from repo root (works when running from apps/api)
|
||||
dotenv.config({ path: path.resolve(__dirname, '../../../../.env') });
|
||||
dotenv.config({ path: path.resolve(process.cwd(), '.env') });
|
||||
|
||||
const dbName = process.env.DB_NAME || 'cryptowallet_devphase3';
|
||||
if (!/^[a-zA-Z0-9_]+$/.test(dbName)) {
|
||||
console.error('[DB Reset] Invalid DB_NAME');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const baseConnection = {
|
||||
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',
|
||||
};
|
||||
|
||||
async function reset() {
|
||||
const admin = knex({
|
||||
client: 'pg',
|
||||
connection: { ...baseConnection, database: 'postgres' },
|
||||
});
|
||||
|
||||
try {
|
||||
await admin.raw(
|
||||
`SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = ? AND pid <> pg_backend_pid()`,
|
||||
[dbName]
|
||||
);
|
||||
} catch {
|
||||
// Ignore if no connections
|
||||
}
|
||||
|
||||
const safeName = dbName.replace(/"/g, '""');
|
||||
await admin.raw(`DROP DATABASE IF EXISTS "${safeName}"`);
|
||||
await admin.raw(`CREATE DATABASE "${safeName}"`);
|
||||
|
||||
await admin.destroy();
|
||||
console.log('[DB Reset] Database dropped and recreated:', dbName);
|
||||
}
|
||||
|
||||
reset().catch((err: unknown) => {
|
||||
console.error('[DB Reset] Failed:', err instanceof Error ? err.message : String(err));
|
||||
if (err instanceof Error && err.stack) console.error(err.stack);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
import amqplib, { type Channel, type ChannelModel } from 'amqplib';
|
||||
import { env } from '../config/env';
|
||||
|
||||
let connectionModel: ChannelModel | null = null;
|
||||
let channel: Channel | null = null;
|
||||
|
||||
const DLX_EXCHANGE = `${env.rabbitmqExchange}.dlx`;
|
||||
const DLQ_NAME = `${env.rabbitmqWalletQueue}.dlq`;
|
||||
|
||||
export async function createRabbitConnection(): Promise<Channel> {
|
||||
connectionModel = await amqplib.connect(env.rabbitmqUrl);
|
||||
|
||||
connectionModel.on('error', (err) => {
|
||||
console.error('[RabbitMQ] Connection error:', err.message);
|
||||
});
|
||||
|
||||
connectionModel.on('close', () => {
|
||||
console.warn('[RabbitMQ] Connection closed. Reconnecting in 5s...');
|
||||
setTimeout(() => createRabbitConnection().catch(console.error), 5000);
|
||||
});
|
||||
|
||||
channel = await connectionModel.createChannel();
|
||||
await channel.prefetch(1);
|
||||
|
||||
// Declare main exchange
|
||||
await channel.assertExchange(env.rabbitmqExchange, 'topic', { durable: true });
|
||||
|
||||
// Declare DLX and DLQ
|
||||
await channel.assertExchange(DLX_EXCHANGE, 'topic', { durable: true });
|
||||
await channel.assertQueue(DLQ_NAME, { durable: true });
|
||||
await channel.bindQueue(DLQ_NAME, DLX_EXCHANGE, '#');
|
||||
|
||||
// Declare main queue with DLX
|
||||
await channel.assertQueue(env.rabbitmqWalletQueue, {
|
||||
durable: true,
|
||||
arguments: {
|
||||
'x-dead-letter-exchange': DLX_EXCHANGE,
|
||||
},
|
||||
});
|
||||
|
||||
// Bind routing keys
|
||||
await channel.bindQueue(env.rabbitmqWalletQueue, env.rabbitmqExchange, 'user.kyc_verified');
|
||||
await channel.bindQueue(env.rabbitmqWalletQueue, env.rabbitmqExchange, 'user.deleted');
|
||||
|
||||
console.log('[RabbitMQ] Connected and queues declared');
|
||||
|
||||
return channel;
|
||||
}
|
||||
|
||||
export async function closeRabbitConnection(): Promise<void> {
|
||||
try {
|
||||
if (channel) await channel.close();
|
||||
if (connectionModel) await connectionModel.close();
|
||||
} catch {
|
||||
// ignore close errors
|
||||
}
|
||||
channel = null;
|
||||
connectionModel = null;
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import type { Channel, ConsumeMessage } from 'amqplib';
|
||||
import crypto from 'crypto';
|
||||
import { db } from '../config/database';
|
||||
import { env } from '../config/env';
|
||||
import { handleKycVerified } from './handlers/kyc-verified.handler';
|
||||
import { handleUserDeleted } from './handlers/deleted.handler';
|
||||
|
||||
const MAX_RETRIES = 3;
|
||||
|
||||
interface BitokEvent {
|
||||
event_id: string;
|
||||
event_type: string;
|
||||
payload: Record<string, unknown>;
|
||||
occurred_at: string;
|
||||
schema_version: number;
|
||||
}
|
||||
|
||||
function isValidEvent(msg: unknown): msg is BitokEvent {
|
||||
if (!msg || typeof msg !== 'object') return false;
|
||||
const e = msg as Record<string, unknown>;
|
||||
return (
|
||||
typeof e.event_id === 'string' &&
|
||||
typeof e.event_type === 'string' &&
|
||||
typeof e.payload === 'object' &&
|
||||
e.payload !== null &&
|
||||
typeof e.occurred_at === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
function getRetryCount(msg: ConsumeMessage): number {
|
||||
const xDeath = msg.properties.headers?.['x-death'] as Array<{ count: number }> | undefined;
|
||||
if (!xDeath || xDeath.length === 0) return 0;
|
||||
return xDeath[0].count ?? 0;
|
||||
}
|
||||
|
||||
function hashPayload(payload: Record<string, unknown>): string {
|
||||
return crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex');
|
||||
}
|
||||
|
||||
async function isAlreadyProcessed(eventId: string): Promise<boolean> {
|
||||
const row = await db('processed_events').where({ event_id: eventId }).first();
|
||||
return !!row;
|
||||
}
|
||||
|
||||
async function markProcessed(eventId: string, eventType: string, payloadHash: string): Promise<void> {
|
||||
await db('processed_events').insert({
|
||||
event_id: eventId,
|
||||
event_type: eventType,
|
||||
payload_hash: payloadHash,
|
||||
});
|
||||
}
|
||||
|
||||
export async function startConsumer(channel: Channel): Promise<void> {
|
||||
console.log('[Consumer] Listening on queue:', env.rabbitmqWalletQueue);
|
||||
|
||||
await channel.consume(env.rabbitmqWalletQueue, async (msg) => {
|
||||
if (!msg) return;
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(msg.content.toString());
|
||||
} catch {
|
||||
console.error('[Consumer] Invalid JSON, nacking without requeue');
|
||||
channel.nack(msg, false, false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isValidEvent(parsed)) {
|
||||
console.error('[Consumer] Schema validation failed, nacking without requeue');
|
||||
channel.nack(msg, false, false);
|
||||
return;
|
||||
}
|
||||
|
||||
const event = parsed;
|
||||
|
||||
// Idempotency check
|
||||
try {
|
||||
if (await isAlreadyProcessed(event.event_id)) {
|
||||
console.log(`[Consumer] Event ${event.event_id} already processed, acking`);
|
||||
channel.ack(msg);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Consumer] DB error checking idempotency, nacking with requeue');
|
||||
channel.nack(msg, false, true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check retry count
|
||||
const retries = getRetryCount(msg);
|
||||
if (retries >= MAX_RETRIES) {
|
||||
console.error(`[Consumer] Event ${event.event_id} exceeded max retries (${MAX_RETRIES}), sending to DLQ`);
|
||||
channel.nack(msg, false, false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
switch (event.event_type) {
|
||||
case 'user.kyc_verified':
|
||||
await handleKycVerified(event.payload);
|
||||
break;
|
||||
case 'user.deleted':
|
||||
await handleUserDeleted(event.payload);
|
||||
break;
|
||||
default:
|
||||
console.warn(`[Consumer] Unknown event type: ${event.event_type}, acking`);
|
||||
channel.ack(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
const payloadHash = hashPayload(event.payload);
|
||||
await markProcessed(event.event_id, event.event_type, payloadHash);
|
||||
channel.ack(msg);
|
||||
console.log(`[Consumer] Processed event: ${event.event_id} (${event.event_type})`);
|
||||
} catch (err: any) {
|
||||
console.error(`[Consumer] Handler error for ${event.event_id}:`, err.message);
|
||||
// DB/handler error -- requeue for retry
|
||||
channel.nack(msg, false, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { UserModel } from '../../models/user.model';
|
||||
|
||||
interface UserDeletedPayload {
|
||||
bitok_user_id: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export async function handleUserDeleted(payload: Record<string, unknown>): Promise<void> {
|
||||
const data = payload as unknown as UserDeletedPayload;
|
||||
|
||||
if (!data.bitok_user_id) {
|
||||
throw new Error('Invalid user.deleted payload: missing bitok_user_id');
|
||||
}
|
||||
|
||||
await UserModel.softDelete(data.bitok_user_id);
|
||||
console.log(`[UserDeleted] Soft-deleted user ${data.bitok_user_id} reason=${data.reason}`);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { UserModel } from '../../models/user.model';
|
||||
|
||||
interface KycVerifiedPayload {
|
||||
bitok_user_id: string;
|
||||
kyc_verified: boolean;
|
||||
kyc_level: string;
|
||||
}
|
||||
|
||||
export async function handleKycVerified(payload: Record<string, unknown>): Promise<void> {
|
||||
const data = payload as unknown as KycVerifiedPayload;
|
||||
|
||||
if (!data.bitok_user_id || typeof data.kyc_verified !== 'boolean') {
|
||||
throw new Error('Invalid kyc_verified payload');
|
||||
}
|
||||
|
||||
await UserModel.updateKyc(data.bitok_user_id, data.kyc_verified, data.kyc_level || null);
|
||||
console.log(`[KYC] Updated KYC for user ${data.bitok_user_id}: verified=${data.kyc_verified}, level=${data.kyc_level}`);
|
||||
}
|
||||
@@ -1,23 +1,43 @@
|
||||
import knex from 'knex';
|
||||
import knexConfig from './db/knexfile';
|
||||
import app from './app';
|
||||
import { env, initEnv } from './config/env';
|
||||
import { createRabbitConnection } from './events/connection';
|
||||
import { startConsumer } from './events/consumer';
|
||||
import { env, initEnv, getVaultToken } from './config/env';
|
||||
import { loadJwtKeysFromVault } from './services/jwt.service';
|
||||
import { logger } from './lib/logger';
|
||||
|
||||
async function main() {
|
||||
logger.info(`Wallet service instance started with id ${logger.instanceId}`);
|
||||
|
||||
await initEnv();
|
||||
|
||||
// Start RabbitMQ consumer
|
||||
try {
|
||||
const channel = await createRabbitConnection();
|
||||
await startConsumer(channel);
|
||||
console.log('[API] RabbitMQ consumer started');
|
||||
} catch (err: any) {
|
||||
console.warn('[API] RabbitMQ not available, events will not be consumed:', err.message);
|
||||
// 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, () => {
|
||||
console.log(`[API] Server running on port ${env.port}`);
|
||||
logger.info(`Server running on port ${env.port}`);
|
||||
});
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
main().catch((err) => {
|
||||
logger.error(`Failed to start: ${err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
38
apps/api/src/lib/logger.ts
Normal file
38
apps/api/src/lib/logger.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { generateUlid } from '../utils/ulid';
|
||||
import { getTraceId } from './trace-store';
|
||||
|
||||
const instanceId = generateUlid();
|
||||
|
||||
function getCallerInfo(): { file: string; line: number } {
|
||||
const stack = new Error().stack;
|
||||
if (!stack) return { file: 'unknown', line: 0 };
|
||||
|
||||
const lines = stack.split('\n');
|
||||
// Skip: Error, logger method, actual caller
|
||||
const callerLine = lines[3] || '';
|
||||
const match = callerLine.match(/\((.+):(\d+):\d+\)/) || callerLine.match(/at (.+):(\d+):\d+/);
|
||||
if (match) return { file: match[1], line: parseInt(match[2]) };
|
||||
return { file: 'unknown', line: 0 };
|
||||
}
|
||||
|
||||
function log(level: string, message: string): void {
|
||||
const caller = getCallerInfo();
|
||||
const entry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
instance_id: instanceId,
|
||||
file: caller.file,
|
||||
line: caller.line,
|
||||
trace_id: getTraceId(),
|
||||
message,
|
||||
};
|
||||
process.stdout.write(JSON.stringify(entry) + '\n');
|
||||
}
|
||||
|
||||
export const logger = {
|
||||
instanceId,
|
||||
info: (msg: string) => log('INFO', msg),
|
||||
warn: (msg: string) => log('WARN', msg),
|
||||
error: (msg: string) => log('ERROR', msg),
|
||||
debug: (msg: string) => log('DEBUG', msg),
|
||||
};
|
||||
7
apps/api/src/lib/trace-store.ts
Normal file
7
apps/api/src/lib/trace-store.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
|
||||
export const traceStore = new AsyncLocalStorage<string>();
|
||||
|
||||
export function getTraceId(): string {
|
||||
return traceStore.getStore() || 'N/A';
|
||||
}
|
||||
41
apps/api/src/middleware/auth.ts
Normal file
41
apps/api/src/middleware/auth.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { verifyAccessToken, AuthContext } from '../services/jwt.service';
|
||||
import { logger } from '../lib/logger';
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
auth?: AuthContext;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractToken(req: Request): string | null {
|
||||
const cookie = req.cookies?.access_token;
|
||||
if (cookie) return cookie;
|
||||
|
||||
const auth = req.headers.authorization;
|
||||
if (auth) {
|
||||
const [scheme, token] = auth.split(' ');
|
||||
if (scheme?.toLowerCase() === 'bearer' && token) return token;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function authMiddleware(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
const token = extractToken(req);
|
||||
|
||||
if (!token) {
|
||||
res.status(401).json({ success: false, error: 'Not authenticated' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
req.auth = await verifyAccessToken(token);
|
||||
next();
|
||||
} catch (err: any) {
|
||||
logger.warn(`Auth failed: ${err.message}`);
|
||||
res.status(err.status || 401).json({ success: false, error: err.message || 'Invalid token' });
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { jwtVerify, decodeProtectedHeader } from 'jose';
|
||||
import { getSigningKey } from '../services/jwks.service';
|
||||
import { env } from '../config/env';
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
user?: { bitokUserId: string; email?: string };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function bitokAuth(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
const header = req.headers.authorization;
|
||||
if (!header?.startsWith('Bearer ')) {
|
||||
res.status(401).json({ success: false, error: 'No token provided' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = header.slice(7);
|
||||
|
||||
// Decode header to get kid
|
||||
const protectedHeader = decodeProtectedHeader(token);
|
||||
if (protectedHeader.alg !== 'RS256') {
|
||||
res.status(401).json({ success: false, error: 'Invalid token algorithm' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!protectedHeader.kid) {
|
||||
res.status(401).json({ success: false, error: 'Token missing kid' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the signing key for this kid
|
||||
const key = await getSigningKey(protectedHeader.kid);
|
||||
|
||||
// Verify the token
|
||||
const { payload } = await jwtVerify(token, key, {
|
||||
issuer: env.bitokIssuer,
|
||||
audience: env.bitokAudience,
|
||||
algorithms: ['RS256'],
|
||||
});
|
||||
|
||||
if (!payload.sub) {
|
||||
res.status(401).json({ success: false, error: 'Token missing subject' });
|
||||
return;
|
||||
}
|
||||
|
||||
req.user = {
|
||||
bitokUserId: payload.sub,
|
||||
email: payload.email as string | undefined,
|
||||
};
|
||||
|
||||
next();
|
||||
} catch {
|
||||
res.status(401).json({ success: false, error: 'Invalid or expired token' });
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { logger } from '../lib/logger';
|
||||
|
||||
export function errorHandler(err: Error, _req: Request, res: Response, _next: NextFunction): void {
|
||||
console.error('[ERROR]', err.message);
|
||||
logger.error(err.message);
|
||||
res.status(500).json({ success: false, error: 'Internal server error' });
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import rateLimit from 'express-rate-limit';
|
||||
|
||||
export const loginLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 20,
|
||||
message: { success: false, error: 'Too many login attempts, try again later' },
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
|
||||
export const registerLimiter = rateLimit({
|
||||
windowMs: 60 * 60 * 1000,
|
||||
max: 3,
|
||||
message: { success: false, error: 'Too many registration attempts, try again later' },
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
|
||||
export const seedPhraseLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 3,
|
||||
message: { success: false, error: 'Too many attempts. Try again in 15 minutes.' },
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
15
apps/api/src/middleware/trace.ts
Normal file
15
apps/api/src/middleware/trace.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { generateUlid } from '../utils/ulid';
|
||||
import { traceStore } from '../lib/trace-store';
|
||||
|
||||
export function traceMiddleware(req: Request, res: Response, next: NextFunction): void {
|
||||
const traceId = req.headers['x-trace-id'] as string
|
||||
|| req.headers['x-request-id'] as string
|
||||
|| generateUlid();
|
||||
|
||||
res.setHeader('X-Trace-ID', traceId);
|
||||
|
||||
traceStore.run(traceId, () => {
|
||||
next();
|
||||
});
|
||||
}
|
||||
66
apps/api/src/models/session.model.ts
Normal file
66
apps/api/src/models/session.model.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { db } from '../config/database';
|
||||
import { generateUlid } from '../utils/ulid';
|
||||
|
||||
export interface SessionRow {
|
||||
id: string;
|
||||
sid: string;
|
||||
user_id: string;
|
||||
device_id: string | null;
|
||||
user_agent: string | null;
|
||||
first_ip: string | null;
|
||||
last_ip: string | null;
|
||||
last_seen_at: Date | null;
|
||||
revoked_at: Date | null;
|
||||
refresh_jti_hash: string | null;
|
||||
refresh_expires_at: Date | null;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export const SessionModel = {
|
||||
async findBySid(sid: string): Promise<SessionRow | undefined> {
|
||||
return db('sessions').where({ sid }).whereNull('revoked_at').first();
|
||||
},
|
||||
|
||||
async findByUserId(userId: string): Promise<SessionRow[]> {
|
||||
return db('sessions').where({ user_id: userId }).whereNull('revoked_at');
|
||||
},
|
||||
|
||||
async create(data: {
|
||||
sid: string;
|
||||
user_id: string;
|
||||
device_id?: string;
|
||||
user_agent?: string;
|
||||
first_ip?: string;
|
||||
refresh_jti_hash?: string;
|
||||
refresh_expires_at?: Date;
|
||||
}): Promise<SessionRow> {
|
||||
const [session] = await db('sessions')
|
||||
.insert({
|
||||
id: generateUlid(),
|
||||
...data,
|
||||
last_ip: data.first_ip || null,
|
||||
})
|
||||
.returning('*');
|
||||
return session;
|
||||
},
|
||||
|
||||
async revoke(sid: string): Promise<void> {
|
||||
await db('sessions')
|
||||
.where({ sid })
|
||||
.update({ revoked_at: db.fn.now(), updated_at: db.fn.now() });
|
||||
},
|
||||
|
||||
async revokeAllForUser(userId: string): Promise<void> {
|
||||
await db('sessions')
|
||||
.where({ user_id: userId })
|
||||
.whereNull('revoked_at')
|
||||
.update({ revoked_at: db.fn.now(), updated_at: db.fn.now() });
|
||||
},
|
||||
|
||||
async updateLastSeen(sid: string, ip: string): Promise<void> {
|
||||
await db('sessions')
|
||||
.where({ sid })
|
||||
.update({ last_seen_at: db.fn.now(), last_ip: ip, updated_at: db.fn.now() });
|
||||
},
|
||||
};
|
||||
@@ -3,71 +3,47 @@ import { generateUlid } from '../utils/ulid';
|
||||
|
||||
export interface UserRow {
|
||||
id: string;
|
||||
bitok_user_id: string;
|
||||
email: string | null;
|
||||
encrypted_vault: string;
|
||||
vault_salt: string;
|
||||
mnemonic_shown: boolean;
|
||||
email: string;
|
||||
password_hash: string;
|
||||
last_name: string | null;
|
||||
first_name: string | null;
|
||||
middle_name: string | null;
|
||||
birth_date: string | null;
|
||||
crypto_wallet: string | null;
|
||||
phone: string | null;
|
||||
bik: string | null;
|
||||
account_number: string | null;
|
||||
card_number: string | null;
|
||||
inn: string | null;
|
||||
kyc_verified: boolean;
|
||||
kyc_level: string | null;
|
||||
deleted: boolean;
|
||||
kyc_verified_at: Date | null;
|
||||
is_deleted: boolean;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export const UserModel = {
|
||||
async findByEmail(email: string): Promise<UserRow | undefined> {
|
||||
return db('users').where({ email, is_deleted: false }).first();
|
||||
},
|
||||
|
||||
async findById(id: string): Promise<UserRow | undefined> {
|
||||
return db('users').where({ id }).first();
|
||||
return db('users').where({ id, is_deleted: false }).first();
|
||||
},
|
||||
|
||||
async findByBitokUserId(bitokUserId: string): Promise<UserRow | undefined> {
|
||||
return db('users').where({ bitok_user_id: bitokUserId }).first();
|
||||
},
|
||||
|
||||
async createFromBitok(data: {
|
||||
bitokUserId: string;
|
||||
email?: string | null;
|
||||
encryptedVault: string;
|
||||
vaultSalt: string;
|
||||
async create(data: {
|
||||
email: string;
|
||||
password_hash: string;
|
||||
}): Promise<UserRow> {
|
||||
const [user] = await db('users')
|
||||
.insert({
|
||||
id: generateUlid(),
|
||||
bitok_user_id: data.bitokUserId,
|
||||
email: data.email || null,
|
||||
encrypted_vault: data.encryptedVault,
|
||||
vault_salt: data.vaultSalt,
|
||||
})
|
||||
.returning('*');
|
||||
const [user] = await db('users').insert({ id: generateUlid(), ...data }).returning('*');
|
||||
return user;
|
||||
},
|
||||
|
||||
async setMnemonicShown(id: string): Promise<void> {
|
||||
await db('users').where({ id }).update({ mnemonic_shown: true, updated_at: db.fn.now() });
|
||||
},
|
||||
|
||||
async updateVault(id: string, encrypted_vault: string, vault_salt: string): Promise<void> {
|
||||
await db('users')
|
||||
async update(id: string, data: Partial<Omit<UserRow, 'id' | 'created_at'>>): Promise<UserRow | undefined> {
|
||||
const [user] = await db('users')
|
||||
.where({ id })
|
||||
.update({ encrypted_vault, vault_salt, updated_at: db.fn.now() });
|
||||
},
|
||||
|
||||
async updateKyc(bitokUserId: string, kycVerified: boolean, kycLevel: string | null): Promise<void> {
|
||||
await db('users')
|
||||
.where({ bitok_user_id: bitokUserId })
|
||||
.update({
|
||||
kyc_verified: kycVerified,
|
||||
kyc_level: kycLevel,
|
||||
updated_at: db.fn.now(),
|
||||
});
|
||||
},
|
||||
|
||||
async softDelete(bitokUserId: string): Promise<void> {
|
||||
await db('users')
|
||||
.where({ bitok_user_id: bitokUserId })
|
||||
.update({
|
||||
deleted: true,
|
||||
updated_at: db.fn.now(),
|
||||
});
|
||||
.update({ ...data, updated_at: db.fn.now() })
|
||||
.returning('*');
|
||||
return user;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Router } from 'express';
|
||||
import { VaultController } from '../controllers/vault.controller';
|
||||
import { bitokAuth } from '../middleware/bitok-auth';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', bitokAuth, VaultController.getVault);
|
||||
|
||||
export default router;
|
||||
@@ -1,25 +0,0 @@
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { WalletSetupController } from '../controllers/wallet-setup.controller';
|
||||
import { validate } from '../middleware/validate';
|
||||
import { bitokAuth } from '../middleware/bitok-auth';
|
||||
|
||||
const setupSchema = z.object({
|
||||
encryptedVault: z.string().min(1),
|
||||
vaultSalt: z.string().min(1),
|
||||
wallets: z.array(
|
||||
z.object({
|
||||
chain: z.enum(['ETH', 'BTC', 'SOL', 'TRX', 'BSC']),
|
||||
address: z.string().min(1),
|
||||
derivationPath: z.string().min(1),
|
||||
})
|
||||
).min(4).max(5),
|
||||
});
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post('/setup', bitokAuth, validate(setupSchema), WalletSetupController.setup);
|
||||
router.get('/unlock', bitokAuth, WalletSetupController.unlock);
|
||||
router.post('/confirm-mnemonic', bitokAuth, WalletSetupController.confirmMnemonic);
|
||||
|
||||
export default router;
|
||||
@@ -1,9 +1,8 @@
|
||||
import { Router } from 'express';
|
||||
import { WalletController } from '../controllers/wallet.controller';
|
||||
import { bitokAuth } from '../middleware/bitok-auth';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', bitokAuth, WalletController.getWallets);
|
||||
router.get('/', WalletController.getWallets);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { importJWK, type JWK, type CryptoKey } from 'jose';
|
||||
import { env } from '../config/env';
|
||||
|
||||
interface CachedKey {
|
||||
key: CryptoKey | Uint8Array;
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
const KEY_TTL_MS = 60 * 60 * 1000; // 1 hour
|
||||
const keyCache = new Map<string, CachedKey>();
|
||||
|
||||
async function fetchJwks(): Promise<{ keys: JWK[] }> {
|
||||
const res = await fetch(env.bitokJwksUrl);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch JWKS: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
return res.json() as Promise<{ keys: JWK[] }>;
|
||||
}
|
||||
|
||||
async function refreshKeys(): Promise<void> {
|
||||
const jwks = await fetchJwks();
|
||||
|
||||
for (const jwk of jwks.keys) {
|
||||
if (!jwk.kid) continue;
|
||||
const key = await importJWK(jwk, 'RS256');
|
||||
keyCache.set(jwk.kid, { key, fetchedAt: Date.now() });
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSigningKey(kid: string): Promise<CryptoKey | Uint8Array> {
|
||||
const cached = keyCache.get(kid);
|
||||
|
||||
if (cached && Date.now() - cached.fetchedAt < KEY_TTL_MS) {
|
||||
return cached.key;
|
||||
}
|
||||
|
||||
// Unknown kid or expired -- force refresh
|
||||
await refreshKeys();
|
||||
|
||||
const refreshed = keyCache.get(kid);
|
||||
if (!refreshed) {
|
||||
throw new Error(`No key found for kid: ${kid}`);
|
||||
}
|
||||
|
||||
return refreshed.key;
|
||||
}
|
||||
127
apps/api/src/services/jwt.service.ts
Normal file
127
apps/api/src/services/jwt.service.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import * as jose from 'jose';
|
||||
import { env } from '../config/env';
|
||||
import { logger } from '../lib/logger';
|
||||
|
||||
export interface AccessTokenPayload {
|
||||
sub: string;
|
||||
type: string;
|
||||
sid: string;
|
||||
iat: number;
|
||||
nbf: number;
|
||||
exp: number;
|
||||
iss?: string;
|
||||
aud?: string;
|
||||
}
|
||||
|
||||
export interface AuthContext {
|
||||
userId: string;
|
||||
sid: string;
|
||||
token: AccessTokenPayload;
|
||||
}
|
||||
|
||||
const keyMap = new Map<string, Awaited<ReturnType<typeof jose.importSPKI>>>();
|
||||
|
||||
export async function loadJwtKeysFromVault(
|
||||
vaultAddr: string,
|
||||
vaultToken: string,
|
||||
mount: string,
|
||||
kidPath: string,
|
||||
kidsPrefix: string,
|
||||
): Promise<void> {
|
||||
const { fetchVaultKV2 } = await import('../config/vault');
|
||||
|
||||
const kidData = await fetchVaultKV2(vaultAddr, vaultToken, mount, kidPath);
|
||||
if (!kidData) {
|
||||
logger.warn('Failed to read JWT kid config from Vault');
|
||||
return;
|
||||
}
|
||||
|
||||
const kids: string[] = [];
|
||||
if (kidData.active) kids.push(kidData.active);
|
||||
if (kidData.previous && kidData.previous !== kidData.active) kids.push(kidData.previous);
|
||||
|
||||
if (kids.length === 0) {
|
||||
logger.warn('No active/previous kids found in Vault');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const kid of kids) {
|
||||
const kidSecret = await fetchVaultKV2(vaultAddr, vaultToken, mount, `${kidsPrefix}/${kid}`);
|
||||
if (!kidSecret?.public_key) {
|
||||
logger.warn(`No public_key found for kid=${kid}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const key = await jose.importSPKI(kidSecret.public_key, env.jwt.algorithm);
|
||||
keyMap.set(kid, key);
|
||||
logger.info(`Loaded JWT public key for kid=${kid}`);
|
||||
} catch (err: any) {
|
||||
logger.error(`Failed to import public key for kid=${kid}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`JWT key store loaded: ${keyMap.size} key(s)`);
|
||||
}
|
||||
|
||||
export async function verifyAccessToken(token: string): Promise<AuthContext> {
|
||||
let payload: jose.JWTPayload;
|
||||
|
||||
try {
|
||||
const header = jose.decodeProtectedHeader(token);
|
||||
const kid = header.kid;
|
||||
|
||||
if (!kid) {
|
||||
throw Object.assign(new Error('Missing kid in token header'), { status: 401 });
|
||||
}
|
||||
|
||||
const key = keyMap.get(kid);
|
||||
if (!key) {
|
||||
logger.warn(`Unknown kid=${kid}`);
|
||||
throw Object.assign(new Error('Unknown token kid'), { status: 401 });
|
||||
}
|
||||
|
||||
if (header.alg !== env.jwt.algorithm) {
|
||||
throw Object.assign(new Error('Invalid token algorithm'), { status: 401 });
|
||||
}
|
||||
|
||||
const verifyOptions: jose.JWTVerifyOptions = {
|
||||
algorithms: [env.jwt.algorithm],
|
||||
clockTolerance: 10,
|
||||
};
|
||||
if (env.jwt.issuer) verifyOptions.issuer = env.jwt.issuer;
|
||||
if (env.jwt.audience) verifyOptions.audience = env.jwt.audience;
|
||||
|
||||
const result = await jose.jwtVerify(token, key, verifyOptions);
|
||||
payload = result.payload;
|
||||
} catch (err: any) {
|
||||
if (err.status === 401) throw err;
|
||||
if (err.code === 'ERR_JWT_EXPIRED') {
|
||||
throw Object.assign(new Error('Token expired'), { status: 401 });
|
||||
}
|
||||
throw Object.assign(new Error('Invalid token'), { status: 401 });
|
||||
}
|
||||
|
||||
if (payload.type !== 'access') {
|
||||
throw Object.assign(new Error('Invalid token type'), { status: 401 });
|
||||
}
|
||||
|
||||
if (!payload.sub || !payload.sid) {
|
||||
throw Object.assign(new Error('Missing token claims'), { status: 401 });
|
||||
}
|
||||
|
||||
return {
|
||||
userId: payload.sub,
|
||||
sid: payload.sid as string,
|
||||
token: {
|
||||
sub: payload.sub,
|
||||
type: payload.type as string,
|
||||
sid: payload.sid as string,
|
||||
iat: payload.iat!,
|
||||
nbf: payload.nbf!,
|
||||
exp: payload.exp!,
|
||||
iss: payload.iss,
|
||||
aud: typeof payload.aud === 'string' ? payload.aud : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
101
apps/api/swagger.json
Normal file
101
apps/api/swagger.json
Normal file
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"openapi": "3.0.0",
|
||||
"info": {
|
||||
"title": "CryptoWallet API",
|
||||
"version": "2.0.0",
|
||||
"description": "Multi-chain cryptocurrency wallet API with blockchain proxy services"
|
||||
},
|
||||
"servers": [
|
||||
{ "url": "/api", "description": "API" }
|
||||
],
|
||||
"components": {
|
||||
"securitySchemes": {
|
||||
"bearerAuth": {
|
||||
"type": "http",
|
||||
"scheme": "bearer",
|
||||
"bearerFormat": "JWT"
|
||||
}
|
||||
},
|
||||
"schemas": {
|
||||
"Error": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": { "type": "boolean", "example": false },
|
||||
"error": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"Wallet": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"chain": { "type": "string", "enum": ["ETH", "BTC", "SOL", "TRX", "BSC"] },
|
||||
"address": { "type": "string" },
|
||||
"derivationPath": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"HealthResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": { "type": "boolean", "example": true },
|
||||
"data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": { "type": "string", "example": "ok" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"paths": {
|
||||
"/health": {
|
||||
"get": {
|
||||
"summary": "Health check",
|
||||
"tags": ["System"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Service is healthy",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/HealthResponse" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/wallets": {
|
||||
"get": {
|
||||
"summary": "Get user wallets",
|
||||
"tags": ["Wallets"],
|
||||
"security": [{ "bearerAuth": [] }],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "List of wallets",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": { "type": "boolean", "example": true },
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/components/schemas/Wallet" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Not authenticated",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/Error" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user