diff --git a/server/.env.example b/server/.env.example index eb5a4c8..f474864 100644 --- a/server/.env.example +++ b/server/.env.example @@ -34,3 +34,8 @@ SOROBAN_RPC_URL=https://soroban-testnet.stellar.org # Redis connection URL for caching popular events # Default: redis://127.0.0.1:6379 REDIS_URL=redis://127.0.0.1:6379 + +# JWT Configuration +# Required: Secret used to sign JWT tokens. +# Must be a cryptographically random string of at least 32 bytes. +JWT_SECRET=your_super_secret_jwt_key_that_is_at_least_32_bytes_long diff --git a/server/src/handlers/auth.rs b/server/src/handlers/auth.rs index 8cd07eb..59cb481 100644 --- a/server/src/handlers/auth.rs +++ b/server/src/handlers/auth.rs @@ -41,8 +41,8 @@ pub struct Claims { pub exp: i64, } -fn jwt_secret() -> String { - env::var("JWT_SECRET").unwrap_or_else(|_| "fallback_dev_secret_change_in_prod".to_string()) +pub fn jwt_secret() -> String { + env::var("JWT_SECRET").expect("JWT_SECRET environment variable is missing. It is required for signing JWTs.") } /// Encode a JWT for the given Stellar address with a 24-hour expiry. diff --git a/server/src/handlers/events.rs b/server/src/handlers/events.rs index 80722a2..81e9aff 100644 --- a/server/src/handlers/events.rs +++ b/server/src/handlers/events.rs @@ -411,6 +411,7 @@ mod tests { #[test] fn build_where_clause_includes_min_tickets_available() { let filters = EventFilters { + is_featured: None, organizer_id: None, organizer_wallet: None, location: None, @@ -438,6 +439,7 @@ mod tests { fn test_event_filters_deserialization() { // Test that filters can be deserialized from query params let filters = EventFilters { + is_featured: None, organizer_id: Some(Uuid::new_v4()), organizer_wallet: Some("GABC123".to_string()), location: Some("New York".to_string()), @@ -461,6 +463,7 @@ mod tests { #[test] fn test_organizer_wallet_filter() { let filters = EventFilters { + is_featured: None, organizer_id: None, organizer_wallet: Some("GBXXX".to_string()), location: None, @@ -511,6 +514,7 @@ mod tests { #[test] fn test_is_free_filter() { let filters_free = EventFilters { + is_featured: None, organizer_id: None, organizer_wallet: None, location: None, @@ -528,6 +532,7 @@ mod tests { assert_eq!(filters_free.is_free, Some(true)); let filters_paid = EventFilters { + is_featured: None, organizer_id: None, organizer_wallet: None, location: None, @@ -545,6 +550,7 @@ mod tests { assert_eq!(filters_paid.is_free, Some(false)); let filters_none = EventFilters { + is_featured: None, organizer_id: None, organizer_wallet: None, location: None, @@ -565,6 +571,7 @@ mod tests { #[test] fn test_start_date_filter_generates_where_clause() { let filters = EventFilters { + is_featured: None, organizer_id: None, organizer_wallet: None, location: None, @@ -590,6 +597,7 @@ mod tests { #[test] fn test_end_date_filter_generates_where_clause() { let filters = EventFilters { + is_featured: None, organizer_id: None, organizer_wallet: None, location: None, @@ -615,6 +623,7 @@ mod tests { #[test] fn test_followers_only_filter() { let filters = EventFilters { + is_featured: None, organizer_id: None, organizer_wallet: None, location: None, @@ -1013,6 +1022,7 @@ mod tests { #[test] fn test_invalid_sort_by_returns_validation_error() { let filters = EventFilters { + is_featured: None, organizer_id: None, organizer_wallet: None, location: None, @@ -1035,6 +1045,7 @@ mod tests { #[test] fn test_invalid_sort_order_returns_validation_error() { let filters = EventFilters { + is_featured: None, organizer_id: None, organizer_wallet: None, location: None, @@ -2639,6 +2650,7 @@ pub async fn get_event_revenue( fn test_event_filters_deserialization() { // Test that filters can be deserialized from query params let filters = EventFilters { + is_featured: None, organizer_id: Some(Uuid::new_v4()), organizer_wallet: Some("GABC123".to_string()), location: Some("New York".to_string()), @@ -2662,6 +2674,7 @@ fn test_event_filters_deserialization() { #[test] fn test_organizer_wallet_filter() { let filters = EventFilters { + is_featured: None, organizer_id: None, organizer_wallet: Some("GBXXX".to_string()), location: None, @@ -2682,6 +2695,7 @@ fn test_organizer_wallet_filter() { #[test] fn test_is_free_filter() { let filters_free = EventFilters { + is_featured: None, organizer_id: None, organizer_wallet: None, location: None, @@ -2699,6 +2713,7 @@ fn test_is_free_filter() { assert_eq!(filters_free.is_free, Some(true)); let filters_paid = EventFilters { + is_featured: None, organizer_id: None, organizer_wallet: None, location: None, @@ -2716,6 +2731,7 @@ fn test_is_free_filter() { assert_eq!(filters_paid.is_free, Some(false)); let filters_none = EventFilters { + is_featured: None, organizer_id: None, organizer_wallet: None, location: None, diff --git a/server/src/handlers/health.rs b/server/src/handlers/health.rs index 8c5a8bf..62b2a38 100644 --- a/server/src/handlers/health.rs +++ b/server/src/handlers/health.rs @@ -156,6 +156,31 @@ pub async fn health_check_blockchain() -> Response { } } +#[derive(Serialize)] +struct HealthRedisResponse { + status: &'static str, + timestamp: String, +} + +/// GET /health/redis – Redis connectivity check. +/// +/// Returns 200 when Redis is reachable. +/// Returns a structured JSON error (via [`AppError`]) when it is not. +pub async fn health_check_redis(State(mut redis): State) -> Response { + // Perform a basic Redis command to verify connectivity + match redis.ping().await { + Ok(_) => { + let payload = HealthRedisResponse { + status: "ok", + timestamp: Utc::now().to_rfc3339(), + }; + success(payload, "Redis is healthy").into_response() + } + Err(e) => AppError::ExternalServiceError(format!("Redis health check failed: {e}")) + .into_response(), + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/server/src/handlers/profile.rs b/server/src/handlers/profile.rs index 9f87958..286a7a2 100644 --- a/server/src/handlers/profile.rs +++ b/server/src/handlers/profile.rs @@ -10,7 +10,7 @@ use axum::{ extract::{Path, Query, State}, - http::{HeaderMap, StatusCode}, + http::HeaderMap, response::{IntoResponse, Response}, Json, }; @@ -63,15 +63,15 @@ fn validate_upsert(req: &UpsertProfileRequest) -> Result<(), AppError> { )); } if req.display_name.len() > MAX_DISPLAY_NAME { - return Err(AppError::ValidationError(format!( - "display_name must be at most {MAX_DISPLAY_NAME} characters" - ))); + return Err(AppError::ValidationError( + "displayName must not exceed 50 characters".to_string() + )); } if let Some(ref bio) = req.bio { if bio.len() > MAX_BIO { - return Err(AppError::ValidationError(format!( - "bio must be at most {MAX_BIO} characters" - ))); + return Err(AppError::ValidationError( + "bio must not exceed 500 characters".to_string() + )); } } Ok(()) @@ -345,40 +345,6 @@ pub async fn get_my_profile(State(mut state): State, headers: Head fetch_profile_by_address(&state.pool, &mut state.redis, &address).await } -/// `DELETE /api/v1/profile` -/// -/// Deletes the authenticated organizer's profile row from `organizer_profiles`. -/// Returns 204 No Content on success, 404 if no profile exists. -pub async fn delete_profile(State(mut state): State, headers: HeaderMap) -> Response { - let address = match extract_auth(&headers) { - Ok(a) => a, - Err(e) => return e.into_response(), - }; - - let result = match sqlx::query("DELETE FROM organizer_profiles WHERE address = $1") - .bind(&address) - .execute(&state.pool) - .await - { - Ok(r) => r, - Err(e) => { - tracing::error!("Failed to delete organizer profile: {:?}", e); - return AppError::DatabaseError(e).into_response(); - } - }; - - if result.rows_affected() == 0 { - return AppError::NotFound(format!("No profile found for address '{address}'")) - .into_response(); - } - - let cache_key = format!("profile:{address}"); - if let Err(e) = state.redis.delete(&cache_key).await { - tracing::warn!("Failed to invalidate profile cache for {address}: {:?}", e); - } - - StatusCode::NO_CONTENT.into_response() -} /// Summary of a payment transaction returned by `GET /api/v1/profile/transactions`. #[derive(Debug, Clone, Serialize, FromRow)] diff --git a/server/src/main.rs b/server/src/main.rs index c00054a..da2b7bb 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -46,6 +46,9 @@ async fn main() { tracing::info!("Configuration: REDIS_URL={}", config.redis_url); // Note: DATABASE_URL is strictly excluded from logging for security reasons. + // Validate that JWT_SECRET is set before binding + let _ = agora_server::handlers::auth::jwt_secret(); + let pool = PgPoolOptions::new() .max_connections(5) .connect(&config.database_url) @@ -80,6 +83,31 @@ async fn main() { tracing::info!("🚀 Server running at http://localhost:{}", config.port); tracing::info!("Request IDs will be set via '{REQUEST_ID_HEADER}' header"); + // Spawn periodic background task to clean up old nonces (Issue #823) + let cleanup_pool = pool.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(15 * 60)); // 15 minutes + loop { + interval.tick().await; + tracing::info!("Running periodic cleanup of jwt_nonces..."); + match sqlx::query( + "DELETE FROM jwt_nonces WHERE expires_at < NOW() OR (used = TRUE AND created_at < NOW() - INTERVAL '7 days')" + ) + .execute(&cleanup_pool) + .await + { + Ok(result) => { + if result.rows_affected() > 0 { + tracing::info!("Cleaned up {} expired/used nonces.", result.rows_affected()); + } + } + Err(e) => { + tracing::error!("Failed to clean up jwt_nonces: {:?}", e); + } + } + } + }); + let listener = TcpListener::bind(addr) .await .expect("Failed to bind address"); diff --git a/server/src/middleware/mod.rs b/server/src/middleware/mod.rs index e593288..ddb23de 100644 --- a/server/src/middleware/mod.rs +++ b/server/src/middleware/mod.rs @@ -1,3 +1,4 @@ +pub mod admin_auth; pub mod audit; pub mod content_type; pub mod monitoring_auth; diff --git a/server/src/models/event.rs b/server/src/models/event.rs index 00097e5..c0b9f94 100644 --- a/server/src/models/event.rs +++ b/server/src/models/event.rs @@ -133,6 +133,7 @@ mod tests { fn test_is_free_defaults_false() { // When sqlx skips the field, the default is false. let event = Event { + is_featured: false, id: Uuid::new_v4(), organizer_id: Uuid::new_v4(), title: "Test".into(), @@ -155,6 +156,7 @@ mod tests { #[test] fn test_is_free_serializes() { let mut event = Event { + is_featured: false, id: Uuid::new_v4(), organizer_id: Uuid::new_v4(), title: "Free Concert".into(), @@ -179,6 +181,7 @@ mod tests { #[test] fn test_average_rating_none_when_no_ratings() { let event = Event { + is_featured: false, id: Uuid::new_v4(), organizer_id: Uuid::new_v4(), title: "T".into(), @@ -201,6 +204,7 @@ mod tests { #[test] fn test_average_rating_serialized_when_ratings_exist() { let event = Event { + is_featured: false, id: Uuid::new_v4(), organizer_id: Uuid::new_v4(), title: "Rated".into(), @@ -227,6 +231,7 @@ mod tests { let created = Utc.with_ymd_and_hms(2026, 5, 1, 10, 0, 0).unwrap(); let updated = Utc.with_ymd_and_hms(2026, 5, 20, 14, 30, 0).unwrap(); let event = Event { + is_featured: false, id: Uuid::new_v4(), organizer_id: Uuid::new_v4(), title: "T".into(), @@ -251,6 +256,7 @@ mod tests { #[test] fn test_average_rating_serialized_null_when_no_ratings() { let event = Event { + is_featured: false, id: Uuid::new_v4(), organizer_id: Uuid::new_v4(), title: "Unrated".into(), diff --git a/server/src/routes/mod.rs b/server/src/routes/mod.rs index d0b4156..84c7783 100644 --- a/server/src/routes/mod.rs +++ b/server/src/routes/mod.rs @@ -42,22 +42,25 @@ use crate::handlers::{ export_attendees_csv, get_attendee_count, get_checkin_stats, get_event, get_event_counts, get_event_organizer, get_event_share_link, get_event_social_proof, get_ratings_summary, list_event_ratings, list_event_tickets, list_events, list_events_by_category, - list_featured_events, list_past_events, - list_similar_events, list_ticket_tiers, list_upcoming_events, search_events, + list_past_events, list_similar_events, list_ticket_tiers, list_upcoming_events, search_events, set_event_featured, submit_event_rating, toggle_event_flag, EventState, }, example_empty_success, example_not_found, example_validation_error, - health::{health_check, health_check_blockchain, health_check_db, health_check_ready}, + health::{health_check, health_check_blockchain, health_check_db, health_check_ready, health_check_redis}, leaderboard::{get_leaderboard, LeaderboardState}, monitoring::{monitoring_dashboard, MonitoringState}, profile::{ - get_my_profile, get_organizer_stats, get_profile_by_address, list_events_by_organizer, + delete_profile, get_my_profile, get_organizer_stats, get_profile_by_address, list_events_by_organizer, list_my_transactions, patch_profile, upsert_profile, ProfileState, }, + qr_payload::{ + generate_qr_payload, verify_qr_payload, mark_qr_used, list_qr_payloads, delete_qr_payload, list_event_qr_codes, + }, rates::{get_rates, RatesState}, soroban_listener::{spawn_listener, ListenerConfig}, ws::{ws_purchases_handler, PurchaseBroadcaster}, }; +use crate::middleware::admin_auth::{require_admin_token, AdminAuthState}; use crate::middleware::audit::audit_layer; use crate::middleware::content_type::require_json_content_type; use crate::middleware::monitoring_auth::{require_monitoring_token, MonitoringAuthState}; @@ -211,6 +214,11 @@ pub async fn create_routes(pool: PgPool, config: Config, redis: RedisCache) -> R .route("/health/db", get(health_check_db)) .route("/health/ready", get(health_check_ready)) .with_state(pool.clone()) + .merge( + Router::new() + .route("/health/redis", get(health_check_redis)) + .with_state(redis.clone()) + ) .merge( Router::new() .route("/monitoring/dashboard", get(monitoring_dashboard)) @@ -316,6 +324,7 @@ mod tests { .route("/api/v1/health/blockchain", get(|| async { "ok" })) .route("/api/v1/health/db", get(|| async { "ok" })) .route("/api/v1/health/ready", get(|| async { "ok" })) + .route("/api/v1/health/redis", get(|| async { "ok" })) .route("/api/v1/examples/validation-error", get(|| async { "ok" })) .route("/api/v1/examples/empty-success", get(|| async { "ok" })) .route("/api/v1/examples/not-found/:id", get(|| async { "ok" })) @@ -363,6 +372,15 @@ mod tests { ); } + #[tokio::test] + async fn test_health_redis_route_exists_under_api_v1() { + let router = test_router(); + assert_ne!( + get_status(router, "/api/v1/health/redis").await, + StatusCode::NOT_FOUND + ); + } + #[tokio::test] async fn test_examples_validation_error_route_exists_under_api_v1() { let router = test_router(); diff --git a/server/src/utils/error.rs b/server/src/utils/error.rs index 226f51b..d1ed45b 100644 --- a/server/src/utils/error.rs +++ b/server/src/utils/error.rs @@ -125,6 +125,7 @@ impl AppError { | AppError::AuthError(msg) | AppError::Forbidden(msg) | AppError::NotFound(msg) + | AppError::Conflict(msg) | AppError::ExternalServiceError(msg) | AppError::InternalServerError(msg) => msg.clone(), AppError::DatabaseError(err) => match DatabaseErrorCategory::from_sqlx(err) {