learn and share notes on atproto (wip) 🦉 malfestio.stormlightlabs.org/
readability solid axum atproto srs

feat: add tracing to OAuth handlers

* refactor repository initialization

* add just scripts and local dev docs

+501 -90
+1
README.md
··· 7 7 8 8 ## Documentation 9 9 10 + - [Local Development](./docs/local-dev.md) - Setup and testing guide 10 11 - [Personas & Principles](./docs/personas.md) - Target users and design philosophy 11 12 - [Architecture](./docs/architecture.md) - System components and data model 12 13 - [Information Architecture](./docs/information-architecture.md) - Navigation and URL structure
+56 -13
crates/server/src/api/oauth.rs
··· 105 105 pub async fn authorize( 106 106 State(oauth): State<Arc<OAuthState>>, Json(payload): Json<AuthorizeRequest>, 107 107 ) -> impl IntoResponse { 108 + tracing::info!("OAuth authorization request received for handle: {}", payload.handle); 109 + 108 110 let state = generate_state(); 111 + tracing::debug!("Generated state parameter: {}", state); 109 112 110 113 match oauth 111 114 .flow 112 115 .start_authorization(&payload.handle, &state, &oauth.sessions) 113 116 .await 114 117 { 115 - Ok(auth_url) => ( 116 - StatusCode::OK, 117 - Json(AuthorizeResponse { authorization_url: auth_url, state }), 118 - ) 119 - .into_response(), 120 - Err(e) => (StatusCode::BAD_REQUEST, Json(json!({ "error": e.to_string() }))).into_response(), 118 + Ok(auth_url) => { 119 + tracing::info!( 120 + "OAuth authorization started successfully for handle: {}", 121 + payload.handle 122 + ); 123 + ( 124 + StatusCode::OK, 125 + Json(AuthorizeResponse { authorization_url: auth_url, state }), 126 + ) 127 + .into_response() 128 + } 129 + Err(e) => { 130 + tracing::error!("OAuth authorization failed for handle {}: {}", payload.handle, e); 131 + (StatusCode::BAD_REQUEST, Json(json!({ "error": e.to_string() }))).into_response() 132 + } 121 133 } 122 134 } 123 135 ··· 125 137 /// 126 138 /// GET /api/oauth/callback?code=...&state=... 127 139 pub async fn callback(State(oauth): State<Arc<OAuthState>>, Query(params): Query<CallbackQuery>) -> impl IntoResponse { 140 + tracing::info!("OAuth callback received with state: {}", params.state); 141 + 128 142 if let Some(error) = params.error { 129 143 let description = params.error_description.unwrap_or_default(); 144 + tracing::error!("OAuth authorization error: {} - {}", error, description); 130 145 return Redirect::to(&format!( 131 146 "/login?error={}&description={}", 132 147 urlencoding::encode(&error), ··· 135 150 .into_response(); 136 151 } 137 152 153 + tracing::debug!("Retrieving session for state: {}", params.state); 138 154 let session = { 139 155 let sessions = oauth.sessions.read().unwrap(); 140 156 sessions.get(&params.state).cloned() 141 157 }; 142 158 143 159 let session = match session { 144 - Some(s) => s, 160 + Some(s) => { 161 + tracing::debug!("Session found for state: {}", params.state); 162 + s 163 + } 145 164 None => { 165 + tracing::error!("Session not found for state: {}", params.state); 146 166 return Redirect::to("/login?error=session_not_found").into_response(); 147 167 } 148 168 }; ··· 153 173 .await 154 174 { 155 175 Ok(tokens) => { 156 - let did = session.did.unwrap_or_default(); 176 + let did = session.did.clone().unwrap_or_default(); 157 177 let pds_url = session.pds_url.unwrap_or_default(); 158 178 let expires_at = tokens 159 179 .expires_in 160 180 .map(|secs| Utc::now() + Duration::seconds(secs as i64)); 161 181 182 + tracing::info!("Storing tokens for DID: {}", did); 162 183 if let Err(e) = oauth 163 184 .repo 164 185 .store_tokens(StoreTokensRequest { ··· 172 193 }) 173 194 .await 174 195 { 175 - tracing::error!("Failed to store tokens: {}", e); 196 + tracing::error!("Failed to store tokens for DID {}: {}", did, e); 176 197 return Redirect::to(&format!("/login?error={}", urlencoding::encode("token_storage_failed"))) 177 198 .into_response(); 178 199 } 179 200 201 + tracing::info!("OAuth flow completed successfully for DID: {}", did); 180 202 Redirect::to(&format!("/login/success?did={}", urlencoding::encode(&did))).into_response() 181 203 } 182 - Err(e) => Redirect::to(&format!("/login?error={}", urlencoding::encode(&e.to_string()))).into_response(), 204 + Err(e) => { 205 + tracing::error!("Token exchange failed: {}", e); 206 + Redirect::to(&format!("/login?error={}", urlencoding::encode(&e.to_string()))).into_response() 207 + } 183 208 } 184 209 } 185 210 ··· 201 226 /// POST /api/oauth/refresh 202 227 /// Body: { "did": "did:plc:..." } 203 228 pub async fn refresh(State(oauth): State<Arc<OAuthState>>, Json(payload): Json<RefreshRequest>) -> impl IntoResponse { 229 + tracing::info!("Token refresh request for DID: {}", payload.did); 230 + 204 231 // Get stored tokens from database 232 + tracing::debug!("Retrieving stored tokens from database for DID: {}", payload.did); 205 233 let stored = match oauth.repo.get_tokens(&payload.did).await { 206 - Ok(t) => t, 234 + Ok(t) => { 235 + tracing::debug!("Found stored tokens for DID: {}", payload.did); 236 + t 237 + } 207 238 Err(e) => { 239 + tracing::error!("Failed to retrieve stored tokens for DID {}: {}", payload.did, e); 208 240 return (StatusCode::NOT_FOUND, Json(json!({ "error": e.to_string() }))).into_response(); 209 241 } 210 242 }; 211 243 212 244 // Reconstruct DPoP keypair 245 + tracing::debug!("Reconstructing DPoP keypair from stored data"); 213 246 let dpop_keypair = match stored.dpop_keypair() { 214 247 Some(kp) => kp, 215 248 None => { 249 + tracing::error!("Failed to reconstruct DPoP keypair for DID: {}", payload.did); 216 250 return ( 217 251 StatusCode::INTERNAL_SERVER_ERROR, 218 252 Json(json!({ "error": "Invalid stored keypair" })), ··· 225 259 let refresh_token = match &stored.refresh_token { 226 260 Some(rt) => rt.clone(), 227 261 None => { 262 + tracing::error!("No refresh token available for DID: {}", payload.did); 228 263 return ( 229 264 StatusCode::BAD_REQUEST, 230 265 Json(json!({ "error": "No refresh token available" })), ··· 244 279 .expires_in 245 280 .map(|secs| Utc::now() + Duration::seconds(secs as i64)); 246 281 282 + tracing::info!( 283 + "Token refresh successful, updating stored tokens for DID: {}", 284 + payload.did 285 + ); 247 286 if let Err(e) = oauth 248 287 .repo 249 288 .update_tokens( ··· 254 293 ) 255 294 .await 256 295 { 257 - tracing::error!("Failed to update tokens: {}", e); 296 + tracing::error!("Failed to update tokens in database for DID {}: {}", payload.did, e); 258 297 return ( 259 298 StatusCode::INTERNAL_SERVER_ERROR, 260 299 Json(json!({ "error": "Failed to update tokens" })), ··· 262 301 .into_response(); 263 302 } 264 303 304 + tracing::info!("Token refresh completed successfully for DID: {}", payload.did); 265 305 ( 266 306 StatusCode::OK, 267 307 Json(RefreshResponse { success: true, expires_at: expires_at.map(|dt| dt.to_rfc3339()) }), 268 308 ) 269 309 .into_response() 270 310 } 271 - Err(e) => (StatusCode::BAD_REQUEST, Json(json!({ "error": e.to_string() }))).into_response(), 311 + Err(e) => { 312 + tracing::error!("Token refresh failed for DID {}: {}", payload.did, e); 313 + (StatusCode::BAD_REQUEST, Json(json!({ "error": e.to_string() }))).into_response() 314 + } 272 315 } 273 316 } 274 317
+82 -27
crates/server/src/lib.rs
··· 11 11 use axum::http::Method; 12 12 use axum::{ 13 13 Json, Router, 14 + extract::State, 14 15 http::StatusCode, 15 16 middleware as axum_middleware, 16 17 response::{IntoResponse, Response}, 17 18 routing::{get, post}, 18 19 }; 19 - use serde_json::json; 20 + use serde_json::{Value, json}; 20 21 use std::net::SocketAddr; 21 22 use tokio::net::TcpListener; 22 23 use tower_http::cors::{Any, CorsLayer}; ··· 43 44 44 45 tracing::info!("Database connection pool created"); 45 46 46 - let oauth_repo = std::sync::Arc::new(repository::oauth::DbOAuthRepository::new(pool.clone())); 47 - let deck_repo = std::sync::Arc::new(repository::deck::DbDeckRepository::new(pool.clone())); 48 - let card_repo = std::sync::Arc::new(repository::card::DbCardRepository::new(pool.clone())); 49 - let note_repo = std::sync::Arc::new(repository::note::DbNoteRepository::new(pool.clone())); 50 - let prefs_repo = std::sync::Arc::new(repository::preferences::DbPreferencesRepository::new(pool.clone())); 51 - let review_repo = std::sync::Arc::new(repository::review::DbReviewRepository::new(pool.clone())); 52 - let social_repo = std::sync::Arc::new(repository::social::DbSocialRepository::new(pool.clone())); 53 - 54 - let search_repo = std::sync::Arc::new(repository::search::DbSearchRepository::new(pool.clone())); 55 47 let pds_url = std::env::var("PDS_URL").unwrap_or_else(|_| "https://bsky.social".to_string()); 56 48 let config = state::AppConfig { pds_url }; 57 - 58 - let repos = state::Repositories { 59 - oauth: oauth_repo, 60 - deck: deck_repo, 61 - card: card_repo, 62 - note: note_repo, 63 - prefs: prefs_repo, 64 - review: review_repo, 65 - social: social_repo, 66 - search: search_repo, 67 - }; 68 - 49 + let repos = state::Repositories::from(&pool); 69 50 let state = state::AppState::new(pool, repos, config); 70 51 let oauth_state = std::sync::Arc::new(api::oauth::OAuthState::new()); 71 52 ··· 118 99 119 100 let app = Router::new() 120 101 .route("/health", get(health_check)) 102 + .route("/health/ready", get(readiness_check)) 121 103 .route( 122 104 "/.well-known/oauth-client-metadata", 123 105 get(oauth::client_metadata::client_metadata_handler), ··· 146 128 Ok(()) 147 129 } 148 130 131 + /// Basic liveness check - returns 200 if the server is running. 132 + /// 133 + /// For simple uptime monitoring and should always respond quickly without checking external dependencies. 149 134 async fn health_check() -> impl IntoResponse { 150 - Json(json!({ "status": "ok", "version": env!("CARGO_PKG_VERSION") })) 135 + Json(json!({ 136 + "status": "ok", 137 + "service": "malfestio-server", 138 + "version": env!("CARGO_PKG_VERSION") 139 + })) 140 + } 141 + 142 + /// Readiness check - verifies the server can handle requests. 143 + /// 144 + /// Checks database connectivity and other critical dependencies (load balancer health checks and deployment readiness probes). 145 + async fn readiness_check(State(state): State<state::SharedState>) -> (StatusCode, Json<Value>) { 146 + match state.pool.get().await { 147 + Ok(client) => match client.query("SELECT 1", &[]).await { 148 + Ok(_) => ( 149 + StatusCode::OK, 150 + Json(json!({ 151 + "status": "ready", 152 + "service": "malfestio-server", 153 + "version": env!("CARGO_PKG_VERSION"), 154 + "checks": { "database": "ok" } 155 + })), 156 + ), 157 + Err(e) => { 158 + tracing::error!("Readiness check failed: database query error: {}", e); 159 + ( 160 + StatusCode::SERVICE_UNAVAILABLE, 161 + Json(json!({ 162 + "status": "not_ready", 163 + "service": "malfestio-server", 164 + "version": env!("CARGO_PKG_VERSION"), 165 + "checks": { "database": "query_failed" } 166 + })), 167 + ) 168 + } 169 + }, 170 + Err(e) => { 171 + tracing::error!("Readiness check failed: unable to get database connection: {}", e); 172 + ( 173 + StatusCode::SERVICE_UNAVAILABLE, 174 + Json(json!({ 175 + "status": "not_ready", 176 + "service": "malfestio-server", 177 + "version": env!("CARGO_PKG_VERSION"), 178 + "checks": { "database": "connection_failed" } 179 + })), 180 + ) 181 + } 182 + } 151 183 } 152 184 153 185 pub struct AppError(malfestio_core::Error); ··· 160 192 _ => (StatusCode::INTERNAL_SERVER_ERROR, "Internal Server Error".to_string()), 161 193 }; 162 194 163 - let body = Json(json!({ 164 - "error": error_message, 165 - })); 195 + (status, Json(json!({ "error": error_message }))).into_response() 196 + } 197 + } 166 198 167 - (status, body).into_response() 199 + #[cfg(test)] 200 + mod tests { 201 + use super::*; 202 + 203 + #[test] 204 + fn test_health_check_response_format() { 205 + let runtime = tokio::runtime::Runtime::new().unwrap(); 206 + runtime.block_on(async { 207 + let response = health_check().await.into_response(); 208 + assert_eq!(response.status(), StatusCode::OK); 209 + }); 210 + } 211 + 212 + #[test] 213 + fn test_readiness_check_with_unavailable_db() { 214 + let runtime = tokio::runtime::Runtime::new().unwrap(); 215 + runtime.block_on(async { 216 + let pool = db::create_mock_pool(); 217 + let repos = state::Repositories::default(); 218 + let config = state::AppConfig { pds_url: "https://test.example.com".to_string() }; 219 + let app_state = state::AppState::new(pool, repos, config); 220 + let (status, _json) = readiness_check(State(app_state)).await; 221 + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); 222 + }); 168 223 } 169 224 }
+110 -31
crates/server/src/oauth/flow.rs
··· 73 73 pub async fn start_authorization( 74 74 &self, handle_or_did: &str, state: &str, sessions: &SessionStore, 75 75 ) -> Result<String, OAuthFlowError> { 76 + tracing::info!("Starting OAuth authorization for: {}", handle_or_did); 77 + 76 78 let (did, pds_url) = if handle_or_did.starts_with("did:") { 79 + tracing::debug!("Input is a DID, resolving directly: {}", handle_or_did); 77 80 let resolved = self.resolver.resolve_did(handle_or_did).await?; 81 + tracing::info!("DID resolved to PDS: {}", resolved.pds_url); 78 82 (resolved.did, resolved.pds_url) 79 83 } else { 84 + tracing::debug!("Input is a handle, resolving to DID: {}", handle_or_did); 80 85 let did = self.resolver.resolve_handle(handle_or_did).await?; 86 + tracing::info!("Handle resolved to DID: {}", did); 87 + 81 88 let resolved = self.resolver.resolve_did(&did).await?; 89 + tracing::info!("DID resolved to PDS: {}", resolved.pds_url); 82 90 (resolved.did, resolved.pds_url) 83 91 }; 84 92 93 + tracing::debug!("Fetching authorization server metadata from PDS: {}", pds_url); 85 94 let auth_server = self.get_auth_server_metadata(&pds_url).await?; 95 + tracing::info!( 96 + "Authorization server metadata retrieved - issuer: {}, authorization_endpoint: {}", 97 + auth_server.issuer, 98 + auth_server.authorization_endpoint 99 + ); 86 100 101 + tracing::debug!("Generating PKCE code verifier and challenge"); 87 102 let code_verifier = generate_code_verifier(); 88 103 let code_challenge = derive_code_challenge(&code_verifier); 89 104 105 + tracing::debug!("Generating DPoP keypair for session"); 90 106 let dpop_keypair = DpopKeypair::generate(); 91 107 92 108 let session = OAuthSession { 93 109 code_verifier, 94 110 dpop_keypair, 95 111 did: Some(did.clone()), 96 - pds_url: Some(pds_url), 112 + pds_url: Some(pds_url.clone()), 97 113 created_at: std::time::Instant::now(), 98 114 }; 99 115 100 116 sessions.write().unwrap().insert(state.to_string(), session); 117 + tracing::debug!("OAuth session stored with state: {}", state); 101 118 102 119 let auth_url = format!( 103 120 "{}?response_type=code&client_id={}&redirect_uri={}&scope={}&state={}&code_challenge={}&code_challenge_method=S256&login_hint={}", ··· 110 127 urlencoding::encode(&did) 111 128 ); 112 129 130 + tracing::info!( 131 + "Authorization URL generated, redirecting user to: {}", 132 + auth_server.authorization_endpoint 133 + ); 113 134 Ok(auth_url) 114 135 } 115 136 ··· 117 138 pub async fn exchange_code( 118 139 &self, code: &str, state: &str, sessions: &SessionStore, 119 140 ) -> Result<OAuthTokens, OAuthFlowError> { 120 - let session = sessions 121 - .read() 122 - .unwrap() 123 - .get(state) 124 - .cloned() 125 - .ok_or(OAuthFlowError::SessionNotFound)?; 141 + tracing::info!("Exchanging authorization code for tokens"); 142 + 143 + let session = sessions.read().unwrap().get(state).cloned().ok_or_else(|| { 144 + tracing::error!("OAuth session not found for state: {}", state); 145 + OAuthFlowError::SessionNotFound 146 + })?; 147 + 148 + tracing::debug!("Session retrieved, DID: {:?}", session.did); 126 149 127 - let pds_url = session.pds_url.as_ref().ok_or(OAuthFlowError::SessionNotFound)?; 150 + let pds_url = session.pds_url.as_ref().ok_or_else(|| { 151 + tracing::error!("PDS URL missing from session"); 152 + OAuthFlowError::SessionNotFound 153 + })?; 128 154 155 + tracing::debug!("Fetching authorization server metadata for token exchange"); 129 156 let auth_server = self.get_auth_server_metadata(pds_url).await?; 130 157 158 + tracing::debug!( 159 + "Generating DPoP proof for token endpoint: {}", 160 + auth_server.token_endpoint 161 + ); 131 162 let dpop_proof = session 132 163 .dpop_keypair 133 164 .generate_proof("POST", &auth_server.token_endpoint, None); 134 165 166 + tracing::info!("Sending token exchange request to: {}", auth_server.token_endpoint); 135 167 let response = self 136 168 .client 137 169 .post(&auth_server.token_endpoint) ··· 145 177 ]) 146 178 .send() 147 179 .await 148 - .map_err(|e| OAuthFlowError::NetworkError(e.to_string()))?; 180 + .map_err(|e| { 181 + tracing::error!("Network error during token exchange: {}", e); 182 + OAuthFlowError::NetworkError(e.to_string()) 183 + })?; 149 184 150 - if !response.status().is_success() { 185 + let status = response.status(); 186 + if !status.is_success() { 151 187 let error_body = response.text().await.unwrap_or_default(); 188 + tracing::error!("Token exchange failed with status {}: {}", status, error_body); 152 189 return Err(OAuthFlowError::TokenExchangeFailed(error_body)); 153 190 } 154 191 155 - let tokens: OAuthTokens = response 156 - .json() 157 - .await 158 - .map_err(|e| OAuthFlowError::NetworkError(e.to_string()))?; 192 + tracing::debug!("Token exchange successful, parsing response"); 193 + let tokens: OAuthTokens = response.json().await.map_err(|e| { 194 + tracing::error!("Failed to parse token response: {}", e); 195 + OAuthFlowError::NetworkError(e.to_string()) 196 + })?; 159 197 198 + tracing::info!("Tokens received successfully, cleaning up session"); 160 199 sessions.write().unwrap().remove(state); 161 200 201 + tracing::info!("OAuth token exchange completed successfully"); 162 202 Ok(tokens) 163 203 } 164 204 ··· 166 206 pub async fn refresh_token( 167 207 &self, refresh_token: &str, pds_url: &str, dpop_keypair: &DpopKeypair, 168 208 ) -> Result<OAuthTokens, OAuthFlowError> { 209 + tracing::info!("Refreshing access token for PDS: {}", pds_url); 210 + 211 + tracing::debug!("Fetching authorization server metadata for token refresh"); 169 212 let auth_server = self.get_auth_server_metadata(pds_url).await?; 170 213 214 + tracing::debug!( 215 + "Generating DPoP proof for token endpoint: {}", 216 + auth_server.token_endpoint 217 + ); 171 218 let dpop_proof = dpop_keypair.generate_proof("POST", &auth_server.token_endpoint, None); 172 219 220 + tracing::info!("Sending token refresh request to: {}", auth_server.token_endpoint); 173 221 let response = self 174 222 .client 175 223 .post(&auth_server.token_endpoint) ··· 181 229 ]) 182 230 .send() 183 231 .await 184 - .map_err(|e| OAuthFlowError::NetworkError(e.to_string()))?; 232 + .map_err(|e| { 233 + tracing::error!("Network error during token refresh: {}", e); 234 + OAuthFlowError::NetworkError(e.to_string()) 235 + })?; 185 236 186 - if !response.status().is_success() { 237 + let status = response.status(); 238 + if !status.is_success() { 187 239 let error_body = response.text().await.unwrap_or_default(); 240 + tracing::error!("Token refresh failed with status {}: {}", status, error_body); 188 241 return Err(OAuthFlowError::TokenRefreshFailed(error_body)); 189 242 } 190 243 191 - response 192 - .json() 193 - .await 194 - .map_err(|e| OAuthFlowError::NetworkError(e.to_string())) 244 + tracing::debug!("Token refresh successful, parsing response"); 245 + let result = response.json().await.map_err(|e| { 246 + tracing::error!("Failed to parse token refresh response: {}", e); 247 + OAuthFlowError::NetworkError(e.to_string()) 248 + })?; 249 + 250 + tracing::info!("Token refresh completed successfully"); 251 + Ok(result) 195 252 } 196 253 197 254 /// Get authorization server metadata from PDS. 198 255 async fn get_auth_server_metadata(&self, pds_url: &str) -> Result<AuthServerMetadata, OAuthFlowError> { 199 256 // First get the protected resource metadata 200 257 let resource_url = format!("{}/.well-known/oauth-protected-resource", pds_url); 258 + tracing::debug!("Fetching protected resource metadata from: {}", resource_url); 201 259 202 260 let resource_response = self 203 261 .client ··· 205 263 .timeout(std::time::Duration::from_secs(10)) 206 264 .send() 207 265 .await 208 - .map_err(|e| OAuthFlowError::NetworkError(e.to_string()))?; 266 + .map_err(|e| { 267 + tracing::error!("Failed to fetch protected resource metadata: {}", e); 268 + OAuthFlowError::NetworkError(e.to_string()) 269 + })?; 209 270 210 271 if !resource_response.status().is_success() { 272 + tracing::error!( 273 + "Protected resource metadata fetch failed with status: {}", 274 + resource_response.status() 275 + ); 211 276 return Err(OAuthFlowError::MetadataFetchFailed(pds_url.to_string())); 212 277 } 213 278 214 - let resource: serde_json::Value = resource_response 215 - .json() 216 - .await 217 - .map_err(|e| OAuthFlowError::NetworkError(e.to_string()))?; 279 + let resource: serde_json::Value = resource_response.json().await.map_err(|e| { 280 + tracing::error!("Failed to parse protected resource metadata: {}", e); 281 + OAuthFlowError::NetworkError(e.to_string()) 282 + })?; 218 283 219 284 let auth_server_url = resource["authorization_servers"] 220 285 .as_array() 221 286 .and_then(|arr| arr.first()) 222 287 .and_then(|v| v.as_str()) 223 - .ok_or_else(|| OAuthFlowError::MetadataFetchFailed(pds_url.to_string()))?; 288 + .ok_or_else(|| { 289 + tracing::error!("No authorization servers found in protected resource metadata"); 290 + OAuthFlowError::MetadataFetchFailed(pds_url.to_string()) 291 + })?; 292 + 293 + tracing::debug!("Authorization server URL: {}", auth_server_url); 224 294 225 295 let auth_meta_url = format!("{}/.well-known/oauth-authorization-server", auth_server_url); 296 + tracing::debug!("Fetching authorization server metadata from: {}", auth_meta_url); 226 297 227 298 let auth_response = self 228 299 .client ··· 230 301 .timeout(std::time::Duration::from_secs(10)) 231 302 .send() 232 303 .await 233 - .map_err(|e| OAuthFlowError::NetworkError(e.to_string()))?; 304 + .map_err(|e| { 305 + tracing::error!("Failed to fetch authorization server metadata: {}", e); 306 + OAuthFlowError::NetworkError(e.to_string()) 307 + })?; 234 308 235 309 if !auth_response.status().is_success() { 310 + tracing::error!( 311 + "Authorization server metadata fetch failed with status: {}", 312 + auth_response.status() 313 + ); 236 314 return Err(OAuthFlowError::MetadataFetchFailed(auth_server_url.to_string())); 237 315 } 238 316 239 - auth_response 240 - .json() 241 - .await 242 - .map_err(|e| OAuthFlowError::NetworkError(e.to_string())) 317 + tracing::debug!("Authorization server metadata retrieved successfully"); 318 + auth_response.json().await.map_err(|e| { 319 + tracing::error!("Failed to parse authorization server metadata: {}", e); 320 + OAuthFlowError::NetworkError(e.to_string()) 321 + }) 243 322 } 244 323 } 245 324
+42
crates/server/src/state.rs
··· 1 1 use crate::db::DbPool; 2 2 use crate::middleware::auth::UserContext; 3 + use crate::repository; 3 4 use crate::repository::card::CardRepository; 4 5 use crate::repository::deck::DeckRepository; 5 6 use crate::repository::note::NoteRepository; ··· 9 10 use crate::repository::search::SearchRepository; 10 11 use crate::repository::social::SocialRepository; 11 12 13 + use deadpool_postgres::Pool; 12 14 use std::collections::HashMap; 13 15 use std::sync::Arc; 14 16 use std::time::Instant; ··· 35 37 pub review: Arc<dyn ReviewRepository>, 36 38 pub social: Arc<dyn SocialRepository>, 37 39 pub search: Arc<dyn SearchRepository>, 40 + } 41 + 42 + #[cfg(test)] 43 + impl Default for Repositories { 44 + fn default() -> Self { 45 + Self { 46 + oauth: Arc::new(repository::oauth::mock::MockOAuthRepository::new()), 47 + deck: Arc::new(repository::deck::mock::MockDeckRepository::new()), 48 + card: Arc::new(repository::card::mock::MockCardRepository::new()), 49 + note: Arc::new(repository::note::mock::MockNoteRepository::new()), 50 + prefs: Arc::new(repository::preferences::mock::MockPreferencesRepository::new()), 51 + review: Arc::new(repository::review::mock::MockReviewRepository::new()), 52 + social: Arc::new(repository::social::mock::MockSocialRepository::new()), 53 + search: Arc::new(repository::search::mock::MockSearchRepository::new()), 54 + } 55 + } 56 + } 57 + 58 + impl From<&Pool> for Repositories { 59 + fn from(pool: &Pool) -> Self { 60 + let oauth_repo = std::sync::Arc::new(repository::oauth::DbOAuthRepository::new(pool.clone())); 61 + let deck_repo = std::sync::Arc::new(repository::deck::DbDeckRepository::new(pool.clone())); 62 + let card_repo = std::sync::Arc::new(repository::card::DbCardRepository::new(pool.clone())); 63 + let note_repo = std::sync::Arc::new(repository::note::DbNoteRepository::new(pool.clone())); 64 + let prefs_repo = std::sync::Arc::new(repository::preferences::DbPreferencesRepository::new(pool.clone())); 65 + let review_repo = std::sync::Arc::new(repository::review::DbReviewRepository::new(pool.clone())); 66 + let social_repo = std::sync::Arc::new(repository::social::DbSocialRepository::new(pool.clone())); 67 + let search_repo = std::sync::Arc::new(repository::search::DbSearchRepository::new(pool.clone())); 68 + 69 + Self { 70 + oauth: oauth_repo, 71 + deck: deck_repo, 72 + card: card_repo, 73 + note: note_repo, 74 + prefs: prefs_repo, 75 + review: review_repo, 76 + social: social_repo, 77 + search: search_repo, 78 + } 79 + } 38 80 } 39 81 40 82 pub struct AppState {
+1 -2
crates/server/src/well_known.rs
··· 7 7 8 8 /// Handler for `/.well-known/atproto-did`. 9 9 /// 10 - /// Returns the server's DID from the `ATPROTO_SERVER_DID` environment variable. 11 - /// Used for domain verification in AT Protocol. 10 + /// Returns the server's DID from the `ATPROTO_SERVER_DID` environment variable for domain verification in AT Protocol. 12 11 pub async fn atproto_did_handler() -> impl IntoResponse { 13 12 std::env::var("ATPROTO_SERVER_DID").unwrap_or_default() 14 13 }
+114
docs/local-dev.md
··· 1 + # Local Development 2 + 3 + ## Prerequisites 4 + 5 + ### Required Tools 6 + 7 + - Rust (latest stable) 8 + - Node.js 18+ and pnpm 9 + - PostgreSQL 14+ 10 + - Docker (optional, for containerized Postgres) 11 + 12 + ### Bluesky Account Setup 13 + 14 + 1. Create a Bluesky account at <https://bsky.app> 15 + 2. Generate an App Password (Settings → App Passwords) 16 + 3. Configure `.env` with your credentials: 17 + 18 + ```bash 19 + APP_USERNAME=your-handle.bsky.social 20 + APP_PASSWORD=your-app-password-here 21 + DB_URL="postgres://postgres:postgres@localhost:5432/malfestio_dev?sslmode=disable" 22 + ``` 23 + 24 + ## Testing OAuth Flow 25 + 26 + ### Step-by-Step 27 + 28 + 1. **Start PostgreSQL** 29 + 30 + ```bash 31 + # Using Docker 32 + docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=postgres postgres:14 33 + 34 + # Or use your local PostgreSQL installation 35 + ``` 36 + 37 + 2. **Run migrations** 38 + 39 + ```bash 40 + just migrate 41 + ``` 42 + 43 + 3. **Start backend** 44 + 45 + ```bash 46 + just start 47 + ``` 48 + 49 + Server runs on <http://localhost:8080> 50 + 51 + 4. **Start frontend** 52 + 53 + ```bash 54 + just web-dev 55 + ``` 56 + 57 + Frontend runs on <http://localhost:3000> 58 + 59 + 5. **Test OAuth login** 60 + - Navigate to <http://localhost:3000/login> 61 + - Enter your Bluesky handle (e.g., `thunderbot.bsky.social`) 62 + - Authorize the application on bsky.social 63 + - Verify redirect back to app with successful login 64 + 65 + ### OAuth Flow Details 66 + 67 + When you enter a handle like `thunderbot.bsky.social`, the system: 68 + 69 + 1. **Handle Resolution**: DNS TXT lookup at `_atproto.thunderbot.bsky.social` or HTTP `https://thunderbot.bsky.social/.well-known/atproto-did` 70 + 2. **DID Resolution**: Resolved DID (e.g., `did:plc:...`) queries `https://plc.directory` for PDS endpoint 71 + 3. **OAuth Discovery**: `https://bsky.social/.well-known/oauth-authorization-server` fetched for endpoints 72 + 4. **Authorization**: User redirected to PDS authorization page with PKCE challenge 73 + 5. **Token Exchange**: Authorization code exchanged for access/refresh tokens with DPoP binding 74 + 6. **Storage**: Tokens stored in database with encrypted DPoP keypair 75 + 76 + ## Testing Record Publishing 77 + 78 + After successful OAuth login: 79 + 80 + 1. Create a deck or note in the UI 81 + 2. Click "Publish" to publish to your PDS 82 + 3. Check your Bluesky profile at <https://bsky.app> to see the published record 83 + 4. Verify record appears in your AT Protocol repository 84 + 85 + ## Environment Variables 86 + 87 + ### Required 88 + 89 + ```bash 90 + APP_USERNAME=your-handle.bsky.social 91 + APP_PASSWORD=your-app-password 92 + DB_URL="postgres://postgres:postgres@localhost:5432/malfestio_dev?sslmode=disable" 93 + ``` 94 + 95 + ### Optional 96 + 97 + ```bash 98 + # Server configuration 99 + SERVER_HOST=127.0.0.1 100 + SERVER_PORT=8080 101 + 102 + # Frontend proxy 103 + VITE_API_URL=http://localhost:8080 104 + 105 + # Logging 106 + RUST_LOG=info,malfestio_server=debug 107 + ``` 108 + 109 + ## Additional Resources 110 + 111 + - [AT Protocol OAuth Guide](https://docs.bsky.app/blog/oauth-atproto) 112 + - [OAuth Client Implementation](https://docs.bsky.app/docs/advanced-guides/oauth-client) 113 + - [PDS Self-Hosting](https://atproto.com/guides/self-hosting) 114 + - [AT Protocol Specifications](https://atproto.com)
+14 -17
docs/todo.md
··· 42 42 43 43 - [x] OAuth login directly to user's PDS 44 44 - [x] Handle resolution via DNS TXT or `/.well-known/atproto-did` 45 - - <https://malfestio.stormlightlabs.org> 46 45 - [x] DPoP token binding for secure API calls 47 46 47 + **Local Development:** 48 + 49 + - [x] Document local testing with real Bluesky accounts 50 + - [x] Add justfile commands for common dev tasks 51 + - [x] Environment variable configuration guide 52 + - [x] Update health check endpoint for service monitoring 53 + - [x] Add logging for OAuth flow steps 54 + 48 55 **Sync & Conflict Resolution:** 49 56 50 57 - [ ] Bi-directional sync: local drafts → PDS records, PDS records → local cache 51 58 - [ ] Conflict resolution strategy for concurrent edits (last-write-wins or merge UI) 52 59 - [ ] Offline queue for pending publishes 60 + - [ ] Sync status UI indicators 53 61 54 62 **Deep Linking:** 55 63 56 64 - [ ] AT-URI deep linking from external clients 57 65 - [ ] Handle `at://` URL scheme in app 66 + - [ ] Link preview generation for shared content 58 67 59 68 #### Acceptance 60 69 61 - - User can log in with their existing Bluesky/PDS identity. 62 - - Local drafts sync correctly after reconnecting. 63 - 64 - #### Implementation Details 65 - 66 - **Considerations:** 67 - 68 - - Scalability: substantial compute; caching, DB optimization, distributed processing 69 - - Lexicon Validation: validate schemas, ignore invalid records gracefully 70 - - Account State: track latest processed revision per repo; handle deletions 71 - - Bluesky's AppView uses PostgreSQL or ScyllaDB + image proxy + AppView core 72 - 73 - **Identity:** 74 - 75 - - Use `did:web` for simplicity, `did:plc` for long-term stability 76 - - ATProto OAuth is the forward path 70 + - User can log in with existing Bluesky/PDS identity 71 + - OAuth flow works with production bsky.social accounts 72 + - Developers can test locally using real accounts (see [Local Development Guide](./local-dev.md)) 73 + - Local drafts sync correctly after reconnecting 77 74 78 75 ### Milestone M - Reliability, Observability, Launch (v0.1.0) 79 76
+81
justfile
··· 1 + # Malfestio 2 + 3 + # Build all Rust crates 4 + build: 5 + cargo build 6 + 7 + # Build for release 8 + build-release: 9 + cargo build --release 10 + 11 + # Run the server via CLI 12 + start: 13 + cargo run --bin malfestio-cli start 14 + 15 + # Run all tests 16 + test: 17 + cargo test --quiet 18 + 19 + # Check code without building 20 + check: 21 + cargo check 22 + 23 + # Run clippy lints 24 + lint: 25 + cargo clippy --fix --allow-dirty 26 + 27 + # Format code 28 + fmt: 29 + cargo fmt 30 + 31 + # Install frontend dependencies 32 + web-install: 33 + cd web && pnpm install 34 + 35 + # Run development server 36 + web-dev: 37 + cd web && pnpm dev 38 + 39 + # Build frontend for production 40 + web-build: 41 + cd web && pnpm build 42 + 43 + # Run frontend tests 44 + web-test: 45 + cd web && pnpm test 46 + 47 + # Type check frontend 48 + web-check: 49 + cd web && pnpm check 50 + 51 + # Lint frontend 52 + web-lint: 53 + cd web && pnpm lint 54 + 55 + # Start both backend and frontend (in separate terminals recommended) 56 + dev: 57 + @echo "Start backend: just start" 58 + @echo "Start frontend: just web-dev" 59 + 60 + # Run all tests (backend + frontend) 61 + test-all: test web-test 62 + 63 + # Run database migrations 64 + migrate: 65 + cargo run --bin malfestio-cli migrate 66 + 67 + # Setup and test OAuth flow with real Bluesky account 68 + test-oauth: 69 + @echo "Testing OAuth with Bluesky account..." 70 + @echo "1. Ensure PostgreSQL is running" 71 + @echo "2. Running migrations..." 72 + @just migrate 73 + @echo "3. Start backend with: just start" 74 + @echo "4. Start frontend with: just web-dev" 75 + @echo "5. Navigate to http://localhost:3000/login" 76 + @echo "6. Enter your Bluesky handle from .env" 77 + 78 + # Clean build artifacts 79 + clean: 80 + cargo clean 81 + cd web && rm -rf dist node_modules/.vite