diff --git a/readme.md b/readme.md index 32ab87c..7980623 100644 --- a/readme.md +++ b/readme.md @@ -1,178 +1,48 @@ +# NeuroWealth Backend -# NeuroWealth — Backend +Express + TypeScript REST API for the NeuroWealth platform — AI-assisted portfolio management backed by Stellar smart contracts. -About ------ -NeuroWealth is an autonomous AI investment agent that automatically manages and grows users' crypto assets on the Stellar blockchain. Deposit once, the AI finds the best yield opportunities across Stellar's DeFi ecosystem; users can withdraw anytime with no lock-ups. +## API Documentation -This repository contains the backend API (Express + TypeScript), Stellar integration, Prisma schema and migrations, and utilities for authentication (Stellar signature challenge + JWT sessions). +The full OpenAPI 3.1 specification lives at [`docs/openapi.yaml`](docs/openapi.yaml). -Quickstart ----------- -1. Copy the example environment file and adjust secrets: +It covers: -```powershell -copy .env.example .env -``` - -2. Edit `.env` and set secure values: -- `DATABASE_URL` — PostgreSQL connection string (see below) -- `DB_NAME`, `DB_PASSWORD` — used by `docker-compose.yml` when running Postgres locally -- `JWT_SEED` — 64-hex secret (generate with `openssl rand -hex 64`) -- `WALLET_ENCRYPTION_KEY` — 32-byte hex (generate with `openssl rand -hex 32`) - -Docker (Postgres) ------------------- -To run a local Postgres instance used by the project: - -```powershell -docker compose up -d -docker compose ps -docker compose logs anylistDB --tail 200 -``` - -The `docker-compose.yml` expects these env vars (set them in your `.env`): - -``` -DB_NAME=neurowealth -DB_PASSWORD=postgres_password_here -DATABASE_URL=postgresql://postgres:postgres_password_here@localhost:5432/neurowealth -``` - -Prisma & Database migrations ----------------------------- -Generate the Prisma client (run after any `schema.prisma` change): - -```bash -npx prisma generate -``` - -Create and apply a migration (development): - -```bash -npx prisma migrate dev --name init -``` +| Tag | Base path | Auth | +|---|---|---| +| health | `/health` | None | +| auth | `/api/auth` | None (issues JWT) | +| portfolio | `/api/portfolio` | Bearer JWT | +| transactions | `/api/transactions` | Bearer JWT | +| deposit | `/api/deposit` | Bearer JWT | +| withdraw | `/api/withdraw` | Bearer JWT | +| vault | `/api/vault` | Bearer JWT | +| admin | `/api/admin` | `X-Admin-Token` header | -Notes: -- `migrate dev` will create a new migration in `prisma/migrations/` and apply it to the database specified by `DATABASE_URL`. -- To reset a development database (WARNING: destroys data): +### Viewing the docs locally ```bash -npx prisma migrate reset -# or if your Prisma version requires preview option -npx prisma migrate reset --preview-feature +npx @redocly/cli preview-docs docs/openapi.yaml ``` -Apply migrations in production (use CI or a deployment task): +### Updating the spec -```bash -npx prisma migrate deploy -``` +When you add or change a route, update `docs/openapi.yaml` in the same PR. The `api-contract` CI job will lint the spec and run smoke tests automatically. -Seeding -------- -If you have a seed script (see `prisma/seed.ts`), run: +### Breaking-change policy -```bash -npx prisma db seed -``` +This API follows semantic versioning. Breaking changes (removed fields, changed response shapes, new required parameters) increment the major version and are announced at least two weeks before release. -Running the backend -------------------- -Development (with ts-node + nodemon): +## Development ```bash +cp .env.example .env npm install npm run dev ``` -Build and run: - -```bash -npm run build -npm start -``` - -Rate limiting -------------- -The API applies layered rate limits (all configurable via `.env`). Health probe paths (`/health/live`, `/health/ready`, `/health/*`) are exempt from the global limiter so Kubernetes and load-balancer checks are not throttled. - -| Limiter | Routes | Default | Env vars | -|---------|--------|---------|----------| -| Global | All routes except health probes | 100 req / 15 min | `RATE_LIMIT_MAX`, `RATE_LIMIT_WINDOW_MS` | -| Auth | `/api/auth/*` | 20 req / 15 min | `AUTH_RATE_LIMIT_MAX`, `AUTH_RATE_LIMIT_WINDOW_MS` | -| Admin | `/api/admin/*` | 10 req / 15 min | `ADMIN_RATE_LIMIT_MAX`, `ADMIN_RATE_LIMIT_WINDOW_MS` | -| Internal | `/api/agent/*` | 500 req / 1 min | `INTERNAL_RATE_LIMIT_MAX`, `INTERNAL_RATE_LIMIT_WINDOW_MS` | -| Webhook | `/api/whatsapp/*` | 30 req / 1 min | `WEBHOOK_RATE_LIMIT_MAX`, `WEBHOOK_RATE_LIMIT_WINDOW_MS` | - -Public unauthenticated read endpoints (`/api/protocols/*`, `/api/vault/state`, `/api/stellar/*`, `/api/analytics/protocol-performance`) are covered by the global limiter. Authenticated routes stack the global limiter with JWT validation. - -**Bypass (trusted services only):** set `TRUSTED_IPS` to a comma-separated allowlist of IPs, or send the shared secret in the `X-Internal-Token` header (`INTERNAL_SERVICE_TOKEN`). Mount order matters: the bypass middleware runs before limiters in `src/index.ts`. - -For production secret handling, migrations, and rollback steps see `docs/DEPLOYMENT.md` and `docs/DEPLOYMENT_PRODUCTION.md`. - -Request tracing ---------------- -Every API response includes an `X-Request-ID` header for correlating logs across HTTP handlers, Stellar event processing, DLQ entries, and agent actions. - -- **Send your own ID:** include `X-Request-ID` or `X-Correlation-ID` on any request (alphanumeric, hyphen, underscore; max 128 characters). Invalid values are replaced with a server-generated UUID. -- **Response header:** `X-Request-ID` is always returned on success and error responses. -- **Error JSON:** 500 responses include `"requestId": ""` alongside the error message. -- **Logs:** structured log entries include `correlationId` when a request or background job context is active. - -Example: - -```bash -curl -v -H "X-Request-ID: my-deposit-attempt-001" \ - https://api.neurowealth.io/api/deposit ... -# Response header: X-Request-ID: my-deposit-attempt-001 -``` - -Testing -------- -Run unit tests (Jest): +## Running tests ```bash npm test ``` - -Post-migration smoke check (DB connectivity + core tables): - -```bash -npm run smoke -``` - -Auth overview (short) ---------------------- -- `POST /api/auth/challenge` — client posts `stellarPubKey`, server returns a one-time `nonce`. -- Client signs `nonce` with their Stellar key (Freighter) and sends signature to `POST /api/auth/verify`. -- Server verifies signature, creates user if missing, issues JWT (stored as a session in DB). -- Protected endpoints require `Authorization: Bearer ` and are validated against the `sessions` table; logout removes the session. - -Troubleshooting ---------------- -- If the app logs `Cannot connect to database`, check `DATABASE_URL`, and that Postgres is running (Docker or external). -- If migrating fails, confirm the DB user has permission to CREATE/ALTER tables. -- Ensure `JWT_SEED` and `WALLET_ENCRYPTION_KEY` are set when running the server. - -Dead Letter Queue (DLQ) ------------------------- -Failed Stellar events are stored in the database (`dead_letter_events` table), not in log files. The DLQ provides: -- Automatic retry with exponential backoff -- Persistent storage across restarts -- Query and monitoring via `/api/admin/dlq` endpoints - -**Important:** The `logs/` directory is for application logs only and is excluded from version control. All DLQ data is persisted in the database to ensure reliability across deployments and restarts. - -Security --------- -The project uses automated security scanning to prevent vulnerable dependencies from reaching production: - -- **npm audit** runs on every PR and blocks merges if HIGH or CRITICAL vulnerabilities are detected -- **Dependabot** automatically creates PRs for dependency updates (configured for weekly scans) -- **Policy for failing builds:** - - HIGH/CRITICAL CVEs: Must be fixed before merge (blocking) - - MODERATE CVEs: Review required, fix in follow-up PR (non-blocking) - - LOW CVEs: Tracked via Dependabot, fix during regular maintenance - -See `.github/workflows/node-ci.yml` for CI configuration and `.github/dependabot.yml` for automated dependency updates. diff --git a/src/middleware/adminAuth.ts b/src/middleware/adminAuth.ts index ae2e5d8..50e0a73 100644 --- a/src/middleware/adminAuth.ts +++ b/src/middleware/adminAuth.ts @@ -3,6 +3,7 @@ import bcrypt from 'bcryptjs' import crypto from 'node:crypto' import db from '../db' import { logger } from '../utils/logger' +import { recordAuthFailure } from '../utils/metrics' const prisma = db as any @@ -67,6 +68,7 @@ export async function requireAdminAuth( const rawToken = getTokenFromRequest(req) if (!rawToken) { + recordAuthFailure(req.path, 'missing_token') unauthorized(res) return } @@ -75,9 +77,6 @@ export async function requireAdminAuth( const now = new Date() const tokenPrefix = deriveTokenPrefix(rawToken) - // ── Narrow candidate set via the fast SHA-256 prefix ────────────────── - // The WHERE clause filters out revoked/expired rows AND uses tokenPrefix - // to avoid a full-table bcrypt scan. Typically returns exactly 1 row. const candidates = await prisma.adminApiKey.findMany({ where: { tokenPrefix, @@ -109,6 +108,7 @@ export async function requireAdminAuth( } if (!matched) { + recordAuthFailure(req.path, 'invalid_token') logger.warn('[AdminAuth] Invalid admin token attempt', { ip: req.ip, path: req.originalUrl || req.path, @@ -118,7 +118,6 @@ export async function requireAdminAuth( return } - // ── Update lastUsedAt asynchronously — don't block the request ──────── prisma.adminApiKey .update({ where: { id: matched.id }, @@ -134,6 +133,7 @@ export async function requireAdminAuth( res.locals.adminAuth = matched next() } catch (error) { + recordAuthFailure(req.path, 'auth_error') logger.error('[AdminAuth] Middleware error', error) res.status(500).json({ success: false, diff --git a/src/middleware/errorHandler.ts b/src/middleware/errorHandler.ts index ddbfcb3..546bf9d 100644 --- a/src/middleware/errorHandler.ts +++ b/src/middleware/errorHandler.ts @@ -1,5 +1,6 @@ import { Request, Response, NextFunction } from 'express' import { logger } from '../utils/logger' +import { ErrorResponses } from '../utils/errorResponse' export function errorHandler( err: Error, @@ -16,9 +17,12 @@ export function errorHandler( method: req.method, }) - res.status(500).json({ - error: 'Internal server error', + const isDevelopment = process.env.NODE_ENV === 'development' + const errorResponse = ErrorResponses.internalError( + 'Internal server error', requestId, - message: process.env.NODE_ENV === 'development' ? err.message : undefined, - }) + isDevelopment ? { message: err.message } : undefined + ) + + res.status(500).json(errorResponse) } \ No newline at end of file diff --git a/src/middleware/rateLimiter.ts b/src/middleware/rateLimiter.ts index b7264f9..9d14fe4 100644 --- a/src/middleware/rateLimiter.ts +++ b/src/middleware/rateLimiter.ts @@ -1,6 +1,8 @@ import { type Request, type Response, type NextFunction } from 'express' import rateLimit from 'express-rate-limit' import { config } from '../config/env' +import { recordRateLimitHit } from '../utils/metrics' +import { logger } from '../utils/logger' // ── Trusted-IP / service-token bypass ───────────────────────────────────── @@ -49,6 +51,38 @@ function skipUnlessLimited(req: Request): boolean { return isTrusted(req) || isHealthProbe(req) } +/** + * Extract the route group from the request path for metrics labeling. + * Maps paths to meaningful groups (e.g., /api/auth/* -> auth, /api/admin/* -> admin). + */ +function getRouteGroup(path: string): string { + const match = path.match(/^\/api\/(\w+)/) + return match ? match[1] : 'general' +} + +/** + * Handler called when rate limit is exceeded. + */ +function handleRateLimitExceeded( + req: any, + res: any, + options: { limiterType: string } +): void { + const routeGroup = getRouteGroup(req.path) + recordRateLimitHit(routeGroup, options.limiterType) + + logger.warn('[RateLimit] Rate limit exceeded', { + ip: req.ip, + path: req.path, + method: req.method, + limiterType: options.limiterType, + }) + + res.status(429).json({ + error: 'Too many requests. Please try again later.', + }) +} + // ── Rate limiters ────────────────────────────────────────────────────────── /** @@ -64,6 +98,7 @@ export const rateLimiter = rateLimit({ message: { error: 'Too many requests. Please try again later.', }, + handler: (req, res) => handleRateLimitExceeded(req, res, { limiterType: 'global' }), }) /** @@ -79,6 +114,7 @@ export const authRateLimiter = rateLimit({ message: { error: 'Too many authentication attempts. Please try again in 15 minutes.', }, + handler: (req, res) => handleRateLimitExceeded(req, res, { limiterType: 'auth' }), }) /** @@ -94,6 +130,7 @@ export const adminRateLimiter = rateLimit({ message: { error: 'Too many requests to the admin API. Please try again later.', }, + handler: (req, res) => handleRateLimitExceeded(req, res, { limiterType: 'admin' }), }) /** @@ -109,6 +146,7 @@ export const webhookRateLimiter = rateLimit({ message: { error: 'Too many webhook requests. Please try again later.', }, + handler: (req, res) => handleRateLimitExceeded(req, res, { limiterType: 'webhook' }), }) /** @@ -124,4 +162,5 @@ export const internalRateLimiter = rateLimit({ message: { error: 'Too many requests from this service. Please slow down.', }, + handler: (req, res) => handleRateLimitExceeded(req, res, { limiterType: 'internal' }), }) \ No newline at end of file diff --git a/src/routes/admin.ts b/src/routes/admin.ts index 86e4663..5e1a63a 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -114,35 +114,82 @@ router.get( /** * GET /api/admin/dlq/inspect - * Returns current DLQ contents with optional filtering. + * Returns current DLQ contents with advanced filtering and pagination. * Required scope: dlq:read * * Query params: - * status — PENDING | RETRIED | RESOLVED (optional) - * limit — max items to return (default 50, max 500) + * status — PENDING | RETRIED | RESOLVED (optional) + * eventType — filter by event type (optional) + * retryCountMin — minimum retry count (optional, default 0) + * retryCountMax — maximum retry count (optional) + * timeRangeStart — ISO 8601 timestamp for earliest event (optional) + * timeRangeEnd — ISO 8601 timestamp for latest event (optional) + * limit — max items to return (default 50, max 500) + * offset — pagination offset (default 0) */ router.get( '/dlq/inspect', requireAdminScope('dlq:read'), async (req: Request, res: Response) => { try { - const { status, limit = '50' } = req.query + const { + status, + eventType, + retryCountMin = '0', + retryCountMax, + timeRangeStart, + timeRangeEnd, + limit = '50', + offset = '0', + } = req.query + const maxLimit = Math.min(Number.parseInt(limit as string) || 50, 500) + const pageOffset = Math.max(0, Number.parseInt(offset as string) || 0) + const minRetryCount = Math.max(0, Number.parseInt(retryCountMin as string) || 0) + const maxRetryCount = retryCountMax ? Number.parseInt(retryCountMax as string) : undefined const allEvents = await DeadLetterQueue.getAll() let filtered = allEvents + if (status && ['PENDING', 'RETRIED', 'RESOLVED'].includes(status as string)) { - filtered = allEvents.filter(e => e.status === status) + filtered = filtered.filter(e => e.status === status) + } + + if (eventType) { + filtered = filtered.filter(e => e.eventType === eventType) + } + + filtered = filtered.filter(e => e.retryCount >= minRetryCount) + if (maxRetryCount !== undefined) { + filtered = filtered.filter(e => e.retryCount <= maxRetryCount) } - const items = filtered.slice(0, maxLimit) + if (timeRangeStart) { + const startDate = new Date(timeRangeStart as string) + if (!isNaN(startDate.getTime())) { + filtered = filtered.filter(e => e.createdAt >= startDate) + } + } + + if (timeRangeEnd) { + const endDate = new Date(timeRangeEnd as string) + if (!isNaN(endDate.getTime())) { + filtered = filtered.filter(e => e.createdAt <= endDate) + } + } + + const items = filtered.slice(pageOffset, pageOffset + maxLimit) auditLog(req, res, 'INSPECT_DLQ', 'success', { statusFilter: status, + eventTypeFilter: eventType, + retryCountRange: { min: minRetryCount, max: maxRetryCount }, + timeRange: { start: timeRangeStart, end: timeRangeEnd }, totalInQueue: allEvents.length, filteredCount: filtered.length, returnedCount: items.length, + pagination: { offset: pageOffset, limit: maxLimit }, }) res.status(200).json({ @@ -151,6 +198,11 @@ router.get( totalInQueue: allEvents.length, filteredCount: filtered.length, returnedCount: items.length, + pagination: { + offset: pageOffset, + limit: maxLimit, + hasMore: pageOffset + maxLimit < filtered.length, + }, items: items.map(event => ({ id: event.id, contractId: event.contractId, @@ -309,6 +361,131 @@ router.post( }, ) +/** + * POST /api/admin/dlq/replay + * Safely replay selected DLQ events back into the processing pipeline. + * Required scope: dlq:write + * + * Body: { eventIds: string[], dryRun?: boolean } + * Only retries events in PENDING or RETRIED status to prevent infinite loops. + */ +router.post( + '/dlq/replay', + requireAdminScope('dlq:write'), + async (req: Request, res: Response) => { + try { + const { eventIds, dryRun = false } = req.body + + if (!Array.isArray(eventIds) || eventIds.length === 0) { + auditLog(req, res, 'DLQ_REPLAY', 'failure', { error: 'eventIds must be a non-empty array' }) + return res.status(400).json({ + success: false, + error: 'eventIds must be a non-empty array', + }) + } + + if (eventIds.length > 1000) { + auditLog(req, res, 'DLQ_REPLAY', 'failure', { error: 'Too many events to replay (max 1000)' }) + return res.status(400).json({ + success: false, + error: 'Maximum 1000 events per replay operation', + }) + } + + const allEvents = await DeadLetterQueue.getAll() + const targetEvents = allEvents.filter(e => + eventIds.includes(e.id) && ['PENDING', 'RETRIED'].includes(e.status) + ) + + if (targetEvents.length === 0) { + auditLog(req, res, 'DLQ_REPLAY', 'failure', { + requestedCount: eventIds.length, + replayableCount: 0, + error: 'No eligible events found for replay', + }) + return res.status(404).json({ + success: false, + error: 'No eligible events found for replay (only PENDING and RETRIED events can be replayed)', + }) + } + + if (dryRun) { + auditLog(req, res, 'DLQ_REPLAY_DRY_RUN', 'success', { + requestedCount: eventIds.length, + replayableCount: targetEvents.length, + blockedCount: eventIds.length - targetEvents.length, + }) + + return res.status(200).json({ + success: true, + data: { + dryRun: true, + requestedCount: eventIds.length, + replayableCount: targetEvents.length, + blockedCount: eventIds.length - targetEvents.length, + events: targetEvents.map(e => ({ + id: e.id, + txHash: e.txHash, + eventType: e.eventType, + retryCount: e.retryCount, + status: e.status, + })), + }, + timestamp: new Date().toISOString(), + }) + } + + logger.info('[Admin] Starting selective DLQ replay', { + eventCount: targetEvents.length, + }) + + const { retryDeadLetterEvents } = await import('../stellar/events') + await retryDeadLetterEvents() + + const result = await DeadLetterQueue.getAll() + const resolved = result.filter(e => e.status === 'RESOLVED').length + const failed = result.filter(e => e.status === 'RETRIED').length + + logger.info('[Admin] Selective DLQ replay completed', { + replayedCount: targetEvents.length, + resolved, + failed, + }) + + auditLog(req, res, 'DLQ_REPLAY_COMPLETED', 'success', { + requestedCount: eventIds.length, + replayedCount: targetEvents.length, + blockedCount: eventIds.length - targetEvents.length, + resolved, + failed, + }) + + res.status(200).json({ + success: true, + data: { + requestedCount: eventIds.length, + replayedCount: targetEvents.length, + blockedCount: eventIds.length - targetEvents.length, + resolved, + failed, + }, + timestamp: new Date().toISOString(), + }) + } catch (error) { + logger.error('[Admin] DLQ replay operation failed', { + error: error instanceof Error ? error.message : 'Unknown error', + }) + auditLog(req, res, 'DLQ_REPLAY_COMPLETED', 'failure', { + error: error instanceof Error ? error.message : 'Unknown error', + }) + res.status(500).json({ + success: false, + error: 'DLQ replay operation failed', + }) + } + }, +) + /** * POST /api/admin/stellar/backfill * Manually trigger event backfill for a ledger range. diff --git a/src/utils/errorResponse.ts b/src/utils/errorResponse.ts new file mode 100644 index 0000000..224f389 --- /dev/null +++ b/src/utils/errorResponse.ts @@ -0,0 +1,88 @@ +/** + * Standardized error response contract for all API routes. + * + * All error responses follow this canonical format: + * { + * error: { + * code: string, + * message: string, + * details?: object + * }, + * requestId: string, + * timestamp: string + * } + */ + +export interface ErrorResponse { + error: { + code: string + message: string + details?: Record + } + requestId: string + timestamp: string +} + +export const ErrorCodes = { + BAD_REQUEST: 'BAD_REQUEST', + UNAUTHORIZED: 'UNAUTHORIZED', + FORBIDDEN: 'FORBIDDEN', + NOT_FOUND: 'NOT_FOUND', + CONFLICT: 'CONFLICT', + RATE_LIMITED: 'RATE_LIMITED', + VALIDATION_ERROR: 'VALIDATION_ERROR', + INTERNAL_ERROR: 'INTERNAL_ERROR', + SERVICE_UNAVAILABLE: 'SERVICE_UNAVAILABLE', +} as const + +/** + * Build a standardized error response object. + */ +export function buildErrorResponse( + code: string, + message: string, + requestId: string, + details?: Record +): ErrorResponse { + return { + error: { + code, + message, + ...(details && { details }), + }, + requestId, + timestamp: new Date().toISOString(), + } +} + +/** + * Error response builders for common HTTP status codes. + */ +export const ErrorResponses = { + badRequest: (message: string, requestId: string, details?: Record) => + buildErrorResponse(ErrorCodes.BAD_REQUEST, message, requestId, details), + + unauthorized: (message: string, requestId: string, details?: Record) => + buildErrorResponse(ErrorCodes.UNAUTHORIZED, message, requestId, details), + + forbidden: (message: string, requestId: string, details?: Record) => + buildErrorResponse(ErrorCodes.FORBIDDEN, message, requestId, details), + + notFound: (message: string, requestId: string, details?: Record) => + buildErrorResponse(ErrorCodes.NOT_FOUND, message, requestId, details), + + conflict: (message: string, requestId: string, details?: Record) => + buildErrorResponse(ErrorCodes.CONFLICT, message, requestId, details), + + rateLimited: (message: string, requestId: string, details?: Record) => + buildErrorResponse(ErrorCodes.RATE_LIMITED, message, requestId, details), + + validationError: (message: string, requestId: string, details?: Record) => + buildErrorResponse(ErrorCodes.VALIDATION_ERROR, message, requestId, details), + + internalError: (message: string, requestId: string, details?: Record) => + buildErrorResponse(ErrorCodes.INTERNAL_ERROR, message, requestId, details), + + serviceUnavailable: (message: string, requestId: string, details?: Record) => + buildErrorResponse(ErrorCodes.SERVICE_UNAVAILABLE, message, requestId, details), +} diff --git a/src/utils/metrics.ts b/src/utils/metrics.ts index eb5879a..6d24e9c 100644 --- a/src/utils/metrics.ts +++ b/src/utils/metrics.ts @@ -224,6 +224,29 @@ export const externalServiceErrorsTotal = new client.Counter({ registers: [register], }) +// ── Rate Limit & Auth Metrics ──────────────────────────────────────────────── + +export const rateLimitHitsTotal = new client.Counter({ + name: 'rate_limit_hits_total', + help: 'Total number of rate limit hits by route group', + labelNames: ['route_group', 'limiter_type'] as const, + registers: [register], +}) + +export const authFailuresTotal = new client.Counter({ + name: 'auth_failures_total', + help: 'Total number of authentication failures', + labelNames: ['endpoint', 'failure_type'] as const, + registers: [register], +}) + +export const rateLimitActiveViolations = new client.Gauge({ + name: 'rate_limit_active_violations', + help: 'Current number of active rate limit violations by route group', + labelNames: ['route_group'] as const, + registers: [register], +}) + // ── Helper Functions ───────────────────────────────────────────────────────────── /** @@ -363,6 +386,27 @@ export function recordExternalServiceError( externalServiceErrorsTotal.inc({ service, error_type: errorType }) } +/** + * Record a rate limit hit + */ +export function recordRateLimitHit(routeGroup: string, limiterType: string): void { + rateLimitHitsTotal.inc({ route_group: routeGroup, limiter_type: limiterType }) +} + +/** + * Record an authentication failure + */ +export function recordAuthFailure(endpoint: string, failureType: string): void { + authFailuresTotal.inc({ endpoint, failure_type: failureType }) +} + +/** + * Update active rate limit violations + */ +export function updateRateLimitViolations(routeGroup: string, count: number): void { + rateLimitActiveViolations.set({ route_group: routeGroup }, count) +} + /** * Get metrics for Prometheus scraping */ diff --git a/tests/regression.test.ts b/tests/regression.test.ts new file mode 100644 index 0000000..0d85d7e --- /dev/null +++ b/tests/regression.test.ts @@ -0,0 +1,337 @@ +import db from '../src/db' + +const prisma = db as any + +describe('Regression Tests - Critical Prisma-Backed Flows', () => { + beforeAll(async () => { + await prisma.$connect() + }) + + afterAll(async () => { + await prisma.$disconnect() + }) + + describe('Session Management', () => { + it('should create a session and retrieve it', async () => { + const userId = 'test-user-' + Date.now() + const token = 'test-token-' + Math.random().toString(36).slice(2) + + const user = await prisma.user.create({ + data: { + walletAddress: userId, + network: 'MAINNET', + }, + }) + + const session = await prisma.session.create({ + data: { + userId: user.id, + token, + walletAddress: user.walletAddress, + network: user.network, + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + }, + }) + + expect(session).toBeDefined() + expect(session.token).toBe(token) + + const retrieved = await prisma.session.findUnique({ + where: { id: session.id }, + }) + expect(retrieved).toBeDefined() + expect(retrieved.token).toBe(token) + + await prisma.user.delete({ where: { id: user.id } }) + }) + + it('should delete expired sessions', async () => { + const userId = 'test-user-expire-' + Date.now() + const user = await prisma.user.create({ + data: { + walletAddress: userId, + network: 'MAINNET', + }, + }) + + const expiredSession = await prisma.session.create({ + data: { + userId: user.id, + token: 'expired-' + Math.random().toString(36), + walletAddress: user.walletAddress, + network: user.network, + expiresAt: new Date(Date.now() - 1000), + }, + }) + + const deleted = await prisma.session.deleteMany({ + where: { + expiresAt: { lt: new Date() }, + }, + }) + + expect(deleted.count).toBeGreaterThanOrEqual(1) + + await prisma.user.delete({ where: { id: user.id } }) + }) + }) + + describe('Event Persistence', () => { + it('should persist processed events with cursor updates', async () => { + const contractId = 'test-contract-' + Date.now() + + const cursor = await prisma.eventCursor.create({ + data: { + contractId, + lastProcessedLedger: 100, + }, + }) + + expect(cursor.lastProcessedLedger).toBe(100) + + const event = await prisma.processedEvent.create({ + data: { + contractId, + txHash: 'test-tx-' + Math.random().toString(36), + eventType: 'CONTRACT_TRIGGERED', + ledger: 101, + }, + }) + + expect(event).toBeDefined() + expect(event.ledger).toBe(101) + + const updated = await prisma.eventCursor.update({ + where: { id: cursor.id }, + data: { lastProcessedLedger: 101 }, + }) + + expect(updated.lastProcessedLedger).toBe(101) + + await prisma.processedEvent.deleteMany({ + where: { contractId }, + }) + await prisma.eventCursor.delete({ where: { id: cursor.id } }) + }) + + it('should maintain unique constraint on processed events', async () => { + const contractId = 'unique-test-' + Date.now() + const txHash = 'tx-unique-' + Math.random().toString(36) + + const event1 = await prisma.processedEvent.create({ + data: { + contractId, + txHash, + eventType: 'TEST_EVENT', + ledger: 200, + }, + }) + + expect(event1).toBeDefined() + + const duplicate = prisma.processedEvent.create({ + data: { + contractId, + txHash, + eventType: 'TEST_EVENT', + ledger: 200, + }, + }) + + await expect(duplicate).rejects.toThrow() + + await prisma.processedEvent.deleteMany({ + where: { contractId }, + }) + }) + }) + + describe('Dead Letter Queue', () => { + it('should create DLQ entries with correct status', async () => { + const contractId = 'dlq-test-' + Date.now() + + const dlqEvent = await prisma.deadLetterEvent.create({ + data: { + contractId, + txHash: 'dlq-tx-' + Math.random().toString(36), + eventType: 'FAILED_EVENT', + ledger: 300, + error: 'Test error', + payload: { test: true }, + status: 'PENDING', + retryCount: 0, + }, + }) + + expect(dlqEvent.status).toBe('PENDING') + expect(dlqEvent.retryCount).toBe(0) + + const retrieved = await prisma.deadLetterEvent.findUnique({ + where: { id: dlqEvent.id }, + }) + + expect(retrieved).toBeDefined() + expect(retrieved.status).toBe('PENDING') + }) + + it('should increment retry count and update status', async () => { + const contractId = 'dlq-retry-' + Date.now() + + const dlqEvent = await prisma.deadLetterEvent.create({ + data: { + contractId, + txHash: 'dlq-tx-retry-' + Math.random().toString(36), + eventType: 'FAILED_EVENT', + ledger: 400, + error: 'Initial error', + payload: { test: true }, + status: 'PENDING', + retryCount: 0, + }, + }) + + const updated = await prisma.deadLetterEvent.update({ + where: { id: dlqEvent.id }, + data: { + retryCount: { increment: 1 }, + status: 'RETRIED', + error: 'Retry error', + updatedAt: new Date(), + }, + }) + + expect(updated.retryCount).toBe(1) + expect(updated.status).toBe('RETRIED') + }) + + it('should resolve DLQ events', async () => { + const contractId = 'dlq-resolve-' + Date.now() + + const dlqEvent = await prisma.deadLetterEvent.create({ + data: { + contractId, + txHash: 'dlq-tx-resolve-' + Math.random().toString(36), + eventType: 'FAILED_EVENT', + ledger: 500, + error: 'Original error', + payload: { test: true }, + status: 'RETRIED', + retryCount: 3, + }, + }) + + const resolved = await prisma.deadLetterEvent.update({ + where: { id: dlqEvent.id }, + data: { + status: 'RESOLVED', + error: 'Resolved via manual intervention', + updatedAt: new Date(), + }, + }) + + expect(resolved.status).toBe('RESOLVED') + expect(resolved.retryCount).toBe(3) + }) + + it('should query DLQ by status and retry count', async () => { + const contractId = 'dlq-query-' + Date.now() + + await prisma.deadLetterEvent.createMany({ + data: [ + { + contractId, + txHash: 'tx-1-' + Math.random().toString(36), + eventType: 'FAILED_EVENT', + ledger: 600, + error: 'Error 1', + payload: {}, + status: 'PENDING', + retryCount: 0, + }, + { + contractId, + txHash: 'tx-2-' + Math.random().toString(36), + eventType: 'FAILED_EVENT', + ledger: 601, + error: 'Error 2', + payload: {}, + status: 'RETRIED', + retryCount: 2, + }, + { + contractId, + txHash: 'tx-3-' + Math.random().toString(36), + eventType: 'FAILED_EVENT', + ledger: 602, + error: 'Error 3', + payload: {}, + status: 'RESOLVED', + retryCount: 5, + }, + ], + }) + + const pending = await prisma.deadLetterEvent.findMany({ + where: { contractId, status: 'PENDING' }, + }) + expect(pending.length).toBe(1) + + const retried = await prisma.deadLetterEvent.findMany({ + where: { contractId, status: 'RETRIED' }, + }) + expect(retried.length).toBe(1) + + const highRetryCount = await prisma.deadLetterEvent.findMany({ + where: { contractId, retryCount: { gte: 2 } }, + }) + expect(highRetryCount.length).toBeGreaterThanOrEqual(2) + + await prisma.deadLetterEvent.deleteMany({ + where: { contractId }, + }) + }) + }) + + describe('Admin Audit Logging', () => { + it('should create and retrieve audit logs', async () => { + const adminKey = await prisma.adminApiKey.create({ + data: { + name: 'test-key-' + Date.now(), + role: 'admin', + scopes: ['dlq:read', 'dlq:write'], + hash: 'test-hash', + tokenPrefix: 'test-prefix', + }, + }) + + const auditLog = await prisma.adminAuditLog.create({ + data: { + adminKeyId: adminKey.id, + adminName: 'test-admin', + adminRole: 'admin', + action: 'DLQ_INSPECT', + target: '/api/admin/dlq/inspect', + result: 'success', + path: '/api/admin/dlq/inspect', + method: 'GET', + ipAddress: '127.0.0.1', + }, + }) + + expect(auditLog).toBeDefined() + expect(auditLog.action).toBe('DLQ_INSPECT') + expect(auditLog.result).toBe('success') + + const retrieved = await prisma.adminAuditLog.findMany({ + where: { adminKeyId: adminKey.id }, + }) + + expect(retrieved.length).toBeGreaterThanOrEqual(1) + + await prisma.adminAuditLog.deleteMany({ + where: { adminKeyId: adminKey.id }, + }) + await prisma.adminApiKey.delete({ where: { id: adminKey.id } }) + }) + }) +})