diff --git a/.polytoken/subagents/playwright-explorer.md b/.polytoken/subagents/playwright-explorer.md index e3eb578..c79deab 100644 --- a/.polytoken/subagents/playwright-explorer.md +++ b/.polytoken/subagents/playwright-explorer.md @@ -2,7 +2,7 @@ name: playwright-explorer description: Use when exploring websites, proving hypotheses about web application behavior, automating browser interactions, or generating E2E tests - investigates page structure through accessibility snapshots, tests assumptions systematically, and coordinates Playwright MCP tools with tenacity to complete complex multi-step investigations polytoken: - model: codex/gpt-5.4-mini(high) + model: codex/gpt-5.4-mini tools: [file_read, grep, glob, skill, mcp__atlassian, mcp__playwright] undeferred_tools: [file_read, grep, glob] skills_allow: ["frontend-design", "polytoken:investigating-a-codebase"] diff --git a/src/appview/actor.rs b/src/appview/actor.rs index 67fb5c2..93ae5ee 100644 --- a/src/appview/actor.rs +++ b/src/appview/actor.rs @@ -5,7 +5,8 @@ use axum::extract::State; use jacquard::identity::PublicResolver; use jacquard::identity::resolver::IdentityResolver; -use jacquard_axum::oauth::OptionalBrowserOAuthSession; +use jacquard::oauth::client::OAuthSession; +use jacquard_axum::oauth::ExtractOptionalOAuthSession; use jacquard_axum::{ExtractXrpc, XrpcResponse}; use jacquard_common::deps::smol_str::SmolStr; use jacquard_common::types::ident::AtIdentifier; @@ -87,11 +88,20 @@ async fn fetch_bsky_profile( pub(super) async fn get_session( State(state): State, - OptionalBrowserOAuthSession(session): OptionalBrowserOAuthSession< + ExtractOptionalOAuthSession(session): ExtractOptionalOAuthSession< PublicResolver, SqliteAuthStore, >, ) -> AppResult> { + Ok(XrpcResponse( + get_session_output(&state, session.as_ref()).await?, + )) +} + +pub(crate) async fn get_session_output( + state: &AppState, + session: Option<&OAuthSession>, +) -> AppResult { if let Some(session) = session.as_ref() { let t_session = std::time::Instant::now(); let did = session.session_info().await.0; @@ -128,7 +138,7 @@ pub(super) async fn get_session( profile_ms = t_profile.elapsed().as_millis(), "get_session authed timing" ); - return Ok(XrpcResponse(GetSessionOutput { + return Ok(GetSessionOutput { value: SessionView::SessionAuthenticated(Box::new(SessionAuthenticated { authenticated: true, did, @@ -137,7 +147,7 @@ pub(super) async fn get_session( extra_data: None, })), extra_data: None, - })); + }); } Err(error) => { tracing::warn!(error = ?error, did = %did, "session handle resolution failed; reporting unauthenticated"); @@ -148,14 +158,14 @@ pub(super) async fn get_session( Ok(unauthenticated_session()) } -fn unauthenticated_session() -> XrpcResponse { - XrpcResponse(GetSessionOutput { +fn unauthenticated_session() -> GetSessionOutput { + GetSessionOutput { value: SessionView::SessionUnauthenticated(Box::new(SessionUnauthenticated { authenticated: false, extra_data: None, })), extra_data: None, - }) + } } fn minimal_session_profile(did: Did, handle: String) -> AppResult { diff --git a/src/appview/mod.rs b/src/appview/mod.rs index 318f26c..db1eab4 100644 --- a/src/appview/mod.rs +++ b/src/appview/mod.rs @@ -15,6 +15,7 @@ //! forwarded with their real status + body. pub mod error; +pub mod ssr; pub mod state; pub mod views; @@ -106,7 +107,10 @@ pub fn router() -> Router { )) // getSession takes no parameters, so it bypasses ExtractXrpc (which // decodes the query string; a unit-struct request from an empty query is - // rejected by serde_html_form). Identity comes from the session. + // rejected by serde_html_form). It uses Jacquard's API/headless optional + // extractor so the documented x-jacquard-session fallback is confined to + // this session bridge surface; other public read endpoints keep the + // browser-oriented optional extractor semantics. .route( GetSessionRequest::PATH, axum::routing::get(actor::get_session), diff --git a/src/appview/ssr.rs b/src/appview/ssr.rs new file mode 100644 index 0000000..80db766 --- /dev/null +++ b/src/appview/ssr.rs @@ -0,0 +1,70 @@ +//! Server-side bridge for SSR and Dioxus server functions. +//! +//! Normal SSR/server-function reads should extract the inbound OAuth session +//! from the current Axum request and call appview helpers directly in-process. +//! The `x-jacquard-session` utility below is only an escape hatch for the rare +//! server-side outbound XRPC call that cannot be avoided. + +use axum::extract::FromRequestParts; +use dioxus::prelude::{ServerFnError, dioxus_fullstack::FullstackContext}; +use http::HeaderValue; +use jacquard::identity::PublicResolver; +use jacquard::oauth::client::OAuthSession; +use jacquard_axum::oauth::ExtractOptionalOAuthSession; +use jacquard_common::session::SessionKey; +use jacquard_common::xrpc::CallOptions; +use polymodel_api::space_polymodel::actor::get_session::GetSessionOutput; + +use super::actor; +use super::state::AppState; +use crate::oauth::SqliteAuthStore; + +pub type PolymodelOAuthSession = OAuthSession; + +/// Extract the current request's optional OAuth session and resolve the +/// `getSession` appview output without issuing a same-origin XRPC request. +pub async fn get_session_output_from_fullstack_context() -> Result +{ + let context = FullstackContext::current() + .ok_or_else(|| ServerFnError::new("missing Dioxus fullstack request context"))?; + let state = context + .extension::() + .ok_or_else(|| ServerFnError::new("missing Polymodel AppState request extension"))?; + let session = extract_optional_oauth_session(&context, &state).await?; + actor::get_session_output(&state, session.as_ref()) + .await + .map_err(|error| ServerFnError::new(format!("{error:?}"))) +} + +async fn extract_optional_oauth_session( + context: &FullstackContext, + state: &AppState, +) -> Result, ServerFnError> { + let mut parts = context.parts_mut().clone(); + ExtractOptionalOAuthSession::::from_request_parts( + &mut parts, state, + ) + .await + .map(|ExtractOptionalOAuthSession(session)| session) + .map_err(|error| ServerFnError::new(error.to_string())) +} + +/// Build Jacquard call options carrying the encoded session-key header. +/// +/// Use this only when a server-side outbound XRPC hop is unavoidable. Normal +/// SSR/server-function appview reads should stay in-process via the helpers +/// above. +#[allow(dead_code)] +pub fn session_key_call_options( + state: &AppState, + key: &SessionKey, +) -> Result { + let encoded = jacquard_axum::oauth::encode_session_key(key) + .map_err(|error| ServerFnError::new(error.to_string()))?; + let value = + HeaderValue::from_str(&encoded).map_err(|error| ServerFnError::new(error.to_string()))?; + Ok(CallOptions { + extra_headers: vec![(state.oauth_config.session_header.clone(), value)], + ..CallOptions::default() + }) +} diff --git a/src/appview/tests.rs b/src/appview/tests.rs index a0a6e34..fc02a72 100644 --- a/src/appview/tests.rs +++ b/src/appview/tests.rs @@ -10,8 +10,20 @@ use axum::body::Body; use axum::http::{Request, StatusCode}; +use dioxus::prelude::dioxus_fullstack::FullstackContext; +use jacquard::oauth::authstore::ClientAuthStore; +use jacquard::oauth::client::OAuthSession; +use jacquard::oauth::keyset::Keyset; +use jacquard::oauth::scopes::Scopes; +use jacquard::oauth::session::{ClientSessionData, DpopClientData}; +use jacquard::oauth::types::{OAuthTokenType, TokenSet}; +use jacquard_common::deps::fluent_uri::Uri; +use jacquard_common::deps::smol_str::SmolStr; +use jacquard_common::session::SessionKey; +use jacquard_common::types::datetime::Datetime; use jacquard_common::types::string::Did; use polymodel_api::space_polymodel::actor::ProfileViewUnion; +use polymodel_api::space_polymodel::actor::get_session::{GetSessionOutput, SessionView}; use serde_json::json; use sqlx::SqlitePool; use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; @@ -30,6 +42,55 @@ fn did(s: &str) -> Did { Did::new_owned(s).unwrap() } +fn client_session(account_did: &str, session_id: &str) -> ClientSessionData { + let dpop_key = Keyset::generate_es256("test") + .unwrap() + .public_jwks() + .keys + .into_iter() + .next() + .unwrap() + .key; + ClientSessionData { + account_did: did(account_did), + session_id: SmolStr::new(session_id), + host_url: Uri::parse("https://pds.example.com").unwrap().to_owned(), + authserver_url: SmolStr::new("https://issuer.example.com"), + authserver_token_endpoint: SmolStr::new("https://issuer.example.com/token"), + authserver_revocation_endpoint: None, + scopes: Scopes::empty(), + dpop_data: DpopClientData { + dpop_key, + dpop_authserver_nonce: SmolStr::default(), + dpop_host_nonce: SmolStr::default(), + }, + token_set: TokenSet { + iss: SmolStr::new("https://issuer.example.com"), + sub: did(account_did), + aud: SmolStr::new("https://pds.example.com"), + scope: None, + refresh_token: None, + access_token: SmolStr::new("token"), + token_type: OAuthTokenType::DPoP, + expires_at: Some(Datetime::raw_str("2099-01-01T00:00:00.000Z")), + }, + resolved_scopes: None, + } +} + +async fn seed_oauth_session(state: &AppState, account_did: &str, session_id: &str) -> SessionKey { + let data = client_session(account_did, session_id); + let key = SessionKey::new(data.account_did.clone(), data.session_id.clone()); + state + .oauth + .registry + .store + .upsert_session(data) + .await + .unwrap(); + key +} + async fn pool() -> SqlitePool { let options = SqliteConnectOptions::from_str("sqlite::memory:").unwrap(); let pool = SqlitePoolOptions::new() @@ -660,6 +721,97 @@ async fn get_session_route_returns_unauthenticated_marker() { assert!(body.contains("\"authenticated\":false"), "body was: {body}"); } +#[tokio::test] +async fn get_session_in_process_helper_returns_authenticated_viewer() { + let state = state().await; + seed_identity(&state.pool, DID_A, "alice.com").await; + seed_profile(&state.pool, DID_A, "Alice").await; + let session_data = client_session(DID_A, "in-process-session"); + let session = OAuthSession::new( + state.oauth.registry.clone(), + state.oauth.client.clone(), + session_data, + ); + + let output = super::actor::get_session_output(&state, Some(&session)) + .await + .unwrap(); + + match output.value { + SessionView::SessionAuthenticated(auth) => { + assert!(auth.authenticated); + assert_eq!(auth.did, did(DID_A)); + assert_eq!(auth.profile.display_name.as_deref(), Some("Alice")); + } + other => panic!("expected authenticated session, got {other:?}"), + } +} + +#[tokio::test] +async fn get_session_route_accepts_x_jacquard_session_header() { + let state = state().await; + seed_identity(&state.pool, DID_A, "alice.com").await; + seed_profile(&state.pool, DID_A, "Alice").await; + let key = seed_oauth_session(&state, DID_A, "header-session").await; + let encoded = jacquard_axum::oauth::encode_session_key(&key).unwrap(); + let header_name = state.oauth_config.session_header.clone(); + let app = super::router().with_state(state); + + let resp = app + .oneshot( + Request::builder() + .uri("/xrpc/space.polymodel.actor.getSession") + .header(header_name, encoded) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = body_text(resp).await; + let output: GetSessionOutput = serde_json::from_str(&body).unwrap(); + match output.value { + SessionView::SessionAuthenticated(auth) => { + assert!(auth.authenticated); + assert_eq!(auth.did, did(DID_A)); + } + other => panic!("expected authenticated session from header, got {other:?}"), + } +} + +#[tokio::test] +async fn fullstack_context_bridge_returns_authenticated_session_from_header() { + let state = state().await; + seed_identity(&state.pool, DID_A, "alice.com").await; + seed_profile(&state.pool, DID_A, "Alice").await; + let key = seed_oauth_session(&state, DID_A, "fullstack-header-session").await; + let encoded = jacquard_axum::oauth::encode_session_key(&key).unwrap(); + let header_name = state.oauth_config.session_header.clone(); + + let mut parts = Request::builder() + .uri("/") + .header(header_name, encoded) + .body(()) + .unwrap() + .into_parts() + .0; + parts.extensions.insert(state); + + let output = FullstackContext::new(parts) + .scope(super::ssr::get_session_output_from_fullstack_context()) + .await + .unwrap(); + + match output.value { + SessionView::SessionAuthenticated(auth) => { + assert!(auth.authenticated); + assert_eq!(auth.did, did(DID_A)); + } + other => panic!("expected authenticated session from fullstack bridge, got {other:?}"), + } +} + #[tokio::test] async fn get_thing_route_decodes_query_and_404s_when_missing() { let state = state().await; diff --git a/src/main.rs b/src/main.rs index 2736f30..e4bae2b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ +use dioxus::prelude::ServerFnError; use dioxus::prelude::*; use jacquard_common::xrpc::XrpcClient; -use polymodel_api::space_polymodel::actor::get_session::GetSession; +use polymodel_api::space_polymodel::actor::get_session::{GetSession, GetSessionOutput}; mod auth; mod browse; @@ -84,15 +85,17 @@ fn main() { .expect("failed to bootstrap OAuth client"); // The same SQLite pool backs both indexing and the appview read handlers. let app_state = appview::state::AppState::new(db, oauth); - Ok(dioxus::server::router(App).merge( - appview::router() - .merge(jacquard_axum::oauth::routes::< - jacquard::identity::PublicResolver, - oauth::SqliteAuthStore, - appview::state::AppState, - >(&app_state.oauth_config.clone())) - .with_state(app_state), - )) + Ok(dioxus::server::router(App) + .layer(axum::Extension(app_state.clone())) + .merge( + appview::router() + .merge(jacquard_axum::oauth::routes::< + jacquard::identity::PublicResolver, + oauth::SqliteAuthStore, + appview::state::AppState, + >(&app_state.oauth_config.clone())) + .with_state(app_state), + )) }); } @@ -133,13 +136,22 @@ pub(crate) enum Route { #[component] fn App() -> Element { let client = use_context_provider(PolymodelClient::default); - let session = use_context_provider(|| Signal::new(SessionIdentity::Anonymous)); + let mut session = use_context_provider(|| Signal::new(SessionIdentity::Anonymous)); let toasts = use_context_provider(|| Signal::new(auth::ToastState::default())); + let ssr_session_seed = use_server_future(server_session_seed)?; + if let Some(Ok(output)) = ssr_session_seed() { + session.set(SessionIdentity::from_get_session(output)); + } + let _session_seed = use_resource(move || { let client = client.clone(); let mut session = session; async move { + #[cfg(feature = "server")] + if dioxus::prelude::dioxus_fullstack::FullstackContext::current().is_some() { + return; + } match client.send(GetSession).await { Ok(response) => match response.into_output() { Ok(output) => session.set(SessionIdentity::from_get_session(output)), @@ -168,3 +180,8 @@ fn App() -> Element { ToastShelf { toasts } } } + +#[server] +async fn server_session_seed() -> Result { + appview::ssr::get_session_output_from_fullstack_context().await +}