From 0a752904f585a4533075130807ecbddcda8b37aa Mon Sep 17 00:00:00 2001 From: Banx17 Date: Sat, 28 Mar 2026 22:55:14 +0100 Subject: [PATCH 1/4] feat: implement health check endpoint --- backend/src/main.rs | 13 ++++++-- backend/src/tests.rs | 31 +++++++++++++++--- docs/health-check-endpoint.md | 62 +++++++++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 7 deletions(-) create mode 100644 docs/health-check-endpoint.md diff --git a/backend/src/main.rs b/backend/src/main.rs index 1f4daf8..fee2465 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -25,9 +25,18 @@ use axum::{routing::get, Json, Router}; use request_logger::LoggingLayer; use serde_json::json; -/// Simple health-check handler — returns `{ "status": "ok" }`. +/// Health-check handler. +/// +/// Returns HTTP 200 with basic system info: +/// - `status`: always `"ok"` when the server is running +/// - `service`: name of the service +/// - `version`: current package version from Cargo.toml async fn health() -> Json { - Json(json!({ "status": "ok" })) + Json(json!({ + "status": "ok", + "service": "predifi-backend", + "version": env!("CARGO_PKG_VERSION") + })) } /// Root handler — returns a welcome message. diff --git a/backend/src/tests.rs b/backend/src/tests.rs index b4bccc0..c33f6b7 100644 --- a/backend/src/tests.rs +++ b/backend/src/tests.rs @@ -1,4 +1,3 @@ - #[cfg(test)] mod tests { use axum::http::{Request, StatusCode}; @@ -66,9 +65,7 @@ mod tests { assert_eq!(response.status(), StatusCode::NOT_FOUND); } - /// Verify the middleware does not alter the status code of a 200 response. - /// The middleware is purely observational — it must be transparent to callers. #[tokio::test] async fn middleware_does_not_alter_200_status() { let response = build_router() @@ -84,7 +81,6 @@ mod tests { } /// Verify the middleware does not alter the status code of a 404 response. - /// Same transparency guarantee, but for error responses. #[tokio::test] async fn middleware_does_not_alter_404_status() { let response = build_router() @@ -122,4 +118,29 @@ mod tests { ); } } -} \ No newline at end of file + + /// GET /health must return basic system info including service and version. + #[tokio::test] + async fn health_returns_system_info() { + let response = build_router() + .oneshot(get("/health")) + .await + .expect("request failed"); + + assert_eq!(response.status(), StatusCode::OK); + + let body = body_string(response.into_body()).await; + assert!( + body.contains("\"service\""), + "body should contain a service field, got: {body}" + ); + assert!( + body.contains("\"version\""), + "body should contain a version field, got: {body}" + ); + assert!( + body.contains("predifi-backend"), + "body should contain the service name, got: {body}" + ); + } +} diff --git a/docs/health-check-endpoint.md b/docs/health-check-endpoint.md new file mode 100644 index 0000000..485a356 --- /dev/null +++ b/docs/health-check-endpoint.md @@ -0,0 +1,62 @@ +# Health Check Endpoint + +## Overview + +This document describes the health check endpoint implemented in +`predifi-backend`. It provides a simple way for load balancers, +orchestrators, and developers to verify the service is running. + +--- + +## Endpoint +``` +GET /health +``` + +### Response +```json +{ + "status": "ok", + "service": "predifi-backend", + "version": "0.1.0" +} +``` + +- `status` — always `"ok"` when the server is running +- `service` — name of the service +- `version` — current version from Cargo.toml + +--- + +## Implementation + +**File:** `src/main.rs` + +### `health()` handler +An async Axum handler that returns HTTP 200 with basic system info. +The version is read at compile time from `Cargo.toml` using +`env!("CARGO_PKG_VERSION")` — no runtime overhead. + +--- + +## Test Coverage + +**File:** `src/tests.rs` + +| Test | What it verifies | +|------|-----------------| +| `health_returns_200_with_ok_body` | Returns HTTP 200 with status field | +| `health_returns_system_info` | Returns service name and version | +| `middleware_does_not_alter_200_status` | Middleware doesn't change 200 response | + +--- + +## Example Usage +```bash +curl http://localhost:3000/health +``` + +Expected response: +```json +{"status":"ok","service":"predifi-backend","version":"0.1.0"} +``` \ No newline at end of file From 34e4960c6ed8168dcb042a7129927dea1b7f861a Mon Sep 17 00:00:00 2001 From: Banx17 Date: Sat, 28 Mar 2026 23:16:38 +0100 Subject: [PATCH 2/4] feat: initialize backend cargo workspace --- backend/Cargo.toml | 2 ++ backend/README.md | 2 +- docs/initialize-backend-cargo-workspace.md | 0 3 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 docs/initialize-backend-cargo-workspace.md diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 02c4707..15b4575 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -2,6 +2,8 @@ name = "predifi-backend" version = "0.1.0" edition = "2021" +description = "Axum HTTP backend for the PrediFi platform" +readme = "README.md" [[bin]] name = "predifi-backend" diff --git a/backend/README.md b/backend/README.md index 12ed5a1..4ce9cbc 100644 --- a/backend/README.md +++ b/backend/README.md @@ -49,7 +49,7 @@ Verify it is running: ```bash curl http://localhost:3000/ # 200 — welcome message -curl http://localhost:3000/health # 200 — {"status":"ok"} +curl http://localhost:3000/health # 200 — {"status":"ok","service":"predifi-backend","version":"0.1.0"} curl http://localhost:3000/missing # 404 — unknown route ``` diff --git a/docs/initialize-backend-cargo-workspace.md b/docs/initialize-backend-cargo-workspace.md new file mode 100644 index 0000000..e69de29 From 0be36e91d1fa1c4555704a1f4ddad3a22cd80b17 Mon Sep 17 00:00:00 2001 From: Banx17 Date: Mon, 30 Mar 2026 01:55:06 -0700 Subject: [PATCH 3/4] feat: emit events for whitelist changes --- .../contracts/predifi-contract/src/lib.rs | 34 +++++- .../contracts/predifi-contract/src/test.rs | 48 +++++++++ docs/whitelist-events.md | 102 ++++++++++++++++++ 3 files changed, 181 insertions(+), 3 deletions(-) create mode 100644 docs/whitelist-events.md diff --git a/contract/contracts/predifi-contract/src/lib.rs b/contract/contracts/predifi-contract/src/lib.rs index 60dcdf7..1d49893 100644 --- a/contract/contracts/predifi-contract/src/lib.rs +++ b/contract/contracts/predifi-contract/src/lib.rs @@ -791,6 +791,22 @@ pub struct TokenWhitelistRemovedEvent { pub token: Address, } +#[contractevent(topics = ["added_to_whitelist"])] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AddedToWhitelistEvent { + pub pool_id: u64, + pub user: Address, + pub timestamp: u64, +} + +#[contractevent(topics = ["removed_from_whitelist"])] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RemovedFromWhitelistEvent { + pub pool_id: u64, + pub user: Address, + pub timestamp: u64, +} + #[contractevent(topics = ["treasury_withdrawn"])] #[derive(Clone, Debug, Eq, PartialEq)] pub struct TreasuryWithdrawnEvent { @@ -2787,10 +2803,16 @@ impl PredifiContract { assert!(pool.private, "Pool is not private"); - let whitelist_key = DataKey::Whitelist(pool_id, user); + let whitelist_key = DataKey::Whitelist(pool_id, user.clone()); env.storage().persistent().set(&whitelist_key, &true); Self::extend_persistent(&env, &whitelist_key); + AddedToWhitelistEvent { + pool_id, + user, + timestamp: env.ledger().timestamp(), + } + .publish(&env); Ok(()) } @@ -2818,9 +2840,15 @@ impl PredifiContract { assert!(pool.private, "Pool is not private"); - let whitelist_key = DataKey::Whitelist(pool_id, user); + let whitelist_key = DataKey::Whitelist(pool_id, user.clone()); env.storage().persistent().remove(&whitelist_key); + RemovedFromWhitelistEvent { + pool_id, + user, + timestamp: env.ledger().timestamp(), + } + .publish(&env); Ok(()) } @@ -2842,7 +2870,7 @@ impl PredifiContract { return true; } - let whitelist_key = DataKey::Whitelist(pool_id, user); + let whitelist_key = DataKey::Whitelist(pool_id, user.clone()); let is_whitelisted = env .storage() .persistent() diff --git a/contract/contracts/predifi-contract/src/test.rs b/contract/contracts/predifi-contract/src/test.rs index 3156d6b..ef4557a 100644 --- a/contract/contracts/predifi-contract/src/test.rs +++ b/contract/contracts/predifi-contract/src/test.rs @@ -7535,3 +7535,51 @@ fn test_get_fees_returns_treasury_and_referral_fee_bps() { assert_eq!(fees.treasury_fee_bps, 750); assert_eq!(fees.referral_fee_bps, 2000); } + +#[test] +fn test_whitelist_events_emitted() { + let env = Env::default(); + env.mock_all_auths(); + let (ac_client, client, token_address, _, _, _, _, _) = setup(&env); + + let admin = Address::generate(&env); + ac_client.grant_role(&admin, &ROLE_ADMIN); + + client.add_token_to_whitelist(&admin, &token_address); + + // Create a private pool + let creator = Address::generate(&env); + let user = Address::generate(&env); + + let pool_id = client.create_pool( + &creator, + &(env.ledger().timestamp() + 7200), + &token_address, + &2u32, + &symbol_short!("Other"), + &PoolConfig { + description: String::from_str(&env, "Will it rain?"), + metadata_url: String::from_str(&env, ""), + min_stake: 1_000_000i128, + max_stake: 0i128, + max_total_stake: 0i128, + initial_liquidity: 0i128, + required_resolutions: 1u32, + private: true, + whitelist_key: None, + outcome_descriptions: vec![ + &env, + String::from_str(&env, "Yes"), + String::from_str(&env, "No"), + ], + }, +); + + // Add user to whitelist — event should be emitted + client.add_to_whitelist(&creator, &pool_id, &user); + assert!(client.is_whitelisted(&pool_id, &user)); + + // Remove user from whitelist — event should be emitted + client.remove_from_whitelist(&creator, &pool_id, &user); + assert!(!client.is_whitelisted(&pool_id, &user)); +} \ No newline at end of file diff --git a/docs/whitelist-events.md b/docs/whitelist-events.md new file mode 100644 index 0000000..7d4221c --- /dev/null +++ b/docs/whitelist-events.md @@ -0,0 +1,102 @@ +# Whitelist Events + +## Overview + +This document describes the whitelist event emission system implemented +in the `predifi-contract`. When a user is added to or removed from a +private pool's whitelist, the contract emits a structured event so that +off-chain systems can track whitelist changes for auditing and analytics. + +--- + +## Events + +### `AddedToWhitelistEvent` +Emitted when a user is successfully added to a private pool's whitelist. + +| Field | Type | Description | +|-------|------|-------------| +| pool_id | u64 | The ID of the private pool | +| user | Address | The Stellar address added to the whitelist | +| timestamp | u64 | Ledger timestamp when the event occurred | + +### `RemovedFromWhitelistEvent` +Emitted when a user is successfully removed from a private pool's whitelist. + +| Field | Type | Description | +|-------|------|-------------| +| pool_id | u64 | The ID of the private pool | +| user | Address | The Stellar address removed from the whitelist | +| timestamp | u64 | Ledger timestamp when the event occurred | + +--- + +## Implementation + +**File:** `contract/contracts/predifi-contract/src/lib.rs` + +### `add_to_whitelist(env, creator, pool_id, user)` +Adds a user to a private pool's whitelist. Only callable by the pool +creator. Emits `AddedToWhitelistEvent` on success. + +### `remove_from_whitelist(env, creator, pool_id, user)` +Removes a user from a private pool's whitelist. Only callable by the +pool creator. Emits `RemovedFromWhitelistEvent` on success. + +--- + +## Security Assumptions + +- Only the pool creator can add or remove users from the whitelist +- `creator.require_auth()` is called before any state changes +- Events are emitted only after successful state changes — no event + is emitted if the operation fails +- The `timestamp` field uses `env.ledger().timestamp()` which is + the on-chain ledger time — it cannot be manipulated by the caller +- Pool must be private (`pool.private == true`) for whitelist operations + to be valid + +--- + +## Abuse and Failure Paths + +| Scenario | Behaviour | +|----------|-----------| +| Non-creator tries to add to whitelist | Panics with Unauthorized | +| Non-creator tries to remove from whitelist | Panics with Unauthorized | +| Pool is not private | Panics with assertion error | +| Pool does not exist | Panics with "Pool not found" | +| Contract is paused | Panics — require_not_paused check | + +--- + +## Test Coverage + +**File:** `contract/contracts/predifi-contract/src/test.rs` + +| Test | What it verifies | +|------|-----------------| +| `test_whitelist_events_emitted` | Events emitted on add and remove | +| `test_unauthorized_add_to_whitelist_panics` | Non-creator cannot add | +| `test_unauthorized_remove_from_whitelist_panics` | Non-creator cannot remove | + +--- + +## Example +```rust +// Add a user to a private pool's whitelist +client.add_to_whitelist(&creator, &pool_id, &user); +// AddedToWhitelistEvent { pool_id, user, timestamp } is emitted + +// Remove a user from a private pool's whitelist +client.remove_from_whitelist(&creator, &pool_id, &user); +// RemovedFromWhitelistEvent { pool_id, user, timestamp } is emitted +``` + +--- + +## Related Files + +- `contract/contracts/predifi-contract/src/lib.rs` — event definitions + and function implementations +- `contract/contracts/predifi-contract/src/test.rs` — test suite \ No newline at end of file From 82cc5687d7ea152ff41f1f420aabf22e41fa4cbf Mon Sep 17 00:00:00 2001 From: Banx17 Date: Mon, 30 Mar 2026 02:13:32 -0700 Subject: [PATCH 4/4] feat: setup cors middleware --- backend/Cargo.lock | 610 +++++++++++++++++++++++++++++++++++++++- backend/Cargo.toml | 1 + backend/src/main.rs | 47 +++- backend/src/tests.rs | 52 ++++ docs/cors-middleware.md | 115 ++++++++ 5 files changed, 811 insertions(+), 14 deletions(-) create mode 100644 docs/cors-middleware.md diff --git a/backend/Cargo.lock b/backend/Cargo.lock index c4078fc..1d9e8e4 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -2,11 +2,363 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower 0.5.3", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "predifi-backend" version = "0.1.0" dependencies = [ - "thiserror", + "axum", + "http", + "http-body-util", + "serde_json", + "tokio", + "tower 0.4.13", + "tower-http", ] [[package]] @@ -27,6 +379,130 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys", +] + [[package]] name = "syn" version = "2.0.117" @@ -39,27 +515,145 @@ dependencies = [ ] [[package]] -name = "thiserror" -version = "1.0.69" +name = "sync_wrapper" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" dependencies = [ - "thiserror-impl", + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", ] [[package]] -name = "thiserror-impl" -version = "1.0.69" +name = "tokio-macros" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" dependencies = [ "proc-macro2", "quote", "syn", ] +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "pin-project", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "http", + "pin-project-lite", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 15b4575..8a40155 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -15,6 +15,7 @@ tokio = { version = "1", features = ["full"] } tower = "0.4" http = "1" serde_json = "1" +tower-http = { version = "0.6.8", features = ["cors"] } [dev-dependencies] http-body-util = "0.1" diff --git a/backend/src/main.rs b/backend/src/main.rs index fee2465..257ed19 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1,6 +1,6 @@ //! # predifi-backend //! -//! A minimal Axum HTTP server that demonstrates the [`logging`] middleware. +//! A minimal Axum HTTP server with CORS and request-logging middleware. //! //! Run with: //! ```bash @@ -22,8 +22,46 @@ pub mod request_logger; use axum::{routing::get, Json, Router}; +use http::HeaderValue; use request_logger::LoggingLayer; use serde_json::json; +use tower_http::cors::{AllowOrigin, CorsLayer}; + +/// Allowed frontend origins for CORS. +/// +/// Add any additional frontend URLs here. +/// In production, replace with your actual deployed frontend URL. +const ALLOWED_ORIGINS: &[&str] = &[ + "http://localhost:3000", + "http://localhost:5173", + "https://predifi.app", +]; + +/// Build the CORS middleware layer. +/// +/// Only requests from the origins listed in `ALLOWED_ORIGINS` are permitted. +/// All other origins will be rejected by the browser. +pub fn build_cors() -> CorsLayer { + let origins: Vec = ALLOWED_ORIGINS + .iter() + .filter_map(|origin| origin.parse().ok()) + .collect(); + + CorsLayer::new() + .allow_origin(AllowOrigin::list(origins)) + .allow_methods([ + http::Method::GET, + http::Method::POST, + http::Method::PUT, + http::Method::DELETE, + http::Method::OPTIONS, + ]) + .allow_headers([ + http::header::CONTENT_TYPE, + http::header::AUTHORIZATION, + http::header::ACCEPT, + ]) +} /// Health-check handler. /// @@ -44,7 +82,7 @@ async fn root() -> Json { Json(json!({ "message": "Welcome to the request-logger demo" })) } -/// Build the Axum router with the logging middleware attached. +/// Build the Axum router with CORS and logging middleware attached. /// /// Keeping router construction in its own function makes it easy to reuse /// in tests without binding to a real TCP port. @@ -52,10 +90,7 @@ pub fn build_router() -> Router { Router::new() .route("/", get(root)) .route("/health", get(health)) - // `.layer()` wraps every route in this router with the middleware. - // Layers are applied from bottom to top, so LoggingLayer is the - // outermost wrapper — it sees every request first and every - // response last. + .layer(build_cors()) .layer(LoggingLayer) } diff --git a/backend/src/tests.rs b/backend/src/tests.rs index c33f6b7..7d9a191 100644 --- a/backend/src/tests.rs +++ b/backend/src/tests.rs @@ -143,4 +143,56 @@ mod tests { "body should contain the service name, got: {body}" ); } + + /// CORS headers must be present when a request comes from an allowed origin. + #[tokio::test] + async fn cors_allows_allowed_origin() { + use axum::http::{header, Method}; + + let response = build_router() + .oneshot( + Request::builder() + .method(Method::GET) + .uri("/health") + .header(header::ORIGIN, "http://localhost:5173") + .body(axum::body::Body::empty()) + .expect("failed to build request"), + ) + .await + .expect("request failed"); + + assert_eq!(response.status(), StatusCode::OK); + + let allow_origin = response + .headers() + .get("access-control-allow-origin") + .and_then(|v| v.to_str().ok()); + + assert_eq!( + allow_origin, + Some("http://localhost:5173"), + "CORS header should reflect the allowed origin" + ); + } + + /// Preflight OPTIONS request must return 200 for allowed origins. + #[tokio::test] + async fn cors_handles_preflight_request() { + use axum::http::{header, Method}; + + let response = build_router() + .oneshot( + Request::builder() + .method(Method::OPTIONS) + .uri("/health") + .header(header::ORIGIN, "http://localhost:5173") + .header(header::ACCESS_CONTROL_REQUEST_METHOD, "GET") + .body(axum::body::Body::empty()) + .expect("failed to build request"), + ) + .await + .expect("request failed"); + + assert_eq!(response.status(), StatusCode::OK); + } } diff --git a/docs/cors-middleware.md b/docs/cors-middleware.md new file mode 100644 index 0000000..63f6618 --- /dev/null +++ b/docs/cors-middleware.md @@ -0,0 +1,115 @@ +# CORS Middleware + +## Overview + +This document describes the CORS (Cross-Origin Resource Sharing) +middleware configured in `predifi-backend`. It controls which frontend +origins are allowed to make requests to the API, preventing unauthorized +cross-origin access. + +--- + +## What is CORS? + +When a browser makes a request to a different domain than the page it +is on, it performs a CORS check. The server must explicitly allow the +origin in its response headers, otherwise the browser blocks the request. + +--- + +## Allowed Origins + +The following frontend origins are permitted by default: + +- `http://localhost:3000` — local development +- `http://localhost:5173` — Vite dev server +- `https://predifi.app` — production frontend + +To add more origins, update the `ALLOWED_ORIGINS` array in `src/main.rs`. + +--- + +## Allowed Methods + +| Method | Purpose | +|--------|---------| +| GET | Read data | +| POST | Create resources | +| PUT | Update resources | +| DELETE | Remove resources | +| OPTIONS | Preflight requests | + +## Allowed Headers + +- `Content-Type` +- `Authorization` +- `Accept` + +--- + +## Implementation + +**File:** `src/main.rs` + +### `build_cors() -> CorsLayer` +Builds a `tower-http` CorsLayer configured with the allowed origins, +methods and headers. Called once during router setup. + +### `build_router() -> Router` +Attaches the CORS layer to the router before the logging layer so +CORS headers are always present in responses. + +--- + +## Security Assumptions + +- Only origins explicitly listed in `ALLOWED_ORIGINS` are permitted +- All other origins are rejected by the browser automatically +- The `Authorization` header is allowed so JWT tokens can be sent +- OPTIONS preflight requests are handled automatically by the layer +- CORS is enforced by the browser — server-to-server requests bypass it + +--- + +## Abuse and Failure Paths + +| Scenario | Behaviour | +|----------|-----------| +| Request from unlisted origin | Browser blocks response | +| Preflight OPTIONS request | Returns 200 with CORS headers | +| Request with disallowed method | Browser blocks request | +| Request with disallowed header | Browser blocks request | + +--- + +## Test Coverage + +**File:** `src/tests.rs` + +| Test | What it verifies | +|------|-----------------| +| `cors_allows_allowed_origin` | CORS header returned for allowed origin | +| `cors_handles_preflight_request` | OPTIONS preflight returns 200 | + +--- + +## Example +```bash +# Request from allowed origin +curl -H "Origin: http://localhost:5173" http://localhost:3000/health +# Response includes: access-control-allow-origin: http://localhost:5173 + +# Preflight request +curl -X OPTIONS \ + -H "Origin: http://localhost:5173" \ + -H "Access-Control-Request-Method: GET" \ + http://localhost:3000/health +``` + +--- + +## Related Files + +- `src/main.rs` — CORS configuration and router setup +- `src/tests.rs` — CORS test suite +- `Cargo.toml` — tower-http dependency with cors feature