atproto Thingiverse but good
10

Configure Feed

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

PM-45: client appview session

Orual (Jun 29, 2026, 7:06 PM EDT) b82376a1 00d7a0f0

+1539 -75
+15
.zed/settings.json
··· 1 + // Folder-specific settings 2 + // 3 + // For a full list of overridable settings, and general information on folder-specific settings, 4 + // see the documentation: https://zed.dev/docs/configuring-zed#settings-files 5 + { 6 + "lsp": { 7 + "rust-analyzer": { 8 + "initialization_options": { 9 + "cargo": { 10 + "allFeatures": true, 11 + }, 12 + }, 13 + }, 14 + }, 15 + }
+2
Cargo.lock
··· 6189 6189 "dioxus", 6190 6190 "dotenvy", 6191 6191 "futures", 6192 + "http", 6192 6193 "hydrant", 6193 6194 "jacquard", 6194 6195 "jacquard-axum", ··· 6197 6198 "js-sys", 6198 6199 "libsqlite3-sys", 6199 6200 "polymodel-api", 6201 + "reqwest", 6200 6202 "rustls", 6201 6203 "serde", 6202 6204 "serde_json",
+3 -1
Cargo.toml
··· 91 91 "fmt", 92 92 "env-filter", 93 93 ] } 94 + http = "1.4" 95 + reqwest = { version = "0.12", default-features = false, features = ["json", "charset"] } 94 96 95 97 96 98 [target.'cfg(all(target_family = "wasm", target_os = "unknown"))'.dependencies] ··· 99 101 tracing-wasm = "0.2" 100 102 wasm-bindgen = "=0.2.121" 101 103 wasm-bindgen-futures = "0.4" 102 - web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Event", "EventTarget", "HtmlCanvasElement", "WebGl2RenderingContext", "Performance", "MouseEvent", "WheelEvent", "PointerEvent", "DomRect", "console", "Response"] } 104 + web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Event", "EventTarget", "HtmlCanvasElement", "WebGl2RenderingContext", "Performance", "MouseEvent", "WheelEvent", "PointerEvent", "DomRect", "BroadcastChannel", "console", "Response"] } 103 105 104 106 [dev-dependencies] 105 107 tokio = { version = "1", features = ["rt", "macros"] }
+43 -4
assets/styling/browse.css
··· 62 62 font-weight: 600; 63 63 padding: 0.45rem 0; 64 64 } 65 - 65 + .session-control { 66 + display: inline-flex; 67 + align-items: stretch; 68 + gap: 0; 69 + flex-wrap: nowrap; 70 + } 71 + .session-control input { 72 + width: min(9.5rem, 42vw); 73 + min-height: 2rem; 74 + padding: 0.45rem 0.65rem; 75 + border: 1px solid var(--color-border); 76 + border-right: 0; 77 + border-radius: var(--radius-sm) 0 0 var(--radius-sm); 78 + background: var(--color-surface); 79 + color: var(--color-text); 80 + font-family: var(--font-ui); 81 + font-size: 0.9rem; 82 + } 83 + .session-control input:focus { 84 + outline: 2px solid color-mix(in srgb, var(--color-primary) 25%, transparent); 85 + outline-offset: 1px; 86 + border-color: var(--color-primary); 87 + } 88 + .session-control .button { 89 + min-height: 2rem; 90 + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; 91 + } 92 + .session-control-authenticated { 93 + justify-content: flex-end; 94 + gap: 0.5rem; 95 + } 66 96 .foundation-hero { 67 97 display: grid; 68 98 grid-template-columns: minmax(0, 1.05fr) minmax(18rem, 0.95fr); ··· 109 139 color: var(--color-primary); 110 140 } 111 141 112 - .blueprint-axis-x { right: 0.75rem; bottom: 0.75rem; } 113 - .blueprint-axis-y { left: 0.75rem; top: 0.75rem; } 114 - .blueprint-dimension { left: 0.75rem; bottom: 0.75rem; } 142 + .blueprint-axis-x { 143 + right: 0.75rem; 144 + bottom: 0.75rem; 145 + } 146 + .blueprint-axis-y { 147 + left: 0.75rem; 148 + top: 0.75rem; 149 + } 150 + .blueprint-dimension { 151 + left: 0.75rem; 152 + bottom: 0.75rem; 153 + } 115 154 116 155 .browse-controls { 117 156 display: grid;
+52 -22
crates/polymodel-api/lexicons/space_polymodel_actor_getSession.json
··· 4 4 "defs": { 5 5 "main": { 6 6 "type": "query", 7 - "description": "Identity primitive (PM-9). Returns the authenticated viewer's identity plus profile for SSR-first session seeding, or an unauthenticated marker. The profile field reuses the getProfile union shape so the client renders identity + profile from a single first-paint value. PM-26 returns only the unauthenticated marker ({authenticated: false}); the authenticated fields (did/handle/profile) are populated once PM-27 wires OAuth session extraction, without changing this output contract. Takes no parameters: identity is derived from the request session, not an actor argument.", 7 + "description": "Identity primitive (PM-9). Returns either a logged-out marker or the authenticated viewer's full identity/profile for client session seeding. Takes no parameters: identity is derived from the request session, not an actor argument.", 8 8 "output": { 9 9 "encoding": "application/json", 10 10 "schema": { 11 - "type": "object", 12 - "required": [ 13 - "authenticated" 14 - ], 15 - "properties": { 16 - "authenticated": { 17 - "type": "boolean", 18 - "description": "Always present. When false the response carries only this field (no identity/profile); when true, did/handle/profile are populated." 19 - }, 20 - "did": { 21 - "type": "string", 22 - "format": "did" 23 - }, 24 - "handle": { 25 - "type": "string" 26 - }, 27 - "profile": { 28 - "type": "ref", 29 - "ref": "space.polymodel.actor.defs#profileViewUnion" 30 - } 31 - } 11 + "type": "ref", 12 + "ref": "space.polymodel.actor.getSession#sessionView" 32 13 } 33 14 } 15 + }, 16 + "sessionAuthenticated": { 17 + "type": "object", 18 + "description": "Authenticated viewer identity for client session seeding. If authenticated is true, did, handle, and profile are all present.", 19 + "required": [ 20 + "authenticated", 21 + "did", 22 + "handle", 23 + "profile" 24 + ], 25 + "properties": { 26 + "authenticated": { 27 + "type": "boolean", 28 + "const": true 29 + }, 30 + "did": { 31 + "type": "string", 32 + "format": "did" 33 + }, 34 + "handle": { 35 + "type": "string", 36 + "format": "handle" 37 + }, 38 + "profile": { 39 + "type": "ref", 40 + "ref": "space.polymodel.actor.defs#profileView" 41 + } 42 + } 43 + }, 44 + "sessionUnauthenticated": { 45 + "type": "object", 46 + "description": "Logged-out session marker. No identity fields are present when authenticated is false.", 47 + "required": [ 48 + "authenticated" 49 + ], 50 + "properties": { 51 + "authenticated": { 52 + "type": "boolean", 53 + "const": false 54 + } 55 + } 56 + }, 57 + "sessionView": { 58 + "type": "union", 59 + "refs": [ 60 + "space.polymodel.actor.getSession#sessionUnauthenticated", 61 + "space.polymodel.actor.getSession#sessionAuthenticated" 62 + ], 63 + "closed": true 34 64 } 35 65 } 36 66 }
+553 -10
crates/polymodel-api/src/space_polymodel/actor/get_session.rs
··· 10 10 11 11 #[allow(unused_imports)] 12 12 use core::marker::PhantomData; 13 - use jacquard_common::{CowStr, BosStr, DefaultStr, FromStaticStr}; 13 + use jacquard_common::{BosStr, DefaultStr, FromStaticStr}; 14 + 15 + #[allow(unused_imports)] 16 + use jacquard_common::deps::codegen::unicode_segmentation::UnicodeSegmentation; 14 17 use jacquard_common::deps::smol_str::SmolStr; 15 - use jacquard_common::types::string::Did; 18 + use jacquard_common::types::string::{Did, Handle}; 16 19 use jacquard_common::types::value::Data; 17 20 use jacquard_derive::IntoStatic; 21 + use jacquard_lexicon::lexicon::LexiconDoc; 22 + use jacquard_lexicon::schema::LexiconSchema; 23 + 24 + #[allow(unused_imports)] 25 + use jacquard_lexicon::validation::{ConstraintError, ValidationPath}; 18 26 use serde::{Serialize, Deserialize}; 19 - use crate::space_polymodel::actor::ProfileViewUnion; 27 + use crate::space_polymodel::actor::ProfileView; 28 + use crate::space_polymodel::actor::get_session; 20 29 21 30 #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] 22 31 #[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] 23 32 pub struct GetSessionOutput<S: BosStr = DefaultStr> { 24 - ///Always present. When false the response carries only this field (no identity/profile); when true, did/handle/profile are populated. 33 + #[serde(flatten)] 34 + pub value: get_session::SessionView<S>, 35 + #[serde(flatten, default, skip_serializing_if = "Option::is_none")] 36 + pub extra_data: Option<BTreeMap<SmolStr, Data<S>>>, 37 + } 38 + 39 + /// Authenticated viewer identity for client session seeding. If authenticated is true, did, handle, and profile are all present. 40 + 41 + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] 42 + #[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] 43 + pub struct SessionAuthenticated<S: BosStr = DefaultStr> { 44 + pub authenticated: bool, 45 + pub did: Did<S>, 46 + pub handle: Handle<S>, 47 + pub profile: ProfileView<S>, 48 + #[serde(flatten, default, skip_serializing_if = "Option::is_none")] 49 + pub extra_data: Option<BTreeMap<SmolStr, Data<S>>>, 50 + } 51 + 52 + /// Logged-out session marker. No identity fields are present when authenticated is false. 53 + 54 + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] 55 + #[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] 56 + pub struct SessionUnauthenticated<S: BosStr = DefaultStr> { 25 57 pub authenticated: bool, 26 - #[serde(skip_serializing_if = "Option::is_none")] 27 - pub did: Option<Did<S>>, 28 - #[serde(skip_serializing_if = "Option::is_none")] 29 - pub handle: Option<S>, 30 - #[serde(skip_serializing_if = "Option::is_none")] 31 - pub profile: Option<ProfileViewUnion<S>>, 32 58 #[serde(flatten, default, skip_serializing_if = "Option::is_none")] 33 59 pub extra_data: Option<BTreeMap<SmolStr, Data<S>>>, 60 + } 61 + 62 + 63 + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] 64 + #[serde(tag = "$type", bound(deserialize = "S: Deserialize<'de> + BosStr"))] 65 + pub enum SessionView<S: BosStr = DefaultStr> { 66 + #[serde(rename = "space.polymodel.actor.getSession#sessionUnauthenticated")] 67 + SessionUnauthenticated(Box<get_session::SessionUnauthenticated<S>>), 68 + #[serde(rename = "space.polymodel.actor.getSession#sessionAuthenticated")] 69 + SessionAuthenticated(Box<get_session::SessionAuthenticated<S>>), 34 70 } 35 71 36 72 /** Request marker for the `space.polymodel.actor.getSession` query. ··· 65 101 const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Query; 66 102 type Request<S: BosStr> = GetSession; 67 103 type Response = GetSessionResponse; 104 + } 105 + 106 + impl<S: BosStr> LexiconSchema for SessionAuthenticated<S> { 107 + fn nsid() -> &'static str { 108 + "space.polymodel.actor.getSession" 109 + } 110 + fn def_name() -> &'static str { 111 + "sessionAuthenticated" 112 + } 113 + fn lexicon_doc() -> LexiconDoc<'static> { 114 + lexicon_doc_space_polymodel_actor_getSession() 115 + } 116 + fn validate(&self) -> Result<(), ConstraintError> { 117 + Ok(()) 118 + } 119 + } 120 + 121 + impl<S: BosStr> LexiconSchema for SessionUnauthenticated<S> { 122 + fn nsid() -> &'static str { 123 + "space.polymodel.actor.getSession" 124 + } 125 + fn def_name() -> &'static str { 126 + "sessionUnauthenticated" 127 + } 128 + fn lexicon_doc() -> LexiconDoc<'static> { 129 + lexicon_doc_space_polymodel_actor_getSession() 130 + } 131 + fn validate(&self) -> Result<(), ConstraintError> { 132 + Ok(()) 133 + } 134 + } 135 + 136 + pub mod session_authenticated_state { 137 + 138 + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; 139 + #[allow(unused)] 140 + use ::core::marker::PhantomData; 141 + mod sealed { 142 + pub trait Sealed {} 143 + } 144 + /// State trait tracking which required fields have been set 145 + pub trait State: sealed::Sealed { 146 + type Authenticated; 147 + type Did; 148 + type Handle; 149 + type Profile; 150 + } 151 + /// Empty state - all required fields are unset 152 + pub struct Empty(()); 153 + impl sealed::Sealed for Empty {} 154 + impl State for Empty { 155 + type Authenticated = Unset; 156 + type Did = Unset; 157 + type Handle = Unset; 158 + type Profile = Unset; 159 + } 160 + ///State transition - sets the `authenticated` field to Set 161 + pub struct SetAuthenticated<St: State = Empty>(PhantomData<fn() -> St>); 162 + impl<St: State> sealed::Sealed for SetAuthenticated<St> {} 163 + impl<St: State> State for SetAuthenticated<St> { 164 + type Authenticated = Set<members::authenticated>; 165 + type Did = St::Did; 166 + type Handle = St::Handle; 167 + type Profile = St::Profile; 168 + } 169 + ///State transition - sets the `did` field to Set 170 + pub struct SetDid<St: State = Empty>(PhantomData<fn() -> St>); 171 + impl<St: State> sealed::Sealed for SetDid<St> {} 172 + impl<St: State> State for SetDid<St> { 173 + type Authenticated = St::Authenticated; 174 + type Did = Set<members::did>; 175 + type Handle = St::Handle; 176 + type Profile = St::Profile; 177 + } 178 + ///State transition - sets the `handle` field to Set 179 + pub struct SetHandle<St: State = Empty>(PhantomData<fn() -> St>); 180 + impl<St: State> sealed::Sealed for SetHandle<St> {} 181 + impl<St: State> State for SetHandle<St> { 182 + type Authenticated = St::Authenticated; 183 + type Did = St::Did; 184 + type Handle = Set<members::handle>; 185 + type Profile = St::Profile; 186 + } 187 + ///State transition - sets the `profile` field to Set 188 + pub struct SetProfile<St: State = Empty>(PhantomData<fn() -> St>); 189 + impl<St: State> sealed::Sealed for SetProfile<St> {} 190 + impl<St: State> State for SetProfile<St> { 191 + type Authenticated = St::Authenticated; 192 + type Did = St::Did; 193 + type Handle = St::Handle; 194 + type Profile = Set<members::profile>; 195 + } 196 + /// Marker types for field names 197 + #[allow(non_camel_case_types)] 198 + pub mod members { 199 + ///Marker type for the `authenticated` field 200 + pub struct authenticated(()); 201 + ///Marker type for the `did` field 202 + pub struct did(()); 203 + ///Marker type for the `handle` field 204 + pub struct handle(()); 205 + ///Marker type for the `profile` field 206 + pub struct profile(()); 207 + } 208 + } 209 + 210 + /// Builder for constructing an instance of this type. 211 + pub struct SessionAuthenticatedBuilder< 212 + St: session_authenticated_state::State, 213 + S: BosStr = DefaultStr, 214 + > { 215 + _state: PhantomData<fn() -> St>, 216 + _fields: (Option<bool>, Option<Did<S>>, Option<Handle<S>>, Option<ProfileView<S>>), 217 + _type: PhantomData<fn() -> S>, 218 + } 219 + 220 + impl SessionAuthenticated<DefaultStr> { 221 + /// Create a new builder for this type, using the default string type (DefaultStr = SmolStr) if needed 222 + pub fn new() -> SessionAuthenticatedBuilder< 223 + session_authenticated_state::Empty, 224 + DefaultStr, 225 + > { 226 + SessionAuthenticatedBuilder::new() 227 + } 228 + } 229 + 230 + impl<S: BosStr> SessionAuthenticated<S> { 231 + /// Create a new builder for this type 232 + pub fn builder() -> SessionAuthenticatedBuilder< 233 + session_authenticated_state::Empty, 234 + S, 235 + > { 236 + SessionAuthenticatedBuilder::builder() 237 + } 238 + } 239 + 240 + impl SessionAuthenticatedBuilder<session_authenticated_state::Empty, DefaultStr> { 241 + /// Create a new builder with all fields unset, using the default string type, if needed 242 + pub fn new() -> Self { 243 + SessionAuthenticatedBuilder { 244 + _state: PhantomData, 245 + _fields: (None, None, None, None), 246 + _type: PhantomData, 247 + } 248 + } 249 + } 250 + 251 + impl<S: BosStr> SessionAuthenticatedBuilder<session_authenticated_state::Empty, S> { 252 + /// Create a new builder with all fields unset 253 + pub fn builder() -> Self { 254 + SessionAuthenticatedBuilder { 255 + _state: PhantomData, 256 + _fields: (None, None, None, None), 257 + _type: PhantomData, 258 + } 259 + } 260 + } 261 + 262 + impl<St, S: BosStr> SessionAuthenticatedBuilder<St, S> 263 + where 264 + St: session_authenticated_state::State, 265 + St::Authenticated: session_authenticated_state::IsUnset, 266 + { 267 + /// Set the `authenticated` field (required) 268 + pub fn authenticated( 269 + mut self, 270 + value: impl Into<bool>, 271 + ) -> SessionAuthenticatedBuilder< 272 + session_authenticated_state::SetAuthenticated<St>, 273 + S, 274 + > { 275 + self._fields.0 = Option::Some(value.into()); 276 + SessionAuthenticatedBuilder { 277 + _state: PhantomData, 278 + _fields: self._fields, 279 + _type: PhantomData, 280 + } 281 + } 282 + } 283 + 284 + impl<St, S: BosStr> SessionAuthenticatedBuilder<St, S> 285 + where 286 + St: session_authenticated_state::State, 287 + St::Did: session_authenticated_state::IsUnset, 288 + { 289 + /// Set the `did` field (required) 290 + pub fn did( 291 + mut self, 292 + value: impl Into<Did<S>>, 293 + ) -> SessionAuthenticatedBuilder<session_authenticated_state::SetDid<St>, S> { 294 + self._fields.1 = Option::Some(value.into()); 295 + SessionAuthenticatedBuilder { 296 + _state: PhantomData, 297 + _fields: self._fields, 298 + _type: PhantomData, 299 + } 300 + } 301 + } 302 + 303 + impl<St, S: BosStr> SessionAuthenticatedBuilder<St, S> 304 + where 305 + St: session_authenticated_state::State, 306 + St::Handle: session_authenticated_state::IsUnset, 307 + { 308 + /// Set the `handle` field (required) 309 + pub fn handle( 310 + mut self, 311 + value: impl Into<Handle<S>>, 312 + ) -> SessionAuthenticatedBuilder<session_authenticated_state::SetHandle<St>, S> { 313 + self._fields.2 = Option::Some(value.into()); 314 + SessionAuthenticatedBuilder { 315 + _state: PhantomData, 316 + _fields: self._fields, 317 + _type: PhantomData, 318 + } 319 + } 320 + } 321 + 322 + impl<St, S: BosStr> SessionAuthenticatedBuilder<St, S> 323 + where 324 + St: session_authenticated_state::State, 325 + St::Profile: session_authenticated_state::IsUnset, 326 + { 327 + /// Set the `profile` field (required) 328 + pub fn profile( 329 + mut self, 330 + value: impl Into<ProfileView<S>>, 331 + ) -> SessionAuthenticatedBuilder<session_authenticated_state::SetProfile<St>, S> { 332 + self._fields.3 = Option::Some(value.into()); 333 + SessionAuthenticatedBuilder { 334 + _state: PhantomData, 335 + _fields: self._fields, 336 + _type: PhantomData, 337 + } 338 + } 339 + } 340 + 341 + impl<St, S: BosStr> SessionAuthenticatedBuilder<St, S> 342 + where 343 + St: session_authenticated_state::State, 344 + St::Authenticated: session_authenticated_state::IsSet, 345 + St::Did: session_authenticated_state::IsSet, 346 + St::Handle: session_authenticated_state::IsSet, 347 + St::Profile: session_authenticated_state::IsSet, 348 + { 349 + /// Build the final struct. 350 + pub fn build(self) -> SessionAuthenticated<S> { 351 + SessionAuthenticated { 352 + authenticated: self._fields.0.unwrap(), 353 + did: self._fields.1.unwrap(), 354 + handle: self._fields.2.unwrap(), 355 + profile: self._fields.3.unwrap(), 356 + extra_data: Default::default(), 357 + } 358 + } 359 + /// Build the final struct with custom extra_data. 360 + pub fn build_with_data( 361 + self, 362 + extra_data: BTreeMap<SmolStr, Data<S>>, 363 + ) -> SessionAuthenticated<S> { 364 + SessionAuthenticated { 365 + authenticated: self._fields.0.unwrap(), 366 + did: self._fields.1.unwrap(), 367 + handle: self._fields.2.unwrap(), 368 + profile: self._fields.3.unwrap(), 369 + extra_data: Some(extra_data), 370 + } 371 + } 372 + } 373 + 374 + fn lexicon_doc_space_polymodel_actor_getSession() -> LexiconDoc<'static> { 375 + #[allow(unused_imports)] 376 + use jacquard_common::{CowStr, deps::smol_str::SmolStr, types::blob::MimeType}; 377 + use jacquard_lexicon::lexicon::*; 378 + use alloc::collections::BTreeMap; 379 + LexiconDoc { 380 + lexicon: Lexicon::Lexicon1, 381 + id: CowStr::new_static("space.polymodel.actor.getSession"), 382 + defs: { 383 + let mut map = BTreeMap::new(); 384 + map.insert( 385 + SmolStr::new_static("main"), 386 + LexUserType::XrpcQuery(LexXrpcQuery { 387 + parameters: None, 388 + ..Default::default() 389 + }), 390 + ); 391 + map.insert( 392 + SmolStr::new_static("sessionAuthenticated"), 393 + LexUserType::Object(LexObject { 394 + description: Some( 395 + CowStr::new_static( 396 + "Authenticated viewer identity for client session seeding. If authenticated is true, did, handle, and profile are all present.", 397 + ), 398 + ), 399 + required: Some( 400 + vec![ 401 + SmolStr::new_static("authenticated"), 402 + SmolStr::new_static("did"), SmolStr::new_static("handle"), 403 + SmolStr::new_static("profile") 404 + ], 405 + ), 406 + properties: { 407 + #[allow(unused_mut)] 408 + let mut map = BTreeMap::new(); 409 + map.insert( 410 + SmolStr::new_static("authenticated"), 411 + LexObjectProperty::Boolean(LexBoolean { 412 + ..Default::default() 413 + }), 414 + ); 415 + map.insert( 416 + SmolStr::new_static("did"), 417 + LexObjectProperty::String(LexString { 418 + format: Some(LexStringFormat::Did), 419 + ..Default::default() 420 + }), 421 + ); 422 + map.insert( 423 + SmolStr::new_static("handle"), 424 + LexObjectProperty::String(LexString { 425 + format: Some(LexStringFormat::Handle), 426 + ..Default::default() 427 + }), 428 + ); 429 + map.insert( 430 + SmolStr::new_static("profile"), 431 + LexObjectProperty::Ref(LexRef { 432 + r#ref: CowStr::new_static( 433 + "space.polymodel.actor.defs#profileView", 434 + ), 435 + ..Default::default() 436 + }), 437 + ); 438 + map 439 + }, 440 + ..Default::default() 441 + }), 442 + ); 443 + map.insert( 444 + SmolStr::new_static("sessionUnauthenticated"), 445 + LexUserType::Object(LexObject { 446 + description: Some( 447 + CowStr::new_static( 448 + "Logged-out session marker. No identity fields are present when authenticated is false.", 449 + ), 450 + ), 451 + required: Some(vec![SmolStr::new_static("authenticated")]), 452 + properties: { 453 + #[allow(unused_mut)] 454 + let mut map = BTreeMap::new(); 455 + map.insert( 456 + SmolStr::new_static("authenticated"), 457 + LexObjectProperty::Boolean(LexBoolean { 458 + ..Default::default() 459 + }), 460 + ); 461 + map 462 + }, 463 + ..Default::default() 464 + }), 465 + ); 466 + map.insert( 467 + SmolStr::new_static("sessionView"), 468 + LexUserType::Union(LexRefUnion { 469 + refs: vec![ 470 + CowStr::new_static("space.polymodel.actor.getSession#sessionUnauthenticated"), 471 + CowStr::new_static("space.polymodel.actor.getSession#sessionAuthenticated") 472 + ], 473 + closed: Some(true), 474 + ..Default::default() 475 + }), 476 + ); 477 + map 478 + }, 479 + ..Default::default() 480 + } 481 + } 482 + 483 + pub mod session_unauthenticated_state { 484 + 485 + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; 486 + #[allow(unused)] 487 + use ::core::marker::PhantomData; 488 + mod sealed { 489 + pub trait Sealed {} 490 + } 491 + /// State trait tracking which required fields have been set 492 + pub trait State: sealed::Sealed { 493 + type Authenticated; 494 + } 495 + /// Empty state - all required fields are unset 496 + pub struct Empty(()); 497 + impl sealed::Sealed for Empty {} 498 + impl State for Empty { 499 + type Authenticated = Unset; 500 + } 501 + ///State transition - sets the `authenticated` field to Set 502 + pub struct SetAuthenticated<St: State = Empty>(PhantomData<fn() -> St>); 503 + impl<St: State> sealed::Sealed for SetAuthenticated<St> {} 504 + impl<St: State> State for SetAuthenticated<St> { 505 + type Authenticated = Set<members::authenticated>; 506 + } 507 + /// Marker types for field names 508 + #[allow(non_camel_case_types)] 509 + pub mod members { 510 + ///Marker type for the `authenticated` field 511 + pub struct authenticated(()); 512 + } 513 + } 514 + 515 + /// Builder for constructing an instance of this type. 516 + pub struct SessionUnauthenticatedBuilder< 517 + St: session_unauthenticated_state::State, 518 + S: BosStr = DefaultStr, 519 + > { 520 + _state: PhantomData<fn() -> St>, 521 + _fields: (Option<bool>,), 522 + _type: PhantomData<fn() -> S>, 523 + } 524 + 525 + impl SessionUnauthenticated<DefaultStr> { 526 + /// Create a new builder for this type, using the default string type (DefaultStr = SmolStr) if needed 527 + pub fn new() -> SessionUnauthenticatedBuilder< 528 + session_unauthenticated_state::Empty, 529 + DefaultStr, 530 + > { 531 + SessionUnauthenticatedBuilder::new() 532 + } 533 + } 534 + 535 + impl<S: BosStr> SessionUnauthenticated<S> { 536 + /// Create a new builder for this type 537 + pub fn builder() -> SessionUnauthenticatedBuilder< 538 + session_unauthenticated_state::Empty, 539 + S, 540 + > { 541 + SessionUnauthenticatedBuilder::builder() 542 + } 543 + } 544 + 545 + impl SessionUnauthenticatedBuilder<session_unauthenticated_state::Empty, DefaultStr> { 546 + /// Create a new builder with all fields unset, using the default string type, if needed 547 + pub fn new() -> Self { 548 + SessionUnauthenticatedBuilder { 549 + _state: PhantomData, 550 + _fields: (None,), 551 + _type: PhantomData, 552 + } 553 + } 554 + } 555 + 556 + impl<S: BosStr> SessionUnauthenticatedBuilder<session_unauthenticated_state::Empty, S> { 557 + /// Create a new builder with all fields unset 558 + pub fn builder() -> Self { 559 + SessionUnauthenticatedBuilder { 560 + _state: PhantomData, 561 + _fields: (None,), 562 + _type: PhantomData, 563 + } 564 + } 565 + } 566 + 567 + impl<St, S: BosStr> SessionUnauthenticatedBuilder<St, S> 568 + where 569 + St: session_unauthenticated_state::State, 570 + St::Authenticated: session_unauthenticated_state::IsUnset, 571 + { 572 + /// Set the `authenticated` field (required) 573 + pub fn authenticated( 574 + mut self, 575 + value: impl Into<bool>, 576 + ) -> SessionUnauthenticatedBuilder< 577 + session_unauthenticated_state::SetAuthenticated<St>, 578 + S, 579 + > { 580 + self._fields.0 = Option::Some(value.into()); 581 + SessionUnauthenticatedBuilder { 582 + _state: PhantomData, 583 + _fields: self._fields, 584 + _type: PhantomData, 585 + } 586 + } 587 + } 588 + 589 + impl<St, S: BosStr> SessionUnauthenticatedBuilder<St, S> 590 + where 591 + St: session_unauthenticated_state::State, 592 + St::Authenticated: session_unauthenticated_state::IsSet, 593 + { 594 + /// Build the final struct. 595 + pub fn build(self) -> SessionUnauthenticated<S> { 596 + SessionUnauthenticated { 597 + authenticated: self._fields.0.unwrap(), 598 + extra_data: Default::default(), 599 + } 600 + } 601 + /// Build the final struct with custom extra_data. 602 + pub fn build_with_data( 603 + self, 604 + extra_data: BTreeMap<SmolStr, Data<S>>, 605 + ) -> SessionUnauthenticated<S> { 606 + SessionUnauthenticated { 607 + authenticated: self._fields.0.unwrap(), 608 + extra_data: Some(extra_data), 609 + } 610 + } 68 611 }
+7 -7
justfile
··· 2 2 3 3 set dotenv-load := false 4 4 5 - # Format and apply safe automatic fixes. This is the default pre-review cleanup command. 5 + # Format Rust sources. Run clippy/check separately; clippy --fix can corrupt RSX. 6 6 fix: 7 7 cargo fmt --all 8 - cargo clippy --workspace --all-targets --fix --allow-dirty --allow-staged --allow-no-vcs -- -D warnings 9 - cargo clippy -p polymodel --all-targets --features server --fix --allow-dirty --allow-staged --allow-no-vcs -- -D warnings 10 - cargo clippy -p polymodel --target wasm32-unknown-unknown --features web --fix --allow-dirty --allow-staged --allow-no-vcs -- -D warnings 11 - 8 + # Run clippy without --fix; automatic clippy rewrites can corrupt Dioxus RSX. 9 + lint: 10 + cargo clippy --workspace --all-targets -- -D warnings 11 + cargo clippy -p polymodel --all-targets --features server -- -D warnings 12 + cargo clippy -p polymodel --target wasm32-unknown-unknown --features web -- -D warnings 12 13 # Compile checks across the whole workspace plus the app's server/wasm targets. 13 - # Note: the server check uses compile-time `query!` macros that validate against a 14 14 # migrated SQLite database. Run `just migrate` first (or run `just sqlx-prepare`) 15 15 # so the macros can resolve. 16 16 check: ··· 39 39 cargo nextest run -p polymodel --features server 40 40 41 41 # Run all local validation expected before review. 42 - test-all: fix check test test-server 42 + test-all: fix check lint test test-server 43 43 # Run browser end-to-end tests. 44 44 e2e: 45 45 cd e2e && npm test
+27 -11
lexicons/actor/getSession.json
··· 2 2 "lexicon": 1, 3 3 "id": "space.polymodel.actor.getSession", 4 4 "defs": { 5 + "sessionUnauthenticated": { 6 + "type": "object", 7 + "description": "Logged-out session marker. No identity fields are present when authenticated is false.", 8 + "required": ["authenticated"], 9 + "properties": { "authenticated": { "type": "boolean", "const": false } } 10 + }, 11 + "sessionAuthenticated": { 12 + "type": "object", 13 + "description": "Authenticated viewer identity for client session seeding. If authenticated is true, did, handle, and profile are all present.", 14 + "required": ["authenticated", "did", "handle", "profile"], 15 + "properties": { 16 + "authenticated": { "type": "boolean", "const": true }, 17 + "did": { "type": "string", "format": "did" }, 18 + "handle": { "type": "string", "format": "handle" }, 19 + "profile": { "type": "ref", "ref": "space.polymodel.actor.defs#profileView" } 20 + } 21 + }, 22 + "sessionView": { 23 + "type": "union", 24 + "refs": [ 25 + "space.polymodel.actor.getSession#sessionUnauthenticated", 26 + "space.polymodel.actor.getSession#sessionAuthenticated" 27 + ], 28 + "closed": true 29 + }, 5 30 "main": { 6 31 "type": "query", 7 - "description": "Identity primitive (PM-9). Returns the authenticated viewer's identity plus profile for SSR-first session seeding, or an unauthenticated marker. The profile field reuses the getProfile union shape so the client renders identity + profile from a single first-paint value. PM-26 returns only the unauthenticated marker ({authenticated: false}); the authenticated fields (did/handle/profile) are populated once PM-27 wires OAuth session extraction, without changing this output contract. Takes no parameters: identity is derived from the request session, not an actor argument.", 32 + "description": "Identity primitive (PM-9). Returns either a logged-out marker or the authenticated viewer's full identity/profile for client session seeding. Takes no parameters: identity is derived from the request session, not an actor argument.", 8 33 "output": { 9 34 "encoding": "application/json", 10 - "schema": { 11 - "type": "object", 12 - "required": ["authenticated"], 13 - "properties": { 14 - "authenticated": { "type": "boolean", "description": "Always present. When false the response carries only this field (no identity/profile); when true, did/handle/profile are populated." }, 15 - "did": { "type": "string", "format": "did" }, 16 - "handle": { "type": "string" }, 17 - "profile": { "type": "ref", "ref": "space.polymodel.actor.defs#profileViewUnion" } 18 - } 19 - } 35 + "schema": { "type": "ref", "ref": "space.polymodel.actor.getSession#sessionView" } 20 36 } 21 37 } 22 38 }
+8 -8
src/appview/actor.rs
··· 15 15 use polymodel_api::space_polymodel::actor::{ 16 16 ProfileViewUnion, 17 17 get_profile::{GetProfileOutput, GetProfileRequest}, 18 - get_session::{GetSessionOutput, GetSessionRequest}, 18 + get_session::{GetSessionOutput, GetSessionRequest, SessionUnauthenticated, SessionView}, 19 19 }; 20 20 21 21 use super::error::{AppResult, internal}; ··· 82 82 pub(super) async fn get_session( 83 83 State(_state): State<Arc<AppState>>, 84 84 ) -> AppResult<XrpcResponse<GetSessionRequest>> { 85 - // PM-26 ships the read seam + output contract only. Authenticated state 86 - // (did/handle/profile) is populated once PM-27 wires OAuth session extraction 87 - // — the output shape does not change, only which fields are filled. 85 + // PM-26 ships the unauthenticated arm only. PM-27 will return 86 + // `SessionAuthenticated` once OAuth session extraction and profile writes are wired. 87 + 88 88 Ok(XrpcResponse(GetSessionOutput { 89 - authenticated: false, 90 - did: None, 91 - handle: None, 92 - profile: None, 89 + value: SessionView::SessionUnauthenticated(Box::new(SessionUnauthenticated { 90 + authenticated: false, 91 + extra_data: None, 92 + })), 93 93 extra_data: None, 94 94 })) 95 95 }
+150
src/auth.rs
··· 1 + #[cfg(test)] 2 + use crate::session::SessionIdentity; 3 + 4 + #[cfg(all(target_family = "wasm", target_os = "unknown"))] 5 + pub const AUTH_BROADCAST_CHANNEL: &str = "polymodel-auth"; 6 + #[cfg(all(target_family = "wasm", target_os = "unknown"))] 7 + pub const SIGN_OUT_MESSAGE: &str = "sign-out"; 8 + 9 + #[derive(Clone, Debug, PartialEq, Eq)] 10 + pub struct Toast { 11 + pub id: u64, 12 + pub message: String, 13 + } 14 + 15 + #[derive(Clone, Debug, Default, PartialEq, Eq)] 16 + pub struct ToastState { 17 + next_id: u64, 18 + items: Vec<Toast>, 19 + } 20 + 21 + impl ToastState { 22 + pub fn push(&mut self, message: impl Into<String>) -> u64 { 23 + let id = self.next_id; 24 + self.next_id += 1; 25 + self.items.push(Toast { 26 + id, 27 + message: message.into(), 28 + }); 29 + id 30 + } 31 + 32 + pub fn dismiss(&mut self, id: u64) { 33 + self.items.retain(|toast| toast.id != id); 34 + } 35 + 36 + pub fn items(&self) -> &[Toast] { 37 + &self.items 38 + } 39 + } 40 + 41 + #[cfg(test)] 42 + pub fn handle_remote_sign_out(session: &mut SessionIdentity, toasts: &mut ToastState) { 43 + *session = SessionIdentity::Anonymous; 44 + toasts.push("Signed out in another tab."); 45 + } 46 + 47 + pub fn login_url(identifier: &str) -> String { 48 + let identifier = identifier.trim(); 49 + if identifier.is_empty() { 50 + "/oauth/login".to_string() 51 + } else { 52 + format!( 53 + "/oauth/start?identifier={}", 54 + encode_query_component(identifier) 55 + ) 56 + } 57 + } 58 + 59 + pub fn logout_url() -> &'static str { 60 + "/oauth/logout" 61 + } 62 + 63 + fn encode_query_component(value: &str) -> String { 64 + #[cfg(all(target_family = "wasm", target_os = "unknown"))] 65 + { 66 + js_sys::encode_uri_component(value) 67 + .as_string() 68 + .unwrap_or_default() 69 + } 70 + 71 + #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 72 + { 73 + value 74 + .bytes() 75 + .flat_map(|byte| match byte { 76 + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { 77 + vec![byte as char] 78 + } 79 + _ => format!("%{byte:02X}").chars().collect(), 80 + }) 81 + .collect() 82 + } 83 + } 84 + 85 + #[cfg(all(target_family = "wasm", target_os = "unknown"))] 86 + pub fn navigate_to(url: &str) { 87 + if let Some(window) = web_sys::window() { 88 + let _ = window.location().set_href(url); 89 + } 90 + } 91 + 92 + #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 93 + pub fn navigate_to(_url: &str) {} 94 + 95 + #[cfg(all(target_family = "wasm", target_os = "unknown"))] 96 + pub fn broadcast_sign_out() { 97 + if let Ok(channel) = web_sys::BroadcastChannel::new(AUTH_BROADCAST_CHANNEL) { 98 + let _ = channel.post_message(&wasm_bindgen::JsValue::from_str(SIGN_OUT_MESSAGE)); 99 + } 100 + } 101 + 102 + #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 103 + pub fn broadcast_sign_out() {} 104 + 105 + #[cfg(test)] 106 + mod tests { 107 + use super::*; 108 + use jacquard::types::string::Handle; 109 + use jacquard_common::types::did::Did; 110 + 111 + #[test] 112 + fn login_url_uses_oauth_login_when_identifier_empty() { 113 + assert_eq!(login_url(" "), "/oauth/login"); 114 + } 115 + 116 + #[test] 117 + fn login_url_uses_oauth_start_with_encoded_identifier() { 118 + assert_eq!( 119 + login_url("alice model.example"), 120 + "/oauth/start?identifier=alice%20model.example" 121 + ); 122 + } 123 + 124 + #[test] 125 + fn remote_sign_out_clears_session_and_adds_toast() { 126 + let mut session = SessionIdentity::Authenticated(crate::session::AuthenticatedIdentity { 127 + did: Did::new_owned("did:plc:alice").unwrap(), 128 + handle: Handle::new_owned("alice.test").unwrap(), 129 + display_name: None, 130 + }); 131 + let mut toasts = ToastState::default(); 132 + 133 + handle_remote_sign_out(&mut session, &mut toasts); 134 + 135 + assert_eq!(session, SessionIdentity::Anonymous); 136 + assert_eq!(toasts.items()[0].message, "Signed out in another tab."); 137 + } 138 + 139 + #[test] 140 + fn toast_state_dismisses_by_id() { 141 + let mut toasts = ToastState::default(); 142 + let first = toasts.push("one"); 143 + let _second = toasts.push("two"); 144 + 145 + toasts.dismiss(first); 146 + 147 + assert_eq!(toasts.items().len(), 1); 148 + assert_eq!(toasts.items()[0].message, "two"); 149 + } 150 + }
+305
src/client.rs
··· 1 + //! Same-origin Polymodel appview client for browser UI calls. 2 + //! 3 + //! Polymodel deliberately uses this thin client rather than Jacquard's 4 + //! credential/session `BasicClient` path: appview reads should target this 5 + //! server's `/xrpc/space.polymodel.*` routes and rely on browser-managed 6 + //! cookies once PM-27 wires OAuth sessions. Callers send generated payload 7 + //! request values, for example `client.send(GetSession).await?.into_output()`. 8 + //! Identity resolution is delegated to Jacquard's resolver over the same 9 + //! injected HTTP transport; this keeps Polymodel's appview client thin without 10 + //! adopting a credential/session agent for read calls. 11 + 12 + use std::fmt; 13 + use std::sync::{Arc, RwLock}; 14 + 15 + use jacquard::identity::JacquardResolver; 16 + use jacquard::identity::resolver::{ 17 + DidDocResponse, IdentityError, IdentityResolver, ResolverOptions, 18 + }; 19 + use jacquard_common::BosStr; 20 + use jacquard_common::http_client::HttpClient; 21 + use jacquard_common::types::did::Did; 22 + use jacquard_common::types::string::Handle; 23 + use jacquard_common::xrpc::{CallOptions, XrpcClient, XrpcExt, XrpcRequest, XrpcResponse}; 24 + use jacquard_common::{deps::fluent_uri::Uri, error::XrpcResult}; 25 + 26 + const DEFAULT_NATIVE_BASE_URI: &str = "http://127.0.0.1:8080"; 27 + 28 + #[derive(Clone)] 29 + pub struct PolymodelClient { 30 + http: reqwest::Client, 31 + resolver: JacquardResolver<reqwest::Client>, 32 + base_uri: Arc<RwLock<Uri<String>>>, 33 + opts: Arc<RwLock<CallOptions>>, 34 + } 35 + 36 + impl fmt::Debug for PolymodelClient { 37 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 38 + f.debug_struct("PolymodelClient") 39 + .field("base_uri", &self.current_base_uri().as_str()) 40 + .finish_non_exhaustive() 41 + } 42 + } 43 + 44 + impl PolymodelClient { 45 + pub fn new() -> Result<Self, ClientError> { 46 + Self::with_base_uri(default_base_uri()?) 47 + } 48 + 49 + pub fn with_base_uri(base_uri: Uri<String>) -> Result<Self, ClientError> { 50 + Self::with_http_and_base_uri(reqwest::Client::new(), base_uri) 51 + } 52 + 53 + pub fn with_http_and_base_uri( 54 + http: reqwest::Client, 55 + base_uri: Uri<String>, 56 + ) -> Result<Self, ClientError> { 57 + let resolver = JacquardResolver::new(http.clone(), ResolverOptions::default()); 58 + Ok(Self { 59 + http, 60 + resolver, 61 + base_uri: Arc::new(RwLock::new(base_uri)), 62 + opts: Arc::new(RwLock::new(CallOptions::default())), 63 + }) 64 + } 65 + 66 + pub fn parse_base_uri(value: &str) -> Result<Uri<String>, ClientError> { 67 + Uri::parse(value.to_string()).map_err(|_| ClientError::InvalidBaseUri(value.to_string())) 68 + } 69 + 70 + pub fn current_base_uri(&self) -> Uri<String> { 71 + self.base_uri 72 + .read() 73 + .expect("Polymodel client base URI lock is not poisoned") 74 + .clone() 75 + } 76 + } 77 + 78 + impl Default for PolymodelClient { 79 + fn default() -> Self { 80 + Self::new().expect("default Polymodel appview base URI is valid") 81 + } 82 + } 83 + 84 + impl HttpClient for PolymodelClient { 85 + type Error = reqwest::Error; 86 + 87 + async fn send_http( 88 + &self, 89 + request: http::Request<Vec<u8>>, 90 + ) -> Result<http::Response<Vec<u8>>, Self::Error> { 91 + self.http.send_http(request).await 92 + } 93 + } 94 + 95 + impl XrpcClient for PolymodelClient { 96 + async fn base_uri(&self) -> Uri<String> { 97 + self.current_base_uri() 98 + } 99 + 100 + async fn set_base_uri(&self, uri: Uri<String>) { 101 + *self 102 + .base_uri 103 + .write() 104 + .expect("Polymodel client base URI lock is not poisoned") = uri; 105 + } 106 + 107 + async fn opts(&self) -> CallOptions { 108 + self.opts 109 + .read() 110 + .expect("Polymodel client XRPC options lock is not poisoned") 111 + .clone() 112 + } 113 + 114 + async fn set_opts(&self, opts: CallOptions) { 115 + *self 116 + .opts 117 + .write() 118 + .expect("Polymodel client XRPC options lock is not poisoned") = opts; 119 + } 120 + 121 + #[cfg(not(target_arch = "wasm32"))] 122 + async fn send<R>(&self, request: R) -> XrpcResult<XrpcResponse<R>> 123 + where 124 + R: XrpcRequest + Send + Sync + serde::Serialize, 125 + <R as XrpcRequest>::Response: Send + Sync, 126 + Self: Sync, 127 + { 128 + let base = self.base_uri().await; 129 + let opts = self.opts().await; 130 + let base_ref: Uri<&str> = base.borrow(); 131 + self.xrpc(base_ref).with_options(opts).send(&request).await 132 + } 133 + 134 + #[cfg(target_arch = "wasm32")] 135 + async fn send<R>(&self, request: R) -> XrpcResult<XrpcResponse<R>> 136 + where 137 + R: XrpcRequest + Send + Sync + serde::Serialize, 138 + <R as XrpcRequest>::Response: Send + Sync, 139 + { 140 + let base = self.base_uri().await; 141 + let opts = self.opts().await; 142 + let base_ref: Uri<&str> = base.borrow(); 143 + self.xrpc(base_ref).with_options(opts).send(&request).await 144 + } 145 + 146 + #[cfg(not(target_arch = "wasm32"))] 147 + async fn send_with_opts<R>(&self, request: R, opts: CallOptions) -> XrpcResult<XrpcResponse<R>> 148 + where 149 + R: XrpcRequest + Send + Sync + serde::Serialize, 150 + <R as XrpcRequest>::Response: Send + Sync, 151 + Self: Sync, 152 + { 153 + let base = self.base_uri().await; 154 + let base_ref: Uri<&str> = base.borrow(); 155 + self.xrpc(base_ref).with_options(opts).send(&request).await 156 + } 157 + 158 + #[cfg(target_arch = "wasm32")] 159 + async fn send_with_opts<R>(&self, request: R, opts: CallOptions) -> XrpcResult<XrpcResponse<R>> 160 + where 161 + R: XrpcRequest + Send + Sync + serde::Serialize, 162 + <R as XrpcRequest>::Response: Send + Sync, 163 + { 164 + let base = self.base_uri().await; 165 + let base_ref: Uri<&str> = base.borrow(); 166 + self.xrpc(base_ref).with_options(opts).send(&request).await 167 + } 168 + } 169 + 170 + impl IdentityResolver for PolymodelClient { 171 + fn options(&self) -> &ResolverOptions { 172 + self.resolver.options() 173 + } 174 + 175 + #[cfg(not(target_arch = "wasm32"))] 176 + async fn resolve_handle<S: BosStr + Sync>( 177 + &self, 178 + handle: &Handle<S>, 179 + ) -> Result<Did, IdentityError> 180 + where 181 + Self: Sync, 182 + { 183 + self.resolver.resolve_handle(handle).await 184 + } 185 + 186 + #[cfg(target_arch = "wasm32")] 187 + async fn resolve_handle<S: BosStr + Sync>( 188 + &self, 189 + handle: &Handle<S>, 190 + ) -> Result<Did, IdentityError> { 191 + self.resolver.resolve_handle(handle).await 192 + } 193 + 194 + #[cfg(not(target_arch = "wasm32"))] 195 + async fn resolve_did_doc<S: BosStr + Sync>( 196 + &self, 197 + did: &Did<S>, 198 + ) -> Result<DidDocResponse, IdentityError> 199 + where 200 + Self: Sync, 201 + { 202 + self.resolver.resolve_did_doc(did).await 203 + } 204 + 205 + #[cfg(target_arch = "wasm32")] 206 + async fn resolve_did_doc<S: BosStr + Sync>( 207 + &self, 208 + did: &Did<S>, 209 + ) -> Result<DidDocResponse, IdentityError> { 210 + self.resolver.resolve_did_doc(did).await 211 + } 212 + } 213 + 214 + #[derive(Debug, thiserror::Error, PartialEq, Eq)] 215 + pub enum ClientError { 216 + #[error("invalid Polymodel appview base URI: {0}")] 217 + InvalidBaseUri(String), 218 + } 219 + 220 + fn default_base_uri() -> Result<Uri<String>, ClientError> { 221 + #[cfg(all(target_family = "wasm", target_os = "unknown"))] 222 + { 223 + let origin = web_sys::window() 224 + .and_then(|window| window.location().origin().ok()) 225 + .filter(|origin| !origin.is_empty()) 226 + .unwrap_or_else(|| DEFAULT_NATIVE_BASE_URI.to_string()); 227 + PolymodelClient::parse_base_uri(&origin) 228 + } 229 + 230 + #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 231 + { 232 + PolymodelClient::parse_base_uri(DEFAULT_NATIVE_BASE_URI) 233 + } 234 + } 235 + 236 + #[cfg(test)] 237 + mod tests { 238 + use super::*; 239 + use jacquard::ToSmolStr; 240 + use jacquard_common::xrpc::{CallOptions, build_http_request}; 241 + use polymodel_api::space_polymodel::actor::get_session::GetSession; 242 + use polymodel_api::space_polymodel::library::get_feed::GetFeed; 243 + 244 + #[test] 245 + fn stores_base_uri_without_rewriting_it() { 246 + let client = PolymodelClient::with_base_uri( 247 + PolymodelClient::parse_base_uri("https://example.test/app").unwrap(), 248 + ) 249 + .unwrap(); 250 + 251 + assert_eq!( 252 + client.current_base_uri().as_str(), 253 + "https://example.test/app" 254 + ); 255 + } 256 + 257 + #[test] 258 + fn get_feed_payload_builds_same_origin_xrpc_query() { 259 + let base = Uri::parse("https://example.test".to_string()).unwrap(); 260 + let base_ref = Uri::parse(base.as_str()).unwrap(); 261 + let request = GetFeed::new() 262 + .algorithm(Some("recent".to_smolstr())) 263 + .limit(24) 264 + .build(); 265 + let http = build_http_request(&base_ref, &request, &CallOptions::default()).unwrap(); 266 + 267 + assert_eq!(http.method(), http::Method::GET); 268 + assert_eq!( 269 + http.uri().to_string(), 270 + "https://example.test/xrpc/space.polymodel.library.getFeed?algorithm=recent&limit=24" 271 + ); 272 + } 273 + 274 + #[test] 275 + fn get_session_payload_builds_no_param_query() { 276 + let base = Uri::parse("https://example.test".to_string()).unwrap(); 277 + let base_ref = Uri::parse(base.as_str()).unwrap(); 278 + let http = build_http_request(&base_ref, &GetSession, &CallOptions::default()).unwrap(); 279 + 280 + assert_eq!(http.method(), http::Method::GET); 281 + assert_eq!( 282 + http.uri().to_string(), 283 + "https://example.test/xrpc/space.polymodel.actor.getSession" 284 + ); 285 + } 286 + 287 + #[test] 288 + fn polymodel_client_exposes_jacquard_identity_resolver_options() { 289 + let client = PolymodelClient::with_base_uri( 290 + PolymodelClient::parse_base_uri("https://example.test").unwrap(), 291 + ) 292 + .unwrap(); 293 + 294 + let _: &ResolverOptions = client.options(); 295 + } 296 + 297 + #[test] 298 + fn client_error_formats_for_ui() { 299 + let error = PolymodelClient::parse_base_uri("not a uri").unwrap_err(); 300 + assert_eq!( 301 + error.to_string(), 302 + "invalid Polymodel appview base URI: not a uri" 303 + ); 304 + } 305 + }
+3 -3
src/indexing/projection.rs
··· 886 886 record: Some(json!({ 887 887 "name": name, 888 888 "summary": "a thing summary", 889 - "tags": ["cube", "calibration"], 889 + "license": "MIT", "tags": ["cube", "calibration"], 890 890 "createdAt": "2024-01-15T12:00:00.000Z", 891 891 "models": models.iter().map(|m| json!({"uri": m, "cid": "c"})).collect::<Vec<_>>(), 892 892 })), ··· 1236 1236 action: "create".to_string(), 1237 1237 record: Some(json!({ 1238 1238 "name": "Calibration Cube", 1239 - "tags": ["cube"], 1239 + "license": "MIT", "tags": ["cube"], 1240 1240 "createdAt": "2024-01-01T00:00:00.000Z", 1241 1241 "models": [], 1242 1242 })), ··· 1253 1253 update.cid = Some("cid2".to_string()); 1254 1254 update.record = Some(json!({ 1255 1255 "name": "Friendly Sphere", 1256 - "tags": ["sphere"], 1256 + "license": "MIT", "tags": ["sphere"], 1257 1257 "createdAt": "2024-01-02T00:00:00.000Z", 1258 1258 "models": [], 1259 1259 }));
+243 -9
src/main.rs
··· 1 1 use dioxus::prelude::*; 2 + use jacquard_common::xrpc::XrpcClient; 3 + use polymodel_api::space_polymodel::{actor::get_session::GetSession, library}; 2 4 5 + mod auth; 6 + mod client; 3 7 mod fonts; 4 8 mod mesh; 9 + mod session; 5 10 mod thing_card; 6 11 mod viewer; 7 12 ··· 14 19 #[cfg(feature = "server")] 15 20 mod appview; 16 21 17 - use thing_card::{ThingCard, thing_card_fixtures}; 22 + use auth::{ToastState, broadcast_sign_out, login_url, logout_url, navigate_to}; 23 + use client::PolymodelClient; 24 + use session::SessionIdentity; 25 + use thing_card::ThingCard; 18 26 use viewer::{ViewerPage, demo_model_for_route, demo_models}; 19 27 20 28 const FAVICON: Asset = asset!("/assets/favicon.jpg"); ··· 73 81 74 82 #[component] 75 83 fn App() -> Element { 84 + let client = use_context_provider(PolymodelClient::default); 85 + let session = use_context_provider(|| Signal::new(SessionIdentity::Anonymous)); 86 + let toasts = use_context_provider(|| Signal::new(ToastState::default())); 87 + 88 + let _session_seed = use_resource(move || { 89 + let client = client.clone(); 90 + let mut session = session; 91 + async move { 92 + match client.send(GetSession).await { 93 + Ok(response) => match response.into_output() { 94 + Ok(output) => session.set(SessionIdentity::from_get_session(output)), 95 + Err(error) => tracing::warn!(%error, "failed to decode Polymodel session"), 96 + }, 97 + Err(error) => tracing::warn!(%error, "failed to fetch Polymodel session"), 98 + } 99 + } 100 + }); 101 + 76 102 rsx! { 77 103 document::Meta { name: "viewport", content: "width=device-width, initial-scale=1" } 78 104 document::Title { "Polymodel" } ··· 84 110 Stylesheet { href: POLYMODEL_CSS } 85 111 86 112 Router::<Route> {} 113 + ToastShelf { toasts } 87 114 } 88 115 } 89 116 117 + #[derive(Clone, Copy, Debug, PartialEq, Eq)] 118 + enum FeedAlgorithm { 119 + Recent, 120 + Hot, 121 + } 122 + 123 + impl FeedAlgorithm { 124 + fn as_str(self) -> &'static str { 125 + match self { 126 + Self::Recent => "recent", 127 + Self::Hot => "hot", 128 + } 129 + } 130 + 131 + fn label(self) -> &'static str { 132 + match self { 133 + Self::Recent => "Recent", 134 + Self::Hot => "Hot", 135 + } 136 + } 137 + } 138 + 139 + #[derive(Clone, Debug, PartialEq, Eq)] 140 + enum BrowseFeedState { 141 + Loading, 142 + Error(String), 143 + Empty, 144 + Populated(Vec<library::ThingViewBasic>), 145 + } 146 + 147 + fn browse_feed_state( 148 + pending: bool, 149 + result: Option<&Result<Vec<library::ThingViewBasic>, String>>, 150 + ) -> BrowseFeedState { 151 + if pending && result.is_none() { 152 + return BrowseFeedState::Loading; 153 + } 154 + 155 + match result { 156 + Some(Ok(things)) if things.is_empty() => BrowseFeedState::Empty, 157 + Some(Ok(things)) => BrowseFeedState::Populated(things.clone()), 158 + Some(Err(error)) => BrowseFeedState::Error(error.clone()), 159 + None => BrowseFeedState::Loading, 160 + } 161 + } 162 + 163 + #[allow(clippy::useless_format)] 90 164 #[component] 91 165 fn Browse() -> Element { 92 166 let viewer_model = demo_models()[0].id.to_string(); 93 - let things = thing_card_fixtures(); 167 + let client = use_context::<PolymodelClient>(); 168 + let session = use_context::<Signal<SessionIdentity>>(); 169 + let toasts = use_context::<Signal<ToastState>>(); 170 + let selected_algorithm = use_signal(|| FeedAlgorithm::Recent); 171 + 172 + let mut feed = use_resource(move || { 173 + let client = client.clone(); 174 + let algorithm = *selected_algorithm.read(); 175 + async move { 176 + let request = library::get_feed::GetFeed::new() 177 + .algorithm(Some(algorithm.as_str().into())) 178 + .limit(24) 179 + .build(); 180 + let response = client 181 + .send(request) 182 + .await 183 + .map_err(|error| format!("Feed request failed: {error}"))?; 184 + let output = response 185 + .into_output() 186 + .map_err(|error| format!("Feed decode failed: {error}"))?; 187 + Ok(output 188 + .value 189 + .items 190 + .into_iter() 191 + .map(|item| item.thing) 192 + .collect::<Vec<_>>()) 193 + } 194 + }); 195 + 196 + let feed_result = feed.read(); 197 + let feed_state = browse_feed_state(feed.pending(), feed_result.as_ref()); 94 198 95 199 rsx! { 96 200 main { class: "browse-page product-shell", ··· 108 212 to: Route::Viewer { model_id: viewer_model }, 109 213 "Open viewer" 110 214 } 215 + SessionControl { session, toasts } 111 216 } 112 217 } 113 218 } ··· 121 226 aria_label: "Search model projects" 122 227 } 123 228 } 124 - div { class: "tab-row", role: "tablist", aria_label: "Feed placeholders", 125 - button { class: "tab is-active", role: "tab", aria_selected: "true", "Recent" } 126 - button { class: "tab", role: "tab", aria_selected: "false", "Hot" } 127 - button { class: "tab", role: "tab", aria_selected: "false", "Following" } 229 + div { class: "tab-row", role: "tablist", aria_label: "Feeds", 230 + FeedTab { algorithm: FeedAlgorithm::Recent, selected_algorithm } 231 + FeedTab { algorithm: FeedAlgorithm::Hot, selected_algorithm } 232 + button { class: "tab", role: "tab", aria_selected: "false", disabled: true, "Following" } 128 233 } 129 234 } 130 235 131 236 section { class: "browse-results-grid", aria_label: "Browse results", 132 - for thing in things { 133 - ThingCard { key: "{thing.uri}", thing } 237 + match feed_state { 238 + BrowseFeedState::Loading => rsx! { 239 + for index in 0..6 { 240 + div { key: "skeleton-{index}", class: "state-card loading-state", 241 + span { class: "status-pill", "Loading" } 242 + div { class: "skeleton-line skeleton-wide" } 243 + div { class: "skeleton-line" } 244 + div { class: "skeleton-box" } 245 + } 246 + } 247 + }, 248 + BrowseFeedState::Error(error) => { 249 + let error_message = error.clone(); 250 + rsx! { 251 + div { class: "state-card error-state browse-state-card", 252 + p { "{error_message}" } 253 + button { 254 + class: "button button-secondary", 255 + onclick: move |_| feed.restart(), 256 + "Retry" 257 + } 258 + } 259 + } }, 260 + BrowseFeedState::Empty => rsx! { 261 + div { class: "state-card empty-state browse-state-card", 262 + span { class: "status-pill status-muted", "Empty" } 263 + h2 { "No projects yet" } 264 + p { "Polymodel reached the appview feed, but this local index has no projects for the selected feed yet." } 265 + } 266 + }, 267 + BrowseFeedState::Populated(things) => rsx! { 268 + for thing in things { 269 + ThingCard { key: "{thing.uri}", thing } 270 + } 271 + }, 272 + } 273 + } 274 + } 275 + } 276 + } 277 + 278 + #[component] 279 + fn FeedTab(algorithm: FeedAlgorithm, selected_algorithm: Signal<FeedAlgorithm>) -> Element { 280 + let is_active = *selected_algorithm.read() == algorithm; 281 + let class = if is_active { "tab is-active" } else { "tab" }; 282 + 283 + rsx! { 284 + button { 285 + class, 286 + role: "tab", 287 + aria_selected: "{is_active}", 288 + onclick: move |_| selected_algorithm.set(algorithm), 289 + "{algorithm.label()}" 290 + } 291 + } 292 + } 293 + 294 + #[component] 295 + fn SessionControl(session: Signal<SessionIdentity>, toasts: Signal<ToastState>) -> Element { 296 + let identity = session.read().clone(); 297 + let mut identifier = use_signal(String::new); 298 + 299 + match identity { 300 + SessionIdentity::Anonymous => rsx! { 301 + form { 302 + class: "session-control", 303 + onsubmit: move |_| navigate_to(&login_url(&identifier.read())), 304 + input { 305 + r#type: "text", 306 + placeholder: "handle.test", 307 + aria_label: "ATProto handle", 308 + value: "{identifier}", 309 + oninput: move |event| identifier.set(event.value()) 310 + } 311 + button { class: "button button-primary", r#type: "submit", "Sign in" } 312 + } 313 + }, 314 + SessionIdentity::Authenticated(identity) => { 315 + let label = SessionIdentity::Authenticated(identity).label(); 316 + rsx! { 317 + div { class: "session-control session-control-authenticated", 318 + span { class: "status-pill", "{label}" } 319 + button { 320 + class: "button button-secondary", 321 + onclick: move |_| { 322 + broadcast_sign_out(); 323 + session.set(SessionIdentity::Anonymous); 324 + toasts.write().push("Signing out…"); 325 + navigate_to(logout_url()); 326 + }, 327 + "Sign out" 328 + } 329 + } 330 + } 331 + } 332 + } 333 + } 334 + 335 + #[component] 336 + fn ToastShelf(toasts: Signal<ToastState>) -> Element { 337 + let items = toasts.read().items().to_vec(); 338 + 339 + rsx! { 340 + div { class: "toast-shelf", aria_live: "polite", 341 + for toast in items { 342 + div { key: "toast-{toast.id}", class: "toast blueprint-panel", 343 + span { "{toast.message}" } 344 + button { 345 + class: "toast-dismiss", 346 + aria_label: "Dismiss notification", 347 + onclick: move |_| toasts.write().dismiss(toast.id), 348 + "×" 349 + } 134 350 } 135 351 } 136 352 } ··· 268 484 269 485 #[cfg(test)] 270 486 mod tests { 271 - use super::foundation_items; 487 + use super::*; 488 + use thing_card::thing_card_fixtures; 272 489 273 490 #[test] 274 491 fn foundation_items_cover_base_primitives() { ··· 287 504 .join(" "); 288 505 assert!(!combined.contains("Jira")); 289 506 assert!(!combined.contains("Polytoken")); 507 + } 508 + 509 + #[test] 510 + fn browse_state_distinguishes_loading_empty_error_and_populated() { 511 + assert_eq!(browse_feed_state(true, None), BrowseFeedState::Loading); 512 + assert_eq!( 513 + browse_feed_state(false, Some(&Ok(vec![]))), 514 + BrowseFeedState::Empty 515 + ); 516 + assert_eq!( 517 + browse_feed_state(false, Some(&Err("boom".to_string()))), 518 + BrowseFeedState::Error("boom".to_string()) 519 + ); 520 + match browse_feed_state(false, Some(&Ok(thing_card_fixtures()))) { 521 + BrowseFeedState::Populated(things) => assert_eq!(things.len(), 3), 522 + state => panic!("expected populated state, got {state:?}"), 523 + } 290 524 } 291 525 }
+124
src/session.rs
··· 1 + use jacquard_common::deps::smol_str::{SmolStr, ToSmolStr}; 2 + use jacquard_common::types::string::{Did, Handle}; 3 + use polymodel_api::space_polymodel::actor::ProfileView; 4 + use polymodel_api::space_polymodel::actor::get_session::{GetSessionOutput, SessionView}; 5 + #[derive(Clone, Debug, Default, PartialEq, Eq)] 6 + pub enum SessionIdentity { 7 + #[default] 8 + Anonymous, 9 + Authenticated(AuthenticatedIdentity), 10 + } 11 + 12 + #[derive(Clone, Debug, PartialEq, Eq)] 13 + pub struct AuthenticatedIdentity { 14 + pub did: Did, 15 + pub handle: Handle, 16 + pub display_name: Option<SmolStr>, 17 + } 18 + 19 + impl SessionIdentity { 20 + pub fn from_get_session(output: GetSessionOutput) -> Self { 21 + match output.value { 22 + SessionView::SessionUnauthenticated(_) => Self::Anonymous, 23 + SessionView::SessionAuthenticated(session) => { 24 + Self::Authenticated(AuthenticatedIdentity { 25 + did: session.did, 26 + handle: session.handle, 27 + display_name: profile_display_name(&session.profile), 28 + }) 29 + } 30 + } 31 + } 32 + 33 + pub fn label(&self) -> SmolStr { 34 + match self { 35 + Self::Anonymous => SmolStr::new_static("Signed out"), 36 + Self::Authenticated(identity) => identity 37 + .display_name 38 + .clone() 39 + .unwrap_or_else(|| identity.handle.as_str().to_smolstr()), 40 + } 41 + } 42 + } 43 + 44 + fn profile_display_name(profile: &ProfileView) -> Option<SmolStr> { 45 + profile 46 + .display_name 47 + .as_ref() 48 + .map(|value| value.to_smolstr()) 49 + .filter(|value| !value.trim().is_empty()) 50 + } 51 + 52 + #[cfg(test)] 53 + mod tests { 54 + use super::*; 55 + use jacquard_common::types::did::Did; 56 + use polymodel_api::space_polymodel::actor::ProfileView; 57 + use polymodel_api::space_polymodel::actor::get_session::{ 58 + SessionAuthenticated, SessionUnauthenticated, 59 + }; 60 + #[test] 61 + fn unauthenticated_get_session_maps_to_anonymous() { 62 + let output = GetSessionOutput { 63 + value: SessionView::SessionUnauthenticated(Box::new(SessionUnauthenticated { 64 + authenticated: false, 65 + extra_data: None, 66 + })), 67 + extra_data: None, 68 + }; 69 + 70 + assert_eq!( 71 + SessionIdentity::from_get_session(output), 72 + SessionIdentity::Anonymous 73 + ); 74 + } 75 + 76 + #[test] 77 + fn authenticated_get_session_maps_identity_label() { 78 + let output = GetSessionOutput { 79 + value: SessionView::SessionAuthenticated(Box::new(SessionAuthenticated { 80 + did: Did::new_owned("did:plc:alice").unwrap(), 81 + handle: Handle::new_owned("alice.test").unwrap(), 82 + profile: ProfileView { 83 + avatar: None, 84 + default_license: None, 85 + indexed_at: None, 86 + description: None, 87 + did: Did::new_owned("did:plc:alice").unwrap(), 88 + display_name: Some("Alice Maker".into()), 89 + handle: "alice.test".into(), 90 + pronouns: None, 91 + thing_count: None, 92 + extra_data: None, 93 + links: None, 94 + printers: None, 95 + record: jacquard::Data::Null, 96 + total_likes: None, 97 + }, 98 + extra_data: None, 99 + authenticated: true, 100 + })), 101 + extra_data: None, 102 + }; 103 + 104 + let identity = SessionIdentity::from_get_session(output); 105 + assert!(matches!(identity, SessionIdentity::Authenticated(_))); 106 + assert_eq!(identity.label(), "Alice Maker"); 107 + } 108 + #[test] 109 + fn get_session_json_decodes_unauthenticated_union_arm() { 110 + let output: GetSessionOutput = serde_json::from_str(r#"{"$type":"space.polymodel.actor.getSession#sessionUnauthenticated","authenticated":false}"#).unwrap(); 111 + assert_eq!( 112 + SessionIdentity::from_get_session(output), 113 + SessionIdentity::Anonymous 114 + ); 115 + } 116 + #[test] 117 + fn get_session_json_decodes_authenticated_union_arm() { 118 + let output: GetSessionOutput = serde_json::from_str(r#"{"$type":"space.polymodel.actor.getSession#sessionAuthenticated","authenticated":true,"did":"did:plc:alice","handle":"alice.test","profile":{"avatar":null,"description":null,"did":"did:plc:alice","displayName":"Alice Maker","handle":"alice.test","record":null}}"#).unwrap(); 119 + assert_eq!( 120 + SessionIdentity::from_get_session(output).label(), 121 + "Alice Maker" 122 + ); 123 + } 124 + }
+4
src/thing_card.rs
··· 1 1 use dioxus::prelude::*; 2 + #[cfg(test)] 2 3 use jacquard_common::types::{ 3 4 string::{AtUri, Datetime, Did, Handle}, 4 5 uri::UriValue, 5 6 }; 6 7 use polymodel_api::space_polymodel::library; 7 8 9 + #[cfg(test)] 8 10 const COMPLETE_PREVIEW_SVG: &str = "data:image/svg+xml,%3Csvg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 640 480\"%3E%3Crect width=\"640\" height=\"480\" fill=\"%23f4efe4\"/%3E%3Cg stroke=\"%232a6f97\" stroke-width=\"2\" fill=\"none\"%3E%3Cpath d=\"M64 96h512v288H64zM160 144h320v192H160zM256 192h128v96H256z\"/%3E%3Cpath d=\"M64 384 160 336M576 384 480 336M160 144 64 96M480 144 576 96\"/%3E%3C/g%3E%3Cg fill=\"%2366767f\" font-family=\"monospace\" font-size=\"24\"%3E%3Ctext x=\"72\" y=\"438\"%3Eparametric enclosure preview%3C/text%3E%3C/g%3E%3C/svg%3E"; 9 11 10 12 #[derive(Clone, Debug, PartialEq)] ··· 13 15 pub alt: String, 14 16 } 15 17 18 + #[cfg(test)] 16 19 pub fn thing_card_fixtures() -> Vec<library::ThingViewBasic> { 17 20 vec![ 18 21 library::ThingViewBasic { ··· 234 237 }) 235 238 } 236 239 240 + #[cfg(test)] 237 241 fn actor( 238 242 did: &str, 239 243 handle: &str,