BlueSky & more on desktop lazurite.stormlightlabs.org/
tauri rust typescript bluesky appview atproto solid
2
fork

Configure Feed

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

fix: improve session refresh error handling

* proper did resolution

+173 -90
+78 -61
src-tauri/src/explorer.rs
··· 9 9 use jacquard::api::com_atproto::sync::list_repos::ListRepos; 10 10 use jacquard::client::{Agent, UnauthenticatedSession}; 11 11 use jacquard::deps::fluent_uri::Uri; 12 - use jacquard::identity::JacquardResolver; 12 + use jacquard::identity::{resolver::IdentityResolver, JacquardResolver}; 13 13 use jacquard::types::aturi::AtUri; 14 14 use jacquard::types::did::Did; 15 + use jacquard::types::did_doc::DidDocument; 15 16 use jacquard::types::handle::Handle; 16 17 use jacquard::types::ident::AtIdentifier; 17 18 use jacquard::types::nsid::Nsid; ··· 166 167 } 167 168 168 169 pub async fn list_records(did: String, collection: String, cursor: Option<String>) -> Result<Value> { 169 - let client = public_client(); 170 + let client = client_for_repo_did(&did).await?; 170 171 let request = ListRecords::new() 171 172 .repo(parse_at_identifier(&did)?) 172 173 .collection(parse_collection(&collection)?) ··· 185 186 } 186 187 187 188 pub async fn get_record(did: String, collection: String, rkey: String) -> Result<Value> { 188 - let client = public_client(); 189 + let client = client_for_repo_did(&did).await?; 189 190 let request = GetRecord::new() 190 191 .repo(parse_at_identifier(&did)?) 191 192 .collection(parse_collection(&collection)?) ··· 205 206 206 207 pub async fn export_repo_car(did: String, app: &AppHandle) -> Result<RepoCarExport> { 207 208 let parsed_did = Did::new(&did)?.into_static(); 208 - let client = public_client(); 209 + let client = client_for_repo_did(parsed_did.as_str()).await?; 209 210 let output = client 210 211 .send(GetRepo::new().did(parsed_did.clone()).build()) 211 212 .await ··· 262 263 Ok(client) 263 264 } 264 265 266 + async fn client_for_repo(repo: &str) -> Result<ExplorerClient> { 267 + match parse_at_identifier(repo)? { 268 + AtIdentifier::Did(did) => client_for_repo_did(did.as_str()).await, 269 + AtIdentifier::Handle(handle) => { 270 + let did = resolve_handle_to_did(handle.as_str()).await?; 271 + client_for_repo_did(&did).await 272 + } 273 + } 274 + } 275 + 276 + async fn client_for_repo_did(did: &str) -> Result<ExplorerClient> { 277 + let metadata = resolve_repo_metadata(did).await?; 278 + let pds_url = metadata 279 + .pds_url 280 + .ok_or_else(|| AppError::validation(format!("missing PDS endpoint for repo {did}")))?; 281 + client_for_base_uri(&pds_url).await 282 + } 283 + 265 284 async fn resolve_at_uri_input(input: &str) -> Result<ResolvedExplorerInput> { 266 285 let parsed = AtUri::new(input)?; 267 286 let (did, handle) = match parsed.authority() { 268 287 AtIdentifier::Did(did) => (did.to_string(), None), 269 288 AtIdentifier::Handle(handle) => (resolve_handle_to_did(handle.as_str()).await?, Some(handle.to_string())), 270 289 }; 271 - let repo_metadata = describe_repo_metadata(&did).await?; 290 + let repo_metadata = resolve_repo_metadata(&did).await?; 272 291 273 292 Ok(build_resolved_at_uri( 274 293 input, ··· 282 301 async fn resolve_handle_input(input: &str) -> Result<ResolvedExplorerInput> { 283 302 let normalized_handle = normalize_handle(input).ok_or_else(|| AppError::validation("invalid handle input"))?; 284 303 let did = resolve_handle_to_did(&normalized_handle).await?; 285 - let repo_metadata = describe_repo_metadata(&did).await?; 304 + let repo_metadata = resolve_repo_metadata(&did).await?; 286 305 287 306 Ok(ResolvedExplorerInput { 288 307 input: input.trim().to_string(), ··· 300 319 301 320 async fn resolve_did_input(input: &str) -> Result<ResolvedExplorerInput> { 302 321 let did = Did::new(input.trim())?.to_string(); 303 - let repo_metadata = describe_repo_metadata(&did).await?; 322 + let repo_metadata = resolve_repo_metadata(&did).await?; 304 323 305 324 Ok(ResolvedExplorerInput { 306 325 input: input.trim().to_string(), ··· 319 338 async fn describe_repo_output( 320 339 repo: &str, 321 340 ) -> Result<jacquard::api::com_atproto::repo::describe_repo::DescribeRepoOutput<'static>> { 322 - let client = public_client(); 341 + let client = client_for_repo(repo).await?; 323 342 client 324 343 .send(DescribeRepo::new().repo(parse_at_identifier(repo)?).build()) 325 344 .await ··· 335 354 pds_url: Option<String>, 336 355 } 337 356 338 - async fn describe_repo_metadata(did: &str) -> Result<RepoMetadata> { 339 - let output = describe_repo_output(did).await?; 340 - let did_doc = serde_json::to_value(&output.did_doc)?; 357 + async fn resolve_repo_metadata(did: &str) -> Result<RepoMetadata> { 358 + let did_doc = resolve_did_document(did).await?; 359 + Ok(repo_metadata_from_did_doc(&did_doc)) 360 + } 341 361 342 - Ok(RepoMetadata { handle: Some(output.handle.to_string()), pds_url: extract_pds_url_from_did_doc_json(&did_doc) }) 362 + async fn resolve_did_document(did: &str) -> Result<DidDocument<'static>> { 363 + let client = public_client(); 364 + let parsed_did = Did::new(did)?.into_static(); 365 + 366 + client 367 + .resolve_did_doc(&parsed_did) 368 + .await 369 + .map_err(|error| AppError::validation(format!("resolveDid request failed: {error}")))? 370 + .into_owned() 371 + .map_err(|error| AppError::validation(format!("resolveDid output failed: {error}"))) 372 + } 373 + 374 + fn repo_metadata_from_did_doc(did_doc: &DidDocument<'_>) -> RepoMetadata { 375 + let handle = did_doc.also_known_as.as_ref().and_then(|aliases| { 376 + aliases.iter().find_map(|alias| { 377 + let candidate = alias.as_ref().strip_prefix("at://")?; 378 + Handle::new(candidate).ok().map(|handle| handle.to_string()) 379 + }) 380 + }); 381 + let pds_url = did_doc 382 + .pds_endpoint() 383 + .and_then(|uri| normalize_pds_url(uri.as_str()).ok()); 384 + 385 + RepoMetadata { handle, pds_url } 343 386 } 344 387 345 388 async fn resolve_handle_to_did(handle: &str) -> Result<String> { ··· 469 512 .map_err(AppError::from) 470 513 } 471 514 472 - fn extract_pds_url_from_did_doc_json(did_doc: &Value) -> Option<String> { 473 - did_doc 474 - .get("service") 475 - .and_then(Value::as_array) 476 - .and_then(|services| { 477 - services.iter().find_map(|service| { 478 - let service_type = service.get("type").and_then(Value::as_str)?; 479 - if service_type != "AtprotoPersonalDataServer" { 480 - return None; 481 - } 482 - 483 - match service.get("serviceEndpoint") { 484 - Some(Value::String(url)) => Some(url.clone()), 485 - Some(Value::Object(object)) => object.get("url").and_then(Value::as_str).map(str::to_owned), 486 - _ => None, 487 - } 488 - }) 489 - }) 490 - .and_then(|url| normalize_pds_url(&url).ok()) 491 - } 492 - 493 515 fn resolve_car_export_path(app: &AppHandle, did: &str) -> Result<PathBuf> { 494 516 let mut app_data_dir = app 495 517 .path() ··· 516 538 #[cfg(test)] 517 539 mod tests { 518 540 use super::{ 519 - build_resolved_at_uri, canonical_at_uri, detect_input_kind, extract_pds_url_from_did_doc_json, 520 - normalize_handle, normalize_pds_url, repo_car_filename, sanitize_did_for_filename, ExplorerInputKind, 541 + build_resolved_at_uri, canonical_at_uri, detect_input_kind, normalize_handle, normalize_pds_url, 542 + repo_car_filename, repo_metadata_from_did_doc, sanitize_did_for_filename, ExplorerInputKind, 521 543 ExplorerTargetKind, 522 544 }; 523 545 use jacquard::types::aturi::AtUri; 546 + use jacquard::types::did_doc::DidDocument; 524 547 525 548 #[test] 526 549 fn detects_all_supported_input_kinds() { ··· 570 593 } 571 594 572 595 #[test] 573 - fn extracts_pds_url_from_did_doc_shapes() { 574 - let string_endpoint = serde_json::json!({ 575 - "service": [ 576 - { 577 - "type": "AtprotoPersonalDataServer", 578 - "serviceEndpoint": "https://pds.example.com/" 579 - } 580 - ] 581 - }); 582 - let object_endpoint = serde_json::json!({ 583 - "service": [ 584 - { 585 - "type": "AtprotoPersonalDataServer", 586 - "serviceEndpoint": { 587 - "url": "https://pds.object.example.com/xrpc" 596 + fn extracts_repo_metadata_from_did_documents() { 597 + let did_doc: DidDocument<'_> = serde_json::from_str( 598 + r##"{ 599 + "id": "did:plc:alice", 600 + "alsoKnownAs": ["at://alice.bsky.social"], 601 + "service": [ 602 + { 603 + "id": "#pds", 604 + "type": "AtprotoPersonalDataServer", 605 + "serviceEndpoint": { 606 + "url": "https://pds.object.example.com/xrpc" 607 + } 588 608 } 589 - } 590 - ] 591 - }); 609 + ] 610 + }"##, 611 + ) 612 + .expect("did document should parse"); 613 + 614 + let metadata = repo_metadata_from_did_doc(&did_doc); 592 615 593 - assert_eq!( 594 - extract_pds_url_from_did_doc_json(&string_endpoint), 595 - Some("https://pds.example.com".to_string()) 596 - ); 597 - assert_eq!( 598 - extract_pds_url_from_did_doc_json(&object_endpoint), 599 - Some("https://pds.object.example.com".to_string()) 600 - ); 616 + assert_eq!(metadata.handle, Some("alice.bsky.social".to_string())); 617 + assert_eq!(metadata.pds_url, Some("https://pds.object.example.com".to_string())); 601 618 } 602 619 603 620 #[test]
+94 -28
src-tauri/src/state.rs
··· 6 6 use super::db::DbPool; 7 7 use super::error::AppError; 8 8 use jacquard::oauth::authstore::ClientAuthStore; 9 + use jacquard::oauth::error::OAuthError; 9 10 use jacquard::types::did::Did; 10 11 use jacquard::IntoStatic; 11 12 use serde::Serialize; ··· 213 214 let did = Did::new(&account.did)?; 214 215 let session = if refresh { 215 216 log::info!("restoring session with token refresh for {}", account.handle); 216 - Arc::new(self.oauth_client.restore(&did, session_id).await?) 217 + match self.oauth_client.restore(&did, session_id).await { 218 + Ok(session) => Arc::new(session), 219 + Err(error) if should_fallback_to_persisted_session(&error) => { 220 + log::warn!( 221 + "token refresh unavailable for {} during restore: {}; using persisted session data", 222 + account.handle, 223 + error 224 + ); 225 + self.restore_persisted_session(account, &did, session_id).await? 226 + } 227 + Err(error) => return Err(AppError::from(error)), 228 + } 217 229 } else { 218 230 log::debug!("restoring session from persisted data for {}", account.handle); 219 - let session_data = self.auth_store.get_session(&did, session_id).await?.ok_or_else(|| { 220 - AppError::Validation(format!("missing persisted oauth session for account {}", account.did)) 221 - })?; 222 - Arc::new(restore_session_from_data( 223 - &self.oauth_client, 224 - session_data.into_static(), 225 - )) 231 + self.restore_persisted_session(account, &did, session_id).await? 226 232 }; 227 233 228 234 self.sessions ··· 232 238 233 239 log::info!("session restored successfully for {}", account.handle); 234 240 Ok(session) 241 + } 242 + 243 + async fn restore_persisted_session( 244 + &self, account: &StoredAccount, did: &Did<'_>, session_id: &str, 245 + ) -> Result<Arc<LazuriteOAuthSession>, AppError> { 246 + let session_data = self.auth_store.get_session(did, session_id).await?.ok_or_else(|| { 247 + AppError::Validation(format!("missing persisted oauth session for account {}", account.did)) 248 + })?; 249 + 250 + Ok(Arc::new(restore_session_from_data( 251 + &self.oauth_client, 252 + session_data.into_static(), 253 + ))) 235 254 } 236 255 237 256 async fn restore_sessions(&self) -> Result<(), AppError> { ··· 286 305 None => return Ok(()), 287 306 }; 288 307 289 - let session_id = match account.session_id.as_deref() { 290 - Some(id) => id, 291 - None => return Ok(()), 308 + let session = match self.ensure_session(&account, false).await { 309 + Ok(session) => session, 310 + Err(error) => { 311 + log::warn!("active session could not be loaded for {}: {error}", active.handle); 312 + self.invalidate_active_session(app)?; 313 + return Err(AppError::validation(format!("session unavailable: {error}"))); 314 + } 292 315 }; 293 316 294 - remove_cached_session(&self.sessions, &active.did)?; 295 - 296 - let did = Did::new(&active.did)?; 297 - match self.oauth_client.restore(&did, session_id).await { 298 - Ok(session) => { 299 - self.sessions 300 - .write() 301 - .map_err(|error| { 302 - log::error!("failed to acquire sessions write lock: {error}"); 303 - AppError::StatePoisoned("sessions") 304 - })? 305 - .insert(active.did.clone(), Arc::new(session)); 317 + match session.refresh().await { 318 + Ok(_) => { 306 319 log::info!("token refresh succeeded for {}", active.handle); 307 320 Ok(()) 308 321 } 322 + Err(error) if oauth_error_requires_reauth(&error) => { 323 + log::warn!("token refresh failed permanently for {}: {error}", active.handle); 324 + remove_cached_session(&self.sessions, &active.did)?; 325 + self.invalidate_active_session(app)?; 326 + Err(AppError::validation(format!("refresh failed permanently: {error}"))) 327 + } 309 328 Err(error) => { 310 - log::warn!("token refresh failed for {}: {error}", active.handle); 311 - self.auth_store.clear_active_account()?; 312 - self.refresh_account_cache()?; 313 - app.emit(super::auth::ACCOUNT_SWITCHED_EVENT, None::<ActiveSession>)?; 314 - Err(AppError::validation(format!("refresh failed: {error}"))) 329 + log::warn!("token refresh unavailable for {}: {error}", active.handle); 330 + Err(AppError::validation(format!("refresh unavailable: {error}"))) 315 331 } 316 332 } 317 333 } ··· 371 387 tokio::time::sleep(REFRESH_INTERVAL).await; 372 388 } 373 389 }); 390 + } 391 + 392 + fn invalidate_active_session(&self, app: &AppHandle) -> Result<(), AppError> { 393 + self.auth_store.clear_active_account()?; 394 + self.refresh_account_cache()?; 395 + app.emit(super::auth::ACCOUNT_SWITCHED_EVENT, None::<ActiveSession>)?; 396 + Ok(()) 397 + } 398 + } 399 + 400 + fn oauth_error_requires_reauth(error: &OAuthError) -> bool { 401 + match error { 402 + OAuthError::Session(error) => error.is_permanent(), 403 + OAuthError::Request(error) => error.is_permanent(), 404 + _ => false, 405 + } 406 + } 407 + 408 + fn should_fallback_to_persisted_session(error: &OAuthError) -> bool { 409 + matches!(error, OAuthError::Session(error) if !error.is_permanent()) 410 + || matches!(error, OAuthError::Request(error) if !error.is_permanent()) 411 + || matches!(error, OAuthError::Resolver(_)) 412 + } 413 + 414 + #[cfg(test)] 415 + mod tests { 416 + use super::{oauth_error_requires_reauth, should_fallback_to_persisted_session}; 417 + use jacquard::oauth::error::OAuthError; 418 + use jacquard::oauth::request::RequestError; 419 + use jacquard::oauth::session::Error as OAuthSessionError; 420 + 421 + #[test] 422 + fn transient_session_errors_fall_back_to_persisted_data() { 423 + let error = OAuthError::Session(OAuthSessionError::ServerAgent(RequestError::http_status( 424 + reqwest::StatusCode::BAD_GATEWAY, 425 + ))); 426 + assert!(should_fallback_to_persisted_session(&error)); 427 + assert!(!oauth_error_requires_reauth(&error)); 428 + } 429 + 430 + #[test] 431 + fn permanent_session_errors_require_reauth() { 432 + let not_found = OAuthError::Session(OAuthSessionError::SessionNotFound); 433 + let refresh_failed = OAuthError::Session(OAuthSessionError::RefreshFailed(RequestError::no_refresh_token())); 434 + 435 + assert!(!should_fallback_to_persisted_session(&not_found)); 436 + assert!(oauth_error_requires_reauth(&not_found)); 437 + 438 + assert!(!should_fallback_to_persisted_session(&refresh_failed)); 439 + assert!(oauth_error_requires_reauth(&refresh_failed)); 374 440 } 375 441 }
+1 -1
src/App.tsx
··· 127 127 128 128 function markPotentialExpiry(error: unknown) { 129 129 const message = String(error).toLowerCase(); 130 - if (message.includes("refresh failed") || message.includes("session does not exist")) { 130 + if (message.includes("refresh failed permanently") || message.includes("session does not exist")) { 131 131 setApp("reauthNeeded", true); 132 132 } 133 133 }