Skip to main content

tuitbot_core/x_api/
scopes.rs

1//! OAuth scope registry and diagnostics for X API features.
2//!
3//! Centralizes the required scopes and maps missing scopes to degraded
4//! product capabilities for actionable diagnostics.
5
6use std::collections::BTreeSet;
7
8use serde::{Deserialize, Serialize};
9
10/// OAuth 2.0 scopes required by Tuitbot.
11pub const REQUIRED_SCOPES: &[&str] = &[
12    "tweet.read",
13    "tweet.write",
14    "users.read",
15    "follows.read",
16    "follows.write",
17    "like.read",
18    "like.write",
19    "bookmark.read",
20    "bookmark.write",
21    "dm.read",
22    "dm.write",
23    "offline.access",
24];
25
26/// Mapping between a feature and the scopes it requires.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct FeatureScopeMapping {
29    /// Feature label shown to users.
30    pub feature: &'static str,
31    /// Short explanation of what the feature does.
32    pub description: &'static str,
33    /// Scopes required for this feature.
34    pub required_scopes: &'static [&'static str],
35}
36
37/// Feature-to-scope registry used by diagnostics.
38pub const FEATURE_SCOPE_MAP: &[FeatureScopeMapping] = &[
39    FeatureScopeMapping {
40        feature: "Search tweets",
41        description: "Search recent tweets for discovery and targeting.",
42        required_scopes: &["tweet.read", "users.read"],
43    },
44    FeatureScopeMapping {
45        feature: "Post tweet/reply/thread",
46        description: "Create tweets, replies, and thread posts.",
47        required_scopes: &["tweet.read", "tweet.write", "users.read"],
48    },
49    FeatureScopeMapping {
50        feature: "Like/unlike",
51        description: "Like and unlike tweets on behalf of the account.",
52        required_scopes: &["like.read", "like.write", "users.read"],
53    },
54    FeatureScopeMapping {
55        feature: "Follow/unfollow",
56        description: "Follow and unfollow users from the authenticated account.",
57        required_scopes: &["follows.read", "follows.write", "users.read"],
58    },
59    FeatureScopeMapping {
60        feature: "Read mentions",
61        description: "Read @mentions for mention-reply workflows.",
62        required_scopes: &["tweet.read", "users.read"],
63    },
64    FeatureScopeMapping {
65        feature: "Bookmarks",
66        description: "Bookmark and unbookmark tweets, read bookmarked tweets.",
67        required_scopes: &["bookmark.read", "bookmark.write", "users.read"],
68    },
69    FeatureScopeMapping {
70        feature: "Token refresh",
71        description: "Refresh access tokens without re-authentication.",
72        required_scopes: &["offline.access"],
73    },
74    FeatureScopeMapping {
75        feature: "Read DMs",
76        description: "Read direct message conversations and events.",
77        required_scopes: &["dm.read", "users.read"],
78    },
79    FeatureScopeMapping {
80        feature: "Send DMs",
81        description: "Send direct messages and create group conversations.",
82        required_scopes: &["dm.write", "dm.read", "users.read"],
83    },
84];
85
86/// A degraded feature caused by missing scopes.
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88pub struct DegradedFeature {
89    /// Feature label.
90    pub feature: String,
91    /// Human-readable feature description.
92    pub description: String,
93    /// Missing scopes that degrade this feature.
94    pub missing_scopes: Vec<String>,
95}
96
97/// Result of comparing granted OAuth scopes to required scopes.
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99pub struct ScopeAnalysis {
100    /// Granted scopes from the token.
101    pub granted: Vec<String>,
102    /// Scopes required by Tuitbot.
103    pub required: Vec<String>,
104    /// Required scopes missing from the token.
105    pub missing: Vec<String>,
106    /// Granted scopes not required by Tuitbot.
107    pub extra: Vec<String>,
108    /// Features degraded due to missing scopes.
109    pub degraded_features: Vec<DegradedFeature>,
110    /// True when all required scopes are present.
111    pub all_required_present: bool,
112}
113
114/// Compare granted OAuth scopes with required scopes.
115pub fn analyze_scopes(granted: &[String]) -> ScopeAnalysis {
116    let granted_set: BTreeSet<String> = granted
117        .iter()
118        .map(|scope| scope.trim())
119        .filter(|scope| !scope.is_empty())
120        .map(ToOwned::to_owned)
121        .collect();
122
123    let required_set: BTreeSet<String> = REQUIRED_SCOPES.iter().map(|s| (*s).to_string()).collect();
124
125    let missing: Vec<String> = required_set.difference(&granted_set).cloned().collect();
126    let extra: Vec<String> = granted_set.difference(&required_set).cloned().collect();
127
128    let degraded_features: Vec<DegradedFeature> = FEATURE_SCOPE_MAP
129        .iter()
130        .filter_map(|mapping| {
131            let missing_scopes: Vec<String> = mapping
132                .required_scopes
133                .iter()
134                .filter(|scope| !granted_set.contains(**scope))
135                .map(|scope| (*scope).to_string())
136                .collect();
137
138            if missing_scopes.is_empty() {
139                None
140            } else {
141                Some(DegradedFeature {
142                    feature: mapping.feature.to_string(),
143                    description: mapping.description.to_string(),
144                    missing_scopes,
145                })
146            }
147        })
148        .collect();
149
150    ScopeAnalysis {
151        granted: granted_set.into_iter().collect(),
152        required: REQUIRED_SCOPES.iter().map(|s| (*s).to_string()).collect(),
153        missing: missing.clone(),
154        extra,
155        degraded_features,
156        all_required_present: missing.is_empty(),
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    fn all_required_scopes() -> Vec<String> {
165        REQUIRED_SCOPES.iter().map(|s| (*s).to_string()).collect()
166    }
167
168    #[test]
169    fn full_scopes_have_no_degradation() {
170        let analysis = analyze_scopes(&all_required_scopes());
171        assert!(analysis.all_required_present);
172        assert!(analysis.missing.is_empty());
173        assert!(analysis.degraded_features.is_empty());
174        assert!(analysis.extra.is_empty());
175    }
176
177    #[test]
178    fn partial_scopes_report_degraded_features() {
179        let mut scopes = all_required_scopes();
180        scopes.retain(|scope| scope != "like.write");
181
182        let analysis = analyze_scopes(&scopes);
183
184        assert!(!analysis.all_required_present);
185        assert_eq!(analysis.missing, vec!["like.write".to_string()]);
186
187        let like_feature = analysis
188            .degraded_features
189            .iter()
190            .find(|feature| feature.feature == "Like/unlike")
191            .expect("like/unlike feature should be degraded");
192        assert_eq!(like_feature.missing_scopes, vec!["like.write".to_string()]);
193    }
194
195    #[test]
196    fn empty_scopes_degrade_all_features() {
197        let analysis = analyze_scopes(&[]);
198
199        assert!(!analysis.all_required_present);
200        assert_eq!(analysis.missing.len(), REQUIRED_SCOPES.len());
201        assert_eq!(analysis.degraded_features.len(), FEATURE_SCOPE_MAP.len());
202        assert!(analysis.extra.is_empty());
203    }
204
205    #[test]
206    fn extra_scopes_are_reported_without_error() {
207        let mut scopes = all_required_scopes();
208        scopes.push("mute.read".to_string());
209
210        let analysis = analyze_scopes(&scopes);
211
212        assert!(analysis.all_required_present);
213        assert!(analysis.missing.is_empty());
214        assert_eq!(analysis.extra, vec!["mute.read".to_string()]);
215    }
216}