//! `space.polymodel.actor.*` read endpoint handlers: `getProfile` (typed union //! of polymodel profileView / app.bsky profileViewDetailed) and `getSession` //! (the session identity primitive). use axum::extract::State; use jacquard::identity::PublicResolver; use jacquard::identity::resolver::IdentityResolver; use jacquard_axum::oauth::OptionalBrowserOAuthSession; use jacquard_axum::{ExtractXrpc, XrpcResponse}; use jacquard_common::deps::smol_str::SmolStr; use jacquard_common::types::ident::AtIdentifier; use jacquard_common::types::string::{Did, Handle}; use jacquard_common::types::value::Data; use jacquard_common::xrpc::XrpcClient; use polymodel_api::app_bsky::actor::ProfileViewDetailed; use polymodel_api::app_bsky::actor::get_profile::GetProfile as BskyGetProfile; use polymodel_api::space_polymodel::actor::{ ProfileView, ProfileViewUnion, get_profile::{GetProfileOutput, GetProfileRequest}, get_session::{ GetSessionOutput, GetSessionRequest, SessionAuthenticated, SessionUnauthenticated, SessionView, }, }; use super::error::{AppResult, internal}; use super::state::AppState; use super::views; use crate::oauth::SqliteAuthStore; /// Canonicalize an at-identifier to a DID by matching on its variant (not by /// sniffing its serialized form). DIDs pass through; handles are resolved via /// the identity resolver. A handle that cannot be resolved is a 500 (the /// identifier was already structurally valid). pub(super) async fn resolve_actor(state: &AppState, ident: &AtIdentifier) -> AppResult { match ident { AtIdentifier::Did(did) => Ok(did.clone()), AtIdentifier::Handle(handle) => state .resolver .resolve_handle(handle) .await .map_err(|e| internal(format!("handle resolution failed: {e}"))), } } pub(super) async fn get_profile( State(state): State, ExtractXrpc(req): ExtractXrpc, ) -> AppResult> { let did = resolve_actor(&state, &req.actor).await?; // Polymodel variant when the actor authored a polymodel profile record. if let Some(profile) = views::polymodel_profile_view(&state, &did).await? { return Ok(XrpcResponse(GetProfileOutput { value: ProfileViewUnion::ProfileView(Box::new(profile)), extra_data: None, })); } // Otherwise the bluesky variant, fetched from the public AppView. let detailed = fetch_bsky_profile(&state, &req.actor).await?; Ok(XrpcResponse(GetProfileOutput { value: ProfileViewUnion::ProfileViewDetailed(Box::new(detailed)), extra_data: None, })) } /// Fetch `app.bsky.actor.getProfile` from `https://public.api.bsky.app` via the /// unauthenticated jacquard client. This is the only network path in the read /// endpoints; tests exercise union-variant construction without it. async fn fetch_bsky_profile( state: &AppState, ident: &AtIdentifier, ) -> AppResult { let resp = state .bsky .send(BskyGetProfile { actor: ident.clone(), }) .await .map_err(|e| internal(format!("app.bsky.actor.getProfile failed: {e}")))?; let out = resp .into_output() .map_err(|e| internal(format!("app.bsky.actor.getProfile decode failed: {e}")))?; Ok(out.value) } pub(super) async fn get_session( State(state): State, OptionalBrowserOAuthSession(session): OptionalBrowserOAuthSession< PublicResolver, SqliteAuthStore, >, ) -> AppResult> { if let Some(session) = session.as_ref() { let t_session = std::time::Instant::now(); let did = session.session_info().await.0; let session_info_ms = t_session.elapsed().as_millis(); let t_resolve = std::time::Instant::now(); let resolved = views::resolve_handle(&state, &did).await; let resolve_handle_ms = t_resolve.elapsed().as_millis(); match resolved { Ok(handle_str) => { let handle = Handle::new_owned(handle_str.clone()) .map_err(|e| internal(format!("invalid session handle: {e}")))?; let t_profile = std::time::Instant::now(); let profile = match views::polymodel_profile_view(&state, &did).await? { Some(profile) => profile, None => { // No polymodel profile record: fall back to the actor's // Bluesky profile so the session carries their real avatar // and display name. If that fetch fails, degrade to a // handle-only profile rather than dropping the session. match fetch_bsky_profile(&state, &AtIdentifier::Did(did.clone())).await { Ok(detailed) => { bsky_session_profile(detailed, did.clone(), &handle_str) } Err(error) => { tracing::warn!(error = ?error, did = %did, "bsky profile fetch failed for session; using handle-only profile"); minimal_session_profile(did.clone(), handle_str)? } } } }; tracing::info!( session_info_ms, resolve_handle_ms, profile_ms = t_profile.elapsed().as_millis(), "get_session authed timing" ); return Ok(XrpcResponse(GetSessionOutput { value: SessionView::SessionAuthenticated(Box::new(SessionAuthenticated { authenticated: true, did, handle, profile, extra_data: None, })), extra_data: None, })); } Err(error) => { tracing::warn!(error = ?error, did = %did, "session handle resolution failed; reporting unauthenticated"); } } } Ok(unauthenticated_session()) } fn unauthenticated_session() -> XrpcResponse { XrpcResponse(GetSessionOutput { value: SessionView::SessionUnauthenticated(Box::new(SessionUnauthenticated { authenticated: false, extra_data: None, })), extra_data: None, }) } fn minimal_session_profile(did: Did, handle: String) -> AppResult { Ok(ProfileView { avatar: None, default_license: None, description: None, did, display_name: None, handle: SmolStr::from(handle), indexed_at: None, links: None, printers: None, pronouns: None, record: Data::Null, thing_count: None, total_likes: None, extra_data: None, }) } /// Build a session `ProfileView` from the actor's Bluesky profile so the /// signed-in chrome can show their real avatar and display name when they have /// no polymodel profile record. Polymodel-specific fields stay absent. fn bsky_session_profile(detailed: ProfileViewDetailed, did: Did, handle: &str) -> ProfileView { ProfileView { avatar: detailed.avatar, default_license: None, description: detailed.description, did, display_name: detailed.display_name, handle: SmolStr::from(handle), indexed_at: detailed.indexed_at, links: None, printers: None, pronouns: detailed.pronouns, record: Data::Null, thing_count: None, total_likes: None, extra_data: None, } }