diff --git a/.github/workflows/backend-governance.yml b/.github/workflows/backend-governance.yml index 5246172d5..73cbdd89c 100644 --- a/.github/workflows/backend-governance.yml +++ b/.github/workflows/backend-governance.yml @@ -85,3 +85,6 @@ jobs: - name: Check for database schema drift run: npm run db:check-drift + + - name: Check dev (SQLite) vs prod (PostgreSQL) column type mapping + run: npm run db:check-types diff --git a/backend/docs/DB_TYPE_MAPPING.md b/backend/docs/DB_TYPE_MAPPING.md new file mode 100644 index 000000000..279ef0db0 --- /dev/null +++ b/backend/docs/DB_TYPE_MAPPING.md @@ -0,0 +1,36 @@ +# Dev / prod column type mapping + +Development and tests run Prisma against SQLite (`prisma/schema.prisma`, +`file:./dev.db`). Production storage is PostgreSQL, created by the raw SQL +migrations in `migrations/*.sql`. SQLite has no exact decimal, JSON or +timezone-aware timestamp type, so those columns use different types on each +side: + +| PostgreSQL (prod) | Prisma / SQLite (dev) | Encoding rule | +|------------------------------|-----------------------|---------------| +| `NUMERIC(38, 18)` | `String` | Plain decimal string, at most 20 integer and 18 fractional digits. Parse and do arithmetic with `NumericDecimal` and write with `toNumeric38_18` (`src/numericColumns.ts`); plain `Decimal` keeps only 20 significant digits and rounds silently. | +| `NUMERIC(12, 6)` | `String` | Plain decimal string. | +| `JSONB` | `String` | `JSON.stringify` on write, `JSON.parse` on read. | +| `TIMESTAMPTZ`, `DATE` | `DateTime` | Always UTC. | + +The column-by-column mapping lives in `prisma/postgres-type-mapping.json`. +Every PostgreSQL `NUMERIC` / `JSONB` / `TIMESTAMPTZ` / `DATE` column must be +listed there, either with the Prisma field that mirrors it or `null` when it +has no dev counterpart. + +## Drift check + +```bash +npm run db:check-types +``` + +`scripts/check-type-mapping.js` parses the schema, the migrations and the +mapping (no database needed) and fails when: + +- a new `NUMERIC` / `JSONB` / `TIMESTAMPTZ` / `DATE` column is not in the mapping, +- a mapped column or Prisma field no longer exists, +- a mapped Prisma field's type differs from the mapping for its PostgreSQL type + (for example an amount changed to `Float`). + +It runs in the Backend Governance workflow next to `db:check-drift`, which +checks the applied PostgreSQL migrations themselves. diff --git a/backend/package.json b/backend/package.json index 1ba14f021..fd10d099d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -31,6 +31,7 @@ "snapshots:check": "tsx scripts/check-schema-snapshots.ts", "db:migrate": "node scripts/migrate-postgres.js", "db:check-drift": "node scripts/check-postgres-drift.js", + "db:check-types": "node scripts/check-type-mapping.js", "adrs:check": "node scripts/check-adrs.js", "prisma:generate": "prisma generate --schema prisma/schema.prisma", "prisma:schema-check": "prisma validate --schema prisma/schema.prisma && prisma migrate diff --from-migrations prisma/migrations --to-schema-datamodel prisma/schema.prisma --exit-code", diff --git a/backend/prisma/postgres-type-mapping.json b/backend/prisma/postgres-type-mapping.json new file mode 100644 index 000000000..ab5ebb12e --- /dev/null +++ b/backend/prisma/postgres-type-mapping.json @@ -0,0 +1,29 @@ +{ + "$comment": "Dev (Prisma/SQLite) <-> prod (migrations/*.sql, PostgreSQL) type mapping. Checked by scripts/check-type-mapping.js; see docs/DB_TYPE_MAPPING.md.", + "typeMap": { + "NUMERIC(38,18)": "String", + "NUMERIC(12,6)": "String", + "JSONB": "String", + "TIMESTAMPTZ": "DateTime", + "DATE": "DateTime" + }, + "columns": { + "transactions.amount": "Transaction.amount", + "transactions.metadata": null, + "transactions.created_at": "Transaction.timestamp", + "transactions.updated_at": null, + "idempotency_keys.response_body": "IdempotencyKey.responseBody", + "idempotency_keys.locked_until": null, + "idempotency_keys.expires_at": "IdempotencyKey.expiresAt", + "idempotency_keys.created_at": "IdempotencyKey.createdAt", + "idempotency_keys.updated_at": null, + "vault_metrics_snapshots.total_assets": "SharePriceSnapshot.totalAssets", + "vault_metrics_snapshots.total_shares": "SharePriceSnapshot.totalShares", + "vault_metrics_snapshots.share_price": "SharePriceSnapshot.sharePrice", + "vault_metrics_snapshots.apy": null, + "vault_metrics_snapshots.recorded_at": "SharePriceSnapshot.recordedAt", + "apy_snapshots.date": null, + "apy_snapshots.apy": null, + "apy_snapshots.created_at": null + } +} diff --git a/backend/scripts/check-type-mapping.js b/backend/scripts/check-type-mapping.js new file mode 100644 index 000000000..27652a09a --- /dev/null +++ b/backend/scripts/check-type-mapping.js @@ -0,0 +1,106 @@ +'use strict'; +/** + * Static dev/prod type drift check (issue #1546). + * + * The dev database is Prisma on SQLite (`prisma/schema.prisma`), production + * storage is PostgreSQL (`migrations/*.sql`). SQLite has no NUMERIC, JSONB or + * TIMESTAMPTZ, so those columns are stored as Prisma `String` / `DateTime`. + * `prisma/postgres-type-mapping.json` records that mapping; this check fails + * when either side changes without the mapping being updated: + * + * - a NUMERIC / JSONB / TIMESTAMPTZ / DATE column is not listed, + * - a listed column or Prisma field no longer exists, + * - a mapped Prisma field's type differs from the mapping for its Postgres type. + * + * Needs no database, so it runs in CI next to `db:check-drift`. + */ +const fs = require('fs'); +const path = require('path'); + +const backendRoot = path.resolve(__dirname, '..'); +const DIVERGENT_TYPE = /^(NUMERIC|JSONB|TIMESTAMPTZ|DATE)\b/; + +/** Returns `{ "table.column": "PGTYPE" }` for every CREATE TABLE column. */ +function parsePostgresColumns(sql) { + const columns = {}; + const tablePattern = /CREATE TABLE(?: IF NOT EXISTS)?\s+(\w+)\s*\(([\s\S]*?)\n\);/gi; + for (const [, table, body] of sql.matchAll(tablePattern)) { + for (const line of body.split('\n')) { + const match = line.trim().match(/^(\w+)\s+([A-Z]+(?:\s*\(\s*\d+\s*(?:,\s*\d+\s*)?\))?)/); + if (!match || /^(PRIMARY|UNIQUE|CHECK|CONSTRAINT|FOREIGN)$/i.test(match[1])) continue; + columns[`${table}.${match[1]}`] = match[2].replace(/\s+/g, '').toUpperCase(); + } + } + return columns; +} + +/** Returns `{ "Model.field": "Type" }` (optional `?` / list `[]` stripped). */ +function parsePrismaFields(schema) { + const fields = {}; + for (const [, model, body] of schema.matchAll(/^model\s+(\w+)\s*\{([\s\S]*?)^\}/gm)) { + for (const line of body.split('\n')) { + const match = line.trim().match(/^(\w+)\s+(\w+)(\?|\[\])?/); + if (match) fields[`${model}.${match[1]}`] = match[2]; + } + } + return fields; +} + +function checkTypeMapping({ sql, schema, mapping }) { + const columns = parsePostgresColumns(sql); + const fields = parsePrismaFields(schema); + const errors = []; + + for (const [column, pgType] of Object.entries(columns)) { + if (DIVERGENT_TYPE.test(pgType) && !(column in mapping.columns)) { + errors.push(`${column} (${pgType}) is not listed in postgres-type-mapping.json`); + } + } + + for (const [column, field] of Object.entries(mapping.columns)) { + const pgType = columns[column]; + if (!pgType) { + errors.push(`${column} is mapped but not defined in migrations/*.sql`); + continue; + } + if (field === null) continue; + const expected = mapping.typeMap[pgType]; + if (!expected) { + errors.push(`${column}: no typeMap entry for ${pgType}`); + } else if (!fields[field]) { + errors.push(`${column}: Prisma field ${field} does not exist`); + } else if (fields[field] !== expected) { + errors.push(`${column} (${pgType}) maps to ${field} which is ${fields[field]}, expected ${expected}`); + } + } + + return errors; +} + +function loadSources(root = backendRoot) { + const migrationsDir = path.join(root, 'migrations'); + const sql = fs + .readdirSync(migrationsDir) + .filter((file) => file.endsWith('.sql')) + .sort() + .map((file) => fs.readFileSync(path.join(migrationsDir, file), 'utf8')) + .join('\n'); + return { + sql, + schema: fs.readFileSync(path.join(root, 'prisma', 'schema.prisma'), 'utf8'), + mapping: JSON.parse( + fs.readFileSync(path.join(root, 'prisma', 'postgres-type-mapping.json'), 'utf8'), + ), + }; +} + +if (require.main === module) { + const errors = checkTypeMapping(loadSources()); + if (errors.length > 0) { + console.error(`Dev/prod type drift detected:\n- ${errors.join('\n- ')}`); + process.exit(1); + } + console.log('Prisma (SQLite) and PostgreSQL column types match postgres-type-mapping.json'); +} + +module.exports = { checkTypeMapping, loadSources, parsePostgresColumns, parsePrismaFields }; diff --git a/backend/src/__tests__/numericColumns.test.ts b/backend/src/__tests__/numericColumns.test.ts new file mode 100644 index 000000000..48b8b3068 --- /dev/null +++ b/backend/src/__tests__/numericColumns.test.ts @@ -0,0 +1,50 @@ +import Decimal from 'decimal.js'; +import { NumericDecimal, toNumeric38_18 } from '../numericColumns'; +import { + checkTypeMapping, + loadSources, +} from '../../scripts/check-type-mapping'; + +/** Largest value PostgreSQL NUMERIC(38, 18) can hold. */ +const MAX_NUMERIC = '99999999999999999999.999999999999999999'; +const SMALLEST_STEP = '0.000000000000000001'; + +describe('NUMERIC(38, 18) precision (issue #1546)', () => { + it('round-trips the full 38-digit range through the String encoding', () => { + for (const value of [MAX_NUMERIC, `-${MAX_NUMERIC}`, SMALLEST_STEP, '0', '1.5']) { + expect(toNumeric38_18(value)).toBe(value); + expect(toNumeric38_18(toNumeric38_18(value))).toBe(value); + } + }); + + it('keeps sums exact where default Decimal precision rounds', () => { + const sum = new NumericDecimal('99999999999999999999.999999999999999998').plus(SMALLEST_STEP); + expect(toNumeric38_18(sum)).toBe(MAX_NUMERIC); + + const lossy = new Decimal('99999999999999999999.999999999999999998').plus(SMALLEST_STEP); + expect(lossy.toFixed()).not.toBe(MAX_NUMERIC); + }); + + it('rejects values PostgreSQL would reject', () => { + expect(() => toNumeric38_18('100000000000000000000')).toThrow(RangeError); + expect(() => toNumeric38_18('0.0000000000000000001')).toThrow(RangeError); + expect(() => toNumeric38_18('Infinity')).toThrow(RangeError); + expect(() => toNumeric38_18('NaN')).toThrow(RangeError); + }); +}); + +describe('dev/prod type mapping check', () => { + it('passes for the committed schema, migrations and mapping', () => { + expect(checkTypeMapping(loadSources())).toEqual([]); + }); + + it('flags an unlisted NUMERIC column and a mismatched Prisma type', () => { + const sources = loadSources(); + const sql = `${sources.sql}\nCREATE TABLE IF NOT EXISTS fees (\n total NUMERIC(38, 18) NOT NULL\n);`; + const schema = sources.schema.replace(/(model Transaction \{[\s\S]*?amount\s+)String/, '$1Float'); + expect(checkTypeMapping({ ...sources, sql, schema })).toEqual([ + 'fees.total (NUMERIC(38,18)) is not listed in postgres-type-mapping.json', + 'transactions.amount (NUMERIC(38,18)) maps to Transaction.amount which is Float, expected String', + ]); + }); +}); diff --git a/backend/src/index.ts b/backend/src/index.ts index 22150059c..b8f267179 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -10,20 +10,9 @@ initTracing(); import 'express-async-errors'; import express, { Express, Request, Response, NextFunction, ErrorRequestHandler } from 'express'; -import NodeCache from 'node-cache'; -import { - loginHandler, - nonceHandler, - refreshHandler, - requireAuth, - verifyJwt, - revokeAccessToken, - revokeAllAccessTokens, - revokeCurrentSession, - revokeAllSessions, -} from './auth'; +import { loginHandler, nonceHandler, refreshHandler, requireAuth, revokeAccessToken, revokeAllAccessTokens, revokeCurrentSession, revokeAllSessions } from './auth'; import vaultListRouter from './routes/vaults'; -import { loginHandler, nonceHandler, refreshHandler, requireAuth, verifyJwt } from './auth'; +import { loginHandler, nonceHandler, refreshHandler, requireAuth } from './auth'; import { authLimiter, authIpLimiter, @@ -35,6 +24,8 @@ import { getRateLimitMonitorSnapshot, loadConfig as loadRateLimiterConfig, } from './rateLimiter'; +import { createAdminAuditMiddleware } from './auditLog'; +import { getActorFromRequest } from './adminConfigChangeAudit'; import { idempotencyStore } from './idempotency'; import { createAdminAuditMiddleware, getAuditLogs, countAuditLogs, getAuditLogMetrics } from './auditLog'; import { AuditLogQuerySchema } from './middleware/validate'; @@ -71,45 +62,16 @@ import { } from './errors'; import { corsMiddleware } from './middleware/cors'; import { geofencingMiddleware } from './middleware/geofencing'; -import { cacheMiddleware, invalidateCache, getCacheStats, registerInvalidationHook } from './middleware/cache'; +import { registerInvalidationHook } from './middleware/cache'; import { invalidateVaultCaches } from './vaultDataCache'; -import { getRedisCacheHealth, redisCacheClient } from './redisCache'; -import { validate, LoginSchema, NonceRequestSchema, RefreshSchema, WebhookRegisterSchema, WebhookUpdateSchema, ApyBackfillBodySchema, MaintenanceToggleSchema, MaintenanceWindowBodySchema, FeatureFlagOverrideSchema, CacheInvalidateSchema, EventReplayBodySchema, WithdrawalLimitOverrideSchema, AllowlistWalletBodySchema, ImpersonationSessionBodySchema, ApiKeyRegisterSchema, ApiKeyRotateSchema, ApiKeyRevokeSchema, WebhookVerifyBodySchema, BulkExportBodySchema, TransactionBackfillBodySchema, GovernanceSnapshotExportSchema, ReportExportBodySchema, ChecksumVerifyBodySchema, DeadLetterResolveSchema, DeadLetterIdsSchema, DeadLetterProcessSchema, ScopedTokenCreateSchema, PaginationQuerySchema, WebhookListQuerySchema, IdParamSchema, WindowIdParamSchema, CreateVaultBodySchema } from './middleware/validate'; +import { redisCacheClient } from './redisCache'; +import { validate, LoginSchema, NonceRequestSchema, RefreshSchema } from './middleware/validate'; import { tieredJsonBodyParser } from './middleware/payloadLimit'; import { requireSignedWalletAction } from './middleware/walletSignedAction'; -import { timeoutMiddleware, createTimeoutFor } from './middleware/timeoutMiddleware'; -import { - setWithdrawalLimitOverride, - listWithdrawalLimitAuditEntries, -} from './middleware/withdrawalDailyLimit'; +import { timeoutMiddleware } from './middleware/timeoutMiddleware'; import { adaptiveThrottleMiddleware } from './middleware/adaptiveThrottle'; -import { - validateApiKey, - authenticateApiKeyValue, - registerApiKey, - rotateApiKey, - revokeApiKey, - getApiKeyMetadata, - restoreApiKey, - hasRequiredApiKeyRole, - normalizeApiKeyRole, -} from './middleware/apiKeyAuth'; -import { - API_KEY_AUDIT_ACTIONS, - isApiKeyHash, - getApiKeyFingerprintFromHash, - getApiKeyFingerprintFromValue, - resolveApiKeyAuditActor, - recordApiKeyAuditEvent, - listApiKeyAuditEvents, -} from './apiKeyAudit'; -import { - addAddress, - removeAddress, - listAddresses, - allowlistSize, -} from './middleware/allowlist'; -import { adminRbacMiddleware, assertWebhookParameterUpdate } from './middleware/rbac'; +import { validateApiKey } from './middleware/apiKeyAuth'; +import { adminRbacMiddleware } from './middleware/rbac'; import { apiVersionMiddleware } from './middleware/versionNegotiation'; import { versionRoutingMiddleware } from './middleware/versionRouting'; import { createVersionDiscoveryRouter } from './routes/apiVersions'; @@ -120,22 +82,10 @@ import vaultsListRouter from './routes/vaults'; import walletAliasRouter from './walletAliasEndpoints'; import { walletAliasMappingService } from './walletAliasService'; import transactionRouter from './transactionEndpoints'; -import { - buildPortfolioHoldingsResponse, - buildTransactionExportArtifact, - buildTransactionsResponse, - buildVaultHistoryResponse, - TRANSACTION_PAGINATION_CONFIG, -} from './listEndpoints'; -import { createPaginatedResponse, createPaginationEnvelope, encodeCursor } from './pagination'; +import { buildTransactionsResponse } from './listEndpoints'; import listRouter from './listEndpoints'; -import referralRouter, { - buildReferralCodeErrorBody, - buildReferralStatsErrorBody, - buildReferralStatsNotFoundBody, -} from './referralEndpoints'; +import referralRouter from './referralEndpoints'; import auditLogRouter from './auditLogEndpoints'; -import { referralService } from './referralService'; import { register, httpRequestCount, @@ -152,89 +102,13 @@ import { latencyMonitoringService } from './latencyMonitoring'; import { listEndpointSlaRegistry } from './endpointSlaRegistry'; import { getEventPollingHealth, startEventPollingService, stopEventPollingService } from './eventPollingService'; import { eventOutboxService } from './eventOutbox'; -import { prisma, getPrismaRuntimeConfig } from './prisma'; -import { getPrismaClient } from './prismaClient'; -import { computeVaultApy } from './services/apy'; -import { - verifyWebhookEndpoint, - registerWebhookEndpoint, - updateWebhookEndpoint, - deleteWebhookEndpoint, - restoreWebhookEndpoint, - listWebhookEndpoints, - listWebhookDeliveryPage, - getWebhookDeliveryMetrics, - createWebhookSignature, - verifyWebhookSignature, - listWebhookDeadLetters, - retryWebhookDeadLetter, - initializeWebhookEndpoints, - WEBHOOK_SCHEMA_VERSION, -} from './webhookDelivery'; -import { webhookDeduplicationStore } from './webhookDeduplication'; +import { prisma } from './prisma'; +import { initializeWebhookEndpoints, WEBHOOK_SCHEMA_VERSION } from './webhookDelivery'; import { healthProbeService } from './healthProbe'; -import { writeAheadAuditLog } from './writeAheadAuditLog'; -import { - withdrawalRecoveryCoordinator, - type WithdrawalSagaStatus, -} from './withdrawalRecovery'; -import { - maintenanceModeMiddleware, - getMaintenanceModeState, - updateMaintenanceModeState, - logMaintenanceTransition, -} from './maintenanceMode'; -import { - buildMaintenanceStatusPayload, - cancelMaintenanceWindow, - listMaintenanceWindows, - scheduleMaintenanceWindow, - startMaintenanceWindowScheduler, -} from './maintenanceWindow'; -import { - buildExportMetadataHeaderValue, - getExportJobById, - listExportJobs, - recordExportJob, - resolveExportGeneratedBy, -} from './exportJobs'; -import { parseUtcDateRange, DateRangeParseError } from './dateRange'; -import { backfillApySnapshots } from './apySnapshot'; -import { - getJobMetrics, - getJobHealthStatus, - listDeadLetters, - getDeadLetterRecord, - retryDeadLetter, - resolveDeadLetter, - discardDeadLetter, - bulkRetryDeadLetters, - bulkDiscardDeadLetters, - processDeadLetterQueue, - type JobName, - type DeadLetterStatus, -} from './jobGovernance'; -import { - createBulkExportJob, - getBulkExportJob, - listBulkExportJobs, - cancelBulkExportJob, - processBulkExportJob, - getBulkExportArtifact, -} from './bulkExportJobs'; -import { normalizeWalletAddress } from './walletUtils'; -import { emailQueueService } from './emailQueue'; -import { - createOrResumeTransactionBackfill, - getTransactionBackfillJob, - listTransactionBackfillJobs, -} from './transactionBackfill'; -import { - createExportManifest, - getExportManifestById, - listExportManifests, - verifyExportManifestChecksum, -} from './exportManifest'; +import { withdrawalRecoveryCoordinator } from './withdrawalRecovery'; +import { maintenanceModeMiddleware } from './maintenanceMode'; +import { buildMaintenanceStatusPayload, startMaintenanceWindowScheduler } from './maintenanceWindow'; +import { getJobHealthStatus } from './jobGovernance'; import { reconciliationReportHandler, automatedReconciliationSummaryHandler, @@ -242,16 +116,13 @@ import { import { diagnosticsBundleHandler } from './diagnosticsBundle'; import { errorBoundaryMiddleware } from './middleware/errorBoundary'; import { apiErrorContractMiddleware, sendApiError } from './middleware/apiError'; -import { - exportGovernanceSnapshots, - listGovernanceSnapshots, -} from './governanceSnapshotExport'; -import { - getIdempotencyRetentionMetrics, - pruneStaleIdempotencyRecords, - startIdempotencyRetentionScheduler, -} from './idempotencyRetention'; -import { scopedAdminTokenStore } from './scopedAdminTokens'; +import { startIdempotencyRetentionScheduler } from './idempotencyRetention'; +import vaultRoutes from './routes/vault'; +import transactionRoutes from './routes/transactions'; +import webhookRoutes, { apiV1WebhookRoutes } from './routes/webhooks'; +import exportRoutes from './routes/exports'; +import adminRoutes from './routes/admin'; +import { cache } from './routes/shared'; declare global { namespace Express { @@ -270,7 +141,6 @@ const port = process.env.PORT || 3000; const nodeEnv = process.env.NODE_ENV || 'development'; const logLevel = (process.env.LOG_LEVEL || (nodeEnv === 'development' ? 'debug' : 'info')) as LogLevel; const drainTimeout = parseInt(process.env.DRAIN_TIMEOUT_MS || '30000', 10); -const cacheVaultMetricsTtl = parseInt(process.env.CACHE_TTL_MS || process.env.CACHE_VAULT_METRICS_TTL_MS || '60000', 10); // Configure logger logger.configure(logLevel); @@ -295,412 +165,6 @@ void walletAliasMappingService.loadFromDatabase().catch((error) => { }); }); -// Health check cache to track dependency status -const cache = new NodeCache({ stdTTL: 30 }); - -type VaultHealthResponse = { - vaultId: string; - status: 'healthy' | 'degraded'; - uptimeSeconds: number; - metrics: { - totalAssets: string; - totalShares: string; - sharePrice: string; - apy: number; - }; - dependencies: Record; - cached: boolean; - timestamp: string; -}; - -/** - * Reads the vault summary from the VaultState table and the most recent - * SharePriceSnapshot for the share price. Falls back to zeroed values when - * the database is unavailable (e.g., during startup or in tests). - */ -async function buildVaultSummaryResponseFromDb(): Promise<{ - totalAssets: string; - totalShares: string; - sharePrice: string; - apy: number; - timestamp: string; -}> { - try { - const prismaClient = getPrismaClient(); - const [vaultState, latestSnapshot] = await Promise.all([ - prismaClient.vaultState.findUnique({ where: { id: 1 } }), - prismaClient.sharePriceSnapshot - .findFirst({ orderBy: { recordedAt: 'desc' } }) - .catch(() => null), - ]); - - const totalAssets = vaultState?.totalAssets ?? '0'; - const totalShares = vaultState?.totalShares ?? '0'; - const sharePrice = latestSnapshot?.sharePrice ?? '1.000000'; - - return { - totalAssets, - totalShares, - sharePrice, - apy: 0, // APY is computed by the nightly snapshot job; placeholder until first run - timestamp: new Date().toISOString(), - }; - } catch { - // Fail-open: return safe zero values so the endpoint never 500s - return { - totalAssets: '0', - totalShares: '0', - sharePrice: '1.000000', - apy: 0, - timestamp: new Date().toISOString(), - }; - } -} - -async function buildVaultHealthResponse(vaultId: string): Promise { - const cacheKey = `vault-health:${vaultId}`; - const cached = cache.get>(cacheKey); - if (cached) { - return { ...cached, cached: true }; - } - - const [summary, probes] = await Promise.all([ - buildVaultSummaryResponseFromDb(), - healthProbeService.checkAll().catch( - () => ({}) as Record, - ), - ]); - const dependencies = Object.fromEntries( - Object.entries(probes).map(([name, state]) => [name, state.status]), - ) as Record; - const degraded = Object.values(dependencies).some((state) => state !== 'up'); - const body = { - vaultId, - status: degraded ? 'degraded' as const : 'healthy' as const, - uptimeSeconds: Math.floor(process.uptime()), - metrics: { - totalAssets: summary.totalAssets, - totalShares: summary.totalShares, - sharePrice: summary.sharePrice, - apy: summary.apy, - }, - dependencies, - timestamp: new Date().toISOString(), - }; - - cache.set(cacheKey, body, 5); - return { ...body, cached: false }; -} - -function resolveActingAdminAddress(req: Request): string { - const address = - req.get('x-admin-address') || - req.get('x-admin-id') || - req.get('x-wallet-address') || - 'unknown'; - return address === 'unknown' ? address : normalizeWalletAddress(address); -} - -function parseLimited(v: unknown, fallback: number, min: number, max: number): number { - const n = parseInt(String(v ?? ''), 10); - return Number.isNaN(n) ? fallback : Math.min(Math.max(n, min), max); -} - -function isDryRunRequest(req: Request): boolean { - const value = req.body?.dryRun ?? req.query.dryRun; - return value === true || value === 'true' || value === '1'; -} - -function countInclusiveDays(start: string, end: string): number { - const startMs = Date.parse(start + 'T00:00:00.000Z'); - const endMs = Date.parse(end + 'T00:00:00.000Z'); - return Math.floor((endMs - startMs) / 86400000) + 1; -} - -function paginateByLimit(rows: T[], limit: number): { data: T[]; hasNextPage: boolean } { - const hasNextPage = rows.length > limit; - return { - data: hasNextPage ? rows.slice(0, limit) : rows, - hasNextPage, - }; -} - -function sendStandardListEnvelope( - res: Response, - input: { - data: T[]; - limit: number; - hasNextPage?: boolean; - hasPrevPage?: boolean; - nextCursor?: string; - total?: number; - statusCode?: number; - extras?: Record; - }, -): void { - const payload = createPaginatedResponse(input.data, { - count: input.data.length, - total: input.total ?? input.data.length, - nextCursor: input.nextCursor ?? null, - prevCursor: null, - currentPage: null, - totalPages: null, - hasNextPage: input.hasNextPage ?? false, - hasPrevPage: input.hasPrevPage ?? false, - limit: input.limit, - }); - - res.status(input.statusCode ?? 200).json({ - ...payload, - ...(input.extras || {}), - }); -} - -async function buildReferralStatsSnapshot(wallet: string) { - const normalizedWallet = normalizeWalletAddress(wallet); - - try { - const stats = await referralService.getReferralStats(normalizedWallet); - - if (!stats) { - // Must be the exact envelope GET /api/v1/referrals/:wallet returns, not - // a hand-rolled stand-in — admin impersonation is compared against that - // endpoint field-for-field by the governance contract test. - return { - statusCode: 404, - body: buildReferralStatsNotFoundBody(), - }; - } - - return { - statusCode: 200, - body: stats, - }; - } catch (error) { - // Same reasoning as the 404 above. The route reports a failed lookup as a - // 500 with the canonical envelope; letting the rejection escape instead - // would 500 the entire impersonation response with a different message, so - // one broken sub-resource would take the whole snapshot with it (#1319). - logger.log('error', 'Error building referral stats snapshot', { - error: error instanceof Error ? error.message : String(error), - wallet: normalizedWallet, - }); - return { - statusCode: 500, - body: buildReferralStatsErrorBody(), - }; - } -} - -async function buildReferralCodeSnapshot(wallet: string) { - const normalizedWallet = normalizeWalletAddress(wallet); - - try { - const code = await referralService.getOrCreateReferralCode(normalizedWallet); - return { - statusCode: 200, - body: { code }, - }; - } catch (error) { - logger.log('error', 'Error building referral code snapshot', { - error: error instanceof Error ? error.message : String(error), - wallet: normalizedWallet, - }); - return { - statusCode: 500, - body: buildReferralCodeErrorBody(), - }; - } -} - -async function buildWalletTransactionsSnapshot(wallet: string) { - const normalizedWallet = normalizeWalletAddress(wallet); - const prisma = getPrismaClient(); - // Shared with GET /api/v1/transactions so the proxy can never drift to a - // different page size than the endpoint it stands in for (#1322). - const limit = TRANSACTION_PAGINATION_CONFIG.defaultLimit ?? 20; - const where = { user: normalizedWallet }; - const [total, transactions] = await Promise.all([ - prisma.transaction.count({ where }), - prisma.transaction.findMany({ - where, - orderBy: { timestamp: 'desc' }, - take: limit + 1, - }), - ]); - const hasNextPage = transactions.length > limit; - const data = hasNextPage ? transactions.slice(0, limit) : transactions; - - return createPaginatedResponse( - data, - createPaginationEnvelope({ - count: data.length, - limit, - total, - hasNextPage, - hasPrevPage: false, - nextCursor: hasNextPage && data.length > 0 ? encodeCursor(data[data.length - 1].id) : null, - }), - ); -} - -async function buildImpersonatedVaultState(wallet: string) { - const normalizedWallet = normalizeWalletAddress(wallet); - return { - walletAddress: normalizedWallet, - // Impersonation must show an admin exactly what the wallet's own - // dashboard would show, so read the same DB-backed summary - // GET /api/v1/vault/summary serves. - summary: await buildVaultSummaryResponseFromDb(), - transactions: await buildWalletTransactionsSnapshot(normalizedWallet), - portfolioHoldings: buildPortfolioHoldingsResponse({ walletAddress: normalizedWallet }), - vaultHistory: buildVaultHistoryResponse({}), - referralStats: await buildReferralStatsSnapshot(normalizedWallet), - referralCode: await buildReferralCodeSnapshot(normalizedWallet), - }; -} - -function resolveTransactionExportAccess(req: Request): - | { kind: 'admin'; walletAddress?: string } - | { kind: 'user'; walletAddress: string } - | null { - const authHeader = req.get('authorization') || ''; - const walletAddress = - typeof req.query.walletAddress === 'string' ? normalizeWalletAddress(req.query.walletAddress) : undefined; - - const apiKeyMatch = authHeader.match(/^ApiKey\s+(.+)$/i); - if (apiKeyMatch) { - const authenticated = authenticateApiKeyValue(apiKeyMatch[1]); - if (!authenticated) { - return null; - } - req.authApiKeyHash = authenticated.hash; - req.authApiKeyRole = authenticated.role; - return { - kind: 'admin', - walletAddress: walletAddress || undefined, - }; - } - - const bearerMatch = authHeader.match(/^Bearer\s+(.+)$/i); - if (!bearerMatch) { - return null; - } - - const payload = verifyJwt(bearerMatch[1]); - const userWallet = normalizeWalletAddress(payload.sub); - if (walletAddress && walletAddress !== userWallet) { - throw new Error('FORBIDDEN_WALLET_EXPORT'); - } - - return { - kind: 'user', - walletAddress: userWallet, - }; -} - -function buildTransactionExportFilename(format: 'csv' | 'json'): string { - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - return `transaction-history-${timestamp}.${format}`; -} - -async function handleTransactionExport(req: Request, res: Response): Promise { - const format = req.query.format === 'csv' ? 'csv' : req.query.format === 'json' ? 'json' : null; - if (!format) { - res.status(400).json({ - error: 'Bad Request', - status: 400, - message: 'format query parameter must be either csv or json', - }); - return; - } - - let access; - try { - access = resolveTransactionExportAccess(req); - } catch (error) { - if (error instanceof Error && error.message === 'FORBIDDEN_WALLET_EXPORT') { - res.status(403).json({ - error: 'Forbidden', - status: 403, - message: 'Users may only export their own wallet transactions', - }); - return; - } - - res.status(401).json({ - error: 'Unauthorized', - status: 401, - message: error instanceof Error ? error.message : 'Invalid authorization header', - }); - return; - } - - if (!access) { - res.status(401).json({ - error: 'Unauthorized', - status: 401, - message: 'Authorization header must contain a Bearer token or ApiKey', - }); - return; - } - - const exportQuery = { - 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' || req.query.sortOrder === 'desc' - ? req.query.sortOrder - : undefined - ) as 'asc' | 'desc' | undefined, - walletAddress: access.walletAddress, - startDate: typeof req.query.startDate === 'string' ? req.query.startDate : undefined, - endDate: typeof req.query.endDate === 'string' ? req.query.endDate : undefined, - }; - - try { - const artifact = buildTransactionExportArtifact(format, exportQuery); - const fileName = buildTransactionExportFilename(format); - const job = await recordExportJob({ - format, - fileName, - contentType: artifact.contentType, - checksum: artifact.checksum, - checksumAlgorithm: artifact.checksumAlgorithm, - generatedBy: resolveExportGeneratedBy(req), - walletAddress: access.walletAddress, - rowCount: artifact.rowCount, - filters: { - type: exportQuery.type || null, - status: exportQuery.status || null, - sortBy: exportQuery.sortBy || null, - sortOrder: exportQuery.sortOrder || null, - startDate: exportQuery.startDate || null, - endDate: exportQuery.endDate || null, - walletAddress: exportQuery.walletAddress || null, - }, - }); - - 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) { - logger.log('error', 'Transaction export failed', { - error: error instanceof Error ? error.message : String(error), - }); - res.status(500).json({ - error: 'Internal Server Error', - status: 500, - message: 'Failed to generate transaction export', - }); - } -} - // ─── Rate Limiting Middleware ──────────────────────────────────────────────── // Issue #455: Use the Redis-backed limiter factory from rateLimiter.ts. // @@ -1030,40 +494,8 @@ apiV1.post('/auth/login', authIpLimiter, authLimiter, authUserLimiter, validate( */ apiV1.post('/auth/refresh', authIpLimiter, authLimiter, authUserLimiter, validate({ body: RefreshSchema }), refreshHandler); -/** - * POST /api/v1/webhooks — authenticated clients register an HTTPS callback - * instead of polling for transaction/vault events. - */ -apiV1.post('/webhooks', requireAuth, validate({ body: WebhookRegisterSchema }), (req: Request, res: Response) => { - try { - const { url, eventTypes, enabled, secret } = req.body; - const endpoint = registerWebhookEndpoint({ - url, - eventTypes, - enabled: enabled ?? true, - secret, - }); - res.status(201).json({ - message: 'Webhook endpoint registered', - endpoint, - }); - } catch (error) { - res.status(422).json({ - error: 'Unprocessable Entity', - status: 422, - message: error instanceof Error ? error.message : 'Invalid webhook configuration', - }); - } -}); - -apiV1.get('/webhooks', requireAuth, validate({ query: PaginationQuerySchema }), (req: Request, res: Response) => { - const includeDeleted = req.query.includeDeleted === 'true'; - const endpoints = listWebhookEndpoints(includeDeleted); - res.status(200).json({ - endpoints, - metrics: getWebhookDeliveryMetrics(), - }); -}); +// Webhook registration on /api/v1 (src/routes/webhooks.ts). +apiV1.use(apiV1WebhookRoutes); // Admin routes share API-key authentication and role-based authorization. app.use('/admin', validateApiKey, adminRbacMiddleware); @@ -1174,55 +606,6 @@ app.post('/auth/logout-all', (req: Request, res: Response) => { res.redirect(301, '/api/v1/auth/logout-all'); }); -// /api/vault/* → /api/v1/vault/* -app.get('/api/vault/summary', (_req: Request, res: Response) => { - res.setHeader('deprecation', 'true'); - res.redirect(301, '/api/v1/vault/summary'); -}); -app.get('/api/vault/transactions/export', (_req: Request, res: Response) => { - res.redirect(301, '/api/v1/vault/transactions/export'); -}); -app.get('/api/vault/metrics', (_req: Request, res: Response) => { - res.redirect(301, '/api/v1/vault/metrics'); -}); -app.get('/api/vault/apy', (_req: Request, res: Response) => { - res.redirect(301, '/api/v1/vault/apy'); -}); - -// /webhooks/verify → /api/v1/webhooks/verify -app.post('/webhooks/verify', validate({ body: WebhookVerifyBodySchema }), (req: Request, res: Response) => { - const { secret, payload, signature } = req.body || {}; - if (typeof secret !== 'string' || !secret.trim()) { - res.status(400).json({ - error: 'Bad Request', - status: 400, - message: 'secret is required and must be a non-empty string', - }); - return; - } - - if (typeof payload === 'undefined') { - res.status(400).json({ - error: 'Bad Request', - status: 400, - message: 'payload is required', - }); - return; - } - - const computedSignature = createWebhookSignature(secret, payload); - const verified = - typeof signature === 'string' && signature.length > 0 - ? verifyWebhookSignature(secret, payload, signature) - : null; - - res.status(200).json({ - algorithm: 'HMAC-SHA256', - signature: computedSignature, - verified, - }); -}); - // ─── Backward-compatibility redirects for list/router-mounted paths ────────── // Generic catch-all redirects for unversioned /vault/*, /referrals/*, // /transactions/*, /portfolio/* paths → /api/v1 equivalents. @@ -1243,215 +626,71 @@ app.use('/portfolio', (req: Request, res: Response) => { res.redirect(301, `/api/v1/portfolio${req.path}${qs}`); }); -// ─── Versioned export & summary endpoints ──────────────────────────────── -app.get('/api/v1/vault/transactions/export', handleTransactionExport); - -// ─── Versioned vault summary/metrics/apy endpoints ─────────────────────── +// ─── Feature routers (src/routes/, issue #1545) ───────────────────────────── +// Mounted where these routes were previously registered, after the legacy +// redirects above, so request handling order is unchanged. +app.use(vaultRoutes); +app.use(transactionRoutes); +app.use(webhookRoutes); +app.use(exportRoutes); +app.use(adminRoutes); /** - * GET /api/v1/vaults/:id/health - monitoring-friendly vault health payload. - * - * Kept separate from /health so external monitors can target one vault and - * receive vault metrics, dependency state, uptime, and cache status. Responses - * are cached for five seconds to avoid turning health probes into DB pressure. + * Mock vault metrics poll cycle + * In a real application, this would fetch data from a database or Stellar RPC */ -app.get( - '/api/v1/vaults/:id/health', - readsLimiter, - createTimeoutFor.read({ - timeoutMs: 1500, - routeName: '/api/v1/vaults/:id/health', - message: 'Vault health took too long to load', - fallbackResponse: () => ({ - error: 'Service Unavailable', - status: 503, - code: 'VAULT_HEALTH_TIMEOUT', - message: 'Vault health is temporarily unavailable. Please retry shortly.', - timestamp: new Date().toISOString(), - }), - }), - async (req: Request, res: Response) => { - const health = await buildVaultHealthResponse(req.params.id); - res.status(health.status === 'healthy' ? 200 : 503).json(health); - }, -); - -app.get('/api/v2/vaults/:id/health', (req: Request, res: Response) => { - const qs = req.originalUrl.includes('?') ? req.originalUrl.slice(req.originalUrl.indexOf('?')) : ''; - res.set('X-API-Preview', 'v2'); - res.redirect(307, `/api/v1/vaults/${encodeURIComponent(req.params.id)}/health${qs}`); -}); +const pollVaultMetrics = () => { + // Mock data for TVL and Share Price + const mockTvl = 1000000 + Math.random() * 100000; + const mockSharePrice = 1.25 + Math.random() * 0.05; -/** - * GET /api/v1/vaults/:id/apy - * - * Returns the annualised APY for the vault. Returns `apy: null` with - * `apyStatus: 'insufficient_data'` for new vaults that have zero shares or - * fewer than 2 price snapshots, preventing Infinity / NaN from reaching the - * frontend (Issue #1456). - */ -app.get( - '/api/v1/vaults/:id/apy', - readsLimiter, - cacheMiddleware({ ttl: cacheVaultMetricsTtl }), - createTimeoutFor.read({ - timeoutMs: 1500, - routeName: '/api/v1/vaults/:id/apy', - message: 'Vault APY took too long to load', - fallbackResponse: () => ({ - error: 'Service Unavailable', - status: 503, - code: 'VAULT_APY_TIMEOUT', - message: 'Vault APY is temporarily unavailable. Please try again shortly.', - timestamp: new Date().toISOString(), - }), - }), - async (_req: Request, res: Response) => { - const result = await computeVaultApy(); - res.json({ - ...result, - timestamp: new Date().toISOString(), - }); - }, -); + updateVaultMetrics(mockTvl, mockSharePrice); -/** - * @openapi - * /vault/summary: - * get: - * summary: Vault summary - * description: Returns high-level vault metrics including total assets, shares, and APY. - * tags: [Vault] - * responses: - * 200: - * description: Vault summary metrics - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/VaultSummary' - */ -/** - * GET /api/v1/vault/summary – read-only summary; relaxed rate limit. - * Data is sourced from VaultState + SharePriceSnapshot (DB) and cached via - * the Redis-backed response cache. - */ -app.get( - '/api/v1/vault/summary', - readsLimiter, - cacheMiddleware({ ttl: cacheVaultMetricsTtl }), - createTimeoutFor.read({ - timeoutMs: 1500, - routeName: '/api/v1/vault/summary', - message: 'Vault summary took too long to load', - fallbackResponse: () => ({ - error: 'Service Unavailable', - status: 503, - code: 'VAULT_SUMMARY_TIMEOUT', - message: 'Vault summary is temporarily unavailable. Please refresh in a moment.', - stale: true, - data: { - totalAssets: '0', - totalShares: '0', - sharePrice: '1.000000', - apy: 0, - timestamp: new Date().toISOString(), - }, - timestamp: new Date().toISOString(), - }), - }), - async (_req: Request, res: Response) => { - const summary = await buildVaultSummaryResponseFromDb(); - res.json(summary); - }, -); + logger.log('info', 'Vault metrics updated in Prometheus gauges', { + tvl: mockTvl, + sharePrice: mockSharePrice, + }); +}; -/** - * GET /api/v1/vault/metrics - Cache with configurable TTL - */ -app.get( - '/api/v1/vault/metrics', - cacheMiddleware({ ttl: cacheVaultMetricsTtl }), - createTimeoutFor.read({ - timeoutMs: 2000, - routeName: '/api/v1/vault/metrics', - message: 'Vault metrics took too long to load', - fallbackResponse: () => ({ - error: 'Service Unavailable', - status: 503, - code: 'VAULT_METRICS_TIMEOUT', - message: 'Vault metrics are temporarily unavailable. Showing the last known state.', - stale: true, - timestamp: new Date().toISOString(), - }), - }), - (_req: Request, res: Response) => { - res.json({ - message: 'Vault metrics', - timestamp: new Date().toISOString(), - }); - }, -); +// Start poll cycle every 60 seconds (configurable) +const METRICS_POLL_INTERVAL = parseInt(process.env.METRICS_POLL_INTERVAL_MS || '60000', 10); +const metricsInterval = + process.env.NODE_ENV === 'test' + ? null + : setInterval(pollVaultMetrics, METRICS_POLL_INTERVAL); -/** - * GET /api/v1/vault/apy - Cache with configurable TTL - */ -app.get( - '/api/v1/vault/apy', - cacheMiddleware({ ttl: cacheVaultMetricsTtl }), - createTimeoutFor.read({ - timeoutMs: 1200, - routeName: '/api/v1/vault/apy', - message: 'Vault APY took too long to load', - fallbackResponse: () => ({ - error: 'Service Unavailable', - status: 503, - code: 'VAULT_APY_TIMEOUT', - message: 'Vault APY is temporarily unavailable. Please try again shortly.', - stale: true, - timestamp: new Date().toISOString(), - }), - }), - (_req: Request, res: Response) => { - res.json({ - message: 'Vault APY', - timestamp: new Date().toISOString(), - }); - }, -); +if (process.env.NODE_ENV !== 'test') { + pollVaultMetrics(); // Initial call + void initializeWebhookEndpoints(); +} -// ─── Admin Routes (with API key authentication) ────────────────────────────── +// Start latency monitoring +latencyMonitoringService.startMonitoring(); -/** - * POST /admin/apy/backfill - backfill missing APY snapshots for a date range - * Body: { start: "YYYY-MM-DD", end: "YYYY-MM-DD" } - * Requires API key authentication. - */ -app.post('/admin/apy/backfill', validateApiKey, validate({ body: ApyBackfillBodySchema }), async (req: Request, res: Response) => { - const { start, end } = req.body; - if (!start || !end || typeof start !== 'string' || typeof end !== 'string') { - res.status(400).json({ - error: 'Bad Request', - status: 400, - message: '`start` and `end` (YYYY-MM-DD) are required', - }); - return; - } +// ─── Event Polling Service (Issue: Event Replay) ──────────────────────────── +if (process.env.NODE_ENV !== 'test' && process.env.VAULT_CONTRACT_ID) { + startEventPollingService({ + rpcUrl: process.env.STELLAR_RPC_URL || 'https://soroban-testnet.stellar.org', + contractId: process.env.VAULT_CONTRACT_ID, + pollIntervalMs: parseInt(process.env.EVENT_POLL_INTERVAL_MS || '10000', 10), + batchSize: parseInt(process.env.EVENT_REPLAY_BATCH_SIZE || '100', 10), + }); - const datePattern = /^\d{4}-\d{2}-\d{2}$/; - if (!datePattern.test(start) || !datePattern.test(end)) { - res.status(400).json({ - error: 'Bad Request', - status: 400, - message: '`start` and `end` must be in YYYY-MM-DD format', - }); - return; - } + // stopEventPollingService() is a no-op if the service was never started, + // so this is safe to register unconditionally alongside the block above. + shutdownHandler.onShutdown(async () => { + stopEventPollingService(); + }); +} - if (end < start) { - res.status(400).json({ - error: 'Bad Request', - status: 400, - message: '`end` must be >= `start`', +// ─── Outbox Pattern Processor ───────────────────────────────────────── +// Starts the background outbox event relay processor. +// Writes from vault operations go to the EventOutbox table atomically; +// this processor reads them and delivers via the webhook system. +if (process.env.NODE_ENV !== 'test') { + eventOutboxService.replayOnStartup().catch((err) => { + logger.log('error', 'Outbox startup replay failed', { + error: err instanceof Error ? err.message : String(err), }); return; } @@ -5011,15 +4250,6 @@ async function getPrismaHealth(): Promise<'up' | 'down'> { } } -function getPrismaConfig() { - const config = getPrismaRuntimeConfig(); - return { - prismaPoolSize: config.poolMax, - prismaQueryTimeoutMs: config.queryTimeoutMs, - prismaPoolTimeoutMs: config.poolTimeoutMs, - }; -} - /** * Check Stellar RPC health * In production, this would make actual RPC calls diff --git a/backend/src/numericColumns.ts b/backend/src/numericColumns.ts new file mode 100644 index 000000000..548bd1efc --- /dev/null +++ b/backend/src/numericColumns.ts @@ -0,0 +1,39 @@ +import Decimal from 'decimal.js'; + +/** + * Encoding contract for Prisma `String` columns that map to PostgreSQL + * `NUMERIC(38, 18)` (see prisma/postgres-type-mapping.json, issue #1546). + * + * SQLite has no exact decimal type, so dev stores these amounts as strings. + * Decimal.js defaults to 20 significant digits, which silently rounds values + * that PostgreSQL stores exactly; arithmetic on these columns must use + * `NumericDecimal`, and values must pass `toNumeric38_18` to be accepted by + * both databases. + */ +export const NUMERIC_PRECISION = 38; +export const NUMERIC_SCALE = 18; + +export const NumericDecimal = Decimal.clone({ precision: NUMERIC_PRECISION }); + +const MAX_INTEGER_DIGITS = NUMERIC_PRECISION - NUMERIC_SCALE; + +/** + * Returns `value` as a plain decimal string with at most 18 fractional + * digits, exactly as PostgreSQL `NUMERIC(38, 18)` would store it. + * + * @throws RangeError if the value is not finite, has more than 18 fractional + * digits, or more than 20 integer digits (PostgreSQL would reject it). + */ +export function toNumeric38_18(value: Decimal.Value): string { + const decimal = new NumericDecimal(value); + if (!decimal.isFinite()) { + throw new RangeError(`NUMERIC(38, 18) value must be finite: ${String(value)}`); + } + if (decimal.decimalPlaces() > NUMERIC_SCALE) { + throw new RangeError(`NUMERIC(38, 18) allows ${NUMERIC_SCALE} fractional digits: ${String(value)}`); + } + if (decimal.abs().trunc().toFixed(0).replace(/^0$/, '').length > MAX_INTEGER_DIGITS) { + throw new RangeError(`NUMERIC(38, 18) allows ${MAX_INTEGER_DIGITS} integer digits: ${String(value)}`); + } + return decimal.toFixed(); +} diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts new file mode 100644 index 000000000..241433111 --- /dev/null +++ b/backend/src/routes/admin.ts @@ -0,0 +1,3263 @@ +/** + * Remaining /admin/* operational endpoints. + * + * Extracted from src/index.ts (issue #1545); mounted by the composition root + * in the same position the routes were registered, so behaviour is unchanged. + */ +import { Router, Request, Response } from 'express'; +import { API_KEY_AUDIT_ACTIONS, getApiKeyFingerprintFromHash, getApiKeyFingerprintFromValue, isApiKeyHash, listApiKeyAuditEvents, recordApiKeyAuditEvent, resolveApiKeyAuditActor } from '../apiKeyAudit'; +import { AllowlistWalletBodySchema, ApiKeyRegisterSchema, ApiKeyRevokeSchema, ApiKeyRotateSchema, ApyBackfillBodySchema, AuditLogQuerySchema, CacheInvalidateSchema, CreateVaultBodySchema, DeadLetterIdsSchema, DeadLetterProcessSchema, DeadLetterResolveSchema, EventReplayBodySchema, FeatureFlagOverrideSchema, GovernanceSnapshotExportSchema, IdParamSchema, ImpersonationSessionBodySchema, MaintenanceToggleSchema, MaintenanceWindowBodySchema, ScopedTokenCreateSchema, WindowIdParamSchema, WithdrawalLimitOverrideSchema, validate } from '../middleware/validate'; +import { DateRangeParseError, parseUtcDateRange } from '../dateRange'; +import { type DeadLetterStatus, type JobName, bulkDiscardDeadLetters, bulkRetryDeadLetters, discardDeadLetter, getDeadLetterRecord, getJobHealthStatus, getJobMetrics, listDeadLetters, processDeadLetterQueue, resolveDeadLetter, retryDeadLetter } from '../jobGovernance'; +import { type ImpersonationSessionRecord, endImpersonationSession, listImpersonationSessions, resolveImpersonationSessionContext, startImpersonationSession, validateImpersonationSession } from '../impersonationSessionService'; +import { TRANSACTION_PAGINATION_CONFIG, buildPortfolioHoldingsResponse, buildVaultHistoryResponse } from '../listEndpoints'; +import { type WithdrawalSagaStatus, withdrawalRecoveryCoordinator } from '../withdrawalRecovery'; +import { addAddress, allowlistSize, listAddresses, removeAddress } from '../middleware/allowlist'; +import { backfillApySnapshots } from '../apySnapshot'; +import { buildReferralCodeErrorBody, buildReferralStatsErrorBody, buildReferralStatsNotFoundBody } from '../referralEndpoints'; +import { buildVaultSummaryResponseFromDb, isDryRunRequest, paginateByLimit, parseLimited, resolveActingAdminAddress, sendStandardListEnvelope } from './shared'; +import { cancelMaintenanceWindow, listMaintenanceWindows, scheduleMaintenanceWindow } from '../maintenanceWindow'; +import { countAuditLogs, getAuditLogMetrics, getAuditLogs } from '../auditLog'; +import { createPaginatedResponse, createPaginationEnvelope, encodeCursor } from '../pagination'; +import { emailQueueService } from '../emailQueue'; +import { exportGovernanceSnapshots, listGovernanceSnapshots } from '../governanceSnapshotExport'; +import { featureFlags } from '../featureFlags'; +import { generateAdminReceipt, getAdminReceipt, listAdminReceipts, verifyReceiptSignature } from '../adminReceipt'; +import { getApiKeyMetadata, hasRequiredApiKeyRole, normalizeApiKeyRole, registerApiKey, restoreApiKey, revokeApiKey, rotateApiKey, validateApiKey } from '../middleware/apiKeyAuth'; +import { getCacheStats, invalidateCache } from '../middleware/cache'; +import { getIdempotencyRetentionMetrics, pruneStaleIdempotencyRecords } from '../idempotencyRetention'; +import { getMaintenanceModeState, logMaintenanceTransition, updateMaintenanceModeState } from '../maintenanceMode'; +import { getPrismaClient } from '../prismaClient'; +import { getPrismaRuntimeConfig, prisma } from '../prisma'; +import { getRedisCacheHealth, redisCacheClient } from '../redisCache'; +import { getWebhookDeliveryMetrics } from '../webhookDelivery'; +import { idempotencyStore } from '../idempotency'; +import { listAdminConfigChanges, recordAdminConfigChange } from '../adminConfigChangeAudit'; +import { listWithdrawalLimitAuditEntries, setWithdrawalLimitOverride } from '../middleware/withdrawalDailyLimit'; +import { logger } from '../middleware/structuredLogging'; +import { normalizeWalletAddress } from '../walletUtils'; +import { recordAdminAuditLog } from '../adminAudit'; +import { referralService } from '../referralService'; +import { register } from '../metrics'; +import { scopedAdminTokenStore } from '../scopedAdminTokens'; +import { walletAliasMappingService } from '../walletAliasService'; +import { writeAheadAuditLog } from '../writeAheadAuditLog'; + +function countInclusiveDays(start: string, end: string): number { + const startMs = Date.parse(start + 'T00:00:00.000Z'); + const endMs = Date.parse(end + 'T00:00:00.000Z'); + return Math.floor((endMs - startMs) / 86400000) + 1; +} + +async function buildReferralStatsSnapshot(wallet: string) { + const normalizedWallet = normalizeWalletAddress(wallet); + + try { + const stats = await referralService.getReferralStats(normalizedWallet); + + if (!stats) { + // Must be the exact envelope GET /api/v1/referrals/:wallet returns, not + // a hand-rolled stand-in — admin impersonation is compared against that + // endpoint field-for-field by the governance contract test. + return { + statusCode: 404, + body: buildReferralStatsNotFoundBody(), + }; + } + + return { + statusCode: 200, + body: stats, + }; + } catch (error) { + // Same reasoning as the 404 above. The route reports a failed lookup as a + // 500 with the canonical envelope; letting the rejection escape instead + // would 500 the entire impersonation response with a different message, so + // one broken sub-resource would take the whole snapshot with it (#1319). + logger.log('error', 'Error building referral stats snapshot', { + error: error instanceof Error ? error.message : String(error), + wallet: normalizedWallet, + }); + return { + statusCode: 500, + body: buildReferralStatsErrorBody(), + }; + } +} + +async function buildReferralCodeSnapshot(wallet: string) { + const normalizedWallet = normalizeWalletAddress(wallet); + + try { + const code = await referralService.getOrCreateReferralCode(normalizedWallet); + return { + statusCode: 200, + body: { code }, + }; + } catch (error) { + logger.log('error', 'Error building referral code snapshot', { + error: error instanceof Error ? error.message : String(error), + wallet: normalizedWallet, + }); + return { + statusCode: 500, + body: buildReferralCodeErrorBody(), + }; + } +} + +async function buildWalletTransactionsSnapshot(wallet: string) { + const normalizedWallet = normalizeWalletAddress(wallet); + const prisma = getPrismaClient(); + // Shared with GET /api/v1/transactions so the proxy can never drift to a + // different page size than the endpoint it stands in for (#1322). + const limit = TRANSACTION_PAGINATION_CONFIG.defaultLimit ?? 20; + const where = { user: normalizedWallet }; + const [total, transactions] = await Promise.all([ + prisma.transaction.count({ where }), + prisma.transaction.findMany({ + where, + orderBy: { timestamp: 'desc' }, + take: limit + 1, + }), + ]); + const hasNextPage = transactions.length > limit; + const data = hasNextPage ? transactions.slice(0, limit) : transactions; + + return createPaginatedResponse( + data, + createPaginationEnvelope({ + count: data.length, + limit, + total, + hasNextPage, + hasPrevPage: false, + nextCursor: hasNextPage && data.length > 0 ? encodeCursor(data[data.length - 1].id) : null, + }), + ); +} + +async function buildImpersonatedVaultState(wallet: string) { + const normalizedWallet = normalizeWalletAddress(wallet); + return { + walletAddress: normalizedWallet, + // Impersonation must show an admin exactly what the wallet's own + // dashboard would show, so read the same DB-backed summary + // GET /api/v1/vault/summary serves. + summary: await buildVaultSummaryResponseFromDb(), + transactions: await buildWalletTransactionsSnapshot(normalizedWallet), + portfolioHoldings: buildPortfolioHoldingsResponse({ walletAddress: normalizedWallet }), + vaultHistory: buildVaultHistoryResponse({}), + referralStats: await buildReferralStatsSnapshot(normalizedWallet), + referralCode: await buildReferralCodeSnapshot(normalizedWallet), + }; +} + +function getPrismaConfig() { + const config = getPrismaRuntimeConfig(); + return { + prismaPoolSize: config.poolMax, + prismaQueryTimeoutMs: config.queryTimeoutMs, + prismaPoolTimeoutMs: config.poolTimeoutMs, + }; +} + +// ─── Withdrawal Partial-Failure Recovery Endpoints (Issue #954) ────────────── + +const WITHDRAWAL_SAGA_STATUSES: readonly WithdrawalSagaStatus[] = [ + 'in_progress', + 'completed', + 'awaiting_retry', + 'compensated', + 'needs_manual_intervention', + 'failed', +]; + +function parseSagaStatus(value: unknown): WithdrawalSagaStatus | undefined { + return typeof value === 'string' && + (WITHDRAWAL_SAGA_STATUSES as readonly string[]).includes(value) + ? (value as WithdrawalSagaStatus) + : undefined; +} + +const router = Router(); + +// ─── Admin Routes (with API key authentication) ────────────────────────────── + +/** + * POST /admin/apy/backfill - backfill missing APY snapshots for a date range + * Body: { start: "YYYY-MM-DD", end: "YYYY-MM-DD" } + * Requires API key authentication. + */ +router.post('/admin/apy/backfill', validateApiKey, validate({ body: ApyBackfillBodySchema }), async (req: Request, res: Response) => { + const { start, end } = req.body; + if (!start || !end || typeof start !== 'string' || typeof end !== 'string') { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: '`start` and `end` (YYYY-MM-DD) are required', + }); + return; + } + + const datePattern = /^\d{4}-\d{2}-\d{2}$/; + if (!datePattern.test(start) || !datePattern.test(end)) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: '`start` and `end` must be in YYYY-MM-DD format', + }); + return; + } + + if (end < start) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: '`end` must be >= `start`', + }); + return; + } + + const actor = resolveActingAdminAddress(req); + const jobStart = Date.now(); + + try { + if (isDryRunRequest(req)) { + void recordAdminAuditLog(req, 'apy.backfill.dry_run', 200, { + start, + end, + actor, + estimatedDates: countInclusiveDays(start, end), + }); + + res.status(200).json({ + dryRun: true, + message: 'APY backfill dry-run preview', + start, + end, + estimatedDates: countInclusiveDays(start, end), + wouldCreateSnapshots: true, + timestamp: new Date().toISOString(), + }); + return; + } + + const result = await backfillApySnapshots(start, end); + const durationMs = Date.now() - jobStart; + + const receipt = await generateAdminReceipt({ + action: 'apy.backfill', + actor, + input: { start, end }, + resultingState: { + created: result.created, + skipped: result.skipped, + durationMs, + }, + }); + + void recordAdminAuditLog(req, 'apy.backfill', 200, { + start, + end, + created: result.created, + skipped: result.skipped, + durationMs, + actor, + receiptId: receipt.id, + }); + + res.status(200).json({ + message: 'APY backfill completed', + start, + end, + created: result.created, + skipped: result.skipped, + dates: result.dates, + durationMs, + timestamp: new Date().toISOString(), + receipt, + }); + } catch (err) { + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: err instanceof Error ? err.message : String(err), + }); + } +}); + +/** + * GET /admin/maintenance - get current maintenance mode state + * Requires API key authentication. + */ +router.get('/admin/maintenance', validateApiKey, (_req: Request, res: Response) => { + res.status(200).json({ + maintenance: getMaintenanceModeState(), + timestamp: new Date().toISOString(), + }); +}); + +/** + * POST /admin/maintenance - enable or disable maintenance mode + * Body: { enabled: boolean, reason?: string, retryAfterSeconds?: number } + * Requires API key authentication. + */ +router.post('/admin/maintenance', validateApiKey, validate({ body: MaintenanceToggleSchema }), async (req: Request, res: Response) => { + const { enabled, reason, retryAfterSeconds } = req.body; + if (typeof enabled !== 'boolean') { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: '`enabled` (boolean) is required', + }); + return; + } + + const actor = resolveActingAdminAddress(req); + const previous = getMaintenanceModeState(); + const dryRun = isDryRunRequest(req); + const preview = { + enabled: enabled ?? previous.enabled, + reason: + reason === undefined + ? previous.reason + : typeof reason === 'string' + ? reason.trim() || undefined + : undefined, + updatedAt: new Date().toISOString(), + updatedBy: actor, + retryAfterSeconds: retryAfterSeconds ?? previous.retryAfterSeconds, + }; + + if (dryRun) { + void recordAdminAuditLog(req, 'maintenance.toggle.dry_run', 200, { + enabled: preview.enabled, + previousEnabled: previous.enabled, + reason: preview.reason, + actor, + }); + + res.status(200).json({ + dryRun: true, + message: `Maintenance mode would be ${preview.enabled ? 'enabled' : 'disabled'}`, + previous, + maintenance: preview, + timestamp: new Date().toISOString(), + }); + return; + } + + const next = updateMaintenanceModeState({ enabled, reason, retryAfterSeconds, actor }); + + await recordAdminConfigChange({ + configType: 'maintenance', + action: 'toggle', + actor, + ipAddress: req.ip, + userAgent: req.get('user-agent'), + preChangeSnapshot: previous as unknown as Record, + postChangeSnapshot: next as unknown as Record, + metadata: { receiptId: '' }, + }); + + const receipt = await generateAdminReceipt({ + action: 'maintenance.toggle', + actor, + input: { enabled, reason, retryAfterSeconds }, + resultingState: { + enabled: next.enabled, + reason: next.reason, + retryAfterSeconds: next.retryAfterSeconds, + previousEnabled: previous.enabled, + }, + }); + + logMaintenanceTransition({ + enabled: next.enabled, + actor, + reason: next.reason, + retryAfterSeconds: next.retryAfterSeconds, + previousEnabled: previous.enabled, + }); + + void recordAdminAuditLog(req, 'maintenance.toggle', 200, { + enabled: next.enabled, + previousEnabled: previous.enabled, + reason: next.reason, + actor, + receiptId: receipt.id, + }); + + res.status(200).json({ + message: `Maintenance mode ${next.enabled ? 'enabled' : 'disabled'}`, + maintenance: next, + timestamp: new Date().toISOString(), + receipt, + }); +}); + +/** + * GET /admin/maintenance/windows - list scheduled maintenance windows + */ +router.get('/admin/maintenance/windows', validateApiKey, (_req: Request, res: Response) => { + res.status(200).json({ + windows: listMaintenanceWindows(), + timestamp: new Date().toISOString(), + }); +}); + +/** + * POST /admin/maintenance/windows - schedule a maintenance window + * Body: { title: string, reason?: string, startsAt: string, endsAt: string } + */ +router.post('/admin/maintenance/windows', validateApiKey, validate({ body: MaintenanceWindowBodySchema }), (req: Request, res: Response) => { + const { title, reason, startsAt, endsAt } = req.body; + if (typeof title !== 'string' || !title.trim()) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: '`title` (string) is required', + }); + return; + } + if (typeof startsAt !== 'string' || typeof endsAt !== 'string') { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: '`startsAt` and `endsAt` (ISO strings) are required', + }); + return; + } + + try { + const actor = resolveActingAdminAddress(req); + const window = scheduleMaintenanceWindow({ + title, + reason: typeof reason === 'string' ? reason : undefined, + startsAt, + endsAt, + actor, + }); + res.status(201).json({ window, timestamp: new Date().toISOString() }); + } catch (error) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: error instanceof Error ? error.message : String(error), + }); + } +}); + +/** + * DELETE /admin/maintenance/windows/:windowId - cancel a scheduled window + */ +router.delete('/admin/maintenance/windows/:windowId', validateApiKey, validate({ params: WindowIdParamSchema }), (req: Request, res: Response) => { + const cancelled = cancelMaintenanceWindow(req.params.windowId); + if (!cancelled) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Maintenance window not found', + }); + return; + } + res.status(200).json({ + cancelled: true, + windowId: req.params.windowId, + timestamp: new Date().toISOString(), + }); +}); + +/** + * GET /admin/config-changes - List admin configuration changes + * Requires API key authentication + */ +router.get('/admin/config-changes', validateApiKey, async (req: Request, res: Response) => { + const { configType, actor, start, end, limit } = req.query; + const configChanges = await listAdminConfigChanges({ + configType: typeof configType === 'string' ? configType : undefined, + actor: typeof actor === 'string' ? actor : undefined, + start: typeof start === 'string' ? start : undefined, + end: typeof end === 'string' ? end : undefined, + limit: typeof limit === 'string' ? parseInt(limit, 10) : 100, + }); + + res.json({ + data: configChanges, + timestamp: new Date().toISOString(), + }); +}); + +/** + * GET /admin/feature-flags/overrides - List active feature flag overrides + * Requires API key authentication + */ +router.get('/admin/feature-flags/overrides', validateApiKey, async (_req: Request, res: Response) => { + const overrides = await featureFlags.listActiveOverrides(); + res.json({ + data: overrides, + timestamp: new Date().toISOString(), + }); +}); + +/** + * POST /admin/feature-flags/overrides - Create a new feature flag override + * Requires API key authentication + * Body: { + * flagName: string, + * enabled: boolean, + * scopeType: "wallet" | "environment", + * scopeValue?: string, + * expiresAt: string (ISO 8601) + * } + */ +router.post('/admin/feature-flags/overrides', validateApiKey, validate({ body: FeatureFlagOverrideSchema }), async (req: Request, res: Response) => { + const { flagName, enabled, scopeType, scopeValue, expiresAt } = req.body; + + if (!flagName || typeof flagName !== 'string') { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: '`flagName` (string) is required' + }); + return; + } + + if (typeof enabled !== 'boolean') { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: '`enabled` (boolean) is required' + }); + return; + } + + if (!scopeType || !['wallet', 'environment'].includes(scopeType)) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: '`scopeType` must be "wallet" or "environment"' + }); + return; + } + + if (scopeType === 'wallet' && (!scopeValue || typeof scopeValue !== 'string')) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: '`scopeValue` (string) is required for wallet scope' + }); + return; + } + + if (scopeType === 'environment' && (!scopeValue || typeof scopeValue !== 'string')) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: '`scopeValue` (string) is required for environment scope' + }); + return; + } + + if (!expiresAt || typeof expiresAt !== 'string') { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: '`expiresAt` (ISO 8601 string) is required' + }); + return; + } + + const expiresAtDate = new Date(expiresAt); + if (isNaN(expiresAtDate.getTime()) || expiresAtDate <= new Date()) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: '`expiresAt` must be a valid future date in ISO 8601 format' + }); + return; + } + + const actor = resolveActingAdminAddress(req); + const override = await featureFlags.createOverride( + flagName, + enabled, + scopeType as 'wallet' | 'environment', + scopeValue, + expiresAtDate, + actor + ); + + res.status(201).json({ + message: 'Feature flag override created successfully', + data: override, + timestamp: new Date().toISOString(), + }); +}); + +/** + * DELETE /admin/feature-flags/overrides/:id - Delete a feature flag override + * Requires API key authentication + */ +router.delete('/admin/feature-flags/overrides/:id', validateApiKey, async (req: Request, res: Response) => { + const { id } = req.params; + if (!id) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'Override ID is required' + }); + return; + } + + try { + await featureFlags.deleteOverride(id); + res.status(200).json({ + message: 'Feature flag override deleted successfully', + timestamp: new Date().toISOString(), + }); + } catch (err) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Feature flag override not found' + }); + } +}); + +/** + * GET /admin/cache/stats - Get cache statistics including hit rate (R8) and Redis status + * Requires API key authentication + */ +router.get('/admin/cache/stats', validateApiKey, async (_req: Request, res: Response) => { + const stats = getCacheStats(); + // Compute hit rate from Prometheus counters by reading the registry metrics text + let hitRate: number | null = null; + try { + const metricsText = await register.metrics(); + const hitMatch = metricsText.match(/^cache_hit_count(?:\{[^}]*\})?\s+([\d.]+)/m); + const missMatch = metricsText.match(/^cache_miss_count(?:\{[^}]*\})?\s+([\d.]+)/m); + // Sum all label combinations + const hitTotal = hitMatch + ? metricsText + .split('\n') + .filter((l) => l.startsWith('cache_hit_count')) + .reduce((acc, l) => { + const m = l.match(/\s+([\d.]+)$/); + return acc + (m ? parseFloat(m[1]) : 0); + }, 0) + : 0; + const missTotal = missMatch + ? metricsText + .split('\n') + .filter((l) => l.startsWith('cache_miss_count')) + .reduce((acc, l) => { + const m = l.match(/\s+([\d.]+)$/); + return acc + (m ? parseFloat(m[1]) : 0); + }, 0) + : 0; + const total = hitTotal + missTotal; + hitRate = total > 0 ? parseFloat((hitTotal / total).toFixed(4)) : null; + } catch { + hitRate = null; + } + + const redisHealth = await getRedisCacheHealth(); + + res.json({ + entryCount: stats.size, + entries: stats.entries, + hitRate, + redis: { + configured: redisCacheClient.isConfigured, + ready: redisCacheClient.isReady, + health: redisHealth, + }, + timestamp: new Date().toISOString(), + }); +}); + +/** + * GET /admin/cache/redis-status - Detailed Redis cache connection and health status + * Requires API key authentication + */ +router.get('/admin/cache/redis-status', validateApiKey, async (_req: Request, res: Response) => { + const redisHealth = await getRedisCacheHealth(); + const pingResponse = await redisCacheClient.ping(); + + res.json({ + configured: redisCacheClient.isConfigured, + ready: redisCacheClient.isReady, + health: redisHealth, + ping: pingResponse, + fallbackActive: redisCacheClient.isConfigured && !redisCacheClient.isReady, + timestamp: new Date().toISOString(), + }); +}); + +/** + * DELETE /admin/cache - Clear entire cache or by regex pattern (R8) + * ?pattern= removes only matching entries + * Requires API key authentication + */ +router.delete('/admin/cache', validateApiKey, (req: Request, res: Response) => { + const pattern = typeof req.query.pattern === 'string' ? req.query.pattern : undefined; + + if (pattern !== undefined) { + if (pattern.trim() === '') { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'pattern query parameter must not be empty; omit it to clear the entire cache', + }); + return; + } + try { + new RegExp(pattern); + } catch (e) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: `Invalid regex pattern "${pattern}": ${e instanceof Error ? e.message : String(e)}`, + }); + return; + } + } + + const removed = invalidateCache(pattern); + res.json({ + removed, + pattern: pattern ?? null, + timestamp: new Date().toISOString(), + }); +}); + +/** + * POST /admin/cache/invalidate - Invalidate cache by pattern (legacy endpoint) + * Requires API key authentication + */ +router.post('/admin/cache/invalidate', validateApiKey, validate({ body: CacheInvalidateSchema }), (req: Request, res: Response) => { + const { pattern } = req.body; + if (isDryRunRequest(req)) { + res.json({ + dryRun: true, + message: 'Cache invalidation dry-run preview', + pattern, + wouldInvalidate: true, + stats: getCacheStats(), + timestamp: new Date().toISOString(), + }); + return; + } + + invalidateCache(pattern); + res.json({ + message: 'Cache invalidated', + pattern, + stats: getCacheStats(), + }); +}); + +/** + * POST /admin/events/replay - Manual admin endpoint to replay events for a specific ledger range + * Requires API key authentication + */ +router.post('/admin/events/replay', validateApiKey, validate({ body: EventReplayBodySchema }), async (req: Request, res: Response) => { + try { + const { fromLedger, toLedger } = req.body; + + if (fromLedger === undefined || toLedger === undefined) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'fromLedger and toLedger are required in request body', + }); + return; + } + + if (typeof fromLedger !== 'number' || typeof toLedger !== 'number') { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'fromLedger and toLedger must be numbers', + }); + return; + } + + // Validate ledger range + if (fromLedger < 0 || toLedger < 0 || fromLedger > toLedger) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'fromLedger must be >= 0, toLedger must be >= 0, and fromLedger must be <= toLedger', + }); + return; + } + + const actor = resolveActingAdminAddress(req); + if (isDryRunRequest(req)) { + void recordAdminAuditLog(req, 'events.replay.manual.dry_run', 200, { + fromLedger, + toLedger, + ledgerCount: toLedger - fromLedger + 1, + actor, + timestamp: new Date().toISOString(), + }); + + res.status(200).json({ + dryRun: true, + message: 'Event replay dry-run preview', + fromLedger, + toLedger, + ledgerCount: toLedger - fromLedger + 1, + wouldReplay: true, + timestamp: new Date().toISOString(), + }); + return; + } + + // Import the replay function + const { replayEventsForRange } = await import('../eventPollingService'); + + const startTime = Date.now(); + const result = await replayEventsForRange(fromLedger, toLedger); + const duration = Date.now() - startTime; + + const receipt = await generateAdminReceipt({ + action: 'events.replay.manual', + actor, + input: { fromLedger, toLedger }, + resultingState: { + processedCount: result.processedCount, + duplicateCount: result.duplicateCount, + durationMs: duration, + }, + }); + + // Record replay job metadata + void recordAdminAuditLog(req, 'events.replay.manual', 200, { + fromLedger, + toLedger, + processedCount: result.processedCount, + duplicateCount: result.duplicateCount, + durationMs: duration, + timestamp: new Date().toISOString(), + receiptId: receipt.id, + }); + + res.status(200).json({ + message: 'Event replay completed successfully', + fromLedger, + toLedger, + processedCount: result.processedCount, + duplicateCount: result.duplicateCount, + durationMs: duration, + timestamp: new Date().toISOString(), + receipt, + }); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + + // Record failed replay attempt + void recordAdminAuditLog(req, 'events.replay.manual.failed', 500, { + error: errorMessage, + timestamp: new Date().toISOString(), + }); + + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: errorMessage, + }); + } +}); + +/** + * POST /admin/withdrawal-limits/override + * Grants a temporary admin override for a wallet's daily withdrawal limit. + * Requires super-admin API key. + */ +router.post('/admin/withdrawal-limits/override', validateApiKey, validate({ body: WithdrawalLimitOverrideSchema }), async (req: Request, res: Response) => { + const walletAddress = typeof req.body?.walletAddress === 'string' ? req.body.walletAddress.trim() : ''; + const reason = typeof req.body?.reason === 'string' ? req.body.reason.trim() : ''; + const ttlSeconds = + typeof req.body?.ttlSeconds === 'number' && req.body.ttlSeconds > 0 + ? req.body.ttlSeconds + : 3600; + + if (!walletAddress || !reason) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'walletAddress and reason are required', + }); + return; + } + + if (!hasRequiredApiKeyRole(req, 'super-admin')) { + res.status(403).json({ + error: 'Forbidden', + status: 403, + message: 'Super-admin role is required to override withdrawal limits', + }); + return; + } + + const actor = resolveActingAdminAddress(req); + const override = setWithdrawalLimitOverride(walletAddress, reason, actor, ttlSeconds); + + await recordAdminAuditLog(req, 'withdrawal.limit.override.grant', 201, { + walletAddress: override.wallet, + reason: override.reason, + expiresAt: override.expiresAt, + actor, + }); + + res.status(201).json({ override }); +}); + +/** + * GET /admin/withdrawal-limits/audit + * Lists recent blocked and overridden withdrawal attempts. + */ +router.get('/admin/withdrawal-limits/audit', validateApiKey, (req: Request, res: Response) => { + const limit = parseLimited(req.query.limit, 50, 1, 200); + const windowed = listWithdrawalLimitAuditEntries(limit + 1); + const { data, hasNextPage } = paginateByLimit(windowed, limit); + + sendStandardListEnvelope(res, { + data, + limit, + hasNextPage, + extras: { entries: data }, + }); +}); + +/** + * GET /admin/emails/queue + * Lists queued outbound emails, optionally filtered by status. + */ +router.get('/admin/emails/queue', validateApiKey, async (req: Request, res: Response) => { + const status = typeof req.query.status === 'string' ? req.query.status : undefined; + const emails = await emailQueueService.getEmailQueue(status); + + res.status(200).json({ + emails, + count: emails.length, + }); +}); + +/** + * POST /admin/emails/replay/:id + * Requeues a failed/dead-letter email for another send attempt. + */ +router.post('/admin/emails/replay/:id', validateApiKey, async (req: Request, res: Response) => { + try { + const email = await emailQueueService.replayEmail(req.params.id); + res.status(200).json({ + message: 'Email requeued successfully', + email, + }); + } catch { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Email queue item not found', + }); + } +}); + +// ─── Allowlist Admin Endpoints (Issue #375) ────────────────────────────────── + +/** + * POST /admin/allowlist/add + * Adds a wallet address to the private beta allowlist. + * Requires API key authentication. + * Body: { "walletAddress": "G..." } + */ +router.post('/admin/allowlist/add', validateApiKey, validate({ body: AllowlistWalletBodySchema }), async (req: Request, res: Response) => { + const { walletAddress } = req.body; + const added = addAddress(walletAddress); + const actor = resolveActingAdminAddress(req); + + const receipt = await generateAdminReceipt({ + action: 'allowlist.add', + actor, + input: { walletAddress }, + resultingState: { + added, + totalCount: allowlistSize(), + }, + }); + + void recordAdminAuditLog(req, 'allowlist.add', added ? 201 : 200, { + walletAddress, + added, + actor, + receiptId: receipt.id, + }); + + res.status(added ? 201 : 200).json({ + message: added ? 'Wallet added to allowlist' : 'Wallet already in allowlist', + walletAddress: walletAddress.trim().toUpperCase(), + count: allowlistSize(), + receipt, + }); +}); + +/** + * DELETE /admin/allowlist/remove + * Removes a wallet address from the private beta allowlist. + * Requires API key authentication. + * Body: { "walletAddress": "G..." } + */ +router.delete('/admin/allowlist/remove', validateApiKey, validate({ body: AllowlistWalletBodySchema }), async (req: Request, res: Response) => { + const { walletAddress } = req.body; + const removed = removeAddress(walletAddress); + if (!removed) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Wallet address not found in allowlist', + }); + return; + } + + const actor = resolveActingAdminAddress(req); + const receipt = await generateAdminReceipt({ + action: 'allowlist.remove', + actor, + input: { walletAddress }, + resultingState: { + removed: true, + totalCount: allowlistSize(), + }, + }); + + void recordAdminAuditLog(req, 'allowlist.remove', 200, { + walletAddress, + actor, + receiptId: receipt.id, + }); + + res.json({ + message: 'Wallet removed from allowlist', + walletAddress: walletAddress.trim().toUpperCase(), + count: allowlistSize(), + receipt, + }); +}); + +/** + * GET /admin/allowlist + * Lists all wallet addresses on the allowlist. + * Requires API key authentication. + */ +router.get('/admin/allowlist', validateApiKey, (_req: Request, res: Response) => { + res.json({ + addresses: listAddresses(), + count: allowlistSize(), + enabled: process.env.ALLOWLIST_ENABLED !== 'false', + }); +}); + +/** + * GET /admin/wallet-aliases + * Lists persisted wallet alias identity groups. + * Requires API key authentication. + */ +router.get('/admin/wallet-aliases', validateApiKey, async (_req: Request, res: Response) => { + const groups = await walletAliasMappingService.listIdentityLinks(); + res.json({ + groups, + count: groups.length, + }); +}); + +/** + * DELETE /admin/wallet-aliases/:canonicalId + * Deletes a canonical wallet alias group and all linked aliases. + * Requires API key authentication. + */ +router.delete('/admin/wallet-aliases/:canonicalId', validateApiKey, async (req: Request, res: Response) => { + const { canonicalId } = req.params; + const existing = await walletAliasMappingService.getIdentityLinks(canonicalId); + + if (!existing) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Canonical identity not found', + }); + return; + } + + const deleted = await walletAliasMappingService.deleteIdentityGroup(canonicalId); + if (!deleted) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Canonical identity not found', + }); + return; + } + + const actor = resolveActingAdminAddress(req); + void recordAdminAuditLog(req, 'wallet-alias.delete', 200, { + actor, + canonicalId, + aliases: existing.aliases, + sources: existing.sources, + }); + + res.status(200).json({ + message: 'Wallet alias group deleted', + canonicalId, + aliasesDeleted: existing.aliases.length, + sources: existing.sources, + }); +}); + +/** + * POST /admin/impersonate/sessions - start a time-bounded impersonation session + * Requires super-admin API key. + */ +router.post('/admin/impersonate/sessions', validateApiKey, validate({ body: ImpersonationSessionBodySchema }), async (req: Request, res: Response) => { + const actingAdminAddress = resolveActingAdminAddress(req); + const { actor, apiKeyHash, ipAddress, userAgent } = resolveImpersonationSessionContext(req); + const targetWallet = typeof req.body?.targetWallet === 'string' ? req.body.targetWallet.trim() : ''; + const reason = typeof req.body?.reason === 'string' ? req.body.reason.trim() : ''; + + req.adminAuditActor = actingAdminAddress; + req.adminAuditMetadata = { + actingAdminAddress, + adminRole: req.authApiKeyRole || 'admin', + targetWallet: targetWallet || 'unknown', + impersonation: true, + }; + + if (!targetWallet || !reason) { + req.adminAuditAction = 'admin.impersonate.session.invalid'; + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'targetWallet and reason are required', + }); + return; + } + + if (!hasRequiredApiKeyRole(req, 'super-admin')) { + req.adminAuditAction = 'admin.impersonate.session.denied'; + res.status(403).json({ + error: 'Forbidden', + status: 403, + message: 'Super-admin role is required for impersonation sessions', + }); + return; + } + + try { + const session = await startImpersonationSession({ + actor: actingAdminAddress, + apiKeyHash, + targetWallet, + reason, + ipAddress, + userAgent, + }); + + req.adminAuditAction = 'admin.impersonate.session.started'; + req.adminAuditMetadata = { + ...req.adminAuditMetadata, + sessionId: session.id, + expiresAt: session.expiresAt, + }; + + res.status(201).json({ session }); + } catch (error) { + req.adminAuditAction = 'admin.impersonate.session.failed'; + req.adminAuditMetadata = { + ...req.adminAuditMetadata, + error: error instanceof Error ? error.message : String(error), + }; + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: 'Failed to start impersonation session', + }); + } +}); + +/** + * GET /admin/impersonate/sessions - list active and historical impersonation sessions + * Requires super-admin API key. + */ +router.get('/admin/impersonate/sessions', validateApiKey, async (req: Request, res: Response) => { + const actingAdminAddress = resolveActingAdminAddress(req); + + req.adminAuditActor = actingAdminAddress; + req.adminAuditMetadata = { + actingAdminAddress, + adminRole: req.authApiKeyRole || 'admin', + impersonation: true, + }; + + if (!hasRequiredApiKeyRole(req, 'super-admin')) { + req.adminAuditAction = 'admin.impersonate.session.list.denied'; + res.status(403).json({ + error: 'Forbidden', + status: 403, + message: 'Super-admin role is required to list impersonation sessions', + }); + return; + } + + const statusRaw = typeof req.query.status === 'string' ? req.query.status : 'all'; + const status = + statusRaw === 'active' || statusRaw === 'ended' || statusRaw === 'expired' ? statusRaw : 'all'; + const actor = typeof req.query.actor === 'string' ? req.query.actor : undefined; + const targetWallet = typeof req.query.targetWallet === 'string' ? req.query.targetWallet : undefined; + const limit = parseLimited(req.query.limit, 50, 1, 200); + + try { + const sessions = await listImpersonationSessions({ + status, + actor, + targetWallet, + limit: limit + 1, + }); + const { data, hasNextPage } = paginateByLimit(sessions, limit); + + req.adminAuditAction = 'admin.impersonate.session.list'; + sendStandardListEnvelope(res, { + data, + limit, + hasNextPage, + extras: { + sessions: data, + count: data.length, + }, + }); + } catch (error) { + req.adminAuditAction = 'admin.impersonate.session.list.failed'; + req.adminAuditMetadata = { + ...req.adminAuditMetadata, + error: error instanceof Error ? error.message : String(error), + }; + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: 'Failed to list impersonation sessions', + }); + } +}); + +/** + * DELETE /admin/impersonate/sessions/:id - end an active impersonation session + * Requires super-admin API key. + */ +router.delete('/admin/impersonate/sessions/:id', validateApiKey, async (req: Request, res: Response) => { + const actingAdminAddress = resolveActingAdminAddress(req); + const sessionId = String(req.params.id || '').trim(); + + req.adminAuditActor = actingAdminAddress; + req.adminAuditMetadata = { + actingAdminAddress, + adminRole: req.authApiKeyRole || 'admin', + sessionId: sessionId || 'unknown', + impersonation: true, + }; + + if (!sessionId) { + req.adminAuditAction = 'admin.impersonate.session.end.invalid'; + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'session id is required', + }); + return; + } + + if (!hasRequiredApiKeyRole(req, 'super-admin')) { + req.adminAuditAction = 'admin.impersonate.session.end.denied'; + res.status(403).json({ + error: 'Forbidden', + status: 403, + message: 'Super-admin role is required to end impersonation sessions', + }); + return; + } + + try { + const session = await endImpersonationSession(sessionId, actingAdminAddress); + if (!session) { + req.adminAuditAction = 'admin.impersonate.session.end.not_found'; + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Impersonation session not found or already expired', + }); + return; + } + + req.adminAuditAction = 'admin.impersonate.session.ended'; + res.status(200).json({ session }); + } catch (error) { + req.adminAuditAction = 'admin.impersonate.session.end.failed'; + req.adminAuditMetadata = { + ...req.adminAuditMetadata, + error: error instanceof Error ? error.message : String(error), + }; + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: 'Failed to end impersonation session', + }); + } +}); + +/** + * GET /admin/impersonate/:wallet - inspect vault state as a specific wallet + * Requires super-admin API key and a valid non-expired impersonation session. + */ +router.get('/admin/impersonate/:wallet', validateApiKey, async (req: Request, res: Response) => { + const wallet = String(req.params.wallet || '').trim(); + const actingAdminAddress = resolveActingAdminAddress(req); + const sessionId = String(req.get('x-impersonation-session-id') || '').trim(); + + req.adminAuditActor = actingAdminAddress; + req.adminAuditMetadata = { + actingAdminAddress, + adminRole: req.authApiKeyRole || 'admin', + targetWallet: wallet || 'unknown', + sessionId: sessionId || undefined, + impersonation: true, + }; + + if (!wallet) { + req.adminAuditAction = 'admin.impersonate.invalid'; + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'wallet path parameter is required', + }); + return; + } + + if (!hasRequiredApiKeyRole(req, 'super-admin')) { + req.adminAuditAction = 'admin.impersonate.denied'; + res.status(403).json({ + error: 'Forbidden', + status: 403, + message: 'Super-admin role is required for impersonation', + }); + return; + } + + if (!sessionId && process.env.IMPERSONATION_SESSION_STORAGE) { + req.adminAuditAction = 'admin.impersonate.session.required'; + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'x-impersonation-session-id header is required', + }); + return; + } + + try { + let activeSession: ImpersonationSessionRecord | undefined; + + if (sessionId) { + const validation = await validateImpersonationSession(sessionId, wallet, actingAdminAddress); + if (!validation.ok) { + const statusCode = validation.reason === 'not_found' ? 404 : 403; + req.adminAuditAction = + validation.reason === 'expired' + ? 'admin.impersonate.session.expired' + : validation.reason === 'ended' + ? 'admin.impersonate.session.ended' + : validation.reason === 'wallet_mismatch' + ? 'admin.impersonate.session.wallet_mismatch' + : validation.reason === 'actor_mismatch' + ? 'admin.impersonate.session.actor_mismatch' + : 'admin.impersonate.session.invalid'; + req.adminAuditMetadata = { + ...req.adminAuditMetadata, + validationReason: validation.reason, + }; + res.status(statusCode).json({ + error: statusCode === 404 ? 'Not Found' : 'Forbidden', + status: statusCode, + message: + validation.reason === 'expired' + ? 'Impersonation session has expired' + : validation.reason === 'ended' + ? 'Impersonation session has ended' + : validation.reason === 'wallet_mismatch' + ? 'Session wallet does not match target wallet' + : validation.reason === 'actor_mismatch' + ? 'Session actor does not match requesting admin' + : 'Impersonation session is invalid', + }); + return; + } + activeSession = validation.session; + } + + req.adminAuditAction = 'admin.impersonate'; + + const vaultState = await buildImpersonatedVaultState(wallet); + res.status(200).json({ + ...vaultState, + impersonationSession: activeSession + ? { + id: activeSession.id, + reason: activeSession.reason, + startedAt: activeSession.startedAt, + expiresAt: activeSession.expiresAt, + } + : undefined, + }); + } catch (error) { + req.adminAuditAction = 'admin.impersonate.failed'; + req.adminAuditMetadata = { + ...req.adminAuditMetadata, + error: error instanceof Error ? error.message : String(error), + }; + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: 'Failed to build impersonated vault state', + }); + } +}); + +router.get('/admin/receipts', validateApiKey, async (req: Request, res: Response) => { + const action = typeof req.query.action === 'string' ? req.query.action : undefined; + const actor = typeof req.query.actor === 'string' ? req.query.actor : undefined; + const limit = parseLimited(req.query.limit, 50, 1, 200); + + try { + const receipts = await listAdminReceipts({ action, actor, limit: limit + 1 }); + const { data, hasNextPage } = paginateByLimit(receipts, limit); + + sendStandardListEnvelope(res, { + data, + limit, + hasNextPage, + extras: { + receipts: data, + count: data.length, + }, + }); + } catch (err) { + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: err instanceof Error ? err.message : String(err), + }); + } +}); + +/** + * GET /admin/receipts/:id + * Retrieves a specific admin action receipt. + * Requires API key authentication. + */ +router.get('/admin/receipts/:id', validateApiKey, async (req: Request, res: Response) => { + try { + const receipt = await getAdminReceipt(req.params.id); + if (!receipt) { + res.status(404).json({ error: 'Receipt not found' }); + return; + } + res.json(receipt); + } catch (err) { + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: err instanceof Error ? err.message : String(err), + }); + } +}); + +/** + * GET /admin/receipts/:id/verify + * Verifies the integrity of an admin action receipt. + * Requires API key authentication. + */ +router.get('/admin/receipts/:id/verify', validateApiKey, async (req: Request, res: Response) => { + try { + const receipt = await getAdminReceipt(req.params.id); + if (!receipt) { + res.status(404).json({ error: 'Receipt not found' }); + return; + } + + const isValid = verifyReceiptSignature(receipt); + res.json({ + id: receipt.id, + isValid, + timestamp: new Date().toISOString(), + }); + } catch (err) { + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: err instanceof Error ? err.message : String(err), + }); + } +}); + +/** + * POST /admin/api-keys/register - Register a new API key + * Requires API key authentication (for boostrapping, requires special permission) + */ +router.post('/admin/api-keys/register', validateApiKey, validate({ body: ApiKeyRegisterSchema }), async (req: Request, res: Response) => { + const { key, role: requestedRole } = req.body; + if (!key || typeof key !== 'string' || !key.trim()) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'Missing key in request body', + }); + return; + } + + const role = normalizeApiKeyRole(requestedRole) || 'admin'; + if (role === 'super-admin' && !hasRequiredApiKeyRole(req, 'super-admin')) { + res.status(403).json({ + error: 'Forbidden', + status: 403, + message: 'Super-admin role is required to register super-admin API keys', + }); + return; + } + + const normalizedKey = key.trim(); + const hash = registerApiKey(normalizedKey, { role }); + + try { + await recordApiKeyAuditEvent({ + actor: resolveApiKeyAuditActor(req), + action: API_KEY_AUDIT_ACTIONS.created, + keyFingerprint: getApiKeyFingerprintFromHash(hash), + }); + + res.json({ + message: 'API key registered', + hash, + fingerprint: getApiKeyFingerprintFromHash(hash), + role, + created: new Date().toISOString(), + }); + } catch (error) { + revokeApiKey(hash); + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: error instanceof Error ? error.message : 'Failed to persist API key audit event', + }); + } +}); + +/** + * POST /admin/api-keys/rotate - Rotate an API key + * Body: { oldHash: string, newKey: string } + * Requires API key authentication. + */ +router.post('/admin/api-keys/rotate', validateApiKey, validate({ body: ApiKeyRotateSchema }), async (req: Request, res: Response) => { + const { oldHash, newKey } = req.body || {}; + if (!isApiKeyHash(oldHash)) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'oldHash must be a valid SHA-256 API key hash', + }); + return; + } + + if (typeof newKey !== 'string' || !newKey.trim()) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'newKey is required', + }); + return; + } + + const previousMetadata = getApiKeyMetadata(oldHash); + if (!previousMetadata) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'API key not found', + }); + return; + } + + const normalizedNewKey = newKey.trim(); + const newHash = rotateApiKey(oldHash, normalizedNewKey); + if (!newHash) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'API key not found', + }); + return; + } + + try { + await recordApiKeyAuditEvent({ + actor: resolveApiKeyAuditActor(req), + action: API_KEY_AUDIT_ACTIONS.rotated, + keyFingerprint: getApiKeyFingerprintFromValue(normalizedNewKey), + }); + + res.status(200).json({ + message: 'API key rotated', + oldFingerprint: getApiKeyFingerprintFromHash(oldHash), + newHash, + newFingerprint: getApiKeyFingerprintFromHash(newHash), + rotatedAt: new Date().toISOString(), + }); + } catch (error) { + revokeApiKey(newHash); + restoreApiKey(oldHash); + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: error instanceof Error ? error.message : 'Failed to persist API key audit event', + }); + } +}); + +/** + * POST /admin/api-keys/revoke - Revoke an API key + * Body: { hash: string } + * Requires API key authentication. + */ +router.post('/admin/api-keys/revoke', validateApiKey, validate({ body: ApiKeyRevokeSchema }), async (req: Request, res: Response) => { + const { hash } = req.body || {}; + if (!isApiKeyHash(hash)) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'hash must be a valid SHA-256 API key hash', + }); + return; + } + + const previousMetadata = getApiKeyMetadata(hash); + if (!previousMetadata) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'API key not found', + }); + return; + } + + revokeApiKey(hash); + + try { + await recordApiKeyAuditEvent({ + actor: resolveApiKeyAuditActor(req), + action: API_KEY_AUDIT_ACTIONS.revoked, + keyFingerprint: getApiKeyFingerprintFromHash(hash), + }); + + res.status(200).json({ + message: 'API key revoked', + fingerprint: getApiKeyFingerprintFromHash(hash), + revokedAt: new Date().toISOString(), + }); + } catch (error) { + restoreApiKey(hash); + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: error instanceof Error ? error.message : 'Failed to persist API key audit event', + }); + } +}); + +/** + * GET /admin/api-keys/audit-events - list API key lifecycle audit events + * Supports ?action=created|rotated|revoked&from=&to=&limit=N + */ +router.get('/admin/api-keys/audit-events', validateApiKey, async (req: Request, res: Response) => { + const rawAction = typeof req.query.action === 'string' ? req.query.action : undefined; + const action = + rawAction === API_KEY_AUDIT_ACTIONS.created || + rawAction === API_KEY_AUDIT_ACTIONS.rotated || + rawAction === API_KEY_AUDIT_ACTIONS.revoked + ? rawAction + : undefined; + + if (rawAction && !action) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'action must be one of: created, rotated, revoked', + }); + return; + } + + try { + const range = parseUtcDateRange({ + from: typeof req.query.from === 'string' ? req.query.from : undefined, + to: typeof req.query.to === 'string' ? req.query.to : undefined, + }); + const limit = parseLimited(req.query.limit, 50, 1, 200); + const events = await listApiKeyAuditEvents({ + action, + start: range.start, + end: range.end, + limit: limit + 1, + }); + const { data, hasNextPage } = paginateByLimit(events, limit); + + sendStandardListEnvelope(res, { + data, + limit, + hasNextPage, + extras: { + events: data, + meta: { + count: data.length, + limit, + filters: { + action: action || null, + from: range.start || null, + to: range.end || null, + }, + timestamp: new Date().toISOString(), + }, + }, + }); + } catch (error) { + if (error instanceof DateRangeParseError) { + res.status(error.status).json({ + error: 'Bad Request', + status: error.status, + message: error.message, + }); + return; + } + + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: error instanceof Error ? error.message : 'Failed to read API key audit events', + }); + } +}); + +/** + * GET /admin/audit/logs - list admin activity logs + */ +router.get('/admin/audit/logs', validateApiKey, validate({ query: AuditLogQuerySchema }), (req: Request, res: Response) => { + const statusCode = req.query.statusCode ? parseInt(String(req.query.statusCode), 10) : undefined; + const limit = parseLimited(req.query.limit, 100, 1, 500); + const page = parseLimited(req.query.page, 1, 1, 1000000); + const offset = (page - 1) * limit; + const filters = { + actor: req.query.actor ? String(req.query.actor) : undefined, + action: req.query.action ? String(req.query.action) : undefined, + path: req.query.path ? String(req.query.path) : undefined, + statusCode, + from: req.query.from ? String(req.query.from) : undefined, + to: req.query.to ? String(req.query.to) : undefined, + }; + + const logs = getAuditLogs({ ...filters, limit: limit + 1, offset }); + const { data, hasNextPage } = paginateByLimit(logs, limit); + + sendStandardListEnvelope(res, { + data, + limit, + hasNextPage, + extras: { + logs: data, + page, + total: countAuditLogs(filters), + metrics: getAuditLogMetrics(), + }, + }); +}); + +/** + * GET /admin/audit-logs - list admin audit entries (Issue #253) + */ +router.get('/admin/audit-logs', validateApiKey, validate({ query: AuditLogQuerySchema }), async (req: Request, res: Response) => { + const limit = parseLimited(req.query.limit, 50, 1, 200); + const page = parseLimited(req.query.page, 1, 1, 1000000); + const offset = (page - 1) * limit; + const statusValue = req.query.statusCode ?? req.query.status; + const statusCode = statusValue + ? parseLimited(statusValue, 0, 100, 599) + : undefined; + const filters = { + action: typeof req.query.action === 'string' + ? req.query.action + : typeof req.query.type === 'string' ? req.query.type : undefined, + actor: typeof req.query.actor === 'string' ? req.query.actor : undefined, + path: typeof req.query.path === 'string' ? req.query.path : undefined, + statusCode, + from: typeof req.query.from === 'string' ? req.query.from : undefined, + to: typeof req.query.to === 'string' ? req.query.to : undefined, + }; + + const rows = getAuditLogs({ ...filters, limit: limit + 1, offset }); + const { data, hasNextPage } = paginateByLimit(rows, limit); + + void recordAdminAuditLog(req, 'audit-logs.read', 200, { + limit, + returned: data.length, + }); + + sendStandardListEnvelope(res, { + data, + limit, + hasNextPage, + extras: { + meta: { + count: data.length, + total: countAuditLogs(filters), + page, + limit, + timestamp: new Date().toISOString(), + }, + }, + }); +}); + +/** + * GET /admin/prisma/config - operational prisma runtime settings (Issue #254) + */ +router.get('/admin/prisma/config', validateApiKey, (_req: Request, res: Response) => { + res.status(200).json({ + config: getPrismaConfig(), + timestamp: new Date().toISOString(), + }); +}); + +/** + * GET /admin/jobs/monitor - structured JSON for background jobs/webhook workers + */ +router.get('/admin/jobs/monitor', validateApiKey, (_req: Request, res: Response) => { + res.status(200).json({ + jobHealth: getJobHealthStatus(), + jobs: getJobMetrics(), + webhooks: getWebhookDeliveryMetrics(), + timestamp: new Date().toISOString(), + }); +}); + +/** + * GET /admin/jobs/metrics - JSON metrics dashboard for background jobs (Issue #255) + */ +router.get('/admin/jobs/metrics', validateApiKey, (req: Request, res: Response) => { + const metrics = getJobMetrics(); + const summary = { + totalDeadLetters: metrics.totalDeadLetters, + recurringFailureJobs: Object.keys(metrics.recurringFailures), + jobHealth: getJobHealthStatus(), + activeJobs: Object.values(metrics.runtime).filter((job) => job.inFlight > 0).length, + }; + + void recordAdminAuditLog(req, 'jobs.metrics.read', 200); + + res.json({ + summary, + metrics, + prisma: getPrismaRuntimeConfig(), + timestamp: new Date().toISOString(), + }); +}); + +/** + * GET /admin/governance/snapshots - list historical governance snapshots + */ +router.get('/admin/governance/snapshots', validateApiKey, async (req: Request, res: Response) => { + const limit = parseLimited(req.query.limit, 50, 1, 500); + const offset = parseLimited(req.query.offset, 0, 0, 10_000); + const type = typeof req.query.type === 'string' ? req.query.type : undefined; + const start = typeof req.query.start === 'string' ? req.query.start : undefined; + const end = typeof req.query.end === 'string' ? req.query.end : undefined; + + const result = await listGovernanceSnapshots({ + type: type as 'reconciliation' | 'config-change' | 'export-manifest' | undefined, + start, + end, + limit, + offset, + }); + + res.status(200).json({ + data: result.data, + total: result.total, + limit, + offset, + timestamp: new Date().toISOString(), + }); +}); + +/** + * POST /admin/governance/snapshots/export - export historical governance snapshots + */ +router.post('/admin/governance/snapshots/export', validateApiKey, validate({ body: GovernanceSnapshotExportSchema }), async (req: Request, res: Response) => { + const requester = resolveActingAdminAddress(req); + const types = Array.isArray(req.body?.types) + ? (req.body.types as string[]) + : undefined; + const start = typeof req.body?.start === 'string' ? req.body.start : undefined; + const end = typeof req.body?.end === 'string' ? req.body.end : undefined; + const limit = parseLimited(req.body?.limit, 500, 1, 5000); + + const { manifest, rows } = await exportGovernanceSnapshots({ + requester, + types: types as ('reconciliation' | 'config-change' | 'export-manifest')[] | undefined, + start, + end, + limit, + }); + + res.status(201).json({ + message: 'Governance snapshot export generated', + rowCount: rows.length, + manifest, + timestamp: new Date().toISOString(), + }); +}); + +/** + * GET /admin/jobs/dashboard - comprehensive HTML dashboard for operators to monitor jobs and DLQ + */ +router.get('/admin/jobs/dashboard', validateApiKey, async (_req: Request, res: Response) => { + const jobMetrics = getJobMetrics(); + const webhookMetrics = getWebhookDeliveryMetrics(); + const dlqRecords = listDeadLetters({ status: 'dead-letter', limit: 10 }); + + const jobsByStatus = { + 'dead-letter': dlqRecords.records.filter((r) => r.status === 'dead-letter').length, + 'requeued': dlqRecords.records.filter((r) => r.status === 'requeued').length, + 'resolved': dlqRecords.records.filter((r) => r.status === 'resolved').length, + 'discarded': dlqRecords.records.filter((r) => r.status === 'discarded').length, + }; + + const recurringFailures = Object.entries(jobMetrics.recurringFailures || {}) + .map(([jobName, count]) => `${jobName} (${count} failures)`) + .join(', ') || 'None'; + + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.status(200).send(` + + + + + + YieldVault Job Dashboard + + + +
+

🎯 Background Job Monitoring Dashboard

+ +
+
+
Overall Health
+
${getJobHealthStatus().toUpperCase()}
+
+
+
Dead-Letter Records
+
${jobMetrics.totalDeadLetters}
+
${dlqRecords.total} total (pending: ${jobsByStatus['dead-letter']})
+
+
+
Webhook Endpoints
+
${webhookMetrics.totalEndpoints}
+
Failed: ${webhookMetrics.failed}
+
+
+
Recurring Failures
+
${Object.keys(jobMetrics.recurringFailures || {}).length}
+
${recurringFailures}
+
+
+ + ${jobMetrics.totalDeadLetters > 0 ? ` +
+ ⚠️ ${jobMetrics.totalDeadLetters} dead-letter records pending. + Review and retry failed jobs or mark them as resolved. +
+ ` : ''} + +

📊 Job Runtime Metrics

+
+ + + + + + + + + + + + + + ${Object.entries(jobMetrics.runtime || {}) + .map(([jobName, metrics]) => ` + + + + + + + + + + `) + .join('')} + +
Job NameTotal RunsSuccessfulFailedIn FlightAvg Duration (ms)Last Run
${jobName}${metrics.totalRuns}${metrics.successfulRuns}${metrics.failedRuns}${metrics.inFlight}${metrics.averageDurationMs}${metrics.lastRunAt ? new Date(metrics.lastRunAt).toLocaleString() : 'Never'}
+
+ +

🔄 Recent Dead-Letter Records

+
+ ${dlqRecords.records.length > 0 ? ` + + + + + + + + + + + + + + ${dlqRecords.records.slice(0, 10).map((record) => ` + + + + + + + + + + `).join('')} + +
IDJobStatusAttemptsErrorFailed AtActions
${record.id}${record.jobName}${record.status}${record.attempts}${record.error}${new Date(record.failedAt).toLocaleString()} + View | + Retry +
+ ` : ` +

No dead-letter records (great job!)

+ `} +
+ +

📡 API Endpoints

+
+

Use these endpoints to manage dead-letter queue records:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodEndpointDescription
GET/admin/jobs/dead-lettersList all dead-letter records with optional filters
GET/admin/jobs/dead-letters/:idRetrieve a specific dead-letter record
POST/admin/jobs/dead-letters/:id/retryRetry a failed job (supports ?dryRun=true)
POST/admin/jobs/dead-letters/:id/resolveMark a job as manually resolved with notes
DELETE/admin/jobs/dead-letters/:idDiscard a dead-letter record
POST/admin/jobs/dead-letters/bulk-retryBulk retry multiple records (body: {ids: []})
POST/admin/jobs/dead-letters/bulk-discardBulk discard multiple records (body: {ids: []})
POST/admin/jobs/dead-letters/processTrigger batch processing worker (body: {batchSize?: 10})
+
+ +

📋 Full Metrics JSON

+
+ Click to expand full metrics +
${JSON.stringify(jobMetrics, null, 2)}
+
+ +
+

+ Last updated: ${new Date().toISOString()} | + JSON API +

+
+ + + `); +}); + +// ─── Dead-Letter Queue Admin Endpoints ───────────────────────────────────── + +/** + * GET /admin/jobs/metrics + * Returns background job runtime and governance metrics. + */ +router.get('/admin/jobs/metrics', validateApiKey, (_req: Request, res: Response) => { + const metrics = getJobMetrics(); + const health = getJobHealthStatus(); + + res.status(200).json({ + summary: { health }, + metrics, + prisma: getPrismaConfig(), + timestamp: new Date().toISOString(), + }); +}); + +/** + * GET /admin/jobs/dead-letters + * Lists dead-letter queue records with optional filtering (jobName, status, limit, offset). + * Requires API key authentication. + */ +router.get('/admin/jobs/dead-letters', validateApiKey, (req: Request, res: Response) => { + const jobName = typeof req.query.jobName === 'string' ? (req.query.jobName as JobName) : undefined; + const status = typeof req.query.status === 'string' ? (req.query.status as DeadLetterStatus) : undefined; + const limit = parseLimited(req.query.limit, 50, 1, 500); + const offset = parseLimited(req.query.offset, 0, 0, 10000); + + const { records, total } = listDeadLetters({ jobName, status, limit, offset }); + + res.status(200).json({ + data: records, + total, + limit, + offset, + timestamp: new Date().toISOString(), + }); +}); + +/** + * GET /admin/jobs/dead-letters/:id + * Retrieves a single dead-letter record by ID. + * Requires API key authentication. + */ +router.get('/admin/jobs/dead-letters/:id', validateApiKey, (req: Request, res: Response) => { + const record = getDeadLetterRecord(req.params.id); + if (!record) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Dead-letter record not found', + }); + return; + } + + res.status(200).json({ + record, + timestamp: new Date().toISOString(), + }); +}); + +/** + * POST /admin/jobs/dead-letters/:id/retry + * Retries a single dead-letter queue entry. + * Supports dryRun flag. + * Requires API key authentication. + */ +router.post('/admin/jobs/dead-letters/:id/retry', validateApiKey, async (req: Request, res: Response) => { + const record = getDeadLetterRecord(req.params.id); + if (!record) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Dead-letter record not found', + }); + return; + } + + const actor = resolveActingAdminAddress(req); + + if (isDryRunRequest(req)) { + void recordAdminAuditLog(req, 'jobs.dlq.retry.dry_run', 200, { + id: req.params.id, + jobName: record.jobName, + actor, + }); + + res.status(200).json({ + dryRun: true, + message: `Dead-letter record '${req.params.id}' (${record.jobName}) would be retried`, + record, + wouldRetry: true, + timestamp: new Date().toISOString(), + }); + return; + } + + const outcome = await retryDeadLetter(req.params.id); + void recordAdminAuditLog(req, 'jobs.dlq.retry', outcome.success ? 200 : 500, { + id: req.params.id, + jobName: record.jobName, + success: outcome.success, + actor, + error: outcome.error, + }); + + if (!outcome.success) { + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: outcome.error || 'Failed to retry dead-letter record', + record: outcome.record, + }); + return; + } + + res.status(200).json({ + message: 'Dead-letter record retried successfully', + result: outcome.result, + record: outcome.record, + timestamp: new Date().toISOString(), + }); +}); + +/** + * POST /admin/jobs/dead-letters/:id/resolve + * Marks a dead-letter entry as manually resolved. + * Body: { notes?: string } + * Requires API key authentication. + */ +router.post('/admin/jobs/dead-letters/:id/resolve', validateApiKey, validate({ params: IdParamSchema, body: DeadLetterResolveSchema }), async (req: Request, res: Response) => { + const actor = resolveActingAdminAddress(req); + const notes = typeof req.body?.notes === 'string' ? req.body.notes : undefined; + + const record = resolveDeadLetter(req.params.id, actor, notes); + if (!record) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Dead-letter record not found', + }); + return; + } + + void recordAdminAuditLog(req, 'jobs.dlq.resolve', 200, { + id: req.params.id, + jobName: record.jobName, + actor, + notes, + }); + + res.status(200).json({ + message: 'Dead-letter record resolved successfully', + record, + timestamp: new Date().toISOString(), + }); +}); + +/** + * DELETE /admin/jobs/dead-letters/:id + * Discards a dead-letter record from the queue. + * Requires API key authentication. + */ +router.delete('/admin/jobs/dead-letters/:id', validateApiKey, (req: Request, res: Response) => { + const actor = resolveActingAdminAddress(req); + + if (isDryRunRequest(req)) { + const existing = getDeadLetterRecord(req.params.id); + res.status(existing ? 200 : 404).json({ + dryRun: true, + message: existing + ? `Dead-letter record '${req.params.id}' would be discarded` + : `Dead-letter record '${req.params.id}' not found`, + id: req.params.id, + wouldDiscard: Boolean(existing), + timestamp: new Date().toISOString(), + }); + return; + } + + const record = discardDeadLetter(req.params.id, actor); + if (!record) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Dead-letter record not found', + }); + return; + } + + void recordAdminAuditLog(req, 'jobs.dlq.discard', 200, { + id: req.params.id, + jobName: record.jobName, + actor, + }); + + res.status(200).json({ + message: 'Dead-letter record discarded successfully', + record, + timestamp: new Date().toISOString(), + }); +}); + +/** + * POST /admin/jobs/dead-letters/bulk-retry + * Bulk retries multiple dead-letter records. + * Body: { ids: string[] } + * Requires API key authentication. + */ +router.post('/admin/jobs/dead-letters/bulk-retry', validateApiKey, validate({ body: DeadLetterIdsSchema }), async (req: Request, res: Response) => { + const ids = Array.isArray(req.body?.ids) ? (req.body.ids as string[]) : []; + + if (ids.length === 0) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: '`ids` array must not be empty', + }); + return; + } + + const actor = resolveActingAdminAddress(req); + + if (isDryRunRequest(req)) { + res.status(200).json({ + dryRun: true, + message: `Bulk retry preview for ${ids.length} dead-letter records`, + ids, + wouldRetryCount: ids.length, + timestamp: new Date().toISOString(), + }); + return; + } + + const summary = await bulkRetryDeadLetters(ids); + + void recordAdminAuditLog(req, 'jobs.dlq.bulk_retry', 200, { + requestedCount: ids.length, + retried: summary.retried, + failed: summary.failed, + actor, + }); + + res.status(200).json({ + message: 'Bulk retry completed', + retried: summary.retried, + failed: summary.failed, + results: summary.results, + timestamp: new Date().toISOString(), + }); +}); + +/** + * POST /admin/jobs/dead-letters/bulk-discard + * Bulk discards multiple dead-letter records. + * Body: { ids: string[] } + * Requires API key authentication. + */ +router.post('/admin/jobs/dead-letters/bulk-discard', validateApiKey, validate({ body: DeadLetterIdsSchema }), (req: Request, res: Response) => { + const ids = Array.isArray(req.body?.ids) ? (req.body.ids as string[]) : []; + + if (ids.length === 0) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: '`ids` array must not be empty', + }); + return; + } + + const actor = resolveActingAdminAddress(req); + + if (isDryRunRequest(req)) { + res.status(200).json({ + dryRun: true, + message: `Bulk discard preview for ${ids.length} dead-letter records`, + ids, + wouldDiscardCount: ids.length, + timestamp: new Date().toISOString(), + }); + return; + } + + const summary = bulkDiscardDeadLetters(ids); + + void recordAdminAuditLog(req, 'jobs.dlq.bulk_discard', 200, { + requestedCount: ids.length, + discardedCount: summary.discarded, + actor, + }); + + res.status(200).json({ + message: 'Bulk discard completed', + discardedCount: summary.discarded, + ids: summary.ids, + timestamp: new Date().toISOString(), + }); +}); + +/** + * POST /admin/jobs/dead-letters/process + * Triggers batch processing worker for dead-letter records. + * Body: { batchSize?: number } + * Requires API key authentication. + */ +router.post('/admin/jobs/dead-letters/process', validateApiKey, validate({ body: DeadLetterProcessSchema }), async (req: Request, res: Response) => { + const batchSize = typeof req.body?.batchSize === 'number' && req.body.batchSize > 0 ? req.body.batchSize : 10; + const actor = resolveActingAdminAddress(req); + + if (isDryRunRequest(req)) { + res.status(200).json({ + dryRun: true, + message: `DLQ processor dry-run preview for batchSize ${batchSize}`, + batchSize, + wouldProcess: true, + timestamp: new Date().toISOString(), + }); + return; + } + + const summary = await processDeadLetterQueue(batchSize); + + void recordAdminAuditLog(req, 'jobs.dlq.process', 200, { + batchSize, + processed: summary.processed, + succeeded: summary.succeeded, + failed: summary.failed, + actor, + }); + + res.status(200).json({ + message: 'DLQ queue processing batch completed', + batchSize, + processed: summary.processed, + succeeded: summary.succeeded, + failed: summary.failed, + timestamp: new Date().toISOString(), + }); +}); + +// ─── Idempotency Admin Endpoints (Issues #457 & #466) ──────────────────────── + +/** + * GET /admin/idempotency/keys + * Lists idempotency keys with metadata. + * Optional query param: ?prefix= to filter keys by prefix. + * Requires API key authentication. + */ +router.get('/admin/idempotency/keys', validateApiKey, (req: Request, res: Response) => { + const prefix = typeof req.query.prefix === 'string' ? req.query.prefix : undefined; + const keys = idempotencyStore.inspectKeys(prefix); + res.status(200).json({ + keys, + count: keys.length, + metrics: idempotencyStore.getMetrics(), + timestamp: new Date().toISOString(), + }); +}); + +/** + * DELETE /admin/idempotency/keys/:key + * Removes a single idempotency key from the store. + * Requires API key authentication. + */ +router.delete('/admin/idempotency/keys/:key', validateApiKey, (req: Request, res: Response) => { + const key = decodeURIComponent(req.params.key); + if (isDryRunRequest(req)) { + const exists = idempotencyStore.inspectKeys().some((entry) => entry.key === key); + res.status(exists ? 200 : 404).json({ + dryRun: true, + message: exists + ? `Idempotency key '${key}' would be deleted` + : `Idempotency key '${key}' not found`, + key, + wouldDelete: exists, + metrics: idempotencyStore.getMetrics(), + timestamp: new Date().toISOString(), + }); + return; + } + + const deleted = idempotencyStore.deleteKey(key); + if (!deleted) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: `Idempotency key '${key}' not found`, + }); + return; + } + res.status(200).json({ + message: `Idempotency key '${key}' deleted`, + metrics: idempotencyStore.getMetrics(), + timestamp: new Date().toISOString(), + }); +}); + +/** + * GET /admin/idempotency/retention/metrics + * Returns idempotency retention policy and sweep metrics. + */ +router.get('/admin/idempotency/retention/metrics', validateApiKey, (_req: Request, res: Response) => { + res.status(200).json({ + metrics: getIdempotencyRetentionMetrics(), + timestamp: new Date().toISOString(), + }); +}); + +/** + * POST /admin/idempotency/retention/cleanup + * Runs a retention sweep for stale idempotency records. + */ +router.post('/admin/idempotency/retention/cleanup', validateApiKey, async (req: Request, res: Response) => { + const dryRun = isDryRunRequest(req); + const result = await pruneStaleIdempotencyRecords(dryRun); + res.status(200).json({ + ...result, + metrics: getIdempotencyRetentionMetrics(), + timestamp: new Date().toISOString(), + }); +}); + +/** + * DELETE /admin/idempotency/keys + * Flushes the entire idempotency store. + * Requires super-admin API key. + */ +router.delete('/admin/idempotency/keys', validateApiKey, (req: Request, res: Response) => { + if (!hasRequiredApiKeyRole(req, 'super-admin')) { + res.status(403).json({ + error: 'Forbidden', + status: 403, + message: 'Super-admin role is required to flush the idempotency store', + }); + return; + } + if (isDryRunRequest(req)) { + const keys = idempotencyStore.inspectKeys(); + res.status(200).json({ + dryRun: true, + message: 'Idempotency store flush dry-run preview', + wouldFlush: true, + keyCount: keys.length, + metrics: idempotencyStore.getMetrics(), + timestamp: new Date().toISOString(), + }); + return; + } + + idempotencyStore.clear(); + res.status(200).json({ + message: 'Idempotency store flushed', + metrics: idempotencyStore.getMetrics(), + timestamp: new Date().toISOString(), + }); +}); + +/** + * GET /admin/idempotency/metrics + * Returns hit/conflict/eviction counters for the idempotency store. + * Requires API key authentication. + */ +router.get('/admin/idempotency/metrics', validateApiKey, (_req: Request, res: Response) => { + res.status(200).json({ + metrics: idempotencyStore.getMetrics(), + timestamp: new Date().toISOString(), + }); +}); + +// ─── Wallet Activity Heatmap Endpoint (Issue #712) ─────────────────────────── + +/** + * GET /admin/analytics/wallet-activity/heatmap + * + * Returns aggregated wallet activity counts bucketed by calendar date for use + * in admin analytics dashboards. Raw wallet records are never exposed; only + * the per-bucket counts and the summary statistics are returned. + * + * Query parameters: + * from ISO-8601 date or datetime (inclusive). Defaults to 30 days ago. + * to ISO-8601 date or datetime (inclusive). Defaults to today. + * granularity day | week | month. Defaults to day. + * walletAddress Optional address to scope results to a single wallet. + * + * Requires API key authentication. + */ +router.get('/admin/analytics/wallet-activity/heatmap', validateApiKey, async (req: Request, res: Response) => { + const granularity = + req.query.granularity === 'week' || req.query.granularity === 'month' + ? (req.query.granularity as 'week' | 'month') + : 'day'; + + let rangeStart: string; + let rangeEnd: string; + + try { + const parsed = parseUtcDateRange({ + from: typeof req.query.from === 'string' ? req.query.from : undefined, + to: typeof req.query.to === 'string' ? req.query.to : undefined, + }); + const defaultStart = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); + const defaultEnd = new Date().toISOString().slice(0, 10); + rangeStart = parsed.start ?? defaultStart; + rangeEnd = parsed.end ?? defaultEnd; + } catch (error) { + if (error instanceof DateRangeParseError) { + res.status(error.status).json({ + error: 'Bad Request', + status: error.status, + message: error.message, + }); + return; + } + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: error instanceof Error ? error.message : 'Failed to parse date range', + }); + return; + } + + const walletAddress = + typeof req.query.walletAddress === 'string' && req.query.walletAddress.trim() + ? normalizeWalletAddress(req.query.walletAddress.trim()) + : undefined; + + try { + const where: Record = { + timestamp: { + gte: new Date(rangeStart + 'T00:00:00.000Z'), + lte: new Date(rangeEnd + 'T23:59:59.999Z'), + }, + }; + + if (walletAddress) { + where.user = walletAddress; + } + + const transactions = await prisma.transaction.findMany({ + where: where as any, + select: { timestamp: true }, + orderBy: { timestamp: 'asc' }, + }); + + const bucketKey = (date: Date, g: 'day' | 'week' | 'month'): string => { + const y = date.getUTCFullYear(); + const m = String(date.getUTCMonth() + 1).padStart(2, '0'); + const d = String(date.getUTCDate()).padStart(2, '0'); + if (g === 'month') return `${y}-${m}`; + if (g === 'week') { + const dayOfWeek = date.getUTCDay(); + const mondayOffset = (dayOfWeek === 0 ? -6 : 1 - dayOfWeek) * 86400000; + const monday = new Date(date.getTime() + mondayOffset); + const wy = monday.getUTCFullYear(); + const wm = String(monday.getUTCMonth() + 1).padStart(2, '0'); + const wd = String(monday.getUTCDate()).padStart(2, '0'); + return `${wy}-${wm}-${wd}`; + } + return `${y}-${m}-${d}`; + }; + + const buckets = new Map(); + for (const tx of transactions) { + const key = bucketKey(new Date(tx.timestamp), granularity); + buckets.set(key, (buckets.get(key) ?? 0) + 1); + } + + const heatmap = Array.from(buckets.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([bucket, count]) => ({ bucket, count })); + + const totalActivity = heatmap.reduce((sum, b) => sum + b.count, 0); + const peakBucket = heatmap.reduce( + (max, b) => (b.count > (max?.count ?? -1) ? b : max), + null as { bucket: string; count: number } | null, + ); + + res.status(200).json({ + heatmap, + summary: { + totalActivity, + bucketCount: heatmap.length, + granularity, + rangeStart, + rangeEnd, + walletAddress: walletAddress ?? null, + peakBucket: peakBucket ?? null, + }, + timestamp: new Date().toISOString(), + }); + } catch (error) { + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: error instanceof Error ? error.message : 'Failed to aggregate wallet activity', + }); + } +}); + +// ─── Write-Ahead Audit Log Endpoints (Issue #707) ─────────────────────────── +/** + * GET /admin/wal/entries + * Lists write-ahead audit log entries with optional filters. + */ +router.get('/admin/wal/entries', validateApiKey, async (req: Request, res: Response) => { + const configType = typeof req.query.configType === 'string' ? req.query.configType : undefined; + const actor = typeof req.query.actor === 'string' ? req.query.actor : undefined; + const status = typeof req.query.status === 'string' ? req.query.status as 'pending' | 'committed' | 'rolled_back' : undefined; + const limit = parseLimited(req.query.limit, 50, 1, 200); + + try { + const entries = await writeAheadAuditLog.list({ configType, actor, status, limit }); + const metrics = await writeAheadAuditLog.getMetrics(); + + res.status(200).json({ + entries, + count: entries.length, + metrics, + timestamp: new Date().toISOString(), + }); + } catch (error) { + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: error instanceof Error ? error.message : 'Failed to list write-ahead audit log entries', + }); + } +}); + +/** + * GET /admin/wal/entries/:id + * Returns a specific write-ahead audit log entry. + */ +router.get('/admin/wal/entries/:id', validateApiKey, async (req: Request, res: Response) => { + try { + const entry = await writeAheadAuditLog.getEntry(req.params.id); + if (!entry) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Write-ahead audit log entry not found', + }); + return; + } + res.status(200).json({ entry }); + } catch (error) { + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: error instanceof Error ? error.message : 'Failed to fetch write-ahead audit log entry', + }); + } +}); + +/** + * GET /admin/wal/metrics + * Returns metrics for the write-ahead audit log. + */ +router.get('/admin/wal/metrics', validateApiKey, async (_req: Request, res: Response) => { + try { + const metrics = await writeAheadAuditLog.getMetrics(); + res.status(200).json({ + metrics, + timestamp: new Date().toISOString(), + }); + } catch (error) { + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: error instanceof Error ? error.message : 'Failed to fetch write-ahead audit log metrics', + }); + } +}); + +/** + * GET /admin/wal/pending + * Returns currently pending (uncommitted) write-ahead entries. + */ +router.get('/admin/wal/pending', validateApiKey, async (_req: Request, res: Response) => { + try { + const pending = await writeAheadAuditLog.getPendingEntries(); + res.status(200).json({ + entries: pending, + count: pending.length, + timestamp: new Date().toISOString(), + }); + } catch (error) { + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: error instanceof Error ? error.message : 'Failed to fetch pending write-ahead audit log entries', + }); + } +}); + +// ─── Scoped Admin Token Endpoints (Issue #723 / #858) ─────────────────────── +// All endpoints are async – store operations now hit Prisma for cluster-wide durability. + +/** + * GET /admin/scoped-tokens/permissions + * Returns the list of valid permissions for scoped tokens. + * Must be registered before /:keyId routes to avoid shadowing. + */ +router.get('/admin/scoped-tokens/permissions', validateApiKey, (_req: Request, res: Response) => { + res.status(200).json({ + permissions: scopedAdminTokenStore.getValidPermissions(), + timestamp: new Date().toISOString(), + }); +}); + +/** + * POST /admin/scoped-tokens + * Creates a new permission-scoped admin token. + * Requires super-admin API key. + * Returns the plaintext secret once; it is never stored. + */ +router.post('/admin/scoped-tokens', validateApiKey, validate({ body: ScopedTokenCreateSchema }), async (req: Request, res: Response) => { + if (!hasRequiredApiKeyRole(req, 'super-admin')) { + res.status(403).json({ error: 'Forbidden', status: 403, message: 'Super-admin role is required to create scoped tokens' }); + return; + } + const { label, permissions, expiresInSeconds } = req.body; + if (typeof label !== 'string' || !label.trim()) { + res.status(400).json({ error: 'Bad Request', status: 400, message: '`label` (string) is required' }); + return; + } + if (!Array.isArray(permissions) || permissions.length === 0) { + res.status(400).json({ error: 'Bad Request', status: 400, message: '`permissions` (non-empty array) is required' }); + return; + } + const actor = resolveActingAdminAddress(req); + try { + const { token, secret } = await scopedAdminTokenStore.create({ + label: label.trim(), + permissions, + expiresInSeconds: typeof expiresInSeconds === 'number' && expiresInSeconds > 0 ? expiresInSeconds : undefined, + createdBy: actor, + }); + void recordAdminAuditLog(req, 'scoped-token.created', 201, { keyId: token.keyId, label: token.label, permissions: token.permissions, actor }); + res.status(201).json({ message: 'Scoped admin token created', keyId: token.keyId, secret, label: token.label, permissions: token.permissions, expiresAt: token.expiresAt, createdAt: token.createdAt }); + } catch (error) { + res.status(400).json({ error: 'Bad Request', status: 400, message: error instanceof Error ? error.message : 'Failed to create scoped token' }); + } +}); + +/** + * GET /admin/scoped-tokens + * Lists all scoped admin tokens (without secrets). + * Requires admin or super-admin API key. + */ +router.get('/admin/scoped-tokens', validateApiKey, async (req: Request, res: Response) => { + const includeRevoked = req.query.includeRevoked === 'true'; + const tokens = await scopedAdminTokenStore.list({ includeRevoked }); + const sanitized = tokens.map(({ hashedSecret: _hs, ...rest }) => rest); + res.status(200).json({ tokens: sanitized, count: sanitized.length, timestamp: new Date().toISOString() }); +}); + +/** + * GET /admin/scoped-tokens/:keyId + * Returns a single token record (without secret). + * Requires admin or super-admin API key. + */ +router.get('/admin/scoped-tokens/:keyId', validateApiKey, async (req: Request, res: Response) => { + const token = await scopedAdminTokenStore.get(req.params.keyId); + if (!token) { + res.status(404).json({ error: 'Not Found', status: 404, message: 'Scoped token not found' }); + return; + } + const { hashedSecret: _hs, ...sanitized } = token; + res.status(200).json({ token: sanitized }); +}); + +/** + * GET /admin/scoped-tokens/:keyId/rotations + * Returns the immutable rotation history for a token (fingerprints only, no old secrets). + * Requires admin or super-admin API key. + */ +router.get('/admin/scoped-tokens/:keyId/rotations', validateApiKey, async (req: Request, res: Response) => { + const token = await scopedAdminTokenStore.get(req.params.keyId); + if (!token) { + res.status(404).json({ error: 'Not Found', status: 404, message: 'Scoped token not found' }); + return; + } + const events = await scopedAdminTokenStore.listRotationEvents(req.params.keyId); + res.status(200).json({ keyId: req.params.keyId, rotations: events, count: events.length, timestamp: new Date().toISOString() }); +}); + +/** + * POST /admin/scoped-tokens/:keyId/rotate + * Rotates the secret and writes an immutable audit event row. + * Requires super-admin API key. + */ +router.post('/admin/scoped-tokens/:keyId/rotate', validateApiKey, async (req: Request, res: Response) => { + if (!hasRequiredApiKeyRole(req, 'super-admin')) { + res.status(403).json({ error: 'Forbidden', status: 403, message: 'Super-admin role is required to rotate scoped tokens' }); + return; + } + const actor = resolveActingAdminAddress(req); + const result = await scopedAdminTokenStore.rotate(req.params.keyId, { rotatedBy: actor }); + if (!result) { + res.status(404).json({ error: 'Not Found', status: 404, message: 'Scoped token not found or already revoked' }); + return; + } + void recordAdminAuditLog(req, 'scoped-token.rotated', 200, { keyId: result.keyId, rotatedAt: result.rotatedAt, actor }); + res.status(200).json({ message: 'Scoped token rotated', keyId: result.keyId, newSecret: result.newSecret, rotatedAt: result.rotatedAt }); +}); + +/** + * POST /admin/scoped-tokens/:keyId/revoke + * Revokes a scoped admin token cluster-wide. + * Requires super-admin API key. + */ +router.post('/admin/scoped-tokens/:keyId/revoke', validateApiKey, async (req: Request, res: Response) => { + if (!hasRequiredApiKeyRole(req, 'super-admin')) { + res.status(403).json({ error: 'Forbidden', status: 403, message: 'Super-admin role is required to revoke scoped tokens' }); + return; + } + const actor = resolveActingAdminAddress(req); + const revoked = await scopedAdminTokenStore.revoke(req.params.keyId, { revokedBy: actor }); + if (!revoked) { + res.status(404).json({ error: 'Not Found', status: 404, message: 'Scoped token not found or already revoked' }); + return; + } + void recordAdminAuditLog(req, 'scoped-token.revoked', 200, { keyId: req.params.keyId, actor }); + res.status(200).json({ message: 'Scoped token revoked', keyId: req.params.keyId, timestamp: new Date().toISOString() }); +}); + +/** + * GET /admin/withdrawals/recovery + * Lists journalled withdrawal sagas with optional filters. Use + * `?requiresManualIntervention=true` for the operator work queue. + */ +router.get('/admin/withdrawals/recovery', validateApiKey, (req: Request, res: Response) => { + const requiresManualIntervention = + req.query.requiresManualIntervention === undefined + ? undefined + : req.query.requiresManualIntervention === 'true'; + + const sagas = withdrawalRecoveryCoordinator.list({ + status: parseSagaStatus(req.query.status), + walletAddress: + typeof req.query.walletAddress === 'string' ? req.query.walletAddress : undefined, + withdrawalId: + typeof req.query.withdrawalId === 'string' ? req.query.withdrawalId : undefined, + requiresManualIntervention, + limit: parseLimited(req.query.limit, 50, 1, 200), + }); + + res.status(200).json({ + sagas, + count: sagas.length, + metrics: withdrawalRecoveryCoordinator.getMetrics(), + timestamp: new Date().toISOString(), + }); +}); + +/** + * GET /admin/withdrawals/recovery/metrics + * Aggregate recovery counters for dashboards and alert rules. + */ +router.get('/admin/withdrawals/recovery/metrics', validateApiKey, (_req: Request, res: Response) => { + res.status(200).json({ + metrics: withdrawalRecoveryCoordinator.getMetrics(), + timestamp: new Date().toISOString(), + }); +}); + +/** + * GET /admin/withdrawals/recovery/:sagaId + * Returns the full step journal for one withdrawal saga. + */ +router.get('/admin/withdrawals/recovery/:sagaId', validateApiKey, (req: Request, res: Response) => { + const saga = withdrawalRecoveryCoordinator.get(req.params.sagaId); + if (!saga) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Withdrawal recovery saga not found', + }); + return; + } + res.status(200).json({ saga }); +}); + +/** + * POST /admin/withdrawals/recovery/:sagaId/resume + * Replays the saga from its journal. Completed steps are skipped, so no side + * effect is repeated. `force: true` also resumes sagas parked for an operator. + */ +router.post( + '/admin/withdrawals/recovery/:sagaId/resume', + validateApiKey, + async (req: Request, res: Response) => { + const force = req.body?.force === true; + const outcome = await withdrawalRecoveryCoordinator.resume(req.params.sagaId, { force }); + + if (!outcome) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Withdrawal recovery saga not found', + }); + return; + } + + void recordAdminAuditLog(req, 'withdrawal-recovery.resumed', 200, { + sagaId: outcome.saga.id, + withdrawalId: outcome.saga.withdrawalId, + status: outcome.status, + forced: force, + actor: resolveActingAdminAddress(req), + }); + + res.status(200).json({ + message: 'Withdrawal recovery pass completed', + status: outcome.status, + completed: outcome.completed, + partial: outcome.partial, + saga: outcome.saga, + }); + }, +); + +/** + * POST /admin/withdrawals/recovery/:sagaId/resolve + * Closes out a parked saga after an operator reconciled it by hand. + */ +router.post( + '/admin/withdrawals/recovery/:sagaId/resolve', + validateApiKey, + (req: Request, res: Response) => { + const note = typeof req.body?.note === 'string' ? req.body.note.trim() : ''; + if (!note) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'A "note" describing the manual resolution is required', + }); + return; + } + + const outcome = req.body?.outcome; + if (outcome !== undefined && !['completed', 'compensated', 'failed'].includes(outcome)) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'outcome must be one of: completed, compensated, failed', + }); + return; + } + + const actor = resolveActingAdminAddress(req); + const saga = withdrawalRecoveryCoordinator.resolveManually( + req.params.sagaId, + actor, + note, + outcome, + ); + + if (!saga) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Withdrawal recovery saga not found', + }); + return; + } + + void recordAdminAuditLog(req, 'withdrawal-recovery.resolved', 200, { + sagaId: saga.id, + withdrawalId: saga.withdrawalId, + outcome: saga.status, + actor, + }); + + res.status(200).json({ message: 'Withdrawal saga resolved', saga }); + }, +); + +/** + * POST /admin/withdrawals/recovery/sweep + * Runs one recovery sweep immediately instead of waiting for the interval. + */ +router.post('/admin/withdrawals/recovery/sweep', validateApiKey, async (req: Request, res: Response) => { + const result = await withdrawalRecoveryCoordinator.sweep(); + void recordAdminAuditLog(req, 'withdrawal-recovery.swept', 200, { + resumed: result.resumed.length, + stale: result.stale.length, + actor: resolveActingAdminAddress(req), + }); + res.status(200).json({ + message: 'Withdrawal recovery sweep completed', + resumed: result.resumed, + staleResumed: result.stale, + metrics: withdrawalRecoveryCoordinator.getMetrics(), + timestamp: new Date().toISOString(), + }); +}); + +// ─── Vault management ──────────────────────────────────────────────────────── + +/** + * POST /admin/vaults + * + * Creates a new vault for a tenant. + * + * Body: { name: string, symbol: string, tenantId: string } + * + * Validation is handled entirely by CreateVaultBodySchema (which trims + * whitespace before checking min/max lengths), so no manual trim() calls + * are needed in this handler. A whitespace-only name like " " fails schema + * validation and returns 400 before any DB write is attempted. + */ +router.post( + '/admin/vaults', + validateApiKey, + validate({ body: CreateVaultBodySchema }), + async (req: Request, res: Response) => { + const { name, symbol, tenantId } = req.body as { + name: string; + symbol: string; + tenantId: string; + }; + + const prismaClient = getPrismaClient(); + const vault = await prismaClient.vault.create({ + data: { name, symbol, tenantId }, + }); + + void recordAdminAuditLog(req, 'vault.created', 201, { + vaultId: vault.id, + name: vault.name, + symbol: vault.symbol, + tenantId: vault.tenantId, + actor: resolveActingAdminAddress(req), + }); + + res.status(201).json({ + vault: { + id: vault.id, + name: vault.name, + symbol: vault.symbol, + tenantId: vault.tenantId, + aum: vault.aum, + createdAt: vault.createdAt.toISOString(), + }, + timestamp: new Date().toISOString(), + }); + }, +); + +export default router; diff --git a/backend/src/routes/exports.ts b/backend/src/routes/exports.ts new file mode 100644 index 000000000..334832426 --- /dev/null +++ b/backend/src/routes/exports.ts @@ -0,0 +1,366 @@ +/** + * Admin export job, bulk export and report export endpoints. + * + * Extracted from src/index.ts (issue #1545); mounted by the composition root + * in the same position the routes were registered, so behaviour is unchanged. + */ +import { Router, Request, Response } from 'express'; +import { BulkExportBodySchema, ChecksumVerifyBodySchema, IdParamSchema, ReportExportBodySchema, validate } from '../middleware/validate'; +import { DateRangeParseError, parseUtcDateRange } from '../dateRange'; +import { cancelBulkExportJob, createBulkExportJob, getBulkExportArtifact, getBulkExportJob, listBulkExportJobs, processBulkExportJob } from '../bulkExportJobs'; +import { createExportManifest, getExportManifestById, listExportManifests, verifyExportManifestChecksum } from '../exportManifest'; +import { getExportJobById, listExportJobs, resolveExportGeneratedBy } from '../exportJobs'; +import { paginateByLimit, parseLimited, resolveActingAdminAddress, sendStandardListEnvelope } from './shared'; +import { validateApiKey } from '../middleware/apiKeyAuth'; + +const router = Router(); + +/** + * GET /admin/exports/jobs - list persisted transaction export metadata + */ +router.get('/admin/exports/jobs', validateApiKey, async (req: Request, res: Response) => { + const rawFormat = typeof req.query.format === 'string' ? req.query.format : undefined; + const format = rawFormat === 'csv' || rawFormat === 'json' ? rawFormat : undefined; + if (rawFormat && !format) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'format must be either csv or json', + }); + return; + } + + try { + const range = parseUtcDateRange({ + from: typeof req.query.from === 'string' ? req.query.from : undefined, + to: typeof req.query.to === 'string' ? req.query.to : undefined, + }); + const limit = parseLimited(req.query.limit, 50, 1, 200); + const jobs = await listExportJobs({ + format, + generatedBy: typeof req.query.generatedBy === 'string' ? req.query.generatedBy : undefined, + walletAddress: typeof req.query.walletAddress === 'string' ? req.query.walletAddress : undefined, + checksum: typeof req.query.checksum === 'string' ? req.query.checksum : undefined, + start: range.start, + end: range.end, + limit: limit + 1, + }); + const { data, hasNextPage } = paginateByLimit(jobs, limit); + + sendStandardListEnvelope(res, { + data, + limit, + hasNextPage, + extras: { + jobs: data, + meta: { + count: data.length, + limit, + filters: { + format: format || null, + generatedBy: typeof req.query.generatedBy === 'string' ? req.query.generatedBy : null, + walletAddress: typeof req.query.walletAddress === 'string' ? req.query.walletAddress : null, + checksum: typeof req.query.checksum === 'string' ? req.query.checksum : null, + from: range.start || null, + to: range.end || null, + }, + timestamp: new Date().toISOString(), + }, + }, + }); + } catch (error) { + if (error instanceof DateRangeParseError) { + res.status(error.status).json({ + error: 'Bad Request', + status: error.status, + message: error.message, + }); + return; + } + + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: error instanceof Error ? error.message : 'Failed to query export jobs', + }); + } +}); + +/** + * POST /admin/exports/jobs/:id/verify - verify a previously generated export checksum + * Body: { checksum: string } + */ +router.post('/admin/exports/jobs/:id/verify', validateApiKey, validate({ params: IdParamSchema, body: ChecksumVerifyBodySchema }), async (req: Request, res: Response) => { + const checksum = + typeof req.body?.checksum === 'string' + ? req.body.checksum.trim().toLowerCase() + : typeof req.query.checksum === 'string' + ? req.query.checksum.trim().toLowerCase() + : ''; + + if (!checksum) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'checksum is required', + }); + return; + } + + try { + const job = await getExportJobById(String(req.params.id)); + if (!job) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Export job not found', + }); + return; + } + + res.status(200).json({ + exportJobId: job.id, + valid: job.checksum.toLowerCase() === checksum, + expectedChecksum: job.checksum, + providedChecksum: checksum, + checksumAlgorithm: job.checksumAlgorithm, + generatedBy: job.generatedBy, + createdAt: job.createdAt, + fileName: job.fileName, + format: job.format, + rowCount: job.rowCount, + }); + } catch (error) { + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: error instanceof Error ? error.message : 'Failed to verify export checksum', + }); + } +}); + +/** + * POST /admin/exports/bulk - create a new bulk export job + * Body: { format: "csv"|"json", filters: { ... } } + */ +router.post('/admin/exports/bulk', validateApiKey, validate({ body: BulkExportBodySchema }), async (req: Request, res: Response) => { + try { + const { format, filters } = req.body; + if (format !== 'csv' && format !== 'json') { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'format must be either csv or json', + }); + return; + } + + const generatedBy = resolveExportGeneratedBy(req); + const job = await createBulkExportJob({ + format, + generatedBy, + filters: filters || {}, + }); + + void processBulkExportJob(job.id).catch(() => {}); + + res.status(201).json({ + message: 'Bulk export job created', + job, + timestamp: new Date().toISOString(), + }); + } catch (error) { + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: error instanceof Error ? error.message : 'Failed to create bulk export job', + }); + } +}); + +/** + * GET /admin/exports/bulk/jobs - list bulk export jobs + */ +router.get('/admin/exports/bulk/jobs', validateApiKey, async (req: Request, res: Response) => { + const limit = parseLimited(req.query.limit, 50, 1, 200); + const jobs = await listBulkExportJobs(limit + 1); + const { data, hasNextPage } = paginateByLimit(jobs, limit); + sendStandardListEnvelope(res, { + data, + limit, + hasNextPage, + extras: { + jobs: data, + meta: { + count: data.length, + limit, + timestamp: new Date().toISOString(), + }, + }, + }); +}); + +/** + * GET /admin/exports/bulk/jobs/:id - get bulk export job status + */ +router.get('/admin/exports/bulk/jobs/:id', validateApiKey, async (req: Request, res: Response) => { + const job = await getBulkExportJob(String(req.params.id)); + if (!job) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Bulk export job not found', + }); + return; + } + res.status(200).json({ job }); +}); + +/** + * POST /admin/exports/bulk/jobs/:id/cancel - cancel a pending/processing bulk export job + */ +router.post('/admin/exports/bulk/jobs/:id/cancel', validateApiKey, async (req: Request, res: Response) => { + const cancelled = await cancelBulkExportJob(String(req.params.id)); + if (!cancelled) { + const job = await getBulkExportJob(String(req.params.id)); + if (!job) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Bulk export job not found', + }); + return; + } + res.status(409).json({ + error: 'Conflict', + status: 409, + message: `Bulk export job is already ${job.status} and cannot be cancelled`, + }); + return; + } + res.status(200).json({ + message: 'Bulk export job cancelled', + timestamp: new Date().toISOString(), + }); +}); + +/** + * GET /admin/exports/bulk/artifacts/:artifactId - download a completed bulk export artifact + */ +router.get('/admin/exports/bulk/artifacts/:artifactId', validateApiKey, (req: Request, res: Response) => { + const artifact = getBulkExportArtifact(String(req.params.artifactId)); + if (!artifact) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Bulk export artifact not found or expired', + }); + return; + } + res.setHeader('Content-Type', artifact.contentType); + res.setHeader('Content-Disposition', `attachment; filename="bulk-export-${artifact.id}.${artifact.contentType === 'text/csv' ? 'csv' : 'json'}"`); + res.setHeader('X-Artifact-Checksum', artifact.checksum); + res.setHeader('X-Artifact-Checksum-Algorithm', artifact.checksumAlgorithm); + res.setHeader('X-Artifact-Row-Count', String(artifact.rowCount)); + res.status(200).send(artifact.body); +}); + +/** + * POST /admin/reports/exports - generate a report export and immutable manifest record + */ +router.post('/admin/reports/exports', validateApiKey, validate({ body: ReportExportBodySchema }), async (req: Request, res: Response) => { + const reportType = String(req.body?.reportType || 'transactions').trim(); + const requester = resolveActingAdminAddress(req); + const filters = + req.body?.filters && typeof req.body.filters === 'object' + ? (req.body.filters as Record) + : {}; + + const mockRows = [ + { + reportType, + generatedAt: new Date().toISOString(), + filters, + }, + ]; + + const manifest = await createExportManifest({ + requester, + reportType, + filters, + rows: mockRows, + }); + + res.status(201).json({ + message: 'Export generated and manifest recorded', + manifest, + }); +}); + +/** + * GET /admin/reports/exports/manifests - list immutable export manifests + */ +router.get('/admin/reports/exports/manifests', validateApiKey, async (req: Request, res: Response) => { + const limit = parseInt(String(req.query.limit || '50'), 10); + const offset = parseInt(String(req.query.offset || '0'), 10); + const result = await listExportManifests(limit, offset); + res.status(200).json({ + data: result.data, + total: result.total, + limit, + offset, + timestamp: new Date().toISOString(), + }); +}); + +/** + * GET /admin/reports/exports/manifests/:id - fetch a manifest by id + */ +router.get('/admin/reports/exports/manifests/:id', validateApiKey, async (req: Request, res: Response) => { + const manifest = await getExportManifestById(String(req.params.id)); + if (!manifest) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Export manifest not found', + }); + return; + } + + res.status(200).json({ + manifest, + }); +}); + +/** + * POST /admin/reports/exports/manifests/:id/verify - verify manifest checksum + */ +router.post('/admin/reports/exports/manifests/:id/verify', validateApiKey, validate({ params: IdParamSchema, body: ChecksumVerifyBodySchema }), async (req: Request, res: Response) => { + const checksum = String(req.body?.checksum || '').trim(); + if (!checksum) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'checksum is required', + }); + return; + } + + const result = await verifyExportManifestChecksum(String(req.params.id), checksum); + if (!result.manifest) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Export manifest not found', + }); + return; + } + + res.status(200).json({ + match: result.match, + manifest: result.manifest, + timestamp: new Date().toISOString(), + }); +}); + +export default router; diff --git a/backend/src/routes/shared.ts b/backend/src/routes/shared.ts new file mode 100644 index 000000000..ba9430648 --- /dev/null +++ b/backend/src/routes/shared.ts @@ -0,0 +1,114 @@ +/** + * Helpers and state shared by src/index.ts and the feature routers in + * src/routes/ (issue #1545). + */ +import NodeCache from 'node-cache'; +import { Request, Response } from 'express'; +import { createPaginatedResponse } from '../pagination'; +import { getPrismaClient } from '../prismaClient'; +import { normalizeWalletAddress } from '../walletUtils'; + +// Health check cache to track dependency status +export const cache = new NodeCache({ stdTTL: 30 }); + +/** + * Reads the vault summary from the VaultState table and the most recent + * SharePriceSnapshot for the share price. Falls back to zeroed values when + * the database is unavailable (e.g., during startup or in tests). + */ +export async function buildVaultSummaryResponseFromDb(): Promise<{ + totalAssets: string; + totalShares: string; + sharePrice: string; + apy: number; + timestamp: string; +}> { + try { + const prismaClient = getPrismaClient(); + const [vaultState, latestSnapshot] = await Promise.all([ + prismaClient.vaultState.findUnique({ where: { id: 1 } }), + prismaClient.sharePriceSnapshot + .findFirst({ orderBy: { recordedAt: 'desc' } }) + .catch(() => null), + ]); + + const totalAssets = vaultState?.totalAssets ?? '0'; + const totalShares = vaultState?.totalShares ?? '0'; + const sharePrice = latestSnapshot?.sharePrice ?? '1.000000'; + + return { + totalAssets, + totalShares, + sharePrice, + apy: 0, // APY is computed by the nightly snapshot job; placeholder until first run + timestamp: new Date().toISOString(), + }; + } catch { + // Fail-open: return safe zero values so the endpoint never 500s + return { + totalAssets: '0', + totalShares: '0', + sharePrice: '1.000000', + apy: 0, + timestamp: new Date().toISOString(), + }; + } +} + +export function resolveActingAdminAddress(req: Request): string { + const address = + req.get('x-admin-address') || + req.get('x-admin-id') || + req.get('x-wallet-address') || + 'unknown'; + return address === 'unknown' ? address : normalizeWalletAddress(address); +} + +export function parseLimited(v: unknown, fallback: number, min: number, max: number): number { + const n = parseInt(String(v ?? ''), 10); + return Number.isNaN(n) ? fallback : Math.min(Math.max(n, min), max); +} + +export function isDryRunRequest(req: Request): boolean { + const value = req.body?.dryRun ?? req.query.dryRun; + return value === true || value === 'true' || value === '1'; +} + +export function paginateByLimit(rows: T[], limit: number): { data: T[]; hasNextPage: boolean } { + const hasNextPage = rows.length > limit; + return { + data: hasNextPage ? rows.slice(0, limit) : rows, + hasNextPage, + }; +} + +export function sendStandardListEnvelope( + res: Response, + input: { + data: T[]; + limit: number; + hasNextPage?: boolean; + hasPrevPage?: boolean; + nextCursor?: string; + total?: number; + statusCode?: number; + extras?: Record; + }, +): void { + const payload = createPaginatedResponse(input.data, { + count: input.data.length, + total: input.total ?? input.data.length, + nextCursor: input.nextCursor ?? null, + prevCursor: null, + currentPage: null, + totalPages: null, + hasNextPage: input.hasNextPage ?? false, + hasPrevPage: input.hasPrevPage ?? false, + limit: input.limit, + }); + + res.status(input.statusCode ?? 200).json({ + ...payload, + ...(input.extras || {}), + }); +} diff --git a/backend/src/routes/transactions.ts b/backend/src/routes/transactions.ts new file mode 100644 index 000000000..c86b7d0b7 --- /dev/null +++ b/backend/src/routes/transactions.ts @@ -0,0 +1,259 @@ +/** + * Transaction export and admin transaction backfill endpoints. + * + * Extracted from src/index.ts (issue #1545); mounted by the composition root + * in the same position the routes were registered, so behaviour is unchanged. + */ +import { Router, Request, Response } from 'express'; +import { TransactionBackfillBodySchema, validate } from '../middleware/validate'; +import { authenticateApiKeyValue, validateApiKey } from '../middleware/apiKeyAuth'; +import { buildExportMetadataHeaderValue, recordExportJob, resolveExportGeneratedBy } from '../exportJobs'; +import { buildTransactionExportArtifact } from '../listEndpoints'; +import { createOrResumeTransactionBackfill, getTransactionBackfillJob, listTransactionBackfillJobs } from '../transactionBackfill'; +import { logger } from '../middleware/structuredLogging'; +import { normalizeWalletAddress } from '../walletUtils'; +import { verifyJwt } from '../auth'; + +function resolveTransactionExportAccess(req: Request): + | { kind: 'admin'; walletAddress?: string } + | { kind: 'user'; walletAddress: string } + | null { + const authHeader = req.get('authorization') || ''; + const walletAddress = + typeof req.query.walletAddress === 'string' ? normalizeWalletAddress(req.query.walletAddress) : undefined; + + const apiKeyMatch = authHeader.match(/^ApiKey\s+(.+)$/i); + if (apiKeyMatch) { + const authenticated = authenticateApiKeyValue(apiKeyMatch[1]); + if (!authenticated) { + return null; + } + req.authApiKeyHash = authenticated.hash; + req.authApiKeyRole = authenticated.role; + return { + kind: 'admin', + walletAddress: walletAddress || undefined, + }; + } + + const bearerMatch = authHeader.match(/^Bearer\s+(.+)$/i); + if (!bearerMatch) { + return null; + } + + const payload = verifyJwt(bearerMatch[1]); + const userWallet = normalizeWalletAddress(payload.sub); + if (walletAddress && walletAddress !== userWallet) { + throw new Error('FORBIDDEN_WALLET_EXPORT'); + } + + return { + kind: 'user', + walletAddress: userWallet, + }; +} + +function buildTransactionExportFilename(format: 'csv' | 'json'): string { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + return `transaction-history-${timestamp}.${format}`; +} + +async function handleTransactionExport(req: Request, res: Response): Promise { + const format = req.query.format === 'csv' ? 'csv' : req.query.format === 'json' ? 'json' : null; + if (!format) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'format query parameter must be either csv or json', + }); + return; + } + + let access; + try { + access = resolveTransactionExportAccess(req); + } catch (error) { + if (error instanceof Error && error.message === 'FORBIDDEN_WALLET_EXPORT') { + res.status(403).json({ + error: 'Forbidden', + status: 403, + message: 'Users may only export their own wallet transactions', + }); + return; + } + + res.status(401).json({ + error: 'Unauthorized', + status: 401, + message: error instanceof Error ? error.message : 'Invalid authorization header', + }); + return; + } + + if (!access) { + res.status(401).json({ + error: 'Unauthorized', + status: 401, + message: 'Authorization header must contain a Bearer token or ApiKey', + }); + return; + } + + const exportQuery = { + 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' || req.query.sortOrder === 'desc' + ? req.query.sortOrder + : undefined + ) as 'asc' | 'desc' | undefined, + walletAddress: access.walletAddress, + startDate: typeof req.query.startDate === 'string' ? req.query.startDate : undefined, + endDate: typeof req.query.endDate === 'string' ? req.query.endDate : undefined, + }; + + try { + const artifact = buildTransactionExportArtifact(format, exportQuery); + const fileName = buildTransactionExportFilename(format); + const job = await recordExportJob({ + format, + fileName, + contentType: artifact.contentType, + checksum: artifact.checksum, + checksumAlgorithm: artifact.checksumAlgorithm, + generatedBy: resolveExportGeneratedBy(req), + walletAddress: access.walletAddress, + rowCount: artifact.rowCount, + filters: { + type: exportQuery.type || null, + status: exportQuery.status || null, + sortBy: exportQuery.sortBy || null, + sortOrder: exportQuery.sortOrder || null, + startDate: exportQuery.startDate || null, + endDate: exportQuery.endDate || null, + walletAddress: exportQuery.walletAddress || null, + }, + }); + + 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) { + logger.log('error', 'Transaction export failed', { + error: error instanceof Error ? error.message : String(error), + }); + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: 'Failed to generate transaction export', + }); + } +} + +const router = Router(); + +router.get('/api/vault/transactions/export', (_req: Request, res: Response) => { + res.redirect(301, '/api/v1/vault/transactions/export'); +}); + +// ─── Versioned export & summary endpoints ──────────────────────────────── +router.get('/api/v1/vault/transactions/export', handleTransactionExport); + +/** + * POST /admin/transactions/backfill - controlled backfill of missing ledger index ranges + */ +router.post('/admin/transactions/backfill', validateApiKey, validate({ body: TransactionBackfillBodySchema }), async (req: Request, res: Response) => { + const startLedger = Number(req.body?.startLedger); + const endLedger = Number(req.body?.endLedger); + const batchSize = req.body?.batchSize === undefined ? undefined : Number(req.body.batchSize); + const dryRun = Boolean(req.body?.dryRun); + const rpcUrl = String(req.body?.rpcUrl || process.env.STELLAR_RPC_URL || '').trim(); + const contractId = String(req.body?.contractId || process.env.VAULT_CONTRACT_ID || '').trim(); + + if (!Number.isInteger(startLedger) || !Number.isInteger(endLedger)) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'startLedger and endLedger must be integers', + }); + return; + } + + if (!rpcUrl) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'rpcUrl is required (or set STELLAR_RPC_URL)', + }); + return; + } + + if (!contractId) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'contractId is required (or set VAULT_CONTRACT_ID)', + }); + return; + } + + try { + const job = await createOrResumeTransactionBackfill({ + startLedger, + endLedger, + batchSize, + dryRun, + rpcUrl, + contractId, + }); + + res.status(202).json({ + message: 'Backfill accepted', + job, + }); + } catch (error) { + res.status(422).json({ + error: 'Unprocessable Entity', + status: 422, + message: error instanceof Error ? error.message : 'Backfill request failed', + }); + } +}); + +/** + * GET /admin/transactions/backfill - list recent backfill jobs + */ +router.get('/admin/transactions/backfill', validateApiKey, async (req: Request, res: Response) => { + const limit = parseInt(String(req.query.limit || '20'), 10); + const data = await listTransactionBackfillJobs(limit); + res.status(200).json({ + data, + timestamp: new Date().toISOString(), + }); +}); + +/** + * GET /admin/transactions/backfill/:jobId - fetch a specific backfill job + */ +router.get('/admin/transactions/backfill/:jobId', validateApiKey, async (req: Request, res: Response) => { + const job = await getTransactionBackfillJob(String(req.params.jobId)); + if (!job) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Backfill job not found', + }); + return; + } + + res.status(200).json({ + job, + }); +}); + +export default router; diff --git a/backend/src/routes/vault.ts b/backend/src/routes/vault.ts new file mode 100644 index 000000000..d6bfde6ff --- /dev/null +++ b/backend/src/routes/vault.ts @@ -0,0 +1,256 @@ +/** + * Vault summary, metrics, APY and health endpoints (legacy, v1, v2). + * + * Extracted from src/index.ts (issue #1545); mounted by the composition root + * in the same position the routes were registered, so behaviour is unchanged. + */ +import { Router, Request, Response } from 'express'; +import { buildVaultSummaryResponseFromDb, cache } from './shared'; +import { cacheMiddleware } from '../middleware/cache'; +import { computeVaultApy } from '../services/apy'; +import { createTimeoutFor } from '../middleware/timeoutMiddleware'; +import { healthProbeService } from '../healthProbe'; +import { readsLimiter } from '../rateLimiter'; + +const cacheVaultMetricsTtl = parseInt(process.env.CACHE_TTL_MS || process.env.CACHE_VAULT_METRICS_TTL_MS || '60000', 10); + +type VaultHealthResponse = { + vaultId: string; + status: 'healthy' | 'degraded'; + uptimeSeconds: number; + metrics: { + totalAssets: string; + totalShares: string; + sharePrice: string; + apy: number; + }; + dependencies: Record; + cached: boolean; + timestamp: string; +}; + +async function buildVaultHealthResponse(vaultId: string): Promise { + const cacheKey = `vault-health:${vaultId}`; + const cached = cache.get>(cacheKey); + if (cached) { + return { ...cached, cached: true }; + } + + const [summary, probes] = await Promise.all([ + buildVaultSummaryResponseFromDb(), + healthProbeService.checkAll().catch( + () => ({}) as Record, + ), + ]); + const dependencies = Object.fromEntries( + Object.entries(probes).map(([name, state]) => [name, state.status]), + ) as Record; + const degraded = Object.values(dependencies).some((state) => state !== 'up'); + const body = { + vaultId, + status: degraded ? 'degraded' as const : 'healthy' as const, + uptimeSeconds: Math.floor(process.uptime()), + metrics: { + totalAssets: summary.totalAssets, + totalShares: summary.totalShares, + sharePrice: summary.sharePrice, + apy: summary.apy, + }, + dependencies, + timestamp: new Date().toISOString(), + }; + + cache.set(cacheKey, body, 5); + return { ...body, cached: false }; +} + +const router = Router(); + +// /api/vault/* → /api/v1/vault/* +router.get('/api/vault/summary', (_req: Request, res: Response) => { + res.setHeader('deprecation', 'true'); + res.redirect(301, '/api/v1/vault/summary'); +}); + +router.get('/api/vault/metrics', (_req: Request, res: Response) => { + res.redirect(301, '/api/v1/vault/metrics'); +}); + +router.get('/api/vault/apy', (_req: Request, res: Response) => { + res.redirect(301, '/api/v1/vault/apy'); +}); + +// ─── Versioned vault summary/metrics/apy endpoints ─────────────────────── + +/** + * GET /api/v1/vaults/:id/health - monitoring-friendly vault health payload. + * + * Kept separate from /health so external monitors can target one vault and + * receive vault metrics, dependency state, uptime, and cache status. Responses + * are cached for five seconds to avoid turning health probes into DB pressure. + */ +router.get( + '/api/v1/vaults/:id/health', + readsLimiter, + createTimeoutFor.read({ + timeoutMs: 1500, + routeName: '/api/v1/vaults/:id/health', + message: 'Vault health took too long to load', + fallbackResponse: () => ({ + error: 'Service Unavailable', + status: 503, + code: 'VAULT_HEALTH_TIMEOUT', + message: 'Vault health is temporarily unavailable. Please retry shortly.', + timestamp: new Date().toISOString(), + }), + }), + async (req: Request, res: Response) => { + const health = await buildVaultHealthResponse(req.params.id); + res.status(health.status === 'healthy' ? 200 : 503).json(health); + }, +); + +router.get('/api/v2/vaults/:id/health', (req: Request, res: Response) => { + const qs = req.originalUrl.includes('?') ? req.originalUrl.slice(req.originalUrl.indexOf('?')) : ''; + res.set('X-API-Preview', 'v2'); + res.redirect(307, `/api/v1/vaults/${encodeURIComponent(req.params.id)}/health${qs}`); +}); + +/** + * GET /api/v1/vaults/:id/apy + * + * Returns the annualised APY for the vault. Returns `apy: null` with + * `apyStatus: 'insufficient_data'` for new vaults that have zero shares or + * fewer than 2 price snapshots, preventing Infinity / NaN from reaching the + * frontend (Issue #1456). + */ +router.get( + '/api/v1/vaults/:id/apy', + readsLimiter, + cacheMiddleware({ ttl: cacheVaultMetricsTtl }), + createTimeoutFor.read({ + timeoutMs: 1500, + routeName: '/api/v1/vaults/:id/apy', + message: 'Vault APY took too long to load', + fallbackResponse: () => ({ + error: 'Service Unavailable', + status: 503, + code: 'VAULT_APY_TIMEOUT', + message: 'Vault APY is temporarily unavailable. Please try again shortly.', + timestamp: new Date().toISOString(), + }), + }), + async (_req: Request, res: Response) => { + const result = await computeVaultApy(); + res.json({ + ...result, + timestamp: new Date().toISOString(), + }); + }, +); + +/** + * @openapi + * /vault/summary: + * get: + * summary: Vault summary + * description: Returns high-level vault metrics including total assets, shares, and APY. + * tags: [Vault] + * responses: + * 200: + * description: Vault summary metrics + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/VaultSummary' + */ +/** + * GET /api/v1/vault/summary – read-only summary; relaxed rate limit. + * Data is sourced from VaultState + SharePriceSnapshot (DB) and cached via + * the Redis-backed response cache. + */ +router.get( + '/api/v1/vault/summary', + readsLimiter, + cacheMiddleware({ ttl: cacheVaultMetricsTtl }), + createTimeoutFor.read({ + timeoutMs: 1500, + routeName: '/api/v1/vault/summary', + message: 'Vault summary took too long to load', + fallbackResponse: () => ({ + error: 'Service Unavailable', + status: 503, + code: 'VAULT_SUMMARY_TIMEOUT', + message: 'Vault summary is temporarily unavailable. Please refresh in a moment.', + stale: true, + data: { + totalAssets: '0', + totalShares: '0', + sharePrice: '1.000000', + apy: 0, + timestamp: new Date().toISOString(), + }, + timestamp: new Date().toISOString(), + }), + }), + async (_req: Request, res: Response) => { + const summary = await buildVaultSummaryResponseFromDb(); + res.json(summary); + }, +); + +/** + * GET /api/v1/vault/metrics - Cache with configurable TTL + */ +router.get( + '/api/v1/vault/metrics', + cacheMiddleware({ ttl: cacheVaultMetricsTtl }), + createTimeoutFor.read({ + timeoutMs: 2000, + routeName: '/api/v1/vault/metrics', + message: 'Vault metrics took too long to load', + fallbackResponse: () => ({ + error: 'Service Unavailable', + status: 503, + code: 'VAULT_METRICS_TIMEOUT', + message: 'Vault metrics are temporarily unavailable. Showing the last known state.', + stale: true, + timestamp: new Date().toISOString(), + }), + }), + (_req: Request, res: Response) => { + res.json({ + message: 'Vault metrics', + timestamp: new Date().toISOString(), + }); + }, +); + +/** + * GET /api/v1/vault/apy - Cache with configurable TTL + */ +router.get( + '/api/v1/vault/apy', + cacheMiddleware({ ttl: cacheVaultMetricsTtl }), + createTimeoutFor.read({ + timeoutMs: 1200, + routeName: '/api/v1/vault/apy', + message: 'Vault APY took too long to load', + fallbackResponse: () => ({ + error: 'Service Unavailable', + status: 503, + code: 'VAULT_APY_TIMEOUT', + message: 'Vault APY is temporarily unavailable. Please try again shortly.', + stale: true, + timestamp: new Date().toISOString(), + }), + }), + (_req: Request, res: Response) => { + res.json({ + message: 'Vault APY', + timestamp: new Date().toISOString(), + }); + }, +); + +export default router; diff --git a/backend/src/routes/webhooks.ts b/backend/src/routes/webhooks.ts new file mode 100644 index 000000000..ecbbc27a5 --- /dev/null +++ b/backend/src/routes/webhooks.ts @@ -0,0 +1,492 @@ +/** + * Webhook verification and admin webhook management endpoints. + * + * Extracted from src/index.ts (issue #1545); mounted by the composition root + * in the same position the routes were registered, so behaviour is unchanged. + */ +import { Router, Request, Response } from 'express'; +import { IdParamSchema, PaginationQuerySchema, WebhookListQuerySchema, WebhookRegisterSchema, WebhookUpdateSchema, WebhookVerifyBodySchema, validate } from '../middleware/validate'; +import { assertWebhookParameterUpdate } from '../middleware/rbac'; +import { createWebhookSignature, deleteWebhookEndpoint, getWebhookDeliveryMetrics, listWebhookDeadLetters, listWebhookDeliveryPage, listWebhookEndpoints, registerWebhookEndpoint, restoreWebhookEndpoint, retryWebhookDeadLetter, updateWebhookEndpoint, verifyWebhookEndpoint, verifyWebhookSignature } from '../webhookDelivery'; +import { hasRequiredApiKeyRole, validateApiKey } from '../middleware/apiKeyAuth'; +import { isDryRunRequest, paginateByLimit, parseLimited, resolveActingAdminAddress, sendStandardListEnvelope } from './shared'; +import { recordAdminAuditLog } from '../adminAudit'; +import { requireAuth } from '../auth'; +import { webhookDeduplicationStore } from '../webhookDeduplication'; + +const router = Router(); + +/** Routes mounted on the `/api/v1` router (`apiV1WebhookRoutes`). */ +export const apiV1WebhookRoutes = Router(); + +/** + * POST /api/v1/webhooks — authenticated clients register an HTTPS callback + * instead of polling for transaction/vault events. + */ +apiV1WebhookRoutes.post('/webhooks', requireAuth, validate({ body: WebhookRegisterSchema }), (req: Request, res: Response) => { + try { + const { url, eventTypes, enabled, secret } = req.body; + const endpoint = registerWebhookEndpoint({ + url, + eventTypes, + enabled: enabled ?? true, + secret, + }); + res.status(201).json({ + message: 'Webhook endpoint registered', + endpoint, + }); + } catch (error) { + res.status(422).json({ + error: 'Unprocessable Entity', + status: 422, + message: error instanceof Error ? error.message : 'Invalid webhook configuration', + }); + } +}); + +apiV1WebhookRoutes.get('/webhooks', requireAuth, validate({ query: PaginationQuerySchema }), (req: Request, res: Response) => { + const includeDeleted = req.query.includeDeleted === 'true'; + const endpoints = listWebhookEndpoints(includeDeleted); + res.status(200).json({ + endpoints, + metrics: getWebhookDeliveryMetrics(), + }); +}); + +// /webhooks/verify → /api/v1/webhooks/verify +router.post('/webhooks/verify', validate({ body: WebhookVerifyBodySchema }), (req: Request, res: Response) => { + const { secret, payload, signature } = req.body || {}; + if (typeof secret !== 'string' || !secret.trim()) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'secret is required and must be a non-empty string', + }); + return; + } + + if (typeof payload === 'undefined') { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'payload is required', + }); + return; + } + + const computedSignature = createWebhookSignature(secret, payload); + const verified = + typeof signature === 'string' && signature.length > 0 + ? verifyWebhookSignature(secret, payload, signature) + : null; + + res.status(200).json({ + algorithm: 'HMAC-SHA256', + signature: computedSignature, + verified, + }); +}); + +/** + * POST /admin/webhooks - register webhook endpoint for transaction events + */ +router.post('/admin/webhooks', validateApiKey, validate({ body: WebhookRegisterSchema }), (req: Request, res: Response) => { + const { url, eventTypes, enabled, secret } = req.body; + + const endpoint = registerWebhookEndpoint({ + url, + eventTypes, + enabled: enabled ?? true, + secret, + }); + + res.status(201).json({ + message: 'Webhook endpoint registered', + endpoint, + }); +}); + +/** + * POST /admin/webhooks/:id/verify - run challenge-response verification for an endpoint + */ +router.post('/admin/webhooks/:id/verify', validateApiKey, validate({ params: IdParamSchema }), async (req: Request, res: Response) => { + try { + const endpoint = await verifyWebhookEndpoint(req.params.id); + if (!endpoint) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Webhook endpoint not found', + }); + return; + } + + await recordAdminAuditLog(req, 'webhook.verify', endpoint.verificationStatus === 'verified' ? 200 : 422, { + endpointId: endpoint.id, + verificationStatus: endpoint.verificationStatus, + lastVerificationError: endpoint.lastVerificationError, + }); + + res.status(endpoint.verificationStatus === 'verified' ? 200 : 422).json({ + message: + endpoint.verificationStatus === 'verified' + ? 'Webhook endpoint verified' + : 'Webhook endpoint verification failed', + endpoint, + }); + } catch (error) { + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: error instanceof Error ? error.message : 'Failed to verify webhook endpoint', + }); + } +}); + +/** + * PATCH /admin/webhooks/:id - update webhook endpoint + */ +router.patch('/admin/webhooks/:id', validateApiKey, validate({ params: IdParamSchema, body: WebhookUpdateSchema }), (req: Request, res: Response) => { + if (!assertWebhookParameterUpdate(req, res)) { + return; + } + + const endpoint = updateWebhookEndpoint(req.params.id, req.body || {}); + if (!endpoint) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Webhook endpoint not found', + }); + return; + } + + res.status(200).json({ + message: 'Webhook endpoint updated', + endpoint, + }); +}); + +/** + * GET /admin/webhooks - list webhook endpoints + */ +router.get('/admin/webhooks', validateApiKey, validate({ query: PaginationQuerySchema }), (req: Request, res: Response) => { + const includeDeleted = req.query.includeDeleted === 'true'; + const limit = parseLimited(req.query.limit, 100, 1, 500); + const allEndpoints = listWebhookEndpoints(includeDeleted); + const windowed = allEndpoints.slice(0, limit + 1); + const { data, hasNextPage } = paginateByLimit(windowed, limit); + + sendStandardListEnvelope(res, { + data, + limit, + hasNextPage, + total: allEndpoints.length, + extras: { + endpoints: data, + metrics: getWebhookDeliveryMetrics(), + }, + }); +}); + +/** + * DELETE /admin/webhooks/:id - soft delete webhook endpoint + */ +router.delete('/admin/webhooks/:id', validateApiKey, validate({ params: IdParamSchema }), async (req: Request, res: Response) => { + const actor = resolveActingAdminAddress(req); + const endpoint = deleteWebhookEndpoint(req.params.id, actor); + + if (!endpoint) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Webhook endpoint not found or already deleted', + }); + return; + } + + void recordAdminAuditLog(req, 'webhook.delete', 200, { + endpointId: endpoint.id, + url: endpoint.url, + actor, + }); + + res.status(200).json({ + message: 'Webhook endpoint soft-deleted', + endpoint, + }); +}); + +/** + * POST /admin/webhooks/:id/restore - restore soft-deleted webhook endpoint + */ +router.post('/admin/webhooks/:id/restore', validateApiKey, validate({ params: IdParamSchema }), async (req: Request, res: Response) => { + const actor = resolveActingAdminAddress(req); + const endpoint = restoreWebhookEndpoint(req.params.id, actor); + + if (!endpoint) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Webhook endpoint not found or not deleted', + }); + return; + } + + void recordAdminAuditLog(req, 'webhook.restore', 200, { + endpointId: endpoint.id, + url: endpoint.url, + actor, + }); + + res.status(200).json({ + message: 'Webhook endpoint restored', + endpoint, + }); +}); + +/** + * GET /admin/webhooks/dead-letter - list permanently failed webhook deliveries + */ +router.get('/admin/webhooks/dead-letter', validateApiKey, validate({ query: WebhookListQuerySchema }), (req: Request, res: Response) => { + const endpointId = typeof req.query.endpointId === 'string' ? req.query.endpointId : undefined; + const eventType = typeof req.query.eventType === 'string' ? req.query.eventType : undefined; + const start = typeof req.query.start === 'string' ? req.query.start : undefined; + const end = typeof req.query.end === 'string' ? req.query.end : undefined; + const limit = parseLimited(req.query.limit, 100, 1, 500); + + const rows = listWebhookDeadLetters({ + endpointId, + eventType: eventType as any, + start, + end, + limit: limit + 1, + }); + const { data, hasNextPage } = paginateByLimit(rows, limit); + + sendStandardListEnvelope(res, { + data, + limit, + hasNextPage, + extras: { + deadLetters: data, + }, + }); +}); + +/** + * POST /admin/webhooks/dead-letter/:id/retry - re-queue a dead-letter delivery + */ +router.post('/admin/webhooks/dead-letter/:id/retry', validateApiKey, async (req: Request, res: Response) => { + try { + const entry = await retryWebhookDeadLetter(req.params.id); + if (!entry) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Dead-letter entry not found', + }); + return; + } + + res.status(200).json({ + message: 'Dead-letter entry re-queued for delivery', + deadLetter: entry, + }); + } catch (error) { + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: error instanceof Error ? error.message : 'Failed to retry dead-letter entry', + }); + } +}); + +/** + * GET /admin/webhooks/deliveries - list recent webhook delivery attempts + * Supports cursor-based pagination: ?limit=N&cursor= + */ +router.get('/admin/webhooks/deliveries', validateApiKey, validate({ query: PaginationQuerySchema }), (req: Request, res: Response) => { + const limit = parseLimited(req.query.limit, 100, 1, 500); + const cursor = typeof req.query.cursor === 'string' ? req.query.cursor : undefined; + + try { + const page = listWebhookDeliveryPage({ limit, cursor }); + sendStandardListEnvelope(res, { + data: page.deliveries, + limit, + hasNextPage: page.hasNextPage, + hasPrevPage: Boolean(cursor), + nextCursor: page.nextCursor, + extras: { + deliveries: page.deliveries, + nextCursor: page.nextCursor, + hasNextPage: page.hasNextPage, + metrics: getWebhookDeliveryMetrics(), + }, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (message.includes('Invalid or expired cursor')) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'Invalid or expired cursor. Start a new page without a cursor.', + }); + return; + } + res.status(500).json({ error: 'Internal Server Error', status: 500, message }); + } +}); + +/** + * POST /api/v1/webhooks/verify - verify webhook secret/signature pairing before go-live + */ +router.post('/api/v1/webhooks/verify', validate({ body: WebhookVerifyBodySchema }), (req: Request, res: Response) => { + const { secret, payload, signature } = req.body || {}; + if (typeof secret !== 'string' || !secret.trim()) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'secret is required and must be a non-empty string', + }); + return; + } + + if (typeof payload === 'undefined') { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'payload is required', + }); + return; + } + + const computedSignature = createWebhookSignature(secret, payload); + const verified = + typeof signature === 'string' && signature.length > 0 + ? verifyWebhookSignature(secret, payload, signature) + : null; + + res.status(200).json({ + algorithm: 'HMAC-SHA256', + signature: computedSignature, + verified, + }); +}); + +// ─── Webhook Deduplication Admin Endpoints (Issue #710) ────────────────────── + +/** + * GET /admin/webhooks/deduplication/metrics + * Returns observability counters for the webhook replay-safe deduplication store. + * Requires API key authentication. + */ +router.get('/admin/webhooks/deduplication/metrics', validateApiKey, (_req: Request, res: Response) => { + res.status(200).json({ + metrics: webhookDeduplicationStore.getMetrics(), + timestamp: new Date().toISOString(), + }); +}); + +/** + * GET /admin/webhooks/deduplication/entries + * Lists active event fingerprints held in the deduplication store. + * Optional ?prefix= filters by event id prefix. + * Requires API key authentication. + */ +router.get('/admin/webhooks/deduplication/entries', validateApiKey, (req: Request, res: Response) => { + const prefix = typeof req.query.prefix === 'string' ? req.query.prefix : undefined; + const entries = webhookDeduplicationStore.inspect(prefix); + res.status(200).json({ + entries, + count: entries.length, + metrics: webhookDeduplicationStore.getMetrics(), + timestamp: new Date().toISOString(), + }); +}); + +/** + * DELETE /admin/webhooks/deduplication/:eventId + * Removes a single event fingerprint from the deduplication store. + * Requires API key authentication. + */ +router.delete('/admin/webhooks/deduplication/:eventId', validateApiKey, (req: Request, res: Response) => { + const eventId = decodeURIComponent(req.params.eventId); + + if (isDryRunRequest(req)) { + const exists = webhookDeduplicationStore.has(eventId); + res.status(exists ? 200 : 404).json({ + dryRun: true, + message: exists + ? `Deduplication entry '${eventId}' would be removed` + : `Deduplication entry '${eventId}' not found`, + eventId, + wouldDelete: exists, + timestamp: new Date().toISOString(), + }); + return; + } + + const removed = webhookDeduplicationStore.remove(eventId); + if (!removed) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: `Deduplication entry '${eventId}' not found`, + }); + return; + } + + void recordAdminAuditLog(req, 'webhook.dedup.entry.removed', 200, { eventId }); + + res.status(200).json({ + message: `Deduplication entry '${eventId}' removed`, + metrics: webhookDeduplicationStore.getMetrics(), + timestamp: new Date().toISOString(), + }); +}); + +/** + * DELETE /admin/webhooks/deduplication + * Flushes the entire webhook deduplication store. + * Requires super-admin API key. + */ +router.delete('/admin/webhooks/deduplication', validateApiKey, (req: Request, res: Response) => { + if (!hasRequiredApiKeyRole(req, 'super-admin')) { + res.status(403).json({ + error: 'Forbidden', + status: 403, + message: 'Super-admin role is required to flush the webhook deduplication store', + }); + return; + } + + const metrics = webhookDeduplicationStore.getMetrics(); + + if (isDryRunRequest(req)) { + res.status(200).json({ + dryRun: true, + message: 'Webhook deduplication store flush dry-run preview', + wouldFlush: true, + activeFingerprints: metrics.activeFingerprints, + timestamp: new Date().toISOString(), + }); + return; + } + + webhookDeduplicationStore.flush(); + + void recordAdminAuditLog(req, 'webhook.dedup.store.flushed', 200, { + flushedCount: metrics.activeFingerprints, + }); + + res.status(200).json({ + message: 'Webhook deduplication store flushed', + metrics: webhookDeduplicationStore.getMetrics(), + timestamp: new Date().toISOString(), + }); +}); + +export default router; diff --git a/contracts/vault/src/lib.rs b/contracts/vault/src/lib.rs index 0198dd537..b5bd3f42d 100644 --- a/contracts/vault/src/lib.rs +++ b/contracts/vault/src/lib.rs @@ -126,6 +126,7 @@ pub use risk_limits::ProtocolRiskLimits; use crate::strategy::StrategyClient; use crate::strategy_registration::{STATE_ACTIVE, STATE_PENDING, STATE_RETIRED}; +use crate::strategy_validation::StrategyValidator; use crate::upgrade::{ get_admin, get_implementation, get_pending_admin, get_pending_upgrade, get_storage_version, is_initialized, set_admin, set_implementation, set_initialized, set_pending_admin, @@ -961,16 +962,17 @@ impl YieldVault { strategy_addr: &Address, expected_asset: &Address, ) -> Result { + StrategyValidator::validate_strategy_address(env, strategy_addr)?; let strategy_client = StrategyClient::new(env, strategy_addr); let actual_asset = strategy_client.asset(); if actual_asset != *expected_asset { return Err(VaultError::UnauthorizedStrategy); } + StrategyValidator::validate_decimals(token::Client::new(env, &actual_asset).decimals())?; + // Non-negative and at most `MAX_STRATEGY_VALUE` (issue #1530). let value = strategy_client.total_value(); - if value < 0 { - return Err(VaultError::InvalidAmount); - } + StrategyValidator::validate_total_value(value)?; Ok(value) } @@ -1644,6 +1646,7 @@ impl YieldVault { if harvested <= 0 { return Err(VaultError::InvalidYieldAmount); } + StrategyValidator::validate_total_value(harvested)?; // Issue #1230: Performance fee — redirect portion of yield above watermark let perf_enabled: bool = env @@ -1796,10 +1799,11 @@ impl YieldVault { /// During migration, accepts signatures from either active or previous signer sets. /// /// ### Parameters - /// * `approvals` - Vector of addresses that have approved the operation + /// * `approvals` - Vector of addresses that have approved the operation; + /// every approver must authorize this invocation (`require_auth`) /// /// ### Returns - /// Ok if threshold is met, panics otherwise + /// Ok if threshold is met, otherwise the `MultiSignerValidator` error pub fn require_governance_threshold( env: Env, approvals: Vec
, @@ -1818,30 +1822,19 @@ impl YieldVault { // During migration, accept both old and new signer sets let is_migration = current_time < migration_deadline && !config.previous_signers.is_empty(); - if is_migration { - let old_signers = config.previous_signers; - - // Try new signer set first, then fall back to old set - if permissions::MultiSignerValidator::verify_threshold(&signers, threshold, &approvals) - .is_ok() - { - return Ok(()); - } - if permissions::MultiSignerValidator::verify_threshold( - &old_signers, - threshold, - &approvals, + // During migration either set may satisfy the threshold. The set is + // chosen without auth checks so each approver signs exactly once. + let active = if is_migration + && permissions::MultiSignerValidator::check_threshold( + &env, &signers, threshold, &approvals, ) - .is_ok() - { - return Ok(()); - } - return Err(VaultError::GovernanceThresholdNotMet); + .is_err() + { + config.previous_signers } else { - permissions::MultiSignerValidator::verify_threshold(&signers, threshold, &approvals) - .map_err(|_| VaultError::GovernanceThresholdNotMet)?; - } - Ok(()) + signers + }; + permissions::MultiSignerValidator::verify_threshold(&env, &active, threshold, &approvals) } /// Clear migration state. Called after old signer set is no longer needed. @@ -3379,6 +3372,11 @@ impl YieldVault { // Verify invest slippage let to_strategy_val_after = Self::validate_strategy_response(&env, &to_strategy, &token_addr)?; + StrategyValidator::validate_deposit_result( + withdrawn_assets, + to_strategy_val_before, + to_strategy_val_after, + )?; let invested_value = to_strategy_val_after .checked_sub(to_strategy_val_before) .unwrap_or(0); diff --git a/contracts/vault/src/permissions.rs b/contracts/vault/src/permissions.rs index 80cf77995..5e177304f 100644 --- a/contracts/vault/src/permissions.rs +++ b/contracts/vault/src/permissions.rs @@ -13,7 +13,8 @@ //! - Migration-safe updates: old and new signer sets coexist during transition //! - Operations require threshold signatures from the current active set -use soroban_sdk::{Address, Vec}; +use crate::VaultError; +use soroban_sdk::{Address, Env, Map, Vec}; /// Verifies that the caller is the admin /// @@ -60,51 +61,75 @@ pub fn require_pauser_or_admin_auth(caller: &Address, admin: &Address, pauser: O pub struct MultiSignerValidator; impl MultiSignerValidator { - /// Verify that threshold signatures are satisfied. + /// Verify that threshold signatures are satisfied and that every approver + /// actually authorized this invocation. + /// + /// Runs [`Self::check_threshold`] first, then calls `require_auth` on each + /// approver, so an address listed in `approvals` without its signature + /// cannot count toward the threshold. /// /// ### Parameters /// * `signers` - Set of authorized signers for this operation /// * `threshold` - Number of required signatures (M of N) - /// * `approvals` - Vector of addresses that have approved (deduplicated, sorted) + /// * `approvals` - Addresses that have approved; each must sign the call /// - /// ### Returns - /// Ok if number of approvals >= threshold, Err otherwise + /// ### Errors + /// See [`Self::check_threshold`]; a missing signature aborts the call. pub fn verify_threshold( + env: &Env, signers: &Vec
, threshold: u32, approvals: &Vec
, - ) -> Result<(), &'static str> { - if threshold == 0 { - return Err("threshold must be > 0"); + ) -> Result<(), VaultError> { + Self::check_threshold(env, signers, threshold, approvals)?; + for approver in approvals.iter() { + approver.require_auth(); } - if threshold > signers.len() { - return Err("threshold exceeds signer set size"); + Ok(()) + } + + /// Validates `approvals` against `signers` and `threshold` without + /// checking signatures. + /// + /// ### Errors + /// * `VaultError::InvalidGovernanceThreshold` - threshold is 0 or exceeds the signer set + /// * `VaultError::DuplicateVote` - the same approver appears more than once + /// * `VaultError::GovernanceThresholdNotMet` - an approver is not a signer, + /// or there are fewer approvals than `threshold` + pub fn check_threshold( + env: &Env, + signers: &Vec
, + threshold: u32, + approvals: &Vec
, + ) -> Result<(), VaultError> { + if threshold == 0 || threshold > signers.len() { + return Err(VaultError::InvalidGovernanceThreshold); + } + + // Map-backed sets give logarithmic membership / duplicate checks + // instead of the previous O(n^2) pairwise scans. + let mut signer_set: Map = Map::new(env); + for signer in signers.iter() { + signer_set.set(signer, ()); } // Reject duplicate approvals before counting toward the threshold. // This prevents a single signer from replaying the same authorization // multiple times to satisfy an M-of-N requirement. - for i in 0..approvals.len() { - let first = approvals.get(i).unwrap(); - for j in (i + 1)..approvals.len() { - let second = approvals.get(j).unwrap(); - if first == second { - return Err("duplicate signer approval"); - } + let mut seen: Map = Map::new(env); + for approver in approvals.iter() { + if seen.contains_key(approver.clone()) { + return Err(VaultError::DuplicateVote); + } + if !signer_set.contains_key(approver.clone()) { + return Err(VaultError::GovernanceThresholdNotMet); } + seen.set(approver, ()); } if approvals.len() < threshold { - return Err("insufficient approvals"); + return Err(VaultError::GovernanceThresholdNotMet); } - - // Verify all approvers are in the signer set. - for approver in approvals.iter() { - if !signers.iter().any(|s| s == approver) { - return Err("unauthorized signer"); - } - } - Ok(()) } @@ -132,27 +157,72 @@ mod tests { // Actual enforcement is tested in lib.rs via role gating tests } + fn signers(env: &soroban_sdk::Env, n: usize) -> Vec
{ + let mut v = Vec::new(env); + for _ in 0..n { + v.push_back(Address::generate(env)); + } + v + } + #[test] fn test_threshold_valid_approvals() { let env = soroban_sdk::Env::default(); - let signers = Vec::from_array( - &env, - [ - Address::generate(&env), - Address::generate(&env), - Address::generate(&env), - ], - ); + let signers = signers(&env, 3); let approvals = Vec::from_array(&env, [signers.get(0).unwrap(), signers.get(1).unwrap()]); - assert!(MultiSignerValidator::verify_threshold(&signers, 2, &approvals).is_ok()); + assert_eq!( + MultiSignerValidator::check_threshold(&env, &signers, 2, &approvals), + Ok(()) + ); } #[test] fn test_threshold_insufficient_approvals() { let env = soroban_sdk::Env::default(); - let signers = Vec::from_array(&env, [Address::generate(&env), Address::generate(&env)]); + let signers = signers(&env, 2); let approvals = Vec::from_array(&env, [signers.get(0).unwrap()]); - assert!(MultiSignerValidator::verify_threshold(&signers, 2, &approvals).is_err()); + assert_eq!( + MultiSignerValidator::check_threshold(&env, &signers, 2, &approvals), + Err(VaultError::GovernanceThresholdNotMet) + ); + } + + #[test] + fn test_threshold_rejects_invalid_threshold() { + let env = soroban_sdk::Env::default(); + let signers = signers(&env, 2); + let approvals = signers.clone(); + assert_eq!( + MultiSignerValidator::check_threshold(&env, &signers, 0, &approvals), + Err(VaultError::InvalidGovernanceThreshold) + ); + assert_eq!( + MultiSignerValidator::check_threshold(&env, &signers, 3, &approvals), + Err(VaultError::InvalidGovernanceThreshold) + ); + } + + #[test] + fn test_threshold_rejects_duplicate_approval() { + let env = soroban_sdk::Env::default(); + let signers = signers(&env, 3); + let first = signers.get(0).unwrap(); + let approvals = Vec::from_array(&env, [first.clone(), first]); + assert_eq!( + MultiSignerValidator::check_threshold(&env, &signers, 2, &approvals), + Err(VaultError::DuplicateVote) + ); + } + + #[test] + fn test_threshold_rejects_non_signer_approval() { + let env = soroban_sdk::Env::default(); + let signers = signers(&env, 3); + let approvals = Vec::from_array(&env, [signers.get(0).unwrap(), Address::generate(&env)]); + assert_eq!( + MultiSignerValidator::check_threshold(&env, &signers, 2, &approvals), + Err(VaultError::GovernanceThresholdNotMet) + ); } #[test] @@ -166,4 +236,57 @@ mod tests { let result = MultiSignerValidator::is_migration_active(Some(1), Some(2), 1000, 1500); assert!(!result); } + + /// Contract-level spoof rejection: approvals only count for signers that + /// actually signed the `require_governance_threshold` invocation. + #[test] + fn test_require_governance_threshold_needs_each_approver_signature() { + use crate::{YieldVault, YieldVaultClient}; + use soroban_sdk::testutils::{MockAuth, MockAuthInvoke}; + use soroban_sdk::IntoVal; + + let env = soroban_sdk::Env::default(); + let vault_id = env.register(YieldVault, ()); + let vault = YieldVaultClient::new(&env, &vault_id); + let admin = Address::generate(&env); + let signers = signers(&env, 3); + + env.mock_all_auths(); + vault.initialize(&admin, &Address::generate(&env)); + vault.set_governance_signers(&signers, &2, &0); + + let approvals = Vec::from_array(&env, [signers.get(0).unwrap(), signers.get(1).unwrap()]); + let args = (approvals.clone(),).into_val(&env); + let invoke = MockAuthInvoke { + contract: &vault_id, + fn_name: "require_governance_threshold", + args, + sub_invokes: &[], + }; + let first = signers.get(0).unwrap(); + let second = signers.get(1).unwrap(); + + // Only the first approver signed: the second is a spoofed entry. + env.mock_auths(&[MockAuth { + address: &first, + invoke: &invoke, + }]); + assert!(vault.try_require_governance_threshold(&approvals).is_err()); + + // Both approvers signed. + env.mock_auths(&[ + MockAuth { + address: &first, + invoke: &invoke, + }, + MockAuth { + address: &second, + invoke: &invoke, + }, + ]); + assert_eq!( + vault.try_require_governance_threshold(&approvals), + Ok(Ok(())) + ); + } } diff --git a/contracts/vault/src/test.rs b/contracts/vault/src/test.rs index 930e4a86f..50c5d94b3 100644 --- a/contracts/vault/src/test.rs +++ b/contracts/vault/src/test.rs @@ -743,6 +743,77 @@ fn test_strategy_response_rejects_negative_total_value() { assert_eq!(result, Err(VaultError::InvalidAmount)); } +/// Registers a `MaliciousStrategy` reporting `value` in `asset`. +fn seeded_strategy(env: &Env, vault_id: &Address, asset: &Address, value: i128) -> Address { + let strategy_id = env.register(MaliciousStrategy, ()); + env.as_contract(&strategy_id, || { + let storage = env.storage().instance(); + storage.set(&StrategyTestKey::Vault, vault_id); + storage.set(&StrategyTestKey::Asset, asset); + storage.set(&StrategyTestKey::Value, &value); + }); + strategy_id +} + +/// Issue #1530: strategy reads enforce the full `StrategyValidator` bounds. +#[test] +fn test_strategy_response_value_bounds() { + use crate::strategy_validation::MAX_STRATEGY_VALUE; + + let env = Env::default(); + env.mock_all_auths(); + let vault_id = env.register(YieldVault, ()); + let vault = YieldVaultClient::new(&env, &vault_id); + let usdc = create_token(&env, &Address::generate(&env)); + vault.initialize(&Address::generate(&env), &usdc.address); + + for (value, expected) in [ + (0, Ok(0)), + (MAX_STRATEGY_VALUE, Ok(MAX_STRATEGY_VALUE)), + (MAX_STRATEGY_VALUE + 1, Err(VaultError::MathOverflow)), + (i128::MAX, Err(VaultError::MathOverflow)), + (-1, Err(VaultError::InvalidAmount)), + ] { + let strategy = seeded_strategy(&env, &vault_id, &usdc.address, value); + assert_eq!( + YieldVault::validate_strategy_response(&env, &strategy, &usdc.address), + expected, + "value {value}" + ); + } +} + +#[test] +fn test_strategy_response_rejects_zero_strategy_address() { + use crate::strategy_validation::ZERO_CONTRACT_STRKEY; + + let env = Env::default(); + let usdc = create_token(&env, &Address::generate(&env)); + let zero = Address::from_str(&env, ZERO_CONTRACT_STRKEY); + assert_eq!( + YieldVault::validate_strategy_response(&env, &zero, &usdc.address), + Err(VaultError::InvalidAmount) + ); +} + +#[test] +fn test_total_assets_rejects_strategy_value_above_max() { + use crate::strategy_validation::MAX_STRATEGY_VALUE; + + let env = Env::default(); + env.mock_all_auths(); + let vault_id = env.register(YieldVault, ()); + let vault = YieldVaultClient::new(&env, &vault_id); + let usdc = create_token(&env, &Address::generate(&env)); + vault.initialize(&Address::generate(&env), &usdc.address); + + let strategy = seeded_strategy(&env, &vault_id, &usdc.address, MAX_STRATEGY_VALUE + 1); + vault.whitelist_strategy(&strategy, &true); + vault.set_strategy(&strategy); + + assert!(vault.try_total_assets().is_err()); +} + #[test] #[should_panic] fn test_report_benji_yield_before_strategy_configured_panics_on_missing_key() {