diff --git a/backend/migrations/20260528000001_production_bounds.sql b/backend/migrations/20260528000001_production_bounds.sql new file mode 100644 index 00000000..768608a2 --- /dev/null +++ b/backend/migrations/20260528000001_production_bounds.sql @@ -0,0 +1,35 @@ +-- Migration 004: production query bounds and lookup indexes + +CREATE INDEX IF NOT EXISTS jobs_status_created_idx + ON jobs (status, created_at DESC, id DESC); + +CREATE INDEX IF NOT EXISTS jobs_client_status_idx + ON jobs (client_address, status, created_at DESC); + +CREATE INDEX IF NOT EXISTS jobs_freelancer_status_idx + ON jobs (freelancer_address, status, created_at DESC) + WHERE freelancer_address IS NOT NULL; + +CREATE INDEX IF NOT EXISTS bids_job_created_idx + ON bids (job_id, created_at ASC, id ASC); + +CREATE INDEX IF NOT EXISTS milestones_job_status_idx + ON milestones (job_id, status, index ASC); + +CREATE INDEX IF NOT EXISTS disputes_job_created_idx + ON disputes (job_id, created_at DESC, id DESC); + +CREATE INDEX IF NOT EXISTS disputes_status_created_idx + ON disputes (status, created_at DESC); + +CREATE INDEX IF NOT EXISTS evidence_dispute_created_idx + ON evidence (dispute_id, created_at ASC, id ASC); + +CREATE INDEX IF NOT EXISTS verdicts_dispute_created_idx + ON verdicts (dispute_id, created_at DESC, id DESC); + +CREATE INDEX IF NOT EXISTS appeals_status_created_idx + ON appeals (status, created_at DESC, id DESC); + +CREATE INDEX IF NOT EXISTS arbiter_votes_appeal_created_idx + ON arbiter_votes (appeal_id, created_at ASC, id ASC); diff --git a/backend/src/config.rs b/backend/src/config.rs new file mode 100644 index 00000000..fdede925 --- /dev/null +++ b/backend/src/config.rs @@ -0,0 +1,167 @@ +use std::{env, time::Duration}; + +use anyhow::{anyhow, Context, Result}; +use axum::http::{header, HeaderName, HeaderValue, Method}; +use sqlx::postgres::PgPoolOptions; +use sqlx::PgPool; +use tower_http::cors::{AllowCredentials, AllowHeaders, AllowMethods, AllowOrigin, CorsLayer}; + +#[derive(Clone, Debug)] +pub struct DatabasePoolConfig { + pub max_connections: u32, + pub min_connections: u32, + pub acquire_timeout: Duration, + pub idle_timeout: Duration, + pub max_lifetime: Duration, +} + +impl DatabasePoolConfig { + pub fn from_env() -> Result { + let max_connections = read_u32_env("DATABASE_MAX_CONNECTIONS", 16)?; + let min_connections = read_u32_env("DATABASE_MIN_CONNECTIONS", 2)?; + + if min_connections > max_connections { + return Err(anyhow!( + "DATABASE_MIN_CONNECTIONS ({min_connections}) cannot exceed DATABASE_MAX_CONNECTIONS ({max_connections})" + )); + } + + Ok(Self { + max_connections, + min_connections, + acquire_timeout: Duration::from_secs(read_u64_env("DATABASE_ACQUIRE_TIMEOUT_SECS", 5)?), + idle_timeout: Duration::from_secs(read_u64_env("DATABASE_IDLE_TIMEOUT_SECS", 300)?), + max_lifetime: Duration::from_secs(read_u64_env("DATABASE_MAX_LIFETIME_SECS", 1_800)?), + }) + } + + pub fn connect_pool(&self, database_url: &str) -> Result { + let pool = PgPoolOptions::new() + .max_connections(self.max_connections) + .min_connections(self.min_connections) + .acquire_timeout(self.acquire_timeout) + .idle_timeout(Some(self.idle_timeout)) + .max_lifetime(Some(self.max_lifetime)) + .test_before_acquire(true) + .connect(database_url); + + Ok(pool.await.context("failed to connect to PostgreSQL")?) + } +} + +#[derive(Clone, Debug)] +pub struct CorsConfig { + allowed_origins: Vec, +} + +impl CorsConfig { + pub fn from_env() -> Result { + let app_env = env::var("APP_ENV").unwrap_or_else(|_| "development".to_string()); + let raw = env::var("CORS_ALLOWED_ORIGINS").ok(); + + if raw.is_none() && app_env.eq_ignore_ascii_case("production") { + return Err(anyhow!( + "CORS_ALLOWED_ORIGINS must be set when APP_ENV=production" + )); + } + + let origins = match raw { + Some(raw) => parse_allowed_origins(&raw)?, + None => default_dev_origins(), + }; + + if origins.is_empty() { + return Err(anyhow!("at least one CORS origin must be configured")); + } + + Ok(Self { + allowed_origins: origins, + }) + } + + pub fn layer(&self) -> CorsLayer { + CorsLayer::new() + .allow_origin(AllowOrigin::list(self.allowed_origins.clone())) + .allow_methods(AllowMethods::list([ + Method::GET, + Method::POST, + Method::PUT, + Method::PATCH, + Method::DELETE, + Method::OPTIONS, + ])) + .allow_headers(AllowHeaders::list([ + header::ACCEPT, + header::AUTHORIZATION, + header::CONTENT_TYPE, + HeaderName::from_static("x-wallet-address"), + ])) + .allow_credentials(AllowCredentials::yes()) + .max_age(Duration::from_secs(600)) + } +} + +fn default_dev_origins() -> Vec { + [ + "http://localhost:3000", + "http://127.0.0.1:3000", + "http://localhost:5173", + ] + .into_iter() + .map(|origin| HeaderValue::from_str(origin).expect("static origin")) + .collect() +} + +fn parse_allowed_origins(raw: &str) -> Result> { + raw.split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|origin| { + HeaderValue::from_str(origin).with_context(|| format!("invalid CORS origin: {origin}")) + }) + .collect() +} + +fn read_u32_env(name: &str, default: u32) -> Result { + match env::var(name) { + Ok(value) => value + .parse::() + .with_context(|| format!("invalid integer in {name}")), + Err(env::VarError::NotPresent) => Ok(default), + Err(err) => Err(err.into()), + } +} + +fn read_u64_env(name: &str, default: u64) -> Result { + match env::var(name) { + Ok(value) => value + .parse::() + .with_context(|| format!("invalid integer in {name}")), + Err(env::VarError::NotPresent) => Ok(default), + Err(err) => Err(err.into()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_allowed_origins_trims_and_filters_empty_values() { + let origins = parse_allowed_origins(" https://a.example , , https://b.example ") + .expect("origins should parse"); + assert_eq!(origins.len(), 2); + } + + #[test] + fn rejects_invalid_origin_values() { + let err = parse_allowed_origins("not a url").unwrap_err(); + assert!(err.to_string().contains("invalid CORS origin")); + } + + #[test] + fn defaults_to_dev_origins_when_unset() { + let origins = default_dev_origins(); + assert!(origins.len() >= 3); + } +} diff --git a/backend/src/routes/pagination.rs b/backend/src/routes/pagination.rs new file mode 100644 index 00000000..7fc7daa3 --- /dev/null +++ b/backend/src/routes/pagination.rs @@ -0,0 +1,57 @@ +use serde::Deserialize; + +#[derive(Clone, Copy, Debug, Deserialize)] +pub struct PaginationQuery { + pub limit: Option, + pub offset: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PaginationBounds { + pub limit: i64, + pub offset: i64, +} + +impl PaginationQuery { + const DEFAULT_LIMIT: u32 = 25; + const MAX_LIMIT: u32 = 100; + + pub fn bounds(self) -> PaginationBounds { + let limit = self + .limit + .unwrap_or(Self::DEFAULT_LIMIT) + .clamp(1, Self::MAX_LIMIT) as i64; + let offset = self.offset.unwrap_or(0) as i64; + + PaginationBounds { limit, offset } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bounds_apply_defaults() { + let bounds = PaginationQuery { + limit: None, + offset: None, + } + .bounds(); + + assert_eq!(bounds.limit, 25); + assert_eq!(bounds.offset, 0); + } + + #[test] + fn bounds_clamp_limit_to_maximum() { + let bounds = PaginationQuery { + limit: Some(1_000), + offset: Some(42), + } + .bounds(); + + assert_eq!(bounds.limit, 100); + assert_eq!(bounds.offset, 42); + } +}