From 957d8370e4e24c57ebdd1eee4510801af1aa4d2c Mon Sep 17 00:00:00 2001 From: ggrunlab-pixel Date: Sun, 27 Sep 2026 11:59:00 +0000 Subject: [PATCH] feat: API versioning, rate limit keying + tiering, site.rs docs - Document site.rs purpose and its responsibility split from routes.rs - Add /v1-style version prefix to API routes - Extend rate limiting beyond pure IP-based keying - Add distinct rate limit tiers for submit vs verify endpoints Closes #1360 Closes #1359 Closes #1355 Closes #1356 --- contract/.env.example | 8 +- contract/README.md | 42 ++++++---- contract/src/config.rs | 60 +++++++++++++- contract/src/health.rs | 1 + contract/src/lib.rs | 68 +++------------ contract/src/main.rs | 8 +- contract/src/rate_limit.rs | 13 ++- contract/src/routes.rs | 91 +++++++++++++++++---- contract/src/site.rs | 7 ++ contract/src/tests/integration.rs | 1 + contract/tests/handler_integration_tests.rs | 1 + contract/tests/hash_validation.rs | 1 + contract/tests/rate_limit.rs | 33 ++++++-- contract/tests/revoke.rs | 1 + docker-compose.yml | 4 + docs/contract-deployment.md | 6 +- 16 files changed, 236 insertions(+), 109 deletions(-) diff --git a/contract/.env.example b/contract/.env.example index 82b02412..29b66a89 100644 --- a/contract/.env.example +++ b/contract/.env.example @@ -22,12 +22,16 @@ STELLAR_SECRET_KEY= # Redis connection URL. [default: redis://127.0.0.1:6379] REDIS_URL=redis://127.0.0.1:6379 -# Sustained rate limit (requests per second). [default: 10] +# Sustained verification/other-route quota (requests per second). [default: 10] RATE_LIMIT_PER_SECOND=10 -# Burst rate limit (concurrent requests allowed). [default: same as RATE_LIMIT_PER_SECOND] +# Burst quota for verification/other routes. [default: 10] RATE_LIMIT_BURST=10 +# Lower, independent quota for Stellar document submissions. +SUBMIT_RATE_LIMIT_PER_SECOND=1 +SUBMIT_RATE_LIMIT_BURST=2 + # Maximum number of retries for Stellar Horizon requests. [default: 3] STELLAR_MAX_RETRIES=3 diff --git a/contract/README.md b/contract/README.md index 96b49d8d..f8e8fb80 100644 --- a/contract/README.md +++ b/contract/README.md @@ -62,8 +62,10 @@ smart contract. All request/response bodies are JSON. There is currently **no authentication or authorization middleware** on any route: every endpoint -below is open to any caller who can reach the service. There is also no -rate-limiting middleware currently active (see [Known issues](#known-issues)). +below is open to any caller who can reach the service. Rate limits are active +but process-wide rather than per-client; see [Known issues](#known-issues). +The versioned API is mounted at `/v1`. The unprefixed paths shown below remain +available as compatibility aliases; new clients should use the `/v1` prefix. ### `GET /health` @@ -269,18 +271,26 @@ Redis (keyed as `transfer:`, retained ~10 years). | `500` | Failed to derive the anchor account, read/write transfer history in cache | | `502` | Horizon rejected or failed to submit the transfer transaction (surfaced as `500`, not `502`, in the current handler: see [Known issues](#known-issues)) | +### `GET /transfer/:document_hash` + +Returns the cached transfer records for a document as a JSON array. Returns +an empty array when there is no recorded transfer history. The versioned path +is `/v1/transfer/:document_hash`. + ## Module layout | Module | Responsibility | |---|---| | `main.rs` | Binary entry point: loads config, initializes tracing, constructs `AppState`, starts the Axum server. | -| `lib.rs` | `AppState`, all HTTP request/response types, the `app()` router, and every route handler. | +| `lib.rs` | `AppState`, HTTP request/response types, middleware, and route handlers. | +| `routes.rs` | Maps versioned and compatibility URL paths to handlers and composes request-ID, tracing, and rate-limit middleware. | | `config.rs` | `AppConfig::from_env()`: reads and validates all environment variables into a single typed config, collecting *all* validation errors before failing rather than stopping at the first one. | | `stellar.rs` | `StellarClient`: all Horizon HTTP interaction: fetching account state, building/signing/submitting `ManageData` transactions for anchoring, revoking, and transferring, and reading operation history. Also owns the `ManageData` key-naming scheme (`doc_`, `trf_`, `revoked_` prefixes). | | `cache.rs` | `CacheBackend`: a small abstraction over Redis (`RedisCache`) or an in-process `HashMap` (`InMemoryCache`), used for verification results and transfer/verification history. | | `hash_validator.rs` | `HashValidator`: normalizes and validates hex-encoded SHA-256/SHA-512 hashes, with structured validation errors. | | `metrics.rs` | `MetricsRegistry`: Prometheus counters (requests, cache hits/misses, errors) and text-format rendering for `/metrics`. | -| `rate_limit.rs` | A `governor`-based rate limiter builder. **Not currently wired into `app()`**: see [Known issues](#known-issues). | +| `rate_limit.rs` | Builds the in-memory request quotas used by `routes.rs`. | +| `site.rs` | Unused legacy metrics registry; the active metrics implementation is `metrics.rs`. | ## Known issues @@ -288,20 +298,20 @@ Documenting these here rather than silently working around them, since a new contributor hitting them would otherwise reasonably assume they're missing something: -- **`rate_limit.rs` is dead code.** `build_rate_limiter()` is never called - from `main.rs` or `app()`. `AppConfig` still parses - `RATE_LIMIT_PER_SECOND` / `RATE_LIMIT_BURST`, but neither value is - currently enforced anywhere. +- **Rate limits are not per-client.** Verification/other routes share a + process-wide quota and submissions have a separate, lower process-wide + quota. The service has no authenticated caller identity, so it cannot + enforce a robust per-account limit; caller-supplied fields and forwarded-IP + headers are not trustworthy substitutes. Add authentication before relying + on per-client quotas, and configure upstream abuse protection as needed. - **`event.rs` is not part of the compiled crate.** `lib.rs` does not declare `pub mod event;`, and the file references `crate::error::Result` / `crate::error::AuditError`, which don't exist in this crate. It appears to be leftover/orphaned code from a different module structure. - **`transfer_document` and `get_transfer_history` in `lib.rs` are unused.** - The live `app()` router wires `/transfer` to `record_transfer`, not to - `transfer_document` (which always returns "not yet implemented"). There - is no live route for reading transfer history back out: only - `record_transfer`'s write path and `verify_document_history`'s (separate) - read path are actually routed. + The router wires `/transfer` to `record_transfer`; the `transfer_document` + handler remains unimplemented. Transfer-history reads use the separate + implementation in `handlers/transfer.rs`. - **No authentication.** No route requires any credential today, despite `submit`/`revoke`/`transfer` all being state-changing, chain-writing operations. @@ -369,8 +379,10 @@ docker run -p 6379:6379 redis:7 | `STELLAR_HORIZON_URL` | no | `https://horizon-testnet.stellar.org` | Must be a valid URL. | | `STELLAR_SECRET_KEY` | **yes** | none | 56-character Stellar secret seed, starts with `S`. The single account all `ManageData` anchors are written to. | | `REDIS_URL` | no | `redis://127.0.0.1:6379` | | -| `RATE_LIMIT_PER_SECOND` | no | `10` | Parsed and validated but not currently enforced: see [Known issues](#known-issues). | -| `RATE_LIMIT_BURST` | no | same as `RATE_LIMIT_PER_SECOND` | Same caveat. | +| `RATE_LIMIT_PER_SECOND` | no | `10` | Verification and other non-submit routes; process-wide per instance. | +| `RATE_LIMIT_BURST` | no | same as `RATE_LIMIT_PER_SECOND` | Non-zero burst capacity for verification and other routes. | +| `SUBMIT_RATE_LIMIT_PER_SECOND` | no | `1` | Separate, lower process-wide quota for document submissions. | +| `SUBMIT_RATE_LIMIT_BURST` | no | `2` | Non-zero burst capacity for document submissions. | | `STELLAR_MAX_RETRIES` | no | `3` | | | `LOG_LEVEL` | no | `info` | Used as the default `tracing` filter if `RUST_LOG` isn't set. | | `WEBHOOK_URLS` | no | (empty) | Comma-separated. Parsed but currently unused. | diff --git a/contract/src/config.rs b/contract/src/config.rs index 51d09806..475ad207 100644 --- a/contract/src/config.rs +++ b/contract/src/config.rs @@ -30,6 +30,8 @@ pub struct AppConfig { pub redis_url: String, pub rate_limit_per_second: u32, pub rate_limit_burst: u32, + pub submit_rate_limit_per_second: u32, + pub submit_rate_limit_burst: u32, pub stellar_max_retries: u32, pub log_level: String, pub environment: Environment, @@ -171,6 +173,20 @@ impl AppConfig { &mut errors, &placeholders, ); + let submit_rate_limit_per_second_raw = get_env_or_default( + "SUBMIT_RATE_LIMIT_PER_SECOND", + "1", + is_production, + &mut errors, + &placeholders, + ); + let submit_rate_limit_burst_raw = get_env_or_default( + "SUBMIT_RATE_LIMIT_BURST", + "2", + is_production, + &mut errors, + &placeholders, + ); let stellar_max_retries_raw = get_env_or_default( "STELLAR_MAX_RETRIES", "3", @@ -235,7 +251,11 @@ impl AppConfig { }; let rate_limit_burst: u32 = match rate_limit_burst_raw.parse() { - Ok(v) => v, + Ok(v) if v > 0 => v, + Ok(_) => { + errors.push("RATE_LIMIT_BURST must be greater than 0".to_string()); + rate_limit_per_second + } Err(_) => { errors.push(format!( "RATE_LIMIT_BURST must be a valid u32, got '{}'", @@ -245,6 +265,36 @@ impl AppConfig { } }; + let submit_rate_limit_per_second: u32 = match submit_rate_limit_per_second_raw.parse() { + Ok(v) if v > 0 => v, + Ok(_) => { + errors.push("SUBMIT_RATE_LIMIT_PER_SECOND must be greater than 0".to_string()); + 1 + } + Err(_) => { + errors.push(format!( + "SUBMIT_RATE_LIMIT_PER_SECOND must be a valid u32, got '{}'", + submit_rate_limit_per_second_raw + )); + 1 + } + }; + + let submit_rate_limit_burst: u32 = match submit_rate_limit_burst_raw.parse() { + Ok(v) if v > 0 => v, + Ok(_) => { + errors.push("SUBMIT_RATE_LIMIT_BURST must be greater than 0".to_string()); + 2 + } + Err(_) => { + errors.push(format!( + "SUBMIT_RATE_LIMIT_BURST must be a valid u32, got '{}'", + submit_rate_limit_burst_raw + )); + 2 + } + }; + let stellar_max_retries: u32 = match stellar_max_retries_raw.parse() { Ok(v) => v, Err(_) => { @@ -301,6 +351,8 @@ impl AppConfig { redis_url, rate_limit_per_second, rate_limit_burst, + submit_rate_limit_per_second, + submit_rate_limit_burst, stellar_max_retries, log_level, environment, @@ -328,6 +380,8 @@ mod tests { "REDIS_URL", "RATE_LIMIT_PER_SECOND", "RATE_LIMIT_BURST", + "SUBMIT_RATE_LIMIT_PER_SECOND", + "SUBMIT_RATE_LIMIT_BURST", "STELLAR_MAX_RETRIES", "LOG_LEVEL", "APP_ENV", @@ -358,6 +412,8 @@ mod tests { ); assert_eq!(cfg.redis_url, "redis://127.0.0.1:6379"); assert_eq!(cfg.rate_limit_per_second, 10); + assert_eq!(cfg.submit_rate_limit_per_second, 1); + assert_eq!(cfg.submit_rate_limit_burst, 2); assert_eq!(cfg.cache_verification_ttl, 3600); assert_eq!(cfg.shutdown_timeout_secs, 30); } @@ -369,6 +425,7 @@ mod tests { env::set_var("PORT", "0"); env::set_var("STELLAR_HORIZON_URL", "not-a-url"); env::set_var("RATE_LIMIT_PER_SECOND", "0"); + env::set_var("SUBMIT_RATE_LIMIT_BURST", "0"); let err = AppConfig::from_env().expect_err("config should fail"); let msg = err.to_string(); @@ -376,6 +433,7 @@ mod tests { assert!(msg.contains("PORT must be between 1 and 65535")); assert!(msg.contains("STELLAR_HORIZON_URL must be a valid URL")); assert!(msg.contains("RATE_LIMIT_PER_SECOND must be greater than 0")); + assert!(msg.contains("SUBMIT_RATE_LIMIT_BURST must be greater than 0")); } #[test] diff --git a/contract/src/health.rs b/contract/src/health.rs index b7246f9d..bd4d89eb 100644 --- a/contract/src/health.rs +++ b/contract/src/health.rs @@ -180,6 +180,7 @@ mod tests { metrics: Arc::new(MetricsRegistry::new()), stellar_secret_key: String::new(), rate_limiter: build_rate_limiter(1000, 1000), + submit_rate_limiter: build_rate_limiter(1000, 1000), webhook_urls: Vec::new(), webhook_secret: None, } diff --git a/contract/src/lib.rs b/contract/src/lib.rs index d0a1f87d..2970ec25 100644 --- a/contract/src/lib.rs +++ b/contract/src/lib.rs @@ -16,10 +16,9 @@ pub mod webhook; use axum::{ body::Body, extract::{Path, State}, - http::{HeaderName, Request, StatusCode}, + http::{Request, StatusCode}, middleware::Next, response::{IntoResponse, Response}, - routing::{get, post}, Json, Router, }; use chrono::{NaiveDate, Utc}; @@ -28,9 +27,6 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::sync::Arc; -use tower::ServiceBuilder; -use tower_http::request_id::{MakeRequestUuid, PropagateRequestIdLayer, SetRequestIdLayer}; -use tower_http::trace::TraceLayer; use tracing::{info, warn}; use cache::CacheBackend; @@ -53,7 +49,13 @@ async fn enforce_rate_limit( req: Request, next: Next, ) -> Response { - if state.rate_limiter.check().is_err() { + let path = req.uri().path(); + let limiter = if path == "/submit" || path == "/v1/submit" { + &state.submit_rate_limiter + } else { + &state.rate_limiter + }; + if limiter.check().is_err() { return ( StatusCode::TOO_MANY_REQUESTS, Json(ValidationErrorResponse { @@ -72,9 +74,10 @@ pub struct AppState { pub cache: Arc, pub metrics: Arc, pub stellar_secret_key: String, - /// Governor-based rate limiter built from `RATE_LIMIT_PER_SECOND` / - /// `RATE_LIMIT_BURST` and enforced as a router middleware (CT-37). + /// Process-wide limiter for verification and other non-submit routes. pub rate_limiter: rate_limit::DefaultRateLimiter, + /// Lower independent process-wide quota for document submissions. + pub submit_rate_limiter: rate_limit::DefaultRateLimiter, /// Comma-separated webhook URLs parsed from `WEBHOOK_URLS` (CT-38). pub webhook_urls: Vec, /// Shared secret used to sign webhook payloads (CT-38). @@ -270,54 +273,7 @@ pub async fn audit_log_handler(State(state): State) -> Response { } pub fn app(state: AppState) -> Router { - let request_id_header = HeaderName::from_static(REQUEST_ID_HEADER); - let span_header = request_id_header.clone(); - - Router::new() - .route("/health", get(health_check)) - .route("/metrics", get(metrics_handler)) - .route("/audit", get(audit_log_handler)) - .route("/verify", post(verify_document)) - .route("/verify/batch", post(batch_verify_documents)) - .route("/verify/:hash", get(verify_document_by_hash)) - .route("/verify/:hash/history", get(verify_document_history)) - .route("/submit", post(submit_document)) - .route("/revoke", post(revoke_document)) - .route("/transfer", post(record_transfer)) - .layer( - ServiceBuilder::new() - // Honour an inbound X-Request-Id (propagated from the NestJS - // backend, per BE-136); generate one only if it's absent. - .layer(SetRequestIdLayer::new( - request_id_header.clone(), - MakeRequestUuid, - )) - .layer( - TraceLayer::new_for_http().make_span_with(move |request: &Request<_>| { - let request_id = request - .headers() - .get(&span_header) - .and_then(|v| v.to_str().ok()) - .unwrap_or("unknown"); - - tracing::info_span!( - "http_request", - method = %request.method(), - path = %request.uri().path(), - request_id = %request_id, - ) - }), - ) - // Echo the request id back on the response so callers (and - // the backend) can confirm which id was used for this op. - .layer(PropagateRequestIdLayer::new(request_id_header)), - ) - // Apply the configured rate limiter to the whole router (CT-37). - .layer(axum::middleware::from_fn_with_state( - state.clone(), - enforce_rate_limit, - )) - .with_state(state) + routes::app(state) } // Health check endpoint diff --git a/contract/src/main.rs b/contract/src/main.rs index 671e435f..979242e6 100644 --- a/contract/src/main.rs +++ b/contract/src/main.rs @@ -51,12 +51,14 @@ async fn main() -> Result<(), Box> { // Startup configuration summary (redacting secrets) info!( - "Configuration: port={}, stellar_horizon_url={}, redis_url={}, rate_limit_per_second={}, rate_limit_burst={}, stellar_max_retries={}, log_level={}, webhook_urls={:?}, stellar_secret_key=[REDACTED], webhook_secret=[REDACTED], cache_verification_ttl={}, shutdown_timeout_secs={}", + "Configuration: port={}, stellar_horizon_url={}, redis_url={}, rate_limit_per_second={}, rate_limit_burst={}, submit_rate_limit_per_second={}, submit_rate_limit_burst={}, stellar_max_retries={}, log_level={}, webhook_urls={:?}, stellar_secret_key=[REDACTED], webhook_secret=[REDACTED], cache_verification_ttl={}, shutdown_timeout_secs={}", config.port, config.stellar_horizon_url, config.redis_url, config.rate_limit_per_second, config.rate_limit_burst, + config.submit_rate_limit_per_second, + config.submit_rate_limit_burst, config.stellar_max_retries, config.log_level, config.webhook_urls, @@ -80,6 +82,10 @@ async fn main() -> Result<(), Box> { config.rate_limit_per_second, config.rate_limit_burst, ), + submit_rate_limiter: build_rate_limiter( + config.submit_rate_limit_per_second, + config.submit_rate_limit_burst, + ), webhook_urls: config.webhook_urls.clone(), webhook_secret: config.webhook_secret.clone(), }; diff --git a/contract/src/rate_limit.rs b/contract/src/rate_limit.rs index f7147138..7ec5b3f2 100644 --- a/contract/src/rate_limit.rs +++ b/contract/src/rate_limit.rs @@ -1,10 +1,9 @@ //! A `governor`-based rate limiter builder. //! -//! **Not currently wired into the service.** [`build_rate_limiter`] is -//! defined but never called from `main.rs` or the Axum router - see the -//! "Known issues" section of `contract/README.md`. `RATE_LIMIT_PER_SECOND` -//! / `RATE_LIMIT_BURST` are parsed by [`crate::config::AppConfig`] but not -//! currently enforced anywhere. +//! Builds the independent process-wide quotas used by the Axum router. +//! Verification and other routes use `RATE_LIMIT_*`; `/submit` uses the +//! lower `SUBMIT_RATE_LIMIT_*` quota. These are global per service instance, +//! not per-client limits; the API currently has no authenticated client ID. use governor::{Quota, RateLimiter}; use std::num::NonZeroU32; @@ -15,8 +14,8 @@ pub type DefaultRateLimiter = RateLimiter< governor::clock::DefaultClock, >; -/// Build an in-memory, non-keyed rate limiter allowing `per_second` -/// sustained requests with a burst capacity of `burst`. +/// Build an in-memory rate limiter allowing `per_second` sustained requests +/// with a burst capacity of `burst`. /// /// Panics if either argument is zero (`NonZeroU32::new(...).unwrap()`). pub fn build_rate_limiter(per_second: u32, burst: u32) -> DefaultRateLimiter { diff --git a/contract/src/routes.rs b/contract/src/routes.rs index 69d2f829..8451a033 100644 --- a/contract/src/routes.rs +++ b/contract/src/routes.rs @@ -1,30 +1,87 @@ +//! HTTP route composition for the verifier service. +//! +//! This module maps URL paths and HTTP methods to handlers. Handler behavior, +//! shared state, and service-level metrics are defined in `lib.rs` and +//! `metrics.rs`; `site.rs` is an unused legacy metrics registry. +//! `/v1` is the canonical API prefix; unprefixed routes remain compatibility +//! aliases for existing clients. + use axum::{ routing::{get, post}, Router, }; +use tower::ServiceBuilder; +use tower_http::request_id::{MakeRequestUuid, PropagateRequestIdLayer, SetRequestIdLayer}; use tower_http::trace::TraceLayer; -use crate::handlers::{health, revoke, submit, transfer, verify}; -use crate::types::AppState; +use crate::{ + audit_log_handler, batch_verify_documents, enforce_rate_limit, health_check, metrics_handler, + record_transfer, revoke_document, submit_document, verify_document, verify_document_by_hash, + verify_document_history, AppState, REQUEST_ID_HEADER, +}; +use crate::handlers::transfer::get_transfer_history; pub fn app(state: AppState) -> Router { + let request_id_header = axum::http::HeaderName::from_static(REQUEST_ID_HEADER); + let span_header = request_id_header.clone(); + Router::new() - .route("/health", get(health::health_check)) - .route("/metrics", get(health::metrics_handler)) - .route("/verify", post(verify::verify_document)) - .route("/verify/batch", post(verify::batch_verify_documents)) - .route("/verify/:hash", get(verify::verify_document_by_hash)) - .route( - "/verify/:hash/history", - get(verify::verify_document_history), + .nest( + "/v1", + Router::new() + .route("/health", get(health_check)) + .route("/metrics", get(metrics_handler)) + .route("/audit", get(audit_log_handler)) + .route("/verify", post(verify_document)) + .route("/verify/batch", post(batch_verify_documents)) + .route("/verify/:hash", get(verify_document_by_hash)) + .route("/verify/:hash/history", get(verify_document_history)) + .route("/submit", post(submit_document)) + .route("/revoke", post(revoke_document)) + .route("/transfer", post(record_transfer)) + .route("/transfer/:document_hash", get(get_transfer_history)), ) - .route("/submit", post(submit::submit_document)) - .route("/revoke", post(revoke::revoke_document)) - .route("/transfer", post(transfer::record_transfer)) - .route( - "/transfer/:document_hash", - get(transfer::get_transfer_history), + .route("/health", get(health_check)) + .route("/metrics", get(metrics_handler)) + .route("/audit", get(audit_log_handler)) + .route("/verify", post(verify_document)) + .route("/verify/batch", post(batch_verify_documents)) + .route("/verify/:hash", get(verify_document_by_hash)) + .route("/verify/:hash/history", get(verify_document_history)) + .route("/submit", post(submit_document)) + .route("/revoke", post(revoke_document)) + .route("/transfer", post(record_transfer)) + .route("/transfer/:document_hash", get(get_transfer_history)) + .layer( + ServiceBuilder::new() + // Honor an inbound X-Request-Id; generate one only when absent. + .layer(SetRequestIdLayer::new( + request_id_header.clone(), + MakeRequestUuid, + )) + .layer( + TraceLayer::new_for_http().make_span_with( + move |request: &axum::http::Request<_>| { + let request_id = request + .headers() + .get(&span_header) + .and_then(|value| value.to_str().ok()) + .unwrap_or("unknown"); + + tracing::info_span!( + "http_request", + method = %request.method(), + path = %request.uri().path(), + request_id = %request_id, + ) + }, + ), + ) + .layer(PropagateRequestIdLayer::new(request_id_header)), ) - .layer(TraceLayer::new_for_http()) + .layer(axum::middleware::from_fn_with_state( + state.clone(), + enforce_rate_limit, + )) .with_state(state) } diff --git a/contract/src/site.rs b/contract/src/site.rs index f9c09c29..729961c4 100644 --- a/contract/src/site.rs +++ b/contract/src/site.rs @@ -1,3 +1,10 @@ +//! Legacy site-level metrics registry, retained for historical context. +//! +//! This module is not declared or used by the service. Its intended role was +//! aggregating service-wide Prometheus metrics, distinct from `routes.rs`, +//! which maps HTTP paths to handlers. The active metrics registry is +//! `metrics.rs`. + use prometheus::{opts, Counter, Encoder, Gauge, IntCounterVec, Registry, TextEncoder}; #[derive(Clone)] diff --git a/contract/src/tests/integration.rs b/contract/src/tests/integration.rs index 58f6d6c0..e7a8d514 100644 --- a/contract/src/tests/integration.rs +++ b/contract/src/tests/integration.rs @@ -51,6 +51,7 @@ fn make_state(horizon_url: &str) -> AppState { // Generous enough that the rate limiter never interferes with these // functional/integration tests (some exercise batch/concurrent calls). rate_limiter: build_rate_limiter(10_000, 10_000), + submit_rate_limiter: build_rate_limiter(10_000, 10_000), webhook_urls: Vec::new(), webhook_secret: None, } diff --git a/contract/tests/handler_integration_tests.rs b/contract/tests/handler_integration_tests.rs index 9dee523a..944378f5 100644 --- a/contract/tests/handler_integration_tests.rs +++ b/contract/tests/handler_integration_tests.rs @@ -24,6 +24,7 @@ fn test_app_state() -> AppState { metrics, stellar_secret_key: SECRET.to_string(), rate_limiter: build_rate_limiter(1000, 1000), + submit_rate_limiter: build_rate_limiter(1000, 1000), webhook_urls: Vec::new(), webhook_secret: None, } diff --git a/contract/tests/hash_validation.rs b/contract/tests/hash_validation.rs index f6ff7b1d..86a9d516 100644 --- a/contract/tests/hash_validation.rs +++ b/contract/tests/hash_validation.rs @@ -21,6 +21,7 @@ fn test_state() -> AppState { metrics: Arc::new(MetricsRegistry::new()), stellar_secret_key: SECRET.to_string(), rate_limiter: build_rate_limiter(1000, 1000), + submit_rate_limiter: build_rate_limiter(1000, 1000), webhook_urls: Vec::new(), webhook_secret: None, } diff --git a/contract/tests/rate_limit.rs b/contract/tests/rate_limit.rs index 9adcfe44..a85812b6 100644 --- a/contract/tests/rate_limit.rs +++ b/contract/tests/rate_limit.rs @@ -1,10 +1,7 @@ //! Integration tests for the wired rate limiter (CT-37). //! -//! `build_rate_limiter` is constructed from `RATE_LIMIT_PER_SECOND` / -//! `RATE_LIMIT_BURST` in `main.rs`, stored on `AppState`, and enforced as a -//! router middleware. These tests drive the real `app()` router and assert -//! that a `429 Too Many Requests` is returned once the burst quota is -//! exceeded. +//! Configured quotas are attached to the real `app()` router. The tests cover +//! both the verification quota and the independent submit quota. use axum_test::TestServer; use std::sync::Arc; @@ -22,6 +19,7 @@ fn test_state(burst: u32) -> AppState { metrics: Arc::new(MetricsRegistry::new()), stellar_secret_key: "SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_string(), rate_limiter: build_rate_limiter(10, burst), + submit_rate_limiter: build_rate_limiter(1, 1), webhook_urls: Vec::new(), webhook_secret: None, } @@ -37,6 +35,13 @@ async fn requests_within_burst_are_allowed() { } } +#[tokio::test] +async fn versioned_routes_are_available() { + let server = TestServer::new(app(test_state(5))).unwrap(); + + assert_eq!(server.get("/v1/metrics").await.status_code(), 200); +} + #[tokio::test] async fn exceeding_the_burst_returns_429() { let server = TestServer::new(app(test_state(1))).unwrap(); @@ -50,13 +55,25 @@ async fn exceeding_the_burst_returns_429() { } #[tokio::test] -async fn rejected_requests_hit_every_route() { - // The limiter applies to the whole router, not a single route. +async fn non_submit_routes_share_the_verification_quota() { let server = TestServer::new(app(test_state(1))).unwrap(); // Consume the single burst token. assert_eq!(server.get("/metrics").await.status_code(), 200); - // Now every route is rejected until the limiter refills. assert_eq!(server.get("/health").await.status_code(), 429); } + +#[tokio::test] +async fn submit_quota_is_independent_from_verification_quota() { + let server = TestServer::new(app(test_state(1))).unwrap(); + let invalid_body = serde_json::json!({}); + + let first_submit = server.post("/v1/submit").json(&invalid_body).await; + assert_ne!(first_submit.status_code(), 429); + assert_eq!( + server.post("/submit").json(&invalid_body).await.status_code(), + 429 + ); + assert_eq!(server.get("/v1/metrics").await.status_code(), 200); +} diff --git a/contract/tests/revoke.rs b/contract/tests/revoke.rs index a22a4393..0338bf25 100644 --- a/contract/tests/revoke.rs +++ b/contract/tests/revoke.rs @@ -28,6 +28,7 @@ fn test_state(horizon_url: &str) -> AppState { metrics: Arc::new(MetricsRegistry::new()), stellar_secret_key: SECRET.to_string(), rate_limiter: build_rate_limiter(100, 100), + submit_rate_limiter: build_rate_limiter(100, 100), webhook_urls: Vec::new(), webhook_secret: None, } diff --git a/docker-compose.yml b/docker-compose.yml index 7a9442b1..83fdf589 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -42,6 +42,10 @@ services: STELLAR_HORIZON_URL: ${STELLAR_HORIZON_URL:-https://horizon-testnet.stellar.org} STELLAR_SECRET_KEY: ${STELLAR_SECRET_KEY:-} REDIS_URL: redis://redis:6379 + RATE_LIMIT_PER_SECOND: ${RATE_LIMIT_PER_SECOND:-10} + RATE_LIMIT_BURST: ${RATE_LIMIT_BURST:-${RATE_LIMIT_PER_SECOND:-10}} + SUBMIT_RATE_LIMIT_PER_SECOND: ${SUBMIT_RATE_LIMIT_PER_SECOND:-1} + SUBMIT_RATE_LIMIT_BURST: ${SUBMIT_RATE_LIMIT_BURST:-2} STELLAR_MAX_RETRIES: ${STELLAR_MAX_RETRIES:-3} HORIZON_RETRY_INITIAL_BACKOFF_MS: ${HORIZON_RETRY_INITIAL_BACKOFF_MS:-200} HORIZON_RETRY_MAX_BACKOFF_MS: ${HORIZON_RETRY_MAX_BACKOFF_MS:-5000} diff --git a/docs/contract-deployment.md b/docs/contract-deployment.md index 8a4af7a8..c44cbc6e 100644 --- a/docs/contract-deployment.md +++ b/docs/contract-deployment.md @@ -37,8 +37,10 @@ The verifier runs as a single binary that: | `HORIZON_CB_COOLDOWN_SECS` | `30` | How long the breaker stays `Open` before allowing a `HalfOpen` probe. | | `REDIS_URL` | `redis://127.0.0.1:6379` | Redis connection string for caching. | | `CACHE_VERIFICATION_TTL` | `3600` | Seconds to cache `VerifyResponse` results. | -| `RATE_LIMIT_PER_SECOND` | `10` | `governor` token-bucket refill rate. | -| `RATE_LIMIT_BURST` | `RATE_LIMIT_PER_SECOND` | `governor` token-bucket capacity. | +| `RATE_LIMIT_PER_SECOND` | `10` | Process-wide refill rate for verify and other non-submit routes. | +| `RATE_LIMIT_BURST` | `RATE_LIMIT_PER_SECOND` | Process-wide burst capacity for verify and other non-submit routes. | +| `SUBMIT_RATE_LIMIT_PER_SECOND` | `1` | Separate, lower process-wide refill rate for `/submit`. | +| `SUBMIT_RATE_LIMIT_BURST` | `2` | Burst capacity for `/submit`. | | `LOG_LEVEL` | `info` | Passed straight through to `tracing-subscriber`. | | `WEBHOOK_URLS` | *(empty)* | Comma-separated list of URLs to fan out webhook events to. | | `WEBHOOK_SECRET` | *(empty)* | HMAC secret for outbound webhook payloads. |