From 4037e2bef917f1348552a271a25f1e407e231465 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C2=96=C2=96=C2=96feyisaralawal?= <––––feyisaralawal01@gmail.com> Date: Sat, 26 Sep 2026 21:44:21 +0100 Subject: [PATCH] feat: readiness probe, shared cursor pagination, rate limit docs, and sponsor negative tests This commit resolves issues #336, #334, #341, and #337: 1. Readiness endpoint distinct from liveness (Closes #336) - Added GET /health/ready endpoint in crates/api/src/lib.rs distinct from the cheap GET /health liveness probe. - Implemented Store::ping in crates/store/src/lib.rs executing a lightweight SELECT 1 against the Postgres connection pool. - Implemented Horizon::check_reachability in crates/api/src/horizon.rs probing the root endpoint with a 3-second timeout. - Returns HTTP 200 with structured JSON (status: ready, database: ok, horizon: ok) when all dependencies are healthy. - Returns HTTP 503 with structured JSON naming failed dependencies when degraded. - Added documentation in docs/architecture.md covering liveness vs readiness semantics for orchestrators. - Added integration tests covering reachable 200 and degraded 503 scenarios for DB and Horizon. 2. Shared cursor pagination helper (Closes #334) - Extracted keyset cursor pagination query construction into cursor_pagination_query(table, filter_column) in crates/store/src/lib.rs. - Refactored list_wallets_for_user_page, list_addresses_page, list_transactions_page, and list_payment_links to use the shared helper, eliminating SQL query drift while preserving exact pagination semantics. - Added isolated unit test in crates/store/tests/store_tests.rs asserting query shape and keyset comparison invariants. 3. Documented rate limit reference table (Closes #341) - Extracted rate limit thresholds and windows into public named constants in crates/api/src/rate_limit.rs. - Updated crates/api/src/auth.rs and crates/api/src/routes/payment_links.rs to reference the centralized constants. - Added comprehensive reference table to docs/api.md detailing every limited endpoint, HTTP method, key scope (per-IP, per-user), limit, window, and code constant name. - Included process note mandating updates to the table for future rate limit additions. 4. Negative-path coverage for sponsor endpoint (Closes #337) - Added focused negative-path integration tests in crates/api/tests/sponsor_e2e_tests.rs: - sponsor_rejects_when_sponsorship_is_disabled_for_the_wallet (HTTP 403) - sponsor_rejects_a_max_fee_above_the_per_tx_cap (HTTP 400) - sponsor_rejects_a_self_sponsoring_inner_transaction (HTTP 400) - sponsor_rejects_once_the_daily_budget_is_exhausted (HTTP 429) - sponsor_rejects_for_a_client_custody_wallet_with_no_gas_tank (HTTP 403) - Added create_wallet_without_gas_tank helper to reliably exercise the client-custody unprovisioned gas tank rejection path. --- crates/api/src/auth.rs | 10 +- crates/api/src/horizon.rs | 17 ++ crates/api/src/lib.rs | 54 +++++- crates/api/src/rate_limit.rs | 18 ++ crates/api/src/routes/payment_links.rs | 48 ++++- crates/api/tests/api_tests.rs | 91 ++++++++++ crates/api/tests/sponsor_e2e_tests.rs | 233 +++++++++++++++++++++++++ crates/store/src/lib.rs | 113 ++++++------ crates/store/tests/store_tests.rs | 11 ++ docs/api.md | 22 +++ docs/architecture.md | 7 + 11 files changed, 547 insertions(+), 77 deletions(-) diff --git a/crates/api/src/auth.rs b/crates/api/src/auth.rs index f7a808b..9cc530b 100644 --- a/crates/api/src/auth.rs +++ b/crates/api/src/auth.rs @@ -131,7 +131,7 @@ fn check_auth_rate_limit( let ip = crate::rate_limit::client_ip(headers, peer); if state .rate_limiter() - .check(&ip, "auth", 10, std::time::Duration::from_secs(60)) + .check(&ip, "auth", crate::rate_limit::AUTH_RATE_LIMIT, crate::rate_limit::AUTH_RATE_WINDOW) { Ok(()) } else { @@ -280,8 +280,8 @@ pub async fn resend_otp( if !state.rate_limiter().check( &format!("otp:{user_id}"), "otp_resend", - 3, - std::time::Duration::from_secs(60 * 60), + crate::rate_limit::OTP_RESEND_USER_LIMIT, + crate::rate_limit::OTP_RESEND_USER_WINDOW, ) { return Err(ApiError::TooManyRequests( "too many resend attempts — wait a while and try again".into(), @@ -291,8 +291,8 @@ pub async fn resend_otp( if !state.rate_limiter().check( &ip, "otp_resend_ip", - 10, - std::time::Duration::from_secs(60 * 60), + crate::rate_limit::OTP_RESEND_IP_LIMIT, + crate::rate_limit::OTP_RESEND_IP_WINDOW, ) { return Err(ApiError::TooManyRequests( "too many resend attempts — wait a while and try again".into(), diff --git a/crates/api/src/horizon.rs b/crates/api/src/horizon.rs index 0e43073..5d046ce 100644 --- a/crates/api/src/horizon.rs +++ b/crates/api/src/horizon.rs @@ -387,6 +387,23 @@ impl Horizon { Err(ResilienceError::Exhausted(_)) => Err(ApiError::Internal), } } + + // Probe Horizon root endpoint to verify node reachability. + pub async fn check_reachability(&self) -> Result<(), String> { + let url = self.base_url.trim_end_matches('/'); + let resp = self + .http + .get(url) + .timeout(Duration::from_secs(3)) + .send() + .await + .map_err(|e| format!("horizon unreachable: {e}"))?; + if resp.status().is_success() { + Ok(()) + } else { + Err(format!("horizon returned HTTP {}", resp.status())) + } + } } // --------------------------------------------------------------------------- diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index a41dbf8..714a471 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -18,9 +18,11 @@ pub mod submit_validation; pub use error::{ApiError, ApiResult, Envelope}; pub use state::AppState; -use axum::extract::DefaultBodyLimit; +use axum::extract::{DefaultBodyLimit, State}; +use axum::http::StatusCode; +use axum::response::IntoResponse; use axum::routing::{delete, get, post}; -use axum::Router; +use axum::{Json, Router}; use tower_http::cors::{Any, CorsLayer}; /// Keep API request payloads bounded to a deliberate, documented ceiling. @@ -42,6 +44,7 @@ pub fn build_router(state: AppState) -> Router { // together with the error handler that turns an oversized body into a 413 envelope. Router::new() .route("/health", get(health)) + .route("/health/ready", get(health_ready)) .route("/v1/auth/signup", post(auth::signup)) .route("/v1/auth/verify-email", post(auth::verify_email)) .route("/v1/auth/resend-otp", post(auth::resend_otp)) @@ -197,6 +200,53 @@ async fn health() -> &'static str { "ok" } +// Readiness probe checking database and Horizon reachability. +async fn health_ready(State(state): State) -> impl IntoResponse { + let mut db_ok = false; + let mut horizon_ok = false; + let mut db_err = None; + let mut horizon_err = None; + + match state.store().ping().await { + Ok(_) => db_ok = true, + Err(e) => db_err = Some(e.to_string()), + } + + match state.horizon().check_reachability().await { + Ok(_) => horizon_ok = true, + Err(e) => horizon_err = Some(e), + } + + if db_ok && horizon_ok { + ( + StatusCode::OK, + Json(serde_json::json!({ + "status": "ready", + "database": "ok", + "horizon": "ok" + })), + ) + } else { + let mut failed = Vec::new(); + if !db_ok { + failed.push("database"); + } + if !horizon_ok { + failed.push("horizon"); + } + ( + StatusCode::SERVICE_UNAVAILABLE, + Json(serde_json::json!({ + "status": "not_ready", + "database": if db_ok { "ok".to_string() } else { db_err.unwrap_or_else(|| "unreachable".into()) }, + "horizon": if horizon_ok { "ok".to_string() } else { horizon_err.unwrap_or_else(|| "unreachable".into()) }, + "failed": failed, + "error": format!("unreachable dependencies: {}", failed.join(", ")) + })), + ) + } +} + // NOTE: a `handle_errors` HandleErrorLayer helper lived here to convert oversized-body errors // into a 413 envelope. It is unnecessary with `DefaultBodyLimit` (axum renders that rejection as // 413 itself) and did not satisfy `Router::layer`'s Service bounds, so it was removed. diff --git a/crates/api/src/rate_limit.rs b/crates/api/src/rate_limit.rs index 28f4812..476e8b5 100644 --- a/crates/api/src/rate_limit.rs +++ b/crates/api/src/rate_limit.rs @@ -12,6 +12,24 @@ use std::time::{Duration, Instant}; /// Cap on tracked (ip, class) buckets before expired entries are swept. const SWEEP_THRESHOLD: usize = 10_000; +// Rate limit thresholds and fixed windows for API endpoints. +pub const AUTH_RATE_LIMIT: u32 = 10; +pub const AUTH_RATE_WINDOW: Duration = Duration::from_secs(60); +pub const OTP_RESEND_USER_LIMIT: u32 = 3; +pub const OTP_RESEND_USER_WINDOW: Duration = Duration::from_secs(3600); +pub const OTP_RESEND_IP_LIMIT: u32 = 10; +pub const OTP_RESEND_IP_WINDOW: Duration = Duration::from_secs(3600); +pub const PAY_READ_LIMIT: u32 = 60; +pub const PAY_READ_WINDOW: Duration = Duration::from_secs(60); +pub const PAY_INTENT_LIMIT: u32 = 5; +pub const PAY_INTENT_WINDOW: Duration = Duration::from_secs(60); +pub const PAY_STATUS_LIMIT: u32 = 60; +pub const PAY_STATUS_WINDOW: Duration = Duration::from_secs(60); +pub const PAY_SIGNING_INFO_LIMIT: u32 = 60; +pub const PAY_SIGNING_INFO_WINDOW: Duration = Duration::from_secs(60); +pub const PAY_SUBMIT_LIMIT: u32 = 20; +pub const PAY_SUBMIT_WINDOW: Duration = Duration::from_secs(60); + /// Bucket key: the client IP plus the endpoint class it is being limited against. type BucketKey = (String, &'static str); /// Bucket value: when the current fixed window started, and hits so far within it. diff --git a/crates/api/src/routes/payment_links.rs b/crates/api/src/routes/payment_links.rs index bd039b4..15c34ae 100644 --- a/crates/api/src/routes/payment_links.rs +++ b/crates/api/src/routes/payment_links.rs @@ -326,11 +326,12 @@ fn check_public_rate_limit( peer: Option>, class: &'static str, limit: u32, + window: std::time::Duration, ) -> Result<(), ApiError> { let ip = crate::rate_limit::client_ip(headers, peer.map(|c| c.0)); if state .rate_limiter() - .check(&ip, class, limit, std::time::Duration::from_secs(60)) + .check(&ip, class, limit, window) { Ok(()) } else { @@ -347,7 +348,14 @@ pub async fn get_public_payment_link( peer: Option>, headers: HeaderMap, ) -> ApiResult>> { - check_public_rate_limit(&state, &headers, peer, "pay_read", 60)?; + check_public_rate_limit( + &state, + &headers, + peer, + "pay_read", + crate::rate_limit::PAY_READ_LIMIT, + crate::rate_limit::PAY_READ_WINDOW, + )?; let link = state.store().get_payment_link_by_slug(&slug).await?; if !link.active { return Err(ApiError::NotFound); @@ -390,7 +398,14 @@ pub async fn create_payment_intent( headers: HeaderMap, body: Bytes, ) -> ApiResult<(StatusCode, Json>)> { - check_public_rate_limit(&state, &headers, peer, "pay_intent", 5)?; + check_public_rate_limit( + &state, + &headers, + peer, + "pay_intent", + crate::rate_limit::PAY_INTENT_LIMIT, + crate::rate_limit::PAY_INTENT_WINDOW, + )?; let link = state.store().get_payment_link_by_slug(&slug).await?; if !link.active { return Err(ApiError::NotFound); @@ -463,7 +478,14 @@ pub async fn get_payment_status( headers: HeaderMap, ) -> ApiResult>> { // The pay page polls this every ~3s while waiting, so the ceiling is generous. - check_public_rate_limit(&state, &headers, peer, "pay_status", 60)?; + check_public_rate_limit( + &state, + &headers, + peer, + "pay_status", + crate::rate_limit::PAY_STATUS_LIMIT, + crate::rate_limit::PAY_STATUS_WINDOW, + )?; let link = state.store().get_payment_link_by_slug(&slug).await?; let payment = state .store() @@ -501,7 +523,14 @@ pub async fn public_signing_info( peer: Option>, headers: HeaderMap, ) -> ApiResult>> { - check_public_rate_limit(&state, &headers, peer, "pay_signing_info", 60)?; + check_public_rate_limit( + &state, + &headers, + peer, + "pay_signing_info", + crate::rate_limit::PAY_SIGNING_INFO_LIMIT, + crate::rate_limit::PAY_SIGNING_INFO_WINDOW, + )?; // Confirms the link exists/is active before doing any Horizon work on the caller's behalf. let link = state.store().get_payment_link_by_slug(&slug).await?; if !link.active { @@ -552,7 +581,14 @@ pub async fn submit_payment( headers: HeaderMap, body: Bytes, ) -> ApiResult<(StatusCode, Json>)> { - check_public_rate_limit(&state, &headers, peer, "pay_submit", 20)?; + check_public_rate_limit( + &state, + &headers, + peer, + "pay_submit", + crate::rate_limit::PAY_SUBMIT_LIMIT, + crate::rate_limit::PAY_SUBMIT_WINDOW, + )?; let link = state.store().get_payment_link_by_slug(&slug).await?; if !link.active { return Err(ApiError::NotFound); diff --git a/crates/api/tests/api_tests.rs b/crates/api/tests/api_tests.rs index 445d88d..3ffdac4 100644 --- a/crates/api/tests/api_tests.rs +++ b/crates/api/tests/api_tests.rs @@ -435,6 +435,97 @@ async fn health_is_public_and_ok() { assert_eq!(resp.status(), StatusCode::OK); } +// Local mock Horizon server for readiness testing. +async fn start_mock_horizon_ok() -> String { + let app = Router::new().route("/", axum::routing::get(|| async { "horizon ok" })); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind mock horizon"); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + format!("http://{addr}") +} + +#[tokio::test] +async fn health_ready_returns_200_when_db_and_horizon_are_both_reachable() { + let Some(_) = test_state().await else { + return; + }; + let mock_horizon = start_mock_horizon_ok().await; + let url = database_url().unwrap(); + let store = Store::connect(&url).await.expect("connect"); + let state = AppState::new( + store, + [42u8; 32], + StellarNetwork::Testnet, + mock_horizon, + None, + octo_email::EmailSender::new_captured(), + ); + let app = build_router(state); + let resp = app.oneshot(get("/health/ready")).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let json = body_json(resp).await; + assert_eq!(json["status"], "ready"); + assert_eq!(json["database"], "ok"); + assert_eq!(json["horizon"], "ok"); +} + +#[tokio::test] +async fn health_ready_returns_a_clear_503_naming_the_db_when_the_database_is_unreachable() { + let mock_horizon = start_mock_horizon_ok().await; + let dead_pool = sqlx::postgres::PgPoolOptions::new() + .acquire_timeout(std::time::Duration::from_millis(100)) + .connect_lazy("postgres://postgres:wrong@127.0.0.1:1/nonexistent") + .unwrap(); + let store = Store::from_pool(dead_pool); + let state = AppState::new( + store, + [42u8; 32], + StellarNetwork::Testnet, + mock_horizon, + None, + octo_email::EmailSender::new_captured(), + ); + let app = build_router(state); + let resp = app.oneshot(get("/health/ready")).await.unwrap(); + assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); + let json = body_json(resp).await; + assert_eq!(json["status"], "not_ready"); + assert_eq!(json["horizon"], "ok"); + assert!(json["database"] != "ok"); + let error_str = json["error"].as_str().unwrap(); + assert!(error_str.contains("database")); +} + +#[tokio::test] +async fn health_ready_returns_a_clear_503_naming_horizon_when_horizon_is_unreachable() { + let Some(_) = test_state().await else { + return; + }; + let url = database_url().unwrap(); + let store = Store::connect(&url).await.expect("connect"); + let state = AppState::new( + store, + [42u8; 32], + StellarNetwork::Testnet, + "http://127.0.0.1:1".into(), + None, + octo_email::EmailSender::new_captured(), + ); + let app = build_router(state); + let resp = app.oneshot(get("/health/ready")).await.unwrap(); + assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); + let json = body_json(resp).await; + assert_eq!(json["status"], "not_ready"); + assert_eq!(json["database"], "ok"); + assert!(json["horizon"] != "ok"); + let error_str = json["error"].as_str().unwrap(); + assert!(error_str.contains("horizon")); +} + #[tokio::test] async fn backup_round_trips_the_opaque_blob_verbatim() { let Some(state) = test_state().await else { diff --git a/crates/api/tests/sponsor_e2e_tests.rs b/crates/api/tests/sponsor_e2e_tests.rs index e266606..d20551b 100644 --- a/crates/api/tests/sponsor_e2e_tests.rs +++ b/crates/api/tests/sponsor_e2e_tests.rs @@ -134,6 +134,29 @@ async fn create_wallet(app: &Router, token: &str) -> (String, String) { (id, address) } +// Create a client-custody wallet without provisioning a gas tank. +async fn create_wallet_without_gas_tank(app: &Router, token: &str) -> (String, String) { + let kp = DalekKeyPair::random().unwrap(); + let reg_body = common::wallet_body(app, token, &kp).await; + let resp = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/wallets") + .header("content-type", "application/json") + .header("authorization", format!("Bearer {token}")) + .body(Body::from(reg_body)) + .unwrap(), + ) + .await + .unwrap(); + let w = body_json(resp).await; + let id = w["data"]["id"].as_str().unwrap().to_string(); + let address = w["data"]["address"].as_str().unwrap().to_string(); + (id, address) +} + /// Local mock Horizon that accepts POST /transactions and returns a successful submission. async fn start_mock_horizon() -> String { async fn submit() -> axum::Json { @@ -813,3 +836,213 @@ async fn e2e_concurrent_sponsor_requests_respect_budget() { "total reserved fees {total:?} must not exceed budget {daily_budget}" ); } + +#[tokio::test] +async fn sponsor_rejects_when_sponsorship_is_disabled_for_the_wallet() { + let horizon = start_mock_horizon().await; + let Some(state) = test_state(horizon).await else { + return; + }; + let app = build_router(state.clone()); + let token = auth_token(&app, &state).await; + let (wallet_id, master_g) = create_wallet(&app, &token).await; + + let uri = format!("/v1/wallets/{wallet_id}/sponsorship"); + let resp = app + .clone() + .oneshot(put_json_auth( + &uri, + r#"{"enabled":false,"daily_budget_stroops":1000000}"#, + &token, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let xdr = random_payment_xdr(&master_g); + let body = format!(r#"{{"transaction_xdr":"{xdr}","max_base_fee_stroops":200}}"#); + let resp = app + .oneshot(post_json_auth( + &format!("/v1/wallets/{wallet_id}/sponsor"), + &body, + &token, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + let json = body_json(resp).await; + assert_eq!( + json["message"], + "gas sponsorship is not enabled for this wallet" + ); +} + +#[tokio::test] +async fn sponsor_rejects_a_max_fee_above_the_per_tx_cap() { + let horizon = start_mock_horizon().await; + let Some(state) = test_state(horizon).await else { + return; + }; + let app = build_router(state.clone()); + let token = auth_token(&app, &state).await; + let (wallet_id, master_g) = create_wallet(&app, &token).await; + + let cap = 250_000_i64; + let uri = format!("/v1/wallets/{wallet_id}/sponsorship"); + let resp = app + .clone() + .oneshot(put_json_auth( + &uri, + &format!(r#"{{"enabled":true,"per_tx_fee_cap_stroops":{cap}}}"#), + &token, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let xdr = random_payment_xdr(&master_g); + let body = format!(r#"{{"transaction_xdr":"{xdr}","max_base_fee_stroops":{}}}"#, cap + 1); + let resp = app + .oneshot(post_json_auth( + &format!("/v1/wallets/{wallet_id}/sponsor"), + &body, + &token, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + let json = body_json(resp).await; + assert!( + json["message"] + .as_str() + .unwrap() + .contains("exceeds the per-transaction cap"), + "expected cap error, got: {}", + json["message"] + ); +} + +#[tokio::test] +async fn sponsor_rejects_a_self_sponsoring_inner_transaction() { + let horizon = start_mock_horizon().await; + let Some(state) = test_state(horizon).await else { + return; + }; + let app = build_router(state.clone()); + let token = auth_token(&app, &state).await; + let (wallet_id, master_g) = create_wallet(&app, &token).await; + enable_sponsorship_via_api(&app, &token, &wallet_id).await; + + let dest_g = "GBAW5XGWORWVFE2XTJYDTLDHXTY2Q2MO73HYCGB3XMFMQ562Q2W2GJQX"; + let master_g_key = stellar_base::crypto::PublicKey::from_account_id(&master_g).unwrap(); + let dest = stellar_base::crypto::PublicKey::from_account_id(dest_g).unwrap(); + let op = Operation::new_payment() + .with_destination(dest) + .with_amount(stellar_base::amount::Stroops::new(100)) + .unwrap() + .with_asset(stellar_base::asset::Asset::new_native()) + .build() + .unwrap(); + let tx = Transaction::builder(master_g_key, 1, MIN_BASE_FEE) + .add_operation(op) + .into_transaction() + .unwrap(); + let xdr = tx.into_envelope().xdr_base64().unwrap(); + + let body = format!(r#"{{"transaction_xdr":"{xdr}","max_base_fee_stroops":200}}"#); + let resp = app + .oneshot(post_json_auth( + &format!("/v1/wallets/{wallet_id}/sponsor"), + &body, + &token, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + let json = body_json(resp).await; + assert_eq!( + json["message"], + "inner transaction source must not be the master account" + ); +} + +#[tokio::test] +async fn sponsor_rejects_once_the_daily_budget_is_exhausted() { + let horizon = start_mock_horizon().await; + let Some(state) = test_state(horizon).await else { + return; + }; + let app = build_router(state.clone()); + let token = auth_token(&app, &state).await; + let (wallet_id, master_g) = create_wallet(&app, &token).await; + + let uri = format!("/v1/wallets/{wallet_id}/sponsorship"); + let resp = app + .clone() + .oneshot(put_json_auth( + &uri, + r#"{"enabled":true,"daily_budget_stroops":10000000}"#, + &token, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let fee = 6_000_000; + let body1 = format!( + r#"{{"transaction_xdr":"{}","max_base_fee_stroops":{fee}}}"#, + random_payment_xdr(&master_g) + ); + let sponsor_uri = format!("/v1/wallets/{wallet_id}/sponsor"); + let resp = app + .clone() + .oneshot(post_json_auth(&sponsor_uri, &body1, &token)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::CREATED); + + let body2 = format!( + r#"{{"transaction_xdr":"{}","max_base_fee_stroops":{fee}}}"#, + random_payment_xdr(&master_g) + ); + let resp = app + .oneshot(post_json_auth(&sponsor_uri, &body2, &token)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS); + let json = body_json(resp).await; + assert_eq!(json["message"], "daily sponsorship budget exceeded"); +} + +#[tokio::test] +async fn sponsor_rejects_for_a_client_custody_wallet_with_no_gas_tank() { + let horizon = start_mock_horizon().await; + let Some(state) = test_state(horizon).await else { + return; + }; + let app = build_router(state.clone()); + let token = auth_token(&app, &state).await; + let (wallet_id, master_g) = create_wallet_without_gas_tank(&app, &token).await; + enable_sponsorship_via_api(&app, &token, &wallet_id).await; + + let xdr = random_payment_xdr(&master_g); + let body = format!(r#"{{"transaction_xdr":"{xdr}","max_base_fee_stroops":200}}"#); + let resp = app + .oneshot(post_json_auth( + &format!("/v1/wallets/{wallet_id}/sponsor"), + &body, + &token, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + let json = body_json(resp).await; + assert!( + json["message"] + .as_str() + .unwrap() + .contains("no gas-tank account"), + "expected gas-tank error, got: {}", + json["message"] + ); +} diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs index 97f981a..602e26a 100644 --- a/crates/store/src/lib.rs +++ b/crates/store/src/lib.rs @@ -99,6 +99,12 @@ impl Store { &self.pool } + // Ping the database to verify pool reachability. + pub async fn ping(&self) -> Result<(), StoreError> { + sqlx::query("SELECT 1").execute(&self.pool).await?; + Ok(()) + } + // --- users ------------------------------------------------------------ /// Create a user. `email` should already be lowercased by the caller. Returns @@ -468,22 +474,13 @@ impl Store { limit: i64, before_id: Option, ) -> Result, StoreError> { - let rows = sqlx::query_as::<_, Wallet>( - r#" - SELECT * FROM wallets - WHERE user_id = $1 - AND ($2::uuid IS NULL OR (created_at, id) < ( - SELECT created_at, id FROM wallets WHERE id = $2 - )) - ORDER BY created_at DESC, id DESC - LIMIT $3 - "#, - ) - .bind(user_id) - .bind(before_id) - .bind(limit) - .fetch_all(&self.pool) - .await?; + let query = cursor_pagination_query("wallets", "user_id"); + let rows = sqlx::query_as::<_, Wallet>(&query) + .bind(user_id) + .bind(before_id) + .bind(limit) + .fetch_all(&self.pool) + .await?; Ok(rows) } @@ -736,22 +733,13 @@ impl Store { limit: i64, before_id: Option, ) -> Result, StoreError> { - let rows = sqlx::query_as::<_, Address>( - r#" - SELECT * FROM addresses - WHERE wallet_id = $1 - AND ($2::uuid IS NULL OR (created_at, id) < ( - SELECT created_at, id FROM addresses WHERE id = $2 - )) - ORDER BY created_at DESC, id DESC - LIMIT $3 - "#, - ) - .bind(wallet_id) - .bind(before_id) - .bind(limit) - .fetch_all(&self.pool) - .await?; + let query = cursor_pagination_query("addresses", "wallet_id"); + let rows = sqlx::query_as::<_, Address>(&query) + .bind(wallet_id) + .bind(before_id) + .bind(limit) + .fetch_all(&self.pool) + .await?; Ok(rows) } @@ -856,22 +844,13 @@ impl Store { limit: i64, before_id: Option, ) -> Result, StoreError> { - let rows = sqlx::query_as::<_, Transaction>( - r#" - SELECT * FROM transactions - WHERE wallet_id = $1 - AND ($2::uuid IS NULL OR (created_at, id) < ( - SELECT created_at, id FROM transactions WHERE id = $2 - )) - ORDER BY created_at DESC, id DESC - LIMIT $3 - "#, - ) - .bind(wallet_id) - .bind(before_id) - .bind(limit) - .fetch_all(&self.pool) - .await?; + let query = cursor_pagination_query("transactions", "wallet_id"); + let rows = sqlx::query_as::<_, Transaction>(&query) + .bind(wallet_id) + .bind(before_id) + .bind(limit) + .fetch_all(&self.pool) + .await?; Ok(rows) } @@ -1324,22 +1303,13 @@ impl Store { limit: i64, before_id: Option, ) -> Result, StoreError> { - let rows = sqlx::query_as::<_, PaymentLink>( - r#" - SELECT * FROM payment_links - WHERE wallet_id = $1 - AND ($2::uuid IS NULL OR (created_at, id) < ( - SELECT created_at, id FROM payment_links WHERE id = $2 - )) - ORDER BY created_at DESC, id DESC - LIMIT $3 - "#, - ) - .bind(wallet_id) - .bind(before_id) - .bind(limit) - .fetch_all(&self.pool) - .await?; + let query = cursor_pagination_query("payment_links", "wallet_id"); + let rows = sqlx::query_as::<_, PaymentLink>(&query) + .bind(wallet_id) + .bind(before_id) + .bind(limit) + .fetch_all(&self.pool) + .await?; Ok(rows) } @@ -1952,3 +1922,18 @@ impl Store { Ok(result.rows_affected()) } } + +// Builds keyset cursor pagination query using (created_at, id) tuple comparison for deterministic descending order. +pub fn cursor_pagination_query(table: &str, filter_column: &str) -> String { + format!( + r#" + SELECT * FROM {table} + WHERE {filter_column} = $1 + AND ($2::uuid IS NULL OR (created_at, id) < ( + SELECT created_at, id FROM {table} WHERE id = $2 + )) + ORDER BY created_at DESC, id DESC + LIMIT $3 + "# + ) +} diff --git a/crates/store/tests/store_tests.rs b/crates/store/tests/store_tests.rs index 9b047f6..5d275f5 100644 --- a/crates/store/tests/store_tests.rs +++ b/crates/store/tests/store_tests.rs @@ -1200,3 +1200,14 @@ async fn mark_polled_creates_and_updates_the_cursor_row() { "mark_polled must not fabricate a cursor position" ); } + +#[test] +fn shared_cursor_pagination_helper_encodes_invariant() { + let query = octo_store::cursor_pagination_query("wallets", "user_id"); + assert!(query.contains("SELECT * FROM wallets")); + assert!(query.contains("WHERE user_id = $1")); + assert!(query.contains("($2::uuid IS NULL OR (created_at, id) < (")); + assert!(query.contains("SELECT created_at, id FROM wallets WHERE id = $2")); + assert!(query.contains("ORDER BY created_at DESC, id DESC")); + assert!(query.contains("LIMIT $3")); +} diff --git a/docs/api.md b/docs/api.md index b18cace..a37adbb 100644 --- a/docs/api.md +++ b/docs/api.md @@ -119,3 +119,25 @@ so it cannot escalate or revoke itself. - **Amounts** are integer **stroops** (1 XLM = 10,000,000) end-to-end — never floats. - **Errors** map to `400` (validation), `401`, `403`, `404`, `409` (conflict), `410` (removed custodial endpoints), `413` (body over 64 KiB), `429` (budget exceeded). There is no `422`. + +## Rate Limits + +The API enforces fixed-window rate limiting on unauthenticated and authentication endpoints to protect against brute-force and resource-exhaustion attacks. When a rate limit is exceeded, the server responds with HTTP `429 Too Many Requests`. + +| Endpoint | Method | Key Scope | Limit | Window | Code Constant | +|---|---|---|---|---|---| +| `/v1/auth/signup` | POST | Per-IP | 10 req | 60s (1m) | `AUTH_RATE_LIMIT` / `AUTH_RATE_WINDOW` | +| `/v1/auth/verify-email` | POST | Per-IP | 10 req | 60s (1m) | `AUTH_RATE_LIMIT` / `AUTH_RATE_WINDOW` | +| `/v1/auth/login` | POST | Per-IP | 10 req | 60s (1m) | `AUTH_RATE_LIMIT` / `AUTH_RATE_WINDOW` | +| `/v1/auth/refresh` | POST | Per-IP | 10 req | 60s (1m) | `AUTH_RATE_LIMIT` / `AUTH_RATE_WINDOW` | +| `/v1/auth/resend-otp` | POST | Per-IP | 10 req | 60s (1m) | `AUTH_RATE_LIMIT` / `AUTH_RATE_WINDOW` | +| `/v1/auth/resend-otp` | POST | Per-User (`otp:{user_id}`) | 3 req | 3600s (1h) | `OTP_RESEND_USER_LIMIT` / `OTP_RESEND_USER_WINDOW` | +| `/v1/auth/resend-otp` | POST | Per-IP | 10 req | 3600s (1h) | `OTP_RESEND_IP_LIMIT` / `OTP_RESEND_IP_WINDOW` | +| `/v1/pay/:slug` | GET | Per-IP | 60 req | 60s (1m) | `PAY_READ_LIMIT` / `PAY_READ_WINDOW` | +| `/v1/pay/:slug/intent` | POST | Per-IP | 5 req | 60s (1m) | `PAY_INTENT_LIMIT` / `PAY_INTENT_WINDOW` | +| `/v1/pay/:slug/payments/:payment_id` | GET | Per-IP | 60 req | 60s (1m) | `PAY_STATUS_LIMIT` / `PAY_STATUS_WINDOW` | +| `/v1/pay/:slug/signing-info` | GET | Per-IP | 60 req | 60s (1m) | `PAY_SIGNING_INFO_LIMIT` / `PAY_SIGNING_INFO_WINDOW` | +| `/v1/pay/:slug/submit-signed` | POST | Per-IP | 20 req | 60s (1m) | `PAY_SUBMIT_LIMIT` / `PAY_SUBMIT_WINDOW` | + +> [!NOTE] +> **Process Note:** Any new rate limit added to the API must update this table and reference named constants in `crates/api/src/rate_limit.rs` within the same pull request. diff --git a/docs/architecture.md b/docs/architecture.md index 281c910..67742e6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -77,3 +77,10 @@ server, and it is confined to one crate: Keys are never written to disk or logs and are never persisted in derived form. Worst-case exposure of this key is the gas budget — never customer balances. + +## Deployment and Health Checks + +The server exposes two distinct health endpoints for container orchestrators and load balancers: + +- **Liveness probe (`GET /health`):** Lightweight and zero-dependency. Always returns HTTP 200 `ok` as long as the process is alive and able to accept HTTP connections. Used by orchestrators (e.g. Kubernetes liveness probe) to restart hung or deadlocked processes. +- **Readiness probe (`GET /health/ready`):** Deep dependency check. Probes database reachability via `SELECT 1` and Horizon node reachability via `GET /`. Returns HTTP 200 with structured JSON (`{ "status": "ready", "database": "ok", "horizon": "ok" }`) when dependencies are operational. If any dependency is unreachable, returns HTTP 503 `Service Unavailable` with a structured payload naming the specific degraded dependency (`failed: ["database"]` or `failed: ["horizon"]`), allowing load balancers to safely remove the instance from traffic rotation without restarting the process.