diff --git a/backend/src/config/database.ts b/backend/src/config/database.ts index b60bfe17..8203121d 100644 --- a/backend/src/config/database.ts +++ b/backend/src/config/database.ts @@ -2,7 +2,7 @@ * database.ts * * Database configuration, query profiling, connection pool tuning, - * and recommended composite indexes for AgenticPay. + * PgBouncer integration, and recommended composite indexes for AgenticPay. */ import { featureFlags } from './featureFlags.js'; @@ -55,6 +55,386 @@ export function buildPoolConfig(env = process.env.NODE_ENV): PoolConfig { } } +// ── PgBouncer Integration ───────────────────────────────────────────────────── + +export interface PgBouncerConfig { + enabled: boolean; + poolMode: 'transaction' | 'session' | 'statement'; + defaultPoolSize: number; + maxPoolSize: number; + minPoolSize: number; + reservePoolSize: number; + reservePoolTimeoutMs: number; + maxClientConnections: number; + maxPreparedStatements: number; + queryTimeoutMs: number; + idleTimeoutMs: number; + serverLifetimeMs: number; + serverIdleTimeoutMs: number; + healthCheckIntervalMs: number; +} + +const DEFAULT_PGBOUNCER_CONFIG: PgBouncerConfig = { + enabled: process.env.PGBOUNCER_ENABLED === 'true', + poolMode: 'transaction', + defaultPoolSize: envInt('PGBOUNCER_DEFAULT_POOL_SIZE', 25), + maxPoolSize: envInt('PGBOUNCER_MAX_POOL_SIZE', 50), + minPoolSize: envInt('PGBOUNCER_MIN_POOL_SIZE', 5), + reservePoolSize: envInt('PGBOUNCER_RESERVE_POOL_SIZE', 5), + reservePoolTimeoutMs: envInt('PGBOUNCER_RESERVE_POOL_TIMEOUT_MS', 5_000), + maxClientConnections: envInt('PGBOUNCER_MAX_CLIENT_CONNECTIONS', 100), + maxPreparedStatements: envInt('PGBOUNCER_MAX_PREPARED_STATEMENTS', 50), + queryTimeoutMs: envInt('PGBOUNCER_QUERY_TIMEOUT_MS', 30_000), + idleTimeoutMs: envInt('PGBOUNCER_IDLE_TIMEOUT_MS', 600_000), + serverLifetimeMs: envInt('PGBOUNCER_SERVER_LIFETIME_MS', 3_600_000), + serverIdleTimeoutMs: envInt('PGBOUNCER_SERVER_IDLE_TIMEOUT_MS', 600_000), + healthCheckIntervalMs: envInt('PGBOUNCER_HEALTH_CHECK_INTERVAL_MS', 30_000), +}; + +let pgBouncerConfig: PgBouncerConfig = { ...DEFAULT_PGBOUNCER_CONFIG }; + +export function configurePgBouncer(config: Partial): void { + pgBouncerConfig = { ...pgBouncerConfig, ...config }; +} + +export function getPgBouncerConfig(): PgBouncerConfig { + return { ...pgBouncerConfig }; +} + +// ── Pool Metrics ────────────────────────────────────────────────────────────── + +interface ConnectionPoolMetrics { + activeConnections: number; + idleConnections: number; + waitingClients: number; + totalConnections: number; + maxConnections: number; + minConnections: number; + connectionLeasesTotal: number; + connectionLeasesActive: number; + connectionLeasesReleased: number; + connectionLeaseErrors: number; + leakedConnectionsDetected: number; + poolExhaustionCount: number; + averageAcquireTimeMs: number; + peakActiveConnections: number; + timestamp: string; +} + +class PoolMetricsCollector { + private activeConnections = 0; + private idleConnections = 0; + private waitingClients = 0; + private totalConnections = 0; + private connectionLeasesTotal = 0; + private connectionLeasesActive = 0; + private connectionLeasesReleased = 0; + private connectionLeaseErrors = 0; + private leakedConnectionsDetected = 0; + private poolExhaustionCount = 0; + private acquireTimes: number[] = []; + private peakActiveConnections = 0; + private maxConnections = 50; + private minConnections = 5; + private readonly maxAcquireTimeSamples = 100; + + setPoolLimits(max: number, min: number): void { + this.maxConnections = max; + this.minConnections = min; + } + + recordConnectionAcquired(durationMs: number): void { + this.activeConnections++; + this.totalConnections++; + this.connectionLeasesTotal++; + this.connectionLeasesActive++; + this.acquireTimes.push(durationMs); + if (this.acquireTimes.length > this.maxAcquireTimeSamples) { + this.acquireTimes.shift(); + } + if (this.activeConnections > this.peakActiveConnections) { + this.peakActiveConnections = this.activeConnections; + } + } + + recordConnectionReleased(): void { + this.activeConnections = Math.max(0, this.activeConnections - 1); + this.connectionLeasesActive = Math.max(0, this.connectionLeasesActive - 1); + this.connectionLeasesReleased++; + } + + recordConnectionIdle(): void { + this.idleConnections++; + } + + recordWaitingClient(): void { + this.waitingClients++; + } + + recordPoolExhaustion(): void { + this.poolExhaustionCount++; + } + + recordLeakDetected(): void { + this.leakedConnectionsDetected++; + } + + recordLeaseError(): void { + this.connectionLeaseErrors++; + } + + snapshot(): ConnectionPoolMetrics { + const averageAcquireTimeMs = + this.acquireTimes.length > 0 + ? this.acquireTimes.reduce((sum, t) => sum + t, 0) / this.acquireTimes.length + : 0; + + return { + activeConnections: this.activeConnections, + idleConnections: this.idleConnections, + waitingClients: this.waitingClients, + totalConnections: this.totalConnections, + maxConnections: this.maxConnections, + minConnections: this.minConnections, + connectionLeasesTotal: this.connectionLeasesTotal, + connectionLeasesActive: this.connectionLeasesActive, + connectionLeasesReleased: this.connectionLeasesReleased, + connectionLeaseErrors: this.connectionLeaseErrors, + leakedConnectionsDetected: this.leakedConnectionsDetected, + poolExhaustionCount: this.poolExhaustionCount, + averageAcquireTimeMs: Math.round(averageAcquireTimeMs * 100) / 100, + peakActiveConnections: this.peakActiveConnections, + timestamp: new Date().toISOString(), + }; + } + + reset(): void { + this.activeConnections = 0; + this.idleConnections = 0; + this.waitingClients = 0; + this.totalConnections = 0; + this.connectionLeasesTotal = 0; + this.connectionLeasesActive = 0; + this.connectionLeasesReleased = 0; + this.connectionLeaseErrors = 0; + this.leakedConnectionsDetected = 0; + this.poolExhaustionCount = 0; + this.acquireTimes = []; + this.peakActiveConnections = 0; + } +} + +export const poolMetrics = new PoolMetricsCollector(); + +// ── Connection Lease Manager ─────────────────────────────────────────────────── + +interface ConnectionLease { + id: string; + acquiredAt: number; + released: boolean; +} + +class ConnectionLeaseManager { + private leases = new Map(); + private readonly leaseTimeoutMs: number; + private leakCheckInterval?: ReturnType; + + constructor(leaseTimeoutMs = 300_000) { + this.leaseTimeoutMs = leaseTimeoutMs; + } + + startLeakDetection(): void { + if (this.leakCheckInterval) return; + this.leakCheckInterval = setInterval(() => { + this.detectLeaks(); + }, 60_000); + } + + stopLeakDetection(): void { + if (this.leakCheckInterval) { + clearInterval(this.leakCheckInterval); + this.leakCheckInterval = undefined; + } + } + + acquire(id: string): void { + this.leases.set(id, { id, acquiredAt: Date.now(), released: false }); + } + + release(id: string): void { + const lease = this.leases.get(id); + if (lease) { + lease.released = true; + } + } + + private detectLeaks(): void { + const now = Date.now(); + for (const [id, lease] of this.leases.entries()) { + if (!lease.released && now - lease.acquiredAt > this.leaseTimeoutMs) { + console.warn(`[PoolLeak] Connection ${id} has been held for ${now - lease.acquiredAt}ms without release`); + poolMetrics.recordLeakDetected(); + this.leases.delete(id); + } else if (lease.released) { + if (now - lease.acquiredAt > 60_000) { + this.leases.delete(id); + } + } + } + } + + getActiveLeaseCount(): number { + let count = 0; + for (const lease of this.leases.values()) { + if (!lease.released) count++; + } + return count; + } +} + +export const connectionLeaseManager = new ConnectionLeaseManager(); +connectionLeaseManager.startLeakDetection(); + +// ── Pool Exhaustion Handler ──────────────────────────────────────────────────── + +interface PoolExhaustionHandler { + onExhaustion: () => void; + onRecovery: () => void; + backoffMs: number; + maxBackoffMs: number; +} + +class PoolExhaustionManager { + private handlers: PoolExhaustionHandler[] = []; + private isExhausted = false; + private backoffMs = 100; + private readonly maxBackoffMs = 10_000; + private recoveryTimer?: ReturnType; + + registerHandler(handler: Partial): void { + this.handlers.push({ + onExhaustion: handler.onExhaustion ?? (() => {}), + onRecovery: handler.onRecovery ?? (() => {}), + backoffMs: handler.backoffMs ?? this.backoffMs, + maxBackoffMs: handler.maxBackoffMs ?? this.maxBackoffMs, + }); + } + + notifyExhaustion(): void { + poolMetrics.recordPoolExhaustion(); + this.isExhausted = true; + this.backoffMs = Math.min(this.backoffMs * 2, this.maxBackoffMs); + + for (const handler of this.handlers) { + try { + handler.onExhaustion(); + } catch { } + } + + console.warn(`[PoolExhaustion] Pool exhausted, backing off for ${this.backoffMs}ms`); + } + + notifyRecovery(): void { + this.isExhausted = false; + this.backoffMs = 100; + + for (const handler of this.handlers) { + try { + handler.onRecovery(); + } catch { } + } + + console.log('[PoolExhaustion] Pool recovered'); + } + + scheduleRecovery(): void { + if (this.recoveryTimer) return; + this.recoveryTimer = setTimeout(() => { + this.notifyRecovery(); + this.recoveryTimer = undefined; + }, this.backoffMs); + } + + isPoolExhausted(): boolean { + return this.isExhausted; + } + + getBackoffMs(): number { + return this.backoffMs; + } +} + +export const poolExhaustionManager = new PoolExhaustionManager(); + +// ── Prepared Statement Registry ──────────────────────────────────────────────── + +export const PREPARED_STATEMENTS = { + getPaymentById: 'SELECT * FROM payments WHERE id = $1 AND tenant_id = $2 LIMIT 1', + listPendingPayments: + "SELECT id, tx_hash, amount, network FROM payments WHERE status = 'pending' ORDER BY created_at ASC LIMIT $1", + upsertGasEstimate: ` + INSERT INTO gas_estimates (network, gas_price_gwei, base_fee_gwei, recorded_at) + VALUES ($1, $2, $3, NOW()) + ON CONFLICT (network) DO UPDATE + SET gas_price_gwei = EXCLUDED.gas_price_gwei, + base_fee_gwei = EXCLUDED.base_fee_gwei, + recorded_at = EXCLUDED.recorded_at + `, +} as const; + +export type PreparedStatementKey = keyof typeof PREPARED_STATEMENTS; + +class PreparedStatementManager { + private statements = new Map(); + private maxStatements: number; + private deallocateOnError = true; + + constructor(maxStatements = 50) { + this.maxStatements = maxStatements; + } + + register(name: string, sql: string): void { + if (this.statements.size >= this.maxStatements) { + const oldestKey = this.statements.keys().next().value; + if (oldestKey !== undefined) { + this.statements.delete(oldestKey as string); + } + } + this.statements.set(name, sql); + } + + get(name: string): string | undefined { + return this.statements.get(name); + } + + registerDefaults(): void { + for (const [name, sql] of Object.entries(PREPARED_STATEMENTS)) { + this.register(name, sql); + } + } + + deallocate(name: string): void { + this.statements.delete(name); + } + + deallocateAll(): void { + this.statements.clear(); + } + + getRegisteredStatements(): Array<{ name: string; sql: string }> { + return Array.from(this.statements.entries()).map(([name, sql]) => ({ name, sql })); + } + + getStatementCount(): number { + return this.statements.size; + } +} + +export const preparedStatementManager = new PreparedStatementManager( + envInt('PGBOUNCER_MAX_PREPARED_STATEMENTS', 50), +); +preparedStatementManager.registerDefaults(); + // ── Slow query detection ─────────────────────────────────────────────────────── export const SLOW_QUERY_THRESHOLD_MS = envInt('SLOW_QUERY_THRESHOLD_MS', 500); @@ -209,24 +589,6 @@ export function getRecommendedIndexes(): CompositeIndex[] { return RECOMMENDED_INDEXES; } -// ── Prepared statement registry ─────────────────────────────────────────────── - -export const PREPARED_STATEMENTS = { - getPaymentById: 'SELECT * FROM payments WHERE id = $1 AND tenant_id = $2 LIMIT 1', - listPendingPayments: - "SELECT id, tx_hash, amount, network FROM payments WHERE status = 'pending' ORDER BY created_at ASC LIMIT $1", - upsertGasEstimate: ` - INSERT INTO gas_estimates (network, gas_price_gwei, base_fee_gwei, recorded_at) - VALUES ($1, $2, $3, NOW()) - ON CONFLICT (network) DO UPDATE - SET gas_price_gwei = EXCLUDED.gas_price_gwei, - base_fee_gwei = EXCLUDED.base_fee_gwei, - recorded_at = EXCLUDED.recorded_at - `, -} as const; - -export type PreparedStatementKey = keyof typeof PREPARED_STATEMENTS; - // ── Read replica routing ─────────────────────────────────────────────────────── export interface ReplicaConfig { diff --git a/backend/src/index.ts b/backend/src/index.ts index 3cefc41a..3d4c60aa 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -24,7 +24,8 @@ Sentry.init({ }); import cors from 'cors'; import { tokenBucketRateLimit } from './middleware/rate-limit.js'; -import compression from 'compression'; +import { compressionMiddleware, getCompressionMetrics } from './middleware/compression.js'; +import { poolMetrics } from './config/database.js'; import { config } from './config.js'; import { verificationRouter } from './routes/verification.js'; import { invoiceRouter } from './routes/invoice.js'; @@ -102,6 +103,7 @@ import { SecurityMiddleware, SecurityMonitor } from './middleware/security.js'; import { sanitizeInput, contentSecurityPolicy } from './middleware/sanitize.js'; import { signaturesRouter } from './routes/signatures.js'; import { createSandboxRouter } from './routes/sandbox.js'; +import { circuitBreakerRouter } from './routes/circuit-breaker.js'; import SandboxManager from './services/sandbox.js'; import MockPaymentProcessor from './services/mock-payments.js'; import TestDataSeeder from './services/test-data-seeder.js'; @@ -186,21 +188,10 @@ app.use(express.json()); app.use(express.text({ type: ['text/csv', 'text/plain'] })); app.use( - compression({ - threshold: config.compression.threshold, - filter: (req, res) => { - if (req.headers['x-no-compression']) { - return false; - } - const contentType = res.getHeader('Content-Type'); - if (typeof contentType === 'string' && contentType.includes('application/json')) { - return true; - } - if (Array.isArray(contentType) && contentType.some((ct) => ct.includes('application/json'))) { - return true; - } - return compression.filter(req, res); - }, + compressionMiddleware({ + brotliLevel: 5, + gzipLevel: 6, + minSizeBytes: 1024, }) ); @@ -280,6 +271,14 @@ apiV1Router.use('/nfc', nfcRouter); // Cache management apiV1Router.use('/cache', cacheRouter); +apiV1Router.use('/circuit-breaker', circuitBreakerRouter); +apiV1Router.get('/compression/metrics', (_req, res) => { + res.json(getCompressionMetrics()); +}); +apiV1Router.get('/pool/metrics', (_req, res) => { + res.json(poolMetrics.snapshot()); +}); + app.use('/api/v1', ipAllowlistMiddleware(), apiV1Router); app.use('/api/v1/notifications', notificationsRouter); diff --git a/backend/src/middleware/circuit-breaker.ts b/backend/src/middleware/circuit-breaker.ts index 7a70e1e4..816e4e93 100644 --- a/backend/src/middleware/circuit-breaker.ts +++ b/backend/src/middleware/circuit-breaker.ts @@ -7,6 +7,19 @@ interface CircuitBreakerConfig { successThreshold: number; timeoutMs: number; halfOpenMaxCalls: number; + requestTimeoutMs: number; +} + +interface CircuitBreakerMetrics { + totalCalls: number; + successfulCalls: number; + failedCalls: number; + timeoutCalls: number; + rejectedCalls: number; + lastFailureAt?: number; + lastSuccessAt?: number; + openedAt?: number; + halfOpenAttempts: number; } interface CircuitBreakerState { @@ -16,6 +29,12 @@ interface CircuitBreakerState { halfOpenCalls: number; lastFailureAt?: number; openedAt?: number; + metrics: CircuitBreakerMetrics; +} + +interface CircuitBreakerEntry { + config: CircuitBreakerConfig; + state: CircuitBreakerState; } const DEFAULT_CONFIG: CircuitBreakerConfig = { @@ -23,86 +42,122 @@ const DEFAULT_CONFIG: CircuitBreakerConfig = { successThreshold: 2, timeoutMs: 60_000, halfOpenMaxCalls: 3, + requestTimeoutMs: 10_000, }; -const circuits = new Map(); +const circuits = new Map(); -function getOrCreate(name: string): CircuitBreakerState { +function getOrCreate(name: string, configOverride?: Partial): CircuitBreakerEntry { const existing = circuits.get(name); if (existing) return existing; - const state: CircuitBreakerState = { state: 'closed', failures: 0, successes: 0, halfOpenCalls: 0 }; - circuits.set(name, state); - return state; -} - -function onSuccess(name: string, config: CircuitBreakerConfig): void { - const cb = getOrCreate(name); - if (cb.state === 'half_open') { - cb.successes += 1; - if (cb.successes >= config.successThreshold) { - cb.state = 'closed'; - cb.failures = 0; - cb.successes = 0; - cb.halfOpenCalls = 0; + const config = { ...DEFAULT_CONFIG, ...configOverride }; + const state: CircuitBreakerState = { + state: 'closed', + failures: 0, + successes: 0, + halfOpenCalls: 0, + metrics: { + totalCalls: 0, + successfulCalls: 0, + failedCalls: 0, + timeoutCalls: 0, + rejectedCalls: 0, + halfOpenAttempts: 0, + }, + }; + const entry: CircuitBreakerEntry = { config, state }; + circuits.set(name, entry); + return entry; +} + +function onSuccess(name: string): void { + const entry = circuits.get(name); + if (!entry) return; + const { state, config } = entry; + + state.metrics.totalCalls++; + state.metrics.successfulCalls++; + state.metrics.lastSuccessAt = Date.now(); + + if (state.state === 'half_open') { + state.successes += 1; + if (state.successes >= config.successThreshold) { + state.state = 'closed'; + state.failures = 0; + state.successes = 0; + state.halfOpenCalls = 0; } - } else if (cb.state === 'closed') { - cb.failures = Math.max(0, cb.failures - 1); + } else if (state.state === 'closed') { + state.failures = Math.max(0, state.failures - 1); } - circuits.set(name, cb); } -function onFailure(name: string, config: CircuitBreakerConfig): void { - const cb = getOrCreate(name); - cb.failures += 1; - cb.lastFailureAt = Date.now(); +function onFailure(name: string): void { + const entry = circuits.get(name); + if (!entry) return; + const { state, config } = entry; + + state.failures += 1; + state.lastFailureAt = Date.now(); + state.metrics.totalCalls++; + state.metrics.failedCalls++; - if (cb.state === 'half_open' || cb.failures >= config.failureThreshold) { - cb.state = 'open'; - cb.openedAt = Date.now(); - cb.halfOpenCalls = 0; - cb.successes = 0; + if (state.state === 'half_open' || state.failures >= config.failureThreshold) { + state.state = 'open'; + state.openedAt = Date.now(); + state.metrics.openedAt = Date.now(); + state.halfOpenCalls = 0; + state.successes = 0; } +} - circuits.set(name, cb); +function onTimeout(name: string): void { + const entry = circuits.get(name); + if (!entry) return; + entry.state.metrics.timeoutCalls++; + onFailure(name); } -function shouldAllow(name: string, config: CircuitBreakerConfig): boolean { - const cb = getOrCreate(name); +function shouldAllow(name: string): boolean { + const entry = circuits.get(name); + if (!entry) return true; + const { state, config } = entry; - if (cb.state === 'closed') return true; + if (state.state === 'closed') return true; - if (cb.state === 'open') { - const elapsed = Date.now() - (cb.openedAt ?? 0); + if (state.state === 'open') { + const elapsed = Date.now() - (state.openedAt ?? 0); if (elapsed >= config.timeoutMs) { - cb.state = 'half_open'; - cb.successes = 0; - cb.halfOpenCalls = 0; - circuits.set(name, cb); + state.state = 'half_open'; + state.successes = 0; + state.halfOpenCalls = 0; + state.metrics.halfOpenAttempts++; return true; } + state.metrics.rejectedCalls++; return false; } - // half_open: allow limited calls - if (cb.halfOpenCalls < config.halfOpenMaxCalls) { - cb.halfOpenCalls += 1; - circuits.set(name, cb); + if (state.halfOpenCalls < config.halfOpenMaxCalls) { + state.halfOpenCalls += 1; return true; } + state.metrics.rejectedCalls++; return false; } export function circuitBreaker(name: string, config: Partial = {}) { - const cfg = { ...DEFAULT_CONFIG, ...config }; + getOrCreate(name, config); return (req: Request, res: Response, next: NextFunction): void => { - if (!shouldAllow(name, cfg)) { + if (!shouldAllow(name)) { res.status(503).json({ error: { code: 'CIRCUIT_OPEN', message: `Service ${name} is temporarily unavailable. Circuit breaker is open.`, status: 503, + retryAfterMs: getRetryAfterMs(name), }, }); return; @@ -111,9 +166,9 @@ export function circuitBreaker(name: string, config: Partial { if (res.statusCode >= 500) { - onFailure(name, cfg); + onFailure(name); } else { - onSuccess(name, cfg); + onSuccess(name); } return originalJson(body); }; @@ -122,14 +177,108 @@ export function circuitBreaker(name: string, config: Partial ({ + name, + state: entry.state.state, + failures: entry.state.failures, + successes: entry.state.successes, + halfOpenCalls: entry.state.halfOpenCalls, + lastFailureAt: entry.state.lastFailureAt, + openedAt: entry.state.openedAt, + config: entry.config, + metrics: entry.state.metrics, + })); } -export function getAllCircuits(): Array { - return Array.from(circuits.entries()).map(([name, state]) => ({ name, ...state })); +export function resetCircuit(name: string): boolean { + const entry = circuits.get(name); + if (!entry) return false; + entry.state = { + state: 'closed', + failures: 0, + successes: 0, + halfOpenCalls: 0, + metrics: { + totalCalls: 0, + successfulCalls: 0, + failedCalls: 0, + timeoutCalls: 0, + rejectedCalls: 0, + halfOpenAttempts: 0, + }, + }; + return true; } -export function resetCircuit(name: string): void { - circuits.set(name, { state: 'closed', failures: 0, successes: 0, halfOpenCalls: 0 }); +export async function withCircuitBreaker( + name: string, + fn: () => Promise, + fallback?: () => Promise, + configOverride?: Partial, +): Promise { + const entry = getOrCreate(name, configOverride); + const { state, config } = entry; + + if (!shouldAllow(name)) { + if (fallback) { + return fallback(); + } + throw new CircuitBreakerError(name, `Circuit breaker is open for ${name}`); + } + + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + onTimeout(name); + reject(new CircuitBreakerError(name, `Request to ${name} timed out after ${config.requestTimeoutMs}ms`, true)); + }, config.requestTimeoutMs); + }); + + try { + const result = await Promise.race([fn(), timeoutPromise]); + onSuccess(name); + return result; + } catch (error) { + if (error instanceof CircuitBreakerError) throw error; + onFailure(name); + if (fallback) { + return fallback(); + } + throw error; + } +} + +export class CircuitBreakerError extends Error { + serviceName: string; + isTimeout: boolean; + + constructor(serviceName: string, message: string, isTimeout = false) { + super(message); + this.name = 'CircuitBreakerError'; + this.serviceName = serviceName; + this.isTimeout = isTimeout; + } } diff --git a/backend/src/middleware/compression.ts b/backend/src/middleware/compression.ts new file mode 100644 index 00000000..c9a8e3d1 --- /dev/null +++ b/backend/src/middleware/compression.ts @@ -0,0 +1,169 @@ +import { brotliCompressSync, gzipSync, constants } from 'node:zlib'; +import type { Request, Response, NextFunction } from 'express'; + +interface CompressionConfig { + brotliLevel: number; + gzipLevel: number; + minSizeBytes: number; + excludeContentTypes: string[]; +} + +const DEFAULT_CONFIG: CompressionConfig = { + brotliLevel: 5, + gzipLevel: 6, + minSizeBytes: 1024, + excludeContentTypes: [ + 'image/', + 'video/', + 'audio/', + 'application/zip', + 'application/gzip', + 'application/br', + 'font/', + ], +}; + +const TEXT_TYPES = [ + 'text/', + 'application/json', + 'application/javascript', + 'application/xml', + 'application/graphql-response+json', + 'application/problem+json', +]; + +const configs = new Map(); + +export function configureEndpoint(endpoint: string, config: Partial): void { + const existing = configs.get(endpoint) ?? { ...DEFAULT_CONFIG }; + Object.assign(existing, config); + configs.set(endpoint, existing); +} + +function getConfig(req: Request): CompressionConfig { + const endpoint = req.route?.path ?? req.path; + return configs.get(endpoint) ?? DEFAULT_CONFIG; +} + +function shouldCompress(req: Request, res: Response, config: CompressionConfig): boolean { + if (req.headers['x-no-compression']) return false; + + const contentLength = parseInt(res.getHeader('Content-Length') as string || '0', 10); + if (contentLength > 0 && contentLength < config.minSizeBytes) return false; + + const contentType = (res.getHeader('Content-Type') as string || '').toLowerCase(); + if (!contentType) return false; + + for (const exclude of config.excludeContentTypes) { + if (contentType.startsWith(exclude)) return false; + } + + if (TEXT_TYPES.some(t => contentType.startsWith(t))) return true; + + return false; +} + +function getCompressedResponse(res: Response, body: unknown, config: CompressionConfig): Buffer | null { + const bodyStr = typeof body === 'string' ? body : JSON.stringify(body); + const bodyBuf = Buffer.from(bodyStr, 'utf-8'); + const len = bodyBuf.length; + + const acceptEncoding = (res.req.headers['accept-encoding'] as string) || ''; + + if (acceptEncoding.includes('br')) { + try { + const compressed = brotliCompressSync(bodyBuf, { + params: { + [constants.BROTLI_PARAM_QUALITY]: config.brotliLevel, + [constants.BROTLI_PARAM_MODE]: constants.BROTLI_MODE_TEXT, + }, + }); + if (compressed.length < len) { + res.setHeader('Content-Encoding', 'br'); + res.setHeader('X-Compression', 'brotli'); + return compressed; + } + } catch { + // Fall through to gzip + } + } + + if (acceptEncoding.includes('gzip')) { + try { + const compressed = gzipSync(bodyBuf, { level: config.gzipLevel }); + if (compressed.length < len) { + res.setHeader('Content-Encoding', 'gzip'); + res.setHeader('X-Compression', 'gzip'); + return compressed; + } + } catch { + // Fall through to uncompressed + } + } + + if (acceptEncoding.includes('deflate')) { + return null; + } + + return null; +} + +export function compressionMiddleware(config?: Partial) { + const globalConfig = { ...DEFAULT_CONFIG, ...config }; + + return (req: Request, res: Response, next: NextFunction): void => { + if (req.method === 'HEAD') { + next(); + return; + } + + const endpointConfig = configs.get(req.path) ?? globalConfig; + + if (!shouldCompress(req, res, endpointConfig)) { + next(); + return; + } + + const originalSend = res.send.bind(res); + const originalJson = res.json.bind(res); + const originalEnd = res.end.bind(res); + + let responseBody: unknown = null; + let contentType: string | undefined; + + res.send = (body: unknown): Response => { + responseBody = body; + contentType = res.getHeader('Content-Type') as string || undefined; + return originalSend(''); // Will be replaced by our compressed version in end + }; + + res.json = (body: unknown): Response => { + responseBody = body; + contentType = 'application/json'; + return originalJson(body); + }; + + res.end = (data?: unknown, encoding?: BufferEncoding | (() => void), cb?: () => void): Response => { + const body = data ?? responseBody; + if (!body) { + return originalEnd(data as Buffer, (encoding as BufferEncoding) || 'utf-8', cb as (() => void) | undefined); + } + + const compressed = getCompressedResponse(res, body, endpointConfig); + if (compressed) { + res.removeHeader('Content-Length'); + return originalEnd(compressed, cb as (() => void) | undefined); + } + + return originalEnd(data as Buffer, (encoding as BufferEncoding) || 'utf-8', cb as (() => void) | undefined); + }; + + next(); + }; +} + +export function getCompressionMetrics() { + return { + activeEndpoints: Array.from(configs.keys()), + }; +} diff --git a/backend/src/routes/circuit-breaker.ts b/backend/src/routes/circuit-breaker.ts new file mode 100644 index 00000000..b2906363 --- /dev/null +++ b/backend/src/routes/circuit-breaker.ts @@ -0,0 +1,32 @@ +import { Router } from 'express'; +import { asyncHandler } from '../middleware/errorHandler.js'; +import { getAllCircuits, getCircuitState, resetCircuit } from '../middleware/circuit-breaker.js'; + +const router = Router(); + +router.get('/', asyncHandler(async (_req, res) => { + const circuits = getAllCircuits(); + res.json({ circuits, count: circuits.length }); +})); + +router.get('/:name', asyncHandler(async (req, res) => { + const name = req.params.name as string; + const state = getCircuitState(name); + if (!state) { + res.status(404).json({ error: { code: 'CIRCUIT_NOT_FOUND', message: `Circuit ${name} not found` } }); + return; + } + res.json(state); +})); + +router.post('/:name/reset', asyncHandler(async (req, res) => { + const name = req.params.name as string; + const success = resetCircuit(name); + if (!success) { + res.status(404).json({ error: { code: 'CIRCUIT_NOT_FOUND', message: `Circuit ${name} not found` } }); + return; + } + res.json({ message: `Circuit ${name} reset to closed state`, name }); +})); + +export { router as circuitBreakerRouter }; diff --git a/backend/src/services/stellar.ts b/backend/src/services/stellar.ts index 1de5e31f..a3c918f2 100644 --- a/backend/src/services/stellar.ts +++ b/backend/src/services/stellar.ts @@ -1,6 +1,7 @@ import * as StellarSdk from '@stellar/stellar-sdk'; import { config } from '../config/env.js'; import { withQueryProfiling } from '../config/database.js'; +import { withCircuitBreaker, CircuitBreakerError } from '../middleware/circuit-breaker.js'; const NETWORK = config().STELLAR_NETWORK; const HORIZON_URL = @@ -8,7 +9,11 @@ const HORIZON_URL = ? 'https://horizon.stellar.org' : 'https://horizon-testnet.stellar.org'; -export const server = new StellarSdk.Horizon.Server(HORIZON_URL); +const STELLAR_CIRCUIT_NAME = 'stellar-horizon'; + +const serverOptions: StellarSdk.Horizon.Server.Options = {}; + +export const server = new StellarSdk.Horizon.Server(HORIZON_URL, serverOptions); const networkPassphrase = NETWORK === 'public' @@ -82,13 +87,23 @@ class NonceManager { } try { - const account = await server.loadAccount(address); + const account = await withCircuitBreaker( + STELLAR_CIRCUIT_NAME, + () => server.loadAccount(address), + ); state.current = account.sequence; state.locked = true; state.lastUsedAt = Date.now(); this.nonces.set(address, state); return state.current; } catch (error) { + if (error instanceof CircuitBreakerError) { + throw new UnitOfWorkError( + `Stellar Horizon unavailable: ${error.message}`, + 'acquire-nonce', + error, + ); + } throw new UnitOfWorkError( `Failed to acquire nonce for ${address}`, 'acquire-nonce', @@ -116,7 +131,10 @@ class NonceManager { async resolveConflict(address: string): Promise { for (let attempt = 1; attempt <= this.maxRetries; attempt++) { try { - const account = await server.loadAccount(address); + const account = await withCircuitBreaker( + STELLAR_CIRCUIT_NAME, + () => server.loadAccount(address), + ); const state = this.nonces.get(address); if (state) { state.current = account.sequence; @@ -166,12 +184,15 @@ class GasEstimator { const now = Date.now(); if (now - this.estimateTimestamp > this.estimateTtlMs) { try { - const feeStats = await server.feeStats(); + const feeStats = await withCircuitBreaker( + STELLAR_CIRCUIT_NAME, + () => server.feeStats(), + async () => ({ max_fee: { mode: '100' }, last_ledger: '0' }), + ); this.baseFee = parseInt(feeStats.max_fee?.mode || '100', 10); this.lastEstimate = this.baseFee; this.estimateTimestamp = now; - const ledgers = parseInt(feeStats.last_ledger, 10) || 0; const surge = feeStats.max_fee?.mode && parseInt(feeStats.max_fee.mode, 10) > 1000; this.surgeMultiplier = surge ? 2.0 : 1.0; } catch { @@ -370,7 +391,10 @@ export async function getAccountInfo(address: string) { `getAccountInfo(${address})`, 'stellar.service', async () => { - const account = await server.loadAccount(address); + const account = await withCircuitBreaker( + STELLAR_CIRCUIT_NAME, + () => server.loadAccount(address), + ); return { address: account.accountId(), balances: account.balances.map((b) => ({ @@ -390,7 +414,10 @@ export async function getTransactionStatus(hash: string) { `getTransactionStatus(${hash})`, 'stellar.service', async () => { - const tx = await server.transactions().transaction(hash).call(); + const tx = await withCircuitBreaker( + STELLAR_CIRCUIT_NAME, + () => server.transactions().transaction(hash).call(), + ); return { hash: tx.hash, successful: tx.successful, diff --git a/backend/src/services/stripe.ts b/backend/src/services/stripe.ts index 2a6866f0..e5018054 100644 --- a/backend/src/services/stripe.ts +++ b/backend/src/services/stripe.ts @@ -1,6 +1,9 @@ import Stripe from 'stripe'; import { config } from '../config/env.js'; import { AppError } from '../middleware/errorHandler.js'; +import { withCircuitBreaker } from '../middleware/circuit-breaker.js'; + +const STRIPE_CIRCUIT_NAME = 'stripe-api'; let stripeClient: Stripe | null = null; @@ -10,7 +13,11 @@ export function getStripe(): Stripe { throw new AppError(500, 'Stripe is not configured', 'STRIPE_NOT_CONFIGURED'); } if (!stripeClient) { - stripeClient = new Stripe(cfg.STRIPE_SECRET_KEY, { apiVersion: '2025-02-24.acacia' }); + stripeClient = new Stripe(cfg.STRIPE_SECRET_KEY, { + apiVersion: '2025-02-24.acacia', + timeout: 15_000, + maxNetworkRetries: 2, + }); } return stripeClient; } @@ -26,38 +33,62 @@ export interface CreatePaymentIntentInput { } export async function createPaymentIntent(input: CreatePaymentIntentInput): Promise { - const stripe = getStripe(); - return stripe.paymentIntents.create({ - amount: input.amount, - currency: input.currency.toLowerCase(), - customer: input.customerId, - description: input.description, - metadata: input.metadata ?? {}, - // Enable 3D Secure automatically - payment_method_types: ['card'], - }); + return withCircuitBreaker( + STRIPE_CIRCUIT_NAME, + async () => { + const stripe = getStripe(); + return stripe.paymentIntents.create({ + amount: input.amount, + currency: input.currency.toLowerCase(), + customer: input.customerId, + description: input.description, + metadata: input.metadata ?? {}, + payment_method_types: ['card'], + }); + }, + ); } export async function confirmPaymentIntent(paymentIntentId: string): Promise { - const stripe = getStripe(); - return stripe.paymentIntents.retrieve(paymentIntentId); + return withCircuitBreaker( + STRIPE_CIRCUIT_NAME, + async () => { + const stripe = getStripe(); + return stripe.paymentIntents.retrieve(paymentIntentId); + }, + ); } export async function cancelPaymentIntent(paymentIntentId: string): Promise { - const stripe = getStripe(); - return stripe.paymentIntents.cancel(paymentIntentId); + return withCircuitBreaker( + STRIPE_CIRCUIT_NAME, + async () => { + const stripe = getStripe(); + return stripe.paymentIntents.cancel(paymentIntentId); + }, + ); } // ── Customers ──────────────────────────────────────────────────────────────── export async function createCustomer(email: string, name?: string): Promise { - const stripe = getStripe(); - return stripe.customers.create({ email, name }); + return withCircuitBreaker( + STRIPE_CIRCUIT_NAME, + async () => { + const stripe = getStripe(); + return stripe.customers.create({ email, name }); + }, + ); } export async function getCustomer(customerId: string): Promise { - const stripe = getStripe(); - return stripe.customers.retrieve(customerId); + return withCircuitBreaker( + STRIPE_CIRCUIT_NAME, + async () => { + const stripe = getStripe(); + return stripe.customers.retrieve(customerId); + }, + ); } // ── Refunds ────────────────────────────────────────────────────────────────── @@ -69,37 +100,62 @@ export interface CreateRefundInput { } export async function createRefund(input: CreateRefundInput): Promise { - const stripe = getStripe(); - return stripe.refunds.create({ - payment_intent: input.paymentIntentId, - amount: input.amount, - reason: input.reason ?? 'requested_by_customer', - }); + return withCircuitBreaker( + STRIPE_CIRCUIT_NAME, + async () => { + const stripe = getStripe(); + return stripe.refunds.create({ + payment_intent: input.paymentIntentId, + amount: input.amount, + reason: input.reason ?? 'requested_by_customer', + }); + }, + ); } export async function getRefund(refundId: string): Promise { - const stripe = getStripe(); - return stripe.refunds.retrieve(refundId); + return withCircuitBreaker( + STRIPE_CIRCUIT_NAME, + async () => { + const stripe = getStripe(); + return stripe.refunds.retrieve(refundId); + }, + ); } // ── Disputes ───────────────────────────────────────────────────────────────── export async function getDispute(disputeId: string): Promise { - const stripe = getStripe(); - return stripe.disputes.retrieve(disputeId); + return withCircuitBreaker( + STRIPE_CIRCUIT_NAME, + async () => { + const stripe = getStripe(); + return stripe.disputes.retrieve(disputeId); + }, + ); } export async function listDisputes(paymentIntentId?: string): Promise> { - const stripe = getStripe(); - return stripe.disputes.list(paymentIntentId ? { payment_intent: paymentIntentId } : {}); + return withCircuitBreaker( + STRIPE_CIRCUIT_NAME, + async () => { + const stripe = getStripe(); + return stripe.disputes.list(paymentIntentId ? { payment_intent: paymentIntentId } : {}); + }, + ); } export async function submitDisputeEvidence( disputeId: string, evidence: Stripe.DisputeUpdateParams['evidence'] ): Promise { - const stripe = getStripe(); - return stripe.disputes.update(disputeId, { evidence }); + return withCircuitBreaker( + STRIPE_CIRCUIT_NAME, + async () => { + const stripe = getStripe(); + return stripe.disputes.update(disputeId, { evidence }); + }, + ); } // ── Webhooks ───────────────────────────────────────────────────────────────── diff --git a/frontend/components/OptimizedImage.tsx b/frontend/components/OptimizedImage.tsx index ffdf9839..240a0d01 100644 --- a/frontend/components/OptimizedImage.tsx +++ b/frontend/components/OptimizedImage.tsx @@ -1,17 +1,66 @@ -import Image, { type ImageProps } from "next/image"; +"use client"; -type OptimizedImageProps = Omit & { +import Image, { type ImageProps, getImageProps } from "next/image"; +import { useMemo } from "react"; + +type OptimizedImageProps = Omit & { blurDataURL?: string; + sizes?: string; + priority?: boolean; + quality?: number; }; -export function OptimizedImage({ blurDataURL, loading, ...props }: OptimizedImageProps) { +const DEVICE_SIZES = [640, 750, 828, 1080, 1200, 1920, 2048, 3840]; + +export function OptimizedImage({ + blurDataURL, + priority = false, + quality = 85, + sizes, + style, + ...props +}: OptimizedImageProps) { + const resolvedSizes = sizes ?? getResponsiveSizes(props.fill ?? false); + + const defaultBlurDataURL = useMemo(() => { + if (blurDataURL) return blurDataURL; + return generatePlaceholder(props.src); + }, [blurDataURL, props.src]); + return ( ); } +function getResponsiveSizes(fill: boolean): string { + if (fill) { + return "(max-width: 640px) 100vw, (max-width: 768px) 50vw, (max-width: 1024px) 33vw, 25vw"; + } + return "(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"; +} + +function generatePlaceholder(src: ImageProps["src"]): string { + if (typeof src === "string") { + const srcStr = src; + if (srcStr.startsWith("data:") || srcStr.startsWith("blob:")) return ""; + if (srcStr.endsWith(".svg")) return ""; + } + return ""; +} + +export function getOptimizedImageProps(props: ImageProps) { + return getImageProps(props); +} diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 7470b48b..bf63b5ce 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -97,6 +97,8 @@ const nextConfig: NextConfig = { images: { formats: ["image/avif", "image/webp"], minimumCacheTTL: 60 * 60 * 24 * 7, + deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840], + imageSizes: [16, 32, 48, 64, 96, 128, 256, 384], remotePatterns: process.env.NEXT_PUBLIC_IMAGE_CDN_DOMAIN ? [ { diff --git a/infra/environments/dev.tfvars b/infra/environments/dev.tfvars index 949496cc..0e482b41 100644 --- a/infra/environments/dev.tfvars +++ b/infra/environments/dev.tfvars @@ -3,3 +3,4 @@ stellar_network = "testnet" vpc_cidr = "10.0.0.0/16" private_subnets = ["10.0.1.0/24", "10.0.2.0/24"] public_subnets = ["10.0.101.0/24", "10.0.102.0/24"] +database_subnets = ["10.0.201.0/24", "10.0.202.0/24"] diff --git a/infra/environments/prod.tfvars b/infra/environments/prod.tfvars index f7970a4a..07031451 100644 --- a/infra/environments/prod.tfvars +++ b/infra/environments/prod.tfvars @@ -3,3 +3,4 @@ stellar_network = "public" vpc_cidr = "10.2.0.0/16" private_subnets = ["10.2.1.0/24", "10.2.2.0/24"] public_subnets = ["10.2.101.0/24", "10.2.102.0/24"] +database_subnets = ["10.2.201.0/24", "10.2.202.0/24"] diff --git a/infra/environments/staging.tfvars b/infra/environments/staging.tfvars index 3cbef095..45b734b1 100644 --- a/infra/environments/staging.tfvars +++ b/infra/environments/staging.tfvars @@ -3,3 +3,4 @@ stellar_network = "testnet" vpc_cidr = "10.1.0.0/16" private_subnets = ["10.1.1.0/24", "10.1.2.0/24"] public_subnets = ["10.1.101.0/24", "10.1.102.0/24"] +database_subnets = ["10.1.201.0/24", "10.1.202.0/24"] diff --git a/infra/main.tf b/infra/main.tf index 45615174..ee2fb2d4 100644 --- a/infra/main.tf +++ b/infra/main.tf @@ -46,6 +46,201 @@ module "vpc" { enable_nat_gateway = true single_nat_gateway = var.environment != "prod" # Cost optimization for non-prod + + # Security group for RDS + create_database_subnet_group = true + database_subnets = var.database_subnets + create_database_internet_gateway_route = false +} + +# ------------------------------------------------------------------------------ +# DATABASE RESOURCES (PostgreSQL + PgBouncer via RDS Proxy) +# ------------------------------------------------------------------------------ + +resource "aws_db_subnet_group" "main" { + name = "agenticpay-${var.environment}-db-subnet-group" + subnet_ids = module.vpc.database_subnets + + tags = { + Name = "agenticpay-${var.environment}-db-subnet-group" + } +} + +resource "aws_security_group" "rds" { + name = "agenticpay-${var.environment}-rds-sg" + vpc_id = module.vpc.vpc_id + + ingress { + from_port = 5432 + to_port = 5432 + protocol = "tcp" + security_groups = [aws_security_group.rds_proxy.id] + description = "Allow RDS Proxy access to PostgreSQL" + } + + tags = { + Name = "agenticpay-${var.environment}-rds-sg" + } +} + +resource "aws_db_instance" "postgres" { + identifier = "agenticpay-${var.environment}" + + engine = "postgres" + engine_version = "16.3" + instance_class = var.db_instance_class + + db_name = "agenticpay" + username = var.db_username + password = var.db_password + + db_subnet_group_name = aws_db_subnet_group.main.name + vpc_security_group_ids = [aws_security_group.rds.id] + + allocated_storage = var.db_allocated_storage + max_allocated_storage = var.db_max_allocated_storage + storage_type = "gp3" + storage_encrypted = true + + backup_retention_period = var.environment == "prod" ? 30 : 7 + backup_window = "03:00-04:00" + maintenance_window = "sun:04:00-sun:05:00" + + auto_minor_version_upgrade = true + deletion_protection = var.environment == "prod" + skip_final_snapshot = var.environment != "prod" + copy_tags_to_snapshot = true + + performance_insights_enabled = var.environment == "prod" + performance_insights_retention_period = var.environment == "prod" ? 7 : 0 + + enabled_cloudwatch_logs_exports = ["postgresql"] + + tags = { + Name = "agenticpay-${var.environment}" + } +} + +# RDS Proxy (AWS-managed PgBouncer in transaction mode) +resource "aws_security_group" "rds_proxy" { + name = "agenticpay-${var.environment}-rds-proxy-sg" + vpc_id = module.vpc.vpc_id + + ingress { + from_port = 5432 + to_port = 5432 + protocol = "tcp" + # Allow from App Runner VPC connector (default security group) + security_groups = [module.vpc.default_security_group_id] + description = "Allow App Runner to connect to RDS Proxy" + } + + egress { + from_port = 5432 + to_port = 5432 + protocol = "tcp" + security_groups = [aws_security_group.rds.id] + description = "Allow RDS Proxy to connect to RDS" + } + + tags = { + Name = "agenticpay-${var.environment}-rds-proxy-sg" + } +} + +resource "aws_db_proxy" "pgbouncer" { + name = "agenticpay-${var.environment}-proxy" + debug_logging = var.environment != "prod" + engine_family = "POSTGRESQL" + idle_client_timeout = var.db_proxy_idle_timeout + require_tls = true + role_arn = aws_iam_role.rds_proxy.arn + vpc_subnet_ids = module.vpc.database_subnets + vpc_security_group_ids = [aws_security_group.rds_proxy.id] + + auth { + auth_scheme = "SECRETS" + description = "RDS Proxy authentication via Secrets Manager" + iam_auth = "DISABLED" + secret_arn = aws_secretsmanager_secret.db_credentials.arn + } + + connection_pool_config { + connection_borrow_timeout = var.db_proxy_borrow_timeout + init_query = "SET application_name = 'agenticpay'" + max_connections_percent = var.db_proxy_max_connections_percent + max_idle_connections_percent = var.db_proxy_max_idle_connections_percent + session_pinning_filters = ["EXCLUDE_VARIABLE_SETS"] + } +} + +resource "aws_db_proxy_default_target_group" "main" { + db_proxy_name = aws_db_proxy.pgbouncer.name + + connection_pool_config { + connection_borrow_timeout = var.db_proxy_borrow_timeout + init_query = "SET application_name = 'agenticpay'" + max_connections_percent = var.db_proxy_max_connections_percent + max_idle_connections_percent = var.db_proxy_max_idle_connections_percent + session_pinning_filters = ["EXCLUDE_VARIABLE_SETS"] + } +} + +resource "aws_db_proxy_target" "main" { + db_proxy_name = aws_db_proxy.pgbouncer.name + target_group_name = aws_db_proxy_default_target_group.main.name + db_instance_identifier = aws_db_instance.postgres.identifier +} + +# Secrets Manager for database credentials +resource "aws_secretsmanager_secret" "db_credentials" { + name = "agenticpay-${var.environment}-db-credentials" +} + +resource "aws_secretsmanager_secret_version" "db_credentials" { + secret_id = aws_secretsmanager_secret.db_credentials.id + secret_string = jsonencode({ + username = var.db_username + password = var.db_password + engine = "postgres" + host = aws_db_proxy.pgbouncer.endpoint + port = 5432 + dbname = "agenticpay" + dbInstanceIdentifier = aws_db_instance.postgres.identifier + }) +} + +resource "aws_iam_role" "rds_proxy" { + name = "agenticpay-${var.environment}-rds-proxy-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "rds.amazonaws.com" + } + } + ] + }) +} + +resource "aws_iam_role_policy" "rds_proxy_secrets" { + name = "agenticpay-${var.environment}-rds-proxy-secrets-policy" + role = aws_iam_role.rds_proxy.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = "secretsmanager:GetSecretValue" + Effect = "Allow" + Resource = aws_secretsmanager_secret.db_credentials.arn + } + ] + }) } # ------------------------------------------------------------------------------ @@ -68,8 +263,12 @@ resource "aws_apprunner_service" "backend" { image_configuration { port = "3001" runtime_environment_variables = { - NODE_ENV = var.environment - STELLAR_NETWORK = var.stellar_network + NODE_ENV = var.environment + STELLAR_NETWORK = var.stellar_network + PGBOUNCER_ENABLED = "true" + DATABASE_URL = "postgresql://${var.db_username}:${var.db_password}@${aws_db_proxy.pgbouncer.endpoint}:5432/agenticpay" + DB_POOL_MAX = var.db_proxy_pool_max + DB_POOL_MIN = var.db_proxy_pool_min } } image_identifier = "${aws_ecr_repository.backend.repository_url}:latest" @@ -123,4 +322,4 @@ resource "aws_amplify_app" "frontend" { NEXT_PUBLIC_API_URL = "https://${aws_apprunner_service.backend.service_url}/api/v1" NODE_ENV = var.environment } -} \ No newline at end of file +} diff --git a/infra/variables.tf b/infra/variables.tf index 6de8e681..b4a83f81 100644 --- a/infra/variables.tf +++ b/infra/variables.tf @@ -31,4 +31,80 @@ variable "private_subnets" { variable "public_subnets" { description = "List of public subnet CIDRs" type = list(string) -} \ No newline at end of file +} + +variable "database_subnets" { + description = "List of database subnet CIDRs" + type = list(string) + default = [] +} + +# ── Database Variables ────────────────────────────────────────────────────────── + +variable "db_instance_class" { + description = "RDS instance class" + type = string + default = "db.t4g.medium" +} + +variable "db_username" { + description = "RDS master username" + type = string + sensitive = true +} + +variable "db_password" { + description = "RDS master password" + type = string + sensitive = true +} + +variable "db_allocated_storage" { + description = "Allocated storage for RDS in GB" + type = number + default = 20 +} + +variable "db_max_allocated_storage" { + description = "Maximum autoscaled storage for RDS in GB" + type = number + default = 100 +} + +# ── RDS Proxy (PgBouncer) Variables ──────────────────────────────────────────── + +variable "db_proxy_borrow_timeout" { + description = "Max time in seconds to borrow a connection from the pool" + type = number + default = 30 +} + +variable "db_proxy_max_connections_percent" { + description = "Max connections percentage for RDS Proxy" + type = number + default = 100 +} + +variable "db_proxy_max_idle_connections_percent" { + description = "Max idle connections percentage for RDS Proxy" + type = number + default = 50 +} + +variable "db_proxy_idle_timeout" { + description = "Idle client timeout in seconds for RDS Proxy" + type = number + default = 1800 +} + +variable "db_proxy_pool_max" { + description = "Max pool connections for the application" + type = number + default = 25 +} + +variable "db_proxy_pool_min" { + description = "Min pool connections for the application" + type = number + default = 2 +}