init2222
This commit is contained in:
@@ -27,6 +27,13 @@ JWT_AUDIENCE=elcsa
|
||||
API_PORT=3001
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# ── KeyDB / Redis (idempotency cache) ──────────────────────────────
|
||||
# REDIS_PASSWORD also used by docker-compose to seed KeyDB --requirepass.
|
||||
REDIS_HOST=keydb
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=
|
||||
REDIS_DB=0
|
||||
|
||||
# ── CORS ────────────────────────────────────────────────────────────
|
||||
# Comma-separated list of allowed origins. ПУСТО = no cross-origin.
|
||||
# Никогда не используй wildcard *
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"express": "^4.21.0",
|
||||
"express-rate-limit": "^8.4.1",
|
||||
"helmet": "^8.0.0",
|
||||
"ioredis": "^5.4.0",
|
||||
"jose": "^6.2.2",
|
||||
"knex": "^3.1.0",
|
||||
"pg": "^8.13.0",
|
||||
|
||||
@@ -53,6 +53,12 @@ export let env = {
|
||||
allowCredentials: p.CORS_ALLOW_CREDENTIALS === 'true',
|
||||
},
|
||||
port: parseInt(p.API_PORT || '3001'),
|
||||
redis: {
|
||||
host: p.REDIS_HOST || 'keydb',
|
||||
port: parseInt(p.REDIS_PORT || '6379'),
|
||||
password: p.REDIS_PASSWORD || '',
|
||||
db: parseInt(p.REDIS_DB || '0'),
|
||||
},
|
||||
relayApiKey: p.RELAY_API_KEY || null,
|
||||
tronApiKey: p.TRON_API_KEY || null,
|
||||
jupiterApiKey: p.JUPITER_API_KEY || null,
|
||||
|
||||
86
apps/api/src/config/redis.ts
Normal file
86
apps/api/src/config/redis.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* KeyDB / Redis singleton client.
|
||||
*
|
||||
* Используется для idempotency cache (см. `lib/idempotency.ts`).
|
||||
*
|
||||
* Connection:
|
||||
* REDIS_HOST=keydb (docker service name) / REDIS_PORT=6379 / REDIS_PASSWORD / REDIS_DB=0
|
||||
*
|
||||
* Startup contract: `pingRedis()` вызывается из `index.ts` и throws если KeyDB
|
||||
* unreachable — fail-fast, потому что idempotency critical для money flow.
|
||||
*/
|
||||
|
||||
import Redis, { type RedisOptions } from 'ioredis';
|
||||
import { logger } from '../lib/logger';
|
||||
|
||||
let _client: Redis | null = null;
|
||||
|
||||
function buildClient(): Redis {
|
||||
const host = process.env.REDIS_HOST || 'keydb';
|
||||
const port = parseInt(process.env.REDIS_PORT || '6379', 10);
|
||||
const password = process.env.REDIS_PASSWORD || '';
|
||||
const db = parseInt(process.env.REDIS_DB || '0', 10);
|
||||
|
||||
if (!Number.isFinite(port) || port < 1 || port > 65535) {
|
||||
throw new Error(`Invalid REDIS_PORT ${process.env.REDIS_PORT}`);
|
||||
}
|
||||
if (!Number.isFinite(db) || db < 0 || db > 15) {
|
||||
throw new Error(`Invalid REDIS_DB ${process.env.REDIS_DB} (must be 0-15)`);
|
||||
}
|
||||
|
||||
const opts: RedisOptions = {
|
||||
host,
|
||||
port,
|
||||
db,
|
||||
lazyConnect: true,
|
||||
// Не зависать forever — fail-fast если cache недоступен
|
||||
connectTimeout: 5000,
|
||||
maxRetriesPerRequest: 3,
|
||||
// Reconnect strategy: exponential backoff, max 5s
|
||||
retryStrategy: (times) => Math.min(times * 200, 5000),
|
||||
};
|
||||
if (password) opts.password = password;
|
||||
|
||||
const client = new Redis(opts);
|
||||
|
||||
client.on('error', (err) => {
|
||||
// Не логируем secret в случае конфигурационной ошибки
|
||||
logger.error(`Redis client error: ${err.message}`);
|
||||
});
|
||||
client.on('connect', () => logger.info(`Redis connected (host=${host}:${port} db=${db})`));
|
||||
client.on('reconnecting', (delay: number) => logger.warn(`Redis reconnecting in ${delay}ms`));
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
/** Lazily initialised singleton. */
|
||||
export function getRedis(): Redis {
|
||||
if (!_client) {
|
||||
_client = buildClient();
|
||||
}
|
||||
return _client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Startup ping. Throws on failure → caller process.exit(1).
|
||||
* Connect-on-demand (lazyConnect=true), .ping() триггерит connect + первый round-trip.
|
||||
*/
|
||||
export async function pingRedis(): Promise<void> {
|
||||
const client = getRedis();
|
||||
try {
|
||||
const pong = await client.ping();
|
||||
if (pong !== 'PONG') {
|
||||
throw new Error(`Redis PING returned ${pong} (expected PONG)`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
throw new Error(`Redis ping failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Graceful shutdown — closes connection cleanly. */
|
||||
export async function closeRedis(): Promise<void> {
|
||||
if (_client) {
|
||||
await _client.quit().catch(() => _client?.disconnect());
|
||||
_client = null;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { env, initEnv } from './config/env';
|
||||
import { refreshAllKeys, startKeyRotation, stopKeyRotation } from './services/key-rotation.service';
|
||||
import { isCryptoReady, decryptMnemonic, encryptMnemonic } from './services/crypto.service';
|
||||
import { db } from './config/database';
|
||||
import { pingRedis, closeRedis } from './config/redis';
|
||||
import { logger } from './lib/logger';
|
||||
|
||||
// Global error handlers — иначе unhandled errors идут в stderr без sanitization (leak secrets)
|
||||
@@ -36,6 +37,15 @@ async function main() {
|
||||
// и любой /send или /sign будет тихо валиться с GCM auth error. Лучше упасть сразу.
|
||||
await runCryptoIntegritySelfTest();
|
||||
|
||||
// KeyDB / Redis ping — idempotency critical для money flow; fail-fast если недоступен.
|
||||
try {
|
||||
await pingRedis();
|
||||
logger.info('KeyDB/Redis self-test: PASSED');
|
||||
} catch (err: any) {
|
||||
logger.error(`KeyDB/Redis ping failed: ${err.message}. Refusing to start (idempotency unavailable).`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
startKeyRotation();
|
||||
|
||||
const server = app.listen(env.port, () => {
|
||||
@@ -45,6 +55,7 @@ async function main() {
|
||||
const shutdown = (signal: string) => {
|
||||
logger.info(`${signal} received, shutting down gracefully`);
|
||||
stopKeyRotation();
|
||||
void closeRedis();
|
||||
server.close(() => process.exit(0));
|
||||
// Force exit if shutdown takes too long
|
||||
setTimeout(() => process.exit(1), 10_000).unref();
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
/**
|
||||
* Audit log — durable durable durable.
|
||||
* Audit log — STDOUT ONLY (best-effort).
|
||||
*
|
||||
* Two sinks:
|
||||
* 1. **DB `audit_log` table** — primary, used by `auditLogStrict` для critical
|
||||
* операций. INSERT pending → mutation → UPDATE success/failure с txid.
|
||||
* Если INSERT fails — operation must NOT proceed (fail-secure).
|
||||
* 2. **stdout JSON line** — для log-aggregator (Docker logs / Loki etc).
|
||||
* Best-effort, всегда (даже если DB sink fails).
|
||||
* ⚠️ DURABLE AUDIT REMOVED. Per design choice: `audit_log` DB-таблица убрана,
|
||||
* pre-mutation INSERT pattern → not used. Audit-trail доступен только в Docker
|
||||
* stdout (`level=audit` JSON lines), который log-aggregator (Loki/CloudWatch/etc.)
|
||||
* подбирает.
|
||||
*
|
||||
* НИКОГДА не логирует mnemonic / privkey / encrypted blob.
|
||||
* Trade-off: stdout не обеспечивает strict fail-secure семантику. Если Docker
|
||||
* log driver buffer переполнится или log-aggregator down — записи могут потеряться.
|
||||
* Если потребуется restore compliance-grade audit — вернуть `audit_log` table
|
||||
* и pre-mutation INSERT/UPDATE pattern (см. git history).
|
||||
*
|
||||
* Public API сохраняет signatures из предыдущей DB-версии для backward compat
|
||||
* без рефакторинга wallet.controller.ts callers:
|
||||
* - `auditLog(entry)` — best-effort, returns void
|
||||
* - `auditLogStrict(entry)` — now == auditLog + returns dummy ID для compat
|
||||
* - `completeAudit(id, ...)` — теперь stdout-mirror update event
|
||||
*/
|
||||
|
||||
import { ulid } from 'ulidx';
|
||||
import { db } from '../config/database';
|
||||
import { getTraceId } from './trace-store';
|
||||
import { logger } from './logger';
|
||||
|
||||
export interface AuditEntry {
|
||||
event: string;
|
||||
@@ -25,7 +29,7 @@ export interface AuditEntry {
|
||||
errorCode?: string;
|
||||
}
|
||||
|
||||
function buildStdoutLine(entry: AuditEntry, status: 'pending' | 'success' | 'failure'): string {
|
||||
function buildLine(entry: AuditEntry, status: string): string {
|
||||
return JSON.stringify({
|
||||
level: 'audit',
|
||||
status,
|
||||
@@ -39,54 +43,36 @@ function writeStdoutBestEffort(line: string): void {
|
||||
try {
|
||||
process.stdout.write(line);
|
||||
} catch {
|
||||
// swallow
|
||||
// EPIPE / closed — swallow
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort: stdout only. Используется для info-level событий
|
||||
* (wallet.create success, lookup, etc). Не блокирует request на DB.
|
||||
*/
|
||||
/** Best-effort: stdout only. */
|
||||
export async function auditLog(entry: AuditEntry): Promise<void> {
|
||||
const status: 'success' | 'failure' = entry.result === 'failure' ? 'failure' : 'success';
|
||||
writeStdoutBestEffort(buildStdoutLine(entry, status));
|
||||
writeStdoutBestEffort(buildLine(entry, status));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail-secure audit для critical custodial операций (mnemonic.reveal, wallet.send,
|
||||
* wallet.sign_raw_evm).
|
||||
* Backward-compat shim. Раньше это был pre-mutation DB INSERT (fail-secure).
|
||||
* Сейчас — просто stdout audit + возвращает opaque ID для совместимости с callers
|
||||
* которые передают его в `completeAudit()`.
|
||||
*
|
||||
* Семантика: INSERT row в `audit_log` table перед mutation. Если INSERT FAILS
|
||||
* (DB down, connection pool exhausted, constraint violation) — throws.
|
||||
* Caller ОБЯЗАН abort'нуть mutation, не вернуть response с funds-action.
|
||||
*
|
||||
* Возвращает audit row id — caller использует его в `completeAudit()` после mutation.
|
||||
* Никогда не throws (раньше throw'ил при DB failure → caller отказывал в operation).
|
||||
* Returns timestamp-based ID; не reliable identifier, чисто для completeAudit pairing.
|
||||
*/
|
||||
export async function auditLogStrict(entry: AuditEntry & { status?: 'pending' | 'success' | 'failure' }): Promise<string> {
|
||||
const id = ulid();
|
||||
export async function auditLogStrict(entry: AuditEntry & { status?: string }): Promise<string> {
|
||||
const status = entry.status ?? 'pending';
|
||||
|
||||
// DB INSERT — fail-secure (throws on DB failure)
|
||||
await db('audit_log').insert({
|
||||
id,
|
||||
user_id: entry.userId,
|
||||
event: entry.event,
|
||||
status,
|
||||
error_code: entry.errorCode ?? null,
|
||||
ip: entry.ip ?? null,
|
||||
trace_id: getTraceId() ?? null,
|
||||
meta: entry.meta ? JSON.stringify(entry.meta) : null,
|
||||
});
|
||||
|
||||
// Mirror to stdout (best-effort, не критично)
|
||||
writeStdoutBestEffort(buildStdoutLine(entry, status));
|
||||
|
||||
return id;
|
||||
writeStdoutBestEffort(buildLine(entry, status));
|
||||
// Opaque ID: timestamp-ms + random suffix. Не store'им — только для symmetry call-site.
|
||||
return `audit-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update audit row после mutation (success или failure с txid/error).
|
||||
* Best-effort — если update fails, операция уже произошла, мы just log warning.
|
||||
* Backward-compat: завершающий event audit. Раньше — DB UPDATE row.
|
||||
* Сейчас — просто stdout write parallel event.
|
||||
*
|
||||
* `auditId` параметр игнорируется (его не было где writer'у искать в БД).
|
||||
*/
|
||||
export async function completeAudit(
|
||||
auditId: string,
|
||||
@@ -94,21 +80,10 @@ export async function completeAudit(
|
||||
meta?: Record<string, unknown>,
|
||||
errorCode?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await db('audit_log')
|
||||
.where({ id: auditId })
|
||||
.update({
|
||||
status: result,
|
||||
error_code: errorCode ?? null,
|
||||
meta: meta ? JSON.stringify(meta) : db.raw('meta'),
|
||||
updated_at: db.fn.now(),
|
||||
});
|
||||
} catch (err: any) {
|
||||
logger.error(`completeAudit failed for ${auditId}: ${err?.message}`);
|
||||
}
|
||||
// Mirror to stdout
|
||||
writeStdoutBestEffort(buildStdoutLine(
|
||||
{ event: `audit.update.${auditId}`, userId: '<see-audit-row>', meta, errorCode, result },
|
||||
result,
|
||||
));
|
||||
writeStdoutBestEffort(
|
||||
buildLine(
|
||||
{ event: `audit.complete:${auditId}`, userId: '<see-original-event>', meta, errorCode, result },
|
||||
result,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,28 +1,59 @@
|
||||
/**
|
||||
* Idempotency-Key handling — C3 защита от double-spend при retry.
|
||||
* Idempotency-Key handling — anti-double-spend на retry.
|
||||
*
|
||||
* Storage: KeyDB / Redis (см. `config/redis.ts`).
|
||||
*
|
||||
* Контракт:
|
||||
* Client передаёт header `Idempotency-Key: <opaque-string-up-to-128-chars>`.
|
||||
* Server:
|
||||
* 1. INSERT row (user_id, key, request_hash) — PK conflict = retry detected.
|
||||
* 2. На retry: SELECT existing row. Если response_status is null — operation
|
||||
* ещё in-flight → return 409 "retry too soon". Если response_status set →
|
||||
* return cached response (same status, same body).
|
||||
* Retention: 24h. Cleanup via cron.
|
||||
* 1. `SET NX EX 600 idem:{userId}:{key} '{requestHash,status:null,body:null}'`
|
||||
* - NX (only-if-not-exists) → atomic claim
|
||||
* - EX 600 → 10 минут TTL
|
||||
* 2. Если NX вернул OK → fresh claim, caller proceed'ит mutation.
|
||||
* 3. Если NX вернул null → retry detected. GET значение и:
|
||||
* - request_hash отличается → 409 "key reuse with different body"
|
||||
* - status null → 409 "in-flight, retry after a few seconds"
|
||||
* - status set → return cached response (no double-broadcast)
|
||||
*
|
||||
* После mutation client вызывает `saveIdempotencyResponse(userId, key, status, body)`
|
||||
* чтобы cache последующих retry'ев на тот же key.
|
||||
*
|
||||
* Trade-off vs DB:
|
||||
* + Latency <1ms (single Redis round-trip vs ~5ms DB)
|
||||
* + No DB pressure
|
||||
* + Auto-expiry via Redis EX
|
||||
* + Distributed (multi-replica work через shared cache)
|
||||
* – KeyDB single point of failure → API падает на startup ping (fail-fast)
|
||||
*/
|
||||
|
||||
import { createHash } from 'crypto';
|
||||
import { db } from '../config/database';
|
||||
import { getRedis } from '../config/redis';
|
||||
|
||||
const TTL_SECONDS = 10 * 60; // 10 minutes
|
||||
|
||||
interface CacheEntry {
|
||||
requestHash: string;
|
||||
responseStatus: number | null; // null = in-flight
|
||||
responseBody: string | null;
|
||||
}
|
||||
|
||||
export interface IdempotencyClaim {
|
||||
fresh: boolean;
|
||||
cached?: { status: number; body: string };
|
||||
}
|
||||
|
||||
function cacheKey(userId: string, key: string): string {
|
||||
return `idem:${userId}:${key}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to claim the key. If first time → fresh=true, caller proceeds with mutation.
|
||||
* If duplicate с existing response → fresh=false + cached response.
|
||||
* If duplicate с pending in-flight → throws (caller returns 409).
|
||||
* Atomic claim. Returns:
|
||||
* - fresh=true → caller обязан proceed mutation и save response
|
||||
* - fresh=false + cached → return cached response без mutation (retry case)
|
||||
*
|
||||
* Throws при:
|
||||
* - in-flight (другой attempt ещё не save'нул response)
|
||||
* - body hash mismatch (replay с другим body на тот же key)
|
||||
*/
|
||||
export async function claimIdempotency(
|
||||
userId: string,
|
||||
@@ -33,60 +64,89 @@ export async function claimIdempotency(
|
||||
.update(JSON.stringify(requestBody ?? {}))
|
||||
.digest('hex');
|
||||
|
||||
try {
|
||||
await db('idempotency_keys').insert({
|
||||
user_id: userId,
|
||||
key,
|
||||
request_hash: requestHash,
|
||||
});
|
||||
const redis = getRedis();
|
||||
const k = cacheKey(userId, key);
|
||||
const initial: CacheEntry = {
|
||||
requestHash,
|
||||
responseStatus: null,
|
||||
responseBody: null,
|
||||
};
|
||||
|
||||
// SET key value NX EX seconds — atomic claim. Returns 'OK' if set, null if existed.
|
||||
const setResult = await redis.set(k, JSON.stringify(initial), 'EX', TTL_SECONDS, 'NX');
|
||||
if (setResult === 'OK') {
|
||||
return { fresh: true };
|
||||
} catch (err: any) {
|
||||
// PK violation = retry
|
||||
const existing = await db('idempotency_keys')
|
||||
.where({ user_id: userId, key })
|
||||
.first();
|
||||
if (!existing) throw err;
|
||||
|
||||
// Verify request body matches (защита от replay с другим body)
|
||||
if (existing.request_hash !== requestHash) {
|
||||
throw new Error(`Idempotency-Key reuse with different request body. Use a new key.`);
|
||||
}
|
||||
|
||||
if (existing.response_status === null || existing.response_status === undefined) {
|
||||
throw new Error('Operation already in flight; retry after a few seconds.');
|
||||
}
|
||||
|
||||
return {
|
||||
fresh: false,
|
||||
cached: {
|
||||
status: existing.response_status as number,
|
||||
body: existing.response_body as string,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Already exists — это retry. Читаем.
|
||||
const raw = await redis.get(k);
|
||||
if (!raw) {
|
||||
// Race: между NX и GET значение expired. Перепопытка как fresh.
|
||||
const retry = await redis.set(k, JSON.stringify(initial), 'EX', TTL_SECONDS, 'NX');
|
||||
if (retry === 'OK') return { fresh: true };
|
||||
throw new Error('Idempotency cache race; retry after a few seconds.');
|
||||
}
|
||||
|
||||
let entry: CacheEntry;
|
||||
try {
|
||||
entry = JSON.parse(raw) as CacheEntry;
|
||||
} catch {
|
||||
throw new Error('Idempotency cache entry corrupt');
|
||||
}
|
||||
|
||||
if (entry.requestHash !== requestHash) {
|
||||
throw new Error('Idempotency-Key reuse with different request body. Use a new key.');
|
||||
}
|
||||
|
||||
if (entry.responseStatus === null) {
|
||||
throw new Error('Operation already in flight; retry after a few seconds.');
|
||||
}
|
||||
|
||||
return {
|
||||
fresh: false,
|
||||
cached: {
|
||||
status: entry.responseStatus,
|
||||
body: entry.responseBody ?? '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Сохранить response в idempotency row (после mutation succeeds/fails). */
|
||||
/**
|
||||
* Сохранить response в cache после mutation (success или failure).
|
||||
* Best-effort: если Redis недоступен — log error, не throw (mutation уже произошла,
|
||||
* cache update — UX optimization для retry'ев).
|
||||
*/
|
||||
export async function saveIdempotencyResponse(
|
||||
userId: string,
|
||||
key: string,
|
||||
status: number,
|
||||
body: string,
|
||||
): Promise<void> {
|
||||
await db('idempotency_keys')
|
||||
.where({ user_id: userId, key })
|
||||
.update({
|
||||
response_status: status,
|
||||
response_body: body,
|
||||
});
|
||||
try {
|
||||
const redis = getRedis();
|
||||
const k = cacheKey(userId, key);
|
||||
const raw = await redis.get(k);
|
||||
if (!raw) return; // expired — skip
|
||||
let entry: CacheEntry;
|
||||
try {
|
||||
entry = JSON.parse(raw) as CacheEntry;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
entry.responseStatus = status;
|
||||
entry.responseBody = body;
|
||||
// Re-set with refreshed TTL чтобы retry мог получить cached response
|
||||
await redis.set(k, JSON.stringify(entry), 'EX', TTL_SECONDS);
|
||||
} catch {
|
||||
// Cache update — non-critical
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate header format. Returns null if missing/invalid (caller may make mandatory). */
|
||||
/** Validate header format. Returns null if missing/invalid. */
|
||||
export function extractIdempotencyKey(headerValue: unknown): string | null {
|
||||
if (typeof headerValue !== 'string') return null;
|
||||
const v = headerValue.trim();
|
||||
if (!v) return null;
|
||||
// Restrict charset: alphanum + dash/underscore, max 128
|
||||
if (!/^[A-Za-z0-9_-]{1,128}$/.test(v)) return null;
|
||||
return v;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
-- ║ Безопасно прогонять повторно на existing БД. ║
|
||||
-- ╚══════════════════════════════════════════════════════════════════╝
|
||||
|
||||
-- NOTE: idempotency_keys + audit_log таблицы УДАЛЕНЫ из БД.
|
||||
-- - idempotency_keys → KeyDB (Redis cache), см. apps/api/src/config/redis.ts
|
||||
-- - audit_log → stdout-only (Docker logs / log-aggregator подбирает JSON lines)
|
||||
-- Migration ниже drop'ает их если они существуют от прошлой версии.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id VARCHAR(26) NOT NULL PRIMARY KEY,
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
@@ -43,9 +48,6 @@ BEGIN
|
||||
END $$;
|
||||
|
||||
-- Sanity check на blob size. Floor 100 (worst-case 12-word всё-3char mnemonic):
|
||||
-- plaintext 47 bytes + IV(12) + tag(16) = 75 raw → 100 base64
|
||||
-- typical 12-word: 113 raw → 152 base64; 24-word: 240 raw → 320 base64
|
||||
-- (Раньше floor 140 отвергал ~4% валидных 12-word mnemonics — fixed.)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'users_encrypted_mnemonic_size') THEN
|
||||
@@ -89,8 +91,6 @@ END $$;
|
||||
|
||||
-- ── WALLETS ─────────────────────────────────────────────────────────
|
||||
-- ON DELETE RESTRICT: hard-delete user → запрос отвергнут пока есть wallets.
|
||||
-- Это защита от unrecoverable fund loss при GDPR-wipe или admin удалении.
|
||||
-- Use is_deleted=true для soft-delete.
|
||||
CREATE TABLE IF NOT EXISTS wallets (
|
||||
id VARCHAR(26) NOT NULL PRIMARY KEY,
|
||||
user_id VARCHAR(26) NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||
@@ -118,38 +118,8 @@ BEGIN
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ── AUDIT_LOG (durable sink для критических custodial операций) ─────
|
||||
-- Pre-mutation INSERT 'pending', post-mutation UPDATE 'completed' с txid.
|
||||
-- Если INSERT fails — операция НЕ происходит (fail-secure).
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id VARCHAR(26) NOT NULL PRIMARY KEY,
|
||||
user_id VARCHAR(26) NOT NULL,
|
||||
event VARCHAR(64) NOT NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'success', 'failure')),
|
||||
error_code VARCHAR(64),
|
||||
ip VARCHAR(64),
|
||||
trace_id VARCHAR(64),
|
||||
meta JSONB,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_user_id ON audit_log(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_event_created ON audit_log(event, created_at DESC);
|
||||
|
||||
-- ── IDEMPOTENCY_KEYS (защита от double-spend на retry) ──────────────
|
||||
-- Client шлёт Idempotency-Key header. Pre-mutation INSERT row, post-mutation UPDATE с response.
|
||||
-- На retry — возвращаем cached response без второго broadcast.
|
||||
CREATE TABLE IF NOT EXISTS idempotency_keys (
|
||||
user_id VARCHAR(26) NOT NULL,
|
||||
key VARCHAR(128) NOT NULL,
|
||||
request_hash VARCHAR(64) NOT NULL,
|
||||
response_status SMALLINT,
|
||||
response_body TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id, key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_idempotency_keys_created ON idempotency_keys(created_at);
|
||||
-- Retention cleanup (run via cron): DELETE FROM idempotency_keys WHERE created_at < NOW() - INTERVAL '24 hours';
|
||||
-- ── DROP legacy tables (если existing БД от прошлой версии) ────────
|
||||
-- idempotency_keys → KeyDB cache (apps/api/src/lib/idempotency.ts → Redis)
|
||||
-- audit_log → stdout-only (apps/api/src/lib/audit-log.ts)
|
||||
DROP TABLE IF EXISTS audit_log CASCADE;
|
||||
DROP TABLE IF EXISTS idempotency_keys CASCADE;
|
||||
|
||||
@@ -1,20 +1,54 @@
|
||||
services:
|
||||
keydb:
|
||||
image: eqalpha/keydb
|
||||
container_name: cryptowallet-keydb
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- "6379"
|
||||
volumes:
|
||||
- keydb_data:/data
|
||||
command:
|
||||
- keydb-server
|
||||
- --requirepass
|
||||
- ${REDIS_PASSWORD}
|
||||
- --dir
|
||||
- /data
|
||||
- --appendonly
|
||||
- "yes"
|
||||
- --appendfsync
|
||||
- everysec
|
||||
- --save
|
||||
- "900"
|
||||
- "1"
|
||||
- --save
|
||||
- "300"
|
||||
- "10"
|
||||
- --save
|
||||
- "60"
|
||||
- "10000"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
|
||||
interval: 5s
|
||||
timeout: 2s
|
||||
retries: 20
|
||||
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: cryptowallet-api
|
||||
restart: unless-stopped
|
||||
# Bind to loopback only — TLS termination + WAF на reverse proxy (Caddy / Nginx).
|
||||
# Для direct exposure в dev → поменяй на "3001:3001".
|
||||
depends_on:
|
||||
keydb:
|
||||
condition: service_healthy
|
||||
# Production: port открыт на all interfaces. TLS/WAF обязательно на reverse proxy.
|
||||
ports:
|
||||
- "127.0.0.1:3001:3001"
|
||||
- "3001:3001"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
API_PORT: "3001"
|
||||
# Container hardening — post-RCE blast radius minimization.
|
||||
# Audit-логи теперь идут в stdout (не файл), поэтому read_only OK без logs mount.
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp
|
||||
@@ -36,3 +70,6 @@ services:
|
||||
options:
|
||||
max-size: "20m"
|
||||
max-file: "5"
|
||||
|
||||
volumes:
|
||||
keydb_data:
|
||||
|
||||
67
pnpm-lock.yaml
generated
67
pnpm-lock.yaml
generated
@@ -56,6 +56,9 @@ importers:
|
||||
helmet:
|
||||
specifier: ^8.0.0
|
||||
version: 8.1.0
|
||||
ioredis:
|
||||
specifier: ^5.4.0
|
||||
version: 5.10.1
|
||||
jose:
|
||||
specifier: ^6.2.2
|
||||
version: 6.2.2
|
||||
@@ -318,6 +321,9 @@ packages:
|
||||
resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==}
|
||||
deprecated: Use @eslint/object-schema instead
|
||||
|
||||
'@ioredis/commands@1.5.1':
|
||||
resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==}
|
||||
|
||||
'@jridgewell/resolve-uri@3.1.2':
|
||||
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
@@ -699,6 +705,10 @@ packages:
|
||||
resolution: {integrity: sha512-Mz9QMT5fJe7bKI7MH31UilT5cEK5EHHRCccw/YRFsRY47AuNgaV6HY3rscp0/I4Q+tTW/5zoqpSeRRI54TkDWA==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
cluster-key-slot@1.1.2:
|
||||
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
color-convert@2.0.1:
|
||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||
engines: {node: '>=7.0.0'}
|
||||
@@ -793,6 +803,10 @@ packages:
|
||||
resolution: {integrity: sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
denque@2.1.0:
|
||||
resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
|
||||
engines: {node: '>=0.10'}
|
||||
|
||||
depd@2.0.0:
|
||||
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -1108,6 +1122,10 @@ packages:
|
||||
resolution: {integrity: sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
ioredis@5.10.1:
|
||||
resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==}
|
||||
engines: {node: '>=12.22.0'}
|
||||
|
||||
ip-address@10.1.0:
|
||||
resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==}
|
||||
engines: {node: '>= 12'}
|
||||
@@ -1231,6 +1249,12 @@ packages:
|
||||
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
lodash.defaults@4.2.0:
|
||||
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
|
||||
|
||||
lodash.isarguments@3.1.0:
|
||||
resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==}
|
||||
|
||||
lodash.merge@4.6.2:
|
||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||
|
||||
@@ -1492,6 +1516,14 @@ packages:
|
||||
resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==}
|
||||
engines: {node: '>= 10.13.0'}
|
||||
|
||||
redis-errors@1.2.0:
|
||||
resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
redis-parser@3.0.0:
|
||||
resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
resolve-from@4.0.0:
|
||||
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -1605,6 +1637,9 @@ packages:
|
||||
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
||||
engines: {node: '>= 10.x'}
|
||||
|
||||
standard-as-callback@2.1.0:
|
||||
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
|
||||
|
||||
statuses@2.0.2:
|
||||
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -2392,6 +2427,8 @@ snapshots:
|
||||
|
||||
'@humanwhocodes/object-schema@2.0.3': {}
|
||||
|
||||
'@ioredis/commands@1.5.1': {}
|
||||
|
||||
'@jridgewell/resolve-uri@3.1.2': {}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5': {}
|
||||
@@ -2846,6 +2883,8 @@ snapshots:
|
||||
safe-buffer: 5.2.1
|
||||
to-buffer: 1.2.2
|
||||
|
||||
cluster-key-slot@1.1.2: {}
|
||||
|
||||
color-convert@2.0.1:
|
||||
dependencies:
|
||||
color-name: 1.1.4
|
||||
@@ -2929,6 +2968,8 @@ snapshots:
|
||||
|
||||
delay@5.0.0: {}
|
||||
|
||||
denque@2.1.0: {}
|
||||
|
||||
depd@2.0.0: {}
|
||||
|
||||
destroy@1.2.0: {}
|
||||
@@ -3353,6 +3394,20 @@ snapshots:
|
||||
|
||||
interpret@2.2.0: {}
|
||||
|
||||
ioredis@5.10.1:
|
||||
dependencies:
|
||||
'@ioredis/commands': 1.5.1
|
||||
cluster-key-slot: 1.1.2
|
||||
debug: 4.3.4
|
||||
denque: 2.1.0
|
||||
lodash.defaults: 4.2.0
|
||||
lodash.isarguments: 3.1.0
|
||||
redis-errors: 1.2.0
|
||||
redis-parser: 3.0.0
|
||||
standard-as-callback: 2.1.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
ip-address@10.1.0: {}
|
||||
|
||||
ipaddr.js@1.9.1: {}
|
||||
@@ -3461,6 +3516,10 @@ snapshots:
|
||||
dependencies:
|
||||
p-locate: 5.0.0
|
||||
|
||||
lodash.defaults@4.2.0: {}
|
||||
|
||||
lodash.isarguments@3.1.0: {}
|
||||
|
||||
lodash.merge@4.6.2: {}
|
||||
|
||||
lodash@4.17.23: {}
|
||||
@@ -3673,6 +3732,12 @@ snapshots:
|
||||
dependencies:
|
||||
resolve: 1.22.11
|
||||
|
||||
redis-errors@1.2.0: {}
|
||||
|
||||
redis-parser@3.0.0:
|
||||
dependencies:
|
||||
redis-errors: 1.2.0
|
||||
|
||||
resolve-from@4.0.0: {}
|
||||
|
||||
resolve-from@5.0.0: {}
|
||||
@@ -3814,6 +3879,8 @@ snapshots:
|
||||
|
||||
split2@4.2.0: {}
|
||||
|
||||
standard-as-callback@2.1.0: {}
|
||||
|
||||
statuses@2.0.2: {}
|
||||
|
||||
stream-chain@2.2.5: {}
|
||||
|
||||
Reference in New Issue
Block a user