Our Personal Data Server from scratch!
0

Configure Feed

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

fix: prevent transient permission set publishers from causing auth refresh failures

Signed-off-by: Trezy <tre@trezy.com>

author
Trezy
committer
Tangled
date (Jul 24, 2026, 8:11 PM +0300) commit 19e7ec29 parent 311fbfcb
+81 -7
+7 -1
crates/tranquil-oauth-server/src/endpoints/authorize/scope_resolution.rs
··· 52 52 fn cache_with(nsid: &str, scopes: &str) -> MapCache { 53 53 let c = MapCache::default(); 54 54 let key = tranquil_pds::cache_keys::permission_set_key(&tranquil_types::Nsid::new(nsid).unwrap(), None); 55 - let json = serde_json::json!({ "scope": scopes, "title": null, "detail": null }).to_string(); 55 + let json = serde_json::json!({ 56 + "scope": scopes, 57 + "title": null, 58 + "detail": null, 59 + "refreshed_at": chrono::Utc::now().timestamp(), 60 + }) 61 + .to_string(); 56 62 c.0.lock().unwrap().insert(key, json); 57 63 c 58 64 }
+74 -6
crates/tranquil-pds/src/oauth/permission_set_resolver.rs
··· 13 13 scope: String, 14 14 title: Option<String>, 15 15 detail: Option<String>, 16 + #[serde(default)] 17 + refreshed_at: i64, 16 18 } 17 19 18 - const PERMISSION_SET_CACHE_TTL_SECS: u64 = 24 * 60 * 60; 20 + const STALE_AFTER_SECS: i64 = 24 * 60 * 60; 21 + const PERMISSION_SET_CACHE_TTL_SECS: u64 = 90 * 24 * 60 * 60; 22 + 23 + fn now_secs() -> i64 { 24 + chrono::Utc::now().timestamp() 25 + } 26 + 27 + fn is_stale(refreshed_at: i64) -> bool { 28 + now_secs().saturating_sub(refreshed_at) >= STALE_AFTER_SECS 29 + } 19 30 20 31 pub async fn expand_scopes(cache: &dyn Cache, scope_string: &str) -> ExpansionOutcome { 21 32 let mut outcome = ExpansionOutcome::default(); ··· 46 57 let parsed = Nsid::new(nsid).map_err(|_| ResolveFailure::Malformed)?; 47 58 let key = permission_set_key(&parsed, aud); 48 59 49 - if let Some(json) = cache.get(&key).await 50 - && let Ok(v) = serde_json::from_str::<CachedPermissionSet>(&json) 60 + let cached = cache 61 + .get(&key) 62 + .await 63 + .and_then(|json| serde_json::from_str::<CachedPermissionSet>(&json).ok()); 64 + 65 + if let Some(v) = &cached 66 + && !is_stale(v.refreshed_at) 51 67 { 52 - return Ok(group_from(parsed, aud, v.scope, v.title, v.detail)); 68 + return Ok(group_from( 69 + parsed, 70 + aud, 71 + v.scope.clone(), 72 + v.title.clone(), 73 + v.detail.clone(), 74 + )); 53 75 } 54 76 55 77 match fetch_and_expand(&parsed, aud).await { ··· 58 80 scope: fetched.expanded.clone(), 59 81 title: fetched.title.clone(), 60 82 detail: fetched.detail.clone(), 83 + refreshed_at: now_secs(), 61 84 }; 62 85 if let Ok(json) = serde_json::to_string(&stored) { 63 86 let _ = cache ··· 66 89 } 67 90 Ok(group_from(parsed, aud, fetched.expanded, fetched.title, fetched.detail)) 68 91 } 69 - Err(e) => Err(map_err(&e)), 92 + Err(e) => match cached { 93 + Some(v) => Ok(group_from(parsed, aud, v.scope, v.title, v.detail)), 94 + None => Err(map_err(&e)), 95 + }, 70 96 } 71 97 } 72 98 ··· 133 159 } 134 160 } 135 161 136 - fn seed(cache: &MapCache, nsid: &str, scope: &str) { 162 + fn seed_at(cache: &MapCache, nsid: &str, scope: &str, refreshed_at: i64) { 137 163 let key = crate::cache_keys::permission_set_key(&tranquil_types::Nsid::new(nsid).unwrap(), None); 138 164 let val = serde_json::to_string(&CachedPermissionSet { 139 165 scope: scope.to_string(), 140 166 title: Some("Basic".into()), 141 167 detail: None, 168 + refreshed_at, 142 169 }) 143 170 .unwrap(); 144 171 cache.0.lock().unwrap().insert(key, val); 145 172 } 146 173 174 + fn seed(cache: &MapCache, nsid: &str, scope: &str) { 175 + seed_at(cache, nsid, scope, now_secs()); 176 + } 177 + 147 178 #[tokio::test] 148 179 async fn cache_hit_expands_without_network() { 149 180 let cache = MapCache::default(); ··· 162 193 .iter() 163 194 .any(|s| s == "repo:io.atcr.manifest?action=create") 164 195 ); 196 + } 197 + 198 + #[tokio::test] 199 + async fn stale_entry_is_served_when_refresh_fails() { 200 + let cache = MapCache::default(); 201 + seed_at( 202 + &cache, 203 + "nonexistent.fake.permissionSet", 204 + "repo:nonexistent.fake.record?action=create", 205 + now_secs() - STALE_AFTER_SECS - 1, 206 + ); 207 + let out = expand_scopes(&cache, "include:nonexistent.fake.permissionSet").await; 208 + assert!( 209 + out.failures.is_empty(), 210 + "stale entry should survive an unresolvable publisher" 211 + ); 212 + assert_eq!(out.sets.len(), 1); 213 + assert!( 214 + out.flat_scopes() 215 + .iter() 216 + .any(|s| s == "repo:nonexistent.fake.record?action=create") 217 + ); 218 + } 219 + 220 + #[tokio::test] 221 + async fn entry_without_refreshed_at_is_treated_as_stale_but_usable() { 222 + let cache = MapCache::default(); 223 + let key = crate::cache_keys::permission_set_key( 224 + &tranquil_types::Nsid::new("nonexistent.fake.permissionSet").unwrap(), 225 + None, 226 + ); 227 + // Shape written before `refreshed_at` existed. 228 + let legacy = r#"{"scope":"repo:nonexistent.fake.record?action=create","title":null,"detail":null}"#; 229 + cache.0.lock().unwrap().insert(key, legacy.to_string()); 230 + let out = expand_scopes(&cache, "include:nonexistent.fake.permissionSet").await; 231 + assert!(out.failures.is_empty()); 232 + assert_eq!(out.sets.len(), 1); 165 233 } 166 234 167 235 #[tokio::test]