diff --git a/backend/.env.example b/backend/.env.example index 3432cc374..ffe7db373 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -159,6 +159,12 @@ EMAIL_FROM_ADDRESS=notifications@yieldvault.finance SLO_READ_THRESHOLD_MS=200 SLO_WRITE_THRESHOLD_MS=500 +# Prometheus /metrics endpoint scrape credential +# When set, GET /metrics requires Authorization: Bearer or ApiKey +# METRICS_AUTH_TOKEN=your-scrape-token +# METRICS_SCRAPE_TOKEN=your-scrape-token +# PROMETHEUS_BEARER_TOKEN=your-scrape-token + # Evaluation window for P95 calculation (5 minutes = 300000 ms) SLO_EVALUATION_WINDOW_MS=300000 diff --git a/backend/docs/OPERATIONAL_METRICS.md b/backend/docs/OPERATIONAL_METRICS.md index b30543aee..e11caa000 100644 --- a/backend/docs/OPERATIONAL_METRICS.md +++ b/backend/docs/OPERATIONAL_METRICS.md @@ -529,7 +529,24 @@ LATENCY_UNHEALTHY_THRESHOLD=5000 # Failure rate threshold for "unhealthy" (percent) FAILURE_RATE_UNHEALTHY_THRESHOLD=10 + +# Prometheus scrape credential for GET /metrics (optional; default open) +METRICS_AUTH_TOKEN=your-scrape-token +``` + +### Scrape Authentication + +When `METRICS_AUTH_TOKEN` (or `METRICS_SCRAPE_TOKEN` or `PROMETHEUS_BEARER_TOKEN`) is set in the environment, the `GET /metrics` endpoint requires authentication: + ``` +Authorization: Bearer +``` +or +``` +Authorization: ApiKey +``` +Requests without a valid credential will receive a `401 Unauthorized` plain-text response. + ## Data Privacy diff --git a/backend/jest.config.js b/backend/jest.config.js index 3946f79f8..b8a93da0d 100644 --- a/backend/jest.config.js +++ b/backend/jest.config.js @@ -38,10 +38,10 @@ module.exports = { }, // Preload script to block problematic modules setupFiles: ['/src/__tests__/preload.js'], - // Override module resolution to prevent @prisma/instrumentation from loading + moduleDirectories: ['node_modules', '/node_modules'], moduleNameMapper: { - '^@yieldvault/api-schemas$': '/../packages/api-schemas/src/index.ts', - '^@yieldvault/api-schemas/(.*)$': '/../packages/api-schemas/src/$1', + '^@yieldvault/api-schemas$': '/../packages/api-schemas/dist/index.js', + '^@yieldvault/api-schemas/(.*)$': '/../packages/api-schemas/dist/$1', '^@prisma/instrumentation$': '/src/__tests__/mocks/prismainstrumentation.js', '^@opentelemetry/(.*)$': '/src/__tests__/mocks/opentelemetry.js', // Redirect the sorobanClient module that vaultEndpoints imports (./sorobanClient) diff --git a/backend/src/__tests__/preload.js b/backend/src/__tests__/preload.js index 4c14f7d45..3bd10a358 100644 --- a/backend/src/__tests__/preload.js +++ b/backend/src/__tests__/preload.js @@ -6,3 +6,10 @@ process.env.RATE_LIMIT_WRITES_MAX = process.env.RATE_LIMIT_WRITES_MAX || '100000 process.env.RATE_LIMIT_READS_MAX = process.env.RATE_LIMIT_READS_MAX || '100000'; process.env.RATE_LIMIT_ADMIN_MAX = process.env.RATE_LIMIT_ADMIN_MAX || '100000'; process.env.API_RATE_LIMIT_MAX_REQUESTS = process.env.API_RATE_LIMIT_MAX_REQUESTS || '100000'; + +const mime = require('mime'); +if (typeof mime.getType !== 'function') { + mime.getType = function(path) { + return mime.lookup(path); + }; +} diff --git a/backend/src/__tests__/setup.ts b/backend/src/__tests__/setup.ts index 901b8efa8..0b36ba6dc 100644 --- a/backend/src/__tests__/setup.ts +++ b/backend/src/__tests__/setup.ts @@ -139,5 +139,19 @@ export async function ensurePaginationFixtureTransactions( timestamp: new Date(now - i * 3600000), }, }); + + const validWalletTxId = `tx-valid-${prefix}-${i + 1}`; + await prisma.transaction.upsert({ + where: { id: validWalletTxId }, + update: {}, + create: { + id: validWalletTxId, + user: VALID_TEST_WALLET, + amount: ((i + 1) * 15).toFixed(2), + type: i % 2 === 0 ? 'deposit' : 'withdrawal', + status: 'completed', + timestamp: new Date(now - i * 3600000), + }, + }); } } diff --git a/backend/src/apiContractSnapshots.ts b/backend/src/apiContractSnapshots.ts index c8f5e45ce..ecefb8620 100644 --- a/backend/src/apiContractSnapshots.ts +++ b/backend/src/apiContractSnapshots.ts @@ -160,7 +160,7 @@ export const BulkExportJobSchema = z status: z.enum(['pending', 'processing', 'completed', 'failed', 'cancelled']), format: z.enum(['csv', 'json']), generatedBy: z.string(), - filters: z.record(z.unknown()), + filters: z.record(z.string(), z.unknown()), totalRows: z.number(), processedRows: z.number(), errorRows: z.number(), @@ -266,7 +266,7 @@ export const ExportManifestSchema = z id: z.string(), requester: z.string(), reportType: z.string(), - filters: z.record(z.unknown()), + filters: z.record(z.string(), z.unknown()), checksum: z.string(), generatedAt: z.string(), fileName: z.string(), diff --git a/backend/src/auditLogEndpoints.ts b/backend/src/auditLogEndpoints.ts index d4d114c0d..277d2b8d8 100644 --- a/backend/src/auditLogEndpoints.ts +++ b/backend/src/auditLogEndpoints.ts @@ -1,49 +1,19 @@ import { Router, Request, Response } from 'express'; +import { prisma } from './prisma'; +import { validateApiKey } from './middleware/apiKeyAuth'; const router = Router(); +router.use(validateApiKey); + export interface AuditLogEntry { id: string; timestamp: string; - operation: 'deposit' | 'withdraw' | 'strategy_switch' | 'parameter_change'; + operation: string; actor: string; details: Record; } -function generateMockAuditLogs(): AuditLogEntry[] { - const now = Date.now(); - return [ - { - id: 'audit-1', - timestamp: new Date(now - 3_600_000).toISOString(), - operation: 'deposit', - actor: 'GABC...1234', - details: { amount: 1000, shares: 990, token: 'USDC' }, - }, - { - id: 'audit-2', - timestamp: new Date(now - 7_200_000).toISOString(), - operation: 'strategy_switch', - actor: 'GDEF...5678', - details: { from: 'BENJI', to: 'Korean Sovereign' }, - }, - { - id: 'audit-3', - timestamp: new Date(now - 10_800_000).toISOString(), - operation: 'withdraw', - actor: 'GABC...1234', - details: { shares: 500, assets: 505, token: 'USDC' }, - }, - { - id: 'audit-4', - timestamp: new Date(now - 14_400_000).toISOString(), - operation: 'parameter_change', - actor: 'GDEF...5678', - details: { parameter: 'min_deposit', oldValue: 100, newValue: 50 }, - }, - ]; -} - /** * @openapi * /api/v1/audit-logs: @@ -51,12 +21,13 @@ function generateMockAuditLogs(): AuditLogEntry[] { * summary: Audit log * description: Returns paginated audit log entries for vault state changes. * tags: [Audit] + * security: + * - ApiKeyAuth: [] * parameters: * - in: query * name: operation * schema: * type: string - * enum: [deposit, withdraw, strategy_switch, parameter_change] * - in: query * name: actor * schema: @@ -69,36 +40,62 @@ function generateMockAuditLogs(): AuditLogEntry[] { * responses: * 200: * description: Audit log entries + * 401: + * description: Unauthorized */ -router.get('/', (req: Request, res: Response) => { - const { operation, actor, limit: limitStr } = req.query; +router.get('/', async (req: Request, res: Response) => { + const { operation, action, actor, limit: limitStr } = req.query; const limit = Math.min(parseInt(limitStr as string, 10) || 20, 100); - let entries = generateMockAuditLogs(); + const actionFilter = + (typeof operation === 'string' && operation) || + (typeof action === 'string' && action) || + undefined; + const actorFilter = typeof actor === 'string' && actor ? actor : undefined; - if (typeof operation === 'string' && operation) { - entries = entries.filter((e) => e.operation === operation); - } + const where = { + ...(actionFilter ? { action: { contains: actionFilter, mode: 'insensitive' as const } } : {}), + ...(actorFilter ? { actor: { contains: actorFilter, mode: 'insensitive' as const } } : {}), + }; - if (typeof actor === 'string' && actor) { - entries = entries.filter((e) => - e.actor.toLowerCase().includes(actor.toLowerCase()), - ); - } + const [total, rows] = await Promise.all([ + prisma.adminAuditLog.count({ where }), + prisma.adminAuditLog.findMany({ + where, + orderBy: { createdAt: 'desc' }, + take: limit, + }), + ]); - const data = entries.slice(0, limit); + const data: AuditLogEntry[] = rows.map((row) => { + let details: Record = {}; + if (row.metadata) { + try { + details = JSON.parse(row.metadata); + } catch { + details = { raw: row.metadata }; + } + } + return { + id: row.id, + timestamp: row.createdAt.toISOString(), + operation: row.action, + actor: row.actor, + details, + }; + }); res.json({ data, pagination: { count: data.length, limit, - total: entries.length, + total, nextCursor: null, prevCursor: null, currentPage: null, totalPages: null, - hasNextPage: data.length < entries.length, + hasNextPage: data.length < total, hasPrevPage: false, }, timestamp: new Date().toISOString(), diff --git a/backend/src/criticalEntityLifecycle.ts b/backend/src/criticalEntityLifecycle.ts index 1c916181c..ee385fb9a 100644 --- a/backend/src/criticalEntityLifecycle.ts +++ b/backend/src/criticalEntityLifecycle.ts @@ -240,7 +240,7 @@ export async function restoreApiKey( if (!key.deletedAt) { return { entityType: 'api_key' as const, entityId: apiKeyId, status: 'not_deleted' as const }; } - if (key.tenant.deletedAt) { + if (key.tenant?.deletedAt) { return { entityType: 'api_key' as const, entityId: apiKeyId, @@ -273,7 +273,7 @@ export async function restoreApiKey( let status: CriticalEntityLifecycleStatus = 'not_deleted'; if (!current) { status = 'not_found'; - } else if (current.deletedAt && current.tenant.deletedAt) { + } else if (current.deletedAt && current.tenant?.deletedAt) { status = 'parent_deleted'; } return { diff --git a/backend/src/database.ts b/backend/src/database.ts index a0530ed4c..0bffe5b02 100644 --- a/backend/src/database.ts +++ b/backend/src/database.ts @@ -90,10 +90,10 @@ export class NoopDatabasePool implements IDatabasePool { */ export class DatabaseManager { private readonly primaryPool: IDatabasePool; - private readonly replicaPool: IDAtabasePool; + private readonly replicaPool: IDatabasePool; private readonly poolsAreShared: boolean; - constructor(primaryPool?: IDatabasePool, replicaPool?: IDAtabasePool) { + constructor(primaryPool?: IDatabasePool, replicaPool?: IDatabasePool) { if (primaryPool) { this.primaryPool = primaryPool; this.replicaPool = replicaPool || primaryPool; diff --git a/backend/src/eventOutbox.ts b/backend/src/eventOutbox.ts index 7b7fe922a..30254f059 100644 --- a/backend/src/eventOutbox.ts +++ b/backend/src/eventOutbox.ts @@ -136,8 +136,11 @@ function getInstanceId(): string { * the failure surfaces as a `Cannot read properties of undefined` TypeError * deep inside fire-and-forget writes, where callers only log it. */ -export function assertEventOutboxModelAvailable(client: unknown = prisma): void { - const delegate = (client as { eventOutbox?: { create?: unknown } } | null | undefined)?.eventOutbox; +export function assertEventOutboxModelAvailable(client?: unknown): void { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { prisma } = require('./prisma') as typeof import('./prisma'); + const c = client ?? prisma; + const delegate = (c as { eventOutbox?: { create?: unknown } } | null | undefined)?.eventOutbox; if (!delegate || typeof delegate.create !== 'function') { throw new Error( 'Prisma client is missing the eventOutbox model. Run `prisma generate` and ensure the EventOutbox migration is applied.', @@ -170,14 +173,9 @@ class EventOutboxService { * Returns the created outbox record. */ async writeEvent(input: OutboxWriteInput): Promise { - if (!prisma.eventOutbox) { - throw new Error('prisma.eventOutbox is not initialized. Ensure Prisma schema includes eventOutbox model.'); - } - assertEventOutboxModelAvailable(); const now = new Date(); const vaultId = input.vaultId ?? input.aggregateId; - const record = await prisma.eventOutbox.create({ const record = await getEventOutboxDelegate().create({ data: { id: `obx-${crypto.randomUUID()}`, @@ -186,7 +184,6 @@ class EventOutboxService { status: 'pending', aggregateType: input.aggregateType, aggregateId: input.aggregateId, - vaultId, sequence: await this.nextSequenceForVault(vaultId), attemptCount: 0, maxAttempts: input.maxAttempts ?? getMaxAttempts(), @@ -222,7 +219,6 @@ class EventOutboxService { // We select the head-of-line event per vault to enforce strict // per-vault ordering (a vault's next event is only processed after // its predecessor has been relayed or dead-lettered). - const candidates = await prisma.eventOutbox.findMany({ // 1. Find eligible entries: pending or failed entries whose lock is expired const candidates = await getEventOutboxDelegate().findMany({ where: { @@ -233,7 +229,7 @@ class EventOutboxService { ], }, orderBy: [ - { vaultId: 'asc' }, + { aggregateId: 'asc' }, { sequence: 'asc' }, { createdAt: 'asc' }, ], @@ -248,25 +244,23 @@ class EventOutboxService { // process the lowest-sequence eligible event. This guarantees a // consumer tracking vault.version never observes sequence N+1 // before sequence N for the same vault. - const headOfLine = new Map(); + const headOfLine = new Map(); for (const c of candidates) { - const key = c.vaultId ?? c.aggregateId; + const key = c.aggregateId; const existing = headOfLine.get(key); if (!existing || c.sequence < existing.sequence) { headOfLine.set(key, c); } } const orderedCandidates = Array.from(headOfLine.values()).sort((a, b) => { - const va = a.vaultId ?? a.aggregateId; - const vb = b.vaultId ?? b.aggregateId; + const va = a.aggregateId; + const vb = b.aggregateId; if (va !== vb) return va < vb ? -1 : 1; return a.sequence - b.sequence; }); // 2. Lock the claimed entries by updating lockedAt/lockedBy in bulk const candidateIds = orderedCandidates.map((e) => e.id); - await prisma.eventOutbox.updateMany({ - const candidateIds = candidates.map((e) => e.id); await getEventOutboxDelegate().updateMany({ where: { id: { in: candidateIds }, @@ -290,7 +284,7 @@ class EventOutboxService { lockedAt: now, }, orderBy: [ - { vaultId: 'asc' }, + { aggregateId: 'asc' }, { sequence: 'asc' }, { createdAt: 'asc' }, ], @@ -318,12 +312,11 @@ class EventOutboxService { payload, ); - await getEventOutboxDelegate().update({ // Do not write the mark-as-sent update after abort; the event will be // re-relayed after restart (at-least-once delivery). if (signal?.aborted) break; - await prisma.eventOutbox.update({ + await getEventOutboxDelegate().update({ where: { id: entry.id }, data: { status: 'relayed', @@ -488,7 +481,6 @@ class EventOutboxService { */ async replayOnStartup(): Promise { assertEventOutboxModelAvailable(); - const pendingCount = await prisma.eventOutbox.count({ const pendingCount = await getEventOutboxDelegate().count({ where: { status: { in: ['pending', 'failed'] } }, }); @@ -738,15 +730,12 @@ class EventOutboxService { * concurrent writers. */ private async nextSequenceForVault(vaultId: string): Promise { - const result = await prisma.$transaction(async (tx) => { - const latest = await tx.eventOutbox.findFirst({ - where: { vaultId }, - orderBy: { sequence: 'desc' }, - select: { sequence: true }, - }); - return (latest?.sequence ?? 0) + 1; + const latest = await getEventOutboxDelegate().findFirst({ + where: { aggregateId: vaultId }, + orderBy: { sequence: 'desc' }, + select: { sequence: true }, }); - return result; + return (latest?.sequence ?? 0) + 1; } } diff --git a/backend/src/idempotency.ts b/backend/src/idempotency.ts index cf111e0d6..26addb870 100644 --- a/backend/src/idempotency.ts +++ b/backend/src/idempotency.ts @@ -27,8 +27,6 @@ import { prisma } from './prisma'; import { redisClientManager } from './rateLimiter'; import { logger } from './middleware/structuredLogging'; import type { Request, Response, NextFunction } from 'express'; -import NodeCache from 'node-cache'; -import { redisClientManager } from './rateLimiter'; /** * The replay cache used by money-moving operations (deposits, withdrawals, @@ -395,728 +393,3 @@ declare global { } } } - -// ─── Error Classes ────────────────────────────────────────────────────── - -export class IdempotencyConflictError extends Error { - constructor(message = 'Idempotency key already used for a different request body') { - super(message); - this.name = 'IdempotencyConflictError'; - } -} - -// ─── Internal Types ───────────────────────────────────────────────────── - -export interface IdempotentOperationResult { - statusCode: number; - body: T; -} - -interface StoredResponse extends IdempotentOperationResult { - fingerprint: string; - metadata: IdempotencyKeyMetadata; -} - -interface PendingOperation { - fingerprint: string; - promise: Promise>; - metadata: IdempotencyKeyMetadata; -} - -export interface IdempotencyKeyInfo { - key: string; - metadata: IdempotencyKeyMetadata; -} - -export interface IdempotencyMetrics { - hits: number; - conflicts: number; - evictions: number; - activeKeys: number; - pendingKeys: number; -} - -// ─── Redis key prefix ─────────────────────────────────────────────────── - -const REDIS_PREFIX = 'idempotency:'; - -// ─── Fingerprint helper ───────────────────────────────────────────────── - -export function getIdempotencyHashThreshold(): number { - return parseInt(process.env.IDEMPOTENCY_HASH_THRESHOLD_BYTES || '4096', 10); -} - -export function buildIdempotencyFingerprint(payload: unknown): string { - const stable = stableStringify(payload); - const byteLength = Buffer.byteLength(stable, 'utf-8'); - if (byteLength > getIdempotencyHashThreshold()) { - return `hashv1:${crypto.createHash('sha256').update(stable).digest('hex')}`; - } - return stable; -} - -function stableStringify(value: unknown): string { - if (value === null) return 'null'; - if (value instanceof Date) return JSON.stringify(value.toISOString()); - if (typeof value !== 'object') return JSON.stringify(value); - - if (Array.isArray(value)) { - return `[${value.map((item) => stableStringify(item)).join(',')}]`; - } - - const record = value as Record; - const keys = Object.keys(record).sort(); - const serialized = keys.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`); - return `{${serialized.join(',')}}`; -} - -// ─── IdempotencyStore ─────────────────────────────────────────────────── - -export class IdempotencyStore { - private readonly completedResponses: NodeCache; - private readonly pendingResponses = new Map>(); - - private _hits = 0; - private _conflicts = 0; - private _evictions = 0; - - constructor(private readonly ttlMs = 24 * 60 * 60 * 1000) { - const ttlSeconds = Math.max(1, Math.ceil(this.ttlMs / 1000)); - this.completedResponses = new NodeCache({ stdTTL: ttlSeconds, checkperiod: ttlSeconds }); - this.completedResponses.on('expired', (_key: string, _value: unknown) => { - this._evictions++; - }); - } - - private redisKey(key: string): string { - return `${REDIS_PREFIX}${key}`; - } - - private get redis() { - const client = redisClientManager.getClient(); - return redisClientManager.isReady() && client ? client : null; - } - - private async redisGet(key: string): Promise | null> { - const r = this.redis; - if (!r) return null; - try { - const raw = await r.get(this.redisKey(key)); - return raw ? (JSON.parse(raw) as StoredResponse) : null; - } catch { - return null; - } - } - - private async redisSet(key: string, value: StoredResponse): Promise { - const r = this.redis; - if (!r) return; - try { - const ttlSeconds = Math.max(1, Math.ceil(this.ttlMs / 1000)); - await r.set(this.redisKey(key), JSON.stringify(value), 'EX', ttlSeconds); - } catch (err) { - console.log(JSON.stringify({ level: 'warn', event: 'idempotency_redis_write_fail', key, reason: (err as Error).message })); - } - } - - private async redisDel(key: string): Promise { - const r = this.redis; - if (!r) return false; - try { - return (await r.del(this.redisKey(key))) > 0; - } catch { - return false; - } - } - - async execute( - key: string, - fingerprint: string, - operation: () => Promise> - ): Promise<{ result: IdempotentOperationResult; replayed: boolean }> { - const now = new Date().toISOString(); - - let completed = await this.redisGet(key); - if (!completed) { - completed = this.completedResponses.get>(key) ?? null; - } - - if (completed) { - if (completed.fingerprint !== fingerprint) { - this._conflicts++; - throw new IdempotencyConflictError(); - } - this._hits++; - completed.metadata.lastAccessedAt = now; - completed.metadata.replayCount++; - await this.redisSet(key, completed); - this.completedResponses.set(key, completed); - return { result: { statusCode: completed.statusCode, body: completed.body }, replayed: true }; - } - - const pendingOperation = this.pendingResponses.get(key) as PendingOperation | undefined; - if (pendingOperation) { - if (pendingOperation.fingerprint !== fingerprint) { - this._conflicts++; - throw new IdempotencyConflictError(); - } - this._hits++; - pendingOperation.metadata.lastAccessedAt = now; - pendingOperation.metadata.replayCount++; - const replayed = await pendingOperation.promise; - return { result: { statusCode: replayed.statusCode, body: replayed.body }, replayed: true }; - } - - const metadata: IdempotencyKeyMetadata = { createdAt: now, lastAccessedAt: now, replayCount: 0, status: 'pending' }; - - const operationPromise = (async () => { - const result = await operation(); - const stored: StoredResponse = { - ...result, - fingerprint, - metadata: { ...metadata, status: 'completed', lastAccessedAt: new Date().toISOString() }, - }; - await this.redisSet(key, stored); - this.completedResponses.set(key, stored, this.ttlMs / 1000); - return stored; - })(); - - this.pendingResponses.set(key, { fingerprint, promise: operationPromise, metadata }); - - try { - const stored = await operationPromise; - return { result: { statusCode: stored.statusCode, body: stored.body }, replayed: false }; - } finally { - this.pendingResponses.delete(key); - } - } - - inspectKeys(prefix?: string): IdempotencyKeyInfo[] { - const results: IdempotencyKeyInfo[] = []; - for (const key of this.completedResponses.keys()) { - if (prefix && !key.startsWith(prefix)) continue; - const entry = this.completedResponses.get>(key); - if (entry) results.push({ key, metadata: { ...entry.metadata } }); - } - for (const [key, pending] of this.pendingResponses.entries()) { - if (prefix && !key.startsWith(prefix)) continue; - if (!results.some((r) => r.key === key)) { - results.push({ key, metadata: { ...pending.metadata } }); - } - } - return results; - } - - async deleteKey(key: string): Promise { - const deletedLocal = this.completedResponses.del(key) > 0; - const deletedPending = this.pendingResponses.delete(key); - const deletedRedis = await this.redisDel(key); - if (deletedLocal || deletedPending || deletedRedis) { - this._evictions++; - return true; - } - return false; - } - - clear(): void { - const count = this.completedResponses.keys().length + this.pendingResponses.size; - this._evictions += count; - this.completedResponses.flushAll(); - this.pendingResponses.clear(); - } - - getMetrics(): IdempotencyMetrics { - return { - hits: this._hits, - conflicts: this._conflicts, - evictions: this._evictions, - activeKeys: this.completedResponses.keys().length, - pendingKeys: this.pendingResponses.size, - }; - } - - async pruneStaleKeys( - retentionMs: number, - dryRun = false, - ): Promise<{ pruned: number; localPruned: number; redisPruned: number }> { - const cutoff = Date.now() - retentionMs; - let localPruned = 0; - let redisPruned = 0; - - for (const key of this.completedResponses.keys()) { - const entry = this.completedResponses.get>(key); - if (!entry) continue; - const createdAt = Date.parse(entry.metadata.createdAt); - if (Number.isNaN(createdAt) || createdAt >= cutoff) continue; - if (!dryRun) { - this.completedResponses.del(key); - this._evictions++; - } - localPruned++; - } - - const r = this.redis; - if (r) { - let cursor = '0'; - do { - const [nextCursor, keys] = await r.scan(cursor, 'MATCH', `${REDIS_PREFIX}*`, 'COUNT', 100); - cursor = nextCursor; - for (const redisKey of keys) { - try { - const raw = await r.get(redisKey); - if (!raw) continue; - const entry = JSON.parse(raw) as StoredResponse; - const createdAt = Date.parse(entry.metadata?.createdAt ?? ''); - const ttl = await r.ttl(redisKey); - const isStale = (!Number.isNaN(createdAt) && createdAt < cutoff) || ttl === 0; - if (!isStale) continue; - if (!dryRun) { - await r.del(redisKey); - this._evictions++; - } - redisPruned++; - } catch { - if (!dryRun) { - await r.del(redisKey); - this._evictions++; - } - redisPruned++; - } - } - } while (cursor !== '0'); - } - - return { pruned: localPruned + redisPruned, localPruned, redisPruned }; - } -} - -// ─── Singleton ────────────────────────────────────────────────────────── - -export const idempotencyStore = new IdempotencyStore( - parseInt(process.env.IDEMPOTENCY_KEY_TTL_MS || '86400000', 10) -); - -// ─── Cleanup ──────────────────────────────────────────────────────────────── - -/** - * Periodic cleanup task to remove expired idempotency records. - * Should be scheduled to run regularly (e.g., hourly or daily). - */ -export async function cleanupExpiredIdempotencyKeys(): Promise { - const now = new Date(); - - const result = await prisma.idempotencyKey.deleteMany({ - where: { - expiresAt: { lt: now }, - }, - }); - - if (result.count > 0) { - logger.log('info', 'Cleaned up expired idempotency keys', { - action: 'idempotency_cleanup', - deletedCount: result.count, - }); - } - - return result.count; -} - -/** - * Starts a periodic cleanup task for expired idempotency keys. - */ -export function startIdempotencyCleanupTask(intervalMs = 3600000): NodeJS.Timer { - logger.log('info', 'Starting idempotency cleanup task', { - action: 'idempotency_cleanup_start', - intervalMs, - }); - - return setInterval(() => { - cleanupExpiredIdempotencyKeys().catch((error) => { - logger.log('error', 'Idempotency cleanup failed', { - action: 'idempotency_cleanup_error', - error: error instanceof Error ? error.message : String(error), - }); - }); - }, intervalMs); -} - -// ─── In-flight store (Redis + NodeCache) ───────────────────────────────────── -// -// Issue #811: multi-instance deployments lost idempotency guarantees on pod -// recycle because responses were stored only in-process. This revision -// persists completed responses to Redis using SET … EX so all replicas share -// the same store, falling back to NodeCache when Redis is unavailable -// (fail-open, with a warning). -// -// The Prisma helpers above (enforceIdempotency / *IdempotencyRecord) are the -// newer, DB-backed design but are not yet wired into any route. The mutation -// endpoints (deposit/withdrawal/transfer), the admin key routes and the -// retention sweeper all still call the store below, so both surfaces live here -// until those call sites are migrated. Restoring this store is what keeps -// `idempotencyStore`, `IdempotencyStore` and `IdempotencyConflictError` -// resolvable for those consumers. -// ─── Response Store (Redis + NodeCache) ───────────────────────────────────── -// -// Shared response store used by vault endpoints and the transfer orchestrator. -// Completed responses are persisted to Redis (when available) with a NodeCache -// in-process fallback. Retention sweeps live in idempotencyRetention.ts. - -// ─── Public Types ───────────────────────────────────────────────────────────── - -export interface IdempotentOperationResult { - statusCode: number; - body: T; -} - -/** Metadata attached to every idempotency key entry. */ -export interface IdempotencyEntryMetadata { -export interface IdempotencyStoreKeyMetadata { - /** ISO-8601 timestamp when the key was first stored. */ - createdAt: string; - /** ISO-8601 timestamp of the most recent access (read or write). */ - lastAccessedAt: string; - /** Number of times this key has been replayed (returned cached result). */ - replayCount: number; - /** Current state of the entry. */ - status: 'pending' | 'completed'; -} - -/** Summary returned by GET /admin/idempotency/keys. */ -export interface IdempotencyKeyInfo { - key: string; - metadata: IdempotencyEntryMetadata; - metadata: IdempotencyStoreKeyMetadata; -} - -/** Snapshot of store-wide observability counters. */ -export interface IdempotencyMetrics { - hits: number; - conflicts: number; - evictions: number; - activeKeys: number; - pendingKeys: number; -} - -interface StoredResponse extends IdempotentOperationResult { - fingerprint: string; - metadata: IdempotencyEntryMetadata; -// ─── Internal Types ─────────────────────────────────────────────────────────── - -interface StoredResponse extends IdempotentOperationResult { - fingerprint: string; - metadata: IdempotencyStoreKeyMetadata; -} - -interface PendingOperation { - fingerprint: string; - promise: Promise>; - metadata: IdempotencyEntryMetadata; -} - - metadata: IdempotencyStoreKeyMetadata; -} - -// ─── Errors ─────────────────────────────────────────────────────────────────── - -export class IdempotencyConflictError extends Error { - constructor(message = 'Idempotency key already used for a different request body') { - super(message); - this.name = 'IdempotencyConflictError'; - } -} - -const REDIS_PREFIX = 'idempotency:'; - -// ─── Redis key prefix ───────────────────────────────────────────────────────── - -const REDIS_PREFIX = 'idempotency:'; - -// ─── Store ──────────────────────────────────────────────────────────────────── - -export class IdempotencyStore { - /** Fallback in-process store used when Redis is unavailable. */ - private readonly localCache: NodeCache; - private readonly pendingResponses = new Map>(); - - // Observability counters - private _hits = 0; - private _conflicts = 0; - private _evictions = 0; - - constructor(private readonly ttlMs = 24 * 60 * 60 * 1000) { - const ttlSeconds = Math.max(1, Math.ceil(this.ttlMs / 1000)); - this.localCache = new NodeCache({ stdTTL: ttlSeconds, checkperiod: ttlSeconds }); - this.localCache.on('expired', () => { this._evictions++; }); - } - - // ─── Redis helpers ───────────────────────────────────────────────────────── - - private redisKey(key: string): string { - return `${REDIS_PREFIX}${key}`; - } - - private get redis() { - const client = redisClientManager.getClient(); - return redisClientManager.isReady() && client ? client : null; - } - - private async redisGet(key: string): Promise | null> { - const r = this.redis; - if (!r) return null; - try { - const raw = await r.get(this.redisKey(key)); - return raw ? (JSON.parse(raw) as StoredResponse) : null; - } catch { - return null; - } - } - - private async redisSet(key: string, value: StoredResponse): Promise { - const r = this.redis; - if (!r) return; - try { - const ttlSeconds = Math.max(1, Math.ceil(this.ttlMs / 1000)); - await r.set(this.redisKey(key), JSON.stringify(value), 'EX', ttlSeconds); - } catch (err) { - console.log(JSON.stringify({ level: 'warn', event: 'idempotency_redis_write_fail', key, reason: (err as Error).message })); - } - } - - private async redisDel(key: string): Promise { - const r = this.redis; - if (!r) return false; - try { - return (await r.del(this.redisKey(key))) > 0; - } catch { - return false; - } - } - - // ─── Core execute ────────────────────────────────────────────────────────── - - async execute( - key: string, - fingerprint: string, - operation: () => Promise> - ): Promise<{ result: IdempotentOperationResult; replayed: boolean }> { - const now = new Date().toISOString(); - - // 1. Check Redis first, then local cache - let completed = await this.redisGet(key); - if (!completed) { - completed = this.localCache.get>(key) ?? null; - } - - if (completed) { - if (completed.fingerprint !== fingerprint) { - this._conflicts++; - throw new IdempotencyConflictError(); - } - this._hits++; - completed.metadata.lastAccessedAt = now; - completed.metadata.replayCount++; - // Refresh in both backends; errors are non-fatal - await this.redisSet(key, completed); - this.localCache.set(key, completed); - return { result: { statusCode: completed.statusCode, body: completed.body }, replayed: true }; - } - - // 2. Currently in-flight - const pendingOperation = this.pendingResponses.get(key) as PendingOperation | undefined; - if (pendingOperation) { - if (pendingOperation.fingerprint !== fingerprint) { - this._conflicts++; - throw new IdempotencyConflictError(); - } - this._hits++; - pendingOperation.metadata.lastAccessedAt = now; - pendingOperation.metadata.replayCount++; - const replayed = await pendingOperation.promise; - return { result: { statusCode: replayed.statusCode, body: replayed.body }, replayed: true }; - } - - // 3. First execution - const metadata: IdempotencyEntryMetadata = { createdAt: now, lastAccessedAt: now, replayCount: 0, status: 'pending' }; - const metadata: IdempotencyStoreKeyMetadata = { createdAt: now, lastAccessedAt: now, replayCount: 0, status: 'pending' }; - - const operationPromise = (async () => { - const result = await operation(); - const stored: StoredResponse = { - ...result, - fingerprint, - metadata: { ...metadata, status: 'completed', lastAccessedAt: new Date().toISOString() }, - }; - // Persist to Redis (primary) and local cache (fallback/fast-path) - await this.redisSet(key, stored); - this.localCache.set(key, stored, this.ttlMs / 1000); - return stored; - })(); - - this.pendingResponses.set(key, { fingerprint, promise: operationPromise, metadata }); - - try { - const stored = await operationPromise; - return { result: { statusCode: stored.statusCode, body: stored.body }, replayed: false }; - } finally { - this.pendingResponses.delete(key); - } - } - - // ─── Inspection ──────────────────────────────────────────────────────────── - - inspectKeys(prefix?: string): IdempotencyKeyInfo[] { - const results: IdempotencyKeyInfo[] = []; - for (const key of this.localCache.keys()) { - if (prefix && !key.startsWith(prefix)) continue; - const entry = this.localCache.get>(key); - if (entry) results.push({ key, metadata: { ...entry.metadata } }); - } - for (const [key, pending] of this.pendingResponses.entries()) { - if (prefix && !key.startsWith(prefix)) continue; - if (!results.some((r) => r.key === key)) { - results.push({ key, metadata: { ...pending.metadata } }); - } - } - return results; - } - - // ─── Targeted deletion ───────────────────────────────────────────────────── - - async deleteKey(key: string): Promise { - const deletedLocal = this.localCache.del(key) > 0; - const deletedPending = this.pendingResponses.delete(key); - const deletedRedis = await this.redisDel(key); - if (deletedLocal || deletedPending || deletedRedis) { - this._evictions++; - return true; - } - return false; - } - - // ─── Global clear (admin only) ───────────────────────────────────────────── - - clear(): void { - const count = this.localCache.keys().length + this.pendingResponses.size; - this._evictions += count; - this.localCache.flushAll(); - this.pendingResponses.clear(); - // Note: Redis keys are prefixed with REDIS_PREFIX; a full Redis FLUSHDB is intentionally - // not issued here to avoid clearing unrelated keys. Use deleteKey() per-key when needed. - } - - // ─── Observability ───────────────────────────────────────────────────────── - - getMetrics(): IdempotencyMetrics { - return { - hits: this._hits, - conflicts: this._conflicts, - evictions: this._evictions, - activeKeys: this.localCache.keys().length, - pendingKeys: this.pendingResponses.size, - }; - } - - // ─── Retention cleanup ───────────────────────────────────────────────────── - - async pruneStaleKeys( - retentionMs: number, - dryRun = false, - ): Promise<{ pruned: number; localPruned: number; redisPruned: number }> { - /** - * Removes entries older than `retentionMs` from the local cache and Redis. - * With `dryRun`, only counts the entries that would be removed: nothing is - * deleted and the eviction counter is left untouched (Issue #1375). - */ - - async pruneStaleKeys( - retentionMs: number, - dryRun = false, - ): Promise<{ pruned: number; localPruned: number; redisPruned: number; dryRun: boolean }> { - const cutoff = Date.now() - retentionMs; - let localPruned = 0; - let redisPruned = 0; - - for (const key of this.localCache.keys()) { - const entry = this.localCache.get>(key); - if (!entry) continue; - const createdAt = Date.parse(entry.metadata.createdAt); - if (Number.isNaN(createdAt) || createdAt >= cutoff) continue; - if (!dryRun) { - this.localCache.del(key); - this._evictions++; - } - localPruned++; - } - - const r = this.redis; - if (r) { - let cursor = '0'; - do { - const [nextCursor, keys] = await r.scan(cursor, 'MATCH', `${REDIS_PREFIX}*`, 'COUNT', 100); - cursor = nextCursor; - for (const redisKey of keys) { - try { - const raw = await r.get(redisKey); - if (!raw) continue; - const entry = JSON.parse(raw) as StoredResponse; - const createdAt = Date.parse(entry.metadata?.createdAt ?? ''); - const ttl = await r.ttl(redisKey); - const isStale = (!Number.isNaN(createdAt) && createdAt < cutoff) || ttl === 0; - if (!isStale) continue; - if (!dryRun) { - await r.del(redisKey); - this._evictions++; - } - redisPruned++; - } catch { - if (!dryRun) { - await r.del(redisKey); - this._evictions++; - } - redisPruned++; - } - } - } while (cursor !== '0'); - } - - return { pruned: localPruned + redisPruned, localPruned, redisPruned }; - return { pruned: localPruned + redisPruned, localPruned, redisPruned, dryRun }; - } -} - -// ─── Singleton ──────────────────────────────────────────────────────────────── - -export const idempotencyStore = new IdempotencyStore( - parseInt(process.env.IDEMPOTENCY_KEY_TTL_MS || '86400000', 10) -); - -// ─── Fingerprint helper ─────────────────────────────────────────────────────── - -export function getIdempotencyHashThreshold(): number { - return parseInt(process.env.IDEMPOTENCY_HASH_THRESHOLD_BYTES || '4096', 10); -} - -export function buildIdempotencyFingerprint(payload: unknown): string { - const stable = stableStringify(payload); - const byteLength = Buffer.byteLength(stable, 'utf-8'); - if (byteLength > getIdempotencyHashThreshold()) { - return `hashv1:${crypto.createHash('sha256').update(stable).digest('hex')}`; - } - return stable; -} - -function stableStringify(value: unknown): string { - if (value === null) return 'null'; - if (value instanceof Date) return JSON.stringify(value.toISOString()); - if (typeof value !== 'object') return JSON.stringify(value); - - if (Array.isArray(value)) { - return `[${value.map((item) => stableStringify(item)).join(',')}]`; - } - - const record = value as Record; - const keys = Object.keys(record).sort(); - const serialized = keys.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`); - return `{${serialized.join(',')}}`; -} - diff --git a/backend/src/index.ts b/backend/src/index.ts index 751630bd1..6870ce7bc 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -22,7 +22,6 @@ import { revokeAllSessions, } from './auth'; import vaultListRouter from './routes/vaults'; -import { loginHandler, nonceHandler, refreshHandler, requireAuth, verifyJwt } from './auth'; import { authLimiter, authIpLimiter, @@ -115,7 +114,6 @@ import { createVersionDiscoveryRouter } from './routes/apiVersions'; import { GracefulShutdownHandler } from './gracefulShutdown'; import { db } from './database'; import vaultRouter from './vaultEndpoints'; -import vaultsListRouter from './routes/vaults'; import walletAliasRouter from './walletAliasEndpoints'; import { walletAliasMappingService } from './walletAliasService'; import transactionRouter from './transactionEndpoints'; @@ -650,7 +648,7 @@ async function handleTransactionExport(req: Request, res: Response): Promise { +app.get('/metrics', async (req: Request, res: Response) => { + const scrapeToken = + process.env.METRICS_AUTH_TOKEN || + process.env.METRICS_SCRAPE_TOKEN || + process.env.PROMETHEUS_BEARER_TOKEN; + + if (scrapeToken) { + const authHeader = req.headers.authorization; + const apiKeyHeader = req.headers['x-api-key'] || req.headers['apikey']; + const bearerMatch = authHeader?.match(/^Bearer\s+(.+)$/i); + const apiKeyMatch = authHeader?.match(/^ApiKey\s+(.+)$/i); + const provided = + bearerMatch?.[1] || + apiKeyMatch?.[1] || + (typeof apiKeyHeader === 'string' ? apiKeyHeader : undefined); + + if (!provided || provided !== scrapeToken) { + res.status(401).type('text/plain').send('Unauthorized'); + return; + } + } + try { syncJobGovernanceMetrics(); latencyMonitoringService.syncSloMetrics(); res.set('Content-Type', register.contentType); res.end(await register.metrics()); } catch (err) { - res.status(500).end(err); + res.status(500).type('text/plain').send(err instanceof Error ? err.message : String(err)); } }); @@ -987,7 +1006,6 @@ app.use('/api', createVersionDiscoveryRouter()); // Mount routers under /api/v1 apiV1.use('/vault', vaultRouter); -apiV1.use('/vaults', vaultsListRouter); apiV1.use('/vaults', vaultListRouter); apiV1.use('/wallet-aliases', walletAliasRouter); apiV1.use('/referrals', referralRouter); @@ -2185,11 +2203,27 @@ app.post('/admin/emails/replay/:id', validateApiKey, async (req: Request, res: R message: 'Email requeued successfully', email, }); - } catch { - res.status(404).json({ - error: 'Not Found', - status: 404, - message: 'Email queue item not found', + } catch (err: unknown) { + const isNotFound = + (err && typeof err === 'object' && 'code' in err && (err as { code: unknown }).code === 'P2025') || + (err instanceof Error && + (err.name === 'NotFoundError' || + err.message.includes('No EmailQueue found') || + err.message.includes('not found'))); + + if (isNotFound) { + sendApiError(req, res, { + status: 404, + code: 'EMAIL_NOT_FOUND', + message: 'Email queue item not found', + }); + return; + } + + sendApiError(req, res, { + status: 500, + code: 'INTERNAL_SERVER_ERROR', + message: err instanceof Error ? err.message : 'Failed to replay email', }); } }); diff --git a/backend/src/jobGovernance.ts b/backend/src/jobGovernance.ts index cb49b964d..764224820 100644 --- a/backend/src/jobGovernance.ts +++ b/backend/src/jobGovernance.ts @@ -2,7 +2,7 @@ import crypto from 'crypto'; import { prisma } from './prisma'; import { logger } from './middleware/structuredLogging'; -export type JobName = 'priceRefresh' | 'positionReconciliation' | 'reportGeneration' | 'databaseBackup' | 'apySnapshot' | 'vaultRebalance'; +export type JobName = 'priceRefresh' | 'positionReconciliation' | 'reportGeneration' | 'databaseBackup' | 'apySnapshot' | 'vaultRebalance' | 'yieldAccrual'; export type DeadLetterStatus = 'dead-letter' | 'processing' | 'resolved' | 'requeued' | 'discarded' | 'needs_retry'; @@ -81,6 +81,12 @@ export const JOB_POLICIES: Record = { backoffMultiplier: 2, deadLetterThreshold: 2, }, + yieldAccrual: { + maxAttempts: 3, + baseDelayMs: 2000, + backoffMultiplier: 2, + deadLetterThreshold: 3, + }, }; class JobGovernanceStore { diff --git a/backend/src/jobs/outboxPoller.ts b/backend/src/jobs/outboxPoller.ts index d89aeaaef..b3d17462d 100644 --- a/backend/src/jobs/outboxPoller.ts +++ b/backend/src/jobs/outboxPoller.ts @@ -1,3 +1,4 @@ +import crypto from 'crypto'; import { PrismaClient } from '@prisma/client'; import { Prisma, EventOutbox } from '@prisma/client'; @@ -50,7 +51,8 @@ export function resolveVaultId(event: EventOutbox): string { // Malformed payloads: fall back to the aggregate id below. } - return event.aggregateId || GLOBAL_VAULT_KEY:} + return event.aggregateId || GLOBAL_VAULT_KEY; +} /** * Poller that delivers outbox events while preserving per-vault ordering. @@ -63,7 +65,7 @@ export function resolveVaultId(event: EventOutbox): string { * `sequence` order while different vaults progress independently. */ export class OutboxPoller { - private readonly prsma: PrismaClient; + private readonly prisma: PrismaClient; private readonly batchSize: number; private readonly maxAttempts: number; private readonly lockTimeoutMs: number; @@ -126,16 +128,21 @@ export class OutboxPoller { // Select the oldest pending event per vault. This ensures we always advance // each vault from its oldest undelivered event and never jump ahead. - const heads = await this.prisma.$QueryRaw>( + const heads = await this.prisma.$queryRaw>( Prisma.sql` - SELECT as "vaultId", MIN("createdAt") AS "minCreatedAt" + SELECT + CASE + WHEN "aggregateType" = 'VaultState' THEN "aggregateId" + ELSE COALESCE("aggregateId", ':', "id") + END AS "vaultId", + MIN("createdAt") AS "minCreatedAt" FROM "EventOutbox" WHERE "status" = 'pending' AND ("lockedAt" IS NULL OR "lockedAt" <= ${lockCutoff}) GROUP BY CASE WHEN "aggregateType" = 'VaultState' THEN "aggregateId" - ELSE COALSCE("aggregateId", ':', "id") + ELSE COALESCE("aggregateId", ':', "id") END `, ); @@ -149,7 +156,7 @@ export class OutboxPoller { const vaultId = head.vaultId; const minCreatedAt = head.minCreatedAt; - const vaultEvents = await this.prisma.$QueryRaw( + const vaultEvents = await this.prisma.$queryRaw( Prisma.sql` SELECT * FROM "EventOutbox" WHERE "status" = 'pending' @@ -186,7 +193,7 @@ export class OutboxPoller { const now = new Date(); const lockCutoff = new Date(now.getTime() - this.lockTimeoutMs); - const claimed = await this.prisma.$QueryRaw( + const claimed = await this.prisma.$queryRaw( Prisma.sql` UPDATE "EventOutbox" SET "status" = 'processing', @@ -269,7 +276,7 @@ export async function enqueueVaultEvent( const now = new Date(); return prisma.$transaction(async (tx) => { - const latest = await tx.$QueryRaw>( + const latest = await tx.$queryRaw>( Prisma.sql` SELECT MAX("sequence") AS "sequence" FROM "EventOutbox" @@ -282,6 +289,7 @@ export async function enqueueVaultEvent( return tx.eventOutbox.create({ data: { + id: `obx-${crypto.randomUUID()}`, eventType: input.eventType, payload: JSON.stringify(input.payload), aggregateType, diff --git a/backend/src/listEndpoints.ts b/backend/src/listEndpoints.ts index f34dc4110..e92f63c7a 100644 --- a/backend/src/listEndpoints.ts +++ b/backend/src/listEndpoints.ts @@ -43,6 +43,7 @@ import { createTimeoutFor } from './middleware/timeoutMiddleware'; import { validate, PaginationQuerySchema, TransactionListQuerySchema } from './middleware/validate'; import { logger } from './middleware/structuredLogging'; import { getPrismaClient } from './prismaClient'; +import { sendApiError } from './middleware/apiError'; const router = Router(); const CACHE_TTL_MS = parseInt(process.env.CACHE_LIST_ENDPOINTS_TTL_MS || '30000', 10); @@ -167,18 +168,6 @@ export interface TransactionExportArtifact { // a real, checksum-valid Ed25519 public key (not just regex-shaped) since // some endpoints validate wallet addresses via StrKey.isValidEd25519PublicKey. const MOCK_WALLET_ADDRESS = 'GBF2VOZSQLF2BZRW6NIDETV2L3I6GHXZUEFSIESELEAGATV2FYTOHOHI'; - -const MOCK_TRANSACTIONS: Transaction[] = Array.from({ length: 100 }, (_, i) => ({ - id: `tx-${i + 1}`, - type: i % 2 === 0 ? 'deposit' : 'withdrawal', - status: i % 11 === 0 ? 'failed' : i % 3 === 0 ? 'pending' : 'completed', - amount: (Math.random() * 1000).toFixed(2), - asset: ['XLM', 'USDC', 'yUSDC', 'RWA'][i % 4], - timestamp: new Date(Date.now() - i * 3600000).toISOString(), - transactionHash: `hash-${i + 1}-${Math.random().toString(36).substring(7)}`, - walletAddress: MOCK_WALLET_ADDRESS, -})); - const MOCK_PORTFOLIO_HOLDINGS: PortfolioHolding[] = Array.from({ length: 50 }, (_, i) => ({ id: `holding-${i + 1}`, asset: ['XLM', 'USDC', 'yUSDC', 'RWA'][i % 4], @@ -297,28 +286,7 @@ function isTransactionInDateRange( return true; } -function filterTransactionsForExport( - query: WalletStateQuery & { startDate?: string; endDate?: string; walletAddress?: string } -): Transaction[] { - const sortBy = query.sortBy ?? TRANSACTION_PAGINATION_CONFIG.defaultSortBy; - const sortOrder = query.sortOrder ?? TRANSACTION_PAGINATION_CONFIG.defaultSortOrder ?? 'desc'; - const from = query.startDate ?? query.from; - const to = query.endDate ?? query.to; - let filtered = filterTransactions(MOCK_TRANSACTIONS, { - type: query.type, - status: query.status, - from, - to, - walletAddress: query.walletAddress, - }); - - if (sortBy) { - filtered = sortItems(filtered, sortBy, sortOrder); - } - - return filtered; -} function escapeCsv(value: unknown): string { const serialized = String(value ?? ''); @@ -353,11 +321,11 @@ function authenticateTransactionExport(req: ExportRequest, res: Response, next: if (authHeader.startsWith('ApiKey ')) { validateApiKey(req, res, () => { if (!hasPermission(req, Permission.EXPORTS_WRITE)) { - res.status(403).json({ - error: 'Forbidden', + sendApiError(req, res, { status: 403, + code: 'FORBIDDEN', message: 'Operator role or higher is required for admin transaction export', - requiredPermission: Permission.EXPORTS_WRITE, + details: { requiredPermission: Permission.EXPORTS_WRITE }, }); return; } @@ -535,33 +503,53 @@ export async function buildTransactionsResponse( }); } -export function getTransactionsForExport(query: TransactionExportQuery): Transaction[] { - const filters = { - type: query.type, - status: query.status, - from: query.startDate ?? query.from, - to: query.endDate ?? query.to, - walletAddress: query.walletAddress, - }; +export async function getTransactionsForExport(query: TransactionExportQuery): Promise { + const prisma = getPrismaClient(); + const from = query.startDate ?? query.from; + const to = query.endDate ?? query.to; + const normalizedDateRange = parseDateRangeOrThrow({ from, to }); + const sortBy = validateSortBy( - query.sortBy ?? TRANSACTION_PAGINATION_CONFIG.defaultSortBy, + query.sortBy ?? TRANSACTION_PAGINATION_CONFIG.defaultSortBy ?? 'timestamp', TRANSACTION_PAGINATION_CONFIG, ); const sortOrder = query.sortOrder ?? TRANSACTION_PAGINATION_CONFIG.defaultSortOrder ?? 'desc'; + const orderByField = + sortBy === 'type' ? 'type' : + sortBy === 'status' ? 'status' : + sortBy === 'amount' ? 'amount' : + sortBy === 'asset' ? 'asset' : + sortBy === 'walletAddress' ? 'user' : + sortBy === 'id' ? 'id' : + 'timestamp'; - let filtered = filterTransactions(MOCK_TRANSACTIONS, filters); - if (sortBy) { - filtered = sortItems(filtered, sortBy, sortOrder); - } + const where = { + ...(query.type ? { type: query.type } : {}), + ...(query.status ? { status: query.status } : {}), + ...(query.walletAddress ? { user: normalizeWalletAddress(query.walletAddress) } : {}), + ...((normalizedDateRange.normalizedStart || normalizedDateRange.normalizedEnd) + ? { + timestamp: { + ...(normalizedDateRange.normalizedStart ? { gte: new Date(normalizedDateRange.normalizedStart) } : {}), + ...(normalizedDateRange.normalizedEnd ? { lte: new Date(normalizedDateRange.normalizedEnd) } : {}), + }, + } + : {}), + }; - return filtered; + const rows = await prisma.transaction.findMany({ + where: where as any, + orderBy: { [orderByField]: sortOrder }, + }); + + return rows.map(dbRowToTransaction); } -export function buildTransactionExportArtifact( +export async function buildTransactionExportArtifact( format: ExportFormat, query: TransactionExportQuery, -): TransactionExportArtifact { - const transactions = getTransactionsForExport(query); +): Promise { + const transactions = await getTransactionsForExport(query); const body = format === 'csv' ? buildTransactionsCsvExportBody(transactions) @@ -578,9 +566,8 @@ export function buildTransactionExportArtifact( } export function createTransactionsJsonExportStream(query: TransactionExportQuery): Readable { - const transactions = getTransactionsForExport(query); - async function* generate(): AsyncGenerator { + const transactions = await getTransactionsForExport(query); yield '{"data":['; for (let index = 0; index < transactions.length; index += 1) { if (index > 0) { @@ -595,7 +582,6 @@ export function createTransactionsJsonExportStream(query: TransactionExportQuery } export function createTransactionsCsvExportStream(query: TransactionExportQuery): Readable { - const transactions = getTransactionsForExport(query); const columns: Array = [ 'id', 'type', @@ -608,6 +594,7 @@ export function createTransactionsCsvExportStream(query: TransactionExportQuery) ]; async function* generate(): AsyncGenerator { + const transactions = await getTransactionsForExport(query); yield `${columns.join(',')}\r\n`; for (const transaction of transactions) { const row = columns @@ -791,9 +778,9 @@ router.get('/transactions', res.status(200).json(response); } catch (error) { if (error instanceof DateRangeParseError) { - res.status(400).json({ - error: 'Bad Request', + sendApiError(req, res, { status: 400, + code: 'BAD_REQUEST', message: error.message, }); return; @@ -804,9 +791,9 @@ router.get('/transactions', allowedSortBy: TRANSACTION_PAGINATION_CONFIG.allowedSortBy, error: error.message, }); - res.status(400).json({ - error: 'Bad Request', + sendApiError(req, res, { status: 400, + code: 'BAD_REQUEST', message: error.message, }); return; @@ -815,102 +802,14 @@ router.get('/transactions', error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, }); - res.status(500).json({ - error: 'Internal Server Error', + sendApiError(req, res, { status: 500, + code: 'INTERNAL_SERVER_ERROR', message: 'Failed to fetch transactions', }); } }); -router.get('/vault/transactions/export', - authenticateTransactionExport, - createTimeoutFor.export(), - async (req: Request, res: Response) => { - const exportReq = req as ExportRequest; - const format = resolveExportFormat(req.query.format); - if (!format) { - res.status(400).json({ - error: 'Bad Request', - status: 400, - message: 'format query parameter must be either csv or json', - }); - return; - } - - const requestedWallet = typeof req.query.walletAddress === 'string' ? req.query.walletAddress : undefined; - const walletAddress = exportReq.exportAsAdmin - ? requestedWallet - : exportReq.jwtPayload?.sub; - - if (!exportReq.exportAsAdmin && requestedWallet && requestedWallet !== exportReq.jwtPayload?.sub) { - res.status(403).json({ - error: 'Forbidden', - status: 403, - message: 'Users can only export their own wallet transaction history', - }); - return; - } - - if (!walletAddress) { - res.status(400).json({ - error: 'Bad Request', - status: 400, - message: 'walletAddress is required for admin exports', - }); - return; - } - - const stamp = new Date().toISOString().slice(0, 10); - const fileBase = `transaction-history-${walletAddress.slice(0, 8)}-${stamp}`; - const fileName = `${fileBase}.${format}`; - - try { - const artifact = buildTransactionExportArtifact(format, { - type: typeof req.query.type === 'string' ? req.query.type : undefined, - status: typeof req.query.status === 'string' ? req.query.status : undefined, - sortBy: typeof req.query.sortBy === 'string' ? req.query.sortBy : undefined, - sortOrder: req.query.sortOrder === 'asc' ? 'asc' : 'desc', - startDate: typeof req.query.startDate === 'string' ? req.query.startDate : undefined, - endDate: typeof req.query.endDate === 'string' ? req.query.endDate : undefined, - walletAddress, - }); - - const job = await recordExportJob({ - format, - fileName, - contentType: artifact.contentType, - checksum: artifact.checksum, - checksumAlgorithm: artifact.checksumAlgorithm, - generatedBy: resolveExportGeneratedBy(req), - walletAddress, - rowCount: artifact.rowCount, - filters: { - type: typeof req.query.type === 'string' ? req.query.type : null, - status: typeof req.query.status === 'string' ? req.query.status : null, - sortBy: typeof req.query.sortBy === 'string' ? req.query.sortBy : null, - sortOrder: req.query.sortOrder === 'asc' ? 'asc' : 'desc', - startDate: typeof req.query.startDate === 'string' ? req.query.startDate : null, - endDate: typeof req.query.endDate === 'string' ? req.query.endDate : null, - walletAddress, - }, - }); - - res.setHeader('Content-Type', artifact.contentType); - res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`); - res.setHeader('X-Export-Job-Id', job.id); - res.setHeader('X-Export-Checksum', artifact.checksum); - res.setHeader('X-Export-Checksum-Algorithm', artifact.checksumAlgorithm); - res.setHeader('X-Export-Metadata', buildExportMetadataHeaderValue(job)); - res.status(200).send(artifact.body); - } catch (error) { - res.status(500).json({ - error: 'Internal Server Error', - status: 500, - message: error instanceof Error ? error.message : 'Failed to generate transaction export', - }); - } -}); /** * @openapi @@ -959,9 +858,9 @@ router.get('/portfolio/holdings', allowedSortBy: PORTFOLIO_PAGINATION_CONFIG.allowedSortBy, error: error.message, }); - res.status(400).json({ - error: 'Bad Request', + sendApiError(req, res, { status: 400, + code: 'BAD_REQUEST', message: error.message, }); return; @@ -970,9 +869,9 @@ router.get('/portfolio/holdings', error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, }); - res.status(500).json({ - error: 'Internal Server Error', + sendApiError(req, res, { status: 500, + code: 'INTERNAL_SERVER_ERROR', message: 'Failed to fetch portfolio holdings', }); } @@ -1015,9 +914,9 @@ router.get('/vault/history', res.status(200).json(response); } catch (error) { if (error instanceof DateRangeParseError) { - res.status(400).json({ - error: 'Bad Request', + sendApiError(req, res, { status: 400, + code: 'BAD_REQUEST', message: error.message, }); return; @@ -1028,9 +927,9 @@ router.get('/vault/history', allowedSortBy: VAULT_HISTORY_PAGINATION_CONFIG.allowedSortBy, error: error.message, }); - res.status(400).json({ - error: 'Bad Request', + sendApiError(req, res, { status: 400, + code: 'BAD_REQUEST', message: error.message, }); return; @@ -1039,9 +938,9 @@ router.get('/vault/history', error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, }); - res.status(500).json({ - error: 'Internal Server Error', + sendApiError(req, res, { status: 500, + code: 'INTERNAL_SERVER_ERROR', message: 'Failed to fetch vault history', }); } @@ -1101,9 +1000,9 @@ router.get('/vault/apy/history', error: err instanceof Error ? err.message : String(err), stack: err instanceof Error ? err.stack : undefined, }); - res.status(500).json({ - error: 'Internal Server Error', + sendApiError(req, res, { status: 500, + code: 'INTERNAL_SERVER_ERROR', message: 'Failed to fetch APY history', }); } diff --git a/backend/src/middleware/apiError.ts b/backend/src/middleware/apiError.ts index 8c397b3b0..e0c90393f 100644 --- a/backend/src/middleware/apiError.ts +++ b/backend/src/middleware/apiError.ts @@ -13,6 +13,7 @@ export interface ApiErrorOptions { summary?: string; errors?: unknown[]; path?: string; + limit?: string; } /** @@ -38,6 +39,7 @@ export interface ApiErrorBody { summary?: string; errors?: unknown[]; path?: string; + limit?: string; } export interface BuildApiErrorBodyOptions { @@ -52,6 +54,7 @@ export interface BuildApiErrorBodyOptions { summary?: string; errors?: unknown[]; path?: string; + limit?: string; } /** @@ -76,24 +79,10 @@ export function buildApiErrorBody(options: BuildApiErrorBodyOptions): ApiErrorBo ...(options.summary !== undefined ? { summary: options.summary } : {}), ...(options.errors !== undefined ? { errors: options.errors } : {}), ...(options.path !== undefined ? { path: options.path } : {}), + ...(options.limit !== undefined ? { limit: options.limit } : {}), ...(options.correlationId ? { correlationId: options.correlationId } : {}), ...(options.traceId ? { traceId: options.traceId } : {}), }; - /** - * Short, stable headline for the failure. Part of the published error - * contract (see `ErrorEnvelope` in openapi.json) alongside `message`. - */ - summary?: string; - /** - * Field-level failures, surfaced at the top level for clients that read - * `errors` directly. `details` carries the same payload for the generic - * envelope. - */ - errors?: unknown[]; - /** Request path, echoed on routing failures so clients can log it directly. */ - path?: string; - summary?: string; - errors?: unknown; } export function sendApiError( @@ -119,23 +108,11 @@ export function sendApiError( summary: options.summary, errors: options.errors, path: options.path, + limit: options.limit, correlationId, ...(traceId ? { traceId } : {}), }) ); - res.status(options.status).json({ - error: options.error ?? statusLabel(options.status), - status: options.status, - code: options.code, - message: options.message, - retryable: options.retryable ?? options.status >= 500, - ...(options.summary !== undefined ? { summary: options.summary } : {}), - ...(options.errors !== undefined ? { errors: options.errors } : {}), - ...(options.path !== undefined ? { path: options.path } : {}), - ...(options.details !== undefined ? { details: options.details } : {}), - ...(correlationId ? { correlationId } : {}), - ...(traceId ? { traceId } : {}), - }); } export function apiErrorContractMiddleware( @@ -181,6 +158,7 @@ function defaultErrorCode(status: number): string { case 403: return 'AUTH_FORBIDDEN'; case 404: return 'ROUTE_NOT_FOUND'; case 409: return 'REQUEST_CONFLICT'; + case 413: return 'PAYLOAD_TOO_LARGE'; case 422: return 'REQUEST_UNPROCESSABLE'; case 429: return 'RATE_LIMITED'; default: return status >= 500 ? 'INTERNAL_ERROR' : 'REQUEST_ERROR'; @@ -194,6 +172,7 @@ export function statusLabel(status: number): string { case 403: return 'Forbidden'; case 404: return 'Not Found'; case 409: return 'Conflict'; + case 413: return 'Payload Too Large'; case 422: return 'Unprocessable Entity'; case 429: return 'Too Many Requests'; case 500: return 'Internal Server Error'; diff --git a/backend/src/middleware/apiKeyAuth.ts b/backend/src/middleware/apiKeyAuth.ts index cdb3d5cd9..f47d82d5a 100644 --- a/backend/src/middleware/apiKeyAuth.ts +++ b/backend/src/middleware/apiKeyAuth.ts @@ -1 +1,421 @@ -aW1wb3J0IHR5cGUgeyBSZXF1ZXN0LCBSZXNwb25zZSwgTmV4dEZ1bmN0aW9uIH0gZnJvbSAnZXhwcmVzcyc7CmltcG9ydCBjcnlwdG8gZnJvbSAnY3J5cHRvJzsKaW1wb3J0IHsgcHJpc21hIH0gZnJvbSAnLi4vcHJpc21hJzsKaW1wb3J0IHsgc2VuZEFwaUVycm9yIH0gZnJvbSAnLi9hcGlFcnJvcic7CgpleHBvcnQgdHlwZSBBcGlLZXlSb2xlID0gJ3ZpZXdlcicgfCAnb3BlcmF0b3InIHwgJ2FkbWluJyB8ICdzdXBlci1hZG1pbic7CgppbnRlcmZhY2UgQXBpS2V5TWV0YWRhdGEgewogIHJvbGU6IEFwaUtleVJvbGU7CiAgY3JlYXRlZEF0OiBEYXRlOwogIHJvdGF0ZWRBdD86IERhdGU7CiAgcmV2b2tlZEF0PzogRGF0ZTsKICB0ZW5hbnRJZDogc3RyaW5nOwogIHNjb3Blczogc3RyaW5nW107CiAgZXhwaXJlc0F0PzogRGF0ZSB8IG51bGw7CiAgbGFzdFVzZWRBdD86IERhdGUgfCBudWxsOwp9CgpkZWNsYXJlIGdsb2JhbCB7CiAgbmFtZXNwYWNlIEV4cHJlc3MgewogICAgaW50ZXJmYWNlIFJlcXVlc3QgewogICAgICBhdXRoQXBpS2V5SGFzaD86IHN0cmluZzsKICAgICAgYXV0aEFwaUtleVJvbGU/OiBBcGlLZXlSb2xlOwogICAgICBhdXRoQXBpS2V5VGVuYW50SWQ/OiBzdHJpbmc7CiAgICAgIGF1dGhBcGlLZXlTY29wZXM/OiBzdHJpbmdbXTsKICAgICAgYXV0aEFwaUtleUxhc3RVc2VkQXQ/OiBEYXRlIHwgbnVsbDsKICAgICAgYXV0aEFwaUtleUxlZ2FjeT86IGJvb2xlYW47CiAgICB9CiAgfQp9Cgpjb25zdCBST0xFX1BSRUNFREVOQ0U6IFJlY29yZDxBcGlLZXlSb2xlLCBudW1iZXI+ID0gewogIHZpZXdlcjogMCwKICBvcGVyYXRvcjogMSwKICBhZG1pbjogMiwKICAnc3VwZXItYWRtaW4nOiAzLAp9OwoKZXhwb3J0IGZ1bmN0aW9uIG5vcm1hbGl6ZUFwaUtleVJvbGUocmF3OiB1bmtub3duKTogQXBpS2V5Um9sZSB8IG51bGwgewogIGlmICh0eXBlb2YgcmF3ICE9PSAnc3RyaW5nJykgewogICAgcmV0dXJuIG51bGw7CiAgfQogIGNvbnN0IHZhbHVlID0gcmF3LnRyaW0oKS50b0xvd2VyQ2FzZSgpOwogIGlmICh2YWx1ZSA9PT0gJ3ZpZXdlcicgfHwgdmFsdWUgPT09ICdvcGVyYXRvcicgfHwgdmFsdWUgPT09ICdhZG1pbicgfHwgdmFsdWUgPT09ICdzdXBlci1hZG1pbicpIHsKICAgIHJldHVybiB2YWx1ZSBhcyBBcGlLZXlSb2xlOwogIH0KICByZXR1cm4gbnVsbDsKfQoKZnVuY3Rpb24gcGFyc2VTY29wZXMocmF3OiB1bmtub3duKTogc3RyaW5nW10gewogIGlmIChBcnJheS5pc0FycmF5KHJhdykpIHJldHVybiByYXcgYXMgc3RyaW5nW107CiAgaWYgKHR5cGVvZiByYXcgPT09ICdzdHJpbmcnKSB7CiAgICB0cnkgewogICAgICBjb25zdCBwYXJzZWQgPSBKU09OLnBhcnNlKHJhdyk7CiAgICAgIHJldHVybiBBcnJheS5pc0FycmF5KHBhcnNlZCkgPyBwYXJzZWQgOiBbXTsKICAgIH0gY2F0Y2ggewogICAgICByZXR1cm4gW107CiAgICB9CiAgfQogIHJldHVybiBbXTsKfQoKZXhwb3J0IGludGVyZmFjZSBQZXJzaXN0ZWRBcGlLZXlTdGF0dXMgewogIGlzQWN0aXZlOiBib29sZWFuOwogIGRlbGV0ZWRBdD86IERhdGUgfCBudWxsOwogIGV4cGlyZXNBdD86IERhdGUgfCBudWxsOwogIHRlbmFudD86IHsgZGVsZXRlZEF0PzogRGF0ZSB8IG51bGwgfTsKfQoKZXhwb3J0IGZ1bmN0aW9uIGlzUGVyc2lzdGVkQXBpS2V5VXNhYmxlKAogIGFwaUtleTogUGVyc2lzdGVkQXBpS2V5U3RhdHVzIHwgbnVsbApdOiBhcGlLZXkgaXMgUGVyc2lzdGVkQXBpS2V5U3RhdHVzIHsKICByZXR1cm4gQm9vbGVhbihhcGlLZXkgJiYgYXBpS2V5LmlzQWN0aXZlICYmICFhcGlLZXkuZGVsZXRlZEF0ICYmICFhcGlLZXkudGVuYW50Py5kZWxldGVkQXQpOwp9CgpleHBvcnQgZnVuY3Rpb24gaXNQZXJzaXN0ZWRBcGlLZXlFeHBpcmVkKAogIGFwaUtleTogUGVyc2lzdGVkQXBpS2V5U3RhdHVzIHwgbnVsbApdOiBib29sZWFuIHsKICByZXR1cm4gQm9vbGVhbihhcGlLZXkgJiYgYXBpS2V5LmV4cGlyZXNBdCAmJiBhcGlLZXkuZXhwaXJlc0F0LmdldFRpbWUoKSA8PSBEYXRlLm5vdygpKTsKfQoKZXhwb3J0IGZ1bmN0aW9uIGV4dHJhY3RUZW5hbnRJZEZyb21SZXF1ZXN0KHJlcTogUmVxdWVzdCk6IHN0cmluZyB8IG51bGwgewogIGNvbnN0IGZyb21QYXJhbXMgPSByZXEucGFyYW1zPy50ZW5hbnRJZDsKICBpZiAodHlwZW9mIGZyb21QYXJhbXMgPT09ICdzdHJpbmcnICYmIGZyb21QYXJhbXMudHJpbSgpKSB7CiAgICByZXR1cm4gZnJvbVBhcmFtcy50cmltKCk7CiAgfQogIGNvbnN0IGZyb21RdWVyeSA9IHJlcS5xdWVyeT8udGVuYW50SWQ7CiAgaWYgKHR5cGVvZiBmcm9tUXVlcnkgPT09ICdzdHJpbmcnICYmIGZyb21RdWVyeS50cmltKCkpIHsKICAgIHJldHVybiBmcm9tUXVlcnkudHJpbSgpOwogIH0KICBjb25zdCBmcm9tSGVhZGVyID0gcmVxLmdldD8uKCd4LXRlbmFudC1pZCcpOwogIGlmICh0eXBlb2YgZnJvbUhlYWRlciA9PT0gJ3N0cmluZycgJiYgZnJvbUhlYWRlci50cmltKCkpIHsKICAgIHJldHVybiBmcm9tSGVhZGVyLnRyaW0oKTsKICB9CiAgcmV0dXJuIG51bGw7Cn0KCmV4cG9ydCBmdW5jdGlvbiBleHRyYWN0UmVxdWlyZWRTY29wZXMocmVxOiBSZXF1ZXN0KTogc3RyaW5nW10gewogIGNvbnN0IHJhdyA9IChyZXEgYXMgUmVxdWVzdCAmIHsgcmVxdWlyZWRTY29wZXM/OiB1bmtub3duIH0pLnJlcXVpcmVkU2NvcGVzOwogIGlmIChBcnJheS5pc0FycmF5KHJhdykpIHsKICAgIHJldHVybiByYXcuZmlsdGVyKChzKTogcyBpcyBzdHJpbmcgPT4gdHlwZW9mIHMgPT09ICdzdHJpbmcnKTsKICB9CiAgaWYgKHR5cGVvZiByYXcgPT09ICdzdHJpbmcnICYmIHJhdy50cmltKCkpIHsKICAgIHJldHVybiByYXcuc3BsaXQoJywnKS5tYXAoKHMpID0+IHMudHJpbSgpKS5maWx0ZXIoQm9vbGVhbik7CiAgfQogIHJldHVybiBbXTsKfQoKZXhwb3J0IGZ1bmN0aW9uIGhhc1JlcXVpcmVkU2NvcGVzKGF2YWlsYWJsZTogc3RyaW5nW10sIHJlcXVpcmVkOiBzdHJpbmdbXSk6IGJvb2xlYW4gewogIGlmIChyZXF1aXJlZC5sZW5ndGggPT09IDApIHJldHVybiB0cnVlOwogIGlmIChhdmFpbGFibGUuaW5jbHVkZXMoJyonKSkgcmV0dXJuIHRydWU7CiAgcmV0dXJuIHJlcXVpcmVkLmV2ZXJ5KChzY29wZSkgPT4gYXZhaWxhYmxlLmluY2x1ZGVzKHNjb3BlKSk7Cn0KCmZ1bmN0aW9uIHNlbmRVbmF1dGhvcml6ZWQoCiAgcmVxOiBSZXF1ZXN0LAogIHJlczogUmVzcG9uc2UsCiAgY29kZTogc3RyaW5nLAogIG1lc3NhZ2U6IHN0cmluZywKICBzY2hlbWU6IHN0cmluZyA9ICdCZWFyZXInCik6IHZvaWQgewogIHJlcy5zZXRIZWFkZXIoJ1dXVy1BdXRoZW50aWNhdGUnLCBzY2hlbWUpOwogIHNlbmRBcGlFcnJvcihyZXEsIHJlcywgewogICAgc3RhdHVzOiA0MDEsCiAgICBjb2RlLAogICAgbWVzc2FnZSwKICAgIHJldHJhYmxlOiBmYWxzZSwKICB9KTsKfQoKY29uc3QgTEVHQUNZX0VOVl9LRVkgPSBwcm9jZXNzLmVudi5BUElfS0VZOwpjb25zdCBMRUdBQ1lfRU5WX1RFTkFOVCA9IHByb2Nlc3MuZW52LkFQSV9LRVlfVEVOQU5UX0lEIHx8ICdkZWZhdWx0JzsKY29uc3QgTEVHQUNZX0VOVl9TQ09QRVMgPSAocHJvY2Vzcy5lbnYuQVBJX0tFWV9TQ09QRVMgfHwgJyonKQogIC5zcGxpdCgnLCcpCiAgLm1hcCgocykgPT4gcy50cmltKCkpCiAgLmZpbHRlcihCb29sZWFuKTsKCmZ1bmN0aW9uIGNoZWNrTGVnYWN5RW52S2V5KHByb3ZpZGVkS2V5OiBzdHJpbmcpOiBib29sZWFuIHsKICBpZiAoIUxFR0FDWV9FTlZfS0VZKSByZXR1cm4gZmFsc2U7CiAgY29uc3QgYSA9IEJ1ZmZlci5mcm9tKHByb3ZpZGVkS2V5KTsKICBjb25zdCBiID0gQnVmZmVyLmZyb20oTEVHQUNZX0VOVl9LRVkpOwogIGlmIChhLmxlbmd0aCAhPT0gYi5sZW5ndGgpIHJldHVybiBmYWxzZTsKICByZXR1cm4gY3J5cHRvLnRpbWluZ1NhZmVFcXVhbChhLCBiKTsKfQoKZXhwb3J0IGFzeW5jIGZ1bmN0aW9uIHZhbGlkYXRlQXBpS2V5KAogIHJlcTogUmVxdWVzdCwKICByZXM6IFJlc3BvbnNlLAogIG5leHQ6IE5leHRGdW5jdGlvbgopOiBQcm9taXNlPHZvaWQ+IHsKICBjb25zdCBhdXRoSGVhZGVyID0gcmVxLmdldD8uKCdBdXRob3JpemF0aW9uJykgfHwgJyc7CiAgY29uc3QgbWF0Y2ggPSBhdXRoSGVhZGVyLm1hdGNoKC9eQXBpS2V5XHMrKC4rKSQvaSk7CiAgaWYgKCFtYXRjaCkgewogICAgc2VuZEFwaUVycm9yKHJlcSwgcmVzLCB7CiAgICAgIHN0YXR1czogNDAxLAogICAgICBjb2RlOiAnQVVUSF9BUElfS0VZX01JU1NJTkcnLAogICAgICBtZXNzYWdlOiAnTWlzc2luZyBvciBpbnZhbGlkIEFQSSBrZXknLAogICAgICByZXRyeWFibGU6IGZhbHNlLAogICAgfSk7CiAgICByZXR1cm47CiAgfQogIGNvbnN0IHByb3ZpZGVkS2V5ID0gbWF0Y2hbMV07CiAgY29uc3QgaGFzaGVkID0gY3J5cHRvLmNyZWF0ZUhhc2goJ3NoYTI1NicpLnVwZGF0ZShwcm92aWRlZEtleSkuZGlnZXN0KCdoZXgnKTsKICBjb25zdCByZXF1ZXN0ZWRUZW5hbnRJZCA9IGV4dHJhY3RUZW5hbnRJZEZyb21SZXF1ZXN0KHJlcSk7CiAgY29uc3QgcmVxdWlyZWRTY29wZXMgPSBleHRyYWN0UmVxdWlyZWRTY29wZXMocmVxKTsKCiAgLy8gVHJ5IFByaXNtYSBmaXJzdDsgZmFsbCBiYWNrIHRvIGluLW1lbW9yeSBzdG9yZSB3aGVuIHRoZSB0YWJsZSBkb2Vzbid0IGV4aXN0CiAgLy8gKGUuZy4gaW4gdGVzdHMgd2l0aCBhbiB1bi1taWdyYXRlZCBTUUxpdGUgZGF0YWJhc2UpLgogIGxldCBhcGlLZXk6IHsKICAgIGlkPzogc3RyaW5nOwogICAgaGFzaGVkS2V5OiBzdHJpbmc7CiAgICByb2xlOiBzdHJpbmc7CiAgICB0ZW5hbnRJZDogc3RyaW5nOwogICAgc2NvcGVzOiB1bmtub3duOwogICAgaXNBY3RpdmU6IGJvb2xlYW47CiAgICBkZWxldGVkQXQ/OiBEYXRlIHwgbnVsbDsKICAgIGV4cGlyZXNBdD86IERhdGUgfCBudWxsOwogICAgdGVuYW50PzogeyBkZWxldGVkQXQ/OiBEYXRlIHwgbnVsbCB9OwogIH0gfCBudWxsID0gbnVsbDsKICB0cnkgewogICAgYXBpS2V5ID0gYXdhaXQgcHJpc21hLmFwaUtleS5maW5kVW5pcXVlKHsKICAgICAgd2hlcmU6IHsgaGFzaGVkS2V5OiBoYXNoZWQgfSwKICAgICAgaW5jbHVkZTogeyB0ZW5hbnQ6IHsgc2VsZWN0OiB7IGRlbGV0ZWRBdDogdHJ1ZSB9IH0gfSwKICAgIH0pOwogIH0gY2F0Y2ggKGVycikgewogICAgLy8gT25seSBmYWxsIGJhY2sgd2hlbiB0aGUgQXBpS2V5IHRhYmxlIGhhc24ndCBiZWVuIGNyZWF0ZWQgKHRlc3RzIC8gZnJlc2ggREIpLgogICAgY29uc3QgaXNN aXNzaW5nVGFibGUgPQogICAgICBlcnIgaW5zdGFuY2VvZiBFcnJvciAmJgogICAgICAoZXJyLm1lc3NhZ2UuaW5jbHVkZXMoJ2RvZXMgbm90IGV4aXN0IGluIHRoZSBjdXJyZW50IGRhdGFiYXNlJykgfHwKICAgICAgICAoZXJyIGFzIHsgY29kZT86IHN0cmluZyB9KS5jb2RlID09PSAnUDIwMjEnKTsKICAgIGlmICghaXNN aXNzaW5nVGFibGUpIHRocm93IGVycjsKICB9CgogIGlmIChhcGlLZXkpIHsKICAgIGlmICghaXNQZXJzaXN0ZWRBcGlLZXlVc2FibGUoYXBpS2V5KSkgewogICAgICAvLyBBIHBlcnNpc3RlZC1idXQtZGlzYWJsZWQgY3JlZGVudGlhbCBtdXN0IGZhaWwgY2xvc2VkLiBOZXZlciBsZXQgaXQgZmFsbAogICAgICAvLyB0aHJvdWdoIHRvIHRoZSBsZWdhY3kgaW4tbWVtb3J5IHN0b3JlIHdoZXJlIGFuIG9sZCBkdXBsaWNhdGUgY291bGQgbGl2ZS4KICAgICAgc2VuZEFwaUVycm9yKHJlcSwgcmVzLCB7CiAgICAgICAgc3RhdHVzOiA0MDEsCiAgICAgICAgY29kZTogJ0FVVEhfQVBJX0tFWV9JTlZBTElEJywKICAgICAgICBtZXNzYWdlOiAnSW52YWxpZCBBUEkga2V5JywKICAgICAgICByZXRyeWFibGU6IGZhbHNlLAogICAgICB9KTsKICAgICAgcmV0dXJuOwogICAgfQoKICAgIGlmIChpc1BlcnNpc3RlZEFwaUtleUV4cGlyZWQoYXBpS2V5KSkgewogICAgICBzZW5kVW5hdXRob3JpemVkKHJlcSwgcmVzLCAnQVBJX0tFWV9FWFBJUkVEJywgJ0FQSSBrZXkgaGFzIGV4cGlyZWQnKTsKICAgICAgcmV0dXJuOwogICAgfQoKICAgIC8vIFRlbmFudCBzY29waW5nOiBpZiB0aGUgcmVxdWVzdCB0YXJnZXRzIGEgdGVuYW50LCB0aGUga2V5IG11c3QgYmVsb25nIHRvIGl0LgogICAgaWYgKHJlcXVlc3RlZFRlbmFudElkICYmIHJlcXVlc3RlZFRlbmFudElkICE9PSBhcGlLZXkudGVuYW50SWQpIHsKICAgICAgc2VuZEFwaUVycm9yKHJlcSwgcmVzLCB7CiAgICAgICAgc3RhdHVzOiA0MDEsCiAgICAgICAgY29kZTogJ0FVVEhfQVBJX0tFWV9JTlZBTElEJywKICAgICAgICBtZXNzYWdlOiAnQVBJIGtleSBpcyBub3QgdmFsaWQgZm9yIHRoaXMgdGVuYW50JywKICAgICAgICByZXRyeWFibGU6IGZhbHNlLAogICAgICB9KTsKICAgICAgcmV0dXJuOwogICAgfQoKICAgIGNvbnN0IGF2YWlsYWJsZVNjb3BlcyA9IHBhcnNlU2NvcGVzKGFwaUtleS5zY29wZXMpOwogICAgaWYgKCFoYXNSZXF1aXJlZFNjb3BlcyhhdmFpbGFibGVTY29wZXMsIHJlcXVpcmVkU2NvcGVzKSkgewogICAgICBzZW5kVW5hdXRob3JpemVkKAogICAgICAgIHJlcSwKICAgICAgICByZXMsCiAgICAgICAgJ1NDT1BFX0lOU1VGRklDSUVOVCcsCiAgICAgICAgJ0FQSSBrZXkgbWlzc2VzIHJlcXVpcmVkIHNjb3BlKHMpJywKICAgICAgICAnQmVhcmVyIGVycm9yPSJpbnN1ZmZpY2llbnRfc2NvcGUiJwogICAgICApOwogICAgICByZXR1cm47CiAgICB9CgogICAgcmVxLmF1dGhBcGlLZXlIYXNoID0gYXBpS2V5Lmhhc2hlZEtleTsKICAgIHJlcS5hdXRoQXBpS2V5Um9sZSA9IGFwaUtleS5yb2xlIGFzIEFwaUtleVJvbGU7CiAgICByZXEuYXV0aEFwaUtleVRlbmFudElkID0gYXBpS2V5LnRlbmFudElkOwogICAgcmVxLmF1dGhBcGlLZXlTY29wZXMgPSBhdmFpbGFibGVTY29wZXM7CiAgICByZXEuYXV0aEFwaUtleUxlZ2FjeSA9IGZhbHNlOwoKICAgIC8vIEJlc3QtZWZmb3J0IGxhc3QtdXNlZCB0cmFja2luZyBmb3IgYXVkaXQuIE5ldmVyIGZhaWwgYXV0aCBiZWNhdXNlIG9mIGl0LgogICAgdHJ5IHsKICAgICAgY29uc3Qgbm93ID0gbmV3IERhdGUoKTsKICAgICAgYXdhaXQgcHJpc21hLmFwaUtleS51cGRhdGUoewogICAgICAgIHdoZXJlOiB7IGhhc2hlZEtleTogYXBpS2V5Lmhhc2hlZEtleSB9LAogICAgICAgIGRhdGE6IHsgbGFzdFVzZWRBdDogbm93IH0sCiAgICAgIH0pOwogICAgICByZXEuYXV0aEFwaUtleUxhc3RVc2VkQXQgPSBub3c7CiAgICB9IGNhdGNoIHsKICAgICAgLy8gaWdub3JlIGxhc3QtdXNlZCB1cGRhdGUgZmFpbHVyZXMKICAgIH0KCiAgICBuZXh0KCk7CiAgICByZXR1cm47CiAgfQoKICAvLyBGYWxsYmFjazogY2hlY2sgdGhlIGluLW1lbW9yeSBzdG9yZSAodXNlZCBieSB0ZXN0cyBhbmQgbGVnYWN5IGNhbGxlcnMpCiAgY29uc3QgbWV0YSA9IElOX01FTU9SWV9LRVlTLmdldChoYXNoZWQpOwogIGlmIChtZXRhICYmICFtZXRhLnJldm9rZWRBdCkgewogICAgaWYgKG1ldGEuZXhwaXJlc0F0ICYmIG1ldGEuZXhwaXJlc0F0LmdldFRpbWUoKSA8PSBEYXRlLm5vdygpKSB7CiAgICAgIHNlbmRVbmF1dGhvcml6ZWQocmVxLCByZXMsICdBUElfS0VZX0VYUElSRUQnLCAnQVBJIGtleSBoYXMgZXhwaXJlZCcpOwogICAgICByZXR1cm47CiAgICB9CiAgICBpZiAocmVxdWVzdGVkVGVuYW50SWQgJiYgcmVxdWVzdGVkVGVuYW50SWQgIT09IG1ldGEudGVuYW50SWQpIHsKICAgICAgc2VuZEFwaUVycm9yKHJlcSwgcmVzLCB7CiAgICAgICAgc3RhdHVzOiA0MDEsCiAgICAgICAgY29kZTogJ0FVVEhfQVBJX0tFWV9JTlZBTElEJywKICAgICAgICBtZXNzYWdlOiAnQVBJIGtleSBpcyBub3QgdmFsaWQgZm9yIHRoaXMgdGVuYW50JywKICAgICAgICByZXRyeWFibGU6IGZhbHNlLAogICAgICB9KTsKICAgICAgcmV0dXJuOwogICAgfQogICAgaWYgKCFoYXNSZXF1aXJlZFNjb3BlcyhtZXRhLnNjb3BlcywgcmVxdWlyZWRTY29wZXMpKSB7CiAgICAgIHNlbmRVbmF1dGhvcml6ZWQoCiAgICAgICAgcmVxLAogICAgICAgIHJlcywKICAgICAgICAnU0NPUEVfSU5TVUZGSUNJRU5UJywKICAgICAgICAnQVBJIGtleSBtaXNzZXMgcmVxdWlyZWQgc2NvcGUocyknLAogICAgICAgICdCZWFyZXIgZXJyb3I9Imluc3VmZmljaWVudF9zY29wZSInCiAgICAgICk7CiAgICAgIHJldHVybjsKICAgIH0KICAgIHJlcS5hdXRoQXBpS2V5SGFzaCA9IGhhc2hlZDsKICAgIHJlcS5hdXRoQXBpS2V5Um9sZSA9IG1ldGEucm9sZTsKICAgIHJlcS5hdXRoQXBpS2V5VGVuYW50SWQgPSBtZXRhLnRlbmFudElkOwogICAgcmVxLmF1dGhBcGlLZXlTY29wZXMgPSBtZXRhLnNjb3BlczsKICAgIHJlcS5hdXRoQXBpS2V5TGVnYWN5ID0gZmFsc2U7CiAgICBtZXRhLmxhc3RVc2VkQXQgPSBuZXcgRGF0ZSgpOwogICAgcmVxLmF1dGhBcGlLZXlMYXN0VXNlZEF0ID0gbWV0YS5sYXN0VXNlZEF0OwogICAgbmV4dCgpOwogICAgcmV0dXJuOwogIH0KCiAgLy8gTGVnYWN5IGZhbGxiYWNrOiBzaW5nbGUgZ2xvYmFsIGVudiB2YXIgQVBJX0tFWS4gRGVwcmVjYXRlZC4KICBpZiAoY2hlY2tMZWdhY3lFbnZLZXkocHJvdmlkZWRLZXkpKSB7CiAgICBjb25zb2xlLndhcm4oCiAgICAgICdbZGVwcmVjYXRpb25dIEFQSV9LRVkgZW52IHZhciBmYWxsYmFjayBpcyBkZXByZWNhdGVkLiBNaWdyYXRlIHRvIHBlci10ZW5hbnQgQVBJIGtleXMgaW4gdGhlIGFwaUtleSB0YWJsZS4nCiAgICApOwogICAgaWYgKHJlcXVlc3RlZFRlbmFudElkICYmIHJlcXVlc3RlZFRlbmFudElkICE9PSBMRUdBQ1lfRU5WX1RFTkFOVCkgewogICAgICBzZW5kQXBpRXJyb3IocmVxLCByZXMsIHsKICAgICAgICBzdGF0dXM6IDQwMSwKICAgICAgICBjb2RlOiAnQVVUSF9BUElfS0VZX0lOVkFMSUQnLAogICAgICAgIG1lc3NhZ2U6ICdBUElrZXkgaXMgbm90IHZhbGlkIGZvciB0aGlzIHRlbmFudCcsCiAgICAgICAgcmV0cnlhYmxlOiBmYWxzZSwKICAgICAgfSk7CiAgICAgIHJldHVybjsKICAgIH0KICAgIGlmICghaGFzUmVxdWlyZWRTY29wZXMoTEVHQUNZX0VOVl9TQ09QRVMsIHJlcXVpcmVkU2NvcGVzKSkgewogICAgICBzZW5kVW5hdXRob3JpemVkKAogICAgICAgIHJlcSwKICAgICAgICByZXMsCiAgICAgICAgJ1NDT1BFX0lOU1VGRklDSUVOVCcsCiAgICAgICAgJ0FQSSBrZXkgbWlzc2VzIHJlcXVpcmVkIHNjb3BlKHMpJywKICAgICAgICAnQmVhcmVyIGVycm9yPSJpbnN1ZmZpY2llbnRfc2NvcGUiJwogICAgICApOwogICAgICByZXR1cm47CiAgICB9CiAgICByZXEuYXV0aEFwaUtleUhhc2ggPSBoYXNoZWQ7CiAgICByZXEuYXV0aEFwaUtleVJvbGUgPSAnYWRtaW4nOwogICAgcmVxLmF1dGhBcGlLZXlUZW5hbnRJZCA9IExFR0FDWV9FTlZfVEVOQU5UOwogICAgcmVxLmF1dGhBcGlLZXlTY29wZXMgPSBMRUdBQ1lfRU5WX1NDT1BFUzsKICAgIHJlcS5hdXRoQXBpS2V5TGVnYWN5ID0gdHJ1ZTsKICAgIG5leHQoKTsKICAgIHJldHVybjsKICB9CgogIHNlbmRBcGlFcnJvcihyZXEsIHJlcywgewogICAgc3RhdHVzOiA0MDEsCiAgICBjb2RlOiAnQVVUSF9BUElfS0VZX0lOVkFMSUQnLAogICAgbWVzc2FnZTogJ0ludmFsaWQgQVBJIGtleScsCiAgICByZXRyeWFibGU6IGZhbHNlLAogIH0pOwp9CgpleHBvcnQgZnVuY3Rpb24gaGFzaEFwaUtleShrZXk6IHN0cmluZyk6IHN0cmluZyB7CiAgcmV0dXJuIGNyeXB0by5jcmVhdGVIYXNoKCdzaGEyNTYnKS51cGRhdGUoa2V5KS5kaWdlc3QoJ2hleCcpOwp9CgovLyBMZWdhY3kgaGVscGVycyByZXRhaW5lZCBmb3IgY29tcGF0aWJpbGl0eSDigJMgb3BlcmF0ZSBvbiBpbi1tZW1vcnkgbWFwIG9ubHkuCmNvbnN0IElOX01FTU9SWV9LRVlTID0gbmV3IE1hcDxzdHJpbmcsIEFwaUtleU1ldGFkYXRhPigpOwoKZXhwb3J0IGZ1bmN0aW9uIHJlZ2lzdGVyQXBpS2V5KAogIGtleTogc3RyaW5nLAogIG9wdGlvbnM/OiB7IHJvbGU/OiBBcGlLZXlSb2xlOyB0ZW5hbnRJZD86IHN0cmluZzsgc2NvcGVzPzogc3RyaW5nW107IGV4cGlyZXNBdD86IERhdGUgfCBudWxsIH0KKTogc3RyaW5nIHsKICBjb25zdCBoYXNoID0gaGFzaEFwaUtleShrZXkpOwogIElOX01FTU9SWV9LRVlTLnNldChoYXNoLCB7CiAgICByb2xlOiBvcHRpb25zPy5yb2xlIHx8ICdhZG1pbicsCiAgICBjcmVhdGVkQXQ6IG5ldyBEYXRlKCksCiAgICB0ZW5hbnRJZDogb3B0aW9ucz8udGVuYW50SWQgfHwgJ3Vua25vd24nLAogICAgc2NvcGVzOiBvcHRpb25zPy5zY29wZXMgfHwgW10sCiAgICBleHBpcmVzQXQ6IG9wdGlvbnM/LmV4cGlyZXNBdCA/PyBudWxsLAogIH0pOwogIHJldHVybiBoYXNoOwp9CgpleHBvcnQgZnVuY3Rpb24gcmV2b2tlQXBpS2V5KGhhc2g6IHN0cmluZyk6IGJvb2xlYW4gewogIGNvbnN0IG1ldGEgPSBJTl9NRU1PUllfS0VZUy5nZXQoaGFzaCk7CiAgaWYgKG1ldGEpIHsKICAgIG1ldGEucmV2b2tlZEF0ID0gbmV3IERhdGUoKTsKICAgIElOX01FTU9SWV9LRVlTLnNldChoYXNoLCBtZXRhKTsKICB9CiAgcmV0dXJuIElOX01FTU9SWV9LRVlTLmRlbGV0ZShoYXNoKTsKfQoKZXhwb3J0IGZ1bmN0aW9uIHJvdGF0ZUFwaUtleSgKICBvbGRIYXNoOiBzdHJpbmcsCiAgbmV3S2V5OiBzdHJpbmcsCiAgb3B0aW9uczogeyByb2xlPzogQXBpS2V5Um9sZTsgdGVuYW50SWQ/OiBzdHJpbmc7IHNjb3Blcz86IHN0cmluZ1tdIH0gPSB7fQopOiBzdHJpbmcgfCBudWxsIHsKICBjb25zdCBtZXRhID0gSU5fTUVNT1JZX0tFWVMuZ2V0KG9sZEhhc2gpOwogIGlmICghbWV0YSkgcmV0dXJuIG51bGw7CiAgSU5fTUVNT1JZX0tFWVMuZGVsZXRlKG9sZEhhc2gpOwogIGNvbnN0IG5ld0hhc2ggPSBoYXNoQXBpS2V5KG5ld0tleSk7CiAgSU5fTUVNT1JZX0tFWVMuc2V0KG5ld0hhc2gsIHsKICAgIHJvbGU6IG9wdGlvbnMucm9sZSB8fCBtZXRhLnJvbGUsCiAgICBjcmVhdGVkQXQ6IG1ldGEuY3JlYXRlZEF0LAogICAgcm90YXRlZEF0OiBuZXcgRGF0ZSgpLAogICAgdGVuYW50SWQ6IG9wdGlvbnMudGVuYW50SWQgfHwgbWV0YS50ZW5hbnRJZCwKICAgIHNjb3Blczogb3B0aW9ucy5zY29wZXMgfHwgbWV0YS5zY29wZXMsCiAgICBleHBpcmVzQXQ6IG1ldGEuZXhwaXJlc0F0ID8/IG51bGwsCiAgfSk7CiAgcmV0dXJuIG5ld0hhc2g7Cn0KCmV4cG9ydCBmdW5jdGlvbiByZXN0b3JlQXBpS2V5KGhhc2g6IHN0cmluZyk6IGJvb2xlYW4gewogIGNvbnN0IG1ldGEgPSBJTl9NRU1PUllfS0VZUy5nZXQoaGFzaCk7CiAgaWYgKCFtZXRhKSByZXR1cm4gZmFsc2U7CiAgbWV0YS5yZXZva2VkQXQgPSB1bmRlZmluZWQ7CiAgSU5fTUVNT1JZX0tFWVMuc2V0KGhhc2gsIG1ldGEpOwogIHJldHVybiB0cnVlOwp9CgpleHBvcnQgZnVuY3Rpb24gZ2V0QXBpS2V5TWV0YWRhdGEoaGFzaDogc3RyaW5nKSB7CiAgY29uc3QgbWV0YSA9IElOX01FTU9SWV9LRVlTLmdldChoYXNoKTsKICBpZiAoIW1ldGEpIHJldHVybiBudWxsOwogIHJldHVybiB7CiAgICBoYXNoLAogICAgcm9sZTogbWV0YS5yb2xlLAogICAgY3JlYXRlZEF0OiBtZXRhLmNyZWF0ZWRBdC50b0lTT1N0cmluZygpLAogICAgLi4uKG1ldGEucm90YXRlZEF0ID8geyByb3RhdGVkQXQ6IG1ldGEucm90YXRlZEF0LnRvSVNPU3RyaW5nKCkgfSA6IHt9KSwKICAgIC4uLihtZXRhLnJldm9rZWRBdCA/IHsgcmV2b2tlZEF0OiBtZXRhLnJldm9rZWRBdC50b0lTT1N0cmluZygpIH0gOiB7fSksCiAgICB0ZW5hbnRJZDogbWV0YS50ZW5hbnRJZCwKICAgIHNjb3BlczogbWV0YS5zY29wZXMsCiAgICAuLi4obWV0YS5leHBpcmVzQXQgPyB7IGV4cGlyZXNBdDogbWV0YS5leHBpcmVzQXQudG9JU09TdHJpbmcoKSB9IDoge30pLAogICAgLi4uKG1ldGEubGFzdFVzZWRBdCA/IHsgbGFzdFVzZWRBdDogbWV0YS5sYXN0VXNlZEF0LnRvSVNPU3RyaW5nKCkgfSA6IHt9KSwKICB9Owp9CgpleHBvcnQgZnVuY3Rpb24gYXV0aGVudGljYXRlQXBpS2V5VmFsdWUodmFsdWU6IHN0cmluZykgewogIGNvbnN0IGhhc2ggPSBoYXNoQXBpS2V5KHZhbHVlKTsKICBjb25zdCBtZXRhID0gSU5fTUVNT1JZX0tFWVMuZ2V0KGhhc2gpOwogIGlmICghbWV0YSB8fCBtZXRhLnJldm9rZWRBdCkgcmV0dXJuIG51bGw7CiAgaWYgKG1ldGEuZXhwaXJlc0F0ICYmIG1ldGEuZXhwaXJlc0F0LmdldFRpbWUoKSA8PSBEYXRlLm5vdygpKSByZXR1cm4gbnVsbDsKICByZXR1cm4geyBoYXNoLCByb2xlOiBtZXRhLnJvbGUsIHRlbmFudElkOiBtZXRhLnRlbmFudElkLCBzY29wZXM6IG1ldGEuc2NvcGVzIH07Cn0KCmV4cG9ydCBmdW5jdGlvbiBoYXNSZXF1aXJlZEFwaUtleVJvbGUocmVxOiBSZXF1ZXN0LCByZXF1aXJlZFJvbGU6IEFwaUtleVJvbGUpOiBib29sZWFuIHsKICBjb25zdCBjdXJyZW50Um9sZSA9IHJlcS5hdXRoQXBpS2V5Um9sZSB8fCAnYWRtaW4nOwogIHJldHVybiBST0xFX1BSRUNFREVOQ0VbY3VycmVudFJvbGVdID49IFJPTEVfUFJFQ0VERU5DRVtyZXF1aXJlZFJvbGVdOwp9Cg== \ No newline at end of file +import type { Request, Response, NextFunction } from 'express'; +import crypto from 'crypto'; +import { prisma } from '../prisma'; +import { sendApiError } from './apiError'; + +export type ApiKeyRole = 'viewer' | 'operator' | 'admin' | 'super-admin'; + +interface ApiKeyMetadata { + role: ApiKeyRole; + createdAt: Date; + rotatedAt?: Date; + revokedAt?: Date; + tenantId: string; + scopes: string[]; + expiresAt?: Date | null; + lastUsedAt?: Date | null; +} + +declare global { + namespace Express { + interface Request { + authApiKeyHash?: string; + authApiKeyRole?: ApiKeyRole; + authApiKeyTenantId?: string; + authApiKeyScopes?: string[]; + authApiKeyLastUsedAt?: Date | null; + authApiKeyLegacy?: boolean; + } + } +} + +const ROLE_PRECEDENCE: Record = { + viewer: 0, + operator: 1, + admin: 2, + 'super-admin': 3, +}; + +export function normalizeApiKeyRole(raw: unknown): ApiKeyRole | null { + if (typeof raw !== 'string') { + return null; + } + const value = raw.trim().toLowerCase(); + if (value === 'viewer' || value === 'operator' || value === 'admin' || value === 'super-admin') { + return value as ApiKeyRole; + } + return null; +} + +function parseScopes(raw: unknown): string[] { + if (Array.isArray(raw)) return raw as string[]; + if (typeof raw === 'string') { + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } + } + return []; +} + +export interface PersistedApiKeyStatus { + isActive: boolean; + deletedAt?: Date | null; + expiresAt?: Date | null; + tenant?: { deletedAt?: Date | null } | null; +} + +export function isPersistedApiKeyUsable( + apiKey: PersistedApiKeyStatus | null +): apiKey is PersistedApiKeyStatus { + return Boolean(apiKey && apiKey.isActive && !apiKey.deletedAt && !apiKey.tenant?.deletedAt); +} + +export function isPersistedApiKeyExpired( + apiKey: PersistedApiKeyStatus | null +): boolean { + return Boolean(apiKey && apiKey.expiresAt && apiKey.expiresAt.getTime() <= Date.now()); +} + +export function extractTenantIdFromRequest(req: Request): string | null { + const fromParams = req.params?.tenantId; + if (typeof fromParams === 'string' && fromParams.trim()) { + return fromParams.trim(); + } + const fromQuery = req.query?.tenantId; + if (typeof fromQuery === 'string' && fromQuery.trim()) { + return fromQuery.trim(); + } + const fromHeader = req.get?.('x-tenant-id'); + if (typeof fromHeader === 'string' && fromHeader.trim()) { + return fromHeader.trim(); + } + return null; +} + +export function extractRequiredScopes(req: Request): string[] { + const raw = (req as Request & { requiredScopes?: unknown }).requiredScopes; + if (Array.isArray(raw)) { + return raw.filter((s): s is string => typeof s === 'string'); + } + if (typeof raw === 'string' && raw.trim()) { + return raw.split(',').map((s) => s.trim()).filter(Boolean); + } + return []; +} + +export function hasRequiredScopes(available: string[], required: string[]): boolean { + if (required.length === 0) return true; + if (available.includes('*')) return true; + return required.every((scope) => available.includes(scope)); +} + +function sendUnauthorized( + req: Request, + res: Response, + code: string, + message: string, + scheme: string = 'Bearer' +): void { + res.setHeader('WWW-Authenticate', scheme); + sendApiError(req, res, { + status: 401, + code, + message, + retryable: false, + }); +} + +const LEGACY_ENV_KEY = process.env.API_KEY; +const LEGACY_ENV_TENANT = process.env.API_KEY_TENANT_ID || 'default'; +const LEGACY_ENV_SCOPES = (process.env.API_KEY_SCOPES || '*') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + +function checkLegacyEnvKey(providedKey: string): boolean { + if (!LEGACY_ENV_KEY) return false; + const a = Buffer.from(providedKey); + const b = Buffer.from(LEGACY_ENV_KEY); + if (a.length !== b.length) return false; + return crypto.timingSafeEqual(a, b); +} + +export async function validateApiKey( + req: Request, + res: Response, + next: NextFunction +): Promise { + const authHeader = req.get?.('Authorization') || ''; + const match = authHeader.match(/^ApiKey\s+(.+)$/i); + if (!match) { + sendApiError(req, res, { + status: 401, + code: 'AUTH_API_KEY_MISSING', + message: 'Missing or invalid API key', + retryable: false, + }); + return; + } + const providedKey = match[1]; + const hashed = crypto.createHash('sha256').update(providedKey).digest('hex'); + const requestedTenantId = extractTenantIdFromRequest(req); + const requiredScopes = extractRequiredScopes(req); + + // Try Prisma first; fall back to in-memory store when the table doesn't exist + // (e.g. in tests with an un-migrated SQLite database). + let apiKey: { + id?: string; + hashedKey: string; + role: string | null; + tenantId: string; + scopes: unknown; + isActive: boolean; + deletedAt?: Date | null; + expiresAt?: Date | null; + tenant?: { deletedAt?: Date | null } | null; + } | null = null; + try { + apiKey = await prisma.apiKey.findUnique({ + where: { hashedKey: hashed }, + include: { tenant: { select: { deletedAt: true } } }, + }); + } catch (err) { + // Only fall back when the ApiKey table hasn't been created (tests / fresh DB). + const isMissingTable = + err instanceof Error && + (err.message.includes('does not exist in the current database') || + (err as { code?: string }).code === 'P2021'); + if (!isMissingTable) throw err; + } + + if (apiKey) { + if (!isPersistedApiKeyUsable(apiKey)) { + // A persisted-but-disabled credential must fail closed. Never let it fall + // through to the legacy in-memory store where an old duplicate could live. + sendApiError(req, res, { + status: 401, + code: 'AUTH_API_KEY_INVALID', + message: 'Invalid API key', + retryable: false, + }); + return; + } + + if (isPersistedApiKeyExpired(apiKey)) { + sendUnauthorized(req, res, 'API_KEY_EXPIRED', 'API key has expired'); + return; + } + + // Tenant scoping: if the request targets a tenant, the key must belong to it. + if (requestedTenantId && requestedTenantId !== apiKey.tenantId) { + sendApiError(req, res, { + status: 401, + code: 'AUTH_API_KEY_INVALID', + message: 'API key is not valid for this tenant', + retryable: false, + }); + return; + } + + const availableScopes = parseScopes(apiKey.scopes); + if (!hasRequiredScopes(availableScopes, requiredScopes)) { + sendUnauthorized( + req, + res, + 'SCOPE_INSUFFICIENT', + 'API key misses required scope(s)', + 'Bearer error="insufficient_scope"' + ); + return; + } + + req.authApiKeyHash = apiKey.hashedKey; + req.authApiKeyRole = (apiKey.role || 'viewer') as ApiKeyRole; + req.authApiKeyTenantId = apiKey.tenantId; + req.authApiKeyScopes = availableScopes; + req.authApiKeyLegacy = false; + + // Best-effort last-used tracking for audit. Never fail auth because of it. + try { + const now = new Date(); + await prisma.apiKey.update({ + where: { hashedKey: apiKey.hashedKey }, + data: { lastUsedAt: now }, + }); + req.authApiKeyLastUsedAt = now; + } catch { + // ignore last-used update failures + } + + next(); + return; + } + + // Fallback: check the in-memory store (used by tests and legacy callers) + const meta = IN_MEMORY_KEYS.get(hashed); + if (meta && !meta.revokedAt) { + if (meta.expiresAt && meta.expiresAt.getTime() <= Date.now()) { + sendUnauthorized(req, res, 'API_KEY_EXPIRED', 'API key has expired'); + return; + } + if (requestedTenantId && requestedTenantId !== meta.tenantId) { + sendApiError(req, res, { + status: 401, + code: 'AUTH_API_KEY_INVALID', + message: 'API key is not valid for this tenant', + retryable: false, + }); + return; + } + if (!hasRequiredScopes(meta.scopes, requiredScopes)) { + sendUnauthorized( + req, + res, + 'SCOPE_INSUFFICIENT', + 'API key misses required scope(s)', + 'Bearer error="insufficient_scope"' + ); + return; + } + req.authApiKeyHash = hashed; + req.authApiKeyRole = meta.role; + req.authApiKeyTenantId = meta.tenantId; + req.authApiKeyScopes = meta.scopes; + req.authApiKeyLegacy = false; + meta.lastUsedAt = new Date(); + req.authApiKeyLastUsedAt = meta.lastUsedAt; + next(); + return; + } + + // Legacy fallback: single global env var API_KEY. Deprecated. + if (checkLegacyEnvKey(providedKey)) { + console.warn( + '[deprecation] API_KEY env var fallback is deprecated. Migrate to per-tenant API keys in the apiKey table.' + ); + if (requestedTenantId && requestedTenantId !== LEGACY_ENV_TENANT) { + sendApiError(req, res, { + status: 401, + code: 'AUTH_API_KEY_INVALID', + message: 'APIkey is not valid for this tenant', + retryable: false, + }); + return; + } + if (!hasRequiredScopes(LEGACY_ENV_SCOPES, requiredScopes)) { + sendUnauthorized( + req, + res, + 'SCOPE_INSUFFICIENT', + 'API key misses required scope(s)', + 'Bearer error="insufficient_scope"' + ); + return; + } + req.authApiKeyHash = hashed; + req.authApiKeyRole = 'admin'; + req.authApiKeyTenantId = LEGACY_ENV_TENANT; + req.authApiKeyScopes = LEGACY_ENV_SCOPES; + req.authApiKeyLegacy = true; + next(); + return; + } + + sendApiError(req, res, { + status: 401, + code: 'AUTH_API_KEY_INVALID', + message: 'Invalid API key', + retryable: false, + }); +} + +export function hashApiKey(key: string): string { + return crypto.createHash('sha256').update(key).digest('hex'); +} + +// Legacy helpers retained for compatibility – operate on in-memory map only. +const IN_MEMORY_KEYS = new Map(); + +export function registerApiKey( + key: string, + options?: { role?: ApiKeyRole; tenantId?: string; scopes?: string[]; expiresAt?: Date | null } +): string { + const hash = hashApiKey(key); + IN_MEMORY_KEYS.set(hash, { + role: options?.role || 'admin', + createdAt: new Date(), + tenantId: options?.tenantId || 'unknown', + scopes: options?.scopes || [], + expiresAt: options?.expiresAt ?? null, + }); + return hash; +} + +export function revokeApiKey(hash: string): boolean { + const meta = IN_MEMORY_KEYS.get(hash); + if (meta) { + meta.revokedAt = new Date(); + IN_MEMORY_KEYS.set(hash, meta); + } + return IN_MEMORY_KEYS.delete(hash); +} + +export function rotateApiKey( + oldHash: string, + newKey: string, + options: { role?: ApiKeyRole; tenantId?: string; scopes?: string[] } = {} +): string | null { + const meta = IN_MEMORY_KEYS.get(oldHash); + if (!meta) return null; + IN_MEMORY_KEYS.delete(oldHash); + const newHash = hashApiKey(newKey); + IN_MEMORY_KEYS.set(newHash, { + role: options.role || meta.role, + createdAt: meta.createdAt, + rotatedAt: new Date(), + tenantId: options.tenantId || meta.tenantId, + scopes: options.scopes || meta.scopes, + expiresAt: meta.expiresAt ?? null, + }); + return newHash; +} + +export function restoreApiKey(hash: string): boolean { + const meta = IN_MEMORY_KEYS.get(hash); + if (!meta) return false; + meta.revokedAt = undefined; + IN_MEMORY_KEYS.set(hash, meta); + return true; +} + +export function getApiKeyMetadata(hash: string) { + const meta = IN_MEMORY_KEYS.get(hash); + if (!meta) return null; + return { + hash, + role: meta.role, + createdAt: meta.createdAt.toISOString(), + ...(meta.rotatedAt ? { rotatedAt: meta.rotatedAt.toISOString() } : {}), + ...(meta.revokedAt ? { revokedAt: meta.revokedAt.toISOString() } : {}), + tenantId: meta.tenantId, + scopes: meta.scopes, + ...(meta.expiresAt ? { expiresAt: meta.expiresAt.toISOString() } : {}), + ...(meta.lastUsedAt ? { lastUsedAt: meta.lastUsedAt.toISOString() } : {}), + }; +} + +export function authenticateApiKeyValue(value: string) { + const hash = hashApiKey(value); + const meta = IN_MEMORY_KEYS.get(hash); + if (!meta || meta.revokedAt) return null; + if (meta.expiresAt && meta.expiresAt.getTime() <= Date.now()) return null; + return { hash, role: meta.role, tenantId: meta.tenantId, scopes: meta.scopes }; +} + +export function hasRequiredApiKeyRole(req: Request, requiredRole: ApiKeyRole): boolean { + const currentRole = req.authApiKeyRole || 'admin'; + return ROLE_PRECEDENCE[currentRole] >= ROLE_PRECEDENCE[requiredRole]; +} diff --git a/backend/src/middleware/cache.ts b/backend/src/middleware/cache.ts index c080b4ca1..4878255d4 100644 --- a/backend/src/middleware/cache.ts +++ b/backend/src/middleware/cache.ts @@ -352,24 +352,17 @@ export function triggerCacheInvalidation( continue; } - if (!Array.isArray(hookPatterns)) { - throw new TypeError('invalidation hook must return an array of patterns'); - } - if (!Array.isArray(hookPatterns)) { console.error( JSON.stringify({ level: 'error', - event: 'invalidation_hook_error', - error: `InvalidationHook must return string[], got ${typeof hookPatterns}`, - eventType, event: 'invalidation_hook_invalid_return', error: 'Invalidation hook must return an array of cache key patterns', + eventType, }), ); continue; } - patterns.push(...hookPatterns); patterns.push(...hookPatterns.filter((pattern): pattern is string => typeof pattern === 'string')); } catch (err) { console.error( diff --git a/backend/src/middleware/errorBoundary.ts b/backend/src/middleware/errorBoundary.ts index b35e59d24..e0e5ee19f 100644 --- a/backend/src/middleware/errorBoundary.ts +++ b/backend/src/middleware/errorBoundary.ts @@ -100,7 +100,9 @@ interface TypedErrorResponse { // ─── Classifier ───────────────────────────────────────────────────────────── -function classifyError(err: Error): TypedErrorResponse | null { +function classifyError(err: unknown): TypedErrorResponse | null { + if (!err || typeof err !== 'object') return null; + // Already a typed upstream error if (err instanceof UpstreamError) { return { @@ -130,13 +132,11 @@ function classifyError(err: Error): TypedErrorResponse | null { // Soroban simulation / submission errors if (err instanceof SorobanSimulationError) { - const retryable = ['RESTORE_REQUIRED', 'RPC_ERROR'].includes( - (err as any).code ?? '', - ); + const retryable = ['RESTORE_REQUIRED', 'RPC_ERROR'].includes(err.code); return { error: 'SorobanError', - status: (err as any).statusCode ?? 502, - code: (err as any).code ?? 'RPC_ERROR', + status: err.statusCode, + code: err.code, message: err.message, dependency: 'stellar_rpc', retryable, @@ -162,8 +162,12 @@ function classifyError(err: Error): TypedErrorResponse | null { }; } + const errMessage = 'message' in err && typeof (err as { message?: unknown }).message === 'string' + ? (err as { message: string }).message + : ''; + // Prisma query timeout - if (err.message?.includes('Prisma query timed out')) { + if (errMessage.includes('Prisma query timed out')) { return { error: 'DatabaseError', status: 503, @@ -189,7 +193,7 @@ function classifyError(err: Error): TypedErrorResponse | null { } // Wallet scope violation from walletQueryGuard - if (err.message === 'FORBIDDEN_TENANT_ACCESS' || err.message === 'WALLET_SCOPE_VIOLATION') { + if (errMessage === 'FORBIDDEN_TENANT_ACCESS' || errMessage === 'WALLET_SCOPE_VIOLATION') { return { error: 'Forbidden', status: 403, @@ -202,8 +206,10 @@ function classifyError(err: Error): TypedErrorResponse | null { return null; } -function classifyPrismaKnownRequestError(err: Error): TypedErrorResponse | null { - if (err.constructor?.name !== 'PrismaClientKnownRequestError') return null; +function classifyPrismaKnownRequestError(err: unknown): TypedErrorResponse | null { + if (!err || typeof err !== 'object') return null; + const name = (err as { constructor?: { name?: string } }).constructor?.name; + if (name !== 'PrismaClientKnownRequestError') return null; switch ((err as { code?: unknown }).code) { case 'P2002': @@ -235,24 +241,29 @@ function classifyPrismaKnownRequestError(err: Error): TypedErrorResponse | null } } -function isPrismaError(err: Error): boolean { - const name = err.constructor?.name ?? ''; +function isPrismaError(err: unknown): boolean { + if (!err || typeof err !== 'object') return false; + const name = (err as { constructor?: { name?: string } }).constructor?.name ?? ''; if (name.startsWith('Prisma')) return true; - if ('code' in err && typeof (err as any).code === 'string') { - const code: string = (err as any).code; + if ('code' in err && typeof (err as { code?: unknown }).code === 'string') { + const code = (err as { code: string }).code; // Prisma error codes start with P if (/^P\d{4}$/.test(code)) return true; } return false; } -function isRedisError(err: Error): boolean { - const msg = err.message?.toLowerCase() ?? ''; +function isRedisError(err: unknown): boolean { + if (!err || typeof err !== 'object') return false; + const msg = 'message' in err && typeof (err as { message?: unknown }).message === 'string' + ? (err as { message: string }).message.toLowerCase() + : ''; + const name = (err as { constructor?: { name?: string } }).constructor?.name ?? ''; return ( msg.includes('redis') || - msg.includes('econnrefused') && msg.includes('6379') || - err.constructor?.name === 'ReplyError' || - err.constructor?.name === 'AbortError' + (msg.includes('econnrefused') && msg.includes('6379')) || + name === 'ReplyError' || + name === 'AbortError' ); } @@ -265,7 +276,7 @@ function isRedisError(err: Error): boolean { * Mount BEFORE the catch-all error handler so it gets first crack at errors. */ export const errorBoundaryMiddleware: ErrorRequestHandler = ( - err: any, // eslint-disable-line @typescript-eslint/no-explicit-any + err: unknown, req: Request, res: Response, next: NextFunction, @@ -288,7 +299,7 @@ export const errorBoundaryMiddleware: ErrorRequestHandler = ( retryable: classified.retryable, correlationId, traceId, - originalError: err.message, + originalError: err instanceof Error ? err.message : String(err), }); const body: TypedErrorResponse = { diff --git a/backend/src/middleware/payloadLimit.ts b/backend/src/middleware/payloadLimit.ts index 5f8dca1fc..30a2c8ba1 100644 --- a/backend/src/middleware/payloadLimit.ts +++ b/backend/src/middleware/payloadLimit.ts @@ -20,6 +20,7 @@ */ import express, { Request, Response, NextFunction, RequestHandler, ErrorRequestHandler } from 'express'; +import { sendApiError } from './apiError'; // ─── Tier Definitions ───────────────────────────────────────────────────────── @@ -90,21 +91,35 @@ export function resolvePayloadTier(req: Request): Tier { // ─── 413 Error Handler ──────────────────────────────────────────────────────── +interface PayloadTooLargeError { + status?: number; + statusCode?: number; + type?: string; + message?: string; +} + +function isPayloadTooLargeError(err: unknown): err is PayloadTooLargeError { + if (!err || typeof err !== 'object') return false; + const candidate = err as Record; + return ( + candidate.status === 413 || + candidate.statusCode === 413 || + candidate.type === 'entity.too.large' + ); +} + function makePayloadErrorHandler(tier: Tier): ErrorRequestHandler { const limit = resolveTierLimit(tier); - return (err: any, _req: Request, res: Response, next: NextFunction): void => { - if ( - err && - (err.status === 413 || - err.statusCode === 413 || - err.type === 'entity.too.large') - ) { - res.status(413).json({ - error: 'Payload Too Large', + return (err: unknown, req: Request, res: Response, next: NextFunction): void => { + if (isPayloadTooLargeError(err)) { + sendApiError(req, res, { status: 413, + code: 'PAYLOAD_TOO_LARGE', message: `Request body exceeds the ${limit} limit for ${tier} routes. Reduce the payload size and retry.`, limit, + details: { limit, tier }, + retryable: false, }); return; } diff --git a/backend/src/middleware/tenantBoundary.ts b/backend/src/middleware/tenantBoundary.ts index 4ba5fab85..df7b8fa08 100644 --- a/backend/src/middleware/tenantBoundary.ts +++ b/backend/src/middleware/tenantBoundary.ts @@ -17,6 +17,7 @@ import type { Request, Response, NextFunction } from 'express'; import { logger } from './structuredLogging'; import { prisma } from '../prisma'; +import type { ApiKeyRole } from './apiKeyAuth'; // ─── Extended Express Request Interface ────────────────────────────────────── @@ -28,8 +29,9 @@ declare global { tenantScopes?: Set; authApiKeyTenantId?: string; authApiKeyScopes?: string[]; - authApiKeyRole?: string; + authApiKeyRole?: ApiKeyRole; authApiKeyHash?: string; + authApiKeyLegacy?: boolean; } } } @@ -278,7 +280,7 @@ export function assertApiKeyUsable(req: Request, resourceName: string): void { // Legacy fallback: keys minted from the deprecated global API_KEY env var // have no scopes recorded. Treat them as having all tenant scopes so the // fallback path keeps working while operators migrate. - if (granted.length === 0 && req.authApiKeyRole === 'legacy') { + if (granted.length === 0 && (req.authApiKeyLegacy || (req.authApiKeyRole as unknown) === 'legacy')) { return; } diff --git a/backend/src/middleware/validate.ts b/backend/src/middleware/validate.ts index 1d0c1462b..3568203d0 100644 --- a/backend/src/middleware/validate.ts +++ b/backend/src/middleware/validate.ts @@ -253,8 +253,6 @@ export function validate(schemas: ValidateTargets) { code: 'VALIDATION_ERROR', summary: 'Request validation failed', message: formatZodError(issues), - summary: 'Request validation failed', - errors: details, details, errors: details, retryable: false, diff --git a/backend/src/pagination.ts b/backend/src/pagination.ts index 51d4b1ee9..c6f16df21 100644 --- a/backend/src/pagination.ts +++ b/backend/src/pagination.ts @@ -48,6 +48,8 @@ export interface PaginationMeta { prevCursor: string | null; /** Current page number (for offset-based pagination). */ currentPage: number | null; + /** Legacy alias for currentPage. */ + page?: number; /** Total number of pages (if total is known). */ totalPages: number | null; /** Whether there are more items after this page. */ @@ -218,7 +220,6 @@ export function parsePaginationQuery( query.page = 1; } } - } // Parse sortBy if (req.query.sortBy !== undefined && typeof req.query.sortBy === 'string') { @@ -452,6 +453,7 @@ export function createPaginationEnvelope(meta: { nextCursor: meta.nextCursor ?? null, prevCursor: meta.prevCursor ?? null, currentPage: meta.currentPage ?? null, + page: meta.currentPage ?? undefined, totalPages: meta.totalPages ?? null, hasNextPage: meta.hasNextPage, hasPrevPage: meta.hasPrevPage, diff --git a/backend/src/reconciliationReport.ts b/backend/src/reconciliationReport.ts index a1be91d69..0f27ee221 100644 --- a/backend/src/reconciliationReport.ts +++ b/backend/src/reconciliationReport.ts @@ -13,6 +13,7 @@ import type { Request, Response } from 'express'; import { getPrismaClient } from './prismaClient'; import { logger } from './middleware/structuredLogging'; import { getCurrentTraceId } from './tracing'; +import { sendApiError } from './middleware/apiError'; // ─── Types ────────────────────────────────────────────────────────────────── @@ -347,9 +348,9 @@ export async function automatedReconciliationSummaryHandler( ): Promise { const summary = getLastAutomatedReconciliationSummary(); if (!summary) { - res.status(404).json({ - error: 'Not Found', + sendApiError(req, res, { status: 404, + code: 'RECONCILIATION_SUMMARY_NOT_FOUND', message: 'No automated reconciliation summary available yet', }); return; diff --git a/backend/src/referralEndpoints.ts b/backend/src/referralEndpoints.ts index f4d26b9b7..799682538 100644 --- a/backend/src/referralEndpoints.ts +++ b/backend/src/referralEndpoints.ts @@ -3,7 +3,7 @@ import { referralService } from './referralService'; import { logger } from './middleware/structuredLogging'; import { normalizeWalletAddress } from './walletUtils'; import { cacheMiddleware } from './middleware/cache'; -import { buildApiErrorBody, type ApiErrorBody } from './middleware/apiError'; +import { buildApiErrorBody, type ApiErrorBody, sendApiError } from './middleware/apiError'; const router = Router(); const REFERRAL_CACHE_TTL_MS = parseInt(process.env.CACHE_LIST_ENDPOINTS_TTL_MS || '30000', 10); @@ -61,7 +61,8 @@ router.get('/:wallet', cacheMiddleware({ ttl: REFERRAL_CACHE_TTL_MS }), async (r const { wallet } = req.params; if (!wallet) { - return res.status(400).json(buildReferralWalletRequiredBody()); + sendApiError(req, res, { status: 400, code: 'BAD_REQUEST', message: WALLET_REQUIRED_MESSAGE }); + return; } const normalizedWallet = normalizeWalletAddress(wallet); @@ -70,7 +71,8 @@ router.get('/:wallet', cacheMiddleware({ ttl: REFERRAL_CACHE_TTL_MS }), async (r const stats = await referralService.getReferralStats(normalizedWallet); if (!stats) { - return res.status(404).json(buildReferralStatsNotFoundBody()); + sendApiError(req, res, { status: 404, code: 'NOT_FOUND', message: REFERRAL_STATS_NOT_FOUND_MESSAGE }); + return; } return res.status(200).json(stats); @@ -79,7 +81,8 @@ router.get('/:wallet', cacheMiddleware({ ttl: REFERRAL_CACHE_TTL_MS }), async (r error: error instanceof Error ? error.message : String(error), wallet: normalizedWallet, }); - return res.status(500).json(buildReferralStatsErrorBody()); + sendApiError(req, res, { status: 500, code: 'INTERNAL_SERVER_ERROR', message: REFERRAL_STATS_ERROR_MESSAGE }); + return; } }); @@ -112,7 +115,8 @@ router.get('/code/:wallet', cacheMiddleware({ ttl: REFERRAL_CACHE_TTL_MS }), asy const { wallet } = req.params; if (!wallet) { - return res.status(400).json(buildReferralWalletRequiredBody()); + sendApiError(req, res, { status: 400, code: 'BAD_REQUEST', message: WALLET_REQUIRED_MESSAGE }); + return; } const normalizedWallet = normalizeWalletAddress(wallet); @@ -125,7 +129,8 @@ router.get('/code/:wallet', cacheMiddleware({ ttl: REFERRAL_CACHE_TTL_MS }), asy error: error instanceof Error ? error.message : String(error), wallet: normalizedWallet, }); - return res.status(500).json(buildReferralCodeErrorBody()); + sendApiError(req, res, { status: 500, code: 'INTERNAL_SERVER_ERROR', message: REFERRAL_CODE_ERROR_MESSAGE }); + return; } }); diff --git a/backend/src/routes/vaults.ts b/backend/src/routes/vaults.ts index de9854efa..7e49815f7 100644 --- a/backend/src/routes/vaults.ts +++ b/backend/src/routes/vaults.ts @@ -22,6 +22,7 @@ */ import { Router, Request, Response } from 'express'; +import { sendApiError } from '../middleware/apiError'; import { readsLimiter } from '../rateLimiter'; import { createTimeoutFor } from '../middleware/timeoutMiddleware'; import { @@ -142,8 +143,7 @@ router.get( logger.log('error', 'Failed to list vaults', { error: error instanceof Error ? error.message : String(error), }); - res.status(500).json({ - error: 'Internal Server Error', + sendApiError(req, res, { status: 500, code: 'VAULTS_LIST_FAILED', message: 'Failed to fetch vaults', @@ -152,79 +152,5 @@ router.get( }); }, ); -import { Router, Request, Response, NextFunction } from 'express'; -import type { Prisma } from '@prisma/client'; -import { getPrismaClient } from '../prismaClient'; -import { validateApiKey } from '../middleware/apiKeyAuth'; -import { - createPaginatedResponse, - createPaginationEnvelope, - parsePaginationQuery, -} from '../pagination'; -import { buildVaultFilterHash, vaultCountCache } from '../vaultCountCache'; - -const router = Router(); - -/** GET /vaults: fresh page rows with a five-second cached, tenant-scoped total. */ -router.get('/', validateApiKey, async (req: Request, res: Response, next: NextFunction) => { - try { - const rawTenant = req.query.tenantId; - if (rawTenant !== undefined && (typeof rawTenant !== 'string' || !rawTenant.trim())) { - res.status(400).json({ error: 'tenantId must be a non-empty string' }); - return; - } - const isAdmin = ['admin', 'super-admin'].includes(req.authApiKeyRole || ''); - const tenantId = isAdmin ? (rawTenant as string | undefined) : req.authApiKeyTenantId; - if ( - !isAdmin && - (!tenantId || tenantId === 'unknown' || (rawTenant && rawTenant !== tenantId)) - ) { - res.status(403).json({ error: 'Vault access requires a matching authenticated tenant' }); - return; - } - - const where: Prisma.VaultWhereInput = { deletedAt: null, ...(tenantId ? { tenantId } : {}) }; - const query = parsePaginationQuery(req, { defaultSortBy: 'createdAt' }); - const limit = query.limit || 20; - const page = query.page || 1; - const sortBy = query.sortBy || 'createdAt'; - if (!['createdAt', 'updatedAt', 'id', 'aum'].includes(sortBy) || query.cursor) { - res - .status(400) - .json({ error: 'Use page pagination and sortBy createdAt, updatedAt, id or aum' }); - return; - } - const skip = (page - 1) * limit; - if (!Number.isSafeInteger(skip) || skip > 2147483647) { - res.status(400).json({ error: 'Page offset is too large' }); - return; - } - const prisma = getPrismaClient(); - const orderBy: Prisma.VaultOrderByWithRelationInput[] = [{ [sortBy]: query.sortOrder }]; - if (sortBy !== 'id') orderBy.push({ id: query.sortOrder }); - const [data, count] = await Promise.all([ - prisma.vault.findMany({ where, skip, take: limit, orderBy }), - vaultCountCache.get(buildVaultFilterHash(where), () => prisma.vault.count({ where })), - ]); - res.setHeader('X-Cache', count.status); - res.setHeader('Cache-Control', 'no-store'); - res.json( - createPaginatedResponse( - data, - createPaginationEnvelope({ - count: data.length, - limit, - total: count.total, - currentPage: page, - totalPages: Math.max(1, Math.ceil(count.total / limit)), - hasNextPage: skip + data.length < count.total, - hasPrevPage: page > 1, - }) - ) - ); - } catch (error) { - next(error); - } -}); export default router; diff --git a/backend/src/services/apiKeyService.ts b/backend/src/services/apiKeyService.ts index dfa77badf..b02cecd7a 100644 --- a/backend/src/services/apiKeyService.ts +++ b/backend/src/services/apiKeyService.ts @@ -47,15 +47,15 @@ function toApiKeyRecord(r: { id: string; tenantId: string; hashedKey: string; - role: string; + role: string | null; scopes: unknown; createdAt: Date; expiresAt: Date | null; lastUsedAt?: Date | null; isActive: boolean; deletedAt: Date | null; - deletedBy: string | null; - deletionReason: string | null; + deletedBy?: string | null; + deletionReason?: string | null; }): ApiKeyRecord { return { id: r.id, @@ -117,7 +117,7 @@ export async function getApiKeyByHashed(hashed: string): Promise; - pin unknown; - pin { + pin: { add(cid: string): Promise<{ cid: string }>; rm(cid: string): Promise; }; @@ -28,7 +26,7 @@ export interface MetadataRepository { cid: string, pinnedAt: Date, ): Promise; - getByVaultId(vaultId: string): Promise; + getByVaultId(vaultId: string): Promise<{ cid: string; pinnedAt: Date | null } | null>; unpin(vaultId: string): Promise; listUnpinnedOlderThan(cutoff: Date): Promise>; } @@ -62,7 +60,7 @@ export class MetadataService { `Failed to fetch metadata for vault ${vaultId}: ${response.status}`, ); } - return (json = await response.json()) as VaultMetadata; + return (await response.json()) as VaultMetadata; } async deleteVaultMetadata(vaultId: string): Promise { diff --git a/backend/src/sorobanClient.ts b/backend/src/sorobanClient.ts index 1cb913662..3f406814f 100644 --- a/backend/src/sorobanClient.ts +++ b/backend/src/sorobanClient.ts @@ -337,7 +337,7 @@ export async function submitVaultOperation( // assembleTransaction injects the resource footprint and auth entries. // .build() gives a Transaction; it must be signed before submission. - const assembled = rpc.assembleTransaction(tx, simulated).build(); + const assembled = rpc.assembleTransaction(tx, simulated).build() as any; const signature = await sourceSigner.sign(assembled.hash()); assembled.addSignature(sourceSigner.publicKey, Buffer.from(signature)); diff --git a/backend/src/swagger.ts b/backend/src/swagger.ts index b63be75cc..218273e07 100644 --- a/backend/src/swagger.ts +++ b/backend/src/swagger.ts @@ -248,62 +248,6 @@ const options: swaggerJsdoc.Options = { { name: 'Admin', description: 'Operational/admin endpoints (API key required)' }, ], paths: { - '/api/v1/vaults': { - get: { - tags: ['Vault'], - summary: 'List vaults with a cached total count', - description: - 'Send Authorization: ApiKey . Non-admin keys are restricted to their tenant; ' + - 'admin keys may filter tenantId or list all tenants. Excludes soft-deleted vaults. ' + - 'Rows are fresh; totals use a process-local 500-entry LRU for five seconds, ' + - 'invalidated on successful Prisma vault writes. Also available at /api/vaults and /vaults. ' + - RATE_LIMIT_NOTE, - security: [{ apiKeyAuth: [] }], - parameters: [ - { name: 'tenantId', in: 'query', schema: { type: 'string', minLength: 1 } }, - { name: 'limit', in: 'query', schema: { type: 'integer', minimum: 1, maximum: 100, default: 20 } }, - { name: 'page', in: 'query', schema: { type: 'integer', minimum: 1, default: 1 } }, - { name: 'sortBy', in: 'query', schema: { type: 'string', enum: ['createdAt', 'updatedAt', 'id', 'aum'], default: 'createdAt' } }, - { name: 'sortOrder', in: 'query', schema: { type: 'string', enum: ['asc', 'desc'], default: 'desc' } }, - ], - responses: { - '200': { - description: 'Paginated vault records (offset pagination only)', - headers: { - 'X-Cache': { description: 'Whether the total count was reused', schema: { type: 'string', enum: ['HIT', 'MISS'] } }, - }, - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - data: { - type: 'array', - items: { - type: 'object', - properties: { - id: { type: 'string' }, tenantId: { type: 'string' }, - aum: { type: 'number' }, tvlUsd: { type: ['string', 'null'] }, - createdAt: { type: 'string', format: 'date-time' }, - updatedAt: { type: 'string', format: 'date-time' }, - deletedAt: { type: 'null' }, - }, - }, - }, - pagination: { $ref: '#/components/schemas/PaginationMeta' }, - timestamp: { type: 'string', format: 'date-time' }, - }, - }, - }, - }, - }, - '400': { description: 'Invalid query or unsupported cursor pagination' }, - '401': { description: 'Missing or invalid API key' }, - '403': { description: 'Missing or mismatched authenticated tenant' }, - '500': { description: 'Database query failed' }, - }, - }, - }, '/health': { get: { tags: ['System'], diff --git a/backend/src/tracing.ts b/backend/src/tracing.ts index 0f3f7db24..3afe57d80 100644 --- a/backend/src/tracing.ts +++ b/backend/src/tracing.ts @@ -84,7 +84,6 @@ export function initTracing(): void { sdk = new NodeSDK({ resource: buildResource({ - resource: new Resource({ [ATTR_SERVICE_NAME]: SERVICE_NAME, [ATTR_SERVICE_VERSION]: process.env.npm_package_version || '1.0.0', }), diff --git a/backend/src/transactionEndpoints.ts b/backend/src/transactionEndpoints.ts index 42d690936..d148c9456 100644 --- a/backend/src/transactionEndpoints.ts +++ b/backend/src/transactionEndpoints.ts @@ -25,6 +25,7 @@ import { tenantGuard } from './middleware/tenantGuard'; import { Permission } from './middleware/rbac'; import { createTimeoutFor } from './middleware/timeoutMiddleware'; import { validate, TransactionListQuerySchema } from './middleware/validate'; +import { sendApiError } from './middleware/apiError'; const router = Router(); const CACHE_TTL_MS = parseInt(process.env.CACHE_LIST_ENDPOINTS_TTL_MS || '30000', 10); @@ -73,7 +74,7 @@ router.get('/', // Validate type filter if provided const { error: typeError } = parseTypeFilter(typeof type === 'string' ? type : undefined); if (typeError) { - res.status(400).json({ error: 'Bad Request', status: 400, message: typeError }); + sendApiError(req, res, { status: 400, code: 'INVALID_TYPE_FILTER', message: typeError }); return; } @@ -82,7 +83,7 @@ router.get('/', typeof status === 'string' ? status : undefined, ); if (statusError) { - res.status(400).json({ error: 'Bad Request', status: 400, message: statusError }); + sendApiError(req, res, { status: 400, code: 'INVALID_STATUS_FILTER', message: statusError }); return; } @@ -97,9 +98,9 @@ router.get('/', res.status(200).json(response); } catch (err) { if (err instanceof DateRangeParseError) { - res.status(400).json({ - error: 'Bad Request', + sendApiError(req, res, { status: 400, + code: 'INVALID_DATE_RANGE', message: err.message, }); return; @@ -115,7 +116,7 @@ router.get('/', typeof type === 'string' ? type : undefined, ); if (typeError) { - res.status(400).json({ error: 'Bad Request', status: 400, message: typeError }); + sendApiError(req, res, { status: 400, code: 'INVALID_TYPE_FILTER', message: typeError }); return; } @@ -124,7 +125,7 @@ router.get('/', typeof status === 'string' ? status : undefined, ); if (statusError) { - res.status(400).json({ error: 'Bad Request', status: 400, message: statusError }); + sendApiError(req, res, { status: 400, code: 'INVALID_STATUS_FILTER', message: statusError }); return; } @@ -132,9 +133,9 @@ router.get('/', // Resolve and validate the requested sort field against the allowlist. const sort = resolveTransactionSort(paginationQuery.sortBy, paginationQuery.sortOrder); if (!sort.valid) { - res.status(400).json({ - error: 'Bad Request', + sendApiError(req, res, { status: 400, + code: 'INVALID_SORT_BY', message: `Invalid sortBy value '${sort.requested}'. Allowed values: timestamp, type, status`, }); return; @@ -148,9 +149,9 @@ router.get('/', } } catch (err) { const errorMsg = err instanceof Error ? err.message : 'Invalid date range'; - res.status(400).json({ - error: 'Bad Request', + sendApiError(req, res, { status: 400, + code: 'INVALID_DATE_RANGE', message: errorMsg, }); return; @@ -210,9 +211,9 @@ router.get('/', }); if (!cursorTx) { - res.status(400).json({ - error: 'Bad Request', + sendApiError(req, res, { status: 400, + code: 'INVALID_CURSOR', message: 'Invalid cursor value', }); return; @@ -230,9 +231,9 @@ router.get('/', error: err instanceof Error ? err.message : String(err), traceId, }); - res.status(400).json({ - error: 'Bad Request', + sendApiError(req, res, { status: 400, + code: 'INVALID_CURSOR', message: 'Invalid cursor value', }); return; @@ -282,9 +283,9 @@ router.get('/', traceId, }); - res.status(500).json({ - error: 'Internal Server Error', + sendApiError(req, res, { status: 500, + code: 'TRANSACTION_HISTORY_FAILED', message: 'Failed to retrieve transaction history', }); } diff --git a/backend/src/types/validation.ts b/backend/src/types/validation.ts index afdfc311b..24370b7c0 100644 --- a/backend/src/types/validation.ts +++ b/backend/src/types/validation.ts @@ -34,7 +34,6 @@ export const PaginationQuerySchema = z .object({ limit: z.string().optional(), cursor: z.string().optional(), - page: z.string().optional(), // Signed so an out-of-range page (`page=-1`, `page=1000000`) reaches // `parsePaginationQuery`, which clamps it into `1..maxPage`, instead of // being rejected outright (Issue #1430). @@ -282,6 +281,8 @@ export const VaultStrategyBodySchema = z previousStrategyId: z.string().optional(), walletAddress: z.string().optional(), weights: _weightsRawSchema.optional(), + nonce: z.string().optional(), + signature: z.string().optional(), }) .superRefine((val, ctx) => { if (val.weights === undefined) return; diff --git a/backend/src/vaultEndpoints.ts b/backend/src/vaultEndpoints.ts index 9a25673d6..5f3106b17 100644 --- a/backend/src/vaultEndpoints.ts +++ b/backend/src/vaultEndpoints.ts @@ -28,6 +28,11 @@ import { import { withdrawalDailyLimitMiddleware } from './middleware/withdrawalDailyLimit'; import { requireSignedWalletAction } from './middleware/walletSignedAction'; import { createTimeoutFor } from './middleware/timeoutMiddleware'; +import { sendApiError } from './middleware/apiError'; +import { authenticateApiKeyValue, hashApiKey } from './middleware/apiKeyAuth'; +import { walletNonceService } from './walletNonce'; +import { verifyWalletActionSignature } from './walletSignature'; +import { redisCacheClient } from './redisCache'; import crypto from 'crypto'; // crypto is still used below for generateFingerprint and body.id generation. import { tryAcquireWalletLock } from './walletLock'; @@ -41,6 +46,7 @@ const EXPLORER_BASE_URL = process.env.STELLAR_EXPLORER_URL || 'https://stellar.expert/explorer/testnet/tx'; const router = Router(); +const prisma = getPrismaClient(); const ZERO = new Decimal(0); const DEFAULT_SHARE_PRICE = new Decimal(1); const STRATEGY_CACHE_TTL_MS = parseInt(process.env.CACHE_STRATEGY_TTL_MS || '30000', 10); @@ -251,7 +257,7 @@ async function handleVaultOperation( req: Request, res: Response, type: 'deposit' | 'withdrawal' -): Promise { +): Promise { // Task 3: read Idempotency-Key header (spec-compliant name) const idempotencyKey = (req.headers['idempotency-key'] as string | undefined) || @@ -268,13 +274,13 @@ async function handleVaultOperation( const withdrawalId = idempotencyKey || `wd-${crypto.randomBytes(12).toString('hex')}`; if (!walletLock.acquired) { - return res.status(409).json({ - error: 'Conflict', + sendApiError(req, res, { status: 409, code: 'WALLET_OPERATION_IN_PROGRESS', message: 'Another operation is already in progress for this wallet', - walletAddress: normalizedWallet, + details: { walletAddress: normalizedWallet }, }); + return; } // Audit: the vault operation has been accepted and is about to be attempted. @@ -615,42 +621,44 @@ async function handleVaultOperation( }); if (err instanceof IdempotencyConflictError) { - return res.status(409).json({ - error: 'Conflict', + sendApiError(req, res, { status: 409, + code: 'IDEMPOTENCY_CONFLICT', message: err.message, }); + return; } if (err instanceof CircuitOpenError) { const retryAfterSec = Math.ceil(err.retryAfterMs / 1000); - res.setHeader('Retry-After', String(retryAfterSec)); - return res.status(503).json({ - error: 'Service Unavailable', + sendApiError(req, res, { status: 503, + code: 'CIRCUIT_OPEN', message: 'Soroban RPC is temporarily unavailable. Please retry later.', - retryAfterMs: err.retryAfterMs, + retryAfterSeconds: retryAfterSec, }); + return; } if (err instanceof SorobanSimulationError) { - return res.status(err.statusCode).json({ - error: err.statusCode === 422 ? 'Unprocessable Entity' : 'Bad Gateway', + sendApiError(req, res, { status: err.statusCode, code: err.code, message: err.message, }); + return; } logger.log('error', `${type} operation failed`, { error: err instanceof Error ? err.message : String(err), traceId: getCurrentTraceId(), }); - return res.status(500).json({ - error: 'Internal Server Error', + sendApiError(req, res, { status: 500, + code: 'VAULT_OPERATION_FAILED', message: `Failed to process ${type}`, }); + return; } finally { walletLock.release(); } @@ -724,9 +732,136 @@ router.get( } ); -router.get('/strategy/cooldown', cacheMiddleware({ ttl: 5000 }), (_req: Request, res: Response) => { +async function getLastStrategySwitchTime(): Promise { + if (redisCacheClient.isReady) { + try { + const cached = await redisCacheClient.get('vault:strategy:last_switch'); + if (cached && typeof cached === 'string') return cached; + } catch {} + } + try { + const record = await prisma.adminConfigChange.findFirst({ + where: { configType: 'vault.strategy', action: 'switch' }, + orderBy: { createdAt: 'desc' }, + select: { createdAt: true }, + }); + if (record) { + return record.createdAt.toISOString(); + } + } catch {} + return null; +} + +async function recordStrategySwitchTime( + actor: string, + strategyId: string, + previousStrategyId?: string, +): Promise { + const now = new Date(); + const nowIso = now.toISOString(); + if (redisCacheClient.isReady) { + try { + await redisCacheClient.set('vault:strategy:last_switch', nowIso as any, 24 * 60 * 60 * 1000); + } catch {} + } + try { + await prisma.adminConfigChange.create({ + data: { + configType: 'vault.strategy', + action: 'switch', + actor: actor || 'unknown', + preChangeSnapshot: previousStrategyId || '', + postChangeSnapshot: strategyId, + metadata: JSON.stringify({ strategyId, previousStrategyId, timestamp: nowIso }), + createdAt: now, + }, + }); + } catch (err) { + logger.log('error', 'Failed to record strategy switch config change', { + error: err instanceof Error ? err.message : String(err), + }); + } + return nowIso; +} + +async function requireStrategyAuth( + req: Request, + res: Response, + next: NextFunction, +): Promise { + const authHeader = req.get('Authorization') || req.get('authorization') || ''; + const apiKeyHeader = req.get('x-api-key'); + let providedKey = apiKeyHeader; + const match = authHeader.match(/^ApiKey\s+(.+)$/i) || authHeader.match(/^Bearer\s+(.+)$/i); + if (match) { + providedKey = match[1]; + } + + if (providedKey) { + const inMem = authenticateApiKeyValue(providedKey); + if (inMem && (inMem.role === 'admin' || inMem.role === 'super-admin')) { + req.authApiKeyRole = inMem.role; + return next(); + } + try { + const hashed = hashApiKey(providedKey); + const dbKey = await prisma.apiKey.findUnique({ + where: { hashedKey: hashed }, + }); + if ( + dbKey && + dbKey.isActive && + !dbKey.deletedAt && + (dbKey.role === 'admin' || dbKey.role === 'super-admin') + ) { + req.authApiKeyRole = dbKey.role as any; + return next(); + } + } catch {} + sendApiError(req, res, { + status: 401, + code: 'AUTH_REQUIRED', + message: 'Invalid admin API key for strategy switch', + }); + return; + } + + const body = req.body || {}; + const { walletAddress, nonce, signature } = body; + if (walletAddress && nonce && signature) { + try { + const meta = await walletNonceService.validateForUse(walletAddress, 'strategy', nonce); + verifyWalletActionSignature({ + walletAddress, + action: 'strategy', + nonce: meta.nonce, + issuedAt: meta.issuedAt, + expiresAt: meta.expiresAt, + signature, + }); + await walletNonceService.consume(walletAddress, 'strategy', nonce); + req.body.walletAddress = normalizeWalletAddress(walletAddress); + return next(); + } catch (err) { + sendApiError(req, res, { + status: 401, + code: 'AUTH_REQUIRED', + message: err instanceof Error ? err.message : 'Invalid wallet signature or nonce', + }); + return; + } + } + + sendApiError(req, res, { + status: 401, + code: 'AUTH_REQUIRED', + message: 'Strategy switch requires a signed wallet action or admin API key', + }); +} + +router.get('/strategy/cooldown', cacheMiddleware({ ttl: 5000 }), async (_req: Request, res: Response) => { const cooldownSec = parseInt(process.env.STRATEGY_SWITCH_COOLDOWN_SEC || '0', 10); - const lastSwitchIso = process.env.LAST_STRATEGY_SWITCH_TIME || null; + const lastSwitchIso = await getLastStrategySwitchTime(); const now = Date.now(); const lastSwitchMs = lastSwitchIso ? new Date(lastSwitchIso).getTime() : 0; const elapsed = lastSwitchMs > 0 ? Math.floor((now - lastSwitchMs) / 1000) : cooldownSec; @@ -745,10 +880,11 @@ router.post( '/strategy', depositsLimiter, requireFlag('strategy-selection'), + requireStrategyAuth, validate({ body: VaultStrategyBodySchema }), - (req: Request, res: Response) => { + async (req: Request, res: Response) => { const cooldownSec = parseInt(process.env.STRATEGY_SWITCH_COOLDOWN_SEC || '0', 10); - const lastSwitchIso = process.env.LAST_STRATEGY_SWITCH_TIME || null; + const lastSwitchIso = await getLastStrategySwitchTime(); const now = Date.now(); const lastSwitchMs = lastSwitchIso ? new Date(lastSwitchIso).getTime() : 0; @@ -757,43 +893,26 @@ router.post( if (elapsed < cooldownSec) { const retryAfter = cooldownSec - elapsed; res.setHeader('Retry-After', String(retryAfter)); - return res.status(429).json({ - error: 'Too Many Requests', + sendApiError(req, res, { status: 429, code: 'STRATEGY_COOLDOWN_ACTIVE', message: `Strategy switch cooldown active. Retry in ${retryAfter}s.`, - cooldownRemaining: retryAfter, - cooldownTotal: cooldownSec, + details: { + cooldownRemaining: retryAfter, + cooldownTotal: cooldownSec, + }, }); + return; } -router.post('/strategy', depositsLimiter, requireFlag('strategy-selection'), validate({ body: VaultStrategyBodySchema }), (req: Request, res: Response) => { - const cooldownSec = parseInt(process.env.STRATEGY_SWITCH_COOLDOWN_SEC || '0', 10); - const lastSwitchIso = process.env.LAST_STRATEGY_SWITCH_TIME || null; - const now = Date.now(); - const lastSwitchMs = lastSwitchIso ? new Date(lastSwitchIso).getTime() : 0; - - if (cooldownSec > 0 && lastSwitchMs > 0) { - const elapsed = Math.floor((now - lastSwitchMs) / 1000); - if (elapsed < cooldownSec) { - const retryAfter = cooldownSec - elapsed; - res.setHeader('Retry-After', String(retryAfter)); - res.status(429).json({ - error: 'Too Many Requests', - status: 429, - code: 'STRATEGY_COOLDOWN_ACTIVE', - message: `Strategy switch cooldown active. Retry in ${retryAfter}s.`, - cooldownRemaining: retryAfter, - cooldownTotal: cooldownSec, - }); - return; } const strategyId = typeof req.body?.strategyId === 'string' ? req.body.strategyId : 'default'; const previousStrategyId = typeof req.body?.previousStrategyId === 'string' ? req.body.previousStrategyId : undefined; - // Record switch time for cooldown tracking - process.env.LAST_STRATEGY_SWITCH_TIME = new Date().toISOString(); + const actingWallet = String(req.body?.walletAddress ?? req.get('x-wallet-address') ?? 'admin'); + + await recordStrategySwitchTime(actingWallet, strategyId, previousStrategyId); void eventOutboxService .writeEvent({ @@ -802,9 +921,7 @@ router.post('/strategy', depositsLimiter, requireFlag('strategy-selection'), val transactionId: `strategy-${crypto.randomBytes(4).toString('hex')}`, amount: '0', asset: 'RWA', - walletAddress: String( - req.body?.walletAddress ?? req.get('x-wallet-address') ?? 'unknown' - ), + walletAddress: actingWallet, transactionHash: 'strategy-change', status: 'accepted', timestamp: new Date().toISOString(), @@ -825,8 +942,6 @@ router.post('/strategy', depositsLimiter, requireFlag('strategy-selection'), val res.status(200).json({ message: 'Strategy selection endpoint (v2 preview)' }); } ); - return res.status(200).json({ message: 'Strategy selection endpoint (v2 preview)' }); -}); /** * POST /api/v1/vault/gasless-deposits @@ -851,21 +966,22 @@ router.post( const relayerAddress = req.get('x-relayer-address') || req.get('x-wallet-address'); if (!relayerAddress) { - return res.status(400).json({ - error: 'Bad Request', + sendApiError(req, res, { status: 400, + code: 'RELAYER_ADDRESS_REQUIRED', message: 'Relayer address is required (x-relayer-address header)', }); + return; } const walletLock = tryAcquireWalletLock(normalizedWallet); if (!walletLock.acquired) { - return res.status(409).json({ - error: 'Conflict', + sendApiError(req, res, { status: 409, code: 'WALLET_OPERATION_IN_PROGRESS', message: 'Another operation is already in progress for this wallet', }); + return; } try { @@ -935,11 +1051,12 @@ router.post( errorMessage: err instanceof Error ? err.message : String(err), }); - return res.status(500).json({ - error: 'Internal Server Error', + sendApiError(req, res, { status: 500, + code: 'GASLESS_DEPOSIT_ERROR', message: 'Failed to process gasless deposit', }); + return; } finally { walletLock.release(); } @@ -956,7 +1073,6 @@ router.get('/receipts', readsLimiter, async (req: Request, res: Response) => { req.query.limit === undefined ? undefined : parseInt(req.query.limit as string, 10), RECEIPTS_PAGINATION_CONFIG ); - const limit = Math.min(parseInt((req.query.limit as string) || '50', 10), 100); const cursor = req.query.cursor as string | undefined; const where = wallet ? { user: wallet } : {}; diff --git a/backend/src/walletAliasEndpoints.ts b/backend/src/walletAliasEndpoints.ts index 4d385a0f5..7b892653c 100644 --- a/backend/src/walletAliasEndpoints.ts +++ b/backend/src/walletAliasEndpoints.ts @@ -2,6 +2,7 @@ import { Router, Request, Response } from 'express'; import { walletAliasMappingService } from './walletAliasService'; import { validate, WalletAliasLinkSchema, WalletAliasResolveQuerySchema } from './middleware/validate'; import { readsLimiter } from './rateLimiter'; +import { sendApiError } from './middleware/apiError'; const router = Router(); @@ -40,9 +41,9 @@ router.post( canonicalWallet, }); } catch (err) { - res.status(400).json({ - error: 'Bad Request', + sendApiError(req, res, { status: 400, + code: 'WALLET_ALIAS_LINK_FAILED', message: err instanceof Error ? err.message : 'Failed to link wallet aliases', }); } @@ -62,9 +63,9 @@ router.get( const mapping = await walletAliasMappingService.resolveAlias(alias, source); if (!mapping) { - res.status(404).json({ - error: 'Not Found', + sendApiError(req, res, { status: 404, + code: 'WALLET_ALIAS_NOT_FOUND', message: 'No identity linkage found for the provided alias', }); return; @@ -87,9 +88,9 @@ router.get('/:canonicalId', readsLimiter, async (req: Request, res: Response) => const mapping = await walletAliasMappingService.getIdentityLinks(req.params.canonicalId); if (!mapping) { - res.status(404).json({ - error: 'Not Found', + sendApiError(req, res, { status: 404, + code: 'CANONICAL_IDENTITY_NOT_FOUND', message: 'Canonical identity not found', }); return; diff --git a/backend/src/walletNonce.ts b/backend/src/walletNonce.ts index c0fa579ea..d0f88c3cd 100644 --- a/backend/src/walletNonce.ts +++ b/backend/src/walletNonce.ts @@ -15,7 +15,7 @@ import { normalizeWalletAddress } from './walletUtils'; // ─── Config ─────────────────────────────────────────────────────────────────── -export type WalletAction = 'login' | 'deposit' | 'withdrawal'; +export type WalletAction = 'login' | 'deposit' | 'withdrawal' | 'strategy'; const DEFAULT_NONCE_TTL_SECONDS = parseInt(process.env.WALLET_NONCE_TTL_SECONDS || '300', 10); const MAX_ACTIVE_NONCES_PER_WALLET = parseInt( diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 742afbd15..0f5d32087 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -24,7 +24,11 @@ "noUnusedLocals": false, "noUnusedParameters": false, "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@yieldvault/api-schemas": ["../packages/api-schemas/dist/index.d.ts"] + } }, "include": ["src"], "exclude": ["node_modules", "dist", "src/__tests__", "src/integration-test.ts", "src/alert-integration-test.ts"]