diff --git a/backend/src/__tests__/governance.test.ts b/backend/src/__tests__/governance.test.ts index cb2ea92..e83f1a3 100644 --- a/backend/src/__tests__/governance.test.ts +++ b/backend/src/__tests__/governance.test.ts @@ -7,6 +7,8 @@ import { registerApiKey } from '../middleware/apiKeyAuth'; describe('Backend governance', () => { const adminApiKey = 'admin-test-key'; + const superAdminApiKey = 'super-admin-test-key'; + const targetWallet = 'GABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz234567'; beforeEach(() => { idempotencyStore.clear(); @@ -14,6 +16,7 @@ describe('Backend governance', () => { clearAdminAuditLogsForTests(); process.env.ADMIN_AUDIT_LOG_STORAGE = 'memory'; registerApiKey(adminApiKey); + registerApiKey(superAdminApiKey, { role: 'super-admin' }); }); it('marks unversioned summary route as deprecated while preserving compatibility', async () => { @@ -141,4 +144,90 @@ describe('Backend governance', () => { expect(response.body.data[0]).toHaveProperty('method'); expect(response.body.data[0]).toHaveProperty('statusCode'); }); -}); \ No newline at end of file + + it('allows super-admins to impersonate a wallet with the same user-visible data', async () => { + const [summary, transactions, holdings, history, referralStats, referralCode] = await Promise.all([ + request(app).get('/api/v1/vault/summary'), + request(app).get('/api/v1/transactions').query({ walletAddress: targetWallet }), + request(app).get('/api/v1/portfolio/holdings').query({ walletAddress: targetWallet }), + request(app).get('/api/v1/vault/history'), + request(app).get(`/api/v1/referrals/${targetWallet}`), + request(app).get(`/api/v1/referrals/code/${targetWallet}`), + ]); + + const response = await request(app) + .get(`/admin/impersonate/${targetWallet}`) + .set('Authorization', `ApiKey ${superAdminApiKey}`) + .set('x-admin-id', 'GADMIN000000000000000000000000000000000000000000000001'); + + expect(response.status).toBe(200); + expect(response.body.walletAddress).toBe(targetWallet); + expect(response.body.summary).toMatchObject({ + totalAssets: summary.body.totalAssets, + totalShares: summary.body.totalShares, + apy: summary.body.apy, + }); + expect(response.body.transactions.data).toEqual(transactions.body.data); + expect(response.body.transactions.pagination).toEqual(transactions.body.pagination); + expect(response.body.portfolioHoldings.data).toEqual(holdings.body.data); + expect(response.body.portfolioHoldings.pagination).toEqual(holdings.body.pagination); + expect(response.body.vaultHistory.data).toEqual(history.body.data); + expect(response.body.vaultHistory.pagination).toEqual(history.body.pagination); + expect(response.body.referralStats).toEqual({ + statusCode: referralStats.status, + body: referralStats.body, + }); + expect(response.body.referralCode).toEqual({ + statusCode: referralCode.status, + body: referralCode.body, + }); + + const auditResponse = await request(app) + .get('/admin/audit/logs') + .query({ action: 'admin.impersonate', limit: 5 }) + .set('Authorization', `ApiKey ${superAdminApiKey}`); + + expect(auditResponse.status).toBe(200); + expect(auditResponse.body.logs[0]).toMatchObject({ + action: 'admin.impersonate', + actor: 'GADMIN000000000000000000000000000000000000000000000001', + statusCode: 200, + metadata: { + actingAdminAddress: 'GADMIN000000000000000000000000000000000000000000000001', + adminRole: 'super-admin', + targetWallet, + impersonation: true, + }, + }); + }); + + it('returns 403 for non-super-admin impersonation attempts and still audits them', async () => { + const actingAdmin = 'GADMIN000000000000000000000000000000000000000000000002'; + + const response = await request(app) + .get(`/admin/impersonate/${targetWallet}`) + .set('Authorization', `ApiKey ${adminApiKey}`) + .set('x-admin-id', actingAdmin); + + expect(response.status).toBe(403); + expect(response.body.message).toMatch(/super-admin/i); + + const auditResponse = await request(app) + .get('/admin/audit/logs') + .query({ action: 'admin.impersonate.denied', limit: 5 }) + .set('Authorization', `ApiKey ${adminApiKey}`); + + expect(auditResponse.status).toBe(200); + expect(auditResponse.body.logs[0]).toMatchObject({ + action: 'admin.impersonate.denied', + actor: actingAdmin, + statusCode: 403, + metadata: { + actingAdminAddress: actingAdmin, + adminRole: 'admin', + targetWallet, + impersonation: true, + }, + }); + }); +}); diff --git a/backend/src/__tests__/mocks/prismainstrumentation.js b/backend/src/__tests__/mocks/prismainstrumentation.js new file mode 100644 index 0000000..36f797d --- /dev/null +++ b/backend/src/__tests__/mocks/prismainstrumentation.js @@ -0,0 +1,10 @@ +class PrismaInstrumentation { + setTracerProvider() {} + setMeterProvider() {} + enable() {} + disable() {} +} + +module.exports = { + PrismaInstrumentation, +}; diff --git a/backend/src/__tests__/preload.js b/backend/src/__tests__/preload.js new file mode 100644 index 0000000..ff5eaaa --- /dev/null +++ b/backend/src/__tests__/preload.js @@ -0,0 +1,2 @@ +process.env.NODE_ENV = 'test'; +process.env.OTEL_ENABLED = 'false'; diff --git a/backend/src/adminAudit.ts b/backend/src/adminAudit.ts index 796d3b2..2272c91 100644 --- a/backend/src/adminAudit.ts +++ b/backend/src/adminAudit.ts @@ -103,7 +103,7 @@ export async function listAdminAuditLogs(filters: AuditLogFilters): Promise ({ + return rows.map((row: any) => ({ id: row.id, action: row.action, method: row.method, @@ -176,4 +176,4 @@ function safeParseMetadata(raw: string): Record { export function clearAdminAuditLogsForTests(): void { inMemoryLogs.length = 0; resetAuditLogs(); -} \ No newline at end of file +} diff --git a/backend/src/auditLog.ts b/backend/src/auditLog.ts index 7ede505..c4bdcf0 100644 --- a/backend/src/auditLog.ts +++ b/backend/src/auditLog.ts @@ -1,6 +1,16 @@ import type { NextFunction, Request, Response } from 'express'; import crypto from 'crypto'; +declare global { + namespace Express { + interface Request { + adminAuditAction?: string; + adminAuditActor?: string; + adminAuditMetadata?: Record; + } + } +} + export interface AuditLogEntry { id: string; timestamp: string; @@ -12,6 +22,7 @@ export interface AuditLogEntry { durationMs: number; ip: string; correlationId?: string; + metadata?: Record; } interface AuditLogFilters { @@ -38,11 +49,12 @@ export function createAdminAuditMiddleware() { actor, method: req.method, path: req.originalUrl || req.path, - action: buildAction(req.method, req.path), + action: buildAction(req), statusCode: res.statusCode, durationMs: Date.now() - startedAt, ip: req.ip || 'unknown', correlationId: req.header('x-correlation-id') || undefined, + metadata: req.adminAuditMetadata, }; entries.unshift(entry); @@ -97,11 +109,32 @@ export function resetAuditLogs() { entries.length = 0; } -function buildAction(method: string, path: string): string { - return `${method.toUpperCase()} ${path}`; +function buildAction(req: Request): string { + if (req.adminAuditAction) { + return req.adminAuditAction; + } + + return `${req.method.toUpperCase()} ${req.path}`; } function resolveActor(req: Request): string { + if (req.adminAuditActor) { + return req.adminAuditActor; + } + + const explicitActor = + req.header('x-admin-address') || + req.header('x-admin-id') || + req.header('x-wallet-address'); + if (explicitActor) { + return explicitActor; + } + + const actionOverride = req.adminAuditAction; + if (actionOverride) { + return req.adminAuditActor || 'unknown'; + } + const authHeader = req.header('authorization'); if (!authHeader) { diff --git a/backend/src/index.ts b/backend/src/index.ts index 2d29403..8b1b461 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -11,12 +11,22 @@ initTracing(); import express, { Express, Request, Response, NextFunction, ErrorRequestHandler } from 'express'; import rateLimit from 'express-rate-limit'; import NodeCache from 'node-cache'; +import { loginHandler, refreshHandler } from './auth'; +import { createAdminAuditMiddleware, getAuditLogs, getAuditLogMetrics } from './auditLog'; +import { recordAdminAuditLog } from './adminAudit'; +import { startApySnapshotScheduler } from './apySnapshot'; +import { sorobanCircuitBreaker } from './circuitBreaker'; import { correlationIdMiddleware, CorrelationIdRequest } from './middleware/correlationId'; import { structuredLoggingMiddleware, logger, LogLevel } from './middleware/structuredLogging'; import { corsMiddleware } from './middleware/cors'; import { geofencingMiddleware } from './middleware/geofencing'; import { cacheMiddleware, invalidateCache, getCacheStats } from './middleware/cache'; -import { validateApiKey, registerApiKey } from './middleware/apiKeyAuth'; +import { + validateApiKey, + registerApiKey, + hasRequiredApiKeyRole, + normalizeApiKeyRole, +} from './middleware/apiKeyAuth'; import { addAddress, removeAddress, @@ -26,8 +36,14 @@ import { import { GracefulShutdownHandler } from './gracefulShutdown'; import { db } from './database'; import vaultRouter from './vaultEndpoints'; +import { + buildPortfolioHoldingsResponse, + buildTransactionsResponse, + buildVaultHistoryResponse, +} from './listEndpoints'; import listRouter from './listEndpoints'; import referralRouter from './referralEndpoints'; +import { referralService } from './referralService'; import { register, httpRequestCount, @@ -36,6 +52,15 @@ import { updateVaultMetrics, } from './metrics'; import { latencyMonitoringService } from './latencyMonitoring'; +import { prisma, getPrismaRuntimeConfig } from './prisma'; +import { + registerWebhookEndpoint, + updateWebhookEndpoint, + listWebhookEndpoints, + listWebhookDeliveries, + getWebhookDeliveryMetrics, +} from './webhookDelivery'; +import { getJobMetrics, getJobHealthStatus } from './jobGovernance'; declare global { namespace Express { @@ -62,6 +87,58 @@ logger.configure(logLevel); // Health check cache to track dependency status const cache = new NodeCache({ stdTTL: 30 }); +function buildVaultSummaryResponse() { + return { + totalAssets: 0, + totalShares: 0, + apy: 0, + timestamp: new Date().toISOString(), + }; +} + +function resolveActingAdminAddress(req: Request): string { + return ( + req.get('x-admin-address') || + req.get('x-admin-id') || + req.get('x-wallet-address') || + 'unknown' + ); +} + +async function buildReferralStatsSnapshot(wallet: string) { + const stats = await referralService.getReferralStats(wallet); + if (!stats) { + return { + statusCode: 404, + body: { + error: 'Not Found', + status: 404, + message: 'No referral activity found for this wallet', + }, + }; + } + + return { + statusCode: 200, + body: stats, + }; +} + +async function buildImpersonatedVaultState(wallet: string) { + return { + walletAddress: wallet, + summary: buildVaultSummaryResponse(), + transactions: buildTransactionsResponse({ walletAddress: wallet }), + portfolioHoldings: buildPortfolioHoldingsResponse({ walletAddress: wallet }), + vaultHistory: buildVaultHistoryResponse({}), + referralStats: await buildReferralStatsSnapshot(wallet), + referralCode: { + statusCode: 200, + body: { code: await referralService.getOrCreateReferralCode(wallet) }, + }, + }; +} + // ─── Rate Limiting Middleware ──────────────────────────────────────────────── // Issue #145: Rate limiting per IP/user key @@ -308,13 +385,7 @@ app.get( apiLimiter, cacheMiddleware({ ttl: cacheVaultMetricsTtl }), (_req: Request, res: Response) => { - // This would typically fetch data from Stellar RPC or database - res.json({ - totalAssets: 0, - totalShares: 0, - apy: 0, - timestamp: new Date().toISOString(), - }); + res.json(buildVaultSummaryResponse()); }, ); @@ -324,12 +395,7 @@ app.get( cacheMiddleware({ ttl: cacheVaultMetricsTtl }), (_req: Request, res: Response) => { res.setHeader('deprecation', 'true'); - res.json({ - totalAssets: 0, - totalShares: 0, - apy: 0, - timestamp: new Date().toISOString(), - }); + res.json(buildVaultSummaryResponse()); }, ); @@ -447,21 +513,87 @@ app.get('/admin/allowlist', validateApiKey, (_req: Request, res: Response) => { }); }); +/** + * GET /admin/impersonate/:wallet - inspect vault state as a specific wallet + * Requires super-admin API key. + */ +app.get('/admin/impersonate/:wallet', validateApiKey, async (req: Request, res: Response) => { + const wallet = String(req.params.wallet || '').trim(); + const actingAdminAddress = resolveActingAdminAddress(req); + + req.adminAuditActor = actingAdminAddress; + req.adminAuditMetadata = { + actingAdminAddress, + adminRole: req.authApiKeyRole || 'admin', + targetWallet: wallet || 'unknown', + 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; + } + + req.adminAuditAction = 'admin.impersonate'; + + try { + const snapshot = await buildImpersonatedVaultState(wallet); + res.status(200).json(snapshot); + } 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', + }); + } +}); + /** * POST /admin/api-keys/register - Register a new API key * Requires API key authentication (for boostrapping, requires special permission) */ app.post('/admin/api-keys/register', validateApiKey, (req: Request, res: Response) => { - const { key } = req.body; + const { key, role: requestedRole } = req.body; if (!key) { res.status(400).json({ error: 'Missing key in request body' }); return; } - const hash = registerApiKey(key); + 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 hash = registerApiKey(key, { role }); res.json({ message: 'API key registered', hash, + role, created: new Date().toISOString(), }); }); @@ -758,6 +890,24 @@ async function getDatabaseHealth(): Promise<{ primary: string; replica: string } } } +async function getPrismaHealth(): Promise<'up' | 'down'> { + try { + await prisma.$queryRaw`SELECT 1`; + return 'up'; + } catch { + return '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/listEndpoints.ts b/backend/src/listEndpoints.ts index 85a1a72..152d5ab 100644 --- a/backend/src/listEndpoints.ts +++ b/backend/src/listEndpoints.ts @@ -17,7 +17,10 @@ import { sendPaginatedResponse, encodeCursor, PaginationConfig, + createPaginatedResponse, + PaginatedResponse, } from './pagination'; +import { getApyHistory } from './apySnapshot'; import { cacheMiddleware } from './middleware/cache'; const router = Router(); @@ -102,6 +105,19 @@ interface VaultHistoryPoint { [key: string]: unknown; } +export interface WalletStateQuery { + limit?: number; + cursor?: string; + page?: number; + sortBy?: string; + sortOrder?: 'asc' | 'desc'; + type?: string; + status?: string; + from?: string; + to?: string; + walletAddress?: string; +} + // ─── Mock Data ────────────────────────────────────────────────────────────── const MOCK_TRANSACTIONS: Transaction[] = Array.from({ length: 100 }, (_, i) => ({ @@ -258,6 +274,88 @@ function filterVaultHistory( }); } +export function buildTransactionsResponse( + query: WalletStateQuery +): PaginatedResponse { + const pagination = { + limit: query.limit ?? TRANSACTION_PAGINATION_CONFIG.defaultLimit ?? 20, + cursor: query.cursor, + page: query.page, + sortBy: query.sortBy ?? TRANSACTION_PAGINATION_CONFIG.defaultSortBy, + sortOrder: query.sortOrder ?? TRANSACTION_PAGINATION_CONFIG.defaultSortOrder ?? 'desc', + }; + const filters = { + type: query.type, + status: query.status, + from: query.from, + to: query.to, + walletAddress: query.walletAddress, + }; + + let filtered = filterTransactions(MOCK_TRANSACTIONS, filters); + if (pagination.sortBy) { + filtered = sortItems(filtered, pagination.sortBy, pagination.sortOrder); + } + + const paginated = pagination.page + ? paginateWithOffset(filtered, pagination) + : paginateWithCursor(filtered, pagination, (tx) => encodeCursor(tx.id)); + + return createPaginatedResponse(paginated.data, paginated.pagination); +} + +export function buildPortfolioHoldingsResponse( + query: WalletStateQuery +): PaginatedResponse { + const pagination = { + limit: query.limit ?? PORTFOLIO_PAGINATION_CONFIG.defaultLimit ?? 20, + cursor: query.cursor, + sortBy: query.sortBy ?? PORTFOLIO_PAGINATION_CONFIG.defaultSortBy, + sortOrder: query.sortOrder ?? PORTFOLIO_PAGINATION_CONFIG.defaultSortOrder ?? 'desc', + }; + const filters = { + status: query.status, + walletAddress: query.walletAddress, + }; + + let filtered = filterPortfolioHoldings(MOCK_PORTFOLIO_HOLDINGS, filters); + if (pagination.sortBy) { + filtered = sortItems(filtered, pagination.sortBy, pagination.sortOrder); + } + + const paginated = paginateWithCursor(filtered, pagination, (holding) => + encodeCursor(holding.id) + ); + + return createPaginatedResponse(paginated.data, paginated.pagination); +} + +export function buildVaultHistoryResponse( + query: Pick +): PaginatedResponse { + const pagination = { + limit: query.limit ?? VAULT_HISTORY_PAGINATION_CONFIG.defaultLimit ?? 30, + cursor: query.cursor, + sortBy: query.sortBy ?? VAULT_HISTORY_PAGINATION_CONFIG.defaultSortBy, + sortOrder: query.sortOrder ?? VAULT_HISTORY_PAGINATION_CONFIG.defaultSortOrder ?? 'desc', + }; + const filters = { + from: query.from, + to: query.to, + }; + + let filtered = filterVaultHistory(MOCK_VAULT_HISTORY, filters); + if (pagination.sortBy) { + filtered = sortItems(filtered, pagination.sortBy, pagination.sortOrder); + } + + const paginated = paginateWithCursor(filtered, pagination, (point) => + encodeCursor(point.date) + ); + + return createPaginatedResponse(paginated.data, paginated.pagination); +} + // ─── Endpoints ────────────────────────────────────────────────────────────── /** @@ -310,27 +408,16 @@ function filterVaultHistory( router.get('/transactions', cacheMiddleware({ ttl: CACHE_TTL_MS }), (req: Request, res: Response) => { try { const pagination = parsePaginationQuery(req, TRANSACTION_PAGINATION_CONFIG); - const filters = { + const response = buildTransactionsResponse({ + ...pagination, type: req.query.type as string | undefined, status: req.query.status as string | undefined, from: req.query.from as string | undefined, to: req.query.to as string | undefined, walletAddress: req.query.walletAddress as string | undefined, - }; - - // Filter transactions - let filtered = filterTransactions(MOCK_TRANSACTIONS, filters); - - // Sort transactions - if (pagination.sortBy) { - filtered = sortItems(filtered, pagination.sortBy, pagination.sortOrder || 'desc'); - } - - const paginated = pagination.page - ? paginateWithOffset(filtered, pagination) - : paginateWithCursor(filtered, pagination, (tx) => encodeCursor(tx.id)); + }); - sendPaginatedResponse(res, paginated.data, paginated.pagination); + sendPaginatedResponse(res, response.data, response.pagination); } catch (error) { console.error('Error fetching transactions:', error); res.status(500).json({ @@ -365,27 +452,13 @@ router.get('/transactions', cacheMiddleware({ ttl: CACHE_TTL_MS }), (req: Reques router.get('/portfolio/holdings', cacheMiddleware({ ttl: CACHE_TTL_MS }), (req: Request, res: Response) => { try { const pagination = parsePaginationQuery(req, PORTFOLIO_PAGINATION_CONFIG); - const filters = { + const response = buildPortfolioHoldingsResponse({ + ...pagination, status: req.query.status as string | undefined, walletAddress: req.query.walletAddress as string | undefined, - }; - - // Filter holdings - let filtered = filterPortfolioHoldings(MOCK_PORTFOLIO_HOLDINGS, filters); - - // Sort holdings - if (pagination.sortBy) { - filtered = sortItems(filtered, pagination.sortBy, pagination.sortOrder || 'desc'); - } - - // Paginate with cursor - const { data, pagination: paginationMeta } = paginateWithCursor( - filtered, - pagination, - (holding) => encodeCursor(holding.id) - ); + }); - sendPaginatedResponse(res, data, paginationMeta); + sendPaginatedResponse(res, response.data, response.pagination); } catch (error) { console.error('Error fetching portfolio holdings:', error); res.status(500).json({ @@ -420,25 +493,13 @@ router.get('/portfolio/holdings', cacheMiddleware({ ttl: CACHE_TTL_MS }), (req: router.get('/vault/history', cacheMiddleware({ ttl: CACHE_TTL_MS }), (req: Request, res: Response) => { try { const pagination = parsePaginationQuery(req, VAULT_HISTORY_PAGINATION_CONFIG); - const filters = { + const response = buildVaultHistoryResponse({ + ...pagination, from: req.query.from as string | undefined, to: req.query.to as string | undefined, - }; - - // Filter history - let filtered = filterVaultHistory(MOCK_VAULT_HISTORY, filters); - - // Sort history - if (pagination.sortBy) { - filtered = sortItems(filtered, pagination.sortBy, pagination.sortOrder || 'desc'); - } - - // Paginate with cursor - const { data, pagination: paginationMeta } = paginateWithCursor(filtered, pagination, (point) => - encodeCursor(point.date) - ); + }); - sendPaginatedResponse(res, data, paginationMeta); + sendPaginatedResponse(res, response.data, response.pagination); } catch (error) { console.error('Error fetching vault history:', error); res.status(500).json({ diff --git a/backend/src/middleware/apiKeyAuth.ts b/backend/src/middleware/apiKeyAuth.ts index 68eef3b..4b6edbb 100644 --- a/backend/src/middleware/apiKeyAuth.ts +++ b/backend/src/middleware/apiKeyAuth.ts @@ -5,13 +5,17 @@ declare global { namespace Express { interface Request { authApiKeyHash?: string; + authApiKeyRole?: ApiKeyRole; } } } +export type ApiKeyRole = 'admin' | 'super-admin'; + interface ApiKeyMetadata { createdAt: Date; rotatedAt?: Date; + role: ApiKeyRole; } const API_KEYS = new Map(); // hash -> key metadata @@ -34,8 +38,9 @@ export function validateApiKey( const providedKey = match[1]; const hash = hashApiKey(providedKey); + const metadata = API_KEYS.get(hash); - if (!API_KEYS.has(hash)) { + if (!metadata) { res.status(401).json({ error: 'Unauthorized', message: 'Invalid API key', @@ -44,6 +49,7 @@ export function validateApiKey( } req.authApiKeyHash = hash; + req.authApiKeyRole = metadata.role; next(); } @@ -52,9 +58,15 @@ export function hashApiKey(key: string): string { return crypto.createHash('sha256').update(key).digest('hex'); } -export function registerApiKey(key: string): string { +export function registerApiKey( + key: string, + options: { role?: ApiKeyRole } = {}, +): string { const hash = hashApiKey(key); - API_KEYS.set(hash, { createdAt: new Date() }); + API_KEYS.set(hash, { + createdAt: new Date(), + role: options.role || 'admin', + }); return hash; } @@ -74,7 +86,28 @@ export function rotateApiKey(oldHash: string, newKey: string): string | null { API_KEYS.set(newHash, { createdAt: metadata.createdAt, rotatedAt: new Date(), + role: metadata.role, }); return newHash; } + +export function hasRequiredApiKeyRole( + req: Request, + requiredRole: ApiKeyRole, +): boolean { + const role = req.authApiKeyRole || 'admin'; + if (requiredRole === 'admin') { + return true; + } + + return role === 'super-admin'; +} + +export function normalizeApiKeyRole(raw: unknown): ApiKeyRole | null { + if (raw === 'admin' || raw === 'super-admin') { + return raw; + } + + return null; +} diff --git a/backend/src/pagination.ts b/backend/src/pagination.ts index e241795..c46f10c 100644 --- a/backend/src/pagination.ts +++ b/backend/src/pagination.ts @@ -189,11 +189,8 @@ export function paginateWithCursor( : {}), }, }; - if (cursorIndex !== -1) { - startIndex = cursorIndex + 1; - } else { - invalidCursor = true; } + startIndex = cursorIndex + 1; } diff --git a/backend/src/vaultEndpoints.ts b/backend/src/vaultEndpoints.ts index 2656eda..63a0b7d 100644 --- a/backend/src/vaultEndpoints.ts +++ b/backend/src/vaultEndpoints.ts @@ -1,12 +1,15 @@ import { Router, Request, Response } from 'express'; import { emailService } from './emailService'; import { logger } from './middleware/structuredLogging'; +import { allowlistMiddleware } from './middleware/allowlist'; +import { invalidateCache } from './middleware/cache'; import { idempotencyStore, IdempotencyConflictError } from './idempotency'; import { sorobanCircuitBreaker, CircuitOpenError } from './circuitBreaker'; import { withSpan, getCurrentTraceId } from './tracing'; import { requireFlag } from './featureFlags'; import { referralService } from './referralService'; import { getPrismaClient } from './prismaClient'; +import { emitTransactionEvent, TransactionEventType } from './webhookDelivery'; import crypto from 'crypto'; const router = Router();