Browse Source

feat: glob pattern for nut21/22 (#1586)

tsk 1 week ago
parent
commit
eae8f6490b
4 changed files with 477 additions and 58 deletions
  1. 0 1
      Cargo.lock
  2. 0 1
      crates/cashu/Cargo.toml
  3. 471 47
      crates/cashu/src/nuts/auth/nut21.rs
  4. 6 9
      crates/cashu/src/nuts/auth/nut22.rs

+ 0 - 1
Cargo.lock

@@ -1119,7 +1119,6 @@ dependencies = [
  "lightning-invoice 0.34.0",
  "nostr-sdk",
  "once_cell",
- "regex",
  "serde",
  "serde_json",
  "serde_with",

+ 0 - 1
crates/cashu/Cargo.toml

@@ -33,7 +33,6 @@ url.workspace = true
 utoipa = { workspace = true, optional = true }
 serde_json.workspace = true
 serde_with.workspace = true
-regex.workspace = true
 strum.workspace = true
 strum_macros.workspace = true
 nostr-sdk = { workspace = true, optional = true }

+ 471 - 47
crates/cashu/src/nuts/auth/nut21.rs

@@ -3,16 +3,18 @@
 use std::collections::HashSet;
 use std::str::FromStr;
 
-use regex::Regex;
 use serde::{Deserialize, Serialize};
 use thiserror::Error;
 
 /// NUT21 Error
 #[derive(Debug, Error)]
 pub enum Error {
-    /// Invalid regex pattern
-    #[error("Invalid regex pattern: {0}")]
-    InvalidRegex(#[from] regex::Error),
+    /// Invalid pattern
+    #[error("Invalid pattern: {0}")]
+    InvalidPattern(String),
+    /// Unknown route path
+    #[error("Unknown route path: {0}. Valid paths are: /v1/mint/quote/{{method}}, /v1/mint/{{method}}, /v1/melt/quote/{{method}}, /v1/melt/{{method}}, /v1/swap, /v1/checkstate, /v1/restore, /v1/auth/blind/mint, /v1/ws")]
+    UnknownRoute(String),
 }
 
 /// Clear Auth Settings
@@ -42,7 +44,7 @@ impl Settings {
     }
 }
 
-// Custom deserializer for Settings to expand regex patterns in protected endpoints
+// Custom deserializer for Settings to expand patterns in protected endpoints
 impl<'de> Deserialize<'de> for Settings {
     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
     where
@@ -65,15 +67,12 @@ impl<'de> Deserialize<'de> for Settings {
         // Deserialize into the temporary struct
         let raw = RawSettings::deserialize(deserializer)?;
 
-        // Process protected endpoints, expanding regex patterns if present
+        // Process protected endpoints, expanding patterns if present
         let mut protected_endpoints = HashSet::new();
 
         for raw_endpoint in raw.protected_endpoints {
             let expanded_paths = matching_route_paths(&raw_endpoint.path).map_err(|e| {
-                serde::de::Error::custom(format!(
-                    "Invalid regex pattern '{}': {}",
-                    raw_endpoint.path, e
-                ))
+                serde::de::Error::custom(format!("Invalid pattern '{}': {}", raw_endpoint.path, e))
             })?;
 
             for path in expanded_paths {
@@ -151,15 +150,12 @@ impl Serialize for RoutePath {
     }
 }
 
-impl<'de> Deserialize<'de> for RoutePath {
-    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
-    where
-        D: serde::Deserializer<'de>,
-    {
-        let s = String::deserialize(deserializer)?;
+impl std::str::FromStr for RoutePath {
+    type Err = Error;
 
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
         // Try to parse as a known static path first
-        match s.as_str() {
+        match s {
             "/v1/swap" => Ok(RoutePath::Swap),
             "/v1/checkstate" => Ok(RoutePath::Checkstate),
             "/v1/restore" => Ok(RoutePath::Restore),
@@ -178,16 +174,23 @@ impl<'de> Deserialize<'de> for RoutePath {
                 } else {
                     // Unknown path - this might be an old database value or config
                     // Provide a helpful error message
-                    Err(serde::de::Error::custom(format!(
-                        "Unknown route path: {}. Valid paths are: /v1/mint/quote/{{method}}, /v1/mint/{{method}}, /v1/melt/quote/{{method}}, /v1/melt/{{method}}, /v1/swap, /v1/checkstate, /v1/restore, /v1/auth/blind/mint, /v1/ws",
-                        s
-                    )))
+                    Err(Error::UnknownRoute(s.to_string()))
                 }
             }
         }
     }
 }
 
+impl<'de> Deserialize<'de> for RoutePath {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: serde::Deserializer<'de>,
+    {
+        let s = String::deserialize(deserializer)?;
+        RoutePath::from_str(&s).map_err(serde::de::Error::custom)
+    }
+}
+
 impl RoutePath {
     /// Get all non-payment-method route paths
     /// These are routes that don't depend on payment methods
@@ -202,7 +205,7 @@ impl RoutePath {
     }
 
     /// Get all route paths for common payment methods (bolt11, bolt12)
-    /// This is used for regex matching in configuration
+    /// This is used for pattern matching in configuration
     pub fn common_payment_method_paths() -> Vec<RoutePath> {
         let methods = vec!["bolt11", "bolt12"];
         let mut paths = Vec::new();
@@ -217,7 +220,7 @@ impl RoutePath {
         paths
     }
 
-    /// Get all paths for regex matching (static + common payment methods)
+    /// Get all paths for pattern matching (static + common payment methods)
     pub fn all_known_paths() -> Vec<RoutePath> {
         let mut paths = Self::static_paths();
         paths.extend(Self::common_payment_method_paths());
@@ -225,15 +228,36 @@ impl RoutePath {
     }
 }
 
-/// Returns [`RoutePath`]s that match regex
-/// Matches against all known static paths and common payment methods (bolt11, bolt12)
+/// Returns [`RoutePath`]s that match the pattern (Exact or Prefix)
 pub fn matching_route_paths(pattern: &str) -> Result<Vec<RoutePath>, Error> {
-    let regex = Regex::from_str(pattern)?;
+    // Check for wildcard
+    if let Some(prefix) = pattern.strip_suffix('*') {
+        // Prefix matching
+        // Ensure '*' is only at the end
+        if prefix.contains('*') {
+            return Err(Error::InvalidPattern(
+                "Wildcard '*' must be the last character".to_string(),
+            ));
+        }
 
-    Ok(RoutePath::all_known_paths()
-        .into_iter()
-        .filter(|path| regex.is_match(&path.to_string()))
-        .collect())
+        // Filter all known paths
+        Ok(RoutePath::all_known_paths()
+            .into_iter()
+            .filter(|path| path.to_string().starts_with(prefix))
+            .collect())
+    } else {
+        // Exact matching
+        if pattern.contains('*') {
+            return Err(Error::InvalidPattern(
+                "Wildcard '*' must be the last character".to_string(),
+            ));
+        }
+
+        match RoutePath::from_str(pattern) {
+            Ok(path) => Ok(vec![path]),
+            Err(_) => Ok(vec![]), // Ignore unknown paths for matching
+        }
+    }
 }
 impl std::fmt::Display for RoutePath {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
@@ -259,9 +283,55 @@ mod tests {
     use crate::PaymentMethod;
 
     #[test]
+    fn test_matching_route_paths_root_wildcard() {
+        // Pattern that matches everything
+        let paths = matching_route_paths("*").unwrap();
+
+        // Should match all known variants
+        assert_eq!(paths.len(), RoutePath::all_known_paths().len());
+    }
+
+    #[test]
+    fn test_matching_route_paths_middle_wildcard() {
+        // Invalid wildcard position
+        let result = matching_route_paths("/v1/*/mint");
+        assert!(result.is_err());
+        assert!(matches!(result.unwrap_err(), Error::InvalidPattern(_)));
+    }
+
+    #[test]
+    fn test_matching_route_paths_prefix_without_slash() {
+        // "/v1/mint*" matches "/v1/mint" and "/v1/mint/..."
+        let paths = matching_route_paths("/v1/mint*").unwrap();
+
+        // Should match all mint paths + mint quote paths
+        assert_eq!(paths.len(), 4);
+
+        // Should NOT match /v1/melt...
+        assert!(!paths.contains(&RoutePath::MeltQuote(
+            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
+        )));
+    }
+
+    #[test]
+    fn test_matching_route_paths_exact_match_unknown() {
+        // Exact match for unknown path structure should return empty list
+        let paths = matching_route_paths("/v1/invalid/path").unwrap();
+        assert!(paths.is_empty());
+    }
+
+    #[test]
+    fn test_matching_route_paths_dynamic_method() {
+        // Verify that custom payment methods are parsed correctly
+        let paths = matching_route_paths("/v1/mint/custom_method").unwrap();
+        assert_eq!(paths.len(), 1);
+        assert_eq!(paths[0], RoutePath::Mint("custom_method".to_string()));
+    }
+
+    #[test]
     fn test_matching_route_paths_all() {
-        // Regex that matches all paths
-        let paths = matching_route_paths(".*").unwrap();
+        // Prefix that matches all paths
+        let paths = matching_route_paths("/v1/*").unwrap();
 
         // Should match all known variants
         assert_eq!(paths.len(), RoutePath::all_known_paths().len());
@@ -294,7 +364,7 @@ mod tests {
     #[test]
     fn test_matching_route_paths_mint_only() {
         // Regex that matches only mint paths
-        let paths = matching_route_paths("^/v1/mint/.*").unwrap();
+        let paths = matching_route_paths("/v1/mint/*").unwrap();
 
         // Should match only mint paths (4 paths: mint quote and mint for bolt11 and bolt12)
         assert_eq!(paths.len(), 4);
@@ -330,22 +400,16 @@ mod tests {
     #[test]
     fn test_matching_route_paths_quote_only() {
         // Regex that matches only quote paths
-        let paths = matching_route_paths(".*/quote/.*").unwrap();
+        let paths = matching_route_paths("/v1/mint/quote/*").unwrap();
 
-        // Should match only quote paths (4 paths: mint quote and melt quote for bolt11 and bolt12)
-        assert_eq!(paths.len(), 4);
+        // Should match only quote paths (2 paths: mint quote for bolt11 and bolt12)
+        assert_eq!(paths.len(), 2);
         assert!(paths.contains(&RoutePath::MintQuote(
             PaymentMethod::Known(KnownMethod::Bolt11).to_string()
         )));
-        assert!(paths.contains(&RoutePath::MeltQuote(
-            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
-        )));
         assert!(paths.contains(&RoutePath::MintQuote(
             PaymentMethod::Known(KnownMethod::Bolt12).to_string()
         )));
-        assert!(paths.contains(&RoutePath::MeltQuote(
-            PaymentMethod::Known(KnownMethod::Bolt12).to_string()
-        )));
 
         // Should not match non-quote paths
         assert!(!paths.contains(&RoutePath::Mint(
@@ -380,11 +444,11 @@ mod tests {
     #[test]
     fn test_matching_route_paths_invalid_regex() {
         // Invalid regex pattern
-        let result = matching_route_paths("(unclosed parenthesis");
+        let result = matching_route_paths("/*unclosed parenthesis");
 
         // Should return an error for invalid regex
         assert!(result.is_err());
-        assert!(matches!(result.unwrap_err(), Error::InvalidRegex(_)));
+        assert!(matches!(result.unwrap_err(), Error::InvalidPattern(_)));
     }
 
     #[test]
@@ -492,7 +556,7 @@ mod tests {
             "protected_endpoints": [
                 {
                     "method": "GET",
-                    "path": "^/v1/mint/.*"
+                    "path": "/v1/mint/*"
                 },
                 {
                     "method": "POST",
@@ -543,7 +607,7 @@ mod tests {
             "protected_endpoints": [
                 {
                     "method": "GET",
-                    "path": "(unclosed parenthesis"
+                    "path": "/*wildcard_start"
                 }
             ]
         }"#;
@@ -582,7 +646,7 @@ mod tests {
             "protected_endpoints": [
                 {
                     "method": "GET",
-                    "path": ".*"
+                    "path": "/v1/*"
                 }
             ]
         }"#;
@@ -593,4 +657,364 @@ mod tests {
             RoutePath::all_known_paths().len()
         );
     }
+
+    #[test]
+    fn test_matching_route_paths_empty_pattern() {
+        // Empty pattern should return empty list (nothing matches)
+        let paths = matching_route_paths("").unwrap();
+        assert!(paths.is_empty());
+    }
+
+    #[test]
+    fn test_matching_route_paths_just_slash() {
+        // Pattern "/" should not match any known paths (all start with /v1/)
+        let paths = matching_route_paths("/").unwrap();
+        assert!(paths.is_empty());
+    }
+
+    #[test]
+    fn test_matching_route_paths_trailing_slash() {
+        // Pattern with trailing slash after wildcard: "/v1/mint/*/"
+        // The wildcard "*" is not the last character ("/" comes after it)
+        // This should be an invalid pattern according to the spec
+        let result = matching_route_paths("/v1/mint/*/");
+        assert!(result.is_err());
+        assert!(matches!(result.unwrap_err(), Error::InvalidPattern(_)));
+    }
+
+    #[test]
+    fn test_matching_route_paths_consecutive_wildcards() {
+        // Pattern "**" - the first * is the suffix, second * is in prefix
+        // After strip_suffix('*'), we get "*" which contains '*'
+        // This should be an error because wildcard must be at the end only
+        let result = matching_route_paths("**");
+        assert!(result.is_err());
+        assert!(matches!(result.unwrap_err(), Error::InvalidPattern(_)));
+    }
+
+    #[test]
+    fn test_matching_route_paths_method_specific() {
+        // Test that GET and POST methods are properly distinguished
+        // The matching function only returns paths, methods are handled by Settings
+        // This test verifies paths are correctly matched regardless of method
+        let paths = matching_route_paths("/v1/swap").unwrap();
+        assert_eq!(paths.len(), 1);
+        assert!(paths.contains(&RoutePath::Swap));
+    }
+
+    #[test]
+    fn test_settings_mixed_methods() {
+        // Test Settings with mixed methods for same path pattern
+        let json = r#"{
+            "openid_discovery": "https://example.com/.well-known/openid-configuration",
+            "client_id": "client123",
+            "protected_endpoints": [
+                {
+                    "method": "GET",
+                    "path": "/v1/swap"
+                },
+                {
+                    "method": "POST",
+                    "path": "/v1/swap"
+                }
+            ]
+        }"#;
+
+        let settings: Settings = serde_json::from_str(json).unwrap();
+        assert_eq!(settings.protected_endpoints.len(), 2);
+
+        // Check both methods are present
+        let methods: Vec<_> = settings
+            .protected_endpoints
+            .iter()
+            .map(|ep| ep.method)
+            .collect();
+        assert!(methods.contains(&Method::Get));
+        assert!(methods.contains(&Method::Post));
+
+        // Both should have the same path
+        for ep in &settings.protected_endpoints {
+            assert_eq!(ep.path, RoutePath::Swap);
+        }
+    }
+
+    #[test]
+    fn test_matching_route_paths_melt_prefix() {
+        // Test prefix matching for melt endpoints: "/v1/melt/*"
+        let paths = matching_route_paths("/v1/melt/*").unwrap();
+
+        // Should match 4 melt paths (bolt11/12 for melt and melt quote)
+        assert_eq!(paths.len(), 4);
+        assert!(paths.contains(&RoutePath::Melt(
+            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
+        )));
+        assert!(paths.contains(&RoutePath::MeltQuote(
+            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
+        )));
+        assert!(paths.contains(&RoutePath::Melt(
+            PaymentMethod::Known(KnownMethod::Bolt12).to_string()
+        )));
+        assert!(paths.contains(&RoutePath::MeltQuote(
+            PaymentMethod::Known(KnownMethod::Bolt12).to_string()
+        )));
+
+        // Should NOT match mint paths
+        assert!(!paths.contains(&RoutePath::Mint(
+            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
+        )));
+    }
+
+    #[test]
+    fn test_matching_route_paths_static_exact() {
+        // Test exact matches for static paths
+        let swap_paths = matching_route_paths("/v1/swap").unwrap();
+        assert_eq!(swap_paths.len(), 1);
+        assert_eq!(swap_paths[0], RoutePath::Swap);
+
+        let checkstate_paths = matching_route_paths("/v1/checkstate").unwrap();
+        assert_eq!(checkstate_paths.len(), 1);
+        assert_eq!(checkstate_paths[0], RoutePath::Checkstate);
+
+        let restore_paths = matching_route_paths("/v1/restore").unwrap();
+        assert_eq!(restore_paths.len(), 1);
+        assert_eq!(restore_paths[0], RoutePath::Restore);
+
+        let ws_paths = matching_route_paths("/v1/ws").unwrap();
+        assert_eq!(ws_paths.len(), 1);
+        assert_eq!(ws_paths[0], RoutePath::Ws);
+    }
+
+    #[test]
+    fn test_matching_route_paths_auth_blind_mint() {
+        // Test exact match for auth blind mint endpoint
+        let paths = matching_route_paths("/v1/auth/blind/mint").unwrap();
+        assert_eq!(paths.len(), 1);
+        assert_eq!(paths[0], RoutePath::MintBlindAuth);
+    }
+
+    #[test]
+    fn test_settings_empty_endpoints() {
+        // Test Settings with empty protected_endpoints array
+        let json = r#"{
+            "openid_discovery": "https://example.com/.well-known/openid-configuration",
+            "client_id": "client123",
+            "protected_endpoints": []
+        }"#;
+
+        let settings: Settings = serde_json::from_str(json).unwrap();
+        assert!(settings.protected_endpoints.is_empty());
+    }
+
+    #[test]
+    fn test_settings_duplicate_paths() {
+        // Test that duplicate paths are deduplicated by HashSet
+        // Using same pattern twice with same method should result in single entry
+        let json = r#"{
+            "openid_discovery": "https://example.com/.well-known/openid-configuration",
+            "client_id": "client123",
+            "protected_endpoints": [
+                {
+                    "method": "POST",
+                    "path": "/v1/swap"
+                },
+                {
+                    "method": "POST",
+                    "path": "/v1/swap"
+                }
+            ]
+        }"#;
+
+        let settings: Settings = serde_json::from_str(json).unwrap();
+        assert_eq!(settings.protected_endpoints.len(), 1);
+        assert_eq!(settings.protected_endpoints[0].method, Method::Post);
+        assert_eq!(settings.protected_endpoints[0].path, RoutePath::Swap);
+    }
+
+    #[test]
+    fn test_matching_route_paths_only_wildcard() {
+        // Pattern with just "*" matches everything
+        let paths = matching_route_paths("*").unwrap();
+        assert_eq!(paths.len(), RoutePath::all_known_paths().len());
+    }
+
+    #[test]
+    fn test_matching_route_paths_wildcard_in_middle() {
+        // Pattern "/v1/*/bolt11" - wildcard in the middle
+        // After strip_suffix('*'), we get "/v1/*/bolt11" which contains '*'
+        // This should be an error
+        let result = matching_route_paths("/v1/*/bolt11");
+        assert!(result.is_err());
+        assert!(matches!(result.unwrap_err(), Error::InvalidPattern(_)));
+    }
+
+    #[test]
+    fn test_exact_match_no_child_paths() {
+        // Exact match "/v1/mint" should NOT match child paths like "/v1/mint/bolt11"
+        let paths = matching_route_paths("/v1/mint").unwrap();
+
+        // "/v1/mint" is not a valid RoutePath by itself (needs payment method)
+        // So it should return empty
+        assert!(paths.is_empty());
+
+        // Also verify it doesn't match any mint paths with payment methods
+        assert!(!paths.contains(&RoutePath::Mint(
+            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
+        )));
+        assert!(!paths.contains(&RoutePath::MintQuote(
+            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
+        )));
+    }
+
+    #[test]
+    fn test_exact_match_no_extra_path() {
+        // Exact match "/v1/swap" should NOT match "/v1/swap/extra"
+        // Since "/v1/swap/extra" is not a known path, it won't be in all_known_paths
+        // But let's verify "/v1/swap" only matches the exact Swap path
+        let paths = matching_route_paths("/v1/swap").unwrap();
+        assert_eq!(paths.len(), 1);
+        assert_eq!(paths[0], RoutePath::Swap);
+
+        // Verify it doesn't match any other paths
+        assert!(!paths.contains(&RoutePath::Checkstate));
+        assert!(!paths.contains(&RoutePath::Restore));
+    }
+
+    #[test]
+    fn test_partial_prefix_matching() {
+        // Pattern "/v1/mi*" - partial prefix that matches "/v1/mint/..." but not "/v1/melt/..."
+        let paths = matching_route_paths("/v1/mi*").unwrap();
+
+        // This DOES match "/v1/mint/bolt11" because "/v1/mint/bolt11" starts with "/v1/mi"
+        assert!(paths.contains(&RoutePath::Mint(
+            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
+        )));
+        assert!(paths.contains(&RoutePath::MintQuote(
+            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
+        )));
+
+        // But it does NOT match melt paths because "/v1/melt" doesn't start with "/v1/mi"
+        assert!(!paths.contains(&RoutePath::Melt(
+            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
+        )));
+        assert!(!paths.contains(&RoutePath::MeltQuote(
+            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
+        )));
+    }
+
+    #[test]
+    fn test_exact_match_wrong_payment_method() {
+        // Pattern "/v1/mint/quote/bolt11" should NOT match "/v1/mint/quote/bolt12"
+        let paths = matching_route_paths("/v1/mint/quote/bolt11").unwrap();
+
+        assert_eq!(paths.len(), 1);
+        assert!(paths.contains(&RoutePath::MintQuote(
+            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
+        )));
+
+        // Should NOT contain bolt12
+        assert!(!paths.contains(&RoutePath::MintQuote(
+            PaymentMethod::Known(KnownMethod::Bolt12).to_string()
+        )));
+
+        // Should NOT contain regular mint (non-quote)
+        assert!(!paths.contains(&RoutePath::Mint(
+            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
+        )));
+    }
+
+    #[test]
+    fn test_prefix_match_wrong_category() {
+        // Pattern "/v1/mint/*" should NOT match melt paths "/v1/melt/*"
+        let paths = matching_route_paths("/v1/mint/*").unwrap();
+
+        // Should contain mint paths
+        assert!(paths.contains(&RoutePath::Mint(
+            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
+        )));
+        assert!(paths.contains(&RoutePath::MintQuote(
+            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
+        )));
+
+        // Should NOT contain melt paths (different category)
+        assert!(!paths.contains(&RoutePath::Melt(
+            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
+        )));
+        assert!(!paths.contains(&RoutePath::MeltQuote(
+            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
+        )));
+
+        // Should NOT contain static paths
+        assert!(!paths.contains(&RoutePath::Swap));
+        assert!(!paths.contains(&RoutePath::Checkstate));
+    }
+
+    #[test]
+    fn test_case_sensitivity() {
+        // Pattern "/v1/MINT/*" should NOT match "/v1/mint/bolt11" (case sensitive)
+        let paths_upper = matching_route_paths("/v1/MINT/*").unwrap();
+        let paths_lower = matching_route_paths("/v1/mint/*").unwrap();
+
+        // Uppercase should NOT match any known paths
+        assert!(paths_upper.is_empty());
+
+        // Lowercase should match 4 mint paths
+        assert_eq!(paths_lower.len(), 4);
+    }
+
+    #[test]
+    fn test_negative_assertions_comprehensive() {
+        // Comprehensive test that verifies multiple negative cases in one place
+
+        // 1. Exact match for wrong payment method
+        let bolt11_paths = matching_route_paths("/v1/mint/quote/bolt11").unwrap();
+        assert!(!bolt11_paths.contains(&RoutePath::MintQuote(
+            PaymentMethod::Known(KnownMethod::Bolt12).to_string()
+        )));
+
+        // 2. Prefix for one category doesn't match another
+        let mint_paths = matching_route_paths("/v1/mint/*").unwrap();
+        assert!(!mint_paths.contains(&RoutePath::Melt(
+            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
+        )));
+        assert!(!mint_paths.contains(&RoutePath::MeltQuote(
+            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
+        )));
+
+        // 3. Exact match for static path doesn't match others
+        let swap_paths = matching_route_paths("/v1/swap").unwrap();
+        assert!(!swap_paths.contains(&RoutePath::Checkstate));
+        assert!(!swap_paths.contains(&RoutePath::Restore));
+        assert!(!swap_paths.contains(&RoutePath::MintBlindAuth));
+
+        // 4. Case sensitivity - wrong case matches nothing
+        assert!(matching_route_paths("/V1/SWAP").unwrap().is_empty());
+        assert!(matching_route_paths("/V1/MINT/*").unwrap().is_empty());
+
+        // 5. Invalid/unknown paths match nothing
+        assert!(matching_route_paths("/unknown/path").unwrap().is_empty());
+        assert!(matching_route_paths("/invalid").unwrap().is_empty());
+    }
+
+    #[test]
+    fn test_prefix_vs_exact_boundary() {
+        // Pattern "/v1/mint/quote/*" should NOT match "/v1/mint/quote" itself
+        let paths = matching_route_paths("/v1/mint/quote/*").unwrap();
+
+        // The pattern requires something after "/v1/mint/quote/"
+        // So "/v1/mint/quote" (without payment method) is NOT a valid RoutePath
+        // and won't be in the results
+        assert!(!paths.is_empty()); // Should have bolt11 and bolt12
+
+        // Verify we have the quote paths with payment methods
+        assert!(paths.contains(&RoutePath::MintQuote(
+            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
+        )));
+        assert!(paths.contains(&RoutePath::MintQuote(
+            PaymentMethod::Known(KnownMethod::Bolt12).to_string()
+        )));
+
+        // But there is no RoutePath::MintQuote without a payment method
+        // So the list should only contain 2 items (bolt11 and bolt12), not a bare "/v1/mint/quote"
+        assert_eq!(paths.len(), 2);
+    }
 }

+ 6 - 9
crates/cashu/src/nuts/auth/nut22.rs

@@ -59,7 +59,7 @@ impl Settings {
     }
 }
 
-// Custom deserializer for Settings to expand regex patterns in protected endpoints
+// Custom deserializer for Settings to expand patterns in protected endpoints
 impl<'de> Deserialize<'de> for Settings {
     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
     where
@@ -85,15 +85,12 @@ impl<'de> Deserialize<'de> for Settings {
         // Deserialize into the temporary struct
         let raw = RawSettings::deserialize(deserializer)?;
 
-        // Process protected endpoints, expanding regex patterns if present
+        // Process protected endpoints, expanding patterns if present
         let mut protected_endpoints = HashSet::new();
 
         for raw_endpoint in raw.protected_endpoints {
             let expanded_paths = matching_route_paths(&raw_endpoint.path).map_err(|e| {
-                serde::de::Error::custom(format!(
-                    "Invalid regex pattern '{}': {}",
-                    raw_endpoint.path, e
-                ))
+                serde::de::Error::custom(format!("Invalid pattern '{}': {}", raw_endpoint.path, e))
             })?;
 
             for path in expanded_paths {
@@ -321,7 +318,7 @@ mod tests {
             "protected_endpoints": [
                 {
                     "method": "GET",
-                    "path": "^/v1/mint/.*"
+                    "path": "/v1/mint/*"
                 },
                 {
                     "method": "POST",
@@ -367,7 +364,7 @@ mod tests {
             "protected_endpoints": [
                 {
                     "method": "GET",
-                    "path": "(unclosed parenthesis"
+                    "path": "/*wildcard_start"
                 }
             ]
         }"#;
@@ -383,7 +380,7 @@ mod tests {
             "protected_endpoints": [
                 {
                     "method": "GET",
-                    "path": ".*"
+                    "path": "/v1/*"
                 }
             ]
         }"#;