diff --git a/.env.example b/.env.example index d74de58..3c09f46 100644 --- a/.env.example +++ b/.env.example @@ -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 * diff --git a/apps/api/package.json b/apps/api/package.json index 09fe938..546cda6 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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", diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index 59aa255..67042d0 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -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, diff --git a/apps/api/src/config/redis.ts b/apps/api/src/config/redis.ts new file mode 100644 index 0000000..83bf0ac --- /dev/null +++ b/apps/api/src/config/redis.ts @@ -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 { + 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 { + if (_client) { + await _client.quit().catch(() => _client?.disconnect()); + _client = null; + } +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 42c4b39..b9df2d7 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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(); diff --git a/apps/api/src/lib/audit-log.ts b/apps/api/src/lib/audit-log.ts index 8819aa3..aa0d1e7 100644 --- a/apps/api/src/lib/audit-log.ts +++ b/apps/api/src/lib/audit-log.ts @@ -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 { 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 { - const id = ulid(); +export async function auditLogStrict(entry: AuditEntry & { status?: string }): Promise { 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, errorCode?: string, ): Promise { - 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: '', meta, errorCode, result }, - result, - )); + writeStdoutBestEffort( + buildLine( + { event: `audit.complete:${auditId}`, userId: '', meta, errorCode, result }, + result, + ), + ); } diff --git a/apps/api/src/lib/idempotency.ts b/apps/api/src/lib/idempotency.ts index 4a30e06..f9d68e0 100644 --- a/apps/api/src/lib/idempotency.ts +++ b/apps/api/src/lib/idempotency.ts @@ -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: `. * 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 { - 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; } diff --git a/cryptowallet-schema.sql b/cryptowallet-schema.sql index fde1e46..0d32f65 100644 --- a/cryptowallet-schema.sql +++ b/cryptowallet-schema.sql @@ -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; diff --git a/docker-compose.yml b/docker-compose.yml index db8c2ce..d7d92ea 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5948401..748a04b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {}