Our Personal Data Server from scratch!
0

Configure Feed

Select the types of activity you want to include in your feed.

feat: cache expanded permission sets

author
Trezy
committer
Tangled
date (Jul 24, 2026, 8:11 PM +0300) commit ecdda4c5 parent f17adc6f
+334 -28
+7
crates/tranquil-pds/src/cache_keys.rs
··· 39 39 pub fn auto_verify_sent_key(did: &Did) -> String { 40 40 format!("auto_verify_sent:{}", did) 41 41 } 42 + 43 + pub fn permission_set_key(nsid: &str, aud: Option<&str>) -> String { 44 + match aud { 45 + Some(a) => format!("permset:{}:{}", nsid, a), 46 + None => format!("permset:{}", nsid), 47 + } 48 + }
+2
crates/tranquil-pds/src/oauth/mod.rs
··· 1 1 pub mod client; 2 2 pub mod db; 3 + pub mod permission_set_resolver; 3 4 pub mod scopes; 4 5 pub mod verify; 5 6 ··· 20 21 compute_pkce_challenge, verify_client_auth, 21 22 }; 22 23 24 + pub use permission_set_resolver::expand_scopes; 23 25 pub use scopes::{AccountAction, AccountAttr, RepoAction, ScopeError, ScopePermissions}; 24 26 pub use verify::{ 25 27 OAuthAuthError, OAuthUser, VerifyResult, generate_dpop_nonce, verify_oauth_access_token,
+182
crates/tranquil-pds/src/oauth/permission_set_resolver.rs
··· 1 + use crate::cache::Cache; 2 + use crate::cache_keys::permission_set_key; 3 + use serde::{Deserialize, Serialize}; 4 + use std::time::Duration; 5 + use tranquil_scopes::{ 6 + fetch_and_expand, parse_include_scope, ExpansionOutcome, FailedSet, ResolveFailure, 7 + ResolvedSetGroup, ScopeExpansionError, 8 + }; 9 + use tranquil_types::Nsid; 10 + 11 + #[derive(Serialize, Deserialize)] 12 + struct CachedPermissionSet { 13 + scope: String, 14 + title: Option<String>, 15 + detail: Option<String>, 16 + } 17 + 18 + const PERMISSION_SET_CACHE_TTL_SECS: u64 = 24 * 60 * 60; 19 + 20 + pub async fn expand_scopes(cache: &dyn Cache, scope_string: &str) -> ExpansionOutcome { 21 + let mut outcome = ExpansionOutcome::default(); 22 + for tok in scope_string.split_whitespace() { 23 + match tok.strip_prefix("include:") { 24 + None => outcome.passthrough.push(tok.to_string()), 25 + Some(rest) => { 26 + let (nsid, aud) = parse_include_scope(rest); 27 + match resolve_one(cache, nsid, aud).await { 28 + Ok(group) => outcome.sets.push(group), 29 + Err(reason) => outcome.failures.push(FailedSet { 30 + nsid: nsid.to_string(), 31 + aud: aud.map(str::to_string), 32 + reason, 33 + }), 34 + } 35 + } 36 + } 37 + } 38 + outcome 39 + } 40 + 41 + async fn resolve_one( 42 + cache: &dyn Cache, 43 + nsid: &str, 44 + aud: Option<&str>, 45 + ) -> Result<ResolvedSetGroup, ResolveFailure> { 46 + let key = permission_set_key(nsid, aud); 47 + // Cache hit 48 + if let Some(json) = cache.get(&key).await 49 + && let Ok(v) = serde_json::from_str::<CachedPermissionSet>(&json) 50 + { 51 + return Ok(group_from(nsid, aud, v.scope, v.title, v.detail)); 52 + } 53 + // Miss → network fetch → cache 54 + let parsed = Nsid::new(nsid).map_err(|_| ResolveFailure::Invalid)?; 55 + match fetch_and_expand(&parsed, aud).await { 56 + Ok(fetched) => { 57 + let stored = CachedPermissionSet { 58 + scope: fetched.expanded.clone(), 59 + title: fetched.title.clone(), 60 + detail: fetched.detail.clone(), 61 + }; 62 + if let Ok(json) = serde_json::to_string(&stored) { 63 + let _ = cache 64 + .set(&key, &json, Duration::from_secs(PERMISSION_SET_CACHE_TTL_SECS)) 65 + .await; 66 + } 67 + Ok(group_from(nsid, aud, fetched.expanded, fetched.title, fetched.detail)) 68 + } 69 + Err(e) => Err(map_err(&e)), 70 + } 71 + } 72 + 73 + fn group_from( 74 + nsid: &str, 75 + aud: Option<&str>, 76 + scope: String, 77 + title: Option<String>, 78 + detail: Option<String>, 79 + ) -> ResolvedSetGroup { 80 + ResolvedSetGroup { 81 + nsid: nsid.to_string(), 82 + aud: aud.map(str::to_string), 83 + title, 84 + detail, 85 + expanded: scope.split_whitespace().map(str::to_string).collect(), 86 + } 87 + } 88 + 89 + fn map_err(e: &ScopeExpansionError) -> ResolveFailure { 90 + use ScopeExpansionError as E; 91 + match e { 92 + E::InvalidNsid(_) | E::UnexpectedType(_) | E::EmptyPermissions | E::MissingDefinition(_) => { 93 + ResolveFailure::Invalid 94 + } 95 + E::DnsResolution(_) | E::HttpFailed(_) | E::DidResolution(_) => ResolveFailure::NetworkError, 96 + } 97 + } 98 + 99 + #[cfg(test)] 100 + mod tests { 101 + use super::*; 102 + use crate::cache::{Cache, CacheError}; 103 + use std::collections::HashMap; 104 + use std::sync::Mutex; 105 + use std::time::Duration; 106 + 107 + #[derive(Default)] 108 + struct MapCache(Mutex<HashMap<String, String>>); 109 + 110 + #[async_trait::async_trait] 111 + impl Cache for MapCache { 112 + async fn get(&self, key: &str) -> Option<String> { 113 + self.0.lock().unwrap().get(key).cloned() 114 + } 115 + async fn set(&self, key: &str, value: &str, _ttl: Duration) -> Result<(), CacheError> { 116 + self.0 117 + .lock() 118 + .unwrap() 119 + .insert(key.to_string(), value.to_string()); 120 + Ok(()) 121 + } 122 + async fn delete(&self, key: &str) -> Result<(), CacheError> { 123 + self.0.lock().unwrap().remove(key); 124 + Ok(()) 125 + } 126 + async fn get_bytes(&self, _key: &str) -> Option<Vec<u8>> { 127 + None 128 + } 129 + async fn set_bytes(&self, _k: &str, _v: &[u8], _t: Duration) -> Result<(), CacheError> { 130 + Ok(()) 131 + } 132 + } 133 + 134 + fn seed(cache: &MapCache, nsid: &str, scope: &str) { 135 + let key = crate::cache_keys::permission_set_key(nsid, None); 136 + let val = serde_json::to_string(&CachedPermissionSet { 137 + scope: scope.to_string(), 138 + title: Some("Basic".into()), 139 + detail: None, 140 + }) 141 + .unwrap(); 142 + cache.0.lock().unwrap().insert(key, val); 143 + } 144 + 145 + #[tokio::test] 146 + async fn cache_hit_expands_without_network() { 147 + let cache = MapCache::default(); 148 + seed( 149 + &cache, 150 + "io.atcr.authFullApp", 151 + "repo:io.atcr.manifest?action=create identity:*", 152 + ); 153 + let out = expand_scopes(&cache, "atproto include:io.atcr.authFullApp").await; 154 + assert!(out.failures.is_empty()); 155 + assert_eq!(out.passthrough, vec!["atproto".to_string()]); 156 + assert_eq!(out.sets.len(), 1); 157 + assert_eq!(out.sets[0].nsid, "io.atcr.authFullApp"); 158 + assert!( 159 + out.flat_scopes() 160 + .iter() 161 + .any(|s| s == "repo:io.atcr.manifest?action=create") 162 + ); 163 + } 164 + 165 + #[tokio::test] 166 + async fn passthrough_scopes_untouched() { 167 + let cache = MapCache::default(); 168 + let out = expand_scopes(&cache, "atproto repo:app.bsky.feed.post?action=create").await; 169 + assert!(out.failures.is_empty()); 170 + assert!(out.sets.is_empty()); 171 + assert_eq!(out.flat_scopes().len(), 2); 172 + } 173 + 174 + #[tokio::test] 175 + async fn cache_miss_unresolvable_is_a_failure() { 176 + let cache = MapCache::default(); // empty → miss → network fetch of a fake NSID fails fast 177 + let out = expand_scopes(&cache, "include:nonexistent.fake.permissionSet").await; 178 + assert_eq!(out.sets.len(), 0); 179 + assert_eq!(out.failures.len(), 1); 180 + assert_eq!(out.failures[0].nsid, "nonexistent.fake.permissionSet"); 181 + } 182 + }
+4 -1
crates/tranquil-scopes/src/lib.rs
··· 15 15 AccountAction, AccountAttr, AccountScope, BlobScope, IdentityAttr, IdentityScope, IncludeScope, 16 16 ParsedScope, RepoAction, RepoScope, RpcScope, parse_scope, parse_scope_string, 17 17 }; 18 - pub use permission_set::{ScopeExpansionError, expand_include_scopes}; 18 + pub use permission_set::{ 19 + ExpansionOutcome, FailedSet, FetchedSet, ResolveFailure, ResolvedSetGroup, 20 + ScopeExpansionError, expand_include_scopes, fetch_and_expand, parse_include_scope, 21 + }; 19 22 pub use permissions::ScopePermissions;
+139 -27
crates/tranquil-scopes/src/permission_set.rs
··· 26 26 EmptyPermissions, 27 27 } 28 28 29 + #[derive(Debug, Clone, PartialEq, Eq)] 30 + pub enum ResolveFailure { 31 + NotFound, 32 + NetworkError, 33 + Invalid, 34 + } 35 + 36 + #[derive(Debug, Clone)] 37 + pub struct FailedSet { 38 + pub nsid: String, 39 + pub aud: Option<String>, 40 + pub reason: ResolveFailure, 41 + } 42 + 43 + #[derive(Debug, Clone)] 44 + pub struct ResolvedSetGroup { 45 + pub nsid: String, 46 + pub aud: Option<String>, 47 + pub title: Option<String>, 48 + pub detail: Option<String>, 49 + pub expanded: Vec<String>, 50 + } 51 + 52 + #[derive(Debug, Clone, Default)] 53 + pub struct ExpansionOutcome { 54 + pub passthrough: Vec<String>, 55 + pub sets: Vec<ResolvedSetGroup>, 56 + pub failures: Vec<FailedSet>, 57 + } 58 + 59 + impl ExpansionOutcome { 60 + pub fn flat_scopes(&self) -> Vec<String> { 61 + let mut out = self.passthrough.clone(); 62 + for s in &self.sets { 63 + out.extend(s.expanded.iter().cloned()); 64 + } 65 + out 66 + } 67 + /// Space-joined `flat_scopes`. 68 + pub fn to_scope_string(&self) -> String { 69 + self.flat_scopes().join(" ") 70 + } 71 + } 72 + 29 73 static LEXICON_CACHE: LazyLock<RwLock<HashMap<String, CachedLexicon>>> = 30 74 LazyLock::new(|| RwLock::new(HashMap::new())); 31 75 ··· 63 107 struct LexiconDef { 64 108 #[serde(rename = "type")] 65 109 def_type: String, 110 + #[serde(default)] 111 + title: Option<String>, 112 + #[serde(default)] 113 + detail: Option<String>, 66 114 permissions: Option<Vec<PermissionEntry>>, 67 115 } 68 116 ··· 98 146 .map(|v| v.join(" ")) 99 147 } 100 148 101 - fn parse_include_scope(rest: &str) -> (&str, Option<&str>) { 149 + pub fn parse_include_scope(rest: &str) -> (&str, Option<&str>) { 102 150 rest.split_once('?') 103 151 .map(|(nsid, params)| { 104 152 let aud = params.split('&').find_map(|p| p.strip_prefix("aud=")); ··· 126 174 } 127 175 } 128 176 177 + let fetched = fetch_and_expand(nsid, aud).await?; 178 + let expanded = fetched.expanded; 179 + 180 + { 181 + let mut cache = LEXICON_CACHE.write().await; 182 + cache.insert( 183 + cache_key, 184 + CachedLexicon { 185 + expanded_scope: expanded.clone(), 186 + cached_at: std::time::Instant::now(), 187 + }, 188 + ); 189 + } 190 + 191 + debug!(nsid = %nsid, expanded = %expanded, "Successfully expanded permission set"); 192 + Ok(expanded) 193 + } 194 + 195 + pub struct FetchedSet { 196 + pub expanded: String, 197 + pub title: Option<String>, 198 + pub detail: Option<String>, 199 + } 200 + 201 + pub async fn fetch_and_expand( 202 + nsid: &Nsid, 203 + aud: Option<&str>, 204 + ) -> Result<FetchedSet, ScopeExpansionError> { 129 205 let lexicon = fetch_lexicon_via_atproto(nsid).await?; 130 - 131 206 let main_def = lexicon 132 207 .defs 133 208 .get("main") 134 209 .ok_or(ScopeExpansionError::MissingDefinition("main".to_string()))?; 135 - 136 210 if main_def.def_type != "permission-set" { 137 211 return Err(ScopeExpansionError::UnexpectedType( 138 212 main_def.def_type.clone(), 139 213 )); 140 214 } 141 - 142 - let permissions = 143 - main_def 144 - .permissions 145 - .as_ref() 146 - .ok_or(ScopeExpansionError::MissingDefinition( 147 - "permissions".to_string(), 148 - ))?; 149 - 215 + let permissions = main_def 216 + .permissions 217 + .as_ref() 218 + .ok_or(ScopeExpansionError::MissingDefinition( 219 + "permissions".to_string(), 220 + ))?; 150 221 let namespace_authority = extract_namespace_authority(nsid); 151 222 let expanded = build_expanded_scopes(permissions, aud, &namespace_authority); 152 - 153 223 if expanded.is_empty() { 154 224 return Err(ScopeExpansionError::EmptyPermissions); 155 225 } 156 - 157 - { 158 - let mut cache = LEXICON_CACHE.write().await; 159 - cache.insert( 160 - cache_key, 161 - CachedLexicon { 162 - expanded_scope: expanded.clone(), 163 - cached_at: std::time::Instant::now(), 164 - }, 165 - ); 166 - } 167 - 168 - debug!(nsid = %nsid, expanded = %expanded, "Successfully expanded permission set"); 169 - Ok(expanded) 226 + Ok(FetchedSet { 227 + expanded, 228 + title: main_def.title.clone(), 229 + detail: main_def.detail.clone(), 230 + }) 170 231 } 171 232 172 233 async fn fetch_lexicon_via_atproto(nsid: &Nsid) -> Result<LexiconDoc, ScopeExpansionError> { ··· 677 738 dns_authority("community.lexicon.bookmarks.authManageBookmarks"), 678 739 "bookmarks.lexicon.community" 679 740 ); 741 + } 742 + 743 + #[test] 744 + fn expansion_outcome_flat_scopes_and_string() { 745 + let out = ExpansionOutcome { 746 + passthrough: vec!["atproto".into()], 747 + sets: vec![ResolvedSetGroup { 748 + nsid: "io.atcr.authFullApp".into(), 749 + aud: None, 750 + title: Some("T".into()), 751 + detail: None, 752 + expanded: vec![ 753 + "repo:io.atcr.manifest?action=create".into(), 754 + "rpc:io.atcr.getManifest".into(), 755 + ], 756 + }], 757 + failures: vec![FailedSet { 758 + nsid: "nonexistent.fake.permissionSet".into(), 759 + aud: None, 760 + reason: ResolveFailure::NotFound, 761 + }], 762 + }; 763 + let flat = out.flat_scopes(); 764 + assert_eq!( 765 + flat, 766 + vec![ 767 + "atproto", 768 + "repo:io.atcr.manifest?action=create", 769 + "rpc:io.atcr.getManifest" 770 + ] 771 + ); 772 + assert_eq!( 773 + out.to_scope_string(), 774 + "atproto repo:io.atcr.manifest?action=create rpc:io.atcr.getManifest" 775 + ); 776 + } 777 + 778 + #[test] 779 + fn test_lexicon_def_captures_title_and_detail() { 780 + let json = serde_json::json!({ 781 + "defs": { "main": { 782 + "type": "permission-set", 783 + "title": "Basic App", 784 + "detail": "Posts and interactions", 785 + "permissions": [{ "resource": "repo", "collection": ["io.atcr.manifest"] }] 786 + }} 787 + }); 788 + let doc: LexiconDoc = serde_json::from_value(json).unwrap(); 789 + let main = doc.defs.get("main").unwrap(); 790 + assert_eq!(main.title.as_deref(), Some("Basic App")); 791 + assert_eq!(main.detail.as_deref(), Some("Posts and interactions")); 680 792 } 681 793 }