Skip to content

M5: per-wallet quota and wallet heuristic gate#11

Merged
aliXsed merged 4 commits into
mainfrom
feat/quota-m5
Jun 5, 2026
Merged

M5: per-wallet quota and wallet heuristic gate#11
aliXsed merged 4 commits into
mainfrom
feat/quota-m5

Conversation

@aliXsed

@aliXsed aliXsed commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Summary

Area Change
Heuristic Phase-0 first-seen profile (heuristic:seen:{wallet}) — new wallets get lower paid-scope limits
Quota store Per-wallet counters in Redis/memory (quota:{wallet}:{scope}:{window})
Session Initializes quota buckets after SIWE; marks wallet established after mint
API GET /v1/auth/quota (public), POST /v1/auth/quota/consume (internal only)
Config [quota]window_secs, paid_scopes, limits.new_wallet / limits.established
Exposure APP_SERVER__EXPOSE=all|public|internal — split wallet vs RS routes in one binary
Docs docs/DEPLOYMENT.md, ADR 0003, architecture + sequence diagrams in ARCHITECTURE.md

Paid scopes (default): ai:invoke, mint:request. On-chain heuristics deferred; established tier applies from the second session onward.

Public vs internal (GCP)

Deploy expose Routes
trust-relay-public public SIWE ceremony, JWKS, logout, GET /quota
trust-relay-internal internal POST /quota/consume, /healthz

Quota consume auth (phase 0): RS forwards the user Authorization: Bearer JWT — trust-relay does not identify the RS yet. Network isolation on internal ingress is the guardrail until trust-auth (M3b).

Test plan

  • cargo test
  • cargo clippy -- -D warnings
  • cargo fmt --check
  • Coverage ≥ 80% lines / 78% regions locally (89.2% lines / 88.1% regions)
  • tests/expose.rs — public/internal route surfaces
  • CI Redis job runs tests/quota_redis.rs

aliXsed added 2 commits June 4, 2026 17:07
Initialize paid-scope buckets on session, expose quota consume/status endpoints, and apply lower limits for new wallets.
Use an ephemeral wallet per test so heuristic and quota state are not polluted by parallel runs against account #0.
@github-actions

github-actions Bot commented Jun 5, 2026

Copy link
Copy Markdown

Coverage report

Thresholds: 80% line · 78% region (condition)

✅ Coverage meets the required threshold.

Summary
/home/runner/work/trust-relay/trust-relay/src/bootstrap.rs:
    1|       |//! Store and service wiring for production and tests.
    2|       |
    3|       |use std::sync::Arc;
    4|       |
    5|       |use crate::{
    6|       |    config::Config,
    7|       |    error::AppError,
    8|       |    middleware::rate_limit::{new_nonce_rate_limiter, RateLimiter},
    9|       |    services::{
   10|       |        heuristics::HeuristicsService, nonce::NonceService, quota::QuotaService,
   11|       |        refresh::RefreshService, revocation::RevocationService, token::TokenService,
   12|       |    },
   13|       |    store::heuristics::{HeuristicsStore, InMemoryHeuristicsStore, RedisHeuristicsStore},
   14|       |    store::nonce::{InMemoryNonceStore, NonceStore, RedisNonceStore},
   15|       |    store::quota::{InMemoryQuotaStore, QuotaStore, RedisQuotaStore},
   16|       |    store::refresh::{InMemoryRefreshStore, RedisRefreshStore, RefreshStore},
   17|       |    store::revocation::{InMemoryRevocationStore, RedisRevocationStore, RevocationStore},
   18|       |};
   19|       |
   20|       |impl From<redis::RedisError> for AppError {
   21|      1|    fn from(e: redis::RedisError) -> Self {
   22|      1|        Self::Internal(format!("redis: {e}"))
   23|      1|    }
   24|       |}
   25|       |
   26|     27|pub async fn build_nonce_store(config: &Config) -> Result<Arc<dyn NonceStore>, AppError> {
   27|     27|    if config.uses_redis() {
   28|     24|        Ok(Arc::new(RedisNonceStore::connect(&config.redis.url).await?))
                                                                                   ^0
   29|       |    } else {
   30|      3|        Ok(Arc::new(InMemoryNonceStore::default()))
   31|       |    }
   32|     27|}
   33|       |
   34|     25|pub fn build_nonce_service(config: &Config, store: Arc<dyn NonceStore>) -> Arc<NonceService> {
   35|     25|    Arc::new(NonceService::new(store, config.auth.clone()))
   36|     25|}
   37|       |
   38|     25|pub fn build_nonce_rate_limiter(config: &Config) -> Arc<RateLimiter> {
   39|     25|    new_nonce_rate_limiter(config.rate_limit.nonce_per_ip_per_minute)
   40|     25|}
   41|       |
   42|     26|pub fn build_token_service(config: &Config) -> Result<Arc<TokenService>, AppError> {
   43|     26|    Ok(Arc::new(TokenService::new(config.signing.clone())?))
                                                                       ^0
   44|     26|}
   45|       |
   46|     25|pub async fn build_revocation_store(config: &Config) -> Result<Arc<dyn RevocationStore>, AppError> {
   47|     25|    if config.uses_redis() {
   48|     23|        Ok(Arc::new(
   49|     23|            RedisRevocationStore::connect(&config.redis.url).await?,
                                                                                ^0
   50|       |        ))
   51|       |    } else {
   52|      2|        Ok(Arc::new(InMemoryRevocationStore::default()))
   53|       |    }
   54|     25|}
   55|       |
   56|     25|pub fn build_revocation_service(store: Arc<dyn RevocationStore>) -> Arc<RevocationService> {
   57|     25|    Arc::new(RevocationService::new(store))
   58|     25|}
   59|       |
   60|     25|pub async fn build_refresh_store(config: &Config) -> Result<Arc<dyn RefreshStore>, AppError> {
   61|     25|    if config.uses_redis() {
   62|     23|        Ok(Arc::new(
   63|     23|            RedisRefreshStore::connect(&config.redis.url).await?,
                                                                             ^0
   64|       |        ))
   65|       |    } else {
   66|      2|        Ok(Arc::new(InMemoryRefreshStore::default()))
   67|       |    }
   68|     25|}
   69|       |
   70|     25|pub fn build_refresh_service(config: &Config, store: Arc<dyn RefreshStore>) -> Arc<RefreshService> {
   71|     25|    Arc::new(RefreshService::new(store, config.auth.clone()))
   72|     25|}
   73|       |
   74|     25|pub async fn build_heuristics_store(config: &Config) -> Result<Arc<dyn HeuristicsStore>, AppError> {
   75|     25|    if config.uses_redis() {
   76|     23|        Ok(Arc::new(
   77|     23|            RedisHeuristicsStore::connect(&config.redis.url).await?,
                                                                                ^0
   78|       |        ))
   79|       |    } else {
   80|      2|        Ok(Arc::new(InMemoryHeuristicsStore::default()))
   81|       |    }
   82|     25|}
   83|       |
   84|     25|pub fn build_heuristics_service(store: Arc<dyn HeuristicsStore>) -> Arc<HeuristicsService> {
   85|     25|    Arc::new(HeuristicsService::new(store))
   86|     25|}
   87|       |
   88|     25|pub async fn build_quota_store(config: &Config) -> Result<Arc<dyn QuotaStore>, AppError> {
   89|     25|    if config.uses_redis() {
   90|     23|        Ok(Arc::new(RedisQuotaStore::connect(&config.redis.url).await?))
                                                                                   ^0
   91|       |    } else {
   92|      2|        Ok(Arc::new(InMemoryQuotaStore::default()))
   93|       |    }
   94|     25|}
   95|       |
   96|     25|pub fn build_quota_service(config: &Config, store: Arc<dyn QuotaStore>) -> Arc<QuotaService> {
   97|     25|    Arc::new(QuotaService::new(store, config.quota.clone()))
   98|     25|}
   99|       |
  100|       |#[cfg(test)]
  101|       |mod tests {
  102|       |    use super::*;
  103|       |
  104|       |    #[test]
  105|      1|    fn redis_error_converts_to_internal_app_error() {
  106|      1|        let redis_err = redis::RedisError::from((redis::ErrorKind::IoError, "connection refused"));
  107|      1|        let app: AppError = redis_err.into();
  108|      1|        assert!(matches!(app, AppError::Internal(_)));
                              ^0
  109|      1|    }
  110|       |
  111|       |    #[tokio::test]
  112|      1|    async fn build_nonce_store_uses_memory_without_redis_url() {
  113|      1|        let mut cfg = Config::load().expect("config should load");
  114|      1|        cfg.redis.url.clear();
  115|      1|        let store = build_nonce_store(&cfg)
  116|      1|            .await
  117|      1|            .expect("in-memory store should build");
  118|      1|        let now = time::OffsetDateTime::now_utc();
  119|      1|        let record = crate::store::nonce::NonceRecord {
  120|      1|            issued_at: now,
  121|      1|            expires_at: now + time::Duration::seconds(30),
  122|      1|            client_ip: None,
  123|      1|            used: false,
  124|      1|        };
  125|      1|        store.put("n", &record).await.unwrap();
  126|      1|        assert!(store.get("n").await.unwrap().is_some());
  127|      1|    }
  128|       |
  129|       |    #[test]
  130|      1|    fn build_token_service_succeeds() {
  131|      1|        let cfg = Config::load().expect("config should load");
  132|      1|        let token = build_token_service(&cfg).expect("token service should build");
  133|      1|        assert_eq!(token.kid(), cfg.signing.kid);
  134|      1|    }
  135|       |}

/home/runner/work/trust-relay/trust-relay/src/config.rs:
    1|       |//! Figment-based configuration loading.
    2|       |
    3|       |use std::collections::HashMap;
    4|       |
    5|       |use figment::{
    6|       |    providers::{Env, Format, Toml},
    7|       |    Figment,
    8|       |};
    9|       |use serde::Deserialize;
   10|       |
   11|       |#[derive(Debug, Clone, Deserialize)]
   12|       |pub struct Config {
   13|       |    pub server: ServerConfig,
   14|       |    pub telemetry: TelemetryConfig,
   15|       |    pub redis: RedisConfig,
   16|       |    pub auth: AuthConfig,
   17|       |    pub rate_limit: RateLimitConfig,
   18|       |    pub signing: SigningConfig,
   19|       |    pub quota: QuotaConfig,
   20|       |}
   21|       |
   22|       |/// Which HTTP routes this process exposes (see `docs/DEPLOYMENT.md`).
   23|       |#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default, serde::Serialize)]
   24|       |#[serde(rename_all = "lowercase")]
   25|       |pub enum ExposeMode {
   26|       |    /// All routes (local dev and single-process deploys).
   27|       |    #[default]
   28|       |    All,
   29|       |    /// Internet-facing: SIWE ceremony, JWKS, logout; no RS quota APIs.
   30|       |    Public,
   31|       |    /// VPC-only: quota consume and related backend helpers; plus `/healthz`.
   32|       |    Internal,
   33|       |}
   34|       |
   35|       |#[derive(Debug, Clone, Deserialize)]
   36|       |pub struct ServerConfig {
   37|       |    pub host: String,
   38|       |    pub port: u16,
   39|       |    #[serde(default)]
   40|       |    pub expose: ExposeMode,
   41|       |}
   42|       |
   43|       |#[derive(Debug, Clone, Deserialize)]
   44|       |pub struct TelemetryConfig {
   45|       |    pub format: String,
   46|       |    pub filter: String,
   47|       |}
   48|       |
   49|       |#[derive(Debug, Clone, Deserialize)]
   50|       |pub struct RedisConfig {
   51|       |    pub url: String,
   52|       |    pub pool_size: u32,
   53|       |}
   54|       |
   55|       |#[derive(Debug, Clone, Deserialize)]
   56|       |pub struct AuthConfig {
   57|       |    pub nonce_ttl_secs: u32,
   58|       |    pub domain: String,
   59|       |    pub uri: String,
   60|       |    pub chain_id: u64,
   61|       |    /// Scopes granted on wallet-tier `POST /v1/auth/session` (paid-scope gating is M5).
   62|       |    #[serde(default = "default_wallet_scopes")]
   63|       |    pub default_scopes: Vec<String>,
   64|       |    /// Opaque refresh token lifetime (~7 days default).
   65|       |    #[serde(default = "default_refresh_token_ttl_secs")]
   66|       |    pub refresh_token_ttl_secs: u32,
   67|       |    /// When false, session responses omit `refreshToken` (access-only mode).
   68|       |    #[serde(default = "default_issue_refresh_tokens")]
   69|       |    pub issue_refresh_tokens: bool,
   70|       |}
   71|       |
   72|      1|fn default_refresh_token_ttl_secs() -> u32 {
   73|      1|    604_800
   74|      1|}
   75|       |
   76|      1|fn default_issue_refresh_tokens() -> bool {
   77|      1|    true
   78|      1|}
   79|       |
   80|      1|fn default_wallet_scopes() -> Vec<String> {
   81|      1|    vec![
   82|      1|        "ai:invoke".into(),
   83|      1|        "mint:request".into(),
   84|      1|        "scan:submit".into(),
   85|      1|        "profile:read".into(),
   86|       |    ]
   87|      1|}
   88|       |
   89|       |#[derive(Debug, Clone, Deserialize)]
   90|       |pub struct RateLimitConfig {
   91|       |    pub nonce_per_ip_per_minute: u32,
   92|       |}
   93|       |
   94|       |#[derive(Debug, Clone, Deserialize)]
   95|       |pub struct QuotaConfig {
   96|       |    #[serde(default = "default_quota_enabled")]
   97|       |    pub enabled: bool,
   98|       |    #[serde(default = "default_quota_window_secs")]
   99|       |    pub window_secs: u32,
  100|       |    #[serde(default = "default_paid_scopes")]
  101|       |    pub paid_scopes: Vec<String>,
  102|       |    #[serde(default)]
  103|       |    pub limits: QuotaLimitTiers,
  104|       |}
  105|       |
  106|      0|fn default_quota_enabled() -> bool {
  107|      0|    true
  108|      0|}
  109|       |
  110|      1|fn default_quota_window_secs() -> u32 {
  111|      1|    86_400
  112|      1|}
  113|       |
  114|      1|fn default_paid_scopes() -> Vec<String> {
  115|      1|    vec!["ai:invoke".into(), "mint:request".into()]
  116|      1|}
  117|       |
  118|       |#[derive(Debug, Clone, Deserialize)]
  119|       |pub struct QuotaLimitTiers {
  120|       |    #[serde(default = "default_new_wallet_limits")]
  121|       |    pub new_wallet: HashMap<String, u64>,
  122|       |    #[serde(default = "default_established_limits")]
  123|       |    pub established: HashMap<String, u64>,
  124|       |}
  125|       |
  126|      1|fn default_new_wallet_limits() -> HashMap<String, u64> {
  127|      1|    HashMap::from([("ai:invoke".into(), 10), ("mint:request".into(), 2)])
  128|      1|}
  129|       |
  130|      1|fn default_established_limits() -> HashMap<String, u64> {
  131|      1|    HashMap::from([("ai:invoke".into(), 1_000), ("mint:request".into(), 50)])
  132|      1|}
  133|       |
  134|       |impl Default for QuotaLimitTiers {
  135|      1|    fn default() -> Self {
  136|      1|        Self {
  137|      1|            new_wallet: default_new_wallet_limits(),
  138|      1|            established: default_established_limits(),
  139|      1|        }
  140|      1|    }
  141|       |}
  142|       |
  143|       |#[derive(Debug, Clone, Deserialize)]
  144|       |pub struct SigningConfig {
  145|       |    pub issuer: String,
  146|       |    pub audience: String,
  147|       |    pub kid: String,
  148|       |    pub access_token_ttl_secs: u32,
  149|       |    /// Base64-encoded 32-byte Ed25519 seed. Empty generates an ephemeral dev key at startup.
  150|       |    pub key_seed_b64: String,
  151|       |}
  152|       |
  153|       |impl Config {
  154|       |    /// Load config: `config/default.toml` -> `config/{APP_ENV}.toml` ->
  155|       |    /// `APP_`-prefixed env vars (nested via `__`).
  156|     33|    pub fn load() -> Result<Self, Box<figment::Error>> {
  157|     33|        let env = std::env::var("APP_ENV").unwrap_or_else(|_| "development".into());
                                                                            ^31           ^31
  158|     33|        Figment::new()
  159|     33|            .merge(Toml::file("config/default.toml"))
  160|     33|            .merge(Toml::file(format!("config/{env}.toml")))
  161|     33|            .merge(Env::prefixed("APP_").split("__"))
  162|     33|            .extract()
  163|     33|            .map_err(Box::new)
  164|     33|    }
  165|       |
  166|       |    /// Use Redis when `redis.url` is non-empty.
  167|    130|    pub fn uses_redis(&self) -> bool {
  168|    130|        !self.redis.url.trim().is_empty()
  169|    130|    }
  170|       |}
  171|       |
  172|       |#[cfg(test)]
  173|       |mod tests {
  174|       |    use super::*;
  175|       |
  176|       |    #[test]
  177|      1|    fn expose_mode_deserializes_lowercase() {
  178|      1|        assert_eq!(
  179|      1|            serde_json::from_str::<ExposeMode>("\"public\"").unwrap(),
  180|       |            ExposeMode::Public
  181|       |        );
  182|      1|        assert_eq!(
  183|      1|            serde_json::from_str::<ExposeMode>("\"internal\"").unwrap(),
  184|       |            ExposeMode::Internal
  185|       |        );
  186|      1|    }
  187|       |
  188|       |    #[test]
  189|      1|    fn uses_redis_when_url_is_non_empty() {
  190|      1|        let cfg = Config {
  191|      1|            server: ServerConfig {
  192|      1|                host: "127.0.0.1".into(),
  193|      1|                port: 3001,
  194|      1|                expose: ExposeMode::All,
  195|      1|            },
  196|      1|            telemetry: TelemetryConfig {
  197|      1|                format: "pretty".into(),
  198|      1|                filter: "info".into(),
  199|      1|            },
  200|      1|            redis: RedisConfig {
  201|      1|                url: "redis://127.0.0.1/".into(),
  202|      1|                pool_size: 1,
  203|      1|            },
  204|      1|            auth: AuthConfig {
  205|      1|                nonce_ttl_secs: 120,
  206|      1|                domain: "localhost".into(),
  207|      1|                uri: "http://localhost".into(),
  208|      1|                chain_id: 1,
  209|      1|                default_scopes: default_wallet_scopes(),
  210|      1|                refresh_token_ttl_secs: default_refresh_token_ttl_secs(),
  211|      1|                issue_refresh_tokens: default_issue_refresh_tokens(),
  212|      1|            },
  213|      1|            rate_limit: RateLimitConfig {
  214|      1|                nonce_per_ip_per_minute: 30,
  215|      1|            },
  216|      1|            signing: SigningConfig {
  217|      1|                issuer: "http://localhost:3001".into(),
  218|      1|                audience: "nodle-backend".into(),
  219|      1|                kid: "test-key".into(),
  220|      1|                access_token_ttl_secs: 3600,
  221|      1|                key_seed_b64: String::new(),
  222|      1|            },
  223|      1|            quota: QuotaConfig {
  224|      1|                enabled: true,
  225|      1|                window_secs: default_quota_window_secs(),
  226|      1|                paid_scopes: default_paid_scopes(),
  227|      1|                limits: QuotaLimitTiers::default(),
  228|      1|            },
  229|      1|        };
  230|      1|        assert!(cfg.uses_redis());
  231|      1|        let mut empty = cfg;
  232|      1|        empty.redis.url.clear();
  233|      1|        assert!(!empty.uses_redis());
  234|      1|        empty.redis.url = "   ".into();
  235|      1|        assert!(!empty.uses_redis());
  236|      1|    }
  237|       |}

/home/runner/work/trust-relay/trust-relay/src/error.rs:
    1|       |//! Application errors mapped to TOKEN-SPEC HTTP responses.
    2|       |
    3|       |use axum::{
    4|       |    http::{HeaderValue, StatusCode},
    5|       |    response::{IntoResponse, Response},
    6|       |    Json,
    7|       |};
    8|       |use serde::Serialize;
    9|       |use thiserror::Error;
   10|       |
   11|       |/// Machine-readable auth error codes from TOKEN-SPEC §8.
   12|       |#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
   13|       |#[serde(rename_all = "snake_case")]
   14|       |pub enum ErrorCode {
   15|       |    InvalidRequest,
   16|       |    InvalidNonce,
   17|       |    NonceExpired,
   18|       |    NonceUsed,
   19|       |    InvalidSignature,
   20|       |    InvalidToken,
   21|       |    InsufficientScope,
   22|       |    WalletBlocked,
   23|       |    QuotaExceeded,
   24|       |    RateLimited,
   25|       |    Internal,
   26|       |}
   27|       |
   28|       |impl ErrorCode {
   29|     25|    pub fn as_str(self) -> &'static str {
   30|     25|        match self {
   31|      3|            Self::InvalidRequest => "invalid_request",
   32|      2|            Self::InvalidNonce => "invalid_nonce",
   33|      1|            Self::NonceExpired => "nonce_expired",
   34|      2|            Self::NonceUsed => "nonce_used",
   35|      2|            Self::InvalidSignature => "invalid_signature",
   36|      4|            Self::InvalidToken => "invalid_token",
   37|      0|            Self::InsufficientScope => "insufficient_scope",
   38|      1|            Self::WalletBlocked => "wallet_blocked",
   39|      1|            Self::QuotaExceeded => "quota_exceeded",
   40|      8|            Self::RateLimited => "rate_limited",
   41|      1|            Self::Internal => "internal_error",
   42|       |        }
   43|     25|    }
   44|       |}
   45|       |
   46|       |#[derive(Debug, Serialize)]
   47|       |struct ErrorBody<'a> {
   48|       |    error: &'a str,
   49|       |    #[serde(skip_serializing_if = "Option::is_none")]
   50|       |    error_description: Option<&'a str>,
   51|       |}
   52|       |
   53|       |#[derive(Debug, Error)]
   54|       |pub enum AppError {
   55|       |    #[error("{0}")]
   56|       |    InvalidRequest(String),
   57|       |    #[error("{0}")]
   58|       |    InvalidNonce(String),
   59|       |    #[error("{0}")]
   60|       |    NonceExpired(String),
   61|       |    #[error("{0}")]
   62|       |    NonceUsed(String),
   63|       |    #[error("{0}")]
   64|       |    InvalidSignature(String),
   65|       |    #[error("{0}")]
   66|       |    InvalidToken(String),
   67|       |    #[error("{0}")]
   68|       |    InsufficientScope(String),
   69|       |    #[error("{0}")]
   70|       |    WalletBlocked(String),
   71|       |    #[error("{0}")]
   72|       |    QuotaExceeded(String),
   73|       |    #[error("{0}")]
   74|       |    RateLimited(String),
   75|       |    #[error("{0}")]
   76|       |    Internal(String),
   77|       |}
   78|       |
   79|       |impl AppError {
   80|     25|    pub fn code(&self) -> ErrorCode {
   81|     25|        match self {
   82|      3|            Self::InvalidRequest(_) => ErrorCode::InvalidRequest,
   83|      2|            Self::InvalidNonce(_) => ErrorCode::InvalidNonce,
   84|      1|            Self::NonceExpired(_) => ErrorCode::NonceExpired,
   85|      2|            Self::NonceUsed(_) => ErrorCode::NonceUsed,
   86|      2|            Self::InvalidSignature(_) => ErrorCode::InvalidSignature,
   87|      4|            Self::InvalidToken(_) => ErrorCode::InvalidToken,
   88|      0|            Self::InsufficientScope(_) => ErrorCode::InsufficientScope,
   89|      1|            Self::WalletBlocked(_) => ErrorCode::WalletBlocked,
   90|      1|            Self::QuotaExceeded(_) => ErrorCode::QuotaExceeded,
   91|      8|            Self::RateLimited(_) => ErrorCode::RateLimited,
   92|      1|            Self::Internal(_) => ErrorCode::Internal,
   93|       |        }
   94|     25|    }
   95|       |
   96|     40|    pub fn status(&self) -> StatusCode {
   97|     40|        match self {
   98|       |            Self::InvalidRequest(_)
   99|       |            | Self::InvalidNonce(_)
  100|       |            | Self::NonceExpired(_)
  101|     14|            | Self::NonceUsed(_) => StatusCode::BAD_REQUEST,
  102|      8|            Self::InvalidSignature(_) | Self::InvalidToken(_) => StatusCode::UNAUTHORIZED,
  103|      3|            Self::InsufficientScope(_) | Self::WalletBlocked(_) => StatusCode::FORBIDDEN,
  104|     12|            Self::QuotaExceeded(_) | Self::RateLimited(_) => StatusCode::TOO_MANY_REQUESTS,
  105|      3|            Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
  106|       |        }
  107|     40|    }
  108|       |
  109|     25|    fn description(&self) -> &str {
  110|     25|        match self {
  111|      3|            Self::InvalidRequest(m)
  112|      2|            | Self::InvalidNonce(m)
  113|      1|            | Self::NonceExpired(m)
  114|      2|            | Self::NonceUsed(m)
  115|      2|            | Self::InvalidSignature(m)
  116|      4|            | Self::InvalidToken(m)
  117|      0|            | Self::InsufficientScope(m)
  118|      1|            | Self::WalletBlocked(m)
  119|      1|            | Self::QuotaExceeded(m)
  120|      8|            | Self::RateLimited(m)
  121|     25|            | Self::Internal(m) => m,
                                           ^1
  122|       |        }
  123|     25|    }
  124|       |}
  125|       |
  126|       |impl IntoResponse for AppError {
  127|     25|    fn into_response(self) -> Response {
  128|     25|        let status = self.status();
  129|     25|        let code = self.code();
  130|     25|        let body = ErrorBody {
  131|     25|            error: code.as_str(),
  132|     25|            error_description: Some(self.description()),
  133|     25|        };
  134|       |
  135|     25|        let mut response = (status, Json(body)).into_response();
  136|     25|        if status == StatusCode::UNAUTHORIZED {
  137|      6|            if let Ok(value) = HeaderValue::from_str(r#"Bearer error="invalid_token""#) {
  138|      6|                response
  139|      6|                    .headers_mut()
  140|      6|                    .insert(axum::http::header::WWW_AUTHENTICATE, value);
  141|      6|            }
                          ^0
  142|     19|        }
  143|     25|        response
  144|     25|    }
  145|       |}
  146|       |
  147|       |#[cfg(test)]
  148|       |mod tests {
  149|       |    use super::*;
  150|       |
  151|       |    #[tokio::test]
  152|      1|    async fn invalid_token_maps_to_401_with_www_authenticate() {
  153|      1|        let response = AppError::InvalidToken("expired".into()).into_response();
  154|      1|        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
  155|      1|        assert!(response
  156|      1|            .headers()
  157|      1|            .get(axum::http::header::WWW_AUTHENTICATE)
  158|      1|            .is_some());
  159|       |
  160|      1|        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
  161|      1|            .await
  162|      1|            .unwrap();
  163|      1|        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
  164|      1|        assert_eq!(json["error"], "invalid_token");
  165|      1|        assert_eq!(json["error_description"], "expired");
  166|      1|    }
  167|       |
  168|       |    #[tokio::test]
  169|      1|    async fn rate_limited_maps_to_429() {
  170|      1|        let response = AppError::RateLimited("too many requests".into()).into_response();
  171|      1|        assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
  172|      1|    }
  173|       |
  174|       |    #[tokio::test]
  175|      1|    async fn nonce_errors_map_to_bad_request() {
  176|      3|        for err in [
                      ^1
  177|      1|            AppError::InvalidNonce("bad".into()),
  178|      1|            AppError::NonceExpired("old".into()),
  179|      1|            AppError::NonceUsed("again".into()),
  180|      1|        ] {
  181|      3|            let response = err.into_response();
  182|      3|            assert_eq!(response.status(), StatusCode::BAD_REQUEST);
  183|      1|        }
  184|      1|    }
  185|       |}

/home/runner/work/trust-relay/trust-relay/src/main.rs:
    1|       |//! Trust Relay — entry point.
    2|       |//!
    3|       |//! Boot order: load config -> init tracing -> build router -> serve.
    4|       |//! This is the phase-0 seed: only `GET /healthz` is wired. The SIWE grant,
    5|       |//! JWKS endpoint, and token issuance land per docs/ARCHITECTURE.md.
    6|       |
    7|       |use trust_relay::{config, server};
    8|       |
    9|       |#[tokio::main]
   10|      0|async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
   11|      0|    server::run(config::Config::load()?).await
   12|      0|}

/home/runner/work/trust-relay/trust-relay/src/middleware/rate_limit.rs:
    1|       |//! Per-IP rate limiting for nonce issuance.
    2|       |
    3|       |use std::net::IpAddr;
    4|       |use std::sync::Arc;
    5|       |use std::time::{Duration, Instant};
    6|       |
    7|       |use dashmap::DashMap;
    8|       |
    9|       |use crate::error::AppError;
   10|       |
   11|       |#[derive(Clone)]
   12|       |pub struct RateLimiter {
   13|       |    limits: DashMap<IpAddr, Vec<Instant>>,
   14|       |    max_per_window: u32,
   15|       |    window: Duration,
   16|       |}
   17|       |
   18|       |impl RateLimiter {
   19|     25|    pub fn new(max_per_window: u32, window: Duration) -> Self {
   20|     25|        Self {
   21|     25|            limits: DashMap::new(),
   22|     25|            max_per_window,
   23|     25|            window,
   24|     25|        }
   25|     25|    }
   26|       |
   27|     51|    pub fn check(&self, ip: IpAddr) -> Result<(), AppError> {
   28|     51|        let now = Instant::now();
   29|     51|        let mut entry = self.limits.entry(ip).or_default();
   30|    616|        entry.retain(|t| now.duration_since(*t) < self.window);
                      ^51   ^51
   31|     51|        if entry.len() >= self.max_per_window as usize {
   32|      6|            return Err(AppError::RateLimited("nonce rate limit exceeded".into()));
   33|     45|        }
   34|     45|        entry.push(now);
   35|     45|        Ok(())
   36|     51|    }
   37|       |}
   38|       |
   39|     25|pub fn new_nonce_rate_limiter(max_per_minute: u32) -> Arc<RateLimiter> {
   40|     25|    Arc::new(RateLimiter::new(max_per_minute, Duration::from_secs(60)))
   41|     25|}

/home/runner/work/trust-relay/trust-relay/src/routes/auth.rs:
    1|       |//! Authentication endpoints.
    2|       |
    3|       |use std::net::IpAddr;
    4|       |
    5|       |use axum::{
    6|       |    extract::State,
    7|       |    http::{HeaderMap, StatusCode},
    8|       |    Json,
    9|       |};
   10|       |use serde::{Deserialize, Serialize};
   11|       |use time::format_description::well_known::Rfc3339;
   12|       |
   13|       |use crate::{
   14|       |    error::AppError, models::claims::AccessTokenClaims, services::siwe::verify_siwe_session,
   15|       |    state::AppState,
   16|       |};
   17|       |
   18|       |#[derive(Serialize)]
   19|       |pub struct NonceResponse {
   20|       |    pub nonce: String,
   21|       |    #[serde(rename = "expiresAt")]
   22|       |    pub expires_at: String,
   23|       |}
   24|       |
   25|       |#[derive(Deserialize)]
   26|       |pub struct SessionRequest {
   27|       |    pub message: String,
   28|       |    pub signature: String,
   29|       |}
   30|       |
   31|       |#[derive(Serialize)]
   32|       |pub struct SessionResponse {
   33|       |    #[serde(rename = "accessToken")]
   34|       |    pub access_token: String,
   35|       |    #[serde(rename = "tokenType")]
   36|       |    pub token_type: &'static str,
   37|       |    #[serde(rename = "expiresIn")]
   38|       |    pub expires_in: u32,
   39|       |    #[serde(rename = "walletAddress")]
   40|       |    pub wallet_address: String,
   41|       |    pub scopes: Vec<String>,
   42|       |    #[serde(rename = "refreshToken", skip_serializing_if = "Option::is_none")]
   43|       |    pub refresh_token: Option<String>,
   44|       |}
   45|       |
   46|       |#[derive(Deserialize)]
   47|       |pub struct RefreshRequest {
   48|       |    #[serde(rename = "refreshToken")]
   49|       |    pub refresh_token: String,
   50|       |}
   51|       |
   52|       |#[derive(Serialize)]
   53|       |pub struct RefreshResponse {
   54|       |    #[serde(rename = "accessToken")]
   55|       |    pub access_token: String,
   56|       |    #[serde(rename = "expiresIn")]
   57|       |    pub expires_in: u32,
   58|       |    #[serde(rename = "refreshToken")]
   59|       |    pub refresh_token: String,
   60|       |}
   61|       |
   62|       |#[derive(Deserialize)]
   63|       |pub struct QuotaConsumeRequest {
   64|       |    pub scope: String,
   65|       |    #[serde(default = "default_quota_amount")]
   66|       |    pub amount: u64,
   67|       |}
   68|       |
   69|      4|fn default_quota_amount() -> u64 {
   70|      4|    1
   71|      4|}
   72|       |
   73|       |#[derive(Serialize)]
   74|       |pub struct QuotaConsumeResponse {
   75|       |    pub scope: String,
   76|       |    pub remaining: u64,
   77|       |    pub limit: u64,
   78|       |}
   79|       |
   80|       |#[derive(Serialize)]
   81|       |pub struct QuotaStatusResponse {
   82|       |    pub scopes: Vec<QuotaConsumeResponse>,
   83|       |}
   84|       |
   85|     51|pub async fn get_nonce(
   86|     51|    State(state): State<AppState>,
   87|     51|    headers: HeaderMap,
   88|     51|) -> Result<Json<NonceResponse>, AppError> {
   89|     51|    let ip = client_ip_from_headers(&headers)
   90|     51|        .and_then(|s| s.parse().ok())
   91|     51|        .unwrap_or(IpAddr::from([127, 0, 0, 1]));
   92|     51|    state.nonce_rate_limiter.check(ip)?;
                                                    ^6
   93|       |
   94|     45|    let client_ip = client_ip_from_headers(&headers);
   95|     45|    let issued = state.nonce.issue(client_ip).await?;
                                                                 ^0
   96|     45|    let expires_at = issued
   97|     45|        .expires_at
   98|     45|        .format(&Rfc3339)
   99|     45|        .map_err(|e| AppError::Internal(format!("timestamp format: {e}")))?;
                                                      ^0                                ^0
  100|     45|    Ok(Json(NonceResponse {
  101|     45|        nonce: issued.nonce,
  102|     45|        expires_at,
  103|     45|    }))
  104|     51|}
  105|       |
  106|     13|pub async fn post_session(
  107|     13|    State(state): State<AppState>,
  108|     13|    Json(body): Json<SessionRequest>,
  109|     13|) -> Result<Json<SessionResponse>, AppError> {
  110|     13|    let verified = verify_siwe_session(&state.config.auth, &body.message, &body.signature).await?;
                      ^12                                                                                     ^1
  111|     12|    state
  112|     12|        .revocation
  113|     12|        .ensure_wallet_active(&verified.wallet_address)
  114|     12|        .await?;
                            ^1
  115|     11|    state.nonce.validate_and_consume(&verified.nonce).await?;
                                                                         ^1
  116|       |
  117|     10|    let profile = state
  118|     10|        .heuristics
  119|     10|        .profile_for(&verified.wallet_address)
  120|     10|        .await?;
                            ^0
  121|     10|    state
  122|     10|        .quota
  123|     10|        .init_for_wallet(&verified.wallet_address, profile)
  124|     10|        .await?;
                            ^0
  125|       |
  126|     10|    let scopes = state.config.auth.default_scopes.clone();
  127|     10|    let scope_refs: Vec<&str> = scopes.iter().map(String::as_str).collect();
  128|     10|    let access_token = state.token.mint_wallet_token(
  129|     10|        &verified.wallet_address,
  130|     10|        &scope_refs,
  131|     10|        state.config.auth.chain_id,
  132|     10|    )?;
                   ^0
  133|       |
  134|     10|    let refresh_token = if state.refresh.enabled() {
  135|       |        Some(
  136|     10|            state
  137|     10|                .refresh
  138|     10|                .issue(
  139|     10|                    &verified.wallet_address,
  140|     10|                    &scopes,
  141|     10|                    state.config.auth.chain_id,
  142|     10|                )
  143|     10|                .await?
                                    ^0
  144|       |                .token,
  145|       |        )
  146|       |    } else {
  147|      0|        None
  148|       |    };
  149|       |
  150|     10|    state
  151|     10|        .heuristics
  152|     10|        .mark_established(&verified.wallet_address)
  153|     10|        .await?;
                            ^0
  154|       |
  155|     10|    Ok(Json(SessionResponse {
  156|     10|        access_token,
  157|     10|        token_type: "Bearer",
  158|     10|        expires_in: state.config.signing.access_token_ttl_secs,
  159|     10|        wallet_address: verified.wallet_address,
  160|     10|        scopes,
  161|     10|        refresh_token,
  162|     10|    }))
  163|     13|}
  164|       |
  165|       |/// `POST /v1/auth/session/refresh` — rotate opaque refresh token and mint a new access JWT.
  166|      4|pub async fn post_session_refresh(
  167|      4|    State(state): State<AppState>,
  168|      4|    Json(body): Json<RefreshRequest>,
  169|      4|) -> Result<Json<RefreshResponse>, AppError> {
  170|      4|    if !state.refresh.enabled() {
  171|      0|        return Err(AppError::InvalidRequest(
  172|      0|            "refresh tokens are not enabled".into(),
  173|      0|        ));
  174|      4|    }
  175|      4|    let rotated = state.refresh.rotate(&body.refresh_token).await?;
                      ^2                                                       ^2
  176|      2|    state
  177|      2|        .revocation
  178|      2|        .ensure_wallet_active(&rotated.record.wallet)
  179|      2|        .await?;
                            ^0
  180|      2|    state
  181|      2|        .quota
  182|      2|        .init_for_wallet(
  183|      2|            &rotated.record.wallet,
  184|      2|            crate::services::heuristics::WalletProfile::Established,
  185|      2|        )
  186|      2|        .await?;
                            ^0
  187|       |
  188|      2|    let scope_refs: Vec<&str> = rotated.record.scopes.iter().map(String::as_str).collect();
  189|      2|    let access_token = state.token.mint_wallet_token(
  190|      2|        &rotated.record.wallet,
  191|      2|        &scope_refs,
  192|      2|        rotated.record.chain_id,
  193|      2|    )?;
                   ^0
  194|       |
  195|      2|    Ok(Json(RefreshResponse {
  196|      2|        access_token,
  197|      2|        expires_in: state.config.signing.access_token_ttl_secs,
  198|      2|        refresh_token: rotated.token,
  199|      2|    }))
  200|      4|}
  201|       |
  202|       |/// `POST /v1/auth/quota/consume` — decrement a paid-scope counter (RS hot-path helper).
  203|      4|pub async fn post_quota_consume(
  204|      4|    State(state): State<AppState>,
  205|      4|    headers: HeaderMap,
  206|      4|    Json(body): Json<QuotaConsumeRequest>,
  207|      4|) -> Result<Json<QuotaConsumeResponse>, AppError> {
  208|      4|    let claims = verified_claims(&state, &headers)?;
                                                                ^0
  209|      4|    ensure_scope(&claims, &body.scope)?;
                                                    ^0
  210|      4|    let status = state
                      ^3
  211|      4|        .quota
  212|      4|        .consume(&claims.sub, &body.scope, body.amount)
  213|      4|        .await?;
                            ^1
  214|      3|    Ok(Json(QuotaConsumeResponse {
  215|      3|        scope: status.scope,
  216|      3|        remaining: status.remaining,
  217|      3|        limit: status.limit,
  218|      3|    }))
  219|      4|}
  220|       |
  221|       |/// `GET /v1/auth/quota` — remaining paid-scope quota for the bearer wallet.
  222|      2|pub async fn get_quota(
  223|      2|    State(state): State<AppState>,
  224|      2|    headers: HeaderMap,
  225|      2|) -> Result<Json<QuotaStatusResponse>, AppError> {
  226|      2|    let claims = verified_claims(&state, &headers)?;
                                                                ^0
  227|      2|    let scopes: Vec<&str> = claims.scope.split_whitespace().collect();
  228|      2|    let statuses = state.quota.status_for_scopes(&claims.sub, &scopes).await?;
                                                                                          ^0
  229|       |    Ok(Json(QuotaStatusResponse {
  230|      2|        scopes: statuses
  231|      2|            .into_iter()
  232|      2|            .map(|s| QuotaConsumeResponse {
  233|      2|                scope: s.scope,
  234|      2|                remaining: s.remaining,
  235|      2|                limit: s.limit,
  236|      2|            })
  237|      2|            .collect(),
  238|       |    }))
  239|      2|}
  240|       |
  241|       |/// `POST /v1/auth/logout` — revoke the bearer access token's `jti`.
  242|      2|pub async fn post_logout(
  243|      2|    State(state): State<AppState>,
  244|      2|    headers: HeaderMap,
  245|      2|) -> Result<StatusCode, AppError> {
  246|      2|    let token = bearer_token(&headers)?;
                      ^1                            ^1
  247|      1|    let claims = state.token.verify(&token)?;
                                                         ^0
  248|      1|    state.revocation.revoke_access_token(&claims).await?;
                                                                     ^0
  249|      1|    Ok(StatusCode::NO_CONTENT)
  250|      2|}
  251|       |
  252|      6|fn verified_claims(state: &AppState, headers: &HeaderMap) -> Result<AccessTokenClaims, AppError> {
  253|      6|    let token = bearer_token(headers)?;
                                                   ^0
  254|      6|    state.token.verify(&token)
  255|      6|}
  256|       |
  257|      4|fn ensure_scope(claims: &AccessTokenClaims, scope: &str) -> Result<(), AppError> {
  258|      4|    if claims.scope.split_whitespace().any(|s| s == scope) {
  259|      4|        Ok(())
  260|       |    } else {
  261|      0|        Err(AppError::InsufficientScope(format!(
  262|      0|            "token does not grant scope {scope}"
  263|      0|        )))
  264|       |    }
  265|      4|}
  266|       |
  267|      8|fn bearer_token(headers: &HeaderMap) -> Result<String, AppError> {
  268|      8|    let value = headers
                      ^7
  269|      8|        .get(axum::http::header::AUTHORIZATION)
  270|      8|        .and_then(|v| v.to_str().ok())
                                    ^7^7       ^7
  271|      8|        .ok_or_else(|| AppError::InvalidRequest("missing Authorization header".into()))?;
                                                              ^1                             ^1      ^1
  272|      7|    let token = value
  273|      7|        .strip_prefix("Bearer ")
  274|      7|        .or_else(|| value.strip_prefix("bearer "))
                                  ^0    ^0
  275|      7|        .ok_or_else(|| AppError::InvalidRequest("Authorization must be Bearer".into()))?;
                                                              ^0                             ^0      ^0
  276|      7|    if token.trim().is_empty() {
  277|      0|        return Err(AppError::InvalidRequest("missing bearer token".into()));
  278|      7|    }
  279|      7|    Ok(token.trim().to_string())
  280|      8|}
  281|       |
  282|     96|fn client_ip_from_headers(headers: &HeaderMap) -> Option<String> {
  283|     96|    headers
  284|     96|        .get("x-forwarded-for")
  285|     96|        .and_then(|v| v.to_str().ok())
                                    ^2^2       ^2
  286|     96|        .and_then(|v| v.split(',').next())
                                    ^2           ^2
  287|     96|        .map(str::trim)
  288|     96|        .map(str::to_string)
  289|     96|        .or_else(|| Some("127.0.0.1".into()))
                                       ^94         ^94
  290|     96|}

/home/runner/work/trust-relay/trust-relay/src/routes/health.rs:
    1|       |//! Liveness endpoint.
    2|       |
    3|       |use axum::{http::StatusCode, response::IntoResponse, Json};
    4|       |use serde_json::json;
    5|       |
    6|      4|pub async fn healthz() -> impl IntoResponse {
    7|      4|    (StatusCode::OK, Json(json!({ "status": "ok" })))
    8|      4|}

/home/runner/work/trust-relay/trust-relay/src/routes/jwks.rs:
    1|       |//! JWKS publication for resource-server JWT verification.
    2|       |
    3|       |use axum::{extract::State, Json};
    4|       |use serde_json::Value;
    5|       |
    6|       |use crate::state::AppState;
    7|       |
    8|      1|pub async fn jwks(State(state): State<AppState>) -> Json<Value> {
    9|      1|    Json(state.token.jwks())
   10|      1|}

/home/runner/work/trust-relay/trust-relay/src/routes/mod.rs:
    1|       |//! Router construction.
    2|       |
    3|       |mod auth;
    4|       |mod health;
    5|       |mod jwks;
    6|       |
    7|       |use axum::{routing::get, routing::post, Router};
    8|       |use tower_http::{cors::CorsLayer, limit::RequestBodyLimitLayer, trace::TraceLayer};
    9|       |
   10|       |use crate::{config::ExposeMode, state::AppState};
   11|       |
   12|       |/// Max request body size for auth/session routes (POST JSON). Not applied to `/healthz`.
   13|       |pub const MAX_BODY_BYTES: usize = 64 * 1024;
   14|       |
   15|       |/// Liveness and JWKS — exposed on `public` and `all` deploys.
   16|     24|fn health_and_jwks_routes(state: AppState) -> Router {
   17|     24|    Router::new()
   18|     24|        .route("/healthz", get(health::healthz))
   19|     24|        .route("/.well-known/jwks.json", get(jwks::jwks))
   20|     24|        .with_state(state)
   21|     24|}
   22|       |
   23|       |/// Wallet SIWE ceremony and session lifecycle — internet-facing.
   24|     23|fn wallet_auth_routes(state: AppState) -> Router {
   25|     23|    Router::new()
   26|     23|        .route("/v1/auth/nonce", get(auth::get_nonce))
   27|     23|        .route("/v1/auth/session", post(auth::post_session))
   28|     23|        .route("/v1/auth/session/refresh", post(auth::post_session_refresh))
   29|     23|        .route("/v1/auth/logout", post(auth::post_logout))
   30|     23|        .route("/v1/auth/quota", get(auth::get_quota))
   31|     23|        .with_state(state)
   32|     23|        .layer(RequestBodyLimitLayer::new(MAX_BODY_BYTES))
   33|     23|}
   34|       |
   35|       |/// Resource-server helpers — VPC / internal ingress only in production.
   36|     23|fn internal_routes(state: AppState) -> Router {
   37|     23|    Router::new()
   38|     23|        .route("/v1/auth/quota/consume", post(auth::post_quota_consume))
   39|     23|        .with_state(state)
   40|     23|        .layer(RequestBodyLimitLayer::new(MAX_BODY_BYTES))
   41|     23|}
   42|       |
   43|     24|fn apply_global_layers(router: Router) -> Router {
   44|     24|    router
   45|     24|        .layer(TraceLayer::new_for_http())
   46|     24|        .layer(CorsLayer::permissive())
   47|     24|}
   48|       |
   49|       |/// Build the HTTP router for `cfg.server.expose`.
   50|     22|pub fn create_router(state: AppState) -> Router {
   51|     22|    let expose = state.config.server.expose;
   52|     22|    create_router_for_expose(state, expose)
   53|     22|}
   54|       |
   55|       |/// Build the HTTP router for an explicit exposure mode (tests and docs).
   56|     24|pub fn create_router_for_expose(state: AppState, expose: ExposeMode) -> Router {
   57|     24|    let health_state = state.clone();
   58|     24|    let router = match expose {
   59|     22|        ExposeMode::All => Router::new()
   60|     22|            .merge(health_and_jwks_routes(health_state.clone()))
   61|     22|            .merge(wallet_auth_routes(state.clone()))
   62|     22|            .merge(internal_routes(state)),
   63|      1|        ExposeMode::Public => Router::new()
   64|      1|            .merge(health_and_jwks_routes(health_state))
   65|      1|            .merge(wallet_auth_routes(state)),
   66|      1|        ExposeMode::Internal => Router::new()
   67|      1|            .merge(health_and_jwks_routes(health_state))
   68|      1|            .merge(internal_routes(state)),
   69|       |    };
   70|     24|    apply_global_layers(router)
   71|     24|}
   72|       |
   73|       |/// Integration-test router with in-memory stores (or Redis when `APP_REDIS__URL` is set).
   74|      9|pub async fn test_router() -> Router {
   75|      9|    let config = crate::config::Config::load().expect("test config should load");
   76|      9|    test_router_with_config(config).await
   77|      9|}
   78|       |
   79|       |/// Test helper: router with explicit config (`server.expose` respected).
   80|     12|pub async fn test_router_with_config(config: crate::config::Config) -> Router {
   81|     12|    let state = AppState::build(config)
   82|     12|        .await
   83|     12|        .expect("test app state should build");
   84|     12|    create_router(state)
   85|     12|}

/home/runner/work/trust-relay/trust-relay/src/server.rs:
    1|       |//! Server bootstrap — bind, serve, and run entry used by the binary.
    2|       |
    3|       |use std::net::SocketAddr;
    4|       |
    5|       |use tokio::net::TcpListener;
    6|       |
    7|       |use crate::{config::Config, routes, state::AppState, telemetry};
    8|       |
    9|       |/// Load config, init telemetry, bind, and serve until shutdown.
   10|      1|pub async fn run(cfg: Config) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
   11|      1|    telemetry::init(&cfg.telemetry);
   12|      1|    let state = AppState::build(cfg).await?;
                                                        ^0
   13|      1|    let listener = bind(&state.config).await?;
                                                          ^0
   14|      1|    serve(listener, state).await
   15|      0|}
   16|       |
   17|       |/// Bind the listening socket described by `cfg`.
   18|      2|pub async fn bind(cfg: &Config) -> Result<TcpListener, Box<dyn std::error::Error + Send + Sync>> {
   19|      2|    let addr: SocketAddr = format!("{}:{}", cfg.server.host, cfg.server.port).parse()?;
                      ^1    ^1                                                                     ^1
   20|      1|    Ok(TcpListener::bind(addr).await?)
                                                  ^0
   21|      2|}
   22|       |
   23|       |/// Serve the HTTP router on an already-bound listener.
   24|      1|pub async fn serve(
   25|      1|    listener: TcpListener,
   26|      1|    state: AppState,
   27|      1|) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
   28|      1|    let addr = listener.local_addr()?;
                                                  ^0
   29|      1|    let expose = state.config.server.expose;
   30|      1|    tracing::info!(%addr, ?expose, "trust-relay listening");
   31|      1|    axum::serve(listener, routes::create_router(state)).await?;
                                                                           ^0
   32|      0|    Ok(())
   33|      0|}

/home/runner/work/trust-relay/trust-relay/src/services/heuristics.rs:
    1|       |//! Phase-0 wallet heuristics (first-seen vs established).
    2|       |
    3|       |use std::sync::Arc;
    4|       |
    5|       |use crate::{error::AppError, store::heuristics::HeuristicsStore};
    6|       |
    7|       |/// Phase-0 profile derived before minting paid-scope quotas.
    8|       |#[derive(Debug, Clone, Copy, PartialEq, Eq)]
    9|       |pub enum WalletProfile {
   10|       |    /// First successful session for this wallet.
   11|       |    New,
   12|       |    Established,
   13|       |}
   14|       |
   15|       |pub struct HeuristicsService {
   16|       |    store: Arc<dyn HeuristicsStore>,
   17|       |}
   18|       |
   19|       |impl HeuristicsService {
   20|     25|    pub fn new(store: Arc<dyn HeuristicsStore>) -> Self {
   21|     25|        Self { store }
   22|     25|    }
   23|       |
   24|     10|    pub async fn profile_for(&self, wallet: &str) -> Result<WalletProfile, AppError> {
   25|     10|        let wallet = normalize_wallet(wallet)?;
                                                           ^0
   26|     10|        if self.store.is_established(&wallet).await? {
                                                                 ^0
   27|      6|            Ok(WalletProfile::Established)
   28|       |        } else {
   29|      4|            Ok(WalletProfile::New)
   30|       |        }
   31|     10|    }
   32|       |
   33|     10|    pub async fn mark_established(&self, wallet: &str) -> Result<(), AppError> {
   34|     10|        let wallet = normalize_wallet(wallet)?;
                                                           ^0
   35|     10|        self.store.mark_established(&wallet).await?;
                                                                ^0
   36|     10|        Ok(())
   37|     10|    }
   38|       |}
   39|       |
   40|     20|fn normalize_wallet(wallet: &str) -> Result<String, AppError> {
   41|     20|    let w = wallet.trim().to_ascii_lowercase();
   42|     20|    if !w.starts_with("0x") || w.len() != 42 {
   43|      0|        return Err(AppError::InvalidRequest(
   44|      0|            "wallet address must be 0x-prefixed 20-byte hex".into(),
   45|      0|        ));
   46|     20|    }
   47|    800|    if !w[2..].chars().all(|c| c.is_ascii_hexdigit()) {
                      ^20            ^20
   48|      0|        return Err(AppError::InvalidRequest(
   49|      0|            "wallet address must be hex".into(),
   50|      0|        ));
   51|     20|    }
   52|     20|    Ok(w)
   53|     20|}

/home/runner/work/trust-relay/trust-relay/src/services/nonce.rs:
    1|       |//! Single-use nonce generation and validation.
    2|       |
    3|       |use std::sync::Arc;
    4|       |
    5|       |use time::OffsetDateTime;
    6|       |use uuid::Uuid;
    7|       |
    8|       |use crate::{
    9|       |    config::AuthConfig,
   10|       |    error::AppError,
   11|       |    store::nonce::{NonceRecord, NonceStore},
   12|       |};
   13|       |
   14|       |#[derive(Debug, Clone)]
   15|       |pub struct IssuedNonce {
   16|       |    pub nonce: String,
   17|       |    pub expires_at: OffsetDateTime,
   18|       |}
   19|       |
   20|       |pub struct NonceService {
   21|       |    store: Arc<dyn NonceStore>,
   22|       |    config: AuthConfig,
   23|       |}
   24|       |
   25|       |impl NonceService {
   26|     28|    pub fn new(store: Arc<dyn NonceStore>, config: AuthConfig) -> Self {
   27|     28|        Self { store, config }
   28|     28|    }
   29|       |
   30|     48|    pub async fn issue(&self, client_ip: Option<String>) -> Result<IssuedNonce, AppError> {
   31|     48|        let nonce = Uuid::new_v4().to_string();
   32|     48|        let issued_at = OffsetDateTime::now_utc();
   33|     48|        let expires_at = issued_at + time::Duration::seconds(self.config.nonce_ttl_secs as i64);
   34|     48|        let record = NonceRecord {
   35|     48|            issued_at,
   36|     48|            expires_at,
   37|     48|            client_ip,
   38|     48|            used: false,
   39|     48|        };
   40|     48|        self.store.put(&nonce, &record).await?;
                                                           ^0
   41|     48|        Ok(IssuedNonce { nonce, expires_at })
   42|     48|    }
   43|       |
   44|     15|    pub async fn validate_and_consume(&self, nonce: &str) -> Result<(), AppError> {
   45|     15|        let record = self
                          ^14
   46|     15|            .store
   47|     15|            .get(nonce)
   48|     15|            .await?
                                ^0
   49|     15|            .ok_or_else(|| AppError::InvalidNonce("unknown nonce".into()))?;
                                                                ^1              ^1      ^1
   50|       |
   51|     14|        if record.used {
   52|      2|            return Err(AppError::NonceUsed("nonce already used".into()));
   53|     12|        }
   54|       |
   55|     12|        let now = OffsetDateTime::now_utc();
   56|     12|        if now >= record.expires_at {
   57|      1|            return Err(AppError::NonceExpired("nonce expired".into()));
   58|     11|        }
   59|       |
   60|     11|        self.store.mark_used(nonce).await?;
                                                       ^0
   61|     11|        Ok(())
   62|     15|    }
   63|       |}
   64|       |
   65|       |#[cfg(test)]
   66|       |mod tests {
   67|       |    use std::sync::Arc;
   68|       |
   69|       |    use super::*;
   70|       |    use crate::config::AuthConfig;
   71|       |    use crate::store::nonce::InMemoryNonceStore;
   72|       |
   73|      3|    fn test_auth_config() -> AuthConfig {
   74|      3|        AuthConfig {
   75|      3|            nonce_ttl_secs: 120,
   76|      3|            domain: "localhost".into(),
   77|      3|            uri: "http://localhost".into(),
   78|      3|            chain_id: 324,
   79|      3|            default_scopes: vec!["ai:invoke".into()],
   80|      3|            refresh_token_ttl_secs: 604_800,
   81|      3|            issue_refresh_tokens: true,
   82|      3|        }
   83|      3|    }
   84|       |
   85|       |    #[tokio::test]
   86|      1|    async fn consume_rejects_replay() {
   87|      1|        let store = Arc::new(InMemoryNonceStore::default());
   88|      1|        let svc = NonceService::new(store, test_auth_config());
   89|      1|        let issued = svc.issue(None).await.unwrap();
   90|      1|        svc.validate_and_consume(&issued.nonce).await.unwrap();
   91|      1|        let err = svc.validate_and_consume(&issued.nonce).await.unwrap_err();
   92|      1|        assert!(matches!(err, AppError::NonceUsed(_)));
                              ^0
   93|      1|    }
   94|       |
   95|       |    #[tokio::test]
   96|      1|    async fn consume_rejects_unknown_nonce() {
   97|      1|        let store = Arc::new(InMemoryNonceStore::default());
   98|      1|        let svc = NonceService::new(store, test_auth_config());
   99|      1|        let err = svc.validate_and_consume("missing").await.unwrap_err();
  100|      1|        assert!(matches!(err, AppError::InvalidNonce(_)));
                              ^0
  101|      1|    }
  102|       |
  103|       |    #[tokio::test]
  104|      1|    async fn consume_rejects_expired_nonce() {
  105|      1|        let store = Arc::new(InMemoryNonceStore::default());
  106|      1|        let svc = NonceService::new(store.clone(), test_auth_config());
  107|      1|        let issued = svc.issue(None).await.unwrap();
  108|      1|        let past = OffsetDateTime::now_utc() - time::Duration::hours(1);
  109|      1|        let record = NonceRecord {
  110|      1|            issued_at: past - time::Duration::minutes(2),
  111|      1|            expires_at: past,
  112|      1|            client_ip: None,
  113|      1|            used: false,
  114|      1|        };
  115|      1|        store.put(&issued.nonce, &record).await.unwrap();
  116|      1|        let err = svc.validate_and_consume(&issued.nonce).await.unwrap_err();
  117|      1|        assert!(matches!(err, AppError::NonceExpired(_)));
                              ^0
  118|      1|    }
  119|       |}

/home/runner/work/trust-relay/trust-relay/src/services/quota.rs:
    1|       |//! Per-wallet quota counters for paid scopes.
    2|       |
    3|       |use std::collections::HashMap;
    4|       |use std::sync::Arc;
    5|       |
    6|       |use time::OffsetDateTime;
    7|       |
    8|       |use crate::{
    9|       |    config::QuotaConfig,
   10|       |    error::AppError,
   11|       |    services::heuristics::WalletProfile,
   12|       |    store::quota::{current_window_id, QuotaStore},
   13|       |};
   14|       |
   15|       |pub struct QuotaService {
   16|       |    store: Arc<dyn QuotaStore>,
   17|       |    config: QuotaConfig,
   18|       |}
   19|       |
   20|       |#[derive(Debug)]
   21|       |pub struct QuotaStatus {
   22|       |    pub scope: String,
   23|       |    pub remaining: u64,
   24|       |    pub limit: u64,
   25|       |}
   26|       |
   27|       |impl QuotaService {
   28|     26|    pub fn new(store: Arc<dyn QuotaStore>, config: QuotaConfig) -> Self {
   29|     26|        Self { store, config }
   30|     26|    }
   31|       |
   32|     22|    pub fn enabled(&self) -> bool {
   33|     22|        self.config.enabled
   34|     22|    }
   35|       |
   36|       |    /// Initialize quota buckets for paid scopes using heuristic tier limits.
   37|     13|    pub async fn init_for_wallet(
   38|     13|        &self,
   39|     13|        wallet: &str,
   40|     13|        profile: WalletProfile,
   41|     13|    ) -> Result<(), AppError> {
   42|     13|        if !self.enabled() {
   43|      0|            return Ok(());
   44|     13|        }
   45|     13|        let wallet = normalize_wallet(wallet)?;
                                                           ^0
   46|     13|        let limits = limits_for_profile(&self.config, profile);
   47|     13|        let window_id = self.window_id();
   48|     13|        let ttl = self.config.window_secs as u64;
   49|     21|        for scope in &self.config.paid_scopes {
                                   ^13
   50|     21|            let limit = limits.get(scope).copied().unwrap_or(0);
   51|     21|            if limit == 0 {
   52|      0|                continue;
   53|     21|            }
   54|     21|            self.store
   55|     21|                .init_scope(&wallet, scope, window_id, limit, ttl)
   56|     21|                .await?;
                                    ^0
   57|       |        }
   58|     13|        Ok(())
   59|     13|    }
   60|       |
   61|      7|    pub async fn consume(
   62|      7|        &self,
   63|      7|        wallet: &str,
   64|      7|        scope: &str,
   65|      7|        amount: u64,
   66|      7|    ) -> Result<QuotaStatus, AppError> {
   67|      7|        if !self.enabled() {
   68|      0|            return Err(AppError::InvalidRequest(
   69|      0|                "quota enforcement is disabled".into(),
   70|      0|            ));
   71|      7|        }
   72|      7|        if !self.config.paid_scopes.iter().any(|s| s == scope) {
   73|      0|            return Err(AppError::InvalidRequest(format!(
   74|      0|                "scope {scope} is not quota-gated"
   75|      0|            )));
   76|      7|        }
   77|      7|        let wallet = normalize_wallet(wallet)?;
                                                           ^0
   78|      7|        let window_id = self.window_id();
   79|      7|        let bucket = self
                          ^5
   80|      7|            .store
   81|      7|            .try_consume(&wallet, scope, window_id, amount.max(1))
   82|      7|            .await?;
                                ^2
   83|      5|        Ok(QuotaStatus {
   84|      5|            scope: scope.to_string(),
   85|      5|            remaining: bucket.limit.saturating_sub(bucket.used),
   86|      5|            limit: bucket.limit,
   87|      5|        })
   88|      7|    }
   89|       |
   90|      2|    pub async fn status_for_scopes(
   91|      2|        &self,
   92|      2|        wallet: &str,
   93|      2|        scopes: &[&str],
   94|      2|    ) -> Result<Vec<QuotaStatus>, AppError> {
   95|      2|        if !self.enabled() {
   96|      0|            return Ok(Vec::new());
   97|      2|        }
   98|      2|        let wallet = normalize_wallet(wallet)?;
                                                           ^0
   99|      2|        let window_id = self.window_id();
  100|      2|        let mut out = Vec::new();
  101|      8|        for scope in scopes {
                                   ^2
  102|      8|            if !self.config.paid_scopes.iter().any(|s| s == scope) {
  103|      6|                continue;
  104|      2|            }
  105|      2|            if let Some(bucket) = self.store.get_bucket(&wallet, scope, window_id).await? {
                                                                                                      ^0
  106|      2|                out.push(QuotaStatus {
  107|      2|                    scope: scope.to_string(),
  108|      2|                    remaining: bucket.limit.saturating_sub(bucket.used),
  109|      2|                    limit: bucket.limit,
  110|      2|                });
  111|      2|            }
                          ^0
  112|       |        }
  113|      2|        Ok(out)
  114|      2|    }
  115|       |
  116|     22|    fn window_id(&self) -> u64 {
  117|     22|        let now = OffsetDateTime::now_utc().unix_timestamp();
  118|     22|        current_window_id(now, self.config.window_secs as u64)
  119|     22|    }
  120|       |}
  121|       |
  122|     13|fn limits_for_profile(config: &QuotaConfig, profile: WalletProfile) -> &HashMap<String, u64> {
  123|     13|    match profile {
  124|      5|        WalletProfile::New => &config.limits.new_wallet,
  125|      8|        WalletProfile::Established => &config.limits.established,
  126|       |    }
  127|     13|}
  128|       |
  129|     22|fn normalize_wallet(wallet: &str) -> Result<String, AppError> {
  130|     22|    let w = wallet.trim().to_ascii_lowercase();
  131|     22|    if !w.starts_with("0x") || w.len() != 42 {
  132|      0|        return Err(AppError::InvalidRequest(
  133|      0|            "wallet address must be 0x-prefixed 20-byte hex".into(),
  134|      0|        ));
  135|     22|    }
  136|    880|    if !w[2..].chars().all(|c| c.is_ascii_hexdigit()) {
                      ^22            ^22
  137|      0|        return Err(AppError::InvalidRequest(
  138|      0|            "wallet address must be hex".into(),
  139|      0|        ));
  140|     22|    }
  141|     22|    Ok(w)
  142|     22|}
  143|       |
  144|       |#[cfg(test)]
  145|       |mod tests {
  146|       |    use super::*;
  147|       |    use crate::config::QuotaLimitTiers;
  148|       |    use crate::store::quota::InMemoryQuotaStore;
  149|       |
  150|      1|    fn test_config() -> QuotaConfig {
  151|      1|        let mut new_wallet = HashMap::new();
  152|      1|        new_wallet.insert("ai:invoke".into(), 2);
  153|      1|        let mut established = HashMap::new();
  154|      1|        established.insert("ai:invoke".into(), 100);
  155|      1|        QuotaConfig {
  156|      1|            enabled: true,
  157|      1|            window_secs: 3600,
  158|      1|            paid_scopes: vec!["ai:invoke".into()],
  159|      1|            limits: QuotaLimitTiers {
  160|      1|                new_wallet,
  161|      1|                established,
  162|      1|            },
  163|      1|        }
  164|      1|    }
  165|       |
  166|       |    #[tokio::test]
  167|      1|    async fn new_wallet_gets_lower_limit() {
  168|      1|        let store = Arc::new(InMemoryQuotaStore::default());
  169|      1|        let svc = QuotaService::new(store, test_config());
  170|      1|        svc.init_for_wallet(
  171|      1|            "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266",
  172|      1|            WalletProfile::New,
  173|      1|        )
  174|      1|        .await
  175|      1|        .unwrap();
  176|      1|        let s1 = svc
  177|      1|            .consume("0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266", "ai:invoke", 1)
  178|      1|            .await
  179|      1|            .unwrap();
  180|      1|        assert_eq!(s1.remaining, 1);
  181|      1|        let _ = svc
  182|      1|            .consume("0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266", "ai:invoke", 1)
  183|      1|            .await
  184|      1|            .unwrap();
  185|      1|        let err = svc
  186|      1|            .consume("0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266", "ai:invoke", 1)
  187|      1|            .await
  188|      1|            .unwrap_err();
  189|      1|        assert!(matches!(err, AppError::QuotaExceeded(_)));
                              ^0
  190|      1|    }
  191|       |}

/home/runner/work/trust-relay/trust-relay/src/services/refresh.rs:
    1|       |//! Opaque refresh-token issuance, rotation, and reuse detection.
    2|       |
    3|       |use std::sync::Arc;
    4|       |
    5|       |use base64::Engine;
    6|       |use rand::RngCore;
    7|       |use time::OffsetDateTime;
    8|       |use uuid::Uuid;
    9|       |
   10|       |use crate::{
   11|       |    config::AuthConfig,
   12|       |    error::AppError,
   13|       |    store::refresh::{hash_refresh_token, RefreshLookup, RefreshRecord, RefreshStore},
   14|       |};
   15|       |
   16|       |pub struct RefreshService {
   17|       |    store: Arc<dyn RefreshStore>,
   18|       |    auth: AuthConfig,
   19|       |}
   20|       |
   21|       |#[derive(Debug)]
   22|       |pub struct IssuedRefresh {
   23|       |    pub token: String,
   24|       |}
   25|       |
   26|       |#[derive(Debug)]
   27|       |pub struct RotatedRefresh {
   28|       |    pub token: String,
   29|       |    pub record: RefreshRecord,
   30|       |}
   31|       |
   32|       |impl RefreshService {
   33|     26|    pub fn new(store: Arc<dyn RefreshStore>, auth: AuthConfig) -> Self {
   34|     26|        Self { store, auth }
   35|     26|    }
   36|       |
   37|     14|    pub fn enabled(&self) -> bool {
   38|     14|        self.auth.issue_refresh_tokens
   39|     14|    }
   40|       |
   41|       |    /// Mint a new refresh token for a wallet session (rotation counter 0).
   42|     11|    pub async fn issue(
   43|     11|        &self,
   44|     11|        wallet: &str,
   45|     11|        scopes: &[String],
   46|     11|        chain_id: u64,
   47|     11|    ) -> Result<IssuedRefresh, AppError> {
   48|     11|        let wallet = normalize_wallet(wallet)?;
                                                           ^0
   49|     11|        let now = OffsetDateTime::now_utc().unix_timestamp();
   50|     11|        let ttl = i64::from(self.auth.refresh_token_ttl_secs);
   51|     11|        let record = RefreshRecord {
   52|     11|            family_id: Uuid::new_v4().to_string(),
   53|     11|            wallet,
   54|     11|            rotation: 0,
   55|     11|            scopes: scopes.to_vec(),
   56|     11|            chain_id,
   57|     11|            expires_at: now + ttl,
   58|     11|        };
   59|     11|        let token = generate_opaque_token();
   60|     11|        let hash = hash_refresh_token(&token);
   61|     11|        self.store
   62|     11|            .put_token(&hash, &record, self.auth.refresh_token_ttl_secs as u64)
   63|     11|            .await?;
                                ^0
   64|     11|        Ok(IssuedRefresh { token })
   65|     11|    }
   66|       |
   67|       |    /// Validate a refresh token and rotate it (new opaque secret, incremented rotation).
   68|      7|    pub async fn rotate(&self, raw_token: &str) -> Result<RotatedRefresh, AppError> {
   69|      7|        let raw_token = raw_token.trim();
   70|      7|        if raw_token.is_empty() {
   71|      0|            return Err(AppError::InvalidRequest("refreshToken is required".into()));
   72|      7|        }
   73|      7|        let hash = hash_refresh_token(raw_token);
   74|      7|        let lookup = self.lookup(&hash).await?;
                                                           ^0
   75|       |
   76|      7|        let record = match lookup {
                          ^3
   77|      3|            RefreshLookup::Active(record) => {
   78|      3|                if record.expires_at <= OffsetDateTime::now_utc().unix_timestamp() {
   79|      0|                    return Err(AppError::InvalidToken("refresh token expired".into()));
   80|      3|                }
   81|      3|                record
   82|       |            }
   83|      2|            RefreshLookup::Reuse { family_id } => {
   84|      2|                self.store
   85|      2|                    .revoke_family(&family_id, self.auth.refresh_token_ttl_secs as u64)
   86|      2|                    .await?;
                                        ^0
   87|      2|                return Err(AppError::InvalidToken(
   88|      2|                    "refresh token reuse detected; session family revoked".into(),
   89|      2|                ));
   90|       |            }
   91|       |            RefreshLookup::Unknown => {
   92|      2|                return Err(AppError::InvalidToken("refresh token invalid".into()));
   93|       |            }
   94|       |        };
   95|       |
   96|      3|        self.store.delete_token(&hash).await?;
                                                          ^0
   97|      3|        self.store
   98|      3|            .mark_replay(
   99|      3|                &hash,
  100|      3|                &record.family_id,
  101|      3|                self.auth.refresh_token_ttl_secs as u64,
  102|      3|            )
  103|      3|            .await?;
                                ^0
  104|       |
  105|      3|        let mut next = record.clone();
  106|      3|        next.rotation = next.rotation.saturating_add(1);
  107|      3|        next.expires_at = OffsetDateTime::now_utc().unix_timestamp()
  108|      3|            + i64::from(self.auth.refresh_token_ttl_secs);
  109|       |
  110|      3|        let new_token = generate_opaque_token();
  111|      3|        let new_hash = hash_refresh_token(&new_token);
  112|      3|        self.store
  113|      3|            .put_token(&new_hash, &next, self.auth.refresh_token_ttl_secs as u64)
  114|      3|            .await?;
                                ^0
  115|       |
  116|      3|        Ok(RotatedRefresh {
  117|      3|            token: new_token,
  118|      3|            record: next,
  119|      3|        })
  120|      7|    }
  121|       |
  122|      7|    async fn lookup(&self, token_hash: &str) -> Result<RefreshLookup, AppError> {
  123|      7|        if let Some(family_id) = self.store.is_replay(token_hash).await? {
                                  ^2                                                 ^0
  124|      2|            return Ok(RefreshLookup::Reuse { family_id });
  125|      5|        }
  126|      5|        if let Some(record) = self.store.get_token(token_hash).await? {
                                                                                  ^0
  127|      5|            if self.store.is_family_revoked(&record.family_id).await? {
                                                                                  ^0
  128|      2|                return Ok(RefreshLookup::Unknown);
  129|      3|            }
  130|      3|            return Ok(RefreshLookup::Active(record));
  131|      0|        }
  132|      0|        Ok(RefreshLookup::Unknown)
  133|      7|    }
  134|       |}
  135|       |
  136|     14|fn generate_opaque_token() -> String {
  137|     14|    let mut bytes = [0u8; 32];
  138|     14|    rand::rngs::OsRng.fill_bytes(&mut bytes);
  139|     14|    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
  140|     14|}
  141|       |
  142|     11|fn normalize_wallet(wallet: &str) -> Result<String, AppError> {
  143|     11|    let w = wallet.trim().to_ascii_lowercase();
  144|     11|    if !w.starts_with("0x") || w.len() != 42 {
  145|      0|        return Err(AppError::InvalidRequest(
  146|      0|            "wallet address must be 0x-prefixed 20-byte hex".into(),
  147|      0|        ));
  148|     11|    }
  149|    440|    if !w[2..].chars().all(|c| c.is_ascii_hexdigit()) {
                      ^11            ^11
  150|      0|        return Err(AppError::InvalidRequest(
  151|      0|            "wallet address must be hex".into(),
  152|      0|        ));
  153|     11|    }
  154|     11|    Ok(w)
  155|     11|}
  156|       |
  157|       |#[cfg(test)]
  158|       |mod tests {
  159|       |    use super::*;
  160|       |    use crate::store::refresh::InMemoryRefreshStore;
  161|       |
  162|      1|    fn test_auth() -> AuthConfig {
  163|      1|        AuthConfig {
  164|      1|            nonce_ttl_secs: 120,
  165|      1|            domain: "localhost".into(),
  166|      1|            uri: "http://localhost:3001".into(),
  167|      1|            chain_id: 324,
  168|      1|            default_scopes: vec!["ai:invoke".into()],
  169|      1|            refresh_token_ttl_secs: 3600,
  170|      1|            issue_refresh_tokens: true,
  171|      1|        }
  172|      1|    }
  173|       |
  174|       |    #[tokio::test]
  175|      1|    async fn rotate_rejects_replay_of_rotated_token() {
  176|      1|        let store = Arc::new(InMemoryRefreshStore::default());
  177|      1|        let svc = RefreshService::new(store, test_auth());
  178|      1|        let issued = svc
  179|      1|            .issue(
  180|      1|                "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266",
  181|      1|                &["ai:invoke".into()],
  182|      1|                324,
  183|      1|            )
  184|      1|            .await
  185|      1|            .unwrap();
  186|      1|        let rotated = svc.rotate(&issued.token).await.unwrap();
  187|      1|        let err = svc.rotate(&issued.token).await.unwrap_err();
  188|      1|        assert!(matches!(err, AppError::InvalidToken(_)));
                              ^0
  189|       |        // Family revoked on reuse — the legitimately rotated token must not work either.
  190|      1|        let err = svc.rotate(&rotated.token).await.unwrap_err();
  191|      1|        assert!(matches!(err, AppError::InvalidToken(_)));
                              ^0
  192|      1|    }
  193|       |}

/home/runner/work/trust-relay/trust-relay/src/services/revocation.rs:
    1|       |//! Session revocation (`jti` denylist) and wallet blocklist.
    2|       |
    3|       |use std::sync::Arc;
    4|       |
    5|       |use time::OffsetDateTime;
    6|       |
    7|       |use crate::{
    8|       |    error::AppError,
    9|       |    models::claims::AccessTokenClaims,
   10|       |    store::revocation::{normalize_ttl_secs, RevocationStore},
   11|       |};
   12|       |
   13|       |pub struct RevocationService {
   14|       |    store: Arc<dyn RevocationStore>,
   15|       |}
   16|       |
   17|       |impl RevocationService {
   18|     26|    pub fn new(store: Arc<dyn RevocationStore>) -> Self {
   19|     26|        Self { store }
   20|     26|    }
   21|       |
   22|       |    /// Reject onboarding when the wallet is on the blocklist.
   23|     15|    pub async fn ensure_wallet_active(&self, wallet: &str) -> Result<(), AppError> {
   24|     15|        let wallet = normalize_wallet(wallet)?;
                                                           ^0
   25|     15|        if self.store.is_wallet_blocked(&wallet).await? {
                                                                    ^0
   26|      2|            return Err(AppError::WalletBlocked(
   27|      2|                "wallet is blocked from new sessions".into(),
   28|      2|            ));
   29|     13|        }
   30|     13|        Ok(())
   31|     15|    }
   32|       |
   33|       |    /// Denylist the access token's `jti` until its `exp` (logout / admin revoke).
   34|      1|    pub async fn revoke_access_token(&self, claims: &AccessTokenClaims) -> Result<(), AppError> {
   35|      1|        let now = OffsetDateTime::now_utc().unix_timestamp();
   36|      1|        let ttl = normalize_ttl_secs(claims.exp - now);
   37|      1|        self.store.revoke_jti(&claims.jti, ttl).await?;
                                                                   ^0
   38|      1|        Ok(())
   39|      1|    }
   40|       |
   41|      1|    pub async fn is_jti_revoked(&self, jti: &str) -> Result<bool, AppError> {
   42|      1|        Ok(self.store.is_jti_revoked(jti).await?)
                                                             ^0
   43|      1|    }
   44|       |
   45|       |    /// Block a wallet from obtaining new sessions (`ttl_secs == 0` = no expiry).
   46|      2|    pub async fn block_wallet(&self, wallet: &str, ttl_secs: u64) -> Result<(), AppError> {
   47|      2|        let wallet = normalize_wallet(wallet)?;
                                                           ^0
   48|      2|        self.store.block_wallet(&wallet, ttl_secs).await?;
                                                                      ^0
   49|      2|        Ok(())
   50|      2|    }
   51|       |}
   52|       |
   53|     17|fn normalize_wallet(wallet: &str) -> Result<String, AppError> {
   54|     17|    let w = wallet.trim().to_ascii_lowercase();
   55|     17|    if !w.starts_with("0x") || w.len() != 42 {
   56|      0|        return Err(AppError::InvalidRequest(
   57|      0|            "wallet address must be 0x-prefixed 20-byte hex".into(),
   58|      0|        ));
   59|     17|    }
   60|    680|    if !w[2..].chars().all(|c| c.is_ascii_hexdigit()) {
                      ^17            ^17
   61|      0|        return Err(AppError::InvalidRequest(
   62|      0|            "wallet address must be hex".into(),
   63|      0|        ));
   64|     17|    }
   65|     17|    Ok(w)
   66|     17|}
   67|       |
   68|       |#[cfg(test)]
   69|       |mod tests {
   70|       |    use super::*;
   71|       |    use crate::store::revocation::InMemoryRevocationStore;
   72|       |
   73|       |    #[tokio::test]
   74|      1|    async fn ensure_wallet_active_rejects_blocked() {
   75|      1|        let store = Arc::new(InMemoryRevocationStore::default());
   76|      1|        let svc = RevocationService::new(store.clone());
   77|      1|        svc.block_wallet("0xF39Fd6e51aad88F6F4ce6aB8827279cffFb92266", 0)
   78|      1|            .await
   79|      1|            .unwrap();
   80|      1|        let err = svc
   81|      1|            .ensure_wallet_active("0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266")
   82|      1|            .await
   83|      1|            .unwrap_err();
   84|      1|        assert!(matches!(err, AppError::WalletBlocked(_)));
                              ^0
   85|      1|    }
   86|       |}

/home/runner/work/trust-relay/trust-relay/src/services/siwe/eoa.rs:
    1|       |//! EOA EIP-191 personal-sign verification (ecrecover).
    2|       |
    3|       |use hex::FromHex;
    4|       |use k256::ecdsa::{RecoveryId, Signature, VerifyingKey};
    5|       |use sha3::{Digest, Keccak256};
    6|       |
    7|       |use crate::error::AppError;
    8|       |
    9|       |/// Decode a 65-byte hex EOA signature (`r||s||v`).
   10|     16|pub fn decode_eoa_signature(signature_hex: &str) -> Result<[u8; 65], AppError> {
   11|     16|    let hex_str = signature_hex
   12|     16|        .trim()
   13|     16|        .strip_prefix("0x")
   14|     16|        .unwrap_or(signature_hex.trim());
   15|     16|    if hex_str.is_empty() {
   16|      0|        return Err(AppError::InvalidRequest("signature is required".into()));
   17|     16|    }
   18|     16|    let sig = Vec::from_hex(hex_str)
   19|     16|        .map_err(|_| AppError::InvalidRequest("signature must be hex".into()))?;
                                                            ^0                      ^0      ^0
   20|     16|    sig.as_slice()
   21|     16|        .try_into()
   22|     16|        .map_err(|_| AppError::InvalidSignature("EOA signature must be 65 bytes".into()))
                                                              ^2                               ^2
   23|     16|}
   24|       |
   25|       |/// Verify EIP-191 signature over the **exact** SIWE message text the wallet signed.
   26|     20|pub fn verify_eip191(
   27|     20|    message_text: &str,
   28|     20|    signature: &[u8; 65],
   29|     20|    expected_address: &[u8; 20],
   30|     20|) -> Result<(), AppError> {
   31|     20|    let prehash = eip191_hash(message_text)?;
                                                         ^0
   32|     20|    let sig = Signature::from_slice(&signature[..64])
   33|     20|        .map_err(|e| AppError::InvalidSignature(format!("invalid signature: {e}")))?;
                                                              ^0                                 ^0
   34|     20|    let recovery_id = RecoveryId::try_from(signature[64] % 27)
   35|     20|        .map_err(|e| AppError::InvalidSignature(format!("invalid recovery id: {e}")))?;
                                                              ^0                                   ^0
   36|       |
   37|     20|    let pk = VerifyingKey::recover_from_prehash(&prehash, &sig, recovery_id)
   38|     20|        .map_err(|e| AppError::InvalidSignature(format!("recovery failed: {e}")))?;
                                                              ^0                               ^0
   39|       |
   40|     20|    let recovered = keccak256_address(&pk);
   41|     20|    if recovered != *expected_address {
   42|      3|        return Err(AppError::InvalidSignature(
   43|      3|            "recovered signer does not match message address".into(),
   44|      3|        ));
   45|     17|    }
   46|     17|    Ok(())
   47|     20|}
   48|       |
   49|     20|fn eip191_hash(message_text: &str) -> Result<[u8; 32], AppError> {
   50|     20|    let prehash = format!(
   51|       |        "\x19Ethereum Signed Message:\n{}{}",
   52|     20|        message_text.len(),
   53|       |        message_text
   54|       |    );
   55|     20|    Ok(Keccak256::digest(prehash.as_bytes()).into())
   56|     20|}
   57|       |
   58|     20|fn keccak256_address(pk: &VerifyingKey) -> [u8; 20] {
   59|     20|    let encoded = pk.to_encoded_point(false);
   60|     20|    let hash = Keccak256::new_with_prefix(&encoded.as_bytes()[1..]).finalize();
   61|     20|    hash[12..].try_into().expect("20-byte address")
   62|     20|}

/home/runner/work/trust-relay/trust-relay/src/services/siwe/message.rs:
    1|       |//! Parsed EIP-4361 SIWE message (internal representation).
    2|       |
    3|       |use sha3::Digest;
    4|       |use time::OffsetDateTime;
    5|       |
    6|       |/// Parsed Sign-In with Ethereum message fields used by trust-relay.
    7|       |#[derive(Debug, Clone, PartialEq, Eq)]
    8|       |pub struct SiweMessage {
    9|       |    /// Optional scheme from the preamble (`https://example.com wants...`).
   10|       |    pub scheme: Option<String>,
   11|       |    /// RFC 3986 authority (host[:port], optional userinfo).
   12|       |    pub domain: String,
   13|       |    pub address: [u8; 20],
   14|       |    pub statement: Option<String>,
   15|       |    pub uri: String,
   16|       |    pub chain_id: u64,
   17|       |    pub nonce: String,
   18|       |    pub issued_at: OffsetDateTime,
   19|       |    pub expiration_time: Option<OffsetDateTime>,
   20|       |    pub not_before: Option<OffsetDateTime>,
   21|       |    pub request_id: Option<String>,
   22|       |    pub resources: Vec<String>,
   23|       |}
   24|       |
   25|       |/// EIP-55 checksummed address (for comparisons with reference vectors).
   26|    111|pub fn eip55(addr: &[u8; 20]) -> String {
   27|    111|    let addr_str = hex::encode(addr);
   28|    111|    let hash = sha3::Keccak256::digest(addr_str.as_bytes());
   29|    111|    "0x".chars()
   30|  4.44k|        .chain(addr_str.chars().enumerate().map(|(i, c)| {
                       ^111  ^111             ^111        ^111
   31|  4.44k|            match (c, hash[i >> 1] & if i % 2 == 0 { 128 } else { 8 } != 0) {
                                                                   ^2.22k       ^2.22k
   32|  1.14k|                ('a'..='f' | 'A'..='F', true) => c.to_ascii_uppercase(),
                                           ^0
   33|  3.30k|                _ => c.to_ascii_lowercase(),
   34|       |            }
   35|  4.44k|        }))
   36|    111|        .collect()
   37|    111|}
   38|       |
   39|       |pub(super) const PREAMBLE: &str = " wants you to sign in with your Ethereum account:";
   40|       |pub(super) const URI_TAG: &str = "URI: ";
   41|       |pub(super) const VERSION_TAG: &str = "Version: ";
   42|       |pub(super) const CHAIN_TAG: &str = "Chain ID: ";
   43|       |pub(super) const NONCE_TAG: &str = "Nonce: ";
   44|       |pub(super) const IAT_TAG: &str = "Issued At: ";
   45|       |pub(super) const EXP_TAG: &str = "Expiration Time: ";
   46|       |pub(super) const NBF_TAG: &str = "Not Before: ";
   47|       |pub(super) const RID_TAG: &str = "Request ID: ";
   48|       |pub(super) const RES_TAG: &str = "Resources:";

/home/runner/work/trust-relay/trust-relay/src/services/siwe/mod.rs:
    1|       |//! EIP-4361 SIWE verification (in-house parser + EOA ecrecover).
    2|       |
    3|       |mod eoa;
    4|       |mod message;
    5|       |mod parse;
    6|       |mod policy;
    7|       |
    8|       |use time::OffsetDateTime;
    9|       |
   10|       |pub use eoa::{decode_eoa_signature, verify_eip191};
   11|       |pub use message::{eip55, SiweMessage};
   12|       |pub use parse::parse_siwe_message;
   13|       |pub use policy::{valid_at, validate_against_config, validate_time};
   14|       |
   15|       |use crate::{config::AuthConfig, error::AppError};
   16|       |
   17|       |/// Wallet address (`0x` + lowercase hex) and nonce after successful SIWE verification.
   18|       |#[derive(Debug, Clone, PartialEq, Eq)]
   19|       |pub struct VerifiedSiwe {
   20|       |    pub wallet_address: String,
   21|       |    pub nonce: String,
   22|       |}
   23|       |
   24|       |/// Parse and verify a SIWE message + EOA signature against configured domain, URI, and chain.
   25|     16|pub async fn verify_siwe_session(
   26|     16|    auth: &AuthConfig,
   27|     16|    message_str: &str,
   28|     16|    signature_hex: &str,
   29|     16|) -> Result<VerifiedSiwe, AppError> {
   30|     16|    let message_str = message_str.trim();
   31|     16|    let signature = eoa::decode_eoa_signature(signature_hex)?;
                      ^14                                                 ^2
   32|     14|    let message = parse::parse_siwe_message(message_str)?;
                                                                      ^0
   33|       |
   34|     14|    policy::validate_against_config(auth, &message)?;
                                                                 ^1
   35|     13|    policy::validate_time(&message, OffsetDateTime::now_utc())?;
                                                                            ^0
   36|       |
   37|     13|    eoa::verify_eip191(message_str, &signature, &message.address)?;
                                                                               ^0
   38|       |
   39|     13|    Ok(VerifiedSiwe {
   40|     13|        wallet_address: format!("0x{}", hex::encode(message.address)),
   41|     13|        nonce: message.nonce,
   42|     13|    })
   43|     16|}
   44|       |
   45|       |#[cfg(test)]
   46|       |mod tests {
   47|       |    use super::*;
   48|       |    use k256::ecdsa::SigningKey;
   49|       |    use sha3::{Digest, Keccak256};
   50|       |
   51|       |    const TEST_KEY: &str = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
   52|       |
   53|      3|    fn test_auth() -> AuthConfig {
   54|      3|        AuthConfig {
   55|      3|            nonce_ttl_secs: 120,
   56|      3|            domain: "localhost".into(),
   57|      3|            uri: "http://localhost:3001".into(),
   58|      3|            chain_id: 324,
   59|      3|            default_scopes: vec!["ai:invoke".into()],
   60|      3|            refresh_token_ttl_secs: 604_800,
   61|      3|            issue_refresh_tokens: true,
   62|      3|        }
   63|      3|    }
   64|       |
   65|      3|    fn build_message(domain: &str, uri: &str, chain_id: u64, nonce: &str) -> String {
   66|      3|        let now = OffsetDateTime::now_utc();
   67|      3|        let iat = now
   68|      3|            .format(&time::format_description::well_known::Rfc3339)
   69|      3|            .unwrap();
   70|      3|        format!(
   71|       |            "{domain} wants you to sign in with your Ethereum account:\n0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266\n\n\nURI: {uri}\nVersion: 1\nChain ID: {chain_id}\nNonce: {nonce}\nIssued At: {iat}"
   72|       |        )
   73|      3|    }
   74|       |
   75|      2|    fn sign_message(message: &str) -> String {
   76|      2|        let key_bytes = hex::decode(TEST_KEY.trim_start_matches("0x")).unwrap();
   77|      2|        let signing_key = SigningKey::from_bytes((&key_bytes[..]).into()).unwrap();
   78|      2|        let prehash = format!("\x19Ethereum Signed Message:\n{}{}", message.len(), message);
   79|      2|        let digest = Keccak256::digest(prehash.as_bytes());
   80|      2|        let (sig, recid) = signing_key
   81|      2|            .sign_prehash_recoverable(digest.as_ref())
   82|      2|            .unwrap();
   83|      2|        let mut bytes = [0u8; 65];
   84|      2|        bytes[..64].copy_from_slice(&sig.to_bytes());
   85|      2|        bytes[64] = recid.to_byte() + 27;
   86|      2|        format!("0x{}", hex::encode(bytes))
   87|      2|    }
   88|       |
   89|       |    #[tokio::test]
   90|      1|    async fn verify_accepts_valid_eoa_signature() {
   91|      1|        let auth = test_auth();
   92|      1|        let msg = build_message("localhost", "http://localhost:3001", 324, "test-nonce-1");
   93|      1|        let sig = sign_message(&msg);
   94|      1|        let verified = verify_siwe_session(&auth, &msg, &sig).await.unwrap();
   95|      1|        assert_eq!(
   96|       |            verified.wallet_address,
   97|       |            "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266"
   98|       |        );
   99|      1|        assert_eq!(verified.nonce, "test-nonce-1");
  100|      1|    }
  101|       |
  102|       |    #[tokio::test]
  103|      1|    async fn verify_rejects_wrong_chain() {
  104|      1|        let auth = test_auth();
  105|      1|        let msg = build_message("localhost", "http://localhost:3001", 1, "test-nonce-1");
  106|      1|        let sig = sign_message(&msg);
  107|      1|        let err = verify_siwe_session(&auth, &msg, &sig).await.unwrap_err();
  108|      1|        assert!(matches!(err, AppError::InvalidRequest(_)));
                              ^0
  109|      1|    }
  110|       |
  111|       |    #[tokio::test]
  112|      1|    async fn verify_rejects_bad_signature() {
  113|      1|        let auth = test_auth();
  114|      1|        let msg = build_message("localhost", "http://localhost:3001", 324, "test-nonce-1");
  115|      1|        let err = verify_siwe_session(&auth, &msg, "0x00").await.unwrap_err();
  116|      1|        assert!(
  117|      1|            matches!(
                          ^0
  118|      1|                err,
  119|      1|                AppError::InvalidRequest(_) | AppError::InvalidSignature(_)
  120|      1|            ),
  121|      1|            "unexpected: {err:?}"
  122|      1|        );
  123|      1|    }
  124|       |}

/home/runner/work/trust-relay/trust-relay/src/services/siwe/parse.rs:
    1|       |//! EIP-4361 SIWE message parser (trust-relay policy).
    2|       |
    3|       |use hex::FromHex;
    4|       |use time::OffsetDateTime;
    5|       |use url::Url;
    6|       |
    7|       |use super::message::{
    8|       |    eip55, SiweMessage, CHAIN_TAG, EXP_TAG, IAT_TAG, NBF_TAG, NONCE_TAG, PREAMBLE, RES_TAG,
    9|       |    RID_TAG, URI_TAG, VERSION_TAG,
   10|       |};
   11|       |use crate::error::AppError;
   12|       |
   13|       |const MIN_NONCE_LEN: usize = 8;
   14|       |
   15|       |/// Parse an EIP-4361 message string.
   16|     82|pub fn parse_siwe_message(s: &str) -> Result<SiweMessage, AppError> {
   17|     82|    let s = s.trim();
   18|     82|    if s.is_empty() {
   19|      0|        return Err(AppError::InvalidRequest("message is required".into()));
   20|     82|    }
   21|       |
   22|     82|    let mut lines = s.split('\n');
   23|     82|    let preamble = lines
   24|     82|        .next()
   25|     82|        .ok_or_else(|| parse_err("missing preamble line"))?;
                                     ^0                                 ^0
   26|     82|    let (scheme, domain) = parse_preamble(preamble)?;
                       ^79     ^79                               ^3
   27|       |
   28|     79|    let address_line = lines
   29|     79|        .next()
   30|     79|        .ok_or_else(|| parse_err("missing address line"))?;
                                     ^0                                ^0
   31|     79|    let address = parse_address_line(address_line)?;
                      ^74                                       ^5
   32|       |
   33|     74|    lines.next(); // blank line after address
   34|       |
   35|     74|    let statement = match lines.next() {
   36|      0|        None => return Err(parse_err("unexpected end after address")),
   37|     74|        Some("") => None,
                                  ^22
   38|     52|        Some(stmt) => {
   39|  3.05k|            if stmt.contains('\n') || stmt.chars().any(|c| c.is_control()) {
                             ^52  ^52               ^52          ^52
   40|      0|                return Err(parse_err("statement must be printable single-line ASCII"));
   41|     52|            }
   42|     52|            lines.next(); // blank line after statement
   43|     52|            Some(stmt.to_string())
   44|       |        }
   45|       |    };
   46|       |
   47|     74|    let uri = parse_tagged_line(URI_TAG, lines.next())?;
                      ^70                                           ^4
   48|     70|    validate_uri(&uri)?;
                                    ^1
   49|       |
   50|     69|    let version = parse_tagged_line(VERSION_TAG, lines.next())?;
                      ^66                                                   ^3
   51|     66|    if version != "1" {
   52|      1|        return Err(parse_err("version must be 1"));
   53|     65|    }
   54|       |
   55|     65|    let chain_id: u64 = parse_tagged_line(CHAIN_TAG, lines.next())?
                      ^61       ^61                                             ^3
   56|     62|        .parse()
   57|     62|        .map_err(|_| parse_err("invalid chain ID"))?;
                                   ^1                            ^1
   58|       |
   59|     61|    let nonce = parse_tagged_line(NONCE_TAG, lines.next())?;
                      ^59                                               ^2
   60|     59|    if nonce.len() < MIN_NONCE_LEN {
   61|      1|        return Err(parse_err("nonce must be at least 8 characters"));
   62|     58|    }
   63|       |
   64|     58|    let issued_at = parse_timestamp_tag(IAT_TAG, lines.next())?;
                      ^55                                                   ^3
   65|       |
   66|     55|    let mut line = lines.next();
   67|     55|    let expiration_time = match tag_optional(EXP_TAG, line)? {
                      ^54                                                ^1
   68|     16|        Some(exp) => {
   69|     16|            line = lines.next();
   70|     16|            Some(exp)
   71|       |        }
   72|     38|        None => None,
   73|       |    };
   74|     54|    let not_before = match tag_optional(NBF_TAG, line)? {
                      ^53                                           ^1
   75|      9|        Some(nbf) => {
   76|      9|            line = lines.next();
   77|      9|            Some(nbf)
   78|       |        }
   79|     44|        None => None,
   80|       |    };
   81|     53|    let request_id = match tag_optional_str(RID_TAG, line)? {
                                                                        ^0
   82|      6|        Some(rid) => {
   83|      6|            line = lines.next();
   84|      6|            Some(rid)
   85|       |        }
   86|     47|        None => None,
   87|       |    };
   88|       |
   89|     53|    let resources = match line {
                      ^46
   90|     10|        Some(RES_TAG) => lines
                                       ^8
   91|     13|            .filter(|l| !l.is_empty())
                           ^8
   92|      8|            .map(parse_resource_line)
   93|      8|            .collect::<Result<Vec<_>, _>>()?,
                                                         ^5
   94|      2|        Some(_) => return Err(parse_err("unexpected line after request ID")),
   95|     43|        None => Vec::new(),
   96|       |    };
   97|       |
   98|     46|    Ok(SiweMessage {
   99|     46|        scheme,
  100|     46|        domain,
  101|     46|        address,
  102|     46|        statement,
  103|     46|        uri,
  104|     46|        chain_id,
  105|     46|        nonce,
  106|     46|        issued_at,
  107|     46|        expiration_time,
  108|     46|        not_before,
  109|     46|        request_id,
  110|     46|        resources,
  111|     46|    })
  112|     82|}
  113|       |
  114|     82|fn parse_preamble(line: &str) -> Result<(Option<String>, String), AppError> {
  115|     82|    let domain_part = line
                      ^81
  116|     82|        .strip_suffix(PREAMBLE)
  117|     82|        .ok_or_else(|| parse_err("invalid preamble"))?;
                                     ^1                            ^1
  118|       |
  119|     81|    if let Some(idx) = domain_part.find("://") {
                              ^3
  120|      3|        let scheme = &domain_part[..idx];
  121|      3|        if !scheme
  122|      3|            .chars()
  123|     11|            .all(|c| c.is_ascii_alphabetic() && !c.is_ascii_digit())
                           ^3                                 ^10
  124|      2|            || scheme.is_empty()
  125|       |        {
  126|      1|            return Err(parse_err("invalid domain scheme"));
  127|      2|        }
  128|      2|        let host = &domain_part[idx + 3..];
  129|      2|        if host.is_empty() || host.contains('/') {
  130|      0|            return Err(parse_err("invalid domain authority"));
  131|      2|        }
  132|      2|        validate_domain_authority(host)?;
                                                     ^0
  133|      2|        return Ok((Some(scheme.to_string()), host.to_string()));
  134|     78|    }
  135|       |
  136|     78|    validate_domain_authority(domain_part)?;
                                                        ^1
  137|     77|    Ok((None, domain_part.to_string()))
  138|     82|}
  139|       |
  140|     80|fn validate_domain_authority(domain: &str) -> Result<(), AppError> {
  141|     80|    if domain.is_empty() || domain.starts_with('#') {
  142|      1|        return Err(parse_err("domain must be a valid RFC 3986 authority"));
  143|     79|    }
  144|       |    // userinfo@host or host:port or ipv6 [::] forms used in test vectors
  145|     79|    if domain.contains(' ') {
  146|      0|        return Err(parse_err("invalid domain"));
  147|     79|    }
  148|     79|    Ok(())
  149|     80|}
  150|       |
  151|     79|fn parse_address_line(line: &str) -> Result<[u8; 20], AppError> {
  152|     79|    let addr = line.trim();
  153|     79|    if !addr.starts_with("0x") || addr.len() != 42 {
                                                ^78
  154|      3|        return Err(parse_err("address must be 0x-prefixed 20-byte hex"));
  155|     76|    }
  156|     76|    let hex_part = &addr[2..];
  157|  3.03k|    if !hex_part.chars().all(|c| c.is_ascii_hexdigit()) {
                      ^76              ^76
  158|      1|        return Err(parse_err("address must be hex"));
  159|     75|    }
  160|       |
  161|    640|    let is_all_lower = addr[2..].chars().all(|c| !c.is_ascii_uppercase());
                      ^75            ^75               ^75
  162|    195|    let is_all_upper = addr[2..].chars().all(|c| !c.is_ascii_lowercase());
                      ^75            ^75               ^75
  163|     75|    if !is_all_lower && !is_all_upper && !checksum_valid(addr) {
                                      ^63              ^63
  164|      1|        return Err(parse_err("address is not valid EIP-55 checksum"));
  165|     74|    }
  166|       |
  167|     74|    <[u8; 20]>::from_hex(hex_part).map_err(|_| parse_err("invalid address bytes"))
                                                             ^0
  168|     79|}
  169|       |
  170|     63|fn checksum_valid(address: &str) -> bool {
  171|     63|    let stripped = address.trim_start_matches("0x");
  172|     63|    let Ok(bytes) = <[u8; 20]>::from_hex(stripped) else {
  173|      0|        return false;
  174|       |    };
  175|     63|    let sum = eip55(&bytes);
  176|     63|    sum.trim_start_matches("0x") == stripped
  177|     63|}
  178|       |
  179|    269|fn parse_tagged_line(tag: &str, line: Option<&str>) -> Result<String, AppError> {
  180|    269|    let line = line.ok_or_else(|| parse_err(format!("missing {tag} line")))?;
                                                ^0        ^0                             ^0
  181|    269|    line.strip_prefix(tag)
  182|    269|        .map(str::trim)
  183|    269|        .filter(|s| !s.is_empty())
                                   ^257^257
  184|    269|        .map(str::to_string)
  185|    269|        .ok_or_else(|| parse_err(format!("expected line starting with {tag}")))
                                     ^12       ^12
  186|    269|}
  187|       |
  188|    109|fn tag_optional(tag: &str, line: Option<&str>) -> Result<Option<OffsetDateTime>, AppError> {
  189|     34|    match line {
  190|     34|        Some(l) if l.starts_with(tag) => {
                           ^27                    ^27
  191|     27|            let raw = l
  192|     27|                .strip_prefix(tag)
  193|     27|                .ok_or_else(|| parse_err(format!("bad {tag}")))?;
                                             ^0        ^0                    ^0
  194|     27|            Ok(Some(parse_rfc3339(raw)?))
                                                    ^2
  195|       |        }
  196|     82|        _ => Ok(None),
  197|       |    }
  198|    109|}
  199|       |
  200|     53|fn tag_optional_str(tag: &str, line: Option<&str>) -> Result<Option<String>, AppError> {
  201|     10|    match line {
  202|     10|        Some(l) if l.starts_with(tag) => Ok(Some(
                           ^6                     ^6
  203|      6|            l.strip_prefix(tag)
  204|      6|                .ok_or_else(|| parse_err(format!("bad {tag}")))?
                                             ^0        ^0                    ^0
  205|      6|                .to_string(),
  206|       |        )),
  207|     47|        _ => Ok(None),
  208|       |    }
  209|     53|}
  210|       |
  211|     58|fn parse_timestamp_tag(tag: &str, line: Option<&str>) -> Result<OffsetDateTime, AppError> {
  212|     58|    let line = line.ok_or_else(|| parse_err(format!("missing {tag}")))?;
                                                ^0        ^0                        ^0
  213|     58|    let raw = line
                      ^56
  214|     58|        .strip_prefix(tag)
  215|     58|        .ok_or_else(|| parse_err(format!("expected {tag}")))?;
                                     ^2        ^2                         ^2
  216|     56|    parse_rfc3339(raw)
  217|     58|}
  218|       |
  219|     83|fn parse_rfc3339(raw: &str) -> Result<OffsetDateTime, AppError> {
  220|     83|    OffsetDateTime::parse(raw.trim(), &time::format_description::well_known::Rfc3339)
  221|     83|        .map_err(|_| parse_err("timestamp must be RFC 3339"))
                                   ^3
  222|     83|}
  223|       |
  224|     13|fn parse_resource_line(line: &str) -> Result<String, AppError> {
  225|     13|    let uri = line
                      ^11
  226|     13|        .strip_prefix("- ")
  227|     13|        .ok_or_else(|| parse_err("resource must start with '- '"))?;
                                     ^2                                         ^2
  228|     11|    if uri.contains(' ') {
  229|      1|        return Err(parse_err(
  230|      1|            "each resource must be on its own line with a single URI",
  231|      1|        ));
  232|     10|    }
  233|     10|    validate_uri(uri)?;
                                   ^2
  234|      8|    Ok(uri.to_string())
  235|     13|}
  236|       |
  237|     80|fn validate_uri(uri: &str) -> Result<(), AppError> {
  238|     80|    Url::parse(uri).map_err(|_| parse_err("URI must be RFC 3986"))?;
                                              ^3                                ^3
  239|     77|    Ok(())
  240|     80|}
  241|       |
  242|     36|fn parse_err(msg: impl Into<String>) -> AppError {
  243|     36|    AppError::InvalidRequest(format!("invalid SIWE message: {}", msg.into()))
  244|     36|}
  245|       |
  246|       |#[cfg(test)]
  247|       |mod tests {
  248|       |    use super::*;
  249|       |
  250|       |    #[test]
  251|      1|    fn parses_uuid_nonce() {
  252|      1|        let nonce = "550e8400-e29b-41d4-a716-446655440000";
  253|      1|        let msg = format!(
  254|       |            "localhost wants you to sign in with your Ethereum account:\n0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266\n\n\nURI: http://localhost:3001\nVersion: 1\nChain ID: 324\nNonce: {nonce}\nIssued At: 2021-09-30T16:25:24Z"
  255|       |        );
  256|      1|        let parsed = parse_siwe_message(&msg).unwrap();
  257|      1|        assert_eq!(parsed.nonce, nonce);
  258|      1|    }
  259|       |}

/home/runner/work/trust-relay/trust-relay/src/services/siwe/policy.rs:
    1|       |//! SIWE validation policy (time window and trust-relay config binding).
    2|       |
    3|       |use time::OffsetDateTime;
    4|       |
    5|       |use super::message::SiweMessage;
    6|       |use crate::{config::AuthConfig, error::AppError};
    7|       |
    8|       |/// Half-open interval `[not_before, expiration_time)` per EIP-4361 best practice.
    9|     23|pub fn valid_at(message: &SiweMessage, t: &OffsetDateTime) -> bool {
   10|     23|    let after_nbf = message.not_before.as_ref().is_none_or(|nbf| t >= nbf);
                                                                               ^2   ^2
   11|     23|    let before_exp = message.expiration_time.as_ref().is_none_or(|exp| t < exp);
                                                                                     ^7  ^7
   12|     23|    after_nbf && before_exp
                               ^22
   13|     23|}
   14|       |
   15|     14|pub fn validate_against_config(auth: &AuthConfig, message: &SiweMessage) -> Result<(), AppError> {
   16|     14|    if message.chain_id != auth.chain_id {
   17|      1|        return Err(AppError::InvalidRequest(format!(
   18|      1|            "chain ID must be {}",
   19|      1|            auth.chain_id
   20|      1|        )));
   21|     13|    }
   22|       |
   23|     13|    let expected_uri = auth.uri.trim();
   24|     13|    if message.uri != expected_uri {
   25|      0|        return Err(AppError::InvalidRequest(
   26|      0|            "SIWE URI does not match configured auth URI".into(),
   27|      0|        ));
   28|     13|    }
   29|       |
   30|     13|    let expected_domain = auth.domain.trim();
   31|     13|    if message.domain != expected_domain {
   32|      0|        return Err(AppError::InvalidRequest(
   33|      0|            "SIWE domain does not match configured auth domain".into(),
   34|      0|        ));
   35|     13|    }
   36|       |
   37|     13|    Ok(())
   38|     14|}
   39|       |
   40|     13|pub fn validate_time(message: &SiweMessage, now: OffsetDateTime) -> Result<(), AppError> {
   41|     13|    if valid_at(message, &now) {
   42|     13|        Ok(())
   43|       |    } else {
   44|      0|        Err(AppError::InvalidSignature(
   45|      0|            "SIWE message is outside its validity window".into(),
   46|      0|        ))
   47|       |    }
   48|     13|}

/home/runner/work/trust-relay/trust-relay/src/services/token.rs:
    1|       |//! Asymmetric JWT access-token minting and JWKS publication.
    2|       |
    3|       |use base64::Engine;
    4|       |use ed25519_dalek::{pkcs8::EncodePrivateKey, SigningKey, VerifyingKey};
    5|       |use jsonwebtoken::{encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
    6|       |use rand::rngs::OsRng;
    7|       |use serde_json::{json, Value};
    8|       |use time::OffsetDateTime;
    9|       |use uuid::Uuid;
   10|       |
   11|       |use crate::{
   12|       |    config::SigningConfig,
   13|       |    error::AppError,
   14|       |    models::claims::{AccessTokenClaims, TrustTier},
   15|       |};
   16|       |
   17|       |pub struct TokenService {
   18|       |    verifying_key: VerifyingKey,
   19|       |    encoding_key: EncodingKey,
   20|       |    decoding_key: DecodingKey,
   21|       |    config: SigningConfig,
   22|       |}
   23|       |
   24|       |impl TokenService {
   25|     32|    pub fn new(config: SigningConfig) -> Result<Self, AppError> {
   26|     32|        let signing_key = load_signing_key(&config)?;
                                                                 ^0
   27|     32|        let verifying_key = signing_key.verifying_key();
   28|     32|        let encoding_key = encoding_key_from_signing(&signing_key)?;
                                                                                ^0
   29|     32|        let decoding_key = decoding_key_from_verifying(&verifying_key);
   30|     32|        Ok(Self {
   31|     32|            verifying_key,
   32|     32|            encoding_key,
   33|     32|            decoding_key,
   34|     32|            config,
   35|     32|        })
   36|     32|    }
   37|       |
   38|      1|    pub fn kid(&self) -> &str {
   39|      1|        &self.config.kid
   40|      1|    }
   41|       |
   42|       |    /// RFC 7517 JWKS document for resource-server verification.
   43|      2|    pub fn jwks(&self) -> Value {
   44|      2|        let x =
   45|      2|            base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(self.verifying_key.to_bytes());
   46|      2|        json!({
   47|      2|            "keys": [{
   48|      2|                "kty": "OKP",
   49|      2|                "crv": "Ed25519",
   50|      2|                "kid": self.config.kid,
   51|      2|                "use": "sig",
   52|      2|                "alg": "EdDSA",
   53|      2|                "x": x,
   54|       |            }]
   55|       |        })
   56|      2|    }
   57|       |
   58|       |    /// Mint a wallet-tier access token (used by `POST /session` in M2b).
   59|     13|    pub fn mint_wallet_token(
   60|     13|        &self,
   61|     13|        wallet: &str,
   62|     13|        scopes: &[&str],
   63|     13|        chain_id: u64,
   64|     13|    ) -> Result<String, AppError> {
   65|     13|        let sub = normalize_wallet_sub(wallet)?;
                                                            ^0
   66|     13|        let now = OffsetDateTime::now_utc();
   67|     13|        let iat = now.unix_timestamp();
   68|     13|        let exp = iat + i64::from(self.config.access_token_ttl_secs);
   69|     13|        let claims = AccessTokenClaims {
   70|     13|            iss: self.config.issuer.clone(),
   71|     13|            sub,
   72|     13|            aud: self.config.audience.clone(),
   73|     13|            iat,
   74|     13|            nbf: iat,
   75|     13|            exp,
   76|     13|            jti: Uuid::new_v4().to_string(),
   77|     13|            scope: scopes.join(" "),
   78|     13|            tier: TrustTier::Wallet,
   79|     13|            att: None,
   80|     13|            chain_id: Some(chain_id),
   81|     13|            ver: Some(1),
   82|     13|        };
   83|     13|        self.encode_claims(&claims)
   84|     13|    }
   85|       |
   86|       |    /// Verify an access token and return claims (for tests and future session flows).
   87|     12|    pub fn verify(&self, token: &str) -> Result<AccessTokenClaims, AppError> {
   88|     12|        let mut validation = Validation::new(Algorithm::EdDSA);
   89|     12|        validation.set_issuer(&[&self.config.issuer]);
   90|     12|        validation.set_audience(&[&self.config.audience]);
   91|     12|        validation.validate_exp = true;
   92|     12|        validation.validate_nbf = true;
   93|     12|        validation.set_required_spec_claims(&["exp", "nbf", "iat"]);
   94|     11|        let token_data =
   95|     12|            jsonwebtoken::decode::<AccessTokenClaims>(token, &self.decoding_key, &validation)
   96|     12|                .map_err(|e| AppError::InvalidToken(format!("jwt verify failed: {e}")))?;
                                                                  ^1                                 ^1
   97|     11|        Ok(token_data.claims)
   98|     12|    }
   99|       |
  100|     13|    fn encode_claims(&self, claims: &AccessTokenClaims) -> Result<String, AppError> {
  101|     13|        let mut header = Header::new(Algorithm::EdDSA);
  102|     13|        header.typ = Some("at+jwt".to_string());
  103|     13|        header.kid = Some(self.config.kid.clone());
  104|     13|        encode(&header, claims, &self.encoding_key)
  105|     13|            .map_err(|e| AppError::Internal(format!("jwt encode failed: {e}")))
                                                          ^0
  106|     13|    }
  107|       |}
  108|       |
  109|     33|fn encoding_key_from_signing(signing_key: &SigningKey) -> Result<EncodingKey, AppError> {
  110|     33|    let der = signing_key
  111|     33|        .to_pkcs8_der()
  112|     33|        .map_err(|e| AppError::Internal(format!("pkcs8 encode failed: {e}")))?;
                                                      ^0                                   ^0
  113|     33|    Ok(EncodingKey::from_ed_der(der.as_bytes()))
  114|     33|}
  115|       |
  116|     32|fn decoding_key_from_verifying(verifying_key: &VerifyingKey) -> DecodingKey {
  117|     32|    let x = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(verifying_key.to_bytes());
  118|     32|    DecodingKey::from_ed_components(&x).expect("valid ed25519 public key")
  119|     32|}
  120|       |
  121|     33|fn load_signing_key(config: &SigningConfig) -> Result<SigningKey, AppError> {
  122|     33|    if config.key_seed_b64.trim().is_empty() {
  123|     14|        tracing::warn!(
  124|       |            "generating ephemeral Ed25519 signing key; set APP_SIGNING__KEY_SEED_B64 for a stable kid/JWKS"
  125|       |        );
  126|     14|        return Ok(SigningKey::generate(&mut OsRng));
  127|     19|    }
  128|       |
  129|     19|    let seed_bytes = base64::engine::general_purpose::STANDARD
  130|     19|        .decode(config.key_seed_b64.trim())
  131|     19|        .map_err(|e| AppError::Internal(format!("signing key seed decode: {e}")))?;
                                                      ^0                                       ^0
  132|     19|    let seed: [u8; 32] = seed_bytes
  133|     19|        .as_slice()
  134|     19|        .try_into()
  135|     19|        .map_err(|_| AppError::Internal("signing key seed must be 32 bytes".into()))?;
                                                      ^0                                  ^0      ^0
  136|     19|    Ok(SigningKey::from_bytes(&seed))
  137|     33|}
  138|       |
  139|     14|fn normalize_wallet_sub(wallet: &str) -> Result<String, AppError> {
  140|     14|    let w = wallet.trim();
  141|     14|    if !w.starts_with("0x") || w.len() != 42 {
                                             ^13
  142|      1|        return Err(AppError::InvalidRequest(
  143|      1|            "wallet address must be 0x-prefixed 20-byte hex".into(),
  144|      1|        ));
  145|     13|    }
  146|    520|    if !w[2..].chars().all(|c| c.is_ascii_hexdigit()) {
                      ^13            ^13
  147|      0|        return Err(AppError::InvalidRequest(
  148|      0|            "wallet address must be hex".into(),
  149|      0|        ));
  150|     13|    }
  151|     13|    Ok(w.to_ascii_lowercase())
  152|     14|}
  153|       |
  154|       |#[cfg(test)]
  155|       |mod tests {
  156|       |    use super::*;
  157|       |    use base64::Engine;
  158|       |
  159|      3|    fn test_config() -> SigningConfig {
  160|      3|        let key = SigningKey::generate(&mut OsRng);
  161|      3|        let b64 = base64::engine::general_purpose::STANDARD.encode(key.to_bytes());
  162|      3|        SigningConfig {
  163|      3|            issuer: "http://localhost:3001".into(),
  164|      3|            audience: "nodle-backend".into(),
  165|      3|            kid: "test-key-1".into(),
  166|      3|            access_token_ttl_secs: 3600,
  167|      3|            key_seed_b64: b64,
  168|      3|        }
  169|      3|    }
  170|       |
  171|       |    #[test]
  172|      1|    fn jwks_contains_ed25519_okp_key() {
  173|      1|        let svc = TokenService::new(test_config()).unwrap();
  174|      1|        let jwks = svc.jwks();
  175|      1|        let key = &jwks["keys"][0];
  176|      1|        assert_eq!(key["kty"], "OKP");
  177|      1|        assert_eq!(key["crv"], "Ed25519");
  178|      1|        assert_eq!(key["kid"], "test-key-1");
  179|      1|        assert_eq!(key["alg"], "EdDSA");
  180|      1|        assert!(key["x"].as_str().is_some());
  181|      1|    }
  182|       |
  183|       |    #[test]
  184|      1|    fn mint_and_verify_wallet_token() {
  185|      1|        let svc = TokenService::new(test_config()).unwrap();
  186|      1|        let token = svc
  187|      1|            .mint_wallet_token(
  188|      1|                "0xF39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
  189|      1|                &["ai:invoke", "mint:request"],
  190|       |                324,
  191|       |            )
  192|      1|            .unwrap();
  193|      1|        let claims = svc.verify(&token).unwrap();
  194|      1|        assert_eq!(claims.sub, "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266");
  195|      1|        assert_eq!(claims.tier, TrustTier::Wallet);
  196|      1|        assert_eq!(claims.scope, "ai:invoke mint:request");
  197|      1|        assert_eq!(claims.chain_id, Some(324));
  198|      1|        assert_eq!(claims.ver, Some(1));
  199|      1|    }
  200|       |
  201|       |    #[test]
  202|      1|    fn normalize_wallet_rejects_invalid_address() {
  203|      1|        assert!(normalize_wallet_sub("not-a-wallet").is_err());
  204|      1|    }
  205|       |
  206|       |    #[test]
  207|      1|    fn verify_rejects_malformed_exp_claim_type() {
  208|       |        use serde::Serialize;
  209|       |
  210|       |        #[derive(Serialize)]
  211|       |        struct MalformedClaims {
  212|       |            iss: String,
  213|       |            sub: String,
  214|       |            aud: String,
  215|       |            iat: i64,
  216|       |            nbf: i64,
  217|       |            exp: String,
  218|       |            jti: String,
  219|       |        }
  220|       |
  221|      1|        let config = test_config();
  222|      1|        let svc = TokenService::new(config.clone()).unwrap();
  223|      1|        let signing_key = load_signing_key(&config).unwrap();
  224|      1|        let encoding_key = encoding_key_from_signing(&signing_key).unwrap();
  225|      1|        let now = OffsetDateTime::now_utc().unix_timestamp();
  226|      1|        let bad = MalformedClaims {
  227|      1|            iss: config.issuer,
  228|      1|            sub: "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266".into(),
  229|      1|            aud: config.audience,
  230|      1|            iat: now,
  231|      1|            nbf: now,
  232|      1|            exp: "9999999999".into(),
  233|      1|            jti: Uuid::new_v4().to_string(),
  234|      1|        };
  235|      1|        let mut header = Header::new(Algorithm::EdDSA);
  236|      1|        header.kid = Some(config.kid);
  237|      1|        let token = encode(&header, &bad, &encoding_key).unwrap();
  238|      1|        let err = svc.verify(&token).unwrap_err();
  239|      1|        assert!(matches!(err, AppError::InvalidToken(_)));
                              ^0
  240|      1|    }
  241|       |}

/home/runner/work/trust-relay/trust-relay/src/state.rs:
    1|       |//! Shared application state passed to handlers via Axum `State`.
    2|       |
    3|       |use std::sync::Arc;
    4|       |
    5|       |use crate::{
    6|       |    bootstrap,
    7|       |    config::Config,
    8|       |    error::AppError,
    9|       |    middleware::rate_limit::RateLimiter,
   10|       |    services::{
   11|       |        heuristics::HeuristicsService, nonce::NonceService, quota::QuotaService,
   12|       |        refresh::RefreshService, revocation::RevocationService, token::TokenService,
   13|       |    },
   14|       |};
   15|       |
   16|       |/// Shared state for route handlers.
   17|       |#[derive(Clone)]
   18|       |pub struct AppState {
   19|       |    pub config: Arc<Config>,
   20|       |    pub nonce: Arc<NonceService>,
   21|       |    pub nonce_rate_limiter: Arc<RateLimiter>,
   22|       |    pub token: Arc<TokenService>,
   23|       |    pub revocation: Arc<RevocationService>,
   24|       |    pub refresh: Arc<RefreshService>,
   25|       |    pub heuristics: Arc<HeuristicsService>,
   26|       |    pub quota: Arc<QuotaService>,
   27|       |}
   28|       |
   29|       |impl AppState {
   30|     25|    pub async fn build(config: Config) -> Result<Self, AppError> {
   31|     25|        let store = bootstrap::build_nonce_store(&config).await?;
                                                                             ^0
   32|     25|        let nonce = bootstrap::build_nonce_service(&config, store);
   33|     25|        let nonce_rate_limiter = bootstrap::build_nonce_rate_limiter(&config);
   34|     25|        let token = bootstrap::build_token_service(&config)?;
                                                                         ^0
   35|     25|        let revocation_store = bootstrap::build_revocation_store(&config).await?;
                                                                                             ^0
   36|     25|        let revocation = bootstrap::build_revocation_service(revocation_store);
   37|     25|        let refresh_store = bootstrap::build_refresh_store(&config).await?;
                                                                                       ^0
   38|     25|        let refresh = bootstrap::build_refresh_service(&config, refresh_store);
   39|     25|        let heuristics_store = bootstrap::build_heuristics_store(&config).await?;
                                                                                             ^0
   40|     25|        let heuristics = bootstrap::build_heuristics_service(heuristics_store);
   41|     25|        let quota_store = bootstrap::build_quota_store(&config).await?;
                                                                                   ^0
   42|     25|        let quota = bootstrap::build_quota_service(&config, quota_store);
   43|     25|        Ok(Self {
   44|     25|            config: Arc::new(config),
   45|     25|            nonce,
   46|     25|            nonce_rate_limiter,
   47|     25|            token,
   48|     25|            revocation,
   49|     25|            refresh,
   50|     25|            heuristics,
   51|     25|            quota,
   52|     25|        })
   53|     25|    }
   54|       |}

/home/runner/work/trust-relay/trust-relay/src/store/heuristics.rs:
    1|       |//! Wallet heuristic persistence (phase-0: first-seen / established).
    2|       |
    3|       |mod memory;
    4|       |mod redis;
    5|       |
    6|       |pub use memory::InMemoryHeuristicsStore;
    7|       |pub use redis::RedisHeuristicsStore;
    8|       |
    9|       |use async_trait::async_trait;
   10|       |use thiserror::Error;
   11|       |
   12|       |use crate::error::AppError;
   13|       |
   14|       |#[derive(Debug, Error)]
   15|       |pub enum HeuristicsStoreError {
   16|       |    #[error("store unavailable")]
   17|       |    Unavailable(#[from] ::redis::RedisError),
   18|       |    #[error("{0}")]
   19|       |    Other(String),
   20|       |}
   21|       |
   22|       |impl From<HeuristicsStoreError> for AppError {
   23|      0|    fn from(err: HeuristicsStoreError) -> Self {
   24|      0|        Self::Internal(err.to_string())
   25|      0|    }
   26|       |}
   27|       |
   28|       |#[async_trait]
   29|       |pub trait HeuristicsStore: Send + Sync {
   30|       |    /// Whether the wallet has completed at least one prior session.
   31|       |    async fn is_established(&self, wallet: &str) -> Result<bool, HeuristicsStoreError>;
   32|       |
   33|       |    /// Record that the wallet completed a session (called after successful mint).
   34|       |    async fn mark_established(&self, wallet: &str) -> Result<(), HeuristicsStoreError>;
   35|       |}

/home/runner/work/trust-relay/trust-relay/src/store/heuristics/memory.rs:
    1|       |//! In-memory wallet heuristic store.
    2|       |
    3|       |use std::collections::HashSet;
    4|       |use std::sync::Mutex;
    5|       |
    6|       |use async_trait::async_trait;
    7|       |
    8|       |use super::{HeuristicsStore, HeuristicsStoreError};
    9|       |
   10|       |#[derive(Default)]
   11|       |pub struct InMemoryHeuristicsStore {
   12|       |    established: Mutex<HashSet<String>>,
   13|       |}
   14|       |
   15|       |#[async_trait]
   16|       |impl HeuristicsStore for InMemoryHeuristicsStore {
   17|      0|    async fn is_established(&self, wallet: &str) -> Result<bool, HeuristicsStoreError> {
   18|       |        let guard = self
   19|       |            .established
   20|       |            .lock()
   21|      0|            .map_err(|_| HeuristicsStoreError::Other("lock poisoned".into()))?;
   22|       |        Ok(guard.contains(wallet))
   23|      0|    }
   24|       |
   25|      0|    async fn mark_established(&self, wallet: &str) -> Result<(), HeuristicsStoreError> {
   26|       |        self.established
   27|       |            .lock()
   28|      0|            .map_err(|_| HeuristicsStoreError::Other("lock poisoned".into()))?
   29|       |            .insert(wallet.to_string());
   30|       |        Ok(())
   31|      0|    }
   32|       |}

/home/runner/work/trust-relay/trust-relay/src/store/heuristics/redis.rs:
    1|       |//! Redis-backed wallet heuristic store.
    2|       |
    3|       |use async_trait::async_trait;
    4|       |use redis::AsyncCommands;
    5|       |
    6|       |use super::{HeuristicsStore, HeuristicsStoreError};
    7|       |
    8|       |const SEEN_PREFIX: &str = "heuristic:seen:";
    9|       |
   10|       |#[derive(Clone)]
   11|       |pub struct RedisHeuristicsStore {
   12|       |    client: redis::aio::ConnectionManager,
   13|       |}
   14|       |
   15|       |impl RedisHeuristicsStore {
   16|     23|    pub async fn connect(url: &str) -> Result<Self, redis::RedisError> {
   17|     23|        let client = redis::Client::open(url)?;
                                                           ^0
   18|     23|        let conn = client.get_connection_manager().await?;
                                                                      ^0
   19|     23|        Ok(Self { client: conn })
   20|     23|    }
   21|       |
   22|     20|    fn key(wallet: &str) -> String {
   23|     20|        format!("{SEEN_PREFIX}{wallet}")
   24|     20|    }
   25|       |}
   26|       |
   27|       |#[async_trait]
   28|       |impl HeuristicsStore for RedisHeuristicsStore {
   29|     10|    async fn is_established(&self, wallet: &str) -> Result<bool, HeuristicsStoreError> {
   30|       |        let key = Self::key(wallet);
   31|       |        let mut conn = self.client.clone();
   32|       |        let exists: bool = conn.exists(key).await?;
   33|       |        Ok(exists)
   34|     10|    }
   35|       |
   36|     10|    async fn mark_established(&self, wallet: &str) -> Result<(), HeuristicsStoreError> {
   37|       |        let key = Self::key(wallet);
   38|       |        let mut conn = self.client.clone();
   39|       |        conn.set::<_, _, ()>(key, "1").await?;
   40|       |        Ok(())
   41|     10|    }
   42|       |}

/home/runner/work/trust-relay/trust-relay/src/store/nonce.rs:
    1|       |//! Nonce persistence — single-use, TTL-bound challenge values.
    2|       |
    3|       |mod memory;
    4|       |mod redis;
    5|       |
    6|       |pub use memory::InMemoryNonceStore;
    7|       |pub use redis::RedisNonceStore;
    8|       |
    9|       |use async_trait::async_trait;
   10|       |use thiserror::Error;
   11|       |use time::OffsetDateTime;
   12|       |
   13|       |use crate::error::AppError;
   14|       |
   15|       |#[derive(Debug, Clone)]
   16|       |pub struct NonceRecord {
   17|       |    pub issued_at: OffsetDateTime,
   18|       |    pub expires_at: OffsetDateTime,
   19|       |    pub client_ip: Option<String>,
   20|       |    pub used: bool,
   21|       |}
   22|       |
   23|       |#[derive(Debug, Error)]
   24|       |pub enum NonceStoreError {
   25|       |    #[error("store unavailable")]
   26|       |    Unavailable(#[from] ::redis::RedisError),
   27|       |    #[error("{0}")]
   28|       |    Other(String),
   29|       |}
   30|       |
   31|       |impl From<NonceStoreError> for AppError {
   32|      1|    fn from(err: NonceStoreError) -> Self {
   33|      1|        Self::Internal(err.to_string())
   34|      1|    }
   35|       |}
   36|       |
   37|       |#[async_trait]
   38|       |pub trait NonceStore: Send + Sync {
   39|       |    async fn put(&self, nonce: &str, record: &NonceRecord) -> Result<(), NonceStoreError>;
   40|       |    async fn get(&self, nonce: &str) -> Result<Option<NonceRecord>, NonceStoreError>;
   41|       |    async fn mark_used(&self, nonce: &str) -> Result<(), NonceStoreError>;
   42|       |}
   43|       |
   44|       |#[cfg(test)]
   45|       |mod tests {
   46|       |    use super::*;
   47|       |
   48|       |    #[test]
   49|      1|    fn nonce_store_error_maps_to_internal_app_error() {
   50|      1|        let err = NonceStoreError::Other("boom".into());
   51|      1|        let app: AppError = err.into();
   52|      1|        assert!(matches!(app, AppError::Internal(msg) if msg == "boom"));
   53|      1|    }
   54|       |}

/home/runner/work/trust-relay/trust-relay/src/store/nonce/memory.rs:
    1|       |//! In-memory nonce store for tests and development without Redis.
    2|       |
    3|       |use std::collections::HashMap;
    4|       |use std::sync::Mutex;
    5|       |
    6|       |use async_trait::async_trait;
    7|       |
    8|       |use super::{NonceRecord, NonceStore, NonceStoreError};
    9|       |
   10|       |#[derive(Default)]
   11|       |pub struct InMemoryNonceStore {
   12|       |    inner: Mutex<HashMap<String, NonceRecord>>,
   13|       |}
   14|       |
   15|       |#[async_trait]
   16|       |impl NonceStore for InMemoryNonceStore {
   17|      6|    async fn put(&self, nonce: &str, record: &NonceRecord) -> Result<(), NonceStoreError> {
   18|       |        self.inner
   19|       |            .lock()
   20|      0|            .map_err(|_| NonceStoreError::Other("lock poisoned".into()))?
   21|       |            .insert(nonce.to_string(), record.clone());
   22|       |        Ok(())
   23|      6|    }
   24|       |
   25|      7|    async fn get(&self, nonce: &str) -> Result<Option<NonceRecord>, NonceStoreError> {
   26|       |        Ok(self
   27|       |            .inner
   28|       |            .lock()
   29|      0|            .map_err(|_| NonceStoreError::Other("lock poisoned".into()))?
   30|       |            .get(nonce)
   31|       |            .cloned())
   32|      7|    }
   33|       |
   34|      2|    async fn mark_used(&self, nonce: &str) -> Result<(), NonceStoreError> {
   35|       |        let mut guard = self
   36|       |            .inner
   37|       |            .lock()
   38|      0|            .map_err(|_| NonceStoreError::Other("lock poisoned".into()))?;
   39|       |        if let Some(record) = guard.get_mut(nonce) {
   40|       |            record.used = true;
   41|       |        }
   42|       |        Ok(())
   43|      2|    }
   44|       |}
   45|       |
   46|       |#[cfg(test)]
   47|       |mod tests {
   48|       |    use super::*;
   49|       |    use time::OffsetDateTime;
   50|       |
   51|       |    #[tokio::test]
   52|      1|    async fn put_get_and_mark_used() {
   53|      1|        let store = InMemoryNonceStore::default();
   54|      1|        let now = OffsetDateTime::now_utc();
   55|      1|        let record = NonceRecord {
   56|      1|            issued_at: now,
   57|      1|            expires_at: now + time::Duration::minutes(2),
   58|      1|            client_ip: Some("127.0.0.1".into()),
   59|      1|            used: false,
   60|      1|        };
   61|      1|        store.put("abc", &record).await.unwrap();
   62|      1|        let fetched = store.get("abc").await.unwrap().unwrap();
   63|      1|        assert!(!fetched.used);
   64|      1|        store.mark_used("abc").await.unwrap();
   65|      1|        assert!(store.get("abc").await.unwrap().unwrap().used);
   66|      1|    }
   67|       |}

/home/runner/work/trust-relay/trust-relay/src/store/nonce/redis.rs:
    1|       |//! Redis-backed nonce store for production.
    2|       |
    3|       |use async_trait::async_trait;
    4|       |use redis::AsyncCommands;
    5|       |use serde::{Deserialize, Serialize};
    6|       |use time::OffsetDateTime;
    7|       |
    8|       |use super::{NonceRecord, NonceStore, NonceStoreError};
    9|       |
   10|       |const KEY_PREFIX: &str = "nonce:";
   11|       |
   12|       |#[derive(Clone)]
   13|       |pub struct RedisNonceStore {
   14|       |    client: redis::aio::ConnectionManager,
   15|       |}
   16|       |
   17|       |impl RedisNonceStore {
   18|     26|    pub async fn connect(url: &str) -> Result<Self, redis::RedisError> {
   19|     26|        let client = redis::Client::open(url)?;
                                                           ^0
   20|     26|        let conn = client.get_connection_manager().await?;
                                                                      ^0
   21|     26|        Ok(Self { client: conn })
   22|     26|    }
   23|       |}
   24|       |
   25|       |#[derive(Serialize, Deserialize)]
   26|       |struct StoredNonce {
   27|       |    issued_at: i64,
   28|       |    expires_at: i64,
   29|       |    client_ip: Option<String>,
   30|       |    used: bool,
   31|       |}
   32|       |
   33|       |impl From<&NonceRecord> for StoredNonce {
   34|     48|    fn from(record: &NonceRecord) -> Self {
   35|     48|        Self {
   36|     48|            issued_at: record.issued_at.unix_timestamp(),
   37|     48|            expires_at: record.expires_at.unix_timestamp(),
   38|     48|            client_ip: record.client_ip.clone(),
   39|     48|            used: record.used,
   40|     48|        }
   41|     48|    }
   42|       |}
   43|       |
   44|       |impl StoredNonce {
   45|     16|    fn into_record(self) -> NonceRecord {
   46|       |        NonceRecord {
   47|     16|            issued_at: OffsetDateTime::from_unix_timestamp(self.issued_at)
   48|     16|                .unwrap_or_else(|_| OffsetDateTime::now_utc()),
                                                  ^1
   49|     16|            expires_at: OffsetDateTime::from_unix_timestamp(self.expires_at)
   50|     16|                .unwrap_or_else(|_| OffsetDateTime::now_utc()),
                                                  ^1
   51|     16|            client_ip: self.client_ip,
   52|     16|            used: self.used,
   53|       |        }
   54|     16|    }
   55|       |}
   56|       |
   57|       |#[async_trait]
   58|       |impl NonceStore for RedisNonceStore {
   59|     47|    async fn put(&self, nonce: &str, record: &NonceRecord) -> Result<(), NonceStoreError> {
   60|       |        let key = format!("{KEY_PREFIX}{nonce}");
   61|       |        let ttl = (record.expires_at - record.issued_at)
   62|       |            .whole_seconds()
   63|       |            .max(1) as u64;
   64|       |        let payload = serde_json::to_string(&StoredNonce::from(record))
   65|      0|            .map_err(|e| NonceStoreError::Other(e.to_string()))?;
   66|       |        let mut conn = self.client.clone();
   67|       |        conn.set_ex::<_, _, ()>(key, payload, ttl).await?;
   68|       |        Ok(())
   69|     47|    }
   70|       |
   71|     16|    async fn get(&self, nonce: &str) -> Result<Option<NonceRecord>, NonceStoreError> {
   72|       |        let key = format!("{KEY_PREFIX}{nonce}");
   73|       |        let mut conn = self.client.clone();
   74|       |        let raw: Option<String> = conn.get(key).await?;
   75|       |        Ok(raw
   76|     15|            .map(|s| {
   77|     15|                serde_json::from_str::<StoredNonce>(&s)
   78|     15|                    .map(|stored| stored.into_record())
                                                ^14    ^14
   79|     15|                    .map_err(|e| NonceStoreError::Other(e.to_string()))
                                                                      ^1^1
   80|     15|            })
   81|       |            .transpose()?)
   82|     16|    }
   83|       |
   84|     11|    async fn mark_used(&self, nonce: &str) -> Result<(), NonceStoreError> {
   85|       |        let key = format!("{KEY_PREFIX}{nonce}");
   86|       |        let mut conn = self.client.clone();
   87|       |        let raw: Option<String> = conn.get(&key).await?;
   88|       |        if let Some(s) = raw {
   89|       |            let mut stored: StoredNonce =
   90|      0|                serde_json::from_str(&s).map_err(|e| NonceStoreError::Other(e.to_string()))?;
   91|       |            stored.used = true;
   92|       |            let ttl: i64 = conn.ttl(&key).await.unwrap_or(60);
   93|       |            let ttl = ttl.max(1) as u64;
   94|       |            let payload = serde_json::to_string(&stored)
   95|      0|                .map_err(|e| NonceStoreError::Other(e.to_string()))?;
   96|       |            conn.set_ex::<_, _, ()>(key, payload, ttl).await?;
   97|       |        }
   98|       |        Ok(())
   99|     11|    }
  100|       |}
  101|       |
  102|       |#[cfg(test)]
  103|       |mod tests {
  104|       |    use super::*;
  105|       |    use time::OffsetDateTime;
  106|       |
  107|       |    #[test]
  108|      1|    fn stored_nonce_roundtrip_from_record() {
  109|      1|        let now = OffsetDateTime::now_utc();
  110|      1|        let record = NonceRecord {
  111|      1|            issued_at: now,
  112|      1|            expires_at: now + time::Duration::minutes(2),
  113|      1|            client_ip: Some("127.0.0.1".into()),
  114|      1|            used: true,
  115|      1|        };
  116|      1|        let stored = StoredNonce::from(&record);
  117|      1|        let back = stored.into_record();
  118|      1|        assert_eq!(back.client_ip, record.client_ip);
  119|      1|        assert_eq!(back.used, record.used);
  120|      1|    }
  121|       |
  122|       |    #[test]
  123|      1|    fn stored_nonce_falls_back_on_invalid_timestamps() {
  124|      1|        let stored = StoredNonce {
  125|      1|            issued_at: i64::MAX,
  126|      1|            expires_at: i64::MAX,
  127|      1|            client_ip: None,
  128|      1|            used: false,
  129|      1|        };
  130|      1|        let _ = stored.into_record();
  131|      1|    }
  132|       |}

/home/runner/work/trust-relay/trust-relay/src/store/quota.rs:
    1|       |//! Per-wallet quota counters for paid scopes.
    2|       |
    3|       |mod memory;
    4|       |mod redis;
    5|       |
    6|       |pub use memory::InMemoryQuotaStore;
    7|       |pub use redis::RedisQuotaStore;
    8|       |
    9|       |use async_trait::async_trait;
   10|       |use thiserror::Error;
   11|       |
   12|       |use crate::error::AppError;
   13|       |
   14|       |#[derive(Debug, Clone, PartialEq, Eq)]
   15|       |pub struct QuotaBucket {
   16|       |    pub used: u64,
   17|       |    pub limit: u64,
   18|       |}
   19|       |
   20|       |#[derive(Debug, Error)]
   21|       |pub enum QuotaStoreError {
   22|       |    #[error("store unavailable")]
   23|       |    Unavailable(#[from] ::redis::RedisError),
   24|       |    #[error("quota bucket missing")]
   25|       |    NotFound,
   26|       |    #[error("quota exceeded")]
   27|       |    Exceeded,
   28|       |    #[error("{0}")]
   29|       |    Other(String),
   30|       |}
   31|       |
   32|       |impl From<QuotaStoreError> for AppError {
   33|      2|    fn from(err: QuotaStoreError) -> Self {
   34|      2|        match err {
   35|       |            QuotaStoreError::NotFound => {
   36|      0|                Self::InvalidRequest("quota bucket not initialized for scope".into())
   37|       |            }
   38|      2|            QuotaStoreError::Exceeded => Self::QuotaExceeded("quota exhausted for scope".into()),
   39|      0|            other => Self::Internal(other.to_string()),
   40|       |        }
   41|      2|    }
   42|       |}
   43|       |
   44|       |#[async_trait]
   45|       |pub trait QuotaStore: Send + Sync {
   46|       |    /// Create buckets for the current window when absent (`limit` per scope).
   47|       |    async fn init_scope(
   48|       |        &self,
   49|       |        wallet: &str,
   50|       |        scope: &str,
   51|       |        window_id: u64,
   52|       |        limit: u64,
   53|       |        ttl_secs: u64,
   54|       |    ) -> Result<(), QuotaStoreError>;
   55|       |
   56|       |    async fn get_bucket(
   57|       |        &self,
   58|       |        wallet: &str,
   59|       |        scope: &str,
   60|       |        window_id: u64,
   61|       |    ) -> Result<Option<QuotaBucket>, QuotaStoreError>;
   62|       |
   63|       |    /// Atomically consume `amount` units; returns updated bucket or `NotFound`.
   64|       |    async fn try_consume(
   65|       |        &self,
   66|       |        wallet: &str,
   67|       |        scope: &str,
   68|       |        window_id: u64,
   69|       |        amount: u64,
   70|       |    ) -> Result<QuotaBucket, QuotaStoreError>;
   71|       |}
   72|       |
   73|     22|pub fn encode_bucket(bucket: &QuotaBucket) -> String {
   74|     22|    format!("{},{}", bucket.used, bucket.limit)
   75|     22|}
   76|       |
   77|     22|pub fn decode_bucket(raw: &str) -> Result<QuotaBucket, QuotaStoreError> {
   78|     22|    let (used, limit) = raw
   79|     22|        .split_once(',')
   80|     22|        .ok_or_else(|| QuotaStoreError::Other("invalid quota bucket encoding".into()))?;
                                                            ^0                              ^0      ^0
   81|     22|    let used = used
   82|     22|        .parse()
   83|     22|        .map_err(|_| QuotaStoreError::Other("invalid used count".into()))?;
                                                          ^0                   ^0      ^0
   84|     22|    let limit = limit
   85|     22|        .parse()
   86|     22|        .map_err(|_| QuotaStoreError::Other("invalid limit".into()))?;
                                                          ^0              ^0      ^0
   87|     22|    Ok(QuotaBucket { used, limit })
   88|     22|}
   89|       |
   90|     25|pub fn current_window_id(now_unix: i64, window_secs: u64) -> u64 {
   91|     25|    (now_unix.max(0) as u64) / window_secs.max(1)
   92|     25|}
   93|       |
   94|       |#[cfg(test)]
   95|       |mod tests {
   96|       |    use super::*;
   97|       |
   98|       |    #[test]
   99|      1|    fn bucket_roundtrip() {
  100|      1|        let raw = encode_bucket(&QuotaBucket { used: 3, limit: 10 });
  101|      1|        let bucket = decode_bucket(&raw).unwrap();
  102|      1|        assert_eq!(bucket.used, 3);
  103|      1|        assert_eq!(bucket.limit, 10);
  104|      1|    }
  105|       |
  106|       |    #[test]
  107|      1|    fn window_id_buckets_time() {
  108|      1|        assert_eq!(current_window_id(0, 3600), 0);
  109|      1|        assert_eq!(current_window_id(3600, 3600), 1);
  110|      1|    }
  111|       |}

/home/runner/work/trust-relay/trust-relay/src/store/quota/memory.rs:
    1|       |//! In-memory quota store.
    2|       |
    3|       |use std::collections::HashMap;
    4|       |use std::sync::Mutex;
    5|       |
    6|       |use async_trait::async_trait;
    7|       |
    8|       |use super::{QuotaBucket, QuotaStore, QuotaStoreError};
    9|       |
   10|      4|fn bucket_key(wallet: &str, scope: &str, window_id: u64) -> String {
   11|      4|    format!("{wallet}:{scope}:{window_id}")
   12|      4|}
   13|       |
   14|       |#[derive(Default)]
   15|       |pub struct InMemoryQuotaStore {
   16|       |    buckets: Mutex<HashMap<String, QuotaBucket>>,
   17|       |}
   18|       |
   19|       |#[async_trait]
   20|       |impl QuotaStore for InMemoryQuotaStore {
   21|       |    async fn init_scope(
   22|       |        &self,
   23|       |        wallet: &str,
   24|       |        scope: &str,
   25|       |        window_id: u64,
   26|       |        limit: u64,
   27|       |        _ttl_secs: u64,
   28|      1|    ) -> Result<(), QuotaStoreError> {
   29|       |        let mut guard = self
   30|       |            .buckets
   31|       |            .lock()
   32|      0|            .map_err(|_| QuotaStoreError::Other("lock poisoned".into()))?;
   33|       |        let key = bucket_key(wallet, scope, window_id);
   34|       |        guard
   35|       |            .entry(key)
   36|      0|            .and_modify(|bucket| bucket.limit = limit)
   37|       |            .or_insert(QuotaBucket { used: 0, limit });
   38|       |        Ok(())
   39|      1|    }
   40|       |
   41|       |    async fn get_bucket(
   42|       |        &self,
   43|       |        wallet: &str,
   44|       |        scope: &str,
   45|       |        window_id: u64,
   46|      0|    ) -> Result<Option<QuotaBucket>, QuotaStoreError> {
   47|       |        let guard = self
   48|       |            .buckets
   49|       |            .lock()
   50|      0|            .map_err(|_| QuotaStoreError::Other("lock poisoned".into()))?;
   51|       |        Ok(guard.get(&bucket_key(wallet, scope, window_id)).cloned())
   52|      0|    }
   53|       |
   54|       |    async fn try_consume(
   55|       |        &self,
   56|       |        wallet: &str,
   57|       |        scope: &str,
   58|       |        window_id: u64,
   59|       |        amount: u64,
   60|      3|    ) -> Result<QuotaBucket, QuotaStoreError> {
   61|       |        let mut guard = self
   62|       |            .buckets
   63|       |            .lock()
   64|      0|            .map_err(|_| QuotaStoreError::Other("lock poisoned".into()))?;
   65|       |        let key = bucket_key(wallet, scope, window_id);
   66|       |        let bucket = guard.get_mut(&key).ok_or(QuotaStoreError::NotFound)?;
   67|       |        if bucket.used.saturating_add(amount) > bucket.limit {
   68|       |            return Err(QuotaStoreError::Exceeded);
   69|       |        }
   70|       |        bucket.used += amount;
   71|       |        Ok(bucket.clone())
   72|      3|    }
   73|       |}

/home/runner/work/trust-relay/trust-relay/src/store/quota/redis.rs:
    1|       |//! Redis-backed per-wallet quota counters.
    2|       |
    3|       |use async_trait::async_trait;
    4|       |use redis::AsyncCommands;
    5|       |
    6|       |use super::{decode_bucket, encode_bucket, QuotaBucket, QuotaStore, QuotaStoreError};
    7|       |
    8|       |const KEY_PREFIX: &str = "quota:";
    9|       |
   10|       |#[derive(Clone)]
   11|       |pub struct RedisQuotaStore {
   12|       |    client: redis::aio::ConnectionManager,
   13|       |}
   14|       |
   15|       |impl RedisQuotaStore {
   16|     24|    pub async fn connect(url: &str) -> Result<Self, redis::RedisError> {
   17|     24|        let client = redis::Client::open(url)?;
                                                           ^0
   18|     24|        let conn = client.get_connection_manager().await?;
                                                                      ^0
   19|     24|        Ok(Self { client: conn })
   20|     24|    }
   21|       |
   22|     32|    fn key(wallet: &str, scope: &str, window_id: u64) -> String {
   23|     32|        format!("{KEY_PREFIX}{wallet}:{scope}:{window_id}")
   24|     32|    }
   25|       |}
   26|       |
   27|       |#[async_trait]
   28|       |impl QuotaStore for RedisQuotaStore {
   29|       |    async fn init_scope(
   30|       |        &self,
   31|       |        wallet: &str,
   32|       |        scope: &str,
   33|       |        window_id: u64,
   34|       |        limit: u64,
   35|       |        ttl_secs: u64,
   36|     21|    ) -> Result<(), QuotaStoreError> {
   37|       |        let key = Self::key(wallet, scope, window_id);
   38|       |        let mut conn = self.client.clone();
   39|       |        if let Some(raw) = conn.get::<_, Option<String>>(&key).await? {
   40|       |            let mut bucket = decode_bucket(&raw)?;
   41|       |            bucket.limit = limit;
   42|       |            let payload = encode_bucket(&bucket);
   43|       |            let ttl: i64 = conn.ttl(&key).await.unwrap_or(ttl_secs as i64);
   44|       |            if ttl > 0 {
   45|       |                conn.set_ex::<_, _, ()>(&key, payload, ttl as u64).await?;
   46|       |            } else {
   47|       |                conn.set::<_, _, ()>(&key, payload).await?;
   48|       |            }
   49|       |        } else {
   50|       |            let payload = encode_bucket(&QuotaBucket { used: 0, limit });
   51|       |            conn.set_ex::<_, _, ()>(&key, payload, ttl_secs.max(1))
   52|       |                .await?;
   53|       |        }
   54|       |        Ok(())
   55|     21|    }
   56|       |
   57|       |    async fn get_bucket(
   58|       |        &self,
   59|       |        wallet: &str,
   60|       |        scope: &str,
   61|       |        window_id: u64,
   62|      6|    ) -> Result<Option<QuotaBucket>, QuotaStoreError> {
   63|       |        let key = Self::key(wallet, scope, window_id);
   64|       |        let mut conn = self.client.clone();
   65|       |        let raw: Option<String> = conn.get(key).await?;
   66|      6|        raw.map(|s| decode_bucket(&s)).transpose()
   67|      6|    }
   68|       |
   69|       |    async fn try_consume(
   70|       |        &self,
   71|       |        wallet: &str,
   72|       |        scope: &str,
   73|       |        window_id: u64,
   74|       |        amount: u64,
   75|      5|    ) -> Result<QuotaBucket, QuotaStoreError> {
   76|       |        let key = Self::key(wallet, scope, window_id);
   77|       |        let mut conn = self.client.clone();
   78|       |        let script = r#"
   79|       |            local raw = redis.call('GET', KEYS[1])
   80|       |            if not raw then return -1 end
   81|       |            local sep = string.find(raw, ',')
   82|       |            if not sep then return -2 end
   83|       |            local used = tonumber(string.sub(raw, 1, sep - 1))
   84|       |            local limit = tonumber(string.sub(raw, sep + 1))
   85|       |            local amount = tonumber(ARGV[1])
   86|       |            if used + amount > limit then return -3 end
   87|       |            used = used + amount
   88|       |            local updated = tostring(used) .. ',' .. tostring(limit)
   89|       |            local ttl = redis.call('TTL', KEYS[1])
   90|       |            if ttl > 0 then
   91|       |                redis.call('SET', KEYS[1], updated, 'EX', ttl)
   92|       |            else
   93|       |                redis.call('SET', KEYS[1], updated)
   94|       |            end
   95|       |            return limit - used
   96|       |        "#;
   97|       |        let remaining: i64 = redis::Script::new(script)
   98|       |            .key(key)
   99|       |            .arg(amount)
  100|       |            .invoke_async(&mut conn)
  101|       |            .await?;
  102|       |        match remaining {
  103|       |            -1 => Err(QuotaStoreError::NotFound),
  104|       |            -2 => Err(QuotaStoreError::Other("invalid bucket".into())),
  105|       |            -3 => Err(QuotaStoreError::Exceeded),
  106|       |            _ => self
  107|       |                .get_bucket(wallet, scope, window_id)
  108|       |                .await?
  109|       |                .ok_or(QuotaStoreError::NotFound),
  110|       |        }
  111|      5|    }
  112|       |}

/home/runner/work/trust-relay/trust-relay/src/store/refresh.rs:
    1|       |//! Opaque refresh-token persistence (rotation + replay tombstones).
    2|       |
    3|       |mod memory;
    4|       |mod redis;
    5|       |
    6|       |pub use memory::InMemoryRefreshStore;
    7|       |pub use redis::RedisRefreshStore;
    8|       |
    9|       |use async_trait::async_trait;
   10|       |use serde::{Deserialize, Serialize};
   11|       |use thiserror::Error;
   12|       |
   13|       |use crate::error::AppError;
   14|       |
   15|       |#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
   16|       |pub struct RefreshRecord {
   17|       |    pub family_id: String,
   18|       |    pub wallet: String,
   19|       |    pub rotation: u32,
   20|       |    pub scopes: Vec<String>,
   21|       |    pub chain_id: u64,
   22|       |    pub expires_at: i64,
   23|       |}
   24|       |
   25|       |#[derive(Debug, Error)]
   26|       |pub enum RefreshStoreError {
   27|       |    #[error("store unavailable")]
   28|       |    Unavailable(#[from] ::redis::RedisError),
   29|       |    #[error("{0}")]
   30|       |    Other(String),
   31|       |}
   32|       |
   33|       |impl From<RefreshStoreError> for AppError {
   34|      0|    fn from(err: RefreshStoreError) -> Self {
   35|      0|        Self::Internal(err.to_string())
   36|      0|    }
   37|       |}
   38|       |
   39|       |/// Result of looking up a presented refresh token.
   40|       |#[derive(Debug, Clone, PartialEq, Eq)]
   41|       |pub enum RefreshLookup {
   42|       |    Active(RefreshRecord),
   43|       |    /// Rotated-out token presented again — caller must revoke the family.
   44|       |    Reuse {
   45|       |        family_id: String,
   46|       |    },
   47|       |    Unknown,
   48|       |}
   49|       |
   50|       |#[async_trait]
   51|       |pub trait RefreshStore: Send + Sync {
   52|       |    /// Persist a new refresh token; `token_hash` is SHA-256 hex of the opaque secret.
   53|       |    async fn put_token(
   54|       |        &self,
   55|       |        token_hash: &str,
   56|       |        record: &RefreshRecord,
   57|       |        ttl_secs: u64,
   58|       |    ) -> Result<(), RefreshStoreError>;
   59|       |
   60|       |    async fn get_token(&self, token_hash: &str)
   61|       |        -> Result<Option<RefreshRecord>, RefreshStoreError>;
   62|       |
   63|       |    async fn delete_token(&self, token_hash: &str) -> Result<(), RefreshStoreError>;
   64|       |
   65|       |    /// Tombstone a rotated-out token so reuse can be detected.
   66|       |    async fn mark_replay(
   67|       |        &self,
   68|       |        token_hash: &str,
   69|       |        family_id: &str,
   70|       |        ttl_secs: u64,
   71|       |    ) -> Result<(), RefreshStoreError>;
   72|       |
   73|       |    async fn is_replay(&self, token_hash: &str) -> Result<Option<String>, RefreshStoreError>;
   74|       |
   75|       |    async fn revoke_family(&self, family_id: &str, ttl_secs: u64) -> Result<(), RefreshStoreError>;
   76|       |
   77|       |    async fn is_family_revoked(&self, family_id: &str) -> Result<bool, RefreshStoreError>;
   78|       |}
   79|       |
   80|       |/// SHA-256 hex digest of the opaque refresh secret (never store the raw token).
   81|     24|pub fn hash_refresh_token(token: &str) -> String {
   82|       |    use sha2::{Digest, Sha256};
   83|     24|    hex::encode(Sha256::digest(token.as_bytes()))
   84|     24|}
   85|       |
   86|       |#[cfg(test)]
   87|       |mod tests {
   88|       |    use super::*;
   89|       |
   90|       |    #[test]
   91|      1|    fn hash_refresh_token_is_stable_hex() {
   92|      1|        let h = hash_refresh_token("opaque-secret");
   93|      1|        assert_eq!(h.len(), 64);
   94|      1|        assert_eq!(h, hash_refresh_token("opaque-secret"));
   95|      1|    }
   96|       |}

/home/runner/work/trust-relay/trust-relay/src/store/refresh/memory.rs:
    1|       |//! In-memory refresh-token store for tests and dev without Redis.
    2|       |
    3|       |use std::collections::HashMap;
    4|       |use std::sync::Mutex;
    5|       |use std::time::{Duration, Instant};
    6|       |
    7|       |use async_trait::async_trait;
    8|       |
    9|       |use super::{RefreshRecord, RefreshStore, RefreshStoreError};
   10|       |
   11|       |struct Timed<T> {
   12|       |    value: T,
   13|       |    expires_at: Instant,
   14|       |}
   15|       |
   16|       |#[derive(Default)]
   17|       |pub struct InMemoryRefreshStore {
   18|       |    tokens: Mutex<HashMap<String, Timed<RefreshRecord>>>,
   19|       |    replays: Mutex<HashMap<String, Timed<String>>>,
   20|       |    families: Mutex<HashMap<String, Timed<()>>>,
   21|       |}
   22|       |
   23|       |impl InMemoryRefreshStore {
   24|      4|    fn retain_tokens(map: &mut HashMap<String, Timed<RefreshRecord>>) {
   25|      4|        map.retain(|_, e| e.expires_at > Instant::now());
                                        ^2             ^2
   26|      4|    }
   27|       |
   28|      4|    fn retain_replays(map: &mut HashMap<String, Timed<String>>) {
   29|      4|        map.retain(|_, e| e.expires_at > Instant::now());
                                        ^2             ^2
   30|      4|    }
   31|       |
   32|      3|    fn retain_families(map: &mut HashMap<String, Timed<()>>) {
   33|      3|        map.retain(|_, e| e.expires_at > Instant::now());
                                        ^1             ^1
   34|      3|    }
   35|       |}
   36|       |
   37|       |#[async_trait]
   38|       |impl RefreshStore for InMemoryRefreshStore {
   39|       |    async fn put_token(
   40|       |        &self,
   41|       |        token_hash: &str,
   42|       |        record: &RefreshRecord,
   43|       |        ttl_secs: u64,
   44|      2|    ) -> Result<(), RefreshStoreError> {
   45|       |        let mut guard = self
   46|       |            .tokens
   47|       |            .lock()
   48|      0|            .map_err(|_| RefreshStoreError::Other("lock poisoned".into()))?;
   49|       |        Self::retain_tokens(&mut guard);
   50|       |        guard.insert(
   51|       |            token_hash.to_string(),
   52|       |            Timed {
   53|       |                value: record.clone(),
   54|       |                expires_at: Instant::now() + Duration::from_secs(ttl_secs.max(1)),
   55|       |            },
   56|       |        );
   57|       |        Ok(())
   58|      2|    }
   59|       |
   60|       |    async fn get_token(
   61|       |        &self,
   62|       |        token_hash: &str,
   63|      2|    ) -> Result<Option<RefreshRecord>, RefreshStoreError> {
   64|       |        let mut guard = self
   65|       |            .tokens
   66|       |            .lock()
   67|      0|            .map_err(|_| RefreshStoreError::Other("lock poisoned".into()))?;
   68|       |        Self::retain_tokens(&mut guard);
   69|      2|        Ok(guard.get(token_hash).map(|e| e.value.clone()))
   70|      2|    }
   71|       |
   72|      1|    async fn delete_token(&self, token_hash: &str) -> Result<(), RefreshStoreError> {
   73|       |        let mut guard = self
   74|       |            .tokens
   75|       |            .lock()
   76|      0|            .map_err(|_| RefreshStoreError::Other("lock poisoned".into()))?;
   77|       |        guard.remove(token_hash);
   78|       |        Ok(())
   79|      1|    }
   80|       |
   81|       |    async fn mark_replay(
   82|       |        &self,
   83|       |        token_hash: &str,
   84|       |        family_id: &str,
   85|       |        ttl_secs: u64,
   86|      1|    ) -> Result<(), RefreshStoreError> {
   87|       |        let mut guard = self
   88|       |            .replays
   89|       |            .lock()
   90|      0|            .map_err(|_| RefreshStoreError::Other("lock poisoned".into()))?;
   91|       |        Self::retain_replays(&mut guard);
   92|       |        guard.insert(
   93|       |            token_hash.to_string(),
   94|       |            Timed {
   95|       |                value: family_id.to_string(),
   96|       |                expires_at: Instant::now() + Duration::from_secs(ttl_secs.max(1)),
   97|       |            },
   98|       |        );
   99|       |        Ok(())
  100|      1|    }
  101|       |
  102|      3|    async fn is_replay(&self, token_hash: &str) -> Result<Option<String>, RefreshStoreError> {
  103|       |        let mut guard = self
  104|       |            .replays
  105|       |            .lock()
  106|      0|            .map_err(|_| RefreshStoreError::Other("lock poisoned".into()))?;
  107|       |        Self::retain_replays(&mut guard);
  108|      1|        Ok(guard.get(token_hash).map(|e| e.value.clone()))
  109|      3|    }
  110|       |
  111|      1|    async fn revoke_family(&self, family_id: &str, ttl_secs: u64) -> Result<(), RefreshStoreError> {
  112|       |        let mut guard = self
  113|       |            .families
  114|       |            .lock()
  115|      0|            .map_err(|_| RefreshStoreError::Other("lock poisoned".into()))?;
  116|       |        Self::retain_families(&mut guard);
  117|       |        guard.insert(
  118|       |            family_id.to_string(),
  119|       |            Timed {
  120|       |                value: (),
  121|       |                expires_at: Instant::now() + Duration::from_secs(ttl_secs.max(1)),
  122|       |            },
  123|       |        );
  124|       |        Ok(())
  125|      1|    }
  126|       |
  127|      2|    async fn is_family_revoked(&self, family_id: &str) -> Result<bool, RefreshStoreError> {
  128|       |        let mut guard = self
  129|       |            .families
  130|       |            .lock()
  131|      0|            .map_err(|_| RefreshStoreError::Other("lock poisoned".into()))?;
  132|       |        Self::retain_families(&mut guard);
  133|       |        Ok(guard.contains_key(family_id))
  134|      2|    }
  135|       |}

/home/runner/work/trust-relay/trust-relay/src/store/refresh/redis.rs:
    1|       |//! Redis-backed opaque refresh tokens.
    2|       |
    3|       |use async_trait::async_trait;
    4|       |use redis::AsyncCommands;
    5|       |use serde_json;
    6|       |
    7|       |use super::{RefreshRecord, RefreshStore, RefreshStoreError};
    8|       |
    9|       |const TOKEN_PREFIX: &str = "refresh:tok:";
   10|       |const REPLAY_PREFIX: &str = "refresh:replay:";
   11|       |const FAMILY_PREFIX: &str = "refresh:family:";
   12|       |
   13|       |#[derive(Clone)]
   14|       |pub struct RedisRefreshStore {
   15|       |    client: redis::aio::ConnectionManager,
   16|       |}
   17|       |
   18|       |impl RedisRefreshStore {
   19|     24|    pub async fn connect(url: &str) -> Result<Self, redis::RedisError> {
   20|     24|        let client = redis::Client::open(url)?;
                                                           ^0
   21|     24|        let conn = client.get_connection_manager().await?;
                                                                      ^0
   22|     24|        Ok(Self { client: conn })
   23|     24|    }
   24|       |
   25|     21|    fn token_key(hash: &str) -> String {
   26|     21|        format!("{TOKEN_PREFIX}{hash}")
   27|     21|    }
   28|       |
   29|      8|    fn replay_key(hash: &str) -> String {
   30|      8|        format!("{REPLAY_PREFIX}{hash}")
   31|      8|    }
   32|       |
   33|      4|    fn family_key(family_id: &str) -> String {
   34|      4|        format!("{FAMILY_PREFIX}{family_id}")
   35|      4|    }
   36|       |}
   37|       |
   38|       |#[async_trait]
   39|       |impl RefreshStore for RedisRefreshStore {
   40|       |    async fn put_token(
   41|       |        &self,
   42|       |        token_hash: &str,
   43|       |        record: &RefreshRecord,
   44|       |        ttl_secs: u64,
   45|     13|    ) -> Result<(), RefreshStoreError> {
   46|       |        let payload =
   47|      0|            serde_json::to_string(record).map_err(|e| RefreshStoreError::Other(e.to_string()))?;
   48|       |        let key = Self::token_key(token_hash);
   49|       |        let mut conn = self.client.clone();
   50|       |        conn.set_ex::<_, _, ()>(key, payload, ttl_secs.max(1))
   51|       |            .await?;
   52|       |        Ok(())
   53|     13|    }
   54|       |
   55|       |    async fn get_token(
   56|       |        &self,
   57|       |        token_hash: &str,
   58|      5|    ) -> Result<Option<RefreshRecord>, RefreshStoreError> {
   59|       |        let key = Self::token_key(token_hash);
   60|       |        let mut conn = self.client.clone();
   61|       |        let raw: Option<String> = conn.get(key).await?;
   62|       |        Ok(raw
   63|      4|            .map(|s| serde_json::from_str(&s).map_err(|e| RefreshStoreError::Other(e.to_string())))
                                                                                                 ^0^0
   64|       |            .transpose()?)
   65|      5|    }
   66|       |
   67|      3|    async fn delete_token(&self, token_hash: &str) -> Result<(), RefreshStoreError> {
   68|       |        let key = Self::token_key(token_hash);
   69|       |        let mut conn = self.client.clone();
   70|       |        conn.del::<_, ()>(key).await?;
   71|       |        Ok(())
   72|      3|    }
   73|       |
   74|       |    async fn mark_replay(
   75|       |        &self,
   76|       |        token_hash: &str,
   77|       |        family_id: &str,
   78|       |        ttl_secs: u64,
   79|      3|    ) -> Result<(), RefreshStoreError> {
   80|       |        let key = Self::replay_key(token_hash);
   81|       |        let mut conn = self.client.clone();
   82|       |        conn.set_ex::<_, _, ()>(key, family_id, ttl_secs.max(1))
   83|       |            .await?;
   84|       |        Ok(())
   85|      3|    }
   86|       |
   87|      5|    async fn is_replay(&self, token_hash: &str) -> Result<Option<String>, RefreshStoreError> {
   88|       |        let key = Self::replay_key(token_hash);
   89|       |        let mut conn = self.client.clone();
   90|       |        let raw: Option<String> = conn.get(key).await?;
   91|       |        Ok(raw)
   92|      5|    }
   93|       |
   94|      1|    async fn revoke_family(&self, family_id: &str, ttl_secs: u64) -> Result<(), RefreshStoreError> {
   95|       |        let key = Self::family_key(family_id);
   96|       |        let mut conn = self.client.clone();
   97|       |        conn.set_ex::<_, _, ()>(key, "1", ttl_secs.max(1)).await?;
   98|       |        Ok(())
   99|      1|    }
  100|       |
  101|      3|    async fn is_family_revoked(&self, family_id: &str) -> Result<bool, RefreshStoreError> {
  102|       |        let key = Self::family_key(family_id);
  103|       |        let mut conn = self.client.clone();
  104|       |        let exists: bool = conn.exists(key).await?;
  105|       |        Ok(exists)
  106|      3|    }
  107|       |}

/home/runner/work/trust-relay/trust-relay/src/store/revocation.rs:
    1|       |//! `jti` denylist and wallet blocklist persistence.
    2|       |
    3|       |mod memory;
    4|       |mod redis;
    5|       |
    6|       |pub use memory::InMemoryRevocationStore;
    7|       |pub use redis::RedisRevocationStore;
    8|       |
    9|       |use async_trait::async_trait;
   10|       |use thiserror::Error;
   11|       |
   12|       |use crate::error::AppError;
   13|       |
   14|       |const MIN_TTL_SECS: u64 = 1;
   15|       |
   16|       |#[derive(Debug, Error)]
   17|       |pub enum RevocationStoreError {
   18|       |    #[error("store unavailable")]
   19|       |    Unavailable(#[from] ::redis::RedisError),
   20|       |    #[error("{0}")]
   21|       |    Other(String),
   22|       |}
   23|       |
   24|       |impl From<RevocationStoreError> for AppError {
   25|      0|    fn from(err: RevocationStoreError) -> Self {
   26|      0|        Self::Internal(err.to_string())
   27|      0|    }
   28|       |}
   29|       |
   30|       |#[async_trait]
   31|       |pub trait RevocationStore: Send + Sync {
   32|       |    /// Add `jti` to the denylist until `ttl_secs` elapses (token remaining lifetime).
   33|       |    async fn revoke_jti(&self, jti: &str, ttl_secs: u64) -> Result<(), RevocationStoreError>;
   34|       |
   35|       |    async fn is_jti_revoked(&self, jti: &str) -> Result<bool, RevocationStoreError>;
   36|       |
   37|       |    /// Block a wallet from obtaining new sessions. `ttl_secs == 0` means no expiry.
   38|       |    async fn block_wallet(&self, wallet: &str, ttl_secs: u64) -> Result<(), RevocationStoreError>;
   39|       |
   40|       |    async fn is_wallet_blocked(&self, wallet: &str) -> Result<bool, RevocationStoreError>;
   41|       |}
   42|       |
   43|      7|pub fn normalize_ttl_secs(ttl_secs: i64) -> u64 {
   44|      7|    ttl_secs.max(MIN_TTL_SECS as i64) as u64
   45|      7|}
   46|       |
   47|       |#[cfg(test)]
   48|       |mod tests {
   49|       |    use super::*;
   50|       |
   51|       |    #[test]
   52|      1|    fn normalize_ttl_secs_enforces_minimum() {
   53|      1|        assert_eq!(normalize_ttl_secs(0), MIN_TTL_SECS);
   54|      1|        assert_eq!(normalize_ttl_secs(-5), MIN_TTL_SECS);
   55|      1|        assert_eq!(normalize_ttl_secs(120), 120);
   56|      1|    }
   57|       |}

/home/runner/work/trust-relay/trust-relay/src/store/revocation/memory.rs:
    1|       |//! In-memory revocation store for tests and development without Redis.
    2|       |
    3|       |use std::collections::HashMap;
    4|       |use std::sync::Mutex;
    5|       |use std::time::{Duration, Instant};
    6|       |
    7|       |use async_trait::async_trait;
    8|       |
    9|       |use super::{normalize_ttl_secs, RevocationStore, RevocationStoreError};
   10|       |
   11|       |struct JtiEntry {
   12|       |    expires_at: Instant,
   13|       |}
   14|       |
   15|       |#[derive(Default)]
   16|       |pub struct InMemoryRevocationStore {
   17|       |    jtis: Mutex<HashMap<String, JtiEntry>>,
   18|       |    wallets: Mutex<HashMap<String, Option<Instant>>>,
   19|       |}
   20|       |
   21|       |#[async_trait]
   22|       |impl RevocationStore for InMemoryRevocationStore {
   23|      1|    async fn revoke_jti(&self, jti: &str, ttl_secs: u64) -> Result<(), RevocationStoreError> {
   24|       |        let ttl = normalize_ttl_secs(ttl_secs as i64);
   25|       |        let mut guard = self
   26|       |            .jtis
   27|       |            .lock()
   28|      0|            .map_err(|_| RevocationStoreError::Other("lock poisoned".into()))?;
   29|       |        guard.insert(
   30|       |            jti.to_string(),
   31|       |            JtiEntry {
   32|       |                expires_at: Instant::now() + Duration::from_secs(ttl),
   33|       |            },
   34|       |        );
   35|       |        Ok(())
   36|      1|    }
   37|       |
   38|      2|    async fn is_jti_revoked(&self, jti: &str) -> Result<bool, RevocationStoreError> {
   39|       |        let mut guard = self
   40|       |            .jtis
   41|       |            .lock()
   42|      0|            .map_err(|_| RevocationStoreError::Other("lock poisoned".into()))?;
   43|      2|        guard.retain(|_, entry| entry.expires_at > Instant::now());
   44|       |        Ok(guard.contains_key(jti))
   45|      2|    }
   46|       |
   47|      2|    async fn block_wallet(&self, wallet: &str, ttl_secs: u64) -> Result<(), RevocationStoreError> {
   48|       |        let expires = if ttl_secs == 0 {
   49|       |            None
   50|       |        } else {
   51|       |            Some(Instant::now() + Duration::from_secs(ttl_secs))
   52|       |        };
   53|       |        self.wallets
   54|       |            .lock()
   55|      0|            .map_err(|_| RevocationStoreError::Other("lock poisoned".into()))?
   56|       |            .insert(wallet.to_string(), expires);
   57|       |        Ok(())
   58|      2|    }
   59|       |
   60|      2|    async fn is_wallet_blocked(&self, wallet: &str) -> Result<bool, RevocationStoreError> {
   61|       |        let mut guard = self
   62|       |            .wallets
   63|       |            .lock()
   64|      0|            .map_err(|_| RevocationStoreError::Other("lock poisoned".into()))?;
   65|      2|        guard.retain(|_, expiry| expiry.is_none_or(|t| t > Instant::now()));
                                                                     ^0  ^0
   66|       |        Ok(guard.contains_key(wallet))
   67|      2|    }
   68|       |}
   69|       |
   70|       |#[cfg(test)]
   71|       |mod tests {
   72|       |    use super::*;
   73|       |
   74|       |    #[tokio::test]
   75|      1|    async fn jti_revocation_expires() {
   76|      1|        let store = InMemoryRevocationStore::default();
   77|      1|        store.revoke_jti("jti-1", 1).await.unwrap();
   78|      1|        assert!(store.is_jti_revoked("jti-1").await.unwrap());
   79|      1|        tokio::time::sleep(Duration::from_secs(2)).await;
   80|      1|        assert!(!store.is_jti_revoked("jti-1").await.unwrap());
   81|      1|    }
   82|       |
   83|       |    #[tokio::test]
   84|      1|    async fn wallet_block_without_ttl_persists() {
   85|      1|        let store = InMemoryRevocationStore::default();
   86|      1|        store.block_wallet("0xabc", 0).await.unwrap();
   87|      1|        assert!(store.is_wallet_blocked("0xabc").await.unwrap());
   88|      1|    }
   89|       |}

/home/runner/work/trust-relay/trust-relay/src/store/revocation/redis.rs:
    1|       |//! Redis-backed `jti` denylist and wallet blocklist.
    2|       |
    3|       |use async_trait::async_trait;
    4|       |use redis::AsyncCommands;
    5|       |
    6|       |use super::{normalize_ttl_secs, RevocationStore, RevocationStoreError};
    7|       |
    8|       |const JTI_PREFIX: &str = "revoke:jti:";
    9|       |const WALLET_PREFIX: &str = "blocklist:wallet:";
   10|       |
   11|       |#[derive(Clone)]
   12|       |pub struct RedisRevocationStore {
   13|       |    client: redis::aio::ConnectionManager,
   14|       |}
   15|       |
   16|       |impl RedisRevocationStore {
   17|     24|    pub async fn connect(url: &str) -> Result<Self, redis::RedisError> {
   18|     24|        let client = redis::Client::open(url)?;
                                                           ^0
   19|     24|        let conn = client.get_connection_manager().await?;
                                                                      ^0
   20|     24|        Ok(Self { client: conn })
   21|     24|    }
   22|       |
   23|      4|    fn jti_key(jti: &str) -> String {
   24|      4|        format!("{JTI_PREFIX}{jti}")
   25|      4|    }
   26|       |
   27|     17|    fn wallet_key(wallet: &str) -> String {
   28|     17|        format!("{WALLET_PREFIX}{wallet}")
   29|     17|    }
   30|       |}
   31|       |
   32|       |#[async_trait]
   33|       |impl RevocationStore for RedisRevocationStore {
   34|      2|    async fn revoke_jti(&self, jti: &str, ttl_secs: u64) -> Result<(), RevocationStoreError> {
   35|       |        let ttl = normalize_ttl_secs(ttl_secs as i64);
   36|       |        let key = Self::jti_key(jti);
   37|       |        let mut conn = self.client.clone();
   38|       |        conn.set_ex::<_, _, ()>(key, "1", ttl).await?;
   39|       |        Ok(())
   40|      2|    }
   41|       |
   42|      2|    async fn is_jti_revoked(&self, jti: &str) -> Result<bool, RevocationStoreError> {
   43|       |        let key = Self::jti_key(jti);
   44|       |        let mut conn = self.client.clone();
   45|       |        let exists: bool = conn.exists(key).await?;
   46|       |        Ok(exists)
   47|      2|    }
   48|       |
   49|      2|    async fn block_wallet(&self, wallet: &str, ttl_secs: u64) -> Result<(), RevocationStoreError> {
   50|       |        let key = Self::wallet_key(wallet);
   51|       |        let mut conn = self.client.clone();
   52|       |        if ttl_secs == 0 {
   53|       |            conn.set::<_, _, ()>(key, "1").await?;
   54|       |        } else {
   55|       |            conn.set_ex::<_, _, ()>(key, "1", ttl_secs).await?;
   56|       |        }
   57|       |        Ok(())
   58|      2|    }
   59|       |
   60|     15|    async fn is_wallet_blocked(&self, wallet: &str) -> Result<bool, RevocationStoreError> {
   61|       |        let key = Self::wallet_key(wallet);
   62|       |        let mut conn = self.client.clone();
   63|       |        let exists: bool = conn.exists(key).await?;
   64|       |        Ok(exists)
   65|     15|    }
   66|       |}

/home/runner/work/trust-relay/trust-relay/src/telemetry.rs:
    1|       |//! Tracing subscriber initialization.
    2|       |
    3|       |use crate::config::TelemetryConfig;
    4|       |use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
    5|       |
    6|      4|pub fn init(cfg: &TelemetryConfig) {
    7|      4|    let filter = EnvFilter::try_new(&cfg.filter).unwrap_or_else(|_| EnvFilter::new("info"));
                                                                                  ^1
    8|      4|    let registry = tracing_subscriber::registry().with(filter);
    9|       |
   10|      4|    match cfg.format.as_str() {
   11|      4|        "json" => registry
                                ^1
   12|      1|            .with(tracing_subscriber::fmt::layer().json())
   13|      1|            .init(),
   14|      3|        _ => registry.with(tracing_subscriber::fmt::layer()).init(),
   15|       |    }
   16|      4|}

aliXsed added 2 commits June 5, 2026 13:14
ExposeMode routes wallet APIs separately from RS-only POST /quota/consume,
with DEPLOYMENT.md, ADR 0003, and architecture/sequence diagrams.
Clarify that internal quota consume must forward the user Authorization header;
fix sequenceDiagram labels (no semicolons) and add mermaid-github cursor rule.
@aliXsed aliXsed merged commit 86890c2 into main Jun 5, 2026
3 checks passed
@aliXsed aliXsed deleted the feat/quota-m5 branch June 5, 2026 01:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant